diff --git a/docs/plans/2026-06-01-skeleton-roofline-edit-design.md b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md index 2efd354e..82414cae 100644 --- a/docs/plans/2026-06-01-skeleton-roofline-edit-design.md +++ b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md @@ -86,4 +86,76 @@ splitRoofLinesToInnerLines(roof, canvas, textMode) → QLine[] ## 6. 범위 밖 (YAGNI) - 외곽 지붕선 전용 우클릭 메뉴/가드 (변별 경로와 완전 동일하게 가므로 추가 분기 없음). -- 박공(`drawGableRoof`) / 한쪽흐름(`drawShedRoof`) 경로는 이번 변경 대상 아님. + +> 박공(`drawGableRoof`) / 한쪽흐름(`drawShedRoof`) 경로는 **§7 에서 범위로 편입** (2026-06-09). + +--- + +## 7. 4경로 종합 (2026-06-09 추가) + +`QPolygon.drawHelpLine()`(`QPolygon.js:773-791`) 의 4갈래 분기별 외곽 지붕선 처리 현황. + +| 경로 | 함수 | 외곽선 생성 방식 | name / selectable / visible | 편집 가능? | 작업량 | +|---|---|---|---|---|---| +| **변별** | `drawRoofByAttribute` (`qpolygon-utils.js:1731`) | `roof.lines` 직접 순회 → innerLine 끝점으로 **split** (`:5432-5496`) | `hip` / true / true | ✅ 레퍼런스 | 없음(원본 추출) | +| **박공** | `drawGableRoof` (`:710`) | 지붕면 **둘레 재구성** → `drawRoofLine`/`drawHipLine` (`:1605-1617`), `roof.innerLines.push` (`:1630`) | `hip` / true / true | ✅ **이미 됨** | 회귀 검증만 | +| **용마루** | `drawSkeletonRidgeRoof` → `createInnerLinesFromSkeleton` | SK outer edge QLine 직접 생성 후 **숨김** (`skeleton-utils.js:1624,1638`) | `eaves` / **false** / **false** | ❌ | 중 (§3) | +| **한쪽흐름** | `drawShedRoof` (`:1644`) | 외곽선 innerLine화 **없음** — `pitchSizeLine` 만 add (`:1701-1721`) | — | ❌ | 대 (§8) | + +핵심 계약: `drawRoofLine`(`:5863`) 과 `drawHipLine`(`:5763`) 은 둘 다 `name: LINE_TYPE.SUBLINE.HIP` + `selectable` 미지정(QLine 기본 true). 즉 **"이 함수로 그린 외곽선 = 편집 가능한 hip innerLine"**. 컨텍스트 메뉴는 `useContextMenu.js:300-303` 에서 `name` 기준으로 자동 연결되므로 별도 바인딩 불필요(§2-1 과 동일). + +### 7-1. 박공 — 이미 편집 가능, 메커니즘만 다름 +외곽선이 `name='hip'` innerLine 으로 이미 들어가 select+우클릭 동작. 단 변별처럼 `roof.lines` 를 split 하지 않고 **지붕면 둘레 점을 직교 재정렬**(`getSortedOrthogonalPoints`, `:1571`)해서 그린다. → "신규 구현" 이 아니라 **이동/사이즈변경 시 `roof.lines` 정합성 회귀 검증** 대상. + +### 7-2. 한쪽흐름 — 미구현 + 경사변 특수성 (→ §8) +`drawShedRoof` 는 `roof.innerLines` 를 건드리지 않아 초기화된 `[]` 로 남는다. 외곽선 편집을 위해 split 을 새로 붙여야 하나, **공용 split 을 그대로 쓰면 경사변의 실제길이가 틀린다** — §8. + +## 8. 한쪽흐름 degree 전략 주입 설계 (2026-06-09 추가) + +### 8-1. 문제: 평면길이 ≠ 실제길이 +한쪽흐름은 흐름방향으로 기울어져 변마다 경사 적용 여부가 다르다. + +| 변 종류 | 흐름방향 관계 | 경사 | plane vs actual | +|---|---|---|---| +| 처마(eaves) / 마루(shed) | 수직 | 수평 | plane = actual | +| **박공(gable)** | **평행** | 경사면 | **plane ≠ actual** (= `planeSize / cos(shedDegree)`) | + +공용 split 이 외곽선을 그리는 `drawRoofLine`(`:5863`) 은 `actualSize: calcLinePlaneSize(...)` 로 **무조건 actual=plane** 을 박는다(degree 인자 없음, 수평선 전용 가정). `drawHipLine`(`:5763`) 은 degree 를 받지만 split 이 넘기는 값은 **인접 변의 `attributes.pitch`**(`:5443-5444`)라 흐름 경사와 무관. 게다가 한쪽흐름은 분할점이 없어(내부 용마루·추녀 없음) 전 외곽변이 통째 `drawRoofLine` 으로 그려져 → 박공변도 actual=plane → **틀림**. + +도메인 규칙의 단서는 `drawShedRoof` 자체에 이미 있다(`:1656-1669`): +```js +const shedDegree = getDegreeByChon(sheds[0].attributes.pitch) // 흐름 경사각 +gables.forEach((gable) => + gable.attributes.actualSize = calcLineActualSize({...gable}, shedDegree)) // 박공변만 보정 +``` +`calcLineActualSize(points, deg) = planeSize / cos(deg)` (`:6270`). 현재는 `roof.lines` attributes 에만 쓰고 innerLine 을 안 만들어 편집 불가. + +### 8-2. 설계 (방향 1 채택): degree 결정 전략 주입 +공용 split 의 **기하(분할/연결) 로직은 재사용**, "각 분할 라인의 degree" 만 경로별 전략으로 분리한다. + +`splitRoofLinesToInnerLines(roof, canvas, textMode, opts)` 시그니처에 degree 전략 주입: + +``` +opts.degreeOf?: (parentLine) => number // 부모 외곽변 → 경사각(도). 없으면 기존 인접 pitch 방식. +``` + +- **변별 / 박공 / 용마루**: `degreeOf` 미전달 → 기존 끝점·인접 pitch 기반 (동작 불변). +- **한쪽흐름**: `degreeOf = (parentLine) => isGable(parentLine) ? shedDegree : 0` + - `isGable`: 부모 변의 `attributes.type === LINE_TYPE.WALLLINE.GABLE` (흐름방향 평행 변). + +구간 그리기 분기: +- 경사 구간(`deg > 0`): `drawRoofLine` 대신 **`drawHipLine(points, …, deg, deg)`**. 박공변이 수평/수직이면 `drawHipLine` 의 대각선 분기(`:5767`)를 건너뛰고 `calcLineActualSize(points, deg)` 로 `plane/cos(deg)` 계산 → `drawShedRoof` 기존 보정과 **수식 일치**. +- 수평 구간(`deg === 0`): 기존대로 `drawRoofLine`. + +### 8-3. 편집 환산까지 해결 — degree 를 라인 attributes 에 저장 +표시용 actualSize 뿐 아니라 **select → 사이즈 변경** 시 입력값(plane↔actual) 환산에도 라인이 자기 degree 를 들고 있어야 한다. 변별/용마루 hip 은 `attributes` 에 경사 정보가 있어 환산되지만, 한쪽흐름 경사변은 **`shedDegree` 를 라인 `attributes.pitch`(또는 degree)에 심지 않으면 편집 모달 환산이 깨진다.** → §8-2 의 `drawHipLine(…, deg, deg)` 호출이 degree 를 라인 속성에 함께 저장하도록 하여 표시·편집을 동시에 해결. + +### 8-4. 적용 순서 / 검증 +1. `splitRoofLinesToInnerLines` 추출 시 `degreeOf` 옵션 포함 (§3-1 + §8-2 병합). +2. `drawShedRoof` 끝에서 `splitRoofLinesToInnerLines(roof, canvas, textMode, { degreeOf })` 호출. 기존 `pitchSizeLine` 추가는 유지. +3. 검증: 한쪽흐름 박공변 select 시 actual 표시값 = `plane / cos(shedDegree)`, 사이즈변경 모달에서 plane↔actual 양방향 환산 정상, 처마/마루변은 plane=actual. +4. 회귀: 변별·박공·용마루는 `degreeOf` 미전달이므로 동작 불변 확인. + +### 8-5. 리스크 +- **분할점 부재**: 한쪽흐름은 내부 보조선이 없어 외곽변이 1:1 통짜 라인으로 그려진다 → 부모 변 type 매칭이 단순(분할 시에도 조각은 부모 type 상속). split 의 끝점 정합성(`isPointOnLineNew`, tol 0.1) 자체는 분할점이 없으면 무의미하므로 `else` 통짜 경로(`:5493`)로 안전하게 떨어진다. +- **`isGable` 판정원**: `drawShedRoof` 의 `gables` 분류(`:1653`)와 동일 기준(`attributes.type === GABLE`)을 써야 보정 대상이 일치. diff --git a/src/components/common/context-menu/QContextMenu.jsx b/src/components/common/context-menu/QContextMenu.jsx index 610a2a29..c3adac21 100644 --- a/src/components/common/context-menu/QContextMenu.jsx +++ b/src/components/common/context-menu/QContextMenu.jsx @@ -34,6 +34,10 @@ export default function QContextMenu(props) { const handleContextMenu = (e) => { // e.preventDefault() //기존 contextmenu 막고 + // [MODULE-MULTI-SELECT 2026-06-10] 복수선택(2개 이상) 시 컨텍스트 메뉴 노출 차단. + // 복수선택은 Del 키 삭제 + 마우스 드래그 이동만 지원. + if (canvas?.getActiveObjects && canvas.getActiveObjects().length > 1) return + if (currentObject) { const isArray = currentObject.hasOwnProperty('arrayData') if (isArray && currentObject.arrayData.length === 0) return diff --git a/src/components/floor-plan/modal/circuitTrestle/step/type/PassivityCircuitAllocation.jsx b/src/components/floor-plan/modal/circuitTrestle/step/type/PassivityCircuitAllocation.jsx index 2a6cb226..dc85d1c6 100644 --- a/src/components/floor-plan/modal/circuitTrestle/step/type/PassivityCircuitAllocation.jsx +++ b/src/components/floor-plan/modal/circuitTrestle/step/type/PassivityCircuitAllocation.jsx @@ -49,7 +49,16 @@ export default function PassivityCircuitAllocation(props) { obj.on('mousedown', (e) => handleTargetModules(obj)) }) + // [CIRCUIT-BOX-SELECT 2026-06-10] 手動回路割り当て 에서만 드래그 박스 복수선택 활성화 (이 화면 한정) + const prevSelection = canvas.selection + canvas.selection = true + canvas.on('selection:created', handleBoxSelect) + canvas.on('selection:updated', handleBoxSelect) + return () => { + canvas.off('selection:created', handleBoxSelect) + canvas.off('selection:updated', handleBoxSelect) + canvas.selection = prevSelection canvas .getObjects() .filter((obj) => obj.name === POLYGON_TYPE.MODULE) @@ -57,6 +66,7 @@ export default function PassivityCircuitAllocation(props) { obj.set({ strokeWidth: 0.3 }) obj.off('mousedown') }) + canvas.discardActiveObject() canvas.renderAll() } }, []) @@ -84,6 +94,28 @@ export default function PassivityCircuitAllocation(props) { canvas.renderAll() } + // [CIRCUIT-BOX-SELECT 2026-06-10] 드래그 박스(activeSelection) 로 잡힌 모듈을 targetModules 에 가산 병합 후 fabric 선택 해제 + const handleBoxSelect = () => { + const active = canvas.getActiveObject() + if (!active || active.type !== 'activeSelection') return + const boxModules = active.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE) + if (boxModules.length === 0) return + + setTargetModules((prev) => { + const next = Array.isArray(prev) ? [...prev] : [] + boxModules.forEach((obj) => { + if (!next.includes(obj.id)) { + obj.set({ strokeWidth: 2 }) + next.push(obj.id) + } + }) + return next + }) + + canvas.discardActiveObject() + canvas.renderAll() + } + const handleCircuitNumberFix = () => { const pcsTpCd = selectedPcs.pcsTpCd // 실내집중형, 옥외멀티형 let uniqueCircuitNumbers = [ diff --git a/src/hooks/module/useModule.js b/src/hooks/module/useModule.js index 22375b1c..eed7ec4d 100644 --- a/src/hooks/module/useModule.js +++ b/src/hooks/module/useModule.js @@ -990,15 +990,11 @@ export function useModule() { // 회로가 없을때만 삭제 가능 const modulesRemove = () => { - const activeModule = canvas.getObjects().find((obj) => canvas.getActiveObjects()[0].id === obj.id) - const modules = canvas - .getObjects() - .filter((obj) => obj.surfaceId === activeModule.surfaceId && obj.name === POLYGON_TYPE.MODULE && activeModule.id !== obj.id) - const surface = canvas.getObjects().find((obj) => obj.id === activeModule.surfaceId && obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) - if (surface.isComplete) { - removeTrestleMaterials() - } - if (activeModule.circuit) { + // [MODULE-MULTI-SELECT 2026-06-10] 단일/복수 선택 모두 처리. 회로가 걸린 모듈이 하나라도 있으면 차단. + const activeModules = canvas.getActiveObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE) + if (activeModules.length === 0) return + + if (activeModules.some((module) => module.circuit)) { swalFire({ title: getMessage('can.not.remove.module'), icon: 'error', @@ -1010,9 +1006,24 @@ export function useModule() { saveSnapshot() surface.set({ modules: modules }) canvas.remove(activeModule) + const surfaceIds = [...new Set(activeModules.map((module) => module.surfaceId))] + const affectedSurfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && surfaceIds.includes(obj.id)) + + if (affectedSurfaces.some((surface) => surface.isComplete)) { + removeTrestleMaterials() + } + + activeModules.forEach((module) => canvas.remove(module)) + + affectedSurfaces.forEach((surface) => { + const remaining = canvas.getObjects().filter((obj) => obj.surfaceId === surface.id && obj.name === POLYGON_TYPE.MODULE) + surface.set({ modules: remaining }) + }) + + canvas.discardActiveObject() canvas.renderAll() setModuleStatisticsData() - recalculateAllModulesCoords(activeModule.surfaceId) + surfaceIds.forEach((surfaceId) => recalculateAllModulesCoords(surfaceId)) } const moduleRoofRemove = (surfaceArray) => { diff --git a/src/hooks/module/useModuleBasicSetting.js b/src/hooks/module/useModuleBasicSetting.js index c7450aff..c389f3e8 100644 --- a/src/hooks/module/useModuleBasicSetting.js +++ b/src/hooks/module/useModuleBasicSetting.js @@ -998,7 +998,10 @@ export function useModuleBasicSetting(tabNum) { if (!isIntersection) return tempModule.setCoords() //좌표 재정렬 - if (checkModuleDisjointSurface(tempTurfModule, turfPolygon)) { + // [EDGE-PLACE-TOLERANCE 2026-06-09] 手動 1枚 배치: 端部에 딱 붙인 모듈이 소수 절단 잔차(±0.02)로 屋根 경계를 살짝 넘으면 + // spare=0 판정이 屋根外로 오인 → 「取り付け面からモジュールを取り付けることはできません」 경고. 屋根 절대좌표에 따라 2번째/3번째가 들쭉날쭉. + // 이동/삽입/정렬(isOutsideSurface)과 동일한 0.5 허용오차로 잔차 흡수. + if (checkModuleDisjointSurface(tempTurfModule, turfPolygon, 0.5)) { //마우스 클릭시 set으로 해당 위치에 셀을 넣음 const isOverlap = manualDrawModules.some((module) => turf.booleanOverlap(tempTurfModule, polygonToTurfPolygon(module, true))) //겹치는지 확인 if (!isOverlap) { @@ -1965,9 +1968,6 @@ export function useModuleBasicSetting(tabNum) { const isMaxSetup = false const moduleSetupSurfaces = moduleSetupSurface //선택 설치면 - const notSelectedTrestlePolygons = canvas - ?.getObjects() - .filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && !moduleSetupSurfaces.includes(obj)) //설치면이 아닌것 const batchObjects = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.OBJECT_SURFACE) //도머s 객체 @@ -1976,14 +1976,8 @@ export function useModuleBasicSetting(tabNum) { return } - //어짜피 자동으로 누르면 선택안된데도 다 날아간다 - // canvas.getObjects().forEach((obj) => { - // if (obj.name === POLYGON_TYPE.MODULE) { - // canvas.remove(obj) - // } - // }) - - //자동일때만 선택 안된 모듈 삭제 + // [AUTO-KEEP-UNSELECTED 2026-06-09] 自動配置도 선택 안 된 면의 모듈은 보존 (전체 리셋 → 선택면만 재배치) + // 선택된 설치면의 기존 모듈만 삭제 후 재배치 moduleSetupSurfaces.forEach((obj) => { if (obj.modules) { obj.modules.forEach((module) => { @@ -1994,18 +1988,6 @@ export function useModuleBasicSetting(tabNum) { } }) - if (type === MODULE_SETUP_TYPE.AUTO) { - notSelectedTrestlePolygons.forEach((obj) => { - if (obj.modules) { - obj.modules.forEach((module) => { - canvas?.remove(module) - canvas?.renderAll() - }) - obj.modules = [] - } - }) - } - let moduleOptions = { stroke: 'black', strokeWidth: 0.3, @@ -2249,6 +2231,9 @@ export function useModuleBasicSetting(tabNum) { // 대칭 지붕을 위해 south의 calcAreaWidth 저장 (north에서 참조) if (symmetricWidthRef && moduleIndex === 0) { symmetricWidthRef.south = calcAreaWidth + // [SYMM-FIX 2026-06-09] 진짜 대칭 판정용 남면 실제 가로폭 저장 + const southBBox = turf.bbox(polygonToTurfPolygon(moduleSetupSurface)) + symmetricWidthRef.southActualWidth = southBBox[2] - southBBox[0] } if (type === MODULE_SETUP_TYPE.LAYOUT) { @@ -2436,8 +2421,13 @@ export function useModuleBasicSetting(tabNum) { // 북쪽: 남쪽과 동일한 방식으로 계산 (대칭을 위해) let calcAreaWidth = Math.abs(flowLines.right.x1 - flowLines.left.x1) //오른쪽 x에서 왼쪽 x를 뺀 가운데를 찾는 로직 - // 대칭 지붕: south의 calcAreaWidth가 있고 north의 값이 south보다 10% 이상 작으면 south 값 사용 - if (symmetricWidthRef?.south && calcAreaWidth < symmetricWidthRef.south * 0.9) { + // [SYMM-FIX 2026-06-09] 진짜 대칭 판정: 북면 실제 폴리곤 가로폭이 남면 실제 가로폭과 비슷(>=90%)할 때만 보정. + // T자처럼 북면이 물리적으로 작으면(실제폭 작음) 보정 제외 → 단독 선택과 동일하게 자연 배치. + const northBBox = turf.bbox(polygonToTurfPolygon(moduleSetupSurface)) + const northActualWidth = northBBox[2] - northBBox[0] + const reallySymmetric = !!(symmetricWidthRef?.southActualWidth && northActualWidth >= symmetricWidthRef.southActualWidth * 0.9) + // 대칭 지붕: south의 calcAreaWidth가 있고 north의 flowLines폭이 south보다 10% 이상 작은데 실제 폴리곤은 대칭이면 south 값 사용 + if (symmetricWidthRef?.south && calcAreaWidth < symmetricWidthRef.south * 0.9 && reallySymmetric) { // flowLines 좌표도 보정 (중심점 유지하면서 너비 확장) const center = (flowLines.right.x1 + flowLines.left.x1) / 2 const halfWidth = symmetricWidthRef.south / 2 @@ -2640,6 +2630,8 @@ export function useModuleBasicSetting(tabNum) { // 대칭 지붕을 위해 west의 calcAreaWidth 저장 (east에서 참조) if (symmetricWidthRef && moduleIndex === 0) { symmetricWidthRef.west = calcAreaWidth + const westBBox = turf.bbox(polygonToTurfPolygon(moduleSetupSurface)) + symmetricWidthRef.westActualHeight = westBBox[3] - westBBox[1] } //단수지정 자동이면 @@ -2824,8 +2816,12 @@ export function useModuleBasicSetting(tabNum) { // 동쪽: 서쪽과 동일한 방식으로 계산 (대칭을 위해) let calcAreaWidth = Math.abs(flowLines.bottom.y1 - flowLines.top.y1) //아래에서 y에서 위를 y를 뺀 가운데를 찾는 로직 - // 대칭 지붕: west의 calcAreaWidth가 있고 east의 값이 west보다 10% 이상 작으면 west 값 사용 - if (symmetricWidthRef?.west && calcAreaWidth < symmetricWidthRef.west * 0.9) { + // 진짜 대칭 판정: 동면 실제 폴리곤 세로높이가 서면 실제 높이와 비슷(>=90%)할 때만 보정 (T자형 비대칭 제외) + const eastBBox = turf.bbox(polygonToTurfPolygon(moduleSetupSurface)) + const eastActualHeight = eastBBox[3] - eastBBox[1] + const eastReallySymmetric = !!(symmetricWidthRef?.westActualHeight && eastActualHeight >= symmetricWidthRef.westActualHeight * 0.9) + // 대칭 지붕: west의 calcAreaWidth가 있고 east의 flowLines높이가 west보다 10% 이상 작은데 실제 폴리곤은 대칭이면 west 값 사용 + if (symmetricWidthRef?.west && calcAreaWidth < symmetricWidthRef.west * 0.9 && eastReallySymmetric) { // flowLines 좌표도 보정 (중심점 유지하면서 높이 확장) const center = (flowLines.bottom.y1 + flowLines.top.y1) / 2 const halfHeight = symmetricWidthRef.west / 2 @@ -2940,8 +2936,8 @@ export function useModuleBasicSetting(tabNum) { } } - // 대칭 지붕을 위한 calcAreaWidth 공유 객체 (south→north, west→east) - const symmetricWidthRef = { south: null, west: null } + // 대칭 지붕을 위한 calcAreaWidth 공유 객체 (south→north, west→east) + 실제 폴리곤 크기(진짜 대칭 판정용) + const symmetricWidthRef = { south: null, west: null, southActualWidth: null, westActualHeight: null } // 대칭 보정을 위해 south/west가 north/east보다 먼저 처리되도록 정렬 const directionOrder = { south: 0, west: 1, north: 2, east: 3 } @@ -3049,6 +3045,7 @@ export function useModuleBasicSetting(tabNum) { icon: 'warning', }) } + } const coordToTurfPolygon = (points) => { diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 7fde855b..6eb43419 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -25,6 +25,10 @@ import { applyKerabOffsetSurgical } from '@/util/kerab-offset-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) { const canvas = useRecoilValue(canvasState) @@ -154,6 +158,136 @@ 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 이 떨어지거나 엉뚱한 코너로 횡단하면 점유수 변화). + 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') + } + } 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 흐름 보호. @@ -241,6 +375,53 @@ 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 정리)를 망가뜨림. @@ -410,11 +591,16 @@ export function useEavesGableEdit(id) { 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 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))) + 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 @@ -427,14 +613,10 @@ export function useEavesGableEdit(id) { // 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, - }), - ) + 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) => { @@ -463,7 +645,8 @@ export function useEavesGableEdit(id) { } } 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) @@ -486,9 +669,18 @@ export function useEavesGableEdit(id) { 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) + 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') + 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 @@ -535,20 +727,10 @@ export function useEavesGableEdit(id) { t1, t2, h1: h1Match - ? { - label: labelOf(h1Match.hip), - near: h1Match.near, - far: h1Match.far, - dist: Math.round(h1Match.dist * 100) / 100, - } + ? { 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, - } + ? { label: labelOf(h2Match.hip), near: h2Match.near, far: h2Match.far, dist: Math.round(h2Match.dist * 100) / 100 } : null, }), ) @@ -562,7 +744,6 @@ export function useEavesGableEdit(id) { apex = null } } - logger.log( '[KERAB-SIMPLE] apex ' + JSON.stringify({ @@ -570,7 +751,7 @@ export function useEavesGableEdit(id) { parallel: !apex, }), ) - // [KERAB-PARALLEL-FULLALGO 2026-05-21] 평행(apex=null) 도 풀 알고리즘으로 처리. +// [KERAB-PARALLEL-FULLALGO 2026-05-21] 평행(apex=null) 도 풀 알고리즘으로 처리. // 폴리곤 경로 + extender 확장 + 반사/meet/apex/kLine — h1·h2 만나지 않더라도 // 내부 라인(path hips/ridges) 은 삭제, 접점 extender 는 인쪽 확장. // 자연 만남(condition 1) 만 단축: apex 존재 + h*.far ≈ apex. @@ -595,10 +776,7 @@ export function useEavesGableEdit(id) { 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, + x1: p.line.x1, y1: p.line.y1, x2: p.line.x2, y2: p.line.y2, })) : null, }), @@ -635,7 +813,9 @@ export function useEavesGableEdit(id) { // 양 끝점 둘 다 후보로 push → raycast 가 valley 측을 자동 결정. const valleyPlannedEndpoints = [] if (polygonPath) { - const matchingRoofLine = Array.isArray(roof.lines) ? roof.lines.find((rl) => rl && rl.attributes?.wallLine === target.id) : null + 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({ @@ -643,12 +823,7 @@ export function useEavesGableEdit(id) { targetIdx: target.idx, found: !!matchingRoofLine, rl: matchingRoofLine - ? { - x1: matchingRoofLine.x1, - y1: matchingRoofLine.y1, - x2: matchingRoofLine.x2, - y2: matchingRoofLine.y2, - } + ? { x1: matchingRoofLine.x1, y1: matchingRoofLine.y1, x2: matchingRoofLine.x2, y2: matchingRoofLine.y2 } : null, }), ) @@ -679,6 +854,7 @@ export function useEavesGableEdit(id) { 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)] @@ -702,13 +878,7 @@ export function useEavesGableEdit(id) { 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, - }) + else if (dB <= 1.0) allAtJ.push({ line: il, near: b, far: a, isHip: il.name === LINE_TYPE.SUBLINE.HIP }) } return { jp, exts: allAtJ } }) @@ -759,6 +929,7 @@ export function useEavesGableEdit(id) { logger.log('[KERAB-SIMPLE] no extenders — fallback attr-only') target.set({ attributes }) applyKerabAttributeOnlyPattern() + runKerabRuleCheck(roof, 'forward', kerabBeforeSnap) return } // 인쪽 방향 검증: extender 자연 방향(near→far)의 반대(near→inward) 와 일치해야 함. @@ -775,7 +946,12 @@ export function useEavesGableEdit(id) { // 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, + (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 @@ -873,26 +1049,15 @@ export function useEavesGableEdit(id) { 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 + - '}', + '[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 + - '}', + '[KERAB-VALLEY-EXT-WALL] pre label=' + ep.label + + ' end={' + Math.round(bestPt.x * 100) / 100 + ',' + Math.round(bestPt.y * 100) / 100 + '}', ) } if (bestPt) { @@ -943,12 +1108,7 @@ export function useEavesGableEdit(id) { 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), - }, + 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 @@ -976,9 +1136,7 @@ export function useEavesGableEdit(id) { 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, + a: ea, b: eb, point: ip, minDist: Math.min(dA, dB), bothHips: ea.isHip && eb.isHip, }) @@ -1013,9 +1171,7 @@ export function useEavesGableEdit(id) { } if (valleyExtMeets.length > 0) { logger.log( - '[KERAB-VALLEY-EXT-BARRIER] meets iter=' + - iter + - ' ' + + '[KERAB-VALLEY-EXT-BARRIER] meets iter=' + iter + ' ' + JSON.stringify( valleyExtMeets.map((v) => ({ ext: labelOf(v.ext.line) || (v.ext.isHip ? 'H' : 'R'), @@ -1027,7 +1183,11 @@ export function useEavesGableEdit(id) { } // [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), + (il) => + il && + il.name === LINE_TYPE.SUBLINE.RIDGE && + il.lineName !== 'kerabPatternRidge' && + !polygonLines.includes(il), ) const ridgeMeets = [] for (const ext of unresolved) { @@ -1045,7 +1205,11 @@ export function useEavesGableEdit(id) { // 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, + (il) => + il && + il.name === LINE_TYPE.SUBLINE.HIP && + !polygonLines.includes(il) && + il.visible !== false, ) const staticMeets = [] for (const ext of unresolved) { @@ -1065,9 +1229,7 @@ export function useEavesGableEdit(id) { } if (staticMeets.length > 0) { logger.log( - '[KERAB-FIXPOINT-PHASE-A] staticMeets iter=' + - iter + - ' ' + + '[KERAB-FIXPOINT-PHASE-A] staticMeets iter=' + iter + ' ' + JSON.stringify( staticMeets.map((s) => ({ ext: labelOf(s.ext.line) || (s.ext.isHip ? 'H' : 'R'), @@ -1097,20 +1259,12 @@ export function useEavesGableEdit(id) { 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 }, - }) + 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 + - ' ' + + '[KERAB-FIXPOINT-STEP2] drawnMeets iter=' + iter + ' ' + JSON.stringify( drawnMeets.map((d) => ({ ext: d.ext.line?.attributes?.label || (d.ext.isHip ? 'H' : 'R'), @@ -1122,22 +1276,10 @@ export function useEavesGableEdit(id) { } 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, - }) + 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, - }) + 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 생성 안 됨. @@ -1204,14 +1346,8 @@ export function useEavesGableEdit(id) { } if (capFilteredOut.length > 0) { logger.log( - '[KERAB-ROOF-MAX-INWARD] iter=' + - iter + - ' filtered=' + - capFilteredOut.length + - '/' + - candidatesBeforeCap + - ' ' + - JSON.stringify(capFilteredOut), + '[KERAB-ROOF-MAX-INWARD] iter=' + iter + ' filtered=' + capFilteredOut.length + + '/' + candidatesBeforeCap + ' ' + JSON.stringify(capFilteredOut), ) } candidates.sort((a, b) => a.minDist - b.minDist) @@ -1251,8 +1387,7 @@ export function useEavesGableEdit(id) { 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), + 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 @@ -1279,8 +1414,7 @@ export function useEavesGableEdit(id) { // 정적 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), + 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 @@ -1318,10 +1452,9 @@ export function useEavesGableEdit(id) { // 부모(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, - )) + (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 }, @@ -1397,31 +1530,15 @@ export function useEavesGableEdit(id) { } } 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 + '[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.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 + + Math.round(bestSrc.b.x * 100) / 100 + ',' + Math.round(bestSrc.b.y * 100) / 100 + ']' : 'none'), ) @@ -1545,16 +1662,10 @@ export function useEavesGableEdit(id) { 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 { 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 - } + else { line.x1 = cut.point.x; line.y1 = cut.point.y } } // purge ext.line === cut.line BEFORE deletion: capture drawn segments. const purgedSegments = [] @@ -1591,15 +1702,14 @@ export function useEavesGableEdit(id) { // 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 }, - }, + { 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)) + 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) @@ -1639,45 +1749,25 @@ export function useEavesGableEdit(id) { 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 - } + 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, - }, - ) + 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 (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] 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'), ) } } @@ -1693,7 +1783,9 @@ export function useEavesGableEdit(id) { 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) + 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 } }) @@ -1712,25 +1804,12 @@ export function useEavesGableEdit(id) { } } 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 + - '}', + '[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 + '}', ) } } @@ -1741,7 +1820,7 @@ export function useEavesGableEdit(id) { 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 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 ' + @@ -1753,10 +1832,7 @@ export function useEavesGableEdit(id) { })), drawKLine, markerApex: markerApex - ? { - x: Math.round(markerApex.x * 100) / 100, - y: Math.round(markerApex.y * 100) / 100, - } + ? { 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, @@ -1832,47 +1908,17 @@ export function useEavesGableEdit(id) { wallHit = ip } } - let ridgeStop = null - let ridgeT = Infinity - // [KERAB-VALLEY-EXT-RIDGE-STOP 2026-05-29] 룰 1: 원래 ridge 만 stop (roof 측 post 경로). - 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 (!isOnSegV(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) { - bestStop = ridgeStop + // [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-EXT-RIDGE-STOP] post label=' + - ep.label + - ' ridgeT=' + - Math.round(ridgeT * 100) / 100 + - ' stop={' + - Math.round(bestStop.x * 100) / 100 + - ',' + - Math.round(bestStop.y * 100) / 100 + - '}', - ) - } else if (wallHit) { - bestStop = wallHit - logger.log( - '[KERAB-VALLEY-EXT-WALL] post label=' + - ep.label + - ' end={' + - Math.round(bestStop.x * 100) / 100 + - ',' + - Math.round(bestStop.y * 100) / 100 + - '}', + '[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) { @@ -1896,83 +1942,178 @@ export function useEavesGableEdit(id) { // ── 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')) - 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) + 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 - } + // [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). + } + // 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). @@ -1989,6 +2130,16 @@ export function useEavesGableEdit(id) { 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 출발점 룰은 케라바 패턴 한정. @@ -2047,104 +2198,7 @@ export function useEavesGableEdit(id) { '[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 } }), ) - 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 } - // wEnd(원 ray hit) 가 wEndProj 보다 ray 방향으로 더 멀리 있는지 — newWStart 기준 투영 비교. - const tProj = (wEndProj.x - newWStart.x) * wUx + (wEndProj.y - newWStart.y) * wUy - const tEndR = (wEnd.x - newWStart.x) * wUx + (wEnd.y - newWStart.y) * wUy - buildOverlapLine(newWStart, wEndProj, '-wall') - buildOverlapLine(vStart, newWStart, '-bridge-start') - buildOverlapLine(vEnd, wEndProj, '-bridge-end') - if (tEndR > tProj + 0.5) { - buildOverlapLine(wEndProj, wEnd, '-extend') - } - logger.log( - '[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: tEndR > tProj + 0.5, - }), - ) - - // [KERAB-VALLEY-EXT-TRIM 2026-05-29] 룰 2: vExt 가 ridge 와 교차하면 - // wallExt(`:`) 측(=vExt 와 가까운 영역) ridge 부분 절삭. - // ridge 의 V apex 는 vExt 와 먼 끝점 (Y 출발점). 살아남는 쪽 = V apex 쪽. - // 방향 판정: 절삭 방향 = wallExt 중점 - vExt 중점 (wallExt 측). dot 양수면 절삭 대상. - const veMid = { x: (vStart.x + vEnd.x) / 2, y: (vStart.y + vEnd.y) / 2 } - const wsMid = { x: (newWStart.x + wEndProj.x) / 2, y: (newWStart.y + wEndProj.y) / 2 } - const bDirX = wsMid.x - veMid.x - const bDirY = wsMid.y - veMid.y - const isBSide = (px, py) => (px - veMid.x) * bDirX + (py - veMid.y) * bDirY > 0 - const trimCascadePts = [] - const newTrimRecords = [] // cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide const cascadeHidden = new Set() let cascadeGuard = 0 @@ -2154,89 +2208,25 @@ export function useEavesGableEdit(id) { const pt = entry.pt || entry for (const il of roof.innerLines || []) { if (!il || il.visible === false) continue - // [KERAB-VALLEY-EXT-TRIM 2026-05-29] trim 대상 = 원래 ridge/hip + 마루 확장(ExtRidge) + kLine(kerabPatternRidge). - // stop 룰(룰 1) 은 ridge 만, trim 룰(룰 2) 은 hip 도 포함 — vExt 는 hip 통과 후 절삭. - // 절삭 방향은 V apex 우선 룰 (아래) 로 결정. - const isRidge = - il.name === LINE_TYPE.SUBLINE.RIDGE && - (il.lineName === 'ridge' || il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge') - const isHip = - il.name === LINE_TYPE.SUBLINE.HIP && - (il.lineName === 'hip' || il.lineName === 'kerabPatternHip' || il.lineName === 'kerabPatternExtHip') - if (!isRidge && !isHip) continue - const ip = lineLineIntersection( - vStart, - vEnd, - { x: il.x1, y: il.y1 }, - { - x: il.x2, - y: il.y2, - }, - ) - if (!ip) continue - if (!isOnSegV(ip, vStart.x, vStart.y, vEnd.x, vEnd.y)) continue - if (!isOnSegV(ip, il.x1, il.y1, il.x2, il.y2)) continue - // [KERAB-VALLEY-EXT-TRIM 2026-05-29] 절삭 방향 — V apex 우선 룰. - // V apex = 케라바 확장 hip(kerabPatternHip/kerabPatternExtHip) 두 개의 교점. - // 원래 hip(lineName='hip') 모임은 V apex 아님 — Y 출발점 룰은 케라바 패턴 한정. - // 1) 한 끝만 V apex → 그 V apex 측 살림, 반대 끝 절삭. - // 2) 양 끝 모두 V apex → vExt 와 먼 끝 살림 (가까운 끝 절삭). - // 3) 둘 다 V apex 아님 → 기존 fallback (B 측 = wallExt 측 절삭). - const isVApex = (px, py) => { - let cnt = 0 - for (const other of roof.innerLines || []) { - if (!other || other === il) continue - if (other.visible === false) continue - if (other.name !== LINE_TYPE.SUBLINE.HIP) continue - if (other.lineName !== 'kerabPatternHip' && other.lineName !== 'kerabPatternExtHip') continue - if (Math.hypot(other.x1 - px, other.y1 - py) < 1.0) cnt++ - else if (Math.hypot(other.x2 - px, other.y2 - py) < 1.0) cnt++ - if (cnt >= 2) return true - } - return false - } - const e1V = isVApex(il.x1, il.y1) - const e2V = isVApex(il.x2, il.y2) - const e1B = isBSide(il.x1, il.y1) - const e2B = isBSide(il.x2, il.y2) - let trimEnd = 0 - if (e1V && !e2V) - trimEnd = 2 // e1 V apex 살림, e2 절삭 - else if (!e1V && e2V) trimEnd = 1 - else if (e1V && e2V) { - // 양 끝 모두 V apex → vExt 와 가까운 끝 절삭 - const d1 = Math.hypot(il.x1 - veMid.x, il.y1 - veMid.y) - const d2 = Math.hypot(il.x2 - veMid.x, il.y2 - veMid.y) - trimEnd = d1 < d2 ? 1 : 2 - } else { - // 둘 다 V apex 아님 → 기존 B 측 fallback - if (e1B && !e2B) trimEnd = 1 - else if (!e1B && e2B) trimEnd = 2 - else continue - } - const oldPt = trimEnd === 1 ? { x: il.x1, y: il.y1 } : { x: il.x2, y: il.y2 } + 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 || {}) } - if (trimEnd === 1) il.set({ x1: ip.x, y1: ip.y }) - else il.set({ x2: ip.x, y2: ip.y }) + il.visible = false if (typeof il.setCoords === 'function') il.setCoords() - const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) - il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } - trimCascadePts.push(oldPt) newTrimRecords.push({ line: il, - end: trimEnd, - oldPt, - newPt: { x: ip.x, y: ip.y }, + hide: true, + originalVisible, 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 }, - }), + '[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 } }) } @@ -2314,43 +2304,6 @@ export function useEavesGableEdit(id) { segCount: newSegs.length + 1, }) } - // cascade — 절삭된 끝점에 붙은 ridge 확장(kerabPatternExtRidge) 만 BFS hide - const cascadeHidden = new Set() - let cascadeGuard = 0 - while (trimCascadePts.length && cascadeGuard++ < 200) { - const pt = trimCascadePts.shift() - if (!pt) continue - for (const il of roof.innerLines || []) { - if (!il || il.visible === false) continue - if (cascadeHidden.has(il)) continue - if (il.lineName !== 'kerabPatternExtRidge') continue - const m1 = Math.hypot(il.x1 - pt.x, il.y1 - pt.y) < 1.0 - const m2 = Math.hypot(il.x2 - pt.x, il.y2 - pt.y) < 1.0 - if (!m1 && !m2) continue - cascadeHidden.add(il) - const originalVisible = il.visible !== false - const originalAttrs = { ...(il.attributes || {}) } - il.visible = false - if (typeof il.setCoords === 'function') il.setCoords() - newTrimRecords.push({ - line: il, - hide: true, - originalVisible, - originalAttrs, - }) - logger.log( - '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + - JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), - ) - trimCascadePts.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) - } - } - // [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적. - // 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리. - // 한 mousedown 에 여러 vExt 가 호출될 수 있어 concat. - if (newTrimRecords.length) { - target.__valleyExtTrims = (target.__valleyExtTrims || []).concat(newTrimRecords) - } } // [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적. // 기존 revert 로직(2566~) 이 hide/end/oldPt/originalAttrs 그대로 처리. @@ -2387,103 +2340,91 @@ export function useEavesGableEdit(id) { 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, - }), - ) + 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 } @@ -2526,14 +2467,7 @@ export function useEavesGableEdit(id) { 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, - }), + 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 }) } @@ -2554,6 +2488,7 @@ export function useEavesGableEdit(id) { if (trimRecords.length) target.__valleyExtTrims = trimRecords } dumpInnerLineSnapshot('AFTER') + runKerabRuleCheck(roof, 'forward', kerabBeforeSnap) logger.log('[KERAB-SIMPLE] applied (sequential, drawKLine=' + drawKLine + ', extraApexes=' + extraApexes.length + ')') return } @@ -2561,6 +2496,7 @@ export function useEavesGableEdit(id) { logger.log('[KERAB-SIMPLE] no resolution possible — fallback attr-only') target.set({ attributes }) applyKerabAttributeOnlyPattern() + runKerabRuleCheck(roof, 'forward', kerabBeforeSnap) return } // condition 1: 자연 만남 — ext 없이 hip 제거 + kLine @@ -2576,13 +2512,14 @@ export function useEavesGableEdit(id) { 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 } @@ -2592,8 +2529,12 @@ export function useEavesGableEdit(id) { // 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) + 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) { @@ -2609,6 +2550,9 @@ export function useEavesGableEdit(id) { // 증상: (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 }) const wA = { x: target.x1, y: target.y1 } const wB = { x: target.x2, y: target.y2 } @@ -2634,35 +2578,83 @@ export function useEavesGableEdit(id) { 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 } - // hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음. - const dx = outerOld.x - innerEnd.x - const dy = outerOld.y - innerEnd.y - const dlen = Math.hypot(dx, dy) - if (dlen < 1e-6) { - delete il.__kerabRevertOuterWhich - delete il.__kerabRevertOuterSide - continue + // [KERAB-REVERT-SHARED-ENDPOINT-GUARD 2026-06-10] outer(확장) 끝이 다른 내부선과 + // 공유되면 확장하지 않는다. 골짜기(valley) 내부 hip 은 양 끝이 skeleton 교점이라 outer 도 + // 공유 → 자동 제외. 게이블/코너 hip 의 outer 는 free(roofLine 변에 홀로 도달) → 확장. + // 판정이 출폭·snapshot 좌표와 무관한 구조 기준이라 다중 출폭 변경에도 안 깨진다. + // (구 c1/c2 가드는 c1/c2 를 현재 출폭으로, outerOld 를 forward 당시 출폭으로 잡아 + // 출폭이 여러 번 바뀌면 게이블 hip 을 interior 로 오판 → SK 확장이 중간에 멈췄다.) + 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), + ) + // [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] TYPE 출신 게이블 hip 의 outer 끝은 폴리곤 코너(roofLine 꼭짓점) + // 라 인접 면경계선과 공유 → outerShared 가 true 가 돼 확장이 스킵됐다(처마변경 실패: hip 이 옛 코너에 멈춤). + // 골짜기 내부 hip 의 outer 는 폴리곤 경계에 없는 skeleton 교점이다. 따라서 outerOld 가 (이동 전) 폴리곤 + // 꼭짓점이면 게이블 코너 hip 으로 보고 확장을 강제, 공유여도 스킵하지 않는다. + const VERT_TOL = 2.0 + // outerOld 가 (이동 전) 폴리곤 꼭짓점이면 그 인덱스를 잡는다. surgical 은 점 순서·개수를 보존하므로 + // 같은 인덱스의 새 roof.points 가 곧 이동 후 코너 = 게이블 hip 의 새 outer 끝점. + let vtxIdx = -1 + for (let vi = 0; vi < oldPolyPoints.length; vi++) { + const p = oldPolyPoints[vi] + if (p && Math.hypot(p.x - outerOld.x, p.y - outerOld.y) < VERT_TOL) { + vtxIdx = vi + break + } } - const ux = dx / dlen - const uy = dy / dlen - // wL 코너에서 45° 방향으로 ray → 새 rL 변 (roof.points 는 surgical 후 새 출폭) 의 첫 hit. - 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) { + const outerOnVertex = vtxIdx >= 0 + if (outerShared && !outerOnVertex) { logger.log( - '[KERAB-REVERT-EXTEND-45] no-hit ' + - JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }), + '[KERAB-REVERT-EXTEND-45] skip (interior hip — outer shared) ' + + JSON.stringify({ lineName: il.lineName, which, side, outerOld }), ) delete il.__kerabRevertOuterWhich delete il.__kerabRevertOuterSide continue } - const hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT } + let hit + // [KERAB-REVERT-VERTEX-EXTEND 2026-06-11] 게이블 코너 hip: 45° ray-cast 는 출폭이 클 때 인접 + // 변에 먼저 닿아(예: 출폭 80 → -803 에서 멈춤) 코너(-839)에 못 미친다 → rLine 겹침/틈. + // surgical 후 동일 인덱스 코너로 직접 스냅해 항상 새 roofLine 꼭짓점까지 도달시킨다. + if (outerOnVertex && rps.length === oldPolyPoints.length && rps[vtxIdx]) { + hit = { x: rps[vtxIdx].x, y: rps[vtxIdx].y } + } else { + // hip 방향 = inner → outer (snapshot 좌표 기반 = 45°). 동일 직선이면 wL 코너도 위에 있음. + const dx = outerOld.x - innerEnd.x + const dy = outerOld.y - innerEnd.y + const dlen = Math.hypot(dx, dy) + if (dlen < 1e-6) { + delete il.__kerabRevertOuterWhich + delete il.__kerabRevertOuterSide + continue + } + const ux = dx / dlen + const uy = dy / dlen + // wL 코너에서 45° 방향으로 ray → 새 rL 변 (roof.points 는 surgical 후 새 출폭) 의 첫 hit. + 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( + '[KERAB-REVERT-EXTEND-45] no-hit ' + + JSON.stringify({ lineName: il.lineName, which, side, wCorner, dir: { ux, uy } }), + ) + delete il.__kerabRevertOuterWhich + delete il.__kerabRevertOuterSide + continue + } + hit = { x: wCorner.x + ux * bestT, y: wCorner.y + uy * bestT } + } 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 @@ -2696,6 +2688,7 @@ export function useEavesGableEdit(id) { delete il.__kerabRevertOuterWhich delete il.__kerabRevertOuterSide } + runKerabRuleCheck(roof, 'revert', kerabRevertBeforeSnap) } // [2240 KERAB-PARALLEL-HIPS 2026-05-19] forward 가 parallel-hips 였으면 target 에 스냅샷이 붙어있음 → hip 2개 복원 if (Array.isArray(target.__kerabParallelHipsSnapshot)) { @@ -2706,18 +2699,83 @@ export function useEavesGableEdit(id) { 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) + 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() - logger.log('[KERAB-REVERT] applied (kLineOnly via targetId) ' + JSON.stringify({ ok })) + 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 가 이미 처리 → 제외. + moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes) + // [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장. + extendTypeGableHipsStraightToRoofLine(roof, target, apexRes) + } + 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) @@ -2756,10 +2814,7 @@ export function useEavesGableEdit(id) { } if (wall?.baseLines) { savedBaseLineCoords[roof.id] = wall.baseLines.map((bl) => ({ - x1: bl.x1, - y1: bl.y1, - x2: bl.x2, - y2: bl.y2, + x1: bl.x1, y1: bl.y1, x2: bl.x2, y2: bl.y2, startPoint: bl.startPoint ? { ...bl.startPoint } : undefined, endPoint: bl.endPoint ? { ...bl.endPoint } : undefined, })) @@ -2788,10 +2843,8 @@ export function useEavesGableEdit(id) { roof.wall.baseLines.forEach((bl, i) => { if (!savedBL[i]) return bl.set({ - x1: savedBL[i].x1, - y1: savedBL[i].y1, - x2: savedBL[i].x2, - y2: savedBL[i].y2, + x1: savedBL[i].x1, y1: savedBL[i].y1, + x2: savedBL[i].x2, y2: savedBL[i].y2, startPoint: savedBL[i].startPoint, endPoint: savedBL[i].endPoint, }) @@ -2858,6 +2911,7 @@ export function useEavesGableEdit(id) { 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 @@ -3067,22 +3121,10 @@ export function useEavesGableEdit(id) { 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 } - } + 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 } @@ -3266,6 +3308,9 @@ export function useEavesGableEdit(id) { 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 { @@ -3460,13 +3505,259 @@ export function useEavesGableEdit(id) { 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 } + // 마루의 케라바변쪽 끝점 식별 + 내부(inward) 방향 도출. + 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) + const ridgeInner = midIsA ? rB : rA + let ix = ridgeInner.x - mid.x + let iy = ridgeInner.y - mid.y + const ilen = Math.hypot(ix, iy) || 1 + ix /= ilen + iy /= ilen + const apex = { x: mid.x + ix * (L / 2), y: mid.y + iy * (L / 2) } + // 마루 단축: 케라바변쪽 끝점 → apex. + if (midIsA) ridge.set({ x1: apex.x, y1: apex.y }) + else ridge.set({ x2: apex.x, y2: apex.y }) + 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→apex, wB→apex. pts=[corner, apex] 라 corner 끝(x1,y1)이 outer(=which 1). + const mkHip = (corner, side) => { + const pts = [corner.x, corner.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, + // [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') + const hip2 = mkHip(wB, 'B') + 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, inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) } }), + ) + 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 + const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2) + // 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동. + const relocate = (px, py) => { + 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 }) + 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-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) + const labels = canvas + .getObjects() + .filter((obj) => obj.name === KERAB_HALF_LABEL_NAME && obj.parentId === parentId) labels.forEach((obj) => canvas.remove(obj)) } @@ -3519,7 +3810,9 @@ export function useEavesGableEdit(id) { 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) + 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 } @@ -3809,10 +4102,7 @@ export function useEavesGableEdit(id) { for (const hip of hipsToRemove) { if (!hip) continue snapshot.push({ - x1: hip.x1, - y1: hip.y1, - x2: hip.x2, - y2: hip.y2, + x1: hip.x1, y1: hip.y1, x2: hip.x2, y2: hip.y2, attributes: hip.attributes ? { ...hip.attributes } : null, lineName: hip.lineName, __extended: hip.__extended, @@ -3891,12 +4181,7 @@ export function useEavesGableEdit(id) { // [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 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, @@ -3944,7 +4229,6 @@ export function useEavesGableEdit(id) { 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) diff --git a/src/hooks/useCanvasEvent.js b/src/hooks/useCanvasEvent.js index 50e1e65d..d126ff03 100644 --- a/src/hooks/useCanvasEvent.js +++ b/src/hooks/useCanvasEvent.js @@ -1,7 +1,8 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' +import { fabric } from 'fabric' import { useRecoilState, useRecoilValue } from 'recoil' import { v4 as uuidv4 } from 'uuid' -import { canvasSizeState, canvasState, canvasZoomState, currentMenuState, currentObjectState } from '@/store/canvasAtom' +import { canvasSizeState, canvasState, canvasZoomState, currentMenuState, currentObjectState, isManualModuleSetupState } from '@/store/canvasAtom' import { QPolygon } from '@/components/fabric/QPolygon' import { fontSelector } from '@/store/fontAtom' import { MENU, POLYGON_TYPE } from '@/common/common' @@ -19,9 +20,12 @@ export function useCanvasEvent() { const [shouldCenter, setShouldCenter] = useState(false) // 중앙 정렬 플래그 추가 const lengthTextOption = useRecoilValue(fontSelector('lengthText')) const currentMenu = useRecoilValue(currentMenuState) + const isManualModuleSetup = useRecoilValue(isManualModuleSetupState) const { changeCorridorDimensionText } = useText() const { setPolygonLinesActualSize } = usePolygon() const { undo, redo, saveSnapshot } = useUndoRedo() + // [MODULE-MULTI-SELECT 2026-06-10] 모듈 복수선택 refine 재진입 가드 (rebuild 중첩 방지) + const refiningRef = useRef(false) useEffect(() => { canvas?.setZoom(canvasZoom / 100) @@ -40,7 +44,16 @@ export function useCanvasEvent() { useEffect(() => { attachDefaultEventOnCanvas() - }, [currentMenu]) + // [MODULE-MULTI-SELECT 2026-06-10] 모듈 기본설정 메뉴에서만 드래그 박스/복수선택 활성화. + // 그 외 메뉴는 selection:false 유지(지붕선·마루 등 다른 레이어가 드래그에 잡히는 부작용 방지). + // [MODULE-MULTI-SELECT 2026-06-10] 手動配置 활성 중에는 드래그 모드 중지(배치 클릭과 충돌 방지). + if (canvas) { + const isModuleMenu = currentMenu === MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING + const enableDrag = isModuleMenu && !isManualModuleSetup + canvas.selection = enableDrag + if (!enableDrag) canvas.discardActiveObject() + } + }, [currentMenu, isManualModuleSetup]) // 키보드 이벤트 등록 (Cmd+Z / Ctrl+Z: undo, Shift+Cmd+Z / Shift+Ctrl+Z: redo) useEffect(() => { @@ -167,13 +180,13 @@ export function useCanvasEvent() { target.set({ top: y }) } } else if (target.parentDirection === 'top' || target.parentDirection === 'bottom') { - const minY = target.minY - const maxY = target.maxY + const miny = target.miny + const maxy = target.maxy - if (target.top <= minY) { - target.set({ left: x, top: minY }) - } else if (target.top >= maxY) { - target.set({ left: x, top: maxY }) + if (target.top <= miny) { + target.set({ left: x, top: miny }) + } else if (target.top >= maxy) { + target.set({ left: x, top: maxy }) } else { target.set({ left: x }) } @@ -216,6 +229,7 @@ export function useCanvasEvent() { }) canvas.renderAll() } + refineModuleSelection() }, cleared: (e) => { setCurrentObject(null) @@ -230,6 +244,10 @@ export function useCanvasEvent() { if (obj.name === POLYGON_TYPE.MODULE && currentMenu === MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) { obj.set({ strokeWidth: 0.3 }) } + // [MODULE-MULTI-SELECT 2026-06-10] 선택 해제 시 모듈 이동잠금 복원(다른 메뉴에서 드래그 방지) + if (obj.name === POLYGON_TYPE.MODULE) { + obj.set({ lockMovementX: true, lockMovementY: true }) + } } }) } @@ -248,6 +266,10 @@ export function useCanvasEvent() { //모듈 미선택시 라인 두께 변경 obj.set({ strokeWidth: 0.3 }) } + // [MODULE-MULTI-SELECT 2026-06-10] 선택 해제 시 모듈 이동잠금 복원(다른 메뉴에서 드래그 방지) + if (obj.name === POLYGON_TYPE.MODULE) { + obj.set({ lockMovementX: true, lockMovementY: true }) + } } }) } @@ -265,9 +287,64 @@ export function useCanvasEvent() { } canvas.renderAll() + refineModuleSelection() }, } + // [MODULE-MULTI-SELECT 2026-06-10] 선택을 모듈만으로 정제, 이동잠금 해제 (회전·스케일은 잠금 유지). refiningRef 로 재구성 중첩 차단. + const refineModuleSelection = () => { + if (!canvas) return + if (currentMenu !== MENU.MODULE_CIRCUIT_SETTING.BASIC_SETTING) return + if (refiningRef.current) return + + const active = canvas.getActiveObject() + if (!active) return + + // [MODULE-DRAG-DISABLE 2026-06-12] 단일 모듈 선택: 마우스 이동 잠금 유지(드래그 박스 복수선택·Del 삭제만 허용). 회전·스케일도 잠금. + if (active.type !== 'activeSelection') { + if (active.name === POLYGON_TYPE.MODULE) { + active.set({ + lockMovementX: true, + lockMovementY: true, + lockRotation: true, + lockScalingX: true, + lockScalingY: true, + hasControls: false, + }) + canvas.requestRenderAll() + } + return + } + + const objs = active.getObjects() + const modules = objs.filter((obj) => obj.name === POLYGON_TYPE.MODULE) + + if (modules.length !== objs.length) { + refiningRef.current = true + canvas.discardActiveObject() + if (modules.length > 1) { + canvas.setActiveObject(new fabric.ActiveSelection(modules, { canvas })) + } else if (modules.length === 1) { + canvas.setActiveObject(modules[0]) + } + refiningRef.current = false + } + + // [MODULE-DRAG-DISABLE 2026-06-12] 복수선택 그룹도 이동 잠금 유지(드래그 박스 선택·Del 삭제만 허용). + const finalActive = canvas.getActiveObject() + if (finalActive?.type === 'activeSelection') { + finalActive.set({ + lockMovementX: true, + lockMovementY: true, + lockScalingX: true, + lockScalingY: true, + lockRotation: true, + hasControls: false, + }) + } + canvas.requestRenderAll() + } + const removeEventOnCanvas = () => { canvas?.off('object:added') canvas?.off('object:modified') diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js index f791451f..019c7538 100644 --- a/src/util/kerab-offset-surgical.js +++ b/src/util/kerab-offset-surgical.js @@ -40,6 +40,18 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {} const oldOffset = matchingRL.attributes?.offset ?? 0 if (Math.abs(newOffset - oldOffset) < 1e-3) return false + // [RIDGE-DIAG 2026-06-10] 자연 마루(ridge) 길이 간헐 변동 추적 — surgical 진입 전 before 스냅샷, + // 함수 종료 직전 길이 비교. 처마/케라바/revert(skipInnerLines 포함) 모든 경로 감시. + // 출폭 변경 시 자연 마루 끝점(apex)은 wallLine 안쪽이라 불변이 룰 → 길이 변동은 버그. + // kerabPatternRidge(kLine)/kerabPatternExtRidge 는 출폭에 따라 정상적으로 신축 → 감시 제외. + const isRidgeDiagLine = (il) => il && il.lineName === 'ridge' + const ridgeDiagBefore = new Map() + for (const il of roof.innerLines || []) { + if (isRidgeDiagLine(il)) { + ridgeDiagBefore.set(il, { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2, len: Math.hypot(il.x2 - il.x1, il.y2 - il.y1) }) + } + } + const N = roof.lines.length const prevRL = roof.lines[(idx - 1 + N) % N] const nextRL = roof.lines[(idx + 1) % N] @@ -118,15 +130,6 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {} // 원본 좌표는 il.__shrinkOrig 에 보관 — 첫 절삭 시점에 백업, 양 끝 모두 안쪽으로 복귀 시 삭제. // 매 surgical 호출마다 원본 기반으로 재계산하므로 출폭 증감 무관 idempotent. if (!skipInnerLines) { - const newAxisMid = { x: (newCorner1.x + newCorner2.x) / 2, y: (newCorner1.y + newCorner2.y) / 2 } - const OUTSIDE_TOL = 0.5 - const segOk = (ip) => { - const minX = Math.min(newCorner1.x, newCorner2.x) - OUTSIDE_TOL - const maxX = Math.max(newCorner1.x, newCorner2.x) + OUTSIDE_TOL - const minY = Math.min(newCorner1.y, newCorner2.y) - OUTSIDE_TOL - const maxY = Math.max(newCorner1.y, newCorner2.y) + OUTSIDE_TOL - return ip.x >= minX && ip.x <= maxX && ip.y >= minY && ip.y <= maxY - } // [KERAB-PATTERN-CORNER-SNAP 2026-06-01] 케라바 패턴 라인(kLine/ExtRidge/Hip/ExtHip)은 // roofLine corner 변경에 따라 끝점도 같이 이동. corner 일치뿐 아니라 옛 segment 위의 // 중간 점(예: kLine 끝점이 옛 roofLine 변 위)도 새 segment 의 동일 t 비율 점으로 매핑. @@ -154,31 +157,66 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {} if (t < -0.05 || t > 1.05) return null return { x: newCorner1.x + t * newSegDx, y: newCorner1.y + t * newSegDy } } - for (const il of roof.innerLines || []) { - if (!il) continue - - // 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점. - // 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 → - // 직각/형상 보존. 절삭/복원 흐름 skip. - if (isKerabPatternLine(il)) { + // [KERAB-APEX-INVARIANT 2026-06-10] skeleton 교점(junction) 판별식(기하, lineName 아님). + // 한 점이 다른 내부선 ≥1개의 끝점과 공유되면 그 점은 라인끼리 만나는 junction 이다. + // junction 은 wallLine 이 정하는 고정점이라 出幅 변경과 무관 → CORNER-SNAP/CASCADE 가 끌면 안 됨. + // degree≥1 임계: Y 형 apex(ridge·2hip, degree 2)뿐 아니라 케라바 병합 후 콜리니어 ridge 하나만 + // 남은 junction(degree 1)도 잡아야 한다. (degree≥2 였을 때 병합 apex 를 놓쳐 kLine 의 apex 끝을 + // 끌고 자연 ridge 까지 cascade → 마루 길이 증가 + revert R1-dangling 발생.) vExt 는 !isVExt 로 제외. + const APEX_SHARE_TOL = 2.0 + const skeletonApexDegree = (pt, selfLine) => { + let deg = 0 + for (const o of roof.innerLines || []) { + if (!o || o === selfLine || o.visible === false) continue + if ( + Math.hypot(o.x1 - pt.x, o.y1 - pt.y) < APEX_SHARE_TOL || + Math.hypot(o.x2 - pt.x, o.y2 - pt.y) < APEX_SHARE_TOL + ) + deg++ + } + return deg + } + // [KERAB-OFFSET-FUNCTIONIZE 2026-06-10] 라인 성격별 독립 핸들러. + // 두 핸들러는 공유 가변상태 없이 각자 il 만 갱신 → 골짜기↔일반 상호 간섭(whack-a-mole) 차단. + // 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점. + // 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 → + // 직각/형상 보존. 절삭/복원 흐름 skip. + const recomputeKerabPatternLine = (il) => { const oldX1 = il.x1 const oldY1 = il.y1 const oldX2 = il.x2 const oldY2 = il.y2 const np1 = mapToNewSeg({ x: il.x1, y: il.y1 }) const np2 = mapToNewSeg({ x: il.x2, y: il.y2 }) + // [KERAB-APEX-INVARIANT 2026-06-10] 한 끝만 roofLine 에 매핑될 때: + // 매핑 안 된 끝점이 junction(다른 내부선 ≥1 공유)이면 出幅 무관 고정점 → + // 이동 금지(junction 중심 pivot). vExt(수직 self-extension)는 우선순위상 평행이동 유지. + // 그 외(free 끝점, degree 0)만 같은 변위 평행이동(직각/형상 보존). + const isVExt = il.lineName === 'kerabPatternValleyExt' + let apexPivot = false if (np1 && np2) { il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y }) } else if (np1 && !np2) { - const dx = np1.x - il.x1 - const dy = np1.y - il.y1 - il.set({ x1: np1.x, y1: np1.y, x2: il.x2 + dx, y2: il.y2 + dy }) + if (!isVExt && skeletonApexDegree({ x: il.x2, y: il.y2 }, il) >= 1) { + il.set({ x1: np1.x, y1: np1.y }) + apexPivot = true + } else { + const dx = np1.x - il.x1 + const dy = np1.y - il.y1 + il.set({ x1: np1.x, y1: np1.y, x2: il.x2 + dx, y2: il.y2 + dy }) + } } else if (!np1 && np2) { - const dx = np2.x - il.x2 - const dy = np2.y - il.y2 - il.set({ x1: il.x1 + dx, y1: il.y1 + dy, x2: np2.x, y2: np2.y }) + if (!isVExt && skeletonApexDegree({ x: il.x1, y: il.y1 }, il) >= 1) { + il.set({ x2: np2.x, y2: np2.y }) + apexPivot = true + } else { + const dx = np2.x - il.x2 + const dy = np2.y - il.y2 + il.set({ x1: il.x1 + dx, y1: il.y1 + dy, x2: np2.x, y2: np2.y }) + } } if (typeof il.setCoords === 'function') il.setCoords() + if (isRidgeDiagLine(il)) il.__ridgeDiagBranch = `PATTERN-MAP np1=${!!np1} np2=${!!np2}` if (np1 || np2) { logger.log( '[KERAB-PATTERN-CORNER-SNAP] mapped ' + @@ -203,7 +241,10 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {} const projY = oldY1 + t * sdy return Math.hypot(px - projX, py - projY) < 1.0 } - if (Math.hypot(dxVExt, dyVExt) > 0.01) { + // [KERAB-APEX-INVARIANT 2026-06-10] apex pivot(매핑 안 된 끝점이 apex 라 고정)인 경우는 + // 라인이 평행이동이 아니라 apex 중심 회전이므로 cascade 금지 — apex 공유 ridge/hip 끌림 방지. + // vExt 등 실제 평행이동(양 끝 같은 변위)일 때만 옛 segment 위 끝점 전파. + if (!apexPivot && Math.hypot(dxVExt, dyVExt) > 0.01) { // cascade 대상: roof.innerLines + canvas 의 kerabValleyOverlapLine (innerLines 미포함). // overlap 보조선들은 vExt 의 끝점과 90도로 만나므로 vExt 이동에 같이 따라가야 정합. const overlapInCanvas = (canvas.getObjects() || []).filter( @@ -255,97 +296,76 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {} } } } - continue - } + } - const orig = il.__shrinkOrig || { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } - const orig1 = { x: orig.x1, y: orig.y1 } - const orig2 = { x: orig.x2, y: orig.y2 } - - // [KERAB-OFFSET-CORNER-SHORTCUT 2026-06-01] orig 끝점이 옛 corner 와 일치하면 새 corner 로 직접 snap. - // 일반 절삭 흐름은 il segment 와 새 roofLine 변의 lineLineIntersection 으로 ip 계산하는데, - // 끝점이 옛 corner 위에 있으면 ip 가 새 corner segment 밖으로 떨어져 segOk 가 reject → 절삭 실패. - // 그 결과 c1·c2 비대칭으로 kLine 대각선 변형됨. - const CORNER_SNAP_TOL_TRIM = 0.5 - let cornerSnapped = false - const trySnap = (epx, epy, which) => { - if (Math.hypot(epx - oldCorner1.x, epy - oldCorner1.y) < CORNER_SNAP_TOL_TRIM) { - if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } - if (which === 1) il.set({ x1: newCorner1.x, y1: newCorner1.y }) - else il.set({ x2: newCorner1.x, y2: newCorner1.y }) - cornerSnapped = true - return true + const recomputeNormalLine = (il) => { + // [KERAB-OFFSET-NORMAL-ABS 2026-06-10] 일반 라인(힙/마루/골짜기) 절대 재계산. + // 도메인: 모든 끝점은 둘 중 하나 — + // (a) anchor = apex(wallLine 이 정하는 skeleton 교점) 또는 변경 무관한 다른 roofLine 변 위 점 → 出幅 불변, 고정. + // (b) hit = 변경된 roofLine 변(oldCorner1→oldCorner2) 위의 처마 끝 → 새 변으로 다시 그린다. + // 변경 변과 무관한 라인(양 끝 모두 hit 아님)은 그대로. 증감·복원 분기 불필요(절대값이라 idempotent). + // __shrinkOrig snapshot 제거 — apex 가 이미 불변(현재값=원본)이라 스냅샷 없이 매번 절대 재계산. + // hit 재계산 규칙: + // - 끝점이 옛 corner 와 일치 → 새 corner 로 snap. (코너는 인접 변끼리의 교점이라 + // 라인 자기 방향 교점과 위치가 다르다 → 반드시 코너로 보정. 구 CORNER-SHORTCUT 의 기하 근거.) + // - 끝점이 변 중간(mid-edge) → anchor→hit 라인 방향 보존하며 새 변과의 교점. 방향은 평행 + // 出幅 변경에 불변(skeleton 각이등분선)이라 교점이 곧 새 처마 끝. 증가=확장/감소=절삭 동일식. + const CORNER_TOL = 0.5 + const atCorner = (px, py) => { + if (Math.hypot(px - oldCorner1.x, py - oldCorner1.y) < CORNER_TOL) return newCorner1 + if (Math.hypot(px - oldCorner2.x, py - oldCorner2.y) < CORNER_TOL) return newCorner2 + return null + } + const e1 = { x: il.x1, y: il.y1 } + const e2 = { x: il.x2, y: il.y2 } + const m1 = mapToNewSeg(e1) + const m2 = mapToNewSeg(e2) + // 변경 변에 닿지 않는 라인(마루 등) → 무동작. + if (!m1 && !m2) return + const c1 = atCorner(e1.x, e1.y) + const c2 = atCorner(e2.x, e2.y) + let n1 = e1 + let n2 = e2 + let branch = '' + if (m1 && m2 && !c1 && !c2) { + // 양 끝 모두 변 중간 → 변 위에 누운 라인(드묾). t 비율로 매핑. + n1 = m1 + n2 = m2 + branch = 'BOTH-MID' + } else { + if (c1) { + n1 = c1 + } else if (m1) { + // [KERAB-OFFSET-COLLINEAR 2026-06-11] 라인 방향이 변과 평행(변 위에 누운 면경계 세그먼트)이면 + // 교점이 없어 ip=null → 옛날엔 return 으로 라인 통째 포기(=옛 위치 ghost). 이때는 변이 + // 평행이동했으므로 mapToNewSeg(m1) 의 t-비율 투영이 곧 새 변 위 대응점 → 그걸로 폴백. + const ip = lineLineIntersection(e2, e1, newCorner1, newCorner2) + n1 = ip || m1 } - if (Math.hypot(epx - oldCorner2.x, epy - oldCorner2.y) < CORNER_SNAP_TOL_TRIM) { - if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } - if (which === 1) il.set({ x1: newCorner2.x, y1: newCorner2.y }) - else il.set({ x2: newCorner2.x, y2: newCorner2.y }) - cornerSnapped = true - return true + if (c2) { + n2 = c2 + } else if (m2) { + const ip = lineLineIntersection(e1, e2, newCorner1, newCorner2) + n2 = ip || m2 } - return false + branch = `c1=${!!c1} c2=${!!c2} m1=${!!m1} m2=${!!m2}` } - trySnap(orig.x1, orig.y1, 1) - trySnap(orig.x2, orig.y2, 2) - if (cornerSnapped) { - if (typeof il.setCoords === 'function') il.setCoords() - logger.log( - '[KERAB-OFFSET-CORNER-SHORTCUT] snapped ' + - JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), - ) - continue - } - - const d1 = (orig1.x - newAxisMid.x) * nx + (orig1.y - newAxisMid.y) * ny - const d2 = (orig2.x - newAxisMid.x) * nx + (orig2.y - newAxisMid.y) * ny - - // 양 끝 모두 새 polygon 안쪽 → 원본 복원 + 백업 삭제 - if (d1 <= OUTSIDE_TOL && d2 <= OUTSIDE_TOL) { - if (il.__shrinkOrig) { - il.set({ x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }) - if (typeof il.setCoords === 'function') il.setCoords() - logger.log( - '[KERAB-OFFSET-SHRINK-TRIM] restored ' + - JSON.stringify({ lineName: il.lineName, x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 }), - ) - delete il.__shrinkOrig - } - continue - } - - // 바깥 끝점 절삭 (원본 segment 와 새 roofLine 변의 교점) - let nx1 = orig.x1 - let ny1 = orig.y1 - let nx2 = orig.x2 - let ny2 = orig.y2 - let trimmed = false - if (d1 > OUTSIDE_TOL) { - const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2) - if (ip && segOk(ip)) { - nx1 = ip.x - ny1 = ip.y - trimmed = true - } - } - if (d2 > OUTSIDE_TOL) { - const ip = lineLineIntersection(orig1, orig2, newCorner1, newCorner2) - if (ip && segOk(ip)) { - nx2 = ip.x - ny2 = ip.y - trimmed = true - } - } - if (!trimmed) continue - if (!il.__shrinkOrig) { - il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } - } - il.set({ x1: nx1, y1: ny1, x2: nx2, y2: ny2 }) + il.set({ x1: n1.x, y1: n1.y, x2: n2.x, y2: n2.y }) if (typeof il.setCoords === 'function') il.setCoords() + if (isRidgeDiagLine(il)) il.__ridgeDiagBranch = `NORMAL-ABS ${branch}` logger.log( - '[KERAB-OFFSET-SHRINK-TRIM] trimmed ' + - JSON.stringify({ lineName: il.lineName, orig, newPts: { x1: nx1, y1: ny1, x2: nx2, y2: ny2 } }), + '[KERAB-OFFSET-NORMAL-ABS] ' + + JSON.stringify({ lineName: il.lineName, branch, newPts: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } }), ) } + + // [KERAB-OFFSET-FUNCTIONIZE 2026-06-10] dispatch — 라인 성격으로만 분기. + // 골짜기(케라바 패턴) 라인과 일반 라인은 각자 독립 핸들러로 처리되어 서로 간섭하지 않는다. + for (const il of roof.innerLines || []) { + if (!il) continue + if (isKerabPatternLine(il)) recomputeKerabPatternLine(il) + else recomputeNormalLine(il) + } } // points 는 새 배열로 set 해야 fabric 의 dirty 감지가 동작. @@ -412,6 +432,30 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset, options = {} } if (typeof roof.addLengthText === 'function') roof.addLengthText() + // [RIDGE-DIAG 2026-06-10] 마루 길이 변동 검사 — 0.5mm 넘게 변하면 어느 분기(branch)에서 + // 끌렸는지 + before/after 좌표를 warn 으로 남긴다. 우연 재발 시 콘솔에서 즉시 원인 특정. + for (const [il, before] of ridgeDiagBefore) { + const afterLen = Math.hypot(il.x2 - il.x1, il.y2 - il.y1) + const delta = afterLen - before.len + if (Math.abs(delta) > 0.5) { + logger.warn( + '[RIDGE-DIAG] 마루 길이 변동 ' + + JSON.stringify({ + lineName: il.lineName, + branch: il.__ridgeDiagBranch || '(no-branch/skipInnerLines?)', + beforeLen: Math.round(before.len * 100) / 100, + afterLen: Math.round(afterLen * 100) / 100, + delta: Math.round(delta * 100) / 100, + oldOffset, + newOffset, + before: { x1: before.x1, y1: before.y1, x2: before.x2, y2: before.y2 }, + after: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }, + }), + ) + } + delete il.__ridgeDiagBranch + } + canvas.renderAll() logger.log( '[KERAB-OFFSET-SURGICAL] applied ' +