diff --git a/docs/plans/2026-06-01-skeleton-roofline-edit-design.md b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md index 82414cae..3164c6b2 100644 --- a/docs/plans/2026-06-01-skeleton-roofline-edit-design.md +++ b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md @@ -1,7 +1,7 @@ # 용마루(skeleton) 경로 외곽 지붕선 편집 기능 — 설계 - 작성일: 2026-06-01 -- 대상 브랜치: `feature/redo-undo` +- 대상 브랜치: `feature/roofline-edit` - 관련 파일: `src/util/skeleton-utils.js`, `src/util/qpolygon-utils.js`, `src/hooks/useContextMenu.js` ## 1. 배경 / 문제 @@ -66,13 +66,13 @@ splitRoofLinesToInnerLines(roof, canvas, textMode) → QLine[] - **`drawRoofByAttribute` 교체**: 인라인 블록을 함수 호출로 교체. 순수 추출이므로 변별 경로 동작 불변. ### 3-2. skeleton 경로 변경 -- `createInnerLinesFromSkeleton` 의 `isOuterEdge` 숨김 처리(`:1636-1643` 의 `visible:false`, `:1624` 의 `selectable:false`) 제거. -- 내부선(용마루/추녀) 생성 완료 후 `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. +- `createInnerLinesFromSkeleton` 의 `isOuterEdge`(SK eaves) 숨김 처리는 **유지**. (§9 정정: SK eaves 라인은 baseLine 기준이라, 노출하면 처마변 split 결과와 offset 만큼 어긋나 이중 표시됨. 처마 외곽선은 아래 split 으로 별도 innerLine 화.) +- **추녀 연장(`drawBaselineToRooflineHelpers`, `:1285`) 완료 후** `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 추녀 종점이 처마변 위에 있는 상태라 split 정합 성립, 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. (⚠️ 연장 **전**에 호출하면 종점이 baseLine 위라 분할점을 못 잡음 — §9 참조) - `drawHelpLine` 재호출 시마다 자동 재실행되어 편집 후 재그리기에도 외곽 지붕선 유지. ## 4. 리스크 & 구현 전 검증 -1. **SK 추녀 끝점 ↔ `roof.lines` 정합성**: split 은 "innerLine 끝점이 `roof.lines` 위에 있다"(`isPointOnLineNew`, tol 0.1)를 전제로 한다. SK 가 만든 hip 끝점이 처마 외곽선 위에 실제로 떨어지는지 **실측 확인 필요**. 안 맞으면 split 이 헛돌아 외곽선이 통째로 roofLine 1개로만 그려진다. → 구현 첫 단계에서 검증. +1. **SK 추녀 끝점 ↔ `roof.lines` 정합성** — ✅ **검증 완료 (2026-06-12, §9 참조)**: **연장 후 정합 OK**. SK 골조는 `wall.baseLines` 기준이라 추녀 끝점이 처음엔 baseLine 코너에 있으나(연장 전 `onRoof=false`), `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 **연장**해 종점을 처마변 위로 옮긴다(연장 후 4코너 전부 `diffRoof=0`, `onRoof=true`). → **split 을 연장 다음에 호출하면 정합 자동 성립**. (초기 측정이 연장 전에 찍혀 "구조적 불일치"로 오판했음 — §9-0 참조) 2. **순환 import**: `qpolygon-utils ↔ skeleton-utils` 상호 import 여부 점검. 순환이면 split 함수를 제3의 모듈(예: canvas-util)로 추출하거나 의존성 정리. 3. **기존 `isOuterEdge` roofLine 제거 영향**: 숨긴 라인을 참조하는 곳(치수 계산, 플랜 저장/복원 등)이 있는지 확인 후 제거. @@ -159,3 +159,91 @@ opts.degreeOf?: (parentLine) => number // 부모 외곽변 → 경사각(도). ### 8-5. 리스크 - **분할점 부재**: 한쪽흐름은 내부 보조선이 없어 외곽변이 1:1 통짜 라인으로 그려진다 → 부모 변 type 매칭이 단순(분할 시에도 조각은 부모 type 상속). split 의 끝점 정합성(`isPointOnLineNew`, tol 0.1) 자체는 분할점이 없으면 무의미하므로 `else` 통짜 경로(`:5493`)로 안전하게 떨어진다. - **`isGable` 판정원**: `drawShedRoof` 의 `gables` 분류(`:1653`)와 동일 기준(`attributes.type === GABLE`)을 써야 보정 대상이 일치. + +--- + +## 9. SK 끝점 ↔ 처마변 정합 — 실측 결과 (2026-06-12, 측정 시점 정정) + +§4 리스크1 을 임시 계측(`skeleton-utils.js` 의 `logSkRoofLineAlignment`)으로 추녀 연장 **전/후** 2단계 실측한 결과. + +### 9-0. ⚠️ 측정 시점 정정 (중요) +1차 측정은 `createInnerLinesFromSkeleton` **직후**(`:1279`, 추녀 연장 전)에 찍혀, 추녀 종점이 baseLine 코너에 있는 상태(`onRoof=false`, `perpRoof=offset`)만 보고 **"구조적 불일치 → 투영 보정 필요"로 오판**했다. 실제로는 그 직후 `drawBaselineToRooflineHelpers`(`:1285`)가 추녀를 처마변까지 연장한다. 연장 **후**(`afterExt`) 측정에서 정합이 성립함을 확인 → 아래는 정정된 결론. **(투영 보정·SK 입력 교체·연장 보정 설계는 모두 폐기. 불필요했음.)** + +### 9-1. 실측 결과 (용마루 우진각, 변별 offset 50px) +처마변 x∈[357.5,1147.6]·y∈[115.2,636.7] vs baseLine x∈[407.5,1097.6]·y∈[165.2,586.7] (전 변 50px 안쪽). + +| 추녀 끝점 | beforeExt (`:1279`, 연장 전) | afterExt (`:1285`, 연장 후) | +|---|---|---| +| **바깥** 끝점(4코너) | baseLine 코너 — `onRoof=false`, `diffRoof=22.4`, `onBase=true` | **처마 코너로 연장 — `onRoof=true`, `diffRoof=0`** | +| 안쪽·용마루(내부점) | `onRoof=false` (260.7) | `onRoof=false` (260.7) — 내부점이라 정상 | + +예: 추녀 바깥 끝 `(407.5,165.2)`(baseLine) → `(357.5,115.2)`(처마 코너) 로 연장. 4코너 전부 `diffRoof=0`, `onRoof=true`. + +### 9-2. 추녀 연장 메커니즘 (이미 구현됨) +`drawBaselineToRooflineHelpers`(`:685`, 플래그 `DRAW_BASELINE_TO_ROOFLINE_HELPER=true`, `:25`): +- `hipLines` 추출(`:734`) → baseLine 코너에 닿는 hip 만(`CORNER_EPS`, `:773`) → 추녀 진행 방향으로 ray cast(`rayHitSegment`, `:693`) → 처마변 교점까지 hip 좌표를 **연장**(`:861-865`). +- `actualSize` 비율 보정(`:871`) + `__extended`/`attributes.extended` 플래그로 save/load 영속(`:879`). +- 즉 "추녀는 roof.lines 까지 연결되는 선" 이 이미 코드에 있다. SK 골조 자체는 baseLine 기준(동기화 유지) + 추녀 종점만 처마변까지 연장. + +### 9-3. 결론 — split 은 추녀 연장 **다음**에 호출 +- 추녀 종점은 연장 후 처마변 위(`onRoof=true`, `diffRoof=0`)에 정확히 앉으므로, **split/외곽선 innerLine화의 정합은 깨지지 않는다.** +- **유일한 요건**: `splitRoofLinesToInnerLines` 호출을 `createInnerLinesFromSkeleton` 직후가 아니라 **`drawBaselineToRooflineHelpers`(`:1285`) 다음에** 둘 것. §3-2 가 호출 위치를 연장 전으로 잡은 것이 유일한 함정이었음. +- **단순 우진각**: 추녀가 처마 **코너**(변 끝점)에 닿으므로 split 분할점 없음 → 처마 4변 통짜 innerLine(정상). +- **복합 형상**(L자·凸자): 추녀가 처마변 **중간**에 닿으면 그 점에서 분할. 연장 후 종점이 처마변 위이므로 분할점으로 정상 인식. + +### 9-4. 검증 기준 (구현 시) +- split 을 연장 후 호출 → 처마 4변이 innerLine 으로 등록, select+우클릭 편집 가능. +- 복합 형상: 처마변 중간 접점에서 분할, 각 조각 독립 편집. +- offset=0 / offset≠0 양쪽 동작. +- 마루이동/벽이동 후 재그리기에도 유지(연장은 `__extended`/`attributes.extended` 로 영속). +- **임시 계측(`logSkRoofLineAlignment` 정의 + 호출부 3곳 `:1279`/`:1285`/`:1413`) 제거** 후 커밋. + +--- + +## 10. 육각형 등 비직교 형상에서 외곽 지붕선 소실 — 진단 (2026-06-22 추가) + +### 10-1. 증상 +**육각형(wall 6변) 용마루 지붕** 설정 시 오른쪽 위 외곽 지붕선 2개가 화면에서 사라진다. +단순 우진각(사각형)은 정상(§9-1 실측 검증 완료) — 비직교(대각선) 변이 많은 형상에서만 발생. + +### 10-2. 가설 (코드 분석, 1순위) +`splitRoofLinesToInnerLines`(`qpolygon-utils.js:5485`) 는 처마변을 innerLine 으로 등록한 뒤 +**roof 원본 외곽선을 `stroke:'transparent'` 로 숨긴다**(`:5563`). 따라서 어떤 처마변이 +innerLine 으로 **재등록되지 못하면** 원본 대체선도 없어 그 자리에 선이 완전히 사라진다. + +누락 1순위 지점 = `hasOverlapLine` 가드(`:5511-5523`). 이는 **축정렬 bounding-box 포함관계**로만 +overlap 을 판정하는데, 이 블록은 원래 **변별 경로(`drawRoofByAttribute`, 주로 직교 형상)** 의 +인라인 코드를 `1bf5f45b` 에서 공용 추출한 것이다. 직교 변은 bbox 가 선분에 딱 맞아 false positive 가 +드물지만, **육각형의 대각선 처마변 + 대각선 추녀(hip)** 는 bbox 가 큰 사각형이라 코너를 공유할 때 +서로의 bbox 를 포함하기 쉬워 `hasOverlapLine=true` 오발동 → 해당 처마변 `return`(skip). +오른쪽 위 짧은 대각선 변 2개가 인접 추녀 bbox 에 함께 포함되어 동시 skip 된 것으로 추정. + +### 10-3. 진단 결과 — 가설 확정 (2026-06-22) +육각형(직사각형 우상단 모서리를 계단으로 자른 형상) 재현 `[HEXDIAG]` 로그: + + split entry roof.lines=6 innerLines=9 + line#0 WHOLE (386,35)→(386,1335) ← 좌 수직변, 정상 + line#1 WHOLE (386,1335)→(1286,1335) ← 하 수평변, 정상 + line#2 WHOLE (1286,1335)→(1286,235) ← 우 수직변, 정상 + line#3 SKIP (1286,235)→(1086,235) overlapBy=inner⊇curr hip(1286,235)→(836,685) ✗ + line#4 SKIP (1086,235)→(1086,35) overlapBy=inner⊇curr hip(1086,35)→(736,385) ✗ + line#5 WHOLE (1086,35)→(386,35) ← 상 수평변, 정상 + +- 사라진 line#3(수평, y두께 0)·line#4(수직, x두께 0) = 잘린 계단의 짧은 변 2개. +- 둘 다 `inner⊇curr`: 끝점 코너를 공유한 **대각선 추녀(hip)의 bbox 가 0-두께 처마변 bbox 를 포함**. + 예) line#3 bbox `x[1086,1286]·y[235,235]` ⊂ hip bbox `x[836,1286]·y[235,685]`. +- line#0·1·2·5 가 멀쩡한 건 길어서 어떤 hip bbox 로도 통째로 못 감싸기 때문. → **가설 확정.** +- 1순위 정정: 범인은 "대각선 처마변"이 아니라 **축정렬(수평/수직) 짧은 처마변 + 대각선 추녀** 조합. + +### 10-4. 수정 (적용 완료, 2026-06-22) +`hasOverlapLine` 판정을 **bbox 포함 → 공선 + 구간 덮음**으로 교체(`qpolygon-utils.js:5507-5519`): +한쪽 선분의 **양 끝점이 모두 상대 선분 위**(`isPointOnLineNew`, tol 0.1)일 때만 overlap 인정. +대각선 추녀는 처마변과 공선이 아니라 통과 → 처마변 정상 등록. `[HEXDIAG]` 계측·`logger` import 전량 제거. + +**회귀 영향 분석** (이 함수를 쓰는 4경로): +- **변별**(`drawRoofByAttribute` → `:5447`): 가드 원사용처. 원래 bbox 가 잡던 "진짜 같은 선 위 겹침"은 공선이라 새 판정으로도 잡힘. bbox-만-겹침(공선 아님)은 원래 false positive → 제거가 곧 개선. 회귀 위험 = 공선인데 부동소수로 tol 0.1 초과하는 경계뿐(드묾). +- **한쪽흐름**(`drawShedRoof` → `:1731`): 내부 보조선 없어 `innerLines` 거의 비어 가드 트리거 자체가 드묾. 영향 미미. +- **용마루**(skeleton `:1288`): 대상 케이스. 사각형 우진각·육각형 양쪽 확인. +- **박공**(`drawGableRoof`): split 미사용 → 무영향. + +**재현 검증 체크리스트**: ① 육각형 용마루 6변 전부 등록(소실 없음) ② 단순 사각형 우진각 4변 유지(회귀 없음) ③ 변별 지붕 외곽선·추녀 정상 ④ 한쪽흐름 경사변 actual=plane/cos 유지. (캔버스 인터랙션이라 런타임 확인은 재현 필요 — 정적 분석상 ①만 동작 변화, ②③④ 불변 예상.) diff --git a/package.json b/package.json index e990626b..7e51dd5d 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "start:cluster1": "env-cmd -f .env.production next start -p 5000", "start:cluster2": "env-cmd -f .env.production next start -p 5001", "start:dev": "env-cmd -f .env.development next start -p 5010", + "start:ur": "env-cmd -f .env.development next start -p 5050", "start:btob": "env-cmd -f .env.development next start -p 5060", "lint": "next lint", "serve": "node server.js" diff --git a/src/common/common.js b/src/common/common.js index 2e4bb98c..df8be1a2 100644 --- a/src/common/common.js +++ b/src/common/common.js @@ -227,6 +227,11 @@ export const SAVE_KEY = [ 'outerLineFix', 'adjustRoofLines', 'northModuleYn', + // SK 빌드 시 hip 이 ext 끝점까지 연장된 상태임을 표시. + // undo/redo 시 보존되어야 integrateExtensionLines 가 중복 merge 를 스킵할 수 있다. + // (없으면 undo 후 지붕면 할당 시 hip 이 다시 merge 되어 corner 가 inner mesh 와 분리되며 + // path-finding 이 실패 → 4 면 분할 안 됨) + '__extended', ] export const OBJECT_PROTOTYPE = [fabric.Line.prototype, fabric.Polygon.prototype, fabric.Triangle.prototype, fabric.Group.prototype] diff --git a/src/components/common/ErrorBoundary.jsx b/src/components/common/ErrorBoundary.jsx index 677a67fa..dad497a0 100644 --- a/src/components/common/ErrorBoundary.jsx +++ b/src/components/common/ErrorBoundary.jsx @@ -40,7 +40,7 @@ class ErrorBoundary extends React.Component {

Timestamp: {new Date().toISOString()}

Error: {this.state.error && this.state.error.toString()}

Stack Trace:

-
{this.state.errorInfo.componentStack}
+
{this.state.errorInfo?.componentStack}
+ + {
{getMessage('plan.mode.vertical.horizontal')} diff --git a/src/components/floor-plan/MenuDepth01.jsx b/src/components/floor-plan/MenuDepth01.jsx index d6bb2477..9e02df75 100644 --- a/src/components/floor-plan/MenuDepth01.jsx +++ b/src/components/floor-plan/MenuDepth01.jsx @@ -9,6 +9,7 @@ import { useRecoilState, useRecoilValue, useResetRecoilState } from 'recoil' import { subMenusState } from '@/store/menuAtom' import { useCanvasMenu } from '@/hooks/common/useCanvasMenu' import { commonUtilsState } from '@/store/commonUtilsAtom' +import { isUndoRedoRestoring } from '@/hooks/useUndoRedo' export default function MenuDepth01() { const canvas = useRecoilValue(canvasState) @@ -29,6 +30,10 @@ export default function MenuDepth01() { } useEffect(() => { + // undo/redo 복원 중에는 메뉴 변경에 따른 팝업 자동 오픈을 건너뛴다. + // 그렇지 않으면 그리기 팝업이 다시 열리며 setDrawing(true) 가 호출되어 + // redo 버튼이 비활성화되는 문제가 발생한다. + if (isUndoRedoRestoring()) return handleMenu(selectedMenu) canvas?.discardActiveObject() }, [currentMenu]) diff --git a/src/components/floor-plan/modal/auxiliary/AuxiliaryEdit.jsx b/src/components/floor-plan/modal/auxiliary/AuxiliaryEdit.jsx index 9648ef2e..77e09e8f 100644 --- a/src/components/floor-plan/modal/auxiliary/AuxiliaryEdit.jsx +++ b/src/components/floor-plan/modal/auxiliary/AuxiliaryEdit.jsx @@ -10,6 +10,7 @@ import { useAuxiliaryDrawing } from '@/hooks/roofcover/useAuxiliaryDrawing' import { useSwal } from '@/hooks/useSwal' import { normalizeDigits } from '@/util/input-utils' import { CalculatorInput } from '@/components/common/input/CalcInput' +import { useUndoRedo } from '@/hooks/useUndoRedo' export default function AuxiliaryEdit(props) { const contextPopupPosition = useRecoilValue(contextPopupPositionState) @@ -23,8 +24,10 @@ export default function AuxiliaryEdit(props) { const [arrow2, setArrow2] = useState(null) const currentObject = useRecoilValue(currentObjectState) const { swalFire } = useSwal() + const { saveSnapshot } = useUndoRedo() const [useCalcPad, setUseCalcPad] = useState(false) const disableKeypad = !useCalcPad + const handleSave = () => { if ((!arrow1 && !arrow2) || (+verticalSize === 0 && +horizonSize === 0)) { swalFire({ title: getMessage('length.direction.is.required'), type: 'alert' }) @@ -41,6 +44,7 @@ export default function AuxiliaryEdit(props) { return } + saveSnapshot() if (type === 'copy') { if (currentObject) { copy( @@ -81,7 +85,7 @@ export default function AuxiliaryEdit(props) { disableKeypad={disableKeypad} options={{ allowNegative: false, - allowDecimal: false + allowDecimal: false, }} />
@@ -91,14 +95,12 @@ export default function AuxiliaryEdit(props) { className={`direction up ${arrow1 === '↑' ? 'act' : ''}`} onClick={() => { setArrow1('↑') - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp' })) }} > @@ -116,7 +118,7 @@ export default function AuxiliaryEdit(props) { disableKeypad={disableKeypad} options={{ allowNegative: false, - allowDecimal: false + allowDecimal: false, }} /> @@ -126,14 +128,12 @@ export default function AuxiliaryEdit(props) { className={`direction left ${arrow2 === '←' ? 'act' : ''}`} onClick={() => { setArrow2('←') - document.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' })) }} > @@ -143,7 +143,12 @@ export default function AuxiliaryEdit(props) {