# SK 라인 이동 인수인계서 > 최초 작성일: 2026-04-22 > 최근 갱신일: 2026-05-06 (옵션 B 분리 — SK 라인 전용으로 축소) > 대상: 후임 AI 엔지니어 또는 개발 관리자 > 작업 브랜치: `hotfix/dev_yscha` > **이 문서의 범위**: Straight Skeleton(SK) 라인 이동·계산 트랙만 다룹니다. > SK 외 작업은 다음 별도 문서를 보세요: > - `UI_MODAL_HANDOVER.md` — 모달/다이얼로그/계산기 토글 > - `API_STABILITY_HANDOVER.md` — API 안정화 / 인증 / 세션 > - `MODULE_CIRCUIT_HANDOVER.md` — 모듈/배치/회로/처마/티켓 시리즈 > - `HANDOVER_INDEX.md` — 진입점 인덱스 --- ## 0. 이 문서가 뭔가요? 이 프로젝트(Qcast Front)에서 가장 까다롭고 위험한 부분인 **"지붕 모양을 바꾸면 내부 선이 깨지는 문제"** 를 어떻게 풀어왔는지 정리한 인수인계 문서입니다. 이 문서를 읽고 나면: 1. 지붕 도면이 어떤 부품으로 구성돼 있는지 알 수 있고, 2. 어떤 버그가 있었고 어떻게 풀었는지 따라갈 수 있고, 3. 다음에 손대면 안 되는 코드와, 손대도 되는 코드를 구분할 수 있게 됩니다. ### 같이 봐야 하는 자료 - 메모리 인덱스: `~/.claude/projects/-Users-cha-Works-Projects-qcast3-qcast-front/memory/MEMORY.md` - 모든 정책 결정·임계값·"왜 이렇게 했는지" 가 항목별로 들어 있습니다. - **작업을 시작하기 전에 반드시 한 번 통독하세요.** --- ## 1. 도메인 1분 설명 — 지붕 도면은 어떻게 구성되나 이 앱은 **건물 지붕을 그리는 CAD 같은 도구**입니다. 사용자가 건물 외곽선을 그리면, 시스템이 자동으로 지붕(처마)과 그 안쪽 선(용마루 등)을 계산해 그려줍니다. ### 1.1 화면에 나오는 선 3종류 | 선 이름 | 색깔 | 누가 만드나? | 어디에 저장되나? | |---------|------|--------------|------------------| | **wallLine (외벽선)** | 초록색 | **사용자가 직접** 그리고 움직임 | `wall.lines`, `wall.baseLines` | | **roofLine (처마선)** | 검정색 | 외벽선에서 일정 거리(offset)만큼 바깥쪽으로 자동 계산됨 | `roof.points` | | **innerLine (내부선)** | 빨강 등 | 자동 계산 + 사용자가 추가한 용마루/추녀 | `roof.innerLines`, `canvas.skeletonLines` | > 핵심: 사용자가 만지는 건 **외벽선** 뿐이고, 나머지는 자동 계산입니다. 그래서 외벽선이 움직이면 처마와 내부선이 다 같이 다시 계산됩니다. ### 1.2 "Straight Skeleton" (줄여서 SK) 가 뭔가요? 지붕 안쪽의 용마루·추녀 같은 선을 자동으로 그어주는 **수학 알고리즘**입니다. 다각형(외벽선)을 입력으로 주면, 안쪽으로 똑같은 속도로 줄어드는 가상의 선들이 만나는 자취가 나옵니다. 이게 곧 용마루 라인이 됩니다. 이 프로젝트의 가장 큰 어려움은 **이 SK 알고리즘이 입력 값에 매우 예민**해서, 외벽선을 조금만 잘못 움직여도 결과가 무너진다는 점입니다. ### 1.3 외벽선 이동의 4가지 방향 사용자가 외벽선을 잡고 끌면, 시스템은 그걸 다음 4가지로 분류합니다: - 위치: `top` (위) / `bottom` (아래) / `left` (왼쪽) / `right` (오른쪽) - 방향: `in` (안쪽으로) / `out` (바깥쪽으로) 이 8개 조합을 다 처리해야 합니다. ### 1.4 "오버 이동" 이란? 외벽선을 너무 많이 바깥쪽으로 끌면, 안쪽 도형이 뒤집혀 버립니다(예: ㄴ자가 ㄱ자가 됨). 이걸 **"경계를 넘었다(crossed)"** 고 부릅니다. 이 프로젝트의 핵심 골칫거리가 바로 이겁니다: - 사용자가 실수로/일부러 너무 많이 끌었을 때 - 그동안 사용자가 힘들게 추가한 보조선들이 사라지면 안 됨 - 동시에 SK 자동 계산은 정상적으로 다시 동작해야 함 ### 1.5 4가지 판정 상태 이동이 들어오면 시스템이 다음 중 하나로 판정합니다: | 판정 | 의미 | |------|------| | `ok` | 안전한 이동, 정상 처리 | | `on-boundary` | 평행한 위치까지 딱 맞게 도달, 정상 처리 | | `crossed` | 경계를 넘었음(뒤집힘), **특별 경로**로 처리 | | `unknown` | 판정 불가, 일단 기존 흐름 진행 | 판정 함수: `verifyMoveBoundary()` (`src/util/skeleton-utils.js:510`) --- ## 2. 어떤 문제가 있었나 (수정 전 증상) 1. 외벽선을 우측 상단으로 강하게 끌면, 화면에 **있지도 않은 가짜 수직선**이 그려졌습니다. 2. 외벽선을 **두 번 연속 이동**하면, 첫 번째 이동 결과가 사라지고 도형이 무너졌습니다. 3. 경계를 넘는 이동을 하면 **사용자가 그동안 추가한 보조선들이 다 사라졌습니다**. — 이게 사용자가 가장 싫어하던 문제입니다. --- ## 3. 왜 이런 문제가 났나 (근본 원인) ### 3.1 좌표 기록이 짧게 잘려서 저장됐음 이동 후에 도형의 좌표를 `lastPoints` 라는 변수에 저장하는데, 이 저장 과정에서 **점 일부가 빠졌습니다**. 그래서 그다음 이동이 들어왔을 때 원본(`roof.points`) 과 **점 개수가 안 맞아서** 매칭이 깨졌습니다. ### 3.2 보정 함수가 항상 "원본"만 기준으로 비교했음 오버 이동을 감지하는 함수(`calcOverCorrectedPoints`) 가 1차 이동 결과를 무시하고 항상 원래 도형하고만 비교했습니다. 그래서 2차 이동에서 오버가 감지되면 **1차 이동도 같이 되돌려버리는** 부작용이 있었습니다. ### 3.3 화면을 먼저 지운 뒤 다시 그렸음 `drawHelpLine` 함수가 SK 재계산을 하기 **전에** 캔버스를 먼저 다 지웁니다. 그래서 SK 재계산 도중에 문제가 생겨도 **화면은 이미 텅 빈 상태**가 됩니다. > **(2026-05-06 현재)** 위 분석은 옛 정책 기준입니다. 지금은 `crossed` 일 때 `drawSkeletonRidgeRoofFromBaseLines` 라는 **오버 전용 그리기 함수**로 갈아타서, 화면이 비어 있어도 곧바로 다시 채워줍니다. 또 §6.1 의 클램프 정책 덕분에 애초에 `crossed` 자체가 잘 발생하지 않습니다. --- ## 4. 어떻게 풀었나 (2026-04-22 시점 작업) ### 4.1 새로 추가한 함수들 (기존 함수는 절대 안 건드림) | 함수 | 위치 | 한마디로 | |------|------|---------| | `buildRawMovedPoints(roofId, canvas)` | `skeleton-utils.js:385` | 점을 가공/축소 없이 **원본 그대로** 이동시킨 결과를 만들어 줌 | | `verifyMoveBoundary(roofId, canvas)` | `skeleton-utils.js:510` | "이 이동이 안전한가?" 를 4단계 (`ok`/`on-boundary`/`crossed`/`unknown`)로 판정해 줌 | | `calcOverCorrectedPointsSafe(...)` | `skeleton-utils.js:3222` | 기존 보정 함수가 "이전 이동 결과"를 잊어버리는 문제를 우회. 이전 결과를 합쳐서 가짜 원본을 만들어 넘김 | ### 4.2 안전장치(가드) 3곳 옛 정책에서는 "위험 감지 → 즉시 멈춤" 이었습니다. **현재(2026-05-06)는** 멈추는 대신 **다른 경로로 가게** 바뀌었습니다. | 어디 | 무슨 일을 하나 | |------|---------------| | `skeleton-utils.js:1071` (skeletonBuilder 안) | `verifyMoveBoundary` 결과 확인 → `crossed` 면 **경고만 찍고 그대로 진행** | | `skeleton-utils.js:1331` (catch 블록) | 에러가 나도 전체 상태를 리셋하지 않음. 해당 지붕만 false 처리 | | `QPolygon.js:467` (drawHelpLine 진입부) | 결과를 `__verdict` 변수에 기록만. 용마루 분기에서 `crossed` 면 **오버 전용 함수**로 분기 | ### 4.3 좌표 저장을 풀 길이로 보존 `skeletonBuilder` 끝부분(`skeleton-utils.js:1315`)에서: ```js const rawMovedFull = buildRawMovedPoints(roofId, canvas) if (rawMovedFull && rawMovedFull.length === (roof.points?.length ?? 0)) { canvas.skeleton.lastPoints = rawMovedFull // 점 개수 보존 } else { canvas.skeleton.lastPoints = roofLineContactPoints // 안 맞으면 기존 방식 } ``` ### 4.4 처음 검증 결과 (2026-04-22) - 1차 이동 820 → 정상. - 2차 이동 1912 (경계 넘는 이동) → 옛 정책에서는 화면이 그대로 유지됐음. - **현재는** 같은 입력에서 오버 전용 함수로 분기해서 SK 가 정상 재계산됩니다. 그리고 §6.1 의 클램프 정책이 들어와서 실서비스에서는 애초에 `crossed` 가 잘 안 납니다. --- ## 5. 아직 안 끝난 일 / 추적 중 ### 5.1 사용자가 추가한 라인을 따로 보관하기 (Step 5, **미완료**) **문제:** SK 가 다시 계산될 때마다 `roof.innerLines` 를 통째로 덮어씁니다. 사용자가 손으로 추가한 용마루/추녀 같은 라인은 SK 결과에 안 들어가니까 **사라질 위험**이 있습니다. **제안:** - `roof.userAddedLines` 라는 별도 보관함을 만들고 - 라인 추가 UI 에서 거기에 저장한 다음 - SK 결과 + 사용자 추가분을 합쳐서 `innerLines` 에 넣기 ```js roof.innerLines = [ ...createInnerLinesFromSkeleton(...), // 자동 계산 ...(roof.userAddedLines ?? []), // 사용자 추가 ] ``` ### 5.2 용마루 이동(`moveFlowLine`) 경로 판정 (**미완료**) 지금 `verifyMoveBoundary` 는 외벽 이동만 판정합니다. 용마루를 직접 잡고 끄는 경우는 항상 `unknown` 이 나옵니다. 이 경로에서도 경계 판정이 필요해지면 `buildRawMovedPoints` 를 확장해야 합니다. ### 5.3 빨강 처마 보조선 잔존 (**추적 중, 휴면**) 오버 이동을 클램프하면 빨강 색의 처마 보조선(`eaveHelpLine`) 이 화면에 남는 별건 이슈가 있습니다. 2026-04-27 이후 재개 예정 (메모리: `project_over_move_red_helper_deferred.md`). ### 5.4 v2.0 설계 (**휴면**) v2.0 으로 가는 새 설계 방향이 있었지만 **2026-05-06 자로 휴면**입니다. 사용자 명령 전까지는 v2 관련 메모리(`project_v2_*`) 를 참조하지 마세요. --- ## 6. 꼭 지켜야 할 9가지 원칙 이 프로젝트에서 이 원칙들을 어기면 거의 반드시 새로운 버그가 생깁니다. | # | 원칙 | 한 줄 설명 | |---|------|-----------| | 1 | **잘 되는 코드는 안 건드리기** | 버그 수정은 **최소 지점만**. 기존 함수 수정 금지. 필요하면 새 함수를 만드세요. | | 2 | **고치기 전에 흐름부터 확인하기** | 한 파일만 보고 추측해서 고치지 마세요. 호출 체인 전체를 따라가서 어디를 고칠지 정한 다음 작업하세요. | | 3 | **수정한 곳에는 태그를 남기기** | 수정한 줄 위에 `// [태그명 YYYY-MM-DD]` 주석을 달고, 작업 끝나면 git commit 메시지를 사용자에게 제시하세요 (커밋은 사용자가 직접 함). | | 4 | **오버 이동은 평행 위치까지만 (clamp)** | 사용자가 너무 많이 끌면 평행한 위치까지만 잘라줍니다. 정책은 `OVER_MOVE_POLICY` 상수로 바꿀 수 있어요. | | 5 | **화면 표시값과 내부 좌표는 따로** | 라벨이 흔들려 보이면 표시 단계에서 평균을 내는 식으로 보정하세요. 내부 좌표를 함부로 round 하면 곳곳에서 깨집니다. | | 6 | **좌표 비교 허용오차는 0.5** | UI/Big.js 계산 오차 때문에 0.1 로 비교하면 자주 매칭 실패합니다. | | 7 | **디버그 라벨은 별도 파일 만들지 말기** | `QPolygon.js` 안에 인라인으로 두고, `NEXT_PUBLIC_RUN_MODE === 'local'` 일 때만 표시되도록 가드. | | 8 | **V1 한 트랙만** | v2.0 은 휴면 중. 사용자가 직접 명령하기 전까지 v2 관련 코드/메모리 언급 금지. | | 9 | **moveLine + offset 이중 적용 방지** | offset 변경 후 baseLines 복원 → `drawHelpLine` 호출 시 `roof.moveFlowLine` / `roof.moveUpDown` 을 미리 설정하면 SK 빌더가 또 한 번 이동시켜 **이중 이동**이 발생합니다. 순서: `baseLines 복원 → drawHelpLine(moveLine 없이) → moveLine 속성 복원`. (메모리: `feedback_moveline_offset.md`) | --- ## 7. 전체 흐름 (사용자가 외벽선을 끌었을 때) ``` 사용자가 마우스로 외벽선을 끌어다 놓음 ↓ useMovementSetting.js __onMouseDown 핸들러 ↓ handleSave() (line 514) ├─ roof.moveUpDown / moveSelectLine / moveDirect / movePosition 세팅 │ = "어느 라인을 어느 방향으로 얼마나 옮기는가" 정보를 저장 │ ├─ ★ 안전장치: OVER_GUARD (line 895~940) │ 너무 많이 끌었으면 평행 직전까지 잘라줌 (clamp 정책) │ - 'clamp' (기본): 평행까지만 자르고 진행 │ - 'reject' : 아무것도 안 함 │ - 'passthrough' : 옛 동작 (위험) │ ├─ wall.baseLines 좌표를 실제로 변경 │ └─ roof.drawHelpLine() 호출 ↓ QPolygon.js drawHelpLine (line 467) ├─ ★ verifyMoveBoundary 로 판정 → __verdict 변수에 보관 ├─ 캔버스에서 내부 그림 다 지움 + innerLines = [] ├─ 지붕 종류별로 분기: │ - 용마루형(Eaves) + crossed → drawSkeletonRidgeRoofFromBaseLines (오버 전용) │ - 용마루형 그 외 → drawSkeletonRidgeRoof (일반) │ - 박공형(Gable) / 외쪽(Shed) / 패턴 → 각각 전용 함수 ↓ skeletonBuilder (skeleton-utils.js) ├─ ★ 안전장치: verifyMoveBoundary (line 1071) │ crossed 여도 멈추지 않고 경고만 찍음 ├─ safeMovedPointsWithFallback → movingLineFromSkeleton ├─ wall.baseLines 직접 사용 (V1 정책) ├─ SkeletonBuilder.BuildFromGeoJSON ← 진짜 SK 계산 ├─ createInnerLinesFromSkeleton (line 937 부근) │ 끝에 가짜 ridge 제거(cull) 2단계 추가됨 │ ⚠ 사용자 추가 라인 보존(Step 5) 미완료 └─ canvas.skeleton.lastPoints 저장 (line 1315, 풀 길이 보장) ``` --- ## 8. SK 라인 — 그동안 누적된 작업 > 모든 항목은 §6 의 원칙(특히 #1, #4)을 만족합니다. **기존 함수를 고치지 않고**, 진입부에 안전장치를 추가하거나 임계값을 조정하거나 끝부분에서 정리하는 식으로 처리했습니다. ### 8.1 오버 이동 정책 — 평행 직전까지 자동 클램프 - **장소**: `useMovementSetting.handleSave` 의 `OVER_GUARD` 블록 (line 895~940) - **상수**: 파일 맨 위 `OVER_MOVE_POLICY = 'clamp' | 'reject' | 'passthrough'` (기본 `clamp`) - **메모리**: `feedback_over_move_silent_skip.md` ### 8.2 평행 이동 시 SK 가 무너지는 문제 - **증상**: 외벽선을 평행 이동했을 때 점 개수 매칭이 깨졌습니다. - **수정**: 좌표 변경 경로 2곳을 **인덱스 직접 매핑** 방식으로 통일. - **메모리**: `project_parallel_collapse_fix.md` ### 8.3 정렬 기준을 `wallLines` 에서 `roofLines` 로 변경 - **이유**: wallLines 기준으로 정렬하니까 후처리에서 자꾸 어긋났습니다. - **수정**: 정렬 master 를 `roofLines` 로. **2026-05-06**: `ensureCounterClockwiseLines` 의 그래프 키도 `0.1 round` 로 통일 (커밋 `6f41e127`). - **메모리**: `project_index_master_roofLines.md` ### 8.4 SHOULDER_ABSORBED 임계값 통일 - 4개 경로에서 같은 임계값(< 10) 을 쓰도록 정리. - **메모리**: `project_shoulder_absorbed_thresholds.md` ### 8.5 가짜 ridge 제거 (Phantom ridge cull) - **증상**: SK 계산 오차로 길이 0인 가짜 ridge 가 생겼습니다. - **수정**: `createInnerLinesFromSkeleton` 끝부분에서 **2단계로 청소** (zero-length → dead-end). - **메모리**: `project_phantom_ridge_cull.md` ### 8.6 HIP 와 RIDGE 구분 임계값 - **증상**: 외벽 이동 오차로 ridge 가 hip 으로 잘못 분류됐습니다. - **수정**: `addRawLine` 의 대각 판정 임계 `0.1 → 5.0mm`. - **메모리**: `project_hip_ridge_threshold.md` ### 8.7 Hip pitch 기본값 - **증상**: hip 의 평면도/실측 토글이 동작 안 했습니다. - **수정**: `processEavesEdge:2915` 에서 `outerLine.pitch ?? roof.pitch ?? 0` 폴백. - **메모리**: `project_hip_pitch_fallback.md` ### 8.8 보조선(auxiliaryLine) 통합 단계 제외 - **이유**: 보조선이 통합에 끼면 코너 끝점이 사라져서 split 이 실패. - **수정**: `integrateExtensionLines` 에서 보조선 제외. - **메모리**: `project_aux_line_integrate_exclusion.md` ### 8.9 마루(ridge) 이동 보강 - **2차 이동에서 ridge 클릭 안 됨** → `useMovementSetting` 의 `[type]` effect 에 `ridge.selectable = true` fallback. - **반대측 wall 오선택** → 거리 필터 전에 x/y 겹침 사전필터 추가. - **메모리**: `project_ridge_2nd_move_fix.md`, `project_marudo_xoverlap_prefilter.md` ### 8.10 회귀 검증 5 시나리오 - 어떤 SK 수정이든 끝나면 5개 시나리오로 동작 확인 필수. - **메모리**: `project_skeleton_regression_checklist.md` --- ## 9. 메모리 인덱스 (SK 관련, 전부 `~/.claude/projects/-Users-cha-Works-Projects-qcast3-qcast-front/memory/`) ### 원칙(`feedback_*`) - `feedback_no_touch_working_code.md` — 잘 되는 코드 안 건드리기 - `feedback_full_analysis_before_action.md` — 흐름 먼저 확인 - `feedback_inline_change_tag.md` — 수정 태그 + 커밋 메시지 - `feedback_over_move_silent_skip.md` — 오버 이동 클램프 정책 - `feedback_moveline_offset.md` — moveLine + offset 이중 적용 방지 ★ - `feedback_display_vs_internal_values.md` — 표시값 vs 내부값 분리 - `feedback_same_point_tolerance.md` — 좌표 tolerance 0.5 - `feedback_debug_labels_local_only.md` — 디버그 라벨 인라인 - `feedback_v1_v2_dual_track.md` — V1 단일 트랙 ### SK / 이동 (`project_*`) - `project_parallel_collapse_fix.md` - `project_index_master_roofLines.md` - `project_shoulder_absorbed_thresholds.md` - `project_phantom_ridge_cull.md` - `project_hip_ridge_threshold.md` - `project_hip_pitch_fallback.md` - `project_aux_line_integrate_exclusion.md` - `project_ridge_2nd_move_fix.md` - `project_marudo_xoverlap_prefilter.md` - `project_skeleton_regression_checklist.md` - `project_over_move_red_helper_deferred.md` (빨강 보조선 잔존, 추적 중) ### 휴면 (참조 금지) - `project_v2_design_direction.md` - `project_v2_invariant_no_reset.md` --- ## 10. 인수받은 직후 추천 순서 1. **이 문서 §1~§3** 읽기 — 도메인 감 잡기. 2. **§6 의 원칙 9개** 머리에 박기. 3. **메모리 `MEMORY.md` 인덱스** 통독. 4. **§8.10 의 회귀 체크리스트 5 시나리오** 직접 실행해서 현재 동작 확인. 5. §5 의 미완료 항목 중 사용자 우선순위에 따라 착수. --- ## 마지막 한마디 이 프로젝트는 **수학적으로 예민한 SK 계산** 위에 사용자가 **수많은 수동 작업을 쌓아 올린** 구조입니다. 그래서 새 기능보다는 **회귀 방지가 항상 우선**입니다. "잘 되는 코드는 건들지 말고", "오버 이동은 평행까지만 잘라주고", "사용자가 추가한 라인은 절대 사라지지 않게" — 이 세 줄만 머리에 두고 가시면 됩니다. 끝 (2026-05-06 갱신, SK 전용으로 분리).