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