diff --git a/src/components/fabric/QPolygon.js b/src/components/fabric/QPolygon.js index 5486c54b..96ca4b72 100644 --- a/src/components/fabric/QPolygon.js +++ b/src/components/fabric/QPolygon.js @@ -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() } diff --git a/src/components/main/MainContents.jsx b/src/components/main/MainContents.jsx index f20bfaf7..52117630 100644 --- a/src/components/main/MainContents.jsx +++ b/src/components/main/MainContents.jsx @@ -80,14 +80,14 @@ export default function MainContents({ setFaqOpen, setFaqModalNoticeNo }) { //공지사항 호출 const fetchNoticeList = async () => { + const param = { + schNoticeTpCd: 'QC', + schNoticeClsCd: 'NOTICE', + startRow: 1, + endRow: 1, + } + const noticeApiUrl = `api/board/list?${queryStringFormatter(param)}` try { - const param = { - schNoticeTpCd: 'QC', - schNoticeClsCd: 'NOTICE', - startRow: 1, - endRow: 1, - } - const noticeApiUrl = `api/board/list?${queryStringFormatter(param)}` await promiseGet({ url: noticeApiUrl }).then((res) => { if (res.status === 200) { setRecentNoticeList(res.data.data) @@ -111,14 +111,14 @@ export default function MainContents({ setFaqOpen, setFaqModalNoticeNo }) { //FAQ 호출 const fetchFaqList = async () => { + const param = { + schNoticeTpCd: 'QC', + schNoticeClsCd: 'FAQ', + startRow: 1, + endRow: 3, + } + const faqApiUrl = `api/board/list?${queryStringFormatter(param)}` try { - const param = { - schNoticeTpCd: 'QC', - schNoticeClsCd: 'FAQ', - startRow: 1, - endRow: 3, - } - const faqApiUrl = `api/board/list?${queryStringFormatter(param)}` await promiseGet({ url: faqApiUrl, }).then((res) => { diff --git a/src/hooks/option/useCanvasSetting.js b/src/hooks/option/useCanvasSetting.js index 49c7814a..8379ab59 100644 --- a/src/hooks/option/useCanvasSetting.js +++ b/src/hooks/option/useCanvasSetting.js @@ -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) { diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index aadbea50..5cfb3961 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -12,23 +12,6 @@ import { usePopup } from '@/hooks/usePopup' import { getChonByDegree } from '@/util/canvas-util' import { settingModalFirstOptionsState } from '@/store/settingAtom' import { useUndoRedo } from '@/hooks/useUndoRedo' -import { fabric } from 'fabric' -import { QLine } from '@/components/fabric/QLine' -import { reattachDebugLabels } from '@/components/fabric/QPolygon' -import { calcLinePlaneSize } from '@/util/qpolygon-utils' -import { findInteriorPoint } from '@/util/skeleton-utils' -import { logger } from '@/util/logger' -import { checkKerabRules } from '@/util/kerab-rule-checker' -import { debugCapture } from '@/util/debugCapture' -import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical' - -// [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 시 出幅 변경분 surgical 반영 기능 토글. -// false 로 두면 이번 세션 변경 전 동작 (apply/revert 시 출폭 입력값 무시) 으로 즉시 회귀. -const ENABLE_KERAB_OFFSET_SURGICAL = true - -// [KERAB-TYPE-EAVES 2026-06-11] TYPE(A/B 박공) 출신 케라바→처마 전용 변환 토글. -// false 면 기존 applyKerabRevertPattern 폴백(토글 이력 기반) 으로 회귀. -const ENABLE_TYPE_GABLE_TO_EAVES = true // 처마.케라바 변경 export function useEavesGableEdit(id) { @@ -159,337 +142,11 @@ export function useEavesGableEdit(id) { canvas.renderAll() } - // [KERAB-RULE-CHECK 2026-06-10] 케라바(처마↔게이블) 토글 종료 시 결과가 도메인 규칙에 - // 맞는지 자동 판정하는 진단 단계. forward/revert 양쪽 끝에서 호출. 로컬 전용 — 위반은 - // logger.warn 로 위반 라인만 덤프(production 은 DCE 로 제거). - // 규칙: - // R1 dangling: 모든 visible hip/ridge 끝점은 roofLine 코너에 닿거나 다른 inner line 과 - // 공유돼야 한다(떠 있는 끝점=벽 교점/코너 이탈). 골짜기 내부 hip 은 양 끝이 - // 다른 라인과 공유되므로 자동 통과(예외 불필요). - // R2 zero-length: 길이 0 으로 붕괴된 visible 라인(=라인 소실) 금지. - // R3 outside: 끝점이 roofLine 폴리곤 밖(경계 tol 초과)으로 이탈 금지. - // R4 anchor: 토글 전후로 움직이지 않은 roofLine 코너(stable corner)의 끝점 점유수 불변 - // (코너에서 hip 이 떨어지거나 엉뚱한 코너로 횡단하면 점유수 변화). - // [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (1/2): 케라바 hip(확장라인) 식별. - // reclick 검증 코드에서 무손실 추출. surgical 적용 *전*(옛 폴리곤) 에 호출해야 한다 — - // outer endpoint = 옛 roof.points 변 위 끝점, side = hip 직선이 지나는 wall 코너(A/B). - const detectKerabHipMarks = (target, roof) => { - const marks = [] - if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return marks - const wA = { x: target.x1, y: target.y1 } - const wB = { x: target.x2, y: target.y2 } - const pts = roof.points - const isOnOldPolyEdge = (P) => { - for (let i = 0; i < pts.length; i++) { - const A = pts[i] - const B = pts[(i + 1) % pts.length] - const ddx = B.x - A.x - const ddy = B.y - A.y - const lenSq = ddx * ddx + ddy * ddy - if (lenSq < 1e-6) continue - const t = ((P.x - A.x) * ddx + (P.y - A.y) * ddy) / lenSq - if (t < -0.02 || t > 1.02) continue - const projX = A.x + t * ddx - const projY = A.y + t * ddy - const d = Math.hypot(P.x - projX, P.y - projY) - if (d < 2.0) return true - } - return false - } - for (const il of roof.innerLines) { - if (!il || (il.lineName !== 'hip' && il.lineName !== 'kerabPatternHip')) continue - const e1 = { x: il.x1, y: il.y1 } - const e2 = { x: il.x2, y: il.y2 } - const e1OnEdge = isOnOldPolyEdge(e1) - const e2OnEdge = isOnOldPolyEdge(e2) - let which = null - if (e1OnEdge && !e2OnEdge) which = 1 - else if (e2OnEdge && !e1OnEdge) which = 2 - if (which === null) continue - const outerEnd = which === 1 ? e1 : e2 - const innerEnd = which === 1 ? e2 : e1 - const ddx = outerEnd.x - innerEnd.x - const ddy = outerEnd.y - innerEnd.y - const llen = Math.hypot(ddx, ddy) || 1 - const distPerp = (P) => Math.abs((ddx * (innerEnd.y - P.y) - ddy * (innerEnd.x - P.x)) / llen) - const dA = distPerp(wA) - const dB = distPerp(wB) - if (Math.min(dA, dB) > 5.0) continue - const side = dA <= dB ? 'A' : 'B' - marks.push({ il, which, side }) - } - logger.log( - '[KERAB-OFFSET-ONLY-RECLICK-EAVES] hip marks ' + - JSON.stringify(marks.map((m) => ({ which: m.which, side: m.side, lineName: m.il.lineName }))), - ) - return marks - } - // [KERAB-OFFSET-HELPER 2026-06-15] 출폭함수 빌딩블록 (2/2): 케라바 hip 을 45° ray-cast 확장. - // surgical 적용 *후*(새 폴리곤) 에 호출. wall 코너에서 옛 hip 방향(45°)으로 새 roofLine 변까지. - // CORNER-SNAP 금지 룰의 실제 구현부 — roofLine 코너가 아니라 교점까지 뻗는다. - // [45-UNIFY 2026-06-15] reclick·revert 두 경로의 45° 구현을 한 벌로 통합. 가드는 opt-in: - // - useRevertGuards=false(reclick): 순수 45° ray-cast (기존 reclick 동작 그대로). - // - useRevertGuards=true(revert): outerShared(골짜기 내부 hip 제외) + outerOnVertex(게이블 코너 - // hip 은 같은 인덱스 새 꼭짓점으로 직접 스냅) 가드 적용. oldPolyPoints = 이동 전 폴리곤 꼭짓점. - const extendKerabHipsTo45 = (target, roof, hipMarks, options = {}) => { - if (!roof || !Array.isArray(roof.points) || !hipMarks || !hipMarks.length) return - const { useRevertGuards = false, oldPolyPoints = null } = options - const wA = { x: target.x1, y: target.y1 } - const wB = { x: target.x2, y: target.y2 } - const rps = roof.points - const M = rps.length - if (!M) return - const baseTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45]' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES]' - const okTag = useRevertGuards ? '[KERAB-REVERT-EXTEND-45] ' : '[KERAB-OFFSET-ONLY-RECLICK-EAVES] extended ' - const rayHit = (P, dir, A, B) => { - const sx = B.x - A.x - const sy = B.y - A.y - const denom = dir.x * sy - dir.y * sx - if (Math.abs(denom) < 1e-9) return Infinity - const ax = A.x - P.x - const ay = A.y - P.y - const t = (ax * sy - ay * sx) / denom - const s = (ax * dir.y - ay * dir.x) / denom - if (t > 1e-6 && s >= -1e-6 && s <= 1 + 1e-6) return t - return Infinity - } - const cast45 = (wCorner, innerEnd, outerOld, il, which, side) => { - const dx = outerOld.x - innerEnd.x - const dy = outerOld.y - innerEnd.y - const dlen = Math.hypot(dx, dy) - if (dlen < 1e-6) return null - const ux = dx / dlen - const uy = dy / dlen - let bestT = Infinity - for (let k = 0; k < M; k++) { - const A = rps[k] - const B = rps[(k + 1) % M] - const t = rayHit(wCorner, { x: ux, y: uy }, A, B) - if (t < bestT) bestT = t - } - if (!isFinite(bestT) || bestT < 0.5) { - logger.log(baseTag + ' no-hit ' + JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } })) - return null - } - return { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT } - } - for (const mark of hipMarks) { - const { il, which, side } = mark - const wCorner = side === 'A' ? wA : wB - const innerEnd = which === 1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } - const outerOld = which === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } - let hit - if (useRevertGuards) { - // outerShared: outer 끝이 다른 내부선과 공유 → 골짜기 내부 hip → 확장 스킵. - const SHARE_TOL = 2.0 - const outerShared = (roof.innerLines || []).some( - (o) => - o && - o !== il && - o.visible !== false && - (Math.hypot(o.x1 - outerOld.x, o.y1 - outerOld.y) < SHARE_TOL || - Math.hypot(o.x2 - outerOld.x, o.y2 - outerOld.y) < SHARE_TOL), - ) - // outerOnVertex: outer 가 (이동 전) 폴리곤 꼭짓점이면 게이블 코너 hip → 같은 인덱스 새 꼭짓점 스냅. - const VERT_TOL = 2.0 - const opp = Array.isArray(oldPolyPoints) ? oldPolyPoints : [] - let vtxIdx = -1 - for (let vi = 0; vi < opp.length; vi++) { - const p = opp[vi] - if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) { - vtxIdx = vi - break - } - } - const outerOnVertex = vtxIdx >= 0 - if (outerShared && !outerOnVertex) { - logger.log(baseTag + ' skip (interior hip — outer shared) ' + JSON.stringify({ lineName: il.lineName, which, side, outerOld })) - continue - } - if (outerOnVertex && rps.length === opp.length && rps[vtxIdx]) { - hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y } - } else { - hit = cast45(wCorner, innerEnd, outerOld, il, which, side) - if (!hit) continue - } - } else { - hit = cast45(wCorner, innerEnd, outerOld, il, which, side) - if (!hit) continue - } - const oldLen = Math.hypot(outerOld.x - innerEnd.x, outerOld.y - innerEnd.y) - const newLen = Math.hypot(hit.x - innerEnd.x, hit.y - innerEnd.y) - const ratio = oldLen > 0 ? newLen / oldLen : 1 - if (which === 1) il.set({ x1: hit.x, y1: hit.y }) - else il.set({ x2: hit.x, y2: hit.y }) - if (il.attributes) { - const oldPlane = il.attributes.planeSize ?? 0 - const oldActual = il.attributes.actualSize ?? 0 - il.attributes.planeSize = Math.round(oldPlane * ratio * 100) / 100 - il.attributes.actualSize = Math.round(oldActual * ratio * 100) / 100 - il.attributes.extended = true - } - il.__extended = true - if (typeof il.setCoords === 'function') il.setCoords() - if (typeof il.setLength === 'function') il.setLength() - if (typeof il.addLengthText === 'function') il.addLengthText() - logger.log( - okTag + - JSON.stringify({ - lineName: il.lineName, - which, - side, - hit, - ratio: Number(ratio.toFixed(3)), - x1: il.x1, - y1: il.y1, - x2: il.x2, - y2: il.y2, - }), - ) - } - } - const snapshotKerabState = (roof) => { - if (!roof || !Array.isArray(roof.innerLines)) return null - const lines = roof.innerLines - .filter((l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE) && l.visible !== false) - .map((l) => ({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 })) - const points = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y })) - return { lines, points } - } - const runKerabRuleCheck = (roof, phase, before) => { - try { - if (!roof || !Array.isArray(roof.innerLines)) return - const TOL = 2.0 - const OUT_TOL = 3.0 - const ZERO = 1.0 - const r1 = (n) => Math.round(n * 10) / 10 - const fails = [] - const rpts = Array.isArray(roof.points) ? roof.points : [] - // 검사 대상 = visible 마루/힙. 끝점 공유(접합) 판정에는 골짜기확장(VALLEY)까지 포함 — - // RG-1 확장(kerabPatternExtRidge)은 vExt(VALLEY) 위에서 끝나므로 VALLEY 를 빼면 오탐. - const visLines = roof.innerLines.filter( - (l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE) && l.visible !== false, - ) - const connLines = roof.innerLines.filter( - (l) => - l && - (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY) && - l.visible !== false, - ) - const info = (l) => ({ n: l.name, ln: l.lineName || '-', x1: r1(l.x1), y1: r1(l.y1), x2: r1(l.x2), y2: r1(l.y2) }) - const onCorner = (p) => rpts.some((c) => c && Math.hypot(c.x - p.x, c.y - p.y) < TOL) - const ends = [] - for (const l of connLines) { - ends.push({ x: l.x1, y: l.y1, line: l }) - ends.push({ x: l.x2, y: l.y2, line: l }) - } - const sharedWithOther = (p, self) => ends.some((e) => e.line !== self && Math.hypot(e.x - p.x, e.y - p.y) < TOL) - // 폴리곤 내부/경계 판정 (ray-casting + edge 거리 tol) - const pip = (pt) => { - let inside = false - for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) { - const xi = rpts[i].x - const yi = rpts[i].y - const xj = rpts[j].x - const yj = rpts[j].y - const intersect = yi > pt.y !== yj > pt.y && pt.x < ((xj - xi) * (pt.y - yi)) / (yj - yi + 1e-12) + xi - if (intersect) inside = !inside - } - return inside - } - const distToSeg = (p, a, b) => { - const dx = b.x - a.x - const dy = b.y - a.y - const l2 = dx * dx + dy * dy || 1 - let t = ((p.x - a.x) * dx + (p.y - a.y) * dy) / l2 - t = Math.max(0, Math.min(1, t)) - return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy)) - } - const minEdgeDist = (pt) => { - let m = Infinity - for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) { - const d = distToSeg(pt, rpts[j], rpts[i]) - if (d < m) m = d - } - return m - } - for (const l of visLines) { - const endpts = [ - { x: l.x1, y: l.y1 }, - { x: l.x2, y: l.y2 }, - ] - // R2 zero-length - if (Math.hypot(l.x2 - l.x1, l.y2 - l.y1) < ZERO) { - fails.push({ rule: 'R2-zero-length', line: info(l) }) - } - for (const p of endpts) { - // R1 dangling: 끝점은 roofLine 경계(코너 + 변)에 닿거나 다른 내부선과 공유돼야 한다. - // kLine(중앙 마루)·게이블 hip 은 roofLine '코너'가 아닌 '변' 중간에 닿는 게 정상 → - // 코너만 보면 오탐. minEdgeDist 로 변까지 포함해 경계 도달을 판정한다. - const onBoundary = rpts.length >= 3 ? minEdgeDist(p) <= TOL : onCorner(p) - if (!onBoundary && !sharedWithOther(p, l)) { - fails.push({ rule: 'R1-dangling', line: info(l), at: { x: r1(p.x), y: r1(p.y) } }) - } - // R3 outside roofLine - if (rpts.length >= 3 && !pip(p) && minEdgeDist(p) > OUT_TOL) { - fails.push({ rule: 'R3-outside', line: info(l), at: { x: r1(p.x), y: r1(p.y) } }) - } - } - } - // R4 anchor: stable roofLine corner 점유수 불변 - if (before && Array.isArray(before.points) && Array.isArray(before.lines)) { - const countOn = (lineArr, c) => { - let n = 0 - for (const l of lineArr) { - if (Math.hypot(l.x1 - c.x, l.y1 - c.y) < TOL) n++ - if (Math.hypot(l.x2 - c.x, l.y2 - c.y) < TOL) n++ - } - return n - } - const afterPlain = visLines.map((l) => ({ x1: l.x1, y1: l.y1, x2: l.x2, y2: l.y2 })) - for (const c of rpts) { - const stable = before.points.some((b) => Math.hypot(b.x - c.x, b.y - c.y) < TOL) - if (!stable) continue - const bN = countOn(before.lines, c) - const aN = countOn(afterPlain, c) - if (bN !== aN) { - fails.push({ rule: 'R4-anchor', at: { x: r1(c.x), y: r1(c.y) }, before: bN, after: aN }) - } - } - } - if (fails.length) { - logger.warn('[KERAB-RULE-CHECK] ' + phase + ' FAIL(' + fails.length + ') ' + JSON.stringify(fails)) - } else { - logger.log('[KERAB-RULE-CHECK] ' + phase + ' PASS') - } - // 기하 불변식 체커(R-45HIP/R-AXISRIDGE/R-CROSS/R-WEDGE). 위 R1~R4 와 상호보완. - checkKerabRules(roof.innerLines, { roofId: roof.id, label: phase }) - } catch (err) { - logger.warn('[KERAB-RULE-CHECK] error', err) - } - } - const mouseDownEvent = (e) => { - // [KERAB-MOUSEDOWN-GUARD 2026-05-29] outerLine 아닌 target(ridge/lengthText 등) 클릭 시 - // discardActiveObject·로그·후속 처리 모두 skip — 다른 hook 의 active 흐름 보호. - if (!e.target || e.target.name !== 'outerLine') { + canvas.discardActiveObject() + if (!e.target || (e.target && e.target.name !== 'outerLine')) { return } - logger.log( - '[KERAB-MOUSEDOWN] fired ' + - JSON.stringify({ - hasTarget: !!e.target, - name: e.target?.name, - type: typeRef.current, - radio: radioTypeRef.current, - targetType: e.target?.attributes?.type, - x1: e.target?.x1, - y1: e.target?.y1, - x2: e.target?.x2, - y2: e.target?.y2, - }), - ) - canvas.discardActiveObject() const target = e.target @@ -556,2994 +213,6 @@ export function useEavesGableEdit(id) { } saveSnapshot() - // [KERAB-STATE-DUMP 2026-06-11] A/B타입(가로/세로 케라바) 토글 진입 시점 지붕 상태 진단. - // 벽 type 지정은 있는데 내부 skLine 이 0인 "무에서 유" 케이스 파악용 — 사용자 설명 전 사실 수집. - // 읽기 전용(좌표/visible/type 만 덤프), 동작 변경 없음. logger 게이트(local 만 출력). - { - const _dr = canvas - .getObjects() - .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) - const _r1 = (v) => (typeof v === 'number' ? Math.round(v * 10) / 10 : v) - logger.log( - '[KERAB-STATE-DUMP] ' + - JSON.stringify({ - uiType: typeRef.current, - radio: radioTypeRef.current, - target: { - type: target.attributes?.type, - offset: target.attributes?.offset, - x1: _r1(target.x1), - y1: _r1(target.y1), - x2: _r1(target.x2), - y2: _r1(target.y2), - }, - willBecome: attributes?.type, - roofId: target.attributes?.roofId, - roofFound: !!_dr, - points: _dr?.points?.map((p) => ({ x: _r1(p.x), y: _r1(p.y) })), - lines: _dr?.lines?.map((l) => ({ - type: l.attributes?.type, - offset: l.attributes?.offset, - x1: _r1(l.x1), - y1: _r1(l.y1), - x2: _r1(l.x2), - y2: _r1(l.y2), - })), - innerCount: (_dr?.innerLines || []).length, - innerLines: (_dr?.innerLines || []).map((il) => ({ - lineName: il.lineName, - type: il.attributes?.type, - visible: il.visible !== false, - x1: _r1(il.x1), - y1: _r1(il.y1), - x2: _r1(il.x2), - y2: _r1(il.y2), - })), - }), - ) - } - - // [2240 KERAB-NOOP-REKLICK 2026-05-19] 같은 type 으로의 재클릭은 무동작. - // - 케라바→케라바, 처마→처마 등. 기존 rebuild 흐름이 다시 돌면 패턴 상태 - // (ridge/half-label/orphan ext 정리)를 망가뜨림. - // - radio 1 의 단순 변환에만 적용. JERKINHEAD/HIPANDGABLE 등 width 가 들어가는 - // radio 2 변환은 파라미터 갱신 가능성이 있어 그대로 진행. - if (radioTypeRef.current === '1' && target.attributes?.type === attributes?.type) { - // [KERAB-OFFSET-ONLY-RECLICK 2026-06-01] 동일 type 재클릭이지만 出幅(offset) 만 다른 경우, - // surgical 갱신(corner / 인접 inner-line 끝점 snap + attributes.offset 반영) 후 종료. - // 기존 collapse 흐름(applyKerab*Pattern, polygonPath BFS 등) 은 건너뛰어 케라바 패턴 보존. - const oldOffset = target.attributes?.offset ?? 0 - const newOffset = attributes?.offset ?? 0 - if (Math.abs(newOffset - oldOffset) > 1e-3) { - logger.log( - `[KERAB-OFFSET-ONLY-RECLICK] type=${attributes.type} 동일, offset ${oldOffset}→${newOffset} surgical 적용`, - ) - // [KERAB-OFFSET-ONLY-RECLICK-EAVES 2026-06-05] 처마 상태 출폭 변경 룰: - // - wallbaseLine 안의 내부라인 본체 끝점(apex) 절대 불변 - // - hip outer endpoint 만 wL 코너에서 hip 방향(=skeleton 45°) 으로 새 rL 변까지 ray-cast 확장 - // - surgical 의 CORNER-SHORTCUT/SHRINK-TRIM 은 룰 위반 → skipInnerLines:true - // (케라바 ONLY-RECLICK 은 KERAB-PATTERN-CORNER-SNAP 필요하므로 그대로 둠.) - const isEaves = attributes?.type === LINE_TYPE.WALLLINE.EAVES - let reclickRoof = null - let hasKerabPattern = false - let hipMarks = [] - if (isEaves) { - reclickRoof = canvas - .getObjects() - .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) - if (reclickRoof && Array.isArray(reclickRoof.innerLines) && Array.isArray(reclickRoof.points)) { - hasKerabPattern = reclickRoof.innerLines.some( - (il) => il && typeof il.lineName === 'string' && il.lineName.startsWith('kerabPattern'), - ) - // surgical 적용 *전*(옛 폴리곤) 에 케라바 hip 식별 — outer endpoint 가 옛 변 위에 있어야 잡힌다. - hipMarks = detectKerabHipMarks(target, reclickRoof) - } - } - // [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] 出幅 재변경(eaves→eaves)을 skLine 과 동일 경로로 통일. - // 출발 형상(skLine / A/B타입)에 무관하게 동일 규칙(= 출폭함수): - // - 케라바 hip(확장라인)은 surgical CORNER-SNAP 금지 → wall 코너에서 45° ray-cast 로 roofLine 까지. - // - 측면 수직선/변조각(normal line)은 surgical(recomputeNormalLine)이 새 코너로 추종. - // skLine: 케라바 패턴 없음 → skipInnerLines(전부 45°가 처리). - // A/B타입: 케라바 패턴 보유 → skipKerabHips(normal/ridge 는 surgical, hip 만 45°가 처리). - // 출폭 증감(확장/수축) 양방향이 한 경로에서 일관 처리됨. - const reclickBeforeSnap = isEaves && reclickRoof ? snapshotKerabState(reclickRoof) : null - applyTargetOffsetSurgical( - target, - newOffset, - isEaves ? (hasKerabPattern ? { skipKerabHips: true } : { skipInnerLines: true }) : undefined, - ) - // surgical 적용 *후*(새 폴리곤) 에 케라바 hip 을 45° ray-cast 로 새 roofLine 변까지 확장. - if (isEaves && reclickRoof) extendKerabHipsTo45(target, reclickRoof, hipMarks) - // [KERAB-OFFSET-RECLICK-UNIFY 2026-06-15] A/B타입은 케라바 hip 외 inner line 좌표가 - // surgical 내부(recomputeKerabPatternLine/recomputeNormalLine)에서 갱신됨 → - // 길이/치수 라벨만 새 좌표 기준으로 새로고침한다(hip 은 45° 블록에서 이미 갱신). - if (isEaves && hasKerabPattern && reclickRoof && Array.isArray(reclickRoof.innerLines)) { - for (const il of reclickRoof.innerLines) { - if (!il || il.lineName === 'kerabPatternHip') continue - if (typeof il.setCoords === 'function') il.setCoords() - if (typeof il.setLength === 'function') il.setLength() - if (typeof il.addLengthText === 'function') il.addLengthText() - } - logger.log('[KERAB-OFFSET-RECLICK-UNIFY] surgical label refresh done') - } - // [KERAB-OFFSET-RECLICK-RULECHECK 2026-06-15] 라인변경(출폭 재적용) 후 규칙 검사 — 사용자 요구. - if (isEaves && reclickRoof && reclickBeforeSnap) { - runKerabRuleCheck(reclickRoof, 'reclick', reclickBeforeSnap) - } - target.set({ attributes }) - canvas.renderAll() - return - } - logger.log(`[KERAB-NOOP] 이미 ${attributes.type} → 재변환 무시`) - return - } - - // [2240 KERAB-NEIGHBOR-GABLE 2026-05-19] 「ケラバの隣にケラバは不可」 - // 처마→케라바 변환 시, target 의 끝점을 공유하는 인접 외곽선 중 하나라도 이미 케라바(GABLE) 면 - // 모든 패턴 시도 전에 조용히 무동작 (alert 없음). - if (typeRef.current === TYPES.GABLE && radioTypeRef.current === '1') { - const isSameXY = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) <= 0.5 - const tP1 = { x: target.x1, y: target.y1 } - const tP2 = { x: target.x2, y: target.y2 } - const neighbors = canvas.getObjects().filter( - (o) => - o.name === 'outerLine' && - o !== target && - o.attributes?.roofId === target.attributes?.roofId, - ) - const sharesEndpoint = (o, pt) => isSameXY({ x: o.x1, y: o.y1 }, pt) || isSameXY({ x: o.x2, y: o.y2 }, pt) - const adjGable = neighbors.find( - (o) => o.attributes?.type === LINE_TYPE.WALLLINE.GABLE && (sharesEndpoint(o, tP1) || sharesEndpoint(o, tP2)), - ) - if (adjGable) { - logger.log('[KERAB-NEIGHBOR-GABLE] 인접 외곽선이 케라바 → 무동작') - return - } - } - - // [KERAB-RECT-SOLVER 2026-06-15] 사각형이면 토글 이력과 무관한 결정론적 솔버로 처리. - // 최종 4변 타입만으로 내부선 전부 재생성(Y 불변식 + apex 멈춤 구조적 보장). 미지원 형상은 - // false → 아래 기존 forward/revert/type 경로로 폴백. - if (radioTypeRef.current === '1' && (attributes?.type === LINE_TYPE.WALLLINE.EAVES || attributes?.type === LINE_TYPE.WALLLINE.GABLE)) { - const rectRoof = canvas - .getObjects() - .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) - if (rectRoof && solveRectangleKerab(rectRoof, target, attributes)) { - logger.log('[KERAB-RECT-SOLVER] handled deterministically → maze 우회') - return - } - } - - // [2240 KERAB-SIMPLE 2026-05-20] 사용자 설명 정직 알고리즘: - // 1) target 양 끝점에 직접 끝이 닿은 hip 2개를 찾는다 (nearestRoofPoint 안 씀) - // 2) 두 hip 직선의 무한확장 교점 = apex - // 3) apex 를 통과하는 ridge(RG-1)가 존재하면 케라바 조건 충족 - // 4) mid(target 중점) → apex 중앙선만 추가 (기존 라인 무손상) - if (typeRef.current === TYPES.GABLE && radioTypeRef.current === '1') { - const roof = canvas - .getObjects() - .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) - logger.log('[KERAB-SIMPLE] roof check ' + JSON.stringify({ roofId: target.attributes?.roofId, roofFound: !!roof })) - // [KERAB-INNERLINES-DUMP 2026-05-27] 토글 진입/종료 시점 innerLines ridge/hip 스냅샷. - // - cascade hide / trim 으로 어떤 라인이 어디서 끊겼는지 추적용 임시 진단 로그. - const dumpInnerLineSnapshot = (label) => { - if (!roof || !Array.isArray(roof.innerLines)) return - const rows = roof.innerLines - .filter((l) => l && (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE)) - .map((l) => ({ - lab: l.label || '?', - n: l.name, - ln: l.lineName || '-', - v: l.visible !== false, - x1: Math.round(l.x1 * 10) / 10, - y1: Math.round(l.y1 * 10) / 10, - x2: Math.round(l.x2 * 10) / 10, - y2: Math.round(l.y2 * 10) / 10, - })) - logger.log('[KERAB-INNERLINES-' + label + '] ' + JSON.stringify(rows)) - // [KERAB-LABEL-REATTACH 2026-05-29] AFTER 시점에서 라벨 재부착 (local 모드 한정). - // 케라바 토글로 추가/변경된 kerabPatternRidge/ExtRidge/Hip 등에도 H-/RG- 라벨 부여. - if (label === 'AFTER') { - try { - reattachDebugLabels(canvas, roof.id) - } catch (e) { - logger.warn('[KERAB-LABEL-REATTACH] failed', e) - } - } - } - dumpInnerLineSnapshot('BEFORE') - // [KERAB-RULE-CHECK 2026-06-10] surgical 전(원본 출폭) 상태를 R4 anchor 기준으로 캡처. - const kerabBeforeSnap = snapshotKerabState(roof) - // [KERAB-OFFSET-SURGICAL 2026-05-27] 케라바 토글 직전 target.attributes.offset 을 roofLine 에 surgical 반영. - // SK 재실행 없이 외곽 corner / inner-line endpoint 만 이동 → kLine 등 layered custom 라인 보존. - if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0) - if (roof && Array.isArray(roof.innerLines)) { - // [KERAB-LABEL-LOOKUP 2026-05-21] QPolygon.__attachDebugLabels 와 동일 분류·카운팅 순서로 - // 캔버스 객체에 라벨(H-1, RG-2, B-3 등) 매핑. 로그에 라벨을 함께 찍기 위함. - const labelByLine = new Map() - { - const counters = {} - const objs = canvas.getObjects().filter((o) => o.parentId === roof.id && o.name !== '__debugLabel') - for (const obj of objs) { - let prefix = null - const nm = obj.name - const ln = obj.lineName - const tp = obj.attributes?.type - if (nm === 'baseLine') prefix = 'B' - else if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') prefix = 'R' - else if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) prefix = 'H' - else if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) prefix = 'RG' - else if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) prefix = 'V' - else if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) prefix = 'G' - else if (nm === LINE_TYPE.SUBLINE.VERGE || ln === LINE_TYPE.SUBLINE.VERGE) prefix = 'VG' - else if ( - tp === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || - ln === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || - nm === LINE_TYPE.WALLLINE.EAVE_HELP_LINE - ) - prefix = 'E' - else if ( - typeof obj.x1 === 'number' && - typeof obj.y1 === 'number' && - typeof obj.x2 === 'number' && - typeof obj.y2 === 'number' - ) - prefix = 'SK' - if (!prefix) continue - counters[prefix] = (counters[prefix] || 0) + 1 - labelByLine.set(obj, `${prefix}-${counters[prefix]}`) - } - } - const labelOf = (line) => (line ? labelByLine.get(line) || '?' : null) - // [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 선택된 target 은 이동 전 wallLine. - // 이동된 실제 위치는 wall.baseLines 에 있다. - // wallId 매칭은 이동 후 인덱스 재정렬로 잘못된 baseLine 반환 → 기하학적 매칭으로 교체. - // 같은 방향(수직/수평) + target 의 고정 끝점(이동 안 된 쪽) 공유 여부로 식별. - const _wall = canvas.getObjects().find((o) => o.name === POLYGON_TYPE.WALL && o.attributes?.roofId === target.attributes?.roofId) - const _BL_TOL = 5 - const _tIsV = Math.abs(target.x1 - target.x2) < 0.5 - const _tIsH = Math.abs(target.y1 - target.y2) < 0.5 - const _matchedBase = _wall?.baseLines?.find((bl) => { - if (_tIsV) { - if (Math.abs(bl.x1 - bl.x2) >= 0.5) return false // bl 방향 불일치 - if (Math.abs(bl.x1 - target.x1) >= _BL_TOL) return false // 다른 수직선 - return ( - Math.abs(bl.y1 - target.y1) < _BL_TOL || Math.abs(bl.y1 - target.y2) < _BL_TOL || - Math.abs(bl.y2 - target.y1) < _BL_TOL || Math.abs(bl.y2 - target.y2) < _BL_TOL - ) - } - if (_tIsH) { - if (Math.abs(bl.y1 - bl.y2) >= 0.5) return false // bl 방향 불일치 - if (Math.abs(bl.y1 - target.y1) >= _BL_TOL) return false // 다른 수평선 - return ( - Math.abs(bl.x1 - target.x1) < _BL_TOL || Math.abs(bl.x1 - target.x2) < _BL_TOL || - Math.abs(bl.x2 - target.x1) < _BL_TOL || Math.abs(bl.x2 - target.x2) < _BL_TOL - ) - } - // 대각선: wallId fallback - return bl.attributes?.wallId === target.attributes?.wallId - }) - const t1 = _matchedBase ? { x: _matchedBase.x1, y: _matchedBase.y1 } : { x: target.x1, y: target.y1 } - const t2 = _matchedBase ? { x: _matchedBase.x2, y: _matchedBase.y2 } : { x: target.x2, y: target.y2 } - const h1Match = findHipAtEndpoint(roof, t1) - const h2Match = findHipAtEndpoint(roof, t2) - logger.log( - '[KERAB-SIMPLE] hip lookup ' + - JSON.stringify({ - target: labelOf(target), - t1, - t2, - h1: h1Match - ? { label: labelOf(h1Match.hip), near: h1Match.near, far: h1Match.far, dist: Math.round(h1Match.dist * 100) / 100 } - : null, - h2: h2Match - ? { label: labelOf(h2Match.hip), near: h2Match.near, far: h2Match.far, dist: Math.round(h2Match.dist * 100) / 100 } - : null, - }), - ) - if (h1Match && h2Match) { - // [KERAB-APEX-FAR-AS-PARALLEL 2026-05-21] lineLineIntersection 은 완전 평행(det≈0) 만 null 반환. - // 거의 평행한 두 hip 은 천문학적 좌표의 가짜 apex 를 만들어 markerApex 오염. 좌표 크기로 평행 강제 판정. - let apex = lineLineIntersection(h1Match.near, h1Match.far, h2Match.near, h2Match.far) - if (apex) { - const APEX_FAR_LIMIT = 1e5 - if (Math.abs(apex.x) > APEX_FAR_LIMIT || Math.abs(apex.y) > APEX_FAR_LIMIT) { - apex = null - } - } - logger.log( - '[KERAB-SIMPLE] apex ' + - JSON.stringify({ - apex: apex ? { x: Math.round(apex.x * 100) / 100, y: Math.round(apex.y * 100) / 100 } : null, - parallel: !apex, - }), - ) -// [KERAB-PARALLEL-FULLALGO 2026-05-21] 평행(apex=null) 도 풀 알고리즘으로 처리. - // 폴리곤 경로 + extender 확장 + 반사/meet/apex/kLine — h1·h2 만나지 않더라도 - // 내부 라인(path hips/ridges) 은 삭제, 접점 extender 는 인쪽 확장. - // 자연 만남(condition 1) 만 단축: apex 존재 + h*.far ≈ apex. - { - const EXT_TOL = 1.0 - const isNatural = - !!apex && - Math.hypot(h1Match.far.x - apex.x, h1Match.far.y - apex.y) <= EXT_TOL && - Math.hypot(h2Match.far.x - apex.x, h2Match.far.y - apex.y) <= EXT_TOL - if (!isNatural) { - // [KERAB-POLYGON-BFS 2026-05-21] 사용자 전제 2: 내부 다각형 경계 = BFS 로 추적한 - // h1.far → h2.far 경로 + h1 + h2. 경로상 모든 hip/ridge 를 삭제 대상으로 모음. - // 직접 연결(RG-1) 뿐 아니라 비대칭(Ridge→junction→otherHip 체인) 도 한 번에 처리. - const polygonPath = traceInnerPolygonPath(roof, h1Match.far, h2Match.far, [h1Match.hip, h2Match.hip]) - logger.log( - '[KERAB-SIMPLE] polygonPath ' + - JSON.stringify({ - found: polygonPath !== null, - length: polygonPath ? polygonPath.length : 0, - lines: polygonPath - ? polygonPath.map((p) => ({ - label: labelOf(p.line), - name: p.line.name, - lineName: p.line.lineName, - x1: p.line.x1, y1: p.line.y1, x2: p.line.x2, y2: p.line.y2, - })) - : null, - }), - ) - // [KERAB-VALLEY-DIAG 2026-05-27] polygonPath 라인들의 valley vertex 식별 (진단). - // apex 유무 무관 — valley 가 polygonPath 에 존재하면 valleyExt 후보 (gate 완화 2026-05-27). - // surgical 출폭 변경으로 H-3·H-2 평행성이 살짝 깨져 apex 가 폴리곤 밖 멀리 잡히는 케이스(거의 평행) - // 에서도 처마확장이 그려져야 함. 내부의 `h1FarIsValley || h2FarIsValley` 가드가 자동 skip 보장. - if (polygonPath) { - const valleyPool = [h1Match.hip, h2Match.hip, target, ...polygonPath.map((p) => p.line)] - const valleyInfo = [] - for (const line of [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)]) { - if (!line) continue - const info = findInteriorPoint(line, valleyPool) - valleyInfo.push({ - label: labelOf(line), - name: line.name, - lineName: line.lineName, - x1: Math.round(line.x1 * 100) / 100, - y1: Math.round(line.y1 * 100) / 100, - x2: Math.round(line.x2 * 100) / 100, - y2: Math.round(line.y2 * 100) / 100, - startValley: info.start, - endValley: info.end, - }) - } - logger.log('[KERAB-VALLEY-DIAG] ' + JSON.stringify(valleyInfo)) - debugCapture.log('KERAB-VALLEY-DIAG', { targetId: target.id, count: valleyInfo.length, valleys: valleyInfo }) - } - // [KERAB-VALLEY-EXT 2026-05-27] 모델 교체: skeleton valley → 외곽 polygon concave corner. - // "확장" = roofLine 의 한쪽 끝점에서 self-extension (반대 끝점 → 그 끝점 방향) 으로 연장. - // concave corner 옆 끝점 self-extension 은 polygon 내부로 향함 → 첫 hip/ridge 와 hit. - // convex 측 끝점 self-extension 은 polygon 외부로 새서 hit 없음 → 자동 skip. - // 양 끝점 둘 다 후보로 push → raycast 가 valley 측을 자동 결정. - const valleyPlannedEndpoints = [] - if (polygonPath) { - const matchingRoofLine = Array.isArray(roof.lines) - ? roof.lines.find((rl) => rl && rl.attributes?.wallLine === target.id) - : null - logger.log( - '[KERAB-VALLEY-EXT] roofLine-match ' + - JSON.stringify({ - targetId: target.id, - targetIdx: target.idx, - found: !!matchingRoofLine, - rl: matchingRoofLine - ? { x1: matchingRoofLine.x1, y1: matchingRoofLine.y1, x2: matchingRoofLine.x2, y2: matchingRoofLine.y2 } - : null, - }), - ) - if (matchingRoofLine) { - valleyPlannedEndpoints.push( - { - sx: matchingRoofLine.x1, - sy: matchingRoofLine.y1, - ox: matchingRoofLine.x2, - oy: matchingRoofLine.y2, - label: 'roofBase-s', - parent: matchingRoofLine, - }, - { - sx: matchingRoofLine.x2, - sy: matchingRoofLine.y2, - ox: matchingRoofLine.x1, - oy: matchingRoofLine.y1, - label: 'roofBase-e', - parent: matchingRoofLine, - }, - ) - } else { - logger.log('[KERAB-VALLEY-EXT] no matching roof.lines for target.id=' + target.id) - } - } - if (polygonPath === null) { - logger.log('[KERAB-SIMPLE] no polygon path → attr-only fallback') - target.set({ attributes }) - applyKerabAttributeOnlyPattern() - runKerabRuleCheck(roof, 'forward', kerabBeforeSnap) - return - } - const polygonLines = [h1Match.hip, h2Match.hip, ...polygonPath.map((p) => p.line)] - // [KERAB-EXTENDER 2026-05-21] 사용자 전제 3: 접점(h1.far, h2.far, 중간 junction) 에서 polygon path 가 아닌 - // inner line(hip OR ridge) 을 extender 로 식별. 경로상 라인은 제외. - const ext1 = findExtenderAtPoint(roof, h1Match.far, polygonLines) - const ext2 = findExtenderAtPoint(roof, h2Match.far, polygonLines) - // [KERAB-JUNCTION-EXT 2026-05-21] 중간 touch point(junction) 의 extender 도 식별 — 모든 - // 사용 가능한 extender 를 수집해 가장 가까운 meet 부터 순차 해소(H-7↔RG-2 → H-1↔roofLine 등). - const intermediatePoints = [] - for (let i = 0; i < polygonPath.length - 1; i++) { - intermediatePoints.push(polygonPath[i].to) - } - const junctionExtenders = intermediatePoints.map((jp) => { - const allAtJ = [] - for (const il of roof.innerLines || []) { - if (!il || polygonLines.includes(il)) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - const dA = Math.hypot(a.x - jp.x, a.y - jp.y) - const dB = Math.hypot(b.x - jp.x, b.y - jp.y) - if (dA <= 1.0) allAtJ.push({ line: il, near: a, far: b, isHip: il.name === LINE_TYPE.SUBLINE.HIP }) - else if (dB <= 1.0) allAtJ.push({ line: il, near: b, far: a, isHip: il.name === LINE_TYPE.SUBLINE.HIP }) - } - return { jp, exts: allAtJ } - }) - logger.log( - '[KERAB-SIMPLE] extenders ' + - JSON.stringify({ - h1Far: h1Match.far, - h2Far: h2Match.far, - e1: ext1 ? { label: labelOf(ext1.line), near: ext1.near, far: ext1.far, isHip: ext1.isHip } : null, - e2: ext2 ? { label: labelOf(ext2.line), near: ext2.near, far: ext2.far, isHip: ext2.isHip } : null, - junctions: junctionExtenders.map((j) => ({ - jp: j.jp, - exts: j.exts.map((e) => ({ - label: labelOf(e.line), - near: e.near, - far: e.far, - isHip: e.isHip, - lineName: e.line.lineName, - })), - })), - }), - ) - // [KERAB-SEQ-RESOLVE 2026-05-21] 사용자 모델: 모든 접점의 extender 를 모아 인쪽 확장. - // 가장 가까운 meet 부터 순차 해소(hip-hip → apex+kLine, hip-ridge → 그 자리 stop). - // 짝 잃은 extender 는 roofLine 까지 확장. parallel 도 자동 처리. - // [KERAB-POLYGON-INSIDE-REVERT 2026-05-21] sub-polygon 내부 필터(goesIntoPolygon) 제거. - // 해당 필터가 평행 케이스의 정상 extender 까지 거름 → 회귀. RG-1 류는 no-pierce(barrier) 로 처리. - const allExtenders = [] - if (ext1) allExtenders.push({ ...ext1, sourcePoint: h1Match.far }) - if (ext2) allExtenders.push({ ...ext2, sourcePoint: h2Match.far }) - for (const j of junctionExtenders) { - for (const e of j.exts) { - allExtenders.push({ ...e, sourcePoint: j.jp }) - } - } - logger.log( - '[KERAB-SIMPLE] extenders-filtered ' + - JSON.stringify({ - accepted: allExtenders.map((e) => ({ - label: labelOf(e.line), - near: { x: Math.round(e.near.x * 100) / 100, y: Math.round(e.near.y * 100) / 100 }, - far: { x: Math.round(e.far.x * 100) / 100, y: Math.round(e.far.y * 100) / 100 }, - isHip: e.isHip, - })), - }), - ) - if (allExtenders.length === 0) { - logger.log('[KERAB-SIMPLE] no extenders — fallback attr-only') - target.set({ attributes }) - applyKerabAttributeOnlyPattern() - runKerabRuleCheck(roof, 'forward', kerabBeforeSnap) - return - } - // 인쪽 방향 검증: extender 자연 방향(near→far)의 반대(near→inward) 와 일치해야 함. - const isInward = (ext, pt) => { - const ix = ext.near.x - ext.far.x - const iy = ext.near.y - ext.far.y - const px = pt.x - ext.near.x - const py = pt.y - ext.near.y - return ix * px + iy * py > 1e-3 - } - // [KERAB-ITER-REFLECT 2026-05-21] 반사 hip 을 1급 extender 로 풀에 추가하고 wave 반복. - // wave: 미해소 extender 들의 meet 계산 → 가장 가까운 meet 부터 해소 → - // hip+(ridge/kLine) → 반사 hip 생성하여 풀에 추가 → - // hip+hip → apex + 새 kLine 도 다음 wave 의 barrier (현재는 첫 apex 하나만 추적). - // 미해소는 roofLine fallback. - const existingKLines = (roof.innerLines || []).filter( - (il) => - il && - il.lineName === 'kerabPatternRidge' && - !il.__noKLine && - il.__targetId !== target.id && - il.visible !== false, - ) - const isPointOnSegment = (pt, ax, ay, bx, by, tol = 0.5) => { - const dx = bx - ax - const dy = by - ay - const lenSq = dx * dx + dy * dy - if (lenSq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol - const t = ((pt.x - ax) * dx + (pt.y - ay) * dy) / lenSq - const margin = tol / Math.sqrt(lenSq) - if (t < -margin || t > 1 + margin) return false - const px = ax + t * dx - const py = ay + t * dy - return Math.hypot(px - pt.x, py - pt.y) <= tol - } - // [KERAB-ROOF-MAX-INWARD 2026-05-27] roof polygon wall 정의를 wave 시작 전으로 이동 (이전 L896). - // 사용자 규칙: "확장을 해도 roofLine 까지이다. 절대 통과 못한다." - // wave 의 모든 meet 후보 거리를 ext.maxInwardDist 로 제한 → roofLine 너머 meet 거부. - const roofPolygonWalls = [] - if (Array.isArray(roof.points) && roof.points.length >= 2) { - for (let i = 0; i < roof.points.length; i++) { - roofPolygonWalls.push({ - a: roof.points[i], - b: roof.points[(i + 1) % roof.points.length], - }) - } - } - const computeMaxInwardDist = (ext) => { - let best = Infinity - for (const wall of roofPolygonWalls) { - const ip = lineLineIntersection(ext.near, ext.far, wall.a, wall.b) - if (!ip) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, wall.a.x, wall.a.y, wall.b.x, wall.b.y)) continue - const d = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - if (d < 1e-3) continue - if (d < best) best = d - } - return best - } - const MAX_INWARD_TOL = 0.5 - for (const ext of allExtenders) { - ext.maxInwardDist = computeMaxInwardDist(ext) - } - // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] 골짜기확장라인 사전 계산 — hip/ridge wave 의 barrier 로 사용. - // 사용자 규칙: "힙/마루 라인은 골짜기 확장라인 및 roofLine 까지. 절대 통과 못한다." - // pre-wave 상태(polygonPath 라인 미삭제) 의 roof.innerLines 로 raycast. polygonLines 는 곧 삭제될 라인이라 stopper 제외. - // 최종 valleyExt 좌표는 L1280+ 에서 post-wave 상태로 다시 raycast → 약간의 차이 있을 수 있음 (수용). - const valleyExtPreSegs = [] - if (valleyPlannedEndpoints.length) { - for (const ep of valleyPlannedEndpoints) { - const dx = ep.sx - ep.ox - const dy = ep.sy - ep.oy - const len = Math.hypot(dx, dy) || 1 - const ux = dx / len - const uy = dy / len - const FAR_RAY = 1e5 - const start = { x: ep.sx, y: ep.sy } - const rayEnd = { x: ep.sx + ux * FAR_RAY, y: ep.sy + uy * FAR_RAY } - // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-27] 새 규칙: - // 1) ridge(마루) 만나면 그 점에서 정지. hip 은 통과. - // 2) ridge 못 만나면 맞은편 polygon-wall(roofLine 너머) 까지 끝까지 확장 (절반 아님). - let bestPt = null - let wallT = Infinity - let wallHit = null - for (const w of roofPolygonWalls) { - const ip = lineLineIntersection(start, rayEnd, w.a, w.b) - if (!ip) continue - if (!isPointOnSegment(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue - const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy - if (t < 0.5) continue - if (t < wallT) { - wallT = t - wallHit = ip - } - } - let ridgeStop = null - let ridgeT = Infinity - // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 골짜기 확장라인이 "원래 있던" ridge(마루) 만나면 그 점에서 정지. - // 확장으로 생긴 ridge(kerabPatternRidge/kerabPatternExtRidge 등) 는 stop 대상 아님. - // 화이트리스트: lineName === 'ridge' 만 매칭. - for (const il of roof.innerLines || []) { - if (!il || il.visible === false) continue - if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - if (il.lineName !== 'ridge') continue - const ip = lineLineIntersection(start, rayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) - if (!ip) continue - if (!isPointOnSegment(ip, il.x1, il.y1, il.x2, il.y2)) continue - const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy - if (t < 0.5) continue - if (wallT !== Infinity && t > wallT + 0.5) continue - if (t < ridgeT) { - ridgeT = t - ridgeStop = ip - } - } - if (ridgeStop) { - bestPt = ridgeStop - logger.log( - '[KERAB-VALLEY-EXT-RIDGE-STOP] pre label=' + ep.label + - ' ridgeT=' + Math.round(ridgeT * 100) / 100 + - ' stop={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}', - ) - } else if (wallHit) { - bestPt = wallHit - logger.log( - '[KERAB-VALLEY-EXT-WALL] pre label=' + ep.label + - ' end={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}', - ) - } - if (bestPt) { - valleyExtPreSegs.push({ x1: start.x, y1: start.y, x2: bestPt.x, y2: bestPt.y, label: ep.label }) - } - } - logger.log( - '[KERAB-VALLEY-EXT-PRE] ' + - JSON.stringify( - valleyExtPreSegs.map((s) => ({ - label: s.label, - from: { x: Math.round(s.x1 * 100) / 100, y: Math.round(s.y1 * 100) / 100 }, - to: { x: Math.round(s.x2 * 100) / 100, y: Math.round(s.y2 * 100) / 100 }, - })), - ), - ) - } - const extenderPool = [...allExtenders] - const resolved = new Map() - // [KERAB-MULTI-APEX 2026-05-22] hip+hip 이 90° 로 만나는 모든 apex 수집 → 각 apex 마다 kLine 1개. - // 첫 apex 는 primary(applyKerabKLinePattern), 이후 apex 는 보조 ridge. - const apexList = [] - const pendingKLines = [] - const PERP_EPS = 0.05 - const isPerpendicular = (vax, vay, vbx, vby) => { - const ma = Math.hypot(vax, vay) || 1 - const mb = Math.hypot(vbx, vby) || 1 - return Math.abs((vax * vbx + vay * vby) / (ma * mb)) < PERP_EPS - } - const computePendingKLine = (apex) => { - // [KERAB-WLINE-T1T2 2026-06-04] wLine 이동 후 hip.near 가 target 끝점에서 벗어나므로 - // 이동한 처마라인 끝점(t1/t2) 기준으로 foot 계산 - const ax = t2.x - t1.x - const ay = t2.y - t1.y - const aSq = ax * ax + ay * ay || 1 - const tFoot = ((apex.x - t1.x) * ax + (apex.y - t1.y) * ay) / aSq - const foot = { x: t1.x + tFoot * ax, y: t1.y + tFoot * ay } - return { x1: apex.x, y1: apex.y, x2: foot.x, y2: foot.y } - } - const pushApexIfNew = (point, callerTag = '?') => { - const dup = apexList.some((ap) => Math.hypot(ap.point.x - point.x, ap.point.y - point.y) < 0.5) - if (dup) { - logger.log('[KERAB-APEX-PUSH-DUP]', callerTag, { x: point.x?.toFixed?.(2), y: point.y?.toFixed?.(2) }) - return false - } - apexList.push({ point: { x: point.x, y: point.y } }) - const pk = computePendingKLine(point) - pendingKLines.push(pk) - logger.log('[KERAB-APEX-PUSH]', callerTag, { - apex: { x: point.x?.toFixed?.(2), y: point.y?.toFixed?.(2) }, - kLine: { x1: pk.x1?.toFixed?.(2), y1: pk.y1?.toFixed?.(2), x2: pk.x2?.toFixed?.(2), y2: pk.y2?.toFixed?.(2) }, - apexCount: apexList.length, - }) - return true - } - // [KERAB-FIXPOINT-PHASE-A 2026-05-22] 정적 inner line 만남 시 절삭 정보 누적. - // wave 종료 후 apply 직전에 fabric line 좌표를 갱신해 그 점 너머 부분을 제거한다. - const cuts = [] - const MAX_ITER = 10 - for (let iter = 0; iter < MAX_ITER; iter++) { - const unresolved = extenderPool.filter((e) => !resolved.has(e)) - if (unresolved.length === 0) break - const meets = [] - // [KERAB-MEETS-FAR-GUARD 2026-05-21] 거의 평행한 두 extender(H-2 vs H-3 등) 의 교점은 - // 천문학적 좌표(예: ±6e5) 로 돌아오고 isInward 도 통과 → 가짜 meet 후보 등록. - // line 291 APEX_FAR_LIMIT 와 동일 임계 1e5 로 거부, 짝 잃은 ext 는 fallback 경로(roof wall)로. - const MEETS_FAR_LIMIT = 1e5 - for (let i = 0; i < unresolved.length; i++) { - for (let k = i + 1; k < unresolved.length; k++) { - const ea = unresolved[i] - const eb = unresolved[k] - const ip = lineLineIntersection(ea.near, ea.far, eb.near, eb.far) - if (!ip) continue - if (Math.abs(ip.x) > MEETS_FAR_LIMIT || Math.abs(ip.y) > MEETS_FAR_LIMIT) continue - if (!isInward(ea, ip) || !isInward(eb, ip)) continue - const dA = Math.hypot(ip.x - ea.near.x, ip.y - ea.near.y) - const dB = Math.hypot(ip.x - eb.near.x, ip.y - eb.near.y) - meets.push({ - a: ea, b: eb, point: ip, - minDist: Math.min(dA, dB), - bothHips: ea.isHip && eb.isHip, - }) - } - } - const kLineMeets = [] - const kLineCandidates = [...existingKLines, ...pendingKLines] - for (const ext of unresolved) { - for (const kl of kLineCandidates) { - const ip = lineLineIntersection(ext.near, ext.far, { x: kl.x1, y: kl.y1 }, { x: kl.x2, y: kl.y2 }) - if (!ip) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, kl.x1, kl.y1, kl.x2, kl.y2)) continue - const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - if (dist < 1e-3) continue - kLineMeets.push({ ext, point: ip, dist, kLine: kl }) - } - } - // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] 골짜기확장라인 segment 와의 meet — hip/ridge 가 그 점에서 정지. - // mirror/reflection 없음 (단순 정지). 거리 가장 짧은 후보면 우선 처리되어 그 너머로 못 감. - const valleyExtMeets = [] - for (const ext of unresolved) { - for (const vs of valleyExtPreSegs) { - const ip = lineLineIntersection(ext.near, ext.far, { x: vs.x1, y: vs.y1 }, { x: vs.x2, y: vs.y2 }) - if (!ip) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, vs.x1, vs.y1, vs.x2, vs.y2)) continue - const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - if (dist < 1e-3) continue - valleyExtMeets.push({ ext, point: ip, dist, valleyExtSeg: vs }) - } - } - if (valleyExtMeets.length > 0) { - logger.log( - '[KERAB-VALLEY-EXT-BARRIER] meets iter=' + iter + ' ' + - JSON.stringify( - valleyExtMeets.map((v) => ({ - ext: labelOf(v.ext.line) || (v.ext.isHip ? 'H' : 'R'), - point: { x: Math.round(v.point.x * 100) / 100, y: Math.round(v.point.y * 100) / 100 }, - dist: Math.round(v.dist * 100) / 100, - })), - ), - ) - } - // [KERAB-STATIC-RIDGE 2026-05-21] 정적 ridge(RG-1 등, 폴리곤 삭제대상 제외) 도 hip extender 의 거울. - const staticRidges = (roof.innerLines || []).filter( - (il) => - il && - il.name === LINE_TYPE.SUBLINE.RIDGE && - il.lineName !== 'kerabPatternRidge' && - !polygonLines.includes(il), - ) - const ridgeMeets = [] - for (const ext of unresolved) { - if (!ext.isHip) continue - for (const r of staticRidges) { - const ip = lineLineIntersection(ext.near, ext.far, { x: r.x1, y: r.y1 }, { x: r.x2, y: r.y2 }) - if (!ip) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, r.x1, r.y1, r.x2, r.y2)) continue - const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - ridgeMeets.push({ ext, point: ip, dist, ridge: r }) - } - } - // [KERAB-FIXPOINT-PHASE-A 2026-05-22] 정적 inner hip 만남 — 무너진 sub-polygon 의 경계. - // unresolved ext (hip 또는 ridge) 가 정적 hip segment 와 만나면 그 점에서 멈춤 + 절삭정보 누적. - // polygon path 라인은 제외(이 polygon 의 처리 대상). 자기 라인은 검사 루프에서 개별 제외. - const staticInnerHips = (roof.innerLines || []).filter( - (il) => - il && - il.name === LINE_TYPE.SUBLINE.HIP && - !polygonLines.includes(il) && - il.visible !== false, - ) - const staticMeets = [] - for (const ext of unresolved) { - for (const il of staticInnerHips) { - if (ext.line === il) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - const ip = lineLineIntersection(ext.near, ext.far, a, b) - if (!ip) continue - if (Math.abs(ip.x) > 1e5 || Math.abs(ip.y) > 1e5) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, a.x, a.y, b.x, b.y)) continue - const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - if (dist < 1e-3) continue - staticMeets.push({ ext, point: ip, dist, staticLine: il }) - } - } - if (staticMeets.length > 0) { - logger.log( - '[KERAB-FIXPOINT-PHASE-A] staticMeets iter=' + iter + ' ' + - JSON.stringify( - staticMeets.map((s) => ({ - ext: labelOf(s.ext.line) || (s.ext.isHip ? 'H' : 'R'), - staticLine: labelOf(s.staticLine) || 'H?', - point: { x: Math.round(s.point.x * 100) / 100, y: Math.round(s.point.y * 100) / 100 }, - dist: Math.round(s.dist * 100) / 100, - })), - ), - ) - } - // [KERAB-FIXPOINT-STEP2 2026-05-21] 이미 그려질 segment(resolved) 도 후보의 거울. - // 뒤늦게 도달한 extender 가 기존 segment 와 만나면 그 점에서 반사 hip 을 만든다. - // (drawn segment 자체의 절단은 Step 3 에서 처리) - // segment 양끝은 그리기 좌표(sourcePoint→stop) 와 일치시킨다. - const drawnMeets = [] - for (const ext of unresolved) { - if (!ext.isHip) continue - for (const [drawnExt, stopPt] of resolved) { - if (!drawnExt || !stopPt) continue - if (drawnExt === ext) continue - const segA = drawnExt.sourcePoint || drawnExt.near - const segB = stopPt - const ip = lineLineIntersection(ext.near, ext.far, segA, segB) - if (!ip) continue - if (Math.abs(ip.x) > 1e5 || Math.abs(ip.y) > 1e5) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, segA.x, segA.y, segB.x, segB.y)) continue - const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - if (dist < 1e-3) continue - drawnMeets.push({ ext, point: ip, dist, drawnExt, mirrorLine: { x1: segA.x, y1: segA.y, x2: segB.x, y2: segB.y } }) - } - } - if (drawnMeets.length > 0) { - logger.log( - '[KERAB-FIXPOINT-STEP2] drawnMeets iter=' + iter + ' ' + - JSON.stringify( - drawnMeets.map((d) => ({ - ext: d.ext.line?.attributes?.label || (d.ext.isHip ? 'H' : 'R'), - point: { x: Math.round(d.point.x * 100) / 100, y: Math.round(d.point.y * 100) / 100 }, - dist: Math.round(d.dist * 100) / 100, - })), - ), - ) - } - const candidates = [] - for (const m of meets) { - candidates.push({ kind: 'pair', extenders: [m.a, m.b], point: m.point, minDist: m.minDist, bothHips: m.bothHips }) - } - for (const km of kLineMeets) { - candidates.push({ kind: 'kline', extenders: [km.ext], point: km.point, minDist: km.dist, mirrorLine: km.kLine }) - } - // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] valleyExt 와 만나는 hip/ridge 는 그 점에서 정지. - // mirror 없음 → L859 의 (kline/ridge/drawn) 분기에 valleyExt 추가하지 않아 reflected 생성 안 됨. - for (const vm of valleyExtMeets) { - candidates.push({ - kind: 'valleyExt', - extenders: [vm.ext], - point: vm.point, - minDist: vm.dist, - }) - } - for (const rm of ridgeMeets) { - candidates.push({ - kind: 'ridge', - extenders: [rm.ext], - point: rm.point, - minDist: rm.dist, - mirrorLine: { x1: rm.ridge.x1, y1: rm.ridge.y1, x2: rm.ridge.x2, y2: rm.ridge.y2 }, - }) - } - for (const dm of drawnMeets) { - candidates.push({ - kind: 'drawn', - extenders: [dm.ext], - point: dm.point, - minDist: dm.dist, - mirrorLine: dm.mirrorLine, - drawnExt: dm.drawnExt, - }) - } - for (const sm of staticMeets) { - candidates.push({ - kind: 'static', - extenders: [sm.ext], - point: sm.point, - minDist: sm.dist, - staticLine: sm.staticLine, - }) - } - // [KERAB-ROOF-MAX-INWARD 2026-05-27] roofLine 너머 meet 후보 제거. - // 사용자 규칙: "확장을 해도 roofLine 까지이다. 절대 통과 못한다." - // ext.maxInwardDist (inward 방향 roof wall 까지 최단거리) 를 초과하는 점은 폐기. - const candidatesBeforeCap = candidates.length - const capFilteredOut = [] - for (let ci = candidates.length - 1; ci >= 0; ci--) { - const c = candidates[ci] - let over = false - for (const e of c.extenders) { - const cap = e.maxInwardDist - if (cap === undefined || !Number.isFinite(cap)) continue - const d = Math.hypot(c.point.x - e.near.x, c.point.y - e.near.y) - if (d > cap + MAX_INWARD_TOL) { - over = true - capFilteredOut.push({ - kind: c.kind, - ext: labelOf(e.line) || (e.isHip ? 'H' : 'R'), - d: Math.round(d * 100) / 100, - cap: Math.round(cap * 100) / 100, - }) - break - } - } - if (over) candidates.splice(ci, 1) - } - if (capFilteredOut.length > 0) { - logger.log( - '[KERAB-ROOF-MAX-INWARD] iter=' + iter + ' filtered=' + capFilteredOut.length + - '/' + candidatesBeforeCap + ' ' + JSON.stringify(capFilteredOut), - ) - } - candidates.sort((a, b) => a.minDist - b.minDist) - const newReflected = [] - let pendingKLineCreated = false - let processedAny = false - // [KERAB-FIXPOINT-STEP1 2026-05-21] 동시성 모델: 한 iter 에 가장 가까운 후보 1개만 처리. - // 이후 외곽 for(iter) 루프가 meets/kLineMeets/ridgeMeets 를 새 상태로 재계산한다. - // 이렇게 해야 뒤늦은 교점이 이미 그려질 라인을 잘라낼 수 있다 (Step 2~3 에서 확장 예정). - for (const c of candidates) { - if (c.extenders.some((e) => resolved.has(e))) continue - for (const e of c.extenders) resolved.set(e, c.point) - processedAny = true - // [KERAB-FIXPOINT-STEP3 2026-05-21] drawn segment 절단: - // 뒤늦은 교점이 잡힌 기존 segment 의 stop 을 교점으로 갱신. - // 절단 후 길이 < 0.5 이면 resolved 에서 제거하여 라인 자체 제거. - if (c.kind === 'drawn' && c.drawnExt) { - const drawnNear = c.drawnExt.sourcePoint || c.drawnExt.near - const remain = Math.hypot(c.point.x - drawnNear.x, c.point.y - drawnNear.y) - if (remain < 0.5) { - resolved.delete(c.drawnExt) - } else { - resolved.set(c.drawnExt, c.point) - } - // [KERAB-MULTI-APEX 2026-05-22] 규칙 5 Case B: drawn-meet 두 hip 90° → kLine 신규 생성. - // ray-ray 가 drift(t<0) 로 pair 후보 미생성이어도 drawn segment 교점에서 사실상 만나는 케이스. - // H-5/H-6 같이 RG-2 양끝 발산 hip → drawn meet 으로 동일점 stop. - const ea = c.extenders[0] - const eb = c.drawnExt - if (ea?.isHip && eb?.isHip && remain >= 0.5) { - const vax = ea.far.x - ea.near.x - const vay = ea.far.y - ea.near.y - const vbx = eb.far.x - eb.near.x - const vby = eb.far.y - eb.near.y - if (isPerpendicular(vax, vay, vbx, vby)) { - const eaTag = `${labelOf(ea.line) || 'H'}${ea.__reflected ? '*' : ''}${ea.__reflectedFromPending ? '!' : ''}` - const ebTag = `${labelOf(eb.line) || 'H'}${eb.__reflected ? '*' : ''}${eb.__reflectedFromPending ? '!' : ''}` - if (ea.__reflectedFromPending || eb.__reflectedFromPending) { - logger.log('[KERAB-APEX-SKIP-PHANTOM]', `iter${iter}/drawn-meet[${eaTag}+${ebTag}]`, { - x: c.point.x?.toFixed?.(2), y: c.point.y?.toFixed?.(2), - }) - } else if (pushApexIfNew(c.point, `iter${iter}/drawn-meet[${eaTag}+${ebTag}]`)) { - pendingKLineCreated = true - } - } - } - } - // [KERAB-FIXPOINT-PHASE-A 2026-05-22] static 만남: ext 멈춤 + 정적 라인 절삭 정보 누적. - if (c.kind === 'static' && c.staticLine) { - cuts.push({ line: c.staticLine, point: c.point }) - } - if (c.kind === 'pair' && c.bothHips) { - const [ea, eb] = c.extenders - const vax = ea.far.x - ea.near.x - const vay = ea.far.y - ea.near.y - const vbx = eb.far.x - eb.near.x - const vby = eb.far.y - eb.near.y - if (isPerpendicular(vax, vay, vbx, vby)) { - const eaTag = `${labelOf(ea.line) || (ea.isHip ? 'H' : 'R')}${ea.__reflected ? '*' : ''}${ea.__reflectedFromPending ? '!' : ''}` - const ebTag = `${labelOf(eb.line) || (eb.isHip ? 'H' : 'R')}${eb.__reflected ? '*' : ''}${eb.__reflectedFromPending ? '!' : ''}` - // [KERAB-MULTI-APEX 2026-05-22] pendingKLine 반사 자식이 끼인 pair-meet 은 phantom 이라 skip. - // primary(H-3+H-4*: H-4는 RG-2 ridge 반사로 fromPending 아님) 는 통과, - // phantom(H-16+H-2!: H-2 가 primary kLine 반사) 은 차단. - // 정적 hip 와의 90° 만남 apex 는 re-resolve 단계에서 따로 push. - if (ea.__reflectedFromPending || eb.__reflectedFromPending) { - logger.log('[KERAB-APEX-SKIP-PHANTOM]', `iter${iter}/pair-meet[${eaTag}+${ebTag}]`, { - x: c.point.x?.toFixed?.(2), y: c.point.y?.toFixed?.(2), - }) - } else if (pushApexIfNew(c.point, `iter${iter}/pair-meet[${eaTag}+${ebTag}]`)) { - pendingKLineCreated = true - } - } - } - let hipExt = null - let mirrorLine = null - if ((c.kind === 'kline' || c.kind === 'ridge' || c.kind === 'drawn') && c.extenders[0].isHip) { - hipExt = c.extenders[0] - mirrorLine = c.mirrorLine - } else if (c.kind === 'pair') { - const [ea, eb] = c.extenders - if (ea.isHip && !eb.isHip) { - hipExt = ea - mirrorLine = { x1: eb.near.x, y1: eb.near.y, x2: eb.far.x, y2: eb.far.y } - } else if (!ea.isHip && eb.isHip) { - hipExt = eb - mirrorLine = { x1: ea.near.x, y1: ea.near.y, x2: ea.far.x, y2: ea.far.y } - } - } - if (hipExt && mirrorLine) { - const hdx = hipExt.near.x - hipExt.far.x - const hdy = hipExt.near.y - hipExt.far.y - const kdx = mirrorLine.x2 - mirrorLine.x1 - const kdy = mirrorLine.y2 - mirrorLine.y1 - const klen = Math.hypot(kdx, kdy) || 1 - const nx = -kdy / klen - const ny = kdx / klen - const dot = hdx * nx + hdy * ny - const rdx = hdx - 2 * dot * nx - const rdy = hdy - 2 * dot * ny - // [KERAB-MULTI-APEX 2026-05-22] 같은 operation 의 pendingKLine 에 반사된 자식은 - // __reflectedFromPending 태그. 이 자식의 pair-meet 으로 추가 apex 생성 차단(phantom 방지). - // 부모(hipExt) 가 이미 pending 에서 반사된 경우도 상속. - const fromPending = - hipExt.__reflectedFromPending || - (c.kind === 'kline' && pendingKLines.some( - (pk) => pk.x1 === mirrorLine.x1 && pk.y1 === mirrorLine.y1 && pk.x2 === mirrorLine.x2 && pk.y2 === mirrorLine.y2, - )) - const reflected = { - line: hipExt.line, - near: { x: c.point.x, y: c.point.y }, - far: { x: c.point.x - rdx, y: c.point.y - rdy }, - isHip: true, - sourcePoint: { x: c.point.x, y: c.point.y }, - __reflected: true, - __reflectedFromPending: fromPending, - } - // [KERAB-ROOF-MAX-INWARD 2026-05-27] reflected ext 도 roof wall 까지 캡 계산. - reflected.maxInwardDist = computeMaxInwardDist(reflected) - newReflected.push(reflected) - } - break - } - if (!processedAny && !pendingKLineCreated) break - extenderPool.push(...newReflected) - } - // [KERAB-ROOF-FALLBACK-ANYWALL 2026-05-21] 미해소 extender 를 roof 폴리곤의 어떤 wall 이라도 - // inward 방향의 가장 가까운 wall 교점까지 확장. 반사 hip 이 target wall 과 반대쪽으로 - // 향해도 다른 wall 에서 정지. - // (roofPolygonWalls 정의는 wave 시작 전 KERAB-ROOF-MAX-INWARD 블록으로 이동됨) - // [KERAB-NO-PIERCE 2026-05-21] fallback 직진 중에도 정적 inner line(이 polygon 의 처리대상 제외) - // + 이번 wave 에 이미 그려질 ext 라인을 barrier 로 검사. 가장 가까운 hit 에서 정지하여 - // 다른 라인을 관통해 내부로 침입하는 케이스를 차단. - const barrierLines = [] - for (const il of roof.innerLines || []) { - if (!il) continue - if (polygonLines.includes(il)) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - if (il.visible === false) continue - barrierLines.push({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - } - for (const [otherExt, otherStop] of resolved) { - barrierLines.push({ - x1: otherExt.sourcePoint.x, - y1: otherExt.sourcePoint.y, - x2: otherStop.x, - y2: otherStop.y, - }) - } - for (const pk of pendingKLines) barrierLines.push(pk) - // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] 골짜기확장라인 — fallback 단계에서도 통과 금지. - for (const vs of valleyExtPreSegs) barrierLines.push(vs) - for (const ext of extenderPool) { - if (resolved.has(ext)) continue - let bestPt = null - let bestDist = Infinity - let bestSrc = null - for (const wall of roofPolygonWalls) { - const ip = lineLineIntersection(ext.near, ext.far, wall.a, wall.b) - if (!ip) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, wall.a.x, wall.a.y, wall.b.x, wall.b.y)) continue - const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - if (dist < bestDist) { - bestDist = dist - bestPt = ip - bestSrc = { kind: 'wall', a: wall.a, b: wall.b } - } - } - for (const bl of barrierLines) { - const ip = lineLineIntersection(ext.near, ext.far, { x: bl.x1, y: bl.y1 }, { x: bl.x2, y: bl.y2 }) - if (!ip) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, bl.x1, bl.y1, bl.x2, bl.y2)) continue - const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - if (dist < 1e-3) continue - if (dist < bestDist) { - bestDist = dist - bestPt = ip - bestSrc = { kind: 'barrier', a: { x: bl.x1, y: bl.y1 }, b: { x: bl.x2, y: bl.y2 } } - } - } - logger.log( - '[KERAB-FALLBACK] ext=' + (labelOf(ext.line) || (ext.isHip ? 'H' : 'R')) + - ' near=' + JSON.stringify({ x: Math.round(ext.near.x * 100) / 100, y: Math.round(ext.near.y * 100) / 100 }) + - ' stop=' + (bestPt ? JSON.stringify({ x: Math.round(bestPt.x * 100) / 100, y: Math.round(bestPt.y * 100) / 100 }) : 'null') + - ' src=' + (bestSrc - ? bestSrc.kind + - '[' + - Math.round(bestSrc.a.x * 100) / 100 + ',' + Math.round(bestSrc.a.y * 100) / 100 + - '→' + - Math.round(bestSrc.b.x * 100) / 100 + ',' + Math.round(bestSrc.b.y * 100) / 100 + - ']' - : 'none'), - ) - if (bestPt) resolved.set(ext, bestPt) - } - // [KERAB-FIXPOINT-PHASE-A 2026-05-22] cuts 적용 — 사용자 멘탈모델: - // 절삭 방향 = static line 이 "확장되는 방향" (= inward extension 의 anchor 쪽). - // 거리 기반(만남점에 가까운 쪽) 가 아니다 — junction-extended outer hip 의 경우 - // 우연히 일치할 뿐. extension source 끝점 = 확장 anchor = junction stub = 제거. - // 반대편 = dead-end = 유지. - // (a) static cut: 확장 방향 끝점을 만남점까지 잘라낸다. - // (b) 확장 자체 purge: 같은 line 의 wave drawn(inward 확장) 제거. - // (c) cascade: 제거된 끝점을 source/stop 으로 쓰던 다른 drawn segment 도 정리. - // (d) re-resolve: stop 점이 죽은 segment(잘려나간 stub 또는 purge 된 확장) - // 위에 있던 다른 resolved entry 는 unresolved 로 되돌리고, 새 상태로 - // wall + barrier 까지 재확장한다 (Phase A의 "다음 행위"). - // [KERAB-VALLEY-EXT 2026-05-27] 골짜기확장 케이스에선 cuts 적용 skip. - // 사용자 요구: "골짜기 라인은 확장만 하라" — 처마확장만 그리고 - // 다른 hip/ridge(H-2 등) 은 일체 손대지 말 것. - // staticMeets/cuts 는 일반 케라바 알고리즘의 ext hip pattern 정리용인데, - // 골짜기 케이스에선 polygonPath 외부의 H-2 같은 라인까지 dir=junction 으로 - // 단축시키는 부작용이 있음. - const isValleyExtCase = valleyPlannedEndpoints.length > 0 - if (cuts.length > 0 && isValleyExtCase) { - logger.log('[KERAB-FIXPOINT-PHASE-A] cuts SKIPPED (valley extension case)') - } - if (cuts.length > 0 && !isValleyExtCase) { - logger.log( - '[KERAB-FIXPOINT-PHASE-A] applying cuts(initial) ' + - JSON.stringify( - cuts.map((c) => ({ - line: labelOf(c.line) || 'H?', - point: { x: Math.round(c.point.x * 100) / 100, y: Math.round(c.point.y * 100) / 100 }, - })), - ), - ) - // [KERAB-FIXPOINT-PHASE-A 2026-05-22] cuts 를 while/index 로 처리해 re-resolve - // 결과로 새로 잡힌 static meet 이 cuts 에 추가되면 같은 루프에서 처리. - let cutIdx = 0 - while (cutIdx < cuts.length) { - const cut = cuts[cutIdx++] - const line = cut.line - if (!line) continue - const a = { x: line.x1, y: line.y1 } - const b = { x: line.x2, y: line.y2 } - const dA = Math.hypot(a.x - cut.point.x, a.y - cut.point.y) - const dB = Math.hypot(b.x - cut.point.x, b.y - cut.point.y) - // 확장 방향 = 이 line 을 source 로 하는 inward extension 의 sourcePoint 쪽. - // 그 끝점 = junction = 제거 대상. 반대편(dead-end) = 유지. - let extensionAnchor = null - for (const ext of resolved.keys()) { - if (ext.line === line && ext.sourcePoint) { - extensionAnchor = ext.sourcePoint - break - } - } - let keepA - let dirSource - if (extensionAnchor) { - const dAanc = Math.hypot(a.x - extensionAnchor.x, a.y - extensionAnchor.y) - const dBanc = Math.hypot(b.x - extensionAnchor.x, b.y - extensionAnchor.y) - keepA = dAanc > dBanc // anchor 와 먼 쪽(dead-end) 유지 - dirSource = 'extension' - } else { - // priority 2: junction-vs-dead-end. 끝점에 다른 inner line 끝점이 모이면 - // junction(3점 교점 등) → 그 쪽 유지. dead-end(연결없음) 쪽 절삭. - // 둘 다 같으면 다음 priority. - const countJunction = (p) => { - let count = 0 - for (const il of roof.innerLines || []) { - if (!il || il === line) continue - if (il.visible === false) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - if (Math.hypot(il.x1 - p.x, il.y1 - p.y) < 0.5) count += 1 - if (Math.hypot(il.x2 - p.x, il.y2 - p.y) < 0.5) count += 1 - } - return count - } - const jA = countJunction(a) - const jB = countJunction(b) - if (jA !== jB) { - keepA = jA > jB // junction count 많은 쪽 유지, dead-end 쪽 절삭 - dirSource = 'junction' - } else { - // priority 3: 교점에 가까운 쪽 절삭 (먼 쪽 유지). - const TIE_EPS = 1.0 - if (Math.abs(dA - dB) > TIE_EPS) { - keepA = dA >= dB - dirSource = 'distance' - } else { - // priority 4: roofLine 에 가까운 끝점을 가진 쪽 절삭. - const pointToSegDist = (p, sa, sb) => { - const vx = sb.x - sa.x - const vy = sb.y - sa.y - const lenSq = vx * vx + vy * vy - if (lenSq < 1e-6) return Math.hypot(p.x - sa.x, p.y - sa.y) - let t = ((p.x - sa.x) * vx + (p.y - sa.y) * vy) / lenSq - if (t < 0) t = 0 - else if (t > 1) t = 1 - return Math.hypot(p.x - (sa.x + t * vx), p.y - (sa.y + t * vy)) - } - const minDistToRoof = (p) => { - let best = Infinity - for (const w of roofPolygonWalls) { - const d = pointToSegDist(p, w.a, w.b) - if (d < best) best = d - } - return best - } - const rA = minDistToRoof(a) - const rB = minDistToRoof(b) - keepA = rA > rB - dirSource = 'roofLine' - } - } - } - const removedEnd = keepA ? b : a - const remain = Math.hypot((keepA ? a : b).x - cut.point.x, (keepA ? a : b).y - cut.point.y) - if (remain < 0.5) { - if (typeof line.set === 'function') line.set({ visible: false }) - else line.visible = false - } else if (keepA) { - if (typeof line.set === 'function') line.set({ x2: cut.point.x, y2: cut.point.y }) - else { line.x2 = cut.point.x; line.y2 = cut.point.y } - } else { - if (typeof line.set === 'function') line.set({ x1: cut.point.x, y1: cut.point.y }) - else { line.x1 = cut.point.x; line.y1 = cut.point.y } - } - // purge ext.line === cut.line BEFORE deletion: capture drawn segments. - const purgedSegments = [] - let purgedLine = 0 - for (const ext of Array.from(resolved.keys())) { - if (ext.line === line) { - const stop = resolved.get(ext) - if (ext.sourcePoint && stop) { - purgedSegments.push({ - a: { x: ext.sourcePoint.x, y: ext.sourcePoint.y }, - b: { x: stop.x, y: stop.y }, - }) - } - resolved.delete(ext) - purgedLine += 1 - } - } - // cascade: removedEnd(잘려나간 stub 끝점) 에 src/stop 이 직접 붙은 drawn - // segment 만 단일 pass purge. resolved 의 stop 점은 apex/reflection 일 수도 - // 있고 inner polygon junction 일 수도 있으므로 deadPts 로 전파하지 않는다. - // junction 은 살아있는 anchor → 다른 extension(예: H-2) 을 휩쓸어선 안 됨. - let purgedCascade = 0 - for (const [ext, stop] of Array.from(resolved.entries())) { - const src = ext.sourcePoint - if (!src || !stop) continue - const srcAtRemoved = Math.hypot(src.x - removedEnd.x, src.y - removedEnd.y) < 0.5 - const stopAtRemoved = Math.hypot(stop.x - removedEnd.x, stop.y - removedEnd.y) < 0.5 - if (srcAtRemoved || stopAtRemoved) { - resolved.delete(ext) - purgedCascade += 1 - } - } - // (d) re-resolve: 죽은 segment 들 위에 stop 이 있던 resolved entry → 재확장. - // killed = static line 의 잘린 stub (cut.point → removedEnd) + - // 방금 purge 한 drawn segment 들(source → stop). - const killedSegments = [ - { a: { x: cut.point.x, y: cut.point.y }, b: { x: removedEnd.x, y: removedEnd.y } }, - ...purgedSegments, - ] - const staleExts = [] - for (const [ext, stop] of Array.from(resolved.entries())) { - const onKilled = killedSegments.some((seg) => - isPointOnSegment(stop, seg.a.x, seg.a.y, seg.b.x, seg.b.y, 0.5), - ) - if (onKilled) { - resolved.delete(ext) - staleExts.push(ext) - } - } - // 재resolve: 현재 resolved + roof.innerLines 로 barrier 재구축 후 fallback 처럼 - // 가장 가까운 wall/barrier 까지 확장. killed segment 는 barrier 에서 제외됨 - // (resolved 에서 빠졌고 fabric line 좌표가 cut 으로 갱신됨). - let reResolved = 0 - if (staleExts.length > 0) { - const barriers2 = [] - for (const il of roof.innerLines || []) { - if (!il) continue - if (polygonLines.includes(il)) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - if (il.visible === false) continue - barriers2.push({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - } - for (const [otherExt, otherStop] of resolved) { - barriers2.push({ - x1: otherExt.sourcePoint.x, - y1: otherExt.sourcePoint.y, - x2: otherStop.x, - y2: otherStop.y, - }) - } - for (const pk of pendingKLines) barriers2.push(pk) - // [KERAB-VALLEY-EXT-BARRIER 2026-05-27] re-resolve 단계에서도 골짜기확장라인 통과 금지. - for (const vs of valleyExtPreSegs) barriers2.push(vs) - for (const ext of staleExts) { - let bestPt = null - let bestDist = Infinity - for (const wall of roofPolygonWalls) { - const ip = lineLineIntersection(ext.near, ext.far, wall.a, wall.b) - if (!ip) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, wall.a.x, wall.a.y, wall.b.x, wall.b.y)) continue - const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - if (dist < 1e-3) continue - if (dist < bestDist) { bestDist = dist; bestPt = ip } - } - for (const bl of barriers2) { - const ip = lineLineIntersection(ext.near, ext.far, { x: bl.x1, y: bl.y1 }, { x: bl.x2, y: bl.y2 }) - if (!ip) continue - if (!isInward(ext, ip)) continue - if (!isPointOnSegment(ip, bl.x1, bl.y1, bl.x2, bl.y2)) continue - const dist = Math.hypot(ip.x - ext.near.x, ip.y - ext.near.y) - if (dist < 1e-3) continue - if (dist < bestDist) { bestDist = dist; bestPt = ip } - } - if (bestPt) { - resolved.set(ext, bestPt) - reResolved += 1 - } - logger.log( - '[KERAB-FIXPOINT-PHASE-A] re-resolve ext=' + (labelOf(ext.line) || (ext.isHip ? 'H' : 'R')) + - ' near={' + Math.round(ext.near.x * 100) / 100 + ',' + Math.round(ext.near.y * 100) / 100 + '}' + - ' newStop=' + (bestPt ? '{' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}' : 'null'), - ) - } - } - // [KERAB-FIXPOINT-PHASE-A 2026-05-22] re-resolve 후 새 stop 이 정적 inner hip - // 위면 hip+hip 만남(또는 hip+static-hip) 으로 간주 → cuts 추가 + apex/kLine. - // 같은 cuts 루프에서 다음 pass 처리(while/index). - let newCuts = 0 - for (const ext of staleExts) { - const newStop = resolved.get(ext) - if (!newStop) continue - for (const il of roof.innerLines || []) { - if (!il) continue - if (polygonLines.includes(il)) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP) continue - if (il.visible === false) continue - const already = cuts.some( - (c) => c.line === il && Math.hypot(c.point.x - newStop.x, c.point.y - newStop.y) < 0.5, - ) - if (already) continue - if (!isPointOnSegment(newStop, il.x1, il.y1, il.x2, il.y2, 0.5)) continue - cuts.push({ line: il, point: { x: newStop.x, y: newStop.y } }) - newCuts += 1 - // [KERAB-MULTI-APEX 2026-05-22] re-resolve 의 정적 hip 만남(H-2반사 + H-14)이 90° 면 kLine 추가. - if (ext.isHip) { - const vax = ext.far.x - ext.near.x - const vay = ext.far.y - ext.near.y - const vbx = il.x2 - il.x1 - const vby = il.y2 - il.y1 - if (isPerpendicular(vax, vay, vbx, vby)) { - pushApexIfNew(newStop, `reresolve/ext=${labelOf(ext) || '?'}/il=${labelOf(il) || '?'}`) - } - } - break - } - } - logger.log( - '[KERAB-FIXPOINT-PHASE-A] cut applied line=' + (labelOf(line) || 'H?') + - ' dir=' + dirSource + - ' purgedLine=' + purgedLine + ' purgedCascade=' + purgedCascade + - ' stale=' + staleExts.length + ' reResolved=' + reResolved + - ' newCuts=' + newCuts + - ' removedEnd={' + Math.round(removedEnd.x * 100) / 100 + ',' + Math.round(removedEnd.y * 100) / 100 + '}', - ) - } - } - const extLines = [] - for (const [ext, stop] of resolved) { - const fromPt = ext.sourcePoint - if (Math.hypot(stop.x - fromPt.x, stop.y - fromPt.y) < 0.5) continue - extLines.push({ from: fromPt, to: stop, isHip: ext.isHip }) - } - const drawKLine = apexList.length > 0 - const markerApex = apexList.length > 0 ? apexList[0].point : (extLines.length > 0 ? extLines[0].to : null) - const extraApexes = apexList.slice(1).map((ap) => ap.point) - logger.log( - '[KERAB-SIMPLE] sequential resolve ' + - JSON.stringify({ - extLines: extLines.map((e) => ({ - from: { x: Math.round(e.from.x * 100) / 100, y: Math.round(e.from.y * 100) / 100 }, - to: { x: Math.round(e.to.x * 100) / 100, y: Math.round(e.to.y * 100) / 100 }, - isHip: e.isHip, - })), - drawKLine, - markerApex: markerApex - ? { x: Math.round(markerApex.x * 100) / 100, y: Math.round(markerApex.y * 100) / 100 } - : null, - apexList: apexList.map((ap) => ({ - x: Math.round(ap.point.x * 100) / 100, - y: Math.round(ap.point.y * 100) / 100, - })), - }), - ) - if (markerApex) { - const pathHips = polygonPath.filter((p) => p.line.name === LINE_TYPE.SUBLINE.HIP).map((p) => p.line) - const pathRidges = polygonPath.filter((p) => p.line.name === LINE_TYPE.SUBLINE.RIDGE).map((p) => p.line) - target.set({ attributes }) - applyKerabKLinePattern( - roof, - target, - markerApex, - t1, - t2, - [h1Match.hip, h2Match.hip, ...pathHips], - pathRidges, - extLines, - drawKLine, - extraApexes, - ) - // [KERAB-VALLEY-EXT 2026-05-27] valley 처마확장 raycast + drawing — applyKerabKLinePattern 후 실행. - // raycast 대상 = 현재 roof.innerLines 중 HIP/RIDGE (= 살아남은 hip·ridge + 새 extLines·kLine 모두 포함). - // 사라진 polygonPath hip/ridge 는 이미 제거되어 자동 제외. valleyExt 자신은 push 후이므로 1mm 필터로 보호. - if (valleyPlannedEndpoints.length) { - // ==================================================================== - // [KERAB-VALLEY-EXT 2026-05-28] valleyExt helper 4종 (Step B 추출). - // Phase 1 = computeValleyExtensions + drawValleyExtensions - // Phase 2 = trimByValleyExtensions + cascadeHideByValleyExtensions - // 모든 helper 는 closure 로 roof/target/canvas/roofPolygonWalls/valleyPlannedEndpoints 캡처. - // revert 계약 (lineName='kerabPatternValleyExt', __targetId, target.__valleyExtTrims) 그대로 유지. - // ==================================================================== - const isOnSegV = (pt, ax, ay, bx, by, tol = 0.5) => { - const sdx = bx - ax - const sdy = by - ay - const lenSq = sdx * sdx + sdy * sdy - if (lenSq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol - const tt = ((pt.x - ax) * sdx + (pt.y - ay) * sdy) / lenSq - const margin = tol / Math.sqrt(lenSq) - if (tt < -margin || tt > 1 + margin) return false - const px = ax + tt * sdx - const py = ay + tt * sdy - return Math.hypot(px - pt.x, py - pt.y) <= tol - } - - // ── Phase 1-a: valleyExt ray 계산 ── - // self-extension 방향만 사용 (양 끝점 둘 다 시도, concave 측만 hit). - // ridge meet first, 못 만나면 wallhit 끝까지. hip 통과. - const computeValleyExtensions = () => { - const exts = [] - for (const ep of valleyPlannedEndpoints) { - const start = { x: ep.sx, y: ep.sy } - const dx = ep.sx - ep.ox - const dy = ep.sy - ep.oy - const len = Math.hypot(dx, dy) || 1 - const ux = dx / len - const uy = dy / len - const FAR_RAY = 1e5 - const rayEnd = { x: start.x + ux * FAR_RAY, y: start.y + uy * FAR_RAY } - let bestStop = null - let wallT = Infinity - let wallHit = null - for (const w of roofPolygonWalls) { - const ip = lineLineIntersection(start, rayEnd, w.a, w.b) - if (!ip) continue - if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue - const t = (ip.x - start.x) * ux + (ip.y - start.y) * uy - if (t < 0.5) continue - if (t < wallT) { - wallT = t - wallHit = ip - } - } - // [KERAB-VALLEY-HALF 2026-06-11] A/B 타입 표준 규칙(방향 무관): - // 내부확장라인은 맞은편 roofLine 까지 거리의 "절반"에서 멈춘다. - // 기존 ridge-stop / wall-끝까지 휴리스틱은 ridge 가 우연히 중간에 있으면 절반처럼, - // 없으면 끝까지 가버려 방향(세로/가로)에 따라 결과가 들쭉날쭉했다 → 항상 절반으로 통일. - // wallHit = 이 ray 가 만나는 맞은편 roof 외곽선(roofLine). 그 중점이 stop. - if (wallHit) { - bestStop = { x: (start.x + wallHit.x) / 2, y: (start.y + wallHit.y) / 2 } - logger.log( - '[KERAB-VALLEY-HALF] post label=' + ep.label + - ' wallHit={' + Math.round(wallHit.x * 100) / 100 + ',' + Math.round(wallHit.y * 100) / 100 + '}' + - ' halfStop={' + Math.round(bestStop.x * 100) / 100 + ',' + Math.round(bestStop.y * 100) / 100 + '}', - ) - } - if (bestStop) { - const seg = { - x1: start.x, - y1: start.y, - x2: bestStop.x, - y2: bestStop.y, - source: ep.label, - parent: ep.parent || null, - } - exts.push(seg) - logger.log('[KERAB-VALLEY-EXT] generated ' + JSON.stringify(seg)) - } else { - logger.log('[KERAB-VALLEY-EXT] no ridge/hip hit for ' + ep.label) - } - } - return exts - } - - // ── Phase 1-b: roof + wall QLine 생성 (순수 그리기, trim/hide 없음) ── - const drawValleyExtensions = (valleyExtensions) => { - for (const vr of valleyExtensions) { - const pts = [vr.x1, vr.y1, vr.x2, vr.y2] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - // [KERAB-VALLEY-EXT 2026-05-27] 지붕면 할당 통합 (option a): - // roofBase 확장은 처마의 연장 → attributes.type=EAVES + parentLine + innerLines push (split 참여). - // wallBase 확장은 wall layer (roof 폴리곤 밖) → 시각만, innerLines 비추가, type/parent 부여 안 함. - const isRoofBase = !!(vr.source && vr.source.startsWith('roofBase')) - // [KERAB-VALLEY-EXT 2026-05-29] wallLine ID 를 attributes 에 명시 저장. - // apply() 의 wallExt 재계산 RECALC 가 vExt.attributes.wallLine 으로 outerLine 매칭 — 보존 필수. - const baseAttrs = { roofId: roof.id, planeSize: sz, actualSize: sz, wallLine: target.id } - if (isRoofBase) { - // [KERAB-VALLEY-EXT 2026-05-27] splitPolygonWithLines 의 innerLineMapping 은 - // EAVES polygonLine 을 skip 하므로(usePolygon.js:822) 처마확장은 자동 매핑이 안 된다. - // 처마 라인 매핑과 동일하게 type/isStart 를 직접 부여. - baseAttrs.type = LINE_TYPE.WALLLINE.EAVES - baseAttrs.isStart = true - } - const vExt = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.VALLEY, - textMode: roof.textMode, - attributes: baseAttrs, - }) - vExt.lineName = 'kerabPatternValleyExt' - vExt.__targetId = target.id - vExt.__valleyExtSource = vr.source - if (isRoofBase && vr.parent) { - vExt.parentLine = vr.parent - // [KERAB-VALLEY-EXT 2026-05-27] direction 도 부모 roofLine 에서 상속 — flow/pitch 방향 일관. - if (vr.parent.direction) vExt.direction = vr.parent.direction - } - canvas.add(vExt) - vExt.bringToFront() - if (isRoofBase) roof.innerLines.push(vExt) - - // [KERAB-VALLEY-EXT-WALL 2026-05-28] wallBase 레벨 확장 라인 — 독립 ray. - // 사용자 룰: "맞은편 roofLine 까지" — wallBase 측도 자체 시작점에서 ray 쏴서 polygon-wall hit 까지. - // roof 측 끝점과 공유 X (그건 만나라는 의미). roof 측과 동일 방향, ridge-stop 룰 동일. - // wallBase 측은 시각만 — innerLines push X, type=EAVES X, parentLine X. - if (isRoofBase && vr.parent && target) { - const rl = vr.parent - const dxR = rl.x2 - rl.x1 - const dyR = rl.y2 - rl.y1 - const lenR = Math.hypot(dxR, dyR) || 1 - const uxR2 = dxR / lenR - const uyR2 = dyR / lenR - const sT1 = (target.x1 - rl.x1) * uxR2 + (target.y1 - rl.y1) * uyR2 - const sT2 = (target.x2 - rl.x1) * uxR2 + (target.y2 - rl.y1) * uyR2 - const tStart = sT1 < sT2 ? { x: target.x1, y: target.y1 } : { x: target.x2, y: target.y2 } - const tEnd = sT1 < sT2 ? { x: target.x2, y: target.y2 } : { x: target.x1, y: target.y1 } - const wallCorner = vr.source === 'roofBase-s' ? tStart : tEnd - // 방향: vr 방향 그대로 (vr.x1,y1 → vr.x2,y2) - const vdx = vr.x2 - vr.x1 - const vdy = vr.y2 - vr.y1 - const vlen = Math.hypot(vdx, vdy) || 1 - const wUx = vdx / vlen - const wUy = vdy / vlen - const wStart = { x: wallCorner.x, y: wallCorner.y } - const wRayEnd = { x: wStart.x + wUx * 1e5, y: wStart.y + wUy * 1e5 } - // polygon-wall hit - let wWallT = Infinity - let wWallHit = null - for (const w of roofPolygonWalls) { - const ip = lineLineIntersection(wStart, wRayEnd, w.a, w.b) - if (!ip) continue - if (!isOnSegV(ip, w.a.x, w.a.y, w.b.x, w.b.y)) continue - const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy - if (t < 0.5) continue - if (t < wWallT) { - wWallT = t - wWallHit = ip - } - } - // ridge stop (roof 측과 동일 룰) - let wRidgeT = Infinity - let wRidgeStop = null - // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (wallBase 측 ray). - for (const il of roof.innerLines || []) { - if (!il || il.visible === false) continue - if (il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - if (il.lineName !== 'ridge') continue - const ip = lineLineIntersection(wStart, wRayEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) - if (!ip) continue - if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue - const t = (ip.x - wStart.x) * wUx + (ip.y - wStart.y) * wUy - if (t < 0.5) continue - if (wWallT !== Infinity && t > wWallT + 0.5) continue - if (t < wRidgeT) { - wRidgeT = t - wRidgeStop = ip - } - } - const wEnd = wRidgeStop || wWallHit - if (wEnd) { -// [KERAB-VALLEY-OVERLAP 2026-05-28] roof 1개에 split 결과 sub-roof 가 나뉜다. - // 라인은 roof.id 로만 생성 → split 후 직사각형 sub-roof 가 분리되어 나옴. - // apply() 후처리에서 직사각형 sub-roof 를 인접 두 sub-roof 에 union 머지하여 "겹침" 표현. - const buildOverlapLine = (p1, p2, suffix) => { - const lpts = [p1.x, p1.y, p2.x, p2.y] - const lsz = calcLinePlaneSize({ x1: lpts[0], y1: lpts[1], x2: lpts[2], y2: lpts[3] }) - const ln = new QLine(lpts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.VALLEY, - textMode: roof.textMode, - attributes: { - roofId: roof.id, - type: 'kerabValleyOverlapLine', - isStart: true, - pitch: roof.pitch, - planeSize: lsz, - actualSize: lsz, - }, - }) - ln.lineName = 'kerabValleyOverlapLine' - ln.roofId = roof.id - ln.__targetId = target.id - ln.__valleyExtSource = vr.source + suffix - canvas.add(ln) - ln.bringToFront() - return ln - } - // [KERAB-VALLEY-OVERLAP 2026-05-28] 평행 사각형으로 b 외곽 닫기 + (선택) 그 너머 ray 연장. - // 사용자 룰: "vStart 에서 wLine 까지 평행선 → 그 교점에서부터 wallExt 확장". - // 출발: vStart 의 wallLine(target) 위 수선의 발 = newWStart (90° 보조선). 원래 wStart(target corner) 는 사용 안 함. - // 도착: vEnd 의 동일 offset 평행이동 = wEndProj (90° 보조선, 이전과 동일). - // 사각형 4변: vStart-newWStart, newWStart-wEndProj(wallExt 본체), wEndProj-vEnd, vEnd-vStart(=vExt 이미 그려짐). - // wEnd(원 ray hit) 가 wEndProj 보다 더 멀면 wEndProj→wEnd 확장 라인 추가. - const vStart = { x: pts[0], y: pts[1] } - const vEnd = { x: pts[2], y: pts[3] } - const dxT = target.x2 - target.x1 - const dyT = target.y2 - target.y1 - const lenTSq = dxT * dxT + dyT * dyT - const tFoot = ((vStart.x - target.x1) * dxT + (vStart.y - target.y1) * dyT) / Math.max(lenTSq, 1e-9) - const newWStart = { x: target.x1 + tFoot * dxT, y: target.y1 + tFoot * dyT } - const offX = newWStart.x - vStart.x - const offY = newWStart.y - vStart.y - const wEndProj = { x: vEnd.x + offX, y: vEnd.y + offY } - buildOverlapLine(newWStart, wEndProj, '-wall') - buildOverlapLine(vStart, newWStart, '-bridge-start') - buildOverlapLine(vEnd, wEndProj, '-bridge-end') - // [KERAB-VALLEY-HALF 2026-06-11] 절반 규칙: wLine(wallBase) 도 vEnd(절반) 까지만. - // wEndProj 는 절반인 vEnd 에서 파생되므로, wEnd(원 ray full hit) 너머 확장은 금지. - logger.log( - '[KERAB-VALLEY-OVERLAP] drawn ' + - JSON.stringify({ - src: vr.source, - stop: wRidgeStop ? 'ridge' : 'wall', - newWStart: { x: Math.round(newWStart.x * 100) / 100, y: Math.round(newWStart.y * 100) / 100 }, - wEndProj: { x: Math.round(wEndProj.x * 100) / 100, y: Math.round(wEndProj.y * 100) / 100 }, - wEnd: { x: Math.round(wEnd.x * 100) / 100, y: Math.round(wEnd.y * 100) / 100 }, - vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 }, - vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 }, - extended: false, - }), - ) - - // [KERAB-VALLEY-EXT-TRIM 2026-05-29] 룰 2: vExt 가 ridge 와 교차하면 - // wallExt(`:`) 측(=vExt 와 가까운 영역) ridge 부분 절삭. - // ridge 의 V apex 는 vExt 와 먼 끝점 (Y 출발점). 살아남는 쪽 = V apex 쪽. - // 방향 판정: 절삭 방향 = wallExt 중점 - vExt 중점 (wallExt 측). dot 양수면 절삭 대상. - const veMid = { x: (vStart.x + vEnd.x) / 2, y: (vStart.y + vEnd.y) / 2 } - const wsMid = { x: (newWStart.x + wEndProj.x) / 2, y: (newWStart.y + wEndProj.y) / 2 } - const bDirX = wsMid.x - veMid.x - const bDirY = wsMid.y - veMid.y - const isBSide = (px, py) => (px - veMid.x) * bDirX + (py - veMid.y) * bDirY > 0 - const trimCascadePts = [] - const newTrimRecords = [] - for (const il of roof.innerLines || []) { - if (!il || il.visible === false) continue - // [KERAB-VALLEY-EXT-TRIM 2026-05-29] trim 대상 = 원래 ridge/hip + 마루 확장(ExtRidge) + kLine(kerabPatternRidge). - // stop 룰(룰 1) 은 ridge 만, trim 룰(룰 2) 은 hip 도 포함 — vExt 는 hip 통과 후 절삭. - // 절삭 방향은 V apex 우선 룰 (아래) 로 결정. - const isRidge = - il.name === LINE_TYPE.SUBLINE.RIDGE && - (il.lineName === 'ridge' || il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge') - const isHip = - il.name === LINE_TYPE.SUBLINE.HIP && - (il.lineName === 'hip' || il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip') - if (!isRidge && !isHip) continue - const ip = lineLineIntersection(vStart, vEnd, { x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) - if (!ip) continue - if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue - if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue - // [VALLEY-EXT-TRIM-ENDPOINT-GUARD 2026-06-10] ip 가 il 끝점과 일치하면 - // 그 라인은 vExt 를 가로지르는(cross) 게 아니라 vExt 에서 끝나는(terminate) - // 라인(예: RG-1 연장선, 한 끝이 이미 vExt 위). 반대 끝을 ip 로 절삭하면 양 끝이 - // 같은 점이 되어 길이 0 붕괴 → 라인 소실. 절삭은 막되, 끝점이 vExt 내부점이면 - // split 은 여전히 필요(그래프 노드 공유 → 할당 dead-end 방지)하므로 split 전용 - // 레코드만 push 한다(좌표 변경/cascade 없음). revert 는 splitOnly 를 건너뛴다. - if (Math.hypot(ip.x - il.x1, ip.y - il.y1) < 1.0 || Math.hypot(ip.x - il.x2, ip.y - il.y2) < 1.0) { - newTrimRecords.push({ line: il, splitOnly: true, newPt: { x: ip.x, y: ip.y } }) - continue - } - // [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰. - // V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점. - // 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정. - // 1) 한 끝만 V apex → 그 V apex 측 살림, 반대 끝 절삭. - // 2) 양 끝 모두 V apex → vExt 와 먼 끝 살림 (가까운 끝 절삭). - // 3) 둘 다 V apex 아님 → 기존 fallback (B 측 = wallExt 측 절삭). - const isVApex = (px, py) => { - let cnt = 0 - for (const other of roof.innerLines || []) { - if (!other || other === il) continue - if (other.visible === false) continue - if (other.name !== LINE_TYPE.SUBLINE.HIP) continue - if (other.lineName !== 'kerabPatternHip' && other.lineName !== 'kerabPatternExtHip') continue - if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) cnt++ - else if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) cnt++ - if (cnt >= 2) return true - } - return false - } - const e1V = isVApex(il.x1, il.y1) - const e2V = isVApex(il.x2, il.y2) - const e1B = isBSide(il.x1, il.y1) - const e2B = isBSide(il.x2, il.y2) - let trimEnd = 0 - if (e1V && !e2V) trimEnd = 2 // e1 V apex 살림, e2 절삭 - else if (!e1V && e2V) trimEnd = 1 - else if (e1V && e2V) { - // 양 끝 모두 V apex → vExt 와 가까운 끝 절삭 - const d1 = Math.hypot(il.x1 - veMid.x, il.y1 - veMid.y) - const d2 = Math.hypot(il.x2 - veMid.x, il.y2 - veMid.y) - trimEnd = d1 < d2 ? 1 : 2 - } else { - // 둘 다 V apex 아님 → 기존 B 측 fallback - if (e1B && !e2B) trimEnd = 1 - else if (!e1B && e2B) trimEnd = 2 - else continue - } - const oldPt = trimEnd === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } - const originalAttrs = { ...(il.attributes || {}) } - if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y }) - else il.set({ x2: ip.x, y2: ip.y }) - if (typeof il.setCoords === 'function') il.setCoords() - const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } - // [KERAB-EXTRIDGE-CONNECT 2026-06-01] cascade hide 시 ExtRidge 끝점을 vExt 끝점(=ip)로 연장. - // ExtRidge 와 vExt 끝점이 분리돼 split graph 의 sub-roof 외곽이 안 닫히는 문제 해소. - trimCascadePts.push({ pt: oldPt, newPt: { x: ip.x, y: ip.y } }) - newTrimRecords.push({ - line: il, - end: trimEnd, - oldPt, - newPt: { x: ip.x, y: ip.y }, - originalAttrs, - }) - logger.log( - '[KERAB-VALLEY-EXT-TRIM] ridge trim ' + - JSON.stringify({ lineName: il.lineName, trimEnd, oldPt, ip: { x: Math.round(ip.x * 100) / 100, y: Math.round(ip.y * 100) / 100 } }), - ) - } - // cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide - const cascadeHidden = new Set() - let cascadeGuard = 0 - while (trimCascadePts.length && cascadeGuard++ < 200) { - const entry = trimCascadePts.shift() - if (!entry) continue - const pt = entry.pt || entry - for (const il of roof.innerLines || []) { - if (!il || il.visible === false) continue - if (cascadeHidden.has(il)) continue - if (il.lineName !== 'kerabPatternExtRidge') continue - const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0 - const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0 - if (!m1 && !m2) continue - cascadeHidden.add(il) - const originalVisible = il.visible !== false - const originalAttrs = { ...(il.attributes || {}) } - il.visible = false - if (typeof il.setCoords === 'function') il.setCoords() - newTrimRecords.push({ - line: il, - hide: true, - originalVisible, - originalAttrs, - }) - logger.log( - '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + - JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), - ) - trimCascadePts.push({ pt: m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } }) - } - } - // [KERAB-VALLEY-EXT-SPLIT 2026-06-04] Option A: vExt(내부 roofLine)가 ridge/hip 와 - // *내부* 교점에서 만나면 그 교점에서 vExt 자체를 분할한다. - // 이유: getSplitRoofsPoints(usePolygon.js)는 라인 *끝점*만 그래프 노드로 만든다 - // (교차분할 없음). 절삭된 ridge(예: RG-2)가 vExt 의 끝점이 아닌 중간(internal)에 - // 닿으면 공유 노드가 없어 dead-end → 지붕면 할당에서 고립 폐기되고 인접 면이 안 - // 쪼개진다(예: F-2 비대). vExt 를 교점에서 분할해 끝점=노드를 만들어 ridge 가 - // 할당 그래프에 연결되게 한다. 절삭 규칙/방향은 건드리지 않고 vExt 분할만 추가. - // revert: 분할 세그먼트도 lineName='kerabPatternValleyExt' + __targetId 이므로 - // applyKerabRevertPattern 의 valleyExt 제거 스캔에서 자동 정리된다. - { - const SPLIT_TOL = 1.0 - const interiorPts = [] - for (const r of newTrimRecords) { - if (!r || r.hide || !r.newPt) continue - const ip = r.newPt - if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue - const dS = Math.hypot(ip.x - vStart.x, ip.y - vStart.y) - const dE = Math.hypot(ip.x - vEnd.x, ip.y - vEnd.y) - if (dS < SPLIT_TOL || dE < SPLIT_TOL) continue // 끝점은 이미 노드 - if (interiorPts.some((p) => Math.hypot(p.x - ip.x, p.y - ip.y) < SPLIT_TOL)) continue - interiorPts.push({ x: ip.x, y: ip.y }) - } - if (interiorPts.length) { - const vdx2 = vEnd.x - vStart.x - const vdy2 = vEnd.y - vStart.y - const tOf = (p) => (p.x - vStart.x) * vdx2 + (p.y - vStart.y) * vdy2 - interiorPts.sort((a, b) => tOf(a) - tOf(b)) - const bps = [vStart, ...interiorPts, vEnd] - // 첫 구간은 기존 vExt 재사용(끝점 단축), 나머지는 새 QLine 세그먼트. - vExt.set({ x2: bps[1].x, y2: bps[1].y }) - if (typeof vExt.setCoords === 'function') vExt.setCoords() - const sz0 = calcLinePlaneSize({ x1: vExt.x1, y1: vExt.y1, x2: vExt.x2, y2: vExt.y2 }) - vExt.attributes = { ...vExt.attributes, planeSize: sz0, actualSize: sz0 } - const newSegs = [] - for (let bi = 1; bi < bps.length - 1; bi++) { - const a = bps[bi] - const b = bps[bi + 1] - const segSz = calcLinePlaneSize({ x1: a.x, y1: a.y, x2: b.x, y2: b.y }) - const seg = new QLine([a.x, a.y, b.x, b.y], { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.VALLEY, - textMode: roof.textMode, - attributes: { ...(vExt.attributes || {}), planeSize: segSz, actualSize: segSz }, - }) - seg.lineName = 'kerabPatternValleyExt' - seg.__targetId = target.id - seg.__valleyExtSource = vExt.__valleyExtSource - if (vExt.parentLine) seg.parentLine = vExt.parentLine - if (vExt.direction) seg.direction = vExt.direction - canvas.add(seg) - seg.bringToFront() - roof.innerLines.push(seg) - newSegs.push(seg) - } - logger.log( - '[KERAB-VALLEY-EXT-SPLIT] vExt split ' + - JSON.stringify({ - source: vr.source, - breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })), - segs: newSegs.length + 1, - }), - ) - debugCapture.log('KERAB-VALLEY-EXT-SPLIT', { - source: vr.source, - vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 }, - vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 }, - breaks: interiorPts.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 })), - segCount: newSegs.length + 1, - }) - } - } - // [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적. - // 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리. - // 한 mousedown 에 여러 vExt 가 호출될 수 있어 concat. - if (newTrimRecords.length) { - target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords) - } - debugCapture.log('KERAB-VALLEY-EXT-TRIM', { - source: vr.source, - vStart: { x: Math.round(vStart.x * 100) / 100, y: Math.round(vStart.y * 100) / 100 }, - vEnd: { x: Math.round(vEnd.x * 100) / 100, y: Math.round(vEnd.y * 100) / 100 }, - count: newTrimRecords.length, - trims: newTrimRecords.map((r) => ({ - lineName: r.line?.lineName, - name: r.line?.name, - label: typeof labelOf === 'function' ? labelOf(r.line) : undefined, - hide: r.hide ?? false, - end: r.end, - oldPt: r.oldPt ? { x: Math.round(r.oldPt.x * 100) / 100, y: Math.round(r.oldPt.y * 100) / 100 } : null, - newPt: r.newPt ? { x: Math.round(r.newPt.x * 100) / 100, y: Math.round(r.newPt.y * 100) / 100 } : null, - now: r.line ? { x1: Math.round(r.line.x1 * 100) / 100, y1: Math.round(r.line.y1 * 100) / 100, x2: Math.round(r.line.x2 * 100) / 100, y2: Math.round(r.line.y2 * 100) / 100 } : null, - })), - }) - } else { - logger.log('[KERAB-VALLEY-EXT-WALL] no hit for ' + vr.source) - } - } - } - } - - // ── Phase 2-a: valleyExt 경로상 교차 hip/ridge 절삭 ── - // trim 대상 = ridge + 케라바 토글 생성 hip(kerabPatternHip/ExtHip). 원래 지붕 hip 보존. - // Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축. - const trimByValleyExtensions = (valleyExtensions) => { - const trimRecords = [] - for (const vr of valleyExtensions) { - if (!vr.source || !vr.source.startsWith('roofBase')) continue - const segA = { x: vr.x1, y: vr.y1 } - const segB = { x: vr.x2, y: vr.y2 } - const wallEnd = { x: 2 * segB.x - segA.x, y: 2 * segB.y - segA.y } - const dxV = wallEnd.x - segA.x - const dyV = wallEnd.y - segA.y - const lenV = Math.hypot(dxV, dyV) || 1 - const ux = dxV / lenV - const uy = dyV / lenV - const tB = (segB.x - segA.x) * ux + (segB.y - segA.y) * uy - const COLLINEAR_TOL = 1.0 - for (const il of roof.innerLines || []) { - if (!il) continue - if (il.lineName === 'kerabPatternValleyExt') continue - // trim 대상 = ridge(마루) + 케라바 토글 생성 hip(kerabPatternHip / kerabPatternExtHip). - // 원래 지붕 hip 은 보존. - const isTrimCandidate = - il.name === LINE_TYPE.SUBLINE.RIDGE || - (il.name === LINE_TYPE.SUBLINE.HIP && (il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip')) - if (!isTrimCandidate) continue - if (il.visible === false) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - let ip = lineLineIntersection(segA, wallEnd, a, b) - if (ip) { - if (!isOnSegV(ip, segA.x, segA.y, wallEnd.x, wallEnd.y)) ip = null - else if (!isOnSegV(ip, a.x, a.y, b.x, b.y)) ip = null - } - if (!ip) { - const perp1 = Math.abs((il.x1 - segA.x) * uy - (il.y1 - segA.y) * ux) - const perp2 = Math.abs((il.x2 - segA.x) * uy - (il.y2 - segA.y) * ux) - if (perp1 > COLLINEAR_TOL || perp2 > COLLINEAR_TOL) continue - const tc1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy - const tc2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy - if (Math.max(tc1, tc2) <= tB + 0.5) continue - ip = { x: segB.x, y: segB.y } - } - // Y 룰: hip-presence 검사 → hip 있는 쪽 보존, 없는 쪽 단축. - // 양쪽 다 원래 hip 있음 → 통째 보존. 양쪽 다 없음 → 진행벡터 fallback (cascade pass 에서 정리). - const isOriginalHipEndAt = (px, py) => { - for (const other of roof.innerLines || []) { - if (!other || other === il) continue - if (other.lineName === 'kerabPatternValleyExt') continue - if (other.lineName === 'kerabPatternHip' || other.lineName === 'kerabPatternExtHip') continue - if (other.name !== LINE_TYPE.SUBLINE.HIP) continue - if (other.visible === false) continue - if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) return true - if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) return true - } - return false - } - const hip1 = isOriginalHipEndAt(il.x1, il.y1) - const hip2 = isOriginalHipEndAt(il.x2, il.y2) - let trimEnd - if (hip1 && hip2) { - logger.log('[KERAB-VALLEY-EXT-TRIM] both-hip preserve ' + JSON.stringify({ name: il.name, lineName: il.lineName })) - continue - } else if (hip1 && !hip2) { - trimEnd = 2 - } else if (!hip1 && hip2) { - trimEnd = 1 - } else { - const t1 = (il.x1 - segA.x) * ux + (il.y1 - segA.y) * uy - const t2 = (il.x2 - segA.x) * ux + (il.y2 - segA.y) * uy - trimEnd = t1 > t2 ? 1 : 2 - } - const oldX = trimEnd === 1 ? il.x1 : il.x2 - const oldY = trimEnd === 1 ? il.y1 : il.y2 - trimRecords.push({ - line: il, - end: trimEnd, - oldPt: { x: oldX, y: oldY }, - newPt: { x: ip.x, y: ip.y }, - originalAttrs: { ...(il.attributes || {}) }, - }) - if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y }) - else il.set({ x2: ip.x, y2: ip.y }) - if (typeof il.setCoords === 'function') il.setCoords() - const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } - logger.log( - '[KERAB-VALLEY-EXT-TRIM] trim ' + - JSON.stringify({ name: il.name, lineName: il.lineName, trimEnd, oldPt: { x: oldX, y: oldY }, ip }), - ) - } - } - return trimRecords - } - - // ── Phase 2-b: cascade hide ── - // trim oldPt 에 끝점 일치 케라바산 라인(kerabPatternHip/ExtHip/Ridge/ExtRidge) BFS hide. - // 원래 지붕 ridge/hip 은 cascade 대상 외 — junction 공유 의도 외 절삭 방지. - const cascadeHideByValleyExtensions = (trimRecords) => { - const isKerabSynthetic = (line) => { - if (!line || !line.lineName) return false - return ( - line.lineName === 'kerabPatternHip' || - line.lineName === 'kerabPatternExtHip' || - line.lineName === 'kerabPatternRidge' || - line.lineName === 'kerabPatternExtRidge' - ) - } - const cascadeHidden = new Set() - const cascadeQueue = trimRecords.map((r) => r.oldPt).filter(Boolean) - let cascadeGuard = 0 - while (cascadeQueue.length && cascadeGuard++ < 200) { - const pt = cascadeQueue.shift() - if (!pt) continue - for (const il of roof.innerLines || []) { - if (!il || il.visible === false) continue - if (cascadeHidden.has(il)) continue - if (il.lineName === 'kerabPatternValleyExt') continue - if (!isKerabSynthetic(il)) continue - const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0 - const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0 - if (!m1 && !m2) continue - cascadeHidden.add(il) - trimRecords.push({ - line: il, - hide: true, - originalAttrs: { ...(il.attributes || {}) }, - originalVisible: il.visible !== false, - }) - il.visible = false - if (typeof il.setCoords === 'function') il.setCoords() - logger.log( - '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + - JSON.stringify({ name: il.name, lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), - ) - cascadeQueue.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) - } - } - } - - // === Phase 1 + Phase 2 실행 === - const valleyExtensions = computeValleyExtensions() - drawValleyExtensions(valleyExtensions) - if (valleyExtensions.length) { - logger.log('[KERAB-VALLEY-EXT] drawn ' + valleyExtensions.length) - } - // [KERAB-VALLEY-EXT 2026-05-28] 사용자 지시: 골짜기 라인이동(trim)·라인절삭(cascade) 다 비활성, 확장만. - // 골짜기 케라바 라인 별도 룰 후속에서 재활성 예정. - // const trimRecords = trimByValleyExtensions(valleyExtensions) - // cascadeHideByValleyExtensions(trimRecords) - const trimRecords = [] - if (trimRecords.length) target.__valleyExtTrims = trimRecords - } - dumpInnerLineSnapshot('AFTER') - runKerabRuleCheck(roof, 'forward', kerabBeforeSnap) - logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')') - return - } - dumpInnerLineSnapshot('AFTER') - logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only') - target.set({ attributes }) - applyKerabAttributeOnlyPattern() - runKerabRuleCheck(roof, 'forward', kerabBeforeSnap) - return - } - // condition 1: 자연 만남 — ext 없이 hip 제거 + kLine - target.set({ attributes }) - applyKerabKLinePattern( - roof, - target, - apex, - t1, - t2, - [h1Match.hip, h2Match.hip], - null, - null, - ) - dumpInnerLineSnapshot('AFTER') - runKerabRuleCheck(roof, 'forward', kerabBeforeSnap) - logger.log('[KERAB-SIMPLE] applied (kLine, natural-apex)') - return - } - } - target.set({ attributes }) - applyKerabAttributeOnlyPattern() - runKerabRuleCheck(roof, 'forward', kerabBeforeSnap) - logger.log('[KERAB-SIMPLE] attr-only fallback') - return - } - } - - // [2240 KERAB-REVERT 2026-05-19] 대전제 1 역방향: 케라바→처마 변환 시 케라바 중점→apex ridge 가 존재하면 - // ridge 1개 제거 + apex→c1, apex→c2 hip 2개 생성으로 처리하고 기존 rebuild 흐름은 건너뜀. - // ridge-at-mid 없으면 forward 가 attribute-only 였던 케이스 → 속성만 EAVES 로 토글. - if (typeRef.current === TYPES.EAVES && radioTypeRef.current === '1' && target.attributes?.type === LINE_TYPE.WALLLINE.GABLE) { - const roof = canvas - .getObjects() - .find((o) => o.name === POLYGON_TYPE.ROOF && !o.isFixed && o.id === target.attributes?.roofId) - if (roof && Array.isArray(roof.innerLines) && Array.isArray(roof.points)) { - // [KERAB-RULE-CHECK 2026-06-10] revert 변환 전(게이블 출폭) 상태를 R4 anchor 기준으로 캡처. - const kerabRevertBeforeSnap = snapshotKerabState(roof) - const c1 = nearestRoofPoint(roof, { x: target.x1, y: target.y1 }) - const c2 = nearestRoofPoint(roof, { x: target.x2, y: target.y2 }) - if (c1 && c2) { - // [KERAB-REVERT-EXTEND-45 2026-06-05] 룰: hip 은 wallbaseLine 의 45° (skeleton 이론 부정 X). - // 본체 inner endpoint = snapshot 그대로 (apex 위치). - // 본체 outer endpoint = wL 코너에서 45° 방향(snapshot 의 inner→outer 방향) 으로 ray cast → 새 rL 변 hit point. - // wL 코너는 라인이 지나가는 transit point 일 뿐 data endpoint 아님. - // skeleton-utils.js drawBaselineToRooflineHelpers (line 770~880) 와 동일 방식. - const surgicalAfter = () => { - // [KERAB-REVERT-EXTEND-45 2026-06-05] 룰: 출폭 변경 시 wallbaseLine 안의 내부라인 끝점 불변. - // surgical 의 CORNER-SHORTCUT(roofLine 코너로 스냅)·SHRINK-TRIM(__shrinkOrig 복원) 은 룰 위반 → - // skipInnerLines:true 로 roof.points + matchingRL/prev/nextRL + edge 객체만 갱신. - // 증상: (1) b1 ray-cast 후 b4 출폭조정 → SHRINK-TRIM restore 가 b1 hip 을 snapshot 좌표로 리셋 - // (2) CORNER-SHORTCUT 가 hip outer endpoint 를 새 roofLine 코너로 강제 스냅. - // hip 의 outer endpoint 만 아래 ray-cast 로 새 rL 변까지 45° 확장한다. - // [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] surgical 은 roof.points 를 새 출폭으로 갱신하므로, - // 게이블 코너 hip 판정용으로 이동 전 옛 폴리곤 꼭짓점을 미리 스냅샷한다. - const oldPolyPoints = (Array.isArray(roof.points) ? roof.points : []).map((p) => ({ x: p.x, y: p.y })) - if (roof) applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true }) - // [45-UNIFY 2026-06-15] reclick 과 동일한 extendKerabHipsTo45 사용 (revert 가드 on): - // forward 가 붙여둔 __kerabRevertOuterWhich/Side 플래그로 marks 를 만든 뒤, surgical *후* - // 새 폴리곤에 대해 outerShared/outerOnVertex 가드를 적용해 45° 확장. 플래그는 사용 후 정리. - const marks = [] - for (const il of roof.innerLines || []) { - if (!il || !il.__kerabRevertOuterWhich) continue - marks.push({ il, which: il.__kerabRevertOuterWhich, side: il.__kerabRevertOuterSide }) - } - extendKerabHipsTo45(target, roof, marks, { useRevertGuards: true, oldPolyPoints }) - for (const m of marks) { - delete m.il.__kerabRevertOuterWhich - delete m.il.__kerabRevertOuterSide - } - runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap) - } - // [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원 - if (Array.isArray(target.__kerabParallelHipsSnapshot)) { - target.set({ attributes }) - revertKerabParallelHipsPattern(roof, target) - surgicalAfter() - logger.log('[KERAB-REVERT] applied (parallel-hips) ' + JSON.stringify({ c1, c2 })) - return - } - // [KERAB-SIMPLE-REVERT 2026-05-20] kLineOnly 패턴: ridge.__targetId 로 직접 매칭 (nearestRoofPoint drift 회피) - const kLineRidge = roof.innerLines.find( - (il) => il && il.__patternKind === 'kLineOnly' && il.__targetId === target.id, - ) - if (kLineRidge) { - target.set({ attributes }) - // [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] TYPE 출신 revert(케라바→처마)도 출폭이 바뀌므로 옛 변 위 - // 면경계선이 따라와야 한다(안 그러면 rLine 겹침). apex(= kLine 의 cMid 반대 끝) + edgeIdx 미리 캡처. - const _kMid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } - const _kApex = - Math.hypot(kLineRidge.x1 - _kMid.x, kLineRidge.y1 - _kMid.y) >= Math.hypot(kLineRidge.x2 - _kMid.x, kLineRidge.y2 - _kMid.y) - ? { x: kLineRidge.x1, y: kLineRidge.y1 } - : { x: kLineRidge.x2, y: kLineRidge.y2 } - const _kTol = 0.5 - const _kNP = roof.points.length - let _kEdgeIdx = -1 - for (let i = 0; i < _kNP; i++) { - const p = roof.points[i] - const q = roof.points[(i + 1) % _kNP] - if ( - (Math.hypot(p.x - c1.x, p.y - c1.y) < _kTol && Math.hypot(q.x - c2.x, q.y - c2.y) < _kTol) || - (Math.hypot(p.x - c2.x, p.y - c2.y) < _kTol && Math.hypot(q.x - c1.x, q.y - c1.y) < _kTol) - ) { - _kEdgeIdx = i - break - } - } - const ok = applyKerabRevertPattern(roof, target, c1, c2, { ridge: kLineRidge, apex: null }) - if (ok) { - surgicalAfter() - moveStaleEdgeInnerLines(roof, c1, c2, _kEdgeIdx, _kApex) - extendTypeGableHipsStraightToRoofLine(roof, target, _kApex) - } - logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok, edgeIdx: _kEdgeIdx, apex: _kApex })) - if (ok) return - } - // [2240 KERAB-MID-FROM-TARGET 2026-05-20] revert 도 target 중점 기준 ridge 검색 (forward 와 일관) - const tEnd1 = { x: target.x1, y: target.y1 } - const tEnd2 = { x: target.x2, y: target.y2 } - const ridgeAtMid = findRidgeAtMidpoint(roof, tEnd1, tEnd2) - // [KERAB-TYPE-EAVES 2026-06-11] TYPE 출신(native 마루·토글 이력 없음) 은 전용 함수로 분기. - // 기존 폴백(applyKerabRevertPattern single-ridge)은 apex=마루 far-end + 마루 전체 삭제라 - // L/2 setback·마루 단축 규칙과 어긋남. native 마루일 때만 가로채고 나머지는 그대로. - // 불변식 "마루는 roofLine 까지" → native 마루 끝점은 wLine 중점이 아니라 roofLine 코너(c1/c2) - // 중점에 있다. 그래서 파인더는 c1/c2 기준 (tEnd 기준 ridgeAtMid 는 出幅>0 이면 null). - const typeRidge = ridgeAtMid || findRidgeAtMidpoint(roof, c1, c2) - if (ENABLE_TYPE_GABLE_TO_EAVES && typeRidge && isNativeTypeRidge(typeRidge.ridge)) { - target.set({ attributes }) - // 변환 전 옛 roofLine 변(c1↔c2) 의 polygon 인덱스 캡처 — surgical 후 새 코너 좌표 매핑용. - const _EDGE_TOL = 0.5 - const _NP = roof.points.length - let _edgeIdx = -1 - for (let i = 0; i < _NP; i++) { - const p = roof.points[i] - const q = roof.points[(i + 1) % _NP] - if ( - (Math.hypot(p.x - c1.x, p.y - c1.y) < _EDGE_TOL && Math.hypot(q.x - c2.x, q.y - c2.y) < _EDGE_TOL) || - (Math.hypot(p.x - c2.x, p.y - c2.y) < _EDGE_TOL && Math.hypot(q.x - c1.x, q.y - c1.y) < _EDGE_TOL) - ) { - _edgeIdx = i - break - } - } - const apexRes = applyTypeGableToEavesPattern(roof, target, typeRidge.ridge) - if (apexRes) { - surgicalAfter() - // [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] surgicalAfter 는 skipInnerLines:true 라 옛 roofLine - // 변 위에 놓인 normal inner line(L자 면경계)이 안 따라온다 → 옛 위치에 ghost("한개 더"). - // 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치). - // 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지). - // 케라바패턴 라인(내 hip)은 surgicalAfter 의 ray-cast 가 이미 처리 → 제외. - // [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] apexRes 를 항상 넘긴다. relocate 가 apexRes.ridgeHidden - // 플래그로 골짜기 세로 힙(H-8) 끝점을 apex 로 끌어올리지 않고 제자리 앵커(위로 반전 방지)하기 때문. - moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes) - // [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장. - extendTypeGableHipsStraightToRoofLine(roof, target, apexRes) - // [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마가 되어 완전 寄棟이면, - // 세로 apex 2개·X자 교차를 버리고 힙·마루 규칙(R1/R2/R3)으로 가로 마루 + 절삭 힙 4개로 재구성. - reconstructHipRoofRidgeIfComplete(roof) - // [KERAB-RULE-CHECK 2026-06-12] 모든 라인변경(재구성 포함)이 끝난 최종 형상을 규칙으로 검증. - // surgicalAfter 내부 검사는 재구성 전 상태라, 최종 상태는 여기서 한 번 더 본다. - runKerabRuleCheck(roof, 'type-eaves', kerabRevertBeforeSnap) - } - logger.log('[KERAB-TYPE-EAVES] branch ' + JSON.stringify({ ok: !!apexRes, c1, c2, edgeIdx: _edgeIdx })) - if (apexRes) return - } - if (ridgeAtMid) { - target.set({ attributes }) - const ok = applyKerabRevertPattern(roof, target, c1, c2, ridgeAtMid) - if (ok) surgicalAfter() - logger.log('[KERAB-REVERT] applied ' + JSON.stringify({ ok, c1, c2, apex: ridgeAtMid.apex })) - if (ok) return - } - // [KERAB-VALLEY-EAVES-RULE1 2026-06-16] A/B타입 native 케라바 변(스냅샷·native마루·kLine 전부 없음)의 - // 첫 처마 변환 "씨앗". 양 코너에서 폴리곤 안쪽 45° 힙 2개를 그려 각자 첫 교차(다른 inner line 또는 - // 상대편 roofLine)까지 뻗는다. R3 절삭만 — 반사·힙 사이 마루 생성은 보류(다각형 넓이 의존, 추후 결정). - // 出幅은 일반 라인변경과 동일하게 기존 surgical 로 처리. 게이트: 변 위에 collinear 게이블벽 inner line - // 이 실재할 때만(= 진짜 케라바 기하). 없으면 아래 attr-only 폴백. - { - const EDGE_TOL = 0.5 - const NP = roof.points.length - let edgeIdx = -1 - for (let i = 0; i < NP; i++) { - const p = roof.points[i] - const q = roof.points[(i + 1) % NP] - if ( - (Math.hypot(p.x - c1.x, p.y - c1.y) < EDGE_TOL && Math.hypot(q.x - c2.x, q.y - c2.y) < EDGE_TOL) || - (Math.hypot(p.x - c2.x, p.y - c2.y) < EDGE_TOL && Math.hypot(q.x - c1.x, q.y - c1.y) < EDGE_TOL) - ) { - edgeIdx = i - break - } - } - // 변환되는 게이블 변의 "게이블벽" 전체를 제거한다(사용자 확정 2026-06-16). - // 게이블벽 = 변 c1↔c2(roofLine쪽) 또는 옛 出幅쪽 outerLine 과 같은 무한직선 위의 HIP 세그먼트. - // 변이 게이블일 때만 존재한 구조 → eaves 가 되면 소멸. 한 직선이 ridge 에 의해 여러 세그먼트로 - // 쪼개져 변 구간 밖(아래)까지 뻗기도 한다(예: H-9 = 변 아래 연장). 따라서 변 구간 겹침이 아니라 - // "무한직선 동일선상(양 끝점 수직거리<1)" 전체를 게이블벽으로 본다 — i5(변 위) + i10(변 아래) 모두. - // 이 전체 제거가 ridge(RG-2)의 anchor 를 끊어, 뒤의 dangling 정리에서 RG-2 까지 연쇄 제거된다. - const isCollinearWith = (l, A, B) => { - const dx = B.x - A.x - const dy = B.y - A.y - const lenSq = dx * dx + dy * dy || 1 - const perp = (px, py) => { - const t = ((px - A.x) * dx + (py - A.y) * dy) / lenSq - return Math.hypot(px - (A.x + t * dx), py - (A.y + t * dy)) - } - return perp(l.x1, l.y1) < 1.0 && perp(l.x2, l.y2) < 1.0 - } - // 옛 出幅쪽 수직벽 식별용 — surgical 전 target outerLine 끝점(=出幅 위치) 캡처. - const tA = { x: target.x1, y: target.y1 } - const tB = { x: target.x2, y: target.y2 } - // 게이블벽(HIP)만 직접 제거. native 마루(RIDGE)는 여기서 보존하되, anchor 가 끊겨 dangling 이 - // 되는 ridge(RG-2)는 뒤의 cleanupDangling 이 연쇄 제거한다(사용자 확정 2026-06-16). - const gableWallLines = (roof.innerLines || []).filter( - (l) => - l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.HIP && (isCollinearWith(l, c1, c2) || isCollinearWith(l, tA, tB)), - ) - if (edgeIdx >= 0 && gableWallLines.length > 0) { - target.set({ attributes }) - // 1) 옛 게이블벽 라인 제거 + 짝 확장 helper(extensionLine) 동반 제거. - for (const gw of gableWallLines) { - removeLine(gw) - removeOrphanExtensionsForHip(gw) - } - roof.innerLines = roof.innerLines.filter((l) => !gableWallLines.includes(l)) - // 2) 出幅 반영 (일반 라인변경과 동일 — 기존 surgical, inner line 불변). - applyTargetOffsetSurgical(target, attributes?.offset ?? 0, { skipInnerLines: true }) - // 3) 새 roofLine 코너 (인덱스는 surgical 후에도 안정). - const pts = roof.points - const nA = pts[edgeIdx] - const nB = pts[(edgeIdx + 1) % NP] - const newC1IsA = Math.hypot(nA.x - c1.x, nA.y - c1.y) <= Math.hypot(nA.x - c2.x, nA.y - c2.y) - const idxA = newC1IsA ? edgeIdx : (edgeIdx + 1) % NP - const idxB = newC1IsA ? (edgeIdx + 1) % NP : edgeIdx - const newC1 = pts[idxA] - const newC2 = pts[idxB] - // 폴리곤 중심 (inward 판정용). - let cenX = 0 - let cenY = 0 - for (const p of pts) { - cenX += p.x - cenY += p.y - } - cenX /= pts.length || 1 - cenY /= pts.length || 1 - // 코너의 두 이웃 변 방향 → 폴리곤 안쪽 45° 이등분선 (convex/reflex 모두 안쪽으로 보정). - const inwardDir = (idx) => { - const C = pts[idx] - const prev = pts[(idx - 1 + NP) % NP] - const next = pts[(idx + 1) % NP] - const n1 = Math.hypot(prev.x - C.x, prev.y - C.y) || 1 - const n2 = Math.hypot(next.x - C.x, next.y - C.y) || 1 - let dx = (prev.x - C.x) / n1 + (next.x - C.x) / n2 - let dy = (prev.y - C.y) / n1 + (next.y - C.y) / n2 - const dl = Math.hypot(dx, dy) - if (dl < 1e-6) { - // 일직선(180°) — 변 수직 안쪽 법선. - dx = -(next.y - C.y) / n2 - dy = (next.x - C.x) / n2 - } else { - dx /= dl - dy /= dl - } - if (dx * (cenX - C.x) + dy * (cenY - C.y) < 0) { - dx = -dx - dy = -dy - } - return { x: dx, y: dy } - } - // corner 에서 dir 로 ray → 첫 교차(roofLine 변 + 기존 inner line), 자기 변(t≈0) 제외. - const rayFirstHit = (P, dir) => { - const segHit = (A, B) => { - const ex = B.x - A.x - const ey = B.y - A.y - const den = dir.x * ey - dir.y * ex - if (Math.abs(den) < 1e-9) return Infinity - const t = ((A.x - P.x) * ey - (A.y - P.y) * ex) / den - const u = ((A.x - P.x) * dir.y - (A.y - P.y) * dir.x) / den - if (t > 1.0 && u >= -1e-6 && u <= 1 + 1e-6) return t - return Infinity - } - let best = Infinity - let hit = null - let hitLine = null - for (let i = 0; i < NP; i++) { - const t = segHit(pts[i], pts[(i + 1) % NP]) - if (t < best) { - best = t - hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: null, kind: 'roofLine' } - hitLine = null - } - } - for (const il of roof.innerLines || []) { - if (!il || il.visible === false) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - const t = segHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) - if (t < best) { - best = t - hit = { x: P.x + dir.x * t, y: P.y + dir.y * t, line: il, kind: il.name } - hitLine = il - } - } - return hit - } - const mkValleyHip = (corner, hit) => { - const arr = [corner.x, corner.y, hit.x, hit.y] - const sz = calcLinePlaneSize({ x1: arr[0], y1: arr[1], x2: arr[2], y2: arr[3] }) - const hip = new QLine(arr, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.HIP, - textMode: roof.textMode, - attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.HIP, planeSize: sz, actualSize: sz }, - }) - hip.lineName = 'kerabPatternHip' - hip.startPoint = { x: hip.x1, y: hip.y1 } - hip.endPoint = { x: hip.x2, y: hip.y2 } - return hip - } - const addHipFrom = (corner, idx) => { - const dir = inwardDir(idx) - const hit = rayFirstHit(corner, dir) - if (!hit) return { dir, hit: null, hip: null, hitLine: null, hitKind: null } - const h = mkValleyHip(corner, hit) - canvas.add(h) - h.bringToFront() - roof.innerLines.push(h) - return { dir, hit, hip: h, hitLine: hit.line, hitKind: hit.kind } - } - // 임의 방향 ray 로 골짜기 힙 1개 생성(반사 힙용). 첫 교차까지. - const drawHipRay = (from, dir) => { - const hit = rayFirstHit(from, dir) - if (!hit) return null - const h = mkValleyHip(from, hit) - canvas.add(h) - h.bringToFront() - roof.innerLines.push(h) - return { hip: h, hit } - } - // 4) 게이블 구조 잔재 정리 — anchor(게이블벽 HIP) 제거로 dangling 이 된 HIP·확장라인(H-9 등)만 제거. - // ★ 마루(RIDGE)는 절대 제거하지 않는다 — dangling 이어도 보존(사용자 확정 2026-06-16: - // "마루를 지우면 안된다"). RIDGE 는 connectivity 판정에는 포함(다른 HIP 의 접합 대상)하되 - // dangler 후보에서는 제외. VALLEY(골짜기확장)도 vExt 위에서 끝나는 정상 케이스라 제외. - // 새 골짜기 힙 그리기 *전에* 수행해야 raycast 가 깨끗한 기하에 닿아 SHORT-hit 를 피한다. - const cleanupDangling = () => { - const TOL = 2.0 - const rpts = roof.points || [] - const distToSeg = (p, a, b) => { - const ex = b.x - a.x - const ey = b.y - a.y - const l2 = ex * ex + ey * ey || 1 - let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2 - t = Math.max(0, Math.min(1, t)) - return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey)) - } - const minEdgeDist = (pt) => { - let m = Infinity - for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) { - const d = distToSeg(pt, rpts[j], rpts[i]) - if (d < m) m = d - } - return m - } - const removed = [] - for (let iter = 0; iter < 12; iter++) { - const surv = (roof.innerLines || []).filter( - (l) => - l && - l.visible !== false && - (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY), - ) - const ends = [] - for (const l of surv) { - ends.push({ x: l.x1, y: l.y1, line: l }) - ends.push({ x: l.x2, y: l.y2, line: l }) - } - // 마루(RIDGE)·VALLEY 는 dangling 이어도 제거 금지 — HIP 만 제거 후보. - const dangler = surv.find((l) => { - if (l.name !== LINE_TYPE.SUBLINE.HIP) return false - for (const p of [ - { x: l.x1, y: l.y1 }, - { x: l.x2, y: l.y2 }, - ]) { - const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL - const shared = ends.some((e) => e.line !== l && Math.hypot(e.x - p.x, e.y - p.y) < TOL) - if (!onBoundary && !shared) return true - } - return false - }) - if (!dangler) break - removeLine(dangler) - roof.innerLines = roof.innerLines.filter((l) => l !== dangler) - removeOrphanExtensionsForHip(dangler) - removed.push({ n: dangler.name, ln: dangler.lineName || '-', x1: Math.round(dangler.x1), y1: Math.round(dangler.y1), x2: Math.round(dangler.x2), y2: Math.round(dangler.y2) }) - } - return removed - } - const cleaned = cleanupDangling() - const resA = addHipFrom(newC1, idxA) - const resB = addHipFrom(newC2, idxB) - // 5b) 마루(RIDGE) 끝점 상호작용 — anchor 잃은 끝점 해소(사용자 확정 2026-06-16: "RG1(h8) 확장, RG2(b5) 절삭"). - // 규칙: 마루는 절대 삭제 안 함. anchor 잃은(=경계 미접촉 + 다른 선과 미공유) 끝점은 - // · 새 골짜기 힙이 그 마루를 가로지르면 → 교점에서 절삭(trim, 끝점을 교점으로 당김) - // · 가로지르는 힙이 없으면 → 끝점 방향으로 roofLine/다음 라인까지 확장(extend) - const resolveRidgeEndpoints = () => { - const TOL = 2.0 - const rpts = roof.points || [] - const newHips = [resA, resB].map((r) => r && r.hip).filter(Boolean) - const distToSeg = (p, a, b) => { - const ex = b.x - a.x - const ey = b.y - a.y - const l2 = ex * ex + ey * ey || 1 - let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2 - t = Math.max(0, Math.min(1, t)) - return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey)) - } - const minEdgeDist = (pt) => { - let m = Infinity - for (let i = 0, j = rpts.length - 1; i < rpts.length; j = i++) { - const d = distToSeg(pt, rpts[j], rpts[i]) - if (d < m) m = d - } - return m - } - // 선분 교차(내부 교점만, 끝점 t∈(eps,1-eps)) - const segInt = (p1, p2, p3, p4) => { - const d1x = p2.x - p1.x - const d1y = p2.y - p1.y - const d2x = p4.x - p3.x - const d2y = p4.y - p3.y - const den = d1x * d2y - d1y * d2x - if (Math.abs(den) < 1e-9) return null - const t = ((p3.x - p1.x) * d2y - (p3.y - p1.y) * d2x) / den - const u = ((p3.x - p1.x) * d1y - (p3.y - p1.y) * d1x) / den - if (t <= 1e-6 || t >= 1 - 1e-6 || u <= 1e-6 || u >= 1 - 1e-6) return null - return { x: p1.x + t * d1x, y: p1.y + t * d1y } - } - // 절삭 전용: 마루(rp→rq)는 내부만, 힙(ha→hb)은 끝점 닿음(T자)까지 허용 - const segIntTrim = (rp, rq, ha, hb) => { - const d1x = rq.x - rp.x - const d1y = rq.y - rp.y - const d2x = hb.x - ha.x - const d2y = hb.y - ha.y - const den = d1x * d2y - d1y * d2x - if (Math.abs(den) < 1e-9) return null - const t = ((ha.x - rp.x) * d2y - (ha.y - rp.y) * d2x) / den - const u = ((ha.x - rp.x) * d1y - (ha.y - rp.y) * d1x) / den - if (t <= 1e-6 || t >= 1 - 1e-6) return null // 마루 내부만 - if (u <= 1e-6 || u >= 1 + 1e-3) return null // 힙 끝점 닿음 허용 - return { x: rp.x + t * d1x, y: rp.y + t * d1y } - } - // 힙의 먼 끝(코너=startPoint 반대쪽 endPoint)을 교차점으로 절삭 + 라벨 갱신 - const trimHipFarEnd = (hip, cross) => { - hip.set({ x2: cross.x, y2: cross.y }) - hip.endPoint = { x: cross.x, y: cross.y } - const nsz = calcLinePlaneSize({ x1: hip.x1, y1: hip.y1, x2: cross.x, y2: cross.y }) - hip.attributes = { ...hip.attributes, planeSize: nsz, actualSize: nsz } - hip.setLength?.() - hip.setCoords?.() - hip.addLengthText?.() - } - const ridges = (roof.innerLines || []).filter((l) => l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.RIDGE) - const allEnds = [] - for (const l of (roof.innerLines || []).filter( - (l) => - l && - l.visible !== false && - (l.name === LINE_TYPE.SUBLINE.HIP || l.name === LINE_TYPE.SUBLINE.RIDGE || l.name === LINE_TYPE.SUBLINE.VALLEY), - )) { - allEnds.push({ x: l.x1, y: l.y1, line: l }) - allEnds.push({ x: l.x2, y: l.y2, line: l }) - } - const touched = [] - for (const rg of ridges) { - const endsOf = [ - { p: { x: rg.x1, y: rg.y1 }, q: { x: rg.x2, y: rg.y2 }, set: (pt) => rg.set({ x1: pt.x, y1: pt.y }) }, - { p: { x: rg.x2, y: rg.y2 }, q: { x: rg.x1, y: rg.y1 }, set: (pt) => rg.set({ x2: pt.x, y2: pt.y }) }, - ] - for (const { p, q, set } of endsOf) { - const onBoundary = rpts.length >= 3 && minEdgeDist(p) <= TOL - const shared = allEnds.some((e) => e.line !== rg && Math.hypot(e.x - p.x, e.y - p.y) < TOL) - if (onBoundary || shared) continue // anchor 있음 → 그대로 - // (a) 가로지르는 힙 탐색 → 절삭 - let trimPt = null - let trimD = Infinity - for (const h of newHips) { - const ip = segIntTrim(p, q, { x: h.x1, y: h.y1 }, { x: h.x2, y: h.y2 }) - if (!ip) continue - const d = Math.hypot(ip.x - p.x, ip.y - p.y) - if (d < trimD) { - trimD = d - trimPt = ip - } - } - if (trimPt) { - set(trimPt) - touched.push({ act: 'trim', ln: rg.lineName || '-', to: { x: Math.round(trimPt.x), y: Math.round(trimPt.y) } }) - continue - } - // (b) 확장 — q→p 방향으로 첫 교차(roofLine 변 + 다른 inner line, 자기 제외)까지 - const dx = p.x - q.x - const dy = p.y - q.y - const mag = Math.hypot(dx, dy) || 1 - const ux = dx / mag - const uy = dy / mag - const castHit = (A, B) => { - const ip = segInt({ x: q.x, y: q.y }, { x: q.x + ux * 1e5, y: q.y + uy * 1e5 }, A, B) - if (!ip) return Infinity - const t = (ip.x - p.x) * ux + (ip.y - p.y) * uy - return t > 1e-3 ? t : Infinity - } - let best = Infinity - let hit = null - let hitLine = null - for (let i = 0; i < rpts.length; i++) { - const A = rpts[i] - const B = rpts[(i + 1) % rpts.length] - const t = castHit(A, B) - if (t < best) { - best = t - hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) } - hitLine = null // roofLine 변 - } - } - for (const il of roof.innerLines || []) { - if (!il || il === rg || il.visible === false) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - const t = castHit({ x: il.x1, y: il.y1 }, { x: il.x2, y: il.y2 }) - if (t < best) { - best = t - hit = { x: q.x + ux * (mag + t), y: q.y + uy * (mag + t) } - hitLine = il - } - } - if (hit) { - set(hit) - touched.push({ act: 'extend', ln: rg.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } }) - // 확장한 마루가 힙 몸통에 닿으면 → 그 힙의 먼 끝(코너 반대쪽)을 교차점으로 절삭(힙은 마루를 넘지 못함) - if (hitLine && hitLine.name === LINE_TYPE.SUBLINE.HIP) { - trimHipFarEnd(hitLine, hit) - touched.push({ act: 'trim-hip', ln: hitLine.lineName || '-', to: { x: Math.round(hit.x), y: Math.round(hit.y) } }) - } - } - } - } - return touched - } - // 5a') 골짜기 마름모 생성 — A,B타입 골짜기 라인은 케라바+처마 조합이라 - // 라인변경(케라바→처마) 시 두 평행 힙( / / ) + 잇는 마루 = 마름모(힙—마루—힙)가 되어야 한다. - // 두 힙은 *같은 방향*(평행)이고 *같은 깊이*면 끝점을 잇는 마루가 골짜기 라인과 평행 - // = 항상 수평/수직(마루 비대각 불변식 충족). 깊이 = 두 raycast hit 중 *짧은 쪽(min)*. - // 짧은 쪽 = 먼저 닫히는 인접면 — 긴 쪽에 맞추면 짧은 면을 넘어 ><(R3 위반)이 되므로 min. - // (사용자 확정 2026-06-22: 가로·세로 골짜기 변경 시 양쪽 다 마름모 생성.) - // 마름모 깊이 산출 진단(로그/검증용). - let adjHipDbg = null - const buildValleyDiamond = () => { - if (!resA || !resA.hip || !resA.hit || !resB || !resB.hip || !resB.hit) return null - // 두 힙이 평행해야 마름모(잇는 마루 = 골짜기 라인과 평행). 방향이 다르면 마름모 아님 → 폴백. - if (Math.hypot(resA.dir.x - resB.dir.x, resA.dir.y - resB.dir.y) > 0.05) return null - // [KERAB-VALLEY-EAVES-RULE1 MIDLINE 2026-06-22] 마름모 깊이 = *결정적 중앙선 공식*. - // 잇는 마루(수평/수직 불변식)는 골짜기 처마 roofLine 과 그 맞은편 평행 건물변의 *정확히 중앙*에 놓인다. - // (straight-skeleton: 두 평행 처마변 사이 ridge 는 중앙선). 인접 힙 매칭·내부 ridge clamp(min raycast)는 - // 값이 매 실행마다 흔들려 폐기(사용자 확정 2026-06-22: "힙길이가 자꾸 바뀐다", 2820/3930 결정값 기대). - // depth(축) = |중앙선축 − offset 코너축|, 힙길이 = depth × √2 (45° 불변식). - // 중앙선축 = (원래 골짜기 roofLine 축 + 맞은편 평행변 축)/2. - // · 원래 골짜기 roofLine 축: c1(=surgical offset 이전 nearestRoofPoint) — 가로 골짜기는 c1.y, 세로는 c1.x. - // · 맞은편 평행변 축: 골짜기 중점에서 *안쪽(힙 방향 부호)* 으로 축정렬 ray 를 쏴 처음 만나는 *외곽 폴리곤 변*. - // 내부선(transient HIP/RIDGE)은 제외 — 그게 값 흔들림의 근본원인이었음. - const valleyHorizontal = - Math.abs(pts[(edgeIdx + 1) % NP].x - pts[edgeIdx].x) >= Math.abs(pts[(edgeIdx + 1) % NP].y - pts[edgeIdx].y) - // 원래 골짜기 roofLine 의 축값 + 중점(c1/c2 = surgical offset 이전 코너). - const origValleyAxis = valleyHorizontal ? c1.y : c1.x - const midX = (c1.x + c2.x) / 2 - const midY = (c1.y + c2.y) / 2 - // 안쪽 방향 부호 = 힙 진행 방향(가로 골짜기→y, 세로 골짜기→x). - const sgn = valleyHorizontal ? Math.sign(resA.dir.y) || 1 : Math.sign(resA.dir.x) || 1 - // 골짜기 중점에서 축정렬 ray 를 외곽 폴리곤 변에만 쏴 맞은편 평행변 축값을 구함. - const EPS = 0.01 - let oppAxis = null - for (let i = 0; i < NP; i++) { - const a = pts[i] - const b = pts[(i + 1) % NP] - if (valleyHorizontal) { - // 변이 x=midX 를 가로지르나? 가로지른다면 그 지점의 y. - if ((a.x - midX) * (b.x - midX) > EPS) continue - if (Math.abs(a.x - b.x) < EPS) continue - const t = (midX - a.x) / (b.x - a.x) - if (t < -EPS || t > 1 + EPS) continue - const y = a.y + t * (b.y - a.y) - if (sgn * (y - midY) <= EPS) continue // 안쪽(힙 방향)만 - if (oppAxis === null || sgn * (y - oppAxis) < 0) oppAxis = y - } else { - if ((a.y - midY) * (b.y - midY) > EPS) continue - if (Math.abs(a.y - b.y) < EPS) continue - const t = (midY - a.y) / (b.y - a.y) - if (t < -EPS || t > 1 + EPS) continue - const x = a.x + t * (b.x - a.x) - if (sgn * (x - midX) <= EPS) continue - if (oppAxis === null || sgn * (x - oppAxis) < 0) oppAxis = x - } - } - const cornerAxis = valleyHorizontal ? newC1.y : newC1.x - // raycast 폴백(중앙선 못 구한 경우의 null-safety 만): 두 raycast hit 중 짧은 쪽. - const depthA = Math.hypot(resA.hit.x - newC1.x, resA.hit.y - newC1.y) - const depthB = Math.hypot(resB.hit.x - newC2.x, resB.hit.y - newC2.y) - const fallbackD = Math.min(depthA, depthB) - // [KERAB-VALLEY-EAVES-RULE1 CLAMP 2026-06-22] 두 힙은 *각자 독립적으로* 중앙선까지 확장하되, - // 가는 길에 *마루/힙* 을 먼저 만나면 *그 힙만* 거기서 멈춤(사용자 확정: "만나는 그 힙만 멈추고, - // 다른 힙은 계속 중앙까지"). 넘어가면 R3(교차/오버슈트) 위반. - // ★ roofLine 히트는 막는 대상 아님(중앙선이 지붕 안이면 정상) → 마루/힙 hit 만 obstruction. - // ★ 두 힙 길이가 *다를 수 있음* → 끝을 잇는 게 축정렬이면 마루, 아니면 힙으로 연결(아래). - const isObstr = (kind) => kind === LINE_TYPE.SUBLINE.HIP || kind === LINE_TYPE.SUBLINE.RIDGE - let dA = depthA - let dB = depthB - let source = 'fallback-min-raycast' - let midlineAxis = null - let dDet = null - if (oppAxis !== null) { - midlineAxis = (origValleyAxis + oppAxis) / 2 - const dM = Math.abs(midlineAxis - cornerAxis) * Math.SQRT2 - if (dM >= 1) { - dDet = dM - dA = resA.hit && isObstr(resA.hitKind) ? Math.min(dM, depthA) : dM - dB = resB.hit && isObstr(resB.hitKind) ? Math.min(dM, depthB) : dM - source = dA < dM - 1e-6 || dB < dM - 1e-6 ? 'midline-clamped' : 'midline' - } - } - adjHipDbg = { - valleyHorizontal, - origValleyAxis: Math.round(origValleyAxis * 10) / 10, - oppAxis: oppAxis === null ? null : Math.round(oppAxis * 10) / 10, - midlineAxis: midlineAxis === null ? null : Math.round(midlineAxis * 10) / 10, - cornerAxis: Math.round(cornerAxis * 10) / 10, - dDet: dDet === null ? null : Math.round(dDet), - depthA: Math.round(depthA), - depthB: Math.round(depthB), - obstr: { a: resA.hitKind, b: resB.hitKind }, - usedDA: Math.round(dA), - usedDB: Math.round(dB), - source, - } - if (dA < 1 || dB < 1) return null - // [KERAB-VALLEY-EAVES-RULE1 CASCADE 2026-06-22] 힙이 *중앙선 전에* 마루/힙을 만나는 경우 - // (depth < 중앙선 depth): 마름모(중앙 clamp + connector) 가 아니라 *순차 절삭 연쇄*. - // 사용자 확정: CCW 로 먼저 그린 힙이 그 마루를 *흡수측 절삭*(--< : 경계=박공 출발측 보존, - // 노치측 흡수) → 그 절삭으로 다른 힙의 장애물(마루 근측)이 사라져 다른 힙은 *다음 라인까지 확장*. - // ★ 2820 대칭 마름모 케이스(장애물이 중앙선 *너머*)는 depth ≥ dDet 라 여기 안 걸림 → 기존 경로 보존. - const cascadeA = !!(resA.hit && isObstr(resA.hitKind) && dDet !== null && depthA < dDet - 1e-6) - const cascadeB = !!(resB.hit && isObstr(resB.hitKind) && dDet !== null && depthB < dDet - 1e-6) - if (cascadeA || cascadeB) { - const cops = [] - const rptsC = roof.points || [] - const TOLC = 2.0 - const d2s = (p, a, b) => { - const ex = b.x - a.x - const ey = b.y - a.y - const l2 = ex * ex + ey * ey || 1 - let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2 - t = Math.max(0, Math.min(1, t)) - return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey)) - } - const minEd = (pt) => { - let m = Infinity - for (let i = 0, j = rptsC.length - 1; i < rptsC.length; j = i++) { - const d = d2s(pt, rptsC[j], rptsC[i]) - if (d < m) m = d - } - return m - } - const hipsC = [resA, resB].filter((r) => r && r.hip) - // 0) ★ 내부확장박스(kerabValleyOverlapLine) 삭제 — 케라바 적용 때 그려진 골짜기 겹침 사각형은 - // 처마로 되돌릴 때 사라져야 한다(사용자 확정 2026-06-22: "내부확장박스를 삭제하지?"). - // 삭제 후, 이 박스를 *지탱하던 마루* 는 따로 확장(아래 [지탱마루확장]) 한다. - const boxLines = (canvas?.getObjects() || []).filter( - (o) => o && o.parentId === roof.id && (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine'), - ) - for (const bl of boxLines) { - cops.push({ act: 'box-remove', ln: bl.lineName || '-', p1: { x: Math.round(bl.x1), y: Math.round(bl.y1) }, p2: { x: Math.round(bl.x2), y: Math.round(bl.y2) } }) - removeLine(bl) - if (Array.isArray(roof.innerLines)) roof.innerLines = roof.innerLines.filter((l) => l !== bl) - } - // 코너에서 dir 로 가는 무한 ray ↔ 마루 선분 교점(마루 내부 u∈[0,1], ray 앞쪽 t>0). - const rayRidgeCross = (corner, dir, rg) => { - const ax = rg.x1 - const ay = rg.y1 - const ex = rg.x2 - rg.x1 - const ey = rg.y2 - rg.y1 - const den = dir.x * ey - dir.y * ex - if (Math.abs(den) < 1e-9) return null - const t = ((ax - corner.x) * ey - (ay - corner.y) * ex) / den - const u = ((ax - corner.x) * dir.y - (ay - corner.y) * dir.x) / den - if (t <= 1e-6 || u < -1e-6 || u > 1 + 1e-6) return null - return { x: corner.x + dir.x * t, y: corner.y + dir.y * t } - } - // 1) ★ 마루확장 + 흡수측 절삭(사용자 확정 2026-06-22: "박스삭제→RG-1·RG-2 확장→힙생성 절삭"). - // 각 마루의 *비경계(free)* 끝을 먼저 roof bbox 까지 바깥확장 → 힙 교점들이 세그먼트 안에 - // 들어오게 한 뒤, *free 끝에서 가장 먼* 교점까지 당겨 절삭(--<). 보존측 = 경계(박공 출발측). - // ★ RG-1 이 짧아 H-8 교점(611)을 못 잡던 문제 해소: 확장하면 farthest-from-free 가 611 을 준다. - // free/keep 판정은 *확장 전* 좌표로 1회 고정(확장 후 bbox 끝이 경계로 재인식되는 것 방지). - const ridgesC = (roof.innerLines || []).filter((l) => l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.RIDGE) - let bx0 = Infinity - let bx1 = -Infinity - let by0 = Infinity - let by1 = -Infinity - for (const p of rptsC) { - if (p.x < bx0) bx0 = p.x - if (p.x > bx1) bx1 = p.x - if (p.y < by0) by0 = p.y - if (p.y > by1) by1 = p.y - } - for (const rg of ridgesC) { - const E1 = { x: rg.x1, y: rg.y1 } - const E2 = { x: rg.x2, y: rg.y2 } - const b1 = minEd(E1) <= TOLC - const b2 = minEd(E2) <= TOLC - if (b1 === b2) continue // 보존/흡수측 판별 불가(둘 다/둘 다 아님) → 절삭 보류 - const keep = b1 ? E1 : E2 // 경계측 보존 - const free = b1 ? E2 : E1 // 비경계측 = 흡수(절삭) 대상 - // free 끝을 ridge 방향(keep→free) 으로 bbox 경계까지 확장. - const dxR = free.x - keep.x - const dyR = free.y - keep.y - const lenR = Math.hypot(dxR, dyR) || 1 - const ux = dxR / lenR - const uy = dyR / lenR - let tMax = Infinity - if (ux > 1e-9) tMax = Math.min(tMax, (bx1 - keep.x) / ux) - else if (ux < -1e-9) tMax = Math.min(tMax, (bx0 - keep.x) / ux) - if (uy > 1e-9) tMax = Math.min(tMax, (by1 - keep.y) / uy) - else if (uy < -1e-9) tMax = Math.min(tMax, (by0 - keep.y) / uy) - const absorbed = isFinite(tMax) && tMax > lenR ? { x: keep.x + ux * tMax, y: keep.y + uy * tMax } : free - const setFree = b1 ? (p) => rg.set({ x2: p.x, y2: p.y }) : (p) => rg.set({ x1: p.x, y1: p.y }) - setFree(absorbed) // 임시 확장(아래에서 교점까지 다시 당김) - const crossings = [] - for (const r of hipsC) { - const corner = r === resA ? newC1 : newC2 - const cx = rayRidgeCross(corner, r.dir, rg) - if (cx) crossings.push(cx) - } - if (!crossings.length) { - // 교점 없으면 확장 취소(원복) — 잘못된 bbox 확장 잔재 방지. - setFree(free) - rg.setLength?.() - rg.setCoords?.() - rg.addLengthText?.() - continue - } - // 흡수측(확장된 free) 에서 *가장 먼* 교점까지 절삭 = 노치측 세그먼트 최대 제거. - let far = null - let farD = -1 - for (const c of crossings) { - const d = Math.hypot(c.x - absorbed.x, c.y - absorbed.y) - if (d > farD) { - farD = d - far = c - } - } - setFree(far) - if (b1) rg.endPoint = { x: far.x, y: far.y } - else rg.startPoint = { x: far.x, y: far.y } - const nsz = calcLinePlaneSize({ x1: rg.x1, y1: rg.y1, x2: rg.x2, y2: rg.y2 }) - rg.attributes = { ...(rg.attributes || {}), planeSize: nsz, actualSize: nsz } - rg.setLength?.() - rg.setCoords?.() - rg.addLengthText?.() - cops.push({ act: 'extend-trim', ln: rg.lineName || '-', to: { x: Math.round(far.x), y: Math.round(far.y) }, sz: nsz }) - } - // 2) ★ 삭제 금지(사용자 확정 2026-06-22: "확장하라고 했지 삭제하니?"). 절삭으로 anchor 잃은 - // 라인은 *제거하지 않고* 확장해 형상을 닫는다. (확장 타깃 확정 전까지 보류 — 삭제만 차단.) - const orphans = [] - // 3) 갱신된 형상으로 두 힙을 *재-raycast* 후 다시 그림(앞 힙 절삭이 뒤 힙 장애물을 없앤 결과 반영). - for (const r of hipsC) { - const corner = r === resA ? newC1 : newC2 - const hit2 = rayFirstHit(corner, r.dir) - if (!hit2) continue - const sz = calcLinePlaneSize({ x1: corner.x, y1: corner.y, x2: hit2.x, y2: hit2.y }) - r.hip.set({ x1: corner.x, y1: corner.y, x2: hit2.x, y2: hit2.y }) - r.hip.startPoint = { x: corner.x, y: corner.y } - r.hip.endPoint = { x: hit2.x, y: hit2.y } - r.hip.attributes = { ...(r.hip.attributes || {}), planeSize: sz, actualSize: sz } - r.hip.setLength?.() - r.hip.setCoords?.() - r.hip.addLengthText?.() - r.hit = hit2 - r.hitLine = hit2.line - r.hitKind = hit2.kind - cops.push({ act: 'hip-recast', ln: r.hip.lineName || '-', to: { x: Math.round(hit2.x), y: Math.round(hit2.y) }, kind: hit2.kind, sz }) - } - // 4) ★ 마지막: 서로 다른 길의 두 힙 끝점(마루 안착점)을 잇는 45° 연결 힙 - // (사용자 확정 2026-06-22: "다른 길의 힙과 힙을 이어주는 라인은 힙이 된다, 45도" = 반삭합). - // 각 힙이 마루에 안착한 뒤 두 안착점을 연결해 노치 형상을 닫는다. - const eA = resA && resA.hit ? { x: resA.hit.x, y: resA.hit.y } : null - const eB = resB && resB.hit ? { x: resB.hit.x, y: resB.hit.y } : null - if (eA && eB) { - const cdx = Math.abs(eB.x - eA.x) - const cdy = Math.abs(eB.y - eA.y) - const dist = Math.hypot(eB.x - eA.x, eB.y - eA.y) - if (dist > 1 && Math.abs(cdx - cdy) <= 1.0) { - const conn = mkValleyHip(eA, eB) - canvas.add(conn) - conn.bringToFront() - roof.innerLines.push(conn) - cops.push({ act: 'connector-hip', from: { x: Math.round(eA.x), y: Math.round(eA.y) }, to: { x: Math.round(eB.x), y: Math.round(eB.y) }, sz: conn.attributes?.planeSize }) - } else { - cops.push({ act: 'connector-skip', reason: Math.abs(cdx - cdy) > 1.0 ? 'not-45' : 'too-short', from: { x: Math.round(eA.x), y: Math.round(eA.y) }, to: { x: Math.round(eB.x), y: Math.round(eB.y) } }) - } - } - adjHipDbg.cascade = { cascadeA, cascadeB, orphans: orphans.length, ops: cops } - return { ops: cops, cascade: true } - } - const ops = [] - const setLen = (res, corner, len) => { - const ex = corner.x + res.dir.x * len - const ey = corner.y + res.dir.y * len - const sz = calcLinePlaneSize({ x1: corner.x, y1: corner.y, x2: ex, y2: ey }) - res.hip.set({ x1: corner.x, y1: corner.y, x2: ex, y2: ey }) - res.hip.startPoint = { x: corner.x, y: corner.y } - res.hip.endPoint = { x: ex, y: ey } - res.hip.attributes = { ...(res.hip.attributes || {}), planeSize: sz, actualSize: sz } - res.hip.setLength?.() - if (typeof res.hip.setCoords === 'function') res.hip.setCoords() - res.hip.addLengthText?.() - ops.push({ ln: res.hip.lineName || '-', to: { x: Math.round(ex), y: Math.round(ey) } }) - return { x: ex, y: ey } - } - const e1 = setLen(resA, newC1, dA) - const e2 = setLen(resB, newC2, dB) - const rlen = Math.hypot(e2.x - e1.x, e2.y - e1.y) - if (rlen >= 1) { - // 두 힙 끝을 잇는 선: 축정렬(수평/수직)이면 마루, 아니면 힙으로 연결(두 힙 길이가 달라진 경우). - const CTOL = 1.0 - const axisAligned = Math.abs(e1.x - e2.x) < CTOL || Math.abs(e1.y - e2.y) < CTOL - const rpts = [e1.x, e1.y, e2.x, e2.y] - const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] }) - const conn = new QLine(rpts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: axisAligned ? 4 : 3, - name: axisAligned ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP, - textMode: roof.textMode, - attributes: { roofId: roof.id, ...(axisAligned ? {} : { type: LINE_TYPE.SUBLINE.HIP }), planeSize: rsz, actualSize: rsz }, - }) - conn.lineName = axisAligned ? 'kerabPatternRidge' : 'kerabPatternHip' - conn.startPoint = { x: conn.x1, y: conn.y1 } - conn.endPoint = { x: conn.x2, y: conn.y2 } - canvas.add(conn) - conn.bringToFront() - roof.innerLines.push(conn) - ops.push({ [axisAligned ? 'ridge' : 'connHip']: { from: { x: Math.round(e1.x), y: Math.round(e1.y) }, to: { x: Math.round(e2.x), y: Math.round(e2.y) } } }) - } - return { ops, e1, e2 } - } - const diamond = buildValleyDiamond() - // 깊이 산출 진단 — 큰 applied 로그에 묻혀 잘리지 않도록 별도 짧은 줄로 먼저 출력. - logger.log('[KERAB-VALLEY-EAVES-RULE1 MIDLINE] ' + JSON.stringify(adjHipDbg)) - // 마름모를 만든 경우에도 resolveRidgeEndpoints 를 *이어서* 실행한다 — 새 힙·마루로 - // anchor 잃은 기존 세로/가로 마루를 절삭(또는 확장)해 dangling(R1) 을 해소(사용자 확정 2026-06-22: - // "세로도 그려야지" = 가로 마름모만 만들고 세로 마루를 떠 있게 두면 안 됨). - // cascade 경로는 마루 절삭/힙 재-raycast 를 자체 처리했으므로 resolveRidgeEndpoints 를 건너뛴다(이중 절삭 방지). - const ridgeOps = diamond ? (diamond.cascade ? diamond.ops : [...diamond.ops, ...resolveRidgeEndpoints()]) : resolveRidgeEndpoints() - // 5c) Y 반사 힙 — 새 힙이 *기존 마루(RIDGE)에 직접 부딪힌* 경우만(=raycast hit 가 RIDGE), - // 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사). - // ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16). - const reflectOps = [] - for (const res of [resA, resB]) { - if (diamond) break // 마름모를 만든 경우 힙을 인접 처마 힙 길이로 재설정했으므로 반사 불필요 - if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue - const M = { x: res.hit.x, y: res.hit.y } - const rg = res.hitLine - const rlen = Math.hypot(rg.x2 - rg.x1, rg.y2 - rg.y1) || 1 - const rx = (rg.x2 - rg.x1) / rlen - const ry = (rg.y2 - rg.y1) / rlen - // 반사 방향 = reflect(역입사) 마루축 기준. w=역입사(코너→M 반대), v_ref=2(w·r̂)r̂ - w - const wx = -res.dir.x - const wy = -res.dir.y - const dot = wx * rx + wy * ry - const refDir = { x: 2 * dot * rx - wx, y: 2 * dot * ry - wy } - const drawn = drawHipRay(M, refDir) - if (drawn) { - reflectOps.push({ from: { x: Math.round(M.x), y: Math.round(M.y) }, to: { x: Math.round(drawn.hit.x), y: Math.round(drawn.hit.y) } }) - } - } - removeKerabHalfLabels(target.id) - hideOriginalLengthText(target.id, false) - canvas.renderAll() - runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap) - logger.log( - '[KERAB-VALLEY-EAVES-RULE1] applied ' + - JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, adjHipDbg, ridgeOps, reflectOps }), - ) - // [KERAB-LABEL-MAP 2026-06-22] 화면 라벨(H-n/RG-n/B-n…)↔좌표 맵 — QPolygon.__attachDebugLabels 와 - // *동일 분류·동일 카운터 순서*(canvas 오브젝트 순서)로 재현. 좌표 대신 라벨로 대화하기 위함(local 전용). - try { - const classify = (o) => { - const nm = o.name - const ln = o.lineName - const ty = o.attributes?.type - if (nm === 'baseLine') return 'B' - if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R' - if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H' - if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG' - if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V' - if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G' - if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK' - return null - } - const ctr = {} - const lmap = [] - for (const o of canvas.getObjects()) { - if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue - const pfx = classify(o) - if (!pfx) continue - ctr[pfx] = (ctr[pfx] || 0) + 1 - lmap.push({ - label: `${pfx}-${ctr[pfx]}`, - p1: { x: Math.round(o.x1), y: Math.round(o.y1) }, - p2: { x: Math.round(o.x2), y: Math.round(o.y2) }, - sz: o.attributes?.planeSize ?? null, - }) - } - logger.log('[KERAB-LABEL-MAP] ' + JSON.stringify(lmap)) - } catch (e) { - logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e)) - } - return - } - } - // [2240 KERAB-ATTR-ONLY 2026-05-19] ridge-at-mid 없음 → forward 가 attribute-only 였음 → 동일 대칭으로 속성만 토글 - target.set({ attributes }) - applyKerabAttributeOnlyPattern() - surgicalAfter() - logger.log('[KERAB-ATTR-ONLY] applied (revert) ' + JSON.stringify({ c1, c2 })) - return - } - } - } - target.set({ attributes, }) @@ -3655,2006 +324,5 @@ export function useEavesGableEdit(id) { canvas?.renderAll() } - // [2240 KERAB-OFFSET-SURGICAL 2026-05-27] 본체는 `@/util/kerab-offset-surgical` 로 분리. - // 고객 회귀 확인을 위한 토글 — ENABLE_KERAB_OFFSET_SURGICAL false 면 즉시 false 반환. - const applyTargetOffsetSurgical = (target, newOffset, options) => { - if (!ENABLE_KERAB_OFFSET_SURGICAL) return false - return applyKerabOffsetSurgical(canvas, target, newOffset, options) - } - - - // [2240 KERAB-SINGLE-RIDGE 2026-05-19] 헬퍼 — 양 끝점 hip 페어 식별 및 ridge 단일화 적용 - const KERAB_TOL = 0.5 - const isSamePt = (a, b) => Math.abs(a.x - b.x) < KERAB_TOL && Math.abs(a.y - b.y) < KERAB_TOL - - // outerLine 끝점(벽 좌표)을 가장 가까운 roof.points 코너(offset 적용 좌표)로 스냅 - const nearestRoofPoint = (roof, point) => { - if (!Array.isArray(roof.points) || roof.points.length === 0) return null - let best = roof.points[0] - let bestD = (best.x - point.x) ** 2 + (best.y - point.y) ** 2 - for (let i = 1; i < roof.points.length; i++) { - const p = roof.points[i] - const d = (p.x - point.x) ** 2 + (p.y - point.y) ** 2 - if (d < bestD) { - bestD = d - best = p - } - } - return { x: best.x, y: best.y } - } - - const findHipsAtPoint = (roof, point) => { - return roof.innerLines.filter((il) => { - if (!il || il.name !== LINE_TYPE.SUBLINE.HIP) return false - return isSamePt({ x: il.x1, y: il.y1 }, point) || isSamePt({ x: il.x2, y: il.y2 }, point) - }) - } - - const otherEndOfHip = (hip, corner) => { - const a = { x: hip.x1, y: hip.y1 } - const b = { x: hip.x2, y: hip.y2 } - return isSamePt(a, corner) ? b : a - } - - const findHipPairWithSharedApex = (hipsAtP1, p1, hipsAtP2, p2) => { - for (const hA of hipsAtP1) { - const apexA = otherEndOfHip(hA, p1) - for (const hB of hipsAtP2) { - if (hB === hA) continue - const apexB = otherEndOfHip(hB, p2) - if (isSamePt(apexA, apexB)) return { hA, hB, apex: apexA } - } - } - return null - } - - // [2240 KERAB-PARALLEL-HIPS 2026-05-19] 양 끝점 hip 페어가 평행한지 검사. - // 두 hip 방향벡터의 정규화 cross product 절대값 < 0.01 이면 평행 → 무한확장해도 교점 없음 → apex 없음. - // 이 케이스는 hip 만 제거하고 중앙선(kLine) 은 생성하지 않는다. - const findParallelHipPair = (hipsAtP1, p1, hipsAtP2, p2) => { - for (const hA of hipsAtP1) { - const oA = otherEndOfHip(hA, p1) - const dAx = oA.x - p1.x - const dAy = oA.y - p1.y - const magA = Math.hypot(dAx, dAy) || 1 - for (const hB of hipsAtP2) { - if (hB === hA) continue - const oB = otherEndOfHip(hB, p2) - const dBx = oB.x - p2.x - const dBy = oB.y - p2.y - const magB = Math.hypot(dBx, dBy) || 1 - const cross = (dAx * dBy - dAy * dBx) / (magA * magB) - if (Math.abs(cross) < 0.01) return { hA, hB } - } - } - return null - } - - // [2240 KERAB-EXTENDED-APEX 2026-05-20] c1·c2 의 outer hip(H1, H2) 을 무한 확장한 교점을 apex 로 채택. - // 사용자 모델: H1, H2 가 (연장하면) 만나는 점이 apex. 처마 중점 수선 위에 있지 않아도 채택. - // (dotEaves 제약 제거 — 2026-05-20) - // 채택 조건: ① 두 hip 이 평행이 아님 ② 교점이 두 hip 끝점 너머(t,s>1) 에 위치 - const findHipPairWithExtendedApex = (hipsAtP1, p1, hipsAtP2, p2) => { - for (const hA of hipsAtP1) { - const oA = otherEndOfHip(hA, p1) - // hip 자체를 무한 직선으로 본 교점 계산. 직선 파라미터: p1 + t*(oA - p1). t>1 이면 oA 너머(폴리곤 내부 방향). - const dAx = oA.x - p1.x - const dAy = oA.y - p1.y - for (const hB of hipsAtP2) { - if (hB === hA) continue - const oB = otherEndOfHip(hB, p2) - const dBx = oB.x - p2.x - const dBy = oB.y - p2.y - const denom = dAx * dBy - dAy * dBx - if (Math.abs(denom) < 1e-6) continue // 평행 → 영원히 못 만남 - const px = p2.x - p1.x - const py = p2.y - p1.y - const t = (px * dBy - py * dBx) / denom - const s = (px * dAy - py * dAx) / denom - // [KERAB-EXTENDED-APEX-TS 2026-05-20] apex 가 hip 의 진행방향(양수 쪽) 에 있을 것만 요구. - // t,s > 1 (양쪽 모두 끝점 너머) 까지 강제하지 않음 — 한 쪽 hip 가 충분히 길어 apex 가 - // 자기 segment 안쪽에 있는 케이스도 「H1, H2 가 만났다」로 인정 (사용자 모델, 2026-05-20). - if (t <= 1e-3 || s <= 1e-3) continue - const apex = { x: p1.x + t * dAx, y: p1.y + t * dAy } - return { hA, hB, apex } - } - } - return null - } - - // [2240 KERAB-JUNCTION-EXTENDED 2026-05-19] H-7/H-1 의 junction 너머 inner hip(H-4/H-2) 을 무한직선으로 - // 확장한 교점이 처마 중점 수선 위에 있으면 apex 채택. H-7/H-1 직접 확장이 가운데가 아닌 - // L-노치 등 복합 형상에서 사용. apex 검증 후 outer hip 제거 + inner hip 확장 + ridge. - const findHipPairViaJunction = (roof, c1, c2) => { - const hipsAtC1 = findHipsAtPoint(roof, c1) - const hipsAtC2 = findHipsAtPoint(roof, c2) - logger.log('[JUNCTION-DIAG] c1/c2 hips', { c1, c2, c1n: hipsAtC1.length, c2n: hipsAtC2.length }) - if (hipsAtC1.length === 0 || hipsAtC2.length === 0) return null - const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } - const eVx = c2.x - c1.x - const eVy = c2.y - c1.y - const eMag = Math.hypot(eVx, eVy) || 1 - for (const outer1 of hipsAtC1) { - const junction1 = otherEndOfHip(outer1, c1) - const innersAtJ1 = findHipsAtPoint(roof, junction1).filter((h) => h !== outer1) - logger.log('[JUNCTION-DIAG] j1', { junction1, innersAtJ1n: innersAtJ1.length }) - if (innersAtJ1.length === 0) continue - for (const outer2 of hipsAtC2) { - if (outer2 === outer1) continue - const junction2 = otherEndOfHip(outer2, c2) - if (isSamePt(junction1, junction2)) { - logger.log('[JUNCTION-DIAG] junction shared → skip (shared-apex case)') - continue - } - const innersAtJ2 = findHipsAtPoint(roof, junction2).filter((h) => h !== outer2) - logger.log('[JUNCTION-DIAG] j2', { junction2, innersAtJ2n: innersAtJ2.length }) - if (innersAtJ2.length === 0) continue - for (const inner1 of innersAtJ1) { - const innerFar1 = otherEndOfHip(inner1, junction1) - const dAx = innerFar1.x - junction1.x - const dAy = innerFar1.y - junction1.y - for (const inner2 of innersAtJ2) { - if (inner2 === inner1) continue - const innerFar2 = otherEndOfHip(inner2, junction2) - const dBx = innerFar2.x - junction2.x - const dBy = innerFar2.y - junction2.y - const denom = dAx * dBy - dAy * dBx - if (Math.abs(denom) < 1e-6) { - logger.log('[JUNCTION-DIAG] parallel inner hips') - continue - } - const px = junction2.x - junction1.x - const py = junction2.y - junction1.y - const t = (px * dBy - py * dBx) / denom - const apex = { x: junction1.x + t * dAx, y: junction1.y + t * dAy } - const dotEaves = ((apex.x - mid.x) * eVx + (apex.y - mid.y) * eVy) / eMag - logger.log('[JUNCTION-DIAG] candidate', { apex, dotEaves, threshold: KERAB_TOL }) - if (Math.abs(dotEaves) > KERAB_TOL) continue - logger.log('[JUNCTION-DIAG] MATCH') - return { outer1, outer2, inner1, inner2, junction1, junction2, apex, innerFar1, innerFar2 } - } - } - } - } - return null - } - - // 재사용 가능한 검증 헬퍼 — ridge 의 한 끝점이 주어진 line 의 중점과 만나는지 - const midpointOfLine = (line) => ({ x: (line.x1 + line.x2) / 2, y: (line.y1 + line.y2) / 2 }) - const ridgeMeetsMidpointOf = (ridge, line) => { - const mid = midpointOfLine(line) - return isSamePt({ x: ridge.x1, y: ridge.y1 }, mid) || isSamePt({ x: ridge.x2, y: ridge.y2 }, mid) - } - - // [2240 KERAB-CENTRAL-RIDGE 2026-05-19] 특정 좌표 점에서 끝나는 ridge 1개 찾기. - // 케라바 패턴 ridge(kerabPatternRidge) 는 제외. - const findRidgeEndingAt = (roof, point) => { - for (const il of roof.innerLines) { - if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - if (il.lineName === 'kerabPatternRidge') continue - if (isSamePt({ x: il.x1, y: il.y1 }, point)) return il - if (isSamePt({ x: il.x2, y: il.y2 }, point)) return il - } - return null - } - - // [2240 KERAB-CENTRAL-RIDGE 2026-05-19] 점이 선분 위에 있는지 (tol 안 거리). - const isPointOnSegment = (p, seg, tol = KERAB_TOL) => { - const dx = seg.x2 - seg.x1 - const dy = seg.y2 - seg.y1 - const len2 = dx * dx + dy * dy - if (len2 < 1e-9) return Math.hypot(p.x - seg.x1, p.y - seg.y1) <= tol - let t = ((p.x - seg.x1) * dx + (p.y - seg.y1) * dy) / len2 - t = Math.max(0, Math.min(1, t)) - const px = seg.x1 + t * dx - const py = seg.y1 + t * dy - return Math.hypot(p.x - px, p.y - py) <= tol - } - - // [2240 KERAB-SIMPLE 2026-05-20] target 끝점에서 가장 가까운 끝점을 가진 hip 검색. - // 처마 끝점(벽 좌표) vs hip 끝점(offset 좌표) 의 좌표계 차이(최대 ~50 단위) 는 거리 비교로 흡수. - // nearestRoofPoint 안 거침 — 처마가 짧을 때(<500mm) 인접 코너로 잘못 스냅되는 문제 회피. - // 동일 위치 겹치는 hip 도 좌표가 미세하게 달라 거리 최소값으로 구분 가능. - // [KERAB-SIMPLE 2026-05-20] target 끝점 pt 에 대응되는 hip 의 "near" 끝점은 - // 코너의 대각선 offset 위치 (dx, dy 둘 다 0 이 아님). 축정렬(dx=0 또는 dy=0) 후보는 - // 인접 L-노치 코너의 offset 일 가능성이 높아 제외. 대각 후보 없으면 폴백으로 최근접. - const AXIS_EPS = 1.0 - const findHipAtEndpoint = (roof, pt) => { - let bestDiag = null - let bestDiagD = Infinity - let bestAny = null - let bestAnyD = Infinity - for (const il of roof.innerLines || []) { - if (!il || il.attributes?.type !== LINE_TYPE.SUBLINE.HIP) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - const dA = Math.hypot(a.x - pt.x, a.y - pt.y) - const dB = Math.hypot(b.x - pt.x, b.y - pt.y) - const aDiag = Math.abs(a.x - pt.x) > AXIS_EPS && Math.abs(a.y - pt.y) > AXIS_EPS - const bDiag = Math.abs(b.x - pt.x) > AXIS_EPS && Math.abs(b.y - pt.y) > AXIS_EPS - if (dA < bestAnyD) { bestAnyD = dA; bestAny = { hip: il, near: a, far: b, dist: dA } } - if (dB < bestAnyD) { bestAnyD = dB; bestAny = { hip: il, near: b, far: a, dist: dB } } - if (aDiag && dA < bestDiagD) { bestDiagD = dA; bestDiag = { hip: il, near: a, far: b, dist: dA } } - if (bDiag && dB < bestDiagD) { bestDiagD = dB; bestDiag = { hip: il, near: b, far: a, dist: dB } } - } - return bestDiag || bestAny - } - - // 두 직선(p1-p2, p3-p4)의 무한확장 교점. 평행이면 null. - const lineLineIntersection = (p1, p2, p3, p4) => { - const d = (p1.x - p2.x) * (p3.y - p4.y) - (p1.y - p2.y) * (p3.x - p4.x) - if (Math.abs(d) < 1e-6) return null - const t = ((p1.x - p3.x) * (p3.y - p4.y) - (p1.y - p3.y) * (p3.x - p4.x)) / d - return { x: p1.x + t * (p2.x - p1.x), y: p1.y + t * (p2.y - p1.y) } - } - - // [KERAB-SIMPLE-OTHERHIP 2026-05-20] pt 위치에서 excludeHip 을 제외한 또 다른 hip 검색. - // RG-1 너머에서 같은 분기점(h*.far) 에 붙어있는 hip — 확장 방향을 정의함. - const findOtherHipAtPoint = (roof, pt, excludeHip, tol = 1.0) => { - let best = null - let bestD = Infinity - for (const il of roof.innerLines || []) { - if (!il || il === excludeHip) continue - if (il.attributes?.type !== LINE_TYPE.SUBLINE.HIP) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - const dA = Math.hypot(a.x - pt.x, a.y - pt.y) - const dB = Math.hypot(b.x - pt.x, b.y - pt.y) - if (dA <= tol && dA < bestD) { - bestD = dA - best = { hip: il, near: a, far: b } - } - if (dB <= tol && dB < bestD) { - bestD = dB - best = { hip: il, near: b, far: a } - } - } - return best - } - - // [KERAB-EXTENDER 2026-05-21] 사용자 전제 3: pt(접점) 에서 polygon 경계 라인을 제외한 - // inner line(hip 또는 ridge) 을 extender 로 검색. extender 의 방향(near→far)이 확장 방향. - // 반환: { line, near, far, isHip } 또는 null. - const findExtenderAtPoint = (roof, pt, excludedLines = [], tol = 1.0) => { - const excludeSet = new Set(excludedLines) - let best = null - let bestD = Infinity - for (const il of roof.innerLines || []) { - if (!il || excludeSet.has(il)) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - const dA = Math.hypot(a.x - pt.x, a.y - pt.y) - const dB = Math.hypot(b.x - pt.x, b.y - pt.y) - const isHip = il.name === LINE_TYPE.SUBLINE.HIP - if (dA <= tol && dA < bestD) { - bestD = dA - best = { line: il, near: a, far: b, isHip } - } - if (dB <= tol && dB < bestD) { - bestD = dB - best = { line: il, near: b, far: a, isHip } - } - } - return best - } - - // [KERAB-SIMPLE-MIDRIDGE 2026-05-20] 두 hip 의 far 끝점 p1·p2 를 잇는 ridge 검색. - // 확장 apex 케이스에서 hip 사이를 잇던 RG-1 같은 마루를 제거 대상으로 식별. - const findRidgeBetweenHipFars = (roof, p1, p2, tol = KERAB_TOL) => { - for (const il of roof.innerLines || []) { - if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - const aP1 = Math.hypot(a.x - p1.x, a.y - p1.y) <= tol - const aP2 = Math.hypot(a.x - p2.x, a.y - p2.y) <= tol - const bP1 = Math.hypot(b.x - p1.x, b.y - p1.y) <= tol - const bP2 = Math.hypot(b.x - p2.x, b.y - p2.y) <= tol - if ((aP1 && bP2) || (aP2 && bP1)) return il - } - return null - } - - // [KERAB-POLYGON-BFS 2026-05-21] 사용자 전제 2: 선택한 외곽선이 속한 내부 다각형의 - // inner line 경계를 BFS 로 추적. p1=h1.far → p2=h2.far 사이의 hip/ridge 체인을 모두 반환. - // excludeLines=[h1, h2] 로 시작점 hip 자체를 통과하지 않게 하고, - // 중간 junction 이 있는 비대칭 케이스(Ridge→junction→otherHip 등)도 한 번에 식별. - // 반환: [{ line, from, to }, ...] 또는 path 가 없으면 null. - const traceInnerPolygonPath = (roof, p1, p2, excludeLines = [], tol = KERAB_TOL) => { - const same = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) <= tol - if (same(p1, p2)) return [] - const excludeSet = new Set(excludeLines) - const ptKey = (p) => `${Math.round(p.x * 10) / 10}_${Math.round(p.y * 10) / 10}` - const visited = new Set([ptKey(p1)]) - const queue = [{ point: p1, path: [] }] - while (queue.length > 0) { - const { point, path } = queue.shift() - for (const il of roof.innerLines || []) { - if (!il || excludeSet.has(il)) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - let nextPoint = null - if (same(a, point)) nextPoint = b - else if (same(b, point)) nextPoint = a - if (!nextPoint) continue - if (same(nextPoint, p2)) { - return [...path, { line: il, from: point, to: nextPoint }] - } - const key = ptKey(nextPoint) - if (visited.has(key)) continue - visited.add(key) - queue.push({ point: nextPoint, path: [...path, { line: il, from: point, to: nextPoint }] }) - } - } - return null - } - - // apex 좌표를 끝점 또는 segment 위로 통과하는 ridge 검색 (RG-1 검증). tol 은 좌표 drift 흡수용. - const findRidgePassingThrough = (roof, pt, tol = KERAB_TOL) => { - for (const il of roof.innerLines || []) { - if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - if (Math.hypot(a.x - pt.x, a.y - pt.y) <= tol) return il - if (Math.hypot(b.x - pt.x, b.y - pt.y) <= tol) return il - if (isPointOnSegment(pt, { x1: a.x, y1: a.y, x2: b.x, y2: b.y }, tol)) return il - } - return null - } - - // [2240 KERAB-CENTRAL-RIDGE 2026-05-19] ridge 의 양 끝점이 ext1·ext2 각각의 segment 위에 있는지. - // RG-1 은 H-4/H-2 확장선 사이에 놓인 ridge — 두 확장선이 RG-1 의 양 끝점을 각각 통과한다. - const ridgeEndpointsOnBothExtensions = (ridge, ext1, ext2) => { - const a = { x: ridge.x1, y: ridge.y1 } - const b = { x: ridge.x2, y: ridge.y2 } - const seg1 = { x1: ext1.x1, y1: ext1.y1, x2: ext1.x2, y2: ext1.y2 } - const seg2 = { x1: ext2.x1, y1: ext2.y1, x2: ext2.x2, y2: ext2.y2 } - const aOn1 = isPointOnSegment(a, seg1) - const aOn2 = isPointOnSegment(a, seg2) - const bOn1 = isPointOnSegment(b, seg1) - const bOn2 = isPointOnSegment(b, seg2) - return (aOn1 && bOn2) || (aOn2 && bOn1) - } - - // 케라바 중점→apex ridge 1개 찾기 (역방향 패턴의 전제) - const findRidgeAtMidpoint = (roof, c1, c2) => { - const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } - for (const il of roof.innerLines) { - if (!il || il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - const a = { x: il.x1, y: il.y1 } - const b = { x: il.x2, y: il.y2 } - if (isSamePt(a, mid)) return { ridge: il, apex: b } - if (isSamePt(b, mid)) return { ridge: il, apex: a } - } - return null - } - - // 케라바→처마 역변환 패턴: ridge 1개 제거 + hip 복원 - // - junctionExtended: inner hip 좌표 원복 + outer hip 2개 신규 복원 (snapshot 기반) - // - 일반 single-ridge (__originalHips): outer hip 2개를 스냅샷 좌표 그대로 복원 - // - 둘 다 없으면 폴백: c1↔apex, c2↔apex 신규 hip (이전 패턴 호환) - const applyKerabRevertPattern = (roof, target, c1, c2, ridgeAtMid) => { - const apex = ridgeAtMid.apex - // [KERAB-VALLEY-EXT 2026-05-27] forward 에서 추가됐던 처마확장(kerabPatternValleyExt) 제거. - // __targetId === target.id 매칭으로 해당 target 의 처마확장만 정확히 정리. 모든 패턴 분기 공통. - // roofBase 확장은 roof.innerLines 에 있고 wallBase 확장은 canvas 에만 있으므로 canvas 전체 스캔. - // [KERAB-VALLEY-OVERLAP 2026-05-28] wallBase 측은 lineName='kerabValleyOverlapLine' (본체 + 90도 보조선 2개). - // b polygon 의 lines[] 에는 apply() 단계에서 들어가므로, 여기서는 canvas 객체와 roof.innerLines 만 정리. - // b 의 lines[] 에서 제거하는 책임은 apply() 다음 사이클에서 자동 (canvas 에서 사라지므로). - const valleyExtsToRemove = (canvas.getObjects() || []).filter( - (il) => il && (il.lineName === 'kerabPatternValleyExt' || il.lineName === 'kerabValleyOverlapLine') && il.__targetId === target.id, - ) - for (const v of valleyExtsToRemove) { - removeLine(v) - roof.innerLines = roof.innerLines.filter((il) => il !== v) - } - if (valleyExtsToRemove.length) { - logger.log('[KERAB-VALLEY-EXT] revert removed ' + valleyExtsToRemove.length) - } - // [KERAB-VALLEY-EXT-TRIM 2026-05-27] forward 에서 절삭/도미노로 단축한 hip/ridge 끝점을 옛 좌표로 복원. - // trimRecords push 순서: 원본 trim → 도미노 cascade. 복원은 역순. - if (Array.isArray(target.__valleyExtTrims) && target.__valleyExtTrims.length) { - for (let i = target.__valleyExtTrims.length - 1; i >= 0; i--) { - const rec = target.__valleyExtTrims[i] - const il = rec.line - if (!il) continue - // [VALLEY-EXT-TRIM-ENDPOINT-GUARD 2026-06-10] split 전용 레코드는 좌표/visible 변경이 - // 없으므로 복원할 게 없다. split 세그먼트는 valleyExt(__targetId) 제거에서 정리됨. - if (rec.splitOnly) continue - if (rec.hide) { - il.visible = rec.originalVisible !== false - } else { - if (rec.end === 1) il.set({ x1: rec.oldPt.x, y1: rec.oldPt.y }) - else il.set({ x2: rec.oldPt.x, y2: rec.oldPt.y }) - } - if (rec.originalAttrs) il.attributes = rec.originalAttrs - if (typeof il.setCoords === 'function') il.setCoords() - } - logger.log('[KERAB-VALLEY-EXT-TRIM] revert restored ' + target.__valleyExtTrims.length + ' trim(s)') - delete target.__valleyExtTrims - } - // [KERAB-REVERT-MARK 2026-06-05] 룰 (b): hip 본체는 wallLine 끝점을 지나고 outer 끝점은 새 roofLine 코너까지 확장. - // buildHipFromSnapshot 시점에는 roof.points 가 아직 옛 출폭 → corner 좌표를 미리 정할 수 없음. - // 여기서는 outer 끝점이 어느 쪽(which=1/2, side=A/B)인지만 마크하고, 실제 좌표 snap 은 surgicalAfter() 안에서 - // roof.points 가 새 출폭으로 갱신된 뒤 nearestRoofPoint 로 다시 잡는다. - // Corner-side 식별: hip 의 두 끝점 중 wallLine 양 끝점(wA/wB)에 더 가까운 쪽이 corner-side. - // apex-side 는 roof 안쪽 깊이 있어 두 wallLine 끝점에서 모두 멀다. - const markRevertOuter = (line) => { - if (!line) return - const wA = { x: target.x1, y: target.y1 } - const wB = { x: target.x2, y: target.y2 } - const e1 = { x: line.x1, y: line.y1 } - const e2 = { x: line.x2, y: line.y2 } - const dE1A = Math.hypot(e1.x - wA.x, e1.y - wA.y) - const dE1B = Math.hypot(e1.x - wB.x, e1.y - wB.y) - const dE2A = Math.hypot(e2.x - wA.x, e2.y - wA.y) - const dE2B = Math.hypot(e2.x - wB.x, e2.y - wB.y) - const minE1 = Math.min(dE1A, dE1B) - const minE2 = Math.min(dE2A, dE2B) - const which = minE1 <= minE2 ? 1 : 2 - const side = which === 1 ? (dE1A <= dE1B ? 'A' : 'B') : dE2A <= dE2B ? 'A' : 'B' - line.__kerabRevertOuterWhich = which - line.__kerabRevertOuterSide = side - logger.log( - '[KERAB-REVERT-MARK] ' + - JSON.stringify({ lineName: line.lineName, which, side, minE1, minE2 }), - ) - } - const buildHipFromSnapshot = (snap) => { - const pts = [snap.x1, snap.y1, snap.x2, snap.y2] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - const hip = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 4, - name: LINE_TYPE.SUBLINE.HIP, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes }, - }) - if (snap.lineName) hip.lineName = snap.lineName - if (snap.__extended) hip.__extended = snap.__extended - markRevertOuter(hip) - return hip - } - const buildHipToApex = (cornerPt) => { - const pts = [cornerPt.x, cornerPt.y, apex.x, apex.y] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - const hip = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.HIP, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, - }) - hip.lineName = 'kerabPatternHip' - return hip - } - const restoreInnerHipFromSnapshot = (hip, snap) => { - hip.set({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 }) - const ns = calcLinePlaneSize({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 }) - hip.attributes = { ...hip.attributes, ...snap.attributes, planeSize: ns, actualSize: ns } - } - - // [2240 KERAB-KLINE-ONLY 2026-05-20] kLine 만 그렸던 패턴: ridge 1개 제거 + 제거됐던 hip 복원. - // forward 가 ridge add + hip 2개 remove 했으므로 revert 도 동일하게 되돌림. - if (ridgeAtMid.ridge.__patternKind === 'kLineOnly') { - const removedHips = Array.isArray(ridgeAtMid.ridge.__removedHipsSnapshot) ? ridgeAtMid.ridge.__removedHipsSnapshot : [] - const removedRidges = Array.isArray(ridgeAtMid.ridge.__removedRidgesSnapshot) ? ridgeAtMid.ridge.__removedRidgesSnapshot : [] - const extHipsCreated = Array.isArray(ridgeAtMid.ridge.__extHipsCreated) ? ridgeAtMid.ridge.__extHipsCreated : [] - // [KERAB-SIMPLE-EXTHIP 2026-05-20] forward 에서 추가됐던 ext hip 제거. - for (const eh of extHipsCreated) { - if (!eh) continue - removeLine(eh) - roof.innerLines = roof.innerLines.filter((il) => il !== eh) - } - // [KERAB-EXTHIP-COORD-CLEAN 2026-06-15] __extHipsCreated 는 객체 참조라, 양쪽 박공→한 변씩 처마 - // 복원 시퀀스에서 중간 인접변 작업이 ext-hip 을 파괴·재생성하면 죽은 객체를 가리켜 위 루프가 - // no-op 이 된다 → 재생성된 ext-hip 이 orphan 으로 잔류(apex 에서 끝나는 1881 V, "라인은 roofLine - // 까지" 규칙 위반). 이 kLine 의 apex(=roofLine 반대쪽 안쪽 끝점) 를 공유하는 ext 라인을 좌표로 - // 한 번 더 제거해 재생성돼도 잡는다. orphan 없으면 0개 제거 → 정상 케이스 무영향. - { - const _r = ridgeAtMid.ridge - const _tMid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 } - const _apexPt = - Math.hypot(_r.x1 - _tMid.x, _r.y1 - _tMid.y) >= Math.hypot(_r.x2 - _tMid.x, _r.y2 - _tMid.y) - ? { x: _r.x1, y: _r.y1 } - : { x: _r.x2, y: _r.y2 } - const _EXT_TOL = 1.0 - const _orphans = roof.innerLines.filter( - (il) => - il && - (il.lineName === 'kerabPatternExtHip' || il.lineName === 'kerabPatternExtRidge') && - (Math.hypot(il.x1 - _apexPt.x, il.y1 - _apexPt.y) < _EXT_TOL || Math.hypot(il.x2 - _apexPt.x, il.y2 - _apexPt.y) < _EXT_TOL), - ) - for (const eh of _orphans) { - removeLine(eh) - roof.innerLines = roof.innerLines.filter((il) => il !== eh) - } - if (_orphans.length) logger.log('[KERAB-EXTHIP-COORD-CLEAN] removed ' + JSON.stringify({ apex: _apexPt, count: _orphans.length })) - } - removeLine(ridgeAtMid.ridge) - roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge) - for (const snap of removedHips) { - const sz = calcLinePlaneSize({ x1: snap.x1, y1: snap.y1, x2: snap.x2, y2: snap.y2 }) - const hip = new QLine([snap.x1, snap.y1, snap.x2, snap.y2], { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.HIP, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...(snap.attributes || {}) }, - }) - if (snap.lineName) hip.lineName = snap.lineName - if (snap.__extended) hip.__extended = snap.__extended - markRevertOuter(hip) - canvas.add(hip) - hip.bringToFront() - roof.innerLines.push(hip) - } - // [KERAB-SIMPLE-MIDRIDGE 2026-05-20] forward 시 제거됐던 hip-사이 ridge 복원. - for (const snap of removedRidges) { - if (!snap) continue - const pts = [snap.x1, snap.y1, snap.x2, snap.y2] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - const restored = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...(snap.attributes || {}) }, - }) - if (snap.lineName) restored.lineName = snap.lineName - canvas.add(restored) - restored.bringToFront() - roof.innerLines.push(restored) - } - removeKerabHalfLabels(target.id) - hideOriginalLengthText(target.id, false) - canvas.renderAll() - return true - } - - // junctionExtended: ext hip 2개 + ridge 1개 제거 + 제거됐던 RG-1 + outer/inner hip 4개 복원 - if (ridgeAtMid.ridge.__patternKind === 'junctionExtended') { - const extHips = Array.isArray(ridgeAtMid.ridge.__patternExtHips) ? ridgeAtMid.ridge.__patternExtHips : [] - extHips.forEach((h) => { - if (!h) return - removeLine(h) - roof.innerLines = roof.innerLines.filter((il) => il !== h) - }) - const removedRidgeSnaps = Array.isArray(ridgeAtMid.ridge.__removedRidgesSnapshot) ? ridgeAtMid.ridge.__removedRidgesSnapshot : [] - const removedHipSnaps = Array.isArray(ridgeAtMid.ridge.__removedHipsSnapshot) ? ridgeAtMid.ridge.__removedHipsSnapshot : [] - removeLine(ridgeAtMid.ridge) - roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge) - removedRidgeSnaps.forEach((snap) => { - if (!snap) return - const pts = [snap.x1, snap.y1, snap.x2, snap.y2] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - const restored = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes }, - }) - if (snap.lineName) restored.lineName = snap.lineName - canvas.add(restored) - restored.bringToFront() - roof.innerLines.push(restored) - }) - removedHipSnaps.forEach((snap) => { - if (!snap) return - const hip = buildHipFromSnapshot(snap) - canvas.add(hip) - hip.bringToFront() - roof.innerLines.push(hip) - }) - removeKerabHalfLabels(target.id) - hideOriginalLengthText(target.id, false) - canvas.renderAll() - return true - } - - // single-ridge / 이전 패턴: outer hip 2개 신규 생성 - const snaps = Array.isArray(ridgeAtMid.ridge.__originalHips) ? ridgeAtMid.ridge.__originalHips : null - const hip1 = snaps ? buildHipFromSnapshot(snaps[0]) : buildHipToApex(c1) - const hip2 = snaps ? buildHipFromSnapshot(snaps[1]) : buildHipToApex(c2) - removeLine(ridgeAtMid.ridge) - roof.innerLines = roof.innerLines.filter((il) => il !== ridgeAtMid.ridge) - canvas.add(hip1) - canvas.add(hip2) - hip1.bringToFront() - hip2.bringToFront() - roof.innerLines.push(hip1, hip2) - removeKerabHalfLabels(target.id) - hideOriginalLengthText(target.id, false) - canvas.renderAll() - return true - } - - // [KERAB-TYPE-EAVES 2026-06-11] TYPE(A/B 박공) 출신 케라바→처마 전용 변환. - // forward 토글 스냅샷이 없는 native 마루(RIDGE) 케이스. 마루확장라인을 hip 2개로 "펼친다". - // 규칙(사용자 확정, 방향 무관): - // 1) apex = 선택라인(wLine) 중점에서 마루 방향(inward) 으로 L/2. (45° 등거리 = 반으로 쪼갬) - // 2) 마루는 케라바변에 닿은 끝점을 apex 로 단축. - // 3) hip 2개(wA→apex, wB→apex) 생성 + outer 마크 → surgicalAfter 가 새 출폭 rLine 까지 45° ray-cast. - // markRevertOuter 는 applyKerabRevertPattern 내부 클로저라 여기선 which/side 를 직접 마크. - const isNativeTypeRidge = (ridge) => - !!ridge && - ridge.name === LINE_TYPE.SUBLINE.RIDGE && - !ridge.__patternKind && - !ridge.__originalHips && - !ridge.__removedHipsSnapshot && - ridge.lineName !== 'kerabPatternRidge' && - ridge.lineName !== 'kerabPatternExtRidge' - - const applyTypeGableToEavesPattern = (roof, target, ridge) => { - const wA = { x: target.x1, y: target.y1 } - const wB = { x: target.x2, y: target.y2 } - const L = Math.hypot(wB.x - wA.x, wB.y - wA.y) - if (L < 1) return null - const mid = { x: (wA.x + wB.x) / 2, y: (wA.y + wB.y) / 2 } - // 마루의 케라바변쪽 끝점 식별 (단축 대상). - const rA = { x: ridge.x1, y: ridge.y1 } - const rB = { x: ridge.x2, y: ridge.y2 } - const midIsA = Math.hypot(rA.x - mid.x, rA.y - mid.y) <= Math.hypot(rB.x - mid.x, rB.y - mid.y) - // [KERAB-TYPE-EAVES-INWARD 2026-06-12] inward 방향을 변의 "폴리곤 안쪽 법선"으로 도출. - // 기존: ridge stub 의 far-end - mid. 마루가 이미 짧아진 stub(2회차 리버트)이면 far-end 가 - // 처마변 위로 와서 방향이 바깥으로 뒤집힘 → apex 폴리곤 밖 폭주(R3-outside, roofLine 절삭 불변식 위반). - // 변⟂마루인 정상 切妻에서는 안쪽 법선 = 마루방향이라 45°/L/2 모델과 동치이고, stub 퇴화에 불변. - const cps = Array.isArray(roof.points) ? roof.points : [] - let cenX = 0 - let cenY = 0 - for (const p of cps) { - cenX += p.x - cenY += p.y - } - cenX /= cps.length || 1 - cenY /= cps.length || 1 - const ex = (wB.x - wA.x) / L - const ey = (wB.y - wA.y) / L - let ix = -ey - let iy = ex - if (ix * (cenX - mid.x) + iy * (cenY - mid.y) < 0) { - ix = -ix - iy = -iy - } - // [KERAB-TYPE-EAVES-APEX-CLAMP 2026-06-15] apex 깊이 = L/2 (45° 이등변). 단 변이 폴리곤 inward - // 두께보다 길면(가로로 납작한 폴리곤) mid+inward*(L/2) 가 반대편 roofLine 을 넘어 폭주 - // (R3-outside). "라인은 roofLine 까지" 규칙 → inward ray 가 반대편 변에 닿는 거리로 상한. - // 정상 切妻(세로로 긴 폴리곤)은 oppDist > L/2 라 min=L/2, 기존과 동치 → 무영향. - const oppRayDist = (P, dx, dy) => { - let best = Infinity - const M = cps.length - for (let i = 0; i < M; i++) { - const A = cps[i] - const B = cps[(i + 1) % M] - const ax = B.x - A.x - const ay = B.y - A.y - const den = dx * ay - dy * ax - if (Math.abs(den) < 1e-9) continue - const t = ((A.x - P.x) * ay - (A.y - P.y) * ax) / den - const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den - if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t - } - // [KERAB-VALLEY-BOX-RELATIVE-ROOFLINE 2026-06-23] 골짜기 박스(kerabValleyOverlapLine)는 인접 - // 두 sub-roof 의 *겹침(union)* 이라, 각 sub-roof 입장에서 박스 변이 곧 자기 roofLine 경계다 - // (상대 roofLine). 따라서 절대 roofLine 뿐 아니라 더 가까운 박스 변에서도 apex/힙이 멈춰야 - // 한다(사용자 확정 2026-06-23: "roofLine 이 상대적"). roofLine 변과 동급으로 ray 교차에 포함. - const boxSegs = (canvas.getObjects() || []).filter( - (o) => - o && - (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') && - (o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id), - ) - for (const o of boxSegs) { - const ax = o.x2 - o.x1 - const ay = o.y2 - o.y1 - const den = dx * ay - dy * ax - if (Math.abs(den) < 1e-9) continue - const t = ((o.x1 - P.x) * ay - (o.y1 - P.y) * ax) / den - const u = ((o.x1 - P.x) * dy - (o.y1 - P.y) * dx) / den - if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t - } - return best - } - const oppDist = oppRayDist(mid, ix, iy) - const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2) - const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth } - // [KERAB-TYPE-EAVES-BOX-CLIP 2026-06-23] 골짜기 박스(지붕의 겹침)는 인접 면 입장에서 자기 - // roofLine 경계 = "상대 roofLine"(사용자 확정 2026-06-23). apex 로 향하는 힙이 박스 경계를 - // 만나면 거기서 멈춰야 하고 뚫고 지나가면 안 된다. apex 자체는 45° 이등변 유지를 위해 보존하되, - // 각 힙을 corner→apex 로 쏴 박스 경계 교차점으로 절삭한다. - // 박스 경계 식별: 마루(ridge)의 박스측 고정 끝점 Bp(= apex 로 단축되지 않는 끝)을 공유하는 - // inner HIP/RIDGE 변(H-6/H-7 류). RG-2(415)가 이 Bp 에서 박스 안으로 올라가므로 Bp 가 곧 - // 박스 코너다. 일반 내부선(H-2 등, Bp 미공유)은 힙이 가로질러야 하므로 제외 → 정상 切妻 - // (Bp=roofLine 코너, 공유 inner 없음)은 무영향. ridge(단축 대상)·invisible 제외. - const boxCorner = midIsA ? rB : rA - const clipHipToBox = (corner) => { - const dx0 = apex.x - corner.x - const dy0 = apex.y - corner.y - const dlen = Math.hypot(dx0, dy0) - if (dlen < 1e-6) return { x: apex.x, y: apex.y } - const dx = dx0 / dlen - const dy = dy0 / dlen - let best = dlen - let bestPt = { x: apex.x, y: apex.y } - for (const il of roof.innerLines || []) { - if (!il || il === ridge || il.visible === false) continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - const sharesBp = - (Math.abs(il.x1 - boxCorner.x) < 0.5 && Math.abs(il.y1 - boxCorner.y) < 0.5) || - (Math.abs(il.x2 - boxCorner.x) < 0.5 && Math.abs(il.y2 - boxCorner.y) < 0.5) - // [KERAB-TYPE-EAVES-HIPMEET 2026-06-23] 신규 힙은 (1) 박스 변(Bp 공유) 또는 (2) 직전 변경에서 - // 생성된 기존 케라바 힙(kerabPatternHip)과의 최근접 교점에서 멈춘다 → ">" 모양. 둘 다 방향 무관 - // 후보로 같은 best(t 최소) 누산기에 경쟁시켜 가장 가까운 교점이 절삭점이 된다. 1차 변경 때는 - // 기존 힙이 없어 박스 변만 후보 → 동작 불변(무영향). - const isPriorHip = il.lineName === 'kerabPatternHip' - if (!sharesBp && !isPriorHip) continue - const ax = il.x2 - il.x1 - const ay = il.y2 - il.y1 - if (Math.hypot(ax, ay) < 1e-6) continue - const den = dx * ay - dy * ax - if (Math.abs(den) < 1e-9) continue - const t = ((il.x1 - corner.x) * ay - (il.y1 - corner.y) * ax) / den - const u = ((il.x1 - corner.x) * dy - (il.y1 - corner.y) * dx) / den - if (t > 1.0 && t < best - 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) { - best = t - bestPt = { x: corner.x + dx * t, y: corner.y + dy * t } - } - } - return bestPt - } - // 박스 절삭점 선계산 → 마루 처리 분기 판단에 사용. - const endA = clipHipToBox(wA) - const endB = clipHipToBox(wB) - const hipClipped = Math.hypot(endA.x - apex.x, endA.y - apex.y) > 1 || Math.hypot(endB.x - apex.x, endB.y - apex.y) > 1 - if (hipClipped) { - // [KERAB-TYPE-EAVES-RIDGE-VANISH 2026-06-23] 박스(겹침)가 상대 roofLine 이라 신규 힙이 박스/직전 힙에서 - // 멈추면, 가운데 마루(RG-1)는 양쪽 코너 힙이 이미 생성됐으므로 "삭제 대상"이다(사용자 규칙 2026-06-23: - // "RG-1 은 마루 = 삭제, 양쪽에 힙 생성됨"). visible=false 숨기기는 객체가 innerLines 에 남아 - // 디버그 라벨 재부착/LABEL-MAP 이 visible 무시하고 RG-1 을 다시 노출 → "삭제 안 됨" 으로 보였다. - // revert 스냅샷은 좌표 값만 복사하고 revert 경로가 라인을 재생성하므로 진짜 삭제해도 무해하다. - // → reconstruct(4820) 와 동일하게 canvas.remove + innerLines splice 로 실제 삭제한다. - canvas.remove(ridge) - const _ridx = roof.innerLines.indexOf(ridge) - if (_ridx >= 0) roof.innerLines.splice(_ridx, 1) - } else { - // 마루 단축: 케라바변쪽 끝점 → apex. - if (midIsA) ridge.set({ x1: apex.x, y1: apex.y }) - else ridge.set({ x2: apex.x, y2: apex.y }) - // [KERAB-TYPE-EAVES-RIDGE-ZERO 2026-06-15] apex 가 반대편 roofLine 에 클램프되면 native 마루가 - // 거기로 단축돼 zero-length 가 될 수 있다(R2 위반). 객체는 revert 위해 보존하되 invisible 처리 - // (R2 체크는 visible 만 검사). 마루 형상 재구성은 완전 우진각 시 reconstruct 가 담당. - const ridgeLen = Math.hypot(ridge.x2 - ridge.x1, ridge.y2 - ridge.y1) - if (ridgeLen < 1) ridge.visible = false - const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 }) - if (ridge.attributes) { - ridge.attributes.planeSize = rsz - ridge.attributes.actualSize = rsz - } - if (typeof ridge.setCoords === 'function') ridge.setCoords() - if (typeof ridge.setLength === 'function') ridge.setLength() - if (typeof ridge.addLengthText === 'function') ridge.addLengthText() - } - // hip 2개: wA→endA, wB→endB. pts=[corner, end] 라 corner 끝(x1,y1)이 outer. - const mkHip = (corner, side, end) => { - const pts = [corner.x, corner.y, end.x, end.y] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - const hip = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.HIP, - textMode: roof.textMode, - // [KERAB-TYPE-EAVES-ROUNDTRIP 2026-06-11] forward(처마→케라바) findHipAtEndpoint 는 - // attributes.type === HIP 로 매칭한다. type 누락 시 재변환에서 hip 미인식 → attr-only 폴백. - attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.HIP, planeSize: sz, actualSize: sz }, - }) - hip.lineName = 'kerabPatternHip' - hip.__kerabRevertOuterWhich = 1 - hip.__kerabRevertOuterSide = side - return hip - } - const hip1 = mkHip(wA, 'A', endA) - const hip2 = mkHip(wB, 'B', endB) - canvas.add(hip1) - canvas.add(hip2) - hip1.bringToFront() - hip2.bringToFront() - roof.innerLines.push(hip1, hip2) - removeKerabHalfLabels(target.id) - hideOriginalLengthText(target.id, false) - canvas.renderAll() - logger.log( - '[KERAB-TYPE-EAVES] applied ' + - JSON.stringify({ - L: Math.round(L), - mid, - apex: { x: Math.round(apex.x), y: Math.round(apex.y) }, - inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) }, - hipA: { x: Math.round(hip1.x2), y: Math.round(hip1.y2), clipped: Math.hypot(hip1.x2 - apex.x, hip1.y2 - apex.y) > 1 }, - hipB: { x: Math.round(hip2.x2), y: Math.round(hip2.y2), clipped: Math.hypot(hip2.x2 - apex.x, hip2.y2 - apex.y) > 1 }, - ridgeDeleted: hipClipped, - ridgeHidden: ridge.visible === false, - }), - ) - // [KERAB-LABEL-MAP 2026-06-23] 경로 A(일반 라인→처마) 진단 — 화면 라벨(H-n/RG-n/V-n…)↔좌표 맵. - // 골짜기 박스(kerabValleyOverlapLine)는 V 로 분류되어 V-n 으로 노출 → 힙이 박스를 통과/멈추는지 - // 라벨로 대조하기 위함(local 전용, 로깅만). - try { - const classify = (o) => { - const nm = o.name - const ln = o.lineName - if (nm === 'baseLine') return 'B' - if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R' - if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H' - if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG' - if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V' - if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G' - if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK' - return null - } - const ctr = {} - const lmap = [] - for (const o of canvas.getObjects()) { - if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue - const pfx = classify(o) - if (!pfx) continue - ctr[pfx] = (ctr[pfx] || 0) + 1 - lmap.push({ - label: `${pfx}-${ctr[pfx]}`, - p1: { x: Math.round(o.x1), y: Math.round(o.y1) }, - p2: { x: Math.round(o.x2), y: Math.round(o.y2) }, - ln: o.lineName || null, - sz: o.attributes?.planeSize ?? null, - }) - } - logger.log('[KERAB-LABEL-MAP] ' + JSON.stringify(lmap)) - } catch (e) { - logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e)) - } - // [KERAB-BOX-DUMP 2026-06-23] 진단 — canvas 전체의 골짜기 박스(kerabValleyOverlapLine) + 모든 ROOF - // 폴리곤 외곽 bbox 를 roofId 와 함께 덤프. 힙이 뚫은 "박스"가 현재 roof 소속인지, 다른 roof/겹침 - // 소속이라 oppRayDist 필터에서 빠졌는지 판별하기 위함(local 전용, 로깅만). - try { - const allBox = (canvas.getObjects() || []) - .filter((o) => o && (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine')) - .map((o) => ({ - p1: { x: Math.round(o.x1), y: Math.round(o.y1) }, - p2: { x: Math.round(o.x2), y: Math.round(o.y2) }, - parentId: o.parentId || null, - roofId: o.roofId || o.attributes?.roofId || null, - name: o.name || null, - })) - const allRoofs = (canvas.getObjects() || []) - .filter((o) => o && o.name === POLYGON_TYPE.ROOF) - .map((o) => { - const ps = o.points || [] - let x0 = Infinity - let x1 = -Infinity - let y0 = Infinity - let y1 = -Infinity - for (const p of ps) { - if (p.x < x0) x0 = p.x - if (p.x > x1) x1 = p.x - if (p.y < y0) y0 = p.y - if (p.y > y1) y1 = p.y - } - return { id: o.id, isFixed: !!o.isFixed, bbox: { x0: Math.round(x0), y0: Math.round(y0), x1: Math.round(x1), y1: Math.round(y1) }, n: ps.length } - }) - logger.log('[KERAB-BOX-DUMP] ' + JSON.stringify({ curRoof: roof.id, box: allBox, roofs: allRoofs })) - } catch (e) { - logger.warn('[KERAB-BOX-DUMP] failed ' + (e?.message || e)) - } - // [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] 마루(RG-1)를 삭제한 케이스. moveStaleEdgeInnerLines - // 의 relocate 가 이 플래그로 박스 세로선(H-8)의 과길게 뻗은 끝점을 박스 바닥(boxCorner=RG-1 의 - // 박스측 끝점, 예 (747,266))으로 내려 H-7↔H-2 사이만 막도록 만든다(사용자 규칙 2026-06-23: - // "H-8 은 힙이 아니라 박스를 막는 세로 roofLine, H-2 와 H-7 사이만"). - apex.ridgeHidden = hipClipped - apex.boxCorner = hipClipped ? boxCorner : null - return apex - } - - // [KERAB-TYPE-EAVES-EDGEMOVE 2026-06-11] TYPE 변환 후 옛 roofLine 변 위에 남은 normal inner line 이동. - // surgicalAfter(skipInnerLines:true) 는 폴리곤 변·내 hip 만 갱신하고 L자 면경계선은 안 옮긴다 → - // 옛 出幅 위치(ghost) 잔존. 변은 出幅만큼 평행이동했으므로 옛 변 위 끝점만 동일 delta 로 평행이동한다. - // (出幅 평행이동이라 변 위 모든 점의 변위가 동일 = recomputeNormalLine 의 NORMAL-ABS 와 동치.) - // 케라바패턴 라인은 ray-cast 가 이미 처리하므로 제외. oldC1/oldC2 = 변환 전 roofLine 코너. - // [KERAB-TYPE-EAVES-WEDGE 2026-06-11] apex 인자 추가. 처마 위상에서는 두 힙이 apex 에서 만나고 - // 힙 사이 쐐기에는 라인이 없어야 한다. 옛 변 중점(cMid)에 끝점이 닿아 있던 非평행 라인(=힙)은 - // 평행이동 시 쐐기를 가로지르므로, 그 끝점만 apex 로 수렴시킨다. 변과 평행한 라인(roofLine 절반)과 - // 코너 끝점은 종전처럼 出幅 delta 로 평행이동. - const moveStaleEdgeInnerLines = (roof, oldC1, oldC2, edgeIdx, apex) => { - if (edgeIdx < 0 || !Array.isArray(roof.points)) return null - const N = roof.points.length - const nP = roof.points[edgeIdx] - const nQ = roof.points[(edgeIdx + 1) % N] - // c1↔c2 ↔ nP↔nQ 방향 정합 (idx 끝과 가까운 쪽으로 매핑). - const c1ToP = Math.hypot(oldC1.x - nP.x, oldC1.y - nP.y) <= Math.hypot(oldC1.x - nQ.x, oldC1.y - nQ.y) - const newA = c1ToP ? nP : nQ - const newB = c1ToP ? nQ : nP - // 出幅 평행이동 delta (양 코너 동일, 안전하게 평균). - const dxE = (newA.x - oldC1.x + (newB.x - oldC2.x)) / 2 - const dyE = (newA.y - oldC1.y + (newB.y - oldC2.y)) / 2 - if (Math.hypot(dxE, dyE) < 0.01) return null - const segDx = oldC2.x - oldC1.x - const segDy = oldC2.y - oldC1.y - const segLen2 = segDx * segDx + segDy * segDy || 1 - const segLen = Math.sqrt(segLen2) - const onOldEdge = (px, py) => { - const t = ((px - oldC1.x) * segDx + (py - oldC1.y) * segDy) / segLen2 - if (t < -0.02 || t > 1.02) return false - const prx = oldC1.x + t * segDx - const pry = oldC1.y + t * segDy - return Math.hypot(px - prx, py - pry) < 0.5 - } - const oldMid = { x: (oldC1.x + oldC2.x) / 2, y: (oldC1.y + oldC2.y) / 2 } - const nearMid = (px, py) => Math.hypot(px - oldMid.x, py - oldMid.y) < 2.0 - // 라인 방향이 변과 평행이면 cross 가 0 → roofLine 절반(평행이동 대상). - const isCollinearWithEdge = (x1, y1, x2, y2) => { - const lx = x2 - x1 - const ly = y2 - y1 - const llen = Math.hypot(lx, ly) - if (llen < 0.01) return true - const cross = Math.abs(lx * segDy - ly * segDx) / (llen * segLen) - return cross < 0.05 - } - const isKerabPattern = (ln) => - ln === 'kerabPatternHip' || - ln === 'kerabPatternRidge' || - ln === 'kerabPatternExtHip' || - ln === 'kerabPatternExtRidge' || - ln === 'kerabPatternValleyExt' - let movedCnt = 0 - for (const il of roof.innerLines || []) { - if (!il || isKerabPattern(il.lineName)) continue - // [KERAB-TYPE-EAVES-EDGEMOVE-SKIPHIDDEN 2026-06-23] 숨긴 마루(hipClipped 시 visible=false)는 - // 옮기지 않는다. 옮기면 cMid→apex 수렴 후 addLengthText 가 박스 위에 "415" 라벨을 되살린다. - if (il.visible === false) continue - const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2) - // 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동. - const relocate = (px, py) => { - // [KERAB-TYPE-EAVES-EDGEMOVE-BOXCLOSE 2026-06-23] 마루(RG-1) 삭제 케이스(apex.ridgeHidden)는 - // 박스 세로선(H-8)이 옛 처마변까지 과길게 뻗어 있다(에러). 그 과길게 뻗은 끝점(cMid 측)을 - // apex 로 올리지도 평행이동하지도 않고, 박스 바닥(boxCorner=RG-1 박스측 끝점)으로 내려 - // H-7↔H-2 사이만 막는 짧은 세로선으로 만든다. - if (apex && apex.ridgeHidden && apex.boxCorner && !collinear && nearMid(px, py)) return { x: apex.boxCorner.x, y: apex.boxCorner.y } - if (apex && !collinear && nearMid(px, py)) return { x: apex.x, y: apex.y } - return { x: px + dxE, y: py + dyE } - } - let moved = false - if (onOldEdge(il.x1, il.y1)) { - const np1 = relocate(il.x1, il.y1) - il.set({ x1: np1.x, y1: np1.y }) - moved = true - } - if (onOldEdge(il.x2, il.y2)) { - const np2 = relocate(il.x2, il.y2) - il.set({ x2: np2.x, y2: np2.y }) - moved = true - } - if (moved) { - const ns = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - if (il.attributes) { - il.attributes.planeSize = ns - il.attributes.actualSize = ns - } - if (typeof il.setCoords === 'function') il.setCoords() - if (typeof il.setLength === 'function') il.setLength() - if (typeof il.addLengthText === 'function') il.addLengthText() - movedCnt++ - } - } - canvas.renderAll() - logger.log('[KERAB-TYPE-EAVES-EDGEMOVE] ' + JSON.stringify({ edgeIdx, delta: { dxE, dyE }, movedCnt })) - return { dxE, dyE } - } - - // [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] 힙은 단일 직선 45°. apex 에서 벽교점(wLine 코너) 방향(=45°) - // 그대로 roofLine 까지 직선 연장한다. 꺾지 않고, roofLine '코너/교점'을 찾아가지도 않는다 — ray 가 - // roofLine 변에 처음 닿는 지점이 끝점(出幅60 → 코너 10mm 못미쳐 변 위에서 끝남, 사용자 모델 그대로). - // apex 는 벽기준 고정값이라 出幅 무관(half-rule 폐기) — "出幅에 의해 힙·마루 변질" 차단. - // surgicalAfter/EXTEND-45 가 코너 스냅한 힙을 ray-cast 직선으로 덮어쓴다. - const extendTypeGableHipsStraightToRoofLine = (roof, target, apex) => { - if (!apex || !roof || !Array.isArray(roof.innerLines)) return - const pts = Array.isArray(roof.points) ? roof.points : [] - if (pts.length < 2) return - const wcs = [ - { x: target.x1, y: target.y1 }, - { x: target.x2, y: target.y2 }, - ] - // ray P+t*dir 가 선분 A→B 와 만나는 t(>0) 반환, 없으면 Infinity. - const rayHit = (P, dir, A, B) => { - const ex = B.x - A.x - const ey = B.y - A.y - const den = dir.x * ey - dir.y * ex - if (Math.abs(den) < 1e-9) return Infinity - const t = ((A.x - P.x) * ey - (A.y - P.y) * ex) / den - const u = ((A.x - P.x) * dir.y - (A.y - P.y) * dir.x) / den - if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) return t - return Infinity - } - const APEX_TOL = 2.0 - let extended = 0 - for (const il of [...roof.innerLines]) { - if (!il || il.lineName !== 'kerabPatternHip') continue - const d1 = Math.hypot(il.x1 - apex.x, il.y1 - apex.y) - const d2 = Math.hypot(il.x2 - apex.x, il.y2 - apex.y) - if (Math.min(d1, d2) > APEX_TOL) continue - const apexIsE1 = d1 <= d2 - const outerEnd = apexIsE1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } - // outerEnd 에 가까운 벽교점(wLine 끝점) 선택. - const wc = Math.hypot(outerEnd.x - wcs[0].x, outerEnd.y - wcs[0].y) <= Math.hypot(outerEnd.x - wcs[1].x, outerEnd.y - wcs[1].y) ? wcs[0] : wcs[1] - // 방향 = apex→wc (45°). 이 방향 그대로 roofLine 까지 ray. - const dx = wc.x - apex.x - const dy = wc.y - apex.y - const dlen = Math.hypot(dx, dy) || 1 - const dir = { x: dx / dlen, y: dy / dlen } - // wc 직전(살짝 안쪽)에서 ray 발사 → wc 통과 후 첫 roofLine 변 교차. - const P = { x: wc.x - dir.x * 0.05, y: wc.y - dir.y * 0.05 } - let best = Infinity - let hit = null - for (let i = 0; i < pts.length; i++) { - const A = pts[i] - const B = pts[(i + 1) % pts.length] - const t = rayHit(P, dir, A, B) - if (t < best) { - best = t - hit = { x: P.x + dir.x * t, y: P.y + dir.y * t } - } - } - if (!hit) continue - // 힙 = apex → hit (단일 직선 45°). - if (apexIsE1) il.set({ x1: apex.x, y1: apex.y, x2: hit.x, y2: hit.y }) - else il.set({ x1: hit.x, y1: hit.y, x2: apex.x, y2: apex.y }) - // [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] .set 은 'modified' 를 안 띄워 startPoint/endPoint - // 가 옛 좌표로 남는다. 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로 - // 동기화 누락 시 절삭된 힙이 옛 좌표로 연결돼 인접 roofLine 이 면에 포함되지 않아 미할당된다. - il.startPoint = { x: il.x1, y: il.y1 } - il.endPoint = { x: il.x2, y: il.y2 } - const sz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - if (il.attributes) { - il.attributes.planeSize = sz - il.attributes.actualSize = sz - il.attributes.extended = true - } - il.__extended = true - if (typeof il.setCoords === 'function') il.setCoords() - if (typeof il.setLength === 'function') il.setLength() - if (typeof il.addLengthText === 'function') il.addLengthText() - extended++ - } - canvas.renderAll() - logger.log('[KERAB-TYPE-EAVES-STRAIGHT] ' + JSON.stringify({ extended, apex })) - } - - // [KERAB-TYPE-EAVES-HIPROOF 2026-06-12] 양쪽 박공이 모두 처마로 바뀌어 완전 寄棟(우진각)이 되는 순간 호출. - // per-edge L/2 apex 모델은 마루를 짧은 축(세로)으로 만들어 두 힙쌍이 X자로 교차하는 틀린 형상을 남긴다. - // 힙·마루 규칙으로 재구성: R2(라인은 교점에서 멈춤)·R3(교점 초과분 절삭)·R1(중앙선=마루). - // 각 힙이 "가장 먼저 만나는" 다른 힙과의 교점이 진짜 마루 끝점(짧은변 공유쌍). 두 끝점을 잇는 선이 마루. - // apex 기반 세로 마루 스텁은 제거하고 절삭된 힙 4개 + 가로 마루 1개(표준 우진각)로 교체. - const reconstructHipRoofRidgeIfComplete = (roof) => { - if (!roof || !Array.isArray(roof.innerLines) || !Array.isArray(roof.points)) return false - // 완전 寄棟 = 외곽선에 박공(GABLE)이 하나도 안 남음. - const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id) - if (!outerLines.length) return false - if (outerLines.some((o) => o.attributes?.type === LINE_TYPE.WALLLINE.GABLE)) return false - // kerabPatternHip 정확히 4개일 때만 표준 우진각으로 간주. - const hips = roof.innerLines.filter((il) => il && il.lineName === 'kerabPatternHip' && il.visible !== false) - if (hips.length !== 4) return false - const pts = roof.points - let cenX = 0 - let cenY = 0 - for (const p of pts) { - cenX += p.x - cenY += p.y - } - cenX /= pts.length || 1 - cenY /= pts.length || 1 - // 각 힙의 outer(roofLine 코너쪽) / inner 끝점 + 방향(outer→inner, 45°). - const info = hips.map((il) => { - const d1 = Math.hypot(il.x1 - cenX, il.y1 - cenY) - const d2 = Math.hypot(il.x2 - cenX, il.y2 - cenY) - const outer = d1 >= d2 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } - const inner = d1 >= d2 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } - return { il, outer, dir: { x: inner.x - outer.x, y: inner.y - outer.y } } - }) - // 두 힙 직선 교점 (a.outer + t·a.dir). - const intersect = (a, b) => { - const den = a.dir.x * b.dir.y - a.dir.y * b.dir.x - if (Math.abs(den) < 1e-9) return null - const t = ((b.outer.x - a.outer.x) * b.dir.y - (b.outer.y - a.outer.y) * b.dir.x) / den - const u = ((b.outer.x - a.outer.x) * a.dir.y - (b.outer.y - a.outer.y) * a.dir.x) / den - if (t <= 1e-6 || u <= 1e-6) return null - return { x: a.outer.x + a.dir.x * t, y: a.outer.y + a.dir.y * t, t } - } - // 각 힙이 "가장 먼저(최소 t)" 만나는 힙 = 마루 끝점 파트너. - for (let i = 0; i < info.length; i++) { - let best = null - let bestJ = -1 - for (let j = 0; j < info.length; j++) { - if (i === j) continue - const ip = intersect(info[i], info[j]) - if (!ip) continue - if (!best || ip.t < best.t) { - best = ip - bestJ = j - } - } - info[i].stop = best - info[i].partner = bestJ - } - if (info.some((h) => !h.stop)) return false - // 마루 끝점 클러스터링 (2개 기대). - const ridgePts = [] - for (const h of info) { - const found = ridgePts.find((rp) => Math.hypot(rp.x - h.stop.x, rp.y - h.stop.y) < 1.0) - if (found) found.count++ - else ridgePts.push({ x: h.stop.x, y: h.stop.y, count: 1 }) - } - if (ridgePts.length !== 2 || ridgePts.some((rp) => rp.count !== 2)) { - logger.log('[KERAB-TYPE-EAVES-HIPROOF] skip ' + JSON.stringify({ ridgePts: ridgePts.length, counts: ridgePts.map((r) => r.count) })) - return false - } - // R2/R3: 각 힙을 outer → 마루 끝점으로 절삭. - for (const h of info) { - const rp = ridgePts.find((p) => Math.hypot(p.x - h.stop.x, p.y - h.stop.y) < 1.0) - const d1 = Math.hypot(h.il.x1 - cenX, h.il.y1 - cenY) - const d2 = Math.hypot(h.il.x2 - cenX, h.il.y2 - cenY) - if (d1 >= d2) h.il.set({ x2: rp.x, y2: rp.y }) - else h.il.set({ x1: rp.x, y1: rp.y }) - // [KERAB-TYPE-EAVES-STARTPOINT-SYNC 2026-06-12] 절삭 후 startPoint/endPoint 동기화. - // 누락 시 할당 graph 가 옛 inner 끝점으로 면을 닫으려다 실패 → 인접 roofLine 미할당. - h.il.startPoint = { x: h.il.x1, y: h.il.y1 } - h.il.endPoint = { x: h.il.x2, y: h.il.y2 } - const sz = calcLinePlaneSize({ x1: h.il.x1, y1: h.il.y1, x2: h.il.x2, y2: h.il.y2 }) - if (h.il.attributes) { - h.il.attributes.planeSize = sz - h.il.attributes.actualSize = sz - } - if (typeof h.il.setCoords === 'function') h.il.setCoords() - if (typeof h.il.setLength === 'function') h.il.setLength() - if (typeof h.il.addLengthText === 'function') h.il.addLengthText() - } - // 기존 마루(세로 apex 스텁 등) 제거 — 완전 우진각엔 가로 마루 1개만. - const staleRidges = roof.innerLines.filter((il) => il && il.name === LINE_TYPE.SUBLINE.RIDGE) - for (const r of staleRidges) { - canvas.remove(r) - const idx = roof.innerLines.indexOf(r) - if (idx >= 0) roof.innerLines.splice(idx, 1) - } - // R1: 두 마루 끝점을 잇는 가로 마루 신규 생성. - const rp1 = ridgePts[0] - const rp2 = ridgePts[1] - const rpts = [rp1.x, rp1.y, rp2.x, rp2.y] - const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] }) - const ridge = new QLine(rpts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: roof.textMode, - attributes: { roofId: roof.id, type: LINE_TYPE.SUBLINE.RIDGE, planeSize: rsz, actualSize: rsz }, - }) - ridge.lineName = 'ridge' - canvas.add(ridge) - ridge.bringToFront() - roof.innerLines.push(ridge) - if (typeof ridge.setCoords === 'function') ridge.setCoords() - if (typeof ridge.setLength === 'function') ridge.setLength() - if (typeof ridge.addLengthText === 'function') ridge.addLengthText() - canvas.renderAll() - logger.log( - '[KERAB-TYPE-EAVES-HIPROOF] reconstructed ' + - JSON.stringify({ - ridge: { x1: Math.round(rp1.x), y1: Math.round(rp1.y), x2: Math.round(rp2.x), y2: Math.round(rp2.y) }, - removedRidges: staleRidges.length, - }), - ) - return true - } - - // [KERAB-HALF-LABEL 2026-05-19] 케라바 외곽선의 좌우/상하 절반 길이 라벨 - // 그림 그릴 단계에서는 외곽선은 1개로 유지하고 라벨만 2개 노출. - // 할당 시 splitPolygonWithLines 가 자연 분할하므로 그때는 사라지고 분할 라인 라벨이 대체. - const KERAB_HALF_LABEL_NAME = 'kerabHalfLabel' - - const removeKerabHalfLabels = (parentId) => { - const labels = canvas - .getObjects() - .filter((obj) => obj.name === KERAB_HALF_LABEL_NAME && obj.parentId === parentId) - labels.forEach((obj) => canvas.remove(obj)) - } - - const addKerabHalfLabels = (target, c1, c2) => { - const mid = { x: (c1.x + c2.x) / 2, y: (c1.y + c2.y) / 2 } - const fullPlane = calcLinePlaneSize({ x1: c1.x, y1: c1.y, x2: c2.x, y2: c2.y }) - const halfPlane = Math.round(fullPlane / 2) - const text = String(halfPlane).replace(/\.0$/, '') - const mkPos = (a, b) => { - const cx = (a.x + b.x) / 2 - const cy = (a.y + b.y) / 2 - // 수평/수직 라인에 한해 라벨을 라인 옆으로 살짝 띄움 (QLine.addLengthText 와 동일한 룰) - if (target.direction === 'left' || target.direction === 'right') return { left: cx, top: cy + 10 } - if (target.direction === 'top' || target.direction === 'bottom') return { left: cx + 10, top: cy } - return { left: cx, top: cy } - } - const buildLabel = (pos) => - new fabric.Textbox(text, { - left: pos.left, - top: pos.top, - fontSize: target.fontSize, - parentId: target.id, - name: KERAB_HALF_LABEL_NAME, - editable: false, - selectable: true, - lockRotation: true, - lockScalingX: true, - lockScalingY: true, - planeSize: halfPlane, - actualSize: halfPlane, - }) - canvas.add(buildLabel(mkPos(c1, mid))) - canvas.add(buildLabel(mkPos(mid, c2))) - } - - const hideOriginalLengthText = (parentId, hide) => { - const lbl = canvas.getObjects().find((obj) => obj.name === 'lengthText' && obj.parentId === parentId) - if (lbl) lbl.set({ visible: !hide }) - } - - // [KERAB-RECT-SOLVER 2026-06-15] 사각형(axis-aligned 4코너) 결정론적 케라바 솔버. - // 토글 이력과 무관하게 「최종 4변 타입」만으로 내부선(힙/마루/케라바 줄기)을 매번 처음부터 - // 재생성한다 = 출폭/변타입의 순수 함수. forward/revert/type 미로를 통째로 우회. - // 구조적으로 보장되는 두 불변식: - // 불변식1(Y): 줄기(마루)는 박공으로 바뀐 변(slope 없음) 방향으로만 뻗는다. \|/, /|\ 불가. - // 불변식2: 힙은 apex(45° 교점)에서 멈춘다 → wallbase 교점 초과(폴리곤 밖) 힙 없음. - // 지원: 박공 0개(우진각)·1개(Y, apex 내부일 때)·2개 마주보기(맞배). 그 외(인접 2박공/3·4박공/ - // 비사각형/회전사각형/radio2 타입)는 false 반환 → 기존 경로 폴백. - const solveRectangleKerab = (roof, target, newAttributes) => { - try { - if (!roof || !Array.isArray(roof.points) || roof.points.length !== 4) return false - if (!Array.isArray(roof.innerLines)) roof.innerLines = [] - const outerLines = canvas.getObjects().filter((o) => o.name === 'outerLine' && o.attributes?.roofId === roof.id) - // [KERAB-RECT-PROBE 2026-06-15] outerLine 이 wallbase(출폭0) 좌표인지 roofLine 좌표인지 - // 실제 데이터로 확정하기 위한 진입 덤프. solver 가 좌표 불일치로 false 폴백해도 이건 먼저 찍힘. - logger.log( - '[KERAB-RECT-PROBE] entry ' + - JSON.stringify({ - roofPoints: roof.points.map((p) => ({ x: Math.round(p.x * 10) / 10, y: Math.round(p.y * 10) / 10 })), - outerLineCount: outerLines.length, - outerLines: outerLines.map((o) => ({ - type: o.attributes?.type, - offset: o.attributes?.offset, - x1: Math.round(o.x1 * 10) / 10, - y1: Math.round(o.y1 * 10) / 10, - x2: Math.round(o.x2 * 10) / 10, - y2: Math.round(o.y2 * 10) / 10, - })), - newType: newAttributes?.type, - newOffset: newAttributes?.offset, - }), - ) - if (outerLines.length !== 4) return false - const newType = newAttributes?.type - const TOL = 0.6 - const xs = roof.points.map((p) => p.x) - const ys = roof.points.map((p) => p.y) - let minX = Math.min(...xs) - let maxX = Math.max(...xs) - let minY = Math.min(...ys) - let maxY = Math.max(...ys) - let W = maxX - minX - let H = maxY - minY - if (W < 2 || H < 2) return false - // 네 코너가 실제 직사각형 코너와 일치해야 함 (axis-aligned). - const corners = [ - { x: minX, y: minY }, - { x: maxX, y: minY }, - { x: maxX, y: maxY }, - { x: minX, y: maxY }, - ] - for (const c of corners) { - if (!roof.points.some((p) => Math.abs(p.x - c.x) < TOL && Math.abs(p.y - c.y) < TOL)) return false - } - // [KERAB-RECT-SIDE-FIX 2026-06-15] outerLine 은 roof.points(roofLine)에서 出幅만큼 안쪽으로 - // 들어온 wallbase 좌표라, roof.points bbox 와 직접 비교하면 出幅(예 50)만큼 어긋나 TOL 초과 → - // sideOf 가 null → 솔버 전체 false 폴백(증상: 토글 시 KERAB-SIMPLE 미로로 빠져 그림 깨짐). - // 변 분류는 outerLine 자체 bbox 기준으로, 지오메트리(힙/마루)는 그대로 roof.points 기준으로 푼다. - const oxs = outerLines.flatMap((o) => [o.x1, o.x2]) - const oys = outerLines.flatMap((o) => [o.y1, o.y2]) - const oMinX = Math.min(...oxs) - const oMaxX = Math.max(...oxs) - const oMinY = Math.min(...oys) - const oMaxY = Math.max(...oys) - // 각 외곽선 → 변(top/bottom/left/right) 분류 + 유효 타입(EAVES/GABLE) 확정. - const sideOf = (o) => { - const horiz = Math.abs(o.y1 - o.y2) < TOL - const vert = Math.abs(o.x1 - o.x2) < TOL - if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMinY) < TOL) return 'top' - if (horiz && Math.abs((o.y1 + o.y2) / 2 - oMaxY) < TOL) return 'bottom' - if (vert && Math.abs((o.x1 + o.x2) / 2 - oMinX) < TOL) return 'left' - if (vert && Math.abs((o.x1 + o.x2) / 2 - oMaxX) < TOL) return 'right' - return null - } - const sides = {} - for (const o of outerLines) { - const s = sideOf(o) - if (!s || sides[s]) return false - const eff = o === target ? newType : o.attributes?.type - if (eff !== LINE_TYPE.WALLLINE.EAVES && eff !== LINE_TYPE.WALLLINE.GABLE) return false - sides[s] = { o, gable: eff === LINE_TYPE.WALLLINE.GABLE } - } - if (!sides.top || !sides.bottom || !sides.left || !sides.right) return false - - const gTop = sides.top.gable - const gBottom = sides.bottom.gable - const gLeft = sides.left.gable - const gRight = sides.right.gable - const nGable = [gTop, gBottom, gLeft, gRight].filter(Boolean).length - - // [KERAB-RECT-GATE 2026-06-15] 出幅을 경계에 반영하기 전에 지원 형상인지 먼저 판정한다. - // 미지원(인접 2박공/3·4박공/얕은 Y)인데 offset 을 먼저 옮기면 폴백된 미로가 offset 을 한 번 더 - // 적용해 이중 出幅이 된다 → 반드시 무변형 상태에서 게이트하고, 미지원이면 boundary 손대지 않고 false. - { - let ok = false - if (nGable === 0) ok = true - // [KERAB-RECT-GATE-1 2026-06-15] 단일 박공은 항상 지원: depth<=span 이면 Y(apex)모델, - // long-edge(케라바, depth>span)면 CASE B(반대코너 45° 힙 2개 + 변 위 마루 1개). 둘 다 정의됨. - else if (nGable === 1) ok = true - else if (nGable === 2) { - ok = (gTop && gBottom) || (gLeft && gRight) - } - if (!ok) return false - } - - // [KERAB-RECT-OFFSET 2026-06-15] 지원 형상 확정 후, 토글되는 변의 出幅 변경분을 경계(roofLine)에 - // 먼저 반영한다(inner line 은 안 건드림). 이후 갱신된 roof.points 로 내부 지오메트리를 푼다. - // 出幅 입력이 기존과 같으면 delta 0 → 무동작. 다르면 maze 의 offset-surgical 과 동일 경로. - if (newAttributes && newAttributes.offset != null) { - applyTargetOffsetSurgical(target, newAttributes.offset, { skipInnerLines: true }) - const nxs = roof.points.map((p) => p.x) - const nys = roof.points.map((p) => p.y) - minX = Math.min(...nxs) - maxX = Math.max(...nxs) - minY = Math.min(...nys) - maxY = Math.max(...nys) - W = maxX - minX - H = maxY - minY - } - - const cx = (minX + maxX) / 2 - const cy = (minY + maxY) / 2 - - const out = [] - const addRidge = (x1, y1, x2, y2) => { - if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'ridge' }) - } - const addHip = (x1, y1, x2, y2) => { - if (Math.hypot(x2 - x1, y2 - y1) >= 1) out.push({ x1, y1, x2, y2, kind: 'hip' }) - } - - if (nGable === 0) { - // 우진각: 마루는 긴 축, apex 는 짧은 변/2 안쪽. 힙 4개. - if (W >= H) { - const inset = H / 2 - const lA = { x: minX + inset, y: cy } - const rA = { x: maxX - inset, y: cy } - addRidge(lA.x, lA.y, rA.x, rA.y) - addHip(minX, minY, lA.x, lA.y) - addHip(minX, maxY, lA.x, lA.y) - addHip(maxX, minY, rA.x, rA.y) - addHip(maxX, maxY, rA.x, rA.y) - } else { - const inset = W / 2 - const tA = { x: cx, y: minY + inset } - const bA = { x: cx, y: maxY - inset } - addRidge(tA.x, tA.y, bA.x, bA.y) - addHip(minX, minY, tA.x, tA.y) - addHip(maxX, minY, tA.x, tA.y) - addHip(minX, maxY, bA.x, bA.y) - addHip(maxX, maxY, bA.x, bA.y) - } - } else if (nGable === 1) { - // 박공변이 짧은/중간 변(맞은편 apex 가 폴리곤 안)이면 Y(apex+마루). - // 긴 변(케라바=마루와 평행, W>2H or H>2W)이면 맞은편 두 코너에서 45° 힙이 박공변까지 - // 직진하고 박공변 위에 마루가 놓인다(CASE B). 어느 쪽이든 단일 박공은 항상 유효. - if (gBottom || gTop) { - const depth = W / 2 // apex 깊이 = 박공변(가로) 길이/2 - if (depth <= H - 0.5) { - // Y: apex 가 맞은편 변 안쪽 - if (gBottom) { - const apex = { x: cx, y: minY + depth } - addHip(minX, minY, apex.x, apex.y) - addHip(maxX, minY, apex.x, apex.y) - addRidge(apex.x, apex.y, cx, maxY) - } else { - const apex = { x: cx, y: maxY - depth } - addHip(minX, maxY, apex.x, apex.y) - addHip(maxX, maxY, apex.x, apex.y) - addRidge(apex.x, apex.y, cx, minY) - } - } else if (gBottom) { - // 케라바(긴 가로 변): 위쪽 두 코너 → 박공변(아래)까지 45° 직진 + 박공변 위 마루 - addHip(minX, minY, minX + H, maxY) - addHip(maxX, minY, maxX - H, maxY) - addRidge(minX + H, maxY, maxX - H, maxY) - } else { - addHip(minX, maxY, minX + H, minY) - addHip(maxX, maxY, maxX - H, minY) - addRidge(minX + H, minY, maxX - H, minY) - } - } else { - const depth = H / 2 - if (depth <= W - 0.5) { - if (gLeft) { - const apex = { x: maxX - depth, y: cy } - addHip(maxX, minY, apex.x, apex.y) - addHip(maxX, maxY, apex.x, apex.y) - addRidge(apex.x, apex.y, minX, cy) - } else { - const apex = { x: minX + depth, y: cy } - addHip(minX, minY, apex.x, apex.y) - addHip(minX, maxY, apex.x, apex.y) - addRidge(apex.x, apex.y, maxX, cy) - } - } else if (gLeft) { - // 케라바(긴 세로 변): 오른쪽 두 코너 → 박공변(왼쪽)까지 45° 직진 + 박공변 위 마루 - addHip(maxX, minY, minX, minY + W) - addHip(maxX, maxY, minX, maxY - W) - addRidge(minX, minY + W, minX, maxY - W) - } else { - addHip(minX, minY, maxX, minY + W) - addHip(minX, maxY, maxX, maxY - W) - addRidge(maxX, minY + W, maxX, maxY - W) - } - } - } else if (nGable === 2) { - // 맞배: 마주보는 두 박공 사이를 잇는 단일 마루, 힙 없음. - if (gTop && gBottom) { - addRidge(cx, minY, cx, maxY) - } else if (gLeft && gRight) { - addRidge(minX, cy, maxX, cy) - } else { - return false // 인접 2박공 = 굽은 마루, 미지원 → 폴백 - } - } else { - return false // 3·4 박공 → 폴백 - } - if (!out.length) return false - - const beforeSnap = snapshotKerabState(roof) - // 기존 내부 HIP/RIDGE(네이티브 + kerabPattern) 전부 제거 후 재생성. - for (const il of [...roof.innerLines]) { - if (!il) continue - if (il.name === LINE_TYPE.SUBLINE.HIP || il.name === LINE_TYPE.SUBLINE.RIDGE) { - canvas.remove(il) - const idx = roof.innerLines.indexOf(il) - if (idx >= 0) roof.innerLines.splice(idx, 1) - } - } - for (const o of outerLines) removeKerabHalfLabels(o.id) - for (const ln of out) { - const sz = calcLinePlaneSize({ x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }) - const isRidge = ln.kind === 'ridge' - const q = new QLine([ln.x1, ln.y1, ln.x2, ln.y2], { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: isRidge ? 4 : 3, - name: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP, - textMode: roof.textMode, - attributes: { - roofId: roof.id, - type: isRidge ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP, - planeSize: sz, - actualSize: sz, - // [KERAB-RECT-EXTENDED 2026-06-15] 솔버 hip 은 roofLine 코너(=출폭 끝점)까지 이미 닿아 있다. - // allocation 의 integrateExtensionLines 가 ext+hip 머지로 hip 을 wall 코너까지 단축시키면 - // split graph 가 polygon 코너와 끊겨 1면만 잡힌다(native hip 은 extended 플래그로 머지 스킵). - // 동일하게 extended=true 부여 → INTEGRATE 가 merge 스킵, ext 는 lines 에서만 제거. - ...(isRidge ? {} : { extended: true }), - }, - }) - q.lineName = ln.kind - if (!isRidge) q.__extended = true - // 할당 graph(splitPolygonWithLines)는 startPoint/endPoint 로 면을 닫으므로 동기화 필수. - q.startPoint = { x: ln.x1, y: ln.y1 } - q.endPoint = { x: ln.x2, y: ln.y2 } - canvas.add(q) - q.bringToFront() - roof.innerLines.push(q) - if (typeof q.setCoords === 'function') q.setCoords() - if (typeof q.setLength === 'function') q.setLength() - if (typeof q.addLengthText === 'function') q.addLengthText() - } - // 박공 변엔 절반 라벨 + 원본 길이 숨김, 처마 변엔 원본 길이 노출. - for (const key of ['top', 'bottom', 'left', 'right']) { - const s = sides[key] - if (s.gable) { - addKerabHalfLabels(s.o, { x: s.o.x1, y: s.o.y1 }, { x: s.o.x2, y: s.o.y2 }) - hideOriginalLengthText(s.o.id, true) - } else { - hideOriginalLengthText(s.o.id, false) - } - } - target.set({ attributes: newAttributes }) - canvas.renderAll() - const phase = newType === LINE_TYPE.WALLLINE.GABLE ? 'forward' : 'revert' - runKerabRuleCheck(roof, phase, beforeSnap) - try { - reattachDebugLabels(canvas, roof.id) - } catch (e) {} - logger.log('[KERAB-RECT-SOLVER] built ' + JSON.stringify({ nGable, lines: out.length, W: Math.round(W), H: Math.round(H) })) - return true - } catch (err) { - logger.warn('[KERAB-RECT-SOLVER] error → fallback', err) - return false - } - } - - // [KERAB-PATTERN-EXT-CLEAN 2026-05-19] forward 시 제거되는 hip 과 짝이었던 orphan - // extensionLine 정리. 한 끝점이 hip 의 한 끝점과 같고 다른 끝점이 hip 의 진행 - // 방향과 동일선상에 있으면 짝으로 판정. 잔류 시 allocation 의 integrateExtensionLines - // 가 "짝없음" 으로 처리하고, ext 끝점이 외곽 polygon 을 잘못 자르는 잉여 분할 발생. - const removeOrphanExtensionsForHip = (hip) => { - const TOL = 1.0 - const hipP1 = { x: hip.x1, y: hip.y1 } - const hipP2 = { x: hip.x2, y: hip.y2 } - const hipVx = hip.x2 - hip.x1 - const hipVy = hip.y2 - hip.y1 - const hipMag = Math.hypot(hipVx, hipVy) || 1 - const same = (a, b) => Math.hypot(a.x - b.x, a.y - b.y) < TOL - const exts = canvas.getObjects().filter( - (o) => o?.lineName === 'extensionLine' && o.roofId === hip.parentId, - ) - exts.forEach((ext) => { - const eP1 = { x: ext.x1, y: ext.y1 } - const eP2 = { x: ext.x2, y: ext.y2 } - const sharesP1 = same(eP1, hipP1) || same(eP1, hipP2) - const sharesP2 = same(eP2, hipP1) || same(eP2, hipP2) - if ((sharesP1 ? 1 : 0) + (sharesP2 ? 1 : 0) !== 1) return - const eVx = ext.x2 - ext.x1 - const eVy = ext.y2 - ext.y1 - const eMag = Math.hypot(eVx, eVy) || 1 - const cross = (hipVx * eVy - hipVy * eVx) / (hipMag * eMag) - if (Math.abs(cross) >= 0.02) return // 동일선상 아님 - canvas.remove(ext) - }) - } - - // [2240 KERAB-HIP-SNAPSHOT 2026-05-19] forward 시 제거되는 hip 의 원본 상태 캡처. - // revert 시 c1↔apex 로 새 hip 만들지 않고 이 스냅샷으로 원본 좌표/속성 그대로 복원. - const snapshotHip = (hip) => ({ - x1: hip.x1, - y1: hip.y1, - x2: hip.x2, - y2: hip.y2, - attributes: hip.attributes ? { ...hip.attributes } : {}, - lineName: hip.lineName, - __extended: hip.__extended, - }) - - // [2240 KERAB-SIMPLE 2026-05-20] 단순 알고리즘 전용 그리기 함수. - // mid(roof corner c1·c2 중점) → apex 중앙선 추가 + 양쪽 hip(h1·h2) 제거. - // c1, c2 가 hip.near (대각 offset roof corner) 이므로 그 중점이 roofLine 위 중점이 됨. - // revert 는 __patternKind='kLineOnly' 분기 + hipSnapshot 으로 ridge 제거 + hip 2개 복원. - const applyKerabKLinePattern = (roof, target, apex, c1, c2, hipsToRemove, ridgesToRemove, extLines, drawKLine = true, extraApexes = []) => { - // [KERAB-SIMPLE-KLINE-PERP 2026-05-20] kLine = apex 에서 roofLine(c1·c2 무한확장) 으로 - // 내린 수선의 발 → apex. 시작점이 hip 교점이므로 이 직선 자체가 "hip 라인의 중앙선". - // roofLine 중앙과는 무관 (비대칭이면 빗겨 떨어진다). - // [KERAB-EXTENDER-MIXED 2026-05-21] drawKLine=false (hip+ridge mixed extender) 케이스: - // 사용자 전제 3 "hip과 마루가 만나면 그 자리에서 멈춘다" — kLine 없이 apex 까지의 ext line 만. - // revert 식별을 위해 apex 위치 zero-length invisible 마커 ridge 추가. - let ridge - if (drawKLine) { - const dx = c2.x - c1.x - const dy = c2.y - c1.y - const lenSq = dx * dx + dy * dy || 1 - const t = ((apex.x - c1.x) * dx + (apex.y - c1.y) * dy) / lenSq - const naiveFoot = { x: c1.x + t * dx, y: c1.y + t * dy } - let foot = naiveFoot - // [KERAB-KLINE-TO-WALL 2026-05-21] kLine 도 roofLine 까지 확장 — apex 에서 naiveFoot 방향으로 - // ray cast 해서 roof 폴리곤의 어떤 wall 이라도 가장 가까운 교점까지 연장. - // 비대칭 폴리곤에서 c1-c2 무한확장선이 폴리곤 밖으로 빠지는 케이스 흡수. - if (Array.isArray(roof.points) && roof.points.length >= 2) { - const fdx = naiveFoot.x - apex.x - const fdy = naiveFoot.y - apex.y - const flen2 = fdx * fdx + fdy * fdy - if (flen2 > 1e-6) { - const rayEnd = { x: apex.x + fdx, y: apex.y + fdy } - let bestPt = null - let bestDist = Infinity - for (let i = 0; i < roof.points.length; i++) { - const a = roof.points[i] - const b = roof.points[(i + 1) % roof.points.length] - const ip = lineLineIntersection(apex, rayEnd, a, b) - if (!ip) continue - const fwd = (ip.x - apex.x) * fdx + (ip.y - apex.y) * fdy - if (fwd <= 1e-3) continue - if (!isPointOnSegment(ip, { x1: a.x, y1: a.y, x2: b.x, y2: b.y })) continue - const dist = Math.hypot(ip.x - apex.x, ip.y - apex.y) - if (dist < bestDist) { - bestDist = dist - bestPt = ip - } - } - if (bestPt) foot = bestPt - } - } - // [KERAB-KLINE-STOP 2026-05-21] 새 kLine(apex→foot) 이 기존 kLine 가로지르면 가장 가까운 교점까지 클립. - const existingKLines = (roof.innerLines || []).filter( - (il) => il && il.lineName === 'kerabPatternRidge' && !il.__noKLine && il.__targetId !== target.id && il.visible !== false, - ) - if (existingKLines.length) { - const segOnLine = (pt, ax, ay, bx, by, tol = 0.5) => { - const ddx = bx - ax - const ddy = by - ay - const sq = ddx * ddx + ddy * ddy - if (sq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol - const tt = ((pt.x - ax) * ddx + (pt.y - ay) * ddy) / sq - const margin = tol / Math.sqrt(sq) - if (tt < -margin || tt > 1 + margin) return false - const px = ax + tt * ddx - const py = ay + tt * ddy - return Math.hypot(px - pt.x, py - pt.y) <= tol - } - let bestT = 1 - let bestPt = null - const adx = foot.x - apex.x - const ady = foot.y - apex.y - for (const kl of existingKLines) { - const ip = lineLineIntersection(apex, foot, { x: kl.x1, y: kl.y1 }, { x: kl.x2, y: kl.y2 }) - if (!ip) continue - if (!segOnLine(ip, kl.x1, kl.y1, kl.x2, kl.y2)) continue - const tt = (adx * (ip.x - apex.x) + ady * (ip.y - apex.y)) / (adx * adx + ady * ady || 1) - if (tt <= 1e-3 || tt > 1 + 1e-3) continue - if (tt < bestT) { - bestT = tt - bestPt = ip - } - } - if (bestPt) foot = bestPt - } - // [KERAB-NO-PIERCE 2026-05-21] kLine 도 정적 hip/ridge + 이번 ext line 가로지르면 거기서 정지. - // hipsToRemove/ridgesToRemove 는 곧 사라질 라인이므로 barrier 에서 제외. - { - const segOnLine = (pt, ax, ay, bx, by, tol = 0.5) => { - const ddx = bx - ax - const ddy = by - ay - const sq = ddx * ddx + ddy * ddy - if (sq < 1e-6) return Math.hypot(pt.x - ax, pt.y - ay) <= tol - const tt = ((pt.x - ax) * ddx + (pt.y - ay) * ddy) / sq - const margin = tol / Math.sqrt(sq) - if (tt < -margin || tt > 1 + margin) return false - const px = ax + tt * ddx - const py = ay + tt * ddy - return Math.hypot(px - pt.x, py - pt.y) <= tol - } - const removingHips = new Set(Array.isArray(hipsToRemove) ? hipsToRemove : []) - const removingRidges = new Set(Array.isArray(ridgesToRemove) ? ridgesToRemove : []) - const kBarriers = [] - for (const il of roof.innerLines || []) { - if (!il) continue - if (removingHips.has(il) || removingRidges.has(il)) continue - if (il.lineName === 'kerabPatternRidge') continue - if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue - if (il.visible === false) continue - kBarriers.push({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - } - if (Array.isArray(extLines)) { - for (const seg of extLines) { - if (!seg) continue - kBarriers.push({ x1: seg.from.x, y1: seg.from.y, x2: seg.to.x, y2: seg.to.y }) - } - } - let bestT = 1 - let bestPt = null - const adx = foot.x - apex.x - const ady = foot.y - apex.y - const aSq = adx * adx + ady * ady || 1 - for (const bl of kBarriers) { - const ip = lineLineIntersection(apex, foot, { x: bl.x1, y: bl.y1 }, { x: bl.x2, y: bl.y2 }) - if (!ip) continue - if (!segOnLine(ip, bl.x1, bl.y1, bl.x2, bl.y2)) continue - const tt = (adx * (ip.x - apex.x) + ady * (ip.y - apex.y)) / aSq - if (tt <= 1e-3 || tt > 1 + 1e-3) continue - if (tt < bestT) { - bestT = tt - bestPt = ip - } - } - if (bestPt) foot = bestPt - } - const points = [foot.x, foot.y, apex.x, apex.y] - const sz = calcLinePlaneSize({ x1: points[0], y1: points[1], x2: points[2], y2: points[3] }) - ridge = new QLine(points, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 4, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, - }) - } else { - ridge = new QLine([apex.x, apex.y, apex.x, apex.y], { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: 'rgba(0,0,0,0)', - strokeWidth: 0, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: roof.textMode, - visible: false, - selectable: false, - attributes: { roofId: roof.id, planeSize: 0, actualSize: 0 }, - }) - } - ridge.lineName = 'kerabPatternRidge' - ridge.__patternKind = 'kLineOnly' - ridge.__targetId = target.id - ridge.__noKLine = !drawKLine - canvas.add(ridge) - ridge.bringToFront() - roof.innerLines.push(ridge) - - // [KERAB-MULTI-APEX 2026-05-22] 추가 apex 각각 별도 kLine ridge — primary 의 apex→foot 방향을 reference 로 ray cast. - // c1·c2 line 에 수직 projection 으로 foot 을 잡으면 추가 apex 가 c1·c2 line 의 반대편이라 ray 방향이 뒤집힘. - // primary 의 unit direction 으로 강제 → primary 와 같은 방향(roofLine 쪽) 으로 kLine 연장. - const auxRidges = [] - if (drawKLine && Array.isArray(extraApexes) && extraApexes.length) { - // primary 의 unit direction (apex→foot, roofLine 쪽). - const primDx = c2.x - c1.x - const primDy = c2.y - c1.y - const primLenSq = primDx * primDx + primDy * primDy || 1 - const primT = ((apex.x - c1.x) * primDx + (apex.y - c1.y) * primDy) / primLenSq - const primNaiveFoot = { x: c1.x + primT * primDx, y: c1.y + primT * primDy } - const refFdx = primNaiveFoot.x - apex.x - const refFdy = primNaiveFoot.y - apex.y - const refFlen = Math.hypot(refFdx, refFdy) || 1 - const refUx = refFdx / refFlen - const refUy = refFdy / refFlen - for (const exApex of extraApexes) { - let foot = { x: exApex.x + refUx, y: exApex.y + refUy } - if (Array.isArray(roof.points) && roof.points.length >= 2) { - const rayEnd = { x: exApex.x + refUx, y: exApex.y + refUy } - let bestPt = null - let bestDist = Infinity - for (let i = 0; i < roof.points.length; i++) { - const a = roof.points[i] - const b = roof.points[(i + 1) % roof.points.length] - const ip = lineLineIntersection(exApex, rayEnd, a, b) - if (!ip) continue - const fwd = (ip.x - exApex.x) * refUx + (ip.y - exApex.y) * refUy - if (fwd <= 1e-3) continue - if (!isPointOnSegment(ip, { x1: a.x, y1: a.y, x2: b.x, y2: b.y })) continue - const dist = Math.hypot(ip.x - exApex.x, ip.y - exApex.y) - if (dist < bestDist) { - bestDist = dist - bestPt = ip - } - } - if (bestPt) foot = bestPt - } - const pts = [foot.x, foot.y, exApex.x, exApex.y] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - const auxRidge = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 4, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, - }) - auxRidge.lineName = 'kerabPatternRidge' - auxRidge.__patternKind = 'kLineOnly' - auxRidge.__targetId = target.id - auxRidge.__auxApex = true - canvas.add(auxRidge) - auxRidge.bringToFront() - roof.innerLines.push(auxRidge) - auxRidges.push(auxRidge) - } - ridge.__auxApexRidges = auxRidges - } - - // [KERAB-SIMPLE-EXTHIP 2026-05-20] 확장 hip/ridge 추가 — 접점에서 newApex 까지의 연장선. - // 원본 hip + RG-1 제거 전에 먼저 그려두어야 시각적으로 끊김 없이 전환됨. - // revert 시 ridge.__extHipsCreated 참조로 제거. - // [KERAB-EXTENDER-MIXED 2026-05-21] seg.isHip 으로 hip/ridge 구분 (mixed extender 케이스). - if (Array.isArray(extLines) && extLines.length) { - const created = [] - for (const seg of extLines) { - if (!seg) continue - const pts = [seg.from.x, seg.from.y, seg.to.x, seg.to.y] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - const segIsHip = seg.isHip !== false - const extLine = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 3, - name: segIsHip ? LINE_TYPE.SUBLINE.HIP : LINE_TYPE.SUBLINE.RIDGE, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, - }) - extLine.lineName = segIsHip ? 'kerabPatternExtHip' : 'kerabPatternExtRidge' - extLine.__extended = true - canvas.add(extLine) - extLine.bringToFront() - roof.innerLines.push(extLine) - created.push(extLine) - } - ridge.__extHipsCreated = created - } - - // [KERAB-SIMPLE-HIP-REMOVE 2026-05-20] 양쪽 hip 제거 + revert 스냅샷 저장 - // [KERAB-POLYGON-BFS 2026-05-21] 폴리곤 경로상 path hip 도 함께 들어옴. lineName/__extended - // 까지 스냅샷에 보존해 revert 시 SK extended flag 등 메타 정보 복원. - if (Array.isArray(hipsToRemove) && hipsToRemove.length) { - const snapshot = [] - for (const hip of hipsToRemove) { - if (!hip) continue - snapshot.push({ - x1: hip.x1, y1: hip.y1, x2: hip.x2, y2: hip.y2, - attributes: hip.attributes ? { ...hip.attributes } : null, - lineName: hip.lineName, - __extended: hip.__extended, - }) - removeLine(hip) - roof.innerLines = roof.innerLines.filter((il) => il !== hip) - } - ridge.__removedHipsSnapshot = snapshot - } - - // [KERAB-SIMPLE-MIDRIDGE 2026-05-20] 확장 apex 케이스에서 hip 사이를 잇던 ridge 제거 + revert 스냅샷. - if (Array.isArray(ridgesToRemove) && ridgesToRemove.length) { - const ridgeSnapshot = [] - for (const r of ridgesToRemove) { - if (!r) continue - ridgeSnapshot.push({ - x1: r.x1, - y1: r.y1, - x2: r.x2, - y2: r.y2, - attributes: r.attributes ? { ...r.attributes } : null, - lineName: r.lineName, - }) - removeLine(r) - roof.innerLines = roof.innerLines.filter((il) => il !== r) - } - ridge.__removedRidgesSnapshot = ridgeSnapshot - } - - hideOriginalLengthText(target.id, true) - removeKerabHalfLabels(target.id) - addKerabHalfLabels(target, { x: target.x1, y: target.y1 }, { x: target.x2, y: target.y2 }) - // [KERAB-SIMPLE-ROOFLABEL 2026-05-20] roofLine 위에도 half 라벨 추가 (c1·c2 기준) - addKerabHalfLabels(target, c1, c2) - canvas.renderAll() - return true - } - - const applyKerabSingleRidgePattern = (roof, target, c1, c2, pair) => { - // [2240 KERAB-KLINE-ONLY 2026-05-20] 사용자 결정: 라인 삭제·생성 없이 중앙선(kLine)만 추가. - // - mid(케라바 외곽선 중점) → apex 으로 ridge 1개만 add - // - hip 페어/orphan ext/RG-1 등 기존 라인은 모두 그대로 보존 - // - revert 는 __patternKind='kLineOnly' 분기로 ridge 만 제거 - // [2240 KERAB-MID-FROM-TARGET 2026-05-20] mid 를 c1·c2(roof corner) 가 아닌 target 외곽선 실제 끝점으로 계산. - // roof.points 와 outerLine 좌표가 50 단위 drift 되어 있어도 외곽선 중점에서 정확히 출발. - const mid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 } - const points = [mid.x, mid.y, pair.apex.x, pair.apex.y] - const sz = calcLinePlaneSize({ x1: points[0], y1: points[1], x2: points[2], y2: points[3] }) - const ridge = new QLine(points, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 4, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz }, - }) - ridge.lineName = 'kerabPatternRidge' - ridge.__patternKind = 'kLineOnly' - if (!ridgeMeetsMidpointOf(ridge, { x1: target.x1, y1: target.y1, x2: target.x2, y2: target.y2 })) { - return false - } - canvas.add(ridge) - ridge.bringToFront() - roof.innerLines.push(ridge) - hideOriginalLengthText(target.id, true) - removeKerabHalfLabels(target.id) - addKerabHalfLabels(target, c1, c2) - canvas.renderAll() - return true - } - - // [2240 KERAB-KLINE-ONLY 2026-05-20] junction-extended 도 동일: 중앙선만 그리고 기존 라인 무손상. - // mid → jp.apex ridge 1개만 add. ext hip 생성·RG-1 제거·outer hip 제거 전부 skip. - const applyKerabJunctionExtendedPattern = (roof, target, c1, c2, jp) => { - // [2240 KERAB-MID-FROM-TARGET 2026-05-20] mid = target 외곽선 실제 중점 (roof corner drift 제거). - const mid = { x: (target.x1 + target.x2) / 2, y: (target.y1 + target.y2) / 2 } - const ridgePoints = [mid.x, mid.y, jp.apex.x, jp.apex.y] - const ridgeSz = calcLinePlaneSize({ x1: ridgePoints[0], y1: ridgePoints[1], x2: ridgePoints[2], y2: ridgePoints[3] }) - const ridge = new QLine(ridgePoints, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 4, - name: LINE_TYPE.SUBLINE.RIDGE, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: ridgeSz, actualSize: ridgeSz }, - }) - ridge.lineName = 'kerabPatternRidge' - ridge.__patternKind = 'kLineOnly' - if (!ridgeMeetsMidpointOf(ridge, { x1: target.x1, y1: target.y1, x2: target.x2, y2: target.y2 })) return false - - canvas.add(ridge) - ridge.bringToFront() - roof.innerLines.push(ridge) - - hideOriginalLengthText(target.id, true) - removeKerabHalfLabels(target.id) - addKerabHalfLabels(target, c1, c2) - canvas.renderAll() - return true - } - - // [2240 KERAB-KLINE-ONLY 2026-05-20] 양 끝 hip 평행 케이스: apex 가 없어 중앙선조차 그릴 수 없음. - // "라인 삭제 금지 + 중앙선만 그리기" 정책상 hip 제거도 skip → attr-only 와 동일하게 동작. - const applyKerabParallelHipsPattern = () => { - canvas.renderAll() - return true - } - - // [2240 KERAB-PARALLEL-HIPS 2026-05-19] revert: target.__kerabParallelHipsSnapshot 기반으로 hip 2개 복원. - const revertKerabParallelHipsPattern = (roof, target) => { - const snaps = Array.isArray(target.__kerabParallelHipsSnapshot) ? target.__kerabParallelHipsSnapshot : [] - snaps.forEach((snap) => { - if (!snap) return - const pts = [snap.x1, snap.y1, snap.x2, snap.y2] - const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] }) - const hip = new QLine(pts, { - parentId: roof.id, - fontSize: roof.fontSize, - stroke: '#1083E3', - strokeWidth: 4, - name: LINE_TYPE.SUBLINE.HIP, - textMode: roof.textMode, - attributes: { roofId: roof.id, planeSize: sz, actualSize: sz, ...snap.attributes }, - }) - if (snap.lineName) hip.lineName = snap.lineName - if (snap.__extended) hip.__extended = snap.__extended - canvas.add(hip) - hip.bringToFront() - roof.innerLines.push(hip) - }) - delete target.__kerabParallelHipsSnapshot - canvas.renderAll() - return true - } - - // [2240 KERAB-ATTR-ONLY 2026-05-19] fallback: hip 페어가 1·2·3·평행 어디에도 해당 안 될 때 (hip 0/1 개, apex 가 중앙 아님 등). - // - 외곽선 attributes.type 만 토글, hip 라인/ridge/라벨 등 SK 산출물 전부 무변경 - // - 호출자가 이미 target.set({ attributes }) 적용한 상태로 들어옴 → 렌더만 갱신 - // - forward/revert 양방향 동일 처리 (대칭) - const applyKerabAttributeOnlyPattern = () => { - canvas.renderAll() - return true - } - return { type, setType, buttonMenu, TYPES, pitchRef, offsetRef, widthRef, radioTypeRef, pitchText } } diff --git a/src/hooks/roofcover/useRoofAllocationSetting.js b/src/hooks/roofcover/useRoofAllocationSetting.js index 24073c0e..033414f6 100644 --- a/src/hooks/roofcover/useRoofAllocationSetting.js +++ b/src/hooks/roofcover/useRoofAllocationSetting.js @@ -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)) diff --git a/src/hooks/usePolygon.js b/src/hooks/usePolygon.js index 7582a390..87769390 100644 --- a/src/hooks/usePolygon.js +++ b/src/hooks/usePolygon.js @@ -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) diff --git a/src/locales/ja.json b/src/locales/ja.json index fecc3c3f..2ee9f131 100644 --- a/src/locales/ja.json +++ b/src/locales/ja.json @@ -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": "棟", diff --git a/src/locales/ko.json b/src/locales/ko.json index af972e11..1275a136 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -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": "용마루", diff --git a/src/util/kerab-rule-checker.js b/src/util/kerab-rule-checker.js index f59faaca..0b015cdb 100644 --- a/src/util/kerab-rule-checker.js +++ b/src/util/kerab-rule-checker.js @@ -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 } diff --git a/src/util/qpolygon-utils.js b/src/util/qpolygon-utils.js index 97e1d394..668efc2c 100644 --- a/src/util/qpolygon-utils.js +++ b/src/util/qpolygon-utils.js @@ -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,38 +5051,79 @@ 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) => { - 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 }) - }) + // 길이 0 잔여 제거 + const residualLines = linesAnalysis.filter((line) => Math.hypot(line.end.x - line.start.x, line.end.y - line.start.y) > 0) - 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)) { - 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) { - innerLines.push(drawRoofLine([line.start.x, line.start.y, line.end.x, line.end.y], canvas, roof, textMode)) - } else { - innerLines.push(drawHipLine([line.start.x, line.start.y, line.end.x, line.end.y], canvas, roof, textMode, line.degree, line.degree)) - } - } - } + // 순서 비의존 연결성 판정용 끝점 집합: 이미 그려진 라인(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)) + + // 자기 자신이 끝점마다 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 = [] @@ -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$/, '') }) } }