dev #930
@ -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`)을 써야 보정 대상이 일치.
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 = [
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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) => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -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')
|
||||
|
||||
@ -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 ' +
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user