Merge pull request 'dev' (#931) from dev into prd-deploy

Reviewed-on: #931
This commit is contained in:
ysCha 2026-06-23 17:47:25 +09:00
commit 2b3e46d28f
52 changed files with 3059 additions and 418 deletions

View File

@ -1,7 +1,7 @@
# 용마루(skeleton) 경로 외곽 지붕선 편집 기능 — 설계 # 용마루(skeleton) 경로 외곽 지붕선 편집 기능 — 설계
- 작성일: 2026-06-01 - 작성일: 2026-06-01
- 대상 브랜치: `feature/redo-undo` - 대상 브랜치: `feature/roofline-edit`
- 관련 파일: `src/util/skeleton-utils.js`, `src/util/qpolygon-utils.js`, `src/hooks/useContextMenu.js` - 관련 파일: `src/util/skeleton-utils.js`, `src/util/qpolygon-utils.js`, `src/hooks/useContextMenu.js`
## 1. 배경 / 문제 ## 1. 배경 / 문제
@ -66,13 +66,13 @@ splitRoofLinesToInnerLines(roof, canvas, textMode) → QLine[]
- **`drawRoofByAttribute` 교체**: 인라인 블록을 함수 호출로 교체. 순수 추출이므로 변별 경로 동작 불변. - **`drawRoofByAttribute` 교체**: 인라인 블록을 함수 호출로 교체. 순수 추출이므로 변별 경로 동작 불변.
### 3-2. skeleton 경로 변경 ### 3-2. skeleton 경로 변경
- `createInnerLinesFromSkeleton``isOuterEdge` 숨김 처리(`:1636-1643``visible:false`, `:1624``selectable:false`) 제거. - `createInnerLinesFromSkeleton``isOuterEdge`(SK eaves) 숨김 처리는 **유지**. (§9 정정: SK eaves 라인은 baseLine 기준이라, 노출하면 처마변 split 결과와 offset 만큼 어긋나 이중 표시됨. 처마 외곽선은 아래 split 으로 별도 innerLine 화.)
- 내부선(용마루/추녀) 생성 완료 후 `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. - **추녀 연장(`drawBaselineToRooflineHelpers`, `:1285`) 완료 후** `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 추녀 종점이 처마변 위에 있는 상태라 split 정합 성립, 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. (⚠️ 연장 **전**에 호출하면 종점이 baseLine 위라 분할점을 못 잡음 — §9 참조)
- `drawHelpLine` 재호출 시마다 자동 재실행되어 편집 후 재그리기에도 외곽 지붕선 유지. - `drawHelpLine` 재호출 시마다 자동 재실행되어 편집 후 재그리기에도 외곽 지붕선 유지.
## 4. 리스크 & 구현 전 검증 ## 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)로 추출하거나 의존성 정리. 2. **순환 import**: `qpolygon-utils ↔ skeleton-utils` 상호 import 여부 점검. 순환이면 split 함수를 제3의 모듈(예: canvas-util)로 추출하거나 의존성 정리.
3. **기존 `isOuterEdge` roofLine 제거 영향**: 숨긴 라인을 참조하는 곳(치수 계산, 플랜 저장/복원 등)이 있는지 확인 후 제거. 3. **기존 `isOuterEdge` roofLine 제거 영향**: 숨긴 라인을 참조하는 곳(치수 계산, 플랜 저장/복원 등)이 있는지 확인 후 제거.
@ -159,3 +159,91 @@ opts.degreeOf?: (parentLine) => number // 부모 외곽변 → 경사각(도).
### 8-5. 리스크 ### 8-5. 리스크
- **분할점 부재**: 한쪽흐름은 내부 보조선이 없어 외곽변이 1:1 통짜 라인으로 그려진다 → 부모 변 type 매칭이 단순(분할 시에도 조각은 부모 type 상속). split 의 끝점 정합성(`isPointOnLineNew`, tol 0.1) 자체는 분할점이 없으면 무의미하므로 `else` 통짜 경로(`:5493`)로 안전하게 떨어진다. - **분할점 부재**: 한쪽흐름은 내부 보조선이 없어 외곽변이 1:1 통짜 라인으로 그려진다 → 부모 변 type 매칭이 단순(분할 시에도 조각은 부모 type 상속). split 의 끝점 정합성(`isPointOnLineNew`, tol 0.1) 자체는 분할점이 없으면 무의미하므로 `else` 통짜 경로(`:5493`)로 안전하게 떨어진다.
- **`isGable` 판정원**: `drawShedRoof``gables` 분류(`:1653`)와 동일 기준(`attributes.type === GABLE`)을 써야 보정 대상이 일치. - **`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 유지. (캔버스 인터랙션이라 런타임 확인은 재현 필요 — 정적 분석상 ①만 동작 변화, ②③④ 불변 예상.)

View File

@ -14,6 +14,7 @@
"start:cluster1": "env-cmd -f .env.production next start -p 5000", "start:cluster1": "env-cmd -f .env.production next start -p 5000",
"start:cluster2": "env-cmd -f .env.production next start -p 5001", "start:cluster2": "env-cmd -f .env.production next start -p 5001",
"start:dev": "env-cmd -f .env.development next start -p 5010", "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", "start:btob": "env-cmd -f .env.development next start -p 5060",
"lint": "next lint", "lint": "next lint",
"serve": "node server.js" "serve": "node server.js"

View File

@ -227,6 +227,11 @@ export const SAVE_KEY = [
'outerLineFix', 'outerLineFix',
'adjustRoofLines', 'adjustRoofLines',
'northModuleYn', '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] export const OBJECT_PROTOTYPE = [fabric.Line.prototype, fabric.Polygon.prototype, fabric.Triangle.prototype, fabric.Group.prototype]

View File

@ -40,7 +40,7 @@ class ErrorBoundary extends React.Component {
<p><strong>Timestamp:</strong> {new Date().toISOString()}</p> <p><strong>Timestamp:</strong> {new Date().toISOString()}</p>
<p><strong>Error:</strong> {this.state.error && this.state.error.toString()}</p> <p><strong>Error:</strong> {this.state.error && this.state.error.toString()}</p>
<p><strong>Stack Trace:</strong></p> <p><strong>Stack Trace:</strong></p>
<pre>{this.state.errorInfo.componentStack}</pre> <pre>{this.state.errorInfo?.componentStack}</pre>
</details> </details>
<button <button
onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })} onClick={() => this.setState({ hasError: false, error: null, errorInfo: null })}

View File

@ -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.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.GABLE || lineName === LINE_TYPE.SUBLINE.GABLE) return 'G'
if (name === LINE_TYPE.SUBLINE.VERGE || lineName === LINE_TYPE.SUBLINE.VERGE) return 'VG' if (name === LINE_TYPE.SUBLINE.VERGE || lineName === LINE_TYPE.SUBLINE.VERGE) return 'VG'
if ( if (type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || name === LINE_TYPE.WALLLINE.EAVE_HELP_LINE)
type === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || return 'E'
lineName === LINE_TYPE.WALLLINE.EAVE_HELP_LINE || if (typeof obj.x1 === 'number' && typeof obj.y1 === 'number' && typeof obj.x2 === 'number' && typeof obj.y2 === 'number') return 'SK'
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 return null
} }
@ -241,7 +233,6 @@ export function reattachRoofFaceDebugLabels(canvas) {
canvas.renderAll() canvas.renderAll()
} }
export const QPolygon = fabric.util.createClass(fabric.Polygon, { export const QPolygon = fabric.util.createClass(fabric.Polygon, {
type: 'QPolygon', type: 'QPolygon',
// lines: [], // lines: [],
@ -421,11 +412,13 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
this.setCoords() this.setCoords()
// 보조선 / 자식 객체들도 같이 이동시킨다 (ROOF 가 아닌 WALL 등에서도 동작하도록) // 보조선 / 자식 객체들도 같이 이동시킨다 (ROOF 가 아닌 WALL 등에서도 동작하도록)
// 단, fabric.Group(도머 등)은 절대 위치 유지해야 하므로 제외
if ((dxModified !== 0 || dyModified !== 0) && this.canvas) { if ((dxModified !== 0 || dyModified !== 0) && this.canvas) {
const directChildren = this.canvas.getObjects().filter((obj) => { const directChildren = this.canvas.getObjects().filter((obj) => {
if (obj === this) return false if (obj === this) return false
if (obj.parentId !== this.id) return false if (obj.parentId !== this.id) return false
if (obj.name === 'lengthText') return false if (obj.name === 'lengthText') return false
if (obj.type === 'group') return false // 도머 등 Group 은 이동 대상에서 제외
return true return true
}) })
const childIds = new Set(directChildren.map((c) => c.id).filter(Boolean)) const childIds = new Set(directChildren.map((c) => c.id).filter(Boolean))
@ -456,6 +449,59 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
movedTextCount++ 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() this.canvas.renderAll()
// ROOF 의 경우 polygonMoved 가 같은 일을 다시 하므로, _preDragLeft 는 거기서 정리됨 // ROOF 의 경우 polygonMoved 가 같은 일을 다시 하므로, _preDragLeft 는 거기서 정리됨
@ -552,6 +598,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
if (obj === this) return false if (obj === this) return false
if (obj.parentId !== this.id) return false if (obj.parentId !== this.id) return false
if (obj.name === 'lengthText') return false // 이미 재생성됨 if (obj.name === 'lengthText') return false // 이미 재생성됨
if (obj.type === 'group') return false // 도머 등 Group 은 절대 위치 유지
return true return true
}) })
@ -593,6 +640,7 @@ export const QPolygon = fabric.util.createClass(fabric.Polygon, {
movedTextCount++ movedTextCount++
} }
}) })
this.canvas.renderAll() 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() 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 // Create a new text object if it doesn't exist
const text = new fabric.Text(__display, { const text = new fabric.Text(__display, {
left: midPoint.x, left: midPoint.x,
top: midPoint.y, top: midPoint.y,

View File

@ -36,7 +36,12 @@ import {
import { sessionStore } from '@/store/commonAtom' import { sessionStore } from '@/store/commonAtom'
import { outerLinePointsState } from '@/store/outerLineAtom' import { outerLinePointsState } from '@/store/outerLineAtom'
import { appMessageStore, globalLocaleStore } from '@/store/localeAtom' 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 { placementShapeDrawingPointsState } from '@/store/placementShapeDrawingAtom'
import { commonUtilsState } from '@/store/commonUtilsAtom' import { commonUtilsState } from '@/store/commonUtilsAtom'
import { menusState } from '@/store/menuAtom' import { menusState } from '@/store/menuAtom'
@ -52,6 +57,7 @@ import { QcastContext } from '@/app/QcastProvider'
import { useRoofFn } from '@/hooks/common/useRoofFn' import { useRoofFn } from '@/hooks/common/useRoofFn'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
import { useTrestle } from '@/hooks/module/useTrestle' import { useTrestle } from '@/hooks/module/useTrestle'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export default function CanvasMenu(props) { export default function CanvasMenu(props) {
const [currentCanvasPlan, setCurrentCanvasPlan] = useRecoilState(currentCanvasPlanState) const [currentCanvasPlan, setCurrentCanvasPlan] = useRecoilState(currentCanvasPlanState)
@ -73,6 +79,7 @@ export default function CanvasMenu(props) {
const { handleZoomClear, handleZoom } = useCanvasEvent() const { handleZoomClear, handleZoom } = useCanvasEvent()
const { setAllModuleSurfaceIsComplete, isAllComplete } = useTrestle() const { setAllModuleSurfaceIsComplete, isAllComplete } = useTrestle()
const { handleMenu } = useMenu() const { handleMenu } = useMenu()
const { undo, redo, canUndo, canRedo } = useUndoRedo()
// const urlParams = useSearchParams() // const urlParams = useSearchParams()
const { handleEstimateSubmit, fetchSetting, estimateContextState, setEstimateContextState } = useEstimateController() const { handleEstimateSubmit, fetchSetting, estimateContextState, setEstimateContextState } = useEstimateController()
const estimateRecoilState = useRecoilValue(estimateState) const estimateRecoilState = useRecoilValue(estimateState)
@ -600,6 +607,20 @@ export default function CanvasMenu(props) {
<div className="canvas-side-btn-wrap"> <div className="canvas-side-btn-wrap">
{!['estimate', 'simulation'].some((num) => num === selectedMenu) && ( {!['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' : ''}`}> <div className={`vertical-horizontal ${verticalHorizontalMode ? 'on' : ''}`}>
<span>{getMessage('plan.mode.vertical.horizontal')}</span> <span>{getMessage('plan.mode.vertical.horizontal')}</span>

View File

@ -9,6 +9,7 @@ import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
import { subMenusState } from '@/store/menuAtom' import { subMenusState } from '@/store/menuAtom'
import { useCanvasMenu } from '@/hooks/common/useCanvasMenu' import { useCanvasMenu } from '@/hooks/common/useCanvasMenu'
import { commonUtilsState } from '@/store/commonUtilsAtom' import { commonUtilsState } from '@/store/commonUtilsAtom'
import { isUndoRedoRestoring } from '@/hooks/useUndoRedo'
export default function MenuDepth01() { export default function MenuDepth01() {
const canvas = useRecoilValue(canvasState) const canvas = useRecoilValue(canvasState)
@ -29,6 +30,10 @@ export default function MenuDepth01() {
} }
useEffect(() => { useEffect(() => {
// undo/redo .
// setDrawing(true)
// redo .
if (isUndoRedoRestoring()) return
handleMenu(selectedMenu) handleMenu(selectedMenu)
canvas?.discardActiveObject() canvas?.discardActiveObject()
}, [currentMenu]) }, [currentMenu])

View File

@ -10,6 +10,7 @@ import { useAuxiliaryDrawing } from '@/hooks/roofcover/useAuxiliaryDrawing'
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
import { normalizeDigits } from '@/util/input-utils' import { normalizeDigits } from '@/util/input-utils'
import { CalculatorInput } from '@/components/common/input/CalcInput' import { CalculatorInput } from '@/components/common/input/CalcInput'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export default function AuxiliaryEdit(props) { export default function AuxiliaryEdit(props) {
const contextPopupPosition = useRecoilValue(contextPopupPositionState) const contextPopupPosition = useRecoilValue(contextPopupPositionState)
@ -23,8 +24,10 @@ export default function AuxiliaryEdit(props) {
const [arrow2, setArrow2] = useState(null) const [arrow2, setArrow2] = useState(null)
const currentObject = useRecoilValue(currentObjectState) const currentObject = useRecoilValue(currentObjectState)
const { swalFire } = useSwal() const { swalFire } = useSwal()
const { saveSnapshot } = useUndoRedo()
const [useCalcPad, setUseCalcPad] = useState(false) const [useCalcPad, setUseCalcPad] = useState(false)
const disableKeypad = !useCalcPad const disableKeypad = !useCalcPad
const handleSave = () => { const handleSave = () => {
if ((!arrow1 && !arrow2) || (+verticalSize === 0 && +horizonSize === 0)) { if ((!arrow1 && !arrow2) || (+verticalSize === 0 && +horizonSize === 0)) {
swalFire({ title: getMessage('length.direction.is.required'), type: 'alert' }) swalFire({ title: getMessage('length.direction.is.required'), type: 'alert' })
@ -41,6 +44,7 @@ export default function AuxiliaryEdit(props) {
return return
} }
saveSnapshot()
if (type === 'copy') { if (type === 'copy') {
if (currentObject) { if (currentObject) {
copy( copy(
@ -81,7 +85,7 @@ export default function AuxiliaryEdit(props) {
disableKeypad={disableKeypad} disableKeypad={disableKeypad}
options={{ options={{
allowNegative: false, allowNegative: false,
allowDecimal: false allowDecimal: false,
}} }}
/> />
</div> </div>
@ -91,14 +95,12 @@ export default function AuxiliaryEdit(props) {
className={`direction up ${arrow1 === '↑' ? 'act' : ''}`} className={`direction up ${arrow1 === '↑' ? 'act' : ''}`}
onClick={() => { onClick={() => {
setArrow1('↑') setArrow1('↑')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
}} }}
></button> ></button>
<button <button
className={`direction down ${arrow1 === '↓' ? 'act' : ''}`} className={`direction down ${arrow1 === '↓' ? 'act' : ''}`}
onClick={() => { onClick={() => {
setArrow1('↓') setArrow1('↓')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' }))
}} }}
></button> ></button>
</div> </div>
@ -116,7 +118,7 @@ export default function AuxiliaryEdit(props) {
disableKeypad={disableKeypad} disableKeypad={disableKeypad}
options={{ options={{
allowNegative: false, allowNegative: false,
allowDecimal: false allowDecimal: false,
}} }}
/> />
</div> </div>
@ -126,14 +128,12 @@ export default function AuxiliaryEdit(props) {
className={`direction left ${arrow2 === '←' ? 'act' : ''}`} className={`direction left ${arrow2 === '←' ? 'act' : ''}`}
onClick={() => { onClick={() => {
setArrow2('←') setArrow2('←')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }))
}} }}
></button> ></button>
<button <button
className={`direction right ${arrow2 === '→' ? 'act' : ''}`} className={`direction right ${arrow2 === '→' ? 'act' : ''}`}
onClick={() => { onClick={() => {
setArrow2('→') setArrow2('→')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }))
}} }}
></button> ></button>
</div> </div>
@ -143,7 +143,12 @@ export default function AuxiliaryEdit(props) {
</div> </div>
<div className="grid-btn-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '5px' }}> <div className="grid-btn-wrap" style={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: '5px' }}>
<button style={{ width: 'auto' }} onClick={() => setUseCalcPad((prev) => !prev)}> <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>
<button className="btn-frame modal act" onClick={handleSave}> <button className="btn-frame modal act" onClick={handleSave}>
{getMessage('modal.common.save')} {getMessage('modal.common.save')}

View File

@ -8,7 +8,9 @@ import { useEffect, useState } from 'react'
import Image from 'next/image' import Image from 'next/image'
import Big from 'big.js' import Big from 'big.js'
import { calcLineActualSize, calcLinePlaneSize } from '@/util/qpolygon-utils' import { calcLineActualSize, calcLinePlaneSize } from '@/util/qpolygon-utils'
import { syncOuterBoundaryTwin } from '@/util/canvas-util'
import { normalizeDigits } from '@/util/input-utils' import { normalizeDigits } from '@/util/input-utils'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { CalculatorInput } from '@/components/common/input/CalcInput' import { CalculatorInput } from '@/components/common/input/CalcInput'
export default function AuxiliarySize(props) { export default function AuxiliarySize(props) {
@ -56,7 +58,9 @@ export default function AuxiliarySize(props) {
} }
} }
const { saveSnapshot } = useUndoRedo()
const handleSave = () => { const handleSave = () => {
saveSnapshot()
const { x1, y1, x2, y2 } = currentObject const { x1, y1, x2, y2 } = currentObject
// planeSize actualSize . // planeSize actualSize .
@ -85,15 +89,26 @@ export default function AuxiliarySize(props) {
// . // .
const scaleFactor = Big(size).div(distance) 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) { if (checkedRadio === 1) {
const newX2 = Big(x1).plus(dx.times(scaleFactor)).toNumber() const newBrX = Big(topLeft.x).plus(Big(bottomRight.x).minus(topLeft.x).times(scaleFactor)).toNumber()
const newY2 = Big(y1).plus(dy.times(scaleFactor)).toNumber() const newBrY = Big(topLeft.y).plus(Big(bottomRight.y).minus(topLeft.y).times(scaleFactor)).toNumber()
currentObject.set({ x2: newX2, y2: newY2 }) if (p1IsTopLeft) currentObject.set({ x2: newBrX, y2: newBrY })
else currentObject.set({ x1: newBrX, y1: newBrY })
} else if (checkedRadio === 2) { } else if (checkedRadio === 2) {
const newX1 = Big(x2).minus(dx.times(scaleFactor)).toNumber() const newTlX = Big(bottomRight.x).plus(Big(topLeft.x).minus(bottomRight.x).times(scaleFactor)).toNumber()
const newY1 = Big(y2).minus(dy.times(scaleFactor)).toNumber() const newTlY = Big(bottomRight.y).plus(Big(topLeft.y).minus(bottomRight.y).times(scaleFactor)).toNumber()
currentObject.set({ x1: newX1, y1: newY1 }) if (p1IsTopLeft) currentObject.set({ x1: newTlX, y1: newTlY })
else currentObject.set({ x2: newTlX, y2: newTlY })
} }
//planeSize actualSize . //planeSize actualSize .
currentObject.attributes.planeSize = calcLinePlaneSize({ currentObject.attributes.planeSize = calcLinePlaneSize({
@ -112,6 +127,12 @@ export default function AuxiliarySize(props) {
degree, degree,
) )
currentObject.addLengthText() 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() canvas.renderAll()
} }

View File

@ -11,6 +11,7 @@ import { useModuleSelection } from '@/hooks/module/useModuleSelection'
import { useOrientation } from '@/hooks/module/useOrientation' import { useOrientation } from '@/hooks/module/useOrientation'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
import { usePopup } from '@/hooks/usePopup' import { usePopup } from '@/hooks/usePopup'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { import {
canvasState, canvasState,
checkedModuleState, checkedModuleState,
@ -32,6 +33,7 @@ import Trestle from './step/Trestle'
export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) { export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) {
const { getMessage } = useMessage() const { getMessage } = useMessage()
const { closePopup } = usePopup() const { closePopup } = usePopup()
const { clearStacks } = useUndoRedo()
const [tabNum, setTabNum] = useState(1) const [tabNum, setTabNum] = useState(1)
const orientationRef = useRef(null) const orientationRef = useRef(null)
const [isManualModuleSetup, setIsManualModuleSetup] = useRecoilState(isManualModuleSetupState) const [isManualModuleSetup, setIsManualModuleSetup] = useRecoilState(isManualModuleSetupState)
@ -84,6 +86,11 @@ export default function BasicSetting({ id, pos = { x: 50, y: 230 } }) {
useModuleBasicSetting(tabNum) useModuleBasicSetting(tabNum)
const { updateObjectDate } = useMasterController() const { updateObjectDate } = useMasterController()
// / undo/redo
useEffect(() => {
clearStacks()
}, [])
useEffect(() => { useEffect(() => {
const moduleTabNum = basicSetting.roofSizeSet != 3 ? 3 : 2 const moduleTabNum = basicSetting.roofSizeSet != 3 ? 3 : 2

View File

@ -18,6 +18,7 @@ import { moduleSelectionDataState, selectedModuleState } from '@/store/selectedM
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { useEstimate } from '@/hooks/useEstimate' import { useEstimate } from '@/hooks/useEstimate'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle' import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle'
import { useImgLoader } from '@/hooks/floorPlan/useImgLoader' import { useImgLoader } from '@/hooks/floorPlan/useImgLoader'
import { QcastContext } from '@/app/QcastProvider' import { QcastContext } from '@/app/QcastProvider'
@ -34,6 +35,7 @@ export default function CircuitTrestleSetting({ id }) {
const { apply, setViewCircuitNumberTexts, getEstimateData, clear: clearTrestle, setAllModuleSurfaceIsComplete } = useTrestle() const { apply, setViewCircuitNumberTexts, getEstimateData, clear: clearTrestle, setAllModuleSurfaceIsComplete } = useTrestle()
const { swalFire } = useSwal() const { swalFire } = useSwal()
const { saveEstimate } = useEstimate() const { saveEstimate } = useEstimate()
const { clearStacks } = useUndoRedo()
const canvas = useRecoilValue(canvasState) const canvas = useRecoilValue(canvasState)
const setCurrentMenu = useSetRecoilState(currentMenuState) const setCurrentMenu = useSetRecoilState(currentMenuState)
const [canvasZoom, setCanvasZoom] = useRecoilState(canvasZoomState) const [canvasZoom, setCanvasZoom] = useRecoilState(canvasZoomState)
@ -722,6 +724,7 @@ export default function CircuitTrestleSetting({ id }) {
setIsGlobalLoading(false) setIsGlobalLoading(false)
} }
clearStacks()
// removeNotAllocationModules() // removeNotAllocationModules()
} }

View File

@ -9,6 +9,7 @@ import { canvasState } from '@/store/canvasAtom'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch' import { useSurfaceShapeBatch } from '@/hooks/surface/useSurfaceShapeBatch'
import { useRoofFn } from '@/hooks/common/useRoofFn' import { useRoofFn } from '@/hooks/common/useRoofFn'
import { useUndoRedo } from '@/hooks/useUndoRedo'
const FLOW_DIRECTION_TYPE = { const FLOW_DIRECTION_TYPE = {
EIGHT_AZIMUTH: 'eightAzimuth', EIGHT_AZIMUTH: 'eightAzimuth',
@ -24,6 +25,7 @@ export default function FlowDirectionSetting(props) {
const { setPolygonLinesActualSize } = usePolygon() const { setPolygonLinesActualSize } = usePolygon()
const { changeSurfaceLineType } = useSurfaceShapeBatch({}) const { changeSurfaceLineType } = useSurfaceShapeBatch({})
const { saveSnapshot } = useUndoRedo()
useEffect(() => { useEffect(() => {
return () => { return () => {
@ -77,6 +79,7 @@ export default function FlowDirectionSetting(props) {
const [type, setType] = useState(target.surfaceCompassType ?? FLOW_DIRECTION_TYPE.EIGHT_AZIMUTH) const [type, setType] = useState(target.surfaceCompassType ?? FLOW_DIRECTION_TYPE.EIGHT_AZIMUTH)
const changeSurfaceFlowDirection = (roof, direction, orientation) => { const changeSurfaceFlowDirection = (roof, direction, orientation) => {
saveSnapshot()
roof.set({ roof.set({
direction: direction, direction: direction,
surfaceCompass: orientation, surfaceCompass: orientation,

View File

@ -8,7 +8,6 @@ import { canvasState } from '@/store/canvasAtom'
import { POLYGON_TYPE } from '@/common/common' import { POLYGON_TYPE } from '@/common/common'
import { useModule } from '@/hooks/module/useModule' import { useModule } from '@/hooks/module/useModule'
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
export const PANEL_EDIT_TYPE = { export const PANEL_EDIT_TYPE = {
MOVE: 'move', MOVE: 'move',
MOVE_ALL: 'moveAll', MOVE_ALL: 'moveAll',

View File

@ -1,3 +1,4 @@
import { useUndoRedo } from '@/hooks/useUndoRedo'
import WithDraggable from '@/components/common/draggable/WithDraggable' import WithDraggable from '@/components/common/draggable/WithDraggable'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { contextPopupPositionState } from '@/store/popupAtom' import { contextPopupPositionState } from '@/store/popupAtom'
@ -14,7 +15,9 @@ export default function ColumnInsert(props) {
const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.LEFT) const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.LEFT)
const { getMessage } = useMessage() const { getMessage } = useMessage()
const { moduleColumnInsert } = useModule() const { moduleColumnInsert } = useModule()
const { saveSnapshot } = useUndoRedo()
const handleApply = () => { const handleApply = () => {
saveSnapshot()
moduleColumnInsert(selectedType) moduleColumnInsert(selectedType)
closePopup(id) closePopup(id)
} }

View File

@ -1,3 +1,4 @@
import { useUndoRedo } from '@/hooks/useUndoRedo'
import WithDraggable from '@/components/common/draggable/WithDraggable' import WithDraggable from '@/components/common/draggable/WithDraggable'
import Image from 'next/image' import Image from 'next/image'
import { useState } from 'react' import { useState } from 'react'
@ -14,7 +15,9 @@ export default function RowInsert(props) {
const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.TOP) const [selectedType, setSelectedType] = useState(MODULE_INSERT_TYPE.TOP)
const { getMessage } = useMessage() const { getMessage } = useMessage()
const { muduleRowInsert } = useModule() const { muduleRowInsert } = useModule()
const { saveSnapshot } = useUndoRedo()
const handleApply = () => { const handleApply = () => {
saveSnapshot()
muduleRowInsert(selectedType) muduleRowInsert(selectedType)
closePopup(id) closePopup(id)
} }

View File

@ -21,7 +21,6 @@ export default function RowRemove(props) {
{ name: getMessage('modal.row.remove.type.none'), value: MODULE_REMOVE_TYPE.NONE }, { name: getMessage('modal.row.remove.type.none'), value: MODULE_REMOVE_TYPE.NONE },
] ]
const handleApply = () => { const handleApply = () => {
// if (apply) apply()
moduleRowRemove(selectedType) moduleRowRemove(selectedType)
closePopup(id) closePopup(id)
} }

View File

@ -16,7 +16,7 @@ import { useSwal } from '@/hooks/useSwal'
export default function SizeSetting(props) { export default function SizeSetting(props) {
const contextPopupPosition = useRecoilValue(contextPopupPositionState) const contextPopupPosition = useRecoilValue(contextPopupPositionState)
const [settingTarget, setSettingTarget] = useState(props.side || 1) 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 { getMessage } = useMessage()
const { closePopup } = usePopup() const { closePopup } = usePopup()
const { resizeObjectBatch } = useObjectBatch({}) const { resizeObjectBatch } = useObjectBatch({})
@ -58,7 +58,7 @@ export default function SizeSetting(props) {
} }
if (target.name === BATCH_TYPE.OPENING || target.name === BATCH_TYPE.SHADOW) { 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) { } else if (target.name === POLYGON_TYPE.ROOF) {
resizeSurfaceShapeBatch(settingTarget, target, width, height, id) resizeSurfaceShapeBatch(settingTarget, target, width, height, id)
} }

View File

@ -13,6 +13,7 @@ import { addedRoofsState, roofDisplaySelector, roofMaterialsAtom } from '@/store
import { useCommonCode } from '@/hooks/common/useCommonCode' import { useCommonCode } from '@/hooks/common/useCommonCode'
import QSelectBox from '@/components/common/select/QSelectBox' import QSelectBox from '@/components/common/select/QSelectBox'
import { globalLocaleStore } from '@/store/localeAtom' import { globalLocaleStore } from '@/store/localeAtom'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util' import { getChonByDegree, getDegreeByChon } from '@/util/canvas-util'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
@ -72,6 +73,7 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
/** usePopup에서 closePopup 함수 가져오기 */ /** usePopup에서 closePopup 함수 가져오기 */
const { drawDirectionArrow } = usePolygon() const { drawDirectionArrow } = usePolygon()
const { setSurfaceShapePattern } = useRoofFn() const { setSurfaceShapePattern } = useRoofFn()
const { clearStacks } = useUndoRedo()
const canvas = useRecoilValue(canvasState) const canvas = useRecoilValue(canvasState)
const [canvasSetting, setCanvasSetting] = useRecoilState(canvasSettingState) const [canvasSetting, setCanvasSetting] = useRecoilState(canvasSettingState)
const currentCanvasPlan = useRecoilValue(currentCanvasPlanState) const currentCanvasPlan = useRecoilValue(currentCanvasPlanState)
@ -442,6 +444,9 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla
/* 저장 후 화면 닫기 */ /* 저장 후 화면 닫기 */
closePopup(id) closePopup(id)
await saveCanvas(false) await saveCanvas(false)
// undo/redo stack
clearStacks()
} }
return ( return (

View File

@ -242,7 +242,9 @@ export function useCanvasConfigInitialize() {
obj.type === 'QLine' && obj.type === 'QLine' &&
obj.attributes?.type !== 'pitchSizeLine' && obj.attributes?.type !== 'pitchSizeLine' &&
obj.attributes?.roofId === roof.id && obj.attributes?.roofId === roof.id &&
innerLineTypes.includes(obj.name), // [보조선 복원 fix] 저장→불러오기 후 지붕면 할당이 보조선을 쓰려면 auxiliaryLine 도 innerLines 에
// 재연결해야 한다. 기존엔 SUBLINE(hip/ridge) 만 포함해 보조선이 빠져, 불러온 직후 할당이 실패했다.
(innerLineTypes.includes(obj.name) || obj.name === 'auxiliaryLine'),
) )
}) })
} }

View File

@ -14,6 +14,7 @@ import { useObjectBatch } from '@/hooks/object/useObjectBatch'
import { BATCH_TYPE } from '@/common/common' import { BATCH_TYPE } from '@/common/common'
import { useMouse } from '@/hooks/useMouse' import { useMouse } from '@/hooks/useMouse'
import { QPolygon } from '@/components/fabric/QPolygon' import { QPolygon } from '@/components/fabric/QPolygon'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useCommonUtils() { export function useCommonUtils() {
const canvas = useRecoilValue(canvasState) const canvas = useRecoilValue(canvasState)
@ -28,6 +29,7 @@ export function useCommonUtils() {
const { drawDirectionArrow, addLengthText } = usePolygon() const { drawDirectionArrow, addLengthText } = usePolygon()
const { applyDormers } = useObjectBatch({}) const { applyDormers } = useObjectBatch({})
const { getIntersectMousePoint } = useMouse() const { getIntersectMousePoint } = useMouse()
const { saveSnapshot } = useUndoRedo()
useEffect(() => { useEffect(() => {
commonTextMode() commonTextMode()
@ -556,6 +558,12 @@ export function useCommonUtils() {
if (object.id) { if (object.id) {
const group = canvas.getObjects().filter((obj) => obj.id === object.id) const group = canvas.getObjects().filter((obj) => obj.id === object.id)
group.forEach((obj) => canvas?.remove(obj)) 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') { if (object.type === 'group') {
@ -617,7 +625,8 @@ export function useCommonUtils() {
} }
const buttonAct = dormerName == BATCH_TYPE.TRIANGLE_DORMER ? 3 : 4 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') { } else if (obj.name === 'roof' && obj.type === 'QPolygon') {
// roof(QPolygon) 객체는 순환 참조(lines[].parent -> polygon)로 인해 // roof(QPolygon) 객체는 순환 참조(lines[].parent -> polygon)로 인해
// fabric.clone() 사용 시 Maximum call stack size exceeded 에러 발생 // fabric.clone() 사용 시 Maximum call stack size exceeded 에러 발생
@ -834,6 +843,7 @@ export function useCommonUtils() {
} }
const deleteObject = () => { const deleteObject = () => {
saveSnapshot()
const selectedObj = canvas?.getActiveObjects() const selectedObj = canvas?.getActiveObjects()
if (selectedObj) { if (selectedObj) {
selectedObj.forEach((obj) => { selectedObj.forEach((obj) => {
@ -863,6 +873,7 @@ export function useCommonUtils() {
} }
const copyObject = () => { const copyObject = () => {
saveSnapshot()
const obj = canvas?.getActiveObject() const obj = canvas?.getActiveObject()
commonCopyObject(obj) commonCopyObject(obj)
} }

View File

@ -21,6 +21,7 @@ import { MENU } from '@/common/common'
import { useTrestle } from '@/hooks/module/useTrestle' import { useTrestle } from '@/hooks/module/useTrestle'
import { useOrientation } from '@/hooks/module/useOrientation' import { useOrientation } from '@/hooks/module/useOrientation'
import { corridorDimensionSelector } from '@/store/settingAtom' 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 { clear: trestleClear, setAllModuleSurfaceIsComplete } = useTrestle()
const { nextStep } = useOrientation() const { nextStep } = useOrientation()
const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector) const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector)
const { saveSnapshot } = useUndoRedo()
const handleMenu = (type) => { const handleMenu = (type) => {
closeAll() closeAll()
if (type === 'outline') { if (type === 'outline') {
@ -67,6 +69,8 @@ export default function useMenu() {
addPopup(popupId, 1, <RoofAllocationSetting id={popupId} />) addPopup(popupId, 1, <RoofAllocationSetting id={popupId} />)
break break
case MENU.ROOF_COVERING.ALL_REMOVE: case MENU.ROOF_COVERING.ALL_REMOVE:
// undo/redo 복원 중에는 confirm 창을 띄우지 않는다
if (isUndoRedoRestoring()) break
deleteAllSurfacesAndObjects() deleteAllSurfacesAndObjects()
break break
} }
@ -89,6 +93,8 @@ export default function useMenu() {
addPopup(popupId, 1, <ObjectSetting id={popupId} />) addPopup(popupId, 1, <ObjectSetting id={popupId} />)
break break
case MENU.BATCH_CANVAS.ALL_REMOVE: case MENU.BATCH_CANVAS.ALL_REMOVE:
// undo/redo 복원 중에는 confirm 창을 띄우지 않는다
if (isUndoRedoRestoring()) break
deleteAllSurfacesAndObjects() deleteAllSurfacesAndObjects()
break break
} }

View File

@ -8,6 +8,7 @@ import { useLine } from '@/hooks/useLine'
import { outerLinePointsState } from '@/store/outerLineAtom' import { outerLinePointsState } from '@/store/outerLineAtom'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
import { useText } from '@/hooks/useText' import { useText } from '@/hooks/useText'
import { useUndoRedo } from '@/hooks/useUndoRedo'
const ROOF_COLOR = { const ROOF_COLOR = {
0: 'rgb(199,240,213)', 0: 'rgb(199,240,213)',
@ -26,6 +27,7 @@ export function useRoofFn() {
const { setPolygonLinesActualSize } = usePolygon() const { setPolygonLinesActualSize } = usePolygon()
const { changeCorridorDimensionText } = useText() const { changeCorridorDimensionText } = useText()
const [outerLinePoints, setOuterLinePoints] = useRecoilState(outerLinePointsState) const [outerLinePoints, setOuterLinePoints] = useRecoilState(outerLinePointsState)
const { saveSnapshot } = useUndoRedo()
//면형상 선택 클릭시 지붕 패턴 입히기 //면형상 선택 클릭시 지붕 패턴 입히기
function setSurfaceShapePattern(polygon, mode = 'onlyBorder', trestleMode = false, roofMaterial, isForceChange = false, isDisplay = false) { function setSurfaceShapePattern(polygon, mode = 'onlyBorder', trestleMode = false, roofMaterial, isForceChange = false, isDisplay = false) {
@ -248,6 +250,9 @@ export function useRoofFn() {
}) })
canvas.on('mouse:down', (event) => { canvas.on('mouse:down', (event) => {
// checkPolygon 을 캔버스에서 제거한 뒤 스냅샷을 찍어야 빨간 사각형이 포함되지 않음
canvas.remove(checkPolygon)
saveSnapshot()
let mousePos = canvas.getPointer(event.e) let mousePos = canvas.getPointer(event.e)
mousePos = { x: Math.round(mousePos.x), y: Math.round(mousePos.y) } mousePos = { x: Math.round(mousePos.x), y: Math.round(mousePos.y) }
@ -275,6 +280,12 @@ export function useRoofFn() {
originY: 'center', originY: 'center',
objectCaching: false, 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('polygonMoved')
roof.fire('modified') roof.fire('modified')

View File

@ -3,7 +3,6 @@ import { QPolygon } from '@/components/fabric/QPolygon'
import { canvasState } from '@/store/canvasAtom' import { canvasState } from '@/store/canvasAtom'
import { polygonToTurfPolygon } from '@/util/canvas-util' import { polygonToTurfPolygon } from '@/util/canvas-util'
import { useRecoilValue } from 'recoil' import { useRecoilValue } from 'recoil'
import { v4 as uuidv4 } from 'uuid'
import * as turf from '@turf/turf' import * as turf from '@turf/turf'
import { useSwal } from '../useSwal' import { useSwal } from '../useSwal'
import { useModuleBasicSetting } from './useModuleBasicSetting' import { useModuleBasicSetting } from './useModuleBasicSetting'
@ -12,6 +11,7 @@ import { selectedModuleState } from '@/store/selectedModuleOptions'
import { useCircuitTrestle } from '../useCirCuitTrestle' import { useCircuitTrestle } from '../useCirCuitTrestle'
import { useTrestle } from './useTrestle' import { useTrestle } from './useTrestle'
import { useTurf } from '@/hooks/common/useTurf' import { useTurf } from '@/hooks/common/useTurf'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
// [MODULE-NAN-DIAG 2026-05-18] NaN 좌표가 어디서 module 로 진입하는지 추적용. // [MODULE-NAN-DIAG 2026-05-18] NaN 좌표가 어디서 module 로 진입하는지 추적용.
@ -62,6 +62,7 @@ export function useModule() {
const { setModuleStatisticsData, resetCircuits } = useCircuitTrestle() const { setModuleStatisticsData, resetCircuits } = useCircuitTrestle()
const { clear: removeTrestleMaterials } = useTrestle() const { clear: removeTrestleMaterials } = useTrestle()
const { checkModuleDisjointSurface } = useTurf() const { checkModuleDisjointSurface } = useTurf()
const { saveSnapshot } = useUndoRedo()
// clone() 대신 직접 새 QPolygon을 생성하여 Maximum call stack 방지 // clone() 대신 직접 새 QPolygon을 생성하여 Maximum call stack 방지
const createModuleCopy = (module, newLeft, newTop, overrides = {}) => { const createModuleCopy = (module, newLeft, newTop, overrides = {}) => {
@ -121,6 +122,7 @@ export function useModule() {
}) })
return return
} }
saveSnapshot()
// canvas.discardActiveObject() //선택해제 // canvas.discardActiveObject() //선택해제
const isSetupModules = getOtherModules(selectedObj) const isSetupModules = getOtherModules(selectedObj)
@ -200,6 +202,7 @@ export function useModule() {
removeTrestleMaterials() removeTrestleMaterials()
} }
saveSnapshot()
const modules = type === 'row' ? getRowModules(activeModule) : getColumnModules(activeModule) const modules = type === 'row' ? getRowModules(activeModule) : getColumnModules(activeModule)
const otherModules = getOtherModules(modules) const otherModules = getOtherModules(modules)
const objects = getObjects() const objects = getObjects()
@ -250,6 +253,7 @@ export function useModule() {
} }
const moduleMoveAll = (length, direction, surfaceArray) => { const moduleMoveAll = (length, direction, surfaceArray) => {
saveSnapshot()
surfaceArray.forEach((surface) => { surfaceArray.forEach((surface) => {
const modules = canvas const modules = canvas
.getObjects() .getObjects()
@ -301,6 +305,7 @@ export function useModule() {
} }
const moduleCopyAll = (length, direction, surfaceArray) => { const moduleCopyAll = (length, direction, surfaceArray) => {
saveSnapshot()
surfaceArray.forEach((surface) => { surfaceArray.forEach((surface) => {
const modules = canvas const modules = canvas
.getObjects() .getObjects()
@ -356,6 +361,7 @@ export function useModule() {
const moduleCopy = (length, direction) => { const moduleCopy = (length, direction) => {
if (canvas.getActiveObjects().length === 0) return if (canvas.getActiveObjects().length === 0) return
saveSnapshot()
const activeModuleIds = canvas.getActiveObjects().map((obj) => obj.id) const activeModuleIds = canvas.getActiveObjects().map((obj) => obj.id)
const modules = canvas.getObjects().filter((obj) => activeModuleIds.includes(obj.id)) const modules = canvas.getObjects().filter((obj) => activeModuleIds.includes(obj.id))
const objects = getObjects() const objects = getObjects()
@ -428,6 +434,7 @@ export function useModule() {
removeTrestleMaterials() removeTrestleMaterials()
} }
saveSnapshot()
const modules = type === 'row' ? getRowModules(activeModule) : getColumnModules(activeModule) 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 otherModules = canvas.getObjects().filter((obj) => obj.surfaceId === modules[0].surfaceId && obj.name === POLYGON_TYPE.MODULE)
const objects = getObjects() const objects = getObjects()
@ -500,6 +507,7 @@ export function useModule() {
if (surfaces.some((surface) => surface.isComplete)) { if (surfaces.some((surface) => surface.isComplete)) {
removeTrestleMaterials() removeTrestleMaterials()
} }
saveSnapshot()
canvas.discardActiveObject() canvas.discardActiveObject()
moduleSetupSurface.set({ modules: otherModules }) moduleSetupSurface.set({ modules: otherModules })
canvas.remove(...columnModules) canvas.remove(...columnModules)
@ -634,6 +642,7 @@ export function useModule() {
if (surfaces.some((surface) => surface.isComplete)) { if (surfaces.some((surface) => surface.isComplete)) {
removeTrestleMaterials() removeTrestleMaterials()
} }
saveSnapshot()
canvas.discardActiveObject() canvas.discardActiveObject()
moduleSetupSurface.set({ modules: otherModules }) moduleSetupSurface.set({ modules: otherModules })
canvas.remove(...rowModules) canvas.remove(...rowModules)
@ -994,10 +1003,12 @@ export function useModule() {
return 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 surfaceIds = [...new Set(activeModules.map((module) => module.surfaceId))]
const affectedSurfaces = canvas const affectedSurfaces = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && surfaceIds.includes(obj.id))
.getObjects()
.filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE && surfaceIds.includes(obj.id))
if (affectedSurfaces.some((surface) => surface.isComplete)) { if (affectedSurfaces.some((surface) => surface.isComplete)) {
removeTrestleMaterials() removeTrestleMaterials()

View File

@ -1,5 +1,5 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' import { useRecoilState, useRecoilValue, useResetRecoilState, useSetRecoilState } from 'recoil'
import { import {
canvasSettingState, canvasSettingState,
@ -33,18 +33,21 @@ import { isObjectNotEmpty } from '@/util/common-utils'
import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle' import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle'
import { useMode } from '@/hooks/useMode' import { useMode } from '@/hooks/useMode'
import { useTurf } from '@/hooks/common/useTurf' import { useTurf } from '@/hooks/common/useTurf'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useModuleBasicSetting(tabNum) { export function useModuleBasicSetting(tabNum) {
const canvas = useRecoilValue(canvasState) const canvas = useRecoilValue(canvasState)
const { getMessage } = useMessage() const { getMessage } = useMessage()
const roofDisplay = useRecoilValue(roofDisplaySelector) const roofDisplay = useRecoilValue(roofDisplaySelector)
const [moduleSetupSurface, setModuleSetupSurface] = useRecoilState(moduleSetupSurfaceState) const [moduleSetupSurface, setModuleSetupSurface] = useRecoilState(moduleSetupSurfaceState)
const resetModuleSetupSurface = useResetRecoilState(moduleSetupSurfaceState)
const { addCanvasMouseEventListener, initEvent, removeMouseEvent, addTargetMouseEventListener } = useEvent() const { addCanvasMouseEventListener, initEvent, removeMouseEvent, addTargetMouseEventListener } = useEvent()
const { swalFire } = useSwal() const { swalFire } = useSwal()
const compasDeg = useRecoilValue(compasDegAtom) const compasDeg = useRecoilValue(compasDegAtom)
const { setSurfaceShapePattern } = useRoofFn() const { setSurfaceShapePattern } = useRoofFn()
const checkedModule = useRecoilValue(checkedModuleState) const checkedModule = useRecoilValue(checkedModuleState)
const [isManualModuleSetup, setIsManualModuleSetup] = useRecoilState(isManualModuleSetupState) const [isManualModuleSetup, setIsManualModuleSetup] = useRecoilState(isManualModuleSetupState)
const [isManualModuleLayoutSetup, setIsManualModuleLayoutSetup] = useRecoilState(isManualModuleLayoutSetupState) const [isManualModuleLayoutSetup, setIsManualModuleLayoutSetup] = useRecoilState(isManualModuleLayoutSetupState)
const canvasSetting = useRecoilValue(canvasSettingState) const canvasSetting = useRecoilValue(canvasSettingState)
const moduleSelectionData = useRecoilValue(moduleSelectionDataState) const moduleSelectionData = useRecoilValue(moduleSelectionDataState)
@ -62,9 +65,11 @@ export function useModuleBasicSetting(tabNum) {
const setModuleRowColArray = useSetRecoilState(moduleRowColArrayState) const setModuleRowColArray = useSetRecoilState(moduleRowColArrayState)
const { checkModuleDisjointSurface } = useTurf() const { checkModuleDisjointSurface } = useTurf()
const { saveSnapshot } = useUndoRedo()
useEffect(() => { useEffect(() => {
initEvent() initEvent()
resetModuleSetupSurface()
return () => { return () => {
//수동 설치시 초기화 //수동 설치시 초기화
removeMouseEvent('mouse:up') removeMouseEvent('mouse:up')
@ -585,7 +590,7 @@ export function useModuleBasicSetting(tabNum) {
*/ */
const manualModuleSetup = () => { 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 const isChidori = moduleSetupOption.isChidori
if (isManualModuleSetup) { if (isManualModuleSetup) {
@ -641,6 +646,9 @@ export function useModuleBasicSetting(tabNum) {
//마우스 이벤트 삭제 후 재추가 //마우스 이벤트 삭제 후 재추가
const mousePoint = canvas.getPointer(e.e) 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++) { for (let i = 0; i < moduleSetupSurfaces.length; i++) {
//배치면이 여러개 일때 옮겨가면서 동작해야함 //배치면이 여러개 일때 옮겨가면서 동작해야함
const moduleSetupSurface = moduleSetupSurfaces[i] const moduleSetupSurface = moduleSetupSurfaces[i]
@ -931,8 +939,10 @@ export function useModuleBasicSetting(tabNum) {
addCanvasMouseEventListener('mouse:up', (e) => { addCanvasMouseEventListener('mouse:up', (e) => {
let isIntersection = true let isIntersection = true
if (!trestlePolygon) return
// 혼합 설치 불가능한 모듈인지 확인 Y는 Y끼리 N은 N끼리만 설치 가능 // 혼합 설치 불가능한 모듈인지 확인 Y는 Y끼리 N은 N끼리만 설치 가능
if (trestlePolygon.modules.length > 0) { if (trestlePolygon.modules && trestlePolygon.modules.length > 0) {
//이미 설치된 모듈중에 한개만 가져옴 //이미 설치된 모듈중에 한개만 가져옴
const mixAsgYn = trestlePolygon.modules[0].moduleInfo.mixAsgYn const mixAsgYn = trestlePolygon.modules[0].moduleInfo.mixAsgYn
//현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능 //현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능
@ -965,10 +975,10 @@ export function useModuleBasicSetting(tabNum) {
tempModule.set({ points: rectPoints }) tempModule.set({ points: rectPoints })
const tempTurfModule = polygonToTurfPolygon(tempModule) const tempTurfModule = polygonToTurfPolygon(tempModule)
//도머 객체를 가져옴 (undo 후 캔버스 객체가 교체될 수 있으므로 다시 가져옴)
//도머 객체를 가져옴 const currentBatchObjects = canvas?.getObjects().filter((obj) => obj.name === POLYGON_TYPE.OBJECT_SURFACE)
if (batchObjects) { if (currentBatchObjects) {
batchObjects.forEach((object) => { currentBatchObjects.forEach((object) => {
// [OPENING-MIN-SIZE 2026-05-08] 0-면적 객체는 turf 변환 시 throw 위험 → skip // [OPENING-MIN-SIZE 2026-05-08] 0-면적 객체는 turf 변환 시 throw 위험 → skip
const _pts = object.getCurrentPoints?.() || object.points || [] const _pts = object.getCurrentPoints?.() || object.points || []
if (_pts.length >= 2 && _pts.every((p) => p.x === _pts[0].x && p.y === _pts[0].y)) { 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, toFixed: 2,
}) })
saveSnapshot()
canvas?.add(manualModule) canvas?.add(manualModule)
canvas.bringToFront(manualModule) canvas.bringToFront(manualModule)
manualDrawModules.push(manualModule) manualDrawModules.push(manualModule)
@ -1050,7 +1061,7 @@ export function useModuleBasicSetting(tabNum) {
//단 열수 지정배치 //단 열수 지정배치
const manualModuleLayoutSetup = (layoutSetupRef) => { 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 isChidori = moduleSetupOption.isChidori //치도리 여부
const setupLocation = moduleSetupOption.setupLocation //모듈 설치 위치 처마, 용마루 const setupLocation = moduleSetupOption.setupLocation //모듈 설치 위치 처마, 용마루
@ -1148,6 +1159,9 @@ export function useModuleBasicSetting(tabNum) {
//마우스 이벤트 삭제 후 재추가 //마우스 이벤트 삭제 후 재추가
const mousePoint = canvas.getPointer(e.e) 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++) { for (let i = 0; i < moduleSetupSurfaces.length; i++) {
//배치면이 여러개 일때 옮겨가면서 동작해야함 //배치면이 여러개 일때 옮겨가면서 동작해야함
const moduleSetupSurface = moduleSetupSurfaces[i] const moduleSetupSurface = moduleSetupSurfaces[i]
@ -1425,7 +1439,9 @@ export function useModuleBasicSetting(tabNum) {
}) })
addCanvasMouseEventListener('mouse:up', (e) => { 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 const mixAsgYn = trestlePolygon.modules[0].moduleInfo.mixAsgYn
//현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능 //현재 체크된 모듈기준으로 혼합가능인지 확인 Y === Y, N === N 일때만 설치 가능
@ -1479,6 +1495,7 @@ export function useModuleBasicSetting(tabNum) {
} }
if (tempModule) { if (tempModule) {
saveSnapshot()
let startX, startY let startX, startY
let installedLastHeightCoord = 0 //마지막으로 설치된 모듈의 좌표 let installedLastHeightCoord = 0 //마지막으로 설치된 모듈의 좌표
let installedHeightModuleCount = 0 let installedHeightModuleCount = 0
@ -2930,6 +2947,7 @@ export function useModuleBasicSetting(tabNum) {
return orderA - orderB return orderA - orderB
}) })
saveSnapshot()
sortedModuleSetupSurfaces.forEach((moduleSetupSurface, index) => { sortedModuleSetupSurfaces.forEach((moduleSetupSurface, index) => {
moduleSetupSurface.fire('mousedown') moduleSetupSurface.fire('mousedown')
const moduleSetupArray = [] const moduleSetupArray = []
@ -3764,6 +3782,7 @@ export function useModuleBasicSetting(tabNum) {
canvas?.add(manualModule) canvas?.add(manualModule)
manualDrawModules.push(manualModule) manualDrawModules.push(manualModule)
setModuleStatisticsData() setModuleStatisticsData()
saveSnapshot()
} else { } else {
swalFire({ text: getMessage('module.place.overlab'), icon: 'warning' }) swalFire({ text: getMessage('module.place.overlab'), icon: 'warning' })
} }
@ -3775,6 +3794,7 @@ export function useModuleBasicSetting(tabNum) {
} }
// getModuleStatistics() // getModuleStatistics()
setModuleStatisticsData() setModuleStatisticsData()
saveSnapshot()
} else { } else {
if (moduleSetupSurfaces) { if (moduleSetupSurfaces) {
//수동모드 해제시 모듈 설치면 선택 잠금 //수동모드 해제시 모듈 설치면 선택 잠금
@ -4083,6 +4103,7 @@ export function useModuleBasicSetting(tabNum) {
}) })
} }
saveSnapshot()
moduleSetupSurfaces.forEach((moduleSetupSurface, index) => { moduleSetupSurfaces.forEach((moduleSetupSurface, index) => {
moduleSetupSurface.fire('mousedown') moduleSetupSurface.fire('mousedown')
const moduleSetupArray = [] const moduleSetupArray = []

View File

@ -25,6 +25,7 @@ import { usePopup } from '@/hooks/usePopup'
import { useMouse } from '@/hooks/useMouse' import { useMouse } from '@/hooks/useMouse'
import { useTurf } from '@/hooks/common/useTurf' import { useTurf } from '@/hooks/common/useTurf'
import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting' import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useObjectBatch({ isHidden, setIsHidden }) { export function useObjectBatch({ isHidden, setIsHidden }) {
const { getMessage } = useMessage() const { getMessage } = useMessage()
@ -41,6 +42,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const { getIntersectMousePoint } = useMouse() const { getIntersectMousePoint } = useMouse()
const { checkModuleDisjointSurface } = useTurf() const { checkModuleDisjointSurface } = useTurf()
const currentObject = useRecoilValue(currentObjectState) const currentObject = useRecoilValue(currentObjectState)
const { saveSnapshot, popLastSnapshot } = useUndoRedo()
useEffect(() => { useEffect(() => {
if (canvas) { if (canvas) {
@ -99,6 +101,8 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
let rect, isDown, origX, origY let rect, isDown, origX, origY
let selectedSurface let selectedSurface
saveSnapshot()
//프리입력 //프리입력
if (selectedType === INPUT_TYPE.FREE) { if (selectedType === INPUT_TYPE.FREE) {
@ -205,7 +209,9 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
initEvent() initEvent()
closeAll() 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) { } else if (selectedType === INPUT_TYPE.DIMENSION) {
@ -307,7 +313,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
* @param {*} surfaceShapePolygons * @param {*} surfaceShapePolygons
* @returns * @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 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 const dormerTempName = buttonAct === 3 ? BATCH_TYPE.TRIANGLE_DORMER_TEMP : BATCH_TYPE.PENTAGON_DORMER_TEMP
@ -325,6 +331,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
return return
} }
// [undo 중복 fix] 도머 복사(commonCopyObject)는 copyObject 에서 이미 saveSnapshot 을 호출한 뒤
// applyDormers 를 재사용하므로, 복사 경로에서는 skipSnapshot=true 로 중복 스냅샷을 막는다.
if (!skipSnapshot) saveSnapshot()
let theta = 0 let theta = 0
let bottomLength = 0, let bottomLength = 0,
@ -1299,7 +1309,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
return { leftPoints, rightPoints, groupPoints } 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 objectWidth = target.width
const objectHeight = target.height 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] const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0]
if (obj) { if (obj) {
saveSnapshot()
obj.set({ obj.set({
lockMovementX: false, lockMovementX: false,
lockMovementY: false, lockMovementY: false,
@ -1428,8 +1442,6 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const originObj = { ...obj } const originObj = { ...obj }
const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints()) const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints())
const originLeft = obj.left
const originTop = obj.top
addCanvasMouseEventListener('mouse:up', (e) => { 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) { if (obj.name === BATCH_TYPE.TRIANGLE_DORMER || obj.name === BATCH_TYPE.PENTAGON_DORMER) {
const calcLeft = obj.left - originLeft // getCurrentPoints() 는 calcTransformMatrix() 기반으로 이미 이동된 월드 좌표를 반환한다.
const calcTop = obj.top - originTop // 과거 코드에서 (obj.left - originLeft) delta 를 다시 더해 2배 이동된 좌표로 검사 →
// 빠르게 이동 시 도머가 지붕 내부에 있어도 within=false 가 되어 alert 오발생.
const currentDormerPoints = obj.getCurrentPoints().map((item) => { obj.setCoords()
return { const currentDormerPoints = obj.getCurrentPoints()
x: item.x + calcLeft,
y: item.y + calcTop,
}
})
const turfObject = pointsToTurfPolygon(currentDormerPoints) const turfObject = pointsToTurfPolygon(currentDormerPoints)
@ -1513,6 +1521,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const copyObjectBatch = () => { const copyObjectBatch = () => {
const obj = canvas.getActiveObject() const obj = canvas.getActiveObject()
if (obj) { if (obj) {
saveSnapshot()
let clonedObj = null let clonedObj = null
const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0] 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) => { const dormerOffset = (arrow1, arrow2, length1, length2) => {
// [undo fix] 도머 이동 전 상태를 스냅샷으로 저장해야 undo 시 도머가 원위치로 복원된다.
// (이동 후 저장하면 undo 해도 도머는 그 자리에 남는다.) 검증 실패 시 popLastSnapshot 으로 제거.
saveSnapshot()
length1 = parseInt(length1) / 10 length1 = parseInt(length1) / 10
length2 = parseInt(length2) / 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 parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === dormer.parentId)[0]
const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints()) const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints())
let turfDormer = pointsToTurfPolygon(rectToPolygon(dormer)) let turfDormer = pointsToTurfPolygon(rectToPolygon(dormer))
let currentPoints = [] let currentPoints = []
if (dormer.name === BATCH_TYPE.TRIANGLE_DORMER || dormer.name === BATCH_TYPE.PENTAGON_DORMER) { if (dormer.name === BATCH_TYPE.TRIANGLE_DORMER || dormer.name === BATCH_TYPE.PENTAGON_DORMER) {
const calcLeft = dormer.left - left currentPoints = dormer.getCurrentPoints()
const calcTop = dormer.top - top
currentPoints = dormer.groupPoints.map((item) => {
return {
x: item.x + calcLeft,
y: item.y + calcTop,
}
})
turfDormer = pointsToTurfPolygon(currentPoints) turfDormer = pointsToTurfPolygon(currentPoints)
} }
@ -1651,7 +1658,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
//위치 원복` //위치 원복`
dormer.left = left dormer.left = left
dormer.top = top dormer.top = top
dormer.setCoords()
// [undo 중복 fix] 검증 실패(원복) 시 위에서 저장한 스냅샷을 제거해 불필요한 undo 단계를 막는다.
popLastSnapshot()
return return
} }

View File

@ -5,11 +5,12 @@ import { useEvent } from '@/hooks/useEvent'
import { useMouse } from '@/hooks/useMouse' import { useMouse } from '@/hooks/useMouse'
import { useLine } from '@/hooks/useLine' import { useLine } from '@/hooks/useLine'
import { useTempGrid } from '@/hooks/useTempGrid' 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 { fabric } from 'fabric'
import { useAdsorptionPoint } from '@/hooks/useAdsorptionPoint' import { useAdsorptionPoint } from '@/hooks/useAdsorptionPoint'
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
import { usePopup } from '@/hooks/usePopup' import { usePopup } from '@/hooks/usePopup'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { calculateAngle, isSamePoint } from '@/util/qpolygon-utils' import { calculateAngle, isSamePoint } from '@/util/qpolygon-utils'
import { POLYGON_TYPE } from '@/common/common' import { POLYGON_TYPE } from '@/common/common'
import { useMessage } from '../useMessage' import { useMessage } from '../useMessage'
@ -36,6 +37,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
const { getAdsorptionPoints } = useAdsorptionPoint() const { getAdsorptionPoints } = useAdsorptionPoint()
const { closePopup } = usePopup() const { closePopup } = usePopup()
const { getMessage } = useMessage() const { getMessage } = useMessage()
const { saveSnapshot, setDrawing, popLastSnapshot } = useUndoRedo()
const adsorptionRange = useRecoilValue(adsorptionRangeState) const adsorptionRange = useRecoilValue(adsorptionRangeState)
@ -44,6 +46,7 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
const mousePointerArr = useRef([]) const mousePointerArr = useRef([])
const roofAdsorptionPoints = useRef([]) const roofAdsorptionPoints = useRef([])
const lineHistory = useRef([]) const lineHistory = useRef([])
const isFix = useRef(false)
const length1Ref = useRef(0) const length1Ref = useRef(0)
const length2Ref = 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(() => { useEffect(() => {
if (type === null) { if (type === null) {
return return
@ -151,6 +172,11 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
roof.innerLines = roof.innerLines.filter((inner) => inner !== object) 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.remove(object)
canvas.setActiveObject(line) canvas.setActiveObject(line)
} }
@ -164,6 +190,13 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
name: 'auxiliaryLine', name: 'auxiliaryLine',
}) })
// [복사본 roofId 유실 fix] addLine 이 내부에서 attributes 를 {} 로 초기화하므로,
// 원본 attributes(roofId 등)를 복사해 복원한다. 미복원 시 복사본의 roofId 가 없어
// 우클릭 '보조선 전체삭제' 등 roofId 기반 처리가 누락된다.
if (newLine) {
newLine.attributes = { ...object.attributes }
}
// roofs의 innerLines에서 object와 같은 것을 찾아서 newLine으로 변경 // roofs의 innerLines에서 object와 같은 것을 찾아서 newLine으로 변경
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF)
roofs.forEach((roof) => { roofs.forEach((roof) => {
@ -887,6 +920,18 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
return 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() const lastLine = lineHistory.current.pop()
if (lastLine) { if (lastLine) {
roofAdsorptionPoints.current = roofAdsorptionPoints.current.filter( roofAdsorptionPoints.current = roofAdsorptionPoints.current.filter(
@ -906,6 +951,9 @@ export function useAuxiliaryDrawing(id, isUseEffect = true) {
return return
} }
// 완료 확정 → 마운트 시 저장한 "보조선 적용 전" 스냅샷을 유지 (언마운트 cleanup 에서 pop 하지 않음)
isFix.current = true
// cutAuxiliary() // cutAuxiliary()
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) 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 roofBase.innerLines = lineHistory.current.length !== 0 ? [...roofInnerLines] : roofBase.innerLines
canvas.renderAll() canvas.renderAll()
}) })
closePopup(id) closePopup(id)
} }

View File

@ -11,12 +11,14 @@ import { useSwal } from '@/hooks/useSwal'
import { usePopup } from '@/hooks/usePopup' import { usePopup } from '@/hooks/usePopup'
import { getChonByDegree } from '@/util/canvas-util' import { getChonByDegree } from '@/util/canvas-util'
import { settingModalFirstOptionsState } from '@/store/settingAtom' import { settingModalFirstOptionsState } from '@/store/settingAtom'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { fabric } from 'fabric' import { fabric } from 'fabric'
import { QLine } from '@/components/fabric/QLine' import { QLine } from '@/components/fabric/QLine'
import { reattachDebugLabels } from '@/components/fabric/QPolygon' import { reattachDebugLabels } from '@/components/fabric/QPolygon'
import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { findInteriorPoint } from '@/util/skeleton-utils' import { findInteriorPoint } from '@/util/skeleton-utils'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
import { checkKerabRules } from '@/util/kerab-rule-checker'
import { debugCapture } from '@/util/debugCapture' import { debugCapture } from '@/util/debugCapture'
import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical' import { applyKerabOffsetSurgical } from '@/util/kerab-offset-surgical'
@ -45,6 +47,7 @@ export function useEavesGableEdit(id) {
const typeRef = useRef(TYPES.EAVES) const typeRef = useRef(TYPES.EAVES)
const { removeLine, addPitchTextsByOuterLines } = useLine() const { removeLine, addPitchTextsByOuterLines } = useLine()
const { swalFire } = useSwal() const { swalFire } = useSwal()
const { saveSnapshot } = useUndoRedo()
const { drawRoofPolygon } = useMode() const { drawRoofPolygon } = useMode()
const currentAngleType = useRecoilValue(currentAngleTypeSelector) const currentAngleType = useRecoilValue(currentAngleTypeSelector)
@ -459,6 +462,8 @@ export function useEavesGableEdit(id) {
} else { } else {
logger.log('[KERAB-RULE-CHECK] ' + phase + ' PASS') 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) { } catch (err) {
logger.warn('[KERAB-RULE-CHECK] error', err) logger.warn('[KERAB-RULE-CHECK] error', err)
} }
@ -550,6 +555,7 @@ export function useEavesGableEdit(id) {
break break
} }
saveSnapshot()
// [KERAB-STATE-DUMP 2026-06-11] A/B타입(가로/세로 케라바) 토글 진입 시점 지붕 상태 진단. // [KERAB-STATE-DUMP 2026-06-11] A/B타입(가로/세로 케라바) 토글 진입 시점 지붕 상태 진단.
// 벽 type 지정은 있는데 내부 skLine 이 0인 "무에서 유" 케이스 파악용 — 사용자 설명 전 사실 수집. // 벽 type 지정은 있는데 내부 skLine 이 0인 "무에서 유" 케이스 파악용 — 사용자 설명 전 사실 수집.
// 읽기 전용(좌표/visible/type 만 덤프), 동작 변경 없음. logger 게이트(local 만 출력). // 읽기 전용(좌표/visible/type 만 덤프), 동작 변경 없음. logger 게이트(local 만 출력).
@ -2749,6 +2755,8 @@ export function useEavesGableEdit(id) {
// 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치). // 변은 出幅만큼 평행이동(delta)했으므로, 옛 변 위 끝점만 동일 delta 로 평행이동(= NORMAL-ABS 동치).
// 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지). // 단, cMid(변 중점)에 걸린 비-collinear 선의 끝점은 apex 로 수렴해야 한다(힙 사이 마루 금지).
// 케라바패턴 라인(내 hip)은 surgicalAfter 의 ray-cast 가 이미 처리 → 제외. // 케라바패턴 라인(내 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) moveStaleEdgeInnerLines(roof, c1, c2, _edgeIdx, apexRes)
// [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장. // [KERAB-TYPE-EAVES-STRAIGHT 2026-06-12] apex 는 벽기준 고정. 힙을 벽교점 방향 45° 그대로 roofLine 까지 직선 연장.
extendTypeGableHipsStraightToRoofLine(roof, target, apexRes) extendTypeGableHipsStraightToRoofLine(roof, target, apexRes)
@ -3146,12 +3154,325 @@ export function useEavesGableEdit(id) {
} }
return touched 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), // 5c) Y 반사 힙 — 새 힙이 *기존 마루(RIDGE)에 직접 부딪힌* 경우만(=raycast hit 가 RIDGE),
// 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사). // 그 마루를 대칭축으로 반대편 면에 반사 힙 1개 생성(R7: 마루+hip 교점 반사).
// ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16). // ★ 마루를 *확장*해서 만난 힙(hit 가 roofLine 이던 resA)은 Y 아님 → 반사 안 함(사용자 확정 2026-06-16).
const reflectOps = [] const reflectOps = []
for (const res of [resA, resB]) { for (const res of [resA, resB]) {
if (diamond) break // 마름모를 만든 경우 힙을 인접 처마 힙 길이로 재설정했으므로 반사 불필요
if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue if (!res || !res.hip || res.hitKind !== LINE_TYPE.SUBLINE.RIDGE || !res.hitLine) continue
const M = { x: res.hit.x, y: res.hit.y } const M = { x: res.hit.x, y: res.hit.y }
const rg = res.hitLine const rg = res.hitLine
@ -3174,8 +3495,42 @@ export function useEavesGableEdit(id) {
runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap) runKerabRuleCheck(roof, 'valley-eaves-rule1', kerabRevertBeforeSnap)
logger.log( logger.log(
'[KERAB-VALLEY-EAVES-RULE1] applied ' + '[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 return
} }
} }
@ -3991,11 +4346,89 @@ export function useEavesGableEdit(id) {
const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den const u = ((A.x - P.x) * dy - (A.y - P.y) * dx) / den
if (t > 1e-6 && u >= -1e-6 && u <= 1 + 1e-6 && t < best) best = t 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 return best
} }
const oppDist = oppRayDist(mid, ix, iy) const oppDist = oppRayDist(mid, ix, iy)
const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2) const apexDepth = Math.min(L / 2, Number.isFinite(oppDist) ? oppDist : L / 2)
const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth } const apex = { x: mid.x + ix * apexDepth, y: mid.y + iy * apexDepth }
// [KERAB-TYPE-EAVES-BOX-CLIP 2026-06-23] 골짜기 박스(지붕의 겹침)는 인접 면 입장에서 자기
// roofLine 경계 = "상대 roofLine"(사용자 확정 2026-06-23). apex 로 향하는 힙이 박스 경계를
// 만나면 거기서 멈춰야 하고 뚫고 지나가면 안 된다. apex 자체는 45° 이등변 유지를 위해 보존하되,
// 각 힙을 corner→apex 로 쏴 박스 경계 교차점으로 절삭한다.
// 박스 경계 식별: 마루(ridge)의 박스측 고정 끝점 Bp(= apex 로 단축되지 않는 끝)을 공유하는
// inner HIP/RIDGE 변(H-6/H-7 류). RG-2(415)가 이 Bp 에서 박스 안으로 올라가므로 Bp 가 곧
// 박스 코너다. 일반 내부선(H-2 등, Bp 미공유)은 힙이 가로질러야 하므로 제외 → 정상 切妻
// (Bp=roofLine 코너, 공유 inner 없음)은 무영향. ridge(단축 대상)·invisible 제외.
const boxCorner = midIsA ? rB : rA
const clipHipToBox = (corner) => {
const dx0 = apex.x - corner.x
const dy0 = apex.y - corner.y
const dlen = Math.hypot(dx0, dy0)
if (dlen < 1e-6) return { x: apex.x, y: apex.y }
const dx = dx0 / dlen
const dy = dy0 / dlen
let best = dlen
let bestPt = { x: apex.x, y: apex.y }
for (const il of roof.innerLines || []) {
if (!il || il === ridge || il.visible === false) continue
if (il.name !== LINE_TYPE.SUBLINE.HIP && il.name !== LINE_TYPE.SUBLINE.RIDGE) continue
const sharesBp =
(Math.abs(il.x1 - boxCorner.x) < 0.5 && Math.abs(il.y1 - boxCorner.y) < 0.5) ||
(Math.abs(il.x2 - boxCorner.x) < 0.5 && Math.abs(il.y2 - boxCorner.y) < 0.5)
// [KERAB-TYPE-EAVES-HIPMEET 2026-06-23] 신규 힙은 (1) 박스 변(Bp 공유) 또는 (2) 직전 변경에서
// 생성된 기존 케라바 힙(kerabPatternHip)과의 최근접 교점에서 멈춘다 → ">" 모양. 둘 다 방향 무관
// 후보로 같은 best(t 최소) 누산기에 경쟁시켜 가장 가까운 교점이 절삭점이 된다. 1차 변경 때는
// 기존 힙이 없어 박스 변만 후보 → 동작 불변(무영향).
const isPriorHip = il.lineName === 'kerabPatternHip'
if (!sharesBp && !isPriorHip) continue
const ax = il.x2 - il.x1
const ay = il.y2 - il.y1
if (Math.hypot(ax, ay) < 1e-6) continue
const den = dx * ay - dy * ax
if (Math.abs(den) < 1e-9) continue
const t = ((il.x1 - corner.x) * ay - (il.y1 - corner.y) * ax) / den
const u = ((il.x1 - corner.x) * dy - (il.y1 - corner.y) * dx) / den
if (t > 1.0 && t < best - 1e-6 && u >= -1e-6 && u <= 1 + 1e-6) {
best = t
bestPt = { x: corner.x + dx * t, y: corner.y + dy * t }
}
}
return bestPt
}
// 박스 절삭점 선계산 → 마루 처리 분기 판단에 사용.
const endA = clipHipToBox(wA)
const endB = clipHipToBox(wB)
const hipClipped = Math.hypot(endA.x - apex.x, endA.y - apex.y) > 1 || Math.hypot(endB.x - apex.x, endB.y - apex.y) > 1
if (hipClipped) {
// [KERAB-TYPE-EAVES-RIDGE-VANISH 2026-06-23] 박스(겹침)가 상대 roofLine 이라 신규 힙이 박스/직전 힙에서
// 멈추면, 가운데 마루(RG-1)는 양쪽 코너 힙이 이미 생성됐으므로 "삭제 대상"이다(사용자 규칙 2026-06-23:
// "RG-1 은 마루 = 삭제, 양쪽에 힙 생성됨"). visible=false 숨기기는 객체가 innerLines 에 남아
// 디버그 라벨 재부착/LABEL-MAP 이 visible 무시하고 RG-1 을 다시 노출 → "삭제 안 됨" 으로 보였다.
// revert 스냅샷은 좌표 값만 복사하고 revert 경로가 라인을 재생성하므로 진짜 삭제해도 무해하다.
// → reconstruct(4820) 와 동일하게 canvas.remove + innerLines splice 로 실제 삭제한다.
canvas.remove(ridge)
const _ridx = roof.innerLines.indexOf(ridge)
if (_ridx >= 0) roof.innerLines.splice(_ridx, 1)
} else {
// 마루 단축: 케라바변쪽 끝점 → apex. // 마루 단축: 케라바변쪽 끝점 → apex.
if (midIsA) ridge.set({ x1: apex.x, y1: apex.y }) if (midIsA) ridge.set({ x1: apex.x, y1: apex.y })
else ridge.set({ x2: apex.x, y2: apex.y }) else ridge.set({ x2: apex.x, y2: apex.y })
@ -4012,9 +4445,10 @@ export function useEavesGableEdit(id) {
if (typeof ridge.setCoords === 'function') ridge.setCoords() if (typeof ridge.setCoords === 'function') ridge.setCoords()
if (typeof ridge.setLength === 'function') ridge.setLength() if (typeof ridge.setLength === 'function') ridge.setLength()
if (typeof ridge.addLengthText === 'function') ridge.addLengthText() 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) => { // hip 2개: wA→endA, wB→endB. pts=[corner, end] 라 corner 끝(x1,y1)이 outer.
const pts = [corner.x, corner.y, apex.x, apex.y] 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 sz = calcLinePlaneSize({ x1: pts[0], y1: pts[1], x2: pts[2], y2: pts[3] })
const hip = new QLine(pts, { const hip = new QLine(pts, {
parentId: roof.id, parentId: roof.id,
@ -4032,8 +4466,8 @@ export function useEavesGableEdit(id) {
hip.__kerabRevertOuterSide = side hip.__kerabRevertOuterSide = side
return hip return hip
} }
const hip1 = mkHip(wA, 'A') const hip1 = mkHip(wA, 'A', endA)
const hip2 = mkHip(wB, 'B') const hip2 = mkHip(wB, 'B', endB)
canvas.add(hip1) canvas.add(hip1)
canvas.add(hip2) canvas.add(hip2)
hip1.bringToFront() hip1.bringToFront()
@ -4044,8 +4478,91 @@ export function useEavesGableEdit(id) {
canvas.renderAll() canvas.renderAll()
logger.log( logger.log(
'[KERAB-TYPE-EAVES] applied ' + '[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 return apex
} }
@ -4102,9 +4619,17 @@ export function useEavesGableEdit(id) {
let movedCnt = 0 let movedCnt = 0
for (const il of roof.innerLines || []) { for (const il of roof.innerLines || []) {
if (!il || isKerabPattern(il.lineName)) continue 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) const collinear = isCollinearWithEdge(il.x1, il.y1, il.x2, il.y2)
// 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동. // 非평행 라인의 cMid 끝점은 apex 로 수렴, 그 외는 出幅 delta 로 평행이동.
const relocate = (px, py) => { 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 } if (apex && !collinear && nearMid(px, py)) return { x: apex.x, y: apex.y }
return { x: px + dxE, y: py + dyE } return { x: px + dxE, y: py + dyE }
} }

View File

@ -10,6 +10,7 @@ import Big from 'big.js'
import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { calcLinePlaneSize } from '@/util/qpolygon-utils'
import { getSelectLinePosition } from '@/util/skeleton-utils' import { getSelectLinePosition } from '@/util/skeleton-utils'
import { useMouse } from '@/hooks/useMouse' import { useMouse } from '@/hooks/useMouse'
import { useUndoRedo } from '@/hooks/useUndoRedo'
// [정책 flag] 오버 이동(인접 라인 반전 = polygon self-cross) 입력 처리 방법. // [정책 flag] 오버 이동(인접 라인 반전 = polygon self-cross) 입력 처리 방법.
// 'clamp' : (기본/2026-04-24 최종) 평행 위치-OVER_EPS 까지 클램핑. wall.baseLines 는 항상 정상 polygon, SK 확장 정상. // '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 [type, setType] = useState(TYPE.FLOW_LINE)
const typeRef = useRef(type) const typeRef = useRef(type)
const { swalFire } = useSwal() const { swalFire } = useSwal()
const { saveSnapshot } = useUndoRedo()
const FLOW_LINE_REF = { const FLOW_LINE_REF = {
POINTER_INPUT_REF: useRef(null), POINTER_INPUT_REF: useRef(null),
@ -154,23 +156,7 @@ export function useMovementSetting(id) {
canvas.remove(CONFIRM_LINE_REF.current) canvas.remove(CONFIRM_LINE_REF.current)
CONFIRM_LINE_REF.current = null CONFIRM_LINE_REF.current = null
} }
const roofs = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) restoreLineStyles()
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()
initEvent() initEvent()
} }
}, []) }, [])
@ -228,6 +214,28 @@ export function useMovementSetting(id) {
canvas.renderAll() canvas.renderAll()
}, [currentObject]) }, [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 = () => { const clearRef = () => {
if (type === TYPE.FLOW_LINE) { if (type === TYPE.FLOW_LINE) {
// 안전한 ref 접근 // 안전한 ref 접근
@ -532,6 +540,11 @@ export function useMovementSetting(id) {
canvas.renderAll() canvas.renderAll()
} }
// [undo 색상 fix] 스냅샷은 동선이동/형올림내림 적용 직전 상태를 저장한다.
// 팝업 오픈 시 입힌 강조색이 스냅샷에 박히면 undo 시 강조색이 복원되므로,
// 스냅샷 저장 전에 평상 선 스타일로 되돌린다.
restoreLineStyles()
saveSnapshot()
const target = selectedObject.current !== null ? selectedObject.current : CONFIRM_LINE_REF.current?.target const target = selectedObject.current !== null ? selectedObject.current : CONFIRM_LINE_REF.current?.target
if (!target) return if (!target) return

View File

@ -6,9 +6,11 @@ import {
adsorptionPointModeState, adsorptionPointModeState,
adsorptionRangeState, adsorptionRangeState,
canvasState, canvasState,
currentMenuState,
dotLineIntervalSelector, dotLineIntervalSelector,
verticalHorizontalModeState, verticalHorizontalModeState,
} from '@/store/canvasAtom' } from '@/store/canvasAtom'
import { MENU } from '@/common/common'
import { useEvent } from '@/hooks/useEvent' import { useEvent } from '@/hooks/useEvent'
import { useMouse } from '@/hooks/useMouse' import { useMouse } from '@/hooks/useMouse'
import { useLine } from '@/hooks/useLine' import { useLine } from '@/hooks/useLine'
@ -35,6 +37,7 @@ import { useMessage } from '@/hooks/useMessage'
import Big from 'big.js' import Big from 'big.js'
import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting' import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting'
import { useObject } from '@/hooks/useObject' import { useObject } from '@/hooks/useObject'
import { useUndoRedo } from '@/hooks/useUndoRedo'
//외벽선 그리기 //외벽선 그리기
export function useOuterLineWall(id, propertiesId) { export function useOuterLineWall(id, propertiesId) {
@ -60,6 +63,7 @@ export function useOuterLineWall(id, propertiesId) {
const { tempGridMode } = useTempGrid() const { tempGridMode } = useTempGrid()
const { addPolygonByLines } = usePolygon() const { addPolygonByLines } = usePolygon()
const { handleSelectableObjects } = useObject() const { handleSelectableObjects } = useObject()
const { saveSnapshot, setDrawing } = useUndoRedo()
const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState) const verticalHorizontalMode = useRecoilValue(verticalHorizontalModeState)
const adsorptionPointAddMode = useRecoilValue(adsorptionPointAddModeState) const adsorptionPointAddMode = useRecoilValue(adsorptionPointAddModeState)
@ -90,6 +94,7 @@ export function useOuterLineWall(id, propertiesId) {
const { getMessage } = useMessage() const { getMessage } = useMessage()
const setOuterLineFix = useSetRecoilState(outerLineFixState) const setOuterLineFix = useSetRecoilState(outerLineFixState)
const setCurrentMenu = useSetRecoilState(currentMenuState)
const outerLineDiagonalLengthRef = useRef(null) const outerLineDiagonalLengthRef = useRef(null)
@ -116,6 +121,15 @@ export function useOuterLineWall(id, propertiesId) {
} }
}, [verticalHorizontalMode, points, adsorptionPointAddMode, adsorptionPointMode, adsorptionRange, interval, tempGridMode]) }, [verticalHorizontalMode, points, adsorptionPointAddMode, adsorptionPointMode, adsorptionRange, interval, tempGridMode])
// 외벽선 그리기 진행 중에는 undo/redo/스냅샷을 차단한다.
// 그리기 전 스냅샷은 첫 클릭(mouseDown) 에서 force 로 저장한다 (기존 동작 유지).
useEffect(() => {
setDrawing(true)
return () => {
setDrawing(false)
}
}, [])
useEffect(() => { useEffect(() => {
arrow1Ref.current = arrow1 arrow1Ref.current = arrow1
}, [arrow1]) }, [arrow1])
@ -153,6 +167,9 @@ export function useOuterLineWall(id, propertiesId) {
pointer = { x: Big(pointer.x).toNumber(), y: Big(pointer.y).toNumber() } pointer = { x: Big(pointer.x).toNumber(), y: Big(pointer.y).toNumber() }
if (points.length === 0) { if (points.length === 0) {
// 그리기 진행 중에는 스냅샷이 차단되므로 force 로 저장한다.
// (처음 한 번만 저장되고 이후 추가 클릭은 points.length > 0 분기로 빠지므로 중복 저장 안 됨)
saveSnapshot(null, { force: true })
setPoints((prev) => [...prev, pointer]) setPoints((prev) => [...prev, pointer])
} else { } else {
const lastPoint = points[points.length - 1] const lastPoint = points[points.length - 1]
@ -258,6 +275,8 @@ export function useOuterLineWall(id, propertiesId) {
canvas.outerLineFix = true canvas.outerLineFix = true
closePopup(id) closePopup(id)
ccwCheck() ccwCheck()
// 외벽선 완료 → 지붕형상 설정 메뉴로 전환
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_SETTINGS)
addPopup(propertiesId, 1, <RoofShapeSetting id={propertiesId} pos={{ x: 50, y: 230 }} />) addPopup(propertiesId, 1, <RoofShapeSetting id={propertiesId} pos={{ x: 50, y: 230 }} />)
} }

View File

@ -13,6 +13,7 @@ import RoofAllocationSetting from '@/components/floor-plan/modal/roofAllocation/
import { settingModalFirstOptionsState } from '@/store/settingAtom' import { settingModalFirstOptionsState } from '@/store/settingAtom'
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
import { useUndoRedo } from '@/hooks/useUndoRedo'
// 외벽선 속성 설정 // 외벽선 속성 설정
export function usePropertiesSetting(id) { export function usePropertiesSetting(id) {
@ -29,6 +30,7 @@ export function usePropertiesSetting(id) {
const { addPopup, closePopup } = usePopup() const { addPopup, closePopup } = usePopup()
const { swalFire } = useSwal() const { swalFire } = useSwal()
const { getMessage } = useMessage() const { getMessage } = useMessage()
const { saveSnapshot } = useUndoRedo()
useEffect(() => { useEffect(() => {
const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') const lines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
@ -67,6 +69,7 @@ export function usePropertiesSetting(id) {
if (!selectedLine) { if (!selectedLine) {
return return
} }
saveSnapshot()
selectedLine.set({ selectedLine.set({
stroke: '#45CD7D', stroke: '#45CD7D',
strokeWidth: 4, strokeWidth: 4,
@ -86,6 +89,7 @@ export function usePropertiesSetting(id) {
if (!selectedLine) { if (!selectedLine) {
return return
} }
saveSnapshot()
selectedLine.set({ selectedLine.set({
stroke: '#3FBAE6', stroke: '#3FBAE6',
strokeWidth: 4, strokeWidth: 4,
@ -150,6 +154,7 @@ export function usePropertiesSetting(id) {
return return
} }
saveSnapshot()
lines.forEach((line) => { lines.forEach((line) => {
line.set({ line.set({
attributes: line.attributes ? line.attributes : { offset: 0, type: LINE_TYPE.WALLLINE.WALL }, attributes: line.attributes ? line.attributes : { offset: 0, type: LINE_TYPE.WALLLINE.WALL },

View File

@ -1,6 +1,6 @@
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil' import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
import { canvasState, currentAngleTypeSelector, currentObjectState } from '@/store/canvasAtom' 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 { useAxios } from '@/hooks/useAxios'
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
@ -15,7 +15,7 @@ import {
selectedRoofMaterialSelector, selectedRoofMaterialSelector,
} from '@/store/settingAtom' } from '@/store/settingAtom'
import { usePopup } from '@/hooks/usePopup' 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 { v4 as uuidv4 } from 'uuid'
import ActualSizeSetting from '@/components/floor-plan/modal/roofAllocation/ActualSizeSetting' import ActualSizeSetting from '@/components/floor-plan/modal/roofAllocation/ActualSizeSetting'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
@ -32,10 +32,11 @@ import { roofsState } from '@/store/roofAtom'
import { useText } from '@/hooks/useText' import { useText } from '@/hooks/useText'
import { fabric } from 'fabric' import { fabric } from 'fabric'
import { QLine } from '@/components/fabric/QLine' import { QLine } from '@/components/fabric/QLine'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { reattachRoofFaceDebugLabels } from '@/components/fabric/QPolygon' import { reattachRoofFaceDebugLabels } from '@/components/fabric/QPolygon'
import { calcLineActualSize2 } from '@/util/qpolygon-utils' import { calcLineActualSize2 } from '@/util/qpolygon-utils'
// [LOW-PITCH-WARN 2026-05-06] 저구배 + 특정 기와 사용 시 시공 매뉴얼 안내 alert // [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 + 호출 함께 제거 // [LOW-PITCH-DIAG 2026-05-06 TEMP] 진단용 — 검증 후 import + 호출 함께 제거
import { debugCapture } from '@/util/debugCapture' import { debugCapture } from '@/util/debugCapture'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
@ -73,7 +74,10 @@ export function useRoofAllocationSetting(id) {
const resetPoints = useResetRecoilState(outerLinePointsState) const resetPoints = useResetRecoilState(outerLinePointsState)
const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector) const [corridorDimension, setCorridorDimension] = useRecoilState(corridorDimensionSelector)
const { changeCorridorDimensionText } = useText() const { changeCorridorDimensionText } = useText()
const { saveSnapshot, clearStacks } = useUndoRedo()
const outlineDisplay = useRecoilValue(outlineDisplaySelector) const outlineDisplay = useRecoilValue(outlineDisplaySelector)
// [UNSELECT-RESTORE] 보조선/능선/골짜기 선택 강조 시 직전 스타일을 저장 → 선택 해제·변경·팝업 닫힘 때 원복
const highlightedLineRef = useRef(null)
useEffect(() => { useEffect(() => {
/** 배치면 초기설정에서 선택한 지붕재 배열 설정 */ /** 배치면 초기설정에서 선택한 지붕재 배열 설정 */
@ -84,6 +88,13 @@ export function useRoofAllocationSetting(id) {
/** 지붕면 조회 */ /** 지붕면 조회 */
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) /** roofPolygon.innerLines */ 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) => { roofBases.forEach((roof) => {
roof.innerLines.forEach((line) => { roof.innerLines.forEach((line) => {
/** 실측값이 없는 경우 라인 두께 4로 설정 */ /** 실측값이 없는 경우 라인 두께 4로 설정 */
@ -100,8 +111,14 @@ export function useRoofAllocationSetting(id) {
/** 현재 선택된 객체가 보조라인, 피라미드, 힙인 경우 두께 4로 설정 */ /** 현재 선택된 객체가 보조라인, 피라미드, 힙인 경우 두께 4로 설정 */
if (currentObject && currentObject.name && ['auxiliaryLine', 'ridge', 'hip'].includes(currentObject.name)) { 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' }) currentObject.set({ strokeWidth: 4, stroke: '#EA10AC' })
} }
canvas.renderAll()
}, [currentObject]) }, [currentObject])
useEffect(() => { useEffect(() => {
@ -116,6 +133,18 @@ export function useRoofAllocationSetting(id) {
fetchBasicSettings(basicSetting.planNo) 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) closePopup(id)
basicSettingSave() basicSettingSave()
setModuleSelectionData({ ...moduleSelectionData, roofConstructions: newRoofList }) setModuleSelectionData({ ...moduleSelectionData, roofConstructions: newRoofList })
// 지붕재 변경 후 undo/redo stack 초기화
clearStacks()
} }
/** /**
@ -457,14 +489,15 @@ export function useRoofAllocationSetting(id) {
const isDiagonal = dx > 0.5 && dy > 0.5 const isDiagonal = dx > 0.5 && dy > 0.5
let newActualSize let newActualSize
if (isDiagonal) { if (isDiagonal) {
const pitch = const pitch = line.attributes?.pitch ?? roof.attributes?.pitch ?? roof.roofMaterial?.pitch ?? 0
line.attributes?.pitch ??
roof.attributes?.pitch ??
roof.roofMaterial?.pitch ??
0
newActualSize = Number( newActualSize = Number(
calcLineActualSize2( 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), getDegreeByChon(pitch),
), ),
) )
@ -474,9 +507,7 @@ export function useRoofAllocationSetting(id) {
line.set({ attributes: { ...line.attributes, actualSize: newActualSize } }) line.set({ attributes: { ...line.attributes, actualSize: newActualSize } })
// lengthText 에 actualSize 즉시 propagate (없으면 모드 토글이 if(obj.actualSize) // lengthText 에 actualSize 즉시 propagate (없으면 모드 토글이 if(obj.actualSize)
// 가드에 막혀 토글 무효화). // 가드에 막혀 토글 무효화).
const lengthText = canvas.getObjects().find( const lengthText = canvas.getObjects().find((o) => o.name === 'lengthText' && o.parentId === line.id)
(o) => o.name === 'lengthText' && o.parentId === line.id,
)
if (lengthText) { if (lengthText) {
lengthText.set({ actualSize: newActualSize }) lengthText.set({ actualSize: newActualSize })
} }
@ -531,8 +562,10 @@ export function useRoofAllocationSetting(id) {
const TOL = 1.0 const TOL = 1.0
const isSamePt = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y) < TOL const isSamePt = (p1, p2) => Math.hypot(p1.x - p2.x, p1.y - p2.y) < TOL
const isCollinear = (l1, l2) => { const isCollinear = (l1, l2) => {
const v1x = l1.x2 - l1.x1, v1y = l1.y2 - l1.y1 const v1x = l1.x2 - l1.x1,
const v2x = l2.x2 - l2.x1, v2y = l2.y2 - l2.y1 v1y = l1.y2 - l1.y1
const v2x = l2.x2 - l2.x1,
v2y = l2.y2 - l2.y1
const m1 = Math.hypot(v1x, v1y) || 1 const m1 = Math.hypot(v1x, v1y) || 1
const m2 = Math.hypot(v2x, v2y) || 1 const m2 = Math.hypot(v2x, v2y) || 1
const cross = (v1x * v2y - v1y * v2x) / (m1 * m2) const cross = (v1x * v2y - v1y * v2x) / (m1 * m2)
@ -566,26 +599,22 @@ export function useRoofAllocationSetting(id) {
}) })
if (!sk) { if (!sk) {
logger.log( logger.log(`[INTEGRATE] ext 짝없음 ` + `(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)})`)
`[INTEGRATE] ext 짝없음 ` +
`(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)})`
)
return return
} }
// [2026-04-30] sk 가 이미 SK 빌드 단계에서 연장된 경우 → merge 스킵. // [2026-04-30] sk 가 이미 SK 빌드 단계에서 연장된 경우 → merge 스킵.
// [save/load 보존 2026-05-13] runtime __extended 는 저장 시 사라지므로 attributes.extended 도 함께 검사. // [save/load 보존 2026-05-13] runtime __extended 는 저장 시 사라지므로 attributes.extended 도 함께 검사.
if (sk.__extended || sk.attributes?.extended) { if (sk.__extended || sk.attributes?.extended) {
logger.log( logger.log(`[INTEGRATE] sk 이미 연장됨(id=${sk.id}) → merge 스킵, ext 는 lines 에서만 제거(canvas 유지)`)
`[INTEGRATE] sk 이미 연장됨(id=${sk.id}) → merge 스킵, ext 는 lines 에서만 제거(canvas 유지)`
)
extLinesOnly.push(ext) extLinesOnly.push(ext)
return return
} }
const skP1 = { x: sk.x1, y: sk.y1 } const skP1 = { x: sk.x1, y: sk.y1 }
const skP2 = { x: sk.x2, y: sk.y2 } 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 extOuter = isSamePt(extP1, sharedPt) ? extP2 : extP1
const skOuter = isSamePt(skP1, sharedPt) ? skP2 : skP1 const skOuter = isSamePt(skP1, sharedPt) ? skP2 : skP1
@ -625,7 +654,7 @@ export function useRoofAllocationSetting(id) {
`[INTEGRATE] merge ` + `[INTEGRATE] merge ` +
`ext=(${extP1.x.toFixed(1)},${extP1.y.toFixed(1)})→(${extP2.x.toFixed(1)},${extP2.y.toFixed(1)}) len=${extLen.toFixed(1)} ` + `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)} ` + `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})` `→ total=${totalLen.toFixed(1)} (plane ${skPlane}${newPlane}, actual ${skActual}${newActual})`,
) )
removedExt.push(ext) removedExt.push(ext)
@ -654,7 +683,7 @@ export function useRoofAllocationSetting(id) {
`[INTEGRATE] 완료 roofBase.id=${roofBase.id} ` + `[INTEGRATE] 완료 roofBase.id=${roofBase.id} ` +
`ext제거=${removedExt.length} sk제거=${removedSk.length} merged=${merged.length} ` + `ext제거=${removedExt.length} sk제거=${removedSk.length} merged=${merged.length} ` +
`extKeptInCanvas=${extLinesOnly.length} ` + `extKeptInCanvas=${extLinesOnly.length} ` +
`→ lines=${roofBase.lines.length} innerLines=${roofBase.innerLines.length}` `→ lines=${roofBase.lines.length} innerLines=${roofBase.innerLines.length}`,
) )
} }
@ -803,6 +832,154 @@ export function useRoofAllocationSetting(id) {
let absorber = null let absorber = null
let absorberShares = null let absorberShares = null
adjacencyMap.forEach((shares, sub) => { 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) { if (!absorber || shares.length > absorberShares.length) {
absorber = sub absorber = sub
absorberShares = shares absorberShares = shares
@ -810,8 +987,7 @@ export function useRoofAllocationSetting(id) {
}) })
if (!absorber || absorberShares.length < lLen) { if (!absorber || absorberShares.length < lLen) {
logger.log( logger.log(
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 전체를 가진 흡수 면 없음 ` + `[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — V1 전체를 가진 흡수 면 없음 ` + `(maxShares=${absorberShares?.length ?? 0}, lLen=${lLen})`,
`(maxShares=${absorberShares?.length ?? 0}, lLen=${lLen})`,
) )
return return
} }
@ -841,13 +1017,9 @@ export function useRoofAllocationSetting(id) {
// 면적 부호 보존 + 확장(절댓값 증가) 검증 — 실패 시 밴드 보존(흡수 skip) // 면적 부호 보존 + 확장(절댓값 증가) 검증 — 실패 시 밴드 보존(흡수 skip)
const oldSigned = signedArea(aPts) const oldSigned = signedArea(aPts)
const newSigned = signedArea(newPts) const newSigned = signedArea(newPts)
if ( if ((oldSigned !== 0 && Math.sign(newSigned) !== Math.sign(oldSigned)) || Math.abs(newSigned) <= Math.abs(oldSigned) + 0.5) {
(oldSigned !== 0 && Math.sign(newSigned) !== Math.sign(oldSigned)) ||
Math.abs(newSigned) <= Math.abs(oldSigned) + 0.5
) {
logger.log( logger.log(
`[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 확장 검증 실패 ` + `[KERAB-VALLEY-OVERLAP-FOLD] X.id=${X.id} skip — 확장 검증 실패 ` + `oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
`oldSigned=${oldSigned.toFixed(0)} newSigned=${newSigned.toFixed(0)}`,
) )
return return
} }
@ -856,18 +1028,12 @@ export function useRoofAllocationSetting(id) {
const findLine = (p1, p2) => { const findLine = (p1, p2) => {
for (const l of aLines) { for (const l of aLines) {
if (!l) continue if (!l) continue
if ( 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)))
(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 } return { src: l, reuse: true }
} }
for (const l of Xlines) { for (const l of Xlines) {
if (!l) continue if (!l) continue
if ( 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)))
(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 { src: l, reuse: false }
} }
return null return null
@ -881,7 +1047,12 @@ export function useRoofAllocationSetting(id) {
found.src.idx = newLines.length + 1 found.src.idx = newLines.length + 1
newLines.push(found.src) newLines.push(found.src)
} else { } 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], { const ln = new QLine([p1.x, p1.y, p2.x, p2.y], {
stroke: absorber.stroke, stroke: absorber.stroke,
strokeWidth: absorber.strokeWidth, strokeWidth: absorber.strokeWidth,
@ -935,6 +1106,7 @@ export function useRoofAllocationSetting(id) {
* 지붕면 할당 * 지붕면 할당
*/ */
const apply = () => { const apply = () => {
saveSnapshot()
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial) const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF && !obj.roofMaterial)
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL)
logger.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`) logger.log(`[ALLOC] apply() 진입. roofBases=${roofBases.length}`)
@ -949,11 +1121,15 @@ export function useRoofAllocationSetting(id) {
roofBases.forEach((roofBase) => { roofBases.forEach((roofBase) => {
try { try {
// 지붕 할당 로직에 extensionLine 추가 // 지붕 할당 로직에 extensionLine 추가
// [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 출폭 띠 외곽 라인(kerabValleyOverlapLine) 도 할당 대상. // [KERAB-VALLEY-OVERLAP 2026-05-28] 골짜기 케라바 출폭 띠 외곽 라인(kerabValleyOverlapLine) 도 할당 대상.
// b polygon 의 lines[] 에 추가되어 b 의 면이 출폭 띠 영역까지 확장 → a 의 sub-면과 겹침 표현. // b polygon 의 lines[] 에 추가되어 b 의 면이 출폭 띠 영역까지 확장 → a 의 sub-면과 겹침 표현.
const roofEaveHelpLines = canvas.getObjects().filter((obj) => const roofEaveHelpLines = canvas
.getObjects()
.filter(
(obj) =>
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') && (obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') &&
obj.roofId === roofBase.id obj.roofId === roofBase.id,
) )
// logger.log('roofBase.id:', roofBase.id) // logger.log('roofBase.id:', roofBase.id)
// logger.log('roofEaveHelpLines:', roofEaveHelpLines) // logger.log('roofEaveHelpLines:', roofEaveHelpLines)
@ -966,44 +1142,53 @@ export function useRoofAllocationSetting(id) {
// Filter out lines from roofBase.lines that share any points with newEaveLines // Filter out lines from roofBase.lines that share any points with newEaveLines
// extensionLine과 일반 eaveHelpLine 분리 // 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 판단 제외, 단순 결합 // [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('extensionLines count:', extensionLines.length)
// logger.log('normalEaveLines count:', normalEaveLines.length) // logger.log('normalEaveLines count:', normalEaveLines.length)
// 일반 eaveHelpLine만 Overlap 판단에 사용 // 일반 eaveHelpLine만 Overlap 판단에 사용
const linesToKeep = roofBase.lines.filter(roofLine => { const linesToKeep = roofBase.lines.filter((roofLine) => {
const shouldRemove = normalEaveLines.some(eaveLine => { const shouldRemove = normalEaveLines.some((eaveLine) => {
// 1. 기본적인 포인트 일치 확인 // 1. 기본적인 포인트 일치 확인
const rX1 = roofLine.x1, rY1 = roofLine.y1, rX2 = roofLine.x2, rY2 = roofLine.y2; const rX1 = roofLine.x1,
const eX1 = eaveLine.x1, eY1 = eaveLine.y1, eX2 = eaveLine.x2, eY2 = eaveLine.y2; 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 isP1Matched =
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); (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) { if (isP1Matched || isP2Matched) {
// 2. 일직선(평행)인지 확인 // 2. 일직선(평행)인지 확인
const dx1 = rX2 - rX1; const dx1 = rX2 - rX1
const dy1 = rY2 - rY1; const dy1 = rY2 - rY1
const dx2 = eX2 - eX1; const dx2 = eX2 - eX1
const dy2 = eY2 - eY1; const dy2 = eY2 - eY1
const crossProduct = Math.abs(dx1 * dy2 - dy1 * dx2); const crossProduct = Math.abs(dx1 * dy2 - dy1 * dx2)
const mag1 = Math.sqrt(dx1 * dx1 + dy1 * dy1); const mag1 = Math.sqrt(dx1 * dx1 + dy1 * dy1)
const mag2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); const mag2 = Math.sqrt(dx2 * dx2 + dy2 * dy2)
const isStraight = (mag1 * mag2) === 0 ? true : (crossProduct / (mag1 * mag2) < 0.01); const isStraight = mag1 * mag2 === 0 ? true : crossProduct / (mag1 * mag2) < 0.01
if (isStraight) { if (isStraight) {
// 3. [핵심] 몸통이 포개지는지(Overlap) 확인 // 3. [핵심] 몸통이 포개지는지(Overlap) 확인
// 한 선의 끝점이 다른 선의 "내부"에 들어와 있는지 체크 // 한 선의 끝점이 다른 선의 "내부"에 들어와 있는지 체크
const isPointInside = (x, y, x1, y1, x2, y2) => { const isPointInside = (x, y, x1, y1, x2, y2) => {
const dotProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1); const dotProduct = (x - x1) * (x2 - x1) + (y - y1) * (y2 - y1)
if (dotProduct < 0.1) return false; // 시작점 바깥쪽 if (dotProduct < 0.1) return false // 시작점 바깥쪽
const squaredLength = (x2 - x1) ** 2 + (y2 - y1) ** 2; const squaredLength = (x2 - x1) ** 2 + (y2 - y1) ** 2
if (dotProduct > squaredLength - 0.1) return false; // 끝점 바깥쪽 if (dotProduct > squaredLength - 0.1) return false // 끝점 바깥쪽
return true; // 선의 내부(몸통)에 있음 return true // 선의 내부(몸통)에 있음
}; }
// roofLine의 끝점 중 하나가 eaveLine의 몸통 안에 있거나, // roofLine의 끝점 중 하나가 eaveLine의 몸통 안에 있거나,
// eaveLine의 끝점 중 하나가 roofLine의 몸통 안에 있으면 "포개짐"으로 판단 // eaveLine의 끝점 중 하나가 roofLine의 몸통 안에 있으면 "포개짐"으로 판단
@ -1011,20 +1196,21 @@ export function useRoofAllocationSetting(id) {
isPointInside(rX1, rY1, eX1, eY1, eX2, eY2) || isPointInside(rX1, rY1, eX1, eY1, eX2, eY2) ||
isPointInside(rX2, rY2, eX1, eY1, eX2, eY2) || isPointInside(rX2, rY2, eX1, eY1, eX2, eY2) ||
isPointInside(eX1, eY1, rX1, rY1, rX2, rY2) || isPointInside(eX1, eY1, rX1, rY1, rX2, rY2) ||
isPointInside(eX2, eY2, rX1, rY1, rX2, rY2); isPointInside(eX2, eY2, rX1, rY1, rX2, rY2)
if (isOverlapping) { if (isOverlapping) {
logger.log('Removing overlapping line:', roofLine); logger.log('Removing overlapping line:', roofLine)
return true; // 포개지는 경우에만 삭제 return true // 포개지는 경우에만 삭제
} }
} }
} }
return false; // 끝점만 닿아 있거나 직각인 경우는 살림
}); return false // 끝점만 닿아 있거나 직각인 경우는 살림
return !shouldRemove; })
}); return !shouldRemove
})
// Combine remaining lines with newEaveLines // Combine remaining lines with newEaveLines
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines]; roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines]
} else { } else {
roofBase.lines = [...roofEaveHelpLines] roofBase.lines = [...roofEaveHelpLines]
} }
@ -1065,9 +1251,7 @@ export function useRoofAllocationSetting(id) {
integrateExtensionLines(roofBase) integrateExtensionLines(roofBase)
// [KERAB-PATTERN-DIAG 2026-05-19] 패턴 라인 상태 진단 — 분할 직전 스냅샷 // [KERAB-PATTERN-DIAG 2026-05-19] 패턴 라인 상태 진단 — 분할 직전 스냅샷
const __patternLines = roofBase.innerLines.filter( const __patternLines = roofBase.innerLines.filter((l) => l?.lineName === 'kerabPatternHip' || l?.lineName === 'kerabPatternRidge')
(l) => l?.lineName === 'kerabPatternHip' || l?.lineName === 'kerabPatternRidge',
)
if (__patternLines.length > 0) { if (__patternLines.length > 0) {
logger.log( logger.log(
`[KERAB-PATTERN-DIAG] roof=${roofBase.id} patternLines=${__patternLines.length} ` + `[KERAB-PATTERN-DIAG] roof=${roofBase.id} patternLines=${__patternLines.length} ` +
@ -1087,24 +1271,18 @@ export function useRoofAllocationSetting(id) {
// case A(저장 X→케라바→할당) 에서 canvas 의 kerabValleyOverlapLine 가 silent 사라지는 문제 회피. // case A(저장 X→케라바→할당) 에서 canvas 의 kerabValleyOverlapLine 가 silent 사라지는 문제 회피.
// vExt(kerabPatternValleyExt) 는 roof.innerLines 에 안정적으로 보존 — 거기서 직접 재계산. // vExt(kerabPatternValleyExt) 는 roof.innerLines 에 안정적으로 보존 — 거기서 직접 재계산.
// 조건: vExt 가 polygon 내부에 들어온 케이스 한정 (innerLines 에 있으면 자동 충족). // 조건: vExt 가 polygon 내부에 들어온 케이스 한정 (innerLines 에 있으면 자동 충족).
const vExtsForOverlap = (roofBase.innerLines || []).filter( const vExtsForOverlap = (roofBase.innerLines || []).filter((l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false)
(l) => l && l.lineName === 'kerabPatternValleyExt' && l.visible !== false,
)
// [KERAB-VALLEY-OVERLAP-DEDUP 2026-06-04] buildOverlapLine(useEavesGableEdit)가 케라바 토글 시 // [KERAB-VALLEY-OVERLAP-DEDUP 2026-06-04] buildOverlapLine(useEavesGableEdit)가 케라바 토글 시
// 생성한 밴드와 이 RECALC 가 이중 생성 → split 가 중복 면 → 머지 모호 → 대각선. // 생성한 밴드와 이 RECALC 가 이중 생성 → split 가 중복 면 → 머지 모호 → 대각선.
// 단일 소스로 통일: 재계산 전 기존 kerabValleyOverlapLine 을 roofBase/canvas 에서 제거. // 단일 소스로 통일: 재계산 전 기존 kerabValleyOverlapLine 을 roofBase/canvas 에서 제거.
// vExt 가 있는 roof 만 (= RECALC 가 다시 생성할 roof) 대상 — 아니면 기존 밴드 유실. // vExt 가 있는 roof 만 (= RECALC 가 다시 생성할 roof) 대상 — 아니면 기존 밴드 유실.
if (vExtsForOverlap.length > 0) { if (vExtsForOverlap.length > 0) {
const isBandLine = (l) => const isBandLine = (l) => l && (l.lineName === 'kerabValleyOverlapLine' || l.attributes?.type === 'kerabValleyOverlapLine')
l && (l.lineName === 'kerabValleyOverlapLine' || l.attributes?.type === 'kerabValleyOverlapLine')
roofBase.lines = (roofBase.lines || []).filter((l) => !isBandLine(l)) roofBase.lines = (roofBase.lines || []).filter((l) => !isBandLine(l))
roofBase.innerLines = (roofBase.innerLines || []).filter((l) => !isBandLine(l)) roofBase.innerLines = (roofBase.innerLines || []).filter((l) => !isBandLine(l))
canvas canvas
.getObjects() .getObjects()
.filter( .filter((o) => isBandLine(o) && (o.roofId === roofBase.id || o.parentId === roofBase.id || o.attributes?.roofId === roofBase.id))
(o) =>
isBandLine(o) && (o.roofId === roofBase.id || o.parentId === roofBase.id || o.attributes?.roofId === roofBase.id),
)
.forEach((o) => canvas.remove(o)) .forEach((o) => canvas.remove(o))
} }
// [KERAB-VALLEY-EXT-SPLIT 2026-06-04] vExt 가 split 되면 같은 V1 이 여러 collinear 세그먼트로 쪼개진다. // [KERAB-VALLEY-EXT-SPLIT 2026-06-04] vExt 가 split 되면 같은 V1 이 여러 collinear 세그먼트로 쪼개진다.

View File

@ -1,15 +1,16 @@
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentObjectState, pitchTextSelector } from '@/store/canvasAtom' import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentMenuState, currentObjectState, pitchTextSelector } from '@/store/canvasAtom'
import { useRecoilValue } from 'recoil' import { useRecoilValue, useSetRecoilState } from 'recoil'
import { useEffect, useRef, useState } from 'react' import { useEffect, useRef, useState } from 'react'
import { useLine } from '@/hooks/useLine' import { useLine } from '@/hooks/useLine'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
import { useEvent } from '@/hooks/useEvent' 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 { useMode } from '@/hooks/useMode'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
import { usePopup } from '@/hooks/usePopup' import { usePopup } from '@/hooks/usePopup'
import { getChonByDegree } from '@/util/canvas-util' import { getChonByDegree } from '@/util/canvas-util'
import { useUndoRedo } from '@/hooks/useUndoRedo'
//지붕형상 수동 설정 //지붕형상 수동 설정
export function useRoofShapePassivitySetting(id) { export function useRoofShapePassivitySetting(id) {
@ -19,6 +20,7 @@ export function useRoofShapePassivitySetting(id) {
SHED: 'shed', SHED: 'shed',
} }
const canvas = useRecoilValue(canvasState) const canvas = useRecoilValue(canvasState)
const setCurrentMenu = useSetRecoilState(currentMenuState)
const currentAngleType = useRecoilValue(currentAngleTypeSelector) const currentAngleType = useRecoilValue(currentAngleTypeSelector)
const pitchText = useRecoilValue(pitchTextSelector) const pitchText = useRecoilValue(pitchTextSelector)
const { getMessage } = useMessage() const { getMessage } = useMessage()
@ -35,10 +37,13 @@ export function useRoofShapePassivitySetting(id) {
const history = useRef([]) const history = useRef([])
const [type, setType] = useState(TYPES.EAVES) const [type, setType] = useState(TYPES.EAVES)
const isFix = useRef(false) const isFix = useRef(false)
const isConverted = useRef(false)
const initLines = useRef([]) const initLines = useRef([])
const removedWalls = useRef([])
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const { closePopup } = usePopup() const { closePopup } = usePopup()
const { saveSnapshot, setDrawing } = useUndoRedo()
const buttons = [ const buttons = [
{ id: 1, name: getMessage('eaves'), type: TYPES.EAVES }, { id: 1, name: getMessage('eaves'), type: TYPES.EAVES },
{ id: 2, name: getMessage('gable'), type: TYPES.GABLE }, { id: 2, name: getMessage('gable'), type: TYPES.GABLE },
@ -52,7 +57,13 @@ export function useRoofShapePassivitySetting(id) {
closePopup(id) closePopup(id)
//return //return
} }
// 수동설정 popup 진행 중에는 undo/redo/snapshot 차단
setDrawing(true)
setIsLoading(true) setIsLoading(true)
return () => {
// popup 닫힐 때 (handleSave 후 closePopup, 또는 사용자가 그냥 닫기) 잠금 해제
setDrawing(false)
}
}, []) }, [])
useEffect(() => { useEffect(() => {
@ -60,6 +71,7 @@ export function useRoofShapePassivitySetting(id) {
addCanvasMouseEventListener('mouse:down', mouseDown) addCanvasMouseEventListener('mouse:down', mouseDown)
const wallLines = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.WALL) 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) canvas?.remove(...wallLines)
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
@ -191,17 +203,52 @@ export function useRoofShapePassivitySetting(id) {
} }
const handleSave = () => { 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 isFix.current = true
handleLineToPolygon() handleLineToPolygon()
// 수동설정 완료 → 지붕면 할당 메뉴로 전환 (undo 가드 해제)
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_ALLOC)
closePopup(id) closePopup(id)
} }
const handleLineToPolygon = () => { const handleLineToPolygon = () => {
// 완료(handleSave) 후 closePopup → 언마운트 cleanup 에서 중복 호출되는 것을 방지 (스냅샷/지붕 중복 생성 차단)
if (isConverted.current) return
isConverted.current = true
const roofBases = canvas.getObjects().filter((obj) => obj.name === POLYGON_TYPE.ROOF) 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') 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 let checkedAllSetting = true
lines.forEach((line) => { lines.forEach((line) => {
@ -219,36 +266,42 @@ export function useRoofShapePassivitySetting(id) {
canvas.remove(obj) canvas.remove(obj)
}) })
// 저장 시에는 자동 설정과 동일하게 외벽선을 타입별 색상으로 표시
lines.forEach((line) => { lines.forEach((line) => {
hideLine(line) let stroke
}) if (line.attributes?.type === LINE_TYPE.WALLLINE.EAVES || line.attributes?.type === LINE_TYPE.WALLLINE.HIPANDGABLE) {
stroke = '#45CD7D'
let wall } else if (line.attributes?.type === LINE_TYPE.WALLLINE.GABLE || line.attributes?.type === LINE_TYPE.WALLLINE.JERKINHEAD) {
stroke = '#3FBAE6'
if (isFix.current) {
wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
} else { } else {
// 그냥 닫을 경우 처리 stroke = '#000000'
wall = addPolygonByLines([...initLines.current], {
name: POLYGON_TYPE.WALL,
fill: 'transparent',
stroke: 'black',
})
lines.forEach((line, idx) => {
line.attributes = initLines.current[idx].attributes
})
} }
// [2026-06-16] 저장 후에도 외벽선 클릭 선택 허용. 드래그는 lockMovement(useCanvas), 보조선 가로채기는 z-order(18abbeb9)로 별도 차단
line.set({ stroke, strokeWidth: 4, selectable: true, visible: true })
})
const wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
wall.lines = [...lines] wall.lines = [...lines]
// 기존 그려진 지붕이 없다면
if (isFix.current) {
// 완료 한 경우에는 지붕까지 그려줌 // 완료 한 경우에는 지붕까지 그려줌
addPitchTextsByOuterLines() addPitchTextsByOuterLines()
const roof = drawRoofPolygon(wall) const roof = drawRoofPolygon(wall)
addLengthText(roof) 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() canvas.renderAll()
} }

View File

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil' import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentMenuState, currentObjectState, pitchTextSelector } from '@/store/canvasAtom' 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 { usePolygon } from '@/hooks/usePolygon'
import { useMode } from '@/hooks/useMode' import { useMode } from '@/hooks/useMode'
import { useLine } from '@/hooks/useLine' import { useLine } from '@/hooks/useLine'
@ -20,6 +20,9 @@ import {
} from '@/store/settingAtom' } from '@/store/settingAtom'
import { useAxios } from '@/hooks/useAxios' import { useAxios } from '@/hooks/useAxios'
import { globalLocaleStore } from '@/store/localeAtom' 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) { export function useRoofShapeSetting(id) {
@ -52,6 +55,11 @@ export function useRoofShapeSetting(id) {
const isFixRef = useRef(false) const isFixRef = useRef(false)
// 저장 없이 닫을 때 롤백할 진입 시점 스냅샷
const initLinesRef = useRef([])
const removedWallsRef = useRef([])
const pitchTextLineIdsRef = useRef(new Set())
const pitchRef = useRef(null) const pitchRef = useRef(null)
const shedPitchRef = useRef(null) const shedPitchRef = useRef(null)
const jerkinHeadPitchRef = useRef(null) const jerkinHeadPitchRef = useRef(null)
@ -69,6 +77,8 @@ export function useRoofShapeSetting(id) {
const { post } = useAxios(globalLocaleState) const { post } = useAxios(globalLocaleState)
const { addLine, removeLine } = useLine() const { addLine, removeLine } = useLine()
const { saveSnapshot } = useUndoRedo()
const [pendingPopup, setPendingPopup] = useRecoilState(pendingPopupState)
useEffect(() => { useEffect(() => {
pitchRef.current = currentAngleType === ANGLE_TYPE.SLOPE ? pitch : getChonByDegree(pitch) 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) jerkinHeadPitchRef.current = currentAngleType === ANGLE_TYPE.SLOPE ? jerkinHeadPitch : getChonByDegree(jerkinHeadPitch)
}, [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(() => { useEffect(() => {
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine') const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
if (!canvas.outerLineFix || outerLines.length === 0) { if (!canvas.outerLineFix || outerLines.length === 0) {
@ -87,14 +109,46 @@ export function useRoofShapeSetting(id) {
closePopup(id) closePopup(id)
return 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 outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
const pitchTexts = canvas.getObjects().filter((obj) => obj.name === 'pitchText') 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) canvas.remove(...pitchTexts)
outerLines.forEach((line) => { outerLines.forEach((line) => {
let stroke, strokeWidth let stroke, strokeWidth
@ -113,12 +167,21 @@ export function useRoofShapeSetting(id) {
line.set({ line.set({
stroke, stroke,
strokeWidth, strokeWidth,
// [2026-06-16] 저장 후에도 외벽선 클릭 선택 허용. 드래그는 lockMovement(useCanvas), 보조선 가로채기는 z-order(18abbeb9)로 별도 차단
selectable: true,
visible: true,
}) })
addPitchText(line) 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() canvas.renderAll()
} }
}, []) }, [])
@ -144,10 +207,15 @@ export function useRoofShapeSetting(id) {
useEffect(() => { useEffect(() => {
if (shapeNum === 4) { 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') const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
outerLines.forEach((line) => { outerLines.forEach((line) => {
showLine(line) showLine(line)
line.set({ selectable: true })
line.bringToFront() line.bringToFront()
}) })
@ -182,6 +250,8 @@ export function useRoofShapeSetting(id) {
] ]
const handleSave = () => { 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 outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
let direction let direction
@ -482,6 +552,8 @@ export function useRoofShapeSetting(id) {
canvas?.renderAll() canvas?.renderAll()
roof.drawHelpLine(settingModalFirstOptions) roof.drawHelpLine(settingModalFirstOptions)
isFixRef.current = true isFixRef.current = true
// 지붕형상 설정 완료 → 지붕면 할당 메뉴로 전환
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_ALLOC)
addPopup(id, 1, <RoofAllocationSetting id={id} pos={{ x: 50, y: 230 }} />) addPopup(id, 1, <RoofAllocationSetting id={id} pos={{ x: 50, y: 230 }} />)
} }
@ -661,6 +733,7 @@ export function useRoofShapeSetting(id) {
return return
} }
saveSnapshot()
let attributes let attributes
switch (buttonAct) { switch (buttonAct) {
case 1: { case 1: {

View File

@ -8,6 +8,7 @@ import { useSwal } from '@/hooks/useSwal'
import { usePopup } from '@/hooks/usePopup' import { usePopup } from '@/hooks/usePopup'
import Big from 'big.js' import Big from 'big.js'
import { outerLineFixState } from '@/store/outerLineAtom' import { outerLineFixState } from '@/store/outerLineAtom'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { calcLinePlaneSize } from '@/util/qpolygon-utils' import { calcLinePlaneSize } from '@/util/qpolygon-utils'
// 외벽선 편집 및 오프셋 // 외벽선 편집 및 오프셋
@ -18,7 +19,7 @@ export function useWallLineOffsetSetting(id) {
const { closePopup } = usePopup() const { closePopup } = usePopup()
const { swalFire } = useSwal() const { swalFire } = useSwal()
const { addCanvasMouseEventListener, initEvent } = useEvent() const { addCanvasMouseEventListener, initEvent } = useEvent()
// const { addCanvasMouseEventListener, initEvent } = useContext(EventContext) const { saveSnapshot } = useUndoRedo()
const wallLineEditRef = useRef(null) const wallLineEditRef = useRef(null)
const length1Ref = useRef(null) const length1Ref = useRef(null)
const length2Ref = useRef(null) const length2Ref = useRef(null)
@ -204,9 +205,9 @@ export function useWallLineOffsetSetting(id) {
const handleSave = () => { const handleSave = () => {
if (currentWallLineRef.current === null) { if (currentWallLineRef.current === null) {
// alert('보조선을 먼저 선택하세요')
return return
} }
saveSnapshot()
switch (type) { switch (type) {
case TYPES.WALL_LINE_EDIT: case TYPES.WALL_LINE_EDIT:
handleWallLineEditSave() 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 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) const nextX = currentLine.x1 < currentLine.x2 ? Math.max(currentLine.x1, currentLine.x2) : Math.min(currentLine.x1, currentLine.x2)
if (arrow1Ref.current === 'up') { 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) { if (prevVector === currentVector) {
const point1 = { x: prevX, y: prevLine.y2 } const point1 = { x: prevX, y: prevLine.y2 }
const point2 = { x: prevX, y: currentLine.y1 } const point2 = { x: prevX, y: currentLine.y1 }
@ -390,7 +394,10 @@ export function useWallLineOffsetSetting(id) {
} }
} }
if (arrow1Ref.current === 'down') { 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) { if (prevVector === currentVector) {
const point1 = { x: prevX, y: prevLine.y2 } const point1 = { x: prevX, y: prevLine.y2 }
const point2 = { x: prevX, y: currentLine.y1 } 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 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) const nextY = currentLine.y1 < currentLine.y2 ? Math.max(currentLine.y1, currentLine.y2) : Math.min(currentLine.y1, currentLine.y2)
if (arrow1Ref.current === 'left') { 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) { if (prevVector === currentVector) {
const point1 = { x: prevLine.x2, y: prevY } const point1 = { x: prevLine.x2, y: prevY }
@ -464,7 +474,10 @@ export function useWallLineOffsetSetting(id) {
} }
} }
if (arrow1Ref.current === 'right') { 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) { if (prevVector === currentVector) {
const point1 = { x: prevLine.x2, y: prevY } 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') const newOuterLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
newOuterLines.sort((a, b) => a.idx - b.idx) newOuterLines.sort((a, b) => a.idx - b.idx)
newOuterLines.forEach((line, idx) => { newOuterLines.forEach((line, idx) => {

View File

@ -29,6 +29,7 @@ import { useSurfaceShapeBatch } from './useSurfaceShapeBatch'
import { roofDisplaySelector } from '@/store/settingAtom' import { roofDisplaySelector } from '@/store/settingAtom'
import { useRoofFn } from '@/hooks/common/useRoofFn' import { useRoofFn } from '@/hooks/common/useRoofFn'
import { useObject } from '@/hooks/useObject' import { useObject } from '@/hooks/useObject'
import { useUndoRedo } from '@/hooks/useUndoRedo'
// 배치면 그리기 // 배치면 그리기
export function usePlacementShapeDrawing(id) { export function usePlacementShapeDrawing(id) {
@ -50,6 +51,7 @@ export function usePlacementShapeDrawing(id) {
const { setSurfaceShapePattern } = useRoofFn() const { setSurfaceShapePattern } = useRoofFn()
const { changeSurfaceLineType } = useSurfaceShapeBatch({}) const { changeSurfaceLineType } = useSurfaceShapeBatch({})
const { handleSelectableObjects } = useObject() const { handleSelectableObjects } = useObject()
const { saveSnapshot, popLastSnapshot, setDrawing } = useUndoRedo()
const { swalFire } = useSwal() const { swalFire } = useSwal()
const { getMessage } = useMessage() const { getMessage } = useMessage()
@ -96,13 +98,22 @@ export function usePlacementShapeDrawing(id) {
}, [verticalHorizontalMode, points, adsorptionRange, adsorptionPointMode]) }, [verticalHorizontalMode, points, adsorptionRange, adsorptionPointMode])
useEffect(() => { useEffect(() => {
// 배치면 그리기 시작 시 스냅샷 저장 (undo 하면 그리기 전 상태로 복원).
// 그리기 진행 중에는 undo/redo/snapshot 을 차단하므로 force 로 저장한다.
setDrawing(true)
saveSnapshot(null, { force: true })
setPoints([]) setPoints([])
setType(OUTER_LINE_TYPE.OUTER_LINE) setType(OUTER_LINE_TYPE.OUTER_LINE)
return () => { return () => {
setDrawing(false)
const placementShapeDrawingStartPoint = canvas.getObjects().find((obj) => obj.name === 'placementShapeDrawingStartPoint') const placementShapeDrawingStartPoint = canvas.getObjects().find((obj) => obj.name === 'placementShapeDrawingStartPoint')
canvas.remove(placementShapeDrawingStartPoint) canvas.remove(placementShapeDrawingStartPoint)
// 확정하지 않고 닫은 경우 스냅샷 제거
if (!isFix.current) {
popLastSnapshot()
}
} }
}, []) }, [])
@ -148,9 +159,17 @@ export function usePlacementShapeDrawing(id) {
const slope = Math.abs(vector.y / vector.x) const slope = Math.abs(vector.y / vector.x)
if (slope >= 1) { if (slope >= 1) {
newPoint = { x: lastPoint.x, y: pointer.y } // 기울기가 1 이상이면 수직으로 제한
newPoint = {
x: lastPoint.x,
y: pointer.y,
}
} else { } else {
newPoint = { x: pointer.x, y: lastPoint.y } // 기울기가 1 미만이면 수평으로 제한
newPoint = {
x: pointer.x,
y: lastPoint.y,
}
} }
} }
setPoints((prev) => [...prev, newPoint]) setPoints((prev) => [...prev, newPoint])
@ -311,9 +330,9 @@ export function usePlacementShapeDrawing(id) {
// 인접하지 않은 별개 屋根까지 끌어와 사용자가 입력한 치수를 변질시켜 제거. tolerance 도 5mm 로 축소. // 인접하지 않은 별개 屋根까지 끌어와 사용자가 입력한 치수를 변질시켜 제거. tolerance 도 5mm 로 축소.
const inheritPlaneSizeFromExistingShapes = (newPolygon) => { const inheritPlaneSizeFromExistingShapes = (newPolygon) => {
const tolerance = 0.5 const tolerance = 0.5
const existingPolygons = canvas.getObjects().filter( const existingPolygons = canvas
(obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.id !== newPolygon.id && obj.lines, .getObjects()
) .filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.id !== newPolygon.id && obj.lines)
if (existingPolygons.length === 0) return if (existingPolygons.length === 0) return
@ -336,15 +355,9 @@ export function usePlacementShapeDrawing(id) {
// 양 끝점이 정확히 일치(흡착 공유변)할 때만 상속 // 양 끝점이 정확히 일치(흡착 공유변)할 때만 상속
const forwardMatch = const forwardMatch =
Math.abs(x1 - ex1) < tolerance && Math.abs(x1 - ex1) < tolerance && Math.abs(y1 - ey1) < tolerance && Math.abs(x2 - ex2) < tolerance && Math.abs(y2 - ey2) < tolerance
Math.abs(y1 - ey1) < tolerance &&
Math.abs(x2 - ex2) < tolerance &&
Math.abs(y2 - ey2) < tolerance
const reverseMatch = const reverseMatch =
Math.abs(x1 - ex2) < tolerance && Math.abs(x1 - ex2) < tolerance && Math.abs(y1 - ey2) < tolerance && Math.abs(x2 - ex1) < tolerance && Math.abs(y2 - ey1) < tolerance
Math.abs(y1 - ey2) < tolerance &&
Math.abs(x2 - ex1) < tolerance &&
Math.abs(y2 - ey1) < tolerance
if (forwardMatch || reverseMatch) { if (forwardMatch || reverseMatch) {
line.attributes = { ...line.attributes, planeSize: edge.attributes.planeSize } line.attributes = { ...line.attributes, planeSize: edge.attributes.planeSize }
@ -368,9 +381,7 @@ export function usePlacementShapeDrawing(id) {
// [2026-06-08] 끝점이 정확히 일치하는 흡착 공유변만 상속. // [2026-06-08] 끝점이 정확히 일치하는 흡착 공유변만 상속.
const findMatchingEdgePlaneSize = (x1, y1, x2, y2) => { const findMatchingEdgePlaneSize = (x1, y1, x2, y2) => {
const tolerance = 0.5 const tolerance = 0.5
const existingPolygons = canvas.getObjects().filter( const existingPolygons = canvas.getObjects().filter((obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.lines)
(obj) => (obj.name === POLYGON_TYPE.ROOF || obj.name === POLYGON_TYPE.WALL) && obj.lines,
)
for (const polygon of existingPolygons) { for (const polygon of existingPolygons) {
for (const edge of polygon.lines) { for (const edge of polygon.lines) {

View File

@ -6,6 +6,7 @@ import { usePopup } from '../usePopup'
import useSWR from 'swr' import useSWR from 'swr'
import { useSwal } from '../useSwal' import { useSwal } from '../useSwal'
import { useMessage } from '../useMessage' import { useMessage } from '../useMessage'
import { useUndoRedo } from '../useUndoRedo'
const LINE_COLOR = { const LINE_COLOR = {
EAVES: '#45CD7D', EAVES: '#45CD7D',
@ -23,6 +24,7 @@ export function useRoofLinePropertySetting(props) {
const { closePopup } = usePopup() const { closePopup } = usePopup()
const { swalFire } = useSwal() const { swalFire } = useSwal()
const { getMessage } = useMessage() const { getMessage } = useMessage()
const { saveSnapshot, popLastSnapshot } = useUndoRedo()
useEffect(() => { useEffect(() => {
if (currentObject && currentObject.name === 'cloneRoofLine') { if (currentObject && currentObject.name === 'cloneRoofLine') {
@ -47,10 +49,20 @@ export function useRoofLinePropertySetting(props) {
} }
}, [currentObject]) }, [currentObject])
const isFixed = useRef(false)
useEffect(() => { useEffect(() => {
// 팝업 열릴 때 스냅샷 저장
saveSnapshot()
return () => { return () => {
canvas.remove(...canvas.getObjects().filter((obj) => obj.name === 'cloneRoofLine')) canvas.remove(...canvas.getObjects().filter((obj) => obj.name === 'cloneRoofLine'))
canvas.renderAll() canvas.renderAll()
// 설정 완료하지 않고 닫은 경우 스냅샷 제거
if (!isFixed.current) {
popLastSnapshot()
}
} }
}, []) }, [])
@ -149,8 +161,6 @@ export function useRoofLinePropertySetting(props) {
} }
const handleFix = () => { const handleFix = () => {
// const roof = canvas.getObjects().find((obj) => currentObject.parentId === obj.id)
// const notSettingLines = roof.lines.filter(
const notSettingLines = canvas const notSettingLines = canvas
.getObjects() .getObjects()
.filter((obj) => obj.name === 'cloneRoofLine') .filter((obj) => obj.name === 'cloneRoofLine')
@ -187,6 +197,7 @@ export function useRoofLinePropertySetting(props) {
// }) // })
// }) // })
isFixed.current = true
canvas.renderAll() canvas.renderAll()
closePopup(id) closePopup(id)
if (setIsHidden) setIsHidden(false) if (setIsHidden) setIsHidden(false)

View File

@ -1,22 +1,16 @@
'use client' 'use client'
import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil' import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil'
import { import { canvasSettingState, canvasState, currentCanvasPlanState, currentObjectState, globalPitchState } from '@/store/canvasAtom'
canvasSettingState,
canvasState,
currentCanvasPlanState,
currentObjectState,
globalPitchState,
} from '@/store/canvasAtom'
import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common' 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 { degreesToRadians } from '@turf/turf'
import { QPolygon } from '@/components/fabric/QPolygon' import { QPolygon } from '@/components/fabric/QPolygon'
import { useSwal } from '@/hooks/useSwal' import { useSwal } from '@/hooks/useSwal'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
import { useEvent } from '@/hooks/useEvent' import { useEvent } from '@/hooks/useEvent'
import { usePopup } from '@/hooks/usePopup' import { usePopup } from '@/hooks/usePopup'
import { roofDisplaySelector, basicSettingState } from '@/store/settingAtom' import { basicSettingState, corridorDimensionSelector, roofDisplaySelector } from '@/store/settingAtom'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
import { fontSelector } from '@/store/fontAtom' import { fontSelector } from '@/store/fontAtom'
import { slopeSelector } from '@/store/commonAtom' import { slopeSelector } from '@/store/commonAtom'
@ -30,6 +24,7 @@ import { useText } from '@/hooks/useText'
import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting' import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import { useState } from 'react' import { useState } from 'react'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { fabric } from 'fabric' import { fabric } from 'fabric'
export function useSurfaceShapeBatch({ isHidden, setIsHidden }) { export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
@ -53,9 +48,12 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const currentCanvasPlan = useRecoilValue(currentCanvasPlanState) const currentCanvasPlan = useRecoilValue(currentCanvasPlanState)
const { fetchSettings } = useCanvasSetting(false) const { fetchSettings } = useCanvasSetting(false)
const [currentObject, setCurrentObject] = useRecoilState(currentObjectState) const [currentObject, setCurrentObject] = useRecoilState(currentObjectState)
const [, setCorridorDimension] = useRecoilState(corridorDimensionSelector)
const [popupId, setPopupId] = useState(uuidv4()) const [popupId, setPopupId] = useState(uuidv4())
const { saveSnapshot } = useUndoRedo()
const applySurfaceShape = (surfaceRefs, selectedType, id) => { const applySurfaceShape = (surfaceRefs, selectedType, id) => {
saveSnapshot()
let length1, length2, length3, length4, length5 let length1, length2, length3, length4, length5
const surfaceId = selectedType?.id const surfaceId = selectedType?.id
const azimuth = surfaceRefs.azimuth.current 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) const planeSize = Math.round(Math.sqrt(dx * dx + dy * dy) * 10)
let actualSize = planeSize 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) { if (+roofSizeSet === 1 && globalPitch > 0) {
const pitchDeg = getDegreeByChon(globalPitch) const pitchDeg = getDegreeByChon(globalPitch)
const isV = Math.abs(start.x - end.x) < 1 const isV = Math.abs(start.x - end.x) < 1
const isH = Math.abs(start.y - end.y) < 1 const isH = Math.abs(start.y - end.y) < 1
const isD = !isH && !isV const isD = !isH && !isV
if (dir === 'south' || dir === 'north') { 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) { 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)) actualSize = Math.round(Math.sqrt(h * h + planeSize * planeSize))
} }
} else if (dir === 'west' || dir === 'east') { } 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) { 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)) actualSize = Math.round(Math.sqrt(h * h + planeSize * planeSize))
} }
} }
@ -850,6 +848,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
text: getMessage('batch.canvas.delete.all'), text: getMessage('batch.canvas.delete.all'),
type: 'confirm', type: 'confirm',
confirmFn: () => { confirmFn: () => {
saveSnapshot()
canvas.clear() canvas.clear()
delete canvas.outerLineFix delete canvas.outerLineFix
@ -946,6 +945,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
} }
const moveSurfaceShapeBatch = () => { const moveSurfaceShapeBatch = () => {
saveSnapshot()
const roof = canvas.getActiveObject() const roof = canvas.getActiveObject()
if (roof) { if (roof) {
@ -986,7 +986,10 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
roof.fire('modified') roof.fire('modified')
drawDirectionArrow(roof) drawDirectionArrow(roof)
changeCorridorDimensionText() changeCorridorDimensionText()
addLengthText(roof) // [이동 후 길이 fix] 이동 경로는 addLengthText 만 호출해 actualSize(경사 보정 실제길이)가
// 재계산되지 않고 평면길이로 표시되던 문제. 생성/resize 경로와 동일하게
// setPolygonLinesActualSize(내부에서 actualSize 재계산 + addLengthText) 로 통일한다.
setPolygonLinesActualSize(roof, true)
roof.setCoords() roof.setCoords()
initEvent() initEvent()
canvas.renderAll() canvas.renderAll()
@ -1026,6 +1029,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const resizeSurfaceShapeBatch = (side, target, width, height) => { const resizeSurfaceShapeBatch = (side, target, width, height) => {
if (!target || target.type !== 'QPolygon') return if (!target || target.type !== 'QPolygon') return
saveSnapshot()
width = width / 10 width = width / 10
height = height / 10 height = height / 10
@ -1144,6 +1148,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
from: 'surface', from: 'surface',
}) })
// 리사이즈 후 이전 사이즈 속성 초기화 (새 기하학적 길이로 재계산하기 위해)
newPolygon.lines.forEach((line) => {
delete line.attributes.planeSize
delete line.attributes.actualSize
delete line.attributes.isCalculated
})
// 캔버스에 추가 // 캔버스에 추가
canvas.add(newPolygon) canvas.add(newPolygon)
@ -1155,6 +1166,13 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
drawDirectionArrow(newPolygon) drawDirectionArrow(newPolygon)
newPolygon.setCoords() newPolygon.setCoords()
changeSurfaceLineType(newPolygon) 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 // [SIZE-RESIZE-PLANESIZE 2026-06-08] サイズ変更 시 좌표는 새 크기로 갱신되지만 initLines 가 옛 attributes
// (planeSize=구 길이) 를 그대로 물려줘 라벨이 stale 하게 남던 문제. 변경된 새 좌표 길이로 planeSize 를 // (planeSize=구 길이) 를 그대로 물려줘 라벨이 stale 하게 남던 문제. 변경된 새 좌표 길이로 planeSize 를
// 강제 재계산 후 actualSize/라벨 재생성 (최초 생성 경로 line 302/320 과 동일하게 맞춤). // 강제 재계산 후 actualSize/라벨 재생성 (최초 생성 경로 line 302/320 과 동일하게 맞춤).
@ -1165,6 +1183,17 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
canvas?.renderAll() canvas?.renderAll()
closeAll() closeAll()
addPopup(popupId, 1, <SizeSetting id={popupId} side={side} target={newPolygon} />) 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 = () => { const changeSurfaceLinePropertyEvent = () => {
@ -1550,6 +1579,7 @@ export function useSurfaceShapeBatch({ isHidden, setIsHidden }) {
const rotateSurfaceShapeBatch = () => { const rotateSurfaceShapeBatch = () => {
if (currentObject) { if (currentObject) {
saveSnapshot()
// 관련 객체들 찾기 // 관련 객체들 찾기
// arrow는 제거 // arrow는 제거
const arrow = canvas.getObjects().find((obj) => obj.parentId === currentObject.id && obj.name === 'arrow') const arrow = canvas.getObjects().find((obj) => obj.parentId === currentObject.id && obj.name === 'arrow')

View File

@ -179,6 +179,7 @@ export function useCanvas(id) {
// 해당 오브젝트 타입의 경우 저장한 값 그대로 불러와야함 // 해당 오브젝트 타입의 경우 저장한 값 그대로 불러와야함
OBJECT_PROTOTYPE.forEach((type) => { OBJECT_PROTOTYPE.forEach((type) => {
const originalToObject = type.toObject
type.toObject = function (propertiesToInclude) { type.toObject = function (propertiesToInclude) {
let source = {} 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)
} }
}) })

View File

@ -8,6 +8,7 @@ import { fontSelector } from '@/store/fontAtom'
import { MENU, POLYGON_TYPE } from '@/common/common' import { MENU, POLYGON_TYPE } from '@/common/common'
import { useText } from '@/hooks/useText' import { useText } from '@/hooks/useText'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
import { useUndoRedo } from '@/hooks/useUndoRedo'
// 캔버스에 필요한 이벤트 // 캔버스에 필요한 이벤트
export function useCanvasEvent() { export function useCanvasEvent() {
@ -22,6 +23,7 @@ export function useCanvasEvent() {
const isManualModuleSetup = useRecoilValue(isManualModuleSetupState) const isManualModuleSetup = useRecoilValue(isManualModuleSetupState)
const { changeCorridorDimensionText } = useText() const { changeCorridorDimensionText } = useText()
const { setPolygonLinesActualSize } = usePolygon() const { setPolygonLinesActualSize } = usePolygon()
const { undo, redo, saveSnapshot } = useUndoRedo()
// [MODULE-MULTI-SELECT 2026-06-10] 모듈 복수선택 refine 재진입 가드 (rebuild 중첩 방지) // [MODULE-MULTI-SELECT 2026-06-10] 모듈 복수선택 refine 재진입 가드 (rebuild 중첩 방지)
const refiningRef = useRef(false) const refiningRef = useRef(false)
@ -53,6 +55,13 @@ export function useCanvasEvent() {
} }
}, [currentMenu, isManualModuleSetup]) }, [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 = () => { const attachDefaultEventOnCanvas = () => {
removeEventOnCanvas() removeEventOnCanvas()
@ -171,13 +180,13 @@ export function useCanvasEvent() {
target.set({ top: y }) target.set({ top: y })
} }
} else if (target.parentDirection === 'top' || target.parentDirection === 'bottom') { } else if (target.parentDirection === 'top' || target.parentDirection === 'bottom') {
const minY = target.minY const miny = target.miny
const maxY = target.maxY const maxy = target.maxy
if (target.top <= minY) { if (target.top <= miny) {
target.set({ left: x, top: minY }) target.set({ left: x, top: miny })
} else if (target.top >= maxY) { } else if (target.top >= maxy) {
target.set({ left: x, top: maxY }) target.set({ left: x, top: maxy })
} else { } else {
target.set({ left: x }) target.set({ left: x })
} }
@ -307,7 +316,14 @@ export function useCanvasEvent() {
// [MODULE-DRAG-DISABLE 2026-06-12] 단일 모듈 선택: 마우스 이동 잠금 유지(드래그 박스 복수선택·Del 삭제만 허용). 회전·스케일도 잠금. // [MODULE-DRAG-DISABLE 2026-06-12] 단일 모듈 선택: 마우스 이동 잠금 유지(드래그 박스 복수선택·Del 삭제만 허용). 회전·스케일도 잠금.
if (active.type !== 'activeSelection') { if (active.type !== 'activeSelection') {
if (active.name === POLYGON_TYPE.MODULE) { 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() canvas.requestRenderAll()
} }
return return
@ -390,14 +406,16 @@ export function useCanvasEvent() {
case 'Escape': case 'Escape':
break break
case 'z': /*case 'z':
if (!e.ctrlKey) { if (!e.ctrlKey && !e.metaKey) {
return return
} }
if (e.shiftKey) {
console.log('뒤로가기') redo()
} else {
break undo()
}
break*/
default: default:
return // 키 이벤트를 처리하지 않는다면 종료합니다. return // 키 이벤트를 처리하지 않는다면 종료합니다.
@ -476,20 +494,26 @@ export function useCanvasEvent() {
/** /**
* 선택한 도형을 삭제한다. * 선택한 도형을 삭제한다.
*/ */
let _isDeleting = false
const handleDelete = () => { const handleDelete = () => {
if (_isDeleting) return
const targets = canvas?.getActiveObjects() const targets = canvas?.getActiveObjects()
if (targets?.length === 0) { if (!targets || targets.length === 0) {
alert('삭제할 대상을 선택해주세요.')
return return
} }
_isDeleting = true
if (!confirm('정말로 삭제하시겠습니까?')) { if (!confirm('정말로 삭제하시겠습니까?')) {
_isDeleting = false
return return
} }
targets?.forEach((target) => { targets.forEach((target) => {
canvas?.remove(target) canvas?.remove(target)
}) })
canvas?.discardActiveObject()
canvas?.renderAll()
_isDeleting = false
} }
const handleZoom = (isZoom) => { const handleZoom = (isZoom) => {

View File

@ -43,6 +43,9 @@ import { useTrestle } from './module/useTrestle'
import { useCircuitTrestle } from './useCirCuitTrestle' import { useCircuitTrestle } from './useCirCuitTrestle'
import { usePolygon } from '@/hooks/usePolygon' import { usePolygon } from '@/hooks/usePolygon'
import { useText } from '@/hooks/useText' import { useText } from '@/hooks/useText'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { POLYGON_TYPE } from '@/common/common'
import { debugCapture } from '@/util/debugCapture'
export function useContextMenu() { export function useContextMenu() {
const canvas = useRecoilValue(canvasState) const canvas = useRecoilValue(canvasState)
@ -77,6 +80,7 @@ export function useContextMenu() {
const { isExistCircuit } = useCircuitTrestle() const { isExistCircuit } = useCircuitTrestle()
const { changeCorridorDimensionText } = useText() const { changeCorridorDimensionText } = useText()
const { setPolygonLinesActualSize, drawDirectionArrow } = usePolygon() const { setPolygonLinesActualSize, drawDirectionArrow } = usePolygon()
const { saveSnapshot } = useUndoRedo()
const currentMenuSetting = () => { const currentMenuSetting = () => {
switch (selectedMenu) { switch (selectedMenu) {
case 'outline': case 'outline':
@ -187,7 +191,10 @@ export function useContextMenu() {
{ {
id: 'roofMaterialRemoveAll', id: 'roofMaterialRemoveAll',
name: getMessage('contextmenu.roof.material.remove.all'), name: getMessage('contextmenu.roof.material.remove.all'),
fn: () => removeAllRoofMaterial(), fn: () => {
saveSnapshot()
removeAllRoofMaterial()
},
}, },
{ {
id: 'selectMove', id: 'selectMove',
@ -200,6 +207,7 @@ export function useContextMenu() {
id: 'wallLineRemove', id: 'wallLineRemove',
name: getMessage('contextmenu.wallline.remove'), name: getMessage('contextmenu.wallline.remove'),
fn: (currentMousePos) => { fn: (currentMousePos) => {
saveSnapshot()
removeOuterLines(currentMousePos) removeOuterLines(currentMousePos)
}, },
}, },
@ -276,7 +284,10 @@ export function useContextMenu() {
{ {
id: 'roofMaterialRemoveAll', id: 'roofMaterialRemoveAll',
name: getMessage('contextmenu.roof.material.remove.all'), name: getMessage('contextmenu.roof.material.remove.all'),
fn: () => removeAllRoofMaterial(), fn: () => {
saveSnapshot()
removeAllRoofMaterial()
},
}, },
{ {
id: 'selectMove', id: 'selectMove',
@ -382,16 +393,49 @@ export function useContextMenu() {
name: getMessage('contextmenu.auxiliary.remove'), name: getMessage('contextmenu.auxiliary.remove'),
fn: () => { fn: () => {
if (!currentObject) return 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) { if (!roof) {
// 아직 innerLines로 세팅이 안되어있는 line인 경우 제거 roof = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.ROOF && Array.isArray(obj.lines) && obj.lines.some(sameSeg))
canvas.remove(currentObject)
canvas.discardActiveObject()
return
} }
const innerLines = roof.innerLines?.filter((line) => currentObject.id !== line.id) const beforeLines = roof?.lines?.length ?? 0
roof.innerLines = [...innerLines] const beforeInner = roof?.innerLines?.length ?? 0
canvas.remove(currentObject) 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() canvas.discardActiveObject()
}, },
}, },
@ -400,6 +444,7 @@ export function useContextMenu() {
name: getMessage('contextmenu.auxiliary.vertical.bisector'), name: getMessage('contextmenu.auxiliary.vertical.bisector'),
fn: () => { fn: () => {
if (!currentObject) return if (!currentObject) return
saveSnapshot()
const slope = (currentObject.y2 - currentObject.y1) / (currentObject.x2 - currentObject.x1) const slope = (currentObject.y2 - currentObject.y1) / (currentObject.x2 - currentObject.x1)
const length = currentObject.length const length = currentObject.length
@ -449,6 +494,7 @@ export function useContextMenu() {
swalFire({ text: getMessage('roof.is.not.selected') }) swalFire({ text: getMessage('roof.is.not.selected') })
return return
} }
saveSnapshot()
const innerLines = canvas.getObjects().filter((obj) => obj.id === currentObject.attributes.roofId)[0]?.innerLines const innerLines = canvas.getObjects().filter((obj) => obj.id === currentObject.attributes.roofId)[0]?.innerLines
if (innerLines) { if (innerLines) {
innerLines.forEach((line) => { innerLines.forEach((line) => {
@ -457,15 +503,25 @@ export function useContextMenu() {
innerLines.length = 0 innerLines.length = 0
} }
// 확정되지 않은 보조선 // [전체삭제 누락 fix] 기존에는 미확정 보조선(!isAuxiliaryFixed)만 삭제해,
const notFixedAuxiliaryLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine' && !obj.isAuxiliaryFixed) // 복사본/미확정 보조선을 우클릭하면 currentObject.roofId 가 위 innerLines 와 어긋나
// 확정된 보조선이 누락되었다. 확정 여부와 무관하게 모든 보조선을 삭제한다.
notFixedAuxiliaryLines.forEach((line) => { const auxiliaryLines = canvas.getObjects().filter((obj) => obj.name === 'auxiliaryLine')
auxiliaryLines.forEach((line) => {
canvas.remove(line) canvas.remove(line)
}) })
//동이동현이동 // 지붕 innerLines 에 남은 보조선/eaveHelpLine 참조 정리 (stale 부활 방지, 용마루 hip·ridge 는 보존)
const eaveHelpLines = canvas.getObjects().filter((obj) => obj.name === 'eaveHelpLine' && obj.parentId === currentObject.attributes.roofId) 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) => { eaveHelpLines.forEach((line) => {
canvas.remove(line) canvas.remove(line)
}) })
@ -714,7 +770,9 @@ export function useContextMenu() {
{ {
id: 'remove', id: 'remove',
name: getMessage('contextmenu.remove'), name: getMessage('contextmenu.remove'),
fn: () => modulesRemove(), fn: () => {
modulesRemove()
},
}, },
{ {
id: 'move', id: 'move',
@ -887,6 +945,7 @@ export function useContextMenu() {
return return
} }
saveSnapshot()
resetModule() resetModule()
alignModule(MODULE_ALIGN_TYPE.VERTICAL, currentObject.arrayData ?? [currentObject]) alignModule(MODULE_ALIGN_TYPE.VERTICAL, currentObject.arrayData ?? [currentObject])
}, },
@ -904,6 +963,7 @@ export function useContextMenu() {
return return
} }
saveSnapshot()
resetModule() resetModule()
alignModule(MODULE_ALIGN_TYPE.HORIZONTAL, currentObject.arrayData ?? [currentObject]) alignModule(MODULE_ALIGN_TYPE.HORIZONTAL, currentObject.arrayData ?? [currentObject])
}, },
@ -912,6 +972,7 @@ export function useContextMenu() {
id: 'moduleRemove', id: 'moduleRemove',
name: getMessage('contextmenu.module.remove'), name: getMessage('contextmenu.module.remove'),
fn: () => { fn: () => {
saveSnapshot()
moduleRoofRemove(currentObject.arrayData ?? [currentObject]) moduleRoofRemove(currentObject.arrayData ?? [currentObject])
// const moduleSetupSurface = canvas.getObjects().filter((obj) => canvas.getActiveObjects()[0].id === obj.id)[0] // const moduleSetupSurface = canvas.getObjects().filter((obj) => canvas.getActiveObjects()[0].id === obj.id)[0]

View File

@ -148,7 +148,7 @@ export const useLine = () => {
// 기존 pitchText가 있으면 삭제 // 기존 pitchText가 있으면 삭제
if (pitchText.length > 0) { if (pitchText.length > 0) {
canvas.remove(pitchText) canvas.remove(...pitchText)
} }
} }

View File

@ -1,18 +1,18 @@
'use client' 'use client'
import { useContext, useEffect, useState } from 'react' import { useContext, useEffect, useRef, useState } from 'react'
import { usePathname, useRouter } from 'next/navigation' import { usePathname, useRouter } from 'next/navigation'
import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil' import { useRecoilState, useResetRecoilState, useSetRecoilState } from 'recoil'
import { import {
canvasSettingState,
canvasState, canvasState,
currentCanvasPlanState, currentCanvasPlanState,
plansState, currentMenuState,
canvasSettingState,
currentObjectState, currentObjectState,
moduleSetupSurfaceState, moduleSetupSurfaceState,
currentMenuState, plansState,
} from '@/store/canvasAtom' } from '@/store/canvasAtom'
import { useAxios } from '@/hooks/useAxios' import { useAxios } from '@/hooks/useAxios'
import { useMessage } from '@/hooks/useMessage' import { useMessage } from '@/hooks/useMessage'
@ -31,6 +31,7 @@ import { useCanvasMenu } from './common/useCanvasMenu'
import { QcastContext } from '@/app/QcastProvider' import { QcastContext } from '@/app/QcastProvider'
import { unescapeString } from '@/util/common-utils' import { unescapeString } from '@/util/common-utils'
import { useTrestle } from '@/hooks/module/useTrestle' import { useTrestle } from '@/hooks/module/useTrestle'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { logger } from '@/util/logger' import { logger } from '@/util/logger'
// putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유. // putCanvasStatus 의 in-flight 요청 — module-scope 로 두어 usePlan() 인스턴스 간 공유.
@ -85,6 +86,8 @@ export function usePlan(params = {}) {
//선택된 모듈 배치면 초기화 //선택된 모듈 배치면 초기화
const resetModuleSetupSurface = useResetRecoilState(moduleSetupSurfaceState) const resetModuleSetupSurface = useResetRecoilState(moduleSetupSurfaceState)
const { isAllComplete } = useTrestle() 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, fill: typeof o.fill === 'string' ? o.fill : o.fill?.type,
opacity: o.opacity, opacity: o.opacity,
direction: o.direction, direction: o.direction,
moduleInfo: o.moduleInfo moduleInfo: o.moduleInfo ? { itemId: o.moduleInfo.itemId, moduleTpCd: o.moduleInfo.moduleTpCd, capacity: o.moduleInfo.capacity } : null,
? { itemId: o.moduleInfo.itemId, moduleTpCd: o.moduleInfo.moduleTpCd, capacity: o.moduleInfo.capacity }
: null,
pointsSample: Array.isArray(o.points) ? o.points.slice(0, 4) : null, pointsSample: Array.isArray(o.points) ? o.points.slice(0, 4) : null,
})) }))
logger.error('[PLAN-CORRUPT-COORD-SAVE-GUARD 2026-05-18] 保存失敗 — corrupted objects', { logger.error('[PLAN-CORRUPT-COORD-SAVE-GUARD 2026-05-18] 保存失敗 — corrupted objects', {
@ -228,9 +229,14 @@ export function usePlan(params = {}) {
return hasPatternFill && !r.roofMaterial return hasPatternFill && !r.roofMaterial
}) })
if (inconsistent.length > 0) { if (inconsistent.length > 0) {
console.warn('[ROOF-MATERIAL-SAVE-GUARD] pattern drawn but roofMaterial missing:', console.warn(
inconsistent.map((r) => ({ id: r.id, name: r.name, fillType: r.fill?.type }))) '[ROOF-MATERIAL-SAVE-GUARD] pattern drawn but roofMaterial missing:',
swalFire({ text: '面に屋根材パターンが描画されていますが、屋根材データが保存されていません。\n屋根材を再設定してから保存してください。', icon: 'warning' }) inconsistent.map((r) => ({ id: r.id, name: r.name, fillType: r.fill?.type })),
)
swalFire({
text: '面に屋根材パターンが描画されていますが、屋根材データが保存されていません。\n屋根材を再設定してから保存してください。',
icon: 'warning',
})
return return
} }
@ -410,9 +416,22 @@ export function usePlan(params = {}) {
const controller = new AbortController() const controller = new AbortController()
canvasStatusInflight.controller = controller 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) => { .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') }) if (saveAlert) swalFire({ text: getMessage('plan.message.save') })
rtn = true rtn = true
}) })
@ -529,11 +548,19 @@ export function usePlan(params = {}) {
} }
} }
// usePlan() 인스턴스가 새로 mount 될 때 clearStacks 가 실수로 호출되지 않도록
// 직전 플랜 id 를 ref 로 보관하고, 실제 id 변경이 일어났을 때만 스택을 비운다.
const prevPlanIdRef = useRef(currentCanvasPlan?.id)
useEffect(() => { useEffect(() => {
setSelectedPlan(currentCanvasPlan) setSelectedPlan(currentCanvasPlan)
handleCurrentPlanUrl() handleCurrentPlanUrl()
// resetCurrentObject() // resetCurrentObject()
resetModuleSetupSurface() resetModuleSetupSurface()
// 플랜 ID가 실제로 바뀐 경우에만 undo/redo 히스토리를 초기화한다.
if (prevPlanIdRef.current !== currentCanvasPlan?.id) {
clearStacks()
prevPlanIdRef.current = currentCanvasPlan?.id
}
}, [currentCanvasPlan]) }, [currentCanvasPlan])
/** /**

View File

@ -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 { useRecoilValue } from 'recoil'
import { fabric } from 'fabric' import { fabric } from 'fabric'
import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, isPointOnLine } from '@/util/canvas-util' import { calculateIntersection, findAndRemoveClosestPoint, getDegreeByChon, isPointOnLine } from '@/util/canvas-util'
import { QPolygon } from '@/components/fabric/QPolygon' import { QPolygon } from '@/components/fabric/QPolygon'
import { calcLinePlaneSize, equalizeSymmetricHips, isSamePoint, removeDuplicatePolygons } from '@/util/qpolygon-utils' 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 { fontSelector } from '@/store/fontAtom'
import { QLine } from '@/components/fabric/QLine' import { QLine } from '@/components/fabric/QLine'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
@ -21,6 +27,7 @@ export const usePolygon = () => {
const pitchText = useRecoilValue(pitchTextSelector) const pitchText = useRecoilValue(pitchTextSelector)
const globalPitch = useRecoilValue(globalPitchState) const globalPitch = useRecoilValue(globalPitchState)
const roofSizeSet = useRecoilValue(basicSettingState).roofSizeSet const roofSizeSet = useRecoilValue(basicSettingState).roofSizeSet
const corridorDimension = useRecoilValue(corridorDimensionSelector)
const { setActualSize } = useLine() const { setActualSize } = useLine()
@ -91,11 +98,12 @@ export const usePolygon = () => {
const maxY = line.top + line.length const maxY = line.top + line.length
const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI const degree = (Math.atan2(y2 - y1, x2 - x1) * 180) / Math.PI
// [2026-05-18 라벨 저장값 통일] planeSize(정수 또는 0.5 step) 그대로. // [라벨 실제길이 모드 fix] 치수표시가 '복도치수 표시'(corridorDimension) 면 roofSizeSet 무관하게
const __display = planeSize ?? length // planeSize(평면길이)를 표시한다. 그 외에는 roofSizeSet===1(실제길이) 이면 actualSize, 아니면 planeSize.
const text = new fabric.Textbox( // 기존엔 planeSize 만 표시해, 실제치수 모드에서도 이동 후 평면길이(예: 8000)가 나오던 문제.
Number(__display).toFixed(1).replace(/\.0$/, ''), 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, left: left,
top: top, top: top,
fontSize: lengthTextFontOptions.fontSize.value, fontSize: lengthTextFontOptions.fontSize.value,
@ -115,8 +123,7 @@ export const usePolygon = () => {
lockScalingY: true, lockScalingY: true,
parent: polygon, parent: polygon,
name: 'lengthText', name: 'lengthText',
}, })
)
polygon.texts.push(text) polygon.texts.push(text)
canvas.add(text) canvas.add(text)
}) })
@ -353,7 +360,9 @@ export const usePolygon = () => {
const drawDirectionStringToArrow2 = (polygon, showDirectionText) => { const drawDirectionStringToArrow2 = (polygon, showDirectionText) => {
let { direction, surfaceCompass, moduleCompass, arrow } = polygon let { direction, surfaceCompass, moduleCompass, arrow } = polygon
if (moduleCompass === null || moduleCompass === undefined) { if (moduleCompass === null || moduleCompass === undefined) {
const textObj = new fabric.Text(`${currentAngleType === ANGLE_TYPE.SLOPE ? arrow.pitch : (arrow.roofAngle ?? getDegreeByChon(arrow.pitch))}${pitchText}`, { const textObj = new fabric.Text(
`${currentAngleType === ANGLE_TYPE.SLOPE ? arrow.pitch : (arrow.roofAngle ?? getDegreeByChon(arrow.pitch))}${pitchText}`,
{
fontFamily: flowFontOptions.fontFamily.value, fontFamily: flowFontOptions.fontFamily.value,
fontWeight: flowFontOptions.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal', fontWeight: flowFontOptions.fontWeight.value.toLowerCase().includes('bold') ? 'bold' : 'normal',
fontStyle: flowFontOptions.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal', fontStyle: flowFontOptions.fontWeight.value.toLowerCase().includes('italic') ? 'italic' : 'normal',
@ -370,7 +379,8 @@ export const usePolygon = () => {
parent: arrow, parent: arrow,
parentId: arrow.id, parentId: arrow.id,
visible: isFlowDisplay, visible: isFlowDisplay,
}) },
)
polygon.canvas.add(textObj) polygon.canvas.add(textObj)
return return
@ -749,7 +759,9 @@ export const usePolygon = () => {
// [SPLIT-ALLOC-DIAG 2026-06-01] 분할 graph 진입 전 innerLines 전수 dump. // [SPLIT-ALLOC-DIAG 2026-06-01] 분할 graph 진입 전 innerLines 전수 dump.
// 케라바 패턴 라인(kerabPatternRidge/ExtRidge/Hip/ExtHip)과 원래 ridge(RG-2 등) 가 // 케라바 패턴 라인(kerabPatternRidge/ExtRidge/Hip/ExtHip)과 원래 ridge(RG-2 등) 가
// type='default' / isStart 미설정으로 graph 시드/유효점 후보에서 제외되는지 추적용. // 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) => { innerLines.forEach((l, i) => {
logger.log( logger.log(
` [SPLIT-ALLOC-DIAG ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` + ` [SPLIT-ALLOC-DIAG ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` +
@ -1262,6 +1274,13 @@ export const usePolygon = () => {
polygonLine.attributes = { ...polygonLine.attributes, isStart: true } 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] let allLines = [...polygonLines, ...innerLines]
// allLines를 전부 돌면서 교차점이 있는 경우 그 line을 잘라서 allLines에 추가 // 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 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 // 나눠서 중복 제거된 roof return
let newRoofs = getSplitRoofsPoints(allLines) let newRoofs = getSplitRoofsPoints(allLines)
@ -1744,9 +1916,12 @@ export const usePolygon = () => {
// 2차 매칭 (옵션 3 + α): 대각선이 외벽 중간을 분할한 sub-segment 처리 // 2차 매칭 (옵션 3 + α): 대각선이 외벽 중간을 분할한 sub-segment 처리
const _isCollinearWithin = (parent, p) => { const _isCollinearWithin = (parent, p) => {
const ax = parent.startPoint.x, ay = parent.startPoint.y const ax = parent.startPoint.x,
const bx = parent.endPoint.x, by = parent.endPoint.y ay = parent.startPoint.y
const dx = bx - ax, dy = by - ay const bx = parent.endPoint.x,
by = parent.endPoint.y
const dx = bx - ax,
dy = by - ay
const segLenSq = dx * dx + dy * dy const segLenSq = dx * dx + dy * dy
if (segLenSq < 1) return false if (segLenSq < 1) return false
const cross = (p.x - ax) * dy - (p.y - ay) * dx const cross = (p.x - ax) * dy - (p.y - ay) * dx
@ -2043,7 +2218,11 @@ export const usePolygon = () => {
// 현재 남아있는 line들로 그래프 생성 // 현재 남아있는 line들로 그래프 생성
const graph = {} 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 p1 = line.startPoint
const p2 = line.endPoint const p2 = line.endPoint
const key1 = pointToKey(p1) 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( return removeDuplicatePolygons(
roofs.filter((roof) => roof.length < 100), roofs.filter((roof) => roof.length < 100),
allLines.some((line) => line.name === 'auxiliaryLine'), allLines.some((line) => line.name === 'auxiliaryLine'),

558
src/hooks/useUndoRedo.js Normal file
View 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,
}
}

View File

@ -408,3 +408,23 @@ export const moduleRowColArrayState = atom({
default: [], default: [],
dangerouslyAllowMutability: true, 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,
})

View File

@ -39,3 +39,9 @@ export const promisePopupState = atom({
key: 'promisePopupStore', key: 'promisePopupStore',
default: false, default: false,
}) })
/** undo/redo 시 복원해야 할 팝업 정보 (JSX 직렬화 불가 → 타입+파라미터만 저장) */
export const pendingPopupState = atom({
key: 'pendingPopupState',
default: null, // { type: 'RoofAllocation', id, pos } 등
})

View File

@ -219,7 +219,12 @@ export const corridorDimensionSelector = selector({
key: 'corridorDimensionSelector', key: 'corridorDimensionSelector',
get: ({ get }) => { get: ({ get }) => {
const settingModalFirstOptions = get(settingModalFirstOptionsState) 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) => { set: ({ set }, newValue) => {
//0 : 복도치수 , 1 : 실제치수 //0 : 복도치수 , 1 : 실제치수
@ -229,6 +234,10 @@ export const corridorDimensionSelector = selector({
...prev, ...prev,
dimensionDisplay: prev.dimensionDisplay.map((item, index) => { dimensionDisplay: prev.dimensionDisplay.map((item, index) => {
if (index === newValue) { if (index === newValue) {
// Fabric 클래스(QPolygon) 등 React hook 외부에서 현재 column 참조용
if (typeof window !== 'undefined') {
window.__currentCorridorColumn = item.column
}
return { ...item, selected: true } return { ...item, selected: true }
} else { } else {
return { ...item, selected: false } return { ...item, selected: false }

File diff suppressed because it is too large Load Diff

View File

@ -1071,7 +1071,9 @@ export function findAndRemoveClosestPoint(targetPoint, points) {
export function polygonToTurfPolygon(object, current = false) { export function polygonToTurfPolygon(object, current = false) {
let coordinates let coordinates
coordinates = object.points.map((point) => [point.x, point.y]) 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]) coordinates.push(coordinates[0])
return turf.polygon( return turf.polygon(
[coordinates], [coordinates],
@ -1122,3 +1124,60 @@ export function getTrianglePoints(triangle) {
return points.map((point) => fabric.util.transformPoint(point, matrix)) 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()
})
}

View File

@ -15,7 +15,7 @@ export const defineQPolygon = () => {
fabric.Object._fromObject('QPolygon', object, callback, 'points') fabric.Object._fromObject('QPolygon', object, callback, 'points')
} }
} }
//undo 가 있는 js
/** /**
* point1에서 point2를 잇는 방향의 각도를 구한다. * point1에서 point2를 잇는 방향의 각도를 구한다.
* @param point1 * @param point1
@ -1598,6 +1598,29 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
} else if (Math.abs(normalizedAngle - 90) <= tolerance) { } else if (Math.abs(normalizedAngle - 90) <= tolerance) {
isVertical = true 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 (analyze.isHorizontal) {
//현재라인이 수평선일때 //현재라인이 수평선일때
if (isHorizontal) { if (isHorizontal) {
@ -1628,6 +1651,10 @@ export const drawGableRoof = (roofId, canvas, textMode) => {
drawRoofPlane(backward) drawRoofPlane(backward)
}) })
roof.innerLines.push(...ridgeLines, ...innerLines) roof.innerLines.push(...ridgeLines, ...innerLines)
// 처마선 innerLine 이 외곽선/치수를 전담하므로 roof 원본 외곽(stroke) 숨김 + 지붕선 치수(roof lengthText) 제거.
// 외벽선(wall) 치수는 유지. (박공은 split 공용 함수를 안 거치므로 여기서 직접 처리)
roof.set({ stroke: 'transparent' })
removeRoofLengthText(roof, canvas)
canvas canvas
.getObjects() .getObjects()
.filter((obj) => obj.name === 'check') .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]) const maxLine = pitchSizeLines.reduce((prev, current) => (prev.length > current.length ? prev : current), pitchSizeLines[0])
canvas.add(maxLine) 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() canvas.renderAll()
} }
@ -5429,7 +5466,52 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
//추가된 하단 지붕 라인 innerLines에 추가. //추가된 하단 지붕 라인 innerLines에 추가.
innerLines.push(...downRoofLines) 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 = [] const innerLinesPoints = []
innerLines.forEach((line) => { innerLines.forEach((line) => {
const hasCoord1 = innerLinesPoints.find((p) => p.x === line.x1 && p.y === line.y1) 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 = [] const splitPoint = []
let hasOverlapLine = false 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) => { innerLines.forEach((innerLine) => {
const innerLineMinX = Math.min(innerLine.x1, innerLine.x2) // [docs §10] overlap 판정: 축정렬 bbox 포함 → "공선 + 구간 덮음" 으로 변경.
const innerLineMaxX = Math.max(innerLine.x1, innerLine.x2) // 기존 bbox 포함은 수평/수직 짧은 처마변(bbox 한 축 두께 0)을 대각선 추녀(hip) bbox 가
const innerLineMinY = Math.min(innerLine.y1, innerLine.y2) // 삼켜 false positive(처마변 skip → 원본 transparent → 지붕선 소실)를 냈다(육각형 잘린 모서리).
const innerLineMaxY = Math.max(innerLine.y1, innerLine.y2) // 원 가드 의도("같은 선 위 더 긴 내부선이 처마변을 덮으면 중복이니 skip")는 공선이 전제이므로,
if (innerLineMinX <= minX && innerLineMaxX >= maxX && innerLineMinY <= minY && innerLineMaxY >= maxY) { // 공선 아닌 추녀는 애초에 skip 대상이 아니다. 양 끝점이 상대 선분 위(isPointOnLineNew)면 덮음.
hasOverlapLine = true const innerCoversCurr =
} isPointOnLineNew(innerLine, { x: currentLine.x1, y: currentLine.y1 }) &&
if (minX <= innerLineMinX && maxX >= innerLineMaxX && minY <= innerLineMinY && maxY >= innerLineMaxY) { isPointOnLineNew(innerLine, { x: currentLine.x2, y: currentLine.y2 })
hasOverlapLine = true 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 if (hasOverlapLine) return
innerLinesPoints.forEach((point) => { innerLinesPoints.forEach((point) => {
if ( const distToStart = Math.hypot(point.x - currentLine.x1, point.y - currentLine.y1)
isPointOnLineNew(currentLine, point) && const distToEnd = Math.hypot(point.x - currentLine.x2, point.y - currentLine.y2)
!(almostEqual(currentLine.x1, point.x) && almostEqual(currentLine.y1, point.y)) && // 변 위 점이면서 양 끝점(코너)에서 END_EPS 보다 멀 때만 진짜 분할점으로 채택.
!(almostEqual(currentLine.x2, point.x) && almostEqual(currentLine.y2, point.y)) if (isPointOnLineNew(currentLine, point) && distToStart > END_EPS && distToEnd > END_EPS) {
) { splitPoint.push({ point, distance: distToStart })
const distance = Math.sqrt((point.x - currentLine.x1) ** 2 + (point.y - currentLine.y1) ** 2)
splitPoint.push({ point, distance })
} }
}) })
if (splitPoint.length > 0) { 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 } let startPoint = { x: currentLine.x1, y: currentLine.y1 }
for (let i = 0; i < splitPoint.length; i++) { for (let i = 0; i < splitPoint.length; i++) {
const point = splitPoint[i].point const point = splitPoint[i].point
@ -5490,19 +5568,23 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
} else { } else {
innerLines.push(drawHipLine([startPoint.x, startPoint.y, currentLine.x2, currentLine.y2], canvas, roof, textMode, nextDegree, nextDegree)) innerLines.push(drawHipLine([startPoint.x, startPoint.y, currentLine.x2, currentLine.y2], canvas, roof, textMode, nextDegree, nextDegree))
} }
} else {
// 분할점 없는 통짜 변. 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 { } else {
innerLines.push(drawRoofLine([currentLine.x1, currentLine.y1, currentLine.x2, currentLine.y2], canvas, roof, textMode)) innerLines.push(drawRoofLine([currentLine.x1, currentLine.y1, currentLine.x2, currentLine.y2], canvas, roof, textMode))
} }
}
}) })
//지붕 innerLines에 추가. // 처마선 innerLine 이 외곽선/치수를 전담하므로 roof 원본 외곽(stroke) 숨김 + 지붕선 치수(roof lengthText) 제거.
roof.innerLines = innerLines // (안 숨기면 처마선 삭제 시 roof 기본 stroke:'black'(usePolygon.js) 노출. 외벽선 wall 치수는 유지)
roof.set({ stroke: 'transparent' })
removeRoofLengthText(roof, canvas)
canvas return innerLines
.getObjects()
.filter((object) => object.name === 'check')
.forEach((object) => canvas.remove(object))
canvas.renderAll()
} }
/** /**

View File

@ -1,6 +1,6 @@
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common' import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import { SkeletonBuilder } from '@/lib/skeletons' 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 { QLine } from '@/components/fabric/QLine'
import { getDegreeByChon } from '@/util/canvas-util' import { getDegreeByChon } from '@/util/canvas-util'
import Big from 'big.js' import Big from 'big.js'
@ -1283,6 +1283,10 @@ export const skeletonBuilder = (roofId, canvas, textMode) => {
// gap 을 시각화. innerLines 와 별개 collection. 기존 working code (processInBoth 등) 무영향. // gap 을 시각화. innerLines 와 별개 collection. 기존 working code (processInBoth 등) 무영향.
drawBaselineToRooflineHelpers(roof, canvas, changRoofLinePoints, roofLinePoints) drawBaselineToRooflineHelpers(roof, canvas, changRoofLinePoints, roofLinePoints)
// [docs §9-3] 추녀 연장 후 처마변(roof.lines)을 split → 편집 가능한 innerLine(hip/roofLine)으로 등록.
// 단순 우진각: 추녀가 처마 코너(변 끝점)에 닿아 분할점 없음 → 처마 4변 통짜. 복합 형상: 중간 접점에서 분할.
splitRoofLinesToInnerLines(roof, roof.innerLines, canvas, textMode)
// 캔버스에 스켈레톤 상태 저장 // 캔버스에 스켈레톤 상태 저장
if (!canvas.skeletonStates) { if (!canvas.skeletonStates) {
canvas.skeletonStates = {} canvas.skeletonStates = {}

2
startscript-ur.js Normal file
View File

@ -0,0 +1,2 @@
var exec = require('child_process').exec
exec('yarn start:ur', { windowsHide: true })