Merge pull request 'dev' (#935) from dev into prd-deploy
Reviewed-on: #935
This commit is contained in:
commit
35f0627a73
@ -32,24 +32,97 @@ function __isDebugLabelsEnabled() {
|
||||
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 lineName = obj.lineName
|
||||
const type = obj.attributes?.type
|
||||
|
||||
if (name === 'baseLine') return 'B'
|
||||
if (name === 'outerLine' || name === 'eaves' || lineName === 'roofLine') return 'R'
|
||||
if (name === LINE_TYPE.SUBLINE.HIP || lineName === LINE_TYPE.SUBLINE.HIP) return 'H'
|
||||
if (name === LINE_TYPE.SUBLINE.RIDGE || lineName === LINE_TYPE.SUBLINE.RIDGE) return 'RG'
|
||||
// 골짜기박스 (B)
|
||||
if (lineName === 'kerabValleyOverlapLine' || type === 'kerabValleyOverlapLine') return 'B'
|
||||
// 확장라인 (기본 + E)
|
||||
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.GABLE || lineName === LINE_TYPE.SUBLINE.GABLE) return 'G'
|
||||
if (name === LINE_TYPE.SUBLINE.VERGE || lineName === LINE_TYPE.SUBLINE.VERGE) return 'VG'
|
||||
if (type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE)
|
||||
return 'E'
|
||||
return 'EH'
|
||||
if (typeof obj.x1 === 'number' && typeof obj.y1 === 'number' && typeof obj.x2 === 'number' && typeof obj.y2 === 'number') return 'SK'
|
||||
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) {
|
||||
__attachDebugLabels(canvas, parentId)
|
||||
}
|
||||
@ -66,10 +139,15 @@ function __attachDebugLabels(canvas, parentId) {
|
||||
.forEach((o) => canvas.remove(o))
|
||||
|
||||
const counters = {}
|
||||
const boundaryEdges = __roofBoundaryEdges(canvas, parentId)
|
||||
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) => {
|
||||
const prefix = __classifyLineForLabel(obj)
|
||||
const prefix = __classifyLineForLabel(obj, boundaryEdges)
|
||||
if (!prefix) return
|
||||
|
||||
counters[prefix] = (counters[prefix] || 0) + 1
|
||||
@ -77,6 +155,19 @@ function __attachDebugLabels(canvas, parentId) {
|
||||
const mx = (obj.x1 + obj.x2) / 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, {
|
||||
left: mx,
|
||||
top: my,
|
||||
@ -99,6 +190,10 @@ function __attachDebugLabels(canvas, parentId) {
|
||||
text.bringToFront()
|
||||
})
|
||||
|
||||
if (__labelDump.length) {
|
||||
debugCapture.log(`LABEL-DUMP roof=${parentId}`, { count: __labelDump.length, lines: __labelDump })
|
||||
}
|
||||
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
|
||||
@ -80,7 +80,6 @@ export default function MainContents({ setFaqOpen, setFaqModalNoticeNo }) {
|
||||
|
||||
//공지사항 호출
|
||||
const fetchNoticeList = async () => {
|
||||
try {
|
||||
const param = {
|
||||
schNoticeTpCd: 'QC',
|
||||
schNoticeClsCd: 'NOTICE',
|
||||
@ -88,6 +87,7 @@ export default function MainContents({ setFaqOpen, setFaqModalNoticeNo }) {
|
||||
endRow: 1,
|
||||
}
|
||||
const noticeApiUrl = `api/board/list?${queryStringFormatter(param)}`
|
||||
try {
|
||||
await promiseGet({ url: noticeApiUrl }).then((res) => {
|
||||
if (res.status === 200) {
|
||||
setRecentNoticeList(res.data.data)
|
||||
@ -111,7 +111,6 @@ export default function MainContents({ setFaqOpen, setFaqModalNoticeNo }) {
|
||||
|
||||
//FAQ 호출
|
||||
const fetchFaqList = async () => {
|
||||
try {
|
||||
const param = {
|
||||
schNoticeTpCd: 'QC',
|
||||
schNoticeClsCd: 'FAQ',
|
||||
@ -119,6 +118,7 @@ export default function MainContents({ setFaqOpen, setFaqModalNoticeNo }) {
|
||||
endRow: 3,
|
||||
}
|
||||
const faqApiUrl = `api/board/list?${queryStringFormatter(param)}`
|
||||
try {
|
||||
await promiseGet({
|
||||
url: faqApiUrl,
|
||||
}).then((res) => {
|
||||
|
||||
@ -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 {
|
||||
adsorptionPointModeState,
|
||||
@ -119,6 +119,12 @@ export function useCanvasSetting(executeEffect = true) {
|
||||
const [basicSetting, setBasicSettings] = useRecoilState(basicSettingState)
|
||||
const { getRoofMaterialList, getModuleTypeItemList } = useMasterController()
|
||||
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 setCurrentMenu = useSetRecoilState(currentMenuState)
|
||||
|
||||
@ -321,20 +327,22 @@ export function useCanvasSetting(executeEffect = true) {
|
||||
const targetObjectNo = objectNo || correntObjectNo
|
||||
|
||||
// 지붕재 데이터가 없으면 Header.jsx에서 로드될 때까지 기다림
|
||||
let materials = roofMaterials
|
||||
// [ROOFMAT-POLL-FIX 2026-06-29] roofMaterialsRef 로 최신값을 폴링 → 데이터가 들어오는 즉시 종료.
|
||||
// (이전엔 stale closure 로 항상 풀카운트를 소모해 select 가 늦게 떴음)
|
||||
let materials = roofMaterialsRef.current
|
||||
if (!materials || materials.length === 0) {
|
||||
logger.log('Waiting for roofMaterials to be loaded from Header.jsx...')
|
||||
// 최대 2초간 기다림 (20번 × 100ms)
|
||||
// 최대 1초간 기다림 (10번 × 100ms), 데이터 도착 시 즉시 break
|
||||
let waitCount = 0
|
||||
while ((!materials || materials.length === 0) && waitCount < 20) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
materials = roofMaterials
|
||||
while ((!materials || materials.length === 0) && waitCount < 10) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
materials = roofMaterialsRef.current
|
||||
waitCount++
|
||||
}
|
||||
|
||||
if (!materials || materials.length === 0) {
|
||||
logger.log('roofMaterials still not loaded after 2 seconds, proceeding without them')
|
||||
// 비상시에만 addRoofMaterials 호출 (fallback)
|
||||
logger.log('roofMaterials still not loaded after 1 second, proceeding via fallback')
|
||||
// 10회 안에 못 받으면 fallback 직접 호출 (getRoofMaterialList promise cache 로 중복 네트워크 방지)
|
||||
materials = await addRoofMaterials()
|
||||
logger.log('roofMaterials loaded via fallback:', materials)
|
||||
} 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) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1411,6 +1411,8 @@ export function useRoofAllocationSetting(id) {
|
||||
// 이 선들은 외곽 gable 변과 동일직선상에서 더 짧은 경로를 제공하여 Dijkstra 가
|
||||
// degenerate collinear face 를 먼저 선택 → 진짜 top/bottom 사다리꼴 면의 start 가
|
||||
// 소진돼 미생성된다. 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')) {
|
||||
const skHelpers = roofBase.innerLines.filter((l) => !l?.lineName && l?.name === 'hip')
|
||||
skHelpers.forEach((l) => canvas.remove(l))
|
||||
|
||||
@ -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
|
||||
let newRoofs = getSplitRoofsPoints(allLines)
|
||||
|
||||
|
||||
@ -60,6 +60,7 @@
|
||||
"modal.placement.initial.setting.roof.angle": "角度",
|
||||
"modal.placement.initial.setting.roof.material": "屋根材選択(単位mm)",
|
||||
"modal.placement.initial.setting.roof.material.info": "対応可能な屋根材や足場は限定されますので、必ず事前マニュアルをご確認ください。",
|
||||
"canvas.roof.material.not.found": "屋根材情報を読み込めませんでした。ページを再読み込みするか、屋根材設定をご確認ください。",
|
||||
"modal.placement.initial.setting.rafter": "垂木",
|
||||
"modal.roof.shape.setting": "屋根形状の設定",
|
||||
"modal.roof.shape.setting.ridge": "棟",
|
||||
|
||||
@ -60,6 +60,7 @@
|
||||
"modal.placement.initial.setting.roof.angle": "각도",
|
||||
"modal.placement.initial.setting.roof.material": "지붕재 선택(단위mm)",
|
||||
"modal.placement.initial.setting.roof.material.info": "대응 가능한 지붕재 및 발판은 한정되므로 반드시 사전 매뉴얼을 확인하십시오.",
|
||||
"canvas.roof.material.not.found": "지붕재 정보를 불러오지 못했습니다. 페이지를 새로고침하거나 지붕재 설정을 확인해 주세요.",
|
||||
"modal.placement.initial.setting.rafter": "서까래",
|
||||
"modal.roof.shape.setting": "지붕형상 설정",
|
||||
"modal.roof.shape.setting.ridge": "용마루",
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
// (dev 콘솔에서 수동: window.__checkKerabRules(roof.innerLines))
|
||||
|
||||
import { logger } from '@/util/logger'
|
||||
import { debugCapture } from '@/util/debugCapture'
|
||||
|
||||
const ENABLED = process.env.NEXT_PUBLIC_ENABLE_LOGGING === 'true'
|
||||
|
||||
@ -23,7 +24,7 @@ const DEFAULTS = {
|
||||
angleDegEps: 1.0, // 45°/축정렬 허용 오차(도). 불변식상 정확해야 하므로 작게 — drift 를 잡는 게 목적.
|
||||
pointEps: 0.5, // 같은 점 판정(메모리: UI/Big.js drift 고려 넉넉히).
|
||||
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 isBox = (ln) => ln?.lineName === 'kerabValleyOverlapLine' || ln?.attributes?.type === 'kerabValleyOverlapLine'
|
||||
const isHip = (ln) => !isBox(ln) && typeOf(ln) === 'hip'
|
||||
const isRidge = (ln) => !isBox(ln) && typeOf(ln) === 'ridge'
|
||||
// 힙의 도메인 정의 = 정사각형 대각선(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])
|
||||
}
|
||||
// [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 groups = new Map()
|
||||
for (const ln of lines) {
|
||||
@ -95,9 +129,254 @@ const buildBoxes = (lines, pad) => {
|
||||
g.maxY = Math.max(g.maxY, c.y1, c.y2)
|
||||
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)
|
||||
// 박스 *내부*(경계 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) => {
|
||||
const ip = segCross(ca, cb, ctx.pointEps)
|
||||
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)}) 에서 관통` }
|
||||
},
|
||||
// [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',
|
||||
@ -156,9 +463,10 @@ const RULES = [
|
||||
kind: 'junction',
|
||||
enabled: true,
|
||||
check: (j, ctx) => {
|
||||
if (pointInBoxes(j.point, ctx.boxes)) return null
|
||||
const hips = j.spokes.filter((s) => isHip(s.line))
|
||||
const ridges = j.spokes.filter((s) => isRidge(s.line))
|
||||
if (pointInBoxes(j.point, ctx.exclBoxes)) return null // 박스 내부 교점 = 멈춤(쐐기) 열외(出幅 사각형 포함)
|
||||
// 이름이 아니라 방향으로 분류 — 'default' 타입 힙/마루도 \|/ 로 잡는다.
|
||||
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
|
||||
for (const r of ridges) {
|
||||
for (let i = 0; i < hips.length; i++) {
|
||||
@ -172,9 +480,228 @@ const RULES = [
|
||||
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)에 있나.
|
||||
@ -196,9 +723,12 @@ const buildJunctions = (lines, eps) => {
|
||||
node.spokes.push({ line, dir: outDir })
|
||||
}
|
||||
for (const ln of lines) {
|
||||
if (!(isHip(ln) || isRidge(ln))) continue
|
||||
if (isBox(ln)) continue
|
||||
const c = coords(ln)
|
||||
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 e = { x: c.x2, y: c.y2 }
|
||||
addSpoke(s, dir(c), ln) // s 에서 바깥 = s→e
|
||||
@ -227,10 +757,70 @@ export function checkKerabRules(lines, opts = {}) {
|
||||
pointEps: opts.pointEps ?? DEFAULTS.pointEps,
|
||||
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 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))
|
||||
|
||||
@ -241,8 +831,11 @@ export function checkKerabRules(lines, opts = {}) {
|
||||
if (rule.applies && !rule.applies(ln)) continue
|
||||
const c = coords(ln)
|
||||
if (!c) continue
|
||||
const r = rule.check(c, ctx)
|
||||
if (r) push(rule, { lineIds: [lineId(ln)], type: typeOf(ln), ...r })
|
||||
const r = rule.check(c, ctx, ln)
|
||||
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)
|
||||
if (!ca || !cb) continue
|
||||
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 규칙
|
||||
const needJunction = RULES.some((r) => r.enabled && r.kind === 'junction')
|
||||
if (needJunction) {
|
||||
const junctions = buildJunctions(lines, ctx.pointEps)
|
||||
ctx.junctions = buildJunctions(lines, ctx.pointEps) // fix(R-RIDGE-GEN 생성)가 마주보는 봉우리 탐색에 쓴다.
|
||||
for (const rule of RULES) {
|
||||
if (!rule.enabled || rule.kind !== 'junction') continue
|
||||
for (const j of junctions) {
|
||||
for (const j of ctx.junctions) {
|
||||
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,
|
||||
boxes: ctx.boxes.length,
|
||||
}
|
||||
const result = { violations, boxes: ctx.boxes, stats }
|
||||
const result = { violations, boxes: ctx.boxes, stats, fixes }
|
||||
|
||||
if (!opts.silent) {
|
||||
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})`)
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* [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 콘솔에서 수동 호출용.
|
||||
if (typeof window !== 'undefined' && ENABLED) {
|
||||
window.__checkKerabRules = checkKerabRules
|
||||
window.__checkAndFixKerabRules = checkAndFixKerabRules
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import { QPolygon } from '@/components/fabric/QPolygon'
|
||||
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||
import Big from 'big.js'
|
||||
import * as turf from '@turf/turf'
|
||||
// import { debugCapture } from '@/util/debugCapture' // [KERAB-GABLE-DIAG 2026-06-29] 박공 마루 미생성 추적용
|
||||
|
||||
const TWO_PI = Math.PI * 2
|
||||
const EPSILON = 1e-10 //좌표계산 시 최소 차이값
|
||||
@ -1569,6 +1570,7 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
|
||||
|
||||
//각 포인트들을 직교하도록 정렬
|
||||
const sortedPoints = getSortedOrthogonalPoints(roofPlanePoint)
|
||||
// logger.log(`[GABLE-H8-PLANE] planeDir ridges=${ridgeLines.length}`)
|
||||
sortedPoints.forEach((currPoint, index) => {
|
||||
const nextPoint = sortedPoints[(index + 1) % sortedPoints.length]
|
||||
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
|
||||
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) {
|
||||
return true
|
||||
}
|
||||
@ -1651,6 +1654,56 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
|
||||
drawRoofPlane(backward)
|
||||
})
|
||||
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) 제거.
|
||||
// 외벽선(wall) 치수는 유지. (박공은 split 공용 함수를 안 거치므로 여기서 직접 처리)
|
||||
roof.set({ stroke: 'transparent' })
|
||||
@ -3543,8 +3596,14 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
const prevIndex = baseLines.findIndex((line) => line === prevLine)
|
||||
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) {
|
||||
// [KERAB-GABLE-DIAG 2026-06-29] ②원인→④결과: 양옆이 처마가 아님 → 마루 미생성
|
||||
// debugCapture.log('KERAB-GABLE-DIAG ②원인:neighbor-not-eaves → ④결과:no-ridge', { gable: __gcoord })
|
||||
return
|
||||
}
|
||||
|
||||
@ -3556,6 +3615,11 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
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)) {
|
||||
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) {
|
||||
const ridgePoints = []
|
||||
|
||||
@ -4130,6 +4201,14 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
|
||||
if (stdAnalyze.isHorizontal) {
|
||||
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
|
||||
}
|
||||
const dist1 = Math.abs(ridgePoint[0] - overlapMinX)
|
||||
@ -4147,6 +4226,14 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
}
|
||||
if (stdAnalyze.isVertical) {
|
||||
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
|
||||
}
|
||||
const dist1 = Math.abs(ridgePoint[1] - overlapMinY)
|
||||
@ -4263,7 +4350,19 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
type: TYPES.RIDGE,
|
||||
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) {
|
||||
// [KERAB-GABLE-DIAG 2026-06-29] ③분기: 양옆 벡터 동일(U자) → 반절마루 불가, GABLE_LINE 분기로
|
||||
// debugCapture.log('KERAB-GABLE-DIAG ③분기:U-shape-gableline', { gable: __gcoord })
|
||||
const analyze = analyzeLine(currentLine)
|
||||
const roofLine = analyze.roofLine
|
||||
|
||||
@ -4333,6 +4434,24 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
while (linesAnalysis.length > 0 && iterations < MAX_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 = []
|
||||
linesAnalysis.forEach((currLine, i) => {
|
||||
let minDistance = Infinity
|
||||
@ -4383,6 +4502,25 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
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
|
||||
.map((item) => item.intersect)
|
||||
.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) {
|
||||
const cLine = linesAnalysis[index]
|
||||
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)
|
||||
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
|
||||
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)
|
||||
|
||||
// [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 point2 = pIntersection.linePoint
|
||||
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)
|
||||
}
|
||||
|
||||
// [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 (cLine.type === TYPES.HIP) {
|
||||
innerLines.push(drawHipLine(point1, canvas, roof, textMode, cLine.degree, cLine.degree))
|
||||
} 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))
|
||||
} else if (cLine.type === TYPES.NEW) {
|
||||
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 (pLine.type === TYPES.HIP) {
|
||||
innerLines.push(drawHipLine(point2, canvas, roof, textMode, pLine.degree, pLine.degree))
|
||||
} 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))
|
||||
} else if (pLine.type === TYPES.NEW) {
|
||||
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) {
|
||||
const analyze = linesAnalysis[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({
|
||||
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,
|
||||
right: analyze.right,
|
||||
type: analyze.type,
|
||||
@ -4687,6 +4921,23 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
// 처리된 가선분 제외
|
||||
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
|
||||
.getObjects()
|
||||
.filter((object) => object.name === 'check' || object.name === 'checkAnalysis')
|
||||
@ -4695,6 +4946,21 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
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 = []
|
||||
linesAnalysis
|
||||
@ -4785,36 +5051,77 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
|
||||
// 최종 라인중 벽에서 시작해서 벽에서 끝나는 라인이 남을 경우(벽취함)
|
||||
if (linesAnalysis.length > 0) {
|
||||
linesAnalysis
|
||||
.filter((line) => {
|
||||
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) => {
|
||||
// 길이 0 잔여 제거
|
||||
const residualLines = linesAnalysis.filter((line) => Math.hypot(line.end.x - line.start.x, line.end.y - line.start.y) > 0)
|
||||
|
||||
// 순서 비의존 연결성 판정용 끝점 집합: 이미 그려진 라인(innerLines) + 아직 안 그려진 잔여 라인(residualLines) 전부.
|
||||
// 끝점이 지붕선 위(앵커)이거나 다른 라인 끝점과 공유(접합)되면 "연결됨". 양끝이 모두 연결된 라인만 그린다.
|
||||
// 이렇게 하면 마루↔마루/마루↔힙 접합도 그리기 순서와 무관하게 살아남고, 자유단(고립)을 가진 라인만 소멸한다.
|
||||
const allEndpoints = []
|
||||
innerLines.forEach((il) => allEndpoints.push({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }))
|
||||
residualLines.forEach((rl) => allEndpoints.push({ x: rl.start.x, y: rl.start.y }, { x: rl.end.x, y: rl.end.y }))
|
||||
// UI/Big.js drift 로 끝점 좌표가 미세하게 어긋날 수 있어 0.5 tolerance 사용 (feedback_same_point_tolerance)
|
||||
const SHARED_EPS = 0.5
|
||||
const sharedCnt = (pt) => allEndpoints.filter((p) => almostEqual(p.x, pt.x, SHARED_EPS) && almostEqual(p.y, pt.y, SHARED_EPS)).length
|
||||
|
||||
residualLines.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 (
|
||||
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
|
||||
) {
|
||||
if (line.type === TYPES.RIDGE && baseLines.length === 4 && (startOnLine || endOnLine)) {
|
||||
// 자기 자신이 끝점마다 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 (startOnLine && endOnLine) {
|
||||
if (line.degree === 0) {
|
||||
} 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,
|
||||
// })
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -5934,6 +6241,27 @@ const drawRidgeLine = (points, canvas, roof, textMode) => {
|
||||
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
|
||||
@ -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)
|
||||
ridge.bringToFront()
|
||||
canvas.renderAll()
|
||||
@ -6400,7 +6733,7 @@ export const equalizeSymmetricHips = (hipLines, canvas) => {
|
||||
text.set({ planeSize: plane, actualSize: actual })
|
||||
const isPlane = !text.actualSize || text.text === String(text.planeSize)
|
||||
// planeSize/actualSize 모두 저장값(정수 또는 0.5 step) 그대로 표시.
|
||||
const __raw = isPlane ? plane : actual
|
||||
// const __raw = isPlane ? plane : actual
|
||||
text.set({ text: Number(__raw).toFixed(1).replace(/\.0$/, '') })
|
||||
}
|
||||
}
|
||||
@ -6472,7 +6805,7 @@ export const equalizeParallelEaveLabels = (lines, canvas) => {
|
||||
}
|
||||
if (text) {
|
||||
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$/, '') })
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user