Merge pull request 'dev' (#931) from dev into prd-deploy
Reviewed-on: #931
This commit is contained in:
commit
2b3e46d28f
@ -1,7 +1,7 @@
|
||||
# 용마루(skeleton) 경로 외곽 지붕선 편집 기능 — 설계
|
||||
|
||||
- 작성일: 2026-06-01
|
||||
- 대상 브랜치: `feature/redo-undo`
|
||||
- 대상 브랜치: `feature/roofline-edit`
|
||||
- 관련 파일: `src/util/skeleton-utils.js`, `src/util/qpolygon-utils.js`, `src/hooks/useContextMenu.js`
|
||||
|
||||
## 1. 배경 / 문제
|
||||
@ -66,13 +66,13 @@ splitRoofLinesToInnerLines(roof, canvas, textMode) → QLine[]
|
||||
- **`drawRoofByAttribute` 교체**: 인라인 블록을 함수 호출로 교체. 순수 추출이므로 변별 경로 동작 불변.
|
||||
|
||||
### 3-2. skeleton 경로 변경
|
||||
- `createInnerLinesFromSkeleton` 의 `isOuterEdge` 숨김 처리(`:1636-1643` 의 `visible:false`, `:1624` 의 `selectable:false`) 제거.
|
||||
- 내부선(용마루/추녀) 생성 완료 후 `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류.
|
||||
- `createInnerLinesFromSkeleton` 의 `isOuterEdge`(SK eaves) 숨김 처리는 **유지**. (§9 정정: SK eaves 라인은 baseLine 기준이라, 노출하면 처마변 split 결과와 offset 만큼 어긋나 이중 표시됨. 처마 외곽선은 아래 split 으로 별도 innerLine 화.)
|
||||
- **추녀 연장(`drawBaselineToRooflineHelpers`, `:1285`) 완료 후** `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 추녀 종점이 처마변 위에 있는 상태라 split 정합 성립, 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. (⚠️ 연장 **전**에 호출하면 종점이 baseLine 위라 분할점을 못 잡음 — §9 참조)
|
||||
- `drawHelpLine` 재호출 시마다 자동 재실행되어 편집 후 재그리기에도 외곽 지붕선 유지.
|
||||
|
||||
## 4. 리스크 & 구현 전 검증
|
||||
|
||||
1. **SK 추녀 끝점 ↔ `roof.lines` 정합성**: split 은 "innerLine 끝점이 `roof.lines` 위에 있다"(`isPointOnLineNew`, tol 0.1)를 전제로 한다. SK 가 만든 hip 끝점이 처마 외곽선 위에 실제로 떨어지는지 **실측 확인 필요**. 안 맞으면 split 이 헛돌아 외곽선이 통째로 roofLine 1개로만 그려진다. → 구현 첫 단계에서 검증.
|
||||
1. **SK 추녀 끝점 ↔ `roof.lines` 정합성** — ✅ **검증 완료 (2026-06-12, §9 참조)**: **연장 후 정합 OK**. SK 골조는 `wall.baseLines` 기준이라 추녀 끝점이 처음엔 baseLine 코너에 있으나(연장 전 `onRoof=false`), `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 **연장**해 종점을 처마변 위로 옮긴다(연장 후 4코너 전부 `diffRoof=0`, `onRoof=true`). → **split 을 연장 다음에 호출하면 정합 자동 성립**. (초기 측정이 연장 전에 찍혀 "구조적 불일치"로 오판했음 — §9-0 참조)
|
||||
2. **순환 import**: `qpolygon-utils ↔ skeleton-utils` 상호 import 여부 점검. 순환이면 split 함수를 제3의 모듈(예: canvas-util)로 추출하거나 의존성 정리.
|
||||
3. **기존 `isOuterEdge` roofLine 제거 영향**: 숨긴 라인을 참조하는 곳(치수 계산, 플랜 저장/복원 등)이 있는지 확인 후 제거.
|
||||
|
||||
@ -159,3 +159,91 @@ opts.degreeOf?: (parentLine) => number // 부모 외곽변 → 경사각(도).
|
||||
### 8-5. 리스크
|
||||
- **분할점 부재**: 한쪽흐름은 내부 보조선이 없어 외곽변이 1:1 통짜 라인으로 그려진다 → 부모 변 type 매칭이 단순(분할 시에도 조각은 부모 type 상속). split 의 끝점 정합성(`isPointOnLineNew`, tol 0.1) 자체는 분할점이 없으면 무의미하므로 `else` 통짜 경로(`:5493`)로 안전하게 떨어진다.
|
||||
- **`isGable` 판정원**: `drawShedRoof` 의 `gables` 분류(`:1653`)와 동일 기준(`attributes.type === GABLE`)을 써야 보정 대상이 일치.
|
||||
|
||||
---
|
||||
|
||||
## 9. SK 끝점 ↔ 처마변 정합 — 실측 결과 (2026-06-12, 측정 시점 정정)
|
||||
|
||||
§4 리스크1 을 임시 계측(`skeleton-utils.js` 의 `logSkRoofLineAlignment`)으로 추녀 연장 **전/후** 2단계 실측한 결과.
|
||||
|
||||
### 9-0. ⚠️ 측정 시점 정정 (중요)
|
||||
1차 측정은 `createInnerLinesFromSkeleton` **직후**(`:1279`, 추녀 연장 전)에 찍혀, 추녀 종점이 baseLine 코너에 있는 상태(`onRoof=false`, `perpRoof=offset`)만 보고 **"구조적 불일치 → 투영 보정 필요"로 오판**했다. 실제로는 그 직후 `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 연장한다. 연장 **후**(`afterExt`) 측정에서 정합이 성립함을 확인 → 아래는 정정된 결론. **(투영 보정·SK 입력 교체·연장 보정 설계는 모두 폐기. 불필요했음.)**
|
||||
|
||||
### 9-1. 실측 결과 (용마루 우진각, 변별 offset 50px)
|
||||
처마변 x∈[357.5,1147.6]·y∈[115.2,636.7] vs baseLine x∈[407.5,1097.6]·y∈[165.2,586.7] (전 변 50px 안쪽).
|
||||
|
||||
| 추녀 끝점 | beforeExt (`:1279`, 연장 전) | afterExt (`:1285`, 연장 후) |
|
||||
|---|---|---|
|
||||
| **바깥** 끝점(4코너) | baseLine 코너 — `onRoof=false`, `diffRoof=22.4`, `onBase=true` | **처마 코너로 연장 — `onRoof=true`, `diffRoof=0`** |
|
||||
| 안쪽·용마루(내부점) | `onRoof=false` (260.7) | `onRoof=false` (260.7) — 내부점이라 정상 |
|
||||
|
||||
예: 추녀 바깥 끝 `(407.5,165.2)`(baseLine) → `(357.5,115.2)`(처마 코너) 로 연장. 4코너 전부 `diffRoof=0`, `onRoof=true`.
|
||||
|
||||
### 9-2. 추녀 연장 메커니즘 (이미 구현됨)
|
||||
`drawBaselineToRooflineHelpers`(`:685`, 플래그 `DRAW_BASELINE_TO_ROOFLINE_HELPER=true`, `:25`):
|
||||
- `hipLines` 추출(`:734`) → baseLine 코너에 닿는 hip 만(`CORNER_EPS`, `:773`) → 추녀 진행 방향으로 ray cast(`rayHitSegment`, `:693`) → 처마변 교점까지 hip 좌표를 **연장**(`:861-865`).
|
||||
- `actualSize` 비율 보정(`:871`) + `__extended`/`attributes.extended` 플래그로 save/load 영속(`:879`).
|
||||
- 즉 "추녀는 roof.lines 까지 연결되는 선" 이 이미 코드에 있다. SK 골조 자체는 baseLine 기준(동기화 유지) + 추녀 종점만 처마변까지 연장.
|
||||
|
||||
### 9-3. 결론 — split 은 추녀 연장 **다음**에 호출
|
||||
- 추녀 종점은 연장 후 처마변 위(`onRoof=true`, `diffRoof=0`)에 정확히 앉으므로, **split/외곽선 innerLine화의 정합은 깨지지 않는다.**
|
||||
- **유일한 요건**: `splitRoofLinesToInnerLines` 호출을 `createInnerLinesFromSkeleton` 직후가 아니라 **`drawBaselineToRooflineHelpers`(`:1285`) 다음에** 둘 것. §3-2 가 호출 위치를 연장 전으로 잡은 것이 유일한 함정이었음.
|
||||
- **단순 우진각**: 추녀가 처마 **코너**(변 끝점)에 닿으므로 split 분할점 없음 → 처마 4변 통짜 innerLine(정상).
|
||||
- **복합 형상**(L자·凸자): 추녀가 처마변 **중간**에 닿으면 그 점에서 분할. 연장 후 종점이 처마변 위이므로 분할점으로 정상 인식.
|
||||
|
||||
### 9-4. 검증 기준 (구현 시)
|
||||
- split 을 연장 후 호출 → 처마 4변이 innerLine 으로 등록, select+우클릭 편집 가능.
|
||||
- 복합 형상: 처마변 중간 접점에서 분할, 각 조각 독립 편집.
|
||||
- offset=0 / offset≠0 양쪽 동작.
|
||||
- 마루이동/벽이동 후 재그리기에도 유지(연장은 `__extended`/`attributes.extended` 로 영속).
|
||||
- **임시 계측(`logSkRoofLineAlignment` 정의 + 호출부 3곳 `:1279`/`:1285`/`:1413`) 제거** 후 커밋.
|
||||
|
||||
---
|
||||
|
||||
## 10. 육각형 등 비직교 형상에서 외곽 지붕선 소실 — 진단 (2026-06-22 추가)
|
||||
|
||||
### 10-1. 증상
|
||||
**육각형(wall 6변) 용마루 지붕** 설정 시 오른쪽 위 외곽 지붕선 2개가 화면에서 사라진다.
|
||||
단순 우진각(사각형)은 정상(§9-1 실측 검증 완료) — 비직교(대각선) 변이 많은 형상에서만 발생.
|
||||
|
||||
### 10-2. 가설 (코드 분석, 1순위)
|
||||
`splitRoofLinesToInnerLines`(`qpolygon-utils.js:5485`) 는 처마변을 innerLine 으로 등록한 뒤
|
||||
**roof 원본 외곽선을 `stroke:'transparent'` 로 숨긴다**(`:5563`). 따라서 어떤 처마변이
|
||||
innerLine 으로 **재등록되지 못하면** 원본 대체선도 없어 그 자리에 선이 완전히 사라진다.
|
||||
|
||||
누락 1순위 지점 = `hasOverlapLine` 가드(`:5511-5523`). 이는 **축정렬 bounding-box 포함관계**로만
|
||||
overlap 을 판정하는데, 이 블록은 원래 **변별 경로(`drawRoofByAttribute`, 주로 직교 형상)** 의
|
||||
인라인 코드를 `1bf5f45b` 에서 공용 추출한 것이다. 직교 변은 bbox 가 선분에 딱 맞아 false positive 가
|
||||
드물지만, **육각형의 대각선 처마변 + 대각선 추녀(hip)** 는 bbox 가 큰 사각형이라 코너를 공유할 때
|
||||
서로의 bbox 를 포함하기 쉬워 `hasOverlapLine=true` 오발동 → 해당 처마변 `return`(skip).
|
||||
오른쪽 위 짧은 대각선 변 2개가 인접 추녀 bbox 에 함께 포함되어 동시 skip 된 것으로 추정.
|
||||
|
||||
### 10-3. 진단 결과 — 가설 확정 (2026-06-22)
|
||||
육각형(직사각형 우상단 모서리를 계단으로 자른 형상) 재현 `[HEXDIAG]` 로그:
|
||||
|
||||
split entry roof.lines=6 innerLines=9
|
||||
line#0 WHOLE (386,35)→(386,1335) ← 좌 수직변, 정상
|
||||
line#1 WHOLE (386,1335)→(1286,1335) ← 하 수평변, 정상
|
||||
line#2 WHOLE (1286,1335)→(1286,235) ← 우 수직변, 정상
|
||||
line#3 SKIP (1286,235)→(1086,235) overlapBy=inner⊇curr hip(1286,235)→(836,685) ✗
|
||||
line#4 SKIP (1086,235)→(1086,35) overlapBy=inner⊇curr hip(1086,35)→(736,385) ✗
|
||||
line#5 WHOLE (1086,35)→(386,35) ← 상 수평변, 정상
|
||||
|
||||
- 사라진 line#3(수평, y두께 0)·line#4(수직, x두께 0) = 잘린 계단의 짧은 변 2개.
|
||||
- 둘 다 `inner⊇curr`: 끝점 코너를 공유한 **대각선 추녀(hip)의 bbox 가 0-두께 처마변 bbox 를 포함**.
|
||||
예) line#3 bbox `x[1086,1286]·y[235,235]` ⊂ hip bbox `x[836,1286]·y[235,685]`.
|
||||
- line#0·1·2·5 가 멀쩡한 건 길어서 어떤 hip bbox 로도 통째로 못 감싸기 때문. → **가설 확정.**
|
||||
- 1순위 정정: 범인은 "대각선 처마변"이 아니라 **축정렬(수평/수직) 짧은 처마변 + 대각선 추녀** 조합.
|
||||
|
||||
### 10-4. 수정 (적용 완료, 2026-06-22)
|
||||
`hasOverlapLine` 판정을 **bbox 포함 → 공선 + 구간 덮음**으로 교체(`qpolygon-utils.js:5507-5519`):
|
||||
한쪽 선분의 **양 끝점이 모두 상대 선분 위**(`isPointOnLineNew`, tol 0.1)일 때만 overlap 인정.
|
||||
대각선 추녀는 처마변과 공선이 아니라 통과 → 처마변 정상 등록. `[HEXDIAG]` 계측·`logger` import 전량 제거.
|
||||
|
||||
**회귀 영향 분석** (이 함수를 쓰는 4경로):
|
||||
- **변별**(`drawRoofByAttribute` → `:5447`): 가드 원사용처. 원래 bbox 가 잡던 "진짜 같은 선 위 겹침"은 공선이라 새 판정으로도 잡힘. bbox-만-겹침(공선 아님)은 원래 false positive → 제거가 곧 개선. 회귀 위험 = 공선인데 부동소수로 tol 0.1 초과하는 경계뿐(드묾).
|
||||
- **한쪽흐름**(`drawShedRoof` → `:1731`): 내부 보조선 없어 `innerLines` 거의 비어 가드 트리거 자체가 드묾. 영향 미미.
|
||||
- **용마루**(skeleton `:1288`): 대상 케이스. 사각형 우진각·육각형 양쪽 확인.
|
||||
- **박공**(`drawGableRoof`): split 미사용 → 무영향.
|
||||
|
||||
**재현 검증 체크리스트**: ① 육각형 용마루 6변 전부 등록(소실 없음) ② 단순 사각형 우진각 4변 유지(회귀 없음) ③ 변별 지붕 외곽선·추녀 정상 ④ 한쪽흐름 경사변 actual=plane/cos 유지. (캔버스 인터랙션이라 런타임 확인은 재현 필요 — 정적 분석상 ①만 동작 변화, ②③④ 불변 예상.)
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"start:cluster1": "env-cmd -f .env.production next start -p 5000",
|
||||
"start:cluster2": "env-cmd -f .env.production next start -p 5001",
|
||||
"start:dev": "env-cmd -f .env.development next start -p 5010",
|
||||
"start:ur": "env-cmd -f .env.development next start -p 5050",
|
||||
"start:btob": "env-cmd -f .env.development next start -p 5060",
|
||||
"lint": "next lint",
|
||||
"serve": "node server.js"
|
||||
|
||||
@ -227,6 +227,11 @@ export const SAVE_KEY = [
|
||||
'outerLineFix',
|
||||
'adjustRoofLines',
|
||||
'northModuleYn',
|
||||
// SK 빌드 시 hip 이 ext 끝점까지 연장된 상태임을 표시.
|
||||
// undo/redo 시 보존되어야 integrateExtensionLines 가 중복 merge 를 스킵할 수 있다.
|
||||
// (없으면 undo 후 지붕면 할당 시 hip 이 다시 merge 되어 corner 가 inner mesh 와 분리되며
|
||||
// path-finding 이 실패 → 4 면 분할 안 됨)
|
||||
'__extended',
|
||||
]
|
||||
|
||||
export const OBJECT_PROTOTYPE = [fabric.Line.prototype, fabric.Polygon.prototype, fabric.Triangle.prototype, fabric.Group.prototype]
|
||||
|
||||
@ -40,7 +40,7 @@ class ErrorBoundary extends React.Component {
|
||||
<p><strong>Timestamp:</strong> {new Date().toISOString()}</p>
|
||||
<p><strong>Error:</strong> {this.state.error && this.state.error.toString()}</p>
|
||||
<p><strong>Stack Trace:</strong></p>
|
||||
<pre>{this.state.errorInfo.componentStack}</pre>
|
||||
<pre>{this.state.errorInfo?.componentStack}</pre>
|
||||
</details>
|
||||
<button
|
||||
onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}
|
||||
|
||||
@ -44,17 +44,9 @@ function __classifyLineForLabel(obj) {
|
||||
if (name === LINE_TYPE.SUBLINE.VALLEY || lineName === LINE_TYPE.SUBLINE.VALLEY) return 'V'
|
||||
if (name === LINE_TYPE.SUBLINE.GABLE || lineName === LINE_TYPE.SUBLINE.GABLE) return 'G'
|
||||
if (name === LINE_TYPE.SUBLINE.VERGE || lineName === LINE_TYPE.SUBLINE.VERGE) return 'VG'
|
||||
if (
|
||||
type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
|
||||
lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE ||
|
||||
name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE
|
||||
) return 'E'
|
||||
if (
|
||||
typeof obj.x1 === 'number' &&
|
||||
typeof obj.y1 === 'number' &&
|
||||
typeof obj.x2 === 'number' &&
|
||||
typeof obj.y2 === 'number'
|
||||
) return 'SK'
|
||||
if (type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE)
|
||||
return 'E'
|
||||
if (typeof obj.x1 === 'number' && typeof obj.y1 === 'number' && typeof obj.x2 === 'number' && typeof obj.y2 === 'number') return 'SK'
|
||||
return null
|
||||
}
|
||||
|
||||
@ -241,7 +233,6 @@ export function reattachRoofFaceDebugLabels(canvas) {
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
|
||||
export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
type: 'QPolygon',
|
||||
// lines: [],
|
||||
@ -421,11 +412,13 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
this.setCoords()
|
||||
|
||||
// 보조선 / 자식 객체들도 같이 이동시킨다 (ROOF 가 아닌 WALL 등에서도 동작하도록)
|
||||
// 단, fabric.Group(도머 등)은 절대 위치 유지해야 하므로 제외
|
||||
if ((dxModified !== 0 || dyModified !== 0) && this.canvas) {
|
||||
const directChildren = this.canvas.getObjects().filter((obj) => {
|
||||
if (obj === this) return false
|
||||
if (obj.parentId !== this.id) return false
|
||||
if (obj.name === 'lengthText') return false
|
||||
if (obj.type === 'group') return false // 도머 등 Group 은 이동 대상에서 제외
|
||||
return true
|
||||
})
|
||||
const childIds = new Set(directChildren.map((c) => c.id).filter(Boolean))
|
||||
@ -456,6 +449,59 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
movedTextCount++
|
||||
}
|
||||
})
|
||||
|
||||
// [WALL→ROOF 동기 이동]
|
||||
// 지붕형상 설정 후 wall 과 roof 는 별개의 폴리곤이고, hip/ridge/baseLine 같은 보조선은
|
||||
// 모두 parentId=roof.id 로 묶여 있어 wall 의 directChildren 에 포함되지 않는다.
|
||||
// wall 만 단독 이동시키면 roof+보조선이 옛 위치에 남아 시각적으로 어긋나므로
|
||||
// wall 이동 시 연결된 roof 를 찾아 polygonMoved 로 같은 delta 만큼 이동시킨다.
|
||||
// (polygonMoved 가 자체적으로 직속 자식 평행이동까지 처리하므로 별도 순회 불필요)
|
||||
if (this.name === POLYGON_TYPE.WALL) {
|
||||
const linkedRoof = this.canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.ROOF && obj.wall === this)
|
||||
if (linkedRoof) {
|
||||
linkedRoof._preDragLeft = linkedRoof.left
|
||||
linkedRoof._preDragTop = linkedRoof.top
|
||||
linkedRoof.set({
|
||||
left: (linkedRoof.left ?? 0) + dxModified,
|
||||
top: (linkedRoof.top ?? 0) + dyModified,
|
||||
})
|
||||
linkedRoof.fire('polygonMoved')
|
||||
// polygonMoved 가 ROOF 분기에서 _preDragLeft 를 정리하지 않으므로 여기서 정리
|
||||
linkedRoof._preDragLeft = null
|
||||
linkedRoof._preDragTop = null
|
||||
|
||||
// roof 의 attributes.roofId 매칭 객체(예: 일부 baseLine, 텍스트류)는
|
||||
// roof.id 와 다른 식별자로 묶여 polygonMoved 의 parentId === roof.id 필터에
|
||||
// 잡히지 않을 수 있다. 누락 방지를 위해 추가 평행이동.
|
||||
const extraKids = this.canvas.getObjects().filter((obj) => {
|
||||
if (obj === linkedRoof) return false
|
||||
if (obj.parentId === linkedRoof.id) return false // 이미 polygonMoved 가 처리
|
||||
return obj.attributes?.roofId === linkedRoof.id
|
||||
})
|
||||
extraKids.forEach((obj) => {
|
||||
const next = {}
|
||||
if (obj.left != null) next.left = obj.left + dxModified
|
||||
if (obj.top != null) next.top = obj.top + dyModified
|
||||
if (obj.x1 != null) next.x1 = obj.x1 + dxModified
|
||||
if (obj.y1 != null) next.y1 = obj.y1 + dyModified
|
||||
if (obj.x2 != null) next.x2 = obj.x2 + dxModified
|
||||
if (obj.y2 != null) next.y2 = obj.y2 + dyModified
|
||||
obj.set(next)
|
||||
if (obj.startPoint)
|
||||
obj.startPoint = {
|
||||
x: obj.startPoint.x + dxModified,
|
||||
y: obj.startPoint.y + dyModified,
|
||||
}
|
||||
if (obj.endPoint) obj.endPoint = { x: obj.endPoint.x + dxModified, y: obj.endPoint.y + dyModified }
|
||||
if (obj.minX != null) obj.minX += dxModified
|
||||
if (obj.maxX != null) obj.maxX += dxModified
|
||||
if (obj.minY != null) obj.minY += dyModified
|
||||
if (obj.maxY != null) obj.maxY += dyModified
|
||||
obj.setCoords?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
this.canvas.renderAll()
|
||||
|
||||
// ROOF 의 경우 polygonMoved 가 같은 일을 다시 하므로, _preDragLeft 는 거기서 정리됨
|
||||
@ -552,6 +598,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
if (obj === this) return false
|
||||
if (obj.parentId !== this.id) return false
|
||||
if (obj.name === 'lengthText') return false // 이미 재생성됨
|
||||
if (obj.type === 'group') return false // 도머 등 Group 은 절대 위치 유지
|
||||
return true
|
||||
})
|
||||
|
||||
@ -593,6 +640,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
movedTextCount++
|
||||
}
|
||||
})
|
||||
|
||||
this.canvas.renderAll()
|
||||
}
|
||||
|
||||
@ -879,7 +927,28 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
|
||||
|
||||
const degree = Big(Math.atan2(dy.toNumber(), dx.toNumber())).times(180).div(Math.PI).toNumber()
|
||||
|
||||
// 이동/undo/redo 시 기하학 drift(round-off)로 text 수치가 변동되지 않도록
|
||||
// 저장된 attributes 우선 사용. 현재 corridorDimension.column에 맞춰 값 결정:
|
||||
// - realDimension(배치면 메뉴): actualSize 우선
|
||||
// - corridorDimension(지붕덮개 메뉴): planeSize 우선
|
||||
const preservedActualSize = this.lines[i]?.attributes?.actualSize
|
||||
const preservedPlaneSize = this.lines[i]?.attributes?.planeSize
|
||||
const currentColumn = (typeof window !== 'undefined' && window.__currentCorridorColumn) || 'corridorDimension'
|
||||
const displayValue =
|
||||
currentColumn === 'realDimension'
|
||||
? preservedActualSize != null
|
||||
? preservedActualSize
|
||||
: preservedPlaneSize != null
|
||||
? preservedPlaneSize
|
||||
: length
|
||||
: preservedPlaneSize != null
|
||||
? preservedPlaneSize
|
||||
: preservedActualSize != null
|
||||
? preservedActualSize
|
||||
: length
|
||||
|
||||
// Create a new text object if it doesn't exist
|
||||
|
||||
const text = new fabric.Text(__display, {
|
||||
left: midPoint.x,
|
||||
top: midPoint.y,
|
||||
|
||||
@ -36,7 +36,12 @@ import {
|
||||
import { sessionStore } from '@/store/commonAtom'
|
||||
import { outerLinePointsState } from '@/store/outerLineAtom'
|
||||
import { appMessageStore, globalLocaleStore } from '@/store/localeAtom'
|
||||
import { addedRoofsState, basicSettingState, selectedRoofMaterialSelector, settingModalFirstOptionsState } from '@/store/settingAtom'
|
||||
import {
|
||||
addedRoofsState,
|
||||
basicSettingState,
|
||||
selectedRoofMaterialSelector,
|
||||
settingModalFirstOptionsState,
|
||||
} from '@/store/settingAtom'
|
||||
import { placementShapeDrawingPointsState } from '@/store/placementShapeDrawingAtom'
|
||||
import { commonUtilsState } from '@/store/commonUtilsAtom'
|
||||
import { menusState } from '@/store/menuAtom'
|
||||
@ -52,6 +57,7 @@ import { QcastContext } from '@/app/QcastProvider'
|
||||
import { useRoofFn } from '@/hooks/common/useRoofFn'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
import { useTrestle } from '@/hooks/module/useTrestle'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
export default function CanvasMenu(props) {
|
||||
const [currentCanvasPlan, setCurrentCanvasPlan] = useRecoilState(currentCanvasPlanState)
|
||||
@ -73,6 +79,7 @@ export default function CanvasMenu(props) {
|
||||
const { handleZoomClear, handleZoom } = useCanvasEvent()
|
||||
const { setAllModuleSurfaceIsComplete, isAllComplete } = useTrestle()
|
||||
const { handleMenu } = useMenu()
|
||||
const { undo, redo, canUndo, canRedo } = useUndoRedo()
|
||||
// const urlParams = useSearchParams()
|
||||
const { handleEstimateSubmit, fetchSetting, estimateContextState, setEstimateContextState } = useEstimateController()
|
||||
const estimateRecoilState = useRecoilValue(estimateState)
|
||||
@ -600,6 +607,20 @@ export default function CanvasMenu(props) {
|
||||
<div className="canvas-side-btn-wrap">
|
||||
{!['estimate', 'simulation'].some((num) => num === selectedMenu) && (
|
||||
<>
|
||||
<div className="undo-redo-wrap">
|
||||
<button className={`undo-btn ${!canUndo ? 'disabled' : ''}`} onClick={undo} disabled={!canUndo} title="Undo">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M3 10h10c4.42 0 8 3.58 8 8v1" />
|
||||
<polyline points="7 14 3 10 7 6" />
|
||||
</svg>
|
||||
</button>
|
||||
<button className={`redo-btn ${!canRedo ? 'disabled' : ''}`} onClick={redo} disabled={!canRedo} title="Redo">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M21 10H11C6.58 10 3 13.58 3 18v1" />
|
||||
<polyline points="17 14 21 10 17 6" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{
|
||||
<div className={`vertical-horizontal ${verticalHorizontalMode ? 'on' : ''}`}>
|
||||
<span>{getMessage('plan.mode.vertical.horizontal')}</span>
|
||||
|
||||
@ -9,6 +9,7 @@ import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
|
||||
import { subMenusState } from '@/store/menuAtom'
|
||||
import { useCanvasMenu } from '@/hooks/common/useCanvasMenu'
|
||||
import { commonUtilsState } from '@/store/commonUtilsAtom'
|
||||
import { isUndoRedoRestoring } from '@/hooks/useUndoRedo'
|
||||
|
||||
export default function MenuDepth01() {
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
@ -29,6 +30,10 @@ export default function MenuDepth01() {
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
// undo/redo 복원 중에는 메뉴 변경에 따른 팝업 자동 오픈을 건너뛴다.
|
||||
// 그렇지 않으면 그리기 팝업이 다시 열리며 setDrawing(true) 가 호출되어
|
||||
// redo 버튼이 비활성화되는 문제가 발생한다.
|
||||
if (isUndoRedoRestoring()) return
|
||||
handleMenu(selectedMenu)
|
||||
canvas?.discardActiveObject()
|
||||
}, [currentMenu])
|
||||
|
||||
@ -10,6 +10,7 @@ import { useAuxiliaryDrawing } from '@/hooks/roofcover/useAuxiliaryDrawing'
|
||||
import { useSwal } from '@/hooks/useSwal'
|
||||
import { normalizeDigits } from '@/util/input-utils'
|
||||
import { CalculatorInput } from '@/components/common/input/CalcInput'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
export default function AuxiliaryEdit(props) {
|
||||
const contextPopupPosition = useRecoilValue(contextPopupPositionState)
|
||||
@ -23,8 +24,10 @@ export default function AuxiliaryEdit(props) {
|
||||
const [arrow2, setArrow2] = useState(null)
|
||||
const currentObject = useRecoilValue(currentObjectState)
|
||||
const { swalFire } = useSwal()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
const [useCalcPad, setUseCalcPad] = useState(false)
|
||||
const disableKeypad = !useCalcPad
|
||||
|
||||
const handleSave = () => {
|
||||
if ((!arrow1 && !arrow2) || (+verticalSize === 0 && +horizonSize === 0)) {
|
||||
swalFire({ title: getMessage('length.direction.is.required'), type: 'alert' })
|
||||
@ -41,6 +44,7 @@ export default function AuxiliaryEdit(props) {
|
||||
return
|
||||
}
|
||||
|
||||
saveSnapshot()
|
||||
if (type === 'copy') {
|
||||
if (currentObject) {
|
||||
copy(
|
||||
@ -81,7 +85,7 @@ export default function AuxiliaryEdit(props) {
|
||||
disableKeypad={disableKeypad}
|
||||
options={{
|
||||
allowNegative: false,
|
||||
allowDecimal: false
|
||||
allowDecimal: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -91,14 +95,12 @@ export default function AuxiliaryEdit(props) {
|
||||
className={`direction up ${arrow1 === '↑' ? 'act' : ''}`}
|
||||
onClick={() => {
|
||||
setArrow1('↑')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
|
||||
}}
|
||||
></button>
|
||||
<button
|
||||
className={`direction down ${arrow1 === '↓' ? 'act' : ''}`}
|
||||
onClick={() => {
|
||||
setArrow1('↓')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
|
||||
}}
|
||||
></button>
|
||||
</div>
|
||||
@ -116,7 +118,7 @@ export default function AuxiliaryEdit(props) {
|
||||
disableKeypad={disableKeypad}
|
||||
options={{
|
||||
allowNegative: false,
|
||||
allowDecimal: false
|
||||
allowDecimal: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@ -126,14 +128,12 @@ export default function AuxiliaryEdit(props) {
|
||||
className={`direction left ${arrow2 === '←' ? 'act' : ''}`}
|
||||
onClick={() => {
|
||||
setArrow2('←')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }))
|
||||
}}
|
||||
></button>
|
||||
<button
|
||||
className={`direction right ${arrow2 === '→' ? 'act' : ''}`}
|
||||
onClick={() => {
|
||||
setArrow2('→')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
|
||||
}}
|
||||
></button>
|
||||
</div>
|
||||
@ -143,7 +143,12 @@ export default function AuxiliaryEdit(props) {
|
||||
</div>
|
||||
<div className="grid-btn-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '5px' }}>
|
||||
<button style={{ width: 'auto' }} onClick={() => setUseCalcPad((prev) => !prev)}>
|
||||
<Image src={useCalcPad ? '/static/images/common/Icon_ON_Black.png' : '/static/images/common/Icon_OFF_Black.png'} alt="toggle" width={34} height={34} />
|
||||
<Image
|
||||
src={useCalcPad ? '/static/images/common/Icon_ON_Black.png' : '/static/images/common/Icon_OFF_Black.png'}
|
||||
alt="toggle"
|
||||
width={34}
|
||||
height={34}
|
||||
/>
|
||||
</button>
|
||||
<button className="btn-frame modal act" onClick={handleSave}>
|
||||
{getMessage('modal.common.save')}
|
||||
|
||||
@ -8,7 +8,9 @@ import { useEffect, useState } from 'react'
|
||||
import Image from 'next/image'
|
||||
import Big from 'big.js'
|
||||
import { calcLineActualSize, calcLinePlaneSize } from '@/util/qpolygon-utils'
|
||||
import { syncOuterBoundaryTwin } from '@/util/canvas-util'
|
||||
import { normalizeDigits } from '@/util/input-utils'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { CalculatorInput } from '@/components/common/input/CalcInput'
|
||||
|
||||
export default function AuxiliarySize(props) {
|
||||
@ -56,7 +58,9 @@ export default function AuxiliarySize(props) {
|
||||
}
|
||||
}
|
||||
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
const handleSave = () => {
|
||||
saveSnapshot()
|
||||
const { x1, y1, x2, y2 } = currentObject
|
||||
|
||||
// 선택한 선분의 planeSize와 actualSize의 사잇각을 구한다.
|
||||
@ -85,15 +89,26 @@ export default function AuxiliarySize(props) {
|
||||
// 현재 선분의 길이와 입력된 길이의 차이를 구한다.
|
||||
const scaleFactor = Big(size).div(distance)
|
||||
|
||||
//1지점 선택일때는 2지점의 좌표, 2지점 선택일때는 1지점의 좌표를 조정한다.
|
||||
// [SIZE-ANCHOR 2026-06-22] 저장된 x1/x2 끝점 순서가 라인마다 달라(왼쪽 선은 1지점이 위, 오른쪽 선은 1지점이 아래)
|
||||
// 동일하게 늘려도 방향이 반대로 보이던 문제 수정. 화면 기준으로 1지점=왼쪽 위, 2지점=오른쪽 아래로 통일한다.
|
||||
// (외벽선 오프셋 useWallLineOffsetSetting 의 "1지점=down/right, 2지점=up/left" 컨벤션과 동일)
|
||||
// 수직선(x 동일)은 위쪽을, 그 외는 왼쪽을 1지점(왼쪽 위)으로 판별한다.
|
||||
const VERTICAL_EPS = 1 // 캔버스 단위(≈10mm) — 이 이하의 x 차이는 수직선으로 간주
|
||||
const p1IsTopLeft = Math.abs(x1 - x2) > VERTICAL_EPS ? x1 < x2 : y1 <= y2
|
||||
const topLeft = p1IsTopLeft ? { x: x1, y: y1 } : { x: x2, y: y2 }
|
||||
const bottomRight = p1IsTopLeft ? { x: x2, y: y2 } : { x: x1, y: y1 }
|
||||
|
||||
//1지점(왼쪽 위) 고정 → 오른쪽 아래 끝점을 늘린다(down/right). 2지점(오른쪽 아래) 고정 → 왼쪽 위 끝점을 늘린다(up/left).
|
||||
if (checkedRadio === 1) {
|
||||
const newX2 = Big(x1).plus(dx.times(scaleFactor)).toNumber()
|
||||
const newY2 = Big(y1).plus(dy.times(scaleFactor)).toNumber()
|
||||
currentObject.set({ x2: newX2, y2: newY2 })
|
||||
const newBrX = Big(topLeft.x).plus(Big(bottomRight.x).minus(topLeft.x).times(scaleFactor)).toNumber()
|
||||
const newBrY = Big(topLeft.y).plus(Big(bottomRight.y).minus(topLeft.y).times(scaleFactor)).toNumber()
|
||||
if (p1IsTopLeft) currentObject.set({ x2: newBrX, y2: newBrY })
|
||||
else currentObject.set({ x1: newBrX, y1: newBrY })
|
||||
} else if (checkedRadio === 2) {
|
||||
const newX1 = Big(x2).minus(dx.times(scaleFactor)).toNumber()
|
||||
const newY1 = Big(y2).minus(dy.times(scaleFactor)).toNumber()
|
||||
currentObject.set({ x1: newX1, y1: newY1 })
|
||||
const newTlX = Big(bottomRight.x).plus(Big(topLeft.x).minus(bottomRight.x).times(scaleFactor)).toNumber()
|
||||
const newTlY = Big(bottomRight.y).plus(Big(topLeft.y).minus(bottomRight.y).times(scaleFactor)).toNumber()
|
||||
if (p1IsTopLeft) currentObject.set({ x1: newTlX, y1: newTlY })
|
||||
else currentObject.set({ x2: newTlX, y2: newTlY })
|
||||
}
|
||||
//planeSize와 actualSize를 재계산한다.
|
||||
currentObject.attributes.planeSize = calcLinePlaneSize({
|
||||
@ -112,6 +127,12 @@ export default function AuxiliarySize(props) {
|
||||
degree,
|
||||
)
|
||||
currentObject.addLengthText()
|
||||
|
||||
// [BOUNDARY-SYNC 2026-06-23] currentObject 가 처마 외곽선(polygon.lines)과 좌표가 같은 "경계 복제본"이면,
|
||||
// 편집 전 동일 좌표였던 처마선 쌍둥이도 변경 후 좌표로 함께 갱신해 두 경계 표현을 동기화한다.
|
||||
// 미동기화 시 처마선이 옛 위치에 남아 지붕면 할당 split 에서 경계가 2벌이 되어 바깥 사각형이 가짜 면으로 잡힌다.
|
||||
// (일반 보조선은 동일 좌표 처마선이 없어 매칭 안 됨 → 영향 없음)
|
||||
syncOuterBoundaryTwin(canvas, currentObject, { x: x1, y: y1 }, { x: x2, y: y2 })
|
||||
}
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
@ -11,6 +11,7 @@ import { useModuleSelection } from '@/hooks/module/useModuleSelection'
|
||||
import { useOrientation } from '@/hooks/module/useOrientation'
|
||||
import { useMessage } from '@/hooks/useMessage'
|
||||
import { usePopup } from '@/hooks/usePopup'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import {
|
||||
canvasState,
|
||||
checkedModuleState,
|
||||
@ -32,6 +33,7 @@ import Trestle from './step/Trestle'
|
||||
export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) {
|
||||
const { getMessage } = useMessage()
|
||||
const { closePopup } = usePopup()
|
||||
const { clearStacks } = useUndoRedo()
|
||||
const [tabNum, setTabNum] = useState(1)
|
||||
const orientationRef = useRef(null)
|
||||
const [isManualModuleSetup, setIsManualModuleSetup] = useRecoilState(isManualModuleSetupState)
|
||||
@ -84,6 +86,11 @@ export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) {
|
||||
useModuleBasicSetting(tabNum)
|
||||
const { updateObjectDate } = useMasterController()
|
||||
|
||||
// 모듈/가대설정 팝업 첫 진입 시 undo/redo 스택 초기화
|
||||
useEffect(() => {
|
||||
clearStacks()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const moduleTabNum = basicSetting.roofSizeSet != 3 ? 3 : 2
|
||||
|
||||
|
||||
@ -18,6 +18,7 @@ import { moduleSelectionDataState, selectedModuleState } from '@/store/selectedM
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
|
||||
import { useEstimate } from '@/hooks/useEstimate'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle'
|
||||
import { useImgLoader } from '@/hooks/floorPlan/useImgLoader'
|
||||
import { QcastContext } from '@/app/QcastProvider'
|
||||
@ -34,6 +35,7 @@ export default function CircuitTrestleSetting({ id }) {
|
||||
const { apply, setViewCircuitNumberTexts, getEstimateData, clear: clearTrestle, setAllModuleSurfaceIsComplete } = useTrestle()
|
||||
const { swalFire } = useSwal()
|
||||
const { saveEstimate } = useEstimate()
|
||||
const { clearStacks } = useUndoRedo()
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
const setCurrentMenu = useSetRecoilState(currentMenuState)
|
||||
const [canvasZoom, setCanvasZoom] = useRecoilState(canvasZoomState)
|
||||
@ -722,6 +724,7 @@ export default function CircuitTrestleSetting({ id }) {
|
||||
setIsGlobalLoading(false)
|
||||
}
|
||||
|
||||
clearStacks()
|
||||
// removeNotAllocationModules()
|
||||
}
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ import { canvasState } from '@/store/canvasAtom'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch'
|
||||
import { useRoofFn } from '@/hooks/common/useRoofFn'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
const FLOW_DIRECTION_TYPE = {
|
||||
EIGHT_AZIMUTH: 'eightAzimuth',
|
||||
@ -24,6 +25,7 @@ export default function FlowDirectionSetting(props) {
|
||||
const { setPolygonLinesActualSize } = usePolygon()
|
||||
|
||||
const { changeSurfaceLineType } = useSurfaceShapeBatch({})
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@ -77,6 +79,7 @@ export default function FlowDirectionSetting(props) {
|
||||
const [type, setType] = useState(target.surfaceCompassType ?? FLOW_DIRECTION_TYPE.EIGHT_AZIMUTH)
|
||||
|
||||
const changeSurfaceFlowDirection = (roof, direction, orientation) => {
|
||||
saveSnapshot()
|
||||
roof.set({
|
||||
direction: direction,
|
||||
surfaceCompass: orientation,
|
||||
|
||||
@ -8,7 +8,6 @@ import { canvasState } from '@/store/canvasAtom'
|
||||
import { POLYGON_TYPE } from '@/common/common'
|
||||
import { useModule } from '@/hooks/module/useModule'
|
||||
import { useSwal } from '@/hooks/useSwal'
|
||||
|
||||
export const PANEL_EDIT_TYPE = {
|
||||
MOVE: 'move',
|
||||
MOVE_ALL: 'moveAll',
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import WithDraggable from '@/components/common/draggable/WithDraggable'
|
||||
import { useRecoilValue } from 'recoil'
|
||||
import { contextPopupPositionState } from '@/store/popupAtom'
|
||||
@ -14,7 +15,9 @@ export default function ColumnInsert(props) {
|
||||
const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.LEFT)
|
||||
const { getMessage } = useMessage()
|
||||
const { moduleColumnInsert } = useModule()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
const handleApply = () => {
|
||||
saveSnapshot()
|
||||
moduleColumnInsert(selectedType)
|
||||
closePopup(id)
|
||||
}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import WithDraggable from '@/components/common/draggable/WithDraggable'
|
||||
import Image from 'next/image'
|
||||
import { useState } from 'react'
|
||||
@ -14,7 +15,9 @@ export default function RowInsert(props) {
|
||||
const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.TOP)
|
||||
const { getMessage } = useMessage()
|
||||
const { muduleRowInsert } = useModule()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
const handleApply = () => {
|
||||
saveSnapshot()
|
||||
muduleRowInsert(selectedType)
|
||||
closePopup(id)
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ export default function RowRemove(props) {
|
||||
{ name: getMessage('modal.row.remove.type.none'), value: MODULE_REMOVE_TYPE.NONE },
|
||||
]
|
||||
const handleApply = () => {
|
||||
// if (apply) apply()
|
||||
moduleRowRemove(selectedType)
|
||||
closePopup(id)
|
||||
}
|
||||
|
||||
@ -16,7 +16,7 @@ import { useSwal } from '@/hooks/useSwal'
|
||||
export default function SizeSetting(props) {
|
||||
const contextPopupPosition = useRecoilValue(contextPopupPositionState)
|
||||
const [settingTarget, setSettingTarget] = useState(props.side || 1)
|
||||
const { id, pos = contextPopupPosition, target } = props
|
||||
const { id, pos = contextPopupPosition, target, initialPlacement = false } = props
|
||||
const { getMessage } = useMessage()
|
||||
const { closePopup } = usePopup()
|
||||
const { resizeObjectBatch } = useObjectBatch({})
|
||||
@ -58,7 +58,7 @@ export default function SizeSetting(props) {
|
||||
}
|
||||
|
||||
if (target.name === BATCH_TYPE.OPENING || target.name === BATCH_TYPE.SHADOW) {
|
||||
resizeObjectBatch(settingTarget, target, width, height, id)
|
||||
resizeObjectBatch(settingTarget, target, width, height, id, initialPlacement)
|
||||
} else if (target.name === POLYGON_TYPE.ROOF) {
|
||||
resizeSurfaceShapeBatch(settingTarget, target, width, height, id)
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ import { addedRoofsState, roofDisplaySelector, roofMaterialsAtom } from '@/store
|
||||
import { useCommonCode } from '@/hooks/common/useCommonCode'
|
||||
import QSelectBox from '@/components/common/select/QSelectBox'
|
||||
import { globalLocaleStore } from '@/store/localeAtom'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
@ -72,6 +73,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
||||
/** usePopup에서 closePopup 함수 가져오기 */
|
||||
const { drawDirectionArrow } = usePolygon()
|
||||
const { setSurfaceShapePattern } = useRoofFn()
|
||||
const { clearStacks } = useUndoRedo()
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
const [canvasSetting, setCanvasSetting] = useRecoilState(canvasSettingState)
|
||||
const currentCanvasPlan = useRecoilValue(currentCanvasPlanState)
|
||||
@ -442,6 +444,9 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
|
||||
/* 저장 후 화면 닫기 */
|
||||
closePopup(id)
|
||||
await saveCanvas(false)
|
||||
|
||||
// 지붕재 변경 저장 후 undo/redo stack 초기화
|
||||
clearStacks()
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@ -242,7 +242,9 @@ export function useCanvasConfigInitialize() {
|
||||
obj.type === 'QLine' &&
|
||||
obj.attributes?.type !== 'pitchSizeLine' &&
|
||||
obj.attributes?.roofId === roof.id &&
|
||||
innerLineTypes.includes(obj.name),
|
||||
// [보조선 복원 fix] 저장→불러오기 후 지붕면 할당이 보조선을 쓰려면 auxiliaryLine 도 innerLines 에
|
||||
// 재연결해야 한다. 기존엔 SUBLINE(hip/ridge) 만 포함해 보조선이 빠져, 불러온 직후 할당이 실패했다.
|
||||
(innerLineTypes.includes(obj.name) || obj.name === 'auxiliaryLine'),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ import { useObjectBatch } from '@/hooks/object/useObjectBatch'
|
||||
import { BATCH_TYPE } from '@/common/common'
|
||||
import { useMouse } from '@/hooks/useMouse'
|
||||
import { QPolygon } from '@/components/fabric/QPolygon'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
export function useCommonUtils() {
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
@ -28,6 +29,7 @@ export function useCommonUtils() {
|
||||
const { drawDirectionArrow, addLengthText } = usePolygon()
|
||||
const { applyDormers } = useObjectBatch({})
|
||||
const { getIntersectMousePoint } = useMouse()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
|
||||
useEffect(() => {
|
||||
commonTextMode()
|
||||
@ -556,6 +558,12 @@ export function useCommonUtils() {
|
||||
if (object.id) {
|
||||
const group = canvas.getObjects().filter((obj) => obj.id === object.id)
|
||||
group.forEach((obj) => canvas?.remove(obj))
|
||||
// QLine 보조선은 lengthText 를 this.text(단수)에 저장해 아래 texts(배열) 제거에 안 걸린다.
|
||||
// lengthText 는 parentId === line.id 로 연결되므로 parentId 매칭으로 함께 제거 (삭제 시 text 잔존 방지).
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((obj) => obj.parentId === object.id && obj.name === 'lengthText')
|
||||
.forEach((obj) => canvas?.remove(obj))
|
||||
}
|
||||
|
||||
if (object.type === 'group') {
|
||||
@ -617,7 +625,8 @@ export function useCommonUtils() {
|
||||
}
|
||||
|
||||
const buttonAct = dormerName == BATCH_TYPE.TRIANGLE_DORMER ? 3 : 4
|
||||
applyDormers(dormerParams, buttonAct)
|
||||
// [undo 중복 fix] copyObject 에서 이미 saveSnapshot 을 호출했으므로 applyDormers 의 스냅샷은 건너뛴다.
|
||||
applyDormers(dormerParams, buttonAct, true)
|
||||
} else if (obj.name === 'roof' && obj.type === 'QPolygon') {
|
||||
// roof(QPolygon) 객체는 순환 참조(lines[].parent -> polygon)로 인해
|
||||
// fabric.clone() 사용 시 Maximum call stack size exceeded 에러 발생
|
||||
@ -834,6 +843,7 @@ export function useCommonUtils() {
|
||||
}
|
||||
|
||||
const deleteObject = () => {
|
||||
saveSnapshot()
|
||||
const selectedObj = canvas?.getActiveObjects()
|
||||
if (selectedObj) {
|
||||
selectedObj.forEach((obj) => {
|
||||
@ -863,6 +873,7 @@ export function useCommonUtils() {
|
||||
}
|
||||
|
||||
const copyObject = () => {
|
||||
saveSnapshot()
|
||||
const obj = canvas?.getActiveObject()
|
||||
commonCopyObject(obj)
|
||||
}
|
||||
|
||||
@ -21,6 +21,7 @@ import { MENU } from '@/common/common'
|
||||
import { useTrestle } from '@/hooks/module/useTrestle'
|
||||
import { useOrientation } from '@/hooks/module/useOrientation'
|
||||
import { corridorDimensionSelector } from '@/store/settingAtom'
|
||||
import { useUndoRedo, isUndoRedoRestoring } from '@/hooks/useUndoRedo'
|
||||
|
||||
/**
|
||||
* 메뉴 처리 훅
|
||||
@ -36,6 +37,7 @@ export default function useMenu() {
|
||||
const { clear: trestleClear, setAllModuleSurfaceIsComplete } = useTrestle()
|
||||
const { nextStep } = useOrientation()
|
||||
const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector)
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
const handleMenu = (type) => {
|
||||
closeAll()
|
||||
if (type === 'outline') {
|
||||
@ -67,6 +69,8 @@ export default function useMenu() {
|
||||
addPopup(popupId, 1, <RoofAllocationSetting id={popupId} />)
|
||||
break
|
||||
case MENU.ROOF_COVERING.ALL_REMOVE:
|
||||
// undo/redo 복원 중에는 confirm 창을 띄우지 않는다
|
||||
if (isUndoRedoRestoring()) break
|
||||
deleteAllSurfacesAndObjects()
|
||||
break
|
||||
}
|
||||
@ -89,6 +93,8 @@ export default function useMenu() {
|
||||
addPopup(popupId, 1, <ObjectSetting id={popupId} />)
|
||||
break
|
||||
case MENU.BATCH_CANVAS.ALL_REMOVE:
|
||||
// undo/redo 복원 중에는 confirm 창을 띄우지 않는다
|
||||
if (isUndoRedoRestoring()) break
|
||||
deleteAllSurfacesAndObjects()
|
||||
break
|
||||
}
|
||||
|
||||
@ -8,6 +8,7 @@ import { useLine } from '@/hooks/useLine'
|
||||
import { outerLinePointsState } from '@/store/outerLineAtom'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
import { useText } from '@/hooks/useText'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
const ROOF_COLOR = {
|
||||
0: 'rgb(199,240,213)',
|
||||
@ -26,6 +27,7 @@ export function useRoofFn() {
|
||||
const { setPolygonLinesActualSize } = usePolygon()
|
||||
const { changeCorridorDimensionText } = useText()
|
||||
const [outerLinePoints, setOuterLinePoints] = useRecoilState(outerLinePointsState)
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
|
||||
//면형상 선택 클릭시 지붕 패턴 입히기
|
||||
function setSurfaceShapePattern(polygon, mode = 'onlyBorder', trestleMode = false, roofMaterial, isForceChange = false, isDisplay = false) {
|
||||
@ -248,6 +250,9 @@ export function useRoofFn() {
|
||||
})
|
||||
|
||||
canvas.on('mouse:down', (event) => {
|
||||
// checkPolygon 을 캔버스에서 제거한 뒤 스냅샷을 찍어야 빨간 사각형이 포함되지 않음
|
||||
canvas.remove(checkPolygon)
|
||||
saveSnapshot()
|
||||
let mousePos = canvas.getPointer(event.e)
|
||||
mousePos = { x: Math.round(mousePos.x), y: Math.round(mousePos.y) }
|
||||
|
||||
@ -275,6 +280,12 @@ export function useRoofFn() {
|
||||
originY: 'center',
|
||||
objectCaching: false,
|
||||
})
|
||||
// [보조선 이중이동 fix] 지붕선 클릭 시 설정된 _preDragLeft 가 남아 있으면 polygonMoved 핸들러가
|
||||
// 내부 보조선(ridge 등 parentId===roof.id)을 delta 만큼 한 번 이동시키고, 아래 allRoofObject
|
||||
// 평행이동이 또 이동시켜 보조선이 2×delta 로 어긋난다(줌 축소+원거리 이동 시 크게 틀어짐).
|
||||
// fire 전에 정리해 polygonMoved 의 자식 이동을 막고, 평행이동은 아래에서 한 번만 수행한다.
|
||||
roof._preDragLeft = null
|
||||
roof._preDragTop = null
|
||||
roof.fire('polygonMoved')
|
||||
roof.fire('modified')
|
||||
|
||||
|
||||
@ -3,7 +3,6 @@ import { QPolygon } from '@/components/fabric/QPolygon'
|
||||
import { canvasState } from '@/store/canvasAtom'
|
||||
import { polygonToTurfPolygon } from '@/util/canvas-util'
|
||||
import { useRecoilValue } from 'recoil'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import * as turf from '@turf/turf'
|
||||
import { useSwal } from '../useSwal'
|
||||
import { useModuleBasicSetting } from './useModuleBasicSetting'
|
||||
@ -12,6 +11,7 @@ import { selectedModuleState } from '@/store/selectedModuleOptions'
|
||||
import { useCircuitTrestle } from '../useCirCuitTrestle'
|
||||
import { useTrestle } from './useTrestle'
|
||||
import { useTurf } from '@/hooks/common/useTurf'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { logger } from '@/util/logger'
|
||||
|
||||
// [MODULE-NAN-DIAG 2026-05-18] NaN 좌표가 어디서 module 로 진입하는지 추적용.
|
||||
@ -62,6 +62,7 @@ export function useModule() {
|
||||
const { setModuleStatisticsData, resetCircuits } = useCircuitTrestle()
|
||||
const { clear: removeTrestleMaterials } = useTrestle()
|
||||
const { checkModuleDisjointSurface } = useTurf()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
|
||||
// clone() 대신 직접 새 QPolygon을 생성하여 Maximum call stack 방지
|
||||
const createModuleCopy = (module, newLeft, newTop, overrides = {}) => {
|
||||
@ -121,6 +122,7 @@ export function useModule() {
|
||||
})
|
||||
return
|
||||
}
|
||||
saveSnapshot()
|
||||
// canvas.discardActiveObject() //선택해제
|
||||
|
||||
const isSetupModules = getOtherModules(selectedObj)
|
||||
@ -200,6 +202,7 @@ export function useModule() {
|
||||
|
||||
removeTrestleMaterials()
|
||||
}
|
||||
saveSnapshot()
|
||||
const modules = type === 'row' ? getRowModules(activeModule) : getColumnModules(activeModule)
|
||||
const otherModules = getOtherModules(modules)
|
||||
const objects = getObjects()
|
||||
@ -250,6 +253,7 @@ export function useModule() {
|
||||
}
|
||||
|
||||
const moduleMoveAll = (length, direction, surfaceArray) => {
|
||||
saveSnapshot()
|
||||
surfaceArray.forEach((surface) => {
|
||||
const modules = canvas
|
||||
.getObjects()
|
||||
@ -301,6 +305,7 @@ export function useModule() {
|
||||
}
|
||||
|
||||
const moduleCopyAll = (length, direction, surfaceArray) => {
|
||||
saveSnapshot()
|
||||
surfaceArray.forEach((surface) => {
|
||||
const modules = canvas
|
||||
.getObjects()
|
||||
@ -356,6 +361,7 @@ export function useModule() {
|
||||
|
||||
const moduleCopy = (length, direction) => {
|
||||
if (canvas.getActiveObjects().length === 0) return
|
||||
saveSnapshot()
|
||||
const activeModuleIds = canvas.getActiveObjects().map((obj) => obj.id)
|
||||
const modules = canvas.getObjects().filter((obj) => activeModuleIds.includes(obj.id))
|
||||
const objects = getObjects()
|
||||
@ -428,6 +434,7 @@ export function useModule() {
|
||||
|
||||
removeTrestleMaterials()
|
||||
}
|
||||
saveSnapshot()
|
||||
const modules = type === 'row' ? getRowModules(activeModule) : getColumnModules(activeModule)
|
||||
const otherModules = canvas.getObjects().filter((obj) => obj.surfaceId === modules[0].surfaceId && obj.name === POLYGON_TYPE.MODULE)
|
||||
const objects = getObjects()
|
||||
@ -500,6 +507,7 @@ export function useModule() {
|
||||
if (surfaces.some((surface) => surface.isComplete)) {
|
||||
removeTrestleMaterials()
|
||||
}
|
||||
saveSnapshot()
|
||||
canvas.discardActiveObject()
|
||||
moduleSetupSurface.set({ modules: otherModules })
|
||||
canvas.remove(...columnModules)
|
||||
@ -634,6 +642,7 @@ export function useModule() {
|
||||
if (surfaces.some((surface) => surface.isComplete)) {
|
||||
removeTrestleMaterials()
|
||||
}
|
||||
saveSnapshot()
|
||||
canvas.discardActiveObject()
|
||||
moduleSetupSurface.set({ modules: otherModules })
|
||||
canvas.remove(...rowModules)
|
||||
@ -994,10 +1003,12 @@ export function useModule() {
|
||||
return
|
||||
}
|
||||
|
||||
saveSnapshot()
|
||||
// [MODULE-MULTI-SELECT fix 2026-06-18] 단수 버전 잔재(surface/modules/activeModule 미정의 참조)로 인한
|
||||
// ReferenceError 제거. 모듈 제거는 아래 activeModules.forEach, surface.modules 갱신은 affectedSurfaces
|
||||
// 루프가 이미 처리하므로 중복이기도 함.
|
||||
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))
|
||||
const affectedSurfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && surfaceIds.includes(obj.id))
|
||||
|
||||
if (affectedSurfaces.some((surface) => surface.isComplete)) {
|
||||
removeTrestleMaterials()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
|
||||
import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'
|
||||
|
||||
import {
|
||||
canvasSettingState,
|
||||
@ -33,18 +33,21 @@ import { isObjectNotEmpty } from '@/util/common-utils'
|
||||
import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle'
|
||||
import { useMode } from '@/hooks/useMode'
|
||||
import { useTurf } from '@/hooks/common/useTurf'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
export function useModuleBasicSetting(tabNum) {
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
const { getMessage } = useMessage()
|
||||
const roofDisplay = useRecoilValue(roofDisplaySelector)
|
||||
const [moduleSetupSurface, setModuleSetupSurface] = useRecoilState(moduleSetupSurfaceState)
|
||||
const resetModuleSetupSurface = useResetRecoilState(moduleSetupSurfaceState)
|
||||
const { addCanvasMouseEventListener, initEvent, removeMouseEvent, addTargetMouseEventListener } = useEvent()
|
||||
const { swalFire } = useSwal()
|
||||
const compasDeg = useRecoilValue(compasDegAtom)
|
||||
const { setSurfaceShapePattern } = useRoofFn()
|
||||
const checkedModule = useRecoilValue(checkedModuleState)
|
||||
const [isManualModuleSetup, setIsManualModuleSetup] = useRecoilState(isManualModuleSetupState)
|
||||
|
||||
const [isManualModuleLayoutSetup, setIsManualModuleLayoutSetup] = useRecoilState(isManualModuleLayoutSetupState)
|
||||
const canvasSetting = useRecoilValue(canvasSettingState)
|
||||
const moduleSelectionData = useRecoilValue(moduleSelectionDataState)
|
||||
@ -62,9 +65,11 @@ export function useModuleBasicSetting(tabNum) {
|
||||
const setModuleRowColArray = useSetRecoilState(moduleRowColArrayState)
|
||||
|
||||
const { checkModuleDisjointSurface } = useTurf()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
|
||||
useEffect(() => {
|
||||
initEvent()
|
||||
resetModuleSetupSurface()
|
||||
return () => {
|
||||
//수동 설치시 초기화
|
||||
removeMouseEvent('mouse:up')
|
||||
@ -585,7 +590,7 @@ export function useModuleBasicSetting(tabNum) {
|
||||
*/
|
||||
const manualModuleSetup = () => {
|
||||
//레이아웃 수동설치 토글
|
||||
const moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴
|
||||
let moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴
|
||||
const isChidori = moduleSetupOption.isChidori
|
||||
|
||||
if (isManualModuleSetup) {
|
||||
@ -641,6 +646,9 @@ export function useModuleBasicSetting(tabNum) {
|
||||
//마우스 이벤트 삭제 후 재추가
|
||||
const mousePoint = canvas.getPointer(e.e)
|
||||
|
||||
// undo 후 캔버스 객체가 교체될 수 있으므로 매번 다시 가져옴
|
||||
moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) || []
|
||||
|
||||
for (let i = 0; i < moduleSetupSurfaces.length; i++) {
|
||||
//배치면이 여러개 일때 옮겨가면서 동작해야함
|
||||
const moduleSetupSurface = moduleSetupSurfaces[i]
|
||||
@ -931,8 +939,10 @@ export function useModuleBasicSetting(tabNum) {
|
||||
addCanvasMouseEventListener('mouse:up', (e) => {
|
||||
let isIntersection = true
|
||||
|
||||
if (!trestlePolygon) return
|
||||
|
||||
// 혼합 설치 불가능한 모듈인지 확인 Y는 Y끼리 N은 N끼리만 설치 가능
|
||||
if (trestlePolygon.modules.length > 0) {
|
||||
if (trestlePolygon.modules && trestlePolygon.modules.length > 0) {
|
||||
//이미 설치된 모듈중에 한개만 가져옴
|
||||
const mixAsgYn = trestlePolygon.modules[0].moduleInfo.mixAsgYn
|
||||
//현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능
|
||||
@ -965,10 +975,10 @@ export function useModuleBasicSetting(tabNum) {
|
||||
|
||||
tempModule.set({ points: rectPoints })
|
||||
const tempTurfModule = polygonToTurfPolygon(tempModule)
|
||||
|
||||
//도머 객체를 가져옴
|
||||
if (batchObjects) {
|
||||
batchObjects.forEach((object) => {
|
||||
//도머 객체를 가져옴 (undo 후 캔버스 객체가 교체될 수 있으므로 다시 가져옴)
|
||||
const currentBatchObjects = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.OBJECT_SURFACE)
|
||||
if (currentBatchObjects) {
|
||||
currentBatchObjects.forEach((object) => {
|
||||
// [OPENING-MIN-SIZE 2026-05-08] 0-면적 객체는 turf 변환 시 throw 위험 → skip
|
||||
const _pts = object.getCurrentPoints?.() || object.points || []
|
||||
if (_pts.length >= 2 && _pts.every((p) => p.x === _pts[0].x && p.y === _pts[0].y)) {
|
||||
@ -1013,6 +1023,7 @@ export function useModuleBasicSetting(tabNum) {
|
||||
toFixed: 2,
|
||||
})
|
||||
|
||||
saveSnapshot()
|
||||
canvas?.add(manualModule)
|
||||
canvas.bringToFront(manualModule)
|
||||
manualDrawModules.push(manualModule)
|
||||
@ -1050,7 +1061,7 @@ export function useModuleBasicSetting(tabNum) {
|
||||
|
||||
//단 열수 지정배치
|
||||
const manualModuleLayoutSetup = (layoutSetupRef) => {
|
||||
const moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴
|
||||
let moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) //모듈설치면를 가져옴
|
||||
|
||||
const isChidori = moduleSetupOption.isChidori //치도리 여부
|
||||
const setupLocation = moduleSetupOption.setupLocation //모듈 설치 위치 처마, 용마루
|
||||
@ -1148,6 +1159,9 @@ export function useModuleBasicSetting(tabNum) {
|
||||
//마우스 이벤트 삭제 후 재추가
|
||||
const mousePoint = canvas.getPointer(e.e)
|
||||
|
||||
// undo 후 캔버스 객체가 교체될 수 있으므로 매번 다시 가져옴
|
||||
moduleSetupSurfaces = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE) || []
|
||||
|
||||
for (let i = 0; i < moduleSetupSurfaces.length; i++) {
|
||||
//배치면이 여러개 일때 옮겨가면서 동작해야함
|
||||
const moduleSetupSurface = moduleSetupSurfaces[i]
|
||||
@ -1425,7 +1439,9 @@ export function useModuleBasicSetting(tabNum) {
|
||||
})
|
||||
|
||||
addCanvasMouseEventListener('mouse:up', (e) => {
|
||||
if (trestlePolygon.modules.length > 0) {
|
||||
if (!trestlePolygon) return
|
||||
|
||||
if (trestlePolygon.modules && trestlePolygon.modules.length > 0) {
|
||||
//이미 설치된 모듈중에 한개만 가져옴
|
||||
const mixAsgYn = trestlePolygon.modules[0].moduleInfo.mixAsgYn
|
||||
//현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능
|
||||
@ -1479,6 +1495,7 @@ export function useModuleBasicSetting(tabNum) {
|
||||
}
|
||||
|
||||
if (tempModule) {
|
||||
saveSnapshot()
|
||||
let startX, startY
|
||||
let installedLastHeightCoord = 0 //마지막으로 설치된 모듈의 좌표
|
||||
let installedHeightModuleCount = 0
|
||||
@ -2930,6 +2947,7 @@ export function useModuleBasicSetting(tabNum) {
|
||||
return orderA - orderB
|
||||
})
|
||||
|
||||
saveSnapshot()
|
||||
sortedModuleSetupSurfaces.forEach((moduleSetupSurface, index) => {
|
||||
moduleSetupSurface.fire('mousedown')
|
||||
const moduleSetupArray = []
|
||||
@ -3764,6 +3782,7 @@ export function useModuleBasicSetting(tabNum) {
|
||||
canvas?.add(manualModule)
|
||||
manualDrawModules.push(manualModule)
|
||||
setModuleStatisticsData()
|
||||
saveSnapshot()
|
||||
} else {
|
||||
swalFire({ text: getMessage('module.place.overlab'), icon: 'warning' })
|
||||
}
|
||||
@ -3775,6 +3794,7 @@ export function useModuleBasicSetting(tabNum) {
|
||||
}
|
||||
// getModuleStatistics()
|
||||
setModuleStatisticsData()
|
||||
saveSnapshot()
|
||||
} else {
|
||||
if (moduleSetupSurfaces) {
|
||||
//수동모드 해제시 모듈 설치면 선택 잠금
|
||||
@ -4083,6 +4103,7 @@ export function useModuleBasicSetting(tabNum) {
|
||||
})
|
||||
}
|
||||
|
||||
saveSnapshot()
|
||||
moduleSetupSurfaces.forEach((moduleSetupSurface, index) => {
|
||||
moduleSetupSurface.fire('mousedown')
|
||||
const moduleSetupArray = []
|
||||
|
||||
@ -25,6 +25,7 @@ import { usePopup } from '@/hooks/usePopup'
|
||||
import { useMouse } from '@/hooks/useMouse'
|
||||
import { useTurf } from '@/hooks/common/useTurf'
|
||||
import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
const { getMessage } = useMessage()
|
||||
@ -41,6 +42,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
const { getIntersectMousePoint } = useMouse()
|
||||
const { checkModuleDisjointSurface } = useTurf()
|
||||
const currentObject = useRecoilValue(currentObjectState)
|
||||
const { saveSnapshot, popLastSnapshot } = useUndoRedo()
|
||||
|
||||
useEffect(() => {
|
||||
if (canvas) {
|
||||
@ -99,6 +101,8 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
let rect, isDown, origX, origY
|
||||
|
||||
let selectedSurface
|
||||
|
||||
saveSnapshot()
|
||||
//프리입력
|
||||
|
||||
if (selectedType === INPUT_TYPE.FREE) {
|
||||
@ -205,7 +209,9 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
initEvent()
|
||||
|
||||
closeAll()
|
||||
addPopup(popupId, 1, <SizeSetting id={popupId} target={rect} />)
|
||||
// 초기 배치 흐름에서 연 사이즈 설정: 배치 시작(applyOpeningAndShadow)이 이미 "오브젝트 없음" 스냅샷을
|
||||
// 찍어뒀으므로, 이 사이즈 확정은 중복 스냅샷을 만들지 않게 한다(undo 한 번에 제거되도록).
|
||||
addPopup(popupId, 1, <SizeSetting id={popupId} target={rect} initialPlacement />)
|
||||
}
|
||||
})
|
||||
} else if (selectedType === INPUT_TYPE.DIMENSION) {
|
||||
@ -307,7 +313,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
* @param {*} surfaceShapePolygons
|
||||
* @returns
|
||||
*/
|
||||
const applyDormers = ({ height, width, pitch, offsetRef, offsetWidthRef, directionRef }, buttonAct) => {
|
||||
const applyDormers = ({ height, width, pitch, offsetRef, offsetWidthRef, directionRef }, buttonAct, skipSnapshot = false) => {
|
||||
const dormerName = buttonAct === 3 ? BATCH_TYPE.TRIANGLE_DORMER : BATCH_TYPE.PENTAGON_DORMER
|
||||
const dormerTempName = buttonAct === 3 ? BATCH_TYPE.TRIANGLE_DORMER_TEMP : BATCH_TYPE.PENTAGON_DORMER_TEMP
|
||||
|
||||
@ -325,6 +331,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
return
|
||||
}
|
||||
|
||||
// [undo 중복 fix] 도머 복사(commonCopyObject)는 copyObject 에서 이미 saveSnapshot 을 호출한 뒤
|
||||
// applyDormers 를 재사용하므로, 복사 경로에서는 skipSnapshot=true 로 중복 스냅샷을 막는다.
|
||||
if (!skipSnapshot) saveSnapshot()
|
||||
|
||||
let theta = 0
|
||||
|
||||
let bottomLength = 0,
|
||||
@ -1299,7 +1309,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
return { leftPoints, rightPoints, groupPoints }
|
||||
}
|
||||
|
||||
const resizeObjectBatch = (side, target, width, height, popupId) => {
|
||||
const resizeObjectBatch = (side, target, width, height, popupId, skipSnapshot = false) => {
|
||||
// 초기 배치(applyOpeningAndShadow)에서 열린 사이즈 설정이면 배치 시작 스냅샷과 중복되므로 건너뛴다.
|
||||
// 컨텍스트 메뉴 "사이즈 수정"으로 기존 오브젝트를 수정할 땐 skipSnapshot=false 라 별개 undo 로 남는다.
|
||||
if (!skipSnapshot) saveSnapshot()
|
||||
const objectWidth = target.width
|
||||
const objectHeight = target.height
|
||||
|
||||
@ -1421,6 +1434,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0]
|
||||
|
||||
if (obj) {
|
||||
saveSnapshot()
|
||||
obj.set({
|
||||
lockMovementX: false,
|
||||
lockMovementY: false,
|
||||
@ -1428,8 +1442,6 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
|
||||
const originObj = { ...obj }
|
||||
const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints())
|
||||
const originLeft = obj.left
|
||||
const originTop = obj.top
|
||||
|
||||
addCanvasMouseEventListener('mouse:up', (e) => {
|
||||
//개구, 그림자 타입일 경우 폴리곤 타입 변경
|
||||
@ -1474,15 +1486,11 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
})
|
||||
|
||||
if (obj.name === BATCH_TYPE.TRIANGLE_DORMER || obj.name === BATCH_TYPE.PENTAGON_DORMER) {
|
||||
const calcLeft = obj.left - originLeft
|
||||
const calcTop = obj.top - originTop
|
||||
|
||||
const currentDormerPoints = obj.getCurrentPoints().map((item) => {
|
||||
return {
|
||||
x: item.x + calcLeft,
|
||||
y: item.y + calcTop,
|
||||
}
|
||||
})
|
||||
// getCurrentPoints() 는 calcTransformMatrix() 기반으로 이미 이동된 월드 좌표를 반환한다.
|
||||
// 과거 코드에서 (obj.left - originLeft) delta 를 다시 더해 2배 이동된 좌표로 검사 →
|
||||
// 빠르게 이동 시 도머가 지붕 내부에 있어도 within=false 가 되어 alert 오발생.
|
||||
obj.setCoords()
|
||||
const currentDormerPoints = obj.getCurrentPoints()
|
||||
|
||||
const turfObject = pointsToTurfPolygon(currentDormerPoints)
|
||||
|
||||
@ -1513,6 +1521,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
const copyObjectBatch = () => {
|
||||
const obj = canvas.getActiveObject()
|
||||
if (obj) {
|
||||
saveSnapshot()
|
||||
let clonedObj = null
|
||||
const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0]
|
||||
|
||||
@ -1595,6 +1604,9 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
}
|
||||
|
||||
const dormerOffset = (arrow1, arrow2, length1, length2) => {
|
||||
// [undo fix] 도머 이동 전 상태를 스냅샷으로 저장해야 undo 시 도머가 원위치로 복원된다.
|
||||
// (이동 후 저장하면 undo 해도 도머는 그 자리에 남는다.) 검증 실패 시 popLastSnapshot 으로 제거.
|
||||
saveSnapshot()
|
||||
length1 = parseInt(length1) / 10
|
||||
length2 = parseInt(length2) / 10
|
||||
|
||||
@ -1623,22 +1635,17 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
}
|
||||
}
|
||||
|
||||
// [좌표 검증 fix] moveObjectBatch 와 동일하게 setCoords()+getCurrentPoints() 로 실제 이동된
|
||||
// 월드좌표를 검사한다. 과거처럼 groupPoints+delta 로 검사하면 2배 이동된 좌표가 되어
|
||||
// 도머가 지붕 내부에 있어도 within=false → '지붕내 설치' alert 오발생.
|
||||
dormer.setCoords()
|
||||
const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === dormer.parentId)[0]
|
||||
const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints())
|
||||
|
||||
let turfDormer = pointsToTurfPolygon(rectToPolygon(dormer))
|
||||
let currentPoints = []
|
||||
if (dormer.name === BATCH_TYPE.TRIANGLE_DORMER || dormer.name === BATCH_TYPE.PENTAGON_DORMER) {
|
||||
const calcLeft = dormer.left - left
|
||||
const calcTop = dormer.top - top
|
||||
|
||||
currentPoints = dormer.groupPoints.map((item) => {
|
||||
return {
|
||||
x: item.x + calcLeft,
|
||||
y: item.y + calcTop,
|
||||
}
|
||||
})
|
||||
|
||||
currentPoints = dormer.getCurrentPoints()
|
||||
turfDormer = pointsToTurfPolygon(currentPoints)
|
||||
}
|
||||
|
||||
@ -1651,7 +1658,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
|
||||
//위치 원복`
|
||||
dormer.left = left
|
||||
dormer.top = top
|
||||
dormer.setCoords()
|
||||
|
||||
// [undo 중복 fix] 검증 실패(원복) 시 위에서 저장한 스냅샷을 제거해 불필요한 undo 단계를 막는다.
|
||||
popLastSnapshot()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@ -5,11 +5,12 @@ import { useEvent } from '@/hooks/useEvent'
|
||||
import { useMouse } from '@/hooks/useMouse'
|
||||
import { useLine } from '@/hooks/useLine'
|
||||
import { useTempGrid } from '@/hooks/useTempGrid'
|
||||
import { calculateIntersection, distanceBetweenPoints, findClosestPoint, isPointOnLine } from '@/util/canvas-util'
|
||||
import { calculateIntersection, distanceBetweenPoints, findClosestPoint, isPointOnLine, translateOuterBoundaryTwin } from '@/util/canvas-util'
|
||||
import { fabric } from 'fabric'
|
||||
import { useAdsorptionPoint } from '@/hooks/useAdsorptionPoint'
|
||||
import { useSwal } from '@/hooks/useSwal'
|
||||
import { usePopup } from '@/hooks/usePopup'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { calculateAngle, isSamePoint } from '@/util/qpolygon-utils'
|
||||
import { POLYGON_TYPE } from '@/common/common'
|
||||
import { useMessage } from '../useMessage'
|
||||
@ -36,6 +37,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
|
||||
const { getAdsorptionPoints } = useAdsorptionPoint()
|
||||
const { closePopup } = usePopup()
|
||||
const { getMessage } = useMessage()
|
||||
const { saveSnapshot, setDrawing, popLastSnapshot } = useUndoRedo()
|
||||
|
||||
const adsorptionRange = useRecoilValue(adsorptionRangeState)
|
||||
|
||||
@ -44,6 +46,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
|
||||
const mousePointerArr = useRef([])
|
||||
const roofAdsorptionPoints = useRef([])
|
||||
const lineHistory = useRef([])
|
||||
const isFix = useRef(false)
|
||||
|
||||
const length1Ref = useRef(0)
|
||||
const length2Ref = useRef(0)
|
||||
@ -117,6 +120,24 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
|
||||
}
|
||||
}, [])
|
||||
|
||||
// 보조선 그리기 시작 시 스냅샷 저장 (undo 하면 보조선 적용 전 상태로 복원).
|
||||
// 그리기 진행 중에는 undo/redo/snapshot 을 차단하므로 force 로 저장한다.
|
||||
// 편집 모달(AuxiliaryEdit) 은 id 없이 hook 을 호출하므로 제외한다.
|
||||
useEffect(() => {
|
||||
if (!id) return
|
||||
const roofs = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||
if (!roofs || roofs.length === 0) return
|
||||
setDrawing(true)
|
||||
saveSnapshot(null, { force: true })
|
||||
return () => {
|
||||
setDrawing(false)
|
||||
// 확정(완료)하지 않고 닫은 경우 스냅샷 제거
|
||||
if (!isFix.current) {
|
||||
popLastSnapshot()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (type === null) {
|
||||
return
|
||||
@ -151,6 +172,11 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
|
||||
roof.innerLines = roof.innerLines.filter((inner) => inner !== object)
|
||||
})
|
||||
|
||||
// [BOUNDARY-SYNC 2026-06-23] object 가 처마 외곽선(polygon.lines)과 좌표가 같은 "경계 복제본"이면,
|
||||
// 그 처마선 쌍둥이도 같은 delta(x,y)로 함께 이동시켜 두 경계 표현을 동기화한다(지붕면 할당 가짜 면 방지).
|
||||
// 일반 보조선은 동일 좌표 처마선이 없어 매칭 안 됨 → 영향 없음.
|
||||
translateOuterBoundaryTwin(canvas, object, x, y)
|
||||
|
||||
canvas.remove(object)
|
||||
canvas.setActiveObject(line)
|
||||
}
|
||||
@ -164,6 +190,13 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
|
||||
name: 'auxiliaryLine',
|
||||
})
|
||||
|
||||
// [복사본 roofId 유실 fix] addLine 이 내부에서 attributes 를 {} 로 초기화하므로,
|
||||
// 원본 attributes(roofId 등)를 복사해 복원한다. 미복원 시 복사본의 roofId 가 없어
|
||||
// 우클릭 '보조선 전체삭제' 등 roofId 기반 처리가 누락된다.
|
||||
if (newLine) {
|
||||
newLine.attributes = { ...object.attributes }
|
||||
}
|
||||
|
||||
// roofs의 innerLines에서 object와 같은 것을 찾아서 newLine으로 변경
|
||||
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||
roofs.forEach((roof) => {
|
||||
@ -887,6 +920,18 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
|
||||
return
|
||||
}
|
||||
|
||||
// lineHistory가 비어있으면 캔버스에서 직접 미확정 보조선을 찾아 제거
|
||||
if (lineHistory.current.length === 0) {
|
||||
const unfixedLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine' && !obj.isAuxiliaryFixed)
|
||||
if (unfixedLines.length > 0) {
|
||||
const lastLine = unfixedLines[unfixedLines.length - 1]
|
||||
canvas.remove(lastLine)
|
||||
canvas.renderAll()
|
||||
}
|
||||
addCanvasMouseEventListener('mouse:move', mouseMove)
|
||||
return
|
||||
}
|
||||
|
||||
const lastLine = lineHistory.current.pop()
|
||||
if (lastLine) {
|
||||
roofAdsorptionPoints.current = roofAdsorptionPoints.current.filter(
|
||||
@ -906,6 +951,9 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
|
||||
return
|
||||
}
|
||||
|
||||
// 완료 확정 → 마운트 시 저장한 "보조선 적용 전" 스냅샷을 유지 (언마운트 cleanup 에서 pop 하지 않음)
|
||||
isFix.current = true
|
||||
|
||||
// cutAuxiliary()
|
||||
|
||||
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||
@ -966,6 +1014,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
|
||||
roofBase.innerLines = lineHistory.current.length !== 0 ? [...roofInnerLines] : roofBase.innerLines
|
||||
canvas.renderAll()
|
||||
})
|
||||
|
||||
closePopup(id)
|
||||
}
|
||||
|
||||
|
||||
@ -11,12 +11,14 @@ import { useSwal } from '@/hooks/useSwal'
|
||||
import { usePopup } from '@/hooks/usePopup'
|
||||
import { getChonByDegree } from '@/util/canvas-util'
|
||||
import { settingModalFirstOptionsState } from '@/store/settingAtom'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { fabric } from 'fabric'
|
||||
import { QLine } from '@/components/fabric/QLine'
|
||||
import { reattachDebugLabels } from '@/components/fabric/QPolygon'
|
||||
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
|
||||
import { findInteriorPoint } from '@/util/skeleton-utils'
|
||||
import { logger } from '@/util/logger'
|
||||
import { checkKerabRules } from '@/util/kerab-rule-checker'
|
||||
import { debugCapture } from '@/util/debugCapture'
|
||||
import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
|
||||
|
||||
@ -45,6 +47,7 @@ export function useEavesGableEdit(id) {
|
||||
const typeRef = useRef(TYPES.EAVES)
|
||||
const { removeLine, addPitchTextsByOuterLines } = useLine()
|
||||
const { swalFire } = useSwal()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
|
||||
const { drawRoofPolygon } = useMode()
|
||||
const currentAngleType = useRecoilValue(currentAngleTypeSelector)
|
||||
@ -459,6 +462,8 @@ export function useEavesGableEdit(id) {
|
||||
} else {
|
||||
logger.log('[KERAB-RULE-CHECK] ' + phase + ' PASS')
|
||||
}
|
||||
// 기하 불변식 체커(R-45HIP/R-AXISRIDGE/R-CROSS/R-WEDGE). 위 R1~R4 와 상호보완.
|
||||
checkKerabRules(roof.innerLines, { roofId: roof.id, label: phase })
|
||||
} catch (err) {
|
||||
logger.warn('[KERAB-RULE-CHECK] error', err)
|
||||
}
|
||||
@ -550,6 +555,7 @@ export function useEavesGableEdit(id) {
|
||||
break
|
||||
}
|
||||
|
||||
saveSnapshot()
|
||||
// [KERAB-STATE-DUMP 2026-06-11] A/B타입(가로/세로 케라바) 토글 진입 시점 지붕 상태 진단.
|
||||
// 벽 type 지정은 있는데 내부 skLine 이 0인 "무에서 유" 케이스 파악용 — 사용자 설명 전 사실 수집.
|
||||
// 읽기 전용(좌표/visible/type 만 덤프), 동작 변경 없음. logger 게이트(local 만 출력).
|
||||
@ -2749,6 +2755,8 @@ export function useEavesGableEdit(id) {
|
||||
// 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치).
|
||||
// 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지).
|
||||
// 케라바패턴 라인(내 hip)은 surgicalAfter 의 ray-cast 가 이미 처리 → 제외.
|
||||
// [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] apexRes 를 항상 넘긴다. relocate 가 apexRes.ridgeHidden
|
||||
// 플래그로 골짜기 세로 힙(H-8) 끝점을 apex 로 끌어올리지 않고 제자리 앵커(위로 반전 방지)하기 때문.
|
||||
moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes)
|
||||
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
|
||||
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
|
||||
@ -3146,12 +3154,325 @@ export function useEavesGableEdit(id) {
|
||||
}
|
||||
return touched
|
||||
}
|
||||
const ridgeOps = resolveRidgeEndpoints()
|
||||
// 5a') 골짜기 마름모 생성 — A,B타입 골짜기 라인은 케라바+처마 조합이라
|
||||
// 라인변경(케라바→처마) 시 두 평행 힙( / / ) + 잇는 마루 = 마름모(힙—마루—힙)가 되어야 한다.
|
||||
// 두 힙은 *같은 방향*(평행)이고 *같은 깊이*면 끝점을 잇는 마루가 골짜기 라인과 평행
|
||||
// = 항상 수평/수직(마루 비대각 불변식 충족). 깊이 = 두 raycast hit 중 *짧은 쪽(min)*.
|
||||
// 짧은 쪽 = 먼저 닫히는 인접면 — 긴 쪽에 맞추면 짧은 면을 넘어 ><(R3 위반)이 되므로 min.
|
||||
// (사용자 확정 2026-06-22: 가로·세로 골짜기 변경 시 양쪽 다 마름모 생성.)
|
||||
// 마름모 깊이 산출 진단(로그/검증용).
|
||||
let adjHipDbg = null
|
||||
const buildValleyDiamond = () => {
|
||||
if (!resA || !resA.hip || !resA.hit || !resB || !resB.hip || !resB.hit) return null
|
||||
// 두 힙이 평행해야 마름모(잇는 마루 = 골짜기 라인과 평행). 방향이 다르면 마름모 아님 → 폴백.
|
||||
if (Math.hypot(resA.dir.x - resB.dir.x, resA.dir.y - resB.dir.y) > 0.05) return null
|
||||
// [KERAB-VALLEY-EAVES-RULE1 MIDLINE 2026-06-22] 마름모 깊이 = *결정적 중앙선 공식*.
|
||||
// 잇는 마루(수평/수직 불변식)는 골짜기 처마 roofLine 과 그 맞은편 평행 건물변의 *정확히 중앙*에 놓인다.
|
||||
// (straight-skeleton: 두 평행 처마변 사이 ridge 는 중앙선). 인접 힙 매칭·내부 ridge clamp(min raycast)는
|
||||
// 값이 매 실행마다 흔들려 폐기(사용자 확정 2026-06-22: "힙길이가 자꾸 바뀐다", 2820/3930 결정값 기대).
|
||||
// depth(축) = |중앙선축 − offset 코너축|, 힙길이 = depth × √2 (45° 불변식).
|
||||
// 중앙선축 = (원래 골짜기 roofLine 축 + 맞은편 평행변 축)/2.
|
||||
// · 원래 골짜기 roofLine 축: c1(=surgical offset 이전 nearestRoofPoint) — 가로 골짜기는 c1.y, 세로는 c1.x.
|
||||
// · 맞은편 평행변 축: 골짜기 중점에서 *안쪽(힙 방향 부호)* 으로 축정렬 ray 를 쏴 처음 만나는 *외곽 폴리곤 변*.
|
||||
// 내부선(transient HIP/RIDGE)은 제외 — 그게 값 흔들림의 근본원인이었음.
|
||||
const valleyHorizontal =
|
||||
Math.abs(pts[(edgeIdx + 1) % NP].x - pts[edgeIdx].x) >= Math.abs(pts[(edgeIdx + 1) % NP].y - pts[edgeIdx].y)
|
||||
// 원래 골짜기 roofLine 의 축값 + 중점(c1/c2 = surgical offset 이전 코너).
|
||||
const origValleyAxis = valleyHorizontal ? c1.y : c1.x
|
||||
const midX = (c1.x + c2.x) / 2
|
||||
const midY = (c1.y + c2.y) / 2
|
||||
// 안쪽 방향 부호 = 힙 진행 방향(가로 골짜기→y, 세로 골짜기→x).
|
||||
const sgn = valleyHorizontal ? Math.sign(resA.dir.y) || 1 : Math.sign(resA.dir.x) || 1
|
||||
// 골짜기 중점에서 축정렬 ray 를 외곽 폴리곤 변에만 쏴 맞은편 평행변 축값을 구함.
|
||||
const EPS = 0.01
|
||||
let oppAxis = null
|
||||
for (let i = 0; i < NP; i++) {
|
||||
const a = pts[i]
|
||||
const b = pts[(i + 1) % NP]
|
||||
if (valleyHorizontal) {
|
||||
// 변이 x=midX 를 가로지르나? 가로지른다면 그 지점의 y.
|
||||
if ((a.x - midX) * (b.x - midX) > EPS) continue
|
||||
if (Math.abs(a.x - b.x) < EPS) continue
|
||||
const t = (midX - a.x) / (b.x - a.x)
|
||||
if (t < -EPS || t > 1 + EPS) continue
|
||||
const y = a.y + t * (b.y - a.y)
|
||||
if (sgn * (y - midY) <= EPS) continue // 안쪽(힙 방향)만
|
||||
if (oppAxis === null || sgn * (y - oppAxis) < 0) oppAxis = y
|
||||
} else {
|
||||
if ((a.y - midY) * (b.y - midY) > EPS) continue
|
||||
if (Math.abs(a.y - b.y) < EPS) continue
|
||||
const t = (midY - a.y) / (b.y - a.y)
|
||||
if (t < -EPS || t > 1 + EPS) continue
|
||||
const x = a.x + t * (b.x - a.x)
|
||||
if (sgn * (x - midX) <= EPS) continue
|
||||
if (oppAxis === null || sgn * (x - oppAxis) < 0) oppAxis = x
|
||||
}
|
||||
}
|
||||
const cornerAxis = valleyHorizontal ? newC1.y : newC1.x
|
||||
// raycast 폴백(중앙선 못 구한 경우의 null-safety 만): 두 raycast hit 중 짧은 쪽.
|
||||
const depthA = Math.hypot(resA.hit.x - newC1.x, resA.hit.y - newC1.y)
|
||||
const depthB = Math.hypot(resB.hit.x - newC2.x, resB.hit.y - newC2.y)
|
||||
const fallbackD = Math.min(depthA, depthB)
|
||||
// [KERAB-VALLEY-EAVES-RULE1 CLAMP 2026-06-22] 두 힙은 *각자 독립적으로* 중앙선까지 확장하되,
|
||||
// 가는 길에 *마루/힙* 을 먼저 만나면 *그 힙만* 거기서 멈춤(사용자 확정: "만나는 그 힙만 멈추고,
|
||||
// 다른 힙은 계속 중앙까지"). 넘어가면 R3(교차/오버슈트) 위반.
|
||||
// ★ roofLine 히트는 막는 대상 아님(중앙선이 지붕 안이면 정상) → 마루/힙 hit 만 obstruction.
|
||||
// ★ 두 힙 길이가 *다를 수 있음* → 끝을 잇는 게 축정렬이면 마루, 아니면 힙으로 연결(아래).
|
||||
const isObstr = (kind) => kind === LINE_TYPE.SUBLINE.HIP || kind === LINE_TYPE.SUBLINE.RIDGE
|
||||
let dA = depthA
|
||||
let dB = depthB
|
||||
let source = 'fallback-min-raycast'
|
||||
let midlineAxis = null
|
||||
let dDet = null
|
||||
if (oppAxis !== null) {
|
||||
midlineAxis = (origValleyAxis + oppAxis) / 2
|
||||
const dM = Math.abs(midlineAxis - cornerAxis) * Math.SQRT2
|
||||
if (dM >= 1) {
|
||||
dDet = dM
|
||||
dA = resA.hit && isObstr(resA.hitKind) ? Math.min(dM, depthA) : dM
|
||||
dB = resB.hit && isObstr(resB.hitKind) ? Math.min(dM, depthB) : dM
|
||||
source = dA < dM - 1e-6 || dB < dM - 1e-6 ? 'midline-clamped' : 'midline'
|
||||
}
|
||||
}
|
||||
adjHipDbg = {
|
||||
valleyHorizontal,
|
||||
origValleyAxis: Math.round(origValleyAxis * 10) / 10,
|
||||
oppAxis: oppAxis === null ? null : Math.round(oppAxis * 10) / 10,
|
||||
midlineAxis: midlineAxis === null ? null : Math.round(midlineAxis * 10) / 10,
|
||||
cornerAxis: Math.round(cornerAxis * 10) / 10,
|
||||
dDet: dDet === null ? null : Math.round(dDet),
|
||||
depthA: Math.round(depthA),
|
||||
depthB: Math.round(depthB),
|
||||
obstr: { a: resA.hitKind, b: resB.hitKind },
|
||||
usedDA: Math.round(dA),
|
||||
usedDB: Math.round(dB),
|
||||
source,
|
||||
}
|
||||
if (dA < 1 || dB < 1) return null
|
||||
// [KERAB-VALLEY-EAVES-RULE1 CASCADE 2026-06-22] 힙이 *중앙선 전에* 마루/힙을 만나는 경우
|
||||
// (depth < 중앙선 depth): 마름모(중앙 clamp + connector) 가 아니라 *순차 절삭 연쇄*.
|
||||
// 사용자 확정: CCW 로 먼저 그린 힙이 그 마루를 *흡수측 절삭*(--< : 경계=박공 출발측 보존,
|
||||
// 노치측 흡수) → 그 절삭으로 다른 힙의 장애물(마루 근측)이 사라져 다른 힙은 *다음 라인까지 확장*.
|
||||
// ★ 2820 대칭 마름모 케이스(장애물이 중앙선 *너머*)는 depth ≥ dDet 라 여기 안 걸림 → 기존 경로 보존.
|
||||
const cascadeA = !!(resA.hit && isObstr(resA.hitKind) && dDet !== null && depthA < dDet - 1e-6)
|
||||
const cascadeB = !!(resB.hit && isObstr(resB.hitKind) && dDet !== null && depthB < dDet - 1e-6)
|
||||
if (cascadeA || cascadeB) {
|
||||
const cops = []
|
||||
const rptsC = roof.points || []
|
||||
const TOLC = 2.0
|
||||
const d2s = (p, a, b) => {
|
||||
const ex = b.x - a.x
|
||||
const ey = b.y - a.y
|
||||
const l2 = ex * ex + ey * ey || 1
|
||||
let t = ((p.x - a.x) * ex + (p.y - a.y) * ey) / l2
|
||||
t = Math.max(0, Math.min(1, t))
|
||||
return Math.hypot(p.x - (a.x + t * ex), p.y - (a.y + t * ey))
|
||||
}
|
||||
const minEd = (pt) => {
|
||||
let m = Infinity
|
||||
for (let i = 0, j = rptsC.length - 1; i < rptsC.length; j = i++) {
|
||||
const d = d2s(pt, rptsC[j], rptsC[i])
|
||||
if (d < m) m = d
|
||||
}
|
||||
return m
|
||||
}
|
||||
const hipsC = [resA, resB].filter((r) => r && r.hip)
|
||||
// 0) ★ 내부확장박스(kerabValleyOverlapLine) 삭제 — 케라바 적용 때 그려진 골짜기 겹침 사각형은
|
||||
// 처마로 되돌릴 때 사라져야 한다(사용자 확정 2026-06-22: "내부확장박스를 삭제하지?").
|
||||
// 삭제 후, 이 박스를 *지탱하던 마루* 는 따로 확장(아래 [지탱마루확장]) 한다.
|
||||
const boxLines = (canvas?.getObjects() || []).filter(
|
||||
(o) => o && o.parentId === roof.id && (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine'),
|
||||
)
|
||||
for (const bl of boxLines) {
|
||||
cops.push({ act: 'box-remove', ln: bl.lineName || '-', p1: { x: Math.round(bl.x1), y: Math.round(bl.y1) }, p2: { x: Math.round(bl.x2), y: Math.round(bl.y2) } })
|
||||
removeLine(bl)
|
||||
if (Array.isArray(roof.innerLines)) roof.innerLines = roof.innerLines.filter((l) => l !== bl)
|
||||
}
|
||||
// 코너에서 dir 로 가는 무한 ray ↔ 마루 선분 교점(마루 내부 u∈[0,1], ray 앞쪽 t>0).
|
||||
const rayRidgeCross = (corner, dir, rg) => {
|
||||
const ax = rg.x1
|
||||
const ay = rg.y1
|
||||
const ex = rg.x2 - rg.x1
|
||||
const ey = rg.y2 - rg.y1
|
||||
const den = dir.x * ey - dir.y * ex
|
||||
if (Math.abs(den) < 1e-9) return null
|
||||
const t = ((ax - corner.x) * ey - (ay - corner.y) * ex) / den
|
||||
const u = ((ax - corner.x) * dir.y - (ay - corner.y) * dir.x) / den
|
||||
if (t <= 1e-6 || u < -1e-6 || u > 1 + 1e-6) return null
|
||||
return { x: corner.x + dir.x * t, y: corner.y + dir.y * t }
|
||||
}
|
||||
// 1) ★ 마루확장 + 흡수측 절삭(사용자 확정 2026-06-22: "박스삭제→RG-1·RG-2 확장→힙생성 절삭").
|
||||
// 각 마루의 *비경계(free)* 끝을 먼저 roof bbox 까지 바깥확장 → 힙 교점들이 세그먼트 안에
|
||||
// 들어오게 한 뒤, *free 끝에서 가장 먼* 교점까지 당겨 절삭(--<). 보존측 = 경계(박공 출발측).
|
||||
// ★ RG-1 이 짧아 H-8 교점(611)을 못 잡던 문제 해소: 확장하면 farthest-from-free 가 611 을 준다.
|
||||
// free/keep 판정은 *확장 전* 좌표로 1회 고정(확장 후 bbox 끝이 경계로 재인식되는 것 방지).
|
||||
const ridgesC = (roof.innerLines || []).filter((l) => l && l.visible !== false && l.name === LINE_TYPE.SUBLINE.RIDGE)
|
||||
let bx0 = Infinity
|
||||
let bx1 = -Infinity
|
||||
let by0 = Infinity
|
||||
let by1 = -Infinity
|
||||
for (const p of rptsC) {
|
||||
if (p.x < bx0) bx0 = p.x
|
||||
if (p.x > bx1) bx1 = p.x
|
||||
if (p.y < by0) by0 = p.y
|
||||
if (p.y > by1) by1 = p.y
|
||||
}
|
||||
for (const rg of ridgesC) {
|
||||
const E1 = { x: rg.x1, y: rg.y1 }
|
||||
const E2 = { x: rg.x2, y: rg.y2 }
|
||||
const b1 = minEd(E1) <= TOLC
|
||||
const b2 = minEd(E2) <= TOLC
|
||||
if (b1 === b2) continue // 보존/흡수측 판별 불가(둘 다/둘 다 아님) → 절삭 보류
|
||||
const keep = b1 ? E1 : E2 // 경계측 보존
|
||||
const free = b1 ? E2 : E1 // 비경계측 = 흡수(절삭) 대상
|
||||
// free 끝을 ridge 방향(keep→free) 으로 bbox 경계까지 확장.
|
||||
const dxR = free.x - keep.x
|
||||
const dyR = free.y - keep.y
|
||||
const lenR = Math.hypot(dxR, dyR) || 1
|
||||
const ux = dxR / lenR
|
||||
const uy = dyR / lenR
|
||||
let tMax = Infinity
|
||||
if (ux > 1e-9) tMax = Math.min(tMax, (bx1 - keep.x) / ux)
|
||||
else if (ux < -1e-9) tMax = Math.min(tMax, (bx0 - keep.x) / ux)
|
||||
if (uy > 1e-9) tMax = Math.min(tMax, (by1 - keep.y) / uy)
|
||||
else if (uy < -1e-9) tMax = Math.min(tMax, (by0 - keep.y) / uy)
|
||||
const absorbed = isFinite(tMax) && tMax > lenR ? { x: keep.x + ux * tMax, y: keep.y + uy * tMax } : free
|
||||
const setFree = b1 ? (p) => rg.set({ x2: p.x, y2: p.y }) : (p) => rg.set({ x1: p.x, y1: p.y })
|
||||
setFree(absorbed) // 임시 확장(아래에서 교점까지 다시 당김)
|
||||
const crossings = []
|
||||
for (const r of hipsC) {
|
||||
const corner = r === resA ? newC1 : newC2
|
||||
const cx = rayRidgeCross(corner, r.dir, rg)
|
||||
if (cx) crossings.push(cx)
|
||||
}
|
||||
if (!crossings.length) {
|
||||
// 교점 없으면 확장 취소(원복) — 잘못된 bbox 확장 잔재 방지.
|
||||
setFree(free)
|
||||
rg.setLength?.()
|
||||
rg.setCoords?.()
|
||||
rg.addLengthText?.()
|
||||
continue
|
||||
}
|
||||
// 흡수측(확장된 free) 에서 *가장 먼* 교점까지 절삭 = 노치측 세그먼트 최대 제거.
|
||||
let far = null
|
||||
let farD = -1
|
||||
for (const c of crossings) {
|
||||
const d = Math.hypot(c.x - absorbed.x, c.y - absorbed.y)
|
||||
if (d > farD) {
|
||||
farD = d
|
||||
far = c
|
||||
}
|
||||
}
|
||||
setFree(far)
|
||||
if (b1) rg.endPoint = { x: far.x, y: far.y }
|
||||
else rg.startPoint = { x: far.x, y: far.y }
|
||||
const nsz = calcLinePlaneSize({ x1: rg.x1, y1: rg.y1, x2: rg.x2, y2: rg.y2 })
|
||||
rg.attributes = { ...(rg.attributes || {}), planeSize: nsz, actualSize: nsz }
|
||||
rg.setLength?.()
|
||||
rg.setCoords?.()
|
||||
rg.addLengthText?.()
|
||||
cops.push({ act: 'extend-trim', ln: rg.lineName || '-', to: { x: Math.round(far.x), y: Math.round(far.y) }, sz: nsz })
|
||||
}
|
||||
// 2) ★ 삭제 금지(사용자 확정 2026-06-22: "확장하라고 했지 삭제하니?"). 절삭으로 anchor 잃은
|
||||
// 라인은 *제거하지 않고* 확장해 형상을 닫는다. (확장 타깃 확정 전까지 보류 — 삭제만 차단.)
|
||||
const orphans = []
|
||||
// 3) 갱신된 형상으로 두 힙을 *재-raycast* 후 다시 그림(앞 힙 절삭이 뒤 힙 장애물을 없앤 결과 반영).
|
||||
for (const r of hipsC) {
|
||||
const corner = r === resA ? newC1 : newC2
|
||||
const hit2 = rayFirstHit(corner, r.dir)
|
||||
if (!hit2) continue
|
||||
const sz = calcLinePlaneSize({ x1: corner.x, y1: corner.y, x2: hit2.x, y2: hit2.y })
|
||||
r.hip.set({ x1: corner.x, y1: corner.y, x2: hit2.x, y2: hit2.y })
|
||||
r.hip.startPoint = { x: corner.x, y: corner.y }
|
||||
r.hip.endPoint = { x: hit2.x, y: hit2.y }
|
||||
r.hip.attributes = { ...(r.hip.attributes || {}), planeSize: sz, actualSize: sz }
|
||||
r.hip.setLength?.()
|
||||
r.hip.setCoords?.()
|
||||
r.hip.addLengthText?.()
|
||||
r.hit = hit2
|
||||
r.hitLine = hit2.line
|
||||
r.hitKind = hit2.kind
|
||||
cops.push({ act: 'hip-recast', ln: r.hip.lineName || '-', to: { x: Math.round(hit2.x), y: Math.round(hit2.y) }, kind: hit2.kind, sz })
|
||||
}
|
||||
// 4) ★ 마지막: 서로 다른 길의 두 힙 끝점(마루 안착점)을 잇는 45° 연결 힙
|
||||
// (사용자 확정 2026-06-22: "다른 길의 힙과 힙을 이어주는 라인은 힙이 된다, 45도" = 반삭합).
|
||||
// 각 힙이 마루에 안착한 뒤 두 안착점을 연결해 노치 형상을 닫는다.
|
||||
const eA = resA && resA.hit ? { x: resA.hit.x, y: resA.hit.y } : null
|
||||
const eB = resB && resB.hit ? { x: resB.hit.x, y: resB.hit.y } : null
|
||||
if (eA && eB) {
|
||||
const cdx = Math.abs(eB.x - eA.x)
|
||||
const cdy = Math.abs(eB.y - eA.y)
|
||||
const dist = Math.hypot(eB.x - eA.x, eB.y - eA.y)
|
||||
if (dist > 1 && Math.abs(cdx - cdy) <= 1.0) {
|
||||
const conn = mkValleyHip(eA, eB)
|
||||
canvas.add(conn)
|
||||
conn.bringToFront()
|
||||
roof.innerLines.push(conn)
|
||||
cops.push({ act: 'connector-hip', from: { x: Math.round(eA.x), y: Math.round(eA.y) }, to: { x: Math.round(eB.x), y: Math.round(eB.y) }, sz: conn.attributes?.planeSize })
|
||||
} else {
|
||||
cops.push({ act: 'connector-skip', reason: Math.abs(cdx - cdy) > 1.0 ? 'not-45' : 'too-short', from: { x: Math.round(eA.x), y: Math.round(eA.y) }, to: { x: Math.round(eB.x), y: Math.round(eB.y) } })
|
||||
}
|
||||
}
|
||||
adjHipDbg.cascade = { cascadeA, cascadeB, orphans: orphans.length, ops: cops }
|
||||
return { ops: cops, cascade: true }
|
||||
}
|
||||
const ops = []
|
||||
const setLen = (res, corner, len) => {
|
||||
const ex = corner.x + res.dir.x * len
|
||||
const ey = corner.y + res.dir.y * len
|
||||
const sz = calcLinePlaneSize({ x1: corner.x, y1: corner.y, x2: ex, y2: ey })
|
||||
res.hip.set({ x1: corner.x, y1: corner.y, x2: ex, y2: ey })
|
||||
res.hip.startPoint = { x: corner.x, y: corner.y }
|
||||
res.hip.endPoint = { x: ex, y: ey }
|
||||
res.hip.attributes = { ...(res.hip.attributes || {}), planeSize: sz, actualSize: sz }
|
||||
res.hip.setLength?.()
|
||||
if (typeof res.hip.setCoords === 'function') res.hip.setCoords()
|
||||
res.hip.addLengthText?.()
|
||||
ops.push({ ln: res.hip.lineName || '-', to: { x: Math.round(ex), y: Math.round(ey) } })
|
||||
return { x: ex, y: ey }
|
||||
}
|
||||
const e1 = setLen(resA, newC1, dA)
|
||||
const e2 = setLen(resB, newC2, dB)
|
||||
const rlen = Math.hypot(e2.x - e1.x, e2.y - e1.y)
|
||||
if (rlen >= 1) {
|
||||
// 두 힙 끝을 잇는 선: 축정렬(수평/수직)이면 마루, 아니면 힙으로 연결(두 힙 길이가 달라진 경우).
|
||||
const CTOL = 1.0
|
||||
const axisAligned = Math.abs(e1.x - e2.x) < CTOL || Math.abs(e1.y - e2.y) < CTOL
|
||||
const rpts = [e1.x, e1.y, e2.x, e2.y]
|
||||
const rsz = calcLinePlaneSize({ x1: rpts[0], y1: rpts[1], x2: rpts[2], y2: rpts[3] })
|
||||
const conn = new QLine(rpts, {
|
||||
parentId: roof.id,
|
||||
fontSize: roof.fontSize,
|
||||
stroke: '#1083E3',
|
||||
strokeWidth: axisAligned ? 4 : 3,
|
||||
name: axisAligned ? LINE_TYPE.SUBLINE.RIDGE : LINE_TYPE.SUBLINE.HIP,
|
||||
textMode: roof.textMode,
|
||||
attributes: { roofId: roof.id, ...(axisAligned ? {} : { type: LINE_TYPE.SUBLINE.HIP }), planeSize: rsz, actualSize: rsz },
|
||||
})
|
||||
conn.lineName = axisAligned ? 'kerabPatternRidge' : 'kerabPatternHip'
|
||||
conn.startPoint = { x: conn.x1, y: conn.y1 }
|
||||
conn.endPoint = { x: conn.x2, y: conn.y2 }
|
||||
canvas.add(conn)
|
||||
conn.bringToFront()
|
||||
roof.innerLines.push(conn)
|
||||
ops.push({ [axisAligned ? 'ridge' : 'connHip']: { from: { x: Math.round(e1.x), y: Math.round(e1.y) }, to: { x: Math.round(e2.x), y: Math.round(e2.y) } } })
|
||||
}
|
||||
return { ops, e1, e2 }
|
||||
}
|
||||
const diamond = buildValleyDiamond()
|
||||
// 깊이 산출 진단 — 큰 applied 로그에 묻혀 잘리지 않도록 별도 짧은 줄로 먼저 출력.
|
||||
logger.log('[KERAB-VALLEY-EAVES-RULE1 MIDLINE] ' + JSON.stringify(adjHipDbg))
|
||||
// 마름모를 만든 경우에도 resolveRidgeEndpoints 를 *이어서* 실행한다 — 새 힙·마루로
|
||||
// anchor 잃은 기존 세로/가로 마루를 절삭(또는 확장)해 dangling(R1) 을 해소(사용자 확정 2026-06-22:
|
||||
// "세로도 그려야지" = 가로 마름모만 만들고 세로 마루를 떠 있게 두면 안 됨).
|
||||
// cascade 경로는 마루 절삭/힙 재-raycast 를 자체 처리했으므로 resolveRidgeEndpoints 를 건너뛴다(이중 절삭 방지).
|
||||
const ridgeOps = diamond ? (diamond.cascade ? diamond.ops : [...diamond.ops, ...resolveRidgeEndpoints()]) : resolveRidgeEndpoints()
|
||||
// 5c) Y 반사 힙 — 새 힙이 *기존 마루(RIDGE)에 직접 부딪힌* 경우만(=raycast hit 가 RIDGE),
|
||||
// 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사).
|
||||
// ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16).
|
||||
const reflectOps = []
|
||||
for (const res of [resA, resB]) {
|
||||
if (diamond) break // 마름모를 만든 경우 힙을 인접 처마 힙 길이로 재설정했으므로 반사 불필요
|
||||
if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue
|
||||
const M = { x: res.hit.x, y: res.hit.y }
|
||||
const rg = res.hitLine
|
||||
@ -3174,8 +3495,42 @@ export function useEavesGableEdit(id) {
|
||||
runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap)
|
||||
logger.log(
|
||||
'[KERAB-VALLEY-EAVES-RULE1] applied ' +
|
||||
JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, ridgeOps, reflectOps }),
|
||||
JSON.stringify({ edgeIdx, removed: gableWallLines.length, cleaned, newC1, newC2, resA, resB, adjHipDbg, ridgeOps, reflectOps }),
|
||||
)
|
||||
// [KERAB-LABEL-MAP 2026-06-22] 화면 라벨(H-n/RG-n/B-n…)↔좌표 맵 — QPolygon.__attachDebugLabels 와
|
||||
// *동일 분류·동일 카운터 순서*(canvas 오브젝트 순서)로 재현. 좌표 대신 라벨로 대화하기 위함(local 전용).
|
||||
try {
|
||||
const classify = (o) => {
|
||||
const nm = o.name
|
||||
const ln = o.lineName
|
||||
const ty = o.attributes?.type
|
||||
if (nm === 'baseLine') return 'B'
|
||||
if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R'
|
||||
if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H'
|
||||
if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG'
|
||||
if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V'
|
||||
if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G'
|
||||
if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK'
|
||||
return null
|
||||
}
|
||||
const ctr = {}
|
||||
const lmap = []
|
||||
for (const o of canvas.getObjects()) {
|
||||
if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue
|
||||
const pfx = classify(o)
|
||||
if (!pfx) continue
|
||||
ctr[pfx] = (ctr[pfx] || 0) + 1
|
||||
lmap.push({
|
||||
label: `${pfx}-${ctr[pfx]}`,
|
||||
p1: { x: Math.round(o.x1), y: Math.round(o.y1) },
|
||||
p2: { x: Math.round(o.x2), y: Math.round(o.y2) },
|
||||
sz: o.attributes?.planeSize ?? null,
|
||||
})
|
||||
}
|
||||
logger.log('[KERAB-LABEL-MAP] ' + JSON.stringify(lmap))
|
||||
} catch (e) {
|
||||
logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
@ -3991,30 +4346,109 @@ export function useEavesGableEdit(id) {
|
||||
const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den
|
||||
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t
|
||||
}
|
||||
// [KERAB-VALLEY-BOX-RELATIVE-ROOFLINE 2026-06-23] 골짜기 박스(kerabValleyOverlapLine)는 인접
|
||||
// 두 sub-roof 의 *겹침(union)* 이라, 각 sub-roof 입장에서 박스 변이 곧 자기 roofLine 경계다
|
||||
// (상대 roofLine). 따라서 절대 roofLine 뿐 아니라 더 가까운 박스 변에서도 apex/힙이 멈춰야
|
||||
// 한다(사용자 확정 2026-06-23: "roofLine 이 상대적"). roofLine 변과 동급으로 ray 교차에 포함.
|
||||
const boxSegs = (canvas.getObjects() || []).filter(
|
||||
(o) =>
|
||||
o &&
|
||||
(o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine') &&
|
||||
(o.parentId === roof.id || o.roofId === roof.id || o.attributes?.roofId === roof.id),
|
||||
)
|
||||
for (const o of boxSegs) {
|
||||
const ax = o.x2 - o.x1
|
||||
const ay = o.y2 - o.y1
|
||||
const den = dx * ay - dy * ax
|
||||
if (Math.abs(den) < 1e-9) continue
|
||||
const t = ((o.x1 - P.x) * ay - (o.y1 - P.y) * ax) / den
|
||||
const u = ((o.x1 - P.x) * dy - (o.y1 - P.y) * dx) / den
|
||||
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t
|
||||
}
|
||||
return best
|
||||
}
|
||||
const oppDist = oppRayDist(mid, ix, iy)
|
||||
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
|
||||
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
|
||||
// 마루 단축: 케라바변쪽 끝점 → apex.
|
||||
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
|
||||
else ridge.set({ x2: apex.x, y2: apex.y })
|
||||
// [KERAB-TYPE-EAVES-RIDGE-ZERO 2026-06-15] apex 가 반대편 roofLine 에 클램프되면 native 마루가
|
||||
// 거기로 단축돼 zero-length 가 될 수 있다(R2 위반). 객체는 revert 위해 보존하되 invisible 처리
|
||||
// (R2 체크는 visible 만 검사). 마루 형상 재구성은 완전 우진각 시 reconstruct 가 담당.
|
||||
const ridgeLen = Math.hypot(ridge.x2 - ridge.x1, ridge.y2 - ridge.y1)
|
||||
if (ridgeLen < 1) ridge.visible = false
|
||||
const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 })
|
||||
if (ridge.attributes) {
|
||||
ridge.attributes.planeSize = rsz
|
||||
ridge.attributes.actualSize = rsz
|
||||
// [KERAB-TYPE-EAVES-BOX-CLIP 2026-06-23] 골짜기 박스(지붕의 겹침)는 인접 면 입장에서 자기
|
||||
// roofLine 경계 = "상대 roofLine"(사용자 확정 2026-06-23). apex 로 향하는 힙이 박스 경계를
|
||||
// 만나면 거기서 멈춰야 하고 뚫고 지나가면 안 된다. apex 자체는 45° 이등변 유지를 위해 보존하되,
|
||||
// 각 힙을 corner→apex 로 쏴 박스 경계 교차점으로 절삭한다.
|
||||
// 박스 경계 식별: 마루(ridge)의 박스측 고정 끝점 Bp(= apex 로 단축되지 않는 끝)을 공유하는
|
||||
// inner HIP/RIDGE 변(H-6/H-7 류). RG-2(415)가 이 Bp 에서 박스 안으로 올라가므로 Bp 가 곧
|
||||
// 박스 코너다. 일반 내부선(H-2 등, Bp 미공유)은 힙이 가로질러야 하므로 제외 → 정상 切妻
|
||||
// (Bp=roofLine 코너, 공유 inner 없음)은 무영향. ridge(단축 대상)·invisible 제외.
|
||||
const boxCorner = midIsA ? rB : rA
|
||||
const clipHipToBox = (corner) => {
|
||||
const dx0 = apex.x - corner.x
|
||||
const dy0 = apex.y - corner.y
|
||||
const dlen = Math.hypot(dx0, dy0)
|
||||
if (dlen < 1e-6) return { x: apex.x, y: apex.y }
|
||||
const dx = dx0 / dlen
|
||||
const dy = dy0 / dlen
|
||||
let best = dlen
|
||||
let bestPt = { x: apex.x, y: apex.y }
|
||||
for (const il of roof.innerLines || []) {
|
||||
if (!il || il === ridge || il.visible === false) continue
|
||||
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
|
||||
const sharesBp =
|
||||
(Math.abs(il.x1 - boxCorner.x) < 0.5 && Math.abs(il.y1 - boxCorner.y) < 0.5) ||
|
||||
(Math.abs(il.x2 - boxCorner.x) < 0.5 && Math.abs(il.y2 - boxCorner.y) < 0.5)
|
||||
// [KERAB-TYPE-EAVES-HIPMEET 2026-06-23] 신규 힙은 (1) 박스 변(Bp 공유) 또는 (2) 직전 변경에서
|
||||
// 생성된 기존 케라바 힙(kerabPatternHip)과의 최근접 교점에서 멈춘다 → ">" 모양. 둘 다 방향 무관
|
||||
// 후보로 같은 best(t 최소) 누산기에 경쟁시켜 가장 가까운 교점이 절삭점이 된다. 1차 변경 때는
|
||||
// 기존 힙이 없어 박스 변만 후보 → 동작 불변(무영향).
|
||||
const isPriorHip = il.lineName === 'kerabPatternHip'
|
||||
if (!sharesBp && !isPriorHip) continue
|
||||
const ax = il.x2 - il.x1
|
||||
const ay = il.y2 - il.y1
|
||||
if (Math.hypot(ax, ay) < 1e-6) continue
|
||||
const den = dx * ay - dy * ax
|
||||
if (Math.abs(den) < 1e-9) continue
|
||||
const t = ((il.x1 - corner.x) * ay - (il.y1 - corner.y) * ax) / den
|
||||
const u = ((il.x1 - corner.x) * dy - (il.y1 - corner.y) * dx) / den
|
||||
if (t > 1.0 && t < best - 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) {
|
||||
best = t
|
||||
bestPt = { x: corner.x + dx * t, y: corner.y + dy * t }
|
||||
}
|
||||
}
|
||||
return bestPt
|
||||
}
|
||||
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 endA = clipHipToBox(wA)
|
||||
const endB = clipHipToBox(wB)
|
||||
const hipClipped = Math.hypot(endA.x - apex.x, endA.y - apex.y) > 1 || Math.hypot(endB.x - apex.x, endB.y - apex.y) > 1
|
||||
if (hipClipped) {
|
||||
// [KERAB-TYPE-EAVES-RIDGE-VANISH 2026-06-23] 박스(겹침)가 상대 roofLine 이라 신규 힙이 박스/직전 힙에서
|
||||
// 멈추면, 가운데 마루(RG-1)는 양쪽 코너 힙이 이미 생성됐으므로 "삭제 대상"이다(사용자 규칙 2026-06-23:
|
||||
// "RG-1 은 마루 = 삭제, 양쪽에 힙 생성됨"). visible=false 숨기기는 객체가 innerLines 에 남아
|
||||
// 디버그 라벨 재부착/LABEL-MAP 이 visible 무시하고 RG-1 을 다시 노출 → "삭제 안 됨" 으로 보였다.
|
||||
// revert 스냅샷은 좌표 값만 복사하고 revert 경로가 라인을 재생성하므로 진짜 삭제해도 무해하다.
|
||||
// → reconstruct(4820) 와 동일하게 canvas.remove + innerLines splice 로 실제 삭제한다.
|
||||
canvas.remove(ridge)
|
||||
const _ridx = roof.innerLines.indexOf(ridge)
|
||||
if (_ridx >= 0) roof.innerLines.splice(_ridx, 1)
|
||||
} else {
|
||||
// 마루 단축: 케라바변쪽 끝점 → apex.
|
||||
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
|
||||
else ridge.set({ x2: apex.x, y2: apex.y })
|
||||
// [KERAB-TYPE-EAVES-RIDGE-ZERO 2026-06-15] apex 가 반대편 roofLine 에 클램프되면 native 마루가
|
||||
// 거기로 단축돼 zero-length 가 될 수 있다(R2 위반). 객체는 revert 위해 보존하되 invisible 처리
|
||||
// (R2 체크는 visible 만 검사). 마루 형상 재구성은 완전 우진각 시 reconstruct 가 담당.
|
||||
const ridgeLen = Math.hypot(ridge.x2 - ridge.x1, ridge.y2 - ridge.y1)
|
||||
if (ridgeLen < 1) ridge.visible = false
|
||||
const rsz = calcLinePlaneSize({ x1: ridge.x1, y1: ridge.y1, x2: ridge.x2, y2: ridge.y2 })
|
||||
if (ridge.attributes) {
|
||||
ridge.attributes.planeSize = rsz
|
||||
ridge.attributes.actualSize = rsz
|
||||
}
|
||||
if (typeof ridge.setCoords === 'function') ridge.setCoords()
|
||||
if (typeof ridge.setLength === 'function') ridge.setLength()
|
||||
if (typeof ridge.addLengthText === 'function') ridge.addLengthText()
|
||||
}
|
||||
// hip 2개: wA→endA, wB→endB. pts=[corner, end] 라 corner 끝(x1,y1)이 outer.
|
||||
const mkHip = (corner, side, end) => {
|
||||
const pts = [corner.x, corner.y, end.x, end.y]
|
||||
const sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
|
||||
const hip = new QLine(pts, {
|
||||
parentId: roof.id,
|
||||
@ -4032,8 +4466,8 @@ export function useEavesGableEdit(id) {
|
||||
hip.__kerabRevertOuterSide = side
|
||||
return hip
|
||||
}
|
||||
const hip1 = mkHip(wA, 'A')
|
||||
const hip2 = mkHip(wB, 'B')
|
||||
const hip1 = mkHip(wA, 'A', endA)
|
||||
const hip2 = mkHip(wB, 'B', endB)
|
||||
canvas.add(hip1)
|
||||
canvas.add(hip2)
|
||||
hip1.bringToFront()
|
||||
@ -4044,8 +4478,91 @@ export function useEavesGableEdit(id) {
|
||||
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)) } }),
|
||||
JSON.stringify({
|
||||
L: Math.round(L),
|
||||
mid,
|
||||
apex: { x: Math.round(apex.x), y: Math.round(apex.y) },
|
||||
inward: { ix: Number(ix.toFixed(3)), iy: Number(iy.toFixed(3)) },
|
||||
hipA: { x: Math.round(hip1.x2), y: Math.round(hip1.y2), clipped: Math.hypot(hip1.x2 - apex.x, hip1.y2 - apex.y) > 1 },
|
||||
hipB: { x: Math.round(hip2.x2), y: Math.round(hip2.y2), clipped: Math.hypot(hip2.x2 - apex.x, hip2.y2 - apex.y) > 1 },
|
||||
ridgeDeleted: hipClipped,
|
||||
ridgeHidden: ridge.visible === false,
|
||||
}),
|
||||
)
|
||||
// [KERAB-LABEL-MAP 2026-06-23] 경로 A(일반 라인→처마) 진단 — 화면 라벨(H-n/RG-n/V-n…)↔좌표 맵.
|
||||
// 골짜기 박스(kerabValleyOverlapLine)는 V 로 분류되어 V-n 으로 노출 → 힙이 박스를 통과/멈추는지
|
||||
// 라벨로 대조하기 위함(local 전용, 로깅만).
|
||||
try {
|
||||
const classify = (o) => {
|
||||
const nm = o.name
|
||||
const ln = o.lineName
|
||||
if (nm === 'baseLine') return 'B'
|
||||
if (nm === 'outerLine' || nm === 'eaves' || ln === 'roofLine') return 'R'
|
||||
if (nm === LINE_TYPE.SUBLINE.HIP || ln === LINE_TYPE.SUBLINE.HIP) return 'H'
|
||||
if (nm === LINE_TYPE.SUBLINE.RIDGE || ln === LINE_TYPE.SUBLINE.RIDGE) return 'RG'
|
||||
if (nm === LINE_TYPE.SUBLINE.VALLEY || ln === LINE_TYPE.SUBLINE.VALLEY) return 'V'
|
||||
if (nm === LINE_TYPE.SUBLINE.GABLE || ln === LINE_TYPE.SUBLINE.GABLE) return 'G'
|
||||
if (typeof o.x1 === 'number' && typeof o.y1 === 'number' && typeof o.x2 === 'number' && typeof o.y2 === 'number') return 'SK'
|
||||
return null
|
||||
}
|
||||
const ctr = {}
|
||||
const lmap = []
|
||||
for (const o of canvas.getObjects()) {
|
||||
if (!o || o.parentId !== roof.id || o.name === '__debugLabel') continue
|
||||
const pfx = classify(o)
|
||||
if (!pfx) continue
|
||||
ctr[pfx] = (ctr[pfx] || 0) + 1
|
||||
lmap.push({
|
||||
label: `${pfx}-${ctr[pfx]}`,
|
||||
p1: { x: Math.round(o.x1), y: Math.round(o.y1) },
|
||||
p2: { x: Math.round(o.x2), y: Math.round(o.y2) },
|
||||
ln: o.lineName || null,
|
||||
sz: o.attributes?.planeSize ?? null,
|
||||
})
|
||||
}
|
||||
logger.log('[KERAB-LABEL-MAP] ' + JSON.stringify(lmap))
|
||||
} catch (e) {
|
||||
logger.warn('[KERAB-LABEL-MAP] failed ' + (e?.message || e))
|
||||
}
|
||||
// [KERAB-BOX-DUMP 2026-06-23] 진단 — canvas 전체의 골짜기 박스(kerabValleyOverlapLine) + 모든 ROOF
|
||||
// 폴리곤 외곽 bbox 를 roofId 와 함께 덤프. 힙이 뚫은 "박스"가 현재 roof 소속인지, 다른 roof/겹침
|
||||
// 소속이라 oppRayDist 필터에서 빠졌는지 판별하기 위함(local 전용, 로깅만).
|
||||
try {
|
||||
const allBox = (canvas.getObjects() || [])
|
||||
.filter((o) => o && (o.lineName === 'kerabValleyOverlapLine' || o.attributes?.type === 'kerabValleyOverlapLine'))
|
||||
.map((o) => ({
|
||||
p1: { x: Math.round(o.x1), y: Math.round(o.y1) },
|
||||
p2: { x: Math.round(o.x2), y: Math.round(o.y2) },
|
||||
parentId: o.parentId || null,
|
||||
roofId: o.roofId || o.attributes?.roofId || null,
|
||||
name: o.name || null,
|
||||
}))
|
||||
const allRoofs = (canvas.getObjects() || [])
|
||||
.filter((o) => o && o.name === POLYGON_TYPE.ROOF)
|
||||
.map((o) => {
|
||||
const ps = o.points || []
|
||||
let x0 = Infinity
|
||||
let x1 = -Infinity
|
||||
let y0 = Infinity
|
||||
let y1 = -Infinity
|
||||
for (const p of ps) {
|
||||
if (p.x < x0) x0 = p.x
|
||||
if (p.x > x1) x1 = p.x
|
||||
if (p.y < y0) y0 = p.y
|
||||
if (p.y > y1) y1 = p.y
|
||||
}
|
||||
return { id: o.id, isFixed: !!o.isFixed, bbox: { x0: Math.round(x0), y0: Math.round(y0), x1: Math.round(x1), y1: Math.round(y1) }, n: ps.length }
|
||||
})
|
||||
logger.log('[KERAB-BOX-DUMP] ' + JSON.stringify({ curRoof: roof.id, box: allBox, roofs: allRoofs }))
|
||||
} catch (e) {
|
||||
logger.warn('[KERAB-BOX-DUMP] failed ' + (e?.message || e))
|
||||
}
|
||||
// [KERAB-TYPE-EAVES-RIDGE-VANISH-FLAG 2026-06-23] 마루(RG-1)를 삭제한 케이스. moveStaleEdgeInnerLines
|
||||
// 의 relocate 가 이 플래그로 박스 세로선(H-8)의 과길게 뻗은 끝점을 박스 바닥(boxCorner=RG-1 의
|
||||
// 박스측 끝점, 예 (747,266))으로 내려 H-7↔H-2 사이만 막도록 만든다(사용자 규칙 2026-06-23:
|
||||
// "H-8 은 힙이 아니라 박스를 막는 세로 roofLine, H-2 와 H-7 사이만").
|
||||
apex.ridgeHidden = hipClipped
|
||||
apex.boxCorner = hipClipped ? boxCorner : null
|
||||
return apex
|
||||
}
|
||||
|
||||
@ -4102,9 +4619,17 @@ export function useEavesGableEdit(id) {
|
||||
let movedCnt = 0
|
||||
for (const il of roof.innerLines || []) {
|
||||
if (!il || isKerabPattern(il.lineName)) continue
|
||||
// [KERAB-TYPE-EAVES-EDGEMOVE-SKIPHIDDEN 2026-06-23] 숨긴 마루(hipClipped 시 visible=false)는
|
||||
// 옮기지 않는다. 옮기면 cMid→apex 수렴 후 addLengthText 가 박스 위에 "415" 라벨을 되살린다.
|
||||
if (il.visible === false) continue
|
||||
const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2)
|
||||
// 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동.
|
||||
const relocate = (px, py) => {
|
||||
// [KERAB-TYPE-EAVES-EDGEMOVE-BOXCLOSE 2026-06-23] 마루(RG-1) 삭제 케이스(apex.ridgeHidden)는
|
||||
// 박스 세로선(H-8)이 옛 처마변까지 과길게 뻗어 있다(에러). 그 과길게 뻗은 끝점(cMid 측)을
|
||||
// apex 로 올리지도 평행이동하지도 않고, 박스 바닥(boxCorner=RG-1 박스측 끝점)으로 내려
|
||||
// H-7↔H-2 사이만 막는 짧은 세로선으로 만든다.
|
||||
if (apex && apex.ridgeHidden && apex.boxCorner && !collinear && nearMid(px, py)) return { x: apex.boxCorner.x, y: apex.boxCorner.y }
|
||||
if (apex && !collinear && nearMid(px, py)) return { x: apex.x, y: apex.y }
|
||||
return { x: px + dxE, y: py + dyE }
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import Big from 'big.js'
|
||||
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
|
||||
import { getSelectLinePosition } from '@/util/skeleton-utils'
|
||||
import { useMouse } from '@/hooks/useMouse'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
// [정책 flag] 오버 이동(인접 라인 반전 = polygon self-cross) 입력 처리 방법.
|
||||
// 'clamp' : (기본/2026-04-24 최종) 평행 위치-OVER_EPS 까지 클램핑. wall.baseLines 는 항상 정상 polygon, SK 확장 정상.
|
||||
@ -40,6 +41,7 @@ export function useMovementSetting(id) {
|
||||
const [type, setType] = useState(TYPE.FLOW_LINE)
|
||||
const typeRef = useRef(type)
|
||||
const { swalFire } = useSwal()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
|
||||
const FLOW_LINE_REF = {
|
||||
POINTER_INPUT_REF: useRef(null),
|
||||
@ -154,23 +156,7 @@ export function useMovementSetting(id) {
|
||||
canvas.remove(CONFIRM_LINE_REF.current)
|
||||
CONFIRM_LINE_REF.current = null
|
||||
}
|
||||
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||
roofs.forEach((roof) => {
|
||||
roof.set({ stroke: '#1083E3' })
|
||||
roof.innerLines.forEach((line) => line.set({ selectable: true, strokeWidth: 2, stroke: '#1083E3' }))
|
||||
})
|
||||
const walls = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
|
||||
walls.forEach((wall) => {
|
||||
if (wall.baseLines.length === 0) {
|
||||
wall.baseLines = canvas.getObjects().filter((obj) => obj.name === 'baseLine' && obj.attributes.wallId === wall.id)
|
||||
}
|
||||
wall.baseLines.forEach((baseLine) => {
|
||||
baseLine.set({ selectable: false, visible: false })
|
||||
})
|
||||
})
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
outerLines.forEach((line) => line.set({ visible: true }))
|
||||
canvas.renderAll()
|
||||
restoreLineStyles()
|
||||
initEvent()
|
||||
}
|
||||
}, [])
|
||||
@ -228,6 +214,28 @@ export function useMovementSetting(id) {
|
||||
canvas.renderAll()
|
||||
}, [currentObject])
|
||||
|
||||
/** 동선이동/형올림내림 적용 전(평상) 선 스타일로 되돌린다.
|
||||
* saveSnapshot 직전과 팝업 cleanup 양쪽에서 호출해, undo 시 강조색이 스냅샷에 박혀 복원되지 않도록 한다. */
|
||||
const restoreLineStyles = () => {
|
||||
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||
roofs.forEach((roof) => {
|
||||
roof.set({ stroke: '#1083E3' })
|
||||
roof.innerLines.forEach((line) => line.set({ selectable: true, strokeWidth: 2, stroke: '#1083E3' }))
|
||||
})
|
||||
const walls = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
|
||||
walls.forEach((wall) => {
|
||||
if (wall.baseLines.length === 0) {
|
||||
wall.baseLines = canvas.getObjects().filter((obj) => obj.name === 'baseLine' && obj.attributes.wallId === wall.id)
|
||||
}
|
||||
wall.baseLines.forEach((baseLine) => {
|
||||
baseLine.set({ selectable: false, visible: false })
|
||||
})
|
||||
})
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
outerLines.forEach((line) => line.set({ visible: true }))
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
const clearRef = () => {
|
||||
if (type === TYPE.FLOW_LINE) {
|
||||
// 안전한 ref 접근
|
||||
@ -532,6 +540,11 @@ export function useMovementSetting(id) {
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
// [undo 색상 fix] 스냅샷은 동선이동/형올림내림 적용 직전 상태를 저장한다.
|
||||
// 팝업 오픈 시 입힌 강조색이 스냅샷에 박히면 undo 시 강조색이 복원되므로,
|
||||
// 스냅샷 저장 전에 평상 선 스타일로 되돌린다.
|
||||
restoreLineStyles()
|
||||
saveSnapshot()
|
||||
const target = selectedObject.current !== null ? selectedObject.current : CONFIRM_LINE_REF.current?.target
|
||||
if (!target) return
|
||||
|
||||
|
||||
@ -6,9 +6,11 @@ import {
|
||||
adsorptionPointModeState,
|
||||
adsorptionRangeState,
|
||||
canvasState,
|
||||
currentMenuState,
|
||||
dotLineIntervalSelector,
|
||||
verticalHorizontalModeState,
|
||||
} from '@/store/canvasAtom'
|
||||
import { MENU } from '@/common/common'
|
||||
import { useEvent } from '@/hooks/useEvent'
|
||||
import { useMouse } from '@/hooks/useMouse'
|
||||
import { useLine } from '@/hooks/useLine'
|
||||
@ -35,6 +37,7 @@ import { useMessage } from '@/hooks/useMessage'
|
||||
import Big from 'big.js'
|
||||
import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting'
|
||||
import { useObject } from '@/hooks/useObject'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
//외벽선 그리기
|
||||
export function useOuterLineWall(id, propertiesId) {
|
||||
@ -60,6 +63,7 @@ export function useOuterLineWall(id, propertiesId) {
|
||||
const { tempGridMode } = useTempGrid()
|
||||
const { addPolygonByLines } = usePolygon()
|
||||
const { handleSelectableObjects } = useObject()
|
||||
const { saveSnapshot, setDrawing } = useUndoRedo()
|
||||
|
||||
const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState)
|
||||
const adsorptionPointAddMode = useRecoilValue(adsorptionPointAddModeState)
|
||||
@ -90,6 +94,7 @@ export function useOuterLineWall(id, propertiesId) {
|
||||
const { getMessage } = useMessage()
|
||||
|
||||
const setOuterLineFix = useSetRecoilState(outerLineFixState)
|
||||
const setCurrentMenu = useSetRecoilState(currentMenuState)
|
||||
|
||||
const outerLineDiagonalLengthRef = useRef(null)
|
||||
|
||||
@ -116,6 +121,15 @@ export function useOuterLineWall(id, propertiesId) {
|
||||
}
|
||||
}, [verticalHorizontalMode, points, adsorptionPointAddMode, adsorptionPointMode, adsorptionRange, interval, tempGridMode])
|
||||
|
||||
// 외벽선 그리기 진행 중에는 undo/redo/스냅샷을 차단한다.
|
||||
// 그리기 전 스냅샷은 첫 클릭(mouseDown) 에서 force 로 저장한다 (기존 동작 유지).
|
||||
useEffect(() => {
|
||||
setDrawing(true)
|
||||
return () => {
|
||||
setDrawing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
arrow1Ref.current = arrow1
|
||||
}, [arrow1])
|
||||
@ -153,6 +167,9 @@ export function useOuterLineWall(id, propertiesId) {
|
||||
pointer = { x: Big(pointer.x).toNumber(), y: Big(pointer.y).toNumber() }
|
||||
|
||||
if (points.length === 0) {
|
||||
// 그리기 진행 중에는 스냅샷이 차단되므로 force 로 저장한다.
|
||||
// (처음 한 번만 저장되고 이후 추가 클릭은 points.length > 0 분기로 빠지므로 중복 저장 안 됨)
|
||||
saveSnapshot(null, { force: true })
|
||||
setPoints((prev) => [...prev, pointer])
|
||||
} else {
|
||||
const lastPoint = points[points.length - 1]
|
||||
@ -258,6 +275,8 @@ export function useOuterLineWall(id, propertiesId) {
|
||||
canvas.outerLineFix = true
|
||||
closePopup(id)
|
||||
ccwCheck()
|
||||
// 외벽선 완료 → 지붕형상 설정 메뉴로 전환
|
||||
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS)
|
||||
addPopup(propertiesId, 1, <RoofShapeSetting id={propertiesId} pos={{ x: 50, y: 230 }} />)
|
||||
}
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import RoofAllocationSetting from '@/components/floor-plan/modal/roofAllocation/
|
||||
import { settingModalFirstOptionsState } from '@/store/settingAtom'
|
||||
import { useSwal } from '@/hooks/useSwal'
|
||||
import { useMessage } from '@/hooks/useMessage'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
// 외벽선 속성 설정
|
||||
export function usePropertiesSetting(id) {
|
||||
@ -29,6 +30,7 @@ export function usePropertiesSetting(id) {
|
||||
const { addPopup, closePopup } = usePopup()
|
||||
const { swalFire } = useSwal()
|
||||
const { getMessage } = useMessage()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
|
||||
useEffect(() => {
|
||||
const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
@ -67,6 +69,7 @@ export function usePropertiesSetting(id) {
|
||||
if (!selectedLine) {
|
||||
return
|
||||
}
|
||||
saveSnapshot()
|
||||
selectedLine.set({
|
||||
stroke: '#45CD7D',
|
||||
strokeWidth: 4,
|
||||
@ -86,6 +89,7 @@ export function usePropertiesSetting(id) {
|
||||
if (!selectedLine) {
|
||||
return
|
||||
}
|
||||
saveSnapshot()
|
||||
selectedLine.set({
|
||||
stroke: '#3FBAE6',
|
||||
strokeWidth: 4,
|
||||
@ -150,6 +154,7 @@ export function usePropertiesSetting(id) {
|
||||
return
|
||||
}
|
||||
|
||||
saveSnapshot()
|
||||
lines.forEach((line) => {
|
||||
line.set({
|
||||
attributes: line.attributes ? line.attributes : { offset: 0, type: LINE_TYPE.WALLLINE.WALL },
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
|
||||
import { canvasState, currentAngleTypeSelector, currentObjectState } from '@/store/canvasAtom'
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { useAxios } from '@/hooks/useAxios'
|
||||
import { useSwal } from '@/hooks/useSwal'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
@ -15,7 +15,7 @@ import {
|
||||
selectedRoofMaterialSelector,
|
||||
} from '@/store/settingAtom'
|
||||
import { usePopup } from '@/hooks/usePopup'
|
||||
import { POLYGON_TYPE, LINE_TYPE } from '@/common/common'
|
||||
import { POLYGON_TYPE } from '@/common/common'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import ActualSizeSetting from '@/components/floor-plan/modal/roofAllocation/ActualSizeSetting'
|
||||
import { useMessage } from '@/hooks/useMessage'
|
||||
@ -32,10 +32,11 @@ import { roofsState } from '@/store/roofAtom'
|
||||
import { useText } from '@/hooks/useText'
|
||||
import { fabric } from 'fabric'
|
||||
import { QLine } from '@/components/fabric/QLine'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { reattachRoofFaceDebugLabels } from '@/components/fabric/QPolygon'
|
||||
import { calcLineActualSize2 } from '@/util/qpolygon-utils'
|
||||
// [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert
|
||||
import { notifyLowPitchRestrictionForRoofs, isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS } from '@/util/roof-pitch-warning'
|
||||
import { isLowPitchRestricted, LOW_PITCH_RESTRICTED_ROOF_IDS, notifyLowPitchRestrictionForRoofs } from '@/util/roof-pitch-warning'
|
||||
// [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 import + 호출 함께 제거
|
||||
import { debugCapture } from '@/util/debugCapture'
|
||||
import { logger } from '@/util/logger'
|
||||
@ -73,7 +74,10 @@ export function useRoofAllocationSetting(id) {
|
||||
const resetPoints = useResetRecoilState(outerLinePointsState)
|
||||
const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector)
|
||||
const { changeCorridorDimensionText } = useText()
|
||||
const { saveSnapshot, clearStacks } = useUndoRedo()
|
||||
const outlineDisplay = useRecoilValue(outlineDisplaySelector)
|
||||
// [UNSELECT-RESTORE] 보조선/능선/골짜기 선택 강조 시 직전 스타일을 저장 → 선택 해제·변경·팝업 닫힘 때 원복
|
||||
const highlightedLineRef = useRef(null)
|
||||
|
||||
useEffect(() => {
|
||||
/** 배치면 초기설정에서 선택한 지붕재 배열 설정 */
|
||||
@ -84,6 +88,13 @@ export function useRoofAllocationSetting(id) {
|
||||
/** 지붕면 조회 */
|
||||
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) /** roofPolygon.innerLines */
|
||||
|
||||
// [UNSELECT-RESTORE] 이전에 강조했던 라인을 선택 해제·변경 시 원래 스타일로 복구
|
||||
const prevHighlighted = highlightedLineRef.current
|
||||
if (prevHighlighted && prevHighlighted.obj !== currentObject) {
|
||||
prevHighlighted.obj.set({ stroke: prevHighlighted.stroke, strokeWidth: prevHighlighted.strokeWidth })
|
||||
highlightedLineRef.current = null
|
||||
}
|
||||
|
||||
roofBases.forEach((roof) => {
|
||||
roof.innerLines.forEach((line) => {
|
||||
/** 실측값이 없는 경우 라인 두께 4로 설정 */
|
||||
@ -100,8 +111,14 @@ export function useRoofAllocationSetting(id) {
|
||||
|
||||
/** 현재 선택된 객체가 보조라인, 피라미드, 힙인 경우 두께 4로 설정 */
|
||||
if (currentObject && currentObject.name && ['auxiliaryLine', 'ridge', 'hip'].includes(currentObject.name)) {
|
||||
// [UNSELECT-RESTORE] 강조 직전(innerLines 리셋 반영 후) 스타일을 1회만 저장해 복구 기준으로 사용
|
||||
if (highlightedLineRef.current?.obj !== currentObject) {
|
||||
highlightedLineRef.current = { obj: currentObject, stroke: currentObject.stroke, strokeWidth: currentObject.strokeWidth }
|
||||
}
|
||||
currentObject.set({ strokeWidth: 4, stroke: '#EA10AC' })
|
||||
}
|
||||
|
||||
canvas.renderAll()
|
||||
}, [currentObject])
|
||||
|
||||
useEffect(() => {
|
||||
@ -116,6 +133,18 @@ export function useRoofAllocationSetting(id) {
|
||||
fetchBasicSettings(basicSetting.planNo)
|
||||
}, [])
|
||||
|
||||
// [UNSELECT-RESTORE] 팝업 닫힘(언마운트) 시 마지막으로 강조한 라인을 원래 스타일로 복구
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const highlighted = highlightedLineRef.current
|
||||
if (highlighted) {
|
||||
highlighted.obj.set({ stroke: highlighted.stroke, strokeWidth: highlighted.strokeWidth })
|
||||
highlightedLineRef.current = null
|
||||
canvas?.renderAll()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 배치면 초기설정 조회
|
||||
*/
|
||||
@ -417,6 +446,9 @@ export function useRoofAllocationSetting(id) {
|
||||
closePopup(id)
|
||||
basicSettingSave()
|
||||
setModuleSelectionData({ ...moduleSelectionData, roofConstructions: newRoofList })
|
||||
|
||||
// 지붕재 변경 후 undo/redo stack 초기화
|
||||
clearStacks()
|
||||
}
|
||||
|
||||
/**
|
||||
@ -457,14 +489,15 @@ export function useRoofAllocationSetting(id) {
|
||||
const isDiagonal = dx > 0.5 && dy > 0.5
|
||||
let newActualSize
|
||||
if (isDiagonal) {
|
||||
const pitch =
|
||||
line.attributes?.pitch ??
|
||||
roof.attributes?.pitch ??
|
||||
roof.roofMaterial?.pitch ??
|
||||
0
|
||||
const pitch = line.attributes?.pitch ?? roof.attributes?.pitch ?? roof.roofMaterial?.pitch ?? 0
|
||||
newActualSize = Number(
|
||||
calcLineActualSize2(
|
||||
{ x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2 },
|
||||
{
|
||||
x1: line.x1,
|
||||
y1: line.y1,
|
||||
x2: line.x2,
|
||||
y2: line.y2,
|
||||
},
|
||||
getDegreeByChon(pitch),
|
||||
),
|
||||
)
|
||||
@ -474,9 +507,7 @@ export function useRoofAllocationSetting(id) {
|
||||
line.set({ attributes: { ...line.attributes, actualSize: newActualSize } })
|
||||
// lengthText 에 actualSize 즉시 propagate (없으면 모드 토글이 if(obj.actualSize)
|
||||
// 가드에 막혀 토글 무효화).
|
||||
const lengthText = canvas.getObjects().find(
|
||||
(o) => o.name === 'lengthText' && o.parentId === line.id,
|
||||
)
|
||||
const lengthText = canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id)
|
||||
if (lengthText) {
|
||||
lengthText.set({ actualSize: newActualSize })
|
||||
}
|
||||
@ -531,8 +562,10 @@ export function useRoofAllocationSetting(id) {
|
||||
const TOL = 1.0
|
||||
const isSamePt = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y) < TOL
|
||||
const isCollinear = (l1, l2) => {
|
||||
const v1x = l1.x2 - l1.x1, v1y = l1.y2 - l1.y1
|
||||
const v2x = l2.x2 - l2.x1, v2y = l2.y2 - l2.y1
|
||||
const v1x = l1.x2 - l1.x1,
|
||||
v1y = l1.y2 - l1.y1
|
||||
const v2x = l2.x2 - l2.x1,
|
||||
v2y = l2.y2 - l2.y1
|
||||
const m1 = Math.hypot(v1x, v1y) || 1
|
||||
const m2 = Math.hypot(v2x, v2y) || 1
|
||||
const cross = (v1x * v2y - v1y * v2x) / (m1 * m2)
|
||||
@ -566,39 +599,35 @@ export function useRoofAllocationSetting(id) {
|
||||
})
|
||||
|
||||
if (!sk) {
|
||||
logger.log(
|
||||
`[INTEGRATE] ext 짝없음 ` +
|
||||
`(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)})`
|
||||
)
|
||||
logger.log(`[INTEGRATE] ext 짝없음 ` + `(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)})`)
|
||||
return
|
||||
}
|
||||
|
||||
// [2026-04-30] sk 가 이미 SK 빌드 단계에서 연장된 경우 → merge 스킵.
|
||||
|
||||
// [save/load 보존 2026-05-13] runtime __extended 는 저장 시 사라지므로 attributes.extended 도 함께 검사.
|
||||
if (sk.__extended || sk.attributes?.extended) {
|
||||
logger.log(
|
||||
`[INTEGRATE] sk 이미 연장됨(id=${sk.id}) → merge 스킵, ext 는 lines 에서만 제거(canvas 유지)`
|
||||
)
|
||||
logger.log(`[INTEGRATE] sk 이미 연장됨(id=${sk.id}) → merge 스킵, ext 는 lines 에서만 제거(canvas 유지)`)
|
||||
extLinesOnly.push(ext)
|
||||
return
|
||||
}
|
||||
|
||||
const skP1 = { x: sk.x1, y: sk.y1 }
|
||||
const skP2 = { x: sk.x2, y: sk.y2 }
|
||||
const sharedPt = (isSamePt(extP1, skP1) || isSamePt(extP1, skP2)) ? extP1 : extP2
|
||||
const sharedPt = isSamePt(extP1, skP1) || isSamePt(extP1, skP2) ? extP1 : extP2
|
||||
const extOuter = isSamePt(extP1, sharedPt) ? extP2 : extP1
|
||||
const skOuter = isSamePt(skP1, sharedPt) ? skP2 : skP1
|
||||
const skOuter = isSamePt(skP1, sharedPt) ? skP2 : skP1
|
||||
|
||||
const extLen = Math.hypot(extP2.x - extP1.x, extP2.y - extP1.y)
|
||||
const skLen = Math.hypot(skP2.x - skP1.x, skP2.y - skP1.y)
|
||||
const skLen = Math.hypot(skP2.x - skP1.x, skP2.y - skP1.y)
|
||||
const totalLen = extLen + skLen
|
||||
|
||||
// SK 의 planeSize/actualSize 를 비율로 확장 (SK 길이가 0 이면 그대로)
|
||||
// [정밀도 0.01 유지] 저장값 소수점 2자리 (면적 계산 정확도). 표시는 addLengthText 에서 round.
|
||||
const skPlane = sk.attributes?.planeSize ?? 0
|
||||
const skPlane = sk.attributes?.planeSize ?? 0
|
||||
const skActual = sk.attributes?.actualSize ?? 0
|
||||
const ratio = skLen > 0 ? totalLen / skLen : 1
|
||||
const newPlane = Math.round(skPlane * ratio * 100) / 100
|
||||
const newPlane = Math.round(skPlane * ratio * 100) / 100
|
||||
const newActual = Math.round(skActual * ratio * 100) / 100
|
||||
|
||||
const mergedLine = new QLine([extOuter.x, extOuter.y, skOuter.x, skOuter.y], {
|
||||
@ -623,9 +652,9 @@ export function useRoofAllocationSetting(id) {
|
||||
|
||||
logger.log(
|
||||
`[INTEGRATE] merge ` +
|
||||
`ext=(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)}) len=${extLen.toFixed(1)} ` +
|
||||
`sk[${sk.lineName || sk.name}]=(${skP1.x.toFixed(1)},${skP1.y.toFixed(1)})→(${skP2.x.toFixed(1)},${skP2.y.toFixed(1)}) len=${skLen.toFixed(1)} ` +
|
||||
`→ total=${totalLen.toFixed(1)} (plane ${skPlane}→${newPlane}, actual ${skActual}→${newActual})`
|
||||
`ext=(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)}) len=${extLen.toFixed(1)} ` +
|
||||
`sk[${sk.lineName || sk.name}]=(${skP1.x.toFixed(1)},${skP1.y.toFixed(1)})→(${skP2.x.toFixed(1)},${skP2.y.toFixed(1)}) len=${skLen.toFixed(1)} ` +
|
||||
`→ total=${totalLen.toFixed(1)} (plane ${skPlane}→${newPlane}, actual ${skActual}→${newActual})`,
|
||||
)
|
||||
|
||||
removedExt.push(ext)
|
||||
@ -640,7 +669,7 @@ export function useRoofAllocationSetting(id) {
|
||||
|
||||
// roofBase.lines 에서: 기존 merge 로 제거할 ext + __extended 케이스의 ext 둘 다 제외
|
||||
// → split 단계에서 ext 가 외곽 처마처럼 처리되는 것 방지.
|
||||
roofBase.lines = roofBase.lines.filter((l) => !removedExt.includes(l) && !extLinesOnly.includes(l))
|
||||
roofBase.lines = roofBase.lines.filter((l) => !removedExt.includes(l) && !extLinesOnly.includes(l))
|
||||
roofBase.innerLines = roofBase.innerLines.filter((l) => !removedSk.includes(l))
|
||||
roofBase.innerLines = [...roofBase.innerLines, ...merged]
|
||||
|
||||
@ -652,9 +681,9 @@ export function useRoofAllocationSetting(id) {
|
||||
|
||||
logger.log(
|
||||
`[INTEGRATE] 완료 roofBase.id=${roofBase.id} ` +
|
||||
`ext제거=${removedExt.length} sk제거=${removedSk.length} merged=${merged.length} ` +
|
||||
`extKeptInCanvas=${extLinesOnly.length} ` +
|
||||
`→ lines=${roofBase.lines.length} innerLines=${roofBase.innerLines.length}`
|
||||
`ext제거=${removedExt.length} sk제거=${removedSk.length} merged=${merged.length} ` +
|
||||
`extKeptInCanvas=${extLinesOnly.length} ` +
|
||||
`→ lines=${roofBase.lines.length} innerLines=${roofBase.innerLines.length}`,
|
||||
)
|
||||
}
|
||||
|
||||
@ -803,6 +832,154 @@ export function useRoofAllocationSetting(id) {
|
||||
let absorber = null
|
||||
let absorberShares = null
|
||||
adjacencyMap.forEach((shares, sub) => {
|
||||
// 다중 공유 → bowtie 위험, 머지 skip
|
||||
if (shares.length !== 1) {
|
||||
logger.log(`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — sharedEdges=${shares.length} (≠1, bowtie 위험)`)
|
||||
return
|
||||
}
|
||||
const { subEdgeStart, xEdgeIdx } = shares[0]
|
||||
const pts = (sub.points || []).map((p) => ({ x: p.x, y: p.y }))
|
||||
const oldLines = [...(sub.lines || [])]
|
||||
const N = Xpts.length
|
||||
|
||||
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] detour 방향은 sub.pts[subEdgeStart] 가
|
||||
// X.xEdge.a (=Xpts[xEdgeIdx]) 인지 X.xEdge.b (=Xpts[xEdgeIdx+1]) 인지로 결정.
|
||||
// sub 외곽선 진행 방향이 xEdge 의 a→b 와 같으면 detour 는 X 의 a 쪽 이웃부터 (역방향)
|
||||
// 반대면 detour 는 X 의 b 쪽 이웃부터 (정방향).
|
||||
const subA = pts[subEdgeStart]
|
||||
const xA = Xpts[xEdgeIdx]
|
||||
const sameDir = eq(subA, xA)
|
||||
|
||||
const detour = []
|
||||
const detourEdgeIdx = []
|
||||
if (sameDir) {
|
||||
// a → Xpts[i-1] → Xpts[i-2] → ... → Xpts[i+2] → b
|
||||
// detour vertices: N-2 개 (a, b 사이)
|
||||
for (let k = 1; k <= N - 2; k++) {
|
||||
detour.push(Xpts[(xEdgeIdx - k + N) % N])
|
||||
}
|
||||
// detour edges: N-1 개. a→Xpts[i-1] 는 X 의 (i-1)%N 변 (반대방향).
|
||||
for (let k = 1; k <= N - 1; k++) {
|
||||
detourEdgeIdx.push((xEdgeIdx - k + N) % N)
|
||||
}
|
||||
} else {
|
||||
// b → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → a 의 a→b 표기:
|
||||
// a → Xpts[i+2] → Xpts[i+3] → ... → Xpts[i+N-1] → b (subA=b 인 관점에서 보면 반대)
|
||||
// 정확히는 subA=X.xEdge.b → detour 시작 = Xpts[(i+2)%N] (b 의 X 내 이웃)
|
||||
for (let k = 2; k <= N - 1; k++) {
|
||||
detour.push(Xpts[(xEdgeIdx + k) % N])
|
||||
}
|
||||
for (let k = 1; k <= N - 1; k++) {
|
||||
detourEdgeIdx.push((xEdgeIdx + k) % N)
|
||||
}
|
||||
}
|
||||
|
||||
const buildCand = (verts) => {
|
||||
const c = [...pts]
|
||||
c.splice(subEdgeStart + 1, 0, ...verts)
|
||||
return c
|
||||
}
|
||||
|
||||
const candA = buildCand(detour)
|
||||
const oldSigned = signedArea(pts)
|
||||
const aSigned = signedArea(candA)
|
||||
const sameSign = (s) => oldSigned === 0 || Math.sign(s) === Math.sign(oldSigned)
|
||||
const okA = sameSign(aSigned) && Math.abs(aSigned) > Math.abs(oldSigned) + 0.5
|
||||
|
||||
let finalPts = null
|
||||
let finalEdges = null
|
||||
if (okA) {
|
||||
finalPts = candA
|
||||
finalEdges = detourEdgeIdx
|
||||
} else {
|
||||
// fallback — 반대 방향 시도 (X 가 CW 로 들어온 경우 대비)
|
||||
const detourRev = [...detour].reverse()
|
||||
const detourEdgeRev = [...detourEdgeIdx].reverse()
|
||||
const candB = buildCand(detourRev)
|
||||
const bSigned = signedArea(candB)
|
||||
const okB = sameSign(bSigned) && Math.abs(bSigned) > Math.abs(oldSigned) + 0.5
|
||||
if (okB) {
|
||||
finalPts = candB
|
||||
finalEdges = detourEdgeRev
|
||||
logger.log(
|
||||
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} fallback reverse detour 사용 ` +
|
||||
`oldSigned=${oldSigned.toFixed(0)} → ${bSigned.toFixed(0)}`,
|
||||
)
|
||||
} else {
|
||||
logger.log(
|
||||
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} skip — detour 양방향 실패 ` +
|
||||
`sameDir=${sameDir} oldSigned=${oldSigned.toFixed(0)} ` +
|
||||
`aSigned=${aSigned.toFixed(0)} bSigned=${bSigned.toFixed(0)} ` +
|
||||
`subA=(${subA.x.toFixed(1)},${subA.y.toFixed(1)}) ` +
|
||||
`xA=(${xA.x.toFixed(1)},${xA.y.toFixed(1)}) ` +
|
||||
`xB=(${Xpts[(xEdgeIdx + 1) % N].x.toFixed(1)},${Xpts[(xEdgeIdx + 1) % N].y.toFixed(1)})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// sub.lines 재구성 — 옛 공유 변 1개 → detour 변 N-1개
|
||||
const newSubLines = []
|
||||
for (let i = 0; i < oldLines.length; i++) {
|
||||
if (i === subEdgeStart) {
|
||||
for (let k = 0; k < finalEdges.length; k++) {
|
||||
const xLine = Xlines[finalEdges[k]]
|
||||
const p1 = finalPts[(subEdgeStart + k) % finalPts.length]
|
||||
const p2 = finalPts[(subEdgeStart + k + 1) % finalPts.length]
|
||||
const attrs = xLine?.attributes ? { ...xLine.attributes } : { type: 'kerabValleyOverlapLine', offset: 0 }
|
||||
const ln = new QLine([p1.x, p1.y, p2.x, p2.y], {
|
||||
stroke: sub.stroke,
|
||||
strokeWidth: sub.strokeWidth,
|
||||
fontSize: sub.fontSize,
|
||||
attributes: attrs,
|
||||
textVisible: false,
|
||||
parent: sub,
|
||||
parentId: sub.id,
|
||||
idx: newSubLines.length + 1,
|
||||
})
|
||||
ln.startPoint = p1
|
||||
ln.endPoint = p2
|
||||
newSubLines.push(ln)
|
||||
}
|
||||
} else {
|
||||
const ln = oldLines[i]
|
||||
if (ln) {
|
||||
ln.idx = newSubLines.length + 1
|
||||
newSubLines.push(ln)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [KERAB-VALLEY-OVERLAP-MERGE 2026-05-28] fabric.Polygon 정식 points 갱신 패턴.
|
||||
// src/util/canvas-util.js#anchorWrapper 패턴 그대로 적용 —
|
||||
// _setPositionDimensions 가 width/height/pathOffset/left/top 을 재계산하므로
|
||||
// 변경 전 앵커(pts[0]) 절대좌표를 캡쳐 → 변경 후 setPositionByOrigin 으로 복원.
|
||||
// 그래야 polygon 의 path 가 새 bbox 기준으로 다시 그려지면서 시각 위치도 유지됨.
|
||||
const anchorIdx = 0
|
||||
const oldLocal = {
|
||||
x: sub.points[anchorIdx].x - sub.pathOffset.x,
|
||||
y: sub.points[anchorIdx].y - sub.pathOffset.y,
|
||||
}
|
||||
const absolutePoint = fabric.util.transformPoint(oldLocal, sub.calcTransformMatrix())
|
||||
|
||||
sub.points = finalPts
|
||||
sub.lines = newSubLines
|
||||
sub._setPositionDimensions({})
|
||||
|
||||
const strokeW = sub.strokeUniform ? sub.strokeWidth / sub.scaleX : sub.strokeWidth
|
||||
const baseW = sub.width + strokeW
|
||||
const baseH = sub.height + (sub.strokeUniform ? sub.strokeWidth / sub.scaleY : sub.strokeWidth)
|
||||
const newX = (sub.points[anchorIdx].x - sub.pathOffset.x) / Math.max(baseW, 1e-9)
|
||||
const newY = (sub.points[anchorIdx].y - sub.pathOffset.y) / Math.max(baseH, 1e-9)
|
||||
sub.setPositionByOrigin(absolutePoint, newX + 0.5, newY + 0.5)
|
||||
sub.setCoords?.()
|
||||
sub.dirty = true
|
||||
|
||||
logger.log(
|
||||
`[KERAB-VALLEY-OVERLAP-MERGE] sub.id=${sub.id} 머지 완료 ` +
|
||||
`oldSigned=${oldSigned.toFixed(0)} → newSigned=${signedArea(finalPts).toFixed(0)} ` +
|
||||
`pts=${finalPts.length} lines=${newSubLines.length}`,
|
||||
)
|
||||
if (!absorber || shares.length > absorberShares.length) {
|
||||
absorber = sub
|
||||
absorberShares = shares
|
||||
@ -810,8 +987,7 @@ export function useRoofAllocationSetting(id) {
|
||||
})
|
||||
if (!absorber || absorberShares.length < lLen) {
|
||||
logger.log(
|
||||
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 전체를 가진 흡수 면 없음 ` +
|
||||
`(maxShares=${absorberShares?.length ?? 0}, lLen=${lLen})`,
|
||||
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 전체를 가진 흡수 면 없음 ` + `(maxShares=${absorberShares?.length ?? 0}, lLen=${lLen})`,
|
||||
)
|
||||
return
|
||||
}
|
||||
@ -841,13 +1017,9 @@ export function useRoofAllocationSetting(id) {
|
||||
// 면적 부호 보존 + 확장(절댓값 증가) 검증 — 실패 시 밴드 보존(흡수 skip)
|
||||
const oldSigned = signedArea(aPts)
|
||||
const newSigned = signedArea(newPts)
|
||||
if (
|
||||
(oldSigned !== 0 && Math.sign(newSigned) !== Math.sign(oldSigned)) ||
|
||||
Math.abs(newSigned) <= Math.abs(oldSigned) + 0.5
|
||||
) {
|
||||
if ((oldSigned !== 0 && Math.sign(newSigned) !== Math.sign(oldSigned)) || Math.abs(newSigned) <= Math.abs(oldSigned) + 0.5) {
|
||||
logger.log(
|
||||
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 확장 검증 실패 ` +
|
||||
`oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
|
||||
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 확장 검증 실패 ` + `oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
|
||||
)
|
||||
return
|
||||
}
|
||||
@ -856,18 +1028,12 @@ export function useRoofAllocationSetting(id) {
|
||||
const findLine = (p1, p2) => {
|
||||
for (const l of aLines) {
|
||||
if (!l) continue
|
||||
if (
|
||||
(eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) ||
|
||||
(eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1))
|
||||
)
|
||||
if ((eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) || (eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1)))
|
||||
return { src: l, reuse: true }
|
||||
}
|
||||
for (const l of Xlines) {
|
||||
if (!l) continue
|
||||
if (
|
||||
(eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) ||
|
||||
(eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1))
|
||||
)
|
||||
if ((eq({ x: l.x1, y: l.y1 }, p1) && eq({ x: l.x2, y: l.y2 }, p2)) || (eq({ x: l.x1, y: l.y1 }, p2) && eq({ x: l.x2, y: l.y2 }, p1)))
|
||||
return { src: l, reuse: false }
|
||||
}
|
||||
return null
|
||||
@ -881,7 +1047,12 @@ export function useRoofAllocationSetting(id) {
|
||||
found.src.idx = newLines.length + 1
|
||||
newLines.push(found.src)
|
||||
} else {
|
||||
const attrs = found?.src?.attributes ? { ...found.src.attributes } : { type: 'kerabValleyOverlapLine', offset: 0 }
|
||||
const attrs = found?.src?.attributes
|
||||
? { ...found.src.attributes }
|
||||
: {
|
||||
type: 'kerabValleyOverlapLine',
|
||||
offset: 0,
|
||||
}
|
||||
const ln = new QLine([p1.x, p1.y, p2.x, p2.y], {
|
||||
stroke: absorber.stroke,
|
||||
strokeWidth: absorber.strokeWidth,
|
||||
@ -935,6 +1106,7 @@ export function useRoofAllocationSetting(id) {
|
||||
* 지붕면 할당
|
||||
*/
|
||||
const apply = () => {
|
||||
saveSnapshot()
|
||||
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial)
|
||||
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
|
||||
logger.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`)
|
||||
@ -949,12 +1121,16 @@ export function useRoofAllocationSetting(id) {
|
||||
roofBases.forEach((roofBase) => {
|
||||
try {
|
||||
// 지붕 할당 로직에 extensionLine 추가
|
||||
|
||||
// [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 출폭 띠 외곽 라인(kerabValleyOverlapLine) 도 할당 대상.
|
||||
// b polygon 의 lines[] 에 추가되어 b 의 면이 출폭 띠 영역까지 확장 → a 의 sub-면과 겹침 표현.
|
||||
const roofEaveHelpLines = canvas.getObjects().filter((obj) =>
|
||||
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') &&
|
||||
obj.roofId === roofBase.id
|
||||
)
|
||||
const roofEaveHelpLines = canvas
|
||||
.getObjects()
|
||||
.filter(
|
||||
(obj) =>
|
||||
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') &&
|
||||
obj.roofId === roofBase.id,
|
||||
)
|
||||
// logger.log('roofBase.id:', roofBase.id)
|
||||
// logger.log('roofEaveHelpLines:', roofEaveHelpLines)
|
||||
// logger.log('extensionLines found:', roofEaveHelpLines.filter(l => l.lineName === 'extensionLine'))
|
||||
@ -966,44 +1142,53 @@ export function useRoofAllocationSetting(id) {
|
||||
// Filter out lines from roofBase.lines that share any points with newEaveLines
|
||||
|
||||
// extensionLine과 일반 eaveHelpLine 분리
|
||||
const extensionLines = newEaveLines.filter(line => line.lineName === 'extensionLine')
|
||||
const normalEaveLines = newEaveLines.filter(line => line.lineName === 'eaveHelpLine')
|
||||
|
||||
const extensionLines = newEaveLines.filter((line) => line.lineName === 'extensionLine')
|
||||
const normalEaveLines = newEaveLines.filter((line) => line.lineName === 'eaveHelpLine')
|
||||
// [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 겹침 라인 — overlap 판단 제외, 단순 결합
|
||||
const overlapLines = newEaveLines.filter(line => line.lineName === 'kerabValleyOverlapLine')
|
||||
const overlapLines = newEaveLines.filter((line) => line.lineName === 'kerabValleyOverlapLine')
|
||||
// logger.log('extensionLines count:', extensionLines.length)
|
||||
// logger.log('normalEaveLines count:', normalEaveLines.length)
|
||||
|
||||
// 일반 eaveHelpLine만 Overlap 판단에 사용
|
||||
const linesToKeep = roofBase.lines.filter(roofLine => {
|
||||
const shouldRemove = normalEaveLines.some(eaveLine => {
|
||||
const linesToKeep = roofBase.lines.filter((roofLine) => {
|
||||
const shouldRemove = normalEaveLines.some((eaveLine) => {
|
||||
// 1. 기본적인 포인트 일치 확인
|
||||
const rX1 = roofLine.x1, rY1 = roofLine.y1, rX2 = roofLine.x2, rY2 = roofLine.y2;
|
||||
const eX1 = eaveLine.x1, eY1 = eaveLine.y1, eX2 = eaveLine.x2, eY2 = eaveLine.y2;
|
||||
const rX1 = roofLine.x1,
|
||||
rY1 = roofLine.y1,
|
||||
rX2 = roofLine.x2,
|
||||
rY2 = roofLine.y2
|
||||
const eX1 = eaveLine.x1,
|
||||
eY1 = eaveLine.y1,
|
||||
eX2 = eaveLine.x2,
|
||||
eY2 = eaveLine.y2
|
||||
|
||||
const isP1Matched = (Math.abs(rX1 - eX1) < 0.1 && Math.abs(rY1 - eY1) < 0.1) || (Math.abs(rX1 - eX2) < 0.1 && Math.abs(rY1 - eY2) < 0.1);
|
||||
const isP2Matched = (Math.abs(rX2 - eX1) < 0.1 && Math.abs(rY2 - eY1) < 0.1) || (Math.abs(rX2 - eX2) < 0.1 && Math.abs(rY2 - eY2) < 0.1);
|
||||
const isP1Matched =
|
||||
(Math.abs(rX1 - eX1) < 0.1 && Math.abs(rY1 - eY1) < 0.1) || (Math.abs(rX1 - eX2) < 0.1 && Math.abs(rY1 - eY2) < 0.1)
|
||||
const isP2Matched =
|
||||
(Math.abs(rX2 - eX1) < 0.1 && Math.abs(rY2 - eY1) < 0.1) || (Math.abs(rX2 - eX2) < 0.1 && Math.abs(rY2 - eY2) < 0.1)
|
||||
|
||||
if (isP1Matched || isP2Matched) {
|
||||
// 2. 일직선(평행)인지 확인
|
||||
const dx1 = rX2 - rX1;
|
||||
const dy1 = rY2 - rY1;
|
||||
const dx2 = eX2 - eX1;
|
||||
const dy2 = eY2 - eY1;
|
||||
const crossProduct = Math.abs(dx1 * dy2 - dy1 * dx2);
|
||||
const mag1 = Math.sqrt(dx1 * dx1 + dy1 * dy1);
|
||||
const mag2 = Math.sqrt(dx2 * dx2 + dy2 * dy2);
|
||||
const isStraight = (mag1 * mag2) === 0 ? true : (crossProduct / (mag1 * mag2) < 0.01);
|
||||
const dx1 = rX2 - rX1
|
||||
const dy1 = rY2 - rY1
|
||||
const dx2 = eX2 - eX1
|
||||
const dy2 = eY2 - eY1
|
||||
const crossProduct = Math.abs(dx1 * dy2 - dy1 * dx2)
|
||||
const mag1 = Math.sqrt(dx1 * dx1 + dy1 * dy1)
|
||||
const mag2 = Math.sqrt(dx2 * dx2 + dy2 * dy2)
|
||||
const isStraight = mag1 * mag2 === 0 ? true : crossProduct / (mag1 * mag2) < 0.01
|
||||
|
||||
if (isStraight) {
|
||||
// 3. [핵심] 몸통이 포개지는지(Overlap) 확인
|
||||
// 한 선의 끝점이 다른 선의 "내부"에 들어와 있는지 체크
|
||||
const isPointInside = (x, y, x1, y1, x2, y2) => {
|
||||
const dotProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1);
|
||||
if (dotProduct < 0.1) return false; // 시작점 바깥쪽
|
||||
const squaredLength = (x2 - x1) ** 2 + (y2 - y1) ** 2;
|
||||
if (dotProduct > squaredLength - 0.1) return false; // 끝점 바깥쪽
|
||||
return true; // 선의 내부(몸통)에 있음
|
||||
};
|
||||
const dotProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1)
|
||||
if (dotProduct < 0.1) return false // 시작점 바깥쪽
|
||||
const squaredLength = (x2 - x1) ** 2 + (y2 - y1) ** 2
|
||||
if (dotProduct > squaredLength - 0.1) return false // 끝점 바깥쪽
|
||||
return true // 선의 내부(몸통)에 있음
|
||||
}
|
||||
|
||||
// roofLine의 끝점 중 하나가 eaveLine의 몸통 안에 있거나,
|
||||
// eaveLine의 끝점 중 하나가 roofLine의 몸통 안에 있으면 "포개짐"으로 판단
|
||||
@ -1011,20 +1196,21 @@ export function useRoofAllocationSetting(id) {
|
||||
isPointInside(rX1, rY1, eX1, eY1, eX2, eY2) ||
|
||||
isPointInside(rX2, rY2, eX1, eY1, eX2, eY2) ||
|
||||
isPointInside(eX1, eY1, rX1, rY1, rX2, rY2) ||
|
||||
isPointInside(eX2, eY2, rX1, rY1, rX2, rY2);
|
||||
isPointInside(eX2, eY2, rX1, rY1, rX2, rY2)
|
||||
|
||||
if (isOverlapping) {
|
||||
logger.log('Removing overlapping line:', roofLine);
|
||||
return true; // 포개지는 경우에만 삭제
|
||||
logger.log('Removing overlapping line:', roofLine)
|
||||
return true // 포개지는 경우에만 삭제
|
||||
}
|
||||
}
|
||||
}
|
||||
return false; // 끝점만 닿아 있거나 직각인 경우는 살림
|
||||
});
|
||||
return !shouldRemove;
|
||||
});
|
||||
// Combine remaining lines with newEaveLines
|
||||
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines];
|
||||
|
||||
return false // 끝점만 닿아 있거나 직각인 경우는 살림
|
||||
})
|
||||
return !shouldRemove
|
||||
})
|
||||
// Combine remaining lines with newEaveLines
|
||||
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines]
|
||||
} else {
|
||||
roofBase.lines = [...roofEaveHelpLines]
|
||||
}
|
||||
@ -1065,9 +1251,7 @@ export function useRoofAllocationSetting(id) {
|
||||
integrateExtensionLines(roofBase)
|
||||
|
||||
// [KERAB-PATTERN-DIAG 2026-05-19] 패턴 라인 상태 진단 — 분할 직전 스냅샷
|
||||
const __patternLines = roofBase.innerLines.filter(
|
||||
(l) => l?.lineName === 'kerabPatternHip' || l?.lineName === 'kerabPatternRidge',
|
||||
)
|
||||
const __patternLines = roofBase.innerLines.filter((l) => l?.lineName === 'kerabPatternHip' || l?.lineName === 'kerabPatternRidge')
|
||||
if (__patternLines.length > 0) {
|
||||
logger.log(
|
||||
`[KERAB-PATTERN-DIAG] roof=${roofBase.id} patternLines=${__patternLines.length} ` +
|
||||
@ -1087,24 +1271,18 @@ export function useRoofAllocationSetting(id) {
|
||||
// case A(저장 X→케라바→할당) 에서 canvas 의 kerabValleyOverlapLine 가 silent 사라지는 문제 회피.
|
||||
// vExt(kerabPatternValleyExt) 는 roof.innerLines 에 안정적으로 보존 — 거기서 직접 재계산.
|
||||
// 조건: vExt 가 polygon 내부에 들어온 케이스 한정 (innerLines 에 있으면 자동 충족).
|
||||
const vExtsForOverlap = (roofBase.innerLines || []).filter(
|
||||
(l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false,
|
||||
)
|
||||
const vExtsForOverlap = (roofBase.innerLines || []).filter((l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false)
|
||||
// [KERAB-VALLEY-OVERLAP-DEDUP 2026-06-04] buildOverlapLine(useEavesGableEdit)가 케라바 토글 시
|
||||
// 생성한 밴드와 이 RECALC 가 이중 생성 → split 가 중복 면 → 머지 모호 → 대각선.
|
||||
// 단일 소스로 통일: 재계산 전 기존 kerabValleyOverlapLine 을 roofBase/canvas 에서 제거.
|
||||
// vExt 가 있는 roof 만 (= RECALC 가 다시 생성할 roof) 대상 — 아니면 기존 밴드 유실.
|
||||
if (vExtsForOverlap.length > 0) {
|
||||
const isBandLine = (l) =>
|
||||
l && (l.lineName === 'kerabValleyOverlapLine' || l.attributes?.type === 'kerabValleyOverlapLine')
|
||||
const isBandLine = (l) => l && (l.lineName === 'kerabValleyOverlapLine' || l.attributes?.type === 'kerabValleyOverlapLine')
|
||||
roofBase.lines = (roofBase.lines || []).filter((l) => !isBandLine(l))
|
||||
roofBase.innerLines = (roofBase.innerLines || []).filter((l) => !isBandLine(l))
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter(
|
||||
(o) =>
|
||||
isBandLine(o) && (o.roofId === roofBase.id || o.parentId === roofBase.id || o.attributes?.roofId === roofBase.id),
|
||||
)
|
||||
.filter((o) => isBandLine(o) && (o.roofId === roofBase.id || o.parentId === roofBase.id || o.attributes?.roofId === roofBase.id))
|
||||
.forEach((o) => canvas.remove(o))
|
||||
}
|
||||
// [KERAB-VALLEY-EXT-SPLIT 2026-06-04] vExt 가 split 되면 같은 V1 이 여러 collinear 세그먼트로 쪼개진다.
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentObjectState, pitchTextSelector } from '@/store/canvasAtom'
|
||||
import { useRecoilValue } from 'recoil'
|
||||
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentMenuState, currentObjectState, pitchTextSelector } from '@/store/canvasAtom'
|
||||
import { useRecoilValue, useSetRecoilState } from 'recoil'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useLine } from '@/hooks/useLine'
|
||||
import { useMessage } from '@/hooks/useMessage'
|
||||
import { useEvent } from '@/hooks/useEvent'
|
||||
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||
import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common'
|
||||
import { useMode } from '@/hooks/useMode'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
import { useSwal } from '@/hooks/useSwal'
|
||||
import { usePopup } from '@/hooks/usePopup'
|
||||
import { getChonByDegree } from '@/util/canvas-util'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
//지붕형상 수동 설정
|
||||
export function useRoofShapePassivitySetting(id) {
|
||||
@ -19,6 +20,7 @@ export function useRoofShapePassivitySetting(id) {
|
||||
SHED: 'shed',
|
||||
}
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
const setCurrentMenu = useSetRecoilState(currentMenuState)
|
||||
const currentAngleType = useRecoilValue(currentAngleTypeSelector)
|
||||
const pitchText = useRecoilValue(pitchTextSelector)
|
||||
const { getMessage } = useMessage()
|
||||
@ -35,10 +37,13 @@ export function useRoofShapePassivitySetting(id) {
|
||||
const history = useRef([])
|
||||
const [type, setType] = useState(TYPES.EAVES)
|
||||
const isFix = useRef(false)
|
||||
const isConverted = useRef(false)
|
||||
const initLines = useRef([])
|
||||
const removedWalls = useRef([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
const { closePopup } = usePopup()
|
||||
const { saveSnapshot, setDrawing } = useUndoRedo()
|
||||
const buttons = [
|
||||
{ id: 1, name: getMessage('eaves'), type: TYPES.EAVES },
|
||||
{ id: 2, name: getMessage('gable'), type: TYPES.GABLE },
|
||||
@ -48,11 +53,17 @@ export function useRoofShapePassivitySetting(id) {
|
||||
useEffect(() => {
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
if (!canvas.outerLineFix || outerLines.length === 0) {
|
||||
swalFire({ text: getMessage('wall.line.not.found'),icon: 'warning' })
|
||||
swalFire({ text: getMessage('wall.line.not.found'), icon: 'warning' })
|
||||
closePopup(id)
|
||||
//return
|
||||
}
|
||||
// 수동설정 popup 진행 중에는 undo/redo/snapshot 차단
|
||||
setDrawing(true)
|
||||
setIsLoading(true)
|
||||
return () => {
|
||||
// popup 닫힐 때 (handleSave 후 closePopup, 또는 사용자가 그냥 닫기) 잠금 해제
|
||||
setDrawing(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
@ -60,6 +71,7 @@ export function useRoofShapePassivitySetting(id) {
|
||||
addCanvasMouseEventListener('mouse:down', mouseDown)
|
||||
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
|
||||
|
||||
removedWalls.current = wallLines.map((wall) => ({ wall, index: canvas.getObjects().indexOf(wall) }))
|
||||
canvas?.remove(...wallLines)
|
||||
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
@ -191,17 +203,52 @@ export function useRoofShapePassivitySetting(id) {
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
// 모든 외곽선이 처마/케라바/편류 중 하나로 설정되었는지 검증 → 미설정 라인이 있으면 완료 차단
|
||||
const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
const isAllSet = lines.length > 0 && lines.every((line) => !!line.attributes?.type)
|
||||
if (!isAllSet) {
|
||||
swalFire({ text: getMessage('modal.canvas.setting.roofline.properties.setting.not.setting'), icon: 'warning' })
|
||||
return
|
||||
}
|
||||
isFix.current = true
|
||||
handleLineToPolygon()
|
||||
|
||||
// 수동설정 완료 → 지붕면 할당 메뉴로 전환 (undo 가드 해제)
|
||||
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_ALLOC)
|
||||
closePopup(id)
|
||||
}
|
||||
|
||||
const handleLineToPolygon = () => {
|
||||
// 완료(handleSave) 후 closePopup → 언마운트 cleanup 에서 중복 호출되는 것을 방지 (스냅샷/지붕 중복 생성 차단)
|
||||
if (isConverted.current) return
|
||||
isConverted.current = true
|
||||
|
||||
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||
const exceptObjs = canvas.getObjects().filter((obj) => obj.name !== 'outerLine' && obj.parent?.name !== 'outerLine')
|
||||
const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
|
||||
if (!isFix.current) {
|
||||
// 저장 없이 닫을 경우 진입 시점 스냅샷으로 롤백 — 캔버스를 진입 전 상태 그대로 되돌린다
|
||||
lines.forEach((line, idx) => {
|
||||
const init = initLines.current[idx]
|
||||
if (!init) {
|
||||
return
|
||||
}
|
||||
init.visible ? showLine(line) : hideLine(line)
|
||||
line.set({ stroke: init.stroke, strokeWidth: init.strokeWidth, selectable: init.selectable })
|
||||
line.attributes = init.attributes
|
||||
})
|
||||
|
||||
// 진입 시 삭제했던 WALL 폴리곤 원본을 원래 z-순서 그대로 재삽입
|
||||
removedWalls.current.forEach(({ wall, index }) => {
|
||||
canvas.insertAt(wall, index)
|
||||
})
|
||||
removedWalls.current = []
|
||||
|
||||
canvas.renderAll()
|
||||
return
|
||||
}
|
||||
|
||||
const exceptObjs = canvas.getObjects().filter((obj) => obj.name !== 'outerLine' && obj.parent?.name !== 'outerLine')
|
||||
|
||||
let checkedAllSetting = true
|
||||
|
||||
lines.forEach((line) => {
|
||||
@ -219,36 +266,42 @@ export function useRoofShapePassivitySetting(id) {
|
||||
canvas.remove(obj)
|
||||
})
|
||||
|
||||
// 저장 시에는 자동 설정과 동일하게 외벽선을 타입별 색상으로 표시
|
||||
lines.forEach((line) => {
|
||||
hideLine(line)
|
||||
let stroke
|
||||
if (line.attributes?.type === LINE_TYPE.WALLLINE.EAVES || line.attributes?.type === LINE_TYPE.WALLLINE.HIPANDGABLE) {
|
||||
stroke = '#45CD7D'
|
||||
} else if (line.attributes?.type === LINE_TYPE.WALLLINE.GABLE || line.attributes?.type === LINE_TYPE.WALLLINE.JERKINHEAD) {
|
||||
stroke = '#3FBAE6'
|
||||
} else {
|
||||
stroke = '#000000'
|
||||
}
|
||||
// [2026-06-16] 저장 후에도 외벽선 클릭 선택 허용. 드래그는 lockMovement(useCanvas), 보조선 가로채기는 z-order(18abbeb9)로 별도 차단
|
||||
line.set({ stroke, strokeWidth: 4, selectable: true, visible: true })
|
||||
})
|
||||
|
||||
let wall
|
||||
|
||||
if (isFix.current) {
|
||||
wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
|
||||
} else {
|
||||
// 그냥 닫을 경우 처리
|
||||
wall = addPolygonByLines([...initLines.current], {
|
||||
name: POLYGON_TYPE.WALL,
|
||||
fill: 'transparent',
|
||||
stroke: 'black',
|
||||
})
|
||||
lines.forEach((line, idx) => {
|
||||
line.attributes = initLines.current[idx].attributes
|
||||
})
|
||||
}
|
||||
|
||||
const wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
|
||||
wall.lines = [...lines]
|
||||
|
||||
// 기존 그려진 지붕이 없다면
|
||||
// 완료 한 경우에는 지붕까지 그려줌
|
||||
addPitchTextsByOuterLines()
|
||||
const roof = drawRoofPolygon(wall)
|
||||
addLengthText(roof)
|
||||
|
||||
if (isFix.current) {
|
||||
// 완료 한 경우에는 지붕까지 그려줌
|
||||
addPitchTextsByOuterLines()
|
||||
// 지붕 그리기 직전(라인 세팅 완료) 상태를 스냅샷 → undo 시 지붕이 그려지기 전 화면으로 복귀
|
||||
saveSnapshot(null, { force: true })
|
||||
const roof = drawRoofPolygon(wall)
|
||||
addLengthText(roof)
|
||||
}
|
||||
// 외벽선은 WALL/ROOF 폴리곤 바로 위에 배치 — 맨 앞(bringToFront)으로 올리면
|
||||
// offset 0인 변에서 같은 좌표에 그려진 보조선의 클릭을 가로채 선택을 막는다
|
||||
const polygonTopIdx = canvas
|
||||
.getObjects()
|
||||
.reduce((top, obj, idx) => (obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ? idx : top), -1)
|
||||
lines.forEach((line) => canvas.moveTo(line, polygonTopIdx))
|
||||
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
|
||||
import { useMessage } from '@/hooks/useMessage'
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
|
||||
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentMenuState, currentObjectState, pitchTextSelector } from '@/store/canvasAtom'
|
||||
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||
import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
import { useMode } from '@/hooks/useMode'
|
||||
import { useLine } from '@/hooks/useLine'
|
||||
@ -20,6 +20,9 @@ import {
|
||||
} from '@/store/settingAtom'
|
||||
import { useAxios } from '@/hooks/useAxios'
|
||||
import { globalLocaleStore } from '@/store/localeAtom'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { pendingPopupState } from '@/store/popupAtom'
|
||||
import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting'
|
||||
|
||||
// 지붕형상 설정
|
||||
export function useRoofShapeSetting(id) {
|
||||
@ -52,6 +55,11 @@ export function useRoofShapeSetting(id) {
|
||||
|
||||
const isFixRef = useRef(false)
|
||||
|
||||
// 저장 없이 닫을 때 롤백할 진입 시점 스냅샷
|
||||
const initLinesRef = useRef([])
|
||||
const removedWallsRef = useRef([])
|
||||
const pitchTextLineIdsRef = useRef(new Set())
|
||||
|
||||
const pitchRef = useRef(null)
|
||||
const shedPitchRef = useRef(null)
|
||||
const jerkinHeadPitchRef = useRef(null)
|
||||
@ -69,6 +77,8 @@ export function useRoofShapeSetting(id) {
|
||||
const { post } = useAxios(globalLocaleState)
|
||||
|
||||
const { addLine, removeLine } = useLine()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
const [pendingPopup, setPendingPopup] = useRecoilState(pendingPopupState)
|
||||
|
||||
useEffect(() => {
|
||||
pitchRef.current = currentAngleType === ANGLE_TYPE.SLOPE ? pitch : getChonByDegree(pitch)
|
||||
@ -80,6 +90,18 @@ export function useRoofShapeSetting(id) {
|
||||
jerkinHeadPitchRef.current = currentAngleType === ANGLE_TYPE.SLOPE ? jerkinHeadPitch : getChonByDegree(jerkinHeadPitch)
|
||||
}, [jerkinHeadPitch])
|
||||
|
||||
// undo/redo 로 pendingPopup 이 복원되면 해당 팝업을 다시 연다
|
||||
useEffect(() => {
|
||||
if (!pendingPopup) return
|
||||
if (pendingPopup.type === 'RoofShapeSetting') {
|
||||
addPopup(pendingPopup.id, 1, <RoofShapeSetting id={pendingPopup.id} pos={pendingPopup.pos} />)
|
||||
setPendingPopup(null)
|
||||
} else if (pendingPopup.type === 'RoofAllocation') {
|
||||
addPopup(pendingPopup.id, 1, <RoofAllocationSetting id={pendingPopup.id} pos={pendingPopup.pos} />)
|
||||
setPendingPopup(null)
|
||||
}
|
||||
}, [pendingPopup])
|
||||
|
||||
useEffect(() => {
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
if (!canvas.outerLineFix || outerLines.length === 0) {
|
||||
@ -87,14 +109,46 @@ export function useRoofShapeSetting(id) {
|
||||
closePopup(id)
|
||||
return
|
||||
}
|
||||
return () => {
|
||||
if (!isFixRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
initLinesRef.current = outerLines.map((line) => ({ ...line }))
|
||||
pitchTextLineIdsRef.current = new Set(
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((obj) => obj.name === 'pitchText')
|
||||
.map((obj) => obj.parentId),
|
||||
)
|
||||
|
||||
return () => {
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
const pitchTexts = canvas.getObjects().filter((obj) => obj.name === 'pitchText')
|
||||
|
||||
if (!isFixRef.current) {
|
||||
// 저장 없이 닫을 경우 진입 시점 스냅샷으로 롤백 — 캔버스를 진입 전 상태 그대로 되돌린다
|
||||
canvas.remove(...pitchTexts)
|
||||
outerLines.forEach((line, idx) => {
|
||||
const init = initLinesRef.current[idx]
|
||||
if (!init) {
|
||||
return
|
||||
}
|
||||
init.visible ? showLine(line) : hideLine(line)
|
||||
line.set({ stroke: init.stroke, strokeWidth: init.strokeWidth, selectable: init.selectable })
|
||||
line.attributes = init.attributes
|
||||
if (pitchTextLineIdsRef.current.has(line.id)) {
|
||||
addPitchText(line)
|
||||
}
|
||||
})
|
||||
|
||||
// 변별로 설정 진입 시 삭제했던 WALL 폴리곤 원본을 원래 z-순서 그대로 재삽입
|
||||
removedWallsRef.current.forEach(({ wall, index }) => {
|
||||
canvas.insertAt(wall, index)
|
||||
})
|
||||
removedWallsRef.current = []
|
||||
|
||||
canvas.discardActiveObject()
|
||||
canvas.renderAll()
|
||||
return
|
||||
}
|
||||
|
||||
canvas.remove(...pitchTexts)
|
||||
outerLines.forEach((line) => {
|
||||
let stroke, strokeWidth
|
||||
@ -113,12 +167,21 @@ export function useRoofShapeSetting(id) {
|
||||
line.set({
|
||||
stroke,
|
||||
strokeWidth,
|
||||
// [2026-06-16] 저장 후에도 외벽선 클릭 선택 허용. 드래그는 lockMovement(useCanvas), 보조선 가로채기는 z-order(18abbeb9)로 별도 차단
|
||||
selectable: true,
|
||||
visible: true,
|
||||
})
|
||||
|
||||
addPitchText(line)
|
||||
line.bringToFront()
|
||||
}
|
||||
})
|
||||
|
||||
// 외벽선은 WALL/ROOF 폴리곤 바로 위에 배치 — 맨 앞(bringToFront)으로 올리면
|
||||
// offset 0인 변에서 같은 좌표에 그려진 보조선의 클릭을 가로채 선택을 막는다
|
||||
const polygonTopIdx = canvas
|
||||
.getObjects()
|
||||
.reduce((top, obj, idx) => (obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ? idx : top), -1)
|
||||
outerLines.forEach((line) => canvas.moveTo(line, polygonTopIdx))
|
||||
canvas.renderAll()
|
||||
}
|
||||
}, [])
|
||||
@ -144,10 +207,15 @@ export function useRoofShapeSetting(id) {
|
||||
|
||||
useEffect(() => {
|
||||
if (shapeNum === 4) {
|
||||
canvas?.remove(canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL))
|
||||
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL)
|
||||
if (wall && removedWallsRef.current.length === 0) {
|
||||
removedWallsRef.current = [{ wall, index: canvas.getObjects().indexOf(wall) }]
|
||||
}
|
||||
canvas?.remove(wall)
|
||||
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
outerLines.forEach((line) => {
|
||||
showLine(line)
|
||||
line.set({ selectable: true })
|
||||
line.bringToFront()
|
||||
})
|
||||
|
||||
@ -182,6 +250,8 @@ export function useRoofShapeSetting(id) {
|
||||
]
|
||||
|
||||
const handleSave = () => {
|
||||
// undo 시 지붕형상 설정 팝업이 다시 열리도록 pendingPopup 을 atomOverrides 로 직접 전달
|
||||
saveSnapshot({ pendingPopup: { type: 'RoofShapeSetting', id, pos: { x: 50, y: 230 } } })
|
||||
let outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
let direction
|
||||
|
||||
@ -482,6 +552,8 @@ export function useRoofShapeSetting(id) {
|
||||
canvas?.renderAll()
|
||||
roof.drawHelpLine(settingModalFirstOptions)
|
||||
isFixRef.current = true
|
||||
// 지붕형상 설정 완료 → 지붕면 할당 메뉴로 전환
|
||||
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_ALLOC)
|
||||
addPopup(id, 1, <RoofAllocationSetting id={id} pos={{ x: 50, y: 230 }} />)
|
||||
}
|
||||
|
||||
@ -661,6 +733,7 @@ export function useRoofShapeSetting(id) {
|
||||
return
|
||||
}
|
||||
|
||||
saveSnapshot()
|
||||
let attributes
|
||||
switch (buttonAct) {
|
||||
case 1: {
|
||||
|
||||
@ -8,6 +8,7 @@ import { useSwal } from '@/hooks/useSwal'
|
||||
import { usePopup } from '@/hooks/usePopup'
|
||||
import Big from 'big.js'
|
||||
import { outerLineFixState } from '@/store/outerLineAtom'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { calcLinePlaneSize } from '@/util/qpolygon-utils'
|
||||
|
||||
// 외벽선 편집 및 오프셋
|
||||
@ -18,7 +19,7 @@ export function useWallLineOffsetSetting(id) {
|
||||
const { closePopup } = usePopup()
|
||||
const { swalFire } = useSwal()
|
||||
const { addCanvasMouseEventListener, initEvent } = useEvent()
|
||||
// const { addCanvasMouseEventListener, initEvent } = useContext(EventContext)
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
const wallLineEditRef = useRef(null)
|
||||
const length1Ref = useRef(null)
|
||||
const length2Ref = useRef(null)
|
||||
@ -204,9 +205,9 @@ export function useWallLineOffsetSetting(id) {
|
||||
|
||||
const handleSave = () => {
|
||||
if (currentWallLineRef.current === null) {
|
||||
// alert('보조선을 먼저 선택하세요')
|
||||
return
|
||||
}
|
||||
saveSnapshot()
|
||||
switch (type) {
|
||||
case TYPES.WALL_LINE_EDIT:
|
||||
handleWallLineEditSave()
|
||||
@ -355,7 +356,10 @@ export function useWallLineOffsetSetting(id) {
|
||||
const prevX = currentLine.x1 < currentLine.x2 ? Math.min(currentLine.x1, currentLine.x2) : Math.max(currentLine.x1, currentLine.x2)
|
||||
const nextX = currentLine.x1 < currentLine.x2 ? Math.max(currentLine.x1, currentLine.x2) : Math.min(currentLine.x1, currentLine.x2)
|
||||
if (arrow1Ref.current === 'up') {
|
||||
currentLine.set({ y1: currentLineMaxY.minus(offsetLength).toNumber(), y2: currentLineMaxY.minus(offsetLength).toNumber() })
|
||||
currentLine.set({
|
||||
y1: currentLineMaxY.minus(offsetLength).toNumber(),
|
||||
y2: currentLineMaxY.minus(offsetLength).toNumber(),
|
||||
})
|
||||
if (prevVector === currentVector) {
|
||||
const point1 = { x: prevX, y: prevLine.y2 }
|
||||
const point2 = { x: prevX, y: currentLine.y1 }
|
||||
@ -390,7 +394,10 @@ export function useWallLineOffsetSetting(id) {
|
||||
}
|
||||
}
|
||||
if (arrow1Ref.current === 'down') {
|
||||
currentLine.set({ y1: currentLineMaxY.plus(offsetLength).toNumber(), y2: currentLineMaxY.plus(offsetLength).toNumber() })
|
||||
currentLine.set({
|
||||
y1: currentLineMaxY.plus(offsetLength).toNumber(),
|
||||
y2: currentLineMaxY.plus(offsetLength).toNumber(),
|
||||
})
|
||||
if (prevVector === currentVector) {
|
||||
const point1 = { x: prevX, y: prevLine.y2 }
|
||||
const point2 = { x: prevX, y: currentLine.y1 }
|
||||
@ -428,7 +435,10 @@ export function useWallLineOffsetSetting(id) {
|
||||
const prevY = currentLine.y1 < currentLine.y2 ? Math.min(currentLine.y1, currentLine.y2) : Math.max(currentLine.y1, currentLine.y2)
|
||||
const nextY = currentLine.y1 < currentLine.y2 ? Math.max(currentLine.y1, currentLine.y2) : Math.min(currentLine.y1, currentLine.y2)
|
||||
if (arrow1Ref.current === 'left') {
|
||||
currentLine.set({ x1: currentLineMaxX.minus(offsetLength).toNumber(), x2: currentLineMaxX.minus(offsetLength).toNumber() })
|
||||
currentLine.set({
|
||||
x1: currentLineMaxX.minus(offsetLength).toNumber(),
|
||||
x2: currentLineMaxX.minus(offsetLength).toNumber(),
|
||||
})
|
||||
|
||||
if (prevVector === currentVector) {
|
||||
const point1 = { x: prevLine.x2, y: prevY }
|
||||
@ -464,7 +474,10 @@ export function useWallLineOffsetSetting(id) {
|
||||
}
|
||||
}
|
||||
if (arrow1Ref.current === 'right') {
|
||||
currentLine.set({ x1: currentLineMaxX.plus(offsetLength).toNumber(), x2: currentLineMaxX.plus(offsetLength).toNumber() })
|
||||
currentLine.set({
|
||||
x1: currentLineMaxX.plus(offsetLength).toNumber(),
|
||||
x2: currentLineMaxX.plus(offsetLength).toNumber(),
|
||||
})
|
||||
|
||||
if (prevVector === currentVector) {
|
||||
const point1 = { x: prevLine.x2, y: prevY }
|
||||
@ -503,6 +516,14 @@ export function useWallLineOffsetSetting(id) {
|
||||
}
|
||||
}
|
||||
|
||||
// 좌표가 변경된 라인의 planeSize/actualSize 재계산 — 길이 라벨은 attributes.planeSize 저장값을 표시하므로
|
||||
// 재계산 없이 fire('modified') 만 하면 이전 길이가 그대로 남는다.
|
||||
;[currentLine, prevLine, nextLine].forEach((line) => {
|
||||
if (!line?.attributes) return
|
||||
const newSize = calcLinePlaneSize({ x1: line.x1, y1: line.y1, x2: line.x2, y2: line.y2 })
|
||||
line.attributes = { ...line.attributes, planeSize: newSize, actualSize: newSize }
|
||||
})
|
||||
|
||||
const newOuterLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
|
||||
newOuterLines.sort((a, b) => a.idx - b.idx)
|
||||
newOuterLines.forEach((line, idx) => {
|
||||
|
||||
@ -29,6 +29,7 @@ import { useSurfaceShapeBatch } from './useSurfaceShapeBatch'
|
||||
import { roofDisplaySelector } from '@/store/settingAtom'
|
||||
import { useRoofFn } from '@/hooks/common/useRoofFn'
|
||||
import { useObject } from '@/hooks/useObject'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
// 배치면 그리기
|
||||
export function usePlacementShapeDrawing(id) {
|
||||
@ -50,6 +51,7 @@ export function usePlacementShapeDrawing(id) {
|
||||
const { setSurfaceShapePattern } = useRoofFn()
|
||||
const { changeSurfaceLineType } = useSurfaceShapeBatch({})
|
||||
const { handleSelectableObjects } = useObject()
|
||||
const { saveSnapshot, popLastSnapshot, setDrawing } = useUndoRedo()
|
||||
const { swalFire } = useSwal()
|
||||
const { getMessage } = useMessage()
|
||||
|
||||
@ -96,13 +98,22 @@ export function usePlacementShapeDrawing(id) {
|
||||
}, [verticalHorizontalMode, points, adsorptionRange, adsorptionPointMode])
|
||||
|
||||
useEffect(() => {
|
||||
// 배치면 그리기 시작 시 스냅샷 저장 (undo 하면 그리기 전 상태로 복원).
|
||||
// 그리기 진행 중에는 undo/redo/snapshot 을 차단하므로 force 로 저장한다.
|
||||
setDrawing(true)
|
||||
saveSnapshot(null, { force: true })
|
||||
setPoints([])
|
||||
setType(OUTER_LINE_TYPE.OUTER_LINE)
|
||||
|
||||
return () => {
|
||||
setDrawing(false)
|
||||
const placementShapeDrawingStartPoint = canvas.getObjects().find((obj) => obj.name === 'placementShapeDrawingStartPoint')
|
||||
|
||||
canvas.remove(placementShapeDrawingStartPoint)
|
||||
|
||||
// 확정하지 않고 닫은 경우 스냅샷 제거
|
||||
if (!isFix.current) {
|
||||
popLastSnapshot()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@ -148,9 +159,17 @@ export function usePlacementShapeDrawing(id) {
|
||||
const slope = Math.abs(vector.y / vector.x)
|
||||
|
||||
if (slope >= 1) {
|
||||
newPoint = { x: lastPoint.x, y: pointer.y }
|
||||
// 기울기가 1 이상이면 수직으로 제한
|
||||
newPoint = {
|
||||
x: lastPoint.x,
|
||||
y: pointer.y,
|
||||
}
|
||||
} else {
|
||||
newPoint = { x: pointer.x, y: lastPoint.y }
|
||||
// 기울기가 1 미만이면 수평으로 제한
|
||||
newPoint = {
|
||||
x: pointer.x,
|
||||
y: lastPoint.y,
|
||||
}
|
||||
}
|
||||
}
|
||||
setPoints((prev) => [...prev, newPoint])
|
||||
@ -311,9 +330,9 @@ export function usePlacementShapeDrawing(id) {
|
||||
// 인접하지 않은 별개 屋根까지 끌어와 사용자가 입력한 치수를 변질시켜 제거. tolerance 도 5mm 로 축소.
|
||||
const inheritPlaneSizeFromExistingShapes = (newPolygon) => {
|
||||
const tolerance = 0.5
|
||||
const existingPolygons = canvas.getObjects().filter(
|
||||
(obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.id !== newPolygon.id && obj.lines,
|
||||
)
|
||||
const existingPolygons = canvas
|
||||
.getObjects()
|
||||
.filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.id !== newPolygon.id && obj.lines)
|
||||
|
||||
if (existingPolygons.length === 0) return
|
||||
|
||||
@ -336,15 +355,9 @@ export function usePlacementShapeDrawing(id) {
|
||||
|
||||
// 양 끝점이 정확히 일치(흡착 공유변)할 때만 상속
|
||||
const forwardMatch =
|
||||
Math.abs(x1 - ex1) < tolerance &&
|
||||
Math.abs(y1 - ey1) < tolerance &&
|
||||
Math.abs(x2 - ex2) < tolerance &&
|
||||
Math.abs(y2 - ey2) < tolerance
|
||||
Math.abs(x1 - ex1) < tolerance && Math.abs(y1 - ey1) < tolerance && Math.abs(x2 - ex2) < tolerance && Math.abs(y2 - ey2) < tolerance
|
||||
const reverseMatch =
|
||||
Math.abs(x1 - ex2) < tolerance &&
|
||||
Math.abs(y1 - ey2) < tolerance &&
|
||||
Math.abs(x2 - ex1) < tolerance &&
|
||||
Math.abs(y2 - ey1) < tolerance
|
||||
Math.abs(x1 - ex2) < tolerance && Math.abs(y1 - ey2) < tolerance && Math.abs(x2 - ex1) < tolerance && Math.abs(y2 - ey1) < tolerance
|
||||
|
||||
if (forwardMatch || reverseMatch) {
|
||||
line.attributes = { ...line.attributes, planeSize: edge.attributes.planeSize }
|
||||
@ -368,9 +381,7 @@ export function usePlacementShapeDrawing(id) {
|
||||
// [2026-06-08] 끝점이 정확히 일치하는 흡착 공유변만 상속.
|
||||
const findMatchingEdgePlaneSize = (x1, y1, x2, y2) => {
|
||||
const tolerance = 0.5
|
||||
const existingPolygons = canvas.getObjects().filter(
|
||||
(obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.lines,
|
||||
)
|
||||
const existingPolygons = canvas.getObjects().filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.lines)
|
||||
|
||||
for (const polygon of existingPolygons) {
|
||||
for (const edge of polygon.lines) {
|
||||
|
||||
@ -6,6 +6,7 @@ import { usePopup } from '../usePopup'
|
||||
import useSWR from 'swr'
|
||||
import { useSwal } from '../useSwal'
|
||||
import { useMessage } from '../useMessage'
|
||||
import { useUndoRedo } from '../useUndoRedo'
|
||||
|
||||
const LINE_COLOR = {
|
||||
EAVES: '#45CD7D',
|
||||
@ -23,6 +24,7 @@ export function useRoofLinePropertySetting(props) {
|
||||
const { closePopup } = usePopup()
|
||||
const { swalFire } = useSwal()
|
||||
const { getMessage } = useMessage()
|
||||
const { saveSnapshot, popLastSnapshot } = useUndoRedo()
|
||||
|
||||
useEffect(() => {
|
||||
if (currentObject && currentObject.name === 'cloneRoofLine') {
|
||||
@ -47,10 +49,20 @@ export function useRoofLinePropertySetting(props) {
|
||||
}
|
||||
}, [currentObject])
|
||||
|
||||
const isFixed = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
// 팝업 열릴 때 스냅샷 저장
|
||||
saveSnapshot()
|
||||
|
||||
return () => {
|
||||
canvas.remove(...canvas.getObjects().filter((obj) => obj.name === 'cloneRoofLine'))
|
||||
canvas.renderAll()
|
||||
|
||||
// 설정 완료하지 않고 닫은 경우 스냅샷 제거
|
||||
if (!isFixed.current) {
|
||||
popLastSnapshot()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
@ -149,8 +161,6 @@ export function useRoofLinePropertySetting(props) {
|
||||
}
|
||||
|
||||
const handleFix = () => {
|
||||
// const roof = canvas.getObjects().find((obj) => currentObject.parentId === obj.id)
|
||||
// const notSettingLines = roof.lines.filter(
|
||||
const notSettingLines = canvas
|
||||
.getObjects()
|
||||
.filter((obj) => obj.name === 'cloneRoofLine')
|
||||
@ -187,6 +197,7 @@ export function useRoofLinePropertySetting(props) {
|
||||
// })
|
||||
// })
|
||||
|
||||
isFixed.current = true
|
||||
canvas.renderAll()
|
||||
closePopup(id)
|
||||
if (setIsHidden) setIsHidden(false)
|
||||
|
||||
@ -1,22 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
|
||||
import {
|
||||
canvasSettingState,
|
||||
canvasState,
|
||||
currentCanvasPlanState,
|
||||
currentObjectState,
|
||||
globalPitchState,
|
||||
} from '@/store/canvasAtom'
|
||||
import { canvasSettingState, canvasState, currentCanvasPlanState, currentObjectState, globalPitchState } from '@/store/canvasAtom'
|
||||
import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common'
|
||||
import { getIntersectionPoint, getDegreeByChon } from '@/util/canvas-util'
|
||||
import { getDegreeByChon, getIntersectionPoint } from '@/util/canvas-util'
|
||||
import { degreesToRadians } from '@turf/turf'
|
||||
import { QPolygon } from '@/components/fabric/QPolygon'
|
||||
import { useSwal } from '@/hooks/useSwal'
|
||||
import { useMessage } from '@/hooks/useMessage'
|
||||
import { useEvent } from '@/hooks/useEvent'
|
||||
import { usePopup } from '@/hooks/usePopup'
|
||||
import { roofDisplaySelector, basicSettingState } from '@/store/settingAtom'
|
||||
import { basicSettingState, corridorDimensionSelector, roofDisplaySelector } from '@/store/settingAtom'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
import { fontSelector } from '@/store/fontAtom'
|
||||
import { slopeSelector } from '@/store/commonAtom'
|
||||
@ -30,6 +24,7 @@ import { useText } from '@/hooks/useText'
|
||||
import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting'
|
||||
import { v4 as uuidv4 } from 'uuid'
|
||||
import { useState } from 'react'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { fabric } from 'fabric'
|
||||
|
||||
export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
@ -53,9 +48,12 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
const currentCanvasPlan = useRecoilValue(currentCanvasPlanState)
|
||||
const { fetchSettings } = useCanvasSetting(false)
|
||||
const [currentObject, setCurrentObject] = useRecoilState(currentObjectState)
|
||||
const [, setCorridorDimension] = useRecoilState(corridorDimensionSelector)
|
||||
const [popupId, setPopupId] = useState(uuidv4())
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
|
||||
const applySurfaceShape = (surfaceRefs, selectedType, id) => {
|
||||
saveSnapshot()
|
||||
let length1, length2, length3, length4, length5
|
||||
const surfaceId = selectedType?.id
|
||||
const azimuth = surfaceRefs.azimuth.current
|
||||
@ -126,22 +124,22 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
const planeSize = Math.round(Math.sqrt(dx * dx + dy * dy) * 10)
|
||||
|
||||
let actualSize = planeSize
|
||||
const lineDir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : (dy > 0 ? 'bottom' : 'top')
|
||||
const lineDir = Math.abs(dx) > Math.abs(dy) ? (dx > 0 ? 'right' : 'left') : dy > 0 ? 'bottom' : 'top'
|
||||
if (+roofSizeSet === 1 && globalPitch > 0) {
|
||||
const pitchDeg = getDegreeByChon(globalPitch)
|
||||
const isV = Math.abs(start.x - end.x) < 1
|
||||
const isH = Math.abs(start.y - end.y) < 1
|
||||
const isD = !isH && !isV
|
||||
if (dir === 'south' || dir === 'north') {
|
||||
if (isV) actualSize = Math.round(planeSize / Math.cos(pitchDeg * Math.PI / 180))
|
||||
if (isV) actualSize = Math.round(planeSize / Math.cos((pitchDeg * Math.PI) / 180))
|
||||
else if (isD) {
|
||||
const h = Math.abs(dy) * 10 * Math.tan(pitchDeg * Math.PI / 180)
|
||||
const h = Math.abs(dy) * 10 * Math.tan((pitchDeg * Math.PI) / 180)
|
||||
actualSize = Math.round(Math.sqrt(h * h + planeSize * planeSize))
|
||||
}
|
||||
} else if (dir === 'west' || dir === 'east') {
|
||||
if (isH) actualSize = Math.round(planeSize / Math.cos(pitchDeg * Math.PI / 180))
|
||||
if (isH) actualSize = Math.round(planeSize / Math.cos((pitchDeg * Math.PI) / 180))
|
||||
else if (isD) {
|
||||
const h = Math.abs(dx) * 10 * Math.tan(pitchDeg * Math.PI / 180)
|
||||
const h = Math.abs(dx) * 10 * Math.tan((pitchDeg * Math.PI) / 180)
|
||||
actualSize = Math.round(Math.sqrt(h * h + planeSize * planeSize))
|
||||
}
|
||||
}
|
||||
@ -850,6 +848,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
text: getMessage('batch.canvas.delete.all'),
|
||||
type: 'confirm',
|
||||
confirmFn: () => {
|
||||
saveSnapshot()
|
||||
canvas.clear()
|
||||
delete canvas.outerLineFix
|
||||
|
||||
@ -946,6 +945,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
}
|
||||
|
||||
const moveSurfaceShapeBatch = () => {
|
||||
saveSnapshot()
|
||||
const roof = canvas.getActiveObject()
|
||||
|
||||
if (roof) {
|
||||
@ -986,7 +986,10 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
roof.fire('modified')
|
||||
drawDirectionArrow(roof)
|
||||
changeCorridorDimensionText()
|
||||
addLengthText(roof)
|
||||
// [이동 후 길이 fix] 이동 경로는 addLengthText 만 호출해 actualSize(경사 보정 실제길이)가
|
||||
// 재계산되지 않고 평면길이로 표시되던 문제. 생성/resize 경로와 동일하게
|
||||
// setPolygonLinesActualSize(내부에서 actualSize 재계산 + addLengthText) 로 통일한다.
|
||||
setPolygonLinesActualSize(roof, true)
|
||||
roof.setCoords()
|
||||
initEvent()
|
||||
canvas.renderAll()
|
||||
@ -1026,6 +1029,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
|
||||
const resizeSurfaceShapeBatch = (side, target, width, height) => {
|
||||
if (!target || target.type !== 'QPolygon') return
|
||||
saveSnapshot()
|
||||
|
||||
width = width / 10
|
||||
height = height / 10
|
||||
@ -1144,6 +1148,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
from: 'surface',
|
||||
})
|
||||
|
||||
// 리사이즈 후 이전 사이즈 속성 초기화 (새 기하학적 길이로 재계산하기 위해)
|
||||
newPolygon.lines.forEach((line) => {
|
||||
delete line.attributes.planeSize
|
||||
delete line.attributes.actualSize
|
||||
delete line.attributes.isCalculated
|
||||
})
|
||||
|
||||
// 캔버스에 추가
|
||||
canvas.add(newPolygon)
|
||||
|
||||
@ -1155,6 +1166,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
drawDirectionArrow(newPolygon)
|
||||
newPolygon.setCoords()
|
||||
changeSurfaceLineType(newPolygon)
|
||||
|
||||
// 리사이즈 후 라인 길이 재계산 (setSurfaceShapePattern 내부 호출이 실패할 수 있으므로 명시적으로 재호출)
|
||||
newPolygon.lines.forEach((line) => {
|
||||
delete line.attributes.planeSize
|
||||
delete line.attributes.actualSize
|
||||
delete line.attributes.isCalculated
|
||||
})
|
||||
// [SIZE-RESIZE-PLANESIZE 2026-06-08] サイズ変更 시 좌표는 새 크기로 갱신되지만 initLines 가 옛 attributes
|
||||
// (planeSize=구 길이) 를 그대로 물려줘 라벨이 stale 하게 남던 문제. 변경된 새 좌표 길이로 planeSize 를
|
||||
// 강제 재계산 후 actualSize/라벨 재생성 (최초 생성 경로 line 302/320 과 동일하게 맞춤).
|
||||
@ -1165,6 +1183,17 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
canvas?.renderAll()
|
||||
closeAll()
|
||||
addPopup(popupId, 1, <SizeSetting id={popupId} side={side} target={newPolygon} />)
|
||||
|
||||
// React 리렌더 후에도 actualSize가 유지되도록 최종 업데이트
|
||||
setTimeout(() => {
|
||||
newPolygon.lines.forEach((line) => {
|
||||
delete line.attributes.planeSize
|
||||
delete line.attributes.actualSize
|
||||
delete line.attributes.isCalculated
|
||||
})
|
||||
setPolygonLinesActualSize(newPolygon, true)
|
||||
canvas?.renderAll()
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const changeSurfaceLinePropertyEvent = () => {
|
||||
@ -1550,6 +1579,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
|
||||
|
||||
const rotateSurfaceShapeBatch = () => {
|
||||
if (currentObject) {
|
||||
saveSnapshot()
|
||||
// 관련 객체들 찾기
|
||||
// arrow는 제거
|
||||
const arrow = canvas.getObjects().find((obj) => obj.parentId === currentObject.id && obj.name === 'arrow')
|
||||
|
||||
@ -179,6 +179,7 @@ export function useCanvas(id) {
|
||||
|
||||
// 해당 오브젝트 타입의 경우 저장한 값 그대로 불러와야함
|
||||
OBJECT_PROTOTYPE.forEach((type) => {
|
||||
const originalToObject = type.toObject
|
||||
type.toObject = function (propertiesToInclude) {
|
||||
let source = {}
|
||||
|
||||
@ -199,7 +200,9 @@ export function useCanvas(id) {
|
||||
}
|
||||
}
|
||||
|
||||
return fabric.util.object.extend(this.callSuper('toObject', propertiesToInclude), source)
|
||||
// 원본 toObject 를 호출하여 Group 의 objects 등 고유 직렬화 로직을 유지
|
||||
const base = originalToObject ? originalToObject.call(this, propertiesToInclude) : this.callSuper('toObject', propertiesToInclude)
|
||||
return fabric.util.object.extend(base, source)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@ -8,6 +8,7 @@ import { fontSelector } from '@/store/fontAtom'
|
||||
import { MENU, POLYGON_TYPE } from '@/common/common'
|
||||
import { useText } from '@/hooks/useText'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
|
||||
// 캔버스에 필요한 이벤트
|
||||
export function useCanvasEvent() {
|
||||
@ -22,6 +23,7 @@ export function useCanvasEvent() {
|
||||
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)
|
||||
|
||||
@ -53,6 +55,13 @@ export function useCanvasEvent() {
|
||||
}
|
||||
}, [currentMenu, isManualModuleSetup])
|
||||
|
||||
// 키보드 이벤트 등록 (Cmd+Z / Ctrl+Z: undo, Shift+Cmd+Z / Shift+Ctrl+Z: redo)
|
||||
useEffect(() => {
|
||||
if (!canvas) return
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [canvas, undo, redo])
|
||||
|
||||
// 기본적인 이벤트 필요시 추가
|
||||
const attachDefaultEventOnCanvas = () => {
|
||||
removeEventOnCanvas()
|
||||
@ -171,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 })
|
||||
}
|
||||
@ -307,7 +316,14 @@ export function useCanvasEvent() {
|
||||
// [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 })
|
||||
active.set({
|
||||
lockMovementX: true,
|
||||
lockMovementY: true,
|
||||
lockRotation: true,
|
||||
lockScalingX: true,
|
||||
lockScalingY: true,
|
||||
hasControls: false,
|
||||
})
|
||||
canvas.requestRenderAll()
|
||||
}
|
||||
return
|
||||
@ -390,14 +406,16 @@ export function useCanvasEvent() {
|
||||
case 'Escape':
|
||||
break
|
||||
|
||||
case 'z':
|
||||
if (!e.ctrlKey) {
|
||||
/*case 'z':
|
||||
if (!e.ctrlKey && !e.metaKey) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('뒤로가기')
|
||||
|
||||
break
|
||||
if (e.shiftKey) {
|
||||
redo()
|
||||
} else {
|
||||
undo()
|
||||
}
|
||||
break*/
|
||||
|
||||
default:
|
||||
return // 키 이벤트를 처리하지 않는다면 종료합니다.
|
||||
@ -476,20 +494,26 @@ export function useCanvasEvent() {
|
||||
/**
|
||||
* 선택한 도형을 삭제한다.
|
||||
*/
|
||||
let _isDeleting = false
|
||||
const handleDelete = () => {
|
||||
if (_isDeleting) return
|
||||
const targets = canvas?.getActiveObjects()
|
||||
if (targets?.length === 0) {
|
||||
alert('삭제할 대상을 선택해주세요.')
|
||||
if (!targets || targets.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
_isDeleting = true
|
||||
if (!confirm('정말로 삭제하시겠습니까?')) {
|
||||
_isDeleting = false
|
||||
return
|
||||
}
|
||||
|
||||
targets?.forEach((target) => {
|
||||
targets.forEach((target) => {
|
||||
canvas?.remove(target)
|
||||
})
|
||||
canvas?.discardActiveObject()
|
||||
canvas?.renderAll()
|
||||
_isDeleting = false
|
||||
}
|
||||
|
||||
const handleZoom = (isZoom) => {
|
||||
|
||||
@ -43,6 +43,9 @@ import { useTrestle } from './module/useTrestle'
|
||||
import { useCircuitTrestle } from './useCirCuitTrestle'
|
||||
import { usePolygon } from '@/hooks/usePolygon'
|
||||
import { useText } from '@/hooks/useText'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { POLYGON_TYPE } from '@/common/common'
|
||||
import { debugCapture } from '@/util/debugCapture'
|
||||
|
||||
export function useContextMenu() {
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
@ -77,6 +80,7 @@ export function useContextMenu() {
|
||||
const { isExistCircuit } = useCircuitTrestle()
|
||||
const { changeCorridorDimensionText } = useText()
|
||||
const { setPolygonLinesActualSize, drawDirectionArrow } = usePolygon()
|
||||
const { saveSnapshot } = useUndoRedo()
|
||||
const currentMenuSetting = () => {
|
||||
switch (selectedMenu) {
|
||||
case 'outline':
|
||||
@ -187,7 +191,10 @@ export function useContextMenu() {
|
||||
{
|
||||
id: 'roofMaterialRemoveAll',
|
||||
name: getMessage('contextmenu.roof.material.remove.all'),
|
||||
fn: () => removeAllRoofMaterial(),
|
||||
fn: () => {
|
||||
saveSnapshot()
|
||||
removeAllRoofMaterial()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'selectMove',
|
||||
@ -200,6 +207,7 @@ export function useContextMenu() {
|
||||
id: 'wallLineRemove',
|
||||
name: getMessage('contextmenu.wallline.remove'),
|
||||
fn: (currentMousePos) => {
|
||||
saveSnapshot()
|
||||
removeOuterLines(currentMousePos)
|
||||
},
|
||||
},
|
||||
@ -276,7 +284,10 @@ export function useContextMenu() {
|
||||
{
|
||||
id: 'roofMaterialRemoveAll',
|
||||
name: getMessage('contextmenu.roof.material.remove.all'),
|
||||
fn: () => removeAllRoofMaterial(),
|
||||
fn: () => {
|
||||
saveSnapshot()
|
||||
removeAllRoofMaterial()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'selectMove',
|
||||
@ -382,16 +393,49 @@ export function useContextMenu() {
|
||||
name: getMessage('contextmenu.auxiliary.remove'),
|
||||
fn: () => {
|
||||
if (!currentObject) return
|
||||
const roof = canvas.getObjects().filter((obj) => obj.id === currentObject.attributes.roofId)[0]
|
||||
|
||||
saveSnapshot()
|
||||
const co = currentObject
|
||||
// [처마선 삭제 면분할 반영 2026-06-15] 기존 '보조선 삭제'는 roof.innerLines/canvas 에서만 지우고
|
||||
// roof.lines(외곽 처마선)는 건드리지 않았다. 그래서 외곽 처마선(eaveHelpLine)을 이 메뉴로 지워도
|
||||
// 면분할 입력(splitPolygonWithLines 의 polygon.lines)엔 그대로 남아, 그 변이 닫는 면(예: 위 사다리꼴)이
|
||||
// 계속 생성됐다(GETSPLIT-IO 진단). 삭제 대상과 같은 좌표의 변을 roof.lines 에서도 제거해 삭제를 면분할까지 반영한다.
|
||||
const pEq = (ax, ay, bx, by) => Math.abs(ax - bx) <= 2 && Math.abs(ay - by) <= 2
|
||||
const sameSeg = (l) =>
|
||||
l.id === co.id ||
|
||||
(pEq(l.x1, l.y1, co.x1, co.y1) && pEq(l.x2, l.y2, co.x2, co.y2)) ||
|
||||
(pEq(l.x1, l.y1, co.x2, co.y2) && pEq(l.x2, l.y2, co.x1, co.y1))
|
||||
// roofId 로 roof 찾기 — eaveHelpLine 은 attributes.roofId 가 없을 수 있어 roofId 도 본다.
|
||||
const roofId = co.attributes?.roofId ?? co.roofId
|
||||
let roof = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.ROOF && obj.id === roofId)
|
||||
// id 로 못 찾으면 같은 좌표의 변을 가진 roof 를 탐색
|
||||
if (!roof) {
|
||||
// 아직 innerLines로 세팅이 안되어있는 line인 경우 제거
|
||||
canvas.remove(currentObject)
|
||||
canvas.discardActiveObject()
|
||||
return
|
||||
roof = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.ROOF && Array.isArray(obj.lines) && obj.lines.some(sameSeg))
|
||||
}
|
||||
const innerLines = roof.innerLines?.filter((line) => currentObject.id !== line.id)
|
||||
roof.innerLines = [...innerLines]
|
||||
canvas.remove(currentObject)
|
||||
const beforeLines = roof?.lines?.length ?? 0
|
||||
const beforeInner = roof?.innerLines?.length ?? 0
|
||||
if (roof) {
|
||||
if (Array.isArray(roof.innerLines)) roof.innerLines = roof.innerLines.filter((l) => !sameSeg(l))
|
||||
if (Array.isArray(roof.lines)) roof.lines = roof.lines.filter((l) => !sameSeg(l))
|
||||
}
|
||||
debugCapture.log('AUX-REMOVE-DIAG', {
|
||||
co: {
|
||||
name: co.name,
|
||||
lineName: co.lineName,
|
||||
id: co.id?.slice?.(0, 8),
|
||||
roofId: co.roofId?.slice?.(0, 8),
|
||||
attrRoofId: co.attributes?.roofId?.slice?.(0, 8),
|
||||
x1: Math.round(co.x1 * 10) / 10,
|
||||
y1: Math.round(co.y1 * 10) / 10,
|
||||
x2: Math.round(co.x2 * 10) / 10,
|
||||
y2: Math.round(co.y2 * 10) / 10,
|
||||
},
|
||||
roofFound: !!roof,
|
||||
roofId: roof?.id?.slice?.(0, 8),
|
||||
lines: `${beforeLines}→${roof?.lines?.length ?? 0}`,
|
||||
innerLines: `${beforeInner}→${roof?.innerLines?.length ?? 0}`,
|
||||
})
|
||||
canvas.remove(co)
|
||||
canvas.discardActiveObject()
|
||||
},
|
||||
},
|
||||
@ -400,6 +444,7 @@ export function useContextMenu() {
|
||||
name: getMessage('contextmenu.auxiliary.vertical.bisector'),
|
||||
fn: () => {
|
||||
if (!currentObject) return
|
||||
saveSnapshot()
|
||||
const slope = (currentObject.y2 - currentObject.y1) / (currentObject.x2 - currentObject.x1)
|
||||
const length = currentObject.length
|
||||
|
||||
@ -449,6 +494,7 @@ export function useContextMenu() {
|
||||
swalFire({ text: getMessage('roof.is.not.selected') })
|
||||
return
|
||||
}
|
||||
saveSnapshot()
|
||||
const innerLines = canvas.getObjects().filter((obj) => obj.id === currentObject.attributes.roofId)[0]?.innerLines
|
||||
if (innerLines) {
|
||||
innerLines.forEach((line) => {
|
||||
@ -457,15 +503,25 @@ export function useContextMenu() {
|
||||
innerLines.length = 0
|
||||
}
|
||||
|
||||
// 확정되지 않은 보조선
|
||||
const notFixedAuxiliaryLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine' && !obj.isAuxiliaryFixed)
|
||||
|
||||
notFixedAuxiliaryLines.forEach((line) => {
|
||||
// [전체삭제 누락 fix] 기존에는 미확정 보조선(!isAuxiliaryFixed)만 삭제해,
|
||||
// 복사본/미확정 보조선을 우클릭하면 currentObject.roofId 가 위 innerLines 와 어긋나
|
||||
// 확정된 보조선이 누락되었다. 확정 여부와 무관하게 모든 보조선을 삭제한다.
|
||||
const auxiliaryLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine')
|
||||
auxiliaryLines.forEach((line) => {
|
||||
canvas.remove(line)
|
||||
})
|
||||
|
||||
//동이동현이동
|
||||
const eaveHelpLines = canvas.getObjects().filter((obj) => obj.name === 'eaveHelpLine' && obj.parentId === currentObject.attributes.roofId)
|
||||
// 지붕 innerLines 에 남은 보조선/eaveHelpLine 참조 정리 (stale 부활 방지, 용마루 hip·ridge 는 보존)
|
||||
canvas.getObjects().forEach((obj) => {
|
||||
if (Array.isArray(obj.innerLines) && obj.innerLines.length > 0) {
|
||||
obj.innerLines = obj.innerLines.filter(
|
||||
(line) => line.name !== 'auxiliaryLine' && line.name !== 'eaveHelpLine' && line.lineName !== 'eaveHelpLine',
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
//동이동현이동 - 모든 eaveHelpLine 삭제 (roofId 무관, name/lineName 두 식별자 모두 검사)
|
||||
const eaveHelpLines = canvas.getObjects().filter((obj) => obj.name === 'eaveHelpLine' || obj.lineName === 'eaveHelpLine')
|
||||
eaveHelpLines.forEach((line) => {
|
||||
canvas.remove(line)
|
||||
})
|
||||
@ -714,7 +770,9 @@ export function useContextMenu() {
|
||||
{
|
||||
id: 'remove',
|
||||
name: getMessage('contextmenu.remove'),
|
||||
fn: () => modulesRemove(),
|
||||
fn: () => {
|
||||
modulesRemove()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'move',
|
||||
@ -887,6 +945,7 @@ export function useContextMenu() {
|
||||
return
|
||||
}
|
||||
|
||||
saveSnapshot()
|
||||
resetModule()
|
||||
alignModule(MODULE_ALIGN_TYPE.VERTICAL, currentObject.arrayData ?? [currentObject])
|
||||
},
|
||||
@ -904,6 +963,7 @@ export function useContextMenu() {
|
||||
return
|
||||
}
|
||||
|
||||
saveSnapshot()
|
||||
resetModule()
|
||||
alignModule(MODULE_ALIGN_TYPE.HORIZONTAL, currentObject.arrayData ?? [currentObject])
|
||||
},
|
||||
@ -912,6 +972,7 @@ export function useContextMenu() {
|
||||
id: 'moduleRemove',
|
||||
name: getMessage('contextmenu.module.remove'),
|
||||
fn: () => {
|
||||
saveSnapshot()
|
||||
moduleRoofRemove(currentObject.arrayData ?? [currentObject])
|
||||
|
||||
// const moduleSetupSurface = canvas.getObjects().filter((obj) => canvas.getActiveObjects()[0].id === obj.id)[0]
|
||||
|
||||
@ -148,7 +148,7 @@ export const useLine = () => {
|
||||
|
||||
// 기존 pitchText가 있으면 삭제
|
||||
if (pitchText.length > 0) {
|
||||
canvas.remove(pitchText)
|
||||
canvas.remove(...pitchText)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,18 +1,18 @@
|
||||
'use client'
|
||||
|
||||
import { useContext, useEffect, useState } from 'react'
|
||||
import { useContext, useEffect, useRef, useState } from 'react'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
|
||||
import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil'
|
||||
|
||||
import {
|
||||
canvasSettingState,
|
||||
canvasState,
|
||||
currentCanvasPlanState,
|
||||
plansState,
|
||||
canvasSettingState,
|
||||
currentMenuState,
|
||||
currentObjectState,
|
||||
moduleSetupSurfaceState,
|
||||
currentMenuState,
|
||||
plansState,
|
||||
} from '@/store/canvasAtom'
|
||||
import { useAxios } from '@/hooks/useAxios'
|
||||
import { useMessage } from '@/hooks/useMessage'
|
||||
@ -31,6 +31,7 @@ import { useCanvasMenu } from './common/useCanvasMenu'
|
||||
import { QcastContext } from '@/app/QcastProvider'
|
||||
import { unescapeString } from '@/util/common-utils'
|
||||
import { useTrestle } from '@/hooks/module/useTrestle'
|
||||
import { useUndoRedo } from '@/hooks/useUndoRedo'
|
||||
import { logger } from '@/util/logger'
|
||||
|
||||
// putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유.
|
||||
@ -85,6 +86,8 @@ export function usePlan(params = {}) {
|
||||
//선택된 모듈 배치면 초기화
|
||||
const resetModuleSetupSurface = useResetRecoilState(moduleSetupSurfaceState)
|
||||
const { isAllComplete } = useTrestle()
|
||||
// undo/redo 스택 초기화 (플랜 전환 시 사용)
|
||||
const { clearStacks } = useUndoRedo()
|
||||
|
||||
/**
|
||||
* 마우스 포인터의 가이드라인 제거
|
||||
@ -197,9 +200,7 @@ export function usePlan(params = {}) {
|
||||
fill: typeof o.fill === 'string' ? o.fill : o.fill?.type,
|
||||
opacity: o.opacity,
|
||||
direction: o.direction,
|
||||
moduleInfo: o.moduleInfo
|
||||
? { itemId: o.moduleInfo.itemId, moduleTpCd: o.moduleInfo.moduleTpCd, capacity: o.moduleInfo.capacity }
|
||||
: null,
|
||||
moduleInfo: o.moduleInfo ? { itemId: o.moduleInfo.itemId, moduleTpCd: o.moduleInfo.moduleTpCd, capacity: o.moduleInfo.capacity } : null,
|
||||
pointsSample: Array.isArray(o.points) ? o.points.slice(0, 4) : null,
|
||||
}))
|
||||
logger.error('[PLAN-CORRUPT-COORD-SAVE-GUARD 2026-05-18] 保存失敗 — corrupted objects', {
|
||||
@ -228,9 +229,14 @@ export function usePlan(params = {}) {
|
||||
return hasPatternFill && !r.roofMaterial
|
||||
})
|
||||
if (inconsistent.length > 0) {
|
||||
console.warn('[ROOF-MATERIAL-SAVE-GUARD] pattern drawn but roofMaterial missing:',
|
||||
inconsistent.map((r) => ({ id: r.id, name: r.name, fillType: r.fill?.type })))
|
||||
swalFire({ text: '面に屋根材パターンが描画されていますが、屋根材データが保存されていません。\n屋根材を再設定してから保存してください。', icon: 'warning' })
|
||||
console.warn(
|
||||
'[ROOF-MATERIAL-SAVE-GUARD] pattern drawn but roofMaterial missing:',
|
||||
inconsistent.map((r) => ({ id: r.id, name: r.name, fillType: r.fill?.type })),
|
||||
)
|
||||
swalFire({
|
||||
text: '面に屋根材パターンが描画されていますが、屋根材データが保存されていません。\n屋根材を再設定してから保存してください。',
|
||||
icon: 'warning',
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@ -410,9 +416,22 @@ export function usePlan(params = {}) {
|
||||
const controller = new AbortController()
|
||||
canvasStatusInflight.controller = controller
|
||||
|
||||
await promisePut({ url: '/api/canvas-management/canvas-statuses', data: planData, option: { signal: controller.signal } })
|
||||
await promisePut({
|
||||
url: '/api/canvas-management/canvas-statuses',
|
||||
data: planData,
|
||||
option: { signal: controller.signal },
|
||||
})
|
||||
.then((res) => {
|
||||
setPlans((plans) => plans.map((plan) => (plan.id === currentCanvasPlan.id ? { ...plan, canvasStatus: canvasStatus } : plan)))
|
||||
setPlans((plans) =>
|
||||
plans.map((plan) =>
|
||||
plan.id === currentCanvasPlan.id
|
||||
? {
|
||||
...plan,
|
||||
canvasStatus: canvasStatus,
|
||||
}
|
||||
: plan,
|
||||
),
|
||||
)
|
||||
if (saveAlert) swalFire({ text: getMessage('plan.message.save') })
|
||||
rtn = true
|
||||
})
|
||||
@ -529,11 +548,19 @@ export function usePlan(params = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// usePlan() 인스턴스가 새로 mount 될 때 clearStacks 가 실수로 호출되지 않도록
|
||||
// 직전 플랜 id 를 ref 로 보관하고, 실제 id 변경이 일어났을 때만 스택을 비운다.
|
||||
const prevPlanIdRef = useRef(currentCanvasPlan?.id)
|
||||
useEffect(() => {
|
||||
setSelectedPlan(currentCanvasPlan)
|
||||
handleCurrentPlanUrl()
|
||||
// resetCurrentObject()
|
||||
resetModuleSetupSurface()
|
||||
// 플랜 ID가 실제로 바뀐 경우에만 undo/redo 히스토리를 초기화한다.
|
||||
if (prevPlanIdRef.current !== currentCanvasPlan?.id) {
|
||||
clearStacks()
|
||||
prevPlanIdRef.current = currentCanvasPlan?.id
|
||||
}
|
||||
}, [currentCanvasPlan])
|
||||
|
||||
/**
|
||||
|
||||
@ -1,10 +1,16 @@
|
||||
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, globalPitchState, pitchTextSelector } from '@/store/canvasAtom'
|
||||
import {
|
||||
ANGLE_TYPE,
|
||||
canvasState,
|
||||
currentAngleTypeSelector,
|
||||
globalPitchState,
|
||||
pitchTextSelector,
|
||||
} from '@/store/canvasAtom'
|
||||
import { useRecoilValue } from 'recoil'
|
||||
import { fabric } from 'fabric'
|
||||
import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, isPointOnLine } from '@/util/canvas-util'
|
||||
import { QPolygon } from '@/components/fabric/QPolygon'
|
||||
import { calcLinePlaneSize, equalizeSymmetricHips, isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils'
|
||||
import { basicSettingState, flowDisplaySelector } from '@/store/settingAtom'
|
||||
import { basicSettingState, corridorDimensionSelector, flowDisplaySelector } from '@/store/settingAtom'
|
||||
import { fontSelector } from '@/store/fontAtom'
|
||||
import { QLine } from '@/components/fabric/QLine'
|
||||
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||
@ -21,6 +27,7 @@ export const usePolygon = () => {
|
||||
const pitchText = useRecoilValue(pitchTextSelector)
|
||||
const globalPitch = useRecoilValue(globalPitchState)
|
||||
const roofSizeSet = useRecoilValue(basicSettingState).roofSizeSet
|
||||
const corridorDimension = useRecoilValue(corridorDimensionSelector)
|
||||
|
||||
const { setActualSize } = useLine()
|
||||
|
||||
@ -91,32 +98,32 @@ export const usePolygon = () => {
|
||||
const maxY = line.top + line.length
|
||||
const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI
|
||||
|
||||
// [2026-05-18 라벨 저장값 통일] planeSize(정수 또는 0.5 step) 그대로.
|
||||
const __display = planeSize ?? length
|
||||
const text = new fabric.Textbox(
|
||||
Number(__display).toFixed(1).replace(/\.0$/, ''),
|
||||
{
|
||||
left: left,
|
||||
top: top,
|
||||
fontSize: lengthTextFontOptions.fontSize.value,
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
parentDirection: line.direction,
|
||||
parentDegree: degree,
|
||||
parentId: polygon.id,
|
||||
planeSize: planeSize ?? length,
|
||||
actualSize: actualSize ?? length,
|
||||
editable: false,
|
||||
selectable: true,
|
||||
lockRotation: true,
|
||||
lockScalingX: true,
|
||||
lockScalingY: true,
|
||||
parent: polygon,
|
||||
name: 'lengthText',
|
||||
},
|
||||
)
|
||||
// [라벨 실제길이 모드 fix] 치수표시가 '복도치수 표시'(corridorDimension) 면 roofSizeSet 무관하게
|
||||
// planeSize(평면길이)를 표시한다. 그 외에는 roofSizeSet===1(실제길이) 이면 actualSize, 아니면 planeSize.
|
||||
// 기존엔 planeSize 만 표시해, 실제치수 모드에서도 이동 후 평면길이(예: 8000)가 나오던 문제.
|
||||
const __isCorridorDimension = corridorDimension?.column === 'corridorDimension'
|
||||
const __display = (__isCorridorDimension ? planeSize : +roofSizeSet === 1 ? actualSize : planeSize) ?? planeSize ?? length
|
||||
const text = new fabric.Textbox(Number(__display).toFixed(1).replace(/\.0$/, ''), {
|
||||
left: left,
|
||||
top: top,
|
||||
fontSize: lengthTextFontOptions.fontSize.value,
|
||||
minX,
|
||||
maxX,
|
||||
minY,
|
||||
maxY,
|
||||
parentDirection: line.direction,
|
||||
parentDegree: degree,
|
||||
parentId: polygon.id,
|
||||
planeSize: planeSize ?? length,
|
||||
actualSize: actualSize ?? length,
|
||||
editable: false,
|
||||
selectable: true,
|
||||
lockRotation: true,
|
||||
lockScalingX: true,
|
||||
lockScalingY: true,
|
||||
parent: polygon,
|
||||
name: 'lengthText',
|
||||
})
|
||||
polygon.texts.push(text)
|
||||
canvas.add(text)
|
||||
})
|
||||
@ -353,24 +360,27 @@ export const usePolygon = () => {
|
||||
const drawDirectionStringToArrow2 = (polygon, showDirectionText) => {
|
||||
let { direction, surfaceCompass, moduleCompass, arrow } = polygon
|
||||
if (moduleCompass === null || moduleCompass === undefined) {
|
||||
const textObj = new fabric.Text(`${currentAngleType === ANGLE_TYPE.SLOPE ? arrow.pitch : (arrow.roofAngle ?? getDegreeByChon(arrow.pitch))}${pitchText}`, {
|
||||
fontFamily: flowFontOptions.fontFamily.value,
|
||||
fontWeight: flowFontOptions.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
|
||||
fontStyle: flowFontOptions.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
|
||||
fontSize: flowFontOptions.fontSize.value,
|
||||
fill: flowFontOptions.fontColor.value,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
pitch: arrow.pitch,
|
||||
roofAngle: arrow.roofAngle,
|
||||
name: 'flowText',
|
||||
selectable: false,
|
||||
left: arrow.stickeyPoint.x,
|
||||
top: arrow.stickeyPoint.y,
|
||||
parent: arrow,
|
||||
parentId: arrow.id,
|
||||
visible: isFlowDisplay,
|
||||
})
|
||||
const textObj = new fabric.Text(
|
||||
`${currentAngleType === ANGLE_TYPE.SLOPE ? arrow.pitch : (arrow.roofAngle ?? getDegreeByChon(arrow.pitch))}${pitchText}`,
|
||||
{
|
||||
fontFamily: flowFontOptions.fontFamily.value,
|
||||
fontWeight: flowFontOptions.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
|
||||
fontStyle: flowFontOptions.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
|
||||
fontSize: flowFontOptions.fontSize.value,
|
||||
fill: flowFontOptions.fontColor.value,
|
||||
originX: 'center',
|
||||
originY: 'center',
|
||||
pitch: arrow.pitch,
|
||||
roofAngle: arrow.roofAngle,
|
||||
name: 'flowText',
|
||||
selectable: false,
|
||||
left: arrow.stickeyPoint.x,
|
||||
top: arrow.stickeyPoint.y,
|
||||
parent: arrow,
|
||||
parentId: arrow.id,
|
||||
visible: isFlowDisplay,
|
||||
},
|
||||
)
|
||||
|
||||
polygon.canvas.add(textObj)
|
||||
return
|
||||
@ -749,7 +759,9 @@ export const usePolygon = () => {
|
||||
// [SPLIT-ALLOC-DIAG 2026-06-01] 분할 graph 진입 전 innerLines 전수 dump.
|
||||
// 케라바 패턴 라인(kerabPatternRidge/ExtRidge/Hip/ExtHip)과 원래 ridge(RG-2 등) 가
|
||||
// type='default' / isStart 미설정으로 graph 시드/유효점 후보에서 제외되는지 추적용.
|
||||
logger.log(`[SPLIT-ALLOC-DIAG] polygon.id=${polygon.id?.slice(0, 8)} innerLines.visible=${innerLines.length} polygonLines=${polygon.lines?.length ?? 0}`)
|
||||
logger.log(
|
||||
`[SPLIT-ALLOC-DIAG] polygon.id=${polygon.id?.slice(0, 8)} innerLines.visible=${innerLines.length} polygonLines=${polygon.lines?.length ?? 0}`,
|
||||
)
|
||||
innerLines.forEach((l, i) => {
|
||||
logger.log(
|
||||
` [SPLIT-ALLOC-DIAG ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` +
|
||||
@ -1262,6 +1274,13 @@ export const usePolygon = () => {
|
||||
polygonLine.attributes = { ...polygonLine.attributes, isStart: true }
|
||||
})
|
||||
|
||||
// [지붕면 공유변 fix] 보조선(innerLines)도 isStart=true 로 표시해 외곽선과 동일한 distance 페널티(+1000)를
|
||||
// 받게 한다. 미설정 시 보조선(빗변)이 페널티 없이 항상 최단으로 선택돼, 면이 외곽 변(예: 직사각형
|
||||
// 왼쪽변)을 두고 빗변으로 우회 → 오각형이 생긴다. (isValidPoint 가 type=default 끝점을 막아 면 자체는 안 늘어남)
|
||||
innerLines.forEach((line) => {
|
||||
line.attributes = { ...line.attributes, isStart: true }
|
||||
})
|
||||
|
||||
let allLines = [...polygonLines, ...innerLines]
|
||||
|
||||
// allLines를 전부 돌면서 교차점이 있는 경우 그 line을 잘라서 allLines에 추가
|
||||
@ -1492,6 +1511,159 @@ export const usePolygon = () => {
|
||||
return Math.abs(line.startPoint.x - line.endPoint.x) > 2 || Math.abs(line.startPoint.y - line.endPoint.y) > 2
|
||||
})
|
||||
|
||||
// [SUPERSEDED-EAVES 2026-06-15] 외곽선(처마/케라바)을 바깥으로 옮겨 처마(연·軒)를 만들면, 옮긴 선과 양끝
|
||||
// 연결선(모두 auxiliaryLine)·원래 외곽선(eaves/gable)이 닫힌 "띠 사각형"을 이룬다. 이때 원래 외곽선이
|
||||
// 면분할 입력에 남아 있으면 바깥 면을 [띠 + 사다리꼴]로 갈라, 사다리꼴을 닫는 시작선이 사라져 면이 통째로
|
||||
// 누락된다(GETSPLIT-IO 진단). 원래 외곽선은 이미 면 내부의 가상선이므로 면분할 입력에서 제외 → 띠+사다리꼴이
|
||||
// 하나의 면(예: 6각형)으로 병합된다. 띠 패턴(양끝 연결선 + 평행 닫힘선)이 없으면 아무 영향 없음.
|
||||
// [2026-06-16] eaves 외에 gable(아래변 이동 L자형)·shed(한쪽흐름 왼쪽변 이동→8각형) 외곽선도 동일 처리.
|
||||
// 연결선이 대각(챔퍼)이어도 끝점만 공유하면 매칭됨.
|
||||
const auxBandLines = allLines.filter((l) => l.name === 'auxiliaryLine')
|
||||
if (auxBandLines.length > 0) {
|
||||
const sharesPt = (l, p) => isSamePoint(l.startPoint, p) || isSamePoint(l.endPoint, p)
|
||||
const otherEnd = (l, p) => (isSamePoint(l.startPoint, p) ? l.endPoint : l.startPoint)
|
||||
const supersededEaves = allLines.filter((E) => {
|
||||
// 외곽 경계선(처마 eaves / 케라바 gable / 한쪽흐름 shed)만 후보 — 보조선·내부선(용마루/추녀)은 제외.
|
||||
const type = E.attributes?.type
|
||||
if (type !== 'eaves' && type !== 'gable' && type !== 'shed') return false
|
||||
const A = E.startPoint
|
||||
const B = E.endPoint
|
||||
// A·B 각각에서 바깥으로 뻗는 연결 보조선 후보(한쪽 끝점만 공유). 한 끝점에 여러 개일 수 있다
|
||||
// (스텝 이동: 중점에 좌·우 띠로 가는 대각이 둘 다 붙음). 첫 매치만 쓰면 잘못된 짝을 골라 닫힘선을
|
||||
// 못 찾으므로, 모든 CA×CB 조합을 시도해 "두 바깥 끝점을 잇는 평행 닫힘 보조선"이 있는 조합이
|
||||
// 하나라도 있으면 띠로 인정한다.
|
||||
const candA = auxBandLines.filter((c) => c !== E && sharesPt(c, A) && !sharesPt(c, B))
|
||||
const candB = auxBandLines.filter((c) => c !== E && sharesPt(c, B) && !sharesPt(c, A))
|
||||
return candA.some((CA) =>
|
||||
candB.some((CB) => {
|
||||
if (CA === CB) return false
|
||||
const Ao = otherEnd(CA, A)
|
||||
const Bo = otherEnd(CB, B)
|
||||
return auxBandLines.some((c) => c !== CA && c !== CB && sharesPt(c, Ao) && sharesPt(c, Bo))
|
||||
}),
|
||||
)
|
||||
})
|
||||
if (supersededEaves.length > 0) {
|
||||
logger.log(`[SUPERSEDED-EAVES] 띠로 대체된 원래 외곽선(처마/케라바) ${supersededEaves.length}개 면분할 입력에서 제외`)
|
||||
allLines = allLines.filter((l) => !supersededEaves.includes(l))
|
||||
}
|
||||
}
|
||||
|
||||
// [MOVED-DIVIDER 2026-06-17] 내부 분할선(예: A타입 중앙 3868)을 옮기면 경계선만 새 위치로 재분할되고
|
||||
// 스켈레톤 hip 은 옛 위치에서 만나, 옮긴 보조선 끝점이 hip 에 노드로 안 붙어 분할이 안 되고 면이
|
||||
// 겹쳐 생긴다(ROOF-FACE-DIAG: 거의 동일한 면 2개). 해결:
|
||||
// (1) 내부 보조선 끝점이 hip 몸통 위(strict)에 있으면 그 hip 을 끝점에서 split → 보조선이 연결됨.
|
||||
// (2) split 이 일어난 경우에만, 옛 접합점이 degree-2 공선(共線) 잉여 노드가 되면 merge → 옛 분할점 제거.
|
||||
// 게이트: 실제 split 이 일어난 경우(=내부분할선 이동)에만 (2) 수행 → 외곽선 띠(SUPERSEDED) 케이스는 불변.
|
||||
{
|
||||
const innerAux = allLines.filter((l) => l.name === 'auxiliaryLine')
|
||||
const isInner = (l) => l.name === 'hip' || l.name === 'ridge'
|
||||
const EPS = 2
|
||||
const mkSeg = (proto, sp, ep) => ({
|
||||
name: proto.name,
|
||||
lineName: proto.lineName,
|
||||
attributes: { ...proto.attributes },
|
||||
startPoint: { x: sp.x, y: sp.y },
|
||||
endPoint: { x: ep.x, y: ep.y },
|
||||
x1: sp.x,
|
||||
y1: sp.y,
|
||||
x2: ep.x,
|
||||
y2: ep.y,
|
||||
})
|
||||
const onBodyStrict = (L, P) => {
|
||||
const ax = L.startPoint.x,
|
||||
ay = L.startPoint.y
|
||||
const dx = L.endPoint.x - ax,
|
||||
dy = L.endPoint.y - ay
|
||||
const lenSq = dx * dx + dy * dy
|
||||
if (lenSq < 1) return false
|
||||
const t = ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
|
||||
if (t <= 0.03 || t >= 0.97) return false
|
||||
const px = ax + t * dx,
|
||||
py = ay + t * dy
|
||||
return (P.x - px) ** 2 + (P.y - py) ** 2 < EPS * EPS
|
||||
}
|
||||
if (innerAux.length > 0) {
|
||||
const auxPts = []
|
||||
innerAux.forEach((a) => {
|
||||
auxPts.push(a.startPoint, a.endPoint)
|
||||
})
|
||||
// (1) 내부선을 보조선 끝점에서 split
|
||||
let didSplit = false
|
||||
const afterSplit = []
|
||||
allLines.forEach((L) => {
|
||||
if (!isInner(L)) {
|
||||
afterSplit.push(L)
|
||||
return
|
||||
}
|
||||
const ax = L.startPoint.x,
|
||||
ay = L.startPoint.y
|
||||
const dx = L.endPoint.x - ax,
|
||||
dy = L.endPoint.y - ay
|
||||
const lenSq = dx * dx + dy * dy || 1
|
||||
const tOf = (P) => ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
|
||||
const cuts = [...new Map(auxPts.filter((P) => onBodyStrict(L, P)).map((P) => [Math.round(tOf(P) * 1000), P])).values()].sort(
|
||||
(p, q) => tOf(p) - tOf(q),
|
||||
)
|
||||
if (cuts.length === 0) {
|
||||
afterSplit.push(L)
|
||||
return
|
||||
}
|
||||
didSplit = true
|
||||
let cur = L.startPoint
|
||||
cuts.forEach((P) => {
|
||||
afterSplit.push(mkSeg(L, cur, P))
|
||||
cur = P
|
||||
})
|
||||
afterSplit.push(mkSeg(L, cur, L.endPoint))
|
||||
})
|
||||
if (didSplit) {
|
||||
let work = afterSplit
|
||||
const key = (p) => `${Math.round(p.x)},${Math.round(p.y)}`
|
||||
// (2) degree-2 공선 내부선 접합점 merge (잉여 옛 분할점 제거)
|
||||
let merged = true
|
||||
let guard = 0
|
||||
while (merged && guard++ < 200) {
|
||||
merged = false
|
||||
const incident = {}
|
||||
work.forEach((L) => {
|
||||
;[L.startPoint, L.endPoint].forEach((p) => {
|
||||
;(incident[key(p)] || (incident[key(p)] = [])).push(L)
|
||||
})
|
||||
})
|
||||
for (const k in incident) {
|
||||
const inc = incident[k]
|
||||
if (inc.length !== 2) continue
|
||||
const [la, lb] = inc
|
||||
if (la === lb || !isInner(la) || !isInner(lb)) continue
|
||||
const [kx, ky] = k.split(',').map(Number)
|
||||
const farA = Math.abs(la.startPoint.x - kx) < 1 && Math.abs(la.startPoint.y - ky) < 1 ? la.endPoint : la.startPoint
|
||||
const farB = Math.abs(lb.startPoint.x - kx) < 1 && Math.abs(lb.startPoint.y - ky) < 1 ? lb.endPoint : lb.startPoint
|
||||
const cross = (kx - farA.x) * (farB.y - ky) - (ky - farA.y) * (farB.x - kx)
|
||||
const norm = Math.hypot(kx - farA.x, ky - farA.y) * Math.hypot(farB.x - kx, farB.y - ky)
|
||||
if (norm < 1 || Math.abs(cross) / norm > 0.02) continue
|
||||
work = work.filter((L) => L !== la && L !== lb)
|
||||
work.push(mkSeg(la, farA, farB))
|
||||
merged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
// 동일 좌표 변 중복 제거
|
||||
work = work.filter(
|
||||
(line, idx, self) =>
|
||||
idx ===
|
||||
self.findIndex(
|
||||
(o) =>
|
||||
(isSamePoint(o.startPoint, line.startPoint) && isSamePoint(o.endPoint, line.endPoint)) ||
|
||||
(isSamePoint(o.startPoint, line.endPoint) && isSamePoint(o.endPoint, line.startPoint)),
|
||||
),
|
||||
)
|
||||
logger.log(`[MOVED-DIVIDER] 내부 분할선 이동 감지 — hip 재분할 적용 (allLines ${allLines.length}→${work.length})`)
|
||||
allLines = work
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 나눠서 중복 제거된 roof return
|
||||
let newRoofs = getSplitRoofsPoints(allLines)
|
||||
|
||||
@ -1744,9 +1916,12 @@ export const usePolygon = () => {
|
||||
|
||||
// 2차 매칭 (옵션 3 + α): 대각선이 외벽 중간을 분할한 sub-segment 처리
|
||||
const _isCollinearWithin = (parent, p) => {
|
||||
const ax = parent.startPoint.x, ay = parent.startPoint.y
|
||||
const bx = parent.endPoint.x, by = parent.endPoint.y
|
||||
const dx = bx - ax, dy = by - ay
|
||||
const ax = parent.startPoint.x,
|
||||
ay = parent.startPoint.y
|
||||
const bx = parent.endPoint.x,
|
||||
by = parent.endPoint.y
|
||||
const dx = bx - ax,
|
||||
dy = by - ay
|
||||
const segLenSq = dx * dx + dy * dy
|
||||
if (segLenSq < 1) return false
|
||||
const cross = (p.x - ax) * dy - (p.y - ay) * dx
|
||||
@ -2043,7 +2218,11 @@ export const usePolygon = () => {
|
||||
// 현재 남아있는 line들로 그래프 생성
|
||||
const graph = {}
|
||||
|
||||
for (const line of remainingLines.filter((line2) => line2 !== startLine)) {
|
||||
// [지붕면 공유변 fix] graph 는 startLine 자신만 제외한 전체 allLines 로 구성한다.
|
||||
// remainingLines(이미 사용된 startLine 이 제거됨)로 만들면, 두 면이 공유하는 외곽 변이 먼저 처리된
|
||||
// 면에서 소비되어 다음 면 graph 에서 사라지고 그 면이 빗변으로 우회한다. startLine 소비는 후보
|
||||
// 목록(remainingLines)에서만 하고, 경로 탐색용 graph 에는 항상 남겨 공유 변을 재사용하게 한다.
|
||||
for (const line of allLines.filter((line2) => line2 !== startLine)) {
|
||||
const p1 = line.startPoint
|
||||
const p2 = line.endPoint
|
||||
const key1 = pointToKey(p1)
|
||||
@ -2076,6 +2255,22 @@ export const usePolygon = () => {
|
||||
}
|
||||
})
|
||||
|
||||
// [GETSPLIT-IO 2026-06-15] getSplitRoofsPoints 의 정확한 입력(allLines, 좌표스냅·교차분할·중복제거 후)과
|
||||
// 출력(roofs 면 polygon)을 debug/debug.log 로 영속화. 면분할 오할당 진단용 — 로컬 전용.
|
||||
debugCapture.log('GETSPLIT-IO', {
|
||||
allLines: allLines.map((l) => ({
|
||||
name: l.name,
|
||||
lineName: l.lineName ?? 'none',
|
||||
type: l.attributes?.type ?? 'none',
|
||||
isStart: l.attributes?.isStart ?? false,
|
||||
sx: Math.round(l.startPoint.x * 100) / 100,
|
||||
sy: Math.round(l.startPoint.y * 100) / 100,
|
||||
ex: Math.round(l.endPoint.x * 100) / 100,
|
||||
ey: Math.round(l.endPoint.y * 100) / 100,
|
||||
})),
|
||||
roofs: roofs.map((r) => r.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 }))),
|
||||
})
|
||||
|
||||
return removeDuplicatePolygons(
|
||||
roofs.filter((roof) => roof.length < 100),
|
||||
allLines.some((line) => line.name === 'auxiliaryLine'),
|
||||
|
||||
558
src/hooks/useUndoRedo.js
Normal file
558
src/hooks/useUndoRedo.js
Normal file
@ -0,0 +1,558 @@
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
|
||||
import {
|
||||
canvasState,
|
||||
canvasZoomState,
|
||||
checkedModuleState,
|
||||
currentMenuState,
|
||||
drewRoofCellsState,
|
||||
moduleIsSetupState,
|
||||
moduleRowColArrayState,
|
||||
moduleSetupSurfaceState,
|
||||
redoStackState,
|
||||
roofPolygonArrayState,
|
||||
undoRedoDrawingState,
|
||||
undoStackState,
|
||||
} from '@/store/canvasAtom'
|
||||
import { outerLinePointsState } from '@/store/outerLineAtom'
|
||||
import { selectedMenuState } from '@/store/menuAtom'
|
||||
import { pendingPopupState, popupState } from '@/store/popupAtom'
|
||||
import { POLYGON_TYPE, SAVE_KEY } from '@/common/common'
|
||||
import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle'
|
||||
|
||||
const MAX_HISTORY = 3
|
||||
|
||||
// 모든 useUndoRedo 인스턴스가 공유하는 복원 플래그
|
||||
// useRef는 인스턴스마다 독립적이므로, 모듈 레벨 변수로 전환
|
||||
let _isRestoring = false
|
||||
let _restoreTimer = null
|
||||
|
||||
// 외벽선/배치면 등 그리기 진행 중에는 undo/redo/snapshot 을 차단한다.
|
||||
// (확정 시점부터 history 가 동작해야 하므로)
|
||||
let _isDrawing = false
|
||||
|
||||
/**
|
||||
* 외부 훅/모듈에서 현재 undo/redo 복원 중인지 확인할 때 사용
|
||||
*/
|
||||
export function isUndoRedoRestoring() {
|
||||
return _isRestoring
|
||||
}
|
||||
|
||||
/**
|
||||
* 외벽선/배치면 등 그리기 진행 상태를 외부에서 설정한다.
|
||||
* 그리기가 시작될 때 true, 확정 또는 취소 시 false 로 설정한다.
|
||||
*/
|
||||
export function setUndoRedoDrawing(value) {
|
||||
_isDrawing = !!value
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 훅/모듈에서 현재 그리기 진행 중인지 확인할 때 사용
|
||||
*/
|
||||
export function isUndoRedoDrawing() {
|
||||
return _isDrawing
|
||||
}
|
||||
|
||||
// loadFromJSON 공통 처리 - 이벤트 일시 중단 후 복원
|
||||
function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData) {
|
||||
// 진행 중인 복원 타이머가 있으면 취소
|
||||
if (_restoreTimer) {
|
||||
clearTimeout(_restoreTimer)
|
||||
_restoreTimer = null
|
||||
}
|
||||
|
||||
// loadFromJSON 중 object:added 등 이벤트로 인한 부작용 방지
|
||||
// 리스너를 저장/복원하지 않고 단순히 off만 한다
|
||||
// restoreAtomSnapshot에서 currentMenu 변경 → useCanvasEvent가 attachDefaultEventOnCanvas로 재등록
|
||||
;['object:added', 'object:modified', 'object:removed'].forEach((eventName) => {
|
||||
canvas.off(eventName)
|
||||
})
|
||||
|
||||
canvas.loadFromJSON(snapshot.canvasJSON, () => {
|
||||
rebuildCanvasAtoms()
|
||||
|
||||
canvas.discardActiveObject()
|
||||
canvas.getObjects().forEach((obj) => {
|
||||
if (obj.type === 'QPolygon' && obj.name === POLYGON_TYPE.MODULE) {
|
||||
obj.set({ stroke: 'black', strokeWidth: 0.3 })
|
||||
}
|
||||
})
|
||||
|
||||
canvas.renderAll()
|
||||
restoreAtomSnapshot(snapshot.atomSnapshot)
|
||||
|
||||
// [undo 집계 fix] 캔버스/surface.modules 복원 후 패널 배치 집계를 재계산한다.
|
||||
// 집계 atom(moduleStatisticsState)은 스냅샷 대상이 아니라, 복원 시점에 다시 계산해야 한다.
|
||||
if (typeof setModuleStatisticsData === 'function') setModuleStatisticsData()
|
||||
|
||||
if (canvas.viewportTransform) {
|
||||
setCanvasZoom(Number((canvas.viewportTransform[0] * 100).toFixed(0)))
|
||||
}
|
||||
|
||||
// 다음 undo/redo 허용까지 짧은 딜레이 (연속 클릭 디바운스)
|
||||
_restoreTimer = setTimeout(() => {
|
||||
_isRestoring = false
|
||||
_restoreTimer = null
|
||||
}, 50)
|
||||
})
|
||||
}
|
||||
|
||||
// 순환 참조만 안전하게 건너뛰는 JSON 직렬화
|
||||
// WeakSet 방식은 공유 참조(같은 배열이 여러 곳에서 참조)까지 제거하므로 사용 불가
|
||||
// 대신 조상 경로(ancestors) 스택으로 진짜 순환만 감지
|
||||
function safeStringify(obj) {
|
||||
const ancestors = []
|
||||
return JSON.stringify(obj, function (key, value) {
|
||||
if (typeof value !== 'object' || value === null) return value
|
||||
|
||||
// `this`는 현재 key를 담고 있는 부모 객체
|
||||
// ancestors 스택에서 현재 부모 이후의 항목(형제 경로)을 정리
|
||||
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
|
||||
ancestors.pop()
|
||||
}
|
||||
|
||||
// 현재 value가 조상 경로에 이미 있으면 순환 참조
|
||||
if (ancestors.includes(value)) return undefined
|
||||
|
||||
ancestors.push(value)
|
||||
return value
|
||||
})
|
||||
}
|
||||
|
||||
function safeClone(value) {
|
||||
if (value === null || value === undefined) return value
|
||||
try {
|
||||
return JSON.parse(safeStringify(value))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useUndoRedo() {
|
||||
const canvas = useRecoilValue(canvasState)
|
||||
const [undoStack, setUndoStack] = useRecoilState(undoStackState)
|
||||
const [redoStack, setRedoStack] = useRecoilState(redoStackState)
|
||||
const [isDrawing, setIsDrawing] = useRecoilState(undoRedoDrawingState)
|
||||
|
||||
const [, setModuleSetupSurface] = useRecoilState(moduleSetupSurfaceState)
|
||||
const [moduleIsSetup, setModuleIsSetup] = useRecoilState(moduleIsSetupState)
|
||||
const [checkedModule, setCheckedModule] = useRecoilState(checkedModuleState)
|
||||
const [moduleRowColArray, setModuleRowColArray] = useRecoilState(moduleRowColArrayState)
|
||||
const [, setDrewRoofCells] = useRecoilState(drewRoofCellsState)
|
||||
const [, setRoofPolygonArray] = useRecoilState(roofPolygonArrayState)
|
||||
const [outerLinePoints, setOuterLinePoints] = useRecoilState(outerLinePointsState)
|
||||
const [selectedMenu, setSelectedMenu] = useRecoilState(selectedMenuState)
|
||||
const [currentMenu, setCurrentMenu] = useRecoilState(currentMenuState)
|
||||
const [, setCanvasZoom] = useRecoilState(canvasZoomState)
|
||||
const setPopup = useSetRecoilState(popupState)
|
||||
const [pendingPopup, setPendingPopup] = useRecoilState(pendingPopupState)
|
||||
const { setModuleStatisticsData } = useCircuitTrestle()
|
||||
|
||||
// 최신 스택 값을 ref로 유지하여 클로저 stale 문제 방지
|
||||
const undoStackRef = useRef(undoStack)
|
||||
undoStackRef.current = undoStack
|
||||
const redoStackRef = useRef(redoStack)
|
||||
redoStackRef.current = redoStack
|
||||
|
||||
const getAtomSnapshot = useCallback(() => {
|
||||
return {
|
||||
moduleIsSetup: safeClone(moduleIsSetup || []),
|
||||
checkedModule: safeClone(checkedModule || []),
|
||||
moduleRowColArray: safeClone(moduleRowColArray || []),
|
||||
outerLinePoints: safeClone(outerLinePoints || []),
|
||||
selectedMenu,
|
||||
currentMenu,
|
||||
pendingPopup: pendingPopup ? safeClone(pendingPopup) : null,
|
||||
}
|
||||
}, [moduleIsSetup, checkedModule, moduleRowColArray, outerLinePoints, selectedMenu, currentMenu, pendingPopup])
|
||||
|
||||
// loadFromJSON 후 오브젝트 간 라이브 참조 관계를 재구축
|
||||
const rebuildObjectGraph = useCallback(() => {
|
||||
if (!canvas) return
|
||||
const objects = canvas.getObjects()
|
||||
|
||||
// wall polygon의 lines를 캔버스의 outerLine 오브젝트로 재연결
|
||||
const walls = objects.filter((obj) => obj.name === POLYGON_TYPE.WALL)
|
||||
const outerLines = objects.filter((obj) => obj.name === 'outerLine')
|
||||
|
||||
walls.forEach((wall) => {
|
||||
if (outerLines.length > 0) {
|
||||
// outerLine에 idx가 있으면 순서대로, 없으면 parentId로 매칭
|
||||
const wallOuterLines = outerLines.filter((line) => !line.parentId || line.parentId === wall.id).sort((a, b) => (a.idx ?? 0) - (b.idx ?? 0))
|
||||
if (wallOuterLines.length > 0) {
|
||||
wall.lines = wallOuterLines
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// roof polygon의 innerLines, hips, ridges, lines를 캔버스 오브젝트로 재연결
|
||||
const roofs = objects.filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||
roofs.forEach((roof) => {
|
||||
// innerLines: hip과 ridge 라인들 (roof.innerLines.push(hip), roof.innerLines.push(ridge) 패턴)
|
||||
const hipLines = objects.filter((obj) => obj.parentId === roof.id && obj.name === 'hip')
|
||||
const ridgeLines = objects.filter((obj) => obj.parentId === roof.id && obj.name === 'ridge')
|
||||
|
||||
if (hipLines.length > 0 || ridgeLines.length > 0) {
|
||||
roof.innerLines = [...hipLines, ...ridgeLines]
|
||||
}
|
||||
if (hipLines.length > 0) {
|
||||
roof.hips = hipLines
|
||||
}
|
||||
if (ridgeLines.length > 0) {
|
||||
roof.ridges = ridgeLines
|
||||
}
|
||||
|
||||
// roof.lines: eaveHelpLine 또는 helpLine (지붕면 할당에서 사용)
|
||||
const eaveHelpLines = objects.filter(
|
||||
(obj) => (obj.lineName === 'eaveHelpLine' || obj.lineName === 'helpLine' || obj.name === 'helpLine') && obj.roofId === roof.id,
|
||||
)
|
||||
if (eaveHelpLines.length > 0) {
|
||||
roof.lines = eaveHelpLines
|
||||
}
|
||||
|
||||
// skeletonLines 재연결
|
||||
const skeletonLines = objects.filter((obj) => (obj.lineName === 'roofLine' || obj.lineName === 'skeletonLine') && obj.parentId === roof.id)
|
||||
if (skeletonLines.length > 0) {
|
||||
roof.skeletonLines = skeletonLines
|
||||
}
|
||||
})
|
||||
|
||||
// moduleSetupSurface의 modules 배열을 캔버스의 실제 QPolygon 모듈 객체로 재연결
|
||||
const surfaces = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
|
||||
const moduleObjects = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE)
|
||||
surfaces.forEach((surface) => {
|
||||
const surfaceModules = moduleObjects.filter((mod) => mod.surfaceId === surface.id)
|
||||
surface.modules = surfaceModules
|
||||
})
|
||||
|
||||
// fabric.Group 내부의 QPolygon/QLine 자식에 canvas 참조 설정
|
||||
// loadFromJSON 후 Group 자식은 canvas 를 받지 못해 init/addLengthText 등이 실패할 수 있음
|
||||
objects
|
||||
.filter((obj) => obj.type === 'group')
|
||||
.forEach((group) => {
|
||||
if (group._objects) {
|
||||
group._objects.forEach((child) => {
|
||||
if (!child.canvas) {
|
||||
child.canvas = canvas
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 부모가 없는 고아 lengthText, arrow, flowText 제거
|
||||
// fabric.Group 내부의 QPolygon/QLine 자식 id도 부모로 간주해야
|
||||
// 도머(Group) 내부 삼각형들의 lengthText가 사라지지 않음
|
||||
const parentIds = new Set()
|
||||
objects.forEach((obj) => {
|
||||
if (obj.id) parentIds.add(obj.id)
|
||||
if (obj.type === 'group' && obj._objects) {
|
||||
obj._objects.forEach((child) => {
|
||||
if (child.id) parentIds.add(child.id)
|
||||
// 그룹 내부 QPolygon의 lines(QLine) id도 포함
|
||||
if (child.lines && Array.isArray(child.lines)) {
|
||||
child.lines.forEach((line) => {
|
||||
if (line.id) parentIds.add(line.id)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
// 일반 QPolygon의 lines id도 포함 (내부 보조선/치수선의 parentId)
|
||||
if (obj.lines && Array.isArray(obj.lines)) {
|
||||
obj.lines.forEach((line) => {
|
||||
if (line.id) parentIds.add(line.id)
|
||||
})
|
||||
}
|
||||
})
|
||||
const orphans = objects.filter(
|
||||
(obj) => (obj.name === 'lengthText' || obj.name === 'arrow' || obj.name === 'flowText') && obj.parentId && !parentIds.has(obj.parentId),
|
||||
)
|
||||
orphans.forEach((obj) => canvas.remove(obj))
|
||||
|
||||
// lengthText 이동 관련 속성 및 이벤트 재설정
|
||||
// loadFromJSON 중 object:added 이벤트가 꺼져있어 addEvent가 실행되지 않으므로 수동 설정
|
||||
const lengthTexts = canvas.getObjects().filter((obj) => obj.name === 'lengthText' && obj.type?.toLowerCase().includes('text'))
|
||||
lengthTexts.forEach((target) => {
|
||||
target.bringToFront()
|
||||
target.selectable = true
|
||||
target.evented = true
|
||||
target.lockMovementX = false
|
||||
target.lockMovementY = false
|
||||
target.lockRotation = true
|
||||
target.lockScalingX = true
|
||||
target.lockScalingY = true
|
||||
|
||||
// 기존 per-object 이벤트 제거 후 재등록
|
||||
target.off('selected')
|
||||
target.off('moving')
|
||||
|
||||
const x = target.left
|
||||
const y = target.top
|
||||
|
||||
target.on('selected', () => {
|
||||
Object.keys(target.controls).forEach((controlKey) => {
|
||||
target.setControlVisible(controlKey, false)
|
||||
})
|
||||
})
|
||||
|
||||
target.on('moving', () => {
|
||||
if (target.parentDirection === 'left' || target.parentDirection === 'right') {
|
||||
const minX = target.minX
|
||||
const maxX = target.maxX
|
||||
if (target.left <= minX) {
|
||||
target.set({ left: minX, top: y })
|
||||
} else if (target.left >= maxX) {
|
||||
target.set({ left: maxX, top: y })
|
||||
} else {
|
||||
target.set({ top: y })
|
||||
}
|
||||
} else if (target.parentDirection === 'top' || target.parentDirection === 'bottom') {
|
||||
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 })
|
||||
} else {
|
||||
target.set({ left: x })
|
||||
}
|
||||
}
|
||||
canvas?.renderAll()
|
||||
})
|
||||
})
|
||||
}, [canvas])
|
||||
|
||||
const rebuildCanvasAtoms = useCallback(() => {
|
||||
if (!canvas) return
|
||||
|
||||
// 먼저 오브젝트 간 참조 관계 재구축 (roof.innerLines 등)
|
||||
rebuildObjectGraph()
|
||||
|
||||
const objects = canvas.getObjects()
|
||||
|
||||
const surfaces = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
|
||||
setModuleSetupSurface(surfaces)
|
||||
|
||||
const roofs = objects.filter((obj) => obj.name === POLYGON_TYPE.ROOF)
|
||||
setRoofPolygonArray(roofs)
|
||||
|
||||
const cells = roofs.filter((obj) => obj.cells && obj.cells.length > 0)
|
||||
setDrewRoofCells(cells)
|
||||
|
||||
// loadFromJSON 후 QPolygon 의 내부 lines 배열은 plain object 로만 복원되므로
|
||||
// initLines() 로 실제 QLine 인스턴스를 재구축한다.
|
||||
// (lengthText 는 JSON 에서 이미 복원되므로 재생성하지 않는다.
|
||||
// 과거에는 addLengthText 도 함께 호출했으나, undo/redo 반복 시 숨겨진 모서리 라인의
|
||||
// 텍스트가 누적 생성되어 "707" 같은 잔존 텍스트가 화면에 나타나는 문제가 있었음)
|
||||
const shouldRebuildText = (poly) =>
|
||||
poly.type === 'QPolygon' &&
|
||||
poly.name !== 'arrow' &&
|
||||
poly.name !== POLYGON_TYPE.MODULE &&
|
||||
poly.name !== POLYGON_TYPE.MODULE_SETUP_SURFACE &&
|
||||
poly.name !== POLYGON_TYPE.OBJECT_SURFACE
|
||||
|
||||
// lengthText 인스턴스를 parentId 기준으로 한 번만 모아둔다 (texts 배열 복원에 재사용)
|
||||
const lengthTextsByParent = new Map()
|
||||
objects.forEach((obj) => {
|
||||
if (obj.name === 'lengthText' && obj.parentId) {
|
||||
const arr = lengthTextsByParent.get(obj.parentId) || []
|
||||
arr.push(obj)
|
||||
lengthTextsByParent.set(obj.parentId, arr)
|
||||
}
|
||||
})
|
||||
|
||||
const qPolygons = objects.filter(shouldRebuildText)
|
||||
qPolygons.forEach((polygon) => {
|
||||
try {
|
||||
polygon.initLines()
|
||||
// texts 배열 참조 복원 (재생성이 아닌 기존 lengthText 인스턴스 연결만)
|
||||
// commonDeleteText 가 group._objects[i].texts / object.texts 를 통해 제거하므로,
|
||||
// loadFromJSON 후 끊긴 참조를 다시 이어주지 않으면 삭제 시 lengthText 가 잔존한다.
|
||||
if (polygon.id) {
|
||||
polygon.texts = lengthTextsByParent.get(polygon.id) || []
|
||||
}
|
||||
} catch (e) {
|
||||
/* skip */
|
||||
}
|
||||
})
|
||||
|
||||
// Group 내부 QPolygon(도머 등)도 동일하게 내부 라인 인스턴스만 재구축한다.
|
||||
objects
|
||||
.filter((obj) => obj.type === 'group' && obj._objects)
|
||||
.forEach((group) => {
|
||||
group._objects.forEach((child) => {
|
||||
if (shouldRebuildText(child)) {
|
||||
try {
|
||||
if (!child.canvas) child.canvas = canvas
|
||||
child.initLines?.()
|
||||
if (child.id) {
|
||||
child.texts = lengthTextsByParent.get(child.id) || []
|
||||
}
|
||||
} catch (e) {
|
||||
/* skip */
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
}, [canvas, rebuildObjectGraph])
|
||||
|
||||
const restoreAtomSnapshot = useCallback((atomSnapshot) => {
|
||||
if (!atomSnapshot) return
|
||||
setModuleIsSetup(atomSnapshot.moduleIsSetup || [])
|
||||
setCheckedModule(atomSnapshot.checkedModule || [])
|
||||
setModuleRowColArray(atomSnapshot.moduleRowColArray || [])
|
||||
setOuterLinePoints(atomSnapshot.outerLinePoints || [])
|
||||
if (atomSnapshot.selectedMenu !== undefined) setSelectedMenu(atomSnapshot.selectedMenu)
|
||||
if (atomSnapshot.currentMenu !== undefined) setCurrentMenu(atomSnapshot.currentMenu)
|
||||
// undo/redo 시 복원해야 할 팝업 정보
|
||||
setPendingPopup(atomSnapshot.pendingPopup ?? null)
|
||||
}, [])
|
||||
|
||||
// canvas.toJSON(SAVE_KEY)의 결과를 순환 참조 없이 안전하게 직렬화
|
||||
const getCanvasSnapshot = useCallback(() => {
|
||||
if (!canvas) return null
|
||||
try {
|
||||
const rawJSON = canvas.toJSON(SAVE_KEY)
|
||||
return JSON.parse(safeStringify(rawJSON))
|
||||
} catch (e) {
|
||||
console.warn('Canvas snapshot failed:', e)
|
||||
return null
|
||||
}
|
||||
}, [canvas])
|
||||
|
||||
const saveSnapshot = useCallback(
|
||||
(atomOverrides = null, { force = false } = {}) => {
|
||||
if (!canvas || _isRestoring) return
|
||||
|
||||
// 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 스냅샷을 쌓지 않는다 (force 시 예외)
|
||||
if (!force && (_isDrawing || isDrawing)) return
|
||||
|
||||
// snapshot 전에 mouseLine 제거
|
||||
const mouseLines = canvas.getObjects().filter((obj) => obj.name === 'mouseLine')
|
||||
mouseLines.forEach((obj) => canvas.remove(obj))
|
||||
|
||||
const canvasJSON = getCanvasSnapshot()
|
||||
if (!canvasJSON) return
|
||||
|
||||
const atomSnapshot = getAtomSnapshot()
|
||||
|
||||
// atomOverrides 로 비동기 state 반영이 안 된 값을 즉시 덮어쓸 수 있다
|
||||
if (atomOverrides) {
|
||||
Object.assign(atomSnapshot, atomOverrides)
|
||||
}
|
||||
|
||||
const snapshot = {
|
||||
canvasJSON,
|
||||
atomSnapshot,
|
||||
}
|
||||
|
||||
setUndoStack((prev) => {
|
||||
const newStack = [...prev, snapshot]
|
||||
if (newStack.length > MAX_HISTORY) {
|
||||
return newStack.slice(newStack.length - MAX_HISTORY)
|
||||
}
|
||||
return newStack
|
||||
})
|
||||
setRedoStack([])
|
||||
},
|
||||
[canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot],
|
||||
)
|
||||
|
||||
const closeAllPopups = useCallback(() => {
|
||||
setPopup((prev) => ({
|
||||
// 처마 케라바 변경 팝업(EavesGableEdit)은 undo/redo 시 닫지 않는다
|
||||
config: prev.config.filter((p) => p.component?.type?.name === 'EavesGableEdit'),
|
||||
other: prev.other.filter((p) => p.component?.type?.name === 'EavesGableEdit'),
|
||||
}))
|
||||
}, [setPopup])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
// 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 undo 차단
|
||||
if (_isDrawing || isDrawing) return
|
||||
|
||||
const currentUndoStack = undoStackRef.current
|
||||
if (!canvas || currentUndoStack.length === 0 || _isRestoring) return
|
||||
|
||||
_isRestoring = true
|
||||
|
||||
const currentCanvasJSON = getCanvasSnapshot()
|
||||
const currentAtomSnapshot = getAtomSnapshot()
|
||||
|
||||
const currentSnapshot = {
|
||||
canvasJSON: currentCanvasJSON,
|
||||
atomSnapshot: currentAtomSnapshot,
|
||||
}
|
||||
|
||||
const newUndoStack = [...currentUndoStack]
|
||||
const snapshot = newUndoStack.pop()
|
||||
|
||||
// undo/redo 시 항상 팝업을 닫고 복원한다
|
||||
closeAllPopups()
|
||||
|
||||
setUndoStack(newUndoStack)
|
||||
setRedoStack((prev) => [...prev, currentSnapshot])
|
||||
|
||||
_restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData)
|
||||
}, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups, setModuleStatisticsData])
|
||||
|
||||
const redo = useCallback(() => {
|
||||
// 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 redo 차단
|
||||
if (_isDrawing || isDrawing) return
|
||||
|
||||
const currentRedoStack = redoStackRef.current
|
||||
if (!canvas || currentRedoStack.length === 0 || _isRestoring) return
|
||||
|
||||
_isRestoring = true
|
||||
|
||||
const currentCanvasJSON = getCanvasSnapshot()
|
||||
const currentAtomSnapshot = getAtomSnapshot()
|
||||
const currentSnapshot = {
|
||||
canvasJSON: currentCanvasJSON,
|
||||
atomSnapshot: currentAtomSnapshot,
|
||||
}
|
||||
|
||||
const newRedoStack = [...currentRedoStack]
|
||||
const snapshot = newRedoStack.pop()
|
||||
|
||||
// undo/redo 시 항상 팝업을 닫고 복원한다
|
||||
closeAllPopups()
|
||||
|
||||
setRedoStack(newRedoStack)
|
||||
setUndoStack((prev) => [...prev, currentSnapshot])
|
||||
|
||||
_restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData)
|
||||
}, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups, setModuleStatisticsData])
|
||||
|
||||
const clearStacks = useCallback(() => {
|
||||
setUndoStack([])
|
||||
setRedoStack([])
|
||||
}, [])
|
||||
|
||||
/** undo 스택에서 마지막 스냅샷 1개만 제거 (취소 시 사용) */
|
||||
const popLastSnapshot = useCallback(() => {
|
||||
setUndoStack((prev) => (prev.length > 0 ? prev.slice(0, -1) : prev))
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 외벽선/배치면 등 그리기 진행 중에는 undo/redo 를 차단한다.
|
||||
* 마운트 시 true, 언마운트 시 false 로 설정한다.
|
||||
*/
|
||||
const setDrawing = useCallback(
|
||||
(value) => {
|
||||
const v = !!value
|
||||
setIsDrawing(v)
|
||||
setUndoRedoDrawing(v)
|
||||
},
|
||||
[setIsDrawing],
|
||||
)
|
||||
|
||||
return {
|
||||
saveSnapshot,
|
||||
undo,
|
||||
redo,
|
||||
clearStacks,
|
||||
popLastSnapshot,
|
||||
setDrawing,
|
||||
canUndo: undoStack.length > 0 && !isDrawing,
|
||||
canRedo: redoStack.length > 0 && !isDrawing,
|
||||
}
|
||||
}
|
||||
@ -408,3 +408,23 @@ export const moduleRowColArrayState = atom({
|
||||
default: [],
|
||||
dangerouslyAllowMutability: true,
|
||||
})
|
||||
|
||||
// Undo/Redo 히스토리 스택
|
||||
export const undoStackState = atom({
|
||||
key: 'undoStackState',
|
||||
default: [],
|
||||
dangerouslyAllowMutability: true,
|
||||
})
|
||||
|
||||
export const redoStackState = atom({
|
||||
key: 'redoStackState',
|
||||
default: [],
|
||||
dangerouslyAllowMutability: true,
|
||||
})
|
||||
|
||||
// 외벽선/배치면 등 그리기 진행 중 여부.
|
||||
// true 동안에는 undo/redo 가 비활성화되고 새 스냅샷이 쌓이지 않는다.
|
||||
export const undoRedoDrawingState = atom({
|
||||
key: 'undoRedoDrawingState',
|
||||
default: false,
|
||||
})
|
||||
|
||||
@ -39,3 +39,9 @@ export const promisePopupState = atom({
|
||||
key: 'promisePopupStore',
|
||||
default: false,
|
||||
})
|
||||
|
||||
/** undo/redo 시 복원해야 할 팝업 정보 (JSX 직렬화 불가 → 타입+파라미터만 저장) */
|
||||
export const pendingPopupState = atom({
|
||||
key: 'pendingPopupState',
|
||||
default: null, // { type: 'RoofAllocation', id, pos } 등
|
||||
})
|
||||
|
||||
@ -219,7 +219,12 @@ export const corridorDimensionSelector = selector({
|
||||
key: 'corridorDimensionSelector',
|
||||
get: ({ get }) => {
|
||||
const settingModalFirstOptions = get(settingModalFirstOptionsState)
|
||||
return settingModalFirstOptions.dimensionDisplay.find((option) => option.selected)
|
||||
const selected = settingModalFirstOptions.dimensionDisplay.find((option) => option.selected)
|
||||
// Fabric 클래스(QPolygon) 등 React hook 외부에서 현재 column 참조용
|
||||
if (typeof window !== 'undefined' && selected) {
|
||||
window.__currentCorridorColumn = selected.column
|
||||
}
|
||||
return selected
|
||||
},
|
||||
set: ({ set }, newValue) => {
|
||||
//0 : 복도치수 , 1 : 실제치수
|
||||
@ -229,6 +234,10 @@ export const corridorDimensionSelector = selector({
|
||||
...prev,
|
||||
dimensionDisplay: prev.dimensionDisplay.map((item, index) => {
|
||||
if (index === newValue) {
|
||||
// Fabric 클래스(QPolygon) 등 React hook 외부에서 현재 column 참조용
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__currentCorridorColumn = item.column
|
||||
}
|
||||
return { ...item, selected: true }
|
||||
} else {
|
||||
return { ...item, selected: false }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1071,7 +1071,9 @@ export function findAndRemoveClosestPoint(targetPoint, points) {
|
||||
export function polygonToTurfPolygon(object, current = false) {
|
||||
let coordinates
|
||||
coordinates = object.points.map((point) => [point.x, point.y])
|
||||
if (current) coordinates = object.getCurrentPoints().map((point) => [point.x, point.y])
|
||||
if (current && typeof object.getCurrentPoints === 'function') {
|
||||
coordinates = object.getCurrentPoints().map((point) => [point.x, point.y])
|
||||
}
|
||||
coordinates.push(coordinates[0])
|
||||
return turf.polygon(
|
||||
[coordinates],
|
||||
@ -1122,3 +1124,60 @@ export function getTrianglePoints(triangle) {
|
||||
|
||||
return points.map((point) => fabric.util.transformPoint(point, matrix))
|
||||
}
|
||||
|
||||
// [BOUNDARY-SYNC 2026-06-23] 지붕 경계는 polygon.lines(처마선)과 polygon.innerLines 안의 경계 복제본
|
||||
// 두 군데에 중복 보관된다(정상 시 좌표 동일 → split 중복제거로 합쳐짐). 보조선 사이즈 변경/이동으로
|
||||
// 한쪽 경계선만 옮기면 두 표현이 어긋나, 지붕면 할당 split 이 경계를 2벌로 인식 → 바깥 전체 사각형이
|
||||
// 가짜 면으로 잡힌다(예: 4면이어야 하는데 5면). 아래 헬퍼로 편집 시 좌표가 동일했던 "쌍둥이" 경계선을
|
||||
// 함께 갱신해 동기화한다. (일반 보조선은 동일 좌표 처마선이 없어 매칭 안 됨 → 영향 없음)
|
||||
const _eqBoundaryPt = (a, b, eps = 1) => Math.abs(a.x - b.x) <= eps && Math.abs(a.y - b.y) <= eps
|
||||
|
||||
/** 모든 지붕의 lines + innerLines 중, 끝점이 (p1,p2) 와 같은(순서 무관) 라인을 editedLine 제외하고 반환 */
|
||||
function _findBoundaryTwins(canvas, editedLine, p1, p2) {
|
||||
if (!canvas) return []
|
||||
const roofs = canvas.getObjects().filter((o) => o.name === 'roof')
|
||||
const twins = []
|
||||
roofs.forEach((roof) => {
|
||||
;[...(roof.lines || []), ...(roof.innerLines || [])].forEach((twin) => {
|
||||
if (twin === editedLine) return
|
||||
const t1 = { x: twin.x1, y: twin.y1 }
|
||||
const t2 = { x: twin.x2, y: twin.y2 }
|
||||
if ((_eqBoundaryPt(t1, p1) && _eqBoundaryPt(t2, p2)) || (_eqBoundaryPt(t1, p2) && _eqBoundaryPt(t2, p1))) {
|
||||
twins.push({ twin, p1IsT1: _eqBoundaryPt(t1, p1) })
|
||||
}
|
||||
})
|
||||
})
|
||||
return twins
|
||||
}
|
||||
|
||||
/**
|
||||
* 사이즈 변경 등으로 끝점이 옮겨진 경우: 편집 전 좌표(origP1/origP2)가 같던 처마선 쌍둥이를
|
||||
* editedLine 의 현재(편집 후) 좌표로 맞춘다. 끝점 대응을 보존해 매핑한다.
|
||||
*/
|
||||
export function syncOuterBoundaryTwin(canvas, editedLine, origP1, origP2) {
|
||||
if (!canvas || !editedLine) return
|
||||
const newP1 = { x: editedLine.x1, y: editedLine.y1 }
|
||||
const newP2 = { x: editedLine.x2, y: editedLine.y2 }
|
||||
_findBoundaryTwins(canvas, editedLine, origP1, origP2).forEach(({ twin, p1IsT1 }) => {
|
||||
// twin 의 t1 끝점이 origP1 에 대응하면 t1→newP1, 아니면 t1→newP2
|
||||
if (p1IsT1) twin.set({ x1: newP1.x, y1: newP1.y, x2: newP2.x, y2: newP2.y })
|
||||
else twin.set({ x1: newP2.x, y1: newP2.y, x2: newP1.x, y2: newP1.y })
|
||||
if (typeof twin.setCoords === 'function') twin.setCoords()
|
||||
twin.attributes = { ...twin.attributes, planeSize: editedLine.attributes?.planeSize, actualSize: editedLine.attributes?.actualSize }
|
||||
if (typeof twin.addLengthText === 'function') twin.addLengthText()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 평행 이동(보조선 이동)으로 옮겨진 경우: object 와 좌표가 같던 처마선 쌍둥이를 같은 delta(dx,dy)로 평행 이동한다.
|
||||
*/
|
||||
export function translateOuterBoundaryTwin(canvas, object, dx, dy) {
|
||||
if (!canvas || !object) return
|
||||
const p1 = { x: object.x1, y: object.y1 }
|
||||
const p2 = { x: object.x2, y: object.y2 }
|
||||
_findBoundaryTwins(canvas, object, p1, p2).forEach(({ twin }) => {
|
||||
twin.set({ x1: twin.x1 + dx, y1: twin.y1 + dy, x2: twin.x2 + dx, y2: twin.y2 + dy })
|
||||
if (typeof twin.setCoords === 'function') twin.setCoords()
|
||||
if (typeof twin.addLengthText === 'function') twin.addLengthText()
|
||||
})
|
||||
}
|
||||
|
||||
@ -15,7 +15,7 @@ export const defineQPolygon = () => {
|
||||
fabric.Object._fromObject('QPolygon', object, callback, 'points')
|
||||
}
|
||||
}
|
||||
|
||||
//undo 가 있는 js
|
||||
/**
|
||||
* point1에서 point2를 잇는 방향의 각도를 구한다.
|
||||
* @param point1
|
||||
@ -1598,6 +1598,29 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
|
||||
} else if (Math.abs(normalizedAngle - 90) <= tolerance) {
|
||||
isVertical = true
|
||||
}
|
||||
// [GABLE-H8 2026-06-23] 노치 결합면에서 직교정렬이 만든 same-direction roofLine 이 기존 마루
|
||||
// 구간 안에 완전히 포함되면(= 마루 위 중복선, isAlready 는 끝점 정확매칭이라 못 거름) 그리지 않는다.
|
||||
// 마루가 이미 그 경계를 닫고 있으므로 중복 roofLine(H-8 과길이 원인)만 제거. cross-direction(hip)·
|
||||
// 부분겹침은 건드리지 않아 정상 A타입 회귀 없음.
|
||||
const _segSameDir = (analyze.isHorizontal && isHorizontal) || (analyze.isVertical && isVertical)
|
||||
if (_segSameDir) {
|
||||
const segV = Math.abs(points[0] - points[2]) < 1
|
||||
const sMin = segV ? Math.min(points[1], points[3]) : Math.min(points[0], points[2])
|
||||
const sMax = segV ? Math.max(points[1], points[3]) : Math.max(points[0], points[2])
|
||||
const coveredByRidge = ridgeLines.find((rg) => {
|
||||
const rgV = Math.abs(rg.x1 - rg.x2) < 1
|
||||
if (rgV !== segV) return false
|
||||
if (segV) {
|
||||
if (Math.abs(rg.x1 - points[0]) >= 1) return false
|
||||
return Math.min(rg.y1, rg.y2) - 1 <= sMin && sMax <= Math.max(rg.y1, rg.y2) + 1
|
||||
}
|
||||
if (Math.abs(rg.y1 - points[1]) >= 1) return false
|
||||
return Math.min(rg.x1, rg.x2) - 1 <= sMin && sMax <= Math.max(rg.x1, rg.x2) + 1
|
||||
})
|
||||
if (coveredByRidge) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if (analyze.isHorizontal) {
|
||||
//현재라인이 수평선일때
|
||||
if (isHorizontal) {
|
||||
@ -1628,6 +1651,10 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
|
||||
drawRoofPlane(backward)
|
||||
})
|
||||
roof.innerLines.push(...ridgeLines, ...innerLines)
|
||||
// 처마선 innerLine 이 외곽선/치수를 전담하므로 roof 원본 외곽(stroke) 숨김 + 지붕선 치수(roof lengthText) 제거.
|
||||
// 외벽선(wall) 치수는 유지. (박공은 split 공용 함수를 안 거치므로 여기서 직접 처리)
|
||||
roof.set({ stroke: 'transparent' })
|
||||
removeRoofLengthText(roof, canvas)
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((obj) => obj.name === 'check')
|
||||
@ -1719,6 +1746,16 @@ export const drawShedRoof = (roofId, canvas, textMode) => {
|
||||
|
||||
const maxLine = pitchSizeLines.reduce((prev, current) => (prev.length > current.length ? prev : current), pitchSizeLines[0])
|
||||
canvas.add(maxLine)
|
||||
|
||||
// [docs §8] 한쪽흐름 외곽 처마선 — 내부 보조선이 없어 각 처마변이 통짜 innerLine 으로 그려진다.
|
||||
// 경사변(gable)은 평면길이≠실제길이 라서 degreeOf 로 shedDegree 를 주입 → drawHipLine(plane/cos) 으로 보정.
|
||||
// roof 원본 외곽(stroke) 숨김은 splitRoofLinesToInnerLines 가 담당.
|
||||
roof.innerLines = roof.innerLines || []
|
||||
splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode, {
|
||||
degreeOf: (parentLine) => (parentLine.attributes?.type === LINE_TYPE.WALLLINE.GABLE ? shedDegree : 0),
|
||||
})
|
||||
// 지붕선 치수(roof lengthText) 제거는 split 내부 removeRoofLengthText 가 담당. 외벽선(wall) 치수는 유지.
|
||||
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
@ -5429,7 +5466,52 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
//추가된 하단 지붕 라인 innerLines에 추가.
|
||||
innerLines.push(...downRoofLines)
|
||||
|
||||
//지붕선에 따라 라인추가 작업 처리.
|
||||
//지붕선에 따라 라인추가 작업 처리. (split 공용 함수 — docs §3-1)
|
||||
splitRoofLinesToInnerLines(roof, innerLines, canvas, textMode)
|
||||
|
||||
//지붕 innerLines에 추가.
|
||||
roof.innerLines = innerLines
|
||||
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((object) => object.name === 'check')
|
||||
.forEach((object) => canvas.remove(object))
|
||||
canvas.renderAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* 지붕선(roof.lines)의 원본 변 길이 text(lengthText)를 제거한다.
|
||||
* 지붕선 치수는 그 위에 그려진 보조선(처마선 innerLine)의 치수로 대체되므로 roof.lines 자체 치수는 숨긴다.
|
||||
* ※ 외벽선(wall) 치수는 유지한다. (변별/박공/용마루/한쪽흐름 공통)
|
||||
*/
|
||||
export const removeRoofLengthText = (roof, canvas) => {
|
||||
canvas
|
||||
?.getObjects()
|
||||
.filter((o) => o.name === 'lengthText' && o.parentId === roof.id)
|
||||
.forEach((o) => canvas.remove(o))
|
||||
}
|
||||
|
||||
/**
|
||||
* roof.lines(처마 외곽선)를 innerLine 끝점 기준으로 split 해 편집 가능한 innerLine(hip/roofLine)으로 만들고,
|
||||
* 처마선 innerLine 이 외곽선을 전담하므로 roof 원본 외곽선(stroke)은 숨긴다(transparent).
|
||||
* 변별/용마루 경로 공용. innerLines 에 결과를 in-place push 하고 반환한다.
|
||||
* (용마루 경로는 추녀 연장 후 호출해야 종점이 처마변 위에 있어 분할점이 잡힌다 — docs §9)
|
||||
* @param {fabric.Object} roof - 대상 지붕 (roof.lines 사용)
|
||||
* @param {Array<QLine>} innerLines - 내부선 배열 (split 결과가 여기에 in-place 추가됨)
|
||||
* @param {fabric.Canvas} canvas
|
||||
* @param {string} textMode
|
||||
* @param {{degreeOf?: (parentLine) => number}} [opts] - degreeOf: 부모 외곽변 → 경사각(도).
|
||||
* 한쪽흐름 경사변(gable)처럼 평면길이≠실제길이인 변을 통짜로 그릴 때 drawHipLine 으로 실제길이 보정(plane/cos) — docs §8.
|
||||
* 미전달 시 기존 동작(분할점 인접 pitch 기반).
|
||||
* @returns {Array<QLine>} innerLines
|
||||
*/
|
||||
export const splitRoofLinesToInnerLines = (roof, innerLines, canvas, textMode, opts = {}) => {
|
||||
const { degreeOf } = opts
|
||||
// 분할점이 처마변 끝점(코너)과 이 거리(px) 이내면 분할에서 제외.
|
||||
// SK 추녀 연장이 처마 코너에 닿을 때 ray cast 부동소수 오차(관측 0.02px)로 코너와 미세하게
|
||||
// 어긋나면, 기존 almostEqual(1e-10)은 "끝점 아님"으로 오판 → 0 길이 조각 생성. END_EPS 로 흡수.
|
||||
// 변별 경로의 진짜 분할점은 끝점에서 수백 px 떨어져 있어 영향 없음.
|
||||
const END_EPS = 1.0
|
||||
const innerLinesPoints = []
|
||||
innerLines.forEach((line) => {
|
||||
const hasCoord1 = innerLinesPoints.find((p) => p.x === line.x1 && p.y === line.y1)
|
||||
@ -5445,36 +5527,32 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
|
||||
const splitPoint = []
|
||||
let hasOverlapLine = false
|
||||
const minX = Math.min(currentLine.x1, currentLine.x2)
|
||||
const maxX = Math.max(currentLine.x1, currentLine.x2)
|
||||
const minY = Math.min(currentLine.y1, currentLine.y2)
|
||||
const maxY = Math.max(currentLine.y1, currentLine.y2)
|
||||
innerLines.forEach((innerLine) => {
|
||||
const innerLineMinX = Math.min(innerLine.x1, innerLine.x2)
|
||||
const innerLineMaxX = Math.max(innerLine.x1, innerLine.x2)
|
||||
const innerLineMinY = Math.min(innerLine.y1, innerLine.y2)
|
||||
const innerLineMaxY = Math.max(innerLine.y1, innerLine.y2)
|
||||
if (innerLineMinX <= minX && innerLineMaxX >= maxX && innerLineMinY <= minY && innerLineMaxY >= maxY) {
|
||||
hasOverlapLine = true
|
||||
}
|
||||
if (minX <= innerLineMinX && maxX >= innerLineMaxX && minY <= innerLineMinY && maxY >= innerLineMaxY) {
|
||||
hasOverlapLine = true
|
||||
}
|
||||
// [docs §10] overlap 판정: 축정렬 bbox 포함 → "공선 + 구간 덮음" 으로 변경.
|
||||
// 기존 bbox 포함은 수평/수직 짧은 처마변(bbox 한 축 두께 0)을 대각선 추녀(hip) bbox 가
|
||||
// 삼켜 false positive(처마변 skip → 원본 transparent → 지붕선 소실)를 냈다(육각형 잘린 모서리).
|
||||
// 원 가드 의도("같은 선 위 더 긴 내부선이 처마변을 덮으면 중복이니 skip")는 공선이 전제이므로,
|
||||
// 공선 아닌 추녀는 애초에 skip 대상이 아니다. 양 끝점이 상대 선분 위(isPointOnLineNew)면 덮음.
|
||||
const innerCoversCurr =
|
||||
isPointOnLineNew(innerLine, { x: currentLine.x1, y: currentLine.y1 }) &&
|
||||
isPointOnLineNew(innerLine, { x: currentLine.x2, y: currentLine.y2 })
|
||||
const currCoversInner =
|
||||
isPointOnLineNew(currentLine, { x: innerLine.x1, y: innerLine.y1 }) &&
|
||||
isPointOnLineNew(currentLine, { x: innerLine.x2, y: innerLine.y2 })
|
||||
if (innerCoversCurr || currCoversInner) hasOverlapLine = true
|
||||
})
|
||||
if (hasOverlapLine) return
|
||||
|
||||
innerLinesPoints.forEach((point) => {
|
||||
if (
|
||||
isPointOnLineNew(currentLine, point) &&
|
||||
!(almostEqual(currentLine.x1, point.x) && almostEqual(currentLine.y1, point.y)) &&
|
||||
!(almostEqual(currentLine.x2, point.x) && almostEqual(currentLine.y2, point.y))
|
||||
) {
|
||||
const distance = Math.sqrt((point.x - currentLine.x1) ** 2 + (point.y - currentLine.y1) ** 2)
|
||||
splitPoint.push({ point, distance })
|
||||
const distToStart = Math.hypot(point.x - currentLine.x1, point.y - currentLine.y1)
|
||||
const distToEnd = Math.hypot(point.x - currentLine.x2, point.y - currentLine.y2)
|
||||
// 변 위 점이면서 양 끝점(코너)에서 END_EPS 보다 멀 때만 진짜 분할점으로 채택.
|
||||
if (isPointOnLineNew(currentLine, point) && distToStart > END_EPS && distToEnd > END_EPS) {
|
||||
splitPoint.push({ point, distance: distToStart })
|
||||
}
|
||||
})
|
||||
if (splitPoint.length > 0) {
|
||||
splitPoint.sort((a, b) => a[1] - b[1])
|
||||
splitPoint.sort((a, b) => a.distance - b.distance)
|
||||
let startPoint = { x: currentLine.x1, y: currentLine.y1 }
|
||||
for (let i = 0; i < splitPoint.length; i++) {
|
||||
const point = splitPoint[i].point
|
||||
@ -5491,18 +5569,22 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
|
||||
innerLines.push(drawHipLine([startPoint.x, startPoint.y, currentLine.x2, currentLine.y2], canvas, roof, textMode, nextDegree, nextDegree))
|
||||
}
|
||||
} else {
|
||||
innerLines.push(drawRoofLine([currentLine.x1, currentLine.y1, currentLine.x2, currentLine.y2], canvas, roof, textMode))
|
||||
// 분할점 없는 통짜 변. degreeOf 가 주어지면(한쪽흐름) 경사변은 drawHipLine(plane/cos) 으로 실제길이 보정.
|
||||
const deg = degreeOf ? degreeOf(currentLine) : 0
|
||||
if (deg > 0) {
|
||||
innerLines.push(drawHipLine([currentLine.x1, currentLine.y1, currentLine.x2, currentLine.y2], canvas, roof, textMode, deg, deg))
|
||||
} else {
|
||||
innerLines.push(drawRoofLine([currentLine.x1, currentLine.y1, currentLine.x2, currentLine.y2], canvas, roof, textMode))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
//지붕 innerLines에 추가.
|
||||
roof.innerLines = innerLines
|
||||
// 처마선 innerLine 이 외곽선/치수를 전담하므로 roof 원본 외곽(stroke) 숨김 + 지붕선 치수(roof lengthText) 제거.
|
||||
// (안 숨기면 처마선 삭제 시 roof 기본 stroke:'black'(usePolygon.js) 노출. 외벽선 wall 치수는 유지)
|
||||
roof.set({ stroke: 'transparent' })
|
||||
removeRoofLengthText(roof, canvas)
|
||||
|
||||
canvas
|
||||
.getObjects()
|
||||
.filter((object) => object.name === 'check')
|
||||
.forEach((object) => canvas.remove(object))
|
||||
canvas.renderAll()
|
||||
return innerLines
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
|
||||
import { SkeletonBuilder } from '@/lib/skeletons'
|
||||
import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, toGeoJSON } from '@/util/qpolygon-utils'
|
||||
import { calcLineActualSize, calcLineActualSize2, calcLinePlaneSize, splitRoofLinesToInnerLines, toGeoJSON } from '@/util/qpolygon-utils'
|
||||
import { QLine } from '@/components/fabric/QLine'
|
||||
import { getDegreeByChon } from '@/util/canvas-util'
|
||||
import Big from 'big.js'
|
||||
@ -1283,6 +1283,10 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
|
||||
// gap 을 시각화. innerLines 와 별개 collection. 기존 working code (processInBoth 등) 무영향.
|
||||
drawBaselineToRooflineHelpers(roof, canvas, changRoofLinePoints, roofLinePoints)
|
||||
|
||||
// [docs §9-3] 추녀 연장 후 처마변(roof.lines)을 split → 편집 가능한 innerLine(hip/roofLine)으로 등록.
|
||||
// 단순 우진각: 추녀가 처마 코너(변 끝점)에 닿아 분할점 없음 → 처마 4변 통짜. 복합 형상: 중간 접점에서 분할.
|
||||
splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode)
|
||||
|
||||
// 캔버스에 스켈레톤 상태 저장
|
||||
if (!canvas.skeletonStates) {
|
||||
canvas.skeletonStates = {}
|
||||
|
||||
2
startscript-ur.js
Normal file
2
startscript-ur.js
Normal file
@ -0,0 +1,2 @@
|
||||
var exec = require('child_process').exec
|
||||
exec('yarn start:ur', { windowsHide: true })
|
||||
Loading…
x
Reference in New Issue
Block a user