[골짜기] 박공→처마 변환 시 골짜기 roofLine 보존 + R8 힙·수평 roofLine 생성

- gableWallLines 필터 + cleanupDangling에서 lineName='roofLine' 제외 — 골짜기 외곽선 삭제 방지
- snapRoofLineCorner: 出幅 이동 코너에 맞춰 인접 세로 roofLine(L-1/L-3) 끝점 갱신
- 변환변 수평 roofLine(L-8) 신규 생성(중복 가드 포함)
- R8: 인접 처마 코너에서 > 완성 힙 생성
- QPolygon 라벨 체계 개편(L/H/W/R/HE/B) + classifyLineForLabel 공개화
- isHip에 isDiagonalHip 가드 — 축정렬 hip 오분류 방지
- drawRoofLine에 lineName='roofLine' 부여 — purge 제외·라벨 L 보장

Co-Authored-By: Claude Opus 4 <noreply@anthropic.com>
This commit is contained in:
ysCha 2026-06-25 14:04:32 +09:00
parent db7ea8c676
commit e79ee3dc52
5 changed files with 718 additions and 92 deletions

View File

@ -32,24 +32,97 @@ function __isDebugLabelsEnabled() {
return process.env.NEXT_PUBLIC_RUN_MODE === 'local' return process.env.NEXT_PUBLIC_RUN_MODE === 'local'
} }
function __classifyLineForLabel(obj) { // [ROOF-BOUNDARY 2026-06-24] roof 폴리곤(roof.points) 절대좌표 경계 edge 목록.
// SK 빌드 결과 roofLine 경계 위 세그먼트가 name='hip' 으로 남는 경우가 있어
// (outerLine 객체는 parentId=null 이라 per-roof 라벨러에서 빠짐) 경계 hip 을 L 로 보정하기 위함.
function __roofBoundaryEdges(canvas, parentId) {
if (!canvas || !parentId) return null
const roof = canvas.getObjects().find((o) => o.name === POLYGON_TYPE.ROOF && o.id === parentId)
if (!roof || !Array.isArray(roof.points) || roof.points.length < 2) return null
const m = roof.calcTransformMatrix()
const ox = roof.pathOffset?.x ?? 0
const oy = roof.pathOffset?.y ?? 0
const abs = roof.points.map((p) => fabric.util.transformPoint({ x: p.x - ox, y: p.y - oy }, m))
const edges = []
for (let i = 0; i < abs.length; i++) {
const a = abs[i]
const b = abs[(i + 1) % abs.length]
edges.push([a.x, a.y, b.x, b.y])
}
return edges
}
// 세그먼트(obj.x1..y2)가 경계 edge 중 하나 위에 (양 끝점 모두) 놓이는지.
function __segOnBoundaryEdge(obj, edges) {
if (!edges || typeof obj.x1 !== 'number' || typeof obj.x2 !== 'number') return false
const pts = [
[obj.x1, obj.y1],
[obj.x2, obj.y2],
]
for (const [bx1, by1, bx2, by2] of edges) {
const dx = bx2 - bx1
const dy = by2 - by1
const len2 = dx * dx + dy * dy
if (!len2) continue
let allOn = true
for (const [px, py] of pts) {
const t = ((px - bx1) * dx + (py - by1) * dy) / len2
const prx = bx1 + t * dx
const pry = by1 + t * dy
if (!(Math.hypot(px - prx, py - pry) <= 1.5 && t >= -0.05 && t <= 1.05)) {
allOn = false
break
}
}
if (allOn) return true
}
return false
}
// [LABEL-SCHEME 2026-06-24] 사용자 확정 라벨 체계:
// roofLine→L, hip→H, wallbaseLine→W, 마루(ridge)→R, 골짜기박스→B,
// 확장라인 = 기본+E (마루확장→RE, 힙확장→HE).
// 순서 주의: 확장/박스 lineName 은 기본 name(hip/ridge)도 함께 가지므로 *먼저* 판정.
function __classifyLineForLabel(obj, boundaryEdges) {
const name = obj.name const name = obj.name
const lineName = obj.lineName const lineName = obj.lineName
const type = obj.attributes?.type const type = obj.attributes?.type
if (name === 'baseLine') return 'B' // 골짜기박스 (B)
if (name === 'outerLine' || name === 'eaves' || lineName === 'roofLine') return 'R' if (lineName === 'kerabValleyOverlapLine' || type === 'kerabValleyOverlapLine') return 'B'
if (name === LINE_TYPE.SUBLINE.HIP || lineName === LINE_TYPE.SUBLINE.HIP) return 'H' // 확장라인 (기본 + E)
if (name === LINE_TYPE.SUBLINE.RIDGE || lineName === LINE_TYPE.SUBLINE.RIDGE) return 'RG' if (lineName === 'kerabPatternExtRidge' || lineName === 'kerabPatternValleyExt') return 'RE'
if (lineName === 'kerabPatternHip') return 'HE'
// 기본 라인
if (name === 'baseLine') return 'W'
// drawRoofLine(박공 처마/골짜기 same-dir roofLine)은 name='hip' 이지만 lineName='roofLine' → 여기서 L 로 분류.
if (name === 'outerLine' || name === 'eaves' || lineName === 'roofLine') return 'L'
// SK 빌드에서 roofLine 경계 위에 남은 hip 세그먼트는 실제 roofLine → L 로 보정.
if (name === LINE_TYPE.SUBLINE.HIP || lineName === LINE_TYPE.SUBLINE.HIP) {
if (__segOnBoundaryEdge(obj, boundaryEdges)) return 'L'
return 'H'
}
if (name === LINE_TYPE.SUBLINE.RIDGE || lineName === LINE_TYPE.SUBLINE.RIDGE) return 'R'
if (name === LINE_TYPE.SUBLINE.VALLEY || lineName === LINE_TYPE.SUBLINE.VALLEY) return 'V' 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.GABLE || lineName === LINE_TYPE.SUBLINE.GABLE) return 'G'
if (name === LINE_TYPE.SUBLINE.VERGE || lineName === LINE_TYPE.SUBLINE.VERGE) return 'VG' 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) if (type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE)
return 'E' return 'EH'
if (typeof obj.x1 === 'number' && typeof obj.y1 === 'number' && typeof obj.x2 === 'number' && typeof obj.y2 === 'number') return 'SK' if (typeof obj.x1 === 'number' && typeof obj.y1 === 'number' && typeof obj.x2 === 'number' && typeof obj.y2 === 'number') return 'SK'
return null return null
} }
// 캔버스 라벨과 로그 라벨이 어긋나지 않도록 분류기를 공개 — 훅에서 동일 함수 사용.
// boundaryEdges: getRoofBoundaryEdges(canvas, roof.id) 결과. 경계 hip→L 보정에 필요.
export function classifyLineForLabel(obj, boundaryEdges) {
return __classifyLineForLabel(obj, boundaryEdges)
}
// 경계 hip→L 보정을 위해 roof 경계 edge 를 외부(훅 로그)에서도 동일하게 계산하도록 공개.
export function getRoofBoundaryEdges(canvas, parentId) {
return __roofBoundaryEdges(canvas, parentId)
}
export function reattachDebugLabels(canvas, parentId) { export function reattachDebugLabels(canvas, parentId) {
__attachDebugLabels(canvas, parentId) __attachDebugLabels(canvas, parentId)
} }
@ -66,10 +139,11 @@ function __attachDebugLabels(canvas, parentId) {
.forEach((o) => canvas.remove(o)) .forEach((o) => canvas.remove(o))
const counters = {} const counters = {}
const boundaryEdges = __roofBoundaryEdges(canvas, parentId)
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)
objects.forEach((obj) => { objects.forEach((obj) => {
const prefix = __classifyLineForLabel(obj) const prefix = __classifyLineForLabel(obj, boundaryEdges)
if (!prefix) return if (!prefix) return
counters[prefix] = (counters[prefix] || 0) + 1 counters[prefix] = (counters[prefix] || 0) + 1

View File

@ -14,7 +14,7 @@ import { settingModalFirstOptionsState } from '@/store/settingAtom'
import { useUndoRedo } from '@/hooks/useUndoRedo' import { useUndoRedo } from '@/hooks/useUndoRedo'
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 { reattachDebugLabels, classifyLineForLabel, getRoofBoundaryEdges } 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'
@ -763,32 +763,10 @@ export function useEavesGableEdit(id) {
const labelByLine = new Map() const labelByLine = new Map()
{ {
const counters = {} const counters = {}
const boundaryEdges = getRoofBoundaryEdges(canvas, roof.id)
const objs = canvas.getObjects().filter((o) => o.parentId === roof.id && o.name !== '__debugLabel') const objs = canvas.getObjects().filter((o) => o.parentId === roof.id && o.name !== '__debugLabel')
for (const obj of objs) { for (const obj of objs) {
let prefix = null const prefix = classifyLineForLabel(obj, boundaryEdges)
const nm = obj.name
const ln = obj.lineName
const tp = obj.attributes?.type
if (nm === 'baseLine') prefix = 'B'
else if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') prefix = 'R'
else if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) prefix = 'H'
else if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) prefix = 'RG'
else if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) prefix = 'V'
else if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) prefix = 'G'
else if (nm === LINE_TYPE.SUBLINE.VERGE || ln === LINE_TYPE.SUBLINE.VERGE) prefix = 'VG'
else if (
tp === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
ln === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
nm === LINE_TYPE.WALLLINE.EAVE_HELP_LINE
)
prefix = 'E'
else if (
typeof obj.x1 === 'number' &&
typeof obj.y1 === 'number' &&
typeof obj.x2 === 'number' &&
typeof obj.y2 === 'number'
)
prefix = 'SK'
if (!prefix) continue if (!prefix) continue
counters[prefix] = (counters[prefix] || 0) + 1 counters[prefix] = (counters[prefix] || 0) + 1
labelByLine.set(obj, `${prefix}-${counters[prefix]}`) labelByLine.set(obj, `${prefix}-${counters[prefix]}`)
@ -2762,10 +2740,22 @@ export function useEavesGableEdit(id) {
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes) extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
// [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마가 되어 완전 寄棟이면, // [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마가 되어 완전 寄棟이면,
// 세로 apex 2개·X자 교차를 버리고 힙·마루 규칙(R1/R2/R3)으로 가로 마루 + 절삭 힙 4개로 재구성. // 세로 apex 2개·X자 교차를 버리고 힙·마루 규칙(R1/R2/R3)으로 가로 마루 + 절삭 힙 4개로 재구성.
reconstructHipRoofRidgeIfComplete(roof) const _hipRoofDone = reconstructHipRoofRidgeIfComplete(roof)
// [KERAB-TYPE-EAVES-HIP-CROSS-CLIP 2026-06-24 Step1] 완전우진각 재구성이 안 된(상단 박공 잔존 등)
// 부분 변환에서, 신규 힙이 기존 대각 힙과 *박스 밖*에서 교차하면 그 교점에서 절삭한다.
// 체크함수(R-CROSS)와 동일한 segCross 내부교차 + 박스(겹침=상대 roofLine) 제외 판정을 재사용 →
// 박스가 막는 노치측 교차(HE-2/HE-3)는 자연히 빠지고 박스 밖 교점 하나만 절삭된다. 마루 생성은 후속.
if (!_hipRoofDone) clipCrossedHipsAtIntersection(roof)
// [KERAB-RULE-CHECK 2026-06-12] 모든 라인변경(재구성 포함)이 끝난 최종 형상을 규칙으로 검증. // [KERAB-RULE-CHECK 2026-06-12] 모든 라인변경(재구성 포함)이 끝난 최종 형상을 규칙으로 검증.
// surgicalAfter 내부 검사는 재구성 전 상태라, 최종 상태는 여기서 한 번 더 본다. // surgicalAfter 내부 검사는 재구성 전 상태라, 최종 상태는 여기서 한 번 더 본다.
runKerabRuleCheck(roof, 'type-eaves', kerabRevertBeforeSnap) runKerabRuleCheck(roof, 'type-eaves', kerabRevertBeforeSnap)
// [KERAB-LABEL-REATTACH 2026-06-24] 라인변경으로 새로 생성된 힙(HE)·마루(R)에도 캔버스 라벨
// 부여(사용자 확정). 이 분기는 dumpInnerLineSnapshot('AFTER') 경로를 안 타므로 직접 호출.
try {
reattachDebugLabels(canvas, roof.id)
} catch (e) {
logger.warn('[KERAB-LABEL-REATTACH] type-eaves failed', e)
}
} }
logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx })) logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx }))
if (apexRes) return if (apexRes) return
@ -2775,7 +2765,15 @@ export function useEavesGableEdit(id) {
const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid) const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid)
if (ok) surgicalAfter() if (ok) surgicalAfter()
logger.log('[KERAB-REVERT] applied ' + JSON.stringify({ ok, c1, c2, apex: ridgeAtMid.apex })) logger.log('[KERAB-REVERT] applied ' + JSON.stringify({ ok, c1, c2, apex: ridgeAtMid.apex }))
if (ok) return if (ok) {
// [KERAB-LABEL-REATTACH 2026-06-24] 케라바→처마 revert 로 생성/변경된 힙·마루에도 라벨 부여.
try {
reattachDebugLabels(canvas, roof.id)
} catch (e) {
logger.warn('[KERAB-LABEL-REATTACH] revert failed', e)
}
return
}
} }
// [KERAB-VALLEY-EAVES-RULE1 2026-06-16] A/B타입 native 케라바 변(스냅샷·native마루·kLine 전부 없음)의 // [KERAB-VALLEY-EAVES-RULE1 2026-06-16] A/B타입 native 케라바 변(스냅샷·native마루·kLine 전부 없음)의
// 첫 처마 변환 "씨앗". 양 코너에서 폴리곤 안쪽 45° 힙 2개를 그려 각자 첫 교차(다른 inner line 또는 // 첫 처마 변환 "씨앗". 양 코너에서 폴리곤 안쪽 45° 힙 2개를 그려 각자 첫 교차(다른 inner line 또는
@ -2818,9 +2816,15 @@ export function useEavesGableEdit(id) {
const tB = { x: target.x2, y: target.y2 } const tB = { x: target.x2, y: target.y2 }
// 게이블벽(HIP)만 직접 제거. native 마루(RIDGE)는 여기서 보존하되, anchor 가 끊겨 dangling 이 // 게이블벽(HIP)만 직접 제거. native 마루(RIDGE)는 여기서 보존하되, anchor 가 끊겨 dangling 이
// 되는 ridge(RG-2)는 뒤의 cleanupDangling 이 연쇄 제거한다(사용자 확정 2026-06-16). // 되는 ridge(RG-2)는 뒤의 cleanupDangling 이 연쇄 제거한다(사용자 확정 2026-06-16).
// [KERAB-VALLEY-EAVES-ROOFLINE-KEEP 2026-06-24] 골짜기 라인을 lineName:'roofLine' 으로 마킹(A/B타입 설정).
// name='hip' 이라 collinear 게이블벽으로 휩쓸려 삭제되면 외곽 roofLine 이 사라진다 → lineName==='roofLine' 은 삭제 제외.
const gableWallLines = (roof.innerLines || []).filter( const gableWallLines = (roof.innerLines || []).filter(
(l) => (l) =>
l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.HIP && (isCollinearWith(l, c1, c2) || isCollinearWith(l, tA, tB)), l &&
l.visible !== false &&
l.name === LINE_TYPE.SUBLINE.HIP &&
l.lineName !== 'roofLine' &&
(isCollinearWith(l, c1, c2) || isCollinearWith(l, tA, tB)),
) )
if (edgeIdx >= 0 && gableWallLines.length > 0) { if (edgeIdx >= 0 && gableWallLines.length > 0) {
target.set({ attributes }) target.set({ attributes })
@ -2841,6 +2845,31 @@ export function useEavesGableEdit(id) {
const idxB = newC1IsA ? (edgeIdx + 1) % NP : edgeIdx const idxB = newC1IsA ? (edgeIdx + 1) % NP : edgeIdx
const newC1 = pts[idxA] const newC1 = pts[idxA]
const newC2 = pts[idxB] const newC2 = pts[idxB]
// [KERAB-VALLEY-EAVES-ROOFLINE-SNAP 2026-06-25] 出幅으로 코너가 이동(c1→newC1, c2→newC2)했는데
// surgical(skipInnerLines) 이 boundary roofLine innerLine 은 안 건드려, 인접 세로 roofLine(L-1/L-3)의
// 코너쪽 끝점이 옛 위치(또는 wallLine 까지 overshoot)에 stale 로 남는다
// (사용자: "L1 끝점이 w6 라인에 가있고", "L3 도 출폭만큼 길어져야"). → 옛 코너 근처 끝점을 새 코너로 스냅.
// SNAP_TOL 은 wallLine overshoot(출폭 크기) 흡수용 — 반대편 진짜 코너(보통 수백 단위)는 안 잡히게 넉넉히.
const snapRoofLineCorner = (oldC, newC) => {
const SNAP_TOL = 80
for (const l of roof.innerLines || []) {
if (!l || l.visible === false || l.lineName !== 'roofLine') continue
const d1 = Math.hypot(l.x1 - oldC.x, l.y1 - oldC.y)
const d2 = Math.hypot(l.x2 - oldC.x, l.y2 - oldC.y)
if (Math.min(d1, d2) > SNAP_TOL) continue
if (d1 <= d2) l.set({ x1: newC.x, y1: newC.y })
else l.set({ x2: newC.x, y2: newC.y })
const sz = calcLinePlaneSize({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 })
l.attributes = { ...(l.attributes || {}), planeSize: sz, actualSize: sz }
l.startPoint = { x: l.x1, y: l.y1 }
l.endPoint = { x: l.x2, y: l.y2 }
l.setCoords?.()
l.setLength?.()
l.addLengthText?.()
}
}
snapRoofLineCorner(c1, newC1)
snapRoofLineCorner(c2, newC2)
// 폴리곤 중심 (inward 판정용). // 폴리곤 중심 (inward 판정용).
let cenX = 0 let cenX = 0
let cenY = 0 let cenY = 0
@ -2984,8 +3013,11 @@ export function useEavesGableEdit(id) {
ends.push({ x: l.x2, y: l.y2, line: l }) ends.push({ x: l.x2, y: l.y2, line: l })
} }
// 마루(RIDGE)·VALLEY 는 dangling 이어도 제거 금지 — HIP 만 제거 후보. // 마루(RIDGE)·VALLEY 는 dangling 이어도 제거 금지 — HIP 만 제거 후보.
// [KERAB-VALLEY-EAVES-ROOFLINE-KEEP 2026-06-24] lineName:'roofLine'(name='hip') 은 외곽 골짜기 라인 —
// dangling 이어도 제거 금지(삭제 시 화면에서 골짜기 라인 사라짐).
const dangler = surv.find((l) => { const dangler = surv.find((l) => {
if (l.name !== LINE_TYPE.SUBLINE.HIP) return false if (l.name !== LINE_TYPE.SUBLINE.HIP) return false
if (l.lineName === 'roofLine') return false
for (const p of [ for (const p of [
{ x: l.x1, y: l.y1 }, { x: l.x1, y: l.y1 },
{ x: l.x2, y: l.y2 }, { x: l.x2, y: l.y2 },
@ -3007,6 +3039,50 @@ export function useEavesGableEdit(id) {
const cleaned = cleanupDangling() const cleaned = cleanupDangling()
const resA = addHipFrom(newC1, idxA) const resA = addHipFrom(newC1, idxA)
const resB = addHipFrom(newC2, idxB) const resB = addHipFrom(newC2, idxB)
// [KERAB-VALLEY-EAVES-R8 2026-06-24] R8: 골짜기 변(케라바) 양 코너에 인접한 처마(일반라인) 코너에서도 힙 생성 → > 완성.
// CCW 기준 idxA 쪽 prev(idxA-1), idxB 쪽 next(idxB+1) 각각 시도.
// hit 가 innerLine(HIP/RIDGE) 일 때만 유효 — roofLine 직격은 제거.
;(() => {
const adjCandidates = [
(idxA - 1 + NP) % NP,
(idxB + 1) % NP,
].filter((ai, _, arr) => ai !== idxA && ai !== idxB && arr.indexOf(ai) === arr.lastIndexOf(ai))
for (const ai of adjCandidates) {
const r = addHipFrom(pts[ai], ai)
if (!r.hip) continue
if (!r.hit || r.hit.kind === 'roofLine') {
canvas.remove(r.hip)
roof.innerLines = roof.innerLines.filter((l) => l !== r.hip)
}
}
})()
// [KERAB-VALLEY-EAVES-ROOFLINE-H 2026-06-25] 변환된 변(=처마)의 수평 roofLine 생성.
// surgical 은 skipInnerLines 로 boundary innerLine 을 안 만들어 세로 roofLine 만 남고 가로가 빈다
// (사용자: "세로만 나오면 어떻게하니.. 가로도 나와야지"). newC1↔newC2 구간에 collinear roofLine 이
// 이미 있으면(중복) 건너뛰고, 없을 때만 drawRoofLine 패턴(strokeWidth:2, lineName='roofLine')으로 1개 생성.
;(() => {
const exists = (roof.innerLines || []).some(
(l) => l && l.visible !== false && l.lineName === 'roofLine' && isCollinearWith(l, newC1, newC2),
)
if (exists) return
const arr = [newC2.x, newC2.y, newC1.x, newC1.y]
const sz = calcLinePlaneSize({ x1: arr[0], y1: arr[1], x2: arr[2], y2: arr[3] })
const rl = new QLine(arr, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 2,
name: LINE_TYPE.SUBLINE.HIP,
textMode: roof.textMode,
attributes: { roofId: roof.id, planeSize: sz, actualSize: sz },
})
rl.lineName = 'roofLine'
rl.startPoint = { x: rl.x1, y: rl.y1 }
rl.endPoint = { x: rl.x2, y: rl.y2 }
canvas.add(rl)
rl.bringToFront()
roof.innerLines.push(rl)
})()
// 5b) 마루(RIDGE) 끝점 상호작용 — anchor 잃은 끝점 해소(사용자 확정 2026-06-16: "RG1(h8) 확장, RG2(b5) 절삭"). // 5b) 마루(RIDGE) 끝점 상호작용 — anchor 잃은 끝점 해소(사용자 확정 2026-06-16: "RG1(h8) 확장, RG2(b5) 절삭").
// 규칙: 마루는 절대 삭제 안 함. anchor 잃은(=경계 미접촉 + 다른 선과 미공유) 끝점은 // 규칙: 마루는 절대 삭제 안 함. anchor 잃은(=경계 미접촉 + 다른 선과 미공유) 끝점은
// · 새 골짜기 힙이 그 마루를 가로지르면 → 교점에서 절삭(trim, 끝점을 교점으로 당김) // · 새 골짜기 힙이 그 마루를 가로지르면 → 교점에서 절삭(trim, 끝점을 교점으로 당김)
@ -3500,24 +3576,12 @@ export function useEavesGableEdit(id) {
// [KERAB-LABEL-MAP 2026-06-22] 화면 라벨(H-n/RG-n/B-n…)↔좌표 맵 — QPolygon.__attachDebugLabels 와 // [KERAB-LABEL-MAP 2026-06-22] 화면 라벨(H-n/RG-n/B-n…)↔좌표 맵 — QPolygon.__attachDebugLabels 와
// *동일 분류·동일 카운터 순서*(canvas 오브젝트 순서)로 재현. 좌표 대신 라벨로 대화하기 위함(local 전용). // *동일 분류·동일 카운터 순서*(canvas 오브젝트 순서)로 재현. 좌표 대신 라벨로 대화하기 위함(local 전용).
try { try {
const classify = (o) => {
const nm = o.name
const ln = o.lineName
const ty = o.attributes?.type
if (nm === 'baseLine') return 'B'
if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R'
if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H'
if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG'
if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V'
if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G'
if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK'
return null
}
const ctr = {} const ctr = {}
const lmap = [] const lmap = []
const bEdges = getRoofBoundaryEdges(canvas, roof.id)
for (const o of canvas.getObjects()) { for (const o of canvas.getObjects()) {
if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue
const pfx = classify(o) const pfx = classifyLineForLabel(o, bEdges)
if (!pfx) continue if (!pfx) continue
ctr[pfx] = (ctr[pfx] || 0) + 1 ctr[pfx] = (ctr[pfx] || 0) + 1
lmap.push({ lmap.push({
@ -3531,6 +3595,12 @@ export function useEavesGableEdit(id) {
} catch (e) { } catch (e) {
logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e)) logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e))
} }
// [KERAB-LABEL-REATTACH 2026-06-24] RULE1 씨앗(첫 처마 변환)으로 생성된 힙·마루에도 라벨 부여.
try {
reattachDebugLabels(canvas, roof.id)
} catch (e) {
logger.warn('[KERAB-LABEL-REATTACH] rule1 failed', e)
}
return return
} }
} }
@ -4367,6 +4437,157 @@ export function useEavesGableEdit(id) {
} }
return best return best
} }
// [KERAB-TYPE-EAVES-VALLEY-BOX 2026-06-24] A/B타입 변환은 골짜기에 겹침 박스를 만든다(사용자 확정).
// 일반 케라바 경로(buildOverlapLine)와 달리 TYPE 경로엔 vExt 가 없어 박스가 kerabValleyOverlapLine
// 객체로 안 만들어졌다 → boxSegs=[] → ① 박스 변이 B 라벨이 아니라 H/R 로 표시 ② oppRayDist/
// clipHipToBox/extendStraight 가 박스를 못 잡아 힙이 박스를 관통(HE 가 H 를 뚫음). 여기서 박스를
// "roof.points 의 凹(reflex) 꼭짓점을 한 코너로 하고 주변 축정렬 SK 내부선으로 둘러싸인 축정렬
// 직사각형"으로 기하 탐지해 실제 kerabValleyOverlapLine 세그먼트로 생성한다. 그러면 기존 boxSegs
// 기반 절삭이 그대로 동작하고 라벨도 B 가 된다. reflex 없는 정상 切妻은 박스 미생성(무영향).
const ensureTypeValleyBox = () => {
const pp = Array.isArray(roof.points) ? roof.points : []
const N = pp.length
if (N < 4) return
// 폴리곤 winding 부호.
let area2 = 0
for (let i = 0; i < N; i++) {
const a = pp[i]
const b = pp[(i + 1) % N]
area2 += a.x * b.y - b.x * a.y
}
const ccw = area2 > 0
const AX_EPS = 0.5
const isAxis = (ax, ay) => Math.abs(ax) < AX_EPS || Math.abs(ay) < AX_EPS
const existsBoxSeg = (x1, y1, x2, y2) =>
(canvas.getObjects() || []).some(
(o) =>
o &&
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
((Math.hypot(o.x1 - x1, o.y1 - y1) < 1 && Math.hypot(o.x2 - x2, o.y2 - y2) < 1) ||
(Math.hypot(o.x1 - x2, o.y1 - y2) < 1 && Math.hypot(o.x2 - x1, o.y2 - y1) < 1)),
)
const mkBoxEdge = (p1, p2, suffix) => {
if (Math.hypot(p2.x - p1.x, p2.y - p1.y) < 1) return
if (existsBoxSeg(p1.x, p1.y, p2.x, p2.y)) return
const lpts = [p1.x, p1.y, p2.x, p2.y]
const lsz = calcLinePlaneSize({ x1: lpts[0], y1: lpts[1], x2: lpts[2], y2: lpts[3] })
const ln = new QLine(lpts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.VALLEY,
textMode: roof.textMode,
attributes: { roofId: roof.id, type: 'kerabValleyOverlapLine', isStart: true, pitch: roof.pitch, planeSize: lsz, actualSize: lsz },
})
ln.lineName = 'kerabValleyOverlapLine'
ln.roofId = roof.id
ln.__targetId = target.id
ln.__valleyExtSource = 'type-box' + suffix
canvas.add(ln)
ln.bringToFront()
}
for (let i = 0; i < N; i++) {
const prev = pp[(i - 1 + N) % N]
const V = pp[i]
const next = pp[(i + 1) % N]
const cz = (V.x - prev.x) * (next.y - V.y) - (V.y - prev.y) * (next.x - V.x)
const reflex = (ccw && cz < 0) || (!ccw && cz > 0)
if (!reflex) continue
// V 의 두 이웃 변이 모두 축정렬일 때만(노치형 박스) 처리.
if (!isAxis(V.x - prev.x, V.y - prev.y) || !isAxis(next.x - V.x, next.y - V.y)) continue
// 폴리곤 안쪽(centroid) 방향으로 축 부호 결정.
const dirX = cenX - V.x >= 0 ? 1 : -1
const dirY = cenY - V.y >= 0 ? 1 : -1
// V 에서 +dirX 가로 ray → 첫 세로 내부선 x.
let xEdge = null
let xBest = Infinity
// V 에서 +dirY 세로 ray → 첫 가로 내부선 y.
let yEdge = null
let yBest = Infinity
for (const il of roof.innerLines || []) {
if (!il || il.visible === false) continue
const ax = il.x2 - il.x1
const ay = il.y2 - il.y1
const vertical = Math.abs(ax) < AX_EPS && Math.abs(ay) > AX_EPS
const horizontal = Math.abs(ay) < AX_EPS && Math.abs(ax) > AX_EPS
if (vertical) {
const lx = il.x1
const d = (lx - V.x) * dirX
const yLo = Math.min(il.y1, il.y2) - 0.5
const yHi = Math.max(il.y1, il.y2) + 0.5
if (d > 0.5 && d < xBest && V.y >= yLo && V.y <= yHi) {
xBest = d
xEdge = lx
}
} else if (horizontal) {
const ly = il.y1
const d = (ly - V.y) * dirY
const xLo = Math.min(il.x1, il.x2) - 0.5
const xHi = Math.max(il.x1, il.x2) + 0.5
if (d > 0.5 && d < yBest && V.x >= xLo && V.x <= xHi) {
yBest = d
yEdge = ly
}
}
}
// [KERAB-TYPE-EAVES-VALLEY-CAP-PREFER 2026-06-24] 골짜기 캡(kerabValleyOverlapLine)은 지붕형상설정
// 단계에서 이미 생성된다(B-1 류 = 우측 캡). 이 기존 캡이 박스의 진짜 경계다 — +x/+y ray 가 중간
// 내부선(R-1 기둥 x=662 등)에 먼저 걸려 박스를 짧게 끊으면 캡(747) 안쪽에 중복 우측변(B-3)이
// 생긴다. 같은 방향에 기존 valley 캡이 있으면 그 좌표를 xEdge/yEdge 로 우선 채택해 중복 제거.
const capScan = (wantVertical) => {
let cap = null
let capBest = Infinity
for (const o of canvas.getObjects() || []) {
if (!o || o.visible === false) continue
if (o.lineName !== 'kerabValleyOverlapLine' && o.attributes?.type !== 'kerabValleyOverlapLine') continue
if (!(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id)) continue
const ax = o.x2 - o.x1
const ay = o.y2 - o.y1
const vertical = Math.abs(ax) < AX_EPS && Math.abs(ay) > AX_EPS
const horizontal = Math.abs(ay) < AX_EPS && Math.abs(ax) > AX_EPS
if (wantVertical && vertical) {
const d = (o.x1 - V.x) * dirX
const yLo = Math.min(o.y1, o.y2) - 0.5
const yHi = Math.max(o.y1, o.y2) + 0.5
if (d > 0.5 && d < capBest && V.y >= yLo && V.y <= yHi) {
capBest = d
cap = o.x1
}
} else if (!wantVertical && horizontal) {
const d = (o.y1 - V.y) * dirY
const xLo = Math.min(o.x1, o.x2) - 0.5
const xHi = Math.max(o.x1, o.x2) + 0.5
if (d > 0.5 && d < capBest && V.x >= xLo && V.x <= xHi) {
capBest = d
cap = o.y1
}
}
}
return cap
}
const xCap = capScan(true)
if (xEdge == null || yEdge == null) continue
// [KERAB-TYPE-EAVES-VALLEY-CAP-PREFER 2026-06-24] xEdge/yEdge 는 그대로 유지(=apex·힙 불변).
// 단지 박스의 우측변(B-3) 위치 이상으로 바깥(출구 쪽)에 지붕형상설정 단계 캡(B-1, kerabValleyOverlapLine)이
// 이미 존재하면, 그 캡이 끝점을 잇는 진짜 우측 경계이므로 중복 우측변(B-3)을 새로 만들지 않는다.
// 힙 기하는 전혀 건드리지 않고 중복 변(B-3)만 억제한다(사용자 확정 2026-06-24).
const hasOuterXCap = xCap != null && Math.abs(xCap - V.x) > Math.abs(xEdge - V.x) - 0.5
const c00 = { x: V.x, y: V.y }
const cX0 = { x: xEdge, y: V.y }
const cXY = { x: xEdge, y: yEdge }
const c0Y = { x: V.x, y: yEdge }
mkBoxEdge(c00, cX0, '-top')
if (!hasOuterXCap) mkBoxEdge(cX0, cXY, '-right')
mkBoxEdge(cXY, c0Y, '-bottom')
mkBoxEdge(c0Y, c00, '-left')
logger.log(
'[KERAB-TYPE-EAVES-VALLEY-BOX] ' +
JSON.stringify({ V: { x: Math.round(V.x), y: Math.round(V.y) }, xEdge: Math.round(xEdge), yEdge: Math.round(yEdge) }),
)
}
}
ensureTypeValleyBox()
const oppDist = oppRayDist(mid, ix, iy) const oppDist = oppRayDist(mid, ix, iy)
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2) const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth } const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
@ -4379,6 +4600,18 @@ export function useEavesGableEdit(id) {
// 박스 코너다. 일반 내부선(H-2 등, Bp 미공유)은 힙이 가로질러야 하므로 제외 → 정상 切妻 // 박스 코너다. 일반 내부선(H-2 등, Bp 미공유)은 힙이 가로질러야 하므로 제외 → 정상 切妻
// (Bp=roofLine 코너, 공유 inner 없음)은 무영향. ridge(단축 대상)·invisible 제외. // (Bp=roofLine 코너, 공유 inner 없음)은 무영향. ridge(단축 대상)·invisible 제외.
const boxCorner = midIsA ? rB : rA const boxCorner = midIsA ? rB : rA
// [KERAB-TYPE-EAVES-RIDGE-VANISH-BOXGATE 2026-06-23] RG-1 삭제·박스닫기 후처리는 박스(겹침) 존재 케이스
// 전용. 박스 없는 일반 L자(box:[]) 에서 힙이 마루에 막혀도 RG-1 은 우진각 마루로 남아야 한다.
// (clipHipToBox 의 apex 클램프 분기도 이 값을 참조하므로 호출 전에 선계산한다.)
const boxExists = (canvas.getObjects() || []).some(
(o) =>
o &&
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id),
)
// [KERAB-TYPE-EAVES-CROSS-HIP-STOP-RIDGE 2026-06-23] 사용자 확정: 힙은 수평 hip(H-2/H-6 등)을
// *가로질러* 계속 뻗고, 세로 마루(RIDGE, RG-2 류)에서 멈춘다 — 마루가 상대 roofLine 역할.
// blocker = roofLine 변 · 마루(RIDGE)만 · 박스 변(상대 roofLine) · 형제 힙 meet(apex).
const clipHipToBox = (corner) => { const clipHipToBox = (corner) => {
const dx0 = apex.x - corner.x const dx0 = apex.x - corner.x
const dy0 = apex.y - corner.y const dy0 = apex.y - corner.y
@ -4386,39 +4619,74 @@ export function useEavesGableEdit(id) {
if (dlen < 1e-6) return { x: apex.x, y: apex.y } if (dlen < 1e-6) return { x: apex.x, y: apex.y }
const dx = dx0 / dlen const dx = dx0 / dlen
const dy = dy0 / dlen const dy = dy0 / dlen
let best = dlen let best = Infinity
let bestPt = { x: apex.x, y: apex.y } let bestPt = null
for (const il of roof.innerLines || []) { const consider = (sx1, sy1, sx2, sy2) => {
if (!il || il === ridge || il.visible === false) continue const ax = sx2 - sx1
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue const ay = sy2 - sy1
const sharesBp = if (Math.hypot(ax, ay) < 1e-6) return
(Math.abs(il.x1 - boxCorner.x) < 0.5 && Math.abs(il.y1 - boxCorner.y) < 0.5) ||
(Math.abs(il.x2 - boxCorner.x) < 0.5 && Math.abs(il.y2 - boxCorner.y) < 0.5)
// [KERAB-TYPE-EAVES-HIPMEET 2026-06-23] 신규 힙은 (1) 박스 변(Bp 공유) 또는 (2) 직전 변경에서
// 생성된 기존 케라바 힙(kerabPatternHip)과의 최근접 교점에서 멈춘다 → ">" 모양. 둘 다 방향 무관
// 후보로 같은 best(t 최소) 누산기에 경쟁시켜 가장 가까운 교점이 절삭점이 된다. 1차 변경 때는
// 기존 힙이 없어 박스 변만 후보 → 동작 불변(무영향).
const isPriorHip = il.lineName === 'kerabPatternHip'
if (!sharesBp && !isPriorHip) continue
const ax = il.x2 - il.x1
const ay = il.y2 - il.y1
if (Math.hypot(ax, ay) < 1e-6) continue
const den = dx * ay - dy * ax const den = dx * ay - dy * ax
if (Math.abs(den) < 1e-9) continue if (Math.abs(den) < 1e-9) return
const t = ((il.x1 - corner.x) * ay - (il.y1 - corner.y) * ax) / den const t = ((sx1 - corner.x) * ay - (sy1 - corner.y) * ax) / den
const u = ((il.x1 - corner.x) * dy - (il.y1 - corner.y) * dx) / den const u = ((sx1 - corner.x) * dy - (sy1 - corner.y) * dx) / den
if (t > 1.0 && t < best - 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) { if (t > 1.0 && u >= -1e-6 && u <= 1 + 1e-6 && t < best - 1e-6) {
best = t best = t
bestPt = { x: corner.x + dx * t, y: corner.y + dy * t } bestPt = { x: corner.x + dx * t, y: corner.y + dy * t }
} }
} }
return bestPt for (let i = 0; i < cps.length; i++) {
consider(cps[i].x, cps[i].y, cps[(i + 1) % cps.length].x, cps[(i + 1) % cps.length].y)
}
// 세로 마루(RIDGE)만 blocker — 수평 hip 은 가로질러 통과.
for (const il of roof.innerLines || []) {
if (!il || il === ridge || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
consider(il.x1, il.y1, il.x2, il.y2)
}
const boxSegs = (canvas.getObjects() || []).filter(
(o) =>
o &&
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id),
)
// [KERAB-TYPE-EAVES-VALLEY-BOX-EXIT 2026-06-24] 박스=상대 roofLine. 힙은 박스 진입변에서 멈추지 않고
// 관통해서 진출변(ray 방향 먼 교점=B-3, 실제 힙 H-2 와 일치)에서 끝난다. 진입변에서 자르면 HE-2 가
// H-2 못미쳐 dangling. 단 박스 진출 t 가 하드 blocker(roofLine/마루 best)보다 멀면 하드가 우선.
const boxTs = []
for (const o of boxSegs) {
const ax = o.x2 - o.x1
const ay = o.y2 - o.y1
if (Math.hypot(ax, ay) < 1e-6) continue
const den = dx * ay - dy * ax
if (Math.abs(den) < 1e-9) continue
const t = ((o.x1 - corner.x) * ay - (o.y1 - corner.y) * ax) / den
const u = ((o.x1 - corner.x) * dy - (o.y1 - corner.y) * dx) / den
if (t > 1.0 && u >= -1e-6 && u <= 1 + 1e-6) boxTs.push(t)
}
if (boxTs.length) {
boxTs.sort((a, b) => a - b)
const tExit = boxTs[boxTs.length - 1]
if (tExit < best - 1e-6) {
best = tExit
bestPt = { x: corner.x + dx * tExit, y: corner.y + dy * tExit }
}
}
// [KERAB-TYPE-EAVES-APEX-CLAMP-BOXGATE 2026-06-24] apex 는 "x=mid 마루의 바닥(L/2)" 으로 설계된 점.
// 박스(겹침) 케이스는 그 마루를 삭제(ridgeDeleted)하므로 apex 가 허공의 유령점이 된다. 이때
// 방해선 없는 힙(HE-1)을 apex 에 클램프하면 마루도 짝힙도 없는 곳에 끝점이 떠 버린다(공중 dangling).
// → 박스 케이스에서는 apex 클램프를 풀어 실제 blocker(살아있는 마루 R-1 등)까지 뻗게 한다.
// 정상 대칭 切妻(box 없음)은 종전대로 apex 에서 짝힙과 만나야 하므로 클램프 유지.
if (!boxExists && dlen < best - 1e-6) {
best = dlen
bestPt = { x: apex.x, y: apex.y }
}
return bestPt || { x: apex.x, y: apex.y }
} }
// 박스 절삭점 선계산 → 마루 처리 분기 판단에 사용. // 박스 절삭점 선계산 → 마루 처리 분기 판단에 사용.
const endA = clipHipToBox(wA) const endA = clipHipToBox(wA)
const endB = clipHipToBox(wB) const endB = clipHipToBox(wB)
const hipClipped = Math.hypot(endA.x - apex.x, endA.y - apex.y) > 1 || Math.hypot(endB.x - apex.x, endB.y - apex.y) > 1 const hipClipped = Math.hypot(endA.x - apex.x, endA.y - apex.y) > 1 || Math.hypot(endB.x - apex.x, endB.y - apex.y) > 1
if (hipClipped) { if (hipClipped && boxExists) {
// [KERAB-TYPE-EAVES-RIDGE-VANISH 2026-06-23] 박스(겹침)가 상대 roofLine 이라 신규 힙이 박스/직전 힙에서 // [KERAB-TYPE-EAVES-RIDGE-VANISH 2026-06-23] 박스(겹침)가 상대 roofLine 이라 신규 힙이 박스/직전 힙에서
// 멈추면, 가운데 마루(RG-1)는 양쪽 코너 힙이 이미 생성됐으므로 "삭제 대상"이다(사용자 규칙 2026-06-23: // 멈추면, 가운데 마루(RG-1)는 양쪽 코너 힙이 이미 생성됐으므로 "삭제 대상"이다(사용자 규칙 2026-06-23:
// "RG-1 은 마루 = 삭제, 양쪽에 힙 생성됨"). visible=false 숨기기는 객체가 innerLines 에 남아 // "RG-1 은 마루 = 삭제, 양쪽에 힙 생성됨"). visible=false 숨기기는 객체가 innerLines 에 남아
@ -4473,6 +4741,54 @@ export function useEavesGableEdit(id) {
hip1.bringToFront() hip1.bringToFront()
hip2.bringToFront() hip2.bringToFront()
roof.innerLines.push(hip1, hip2) roof.innerLines.push(hip1, hip2)
// [KERAB-TYPE-EAVES-RIDGE-CUT 2026-06-24] 신규 힙(HE-1)이 살아있는 마루(RIDGE)에 T자로 착지하면,
// 표준 寄棟 규칙상 마루는 그 교점에서 종료한다. 교점 위쪽(닫히는 쪽=apex 측) 구간은 힙 삼각면에
// 먹히므로 잘라낸다(끝점을 교점으로 단축). 박스 안을 지나던 마루 상단부가 제거돼 R-CROSS 도 동시 해소.
// 박스(겹침) 케이스 전용 — 정상 대칭 切妻(box 없음)은 힙이 apex 에서 만나 마루 중간 착지가 없다.
const truncateRidgeAtHipJunction = (end) => {
const ON_TOL = 1.5
for (const il of roof.innerLines || []) {
if (!il || il === ridge || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const dx = il.x2 - il.x1
const dy = il.y2 - il.y1
const len2 = dx * dx + dy * dy
if (len2 < 1e-6) continue
const t = ((end.x - il.x1) * dx + (end.y - il.y1) * dy) / len2
const prx = il.x1 + t * dx
const pry = il.y1 + t * dy
if (Math.hypot(end.x - prx, end.y - pry) > ON_TOL) continue
// 교점이 마루 끝점이면(이미 끝에서 만남) 단축 불필요.
if (t <= 0.02 || t >= 0.98) continue
// 두 끝점 중 apex(닫히는 쪽)에 가까운 끝을 교점으로 단축 → 위쪽 구간 제거.
const d1 = Math.hypot(il.x1 - apex.x, il.y1 - apex.y)
const d2 = Math.hypot(il.x2 - apex.x, il.y2 - apex.y)
if (d1 <= d2) il.set({ x1: end.x, y1: end.y })
else il.set({ x2: end.x, y2: end.y })
il.startPoint = { x: il.x1, y: il.y1 }
il.endPoint = { x: il.x2, y: il.y2 }
const rs = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 })
if (il.attributes) {
il.attributes.planeSize = rs
il.attributes.actualSize = rs
}
if (typeof il.setCoords === 'function') il.setCoords()
if (typeof il.setLength === 'function') il.setLength()
if (typeof il.addLengthText === 'function') il.addLengthText()
logger.log(
'[KERAB-TYPE-EAVES-RIDGE-CUT] ' +
JSON.stringify({
at: { x: Math.round(end.x), y: Math.round(end.y) },
ridge: { x1: Math.round(il.x1), y1: Math.round(il.y1), x2: Math.round(il.x2), y2: Math.round(il.y2) },
}),
)
break
}
}
if (boxExists) {
truncateRidgeAtHipJunction(endA)
truncateRidgeAtHipJunction(endB)
}
removeKerabHalfLabels(target.id) removeKerabHalfLabels(target.id)
hideOriginalLengthText(target.id, false) hideOriginalLengthText(target.id, false)
canvas.renderAll() canvas.renderAll()
@ -4485,31 +4801,19 @@ export function useEavesGableEdit(id) {
inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) }, inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) },
hipA: { x: Math.round(hip1.x2), y: Math.round(hip1.y2), clipped: Math.hypot(hip1.x2 - apex.x, hip1.y2 - apex.y) > 1 }, hipA: { x: Math.round(hip1.x2), y: Math.round(hip1.y2), clipped: Math.hypot(hip1.x2 - apex.x, hip1.y2 - apex.y) > 1 },
hipB: { x: Math.round(hip2.x2), y: Math.round(hip2.y2), clipped: Math.hypot(hip2.x2 - apex.x, hip2.y2 - apex.y) > 1 }, hipB: { x: Math.round(hip2.x2), y: Math.round(hip2.y2), clipped: Math.hypot(hip2.x2 - apex.x, hip2.y2 - apex.y) > 1 },
ridgeDeleted: hipClipped, ridgeDeleted: hipClipped && boxExists,
ridgeHidden: ridge.visible === false, ridgeHidden: ridge.visible === false,
}), }),
) )
// [KERAB-LABEL-MAP 2026-06-23] 경로 A(일반 라인→처마) 진단 — 화면 라벨(H-n/RG-n/V-n…)↔좌표 맵. // [KERAB-LABEL-MAP 2026-06-23] 경로 A(일반 라인→처마) 진단 — 화면 라벨(L/H/W/R/RE/HE/B…)↔좌표 맵.
// 골짜기 박스(kerabValleyOverlapLine)는 V 로 분류되어 V-n 으로 노출 → 힙이 박스를 통과/멈추는지 // 골짜기 박스(kerabValleyOverlapLine)는 B 로 분류 → 힙이 박스를 통과/멈추는지 라벨로 대조(local 전용, 로깅만).
// 라벨로 대조하기 위함(local 전용, 로깅만).
try { try {
const classify = (o) => {
const nm = o.name
const ln = o.lineName
if (nm === 'baseLine') return 'B'
if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R'
if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H'
if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG'
if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V'
if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G'
if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK'
return null
}
const ctr = {} const ctr = {}
const lmap = [] const lmap = []
const bEdges = getRoofBoundaryEdges(canvas, roof.id)
for (const o of canvas.getObjects()) { for (const o of canvas.getObjects()) {
if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue
const pfx = classify(o) const pfx = classifyLineForLabel(o, bEdges)
if (!pfx) continue if (!pfx) continue
ctr[pfx] = (ctr[pfx] || 0) + 1 ctr[pfx] = (ctr[pfx] || 0) + 1
lmap.push({ lmap.push({
@ -4557,12 +4861,62 @@ export function useEavesGableEdit(id) {
} catch (e) { } catch (e) {
logger.warn('[KERAB-BOX-DUMP] failed ' + (e?.message || e)) logger.warn('[KERAB-BOX-DUMP] failed ' + (e?.message || e))
} }
// [KERAB-HIP-RAY 2026-06-23] 진단 — 각 신규 힙의 corner→apex 45° ray 가 *모든* 내부선과 어디서
// 교차하는지(거리순)를 라인 라벨·좌표와 함께 덤프. 사용자: "왼쪽 힙이 박스 통과" ↔ 현재 좌표모델:
// ray 가 박스(662,236)-(747,266) 안으로 안 들어감. 모순을 좌표로 검증하기 위함. 결정적 사실:
// 박스가 kerabValleyOverlapLine 객체가 아니라 일반 SK 내부선(H-7/RG-2/H-2 + 결손 우변)으로
// 구성돼 boxSegs=[] → 박스 절삭 로직이 발동 안 함. local 전용, 로깅만.
try {
const rayCrossings = (corner) => {
const dx0 = apex.x - corner.x
const dy0 = apex.y - corner.y
const dlen = Math.hypot(dx0, dy0)
if (dlen < 1e-6) return []
const dx = dx0 / dlen
const dy = dy0 / dlen
const hits = []
let idx = 0
for (const il of roof.innerLines || []) {
idx++
if (!il || il === ridge) continue
const ax = il.x2 - il.x1
const ay = il.y2 - il.y1
if (Math.hypot(ax, ay) < 1e-6) continue
const den = dx * ay - dy * ax
if (Math.abs(den) < 1e-9) continue
const t = ((il.x1 - corner.x) * ay - (il.y1 - corner.y) * ax) / den
const u = ((il.x1 - corner.x) * dy - (il.y1 - corner.y) * dx) / den
if (t > 1.0 && t < dlen + 1 && u >= -1e-6 && u <= 1 + 1e-6) {
hits.push({
at: { x: Math.round(corner.x + dx * t), y: Math.round(corner.y + dy * t) },
d: Math.round(t),
name: il.name || null,
ln: il.lineName || null,
vis: il.visible !== false,
seg: { x1: Math.round(il.x1), y1: Math.round(il.y1), x2: Math.round(il.x2), y2: Math.round(il.y2) },
})
}
}
hits.sort((a, b) => a.d - b.d)
return hits
}
logger.log(
'[KERAB-HIP-RAY] ' +
JSON.stringify({
apex: { x: Math.round(apex.x), y: Math.round(apex.y) },
wA: { x: Math.round(wA.x), y: Math.round(wA.y), crossings: rayCrossings(wA) },
wB: { x: Math.round(wB.x), y: Math.round(wB.y), crossings: rayCrossings(wB) },
}),
)
} catch (e) {
logger.warn('[KERAB-HIP-RAY] failed ' + (e?.message || e))
}
// [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] 마루(RG-1)를 삭제한 케이스. moveStaleEdgeInnerLines // [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] 마루(RG-1)를 삭제한 케이스. moveStaleEdgeInnerLines
// 의 relocate 가 이 플래그로 박스 세로선(H-8)의 과길게 뻗은 끝점을 박스 바닥(boxCorner=RG-1 의 // 의 relocate 가 이 플래그로 박스 세로선(H-8)의 과길게 뻗은 끝점을 박스 바닥(boxCorner=RG-1 의
// 박스측 끝점, 예 (747,266))으로 내려 H-7↔H-2 사이만 막도록 만든다(사용자 규칙 2026-06-23: // 박스측 끝점, 예 (747,266))으로 내려 H-7↔H-2 사이만 막도록 만든다(사용자 규칙 2026-06-23:
// "H-8 은 힙이 아니라 박스를 막는 세로 roofLine, H-2 와 H-7 사이만"). // "H-8 은 힙이 아니라 박스를 막는 세로 roofLine, H-2 와 H-7 사이만").
apex.ridgeHidden = hipClipped apex.ridgeHidden = hipClipped && boxExists
apex.boxCorner = hipClipped ? boxCorner : null apex.boxCorner = hipClipped && boxExists ? boxCorner : null
return apex return apex
} }
@ -4714,6 +5068,28 @@ export function useEavesGableEdit(id) {
hit = { x: P.x + dir.x * t, y: P.y + dir.y * t } hit = { x: P.x + dir.x * t, y: P.y + dir.y * t }
} }
} }
// [KERAB-TYPE-EAVES-VALLEY-BOX 2026-06-24] 골짜기 박스(kerabValleyOverlapLine)는 상대 roofLine →
// 힙은 박스 변에서 멈춰야 한다. roof.points 변보다 박스 변이 더 가까우면 거기서 절삭(관통 방지).
const boxSegs = (canvas.getObjects() || []).filter(
(o) =>
o &&
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id),
)
// [KERAB-TYPE-EAVES-VALLEY-BOX-EXIT 2026-06-24] 박스=상대 roofLine → 힙은 관통 후 진출변(먼 교점)에서 종료.
const boxTs = []
for (const o of boxSegs) {
const t = rayHit(P, dir, { x: o.x1, y: o.y1 }, { x: o.x2, y: o.y2 })
if (t < Infinity) boxTs.push(t)
}
if (boxTs.length) {
boxTs.sort((a, b) => a - b)
const tExit = boxTs[boxTs.length - 1]
if (tExit < best) {
best = tExit
hit = { x: P.x + dir.x * tExit, y: P.y + dir.y * tExit }
}
}
if (!hit) continue if (!hit) continue
// 힙 = apex → hit (단일 직선 45°). // 힙 = apex → hit (단일 직선 45°).
if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y }) if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y })
@ -4866,6 +5242,165 @@ export function useEavesGableEdit(id) {
return true return true
} }
// [KERAB-TYPE-EAVES-HIP-CROSS-CLIP 2026-06-24 Step1] 교차하는 두 힙을 교점에서 절삭.
// 체크함수(kerab-rule-checker R-CROSS)와 동일한 원리: 보이는 kerabPatternHip 쌍을 따라가며 *내부*
// 교차(둘 다 끝점 제외)를 segCross 로 찾고, 골짜기 박스(겹침=상대 roofLine) 안 교점은 제외한다.
// 교점에서 각 힙의 inner(폴리곤 중심쪽) 끝을 교점으로 단축 = "교점 이하 절삭". 마루 생성은 후속 단계.
const clipCrossedHipsAtIntersection = (roof) => {
if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return false
const hips = roof.innerLines.filter((il) => il && il.lineName === 'kerabPatternHip' && il.visible !== false)
if (hips.length < 2) return false
// 박스(겹침) bbox + 패딩 — 박스 안 교차는 규칙상 제외(상대 roofLine 이 둘을 막음).
const PAD = 1.0
const boxLines = (canvas.getObjects() || []).filter(
(o) =>
o &&
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id),
)
let bx0 = Infinity
let by0 = Infinity
let bx1 = -Infinity
let by1 = -Infinity
for (const o of boxLines) {
bx0 = Math.min(bx0, o.x1, o.x2)
by0 = Math.min(by0, o.y1, o.y2)
bx1 = Math.max(bx1, o.x1, o.x2)
by1 = Math.max(by1, o.y1, o.y2)
}
const hasBox = boxLines.length > 0
const inBox = (p) => hasBox && p.x >= bx0 - PAD && p.x <= bx1 + PAD && p.y >= by0 - PAD && p.y <= by1 + PAD
// 폴리곤 중심(코너 outer 판별).
let cx = 0
let cy = 0
for (const p of roof.points) {
cx += p.x
cy += p.y
}
cx /= roof.points.length || 1
cy /= roof.points.length || 1
// 내부 교차(끝점 접합 제외) — 체크함수 segCross 와 동치.
const segCross = (a, b) => {
const rx = a.x2 - a.x1
const ry = a.y2 - a.y1
const sx = b.x2 - b.x1
const sy = b.y2 - b.y1
const den = rx * sy - ry * sx
if (Math.abs(den) < 1e-9) return null
const t = ((b.x1 - a.x1) * sy - (b.y1 - a.y1) * sx) / den
const u = ((b.x1 - a.x1) * ry - (b.y1 - a.y1) * rx) / den
const m = 1e-3
if (t > m && t < 1 - m && u > m && u < 1 - m) return { x: a.x1 + t * rx, y: a.y1 + t * ry }
return null
}
const clipInnerEnd = (hip, p) => {
const d1 = Math.hypot(hip.x1 - cx, hip.y1 - cy)
const d2 = Math.hypot(hip.x2 - cx, hip.y2 - cy)
if (d1 >= d2) hip.set({ x2: p.x, y2: p.y })
else hip.set({ x1: p.x, y1: p.y })
hip.startPoint = { x: hip.x1, y: hip.y1 }
hip.endPoint = { x: hip.x2, y: hip.y2 }
const sz = calcLinePlaneSize({ x1: hip.x1, y1: hip.y1, x2: hip.x2, y2: hip.y2 })
if (hip.attributes) {
hip.attributes.planeSize = sz
hip.attributes.actualSize = sz
}
if (typeof hip.setCoords === 'function') hip.setCoords()
if (typeof hip.setLength === 'function') hip.setLength()
if (typeof hip.addLengthText === 'function') hip.addLengthText()
}
let clipped = 0
const clipEvents = []
for (let i = 0; i < hips.length; i++) {
for (let k = i + 1; k < hips.length; k++) {
const ca = { x1: hips[i].x1, y1: hips[i].y1, x2: hips[i].x2, y2: hips[i].y2 }
const cb = { x1: hips[k].x1, y1: hips[k].y1, x2: hips[k].x2, y2: hips[k].y2 }
const ip = segCross(ca, cb)
if (!ip || inBox(ip)) continue
clipInnerEnd(hips[i], ip)
clipInnerEnd(hips[k], ip)
clipped++
clipEvents.push({ p: ip, a: hips[i], b: hips[k] })
logger.log('[KERAB-TYPE-EAVES-HIP-CROSS-CLIP] ' + JSON.stringify({ at: { x: Math.round(ip.x), y: Math.round(ip.y) } }))
}
}
// [KERAB-TYPE-EAVES-HIP-CROSS-CLIP 2026-06-24 Step2] `<` 꼭지점(교점)에서 마루 생성.
// 두 절삭 힙의 바깥방향 이등분선을 축에 스냅(마루는 수평/수직) → 그 반대(폴리곤 안쪽)로 마루를 긋고,
// 안쪽 ray 가 가장 먼저 만나는 다른 kerabPatternHip 에서 멈춰 그 힙을 교점에서 절삭 = "--<" 표준 형상.
for (const ev of clipEvents) {
const otherEnd = (hip) => {
const d1 = Math.hypot(hip.x1 - ev.p.x, hip.y1 - ev.p.y)
const d2 = Math.hypot(hip.x2 - ev.p.x, hip.y2 - ev.p.y)
return d1 >= d2 ? { x: hip.x1, y: hip.y1 } : { x: hip.x2, y: hip.y2 }
}
const norm = (q) => {
const dx = q.x - ev.p.x
const dy = q.y - ev.p.y
const L = Math.hypot(dx, dy) || 1
return { x: dx / L, y: dy / L }
}
const na = norm(otherEnd(ev.a))
const nb = norm(otherEnd(ev.b))
const bisX = na.x + nb.x // `<` 바깥쪽 이등분선
const bisY = na.y + nb.y
// 마루 방향 = 이등분선 반대(안쪽) + 축 스냅.
const dir = Math.abs(bisX) >= Math.abs(bisY) ? { x: bisX > 0 ? -1 : 1, y: 0 } : { x: 0, y: bisY > 0 ? -1 : 1 }
const raySeg = (s1, s2) => {
const sx = s2.x - s1.x
const sy = s2.y - s1.y
const den = dir.x * sy - dir.y * sx
if (Math.abs(den) < 1e-9) return null
const t = ((s1.x - ev.p.x) * sy - (s1.y - ev.p.y) * sx) / den
const u = ((s1.x - ev.p.x) * dir.y - (s1.y - ev.p.y) * dir.x) / den
if (t > 0.5 && u > 1e-3 && u < 1 - 1e-3) return { x: ev.p.x + dir.x * t, y: ev.p.y + dir.y * t, t }
return null
}
let best = null
let bestHip = null
for (const h of hips) {
if (h === ev.a || h === ev.b || h.visible === false) continue
const q = raySeg({ x: h.x1, y: h.y1 }, { x: h.x2, y: h.y2 })
if (!q) continue
if (!best || q.t < best.t) {
best = q
bestHip = h
}
}
if (!best) {
logger.log('[KERAB-TYPE-EAVES-HIP-CROSS-CLIP] Step2 no-hip-met ' + JSON.stringify({ from: { x: Math.round(ev.p.x), y: Math.round(ev.p.y) }, dir }))
continue
}
const q = { x: best.x, y: best.y }
clipInnerEnd(bestHip, q)
const rpts = [ev.p.x, ev.p.y, q.x, q.y]
const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] })
const ridge = new QLine(rpts, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.RIDGE,
textMode: roof.textMode,
attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.RIDGE, planeSize: rsz, actualSize: rsz },
})
ridge.lineName = 'ridge'
ridge.startPoint = { x: rpts[0], y: rpts[1] }
ridge.endPoint = { x: rpts[2], y: rpts[3] }
canvas.add(ridge)
ridge.bringToFront()
roof.innerLines.push(ridge)
if (typeof ridge.setCoords === 'function') ridge.setCoords()
if (typeof ridge.setLength === 'function') ridge.setLength()
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
logger.log(
'[KERAB-TYPE-EAVES-HIP-CROSS-CLIP] Step2 ridge ' +
JSON.stringify({ from: { x: Math.round(ev.p.x), y: Math.round(ev.p.y) }, to: { x: Math.round(q.x), y: Math.round(q.y) }, dir }),
)
}
if (clipped) canvas.renderAll()
return clipped > 0
}
// [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨 // [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨
// 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출. // 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출.
// 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체. // 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체.

View File

@ -1411,6 +1411,8 @@ export function useRoofAllocationSetting(id) {
// 이 선들은 외곽 gable 변과 동일직선상에서 더 짧은 경로를 제공하여 Dijkstra 가 // 이 선들은 외곽 gable 변과 동일직선상에서 더 짧은 경로를 제공하여 Dijkstra 가
// degenerate collinear face 를 먼저 선택 → 진짜 top/bottom 사다리꼴 면의 start 가 // degenerate collinear face 를 먼저 선택 → 진짜 top/bottom 사다리꼴 면의 start 가
// 소진돼 미생성된다. kerab 케이스에서만 purge — 일반 SK hip 에서는 건드리지 않음. // 소진돼 미생성된다. kerab 케이스에서만 purge — 일반 SK hip 에서는 건드리지 않음.
// [GABLE-ROOFLINE-LABEL 2026-06-24] purge 대상은 lineName 없는 SK 격자 반-엣지 hip 뿐.
// 박공 처마/골짜기 roofLine 은 drawRoofLine 에서 lineName='roofLine' 을 받으므로 이 필터에 안 걸린다.
if (roofBase.innerLines.some((l) => l?.lineName === 'kerabPatternHip')) { if (roofBase.innerLines.some((l) => l?.lineName === 'kerabPatternHip')) {
const skHelpers = roofBase.innerLines.filter((l) => !l?.lineName && l?.name === 'hip') const skHelpers = roofBase.innerLines.filter((l) => !l?.lineName && l?.name === 'hip')
skHelpers.forEach((l) => canvas.remove(l)) skHelpers.forEach((l) => canvas.remove(l))

View File

@ -77,7 +77,17 @@ const segCross = (a, b, eps) => {
// ── 라인 분류 ───────────────────────────────────────────────────── // ── 라인 분류 ─────────────────────────────────────────────────────
const typeOf = (ln) => ln?.lineName || ln?.name || ln?.attributes?.type || '' const typeOf = (ln) => ln?.lineName || ln?.name || ln?.attributes?.type || ''
const isBox = (ln) => ln?.lineName === 'kerabValleyOverlapLine' || ln?.attributes?.type === 'kerabValleyOverlapLine' const isBox = (ln) => ln?.lineName === 'kerabValleyOverlapLine' || ln?.attributes?.type === 'kerabValleyOverlapLine'
const isHip = (ln) => !isBox(ln) && typeOf(ln) === 'hip' // 힙의 도메인 정의 = 정사각형 대각선(45°). 스켈레톤 생성 단계에서 축정렬(0°/90°) 라인에도 name='hip'
// 이 붙는 경우가 있는데(실제론 ridge/eaves), 이들을 hip 으로 분류하면 R-45HIP 가 자기모순적으로 위반을
// 낸다(2026-06-24 사용자 확정). 이름이 hip 이어도 축(0/90/180)에 더 가까우면 hip 으로 보지 않는다.
// 45° 쪽에 더 가까운 경우만 hip → 실제 힙의 drift(예: 50°)는 여전히 R-45HIP 가 잡는다.
const isDiagonalHip = (ln) => {
const c = coords(ln)
if (!c) return false
const a = angle180(c)
return devFrom(a, [45, 135]) < devFrom(a, [0, 90, 180])
}
const isHip = (ln) => !isBox(ln) && typeOf(ln) === 'hip' && isDiagonalHip(ln)
const isRidge = (ln) => !isBox(ln) && typeOf(ln) === 'ridge' const isRidge = (ln) => !isBox(ln) && typeOf(ln) === 'ridge'
// 박스(겹침) 영역 = 같은 __targetId 의 kerabValleyOverlapLine 세그먼트들의 bbox. // 박스(겹침) 영역 = 같은 __targetId 의 kerabValleyOverlapLine 세그먼트들의 bbox.

View File

@ -6035,6 +6035,11 @@ const drawRoofLine = (points, canvas, roof, textMode) => {
}), }),
}, },
}) })
// [GABLE-ROOFLINE-LABEL 2026-06-24] 박공 same-direction 세그먼트(처마/골짜기 공통)는 실제 roofLine.
// name='hip' 은 undo 재연결 의존성(useUndoRedo) 때문에 유지하되, lineName='roofLine' 을 부여해
// (1) 라벨러에서 L 로 분류되고 (2) hip purge(useRoofAllocationSetting 의 `!lineName && name==='hip'`)
// 에서 자동 제외된다. lineName 없는 SK 격자 반-엣지 hip 은 그대로 purge 되어 [2294_4] 의도 유지.
ridge.lineName = 'roofLine'
canvas.add(ridge) canvas.add(ridge)
ridge.bringToFront() ridge.bringToFront()
canvas.renderAll() canvas.renderAll()