dev #876

Merged
ysCha merged 5 commits from dev into prd-deploy 2026-06-02 17:01:09 +09:00
3 changed files with 644 additions and 0 deletions
Showing only changes of commit 4644269852 - Show all commits

View File

@ -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``planeSize<EPSILON` 인덱스를 prev/next 페어로 모아 같은 vector 라인 끼리 합쳐 `baseLines` 재구성. zero-merge 후엔 `wall.baseLines` 의 중간 라인이 사라진 짧은 형태가 됨.
- **1761 `checkWallPolygon`**: `baseLines` 의 시작점만 모아 만든 임시 QPolygon. inPolygon 판정 전용.
- **1766-1995 벽취합 corner extension**: WALL+offset>0 라인의 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 마다:
- length<EPSILON 무시
- GABLE_LINE + connectCnt>1 무시
- 다른 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)에서 보지 않음. 즉 케이스 간 충돌 가능.

View File

@ -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``planeSize<EPSILON` 인덱스를 `zeroLines[]`
- 각 zero 라인의 prev/next 페어를 `mergedLines[]`
- 같은 방향 vector 의 라인들을 `indexList` 합쳐 `[startLine.x1, startLine.y1, endLine.x2, endLine.y2]` 로 한 라인으로 압축, `setLength` fire
- 결과 `baseLines` 재구성
**중반부 (1766-...) 벽취합+offset 보강**:
- `WALLLINE.WALL && offset>0` 인 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 만으로 결정적)

View File

@ -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' 시 경고 로그만, 흐름은 진행.