dev #935

Merged
ysCha merged 8 commits from dev into prd-deploy 2026-06-29 16:38:57 +09:00
10 changed files with 1291 additions and 5421 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,15 @@ 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)
// [LABEL-DUMP 2026-06-26] 캔버스 라벨↔lineId↔태그↔좌표↔각도 매핑을 debug.log 로 덤프.
// 체커 로그는 lineId/좌표만 남겨 사용자(라벨 기준)와 대화가 안 맞음 → 라벨 부여 시점에 같이 기록.
const __labelDump = []
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
@ -77,6 +155,19 @@ function __attachDebugLabels(canvas, parentId) {
const mx = (obj.x1 + obj.x2) / 2 const mx = (obj.x1 + obj.x2) / 2
const my = (obj.y1 + obj.y2) / 2 const my = (obj.y1 + obj.y2) / 2
if (typeof obj.x1 === 'number' && typeof obj.x2 === 'number') {
const ang = Math.round((Math.atan2(obj.y2 - obj.y1, obj.x2 - obj.x1) * 180) / Math.PI)
__labelDump.push({
label,
id: obj.id || obj.lineId || '',
name: obj.name || '',
lineName: obj.lineName || '',
type: obj.attributes?.type || '',
coords: `(${Math.round(obj.x1)},${Math.round(obj.y1)})-(${Math.round(obj.x2)},${Math.round(obj.y2)})`,
angle: ang,
})
}
const text = new fabric.Text(label, { const text = new fabric.Text(label, {
left: mx, left: mx,
top: my, top: my,
@ -99,6 +190,10 @@ function __attachDebugLabels(canvas, parentId) {
text.bringToFront() text.bringToFront()
}) })
if (__labelDump.length) {
debugCapture.log(`LABEL-DUMP roof=${parentId}`, { count: __labelDump.length, lines: __labelDump })
}
canvas.renderAll() canvas.renderAll()
} }

View File

@ -80,14 +80,14 @@ export default function MainContents({ setFaqOpen, setFaqModalNoticeNo }) {
// //
const fetchNoticeList = async () => { const fetchNoticeList = async () => {
const param = {
schNoticeTpCd: 'QC',
schNoticeClsCd: 'NOTICE',
startRow: 1,
endRow: 1,
}
const noticeApiUrl = `api/board/list?${queryStringFormatter(param)}`
try { try {
const param = {
schNoticeTpCd: 'QC',
schNoticeClsCd: 'NOTICE',
startRow: 1,
endRow: 1,
}
const noticeApiUrl = `api/board/list?${queryStringFormatter(param)}`
await promiseGet({ url: noticeApiUrl }).then((res) => { await promiseGet({ url: noticeApiUrl }).then((res) => {
if (res.status === 200) { if (res.status === 200) {
setRecentNoticeList(res.data.data) setRecentNoticeList(res.data.data)
@ -111,14 +111,14 @@ export default function MainContents({ setFaqOpen, setFaqModalNoticeNo }) {
//FAQ //FAQ
const fetchFaqList = async () => { const fetchFaqList = async () => {
const param = {
schNoticeTpCd: 'QC',
schNoticeClsCd: 'FAQ',
startRow: 1,
endRow: 3,
}
const faqApiUrl = `api/board/list?${queryStringFormatter(param)}`
try { try {
const param = {
schNoticeTpCd: 'QC',
schNoticeClsCd: 'FAQ',
startRow: 1,
endRow: 3,
}
const faqApiUrl = `api/board/list?${queryStringFormatter(param)}`
await promiseGet({ await promiseGet({
url: faqApiUrl, url: faqApiUrl,
}).then((res) => { }).then((res) => {

View File

@ -1,4 +1,4 @@
import { useContext, useEffect, useState } from 'react' import { useContext, useEffect, useRef, useState } from 'react'
import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil' import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'
import { import {
adsorptionPointModeState, adsorptionPointModeState,
@ -119,6 +119,12 @@ export function useCanvasSetting(executeEffect = true) {
const [basicSetting, setBasicSettings] = useRecoilState(basicSettingState) const [basicSetting, setBasicSettings] = useRecoilState(basicSettingState)
const { getRoofMaterialList, getModuleTypeItemList } = useMasterController() const { getRoofMaterialList, getModuleTypeItemList } = useMasterController()
const [roofMaterials, setRoofMaterials] = useRecoilState(roofMaterialsAtom) const [roofMaterials, setRoofMaterials] = useRecoilState(roofMaterialsAtom)
// [ROOFMAT-POLL-FIX 2026-06-29] fetchBasicSettings 의 폴링 루프가 stale closure 에 묶여
// Header.jsx 가 채운 최신 roofMaterials 를 못 읽던 문제 → ref 미러로 최신값을 읽어 데이터 도착 즉시 종료.
const roofMaterialsRef = useRef(roofMaterials)
useEffect(() => {
roofMaterialsRef.current = roofMaterials
}, [roofMaterials])
const [addedRoofs, setAddedRoofs] = useRecoilState(addedRoofsState) const [addedRoofs, setAddedRoofs] = useRecoilState(addedRoofsState)
const setCurrentMenu = useSetRecoilState(currentMenuState) const setCurrentMenu = useSetRecoilState(currentMenuState)
@ -321,20 +327,22 @@ export function useCanvasSetting(executeEffect = true) {
const targetObjectNo = objectNo || correntObjectNo const targetObjectNo = objectNo || correntObjectNo
// 지붕재 데이터가 없으면 Header.jsx에서 로드될 때까지 기다림 // 지붕재 데이터가 없으면 Header.jsx에서 로드될 때까지 기다림
let materials = roofMaterials // [ROOFMAT-POLL-FIX 2026-06-29] roofMaterialsRef 로 최신값을 폴링 → 데이터가 들어오는 즉시 종료.
// (이전엔 stale closure 로 항상 풀카운트를 소모해 select 가 늦게 떴음)
let materials = roofMaterialsRef.current
if (!materials || materials.length === 0) { if (!materials || materials.length === 0) {
logger.log('Waiting for roofMaterials to be loaded from Header.jsx...') logger.log('Waiting for roofMaterials to be loaded from Header.jsx...')
// 최대 2초간 기다림 (20번 × 100ms) // 최대 1초간 기다림 (10번 × 100ms), 데이터 도착 시 즉시 break
let waitCount = 0 let waitCount = 0
while ((!materials || materials.length === 0) && waitCount < 20) { while ((!materials || materials.length === 0) && waitCount < 10) {
await new Promise(resolve => setTimeout(resolve, 100)) await new Promise((resolve) => setTimeout(resolve, 100))
materials = roofMaterials materials = roofMaterialsRef.current
waitCount++ waitCount++
} }
if (!materials || materials.length === 0) { if (!materials || materials.length === 0) {
logger.log('roofMaterials still not loaded after 2 seconds, proceeding without them') logger.log('roofMaterials still not loaded after 1 second, proceeding via fallback')
// 비상시에만 addRoofMaterials 호출 (fallback) // 10회 안에 못 받으면 fallback 직접 호출 (getRoofMaterialList promise cache 로 중복 네트워크 방지)
materials = await addRoofMaterials() materials = await addRoofMaterials()
logger.log('roofMaterials loaded via fallback:', materials) logger.log('roofMaterials loaded via fallback:', materials)
} else { } else {
@ -485,6 +493,15 @@ export function useCanvasSetting(executeEffect = true) {
}) })
} }
} }
} else {
// [ROOFMAT-TOPMENU-DIAG 2026-06-29] 저장 roofMatlCd 가 마스터에 없거나 마스터 미로드로 매칭 0건 →
// top 메뉴 지붕재 select(CanvasMenu.jsx:648)가 렌더되지 않으므로 사용자에게 에러 안내.
logger.warn('[ROOFMAT-TOPMENU-DIAG] addRoofs 0건 — select 미표시', {
objectNo: targetObjectNo,
materialsCount: materials?.length ?? 0,
savedRoofCodes: roofsArray.map((r) => r.roofMatlCd),
})
swalFire({ icon: 'error', text: getMessage('canvas.roof.material.not.found') })
} }
}) })
} catch (error) { } catch (error) {

File diff suppressed because it is too large Load Diff

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

@ -1664,6 +1664,81 @@ export const usePolygon = () => {
} }
} }
// [INNER-TJUNCTION-SPLIT 2026-06-29] 내부선(추녀 hip / 용마루 ridge)의 끝점이 다른 내부선의 몸통(strict
// interior)에 닿는 T-접합인데 그 내부선에 노드가 없으면, getPath 가 그 지점에서 꺾지 못해 한쪽 면이
// 통째로 누락된다. (A/B TYPE 박공→처마: ridge x=747(64.2→266.4) 의 내부 (747,236.4) 에 hip
// (662.3,236.4)→(747,236.4) 끝점이 닿는데 ridge 가 안 잘려 우측 6각형 면이 안 생김 — GETSPLIT-IO 진단.)
// MOVED-DIVIDER 는 auxiliaryLine 끝점만, 외곽 split 루프는 외곽선만 자른다 → inner-inner T-접합은 둘 다 미커버.
// 여기서 다른 내부선 끝점 위치에서 내부선을 split(노드 삽입)만 한다. merge 없음 — 정상 평면분할 노드를 추가만 하므로
// 기존에 닫히던 면은 불변(노드가 명시될 뿐).
{
const isInnerT = (l) => l.name === 'hip' || l.name === 'ridge'
const EPS_T = 2
const mkSegT = (proto, sp, ep) => ({
name: proto.name,
lineName: proto.lineName,
attributes: { ...proto.attributes },
startPoint: { x: sp.x, y: sp.y },
endPoint: { x: ep.x, y: ep.y },
x1: sp.x,
y1: sp.y,
x2: ep.x,
y2: ep.y,
})
const onBodyStrictT = (L, P) => {
const ax = L.startPoint.x
const ay = L.startPoint.y
const dx = L.endPoint.x - ax
const dy = L.endPoint.y - ay
const lenSq = dx * dx + dy * dy
if (lenSq < 1) return false
const t = ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
if (t <= 0.03 || t >= 0.97) return false
const px = ax + t * dx
const py = ay + t * dy
return (P.x - px) ** 2 + (P.y - py) ** 2 < EPS_T * EPS_T
}
const innerLinesT = allLines.filter(isInnerT)
if (innerLinesT.length > 1) {
const innerPts = []
innerLinesT.forEach((l) => {
innerPts.push(l.startPoint, l.endPoint)
})
let didTSplit = false
const afterT = []
allLines.forEach((L) => {
if (!isInnerT(L)) {
afterT.push(L)
return
}
const ax = L.startPoint.x
const ay = L.startPoint.y
const dx = L.endPoint.x - ax
const dy = L.endPoint.y - ay
const lenSq = dx * dx + dy * dy || 1
const tOf = (P) => ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
const cuts = [...new Map(innerPts.filter((P) => onBodyStrictT(L, P)).map((P) => [Math.round(tOf(P) * 1000), P])).values()].sort(
(p, q) => tOf(p) - tOf(q),
)
if (cuts.length === 0) {
afterT.push(L)
return
}
didTSplit = true
let cur = L.startPoint
cuts.forEach((P) => {
afterT.push(mkSegT(L, cur, P))
cur = P
})
afterT.push(mkSegT(L, cur, L.endPoint))
})
if (didTSplit) {
logger.log(`[INNER-TJUNCTION-SPLIT] inner-inner T-접합 노드 삽입 (allLines ${allLines.length}${afterT.length})`)
allLines = afterT
}
}
}
// 나눠서 중복 제거된 roof return // 나눠서 중복 제거된 roof return
let newRoofs = getSplitRoofsPoints(allLines) let newRoofs = getSplitRoofsPoints(allLines)

View File

@ -60,6 +60,7 @@
"modal.placement.initial.setting.roof.angle": "角度", "modal.placement.initial.setting.roof.angle": "角度",
"modal.placement.initial.setting.roof.material": "屋根材選択単位mm", "modal.placement.initial.setting.roof.material": "屋根材選択単位mm",
"modal.placement.initial.setting.roof.material.info": "対応可能な屋根材や足場は限定されますので、必ず事前マニュアルをご確認ください。", "modal.placement.initial.setting.roof.material.info": "対応可能な屋根材や足場は限定されますので、必ず事前マニュアルをご確認ください。",
"canvas.roof.material.not.found": "屋根材情報を読み込めませんでした。ページを再読み込みするか、屋根材設定をご確認ください。",
"modal.placement.initial.setting.rafter": "垂木", "modal.placement.initial.setting.rafter": "垂木",
"modal.roof.shape.setting": "屋根形状の設定", "modal.roof.shape.setting": "屋根形状の設定",
"modal.roof.shape.setting.ridge": "棟", "modal.roof.shape.setting.ridge": "棟",

View File

@ -60,6 +60,7 @@
"modal.placement.initial.setting.roof.angle": "각도", "modal.placement.initial.setting.roof.angle": "각도",
"modal.placement.initial.setting.roof.material": "지붕재 선택(단위mm)", "modal.placement.initial.setting.roof.material": "지붕재 선택(단위mm)",
"modal.placement.initial.setting.roof.material.info": "대응 가능한 지붕재 및 발판은 한정되므로 반드시 사전 매뉴얼을 확인하십시오.", "modal.placement.initial.setting.roof.material.info": "대응 가능한 지붕재 및 발판은 한정되므로 반드시 사전 매뉴얼을 확인하십시오.",
"canvas.roof.material.not.found": "지붕재 정보를 불러오지 못했습니다. 페이지를 새로고침하거나 지붕재 설정을 확인해 주세요.",
"modal.placement.initial.setting.rafter": "서까래", "modal.placement.initial.setting.rafter": "서까래",
"modal.roof.shape.setting": "지붕형상 설정", "modal.roof.shape.setting": "지붕형상 설정",
"modal.roof.shape.setting.ridge": "용마루", "modal.roof.shape.setting.ridge": "용마루",

View File

@ -15,6 +15,7 @@
// (dev 콘솔에서 수동: window.__checkKerabRules(roof.innerLines)) // (dev 콘솔에서 수동: window.__checkKerabRules(roof.innerLines))
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
import { debugCapture } from '@/util/debugCapture'
const ENABLED = process.env.NEXT_PUBLIC_ENABLE_LOGGING === 'true' const ENABLED = process.env.NEXT_PUBLIC_ENABLE_LOGGING === 'true'
@ -23,7 +24,7 @@ const DEFAULTS = {
angleDegEps: 1.0, // 45°/축정렬 허용 오차(도). 불변식상 정확해야 하므로 작게 — drift 를 잡는 게 목적. angleDegEps: 1.0, // 45°/축정렬 허용 오차(도). 불변식상 정확해야 하므로 작게 — drift 를 잡는 게 목적.
pointEps: 0.5, // 같은 점 판정(메모리: UI/Big.js drift 고려 넉넉히). pointEps: 0.5, // 같은 점 판정(메모리: UI/Big.js drift 고려 넉넉히).
zeroLenEps: 0.5, // 길이 0(소멸) 판정. zeroLenEps: 0.5, // 길이 0(소멸) 판정.
boxPadding: 0.5, // 박스 경계 여유. boxPadding: 1.0, // 박스 경계 여유 — 드로잉(clipCrossedHipsAtIntersection)의 PAD=1.0 과 통일.
} }
// ── 좌표/기하 헬퍼 ──────────────────────────────────────────────── // ── 좌표/기하 헬퍼 ────────────────────────────────────────────────
@ -77,10 +78,43 @@ 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'
const isRidge = (ln) => !isBox(ln) && typeOf(ln) === 'ridge' // 이 붙는 경우가 있는데(실제론 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])
}
// [KERAB-CLASSIFY-BY-GEOMETRY 2026-06-26] 이름(type) 의존 폐기 — 실측 로그에서 힙이 'default' 로 타입돼
// isHip 가 0개를 반환, 모든 힙 규칙(R-BOXINNER/R-45HIP/R-ANCHOR/R-WEDGE)이 통째로 안 돌고 PASS 였다.
// 임의 다각형·임의 라인변경에서 일관되려면 분류는 도형(방향)으로만 한다: innerLine 중 45°≈힙,
// 축정렬≈마루. (eaves/roofLine 은 roof.points 라 innerLines 에 없음 → 오분류 없음.)
// drift(50°/10° 등)는 가까운 쪽으로 분류된 뒤 R-45HIP/R-AXISRIDGE 가 각도 위반으로 잡는다.
// [KERAB-CLASSIFY-EXCLUDE-ROOFLINE 2026-06-26] roofLine(외곽 경계)이 innerLines 에 섞여 들어오는 케이스가
// 실측 로그에 있다(type:'roofLine' 끝점이 박스 코너에 닿아 R-RIDGE-VANISH 오탐). roofLine 은 inner
// 힙/마루가 아니라 경계이므로 분류에서 제외한다(도형 불변 — 이름이 아닌 "경계 vs 내부선" 의 의미 구분).
const isRoofLine = (ln) => ln?.lineName === 'roofLine' || ln?.name === 'roofLine' || ln?.attributes?.type === 'roofLine'
// [KERAB-BOUNDARY-GEOMETRIC 2026-06-26] 경계(roofLine/wallLine) 판정은 lineName 태그가 아니라 *위치(기하)* 로 한다.
// 배경(사용자 확정): roofLine = wallLine + 出幅. 出幅=0 이면 둘이 일치 → 별개가 아니라 하나의 "경계" 가족.
// lineName='roofLine' 태그는 골짜기/박공 *내부선*(name='hip')에도 과적재돼 있어(QPolygon.js:98, qpolygon-utils.js:6042 등)
// 태그만으론 경계↔내부를 못 가른다. 체커는 wallLine *내부 전용*(마루/힙/박스)이므로, "선분 양 끝점이 경계 폴리곤
// (roofPoints/wallEdges)의 한 변 위에 놓였나" 로 경계를 판정해 그 선을 검사·교정에서 제외한다(read-only 참조).
// __boundarySet 은 매 checkKerabRules 호출 시작에서 기하로 다시 채운다(기하 정보 없으면 태그로 폴백).
let __boundarySet = new WeakSet()
const isBoundary = (ln) => __boundarySet.has(ln)
const isHip = (ln) => !isBox(ln) && !isBoundary(ln) && isDiagonalHip(ln)
const isRidge = (ln) => !isBox(ln) && !isBoundary(ln) && !!coords(ln) && !isDiagonalHip(ln)
// 박스(겹침) 영역 = 같은 __targetId 의 kerabValleyOverlapLine 세그먼트들의 bbox. // [KERAB-BOX-CAP-BBOX 2026-06-26] 박스(겹침=出幅) 영역 검출 — 드로잉 경로와 통일.
// 이전엔 캡(kerabValleyOverlapLine)을 양옆 레일까지 growBandRect 로 키웠으나, 레일(긴 수평 힙)이
// 멀리 뻗는 노치 형상에서 박스가 과확장돼(예: x≈412~747) 정상 apex 마루(R-2)를 "박스 안"으로 오탐하고
// roofLine 관통 같은 진짜 위반을 가렸다. 이미 잘 동작하는 드로잉(clipCrossedHipsAtIntersection)은
// 캡 세그먼트 bbox + PAD 를 그대로 박스로 쓴다 → 체커도 동일하게 캡 bbox 만 쓴다(사용자 확정 2026-06-26).
// 퇴화축(폭≈0)은 PAD 로 얇은 띠가 돼 boundary-inclusive 제외(R-CROSS 등)엔 충분하고, strict-interior 규칙
// (R-RIDGE-VANISH/R-BOXINNER)은 얇은 박스에 내부가 없어 자연히 발화 안 함(= 과발화 방지, 의도된 동작).
const buildBoxes = (lines, pad) => { const buildBoxes = (lines, pad) => {
const groups = new Map() const groups = new Map()
for (const ln of lines) { for (const ln of lines) {
@ -95,9 +129,254 @@ const buildBoxes = (lines, pad) => {
g.maxY = Math.max(g.maxY, c.y1, c.y2) g.maxY = Math.max(g.maxY, c.y1, c.y2)
groups.set(key, g) groups.set(key, g)
} }
return [...groups.values()].map((b) => ({ minX: b.minX - pad, minY: b.minY - pad, maxX: b.maxX + pad, maxY: b.maxY + pad })) if (groups.size === 0) return []
return [...groups.values()].map((seed) => ({ minX: seed.minX - pad, minY: seed.minY - pad, maxX: seed.maxX + pad, maxY: seed.maxY + pad }))
}
// [KERAB-OVERLAP-BAND 2026-06-27] 出幅 겹침 박스의 *진짜 사각형* (사용자 확정: 박스 = H-1+H-2+H-3+B-1 4변, 단일선 아님).
// 캡(kerabValleyOverlapLine = B-1)은 박스의 한 변일 뿐 — B-1 의 두 끝점은 출폭만큼 떨어진 두 평행 힙(상·하 레일)
// 위에 놓인다. 박스 = 그 두 레일이 *겹치는 구간* × B-1 두께(出幅). buildBoxes(캡 bbox)는 다른 규칙 호환 위해
// 그대로 두고, 이 사각형은 출幅 겹침 제외 전용으로 체크 시점 실제 기하에서 따로 만든다(전역 박스 안 흔듦).
// 일반 규칙 — 좌표 하드코딩 없이 캡 방향(수직/수평) + 끝점에 닿은 평행 힙들의 합집합 구간으로 박스를 복원한다.
const buildOverlapBands = (boxLines, allLines, pad) => {
const EPS = 1.5
const isHorz = (lc) => Math.abs(lc.y1 - lc.y2) < EPS
const isVert = (lc) => Math.abs(lc.x1 - lc.x2) < EPS
// 레일 = 캡 끝점을 지나는 평행 경계선(상·하 처마힙 H-1/H-2/H-3). 주의: 체커 isHip 은 *대각선*만 hip 으로 보므로
// 축정렬(angle 0/90/180)인 H-1/H-2/H-3 은 ridge 로 분류된다 → 분류 무관하게 *축정렬 collinear 세그먼트*를 모은다.
// 캡(box)·경계(roofLine)는 제외(레일은 내부 힙/마루). collinear 세그먼트(H-2+H-3)는 합집합으로 레일 전체구간 복원.
// axis='h': value=공유 y 인 수평선들의 x 합집합. axis='v': value=공유 x 인 수직선들의 y 합집합.
const railExtent = (value, axis) => {
let lo = Infinity
let hi = -Infinity
let found = false
for (const ln of allLines) {
if (isBox(ln) || isBoundary(ln)) continue
const lc = coords(ln)
if (!lc) continue
if (axis === 'h') {
if (!isHorz(lc) || Math.abs(lc.y1 - value) > EPS) continue
lo = Math.min(lo, lc.x1, lc.x2)
hi = Math.max(hi, lc.x1, lc.x2)
} else {
if (!isVert(lc) || Math.abs(lc.x1 - value) > EPS) continue
lo = Math.min(lo, lc.y1, lc.y2)
hi = Math.max(hi, lc.y1, lc.y2)
}
found = true
}
return found ? { lo, hi } : null
}
const bands = []
for (const bx of boxLines) {
const c = coords(bx)
if (!c) continue
if (isVert(c)) {
// 캡 수직 → 레일 수평(상·하 처마힙), 박스 장축 = x.
const r1 = railExtent(c.y1, 'h')
const r2 = railExtent(c.y2, 'h')
if (!r1 || !r2) continue
const lo = Math.max(r1.lo, r2.lo)
const hi = Math.min(r1.hi, r2.hi)
if (hi - lo <= EPS) continue
bands.push({ minX: lo - pad, maxX: hi + pad, minY: Math.min(c.y1, c.y2) - pad, maxY: Math.max(c.y1, c.y2) + pad })
} else if (isHorz(c)) {
// 캡 수평 → 레일 수직, 박스 장축 = y.
const r1 = railExtent(c.x1, 'v')
const r2 = railExtent(c.x2, 'v')
if (!r1 || !r2) continue
const lo = Math.max(r1.lo, r2.lo)
const hi = Math.min(r1.hi, r2.hi)
if (hi - lo <= EPS) continue
bands.push({ minX: Math.min(c.x1, c.x2) - pad, maxX: Math.max(c.x1, c.x2) + pad, minY: lo - pad, maxY: hi + pad })
}
}
return bands
} }
const pointInBoxes = (p, boxes) => boxes.some((b) => p.x >= b.minX && p.x <= b.maxX && p.y >= b.minY && p.y <= b.maxY) const pointInBoxes = (p, boxes) => boxes.some((b) => p.x >= b.minX && p.x <= b.maxX && p.y >= b.minY && p.y <= b.maxY)
// 박스 *내부*(경계 m 안쪽)에 엄격히 들어왔나 — 박스 변 위(=상대 roofLine, 정상 종단점)는 제외.
// 납작 박스(한 축 폭<2m)는 내부가 없으므로 항상 false → 무영향.
const pointStrictlyInBoxes = (p, boxes, m) =>
boxes.some((b) => b.maxX - b.minX > 2 * m && b.maxY - b.minY > 2 * m && p.x > b.minX + m && p.x < b.maxX - m && p.y > b.minY + m && p.y < b.maxY - m)
// 선분이 박스의 *내부 사각형*(경계 m 안쪽)을 관통(가로지름)하는 첫 진입점. 끝점이 안이 아니어도
// 박스를 통과하면(들어가고 나감) "내부선에서 안 멈췄다"는 위반. 납작 박스는 내부가 없어 항상 null.
const segCrossesBoxInterior = (c, boxes, m) => {
for (const b of boxes) {
const ix0 = b.minX + m
const iy0 = b.minY + m
const ix1 = b.maxX - m
const iy1 = b.maxY - m
if (ix1 - ix0 <= 0 || iy1 - iy0 <= 0) continue // 납작 박스 = 내부 없음.
const rectSegs = [
{ x1: ix0, y1: iy0, x2: ix1, y2: iy0 },
{ x1: ix1, y1: iy0, x2: ix1, y2: iy1 },
{ x1: ix1, y1: iy1, x2: ix0, y2: iy1 },
{ x1: ix0, y1: iy1, x2: ix0, y2: iy0 },
]
for (const rs of rectSegs) {
const ip = segCross(c, rs, 0.01)
if (ip) return ip
}
}
return null
}
// [KERAB-BOX-CROSS 2026-06-26] from→to 선분이 박스(실제 경계, m=0)와 만나는 경계 교차점.
// farthest=true → from 에서 가장 *먼* 교차(= 박스를 빠져나가는 변). 박스는 出幅 겹침이라 힙은 박스안까지
// 그려져야 하고, 박스를 빠져나가 반대편으로 넘어가면 안 됨 → 빠져나가는 경계(far)에 클램프해 박스안까지만 남긴다.
const boxBoundaryCross = (from, to, boxes, farthest) => {
let best = null
let bestT = farthest ? -Infinity : Infinity
const seg = { x1: from.x, y1: from.y, x2: to.x, y2: to.y }
for (const b of boxes) {
const rectSegs = [
{ x1: b.minX, y1: b.minY, x2: b.maxX, y2: b.minY },
{ x1: b.maxX, y1: b.minY, x2: b.maxX, y2: b.maxY },
{ x1: b.maxX, y1: b.maxY, x2: b.minX, y2: b.maxY },
{ x1: b.minX, y1: b.maxY, x2: b.minX, y2: b.minY },
]
for (const rs of rectSegs) {
const ip = segCross(seg, rs, 0.01)
if (!ip) continue
const t = Math.hypot(ip.x - from.x, ip.y - from.y)
if (farthest ? t > bestT : t < bestT) {
bestT = t
best = ip
}
}
}
return best
}
// [KERAB-RAY-ROOFLINE 2026-06-26] 반직선(origin + t·u, t>0)이 roofLine(다각형 변)과 만나는 가장 가까운 forward 교차.
// "힙 생성"의 완성 = 허공에 뜬 힙 끝을 roofLine 까지 연장(불변식: 라인은 roofLine 까지)에 쓴다.
const rayHitPolygon = (ox, oy, ux, uy, pts) => {
let best = null
let bestT = Infinity
for (let i = 0; i < pts.length; i++) {
const a = pts[i]
const b = pts[(i + 1) % pts.length]
if (!a || !b) continue
const ex = b.x - a.x
const ey = b.y - a.y
const denom = ux * ey - uy * ex
if (Math.abs(denom) < 1e-9) continue // 평행.
const wx = a.x - ox
const wy = a.y - oy
const t = (wx * ey - wy * ex) / denom // origin 으로부터의 거리(방향계수).
const s = (wx * uy - wy * ux) / denom // 변 위 매개변수(0~1).
if (t > 0.5 && s >= -0.01 && s <= 1.01 && t < bestT) {
bestT = t
best = { x: ox + ux * t, y: oy + uy * t }
}
}
return best
}
// [KERAB-RAY-SEG 2026-06-26] origin 에서 (ux,uy) 단위방향으로 쏜 ray 가 *다른 힙/마루 선분* 과 만나는 최근접점.
// "힙·마루는 박스·힙·마루를 만나면 멈춘다" 를 구현하려고 R-ANCHOR 연장이 roofLine 보다 먼저 만나는 선을 찾는다.
// self 제외, 박스 내부 교점 제외(he2·he3 박스 시각크로스는 만남이 아님). 반환 t = origin 으로부터의 거리.
const rayHitSegments = (ox, oy, ux, uy, segs, selfRef, boxes, accept) => {
const ok = accept || ((ref) => isHip(ref) || isRidge(ref))
let best = null
let bestT = Infinity
for (const sg of segs) {
if (sg.ref === selfRef) continue
if (!ok(sg.ref)) continue
const ex = sg.x2 - sg.x1
const ey = sg.y2 - sg.y1
const denom = ux * ey - uy * ex
if (Math.abs(denom) < 1e-9) continue
const wx = sg.x1 - ox
const wy = sg.y1 - oy
const t = (wx * ey - wy * ex) / denom // ray 위 거리.
const s = (wx * uy - wy * ux) / denom // 선분 위 매개변수(0~1).
if (t > 0.5 && s >= -0.01 && s <= 1.01 && t < bestT) {
const hit = { x: ox + ux * t, y: oy + uy * t }
if (boxes && pointInBoxes(hit, boxes)) continue // 박스 안 만남은 제외.
bestT = t
best = { x: hit.x, y: hit.y, t }
}
}
return best
}
// 점 → 선분 최단거리.
const distPointSeg = (px, py, x1, y1, x2, y2) => {
const dx = x2 - x1
const dy = y2 - y1
const L2 = dx * dx + dy * dy
if (L2 < 1e-9) return Math.hypot(px - x1, py - y1)
let t = ((px - x1) * dx + (py - y1) * dy) / L2
t = Math.max(0, Math.min(1, t))
return Math.hypot(px - (x1 + t * dx), py - (y1 + t * dy))
}
// 방향(spoke) 기반 분류 — 이름(typeOf)이 'default' 여도 기하학적으로 힙/마루를 판정한다.
// 45°(±SPOKE_TOL) = 힙 방향, 축정렬(0/90/180/270) = 마루 방향. R-WEDGE 가 \|/ 를 이름과 무관하게 잡게 함.
const SPOKE_TOL = 5
const dirAngle360 = (d) => ((Math.atan2(d.y, d.x) * 180) / Math.PI + 360) % 360
const isHipSpoke = (d) => Math.min(...[45, 135, 225, 315].map((t) => Math.abs(dirAngle360(d) - t))) <= SPOKE_TOL
const isRidgeSpoke = (d) => Math.min(...[0, 90, 180, 270, 360].map((t) => Math.abs(dirAngle360(d) - t))) <= SPOKE_TOL
const angDiff360 = (a, b) => {
const d = Math.abs(a - b) % 360
return d > 180 ? 360 - d : d
}
// [KERAB-JUNCTION-PEAK 2026-06-26] 한 교점의 "봉우리(peak)" 방향. 정확히 두 힙이 모여 축정렬 봉우리를 이룰 때만
// { ang, ux, uy }, 아니면 null. 두 힙 바깥(eaves)방향 합의 *반대* = 마루가 서야 할 봉우리 방향.
// R-RIDGE-GEN 검출(부재 판정)과 fix(생성) 가 같은 정의를 공유 — "두 힙이 만나면 마루 생성".
const junctionPeak = (j) => {
const hips = j.spokes.filter((s) => isHipSpoke(s.dir))
if (hips.length !== 2) return null
const bx = hips[0].dir.x + hips[1].dir.x
const by = hips[0].dir.y + hips[1].dir.y
const bm = Math.hypot(bx, by)
if (bm < 0.3) return null // 두 힙이 정반대(일직선) = 봉우리 아님.
const ux = -bx / bm
const uy = -by / bm
const ang = dirAngle360({ x: ux, y: uy })
const axisDev = Math.min(...[0, 90, 180, 270, 360].map((t) => Math.abs(ang - t)))
if (axisDev > 8) return null // 축정렬 봉우리만(대각=apex/박스변 → 마루 대상 아님).
return { ang, ux, uy }
}
// 끝점 P 가 roofLine·다른 라인(자기 제외)·박스 중 하나에 닿아 앵커돼 있나.
const isAnchored = (p, selfRef, ctx) => {
const eps = ctx.anchorEps
const pts = ctx.roofPoints
if (pts && pts.length >= 2) {
for (let i = 0; i < pts.length; i++) {
const a = pts[i]
const b = pts[(i + 1) % pts.length]
if (a && b && distPointSeg(p.x, p.y, a.x, a.y, b.x, b.y) <= eps) return true
}
}
for (const s of ctx.anchorSegs) {
if (s.ref === selfRef) continue
if (distPointSeg(p.x, p.y, s.x1, s.y1, s.x2, s.y2) <= eps) return true
}
return false
}
// [KERAB-EDGE-HIP 2026-06-26] roofLine 변 e 위에 끝점이 놓인 힙의 개수. includeCorners=false 면 변의 양 끝(코너)에
// 놓인 힙은 제외 — 케라바는 인접 처마와 코너를 공유하므로, 처마쪽 힙이 공유코너에 닿은 것을 케라바 위반으로 오탐하지
// 않으려는 것. 처마(eaves) 판정엔 코너 포함(코너에서 뻗는 힙도 그 처마의 힙).
const countHipEndpointsOnEdge = (e, ctx, includeCorners) => {
const eps = ctx.edgeEps
let n = 0
for (const h of ctx.hipLines || []) {
const c = coords(h)
if (!c) continue
for (const p of [
{ x: c.x1, y: c.y1 },
{ x: c.x2, y: c.y2 },
]) {
if (distPointSeg(p.x, p.y, e.x1, e.y1, e.x2, e.y2) > eps) continue
if (!includeCorners && (pointEq(p, { x: e.x1, y: e.y1 }, eps) || pointEq(p, { x: e.x2, y: e.y2 }, eps))) continue
n++
}
}
return n
}
// ── 규칙 정의 (격리된 순수 판정. 여기에 한 줄씩 추가/수정/주석처리) ───────── // ── 규칙 정의 (격리된 순수 판정. 여기에 한 줄씩 추가/수정/주석처리) ─────────
// 종류: // 종류:
@ -146,9 +425,37 @@ const RULES = [
check: (ca, cb, la, lb, ctx) => { check: (ca, cb, la, lb, ctx) => {
const ip = segCross(ca, cb, ctx.pointEps) const ip = segCross(ca, cb, ctx.pointEps)
if (!ip) return null if (!ip) return null
if (pointInBoxes(ip, ctx.boxes)) return null // 겹침 박스 = 규칙 적용 제외 if (pointInBoxes(ip, ctx.exclBoxes)) return null // 겹침 박스 내부 교점 = 절삭 열외(出幅 사각형 포함)
return { point: ip, detail: `${typeOf(la)}${typeOf(lb)} 가 (${ip.x.toFixed(1)}, ${ip.y.toFixed(1)}) 에서 관통` } return { point: ip, detail: `${typeOf(la)}${typeOf(lb)} 가 (${ip.x.toFixed(1)}, ${ip.y.toFixed(1)}) 에서 관통` }
}, },
// [KERAB-RULE-FIX 2026-06-26] "만나면 멈추거나 절삭" — 관통한 두 선을 교점 ip 에서 종단(절삭). 절삭 *우선순위*:
// 힙↔마루 = **힙 보존, 마루만 절삭**(사용자 확정). 힙↔힙 = 둘 다 ip 에서 종단(→ R-RIDGE-GEN 이 마루 생성).
// 각 선은 앵커된 끝(roofLine 코너 등)을 남기고 허공쪽 끝을 ip 로 당긴다. 양끝 앵커/허공이면 어느 쪽 자를지
// 모호하므로 보류(null) — 추측 절삭 금지. ip 는 박스 밖(check 에서 제외)이라 he2·he3 겹침은 안 건드림.
fix: (ca, cb, la, lb, ctx, r) => {
if (!r || !r.point) return null
const ip = r.point
const pickMove = (c, ln) => {
const a0 = isAnchored({ x: c.x1, y: c.y1 }, ln, ctx)
const a1 = isAnchored({ x: c.x2, y: c.y2 }, ln, ctx)
if (a0 && !a1) return { x2: ip.x, y2: ip.y }
if (a1 && !a0) return { x1: ip.x, y1: ip.y }
return null // 모호 — 절삭 보류.
}
const multiSet = []
const push = (c, ln) => {
const m = pickMove(c, ln)
if (m) multiSet.push({ line: ln, set: m })
}
// 힙↔마루: 마루만 절삭(힙 보존). 그 외(힙↔힙·마루↔마루): 둘 다 종단.
if (isHip(la) && isRidge(lb)) push(cb, lb)
else if (isHip(lb) && isRidge(la)) push(ca, la)
else {
push(ca, la)
push(cb, lb)
}
return multiSet.length ? { multiSet } : null
},
}, },
{ {
id: 'R-WEDGE', id: 'R-WEDGE',
@ -156,9 +463,10 @@ const RULES = [
kind: 'junction', kind: 'junction',
enabled: true, enabled: true,
check: (j, ctx) => { check: (j, ctx) => {
if (pointInBoxes(j.point, ctx.boxes)) return null if (pointInBoxes(j.point, ctx.exclBoxes)) return null // 박스 내부 교점 = 멈춤(쐐기) 열외(出幅 사각형 포함)
const hips = j.spokes.filter((s) => isHip(s.line)) // 이름이 아니라 방향으로 분류 — 'default' 타입 힙/마루도 \|/ 로 잡는다.
const ridges = j.spokes.filter((s) => isRidge(s.line)) const hips = j.spokes.filter((s) => isHipSpoke(s.dir))
const ridges = j.spokes.filter((s) => isRidgeSpoke(s.dir))
if (hips.length < 2 || ridges.length < 1) return null if (hips.length < 2 || ridges.length < 1) return null
for (const r of ridges) { for (const r of ridges) {
for (let i = 0; i < hips.length; i++) { for (let i = 0; i < hips.length; i++) {
@ -172,9 +480,228 @@ const RULES = [
return null return null
}, },
}, },
{
id: 'R-ANCHOR',
desc: '힙·마루의 모든 끝점은 roofLine·다른 힙/마루/박스에 닿아야 한다(허공 끝점 금지). 막을 게 없으면 roofLine 까지 그려져야 함.',
kind: 'line',
enabled: true,
applies: (ln) => isHip(ln) || isRidge(ln),
// [KERAB-RULE-FIX 2026-06-26] 케라바→처마의 기본 = "마루 삭제 + 힙 생성". 그 "힙 생성"의 완성을 여기서 교정한다:
// · 허공에 뜬 *힙* → roofLine 까지 자기 방향대로 연장(불변식: 라인은 roofLine 까지). 각도(45°) 보존.
// · 허공에 뜬 *마루* → 케라바→처마에서 마루는 소멸 대상이므로 제거(잔존 중앙마루 = R-2 류).
fix: (ln, c, ctx, r) => {
if (!r || !r.point) return null
if (isRidge(ln)) return { remove: true } // 마루는 소멸 — 허공 마루는 잔존물.
if (!isHip(ln) || !ctx.roofPoints || ctx.roofPoints.length < 3) return null
const ends = [
{ x: c.x1, y: c.y1 },
{ x: c.x2, y: c.y2 },
]
const di = pointEq(ends[0], r.point, ctx.pointEps) ? 0 : 1 // 허공 끝 인덱스.
const anchorEnd = ends[1 - di] // 고정(앵커)된 끝 = 연장 기준.
const ux0 = r.point.x - anchorEnd.x
const uy0 = r.point.y - anchorEnd.y
const m = Math.hypot(ux0, uy0)
if (m < 1e-6) return null
const ux = ux0 / m
const uy = uy0 / m
// [KERAB-RULE-FIX 2026-06-26] "힙은 만나면 멈춘다" — roofLine 까지 가기 전에 다른 *힙* 을 만나면 그 만남점에서
// 멈춘다(절삭). he1·he4 처럼 두 힙이 서로 만나는 경우: 짧으면 연장돼 만남점에 닿고, 길면 만남점까지 당겨진다.
// 둘 다 "만남점에서 종단" → 그 자리에 R-RIDGE-GEN 이 마루 생성. 박스 내부 만남은 rayHitSegments 가 제외.
// *마루* 는 만남 대상에서 뺀다 — 절삭 우선순위상 힙이 보존되고 마루가 잘리므로(R-CROSS), 힙은 마루에 안 멈춤.
const polyHit = rayHitPolygon(anchorEnd.x, anchorEnd.y, ux, uy, ctx.roofPoints)
const segHit = rayHitSegments(anchorEnd.x, anchorEnd.y, ux, uy, ctx.anchorSegs, ln, ctx.exclBoxes, isHip)
const polyT = polyHit ? Math.hypot(polyHit.x - anchorEnd.x, polyHit.y - anchorEnd.y) : Infinity
const hit = segHit && segHit.t < polyT - ctx.pointEps ? segHit : polyHit
if (!hit) return null
return di === 0 ? { set: { x1: hit.x, y1: hit.y } } : { set: { x2: hit.x, y2: hit.y } }
},
check: (c, ctx, ln) => {
const ends = [
{ x: c.x1, y: c.y1 },
{ x: c.x2, y: c.y2 },
]
for (const p of ends) {
if (isAnchored(p, ln, ctx)) continue
return { point: p, detail: `끝점 (${p.x.toFixed(1)}, ${p.y.toFixed(1)}) 가 어떤 선(roofLine/힙/마루/박스)에도 안 닿음(허공)` }
}
return null
},
},
{
id: 'R-BOXINNER',
desc: '박스(골짜기=두 지붕면 出幅 겹침구역)는 선이 그 안까지 상대적으로 그려져야 하는 영역이다 — 힙이 박스 안에서 종단하는 것은 정상(겹침 표현). 위반은 박스를 *관통해 반대편으로 빠져나가는* 경우뿐(들어갔다 다시 나감). 이때 박스 반대편 경계(出幅 끝)에서 멈춰야 한다.',
kind: 'line',
enabled: true,
applies: (ln) => isHip(ln),
// [KERAB-RULE-FIX 2026-06-26] 교정: 박스는 出幅 겹침 표현이라 선이 박스 안까지 그려지는 게 정상.
// 잘못된 건 박스를 관통해 *반대편으로 빠져나가는* 힙뿐 → 빠져나간 쪽 끝을 박스의 *반대편(먼) 경계*로 당겨
// 박스 안에서 종단시킨다(박스 밖으로 못 나가게). 앵커(코너)쪽 보존.
// · 양끝 모두 박스 밖 + 박스 내부 관통일 때만 위반. 박스 안에서 끝나는 종단형(he2·he3 겹침)은 정상 → 보류.
// · 양끝 앵커/양끝 허공이면 어느 면의 힙인지 모호 → 보류(추측 절삭 금지, 위반은 로그에 남김).
fix: (ln, c, ctx) => {
const e0 = { x: c.x1, y: c.y1 }
const e1 = { x: c.x2, y: c.y2 }
// 박스 안에서 종단(끝점이 박스 내부)이면 정상 — 절삭 안 함.
if (pointStrictlyInBoxes(e0, ctx.exclBoxes, ctx.boxInnerEps) || pointStrictlyInBoxes(e1, ctx.exclBoxes, ctx.boxInnerEps)) return null
if (!segCrossesBoxInterior(c, ctx.exclBoxes, ctx.boxInnerEps)) return null
const a0 = isAnchored(e0, ln, ctx)
const a1 = isAnchored(e1, ln, ctx)
if (a0 === a1) return null
const keep = a0 ? e0 : e1
const move = a0 ? e1 : e0
// 박스를 빠져나간 끝을 *먼* 경계(出幅 끝=관통 출구변)로 당겨 박스 안에서 멈추게 한다.
const exit = boxBoundaryCross(keep, move, ctx.exclBoxes, true)
if (!exit) return null
return a0 ? { set: { x2: exit.x, y2: exit.y } } : { set: { x1: exit.x, y1: exit.y } }
},
check: (c, ctx, ln) => {
const e0 = { x: c.x1, y: c.y1 }
const e1 = { x: c.x2, y: c.y2 }
// 박스 안에서 종단(끝점이 박스 내부)이면 정상(出幅 겹침 표현) — 위반 아님.
if (pointStrictlyInBoxes(e0, ctx.exclBoxes, ctx.boxInnerEps) || pointStrictlyInBoxes(e1, ctx.exclBoxes, ctx.boxInnerEps)) return null
// 박스를 관통해 반대편으로 빠져나감(양끝 다 박스 밖 + 내부 가로지름) — 위반.
const ip = segCrossesBoxInterior(c, ctx.exclBoxes, ctx.boxInnerEps)
if (ip) {
return { point: ip, detail: `(${ip.x.toFixed(1)}, ${ip.y.toFixed(1)}) 에서 박스를 관통해 반대편으로 빠져나감 — 박스 반대편 경계(出幅 끝)에서 멈춰야` }
}
return null
},
},
{
id: 'R-RIDGE-VANISH',
desc: '케라바→처마 변환에서 native 마루는 양 코너의 두 힙(HE)으로 펼쳐지며 소멸한다(R5 Case A 의 역: 처마→케라바 = "두 힙 삭제 + 마루 생성" → 역 = "마루 삭제 + 두 힙 생성"). 마루의 존재 근거 = 두 힙의 수렴인데, 박스(겹침)에 막혀 두 힙이 만나지 못하면 그 근거가 없다 → 마루는 남으면 안 된다. 마루 끝점이 박스에 닿거나 들어가 있으면 = 삭제됐어야 할 마루가 잔존 = 위반(박스 진입변에 단축해 남기는 것도 금지 — 힙은 진입변에서 멈춰도 되지만 마루는 박스에 관여 불가).',
kind: 'line',
enabled: true,
applies: (ln) => isRidge(ln),
// [KERAB-RULE-FIX 2026-06-26] 교정: 박스 내부에 잔존한 마루(예: A타입을 사방처마로 오인해 재생성된 R-2)는
// 소멸했어야 하므로 그 마루 객체를 제거한다(라인을 따라가며 체크되면 바로 수정 — 원래 설계 목표).
fix: () => ({ remove: true }),
check: (c, ctx, ln) => {
if (!ctx.boxes.length) return null
const ends = [
{ x: c.x1, y: c.y1 },
{ x: c.x2, y: c.y2 },
]
// (1) [KERAB-RIDGE-VANISH-STRICT 2026-06-26] 박스 *내부*(strict) 에 마루 끝점이 있을 때 위반. 경계(코너)
// 접촉은 정상 — 박스 코너는 roofLine·축정렬 라인과 좌표를 공유하므로 경계포함 판정만으론 오탐을 낳는다.
for (const p of ends) {
if (pointStrictlyInBoxes(p, ctx.boxes, ctx.boxInnerEps)) {
return {
point: p,
detail: `마루 끝점 (${p.x.toFixed(1)}, ${p.y.toFixed(1)}) 이 박스(겹침) 내부에 있음 — 케라바→처마에서 마루는 두 힙으로 펼쳐져 소멸했어야(잔존 금지)`,
}
}
}
// (2) [KERAB-RIDGE-VANISH-DANGLE 2026-06-26] 한 끝이 박스에 닿고(경계/내부) 다른 끝이 허공(미앵커) = 소멸했어야
// 할 중앙마루 잔존(R-2). 박스(겹침)에 막혀 두 힙이 못 만난 자리로 떨어진 마루는 존재근거가 없다. 끝점이
// 박스 *경계*에 걸쳐 strict 내부를 빠져나가는 R-2 를 잡는다. 정상 마루는 박스에 끝나도 반대끝이 앵커됨 → 오탐 X.
const nearBox = (p) =>
ctx.boxes.some(
(b) => p.x >= b.minX - ctx.boxInnerEps && p.x <= b.maxX + ctx.boxInnerEps && p.y >= b.minY - ctx.boxInnerEps && p.y <= b.maxY + ctx.boxInnerEps,
)
const touch = [nearBox(ends[0]), nearBox(ends[1])]
const anch = [isAnchored(ends[0], ln, ctx), isAnchored(ends[1], ln, ctx)]
for (let i = 0; i < 2; i++) {
const j = 1 - i
if (touch[i] && !anch[j]) {
return {
point: ends[j],
detail: `마루 한끝이 박스(겹침)에 닿고 다른끝 (${ends[j].x.toFixed(1)}, ${ends[j].y.toFixed(1)}) 은 허공 — 두 힙이 박스에 막혀 못 만난 자리의 잔존 중앙마루(소멸 대상)`,
}
}
}
return null
},
},
{
id: 'R-RIDGE-GEN',
desc: '힙이 만나면(수렴) 그 교점에 마루가 *생성*돼야 한다. 처마→케라바 = "두 힙 삭제 + 마루 생성"(R5 Case A), 그 역(케라바→처마)도 새로 생긴 두 힙(예: he1·he4)이 한 점에서 만나면 그 자리에 마루가 새로 선다. 정확히 두 힙이 한 점에 모여 축정렬 봉우리(peak)를 이루는데 그 봉우리축 방향으로 마루 spoke 가 하나도 없으면 = 마루 미생성 = 위반. (R-WEDGE 는 \\|/ 끼임을, R-RIDGE-VANISH 는 박스 내부 잔존을 잡고, 이 규칙은 정반대 — 생겼어야 할 마루의 부재를 잡는다.)',
kind: 'junction',
enabled: true,
check: (j, ctx) => {
// [KERAB-OVERLAP-BAND 2026-06-27] 出幅 겹침은 "겹침의 표현"이라 두 힙이 서로 다른 면 위에 스택돼 *크로스/만난 것처럼
// 보일 뿐* 실제 수렴이 아니다(사용자 반복 강조). 캡 bbox(ctx.boxes)는 폭0 으로 he2·he3 시각크로스를 놓치므로
// exclBoxes(캡 + 出幅 사각형 overlapBands)로 박스 내부 "보이는 크로스"를 정상 겹침으로 제외 → 마루 생성 안 함.
if (pointInBoxes(j.point, ctx.exclBoxes)) return null
// [KERAB-INTO-BOX 2026-06-27] 봉우리에서 뻗은 어떤 라인의 *먼 끝*이 出幅 박스 안이면 = 그 라인이 겹침으로
// "그냥 지나가는" 중(사용자: 박스 안에서 잘 만남 → 지나가면 됨). 마루가 박스로 들어가 보이는 것이라 별도
// 마루 생성 의무 없음 → 정상. (대각이라 힙으로 재분류돼 ridges 필터에서 빠지는 ridge 를 구제 — 오발 방지.)
for (const s of j.spokes) {
const sc = coords(s.line)
if (!sc) continue
const d0 = (sc.x1 - j.point.x) ** 2 + (sc.y1 - j.point.y) ** 2
const d1 = (sc.x2 - j.point.x) ** 2 + (sc.y2 - j.point.y) ** 2
const far = d0 >= d1 ? { x: sc.x1, y: sc.y1 } : { x: sc.x2, y: sc.y2 }
if (pointStrictlyInBoxes(far, ctx.exclBoxes, ctx.boxInnerEps)) return null
}
const peak = junctionPeak(j)
if (!peak) return null // 정확히 두 힙이 축정렬 봉우리를 이룰 때만.
const ridges = j.spokes.filter((s) => isRidgeSpoke(s.dir))
for (const r of ridges) {
if (angDiff360(dirAngle360(r.dir), peak.ang) <= 25) return null // 봉우리축 방향 마루 존재 → 정상.
}
return {
point: j.point,
peak,
detail: `두 힙이 (${j.point.x.toFixed(1)}, ${j.point.y.toFixed(1)}) 에서 수렴했으나 봉우리축(${peak.ang.toFixed(0)}°) 방향 마루가 없음 — 힙이 만나면 마루가 생성돼야 함(미생성)`,
}
},
// [KERAB-RULE-FIX 2026-06-26] 교정: 봉우리축 방향으로 *마주보는* 다른 봉우리(두 힙 수렴) 교점을 찾아 그 사이에 마루를
// 새로 생성한다(create). 마주보는 짝이 없거나, 생성선이 박스(겹침)를 가로지르면(=R-2 재생성) 생성 안 함(null).
// "두 힙이 만나면 마루 생성, 단 박스 안 제외" 를 그대로 구현. 끝점 단축/제거가 아니라 신규 라인이라 action=create.
fix: (j, ctx, r) => {
if (!r || !r.peak || !Array.isArray(ctx.junctions)) return null
const { ux, uy, ang } = r.peak
const o = j.point
let best = null
let bestT = Infinity
for (const k of ctx.junctions) {
if (k === j) continue
const vx = k.point.x - o.x
const vy = k.point.y - o.y
const t = vx * ux + vy * uy // 봉우리 ray 전방 거리.
if (t <= 1) continue
const perp = Math.abs(vx * -uy + vy * ux) // ray 로부터 수직 offset.
if (perp > ctx.pointEps + 1) continue
const kp = junctionPeak(k)
if (!kp) continue
if (angDiff360(kp.ang, ang + 180) > 20) continue // 상대 봉우리가 *마주봐야*(반대방향) 마루 양끝.
if (t < bestT) {
bestT = t
best = k
}
}
if (!best) return null
const seg = { x1: o.x, y1: o.y, x2: best.point.x, y2: best.point.y }
if (segCrossesBoxInterior(seg, ctx.exclBoxes, ctx.boxInnerEps)) return null // 박스 관통 마루 = R-2 → 생성 금지(出幅 사각형 포함).
return { create: { x1: o.x, y1: o.y, x2: best.point.x, y2: best.point.y, name: 'ridge' } }
},
},
{
id: 'R-EAVES-HIP',
desc: '처마(eaves) 변에는 힙이 최소 1개 있어야 한다(코너 포함). 케라바→처마 = "마루 삭제 + 힙 생성" 이므로 처마인데 힙 0 = 힙 미생성 위반. (opts.edges 로 변 종류가 주입될 때만 동작.)',
kind: 'edge',
enabled: true,
applies: (e) => e.kind === 'eaves',
check: (e, ctx) => {
if (countHipEndpointsOnEdge(e, ctx, true) >= 1) return null
return { detail: `처마변 (${e.x1.toFixed(1)},${e.y1.toFixed(1)})-(${e.x2.toFixed(1)},${e.y2.toFixed(1)}) 에 힙 없음 — 처마는 힙 ≥1(미생성)` }
},
},
{
id: 'R-KERAB-HIP',
desc: '케라바(gable) 변에는 힙이 존재하면 안 된다(공유 코너 제외). 케라바 = 마루가 향하는 변, 힙 금지. 케라바변 중간에 힙 끝점이 놓이면 위반.',
kind: 'edge',
enabled: true,
applies: (e) => e.kind === 'kerab',
check: (e, ctx) => {
const n = countHipEndpointsOnEdge(e, ctx, false)
if (n === 0) return null
return { detail: `케라바변 (${e.x1.toFixed(1)},${e.y1.toFixed(1)})-(${e.x2.toFixed(1)},${e.y2.toFixed(1)}) 에 힙 ${n}개 — 케라바는 힙 금지` }
},
},
// ── 후속 추가 자리 ────────────────────────────────────────────── // ── 후속 추가 자리 ──────────────────────────────────────────────
// { id: 'R-FLOATING', desc: '양끝 내부(roofLine 미접촉) 마루 = underdetermined 후보', kind: 'line', enabled: false, ... },
// { id: 'R-HIP-ANCHOR', desc: '힙 끝 하나는 코너(roofLine)에 앵커', kind: 'line', enabled: false, ... },
] ]
// v(=r) 가 a→b 의 *작은* 각 사이(cone)에 있나. // v(=r) 가 a→b 의 *작은* 각 사이(cone)에 있나.
@ -196,9 +723,12 @@ const buildJunctions = (lines, eps) => {
node.spokes.push({ line, dir: outDir }) node.spokes.push({ line, dir: outDir })
} }
for (const ln of lines) { for (const ln of lines) {
if (!(isHip(ln) || isRidge(ln))) continue if (isBox(ln)) continue
const c = coords(ln) const c = coords(ln)
if (!c || len(c) <= eps) continue if (!c || len(c) <= eps) continue
const sdir = dir(c)
// 이름(hip/ridge) 또는 방향(45°/축정렬)으로 힙·마루 후보면 junction 에 포함. R-WEDGE 가 \|/ 를 놓치지 않게.
if (!(isHip(ln) || isRidge(ln) || isHipSpoke(sdir) || isRidgeSpoke(sdir))) continue
const s = { x: c.x1, y: c.y1 } const s = { x: c.x1, y: c.y1 }
const e = { x: c.x2, y: c.y2 } const e = { x: c.x2, y: c.y2 }
addSpoke(s, dir(c), ln) // s 에서 바깥 = s→e addSpoke(s, dir(c), ln) // s 에서 바깥 = s→e
@ -227,10 +757,70 @@ export function checkKerabRules(lines, opts = {}) {
pointEps: opts.pointEps ?? DEFAULTS.pointEps, pointEps: opts.pointEps ?? DEFAULTS.pointEps,
zeroLenEps: opts.zeroLenEps ?? DEFAULTS.zeroLenEps, zeroLenEps: opts.zeroLenEps ?? DEFAULTS.zeroLenEps,
} }
ctx.boxes = buildBoxes(lines, opts.boxPadding ?? DEFAULTS.boxPadding) ctx.roofPoints = Array.isArray(opts.roofPoints) ? opts.roofPoints : null
// [KERAB-BOUNDARY-GEOMETRIC 2026-06-26] 경계 선(roofLine/wallLine)을 *위치*로 식별 → __boundarySet 에 등록 →
// isHip/isRidge 가 자동 제외. roofPoints(외곽) 변 + opts.wallEdges(wall.baseLines, 出幅 안쪽) 양쪽을 경계로 본다.
// 出幅=0 이면 두 폴리곤이 겹쳐 한 번에 잡힌다. 기하 정보가 전혀 없으면(수동 콘솔 호출 등) 태그(isRoofLine)로 폴백.
__boundarySet = new WeakSet()
{
const bEps = opts.boundaryEps ?? 1.5
const bEdges = []
if (ctx.roofPoints && ctx.roofPoints.length >= 2) {
for (let i = 0; i < ctx.roofPoints.length; i++) {
const a = ctx.roofPoints[i]
const b = ctx.roofPoints[(i + 1) % ctx.roofPoints.length]
if (a && b) bEdges.push({ x1: a.x, y1: a.y, x2: b.x, y2: b.y })
}
}
if (Array.isArray(opts.wallEdges)) for (const e of opts.wallEdges) if (e) bEdges.push({ x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2 })
const onEdge = (c) =>
bEdges.some((e) => distPointSeg(c.x1, c.y1, e.x1, e.y1, e.x2, e.y2) <= bEps && distPointSeg(c.x2, c.y2, e.x1, e.y1, e.x2, e.y2) <= bEps)
for (const ln of lines) {
if (isBox(ln)) continue
const c = coords(ln)
if (!c) continue
if (bEdges.length ? onEdge(c) : isRoofLine(ln)) __boundarySet.add(ln) // 기하 있으면 위치로, 없으면 태그 폴백.
}
}
// [KERAB-BOX-EXTERNAL 2026-06-26] 박스(kerabValleyOverlapLine)는 outer(polygon.lines)에 살아 innerLines 에 없다 →
// 호출측이 opts.boxLines 로 따로 넘긴다. 박스는 (1) buildBoxes(겹침구역 검출) (2) anchorSegs(끝점 앵커 대상)
// 에만 합쳐 쓰고, 규칙 iteration 의 `lines`(=roof.innerLines)에는 섞지 않는다 — checkAndFixKerabRules 의
// 반복 교정이 roof.innerLines 만 splice/push 하므로 검사 배열과 분리해야 mutation 의미가 어긋나지 않는다.
const boxLines = Array.isArray(opts.boxLines) ? opts.boxLines : []
const linesWithBox = boxLines.length ? lines.concat(boxLines) : lines
ctx.boxes = buildBoxes(linesWithBox, opts.boxPadding ?? DEFAULTS.boxPadding)
// [KERAB-OVERLAP-BAND 2026-06-27] 出幅 겹침 제외 전용 사각형(캡 + 상·하 평행힙 겹침구간). 전역 ctx.boxes 와 분리.
ctx.overlapBands = buildOverlapBands(boxLines, linesWithBox, opts.boxPadding ?? DEFAULTS.boxPadding)
// [KERAB-BOX-INTERIOR-EXCL 2026-06-27] 사용자 원칙: 出幅 겹침 박스 *내부 교점*은 절삭·멈춤·생성 등 모든 상호작용이
// 열외다. 박스는 "겹침의 표현"이라 내부 교점이 실제 만남이 아니기 때문. 폭0 캡 bbox(ctx.boxes)만으론 박스 내부를
// 못 잡으므로, 캡 + 진짜 出幅 사각형(overlapBands)을 합친 단일 제외존을 절삭/멈춤/생성 전 규칙에 일관 적용한다.
ctx.exclBoxes = ctx.boxes.concat(ctx.overlapBands || [])
ctx.boxInnerEps = opts.boxInnerEps ?? 2.0
ctx.anchorEps = opts.anchorEps ?? 1.0
ctx.edgeEps = opts.edgeEps ?? 2.0 // 변 위 힙 끝점 판정(roofLine drift 고려).
ctx.edges = Array.isArray(opts.edges) ? opts.edges : null // 처마/케라바 변 분류. line 규칙(R-RIDGE-VANISH)도 참조.
ctx.hipLines = lines.filter(isHip) // R-EAVES-HIP/R-KERAB-HIP 가 변별 힙 끝점 카운트에 사용.
ctx.anchorSegs = []
for (const ln of linesWithBox) {
const c = coords(ln)
if (c) ctx.anchorSegs.push({ x1: c.x1, y1: c.y1, x2: c.x2, y2: c.y2, ref: ln })
}
const violations = [] const violations = []
const push = (rule, extra) => violations.push({ rule: rule.id, severity: 'error', ...extra }) const push = (rule, extra) => violations.push({ rule: rule.id, severity: 'error', ...extra })
// [KERAB-RULE-FIX 2026-06-26] 위반 발견 시 규칙이 정의한 교정 액션을 모은다. 실제 canvas/innerLines 변형은
// 체크함수가 직접 하지 않고(이식성·순수성 유지) 호출측(canvas 접근 가능)이 fixes 를 받아 적용한다.
// action 예: { remove:true } (라인 제거), { set:{x1,y1,x2,y2} } (끝점 단축). opts.apply 일 때만 수집.
const fixes = []
// [KERAB-FIXRULES-ALLOWLIST 2026-06-26] opts.fixRules 가 배열이면 그 id 의 규칙만 교정(fix) 적용 대상.
// 메뉴별 라우팅용: 처마변경은 생성/절삭(R-CROSS·R-RIDGE-GEN)만 타고 삭제 규칙(R-ANCHOR·R-RIDGE-VANISH remove)은
// 배제한다(박스 파괴 방지). 검출·로그(violations)는 전 규칙 그대로 — 화이트리스트는 *fix* 에만 작용.
const fixAllowed = (id) => !Array.isArray(opts.fixRules) || opts.fixRules.includes(id)
const collectFix = (rule, ln, c, r) => {
if (!opts.apply || typeof rule.fix !== 'function' || !fixAllowed(rule.id)) return
const action = rule.fix(ln, c, ctx, r)
if (action) fixes.push({ rule: rule.id, line: ln, action })
}
const checked = lines.filter((ln) => isHip(ln) || isRidge(ln)) const checked = lines.filter((ln) => isHip(ln) || isRidge(ln))
@ -241,8 +831,11 @@ export function checkKerabRules(lines, opts = {}) {
if (rule.applies && !rule.applies(ln)) continue if (rule.applies && !rule.applies(ln)) continue
const c = coords(ln) const c = coords(ln)
if (!c) continue if (!c) continue
const r = rule.check(c, ctx) const r = rule.check(c, ctx, ln)
if (r) push(rule, { lineIds: [lineId(ln)], type: typeOf(ln), ...r }) if (r) {
push(rule, { lineIds: [lineId(ln)], type: typeOf(ln), ...r })
collectFix(rule, ln, c, r)
}
} }
} }
@ -258,7 +851,13 @@ export function checkKerabRules(lines, opts = {}) {
const cb = coords(lb) const cb = coords(lb)
if (!ca || !cb) continue if (!ca || !cb) continue
const r = rule.check(ca, cb, la, lb, ctx) const r = rule.check(ca, cb, la, lb, ctx)
if (r) push(rule, { lineIds: [lineId(la), lineId(lb)], ...r }) if (r) {
push(rule, { lineIds: [lineId(la), lineId(lb)], ...r })
if (opts.apply && typeof rule.fix === 'function' && fixAllowed(rule.id)) {
const action = rule.fix(ca, cb, la, lb, ctx, r)
if (action) fixes.push({ rule: rule.id, line: null, action })
}
}
} }
} }
} }
@ -266,12 +865,32 @@ export function checkKerabRules(lines, opts = {}) {
// junction 규칙 // junction 규칙
const needJunction = RULES.some((r) => r.enabled && r.kind === 'junction') const needJunction = RULES.some((r) => r.enabled && r.kind === 'junction')
if (needJunction) { if (needJunction) {
const junctions = buildJunctions(lines, ctx.pointEps) ctx.junctions = buildJunctions(lines, ctx.pointEps) // fix(R-RIDGE-GEN 생성)가 마주보는 봉우리 탐색에 쓴다.
for (const rule of RULES) { for (const rule of RULES) {
if (!rule.enabled || rule.kind !== 'junction') continue if (!rule.enabled || rule.kind !== 'junction') continue
for (const j of junctions) { for (const j of ctx.junctions) {
const r = rule.check(j, ctx) const r = rule.check(j, ctx)
if (r) push(rule, { lineIds: j.spokes.map((s) => lineId(s.line)), ...r }) if (r) {
push(rule, { lineIds: j.spokes.map((s) => lineId(s.line)), ...r })
if (opts.apply && typeof rule.fix === 'function' && fixAllowed(rule.id)) {
const action = rule.fix(j, ctx, r)
if (action) fixes.push({ rule: rule.id, line: null, action })
}
}
}
}
}
// edge 규칙 (처마/케라바 변 ↔ 힙 존재). opts.edges = [{x1,y1,x2,y2, kind:'eaves'|'kerab'}] 가 주입될 때만.
// 검출 전용(fix 없음) — 힙 자동생성/삭제는 별개 작업.
const edges = ctx.edges
if (edges && edges.length) {
for (const rule of RULES) {
if (!rule.enabled || rule.kind !== 'edge') continue
for (const e of edges) {
if (rule.applies && !rule.applies(e)) continue
const r = rule.check(e, ctx)
if (r) push(rule, { edge: { x1: e.x1, y1: e.y1, x2: e.x2, y2: e.y2 }, edgeKind: e.kind, ...r })
} }
} }
} }
@ -281,7 +900,7 @@ export function checkKerabRules(lines, opts = {}) {
ridges: lines.filter(isRidge).length, ridges: lines.filter(isRidge).length,
boxes: ctx.boxes.length, boxes: ctx.boxes.length,
} }
const result = { violations, boxes: ctx.boxes, stats } const result = { violations, boxes: ctx.boxes, stats, fixes }
if (!opts.silent) { if (!opts.silent) {
const tag = `[KERAB-RULE-CHECK]${opts.label ? ` ${opts.label}` : ''}${opts.roofId ? ` roof=${opts.roofId}` : ''}` const tag = `[KERAB-RULE-CHECK]${opts.label ? ` ${opts.label}` : ''}${opts.roofId ? ` roof=${opts.roofId}` : ''}`
@ -291,11 +910,70 @@ export function checkKerabRules(lines, opts = {}) {
logger.warn(`${tag} ✗ 위반 ${violations.length}건 (hip ${stats.hips} / ridge ${stats.ridges} / box ${stats.boxes})`) logger.warn(`${tag} ✗ 위반 ${violations.length}건 (hip ${stats.hips} / ridge ${stats.ridges} / box ${stats.boxes})`)
for (const v of violations) logger.warn(`${v.rule}: ${v.detail || ''}`, v) for (const v of violations) logger.warn(`${v.rule}: ${v.detail || ''}`, v)
} }
// 브라우저 콘솔과 별개로 debug/debug.log 에도 남긴다 — 크롬 없이 파일로 검증 가능하게.
debugCapture.log(`KERAB-RULE-CHECK ${opts.label || ''} roof=${opts.roofId || ''}`, {
pass: violations.length === 0,
stats,
// [VALLEY-BOX-RECT-DIAG 2026-06-27] 체커가 쓴 캡 bbox(ctx.boxes)와 出幅 겹침 사각형(overlapBands) 식별용.
boxRects: ctx.boxes.map((b) => ({ minX: b.minX, minY: b.minY, maxX: b.maxX, maxY: b.maxY })),
overlapBands: (ctx.overlapBands || []).map((b) => ({ minX: b.minX, minY: b.minY, maxX: b.maxX, maxY: b.maxY })),
violations,
fixes: fixes.map((f) => ({ rule: f.rule, action: f.action })),
})
} }
return result return result
} }
/**
* [KERAB-RULE-ITERATIVE 2026-06-26] 라인을 따라가며 번에 위반씩 고치고 *처음부터 다시* 체크하는 반복 교정기.
*
* 사용자 모델(확정): "a-b-c 라인이 있으면 a→b 가다 b 문제 발생 → b 수정 → a 부터 다시 체크 → 다시 진행."
* 단일 패스(collect-then-apply)로는 된다 위반을 고치면 기하가 바뀌어(: 박스 내부 마루 R-2 제거
* 자리에 he1·he4 마루가 생성돼야 함이 드러나거나, 인접 절삭이 풀림) 위반이 보이거나 기존 위반이 사라진다.
* 그래서 *하나만* 고치고 전체를 다시 돌린다(수렴할 때까지). 무한루프는 maxIter + 진행없음 감지로 차단.
*
* @param {Array} lines - roof.innerLines (참조로 전달 applyFix 배열을 직접 splice 해야 다음 패스에 반영됨).
* @param {Object} opts
* @param {(fix:{rule:string,line:Object,action:Object}) => boolean} opts.applyFix
* canvas/innerLines 실제 변형 담당. 성공 true. checker canvas 모르므로 호출측이 주입.
* @param {number} [opts.maxIter=16] 안전 상한.
* @returns {{ iterations:number, applied:Array, final:Object }}
*/
export function checkAndFixKerabRules(lines, opts = {}) {
if (!ENABLED) return { iterations: 0, applied: [], final: { violations: [], boxes: [], stats: { hips: 0, ridges: 0, boxes: 0 } } }
const maxIter = opts.maxIter ?? 16
const applied = []
let iter = 0
for (; iter < maxIter; iter++) {
// 매 패스: 전체 재검사(처음부터). 중간 패스는 silent — 마지막에 한 번만 로그를 남긴다.
const res = checkKerabRules(lines, { ...opts, apply: true, silent: true })
const fix = res.fixes && res.fixes.length ? res.fixes[0] : null
if (!fix) break // 더 고칠 게 없음 = 수렴.
// [KERAB-FIX-AUDIT 2026-06-26] 적용 직전에 대상 선의 정체를 박제(적용 후엔 splice 돼 사라짐). roofLine 이
// 잘못 지워진다는 보고 추적용 — 어떤 규칙이 어떤 lineName/type/좌표 선을 건드렸는지 남긴다.
const tline = fix.line || (fix.action && fix.action.multiSet && fix.action.multiSet[0] && fix.action.multiSet[0].line) || null
const tinfo = tline
? { lineName: tline.lineName ?? null, type: typeOf(tline), id: lineId(tline), x1: tline.x1, y1: tline.y1, x2: tline.x2, y2: tline.y2 }
: null
const ok = typeof opts.applyFix === 'function' ? opts.applyFix(fix) === true : false
if (!ok) break // 적용 실패(또는 applyFix 미주입) → 같은 위반 무한반복 차단.
applied.push({ rule: fix.rule, action: fix.action, target: tinfo })
}
// 수렴 후 최종 상태를 한 번 로그(비교정·非silent) — 남은 위반(예: R-RIDGE-GEN 미생성)도 여기서 보인다.
if (!opts.silent && applied.length) {
debugCapture.log(`KERAB-FIX-APPLIED ${opts.label || ''} roof=${opts.roofId || ''}`, { count: applied.length, applied })
}
const final = checkKerabRules(lines, {
...opts,
apply: false,
silent: false,
label: `${opts.label || ''} (after-fix×${applied.length})`,
})
return { iterations: iter, applied, final }
}
// dev 콘솔에서 수동 호출용. // dev 콘솔에서 수동 호출용.
if (typeof window !== 'undefined' && ENABLED) { if (typeof window !== 'undefined' && ENABLED) {
window.__checkKerabRules = checkKerabRules window.__checkKerabRules = checkKerabRules
window.__checkAndFixKerabRules = checkAndFixKerabRules
} }

View File

@ -6,6 +6,7 @@ import { QPolygon } from '@/components/fabric/QPolygon'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import Big from 'big.js' import Big from 'big.js'
import * as turf from '@turf/turf' import * as turf from '@turf/turf'
// import { debugCapture } from '@/util/debugCapture' // [KERAB-GABLE-DIAG 2026-06-29] 박공 마루 미생성 추적용
const TWO_PI = Math.PI * 2 const TWO_PI = Math.PI * 2
const EPSILON = 1e-10 //좌표계산 시 최소 차이값 const EPSILON = 1e-10 //좌표계산 시 최소 차이값
@ -1569,6 +1570,7 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
//각 포인트들을 직교하도록 정렬 //각 포인트들을 직교하도록 정렬
const sortedPoints = getSortedOrthogonalPoints(roofPlanePoint) const sortedPoints = getSortedOrthogonalPoints(roofPlanePoint)
// logger.log(`[GABLE-H8-PLANE] planeDir ridges=${ridgeLines.length}`)
sortedPoints.forEach((currPoint, index) => { sortedPoints.forEach((currPoint, index) => {
const nextPoint = sortedPoints[(index + 1) % sortedPoints.length] const nextPoint = sortedPoints[(index + 1) % sortedPoints.length]
const points = [currPoint.x, currPoint.y, nextPoint.x, nextPoint.y] const points = [currPoint.x, currPoint.y, nextPoint.x, nextPoint.y]
@ -1617,6 +1619,7 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
if (Math.abs(rg.y1 - points[1]) >= 1) return false if (Math.abs(rg.y1 - points[1]) >= 1) return false
return Math.min(rg.x1, rg.x2) - 1 <= sMin && sMax <= Math.max(rg.x1, rg.x2) + 1 return Math.min(rg.x1, rg.x2) - 1 <= sMin && sMax <= Math.max(rg.x1, rg.x2) + 1
}) })
// logger.log(`[GABLE-H8-CHK] seg covered=${coveredByRidge ? 'YES' : 'NO'}`)
if (coveredByRidge) { if (coveredByRidge) {
return true return true
} }
@ -1651,6 +1654,56 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
drawRoofPlane(backward) drawRoofPlane(backward)
}) })
roof.innerLines.push(...ridgeLines, ...innerLines) roof.innerLines.push(...ridgeLines, ...innerLines)
// [VALLEY-BOX-FILL 2026-06-24] 골짜기에서 평행한 두 마루가 수직축으로 겹치면(出幅 겹침=박스),
// 짧은 마루 쪽 위치에 겹침구간을 잇는 세로 박스라인(라벨 B)을 채운다.
// 마루는 건드리지 않고(박스라인+마루) 박스 경계만 보강 → 마루 삭제해도 경계 유지.
{
const VB_EPS = 1
for (let i = 0; i < ridgeLines.length; i++) {
for (let j = i + 1; j < ridgeLines.length; j++) {
const a = ridgeLines[i]
const b = ridgeLines[j]
const aV = Math.abs(a.x1 - a.x2) < VB_EPS
const bV = Math.abs(b.x1 - b.x2) < VB_EPS
const aH = Math.abs(a.y1 - a.y2) < VB_EPS
const bH = Math.abs(b.y1 - b.y2) < VB_EPS
let pts = null
if (aV && bV && Math.abs(a.x1 - b.x1) > VB_EPS) {
const aMin = Math.min(a.y1, a.y2)
const aMax = Math.max(a.y1, a.y2)
const bMin = Math.min(b.y1, b.y2)
const bMax = Math.max(b.y1, b.y2)
const o0 = Math.max(aMin, bMin)
const o1 = Math.min(aMax, bMax)
if (o1 - o0 > VB_EPS) {
const fillX = aMax - aMin <= bMax - bMin ? a.x1 : b.x1
pts = [fillX, o0, fillX, o1]
}
} else if (aH && bH && Math.abs(a.y1 - b.y1) > VB_EPS) {
const aMin = Math.min(a.x1, a.x2)
const aMax = Math.max(a.x1, a.x2)
const bMin = Math.min(b.x1, b.x2)
const bMax = Math.max(b.x1, b.x2)
const o0 = Math.max(aMin, bMin)
const o1 = Math.min(aMax, bMax)
if (o1 - o0 > VB_EPS) {
const fillY = aMax - aMin <= bMax - bMin ? a.y1 : b.y1
pts = [o0, fillY, o1, fillY]
}
}
if (pts) {
// [VALLEY-BOX-FILL 2026-06-26] 박스는 겹침구간(두 마루 범위의 교집합) 위, 더 짧은 마루의 x 에 놓인다.
// 교집합은 항상 짧은 마루의 부분구간이라 박스는 그 마루와 collinear 중복선이 된다.
// 이를 innerLines 에 넣으면 할당 그래프(splitPolygonWithLines)에 중복 변이 생겨 해당 쪽 면이
// degenerate 한 덩어리로 뭉개진다(우측 면 미분할). 박스는 시각용 캡(B 라벨)이므로 canvas 에만
// 추가하고 innerLines 에는 넣지 않는다. (B 라벨러·kerab 머지·surgical 은 모두 canvas 스캔이라 무관.)
drawValleyBoxLine(pts, canvas, roof, roof.textMode)
}
}
}
}
// 처마선 innerLine 이 외곽선/치수를 전담하므로 roof 원본 외곽(stroke) 숨김 + 지붕선 치수(roof lengthText) 제거. // 처마선 innerLine 이 외곽선/치수를 전담하므로 roof 원본 외곽(stroke) 숨김 + 지붕선 치수(roof lengthText) 제거.
// 외벽선(wall) 치수는 유지. (박공은 split 공용 함수를 안 거치므로 여기서 직접 처리) // 외벽선(wall) 치수는 유지. (박공은 split 공용 함수를 안 거치므로 여기서 직접 처리)
roof.set({ stroke: 'transparent' }) roof.set({ stroke: 'transparent' })
@ -3543,8 +3596,14 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
const prevIndex = baseLines.findIndex((line) => line === prevLine) const prevIndex = baseLines.findIndex((line) => line === prevLine)
const nextIndex = baseLines.findIndex((line) => line === nextLine) const nextIndex = baseLines.findIndex((line) => line === nextLine)
// [KERAB-GABLE-DIAG 2026-06-29] ① 이벤트: 박공 변 처리 진입
// const __gcoord = `(${Math.round(currentLine.x1)},${Math.round(currentLine.y1)})-(${Math.round(currentLine.x2)},${Math.round(currentLine.y2)})`
// debugCapture.log('KERAB-GABLE-DIAG ①이벤트:gable-enter', { gable: __gcoord, prevType: prevLine?.attributes?.type, nextType: nextLine?.attributes?.type })
//양옆이 처마가 아닐때 패스 //양옆이 처마가 아닐때 패스
if (prevLine.attributes.type !== LINE_TYPE.WALLLINE.EAVES || nextLine.attributes.type !== LINE_TYPE.WALLLINE.EAVES) { if (prevLine.attributes.type !== LINE_TYPE.WALLLINE.EAVES || nextLine.attributes.type !== LINE_TYPE.WALLLINE.EAVES) {
// [KERAB-GABLE-DIAG 2026-06-29] ②원인→④결과: 양옆이 처마가 아님 → 마루 미생성
// debugCapture.log('KERAB-GABLE-DIAG ②원인:neighbor-not-eaves → ④결과:no-ridge', { gable: __gcoord })
return return
} }
@ -3556,6 +3615,11 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
y: (currentLine.y1 + currentLine.y2) / 2 + Math.sign(nextLine.y2 - nextLine.y1), y: (currentLine.y1 + currentLine.y2) / 2 + Math.sign(nextLine.y2 - nextLine.y1),
} }
// [KERAB-GABLE-DIAG 2026-06-29] ②원인: 본처리 진입 게이트(vector 차이 + inPolygon) 통과 여부
// const __vecDiff = prevLineVector.x !== nextLineVector.x || prevLineVector.y !== nextLineVector.y
// const __inPoly = checkWallPolygon.inPolygon(inPolygonPoint)
// debugCapture.log('KERAB-GABLE-DIAG ②원인:gate-vec+inPoly', { gable: __gcoord, vecDiff: __vecDiff, inPoly: __inPoly, inPolygonPoint })
//좌우 라인이 서로 다른방향일때 //좌우 라인이 서로 다른방향일때
if ((prevLineVector.x !== nextLineVector.x || prevLineVector.y !== nextLineVector.y) && checkWallPolygon.inPolygon(inPolygonPoint)) { if ((prevLineVector.x !== nextLineVector.x || prevLineVector.y !== nextLineVector.y) && checkWallPolygon.inPolygon(inPolygonPoint)) {
const analyze = analyzeLine(currentLine) const analyze = analyzeLine(currentLine)
@ -3869,6 +3933,13 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
} }
}) })
// [KERAB-GABLE-DIAG 2026-06-29] ②원인: 반대편 평행변(oppositeLine) 매칭 — 0이면 마루 미생성
// debugCapture.log('KERAB-GABLE-DIAG ②원인:oppositeLine-match', {
// gable: __gcoord,
// count: oppositeLine.length,
// lines: oppositeLine.map((o) => `(${Math.round(o.line.x1)},${Math.round(o.line.y1)})-(${Math.round(o.line.x2)},${Math.round(o.line.y2)})`),
// })
if (oppositeLine.length > 0) { if (oppositeLine.length > 0) {
const ridgePoints = [] const ridgePoints = []
@ -4130,6 +4201,14 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
if (stdAnalyze.isHorizontal) { if (stdAnalyze.isHorizontal) {
if (overlapMinX > overlapMaxX) { if (overlapMinX > overlapMaxX) {
// [KERAB-GABLE-DIAG 2026-06-29] ③분기→④결과: 수평 박공 X구간 겹침 없음 → 이 oppositeLine skip(no-ridge)
// debugCapture.log('KERAB-GABLE-DIAG ③분기:horizontal-no-overlap → ④결과:no-ridge', {
// gable: __gcoord,
// overlapMinX,
// overlapMaxX,
// stdRange: [stdMinX, stdMaxX],
// ridgeRange: [rMinX, rMaxX],
// })
return return
} }
const dist1 = Math.abs(ridgePoint[0] - overlapMinX) const dist1 = Math.abs(ridgePoint[0] - overlapMinX)
@ -4147,6 +4226,14 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
} }
if (stdAnalyze.isVertical) { if (stdAnalyze.isVertical) {
if (overlapMinY > overlapMaxY) { if (overlapMinY > overlapMaxY) {
// [KERAB-GABLE-DIAG 2026-06-29] ③분기→④결과: 수직 박공 Y구간 겹침 없음 → 이 oppositeLine skip(no-ridge)
// debugCapture.log('KERAB-GABLE-DIAG ③분기:vertical-no-overlap → ④결과:no-ridge', {
// gable: __gcoord,
// overlapMinY,
// overlapMaxY,
// stdRange: [stdMinY, stdMaxY],
// ridgeRange: [rMinY, rMaxY],
// })
return return
} }
const dist1 = Math.abs(ridgePoint[1] - overlapMinY) const dist1 = Math.abs(ridgePoint[1] - overlapMinY)
@ -4263,7 +4350,19 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
type: TYPES.RIDGE, type: TYPES.RIDGE,
degree: 0, degree: 0,
}) })
// [KERAB-GABLE-DIAG 2026-06-29] ④결과: 박공 마루 가선분 생성 성공 — left/right 는 이후 교점 매칭의 핵심 단서
// debugCapture.log('KERAB-GABLE-DIAG ④결과:ridge-pushed', {
// gable: __gcoord,
// ridgeLength,
// left: prevIndex,
// right: nextIndex,
// start: startPoint,
// end: endPoint,
// })
} }
} else {
// [KERAB-GABLE-DIAG 2026-06-29] ③분기→④결과: 마루 길이 ~0 → 가선분 미생성
// debugCapture.log('KERAB-GABLE-DIAG ③분기:ridge-too-short → ④결과:no-ridge', { gable: __gcoord, ridgeLength, point })
} }
}) })
} }
@ -4271,6 +4370,8 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
} }
//반절마루 생성불가이므로 지붕선 분기를 해야하는지 확인 후 처리. //반절마루 생성불가이므로 지붕선 분기를 해야하는지 확인 후 처리.
if (prevLineVector.x === nextLineVector.x && prevLineVector.y === nextLineVector.y) { if (prevLineVector.x === nextLineVector.x && prevLineVector.y === nextLineVector.y) {
// [KERAB-GABLE-DIAG 2026-06-29] ③분기: 양옆 벡터 동일(U자) → 반절마루 불가, GABLE_LINE 분기로
// debugCapture.log('KERAB-GABLE-DIAG ③분기:U-shape-gableline', { gable: __gcoord })
const analyze = analyzeLine(currentLine) const analyze = analyzeLine(currentLine)
const roofLine = analyze.roofLine const roofLine = analyze.roofLine
@ -4333,6 +4434,24 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
while (linesAnalysis.length > 0 && iterations < MAX_ITERATIONS) { while (linesAnalysis.length > 0 && iterations < MAX_ITERATIONS) {
iterations++ iterations++
// [KERAB-GABLE-DIAG 2026-06-29] ①이벤트: 교점 루프 iteration 시작 — RIDGE 현재 상태(붕괴 추적)
// {
// const __r = linesAnalysis.find((l) => l.type === TYPES.RIDGE)
// debugCapture.log('KERAB-GABLE-DIAG ①이벤트:loop-iter-start', {
// iter: iterations,
// total: linesAnalysis.length,
// ridge: __r
// ? {
// start: { x: Math.round(__r.start.x * 10) / 10, y: Math.round(__r.start.y * 10) / 10 },
// end: { x: Math.round(__r.end.x * 10) / 10, y: Math.round(__r.end.y * 10) / 10 },
// len: Math.round(Math.hypot(__r.end.x - __r.start.x, __r.end.y - __r.start.y) * 10) / 10,
// left: __r.left,
// right: __r.right,
// }
// : null,
// })
// }
const intersections = [] const intersections = []
linesAnalysis.forEach((currLine, i) => { linesAnalysis.forEach((currLine, i) => {
let minDistance = Infinity let minDistance = Infinity
@ -4383,6 +4502,25 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
intersections.push({ index: i, intersect: intersectPoint, linePoint, partner }) intersections.push({ index: i, intersect: intersectPoint, linePoint, partner })
} }
}) })
// [KERAB-GABLE-DIAG 2026-06-29] ②원인: 이번 iter 에서 각 RIDGE 가 교점쌍(partner)을 찾았는지 — 못 찾으면 루프 미처리되어 잔여로 떨어진다
// linesAnalysis.forEach((l, li) => {
// if (l.type !== TYPES.RIDGE) return
// const found = intersections.find((it) => it.index === li)
// const partnerLine = found ? linesAnalysis[found.partner] : null
// debugCapture.log('KERAB-GABLE-DIAG ②원인:loop-ridge-intersect-scan', {
// iter: iterations,
// ridge: {
// start: { x: Math.round(l.start.x * 10) / 10, y: Math.round(l.start.y * 10) / 10 },
// end: { x: Math.round(l.end.x * 10) / 10, y: Math.round(l.end.y * 10) / 10 },
// share: [l.left, l.right],
// },
// hasIntersect: !!found,
// partner: partnerLine ? { type: partnerLine.type, share: [partnerLine.left, partnerLine.right] } : null,
// intersect: found ? { x: Math.round(found.intersect.x * 10) / 10, y: Math.round(found.intersect.y * 10) / 10 } : null,
// })
// })
const intersectPoints = intersections const intersectPoints = intersections
.map((item) => item.intersect) .map((item) => item.intersect)
.filter((point, index, self) => self.findIndex((p) => almostEqual(p.x, point.x) && almostEqual(p.y, point.y)) === index) .filter((point, index, self) => self.findIndex((p) => almostEqual(p.x, point.x) && almostEqual(p.y, point.y)) === index)
@ -4398,24 +4536,78 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
for (const { index, intersect, linePoint, partner } of intersections) { for (const { index, intersect, linePoint, partner } of intersections) {
const cLine = linesAnalysis[index] const cLine = linesAnalysis[index]
const pLine = linesAnalysis[partner] const pLine = linesAnalysis[partner]
// [KERAB-GABLE-DIAG 2026-06-29] RIDGE 가 낀 교점쌍이 어느 게이트에서 탈락하는지 추적용
// const __ridgePair = cLine?.type === TYPES.RIDGE || pLine?.type === TYPES.RIDGE
//교점이 없거나, 이미 처리된 선분이면 처리하지 않는다. //교점이 없거나, 이미 처리된 선분이면 처리하지 않는다.
if (!intersect || processed.has(index)) continue if (!intersect || processed.has(index)) {
// if (__ridgePair)
// debugCapture.log('KERAB-GABLE-DIAG ③분기:loop-skip → ④결과:skipped', {
// iter: iterations,
// reason: !intersect ? 'no-intersect' : 'already-processed',
// cType: cLine?.type,
// pType: pLine?.type,
// })
continue
}
//교점이 없거나, 교점 선분이 없으면 처리하지 않는다. //교점이 없거나, 교점 선분이 없으면 처리하지 않는다.
const pIntersection = intersections.find((p) => p.index === partner) const pIntersection = intersections.find((p) => p.index === partner)
if (!pIntersection || !pIntersection.intersect) continue if (!pIntersection || !pIntersection.intersect) {
// if (__ridgePair)
// debugCapture.log('KERAB-GABLE-DIAG ③분기:loop-skip → ④결과:skipped', {
// iter: iterations,
// reason: 'partner-no-intersect',
// cType: cLine?.type,
// pType: pLine?.type,
// })
continue
}
//상호 최단 교점이 아닐경우 처리하지 않는다. //상호 최단 교점이 아닐경우 처리하지 않는다.
if (pIntersection.partner !== index) continue if (pIntersection.partner !== index) {
// if (__ridgePair)
// debugCapture.log('KERAB-GABLE-DIAG ③분기:loop-skip → ④결과:skipped', {
// iter: iterations,
// reason: 'not-mutual-shortest',
// cType: cLine?.type,
// pType: pLine?.type,
// pPartner: pIntersection.partner,
// index,
// })
continue
}
//공통선분이 없으면 처리하지 않는다. //공통선분이 없으면 처리하지 않는다.
const isSameLine = cLine.left === pLine.left || cLine.left === pLine.right || cLine.right === pLine.left || cLine.right === pLine.right const isSameLine = cLine.left === pLine.left || cLine.left === pLine.right || cLine.right === pLine.left || cLine.right === pLine.right
if (!isSameLine) continue if (!isSameLine) {
// if (__ridgePair)
// debugCapture.log('KERAB-GABLE-DIAG ③분기:loop-skip → ④결과:skipped', {
// iter: iterations,
// reason: 'no-common-line',
// cType: cLine?.type,
// pType: pLine?.type,
// cShare: [cLine.left, cLine.right],
// pShare: [pLine.left, pLine.right],
// })
continue
}
//처리 된 라인으로 설정 //처리 된 라인으로 설정
processed.add(index).add(partner) processed.add(index).add(partner)
// [KERAB-GABLE-DIAG 2026-06-29] ②원인: 이번 교점쌍에 RIDGE 포함 — 어떤 partner 와 어디서 만났나
if (cLine.type === TYPES.RIDGE || pLine.type === TYPES.RIDGE) {
// debugCapture.log('KERAB-GABLE-DIAG ②원인:loop-pair-has-ridge', {
// iter: iterations,
// cType: cLine.type,
// pType: pLine.type,
// intersect: { x: Math.round(intersect.x * 10) / 10, y: Math.round(intersect.y * 10) / 10 },
// cShare: [cLine.left, cLine.right],
// pShare: [pLine.left, pLine.right],
// })
}
let point1 = linePoint let point1 = linePoint
let point2 = pIntersection.linePoint let point2 = pIntersection.linePoint
let length1 = Math.sqrt(Math.pow(point1[2] - point1[0], 2) + Math.pow(point1[3] - point1[1], 2)) let length1 = Math.sqrt(Math.pow(point1[2] - point1[0], 2) + Math.pow(point1[3] - point1[1], 2))
@ -4431,10 +4623,22 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
length1 = Math.sqrt((point1[2] - point1[0]) ** 2 + (point1[3] - point1[1]) ** 2) length1 = Math.sqrt((point1[2] - point1[0]) ** 2 + (point1[3] - point1[1]) ** 2)
} }
// [KERAB-GABLE-DIAG 2026-06-29] ③분기: RIDGE(c) 그리기 게이트 판정(length1>0 && !중복)
if (cLine.type === TYPES.RIDGE) {
// debugCapture.log('KERAB-GABLE-DIAG ③분기:loop-ridge(c)-draw-gate', {
// iter: iterations,
// length1: Math.round(length1 * 10) / 10,
// already: alreadyPoints(innerLines, point1),
// willDraw: length1 > 0 && !alreadyPoints(innerLines, point1),
// point: point1.map((v) => Math.round(v * 10) / 10),
// })
}
if (length1 > 0 && !alreadyPoints(innerLines, point1)) { if (length1 > 0 && !alreadyPoints(innerLines, point1)) {
if (cLine.type === TYPES.HIP) { if (cLine.type === TYPES.HIP) {
innerLines.push(drawHipLine(point1, canvas, roof, textMode, cLine.degree, cLine.degree)) innerLines.push(drawHipLine(point1, canvas, roof, textMode, cLine.degree, cLine.degree))
} else if (cLine.type === TYPES.RIDGE) { } else if (cLine.type === TYPES.RIDGE) {
// [KERAB-GABLE-DIAG 2026-06-29] ④결과: RIDGE(c) 실제로 그려짐
// debugCapture.log('KERAB-GABLE-DIAG ④결과:loop-ridge(c)-drawn', { iter: iterations, point: point1.map((v) => Math.round(v * 10) / 10) })
innerLines.push(drawRidgeLine(point1, canvas, roof, textMode)) innerLines.push(drawRidgeLine(point1, canvas, roof, textMode))
} else if (cLine.type === TYPES.NEW) { } else if (cLine.type === TYPES.NEW) {
const isDiagonal = Math.abs(point1[0] - point1[2]) >= 1 && Math.abs(point1[1] - point1[3]) >= 1 const isDiagonal = Math.abs(point1[0] - point1[2]) >= 1 && Math.abs(point1[1] - point1[3]) >= 1
@ -4452,10 +4656,22 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
} }
} }
// [KERAB-GABLE-DIAG 2026-06-29] ③분기: RIDGE(p) 그리기 게이트 판정(length2>0 && !중복)
if (pLine.type === TYPES.RIDGE) {
// debugCapture.log('KERAB-GABLE-DIAG ③분기:loop-ridge(p)-draw-gate', {
// iter: iterations,
// length2: Math.round(length2 * 10) / 10,
// already: alreadyPoints(innerLines, point2),
// willDraw: length2 > 0 && !alreadyPoints(innerLines, point2),
// point: point2.map((v) => Math.round(v * 10) / 10),
// })
}
if (length2 > 0 && !alreadyPoints(innerLines, point2)) { if (length2 > 0 && !alreadyPoints(innerLines, point2)) {
if (pLine.type === TYPES.HIP) { if (pLine.type === TYPES.HIP) {
innerLines.push(drawHipLine(point2, canvas, roof, textMode, pLine.degree, pLine.degree)) innerLines.push(drawHipLine(point2, canvas, roof, textMode, pLine.degree, pLine.degree))
} else if (pLine.type === TYPES.RIDGE) { } else if (pLine.type === TYPES.RIDGE) {
// [KERAB-GABLE-DIAG 2026-06-29] ④결과: RIDGE(p) 실제로 그려짐
// debugCapture.log('KERAB-GABLE-DIAG ④결과:loop-ridge(p)-drawn', { iter: iterations, point: point2.map((v) => Math.round(v * 10) / 10) })
innerLines.push(drawRidgeLine(point2, canvas, roof, textMode)) innerLines.push(drawRidgeLine(point2, canvas, roof, textMode))
} else if (pLine.type === TYPES.NEW) { } else if (pLine.type === TYPES.NEW) {
const isDiagonal = Math.abs(point2[0] - point2[2]) >= 1 && Math.abs(point2[1] - point2[3]) >= 1 const isDiagonal = Math.abs(point2[0] - point2[2]) >= 1 && Math.abs(point2[1] - point2[3]) >= 1
@ -4629,9 +4845,27 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
if (otherIs.length > 0) { if (otherIs.length > 0) {
const analyze = linesAnalysis[otherIs[0].index] const analyze = linesAnalysis[otherIs[0].index]
processed.add(otherIs[0].index) processed.add(otherIs[0].index)
// 연결점(intersect)에서 더 먼 끝점을 유지한다.
// 기존엔 항상 analyze.end 를 유지해, intersect 가 analyze.end 와 일치하면 start=end 로 zero-length 붕괴(박공 마루 미생성)가 발생했다.
const distToStart = Math.hypot(analyze.start.x - intersect.x, analyze.start.y - intersect.y)
const distToEnd = Math.hypot(analyze.end.x - intersect.x, analyze.end.y - intersect.y)
const farEnd = distToStart >= distToEnd ? analyze.start : analyze.end
// [KERAB-GABLE-DIAG 2026-06-29] ③분기→④결과: 연결점 기존 analyze 라인 교체 — far-end 보존으로 붕괴 방지 검증
// const __newLen = Math.round(Math.hypot(farEnd.x - intersect.x, farEnd.y - intersect.y) * 10) / 10
// debugCapture.log('KERAB-GABLE-DIAG ③분기:loop-analyze-replace → ④결과:' + (__newLen < 0.05 ? 'COLLAPSE-len0' : 'replaced-farEnd'), {
// iter: iterations,
// replacedType: analyze.type,
// replacedLeft: analyze.left,
// replacedRight: analyze.right,
// origStart: { x: Math.round(analyze.start.x * 10) / 10, y: Math.round(analyze.start.y * 10) / 10 },
// origEnd: { x: Math.round(analyze.end.x * 10) / 10, y: Math.round(analyze.end.y * 10) / 10 },
// intersect: { x: Math.round(intersect.x * 10) / 10, y: Math.round(intersect.y * 10) / 10 },
// farEnd: { x: Math.round(farEnd.x * 10) / 10, y: Math.round(farEnd.y * 10) / 10 },
// newLen: __newLen,
// })
newAnalysis.push({ newAnalysis.push({
start: { x: intersect.x, y: intersect.y }, start: { x: intersect.x, y: intersect.y },
end: { x: analyze.end.x, y: analyze.end.y }, end: { x: farEnd.x, y: farEnd.y },
left: analyze.left, left: analyze.left,
right: analyze.right, right: analyze.right,
type: analyze.type, type: analyze.type,
@ -4687,6 +4921,23 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
// 처리된 가선분 제외 // 처리된 가선분 제외
linesAnalysis = newAnalysis.concat(linesAnalysis.filter((_, index) => !processed.has(index))) linesAnalysis = newAnalysis.concat(linesAnalysis.filter((_, index) => !processed.has(index)))
// [KERAB-GABLE-DIAG 2026-06-29] ④결과: iteration 종료 후 RIDGE 상태(붕괴/소멸 여부)
// {
// const __r = linesAnalysis.find((l) => l.type === TYPES.RIDGE)
// debugCapture.log('KERAB-GABLE-DIAG ④결과:loop-iter-end', {
// iter: iterations,
// newAnalysisCount: newAnalysis.length,
// processedCount: processed.size,
// ridge: __r
// ? {
// start: { x: Math.round(__r.start.x * 10) / 10, y: Math.round(__r.start.y * 10) / 10 },
// end: { x: Math.round(__r.end.x * 10) / 10, y: Math.round(__r.end.y * 10) / 10 },
// len: Math.round(Math.hypot(__r.end.x - __r.start.x, __r.end.y - __r.start.y) * 10) / 10,
// }
// : 'GONE',
// })
// }
canvas canvas
.getObjects() .getObjects()
.filter((object) => object.name === 'check' || object.name === 'checkAnalysis') .filter((object) => object.name === 'check' || object.name === 'checkAnalysis')
@ -4695,6 +4946,21 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
if (newAnalysis.length === 0) break if (newAnalysis.length === 0) break
} }
// [KERAB-GABLE-DIAG 2026-06-29] ①이벤트: 교점 루프 종료 — 잔여 linesAnalysis 덤프(이 시점에 RIDGE 가 남아있는지가 핵심)
// debugCapture.log('KERAB-GABLE-DIAG ①이벤트:after-loop-residual', {
// baseLinesLen: baseLines.length,
// iterations,
// count: linesAnalysis.length,
// lines: linesAnalysis.map((l) => ({
// type: l.type,
// degree: l.degree,
// left: l.left,
// right: l.right,
// start: { x: Math.round(l.start.x * 10) / 10, y: Math.round(l.start.y * 10) / 10 },
// end: { x: Math.round(l.end.x * 10) / 10, y: Math.round(l.end.y * 10) / 10 },
// })),
// })
// 가선분 중 처리가 안되어있는 붙어있는 라인에 대한 예외처리. // 가선분 중 처리가 안되어있는 붙어있는 라인에 대한 예외처리.
const proceedAnalysis = [] const proceedAnalysis = []
linesAnalysis linesAnalysis
@ -4785,38 +5051,79 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
// 최종 라인중 벽에서 시작해서 벽에서 끝나는 라인이 남을 경우(벽취함) // 최종 라인중 벽에서 시작해서 벽에서 끝나는 라인이 남을 경우(벽취함)
if (linesAnalysis.length > 0) { if (linesAnalysis.length > 0) {
linesAnalysis // 길이 0 잔여 제거
.filter((line) => { const residualLines = linesAnalysis.filter((line) => Math.hypot(line.end.x - line.start.x, line.end.y - line.start.y) > 0)
const dx = line.end.x - line.start.x
const dy = line.end.y - line.start.y
const length = Math.sqrt(dx ** 2 + dy ** 2)
return length > 0
})
.forEach((line) => {
const startOnLine = roof.lines.find((l) => isPointOnLineNew(l, line.start))
const endOnLine = roof.lines.find((l) => isPointOnLineNew(l, line.end))
const allLinesPoints = []
innerLines.forEach((innerLine) => {
allLinesPoints.push({ x: innerLine.x1, y: innerLine.y1 }, { x: innerLine.x2, y: innerLine.y2 })
})
if ( // 순서 비의존 연결성 판정용 끝점 집합: 이미 그려진 라인(innerLines) + 아직 안 그려진 잔여 라인(residualLines) 전부.
allLinesPoints.filter((p) => almostEqual(p.x, line.start.x) && almostEqual(p.y, line.start.y)).length < 3 && // 끝점이 지붕선 위(앵커)이거나 다른 라인 끝점과 공유(접합)되면 "연결됨". 양끝이 모두 연결된 라인만 그린다.
allLinesPoints.filter((p) => almostEqual(p.x, line.end.x) && almostEqual(p.y, line.end.y)).length === 0 // 이렇게 하면 마루↔마루/마루↔힙 접합도 그리기 순서와 무관하게 살아남고, 자유단(고립)을 가진 라인만 소멸한다.
) { const allEndpoints = []
if (line.type === TYPES.RIDGE && baseLines.length === 4 && (startOnLine || endOnLine)) { innerLines.forEach((il) => allEndpoints.push({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }))
innerLines.push(drawRidgeLine([line.start.x, line.start.y, line.end.x, line.end.y], canvas, roof, textMode)) residualLines.forEach((rl) => allEndpoints.push({ x: rl.start.x, y: rl.start.y }, { x: rl.end.x, y: rl.end.y }))
} else { // UI/Big.js drift 로 끝점 좌표가 미세하게 어긋날 수 있어 0.5 tolerance 사용 (feedback_same_point_tolerance)
if (startOnLine && endOnLine) { const SHARED_EPS = 0.5
if (line.degree === 0) { const sharedCnt = (pt) => allEndpoints.filter((p) => almostEqual(p.x, pt.x, SHARED_EPS) && almostEqual(p.y, pt.y, SHARED_EPS)).length
innerLines.push(drawRoofLine([line.start.x, line.start.y, line.end.x, line.end.y], canvas, roof, textMode))
} else { residualLines.forEach((line) => {
innerLines.push(drawHipLine([line.start.x, line.start.y, line.end.x, line.end.y], canvas, roof, textMode, line.degree, line.degree)) const startOnLine = roof.lines.find((l) => isPointOnLineNew(l, line.start))
} const endOnLine = roof.lines.find((l) => isPointOnLineNew(l, line.end))
}
} // 자기 자신이 끝점마다 1회 기여하므로 sharedCnt >= 2 이면 다른 라인과 접합한 것.
const startAnchored = !!startOnLine || sharedCnt(line.start) >= 2
const endAnchored = !!endOnLine || sharedCnt(line.end) >= 2
// 동일 양끝점으로 이미 그려진 라인이 있으면 중복 방지.
const dup = innerLines.find(
(il) =>
(almostEqual(il.x1, line.start.x, SHARED_EPS) && almostEqual(il.y1, line.start.y, SHARED_EPS) && almostEqual(il.x2, line.end.x, SHARED_EPS) && almostEqual(il.y2, line.end.y, SHARED_EPS)) ||
(almostEqual(il.x1, line.end.x, SHARED_EPS) && almostEqual(il.y1, line.end.y, SHARED_EPS) && almostEqual(il.x2, line.start.x, SHARED_EPS) && almostEqual(il.y2, line.start.y, SHARED_EPS)),
)
// [KERAB-GABLE-DIAG 2026-06-29] ②원인: 벽취합 잔여 라인 연결성 판정값(순서 무관)
// debugCapture.log('KERAB-GABLE-DIAG ②원인:wall-merge-residual', {
// type: line.type,
// degree: line.degree,
// baseLinesLen: baseLines.length,
// startOnLine: !!startOnLine,
// endOnLine: !!endOnLine,
// start: { x: Math.round(line.start.x * 10) / 10, y: Math.round(line.start.y * 10) / 10 },
// end: { x: Math.round(line.end.x * 10) / 10, y: Math.round(line.end.y * 10) / 10 },
// startShared: sharedCnt(line.start),
// endShared: sharedCnt(line.end),
// startAnchored,
// endAnchored,
// dup: !!dup,
// })
if (dup) {
// [KERAB-GABLE-DIAG 2026-06-29] ③분기→④결과: 이미 그려진 동일 라인 → 중복 스킵
// debugCapture.log('KERAB-GABLE-DIAG ③분기:duplicate → ④결과:skipped', { type: line.type })
return
}
if (startAnchored && endAnchored) {
if (line.type === TYPES.RIDGE) {
// [KERAB-GABLE-DIAG 2026-06-29] ③분기→④결과: 양끝 연결된 RIDGE → 마루 그림
// debugCapture.log('KERAB-GABLE-DIAG ③분기:anchored-ridge → ④결과:ridge-drawn', { type: line.type })
innerLines.push(drawRidgeLine([line.start.x, line.start.y, line.end.x, line.end.y], canvas, roof, textMode))
} else if (line.degree === 0) {
// [KERAB-GABLE-DIAG 2026-06-29] ③분기→④결과: 양끝 연결 + degree0 → roofLine 그림
// debugCapture.log('KERAB-GABLE-DIAG ③분기:anchored-degree0 → ④결과:roofLine-drawn', { type: line.type, degree: line.degree })
innerLines.push(drawRoofLine([line.start.x, line.start.y, line.end.x, line.end.y], canvas, roof, textMode))
} else {
// [KERAB-GABLE-DIAG 2026-06-29] ③분기→④결과: 양끝 연결 + degree>0 → hip 그림
// debugCapture.log('KERAB-GABLE-DIAG ③분기:anchored-hip → ④결과:hip-drawn', { type: line.type, degree: line.degree })
innerLines.push(drawHipLine([line.start.x, line.start.y, line.end.x, line.end.y], canvas, roof, textMode, line.degree, line.degree))
} }
}) } else {
// [KERAB-GABLE-DIAG 2026-06-29] ③분기→④결과: 한쪽 끝이 고립(연결 안됨) → 소멸
// debugCapture.log('KERAB-GABLE-DIAG ③분기:not-anchored → ④결과:dropped', {
// type: line.type,
// startAnchored,
// endAnchored,
// })
}
})
} }
//케라바에서 파생된 하단 지붕 라인처리 //케라바에서 파생된 하단 지붕 라인처리
const downRoofGable = [] const downRoofGable = []
@ -5934,6 +6241,27 @@ const drawRidgeLine = (points, canvas, roof, textMode) => {
return ridge return ridge
} }
// [VALLEY-BOX-FILL 2026-06-24] 골짜기 박스(出幅 겹침)의 세로변을 마루와 별개의 박스라인으로 채운다.
// kerabValleyOverlapLine(라벨 B) 으로 만들어 마루를 지워도 박스 경계가 남도록 한다(박스라인+마루).
const drawValleyBoxLine = (points, canvas, roof, textMode) => {
const lsz = calcLinePlaneSize({ x1: points[0], y1: points[1], x2: points[2], y2: points[3] })
const box = new QLine(points, {
parentId: roof.id,
fontSize: roof.fontSize,
stroke: '#1083E3',
strokeWidth: 3,
name: LINE_TYPE.SUBLINE.VALLEY,
textMode: textMode,
attributes: { roofId: roof.id, type: 'kerabValleyOverlapLine', isStart: true, pitch: roof.pitch, planeSize: lsz, actualSize: lsz },
})
box.lineName = 'kerabValleyOverlapLine'
box.roofId = roof.id
canvas.add(box)
box.bringToFront()
canvas.renderAll()
return box
}
/** /**
* 지붕선을 그린다. * 지붕선을 그린다.
* @param points * @param points
@ -5966,6 +6294,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()
@ -6400,7 +6733,7 @@ export const equalizeSymmetricHips = (hipLines, canvas) => {
text.set({ planeSize: plane, actualSize: actual }) text.set({ planeSize: plane, actualSize: actual })
const isPlane = !text.actualSize || text.text === String(text.planeSize) const isPlane = !text.actualSize || text.text === String(text.planeSize)
// planeSize/actualSize 모두 저장값(정수 또는 0.5 step) 그대로 표시. // planeSize/actualSize 모두 저장값(정수 또는 0.5 step) 그대로 표시.
const __raw = isPlane ? plane : actual // const __raw = isPlane ? plane : actual
text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') }) text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') })
} }
} }
@ -6472,7 +6805,7 @@ export const equalizeParallelEaveLabels = (lines, canvas) => {
} }
if (text) { if (text) {
text.set({ planeSize: plane, actualSize: actual }) text.set({ planeSize: plane, actualSize: actual })
const __raw = wasPlane ? plane : actual // const __raw = wasPlane ? plane : actual
text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') }) text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') })
} }
} }