From b28397c91a96363e1397202da52c9a2ee8e8d202 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Tue, 2 Jun 2026 09:45:20 +0900 Subject: [PATCH 1/4] =?UTF-8?q?docs:=20=EC=9A=A9=EB=A7=88=EB=A3=A8(skeleto?= =?UTF-8?q?n)=20=EA=B2=BD=EB=A1=9C=20=EC=99=B8=EA=B3=BD=20=EC=A7=80?= =?UTF-8?q?=EB=B6=95=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=84=A4=EA=B3=84=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit drawSkeletonRidgeRoof 경로에서 숨겨진 외곽 지붕선을 변별 경로와 동일한 selectable innerLine으로 생성해 기존 select+우클릭 편집 기능을 재사용하는 설계. splitRoofLinesToInnerLines 공용 추출 + 정렬버그 수정 방향 포함. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...026-06-01-skeleton-roofline-edit-design.md | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 docs/plans/2026-06-01-skeleton-roofline-edit-design.md diff --git a/docs/plans/2026-06-01-skeleton-roofline-edit-design.md b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md new file mode 100644 index 00000000..2efd354e --- /dev/null +++ b/docs/plans/2026-06-01-skeleton-roofline-edit-design.md @@ -0,0 +1,89 @@ +# 용마루(skeleton) 경로 외곽 지붕선 편집 기능 — 설계 + +- 작성일: 2026-06-01 +- 대상 브랜치: `feature/redo-undo` +- 관련 파일: `src/util/skeleton-utils.js`, `src/util/qpolygon-utils.js`, `src/hooks/useContextMenu.js` + +## 1. 배경 / 문제 + +지붕 그리기는 `QPolygon.drawHelpLine()` 에서 외벽선 type 패턴으로 분기한다. + +- **변별 경로** (`drawRoofByAttribute`, qpolygon-utils.js): 외곽선(`roof.lines`)을 내부선 끝점 기준으로 split 해서 추녀(hip)/지붕선을 `name='hip'` selectable QLine 으로 `roof.innerLines` 에 넣는다. 사용자는 이 라인을 select → 우클릭으로 편집할 수 있다. +- **용마루(skeleton) 경로** (`drawSkeletonRidgeRoof` → `skeletonBuilder` → `createInnerLinesFromSkeleton`): 외곽 처마선을 SK 빌더의 outer edge 로 만들되 `lineName='roofLine'` + `selectable:false` + `visible:false` 로 **숨겨버린다** (`skeleton-utils.js:1611-1643`). 그래서 외곽 지붕선을 편집할 수 없다. + +**목표**: 용마루 경로에서도 외곽 지붕선을 변별 경로와 **동일한 형태의 innerLine** 으로 생성하여, 기존 select + 우클릭 편집 기능을 그대로 재사용한다. + +## 2. 핵심 사실 (탐색 결과) + +### 2-1. innerLine 편집 기능의 정체 +사용자가 innerLine 을 편집하는 통로는 **select → 우클릭 컨텍스트 메뉴**다. `useContextMenu.js:309-312` 에서 `auxiliaryLine` / `hip` / `ridge` / `eaveHelpLine` 을 한 case 로 묶는다. + +| 메뉴 | 동작 | +|---|---| +| 사이즈 변경 | `AuxiliarySize` 모달 | +| 보조선 이동(M) / 복사(C) | `AuxiliaryEdit` 모달 | +| 보조선 삭제 / 전체삭제 | `roof.innerLines` 에서 제거 | +| 수직 이등분선 | `roof.innerLines.push` | + +이 분기는 **`currentObject.name` 기준**이다. 따라서 외곽 지붕선을 `name='hip'` (또는 `'ridge'`) 로 만들어 innerLine 에 넣기만 하면 이 메뉴가 **자동으로** 붙는다. 별도 바인딩 코드 불필요. + +### 2-2. 변별 경로는 이미 동작 중 (= 목표 동작의 레퍼런스) +변별 경로 split 은 외곽 지붕선을 `drawRoofLine` 으로 그리는데, 그 함수의 `name` 이 `SUBLINE.HIP` 이다 (의도적 재사용). 즉 변별 경로의 외곽 지붕선은 이미 `name='hip'` innerLine 으로 들어가 select+우클릭이 동작한다. **용마루도 이와 동일하게 동작하면 된다 (전용 차이 없음).** + +### 2-3. innerLine 이 편집 가능하려면 가져야 하는 속성 + +| 조건 | 값 | 안 맞으면 | +|---|---|---| +| `type` | `'QLine'` | 플랜 복원/필터 누락 (`useCanvasConfigInitialize.js:243`) | +| `name` | `'hip'` / `'ridge'` (SUBLINE 값) | 컨텍스트 메뉴·이동 분기에 안 걸림 | +| `attributes.roofId` / `parentId` | `roof.id` | roof 조회·자식 이동 실패 | +| `selectable` | `true` | 클릭 자체 불가 | +| **금지** | `name='roofLine'`, `selectable:false`, `evented:false` | 현재 숨긴 외곽선이 정확히 이 상태 | + +### 2-4. 재그리기 함정 +`drawHelpLine()` 은 호출될 때마다 `roof.innerLines` 를 비우고 SK 함수가 처음부터 다시 그린다 (`QPolygon.js:582-598`). 따라서 외곽 지붕선을 "한 번 추가" 하는 것으로 부족하고, **재그리기 때마다 skeleton 경로가 자동 재생성** 하도록 만들어야 살아남는다. + +## 3. 설계 (접근안 A: split 로직 공용 추출) + +### 3-1. 공용 함수 추출 +`qpolygon-utils.js:5432-5496` 의 "지붕선에 따라 라인추가" 블록을 함수로 추출: + +``` +splitRoofLinesToInnerLines(roof, canvas, textMode) → QLine[] + - innerLinesPoints 수집 (innerLines 끝점 unique) + - roof.lines 순회: + hasOverlapLine 가드 (innerLine 이 외곽선을 완전 포함하면 skip) + splitPoint 수집 (innerLinesPoints 중 currentLine 위 + 끝점 아닌 점) + 분류: 첫 구간 = drawHipLine(prevDegree) + 중간 구간 = drawRoofLine + 끝 구간 = (splitPoint 1개면 drawRoofLine, 아니면 drawHipLine(nextDegree)) + splitPoint 없으면 통째로 drawRoofLine + - 생성 라인을 innerLines 에 push 후 반환 +``` + +- **위치**: `drawHipLine` / `drawRoofLine` 이 있는 `qpolygon-utils.js` 에 두고 `export`. `skeleton-utils.js` 에서 import. +- **정렬 버그 동시 수정**: 현재 `splitPoint.sort((a, b) => a[1] - b[1])` 는 원소가 `{point, distance}` 객체라 `a[1]` 이 `undefined` → 정렬 no-op. `a.distance - b.distance` 로 수정. (분할점 2개 이상 케이스에서 라인이 거리순으로 그려지지 않던 잠재 버그) +- **`drawRoofByAttribute` 교체**: 인라인 블록을 함수 호출로 교체. 순수 추출이므로 변별 경로 동작 불변. + +### 3-2. skeleton 경로 변경 +- `createInnerLinesFromSkeleton` 의 `isOuterEdge` 숨김 처리(`:1636-1643` 의 `visible:false`, `:1624` 의 `selectable:false`) 제거. +- 내부선(용마루/추녀) 생성 완료 후 `splitRoofLinesToInnerLines(roof, canvas, textMode)` 호출 → 외곽 지붕선이 변별 경로와 동일한 형태로 `roof.innerLines` 에 합류. +- `drawHelpLine` 재호출 시마다 자동 재실행되어 편집 후 재그리기에도 외곽 지붕선 유지. + +## 4. 리스크 & 구현 전 검증 + +1. **SK 추녀 끝점 ↔ `roof.lines` 정합성**: split 은 "innerLine 끝점이 `roof.lines` 위에 있다"(`isPointOnLineNew`, tol 0.1)를 전제로 한다. SK 가 만든 hip 끝점이 처마 외곽선 위에 실제로 떨어지는지 **실측 확인 필요**. 안 맞으면 split 이 헛돌아 외곽선이 통째로 roofLine 1개로만 그려진다. → 구현 첫 단계에서 검증. +2. **순환 import**: `qpolygon-utils ↔ skeleton-utils` 상호 import 여부 점검. 순환이면 split 함수를 제3의 모듈(예: canvas-util)로 추출하거나 의존성 정리. +3. **기존 `isOuterEdge` roofLine 제거 영향**: 숨긴 라인을 참조하는 곳(치수 계산, 플랜 저장/복원 등)이 있는지 확인 후 제거. + +## 5. 성공 조건 (검증 기준) + +- 용마루 지붕에서 외곽 지붕선이 화면에 보이고 클릭 선택된다. +- 우클릭 시 hip/ridge 와 동일한 메뉴(사이즈변경/이동/복사/삭제/수직이등분선)가 노출·동작한다. +- 마루이동 등으로 재그리기 후에도 외곽 지붕선이 유지된다. +- 변별 경로 동작 회귀 없음 (외곽선 split 결과 동일, 정렬 버그 수정분 제외). + +## 6. 범위 밖 (YAGNI) + +- 외곽 지붕선 전용 우클릭 메뉴/가드 (변별 경로와 완전 동일하게 가므로 추가 분기 없음). +- 박공(`drawGableRoof`) / 한쪽흐름(`drawShedRoof`) 경로는 이번 변경 대상 아님. From 46442698522fbcf7b31ec87d1b2812709ab9e757 Mon Sep 17 00:00:00 2001 From: Jaeyoung Lee Date: Tue, 2 Jun 2026 09:52:17 +0900 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=EC=A7=80=EB=B6=95=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EB=B6=84=EC=84=9D=20=EB=A9=94?= =?UTF-8?q?=EB=AA=A8=EB=A6=AC=20=EB=AC=B8=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit qpolygon-utils 지붕 생성 구조 / drawRoofByAttribute 내부 구조 / skeleton-utils 용마루 두 경로 차이 분석. roofline-edit 구현 시 reference. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../project_drawRoofByAttribute_internals.md | 396 ++++++++++++++++++ .../project_qpolygon_utils_roof_creation.md | 171 ++++++++ .../project_skeleton_utils_ridge_roof.md | 77 ++++ 3 files changed, 644 insertions(+) create mode 100644 .claude/memory/project_drawRoofByAttribute_internals.md create mode 100644 .claude/memory/project_qpolygon_utils_roof_creation.md create mode 100644 .claude/memory/project_skeleton_utils_ridge_roof.md diff --git a/.claude/memory/project_drawRoofByAttribute_internals.md b/.claude/memory/project_drawRoofByAttribute_internals.md new file mode 100644 index 00000000..a7f891df --- /dev/null +++ b/.claude/memory/project_drawRoofByAttribute_internals.md @@ -0,0 +1,396 @@ +--- +name: drawRoofByAttribute 내부 구조 (후반부 상세) +description: qpolygon-utils.js drawRoofByAttribute (1680-5455, 3777줄) 의 단계별 정밀 분석. 라인 분류→가선분(linesAnalysis) 누적→MAX_ITERATIONS 교점 처리→하단지붕 파생→최종 split. 수정 시 어느 stage 인지 식별 필수. +type: project +--- + +# drawRoofByAttribute 내부 구조 (후반부 상세) + +`src/util/qpolygon-utils.js:1680-5455` 의 정밀 분해. catch-all 함수라 모든 비표준 케이스(요세무네/맨사드/형이동/벽취합/팔작/반절처/케라바 혼합 등)가 여기 모인다. + +## 0. 데이터 흐름 한 장 요약 + +``` +wall.baseLines → [zero merge] → baseLines (mutated copy) + ↓ + type 분류: sheds / hipAndGables / eaves / jerkinHeads / gables + ↓ + [stage1 처리] 각 type 별로: + - innerLines.push(drawHip/Ridge/RoofLine ...) ← 즉시 그림 + - linesAnalysis.push({start, end, left, right, type, degree}) ← 가선분 누적 + ↓ + [stage2 MAX_ITERATIONS 루프] linesAnalysis 끼리 교점 → drawHip/Ridge + newAnalysis 분기 + ↓ + [stage3 가선분 잔여] 평행 라인 예외처리 + ↓ + [stage4 벽취합 잔여] 벽-벽 라인 처리 + ↓ + [stage5 하단지붕 파생] downRoofGable (A/B) + downRoofEaves + ↓ + [stage6 최종 split] roof.lines × innerLinesPoints 분할 → drawHip/RoofLine + ↓ + roof.innerLines = innerLines (덮어쓰기) +``` + +`innerLines` 와 `linesAnalysis` 는 **다른 컨테이너**: +- `innerLines`: 실제 캔버스에 그려진 QLine 객체들 (drawHip/Ridge/RoofLine 반환값) +- `linesAnalysis`: 가상 선분 메타데이터 (`{start, end, left, right, type, degree, gableId?, connectCnt?}`) — 아직 캔버스에 안 그려짐, 교점 계산용 + +`TYPES = { HIP, RIDGE, GABLE_LINE, NEW }` 는 linesAnalysis 내부 enum (canvas 의 `LINE_TYPE.SUBLINE` 과 별개). + +## 1. 전반부 (1680-1995) — 이미 파악된 영역 + +- **1680-1759 형이동 zero-merge**: `wall.baseLines` 중 `planeSize0 라인의 prev/next 처리, `addRoofLine1/2` 추가. `name='addRoofLine'`, `attributes.type=WALLLINE.ETC`. + +## 2. analyzeLine 헬퍼 (2000-2133) + +drawGableRoof 의 `analyzeEavesLine` 과 거의 동일하지만 모든 type 에 적용. **return**: +``` +{ startPoint, endPoint, length, angleDegree, normalizedAngle, + isHorizontal, isVertical, isDiagonal, + directionVector: {x,y}, + roofLine: 매칭된 roof.lines 의 한 라인, + roofVector: {x,y} (지붕 안쪽 방향, 단위벡터) } +``` + +핵심: +- tolerance 1° 로 H/V/Diagonal 분류 +- `roof.lines` 중 directionVector 가 같은 것을 `checkRoofLines` 로 거름 +- offset=0: 좌표 직접 매칭. 실패 시 `currentRoof = { roofLine: line }` 자기 자신 fallback (NPE 방지) +- offset>0: midPoint 에서 roofVector 방향으로 offset 만큼 가선 그어서 `edgesIntersection` 검색, intersect 길이 - offset < 0.1 으로 best match 선택 + +⚠️ `roofLine` 매칭 실패 시 `currentRoof.roofLine` 직접 접근 (2130) — fallback 로직이 있어서 안전하지만 추가 변경 시 NPE 주의. + +## 3. getRidgeDrivePoint 헬퍼 (2142-2270) + +마루(ridge)/이등분선 등이 **반대편 케라바(GABLE) 또는 이중처마(EAVES-EAVES) 코너**까지만 뻗도록 클램프하는 포인트를 계산. + +- prevLine/nextLine 이 모두 EAVES 가 아니면 `null` (호출처에서 fallback 처리) +- prevLine 이 EAVES 이고 그 prev 가 GABLE 이면 → GABLE 의 roofLine 과 testPoint 의 교점 (=GABLE 코너) +- prevLine 이 EAVES 이고 그 prev 도 EAVES 이면 → 두 EAVES 의 사잇각 vector 로 hip edge 그어서 testPoint 와의 교점 +- nextLine 쪽도 대칭 +- 둘 다 있으면 가까운 쪽 +- prevHasGable / nextHasGable 우선순위: `prevLength <= nextLength` 면 prev, 아니면 next 의 GABLE 사용 + +호출처: hipAndGables, ridgeEaves, jerkinHeads, MAX_ITERATIONS 루프(NEW 가선분 분기 시) + +## 4. 라인 분류 (2275-2300) + +``` +baseLines.forEach(switch on attributes.type): + SHED → sheds[] + HIPANDGABLE → hipAndGables[] (팔작지붕/이리모야) + EAVES → eaves[] (처마) + JERKINHEAD → jerkinHeads[] (반절처) + GABLE → gables[] (케라바/박공) + default → 무시 (WALL, ETC, DEFAULT 등) +``` + +`linesAnalysis = []` 초기화 (2303). + +## 5. 단계별 처리 (1~5) + +### 5-1. sheds.forEach (2306-2467) — 한쪽흐름 + +가드: 양옆이 둘 다 GABLE 이어야 함. 아니면 return. + +1. 맞은편 EAVES 라인 찾기 → shedDegree 산출 (없으면 4寸 default) +2. roofLine 양 끝점에서 지붕 안쪽으로의 vector 두 개 (`checkRoofVector1/2`) 그어서 반대편 roof.lines 와의 교점 (`intersectPoints1/2`) +3. **HIP 가선분 2개 push** (양 끝점 → 교점), `left=beforePrevIndex/nextIndex`, `right=prevIndex/afterNextIndex`, `type=TYPES.HIP`, `degree=shedDegree` + +⚠️ `intersectPoints1[0]` 직접 인덱스 접근 (2441) — sort 후 비어있으면 NPE. + +### 5-2. hipAndGables.forEach (2470-2699) — 팔작지붕 + +가드: 양옆이 둘 다 EAVES. 그리고 prevLine/nextLine 의 vector 가 같으면(같은 방향, U자) silent return. + +1. `lineWidth = min(currentLine.attributes.width, roofLength/2)` — 팔작지붕 두께 클램프 +2. prevRoofLine/nextRoofLine 와 currentRoofLine 의 사잇각 vector 로 prevHipPoint/nextHipPoint 산출 (지붕 안쪽 방향 정규화) +3. **HIP 2개 즉시 그림** (`innerLines.push(drawHipLine prev, next)`) +4. 양옆이 모두 EAVES (gable 이 짧을 때): midPoint 에서 양쪽 gable hip 두개 추가 + roofVector 반대 방향으로 10000 길이 가선 그어서 반대편 roof 교점 → `getRidgeDrivePoint` 로 클램프 → `oppLine.attributes.type` 별 offset 보정 (HIPANDGABLE=width, JERKINHEAD=width/2, WALL=0) → **RIDGE 가선분 push** +5. else (gable 길이 충분): `drawRoofLine(gablePoint)` 즉시 그림. 길이 ~0 이면 RIDGE 가선분만 push + +### 5-3. eaves 처리 (2701-3143) + +eaves 를 `planeSize` 오름차순 sort 후 두 그룹으로 분리: +- **`ridgeEaves`** (2704-2723): 양옆 모두 EAVES + prev/next vector 가 다른 방향 + checkWallPolygon.inPolygon(midPoint+nextVector) — 즉 처마-처마-처마 코너에서 안쪽 들여진 모양 +- **`hipedEaves`** (2725): 나머지 (= 옆이 GABLE 등) + +#### ridgeEaves.forEach (2731-3036) — 가장 복잡한 블록 + +상태 누적기: +- `proceedEaves[]`: `{prev, current, point: {x1,y1,x2,y2}, length}` — 처리된 처마 hip +- `proceedRidges[]`: `{prev, next, point}` — 처리된 ridge + +알고리즘: +1. pHipVector/nHipVector (양 옆과의 사잇각) 산출, inPolygon 으로 정규화 +2. `findRoofPoints(checkPoint)` 헬퍼: hipEdge 와 roof.lines 교점들을 forward/backward 로 분리 후 가까운 것 선택. forward+backward / backward 2개 / forward 2개 fallback +3. prevHipPoint/nextHipPoint 두 hip 의 `edgesIntersection` 으로 정점 보정 +4. **proceedRidges 루프**: 같은 base index 영향권의 ridge 가 prev/next hip 와 교차하면 `isRidgePrev/isRidgeNext` 플래그 → hip 좌표를 forward 로 되돌림(roof 정점까지) +5. **proceedEaves 중복 처리**: `alreadyPrev/Next` 가 있으면 길이가 짧은 쪽으로 교체 (또는 새 hip 좌표를 기존 것으로 덮음). isRidge 플래그 시 무조건 교체 +6. prevHipPoint.x2 와 nextHipPoint.x2 가 같으면(=정점 만남) **ridge 생성**: + - midPoint 에서 ridgeVector(nextLine 방향, inPolygon 정규화) 로 가선 + - roof.lines 와의 가까운 교점 → ridge 끝점 + - `getRidgeDrivePoint` 로 클램프 + - `isOverlapBefore/After` (beforePrev/afterNext 가 currentLine 과 동일 좌표 범위) 시 oppLine.type 별 offset 보정 분기: + - 양옆에 GABLE 있음(otherGable): drivePoint 우선 + - 없음 + oppLine=EAVES + 양옆에 EAVES: 길이 같으면 drivePoint, 다르면 offset + - 없음 + 그 외: offset 만 적용 +7. ridge 중복 체크 후 `proceedRidges.push` + +#### hipedEaves.forEach (3038-3143) + +가드: prevLine 이 EAVES 여야 함. 아니면 return. + +1. currentLine 와 prevLine 의 사잇각 vector → hipVector (inPolygon 정규화) +2. `currentLine.x1` 에서 hipVector × `analyze.length` 길이로 가선 → roof.lines 의 forward/backward 교점 +3. **HIP 가선분 push** (linesAnalysis), `degree=currentDegree` (currentLine.pitch) + +#### proceedEaves → linesAnalysis 변환 (3146-3219) + +``` +proceedEaves.forEach((eaves, index)): + - jointEaves = 같은 끝점(point.x2,y2) 공유하는 다른 eaves 검색 + - jointEaves.length === 1 + ridgeEaves.length > 1: + drawHipLine 즉시 그림 (innerLines.push) + - jointEaves.length === 2 (3-way 코너): + jointIndex 3개 모두 drawHipLine 즉시 + proceedRidges 에서 해당 코너 ridge 제거 + "denyVector" (반대쌍 없는 vector) 찾아서 roof 와의 교점 → HIP 가선분 push (uniqueLine 좌우) + - else: + HIP 가선분 push (linesAnalysis) +``` + +#### proceedRidges → linesAnalysis (3220-3229): 모두 RIDGE 가선분으로 push + +### 5-4. jerkinHeads.forEach (3232-3445) — 반절처 + +가드: 양옆 EAVES. 양옆 vector 같으면 silent return. + +1. `gableWidth = currentLine.attributes.width`, `lineLength = (roofLength - gableWidth) / 2` +2. roofLine 따라 4개 점 (`jerkinPoints`): 시작 → +lineLength → +gableWidth → +lineLength=끝 +3. `gableWidth >= roofLength`: **drawRoofLine** 즉시 (roofPoints 그대로) +4. 정상: **drawHipLine(gable1, prevDegree) + drawRoofLine(gable2) + drawHipLine(gable3, nextDegree)** 즉시 +5. 추가로 prevRoofLine/nextRoofLine 과의 사잇각 hipVector × gableWidth → prevHipEdge/nextHipEdge 교점 hipIntersection +6. hipIntersection 있으면 **drawHipLine prev/next 2개 즉시** + roofVector 반대 방향으로 10000 가선 → roof 교점 → drivePoint 클램프 → offset 보정 → **RIDGE 가선분 push** + +### 5-5. gables.forEach (3447-4239) — 케라바/박공 (가장 큰 블록) + +가드: 양옆 EAVES. 그리고 vector 다른 방향 + checkWallPolygon.inPolygon 통과해야 본 처리. + +#### isOverlap 분기 (3502-3562) + +`isOverlapBefore/After` (beforePrev 또는 afterNext 가 currentLine 좌표 범위 동일) + otherGable 없음 → **isOverlap=true**: +- mid-to-mid ridge: cMid + ridgeVector*cOffset → oMid - ridgeVector*oOffset, oppLine.type 별 추가 offset +- 중복 체크 후 **RIDGE 가선분 push** + +#### 일반 분기 (3563-4182) + +stdLine 결정: prevLine/nextLine 의 거리 (`prevDistance/nextDistance`) 비교, 둘 다 GABLE 이면 긴 쪽, 아니면 짧은 쪽. `stdFindOppVector` 도 같이 결정. + +`stdPoints` 좌표 보정: +- **stdPrevVector === stdNextVector** (U자 코너): isHorizontal/isVertical × stdVector × prev/next y/x 비교로 8가지 분기, GABLE 일 때만 offset 적용 +- 다른 방향: 양 끝점에 prev/nextLine offset 적용 + +`oppositeLine` 검색: stdLine 의 반대편 (stdFindOppVector 방향) 평행 라인들 (overlap 케이스 포함) + +`oppositeLine.forEach(opposite)` (3787-4083): +1. ridgePoint 산출: + - oppPrev/oppNextVector 같으면 (U자): 평행 offset 두번 적용 + - 다르면 (Y/L자): inPolygon 분기로 offset 부호 결정 +2. stdAnalyze 축에 맞춰 ridgePoint 의 한 축을 mid 좌표로 통일 +3. **oppLine 양옆 EAVES/GABLE 혼합 케이스 분기** (3835-3976): + - oppPrev xor oppNext 가 EAVES: stdLine 길이 >= oppLine 길이일 때 hipEdge 와 ridgeEdge 교점 → ridgePoint 시작점 보정 + - 짧은 stdLine + EAVES 짧은 stdPrev/Next: drivePoint 보정 +4. stdPrev/Next 가 EAVES: driveEdge 와 ridgeEdge 교점 → stdPoints 의 해당 끝점 좌표 갱신 +5. overlap 영역(stdMin/Max × ridgeMin/Max) 으로 ridgePoint 클램프, vector 정렬 보정 +6. `ridgePoints.push({point, left:stdLine, right:oppLine})` + +`ridgePoints.forEach(r)` (4085-4180): +- inPolygon1/2 체크, 밖이면 vector 내 가장 가까운 roof.line 으로 보정 +- roof boundary 위에 있는 점이 1개만이면 그 점이 startPoint +- **RIDGE 가선분 push** (중복 체크) + +#### 4185-4231 — 양옆 vector 같음 (반절마루 불가) 케이스 + +`prevLineVector === nextLineVector` (U자): roofLine 직각 방향으로 가선 → 반대편 roof.line 교점 (가장 먼 것) → 시작점은 roofLine 양 끝 중 교점에서 먼 쪽 → **GABLE_LINE 가선분 push** (`degree = prevLine.pitch`, `gableId=baseLines.findIndex(currentLine)`, `connectCnt=0`) + +## 6. MAX_ITERATIONS 루프 (4242-4608) — 가선분 교점 처리 + +`linesAnalysis` 가 비거나 1000회 도달까지 반복: + +### 6-1. intersections 수집 (4248-4297) + +각 currLine 마다: +- length1 무시 +- 다른 nextLine 과 `lineIntersection(seg-seg)` 시도 +- 가장 짧은 distance1 (+ 같은 GABLE_LINE pair gableId 같으면 skip) 의 partner 선택 +- distance1==minDistance 일 때 `isSameLine` (left/right 공유) 우선 + +### 6-2. 단일 교점 보정 (4302-4305) + +`intersectPoints` unique 가 1개고 intersections 가 ≥2 → 첫 두개를 서로 partner 로 강제. + +### 6-3. for-of 처리 (4310-4597) + +intersection 페어마다: +- mutual partner 아니거나 left/right 공통 안 되면 skip +- 각각 drawHip/Ridge 그림 (innerLines.push, alreadyPoints 중복 차단): + - `cLine.type === HIP` → drawHipLine (cLine.degree) + - `RIDGE` → drawRidgeLine + - `NEW` → diagonal 이면 hip, 아니면 ridge + - `GABLE_LINE` → degree>0 hip, 아니면 ridge + +#### 동일 교점에 다른 라인이 더 있으면 (otherIs) +- 관련 baseline 공유하는 라인은 추가 그리고 processed +- (GABLE_LINE 페어인 경우는 otherIs 처리 skip) + +#### GABLE_LINE 분기 (4425-4467) +- `gableLine.connectCnt++` +- uniqueLines 2개면: + - connectCnt<2: GABLE_LINE 가선분 새로 push (intersect → end) + - connectCnt>=2: HIP 더미 (start==end) push — "가선분 발생만 시키는" trigger 용 + +#### 일반 분기 (4468-4588) — NEW 가선분 생성 +1. uniqueLines 2개 → bisector 계산 (`isParallel` 면 `getBisectLines`(평행 시 길이 보정 100), 아니면 `getBisectBaseLines`) +2. bisector 방향으로 roof.lines 와 교점 → 가까운 것 +3. **이미 같은 교점 가진 미처리 라인 있으면 그것을 newAnalysis 로 교체** (재활용) +4. 아니면 NEW 가선분 push (`type=NEW`, `degree= isDiagonal ? cLine.degree : 0`) +5. drivePoint 로 ridge 길이 클램프 (NEW + horizontal/vertical 일 때만) + +`newAnalysis.length > 0` 이면 break (한 페어 처리 후 즉시 재계산) + +### 6-4. 루프 종료 후 (4599-4608) + +`linesAnalysis = newAnalysis ⊕ (linesAnalysis - processed)`. newAnalysis 비면 while 종료. + +## 7. 가선분 잔여 (4610-4696) + +남은 linesAnalysis 의 평행 페어 처리: +- isOpposite (반대 방향): start-start 사이 line, type 별 drawHip/Ridge +- 동일 방향: 4개 점 중 unique 2개로 line, isDiagonal 여부에 따라 drawHip/Ridge +- 처리된 라인은 `proceedAnalysis` 로 마킹 후 linesAnalysis 에서 제외 + +## 8. 벽취합 잔여 (4698-4732) + +linesAnalysis 에 남은 line 중: +- start/end 가 모두 roof.lines 위에 있으면: + - line.type=RIDGE + baseLines.length===4 + (start 또는 end 가 onLine): drawRidgeLine + - 그 외 (양쪽 다 onLine): degree===0 → drawRoofLine, 아니면 drawHipLine +- innerLines 에 같은 끝점이 3개 미만, 다른 끝점은 없을 때만 그림 (중복 차단) + +## 9. 하단지붕 분류 (4734-4778) + +baseLines 순회하며 3종류로 분류: + +- **downRoofGable type 'A'** (4750-4757): prevVector === nextVector (U자) + currentLine=EAVES + 양옆 중 하나라도 GABLE +- **downRoofGable type 'B'** (4759-4766): prevVector ≠ nextVector + checkWallPolygon.inPolygon(mid+nextVector) + 양옆 둘 다 GABLE +- **downRoofEaves** (4768-4777): prevVector === nextVector + currentLine=EAVES + 양옆 중 하나라도 EAVES + originPoint ≠ 현재 좌표 (=형이동된 라인) + +## 10. downRoofLines (4780-5158) + +### 10-1. type='A' (4782-4971) — U자 케라바 사이 처마 + +1. `gableLine` = prev 또는 next (currVector × prev/nextVector 8가지 분기) +2. `stdPoint` = currentLine 양 끝 (gableLine 쪽 그대로 + 반대쪽 -currVector × otherLine.offset) +3. `oppLines` = baseLines 중 stdLine 반대쪽 평행 라인들 +4. `innerRidge` = innerLines 중 RIDGE 라인 + currVector 와 같은 축 + stdPoint 범위 안 +5. `roofLinePoint` = stdPoint 에 currOffset 적용 (currVector.x===0 이면 ±y, 아니면 ∓x) +6. **drawRoofLine(roofLinePoint)** push +7. roofLinePoint 에서 oppFindVector 방향 가선 → innerRidge 와 교점 가장 가까운 것 → **drawHipLine + drawRoofLine** (intersect → 인접한 ridge 끝점) + +### 10-2. type='B' (4974-5157) — 안쪽으로 들어간 케라바 양쪽 + +1. `ridgeFindVector` = nextVector (mid 가 polygon 안일 때) 또는 prevVector +2. `oppLines` = innerLines RIDGE 중 ridgeFindVector 방향 + currentLine 범위 안 +3. `roofPoint` = currentLine 좌표에 -nextVector × currOffset 적용 +4. **drawRoofLine(roofPoint)** push +5. orthogonalStart/EndPoint = currentLine 양 끝에서 oppLines 의 가까운 끝점에 직각으로 투영 +6. orthogonalStartLine === orthogonalEndLine: **drawRoofLine(start→end)** 1개 +7. 다르면: **drawRoofLine 2개** (각 ridge 의 먼 끝점까지 연장) +8. 추가로 **drawHipLine 2개** (roofPoint 양 끝 → orthogonalPoint 양 끝) + +## 11. downRoofEaves.forEach (5160-5376) — 처마 형이동 흡수 라인 + +핵심 데이터: +- `flowDistance` = currentLine 과 originPoint 의 axis 거리 +- `isFlowInside` = inPolygon 으로 안/밖 결정 +- `upLine` (상단 지붕) = isFlowInside 면 otherLine, 아니면 currentLine +- `downLine` (하단 지붕) = 반대 +- `joinPoint` = upLine 과 downLine 이 만나는 모서리 + +상단 지붕 처리: +1. `upRoofPoint` = upRoofLine 끝점에 upRoofVector × addUpOffset(=flowDistance) 더한 좌표 (inPolygon 통과하는 방향) +2. `upHipStartPoint` = upRoofLine 의 다른 끝 +3. `upHipVector` = upLine + downLine 사잇각 (inPolygon 정규화) +4. `joinHipLine` = innerLines 중 joinPoint + (joinPoint+upHipVector) 둘 다 위에 있는 라인 (=기존에 그려진 추녀마루) + +하단 지붕 처리: +5. `downRoofPoint` = downRoofLine 끝점 + downRoofVector × addDownOffset(=upLine.offset), inPolygon 분기 +6. `intersectJoin` = upRoofEdge × joinHipEdge — 상단 지붕선과 추녀마루의 교점 → joinHipLine.set 으로 좌표 갱신 (mutated!) + +upSideLine 분기 (upLine 의 다른 인접 라인): +- upSideLine = EAVES (= 처마 끼고 추녀마루 분기 가능): + - innerLines 중 joinHipLine 외에 upRoofEdge 와 교점 + minDistance > EPSILON: **drawHipLine(connectPoint) + drawRoofLine(connectPoint→intersectJoin)** + - 없음: **drawRoofLine(upHipStartPoint→intersectJoin)** +- 아니면: **drawRoofLine(upHipStartPoint→intersectJoin)** + +각 그릴 때 `roof.adjustRoofLines.push({point, roofIdx: upRoofLine.idx})` — 회로/모듈 배치에서 보정 참조용. + +하단 지붕 추녀마루 (intersectDownJoin = downHipEdge × joinHipEdge): +- 있으면 **drawRoofLine(downRoofPoint) + drawHipLine(downHip1) + drawHipLine(downHip2 → joinEndPoint)** — 지붕선/추녀마루1/추녀마루2 트리오 + +## 12. 최종 split + finalize (5379-5454) + +```js +innerLines.push(...downRoofLines) // 하단 지붕 모두 합류 + +// roof.lines 각 라인을 innerLines 끝점들로 분할 +innerLinesPoints = unique(innerLines.{x1,y1,x2,y2}) +roof.lines.forEach((currentLine, idx) => { + if (innerLine 이 currentLine 을 완전 포함 또는 그 반대) skip // hasOverlapLine 가드 + splitPoint = innerLinesPoints 중 currentLine 위에 있고 끝점 아닌 것 + + if (splitPoint > 0): + // 첫 분할 = drawHipLine(prevDegree) + // 중간 = drawRoofLine + // 마지막 = splitPoint===1 이면 drawRoofLine, 아니면 drawHipLine(nextDegree) + else: + // 통째로 drawRoofLine +}) + +roof.innerLines = innerLines // ← 덮어쓰기 (drawGableRoof 의 push 와 다름!) +canvas 의 'check' 객체 제거 + renderAll +``` + +## 13. 수정 작업 시 어느 stage 인지 식별 가이드 + +| 증상 | 의심 stage | +|---|---| +| 한쪽흐름 추녀가 안 그려짐 | 5-1 sheds (양옆 GABLE 가드) | +| 팔작 모서리 잘못 그려짐 | 5-2 hipAndGables, prev/nextHipVector inPolygon 분기 | +| 처마 hip 길이가 비대칭 | 5-3 ridgeEaves proceedEaves alreadyPrev/Next 길이 비교 | +| 마루가 코너 직전에서 끊김/넘침 | getRidgeDrivePoint 또는 isOverlapBefore/After offset 분기 | +| 반절처 추녀마루 안 생김 | 5-4 jerkinHeads hipIntersection 또는 width >= roofLength 가드 | +| 케라바 ridge 가 비뚤어짐 | 5-5 gables stdLine 결정/oppositeLine 보정 | +| `connectCnt`/`gableId` 충돌 | 6-3 GABLE_LINE 분기 | +| 가짜 ridge 가 남음 | 7 가선분 잔여 또는 8 벽취합 잔여 | +| 형이동 처마 위로 추녀마루 누락 | 11 downRoofEaves joinHipLine 분기 | +| roof boundary 가 hip 으로 잘못 분류 | 12 최종 split splitPoint 0개 케이스 | +| 라인이 두 번 그려짐 | `alreadyPoints(innerLines, point)` 가드 누락 또는 splitPoint 의 hasOverlapLine 미작동 | + +## 14. 함정 모음 + +1. **`linesAnalysis` 와 `innerLines` 혼동 금지**. 가선분 메타와 그려진 QLine 은 동기화되지 않음. 어떤 코드는 즉시 그리고 어떤 코드는 가선분만 push 한다. +2. **`drawRoofLine` 의 name 이 SUBLINE.HIP** — 후처리(평균화) 에서 hip 로 분류됨. 이름 보고 type 구분 금지. +3. **TYPES.NEW = 가선분 분기 결과** — bisector 로 만들어진 가상 ridge/hip. degree 는 isDiagonal 일 때만 cLine.degree, 아니면 0 (=plane). +4. **GABLE_LINE.connectCnt** — 0/1/2 단계로 increment. 2 이상이면 더미 가선분만 발생, 본인은 더 이상 그리지 않음. +5. **roof.adjustRoofLines** — downRoofEaves 단계에서만 push. 회로/모듈 배치 단계에서 참조. roof.innerLines 와 별도. +6. **MAX_ITERATIONS=1000** — 무한루프 방지지만 도달하면 silent 미완성 (경고 없음). 케이스 추가 시 수렴 보장 확인 필수. +7. **`joinHipLine.set(...)`** (5278) — 기존 라인을 mutate 한다. 즉 5단계 이전에 만들어진 hip 의 좌표가 바뀐다. +8. **`checkWallPolygon`** (1761) — `baseLines.map(line => ({x:line.x1, y:line.y1}))` 로 만듦. zero-merge 후의 baseLines 시작점만 사용. 전체 라인 좌표가 아님. +9. **여러 곳에서 `intersectionsByRoof[0]`/`intersectPoints1[0]` 직접 접근** — 비어있을 가능성 가드 없음. 케이스 추가 시 NPE 위험. +10. **proceedRidges/proceedEaves 는 ridgeEaves 루프 전용 누적기** — 다른 단계(hipedEaves, jerkinHeads, gables)에서 보지 않음. 즉 케이스 간 충돌 가능. diff --git a/.claude/memory/project_qpolygon_utils_roof_creation.md b/.claude/memory/project_qpolygon_utils_roof_creation.md new file mode 100644 index 00000000..32c96721 --- /dev/null +++ b/.claude/memory/project_qpolygon_utils_roof_creation.md @@ -0,0 +1,171 @@ +--- +name: qpolygon-utils 지붕 생성 구조 +description: src/util/qpolygon-utils.js (6507줄) 의 지붕 그리기 3대 진입점(drawGableRoof / drawShedRoof / drawRoofByAttribute) + 하부 헬퍼 + 호출 경로 매핑. 지붕 생성 로직 수정 전 필독. +type: project +--- + +# qpolygon-utils 지붕 생성 구조 (수정 작업 전 reference) + +`src/util/qpolygon-utils.js` 는 6507줄·49개 top-level decl. **지붕 그리기는 이 파일이 단독 책임**(용마루/SK 케이스만 `skeleton-utils.js` 로 분기). + +## 1. 호출 진입점 (caller side) + +**유일한 호출자**: `QPolygon.drawHelpLine(settingModalFirstOptions, forceRedraw=false)` — `src/components/fabric/QPolygon.js:472` + +분기 (`drawHelpLine` 내부, `QPolygon.js:584-602`): + +``` +types = lines.map(l => l.attributes.type) + +if every type === EAVES: + if verifyMoveBoundary === 'crossed': drawSkeletonRidgeRoofFromBaseLines ← skeleton-utils + else: drawSkeletonRidgeRoof ← skeleton-utils +elif isGableRoof(types): drawGableRoof ← qpolygon-utils +elif isShedRoof(types, lines): drawShedRoof ← qpolygon-utils +else: drawRoofByAttribute ← qpolygon-utils (catch-all) +``` + +판정 함수 (모두 QPolygon.js inline): +- `isGableRoof(types)`: 홀짝 인덱스로 EAVES/GABLE(or JERKINHEAD) 교차 패턴 — 박공(A/B형) +- `isShedRoof(types, lines)`: SHED 포함 + SHED 라인들이 평행 + SHED 반대편이 모두 EAVES + 나머지가 GABLE/JERKINHEAD +- 그 외(혼합)는 모두 `drawRoofByAttribute` + +`drawHelpLine` 호출 지점 (재빌드 트리거): +- `hooks/useMode.js:1561` +- `hooks/roofcover/useMovementSetting.js:841,985` +- `hooks/roofcover/useRoofShapeSetting.js:502` +- `hooks/roofcover/usePropertiesSetting.js:176` +- `hooks/roofcover/useEavesGableEdit.js:276,280` +- `QPolygon.js:664` — `forceRedraw=true` wrapper (verifyMoveBoundary crossed 가드 우회용) + +`drawHelpLine` 직후(`QPolygon.js:606-647`)에 **대칭 hip 쌍 dimension 평균화** 후처리: ±5mm 묶어 평균 plane/actual 산정 → 0.5 단위 round → 라인 attribute + lengthText 동기화. (round-off 비대칭 보정용) + +## 2. qpolygon-utils.js 섹션 맵 + +| 라인 범위 | 카테고리 | 주요 함수 | +|---|---|---| +| 10-100 | 상수·기본 | `EPSILON=1e-10`, `defineQPolygon`, `calculateAngle`, `inward/outwardEdgeNormal`, `createPolygon` | +| 102-372 | 다각형 offset/clip | `edgesIntersection`, `appendArc`, `createOffsetEdge`, `createMargin/PaddingPolygon`, `clipOffsetToOriginal` | +| 373-456 | 자가교차/중복 | `cleanSelfIntersectingPolygon`, `offsetPolygon`(default export), `removeDuplicatePolygons` | +| 478-651 | 검증/유틸 | `doSegmentsIntersect`, `checkPolygonSelfIntersection`, `isValidPoints`, `isSamePoint`(<=2 tol) | +| **659-1585** | **drawGableRoof** | 박공 (A/B 패턴) | +| **1593-1672** | **drawShedRoof** | 한쪽흐름 | +| **1680-5455** | **drawRoofByAttribute** | 변별 설정 (catch-all, ~3777줄) | +| 5467-5493 | createArrow | (디버그/방향 화살표) | +| 5501-5702 | 벡터/교점 헬퍼 | `lineIntersection`, `clamp01`, `isParallel`, `getBisectLines/BaseLines`, `almostEqual`, `getDirection`, `alreadyPoints` | +| **5712-5841** | **drawing primitives** | `drawHipLine`, `drawRidgeLine`, `drawRoofLine` | +| 5848-5883 | 벡터 정규화 | `normalizeVector`, `getHalfAngleVector` | +| 5890-5936 | reDrawPolygon | polygon 재생성 | +| 6167-6262 | 사이즈/변환 | `arePointsEqual`(<=1 tol), `toGeoJSON`, `calcLinePlaneSize`(×10 round), `calcLineActualSize`(/cos), `calcLineActualSize2`(tan, str return), `calcLineActualSizeByLineLength`, `createLinesFromPolygon` | +| 6378-6507 | 정렬 | `getSortedOrthogonalPoints` (시계/반시계 방향 진행규칙 기반) | + +## 3. drawGableRoof — 박공 (659-1585) + +**입력 데이터**: +- `roof` = `canvas.objects.find(id===roofId)` (POLYGON_TYPE.ROOF) +- `wall` = `canvas.objects.find(name===POLYGON_TYPE.WALL && attributes.roofId===roofId)` +- `baseLines = wall.baseLines.filter(planeSize>0)` +- `eavesLines = baseLines.filter(type===WALLLINE.EAVES)` + +**내부 헬퍼** (모두 클로저): +- `analyzeEavesLine(line)` — 처마 분석. horizontal/vertical/diagonal 분류 (tolerance 1°), `roofVector` (안쪽 향한 단위벡터), 매칭되는 roofLine 검색 (offset=0 이면 wall 자기 자신을 fallback) +- `isOverlapLine(curr, check)` — 같은 방향 + 같은 좌표축 + range 겹침 +- `analyzeAllEavesLines(lines)` — `forwardLines`(directionVector x>0 또는 y>0) / `backwardLines` 분리, 같은 방향 겹침은 합병 +- `calculateIntersection(distance, angleA, angleB)` — `tan` 으로 마루 정점까지의 거리 분배 +- `findPloygonLineOverlap(polygon, linePoints)` — line 을 polygon 경계 안쪽으로 클립 +- `findInnerRidge(currentLine, roofVector, lines, tolerance=1)` — currentLine 안쪽에 있는 ridge 라인들을 거리 정렬해 ridge1/ridge2 매핑 +- `drawRoofPlane(currentLine)` — eaves 1개당 지붕면 1개 빌드 + +**전체 흐름**: +1. `analyzeAllEavesLines(eavesLines)` → forward/backward 페어 묶기 +2. `forwardLines.forEach`: backward 매칭 찾고 `calculateIntersection` 으로 각도별 정점 → `findPloygonLineOverlap` 으로 ridge 좌표 클립 → `drawRidgeLine` 호출 → `ridgeLines[]` 추가 +3. forward+backward 각각에 `drawRoofPlane` 호출 → 안쪽 ridge 와 inner roof line 매칭, `getSortedOrthogonalPoints` 정렬, 같은 방향이면 `drawRoofLine` 다른 방향이면 `drawHipLine` 호출 → `innerLines[]` 추가 +4. `roof.innerLines.push(...ridgeLines, ...innerLines)` +5. `name==='check'` 디버그 객체 제거 + `canvas.renderAll()` + +## 4. drawShedRoof — 한쪽흐름 (1593-1672) + +**가드**: `roof.lines` 에 대각선(`|dx|>1 && |dy|>1`)이 있으면 **silent return** (alert 주석처리됨). + +**처리**: +1. lines 를 `sheds` / `gables` / `eaves` 로 분리 (WALLLINE.SHED/GABLE/EAVES) +2. shed 의 `pitch` 로 `shedDegree` 산출 +3. `gables.forEach`: `actualSize = calcLineActualSize(coord, shedDegree)` 갱신 +4. shed × eaves 페어마다 `pitchSizeLine` (중간축 점선) 생성 — `attributes.type='pitchSizeLine'`, dashArray `[5,5]` +5. **최장 라인 1개만** canvas.add (다른 건 버림 — 의도된 동작인지 확인 필요) + +## 5. drawRoofByAttribute — 변별 설정 (1680-5455) + +**catch-all**. 박공/한쪽흐름이 아닌 모든 케이스 (요세무네, 맨사드, 형이동, 벽취합 혼합 등). + +**전반부 (1680-1759) 형이동 흡수 라인 병합**: +- `wall.baseLines` 중 `planeSize0` 인 currentLine 마다 prev/next 페어와 함께 roof.lines 의 대응 라인 찾고 +- `addRoofLine1`, `addRoofLine2` (corner extension) 을 `attributes.type=WALLLINE.ETC, name='addRoofLine'` 으로 추가 + +**후반부 (~5380-5455)**: +- `innerLines` 의 모든 끝점 수집 → `innerLinesPoints[]` +- `roof.lines` 각 라인이 inner 와 겹치면 skip, 아니면 `splitPoint` 로 분할 +- splitPoint 첫번째: `drawHipLine(prevDegree)`, 그 다음: `drawRoofLine`, 마지막 splitPoint: `drawHipLine(nextDegree)` +- 분할 없으면 `drawRoofLine` 1개 +- `roof.innerLines = innerLines` (덮어쓰기. drawGableRoof 의 push 와 다름!) +- `name==='check'` 제거 + render + +## 6. Drawing primitives (5712-5841) + +세 함수 모두 `new QLine(points, {...})` 만들어 `canvas.add(line) → bringToFront() → renderAll()` 후 라인 객체 반환. + +| 함수 | name | stroke | actualSize 계산 | 비고 | +|---|---|---|---|---| +| `drawHipLine(points, canvas, roof, textMode, prevDegree, currentDegree)` | `SUBLINE.HIP` | `#1083E3` | prev===current 이면 `calcLineActualSize(points, currentDegree)`, 다르면 `0` | 대각선이면 hypotenuse/base/tan 로 prev·current degree 재산출 (`|degreeX-degreeY|<1` 일 때만 적용) | +| `drawRidgeLine(points, canvas, roof, textMode)` | `SUBLINE.RIDGE` | `#1083E3` | `calcLinePlaneSize` (수평 거리 그대로) | actual==plane | +| `drawRoofLine(points, canvas, roof, textMode)` | **`SUBLINE.HIP`** (의도적 재사용) | `#1083E3` | `calcLinePlaneSize` | hip 와 같은 name 으로 분류됨 — 대칭 hip 평균화 후처리에 영향 | + +**공통 attributes**: `{ roofId, planeSize, actualSize }`. parentId=roof.id, fontSize=roof.fontSize, strokeWidth=2. + +## 7. 데이터 모델 (수정 작업 시 핵심) + +### Wall/Roof QPolygon +- `wall.baseLines`: `QLine[]`. 각 라인 `attributes`: + - `type`: `LINE_TYPE.WALLLINE.*` (EAVES, GABLE, SHED, WALL, JERKINHEAD, ETC, ...) + - `planeSize`, `actualSize`, `offset`, `pitch` + - `originPoint: { x1, y1, x2, y2 }` (offset 적용 전 원본) +- `roof.lines`: 지붕 외곽 boundary 라인들 (offset 적용 후 또는 wall 동일) +- `roof.innerLines`: 추녀/마루/지붕선 등 그려진 inner 라인 컬렉션 (drawHelpLine 시작 시 비워짐) + +### LINE_TYPE / POLYGON_TYPE (`src/common/common.js`) +- `LINE_TYPE.WALLLINE`: DEFAULT, EAVES, EAVE_HELP_LINE, GABLE(+LEFT/RIGHT), WALL, HIPANDGABLE, JERKINHEAD, SHED, ETC +- `LINE_TYPE.SUBLINE`: HIP(추녀), RIDGE(마루), GABLE, VERGE, ONESIDE_FLOW_RIDGE, YOSEMUNE, VALLEY, L_ABANDON_VALLEY, MANSARD, WALL_COLLECTION(+TYPE/FLOW/+L/R) +- `POLYGON_TYPE`: ROOF, WALL, TRESTLE, MODULE_SETUP_SURFACE, MODULE, OBJECT_SURFACE + +## 8. 수정 작업 시 주의사항 + +1. **drawHelpLine 진입 직후 innerLines 가 비워진다** (`QPolygon.js:489-505`). 즉 매번 from-scratch 그리기 — undo/redo 시도 마찬가지. +2. **drawGableRoof 는 push, drawRoofByAttribute 는 덮어쓰기** — `roof.innerLines`. 일관성 없음. +3. **drawShedRoof 의 silent return** — 대각선 케이스가 들어오면 아무것도 안 그린다. (이전엔 alert 였음) +4. **drawRoofLine 의 name=SUBLINE.HIP** — 후처리(대칭 hip 평균화)에서 hip 로 분류됨. 의도적이지만 함정 포인트. +5. **calcLinePlaneSize 는 ×10 round** — 모든 사이즈가 10배 정수로 저장됨 (mm 단위). UI 표시 시 /10. +6. **tolerance 들이 다 다름**: + - `EPSILON=1e-10` 수학 비교 + - 방향 분류 1° / 1px + - `arePointsEqual` 1px / `isSamePoint` 2px + - 후처리 hip 평균화 5mm + - SHOULDER_ABSORBED 임계 < 10 (별도 메모리 참조) +7. **wall.baseLines 의 zero-length 라인** = 형이동 흡수 결과. drawRoofByAttribute 만 합병 처리. 다른 두 함수는 `planeSize>0` 으로 필터만 함. +8. **`name==='check'` 객체** = 디버그 화살표/마커. 모든 draw 함수가 마지막에 제거. +9. **skeleton-utils.js (5227줄)** 는 별도 파일. 용마루(전체 EAVES) 케이스 전용. qpolygon-utils 와 데이터 모델 일부 공유하지만 로직 독립. +10. **현재 브랜치 `feature/redo-undo`** — 최근 커밋 3개가 모두 지붕 형상 undo/redo 관련. 수정 시 history 정합성 영향 고려 필요. + +## 9. 권장 작업 순서 + +지붕 생성 로직 수정 시: +1. 어떤 케이스(박공/한쪽흐름/변별) 가 영향 받는지 먼저 식별 (`isGableRoof`/`isShedRoof` 분기 추적) +2. 해당 함수의 입력 데이터 (wall.baseLines / roof.lines) 의 invariant 확인 — `planeSize>0` 필터링 여부, type 분포 +3. drawing primitive (drawHip/Ridge/RoofLine) 의 attributes 가 후처리(hip 평균화, lengthText 표시) 와 정합 유지하는지 확인 +4. drawHelpLine 호출 7곳 모두에서 동작 검증 (특히 `useMovementSetting`, `useRoofShapeSetting`) +5. undo/redo 의 경우 `roof.innerLines` 가 매 재호출마다 from-scratch 라는 점 유의 (history snapshot 은 wall.baseLines/roof.lines 만으로 결정적) diff --git a/.claude/memory/project_skeleton_utils_ridge_roof.md b/.claude/memory/project_skeleton_utils_ridge_roof.md new file mode 100644 index 00000000..e0064efc --- /dev/null +++ b/.claude/memory/project_skeleton_utils_ridge_roof.md @@ -0,0 +1,77 @@ +--- +name: skeleton-utils 용마루 지붕 두 경로 차이 +description: src/util/skeleton-utils.js 의 drawSkeletonRidgeRoof(일반) vs drawSkeletonRidgeRoofFromBaseLines(오버 전용) 차이 + skeletonBuilder 전처리 파이프라인 + SK_INPUT_USE_WALL_BASELINE_DIRECT 플래그. 용마루(전체 EAVES) 케이스 수정 전 reference. +type: project +--- + +# skeleton-utils 용마루 지붕 — 일반 vs 오버 두 경로 + +`src/util/skeleton-utils.js`. **용마루 지붕(라인 type 이 전부 EAVES)** 케이스 전용. straight-skeleton(`SkeletonBuilder.BuildFromGeoJSON`)으로 용마루/추녀 내부선 생성. 박공/한쪽흐름/변별은 `qpolygon-utils.js` 가 담당 (별도 메모리 참조: [[qpolygon-utils 지붕 생성 구조]]). + +## 1. 분기 (QPolygon.js:677-684) + +`drawHelpLine` 에서 `types.every(EAVES)` 일 때: + +``` +verdict = verifyMoveBoundary(this.id, this.canvas) +if verdict === 'crossed': drawSkeletonRidgeRoofFromBaseLines ← 오버(경계 넘음) 전용 +else: drawSkeletonRidgeRoof ← 일반 +``` + +`'crossed'` = 마루/벽 이동이 roof 경계를 넘어선 "오버" 상태. wall.baseLines 가 이미 오버된 좌표로 mutate 됨. + +## 2. drawSkeletonRidgeRoof (일반, :71-75) + +```js +export const drawSkeletonRidgeRoof = (roofId, canvas, textMode) => { + skeletonBuilder(roofId, canvas, textMode) // 얇은 래퍼 +} +``` + +실체는 `skeletonBuilder` (:922). 전처리 파이프라인: +1. roof.lines ↔ wall.lines index 매칭으로 `wallLine` id 주입 +2. `baseLines = wall.baseLines.filter(planeSize>0)`, `createOrderedBasePoints` 로 roof.points 순서 정렬 +3. **45° 대각 확장 계산** (:1011-1066): contactData(baseLine→roofLine 방향벡터) → `maxStep`(전체 꼭짓점 중 min(|dx|,|dy|) 의 max) → centroid 보정 → `changRoofLinePoints` (maxContactDistance=√2·maxStep 확장) +4. 마루이동(`moveFlowLine`/`moveUpDown` ≠ 0) 시 `safeMovedPointsWithFallback` 의 `movedPoints` 로 대체 (:1071-1121) +5. **⚠️ `SK_INPUT_USE_WALL_BASELINE_DIRECT = true` (:36, :1128)** → 위 3·4 결과를 **전부 무시**하고 `orderedBaseLinePoints`(=wall.baseLines corner) 직접 사용. + 평행오버 corner 흡수(DUP_EPS=1.0, COLLINEAR_EPS=50.0, 최대 5패스) +6. 좌표 유효성/중복점/일직선/**방향 뒤집힘(isOverDetected, cross product 부호 반전)** 검출 (:1175-1236) +7. `roof.skeletonPoints` 갱신 → SK 빌드 → `createInnerLinesFromSkeleton` + +즉 45° 확장 코드는 **계산만 하고 버려지는** 상태(점진 리팩토링 흔적). 실입력은 baseLines corner. + +## 3. drawSkeletonRidgeRoofFromBaseLines (오버 전용, :1363-1444) + +헤더 주석(:1350-1362) 의 존재 이유: 일반 경로의 45° 확장/roof 경계 클램핑이 오버 상태에선 **폴리곤 자가교차/경계 되돌림**을 일으켜 엉뚱한 SK 를 만든다 → 전처리 다 건너뛰고 baseLines 그대로 입력. + +처리: +1. `wall.baseLines` 순차 끝점(`{x:bl.x1, y:bl.y1}`) 수집, **확장·offset 없이 그대로**. `planeSize<1`(SHOULDER_ABSORBED) 스킵 +2. rawPoints < 3 이면 중단 +3. `roof.skeletonPoints = rawPoints` (오버 좌표 그대로) +4. `perturbPolygonPoints` → `toGeoJSON` → `SkeletonBuilder.BuildFromGeoJSON` +5. `createInnerLinesFromSkeleton(..., isOverDetected=true, rawPoints)` +6. `canvas.skeletonStates[roofId]=true`, `canvas.skeletonLines` 갱신 +7. **`canvas.skeleton.lastPoints` 는 기존 값 보존** (`__preservedLastPoints`) — 오버 상태에서 새 누적 기준점 안 만듦 + +**건드리지 않는 것**: `roof.points` / `roof.lines` / `outerLine` / `lengthText` (주석 :1358 명시). + +## 4. 핵심 차이 요약 + +| 항목 | drawSkeletonRidgeRoof (일반) | ...FromBaseLines (오버) | +|---|---|---| +| 트리거 | EAVES 전부 + verdict ≠ crossed | EAVES 전부 + verdict === crossed | +| 실체 | skeletonBuilder 래퍼 | 자체 SK 빌드 | +| SK 입력 | 전처리 파이프라인(but 플래그로 baseLine 직접) | baseLines 끝점 그대로 | +| roof.points/lines/outerLine/lengthText | 마루이동 시 갱신·검증 | **불변 (side-effect free)** | +| lastPoints | 새로 계산/누적 | **보존** | +| isOverDetected | cross 부호 반전 검출로 산출 | 무조건 true 마킹 | +| 공통 | createInnerLinesFromSkeleton, planeSize<1 스킵 | 동일 | + +**현 시점 진짜 차이**: 플래그(`SK_INPUT_USE_WALL_BASELINE_DIRECT=true`) 때문에 두 함수의 SK 입력 좌표는 거의 같아짐. 실질 차이는 **주변 상태(roof.points/lastPoints 등) 를 mutate 하느냐 보존하느냐**. 오버를 undo/redo 단축수단으로 쓸 때 history snapshot(wall.baseLines/roof.lines) 오염 없이 SK 만 재그리기 위함 (현재 `feature/redo-undo` 브랜치 맥락). + +## 5. 수정 시 주의 + +1. **45° 확장 코드(:1011-1066) 는 dead 입력** — `SK_INPUT_USE_WALL_BASELINE_DIRECT` 끄지 않는 한 결과 미사용. 만지기 전 플래그 확인. +2. `createInnerLinesFromSkeleton` (:1455) 은 두 경로 공유 — 여기 바꾸면 양쪽 영향. `processEavesEdge`(처마) + `processGableEdge`(케라바 후처리) 호출. +3. 오버 경로는 `lastPoints` 보존이 의도 — 무심코 갱신하면 누적 마루이동 기준 붕괴. +4. 예외 시 보호 정책(:1341-1346): SK/innerLines 유지, `skeletonStates[roofId]=false` 만, `skeletonLines` 비우지 않음 (이전 성공 상태 보존). +5. `verifyMoveBoundary` 는 QPolygon.drawHelpLine 과 skeletonBuilder(:1077) **두 곳에서** 호출 — 'crossed' 시 경고 로그만, 흐름은 진행. From 562bc79beb6719eed3f5bfbe6510298ea53b968e Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 2 Jun 2026 16:52:42 +0900 Subject: [PATCH 3/4] =?UTF-8?q?[=EB=B0=B0=EC=B9=98=EB=A9=B4=EC=B4=88?= =?UTF-8?q?=EA=B8=B0=EC=84=A4=EC=A0=95]=20=EC=A0=80=EC=9E=A5=EA=B0=92=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=EB=B0=98=EC=98=81=20=E2=80=94=20basicSett?= =?UTF-8?q?ing=20=EB=8D=AE=EC=96=B4=EC=93=B0=EA=B8=B0=20=EC=A0=9C=EA=B1=B0?= =?UTF-8?q?=20+=20inputMode=20=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../modal/placementShape/PlacementShapeSetting.jsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx index 8c442b22..58ffcea5 100644 --- a/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx +++ b/src/components/floor-plan/modal/placementShape/PlacementShapeSetting.jsx @@ -261,15 +261,19 @@ export default function PlacementShapeSetting({ id, pos = { x: 50, y: 180 }, pla if (addedRoofs.length > 0) { const raftCodeList = findCommonCode('203800') setRaftCodes(raftCodeList) + // [2026-06-02] addedRoofs[0]에서 받은 roofSizeSet을 그대로 사용 (basicSetting으로 덮어씌우지 않기) setCurrentRoof({ ...addedRoofs[0], planNo: currentCanvasPlan?.planNo || planNo, - roofSizeSet: String(basicSetting.roofSizeSet), - roofAngleSet: basicSetting.roofAngleSet, + roofSizeSet: String(addedRoofs[0].roofSizeSet), + roofAngleSet: addedRoofs[0].roofAngleSet, }) + // 입력모드도 함께 동기화 + setInputMode(addedRoofs[0].roofSizeSet) } else { /** 데이터 설정 확인 후 데이터가 없으면 기본 데이터 설정 */ setCurrentRoof({ ...DEFAULT_ROOF_SETTINGS }) + setInputMode(DEFAULT_ROOF_SETTINGS.roofSizeSet) } }, [addedRoofs]) From 8ef0810058e8a8fbe93f39b3344bcc65d148869a Mon Sep 17 00:00:00 2001 From: ysCha Date: Tue, 2 Jun 2026 16:58:54 +0900 Subject: [PATCH 4/4] =?UTF-8?q?[2294]=20=EC=BC=80=EB=9D=BC=EB=B0=94=20?= =?UTF-8?q?=EC=B6=9C=ED=8F=AD=20surgical=20=E2=80=94=20vExt=20cascade=20+?= =?UTF-8?q?=20corner=20snap=20+=20ExtRidge=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/roofcover/useEavesGableEdit.js | 11 ++- src/util/kerab-offset-surgical.js | 114 ++++++++++++++++++++++- 2 files changed, 117 insertions(+), 8 deletions(-) diff --git a/src/hooks/roofcover/useEavesGableEdit.js b/src/hooks/roofcover/useEavesGableEdit.js index 3faee18b..f926a8ef 100644 --- a/src/hooks/roofcover/useEavesGableEdit.js +++ b/src/hooks/roofcover/useEavesGableEdit.js @@ -1839,7 +1839,9 @@ export function useEavesGableEdit(id) { if (typeof il.setCoords === 'function') il.setCoords() const newSz = calcLinePlaneSize({ x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }) il.attributes = { ...il.attributes, planeSize: newSz, actualSize: newSz } - trimCascadePts.push(oldPt) + // [KERAB-EXTRIDGE-CONNECT 2026-06-01] cascade hide 시 ExtRidge 끝점을 vExt 끝점(=ip)로 연장. + // ExtRidge 와 vExt 끝점이 분리돼 split graph 의 sub-roof 외곽이 안 닫히는 문제 해소. + trimCascadePts.push({ pt: oldPt, newPt: { x: ip.x, y: ip.y } }) newTrimRecords.push({ line: il, end: trimEnd, @@ -1856,8 +1858,9 @@ export function useEavesGableEdit(id) { const cascadeHidden = new Set() let cascadeGuard = 0 while (trimCascadePts.length && cascadeGuard++ < 200) { - const pt = trimCascadePts.shift() - if (!pt) continue + const entry = trimCascadePts.shift() + if (!entry) continue + const pt = entry.pt || entry for (const il of roof.innerLines || []) { if (!il || il.visible === false) continue if (cascadeHidden.has(il)) continue @@ -1880,7 +1883,7 @@ export function useEavesGableEdit(id) { '[KERAB-VALLEY-EXT-TRIM] cascade hide ' + JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), ) - trimCascadePts.push(m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 }) + trimCascadePts.push({ pt: m1 ? { x: il.x2, y: il.y2 } : { x: il.x1, y: il.y1 } }) } } // [KERAB-VALLEY-EXT-TRIM 2026-05-29] revert 위해 target.__valleyExtTrims 에 누적. diff --git a/src/util/kerab-offset-surgical.js b/src/util/kerab-offset-surgical.js index ed437bea..5d9e3f59 100644 --- a/src/util/kerab-offset-surgical.js +++ b/src/util/kerab-offset-surgical.js @@ -129,7 +129,9 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { (il.lineName === 'kerabPatternRidge' || il.lineName === 'kerabPatternExtRidge' || il.lineName === 'kerabPatternHip' || - il.lineName === 'kerabPatternExtHip') + il.lineName === 'kerabPatternExtHip' || + il.lineName === 'kerabPatternValleyExt' || + il.lineName === 'kerabValleyOverlapLine') const oldSegDx = oldCorner2.x - oldCorner1.x const oldSegDy = oldCorner2.y - oldCorner1.y const oldSegLen2 = oldSegDx * oldSegDx + oldSegDy * oldSegDy || 1 @@ -150,18 +152,87 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { if (!il) continue // 케라바 패턴 라인: 옛 roofLine segment 위 끝점 → 새 segment 의 동일 t 점. - // 절삭/복원 흐름 skip. + // 한 끝만 segment 위(vExt 등 self-extension)일 때는 다른 끝도 같은 변위로 평행 이동 → + // 직각/형상 보존. 절삭/복원 흐름 skip. if (isKerabPatternLine(il)) { + const oldX1 = il.x1 + const oldY1 = il.y1 + const oldX2 = il.x2 + const oldY2 = il.y2 const np1 = mapToNewSeg({ x: il.x1, y: il.y1 }) - if (np1) il.set({ x1: np1.x, y1: np1.y }) const np2 = mapToNewSeg({ x: il.x2, y: il.y2 }) - if (np2) il.set({ x2: np2.x, y2: np2.y }) + if (np1 && np2) { + il.set({ x1: np1.x, y1: np1.y, x2: np2.x, y2: np2.y }) + } else if (np1 && !np2) { + const dx = np1.x - il.x1 + const dy = np1.y - il.y1 + il.set({ x1: np1.x, y1: np1.y, x2: il.x2 + dx, y2: il.y2 + dy }) + } else if (!np1 && np2) { + const dx = np2.x - il.x2 + const dy = np2.y - il.y2 + il.set({ x1: il.x1 + dx, y1: il.y1 + dy, x2: np2.x, y2: np2.y }) + } if (typeof il.setCoords === 'function') il.setCoords() if (np1 || np2) { logger.log( '[KERAB-PATTERN-CORNER-SNAP] mapped ' + JSON.stringify({ lineName: il.lineName, np1, np2, newPts: { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } }), ) + // [KERAB-PATTERN-CASCADE 2026-06-01] vExt 등 평행 이동 시 옛 끝점에 닿아있던 + // 다른 innerLine 끝점도 같은 변위로 평행 이동. RG-1 의 valley-trim 결과 끝점이 + // vExt 끝점과 분리되어 split graph 의 closed path 안 만들어지는 문제 해소. + // cascade: vExt 옛 segment 위에 끝점이 있는 다른 innerLine 도 같은 변위로 평행 이동. + // 끝점-끝점 일치 외에 segment 위 중간 점(예: kLine 끝점이 vExt segment 위)도 매칭. + // 케라바 패턴 라인 가드 제거 — 자체 매핑된 라인은 이미 새 좌표라 옛 segment 위 X → 자동 skip. + const dxVExt = il.x1 - oldX1 + const dyVExt = il.y1 - oldY1 + const pointOnOldSeg = (px, py) => { + const sdx = oldX2 - oldX1 + const sdy = oldY2 - oldY1 + const slen2 = sdx * sdx + sdy * sdy + if (slen2 < 1e-6) return Math.hypot(px - oldX1, py - oldY1) < 1.0 + const t = ((px - oldX1) * sdx + (py - oldY1) * sdy) / slen2 + if (t < -0.02 || t > 1.02) return false + const projX = oldX1 + t * sdx + const projY = oldY1 + t * sdy + return Math.hypot(px - projX, py - projY) < 1.0 + } + if (Math.hypot(dxVExt, dyVExt) > 0.01) { + // cascade 대상: roof.innerLines + canvas 의 kerabValleyOverlapLine (innerLines 미포함). + // overlap 보조선들은 vExt 의 끝점과 90도로 만나므로 vExt 이동에 같이 따라가야 정합. + const overlapInCanvas = (canvas.getObjects() || []).filter( + (o) => + o && + o.lineName === 'kerabValleyOverlapLine' && + (o.roofId === roof.id || o.attributes?.roofId === roof.id), + ) + const cascadeTargets = [...(roof.innerLines || []), ...overlapInCanvas] + for (const other of cascadeTargets) { + if (!other || other === il) continue + let moved = false + if (pointOnOldSeg(other.x1, other.y1)) { + other.set({ x1: other.x1 + dxVExt, y1: other.y1 + dyVExt }) + moved = true + } + if (pointOnOldSeg(other.x2, other.y2)) { + other.set({ x2: other.x2 + dxVExt, y2: other.y2 + dyVExt }) + moved = true + } + if (moved) { + if (typeof other.setCoords === 'function') other.setCoords() + logger.log( + '[KERAB-PATTERN-CASCADE] moved ' + + JSON.stringify({ + lineName: other.lineName, + name: other.name, + dx: dxVExt, + dy: dyVExt, + newPts: { x1: other.x1, y1: other.y1, x2: other.x2, y2: other.y2 }, + }), + ) + } + } + } } continue } @@ -169,6 +240,41 @@ export const applyKerabOffsetSurgical = (canvas, target, newOffset) => { const orig = il.__shrinkOrig || { x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 } const orig1 = { x: orig.x1, y: orig.y1 } const orig2 = { x: orig.x2, y: orig.y2 } + + // [KERAB-OFFSET-CORNER-SHORTCUT 2026-06-01] orig 끝점이 옛 corner 와 일치하면 새 corner 로 직접 snap. + // 일반 절삭 흐름은 il segment 와 새 roofLine 변의 lineLineIntersection 으로 ip 계산하는데, + // 끝점이 옛 corner 위에 있으면 ip 가 새 corner segment 밖으로 떨어져 segOk 가 reject → 절삭 실패. + // 그 결과 c1·c2 비대칭으로 kLine 대각선 변형됨. + const CORNER_SNAP_TOL_TRIM = 0.5 + let cornerSnapped = false + const trySnap = (epx, epy, which) => { + if (Math.hypot(epx - oldCorner1.x, epy - oldCorner1.y) < CORNER_SNAP_TOL_TRIM) { + if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } + if (which === 1) il.set({ x1: newCorner1.x, y1: newCorner1.y }) + else il.set({ x2: newCorner1.x, y2: newCorner1.y }) + cornerSnapped = true + return true + } + if (Math.hypot(epx - oldCorner2.x, epy - oldCorner2.y) < CORNER_SNAP_TOL_TRIM) { + if (!il.__shrinkOrig) il.__shrinkOrig = { x1: orig.x1, y1: orig.y1, x2: orig.x2, y2: orig.y2 } + if (which === 1) il.set({ x1: newCorner2.x, y1: newCorner2.y }) + else il.set({ x2: newCorner2.x, y2: newCorner2.y }) + cornerSnapped = true + return true + } + return false + } + trySnap(orig.x1, orig.y1, 1) + trySnap(orig.x2, orig.y2, 2) + if (cornerSnapped) { + if (typeof il.setCoords === 'function') il.setCoords() + logger.log( + '[KERAB-OFFSET-CORNER-SHORTCUT] snapped ' + + JSON.stringify({ lineName: il.lineName, x1: il.x1, y1: il.y1, x2: il.x2, y2: il.y2 }), + ) + continue + } + const d1 = (orig1.x - newAxisMid.x) * nx + (orig1.y - newAxisMid.y) * ny const d2 = (orig2.x - newAxisMid.x) * nx + (orig2.y - newAxisMid.y) * ny