Merge branch 'dev' of https://git.hanasys.jp/qcast3/qcast-front into hotfix/dev_yscha

This commit is contained in:
ysCha 2026-06-23 16:01:24 +09:00
commit 48532fa226
52 changed files with 2490 additions and 394 deletions

View File

@ -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 유지. (캔버스 인터랙션이라 런타임 확인은 재현 필요 — 정적 분석상 ①만 동작 변화, ②③④ 불변 예상.)

View File

@ -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"

View File

@ -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]

View File

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

View File

@ -44,17 +44,9 @@ function __classifyLineForLabel(obj) {
if (name === LINE_TYPE.SUBLINE.VALLEY || lineName === LINE_TYPE.SUBLINE.VALLEY) return 'V'
if (name === LINE_TYPE.SUBLINE.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,

View File

@ -36,7 +36,12 @@ import {
import { sessionStore } from '@/store/commonAtom'
import { outerLinePointsState } from '@/store/outerLineAtom'
import { appMessageStore, globalLocaleStore } from '@/store/localeAtom'
import { addedRoofsState, basicSettingState, selectedRoofMaterialSelector, settingModalFirstOptionsState } from '@/store/settingAtom'
import {
addedRoofsState,
basicSettingState,
selectedRoofMaterialSelector,
settingModalFirstOptionsState,
} from '@/store/settingAtom'
import { placementShapeDrawingPointsState } from '@/store/placementShapeDrawingAtom'
import { commonUtilsState } from '@/store/commonUtilsAtom'
import { menusState } from '@/store/menuAtom'
@ -52,6 +57,7 @@ import { QcastContext } from '@/app/QcastProvider'
import { useRoofFn } from '@/hooks/common/useRoofFn'
import { usePolygon } from '@/hooks/usePolygon'
import { useTrestle } from '@/hooks/module/useTrestle'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export default function CanvasMenu(props) {
const [currentCanvasPlan, setCurrentCanvasPlan] = useRecoilState(currentCanvasPlanState)
@ -73,6 +79,7 @@ export default function CanvasMenu(props) {
const { handleZoomClear, handleZoom } = useCanvasEvent()
const { setAllModuleSurfaceIsComplete, isAllComplete } = useTrestle()
const { handleMenu } = useMenu()
const { undo, redo, canUndo, canRedo } = useUndoRedo()
// const urlParams = useSearchParams()
const { handleEstimateSubmit, fetchSetting, estimateContextState, setEstimateContextState } = useEstimateController()
const estimateRecoilState = useRecoilValue(estimateState)
@ -600,6 +607,20 @@ export default function CanvasMenu(props) {
<div className="canvas-side-btn-wrap">
{!['estimate', 'simulation'].some((num) => num === selectedMenu) && (
<>
<div className="undo-redo-wrap">
<button className={`undo-btn ${!canUndo ? 'disabled' : ''}`} onClick={undo} disabled={!canUndo} title="Undo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M3 10h10c4.42 0 8 3.58 8 8v1" />
<polyline points="7 14 3 10 7 6" />
</svg>
</button>
<button className={`redo-btn ${!canRedo ? 'disabled' : ''}`} onClick={redo} disabled={!canRedo} title="Redo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M21 10H11C6.58 10 3 13.58 3 18v1" />
<polyline points="17 14 21 10 17 6" />
</svg>
</button>
</div>
{
<div className={`vertical-horizontal ${verticalHorizontalMode ? 'on' : ''}`}>
<span>{getMessage('plan.mode.vertical.horizontal')}</span>

View File

@ -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])

View File

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

View File

@ -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()
}

View File

@ -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

View File

@ -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()
}

View File

@ -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,

View File

@ -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',

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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 (

View File

@ -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'),
)
})
}

View File

@ -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)
}

View File

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

View File

@ -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')

View File

@ -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()

View File

@ -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 = []

View File

@ -25,6 +25,7 @@ import { usePopup } from '@/hooks/usePopup'
import { useMouse } from '@/hooks/useMouse'
import { useTurf } from '@/hooks/common/useTurf'
import SizeSetting from '@/components/floor-plan/modal/object/SizeSetting'
import { useUndoRedo } from '@/hooks/useUndoRedo'
export function useObjectBatch({ isHidden, setIsHidden }) {
const { getMessage } = useMessage()
@ -41,6 +42,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const { getIntersectMousePoint } = useMouse()
const { checkModuleDisjointSurface } = useTurf()
const currentObject = useRecoilValue(currentObjectState)
const { saveSnapshot, popLastSnapshot } = useUndoRedo()
useEffect(() => {
if (canvas) {
@ -99,6 +101,8 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
let rect, isDown, origX, origY
let selectedSurface
saveSnapshot()
//프리입력
if (selectedType === INPUT_TYPE.FREE) {
@ -205,7 +209,9 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
initEvent()
closeAll()
addPopup(popupId, 1, <SizeSetting id={popupId} target={rect} />)
// 초기 배치 흐름에서 연 사이즈 설정: 배치 시작(applyOpeningAndShadow)이 이미 "오브젝트 없음" 스냅샷을
// 찍어뒀으므로, 이 사이즈 확정은 중복 스냅샷을 만들지 않게 한다(undo 한 번에 제거되도록).
addPopup(popupId, 1, <SizeSetting id={popupId} target={rect} initialPlacement />)
}
})
} else if (selectedType === INPUT_TYPE.DIMENSION) {
@ -307,7 +313,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
* @param {*} surfaceShapePolygons
* @returns
*/
const applyDormers = ({ height, width, pitch, offsetRef, offsetWidthRef, directionRef }, buttonAct) => {
const applyDormers = ({ height, width, pitch, offsetRef, offsetWidthRef, directionRef }, buttonAct, skipSnapshot = false) => {
const dormerName = buttonAct === 3 ? BATCH_TYPE.TRIANGLE_DORMER : BATCH_TYPE.PENTAGON_DORMER
const dormerTempName = buttonAct === 3 ? BATCH_TYPE.TRIANGLE_DORMER_TEMP : BATCH_TYPE.PENTAGON_DORMER_TEMP
@ -325,6 +331,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
return
}
// [undo 중복 fix] 도머 복사(commonCopyObject)는 copyObject 에서 이미 saveSnapshot 을 호출한 뒤
// applyDormers 를 재사용하므로, 복사 경로에서는 skipSnapshot=true 로 중복 스냅샷을 막는다.
if (!skipSnapshot) saveSnapshot()
let theta = 0
let bottomLength = 0,
@ -1299,7 +1309,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
return { leftPoints, rightPoints, groupPoints }
}
const resizeObjectBatch = (side, target, width, height, popupId) => {
const resizeObjectBatch = (side, target, width, height, popupId, skipSnapshot = false) => {
// 초기 배치(applyOpeningAndShadow)에서 열린 사이즈 설정이면 배치 시작 스냅샷과 중복되므로 건너뛴다.
// 컨텍스트 메뉴 "사이즈 수정"으로 기존 오브젝트를 수정할 땐 skipSnapshot=false 라 별개 undo 로 남는다.
if (!skipSnapshot) saveSnapshot()
const objectWidth = target.width
const objectHeight = target.height
@ -1421,6 +1434,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0]
if (obj) {
saveSnapshot()
obj.set({
lockMovementX: false,
lockMovementY: false,
@ -1428,8 +1442,6 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const originObj = { ...obj }
const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints())
const originLeft = obj.left
const originTop = obj.top
addCanvasMouseEventListener('mouse:up', (e) => {
//개구, 그림자 타입일 경우 폴리곤 타입 변경
@ -1474,15 +1486,11 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
})
if (obj.name === BATCH_TYPE.TRIANGLE_DORMER || obj.name === BATCH_TYPE.PENTAGON_DORMER) {
const calcLeft = obj.left - originLeft
const calcTop = obj.top - originTop
const currentDormerPoints = obj.getCurrentPoints().map((item) => {
return {
x: item.x + calcLeft,
y: item.y + calcTop,
}
})
// getCurrentPoints() 는 calcTransformMatrix() 기반으로 이미 이동된 월드 좌표를 반환한다.
// 과거 코드에서 (obj.left - originLeft) delta 를 다시 더해 2배 이동된 좌표로 검사 →
// 빠르게 이동 시 도머가 지붕 내부에 있어도 within=false 가 되어 alert 오발생.
obj.setCoords()
const currentDormerPoints = obj.getCurrentPoints()
const turfObject = pointsToTurfPolygon(currentDormerPoints)
@ -1513,6 +1521,7 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
const copyObjectBatch = () => {
const obj = canvas.getActiveObject()
if (obj) {
saveSnapshot()
let clonedObj = null
const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === obj.parentId)[0]
@ -1595,6 +1604,9 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
}
const dormerOffset = (arrow1, arrow2, length1, length2) => {
// [undo fix] 도머 이동 전 상태를 스냅샷으로 저장해야 undo 시 도머가 원위치로 복원된다.
// (이동 후 저장하면 undo 해도 도머는 그 자리에 남는다.) 검증 실패 시 popLastSnapshot 으로 제거.
saveSnapshot()
length1 = parseInt(length1) / 10
length2 = parseInt(length2) / 10
@ -1623,22 +1635,17 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
}
}
// [좌표 검증 fix] moveObjectBatch 와 동일하게 setCoords()+getCurrentPoints() 로 실제 이동된
// 월드좌표를 검사한다. 과거처럼 groupPoints+delta 로 검사하면 2배 이동된 좌표가 되어
// 도머가 지붕 내부에 있어도 within=false → '지붕내 설치' alert 오발생.
dormer.setCoords()
const parentSurface = canvas?.getObjects().filter((item) => item.name === POLYGON_TYPE.ROOF && item.id === dormer.parentId)[0]
const turfSurface = pointsToTurfPolygon(parentSurface.getCurrentPoints())
let turfDormer = pointsToTurfPolygon(rectToPolygon(dormer))
let currentPoints = []
if (dormer.name === BATCH_TYPE.TRIANGLE_DORMER || dormer.name === BATCH_TYPE.PENTAGON_DORMER) {
const calcLeft = dormer.left - left
const calcTop = dormer.top - top
currentPoints = dormer.groupPoints.map((item) => {
return {
x: item.x + calcLeft,
y: item.y + calcTop,
}
})
currentPoints = dormer.getCurrentPoints()
turfDormer = pointsToTurfPolygon(currentPoints)
}
@ -1651,7 +1658,10 @@ export function useObjectBatch({ isHidden, setIsHidden }) {
//위치 원복`
dormer.left = left
dormer.top = top
dormer.setCoords()
// [undo 중복 fix] 검증 실패(원복) 시 위에서 저장한 스냅샷을 제거해 불필요한 undo 단계를 막는다.
popLastSnapshot()
return
}

View File

@ -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)
}

View File

@ -11,6 +11,7 @@ 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'
@ -46,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)
@ -553,6 +555,7 @@ export function useEavesGableEdit(id) {
break
}
saveSnapshot()
// [KERAB-STATE-DUMP 2026-06-11] A/B타입(가로/세로 케라바) 토글 진입 시점 지붕 상태 진단.
// 벽 type 지정은 있는데 내부 skLine 이 0인 "무에서 유" 케이스 파악용 — 사용자 설명 전 사실 수집.
// 읽기 전용(좌표/visible/type 만 덤프), 동작 변경 없음. logger 게이트(local 만 출력).

View File

@ -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

View File

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

View File

@ -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 },

View File

@ -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,26 +599,22 @@ 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
@ -625,7 +654,7 @@ export function useRoofAllocationSetting(id) {
`[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})`
`→ total=${totalLen.toFixed(1)} (plane ${skPlane}${newPlane}, actual ${skActual}${newActual})`,
)
removedExt.push(ext)
@ -654,7 +683,7 @@ export function useRoofAllocationSetting(id) {
`[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}`
`→ 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,11 +1121,15 @@ 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) =>
const roofEaveHelpLines = canvas
.getObjects()
.filter(
(obj) =>
(obj.lineName === 'eaveHelpLine' || obj.lineName === 'extensionLine' || obj.lineName === 'kerabValleyOverlapLine') &&
obj.roofId === roofBase.id
obj.roofId === roofBase.id,
)
// logger.log('roofBase.id:', roofBase.id)
// logger.log('roofEaveHelpLines:', roofEaveHelpLines)
@ -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;
});
return false // 끝점만 닿아 있거나 직각인 경우는 살림
})
return !shouldRemove
})
// Combine remaining lines with newEaveLines
roofBase.lines = [...linesToKeep, ...normalEaveLines, ...extensionLines, ...overlapLines];
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 세그먼트로 쪼개진다.

View File

@ -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 },
@ -52,7 +57,13 @@ export function useRoofShapePassivitySetting(id) {
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 wall
if (isFix.current) {
wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
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 {
// 그냥 닫을 경우 처리
wall = addPolygonByLines([...initLines.current], {
name: POLYGON_TYPE.WALL,
fill: 'transparent',
stroke: 'black',
})
lines.forEach((line, idx) => {
line.attributes = initLines.current[idx].attributes
})
stroke = '#000000'
}
// [2026-06-16] 저장 후에도 외벽선 클릭 선택 허용. 드래그는 lockMovement(useCanvas), 보조선 가로채기는 z-order(18abbeb9)로 별도 차단
line.set({ stroke, strokeWidth: 4, selectable: true, visible: true })
})
const wall = addPolygonByLines(lines, { name: POLYGON_TYPE.WALL, fill: 'transparent', stroke: 'black' })
wall.lines = [...lines]
// 기존 그려진 지붕이 없다면
if (isFix.current) {
// 완료 한 경우에는 지붕까지 그려줌
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()
}

View File

@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { useMessage } from '@/hooks/useMessage'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, currentMenuState, currentObjectState, pitchTextSelector } from '@/store/canvasAtom'
import { LINE_TYPE, POLYGON_TYPE } from '@/common/common'
import { LINE_TYPE, MENU, POLYGON_TYPE } from '@/common/common'
import { usePolygon } from '@/hooks/usePolygon'
import { useMode } from '@/hooks/useMode'
import { useLine } from '@/hooks/useLine'
@ -20,6 +20,9 @@ import {
} from '@/store/settingAtom'
import { useAxios } from '@/hooks/useAxios'
import { globalLocaleStore } from '@/store/localeAtom'
import { useUndoRedo } from '@/hooks/useUndoRedo'
import { pendingPopupState } from '@/store/popupAtom'
import RoofShapeSetting from '@/components/floor-plan/modal/roofShape/RoofShapeSetting'
// 지붕형상 설정
export function useRoofShapeSetting(id) {
@ -52,6 +55,11 @@ export function useRoofShapeSetting(id) {
const isFixRef = useRef(false)
// 저장 없이 닫을 때 롤백할 진입 시점 스냅샷
const initLinesRef = useRef([])
const removedWallsRef = useRef([])
const pitchTextLineIdsRef = useRef(new Set())
const pitchRef = useRef(null)
const shedPitchRef = useRef(null)
const jerkinHeadPitchRef = useRef(null)
@ -69,6 +77,8 @@ export function useRoofShapeSetting(id) {
const { post } = useAxios(globalLocaleState)
const { addLine, removeLine } = useLine()
const { saveSnapshot } = useUndoRedo()
const [pendingPopup, setPendingPopup] = useRecoilState(pendingPopupState)
useEffect(() => {
pitchRef.current = currentAngleType === ANGLE_TYPE.SLOPE ? pitch : getChonByDegree(pitch)
@ -80,6 +90,18 @@ export function useRoofShapeSetting(id) {
jerkinHeadPitchRef.current = currentAngleType === ANGLE_TYPE.SLOPE ? jerkinHeadPitch : getChonByDegree(jerkinHeadPitch)
}, [jerkinHeadPitch])
// undo/redo 로 pendingPopup 이 복원되면 해당 팝업을 다시 연다
useEffect(() => {
if (!pendingPopup) return
if (pendingPopup.type === 'RoofShapeSetting') {
addPopup(pendingPopup.id, 1, <RoofShapeSetting id={pendingPopup.id} pos={pendingPopup.pos} />)
setPendingPopup(null)
} else if (pendingPopup.type === 'RoofAllocation') {
addPopup(pendingPopup.id, 1, <RoofAllocationSetting id={pendingPopup.id} pos={pendingPopup.pos} />)
setPendingPopup(null)
}
}, [pendingPopup])
useEffect(() => {
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
if (!canvas.outerLineFix || outerLines.length === 0) {
@ -87,14 +109,46 @@ export function useRoofShapeSetting(id) {
closePopup(id)
return
}
return () => {
if (!isFixRef.current) {
return
}
initLinesRef.current = outerLines.map((line) => ({ ...line }))
pitchTextLineIdsRef.current = new Set(
canvas
.getObjects()
.filter((obj) => obj.name === 'pitchText')
.map((obj) => obj.parentId),
)
return () => {
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
const pitchTexts = canvas.getObjects().filter((obj) => obj.name === 'pitchText')
if (!isFixRef.current) {
// 저장 없이 닫을 경우 진입 시점 스냅샷으로 롤백 — 캔버스를 진입 전 상태 그대로 되돌린다
canvas.remove(...pitchTexts)
outerLines.forEach((line, idx) => {
const init = initLinesRef.current[idx]
if (!init) {
return
}
init.visible ? showLine(line) : hideLine(line)
line.set({ stroke: init.stroke, strokeWidth: init.strokeWidth, selectable: init.selectable })
line.attributes = init.attributes
if (pitchTextLineIdsRef.current.has(line.id)) {
addPitchText(line)
}
})
// 변별로 설정 진입 시 삭제했던 WALL 폴리곤 원본을 원래 z-순서 그대로 재삽입
removedWallsRef.current.forEach(({ wall, index }) => {
canvas.insertAt(wall, index)
})
removedWallsRef.current = []
canvas.discardActiveObject()
canvas.renderAll()
return
}
canvas.remove(...pitchTexts)
outerLines.forEach((line) => {
let stroke, strokeWidth
@ -113,12 +167,21 @@ export function useRoofShapeSetting(id) {
line.set({
stroke,
strokeWidth,
// [2026-06-16] 저장 후에도 외벽선 클릭 선택 허용. 드래그는 lockMovement(useCanvas), 보조선 가로채기는 z-order(18abbeb9)로 별도 차단
selectable: true,
visible: true,
})
addPitchText(line)
line.bringToFront()
}
})
// 외벽선은 WALL/ROOF 폴리곤 바로 위에 배치 — 맨 앞(bringToFront)으로 올리면
// offset 0인 변에서 같은 좌표에 그려진 보조선의 클릭을 가로채 선택을 막는다
const polygonTopIdx = canvas
.getObjects()
.reduce((top, obj, idx) => (obj.name === POLYGON_TYPE.WALL || obj.name === POLYGON_TYPE.ROOF ? idx : top), -1)
outerLines.forEach((line) => canvas.moveTo(line, polygonTopIdx))
canvas.renderAll()
}
}, [])
@ -144,10 +207,15 @@ export function useRoofShapeSetting(id) {
useEffect(() => {
if (shapeNum === 4) {
canvas?.remove(canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL))
const wall = canvas.getObjects().find((obj) => obj.name === POLYGON_TYPE.WALL)
if (wall && removedWallsRef.current.length === 0) {
removedWallsRef.current = [{ wall, index: canvas.getObjects().indexOf(wall) }]
}
canvas?.remove(wall)
const outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
outerLines.forEach((line) => {
showLine(line)
line.set({ selectable: true })
line.bringToFront()
})
@ -182,6 +250,8 @@ export function useRoofShapeSetting(id) {
]
const handleSave = () => {
// undo 시 지붕형상 설정 팝업이 다시 열리도록 pendingPopup 을 atomOverrides 로 직접 전달
saveSnapshot({ pendingPopup: { type: 'RoofShapeSetting', id, pos: { x: 50, y: 230 } } })
let outerLines = canvas.getObjects().filter((obj) => obj.name === 'outerLine')
let direction
@ -482,6 +552,8 @@ export function useRoofShapeSetting(id) {
canvas?.renderAll()
roof.drawHelpLine(settingModalFirstOptions)
isFixRef.current = true
// 지붕형상 설정 완료 → 지붕면 할당 메뉴로 전환
setCurrentMenu(MENU.ROOF_COVERING.ROOF_SHAPE_ALLOC)
addPopup(id, 1, <RoofAllocationSetting id={id} pos={{ x: 50, y: 230 }} />)
}
@ -661,6 +733,7 @@ export function useRoofShapeSetting(id) {
return
}
saveSnapshot()
let attributes
switch (buttonAct) {
case 1: {

View File

@ -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) => {

View File

@ -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) {

View File

@ -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)

View File

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

View File

@ -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)
}
})

View File

@ -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) => {

View File

@ -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]

View File

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

View File

@ -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])
/**

View File

@ -1,10 +1,16 @@
import { ANGLE_TYPE, canvasState, currentAngleTypeSelector, globalPitchState, pitchTextSelector } from '@/store/canvasAtom'
import {
ANGLE_TYPE,
canvasState,
currentAngleTypeSelector,
globalPitchState,
pitchTextSelector,
} from '@/store/canvasAtom'
import { useRecoilValue } from 'recoil'
import { 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,11 +98,12 @@ 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$/, ''),
{
// [라벨 실제길이 모드 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,
@ -115,8 +123,7 @@ export const usePolygon = () => {
lockScalingY: true,
parent: polygon,
name: 'lengthText',
},
)
})
polygon.texts.push(text)
canvas.add(text)
})
@ -353,7 +360,9 @@ 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}`, {
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',
@ -370,7 +379,8 @@ export const usePolygon = () => {
parent: arrow,
parentId: arrow.id,
visible: isFlowDisplay,
})
},
)
polygon.canvas.add(textObj)
return
@ -749,7 +759,9 @@ export const usePolygon = () => {
// [SPLIT-ALLOC-DIAG 2026-06-01] 분할 graph 진입 전 innerLines 전수 dump.
// 케라바 패턴 라인(kerabPatternRidge/ExtRidge/Hip/ExtHip)과 원래 ridge(RG-2 등) 가
// type='default' / isStart 미설정으로 graph 시드/유효점 후보에서 제외되는지 추적용.
logger.log(`[SPLIT-ALLOC-DIAG] polygon.id=${polygon.id?.slice(0, 8)} innerLines.visible=${innerLines.length} polygonLines=${polygon.lines?.length ?? 0}`)
logger.log(
`[SPLIT-ALLOC-DIAG] polygon.id=${polygon.id?.slice(0, 8)} innerLines.visible=${innerLines.length} polygonLines=${polygon.lines?.length ?? 0}`,
)
innerLines.forEach((l, i) => {
logger.log(
` [SPLIT-ALLOC-DIAG ${i}] lineName=${l.lineName ?? 'none'} name=${l.name} ` +
@ -1262,6 +1274,13 @@ export const usePolygon = () => {
polygonLine.attributes = { ...polygonLine.attributes, isStart: true }
})
// [지붕면 공유변 fix] 보조선(innerLines)도 isStart=true 로 표시해 외곽선과 동일한 distance 페널티(+1000)를
// 받게 한다. 미설정 시 보조선(빗변)이 페널티 없이 항상 최단으로 선택돼, 면이 외곽 변(예: 직사각형
// 왼쪽변)을 두고 빗변으로 우회 → 오각형이 생긴다. (isValidPoint 가 type=default 끝점을 막아 면 자체는 안 늘어남)
innerLines.forEach((line) => {
line.attributes = { ...line.attributes, isStart: true }
})
let allLines = [...polygonLines, ...innerLines]
// allLines를 전부 돌면서 교차점이 있는 경우 그 line을 잘라서 allLines에 추가
@ -1492,6 +1511,159 @@ export const usePolygon = () => {
return Math.abs(line.startPoint.x - line.endPoint.x) > 2 || Math.abs(line.startPoint.y - line.endPoint.y) > 2
})
// [SUPERSEDED-EAVES 2026-06-15] 외곽선(처마/케라바)을 바깥으로 옮겨 처마(연·軒)를 만들면, 옮긴 선과 양끝
// 연결선(모두 auxiliaryLine)·원래 외곽선(eaves/gable)이 닫힌 "띠 사각형"을 이룬다. 이때 원래 외곽선이
// 면분할 입력에 남아 있으면 바깥 면을 [띠 + 사다리꼴]로 갈라, 사다리꼴을 닫는 시작선이 사라져 면이 통째로
// 누락된다(GETSPLIT-IO 진단). 원래 외곽선은 이미 면 내부의 가상선이므로 면분할 입력에서 제외 → 띠+사다리꼴이
// 하나의 면(예: 6각형)으로 병합된다. 띠 패턴(양끝 연결선 + 평행 닫힘선)이 없으면 아무 영향 없음.
// [2026-06-16] eaves 외에 gable(아래변 이동 L자형)·shed(한쪽흐름 왼쪽변 이동→8각형) 외곽선도 동일 처리.
// 연결선이 대각(챔퍼)이어도 끝점만 공유하면 매칭됨.
const auxBandLines = allLines.filter((l) => l.name === 'auxiliaryLine')
if (auxBandLines.length > 0) {
const sharesPt = (l, p) => isSamePoint(l.startPoint, p) || isSamePoint(l.endPoint, p)
const otherEnd = (l, p) => (isSamePoint(l.startPoint, p) ? l.endPoint : l.startPoint)
const supersededEaves = allLines.filter((E) => {
// 외곽 경계선(처마 eaves / 케라바 gable / 한쪽흐름 shed)만 후보 — 보조선·내부선(용마루/추녀)은 제외.
const type = E.attributes?.type
if (type !== 'eaves' && type !== 'gable' && type !== 'shed') return false
const A = E.startPoint
const B = E.endPoint
// A·B 각각에서 바깥으로 뻗는 연결 보조선 후보(한쪽 끝점만 공유). 한 끝점에 여러 개일 수 있다
// (스텝 이동: 중점에 좌·우 띠로 가는 대각이 둘 다 붙음). 첫 매치만 쓰면 잘못된 짝을 골라 닫힘선을
// 못 찾으므로, 모든 CA×CB 조합을 시도해 "두 바깥 끝점을 잇는 평행 닫힘 보조선"이 있는 조합이
// 하나라도 있으면 띠로 인정한다.
const candA = auxBandLines.filter((c) => c !== E && sharesPt(c, A) && !sharesPt(c, B))
const candB = auxBandLines.filter((c) => c !== E && sharesPt(c, B) && !sharesPt(c, A))
return candA.some((CA) =>
candB.some((CB) => {
if (CA === CB) return false
const Ao = otherEnd(CA, A)
const Bo = otherEnd(CB, B)
return auxBandLines.some((c) => c !== CA && c !== CB && sharesPt(c, Ao) && sharesPt(c, Bo))
}),
)
})
if (supersededEaves.length > 0) {
logger.log(`[SUPERSEDED-EAVES] 띠로 대체된 원래 외곽선(처마/케라바) ${supersededEaves.length}개 면분할 입력에서 제외`)
allLines = allLines.filter((l) => !supersededEaves.includes(l))
}
}
// [MOVED-DIVIDER 2026-06-17] 내부 분할선(예: A타입 중앙 3868)을 옮기면 경계선만 새 위치로 재분할되고
// 스켈레톤 hip 은 옛 위치에서 만나, 옮긴 보조선 끝점이 hip 에 노드로 안 붙어 분할이 안 되고 면이
// 겹쳐 생긴다(ROOF-FACE-DIAG: 거의 동일한 면 2개). 해결:
// (1) 내부 보조선 끝점이 hip 몸통 위(strict)에 있으면 그 hip 을 끝점에서 split → 보조선이 연결됨.
// (2) split 이 일어난 경우에만, 옛 접합점이 degree-2 공선(共線) 잉여 노드가 되면 merge → 옛 분할점 제거.
// 게이트: 실제 split 이 일어난 경우(=내부분할선 이동)에만 (2) 수행 → 외곽선 띠(SUPERSEDED) 케이스는 불변.
{
const innerAux = allLines.filter((l) => l.name === 'auxiliaryLine')
const isInner = (l) => l.name === 'hip' || l.name === 'ridge'
const EPS = 2
const mkSeg = (proto, sp, ep) => ({
name: proto.name,
lineName: proto.lineName,
attributes: { ...proto.attributes },
startPoint: { x: sp.x, y: sp.y },
endPoint: { x: ep.x, y: ep.y },
x1: sp.x,
y1: sp.y,
x2: ep.x,
y2: ep.y,
})
const onBodyStrict = (L, P) => {
const ax = L.startPoint.x,
ay = L.startPoint.y
const dx = L.endPoint.x - ax,
dy = L.endPoint.y - ay
const lenSq = dx * dx + dy * dy
if (lenSq < 1) return false
const t = ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
if (t <= 0.03 || t >= 0.97) return false
const px = ax + t * dx,
py = ay + t * dy
return (P.x - px) ** 2 + (P.y - py) ** 2 < EPS * EPS
}
if (innerAux.length > 0) {
const auxPts = []
innerAux.forEach((a) => {
auxPts.push(a.startPoint, a.endPoint)
})
// (1) 내부선을 보조선 끝점에서 split
let didSplit = false
const afterSplit = []
allLines.forEach((L) => {
if (!isInner(L)) {
afterSplit.push(L)
return
}
const ax = L.startPoint.x,
ay = L.startPoint.y
const dx = L.endPoint.x - ax,
dy = L.endPoint.y - ay
const lenSq = dx * dx + dy * dy || 1
const tOf = (P) => ((P.x - ax) * dx + (P.y - ay) * dy) / lenSq
const cuts = [...new Map(auxPts.filter((P) => onBodyStrict(L, P)).map((P) => [Math.round(tOf(P) * 1000), P])).values()].sort(
(p, q) => tOf(p) - tOf(q),
)
if (cuts.length === 0) {
afterSplit.push(L)
return
}
didSplit = true
let cur = L.startPoint
cuts.forEach((P) => {
afterSplit.push(mkSeg(L, cur, P))
cur = P
})
afterSplit.push(mkSeg(L, cur, L.endPoint))
})
if (didSplit) {
let work = afterSplit
const key = (p) => `${Math.round(p.x)},${Math.round(p.y)}`
// (2) degree-2 공선 내부선 접합점 merge (잉여 옛 분할점 제거)
let merged = true
let guard = 0
while (merged && guard++ < 200) {
merged = false
const incident = {}
work.forEach((L) => {
;[L.startPoint, L.endPoint].forEach((p) => {
;(incident[key(p)] || (incident[key(p)] = [])).push(L)
})
})
for (const k in incident) {
const inc = incident[k]
if (inc.length !== 2) continue
const [la, lb] = inc
if (la === lb || !isInner(la) || !isInner(lb)) continue
const [kx, ky] = k.split(',').map(Number)
const farA = Math.abs(la.startPoint.x - kx) < 1 && Math.abs(la.startPoint.y - ky) < 1 ? la.endPoint : la.startPoint
const farB = Math.abs(lb.startPoint.x - kx) < 1 && Math.abs(lb.startPoint.y - ky) < 1 ? lb.endPoint : lb.startPoint
const cross = (kx - farA.x) * (farB.y - ky) - (ky - farA.y) * (farB.x - kx)
const norm = Math.hypot(kx - farA.x, ky - farA.y) * Math.hypot(farB.x - kx, farB.y - ky)
if (norm < 1 || Math.abs(cross) / norm > 0.02) continue
work = work.filter((L) => L !== la && L !== lb)
work.push(mkSeg(la, farA, farB))
merged = true
break
}
}
// 동일 좌표 변 중복 제거
work = work.filter(
(line, idx, self) =>
idx ===
self.findIndex(
(o) =>
(isSamePoint(o.startPoint, line.startPoint) && isSamePoint(o.endPoint, line.endPoint)) ||
(isSamePoint(o.startPoint, line.endPoint) && isSamePoint(o.endPoint, line.startPoint)),
),
)
logger.log(`[MOVED-DIVIDER] 내부 분할선 이동 감지 — hip 재분할 적용 (allLines ${allLines.length}${work.length})`)
allLines = work
}
}
}
// 나눠서 중복 제거된 roof return
let newRoofs = getSplitRoofsPoints(allLines)
@ -1744,9 +1916,12 @@ export const usePolygon = () => {
// 2차 매칭 (옵션 3 + α): 대각선이 외벽 중간을 분할한 sub-segment 처리
const _isCollinearWithin = (parent, p) => {
const ax = parent.startPoint.x, ay = parent.startPoint.y
const bx = parent.endPoint.x, by = parent.endPoint.y
const dx = bx - ax, dy = by - ay
const ax = parent.startPoint.x,
ay = parent.startPoint.y
const bx = parent.endPoint.x,
by = parent.endPoint.y
const dx = bx - ax,
dy = by - ay
const segLenSq = dx * dx + dy * dy
if (segLenSq < 1) return false
const cross = (p.x - ax) * dy - (p.y - ay) * dx
@ -2043,7 +2218,11 @@ export const usePolygon = () => {
// 현재 남아있는 line들로 그래프 생성
const graph = {}
for (const line of remainingLines.filter((line2) => line2 !== startLine)) {
// [지붕면 공유변 fix] graph 는 startLine 자신만 제외한 전체 allLines 로 구성한다.
// remainingLines(이미 사용된 startLine 이 제거됨)로 만들면, 두 면이 공유하는 외곽 변이 먼저 처리된
// 면에서 소비되어 다음 면 graph 에서 사라지고 그 면이 빗변으로 우회한다. startLine 소비는 후보
// 목록(remainingLines)에서만 하고, 경로 탐색용 graph 에는 항상 남겨 공유 변을 재사용하게 한다.
for (const line of allLines.filter((line2) => line2 !== startLine)) {
const p1 = line.startPoint
const p2 = line.endPoint
const key1 = pointToKey(p1)
@ -2076,6 +2255,22 @@ export const usePolygon = () => {
}
})
// [GETSPLIT-IO 2026-06-15] getSplitRoofsPoints 의 정확한 입력(allLines, 좌표스냅·교차분할·중복제거 후)과
// 출력(roofs 면 polygon)을 debug/debug.log 로 영속화. 면분할 오할당 진단용 — 로컬 전용.
debugCapture.log('GETSPLIT-IO', {
allLines: allLines.map((l) => ({
name: l.name,
lineName: l.lineName ?? 'none',
type: l.attributes?.type ?? 'none',
isStart: l.attributes?.isStart ?? false,
sx: Math.round(l.startPoint.x * 100) / 100,
sy: Math.round(l.startPoint.y * 100) / 100,
ex: Math.round(l.endPoint.x * 100) / 100,
ey: Math.round(l.endPoint.y * 100) / 100,
})),
roofs: roofs.map((r) => r.map((p) => ({ x: Math.round(p.x * 100) / 100, y: Math.round(p.y * 100) / 100 }))),
})
return removeDuplicatePolygons(
roofs.filter((roof) => roof.length < 100),
allLines.some((line) => line.name === 'auxiliaryLine'),

558
src/hooks/useUndoRedo.js Normal file
View File

@ -0,0 +1,558 @@
import { useCallback, useRef } from 'react'
import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'
import {
canvasState,
canvasZoomState,
checkedModuleState,
currentMenuState,
drewRoofCellsState,
moduleIsSetupState,
moduleRowColArrayState,
moduleSetupSurfaceState,
redoStackState,
roofPolygonArrayState,
undoRedoDrawingState,
undoStackState,
} from '@/store/canvasAtom'
import { outerLinePointsState } from '@/store/outerLineAtom'
import { selectedMenuState } from '@/store/menuAtom'
import { pendingPopupState, popupState } from '@/store/popupAtom'
import { POLYGON_TYPE, SAVE_KEY } from '@/common/common'
import { useCircuitTrestle } from '@/hooks/useCirCuitTrestle'
const MAX_HISTORY = 3
// 모든 useUndoRedo 인스턴스가 공유하는 복원 플래그
// useRef는 인스턴스마다 독립적이므로, 모듈 레벨 변수로 전환
let _isRestoring = false
let _restoreTimer = null
// 외벽선/배치면 등 그리기 진행 중에는 undo/redo/snapshot 을 차단한다.
// (확정 시점부터 history 가 동작해야 하므로)
let _isDrawing = false
/**
* 외부 /모듈에서 현재 undo/redo 복원 중인지 확인할 사용
*/
export function isUndoRedoRestoring() {
return _isRestoring
}
/**
* 외벽선/배치면 그리기 진행 상태를 외부에서 설정한다.
* 그리기가 시작될 true, 확정 또는 취소 false 설정한다.
*/
export function setUndoRedoDrawing(value) {
_isDrawing = !!value
}
/**
* 외부 /모듈에서 현재 그리기 진행 중인지 확인할 사용
*/
export function isUndoRedoDrawing() {
return _isDrawing
}
// loadFromJSON 공통 처리 - 이벤트 일시 중단 후 복원
function _restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData) {
// 진행 중인 복원 타이머가 있으면 취소
if (_restoreTimer) {
clearTimeout(_restoreTimer)
_restoreTimer = null
}
// loadFromJSON 중 object:added 등 이벤트로 인한 부작용 방지
// 리스너를 저장/복원하지 않고 단순히 off만 한다
// restoreAtomSnapshot에서 currentMenu 변경 → useCanvasEvent가 attachDefaultEventOnCanvas로 재등록
;['object:added', 'object:modified', 'object:removed'].forEach((eventName) => {
canvas.off(eventName)
})
canvas.loadFromJSON(snapshot.canvasJSON, () => {
rebuildCanvasAtoms()
canvas.discardActiveObject()
canvas.getObjects().forEach((obj) => {
if (obj.type === 'QPolygon' && obj.name === POLYGON_TYPE.MODULE) {
obj.set({ stroke: 'black', strokeWidth: 0.3 })
}
})
canvas.renderAll()
restoreAtomSnapshot(snapshot.atomSnapshot)
// [undo 집계 fix] 캔버스/surface.modules 복원 후 패널 배치 집계를 재계산한다.
// 집계 atom(moduleStatisticsState)은 스냅샷 대상이 아니라, 복원 시점에 다시 계산해야 한다.
if (typeof setModuleStatisticsData === 'function') setModuleStatisticsData()
if (canvas.viewportTransform) {
setCanvasZoom(Number((canvas.viewportTransform[0] * 100).toFixed(0)))
}
// 다음 undo/redo 허용까지 짧은 딜레이 (연속 클릭 디바운스)
_restoreTimer = setTimeout(() => {
_isRestoring = false
_restoreTimer = null
}, 50)
})
}
// 순환 참조만 안전하게 건너뛰는 JSON 직렬화
// WeakSet 방식은 공유 참조(같은 배열이 여러 곳에서 참조)까지 제거하므로 사용 불가
// 대신 조상 경로(ancestors) 스택으로 진짜 순환만 감지
function safeStringify(obj) {
const ancestors = []
return JSON.stringify(obj, function (key, value) {
if (typeof value !== 'object' || value === null) return value
// `this`는 현재 key를 담고 있는 부모 객체
// ancestors 스택에서 현재 부모 이후의 항목(형제 경로)을 정리
while (ancestors.length > 0 && ancestors[ancestors.length - 1] !== this) {
ancestors.pop()
}
// 현재 value가 조상 경로에 이미 있으면 순환 참조
if (ancestors.includes(value)) return undefined
ancestors.push(value)
return value
})
}
function safeClone(value) {
if (value === null || value === undefined) return value
try {
return JSON.parse(safeStringify(value))
} catch {
return null
}
}
export function useUndoRedo() {
const canvas = useRecoilValue(canvasState)
const [undoStack, setUndoStack] = useRecoilState(undoStackState)
const [redoStack, setRedoStack] = useRecoilState(redoStackState)
const [isDrawing, setIsDrawing] = useRecoilState(undoRedoDrawingState)
const [, setModuleSetupSurface] = useRecoilState(moduleSetupSurfaceState)
const [moduleIsSetup, setModuleIsSetup] = useRecoilState(moduleIsSetupState)
const [checkedModule, setCheckedModule] = useRecoilState(checkedModuleState)
const [moduleRowColArray, setModuleRowColArray] = useRecoilState(moduleRowColArrayState)
const [, setDrewRoofCells] = useRecoilState(drewRoofCellsState)
const [, setRoofPolygonArray] = useRecoilState(roofPolygonArrayState)
const [outerLinePoints, setOuterLinePoints] = useRecoilState(outerLinePointsState)
const [selectedMenu, setSelectedMenu] = useRecoilState(selectedMenuState)
const [currentMenu, setCurrentMenu] = useRecoilState(currentMenuState)
const [, setCanvasZoom] = useRecoilState(canvasZoomState)
const setPopup = useSetRecoilState(popupState)
const [pendingPopup, setPendingPopup] = useRecoilState(pendingPopupState)
const { setModuleStatisticsData } = useCircuitTrestle()
// 최신 스택 값을 ref로 유지하여 클로저 stale 문제 방지
const undoStackRef = useRef(undoStack)
undoStackRef.current = undoStack
const redoStackRef = useRef(redoStack)
redoStackRef.current = redoStack
const getAtomSnapshot = useCallback(() => {
return {
moduleIsSetup: safeClone(moduleIsSetup || []),
checkedModule: safeClone(checkedModule || []),
moduleRowColArray: safeClone(moduleRowColArray || []),
outerLinePoints: safeClone(outerLinePoints || []),
selectedMenu,
currentMenu,
pendingPopup: pendingPopup ? safeClone(pendingPopup) : null,
}
}, [moduleIsSetup, checkedModule, moduleRowColArray, outerLinePoints, selectedMenu, currentMenu, pendingPopup])
// loadFromJSON 후 오브젝트 간 라이브 참조 관계를 재구축
const rebuildObjectGraph = useCallback(() => {
if (!canvas) return
const objects = canvas.getObjects()
// wall polygon의 lines를 캔버스의 outerLine 오브젝트로 재연결
const walls = objects.filter((obj) => obj.name === POLYGON_TYPE.WALL)
const outerLines = objects.filter((obj) => obj.name === 'outerLine')
walls.forEach((wall) => {
if (outerLines.length > 0) {
// outerLine에 idx가 있으면 순서대로, 없으면 parentId로 매칭
const wallOuterLines = outerLines.filter((line) => !line.parentId || line.parentId === wall.id).sort((a, b) => (a.idx ?? 0) - (b.idx ?? 0))
if (wallOuterLines.length > 0) {
wall.lines = wallOuterLines
}
}
})
// roof polygon의 innerLines, hips, ridges, lines를 캔버스 오브젝트로 재연결
const roofs = objects.filter((obj) => obj.name === POLYGON_TYPE.ROOF)
roofs.forEach((roof) => {
// innerLines: hip과 ridge 라인들 (roof.innerLines.push(hip), roof.innerLines.push(ridge) 패턴)
const hipLines = objects.filter((obj) => obj.parentId === roof.id && obj.name === 'hip')
const ridgeLines = objects.filter((obj) => obj.parentId === roof.id && obj.name === 'ridge')
if (hipLines.length > 0 || ridgeLines.length > 0) {
roof.innerLines = [...hipLines, ...ridgeLines]
}
if (hipLines.length > 0) {
roof.hips = hipLines
}
if (ridgeLines.length > 0) {
roof.ridges = ridgeLines
}
// roof.lines: eaveHelpLine 또는 helpLine (지붕면 할당에서 사용)
const eaveHelpLines = objects.filter(
(obj) => (obj.lineName === 'eaveHelpLine' || obj.lineName === 'helpLine' || obj.name === 'helpLine') && obj.roofId === roof.id,
)
if (eaveHelpLines.length > 0) {
roof.lines = eaveHelpLines
}
// skeletonLines 재연결
const skeletonLines = objects.filter((obj) => (obj.lineName === 'roofLine' || obj.lineName === 'skeletonLine') && obj.parentId === roof.id)
if (skeletonLines.length > 0) {
roof.skeletonLines = skeletonLines
}
})
// moduleSetupSurface의 modules 배열을 캔버스의 실제 QPolygon 모듈 객체로 재연결
const surfaces = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
const moduleObjects = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE)
surfaces.forEach((surface) => {
const surfaceModules = moduleObjects.filter((mod) => mod.surfaceId === surface.id)
surface.modules = surfaceModules
})
// fabric.Group 내부의 QPolygon/QLine 자식에 canvas 참조 설정
// loadFromJSON 후 Group 자식은 canvas 를 받지 못해 init/addLengthText 등이 실패할 수 있음
objects
.filter((obj) => obj.type === 'group')
.forEach((group) => {
if (group._objects) {
group._objects.forEach((child) => {
if (!child.canvas) {
child.canvas = canvas
}
})
}
})
// 부모가 없는 고아 lengthText, arrow, flowText 제거
// fabric.Group 내부의 QPolygon/QLine 자식 id도 부모로 간주해야
// 도머(Group) 내부 삼각형들의 lengthText가 사라지지 않음
const parentIds = new Set()
objects.forEach((obj) => {
if (obj.id) parentIds.add(obj.id)
if (obj.type === 'group' && obj._objects) {
obj._objects.forEach((child) => {
if (child.id) parentIds.add(child.id)
// 그룹 내부 QPolygon의 lines(QLine) id도 포함
if (child.lines && Array.isArray(child.lines)) {
child.lines.forEach((line) => {
if (line.id) parentIds.add(line.id)
})
}
})
}
// 일반 QPolygon의 lines id도 포함 (내부 보조선/치수선의 parentId)
if (obj.lines && Array.isArray(obj.lines)) {
obj.lines.forEach((line) => {
if (line.id) parentIds.add(line.id)
})
}
})
const orphans = objects.filter(
(obj) => (obj.name === 'lengthText' || obj.name === 'arrow' || obj.name === 'flowText') && obj.parentId && !parentIds.has(obj.parentId),
)
orphans.forEach((obj) => canvas.remove(obj))
// lengthText 이동 관련 속성 및 이벤트 재설정
// loadFromJSON 중 object:added 이벤트가 꺼져있어 addEvent가 실행되지 않으므로 수동 설정
const lengthTexts = canvas.getObjects().filter((obj) => obj.name === 'lengthText' && obj.type?.toLowerCase().includes('text'))
lengthTexts.forEach((target) => {
target.bringToFront()
target.selectable = true
target.evented = true
target.lockMovementX = false
target.lockMovementY = false
target.lockRotation = true
target.lockScalingX = true
target.lockScalingY = true
// 기존 per-object 이벤트 제거 후 재등록
target.off('selected')
target.off('moving')
const x = target.left
const y = target.top
target.on('selected', () => {
Object.keys(target.controls).forEach((controlKey) => {
target.setControlVisible(controlKey, false)
})
})
target.on('moving', () => {
if (target.parentDirection === 'left' || target.parentDirection === 'right') {
const minX = target.minX
const maxX = target.maxX
if (target.left <= minX) {
target.set({ left: minX, top: y })
} else if (target.left >= maxX) {
target.set({ left: maxX, top: y })
} else {
target.set({ top: y })
}
} else if (target.parentDirection === 'top' || target.parentDirection === 'bottom') {
const minY = target.minY
const maxY = target.maxY
if (target.top <= minY) {
target.set({ left: x, top: minY })
} else if (target.top >= maxY) {
target.set({ left: x, top: maxY })
} else {
target.set({ left: x })
}
}
canvas?.renderAll()
})
})
}, [canvas])
const rebuildCanvasAtoms = useCallback(() => {
if (!canvas) return
// 먼저 오브젝트 간 참조 관계 재구축 (roof.innerLines 등)
rebuildObjectGraph()
const objects = canvas.getObjects()
const surfaces = objects.filter((obj) => obj.name === POLYGON_TYPE.MODULE_SETUP_SURFACE)
setModuleSetupSurface(surfaces)
const roofs = objects.filter((obj) => obj.name === POLYGON_TYPE.ROOF)
setRoofPolygonArray(roofs)
const cells = roofs.filter((obj) => obj.cells && obj.cells.length > 0)
setDrewRoofCells(cells)
// loadFromJSON 후 QPolygon 의 내부 lines 배열은 plain object 로만 복원되므로
// initLines() 로 실제 QLine 인스턴스를 재구축한다.
// (lengthText 는 JSON 에서 이미 복원되므로 재생성하지 않는다.
// 과거에는 addLengthText 도 함께 호출했으나, undo/redo 반복 시 숨겨진 모서리 라인의
// 텍스트가 누적 생성되어 "707" 같은 잔존 텍스트가 화면에 나타나는 문제가 있었음)
const shouldRebuildText = (poly) =>
poly.type === 'QPolygon' &&
poly.name !== 'arrow' &&
poly.name !== POLYGON_TYPE.MODULE &&
poly.name !== POLYGON_TYPE.MODULE_SETUP_SURFACE &&
poly.name !== POLYGON_TYPE.OBJECT_SURFACE
// lengthText 인스턴스를 parentId 기준으로 한 번만 모아둔다 (texts 배열 복원에 재사용)
const lengthTextsByParent = new Map()
objects.forEach((obj) => {
if (obj.name === 'lengthText' && obj.parentId) {
const arr = lengthTextsByParent.get(obj.parentId) || []
arr.push(obj)
lengthTextsByParent.set(obj.parentId, arr)
}
})
const qPolygons = objects.filter(shouldRebuildText)
qPolygons.forEach((polygon) => {
try {
polygon.initLines()
// texts 배열 참조 복원 (재생성이 아닌 기존 lengthText 인스턴스 연결만)
// commonDeleteText 가 group._objects[i].texts / object.texts 를 통해 제거하므로,
// loadFromJSON 후 끊긴 참조를 다시 이어주지 않으면 삭제 시 lengthText 가 잔존한다.
if (polygon.id) {
polygon.texts = lengthTextsByParent.get(polygon.id) || []
}
} catch (e) {
/* skip */
}
})
// Group 내부 QPolygon(도머 등)도 동일하게 내부 라인 인스턴스만 재구축한다.
objects
.filter((obj) => obj.type === 'group' && obj._objects)
.forEach((group) => {
group._objects.forEach((child) => {
if (shouldRebuildText(child)) {
try {
if (!child.canvas) child.canvas = canvas
child.initLines?.()
if (child.id) {
child.texts = lengthTextsByParent.get(child.id) || []
}
} catch (e) {
/* skip */
}
}
})
})
}, [canvas, rebuildObjectGraph])
const restoreAtomSnapshot = useCallback((atomSnapshot) => {
if (!atomSnapshot) return
setModuleIsSetup(atomSnapshot.moduleIsSetup || [])
setCheckedModule(atomSnapshot.checkedModule || [])
setModuleRowColArray(atomSnapshot.moduleRowColArray || [])
setOuterLinePoints(atomSnapshot.outerLinePoints || [])
if (atomSnapshot.selectedMenu !== undefined) setSelectedMenu(atomSnapshot.selectedMenu)
if (atomSnapshot.currentMenu !== undefined) setCurrentMenu(atomSnapshot.currentMenu)
// undo/redo 시 복원해야 할 팝업 정보
setPendingPopup(atomSnapshot.pendingPopup ?? null)
}, [])
// canvas.toJSON(SAVE_KEY)의 결과를 순환 참조 없이 안전하게 직렬화
const getCanvasSnapshot = useCallback(() => {
if (!canvas) return null
try {
const rawJSON = canvas.toJSON(SAVE_KEY)
return JSON.parse(safeStringify(rawJSON))
} catch (e) {
console.warn('Canvas snapshot failed:', e)
return null
}
}, [canvas])
const saveSnapshot = useCallback(
(atomOverrides = null, { force = false } = {}) => {
if (!canvas || _isRestoring) return
// 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 스냅샷을 쌓지 않는다 (force 시 예외)
if (!force && (_isDrawing || isDrawing)) return
// snapshot 전에 mouseLine 제거
const mouseLines = canvas.getObjects().filter((obj) => obj.name === 'mouseLine')
mouseLines.forEach((obj) => canvas.remove(obj))
const canvasJSON = getCanvasSnapshot()
if (!canvasJSON) return
const atomSnapshot = getAtomSnapshot()
// atomOverrides 로 비동기 state 반영이 안 된 값을 즉시 덮어쓸 수 있다
if (atomOverrides) {
Object.assign(atomSnapshot, atomOverrides)
}
const snapshot = {
canvasJSON,
atomSnapshot,
}
setUndoStack((prev) => {
const newStack = [...prev, snapshot]
if (newStack.length > MAX_HISTORY) {
return newStack.slice(newStack.length - MAX_HISTORY)
}
return newStack
})
setRedoStack([])
},
[canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot],
)
const closeAllPopups = useCallback(() => {
setPopup((prev) => ({
// 처마 케라바 변경 팝업(EavesGableEdit)은 undo/redo 시 닫지 않는다
config: prev.config.filter((p) => p.component?.type?.name === 'EavesGableEdit'),
other: prev.other.filter((p) => p.component?.type?.name === 'EavesGableEdit'),
}))
}, [setPopup])
const undo = useCallback(() => {
// 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 undo 차단
if (_isDrawing || isDrawing) return
const currentUndoStack = undoStackRef.current
if (!canvas || currentUndoStack.length === 0 || _isRestoring) return
_isRestoring = true
const currentCanvasJSON = getCanvasSnapshot()
const currentAtomSnapshot = getAtomSnapshot()
const currentSnapshot = {
canvasJSON: currentCanvasJSON,
atomSnapshot: currentAtomSnapshot,
}
const newUndoStack = [...currentUndoStack]
const snapshot = newUndoStack.pop()
// undo/redo 시 항상 팝업을 닫고 복원한다
closeAllPopups()
setUndoStack(newUndoStack)
setRedoStack((prev) => [...prev, currentSnapshot])
_restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData)
}, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups, setModuleStatisticsData])
const redo = useCallback(() => {
// 외벽선/배치면/지붕형상 수동설정 등 그리기 진행 중에는 redo 차단
if (_isDrawing || isDrawing) return
const currentRedoStack = redoStackRef.current
if (!canvas || currentRedoStack.length === 0 || _isRestoring) return
_isRestoring = true
const currentCanvasJSON = getCanvasSnapshot()
const currentAtomSnapshot = getAtomSnapshot()
const currentSnapshot = {
canvasJSON: currentCanvasJSON,
atomSnapshot: currentAtomSnapshot,
}
const newRedoStack = [...currentRedoStack]
const snapshot = newRedoStack.pop()
// undo/redo 시 항상 팝업을 닫고 복원한다
closeAllPopups()
setRedoStack(newRedoStack)
setUndoStack((prev) => [...prev, currentSnapshot])
_restoreFromJSON(canvas, snapshot, restoreAtomSnapshot, rebuildCanvasAtoms, setCanvasZoom, setModuleStatisticsData)
}, [canvas, currentMenu, isDrawing, getCanvasSnapshot, getAtomSnapshot, restoreAtomSnapshot, rebuildCanvasAtoms, closeAllPopups, setModuleStatisticsData])
const clearStacks = useCallback(() => {
setUndoStack([])
setRedoStack([])
}, [])
/** undo 스택에서 마지막 스냅샷 1개만 제거 (취소 시 사용) */
const popLastSnapshot = useCallback(() => {
setUndoStack((prev) => (prev.length > 0 ? prev.slice(0, -1) : prev))
}, [])
/**
* 외벽선/배치면 그리기 진행 중에는 undo/redo 차단한다.
* 마운트 true, 언마운트 false 설정한다.
*/
const setDrawing = useCallback(
(value) => {
const v = !!value
setIsDrawing(v)
setUndoRedoDrawing(v)
},
[setIsDrawing],
)
return {
saveSnapshot,
undo,
redo,
clearStacks,
popLastSnapshot,
setDrawing,
canUndo: undoStack.length > 0 && !isDrawing,
canRedo: redoStack.length > 0 && !isDrawing,
}
}

View File

@ -408,3 +408,23 @@ export const moduleRowColArrayState = atom({
default: [],
dangerouslyAllowMutability: true,
})
// Undo/Redo 히스토리 스택
export const undoStackState = atom({
key: 'undoStackState',
default: [],
dangerouslyAllowMutability: true,
})
export const redoStackState = atom({
key: 'redoStackState',
default: [],
dangerouslyAllowMutability: true,
})
// 외벽선/배치면 등 그리기 진행 중 여부.
// true 동안에는 undo/redo 가 비활성화되고 새 스냅샷이 쌓이지 않는다.
export const undoRedoDrawingState = atom({
key: 'undoRedoDrawingState',
default: false,
})

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -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()
})
}

View File

@ -1651,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')
@ -1742,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()
}
@ -5452,7 +5466,52 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
//추가된 하단 지붕 라인 innerLines에 추가.
innerLines.push(...downRoofLines)
//지붕선에 따라 라인추가 작업 처리.
//지붕선에 따라 라인추가 작업 처리. (split 공용 함수 — docs §3-1)
splitRoofLinesToInnerLines(roof, innerLines, canvas, textMode)
//지붕 innerLines에 추가.
roof.innerLines = innerLines
canvas
.getObjects()
.filter((object) => object.name === 'check')
.forEach((object) => canvas.remove(object))
canvas.renderAll()
}
/**
* 지붕선(roof.lines) 원본 길이 text(lengthText) 제거한다.
* 지붕선 치수는 위에 그려진 보조선(처마선 innerLine) 치수로 대체되므로 roof.lines 자체 치수는 숨긴다.
* 외벽선(wall) 치수는 유지한다. (변별/박공/용마루/한쪽흐름 공통)
*/
export const removeRoofLengthText = (roof, canvas) => {
canvas
?.getObjects()
.filter((o) => o.name === 'lengthText' && o.parentId === roof.id)
.forEach((o) => canvas.remove(o))
}
/**
* roof.lines(처마 외곽선) innerLine 끝점 기준으로 split 편집 가능한 innerLine(hip/roofLine)으로 만들고,
* 처마선 innerLine 외곽선을 전담하므로 roof 원본 외곽선(stroke) 숨긴다(transparent).
* 변별/용마루 경로 공용. innerLines 결과를 in-place push 하고 반환한다.
* (용마루 경로는 추녀 연장 호출해야 종점이 처마변 위에 있어 분할점이 잡힌다 docs §9)
* @param {fabric.Object} roof - 대상 지붕 (roof.lines 사용)
* @param {Array<QLine>} innerLines - 내부선 배열 (split 결과가 여기에 in-place 추가됨)
* @param {fabric.Canvas} canvas
* @param {string} textMode
* @param {{degreeOf?: (parentLine) => number}} [opts] - degreeOf: 부모 외곽변 경사각().
* 한쪽흐름 경사변(gable)처럼 평면길이실제길이인 변을 통짜로 그릴 drawHipLine 으로 실제길이 보정(plane/cos) docs §8.
* 미전달 기존 동작(분할점 인접 pitch 기반).
* @returns {Array<QLine>} innerLines
*/
export const splitRoofLinesToInnerLines = (roof, innerLines, canvas, textMode, opts = {}) => {
const { degreeOf } = opts
// 분할점이 처마변 끝점(코너)과 이 거리(px) 이내면 분할에서 제외.
// SK 추녀 연장이 처마 코너에 닿을 때 ray cast 부동소수 오차(관측 0.02px)로 코너와 미세하게
// 어긋나면, 기존 almostEqual(1e-10)은 "끝점 아님"으로 오판 → 0 길이 조각 생성. END_EPS 로 흡수.
// 변별 경로의 진짜 분할점은 끝점에서 수백 px 떨어져 있어 영향 없음.
const END_EPS = 1.0
const innerLinesPoints = []
innerLines.forEach((line) => {
const hasCoord1 = innerLinesPoints.find((p) => p.x === line.x1 && p.y === line.y1)
@ -5468,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
@ -5513,19 +5568,23 @@ export const drawRoofByAttribute = (roofId, canvas, textMode) => {
} else {
innerLines.push(drawHipLine([startPoint.x, startPoint.y, currentLine.x2, currentLine.y2], canvas, roof, textMode, nextDegree, nextDegree))
}
} else {
// 분할점 없는 통짜 변. degreeOf 가 주어지면(한쪽흐름) 경사변은 drawHipLine(plane/cos) 으로 실제길이 보정.
const deg = degreeOf ? degreeOf(currentLine) : 0
if (deg > 0) {
innerLines.push(drawHipLine([currentLine.x1, currentLine.y1, currentLine.x2, currentLine.y2], canvas, roof, textMode, deg, deg))
} else {
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
}
/**

View File

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

2
startscript-ur.js Normal file
View File

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