qcast-front/docs/plans/2026-06-01-skeleton-roofline-edit-design.md
Jaeyoung Lee 5c02d1d45c docs: roofline 편집 설계에 박공·한쪽흐름 4경로 분석 추가 — 한쪽흐름 degree 전략 주입(§8)
- §7 4경로(변별/박공/용마루/한쪽흐름) 외곽 지붕선 처리·편집 가능 여부 종합
- §8 한쪽흐름 경사변 plane≠actual 대응: splitRoofLinesToInnerLines degreeOf 콜백 주입
- §6 YAGNI 박공/한쪽흐름 항목을 범위 편입으로 갱신

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 14:49:56 +09:00

13 KiB

용마루(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) 경로 (drawSkeletonRidgeRoofskeletonBuildercreateInnerLinesFromSkeleton): 외곽 처마선을 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 으로 그리는데, 그 함수의 nameSUBLINE.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 경로 변경

  • createInnerLinesFromSkeletonisOuterEdge 숨김 처리(:1636-1643visible:false, :1624selectable: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) 경로는 §7 에서 범위로 편입 (2026-06-09).


7. 4경로 종합 (2026-06-09 추가)

QPolygon.drawHelpLine()(QPolygon.js:773-791) 의 4갈래 분기별 외곽 지붕선 처리 현황.

경로 함수 외곽선 생성 방식 name / selectable / visible 편집 가능? 작업량
변별 drawRoofByAttribute (qpolygon-utils.js:1731) roof.lines 직접 순회 → innerLine 끝점으로 split (:5432-5496) hip / true / true 레퍼런스 없음(원본 추출)
박공 drawGableRoof (:710) 지붕면 둘레 재구성drawRoofLine/drawHipLine (:1605-1617), roof.innerLines.push (:1630) hip / true / true 이미 됨 회귀 검증만
용마루 drawSkeletonRidgeRoofcreateInnerLinesFromSkeleton SK outer edge QLine 직접 생성 후 숨김 (skeleton-utils.js:1624,1638) eaves / false / false 중 (§3)
한쪽흐름 drawShedRoof (:1644) 외곽선 innerLine화 없음pitchSizeLine 만 add (:1701-1721) 대 (§8)

핵심 계약: drawRoofLine(:5863) 과 drawHipLine(:5763) 은 둘 다 name: LINE_TYPE.SUBLINE.HIP + selectable 미지정(QLine 기본 true). 즉 "이 함수로 그린 외곽선 = 편집 가능한 hip innerLine". 컨텍스트 메뉴는 useContextMenu.js:300-303 에서 name 기준으로 자동 연결되므로 별도 바인딩 불필요(§2-1 과 동일).

7-1. 박공 — 이미 편집 가능, 메커니즘만 다름

외곽선이 name='hip' innerLine 으로 이미 들어가 select+우클릭 동작. 단 변별처럼 roof.lines 를 split 하지 않고 지붕면 둘레 점을 직교 재정렬(getSortedOrthogonalPoints, :1571)해서 그린다. → "신규 구현" 이 아니라 이동/사이즈변경 시 roof.lines 정합성 회귀 검증 대상.

7-2. 한쪽흐름 — 미구현 + 경사변 특수성 (→ §8)

drawShedRoofroof.innerLines 를 건드리지 않아 초기화된 [] 로 남는다. 외곽선 편집을 위해 split 을 새로 붙여야 하나, 공용 split 을 그대로 쓰면 경사변의 실제길이가 틀린다 — §8.

8. 한쪽흐름 degree 전략 주입 설계 (2026-06-09 추가)

8-1. 문제: 평면길이 ≠ 실제길이

한쪽흐름은 흐름방향으로 기울어져 변마다 경사 적용 여부가 다르다.

변 종류 흐름방향 관계 경사 plane vs actual
처마(eaves) / 마루(shed) 수직 수평 plane = actual
박공(gable) 평행 경사면 plane ≠ actual (= planeSize / cos(shedDegree))

공용 split 이 외곽선을 그리는 drawRoofLine(:5863) 은 actualSize: calcLinePlaneSize(...)무조건 actual=plane 을 박는다(degree 인자 없음, 수평선 전용 가정). drawHipLine(:5763) 은 degree 를 받지만 split 이 넘기는 값은 인접 변의 attributes.pitch(:5443-5444)라 흐름 경사와 무관. 게다가 한쪽흐름은 분할점이 없어(내부 용마루·추녀 없음) 전 외곽변이 통째 drawRoofLine 으로 그려져 → 박공변도 actual=plane → 틀림.

도메인 규칙의 단서는 drawShedRoof 자체에 이미 있다(:1656-1669):

const shedDegree = getDegreeByChon(sheds[0].attributes.pitch)   // 흐름 경사각
gables.forEach((gable) =>
  gable.attributes.actualSize = calcLineActualSize({...gable}, shedDegree))  // 박공변만 보정

calcLineActualSize(points, deg) = planeSize / cos(deg) (:6270). 현재는 roof.lines attributes 에만 쓰고 innerLine 을 안 만들어 편집 불가.

8-2. 설계 (방향 1 채택): degree 결정 전략 주입

공용 split 의 기하(분할/연결) 로직은 재사용, "각 분할 라인의 degree" 만 경로별 전략으로 분리한다.

splitRoofLinesToInnerLines(roof, canvas, textMode, opts) 시그니처에 degree 전략 주입:

opts.degreeOf?: (parentLine) => number   // 부모 외곽변 → 경사각(도). 없으면 기존 인접 pitch 방식.
  • 변별 / 박공 / 용마루: degreeOf 미전달 → 기존 끝점·인접 pitch 기반 (동작 불변).
  • 한쪽흐름: degreeOf = (parentLine) => isGable(parentLine) ? shedDegree : 0
    • isGable: 부모 변의 attributes.type === LINE_TYPE.WALLLINE.GABLE (흐름방향 평행 변).

구간 그리기 분기:

  • 경사 구간(deg > 0): drawRoofLine 대신 drawHipLine(points, …, deg, deg). 박공변이 수평/수직이면 drawHipLine 의 대각선 분기(:5767)를 건너뛰고 calcLineActualSize(points, deg)plane/cos(deg) 계산 → drawShedRoof 기존 보정과 수식 일치.
  • 수평 구간(deg === 0): 기존대로 drawRoofLine.

8-3. 편집 환산까지 해결 — degree 를 라인 attributes 에 저장

표시용 actualSize 뿐 아니라 select → 사이즈 변경 시 입력값(plane↔actual) 환산에도 라인이 자기 degree 를 들고 있어야 한다. 변별/용마루 hip 은 attributes 에 경사 정보가 있어 환산되지만, 한쪽흐름 경사변은 shedDegree 를 라인 attributes.pitch(또는 degree)에 심지 않으면 편집 모달 환산이 깨진다. → §8-2 의 drawHipLine(…, deg, deg) 호출이 degree 를 라인 속성에 함께 저장하도록 하여 표시·편집을 동시에 해결.

8-4. 적용 순서 / 검증

  1. splitRoofLinesToInnerLines 추출 시 degreeOf 옵션 포함 (§3-1 + §8-2 병합).
  2. drawShedRoof 끝에서 splitRoofLinesToInnerLines(roof, canvas, textMode, { degreeOf }) 호출. 기존 pitchSizeLine 추가는 유지.
  3. 검증: 한쪽흐름 박공변 select 시 actual 표시값 = plane / cos(shedDegree), 사이즈변경 모달에서 plane↔actual 양방향 환산 정상, 처마/마루변은 plane=actual.
  4. 회귀: 변별·박공·용마루는 degreeOf 미전달이므로 동작 불변 확인.

8-5. 리스크

  • 분할점 부재: 한쪽흐름은 내부 보조선이 없어 외곽변이 1:1 통짜 라인으로 그려진다 → 부모 변 type 매칭이 단순(분할 시에도 조각은 부모 type 상속). split 의 끝점 정합성(isPointOnLineNew, tol 0.1) 자체는 분할점이 없으면 무의미하므로 else 통짜 경로(:5493)로 안전하게 떨어진다.
  • isGable 판정원: drawShedRoofgables 분류(:1653)와 동일 기준(attributes.type === GABLE)을 써야 보정 대상이 일치.