852 Commits
Author SHA1 Message Date
eomsangdonandClaude Opus 5.5 f7d0f077f7 fix(B05 배수유역): 349-0i 되돌리기로 저장 노선에 돌아오면 저장분 다시 받기 — 낡음 그대로 머묾 없앰
- 노선 초안이 없어지면(되돌리기 · 버림) 저장 배수유역을 열 때와 같은 길로 조용히 다시 받음(계산 아님) · 그 답의 낡음대로 흐림 풂 또는 그대로 · [적용] 따라감
- 떠 있던 초안용 live 요청 끊음 · 자동도 3 s 셈 대신 저장분 · 답 올 때까지 흐림 유지
- 패널 받기 줄 — loadSaved 를 요청 함수로(열 때 · reload · 되돌리기가 같은 길) · 700줄 맞춤
- test_349_drainage_live_flow 되돌리기 장면(자동 · 수동 요청 중 · 저장분 낡음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 15:21:01 +09:00
eomsangdonandClaude Opus 5.5 658331b04f fix(유토곡선): 349-0f 노선 초안 동안 「다시 계산 중…」 머묾 — 범위 밖 측점도 계획고가 바뀌면 다시 셈 · 그 차례 cross 뒤 풂
- 원인: cross 단계가 바뀐 범위(노선 = 앞 초안 대비 처음 바뀐 접선점부터) 밖 측점을 앞 결과 그대로 씀 · 첫 계획선은 저장 노선 대비 바뀐 구간 전체를 지반에 맞추므로 범위 앞 측점 계획고도 움직임 → 낡은 측점이 남고 초안 동안은 낡음 하나만 있어도 「다시 계산 중」
- logic/B06_Section_Cross_Stage: 범위 밖 재사용 = 같은 자리 · 같은 계획고(0.1 mm)일 때만 · 그 밖은 지문 기억이 맡음
- Profile_Preview scheduleIfStale(key, draft): 노선 초안 동안은 묶음 그 차례 cross(건너뛰면 뒤 단계)까지만 기다림 · 끝나면 남은 낡음 = 곡선 + 그 측점(342-1) · cross 가 끝내 안 와도 3 s 뒤 풀고 다시 그림
- design_profile 1줄(초안 여부를 넘김 · 700 그대로)
- 시험 test_349_0f_draft_stale 8(가짜 · 검증_sub7 지형 — 2번째 꺾임점 서쪽 20 m + 25+0 부근 꺾임점 북동 10 m 뒤 낡음 0 · 고치기 전 코드는 검증_sub7 에서 140 m~ 낡음) · test_317 본문 검사 새 꼴

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 15:15:21 +09:00
eomsangdon 4a750cdb2b Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-16 15:03:10 +09:00
eomsangdonandClaude Opus 5.5 401c53251b feat(계산묶음): 349-0d 노선을 놓으면 관 · 구조물 새 측점을 가게에 바로 — 목록 · 알약 · 횡단 보기
- 사용자 노선 조작 뒤 follow 가 좌표로 다시 매긴 관 · 구조물 = 가게 초안(pipes · structures) · 놓은 틱 안에 써 되돌리기 한 걸음 · 묶음 다시 돎 0(가게 메아리는 입력으로 안 받음)
- 초안을 버리면(되돌리기 · 버림) 초안 전 가게 값으로 · 열기만 하면 안 씀(349-0c)
- 새 노선에서 20 m 넘게 먼 구조물 = 알림 한 줄(구조물 N건이 새 노선에서 20 m 넘게 멀어짐)
- 종단 보기 왼쪽 목록 · 구조물 알약 · 측점선 = 가게 측점(배수유역도 관 알림을 가게 관 측점으로 · 관 id 옮김) · 따라간 측점은 계획고 편집 안 옮김(follow 몫)
- 시험 test_349_0d_follow_store 5건(검증_sub7 노선 · 관 · 옛 코드 2 실패 재현)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 15:01:28 +09:00
eomsangdonandClaude Opus 5.5 9980e8fd4c fix(B05 배수유역): 349-0e 자동은 흐림 없이 · 낡은 동안 제안 [적용] 끔
- 자동 모드 = 칸 흐림 · [업데이트] 없음 · 계산 중엔 구석 「계산 중」만 · 답 오면 조용히 바꿈(수동 그대로)
- 낡은 동안 제안 [적용] 끔(두 모드) · title 「노선이 바뀌어 제안이 낡음 — 새 계산 뒤 적용」 · 새 답이 오면 다시 켬
- test_349_drainage_live_flow 고쳐 씀(자동 흐림 없음 · 낡은 [적용] 막음)
- PLAN.md 349-0e

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 14:51:28 +09:00
eomsangdon 85dc1dfbb7 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-16 14:18:51 +09:00
eomsangdonandClaude Opus 5.5 00c86f4ed9 fix(계산묶음): 349 조작 하나 = 묶음 한 번 · 열기만 하면 저장 정본 그대로
- 고리 끊음: 묶음 측점 → 상세 갈아끼움 → 종단도 render · 비정규 측점 · 유토곡선 설정이 묶음을 처음부터 다시 부르던 것 — 다시 시작은 저장 정본(새로 받은 상세 · 새 노선)이 바뀔 때만 · 횡단 입력도 정본 상세에서만 모음
- 열 때(start 차례)는 측점 · 종단을 가게에 안 냄 — 상세 갈아끼움 없음 · 유토곡선 = 저장값
- 저장 노선(열 때 · 초안 버림)은 관 · 구조물 누가거리 · 계획고를 저장값 그대로(다시 투영 안 함 · 되돌리기 뒤 계획고 한 번 더 셈 없음)
- 노선 초안 동안 종단도 render · 비정규 측점이 초안 그림(기준 · 미리보기 가게)을 덮지 않음
- 시험 test_349_pipeline_once 4건(검증_sub7 실제 노선 · 옛 코드에서 4건 실패 재현)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 14:17:39 +09:00
eomsangdon 5186e79fa2 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-16 14:15:23 +09:00
eomsangdonandClaude Opus 5.5 44ffb99559 fix(B05 배수유역): 349 수동 [업데이트] 답 머묾 · 저장 뒤 낡음 — 답 받기 = 차례 + 입력 지문
- 묶음 drainage 단계 입력 지문(노선 초안 rev 뺌 · 관 id · 자리 cm) — 같은 노선 · 관으로 다시 돈 묶음은 흘려보냄(떠 있는 요청 안 끊음 · 다시 안 흐림)
- 차례 번호는 요청마다 하나 · 답은 차례 + 지문이 지금과 같을 때만 받음
- 서버 답이 「낡음」(노선 [저장] 뒤 등) = 수동은 흐림 + [업데이트] · 자동은 3 s 뒤 조용히(markStale)
- 시험 test_349_drainage_live_flow(가짜 DOM · 시계 · fetch 로 화면 파일 실행 — 수동 · 자동 · 제안 [적용]) · 347-2 헬퍼 새 받기 규칙
- PLAN.md 349-0b

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 14:14:32 +09:00
eomsangdonandClaude Opus 5.5 de014dac95 fix(B05 배수유역): 349 [업데이트] 단추 흐린 칸 가운데 — 머리 줄 단추의 오른쪽 붙임(margin-left auto) 풂
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 14:14:31 +09:00
eomsangdon daaf007501 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-16 14:11:18 +09:00
eomsangdonandClaude Opus 5.5 a7d006c460 fix(B05 되돌리기): 349 열기만 해도 걸음 생김 고침 — 세운 뒤 첫 손길(누름 · 키 · 칸 바꿈) 전 찍기는 첫 사진 다시 뜨기만 · 열 때 초안(표준 횡단면 · 첫 계산 · 되살린 노선 초안)은 열 때 모습 · 조작 한 번 = 걸음 하나
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 14:10:36 +09:00
eomsangdon 5a4e07f25b Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 14:10:12 +09:00
eomsangdonandClaude Opus 5.5 337edb3f20 fix(B05 노선편집): 349 테마 바꿈 뒤 평면 캔버스 다시 그림 — 밝음 ↔ 어두움(단추 · 시스템) 알림에 다음 프레임 다시 그리기 · 바탕 · 등고선 · 노선 · 발자국 색 토큰 다시 읽음
- _RouteEdit_Render.watchTheme(data-theme · prefers-color-scheme · 공용 색 캐시가 먼저 비워지게 다음 프레임) · 편집기가 붙이고 떠날 때 뗌
- 시험: test_349_routeedit_theme_redraw 3 · 관련 37 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 14:09:13 +09:00
eomsangdonandClaude Opus 5.5 14348279fa fix(노선추천): 349 노선 추천 창 옛 글 — 미리 보기 줄 = [적용]하면 바로 저장 · 새 초기값 · 종단 반영 설명 = [적용] 뒤 본 설계 값과 차이 · [적용]하면 이 종단이 첫 계획선(편집 [확인] · 아래 띠 글 지움)
- 다른 옛 글: 노선 편집 상태 줄 「곡선 기준 R — [확인] 때 반영」 → [저장] 때 · 분석 지형 풍선 「편집 창 분석」 → 노선 편집 분석
- 시험: test_349_old_wording 3 · 관련 77 통과(7 건너뜀) · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 14:08:22 +09:00
eomsangdonandClaude Opus 5.5 d36137f850 fix(초기화): 349 [초기화] 확인 글 — 초기값 = 파일 올린 뒤 자동설계 또는 마지막으로 [적용]한 추천 노선 · 다시 셈 아님(저장해 둔 초기값 그대로) · 끝 알림 = 초기값으로 되돌렸습니다
- 「마지막으로 확정한 노선 · 재계산에 시간」 글 지움 · 영문도 같이 · design_reset 머리 글도 같은 뜻

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 14:08:21 +09:00
eomsangdonandClaude Opus 5.5 031b5be1d4 feat(B05 노선편집): 349-0 처음 그림 · [맞춤] = 등고 영역 맞춤 · 뜬 종단 판은 덮이기만 — 판이 없다고 보고 칸 전체 가운데에 등고선 범위 ∪ 노선을 85% 몫(둘레 여유) · 판 끌기 · 접기에 평면 자리 · 글판 · 잰 값 창 · 곡선 바 그대로
- 새 logic/B05_Profile_RouteEdit_Fit(unionArea · fitAreaToView) — 옛 _Visible 자리
- _RouteEdit_Cover 지움 · View · CurveBar · CSS 의 덮인 높이 따라가기 걷음(345-5 덧 · 덧 2)
- 글판 · 도움말 접기(덧 3)는 그대로
- 시험: test_345_5b_plan_visible_area 3 고쳐 씀 · test_345_5c 자리 줄 고침 · 관련 86 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 13:55:35 +09:00
eomsangdonandClaude Opus 5.5 e5cd5ef813 chore(요구): 234 — B05 평면 판이 올라와도 다시 맞추지 않음 · 처음 그림 = 판 없다고 보고 등고 영역 알맞게 · 판은 덮이기만 · 아티팩트 보고 뒤 깃 싱크 · 다음 작업
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-16 13:49:59 +09:00
eomsangdon 0eb5fffcb9 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-16 13:46:07 +09:00
eomsangdonandClaude Opus 5.5 281c71b55b refactor(logic): 348-1 순수 계산 TS 49 파일 <페이지>/logic/ 로 옮김 — 이름 앞머리 그대로 · 동작 고침 없음
- 옮김: B04 2 · B05 23 · B06 11 · A00_Common/design 13 (9,299 줄) · 이미 logic/ 에 있던 16 파일 그대로
- 안 옮김: 묶음 입구(package.json build:*) · 파이썬 · 순수지만 계산 아닌 8 (보기 결선 · 콜백 · API 타입 · 갈아끼움 알림판)
- import 266 줄(142 파일) · 시험 · 주석 · 짝 표시 문자열 경로 고침 · B05 · B06 CLAUDE.md 짝 줄 logic/
- 시험 도우미 새 경로: 가짜 require 열쇠 · B05()/B06() 부름 · 형제 import 자리 · tsc 묶어 돌리는 시험 · 정규식 · rglob
- design_view_cross 줄 한도 시험 685 → 688 (긴 import 경로 줄바꿈 3줄)
- 확인: typecheck 0 · 서버 묶음 넷(corridor · server-calc · b07-extent · b07-note) 다시 지어 코드 같음(//#region 경로 주석만 다름) · 관련 시험 270 파일 — 남은 실패 19 는 옮기기 전과 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 13:44:50 +09:00
eomsangdonandClaude Opus 5.5 b8b4421ff0 fix(349): 횡단 상세 안쪽 호출 길 복구 — GET 처리기만 ETag · 조립 함수는 request 없이
343-2 ETag 가 get_section_detail 에 request 인자를 더해 [저장] 뒤 서버 재계산 · 3D 길 모형 서버
짓기 · B09 인쇄 도면 · B07 덧그림 범위가 TypeError 로 끊기던 흠. 처리기 = get_section_detail_route
(ETag · 304) · get_section_detail = 조립만(안쪽 호출 넷 그대로). 진짜 처리 길 시험 test_349.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 13:21:55 +09:00
eomsangdon 109d5e9a8f Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-16 13:08:22 +09:00
eomsangdonandClaude Opus 5.5 835d8ce828 feat(종단알약): 346-2 덧 3 노선 초안 동안 구조물 · 관 알약 = 묶음 follow 뒤 자리
- 새 logic/B05_Profile_Structure_Follow followedMarks: 묶음 입력(follow 뒤) 관 · 구조물의 새 누가거리를 알약 목록에 얹음 · 구조물 id 짝 · 구간형 시작 · 끝도
- 관 알약 id(세션 id) ≠ 묶음 관 id(서버 고정 id) — 누가거리 차례 짝 · 관 수가 안 맞으면 관은 안 옮김
- Structures_Marks: 노선 초안이 있으면(지금 프로젝트 묶음) 따라간 목록으로 알약 · 띠 · 툴팁 · 초안 버리면 저장 자리
- 시험 test_346_2_draft_cross_pills 5(횡단 카드 = 새 노선 상세 · 숨은 동안 안 그림 · 초안 버림 = 저장 노선 · 알약 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 13:07:30 +09:00
eomsangdonandClaude Opus 5.5 ec28d9d451 feat(횡단보기): 346-2 덧 3 노선 초안 동안 횡단 카드 = 묶음 새 노선 상세
- Station_Swap shownDetail: 노선 초안 동안 카드가 그릴 상세 = pipelineDetail(cross 단계 결과) · 없으면 저장 노선 캐시
- followSwaps 가 묶음 profile · cross 단계 뒤 새 상세를 들음 — 보일 때만 다시 그림(보이는 카드 먼저 길 그대로) · 숨었으면 다시 보일 때
- 묶음은 종단 보기가 세움 — 차례 번호(가게 generation)가 날 때 붙음 · 초안 버리면 상세 null = 저장 노선 카드
- design_view_cross 2줄(가져오기 · 그리기 상세) · 685 줄 그대로 · 저장 · 재계산 길은 저장 노선 상세 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 13:07:20 +09:00
eomsangdon 422d0962a4 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 13:04:15 +09:00
eomsangdonandClaude Opus 5.5 abe6168f3f feat(B05 노선편집): 345-5 덧 3 상태 · 범례 · 도움말 글판 접기 — 상태 · 범례 글판 작게(두 줄 · 폭 460 px 안 · 반투명 토큰 바탕) · ▾ 접으면 한 줄 「계획노선 N m」 · 도움말 기본 접힘 · [?] 펼침 · 접힘 기억 페이지 동안
- _RouteEdit_Chrome: 접기 단추 둘 · 모듈 값 기억 · 상태 글 바뀌면 요약 글 다시 씀(MutationObserver)
- Style_RouteEdit: 글판 격자(상태 두 줄 자름 · 범례 두 줄 · ▾) · 접기 단추만 클릭 받음
- 시험: test_345_5c_info_hint_collapse 4 · 관련 62 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 13:03:26 +09:00
eomsangdon 1a0cbdad1d Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-16 13:00:45 +09:00
eomsangdonandClaude Opus 5.5 9adc35473e fix(종단보기): 346-3 덧 계획고 초안 이음매 — 노선 저장 뒤 노선 초안 칸(DRAFT_ROUTE_ID 0 · sub7) 계획고를 새 노선으로 · 없으면 옛 노선 칸 · 두 칸 다 비움
- design_save 의 DRAFT_ROUTE_ID = design_pipeline_page 값과 같음(합친 뒤 시험이 대조)
- 시험: test_design_store_save 덧 하나 · 관련 52 통과 · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 13:00:04 +09:00
eomsangdon 0a41f3869a Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_8 2026-10-16 12:58:32 +09:00
eomsangdonandClaude Opus 5.5 2764117120 fix(종단보기): 346-3 덧 [저장] 뒤 「노선 밖 고침」 알림 두 줄 → 한 줄 — carryStationDrafts 조용히 꼴(quiet · 기본은 알림 그대로) · 수는 결과 한 줄에 합침
- 시험: test_346_3_route_save_screen 9 · 관련 97 통과 · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 12:34:14 +09:00
eomsangdonandClaude Opus 5.5 d5360194c9 fix(노선추천): 346-3 덧 남은 「[노선 반영]」 글자 — [초기화] 풍선 = 다시 계산은 [저장]에서 · [노선 추천] 풍선 = [적용]은 바로 저장 · 새 초기값
- 주석 글자도 지금 흐름으로([저장] · [저장 전으로]) — RouteEdit · _History · _Draft · design_view_profile
- 남김: design_profile · design_pipeline_page 주석(sub7 일하는 중) · 「없앰」을 적은 주석 · 서버 옛 칸 설명

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 12:34:13 +09:00
eomsangdon 2084533aa8 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-16 12:33:17 +09:00
eomsangdonandClaude Opus 5.5 a35d49b4e2 feat(계산묶음): 346-1 · 346-2 남은 단계 붙임 — cross · masshaul · haul · drainage · corridor · 노선 초안 동안 유토곡선 = 새 노선 횡단
- design_pipeline_page: 단계 다섯 한 줄씩(부르기만) · 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) · haul · corridor 는 노선 초안 동안만 · 열 때 한 번 셈(발자국 바로 보임)
- 노선 초안 동안 종단도 = 새 노선 상세(pipelineDetail — 지반 · 측점 · 횡단 면적 → 유토곡선) · 저장 노선 횡단 다시 셈 안 함
- 횡단 입력 = 저장 상세의 측점 입력을 측점 열쇠로(design_pipeline_cross · 넘기는 칸 = 서버 CARRY_INPUT_KEYS 와 같음)
- 저장 관 목록(좌표 · id) = 관 입력 바탕 · 첫 계산도 저장 노선 기준으로 좌표 따라감(createFollowStage)
- 노선 초안 동안 계획고 = alignment-draft 노선 0 칸(DRAFT_ROUTE_ID) · 초안 버리면 비움 · [저장] 이음 = sub5
- 상단측 짝(logic/B05_Profile_Pipeline_Detail uphillSide ↔ Engine_Sections_Core uphill_side) · 측점 열쇠 = common_util_station_key 한 벌
- 시험 test_346_2_pipeline_cross_page 14 · 옛 시험 둘 새 꼴에 맞춤(317 · 230_2) · 346-1 도움 파일 창구 셋 가짜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 12:32:11 +09:00
eomsangdon 8f4f3b1348 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-16 12:29:09 +09:00
eomsangdonandClaude Opus 5.5 da3dfc1ca8 feat(종단보기): 346-2 덧 2 B05 종단 보기 · 3D 측점선도 갈아끼운 상세로 — 보일 때만 · 숨었으면 다시 보일 때 · 횡단 보기 없이도
- design_view_profile_sections: 노선마다 상세 갈아끼움을 듣고 종단도 · 표 · 3D 측점선 다시(보일 때만 · 숨으면 flushSwap 이 다시 보일 때) · 가게 stations 도 들어 횡단 보기를 안 열어도 갈아끼움(먼저 부른 쪽만 · 다음은 「같은 목록」)
- design_view_profile: 받는 줄만(보임 · 떠날 때 끊음 · 다시 보일 때 flushSwap)
- 갈아끼움 알림판 B06_Section_Swap_Bus(가져오기 없음 · 돌고 도는 가져오기 막음) — 조정창 값 · 암 경계 옮김을 어느 보기가 갈아끼웠든 한 번 · 횡단 보기는 알림으로 다시 그림
- 시험: 종단 보기 보임 7 → 8 · 숨음 = 안 그림 · 다시 보임 = 6 · 3D 측점선 같은 차례 · 옛 시험 둘(342-1 화면 선 = listedBase · 323-9 열쇠 모듈 진짜로) 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 12:28:11 +09:00
eomsangdon b659ed28ca Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-16 12:21:01 +09:00
eomsangdonandClaude Opus 5.5 69d84e5ca9 feat(측점열쇠): 346-2 덧 2 열쇠 한 벌 — 구간형 :start · :end · 묶음 측점 종류(grid · end) · 같은 열쇠 둘째 규칙 공용
- common_util_station_key(ts · py 짝): role 덧붙임(구간형 시작 · 끝 = structure:<id>:start · :end · 기준은 structure:<id> 그대로) · grid(원래 격자 번호) · end(BP/EP) 종류 · dedupeStationKeys(같은 열쇠 둘째부터 at:<누가거리>)
- B06 stationKeysOf 가 같은 규칙(측점 계획 · 구조물 목록의 시작 · 끝 자리 · 공용 dedupe)
- 거울 시험에 새 갈래(구간형 · grid · end · dedupe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 12:20:06 +09:00
eomsangdonandClaude Opus 5.5 82ab2c60b7 test(B05 배수유역): 347 덧 2 test_298 · test_126 본문 검사 자리 — 요청 감싸기를 나눈 B05_Profile_UI_Drainage_Run 을 봄
- 동작 그대로(최신 응답만 · 지도 칸 표시만 · 실패 토스트 한 줄) · 검사 자리만 판 → _Run · 판은 createDetailRunner 에 apply · label 넘김 확인
- test_298 12 · test_126 9 통과 · 판 · 배수유역 파일을 읽는 옛 시험 19 파일 다시 돌려 모두 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 12:19:58 +09:00
eomsangdon b0eb19897b Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-16 12:19:05 +09:00
eomsangdon 0c19bd78f4 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-16 12:18:15 +09:00
eomsangdonandClaude Opus 5.5 322725bf9c feat(B05 되돌리기): 346-4 한 줄 — 노선 편집 되돌리기를 페이지 사진첩 하나로 합침
- Ctrl+Z · Y · 편집기 [되돌리기] · [다시하기] · [반영 전으로] = 페이지 사진첩(_Profile_History) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로
- 편집기 쌓기 · [반영 전으로] 뒤 사진첩 한 걸음(_Draft.watch) · 사진첩이 되쓴 route-draft 는 fromPage 로 편집기 새 걸음(같은 선 옛 걸음의 고정 · 추천 꼬리표째)
- 쌓는 줄 없는 조작(횡단 카드 등) = 놓은 뒤 · change 뒤 400 ms 한 번 찍음 · 고름 · 관 id · 종류 캐시 · 구조물 넘김 · b08 · b09 칸은 사진 밖
- 「마지막에 만진 판만」(_RouteEdit_UndoOwner) 지움 · 편집기는 단축키 안 들음
- 시험 test_346_4_one_undo.py · 345-3 시험 · 219-1 헬퍼 새 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 12:17:57 +09:00
eomsangdon f9c70757b4 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_2 2026-10-16 12:16:54 +09:00
eomsangdon ab14354d9f Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-16 12:16:50 +09:00
eomsangdon cedd2ed194 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-16 12:15:54 +09:00
eomsangdonandClaude Opus 5.5 e8f32cbabb fix(시험): 346-3 서버 길 시험(test_346_3_route_save · sub1) 되살림 — 같은 이름으로 덮었던 화면 시험은 test_346_3_route_save_screen 으로
- 서버 길 시험 = 덮기 전 판과 같음(차이 0) · 둘 함께 22 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 12:15:15 +09:00
eomsangdonandClaude Opus 5.5 20f2044afe feat(횡단목록): 346-2 덧 측점 수가 바뀔 때 횡단 목록 갈아끼움 — 묶음 측점 → 새 상세 · 열쇠로 이어 받기 · 보이는 카드 먼저
- logic/B06_Section_Station_Swap: 묶음 측점(PipelineStation[]) → 새 상세 객체 · 같은 측점(열쇠 · 없으면 같은 자리 같은 종류)은 앞 횡단 이어 받음(설계 · 구조물 제원 · 이름) · 자리 · 지반 표본만 새것 · 새 측점 = 지반 표본 + 기본 설계 · 빠진 측점 뺌 · formatStation(파이썬 format_station 짝)
- Section_Store swapSectionStations: 같은 목록이면 그대로 · 바뀌면 캐시 · 세션 상세 갈아끼움 · 옛 열쇠 = 세워 둔 목록(옮긴 관도 옛 id) · 새 열쇠 = 묶음이 정한 것 · 노선 모양이 바뀐 차례면 세션 고침 넘김(사라진 측점 = 「노선 밖 고침 N 건」) · 듣는 쪽 subscribeSectionDetail
- 횡단 보기 followSwaps(가게 stations 따라감): 조정창 값(반폭 · 기슭막이 · 유입 · 집수정 · 추가 벽)을 옮긴 자리로 · 암 경계 · 암 경사 다시 읽음 · 보일 때 카드 · 종단도 다시 · 떠난 보기의 옛 맵은 지금 값을 안 덮음
- 카드 통째 그리기 = 보이는 줄 + 한 줄만 바로 · 나머지는 같은 id 빈 자리 → 쉬는 틈에 진짜 카드
- 시험: 셋 늘고 둘 빠진 목록(카드 8) · 관 45.054 → 47 m 고침 이어 받음 · 노선 밖 고침 버림 · 조정창 옮김 · 같은 목록 안 갈아끼움 · 보이는 카드 수

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 12:14:54 +09:00
eomsangdonandClaude Opus 5.5 6086f30982 feat(노선추천): 346-3 추천 [적용] = as_initial 로 서버 노선 저장 — 초기값 = 추천 노선 · 잠금 막 · 초안 비움 · 새로고침 없이 다시 세움 · 실패 = 오류 한 줄 · 평면에 실린 채 남음
- _RouteEdit_Initial onApply → 평면 되돌리기 한 칸 그대로 + saveRecommendedRoute
- 시험: test_346_3_route_save 7(안 바뀜 안 보냄 · 계약 꼴 · 저장 뒤 가게 비움 · 결과 한 줄 · 넘김 차례 · as_initial · 실패) · 관련 242 통과 · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 12:14:22 +09:00
eomsangdonandClaude Opus 5.5 47366d3740 feat(종단보기): 346-3 [노선 반영] 없앰 — [저장]이 노선부터(POST /route/replan · draft = 계약 RouteDraft · 안 바뀌면 안 보냄) · 뒤 차례는 새 노선 · 횡단 초안 측점 열쇠로 넘김 · 결과 한 줄
- design_save 차례 = 노선 → 관 → 구조물 → 계획고(옛 노선 칸 → 새 노선) → 암거 옵션 → 상단측 → 횡단(carryStationDrafts 뒤 저장) · 잠금 막 = [저장] 하나 · 실패 = 멈춤 · 초안 그대로 · 오류 한 줄
- [확정] 중 노선이 바뀌면 노선만 저장하고 다시 세움(새 노선으로 다시 [확정])
- _RouteEdit_Apply = 노선 저장 이음(편집기 등록부 · pendingRouteSave · saveRouteDraft · carryAfterRouteSave · routeSaveSummary · rebuildAfterRouteSave) · 단추 듣기 없음
- 결과 한 줄 = 관 지킴 · 새 교차 관 · 오목 보충 · 구조물 옮김 · 멀어짐 · 노선 밖 고침(서버 cross_dropped + 화면) · 배수유역 낡음
- RE_Chrome [노선 반영] 지움 · [반영 전으로] → [저장 전으로] · replanRoute 본문 = RouteSaveBody
- 시험: test_design_store_save 차례 넷 더함 · 꼴 시험 셋 고쳐 씀 · 323_9 대역

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 12:14:22 +09:00
eomsangdonandClaude Opus 5.5 73117fa931 feat(B05 노선편집): 345-5 덧 2 곡선 바 판 위 · [맞춤] 단추 — 꺾임점 곡선 바 아래 한계 = 뜬 종단 판 바로 위(판 끌기 · 접기 따라 다시 자리) · 도구줄 [맞춤] = 노선 전체를 판 위 보이는 영역에(회전 0)
- _RouteEdit_View.fitView(처음 열기 · [맞춤] 한 벌) · _RouteEdit_Cover.onCoverChange(덮인 높이 바뀜 알림 · 떠난 편집기는 뗌)
- 시험: test_345_5b_plan_visible_area 3 · 관련 81 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 12:12:43 +09:00
eomsangdon 5795f359cc Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 12:07:59 +09:00
eomsangdonandClaude Opus 5.5 4a55092bf8 feat(B05 노선편집): 345-5 덧 뜬 판 가려짐 정리 — 노선 맞춤 = 뜬 종단 판 위 보이는 영역 가운데 · 상태 · 범례 · 잰 값 글판 = 판 바로 위(판 높이 끌기 · 접기 따라감)
- 새 logic/B05_Profile_RouteEdit_Visible(덮인 높이 · 보이는 영역 맞춤 — 덮인 높이 0 이면 computeRouteView 와 같음)
- 새 _RouteEdit_Cover(칸 · 판 ResizeObserver → --b05-routeedit-cover) · fitCanvas 가 편집 평면 칸만 붙임
- 보던 화면 되살림도 판 밑 노드는 안 보인 것으로
- 시험: test_345_5b_plan_visible_area 2 · 관련 68 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 12:07:08 +09:00
eomsangdon 6d118c8913 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-16 12:04:34 +09:00
eomsangdon 635b557215 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-16 12:04:33 +09:00
eomsangdon 358a61b000 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-16 12:03:42 +09:00
eomsangdon b9ed9ee101 Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1' and 'origin/sub_laptop_7' into sub_laptop_8 2026-10-16 12:02:08 +09:00
eomsangdon 2153a4a639 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-16 12:02:06 +09:00
eomsangdonandClaude Opus 5.5 13a3e50e6f fix(설계 저장): 347 덧 [확정] 때 관 저장이 배수유역 다시 셈 — side.savePipes(confirm) 한 줄(승인)
- [확정] = PUT pipe-points ?recompute=true(낡았으면 지금 노선으로 배수유역부터) · [저장] = 낡음 달고 저장

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 12:02:03 +09:00
eomsangdonandClaude Opus 5.5 44561251ea feat(배수유역): 347 덧 관 고정 id — 측점 열쇠 pipe:<id> · 옮겨도 · 노선 바뀜 · 다시 셈에도 그대로
- PipePoint.id · ensure_pipe_ids(빈 id = p<누가거리 cm> · 겹치면 꼬리) — 읽기(parse) · 되붙이기(carry · 원본 id) · 저장(save) 세 자리 · 옛 자료는 다음 저장에 남음 · 노선 투영 · 새 자동 관 = 새 id
- 화면 관 모양 DetailPipePoint.id · 배관 편집기 관이 id 를 쥐고 요청에 실음 · reconcilePipes 제자리 · 하나 옮긴 관 id 따라감
- 시험 test_347_pipe_id(8 · station_key = pipe:<id> · 화면 거울) · test_b04_pipe_points_facility 옛 자료 = id 하나만 더함 · test_312 옛 분석 = 낡음(409) 뒤 지문 찍고 자동 배치(새 지문 동작에 맞춤)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 12:02:02 +09:00
eomsangdonandClaude Opus 5.5 37698a8360 feat(계산묶음): 346-1 발자국 계약 칸 — PlanFootprint 에 slopeLeftCut · slopeRightCut · ticks · records(sub6 footprint 단계가 내는 꼴 그대로)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 12:00:54 +09:00
eomsangdon b22633c4b5 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 12:00:30 +09:00
eomsangdonandClaude Opus 5.5 aeca9616cc feat(계산묶음): 346-1 묶음을 B05 페이지 · 노선 편집에 붙임 — 노선 초안 캐시 칸 · 노선 고칠 때마다 종단 따라옴 · ▲▼ = grade · 단계 붙임
- design_pipeline_page.ts 새 — 프로젝트마다 묶음 하나(종단도 dispose 때 버림) · route · ground(sub8) · follow · planline · profile · stations · footprint(sub6) 붙임 · cross · masshaul · haul · drainage 는 붙으면 한 줄 · 첫 계산은 처음 고칠 때 · 가게 stations · profile · footprint · generation
- B05_Profile_UI_RouteEdit_Draft.ts 새 — 사진첩 감싸기(쌓기 · 되돌리기 · 다시하기 · 열 때로 = 초안 넘김 · 같으면 null) · 저장 안 한 초안 되살림 · RouteEdit.ts 는 줄 셋(700줄 안)
- logic/B05_Profile_Pipeline_Grade.ts 새 — follow(관 · 구조물 좌표 · 계획고 옮김) · planline(저장 기준 · 초안이면 바뀐 구간만 새 지반 첫 계획선) · profile(buildAlignment) · routeRange · gradeRange — sub1 짝 부르기만
- design_profile — listed 를 design_profile_base.ts 로 나눔 · rebuild = pipelineGrade · render · 구조물 측점 = 다시 셈 · adoptPipeline(초안 동안 편집은 메모리만 · [저장]이 안 보냄) · pipelineSource
- design_pipeline — input() · dispose()
- 시험 test_346_1_pipeline_page 12 건(실제 단계 · 노선 1,078 m 점 옮김 23 ms · 늦은 응답 버림 · 초안 캐시 칸 살아남음) · popout 시험 문맥에 pipelineGrade 자리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 11:59:45 +09:00
eomsangdonandClaude Opus 5.5 3995e91a1c refactor(346-3): 노선 저장 횡단 이월 측점 열쇠 = common_util_station_key 한 벌
- station_keys 가 station_key(sub2 짝)를 부름 · 관 pipe:<id>(id 없으면 at:<0.01 m>) · 구조물 structure:<id> · 같은 열쇠 둘째는 at:(B06 stationKeysOf 와 같음)
- 파일 차례 관 열쇠 없앰 · 시험 test_346_3_route_save 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 11:59:38 +09:00
eomsangdonandClaude Opus 5.5 cebed851b5 feat(종단보기): 345-5 평면 = 가운데 전체 영역 · 아래 종단 판 = 다시 뜬 판 — 종단 보기 판 흐름 규칙을 횡단 보기만으로 되돌림(345-3 앞 꼴) · 끌어 높이 · 접기 · 새 창 떼기 그대로 · 가려진 아래쪽은 팬으로
- 시험: test_345_5_plan_full_overlay 3(전체 영역 · 뜬 판 · 띠 없음 · 종단 그래프 있음) · test_345_3 판 시험 고쳐 씀 · 관련 162 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:59:27 +09:00
eomsangdonandClaude Opus 5.5 cfeca9c647 feat(B05 노선편집): 345-5 평면 안 종단 띠 지움 — 원지반선 · 계획 종단선 · 연장 띠(_RouteEdit_ProfileStrip) · 띠 칸 · CSS · 띠 전용 요청(/route/profile-preview · fetchProfilePreview · balanced_profile_of terrain) · 눈금 그리기 걷음 · 아래 종단 판 종단 그래프 그대로
- [노선 반영] 균형 종단 여부 = 지금 추천 갈래(띠가 보이던 값과 같은 규칙)
- 시험: test_75 → 띠 없음 시험 · test_133_1 분석 지형 셋 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:59:27 +09:00
eomsangdon 6757d1dcb8 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-16 11:56:32 +09:00
eomsangdon 1a4cb36d1c Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-16 11:53:52 +09:00
eomsangdon 19e0700e9c Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-16 11:53:51 +09:00
eomsangdon 3cd3176fc9 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-16 11:53:49 +09:00
eomsangdon 2ea5d02892 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-16 11:53:48 +09:00
eomsangdon 424fb37e9c Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-16 11:52:45 +09:00
eomsangdon e41300aad6 Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_8 2026-10-16 11:51:51 +09:00
eomsangdon 12f270f9de Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-16 11:51:48 +09:00
eomsangdonandClaude Opus 5.5 a5c36a4a29 feat(B05 배수유역): 347-2 자동/수동 다시 셈 · ⚙ 설정 창 · 347-3 제안 목록 · [적용]
- 계산 묶음 drainage 단계(pipelineStage · 붙이기 = 묶음 쪽) — 노선 초안 · 관이 바뀌면 낡음 · 자동 = 3 s 조용한 뒤 「지금 노선으로 셈」 · 수동 = 칸만 흐림 + 가운데 [업데이트] · 구석 「계산 중」 · 다시 조작하면 요청 끊고 처음으로 · 덮개 없음
- ⚙ 설정 창(배수유역 칸 머리 · openModal) 수동(기본) / 자동 · 계정 화면 설정 drainage-mode(b_page_state 등록표 한 줄 · build:corridor 다시 지음)
- 늦은 답 버림 = 차례 번호 · 서버 superseded · 세부유역 요청 감싸기와 같은 차례(B05_Profile_UI_Drainage_Run 으로 나눔)
- 제안 = 관경(Ø→Ø) · 시설(BOX암거 · 세월교) · 관 자리 후보(지도 점선 원) · [적용] 한 번에 관 초안 · 자동 바꿈 없음
- [확정] savePipes(confirm) = PUT ?recompute=true · [저장] = 낡음 달고 저장 · 패널 691줄
- 순수 규칙 logic/B05_Profile_Drainage_Live · _Suggest · 시험 test_347_2_drainage_live_ui · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 11:47:55 +09:00
eomsangdonandClaude Opus 5.5 519b54eb73 feat(종단보기): 345-4 3D 모달 열 때 낡았으면 자동 다시 짓기 — 닫혀 있는 동안 안 지음 · 「3D 늦음」 = setCorridorPending 그대로 · 단추 · 열기 겹쳐도 한 번
- ProfileViewState.corridorPending(표시와 한 칸) · sections.setCorridorPending · 모달 onOpen(지형 선 뒤 · 그 사이 닫으면 안 부름)
- 시험: test_345_4_corridor_worker 4(워커 = 주 스레드 · transfer 9/9 · 저장본 먼저) · test_345_2_3d_modal 5(닫힌 동안 짓기 0) · 관련 310 통과 · typecheck 내 파일 0 오류

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 11:47:31 +09:00
eomsangdonandClaude Opus 5.5 871693650b perf(3D): 345-4 3D 길 모형 웹 워커 — 저장 안 한 초안 미리보기를 워커에서(buildCorridor 그대로) · 리본 좌표 Transferable · 주 스레드 멈춤 0 · 저장본 먼저(해시 맞으면 받기만)
- 새 _Corridor_Worker(워커 몸 · handleCorridorRequest) · _Corridor_Worker_Client(buildCorridorOffThread · 워커 없거나 깨지면 주 스레드 같은 함수)
- ensureCorridor 의 짓기만 워커로 · vite 설정 그대로(new Worker(new URL(…)) 기본 지원)
- 서버 Node 묶음 입구 · Build 안 고침(다시 짓기 불필요)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 11:47:31 +09:00
eomsangdonandClaude Opus 5.5 4262038cd4 feat(설계묶음): 346-2 cross · masshaul · haul 단계 — 계약 모양 · 측점 지문 국부 연산 · 범위 밖 재사용 · 흙 배분 열쇠 기억 · AbortSignal
- cross 단계(B06_Section_Cross_Stage) = profile · ground · input.cross → 측점 열쇠마다 횡단 설계(CrossOutput.byKey) · 같은 엔진 · 측점 지문이 같으면 앞 결과 · 바뀐 범위 밖은 ctx.prev 재사용
- masshaul 단계(logic/B05_Profile_MassHaul_Stage) = cross · profile → 유토곡선 계열 + 흙 배분 입력
- haul 단계(Api_HaulPlan createHaulStage) = 서버 Node 배분 · 선반입과 같은 열쇠 기억 · 새 조작이면 끊김 · 못 받으면 앞 배분
- 시험: 옛 초안 옮김 · 쓴 자리 읽기 · 노선 바뀜 넘김(노선 밖 2 건) · cross 6 → 같은 입력 0 → 측점 하나 1 · masshaul 계열 · haul 같은 열쇠 POST 1 번

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 11:45:32 +09:00
eomsangdonandClaude Opus 5.5 1c090c0f5e feat(횡단초안): 344-3 측점 열쇠 — 세션 횡단 초안을 측점 열쇠로 · 옛 초안 옮김 · 노선 바뀜 넘김 · 노선 밖 고침 알림
- 열쇠 규칙 한 벌 = common_util_station_key(ts · py 짝 · 거울 시험) — 일반 grid:k · 관 pipe:<id> · 구조물 structure:<id> · 시종점 end:BP/EP · id 모름 at:<0.01 m>
- 세션 칸 다섯(카드 선택 · 노견 확장 · 소단 깊이 · 암 경계 · 암 경사) = 측점 열쇠로 담음 · 부르는 쪽은 그대로 누가거리로 읽고 씀 · 칸마다 쓴 때 자리를 함께 담아 구조물 id 를 아직 모를 때도 그 측점 고침을 읽음
- 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 고침은 지우지 않고 남김
- 측점 목록 = 상세가 초안 측점을 얹을 때 · 다시 셈 때 세움(관 id = 관 장부 · 구조물 id = 측점 계획 · 구조물 목록)
- 노선 바뀜 넘김 carryStationDrafts — 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사만 · 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점 = 「노선 밖 고침 N 건」 한 줄(부르는 자리 = 3파)
- 옛 시험 셋 새 창구로 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 11:45:31 +09:00
eomsangdonandClaude Opus 5.5 bc2b19104e fix(346-3): B06 [저장] · [확정] 측점 맞춤 뒤 옛 횡단 파일 정리(GET 대신 쓰는 쪽)
- _prune_cross_files = 종단 측점에 없는 cross_*.json 지움 · 측점을 못 읽으면 안 지움
- 시험 test_346_3_route_save 에 정리 · 차례 한 벌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 11:44:58 +09:00
eomsangdon 37f8269e15 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 11:44:54 +09:00
eomsangdonandClaude Opus 5.5 2149c51025 chore(요구): 233 — B05 평면은 3D 처럼 가운데 전체 영역 · 아래 종단 판은 오버레이 · 노선 편집 안 원지반선 · 계획 종단선 띠 지움(종단 그래프 영역 아님)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-16 11:44:43 +09:00
eomsangdonandClaude Opus 5.5 8ae90ad7b6 feat(346-3): 노선 저장 서버 길 — 배수유역 뗌 · 관 지킴 · 측점 열쇠 이월 · 초기값은 추천 [적용] 때만
- POST /route/replan = 노선 저장 체인(B05_Profile_Route_Save) — 풀기 → 구조물 · 관 좌표 따라감 → 새 세류선 교차점 기본 관 · 옛 노선 밖 오목 지점 보충 → 종횡단 한 번 → 계획고 옮김(바뀐 구간만 새 지반) → 횡단 이월(측점 열쇠 grid · pipe · structure · end · CARRY 일곱 + 구조물 조정) → B06 확정 · 3D
- 관 정본 안 지움 · 배수유역 체인에서 뗌(관 정본 새 노선 지문 → B04 분석 낡음)
- 요청에 노선 초안(RouteDraft 모양 draft) · as_initial · 응답에 관 · 구조물 · 버린 횡단 수 · 초기값 여부 · 시간
- 초기값 = 자동설계 + 추천 [적용](as_initial)만 · 손 편집 저장은 안 바꿈
- 실패 = 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행 · stage 2 params 되살림
- 시험 test_346_3_route_save · test_133_2 새 체인으로 · 시간 재기 도구 run_346_3_route_save_timing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 11:43:27 +09:00
eomsangdonandClaude Opus 5.5 498d48fc7f feat(B05 발자국): 346-2 평면 발자국 그리기 — 노선 편집 캔버스에 가게 키 footprint(도로 끝 좌 · 우 · 비탈 끝 깎기 붉음 · 쌓기 파랑 · 밝음 · 어두움 토큰) · 등고선 위 · 노선 아래 · 가게 구독으로 바뀐 때만 다시 그림(프레임마다 한 번) · 도구줄 [발자국] 켬끔(기본 켬 · 페이지 동안) · 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:43:20 +09:00
eomsangdonandClaude Opus 5.5 a8ad867468 feat(B05·B06 떠남): 346-4 떠남 확인 — 저장 안 한 조작이면 탭 닫기 · 다른 페이지 때 공용 물음
- A00_Common/design/design_leave.ts — 고침 칸(초안 통 − 고름 · 관 id 장부 · 종류 캐시 · 구조물 넘김 · 표준 횡단면 · b08 · b09) 지문 · 모든 노선
- 횡단 초안은 [저장] 뒤에도 남음 — 저장 · 확정 끝 깨끗한 표(markDesignClean)와 다를 때만 물음 · [초기화]는 초안을 다 지움
- B05 ↔ B06 이동은 안 묻고 다시 걺 · [버리고 넘어감] = 고침 칸만 지움 · 저장 실패 = 머묾
- design_page 한 줄(걺) · design_shared 한 줄(저장 끝 표) · 문구 B05_Design_Leave · b07-extent 묶음 다시 지음
- 시험 test_346_4_design_leave.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:43:19 +09:00
eomsangdon 5a766a92e0 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_7 2026-10-16 11:40:04 +09:00
eomsangdonandClaude Opus 5.5 593ee198aa refactor(종단보기): 345-4 정리 — 빈 saveCorridorIfDirty 와 부르는 곳 지움 · B05 등고선 주소 두 곳 = contourAssetUrl(미리 받기와 같은 파일)
- 3D 뷰어 · 노선 편집 바탕 등고선이 같은 주소 한 벌(recalculate=false 붙음 · 보관함 한 칸)
- 시험: test_345_4_contour_one_url 2 · 관련 250 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 11:39:56 +09:00
eomsangdon 17e143aa27 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 11:38:38 +09:00
eomsangdonandClaude Opus 5.5 d5d6415966 feat(배수유역): 347-2 「지금 노선으로 셈」 서버 길 · 347-3 관 자리 후보 · [확정] 다시 셈
- POST drainage/live — 몸통 = 차례 번호 + 노선 초안(RouteDraft · 노선 [확인]과 같은 폴리라인 규칙) + 관 목록 · 서버 단독 · 작업본(00_watershed_response · 격자 배열 · 관 지점)에 안 씀 · 세부 유역까지 메모리 격자로 한 요청
- 프로젝트별 마지막 차례 · 1차 영역 뒤 · 확장 회차마다 · 세부 유역 앞에서 바뀌었으면 그만둠(superseded · AnalysisStopped)
- build_detail 에 메모리 routing · 상류선 · 관 자리 후보 suggest_pipe_positions(간격 보충 규칙 · 흐름 세기 + 오목 · 기존 관에서 먼 곳 · 정수 m) · 응답 pipe_candidates
- PUT pipe-points ?recompute=true([확정]) = 낡았으면 배수유역부터 다시 셈 · [저장]은 낡음 달고 관만
- 시험 test_347_2_drainage_live(늦은 차례 그만둠 둘 · 엔진 회차 그만둠 · 메모리 격자 · 후보 · 확정 다시 셈) · 검증_sub3 실측 저장 노선 16.9 s · 초안 노선 11.5 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 11:38:17 +09:00
eomsangdon dda35e4113 Merge remote-tracking branches 'origin/sub_laptop_6' and 'origin/sub_laptop_8' into sub_laptop_4 2026-10-16 11:38:07 +09:00
eomsangdon 4ebee05399 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-16 11:38:03 +09:00
eomsangdonandClaude Opus 5.5 31a5912833 feat(지형표본): 346-1 지반고 자릿수 칸 — /route/elevations decimals(기본 3 · 0~6) · 계산 묶음 서버 길은 6자리로 부름(격자 길과 같은 값) · 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:37:57 +09:00
eomsangdonandClaude Opus 5.5 abd8037e10 fix(계산묶음): 346-1 route 단계 곡선표 채움 — 계약 RouteLine.curves(start · apex · end · radius_m) = buildEditedPolyline 곡선표 · savedLine 도 곡선표 함께 · typecheck 빨강 풀림 · 시험 서버 곡선표와 같음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:37:11 +09:00
eomsangdon 91662dfe2e Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-16 11:36:59 +09:00
eomsangdonandClaude Opus 5.5 cadd680b3e feat(B05 되돌리기): 346-4 앞일 — 사진 대상 aislo:draft: 전부 · 누름 중간 안 찍음 · 사진끼리 같은 글 한 벌
- 대상 = 초안 통 전부(노선 초안 · 계획고 · 관 · 구조물 · 횡단) + 옛 b05 · b06 키(보기 설정 뺌) · pref · result 밖
- 누름(마우스 · 터치 · 펜)이 남아 있으면 record() 미룸 → 놓은 뒤 한 틱에 한 번 · 창 밖에서 놓음도 처리
- 안 바뀐 칸은 앞 사진 글을 같이 씀 — 300 KB 칸 셋 × 100단 heap 89 MB → 10 MB 안 · 한도 50 → 100
- 시험 test_346_4_profile_history.py · 노선 편집 이력 합치기는 3파

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:35:30 +09:00
eomsangdonandClaude Opus 5.5 cde4d7fab2 perf(영구저장): 343 덧 파생 저장본 도우미 · 유토 배분 디스크 저장본
- common_util_derived_store — 열쇠 · 읽기 · 원자 쓰기 · 종류마다 최근 2벌 · 결과 옆 열쇠 · 파일 지문
- B04 전처리 · 도엽 3D 면이 도우미의 file_stamp · key_matches 를 씀
- 유토 배분 — 메모리 다음 _derived/haul/<열쇠>.json(같은 _plan_key) · 재시작 뒤 첫 열기도 셈 없음 · 저장소 못 찾아도 셈
- 시험 test_343_4_derived 11건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 11:35:17 +09:00
eomsangdonandClaude Opus 5.5 d07b21c22a perf(묶음): 343 덧 Node 묶음 낡음 검사 기억 — 묶음 mtime 같으면 30 s 안에는 TS 330여 개 다시 안 잼 · 낡음은 기억 안 함(R24)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 11:35:16 +09:00
eomsangdonandClaude Opus 5.5 d538fa9425 fix(도면): 343 덧 B07 측점 파일 읽기는 안 지움 — valid_cross_names 로 걸러 읽기만 · 지우기는 측점 쓰는 쪽 몫(R26)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 11:35:15 +09:00
eomsangdon 8a2060ffbd Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8 2026-10-16 11:34:51 +09:00
eomsangdonandClaude Opus 5.5 c0ad3edc80 refactor(배수유역): 347-2 세부 유역 엔진 700줄 — 관경 산정(강우표 · 합리식 · 추천 구조물)을 common_util_drainage_sizing 으로 나눔
- 이름은 common_util_drainage_detail 에서도 그대로 읽힘(옛 자리 import 유지) · 내용 고침 없음
- 시험 test_b05_drainage_recommend · test_96_b08_hydro_linked · test_342_3_* 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 11:33:06 +09:00
eomsangdon f6aa50f795 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 11:32:44 +09:00
eomsangdon c088ee3024 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-16 11:32:42 +09:00
eomsangdon 888de90128 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-16 11:32:41 +09:00
eomsangdonandClaude Opus 5.5 50620a47b8 feat(B05 발자국): 346-2 footprint 단계 — cross · ground → 계약 PlanFootprint 꼴 · 범위 밖 재사용
- B05_Profile/logic/B05_Profile_Footprint_Stage.ts — footprintStage(ctx) · 측점 지반(우 = 음수 오프셋 · 빈칸 무효) + cross.byKey[key].detail(road_edges · design_line) → 측점 줄 · 바뀐 범위 밖은 ctx.prev.records 같은 열쇠 · 같은 누가거리 줄 그대로
- PlanFootprint.ts — footprintLines(계약 roadLeft · roadRight · slopeLeft · slopeRight + 점마다 깎기 여부 · 눈금 · 측점 줄) · 옛 꼴 이름 SlopePieces · 몸통 sampleLines 하나
- 시험 test_346_2_footprint_stage.py · 거울 시험 tsc 에 --noCheck --noResolve

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:30:16 +09:00
eomsangdon b2a39740ed Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_4 2026-10-16 11:29:54 +09:00
eomsangdonandClaude Opus 5.5 92163bda3e feat(설계묶음): 344-3 · 346-1 stations 단계 — 그린 노선 + 관 · 구조물 → 측점 한 벌(열쇠 · 종류 · 틀 · 방위각 · 반경 · 바깥쪽 · 확폭) · 서버 generate_sections 짝
- B05_Profile/logic/B05_Profile_Sections_Stations.ts 새 — buildStations(격자 · 스냅 · 심는 측점 병합 = 이미 있는 짝 그대로) · 평면 반경 · 확폭 짝(_plan_radii · _curve_widenings · _design_curve_widenings) · stationKey · stationText(0.01 m)
- A00_Common/design/design_pipeline_stations.ts 새 — createStationsStage · 범위 밖(± 20 m) 측점은 앞 객체 그대로
- Engine_Sections_Core.py 머리에 짝 표시
- 거울 시험 test_344_3_pipeline_stations_mirror 7 건(곡선표 · 스냅 · 곡선표 없음 서버와 같음 · 열쇠 · 한 점 옮김 앞 구간 열쇠 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 11:29:27 +09:00
eomsangdon 99a8714160 Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into sub_laptop_3 2026-10-16 11:29:20 +09:00
eomsangdonandClaude Opus 5.5 7a26d21881 feat(계산묶음): 346-1 격자 준비 — designTerrainGridNow(받는 중엔 null · 서버 길 · 받은 뒤 브라우저 길) · groundStageDeps(격자 · elevations 서버 길 4,000점씩 · AbortSignal)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:28:19 +09:00
eomsangdonandClaude Opus 5.5 f8d44cd8e9 feat(계산묶음): 346-1 route · ground 단계 — 노선 초안 → 그린 노선(buildEditedPolyline 짝 그대로 · 길이 붙든 곡선 R 다시 잡음) · 측점 → 지반 채움 · 종단 지반선(계약 꼴) · 범위 밖 측점 · 종단 재사용(자리 · 축 · 지형 같을 때만) · 격자 없음 · 분석 지형 다름 = 주입 서버 길 · 시험(서버 generate_sections 와 같음 · 노선 1,078 m 1.4~6.9 ms)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:28:18 +09:00
eomsangdonandClaude Opus 5.5 58c86e525f feat(종단보기): 345-4 노선 추천 → 좌측 새 칸 「노선 추천」(지운 「계획노선」 자리) — [노선 추천] · 현재 추천 · [초기화] · 추천 창 공용 모달 하나 · [적용] = 본문 평면 되돌리기 한 칸
- 새 _Panel_Recommend(칸 재료) · 칸 차례 30 · 편집 머리에서 단추 셋 지움
- _RouteEdit_Initial 이 칸 요소를 밖에서 받음 · 추천 창 「지금 노선」 = 본문 편집기 지금 노드
- 추천 창 키 막기 주석 정리(뒤 판 Ctrl+Z 끊음 그대로)
- 시험: test_345_4_recommend_panel 4 · 칸 · 머리 시험 고쳐 씀 · 관련 415 통과(옛 3 실패 그대로) · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 11:27:32 +09:00
eomsangdon 27f0f5b8a1 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-16 11:27:21 +09:00
eomsangdonandClaude Opus 5.5 fed5640a4a feat(설계묶음): 344-3 측점 열쇠 규칙 계약 — grid:k(원래 격자 번호) · pipe:<id> · structure:<id> · end:BP/EP · 글 자릿수 0.01 m · RouteLine 곡선표 칸
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 11:26:21 +09:00
eomsangdon 70ad119cf3 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_1 2026-10-16 11:24:59 +09:00
eomsangdonandClaude Opus 5.5 d985c24f5c feat(종단보기): 345-3 노선 편집 → 종단 보기 가운데 위 본문(페이지 수명) · 종단도 판 흐름 배치 · [노선 반영] 뒤 새로고침 없이 다시 세움 · Ctrl+Z · Y 는 마지막에 만진 판 · 「계획노선」 칸 · 편집 단추 지움
- mountRouteEditor(parent, host, …) — 덮개 · 전체 화면 · 닫기 걷음 · 이력 · 키 듣기 = host 보임 · 숨김 · 떠남 · 칸 크기 = ResizeObserver
- [취소] → [반영 전으로](이력 처음으로 · 되돌리기로 다시 살림) · [확인] → [노선 반영] · 대기 막 = 화면 전체(노선 재탐색)
- [노선 반영] 끝 = 초안 · 결과 · 옛 노선 · 종단면 캐시 비움 → remountRoute(location.reload 없앰)
- R 라벨 = 칸 안 절대 위치 · 오른쪽 횡단 미리보기 = 접는 칸 · [3D] 단추 = 평면 머리 도구줄
- 새 _RouteEdit_UndoOwner(누른 판 기억) · 종단도 Ctrl+Z 줄이 물음
- 시험: test_345_3_route_body 5 · 칸 · 겉틀 시험 고쳐 씀 · 관련 411 통과(옛 3 실패 그대로) · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 11:23:27 +09:00
eomsangdon 7e9f541b70 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-16 11:22:42 +09:00
eomsangdon 6984ed0bd9 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-16 11:22:41 +09:00
eomsangdon 16d4f3dfaa Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-16 11:22:40 +09:00
eomsangdonandClaude Opus 5.5 e0051bff72 perf(전처리 화면): 343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 꺼진 층은 켤 때 · 남은 층 ETag · 등고선 주소 한 벌
- b_asset_cache — 쓰기 끝 = 트랜잭션 완료(용량 초과 취소 잡음) · 실패 한 줄 경고(까닭당 한 번) · onblocked · onversionchange · 열기 실패 null 안 기억 · 지우기는 색인 열쇠 · 앞머리 범위(값 안 읽음)
- 점군 미리보기 — 전처리 때 고정 표본 이진(preview_points_{필터}.bin · 원천 지문 같으면 건너뜀) · GET point-cloud/binary(ETag) · 화면은 fetchCachedBytes(IndexedDB) + decodePointPreview(py 짝)
- B04 지도 — 켜진 층만 처음에 받음 · 꺼진 층은 켤 때 · 같은 층 겹쳐 받기 막음
- geojson(지적도 · 행정구역 · 등고선) 파일 그대로 cached_file_response(ETag) · 등고선 단순화본은 원본이 새로우면 다시 만듦
- 등고선 주소 contourAssetUrl 하나(B04 · 미리 받기) — B05 두 곳은 sub5 몫
- 시험 test_343_3_browser_cache 13건 + helper_343_3_asset_cache.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 11:21:51 +09:00
eomsangdon 85e293011b Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7 2026-10-16 11:21:30 +09:00
eomsangdonandClaude Opus 5.5 0e0c1be1c0 perf(배수유역): 347-1 흐름 방향 · 도엽 좌표 저장 — 격자 원점 좌표 정수 m · 노선이 범위 안이면 읽기만
- 격자 원점 = 좌표 정수 m(GRID_ORIGIN_XY · 옛 「도로 시작점」 지시를 바꿈 · 사용자 확정)
- 흐름 방향 저장본 drainage/descent_<입력 지문>_<범위>.npz — 입력 지문 = 도엽 등고선 지문 + 남긴 등고 단 + 셀 + 계산 규칙 · 덮으면 읽어 잘라 씀 · 최근 2벌
- 도엽(하천 · 등고선) 사업지 좌표 저장본 drainage/00_sheet_metric.pkl — 원본 자국 · 좌표계가 같으면 재투영 안 함
- 검증_sub3 실측: 저장본 읽기 = 지은 것과 칸마다 같음(적색 · 해석 영역 · 화살표 · 귀속 차이 0) · 108 → 10.8 s(붐빔 · 340 기준 약 6 s) · 원점 바뀜으로 적색 460,035 → 459,792칸(−0.05 %) · 관 자리 같음
- 시험 test_347_1_descent_store · 실측 bench 저장본 갈래

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 11:21:13 +09:00
eomsangdon e26568e997 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8 2026-10-16 11:20:54 +09:00
eomsangdonandClaude Opus 5.5 bb63b30aae feat(344-2): 첫 계획선 · 관 자리 투영 · 구조물 좌표 따라감 · 계획고 옮김 짝
- 첫 계획선(계획고 = 지반고) base_pvi TS 짝 = B05_Profile/logic/B05_Profile_Grade_Ground.ts(1순위만 · 못 서는 땅은 null = 서버로 다시 셈)
- 관 자리 투영 TS 짝 · 구조물 따라감 py · ts 짝 = common_util_route_follow(정수 m · 구간 끝 밂 · 0 ~ 연장 자름 · 20 m 넘으면 멀어짐 표시 · 상수 STRUCTURE_ROUTE_DRIFT_MAX_M)
- 구조물 x · y 저장(저장 때 노선 위 자리 · 자리가 그대로면 옛 좌표 지킴 · 옛 자료는 읽을 때 채움) · 노선 [확인] 때 구조물 정본이 좌표를 따라감
- 노선 바뀜 때 계획고 옮김 = 바뀐 구간만 새 지반 첫 계획선 · 앞 그대로 · 뒤 길이 차만큼 밂(B05_Profile_Engine_Grade_Carry.py ↔ logic/B05_Profile_Grade_Carry.ts)
- 거울 시험 test_344_2_route_pairs(+ helper cjs) · buildCurves · evaluateAt 내보냄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 11:19:54 +09:00
eomsangdon fc3175e399 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into main_laptop_1 2026-10-16 11:17:44 +09:00
eomsangdon 40deca9ed9 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 11:16:26 +09:00
eomsangdonandClaude Opus 5.5 ca8c88f810 perf(횡단): 346-2 국부 연산 · [전체 측점 반영] 한 번 · 보이는 카드 먼저
- 횡단 다시 셈이 측점별 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)을 기억 — 같으면 앞 결과 그대로 · 계획고 ▲ 한 번 = 10 측점 중 2 측점만 다시 셈 · 결과는 통째 셈과 같음
- [전체 측점 반영] = 다시 셈 한 번(측점마다 66 × 66 돌던 것) · 지금 값을 「사용자 선택」으로 박는 동작 없앰(암 경계 다시 셈 포함 · 사용자 확정 10-16)
- 횡단 카드: 보이는 카드만 바로 갈아 끼움 · 화면 밖은 쉬는 틈에 몇 장씩(경고칩 · 면적 합은 계산값이라 그대로)
- 머묾 재현 · 국부 연산 · 흙 배분 열쇠 · 전체 반영 · 카드 시험(vite ssr 로 진짜 모듈 묶음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 11:16:19 +09:00
eomsangdonandClaude Opus 5.5 eac37456e2 chore(지침): 342-4 폴더별 CLAUDE.md 넷 — B04_PreProcess · B05_Profile · B06_Section · A00_Common/design
- 갈래 지도 · ① 짝 목록(시험) · 서버 Node 묶음 입구 · 끌려가는 파일 · 남의 페이지가 부르는 파일 · logic/ 규칙 · 금지
- 40줄 안 · 뿌리 CLAUDE.md 안 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:16:00 +09:00
eomsangdonandClaude Opus 5.5 48ddca36a2 fix(종횡단): 342-1 계획선 한 벌 · 남은 낡음 표시 · 흙 배분 열쇠
- 횡단 다시 셈이 화면이 쥔 선(측점 한 벌 · 관 지점 · 옮긴 꼭짓점)을 받음 — 종단도가 선을 맡기고 B06 진입 정합 · 3D [업데이트] 앞 다시 셈도 같은 선 · 낡음 판정도 그 선 기준(staleAgainstScreen)
- 다시 셈 뒤에도 낡은 측점이 남으면 유토곡선은 그리고 그 측점만 표시 · 콘솔 경고 한 줄 · 흙 배분도 건너뛰지 않음(09-03 「새 값만 보여주기」 바꿈 · 사용자 확정 10-16)
- 흙 배분: 실패한 열쇠 다시 보냄 · 받은 열쇠(누가토량 결과 전체)는 다시 안 물음 · 서버도 같은 열쇠 기억 · 묶음 실패는 500
- 옛 시험 셋 새 동작으로 맞춤 · 서버 열쇠 시험 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 11:15:22 +09:00
eomsangdonandClaude Opus 5.5 51cebefd40 feat(지형표본): 344-1 횡단 지반선 생성 짝 — B05 logic sectionsGround(종단 1 m · 측점 중심 · 횡단 ±20 m 0.5 m · 상단측) · groundProfile · groundAt(측점 지반고) · extendUnclosed(미교차 덧붙임) · 두 쪽 머리 짝 표시 · 거울 시험(서버 생성 · 저장된 종 · 횡단과 같음 · 노선 1,078 m 3.9 ms)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:14:46 +09:00
eomsangdonandClaude Opus 5.5 fba1eb8a5d perf(배수유역): 342-3 세부 유역 저장본 — 열쇠가 같으면 다시 나누지 않음(부록 4 R15)
- 열쇠 = 관 목록 · 노선(평면 · 종단 Z) · 분석 산출물(03 road_routing · 01 primary_region) · 강우표 · 구조물 정의 자국
- GET · POST · DELETE pipe-points = 메모리 최근 4벌 + 파일 한 벌(drainage/04_detail_cache.pkl · [저장] 때 GET 열쇠로 씀)
- [저장](PUT)은 늘 정본으로 다시 셈 · 관 목록은 사본으로 돌려줌
- 시험 test_342_3_detail_cache(같은 열쇠 재사용 · 입력 바뀌면 다시 셈 · 다시 켜도 파일로 읽힘)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 11:13:24 +09:00
eomsangdonandClaude Opus 5.5 f8fcb812d3 feat(종단보기): 345-2 3D → 큰 모달 — [3D] 단추 · 처음 열 때 지형 · 닫으면 숨긴 자리로 · 그리기 멈춤 · 떠날 때만 버림 · 3D 노선 점 옮김 · 지움 + 「선택 포인트 상세」 칸 지움
- 새 design_view_profile_3d_modal.ts(공용 openModal wide · 보기 조작 줄 · [3D 업데이트] · 3D 고름이 같은 노드로 따라감)
- 지형 올리기는 페이지 열기에서 빠짐 · 모달 처음 열 때(로딩 원) · 등고선 간격 바꿈도 그 값으로
- 시험: test_345_2_3d_modal 4 통과(닫힌 동안 그리기 0) · 칸 시험(parity · 311) 고쳐 씀 · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 11:11:10 +09:00
eomsangdonandClaude Opus 5.5 e503189cb8 feat(배수유역): 342-3 노선 · 관 자리 지문 — 노선이 분석 때와 다르면 세부 유역 안 나누고 「낡음」
- 분석 응답 · 저장분(00_watershed_response.json)에 route_signature · pipe_signature
- 세부 유역(GET · POST · DELETE · PUT pipe-points): 분석 지문 ≠ 지금 노선(옛 저장분 = 지문 없음) → 관 자리만 · basins 없음 · stale 까닭 글 · 관 목록도 없으면 409
- PUT 낡음 = 관 자리 · 속성만 정본 · 세부 유역 산출물(04_detailed_basins) 안 씀
- 응답에 지금 route_signature · pipe_signature · B05 배수유역 칸 = 낡음 글 표시(DetailBasinResponse 칸 셋)
- 시험 test_342_3_drainage_stale

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 11:10:49 +09:00
eomsangdonandClaude Opus 5.5 1fcdf5aa4c perf(영구저장): 343-2 도엽 3D 면 열쇠 · 지표면 모델 id 유지 · 비용면 메모리 기억 · 추천 노선 열쇠 · 횡단 상세 ETag
- 도엽 3D 면 — 결과 옆 dtm_sheet_{방식}.key.json(등고선 이름:mtime:크기 · 노선 · CRS · 격자 · 여유 · 스무딩 · 화면 상자 · ALGO) 같으면 건너뜀
- 지표면 모델 — (방식 · 필터 · 파일) + 파일 지문 같으면 행 id 살려 고쳐 씀 · 이번 결과에 없는 옛 행만 지움
- 비용면 · 계류 메모리 기억(비용면 서명 · 계류 파일 지문 · 좌표계) · 추천 열쇠 = 서명(배열 통째 해시 없앰)
- 추천 저장본 종류(갈래 · 지형)당 최근 2벌 · 꺼내면 최근으로
- 횡단 상세 GET — 측점 파일 안 지움(valid_cross_names) · 본문 해시 ETag 304
- 시험 test_343_2_persist_keys 13건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 11:10:13 +09:00
eomsangdonandClaude Opus 5.5 aac091dacc feat(B05 발자국): 346-2 평면 발자국 계산(순수) — B07 평면 비탈의 ① 짝
- B05_Profile/logic/B05_Profile_PlanFootprint.ts — 측점(중심 · 진행 방향) + 횡단 설계 결과 → 도로 끝 좌 · 우 · 비탈 끝 깎기 · 쌓기 · 눈금
- 짝 = B07_DesignDetail_Engine_Cad_PlanSlope.py(route_frame · side_is_cut · slope_end · slope_pieces) · 머리에 짝 표시
- 거울 시험 test_346_2_plan_footprint_mirror.py · 그리기는 2파(sub2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:10:05 +09:00
eomsangdonandClaude Opus 5.5 e8c6cfd1ea feat(지형표본): 344-1 격자 통로 · 노선 편집 지반고 브라우저 표본 — GET surface/models/{mid}/grid(원시 이진 · ETag · 첫 요청 때 .bin 저장본 · DTM 아니면 404) · terrain-sources 에 격자 받을 자리(manifest 서명) · IndexedDB 받기 · B05 미리 받기 한 줄 · elevations = 격자 있으면 브라우저 · 없으면 서버
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:08:17 +09:00
eomsangdonandClaude Opus 5.5 73a251db26 perf(342-2): [확정] · [저장] 서버 재계산 입력 지문 · 바뀐 행만 씀 · 행 읽기 한 번
- 지문 = Node 입력 전부 + 번들 판 · 같은 입력이면 Node 건너뜀(틀 = B07 덧그림 기억 · TS 가 번들보다 새로우면 버림)
- 측점 행 쓰기: 여러 벌을 읽기 한 번 · 바뀐 행만 한 문장(merge_cross_section_design_batches) · 앞 벌의 새 행을 뒤 벌이 찾음
- 종단 다시 읽기 지움(병렬 조회 한 번) · 유토곡선 같은 값이면 안 씀 · Node 결과 사본에 balloon 위치
- 시험 test_342_2_confirm_fingerprint

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 11:07:25 +09:00
eomsangdonandClaude Opus 5.5 2124bc85b9 fix(B05 배수유역): 342-3 첫 열기 세부 유역 요청 11번 → 1번 — 같은 틱 analyze 를 한 번으로
- B05_Profile_UI_Drainage_Pipes batched(같은 틱 부름 = 한 요청) · 배수유역 판 analyze 에 씌움
- 옛 소수 관 정수 맞춤(snapOldStations)이 관마다 부르던 POST detail-basins 가 한 번 · 계획고 따라가기 · 속성 이어받기 길은 그대로
- 시험 test_342_3_b05_detail_once(11번 부름 = 1번 · 다음 조작은 다시)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 11:06:47 +09:00
eomsangdonandClaude Opus 5.5 3d6c5584d1 fix(묶음): 342-4 서버 Node 묶음 낡음 판정 보강 — ui_template · A00_Common(b_page_state 등) · config 더함
- SOURCE_DIRS 에 ui_template · A00_Common(하위 폴더째 · node_modules 뺌) · SOURCE_GLOBS 에 config/*.ts
- 새 logic/ 하위 폴더 잡힘 · node_modules · CAD 앱은 안 봄 — 시험 test_342_4_bundle_stale.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:06:18 +09:00
eomsangdonandClaude Opus 5.5 8be42a302f feat(설계묶음): 344-4 계산 묶음 본체 · 가게 칸 — 차례 번호 · 옛 결과 버림 · 옛 요청 끊기 · 바뀐 범위 · 단계 시간 기록 · 결과 칸 메모리 우선
- design_pipeline.ts 새 — 단계 주입 · deps 판 같으면 건너뜀 · 단계마다 범위 쌓기(못 끝낸 단계는 합침) · follow 만 입력 한 번 고침 · 단계 · 알림 안 입력 쓰기 막음 · window.__aisloPipeline 20 번
- design_store — routeDraft(세션 즉시) · stations · profile · footprint(메모리 우선 · 멈춘 뒤 1 s 한 번) · generation(메모리만)
- b_page_state — onStateRemoved(키 지움 알림 · clearResults 때 메모리도 버림)
- 시험 test_344_4_design_pipeline 10 건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 11:04:38 +09:00
eomsangdonandClaude Opus 5.5 a2c512b30c chore(B05 정리): 342-4 B05_Profile/Old 27파일 지움 · 노선 거울 시험 resources/tester 로
- Old/ 부르는 곳 0(주석만) · tsconfig exclude 줄 정리 · typecheck 통과
- test_route_polyline_browser_mirror.py — tmp/tests 에서 옮김 · 빌드 자리 = pytest 임시 폴더 · 10 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:04:19 +09:00
eomsangdonandClaude Opus 5.5 5025313b29 perf(342-2): 노선 [확인] 체인 종횡단 생성 한 번 · 자동 체인 재계산 한 번 · 3D 길 모형 서버 짓기
- 노선 [확인] 체인: solve 안 첫 생성을 건너뛰고 관 정한 뒤 한 번만(건너뛴 생성의 옵션을 넘겨 입력 같음 · 배수유역 실패 때도 종횡단 세움)
- 자동 체인: 서버 재계산 · 3D 짓기를 [확정] 끝 한 번씩(재계산 → 짓기 차례)
- 3D 길 모형: B06 [저장] · [확정] 끝에 서버 Node 가 지음 · 브라우저 올림(PUT) 길 없앰 · 브라우저는 해시 맞으면 받기만 · 어긋나면 미리보기만
- 시험 test_342_2_chain_once · corridor router 시험 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 11:03:45 +09:00
eomsangdonandClaude Opus 5.5 b8ed082874 fix(배수유역): 342-3 관경 · 시설 자동 지정 끔 — 새 관 = 마스터 기본 · 추천은 응답 제안만
- _apply_recommendations 지움(시설 BOX암거 · 세월교 바꿈 · 관경 넣기) · 새 관은 _apply_new_defaults 가 구조물정의 배관 기본(1000)
- 응답 recommended_facility · recommended_diameter_mm 그대로(제안)
- 시험 test_b05_drainage_recommend 고쳐 씀(추천 얹지 않음 · 옛 관 그대로 · 마스터 기본 1000)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 11:03:37 +09:00
eomsangdonandClaude Opus 5.5 5d37c851f3 feat(지형표본): 344-1 지형 격자 표본기 짝 — TS heightAt(쌍선형 · 축 이진 탐색 · 네 칸 유효) · 서버 표본기 메모리 기억(경로 · mtime · 크기) · 격자 이진 꼴 · 거울 시험(서버와 차 0)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 11:03:34 +09:00
eomsangdonandClaude Opus 5.5 8eb5ba5450 refactor(종단보기): 345-1 design_view_profile 쪼갬 — 노선 · 종단면 그리기(_sections) · 3D 몫(_3d) · 상태 한 벌(ProfileViewState) · 동작 그대로
- design_view_profile.ts 700 → 517줄 · _sections 179 · _3d 114
- 시험: 관련 51벌 370 통과(이전과 같은 3 실패 = 205_1 · 더하기 글 · 손 안 댄 곳) · typecheck 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 11:02:18 +09:00
eomsangdonandClaude Opus 5.5 a22b72fb10 fix(B05 지반고): 342-4 지반고 값 갈림 둘 맞춤 — 노선 꼭짓점 z · 종단 미리보기 = 공용 1 m 쌍선형 표본기
- 노선 꼭짓점 z: 한 칸 값 · 빈칸 0.0 대신 build_surface_sampler · 빈칸 · 밖은 이웃 정점 누가거리 보간 · 모두 밖이면 멈춤
- 종단 미리보기 · [확인] 균형 목표: 2 m 비용면 대신 1 m 표본기(추천 점수 비용면은 그대로)
- 시험 test_342_4_ground_unify.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 11:01:38 +09:00
eomsangdonandClaude Opus 5.5 0efc77380e perf(배수유역): 342-3 빠른 길 — 흐름 방향 큰 격자 한 번 · 도로 닿음 판정 한꺼번에
- 하강 방향장 = 해석 격자 둘레 400 m 큰 격자로 한 번 짓고 회차마다 잘라 씀(crop_descent) · 넘으면 그때 다시 지음
- classify_flow 한 칸씩 걷기 → 포인터 더블링(_reach_by_doubling) · 기록용 출발 셀 수는 최외곽 · 나머지 셀 수로
- 거울 시험 test_342_3_watershed_fast(옛 걷기와 칸마다 같음 · 잘라 씀 안쪽 같음) · 실측 bench_342_watershed
- 검증_sub3 실측: 적색 차이 0칸 · 도로 귀속 · 강도 · 관 자리 같음 · 108 → 27 s(붐빈 랩탑 · 340 기준 약 15 s)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 11:01:28 +09:00
eomsangdonandClaude Opus 5.5 cf5de56418 perf(전처리): 343-1 같은 지형 파일 다시 올림 = 전처리 결과 살림 · 다시 돌 때 점군 안 풂 · 도엽 병합 · 국가 GIS · 타일 기억 열쇠
- B03 재올림 — 지형 파일이 source_identity.json 과 같으면 B04 폴더 · 지표면 DB 행 남기고 단계 표시만 되돌림
- 확인 창 「처음부터 다시」 따로 고름(fresh_start) · 409 응답에 preprocess_kept
- B04 다시 돎 — 점 수 · 평균 Z 를 structured_stats.json 에 · xyz 는 쓸 때만 풂
- 도엽 병합 열쇠 = 도엽 번호 목록 · 국가 GIS 열쇠 = 범위 · 좌표계
- 지도 타일 메모리 기억 열쇠에 mtime · 크기
- 시험 test_343_1_preprocess_keep 13건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 11:00:04 +09:00
eomsangdonandClaude Opus 5.5 5e8df73763 feat(설계묶음): 344-4 계산 묶음 계약 — 단계 입력 · 출력 · 측점 · 노선 초안 모양 · route-draft 캐시 칸 · 결과 칸 셋
- design_pipeline_types.ts 새 — 단계 차례 · 주인 · deps · 키별 모양(측점 · 지반 · 노선 초안 · 발자국)
- b_page_state 등록표 — route-draft(draft · project) · design-stations · design-profile · design-footprint(result)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 10:59:09 +09:00
eomsangdonandClaude Opus 5.5 2879f9bea9 chore(요구): 232 — 초기값 = 전처리 결과 · 추천 노선 바꾸면 그게 초기값 · 평면 · 종단 · 횡단 0.3 s 양방향 연동이면 배관 · 구조물 제안도 · 국부 연산 · 상세 계획서 · 브레인 수행 · 브라우저 검증 · 아티팩트
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-16 10:39:58 +09:00
eomsangdonandClaude Opus 5.5 dc90611b9c chore(조사): 341 실시간 연동 방향 검토 — 평면 · 종단 · 횡단 · 유토곡선 한 페이지 실시간 가능(점 놓고 종단 0.1 s 안) · 막는 배수유역 60 → 13 → 5.5 s · 3D 큰 모달 · 노선 편집 B05 본문 · 영구저장 29건 · 하위 폴더 조건부 · 부록 8
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-16 10:13:02 +09:00
eomsangdonandClaude Opus 5.5 9018ee4986 chore(요구): 231 — 실시간 연동 방향 검토(3D 모달 · 영구저장 · 노선 편집 B05 본문 · 노선 추천 컨테이너 · 평면 · 종단 · 횡단 한 페이지 실시간 · 관경 기본 1000 · 배수유역 자동/수동 · B04~B06 하위 폴더 · 분석 뒤 계획서 · 표 아티팩트)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-16 09:41:31 +09:00
eomsangdonandClaude Opus 5.5 b224c3d680 chore(조사): 340 계산 시간 측정 — 파일 입력 · 전처리 → 계획노선 · 종단 · 횡단 · 유토곡선 · 배수유역 화면에 보이기까지 · 막는 것 = 배수유역 큰 계산 60 s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-16 07:48:57 +09:00
eomsangdonandClaude Opus 5.5 7d94c49c1d chore(요구): 230 — 계산 시간 측정(파일 입력 · 전처리 → 계획노선 · 종단 · 횡단 · 유토곡선 · 배수유역 · 화면에 보이기까지 · 표 · 코드 수정 없음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-16 06:40:31 +09:00
eomsangdonandClaude Opus 5.5 94838450d8 fix(일위대가실무): 339 덧고침 4 — 우리 지문 build() 가 메모(표 · 표판)까지 갱신 · warm() 중복 갱신 줄 지움
- 빈 캐시 폴더에서 table() 이 None 을 먼저 메모하면 build() 뒤에도 ready() 거짓 · 맞춤 「맞음」 0 이던 흠
- 시험: 캐시 쓰기 읽기 준비 전 none 에 build 직후 ready() 참 · table() = build 결과 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 06:26:09 +09:00
eomsangdon a03e9e139b Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-16 01:33:37 +09:00
eomsangdonandClaude Opus 5.5 912575d9bb fix(일위대가실무): 339 덧고침 맞춤 셋 — 규격 다른 별칭 꼬리표만 · 같은 장비 여러 줄 품 비교 합침 · 근거 안 닿은 덧 로직 후보로
- 규격 다른 별칭(품명|단위 느슨 열쇠) = 꼬리표 「별칭 · 규격 다름」 · 짝은 후보 끝 덤 · 상태 · 옵션은 줄 숫자 맞춤
- 품 비교 같은 갈래 장비 여러 줄은 합쳐 견줌(서버 _qty · 화면 compareText)
- 조합 덧 로직 = 단위 맞음 + 조항 · 직종 · 이름 하나 닿을 때만 · 아니면 덧 후보 · 섞인 줄 그대로 · 맞춤셈 판 2
- 골든 사람 126 1순위 118 · 다섯 안 122 · 맞음 57 틀림 0 · 조합 62/83 그대로 · 338 대상 시험 142 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 01:32:53 +09:00
eomsangdonandClaude Opus 5.5 ca5485b208 fix(M02 실무자료): 339 덧고침 2 — 본 칸 호표 목록 제안 수 · 셈 중 표시 셈 끝까지 다시 읽음
- 본 칸 호표 표: 뒤 숫자 판정 셈 중이면 3 초마다 다시 읽어 「제안 n / 줄 m」 · 호표 줄 갱신(찾기 글 그대로)
- 머리 줄 「숫자 판정 셈 중 n/m」 표시 · 셈 끝 · 호표 하나로 감 · 못 읽음이면 멈춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 01:13:13 +09:00
eomsangdon 480a93ce1c Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-16 01:10:30 +09:00
eomsangdonandClaude Opus 5.5 7f33ffe2bd fix(M02 실무자료): 339 덧고침 — 확인 창 표 꼴 · 만들기 뒤 사전조합 목록 갱신 · 엑셀 보기 창 제안 수 다시 읽음
- 확인 창 줄 표: 한글 낱말 단위 줄바꿈 · 품명 · 규격 최소 폭 · 옵션 · 품 비교 항목마다 한 줄 · 단추 칸 세로 쌓음(좁게)
- 별칭 장부 표: 품명 · 규격 같은 손질
- 만들기(확인 창 · 엑셀 보기 창 일괄) 뒤 document 알림 → 좌측 「일위대가 사전조합」 목록 다시 읽음
- 엑셀 보기 창: 뒤 셈 중이면 3 초마다 목록 「제안 n/m」 다시 읽음 · 펼침 표 숫자 판정 칸 = 맞음 n · 다른 점 n
- 품 비교 판정 함수 · 서버 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 01:09:47 +09:00
eomsangdonandClaude Opus 5.5 d9cc66b5e9 fix(CAD): 339 덧고침 — CAD 시작 상태를 「명령:」 쉼(선택 도구)으로 · 선 명령으로 시작하던 것 고침 · 시작 도구 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 01:03:02 +09:00
eomsangdonandClaude Opus 5.5 933c4c5d71 refactor(CAD): 337-6 가 끝 몫 — 옛 OFFSET · LENGTHEN · 옛 꼴 통과 지움 · ALL · L · P 실제 고름 시험 · 안내 글
- transform-acad: 고르기 물음 ALL · L · P = 「n개를 찾음, 총 m개」 · 다시 「객체 선택:」 · 빈 Enter = 고르기 끝(설계 1-4 · 2-15)
- transform-tools: 안 쓰는 옛 OFFSET · LENGTHEN 머신 지움(새 offset-tool · lengthen-tool 이 대신)
- prompt-input · prompt · sequence-tool: 옛 꼴 통과(meta.options 글 배열 · TEXT_INPUT 대문자 통과 · chainFromLastPoint) 지움
- prompt-contract: 옛 꼴 안 쓰는지 지키는 시험 더함
- 안내 글(설계 4 장 끝): 오른쪽 단추 · 휠 두 번 · CAD 키 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 00:34:02 +09:00
eomsangdon 746c70fcce Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-16 00:26:36 +09:00
eomsangdon 15a270939e Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-10-16 00:26:22 +09:00
eomsangdon 0063e1866f Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-16 00:26:21 +09:00
eomsangdon 2a9418f50b Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-16 00:26:20 +09:00
eomsangdonandClaude Opus 5.5 4ff989c125 feat(일위대가실무): 338-4 이음매 — 확인 화면 ↔ 실물 맞춤 응답 · guess 길 목록 · 끝까지 시험 · 이음매 기준 새 뜻
- 길 — guess 몸 대상 list | dict · to = 만들 층
- 줄 맞춤 — 섞인 줄 1위 대상에 몫 · 배(고임돌 GF000211 × 0.1) · 장비 품 비교 열쇠 「+」(화면과 같음) · guess 가 사용자 배 그대로
- 화면 — 품 비교 장비 표시 열쇠를 갈래 묶음 이름으로
- 시험 — client 고정물 맞춤셈 캐시 = 임시 폴더 · 뒤 셈 안 돌림(tmp/m02_hopyo 안 남음 · 결과 흔들림 없음) · 끝까지(북부청 올림 → 셈 → 보기 → 화면 몸 → 만들기 → 장부 → 다시 올림 별칭 · 몸 없음 400 · hopyo-memory 404 · 영월 STmate) · 실물 대역 화면 시험 · 이음매 268 줄 맞음 122 · 다른 점 86 · 후보 60 · 후보 1순위 근거 맞음 123 → 9
- 응답 대역 JSON 실물로 다시 뽑음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 00:25:51 +09:00
eomsangdonandClaude Opus 5.5 1d5cdff0a5 feat(CAD): 337-5 라 옮기기 무리 2 단계 — COPY · MIRROR · STRETCH · ARRAY · PEDIT 고르기
- COPY: 기준점 [변위(D)/모드(O)/다중(M)] · 두 번째 점 [배열(A)] → [배열(A)/종료(E)/명령 취소(U)] <종료> 되풀이 · 첫 빈 Enter = 변위 · COPYMODE 기억 · A 항목 수 · F 맞춤
- MIRROR: 대칭선 두 점 → 원본 객체를 지우시겠습니까? [예(Y)/아니오(N)] <아니오> · Esc = 안 만듦
- STRETCH: MOVE 틀 + 걸침 창 안 꼭짓점만(선 · 폴리선 토막 · 사각형) · 창 안 통째 · 하나씩 고른 것 = 이동 · STRETCH.DISP
- ARRAY: 유형 [직사각형(R)/원형(PO)] <직사각형>(ARRAYTYPE) → 편집 물음 되풀이(COU · S · COL · R · X / I · A · F · ROT · X) · 미리보기 4 × 3 · 6 개 360° · 처음 간격 범위 1.5 배 · Esc = 안 만듦(N17 없어짐)
- PEDIT: 공용 고르기 상태(객체 선택 · 빈 Enter · Esc = 끝 · 묶은 뒤 끝)
- 시험 transform-acad 29 + ALL · L · P 1(선택 도구 처리 뒤 돎)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 00:25:20 +09:00
eomsangdon 26b6d3ae0d Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_4 2026-10-16 00:17:55 +09:00
eomsangdonandClaude Opus 5.5 d35bdfa69d feat(CAD): 337-5 라 옮기기 무리 1 단계 — MOVE · ROTATE · SCALE 오토캐드 물음 차례
- MOVE: 기준점 [변위(D)] <변위> · 두 번째 점 <첫 번째 점을 변위로 사용> · 변위 기억(MOVE.DISP) · 한 번 뒤 끝 · Esc 제자리 끝
- ROTATE · SCALE: 공용 틀(pivot-tool) — 누름 한 번 = 값 · 복사(C) · 참조(R) → 새 값 [점(P)] · 각 · 비율 기억 · 0 이하 축척 거절
- 공용 고르기 상태(transform.helpers) — PICKFIRST · 빈 고르기 Enter = 끝 · OPTION.* 넘김 · 원본은 확정까지 도면에 그대로(N1 · N2)
- 시험 transform-acad(15) · scale-tool 단위 시험 새 상태 이름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-16 00:17:16 +09:00
eomsangdon 7ab4c3263f Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-16 00:16:51 +09:00
eomsangdonandClaude Opus 5.5 3cebb37ed0 feat(CAD): 337-3a 나-1 그리기 8 — 오토캐드 물음 차례 · 옵션 · 기본값 · 빈 Enter · Esc · 끝난 뒤
- LINE: 「첫 번째 점 지정」 빈 Enter = 마지막 끝점에서 이음 · U = 마지막 토막(되돌림 한 칸) · 둘째 토막부터 C · Enter · Esc = 끝(토막 남음)
- PLINE(새 pline-tool): 호(A) 접선 호 · 길이(L) · 닫기(C · 호 모드 CL) · 명령 취소(U) · 선(L) · Esc · Enter = 그린 데까지 남김
- RECTANG: 치수(D) 길이 · 폭 기억(RECT.L · RECT.W) → 누른 쪽 사분면 · 없는 옵션 안내
- CIRCLE: 3P · 2P · 지름(D) <2r> · 반지름 기억(CIRCLERAD · 처음 <> 없음) · 한 번 뒤 끝
- POLYGON(새 polygon-tool): 변 수 기억 <4> · 3~1024 정수 · 모서리(E) · 내접/외접 기억 · 숫자 = 아래 변 수평
- ARC(새 arc-tool): 세 점 · 중심(C) · 각도(A) · 끝(E) · 반지름(R)
- ELLIPSE(새 ellipse-tool): 축 두 끝 · 중심(C) · 회전(R) · 다른 축 숫자 = 중심에서 반 길이(N10)
- SPLINE: 다음 점 입력 · U · 넷째 점부터 C · Esc 버림 · DONUT 중심 되풀이(loopFrom)
- 시험 draw-acad 22 · keyboard-chain 옛 선 · 폴리선 줄 두 곳 새 동작으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-16 00:16:15 +09:00
eomsangdon c5f16fd4fa Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 00:16:05 +09:00
eomsangdonandClaude Opus 5.5 703c89c617 fix(B07 CAD): 337-2 가-2 합침 어긋남 — ZOOM onText 답을 가-2 settle(sub, result) 꼴로
- prompt-input.ts routeTyped 덧물음 onText 답 = settle(그 덧물음, 답) — 옛 settle(답) 한 인자로 남아 타입 오류 · 「'Z 2X」 뒤 덧물음이 안 닫혀 다음 누름(스냅 메뉴 시험)을 먹던 것
- 가-2 base · 스냅 이름 · FROM · M2P 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 00:15:37 +09:00
eomsangdonandClaude Opus 5.5 c15be5116a feat(B07 CAD): 337-4b 다-2 늘이기 · 재기 · 치수 오토캐드 조작 — LENGTHEN · DIVIDE · MEASURE · DIST · DIM 류
- LENGTHEN 새 도구(lengthen-tool.ts) — 측정할 객체 [증분(DE)/퍼센트(P)/전체(T)] <마지막 방식> · 누름 = 현재 길이 알림 · 방식별 값 기억 · 변경할 객체 되풀이 · U = 이 명령 안 바꿈만 · 빈 Enter · Esc 끝 · 선 · 호
- DIVIDE · MEASURE — 기본값 없음 · 정수 2~32767 · 양수 · 블록(B) 없음 안내 · MEASURE 누른 끝부터
- DIST — 오토캐드 꼴 두 줄 기록(거리 · 각도 · 증분) · 다중 점(M) 없음 안내
- DIMLINEAR · DIMALIGNED — 빈 Enter = 객체 선택 · [문자(T)/수평(H)/수직(V)] · 치수 문자 = 기록 · 치수 객체 문자 바꿈 칸(<> = 잰 값)
- DIMCONTINUE · DIMBASELINE — 되풀이 · 명령 취소(U) · 빈 Enter 끝 · DIMRADIUS · DIMDIAMETER [문자(T)] · 그 밖 치수 물음 글
- 시험 measure-acad 11

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-16 00:15:35 +09:00
eomsangdon eff5fa2505 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-16 00:15:12 +09:00
eomsangdonandClaude Opus 5.5 00a379595b feat(CAD): 337-4a 다-1 고치기 7 — OFFSET · FILLET · CHAMFER · TRIM · EXTEND · BREAK · JOIN 오토캐드 물음 차례 · 옵션 · 기본값 기억 · 빈 Enter · Esc · 끝난 뒤
- OFFSET 새 파일(offset-tool.ts) — 거리 <통과점> · 통과점(T) · 지우기(E) 예/아니오 · 객체 → 면의 점 되풀이 · 다중(M) <다음 객체> · 명령 취소(U) · 기억 OFFSETDIST · OFFSETERASE
- FILLET · CHAMFER 한 꼴 — 객체 먼저 · 반지름(R) · 거리(D) 둘(둘째 기본 = 첫째) · 자르기(T) 모드 · 다중(M) · 명령 취소(U) · Shift = 구석 · 기억 FILLETRAD · CHAMFERA/B · TRIMMODE
- TRIM · EXTEND 빠른 모드 한 꼴 — 되풀이 · Shift = 반대 일 · 집기 반경 밖 무시 · 지우기(R) · 숨은 U · 사각형 변 원래 층 · 꼴(N9) · 자른 선 꼴 유지
- BREAK — 누른 점 = 첫 끊기점 · 첫 번째 점(F) · @0,0 = 같은 점 · JOIN 물음 글
- 시험 resources/tester/cad/modify-acad.test.ts 13

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-16 00:14:29 +09:00
eomsangdon 4a7ad0f159 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-16 00:12:14 +09:00
eomsangdonandClaude Opus 5.5 66279024d8 feat(B07 CAD): 337-2 마-2 ZOOM 덧물음 · Shift+우클릭 스냅 메뉴 · 고르기 ALL/L/P · 그립 종료(X) · ↶↷ 끊고 되돌림 · snapped 캐스트 지움
- ZOOM(Z): 「윈도우 구석 지정, 축척 비율 (nX 또는 nXP) 입력 또는 [전체(A)/범위(E)/이전(P)/윈도우(W)] <실시간>:」 덧물음 · E · A = 범위 · P = 앞 화면 · W · 누름 = 두 구석 윈도우 · 2X = 배율 곱 · 빈 Enter = 실시간 안내 · 중심 · 동적 · 축척 · 객체 = 없음 안내 · 'Z 투명 · ⛶ 단추 = 바로 범위
- 약속 더함: SubPrompt.onText(꼴이 정해지지 않은 글 · nX) — routeTyped 가 덧물음에 먼저 물음 · undefined = 사다리 그대로(브레인 알림)
- Shift+우클릭: 점 물음에서만 한 번 스냅 메뉴(시작점에서 · 두 점 사이의 중간 · 끝점 · 중간점 · 교차점 · 중심 · 사분점 · 수직 · 노드 · 근처점 · 없음) → 고른 이름을 친 것과 같은 길 · Esc · 바깥 누름 = 메뉴만 닫음(ic/snap-menu.ts)
- 고르기 숨은 키워드 ALL · L · P(선택 도구 첫 점 대기 · 쉼 · 명령 안 자식) — 앞 고름에 더함 · 「n개를 찾음, 총 m개」
- 그립 물음 새 꼴 「** 신축 ** 신축점 지정 또는 [종료(X)]:」 · X = 놓음 · 기준점(B) · 복사(C) · 명령 취소(U) 없음 안내
- ↶ ↷ 단추 = undoCommand · redoCommand(끊고 되돌림) · 우클릭 = 명령 중 Enter · 쉴 때 반복(가-1 길 · 시험)
- 시험: mouse-acad 마-2 더함(21)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-16 00:11:53 +09:00
eomsangdon 990ce104d9 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-16 00:11:27 +09:00
eomsangdonandClaude Opus 5.5 b384fa0660 feat(CAD): 337-3b 나-2 구성선 · 글자 · 해치 — XLINE · TEXT · MTEXT · HATCH 오토캐드 물음 차례
- XLINE 「점 지정 또는 [수평(H)/수직(V)/각도(A)]:」 → 「통과점을 지정:」 되풀이 · 각도 기억(XLINE.ANGLE) · 이등분 · 간격띄우기 = 없는 옵션 안내 · 빈 Enter · Esc = 끝(그은 선 남음)
- RAY 통과점 되풀이(loopFrom)
- TEXT 시작점 → 높이 <TEXTSIZE> → 회전 각 <TEXT.ANGLE> → 줄마다 「문자 입력:」(Enter = 아래 새 줄 · 빈 줄 = 끝) · 시작점 Enter = 마지막 글 아래 이어 씀(높이 · 각 안 물음)
- MTEXT 첫 구석 → 「반대 구석 지정 또는 [높이(H)/폭(W)]:」 → 글(\P 줄바꿈) · 상자 왼쪽 위에서 · 줄 감기 없음
- HATCH 「내부 점 선택 또는 [객체 선택(S)/명령 취소(U)]:」 되풀이 · 품은 닫힌 객체 중 가장 작은 경계 · 빈 Enter = 만듦 · Esc = 안 만듦 · 손 머신(엔진은 직교가 둘째 내부 점을 끌어감)
- 시험 resources/tester/cad/text-acad.test.ts 12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-16 00:10:42 +09:00
eomsangdon 49b6ab871c Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-16 00:06:06 +09:00
eomsangdon e81ec1c1e2 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-16 00:05:29 +09:00
eomsangdonandClaude Opus 5.5 5762bee9f1 test(일위대가실무): 338-1 뒤 셈 시험 — 이 시험만의 일감 줄 · 일꾼(앞 시험 올리기가 쌓은 북부청 통째 셈을 안 기다림 · 메인 합침에서 60 초 넘던 것)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 00:05:23 +09:00
eomsangdonandClaude Opus 5.5 8661501dae feat(CAD): 337-1 가-2 점 물음 보조 · ↑↓ 기록 · Tab 후보 — FROM · M2P · @ · # · * · 스냅 이름 늘림 · H11
- 새 inputController/input-history.ts — 쉴 때 = 친 명령 기록 · 명령 중 = 그 물음 최근 값 20 · 알림 줄 안 섞음(H11) · 후보 돌림
- 새 inputController/point-modifiers.ts — FROM(기준점 → <간격띄우기>) · M2P/MTP · @ = 마지막 점 · #·* 절대 좌표 · 스냅 이름
- 스냅 이름 — 세 글자 ~ 영문 전체(END · ENDP · ENDPOINT) · QUA · NOD 더함 · TAN · EXT · INS · APP · PAR · GCE 는 「이 CAD 에 없음」 안내(N14)
- 사다리 — 기준점 없는 첫 점 @dx,dy = 마지막 점 기준 · 옵션 영문 전체 딱 맞음은 옵션 먼저(CENTER) · 줄임은 스냅(CEN) · 덧물음 base
- ↑↓ = 기록 · 후보 · Shift+↑↓ = 화면 옮김 · Tab(쉼 + 친 글) = 후보 돌림 · 명령행도 같은 함수
- 시험 — prompt-frame 가-2 8 · keyboard-chain PLINE 첫 점 줄 손질

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-16 00:04:30 +09:00
eomsangdon b82ea6edcf Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_7 2026-10-16 00:01:44 +09:00
eomsangdon 8ef7bf0e5c Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8 2026-10-16 00:01:11 +09:00
eomsangdon 7c35341002 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_4 2026-10-16 00:01:08 +09:00
eomsangdonandClaude Opus 5.5 e6cdcb2952 feat(일위대가실무): 338-1 맞춤 핵심 서버 2 단계 — 줄 새 상태 · 체크 제안 · 대상 목록 · 품 비교 · 호표 통째 · 셈 · 만든 · guess 목록 + 골든 시험
- 새 Line — 상태 차례(% · 호표 · 노임 · 장부 뺌 · 별칭 · 맞춤셈 판정 · 이름 근거 후보) · 체크 제안(맞음 · 별칭 규칙 셋 · 확인된 하위 호표) · 대상(입력값 출처 별칭 > 블록 > 규격 · 환산 · 몫 · 배) · 품 비교 · 직접 줄 호표 통째 · guess(대상 목록 · 낱개도 받음 · 엔진 한 번)
- Match — view(to = 만들 층 · 셈) · picks · guess 를 Line 으로 · 옛 층 기억 · confirm · 들어감 없앰 · b_order 후보에 근거 칸
- Verdict — 위 다섯 딱 맞음 먼저(조합 없는 줄) · 후보 근거 = 근거 맞춤 꼴 · 지문차례(조합 전)
- Fit — 맞춤셈 캐시 이름에 셈 판(CALC_VERSION)
- 골든 시험(느림 · 1 분 54 초) — 사람 126 1순위 118 · 다섯 안 122 · 맞음 57 틀림 0 · 다른 점 틀림 1 · 옵션까지 57 · 조합 맞음 62/83 · 새 파일 51 맞음 틀림 2 · 씨앗 끔 · 북부청 조합 전 통과
- 근거 시험(나 창) 세 곳 — 옛 match_line → b_order(새 API)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-16 00:00:56 +09:00
eomsangdonandClaude Opus 5.5 6e346cfb5e Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 — 337 가-1 옵션 틀 · 마-1 import 겹침 풂(안 쓰는 import 셋 뺌)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 23:58:14 +09:00
eomsangdonandClaude Opus 5.5 83e246deb4 feat(CAD): 337-1 가-1 옵션 틀 — 물음 꼴 · 친 글 사다리 한 곳 · 명령 끝 → 쉼 · 순서 엔진 v2 · 키보드 흠
- 새 src/commands/prompt.ts — PromptSpec · renderPrompt(키워드 물음은 「또는」 없음) · matchOption(initget 줄임) · 세션 기억 · OPT · SELECT_OBJECTS
- 새 inputController/prompt-input.ts — routeTyped 사다리(점 · 옵션 묻는 중 명령 이름 거절 · 식 · 단위 거절 · 투명 ') · 알림 줄 · 덧물음 · cancelActiveCommand · undoCommand
- 새 tools/factories/end-command.ts — final(END) 갈고리 → 같은 틱 선택 도구 · state.ts 갈고리 · LASTPOINT
- sequence-tool v2 — ROUTE · id · prompt · when · next · options · keyword · loopFrom · escCommits · pickfirst · 확정 뒤 기본 끝 · Esc 늘 끝
- 흠 — N1 ②③ · N5 · N6 · N11 · N18(앞글자 실행 없앰) · H1(Ctrl · Alt 글자 안 쌓음 · Ctrl+C · X · Shift+C · Shift+V · 1) · 우클릭 = 쉴 때 반복 · SE 뺌 · ED 더함
- 시험 — cad-drive 도우미 · prompt-frame · prompt-contract · 옛 시험 손질(keyboard-chain · ime-sink · command-catalog · input-prefs-cad · eraser 녹화)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 23:55:58 +09:00
eomsangdon ffaf66d4d6 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-15 23:52:36 +09:00
eomsangdonandClaude Opus 5.5 908923c23e test(일위대가실무): 338-1 맞춤 응답 대역 JSON — 실물 북부청 임도 제1 · 4호표 파일 보기 · 호표 보기(상태 · 체크 제안 · 대상 · 후보 · 품 비교) · guess(설계 3-3 · 3-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 23:51:59 +09:00
eomsangdon fbfc49c2fa Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-15 23:42:08 +09:00
eomsangdonandClaude Opus 5.5 db8462c6d4 feat(B07 CAD): 337-2 마-1 오토캐드 마우스 — 누름 더함 · Shift 뺌 · 상자 더함/뺌 · 명령 중 끌기 상자 · 스침 · 그립 누르고-옮기고-누르기 · 그립 숫자 · 보기 전용 문 · 직교 강제 · Shift 임시 직교 · 휠 기본 밂 · 휠 두 번 범위
- 고르기: 누름 = 더함 · Shift = 뺌 · Ctrl = 넣고 빼기 · 상자 = 앞 고름 ∪ 상자 · Shift 상자 = 뺌(H4 · H5) · 명령 안 고르기 단계도 누를 때 첫 점 · 스침 강조(ic/selecting.ts)
- 그립: 쉴 때만 집음(N7) · 누르고 떼면 대기 → 다음 누름에 놓음 · 끌어 놓기(H3) · 거리 숫자(N8) · 확정 · 읽기 전용 도면은 그립 · Delete 막음(H2)
- 직교: F8 = 기준점에서 가로 · 세로 강제 · Shift 누른 동안 뒤집음 · 객체 스냅 점 · 좌표 · 각도 잠금은 안 덮음 · 클릭 사건 snapped 채움(ic/ortho.ts · H8) · 가이드는 F3 와 뗌 · 가이드 잡는 거리 = 화면 픽셀(H9)
- 휠: 기본 밂(앞으로 굴리면 확대 · 오토캐드 · 모든 화면 · ☰ 설정 뒤집기 그대로) · 가운데 단추 두 번 = 범위 줌 · ZOOM · ⛶ 앞 화면 기억(ic/zoom-prompt.ts)
- 시험: mouse-acad 새로 · 휠 방향 시험 손질(input-wheel · input-prefs-cad · input_prefs · app_settings · 164 · 220_1 · 221_1 · 244_8 기대값 뒤집음 · 266 pull 박음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 23:41:46 +09:00
eomsangdon 638b5422d2 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-15 23:38:34 +09:00
eomsangdonandClaude Opus 5.5 01d3b0d2f2 feat(M02): 338-3c 확인 창 · 고르기 창 · 별칭 장부 화면 — 체크한 줄만 [확인하고 사전조합 만들기] · 옛 즉시 확정 · 바로 만들기 없앰
- 새 Hopyo_Check(확인 창 · 본 칸 · 모달) — 상태 색 · 체크 제안 · 우리 로직 여럿 · 배 · 몫 · 옵션 · 품 비교 · 뺌 · 원문 보기 · 캐시 m02-hopyo-check · 셈 중 3 초 다시 읽음 · 최신 응답만
- 새 Hopyo_Choose(고르기 창 · 후보 판정 · 여럿 고름 · 몫 · 찾기 글 미리 넣음) · 새 Hopyo_Ledger(장부 화면 · 고친 것 캐시 · [저장] 때만 PUT)
- 고침 View(호표 하나 = 확인 창) · Sheet(펼침 뒤 한 번 · 하위 호표 먼저) · Pick(확인된 것 바로 참조 · 아니면 확인 창) · Api(3-3 꼴 · 만들기 몸 · 장부 길) · Side([별칭 장부]) · css · Labor_Pick(query)
- 없앰 saveHopyoMemory · [확정] · [같음 모두 확정] · [근거 맞음 모두 확정] · 바로 만들기
- 시험 test_m02_hopyo_screen.py 새로 · helper_m02_hopyo_check.cjs 새 · pick · seam 도우미 손질

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 23:37:24 +09:00
eomsangdon 146831fdba Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-15 23:36:37 +09:00
eomsangdonandClaude Opus 5.5 e9e949112c feat(일위대가실무): 338-1 맞춤 핵심 서버 1 단계 — 엑셀 지문 · 우리 로직 지문 캐시 · 옵션 거꾸로 · 순위 · 판정 · 조합 · 뒤 셈 + 정답 표
- 새 파일 — Words(식 풀기 · 장비 갈래 · 조건 낱말) · Finger(블록 글 · STmate 자원 → 지문 · 기계 줄 · 직접 줄 호표) · Shares(절 몫 벡터) · Ours(마스터 판 열쇠 · 우리 지문 캐시 tmp/m02_hopyo · 엔진 셈 캐시 · 자재 값 찾기 건너뜀) · Option(옵션 공간 · 거꾸로 찾기 · 견줌) · Fit(손잡이 한 곳 · 순위 · 뒤 셈 · 맞춤셈 캐시) · Combine(조합 · 섞인 줄 몫 먼저) · Verdict(블록 셈 · 맞음 / 다른 점 / 후보)
- Match — b_order(이름 · 근거 차례 · 같음 근거 검사 · 옛 판 글 · 조항 오타) · clause(윗마디 0.9 · 나 창 Basis.clause 있으면 그것) · 이름 후보 로직은 접지 않음
- 정답 표 fixtures/m02_hopyo_golden.json(사람 136 · 새 파일 51) · 골든 도우미
- 북부청 92 줄 실측 — 1순위 87 · 다섯 안 91 · 맞음 41 틀림 0 · 다른 점 28 틀림 0 · 조합 맞음 57/66 · 우리 지문 셈 37 초 · 캐시 읽기 0.05 초

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 23:36:23 +09:00
eomsangdon b647be18e5 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-15 23:31:36 +09:00
eomsangdonandClaude Opus 5.5 75b8815dac test(M02 일위대가): 338-2c 영월 정답 자리 y_ids — D · X 고유 줄 36(Y000~Y035) 실험 C1 차례와 같음 · 골든 시험용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 23:30:44 +09:00
eomsangdon 6261529017 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-15 23:29:39 +09:00
eomsangdonandClaude Opus 5.5 193e03927b feat(M02 일위대가): 338-2b STmate 꼴 읽기 · 거창 흩어진 칸 이음 · 이음 넓힘 · 노임 갈래 · 문서 보안 400 · STmate 격자
- 새 M02_Hopyo_Stmate — 일위대가표 · 단가산출근거 · 목록표 → 엑셀꼴과 같은 HP(줄 코드 = M~Z 열 코드 꼴 첫 것 · 블록 글 · 자원 품 · 절 · 출처 · 직종 · 호표 근거글)
- 여섯 파일 올라감 — 봉화 45 · 영월 23 · 울진대흥 설계 · 착공 34 · 영덕 9 · 울진소광 25 호표 · 노임 이름 어긋남 0
- Basis.read 거창 꼴 — 빈 산출근거 칸은 오른쪽 칸 이어 붙임 · 돈 머리 맨 오른쪽 묶음 · 단산 줄 21 · 출처 17 · 직종 21
- Excel 이음 넓힘(단산 · 산근 · 단가산출 / 일위 · 대가 · 일위대가 N · 참조 · 호표) · 빈 비고 줄 이름으로 이음 · 노임 갈래 · DRM 400 글(장수 · 진안)
- Grid STmate 시트 · 숨은 칸 글 뺌
- 시험 stmate · excel · basis · grid · match · build 68 통과 · 원본 엑셀 · master_data sha256 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 23:28:57 +09:00
eomsangdonandClaude Opus 5.5 33207a450c chore(조사): 337 내장 CAD 오토캐드 조작 설계 — 옵션 틀 약속 · 명령 31 프롬프트 사전 · 흠 23 · 마우스 · 휠 · 창 다섯 · 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 23:25:05 +09:00
eomsangdon ef76443fab Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-15 23:21:40 +09:00
eomsangdonandClaude Opus 5.5 bf5d130e32 feat(M02): 338-3b 확인한 줄만 사전조합 — Build 새 몸 · 재검사 · 별칭 장부 씀 · 장부 길 · 옛 즉시 확정 길 없앰
- M02_Hopyo_Build — 몸 줄만(없으면 400) · 대상 여럿 같은 별칭 · 배 · 몫 · 통째 · 하위 호표 확인 전 409 · 본뜬 원본 · 확인 · 같은 본뜬 덮음 · confirmed(folder, source)
- M02_Labor_Router — hopyo-build 엄격 몸(extra forbid) · hopyo-ledger GET · PUT · hopyo-memory 없앰 · picks 확인 · 올린 뒤 Fit.ensure · 시동 Ours.warm(있을 때만) · ?to= 만들 층
- M02_Hopyo_Ledger — times_of 나눔
- 시험 test_m02_hopyo_build.py 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 23:21:09 +09:00
eomsangdon 3f2c0583a2 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-15 23:16:12 +09:00
eomsangdonandClaude Opus 5.5 ea21006352 feat(M02): 338-3a 별칭 장부 뼈대 — 열쇠 · 층 차례 찾기 · 느슨 찾기 · 옛 꼴 바꿔 읽음 · 재검사 · 쓰기
- 새 M02_Hopyo_Ledger.py — key · hopyo_key · recall(같은 층 → 위 층 · 품명|단위 느슨 = 규격다름) · books · rows · check · write · put
- 옛 기억 줄(로직|자재|빼기 · 환산 · 확정) = 새 꼴(갈래 짝 · 짝 [{… 배}] · 빼기 = 안 씀)로 읽음 · 쓸 때 새 꼴
- 시험 test_m02_hopyo_ledger.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 23:15:47 +09:00
eomsangdonandClaude Opus 5.5 2286031266 feat(M02 일위대가): 338-2a 단가산출 블록 글 · 출처 · 직종 HP 저장 · 꼴 칸 · Basis.clause 윗마디 0.9 · 조항 오타 13-114
- 단산 블록마다 글(머리 뺀 몸 줄 · 200 줄 · 300 자 안) · 출처 · 직종 → HP 단산 · 꼴 「엑셀꼴」
- Basis.clause 공개 — 같음 1 · 윗마디 0.9(엑셀 6-2 ⊃ 우리 6-2-2) · 앞 마디 0.5 · 근거 맞음에 윗마디 포함
- Basis.sources 조항 오타 「13-114」 → 13-11-4
- 북부청 단산 268 · 사방 139 이음 그대로 · 시험 basis · excel · grid · match 51 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 23:14:23 +09:00
eomsangdonandClaude Opus 5.5 7a8f334520 chore(조사): 338 외부 일위대가 맞춤 설계 — 연결된 단가산출 블록 → 우리 로직 + 옵션 → 일위대가 구성 · 창 셋 · 시험 · 기준 수치 + 실험 자료 사본
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 23:08:43 +09:00
eomsangdonandClaude Opus 5.5 8a4598c051 chore(요구): 229 — CAD 명령 · 옵션 조작을 오토캐드와 같게 구현 · 외부 일위대가 = 연결된 단가산출로 우리 로직 맞춤 → 일위대가 구성 구현 · 끝에 브레인 브라우저 검증 + 아티팩트
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 22:39:05 +09:00
eomsangdonandClaude Opus 5.5 43f3fa923b chore(조사): 336 외부 일위대가 목적 다시 세움 — 우리 사전조합(로직 + 옵션 + 수량)으로 옮김 · 확인한 줄만 · 별칭 장부 · 옵션 거꾸로 찾기 실측 · 묶음 A~F · 기본안 다섯
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 21:28:33 +09:00
eomsangdonandClaude Opus 5.5 3656ebcdf0 chore(조사): 335 다시 조사 — 오토캐드 마우스 · 키보드 조작 146 대 내장 CAD(있음 26 · 일부 69 · 없음 51) · 흠 30 · 묶음 일곱 · 정할 것 24
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 21:02:08 +09:00
eomsangdonandClaude Opus 5.5 9c0ef7f827 chore(요구): 228 — 엑셀 일위대가 = 우리 로직에 옵션을 넣고 나온 값일 확률 높음 · 코덱스 세션 다시 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 20:02:18 +09:00
eomsangdon 8e0bd24853 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-15 19:56:26 +09:00
eomsangdonandClaude Opus 5.5 eba998b585 chore(요구): 227 — CAD 입력 조사 범위 바로잡음(오토캐드 마우스 · 키보드 숫자 · 형상 입력) · 외부 일위대가 목적 다시 고민(별칭 · 사용자 확인)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 19:56:06 +09:00
eomsangdon c267196139 Merge remote-tracking branch 'origin/dev' into CODEX 2026-10-15 19:40:05 +09:00
eomsangdonandClaude Opus 5.5 546db1ab11 chore(조사): 336 외부 일위대가 맞춤 재검토 — 로직 조합 기준 · 원본 로직 안 고침 · 1순위 71 → 93% · 조합 맞음 10 → 76% · 묶음 여섯 · 정할 것 다시 정리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 19:22:57 +09:00
eomsangdonandClaude Opus 5.5 42e4aa70ca chore(조사): 335 CAD 입력 보강 재조사 — 코덱스 · 안티그래비티 · 브레인 바깥 조사 53 줄 · 새 흠 H13 · H14 · 묶음 ⑧~⑩ · 정할 것 15~23
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 18:38:06 +09:00
eomsangdonandClaude Opus 5.5 b7e7278630 chore(요구): 226 — CAD 입력 보강 바깥 조사 넓힘(코덱스 · 안티그래비티 · 브레인) · 외부 일위대가 로직 조합 맞춤 재조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 17:33:08 +09:00
eomsangdonandClaude Opus 5.5 5bf5e0c46e chore(조사): 336 외부 일위대가 이름 맞춤 개선 상세 검토 — 실측 세 파일 · 묶음 넷 · 정할 것 · 부록 둘
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 16:24:23 +09:00
eomsangdonandClaude Opus 5.5 04021d1f73 chore(조사): 335 CAD 입력 보강 상세 검토 — 오토캐드 71 가지 대 우리 · 흠 12 · 묶음 일곱 · 부록 둘
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 16:13:03 +09:00
eomsangdonandClaude Opus 5.5 26fcebeb46 feat(설정): 334-2 ☰ 시스템 설정 4 페이지 바로가기 — 🏠 대시보드 + 카드 · 지금 칸 강조 · M02 → M02 이동은 떠나기 물음 먼저
- 새 A00_Common/m_settings_nav: settingLinks = 대시보드 카드 역할 규칙 한 벌 · mountSettingsDrawer = ☰ 진행 단계 자리
- B01 시스템 설정 카드가 settingLinks 로만 그림(화면 그대로)
- M01 · M02 page 한 줄씩(M02 = 진입 open · company 이어 실음)
- test_b01_settings_cards 가 옮긴 함수 소스를 같이 실음(기대값 그대로) · test_225 뒷부분

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 15:53:07 +09:00
eomsangdonandClaude Opus 5.5 c583dec29b feat(대시보드): 334-1 ☰ 구역 바로가기 — 누름 = 서랍 닫힘 + 그 구역으로 굴림 · 전처리 잠금 길 기준 · 서랍이 집 없는 요소 받음
- ui_template_workflow_layout: 시스템 관리자 전용 판정 = 칸 번호 대신 전처리 길
- app_shell: setDrawerSteps 가 부모 없는 요소도 받음(닫을 때 안 옮김)
- B01 buildPage: 그린 구역 그대로 · 화면 차례 · 아이콘 구역 키 짝 · 떠난 뒤엔 안 맡김
- general_layout.css: B·M 구역 scroll-margin-top = ☰ 자리
- 새 시험 test_225_drawer_shortcuts(앞)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 15:47:14 +09:00
eomsangdonandClaude Opus 5.5 422c449eb9 chore(조사): 334 ☰ 바로가기 코드 조사 — 대시보드 구역 · 시스템 설정 4 페이지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 15:36:42 +09:00
eomsangdonandClaude Opus 5.5 31e6adf7de chore(요구): 225 대시보드 · 시스템 설정 ☰ 바로가기 · CAD 입력 보강 · 외부 일위대가 이름 맞춤 검토 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 15:12:15 +09:00
eomsangdonandClaude Opus 5.5 1a029141f8 fix(일위대가실무): 330 덧고침 2 — 화면 근거 맞음 판정을 조항 같음 · 단위 · 직종(줄 직종이 보통인부뿐 · 없으면 안 봄)으로
- Hopyo_View basisOk(후보 근거, 줄 근거) — 앞 마디 같음은 근거 맞음 아님 · 엑셀 직종 없는 줄(기계)도 조항 같음 + 단위면 맞음 · basisPick · 고르기 창 꼬리표가 줄 근거를 넘김
- 시험 screen(앞 마디 · 보통인부뿐 · 직종 없음) · seam(북부청 실제 응답 1순위 근거 맞음 123 = 서버 기준 · 앞 마디 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:57:25 +09:00
eomsangdon 46ab9d5b11 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8 2026-10-15 12:54:52 +09:00
eomsangdonandClaude Opus 5.5 a8d7832c8b test(일위대가실무): 329 · 330 이음매 — 실제 격자 · 맞춤 응답을 화면 도우미(Hopyo_Sheet · Hopyo_View)에 먹이는 시험
- helper_m02_hopyo_seam.cjs · test_m02_hopyo_seam — 행 → 호표(25 빈 줄) · 영역 B20:O24 · 단산93 행 · 엑셀 근거 줄 · 근거 꼬리표 · 근거 맞음 대상(GC000083 · GC000143)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:52:42 +09:00
eomsangdonandClaude Opus 5.5 c4a09c1ca2 fix(B05추천): 328 추천 저장본 ALGO_VERSION 1 → 2 — 큰 R 로 셈이 바뀌어 옛 저장본(R 12) 무효
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 12:52:08 +09:00
eomsangdon 67e3162a57 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8 2026-10-15 12:51:29 +09:00
eomsangdon 510965f51b Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-15 12:51:26 +09:00
eomsangdonandClaude Opus 5.5 20b2deeddd fix(B05추천): 328-2 큰 R — 단순화가 기울기 위반을 늘리는 급한 지형(검증_sub8)에서 2단을 최소 R 판 노드에 바로 건 판을 1단보다 먼저 · 실측 네 갈래 R 12 → 18/29/147
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 12:50:43 +09:00
eomsangdonandClaude Opus 5.5 510b36eea2 fix(일위대가실무): 330 덧고침 — 후보 다섯에 이름 1순위 자리 · 낱말 안 겹치는 근거 후보 뒤로 · 위 층 격자 읽기 시험
- Basis.merge 같은 점수는 이름 겹침 큰 쪽 · 이름 · 낱말(괄호 글 포함 · 인력 · 기계 · 장비 뺌) 안 겹치는 근거 후보는 근거 맞음(조항 · 직종 · 단위)이 아니면 겹치는 근거 후보 뒤로
- Basis.pick 다섯 안에 이름 1순위 하나 늘 남김 · Match match_line 이 씀
- 시험 basis(제3호표 구조물터파기 다섯 = 굴착(인력/토사) 1위 · 육상토사 안 · 터널굴착 밖 · pick) · match(이름 1순위 자리) · grid(고르기 줄 층 · 주인 그대로 회사 · 시스템 격자 읽기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:50:31 +09:00
eomsangdon 16c363fb9b Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-15 12:49:25 +09:00
eomsangdonandClaude Opus 5.5 34659db663 feat(CAD): 332-3 설정 배선 — 휠 확대 방향 · 내 별칭
- 휠 — A00_Common/input_prefs 방향(기본 당김 = 지금과 같음 · 밀기 저장 뒤 뒤집힘) · 트랙패드 핀치는 늘 벌리면 확대(옛 CAD 는 벌리면 축소)
- 별칭 — 명령 이름 > 내 별칭(cadAliases) > 기본 별칭 · 자동완성 후보 맨 앞 · 부를 때마다 읽어 저장 즉시 따름
- tsconfig include 에 input_prefs.ts · 시험 resources/tester/cad/input-prefs-cad.test.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:47:42 +09:00
eomsangdon 23425e5e15 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-10-15 12:45:45 +09:00
eomsangdonandClaude Opus 5.5 aaecfa3438 feat(설정): 332-2 ☰ [설정] 창 — 마우스 · CAD 단축키 · 다른 곳 단축키 탭 · [저장] 때만 계정 pref · 닫을 때 물음
- app_shell ☰ 설정 묶음에 [설정] 단추(서랍 닫고 창은 처음 누를 때 불러옴)
- 새 app_settings.ts(틀 · 탭 셋 · [저장] · askLeave) · app_settings_cad.ts(명령 표 · 내 별칭 · 검사) · app_settings.css
- 새 openwebcad/src/commands/command-catalog.json(명령 153 · 한글 이름 · 기본 별칭) + 거울 시험
- locale 문구(ui_template_locale_common)
- 시험: test_app_settings · cad/command-catalog 새로 · test_device_audit · test_touch_bridge 화면 주소 환경값(AISLO_UI_BASE · AISLO_UI_PROJECT)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 12:45:03 +09:00
eomsangdon 958f50ee6e Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-15 12:44:39 +09:00
eomsangdonandClaude Opus 5.5 309be22ce0 feat(CAD): 331-4 동적 입력 — 커서 옆 길이 · 각도 두 칸 · Tab 잠금
- 기준점 있는 점 단계에서만 Tab 가로챔(그 밖은 브라우저 초점 이동) · 숫자 넣고 Tab = 그 칸 잠금 + 다음 칸 · 빈 Tab = 칸만 바꿈
- 각도 칸 잠금 = 각도 고정 · 길이 잠근 채 Enter = 그 길이 · `100` Tab `30` Enter → (86.603, 50)
- 커서 옆 줄 `▸[길이 100 🔒] [각도 30_]` — 잠금 🔒 · 지금 칸 ▸ · 나머지는 기준점→커서 실측
- 한 점 · Esc · 새 명령이면 각도 고정 · 길이 잠금 모두 풂(releaseOnePoint)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:44:23 +09:00
eomsangdon 825ae3dcc5 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_6' into main_laptop_1 2026-10-15 12:41:58 +09:00
eomsangdon 4d64317563 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-15 12:41:55 +09:00
eomsangdonandClaude Opus 5.5 ca11ee1456 feat(CAD): 331-3 선 중 닫기(C) · 명령취소(U) · 한 번 스냅 이름 · 한글 안내문
- 선 — 이어 그리기 첫 점 · 구간 기억 · C = 둘째 선부터 첫 점으로 닫고 끝 · U = 마지막 한 구간만 취소
- 점 단계 글 — 선택지 글자(meta.options)만 도구로 · 나머지는 명령 이름 그대로 · 한글 받개는 점 단계에서 안 잡음
- 새 input-snap-override — END · MID · CEN · INT · PER · NEA · NON 다음 한 점만 · F3 꺼져도 · 맞는 점 없으면 클릭 버림 · Esc 풂 · 커서 옆 안내
- 원본 도구 안내문 한글(선 · 원 · 사각형 · 이동 · 복사 · 회전 · 배열 · 정렬 · 지우개 · 그림 · 폴리선 합치기 · 선택)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:41:16 +09:00
eomsangdonandClaude Opus 5.5 f044b8d0b4 feat(일위대가실무): 330-3 고르기 창 근거 — 후보 근거 꼬리표(조항 · 직종 · 단위) · 엑셀 근거 줄(출처 · 직종 · 단가산출 행) + [엑셀에서 보기] · [근거 맞음 모두 확정](셋 모두 맞는 후보만) · 옛 파일 단추 없음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 12:38:41 +09:00
eomsangdon 16aea41631 Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_1' into main_laptop_1 2026-10-15 12:38:16 +09:00
eomsangdonandClaude Opus 5.5 27c62ce220 feat(일위대가실무): 329-1 · 330-1 · 330-2 서버 — 엑셀꼴 격자 · 행 범위 · 단가산출 근거 맞춤
- 329-1 새 M02_Hopyo_Grid(xls formatting_info 값 · 병합 · 열 폭 · 행 높이 · 서식표 · 틀고정 / xlsx 값만) · read_book 호표 · 줄 「행」 · Store 격자 저장 · 읽기 · 지움(실무자료/격자/<키>.json) · GET hopyo-grid/{key}(옛 파일 404) · Match 요약 .get
- 330-1 새 M02_Hopyo_Basis.read — 단산N 블록 이름 · 단위 · 행 · 출처(책 · 절) · 직종(우리 인력 이름 · 옛이름) · 금액 · 단산 줄 「근거」
- 330-2 Basis.ours · candidates(조항 1.0 · 앞 마디 0.5 · 건설 공통 · 토목 0.1 · 직종 0.5 · 단위 0.15 · 까닭) · match_line 이름 후보와 로직 키로 합침 · 상태 후보 그대로
- 시험 test_m02_hopyo_grid · test_m02_hopyo_basis 새로 · test_m02_hopyo_match 제3호표 구조물터파기 1순위 → 굴착(인력/토사)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-15 12:37:18 +09:00
eomsangdonandClaude Opus 5.5 be86e3ee2c feat(노선추천): 327-2 미리 셈 · 표시 — 업로드 체인 스냅샷 뒤 기본 다섯 알약 예약 · 추천 창 상태 줄 「저장된 결과」
- B03 자동 설계 체인: 초기값 스냅샷 뒤 precompute_defaults 예약(기다리지 않음 · 실패는 비치명적)
- 추천 창: 서버 stored 를 받아 미리 보기 줄에 「(저장된 결과)」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 12:37:17 +09:00
eomsangdon 4d84b54f2d Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_5 2026-10-15 12:37:13 +09:00
eomsangdonandClaude Opus 5.5 32ddaa8064 feat(CAD): 331-2 이동 · 복사 · 회전 키보드 값
- 이동 · 복사 — 기준점 `x,y` · 둘째 점 거리 · `x,y` · `@x,y` · 각도 고정 · 미리보기도 같은 점 계산(getPointFromEvent)
- 회전 — 기준점 `x,y` · 각도 숫자 바로(반시계 +)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:36:19 +09:00
eomsangdonandClaude Opus 5.5 b1e8d6cb7f feat(설정): 332-1 휠 확대 방향 읽기 층 — input_prefs · 모든 휠 화면 한 설정(기본 당김) · 핀치는 늘 벌리면 확대
- 새 A00_Common/input_prefs.ts(INPUT_PREFS_KEY · DEFAULT_INPUT_PREFS · inputPrefs · wheelIn · wheelEventIn · 의존 없음)
- 등록표 input-prefs(pref · global) 한 줄
- touch_bridge wheelZoomsIn · b_svg_zoom_pan · B04 3D 카메라 · M02 DF 그림 · M02 노무 그림 · 돋보기 · 시트 배선
- M02 그림 · 시트 · 돋보기 기본 방향 = 당김(지금과 반대)
- 시험: test_input_prefs 새로 · 돋보기 · 266 · 244-8 헬퍼가 읽기 층을 불러옴 · 돋보기 부호 당김 기본

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-15 12:36:04 +09:00
eomsangdonandClaude Opus 5.5 b55bad1686 feat(B05): 326-3 계획노선 편집 창 [측점 스냅] 단추 — 켬끔 보기 모드(되돌리기 밖) · 열 때 서버 값 · 측점 한 곳(stations)으로 눈금 · 누름 · 기울기 · 횡단 미리보기 · [확인] 때 station_snap 실음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-15 12:35:57 +09:00
eomsangdonandClaude Opus 5.5 f127c0179e feat(노선추천): 327-1 추천 결과 영구 저장 — 입력 지문 키 · 같은 입력이면 저장본 · 바뀐 입력만 셈해 더함
- 새 B05_Profile_Engine_RouteInitial_Store(ALGO_VERSION · request_fingerprint · 읽기 · 쓰기 · 묶음 잠금) · 자리 B05_Profile/route_recommend/<키>.json · 덮어쓰기 없음 · 상한 없음
- 엔드포인트 몸통 compute_initial(찾기 → 셈 · 더함 · 덮개 수 꺼낼 때 다시 셈 · stored 표시) · precompute_defaults(기본 다섯 알약)
- resolve_inputs 로 기본값 풀이 한 곳 · 머리글 「파일 안 씀」 정정
- 시험 test_224_3_recommend_store

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-15 12:34:58 +09:00
eomsangdon 2a5d9a4254 Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_6', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-15 12:34:10 +09:00
eomsangdonandClaude Opus 5.5 e36fc5091d feat(일위대가실무): 329-3 엑셀 보기 창 — 읽기 전용 시트(일위대가 · 단가산출 탭) + 호표 목록(체크 · 찾기 · 맞춤) · 목록 누름 → 영역 강조 · 칸 누름 → 그 호표 · 고른 호표 한 번에 사전조합 + 결과 표 · 여는 자리 넷(올린 직후 · 엑셀로 보기 · 엑셀에서 보기 · 로직 추가 덧줄) · 늦게 불러옴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 12:33:32 +09:00
eomsangdonandClaude Opus 5.5 8e96880880 feat(CAD): 331-1 각도 고정 <각도 → 길이 · 시퀀스 첫 점 숫자 무시
- 새 input-angle-lock — 다음 한 점만 고정 방향(동쪽 0° 반시계) · 커서 · 클릭은 고정 직선 위로 · 숫자는 그 방향 길이 · 한 점 · Esc · 새 명령이면 풂 · 커서 옆 안내 「각도 30° 고정 — 길이 입력」
- input-patterns — 숫자 음수 · .5 허용 · 각도 고정 꼴 · 점 글 해석 한 곳(parsePointText)
- Enter 해석 차례 — 자유 글 단계만 원문 먼저 → 각도 고정 → 점 · 거리 → 단계 선택지 → 명령 이름
- 선 · 시퀀스 미리보기도 고정 방향 · 시퀀스 첫 점의 거리 · 상대 좌표는 무시(도구 멈춤 · 「Error in tool actor」 없앰)
- 시험 resources/tester/cad/keyboard-chain.test.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-15 12:33:28 +09:00
eomsangdonandClaude Opus 5.5 394fb7ba60 feat(B05추천): 328-2 추천 노선 큰 R — 곡선마다 [맞춘 R, 접선 자리 ÷ tan(IA/2)] 안 탐색 경로 이탈 ≤ 4 m 인 가장 큰 R(1 m 내림) · 못박아 다시 짬(편 노드 · [확인] 재구성과 같은 선) · 허용 이탈 상수 · test_224_4_recommend_radius
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 12:32:56 +09:00
eomsangdonandClaude Opus 5.5 6ae8bb4f6a feat(B05추천): 328-1 추천 노선 R 맞춤 — 탐색 경로를 예정노선 규칙(단순화 · 묶음 · R 맞춤)으로 · 최소 R 판보다 위반 많으면 최소 R 판 · 도움말 추적 「맞춘 곡선반지름」
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-15 12:32:45 +09:00
eomsangdonandClaude Opus 5.5 4c6658cf1c feat(B05): 326-1·326-2 측점 스냅 서버 — 요청 모델 Schema 로 쪼갬 · 호 안 측점 하나를 곡선 중점으로 옮기는 짝 셈(snap_curve_stations ↔ snapCurveStations) · [확인] 때 곡선표 station_snap 저장 · GET 돌려줌 · 실패 되돌림에 곡선표·노드 · 측점 셈 둘 · 횡단 미리보기 반영
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-15 12:32:36 +09:00
eomsangdonandClaude Opus 5.5 d33cf220f3 feat(종단): 325-3 유토곡선 · 배수유역 [창 분할] — 몸통 attachPopout 한 벌 · 유토 판 좌상단 · 배수 머리줄 맨 앞 단추 · 창 이름 따로 · 단추가 놓인 창에서 엶 · 뗀 판 창 꽉 채움
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-15 12:31:21 +09:00
eomsangdonandClaude Opus 5.5 2d239f780e feat(종단): 325-2 유토 판 붙어 있음 판정 — 높이 나눔 · 끌기는 열림 && 종단 뿌리 안만 · 뗀 유토는 노선 전체 세로 · 방향키 판정에 뗀 유토 · 배수 판
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-15 12:26:31 +09:00
eomsangdonandClaude Opus 5.5 7bae5ebb7d feat(스프레드시트): 329-2 손잡이 select(범위, 시트?) — 범위 글 → 고름(병합 늘림 · 여럿) · 그 시트 탭으로 · 보이게 굴림 · 틀린 글 false
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-15 12:25:45 +09:00
eomsangdonandClaude Opus 5.5 6d5e5f911e feat(종단): 325-1 배수 판 창구 — 지도 크기 관찰을 놓인 창 것으로 바꿔 끼우는 rehome · 직전 크기 이어받아 배율 유지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-15 12:22:37 +09:00
eomsangdonandClaude Opus 5.5 b5b6264016 chore(조사): 요구 224 정밀 분석 — 325 유토곡선·배수유역 창분리 · 326 측점 스냅 · 327-328 추천 저장·큰 R · 329-330 외부 일위대가 보기 창·근거 맞춤 · 331 CAD 연속 명령(오토캐드 조사) · 332 설정 모달
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 12:12:08 +09:00
eomsangdonandClaude Opus 5.5 837fe3d64a chore(요구): 224 원문 — B05·B06 유토곡선·배수유역 창분리 · 계획노선 측점 스냅 · 추천 노선 영구저장 · 큰 R 우선 · M02 외부 일위대가 엑셀꼴 모달 · 계산식·출처 매칭 · CAD 연속 명령 · 설정 모달
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-15 11:43:40 +09:00
eomsangdon 6461b41293 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-15 10:15:22 +09:00
eomsangdonandClaude Opus 5.5 9edaec82e3 fix(일위대가실무): 324 다듬기 — 줄 표 줄바꿈 없음 · 고르기 창 [닫기] 안 갈라짐 · 뜻 낱말 안 겹치는 후보 숨김 · 다시 씀 · 새로 덮음 알림 글
- 줄 표: 갈래 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 입력값 · 환산 · 단추 열 줄바꿈 없음(글 폭) · 입력값 길면 줄임표 · 칸 여백 좁힘 · 품명 · 규격 · 대상이 남는 폭
- 고르기 창 머리: [닫기] 단추 줄바꿈 없음 · 안 줄어듦
- 후보: 정리 이름 뜻 낱말(괄호 밖 두 글자 이상 덩어리 · 낱글자 붙임)이 하나도 안 겹치면 뺌(잔토처리 · 임도표지석 → 후보 없음) · 다 걸러지면 「가까운 것 없음」 + [다른 로직 · 자재 찾기]
- 알림: 있던 사전조합 = 「다시 씀 — 빠진 줄 N」 · [새로 만들기] 뒤 = 「새로 만들어 덮음」(토스트 · 판 글)
- 시험 hopyo 넷 42 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:46:24 +09:00
eomsangdon c7e9747c72 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-14 22:43:00 +09:00
eomsangdonandClaude Opus 5.5 56f606f331 feat(일위대가실무): 324-4 화면 — 좌측 「일위대가 실무자료」 판 · 본 화면(호표 표 · 줄 맞춤 · 확정 · 빠진 줄 · 값 비교) · [로직 추가] 「실무 호표」 줄
- 좌측 판(Hopyo_Side): 일위대가 사전조합 아래 · 구조물 조합 층 · 쓰기 권한 없으면 [엑셀 올리기] 꺼짐 · 숨은 file 입력 .xls,.xlsx · 파일 줄 → 본 화면
- 본 화면(Hopyo_View): 파일 · 시트 · 호표 수 · 검산 · 맞춤 n/m · 호표 표 + 찾기 · [지우기] / 호표 줄 표(갈래 · 품명 · 규격 · 수량 · 단위 · 엑셀 단가 · 맞춤 · 대상 · 입력값 · 환산) · [고르기](후보 5 + 다른 로직 · 자재 찾기 → 미리 채움) · [확정] · [빼기] · [되살림] · [같음 모두 확정] · 빠진 줄 · [사전조합 만들기] → 값 비교 · 다시 씀이면 [새로 만들기](확인 뒤 덮음)
- [로직 추가](Hopyo_Pick · Labor_Pick 늦은 덧줄 `more` · 종류 고르기): 문서 층 · 위 층 호표 줄(맞춤 n/m) → 고르면 서버가 문서 층 사전조합을 만들고 참조 줄(별칭 = 호표 품명) · 빠진 줄 알림 · 프로젝트 층 없음
- Labor_Detail: 제자리 넷 줄(가져오기 · pick 별칭 받음 · withHopyo) · 693 줄 그대로
- 서버: GET /layers/{층}/hopyo-picks(이 층 · 위 층 · 이 층 기억)
- 색 = 공용 토큰(밝음 · 어두움) · 시험 test_m02_hopyo_screen 4 + hopyo-picks 1 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:27:57 +09:00
eomsangdonandClaude Opus 5.5 736aa405cf feat(일위대가실무): 324-3 실무 호표 → 우리 일위대가 사전조합 만들기 · 사전조합 줄 별칭 살림
- M02_Hopyo_Build: 호표 = 사전조합 하나(이름 = 품명 + 규격 · 엑셀 단위 · 구분 실무자료 · 상세구분 파일 · 본뜬 실무자료 표시) · 로직 줄 {별칭 = 엑셀 품명 40 자 · 메모 「실무 0.14㎥ × 1 · 규격」 · 입력값 · 수량 손 = 엑셀 × 환산} · 자재 줄(값 없음 = 별도계상 · 아니면 직접계산 · 같은 자재 수량 더함) · 같은 로직 둘째 = 한 줄 사전조합으로 감쌈 · 일위N = 먼저 만들어 참조 · 순환 = 빠진 줄 · 같은 층 · 파일 · 호표 = 다시 씀 · 새로 = 그 키에 덮음 · 빠진 줄 + 엑셀 금액 · 만든 값 ↔ 엑셀 단가
- M02_Labor_Ref._combo_lines: 별칭 있으면 남김(한 줄)
- 길 POST /layers/{층}/hopyo-build(이 층 쓰기 · 파일 층 읽기)
- 시험 test_m02_hopyo_build 5 통과 · 곁 시험 84 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:15:59 +09:00
eomsangdonandClaude Opus 5.5 5e33dda958 feat(일위대가실무): 324-2 맞춤 — 엑셀 구성 줄 → 돈 로직 · 우리 자재 · 규격 입력값 · 환산 · 층 기억
- M02_Hopyo_Match: 정규화(master_material.plain · plain_spec · plain_unit) · 차례 = 층 기억(같은 층 → 위 층) → 이름 같음 · 일위대가조합.json 메모 씨앗(읽기만) → 정리 이름 · 후보 5(두 글자 묶음 + 단위 + 산림품셈 · 규격 같은 자재 덤) · 후보는 확정 뒤에만 들어감
- 규격 → 입력값: 고르기 글(품명 괄호 포함 · 낱말 머리) · 기계 고르기이름 · D= H= H: B= T= L= L3= · 천 단위 쉼표 · 못 읽음 표시
- 환산: 같은 단위 1 · 단위표 · ㎡→㎥ T · m→㎡ B · m→㎥ B×T = 추정 · 두 단위 로직 · 그 밖 입력
- 길: GET /hopyo/{키}(호표마다 맞춤 n/m) · GET /hopyo/{키}/{번}(줄 맞춤 · 후보) · POST …/guess(미리 채움) · POST /hopyo-memory(확정 · 빼기 · 지움 = 층 쓰기)
- 시험 test_m02_hopyo_match 15 통과(+ 324-1 15)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:12:00 +09:00
eomsangdonandClaude Opus 5.5 63ba44a717 feat(일위대가실무): 324-1 실무자료 엑셀 읽기 · 저장 · 올리기 길 — 「일위대가」 시트 → 호표 · 줄 · 갈래 · 참조 · 검산
- M02_Hopyo_Excel: xls(xlrd · logfile 막음) · xlsx(openpyxl) · 머리 글로 시트 · 열 찾기 · 제N호표 · 구성 줄 · 갈래(단가산출 · 자재 · 기계 · 호표 · % · 별도계상 · 기타) · 단산N/일위N 참조 · 검산(줄 금액 floor · 합계 단가 · % 줄 · 호표 비목 · 총괄표) · 규격 원문 보관 · 시트 없음 = 「일위대가 시트 없음」
- M02_Hopyo_Store: 층 폴더 실무자료/HP-######.json · 맞춤 기억 실무자료/맞춤.json · 잠금 + 원자 쓰기 · 양식 종류 밖(목록 · 복사 · 프로젝트 씨앗에 안 섞임)
- M02_Labor_Router: GET/POST /layers/{층}/hopyo · GET/DELETE /layers/{층}/hopyo/{키} · 20 MB · 엉터리 · 시트 없음 400 · 권한 = 층 읽기 · 쓰기 규칙
- requirements: xlrd==2.0.2
- 시험 test_m02_hopyo_excel 15 통과(북부청 68 · 사방 27 · 검산 0 · 역할 표 · sha256)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 22:03:59 +09:00
eomsangdonandClaude Opus 5.5 c637ff14ea chore(plan): 324 일위대가 실무자료 조사 · 정함 — 엑셀 일위대가 시트 꼴(xlrd · 열 · 호표 · 갈래 · 검산 · 규격) · 우리 구조(로직 · 일위대가 · 사전조합 · 고르기 창 덧줄) · 호표 = 사전조합 하나 + 참조 줄 · 별칭 = 엑셀 이름 · 규격 → 입력값 · 환산 · 맞춤 기억 · 단계 324-1~5
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 21:56:09 +09:00
eomsangdon 13646d2a8c Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-14 21:36:58 +09:00
eomsangdon ecd4a95ab5 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_8 2026-10-14 21:34:50 +09:00
eomsangdonandClaude Opus 5.5 4baf49ba48 chore(master): 사용자 지시 — A군 맹암거 옵션 기본값 암거 높이 3 m · 암거 경사 3 %(요구 223 · M02 저장 길 · 판 21 · 이력 판_20)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 21:34:05 +09:00
eomsangdonandClaude Opus 5.5 0d1c4f2615 fix(구조물표): 323-9 ④ 덧고침 — 노선 끝 관 3D 둘(관 카드 1078 · EP 카드 1078.010813) · 3D 몸체 = 같은 시설(얹을 때 적은 시설 누가거리)을 그리는 카드 중 시설 자리에 가장 가까운 하나만(ownsBody · 관 · 세월교 · BOX · 물넘이는 fordPavementChute 같은 규칙)
- B05 3D: ownsBodies(같은 m 만) → ownsBody(시설 누가거리로 묶음 · 차례 무관)
- 시험: test_323_9_twin_cards_3d 에 EP 꼴(0.010813 m 차 · 차례 바꿈) 더함 · 고치기 전 두 배 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:25:41 +09:00
eomsangdonandClaude Opus 5.5 dd35589079 fix(구조물표): 323-9 ④ 작은 것 — 검사 글 띄어쓰기 「… · 구조물에 안 맞음」(화면 · 서버 짝) · 노선 끝(EP)처럼 같은 m 카드 둘이면 3D 관 · 세월교 · BOX 몸체를 한 번만(앞 카드 · ownsBodies)
- M02_SO_Def_Check.ts ↔ M02_SO_Check.py 글 · test_322_2_check_mirror 기대 글
- B05 3D: buildCorridorStructures 관 몸체 = 같은 m 앞 카드만 · 벽은 그대로(wallSweepAt)
- 시험: test_323_9_twin_cards_3d(세월교 · BOX 카드 둘 = 하나와 같음 · 고치기 전 두 배)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:13:36 +09:00
eomsangdonandClaude Opus 5.5 e067a22439 fix(구조물표): 323-9 ③ M02 부품 끔 막힘 안내 — 형상 부품을 꺼서 형상이 「없음」이 된 것이면 「부품 「맹암거」 끔 — 이 부품을 쓰는 종류(A 횡단배수 · 맹암거)의 횡단도를 「안 만듦」으로 바꿔야 저장」 한 줄(부품 이름 + 쓰는 종류 전부) · 막음은 그대로 · 화면 · 서버 짝
- M02_SO_Def_Check.ts ↔ M02_SO_Check.py: 부품 끄기 전 표로 끈 탓 가름 · 부품마다 한 줄
- 시험: test_322_2_check_mirror(벽 끔 = 다섯 종류 한 줄 · 맹암거 끔 = A 횡단배수 · 맹암거 · 짝 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:09:49 +09:00
eomsangdonandClaude Opus 5.5 13608c6be5 fix(구조물표): 323-9 ② 정의 판 알림 되풀이 — 정본이 옛 정의 판으로 적혔으면 구조물을 안 고쳐도 [저장]이 정본을 그대로 다시 써 지금 판 도장 · 다시 열면 알림 없음 · B05 · B06 같은 길
- B05_Profile_Api_Structures: definitionStale(열 때 판 알림이 표시 · 저장 성공이 풂) · flushPendingStructures 가 초안 없어도 옛 판이면 정본 그대로 내보냄
- B05 보기 saveStructuresIfDirty 같은 조건
- 시험: test_323_9_definition_stamp(옛 판 열기 알림 · 고침 없음 저장 = 판 도장 · 다시 열기 알림 없음 · 또 저장 안 나감)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:07:25 +09:00
eomsangdonandClaude Opus 5.5 7cb82c6112 fix(구조물표): 323-9 ① B06 [저장] 뒤 카드 = 서버 새 상세 — 저장 도중 바뀜 신호로 낡은 상세 캐시 · 빈 초안을 그리지 않음(savingDesign) · 뒤처리가 비운 캐시로 새 상세를 받아 한 번 다시 그림(보일 때만) · B05 보기 저장 · [확정] 길 그대로
- design_save: 저장 도중 표시(savingDesign) · 다 나간 뒤 · 실패 때 풂
- design_view_cross: refreshDetailForStructures 가 저장 도중이면 차례만 올리고 안 그림(최신만) · persistContext.redraw
- B06 Persist done: invalidateSectionDetail 뒤 redraw
- 시험: test_323_9_save_redraw(저장 전 옛 자리 · 저장 뒤 새 자리 · 도중 건너뜀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 21:04:51 +09:00
eomsangdonandClaude Opus 5.5 35f861a517 knowledge(실무문서): 2026 실무일위대가 모음 xls 넷 추가 — 사용자 자료(북부청 임도 설계내역서 공통단가 · 사방 계류+댐 · 사방사업 표준품셈(안) · 수목진료)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 20:59:33 +09:00
eomsangdonandClaude Opus 5.5 56bc88bc47 chore(요구): 223 구조물 표 흠 고침 진행 · 본보기 목록 맹암거 · 옛 종류 그대로(비활성) · 맹암거 기본값 3 m · 3 % · 전역 깃 싱크 · 싱크 뒤 M02 일위대가 실무자료(엑셀 일위대가 탭) 불러오기 · 별칭 · 규격 옵션 · 단위 변환 상세 분석 뒤 신규 계획
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 20:59:24 +09:00
eomsangdonandClaude Opus 5.5 b342e33411 chore(요구): 222 sub1 구조물 관리 표 재설계 전체 = 브레인이 브라우저로 사용자처럼 전수 검증 · 미반영 · 오류 찾기 · 하나씩
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 19:16:17 +09:00
eomsangdonandClaude Opus 5.5 81f7d98e6f feat(구조물표): 323-7 새 종류 · 정리 — M02 종류 더하기(본보기 복사 · 일곱 갈래 본보기 그대로) · 손코딩 동작 · 명세 = 본보기 키 · 특수 동작 = 동작 이름 · 정본 목록 = 표 · drawing_views 걷음 · 옛 이름 되짚기 걷음(자리로 가름) · 허용 거리 파이썬 한 곳 · 벽 툴팁 = 종류 이름
- M02 종류별 탭 [종류 더하기] = 본보기(구조물 정본 원본) 복사 — 키 자동 · 이름 「본보기 2」 · 옵션 · 표시 · 그룹 · 배치 · 규칙 · 종류 덧칸 · 반영 그대로 · 종류.{새}.본보기 · 관 지점 종류 · 복사본은 본보기 밖
- 구조물 표 = 본보기 처음값 이어받음(resolveCatalog · resolve_catalog 짝 · 줄 칸 base) · baseType/base_type · specFor(명세) · 덧단 · 길이 전후 풀기 · 옹벽 추가 단 콘크리트
- 검사(화면 = 서버 짝) — 본보기 = 원본 · 같은 군 · 자리꼴 · 정본 · 만든 뒤 고정 · 복사본은 본보기가 숨긴 칸 이어받음 · 서버 덧칸 본보기 받음
- 특수 동작 = 동작 이름 — 바로 안 놓기 · 포장 나누기 = 포장_물넘이_겹침 · 월류 높이 기다림 = 월류단면
- 정본 목록 = 표 — 파이썬 관 지점 종류 = catalog owner · B05 시설 서브폼 = 표 형상 · 안 쓰는 손 목록 걷음
- drawing_views — 스키마에서 빼고 읽을 때 버림 · M02 [저장] 때 빠짐(코드는 마스터 안 고침)
- 이관 = 지난 판 이름 대신 측점 계획 자리(planted)로 가름 · all_type_names 걷음
- 허용 거리 = common_util_structure_station.py 한 곳(SAME_STATION_M · EDGE_TOLERANCE_M · PIPE_MATCH_M) · TS 표는 station.ts 머리
- 시험: test_323_7_new_type(템플릿 · 일곱 갈래 · 그림 · 검사 짝 · 서버 읽기) · 322-0 유령 시험 = 자리 · 옛 도움 파일 동작 · 표 길 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:59:13 +09:00
eomsangdonandClaude Opus 5.5 6744524669 fix(구조물표): 323-6 고침 — 지우고 새로 바꾸는 종류 바꿈 = 새 종류가 놓인 뒤에만 옛 것을 지움(못 놓으면 옛 것 · 옛 폼 그대로 + 알림) · [추가] 무반응 = 막힌 칸 이름 알림
- 반사경 → 가드레일(길이 빈칸)이 옛 것을 먼저 지우고 새 것은 못 놓아 반사경이 사라지던 것 — 패널 addAt 이 놓였는지 돌려줌 · Events replaceAt = 먼저 놓고 확인 뒤 옛 것 지움 · 못 놓으면 옛 기록 폼(관은 관 폼) + 「반사경 그대로 — 가드레일(으)로 못 바꿈」
- commit 의 조용한 return 여섯 곳 = 막힌 칸으로 커서 + [추가] 때 「「길이」 칸이 비었거나 맞지 않아 못 놓음」(실시간 반영은 알림 없음)
- 패널 줄 수 지킴(setter 한 줄로 · 697)
- 시험: test_319 바꿈 막힘 둘(구조물 · 관) · 비동기 바꿈 · test_323_6 막힌 칸 알림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:32:46 +09:00
eomsangdonandClaude Opus 5.5 529baca3ff feat(구조물표): 323-6 바꿈 한 길 — 바뀜 신호 하나(구조물 초안도 설계 가게 · watchDrafts) · 종류 바꿈 표 규칙(swapsInPlace · E · F 점형 ↔ 구간형 = 지우고 같은 기준 측점에 새로) · 조용한 되돌림 없앰(알림) · 그리기가 조정값 안 만듦 · replayDraftPipeOptions 걷음
- 구조물 초안 쓰기 = useDesignStore().set("structures")(칸 그대로) → 관 초안과 같은 구독 · 횡단 보기 watchDrafts(구조물 = 늘 · 관 = 자리 · 종류 · 구간 · 잇기가 바뀔 때만 · 옵션만은 폼 길이 그 카드만 · 카드 줌 · 팬 지킴) · 옛 되부름 onStructuresChanged 걷음
- 종류 바꿈 = 표(catalog) swapsInPlace — 정본 다름 · 구조물 자리꼴 다름(E · F) = 지우고 같은 기준 측점에 새로(구간 = 기본 길이) · 같으면 종류 키만 · 막히면 옛 기록 · 옛 종류 폼 + 알림(전엔 [수정] 조기 반환 → 조용히 되돌림 · 드롭다운만 새 종류)
- syncApplied = 사용자가 조작한 세션이 있을 때만 깎음(카드 그리기가 자동 배치를 조정값으로 굳혀 [저장]으로 내보내던 것)
- replayDraftPipeOptions 걷음 — 그림 자료는 형상 얹기(초안 현황) · 다단 조정값은 조정 세션이 정본
- 시험: test_323_6_change(신호 · 거르기 · 표 규칙 · 그리기 · 걷음) · test_319 점형 ↔ 구간형 · 막힘 알림 · 되얹기 시험 = 형상 얹기로 · 옛 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:21:41 +09:00
eomsangdonandClaude Opus 5.5 cb4bab7bf2 feat(구조물표): 323-5 고름 한 칸 — 관 세션 id(옮겨도 그대로 · 저장 안 함) · 고름 강조 한 규칙(pickLights · 카드 형상 · 3D 같은 함수) · 그린 것마다 신원표(data-structure-id + data-part) · 같은 자리 거리 = PIPE_MATCH_M 하나 · 322-0 이 깬 옛 시험 도움 파일 · 줄 수 고침
- `design_pipe_ids.ts` — 장부(세션 pipe-ids) · pipe-s{차례} · 자리 → id(찾는 거리 안 가장 가까운 것) · followMovedPipe 가 id 를 새 자리로(고름 id 그대로 · 순환 import 없게 design_selection 에서 옮김) · 설계 페이지가 프로젝트를 정함(usePipeIds) · 자리 이름 만들기 열 곳 · 되읽기 둘 걷음 · 측점 계획 주인 = 세션 id(withPipeIds)
- `design_pick_match.ts` — pickLights(① id ② 같은 자리 ③ 고른 부분) · paintPicked(카드 통째 형상 · 부분 · 관 계열 · 포장층은 제 손잡이) · tagShape — 관 세트 벽 · 집수정 · 관 · 세월교 측벽 · 몸체 · BOX 끝 · C군 벽 · 물넘이 콘크리트에 신원표 · 형상 판정면은 통째 형상 · 콘크리트만
- 3D — 부재 맞춤 = pickLights(옛 0.01 걷음) · C군 벽 솔리드 = 구조물 id(덮는 측점 어디서 골라도 한 번 스윕한 벽이 켜짐)
- 322-0 이 깬 것 고침 — 노면공 흐름 도움 파일(165 · 231-1) 정의 판 확인 흉내 · design_view_profile 700줄(322-0 이 704)
- 시험: test_323_5_pick(세션 id · 옮김 · 강조 규칙 · 칠하기) · 옛 시험 도움 파일 흉내 · 기대 글 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 18:06:13 +09:00
eomsangdonandClaude Opus 5.5 c45814f910 feat(구조물표): 323-4 형상 얹기 한 벌(TS · 파이썬 짝) — 저장분 · 초안 · 미리 뜬 횡단 같은 길(저장 전 그림 = 저장 뒤 그림) · 빈칸 경고 한 꼴(카드 칩 「종류 칸 빈칸」) · 3D 지문 = 그림 자료 통째 · C군 벽 3D 한 번 · 소단 원천 = 표 형상 berm
- 짝 `common_util_structure_attach.ts` attachShapes ↔ `B06_Section_Engine_Attach.py` attach_shapes — 열쇠 걷고 관 세트 → 벽 → 포장 → 맹암거 → 빈칸 경고(측점 계획 자리 · 「옹벽 높이」 · 「배관 관경」 · 「맹암거 암거 높이」 · 「물넘이포장 월류 높이」)
- 서버 `_read_section_detail` · 재생성 응답 = attach_saved_shapes 하나 · 화면 `withDraftShapes`(현황 = 초안 ?? 저장분 · 초안 없어지면 저장분으로 한 번) — decorate 관 세트 · withDraftPipeKinds · 맹암거 · 포장 · 벽 블록 걷음 · 구조물 초안 없을 때 미리 뜬 횡단에 포장 · 벽 · 맹암거 빠지던 것 · 반폭 넓힘 뒤 날 상세(초안 빠짐) 고침
- 파이썬 얹기 = 목록 함수(culvert_sets_from · attach_culvert_specs · wall_specs_from · attach_wall_specs · underdrains_from · attach_underdrain_specs) + 옛 파일 함수는 감쌈
- 3D 지문 = SHAPE_KEYS(`common_util_structure_shape_keys.ts`) + 조작값 통째(손 해시 걷음 · 벽 기초 · 단 · 포장 · 맹암거 빠짐) · C군 벽 = 기준 측점과 가장 가까운 측점에서만 시작 ~ 끝 스윕(`wallSweepAt`)
- 소단 = 표 형상 berm(BERM_TYPE_ID 걷음 · 화면 isBermType · 서버 catalog_row)
- 시험: test_323_4_attach_mirror(42종 + 빈칸 · 서버 = 화면 · 옛 자리 걷힘 · 벽 한 번 · 지문) · 전수 표 ★ 둘(물넘이포장 걸친 격자 100 = 서버와 같음 · STAGE 4) · 옛 도움 파일 = 측점 → 형상 차례

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 17:26:53 +09:00
eomsangdonandClaude Opus 5.5 3d8c161efa fix(구조물표): 322-3 고침 — B06 을 바로 열어도 옛 소수 관 자리 = 정수 m 초안(B05 snapOldStations 와 같은 셈 · 요구 167) · 계획고 편집 · 고름 따라감 · 알림 한 줄 같은 글 · 투영 이월 끝 = 선 길이(관이 전부 0 m 로 가던 흠) · design_view_cross 685줄 안(322-3 넘침)
- B06 구조물 목록 load: 관 초안 없고 저장분에 소수 자리 = `snapPlan` 으로 옮긴 초안(persistPipes) → 카드 · 측점 계획이 B05 를 먼저 연 것과 같음 · 정본은 [저장]에서(B08 사본 이름도 PUT 길)
- 서버 관 읽기는 그대로(파일 = 측점) · `project_pipe_points` 끝 = 선 길이
- 시험: test_322_3_flow 견본(85.05 … 982.64) → 85 · 173 · 258 · 352 · 440 · 534 · 620 · 720 · 804 · 900 · 983 · 정수 저장분 = 새 미리 뜬 카드 0 · 소수 저장분 = 정수 자리로 · test_322_3_saved_snap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 17:02:22 +09:00
eomsangdonandClaude Opus 5.5 0da2fa0767 fix(구조물표): 322-2 다듬기(브레인 화면 확인) — 화면에 영어 키 안 보임 · 검사 글 한국어(화면 · 서버 짝) · 부품 탭 읽는 칸 = 정의 이름 · 줄바꿈 · 한눈 표 폭 줄임 · 바꿨다 되돌리면 [저장] 꺼짐
- 자리꼴 점 · 구간 · 터 · 정본 관 지점 · 구조물(키는 툴팁) · 고르기 글 = 부품 짧은 이름(괄호 설명 · 키는 툴팁) · 횡단도 없음 = 「안 만듦」 · 「벽 스윕」 → 「벽」 · 종류 키는 툴팁
- 검사 글: 「형상이 「없음」이면 횡단도도 「안 만듦」」 · 부품 짧은 이름 · 칸 이름 · 종류 이름(M02_SO_Def_Check.ts = M02_SO_Check.py)
- 부품 탭: 읽는 칸 = 그 부품을 쓰는 종류의 칸 이름(단위) · 표 줄바꿈(table-layout fixed)
- 한눈 표 형상칸 「기본 형태」 = 그림 형태 고르기(REVET_FORMS)
- [저장] 켬 = 저장할 꼴(tidy · 목록 짝 enabled)끼리 지금 판과 다를 때만(M02_SO_Def_Reflect.differs)
- 시험: 322-2 검사 글 · 되돌림 견본

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 16:29:30 +09:00
eomsangdonandClaude Opus 5.5 6c87207f76 feat(구조물표): 322-3 측점 계획 한 벌(TS · 파이썬 짝) — 카드 목록 · B05 종단 · 3D 막대 · B06 종단 띠(모든 종류) · 서버 측점이 같은 목록 · 일반 측점 자리 새 구조물 이름 · 형상(219) · 서버 낡음 = 자리 + 이름 · 옮긴 관 고름 유지 · 배수 패널 되먹임 끊음
- 측점 계획 planStations(TS) ↔ plan_stations(py): 관 · 구조물 + 구조물 표 → 측점 · 역할 · 카드 이름 · 덮는 구조물 id · 빈칸 경고(형상 부품 필수 칸) · 같은 m 하나(이름 = 관 먼저 · 앞선 것)
- 서버 resolve_extra_stations = 계획(같은 자리 한 번 · 옛 「마지막 이름」 걷음) · _stale_marks = 자리 + 이름(종류 · 관 공식 · 정의 판 이름)
- 카드 목록(Section_Drafts) = 계획 이름 · 일반 측점 자리(regular)에 새로 놓은 구조물 = 그 자리 구조물 없는 사본에 이름 칩 · 관 그림 · 벽 · 포장(219 흠)
- B05 종단 · 3D 측점 = 계획(design_structure_status.plannedIrregular) · 배수유역도로 관 자리 되먹임 끊음
- B06 종단 띠 = 계획(관만 → 모든 종류 · 관 구간도 넘김)
- 고름: followMovedPipe — 고른 관을 옮기면 고름도 새 자리(B05 폼 · 종단 끌기 · B06 옮김)
- 부품 상자 형상 「필수 칸」(관경 · 벽 높이 · 맹암거 높이 · 물넘이 높이 · 기슭막이 높이)
- 시험: test_322_3_plan_mirror(거울 · 규칙 · 서버 공급자 · 낡음) · test_322_3_flow(흐름 일곱 + 고름 따라감) · 272_1 = 같은 자리 한 번 · 318 도움 파일 표 모듈 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 16:03:33 +09:00
eomsangdonandClaude Opus 5.5 3abd2bdfd4 feat(구조물표): 322-2 M02 표 화면 — 탭 셋(한눈 표 · 종류별 · 부품) · 반영 · 부품 · 알약 색 저장 · 목록 + enabled 함께 · 검사 짝(화면 = 서버 · 1-3 규칙 · 만든 뒤 고정) · 명세 폼 보임 따름 · 판 알림
- 한눈 표(M02_SO_Def_Table): 군 · 종류 ▲▼ · 이름 · 목록 · 자리꼴 · 정본(고정) · 횡단도 · 카드 이름 · 형상(+ 벽 기본 형태 · 물빼기) · 알약 색 · 3D · 검사 · 처음 값 = 흐림 「기본」 · 바꿈 = 진함 · [처음 값으로]
- 종류별: 반영 칸 + 명세 이름 나무 줄마다 보임(컨테이너 = 그 안 칸) · 못 숨김 · 미리보기 숨김 따름 · 「안 씀」 = 목록 B06 만
- 부품 탭(M02_SO_Def_Parts): 부품 · 읽는 칸 · 자리꼴 · 정본 · 쓰는 종류 · 켬/끔(정의 부품 = 끈 것만)
- 편집 손질(M02_SO_Def_Reflect): 처음 값이면 반영에서 지움 · [저장] 때 enabled = 목록 both 일 때만 참
- 검사 짝 M02_SO_Def_Check.ts ↔ M02_SO_Check.py(글까지 같음): 반영 · 부품 꼴 · 형상 none → 횡단도 none · 부품 자리꼴 · 정본 · 기본값 없는 읽는 칸 새로 숨김 금지(앞 판 숨김 그대로) · 종류 지우기 · 자리꼴 · 정본 · 군 글자 · 선택지 값 고정 · 서버 check(doc, 지금 판) · 앞 판 없으면 꼴만
- 명세 폼: 쓰는 옵션 키가 모두 숨긴 칸 · 상자는 안 그림(값 그대로) · B05 · B06 시설 · 구조물 명세 폼이 정의 「표시」를 따름
- 판 알림: GET 구조물 목록에 definition_version · structures.json 판 ≠ 지금 판이면 「구조물 정의가 판 N 으로 바뀜 — 저장하면 반영」 한 번
- 시험: test_322_2_check_mirror(거울 · 규칙 · 반영 손질 · 서버 저장 · 422) · 판 알림 · 옛 정의 화면 시험 = 선택지 값 대신 이름 표 · 못 숨김 아닌 칸

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 15:40:38 +09:00
eomsangdonandClaude Opus 5.5 bbd6254892 feat(구조물표): 322-1 부품 상자 · 표 읽기 — 목록 · 측점 · 카드 이름 · 형상 고르기 · 3D 몸체 · 알약 띠가 종류 키 · 군 글자 · 자리꼴 · managed_by 대신 구조물 표(catalog) 한 줄을 읽음 · 코드 숨김 목록 걷음 · ★ 넷 + 관 공식 한 벌
- 부품 상자 common_util_structure_parts.json(갈래마다 부품 · 읽는 칸 · 자리꼴 · 정본 · 42종 처음값) · 표 읽기 common_util_structure_catalog.ts / .py 짝(정의 반영 ⊕ 처음값 ⊕ 부품 끔) · 거울 시험 test_322_1_catalog_mirror
- 목록: pickGroups 군 차례 = 구조물군 열쇠 · 페이지 거르기 listPick(B05 both · B06 both + b06) · HIDDEN_PICK_GROUPS · HIDDEN_PICK_TYPES · pickable 걷음(옛 대피 셋 asRoad 그대로)
- 측점: structureStationMarks · pipeStationMarks · is_station_planting_type · resolve_extra_stations = 표 횡단도(anchor · span3 · none)
- 카드 이름: cardTitle / card_title 한 공식(빈칸 = 그 종류 정의 기본값) · 표시 이름이 정의 관종도 앎
- 형상: 벽 = shape wall(형태 = 형상칸.form · 물빼기 = 형상칸.weep) · 포장 = shape pavement(「사용 중」 거르기 없앰) · 맹암거 · 사토 · 관 지점 시설 세트 = shape
- 3D 몸체 = 그림 자료 주인 종류의 표 3D 부품 · 알약 띠 = 표 자리꼴 구간(관 지점 시설은 알약만)
- ★1 노출형 · 개거 횡단도 none · ★2 쇄석 화면에서도 포장층 · ★3 기슭막이 표 자리꼴 interval(정의 파일 그대로) · ★4 코드 숨김 10종 list hidden · ★5 관 빈칸 D800 → 정의 기본 D1000
- 옛 시험: 도움 파일 로더 27개가 표 모듈 공용 한 벌(helper_322_catalog_module) 씀 · 기대값 고침(272_1 · b05_extra_stations · roundtrip · 314 · 52_7 · walls 거울 · wall_drain_pipe)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 15:09:45 +09:00
eomsangdonandClaude Opus 5.5 863abc7766 feat(구조물표): 322-0 안전망 — 42종 전수 표 시험(지금 결과 기록 · ★ 바뀔 칸 표시) · 「기타」 유령 길 막기(지금 · 지난 판 종류 이름 전부) · 종류 캐시 판 확인(보기 열 때 · 탭 다시 보일 때) · 서버 관 기본값 캐시 걷음
- 전수 표 시험 test_322_0_golden + helper_322_0_golden.cjs · 기록 golden_322_structures.json(측점 · 카드 이름 · 형상 열쇠 · 3D 몸체 · 알약 띠 · 고르기 목록 · 물빼기 · ★ 27칸)
- 이관 = 지금 판 + 구조물정의_이력 판_N 종류 이름 측점은 안 옮김(all_type_names)
- GET /api/m02/structure-definition/version · dropStaleStructureTypes · onStaleStructureTypes
- _registry_defaults lru 걷음(정의 읽기가 이미 파일 시각 캐시)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-14 14:16:27 +09:00
eomsangdonandClaude Opus 5.5 6327fd99c3 chore(요구): 221 구조물 관리 표 재설계(PLAN 322 · 323) 전체 = 랩탑 sub1 에만 격리 반영 · 사용자 직접 확인 뒤 병합 여부 결정 · 조심 · 브레인 브라우저 검증
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 13:56:04 +09:00
eomsangdonandClaude Opus 5.5 8dac0bf3f0 chore(plan): 321 · 322 구조물 관리 표 조사 기록 — M02 정의 칸마다 따르나 · 코드 숨김 10종 · 군 순서 안 따름 · 「안 씀」 B05 만 · 종류 이름 바꿈 = 「기타」 유령 · 바꿈 안전 표 · 그리는 쪽 부품 목록(요구 220 다시 정밀 점검)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 13:22:30 +09:00
eomsangdonandClaude Opus 5.5 f8d5e0cbe1 chore(요구): 220 구조물 관리 표 = 사용자 제어 — 코드는 부품을 만들어 두고 켬 · 끔 · 드롭다운에 들어올 메뉴는 사용자가 정함 · M02 구조물 옵션 글 고치는 메뉴와 합쳐 운영 · 다시 정밀 점검 · 끝나면 신규 계획서
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 12:48:46 +09:00
eomsangdonandClaude Opus 5.5 74a6cd9134 chore(요구): 219 구조물 반영 구조 정리 제안 — 드롭다운 = 최신 구조물 현황 · 횡단도 · 하이라이트 · 횡단 형상 · 종단 측점 · 알약 · 3D 측점 형상이 조건마다 달리 반영 · 다이어그램 동작 흐름 · 현재 대비 비교 분석 보고서(코드 수정 없음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 09:10:54 +09:00
eomsangdonandClaude Opus 5.5 d5c322ffa0 chore(요구): 218 구조물 점검 — 드롭다운 구조물 목록 현황표(브라우저 직접 확인) · 규칙 = 횡단배수군 · 그 밖 · 군 넘어가면 패널 리셋 · 군 안 종류 바꿈 = 그 그림 · 그림 없는 종류 = 횡단도 안 더함 · 있으면 지움
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 07:00:58 +09:00
eomsangdonandClaude Opus 5.5 2641c15e29 fix(b05·b06): 정본 다른 종류(관 ↔ 맹암거 · 개거 등)로 바꿈 = 옛 것 지우고 같은 측점에 새 종류 · 일반 측점 자리 종류 바꿈 · 지움 = 구조물만 뺀 사본에 새 종류 · 중심고 상자에 겹친 Y 눈금 숨기지 않음(상자 위 · 안 비치는 바탕) (319 · 요구 217)
- 공용 구조물 패널 종류 바꿈: 관 지점 ↔ 구조물이면 새로 놓기 폼으로 빠지던 것 → 옛 것 지움 → 우클릭 추가 길(addAt)로 같은 측점 · 옛 측점 조정값 버림(318-4)
- 횡단 초안 맞춤: 일반 측점 자리(grid)는 종류가 바뀌어도 저장분(옛 관 그림)이 남던 것 → 구조물만 뺀 사본(지형 쪽 고친 값 그대로) + 새 종류
- 시험: test_319_kind_swap 8 · test_318 중심고 겹침 기대값 바꿈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 06:52:02 +09:00
eomsangdonandClaude Opus 5.5 38e09559c4 chore(요구): 217 중심고 라벨 = 겹친 눈금 숨기지 않고 위에 놓음 · 안 비치는 바탕 · 횡단배수 종류 바꿈 때 맹암거로 안 바뀜(형상 없는 종류 = 지우고 다시)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-14 06:28:06 +09:00
eomsangdon 397096a9d9 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-14 05:57:16 +09:00
eomsangdonandClaude Opus 5.5 6d914b7647 fix(b06): 관 종류 바꿈 = 그 측점 옛 조정값(세션 아홉 칸 · 미리 뜬 횡단 사본) 버림 · [저장] 때 structure_reset 으로 정본 옛 조정값 비움 · 같은 행 여러 줄 이어 얹음(CASE 첫 WHEN 만 쓰던 것) (318-4 · 요구 216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 21:24:37 +09:00
eomsangdonandClaude Opus 5.5 6329ea2a3a feat(b06): 횡단 카드 — 단추 줄 빈자리 누름 = 카드 고름 · 중심고 = Y축 라벨 둥근 상자(눈금과 같은 글자 · 소수 첫째 자리 · 축선 앞에서 멈춤 · 카드 · 상세 모달) · 아래 띠 중심고 글 뺌 · 암 경계 왼쪽 · 카드 = 내용 높이(아래 여유 없앰 · 빈 띠 높이 그대로) (318-1~3 · 요구 215 · 216)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 21:24:30 +09:00
eomsangdonandClaude Opus 5.5 6e981b1c90 chore(요구): 216 종류 바꿈 옛 조정값 저장 섞임 막음 · 중심고 = Y축 라벨 상자 · 아래 띠 여유 없앰 · 단추 줄 빈자리 고르기(브레인 직접)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:47:03 +09:00
eomsangdonandClaude Opus 5.5 08e72ab323 chore(요구): 215 B06 횡단 카드 단추 줄 빈자리 누름이 카드 고르기에서 빠짐(확인만)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:40:03 +09:00
eomsangdon a8efd834b7 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 20:35:20 +09:00
eomsangdon 53d502d9a4 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_8 2026-10-13 20:33:00 +09:00
eomsangdonandClaude Opus 5.5 27ec7da7be chore(요구): 214 구조물 종류 바꿈 뒤 겹침 · 새로고침하면 맞음(캐시) — 브레인 직접
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:31:42 +09:00
eomsangdonandClaude Opus 5.5 fab776eb31 fix(b06): 구조물 종류를 여러 번 바꾸면 앞 종류 제원이 남아 겹치던 것 — 미리 뜬 횡단은 다시 쓰므로 얹기 전에 관 계열 제원을 비움 (317-3 · 요구 214)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:31:32 +09:00
eomsangdon 3428e0abbd Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 20:26:12 +09:00
eomsangdon 5180d001fc Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_8 2026-10-13 20:24:41 +09:00
eomsangdonandClaude Opus 5.5 972baf989d fix(b05): [3D 업데이트] = 낡은 횡단 설계부터 다시 셈한 뒤 예상형상 · 그린 뒤 생긴 낡음도 다시 셈(같은 낡음은 한 번) (317-1 · 요구 213)
- 공용 낡음 지문 staleKey(Section_Common) · 그래프가 그릴 때 잰 지문을 패널에 넘김(Render onStaleDesigns)
- 패널 = 새 지문일 때만 다시 셈 예약(Preview scheduleIfStale · 서버 폴백 반복 막음)
- 단추 refreshCorridor = 짓기 전 refreshStaleDesigns(세션 계획선 초안 → 저장분 · B06 과 같은 규칙)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:17:05 +09:00
eomsangdonandClaude Opus 5.5 5f54a09267 fix(b06): 구조물 측점에서 종류를 바꾸면 횡단을 구조물 없는 상태부터 새로 — 저장분 횡단은 빼 두고 새 자리처럼 미리 뜬 횡단 · 되돌리면 저장분 복귀 (317-2 · 요구 213)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 20:04:26 +09:00
eomsangdonandClaude Opus 5.5 74a318a459 chore(요구): 213 3D 형상 업데이트 단추가 안 맞춤 · 구조물 종류 바꿈 = 지우고 새로 놓기(브레인 직접)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:59:56 +09:00
eomsangdon d7845a6a21 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 19:56:50 +09:00
eomsangdon 0bf2c6fa8c Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_8 2026-10-13 19:55:03 +09:00
eomsangdonandClaude Opus 5.5 fbaa5b4838 fix(b05·b06): 저장 전 구조물 자리 값 다시 셈 — 물넘이포장 · 맹암거 측구 없음 · 종류 바꿈 · 기준측점 옮김 (314 · 요구 210 · 211)
- 낡음 판정(B05 · B06 공용 isStaleSection)에 서버 _enforce_stored_designs 짝 셋: 포장 · 기울기(물넘이 · 맹암거) · 세월교 노면 하강
- 구조물 바뀐 뒤 횡단 다시 받으면 낡은 측점 다시 셈(refreshDetailForStructures)
- 다시 셈의 세월교 노면 하강 = 그 측점에 얹힌 세월교 제원(옛 저장값 안 이음)
- 물넘이포장: 옮기거나 지운 옛 자리 걷고 새 자리 · 걸친 기존 측점에 얹음(빈 관 목록도)
- 월류 높이 기다림: 빈 글자도 빈칸(채우는 쪽과 같은 규칙)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:54:27 +09:00
eomsangdonandClaude Opus 5.5 b7eb44cd5c fix(b06): 물넘이포장 도수로/돌붙임 길이 = 성토 사면보다 길면 원지반 이음 조각까지 한 선 · 월류 높이선 성토 쪽 = 성토선 점선 연장과 교점 (315)
- 315-1 fordChuteLine — 길이 맞춘 선의 첫 조각만 쓰던 흠(원지반 이음 조각 버림) · 이은 조각까지 한 선 · 길이 없음 · 짧은 길이 · 절토 쪽 그대로
- 315-2 overflowLine — 성토 쪽(노면 끝 밖 설계선이 내려감) = 성토 사면선을 노면 끝 너머 위로 연장해 월류 높이선과 만나는 자리에서 끝 · 절토 · 늘인 노면 끝(294-3) 그대로 · 같은 점선 양식(B06 카드 · B07 [상세] 같은 그림)
- 시험 test_315_ford_chute_overflow 새로 · test_287 월류선 기대값 315 규칙

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-13 19:46:28 +09:00
eomsangdonandClaude Opus 5.5 bcca4163df feat(b05·b06): 기본값 둘 — 횡단 복합경사 기본 꺼짐(파이썬 · TS 짝 함께 · 저장된 고른 값 그대로 · 값 없는 측점 · 새 측점만 꺼짐) · B05 3D 첫 보기 원근(화면 설정 b05-3d-projection 기억 우선) (316)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 19:44:45 +09:00
eomsangdonandClaude Opus 5.5 1dc815eae3 chore(요구): 212 B05 3D 영역 기본 보기 = 원근
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:36:49 +09:00
eomsangdonandClaude Opus 5.5 b72ab3a3ed chore(요구): 211 물넘이포장 도수로 길이 · 기준측점 이동 · 성토 점선 연장 · 맹암거 기준측점 이동 · 횡단 복합경사 기본 꺼짐
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:33:08 +09:00
eomsangdonandClaude Opus 5.5 64a64068e0 chore(요구): 210 물넘이포장 · 맹암거 생성 뒤 측구 생김 · 종류 바꿀 때 캐시 안 비움 · 월류 높이 첫 자동 계산 — 브레인 직접 · 전역 싱크
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:24:35 +09:00
eomsangdon d38a680bf8 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 19:17:08 +09:00
eomsangdonandClaude Opus 5.5 8500d4724f chore(요구): 208 · 209 docs 정션 걷기(창 폴더 = git 으로 받음) · 깃 싱크 한 번에 스크립트 · 일하는 창 있으면 싱크 안 함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 19:14:00 +09:00
eomsangdon c278acb35a Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 18:54:46 +09:00
eomsangdonandClaude Opus 5.5 1ab59256e3 chore(동기화): 공유 docs 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141RHfyDdi6VJY8Yiuch6DX
2026-10-13 18:54:46 +09:00
eomsangdon 4bf8bb0e82 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-13 18:51:56 +09:00
eomsangdon b60eef65d1 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-13 18:51:53 +09:00
eomsangdonandClaude Opus 5.5 f3c8436551 fix(b05,b06): [구조물 전체 삭제] 단추 높이 = 위 구조물 단추와 같게 — 공용 --panel-control-h 규칙에 목록 단추 보탬 (313-2)
- 위 [추가] · [삭제] · [리셋]만 205-1 높이 규칙(--b05-control-h)을 받고 목록 맨 아래 단추는 빠져 34px · 위 26px
- 같은 규칙 · 같은 변수에 `.b05-route__irregular-clear` 보탬(px 안 박음) · 위험 색 그대로
- 시험: test_313_clear_all 실제 브라우저 밝음 · 어두움 높이 같음(옛 26 · 34)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-13 18:50:11 +09:00
eomsangdonandClaude Opus 5.5 ec6689ecfd chore(요구): 207 [구조물 전체 삭제] 단추 높이 = 위 구조물 단추 · 다시 전역 깃 싱크
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:47:13 +09:00
eomsangdon 7e056d182d Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4 2026-10-13 18:45:52 +09:00
eomsangdonandClaude Opus 5.5 e40cd1f687 fix(b05·b06): 관 목록 빈 목록 = 관 0개 — 마지막 관 지움이 새로고침 · [저장] 뒤 되살아나던 것 (312)
- 서버 _resolve · _build · build_detail: 빈 목록과 None 을 가름 — None(칸 없음 · 파일 없음 · 노선 바뀜) = 자동 배치 · [] = 관 0개 · 자동으로 되돌리기는 [초기화](DELETE)만
- B05 배수유역도 초안 읽기: 빈 초안을 저장분으로 안 넘김(B06 ?? 규칙과 같음)
- B06 횡단 벽 초안: 빈 목록 = 다 지움(목록과 같은 뜻) · 09-09 옛 결함 까닭(옛 배열 참조가 [] 알림) 막이 시험
- 시험 test_312_empty_pipes

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-13 18:44:03 +09:00
eomsangdon 5284402ce9 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-13 18:40:37 +09:00
eomsangdonandClaude Opus 5.5 3de9ca043a feat(b05,b06): 좌측 구조물 목록 맨 아래 [구조물 전체 삭제] — 빈 목록 숨김 · 공용 확인창 한 번 · 하나씩 지우기와 같은 길 (313)
- 목록 그리기(renderStructureList)가 맨 아래에 위험 단추 줄 — 목록이 비면 그리지 않음(다시 생기면 보임)
- 확인 「구조물 N개를 모두 지울까요?」(showConfirmDialog) · 취소 = 그대로
- 관(자동 횡단배수 · 손 배치) = 보기마다 관 빼기 길(B05 배수유역도 · B06 관 초안) · 구조물 = 정본 배열 비우고 알림 한 번 → 캐시 · [저장] 때 작업본
- 저장 길(초안 읽기 · [저장] · 서버 · 자동 배치)은 안 고침(312 조사 몫)
- 시험: test_313_clear_all 새로 · test_116 기대값(return { startNew, clearAll })

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-13 18:38:50 +09:00
eomsangdon 8227972eec Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-13 18:38:16 +09:00
eomsangdonandClaude Opus 5.5 e12c4d6c28 chore(요구): 조사 AO — 312 관(자동 횡단배수) 빈 목록이 「없음」으로 읽혀 마지막 하나가 되살아남(원인 넷 · 고칠 자리 제안)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:35:54 +09:00
eomsangdonandClaude Opus 5.5 8cf68188b7 chore(요구): 206 B05 · B06 좌측 구조물 목록 맨 아래 [구조물 전체 삭제] · 비면 숨김
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:32:50 +09:00
eomsangdon b93a9ee24d Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-13 18:29:18 +09:00
eomsangdonandClaude Opus 5.5 63dfc9a357 chore(요구): 205 B05 · B06 좌측 구조물 목록 — 마지막 하나 지움이 새로고침 뒤 되살아남(조사)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:28:48 +09:00
eomsangdon cf2201723f Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 18:23:52 +09:00
eomsangdonandClaude Opus 5.5 75730c43f3 chore(동기화): 공유 docs 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141RHfyDdi6VJY8Yiuch6DX
2026-10-13 18:23:50 +09:00
eomsangdon 04b645fd90 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3', 'origin/sub_laptop_4', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_8 2026-10-13 18:19:47 +09:00
eomsangdonandClaude Opus 5.5 d3d038349b fix(b06): 고름 강조 정리 — 측점 길은 형상 안 켬 · 연동 관 콘크리트만 · 측점 없는 구조물 스크롤 없음 (307)
- 노면공 포장층 강조 = 포장층을 직접 누른 턴만(paveShapeId) · 알약 · 목록 · 측점선 고름은 범위 테두리만
- 물넘이 콘크리트(pipe-…)는 측점 길 강조(highlightShapes)에서 뺌 — 형상 누름 「관」 고름만 켬 · 맹암거 그대로
- 벽 고름 「관」에 콘크리트 표식(형상 누름 ownerM · 관 없는 카드) · 카드 「관」 setter 를 관 그림 / 콘크리트로 가름(splitPipePick)
- 연동 관 카드 콘크리트 누름 = 관 형상 안 켬 · 콘크리트 ↔ 관 바꿔 누르면 풀지 않고 옮김 · 좌측 하위 컨테이너 켬 그대로
- 측점 없는 구조물 = 카드 고름 · 스크롤 없음(revealStation 스크롤 걷음 · 넘김 focusPicked)
- 시험: test_307_pick_highlight 새로 · 118 · 177_4 · 287 · 294_6 · 294_7 기대값 307 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-13 18:18:20 +09:00
eomsangdonandClaude Opus 5.5 e657067bd3 feat(b09): 탭 상세 안내 글 영역 = 줄 수 변수(--b09-hint-lines 기본 3)만큼 · 넘치면 영역 안 스크롤 · 탭 표 = 공용 fillViewport(창 끝 − 안내 글) · 설계내역서 레벨 안내 + 탭 안내 한 영역 · 공용 spaceBelow 형제 바깥 여백도 셈(페이지 스크롤 7px 없앰) (310 · 요구 203)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-13 18:15:53 +09:00
eomsangdonandClaude Opus 5.5 608b6c80d9 feat(b07): 311-2 평면도 · 종단도 · 횡단도 기본 접힘 · 다시 세워도 접힘 이어 받음 · 첫 장 표지(기억한 장 있으면 그 장) · 시험 (요구 204)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 18:14:45 +09:00
eomsangdonandClaude Opus 5.5 0d3bb1154a feat(panel): 311-1 좌측 패널 기본 접힘 — B09 데이터 점검 · 공기 옵션 / B06 횡단 보기 설정 / B05 계획노선 (요구 204)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 18:14:45 +09:00
eomsangdonandClaude Opus 5.5 e275bf353b feat(b05·b06): 종단 누가토량 알약 — 바탕 불투명 · 두 테마 글자 대비 4.5 넘음(붉은 계통 그대로) · 그래프 영역 안 끌어 옮김(마우스 · 손가락 pointer 한 길 · 끝 막힘 · 자리 = 영역 안 비율 · 화면 설정 한 칸 B05 · B06 공용) · 끈 뒤 click 삼킴 · 바닥 서브패널 위 (309)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 18:11:04 +09:00
eomsangdonandClaude Opus 5.5 0a8008ce09 feat(B06): 306 횡단 맹암거 안 엇갈려 겹친 돌 무늬 · 그림 전용 · 고르기 차례 = 안쪽 판정면 맨 아래 · 테두리 판정 면 판정 맨 위
- 돌 무늬: 켜마다 반 칸 엇갈림 · 이웃 · 위아래와 겹침 · 기울기 ±22° 번갈아 교차 · 맹암거 색(카드 · [상세] 모달 같은 그림)
- 맹암거 무리 pointer-events none — 성토 띠 · 포장 · 넓은 관 판정면이 맹암거 안에서도 골라짐
- 차례: 보라 선 > 맹암거 테두리 > 구조물 판정면(작은 것 위) > 그림 · 띠 > 맹암거 안쪽
- 시험 test_306_underdrain_pick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-13 18:07:53 +09:00
eomsangdon 5076c44425 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_6 2026-10-13 18:04:54 +09:00
eomsangdonandClaude Opus 5.5 17c785596a chore(요구): 204 좌측 패널 기본 접힘(B09 · B07 · B06 · B05) · B07 첫 쪽 표지 · 실험 이름표 지움
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 18:03:51 +09:00
eomsangdonandClaude Opus 5.5 4ecf51c223 feat(b09): 연결 정보 팝업 Esc = 모두 닫힘 · 맨 위 팝업 밖 누름(아래 팝업 · 빈 곳 · 톡 · 우클릭) = 모두 닫힘 · 그 누름은 아래 화면에 안 넘김 · 맨 위 팝업 안 누름 · 끌기 그대로 (308-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 18:01:49 +09:00
eomsangdonandClaude Opus 5.5 864114ee2b chore(요구): 203 B09 탭 상세 안내 글 영역 3줄 + 스크롤 · 표 넓게 · 합계 줄 · 안내 글 끌려 올라옴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 17:59:32 +09:00
eomsangdonandClaude Opus 5.5 c06ad0835f chore(요구): 201 · 202 B09 연결 팝업 Esc · 바깥 누름 = 모두 닫힘 · 계획서 사용자 정리 · 누가토량 알약 테마 색 · 그래프 안 끌기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 17:54:43 +09:00
eomsangdon bf61251ff8 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 17:50:16 +09:00
eomsangdonandClaude Opus 5.5 dd0650d8f2 chore(동기화): 공유 docs 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141RHfyDdi6VJY8Yiuch6DX
2026-10-13 17:50:16 +09:00
eomsangdon 4821e3b060 Merge branch 'sub_laptop_1' into main_laptop_1 2026-10-13 17:47:08 +09:00
eomsangdonandClaude Opus 5.5 9e49b9c4db chore(요구): 200 B06 횡단 구조물 그림 · 고름 강조 개선 · sub1 포함 전역 싱크 · 측점 없는 구조물 횡단 이동 막음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 17:47:07 +09:00
eomsangdon 3cde4dcd32 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 17:30:44 +09:00
eomsangdonandClaude Opus 5.5 5cdf4e840a chore(동기화): 공유 docs 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0141RHfyDdi6VJY8Yiuch6DX
2026-10-13 17:30:44 +09:00
eomsangdonandClaude Opus 5.5 fbe0f982d3 feat(device): 로그인 뒤 쪽은 데스크탑도 위 줄 대신 ☰ · ☰ 왼쪽 위 36px(손가락 높이 44) · 서랍 왼쪽에서 · 진행 판 대신 서랍 진행 단계 · 첫 줄 들여쓰기 = --app-burger-inset 하나 · 공용(좌측 패널 제목 · 일반 쪽 제목 · --wf-burger-pad) + 쪽마다 한 줄 · CAD 제목 줄은 틀 자리로 잼 · Esc 는 ☰ 포커스에서도 닫힘 (기기대응 7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 17:17:12 +09:00
eomsangdonandClaude Opus 5.5 3c4fc991c4 chore(요구): 199 랩탑 메인 = 임시 배포 — 「깃 싱크해」 = 랩탑 메인 빼고 · 「전역 깃 싱크해」 = 검증 사용자 쉴 때 랩탑 메인까지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 17:04:39 +09:00
eomsangdonandClaude Opus 5.5 b4d2b86da4 chore(요구): 198 기기 대응 실험 추가 — ☰ 단추 폭 조금 줄임(sub1 전용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 16:44:30 +09:00
eomsangdonandClaude Opus 5.5 1c635f3e02 chore(요구): 197 기기 대응 실험 추가 — 프로젝트 안 화면 위 줄 대신 ☰ 를 데스크탑에서도 · ☰ 왼쪽 위 · 제목은 ☰ 폭만큼 들여씀(sub1 전용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 16:37:56 +09:00
eomsangdonandClaude Opus 5.5 3fc50044e1 feat(device): 로그인 뒤 ☰ 상태 위 줄 걷기 · ☰ 는 위 오른쪽 모서리에 떠 있음 · 진행 단계를 ☰ 서랍 묶음으로(같은 요소를 빌려 옴 · 한 벌) · 서랍 차례 로고 ─ 진행 단계 ─ 메뉴 ─ 설정 · 위 줄 높이 = --app-header-h 하나 · data-burger (기기대응 6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 14:44:21 +09:00
eomsangdonandClaude Opus 5.5 0f9807699e chore(요구): 196 기기 대응 실험 추가 — 로그인 뒤 맨 위 줄 걷기 · 진행 단계를 ☰ 안으로(sub1 전용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 14:35:21 +09:00
eomsangdonandClaude Opus 5.5 68e82f9de9 refactor(device): CAD 손가락 명령 단추 CSS 를 input-touch.css 로 — App.css(원래 700 넘음)에 더하지 않음 (기기대응 4-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 14:06:04 +09:00
eomsangdonandClaude Opus 5.5 e27753df82 fix(device): 접힌 좌측 패널 손잡이 터치 폭만 · 오른쪽 패널 손잡이는 그대로 · 데스크탑 대조 = HTML 상자 · 넘친 쪽만 한 번 더 잼 (기기대응 5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 14:05:18 +09:00
eomsangdonandClaude Opus 5.5 c02602dcc0 fix(device): 휴대폰 폭 남은 넘침 — B06 횡단 카드 한 장씩 · B05 3D 보기 단추 줄바꿈 · 접힌 서랍 내용 숨김 · 터치 손잡이 화면 안 · 재기 「넓은 상자」 = 어느 조상이든 가로 밀기면 닿음 (기기대응 5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:48:46 +09:00
eomsangdonandClaude Opus 5.5 a7cc8a304e feat(device): 단축키로만 되던 일에 터치 단추 — 시트 입력줄 단추 줄(찾기 · 모두 같은 값 · 줄바꿈 · 고침 취소 · 오늘 · $ 돌리기) · B05 자유지정 [점 빼기] [끝] · B06 상세 · M02 나무 [여럿] · 공용 .ui-touch-only (기기대응 4-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:45:53 +09:00
eomsangdonandClaude Opus 5.5 f5e7c839d0 feat(device): B05 · B06 손가락 고름 반경 — 공용 판정이 없어 touch_bridge pickPx 한 곳(접촉 크기 · 최소 24px · 마우스는 그대로) · 고름 상수 쓰는 자리 14곳을 감쌈 · 흉내 시험 넷에 stub 한 줄 (기기대응 4-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:39:48 +09:00
eomsangdonandClaude Opus 5.5 4e0d7d2a4a feat(device): 스프레드시트 손가락 — 톡 = 칸 고름(자판 안 뜸) · 고른 칸 · 머리 · 채우기 손잡이 끌기 = 범위 · 채우기 · 안 고른 칸 끌기 = 화면 밀기 · 두 번 톡 = 고침(손 동작 안에서 자판) · 손잡이 잡는 자리 20px · 두 손가락 = 격자 확대 (기기대응 4-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:34:50 +09:00
eomsangdonandClaude Opus 5.5 be3ae14b1b feat(device): B07 CAD 손가락 — 한 손가락 화면 옮기기 · 두 손가락 확대 · 길게 누름 정밀 십자선(손가락 위 70px) · 고름 반경 = 접촉 크기(최소 24px) · 자판 없는 명령 단추(Esc · Enter · 지움 · 되돌리기 · 다시 · 스냅) · user-scalable=no 뺌 · 100vh → 100dvh (기기대응 4-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:31:10 +09:00
eomsangdonandClaude Opus 5.5 68a96e253f feat(device): 손가락 → 마우스 번역 — 길게 누름 = 우클릭 · 두 번 톡 = 더블클릭 · 벌림 = Ctrl+휠(touch_bridge) · 3D 두 손가락 확대 · 이동 · 공용 SVG 확대 두 손가락 · 당기면 확대 지도 벌림 방향 · M02 그림판 손가락 끌기 · 시트 ⋯ 칸 메뉴 · 그림 면 안내 (기기대응 3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:28:09 +09:00
eomsangdonandClaude Opus 5.5 32e3a6723a test(device): 데스크탑 대조 판정 = 원래 코드 두 번 재기 잡음 이하 (기기대응 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:20:45 +09:00
eomsangdonandClaude Opus 5.5 f5c4887056 test(device): 데스크탑 배치 서명 잡음 빼기 — 화면 안 · 움직이는(애니메이션 · 로딩 원) 요소 제외 · 불러오는 중 표시가 사라질 때까지 기다림 (기기대응 1-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:17:32 +09:00
eomsangdonandClaude Opus 5.5 9423bd4202 feat(device): 기기 알아채기 + 공통 틀 — <html> data-size · pointer · input · touch 표시 · ☰ 서랍(위 메뉴 · 언어 · 테마 · 로그인 · 터치 모드 스위치) · 좁은 폭 좌측 패널 서랍 + 덮개 · 진행 판 바텀시트 · 오른쪽 패널 아래로 쌓기 · 휴대폰 팝업 전체 화면(.ui-popup) · 표 첫 열 고정 · 터치 누를 자리 44px · 100vh → 100dvh · 호버 정보 톡 (기기대응 2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:14:45 +09:00
eomsangdonandClaude Opus 5.5 aabd50a5c4 test(device): 기기 대응 자동 점검 — 22쪽 × 네 크기(390 · 820 · 1180 터치 · 1440 마우스) 가로 넘침 · 화면 밖 · 가려짐 · 44px 미만 · 못 닿는 메뉴 셈 · 데스크탑 사진 + 배치 서명 대조 (기기대응 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 13:08:10 +09:00
eomsangdonandClaude Opus 5.5 c672111d18 chore(요구): 193~195 조사 AN — 어느 기기든 같은 기능 · 기기 바뀜 알아채기 · 손가락 대응표 · 도구 · 출처
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 12:19:55 +09:00
eomsangdonandClaude Opus 5.5 54cc2a4ef4 chore(요구): 194 · 195 기기 대응 방향 — 어떤 기기든 같은 기능 · 기기별 따로 개발 없이 데스크탑 기준 자동 반영
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 12:02:09 +09:00
eomsangdonandClaude Opus 5.5 8883e7cee7 chore(요구): 193 태블릿 · 모바일 접근 대비 방향 검토 요청 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 11:46:15 +09:00
eomsangdon 9c7449cf67 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-13 09:02:58 +09:00
eomsangdonandClaude Opus 5.5 5bc017b07e chore(동기화): 공유 docs 요구 장부 받기 — 원격과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZVseAexp5wNFNVfRtxjkg
2026-10-13 09:02:57 +09:00
eomsangdonandClaude Opus 5.5 d4ad0d6685 fix(b09-print): 인쇄 미리보기 좌측 하단 두 줄(가로 넘침 없음) · 엑셀 시트 인쇄 설정 = PDF 방향 · A4 · 폭 1쪽 · 머리 줄 반복 (302-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 08:24:05 +09:00
eomsangdonandClaude Opus 5.5 13ba1fe298 feat(b09-print): B09 좌측 하단 [인쇄 미리보기] 단추 — 초안 입구(B09_Draft_Print_UI)가 DRAFT.actions 에 넣음 · 영역 셋 이음 끝 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 08:10:38 +09:00
eomsangdon 2303798186 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-13 08:07:48 +09:00
eomsangdonandClaude Opus 5.5 cd4eabbf0e test(b09-print): 수량산출 영역 이음매 — 302-3 쪽 → 인쇄 페이지 서버 몸 → 표 PDF(지도 그림) · 엑셀 차례 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 08:07:41 +09:00
eomsangdon 61e9adf369 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-13 08:06:38 +09:00
eomsangdonandClaude Opus 5.5 a53b499878 fix(b09-print): 도면 영역 이어 붙임 — 초안 router 가 스스로 단 /api/projects 머리를 사본 길에서 떼고 끼움(앞머리 두 번 겹침 없앰) · 못 넣은 그림 수 알림 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 08:06:32 +09:00
eomsangdon 5ea602d3a8 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-13 08:05:54 +09:00
eomsangdon 9832374487 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-13 08:04:38 +09:00
eomsangdonandClaude Opus 5.5 c7a438138d feat(b09-print): 인쇄 미리보기 수량산출 영역 — collectQuantitySheets · B08 탭 차례 PrintSheet(구조물 · 자재 셋 · 토공 넷 · 수리 측점마다 · 거리이정표 + 지도 그림 data:) · 화면과 같은 표 짜기 · 캐시 값 · 영역 실패 = 까닭 한 장 (302-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-13 08:03:31 +09:00
eomsangdonandClaude Opus 5.5 5e25f418cc refactor(b08): 탭 표 자료 읽기 · 표 짜기를 화면 없는 파일로 꺼냄 — 구조물 집계표 · 자재 · 토공 · 수리 입력 · 거리이정표 · 화면은 같은 함수를 씀 · 동작 그대로 (302-3)
- 새 파일 = B08_Quantity_Summary_Table · Material_Tables · Earthwork_Tables · Hydro_Table · Milestone_Table
- 화면 UI 파일 = 불러오기 · 그리기만 · 옛 B08 시험 = 옮긴 글 자리 따라 읽는 파일 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-13 08:03:23 +09:00
eomsangdon feea843595 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-13 08:03:21 +09:00
eomsangdonandClaude Opus 5.5 a4f55c3748 feat(b09): 행 고름 팝업 — 링크 줄 · 박스 카드 누름 = 대상 탭 표 조각 창(열 제목 + 강조 범위 줄 · 누른 줄 곁 · 이어진 창 겹침 · Esc · × = 맨 위 · 탭 바꿈 · 주소 바뀜 = 모두 · [탭으로]) · 하이퍼링크 모드 = 그대로 (303-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 08:02:26 +09:00
eomsangdonandClaude Opus 5.5 f4889352f9 feat(b09-print): 인쇄 미리보기 도면 PDF — B07 도면 + 구조물 조합 도면 · 도각 크기 쪽 · 숨긴 층 뺌 · 벡터 · 한글 (302-2)
- 길 = GET /api/projects/{id}/print/drawings · POST /api/projects/{id}/print/drawings-pdf {ids?} (draft 라우터 · 로그인 · 회사 · 프로젝트 권한 스스로)
- 차례 = B07 도면번호 → 구조물 조합 도면(도번 차례 · 도번마다 한 번 · 더한장)
- B07 = B07 단건 조회 그대로(확정본 · 미확정 조립) · 미확정 횡단 장 구조물 = 화면 TS 를 서버 Node 로(번들 tmp · package.json 안 건드림)
- 쪽 = 도각 양식 실치수(A1 840×594) · 색 = openwebcad 밝은 바탕 규칙 · 글 = 맑은 고딕 심음(subset) · 그림 = data URL 넣음 · 못 넣은 수 = 응답 머리
- 화면 쪽 = fetchDrawingList · fetchDrawingsPdf(약속 꼴) · skippedImagesOf
- requirements = PyMuPDF · ezdxf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 08:01:47 +09:00
eomsangdonandClaude Opus 5.5 8230788e96 refactor(b07): 횡단 구조물 얹기 몸통 = Cross_Overlay.placeStructureEntities · Node 가짜 DOM = Node_Dom 파일 (302-2 앞)
- 화면 appendStructureEntities = 정본 상세 읽기 + placeStructureEntities 부름 · 동작 그대로
- 가짜 문서에 addEventListener · removeEventListener 빈 함수 — 화면 모듈이 불러올 때 거는 문서 이벤트로 덧그림 범위 Node 번들이 시작부터 멈추던 흠 (10-04 무렵부터 서버 장 배치가 덧그림 범위 없이 섬)
- 시험 212 = 옮긴 자리에서 글 찾음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-13 08:01:33 +09:00
eomsangdonandClaude Opus 5.5 55e565f3bf feat(b09): 좌측 하단 고정 줄 — [박스보기|호표보기] 한 설정(묶음 탭 셋 · 옛 탭별 값 하나로 · 탭 줄 토글 걷음) · [하이퍼링크|팝업] 스위치(pref global · 기본 팝업) · DRAFT.actions 자리 · [?][저장][내역 초기화] 그대로 (303-1 · 304-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 07:56:53 +09:00
eomsangdonandClaude Opus 5.5 deab5a348f feat(b09-print): 원가계산 영역 이어 붙임 — 서버 몸(pdfBody · xlsxBody)을 표 파일로 · 원가 18장 → 표 PDF · 엑셀 이음매 시험 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 07:55:19 +09:00
eomsangdon 952ce4142a Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-13 07:52:59 +09:00
eomsangdonandClaude Opus 5.5 ed47306209 feat(b09-print): 인쇄 미리보기 틀 — draft 길 선언 glob 등록 · 페이지(영역 셋 · 쪽 체크 · PDF 보기 · 내려받기) · 서버 표 PDF(Playwright) · 엑셀(openpyxl) · B09 Router 초안 길 끼우기 (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 07:52:49 +09:00
eomsangdonandClaude Opus 5.5 6c3831ce43 feat(b09-print): 인쇄 미리보기 원가계산 표 넘김 — B09 18장 = 기번3 내역서 시트 차례 · 이름 · 방향 · 화면 표 짜기(Builders) 그대로 · 단가연결 칸 · 손 줄 · 강조 · 누름 · 고침 뺌 (302-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte
2026-10-13 07:51:02 +09:00
eomsangdon 7993cdcd99 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-13 07:48:34 +09:00
eomsangdonandClaude Opus 5.5 bc92038071 refactor(b09): 탭 표 짜기 꺼냄 — B09_Estimation_UI_Builders.ts(BUILDERS · tabTableOf · tableOptionsOf) · Shell 이 그대로 씀 · 동작 그대로 (303-2 앞 조각)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-13 07:48:02 +09:00
eomsangdonandClaude Opus 5.5 5034fa4ea8 feat(b09-print): 인쇄 미리보기 약속 파일 — PrintSheet · CollectSheets · DrawingItem · FetchDrawingList · FetchDrawingsPdf (302-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-13 07:44:07 +09:00
eomsangdonandClaude Opus 5.5 d7ba07832c fix(b05): 도수로/돌붙임 면적 = 늘 손 입력 — 길이 · 월류 폭으로 면적 자동 셈(syncChuteArea · chuteAreaM2) 걷음 · 물넘이 · 세월교 폼 onChange 원래 흐름 상자로 · 길이 = 그림 전용 (301-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-13 07:32:34 +09:00
eomsangdonandClaude Opus 5.5 9a035bc188 chore(요구): 요구 192 원문 — 물넘이 관 없음 · 도수로 면적 수기 · 인쇄 미리보기 · B09 행 팝업 · 박스보기 · 호표보기 좌측 공용 세팅
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-13 07:19:41 +09:00
eomsangdon b0f354a7ff Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-12 23:02:09 +09:00
eomsangdonandClaude Opus 5.5 ed4df9e2c3 chore(동기화): 공유 docs 요구 장부 받기 — 원격과 같은 내용
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YZVseAexp5wNFNVfRtxjkg
2026-10-12 23:02:08 +09:00
eomsangdonandClaude Opus 5.5 a44b816773 refactor(b06): 카드 고름 폼 자리(formChainageOf)를 선택 배선 파일로 옮김 — design_view_cross 685줄 한도 안 · 동작 그대로 (294-8 후속)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:52:02 +09:00
eomsangdonandClaude Opus 5.5 2d356fac91 fix(b06): 안 골라진 카드 첫 누름 = 눌린 대상 — 카드 고름이 연동 관으로 폼을 옮기지 않음 · 폼 여는 동안 선 도수로 상자를 새 상자로 안 고름 (294-8 · 295-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:48:29 +09:00
eomsangdonandClaude Opus 5.5 a0a089a3d0 fix(b06): 안 골라진 카드 보라 사면선 첫 누름 = 그 선 — 남은 부재 고름(도수로 띠)을 먼저 풀어 카드 고름이 되살리지 않음 (295-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:36:01 +09:00
eomsangdonandClaude Opus 5.5 db5b7656db fix(b06): 물넘이 콘크리트 누름 — 얇은 형상 판정면 아래로 최소 두께 12 px(보라 띠 밖) · 누름 = 그 물넘이 고름(걸친 연동 관 · 자기 다른 관 아님) · 경사각도 상자 (294-7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:35:58 +09:00
eomsangdonandClaude Opus 5.5 e2e71ad3ca fix(b06): 물넘이 연동 범위 안 측점에 자기 관도 함께 얹음 — 키별 하나씩 · 같은 키는 소유 먼저 · 흙막이는 자기 관에 밀림 (299-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-12 22:18:40 +09:00
eomsangdon be5ef91f72 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_6 2026-10-12 22:14:16 +09:00
eomsangdonandClaude Opus 5.5 8ce01dfa35 fix(b06): 신규 관 계열 [추가] — B06 길도 빈 월류 높이 채움 · 알림 글 「뒤에서 다시 나눔」 · 다시 나눔 실패 토스트 (298-1)
- B05 · B06 [추가] = 그 측점 횡단(카드 · 측점선) 바로 + 세부유역 뒤에서 다시 나눔(세션 초안 · 저장 안 함 · 최신 응답만 · 지도 칸 표시만) — 있던 길 그대로
- 까닭 1: B06 [추가]는 높이 기다림을 판에 안 알려 새 세월교 · 물넘이 월류 높이(126-1)가 빈칸 — 기다림 표를 프로젝트마다 한 벌(fordWaitsOf)로 판 · B06 이 같이 씀 · 채운 높이를 B06 목록 · 폼이 받음(adoptFordHeights)
- 까닭 2: B06 알림 글이 「세부 배수유역은 [저장]·[확정] 뒤 계산」 — 실제는 뒤에서 바로 다시 나눔 · 넣기 · 빼기 · 옮기기 글 고침
- 다시 나눔 실패 = 지도 칸 문구 + 토스트 한 줄(analyzeFailed)
- 시험: test_298_add_reanalyze 새로 · test_126 · test_291_1 · helper_165 · helper_231_1 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 22:09:00 +09:00
eomsangdon d03eb1ccda Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-12 22:03:16 +09:00
eomsangdonandClaude Opus 5.5 8b87186a26 feat(b06): [상세] 모달 구조물 선 = 다른 선 나눔 기준만 · 고를 땐 한 선 통째 · 닫힌 영역 · 옛 저장 조각 · 서버 셈 그대로 + 295 시험 (295-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:02:04 +09:00
eomsangdonandClaude Opus 5.5 4eccca40d0 feat(b06): [상세] 모달 첫 보기 = 중심점이 칸 가운데 · 그림 전체가 들어가게 축마다 큰 쪽 반폭 · [⤢] · 더블클릭 원복 같은 보기 (295-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:01:18 +09:00
eomsangdonandClaude Opus 5.5 1ca7e61b08 feat(b06): 고르기 차례 = 좁은 것 위 — 카드 판정면 넓이 큰 것부터 아래 · 보라 선 맨 위 · 끌기 = 고름 아님 · 모달 선 > 작은 면 > 큰 면 · 띠 · 도수로 띠 위 성토 사면 고름 (295-1 · 294-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 22:01:08 +09:00
eomsangdon e24e3900f8 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 21:58:59 +09:00
eomsangdonandClaude Opus 5.5 209383ed17 feat(b05,b06,b07): 도수로/돌붙임 띠 = 낮은 쪽 끝단에서 · 원지반 닿으면 끝 · 길이 = 그 길이 · 모든 시설 같은 규칙과 길이 칸 (294-4)
- 공용 chuteRun(Wall_Hatch) — 길이 비움 = 원지반 닿으면 끝 · 넣음 = 사면 따라 그 길이 · 사면보다 길면 원지반 따라 이어 그 길이 · 끝단이 이미 원지반이면 안 그림
- 물넘이 — 띠 시작 = 늘인 노면 끝(294-3) · 아니면 유출 쪽 노견 끝 · 원지반 닿으면 끝(양측 절토 = 띠 없음)
- 배관 · 박스 · 세월교 — 띠 계산이 원지반(layout.ground)을 받아 같은 규칙 · 3D 띠도 같음 · 코리도 해시에 길이
- 명세 CHUTE_ITEMS 맨 아래 「길이 (m)」(chute_add_{n}_length_m · 그림 전용) · 물넘이는 공용 칸 그대로
- 면적 자동 = 물넘이 · 세월교만 월류 폭 × 길이(배관 · 박스 = 폭 정의 없어 손 입력 그대로 · 브레인 정함)
- 길이는 집계 열 · B08 수량(면적만)에 안 실림 · 마스터 정의 이름 표는 안 건드림(이름 표 대조 시험에서 이 칸만 밖)
- 시험 test_294_3 · 286_1 · 52 · 65 · 152_3 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:58:16 +09:00
eomsangdon d8413cf29b Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-12 21:55:31 +09:00
eomsangdon 5c1c5c32b7 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-12 21:55:29 +09:00
eomsangdonandClaude Opus 5.5 f3586ef8e1 chore(요구): 요구 191 원문 — 신규 관 계열 추가 때 횡단도 생성 · 배수유역 뒤에서 다시 나눔
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 21:54:49 +09:00
eomsangdon 7ac250a23a Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_5 2026-10-12 21:54:48 +09:00
eomsangdonandClaude Opus 5.5 cf396d93ff fix(b09): [업데이트] 때 지운 복사 정보 뺌 — 화면 캐시 기준 수요 · M01 원본 없는 줄 굳힌 값 그대로 (296-1)
- [업데이트] = 화면 캐시(user)를 같이 보냄 · 캐시 그대로 둠(지운 줄 안 되살림 · 저장 안 함)
- 서버 수요 = 캐시 줄이 잇는 서고 사본만(지운 줄의 사본 단가 줄 안 살아남)
- M01 원본이 지워진 키 = 옛 책 줄 그대로 · 낡음 까닭에서 빠짐
- 시험 test_296_b09_update_skip_deleted

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-12 21:54:43 +09:00
eomsangdonandClaude Opus 5.5 df05b1a1f3 feat(b06): 물넘이 월류 폭으로 옆 측점 카드에 걸친 포장 = 흙막이 연동 꼴 연결 표기 · 호버 주인 측점 (294-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-12 21:53:50 +09:00
eomsangdon 6330d1ca52 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_7' into sub_laptop_6 2026-10-12 21:49:26 +09:00
eomsangdon df574665b1 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_7' into sub_laptop_8 2026-10-12 21:49:19 +09:00
eomsangdonandClaude Opus 5.5 7b3bb72b54 feat(b06): 물넘이 낮은 쪽 노면 끝이 절토면 콘크리트 · 노면선을 노면 기울기로 늘여 원지반까지 (294-3)
- 설계 계산(파이썬 _SectionGeometry · TS 짝 SectionGeometry) — 기울인 자리(tilted) 노면이 내려가는 쪽 노면 끝 원지반이 노면보다 높으면 같은 기울기로 늘여 원지반과 처음 만나는 자리까지 · 그 쪽 절토 사면 없음 · 샘플 반폭 안에서 못 만나면 옛 길 · 성토 쪽 그대로 · 결과 tilt_reach
- 토공 면적 · 설계선이 늘인 노면을 따름(서버 정본 · [저장] 재계산 · 거울 시험 갈래)
- 횡단 그림 — 콘크리트(포장층) · 월류 높이선이 늘인 끝까지(fordEdges)
- 시험 test_294_3 새로 · 횡단 설계 거울 「물넘이포장 낮은 쪽 노면 연장」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:47:54 +09:00
eomsangdon bef62e411e Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-12 21:47:13 +09:00
eomsangdonandClaude Opus 5.5 e9084ff9a4 chore(요구): 요구 190 원문 — 고르기 차례 · 상세 모달 첫 보기 · 물넘이 연결 표기 · B09 업데이트 · 마무리
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 21:46:35 +09:00
eomsangdonandClaude Opus 5.5 704e193c1b fix(b06): 물넘이포장 콘크리트 형상 고름 · 강조 = 좌측 「경사각도」 상자 (294-2)
- 형상 누름 키 "pipe" 가 배관 명세 「관」 상자를 찾다 물넘이엔 없어 강조가 안 섬 → 관 상자가 없거나 숨었으면 보이는 「경사각도」(slope) 상자
- 배관은 「관」 상자 그대로 · 알약 · 목록 · 카드 고름 · 다시 누름 = 풂 그대로
- 시험 test_294_1_chute_remove_pick(가짜 DOM 위 조정 강조 — [삭제] 단추 · 원래 글 · 경사각도 상자) 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 21:45:53 +09:00
eomsangdonandClaude Opus 5.5 f570729f7c fix(b06): 선택 컨테이너 [삭제] 단추 — B06 조정 강조 이름표는 글 칸만 고침 · 원래 글에 「삭제」 안 섞임 (294-1)
- setGroupLabel 이 legend 를 통째로 다시 써 [삭제] 단추가 지워지고 baseLabel 에 「도수로/돌붙임 삭제」가 섞이던 것
- 첫 글 마디만 고침 — 도수로/돌붙임 · 날개벽 등 [삭제] 가진 상자 모두

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 21:45:36 +09:00
eomsangdonandClaude Opus 5.5 b87c55c846 chore(요구): 요구 188 · 189 원문 — 도수로 삭제 단추 · 물넘이 고름 컨테이너 · 낮은 쪽 연장 · 도수로 끝단 · 성토 사면 고름
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 21:34:12 +09:00
eomsangdon 557dbf5b8f Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 21:33:10 +09:00
eomsangdon 6e00e2eebd Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-12 21:33:09 +09:00
eomsangdonandClaude Opus 5.5 6c86612f65 fix(b05,b06): 월류 폭 → 높이 — 세월교 · 물넘이 한 벌 overflowSync · 안내 글 · 높이 비우면 다시 채움 (287-3)
- 까닭: 폭 → 높이 계산은 설계유량(담당 유역)이 있어야 돎 — 유량 없는 지점은 아무 일도 안 일어나고 안내도 없었음 · 높이를 비우면 안 채움
- overflowSync(design_structure_ford_calc) — 폭 바꿈 = 필요 수심 · 높이 비움 · 그 아래 = 필요 수심 · 상자 끝 안내 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」 · 유량이 오면 안내 다시 그림
- 세월교 · 물넘이 폼이 같은 것을 씀 · Facility.ts 줄 줄임(697 → 686)
- 시험 test_287 화면 길(폼 onChange 훅) — 유량 있음 · 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:24:35 +09:00
eomsangdon 67b8f1d568 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 21:17:41 +09:00
eomsangdon 74d8a071d6 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-12 21:16:39 +09:00
eomsangdonandClaude Opus 5.5 990343edc1 feat(b06): 맹암거 형상 누름 = 그 구조물 고름 — 물넘이와 같은 판정면 · 보라 손잡이 아래 · 끌기는 고름 아님 (288-2)
- 맹암거 둘레에 고름 표식(data-structure-id) · 287-5 판정면을 같이 탐(손잡이 아래 · 3 px 끌기 = 고름 아님)
- 형상 누름 창구 shapePickRoute — 관 지점(물넘이) = 그 카드 시설 고름 · 맹암거 = id 알림 → 구조물 목록이 id 로 고름(알약 · 폼 · 측점선 따라옴 · 노면공 포장 범위 고름과 따로)
- 목록 · 알약 고름 → 형상 강조(287-5 highlightShapes)
- 시험 test_288 · 287 대조 글

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:15:47 +09:00
eomsangdonandClaude Opus 5.5 fcf7daecb6 feat(b06,b07): 맹암거 측점 횡단 — 측구 없음 · 암거 경사로 지대 낮은 쪽 기울기 · 노면 아래 암거 높이 돌 무늬 형상 (288-1)
- 서버 B06_Section_Engine_Tilt — 기울기 자리(물넘이 범위 + 맹암거 기준 측점 · 경사 = 경사각도 · 암거 경사 · 비면 표준 횡단경사) · 맹암거 제원 얹기(attach_underdrains) · 상세 · 재생성 응답에 얹음
- TS 짝 common_util_structure_underdrain(초안 구조물로 다시 얹음) · 계획선 편집 재계산도 맹암거 자리 기울임
- 형상(B06_Section_UI_Cross_Underdrain) — 노면을 높이만큼 아래로 옵셋 · 성토 쪽 = 성토 사면과 만남 · 절토 쪽 = 절토 사면을 늘여 만남 · 안 = 돌 무늬 · 높이 비면 안 그림 · B06 카드 · B07 같은 그림
- tilt_ranges · tilt_args 는 Router_Design 에서 새 모듈로 옮김(700줄)
- 시험 test_288 새로 · 286_1 문자열 대조

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:12:47 +09:00
eomsangdon 0f05b642ee Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 21:09:16 +09:00
eomsangdonandClaude Opus 5.5 75050815fa feat(b06): 물넘이포장 형상 누름 = 다른 시설과 같은 한 고름 · 보라 손잡이 아래 · 끌기는 고름 아님 (287-5)
- 형상 고름 판정면(B06_Section_UI_Cross_Shape_Pick) — data-structure-id 단 형상마다 · 구조물 판정면 앞(아래)에 꽂아 보라 손잡이 판정이 위(요구 185 ①)
- 누름만 고름 — 3 px 넘게 움직이면 끌기라 고름 아님(②) · 노면공 포장층(범위 표식)은 밖 · 포장 고름과 안 섞임(③)
- 누름 → toggleRevet("pipe") = 세월교 · BOX 와 같은 창구(폼 · 알약 · 측점선) · 물넘이 범위 옆 카드도 그 물넘이 자리로
- 목록 · 알약 · 폼 고름 → 카드 형상 강조(highlightShapes) · 물넘이 포장층 표식 = 관 지점 알약 id
- 시험 test_287(판정면 차례 · 누름 · 끌기 · 강조)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 21:07:53 +09:00
eomsangdonandClaude Opus 5.5 1edf718523 feat(b07): CAD 글자 더블클릭 = 그 자리 입력 칸 — prompt 걷음 · Enter 적용 · Esc 취소 · 여러 줄 Ctrl+Enter (293-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
2026-10-12 21:03:27 +09:00
eomsangdonandClaude Opus 5.5 2b9440a44e test(b05): 물넘이포장 월류 폭 → 높이 자동 = 세월교 한 벌 확인 · 시험으로 못박음 (287-3)
- 물넘이 「경사각도」 상자 폭 칸이 세월교와 같은 overflow · fordHeightM 길을 탐(이미 이어짐 · 코드 변경 없음)
- 설계유량이 없는 지점은 손 입력(높이 칸 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:59:10 +09:00
eomsangdonandClaude Opus 5.5 a5a9a7cec0 feat(b05,b06): 물넘이포장 콘크리트 윗면 = 설계 노면 · 포장층 노견까지 · 월류 높이 점선은 노면 위 (287-2 · 287-4)
- 설계 계산 tilted · tilt_pct(파이썬 · TS 짝) — 물넘이 범위 노면 = 경사각도로 지대 낮은 쪽 한 경사 · 측구 없음 · 중심점 = 계획고(콘크리트 윗면) · 노면 끝에서 사면이 이어짐(짧으면 성토)
- 서버 tilt_ranges · tilt_args · stored_tilt — 기본 설계 · [저장] 재계산 · 포장 범위 맞춤 · 저장분 재계산이 같은 값(옛 ford_paved 갈음)
- 횡단 그림 — 포장층 = 노면공 포장 형상(appendPavementOverlay)을 노견까지 · 물넘이 표식 · 월류 높이선 = 노면 + 높이 같은 각도 · 옛 파임 · 노면 걷기 그림 걷음
- 3D 노면 파기 걷음(설계 노면이 곧 윗면) · 코리도 빌드 판 94
- 시험 test_287 · 286_1 · 횡단 설계 거울(기울임 갈래) · 물넘이 명세 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:57:54 +09:00
eomsangdon e534e65091 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_7' into sub_laptop_6 2026-10-12 20:54:05 +09:00
eomsangdonandClaude Opus 5.5 17b095942d chore(plan): 10-12 요구 187(B07 CAD 글자 더블클릭 = 그 자리 입력)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:53:46 +09:00
eomsangdonandClaude Opus 5.5 f211ff42a5 fix(b05,b06): 종단도 누가토량 알약 테두리 · 글자 · 바탕 붉은 계통 · 음수는 더 진하게 (289-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-12 20:52:31 +09:00
eomsangdonandClaude Opus 5.5 62008429b0 chore(plan): 10-12 요구 186(누가토량 알약 테두리 · 색 붉은 계통)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:49:31 +09:00
eomsangdon 54aa635f0e Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-12 20:47:46 +09:00
eomsangdon cae69d1aa2 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-12 20:47:08 +09:00
eomsangdonandClaude Opus 5.5 e7c9fe3a05 fix(b05,b06): 구조물 배치 — 신규 A군 관은 [추가] 뒤에만 생김 · 기준 측점 입력은 관 정본에 안 넣음 (291-1)
- 원인 = 측점 확정 때 임시 배치(commitTempPipe)가 관 정본 초안에 바로 넣음 · B · C 군은 원래 [추가] 전 기록 없음
- 측점 확정은 옵션 잠금 풀기만 · 우클릭 「추가」(116-1)만 임시 배치 그대로(commitTempPipe(true))
- 새 세월교 · 물넘이 빈 월류 높이는 [추가] 뒤 재계산 유량으로 채움(126-1 fordWaits) — 미리보기 불요
- 시험 test_291_1_add_only_on_add 새로 · test_116 우클릭 길 판정 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 20:46:40 +09:00
eomsangdonandClaude Opus 5.5 743718fe01 chore(plan): 10-12 요구 185(물넘이 · 맹암거 형상 · 포장 고름이 보라색 손잡이 끌기와 안 섞이게)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:46:13 +09:00
eomsangdonandClaude Opus 5.5 3610206fa9 fix(b05,b06): 종단도 누가토량 알약 한 단 크게 — 공용 토큰 · 측점 ▲ 줄 아래로 (289)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-12 20:45:58 +09:00
eomsangdonandClaude Opus 5.5 0bf50a1837 feat(common): 다른 프로젝트 열면 그 밖 프로젝트 캐시 지움 · 저장 안 한 고침은 확인창 (290-1)
- b_project_switch.enterProject — 공용 goToWorkflowStage 에서 부름 · 탭마다 들어온 표식이 다를 때만
- 다른 프로젝트 세션 칸(초안 · 결과 · 옛 키 · 키 마디에 번호가 든 페이지 칸) + 3D 보관함 지움 · 지금 프로젝트 · 계정 취향 그대로
- 다른 프로젝트에 저장 안 한 고침이 있으면 확인창(지우고 열기 / 남기기) · 남기기 = 그 초안만 둠
- showConfirmDialog 취소 단추 글 받는 칸(기본 「취소」)
- 시험 test_290_1(진짜 TS · 가짜 저장소)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-12 20:44:18 +09:00
eomsangdonandClaude Opus 5.5 0c7f71d103 feat(b05): 물넘이포장 도수로/돌붙임 길이 ↔ 면적 — 넣음 = 월류 폭 × 길이 · 비움 = 기본값 (287-1)
- syncChuteArea(design_structure_ford_calc) — 길이 · 월류 폭 칸이 바뀌면 면적 칸을 다시 채움
- 폭 = 띠가 도로 방향으로 걸치는 월류 폭(비면 물넘이 기본 폭) · 그림은 286-3 대로(길이 = 그 길이 · 비움 = 원지반까지)
- 시험 test_287 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:44:14 +09:00
eomsangdonandClaude Opus 5.5 71f8ee7248 chore(plan): 10-12 요구 183 · 184(물넘이포장 후속 · 맹암거 횡단 · 누가토량 알약 · 프로젝트 캐시 · 신규 구조물 [추가] 뒤 · 브레인 검증) · 그림_183
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:39:09 +09:00
eomsangdon 93400e3e5c Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-12 20:16:09 +09:00
eomsangdonandClaude Opus 5.5 6d25379469 feat(b05,b06): 물넘이포장 도수로/돌붙임 = 다른 구조물과 같은 칸 + 맨 아래 「길이 (m)」 — 그림 전용 (286-3)
- 명세 — 도수로/돌붙임 칸 = CHUTE_ITEMS(구조 · 형태 · 두께 · 면적) + 길이(ford_chute_{n}_length_m)
- 서버 _chute · TS chute 짝 — 길이 있으면 제원에 length_m
- 횡단 · 3D 띠 — 유출 성토사면을 노견에서 사면 따라 그 길이까지(비거나 사면보다 길면 원지반까지)
- 길이 = 그림 전용 · 집계 열 · B08 돌붙임 수량(면적)에 안 실림
- 시험 test_286_1 · 물넘이 명세 시험 기대값 새 칸으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:15:17 +09:00
eomsangdonandClaude Opus 5.5 7e1b5bd27e feat(b06): 물넘이포장 자리 횡단 = 측구 없음 · 노면은 표고 낮은 쪽으로 기움 (286-2)
- 설계 계산 ford_paved(파이썬) · fordPaved(TS 짝) — 물넘이 범위 안 측점은 측구가 안 섬(사용자 측구 선택보다 먼저)
- 서버 정본 — ford_pavement_ranges 로 기본 설계 · [저장] 재계산 · 포장 범위 맞춤이 판정 · 저장분 재계산은 되실은 값을 이음
- 화면 — 계획선 편집 재계산이 얹힌 물넘이 제원으로 같은 판정
- 노면 기울기 — 유입 = 설계선 양 끝 표고가 높은 쪽(같으면 단면유형) · 경사각도만큼 낮은 쪽(유출)으로 · 도수로 띠도 같은 쪽
- 시험 test_286_1 · 횡단 설계 거울에 물넘이 갈래 · 도수로 띠 시험 기대값 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:09:41 +09:00
eomsangdonandClaude Opus 5.5 0c0b169d9e chore(plan): 10-12 요구 181 · 182(프로젝트 오갈 때 고름 유지 · 횡단배수군 측점 없는 종류 — 물음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 20:05:05 +09:00
eomsangdonandClaude Opus 5.5 04d16e0e2f feat(b05,b06): 물넘이포장도 종단 기준 측점 하나 심음 — TS · 파이썬 짝 · B05 · B06 거름 걷음 (286-1)
- pipeStationMarks · pipe_station_label 물넘이포장 예외 걷음 · 라벨 「물넘이포장」
- B05 withoutFordPavement · B06 pipeStations · 초안 라벨 거름 걷음 · 알약 = 그 측점선 짝
- 시험 test_286_1 새로 · 272_1 · 284_1 · 옛 물넘이 측점 시험 기대값 새 규칙으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ
2026-10-12 20:03:11 +09:00
eomsangdonandClaude Opus 5.5 0c7d584ef9 chore(plan): 10-12 요구 180(고름 강조 저장 자리 확인 · 물넘이포장 종횡단 설계)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:57:14 +09:00
eomsangdon c5c21a7fa1 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-12 19:52:27 +09:00
eomsangdonandClaude Opus 5.5 54cc970746 fix(b05,b06): 종단도 알약 고름 · 풂 — 측점 없는 알약 = 측점선 안 켬 · B06 다시 누름 = 풀기 한 길 (284)
- 측점 있음 = 제 측점을 심는 알약(272 한 벌 — A군 · C · D 구간형 · 관은 물넘이포장 뺌) · 그 밖 알약은 같은 자리 · 근처 측점선을 짝으로 켜지 않음(plantsStation · stationIdAtStructure)
- B06 알약 다시 누름 = Esc 와 같은 풀기 한 길(카드 · 폼 · 알약 · 범위 테두리) — 옛 처리는 풂 신호를 버림
- B06 측점 없는 구조물 고름 = 노면공처럼 카드 안 고름 · 스크롤만 · 물넘이포장 알약 = 그 관 폼 · 알약 · 고름 값에 알약 id(selectPipeAt)
- B05 물넘이포장 알약 = 고름 값에 알약 id(같은 m 일반 측점선 안 켬)
- 시험: test_284_1 새로 · 옛 시험 글 대조 · 표지 카드 기대값(None) · 헬퍼 stub(진짜 plantsStation)
- 기준점 = 태그 base-284-pill-before(282e9fcfc) · 되돌리기 = 이 커밋 git revert

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:52:13 +09:00
eomsangdonandClaude Opus 5.5 31a3b74d97 feat(b05,b06): 종단 도구줄 [새 창] → [창 분할] · [제자리로] → [창 결합] (285-1)
- 단추 글 · 풍선만 바꿈 — 자리 · 동작 · 팝업 막힘 안내 그대로
- test_282_1 글 대조 새 이름으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-12 19:41:50 +09:00
eomsangdonandClaude Opus 5.5 282e9fcfc8 chore(plan): 10-12 요구 179(알약 고름 · 풂 브레인 직접 · 새 창 → 창 분할 · 제자리로 → 창 결합)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:40:12 +09:00
eomsangdonandClaude Opus 5.5 019c79f9cc chore(plan): 조사 AM — 종단도 알약 고름 · 풂(B06 풂 끊김 · 측점 없는 구조물 근처 측점선 · 고치지 않음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:26:13 +09:00
eomsangdon 688472612b Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-12 19:19:59 +09:00
eomsangdonandClaude Opus 5.5 2fa68b95d8 feat(b05,b06): 종단 패널 도구줄 — [배수유역도] 켬끔 단추 지움 · [새 창] = [초기화] 왼쪽 (282)
- 배수유역 판 켬끔(setVisible · drainage-visible 값) 지움 — 판은 늘 보임 · 접기 · 펼치기는 판 손잡이 하나로 · 옛 값 false 무시
- [새 창] 단추 = 도구 묶음 맨 앞(toolLead) — 다시 그릴 때마다 [초기화] 바로 왼쪽에 옮겨 붙임 · 열림 · 닫힘 동작 그대로
- 시험 test_282_1 추가 · 옛 단추를 찾던 시험(187-1 · drainage_side · profile_single) 새 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
2026-10-12 19:19:05 +09:00
eomsangdonandClaude Opus 5.5 0daebace79 chore(plan): 10-12 요구 178(종단도 알약 고름 강조 · 풂 — 고치지 않고 코드 확인만)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:17:31 +09:00
eomsangdon c2778280fe Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-12 19:16:26 +09:00
eomsangdon 50c6056e95 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-12 19:15:26 +09:00
eomsangdonandClaude Opus 5.5 219e7fc223 feat(b05,b06): 배수유역 지도 줌아웃 한계 = 노선 기준 — 노선 범위 2 배 · 유역 밖이면 유역까지 · 도엽 전체까지 · 창 크기 바뀌어 밖이면 노선 맞춤 (281)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
2026-10-12 19:15:13 +09:00
eomsangdonandClaude Opus 5.5 190066ca86 feat(b05,b06): 구조물조합 이름 칸 누르면 고르기 · 「구조물 조합 선택」 모달 위 표 ↔ 나무 ↔ 카드 · 층 단추 = 그 층 표 (280)
- 280-1 이름 칸 = 고르기(누름 · Enter · Space · 손 커서 · 풍선 · 초점 테두리) · [선택] 단추 지움 · [해제] 그대로 · 본체 · 그룹 · 명세 잇기 같은 줄 부품
- 280-2 모달 제목 「구조물 조합 선택」 · 위 표 열 고름 → 나무 펼쳐 고름 · 카드 · 층 단추 → 위 표 = 그 층 표 · B08 연결 같은 모달
- 280-3 M02 구조물 조합 = 따로 짠 길로 두 고리 정상 · 고침 없음
- 시험 test_280_1_combo_label_pick 새로 · test_224_1 · test_b05_structures_form_summary 새 꼴에 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
2026-10-12 19:14:50 +09:00
eomsangdonandClaude Opus 5.5 eaaa5ab71f chore(plan): 10-12 요구 177(종단 패널 도구줄 배수유역도 단추 지움 · 새 창 = 초기화 왼쪽)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:11:44 +09:00
eomsangdonandClaude Opus 5.5 9208de6435 chore(plan): 10-12 요구 176(B05 · B06 배수유역 지도 줌아웃 범위 = 노선 기준)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:09:21 +09:00
eomsangdonandClaude Opus 5.5 a12f28a7e5 chore(plan): 10-12 요구 175(B05 · B06 구조물조합 칸 · 구조물 조합 선택 모달 · M02 같은 흠 확인)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 19:02:27 +09:00
eomsangdonandClaude Opus 5.5 bb6ae4faf5 fix(b08,b09): 보조 탭 단추 높이 한 꼴 — 공용 토큰 24px · 공용 보조 탭 줄 양식 · 도구는 줄 높이 안 바꿈 (279)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD
2026-10-12 18:58:56 +09:00
eomsangdonandClaude Opus 5.5 fa5fa3bff9 chore(plan): 10-12 요구 174(B08 · B09 보조 탭 단추 높이 한 꼴)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 18:50:47 +09:00
eomsangdonandClaude Opus 5.5 04659a028f feat(b09): 운반 단가 창 왼쪽 폭 줄임 · 단가산출근거 박스보기 머리 줄 = 입력값 · 중간값 칸 (278-1·2)
- 운반 단가 창 몸 두 칸 비율 — 왼쪽(옵션 값) 약 0.8 배
- 단가산출근거 [박스보기] 머리 줄 = 운반 창 ③ 칸과 같은 꼴(이름 · 값 오른쪽 굵게 · 식 작게) · 출처 = 칸 맨 위
- 줄 나눔 · 그리기 한 벌 — Haul_Steps headSteps 내보내 같이 씀 · 공용 묶음 카드 = 선택 몸 위 칸(top)
- 호표 줄 · 총계 · 표 보기 · 다른 탭 카드 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-12 18:44:11 +09:00
eomsangdonandClaude Opus 5.5 76959c9d88 chore(plan): 10-12 요구 173(운반 단가 창 왼쪽 폭 · B09 단가산출근거 박스보기 중간값 꼴)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 18:38:49 +09:00
eomsangdonandClaude Opus 5.5 f43a36aa39 feat(b09): 운반 단가 창 오른쪽 계산 칸 나눔 · 운반거리 칸 자동계산 단추 (277-1~4)
- 오른쪽 글 상자 → 칸 차례(거리 · 사이클 8-2-8 · 단가산출) · 칸마다 제목 · 몸 · 바닥 결과
- 사이클 칸 = Cm · 사이클시간 · t2 받는 로직만 · 조합 = 담은 로직 줄마다 미리보기 · 하위 산근 이어 붙임
- logic-preview 응답에 blocks(머리 · 호표 줄 · 계 · 출처) 더함 · 셈 · lines 그대로
- 값 칸 숫자 칸 = ui-input--sm(고르기 칸과 같은 높이) · 「거리에서」 → 「자동계산」 단추

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Se7ytqbohmBQnfYpwYFnYf
2026-10-12 18:25:05 +09:00
eomsangdonandClaude Opus 5.5 9413b6c69a chore(plan): 10-12 요구 172(단가산출 로직 안 건드림 · 화면 먼저) · 조사 AL 답(GF000284 속도 = 원문 한 쌍 · 옵션 없음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 18:16:39 +09:00
eomsangdonandClaude Opus 5.5 be9250b7e7 chore(plan): 10-12 요구 170 · 171 · 조사 AL(운반 단가 창 오른쪽 계산 · 운반거리 칸)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 17:10:45 +09:00
eomsangdonandClaude Opus 5.5 71f87ea19e chore(config): 구글 시트 로그인 토큰 갱신(옛 토큰 만료 · invalid_grant)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 16:59:39 +09:00
eomsangdonandClaude Opus 5.5 cf704c8459 chore(plan): 271~274 · 276 이관(요구 166~169 · 사용자 10-11)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK
2026-10-12 12:47:07 +09:00
eomsangdon e147242906 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-12 06:14:43 +09:00
eomsangdonandClaude Opus 5.5 7c6735613e feat(b08,b09): 단가연결 — 연결 없음 = 조회 → 옵션 모달(옛 길) · 연결 있음 = 작은 운반 단가 모달 (276)
- B08 거리이정표 우클릭 [단가연결] · B09 운반공 줄 — 연결 없으면 단가연결 목록 → 옵션 값 창(askInputs)
- 연결 있으면 「운반 단가」 모달 · 그 밖 B09 줄 그대로
- 운반 단가 모달 크기 96vw × 94vh → min(1100px, 94vw) × min(680px, 86vh) · 가운데 · 두 칸 따로 스크롤
- 옛 결정(연결 · 미연결 같은 모달)을 고정한 시험 두 줄 고침 · test_276_1 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjUPRxjDxULvh5VA7kX5py
2026-10-11 21:45:06 +09:00
eomsangdon 132935b395 Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-10-11 21:31:39 +09:00
eomsangdonandClaude Opus 5.5 192d4feebe fix(b01): 챗봇 창 블랙 · 화이트 테마 색 맞춤 · Enter 보내기 · Shift+Enter 줄바꿈 (275-1·2)
- 없는 색 변수(--color-text-primary · --color-background) 걷고 테마 변수로 — 어두운 테마 제목 · 말풍선 글자 안 보임 해소
- 단추 · 내 말풍선 = primary 색 짝 · 답 말풍선 = 떠 있는 표면 + 테두리 · 입력칸 바탕 · 글자
- Enter = 보내기 · Shift+Enter = 줄바꿈 · 한글 조합 중 Enter 무시
- 화면 시험 ui_checks/275 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KjUPRxjDxULvh5VA7kX5py
2026-10-11 21:28:54 +09:00
eomsangdonandClaude Opus 5.5 7b5ee62a42 fix(b06,b07): 표 위 CAD 배수관 원형 마크 — 아래 수직 지시선 없앰 (274-1)
- B06 상세 모달 표 위 CAD 미리 넣음(pipeMark)과 B07 짝(_pipe_mark) 모두 원 + 원 안 세 줄만
- 요구 169 원문 기록 · 시험 260 · 265 기대값 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 20:56:17 +09:00
eomsangdonandClaude Opus 5.5 dfd7427c46 feat(b06): 횡단도 바로 반영 — 새로 놓거나 옮긴 관 · 구조물의 횡단 카드를 [저장] 전에 보임 (273-2)
- 서버: 읽기 전용 미리 뜨기 POST /section/preview-stations — 로그인 · 회사 · 프로젝트 권한 그대로 · 한 번에 60곳 · 파일 · DB 안 씀 · 저장과 같은 길(sample_structure_sections · compute_default_designs crosses)
- 화면: 초안이 심는 자리 중 횡단 없는 곳만 묻고 한 번 뜬 자리는 기억(B06_Section_Section_Drafts) · 카드 이름 · 관 그림 얹음 · 늦은 옛 응답 무시 · 배수 재계산이 관경을 정하면 카드 다시 얹음 · B06 관 옮김도 카드 다시
- [저장]은 지금처럼 서버가 같은 계산으로 정본에 씀 — 미리 본 카드와 같은 값
- 알림 글: 횡단도는 바로 보이고 세부 배수유역만 저장 뒤 다시 셈
- 시험: test_273_2 새로 · 177-3 헬퍼에 revealPick 자리 채움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 20:48:32 +09:00
eomsangdonandClaude Opus 5.5 887c89bbf8 feat(b05,b06): 종단 그래프 — 다른 영역에서 고른 측점이 화면 밖이면 가운데로 이동 (273-1)
- 칠한 결과(고른 측점선 · 없으면 고른 구조물 알약)를 보고 보이는 칸 밖일 때만 가운데로 옮김(createSelectionReveal · design_profile_viewport)
- 같은 고름을 다시 칠하면 안 밂(사용자가 옮겨 본 자리 지킴) · 그래프 안 누름은 보이는 자리라 안 움직임 · 고른 뒤 다시 그리기가 끊지 않게 바로 옮김
- 요구 168 원문 기록 · 시험 test_273_1 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 20:20:24 +09:00
eomsangdonandClaude Opus 5.5 93e86f87c6 fix(b05,b06): 측점 한 벌 — 정수 m · 같은 m 측점 하나 · 목록 전체 다시 맞춤 (272)
- 규칙: 구조물 · 관 기준 측점 = 정수 m 사사오입(일반 측점 1 m 붙기 뺌) · 측점 심는 자리 TS 짝(plantedChainages ↔ resolve_extra_stations) · 같은 m 면 구조물 측점 하나(격자 자리 grid) · 떠나면 일반 측점(oneStationList ↔ _merge_irregular_into_longitudinal)
- 서버: B05 [저장] · B06 저장 · [측점 만들기]가 목록 전체 다시 맞춤(빈 목록도 · 옛 글 · 횡단 파일 글) · 계획선 측점 줄 다시 셈 · 정본에 없는 횡단 행은 0.5 m 안 새 측점으로 옮기고 나머지 지움 · 옛 소수 측점 꼭짓점 · 편집은 정수 자리로 · 맞춤 실패는 저장을 안 막음
- 화면: 종단 그래프 · 3D · 표 · ▲▼ 한 목록(초안 반영) · C군 구간형 · 독립 기슭막이 시작 · 끝 측점 · B06 카드는 초안에 없는 옛 구조물 측점 숨김 · 고름은 같은 m 구조물 측점 먼저 · 폼 거리 칸 정수 표기 · C군 계산 구간 · 포장 나눔 경계 정수
- 옛 자료: [정수 맞춤] 단추 없앰 · 종단 보기 열 때 한 번 자동 정수 · 알림 한 줄 · [저장]하면 남음
- 시험: test_272_1 · test_272_2 새로 · 234_1 · 238_1 · b05_extra_stations_supplier · b06_patch_station · b05_station_roundtrip · b05_b06_save_roundtrip 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 19:53:24 +09:00
eomsangdonandClaude Opus 5.5 06390f7a0d chore(plan): 조사 AK 8장 · PLAN 272 측점 한 벌 리팩터링 계획
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 18:37:15 +09:00
eomsangdonandClaude Opus 5.5 26437ef19c chore(plan): 10-11 요구 167(측점 정수 · 겹침 근본 해결)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 18:35:02 +09:00
eomsangdonandClaude Opus 5.5 c0626e3ccb chore(plan): 10-11 조사 AK(정수 맞춤 · 측점 겹침 · 강조)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 18:16:58 +09:00
eomsangdonandClaude Opus 5.5 2399c533c6 feat(b06,b07): 표 위 CAD 칸 미리 넣음 = 구조물 조합 이름 + 좌측 패널 옵션 줄 (166-1)
- 구조물마다 조합 줄 뒤에 좌측 패널 옵션 줄 — 폼과 같은 컨테이너 · 이름 · 보이는 칸(`structureOptionLines`) · 구조 갈래에 안 맞는 옛 값 뺌 · 필수 상자 빈 칸 = 폼 기본값
- B06 [상세] 모달 = 화면 TS 직접 · B07 = 같은 TS 를 Node 번들(B07_DesignDetail_Note_Options_Node · build:b07-note)로 받아 파이썬 짝이 붙임 · 실패는 조합 줄만
- 번들 낡음 판정에 A00_Common/design 더함
- 시험 test_166_1(진짜 명세 · B07 = 화면 같은 줄) · test_260 · test_265 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 17:55:45 +09:00
eomsangdon c61f9a9e9a Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-11 17:40:56 +09:00
eomsangdonandClaude Opus 5.5 d85529c782 chore(plan): 10-11 요구 166(CAD 글 옵션 · 정수 맞춤 검토)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 17:40:36 +09:00
eomsangdon af9d4195aa Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-11 17:32:34 +09:00
eomsangdonandClaude Opus 5.5 07862ff178 chore(plan): 262~270 이관(요구 164 · 165)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 17:31:22 +09:00
eomsangdonandClaude Opus 5.5 e09c6863f5 fix(design): 횡단 보기 배수유역도 = 고름 한 칸에서 파생 · 지도 고름 = 고름 한 칸 (270-1)
- 횡단 보기 구독이 낸 쪽과 무관하게 한 벌 배수유역도(관 마커 · 유역 · 측점 마름모)를 값으로 칠함 — 목록 · 카드 고름이 지도에 이어짐 · 종단 보기에서 칠한 옛 강조 안 남음
- 횡단 보기 종단도 알림에 유역 · 관 고름(onBasinSelected · onPipeSelected) = 고름 한 칸(from map) — 넘김 적용 길로 카드 · 폼 · 알약 · 표 열이 따라옴 · 같은 유역 다시 누름 = 모두 풂
- 배수 재계산(onPipesChanged) 뒤 횡단 보기가 보일 때 지도 강조 다시 얹음
- 시험 test_270_1_cross_map(진짜 bindCrossPick · paintMapPick)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 17:31:07 +09:00
eomsangdon f0dd4a8d75 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-11 16:56:47 +09:00
eomsangdon 8e0c3bfc03 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-11 16:56:46 +09:00
eomsangdonandClaude Opus 5.5 f1ed24d17b refactor(B09): 264 공기 옵션 카드 = 좌측 패널 공용 카드 꼴
- createPanelCard · 공용 칸(createInputField · createSelectField) · 공용 체크박스로 짜기
- B05 스타일 CSS 덧칠 import 지움 · 동작 · 저장 칸 그대로
- 숨김 = style.display(공용 카드 flex 가 hidden 을 덮음) · 두 칸 줄은 바깥 div 로 감싸 접기 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 16:56:14 +09:00
eomsangdonandClaude Sonnet 5.5 af8b667055 fix(test): 201-1 셸 cellColumn 개수 기준을 257-3 이후 코드에 맞춤(== 2 → >= 2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 16:54:13 +09:00
eomsangdon 06afa29d88 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-10-11 16:51:35 +09:00
eomsangdon 8c351bfbf5 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-10-11 16:51:33 +09:00
eomsangdonandClaude Opus 5.5 9e3c87edd5 feat(B05,B08,B09): 269-3 B05 이상 좌측 컨테이너 공용 양식 점검 · 맞춤
- B05 칸 틀(페이지 설정 · 계획노선 · 선택 포인트) = .ui-panel-form
- B08 옵션 카드 · B09 데이터 점검 카드 = createPanelCard · 틈 덧칠 정리
- 아래 단추 묶음은 그대로
- 시험 test_269 점검(빠진 곳 0) · 122 · 203-1 · 240-1 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 16:50:56 +09:00
eomsangdonandClaude Opus 5.5 2c8c715cc2 feat(B09): 263-2 사전조합 · 구조물 조합 운반 = 담은 로직 입력 + 거리 값 사본
- 모달 = 담은 로직 이름별 입력(library-inputs) · 거리 칸 거리에서 · 손 값(own) 이김
- 사본 폴더 = 종류@층@주인@키~값해시8 · 같은 값 = 같은 사본 · 다른 거리 = 따로 · 옛 이름 읽힘
- 셈 때 서버가 거리로 사본 다시 고름(resolve_hauls · fix_keys) · [저장] = 바뀐 연결 저장 값에도 · prune
- 연결 {tab dg, key, lib, given, own} 저장 꼴 · 원본 읽기 전 모양 검사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 16:50:28 +09:00
eomsangdon 9fbaf22565 Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_6' into sub_laptop_3 2026-10-11 16:49:13 +09:00
eomsangdon 23e97a8765 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-11 16:49:11 +09:00
eomsangdonandClaude Opus 5.5 a1d63ebfd6 feat(B06): 상세 창 CAD 칸 표 아래 접기 · 구조물 조합 줄 미리 넣음(265-1 · 265-2)
- 상세 창 차례 = 그림 → 표 → CAD 칸 · 칸 머리 한 줄 접기(공용 ui-collapsible) · 열 때 일반 측점 접힘 · 구조물 측점 폄 · 처음 펼 때 칸 만듦
- 미리 넣는 글 = 그 측점 구조물(관 지점 · 구조물 배치 기준 측점)마다 지정된 구조물 조합 이름 줄 · 미지정 = 「이름 조합 미지정」 · 배관 = 원형 마크(본체 줄 없음) · 옛 관보호공 · 관유출부보강 짜 맞춘 글 버림
- B07 짝(CulvertNote.py) 같은 규칙 · 정본 structures.json · 구조물정의 이름까지 읽음
- 시험 test_265_note_cad 넓힘 · test_260 · test_150 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 16:48:15 +09:00
eomsangdonandClaude Sonnet 5.5 d496517ce4 fix(B07): 횡단도 노란 측구 덧그림 걷음 · 배수관 · 박스암거 덧그림 색 따로(268)
- 측구 구간 구조물 선(b08-structure)도 design_ids 에 실음 — 화면 구조물 덧그림이 같은 자리를 그릴 때 서버 노란 측구가 한 겹 더 남던 것 걷음 (장 · 단일 도면)
- 박스암거 · 배수관 덧그림을 구조물(노랑)과 다른 청록(#22d3ee)으로 — 설계선 보라 · 지반선과 구분
- 시험 test_268_b07_cross_ditch_pipe_color.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 16:47:57 +09:00
eomsangdonandClaude Opus 5.5 c3e56f2e68 feat(B06): 269-2 좌측 컨테이너(횡단 보기 설정 · 표준 횡단면 설정) = 공용 양식
- createSectionFrame 횡단 칸 틀에 .ui-panel-form · 칸 · 라벨 · 단추 = 「구조물 배치」 기준
- B06 덧칠(칸 글자 · 색 · 오류 칸 접기) 걷음 · 줄 배치만 남김
- 시험 test_269_panel_form B06 항목

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 16:45:03 +09:00
eomsangdonandClaude Opus 5.5 3939cd11f9 feat(B07): 269-1 좌측 패널 컨테이너 공용 양식(구조물 배치 기준) · 공용 카드 함수
- ui_template: .ui-panel-form(칸 26px · 라벨 틈 2px · 캡션 글자 · 단추 크기) · .ui-panel-btn · createPanelCard
- 칸 높이 · 라벨 틈 토큰 = theme 한 곳 · 「구조물 배치」도 같은 토큰
- B07 평면도 · 횡단도 묶음 = 공용 카드 · 도면 단추 = .ui-panel-btn · 페이지 덧칠 걷음
- 시험 test_269_panel_form · 212-3 기대값 · 130 · 153 헬퍼 공용 카드 연결

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 16:42:57 +09:00
eomsangdon a2b67626bb Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-11 16:41:14 +09:00
eomsangdonandClaude Opus 5.5 0db53a9032 feat(B09): 264-1 왼쪽 패널 「공기 옵션」 카드
- draft 새 파일 B09_Draft_Schedule_Panel — 착공일 · 기후 자료 · 쉬는 날 · 공휴일 넣기 · 일 작업시간 · 투입 기본 · 준비 · 정리 · 기타 · 반영률 · 주 장비 기준 · 중복 C 끝수 · 마지막 달
- 값 = user.schedule(캐시 → [저장]) · 표 칸과 같은 값 · 셈마다 카드 맞춤
- 훅 = DRAFT.panels(기본 빈 목록) · Shell 「데이터 점검」 아래 한 줄
- 공기집계 비고 = 고른 옵션을 따름 · 착공 칸 「연.월.일」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 16:40:29 +09:00
eomsangdonandClaude Opus 5.5 fe2ed66268 feat(B09): 264-2 공기 옵션 셈 짝 · 다듬기 · 시험
- 셈 짝(Py · TS) 새 옵션 — 착공일(그날부터 · A 날수 비례) · 기후 자료 고르기 · 쉬는 날 · 공휴일 빼기 · 투입 기본 · 반영률 기본 · 주 장비 기준(장비 시간 합) · 중복 C 끝수 · 마지막 달 비작업
- 원천 = 기후 셋(거창 25 · 울진 24 · 울진 소광 25 · 0에서 직접) · 단위당 장비 시간 합
- clean_schedule 새 칸(모르는 값 · 범위 밖 버림)
- 시험 test_264_2 — 기본값 = 지금 결과 · 옵션마다 값 바뀜 · 거울 TS = Py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 16:40:21 +09:00
eomsangdon ff738b7a3c Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4 2026-10-11 16:37:04 +09:00
eomsangdonandClaude Opus 5.5 d909d6aabe feat(B09): 263-1 운반 단가 모달 = 고른 항목 입력만 + 거리 자동
- 사이클시간 입력 · 8-2-8 표 고르기 칸 없앰 · 사이클 = 8-2-8 기본값(계산 절차에만)
- 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 거리에서 · 손 값 이김 · 거리 칸 없는 항목 = 사이클 줄 없음
- [변경] = 단가연결 한 목록(onPick) · [다른 종류] 없앰 · 조합 = [적용] 때 사본 들여옴 · 호표 = 그대로 연결
- 옛 cycle = 서버 읽기 그대로 · [적용]하면 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 16:36:35 +09:00
eomsangdon 6279ac547c Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-11 16:36:32 +09:00
eomsangdonandClaude Opus 5.5 3a08e4df9d feat(B06): 상세 창 처음 가로 범위 = 횡단 보기 반폭 (266-1)
- 칸 맞춤이 넓힌 가로를 처음 배율(homeScale)로 되좁힘 · 개별 표시 반폭 측점은 그 값
- 공용 줌 · 팬 homeScale 칸 — 처음 · [⤢] · 더블클릭 = 그 배율 · 줄이면 칸 맞춤 전체까지
- 시험 test_266 · 164 · 173 기대값

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 16:35:42 +09:00
eomsangdon f453a9ce43 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_3 2026-10-11 16:35:38 +09:00
eomsangdonandClaude Opus 5.5 c47e9f8615 fix(B06): 상세 CAD 칸 원형 마크 관 길이 사사오입 정수(265-3)
- 원형 마크 L = pipeLengthWhole 정수(8.5 → 9 · 8.4 → 8) · B07 짝 같은 규칙
- 거울 시험 test_265_note_cad · test_260 기대값 L=22m

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 16:34:15 +09:00
eomsangdon bd6f6d8836 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-10-11 16:34:03 +09:00
eomsangdonandClaude Sonnet 5.5 5fdfc64d10 fix(B06): 횡단도 관매설 · 세월교 정보 글 크기 = 노폭 라벨 글 크기(267-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-11 16:33:24 +09:00
eomsangdonandClaude Sonnet 5.5 b2ad4f7e40 feat(B08): 262 상자 창 제목 이름만 · 소재지 지도 시점 표지 고정
- 거래처 · 기준점 창 제목 = 이름만 · 시점 창 = 「시점 · 중점」
- 소재지 지도에 시점 표지(고정 · 끌기 없음) · 소재지 없으면 시점 중심

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 16:33:24 +09:00
eomsangdonandClaude Opus 5.5 81950486c7 chore(plan): 10-11 요구 165(B07 측구 · 관 색 · 좌측 패널 양식 · 하이라이트 연동)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 16:28:36 +09:00
eomsangdonandClaude Opus 5.5 e2ce3b5f22 chore(plan): 10-11 요구 164(거리이정표 · 공기 옵션 · B06 상세 CAD)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 16:11:34 +09:00
eomsangdonandClaude Opus 5.5 a3be7da0af chore(plan): 261 이관(내용)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 15:42:01 +09:00
eomsangdonandClaude Opus 5.5 3136eb32c6 chore(plan): 261 이관 · 사용자 결정 거리 146-6 · 146-7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 15:41:47 +09:00
eomsangdonandClaude Opus 5.5 976bd868b9 fix(test): 164 줌 도우미에 표 위 CAD 칸 흉내 더함 (260 마무리)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 15:40:20 +09:00
eomsangdon 254e3c0c6d Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-11 15:38:07 +09:00
eomsangdonandClaude Opus 5.5 2bf429ec98 feat(b06): 상세 누름 판정 거리로 — 보통 배율 얇은 띠 = 영역 · 선 가까이 = 선 (261-3 걸음 1)
누름 = 판정 선 굵기 대신 누른 자리 거리(`pickAt`) — 선 4 px 안 = 선 · 영역 안이면 맞은편 선까지 ⅓ 안이어야 선.
선 누름이 표 띠 누름보다 앞섬(stopImmediatePropagation) · 판정 선 굵기 10 → 8 px(걸침 표시만).
시험 test_261_3_detail_pick(견본 · 실측 4+15 그림) · 233-2 헬퍼 누름 꼴 맞춤.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 15:37:19 +09:00
eomsangdonandClaude Opus 5.5 c55f614d50 feat(b06): 상세 영역 — 벽 · 집수정 · 본체 둘레를 구조물 선으로 (261-3 걸음 2)
설계선 끝 ~ 깎기 비탈 밑을 집수정 둘레가 이어 깎기 암석(암선 ↔ 설계선) · 깎기 토사(지반 ↔ 암선) 영역이 따로 닫힘.
배수관 측점(검증_랩탑메인 4+15) 깎기 암석 영역 18.24㎡ 생김 · 일반 깎기 측점은 그대로(그림 칠 면적과 같음).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 15:36:55 +09:00
eomsangdonandClaude Opus 5.5 b751f29b01 fix(b09): 운반공 줄 이름 = 거리이정표 항목명 그대로 · 행마다 개별 줄 (261-4)
- 「운반」 덧붙임 없앰 · 빈 항목명 = 거래처 · 그것도 없으면 묶음 이름 · 「운반」
- 자동 상자 둘째 행부터 `haul:{묶음}.n` 개별 줄(묶음 줄 바로 뒤 · 수량 빈칸)
- 시험 기대값 고침(253_1 · 251_1 · 145_3) · 새 시험 test_261_4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 15:19:06 +09:00
eomsangdonandClaude Sonnet 5.5 4737756502 fix(b08): 거리이정표 km 칸 끝수 정리 — 3자리 반올림 · 끝 0 지움 · 안 고치면 저장 m 그대로 (261-1)
- 시점 창 · 구간 창 · 다이어그램 선 위 km 칸 · 자동 값 글 = 같은 도우미(fmtKm)
- 안 고친 칸은 반올림 값으로 덮지 않음
- 시험 test_261_1 추가 · test_255_1 기대값만 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 15:17:40 +09:00
eomsangdonandClaude Sonnet 5.5 f0f35c5749 fix(b09): 공사원가계산서 직접재료비 이동 대상을 총괄 가. 순공사비계(direct)로 (261-2)
- 이동 대상 키 material 이 총괄 줄에 없어 빨강 상자가 안 뜸
- 시험 추가 - 모든 원가 줄 이동 대상이 총괄 줄 키에 있는지

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 15:16:12 +09:00
eomsangdonandClaude Opus 5.5 c8e861e90a chore(plan): 10-11 요구 163(화면 검증 흠 답)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 15:12:55 +09:00
eomsangdonandClaude Sonnet 5.5 6e53acfcac fix(b09): 설계내역서 안내 줄 · 머리 주석 단가연결 칸 램프 꼴로 (257 마무리)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 14:22:19 +09:00
eomsangdon 53e49063d4 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-11 14:19:38 +09:00
eomsangdonandClaude Opus 5.5 9ae5ee1439 chore(plan): 253~260 이관(요구 162 묶음 끝)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 14:18:47 +09:00
eomsangdon 2f93c84bb7 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-11 14:17:37 +09:00
eomsangdonandClaude Opus 5.5 96c9eaed37 feat(b07): 안 연 측점 횡단배수 미리 넣음 — 파이썬 짝 (260-4)
- 새 B07_DesignDetail_Engine_Cad_CulvertNote = TS culvertNoteEntities · stationNoLabel 짝(두 파일 머리 표시)
- 횡단 장 만들 때 CAD 칸 없음 · 옛 글 없음 · 배수관 주인 측점 = 정본 pipe_points 로 미리 넣음 칸을 얹은 사본
- 지운 칸(빈 엔티티) · 옛 글 · 설계 없음 · 이웃 측점 = 그대로
- 시험 test_260_note_cad 거울 9쌍(같은 입력 → 같은 엔티티) · 정본 읽기 · B07 그림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 14:16:46 +09:00
eomsangdon 83735340ab Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_8' into sub_laptop_6 2026-10-11 14:12:41 +09:00
eomsangdon 75bfe784cb Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-11 14:12:33 +09:00
eomsangdonandClaude Opus 5.5 41f68b8178 feat(B08): 거리이정표 상자 모달 제목 · 상자 안 장소 그림(254-3)
- 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 항목 정보 없음)
- 장소 그림(256-1 place_images) = 상자 글 아래 132 × 88 · 그림 없으면 자리 없음 · 키 바뀔 때만 다시 받음
- 시험 = test_254_3 새로 · 244_6 · 244_10 node 입력 = stdin(명령 줄 길이)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 14:12:24 +09:00
eomsangdonandClaude Opus 5.5 2c79ff9046 feat(b08): 거리이정표 경로 그림 = 전체 경로(거래처 → 기준점 사슬 → 시점) · 저장 뒤 지문 바뀐 경로만 캡처 (256-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 14:11:39 +09:00
eomsangdon 6c7fc62e08 Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_1 2026-10-11 14:11:07 +09:00
eomsangdon 0e411abdf3 Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-11 14:11:06 +09:00
eomsangdon 8052142cf8 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-11 14:11:03 +09:00
eomsangdonandClaude Opus 5.5 cb2db4ff49 feat(b09): 운반 단가 모달 · 로직 미리보기 길 (253-3)
- POST /api/projects/{id}/estimation/logic-preview — 로직 한 산근을 프로젝트 단가로 풀어 계산 절차 글 · 단가(저장 안 함)
- 「운반 단가」 모달 — 단가산출 이름 · [변경] · [다른 종류] · 옵션 값(로직 입력 + 사이클 입력 · 표 고르기 CC000054~058) · 계산 절차(사이클 식 + 미리보기)
- 여는 곳 = B08 거리이정표 표 우클릭 [단가연결] · B09 설계내역서 운반공 줄 단가연결 · 운반 항목에 거리(dist)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 14:10:38 +09:00
eomsangdonandClaude Opus 5.5 6dd6ced454 feat(b06): 상세 닫힌 영역 고름 · 면적 · 둘레 · 표 칸 → 그래프 · 표 강조 바깥 한 줄 (259-2 · 259-3)
- 나눈 조각으로 면 찾기(열린 영역 제외) · 영역 안 누름 = 고름 · 채움 강조 · 면적 · 둘레
- 선 고름 경사비율 · 닫힌 선 둘레 · 띠 고름 값 글
- 표 칸 누름 = 저장된 조각 그래프 고름 · 띠 강조 + 값 글
- 고른 칸 묶음 바깥 한 줄(겹선 없음) · 줄 물들임 없앰 · 합친 묶음 이름 칸 키 맞춤
- 259-2 · 259-3 이 같은 파일(상세 표 배선)이라 한 커밋

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 14:10:35 +09:00
eomsangdonandClaude Opus 5.5 a00bf2786a feat(b07): 횡단도 표 위 CAD 칸 배치 · 없으면 옛 글 줄 (260-3)
- design.structure_note_cad 엔티티 = 범위 왼쪽 위를 표 왼쪽 · 윗변에 맞춤 · 표 층 · 배율 따름
- 표 · 테두리 · 시트 칸 높이 = 범위 높이 · 칸이 비면 아무것도 안 놓음 · 칸 없으면 옛 글 줄
- 시험 test_260_note_cad B07 몫

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 14:10:20 +09:00
eomsangdon 8ced94e044 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3 2026-10-11 14:10:07 +09:00
eomsangdon 3474e55658 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-11 14:10:05 +09:00
eomsangdon b3325e8e24 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1 2026-10-11 14:10:03 +09:00
eomsangdonandClaude Sonnet 5.5 16854e831e feat(b09): 자재단가대비표 적용 단가 초록 테두리 · 우클릭 적용 단가 바꾸기 (257-3)
- 고른 출처 단가 칸 = 바탕 색 대신 초록 테두리(안쪽 상자 그림자)
- 적용 단가 칸 · 고른 출처 단가 칸 우클릭 = 「적용 단가 바꾸기」 → 출처 목록(왼쪽 누름 메뉴와 같은 항목)
- 칸 우클릭 메뉴 한 자리(TabTable cellMenu · 쉘 배선)
- 시험 test_257_3 · test_183_3 기대값

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 14:09:01 +09:00
eomsangdonandClaude Opus 5.5 53a7b14f94 feat(b08): 거리이정표 거래처 · 기준점 모달 — 칸 이름 = 내용 글 크기 · 소재지 머리 줄 지움 · 기준점 이름 한 줄 · 소재지 위 가로선 · 장소 그림 캡처 부름 (255-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 14:08:59 +09:00
eomsangdon f0febb0d0f Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-11 14:08:13 +09:00
eomsangdonandClaude Opus 5.5 29803ada06 feat(b06): 상세 모달 표 위 빈 CAD 칸 · 횡단배수 미리 넣음 (260-1 · 260-2)
- 새 칸 design.structure_note_cad — 스키마 · 재계산 남김 · 세션 초안 structure-note-cads · [저장] patch
- 상세 모달 글 상자 → 빈 CAD(cad_host bare · 높이 고정 · 닫으면 destroy · 고칠 때마다 초안)
- 처음 열 때 칸이 없으면 옛 글 줄마다 Text · 없으면 횡단배수 주인 측점만 미리 넣음
- 시험 test_260_note_cad · helper_260_note_cad

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-11 14:07:38 +09:00
eomsangdon 5f31458967 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-11 14:07:31 +09:00
eomsangdonandClaude Opus 5.5 2584325b16 feat(B08): 거리이정표 다이어그램 — 기준점 연결 점 · 주소 줄바꿈 · 선 글 두 줄 · 거리 비례 선 길이(254-2)
- 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림)
- 기준점 상자 주소 · 주소 줄바꿈(최대 3줄) + 상자 높이 = 내용 · 항목 글 13px
- 선 글 = 구간마다 두 줄(도로 구분 / km) · 처음 자리 선 길이 = 100 + 90·log10(1 + km)(100 ~ 320)
- 밀어 냄 = 놓인 상자 · 놓인 선 둘 다 피함
- 시험 = test_254_2 새로 · 244 · 250 기대값(두 줄 · 짧아진 선) · 244_9 node 입력 = stdin

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 14:07:14 +09:00
eomsangdon 2f718bc8df Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1 2026-10-11 14:06:10 +09:00
eomsangdon 4dec149964 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-11 14:06:07 +09:00
eomsangdon b6b1201800 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1 2026-10-11 14:05:58 +09:00
eomsangdonandClaude Opus 5.5 5c1eeb759b feat(b08): 거리이정표 경로 탭 — 시점 끌기 = 길찾기 덮개(site.start_pin) · 닿는 구간 모두 다시 길찾기 · 열 때 사슬 구간 모두 · 실패 안내 (255-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 14:05:23 +09:00
eomsangdonandClaude Sonnet 5.5 f468a3a3fa feat(b09): 이동해 온 줄 빨강 모든 탭 · 비고 빈 줄 지움 · 총공사비 아래 고정 줄 (257-2)
- 설계내역서 밖 탭도 이동해 온 줄 = 같은 빨강 상자(줄 누름 · Esc · 방향키로 풂)
- 설계내역서 비고 「빈 줄 N」 지움(Py · TS 짝 · 「(제외금액)」 둠)
- 총괄설계내역서 · 공사원가계산서 총공사비 = 템플릿 아래 고정 줄(줄 이동 누름 빠짐)
- 시험 test_257_2 · 기대값 고침(145_3 · 145_4 · 182 · 185_1 · 242_1)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 14:05:02 +09:00
eomsangdon 560e71cc71 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4 2026-10-11 14:04:44 +09:00
eomsangdon 8c6b0401a4 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-10-11 14:04:41 +09:00
eomsangdonandClaude Opus 5.5 4c7cb49aa7 merge: dev(254-1 여러 기준점) 합침 — 장소 그림 상자 id = bases id (256-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 14:04:14 +09:00
eomsangdonandClaude Opus 5.5 01bca00e3c feat(b08): 운반 사이클시간 짝 · 로직 입력 거리 채움 (253-2)
- B08_Quantity_Haul_Cycle.py · .ts 짝 — 건설품셈 8-2-8 덤프트럭 · 거창 25 단가산출 1308~1347행 재현(t1 5.33 · t2 101.23 · Cm 109.78)
- 로직 입력 이름 채움 Cm · 사이클시간 · t2 · 운반거리 · 덮은 칸(own) 그대로
- B09 셈(hand_sources) 때 거리이정표 거리로 다시 채움 · 덮개 ref 에 cycle · own 남김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 14:03:54 +09:00
eomsangdon 3290b736e5 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-11 14:03:00 +09:00
eomsangdon 29ce206496 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_5 2026-10-11 14:02:19 +09:00
eomsangdonandClaude Opus 5.5 eaf9337638 fix(b06): 상세 고름 — 공용 팬 누름마다 moved 풂 · 암 경계선 나눔 · 고름 (259-1)
- 가운데 단추 팬 뒤 왼쪽 누름 click 삼킴 없앰(거리이정표 다이어그램 같은 줄)
- 암 경계선 조각 고름 · 서버 pick_lines rock 짝 · 스키마 · 타입 rock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-11 14:02:18 +09:00
eomsangdon f4e2fc0ba4 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4 2026-10-11 14:01:55 +09:00
eomsangdon 7c7888235e Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_4 2026-10-11 14:01:54 +09:00
eomsangdon 910dc8d13b Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4 2026-10-11 14:01:52 +09:00
eomsangdonandClaude Opus 5.5 0b5b54c168 merge(dev): 254-1 여러 기준점 받기 — 255-1 시점 모달 km 시험과 충돌 풂(양쪽 살림)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 14:01:29 +09:00
eomsangdonandClaude Opus 5.5 8752bb0d1a feat(b08): 거리이정표 장소 그림 영구 저장 — place/<상자 id>.png · 저장 뒤 바뀐 상자만 캡처 · place_images (256-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-11 14:01:22 +09:00
eomsangdon 769ff14f20 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_1 2026-10-11 14:00:29 +09:00
eomsangdon 7444d0c882 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-10-11 14:00:28 +09:00
eomsangdonandClaude Sonnet 5.5 b342f8f784 feat(b09): 위 탭 설계내역 한 묶음 · 설계내역서 단가연결 칸 램프 (257-1)
- 위 탭 총괄설계내역서 · 설계내역서 = 「설계내역」 한 묶음(총괄 · 설계내역서)
- 단가연결 칸 = 램프(초록 연결 · 노랑 없음) · 풍선 = 글 · 머리 「단가↵연결」 · 폭 44
- 시험 test_257_1 · test_181_1 · test_229_1 기대값

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-11 14:00:21 +09:00
eomsangdon bd7fe3c679 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-11 13:59:23 +09:00
eomsangdonandClaude Opus 5.5 a1c033c3cb feat(b09): 운반공 줄 = 거리이정표 행 1:1 (253-1)
- 자동 상자 = 옛 키 · 사용자 상자 = haul:r-{id} · 둘째 행 haul:r-{id}.{n}
- 이름 = 항목 + 운반 · 수량 빈칸 · 연결 = 경로 ref(설계내역서 덮개가 이김)
- B09 가 거리이정표 표(compute_milestone)를 읽어 행 · 거리(L1 포장 · L2 비포장)를 셈 덧값으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-11 13:59:10 +09:00
eomsangdonandClaude Sonnet 5.5 c2d3b8ccd9 feat(table): 표 폭 CSS 변수 · 고칠 칸 옅은 테두리 되살림 · 구조물 조합 미지정 회색 (258-1 · 258-2 · 258-3)
- 템플릿 표 인라인 width → --dt-w · css width var(--dt-w) · 거리이정표 표 틀 폭 전체
- 고칠 수 있는 칸 td.is-can-edit outline 1px accent 28% (box-shadow 아님)
- B05 구조물 조합 미지정 = 구조물 리스트 램프 회색
- 시험 기대값 고침(243_1 · 246_1 · 177_1 · ui_data_table) · 새 시험 258_1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-11 13:58:29 +09:00
eomsangdonandClaude Opus 5.5 7fdbcc4794 feat(B08): 거리이정표 여러 기준점 — bases[] · 경로 to · 옛 base 옮김 · 사슬 셈(254-1)
- 데이터 = bases[](id · to = 시점 또는 다른 기준점 · linked) · 경로 via_base 대신 to
- 옛 꼴(base · via_base) = 읽을 때 · 받을 때 새 꼴로 옮김(migrate 짝 Py · TS · 저장 = 새 꼴)
- 행 합 = 거래처 구간 + 기준점 사슬 구간들(방문 집합으로 돌기 끊음 · 안 이은 기준점에서 멈춤)
- 도로 구분 = 시점에 닿는 기준점 구간 1차 · 그 밖 기준점 구간 · 거래처 → 기준점 2차
- 화면 = 기준점 여럿 등록 · 삭제(이은 상자 안 이음) · 연결 놓기 = 기준점 id · 길찾기 · 표지 사슬
- 시험 = test_254_1 새로 · 244 · 250 기대값 새 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-11 13:58:00 +09:00
eomsangdonandClaude Opus 5.5 24be69f325 feat(b08): 거리이정표 시점 모달 — 중점 측점 km · 설명 줄 지움 · 카카오 지도 시점 · 중점 표지 (255-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-11 13:58:00 +09:00
eomsangdonandClaude Opus 5.5 c172c4a462 chore(plan): 요구 162 조사 AJ · 250~252 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 13:40:02 +09:00
eomsangdonandClaude Opus 5.5 df2145e853 chore(plan): 10-11 요구 162(거리이정표 · B09 · B06 상세 · 23 묶음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 13:19:32 +09:00
eomsangdonandClaude Opus 5.5 f87831533c feat(b09): 공기 초안 — 메인 탭 공기 · 공기집계 · 공기산출 (draft 폴더 · 252)
설계내역서 항목 줄 역전개(단위당 장비 시간 · 인) → 작업일수 · 착공 연월부터 달마다 작업가능일 채움 · 공휴일 표 2024~2040 · 본 코드 훅은 draft 없으면 건너뜀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:51:32 +09:00
eomsangdonandClaude Opus 5.5 f598a9a530 chore(plan): 10-11 요구 161
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:30:53 +09:00
eomsangdonandClaude Opus 5.5 8eb389eb39 feat(b08): 거리이정표 단가연결 램프 · 우클릭 단가연결 · 다이어그램 틀 높이 맞춤 (251-4)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:29:32 +09:00
eomsangdonandClaude Opus 5.5 433d6d204b feat(b09): 운반 줄 단가연결 = 거리이정표 연결 · 연결 줄 모음 한 곳(ref_rows) (251-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:23:01 +09:00
eomsangdonandClaude Opus 5.5 8e7a795abc feat(b09): 단가연결 모달 — 연결 하위 메뉴 다섯 → 한 창(종류 고르기 · 검색) · 이름 단가연결 (251-3 · 251-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:18:31 +09:00
eomsangdonandClaude Opus 5.5 0e6da95c95 feat(b08): 거리이정표 읽기 = 운반 행마다 상자 자동 · 모델 haul · ref (251-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:10:49 +09:00
eomsangdonandClaude Opus 5.5 5770cb4ce7 feat(b08): 운반 묶음 함수 — 총괄자재 → 운반 행 · B09 운반공 줄 같은 원천 (251-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:09:11 +09:00
eomsangdonandClaude Opus 5.5 6fa684e0fe chore(plan): 10-11 조사 AI(거리이정표 운반 행 · 단가연결)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:07:09 +09:00
eomsangdonandClaude Opus 5.5 1cd614da14 chore(plan): 10-10 요구 160
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:05:31 +09:00
eomsangdonandClaude Opus 5.5 252a49e916 fix(b08): 거리이정표 [자동 정렬] → [방위 배치] · 늘 보임 (159-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 01:00:26 +09:00
eomsangdonandClaude Opus 5.5 35aaed1506 chore(plan): 10-10 요구 159
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:59:31 +09:00
eomsangdonandClaude Opus 5.5 bde0b0098b chore(plan): 10-10 요구 156~158
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:41:01 +09:00
eomsangdonandClaude Opus 5.5 e6be11cdd9 feat(b08): 거리이정표 도구 왼쪽 아래 · 거래처 탭 = 길찾기 지도 + 그림 변경 · 초기화 (250-6)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:41:01 +09:00
eomsangdonandClaude Opus 5.5 eaadf27782 refactor(b08): 거리이정표 거래처 탭 = 그림만 — 새 창 길찾기 · 탭 지도 칸 없앰 (250-5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:27:46 +09:00
eomsangdonandClaude Opus 5.5 edbce254ec chore(plan): 10-10 요구 155
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:24:53 +09:00
eomsangdonandClaude Opus 5.5 2d5d357bf3 feat(b08): 거리이정표 [요소 추가] · 선 없는 새 거래처 · 연결선 수기 입력 (250-4)
- 공사지 · 기준점 [+] 없앰 → [자동 정렬] 옆 [요소 추가]
- 등록 = 상자만(linked false) · 연결 점으로 이음 · 안 이은 거래처 = 구간 km 안 셈(파이썬 · TS 짝)
- 연결선 모달 [수기 입력] = 카카오 길찾기 연결 끊음(manual)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:24:53 +09:00
eomsangdonandClaude Opus 5.5 088bb1289c chore(plan): 10-10 요구 155
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:20:38 +09:00
eomsangdonandClaude Opus 5.5 e012a09cf2 feat(b08): 거리이정표 선 연결 = 연결 점 끌기 (250-3)
- 거래처 상자 ● 를 끌어 기준점 = 기준점 거침 · 공사지 = 시점으로 바로
- 바뀌면 그 구간 km 비움 + 길찾기 한 번 · 등록 모달 「기준점 거침」 체크 없앰

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:17:46 +09:00
eomsangdonandClaude Opus 5.5 46db8e56c3 chore(plan): 10-10 요구 154
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:14:44 +09:00
eomsangdonandClaude Opus 5.5 5c6c8a85b4 chore(plan): 10-10 요구 151
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:13:15 +09:00
eomsangdonandClaude Opus 5.5 5a249afb21 feat(b08): 거리이정표 — 카카오 지도 보기 유지 · 상자 색 · 상자 끌어 옮김 · 자동 정렬 (250-2)
- 카카오 지도 처음 한 번만 맞춤 · 고르기 · 끌기 뒤 중심 · 줌 그대로
- 공사지 강조색 · 기준점 경고색 · 거래처 성공색(테마 변수 · 밝음 · 어두움)
- 거래처 · 기준점 상자 끌어 옮김(pos 저장) · [자동 정렬]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:13:15 +09:00
eomsangdonandClaude Opus 5.5 d70b66f57e feat(b08): 거리이정표 — 수정 글 · 상자 하나 = 항목 하나 · 폭 전체 · 처음 배율 맞춤 · 지도 높이 · 도로 구분 = 선 자리 (250)
- 등록 모달 [+ 줄] · 같은 이름 합침 없앰 · 기준위치 = 항목 · 목적지 없음 · 「고침」 → 「수정」
- 다이어그램 · 표 폭 전체 · 틀 높이 고정 · 틀 비율 그림 범위 · 처음 배율 = 상자 범위 맞춤
- 카카오 지도 높이 어디서나 480px
- 도로 구분 = 선 자리(거래처 → 기준점 2차포장 · 기준점 → 시점 1차포장 · 그 밖 포장 · 비포장만 고름) · 파이썬 · TS 짝

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-11 00:08:57 +09:00
eomsangdon 0a450d47f1 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 23:57:16 +09:00
eomsangdon a870e9a3a4 sync: 임시 반영(목표와 동일 내용) 2026-10-10 23:57:15 +09:00
eomsangdonandClaude Opus 5.5 f57c18ee10 chore(plan): 10-10 요구 151
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:57:04 +09:00
eomsangdon a518afaba2 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 23:56:53 +09:00
eomsangdon 0740592b7b sync: 임시 반영(목표와 동일 내용) 2026-10-10 23:56:52 +09:00
eomsangdonandClaude Opus 5.5 21bd62b18a chore(plan): 10-10 요구 150
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:55:32 +09:00
eomsangdonandClaude Opus 5.5 84bb7a1b72 chore(plan): 10-10 요구 149
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:53:57 +09:00
eomsangdonandClaude Opus 5.5 91aa771878 chore(plan): 10-10 249장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:52:44 +09:00
eomsangdonandClaude Opus 5.5 b5cbe2c589 fix(B08): 거리이정표 자동 그림 캡처 = 자식 파이썬 프로세스(249-1)
서버(uvicorn · Windows Selector 루프) 스레드 안 Playwright 드라이버 기동 실패 해소 ·
입력 JSON stdin · 결과 JSON 한 줄 · 25초 넘김 · 잠금 그대로 · Selector 정책 시험 추가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 23:51:19 +09:00
eomsangdonandClaude Opus 5.5 9ac0e395af chore(plan): 10-10 요구 148
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:48:15 +09:00
eomsangdonandClaude Opus 5.5 3d45038658 chore(env): 카카오 지도 JavaScript 키 이름 추가 (KAKAO_JS_KEY)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:45:20 +09:00
eomsangdonandClaude Opus 5.5 9ac7a3fd12 chore(plan): 10-10 244 · 246~248장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:44:51 +09:00
eomsangdonandClaude Opus 5.5 6ff34a92f2 feat(B08): 거리이정표 등록 모달 항목 = 손 입력 + 분류별 제안 목록(244-14)
- 항목 칸 = 손 입력 + datalist 제안(거래처 = 견본 다섯 · 기준위치 = 기준점) · 목록 밖 글도 받음
- 분류 바꿈 = 제안만 바뀜 · 쓴 글 그대로 · 줄마다 제안 목록 따로
- 시험 test_244_14 (실제 rowField 를 node 로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 23:43:55 +09:00
eomsangdon 078e8356d0 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 23:40:26 +09:00
eomsangdonandClaude Opus 5.5 2afb1af1ca feat(B08): 거리이정표 등록 모달 2열 · 소재지 다음 지도 · 연결선 모달 길찾기 · 도로 구분 갈래 열(244-13)
- 등록 모달 한 벌(추가 · 상자 고침 · 삭제) — 분류(기준위치 · 거래처) · 항목 고르기 · 거래처 이름 · 목적지 2열 · 큰 창
- 소재지 = 주소 찾기 · 다음 지도 누름 · 표지 끌기(직접 입력 없음) · 좌표 → VWorld 주소 서버 길
- 다이어그램 [기준점 +] 없앰 · 연결선 누름 = 도로 구분 · 거리 · 다음 지도 경로 · 끝점 끌기 = 다시 길찾기
- 시점 → 중점 = 손 입력만 · 자동 그림 = 거래처 쪽 구간
- 도로 구분 = 기준점 거침 2차 · 1차 · 비포장 · 그 밖 포장 · 비포장(파이썬 · TS 짝) · 표 열 기준점 따라

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 23:36:39 +09:00
eomsangdonandClaude Opus 5.5 1cd8668d8a fix(b09): 묶음 탭 이동 빨강 = 설계내역서 규칙 — 빨강 하나 · 줄 · 카드 누름 · 방향키 · Esc = 빨강 풂 · 누른 줄만 고름 (248-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 23:28:49 +09:00
eomsangdon 1173d237dd Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 23:21:13 +09:00
eomsangdon d9578fedd6 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 23:21:12 +09:00
eomsangdon d449c38598 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 23:20:52 +09:00
eomsangdonandClaude Opus 5.5 06b676f914 chore(plan): 10-10 요구 147
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:20:19 +09:00
eomsangdonandClaude Opus 5.5 0b698cb1b6 feat(B08): 거리이정표 다이어그램 넉넉한 고정 바탕 · 시점 → 중점 선 · 상자 주소 · 좌표 · 여유 자리 피함(244-12)
- 바탕 = 기준점 · 공사지 둘레 거래처 자리까지 담는 고정 범위 · 넘는 상자가 있을 때만 넓힘
- 시점 → 중점 선 = 공사지 바탕 위(가림 없음) · 시점 · 중점 상자 작은 글씨 주소 · 좌표(소수 5자리)
- 중점 주소 = 지금 중점 측점(site-address ?mid_m=) · 없으면 좌표만
- 거래처 상자 = 항목 · 이름 · 주소 세 줄 · 길면 줄임표 + 호버
- 상자 사이 여유(겹치면 바깥으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 · 다른 선 · 다른 글 피한 자리

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 23:13:07 +09:00
eomsangdon 6df12153c5 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 23:02:08 +09:00
eomsangdonandClaude Opus 5.5 a137c177f8 chore(plan): 10-10 요구 145 · 146
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 23:01:42 +09:00
eomsangdon 916d610ec8 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 23:00:52 +09:00
eomsangdon baa6d10c08 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 23:00:50 +09:00
eomsangdon 767bfe8e00 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 23:00:10 +09:00
eomsangdon 21048c65fc Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 23:00:10 +09:00
eomsangdonandClaude Opus 5.5 877ea70aca chore(plan): 10-10 요구 142~144
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:59:48 +09:00
eomsangdonandClaude Opus 5.5 8f96b16380 feat(b08): 거리이정표 경로 그림 = 서버 자동 캡처 · 브라우저 캡처 없앰 (244-11)
- [등록] · [길찾기] 뒤 길 선 · 표지 → 서버 헤드리스 Edge(없으면 chrome)로 카카오 지도 1200×800 PNG → auto/<경로 id>.png
- 페이지 주소 = KAKAO_CAPTURE_ORIGIN(기본 http://localhost:5173) + /__route_capture page.route 채움
- 사용자 그림 우선 · 자동 그림으로 되돌리기 · 실패 = 까닭 한 줄 · 동시 요청 잠금 · 좌표 저장 없음
- [지도 캡처] · [화면 캡처] · getDisplayMedia · 244-10 캡처 안내 없앰(지도 SDK 도메인 안내는 그대로)
- 새 시험 test_244_11 · 244_4 · 244_9 · 244_10 기대값 고침 · requirements playwright

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 22:59:25 +09:00
eomsangdonandClaude Opus 5.5 c91d5aa6c9 feat(b08): 구조물 집계표 모두 연결 측점 줄 초록 램프 (247-1)
- 측점 줄 시설이 모두 조합에 연결 = 측점 칸 왼쪽 초록 램프(노랑과 같은 자리 · 크기) · 호버 「모두 연결됨」
- 미연결 = 노랑 그대로 · 시설 없는 줄 · 합계 줄 = 램프 없음 · 색 = 성공 토큰(밝음 · 어두움)
- 새 시험 test_247_1 · 198_1 시험 정규식 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 22:58:50 +09:00
eomsangdonandClaude Opus 5.5 c2184625fa chore(plan): 10-10 요구 141
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:54:43 +09:00
eomsangdon a48c1a3b9b Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 22:50:35 +09:00
eomsangdonandClaude Opus 5.5 2ce1e66095 chore(plan): 10-10 요구 139 · 140
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:50:10 +09:00
eomsangdonandClaude Opus 5.5 50cc45731c feat(b08): 거리이정표 경로 지도 테일스케일 주소 안내 (244-10)
- 보안 문맥 아닌 주소(getDisplayMedia 없음) = [지도 캡처] 끔 + 까닭 한 줄 「캡처는 localhost 또는 https 주소에서만 — 사진 올리기를 쓰세요」
- SDK 못 받음(script onerror · 도메인 미등록 401) = 지금 주소(location.origin) 등록 필요 안내 한 줄 · 사진 올리기
- 새 시험 test_244_10 · test_244_9 에 보안 문맥 navigator 가짜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 22:49:44 +09:00
eomsangdon 0a6f4f5ec1 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 22:47:18 +09:00
eomsangdon a7daf042b5 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 22:47:17 +09:00
eomsangdon 86f9a831f7 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 22:46:51 +09:00
eomsangdonandClaude Opus 5.5 0816595e2e feat(b08): 거리이정표 경로 지도 = 카카오 지도 JS 위 길 선 · 지도 캡처 (244-9)
- 서버 길찾기 응답에 경로 좌표 [위도, 경도] 목록(vertexes = 경도 · 위도 번갈아) · 저장 안 함 · km 만 저장 그대로
- 새 길 GET …/quantity/milestone/map-key — 카카오 지도 JavaScript 키(.env · 공개용) · 없으면 null
- 거래처 모달 · 경로 하위 탭에 카카오 지도 — 길 선 · 거래처 · 기준점 · 시점 표지 · 경로 전체 맞춤
- [지도 캡처] = 지금 탭 캡처 → 지도 칸만 자름(CropTarget · 없으면 자리 수치) → 그 경로 그림 · 트랙 바로 멈춤
- JS 키 없음 · SDK 못 받음 = 안내 한 줄 · 사진 올리기 · 다음 지도 새 창 길은 그대로
- 새 시험 test_244_9 · test_244_7 응답 꼴 고침(path 더함)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 22:34:31 +09:00
eomsangdonandClaude Opus 5.5 24f39bcb54 chore(plan): 10-10 요구 138
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:25:53 +09:00
eomsangdonandClaude Opus 5.5 2a969a4f1d chore(plan): 10-10 요구 137
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:21:33 +09:00
eomsangdonandClaude Opus 5.5 09b57398b7 feat(b08): 거리이정표 km 자동 = 카카오 길찾기 REST · 서버 단독 · 사용자 km 우선 · 도로 구분 고르기 (244-7)
- 서버 새 길 POST …/quantity/milestone/directions — 두 좌표 → 카카오 길찾기 → km 소수 1자리 · 키 없음 · 실패 = 까닭 한 줄 · 키 서버만 · 응답 원문 · 경로 좌표 안 돌려줌
- 거래처 모달 [길찾기] · [등록] 뒤 한 번 — 거래처 → 기준점 · 기준점 → 시점(기준점 없으면 거래처 → 시점) 자동 km(auto_km 만 저장)
- 사용자 km 우선 · 다르면 호버 「자동 값 ○○ km」 · 도로 구분 안 고름 = 합에 안 듦 · 표 합계 칸 「도로 구분 고르기」
- 표 합 파이썬 · TS 짝 같이 고침 · 새 시험 test_244_7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 22:11:36 +09:00
eomsangdonandClaude Opus 5.5 e299e1cdb8 chore(env): 카카오 길찾기 REST 키 이름 추가 (KAKAO_REST_API_KEY)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 22:01:47 +09:00
eomsangdonandClaude Opus 5.5 59b17f3a7d chore(plan): 10-10 요구 136
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:56:32 +09:00
eomsangdonandClaude Opus 5.5 4b7402270d fix(b08): 거리이정표 다이어그램 영역 스크롤 없앰 · 휠 = 줌만 (244-8)
- 틀 overflow hidden · max-height 없앰 — 그림 = 틀 안 맞춤(높이 48vh 넘으면 비율대로 좁힘)
- 틀 wheel 듣개(passive false · preventDefault) — 그림 밖 여백 · 단추 줄 휠도 페이지 스크롤로 안 샘
- 새 시험 test_244_8 — 진짜 wheel 사건으로 줌 · 기본동작 막힘 판정

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:51:40 +09:00
eomsangdonandClaude Opus 5.5 f103a3ad57 chore(plan): 10-10 요구 135
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:49:05 +09:00
eomsangdon 19857d9ee1 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 21:47:24 +09:00
eomsangdonandClaude Opus 5.5 4b70627682 feat(b08): 거리이정표 표 폭 = 다이어그램 폭 · 거래처 상자 항목 글 · 좌측 카드 없앰 · 팬 고침 · 거래처 글 (244-6)
- 틀 너비 = 표 너비 · 다이어그램은 그 너비로 늘음
- 거래처 상자 = 항목(윗줄) + 거래처 이름
- 좌측 패널 거리이정표 카드 없앰 — 중점 측점 · 도로는 공사지 모달 · 기준점은 기준점 모달([기준점 +] · 기준점 없앰)
- 팬 고침: 원배율에서 공용 팬 범위가 0 이던 것 — 범위 줌 · 빈 곳 왼쪽 끌기 더함(공용 panStart 선택 칸 · 기본 그대로)
- 화면 글 「업체」 → 「거래처」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:46:37 +09:00
eomsangdonandClaude Opus 5.5 dba853b87e chore(plan): 10-10 요구 134
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:43:03 +09:00
eomsangdonandClaude Opus 5.5 7964fbcf31 chore(plan): 10-10 요구 133
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:41:14 +09:00
eomsangdon d9bf515d47 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 21:41:06 +09:00
eomsangdonandClaude Opus 5.5 2c174f0b3d chore(plan): 10-10 요구 131 · 132
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:39:16 +09:00
eomsangdonandClaude Opus 5.5 ecf99aefde feat(b08): 거리이정표 기준점 방위 칸 없앰 — 주소 좌표로 자리 · 없으면 공사지 서쪽 (244-5)
- 좌측 패널 「공사지에서 본 방위」 칸 · 8방 고르기 지움 · 옛 저장 값 방위는 무시
- 기준점 모달에 「주소를 고르면 실제 방위로 놓임」 안내 한 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:39:16 +09:00
eomsangdon 1b84907500 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 21:37:37 +09:00
eomsangdonandClaude Opus 5.5 7be4feb1cf feat(b08): 거리이정표 다이어그램 선 글 더블클릭 = km · 도로 구분 바로 고침 (244-4 ⑥)
- 선 글 한 줄 = 그 구간(업체 · 공통 · 시점 → 중점) · 글 화면 자리에 도로 구분 ▾ · km 칸 · Enter · 칸 밖 = 반영 · Esc = 취소
- 값은 모달과 한 벌(legAt · setLeg) · 시점 → 중점 km = 중점 측점 · 표 셈 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:36:56 +09:00
eomsangdonandClaude Opus 5.5 5f2e6e18b6 feat(b08): 거리이정표 [+] 모달 · 주소 검색 재활용 · 방위 자동 · 시점 모달 · 팬 줌 · 경로 탭 = 길찾기 그림 (244-4)
- 공사지 · 기준점 [+] = 모달(항목 · 업체 이름 · 목적지 + B01 주소 검색 · 지도) · 방위 고르기 없앰 · 좌표로 자동 자리
- 상자 누름 = 모달(업체 = 항목 정보 · 구간 km · 도로 구분 · 고침 · 삭제 / 공사지 = 등록 주소 · VWorld 주소 · 지도 / 기준점 = 공통 구간)
- 다이어그램 팬 · 줌 = 공용 b_svg_zoom_pan
- 경로 하위 탭 = 그림만 · [다음 지도에서 길찾기] 새 창 · [화면 캡처] · 하위 탭 [추가] · [삭제] 없앰
- 서버 site-address(좌표 → 주소 · VWorld getAddress)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 21:33:53 +09:00
eomsangdon d027304804 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 21:29:04 +09:00
eomsangdonandClaude Opus 5.5 8d50aa0582 feat(B09): 설계내역서 입력 칸 테두리 되돌림 · 우클릭 「항목 추가」 하나 · 연결 이름 셋 · 「줄 삭제」 · 하위 메뉴 옆 판 (246-1 · 246-2)
- 공용 표 입력 칸 = 241 앞 모양(accent 5% 채움 · td is-can-edit 표시 지움 · 더블클릭 입력 그대로)
- 우클릭 더함 넷 → 「항목 추가」 — 항목 = 바로 아래 같은 부모 · 폴더 = 첫 하위 · 1단 = ← 내어쓰기(직접비 · 관급자재대 앞 · 자재 줄 막음)
- 연결 이름 = 구조물 조합 · 일위대가 사전조합 · 단가산출 · 들여오기 알림 글 쉬운 말 · 「줄 지우기」 → 「줄 삭제」 · 안내 글
- 공용 우클릭 메뉴 children = 「글 ▸」 한 줄 + 오른쪽 옆 판(올림 · 누름 · → 열기 · ← 닫기 · 화면 끝 = 왼쪽)
- 시험 기대값 고침(241_1 · 243_1 · 166_2 · 182 · 184_3 · 204_2) · 새 시험 246_1 · 246_2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 21:28:09 +09:00
eomsangdonandClaude Opus 5.5 08dd166da3 chore(plan): 10-10 요구 130
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:25:45 +09:00
eomsangdonandClaude Opus 5.5 0dce57b0aa chore(plan): 10-10 요구 130
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:24:56 +09:00
eomsangdonandClaude Opus 5.5 2402497284 chore(plan): 10-10 요구 129
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:13:08 +09:00
eomsangdonandClaude Opus 5.5 72e2c10824 chore(plan): 10-10 요구 128
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 21:11:15 +09:00
eomsangdon c048788909 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 20:58:36 +09:00
eomsangdonandClaude Opus 5.5 7f0417a010 chore(plan): 10-10 237~245장 기록 이관(244 빼고)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:58:15 +09:00
eomsangdon 4d298a0d23 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 20:58:08 +09:00
eomsangdon d7007591fc Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 20:58:07 +09:00
eomsangdon d63912f101 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 20:57:44 +09:00
eomsangdonandClaude Opus 5.5 924b1f61eb feat(b08): 거리이정표 다이어그램 — 공사지 · 기준점 · 업체 상자 방위 자리 · 선 km 글 · 상자 누름 · [+] 항목 추가 (244-2)
- 상자 = 공사지(시점 · 중점 한 컨테이너) · 기준점 · 업체 · 선마다 「도로 구분 km」
- 업체 자리 = 기준점(없으면 공사지) 기준 지도 방위 — 경위도 방위각 › 8방 › 빈 방위 차례 · 같은 방위 나란히
- 업체 상자 누름 = 그 경로 하위 탭 · 공사지 · 기준점 [+] = 작은 창(항목 · 업체 · 목적지 · 방위) → 경로 탭

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 20:57:12 +09:00
eomsangdon 492ad3a48a Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 20:53:50 +09:00
eomsangdon 959fab65ba Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 20:53:49 +09:00
eomsangdonandClaude Opus 5.5 a52fe78751 feat(b08): 거리이정표 메인 탭 · 경로 하위 탭 추가 · 삭제 · 공사지 · 기준점 · 표 합 짝 · 그림 올리기 (244-1)
- 메인 탭 「거리이정표」 · 하위 탭 [거리이정표] 표 + 경로 탭(이름 = 업체 이름) · [+ 추가] · [삭제]
- 경로 탭 = 구간 km(더블클릭) · 도로 구분 ▾ · 표 줄(항목 · 목적지 · 비고) · 경로 그림 하나
- 좌측 패널 카드 = 공사지(중점 측점 · 기본 노선 ÷ 2) · 기준점(있음 · 이름 · 주소 · 방위)
- 표 합 = 파이썬 · TS 짝(거울 시험) · 정본 = storage B08_Quantity/milestone · [저장] 한 줄에서 PUT

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 20:53:01 +09:00
eomsangdon eb3b5b4918 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-10-10 20:44:41 +09:00
eomsangdon 4f13829326 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-10-10 20:44:40 +09:00
eomsangdon 21a95f8e5a Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 20:44:28 +09:00
eomsangdonandClaude Opus 5.5 efe0eb5417 style(table): 병합 칸 = 줄 강조 밖 · 호버 · 고른 줄 · 줄무늬 안 칠하고 그 줄 나머지 칸만 (245-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 20:43:52 +09:00
eomsangdon 973ec7cb5b Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 20:43:07 +09:00
eomsangdon ffa969e311 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 20:42:38 +09:00
eomsangdon 77afadf8c7 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_laptop_8 2026-10-10 20:41:36 +09:00
eomsangdonandClaude Opus 5.5 ffd94423c6 chore(plan): 10-10 요구 120 답 · 127
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:40:16 +09:00
eomsangdon 86d86db25c Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 20:38:50 +09:00
eomsangdonandClaude Sonnet 5.5 e3266a4eaa feat(table): B08 · B09 표 수기 입력 = 더블클릭 · Enter · F2 · 고칠 칸 테두리 늘 표시 (243-1)
- 공용 표: 고칠 수 있는 칸(editOnDouble) td 에 is-can-edit 입력 칸 테두리 · 칸 누른 뒤 Enter · F2 = 그 칸 입력(keyEdit)
- B09 Shell 표 만드는 길 한 곳 = editOnDouble 기본 · 설계내역서는 레벨 키 몫
- B08 자재 집계표 셋 = editOnDouble
- 새 시험 test_243_1 · 235_1 Shell 배선 기대값 갱신

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 20:35:08 +09:00
eomsangdonandClaude Opus 5.5 f1f988b9f9 chore(plan): 10-10 조사 AH(거리이정표)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:31:45 +09:00
eomsangdonandClaude Opus 5.5 5c1035a5e8 fix(b09): 총괄 → 설계내역서 빨강 = 그 줄 + 하위 전부 · 고름 표시와 한때 하나 · 줄 누름 · 방향키 · Esc = 빨강 풂 (242-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 20:31:43 +09:00
eomsangdonandClaude Opus 5.5 4689576d7d chore(plan): 10-10 요구 126
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:29:45 +09:00
eomsangdonandClaude Opus 5.5 4027005193 chore(plan): 10-10 요구 125
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:26:51 +09:00
eomsangdonandClaude Sonnet 5.5 5102f775ae style(table): 입력 칸 테두리 색 = 두 테마 변수 혼합(accent 60% · canvas) (241-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 20:20:44 +09:00
eomsangdon 8576123f6d Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 20:20:03 +09:00
eomsangdonandClaude Sonnet 5.5 e8c6e35443 style(table): B08 · B09 표 입력 칸 = 채움 색 대신 칸 안쪽 테두리 (241-1)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 20:18:38 +09:00
eomsangdonandClaude Opus 5.5 1268179cc8 chore(plan): 10-10 요구 123
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:17:00 +09:00
eomsangdonandClaude Opus 5.5 d416ac5922 chore(plan): 10-10 요구 123
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:16:10 +09:00
eomsangdonandClaude Opus 5.5 fe34f561a0 fix(B09): 설계내역서 폴더 줄 글꼴 한 벌 · 들여쓰기 넓힘 · 끌기 놓을 자리 표시 · 데이터 점검 틈 (240 · 121 · 122)
- 레벨 고쳐 생긴 폴더 줄도 처음 폴더 줄과 같은 꼴(is-title · 크기 = 레벨)
- 들여쓰기 한 단 22px · 끌기 좌우 한 단 = 들여쓰기 폭
- 끌기 미리 보기 = 레벨 시작 점 · 「○○ 안 · 레벨 N」 · 들어갈 폴더 줄 테두리 · 끄는 줄 흐림
- 데이터 점검 [업데이트] 위 틈 = 카드 왼쪽 여유

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:13:30 +09:00
eomsangdon 58b35b3ab9 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 20:06:04 +09:00
eomsangdonandClaude Opus 5.5 dd360094b3 feat(b09): 총괄설계내역서 맨 위 설계내역서 레벨 1 · 2 집계 · 누름 = 설계내역서 그 줄 빨강 (239-1)
- 총괄 표 맨 위 = 설계내역서 레벨 1 · 2 폴더 줄(관급 빼고 · 레벨 1 항목 포함) · 금액 = 설계내역서 셈 그대로 · 끝 = 순공사비 계
- 줄 누름 = 설계내역서 탭 그 줄로 · 이동해 온 줄 = is-pick 빨강 외곽선(고른 줄 상자와 따로)
- 새 시험 test_239_1 · 200_2 셸 배선 문구 갱신

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 20:05:05 +09:00
eomsangdonandClaude Sonnet 5.5 b099add036 feat(spreadsheet): 칸 맞춤 기본 = 가로 가운데 · 세로 가운데 (237-1)
안 준 칸(가로 없음 · 일반 · 세로 없음)의 칸 · 도구줄 · 서식 창 · 편집창 · 영역 표 · 복사 HTML 기본을 가운데로 · 준 칸은 그대로 · 새 시험 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 20:03:39 +09:00
eomsangdonandClaude Opus 5.5 f262007f56 fix(profile): B05 [저장] 구조물 측점 목록 = 서버 측점 규칙 · 소단 · 옹벽 · 흙막이 · 기슭막이 측점 지움 막음 (238-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 20:03:02 +09:00
eomsangdonandClaude Opus 5.5 a764cd75cc chore(plan): 10-10 요구 122
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:01:56 +09:00
eomsangdonandClaude Opus 5.5 4e5bbac50d chore(plan): 10-10 요구 121
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 20:00:52 +09:00
eomsangdonandClaude Opus 5.5 be1c707da0 chore(plan): 10-10 요구 114~120
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 19:56:53 +09:00
eomsangdon 4dd8dcf30f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 19:19:59 +09:00
eomsangdonandClaude Opus 5.5 493f861e06 chore(plan): 10-10 229~236장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 19:19:31 +09:00
eomsangdon 07db7e2023 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 19:19:14 +09:00
eomsangdonandClaude Opus 5.5 78bab9eeda fix(design): 종단 · 유토곡선 · 횡단 그리드 빈 곳 누름도 고름 안 풂 (233-4)
- 빈 곳 누름 해제 듣개 뗌 — 3D 와 같은 한 규칙
- 풀기는 Esc · 같은 것 다시 누름 한 길 · 도구 중 빈 곳 누름은 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 19:18:19 +09:00
eomsangdon f98480e2f0 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-10 19:15:30 +09:00
eomsangdon 68e3408068 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-10 19:15:28 +09:00
eomsangdon 0c0d7f7f7f Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 19:15:07 +09:00
eomsangdon c2afe5f640 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 19:14:12 +09:00
eomsangdonandClaude Sonnet 5.5 3cb67d1762 fix(B09): 설계내역서 레벨 안내 글 짧은 한 줄 · 잘릴 때만 호버 · 시험 기대값 정리(236-2)
- 레벨 안내 글에서 「=」 꼴 설명 · 긴 호버 설명 지움
- test_181_2 danger 색 수 5(204-1 끌기 못 놓음 점선) · test_200_2 안내 글 기대값

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 19:13:43 +09:00
eomsangdonandClaude Opus 5.5 b3c1762229 feat(design): 목록 · 폼 · 배수유역도 강조를 고름 한 칸에서 · 풀기 = Esc 한 길 (233-3)
- 종단 보기 구독이 배수유역도 관 마커 · 유역 · 마름모까지 알림 없이 칠함 · 재계산 뒤 다시 얹음
- 지도 고름 · 목록 관 고름 · 목록 다시 누름도 고름 한 칸에 적고 칠하기는 구독 몫
- 목록 관 줄 폭 PIPE_MATCH_M · 조금 옮겨진 관도 고름 따라감 · 저장 뒤 목록 주입이 관 폼 안 닫음
- design_pick_escape — Esc = 고름 한 칸 비움 → 종단 · 3D · 지도 · 폼 · 횡단 카드 · 범위 · 형상 모두 풂

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 19:13:39 +09:00
eomsangdon 7643c2d548 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_7 2026-10-10 19:13:13 +09:00
eomsangdon 2df5054fd3 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-10 19:13:12 +09:00
eomsangdon 9bf96b4442 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 19:12:59 +09:00
eomsangdon b6d8983dd7 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
# Conflicts:
#	B09_Estimation/B09_Estimation_UI_Tabs_Bill.ts
2026-10-10 19:12:04 +09:00
eomsangdonandClaude Opus 5.5 e6315f8581 test(B05·M02): 빨강 둘 정리 — 213-1 고정 사본 · 모달 카드 시험 낡은 기대값(234-2)
- test_213_1 spike — 엄상준_검증 storage 원본 대신 고정 사본(fixtures/b05_213_1_hidden_station_longitudinal.json · initial_snapshot 종단 160~360m · 측점 263.21 · 꼭짓점 · 편집 키 263.206 · 263.210) · 옛 코드 15009% 재현 · 지금 100% 아래
- test_item_picker_detail — 카드 도면이 105-3(36fc3d6c3 · 10-08)부터 mountStructureSheets(여러 장 탭) — 낡은 기대값 mountDrawingTemplate 를 고침 · 코드 흠 아님

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 19:10:37 +09:00
eomsangdon 5a079fdbf4 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 19:09:56 +09:00
eomsangdonandClaude Sonnet 5.5 88dd941d73 fix(B08·B09): 표 아래 안내 글 제작 · 셈 설명 지움 · 늘 한 줄 · 페이지 스크롤 0(236-1)
- B09 표 탭 안내 글 = 쓸모 있는 한 줄(lineHint) · 묶음 탭과 같은 줄임표
- B08 표 아래 안내 · 경고 줄 = footNote 한 줄 · 서버 알림은 한 줄로 이어 붙임
- 새 시험 test_236_1 · 창 높이별 창 끝까지 셈 헬퍼

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 19:09:56 +09:00
eomsangdonandClaude Opus 5.5 3e70362b14 feat(B09): 설계내역서 접히지 않는 트리 — 폴더 줄 수식 · 파일 줄 입력 · 안내선 · 더블클릭 입력 · 고른 줄 상자(235-1)
- 공용 표 editOnDouble — 고칠 칸 = 글 · 더블클릭 · Enter · F2 = 그 칸만 글 상자 · Enter · 초점 떠남 = 입력 끝
- 공용 표 level.tree — ├ └ │ 안내선 + 폴더 · 파일 아이콘(공용 나무와 한 벌 TREE_ICON)
- 설계내역서 — 폴더 줄 금액 = 자손 합 수식 풍선 · 고른 줄 = accent 상자 테두리
- test_235_1 새 시험 · test_228_1 들여쓰기 식 개수 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 19:09:00 +09:00
eomsangdonandClaude Opus 5.5 785307dcd0 chore(plan): 10-10 조사 AG(구조물 목록 대 정의)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 19:07:29 +09:00
eomsangdon 710272f6a2 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 19:07:02 +09:00
eomsangdonandClaude Opus 5.5 994b91636c feat(B05·B06): 구조물 · 관 기준 측점 정수 m · 일반 측점 1 m 안 붙기 · [정수 맞춤] 단추(234-1)
- 맞춤 규칙 한 벌 — common_util_structure_station(파이썬 · TS 짝 · 거울 시험)
- 입력 — 좌측 폼 칸(1 m 단계 · 친 값만) · 끌기 · 우클릭 · 방향키(1 m 걸음) · 배수유역도 관 · B06 관 옮김 · 추가
- 구간형 시작 · 끝도 정수 m · 일반 측점 = 횡단 상세를 읽을 때 등록
- 서버 자동 배치 · 노선 재투영 자리도 정수 m(확정본은 단추로만)
- [정수 맞춤] — 구조물 목록 머리 · 확인 창 · 관은 하나씩 폼 [수정] 길 · 계획고 편집 따라감 · 결과 n건 알림
- 찾는 폭 한 값 — 손으로 적은 0.51 을 PIPE_MATCH_M 으로
- 시험 — test_234_1 새로 · 1 m 걸음 · 정수 입력에 맞춘 기대값 고침(219_1 · station_roundtrip) · 헬퍼 대역 보탬

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 19:05:52 +09:00
eomsangdonandClaude Opus 5.5 a2b3217d80 feat(design): 3D · 횡단 카드 · [상세] 강조를 고름 한 칸에서 파생 (233-2)
- 종단 보기 구독이 3D 측점 띠 · 핀 · 부재까지 값으로 칠함(마커 알림 없이 칠하기만)
- B06_Section_UI_Page_Pick_Sync — 횡단 보기 구독 · 다른 곳이 바꾼 값은 넘김 적용 길로 다시 세움
- 풀기 한 길 — 카드 · 폼 관 · 범위 테두리 · 포장층 · 형상 모두 끔(카드 다시 누름도 이 길)
- [상세] 선 조각 — 다시 그려도 같은 자리 조각 고름 유지

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 19:03:14 +09:00
eomsangdonandClaude Opus 5.5 760972bb9a chore(plan): 10-10 요구 113
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:59:17 +09:00
eomsangdon 6cc3edc153 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 18:58:04 +09:00
eomsangdonandClaude Opus 5.5 21c1428772 chore(plan): 10-10 요구 111~112
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:56:14 +09:00
eomsangdonandClaude Opus 5.5 aa2c2a870f feat(design): 고름 한 칸 구독 · 종단 강조를 그 값에서 파생 (233-1)
- design_pick_profile — 고름 한 칸 구독 → 종단 측점선 · 알약 · 띠를 값으로 다시 세움
- 관 · 구조물 목록이 바뀐 뒤 · 폼이 닫힌 뒤(저장 뒤 관 폼 등)에도 같은 값으로 다시 얹음
- 관 알약 id 는 그릴 때마다 지금 관 목록에서 찾음(관이 옮겨져도 이어짐)
- 숨은 보기는 안 그림 · 보기를 떠나면 구독 끊음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 18:55:50 +09:00
eomsangdonandClaude Opus 5.5 b521328bf9 fix(b05): 3D 띠 누름 뒤 강조 유지 · 숨은 핀 · 꺼 둔 측점선 누름 안 가로챔 (232-1)
- 마커 · 띠에 맞은 누름은 3D 부재 고름이 빈 곳 누름으로 읽지 않음(pressedMarker)
- 3D 빈 곳 누름 = 고름 안 풂 · 같은 띠 · 같은 부재 다시 누름 = 풂
- 핀 · 라벨 = 표시만(레이캐스트 뺌) · 숨은 측점선은 누름 대상에서 뺌
- 3D 누름 길 통째 시험(three + 가짜 캔버스)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 18:48:57 +09:00
eomsangdonandClaude Opus 5.5 2b9e2476a9 chore(plan): 10-10 요구 109~110
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:44:26 +09:00
eomsangdon 6a86bd0a2b Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-10 18:43:57 +09:00
eomsangdon d34f6b85ca Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 18:43:25 +09:00
eomsangdonandClaude Opus 5.5 2a272e40c4 chore(plan): 10-10 요구 108
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:42:19 +09:00
eomsangdonandClaude Opus 5.5 4f1803b733 chore(plan): 10-10 요구 106~107 · 조사 AF(강조 체계 · 소수 측점)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 18:28:28 +09:00
eomsangdon 99b93585ad Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-10 18:01:12 +09:00
eomsangdon 595542f8b2 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-10 18:01:11 +09:00
eomsangdon a22deacf6f Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 18:00:58 +09:00
eomsangdonandClaude Opus 5.5 49f9dc73f8 test(B05): 구조물 폼 요약 시험 헬퍼 — @util 별칭 · 가짜 요소 보탬 · 숨긴 길이 기대값 54-26 맞춤(230-3)
헬퍼가 @util/common_util_cross_structure_areas 를 못 찾던 것 — common_util 폴더로 별칭.
가짜 요소에 style.setProperty · select.options · Option 보탬.
숨긴 길이는 칸 없이 값만 쥠(54-26) — 기대값 맞추고 그린 칸 확인 추가 · 코드 안 고침.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 18:00:22 +09:00
eomsangdon 63d4b3da19 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 17:59:31 +09:00
eomsangdon 95a4d29bd9 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 17:59:29 +09:00
eomsangdon 4db7931175 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 17:59:04 +09:00
eomsangdon 7e9563a2c9 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 17:57:47 +09:00
eomsangdonandClaude Opus 5.5 a8dc1bd7e1 chore(plan): 10-10 요구 105
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:57:16 +09:00
eomsangdonandClaude Opus 5.5 d35743870d fix(B06): 목록 고름도 늘 고름 기록 — 같은 카드 안 구조물도 오감 · 새로 고침 뒤 그 폼(231-2)
- 목록 · 알약 · 형상 고름은 노면공이 아니어도 그 구조물 id 를 고름 한 칸에 적음 · 측점 고름이 연 것은 측점만(189-1)
- 새 시험 test_231_2 · test_231_1 · test_177_4 기대값 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 17:52:53 +09:00
eomsangdonandClaude Opus 5.5 3ec5779169 fix(B05): 종단 새 창 최대화 때 편집 · 다시 그리기 — 놓인 창 화면 틀 · 시계 공용 함수로 통일(230-2)
공용 common_util_view_window(viewOf · frameOf) 신설.
design_profile rebuild · 접기 · render, Heights 높이 맞춤, 표 · 유토곡선 크기 끌기, 유토곡선 세로창 미끄러짐,
끌기 뒤 그리기, ▲▼ · 방향키 길게 누르기, 배수유역도, 구조물 말풍선 — 메인 창 전역 틀 · 시계 대신 요소가 놓인 창 것.
새 시험 test_230_2_popout_frames(메인 창 틀 멈춤 흉내 · 새 창 패널 코드 훑기).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 17:52:01 +09:00
eomsangdon 64c9470f95 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 17:50:13 +09:00
eomsangdonandClaude Opus 5.5 42673505e7 fix(B06): 측점만 남은 고름의 노면공 범위 강조 막음 · B05 3D 같은 구멍 · 카드 옮겨 감 · 관 넘김 카드(231-1)
- B06 폼 다시 세움(load) 의 selectAt 에 노면공 거름(coverable) · 측점 고름이 연 폼은 카드 그대로 · 범위 테두리 없음
- B05 3D 부재 고름 resolveAt · selectAt 에 같은 거름 — 노면공 id 를 고름 한 칸에 안 적음
- 넘김 되얹기 카드 = 찾는 거리 안 가장 가까운 카드(관 80.17 이 카드 80 으로 안 감)
- 새 시험 test_231_1 · test_106 배선 문자열 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 17:49:12 +09:00
eomsangdonandClaude Opus 5.5 057cbaa5b5 chore(plan): 10-10 요구 104
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:48:39 +09:00
eomsangdon edc98bad57 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 17:44:06 +09:00
eomsangdon 33a74ef46e Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_1 2026-10-10 17:43:35 +09:00
eomsangdonandClaude Opus 5.5 dd483a8fbd fix(B05): 종단 새 창 최대화 때 휠 이동 — 스크롤러가 놓인 창의 화면 틀 · 시계 사용(230-1)
메인 창 requestAnimationFrame 은 새 창 최대화로 메인 창이 가려지면 멈춤 · 휠은 막히고 이동 0.
휠 이동 · 끌기 뒤 클릭 삼킴 · 글자 선택 풀기 · 스크롤 뒤 다시 그리기 시계를 ownerDocument.defaultView 로.
새 시험 test_230_1_popout_wheel(가려진 메인 창 · 새 창 보통 · 최대화 · 원래 창).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 17:42:52 +09:00
eomsangdon 315accf9ca feat(B09): 설계내역서 레벨 단추 보조 탭 줄로 이동 · 표 위 도구 줄 비움(229-1) 2026-10-10 17:42:26 +09:00
eomsangdonandClaude Opus 5.5 dc027cd738 chore(plan): 10-10 요구 101~103
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:37:55 +09:00
eomsangdonandClaude Opus 5.5 1357c859c8 chore(plan): 10-10 228장(표 레벨 글꼴) 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:37:30 +09:00
eomsangdon ba58637ca2 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 17:37:04 +09:00
eomsangdonandClaude Opus 5.5 833e4146a9 feat(table): 표 레벨 글꼴 자동 — 레벨 크기 · 묶음 굵게 · 설계내역서 · 보기 전용 표 같은 규칙(228-1)
- 공용 표 level 옵션: 레벨 칸 class is-lv1~5 · 묶음 줄 is-node(group 지정 · 안 주면 바로 아래 줄이 더 깊은 줄)
- 크기 Lv1 16 · Lv2 15 · Lv3 14 · Lv4 13 · Lv5 12(토큰) · 굵게 = 묶음 · 항목 줄은 14 이하
- 들여쓰기 한 단 = 표 기본 1em 토큰(레벨 글자 크기와 무관 · 끌기 한 단과 같음)
- 설계내역서 group = 항목 아닌 줄(빈 공종도 묶음) · 들여 · 내어쓰기는 셈 다시 → 레벨 · 글꼴 바로
- 새 시험 test_228_1 · test_200_2 · 201_1 · 185_1 기대값 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 17:36:07 +09:00
eomsangdonandClaude Opus 5.5 ac8dc77c00 chore(plan): 10-10 조사 AD(노면공 범위 고름)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:32:39 +09:00
eomsangdonandClaude Opus 5.5 ef59f49896 chore(plan): 10-10 조사 AE(표 레벨 외부 사례)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:29:58 +09:00
eomsangdonandClaude Opus 5.5 a23ccc2b04 chore(plan): 10-10 요구 100
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:25:34 +09:00
eomsangdonandClaude Opus 5.5 350853997d chore(plan): 10-10 요구 99
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:14:58 +09:00
eomsangdonandClaude Opus 5.5 78ffc8743c chore(plan): 10-10 요구 98
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 17:11:07 +09:00
eomsangdon 4e7704998a Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 16:53:45 +09:00
eomsangdon aa8f846446 sync: 임시 반영(목표와 동일 내용) 2026-10-10 16:53:40 +09:00
eomsangdon 0e85be7e06 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 16:53:08 +09:00
eomsangdon efb5f246d9 sync: 임시 반영(목표와 동일 내용) 2026-10-10 16:53:03 +09:00
eomsangdon 150260c55e Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 16:49:17 +09:00
eomsangdonandClaude Opus 5.5 3f5bbe509e chore(plan): 10-10 221~227장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:48:47 +09:00
eomsangdon 983638473b Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-10 16:48:33 +09:00
eomsangdon fa23d807c2 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-10-10 16:48:31 +09:00
eomsangdon 67aae7e7d6 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 16:48:17 +09:00
eomsangdonandClaude Opus 5.5 48cff44a0c chore(plan): 10-10 조사 AC(로직 기본값)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:47:24 +09:00
eomsangdonandClaude Sonnet 5.5 265c23d5f2 fix(test): 관 보호공 기본 자리 유입 d 기대값 0.6 → 0.4 · 윗면 노견 − 0.3 (211-1 벽 0.2 높아진 당연한 결과)(225-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 16:47:04 +09:00
eomsangdon 9e5d60fc78 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-10 16:45:31 +09:00
eomsangdon abda7002fa Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-10 16:44:26 +09:00
eomsangdonandClaude Opus 5.5 c98706be06 feat(b06): 관 정보 글 노폭 라벨 위 · 관보호공 0.1 m 이동 · 관 길이 표시 「8 m (7.6)」 · B08 관연장 사사오입 (225-1)
- 관 길이 = 유출 벽 자리에서 셈(0.1 m) · 정수 올림 연장 · 길이 맞춤 이동(_Culvert_Fit) 없앰
- 관보호공 ◀▶▲▼ · 끌기 0.1 m 걸음(세월교 흙막이만 1 m) · ◀▶ 때 d 빈 칸은 지금 자리로 묶음
- 관 · 세월교 정보 글 = 노폭 · 노견 라벨 바로 위 줄 · 카드 · 좌측 패널 「정수 m (소수 첫째)」
- 구조물 집계(M02_Table_Fill) 관연장 = 사사오입 정수 · TS pipeLengthWhole 짝 · 새 시험 test_225_1
- 정수 맞춤 없앰에 맞춰 139_6 · 186_1 · 108 · 79 기대값 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 16:43:08 +09:00
eomsangdon 61419af563 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-10 16:40:45 +09:00
eomsangdon 1f2ecf42b1 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-10 16:40:43 +09:00
eomsangdon 9941b976dc Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 16:40:06 +09:00
eomsangdonandClaude Opus 5.5 9a850212d5 fix(m02): 일위대가 별도계상 자재 셈에서 뺌 · 원문 같은 책 절 모두 · 단위 까닭 시험(222-1)
- 로직 자재 별도계상(빈 칸 = 기본) = 기준에서 빼고 일위대가 상세 줄에서 지움 · 자재목록엔 남김
- 사용자 자재 별도계상 = 합에서 뺌(파이썬 · TS settle 짝 · 거울 시험)
- 사전조합 안 줄 · 프로젝트 사본 셈도 같은 길
- 별도계상 바꾸면 다시 셈(로직 자재 = 서버 · 사용자 자재 = 짝 셈)
- 원문 출처 = 로직 출처 + 호표 · 입력 출처 같은 책 절(산림품셈 12-1-2 · 12-1-3)
- 기존 시험 셋 = 직접계산 표시 · 대조용 apart 끔

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 16:39:00 +09:00
eomsangdon b7a434cf1d Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-10-10 16:36:00 +09:00
eomsangdon e54d5bbb94 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-10 16:35:48 +09:00
eomsangdon 2202b6f28c Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_laptop_8 2026-10-10 16:35:31 +09:00
eomsangdon a49454325c Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-10 16:34:57 +09:00
eomsangdonandClaude Sonnet 5.5 ab71a2e06a fix(test): 합친 뒤 빨강 둘 정리 — M02 카드 틀 Ctrl+S 기대값 · B09 안내 글 「더하기」→「추가」 (224-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jonfdxn1Xit9bdkNJpE4G
2026-10-10 16:34:52 +09:00
eomsangdon cc02d1da0d Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_5 2026-10-10 16:34:07 +09:00
eomsangdonandClaude Opus 5.5 66e84b56bf fix(cad): 줌아웃 느려짐 — 공간 색인 칸 범위 자름 · 모눈을 장면 비트맵에서 뺌(227-1)
- 까닭: 화면 상자 조회가 엔티티 칸(64x64) 밖 빈 칸까지 돎 — 0.01배에서 한 화면 19.3 초
- 칸 범위를 0~64 로 잘라 0.01배 19,348 → 16 ms · 0.1배 175 → 15 ms
- 모눈은 화면 clear() 만 그림 — 모눈 켜도 정지 화면에서 장면 다시 그리지 않음
- 측정 · 회귀 시험 zoom-out-perf.test.ts

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
2026-10-10 16:33:00 +09:00
eomsangdon 8c7a3379cc Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-10 16:32:15 +09:00
eomsangdon 233606048b Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_6 2026-10-10 16:31:43 +09:00
eomsangdon b384715d91 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-10 16:31:42 +09:00
eomsangdonandClaude Sonnet 5.5 91e85cfd95 feat(ui): B05/B06 구조물 조합 고르기 모달 상단에 M02 구조물집계표 보기 전용 표 (224-1)
- 고른 항목이 속한 층 · 주인의 구조물집계표를 M02 와 같은 표 부품 · 줄 글 도우미(numberLabels) · 숨김 열 규칙(comboView)으로 보임
- 같은 층 다른 조합 = 열만 옮김 · 다른 층 = 표 바뀜 · 편집 화면에서는 숨김
- 새 시험 test_224_1 · test_m02_combo_table 단정 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
2026-10-10 16:31:18 +09:00
eomsangdon fcf4720066 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-10 16:31:09 +09:00
eomsangdon db03913b90 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 16:30:30 +09:00
eomsangdonandClaude Opus 5.5 02e0727170 feat(cad): 축척 기준점 지정 뒤 숫자 배율 입력 · Enter 적용 · Esc 취소 · 되돌리기 한 칸 · 명령 줄 안내 한글 (226-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNMXVCZXMpHpVvqmFkeChL
2026-10-10 16:29:39 +09:00
eomsangdonandClaude Opus 5.5 8379836748 fix(ui): 돋보기 스크롤 따라감 — 다시 켤 때 원을 먼저 보여 스크롤 옮김이 먹게 · 켠 채 스크롤은 rAF 한 번에 칸 · 페이지 위치 맞춤 · 0.5 초 뒤 다시 뜸 (221-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 16:29:34 +09:00
eomsangdon a03174b1bf Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 16:28:46 +09:00
eomsangdonandClaude Opus 5.5 74c7815475 chore(plan): 10-10 요구 97
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:27:30 +09:00
eomsangdonandClaude Opus 5.5 a5f5cac739 feat(ui): 돋보기 CAD 영역 빼기 — CAD 위 안 켬 · 켜진 채 들어가면 숨김 · 나오면 배율 그대로 · CAD 위 휠 그대로 · 복제에서 CAD 빈칸 (221-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 16:26:31 +09:00
eomsangdonandClaude Opus 5.5 9085a51056 chore(plan): 10-10 요구 96
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:23:13 +09:00
eomsangdonandClaude Opus 5.5 3af0c45306 chore(plan): 10-10 요구 86~95(넷째 붙임)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:22:26 +09:00
eomsangdon 3b976113c3 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 16:20:53 +09:00
eomsangdonandClaude Opus 5.5 ff19a0bc55 chore(plan): 10-10 요구 85
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:20:22 +09:00
eomsangdon 2c874a1066 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 16:20:14 +09:00
eomsangdonandClaude Opus 5.5 50b3e15e54 chore(plan): 10-10 220장(돋보기 휠 배율) 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:16:19 +09:00
eomsangdon eceda4ee35 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 16:15:45 +09:00
eomsangdonandClaude Opus 5.5 64d5921b94 feat(ui): 돋보기 지름 360 · 켜진 동안 휠 배율(1.5~6 · 0.25) · 스크롤 막음 · 배율 글 · 붙는 페이지 B01~B09 · M01 · M02 (220-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 16:14:35 +09:00
eomsangdonandClaude Opus 5.5 1de0e62949 chore(plan): 10-10 요구 84
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:12:32 +09:00
eomsangdon d027e5b617 Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 16:12:02 +09:00
eomsangdon 536dacefac Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 16:11:58 +09:00
eomsangdon 1cbeea6bfd chore(sync): 로컬 미반영 수정분 커밋 (원격과 동일 내용) 2026-10-10 16:11:53 +09:00
eomsangdon add01ef5de chore(sync): 로컬 미반영 수정분 커밋 (원격과 동일 내용) 2026-10-10 16:11:52 +09:00
eomsangdonandClaude Opus 5.5 e95487967b chore(plan): 10-10 요구 83
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:10:51 +09:00
eomsangdon e0136d4db6 Merge commit 'f1ddb82c411de4c08116b50d22ca8886b869907b' into main_laptop_1 2026-10-10 16:04:51 +09:00
eomsangdon 81db8ee970 Merge commit 'b6c4e98d38b410d5d58591a64e0f26df46e7006e' into main_laptop_1 2026-10-10 16:04:50 +09:00
eomsangdon 0038012872 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-10 16:04:50 +09:00
eomsangdonandClaude Opus 5.5 98b9d1b459 chore(plan): 10-10 200~219장 기록 이관
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:04:25 +09:00
eomsangdon 9c747ed13d Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-10 16:03:41 +09:00
eomsangdonandClaude Opus 5.5 08b34194af chore(plan): 10-10 요구 77~82 원문 되살림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
2026-10-10 16:03:21 +09:00
eomsangdonandClaude Sonnet 5.5 d540395938 test(B08,M02): 낡은 시험 둘 기대값을 지금 코드 꼴에 맞춤 — 자재 할증 editable(rows[keep[i]]) · 스프레드시트 미리보기(도구 줄 없음 · 메뉴 ss--preview)(205-2)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SE9MxeXTu2uaDZ55BJowAY
2026-10-10 16:02:15 +09:00
eomsangdon d1999ace14 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-10 15:54:49 +09:00
eomsangdonandClaude Opus 5.5 e0ad372e70 refactor(B05·B06): 종단도 700줄 정리 — 오버레이 · 스크롤 듣개 새 파일로 떼어 641줄(213-3)
- design_profile_overlays.ts — 유토곡선 · 테이블 오버레이 · 유토 총괄 배지 만들기
- design_profile_viewport.ts — 가로 스크롤 다시 그림 · 휠 가로 스크롤 · 크기 바뀜 듣개(dispose 때 뗌)
- 동작 그대로 · test_215_1 줄 수 기준 = 700(규칙) · 새 두 파일도 검사

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 15:53:42 +09:00
eomsangdon dc8f201877 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-10 15:45:43 +09:00
eomsangdonandClaude Opus 5.5 ffa7f9d236 fix(B05·B06): 유령 계획고 편집 근본 막음 — 옮긴 관 편집 따라감 · [저장] 때 자리 없는 편집 버림 · 알림(213-2)
- ① 관 옮김 = 편집 키도 새 자리로: B06 movePipeTo 가 resetStationEdit(옛, 새) · B05 측점 갱신이 사라진 것 하나 · 새것 하나(또는 같은 id · 0.006 m 안)를 짝으로 옮김(followedStation) — 세션 초안도 새 키라 옛 키가 다시 안 나감
- ② [저장](PUT profile-alignment)에서 서버가 측점 · 관 · 구조물 자리 어디에도 없는 편집 키를 버림(drop_unplaced_edits · 저장 차례가 관 · 구조물 뒤라 최신) · 응답 dropped_edit_chainages · 화면 알림 「계획고 편집 N곳을 지웠습니다」 · 자리 못 읽으면 안 버림
- ③ test_192_1 = 고정 사본(fixtures/b05_192_1_moved_pipe_longitudinal.json · 226fc805 460~840 m) · 새 시험 test_213_2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F3mdb91125mPoC6tpsQx4P
2026-10-10 15:44:28 +09:00
eomsangdonandClaude Opus 5.5 37b653eb8a test(B05): 219-1 헬퍼 가짜 모듈에 [자유지정] 두 모듈 추가 · 합친 뒤 setup 실패 해소(219-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 15:44:17 +09:00
eomsangdon 6f1e0c8202 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 15:43:09 +09:00
eomsangdon b9f9795a83 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
# Conflicts:
#	B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts
2026-10-10 15:43:07 +09:00
eomsangdonandClaude Opus 5.5 cfde9dc0bd refactor(공용): 돋보기 Z 키 판정 — 공용 key_shortcut keyLetter 로 바꿈 · 동작 그대로(218-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 15:41:39 +09:00
eomsangdonandClaude Opus 5.5 ca37861159 fix(B06): 횡단 보기 방향키가 관을 몰래 옮기던 흠 · 측점 없는 구조물 판정 폭 0.006 · 노선 밖 뺌(219-1)
- 까닭: 관 · 도수로/돌붙임 띠 고름은 조정창이 없어 방향키를 받는 쪽이 없음 → 종단도 창 듣개가 받아 고른 관 측점 0.1 m 이동 → [저장] 때 pipe_points +0.1(192 · 213 숨은 측점 씨앗)
- 종단도 좌우 이동 = 마지막 누름이 종단도 안일 때만(arrowsBelongToProfile 한 벌 · 새 창 종단도 누름도 closest 로 가림)
- 관 · 도수로 띠 고름 동안 방향키 삼킴(swallowsArrows · 종단도를 누르면 종단도 몫)
- 측점 없는 구조물 판정 폭 = SAME_STATION_SNAP_M(0.006) · 노선 밖(0 미만 · 끝 넘음) 뺌
- 새 시험 test_219_1_arrow_owner · test_b06_missing_stations 폭 0.006

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JywHxQuWPk6UYXTebyrBoU
2026-10-10 15:40:33 +09:00
eomsangdon 9749e3ec68 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-10 15:39:56 +09:00
eomsangdonandClaude Opus 5.5 0d560350bd feat(공용): B05~B09 돋보기 — 오른쪽 길게 누름 0.4 초 · Z 누름 동안 커서 따라감 · 지름 240 px · 2배(218-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QND7Co43B24zK2P937rdxt
2026-10-10 15:39:03 +09:00
eomsangdon b6c4e98d38 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-10-10 15:38:16 +09:00
eomsangdon f1ddb82c41 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-10-10 15:38:14 +09:00
eomsangdon 1fb027093c Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-10 15:38:13 +09:00
eomsangdon 8212da9c19 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-10-10 15:38:11 +09:00
eomsangdon 8fc456657b Merge remote-tracking branch 'origin/CODEX' into CODEX 2026-10-10 14:12:11 +09:00
eomsangdon a4f5997c91 sync: 로컬 정본 파일 반영 2026-10-10 14:12:03 +09:00
eomsangdon a1c5be2361 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 14:11:39 +09:00
eomsangdon 818d7ec757 sync: 로컬 정본 파일 반영 2026-10-10 14:11:38 +09:00
eomsangdon 9fd5594656 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 13:40:48 +09:00
eomsangdon 15a4087bf2 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 13:28:28 +09:00
eomsangdon 828e5f1a13 sync: docs 파일 임시 커밋(내용 동일) 2026-10-10 13:28:27 +09:00
eomsangdon 42a17a5fb4 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-10-10 12:19:03 +09:00
eomsangdon ac768dab94 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-10-10 11:58:24 +09:00
eomsangdon 9370233fbd Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-10-10 11:57:59 +09:00
1459 changed files with 187152 additions and 21077 deletions
+3 -1
View File
@@ -46,7 +46,9 @@ CORS_ORIGINS=http://localhost:5173,http://localhost:8000
# Public URL for email links (프론트엔드 주소)
APP_PUBLIC_BASE_URL=http://localhost:5173
KAKAO_REST_API_KEY=ea05e4c86be146d9e28586d3f25d5a08
KAKAO_JS_KEY=98ac6edef14ffbca300d90e540644b68
# kakao app ID 1603330
# Gitea 토큰 66a40de16156431db0dfbf86149f8bafcec4832f
# 국토정보플랫폼 API키 : C7111C0D1C2D353F37EDE33145E2A286DF8D8EC6CA
# 브이월드 API키 : 3DBD7306-7DBD-38BB-B292-267C5ED7AC6B
+2
View File
@@ -71,6 +71,8 @@ config/formula_node/
config/spreadsheet_node/
# 횡단도 덧그림 범위 번들 — `npm run build:b07-extent` 산출물.
config/b07_extent_node/
# 횡단도 표 위 CAD 칸 옵션 줄 번들 — `npm run build:b07-note` 산출물(166-1).
config/b07_note_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
+53
View File
@@ -0,0 +1,53 @@
/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 · 휠 방향 고름 · 표 */
.app-settings__tabs {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
padding-bottom: var(--spacing-8);
border-bottom: 1px solid var(--color-border);
}
.app-settings__radios {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-16);
}
.app-settings__radios > label {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
min-height: 32px;
cursor: pointer;
}
.app-settings__table {
width: 100%;
border-collapse: collapse;
}
.app-settings__table th,
.app-settings__table td {
padding: var(--spacing-4) var(--spacing-8);
border-bottom: 1px solid var(--color-border);
text-align: left;
vertical-align: middle;
}
.app-settings__table thead th {
position: sticky;
top: 0;
background: var(--color-surface-raised);
}
.app-settings__table input[aria-invalid="true"] {
border-color: var(--color-danger);
}
.app-settings__error {
color: var(--color-danger);
}
.app-settings__error:empty {
display: none;
}
+159
View File
@@ -0,0 +1,159 @@
/* =============================================================================
* app_settings.ts
* ☰ [설정] 창(PLAN 332-2 · 요구 224-8) — 탭 셋: ① 마우스 ② CAD 단축키 ③ 다른 곳 단축키(읽기 전용).
*
* - 고침은 창 안 사본에만 · [저장] 때만 계정 pref(`input-prefs`)로 한 번 씀 — 읽기는 `input_prefs.ts`
* - 저장 안 한 고침이 있으면 닫을 때 물음(askLeave) · 잘못 있는 별칭은 저장 안 함
* - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에)
* ========================================================================== */
import { createButton, el, showToast } from "@ui/ui_template_elements";
import { askLeave } from "@ui/ui_template_leave_dialog";
import { t as L } from "@ui/ui_template_locale";
import { openModal } from "@ui/ui_template_modal";
import { writeState } from "./b_page_state";
import { inputPrefs, type InputPrefs, type WheelZoom } from "./input_prefs";
import { buildCadAliasTab, checkCadAliases } from "./app_settings_cad";
import "./app_settings.css";
type LocaleKey = Parameters<typeof L>[0];
type TabId = "mouse" | "cad" | "keys";
const TABS: [TabId, LocaleKey][] = [
["mouse", "App_Settings_Tab_Mouse"],
["cad", "App_Settings_Tab_Cad"],
["keys", "App_Settings_Tab_Keys"],
];
const WHEEL: [WheelZoom, LocaleKey][] = [
["pull", "App_Settings_Wheel_Pull"],
["push", "App_Settings_Wheel_Push"],
];
const MOUSE_RULES: LocaleKey[] = [
"App_Settings_Rule_Pan",
"App_Settings_Rule_Pick",
"App_Settings_Rule_Right",
"App_Settings_Rule_Pinch",
];
/** ③ 안내표 줄 — [자리, 키] */
const KEY_ROWS: [LocaleKey, LocaleKey][] = [
["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"],
["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"],
["App_Settings_Keys_RouteEdit", "App_Settings_Keys_RouteEdit_Keys"],
["App_Settings_Keys_Section", "App_Settings_Keys_Section_Keys"],
["App_Settings_Keys_Cad", "App_Settings_Keys_Cad_Keys"],
["App_Settings_Keys_Table", "App_Settings_Keys_Table_Keys"],
["App_Settings_Keys_M02", "App_Settings_Keys_M02_Keys"],
];
/** 비교 · 저장 꼴 — 별칭 명령 차례를 맞춤 */
const normalize = (p: InputPrefs): InputPrefs => ({
wheelZoom: p.wheelZoom,
cadAliases: Object.fromEntries(Object.entries(p.cadAliases).sort(([a], [b]) => (a < b ? -1 : 1))),
});
const snapshot = (p: InputPrefs): string => JSON.stringify(normalize(p));
function mouseTab(draft: InputPrefs): HTMLElement {
const group = el("div", {
className: "app-settings__radios",
attrs: { role: "radiogroup", "aria-label": L("App_Settings_Wheel") },
});
for (const [mode, key] of WHEEL) {
const input = el("input", {
attrs: { type: "radio", name: "app-settings-wheel", value: mode },
});
input.checked = draft.wheelZoom === mode;
input.addEventListener("change", () => (draft.wheelZoom = mode));
group.append(el("label", { children: [input, L(key)] }));
}
return el("div", {
children: [
el("h4", { text: L("App_Settings_Wheel") }),
group,
el("h4", { text: L("App_Settings_Rules") }),
el("ul", { children: MOUSE_RULES.map((key) => el("li", { text: L(key) })) }),
],
});
}
function keysTab(): HTMLElement {
const rows = KEY_ROWS.map(([place, keys]) =>
el("tr", { children: [el("th", { text: L(place) }), el("td", { text: L(keys) })] }),
);
return el("table", {
className: "app-settings__table",
children: [el("tbody", { children: rows })],
});
}
/** ☰ [설정] — 창을 열고 지금 설정을 사본으로 고침 */
export function openAppSettings(): void {
const draft = normalize(inputPrefs());
let saved = snapshot(draft);
const cad = buildCadAliasTab(draft.cadAliases);
const panels: Record<TabId, HTMLElement> = {
mouse: mouseTab(draft),
cad: cad.root,
keys: keysTab(),
};
const tabs = el("div", {
className: "app-settings__tabs ui-subtabs",
attrs: { role: "tablist" },
});
const show = (id: TabId): void => {
tabs.replaceChildren(
...TABS.map(([tab, key]) => {
const button = createButton({
label: L(key),
variant: tab === id ? "filled" : "ghost",
size: "sm",
onClick: () => show(tab),
});
button.setAttribute("role", "tab");
button.setAttribute("aria-selected", String(tab === id));
button.dataset.settingsTab = tab;
return button;
}),
);
for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id;
};
/** [저장] — 잘못 있는 별칭이면 CAD 탭을 보이고 안 씀 */
const save = (): boolean => {
const errors = checkCadAliases(draft.cadAliases);
cad.mark(errors);
if (Object.keys(errors).length) {
show("cad");
showToast(L("App_Settings_Cad_Invalid"), "error");
return false;
}
if (snapshot(draft) !== saved) writeState("input-prefs", normalize(draft));
saved = snapshot(draft);
showToast(L("App_Settings_Saved"), "success");
return true;
};
const saveButton = createButton({
label: L("Common_Btn_Save"),
variant: "filled",
size: "sm",
onClick: () => void save(),
});
openModal({
title: L("App_Settings"),
closeLabel: L("Common_Btn_Close"),
dialogClass: "app-settings",
actions: [saveButton],
beforeClose: async () => {
if (snapshot(draft) === saved) return true;
const choice = await askLeave(L("App_Settings_Leave"));
return choice === "discard" || (choice === "save" && save());
},
mount: (body) => {
show("mouse");
body.append(tabs, ...Object.values(panels));
},
});
}
+118
View File
@@ -0,0 +1,118 @@
/* =============================================================================
* app_settings_cad.ts
* ☰ [설정] ② CAD 단축키 탭(PLAN 332-2) — 명령 표(명령 · 한글 이름 · 기본 별칭 · 내 별칭) · 검사.
*
* - 명령 목록 = `openwebcad/src/commands/command-catalog.json`(registry 거울 · commands.*.ts 는 xstate 가 딸려 와서 JSON 한 벌)
* - acad.pgp 식 덮어쓰기 — CAD 는 명령 이름 > 내 별칭 > 기본 별칭 순서로 찾음(배선은 332-3)
* - 검사 = 영문 · 숫자만 · 내 별칭끼리 겹침 금지 · 명령 이름과 같은 별칭 금지
* ========================================================================== */
import catalog from "../B07_DesignDetail/openwebcad/src/commands/command-catalog.json";
import { el } from "@ui/ui_template_elements";
import { t as L } from "@ui/ui_template_locale";
export interface CadCommandRow {
id: string;
label: string;
aliases: string[];
}
export const CAD_COMMANDS = catalog as CadCommandRow[];
export type AliasError = "chars" | "dup" | "name";
const ERROR_TEXT: Record<AliasError, Parameters<typeof L>[0]> = {
chars: "App_Settings_Cad_ErrChars",
dup: "App_Settings_Cad_ErrDup",
name: "App_Settings_Cad_ErrName",
};
/** 칸 글 → 별칭 목록(쉼표 · 빈칸으로 가름 · 대문자) */
export const parseAliases = (text: string): string[] =>
text
.toUpperCase()
.split(/[\s,]+/)
.filter(Boolean);
/** 내 별칭 검사 — `{명령: 첫 잘못}` · 비면 통과 */
export function checkCadAliases(
aliases: Record<string, string[]>,
commands: CadCommandRow[] = CAD_COMMANDS,
): Record<string, AliasError> {
const ids = new Set(commands.map((c) => c.id));
const owner = new Map<string, string>();
const errors: Record<string, AliasError> = {};
for (const [id, list] of Object.entries(aliases)) {
for (const alias of list) {
const first = owner.get(alias);
const error: AliasError | null = !/^[A-Z0-9]+$/.test(alias)
? "chars"
: ids.has(alias)
? "name"
: first !== undefined
? "dup"
: null;
if (!error) owner.set(alias, id);
else {
errors[id] ??= error;
if (first !== undefined) errors[first] ??= "dup";
}
}
}
return errors;
}
/** 탭 몸 — `aliases` 를 칸 입력대로 고쳐 씀 · `mark` 로 잘못 칸 표시 */
export function buildCadAliasTab(aliases: Record<string, string[]>): {
root: HTMLElement;
mark: (errors: Record<string, AliasError>) => void;
} {
const message = el("p", { className: "app-settings__error", attrs: { role: "alert" } });
const inputs = new Map<string, HTMLInputElement>();
const rows = CAD_COMMANDS.map((command) => {
const input = el("input", {
className: "ui-input ui-input--sm",
attrs: { type: "text", "aria-label": `${command.id} ${L("App_Settings_Cad_Mine")}` },
});
input.value = (aliases[command.id] ?? []).join(", ");
input.dataset.cadCommand = command.id;
input.addEventListener("input", () => {
const list = parseAliases(input.value);
if (list.length) aliases[command.id] = list;
else delete aliases[command.id];
});
inputs.set(command.id, input);
const cells = [command.id, command.label, command.aliases.join(", ")].map((text) =>
el("td", { text }),
);
return el("tr", { children: [...cells, el("td", { children: [input] })] });
});
const head = el("tr", {
children: (
[
"App_Settings_Cad_Command",
"App_Settings_Cad_Label",
"App_Settings_Cad_Default",
"App_Settings_Cad_Mine",
] as const
).map((key) => el("th", { text: L(key) })),
});
const table = el("table", {
className: "app-settings__table",
children: [el("thead", { children: [head] }), el("tbody", { children: rows })],
});
const mark = (errors: Record<string, AliasError>): void => {
for (const [id, input] of inputs) {
const error = errors[id];
if (error) input.setAttribute("aria-invalid", "true");
else input.removeAttribute("aria-invalid");
input.title = error ? L(ERROR_TEXT[error]) : "";
}
const kinds = [...new Set(Object.values(errors))];
message.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · ");
};
const root = el("div", {
children: [el("p", { text: L("App_Settings_Cad_Help") }), message, table],
});
return { root, mark };
}
+281 -31
View File
@@ -11,6 +11,7 @@ import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_l
import { createButton } from "@ui/ui_template_elements";
import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend";
import { navigateTo, isAuthenticated } from "./router";
import { readTouchMode, setTouchMode, type TouchMode } from "./device_context";
const BRAND_ICON_URL = new URL("../resources/app_branding/prog_icon.jpg", import.meta.url).href;
@@ -65,38 +66,12 @@ function buildHeader(): HTMLElement {
const header = document.createElement("header");
header.className = "app-header";
// 로고 (클릭 시 홈)
const brand = document.createElement("button");
brand.className = "app-brand";
brand.type = "button";
const brandIcon = document.createElement("img");
brandIcon.className = "app-brand__icon";
brandIcon.src = BRAND_ICON_URL;
brandIcon.alt = "";
const brandName = document.createElement("span");
brandName.className = "app-brand__name";
brandName.textContent = L("App_BrandName");
brand.append(brandIcon, brandName);
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
const brand = buildBrand();
// 네비게이션 링크
const nav = document.createElement("nav");
nav.className = "app-nav";
const navItems: [keyof typeof ui_locales, string][] = [
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
["App_Nav_Support", ROUTES.A08_SUPPORT],
];
for (const [labelKey, route] of navItems) {
const link = createButton({
label: L(labelKey),
variant: "pill",
onClick: () => navigateTo(route as never),
});
nav.append(link);
}
nav.append(...buildNavButtons());
// 우측 액션 (언어/테마/로그인)
const actions = document.createElement("div");
@@ -120,10 +95,171 @@ function buildHeader(): HTMLElement {
actions.append(authSlot);
void fillAuthActions(authSlot);
header.append(brand, nav, actions);
header.append(brand, nav, actions, ...buildDrawer(header));
return header;
}
/** 로고 (클릭 시 홈) — 위 줄 · ☰ 서랍 첫 묶음이 같이 씀 */
function buildBrand(): HTMLButtonElement {
const brand = document.createElement("button");
brand.className = "app-brand";
brand.type = "button";
const brandIcon = document.createElement("img");
brandIcon.className = "app-brand__icon";
brandIcon.src = BRAND_ICON_URL;
brandIcon.alt = "";
const brandName = document.createElement("span");
brandName.className = "app-brand__name";
brandName.textContent = L("App_BrandName");
brand.append(brandIcon, brandName);
brand.addEventListener("click", () => navigateTo(ROUTES.A01_HOME));
return brand;
}
/* ☰ 서랍 진행 단계 묶음(6-2) — 프로젝트 화면이 진행 판에 그린 단계 목록 **그 요소**를 받아 둠.
* 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */
let drawerSteps: { node: HTMLElement; home: HTMLElement | null } | null = null;
/** 집(진행 판) 없는 요소 = 서랍이 늘 가짐 · 닫을 때 안 옮김(B01 · M01 · M02 바로가기) */
export function setDrawerSteps(node: HTMLElement | null): void {
drawerSteps = node ? { node, home: node.parentElement } : null;
}
const NAV_ITEMS: [keyof typeof ui_locales, string][] = [
["App_Nav_Program", ROUTES.A02_PROG_DETAIL],
["App_Nav_Company", ROUTES.A03_COMP_DETAIL],
["App_Nav_News", ROUTES.A04_NEWS_HISTORY],
["App_Nav_Education", ROUTES.A05_EDU_DETAIL],
["App_Nav_Support", ROUTES.A08_SUPPORT],
];
function buildNavButtons(): HTMLElement[] {
return NAV_ITEMS.map(([labelKey, route]) =>
createButton({
label: L(labelKey),
variant: "pill",
onClick: () => navigateTo(route as never),
}),
);
}
/* -----------------------------------------------------------------------------
* ☰ 서랍(PLAN_기기대응 2-2 · 7-1) — 공개 쪽은 폭 900 이하 · 터치 모드에서 · 로그인 뒤 쪽(B · M)은 늘 보임.
* 위 메뉴 · 언어 · 테마 · 로그인 단추와 같은 항목 + 터치 모드 스위치.
* -------------------------------------------------------------------------- */
function buildDrawer(header: HTMLElement): HTMLElement[] {
const burger = document.createElement("button");
burger.type = "button";
burger.className = "app-burger";
burger.textContent = "☰";
burger.setAttribute("aria-label", L("App_Menu"));
burger.setAttribute("aria-expanded", "false");
const drawer = document.createElement("div");
drawer.className = "app-drawer";
drawer.setAttribute("role", "dialog");
drawer.setAttribute("aria-label", L("App_Menu"));
const scrim = document.createElement("div");
scrim.className = "app-drawer__scrim";
const steps = document.createElement("div");
steps.className = "app-drawer__group app-drawer__steps";
const setOpen = (open: boolean): void => {
header.classList.toggle("is-drawer-open", open);
burger.setAttribute("aria-expanded", String(open));
if (!drawerSteps) return;
if (open) steps.append(drawerSteps.node);
else if (drawerSteps.home && steps.contains(drawerSteps.node))
drawerSteps.home.append(drawerSteps.node);
};
burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open")));
scrim.addEventListener("click", () => setOpen(false));
header.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); // ☰ 에 포커스가 있어도 닫힘
// 로고 · 진행 단계 · 메뉴 · 로그인 · [설정] 을 누르면 닫힘(언어 · 테마 · 스위치는 그대로 둠)
drawer.addEventListener("click", (e) => {
const hit = (e.target as Element).closest("button");
if (
hit?.closest(
".app-drawer__brand, .app-drawer__steps, .app-drawer__nav, .app-drawer__auth, .app-drawer__settings",
)
)
setOpen(false);
});
// 묶음 차례 = 로고 · 홈 ─ 진행 단계(프로젝트 안) ─ 메뉴 ─ 언어 · 테마 · 로그인 · 터치 모드
const brandRow = document.createElement("div");
brandRow.className = "app-drawer__group app-drawer__brand";
brandRow.append(buildBrand());
const navList = document.createElement("nav");
navList.className = "app-drawer__group app-drawer__nav";
navList.append(...buildNavButtons());
const prefs = document.createElement("div");
prefs.className = "app-drawer__row";
prefs.append(
createButton({
label: currentLanguageIndex === 0 ? "EN" : "한",
variant: "pill",
onClick: onApp_Lang_Toggle_Click,
}),
createButton({
label: document.documentElement.getAttribute("data-theme") === "dark" ? "☀" : "☾",
variant: "pill",
onClick: onApp_Theme_Toggle_Click,
}),
);
// ☰ [설정](332) — 마우스 · 단축키 창 · 처음 누를 때 불러옴
const settingsButton = createButton({
label: L("App_Settings"),
variant: "pill",
onClick: () => void import("./app_settings").then((m) => m.openAppSettings()),
});
settingsButton.classList.add("app-drawer__settings");
prefs.append(settingsButton);
const auth = document.createElement("div");
auth.className = "app-drawer__row app-drawer__auth";
void fillAuthActions(auth);
const settings = document.createElement("div");
settings.className = "app-drawer__group";
settings.append(prefs, auth, buildTouchModeSwitch());
drawer.append(brandRow, steps, navList, settings);
return [burger, scrim, drawer];
}
function buildTouchModeSwitch(): HTMLElement {
const box = document.createElement("div");
box.className = "app-drawer__touch";
const label = document.createElement("span");
label.textContent = L("App_TouchMode");
const group = document.createElement("div");
group.className = "app-drawer__row";
group.setAttribute("role", "radiogroup");
group.setAttribute("aria-label", L("App_TouchMode"));
const modes: [TouchMode, keyof typeof ui_locales][] = [
["auto", "App_TouchMode_Auto"],
["on", "App_TouchMode_On"],
["off", "App_TouchMode_Off"],
];
const buttons = modes.map(([mode, key]) => {
const b = createButton({
label: L(key),
variant: "pill",
onClick: () => {
setTouchMode(mode);
mark();
},
});
b.setAttribute("role", "radio");
b.dataset.touchMode = mode;
return b;
});
const mark = (): void => {
const now = readTouchMode();
for (const b of buttons) b.setAttribute("aria-checked", String(b.dataset.touchMode === now));
};
mark();
group.append(...buttons);
box.append(label, group);
return box;
}
/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */
async function fillAuthActions(slot: HTMLElement): Promise<void> {
try {
@@ -188,6 +324,7 @@ function buildFooter(): HTMLElement {
* -------------------------------------------------------------------------- */
export function renderShell(appRoot: HTMLElement, showFooter = true): HTMLElement {
appRoot.innerHTML = "";
drawerSteps = null; // 새 쪽이 진행 판을 세우면 다시 맡김
const header = buildHeader();
const outlet = document.createElement("main");
outlet.className = "app-outlet";
@@ -259,10 +396,10 @@ const SHELL_CSS = `
gap: var(--spacing-8);
}
.app-outlet {
min-height: calc(100vh - 64px - 56px);
min-height: calc(100dvh - var(--app-header-h) - 56px);
}
.app-outlet--without-footer {
min-height: calc(100vh - 64px);
min-height: calc(100dvh - var(--app-header-h));
}
.app-footer {
height: 56px;
@@ -285,7 +422,120 @@ const SHELL_CSS = `
}
@media (max-width: 900px) {
.app-nav { display: none; }
.app-actions { margin-left: auto; }
}
/* ☰ 서랍 — 공개 쪽은 폭 900 이하 · 터치 모드에서만 · 로그인 뒤 쪽(B · M)은 늘 */
.app-burger,
.app-drawer,
.app-drawer__scrim {
display: none;
}
.app-burger {
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
border: 1px solid var(--color-border);
border-radius: var(--radius-buttons);
background: var(--color-surface);
color: var(--color-text);
font-size: 20px;
cursor: pointer;
}
html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; }
.app-header.is-drawer-open { z-index: var(--z-overlay); }
.app-header.is-drawer-open .app-drawer__scrim {
display: block;
position: fixed;
inset: var(--app-header-h) 0 0 0;
background: rgba(38, 17, 74, 0.35);
}
.app-header.is-drawer-open .app-drawer {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
position: fixed;
top: var(--app-header-h);
right: 0;
bottom: 0;
width: min(320px, 100vw);
padding: var(--spacing-16);
overflow-y: auto;
background: var(--color-surface-raised);
border-left: 1px solid var(--color-border);
box-shadow: var(--shadow-lg);
}
.app-drawer__nav {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
}
.app-drawer__nav > * { justify-content: flex-start; min-height: 44px; }
.app-drawer__row {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8);
}
.app-drawer__group {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.app-drawer__group + .app-drawer__group {
padding-top: var(--spacing-16);
border-top: 1px solid var(--color-border);
}
.app-drawer__steps:empty { display: none; }
.app-drawer__touch {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.app-drawer__touch [aria-checked="true"] {
background: var(--color-mist-violet);
color: var(--color-accent);
}
/* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */
html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); }
html[data-size="compact"] .app-actions { display: none; }
html[data-size="compact"] .app-burger { margin-left: auto; }
/* 로그인 뒤 쪽(B · M) — 폭 · 입력과 상관없이 위 줄을 걷고 ☰ 만 화면 왼쪽 위 모서리에 띄움(7-1)
본문이 화면 높이를 다 씀 · 서랍은 왼쪽에서 열림 · 위 줄 항목은 모두 서랍에 있음 */
html.is-bm-route .app-header {
height: 0;
padding: 0;
border: 0;
z-index: calc(var(--z-dropdown) + 1);
}
html.is-bm-route .app-header > :is(.app-brand, .app-nav, .app-actions) {
display: none;
}
/* ☰ 폭 36(7-4) · 높이는 손가락 44 · 마우스 36 */
html.is-bm-route .app-burger {
min-width: var(--app-burger-w);
min-height: var(--app-burger-w);
position: fixed;
top: var(--spacing-8);
left: var(--spacing-8);
z-index: 1;
margin: 0;
padding: 0;
box-shadow: var(--shadow-lg);
}
html.is-bm-route[data-touch="on"] .app-burger { min-height: 44px; }
html.is-bm-route .app-header.is-drawer-open .app-drawer {
left: 0;
right: auto;
border-left: 0;
border-right: 1px solid var(--color-border);
}
html.is-bm-route .app-drawer__brand {
min-height: 44px;
padding-left: calc(var(--app-burger-inset) - var(--spacing-16));
}
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-start; }
`;
export function injectShellStyles(): void {
+92 -37
View File
@@ -11,6 +11,7 @@
import { API_BASE_URL } from "@config/config_frontend";
import { fetchConfirmedSurface } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { loadDesignTerrainGrid } from "../B05_Profile/B05_Profile_Api_TerrainGrid";
const DB_NAME = "aislo-asset-cache";
// 담아 둔 자료의 형식이나 내용이 바뀌면 이 번호를 올린다 — 올리면 기존 보관분을 통째로 버린다.
@@ -30,9 +31,19 @@ export interface CachedAsset {
let dbPromise: Promise<IDBDatabase | null> | null = null;
/** 보관함 실패는 화면을 막지 않되 한 줄은 남긴다 — 같은 까닭은 한 번만. */
const warned = new Set<string>();
function warnOnce(what: string, error?: unknown): void {
const name = (error as { name?: string } | null)?.name ?? String(error ?? "");
const key = `${what}|${name}`;
if (warned.has(key)) return;
warned.add(key);
console.warn(`[asset-cache] ${what} 실패 — 보관 없이 받음`, name);
}
function openDatabase(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
const opening = new Promise<IDBDatabase | null>((resolve) => {
if (!("indexedDB" in window)) {
resolve(null);
return;
@@ -45,9 +56,30 @@ function openDatabase(): Promise<IDBDatabase | null> {
const store = db.createObjectStore(STORE, { keyPath: "key" });
store.createIndex("projectId", "projectId", { unique: false });
};
request.onsuccess = () => resolve(request.result);
request.onsuccess = () => {
const db = request.result;
// 다른 탭이 번호를 올리면 닫아 비켜 준다 — 안 닫으면 그 탭이 막혀 기다린다.
db.onversionchange = () => {
db.close();
dbPromise = null;
};
resolve(db);
};
// 옛 번호로 연 다른 탭이 안 닫으면 막힘 — 기다리지 않고 이번엔 보관 없이 간다.
request.onblocked = () => {
warnOnce("열기(다른 탭이 막음)");
resolve(null);
};
// 사생활 보호 모드 등으로 열리지 않으면 보관함 없이 동작한다(항상 새로 받는다).
request.onerror = () => resolve(null);
request.onerror = () => {
warnOnce("열기", request.error);
resolve(null);
};
});
// 실패(null)를 페이지 끝까지 기억하지 않는다 — 다음 부름에서 다시 열어 본다.
dbPromise = opening.then((db) => {
if (!db) dbPromise = null;
return db;
});
return dbPromise;
}
@@ -66,9 +98,19 @@ function runTransaction<T>(
try {
const transaction = db.transaction(STORE, mode);
const request = work(transaction.objectStore(STORE));
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => resolve(null);
} catch {
// 쓰기의 끝 = 트랜잭션 완료 — 요청 성공 뒤에도 용량 초과 등으로 통째 취소될 수 있다.
let result: T | null = null;
request.onsuccess = () => {
result = request.result ?? null;
if (mode === "readonly") resolve(result);
};
transaction.oncomplete = () => resolve(result);
transaction.onabort = () => {
warnOnce(mode === "readonly" ? "읽기" : "저장", transaction.error ?? request.error);
resolve(null);
};
} catch (error) {
warnOnce(mode === "readonly" ? "읽기" : "저장", error);
resolve(null);
}
}),
@@ -87,26 +129,42 @@ async function writeAsset(asset: CachedAsset): Promise<void> {
await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest<unknown>);
}
/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */
export async function purgeOtherProjects(projectId: string): Promise<void> {
/** 지우기 트랜잭션 하나 — 끝(완료 · 취소)까지 기다린다. */
async function deleteWhere(work: (store: IDBObjectStore) => void): Promise<void> {
const db = await openDatabase();
if (!db) return;
await new Promise<void>((resolve) => {
try {
const transaction = db.transaction(STORE, "readwrite");
const store = transaction.objectStore(STORE);
const cursorRequest = store.openCursor();
work(transaction.objectStore(STORE));
transaction.oncomplete = () => resolve();
transaction.onabort = () => {
warnOnce("지우기", transaction.error);
resolve();
};
} catch (error) {
warnOnce("지우기", error);
resolve();
}
});
}
/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다.
* 색인 열쇠만 훑는다 — 값(수십 MB 바이트)을 읽지 않는다. */
export async function purgeOtherProjects(projectId: string): Promise<void> {
await deleteWhere((store) => {
const index = store.index("projectId");
for (const range of [
IDBKeyRange.upperBound(projectId, true),
IDBKeyRange.lowerBound(projectId, true),
]) {
const cursorRequest = index.openKeyCursor(range);
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
const value = cursor.value as CachedAsset;
if (value.projectId !== projectId) cursor.delete();
store.delete(cursor.primaryKey);
cursor.continue();
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => resolve();
} catch {
resolve();
}
});
}
@@ -150,26 +208,9 @@ export async function writeCachedBytes(
* 먹는다. 새것을 담기 **전에** 같은 앞머리를 치운다(2026-09-06).
*/
export async function purgeAssetsWithPrefix(projectId: string, prefix: string): Promise<void> {
const db = await openDatabase();
if (!db) return;
await new Promise<void>((resolve) => {
try {
const transaction = db.transaction(STORE, "readwrite");
const store = transaction.objectStore(STORE);
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
const value = cursor.value as CachedAsset;
if (value.projectId === projectId && value.url.startsWith(prefix)) cursor.delete();
cursor.continue();
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => resolve();
} catch {
resolve();
}
});
// 열쇠 = `${projectId}|${url}` — 앞머리 범위로 한 번에 지운다(값을 읽지 않음).
const low = cacheKey(projectId, prefix);
await deleteWhere((store) => store.delete(IDBKeyRange.bound(low, `${low}\uffff`)));
}
export interface CachedFetchOptions {
@@ -268,6 +309,17 @@ export async function fetchCachedJson<T>(
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
/** 등고선 주소(유일한 정의처) — 같은 파일은 같은 주소여야 보관함 · 미리 받기를 함께 쓴다. */
export function contourAssetUrl(
projectId: string,
modelId: number,
interval: number,
smooth: boolean,
recalculate = false,
): string {
return `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${smooth}&recalculate=${recalculate}`;
}
/** 배수유역도 배경으로 쓰는 도엽 레이어(유일한 정의처 — 준비화면과 B05 패널이 함께 쓴다). */
export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const;
@@ -376,10 +428,13 @@ export async function preloadSurfaceAssets(
report("등고선을 준비하는 중…", null);
await fetchCachedBytes(
projectId,
`${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`,
contourAssetUrl(projectId, confirmed.model_id, interval, smooth),
{ onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) },
);
// 노선 편집 지반고를 브라우저가 읽는 설계 지형 격자(PLAN 344-1) — 못 받아도 멈추지 않음(null).
await loadDesignTerrainGrid(projectId);
// 배수유역도 배경(도엽 표시본·위성사진)도 함께 담는다 — 없으면 B05가 진입할 때마다 받는다.
// 이 자료가 없어도 화면은 뜨므로 실패해도 준비를 멈추지 않는다.
report("배경 지도를 준비하는 중…", null);
+1 -1
View File
@@ -174,7 +174,7 @@ const SCAFFOLD_CSS = `
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
.b-scaffold-wf { height: calc(100vh - 64px - 56px); }
.b-scaffold-wf { height: calc(100dvh - var(--app-header-h) - 56px); }
.b-pending {
display: flex;
+55 -16
View File
@@ -57,8 +57,6 @@ export const STATE_REGISTRY = {
legacy: () => "b05-route-drainage-collapsed",
},
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
/** 종단도 오른쪽 판(배수유역도) 켬 · 끔 — 두 보기가 같은 값(계획서 24-2c · 24-2e). */
"drainage-visible": { bucket: "pref", scope: "global" },
/** M02 면적산출 오른쪽 일위대가 패널(`AreaPanel`) — 접힘 · 폭(옛 조합 패널 키 그대로) */
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
@@ -66,9 +64,11 @@ export const STATE_REGISTRY = {
"m02-card-pills": { bucket: "pref", scope: "global" },
/** M01 단가산출 로직 상세 오른쪽 — 열린 패널 하나(flow · source · none) */
"m01-logic-panel": { bucket: "pref", scope: "global" },
/** B09 묶음 탭(171-3) 보기 `{탭: cards · table}` · 가로 열 수 `{탭: 1~4}` */
/** B09 묶음 탭(171-3) 보기 `cards · table`(304-1 셋 탭 한 값) · 가로 열 수 `{탭: 1~4}` */
"b09-group-view": { bucket: "pref", scope: "global" },
"b09-group-cols": { bucket: "pref", scope: "global" },
/** B09 행 누름(303-1) `popup · link` */
"b09-link-mode": { bucket: "pref", scope: "global" },
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
"routeedit-view": { bucket: "pref", scope: "project" },
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
@@ -101,6 +101,14 @@ export const STATE_REGISTRY = {
},
/** B06 횡단 카드 한 줄 카드 수(138-3 · 1~6 · 기본 3). */
"cross-columns": { bucket: "pref", scope: "global" },
/** B05 · B06 종단 누가토량 알약 자리(309-2) — 그래프 영역 안 비율 `{"x":0~1,"y":0~1}` · 없으면 처음 자리. */
"mass-haul-badge-pos": { bucket: "pref", scope: "global" },
/** ☰ [설정] 마우스 · 단축키(332) — 읽기는 `input_prefs.ts`(같은 키 글자) · [저장] 때만 씀. */
"input-prefs": { bucket: "pref", scope: "global" },
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
"b05-3d-projection": { bucket: "pref", scope: "global" },
/** 배수유역 계산 `manual · auto`(347-2) · 없으면 수동. */
"drainage-mode": { bucket: "pref", scope: "global" },
"section-panel-height": {
bucket: "pref",
scope: "global",
@@ -110,6 +118,8 @@ export const STATE_REGISTRY = {
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
/** B06 횡단 [상세] 모달 측점 구조물 글(138-4) — 측점키(0.01 m) → 글. [저장]이 내보냄. */
"structure-notes": { bucket: "draft", scope: "route" },
/** B06 횡단 [상세] 모달 표 위 CAD 칸(260-1) — 측점키(0.01 m) → CAD 엔티티 묶음. [저장]이 내보냄. */
"structure-note-cads": { bucket: "draft", scope: "route" },
/** B06 횡단 [상세] 표 손질(179-4) — 측점키(0.01 m) → 고른 칸 · 더한 행 · 열. [저장]이 내보냄. */
"detail-table": { bucket: "draft", scope: "route" },
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
@@ -127,10 +137,14 @@ export const STATE_REGISTRY = {
scope: "project",
legacy: (p) => `aislo:structure-pick:${p}`,
},
/** 관 세션 id 장부(323-5 · `design_pipe_ids`) — `{next, ids: [{id, at}]}` · 저장 안 함. */
"pipe-ids": { bucket: "draft", scope: "project" },
/** 설계 가게(`design/design_store`)의 고른 측점 · 구조물 — `{chainageM, structureId}`. */
selection: { bucket: "draft", scope: "project" },
/** 구조물 패널 종류별 캐시 — 고른 구조물 하나 × 종류마다 옛 값(54-32). [저장]·[확정] 뒤 비움. */
"structure-type-cache": { bucket: "draft", scope: "project" },
/** 종류를 바꾼 관 자리 `{누가거리: 처음 종류}`(318-4) — [저장] 때 그 측점 옛 조정값을 정본에서도 비움. */
kindreset: { bucket: "draft", scope: "project" },
/** 배수관·암거 조정창에서 예약한 옵션 값. */
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
/** 배수관 이동(측점 옮김) 예약. */
@@ -185,6 +199,8 @@ export const STATE_REGISTRY = {
"b08-hydro": { bucket: "draft", scope: "project", version: 3 },
/** B08 좌측 패널 옵션 `{one_pipe_length_m, soft_rock_pct}` — [저장] 때 M02 표 변수 · project_settings 로(122). */
"b08-options": { bucket: "draft", scope: "project" },
/** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */
"b08-milestone": { bucket: "draft", scope: "project" },
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
"b09-estimation": { bucket: "draft", scope: "project" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
@@ -194,6 +210,9 @@ export const STATE_REGISTRY = {
scope: "route",
legacy: (_p, r) => `b05-profile-alignment-draft:${r}`,
},
/** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`).
* 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */
"route-draft": { bucket: "draft", scope: "project" },
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
@@ -221,6 +240,10 @@ export const STATE_REGISTRY = {
/** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값).
* 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */
"routeedit-initial": { bucket: "result", scope: "project" },
/** 계산 묶음 결과(344-4) — 측점 · 종단 · 평면 발자국 · 가게가 메모리 우선 · 멈춘 뒤 한 번 씀. */
"design-stations": { bucket: "result", scope: "project" },
"design-profile": { bucket: "result", scope: "project" },
"design-footprint": { bucket: "result", scope: "project" },
} as const satisfies Record<string, StateEntry>;
export type StateName = keyof typeof STATE_REGISTRY;
@@ -400,7 +423,16 @@ function readRaw(key: string): string | null {
}
}
const removedListeners = new Set<(key: string) => void>();
/** 키가 지워질 때 부름(`clearDrafts` · `clearResults` 포함) — 메모리 우선 칸(설계 가게)이 함께 버림. */
export function onStateRemoved(fn: (key: string) => void): () => void {
removedListeners.add(fn);
return () => removedListeners.delete(fn);
}
function writeRaw(key: string, value: string | null): void {
if (value === null) removedListeners.forEach((fn) => fn(key));
try {
const store = storageFor(key);
if (value === null) store.removeItem(key);
@@ -477,29 +509,36 @@ export function clearState(
routeId?: number | string | null,
): void {
const entry = entryOf(name);
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다.
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다(`routeStateKeys`).
// 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`),
// 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169
// 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 —
// 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다.
if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) {
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
try {
const doomed: string[] = [];
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
if (key && key.startsWith(prefix)) doomed.push(key);
}
doomed.forEach((key) => writeRaw(key, null));
} catch {
/* 무시 — 저장소가 막힌 브라우저 */
}
routeStateKeys(name, projectId).forEach((key) => writeRaw(key, null));
return;
}
writeStateRaw(name, null, projectId, routeId);
}
/** 노선 범위 이름의 그 프로젝트 키 전부(모든 노선 · 세션에 있는 것만). */
export function routeStateKeys(name: StateName, projectId: string): string[] {
const entry = entryOf(name);
if (entry?.scope !== "route") return [];
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
const keys: string[] = [];
try {
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
if (key && key.startsWith(prefix)) keys.push(key);
}
} catch {
/* 무시 — 저장소가 막힌 브라우저 */
}
return keys;
}
/** 등록표에서 한 통에 속한 이름만 고른다. */
export function namesInBucket(bucket: StateBucket): StateName[] {
return (Object.keys(STATE_REGISTRY) as StateName[]).filter(
+122
View File
@@ -0,0 +1,122 @@
/* =============================================================================
* A00_Common/b_project_switch.ts
* 다른 프로젝트에 들어갈 때 **그 밖 프로젝트의 캐시**를 지운다(계획서 290-1 · 요구 183-1).
*
* 지우는 것 — 이 탭 세션의 다른 프로젝트 칸(초안 · 결과 · 고름 · 옛 키 · 페이지별 칸)과
* 3D 지형 보관함(IndexedDB). 남기는 것 — 지금 프로젝트 것 · 계정 취향(pref, localStorage).
* 다른 프로젝트에 저장 안 한 고침(초안)이 있으면 확인창 — 「남기기」면 그 초안만 둔다.
* 같은 프로젝트로 다시 들어오면(새로고침 포함) 아무것도 안 지운다(탭마다 들어온 표식).
* ========================================================================== */
import { showConfirmDialog } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "./b_asset_cache";
import { STATE_REGISTRY, type StateEntry } from "./b_page_state";
/** 이 탭이 마지막으로 들어온 프로젝트. */
const ENTERED_KEY = "aislo:entered-project";
const MARK_P = "\u0001";
const MARK_R = "\u0002";
/** 프로젝트를 가르는 키 머리 — 등록표 키(`aislo:{통}:{이름}:`)와 옛 키 머리. 초안 여부를 함께. */
function projectPrefixes(): Array<{ prefix: string; draft: boolean }> {
const out: Array<{ prefix: string; draft: boolean }> = [];
Object.entries(STATE_REGISTRY as Record<string, StateEntry>).forEach(([name, entry]) => {
if (entry.bucket === "pref" || entry.scope === "global") return;
const draft = entry.bucket === "draft";
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
out.push({ prefix: `aislo:${entry.bucket}:${name}${suffix}:`, draft });
const legacy = entry.legacy?.(MARK_P, MARK_R);
const at = legacy?.indexOf(MARK_P) ?? -1;
if (legacy && at > 0) out.push({ prefix: legacy.slice(0, at), draft });
});
return out;
}
const filled = (value: string | null): boolean =>
value !== null && value !== "" && value !== "{}" && value !== "[]";
export interface ProjectPurgePlan {
/** 저장 안 한 고침이 남은 다른 프로젝트. */
draftProjects: string[];
/** 지울 키 — `keep` 프로젝트의 초안은 뺀다. */
doomed: (keep: ReadonlySet<string>) => string[];
}
/** 세션 칸(키 → 값)에서 다른 프로젝트 몫을 가른다 — 저장소를 만지지 않는다. */
export function planProjectPurge(
session: Record<string, string>,
projectId: string,
): ProjectPurgePlan {
const prefixes = projectPrefixes();
const owners = new Map<string, { project: string; draft: boolean }>();
Object.keys(session).forEach((key) => {
const hit = prefixes.find((item) => key.startsWith(item.prefix));
if (!hit) return;
const project = key.slice(hit.prefix.length).split(":")[0];
if (project) owners.set(key, { project, draft: hit.draft });
});
const others = new Set(
[...owners.values()].map((owner) => owner.project).filter((id) => id !== projectId),
);
const draftProjects = [
...new Set(
[...owners.entries()]
.filter(([key, owner]) => owner.draft && others.has(owner.project) && filled(session[key]))
.map(([, owner]) => owner.project),
),
];
return {
draftProjects,
// 등록표 밖 칸도 키 마디에 다른 프로젝트 번호가 있으면 그 프로젝트 몫으로 본다.
doomed: (keep) =>
Object.keys(session).filter((key) => {
const owner = owners.get(key);
if (owner) return others.has(owner.project) && !(owner.draft && keep.has(owner.project));
return key.split(":").some((part) => others.has(part) && !keep.has(part));
}),
};
}
function sessionEntries(): Record<string, string> {
const out: Record<string, string> = {};
try {
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
const value = key === null ? null : window.sessionStorage.getItem(key);
if (key !== null && value !== null) out[key] = value;
}
} catch {
/* 저장소가 막힌 브라우저 — 지울 것이 없다. */
}
return out;
}
/**
* 프로젝트에 들어감 — 들어온 표식이 다르면 그 밖 프로젝트 캐시를 지운다. 실패해도 이동은 막지 않는다.
* `nameOf` 는 확인창에 적을 프로젝트 이름.
*/
export async function enterProject(
projectId: string,
nameOf: (projectId: string) => Promise<string>,
): Promise<void> {
try {
if (window.sessionStorage.getItem(ENTERED_KEY) === projectId) return;
const plan = planProjectPurge(sessionEntries(), projectId);
const keep = new Set<string>();
for (const other of plan.draftProjects) {
const name = await nameOf(other).catch(() => other);
const drop = await showConfirmDialog(
`「${name}」에 저장 안 한 고침이 있음 — 지우고 열기 / 남기기`,
"지우고 열기",
"남기기",
);
if (!drop) keep.add(other);
}
plan.doomed(keep).forEach((key) => window.sessionStorage.removeItem(key));
window.sessionStorage.setItem(ENTERED_KEY, projectId);
await purgeOtherProjects(projectId);
} catch {
/* 저장소가 막힌 브라우저 — 캐시를 못 지워도 프로젝트는 연다. */
}
}
+60 -12
View File
@@ -14,6 +14,8 @@
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
* ========================================================================== */
import { wheelEventIn } from "./input_prefs";
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
export interface ZoomPanState {
scale: number;
@@ -61,12 +63,18 @@ export interface SvgZoomPanOptions {
maxScale?: number;
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
initial?: ZoomPanState;
/** 처음 · 원복 배율(기본 1) — 플롯 중앙을 붙잡음 · 이보다 작게도 줄일 수 있음(266-1 B06 상세). */
homeScale?: number;
/** 처음 · 원복 상태를 통째로(295-2 B06 상세 — 중심점 가운데) · 주면 `homeScale` 대신 · 1 보다 작아도 됨. */
home?: ZoomPanState;
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
onChange?: (state: ZoomPanState) => void;
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds;
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
wheelZoom?: boolean;
/** 팬을 시작할 누름인지 — 안 주면 가운데 버튼만. 고르기가 없는 빈 곳 왼쪽 끌기를 더할 때 쓴다. */
panStart?: (event: PointerEvent) => boolean;
}
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
@@ -83,9 +91,17 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
height: Math.max(heightPx - pad.top - pad.bottom, 1),
};
const maxScale = options.maxScale ?? 8;
let scale = initial?.scale ?? 1;
let tx = initial?.tx ?? 0;
let ty = initial?.ty ?? 0;
const homeScale = Math.min(Math.max(options.homeScale ?? 1, 1), maxScale);
const home: ZoomPanState = options.home ?? {
scale: homeScale,
tx: (plot.x + plot.width / 2) * (1 - homeScale),
ty: (plot.y + plot.height / 2) * (1 - homeScale),
};
/** 축소 한계 — 1배 · 처음 보기가 더 작으면 그만큼. */
const minScale = Math.min(1, home.scale);
let scale = initial?.scale ?? home.scale;
let tx = initial?.tx ?? home.tx;
let ty = initial?.ty ?? home.ty;
const applyTransform = (): void => {
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty });
@@ -122,7 +138,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
const zoomAt = (factor: number, cx: number, cy: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor));
const next = Math.min(maxScale, Math.max(minScale, scale * factor));
tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next;
scale = next;
@@ -146,8 +162,10 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
if (event.button !== 1) return;
// 누름마다 풂 — 가운데 단추 팬 뒤엔 click 이 안 와 `moved` 가 남아 왼쪽 누름을 삼켰음(259-1)
moved = false;
// 팬은 **가운데 버튼**만(기본). 좌클릭은 그 화면의 고르기 몫이다.
if (!(options.panStart ? options.panStart(event) : event.button === 1)) return;
event.preventDefault();
panning = true;
moved = false;
@@ -178,7 +196,7 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
/* 이미 해제됨 */
}
};
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
if (scale !== 1 || tx !== 0 || ty !== 0) {
clampPan();
applyTransform();
}
@@ -197,17 +215,47 @@ export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
const rect = svg.getBoundingClientRect();
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`).
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy);
// ☰ [설정] 휠 방향(332 · 기본 당기면 확대) — 그림 면이 손가락을 받으므로 핀치는 트랙패드뿐.
zoomAt(wheelEventIn(event) ? WHEEL_IN : WHEEL_OUT, cx, cy);
},
{ passive: false },
);
}
// 손가락 둘 = 벌려 확대 · 같이 끌어 이동(기기대응 3-2) — 한 손가락은 그 화면의 고르기 · 목록 밀기 몫.
// 휠 줌 화면은 그림 면이 손가락을 다 받음 · 아니면(B06 카드 목록) 한 손가락 밀기는 브라우저에 둠.
svg.setAttribute?.("data-touch-own", "pinch"); // ?. = 노드 시험의 흉내 SVG
svg.style?.setProperty("touch-action", options.wheelZoom ? "none" : "pan-x pan-y");
const touches = new Map<number, { x: number; y: number }>();
const touchPair = (): { x: number; y: number; dist: number } => {
const [a, b] = [...touches.values()];
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2, dist: Math.hypot(a.x - b.x, a.y - b.y) };
};
svg.addEventListener("pointerdown", (event) => {
if (event.pointerType === "touch")
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
});
svg.addEventListener("pointermove", (event) => {
if (event.pointerType !== "touch" || !touches.has(event.pointerId)) return;
const before = touches.size === 2 ? touchPair() : null;
touches.set(event.pointerId, { x: event.clientX, y: event.clientY });
if (!before) return;
const after = touchPair();
const rect = svg.getBoundingClientRect();
const w = Math.max(rect.width, 1);
const h = Math.max(rect.height, 1);
tx += ((after.x - before.x) / w) * widthPx;
ty += ((after.y - before.y) / h) * heightPx;
moved = true;
const cx = ((after.x - rect.left) / w) * widthPx;
const cy = ((after.y - rect.top) / h) * heightPx;
zoomAt(before.dist > 0 && after.dist > 0 ? after.dist / before.dist : 1, cx, cy);
});
const liftTouch = (event: PointerEvent): void => void touches.delete(event.pointerId);
svg.addEventListener("pointerup", liftTouch);
svg.addEventListener("pointercancel", liftTouch);
const currentScale = (): number => scale;
const reset = (): void => {
scale = 1;
tx = 0;
ty = 0;
({ scale, tx, ty } = home);
applyTransform();
};
// 더블클릭 원복.
+15 -8
View File
@@ -10,6 +10,7 @@ import {
fetchSurfaceStatus,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache";
import { enterProject } from "./b_project_switch";
import { navigateTo } from "./router";
export interface WorkflowState {
@@ -93,14 +94,20 @@ export async function dropGoneProject(): Promise<boolean> {
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
clearWorkflowStateCache();
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
navigateTo(route);
return;
}
void routeAfterPreloadCheck(projectId, route);
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
void enterProject(
projectId,
async (id) => (await fetchWorkflowState(id)).project_name || id,
).then(() => {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
clearWorkflowStateCache();
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
navigateTo(route);
return;
}
void routeAfterPreloadCheck(projectId, route);
});
}
/**
+38
View File
@@ -0,0 +1,38 @@
# A00_Common/design 지침
B05 · B06 한 페이지 뼈대 — 주소 둘(`#/b05-profile` · `#/b06-section`)이 `design_page` 하나를 엶.
갈래 지도(앞머리 → 하는 일):
- `design_page` 페이지 틀 · 보기 전환(mountCenter) · `design_shared` · `design_panel` · `design_dock` 공용 틀 · 좌측 칸 · 도크
- `design_view_profile` 종단 보기 · `design_view_cross` 횡단 보기
- `design_profile*` 가운데 아래 종단도 판(자리 · 겹침 · 떼어 보기 · 자유 보기)
- `design_store` 가게 · `design_selection` · `design_pick_*` 선택 · 집기 · `design_pipe_ids` 관 id · `design_sections` 측점
- `design_save` [저장] · `design_reset` [초기화] — B05 · B06 캐시 한 번에
- `design_structure_*` 구조물 명세 · `_calc` 명세 계산
① 짝(ts ↔ py · 거울 시험):
- `design_structure_ford_calc.ts` fordHeightM ↔ `B04_PreProcess_Router_Basins.ford_height_m` · `test_65_ford_defaults.py`
- 같은 파일 ford_depth ↔ `B08_Quantity_Engine_Hydro.ford_depth` · `test_73_11_b08_hydro_box_ford.py`
- `design_structure_pipe_calc.ts` 관 길이 = B06 `computeStoredLayouts`(② 서버 Node) 그대로 · 새로 셈하지 않음
서버 Node 묶음:
- 입구 없음 · 끌려감 = `design_pick_match` · `design_pipe_ids` · `design_selection`(corridor · server-calc · b07-extent) · `design_store` · `design_structure_{box,ford,ford_pavement,guard,pipe,road,spec}`(b07-extent · b07-note) · 상위 `A00_Common/b_page_state`
- 끌려가는 파일 모듈 맨 위에 DOM · window · sessionStorage 금지 · 고치면 해당 build 다시 지어 시험
남의 페이지가 부르는 파일(고치면 부르는 쪽 시험도):
- B05 · B06 둘 다 — `design_panel` · `design_pick_match` · `design_pipe_ids` · `design_profile` · `design_sections` · `design_selection`(B04 도) · `design_shared` · `design_structure_pipe_calc` · `design_structure_status`
- M02 — `design_structure_form` · `design_structure_pipe` · `design_structure_spec`
logic/ 규칙:
- 페이지 전용 계산은 각 페이지 `logic/` · B05 · B06 공용 계산은 `common_util`
- design 은 화면 · 가게 자리 — 새 순수 계산을 여기 두지 않음
금지:
- B05 · B06 캐시를 따로 저장하지 않음 — [저장] 한 번(`design_save`)
- 자동저장(디바운스 PUT) 만들지 않음
+123
View File
@@ -0,0 +1,123 @@
/* =============================================================================
* A00_Common/design/design_leave.ts
* B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때
* 공용 물음(`ui_template_leave_dialog` · [취소] [버리고 넘어감] [저장 후 넘어감]).
*
* 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) ·
* 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것.
* 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄:
* [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음.
* B05 ↔ B06 는 한 페이지 — 그 사이 이동은 묻지 않음.
* 프로젝트 바꿈은 이 페이지를 떠난 뒤라 이 물음이 먼저 · 들어갈 때 물음은 `b_project_switch`.
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import { t as L } from "@ui/ui_template_locale";
import {
clearState,
namesInBucket,
readByKey,
routeStateKeys,
stateKey,
type StateName,
} from "../b_page_state";
import { currentRoute, setLeaveGuard } from "../router";
/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [버리고 넘어감]에도 안 지움. */
const NOT_EDITS = new Set<StateName>([
"selection",
"pipe-ids",
"structure-pick",
"structure-type-cache",
"std-cross",
]);
const OTHER_PAGE = /^b0[89]-/;
export function editDraftNames(): StateName[] {
return namesInBucket("draft").filter((name) => !NOT_EDITS.has(name) && !OTHER_PAGE.test(name));
}
const filled = (raw: string | null): raw is string =>
raw !== null && raw !== "" && raw !== "{}" && raw !== "[]";
/** 고침 칸 지문 — 모든 노선 것 · 빈 칸 뺌 · 고침이 없으면 "". */
export function editSignature(projectId: string): string {
const parts: string[] = [];
for (const name of editDraftNames()) {
const own = stateKey(name, projectId);
const keys = own ? [own] : routeStateKeys(name, projectId);
for (const key of keys.sort()) {
const raw = readByKey(key);
if (filled(raw)) parts.push(`${key}=${raw}`);
}
}
return parts.join("\n");
}
/** 긴 지문을 들고 있지 않게 — FNV-1a 32비트 + 길이. */
function digest(text: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `${text.length}:${hash >>> 0}`;
}
// ponytail: 깨끗한 표는 메모리만 — [저장] 뒤 새로고침하면 남은 횡단 초안 때문에 한 번 더 물음.
const cleanMarks = new Map<string, string>();
/** [저장] · [확정]이 끝난 때 — 지금 초안을 깨끗한 것으로 적음. */
export function markDesignClean(projectId: string): void {
if (projectId) cleanMarks.set(projectId, digest(editSignature(projectId)));
}
/** 저장 안 한 조작이 있나. */
export function designDirty(projectId: string): boolean {
if (!projectId) return false;
const signature = editSignature(projectId);
if (!signature) return false;
return cleanMarks.get(projectId) !== digest(signature);
}
/** [버리고 넘어감] — 고침 칸만 지움(모든 노선). */
export function discardDesignEdits(projectId: string): void {
editDraftNames().forEach((name) => clearState(name, projectId));
}
const SAME_PAGE = new Set<string>([ROUTES.B05_PROFILE, ROUTES.B06_SECTION]);
/**
* 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂.
* `save` = [저장] 한 줄기(`saveSharedDesign(shared, false)`).
*/
export function guardDesignLeave(
projectId: string,
root: HTMLElement,
save: () => Promise<unknown>,
): void {
const unload = (event: BeforeUnloadEvent): void => {
if (!root.isConnected) return window.removeEventListener("beforeunload", unload);
if (!designDirty(projectId)) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", unload);
const guard = async (): Promise<boolean> => {
if (!root.isConnected) return true;
if (SAME_PAGE.has(currentRoute())) {
// 라우터가 「넘어감」 뒤 물음을 내림 — 같은 페이지 안 이동이면 한 틱 뒤 다시 걺.
window.setTimeout(() => root.isConnected && setLeaveGuard(guard), 0);
return true;
}
if (!designDirty(projectId)) return true;
const got = await leaveOrStay(L("B05_Design_Leave"), {
save,
stillDirty: () => designDirty(projectId),
discard: () => discardDesignEdits(projectId),
});
return got !== "stay";
};
setLeaveGuard(guard);
}
+32 -3
View File
@@ -10,8 +10,8 @@
display: none;
}
/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 3D 위에 떠 있는
판(absolute · 3D 크기가 안 바뀜)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */
/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 평면(노선 편집) 위에
떠 있는 판(absolute · 평면은 전체 영역 · 345-5)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */
.design-page__main {
position: relative;
display: flex;
@@ -50,11 +50,40 @@
display: none;
}
.b05-route-profile__trailing > .b05-route-profile__popout {
.b05-route-profile__tools > .b05-route-profile__popout {
padding: var(--btn-bar-padding);
font-size: var(--btn-bar-font-size);
}
/* 유토곡선 · 배수유역 판 [창 분할](계획서 325-3) — 종단 [창 분할]과 같은 양식 · 유토는 판 좌상단에 띄움. */
.b05-subpanel__popout {
flex: none;
padding: var(--btn-bar-padding);
font-size: var(--btn-bar-font-size);
}
.b05-profile__masshaul-overlay > .b05-subpanel__popout {
position: absolute;
top: 4px;
left: var(--spacing-8);
z-index: 4;
}
/* 새 창으로 뗀 유토곡선 · 배수유역 판 — 창 전체를 채움 · 높이 · 폭 끌기와 접기 손잡이는 감춤. */
.b05-profile__masshaul-overlay.is-popout,
.b05-drainage.is-popout {
position: relative;
width: 100%;
max-width: none;
height: 100dvh;
}
.b05-profile__masshaul-overlay.is-popout > .ui-resizer,
.b05-drainage.is-popout > .ui-resizer,
.b05-drainage.is-popout > .ui-workflow-overlay__handle {
display: none;
}
/* 횡단 조정(카드 조정창 사본 · 폼 칸에 옮겨 붙은 행)은 횡단 보기 몫 — 종단 보기에서는 한 벌 폼
안에서 가린다(패널 뿌리 클래스가 보기를 따라 바뀐다). */
.b05-route__panel .b06-adjust-dock,
+5 -1
View File
@@ -28,7 +28,9 @@ import {
} from "../b_workflow_nav";
import { currentRoute, navigateTo, type MountedPage } from "../router";
import type { DesignView } from "./design_dock";
import type { DesignPageShared } from "./design_shared";
import { saveSharedDesign, type DesignPageShared } from "./design_shared";
import { guardDesignLeave } from "./design_leave";
import { usePipeIds } from "./design_pipe_ids";
import "./design_page.css";
export type { DesignPageShared } from "./design_shared";
@@ -105,6 +107,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
page.className = "design-page";
root.replaceChildren(page);
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
const left = document.createElement("div");
@@ -136,6 +139,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
revealLeft: () => layout?.setOptionsOpen(true),
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
};
guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4)
/** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */
const applyView = (view: DesignView): void => {
+3 -2
View File
@@ -82,15 +82,16 @@ export function createSectionFrame(
heading.textContent = title;
if (view === "profile") {
// ui-sidebar-section: 사이드 컨테이너 공통 외곽선(진하게, 2026-08-05 사용자 지시).
// ui-panel-form: 좌측 패널 컨테이너 공용 양식(269 · 「구조물 배치」 기준 칸 · 라벨 · 단추) — 두 보기 모두.
// 접힘 초기값만 여기서 준다 — 펼치기·접기 토글은 ui_template_collapsible 전역 몫.
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
root.className = `b05-route__panel-section ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
heading.className = "ui-collapsible__title";
const body = document.createElement("div");
body.className = "b05-route__panel-body";
root.append(heading, body);
return { root, body };
}
root.className = `b06-profile__group ui-collapsible ui-sidebar-section${collapsed ? " is-collapsed" : ""}`;
root.className = `b06-profile__group ui-collapsible ui-sidebar-section ui-panel-form${collapsed ? " is-collapsed" : ""}`;
heading.className = "b06-profile__group-legend ui-collapsible__title";
root.append(heading);
return { root, body: root };
+31
View File
@@ -0,0 +1,31 @@
/* =============================================================================
* A00_Common/design/design_pick_escape.ts
* 풀기 한 길 — Esc = 고름 한 칸 비움 (233-3 · 조사 AF (나)).
*
* 값을 비우면 구독한 화면이 모두 따라 풂 — 종단(측점선 · 알약) · 3D(띠 · 핀 · 부재) · 배수유역도 ·
* 좌측 폼 · 횡단 카드 · 범위 테두리 · 포장층 · 형상. 입력 칸 · 창(모달) · 메뉴 · 종단 도구가 먼저
* 받은 Esc(입력 취소 · 창 닫기 · 도구 끝)는 그쪽 몫이라 건드리지 않음.
* ========================================================================== */
import { NO_SELECTION, selectDesign } from "./design_selection";
/** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */
export const ESCAPE_FROM = "esc";
const OWN_ESCAPE =
"input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop";
/** 보기마다 건다 — 보이는 보기 하나만 받음 · 보기를 떠나면 뗌. */
export function bindEscapeRelease(
projectId: string | null,
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
): void {
const onKey = (event: KeyboardEvent): void => {
if (event.key !== "Escape" || event.defaultPrevented || !host.visible()) return;
const target = event.target as HTMLElement | null;
if (target?.closest?.(OWN_ESCAPE) || document.querySelector(".ui-modal__backdrop")) return;
selectDesign(projectId, NO_SELECTION, { from: ESCAPE_FROM });
};
window.addEventListener("keydown", onKey);
host.onDispose(() => window.removeEventListener("keydown", onKey));
}
+53
View File
@@ -0,0 +1,53 @@
/* =============================================================================
* A00_Common/design/design_pick_match.ts
* 고름 강조 한 규칙(323-5) — 그린 것(카드 형상 · 3D 부재 · 알약 · 측점선)마다 신원표 `{구조물 id · 부분 · 자리}`
* 를 달고, 고름 한 칸(`structure-pick`)과 이 함수 하나로 켜짐을 가름.
*
* ① 고름 · 그림 둘 다 구조물 id 가 있으면 id 가 같아야(관 = 세션 id `design_pipe_ids` · 옮겨도 그대로)
* ② 아니면 같은 자리(`PIPE_MATCH_M` — 같은 자리 거리는 이 한 값)
* ③ 고른 부분(부재키)이 있으면 그 부분만 · 부분을 안 골랐으면 그 구조물 그림 모두
* DOM 은 `data-structure-id` · `data-part`(`tagShape`) — 부분 그림(관 · 벽 · 날개벽 · 물넘이 콘크리트)은
* 조정창 손잡이가 같은 고름으로 칠함(`paintPicked` 는 부분 없는 형상만 · 307 「측점 길은 부분 안 켬」).
* ========================================================================== */
import { isNear, type DesignSelection } from "./design_selection";
export interface PickTag {
structureId?: string | null;
part?: string | null;
chainageM?: number | null;
}
export type PickSelection =
(Partial<DesignSelection> & { partKey?: string | null }) | null | undefined;
/** 이 그림이 고름에 켜지나 — 위 ① ② ③. */
export function pickLights(selection: PickSelection, tag: PickTag): boolean {
if (!selection) return false;
let here: boolean;
if (selection.structureId && tag.structureId) here = selection.structureId === tag.structureId;
else if (selection.chainageM == null || tag.chainageM == null) return false;
else here = isNear(tag.chainageM, selection.chainageM);
if (!here) return false;
return !selection.partKey || tag.part === selection.partKey;
}
/** 그린 형상에 신원표를 닮(부분 없으면 통째 형상). */
export function tagShape(el: Element, structureId: string | null, part?: string | null): void {
if (structureId) el.setAttribute("data-structure-id", structureId);
if (part) el.setAttribute("data-part", part);
}
/** 카드 안 통째 형상 칠하기 — 고른 구조물 id 와 같은 형상만. 부분 그림(`data-part`) · 관 계열(`pipe-`) ·
* 노면공 포장층은 제 손잡이(조정창 · 포장층 고름)가 칠함 — 측점 길(알약 · 목록 · 측점선)은 부분을 안 켬(307). */
export function paintPicked(root: ParentNode, selection: PickSelection): void {
for (const shape of root.querySelectorAll<SVGElement>("[data-structure-id]")) {
const id = shape.getAttribute("data-structure-id") ?? "";
if (id.startsWith("pipe-") || shape.getAttribute("data-part") !== null) continue;
if (shape.getAttribute("data-pave-type") !== null) continue;
shape.classList.toggle(
"is-active",
!!selection?.structureId && pickLights(selection, { structureId: id }),
);
}
}
+160
View File
@@ -0,0 +1,160 @@
/* =============================================================================
* A00_Common/design/design_pick_profile.ts
* 종단 보기 고름 강조 = 고름 한 칸에서 파생 — 종단도(측점 세로선 · 알약 · 구간 띠 · 233-1) ·
* 3D(측점 띠 · 핀 · 부재 · 233-2) · 배수유역도(관 마커 · 유역 · 측점 마름모 · 233-3) (조사 AF (나) ② ③ ⑤).
*
* 고름 한 칸(`structure-pick`)이 유일 원천이다. 값이 바뀌면(구독) 그 값으로 종단도를 다시 세우고,
* 관 · 구조물 목록이 바뀐 뒤에도 같은 값으로 다시 얹는다(`paint`) — 화면끼리 손으로 부르던 길이
* 하나 빠지거나 다른 길이 강조를 끄더라도 종단도는 값을 따라간다. 관 알약 id(`pipe-`+측점)는
* 그릴 때마다 지금 관 목록에서 다시 찾고 관 id 는 세션 id(323-5)라 관이 옮겨져도 끊기지 않는다.
* ========================================================================== */
import {
nearestWithin,
readSelection,
subscribeSelection,
type DesignSelection,
} from "./design_selection";
import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape";
import { pipeIdAt } from "./design_pipe_ids";
import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
/** 종단도가 켤 것 — 측점선(알약은 그 측점 짝을 종단도가 맞춤) 또는 알약(구조물 · 관 `pipe-*`). */
export type ProfilePick = { stationId: string | null } | { structureId: string };
export interface ProfilePickSource {
/** 구조물 측점(수동 + 관 투영) — id 는 `irregularStationId` 를 씌우기 전 값. */
irregular: () => ReadonlyArray<{ id: string; chainage_m: number; origin?: string }>;
/** 관 지점 정본(배수유역도). */
pipes: () => ReadonlyArray<{ chainage_m: number }>;
/** 일반 측점 카드. */
sections: () => ReadonlyArray<{ station_id: string; chainage_m: number }>;
}
export interface ProfilePickTarget {
setSelectedStation: (stationId: string | null) => void;
setSelectedStructure: (structureId: string | null) => void;
/** 배수유역도 — 관 마커 · 유역(알림 없이) · 측점 마름모. */
drainage?: {
selectPipeAtChainage: (chainageM: number | null, notify?: boolean) => void;
markStation: (chainageM: number | null) => void;
} | null;
}
/** 같은 값으로 적힌 측점인가 — 고름 한 칸은 고른 측점의 누가거리를 그대로 적음(찾는 폭 아님). */
const sameAt = (a: number, b: number): boolean => Math.abs(a - b) < 1e-6;
/**
* 고름 값 → 종단도가 켤 것(B05 규칙). ① 구조물 id = 그 알약(측점선은 제 측점이 있을 때만 · 189-1)
* ② 그 자리 구조물 측점(같은 m 면 측점 하나 · 구조물이 위 · 272 — 일반 카드 id 는 종단도가 숨긴 측점)
* ③ 고른 측점 그대로(일반 카드) ④ 그 자리 관 측점 · 관 알약 · 카드(`graphTargetAt` · 188-1).
*/
export function profilePickOf(
selection: DesignSelection | null,
source: ProfilePickSource,
): ProfilePick {
if (!selection || selection.chainageM === null) return { stationId: null };
if (selection.structureId) return { structureId: selection.structureId };
const at = selection.chainageM;
const irregular = source.irregular();
const exact = irregular.find((entry) => sameAt(entry.chainage_m, at));
if (exact) return { stationId: irregularStationId(exact.id) };
const card = source.sections().find((entry) => sameAt(entry.chainage_m, at));
if (card) return { stationId: card.station_id };
const own = nearestWithin(
irregular.filter((entry) => entry.origin === "pipe"),
at,
(entry) => entry.chainage_m,
);
if (own) return { stationId: irregularStationId(own.id) };
const target = graphTargetAt(null, at, source.pipes(), source.sections());
return "pipeChainageM" in target ? { structureId: pipeIdAt(target.pipeChainageM) } : target;
}
export function paintProfilePick(target: ProfilePickTarget, pick: ProfilePick): void {
if ("structureId" in pick) target.setSelectedStructure(pick.structureId);
else target.setSelectedStation(pick.stationId);
}
/** 배수유역도 = 그 자리 관 마커 · 유역(없으면 풂) + 측점 마름모 — 재계산이 지운 뒤에도 다시 얹음(a3 · a6). */
export function paintMapPick(
map: NonNullable<ProfilePickTarget["drainage"]>,
selection: DesignSelection | null,
): void {
const at = selection?.chainageM ?? null;
map.selectPipeAtChainage(at, false);
map.markStation(at);
}
export interface ViewerPickTarget {
markers: { selectStation: (stationId: string | null, notify?: boolean) => void };
structurePick: {
select: (
pick: { chainageM: number; kind: string; key?: string; structureId?: string | null } | null,
) => void;
};
}
/** 고름 값 → 3D 가 켤 것 — 띠(+핀) = 종단 측점선과 같은 측점(구조물 고름은 그 자리 구조물 측점만) ·
* 부재 = 고른 측점의 부재 전부(부재키가 있으면 그 부재 하나). */
export function viewerPickOf(
selection: (DesignSelection & { partKey?: string }) | null,
source: ProfilePickSource,
): {
stationId: string | null;
member: { chainageM: number; kind: string; key?: string; structureId?: string | null } | null;
} {
if (!selection || selection.chainageM === null) return { stationId: null, member: null };
const at = selection.chainageM;
const pick = profilePickOf(selection, source);
const own = selection.structureId
? nearestWithin(source.irregular(), at, (entry) => entry.chainage_m)
: undefined;
const stationId = "stationId" in pick ? pick.stationId : own ? irregularStationId(own.id) : null;
return {
stationId,
member: { chainageM: at, kind: "", key: selection.partKey, structureId: selection.structureId },
};
}
export function paintViewerPick(
target: ViewerPickTarget,
pick: ReturnType<typeof viewerPickOf>,
): void {
target.markers.selectStation(pick.stationId, false); // 칠하기만 — 알림이 사슬을 다시 돌리지 않게
target.structurePick.select(pick.member);
}
/**
* 종단 보기(종단도 · 3D · 배수유역도)를 고름 한 칸에 묶음 — 값이 바뀔 때마다 그림 · `paint()` = 목록이
* 바뀐 뒤 · 재계산 뒤 · 폼이 닫힌 뒤 같은 값으로 다시 얹음. 보기가 숨어 있으면(횡단 보기가 종단도를 쥠)
* 안 그림 — 그 보기가 제 규칙으로 그림 · 보기를 떠나면 구독 끊음. Esc(풀기 한 길)는 좌측 폼까지 닫음.
*/
export function bindProfilePick(
projectId: string | null,
target: {
profile: ProfilePickTarget;
viewer?: ViewerPickTarget;
form?: { hasSelection: () => boolean; selectById: (id: null) => void };
},
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
source: ProfilePickSource,
): { paint: () => void } {
const paint = (): void => {
if (!host.visible()) return;
const selection = readSelection(projectId);
paintProfilePick(target.profile, profilePickOf(selection, source));
if (target.viewer) paintViewerPick(target.viewer, viewerPickOf(selection, source));
if (target.profile.drainage) paintMapPick(target.profile.drainage, selection);
};
host.onDispose(
subscribeSelection(projectId, (_selection, from) => {
paint();
if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection())
target.form.selectById(null);
}),
);
bindEscapeRelease(projectId, host);
return { paint };
}
+97
View File
@@ -0,0 +1,97 @@
/* =============================================================================
* A00_Common/design/design_pipe_ids.ts
* 관 세션 id(323-5) — 관을 처음 볼 때 자리마다 붙이는 id · 옮겨도 그대로 · 저장 안 함(파일 모양 그대로).
*
* id = `pipe-s` + 차례(`pipe-s3`) — 머리 `pipe-` 로 관임을 가름(옛 자리 이름 `pipe-{누가거리}` 와 같은 머리) ·
* 누가거리는 id 에서 읽지 않음(`pipeChainageOf`). 장부 = 세션 `pipe-ids`(고름 `structure-pick` 과 같은 수명 ·
* 초안 비우기에 함께 비움). 자리 → id = 찾는 거리(`PIPE_MATCH_M`) 안 가장 가까운 것 · 없으면 새 차례 ·
* 옮김 = `movePipeId`(고름 따라가기 `followMovedPipe` 가 부름).
* 프로젝트 = 보기가 열 때 정함(`usePipeIds`) — 그리는 함수(카드 · 알약)가 프로젝트를 몰라도 같은 id.
* 프로젝트가 없으면(시험 · 도움 파일) 옛 자리 이름.
* ========================================================================== */
import { readState, writeState } from "../b_page_state";
import { isNear, nearestWithin, readSelection, selectDesign } from "./design_selection";
/** 관 찾는 거리 — 정의는 `design_selection` 한 곳(배수 패널이 관 옮김과 함께 가져감). */
export { PIPE_MATCH_M } from "./design_selection";
interface Book {
next: number;
ids: Array<{ id: string; at: number }>;
}
let active: string | null = null;
/** 지금 프로젝트 — 종단 · 횡단 보기가 열 때. */
export function usePipeIds(projectId: string | null): void {
active = projectId;
}
const bookOf = (projectId: string): Book =>
readState<Book>("pipe-ids", projectId) ?? { next: 1, ids: [] };
/** 그 자리 관의 id — 장부에 없으면 새 차례로 적음. */
export function pipeIdAt(chainageM: number, projectId: string | null = active): string {
if (!projectId) return `pipe-${chainageM.toFixed(2)}`;
const book = bookOf(projectId);
const hit = nearestWithin(book.ids, chainageM, (entry) => entry.at);
if (hit) return hit.id;
const id = `pipe-s${book.next}`;
writeState(
"pipe-ids",
{ next: book.next + 1, ids: [...book.ids, { id, at: chainageM }] },
projectId,
);
return id;
}
/** id 의 관 자리 — 장부 · 옛 자리 이름 순 · 관 id 가 아니면 null. */
export function pipeChainageOf(id: string, projectId: string | null = active): number | null {
if (!id.startsWith("pipe-")) return null;
const hit = projectId ? bookOf(projectId).ids.find((entry) => entry.id === id) : undefined;
if (hit) return hit.at;
const legacy = Number(id.slice("pipe-".length));
return Number.isFinite(legacy) ? legacy : null;
}
/** 관을 옮김 — 옛 자리 id 가 새 자리로(새 자리에 남은 옛 기록은 뺌) · 옮긴 관의 id. */
export function movePipeId(fromM: number, toM: number, projectId: string | null = active): string {
if (!projectId) return `pipe-${toM.toFixed(2)}`;
const book = bookOf(projectId);
const moving = nearestWithin(book.ids, fromM, (entry) => entry.at);
if (!moving) return pipeIdAt(toM, projectId);
const stale = nearestWithin(
book.ids.filter((entry) => entry !== moving),
toM,
(entry) => entry.at,
);
const ids = book.ids
.filter((entry) => entry !== stale)
.map((entry) => (entry === moving ? { ...entry, at: toM } : entry));
writeState("pipe-ids", { next: book.next, ids }, projectId);
return moving.id;
}
/** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */
export function withPipeIds<T extends { chainage_m: number }>(
pipes: ReadonlyArray<T>,
projectId: string | null = active,
): Array<T & { id: string }> {
return pipes.map((pipe) => ({ ...pipe, id: pipeIdAt(pipe.chainage_m, projectId) }));
}
/** 관을 옮김 — 관 세션 id 가 새 자리로(323-5 · id 그대로) · 그 관을 골라 둔 고름이면 자리만 새로(부재키
* 그대로 · 옛 자리 이름 id 면 세션 id 로). 다른 것을 골랐으면 그대로. */
export function followMovedPipe(projectId: string | null, fromM: number, toM: number): void {
const id = movePipeId(fromM, toM, projectId);
const picked = readSelection(projectId);
if (!picked || picked.chainageM === null || !isNear(picked.chainageM, fromM)) return;
const sid = picked.structureId;
if (sid !== null && !sid.startsWith("pipe-")) return;
selectDesign(
projectId,
{ chainageM: toM, structureId: sid === null ? null : id },
{ partKey: picked.partKey },
);
}
@@ -0,0 +1,95 @@
/* =============================================================================
* A00_Common/design/design_pipeline_cross.ts
* 계산 묶음 cross 입력 모음(PLAN 346-2 붙임) — 저장 노선 상세의 측점마다 횡단 입력 · 얹힌 시설 제원을
* **측점 열쇠**로 모음. 노선이 바뀌어도 같은 열쇠 측점(격자 번호 · 관 id · 구조물 id)이 그 입력을 받음.
* 열쇠 = 저장 노선을 stations 단계와 같은 함수로 짚은 측점(`buildStations`)과 누가거리로 짝지음 —
* 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id).
*
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
* 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구
* 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계(세션 고침은 종단도 · 횡단 보기가 다시 셈하며 이미 얹음).
* ========================================================================== */
import type { CrossSection, SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { effectiveStandardCross } from "../../B06_Section/B06_Section_UI_Standard_Panel";
import type { CrossInput, SectionSpecs } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import type { PipelineStation } from "./logic/design_pipeline_types";
export interface PipelineCrossSource {
input: CrossInput;
/** 측점 열쇠 → 얹힌 시설 제원. */
specs: Map<string, SectionSpecs>;
}
const SPEC_KEYS = [
"structure",
"culvert",
"ford",
"box",
"ford_pavement",
"revetment",
"pavement",
"underdrain",
] as const;
/** 같은 측점으로 보는 누가거리 차(m) — 측점 열쇠 글 자릿수(0.01 m). */
const SAME_M = 0.01;
const finite = (value: unknown): number | null =>
typeof value === "number" && Number.isFinite(value) ? value : null;
/** 측점 하나의 넘기는 입력(cross 단계 `CrossStationInput` 꼴). */
function carried(section: CrossSection): Record<string, unknown> {
const design = (section.design ?? {}) as unknown as Record<string, unknown>;
const berm = design.berm as
{ width_m: number; interval_m: number; slope_deg: number; depth_m?: number } | undefined;
const cutSlope = finite(design.cut_slope_ratio_user);
const drop = Number(section.ford?.overflow_depth_m);
return {
...(typeof design.ground_type === "string" ? { groundType: design.ground_type } : {}),
...(typeof design.ditch_type === "string" ? { ditchType: design.ditch_type } : {}),
ditchChoice: typeof design.ditch_choice === "boolean" ? design.ditch_choice : null,
twoStageSlope: Boolean(design.two_stage_slope),
rockBoundaryOffsetM: finite(design.rock_boundary_offset_m),
cutSlopeRatio: cutSlope !== null && cutSlope > 0 ? cutSlope : null,
berm: berm
? {
widthM: berm.width_m,
intervalM: berm.interval_m,
slopeDeg: berm.slope_deg,
depthM: berm.depth_m,
}
: null,
paved: Boolean(section.pavement?.length) || Boolean(section.ford_pavement),
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? section.underdrain?.slope_pct ?? null,
surfaceDropM: drop > 0 ? drop : 0,
};
}
/** 저장 상세 + 저장 노선 측점(열쇠) → cross 입력 · 시설 제원 — 표준단면 · 상세가 없으면 null. */
export function pipelineCrossSource(
projectId: string,
detail: SectionDetailResponse | null,
savedStations: ReadonlyArray<PipelineStation>,
): PipelineCrossSource | null {
const standard = detail && effectiveStandardCross(projectId);
if (!detail || !standard) return null;
const byKey: CrossInput["byKey"] = {};
const specs = new Map<string, SectionSpecs>();
for (const section of detail.cross_sections) {
let best: PipelineStation | null = null;
for (const station of savedStations) {
const gap = Math.abs(station.chainageM - section.chainage_m);
if (gap < SAME_M && (!best || gap < Math.abs(best.chainageM - section.chainage_m)))
best = station;
}
if (!best || byKey[best.key]) continue;
byKey[best.key] = carried(section);
const own: Record<string, unknown> = {};
for (const name of SPEC_KEYS) if (section[name] !== undefined) own[name] = section[name];
specs.set(best.key, own as SectionSpecs);
}
return { input: { standard, byKey }, specs };
}
+466
View File
@@ -0,0 +1,466 @@
/* =============================================================================
* A00_Common/design/design_pipeline_page.ts
* B05 · B06 한 페이지의 계산 묶음 하나(PLAN 346-1 · 346-2 붙임) — 프로젝트마다 하나 · 페이지를 떠나면
* 버림(종단도 `dispose`). 단계 붙임 · 입력 모음 · 가게 잇기만 — 셈은 단계 파일 몫.
*
* route · ground `B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts`(sub8)
* follow · planline · profile `B05_Profile/logic/B05_Profile_Pipeline_Grade.ts`
* stations `design_pipeline_stations.ts` · footprint `B05_Profile_Footprint_Stage.ts`(sub6)
* cross · masshaul · haul `B06_Section_Cross_Stage` · `logic/B05_Profile_MassHaul_Stage` ·
* `B05_Profile_Api_HaulPlan`(sub2) · drainage 배수유역 판 `pipelineStage`(sub3) · corridor 3D 「낡음」.
* haul · corridor 는 노선 초안 동안만(저장 노선은 유토곡선 판 · 횡단 다시 셈이 맡음).
*
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
* (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점
* 입력(`design_pipeline_cross.ts`) · 관 = 가게 초안 → 저장 관 목록(좌표 · id) · 구조물 = 가게 초안 → 저장분.
* 출력 — 가게 stations · profile · footprint · generation · 노선 초안이 있으면 종단도가 새 첫 계획선 ·
* 옮긴 계획고 · 새 노선 상세(`pipelineDetail` — 지반 · 측점 · 횡단 면적 → 유토곡선)를 그림.
* 첫 계산 = 열 때 한 번(저장 노선 · 발자국 · 저장 안 한 노선 초안이 있으면 그 노선).
* [저장](sub5)이 서버 정본 — 노선을 먼저 보내고 `pendingAlignment(DRAFT_ROUTE_ID)` 를 새 노선에 보냄.
* ========================================================================== */
import { createDesignPipeline, publishToStore, type DesignPipeline } from "./logic/design_pipeline";
import { pipelineCrossSource, type PipelineCrossSource } from "./design_pipeline_cross";
import { createStationsStage } from "./logic/design_pipeline_stations";
import type {
FollowPatch,
PipelineInputs,
PipelineStation,
RouteDraft,
StageFn,
Vertex,
} from "./logic/design_pipeline_types";
import { useDesignStore, type DesignStoreValues } from "./design_store";
import {
followStructures,
type FollowingStructure,
} from "../../common_util/common_util_route_follow";
import { fetchDetailPipePoints } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createCrossStage } from "../../B06_Section/logic/B06_Section_Cross_Stage";
import {
emptyEdits,
type AlignmentBase,
type AlignmentEdits,
type ProfileAlignment,
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import {
markAlignmentSaved,
readAlignmentDraft,
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { readAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import {
analysisTerrain,
fetchRoutePlan,
type RoutePlanResponse,
} from "../../B05_Profile/B05_Profile_Api_Replan";
import { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import { fetchStructures, fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
import {
groundStageDeps,
loadDesignTerrainGrid,
} from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
import {
createGroundStage,
createRouteStage,
draftLine,
} from "../../B05_Profile/logic/B05_Profile_Pipeline_RouteGround";
import {
createFollowStage,
createPlanlineStage,
gradeRange,
profileStage,
routeRange,
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
import {
crossWithUphill,
draftSectionDetail,
type CrossInput,
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage";
import {
createMassHaulStage,
type MassHaulStageContext,
} from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage";
import type {
PlanPipe,
PlanStructure,
PlanType,
} from "../../common_util/common_util_structure_station";
import type {
RouteDraftBridge,
RouteDraftEdit,
} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Draft";
/** 노선 초안 동안의 계획고 초안 칸 — alignment-draft 의 노선 0(노선 번호는 [저장] 때 남). */
export const DRAFT_ROUTE_ID = 0;
/** 종단도(`design_profile`)가 묶음에 내주는 것. */
export interface ProfileLink {
/** 저장 노선의 종단 기준(측점 한 벌) · 지금 계획고 편집 · 저장 상세 · 노선 번호. */
pipelineSource(): {
base: AlignmentBase | null;
edits: AlignmentEdits;
detail?: SectionDetailResponse | null;
routeId?: number | null;
};
/** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림. */
adoptPipeline(base: AlignmentBase, edits: AlignmentEdits, draft: boolean): void;
/** 새 노선 횡단이 왔음 — 다시 그림(유토곡선). */
redraw?(): void;
/** 배수유역 판 — drainage 단계. */
side?: { pipelineStage: StageFn<"drainage"> } | null;
}
export interface PagePipelineOptions {
stationIntervalM?: number;
/** 노선 초안 동안 종단 · 횡단이 바뀜 — 3D 「낡음」. */
onCorridorStale?: () => void;
/** 노선 조작 뒤 follow 가 가게 관 · 구조물을 다시 씀(목록 · 알약 다시) — `far` = 새 노선에서
* `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘게 먼 구조물 수(초안을 버려 되돌렸으면 0). */
onFacilitiesFollowed?: (far: number) => void;
}
interface PagePipeline {
pipe: DesignPipeline;
bridge: RouteDraftBridge;
grade(edits: AlignmentEdits): void;
restart(): void;
earthwork(context: MassHaulStageContext | null): void;
detail(): SectionDetailResponse | null;
dispose(): void;
}
/** 가게 관 · 구조물 초안(null = 저장분). */
type Facilities = Pick<DesignStoreValues, "pipes" | "structures">;
const pages = new Map<string, PagePipeline>();
const same = (a: unknown, b: unknown): boolean => JSON.stringify(a) === JSON.stringify(b);
/** 편집기에 넘길 다리 — 그 프로젝트 묶음을 처음 부를 때 세움. */
export function routeDraftBridge(
projectId: string,
link: ProfileLink,
options: PagePipelineOptions = {},
): RouteDraftBridge {
let page = pages.get(projectId);
if (!page) pages.set(projectId, (page = createPagePipeline(projectId, link, options)));
return page.bridge;
}
/** 종단도 계획고 편집(▲▼ · 끌기 · 되돌리기) — 묶음이 없으면(편집기 안 뜸) 아무 일 없음. */
export const pipelineGrade = (projectId: string, edits: AlignmentEdits): void =>
pages.get(projectId)?.grade(edits);
/** 저장 정본이 바뀜(새로 받은 상세 · 새 노선) — 처음부터 다시. 갈아끼운 상세 · 같은 상세 다시 그림은 안 셈. */
export const restartPagePipeline = (projectId: string): void => pages.get(projectId)?.restart();
/** 유토곡선 설정(토량환산계수 · 자연방토 경사) — masshaul 단계 재료 · 표준단면이 막 왔으면 횡단 입력도. */
export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void =>
pages.get(projectId)?.earthwork(context);
/** 노선 초안 동안 종단도가 그릴 새 노선 상세 — 초안이 없으면 null(저장 상세). */
export const pipelineDetail = (projectId: string): SectionDetailResponse | null =>
pages.get(projectId)?.detail() ?? null;
/** 지금 묶음(개발 · 시험) — 없으면 null. */
export const pagePipeline = (projectId: string): DesignPipeline | null =>
pages.get(projectId)?.pipe ?? null;
export function disposePagePipeline(projectId: string): void {
pages.get(projectId)?.dispose();
pages.delete(projectId);
}
function createPagePipeline(
projectId: string,
link: ProfileLink,
options: PagePipelineOptions,
): PagePipeline {
const store = useDesignStore(projectId, null);
/** 노선 0 칸 — 종단도 편집 가게(`createProfileEditStore`)와 같은 자리(지금 프로젝트). */
const draftAlignment = useDesignStore(null, DRAFT_ROUTE_ID);
let plan: RoutePlanResponse | null = null;
let types: PlanType[] = [];
let savedStructures: PlanStructure[] = [];
let savedPipes: PlanPipe[] = [];
let earthwork: MassHaulStageContext | null = null;
/** 저장 노선 측점(열쇠) · 그것으로 모은 횡단 입력 — 저장 상세가 바뀌면 다시. */
let savedStations: PipelineStation[] = [];
let crossSource: { detail: SectionDetailResponse; source: PipelineCrossSource | null } | null =
null;
/** 저장 정본(서버가 준 상세 · 노선 번호) — 측점 갈아끼움(swap)이 만든 새 상세는 지반 표본 배열을
* 그대로 둠 → 정본 아님(묶음 출력 → 상세 → 다시 묶음 고리를 끊음). */
let canon: { routeId: unknown; samples: unknown; detail: SectionDetailResponse | null } | null =
null;
let shown: SectionDetailResponse | null = null;
let ready = false;
let started = false;
/** 앞 종단이 노선 초안으로 셈한 것인가 — 초안을 버리면 저장 기준으로 다시 그림. */
let drawnDraft = false;
let disposed = false;
/** 열 때 · 정본이 바뀐 차례(start) 번호 — 측점 · 종단을 가게에 안 냄(상세 = 저장 정본 그대로). */
let openGeneration = -1;
/** 노선 초안 전 가게 관 · 구조물 — 초안 동안 follow 가 새 측점을 가게에 씀 · 초안을 버리면 이것으로. */
let beforeDraft: Facilities | null = null;
/** 묶음이 가게에 마지막으로 쓴 관 · 구조물 · 쓰는 동안(메아리 — 다시 입력으로 안 받음). */
let written: Facilities | null = null;
let echo = false;
const savedLine = (): Vertex[] | null =>
plan ? plan.planned.map(([x, y]): Vertex => [x, y]) : null;
const savedCurves = () =>
(plan?.curves ?? []).map(({ start, apex, end, radius_m }) => ({ start, apex, end, radius_m }));
/** 저장 정본이 바뀌었나(새로 받은 상세 · 새 노선) — 바뀌었으면 잡아 둠. */
const canonChanged = (): boolean => {
const { detail = null, routeId } = link.pipelineSource();
const samples = detail?.longitudinal.samples;
if (canon && canon.routeId === routeId && canon.samples === samples) return false;
canon = { routeId, samples, detail };
return true;
};
/** 저장 노선 계획고 편집(세션 초안 → 저장분) — 노선 초안을 버리면 이것으로 돌아옴. */
const savedEdits = (): AlignmentEdits => {
const routeId = canon?.routeId;
const longitudinal = canon?.detail?.longitudinal;
return (
(typeof routeId === "number" ? readAlignmentDraft(routeId) : null) ??
(longitudinal && readAlignment(longitudinal)?.edits) ??
emptyEdits()
);
};
const crossNow = (): PipelineCrossSource | null => {
const detail = canon?.detail ?? null;
if (!detail) return null;
if (crossSource?.detail !== detail || !crossSource.source)
crossSource = { detail, source: pipelineCrossSource(projectId, detail, savedStations) };
return crossSource.source;
};
const crossStage = createCrossStage();
const haulStage = createHaulStage(projectId, () => link.pipelineSource().routeId ?? undefined);
const pipe = createDesignPipeline({
stages: {
route: createRouteStage({
minRadiusM: () => plan?.min_radius_m ?? 0,
savedLine: () => plan && { line: savedLine()!, curves: savedCurves() },
}),
follow: createFollowStage({
savedLine,
saved: () => {
const before = beforeDraft ?? facilitiesNow();
return {
pipes: before.pipes ?? savedPipes,
structures: before.structures ?? savedStructures,
alignment: savedEdits(),
};
},
}),
stations: createStationsStage({ intervalM: options.stationIntervalM, types: () => types }),
ground: createGroundStage(groundStageDeps(projectId)),
planline: createPlanlineStage({ savedBase: () => link.pipelineSource().base, savedLine }),
profile: profileStage,
// 단면 유형 기본값 = 새 지형 상단측(서버 노선 저장 규칙) — 단계 본문은 그대로 부름
cross: (ctx) =>
crossStage.stage({
...ctx,
inputs: {
...ctx.inputs,
cross: crossWithUphill(
ctx.inputs.cross as CrossInput | null,
ctx.out.ground?.stations ?? [],
),
},
}),
footprint: footprintStage,
masshaul: createMassHaulStage(() => earthwork),
haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)),
drainage: link.side?.pipelineStage,
corridor: (ctx) => {
if (ctx.inputs.route) options.onCorridorStale?.();
return null;
},
},
});
const publish = publishToStore(store);
pipe.subscribe((event) => {
const saved = event.kind === "stage" && (event.stage === "ground" || event.stage === "profile");
if (!(saved && event.generation === openGeneration)) publish(event);
});
// 사용자 노선 조작 뒤 follow 가 다시 매긴 관 · 구조물 = 가게(목록 · 알약 · 횡단 보기가 바로 새 측점) ·
// 초안을 버리면 초안 전 값 · 열 때(start 차례)는 안 씀 — 알림 밖(틱 뒤)에서 씀(쓰기 고리 막음과 안 부딪힘)
pipe.subscribe((event) => {
if (event.kind !== "stage" || event.stage !== "follow") return;
if (event.generation === openGeneration) return;
const patch = event.value as FollowPatch;
queueMicrotask(() => {
if (event.generation !== pipe.generation) return;
if (!pipe.input("route")) {
if (!beforeDraft) return;
writeFacilities(beforeDraft);
beforeDraft = null;
return options.onFacilitiesFollowed?.(0);
}
const now = facilitiesNow();
beforeDraft ??= now;
writeFacilities({
pipes: (patch.pipes as Facilities["pipes"]) ?? now.pipes,
structures: (patch.structures as Facilities["structures"]) ?? now.structures,
});
const line = pipe.output("route")?.line ?? [];
const far = Array.isArray(patch.structures)
? followStructures(
patch.structures as FollowingStructure[],
line.map(([x, y]) => ({ x, y })),
).filter((moved) => moved.far).length
: 0;
options.onFacilitiesFollowed?.(far);
});
});
pipe.subscribe((event) => {
if (event.kind !== "stage" || (event.stage !== "profile" && event.stage !== "cross")) return;
const draft = pipe.input("route") !== null;
const saved = link.pipelineSource().detail ?? null;
const ground = pipe.output("ground");
const profile = pipe.output("profile") as ProfileAlignment | null;
shown =
draft && saved && ground && profile
? draftSectionDetail(
saved,
ground,
profile,
pipe.output("cross"),
crossSource?.source?.specs ?? new Map(),
options.stationIntervalM ?? profile.policy.station_interval_m,
)
: null;
if (event.stage === "cross") return void (draft && link.redraw?.());
const base = pipe.output("planline");
const edits = (pipe.input("alignment") as AlignmentEdits | null) ?? emptyEdits();
if (draft) draftAlignment.set("alignmentDraft", edits);
else if (drawnDraft) markAlignmentSaved(DRAFT_ROUTE_ID); // 초안을 버림 — 노선 0 칸도 비움
if ((draft || drawnDraft) && base) link.adoptPipeline(base, edits, draft);
drawnDraft = draft;
});
function facilitiesNow(): Facilities {
return { pipes: store.get("pipes"), structures: store.get("structures") };
}
/** 가게에 씀 — 앞에 쓴 것과 같으면 안 씀(되돌리기가 세션을 되쓴 뒤에도 듣는 쪽은 다시 부름). */
function writeFacilities(next: Facilities): void {
echo = true;
try {
if (!written || !same(written.pipes, next.pipes)) store.set("pipes", next.pipes);
if (!written || !same(written.structures, next.structures))
store.set("structures", next.structures);
} finally {
echo = false;
}
written = next;
}
const inputsNow = (): PipelineInputs => {
const route = store.get("routeDraft");
return {
route,
alignment: (route && readAlignmentDraft(DRAFT_ROUTE_ID)) || link.pipelineSource().edits,
cross: crossNow()?.input ?? null,
pipes: store.get("pipes") ?? savedPipes,
structures: store.get("structures") ?? savedStructures,
};
};
const start = (): void => {
if (disposed || !ready || !link.pipelineSource().base) return;
canonChanged();
started = true;
void pipe.start(inputsNow());
openGeneration = pipe.generation;
};
/** 저장 노선(그린 선 · 곡선표 · 기본 반경) · 그 측점 열쇠 — [저장] 뒤 다시 받음(`restart`). */
const loadPlan = (): Promise<void> =>
fetchRoutePlan(projectId)
.then((routePlan) => {
plan = routePlan;
savedStations = buildStations(savedLine()!, savedCurves(), savedPipes, savedStructures, {
intervalM: options.stationIntervalM,
types,
stationSnap: routePlan.station_snap ?? false,
});
crossSource = null;
})
.catch(() => undefined);
void Promise.all([
fetchStructureTypes().catch(() => []),
fetchStructures(projectId).catch(() => null),
fetchDetailPipePoints(projectId).catch(() => null),
loadDesignTerrainGrid(projectId).catch(() => null), // 지형 격자(없으면 서버 길)
])
.then(([structureTypes, list, pipes]) => {
types = structureTypes as PlanType[];
savedStructures = (list?.structures ?? []) as PlanStructure[];
savedPipes = (pipes?.pipe_points ?? []).map(({ lonlat: _lonlat, ...pipe }) => pipe);
return loadPlan();
})
.then(() => {
ready = plan !== null;
start(); // 열 때 한 번 — 발자국 · 저장 안 한 노선 초안이 있으면 그 노선으로
})
.catch(() => undefined);
const unsubscribe = [
store.subscribe(
"pipes",
(pipes) => started && !echo && void pipe.update("pipe", pipes ?? savedPipes),
),
store.subscribe(
"structures",
(list) => started && !echo && void pipe.update("structure", list ?? savedStructures),
),
];
const bridge: RouteDraftBridge = {
saved: () => store.get("routeDraft"),
edited(edit: RouteDraftEdit | null) {
const stored = store.get("routeDraft");
let draft: RouteDraft | null = null;
if (edit) {
draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: analysisTerrain(projectId) };
if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored;
}
store.set("routeDraft", draft);
if (!started) return start();
const before = pipe.output("route")?.line ?? null;
const after = draft ? draftLine(draft, plan?.min_radius_m ?? 0).line : savedLine();
void pipe.update("route", draft, routeRange(before, after));
},
};
return {
pipe,
bridge,
grade(edits) {
if (!started) return start();
if (same(edits, pipe.input("alignment"))) return;
const nodes = pipe.output("planline")?.basePvi.map((node) => node.chainage_m) ?? [];
const before = pipe.input("alignment") as AlignmentEdits | null;
void pipe.update("grade", edits, gradeRange(before, edits, nodes));
},
restart() {
if (!started) return start();
if (canonChanged()) void loadPlan().then(start); // 갈아끼움 · 같은 상세 다시 그림은 안 셈
},
earthwork(context) {
earthwork = context;
const cross = crossNow()?.input ?? null;
if (started && cross) void pipe.update("cross", cross);
},
detail: () => shown,
dispose() {
disposed = true;
unsubscribe.forEach((stop) => stop());
pipe.dispose();
},
};
}
+117 -115
View File
@@ -15,18 +15,15 @@ import { routeSpansFromStructures } from "../../B05_Profile/B05_Profile_UI_Drain
import { readState, writeState } from "../b_page_state";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import type {
StructureInstance,
StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
import {
hasLegacyAlignment,
readAlignment,
toDesignProfile,
} from "../../B05_Profile/B05_Profile_UI_Profile_Data";
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
import { createProgressCircle } from "@ui/ui_template_progress";
import { showToast } from "@ui/ui_template_elements";
import { saveProfileAlignment } from "../../B05_Profile/B05_Profile_Api_Fetch";
@@ -42,13 +39,12 @@ import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import {
buildAlignment,
chainageKey,
emptyEdits,
toAlignmentBase,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import {
structureGroupMenuItems,
@@ -56,11 +52,7 @@ import {
structureIdKeeping,
} from "../../B05_Profile/B05_Profile_UI_Profile_Structures";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
createRouteMassHaulPanel,
type MassHaulSummaryValues,
type RouteMassHaulContext,
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import type { RouteMassHaulContext } from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import { renderBalanceBar } from "../../B05_Profile/B05_Profile_UI_Profile_Balance";
import { createHeightCascade } from "../../B05_Profile/B05_Profile_UI_Profile_Heights";
import { renderProfile } from "../../B05_Profile/B05_Profile_UI_Profile_Render";
@@ -77,15 +69,16 @@ import {
createElevationHold,
createProfileDrag,
} from "../../B05_Profile/B05_Profile_UI_Profile_Drag";
import {
planCurveWarning,
sampleAt,
snapToMasterStations,
} from "../../B05_Profile/B05_Profile_UI_Profile_Guard";
import { planCurveWarning, sampleAt } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Guard";
import { listedBase } from "./design_profile_base";
import * as pagePipeline from "./design_pipeline_page";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
import type { ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { frameOf } from "@util/common_util_view_window";
import type { DesignSideBoard, DesignSideFactory, DesignView } from "./design_profile_slots";
import { noticeDroppedEdits } from "./design_save";
import { createProfileOverlays } from "./design_profile_overlays";
import { bindProfileViewport, createSelectionReveal } from "./design_profile_viewport";
import "../../B06_Section/B06_Section_UI_Style.css";
// SVG 차트 색상(.b06-chart__*)의 정의처는 _Style_Cross.css다. 이걸 빼면 B05로 바로 진입했을 때
// 배경 rect가 브라우저 기본 fill(검정)로 그려진다 — B06을 먼저 방문해야 정상으로 보이던 원인.
@@ -107,8 +100,8 @@ export interface DesignProfileContext {
view: DesignView;
/** 오른쪽 판 — 없으면 판 없이 종단 열만. */
side?: DesignSideFactory;
/** 오른쪽 판을 켜고 끄는 단추 — 도구줄 오른쪽에 붙는다(판이 있을 때만). */
sideTool?: (board: DesignSideBoard) => HTMLElement;
/** 도구줄 [초기화] 바로 왼쪽에 놓는 단추([새 창]) — 다시 그려도 그 자리. */
toolLead?: HTMLElement;
/** 측점 선택 변경 — 같은 측점 재선택·빈 곳 클릭이면 null(해제)로 알린다. */
onSelectStation: (stationId: string | null) => void;
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 페이지가 넘긴다). */
@@ -161,40 +154,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
if (drag.isSubDragging()) scheduleLightSync();
else draw();
};
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
* 유토곡선을 접어도 이 값은 남아야 한다. */
const massBadge = createMassHaulBadge();
// 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
bodyWrap.append(massBadge.root);
let massHaulSummary: MassHaulSummaryValues | null = null;
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
const haulPrefetch = createHaulPlanPrefetch(
// 유토곡선 · 테이블 오버레이 · 유토 배지는 `design_profile_overlays` 몫(700줄 한계).
const { massHaul, tableOverlay } = createProfileOverlays({
projectId,
() => routeId ?? undefined,
subPanelChanged,
);
const massHaul = createRouteMassHaulPanel(
subPanelChanged,
(summary) => {
const before = massHaulSummary;
massHaulSummary = summary;
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
},
haulPrefetch,
);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(subPanelChanged);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
tableOverlay.attachScrollSync(body);
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
body.addEventListener("contextmenu", (event) => event.preventDefault());
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
routeId: () => routeId,
bodyWrap,
body,
onChange: subPanelChanged,
});
const content = document.createElement("div");
content.className = "b05-route-profile__content";
/** 횡단배수 최소 계획고 대상(배수관·BOX암거) — 관 목록이 바뀔 때 갱신한다. */
@@ -249,8 +216,12 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
let stationDisplay = { station: 0, cumulative: 0 };
let base: AlignmentBase | null = null;
/** 서버 저장분 그대로의 기준 — `base` 는 늘 여기서 측점 한 벌로 다시 셈(`listed`). */
let storedBase: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
/** 계산 묶음이 노선 초안으로 셈한 종단을 그리는 동안의 편집 한 벌 — 노선 0 칸(alignment-draft · 346-1). */
let preview: typeof store | null = null;
/** 서버 저장분 — 되돌리기 복원이 초안을 다시 읽을 때 기준으로 쓴다. */
let savedEdits: AlignmentEdits = emptyEdits();
let redrawPending = false;
@@ -261,9 +232,16 @@ export function createDesignProfile(ctx: DesignProfileContext) {
/** 한 측점에 알약이 여럿(배수관 · 메쌓기)이면 지금 고른 알약을 그대로 둔다 — 첫 알약으로
* 갈아 끼우면 누른 것과 다른 알약이 켜졌다(계획서 24-6b). */
const structureIdAtStation = (stationId: string | null): string | null =>
structureIdKeeping(stationId, selectedStructureId, irregularStations, structures, detail);
structureIdKeeping(
stationId,
selectedStructureId,
irregularStations,
structures,
detail,
structureTypes,
);
const stationIdAtStructure = (structureId: string | null): string | null =>
stationIdAtStructureOf(structureId, irregularStations, structures);
stationIdAtStructureOf(structureId, irregularStations, structures, structureTypes);
/**
* 측점 선택 — 같은 측점 재선택이면 해제한다(2026-08-04 사용자 지시).
@@ -288,7 +266,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
planCurve: planCurveWarning(alignment, detail),
minCoverViolations,
balanceBar,
tools: tools.render(),
tools: withToolLead(tools.render()),
trailing,
alignment,
legacyAlignment: !!detail && hasLegacyAlignment(detail.longitudinal),
@@ -310,11 +288,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
/** 계획고 편집 버튼(▲▼)을 누르고 있는 동안 Y 축을 고정한다(`_Profile_Drag` 몫). */
const elevationHold = createElevationHold(body, () => draw());
// 오른쪽 판 켬 · 끔 단추 — 가로 배율 조작구 옆, 두 보기 같은 자리.
const sideTool = drainagePanel && ctx.sideTool ? ctx.sideTool(drainagePanel) : null;
const trailing = document.createElement("div");
trailing.className = "b05-route-profile__trailing";
trailing.append(profileZoom.bar, ...(sideTool ? [sideTool] : []));
trailing.append(profileZoom.bar);
/** 도구 묶음은 그릴 때마다 새로 만든다 — 맨 앞([초기화] 왼쪽)에 `toolLead` 를 다시 옮겨 붙인다. */
const withToolLead = (wrap: HTMLElement): HTMLElement => {
if (ctx.toolLead) wrap.prepend(ctx.toolLead);
return wrap;
};
/* [직선화]·[쉬프트]·되돌리기·방향키 배선은 `_Panel_Tools` 몫(700줄 한계). */
const { tools, history, handleToolPick, setListening } = createPanelTools({
@@ -360,19 +341,23 @@ export function createDesignProfile(ctx: DesignProfileContext) {
history.record();
}
/** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */
const listed = (): AlignmentBase | null => listedBase(storedBase, detail, irregularStations);
/**
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
* 실제 렌더링하도록 모은다.
*/
function rebuild(): void {
if (base) alignment = buildAlignment(base, store.edits());
pagePipeline.pipelineGrade(projectId, store.edits());
// 계획고가 바뀌면 측점별 횡단 단면적도 함께 바뀐다 — 전 측점을 다시 계산해 공유
// 캐시에 얹는다. 그래야 **횡단 기준** 유토곡선과 횡단 카드가 따라 움직인다
// (2026-08-03 사용자 보고: B05에서 계획선을 끌어도 횡단 곡선이 그대로였음).
scheduleCrossPreview();
if (redrawPending) return;
redrawPending = true;
requestAnimationFrame(() => {
frameOf(root, () => {
redrawPending = false;
draw();
});
@@ -384,13 +369,15 @@ export function createDesignProfile(ctx: DesignProfileContext) {
detail: () => detail,
routeId: () => routeId,
edits: () => store.edits(),
alignment: () => (store === preview ? null : alignment),
debounceMs: () => (root.dataset.view === "cross" ? 120 : CROSS_PREVIEW_DEBOUNCE_MS),
onApplied: (updated) => {
draw();
callbacks?.onCrossDesignsUpdated?.(updated);
if (updated.length) callbacks?.onCrossDesignsUpdated?.(updated);
},
});
const scheduleCrossPreview = (): void => crossPreview.schedule();
// 노선 초안 동안은 저장 노선 횡단을 안 고침 — 새 노선 횡단은 계산 묶음 cross(346-2)
const scheduleCrossPreview = (): void => void (store !== preview && crossPreview.schedule());
/**
* 높이 캐스케이드(2026-08-05 사용자 확정): 자리가 모자라면 ① 종단이 먼저 최소까지
@@ -418,12 +405,14 @@ export function createDesignProfile(ctx: DesignProfileContext) {
function draw(): void {
renderProfile({
// 그린 뒤 생긴 낡음도 다시 셈(317-1) · 노선 초안 동안은 묶음 cross 를 기다림
onStaleDesigns: (key) => crossPreview.scheduleIfStale(key, store === preview),
body,
callbacks,
massHaul,
tableOverlay,
store,
detail: () => detail,
detail: () => pagePipeline.pipelineDetail(projectId) ?? detail,
alignment: () => alignment,
base: () => base,
stationInterval: () => stationInterval,
@@ -462,46 +451,27 @@ export function createDesignProfile(ctx: DesignProfileContext) {
});
}
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
let settledScrollLeft = 0;
let scrollSettleTimer = 0;
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
body.addEventListener("scroll", () => {
if (elevationHold.editing()) return;
const fitted = updateElevationWindow?.() ?? null;
window.clearTimeout(scrollSettleTimer);
if (!needsFullRedraw(fitted)) return;
scrollSettleTimer = window.setTimeout(() => {
if (elevationHold.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = body.scrollLeft;
draw();
}, SCROLL_SETTLE_MS);
/** 다른 영역에서 고른 자리가 화면 밖이면 가운데로(칠한 뒤 · `design_profile_viewport`). */
const reveal = createSelectionReveal(body);
const revealPick = (): void =>
reveal(
(selectedStationId ?? selectedStructureId)
? `${selectedStationId}|${selectedStructureId}`
: null,
);
// 가로 스크롤 · 휠 · 크기 바뀜은 `design_profile_viewport` 몫(700줄 한계).
const unbindViewport = bindProfileViewport({
body,
editing: () => elevationHold.editing(),
fitElevation: () => updateElevationWindow?.() ?? null,
draw: () => draw(),
lastSize: () => ({ width: lastWidth, height: lastHeight }),
scheduleLightSync,
scheduleDraw: () => drag.scheduleDraw(),
});
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
attachWheelHorizontalScroll(body);
const resizeObserver = new ResizeObserver(() => {
if (
body.clientWidth <= 0 ||
body.clientHeight <= 0 ||
(Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1)
)
return;
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
scheduleLightSync();
drag.scheduleDraw();
});
resizeObserver.observe(body);
// 접기 — 두 보기가 같은 키 하나를 쓴다(`_Profile_Drag` 몫).
bindProfileCollapse(root, panelHandle, () => requestAnimationFrame(draw));
bindProfileCollapse(root, panelHandle, () => frameOf(root, draw));
/** 배수유역도(평면)에 **구간형 구조물 띠**를 넘긴다 — 종단 레인과 같은 자료다(계획서 3-6). */
function syncDrainageSpans(): void {
@@ -524,15 +494,17 @@ export function createDesignProfile(ctx: DesignProfileContext) {
const routeChanged = nextRouteId !== routeId;
const carried = routeChanged && store.edited() ? store.edits() : null;
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
if (routeChanged || !store.dirty()) {
if (routeChanged || (store !== preview && !store.dirty())) {
routeId = nextRouteId ?? routeId;
savedEdits = stored?.edits ?? emptyEdits();
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
}
base = stored ? toAlignmentBase(stored) : null;
storedBase = stored ? toAlignmentBase(stored) : null;
if (store !== preview) base = listed(); // 노선 초안 그림은 그대로(갈아끼운 상세)
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
if (carried) store.replace(carried);
alignment = base ? buildAlignment(base, store.edits()) : null;
pagePipeline.restartPagePipeline(projectId);
// 유토곡선 balloon 위치 캐시 — 두 보기가 **같은 scope**를 써서 같은 자리를 공유한다.
// 영구저장소 값(detail.balloon_offsets)이 있으면 그것이 이긴다.
configureBalloonOffsets(
@@ -540,11 +512,10 @@ export function createDesignProfile(ctx: DesignProfileContext) {
nextDetail.balloon_offsets ?? undefined,
);
draw();
requestAnimationFrame(draw);
frameOf(root, draw);
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
//
// 비교 대상은 **편집이 반영된 계획선**이다. 저장분끼리 견주면, 세션에 미저장 편집이
// 남은 채로 새로고침했을 때 「어긋남 없음」으로 나와 재계산이 예약되지 않고, 그리기
// 쪽은 편집분 기준으로 어긋났다고 보아 유토곡선이 영영 빈 화면이 됐다
@@ -571,6 +542,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
*/
setEarthworkContext(next: RouteMassHaulContext | null) {
massHaul.setContext(next);
pagePipeline.pipelineEarthwork(projectId, next);
draw();
},
setSelectedStation(stationId: string | null) {
@@ -578,6 +550,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
// 3D·리스트·배수유역도에서 온 선택도 같은 자리 알약을 함께 켠다(2026-08-17).
selectedStructureId = structureIdAtStation(stationId);
draw();
revealPick();
},
/** 횡단배수 최소고를 편집에서 강제할지(사이드 체크박스). 경고 표시는 무관하게 유지된다. */
setEnforceMinCover(enforce: boolean) {
@@ -592,9 +565,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 입력을 바꾼 순간 위반 표시와 편집 차단 기준이 따라와야 한다(2026-08-19
* 사용자 지시 6). 계획선 자체는 서버 재계산 몫이라 건드리지 않는다. */
setGradeLimit(maxGradePct: number) {
if (!base || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (!base || !storedBase || !Number.isFinite(maxGradePct) || maxGradePct <= 0) return;
if (Math.abs(base.policy.max_grade_pct - maxGradePct) < 1e-9) return;
base = { ...base, policy: { ...base.policy, max_grade_pct: maxGradePct } };
const policy = { ...base.policy, max_grade_pct: maxGradePct };
storedBase = { ...storedBase, policy };
base = { ...base, policy };
alignment = buildAlignment(base, store.edits());
draw();
},
@@ -604,7 +579,10 @@ export function createDesignProfile(ctx: DesignProfileContext) {
},
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
setIrregularStations(stations: IrregularStation[]) {
irregularStations = snapToMasterStations(alignment, stations);
irregularStations = stations;
if (store !== preview) base = listed();
if (base) alignment = buildAlignment(base, store.edits());
pagePipeline.restartPagePipeline(projectId);
draw();
},
/** 구조물 타입 레지스트리를 받아 마크 색·약호에 쓴다(최초 1회 · 우클릭 목록은 사이드 패널 몫). */
@@ -629,6 +607,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
const keep = structureId !== null && structureIdAtStation(selectedStationId) === structureId;
selectedStationId = keep ? selectedStationId : own;
draw();
revealPick();
},
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
setStationDisplay(next: { station: number; cumulative: number }) {
@@ -640,20 +619,41 @@ export function createDesignProfile(ctx: DesignProfileContext) {
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
*/
resetStationEdit(chainageM: number, moveTo?: number) {
const key = chainageKey(chainageM);
// 옛 판 키(서버 측점 값 `264.055` · 관 값 264.06)도 같은 측점 — 0.006m 안 키(213-1 폭 · 272)
const edits = store.edits();
if (edits.station_offsets[key] === undefined && edits.curve_radii[key] === undefined) return;
store.resetStation(chainageM, moveTo);
const key = [...Object.keys(edits.station_offsets), ...Object.keys(edits.curve_radii)].find(
(entry) => Math.abs(Number(entry) - chainageM) < 0.006,
);
if (key !== undefined) store.resetStation(Number(key), moveTo);
},
isDirty: () => store.dirty(),
/** 계산 묶음(346-1) — 저장 노선의 종단 기준 · 지금 계획고 편집. */
pipelineSource: () => ({ base: listed(), edits: store.edits(), detail, routeId }),
/** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 노선 0 칸(저장 노선에
* 안 씀 · [저장]이 노선 뒤 새 노선에 보냄) · 초안을 버리면 저장 기준 · 세션 편집으로 돌아옴. */
adoptPipeline(nextBase: AlignmentBase, edits: AlignmentEdits, draft: boolean) {
const sameEdits = JSON.stringify(edits) === JSON.stringify(store.edits());
if (draft && store === preview && base === nextBase && sameEdits) return; // 이미 그림
if (draft && (store !== preview || !sameEdits))
store = preview = createProfileEditStore(pagePipeline.DRAFT_ROUTE_ID, edits, () =>
rebuild(),
);
if (!draft && store === preview)
store = createProfileEditStore(routeId, savedEdits, () => rebuild());
if (!draft) preview = null;
base = draft ? nextBase : listed();
rebuild();
},
/** [확정] 직전에 호출한다. 편집이 없으면 아무 것도 하지 않는다. */
async save(): Promise<void> {
if (!routeId || !store.dirty()) return;
if (!routeId || !store.dirty() || store === preview) return; // 노선 초안 미리보기 편집은 안 보냄
const saved = await saveProfileAlignment(projectId, routeId, store.edits());
noticeDroppedEdits(saved);
const next = saved.profile_alignment as ProfileAlignment | undefined;
if (next?.base_pvi?.length) {
base = toAlignmentBase(next);
alignment = next;
storedBase = toAlignmentBase(next);
base = listed();
alignment = buildAlignment(base!, store.edits());
if (detail) detail.longitudinal.profile_alignment = next;
}
store.markSaved();
@@ -662,6 +662,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
clear() {
detail = null;
base = null;
storedBase = null;
alignment = null;
selectedStationId = null;
balanceBar.replaceChildren();
@@ -686,10 +687,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
drainagePanel?.load(projectId);
},
dispose() {
pagePipeline.disposePagePipeline(projectId);
setListening(false);
drag.dispose();
crossPreview.dispose();
resizeObserver.disconnect();
unbindViewport();
},
};
}
+44
View File
@@ -0,0 +1,44 @@
/* =============================================================================
* A00_Common/design/design_profile_base.ts
* 종단도 계획선 기준 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — `design_profile` 에서 나눔(700줄).
* 계산 묶음 planline 단계(`design_pipeline_page`)도 같은 기준을 받음.
* ========================================================================== */
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import type { AlignmentBase } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
import {
irregularGraphStations,
maxChainageOf,
} from "../../B05_Profile/B05_Profile_UI_Profile_Layout";
import { followMovedNodes, oneStationList } from "../../common_util/common_util_structure_station";
/** 계획선 측점 줄 = 측점 한 벌(서버 일반 + 지금 구조물 측점 · 272) — 표 · ▲▼ · 그래프 · 3D · 횡단 다시 셈(342-1)이
* 같은 목록 · 사라진 구조물 측점의 꼭짓점은 서버와 같게 옮기거나 지움(`followMovedNodes`) · 늘 저장분에서 셈. */
export function listedBase(
storedBase: AlignmentBase | null,
detail: SectionDetailResponse | null,
irregularStations: IrregularStation[],
): AlignmentBase | null {
if (!storedBase || !detail) return storedBase;
const server = detail.longitudinal.stations;
const planted = irregularGraphStations(irregularStations, maxChainageOf(detail.longitudinal));
const list = oneStationList(server, planted);
const after = new Set(list.map((station) => station.chainage_m.toFixed(3)));
const before = new Set(server.map((station) => station.chainage_m.toFixed(3)));
const gone = server
.filter((station) => station.kind === "irregular" && !station.grid)
.map((station) => station.chainage_m)
.filter((chainage) => !after.has(chainage.toFixed(3)));
const born = list
.map((station) => station.chainage_m)
.filter((chainage) => !before.has(chainage.toFixed(3)));
return {
...storedBase,
basePvi: followMovedNodes(storedBase.basePvi, gone, born),
stations: list.map((station) => ({
station_id: station.station_id,
chainage_m: station.chainage_m,
})),
};
}
@@ -17,7 +17,7 @@ import {
parseWindowTransform,
type ChartWindow,
type FreeDraw,
} from "./design_profile_free";
} from "./logic/design_profile_free";
const SVG_NS = "http://www.w3.org/2000/svg";
@@ -0,0 +1,69 @@
/* =============================================================================
* design_profile_overlays.ts
* 종단도 바닥 오버레이 서브패널 — 유토곡선 · 테이블 · 유토 총괄 배지(213-3 · 700줄 한계로 뗌).
* 만들어 `bodyWrap` 에 붙이고 종단 스크롤과 잇는 데까지 — 그리기 · 높이는 종단도 몫.
* ========================================================================== */
import { attachMassHaulBadgeDrag, createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { readStateRaw, writeStateRaw } from "../b_page_state";
import { createHaulPlanPrefetch } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
import { createProfileTableOverlay } from "../../B05_Profile/B05_Profile_UI_Profile_TableOverlay";
import {
createRouteMassHaulPanel,
type MassHaulSummaryValues,
} from "../../B05_Profile/B05_Profile_UI_Profile_MassHaul";
export interface ProfileOverlaysDeps {
projectId: string;
routeId: () => number | null;
/** 종단 열(요약 줄 · 본문 · 서클을 담은 칸) — 오버레이가 그 위를 덮는다. */
bodyWrap: HTMLElement;
/** 종단 스크롤러 — 오버레이 가로 스크롤을 이것과 맞춘다. */
body: HTMLElement;
/** 서브패널이 바뀜(펼침 · 범례 · 리사이저 · 유토 배분 도착). */
onChange: () => void;
}
export function createProfileOverlays(deps: ProfileOverlaysDeps) {
const { bodyWrap, body, onChange } = deps;
/** 유토곡선 총괄값 — **그래프 좌측 상단 배지**가 보인다(2026-09-06 사용자 지시).
* 유토곡선을 접어도 이 값은 남아야 한다. */
const massBadge = createMassHaulBadge();
// 그래프 위 도구줄 · 측점 ▲ 줄(바닥 47px) 아래로 내려 겹치지 않게 한다(289).
massBadge.root.style.setProperty("--mass-haul-badge-top", "52px");
bodyWrap.append(massBadge.root);
// 그래프 안 끌어 옮김(309-2) — 자리는 화면 설정 한 칸(B05 · B06 같은 값)
attachMassHaulBadgeDrag(massBadge.root, body, {
read: () => readStateRaw("mass-haul-badge-pos"),
write: (value) => writeStateRaw("mass-haul-badge-pos", value),
});
let massHaulSummary: MassHaulSummaryValues | null = null;
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
const haulPrefetch = createHaulPlanPrefetch(
deps.projectId,
() => deps.routeId() ?? undefined,
onChange,
);
const massHaul = createRouteMassHaulPanel(
onChange,
(summary) => {
const before = massHaulSummary;
massHaulSummary = summary;
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
},
haulPrefetch,
);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블도 유토곡선과 같은 오버레이 서브패널 — 유토곡선 위에 쌓인다(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(onChange);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
tableOverlay.attachScrollSync(body);
// 유토곡선·테이블 영역에서 브라우저 기본 우클릭 메뉴를 막는다(2026-08-05 사용자 지시).
// 종단 그래프의 구조물 메뉴는 chartWrap에서 이미 preventDefault 후 자체 메뉴를 띄운다.
body.addEventListener("contextmenu", (event) => event.preventDefault());
massHaul.overlay.addEventListener("contextmenu", (event) => event.preventDefault());
return { massHaul, tableOverlay };
}
+109 -28
View File
@@ -1,6 +1,7 @@
/* =============================================================================
* A00_Common/design/design_profile_popout.ts
* 종단 하단 패널(종단도 · 표 · 유토곡선 · 배수유역도)을 **브라우저 새 창**으로 뺀다(계획서 54-35).
* 유토곡선 · 배수유역 판은 따로 또 뗀다(계획서 325-3) — 같은 몸통(`attachPopout`), 창 이름만 다름.
*
* 화면 조각(DOM)만 옮긴다 — 코드 · 자료 · 가게는 메인 창 그대로, 같은 함수가 그린다.
* 창 전역에 걸린 듣기(방향키 · 포인터 놓기 · 메뉴 닫기)는 메인 창에만 있으므로 새 창에서 난
@@ -13,30 +14,39 @@ import type { DesignProfile } from "./design_profile";
/** 메인 창 문서 · 창에 걸린 듣기가 받아야 할 새 창 이벤트. */
const FORWARD_TYPES = ["keydown", "keyup", "pointerdown", "pointerup", "pointercancel"];
/** 종단 도구줄 [배수유역도] 단추 뒤에 [새 창] 단추를 붙인다 — 두 보기가 같은 한 벌을 쓴다. */
export function attachProfilePopout(
profile: DesignProfile,
slot: HTMLElement,
sideTool: HTMLElement | null,
): void {
const { root } = profile;
/** 요소가 놓인 창(문서에 안 붙었으면 메인 창). */
const viewOfNode = (node: Node): Window => node.ownerDocument?.defaultView ?? window;
/** 조각 하나를 새 창으로 떼는 단추 — 창 이름은 조각마다 달라야 함(같으면 앞 창 body 를 덮어 조각이 사라짐). */
interface PopoutSpec {
piece: HTMLElement;
button: HTMLButtonElement;
name: string;
features: string;
docTitle: string;
/** 조각을 제자리로 돌림. */
home: () => void;
/** 뗐다 · 붙였다(조각이 옮겨 간 뒤). */
onMove?: (popped: boolean) => void;
}
/** 조각을 새 창으로 떼고 붙이는 몸통 — 새 창은 단추가 놓인 창에서 엶(종단 새 창 안에서 다시 떼기). */
function attachPopout(profile: DesignProfile, spec: PopoutSpec): void {
const { piece, button } = spec;
let popup: Window | null = null;
const button = document.createElement("button");
button.type = "button";
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
button.textContent = "새 창";
button.title = "종단 하단 패널을 브라우저 새 창으로 띄움";
button.textContent = "창 분할";
button.addEventListener("click", () => (popup && !popup.closed ? closePopup() : open()));
sideTool?.after(button);
const restore = (): void => {
if (!root.classList.contains("is-popout")) return;
root.classList.remove("is-popout");
button.textContent = "새 창";
slot.append(root);
if (!piece.classList.contains("is-popout")) return;
piece.classList.remove("is-popout");
button.textContent = "창 분할";
spec.home();
popup = null;
requestAnimationFrame(profile.redraw);
spec.onMove?.(false);
viewOfNode(piece).requestAnimationFrame(profile.redraw);
};
const closePopup = (): void => {
popup?.close();
@@ -51,28 +61,31 @@ export function attachProfilePopout(
};
function open(): void {
const next = window.open("", "aislo-profile-popout", "popup,width=1400,height=720");
const opener = viewOfNode(button);
const next = opener.open("", spec.name, spec.features);
if (!next) {
showToast("새 창이 막힘 — 주소창의 팝업 허용을 켠 뒤 다시 누름", "error");
return;
}
popup = next;
const source = opener.document;
const doc = next.document;
doc.title = "종단 — Aislo";
// 스타일 · 테마 — 메인 창 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다.
doc.title = spec.docTitle;
// 스타일 · 테마 — 단추가 놓인 창의 시트 · html 속성을 그대로 옮겨 같은 색 · 글꼴로 그린다.
doc.head.replaceChildren(
...Array.from(document.querySelectorAll('link[rel="stylesheet"], style'), (node) =>
...Array.from(source.querySelectorAll('link[rel="stylesheet"], style'), (node) =>
doc.importNode(node, true),
),
);
Array.from(document.documentElement.attributes).forEach((attr) =>
Array.from(source.documentElement.attributes).forEach((attr) =>
doc.documentElement.setAttribute(attr.name, attr.value),
);
doc.body.className = document.body.className;
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${getComputedStyle(document.body).backgroundColor}`;
root.classList.add("is-popout");
button.textContent = "제자리로";
doc.body.replaceChildren(root);
doc.body.className = source.body.className;
doc.body.style.cssText = `margin:0;height:100dvh;overflow:hidden;background:${opener.getComputedStyle(source.body).backgroundColor}`;
piece.classList.add("is-popout");
button.textContent = "창 결합";
doc.body.replaceChildren(piece);
spec.onMove?.(true);
// 새 창 키 · 포인터를 메인 문서로 — 대상은 새 창 요소 그대로(입력칸 판정 · 메뉴 안 판정).
FORWARD_TYPES.forEach((type) =>
@@ -95,7 +108,75 @@ export function attachProfilePopout(
new (next as Window & typeof globalThis).ResizeObserver(() => {
next.cancelAnimationFrame(pending);
pending = next.requestAnimationFrame(profile.redraw);
}).observe(root);
}).observe(piece);
next.addEventListener("pagehide", restore);
}
}
/** 종단 도구줄 [초기화] 왼쪽 단추(`toolLead`)를 [창 분할] 단추로 꾸민다 — 두 보기가 같은 한 벌을 쓴다. */
export function attachProfilePopout(
profile: DesignProfile,
slot: HTMLElement,
button: HTMLButtonElement,
): void {
button.className = "ui-btn ui-btn--ghost b05-route-profile__popout";
button.title = "종단 하단 패널을 별도 창으로 분할 · 다시 누르면 결합";
attachPopout(profile, {
piece: profile.root,
button,
name: "aislo-profile-popout",
features: "popup,width=1400,height=720",
docTitle: "종단 — Aislo",
home: () => slot.append(profile.root),
});
}
/** 유토곡선 · 배수유역 판에 [창 분할] 단추를 단다 — 유토 판 좌상단 · 배수 머리줄 맨 앞(계획서 325-3). */
export function attachSubPanelPopouts(profile: DesignProfile): void {
const subButton = (title: string): HTMLButtonElement => {
const button = document.createElement("button");
button.className = "ui-btn ui-btn--ghost b05-subpanel__popout";
button.title = title;
return button;
};
const massOverlay = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-overlay");
const massHandle = profile.root.querySelector<HTMLElement>(".b05-profile__masshaul-handle");
if (massOverlay && massHandle) {
const button = subButton("유토곡선 창 분할");
massOverlay.append(button);
attachPopout(profile, {
piece: massOverlay,
button,
name: "aislo-masshaul-popout",
features: "popup,width=1400,height=420",
docTitle: "유토곡선 — Aislo",
// 제자리 = 유토 손잡이 바로 앞(종단 본문 바닥 겹침 판).
home: () => massHandle.before(massOverlay),
onMove: (popped) => {
// 뗀 동안 메인 손잡이는 숨김 · 같이 줄이기 인라인 높이는 비움(새 창은 창 높이를 다 씀).
massHandle.hidden = popped;
massOverlay.style.height = "";
},
});
}
const drainage = profile.side;
const header = drainage?.root.querySelector<HTMLElement>(".b05-drainage__header");
if (drainage && header) {
const button = subButton("배수유역 창 분할");
header.prepend(button);
const content = drainage.root.parentElement;
attachPopout(profile, {
piece: drainage.root,
button,
name: "aislo-drainage-popout",
features: "popup,width=1000,height=720",
docTitle: "배수유역 — Aislo",
// 제자리 = 종단 content 끝(종단 본문 오른쪽).
home: () => content?.append(drainage.root),
// 지도 크기 관찰을 놓인 창 것으로(325-1).
onMove: () => drainage.rehome(),
});
}
}
@@ -0,0 +1,100 @@
/* =============================================================================
* design_profile_viewport.ts
* 종단 스크롤러의 가로 스크롤 · 휠 · 크기 바뀜 듣개(213-3 · 700줄 한계로 뗌).
* 다시 그리기 · 높이 동기화는 종단도가 넘긴 함수를 부른다.
* ========================================================================== */
import { attachWheelHorizontalScroll } from "../../B05_Profile/B05_Profile_UI_Profile_Wheel";
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
import { viewOf } from "@util/common_util_view_window";
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
export interface ProfileViewportDeps {
body: HTMLElement;
/** ▲▼ 계획고 편집으로 Y 축을 잡고 있는 중. */
editing: () => boolean;
/** 세로 창 갱신기 — 그릴 때마다 렌더러가 새로 준다(없으면 null). */
fitElevation: () => ElevationWindowResult | null;
draw: () => void;
/** 마지막으로 그린 본문 크기 — 같으면 크기 바뀜을 무시한다. */
lastSize: () => { width: number; height: number };
scheduleLightSync: () => void;
scheduleDraw: () => void;
}
/** 듣개를 붙이고 떼는 함수를 돌려준다. */
export function bindProfileViewport(deps: ProfileViewportDeps): () => void {
const { body } = deps;
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
let settledScrollLeft = 0;
let scrollSettleTimer = 0;
/** 시계를 건 창 — 새 창으로 빼면 그 창 시계를 쓴다(가려진 메인 창 시계는 늦게 돎 · 230-1). */
let timerView: Window = window;
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
body.addEventListener("scroll", () => {
if (deps.editing()) return;
const fitted = deps.fitElevation();
timerView.clearTimeout(scrollSettleTimer);
if (!needsFullRedraw(fitted)) return;
timerView = viewOf(body);
scrollSettleTimer = timerView.setTimeout(() => {
if (deps.editing() || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = body.scrollLeft;
deps.draw();
}, SCROLL_SETTLE_MS);
});
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
attachWheelHorizontalScroll(body);
const resizeObserver = new ResizeObserver(() => {
const last = deps.lastSize();
if (
body.clientWidth <= 0 ||
body.clientHeight <= 0 ||
(Math.abs(body.clientWidth - last.width) < 1 && Math.abs(body.clientHeight - last.height) < 1)
)
return;
// 끌리는 동안엔 프레임당 경량 높이 동기화만 — 무거운 전체 재구성은 손을 뗀 뒤 한 번.
// (150ms 디바운스만 있던 예전 방식은 중간 프레임이 없어 툭툭 끊겼다. 2026-08-05 사용자 보고)
deps.scheduleLightSync();
deps.scheduleDraw();
});
resizeObserver.observe(body);
return () => resizeObserver.disconnect();
}
/** 고른 것이 보이는 칸 가장자리에서 이만큼(폭 비율) 안쪽이면 그대로 둔다. */
const REVEAL_EDGE = 0.1;
/**
* 고른 측점선(없으면 고른 구조물 알약)이 종단도 보이는 칸 밖이면 가운데로 민다 — 다른 영역(횡단 카드 ·
* 3D · 배수유역도 · 좌측 목록)에서 고른 자리도 종단에서 바로 보이게. 칠한 결과(강조된 선 · 알약)를
* 보고 옮긴다 · 같은 고름을 다시 칠할 때는 안 밂(사용자가 옮겨 본 자리를 지킴) · 그래프 안에서
* 누른 것은 이미 보이는 자리라 안 움직임. `key` = 고름 열쇠(풀면 null).
*/
export function createSelectionReveal(body: HTMLElement): (key: string | null) => void {
let shown: string | null = null;
return (key) => {
if (key === null || key === shown) {
shown = key;
return;
}
const mark =
body.querySelector(".b06-chart__station--selected") ??
body.querySelector(".b05-structure__mark.is-selected");
if (!mark || body.clientWidth <= 0) return; // 아직 안 그려짐 — 다음 칠하기에서
shown = key;
const box = mark.getBoundingClientRect();
const view = body.getBoundingClientRect();
const at = box.left + box.width / 2 - view.left;
if (at >= view.width * REVEAL_EDGE && at <= view.width * (1 - REVEAL_EDGE)) return;
// 바로 옮김 — 고른 뒤 잇따르는 다시 그리기가 부드러운 이동을 끊음(다시 그리기는 지금 자리를 지킴)
body.scrollLeft += at - view.width / 2;
};
}
+1 -1
View File
@@ -2,7 +2,7 @@
* design_reset.ts
* [초기화] 한 줄기 — 두 보기의 아래 단추가 **같은 함수**를 부른다(계획서 24-2b).
*
* 사용자 편집 전부 폐기, 계획노선 CSV 기본값으로 B05·B06 재계산 후 재진입. 네이티브 confirm 은
* 사용자 편집 전부 폐기, 초기값(자동설계 직후 · 추천 [적용] 직후)을 작업본에 덮어쓴 뒤 재진입(재계산 아님). 네이티브 confirm 은
* 공용 헤드 브라우저가 자동 취소해 버튼이 죽은 듯 보였다(2026-08-19 진단) — 화면 안 모달로
* 확인받는다. (`B05_Profile_UI_Page_Actions.resetDesignAction` 에서 옮겨 왔다 — 동작 그대로.)
* ========================================================================== */
+91 -17
View File
@@ -2,7 +2,13 @@
* A00_Common/design/design_save.ts
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
*
* 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
* 노선 → 관 → 구조물 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
*
* 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan`
* (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고
* 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 →
* 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움.
* 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄).
*
* 차례는 여기 한 곳에만 둔다. 보기마다 다른 것은 **화면이 쥔 값으로 한 번 더 맞추는 일**
* (B05 배수유역도 목록 · 구조물 목록 새로 받기 · 종단 패널 다시 그리기)과 횡단 저장 본문뿐이라,
@@ -26,9 +32,17 @@ import {
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import { clearState, stateKey } from "../b_page_state";
import {
carryAfterRouteSave,
pendingRouteSave,
rebuildAfterRouteSave,
routeSaveSummary,
saveRouteDraft,
} from "../../B05_Profile/B05_Profile_UI_RouteEdit_Apply";
/** 저장 차례 — 이 순서 그대로 돈다. */
/** 저장 차례 — 이 순서 그대로 돈다(`route` 는 노선 초안이 있을 때만). */
export const DESIGN_SAVE_ORDER = [
"route",
"pipes",
"structures",
"alignment",
@@ -48,7 +62,7 @@ export interface DesignSaveContext {
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤).
*/
steps?: Partial<Record<Exclude<DesignSaveStep, "cross">, () => Promise<void>>>;
steps?: Partial<Record<Exclude<DesignSaveStep, "cross" | "route">, () => Promise<void>>>;
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
cross: (confirm: boolean) => Promise<void>;
/** 모두 나간 뒤 — 캐시 비우기 · 화면 다시 읽기 · 알림 · 다음 단계. */
@@ -57,6 +71,13 @@ export interface DesignSaveContext {
failed: (error: unknown, confirm: boolean) => void;
}
const saving = new Set<string>();
/** 초안을 내보내는 중인가(323-9) — 그동안 바뀜 신호로 낡은 상세 · 빈 초안을 그리지 않음(뒤처리가 한 번 다시). */
export function savingDesign(projectId: string | null): boolean {
return projectId !== null && saving.has(projectId);
}
/** 실패해도 저장을 멈추지 않는 창구 — 알리고 넘어간다(초안은 세션에 남아 다시 나갈 수 있다). */
function reportOnly(label: string): (error: unknown) => void {
return (error) => {
@@ -65,12 +86,38 @@ function reportOnly(label: string): (error: unknown) => void {
};
}
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. */
async function flushAlignmentDraft(projectId: string, routeId: number): Promise<void> {
const draft = pendingAlignment(routeId);
/** 서버가 버린 계획고 편집 자리(측점 · 관 · 구조물이 없는 자리 · 213-2)를 알린다. */
export function noticeDroppedEdits(saved: { dropped_edit_chainages?: number[] }): void {
const dropped = saved.dropped_edit_chainages ?? [];
if (!dropped.length) return;
const where = dropped
.slice(0, 5)
.map((value) => `${value.toFixed(2)}m`)
.join(" · ");
showToast(
`계획고 편집 ${dropped.length}곳을 지웠습니다 — 측점 · 관 · 구조물이 없는 자리: ${where}` +
(dropped.length > 5 ? ` 외 ${dropped.length - 5}곳` : ""),
"info",
);
}
/** 노선 초안 동안의 계획고 초안 칸 — `design_pipeline_page` 의 `DRAFT_ROUTE_ID` 와 같은 값(sub7). */
const DRAFT_ROUTE_ID = 0;
/** 계획고 초안(가게 하나)을 종단 정본에 쓴다. 없으면 아무 일도 하지 않는다. 노선이 바뀌었으면
* 노선 초안 칸(`DRAFT_ROUTE_ID`) · 없으면 옛 노선 칸(`from`)의 초안을 새 노선(`routeId`)에 · 두 칸 다
* 비움 — 측점이 없는 자리는 서버가 버리고 알림(213-2). */
async function flushAlignmentDraft(
projectId: string,
routeId: number,
from: number = routeId,
): Promise<void> {
const moved = from !== routeId;
const draft = (moved ? pendingAlignment(DRAFT_ROUTE_ID) : null) ?? pendingAlignment(from);
if (!draft) return;
await saveProfileAlignment(projectId, routeId, draft);
markAlignmentSaved(routeId);
noticeDroppedEdits(await saveProfileAlignment(projectId, routeId, draft));
markAlignmentSaved(from);
if (moved) markAlignmentSaved(DRAFT_ROUTE_ID);
}
/** 실패해도 다음 차례로 넘어가는 창구와 그 이름(보기가 준 일에도 같이 씌운다). */
@@ -82,9 +129,10 @@ const TOLERANT: Partial<Record<DesignSaveStep, string>> = {
/** 차례마다 기본 창구. 암거 옵션 · 상단측은 조용히 넘어간다(다음 [저장]에서 다시 나감). */
function defaultStep(
step: Exclude<DesignSaveStep, "cross">,
step: Exclude<DesignSaveStep, "cross" | "route">,
projectId: string,
routeId: number,
fromRouteId: number = routeId,
): () => Promise<void> {
switch (step) {
case "pipes":
@@ -92,10 +140,10 @@ function defaultStep(
case "structures":
return () => flushPendingStructures(projectId);
case "alignment":
return () => flushAlignmentDraft(projectId, routeId);
return () => flushAlignmentDraft(projectId, routeId, fromRouteId);
case "culvertOptions":
return () =>
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, routeId)).catch(
flushCulvertOptions(projectId, stateKey("culvertopt", projectId, fromRouteId)).catch(
() => undefined,
);
case "uphill":
@@ -112,18 +160,34 @@ export async function saveDesign(
options: { confirm?: boolean } = {},
): Promise<boolean> {
const confirm = options.confirm ?? false;
const routeId = ctx.routeId();
if (routeId === null) return false;
const fromRouteId = ctx.routeId();
if (fromRouteId === null) return false;
let routeId = fromRouteId;
/** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */
let routeSaved: Awaited<ReturnType<typeof saveRouteDraft>> | null = null;
showLoadingOverlay();
saving.add(ctx.projectId);
try {
await ctx.prepare?.();
for (const step of DESIGN_SAVE_ORDER) {
if (step === "cross") {
await ctx.cross(confirm);
if (step === "route") {
const body = await pendingRouteSave(ctx.projectId); // 안 바뀌었으면 null — 안 보냄
if (!body) continue;
routeSaved = await saveRouteDraft(ctx.projectId, body); // 실패 = throw(서버가 되살림)
if (routeSaved.route_id != null) routeId = routeSaved.route_id;
continue;
}
const own = ctx.steps?.[step];
const base = defaultStep(step, ctx.projectId, routeId);
if (step === "cross") {
if (!routeSaved) await ctx.cross(confirm);
else {
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
showToast(routeSaveSummary(routeSaved, outside), "info");
}
continue;
}
// 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로.
const own = routeSaved ? undefined : ctx.steps?.[step];
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId);
const run =
step === "pipes"
? async () => {
@@ -135,12 +199,22 @@ export async function saveDesign(
await (label ? run().catch(reportOnly(label)) : run());
}
clearState("structure-type-cache", ctx.projectId); // 종류별 캐시는 저장 뒤 비움(54-32)
saving.delete(ctx.projectId); // 다 나감 — 뒤처리는 새 상세로 그림
if (routeSaved) {
// 새 노선 — [확정]은 새 노선을 본 뒤 다시(단계 닫기는 새 횡단 본문이 있어야 함) · 페이지 다시 세움.
if (confirm)
showToast("노선을 저장했습니다 — 새 노선을 확인한 뒤 다시 [확정]하세요.", "info");
else await ctx.done?.(false);
await rebuildAfterRouteSave(ctx.projectId);
return true;
}
await ctx.done?.(confirm);
return true;
} catch (error) {
ctx.failed(error, confirm);
return false;
} finally {
saving.delete(ctx.projectId);
hideLoadingOverlay();
}
}
+1 -1
View File
@@ -13,7 +13,7 @@
import { readState, writeState } from "../b_page_state";
/** 관 · 구조물 찾는 거리(m) — 관은 계획선에 스냅돼 측점과 살짝 어긋난다. 이 값이 유일하다. */
/** 관 · 구조물 찾는 거리(m) — 기준 측점은 정수 m(234-1) · 옛 소수 자료도 같은 자리로 봄. 이 값이 유일하다. */
export const PIPE_MATCH_M = 0.51;
export interface DesignSelection {
+9 -6
View File
@@ -21,11 +21,12 @@ import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch";
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import { drainageSide, drainageSideTool } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { drainageSide } from "../../B05_Profile/B05_Profile_UI_Profile_SideTool";
import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout } from "./design_profile_popout";
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
import { saveDesign, type DesignSaveStep } from "./design_save";
import { markDesignClean } from "./design_leave";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
@@ -150,12 +151,12 @@ function toAll<K extends keyof RouteProfilePanelCallbacks>(
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
export function sharedProfile(shared: DesignPageShared): DesignProfile {
if (shared.profile) return shared.profile;
let sideTool: HTMLElement | null = null;
const popoutButton = document.createElement("button");
const profile = createDesignProfile({
projectId: shared.projectId,
view: shared.view(),
side: drainageSide,
sideTool: (board) => (sideTool = drainageSideTool(board)),
toolLead: popoutButton,
onSelectStation: (stationId) => bound(shared).profile?.onSelectStation(stationId),
onResetAll: () => bound(shared).profile?.onResetAll?.(),
callbacks: {
@@ -178,7 +179,8 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
},
});
shared.profileSlot.append(profile.root);
attachProfilePopout(profile, shared.profileSlot, sideTool);
attachProfilePopout(profile, shared.profileSlot, popoutButton);
attachSubPanelPopouts(profile);
shared.profile = profile;
return profile;
}
@@ -298,7 +300,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
...(side
? {
pipes: async () => {
await side.savePipes();
await side.savePipes(confirm);
},
}
: {}),
@@ -312,6 +314,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
else await profileSave?.cross?.(isConfirm);
},
done: async (isConfirm) => {
markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
if (isConfirm) {
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
+86 -3
View File
@@ -13,14 +13,21 @@
* `alignmentDraft` 는 등록부 `alignment-draft`(노선 칸)다 — 옛 키(`b05-profile-alignment-draft:{노선}`)
* 값은 처음 읽을 때 옮겨 받는다. B05 되돌리기(`_Profile_History`)도 이 칸을 사진 찍는다(24-2c).
* 부르는 쪽이 프로젝트를 모르면(`null`) 지금 연 프로젝트 칸을 쓴다.
*
* 계산 묶음 칸(344-4 · 모양 = `design_pipeline_types.ts`) — `routeDraft` 는 초안(세션 즉시) ·
* `stations` · `profile` · `footprint` 는 **메모리 우선**(결과가 커서 끌기마다 세션에 쓰지 않음 ·
* 멈춘 뒤 `PERSIST_DELAY_MS` 한 번 · 키가 지워지면 메모리도 버림) · `generation` 은 메모리만.
* ========================================================================== */
import { readState, writeState } from "../b_page_state";
import { onStateRemoved, readState, stateKey, writeState } from "../b_page_state";
import type { StateName } from "../b_page_state";
import { CURRENT_PROJECT_ID_KEY } from "../../config/config_frontend";
import type { StructureInstance } from "../../B05_Profile/B05_Profile_Api_Structures";
import type { DetailPipeInput } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { AlignmentEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
import type { AlignmentEdits } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import type { ProfileAlignment } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import type { PipelineStation, PlanFootprint, RouteDraft } from "./logic/design_pipeline_types";
/** 한 페이지의 두 보기(계획서 24-1). */
export type DesignView = "profile" | "cross";
@@ -43,6 +50,13 @@ export interface DesignStoreValues {
sectionDetail: SectionDetailResponse | null;
/** 고른 측점 · 구조물. */
selection: DesignSelection | null;
/** 노선 초안(노드 · 곡선 · 반경 · 스냅 · 지형 · rev) — 노선 편집기가 놓을 때 씀. */
routeDraft: RouteDraft | null;
/** 계산 묶음 결과 — 측점(지반 채움) · 종단 · 평면 발자국 · 지금 차례 번호. */
stations: PipelineStation[] | null;
profile: ProfileAlignment | null;
footprint: PlanFootprint | null;
generation: number | null;
}
export type DesignStoreKey = keyof DesignStoreValues;
@@ -100,6 +114,7 @@ function slotOf<K extends DesignStoreKey>(
write: (value) => writeState("alignment-draft", value, project, routeId),
};
}
if (key in MEMORY_SLOTS) return memorySlot<V>(key as MemoryKey, projectId);
// 등록부 이름 — 가게 이름과 캐시 이름이 다른 것만 적는다.
const name = (
{
@@ -107,8 +122,9 @@ function slotOf<K extends DesignStoreKey>(
pipes: "pipes",
sectionDetail: "section-detail",
selection: "selection",
routeDraft: "route-draft",
} as const
)[key as Exclude<DesignStoreKey, "alignmentDraft">];
)[key as Exclude<DesignStoreKey, "alignmentDraft" | MemoryKey>];
const routeScoped = name === "section-detail";
if (!projectId || (routeScoped && routeId === null)) {
return { id: null, read: () => null, write: () => undefined };
@@ -121,6 +137,73 @@ function slotOf<K extends DesignStoreKey>(
};
}
/** 메모리 우선 칸 → 등록부 이름(null = 메모리만). */
const MEMORY_SLOTS = {
stations: "design-stations",
profile: "design-profile",
footprint: "design-footprint",
generation: null,
} as const satisfies Partial<Record<DesignStoreKey, StateName | null>>;
type MemoryKey = keyof typeof MEMORY_SLOTS;
/** 멈춘 뒤 세션에 한 번 쓰기까지(ms). */
export const PERSIST_DELAY_MS = 1000;
const memory = new Map<string, unknown>();
const persistTimers = new Map<
string,
{ timer: ReturnType<typeof setTimeout>; write: () => void }
>();
function cancelPersist(storageKey: string): void {
const pending = persistTimers.get(storageKey);
if (pending) clearTimeout(pending.timer);
persistTimers.delete(storageKey);
}
onStateRemoved((storageKey) => {
memory.delete(storageKey);
cancelPersist(storageKey);
});
/** 미뤄 둔 세션 쓰기를 지금 — [저장] 앞 · 시험. */
export function flushDesignStore(): void {
const writes = [...persistTimers.values()];
persistTimers.forEach((pending) => clearTimeout(pending.timer));
persistTimers.clear();
writes.forEach((pending) => pending.write());
}
function memorySlot<T>(key: MemoryKey, projectId: string | null): Slot<T> {
const name = MEMORY_SLOTS[key];
const storageKey = name ? stateKey(name, projectId) : projectId && `memory:${key}:${projectId}`;
if (!projectId || !storageKey) return { id: null, read: () => null, write: () => undefined };
return {
id: storageKey,
read: () => {
if (memory.has(storageKey)) return memory.get(storageKey) as T;
const stored = name ? readState<T>(name, projectId) : null;
if (stored !== null) memory.set(storageKey, stored);
return stored;
},
write: (value) => {
if (value === null) {
memory.delete(storageKey);
cancelPersist(storageKey);
if (name) writeState(name, null, projectId);
return;
}
memory.set(storageKey, value);
if (!name) return;
cancelPersist(storageKey);
const write = (): void => {
persistTimers.delete(storageKey);
writeState(name, memory.get(storageKey) ?? null, projectId);
};
persistTimers.set(storageKey, { timer: setTimeout(write, PERSIST_DELAY_MS), write });
},
};
}
const listeners = new Map<string, Set<(value: unknown) => void>>();
/**
@@ -16,6 +16,8 @@
* ========================================================================== */
import { fordSection } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility_Fields";
import type { SpecForm } from "./design_structure_form";
import type { SpecContainer, SpecItem } from "./logic/design_structure_spec";
/** 월류 폭(m) → 필요 월류 높이(m) · 0.01 m 올림. 유량 · 폭이 없으면 null. */
export function fordHeightM(designFlowM3s: number | null, widthM: number): number | null {
@@ -23,6 +25,54 @@ export function fordHeightM(designFlowM3s: number | null, widthM: number): numbe
return section ? Math.ceil(section.depthM * 100 - 1e-9) / 100 : null;
}
/**
* 월류 폭 ↔ 높이 연동 — 세월교 · 물넘이 포장 한 벌(52-4 · 65-4 · 65-5 · 287-3).
* · 폭을 바꾸면 높이 = 필요 수심 · 높이를 비우거나 그 아래로 넣으면 필요 수심으로.
* · 새 시설(`arm(true)`) 빈 높이 = 유량이 오면 필요 수심(사용자가 넣은 것처럼 — [추가] · [수정]이 읽음).
* · 상자 끝 안내 — 「필요 수심 n 이상」 · 「설계유량 없음 — 수기 입력」(유량이 없으면 손 입력).
* `box` = 폭 · 높이 칸이 든 상자 id(세월교 `ford` · 물넘이 `slope`) · `flow` = 담당 유역 설계유량.
*/
export function overflowSync(
form: () => Pick<SpecForm, "state" | "setValue" | "refresh">,
box: string,
flow: () => number | null,
emit: () => void,
) {
let pending = false;
const values = () => form().state()[box]?.[0] ?? {};
const width = () => Number(values().width ?? "");
const need = (): number | null => fordHeightM(flow(), width());
return {
arm: (fresh: boolean): void => {
pending = fresh;
},
onChange: (c: SpecContainer, item: SpecItem): void => {
if (c.id === box && item.id === "height") pending = false;
const want = c.id === box ? need() : null;
const height = values().height ?? "";
const low = height === "" || !(Number(height) >= (want ?? 0));
if (want !== null && (item.id === "width" || (item.id === "height" && low)))
form().setValue(box, "height", want.toFixed(2));
emit();
},
/** 유량이 왔거나 시설을 올림 — 새 시설 빈 높이를 채우고 안내를 다시 그림. */
fill: (): void => {
const want = pending && !values().height ? need() : null;
if (want === null) return form().refresh();
form().setValue(box, "height", want.toFixed(2));
pending = false;
},
/** 상자 끝 안내 글(폼 `fields` 훅). */
fields: (c: SpecContainer): HTMLElement[] => {
if (c.id !== box) return [];
const hint = document.createElement("p");
hint.className = "b05-structure__flow-hint";
hint.textContent = fordHeightHint(flow(), width());
return [hint];
},
};
}
/** 높이 빈 칸 안내. */
export function fordHeightHint(designFlowM3s: number | null, widthM: number): string {
const need = fordHeightM(designFlowM3s, widthM);
+10 -2
View File
@@ -22,6 +22,8 @@ import {
linkOf,
readState,
solveSplit,
specContainerHidden,
specItemHidden,
TEXT_MAX,
writeOptions,
type SpecContainer,
@@ -30,7 +32,7 @@ import {
type SpecState,
type SplitRole,
type StructureSpec,
} from "./design_structure_spec";
} from "./logic/design_structure_spec";
import {
group,
grid,
@@ -82,6 +84,8 @@ export interface SpecForm {
groupNames?: Record<string, string>,
/** 정의 기본값(저장 키 → 값) — 필수 상자 빈 고르기 칸이 받는 값(54-43). */
defaults?: Record<string, string | number>,
/** 숨긴 옵션 키(정의 「표시」 · 322-2) — 그 키만 쓰는 칸 · 상자는 안 그림(값은 그대로). */
hidden?: ReadonlySet<string>,
) => void;
read: () => Record<string, string | number>;
state: () => SpecState;
@@ -104,6 +108,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
let names: SpecNames = {};
let groupNames: Record<string, string> = {};
let defaults: Record<string, string | number> = {};
let hidden: ReadonlySet<string> = new Set();
const recent = new Map<Instance, SplitRole[]>();
const slots = new Map<string, HTMLElement>();
const rows: Array<{ key: string; set: (item: SummaryItem | null) => void }> = [];
@@ -235,6 +240,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
}
const fields = c.items
.filter((item) => item.kind === "auto" || !item.by || branchOf(item, view))
.filter((item) => !specItemHidden(item, hidden))
.map((item) => {
const input = control(c, item, view, tags.get(item.id));
const wrap = labeled(itemLabel(c, item, names), input);
@@ -316,6 +322,7 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
const fitted: SpecContainer[] = [];
for (const c of spec?.containers ?? []) {
const list = (state[c.id] ??= c.required ? [{}] : []);
if (specContainerHidden(c, hidden)) continue; // 숨긴 상자 — 값은 그대로(322-2)
if (fit(c, list)) fitted.push(c);
shown(c, list).forEach((values, index) => boxes.push(instanceBox(c, values, index)));
if (c.required || (c.max && list.length >= c.max) || hooks.count?.(c) !== undefined) continue;
@@ -341,9 +348,10 @@ export function createSpecForm(links: SpecLinks | null, hooks: SpecFormHooks = {
return {
root,
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}) {
write(next, options, nextNames = {}, nextGroups = {}, nextDefaults = {}, nextHidden) {
spec = next;
defaults = nextDefaults;
hidden = nextHidden ?? new Set();
names = nextNames;
groupNames = nextGroups;
state = readState(next, options);
@@ -11,7 +11,8 @@
import type { CrossSection } from "../../B06_Section/B06_Section_Api_Fetch";
import { computeStoredLayouts } from "../../B06_Section/B06_Section_Structure_Layouts";
export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Basin";
export { DEFAULT_BASIN_SHAPE } from "../../B06_Section/logic/B06_Section_UI_Cross_Culvert_Basin";
export { pipeLengthText } from "../../B06_Section/logic/B06_Section_UI_Cross_Culvert_Const"; // 「8 m (7.6)」(225-1)
import { PIPE_MATCH_M } from "./design_selection";
/** 횡단 측점 목록을 대는 곳 — 횡단 보기가 자료를 받으면 걸어 둠(종단만 연 동안은 없음). */
@@ -0,0 +1,77 @@
/* =============================================================================
* A00_Common/design/design_structure_status.ts
* 구조물 현황 한 벌(322-3) — 저장분 둘(관 `pipe_points.json` · 구조물 `structures.json`) + 초안 둘(세션)
* → 한 목록(초안이 있으면 초안 · 없으면 저장분 · 종류마다 따로) · 측점 계획 한 벌(`planStations`) →
* 종단 측점 목록(B05 종단 · 3D 막대 · B06 종단 띠가 같은 꼴).
*
* 구조물 id = 구조물은 structure_id · 관은 세션 id(`design_pipe_ids` · 323-5 · 파일 모양 그대로 · 저장 안 함).
* ========================================================================== */
import type { IrregularStation } from "../../B05_Profile/B05_Profile_UI_IrregularStations";
import type { PlannedStation } from "../../common_util/common_util_structure_station";
import { useDesignStore, type DesignStoreValues } from "./design_store";
/** 현황 — 종류마다 초안이 앞섬(초안 null = 만진 적 없음 · 빈 목록 = 다 지움). */
export function structureStatus<P, S>(
saved: readonly [ReadonlyArray<P>, ReadonlyArray<S>],
drafts: readonly [ReadonlyArray<P> | null, ReadonlyArray<S> | null],
): { pipes: P[]; structures: S[] } {
return { pipes: [...(drafts[0] ?? saved[0])], structures: [...(drafts[1] ?? saved[1])] };
}
/**
* 측점 계획 → 종단 측점(측점번호 · 잔여거리 = 간격 기준) — 기준 측점 id = 첫 주인 id(관 = `pipe-…` ·
* 구조물 = structure_id) · 시작 · 끝 = `{id}:{역할}`(옮겨도 계획고 편집이 따라감 · 272) · 관 기준 측점 =
* 「배관」 꼴(관 끌기 · 지우기 길).
*/
export function plannedIrregular(
planned: ReadonlyArray<PlannedStation>,
intervalM: number,
): IrregularStation[] {
const interval = intervalM > 0 ? intervalM : 20;
return planned.map((station) => {
const owner = station.owners[0];
const pipe = owner.startsWith("pipe-") && station.role === "anchor";
const number = Math.floor(station.chainage_m / interval);
return {
id: station.role === "anchor" ? owner : `${owner}:${station.role}`,
station: number,
remainder: station.chainage_m - number * interval,
chainage_m: station.chainage_m,
structure: station.title,
...(pipe ? { structureType: "배관" as const, origin: "pipe" as const } : {}),
};
});
}
/**
* 바뀜 신호 하나(323-6) — 관 · 구조물 초안을 구독(어느 길로 쓰든 설계 가게 한 곳). 구조물 = 늘 · 관 = 자리 · 종류 ·
* 구간 · 잇기가 바뀔 때만(옵션만 고친 것은 폼 길 `applyPipeOptions` 가 그 카드만 고침 — 카드 줌 · 팬 지킴).
* 돌려주는 함수로 끊음.
*/
export function watchDrafts(projectId: string | null, onChange: () => void): () => void {
const shop = useDesignStore(projectId, null);
type Pipe = NonNullable<DesignStoreValues["pipes"]>[number];
const layout = (pipes: ReadonlyArray<Pipe> | null): string =>
JSON.stringify(
(pipes ?? []).map((p) => [
p.chainage_m,
p.facility ?? "pipe",
p.start_m ?? null,
p.end_m ?? null,
p.summary_item ?? null,
p.group_items ?? null,
]),
);
let seen = layout(shop.get("pipes"));
const offs = [
shop.subscribe("structures", onChange),
shop.subscribe("pipes", (pipes) => {
const key = layout(pipes);
if (key === seen) return;
seen = key;
onChange();
}),
];
return () => offs.forEach((off) => off());
}
+54 -51
View File
@@ -17,10 +17,8 @@ import { leaveForDashboard } from "../b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { mountMissingStationNotice } from "../../B06_Section/B06_Section_UI_Missing_Stations";
import type {
StructureInstance,
StructureType,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { onStaleStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
import { pipeIdAt } from "./design_pipe_ids";
import {
fetchSectionContext,
getSections,
@@ -29,6 +27,7 @@ import {
type StandardCrossSection,
} from "../../B06_Section/B06_Section_Api_Fetch";
import { createStationControls } from "../../B06_Section/B06_Section_UI_Page_Station_Controls";
import { setStationAdjustReload } from "../../B06_Section/B06_Section_Api_Culvert_Options";
import {
createCutSlopeStore,
createRockBoundaryStore,
@@ -37,13 +36,19 @@ import {
} from "../../B06_Section/B06_Section_UI_Page_Persist";
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { paintMapPick } from "./design_pick_profile";
import { watchDrafts } from "./design_structure_status";
import { followSwaps, shownDetail } from "../../B06_Section/B06_Section_UI_Page_Station_Swap";
import { savingDesign } from "./design_save";
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
import { createSectionView } from "../../B06_Section/B06_Section_UI_Section_View";
import { revetFormOf, revetWallSpec } from "../../B06_Section/B06_Section_UI_Cross_Culvert_Const";
import {
revetFormOf,
revetWallSpec,
} from "../../B06_Section/logic/B06_Section_UI_Cross_Culvert_Const";
import {
applyPipeOptionsToCache,
replayDraftPipeOptions,
type PipeOptionsContext,
} from "../../B06_Section/B06_Section_UI_Page_Pipe_Options";
import {
@@ -131,8 +136,7 @@ export async function mountCrossView(
);
// 「구조물 배치」 — 종단 보기와 같은 폼 한 벌에 횡단 보기 동작을 건다(계획서 24-3b).
let structureMarksSink:
((structures: StructureInstance[], types: StructureType[]) => void) | null = null;
let structureMarksSink: Parameters<typeof createB06StructuresPanel>[0]["onMarks"] | null = null;
/** 관 목록 초안을 고치면 한 벌 배수유역도가 그 초안으로 다시 선다(잇단 조작은 한 번). */
let pipeReloadTimer = 0;
let wallAdjustTimer = 0;
@@ -140,12 +144,11 @@ export async function mountCrossView(
projectId,
shared,
// 종단 알약 레인에 같은 목록을 넘긴다 — 종단 보기와 같은 표기(2026-09-07 사용자 지시 4).
onMarks: (structures, types) => {
structureMarksSink?.(structures, types);
onMarks: (structures, types, pipes) => {
structureMarksSink?.(structures, types, pipes);
syncBermSpans(structures);
},
// 구조물(C군 벽)이 늘거나 줄면 그 측점 횡단 제원이 달라진다 — 다시 받아 그린다(2026-09-06).
onStructuresChanged: () => void refreshDetailForStructures(),
onPipesWritten: () => {
window.clearTimeout(pipeReloadTimer);
pipeReloadTimer = window.setTimeout(() => shared.profile?.side?.reload(), 200);
@@ -191,7 +194,7 @@ export async function mountCrossView(
if (!isWallSelecting()) focusStationAt(chainageM, revealOnly);
},
releaseCard: () => releaseCross(),
markStructure: (structureId) => sectionView.selectMark(structureId),
markStructure: (structureId, span) => sectionView.selectMark(structureId, span),
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
});
@@ -220,6 +223,7 @@ export async function mountCrossView(
async function refreshDetailForStructures(): Promise<void> {
if (!projectId || currentRouteId === null) return;
const seq = ++structuresRefreshSeq; // 잇단 조작 — 최신 요청만 그림(54-4)
if (savingDesign(projectId)) return; // 저장 도중 = 낡은 캐시 · 빈 초안 — 저장 뒤 한 번(323-9)
try {
const fresh = await loadSectionDetail(projectId, currentRouteId);
if (seq !== structuresRefreshSeq) return;
@@ -233,6 +237,7 @@ export async function mountCrossView(
);
}
renderSectionDetail();
void reconcileStaleDesigns(); // 구조물 제원 ↔ 저장분 값 다른 측점 다시 셈(측구 · 포장 · 노면 하강 · 314)
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`구조물을 횡단에 반영하지 못했습니다.${detail}`, "error");
@@ -301,6 +306,7 @@ export async function mountCrossView(
wallAdjustTimer = window.setTimeout(() => structuresPanel.facility.recount(), 120);
},
});
setStationAdjustReload(() => stationControls.load()); // 종류 바꿈이 세션을 비운 뒤(318-4)
// 배관 흙막이 개수 = 유출 단 수(54-1) — 세션 값 우선
setPipeExtraCountSource((section) => stationControls.extraWalls.countFor(section, "outlet"));
// 좌측 「추가 흙막이(단)」도 같은 단 수 제어로(54-27) — 사라지는 단을 고른 중이면 유출 벽으로 옮김
@@ -332,7 +338,7 @@ export async function mountCrossView(
shared,
);
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
structureMarksSink = sectionView.setStructureMarks;
// 계획선 편집(▲/▼)은 종단도 한 벌이 맡는다(계획서 24-2c) — 같은 초안 가게 · 같은 막기 규칙.
// 종단 그래프 우클릭 — 종단 보기와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자). 어느 길로
@@ -341,15 +347,15 @@ export async function mountCrossView(
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
removeStructure: (structureId) => {
structuresPanel.removeStructureById(structureId);
},
removeStructure: (structureId) => void structuresPanel.removeStructureById(structureId),
movePipe: (fromChainageM, toChainageM) =>
structuresPanel.movePipeTo(fromChainageM, toChainageM),
moveStructure: (structureId, toChainageM) => {
structuresPanel.moveStructureTo(structureId, toChainageM);
},
selectStructure: (structureId) => structuresPanel.selectStructure(structureId),
selectPipe: (chainageM) => structuresPanel.selectPipeAt(chainageM), // 측점 없는 관(요구 178)
pipesChanged: () => (structuresPanel.adoptFordHeights(), void refreshDetailForStructures()),
});
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = {
@@ -387,19 +393,18 @@ export async function mountCrossView(
: null;
if (!target) {
structuresPanel.showAtChainage(null);
selectDesign(projectId, NO_SELECTION, { from: "b06" }); // 카드 해제 — 선택도 비운다.
releaseCross(); // 카드 다시 누름 = 풀기 한 길(범위 테두리 · 형상까지 · 233-2)
return;
}
const owner = isWallSelecting() ? stationControls.structureSpan.ownerOf(target) : null;
const preferred = structuresPanel.preferred() !== null;
const opened = structuresPanel.showAtChainage(owner?.chainage_m ?? target.chainage_m);
const opened = structuresPanel.showAtChainage(structureSelection.formChainageOf(target));
// 카드 하나 고름 = 그 측점(구조물 id 는 목록 · 알약 · 넘김이 고른 것만 · 189-1)
const picked = preferred ? opened : { chainageM: opened.chainageM, structureId: null };
if (!isWallSelecting()) selectDesign(projectId, picked, { from: "b06" });
// 카드 고름은 그 카드 측점선으로 알약을 맞춤 — 일반 측점 구조물 · 옆 측점 이음 카드면
// 알약이 꺼지므로 연 것으로 다시 세움(관 알약 id = pipe-측점 · 53-1)
const pipeAt = structuresPanel.currentChainage();
const mark = opened.structureId ?? (pipeAt === null ? null : `pipe-${pipeAt.toFixed(2)}`);
const mark = opened.structureId ?? (pipeAt === null ? null : pipeIdAt(pipeAt, projectId));
sectionView.selectMark(mark, false); // 측점 고름 = 카드 하나(137-4) · 덮는 것 없으면 알약 · 강조 풂(152-5)
structureSelection.syncInletStructure();
// 카드만 고른 경우엔 구조물(벽·구체) 선택을 비운다 — 조정창이 저절로 뜨면 안 된다
@@ -474,16 +479,11 @@ export async function mountCrossView(
handoff,
sectionDetail,
{
// 넘겨받은 구조물 id 가 그 측점 관보다 먼저다(52-15).
focusStation: (stationId) => {
structuresPanel.prefer(handoff?.structureId ?? null);
sectionView.focusStation(stationId);
structuresPanel.prefer(null);
// 그 카드가 이미 골라져 있으면 카드 고름이 안 돌아 폼이 옛 것으로 남음 — id 로 직접 엶(53-1)
const sid = handoff?.structureId;
if (sid && structuresPanel.getStructures().some((entry) => entry.structure_id === sid))
structuresPanel.selectStructure(sid);
},
// 넘겨받은 구조물 id 가 먼저(52-15) · 폼은 id 로 직접(53-1) · 측점 없는 구조물은 카드 · 스크롤 없음(307)
focusStation: (stationId) =>
structuresPanel.focusPicked(handoff?.structureId ?? null, () =>
sectionView.focusStation(stationId),
),
refreshCard: (chainageM) => sectionView.refreshCard(chainageM),
revetSelect: (chainageM, key) => stationControls.revetOffset.select(chainageM, key),
fordSelect: (chainageM, role) => stationControls.ford.select(chainageM, role),
@@ -494,23 +494,21 @@ export async function mountCrossView(
if (applied) appliedPick = signature;
}
/** 고름을 다 풂(54-38) — 카드 · 벽 · 세월교 · BOX 강조 · 넘김 되얹기 표시 · 선택 칸. */
// 횡단 보기 강조 = 고름 한 칸 구독 · 다른 곳(Esc 등)이 바꾼 값으로 다시 세움(233-2)
const crossPick = bindCrossPick(projectId, host, {
sections: () => sectionDetail?.cross_sections ?? [],
controls: stationControls,
releaseForm: () => structuresPanel.showAtChainage(null),
view: sectionView,
reapply: () => {
appliedPick = null;
restoreStructurePick();
},
paintMap: (selection) => shared.profile?.side && paintMapPick(shared.profile.side, selection),
});
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
function releaseCross(): void {
const { revetOffset, ford, box } = stationControls;
const lit = (sectionDetail?.cross_sections ?? []).filter(
(s) =>
revetOffset.highlightFor(s) ||
ford.selectedFor(s.chainage_m) ||
box.selectedFor(s.chainage_m),
);
for (const s of lit) {
if (revetOffset.selectedFor(s)) revetOffset.select(s.chainage_m, null);
if (ford.selectedFor(s.chainage_m)) ford.select(s.chainage_m, null);
if (box.selectedFor(s.chainage_m)) box.select(s.chainage_m, null);
}
structuresPanel.releasePipe();
sectionView.focusStation(null);
sectionView.refreshCards(lit.map((s) => s.chainage_m));
crossPick.release();
appliedPick = null;
selectDesign(projectId, NO_SELECTION, { from: "b06" });
}
@@ -520,7 +518,7 @@ export async function mountCrossView(
showSectionView();
// 높이 배율은 항상 1(2026-08-06 사용자 지시 — 옵션 폐지).
sectionView.render(
sectionDetail,
shownDetail(projectId, sectionDetail), // 노선 초안 동안 = 새 노선 횡단(346-2 덧 3)
1,
crossHalfWidth(),
stationInterval,
@@ -543,6 +541,7 @@ export async function mountCrossView(
// 저장 직전 전 측점 재계산 — 표준단면·계획선을 고친 뒤 안 만진 측점이 옛 면적으로
// 실려 나가던 자리(2026-09-09 실측).
reconcileDesigns: () => reconcileStaleDesigns({ force: true }),
redraw: () => (host.visible() ? refreshDetailForStructures() : Promise.resolve()),
patchSources: () => ({
rockOffsets: rockStore.offsets,
cutSlopeRatios: cutSlopeStore.ratios,
@@ -568,8 +567,14 @@ export async function mountCrossView(
// 종단 보기에서 고름을 풀었으면 횡단 강조도 풂(54-38)
if (!readStructurePick(projectId)) releaseCross();
void structuresPanel.load().then(restoreStructurePick);
void refreshDetailForStructures().then(() => reconcileStaleDesigns());
void refreshDetailForStructures(); // 안에서 낡은 설계도 맞춤(314)
});
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
const reloadPanel = (): void => void structuresPanel.load().then(restoreStructurePick);
host.onDispose(onStaleStructureTypes(() => host.visible() && reloadPanel()));
// 바뀜 신호 하나(323-6 · `watchDrafts`) — 초안이 어느 길로 바뀌든 보일 때 카드 · 그림 다시(옛 되부름 갈음)
host.onDispose(watchDrafts(projectId, () => host.visible() && void refreshDetailForStructures()));
host.onDispose(followSwaps(projectId, () => currentRouteId, host, refreshDetailForStructures));
host.onDispose(() => {
window.clearTimeout(pipeReloadTimer);
window.clearTimeout(wallAdjustTimer);
@@ -669,8 +674,6 @@ export async function mountCrossView(
bindPipeOptions(shared, (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
);
// 새로고침 뒤 캐시 상세는 저장분 — 저장 전에 고친 관 옵션을 초안에서 다시 얹는다(24-7a).
replayDraftPipeOptions(pipeOptionsContext, readPendingPipes(projectId));
renderSectionDetail();
// 측점이 없는 구조물 알림 — 관을 나중에 놓으면 그 측점이 안 생겨 수량에서 조용히 빠진다
// (계획서 3-14 ㉯). 만드는 것은 사용자가 누를 때만.
+197 -321
View File
@@ -1,44 +1,35 @@
/* =============================================================================
* design_view_profile.ts
* 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 3D 화면. 좌측 패널 · 아래 단추 ·
* 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 평면(노선 편집 · 345-3) · 3D 화면은
* [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 ·
* 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다.
*
* 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫.
* (옛 `B05_Profile_UI_Page.ts` 의 본체다. 버튼 동작은 `_Page_Actions`, 최신 노선 캐시는
* `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.)
* `_Page_Latest`, 3D 측점 바는 `_Page_Stations`, 숨김 · 보임은 `_Page_Life` 가 맡는다.
* 노선 · 종단면 그리기는 `design_view_profile_sections`, 3D 몫은 `design_view_profile_3d`.)
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { purgeOtherProjects } from "../b_asset_cache";
import { createProgressCircle } from "@ui/ui_template_progress";
import {
fetchConfirmedSurface,
type SurfaceConfirmedResponse,
} from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
clearRouteLatestCache,
updateContourInterval,
type RouteLatestResponse,
} from "../../B05_Profile/B05_Profile_Api_Fetch";
import { fetchConfirmedSurface } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
import { leaveForDashboard } from "../b_missing_data_guard";
import { currentRoute, remountRoute } from "../router";
import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
import { mountRouteEditor } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
import { routeDraftBridge } from "./design_pipeline_page";
import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import {
readStructurePick,
restoreStructurePick,
wireStructurePick,
} from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import {
coversAtStation,
NO_SELECTION,
nearestWithin,
readSelection,
selectDesign,
} from "./design_selection";
import { coversAtStation, NO_SELECTION, readSelection, selectDesign } from "./design_selection";
import type { DesignPageHost } from "./design_page";
import { bindProfilePick } from "./design_pick_profile";
import {
bindDesignView,
designReady,
@@ -46,7 +37,6 @@ import {
type DesignPageShared,
} from "./design_shared";
import { bindProfileViewLife } from "../../B05_Profile/B05_Profile_UI_Page_Life";
import { structureAnchorM } from "../../B05_Profile/B05_Profile_Api_Structures";
import { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
import { createRouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
import {
@@ -57,24 +47,18 @@ import {
rememberRockBoundaryDefault,
rememberStandardDefaults,
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import {
fetchSectionContext,
type SectionDetailResponse,
} from "../../B06_Section/B06_Section_Api_Fetch";
import { loadSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures";
onStaleStructureTypes,
structureAnchorM,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import {
loadCorridorIfFresh,
refreshCorridor,
saveCorridorIfDirty,
} from "../../B05_Profile/B05_Profile_UI_Corridor";
import {
profileSaveParts,
type PageActionContext,
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
import { createLatestSource, panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations";
import { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import "../../B05_Profile/B05_Profile_UI_Style.css";
@@ -82,18 +66,19 @@ import "../../B05_Profile/B05_Profile_UI_Style.css";
import "../../B05_Profile/B05_Profile_UI_Style_Table.css";
import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css";
import "../../B05_Profile/B05_Profile_UI_Style_Structures.css";
import { movePipeId, pipeIdAt } from "./design_pipe_ids";
import { useDesignStore } from "./design_store";
import { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow";
import { STRUCTURE_ROUTE_DRIFT_MAX_M } from "../../common_util/common_util_route_follow";
import {
DEFAULT_ROAD_WIDTHS,
fetchRoadWidths,
loadUphillOverrides,
restorePoints,
saveUphillOverrides,
toBounds,
toGradeClass,
} from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */
const migratedRoutes = new Set<string>();
import { createProfileSections, type ProfileViewState } from "./design_view_profile_sections";
import { createProfile3d } from "./design_view_profile_3d";
type PipeReport = Parameters<NonNullable<RouteProfilePanelCallbacks["onPipesChanged"]>>[0];
@@ -121,24 +106,51 @@ export async function mountProfileView(
});
/** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */
let lastPipes: PipeReport | null = shared.lastPipes ?? null;
const applyPipes = (pipes: PipeReport): void => {
const designStore = useDesignStore(activeProjectId, null);
/** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 id 도 옮김. */
let lastShown: PipeReport = [];
const shownPipes = (pipes: PipeReport): PipeReport => {
const byChainage = (x: PipeReport[number], y: PipeReport[number]) =>
x.chainage_m - y.chainage_m;
const shown = followPipeReports(pipes, designStore.get("pipes")).sort(byChainage);
const before = [...lastShown].sort(byChainage);
if (before.length === shown.length)
shown.forEach((pipe, i) => {
if (Math.abs(pipe.chainage_m - before[i].chainage_m) > 0.005)
movePipeId(before[i].chainage_m, pipe.chainage_m, activeProjectId);
});
lastShown = shown;
return shown;
};
const applyPipes = (reported: PipeReport, carryEdits = true): void => {
const pipes = shownPipes(reported);
// 횡단배수는 계획선을 그 지점에 물리는 시설이라 그래프 세로 점선·계획고 틸팅
// 버튼이 필요하다(2026-08-17 사용자 지시). 알약 레인은 종류·선택을 맡는다.
bridge.setPipes(pipes);
bridge.setPipes(pipes, carryEdits);
panel.structures.setPipeFacilities(pipes.map(pipeToFacilityItem));
profilePanel.setMinCoverPipes(pipes);
snapOld();
restorePick(); // 관 목록이 실린 뒤라야 폼이 그 시설을 찾는다.
};
/** 노선을 놓아 계산 묶음이 가게 관 · 구조물 측점을 다시 매김(되돌림 포함) — 목록 · 알약 · 측점선. */
const followFacilities = (far: number): void => {
if (far)
showToast(
`구조물 ${far}건이 새 노선에서 ${STRUCTURE_ROUTE_DRIFT_MAX_M} m 넘게 멀어짐`,
"warning",
);
if (!host.visible()) return;
if (lastPipes) applyPipes(lastPipes, false);
bridge.takeStructures(designStore.get("structures"));
};
/** 측점선 강조 — 측점선이 없는 관 시설(물넘이포장 · 비정규 측점 아님)은 알약을 id 로 켬(53-2) ·
* 관도 없는 자리면 그 측점선 · 3D 띠(횡단에서 고른 일반 측점 · 188-1) — 구조물 고름은 선 없음(189-1). */
const selectGraphAt = (id: string | null, chainageM: number | null): void => {
const structurePick = !!readSelection(activeProjectId)?.structureId;
const sections = structurePick ? [] : (currentSectionDetail?.cross_sections ?? []);
const sections = structurePick ? [] : (state.detail?.cross_sections ?? []);
const target = graphTargetAt(id, chainageM, lastPipes ?? [], sections);
if ("pipeChainageM" in target) {
profilePanel.setSelectedStructure(`pipe-${target.pipeChainageM.toFixed(2)}`);
return;
}
if ("pipeChainageM" in target)
return profilePanel.setSelectedStructure(pipeIdAt(target.pipeChainageM, activeProjectId));
profilePanel.setSelectedStation(target.stationId);
if (id === null && target.stationId !== null) viewer.markers.selectStation(target.stationId);
};
@@ -154,7 +166,7 @@ export async function mountProfileView(
{
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
// (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다.
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
onCrossDesignsUpdated: () => sections.setCorridorPending(true),
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의
// 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫).
onPipesChanged: (pipes) => {
@@ -173,13 +185,15 @@ export async function mountProfileView(
panel.structures.addAt(chainage, LEGACY_TYPE_IDS[type] ?? "etc"),
onIrregularSelect: (station) => syncIrregularSelection(irregularStationId(station.id)),
// 배수유역도에서 유역·관 마커를 고르면 그 관의 측점을 그래프·3D·사이드 패널에서도 고른다.
onBasinSelected: (chainageM) => selectStationOfPipe(chainageM),
onPipeSelected: (chainageM) => selectStationOfPipe(chainageM),
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => {
const chainage = bridge.pipeMarkChainage(structureId);
rememberPick(bridge.markChainage(structureId), chainage === null ? structureId : null);
// 측점 없는 관(물넘이포장)은 알약 id 도 남김 — 같은 m 일반 측점선을 안 켬(요구 178)
const keepId = chainage === null || bridge.stationlessPipeMark(structureId);
rememberPick(bridge.markChainage(structureId), keepId ? structureId : null);
if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage);
selectStationOfPipe(chainage);
@@ -199,19 +213,20 @@ export async function mountProfileView(
},
);
/** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */
let confirmedSurface: SurfaceConfirmedResponse | null = null;
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
let currentSectionDetail: SectionDetailResponse | null = null;
const state: ProfileViewState = {
confirmedSurface: null,
latest: null,
roadWidths: DEFAULT_ROAD_WIDTHS,
detail: null,
corridorVisible: true,
corridorPending: false,
routeReady: false,
restoring: true,
};
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
setPipeSectionsSource(() => currentSectionDetail?.cross_sections ?? null);
setPipeSectionsSource(() => state.detail?.cross_sections ?? null);
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
setPipeCrossOpener(() => shared.ensureView("cross"));
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
let corridorVisible = true;
let routeReady = false;
let restoring = true;
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
let selectionSyncing = false;
@@ -220,7 +235,7 @@ export async function mountProfileView(
* 저장 시 uphill_overrides로 백엔드/DB(종단 정본)에 병합한다(3D 구슬 입력은 176-3 에서 지움). */
const uphillOverrides = loadUphillOverrides(activeProjectId);
const { syncIrregularSelection, syncBasinHighlight, selectStationOfPipe } = createSelectionSync({
const { syncIrregularSelection, selectStationOfPipe } = createSelectionSync({
stations: () => bridge.irregularStations(),
isSyncing: () => selectionSyncing,
setSyncing: (value) => {
@@ -239,14 +254,28 @@ export async function mountProfileView(
markStation: (chainageM) => profilePanel.drainage.markStation(chainageM),
selectStructure3D: (chainageM) => viewer.structurePick.selectAtChainage(chainageM),
regularChainage: (stationId) =>
currentSectionDetail?.cross_sections.find((entry) => entry.station_id === stationId)
?.chainage_m,
state.detail?.cross_sections.find((entry) => entry.station_id === stationId)?.chainage_m,
remember: (chainageM) => rememberPick(chainageM, null),
});
/** 배수유역도 유역 · 관 고름도 고름 한 칸에(사슬 도중 되울림은 안 적음 · 233-3). */
const pickFromMap = (chainageM: number | null): void => {
if (!selectionSyncing) rememberPick(chainageM, null);
selectStationOfPipe(chainageM);
};
let oldSnapped = false;
/** 옛 소수 측점(관 · 구조물)을 정수 m 로 — 관 · 구조물 · 계획고 편집이 다 실린 뒤 한 번(272-4). */
function snapOld(): void {
if (oldSnapped || state.restoring || !lastPipes) return;
oldSnapped = true;
panel.structures.snapOld();
}
/** 세션에 남은 구조물 선택을 되살린다 — 횡단 보기에서 고른 것도 그대로 이어 받는다
* (2026-09-04). 이미 무언가 골라져 있으면 건드리지 않는다. */
function restorePick(): void {
profilePick.paint(); // 관 · 구조물 목록이 바뀐 뒤에도 종단은 고름 한 칸대로(233-1)
if (panel.structures.hasSelection()) return;
const pick = viewer.structurePick;
restoreStructurePick(pick, activeProjectId, panel.structures, selectStationOfPipe);
@@ -254,58 +283,31 @@ export async function mountProfileView(
const latestSource = createLatestSource(activeProjectId);
const loadLatest = latestSource.load;
const sections = createProfileSections({
projectId: activeProjectId,
state,
viewer,
panel: () => panel,
profilePanel: () => profilePanel,
bridge: () => bridge,
visible: () => host.visible(), // 숨은 동안 갈아끼운 상세(346-2)는 다시 보일 때 그림
});
host.onDispose(sections.stopSwap);
const { restorePanel, renderLatest, restoreSections } = sections;
const view3d = createProfile3d({
projectId: activeProjectId,
state,
viewer,
host,
latestSource,
profilePanel: () => profilePanel,
sections: () => sections,
});
const panel = createRoutePanel(
{
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하고 그 결과가 새 초기값이
// 된다. 옛 노선 캐시는 모달이 비우고, 페이지는 통째로 다시 읽는다 — 제자리 다시 세우기는
// 옛 노선 관 목록 등 메모리 · 진행 중 요청이 살아남아 [저장]에 실렸다(PLAN 77).
onEditPlannedRoute: () =>
void openRouteEditModal(activeProjectId, () => location.reload(), {
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: confirmedSurface?.model_id ?? null,
contourIntervalM: latest?.surface_params.contour_interval_m,
smooth: latest?.surface_params.smooth,
}),
onContourApply: (interval) => applyContours(interval),
onSurfaceVisible: viewer.setSurfaceVisible,
onCorridorVisible: (visible) => {
corridorVisible = visible;
viewer.setCorridorVisible(visible);
// 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다.
if (currentSectionDetail) renderStationLines(currentSectionDetail);
},
onContoursVisible: viewer.setContoursVisible,
onAxesVisible: viewer.setAxesVisible,
onStationLinesVisible: viewer.setStationLinesVisible,
onStationLabelsVisible: viewer.setStationLabelsVisible,
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
onProjection: viewer.setProjection,
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
onCorridorRefresh: async () => {
if (!currentSectionDetail || !latest?.route?.id) return;
const routePoints = latest.route_points ?? [];
if (routePoints.length <= 1) return;
await refreshCorridor(
viewer,
activeProjectId,
latest.route.id,
currentSectionDetail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
renderStationLines(currentSectionDetail);
// 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다.
void saveCorridorIfDirty(activeProjectId, latest.route.id);
},
onMovePoint: viewer.beginMoveSelected,
onDeletePoint: viewer.markers.deleteSelected,
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
onContourApply: (interval) => view3d.applyContours(interval),
...view3d.viewCallbacks,
onInputChange: markStale,
onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset),
onStructuresChange: (next) => bridge.applyStructures(next),
@@ -318,210 +320,77 @@ export async function mountProfileView(
forwardPipeOptions(shared, from, attributes.options as Record<string, number | string>);
},
onPipeFacilityRemove: (chainage) => profilePanel.drainage.removePipe(chainage),
// 목록 고름(null = 다시 누름 해제)도 고름 한 칸에 — 종단 · 3D · 배수유역도는 구독이 그 값으로 그림(233-3)
onPipeFacilitySelect: (chainage) => {
rememberPick(chainage, null); // 목록 선택도 횡단 보기로 이어진다.
if (selectionSyncing) return;
selectionSyncing = true;
try {
// null = 사이드 재클릭 해제 — 그래프·3D·배수유역도 선택도 함께 푼다(2026-08-18).
const station =
chainage === null
? undefined
: nearestWithin(bridge.irregularStations(), chainage, (entry) => entry.chainage_m);
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
selectGraphAt(id, chainage);
syncBasinHighlight(id);
// 배수유역 마커 선택 + 부속 옵션 폼도 연다(2026-08-17 전역 선택 동기화).
profilePanel.drainage.selectPipeAtChainage(chainage);
viewer.structurePick.selectAtChainage(chainage); // 3D 도 따라옴(53-2)
} finally {
selectionSyncing = false;
}
rememberPick(chainage, null);
if (!selectionSyncing) profilePick.paint();
},
onListRelease: () => rememberPick(null, null), // 목록 다시 누름 = 풀기 한 길(233-3)
onStructureSelect: (structure) => {
// 목록에서 고른 구조물도 횡단 보기로 이어진다 — 해제(null)는 여기서 적지 않는다(관 폼을
// 여는 길이 구조물 폼을 먼저 비우며 이 콜백을 null 로 부르기 때문).
if (structure) rememberPick(structureAnchorM(structure), structure.structure_id ?? null);
if (selectionSyncing) return;
selectionSyncing = true;
try {
profilePanel.setSelectedStructure(structure?.structure_id ?? null);
viewer.structurePick.selectAtChainage(structure ? structureAnchorM(structure) : null);
} finally {
selectionSyncing = false;
}
// 폼이 닫혀도(저장 뒤 · 관 폼으로) 종단 · 3D 는 고름 한 칸대로(233-1 · 233-2)
if (!selectionSyncing) profilePick.paint();
},
},
shared,
);
wireStructurePick(viewer.structurePick, activeProjectId, panel.structures, selectStationOfPipe);
// 3D 부재 고름도 측점 고름과 같은 거름 — 노면공 id 를 고름 한 칸에 적지 않음(231-1)
wireStructurePick(
viewer.structurePick,
activeProjectId,
panel.structures,
selectStationOfPipe,
(entry) => coversAtStation(bridge.typeGroup(entry.type_id)),
);
/** 입력·마커 변경 시 측점 라인만 다시 그린다. 지형 구분·등급·기울기 세부 입력은 종단 그래프의
* 위반 판정 기준까지 **즉시** 갈아 끼운다(2026-08-19 사용자 지시 6). */
function markStale(): void {
if (restoring || !routeReady) return;
if (state.restoring || !state.routeReady) return;
profilePanel.setGradeLimit(panel.gradeLimitPct());
// 횡단배수 최소고 강제 체크는 재계산 없이 편집 가드에만 즉시 반영된다(2026-09-01).
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
if (currentSectionDetail) renderStationLines(currentSectionDetail);
sections.redrawStationLines();
}
viewer.markers.onChange(markStale);
viewer.markers.onSelectionChange(panel.setSelected);
viewer.markers.onStationSelectionChange((stationId) => {
profilePanel.setSelectedStation(stationId);
syncIrregularSelection(stationId);
});
viewer.root.append(panel.viewControls);
function restorePanel(next: RouteLatestResponse): void {
panel.restore(panelValuesOf(next));
viewer.markers.setPoints(restorePoints(next));
}
function renderStationLines(detail: SectionDetailResponse): void {
const lines = stationLinesOf({
detail,
irregular: bridge.irregularStations(),
routePoints: latest?.route_points ?? [],
liveSamples: profilePanel.alignmentSamples(),
corridorVisible,
});
viewer.renderStationLines(lines, roadWidths[panel.values().gradeClass] / 2);
}
/** 구조물·계곡 통과 시설을 사이드 목록·그래프·3D에 맞추는 다리(정본 저장까지 맡는다). */
const bridge = createStructuresBridge({
projectId: activeProjectId,
panel: () => panel,
profilePanel: () => profilePanel,
renderStationLines: () => {
if (currentSectionDetail) renderStationLines(currentSectionDetail);
},
isRestoring: () => restoring,
renderStationLines: () => sections.redrawStationLines(),
isRestoring: () => state.restoring,
});
// 종단도 · 3D 강조 = 고름 한 칸에서 파생 · 이 보기가 보일 때만(횡단 보기는 제 규칙 · 233-1 · 233-2)
const profilePick = bindProfilePick(
activeProjectId,
{ profile: profilePanel, viewer, form: panel.structures },
host,
{
irregular: () => bridge.irregularStations(),
pipes: () => lastPipes ?? [],
sections: () => state.detail?.cross_sections ?? [],
},
);
// 3D 노선 우클릭 → 구조물 배치 메뉴(그래프·배수유역도와 동일, 2026-08-19).
bridge.mountViewerMenu(viewer, () => latest?.route_points ?? []);
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
currentSectionDetail = detail;
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
// 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19).
profilePanel.setGradeLimit(panel.gradeLimitPct());
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
profilePanel.setStationDisplay(panel.stationDisplayOffset());
profilePanel.setIrregularStations(bridge.irregularStations());
renderStationLines(detail);
// 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기
// 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다).
const routePoints = latest?.route_points ?? [];
if (routePoints.length > 1) {
void loadCorridorIfFresh(
viewer,
activeProjectId,
routeId ?? latest?.route?.id,
detail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
).then((fresh) => panel.setCorridorPending(!fresh));
}
}
async function restoreSections(routeId: number): Promise<void> {
let detail: SectionDetailResponse;
profilePanel.setLoading("종단면 자료를 불러오는 중…");
try {
// 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다.
detail = await loadSectionDetail(activeProjectId, routeId);
} catch {
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
currentSectionDetail = null;
profilePanel.setLoading(null);
profilePanel.clear();
viewer.renderStationLines([], 0);
return;
}
profilePanel.setLoading(null);
try {
renderSections(detail, routeId);
// 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측).
const legacy = detail.longitudinal.stations
.filter((station) => station.kind === "irregular")
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure ?? "",
}));
const migrateKey = `${activeProjectId}:${routeId}`;
if (legacy.length && !migratedRoutes.has(migrateKey)) {
migratedRoutes.add(migrateKey);
void migrateLegacyStations(activeProjectId, legacy)
.then((result) => {
if (result.migrated > 0) {
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
return bridge.refreshFromServer().then(() => undefined);
}
return undefined;
})
.catch(() => {
/* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */
});
}
} catch (error) {
// 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다 — 실패 사실을 드러낸다.
console.error("B05 종단면도 렌더 실패", error);
showToast(
error instanceof Error
? `종단면도 표시 실패: ${error.message}`
: "종단면도 표시에 실패했습니다.",
"error",
);
}
}
function renderLatest(next: RouteLatestResponse): void {
latest = next;
routeReady = Boolean(next.route && next.route_points.length > 1);
profilePanel.setRoutePolyline(next.route_points ?? []);
if (next.route) {
const stored = next.route.algorithm_params ?? {};
viewer.markers.renderRoute(
next.route_points,
(stored.curve_warning_segments as Array<{
polyline_start_index: number;
polyline_end_index: number;
}>) ?? [],
);
}
}
async function applyContours(interval: number): Promise<void> {
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장).
try {
await viewer.reloadContours(interval);
await updateContourInterval(activeProjectId, interval);
// 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다.
const cached = latestSource.read();
if (cached) {
latestSource.write({
...cached,
surface_params: { ...cached.surface_params, contour_interval_m: interval },
});
}
} catch (error) {
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
}
}
bridge.mountViewerMenu(viewer, () => state.latest?.route_points ?? []);
// [경로 계산] · 저장 몫은 따로 뗀 모듈이 맡는다(2026-09-02).
const actionContext: PageActionContext = {
projectId: activeProjectId,
latest: () => latest,
confirmedSurface: () => confirmedSurface,
routeReady: () => routeReady,
latest: () => state.latest,
confirmedSurface: () => state.confirmedSurface,
routeReady: () => state.routeReady,
viewer: () => viewer,
panel: () => panel,
profilePanel: () => profilePanel,
@@ -538,13 +407,23 @@ export async function mountProfileView(
onReset: () => uphillOverrides.clear(),
});
// 탭이 다시 보일 때 정의 판이 바뀌었으면 종류 · 패널 다시 읽음(322-0)
const reloadIfShown = (): void => void (host.visible() && !state.restoring && bridge.load());
host.onDispose(onStaleStructureTypes(reloadIfShown));
// 한 페이지 두 보기 — 숨김 · 보임 · 떠남(계획서 24-3a · 24-3b).
bindProfileViewLife(host, {
projectId: activeProjectId,
viewer,
ready: () => !restoring,
// 3D 는 모달 — 숨으면 닫음(그리기 멈춤) · 보여도 모달을 열 때만 그림 · 떠나면 버림(345-2).
viewer: {
setActive: (active) => {
if (!active) view3d.modal.close();
},
dispose: view3d.modal.dispose,
},
ready: () => !state.restoring,
// 다시 보이면 횡단 보기가 한 벌(폼 · 종단도)에 실어 둔 값을 종단 보기 값으로 되돌린다.
reload: async () => {
sections.flushSwap(); // 숨은 동안 측점 목록이 바뀌었으면 종단도 · 3D 측점선 다시(346-2)
await bridge.load();
if (lastPipes) applyPipes(lastPipes);
},
@@ -558,33 +437,37 @@ export async function mountProfileView(
);
return;
}
viewer.markers.selectStation(null);
profilePanel.setSelectedStation(null);
},
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
onHide: () => {
if (latest?.route?.id) void saveCorridorIfDirty(activeProjectId, latest.route.id);
profilePick.paint();
},
});
/** 노선 저장([저장] · 추천 [적용]) 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를
* 통째로 다시 세운다(345-3) — 제자리 고침은 옛 노선 관 목록 등 메모리 · 진행 중 요청이 살아남아
* [저장]에 실렸다(PLAN 77). 초안 · 결과 칸은 노선 저장이 비웠고 옛 노선 캐시는 여기서 비운다 ·
* 떠나는 페이지의 늦은 응답은 `host.disposed()` 로 버려진다. */
const rebuildAfterReplan = (): void => {
clearRouteLatestCache(activeProjectId);
invalidateSectionDetail(activeProjectId);
remountRoute(currentRoute());
};
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
* 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시).
* 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시). 서클은 3D 뷰포트
* 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */
* 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — [3D] 모달을 처음 열 때
* 올린다(345-2). 서클은 가운데 위 칸 정중앙에 둔다(하단 종단 패널에 가려지는 것은 무방). */
const LOAD_STEP_COUNT = 3;
const mainContent = document.createElement("div");
mainContent.className = "b05-route__main";
mainContent.append(view3d.modal.park);
center.append(mainContent);
const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true });
viewer.root.append(progress.root);
mainContent.append(progress.root);
let loadedSteps = 0;
function advanceLoading(label: string): void {
loadedSteps += 1;
progress.set(Math.min(1, loadedSteps / LOAD_STEP_COUNT), label);
}
const mainContent = document.createElement("div");
mainContent.className = "b05-route__main";
mainContent.append(viewer.root);
center.append(mainContent);
try {
// ① 좌측 폼·노선 설정값 — 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다
// (2026-09-06). 캐시가 없을 때만 DB를 기다린다.
@@ -595,7 +478,7 @@ export async function mountProfileView(
fetchRoadWidths(activeProjectId),
]);
if (host.disposed()) return;
roadWidths = configuredRoadWidths;
state.roadWidths = configuredRoadWidths;
panel.restore({
stationInterval: sectionContext.defaults.station_interval_m,
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
@@ -619,13 +502,30 @@ export async function mountProfileView(
// ② 확정 지표면 — 모델 id·범위를 한 번에 받는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
confirmedSurface = confirmed.model_id === null ? null : confirmed;
if (!confirmedSurface) {
state.confirmedSurface = confirmed.model_id === null ? null : confirmed;
if (!state.confirmedSurface) {
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
leaveForDashboard();
return;
}
advanceLoading("종단면 자료를 불러오는 중…");
// 평면(노선 편집) — 가운데 위 본문 · 페이지 수명(345-3). 자료는 페이지 열기와 함께 읽는다.
void mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan, {
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 편집기가 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: state.confirmedSurface.model_id,
contourIntervalM: latestResponse.surface_params.contour_interval_m,
smooth: latestResponse.surface_params.smooth,
tools: [view3d.modal.button],
recommend: panel.recommend,
// 계산 묶음(346-1) — 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본).
draft: routeDraftBridge(activeProjectId, profilePanel, {
stationIntervalM: panel.values().stationInterval ?? undefined,
onCorridorStale: () => sections.setCorridorPending(true),
onFacilitiesFollowed: followFacilities,
}),
});
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
if (latestResponse.route) await restoreSections(latestResponse.route.id);
@@ -654,32 +554,8 @@ export async function mountProfileView(
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
} finally {
progress.remove();
restoring = false;
state.restoring = false;
snapOld();
restorePick(); // 관·구조물 목록 중 늦게 오는 쪽이 있어 여기서 한 번 더.
}
// ④ 3D 지형 — 화면을 잡지 않고 뒤에서 올린다. 실패해도 나머지는 그대로 쓴다.
void (async () => {
const surface = confirmedSurface;
const current = latest as RouteLatestResponse | null; // 닫힘 안에서 채워져 흐름 분석이 못 봄
if (!surface || !current || host.disposed()) return;
try {
// 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다.
if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
await viewer.loadSurface(
activeProjectId,
surface.model_id as number,
current.surface_params.method,
current.surface_params.smooth,
current.surface_params.contour_interval_m,
toBounds(surface.bounds),
);
if (host.disposed()) return; // 올리는 사이 페이지를 떠났다.
renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
if (currentSectionDetail) renderStationLines(currentSectionDetail);
showToast("3D 지형 준비 완료", "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
}
})();
}
+141
View File
@@ -0,0 +1,141 @@
/* =============================================================================
* design_view_profile_3d.ts
* 종단 보기의 **3D 몫** — 보기 조작 줄 콜백 · [3D 업데이트] · 등고선 다시 적용 · 3D 지형 올리기 ·
* 큰 모달(`design_view_profile_3d_modal` · 345-2 — 지형은 처음 열 때 올림).
* `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔 `ProfileViewState`.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { updateContourInterval } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { refreshCorridor } from "../../B05_Profile/B05_Profile_UI_Corridor";
import type { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { toBounds } from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
import type { ViewControlCallbacks } from "../../B05_Profile/B05_Profile_UI_Panel_View";
import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
import type { DesignPageHost } from "./design_page";
import type { createProfileSections, ProfileViewState } from "./design_view_profile_sections";
import { createViewer3dModal } from "./design_view_profile_3d_modal";
export interface Profile3dDeps {
projectId: string;
state: ProfileViewState;
viewer: RouteViewer;
host: DesignPageHost;
latestSource: ReturnType<typeof createLatestSource>;
profilePanel: () => ReturnType<typeof createRouteProfilePanel>;
sections: () => ReturnType<typeof createProfileSections>;
}
export function createProfile3d(deps: Profile3dDeps) {
const { projectId, state, viewer } = deps;
/** 보기 조작 줄(ISO · 켬끔 · [3D 업데이트])이 부르는 것 — 좌측 패널 콜백에 섞어 넘긴다. */
const viewCallbacks: ViewControlCallbacks = {
onSurfaceVisible: viewer.setSurfaceVisible,
onCorridorVisible: (visible) => {
state.corridorVisible = visible;
viewer.setCorridorVisible(visible);
// 측점 바 기준면이 바뀐다(예상형상 위 ↔ 원지반 위) — 다시 그린다.
deps.sections().redrawStationLines();
},
onContoursVisible: viewer.setContoursVisible,
onAxesVisible: viewer.setAxesVisible,
onStationLinesVisible: viewer.setStationLinesVisible,
onStationLabelsVisible: viewer.setStationLabelsVisible,
onSurfaceGrayscale: viewer.setSurfaceGrayscale,
onView: viewer.setView,
onResetView: () => viewer.setView("top"),
onProjection: viewer.setProjection,
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
onCorridorRefresh: () => refreshOnce(),
};
/** 짓는 중이면 그 결과를 같이 기다린다 — 단추 · 모달 열기가 겹쳐도 한 번만 짓는다. */
let refreshing: Promise<void> | null = null;
function refreshOnce(): Promise<void> {
refreshing ??= refreshCorridorNow().finally(() => (refreshing = null));
return refreshing;
}
/** 낡은 3D 길 모형을 지금 노선 · 계획선으로 — 저장본이 맞으면 받기만 · 아니면 워커 미리보기. */
async function refreshCorridorNow(): Promise<void> {
const detail = state.detail;
const latest = state.latest;
if (!detail || !latest?.route?.id) return;
const routePoints = latest.route_points ?? [];
if (routePoints.length <= 1) return;
await refreshCorridor(
viewer,
projectId,
latest.route.id,
detail,
routePoints,
deps.profilePanel().alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
deps.sections().renderStationLines(detail);
deps.sections().setCorridorPending(false);
}
async function applyContours(interval: number): Promise<void> {
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장).
try {
await viewer.reloadContours(interval); // 지형 전(모달 안 엶)이면 그냥 지나감
await updateContourInterval(projectId, interval);
// 처음 열 때 올릴 지형도 새 간격으로(지형은 모달을 열 때 올림 · 345-2).
if (state.latest)
state.latest = {
...state.latest,
surface_params: { ...state.latest.surface_params, contour_interval_m: interval },
};
// 세션 캐시에도 반영해 다음 진입 시 옛 등고선 간격으로 복원되지 않게 한다.
const cached = deps.latestSource.read();
if (cached) {
deps.latestSource.write({
...cached,
surface_params: { ...cached.surface_params, contour_interval_m: interval },
});
}
} catch (error) {
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
}
}
/** ④ 3D 지형 — 모달을 처음 열 때 올린다(345-2). 실패해도 나머지는 그대로 쓴다. */
async function loadTerrain(): Promise<void> {
const surface = state.confirmedSurface;
const current = state.latest;
if (!surface || !current || deps.host.disposed()) return;
try {
// 범위는 ②에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다.
if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
await viewer.loadSurface(
projectId,
surface.model_id as number,
current.surface_params.method,
current.surface_params.smooth,
current.surface_params.contour_interval_m,
toBounds(surface.bounds),
);
if (deps.host.disposed()) return; // 올리는 사이 페이지를 떠났다.
deps.sections().renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
deps.sections().redrawStationLines();
showToast("3D 지형 준비 완료", "success");
} catch (error) {
showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error");
}
}
const modal = createViewer3dModal({
viewer,
loadTerrain,
visible: () => deps.host.visible(),
// 낡은 채 열면 그때 한 번 다시 짓는다(워커 · 345-4) — 닫혀 있는 동안은 안 짓는다.
onOpen: () => {
if (state.corridorPending) void refreshOnce();
},
});
return { viewCallbacks, applyContours, modal };
}
@@ -0,0 +1,92 @@
/* =============================================================================
* design_view_profile_3d_modal.ts
* 종단 보기 3D 를 **큰 모달**로(345-2). 3D 화면은 숨긴 자리(`park`)에 살고 [3D] 를 누르면 공용 큰
* 모달(`openModal({ wide })`) 몸으로 옮겨 붙는다 — 같은 노드라 보기 조작 줄 · [3D 업데이트] ·
* 3D 안 고름(측점 · 구조물 · 우클릭 더함)이 그대로 따라간다.
*
* 처음 열 때 3D 지형을 올린다(아직이면 로딩 원). 닫으면 숨긴 자리로 되돌려 `setActive(false)` —
* 그리기 루프 · 포인터 듣기만 멈추고 WebGL 문맥 · 지형은 살린다. 버림은 페이지를 떠날 때만.
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress";
export interface Viewer3dModalDeps {
viewer: { root: HTMLElement; setActive: (active: boolean) => void; dispose: () => void };
/** 처음 열 때 한 번 — 3D 지형 올리기(실패해도 모달은 열림). */
loadTerrain: () => Promise<void>;
/** 종단 보기가 보이는가 — 숨은 보기에서는 안 연다. */
visible: () => boolean;
/** 열 때마다(지형이 선 뒤) — 낡은 3D 길 모형 다시 짓기(345-4). */
onOpen?: () => void;
}
export function createViewer3dModal(deps: Viewer3dModalDeps) {
const { viewer } = deps;
const park = document.createElement("div");
park.className = "b05-route__3d-park";
park.hidden = true;
park.append(viewer.root);
viewer.setActive(false); // 닫힌 동안 그리기 0
let modal: { close: () => void } | null = null;
let terrain: Promise<void> | null = null;
const putBack = (): void => {
modal = null;
park.append(viewer.root);
viewer.setActive(false);
};
function open(): void {
if (modal || !deps.visible()) return;
modal = openModal({
title: "3D 보기",
closeLabel: "닫기",
wide: true,
dialogClass: "b05-route__3d-modal",
mount: (body) => {
body.classList.add("b05-route__3d-body");
body.append(viewer.root);
},
// 닫기 단추 · Esc · 바깥 누름 — 묻지 않고 숨긴 자리로 되돌린다.
beforeClose: () => {
putBack();
return true;
},
});
viewer.setActive(true);
if (!terrain) {
const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true });
progress.set(null);
viewer.root.append(progress.root);
terrain = deps.loadTerrain().finally(() => progress.remove());
}
void terrain.then(() => modal && deps.onOpen?.()); // 그 사이 닫았으면 안 지음
}
/** 열려 있으면 닫는다 — 보기를 떠날 때 · 페이지를 떠날 때. */
function close(): void {
if (!modal) return;
const shown = modal;
putBack();
shown.close();
}
const button = createButton({ label: "3D", variant: "ghost", onClick: open });
button.classList.add("b05-route__3d-open");
button.title = "3D 화면을 큰 창으로 엽니다 — 닫으면 그리기를 멈춥니다";
return {
park,
button,
open,
close,
isOpen: () => modal !== null,
dispose(): void {
close();
viewer.dispose();
park.remove();
},
};
}
@@ -0,0 +1,230 @@
/* =============================================================================
* design_view_profile_sections.ts
* 종단 보기의 **노선 · 종단면 그리기** — 최신 노선 · 측점선 · 종단면 자료(불러오기 · 그리기 ·
* 옛 측점 이관). `design_view_profile.ts` 가 700줄을 넘어 뗀 조각(345-1). 상태는 본체가 쥔
* `ProfileViewState` 한 벌을 같이 본다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { SurfaceConfirmedResponse } from "../../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { RouteLatestResponse } from "../../B05_Profile/B05_Profile_Api_Fetch";
import type { RoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
import type { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
import type { createStructuresBridge } from "../../B05_Profile/B05_Profile_UI_Page_Structures";
import type { RouteViewer } from "../../B05_Profile/B05_Profile_UI_Viewer";
import type { SectionDetailResponse } from "../../B06_Section/B06_Section_Api_Fetch";
import {
loadSectionDetail,
subscribeSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
import { useDesignStore } from "./design_store";
import { migrateLegacyStations } from "../../B05_Profile/B05_Profile_Api_Structures";
import { loadCorridorIfFresh } from "../../B05_Profile/B05_Profile_UI_Corridor";
import { stationLinesOf } from "../../B05_Profile/B05_Profile_UI_Page_Stations";
import { restorePoints, type RoadWidths } from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
import { panelValuesOf } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
/** 종단 보기 본체 · 조각이 함께 보는 값 한 벌. */
export interface ProfileViewState {
/** 확정 지표면 — 모델 id 와 3D 로딩에 필요한 범위만 들고 있는다(2026-09-06 호출 정리). */
confirmedSurface: SurfaceConfirmedResponse | null;
latest: RouteLatestResponse | null;
roadWidths: RoadWidths;
detail: SectionDetailResponse | null;
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
corridorVisible: boolean;
/** 3D 길 모형이 지금 정본보다 낡았나 — [3D 업데이트] 표시와 한 값 · 모달을 열 때 다시 짓는 기준(345-4). */
corridorPending: boolean;
routeReady: boolean;
restoring: boolean;
}
export interface ProfileSectionsDeps {
projectId: string;
state: ProfileViewState;
viewer: RouteViewer;
panel: () => RoutePanel;
profilePanel: () => ReturnType<typeof createRouteProfilePanel>;
bridge: () => ReturnType<typeof createStructuresBridge>;
/** 종단 보기가 보이나 — 숨은 동안 갈아끼운 상세는 다시 보일 때 그림(346-2 · 없으면 늘 보임). */
visible?: () => boolean;
}
/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */
const migratedRoutes = new Set<string>();
export function createProfileSections(deps: ProfileSectionsDeps) {
const { projectId, state, viewer } = deps;
function restorePanel(next: RouteLatestResponse): void {
deps.panel().restore(panelValuesOf(next));
viewer.markers.setPoints(restorePoints(next));
}
function renderStationLines(detail: SectionDetailResponse): void {
const lines = stationLinesOf({
detail,
irregular: deps.bridge().irregularStations(),
routePoints: state.latest?.route_points ?? [],
liveSamples: deps.profilePanel().alignmentSamples(),
corridorVisible: state.corridorVisible,
});
viewer.renderStationLines(lines, state.roadWidths[deps.panel().values().gradeClass] / 2);
}
/** 지금 종단면 자료로 측점선만 다시 그린다(자료가 없으면 그대로). */
function redrawStationLines(): void {
if (state.detail) renderStationLines(state.detail);
}
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
const panel = deps.panel();
const profilePanel = deps.profilePanel();
state.detail = detail;
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
// 저장된 계획선의 정책은 만들 당시 기준이라 지금 설정(임도 종류·설계속도·지형)과
// 다를 수 있다 — 그릴 때마다 현재 기준으로 맞춘다(2026-08-19).
profilePanel.setGradeLimit(panel.gradeLimitPct());
profilePanel.setEnforceMinCover(panel.values().enforcePipeClearance);
profilePanel.setStationDisplay(panel.stationDisplayOffset());
profilePanel.setIrregularStations(deps.bridge().irregularStations());
renderStationLines(detail);
// 예상형상 코리도 — 저장본이 지금 정본과 맞을 때만 올린다. 낡았으면 [3D 업데이트] 대기
// 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 만드느라 전환이 16.7초까지 갔다).
const routePoints = state.latest?.route_points ?? [];
if (routePoints.length > 1) {
void loadCorridorIfFresh(
viewer,
projectId,
routeId ?? state.latest?.route?.id,
detail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
).then((fresh) => setCorridorPending(!fresh));
}
}
async function restoreSections(routeId: number): Promise<void> {
const profilePanel = deps.profilePanel();
let detail: SectionDetailResponse;
profilePanel.setLoading("종단면 자료를 불러오는 중…");
try {
// 공유 캐시 — 횡단 보기가 이미 받아 뒀으면 같은 객체를 재사용해 두 보기가 같은 값을 본다.
detail = await loadSectionDetail(projectId, routeId);
} catch {
// 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다.
state.detail = null;
profilePanel.setLoading(null);
profilePanel.clear();
viewer.renderStationLines([], 0);
return;
}
profilePanel.setLoading(null);
followSwap(routeId);
try {
renderSections(detail, routeId);
// 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측).
const legacy = detail.longitudinal.stations
.filter((station) => station.kind === "irregular")
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure ?? "",
}));
const migrateKey = `${projectId}:${routeId}`;
if (legacy.length && !migratedRoutes.has(migrateKey)) {
migratedRoutes.add(migrateKey);
void migrateLegacyStations(projectId, legacy)
.then((result) => {
if (result.migrated > 0) {
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
return deps
.bridge()
.refreshFromServer()
.then(() => undefined);
}
return undefined;
})
.catch(() => {
/* 이관 실패는 치명적이지 않다 — 다음 진입에서 다시 시도된다. */
});
}
} catch (error) {
// 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다 — 실패 사실을 드러낸다.
console.error("B05 종단면도 렌더 실패", error);
showToast(
error instanceof Error
? `종단면도 표시 실패: ${error.message}`
: "종단면도 표시에 실패했습니다.",
"error",
);
}
}
/* 측점 수가 바뀜(346-2 · 묶음 측점 → 횡단 쪽이 상세를 갈아끼움) — 종단도 · 표 · 3D 측점선을 새 목록으로
* 다시(보일 때만 · 숨었으면 다시 보일 때 `flushSwap`). 노선마다 한 번 듣고, 노선이 바뀌면 옛 것을 끊음. */
let swap: { routeId: number; stop: () => void } | null = null;
let swapPending = false;
function followSwap(routeId: number): void {
if (swap?.routeId === routeId) return;
swap?.stop();
const stopListen = subscribeSectionDetail(projectId, routeId, (next) => {
state.detail = next;
swapPending = !(deps.visible?.() ?? true);
if (!swapPending) renderSections(next, routeId);
});
// 묶음 측점(가게)이 바뀌면 갈아끼움 — 횡단 보기를 안 열었어도(그쪽도 부르면 둘째는 「같은 목록」)
const stopStore = useDesignStore(projectId, null).subscribe("stations", (stations) => {
if (stations?.length) void swapSectionStations(projectId, routeId, stations);
});
swap = {
routeId,
stop: () => {
stopListen();
stopStore();
},
};
}
/** 숨은 동안 받은 새 상세를 그림 — 종단 보기가 다시 보일 때. */
function flushSwap(): void {
if (!swapPending || !state.detail || !swap) return;
swapPending = false;
renderSections(state.detail, swap.routeId);
}
/** 「3D 늦음」 — [3D 업데이트] 표시(setCorridorPending 그대로)와 상태 한 칸을 같이 맞춘다. */
function setCorridorPending(pending: boolean): void {
state.corridorPending = pending;
deps.panel().setCorridorPending(pending);
}
function renderLatest(next: RouteLatestResponse): void {
state.latest = next;
state.routeReady = Boolean(next.route && next.route_points.length > 1);
deps.profilePanel().setRoutePolyline(next.route_points ?? []);
if (next.route) {
const stored = next.route.algorithm_params ?? {};
viewer.markers.renderRoute(
next.route_points,
(stored.curve_warning_segments as Array<{
polyline_start_index: number;
polyline_end_index: number;
}>) ?? [],
);
}
}
return {
restorePanel,
renderStationLines,
redrawStationLines,
renderSections,
restoreSections,
renderLatest,
setCorridorPending,
flushSwap,
stopSwap: () => swap?.stop(),
};
}
+366
View File
@@ -0,0 +1,366 @@
/* =============================================================================
* A00_Common/design/logic/design_pipeline.ts
* 실시간 계산 묶음 하나(PLAN 344-4) — 조작마다 차례 번호(generation) +1 · 단계 사이마다 번호
* 확인 · 옛 번호 결과 버림 · 뒤 단계 요청은 AbortController 로 끊음. 계약(키 모양 · 단계 · deps)
* = `design_pipeline_types.ts` 머리 주석.
*
* const pipe = createDesignPipeline({ stages: { route, stations, ... } });
* pipe.subscribe(publishToStore(useDesignStore(projectId, null)));
* pipe.start(inputs); // 처음 · 전 구간
* pipe.update("grade", edits, { fromM: 40, toM: 60 }); // 조작 · 바뀐 범위
*
* 단계 시간 = `window.__aisloPipeline.runs`(마지막 RUN_LOG_SIZE 번 · 단계별 ms · 개발용).
* 화면 · fetch · 세션은 단계 함수(주입) 몫 — 이 파일은 차례 · 버림 · 범위 · 기록만.
* ========================================================================== */
import type { DesignStore } from "../design_store";
import type {
ChangeRange,
ChangeSource,
FollowPatch,
InputKey,
PipelineInputs,
PipelineOutputs,
PipelineRunRecord,
PipelineStages,
RangePart,
StageContext,
StageName,
} from "./design_pipeline_types";
import { SOURCE_INPUT } from "./design_pipeline_types";
/** 브라우저 즉시 — 차례대로 기다림. */
export const BROWSER_STAGES: StageName[] = [
"route",
"follow",
"stations",
"ground",
"planline",
"profile",
"cross",
"footprint",
"masshaul",
];
/** 뒤 — 브라우저 단계가 끝나면 함께 띄우고 기다리지 않음. */
export const AFTER_STAGES: StageName[] = ["haul", "drainage", "corridor"];
type Dep = StageName | `input.${InputKey}`;
/** 단계가 기대는 값 — 이 판들이 그대로면 다시 안 셈(계약 표와 같음). */
export const STAGE_DEPS: Record<StageName, Dep[]> = {
route: ["input.route"],
follow: ["route"],
stations: ["route", "input.pipes", "input.structures"],
ground: ["stations"],
planline: ["ground"],
profile: ["planline", "input.alignment"],
cross: ["profile", "ground", "input.cross", "input.pipes", "input.structures"],
footprint: ["cross", "ground"],
masshaul: ["cross"],
haul: ["masshaul"],
drainage: ["route", "input.pipes"],
corridor: ["profile", "cross"],
};
/** follow 가 고칠 수 있는 입력 — 노선 · 횡단은 안 됨(고리). */
const FOLLOW_KEYS: ReadonlyArray<keyof FollowPatch> = ["pipes", "structures", "alignment"];
/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단 · 유토곡선이 그다음. */
const YIELD_AFTER = new Set<StageName>(["profile"]);
export const RUN_LOG_SIZE = 20;
export type PipelineEvent =
| { kind: "generation"; generation: number }
| { kind: "stage"; stage: StageName; value: unknown; generation: number };
export interface DesignPipeline {
/** 처음 · 전 구간 — 입력 다섯을 한 번에. */
start(inputs: PipelineInputs): Promise<PipelineRunRecord>;
/** 조작 하나 — 값이 같으면 안 셈(null). part 없으면 전 구간. */
update(
source: ChangeSource,
value: unknown,
part?: RangePart | null,
): Promise<PipelineRunRecord> | null;
subscribe(fn: (event: PipelineEvent) => void): () => void;
output<K extends StageName>(name: K): PipelineOutputs[K] | null;
/** 지금 입력(follow 가 고친 뒤 값 포함 · 읽기만). */
input<K extends InputKey>(key: K): PipelineInputs[K];
readonly generation: number;
/** 단계 · 알림 안에서 들어온 입력 쓰기(고리)를 막은 수. */
readonly blockedWrites: number;
/** 떠 있는 뒤 단계가 다 끝날 때까지. */
settled(): Promise<void>;
/** 페이지를 떠남 — 떠 있는 계산 · 요청을 버림. */
dispose(): void;
}
type Outs = { [S in StageName]: PipelineOutputs[S] | null };
function emptyOuts(): Outs {
const out = {} as Record<StageName, null>;
[...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => (out[name] = null));
return out as Outs;
}
/** 두 범위 합침 — null(전 구간)이 이김 · 거리는 앞 ~ 뒤. */
export function mergeRange(
a: ChangeRange | null | undefined,
b: ChangeRange | null,
): ChangeRange | null {
if (a === undefined) return b;
if (a === null || b === null) return null;
// ponytail: 떨어진 두 범위도 앞 ~ 뒤 하나로 — 사이까지 셈. 구간 목록은 느릴 때.
return {
sources: [...new Set([...a.sources, ...b.sources])],
fromM: Math.min(a.fromM, b.fromM),
toM: Math.max(a.toM, b.toM),
keys: [...new Set([...a.keys, ...b.keys])],
};
}
const now = (): number => (typeof performance !== "undefined" ? performance.now() : Date.now());
function runLog(): { runs: PipelineRunRecord[] } {
const root = globalThis as { __aisloPipeline?: { runs: PipelineRunRecord[] } };
root.__aisloPipeline ??= { runs: [] };
return root.__aisloPipeline;
}
export function createDesignPipeline(options: { stages: PipelineStages }): DesignPipeline {
const stages = options.stages;
const inputs: PipelineInputs = {
route: null,
alignment: null,
cross: null,
pipes: null,
structures: null,
};
const inputVer: Record<InputKey, number> = {
route: 0,
alignment: 0,
cross: 0,
pipes: 0,
structures: 0,
};
const out = emptyOuts();
const outVer = {} as Record<StageName, number>;
const lastSig = {} as Partial<Record<StageName, string>>;
/** 마지막으로 끝난 뒤 쌓인 범위 — 없음(undefined) · 전 구간(null) · 범위. */
const pending = {} as Partial<Record<StageName, ChangeRange | null>>;
[...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => {
outVer[name] = 0;
pending[name] = null;
});
const listeners = new Set<(event: PipelineEvent) => void>();
const inFlight = new Set<Promise<unknown>>();
let generation = 0;
let controller = new AbortController();
/** 단계 함수 · 알림을 부르는 동안(동기) — 이때 들어온 입력 쓰기는 고리라 막음. */
let busy = false;
let blockedWrites = 0;
const emit = (event: PipelineEvent): void => {
busy = true;
try {
listeners.forEach((fn) => fn(event));
} finally {
busy = false;
}
};
const sigOf = (name: StageName): string =>
STAGE_DEPS[name]
.map((dep) =>
dep.startsWith("input.") ? inputVer[dep.slice(6) as InputKey] : outVer[dep as StageName],
)
.join(",");
const markAll = (range: ChangeRange | null): void => {
[...BROWSER_STAGES, ...AFTER_STAGES].forEach((name) => {
pending[name] = mergeRange(pending[name], range);
});
};
const applyFollow = (patch: FollowPatch | null): void => {
if (!patch) return;
for (const key of Object.keys(patch) as Array<keyof FollowPatch>) {
if (!FOLLOW_KEYS.includes(key)) {
console.warn(`[pipeline] follow 가 고칠 수 없는 입력 — 버림: ${key}`);
continue;
}
inputs[key] = patch[key] as never;
inputVer[key] += 1;
}
};
/** 한 단계 — 같은 판이면 건너뜀 · 옛 번호면 버림 · 아니면 출력 내보냄. */
async function step(
name: StageName,
gen: number,
signal: AbortSignal,
record: PipelineRunRecord,
before: Outs,
): Promise<"ok" | "dropped" | "error"> {
const sig = sigOf(name);
const fn = stages[name] as ((ctx: StageContext<StageName>) => unknown) | undefined;
if (!fn || sig === lastSig[name]) {
record.skipped.push(name);
if (fn) delete pending[name];
return "ok";
}
const ctx: StageContext<StageName> = {
generation: gen,
range: pending[name] ?? null,
signal,
inputs: { ...inputs },
out: { ...out },
before,
prev: out[name],
};
const t0 = now();
let value: unknown;
try {
busy = true;
let pendingValue: unknown;
try {
pendingValue = fn(ctx);
} finally {
busy = false;
}
value = await pendingValue;
} catch (error) {
if (gen !== generation) return "dropped";
console.error(`[pipeline] ${name} 단계 실패`, error);
return "error";
}
if (gen !== generation) return "dropped";
record.stages[name] = Math.round((now() - t0) * 100) / 100;
if (name === "follow") applyFollow(value as FollowPatch | null);
(out as Record<StageName, unknown>)[name] = value;
outVer[name] += 1;
lastSig[name] = sig;
delete pending[name];
emit({ kind: "stage", stage: name, value, generation: gen });
return "ok";
}
async function run(): Promise<PipelineRunRecord> {
const gen = generation;
const signal = controller.signal;
const record: PipelineRunRecord = {
generation: gen,
sources: [],
stages: {},
skipped: [],
totalMs: 0,
outcome: "running",
};
// 같은 틱의 조작은 마지막 하나만 셈.
await Promise.resolve();
if (gen !== generation) {
record.outcome = "dropped";
return record;
}
record.sources = [...new Set(Object.values(pending).flatMap((range) => range?.sources ?? []))];
const log = runLog();
log.runs.push(record);
if (log.runs.length > RUN_LOG_SIZE) log.runs.splice(0, log.runs.length - RUN_LOG_SIZE);
const before = { ...out };
const t0 = now();
for (const name of BROWSER_STAGES) {
const result = await step(name, gen, signal, record, before);
if (result !== "ok") {
record.outcome = result;
return record;
}
if (YIELD_AFTER.has(name) && record.stages[name] !== undefined) {
await new Promise((resolve) => setTimeout(resolve, 0));
if (gen !== generation) {
record.outcome = "dropped";
return record;
}
}
}
record.totalMs = Math.round((now() - t0) * 100) / 100;
record.outcome = "done";
for (const name of AFTER_STAGES) {
const task = step(name, gen, signal, record, before);
inFlight.add(task);
void task.finally(() => inFlight.delete(task));
}
return record;
}
function begin(range: ChangeRange | null): Promise<PipelineRunRecord> {
generation += 1;
controller.abort();
controller = new AbortController();
markAll(range);
emit({ kind: "generation", generation });
return run();
}
return {
start(next) {
(Object.keys(inputs) as InputKey[]).forEach((key) => {
inputs[key] = next[key] as never;
inputVer[key] += 1;
});
return begin(null);
},
update(source, value, part = null) {
if (busy) {
// 파생(단계 · 알림)이 입력에 쓰는 길 — 고리라 막음. 예외는 follow 반환값뿐.
blockedWrites += 1;
console.warn(`[pipeline] 계산 · 알림 안에서 입력 쓰기 막음: ${source}`);
return null;
}
const key = SOURCE_INPUT[source];
if (JSON.stringify(inputs[key]) === JSON.stringify(value)) return null;
inputs[key] = value as never;
inputVer[key] += 1;
const range = part
? { sources: [source], fromM: part.fromM, toM: part.toM, keys: part.keys ?? [] }
: { sources: [source], fromM: 0, toM: Infinity, keys: [] };
return begin(range);
},
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
output(name) {
return out[name] as never;
},
input(key) {
return inputs[key];
},
get generation() {
return generation;
},
get blockedWrites() {
return blockedWrites;
},
async settled() {
while (inFlight.size) await Promise.allSettled([...inFlight]);
},
dispose() {
generation += 1;
controller.abort();
listeners.clear();
},
};
}
/** 가게 잇기 — 측점(ground 단계 · 지반 채움) · 종단 · 평면 발자국 · 차례 번호를 가게 키로. */
export function publishToStore(store: DesignStore): (event: PipelineEvent) => void {
return (event) => {
if (event.kind === "generation") return store.set("generation", event.generation);
if (event.stage === "ground") {
store.set("stations", (event.value as PipelineOutputs["ground"] | null)?.stations ?? null);
} else if (event.stage === "profile") {
store.set("profile", event.value as PipelineOutputs["profile"] | null);
} else if (event.stage === "footprint") {
store.set("footprint", event.value as PipelineOutputs["footprint"] | null);
}
};
}
@@ -0,0 +1,56 @@
/* =============================================================================
* A00_Common/design/logic/design_pipeline_stations.ts
* 계산 묶음 stations 단계(PLAN 346-1) — 측점 계산은 `B05_Profile/logic/B05_Profile_Sections_Stations.ts`
* (서버 `generate_sections` 짝) · 여기는 단계 꼴 · 범위 밖 앞 값 재사용만.
* ========================================================================== */
import {
buildStations,
REUSE_MARGIN_M,
type StationsOptions,
} from "../../../B05_Profile/logic/B05_Profile_Sections_Stations";
import type {
PlanPipe,
PlanStructure,
PlanType,
} from "../../../common_util/common_util_structure_station";
import type { PipelineStation, StageContext, StageFn } from "./design_pipeline_types";
/** 범위 밖 측점은 앞 값(같은 열쇠 · 같은 자리)을 그대로 — 뒤 단계가 같은 객체로 「안 바뀜」을 앎. */
export function reuseOutside(
next: PipelineStation[],
prev: ReadonlyArray<PipelineStation> | null,
range: { fromM: number; toM: number } | null,
): PipelineStation[] {
if (!prev || !range) return next;
const old = new Map(prev.map((station) => [station.key, station]));
return next.map((station) => {
const inside =
station.chainageM >= range.fromM - REUSE_MARGIN_M &&
station.chainageM <= range.toM + REUSE_MARGIN_M;
const before = old.get(station.key);
return !inside && before && Math.abs(before.chainageM - station.chainageM) < 1e-9
? before
: station;
});
}
/** stations 단계 — 입력 관 · 구조물(follow 뒤 누가거리) · 그린 노선 · 곡선표 · 노선 초안 스냅. */
export function createStationsStage(
options: Omit<StationsOptions, "stationSnap" | "types"> & {
types: () => ReadonlyArray<PlanType>;
},
): StageFn<"stations"> {
return (ctx: StageContext<"stations">) => {
const route = ctx.out.route;
if (!route) return [];
const next = buildStations(
route.line,
route.curves,
(ctx.inputs.pipes as PlanPipe[] | null) ?? [],
(ctx.inputs.structures as PlanStructure[] | null) ?? [],
{ ...options, types: options.types(), stationSnap: ctx.inputs.route?.stationSnap ?? false },
);
return reuseOutside(next, ctx.prev, ctx.range);
};
}
@@ -0,0 +1,281 @@
/* =============================================================================
* A00_Common/design/logic/design_pipeline_types.ts
* 실시간 계산 묶음 **계약**(PLAN 344-4) — 키별 모양 · 단계 입력 · 출력. 다른 창(sub1 · sub2 ·
* sub3 · sub6 · sub8)은 이 모양대로 단계 함수를 만들어 `createDesignPipeline({ stages })` 에 넣음.
* 계산 묶음 본체 = `design_pipeline.ts`.
*
* 흐름 — 한 방향:
* 입력 route(노선 초안) · alignment(계획고) · cross(횡단 입력) · pipes(관) · structures(구조물)
* ↓ update(출처, 값, 범위) — 조작마다 차례 번호(generation) +1 · 옛 계산 · 옛 요청 버림
* 브라우저 즉시(차례대로 · 단계 사이마다 번호 확인 · 단계마다 결과 바로 내보냄)
* route → follow → stations → ground → planline → profile → cross → footprint → masshaul
* 뒤(기다리지 않음 · 새 조작이 오면 AbortSignal 로 끊김 · 옛 번호 결과 버림)
* haul(흙 배분 · 서버 Node) · drainage(배수유역 「낡음」 표시) · corridor(3D 「낡음」 표시)
*
* 규칙:
* · 단계는 자기 출력만 돌려줌 — 입력 · 다른 단계 출력을 고치지 않음(ctx 의 값은 읽기만).
* · 예외 하나 = follow — 노선이 바뀐 차례에만 · pipes · structures · alignment 를 한 번 고쳐
* 돌려줌(관 · 구조물 좌표 따라감 · 계획고 옮김). 묶음이 입력에 넣고 다음 단계로 감.
* · 단계 함수가 없으면(아직 안 붙음) 건너뜀 — 출력은 앞 값 그대로(처음엔 null).
* · 기대는 값(deps)의 판이 그대로면 다시 안 셈(같은 입력 다시 안 셈).
* · range = 마지막으로 이 단계가 끝난 뒤 쌓인 바뀐 범위 전부 — 범위만 셈 · 범위 밖은 ctx.prev 재사용.
*
* 단계 · 주인 · 입력(deps) → 출력:
* | 단계 | 주인 | deps | 출력 |
* | route | sub8 | input.route | RouteLine |
* | follow | sub1 | route | FollowPatch(입력 고침) |
* | stations | sub7 | route · input.pipes · input.structures | PipelineStation[](지반 null) |
* | ground | sub8 | stations | GroundOutput |
* | planline | sub1 | ground | AlignmentBase(첫 계획선) |
* | profile | sub1 | planline · input.alignment | ProfileAlignment |
* | cross | sub2 | profile · ground · input.cross · pipes · structures | CrossOutput |
* | footprint | sub6 | cross · ground | PlanFootprint |
* | masshaul | sub2 | cross | unknown(sub2 모양) |
* | haul | sub2 | masshaul | unknown(서버 응답) |
* | drainage | sub3 | route · input.pipes | 「낡음」 표시(출력 안 씀) |
* | corridor | sub5 | profile · cross | 「낡음」 표시(출력 안 씀) |
*
* 가게 키(`design_store.ts`) — routeDraft(입력 · 세션 즉시) · stations(ground 단계 출력) ·
* profile · footprint(메모리 우선 · 멈춘 뒤 한 번 세션) · generation(메모리만).
*
* 측점 열쇠(344-3 · `stationKey` · `B05_Profile/logic/B05_Profile_Sections_Stations.ts`):
* | 측점 | 열쇠 | 뜻 |
* | 일반(격자) | grid:k | k = 원래 격자 번호(누가거리 ÷ 측점 간격 반올림) · 노선 모양이 바뀌어도 같은 번호 · 스냅으로 옮긴 측점은 원래 번호 |
* | 관 | pipe:<id> | 관 세션 id(없으면 `pipe-{누가거리 2자리}`) |
* | 구조물 | structure:<id> | structure_id · 구간형 시작 · 끝 = `:start` · `:end` 덧붙임 |
* | 시종점 | end:BP · end:EP | 그 자리 관 · 구조물이 있어도 시종점(서버 bp · ep 와 같음) |
* · 같은 m(0.005) 관 · 구조물이 겹치면 관 열쇠 하나 · 격자 자리에 얹히면 관 · 구조물 열쇠(격자는 숨음).
* · 누가거리를 글로 쓸 때(초안 칸 · 서버 이월) 자릿수 0.01 m 하나 — `stationText`.
* · 세션 횡단 초안 · 서버 이월을 이 열쇠로 바꾸는 일 = sub2(344-3 나머지).
* ========================================================================== */
import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
/** 평면 좌표(m) — 도면 좌표계 x · y. */
export type Vertex = [number, number];
/* ── 입력 ───────────────────────────────────────────────────────────────── */
/** 노선 초안 — 캐시 칸 `route-draft`(draft · project 범위) · [저장] 때 서버로 · 노선 편집기가 씀. */
export interface RouteDraft {
/** 놓을 때마다 +1 — 같은 초안인지 보는 판 번호. */
rev: number;
/** 노드(시점 · 교각점 · 종점) — 사용자가 끄는 꺾임점. 원호는 route 단계가 만듦. */
nodes: Vertex[];
/** 꺾임점마다 곡선 둠 · 못박은 반경(m · null = 기본) · 붙든 값 · 붙든 곡선 길이(m). 길이 = nodes. */
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: Array<"radius" | "arc" | null>;
curveArc: Array<number | null>;
/** [측점 스냅](요구 224-2) — 켜면 호 안 측점 하나를 곡선 중점으로. */
stationSnap: boolean;
/** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */
terrain: string | null;
}
/** 묶음이 받는 입력 다섯. 없으면 null(= 저장분 그대로). cross · pipes · structures 모양은 주인 몫. */
export interface PipelineInputs {
route: RouteDraft | null;
alignment: AlignmentEdits | null;
/** 횡단 입력(crossdesign · cutslope · berm · std-cross 등 초안 묶음) — 모양 = sub2. */
cross: unknown;
/** 관 목록(`DetailPipeInput[]`) — 좌표가 정본 · 누가거리는 stations 가 다시 매김. */
pipes: unknown;
/** 구조물 목록(`StructureInstance[]`) — 위와 같음. */
structures: unknown;
}
export type InputKey = keyof PipelineInputs;
/** 조작 출처 — 노선 · 계획고 · 횡단 · 관 · 구조물. */
export type ChangeSource = "route" | "grade" | "cross" | "pipe" | "structure";
/** 출처 → 입력 칸. */
export const SOURCE_INPUT: Record<ChangeSource, InputKey> = {
route: "route",
grade: "alignment",
cross: "cross",
pipe: "pipes",
structure: "structures",
};
/**
* 바뀐 범위(누가거리 m) — 부르는 쪽이 정함:
* 노선 = 처음 바뀐 접선점부터 끝(toM = Infinity) · 계획고 = 이웃 꼭짓점 사이 · 횡단 = 그 측점(fromM = toM).
* 여러 조작이 쌓이면 합침(가장 앞 ~ 가장 뒤 · keys 는 모음). null = 전 구간.
*/
export interface ChangeRange {
sources: ChangeSource[];
fromM: number;
toM: number;
/** 측점 키로 정한 범위(횡단 조작) — 비면 거리로만. */
keys: string[];
}
/** update 가 받는 범위 한 조각 — sources 는 출처로 채움. */
export interface RangePart {
fromM: number;
toM: number;
keys?: string[];
}
/* ── 출력 ───────────────────────────────────────────────────────────────── */
/** route 단계 — 원호가 섞인 그린 노선(`_RouteEdit_Curve` 짝) · 누가거리. */
/** 설계 곡선표 한 줄 — 서버 `RoutePlanCurve` · `EditedCurve` 와 같은 칸(반경 · 바깥쪽 · 스냅 재료). */
export interface DesignCurve {
start: Vertex;
apex: Vertex;
end: Vertex;
radius_m: number;
}
export interface RouteLine {
line: Vertex[];
/** 설계 곡선표(없으면 빈 칸 = 측점마다 반경을 잼 · 서버와 같음). */
curves: DesignCurve[];
/** line 정점마다 누가거리(m). */
chainage: number[];
lengthM: number;
}
/** follow 단계 — 노선이 바뀐 차례에 한 번만 입력을 고침. 안 고치는 칸은 비움. */
export interface FollowPatch {
pipes?: unknown;
structures?: unknown;
alignment?: AlignmentEdits;
}
/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점). */
export type StationKind = "grid" | "structure" | "pipe" | "end";
/** 횡단 지반 표본 한 점 — 중심에서 거리(m · 좌 +) · 높이(null = 빈칸). */
export interface GroundSample {
offsetM: number;
z: number | null;
}
export interface StationGround {
centerZ: number | null;
/** 중심 → 바깥 차례 · offsetM 은 둘 다 양수. */
left: GroundSample[];
right: GroundSample[];
}
/** 측점 한 자리 — stations 가 만들고 ground 가 지반을 채움. 가게 키 `stations`. */
export interface PipelineStation {
/** 측점 열쇠 — 머리 표(grid:k · pipe:<id> · structure:<id> · end:BP/EP). */
key: string;
chainageM: number;
x: number;
y: number;
/** 진행 방향(도 · 북 0 시계) · 좌측 단위 벡터(`_RouteEdit_Frame` 짝). */
azimuthDeg: number;
left: Vertex;
kind: StationKind;
/** 구조물 · 관 id — 격자 · 끝은 null. */
id: string | null;
/** ground 단계 전엔 null. */
ground: StationGround | null;
/** 평면 곡선반경(m · 직선 null) · 확폭이 붙는 바깥쪽 · 확폭량(m). */
planRadiusM: number | null;
curveOuterSide: "left" | "right" | null;
curveWideningM: number | null;
}
/** ground 단계 — 지반 채운 측점 · 종단 지반선(1 m). */
export interface GroundOutput {
stations: PipelineStation[];
longitudinal: { chainageM: number[]; z: Array<number | null> };
}
/** cross 단계 — 측점 키 → 그 측점 결과(면적은 유토곡선 재료 · detail 모양 = sub2). */
export interface CrossStationResult {
key: string;
chainageM: number;
cutAreaM2: number;
fillAreaM2: number;
detail: unknown;
}
export interface CrossOutput {
byKey: Record<string, CrossStationResult>;
}
/** footprint 단계 — 평면 발자국(도로 끝 · 비탈 끝) · 시점 → 종점 차례 · 가게 키 `footprint`. */
export interface PlanFootprint {
roadLeft: Vertex[];
roadRight: Vertex[];
slopeLeft: Vertex[];
slopeRight: Vertex[];
/** 비탈 끝 점마다 깎기(true) · 쌓기 — slopeLeft · slopeRight 와 같은 차례(그리기가 색을 가름). */
slopeLeftCut: boolean[];
slopeRightCut: boolean[];
/** 비탈 표시 짧은 선 — [도로 끝 점, 비탈 쪽 점] 묶음. */
ticks: number[][][];
/** 측점마다 원자료(측점 열쇠 · 누가거리 · 좌우 도로 끝 · 비탈 끝 m · 깎기) — 범위 밖 재사용 ·
* 모양 = `B05_Profile/logic/B05_Profile_PlanFootprint.ts` `FootprintRecord`(sub6). */
records: Array<{ key?: string; chainage_m: number }>;
}
/** 단계 이름 → 출력. drainage · corridor 는 표시만 하므로 출력이 없음(null). */
export interface PipelineOutputs {
route: RouteLine;
follow: FollowPatch;
stations: PipelineStation[];
ground: GroundOutput;
planline: AlignmentBase;
profile: ProfileAlignment;
cross: CrossOutput;
footprint: PlanFootprint;
masshaul: unknown;
haul: unknown;
drainage: null;
corridor: null;
}
export type StageName = keyof PipelineOutputs;
/* ── 단계 함수 ──────────────────────────────────────────────────────────── */
export interface StageContext<K extends StageName> {
/** 이 계산의 차례 번호 — 다르면 이미 버려진 계산. */
generation: number;
/** 마지막으로 이 단계가 끝난 뒤 쌓인 바뀐 범위(null = 전 구간 · 처음). */
range: ChangeRange | null;
/** 새 조작이 오면 끊김 — fetch 에 그대로 넘김. */
signal: AbortSignal;
/** 입력(읽기만). */
inputs: Readonly<PipelineInputs>;
/** 앞 단계 출력(이번 계산 것 · 없으면 null · 읽기만). */
out: Readonly<{ [S in StageName]: PipelineOutputs[S] | null }>;
/** 이번 계산 시작 때 출력 — follow 가 옛 노선(`before.route`)과 새 노선을 견줌. */
before: Readonly<{ [S in StageName]: PipelineOutputs[S] | null }>;
/** 이 단계 앞 출력 — 범위 밖을 재사용. */
prev: PipelineOutputs[K] | null;
}
export type StageFn<K extends StageName> = (
ctx: StageContext<K>,
) => PipelineOutputs[K] | Promise<PipelineOutputs[K]>;
export type PipelineStages = { [K in StageName]?: StageFn<K> };
/* ── 시간 기록(window.__aisloPipeline · 개발용) ─────────────────────────── */
export interface PipelineRunRecord {
generation: number;
sources: ChangeSource[];
/** 단계 → ms(뒤 단계는 끝난 때 채움 · 건너뜀은 안 적음). */
stages: Partial<Record<StageName, number>>;
/** 건너뛴 단계(같은 입력 · 함수 없음). */
skipped: StageName[];
/** 브라우저 단계 합 ms. */
totalMs: number;
/** done = 브라우저 단계 끝 · dropped = 새 조작에 밀려 버림 · error = 단계가 던짐. */
outcome: "running" | "done" | "dropped" | "error";
}
@@ -1,5 +1,5 @@
/* =============================================================================
* A00_Common/design/design_profile_free.ts
* A00_Common/design/logic/design_profile_free.ts
* 종단 [자유지정] 계산 — 범위 · 클릭 점 · 측점선 붙음 · 높이 변환 · 기울기 판정 · 적용(215-1).
*
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
@@ -17,16 +17,16 @@ import type {
AlignmentBase,
AlignmentEdits,
ProfileAlignment,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import {
buildAlignment,
chainageKey,
controlElevationAt,
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
import {
baseElevationAt,
defaultCurveLength,
} from "../../B05_Profile/B05_Profile_UI_Profile_Straighten";
} from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten";
/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */
const SAME_M = 1e-6;
@@ -2,13 +2,16 @@
* design_structure_ford_pavement.ts
* 횡단배수 › 물넘이 포장 명세 — 사용자 구조물 표 03(`docs/raw/구조물정의_표/03_횡단배수_물넘이포장.md`) 그대로.
*
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 구조 = 도수로 · 돌붙임).
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_structure` ·
* 컨테이너 둘: 경사각도(필수 · 각도 손 입력 · 도 + 월류 폭 · 높이 · 65-5) · 도수로/돌붙임(선택 · 다른 구조물과
* 같은 칸 `CHUTE_ITEMS` + 맨 아래 길이 · 286-3).
* 저장 = pipe_points.json `options` — 새 키 `ford_slope_deg` · 선택 컨테이너 `ford_chute_{n}_*` ·
* 월류 폭 · 높이 = 세월교와 같은 키(`ford_width_m` · `ford_height_m`) · 같은 계산(`design_structure_ford_calc`).
* 경사각도는 옛 「바닥 경사(%)」(`ford_slope_pct` · B06 파임 기울기)와 뜻이 같은지 또렷하지 않아 잇지 않음.
* import 없음 — 시험이 이 파일만 옮겨 돌림.
* 길이(`ford_chute_{n}_length_m`) = 횡단 · 3D 띠를 사면 따라 그릴 길이 — 그림 전용(수량 · 내역 · 일위대가에 안 씀) ·
* 비면 원지반까지.
* ========================================================================== */
import { CHUTE_ITEMS } from "./design_structure_guard";
import type { StructureSpec } from "./design_structure_spec";
export const FORD_PAVEMENT_SPEC: StructureSpec = {
@@ -32,20 +35,8 @@ export const FORD_PAVEMENT_SPEC: StructureSpec = {
prefix: "ford_chute",
link: "gfordpavechute",
max: 1,
// 구조(기본 도수로) · 두께(cm · 기본 45 · 173-5) — 배관 · 박스 · 세월교 `CHUTE_ITEMS` 와 같은 칸(90-2)
items: [
{
id: "structure",
label: "구조",
kind: "select",
choices: [
{ value: "도수로", label: "도수로" },
{ value: "돌붙임", label: "돌붙임" },
],
default: "도수로",
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
],
// 배관 · 박스 · 세월교와 같은 칸(구조 · 형태 · 두께 · 면적 · 맨 아래 길이 — 그림 전용 · 286-3 · 294-4)
items: CHUTE_ITEMS,
},
],
// 표 03 에 없는 옛 칸 — 화면에서 빼고 값은 남김(지난 정의 값)
@@ -144,6 +144,8 @@ export const CHUTE_ITEMS: SpecItem[] = [
},
{ id: "thickness", label: "두께 (cm)", kind: "number", step: 1, default: "45" },
{ id: "area", label: "면적 (㎡)", kind: "number", step: 0.1, default: "30" },
// 길이(294-4 · `{접두}_{n}_length_m`) — 띠를 사면 따라 그릴 길이 · 그림 전용(수량 · 내역 · 일위대가에 안 씀)
{ id: "length_m", label: "길이 (m)", kind: "number", step: 0.1 },
];
/** 도수로/돌붙임(선택 · 하나만 · 54-48) — 옛 둘 이상은 보이되 [추가]만 숨김. */
@@ -18,6 +18,7 @@ import { BOX_SPEC } from "./design_structure_box";
import { FORD_SPEC } from "./design_structure_ford";
import { FORD_PAVEMENT_SPEC } from "./design_structure_ford_pavement";
import { ETC_SPEC, PAVEMENT_SPEC } from "./design_structure_road";
import { baseType } from "../../../common_util/common_util_structure_catalog";
const SOIL = "기슭막이"; // 저장 값(옛 키 그대로) — 화면 이름은 흙막이
const BASIN = "집수정";
@@ -116,3 +117,7 @@ export const STRUCTURE_SPECS: Readonly<Record<string, StructureSpec>> = {
pavement_concrete: PAVEMENT_SPEC,
etc: ETC_SPEC,
};
/** 그 종류의 명세 — 복사한 종류는 본보기 명세(323-7 · `baseType`). 화면은 이것으로 고름(종류 키 직접 안 씀). */
export const specFor = (typeId: string): StructureSpec | undefined =>
STRUCTURE_SPECS[baseType(typeId)];
@@ -120,6 +120,23 @@ export const linkOf = (c: SpecContainer, n: number): string =>
const text = (value: unknown): string =>
value === undefined || value === null ? "" : String(value);
/** 항목이 쓰는 정의 옵션 키 — 필수 상자 저장 키 · 갈래 키(선택 상자 인스턴스 키는 옵션 밖 · 322-2 보임). */
export const specItemKeys = (item: SpecItem): string[] =>
[item.key, ...Object.values(item.by?.map ?? {}).map((b) => b.key)].filter(
(key): key is string => !!key,
);
/** 숨긴 항목인가 — 쓰는 키가 있고 모두 숨김(정의 「표시」 · 322-2). */
export const specItemHidden = (item: SpecItem, hidden: ReadonlySet<string>): boolean => {
const keys = specItemKeys(item);
return keys.length > 0 && keys.every((key) => hidden.has(key));
};
/** 숨긴 상자인가 — 키 있는 항목이 하나 이상이고 모두 숨김. */
export const specContainerHidden = (c: SpecContainer, hidden: ReadonlySet<string>): boolean =>
c.items.some((item) => specItemKeys(item).length > 0) &&
c.items.every((item) => !specItemKeys(item).length || specItemHidden(item, hidden));
/** 옵션 → 컨테이너 값. 필수는 한 벌(상위 항목 먼저 읽어 갈래 키를 앎) · 선택은 저장된 n 차례대로. */
export function readState(
spec: StructureSpec,
+98
View File
@@ -0,0 +1,98 @@
/* =============================================================================
* device_context.css — 기기 대응 공통 규칙(PLAN_기기대응 2-6)
* 모든 규칙은 `<html>` 표시(data-touch · data-size) 안에서만 — 데스크탑 마우스 화면은 안 바뀜.
* 표시는 `device_context.ts` 가 적음.
* ========================================================================== */
/* 누를 자리 44px — 손가락(주 입력 coarse) 또는 터치 모드 켬 */
html[data-touch="on"]
:is(
.ui-btn,
.ui-workflow-overlay__toggle,
.ui-sidebar-row,
.ui-workflow-layout__step,
.ui-ctx-menu__item,
select,
input:not([type="checkbox"], [type="radio"], [type="range"], [type="hidden"], [type="color"])
) {
min-height: 44px;
}
html[data-touch="on"] .ui-btn {
min-width: 44px;
}
/* 체크 · 라디오 — 상자를 키우고 글까지 한 줄 전체가 누를 자리 */
html[data-touch="on"] :is(input[type="checkbox"], input[type="radio"]) {
width: 22px;
height: 22px;
}
html[data-touch="on"] .ui-checkbox {
min-height: 44px;
}
/* 접힌 좌측 패널 손잡이 — 손가락 폭 · 화면 왼쪽 끝 안 */
html[data-touch="on"]
.ui-workflow-overlay__panel--title.is-collapsed
> .ui-workflow-overlay__handle--side {
width: 32px;
right: -32px;
}
/* 팝업(2-4) — 폭 600 미만은 전체 화면 · 겹치면 나중 것(맨 위)만 보이고 × 로 앞 창.
공용 모달(.ui-modal) · 페이지 팝업은 공용 클래스 `.ui-popup` 만 붙여 맞춤(자리 · 크기 인라인 값도 덮음) */
html[data-size="compact"] :is(.ui-modal, .ui-popup) {
position: fixed !important;
inset: 0 !important;
width: auto !important;
height: auto !important;
min-width: 0 !important;
max-width: none !important;
max-height: none !important;
margin: 0 !important;
border-radius: 0 !important;
transform: none !important;
overflow: auto;
}
html[data-size="compact"] .ui-confirm__panel {
min-width: 0;
max-width: calc(100vw - var(--spacing-32));
}
/* 공용 표(2-5) — 폭 840 미만은 고정 열이 없는 표도 첫 열을 붙임(머리줄은 늘 붙어 있음 · 가로는 밀기) */
html:is([data-size="compact"], [data-size="medium"])
.ui-data-table__table:not(:has(.is-frozen))
tr
> :first-child {
position: sticky;
left: 0;
z-index: 1;
}
html:is([data-size="compact"], [data-size="medium"])
.ui-data-table__table:not(:has(.is-frozen))
thead
tr
> :first-child {
z-index: 3;
}
/* 터치 모드에서만 보이는 단추(자판 없는 기기용 · 4-3) — 마우스 화면엔 없음 */
html:not([data-touch="on"]) .ui-touch-only {
display: none !important;
}
/* 위 줄 높이(6-1) — 화면 높이 계산이 모두 이 값을 씀 · 로그인 뒤 쪽(B · M)은 위 줄을 걷어 0(7-1)
☰ 폭(7-4) · 들여쓰기(7-2) = 왼쪽 위에 뜬 ☰(자리 8 + 폭) + 틈 8 · 그 자리에 걸치는 첫 줄이 이만큼 왼쪽을 비움 */
:root {
--app-header-h: 64px;
--app-burger-w: 36px;
--app-burger-inset: 0px;
}
html.is-bm-route {
--app-header-h: 0px;
--app-burger-inset: calc(var(--spacing-8) * 2 + var(--app-burger-w));
}
+123
View File
@@ -0,0 +1,123 @@
/* =============================================================================
* device_context.ts
* 기기 상태 한 곳(요구 193~195 · PLAN_기기대응 2-1) — `<html>` 에 지금 상태를 늘 적음.
*
* - data-size = compact(<600) · medium(<840) · expanded(<1200) · large ← 배치는 이것만 봄
* - data-pointer = 주 입력 coarse · fine(미디어 `pointer`)
* - data-input = 마지막 누름의 pointerType(mouse · touch · pen)
* - data-touch-mode = 사용자 스위치(auto · on · off · 이 기기 브라우저에만 저장)
* - data-touch = on · off — 누를 자리 크기를 키울지(auto 면 주 입력 coarse 일 때)
* - data-burger = on · off — 공개 쪽에서 ☰ 서랍이 메뉴를 맡는지(폭 900 이하 · 터치) · 로그인 뒤 쪽(B · M)은 늘 ☰(7-1)
* 바뀔 때만 window 에 `app:context` 이벤트(detail = 지금 상태).
* 경계 표는 여기 한 벌 — CSS 는 위 표시(data-size)로 가르고, 숫자가 필요하면 --bp-* 변수.
* 첫 화면 깜빡임 — 화면은 모두 JS 가 그리므로 main.ts 가 맨 먼저 import 하면 그리기 전에 적힘.
* ========================================================================== */
import "./device_context.css";
export const SIZE_BREAKS = { medium: 600, expanded: 840, large: 1200 } as const;
/** 위 메뉴가 숨는 폭(app_shell `@media (max-width: 900px)` 과 짝) — 이하면 ☰ 서랍 */
export const BURGER_MAX = 900;
export type SizeClass = "compact" | "medium" | "expanded" | "large";
export type TouchMode = "auto" | "on" | "off";
export interface DeviceContext {
size: SizeClass;
pointer: "coarse" | "fine";
input: string;
touchMode: TouchMode;
touch: boolean;
burger: boolean;
}
const TOUCH_MODE_KEY = "frd_touch_mode";
export function sizeClassOf(width: number): SizeClass {
if (width < SIZE_BREAKS.medium) return "compact";
if (width < SIZE_BREAKS.expanded) return "medium";
if (width < SIZE_BREAKS.large) return "expanded";
return "large";
}
let modeInMemory: TouchMode | null = null; // 저장이 막힌 브라우저용
export function readTouchMode(): TouchMode {
if (modeInMemory) return modeInMemory;
try {
const v = localStorage.getItem(TOUCH_MODE_KEY);
return v === "on" || v === "off" ? v : "auto";
} catch {
return "auto";
}
}
const coarseQuery = window.matchMedia("(pointer: coarse)");
let current: DeviceContext | null = null;
let lastInput = "";
function read(): DeviceContext {
const pointer = coarseQuery.matches ? "coarse" : "fine";
const touchMode = readTouchMode();
const touch = touchMode === "on" || (touchMode === "auto" && pointer === "coarse");
return {
size: sizeClassOf(window.innerWidth),
pointer,
input: lastInput || (pointer === "coarse" ? "touch" : "mouse"),
touchMode,
touch,
burger: touch || window.innerWidth <= BURGER_MAX,
};
}
function apply(): void {
const next = read();
if (current && JSON.stringify(current) === JSON.stringify(next)) return;
const html = document.documentElement;
html.dataset.size = next.size;
html.dataset.pointer = next.pointer;
html.dataset.input = next.input;
html.dataset.touchMode = next.touchMode;
html.dataset.touch = next.touch ? "on" : "off";
html.dataset.burger = next.burger ? "on" : "off";
const first = !current;
current = next;
if (!first) window.dispatchEvent(new CustomEvent("app:context", { detail: next }));
}
export function deviceContext(): DeviceContext {
return current ?? read();
}
/** 터치 모드 스위치(☰ 서랍) — 이 기기 브라우저에만 저장 */
export function setTouchMode(mode: TouchMode): void {
modeInMemory = mode;
try {
if (mode === "auto") localStorage.removeItem(TOUCH_MODE_KEY);
else localStorage.setItem(TOUCH_MODE_KEY, mode);
} catch {
/* 저장 막힌 브라우저 — 이번 화면만 */
}
apply();
}
function init(): void {
const html = document.documentElement;
for (const [name, px] of Object.entries(SIZE_BREAKS))
html.style.setProperty(`--bp-${name}`, `${px}px`);
apply();
coarseQuery.addEventListener("change", apply);
window.addEventListener("resize", apply);
window.visualViewport?.addEventListener("resize", apply);
screen.orientation?.addEventListener("change", apply);
window.addEventListener(
"pointerdown",
(e) => {
if (e.pointerType === lastInput) return;
lastInput = e.pointerType;
apply();
},
{ capture: true, passive: true },
);
}
init();
+64
View File
@@ -0,0 +1,64 @@
/* =============================================================================
* input_prefs.ts
* 마우스 · 단축키 설정 읽기 층(PLAN 332) — ☰ [설정] 창이 [저장] 때 계정 pref 로 씀.
*
* - 값 = `aislo:pref:input-prefs`(등록표 `input-prefs`) JSON 한 벌 · 부를 때마다 읽음(새로고침 없이 바로 따름)
* - 없음 · 깨짐 · Node(저장소 없음) = 기본값(= 지금 동작) · 의존 없음 · 불러올 때 window 안 만짐(CAD 도 import)
* - 휠 확대 방향 하나로 모든 화면 일체 — 기본 밂 = 휠을 앞으로(deltaY < 0) 굴리면 확대(오토캐드) · 당김 = 몸 쪽으로
* - 핀치(트랙패드 · 손가락)는 설정과 상관없이 늘 벌리면 확대(deltaY < 0)
* ========================================================================== */
export type WheelZoom = "pull" | "push";
export interface InputPrefs {
/** 휠 확대 방향 — pull = 당기면 확대 · push = 밀면 확대 */
wheelZoom: WheelZoom;
/** CAD 내 별칭 `{명령: [별칭]}`(대문자 · acad.pgp 식 — 명령 이름 > 내 별칭 > 기본 별칭) */
cadAliases: Record<string, string[]>;
}
/** `stateKey("input-prefs")` 와 같은 키(시험이 맞춰 봄) — 등록표를 불러오면 window 가 딸려 와서 글자로 둠 */
export const INPUT_PREFS_KEY = "aislo:pref:input-prefs";
export const DEFAULT_INPUT_PREFS: InputPrefs = { wheelZoom: "push", cadAliases: {} };
const cleanAliases = (raw: unknown): Record<string, string[]> => {
const out: Record<string, string[]> = {};
if (!raw || typeof raw !== "object") return out;
for (const [id, list] of Object.entries(raw as Record<string, unknown>)) {
if (!Array.isArray(list)) continue;
const aliases = list
.filter((a): a is string => typeof a === "string")
.map((a) => a.toUpperCase());
if (aliases.length) out[id.toUpperCase()] = aliases;
}
return out;
};
/** 지금 설정 — 부를 때마다 저장소에서 읽음 */
export function inputPrefs(): InputPrefs {
try {
const raw = globalThis.localStorage?.getItem(INPUT_PREFS_KEY);
if (!raw) return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
const value = JSON.parse(raw) as Partial<InputPrefs> | null;
return {
wheelZoom: value?.wheelZoom === "pull" ? "pull" : "push",
cadAliases: cleanAliases(value?.cadAliases),
};
} catch {
return { ...DEFAULT_INPUT_PREFS, cadAliases: {} };
}
}
/** 휠 한 번이 확대인가 — 설정 방향을 따름 */
export const wheelIn = (deltaY: number): boolean =>
inputPrefs().wheelZoom === "pull" ? deltaY > 0 : deltaY < 0;
/** 트랙패드 핀치 — 브라우저가 Ctrl+휠 · 잘게 쪼갠 값으로 냄
* ponytail: |deltaY| < 50 로 마우스 Ctrl+휠(한 칸 ≥ 약 100)과 가름 · 잘게 도는 마우스는 핀치로 봄 */
export const isTrackpadPinch = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">): boolean =>
e.ctrlKey && Math.abs(e.deltaY) < 50;
/** 휠 사건이 확대인가 — 핀치(`pinch` 손가락 벌림 · 트랙패드)는 늘 벌리면 확대 · 나머지는 설정 방향 */
export const wheelEventIn = (e: Pick<WheelEvent, "ctrlKey" | "deltaY">, pinch = false): boolean =>
pinch || isTrackpadPinch(e) ? e.deltaY < 0 : wheelIn(e.deltaY);
+98
View File
@@ -0,0 +1,98 @@
/* =============================================================================
* m_settings_nav.ts
* 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는
* 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`).
* ========================================================================== */
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { createStepBar } from "@ui/ui_template_workflow_layout";
import { setDrawerSteps } from "./app_shell";
import { currentRoute, navigateTo, takeLeaveGuard } from "./router";
interface SettingUser {
role?: string;
is_master?: boolean;
company_id?: number | null;
}
/** key = M01 은 「m01」 · M02 는 진입 인자 `open` */
export interface SettingLink {
key: string;
title: string;
desc: string;
route: RoutePath;
query?: Record<string, string>;
}
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const ICONS: Record<string, string> = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" };
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
export function settingLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
const company: Record<string, string> =
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({
key: open,
title,
desc,
route: ROUTES.M02_MASTER_TEMPLATE,
query: { open, root, ...company },
});
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
if (user.role === "SYSTEM_ADMIN") {
const desc = L("B01_Dashboard_PermMasterWrite");
return [
{
key: "m01",
title: L("B01_Dashboard_MasterElements"),
desc: L("B01_Dashboard_PermSourceRead"),
route: ROUTES.M01_MASTER_DATA,
},
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
];
}
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
const companyAdmin = user.role === "ADMIN" || user.is_master;
const root = companyAdmin ? "company" : "personal";
const desc = [
L("B01_Dashboard_PermMasterRead"),
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
L("B01_Dashboard_PermPersonalWrite"),
].join(" · ");
return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)];
}
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
export function mountSettingsDrawer(
user: SettingUser,
activeKey: string,
companyId: number | string | null,
): void {
const links = settingLinks(user, companyId);
const bar = createStepBar(
links.map((link) => link.title),
links.findIndex((link) => link.key === activeKey),
{
orientation: "vertical",
icons: links.map((link) => ICONS[link.key] ?? ""),
routes: links.map((link) => link.route),
homeButton: true,
onStepClick: (index) => void go(links[index]),
},
);
setDrawerSteps(bar);
}
async function go(link: SettingLink): Promise<void> {
const guard = link.route === currentRoute() ? takeLeaveGuard() : null;
if (guard && !(await guard())) return;
navigateTo(link.route, link.query);
}
+8 -1
View File
@@ -9,9 +9,12 @@
* ========================================================================== */
import "./random_uuid_polyfill";
import "./device_context"; // 맨 앞 — 그리기 전에 <html> 기기 표시(기기대응 2-1)
import "./touch_bridge"; // 손가락 → 마우스 번역(기기대응 3-1)
import "@ui/ui_template_theme.css";
import { injectBaseStyles } from "@ui/ui_template_elements";
import { blockNativeMenu, NATIVE_MENU_ROUTE } from "@ui/ui_template_native_menu";
import { initLoupe, LOUPE_ROUTE } from "@ui/ui_template_loupe";
import { ROUTES, type RoutePath } from "@config/config_frontend";
import { initThemeAndLang, injectShellStyles, renderShell } from "./app_shell";
import {
@@ -19,6 +22,7 @@ import {
renderCurrentRoute,
ensureInitialHash,
setLeaveGuard,
draftRoute,
showWithinPage,
takeLeaveGuard,
} from "./router";
@@ -54,6 +58,8 @@ function bootstrap(): void {
// B · M 페이지는 브라우저 기본 오른쪽 클릭 메뉴를 막음 — 우리 메뉴는 그대로(계획서 39-9a)
blockNativeMenu(window, () => NATIVE_MENU_ROUTE.test(currentRoute()));
// B01~B09 · M01 · M02 돋보기 — 오른쪽 길게 누름 · Z 누름 동안(계획서 218-1 · 220)
initLoupe(window, () => LOUPE_ROUTE.test(currentRoute()));
// 3. 셸 렌더 + 현재 라우트 렌더를 함께 수행하는 헬퍼
let shown = "";
@@ -67,7 +73,8 @@ function bootstrap(): void {
shown = currentRoute();
shownHash = window.location.hash;
if (within) return;
const showFooter = !FOOTERLESS_ROUTES.includes(currentRoute());
const showFooter =
!FOOTERLESS_ROUTES.includes(currentRoute()) && !draftRoute(currentRoute())?.footerless;
const outlet = renderShell(appRoot, showFooter); // 헤더/푸터(로그인·언어 반영) 갱신
void renderCurrentRoute(outlet); // 아울렛에 페이지 렌더
};
+29 -4
View File
@@ -74,6 +74,29 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
(await import("../M02_MasterTemplete/M02_MasterTemplete_UI_Page")).renderM02MasterTemplate,
};
/** 초안 길(302-1) — 페이지 폴더 `draft/*_Route.ts` 의 `ROUTE` 선언 · 폴더째 지우면 길도 없음.
* 정적 표에 이름을 박지 않음 — 보호 · 단계 화면(로그인 · 회사) · 프로젝트 · 꼬리말 없음도 선언에서. */
export interface DraftRoute {
path: string;
load: () => Promise<PageRenderer>;
/** 로그인 · 회사 필요(B 단계 화면) — 로그인만이면 protected */
workflow?: boolean;
protected?: boolean;
/** 현재 프로젝트로 여는 화면(지워진 프로젝트 안내) */
project?: boolean;
footerless?: boolean;
}
const DRAFT_ROUTES = new Map(
Object.values(import.meta.glob<{ ROUTE: DraftRoute }>("../*/draft/*_Route.ts", { eager: true }))
.map((m) => m.ROUTE)
.filter(Boolean)
.map((r) => [r.path, r] as const),
);
/** 초안 길 선언(없으면 undefined) */
export const draftRoute = (route: string): DraftRoute | undefined => DRAFT_ROUTES.get(route);
/** 페이지가 떠나기 전에 묻는 자리 — false 를 돌려주면 이동을 취소(주소도 되돌림). 이동이 끝나면 비워짐. */
let leaveGuard: (() => Promise<boolean>) | null = null;
export const setLeaveGuard = (guard: (() => Promise<boolean>) | null): void => {
@@ -159,7 +182,9 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
mounted?.dispose();
mounted = null;
if (PROTECTED_ROUTES.includes(route) && !(await isAuthenticated())) {
const draft = draftRoute(route);
const guarded = PROTECTED_ROUTES.includes(route) || !!(draft?.protected || draft?.workflow);
if (guarded && !(await isAuthenticated())) {
navigateTo(ROUTES.A06_LOGIN);
return;
}
@@ -178,7 +203,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
ROUTES.B11_LOADING,
];
if (workflowRoutes.includes(route)) {
if (workflowRoutes.includes(route) || draft?.workflow) {
const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch");
try {
const user = await fetchSessionUser();
@@ -197,7 +222,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
}
// 기억한 현재 프로젝트가 지워졌으면 페이지 대신 안내 — 모든 B 단계 화면이 여기 한 곳을 지난다.
if (PROJECT_ROUTES.includes(route)) {
if (PROJECT_ROUTES.includes(route) || draft?.project) {
const { dropGoneProject } = await import("./b_workflow_nav");
if (await dropGoneProject()) {
if (seq !== renderSeq) return;
@@ -208,7 +233,7 @@ export async function renderCurrentRoute(outlet: HTMLElement): Promise<void> {
}
}
const loader = routeTable[route];
const loader = routeTable[route] ?? draft?.load;
if (!loader) {
renderPlaceholder(outlet, route);
return;
+18 -1
View File
@@ -16,6 +16,7 @@ import { parseA1, rangeToA1, toA1 } from "./spreadsheet_address";
import { attachClipboard, pasteTableAt, type PasteCheck } from "./spreadsheet_clipboard";
import { applyCommand, CommandError } from "./spreadsheet_commands";
import { createEditor, slotOf } from "./spreadsheet_editor";
import { mountTouchKeys } from "./spreadsheet_touch_keys";
import { toCalcValue } from "./spreadsheet_eval";
import { attachExtras } from "./spreadsheet_extras";
import { createCalcEngine } from "./spreadsheet_graph";
@@ -27,7 +28,7 @@ import type { LinkOptions, Links } from "./spreadsheet_links";
import { linkCommand, type LinkInfo } from "./spreadsheet_link_set";
import { attachMenu } from "./spreadsheet_menu";
import { attachMouse } from "./spreadsheet_mouse";
import { LAST_C, LAST_R, selectCell } from "./spreadsheet_selection";
import { LAST_C, LAST_R, selectCell, selectRanges } from "./spreadsheet_selection";
import { createStatusBar } from "./spreadsheet_statusbar";
import { mountTabs } from "./spreadsheet_tabs";
import { mountToolbar } from "./spreadsheet_toolbar";
@@ -89,6 +90,9 @@ export interface SpreadsheetHandle {
/** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) ·
* `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */
pasteTable(html: string, check?: PasteCheck): Promise<string | null>;
/** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) ·
* 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */
select(범위: string | string[], 시트?: string): boolean;
destroy(): void;
}
@@ -161,6 +165,7 @@ export function createSpreadsheet(
const extras = attachExtras(ctx, ed, toolbar?.root ?? null);
others.push(...extras.parts);
const detachKeys = attachKeys(ctx, ed, extras.keys);
mountTouchKeys(ctx, ed); // 자판 없는 기기 단추 줄(기기대응 4-3 · 터치 모드에서만 보임)
const detachMouse = attachMouse(ctx, ed, extras.mouse);
/** `addSheet` 가 나중에 붙인 탭 줄(탭 없이 띄운 시트) */
let lateTabs: PartHandle | null = null;
@@ -348,6 +353,18 @@ export function createSpreadsheet(
return 이름;
},
pasteTable: (html, check) => pasteTableAt(ctx, html, check),
select(range, sheetKey) {
const target = sheetKey
? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey)
: ctx.sheet();
const next = target && selectRanges(target, typeof range === "string" ? [range] : range);
if (!target || !next) return false;
showSheet(target.id);
if (ctx.selection.시트 !== target.id) return false; // 편집기 유효성에 막힘
select(next, true);
grid.reveal(next.활성.r, next.활성.c); // 범위가 판보다 크면 왼위가 보이게
return true;
},
destroy() {
clearTimeout(toastTimer);
detachKeys();
@@ -8,7 +8,7 @@
* ========================================================================== */
import { colName, parseRange, rangeToA1, rangesOverlap, toA1 } from "./spreadsheet_address";
import { cellStyleAt } from "./spreadsheet_cellstyle";
import { cellStyleAt, shownHAlign } from "./spreadsheet_cellstyle";
import { createCalcEngine } from "./spreadsheet_graph";
import { DEFAULT_COL_CHARS, DEFAULT_ROW_PT } from "./spreadsheet_headers";
import { resolveName } from "./spreadsheet_names";
@@ -110,7 +110,7 @@ export function areaTable(ask: TableAsk) {
: { 글: "", 정렬: "left" as const };
const key = `${sheet.id}!${toA1(r, c)}`;
값[key] = shown.글;
const align = ALIGN[style.가로 ?? "general"] ?? (shown.정렬 as TableCell["align"]);
const align: TableCell["align"] = ALIGN[shownHAlign(style.가로)] ?? "center"; // 안 준 칸 = 가운데(237-1)
return { text: shown.글, align, ...(style.굵게 ? { bold: true } : {}), key };
};
const cells = rows.map((r) =>
@@ -139,9 +139,9 @@ function borderCss(side: BorderSide | undefined, scale: number): string {
}
}
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). */
/** 가로 정렬 → `text-align`(균등 분할 · 선택 영역 가운데는 근사: `justify` · `center`). 안 준 칸(일반) = 가운데(237-1). */
const HALIGN_CSS: Record<HAlign, string> = {
general: "left",
general: "center",
left: "left",
right: "right",
center: "center",
@@ -149,13 +149,23 @@ const HALIGN_CSS: Record<HAlign, string> = {
centerContinuous: "center",
};
/** 칸에 맞춤을 안 준(없음 · 일반) 보이는 가로 맞춤 = 가운데 — 화면 · 도구줄 · 대화상자 · 내보내기 공통(237-1). */
export function shownHAlign(h: HAlign | undefined): HAlign {
return !h || h === "general" ? "center" : h;
}
/** 칸에 맞춤을 안 준 보이는 세로 맞춤 = 가운데(237-1). */
export function shownVAlign(v: VAlign | undefined): VAlign {
return v ?? "center";
}
const VALIGN_CSS: Record<VAlign, string> = {
top: "flex-start",
center: "center",
bottom: "flex-end",
};
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 정렬은 값 종류로 갈림 — B `formatValue` 의 `정렬`).
/** 칸 하나에 서식 · 겹쳐 고른 테두리를 입힘. `hAlign` 은 부른 쪽이 정함(일반 = 가운데).
* `scale` = 확대 · 축소 배율(칸 글자 크기 · 테두리 굵기가 같이 커짐 · 기본 1). */
export function applyCellStyle(
cell: HTMLElement,
@@ -178,7 +188,7 @@ export function applyCellStyle(
s.borderLeft = borderCss(borders.왼, scale);
s.borderBottom = borderCss(borders.아래, scale);
s.borderRight = borderCss(borders.오른, scale);
s.justifyContent = VALIGN_CSS[style.세로 ?? "bottom"];
s.justifyContent = VALIGN_CSS[shownVAlign(style.세로)];
s.whiteSpace = style.줄바꿈 ? "normal" : "nowrap";
let text = cell.firstElementChild as HTMLElement | null;
@@ -187,7 +197,7 @@ export function applyCellStyle(
text.className = "aislo-cell__text";
cell.replaceChildren(text);
}
text.style.textAlign = HALIGN_CSS[hAlign];
text.style.textAlign = HALIGN_CSS[shownHAlign(hAlign)];
text.style.textAlignLast = hAlign === "distributed" ? "justify" : "";
}
@@ -14,6 +14,7 @@
import { showToast } from "@ui/ui_template_elements";
import { colName, parseA1, parseRange, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { clipWholeToUsed } from "./spreadsheet_grid_cells";
import { formatValue } from "./spreadsheet_numfmt";
import { moveFormula, r1c1ToA1 } from "./spreadsheet_refshift";
@@ -70,10 +71,11 @@ function styleToCss(style: CellStyle): string {
if (deco) parts.push(`text-decoration:${deco}`);
if (style.글자색) parts.push(`color:${style.글자색}`);
if (style.채움) parts.push(`background-color:${style.채움}`);
if (style.가로 && style.가로 !== "general") {
parts.push(`text-align:${style.가로 === "centerContinuous" ? "center" : style.가로}`);
}
if (style.세로) parts.push(`vertical-align:${style.세로 === "center" ? "middle" : style.세로}`);
// 안 준 칸 = 가운데 · 가운데(237-1) — 밖(엑셀)에서도 같은 모양
const h = shownHAlign(style.가로);
parts.push(`text-align:${h === "centerContinuous" ? "center" : h}`);
const v = shownVAlign(style.세로);
parts.push(`vertical-align:${v === "center" ? "middle" : v}`);
if (style.줄바꿈) parts.push("white-space:pre-wrap");
if (style.테두리) {
(["위", "아래", "왼", "오른"] as const).forEach((side) => {
@@ -129,7 +129,7 @@ export function mountDataMenu(ctx: SpreadsheetContext, back: () => void): DataMe
attrs: { type: "button", title: TEXT.close },
});
const dialog = el("div", {
className: "ss-dialog",
className: "ss-dialog ui-popup",
attrs: { hidden: "" },
children: [el("div", { className: "ss-dialog__head", children: [title, shut] }), body],
});
+9 -1
View File
@@ -294,7 +294,7 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
ta.style.fontSize = s.크기 ? `${s.크기}pt` : "";
ta.style.fontWeight = s.굵게 ? "bold" : "";
ta.style.fontStyle = s.기울임 ? "italic" : "";
ta.style.textAlign = s.가로 === "right" || s.가로 === "center" ? s.가로 : "";
ta.style.textAlign = s.가로 === "left" || s.가로 === "right" ? s.가로 : "center";
// 글이 길면 오른쪽 · 아래로 늘림(엑셀)
ta.style.width = "0";
ta.style.height = "0";
@@ -303,6 +303,14 @@ export function createEditor(ctx: SpreadsheetContext): Editor {
}
function focus(): void {
// 손가락 — 고칠 때만 화면 자판(기기대응 4-2) · 칸 고름만으로는 안 뜸
if (document.documentElement.dataset.touch === "on") {
const mode = st ? "text" : "none";
if (ta.inputMode !== mode) {
ta.inputMode = mode;
if (document.activeElement === ta) ta.blur();
}
}
if (document.activeElement !== ta) ta.focus({ preventScroll: true });
}
@@ -210,7 +210,7 @@ export function openEvalSteps(ctx: SpreadsheetContext): () => void {
onClick: () => go(i + 1 < steps.length ? i + 1 : 0),
});
const box = el("div", {
className: "ss-steps__box",
className: "ss-steps__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-steps", children: [box] });
@@ -23,7 +23,7 @@ import {
type NegStyle,
} from "./spreadsheet_format_code";
import { dialogKeys } from "./spreadsheet_actions";
import { isAutoInk } from "./spreadsheet_cellstyle";
import { isAutoInk, shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { formatValue } from "./spreadsheet_numfmt";
import type { BorderLine, BorderSide, CellStyle, HAlign, VAlign } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
@@ -233,7 +233,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["centerContinuous", "선택 영역 가운데"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
hSelect.value = draft.가로 ?? "general";
hSelect.value = shownHAlign(draft.가로);
hSelect.addEventListener("change", () => (draft.가로 = hSelect.value as HAlign));
const vSelect = el("select", {
@@ -243,7 +243,7 @@ export function openFormatDialog(ctx: SpreadsheetContext): void {
["bottom", "아래"],
].map(([v, t]) => el("option", { attrs: { value: v }, text: t })),
}) as HTMLSelectElement;
vSelect.value = draft.세로 ?? "bottom";
vSelect.value = shownVAlign(draft.세로);
vSelect.addEventListener("change", () => (draft.세로 = vSelect.value as VAlign));
const wrapCheck = el("input", { attrs: { type: "checkbox" } }) as HTMLInputElement;
@@ -263,3 +263,16 @@
.ss--editing .aislo-cell.is-selected.is-spill > .aislo-cell__text {
visibility: hidden;
}
/* 손가락(기기대응 4-2) — 채우기 손잡이를 키움 · 격자는 한 손가락 밀기만 브라우저에(두 손가락 = 격자 확대) */
html[data-touch="on"] .aislo-grid__fill-handle {
width: 14px;
height: 14px;
margin: -4px 0 0 -4px;
}
@media (any-pointer: coarse) {
.aislo-grid {
touch-action: pan-x pan-y;
}
}
+9 -2
View File
@@ -13,6 +13,7 @@
import "./spreadsheet_grid.css";
import { colIndex, toA1 } from "./spreadsheet_address";
import { wheelZoomsIn } from "../touch_bridge";
import {
buildColGeometry,
buildOutlineRuns,
@@ -271,7 +272,8 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
if (!ev.ctrlKey) return;
ev.preventDefault();
const before = zoom;
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(-ev.deltaY * 0.0015)));
const step = (wheelZoomsIn(ev) ? 1 : -1) * Math.abs(ev.deltaY) * 0.0015; // ☰ [설정] 방향(332)
const next = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, zoom * Math.exp(step)));
if (next === before) return;
zoom = next;
colGeo = scaledGeometry(baseColGeo, zoom);
@@ -553,7 +555,12 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const handleAt = sel && sel.시트 === ctx.sheet().id ? fillHandleCell() : null;
if (handleAt) {
const screen = screenBoxOf(handleAt.r, handleAt.c);
if (Math.abs(x - (screen.x + screen.w)) <= 5 && Math.abs(y - (screen.y + screen.h)) <= 5) {
// 손가락이면 손잡이 잡는 자리를 넓힘(기기대응 4-2)
const grip = document.documentElement.dataset.touch === "on" ? 20 : 5;
if (
Math.abs(x - (screen.x + screen.w)) <= grip &&
Math.abs(y - (screen.y + screen.h)) <= grip
) {
return { kind: "fillHandle", r: handleAt.r, c: handleAt.c };
}
}
@@ -13,6 +13,7 @@ import {
isAutoInk,
resolveBoxBorders,
setCellText,
shownHAlign,
type ResolvedBorders,
} from "./spreadsheet_cellstyle";
import type { LineGeometry, OutlineRun } from "./spreadsheet_headers";
@@ -139,7 +140,7 @@ export function buildMergeMap(sheet: Sheet): Map<string, CellRange> {
}
const SPILL_ALIGN: Partial<Record<HAlign, SpillAlign>> = {
general: "left",
general: "center",
left: "left",
right: "right",
center: "center",
@@ -218,7 +219,7 @@ function cellDisplay(
const scalar = scalarAt(ctx, sheet, r, c);
const formatted = formatValue(scalar, code);
const isError = typeof scalar === "object" && scalar !== null && "error" in scalar;
const hAlign: HAlign = hAlign0 !== "general" ? hAlign0 : formatted.정렬;
const hAlign: HAlign = shownHAlign(hAlign0); // 안 준 칸 = 가운데(237-1)
const title = isError ? ((scalar as ErrorValue).why ?? formatted.글) : "";
const isNumeric =
!isError && scalar !== null && typeof scalar !== "string" && typeof scalar !== "boolean";
@@ -255,11 +255,61 @@ export function attachMouse(
};
const onLeave = (): void => hooks.hover?.(null);
// 손가락(기기대응 4-2) — 톡 = 칸 고름(브라우저 호환 마우스 그대로) · 고른 칸 · 행열 머리 · 채우기 손잡이에서
// 끌기 = 범위 · 채우기(마우스 끌기와 같은 길) · 안 고른 칸에서 끌기 = 화면 밀기 ·
// 두 번 톡 = 고침(손 동작 안에서 바로 열어 화면 자판이 뜸)
let touchDrag = false;
let lastTap = 0;
const asMouse = (t: Touch, type: string): MouseEvent =>
new MouseEvent(type, { clientX: t.clientX, clientY: t.clientY, button: 0, bubbles: true });
const onTouchStart = (e: TouchEvent): void => {
if (e.touches.length !== 1 || e.target === ed.ta) return;
const t = e.touches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const grab =
hit &&
(hit.kind === "fillHandle" ||
hit.kind === "rowHead" ||
hit.kind === "colHead" ||
(hit.kind === "cell" && inSelection(hit.r, hit.c)));
if (!grab) return;
e.preventDefault(); // 화면 밀기 대신 범위 · 호환 마우스 이벤트도 안 옴
touchDrag = true;
onDown(asMouse(t, "mousedown"));
};
const onTouchMove = (e: TouchEvent): void => {
if (!touchDrag) return;
e.preventDefault();
root.dispatchEvent(asMouse(e.touches[0], "mousemove")); // 올라가 window 끌기 듣개에 닿음
};
const onTouchEnd = (e: TouchEvent): void => {
if (touchDrag) {
touchDrag = false;
root.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, button: 0 })); // M02 칸 고르기도 받음
}
if (e.touches.length || e.changedTouches.length !== 1) return;
const t = e.changedTouches[0];
const hit = ctx.grid.hitTest(t.clientX, t.clientY);
const now = Date.now();
if (now - lastTap < 350 && hit?.kind === "cell" && !ed.editing() && !ctx.readOnly) {
lastTap = 0;
ed.begin();
return;
}
lastTap = now;
};
root.addEventListener("mousedown", onDown);
root.addEventListener("dblclick", onDbl);
root.addEventListener("mousemove", onHover);
root.addEventListener("mouseleave", onLeave);
root.addEventListener("touchstart", onTouchStart, { passive: false });
root.addEventListener("touchmove", onTouchMove, { passive: false });
root.addEventListener("touchend", onTouchEnd);
return () => {
root.removeEventListener("touchstart", onTouchStart);
root.removeEventListener("touchmove", onTouchMove);
root.removeEventListener("touchend", onTouchEnd);
root.removeEventListener("mousemove", onHover);
root.removeEventListener("mouseleave", onLeave);
stopDrag?.();
@@ -91,6 +91,20 @@ export function selectSpan(sheet: Sheet, anchor: CellAddress, to: CellAddress):
return { 시트: sheet.id, 범위: [g], 활성: { ...anchor }, 기준: { ...anchor } };
}
/** A1 범위 글 여럿 → 고름(329-2 손잡이 `select`) — 병합만큼 늘림 · 활성 = 첫 범위 왼위 · 틀린 글 · 빈 목록 = null */
export function selectRanges(sheet: Sheet, texts: string[]): Selection | null {
const list = merges(sheet);
const 범위: CellRange[] = [];
for (const t of texts) {
const g = parseRange(t);
if (!g) return null;
범위.push(expand(g, list));
}
if (!범위.length) return null;
const at = { r: 범위[0].r0, c: 범위[0].c0 };
return { 시트: sheet.id, 범위, 활성: at, 기준: { ...at } };
}
/** 행 전체(r0~r1) · 열 전체(c0~c1) · 전체 — 활성은 첫 보이는 칸 · 걸친 병합만큼 늘림(엑셀) */
export function selectRows(sheet: Sheet, a: number, b: number, active?: number): Selection {
const r0 = Math.min(a, b);
@@ -87,7 +87,7 @@ export function openSortDialog(ctx: SpreadsheetContext): () => void {
const caseBox = el("input", { attrs: { type: "checkbox" } });
const list = el("div", { className: "ss-sort__keys" });
const box = el("div", {
className: "ss-sort__box",
className: "ss-sort__box ui-popup",
attrs: { role: "dialog", "aria-label": t("Title") },
});
const shade = el("div", { className: "ss-sort", children: [box] });
@@ -15,6 +15,7 @@ export function setSpreadsheetLanguage(index: 0 | 1): void {
const TEXT = {
Undo: ["되돌리기", "Undo"],
CellMenu: ["칸 메뉴(우클릭과 같음)", "Cell menu (same as right-click)"],
Redo: ["다시", "Redo"],
FontName: ["글꼴", "Font"],
FontSize: ["크기", "Size"],
@@ -291,3 +291,23 @@
justify-content: flex-end;
gap: var(--spacing-8);
}
/* ⋯ 칸 메뉴(기기대응 3-5) — 터치 모드에서만 */
.ss-toolbar .ss-toolbar__more {
display: none;
}
html[data-touch="on"] .ss-toolbar .ss-toolbar__more {
display: inline-flex;
}
/* 자판 없는 기기 단추 줄(기기대응 4-3 · spreadsheet_touch_keys.ts) — 터치 모드에서만 */
.ss-touch-keys {
display: none;
}
html[data-touch="on"] .ss-touch-keys {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-4);
}
+28 -3
View File
@@ -13,6 +13,7 @@
import { createButton, el } from "@ui/ui_template_elements";
import { confirmMerge } from "./spreadsheet_actions";
import { colName, rangeToA1, toA1 } from "./spreadsheet_address";
import { shownHAlign, shownVAlign } from "./spreadsheet_cellstyle";
import { cellToText, textToCell } from "./spreadsheet_editor";
import { st } from "./spreadsheet_text";
import type { BorderLine, BorderSide, CellRange, CellStyle, Command } from "./spreadsheet_types";
@@ -147,6 +148,30 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
undoBtn.title = st("Undo");
const redoBtn = createButton({ label: "↷", variant: "ghost", onClick: () => ctx.redo() });
redoBtn.title = st("Redo");
// 손가락 — 우클릭 대신 활성 칸 메뉴를 여는 ⋯(기기대응 3-5 · 터치 모드에서만 보임)
const moreBtn = createButton({
label: "⋯",
variant: "ghost",
onClick: () => {
const { r, c } = ctx.selection.활성;
const { box } = ctx.grid.editorSlot(r, c);
const at = ctx.grid.root.getBoundingClientRect();
const x = at.left + box.x + Math.min(box.w / 2, 24);
const y = at.top + box.y + box.h / 2;
const target = document.elementFromPoint(x, y) ?? ctx.grid.root;
target.dispatchEvent(
new MouseEvent("contextmenu", {
bubbles: true,
cancelable: true,
clientX: x,
clientY: y,
button: 2,
}),
);
},
});
moreBtn.classList.add("ss-toolbar__more");
moreBtn.title = st("CellMenu");
// ── 글꼴 · 크기 ────────────────────────────────────────────────────────
const fontInput = el("input", {
@@ -500,7 +525,7 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
});
root.append(
group(undoBtn, redoBtn),
group(undoBtn, redoBtn, moreBtn),
group(fontInput, sizeInput),
group(boldBtn, italicBtn, underlineBtn, strikeBtn),
group(textColor, textColorClear, fillColor, fillColorClear),
@@ -528,8 +553,8 @@ export function mountToolbar(ctx: SpreadsheetContext): PartHandle {
strikeBtn.classList.toggle("is-on", !!style.취소선);
textColor.value = style.글자색 ?? "#000000";
fillColor.value = style.채움 ?? "#ffffff";
hAlignSelect.value = style.가로 ?? "general";
vAlignSelect.value = style.세로 ?? "bottom";
hAlignSelect.value = shownHAlign(style.가로);
vAlignSelect.value = shownVAlign(style.세로);
wrapBtn.classList.toggle("is-on", !!style.줄바꿈);
numfmtInput.value = style.형식 ?? "";
const range = ctx.selection.범위[0];
@@ -0,0 +1,43 @@
/* =============================================================================
* spreadsheet_touch_keys.ts
* 자판 없는 기기용 단추 줄(기기대응 4-3) — 단축키로만 되던 일을 같은 키 길(`attachKeys`)로 보냄.
* 입력줄(`ed.bar`) 끝에 붙고 터치 모드에서만 보임(spreadsheet_toolbar.css). 마우스 화면은 그대로.
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
import type { Editor } from "./spreadsheet_editor";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
const KEYS: [label: string, title: string, init: KeyboardEventInit, edit: boolean][] = [
["🔍", "찾기 · 바꾸기(Ctrl+F)", { key: "f", code: "KeyF", ctrlKey: true }, false],
["⇉", "고른 칸 모두 같은 값(Ctrl+Enter)", { key: "Enter", ctrlKey: true }, true],
["↵", "칸 안 줄바꿈(Alt+Enter)", { key: "Enter", altKey: true }, true],
["✕", "고침 취소(Esc)", { key: "Escape" }, true],
["📅", "오늘 날짜(Ctrl+;)", { key: ";", code: "Semicolon", ctrlKey: true }, true],
["$", "참조 $ 돌리기(F4)", { key: "F4" }, true],
];
export function mountTouchKeys(ctx: SpreadsheetContext, ed: Editor): void {
const row = document.createElement("div");
row.className = "ss-touch-keys";
for (const [label, title, init, edit] of KEYS) {
if (edit && ctx.readOnly) continue;
const b = createButton({
label,
variant: "ghost",
onClick: () => {
// 고치는 중이면 그 칸(입력줄 · 칸 편집기) — 아니면 칸 편집기로
const target = document.activeElement === ed.formula ? ed.formula : ed.ta;
target.dispatchEvent(
new KeyboardEvent("keydown", { ...init, bubbles: true, cancelable: true }),
);
},
});
b.title = title;
b.setAttribute("aria-label", title);
// 누를 때 초점을 뺏지 않음 — 고치던 칸이 그대로(손가락은 호환 mousedown 이 옴)
b.addEventListener("mousedown", (e) => e.preventDefault());
row.append(b);
}
ed.bar.append(row);
}
+233
View File
@@ -0,0 +1,233 @@
/* =============================================================================
* touch_bridge.ts
* 손가락 → 마우스 번역 한 곳(요구 193~195 · PLAN_기기대응 3-1) — 페이지 코드는 마우스 그대로.
*
* - 길게 누름(500ms · 10px 안) → 그 자리에 `contextmenu` · 뒤따르는 click 막음
* (안드로이드처럼 브라우저가 먼저 내면 그것 하나만)
* - 두 번 톡 → 브라우저가 `dblclick` 을 안 낼 때만 대신
* - 두 손가락 벌림 → 가운데 자리에 Ctrl+휠(deltaY = −100·ln(배율) · 배율 10% 마다 한 번 · 트랙패드 핀치와 같은 꼴)
* 받는 면이 touch-action 으로 브라우저 확대를 막은 곳에서만 끝까지 옴
* - `[data-touch-own]` 요소(3D · 공용 SVG 확대)는 건너뜀 — 자체 손가락 처리 · 입력 칸 길게 누름도 브라우저 몫
* 마우스 · 펜 이벤트는 건드리지 않음.
* ========================================================================== */
import { wheelEventIn } from "./input_prefs";
const LONG_MS = 500;
const SLOP_PX = 10;
const DOUBLE_MS = 300;
const DOUBLE_PX = 25;
/** 브라우저 dblclick 을 기다리는 틈 */
const NATIVE_WAIT_MS = 350;
/** 길게 누름 뒤 따라오는 click · 원래 contextmenu 를 막는 틈 */
const AFTER_LONG_MS = 800;
const PINCH_STEP = Math.log(1.1);
const synthetic = new WeakSet<Event>();
/** 손가락 벌림으로 만든 휠인지 — 「당기면 확대」 화면이 방향을 맞출 때 씀 */
export const isPinchWheel = (e: Event): boolean => synthetic.has(e);
let contactPx = 0;
/** 고름 반경(px) — 손가락으로 눌렀으면 접촉 크기만큼(최소 24px) · 마우스 · 펜은 그대로(기기대응 4-4) */
export const pickPx = (px: number): number => (contactPx ? Math.max(px, contactPx) : px);
/** 휠 화면(B04 · B05 지도 · M02 그림 · 시트)의 확대 판정 — ☰ [설정] 방향(332) · 손가락 벌림은 늘 벌리면 확대 */
export const wheelZoomsIn = (e: WheelEvent): boolean => wheelEventIn(e, isPinchWheel(e));
const ownsTouch = (t: EventTarget | null): boolean =>
t instanceof Element && !!t.closest("[data-touch-own]");
const isEditable = (t: EventTarget | null): boolean =>
t instanceof Element &&
!!t.closest('input, textarea, select, [contenteditable=""], [contenteditable="true"]');
interface Pt {
x: number;
y: number;
}
const touches = new Map<number, Pt>();
let press: (Pt & { id: number; target: Element; timer: ReturnType<typeof setTimeout> }) | null =
null;
let longAt = 0;
let nativeMenuAt = 0;
let nativeDblAt = 0;
let lastTap: (Pt & { t: number }) | null = null;
let pinch: { dist: number; acc: number } | null = null;
const at = (p: Pt, fallback: Element): Element => document.elementFromPoint(p.x, p.y) ?? fallback;
function fire(type: "contextmenu" | "dblclick", target: Element, p: Pt): void {
const ev = new MouseEvent(type, {
bubbles: true,
cancelable: true,
composed: true,
view: window,
clientX: p.x,
clientY: p.y,
screenX: p.x,
screenY: p.y,
button: type === "contextmenu" ? 2 : 0,
detail: type === "dblclick" ? 2 : 1,
});
synthetic.add(ev);
target.dispatchEvent(ev);
}
function fireWheel(target: Element, p: Pt, deltaY: number): void {
const ev = new WheelEvent("wheel", {
bubbles: true,
cancelable: true,
composed: true,
view: window,
clientX: p.x,
clientY: p.y,
ctrlKey: true,
deltaY,
deltaMode: 0,
});
synthetic.add(ev);
target.dispatchEvent(ev);
}
function cancelPress(): void {
if (press) clearTimeout(press.timer);
press = null;
}
const pair = (): { mid: Pt; dist: number } => {
const [a, b] = [...touches.values()];
return {
mid: { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 },
dist: Math.hypot(a.x - b.x, a.y - b.y),
};
};
/** 그림 면(SVG · 캔버스)을 처음 만지면 한 번 — 「길게 누르면 메뉴」(3-5 · 이 탭에서 한 번) */
const HINT_KEY = "frd_touch_hint_shown";
function hintOnce(target: Element): void {
if (!target.closest("svg, canvas")) return;
try {
if (sessionStorage.getItem(HINT_KEY)) return;
sessionStorage.setItem(HINT_KEY, "1");
} catch {
return;
}
void Promise.all([import("@ui/ui_template_elements"), import("@ui/ui_template_locale")]).then(
([ui, loc]) => ui.showToast(loc.t("App_Touch_LongPressHint"), "info", 4000),
);
}
function onDown(e: PointerEvent): void {
contactPx = e.pointerType === "touch" ? Math.max(24, e.width / 2, e.height / 2) : 0;
if (e.pointerType !== "touch") return;
const p = { x: e.clientX, y: e.clientY };
touches.set(e.pointerId, p);
const target = e.target as Element;
hintOnce(target);
if (touches.size === 1) {
if (ownsTouch(target) || isEditable(target)) return;
press = {
...p,
id: e.pointerId,
target,
timer: setTimeout(() => {
const was = press;
press = null;
if (!was || Date.now() - nativeMenuAt < LONG_MS) return;
longAt = Date.now();
fire("contextmenu", at(was, was.target), was);
}, LONG_MS),
};
return;
}
cancelPress();
lastTap = null;
pinch = touches.size === 2 && !ownsTouch(target) ? { dist: pair().dist, acc: 0 } : null;
}
function onMove(e: PointerEvent): void {
if (e.pointerType !== "touch" || !touches.has(e.pointerId)) return;
const p = { x: e.clientX, y: e.clientY };
touches.set(e.pointerId, p);
if (press && press.id === e.pointerId && Math.hypot(p.x - press.x, p.y - press.y) > SLOP_PX) {
cancelPress();
lastTap = null;
}
if (!pinch || touches.size !== 2) return;
const { mid, dist } = pair();
if (pinch.dist > 0 && dist > 0) pinch.acc += Math.log(dist / pinch.dist);
pinch.dist = dist;
if (Math.abs(pinch.acc) < PINCH_STEP) return;
fireWheel(at(mid, e.target as Element), mid, -100 * pinch.acc);
pinch.acc = 0;
}
function onUp(e: PointerEvent): void {
if (e.pointerType !== "touch") return;
touches.delete(e.pointerId);
if (touches.size < 2) pinch = null;
if (!press || press.id !== e.pointerId) return;
const tap = press;
cancelPress();
const now = Date.now();
if (
lastTap &&
now - lastTap.t < DOUBLE_MS &&
Math.hypot(tap.x - lastTap.x, tap.y - lastTap.y) < DOUBLE_PX
) {
lastTap = null;
setTimeout(() => {
if (nativeDblAt < now) fire("dblclick", at(tap, tap.target), tap);
}, NATIVE_WAIT_MS);
return;
}
lastTap = { x: tap.x, y: tap.y, t: now };
}
function onCancel(e: PointerEvent): void {
if (e.pointerType !== "touch") return;
touches.delete(e.pointerId);
cancelPress();
pinch = null;
}
function onNativeMenu(e: Event): void {
if (synthetic.has(e)) return;
if (Date.now() - longAt < AFTER_LONG_MS) {
// 우리가 이미 냄 — 브라우저 것은 하나 더라 버림
e.preventDefault();
e.stopImmediatePropagation();
} else if (press) {
nativeMenuAt = Date.now(); // 브라우저가 먼저 냄 — 우리 것은 안 냄
cancelPress();
}
}
function onClick(e: Event): void {
if (synthetic.has(e) || Date.now() - longAt >= AFTER_LONG_MS) return;
longAt = 0;
e.preventDefault();
e.stopImmediatePropagation();
}
function listen(): void {
const opts = { capture: true, passive: true } as const;
window.addEventListener("pointerdown", onDown, opts);
window.addEventListener("pointermove", onMove, opts);
window.addEventListener("pointerup", onUp, opts);
window.addEventListener("pointercancel", onCancel, opts);
window.addEventListener("contextmenu", onNativeMenu, true);
window.addEventListener("click", onClick, true);
window.addEventListener(
"dblclick",
(e) => {
if (!synthetic.has(e)) nativeDblAt = Date.now();
},
true,
);
}
// 브라우저에서만(노드 시험이 이 모듈을 거쳐 불러도 됨)
if (typeof window !== "undefined" && typeof window.addEventListener === "function") listen();
+27
View File
@@ -114,3 +114,30 @@ def _search_sync(query: str, size: int) -> list[dict[str, Any]]:
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_sync, query.strip(), size)
def _reverse_sync(lon: float, lat: float) -> str | None:
"""좌표 → 주소 한 줄(VWorld getAddress · 도로명 먼저 · 없으면 지번)."""
query = urllib.parse.urlencode(
{
"service": "address",
"request": "getAddress",
"version": "2.0",
"crs": "epsg:4326",
"point": f"{lon},{lat}",
"type": "both",
"format": "json",
"key": VWORLD_API_KEY,
}
)
try:
body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8"))
except Exception:
return None
items = (body.get("response") or {}).get("result") or []
texts = {item.get("type"): item.get("text") for item in items if isinstance(item, dict)}
return texts.get("road") or texts.get("parcel") or None
async def reverse_geocode(lon: float, lat: float) -> str | None:
return await asyncio.to_thread(_reverse_sync, lon, lat)
@@ -19,6 +19,8 @@ export interface AddressFieldHandle {
root: HTMLElement;
/** 저장할 한 줄 주소. 비면 null. */
value: () => string | null;
/** 고른(또는 지도 확인으로 찾은) 좌표. 모르면 null. */
point: () => { lat: number; lon: number } | null;
}
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
@@ -94,7 +96,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
label: "지도 확인",
variant: "ghost",
onClick: async function onB01_Address_Map_Click() {
await map.show(base.input.value.trim());
picked = await map.show(base.input.value.trim());
},
});
mapBtn.hidden = true;
@@ -102,6 +104,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
let manual = false;
let picked: { lat: number; lon: number } | null = null;
function pick(item: AddressCandidate): void {
base.input.value = candidateLine(item);
@@ -109,6 +112,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
zip.hidden = !item.zipcode;
list.hidden = true;
list.innerHTML = "";
picked = { lat: item.lat, lon: item.lon };
map.showPoint(item.lat, item.lon);
detail.input.focus();
}
@@ -160,7 +164,7 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
});
// 이미 있는 주소는 열자마자 지도로 보여 준다.
if (initial && initial.trim()) void map.show(initial.trim());
if (initial && initial.trim()) void map.show(initial.trim()).then((p) => (picked ??= p));
return {
root,
@@ -168,5 +172,6 @@ export function buildAddressField(initial: string | null): AddressFieldHandle {
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
return merged || null;
},
point: () => picked,
};
}
@@ -58,11 +58,17 @@ export function buildKnowledgeChat(): HTMLElement {
input.rows = 2;
input.required = true;
input.maxLength = 500;
input.placeholder = "메시지를 입력하세요";
input.placeholder = "메시지를 입력하세요 (Shift+Enter 줄바꿈)";
input.setAttribute("aria-label", "질문");
const submit = document.createElement("button");
submit.type = "submit";
submit.textContent = "보내기";
input.addEventListener("keydown", (event) => {
if (event.key === "Enter" && !event.shiftKey && !event.isComposing) {
event.preventDefault();
submit.click();
}
});
form.append(input, submit);
panel.append(header, messages, form);
wrap.append(toggle, panel);
+6 -4
View File
@@ -32,7 +32,8 @@ function tileIndex(
export function buildAddressMap(): {
root: HTMLElement;
show: (address: string) => Promise<void>;
/** 찾은 좌표를 돌려줌 — 못 찾으면 null. */
show: (address: string) => Promise<{ lat: number; lon: number } | null>;
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
showPoint: (lat: number, lon: number) => void;
} {
@@ -110,17 +111,18 @@ export function buildAddressMap(): {
note.textContent = message;
}
const show = async (address: string): Promise<void> => {
const show = async (address: string): Promise<{ lat: number; lon: number } | null> => {
if (!address.trim()) {
fail("주소를 먼저 입력하십시오.");
return;
return null;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
return;
return null;
}
draw(point.lat, point.lon);
return { lat: point.lat, lon: point.lon };
};
return { root, show, showPoint: draw };
+43 -32
View File
@@ -9,7 +9,10 @@ import {
} from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { navigateTo } from "../A00_Common/router";
import { createStepBar } from "@ui/ui_template_workflow_layout";
import { setDrawerSteps } from "../A00_Common/app_shell";
import { settingLinks } from "../A00_Common/m_settings_nav";
import { currentRoute, navigateTo } from "../A00_Common/router";
import {
fetchAllCompanies,
fetchAllUsers,
@@ -162,40 +165,15 @@ function settingCard(title: string, desc: string, go: () => void): HTMLElement {
return card;
}
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
/** 시스템 설정 — 카드 규칙은 `settingLinks` 한 벌(☰ 바로가기와 같이 씀 · 334) */
function buildSystemSettingsPanel(user: DashboardUser, companyId: number | null): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "b01-dashboard__setting-grid";
// 시스템 관리자는 고른 회사를 해시에 실음(29-1)
const company: Record<string, string> =
user.role === "SYSTEM_ADMIN" && companyId ? { company: String(companyId) } : {};
const m02 = (title: string, open: string, root: string, desc: string): HTMLElement =>
settingCard(title, desc, () =>
navigateTo(ROUTES.M02_MASTER_TEMPLATE, { open, root, ...company }),
);
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
if (user.role === "SYSTEM_ADMIN") {
const desc = L("B01_Dashboard_PermMasterWrite");
wrap.append(
settingCard(L("B01_Dashboard_MasterElements"), L("B01_Dashboard_PermSourceRead"), () =>
navigateTo(ROUTES.M01_MASTER_DATA),
),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
);
return wrap;
}
// 회사 수정 = 회사 관리자 · 회사 마스터(M02 accessFor 와 같은 규칙)
const companyAdmin = user.role === "ADMIN" || user.is_master;
const root = companyAdmin ? "company" : "personal";
const desc = [
L("B01_Dashboard_PermMasterRead"),
L(companyAdmin ? "B01_Dashboard_PermCompanyWrite" : "B01_Dashboard_PermCompanyRead"),
L("B01_Dashboard_PermPersonalWrite"),
].join(" · ");
wrap.append(m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc));
wrap.append(
...settingLinks(user, companyId).map((link) =>
settingCard(link.title, link.desc, () => navigateTo(link.route, link.query)),
),
);
return wrap;
}
@@ -231,6 +209,38 @@ function buildSystemSettingsSection(state: DashboardState): HTMLElement {
return card;
}
/** ☰ 구역 바로가기 아이콘(334) — 구역 제목 키로 짝(EN 글에서도 같음) */
const SECTION_ICONS: [Parameters<typeof L>[0], string][] = [
["B01_Dashboard_Resources", "📊"],
["B01_Dashboard_SystemSettings", "⚙️"],
["B01_Dashboard_Projects", "🗂️"],
["B01_Temp_Section", "📦"],
["B01_Dashboard_Users", "👥"],
["B01_Dashboard_Members", "👥"],
["B01_Dashboard_JoinRequests", "📨"],
["B01_Dashboard_Companies", "🏢"],
["B01_Dashboard_Company", "🏢"],
["B01_Dashboard_Profile", "👤"],
["B01_Account_Section_Security", "🔒"],
["B01_Dashboard_AuditLogs", "📜"],
];
/** ☰ 구역 바로가기(334) — 그린 구역 그대로 · 화면 차례 · 누름 = 서랍 닫힘 + 그 구역으로 굴림 */
function mountSectionShortcuts(grid: HTMLElement): void {
// 자료 기다리는 사이 다른 쪽으로 떠났으면 맡기지 않음
if (currentRoute() !== ROUTES.B01_ACCOUNT) return;
const sections = [...grid.children] as HTMLElement[];
const titles = sections.map((s) => s.querySelector(".ui-card__title")?.textContent ?? "");
const icons = titles.map((title) => SECTION_ICONS.find(([key]) => L(key) === title)?.[1] ?? "");
const bar = createStepBar(titles, -1, {
orientation: "vertical",
icons,
routes: titles,
onStepClick: (index) => sections[index].scrollIntoView({ behavior: "smooth", block: "start" }),
});
setDrawerSteps(bar);
}
function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div");
@@ -311,6 +321,7 @@ function buildPage(state: DashboardState): HTMLElement {
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
}
page.append(grid);
mountSectionShortcuts(grid);
page.append(buildKnowledgeChat());
const layout = createGeneralLayout({
pageClass: "b01-dashboard",
+18 -11
View File
@@ -536,8 +536,8 @@
.b01-knowledge-chat__toggle,
.b01-knowledge-chat__form button {
border: 0;
background: var(--color-royal-amethyst, #6843a7);
color: #fff;
background: var(--color-primary);
color: var(--color-primary-text);
font: inherit;
cursor: pointer;
}
@@ -560,12 +560,12 @@
display: flex;
flex-direction: column;
width: min(380px, calc(100vw - 32px));
height: min(680px, calc(100vh - 104px));
height: min(680px, calc(100dvh - 104px));
border: 1px solid var(--color-border);
border-radius: 12px;
background: var(--color-surface, #fff);
box-shadow: 0 8px 28px #0003;
color: var(--color-text-primary, #222);
color: var(--color-text-body);
overflow: hidden;
overscroll-behavior: contain;
}
@@ -588,8 +588,8 @@
height: 34px;
place-items: center;
border-radius: 50%;
background: var(--color-royal-amethyst, #6843a7);
color: #fff;
background: var(--color-primary);
color: var(--color-primary-text);
}
.b01-knowledge-chat__avatar svg {
@@ -631,14 +631,15 @@
.b01-knowledge-chat__user {
align-self: flex-end;
border-bottom-right-radius: 4px;
background: var(--color-royal-amethyst, #6843a7);
color: #fff;
background: var(--color-primary);
color: var(--color-primary-text);
}
.b01-knowledge-chat__reply {
align-self: flex-start;
border: 1px solid var(--color-border);
border-bottom-left-radius: 4px;
background: var(--color-background, #f4f4f4);
background: var(--color-surface-raised);
}
.b01-knowledge-chat__reply details {
@@ -722,7 +723,7 @@
.b01-knowledge-source-modal__content code {
padding: 0.1em 0.35em;
border-radius: 4px;
background: var(--color-background, #f4f4f4);
background: var(--color-surface);
}
.b01-knowledge-source-modal__content .ui-markdown-table {
@@ -746,7 +747,7 @@
}
.b01-knowledge-source-modal__content th {
background: var(--color-background, #f4f4f4);
background: var(--color-surface);
}
.b01-knowledge-chat__form {
@@ -765,11 +766,17 @@
padding: 8px;
border: 1px solid var(--color-border);
border-radius: 6px;
background: var(--color-canvas);
color: var(--color-text-body);
font: inherit;
resize: vertical;
overflow-y: auto;
}
.b01-knowledge-chat__form textarea::placeholder {
color: var(--color-text-muted);
}
.b01-knowledge-chat__form button {
padding: 9px 14px;
border-radius: 8px;
+10 -1
View File
@@ -57,11 +57,14 @@ export interface UploadStatusResponse {
*/
export class ReplaceOutputsConfirmRequired extends Error {
readonly targets: string[];
/** 지형 파일이 그대로라 전처리 결과를 살리는지 — 「처음부터 다시」를 따로 묻는다. */
readonly preprocessKept: boolean;
constructor(message: string, targets: string[]) {
constructor(message: string, targets: string[], preprocessKept = false) {
super(message);
this.name = "ReplaceOutputsConfirmRequired";
this.targets = targets;
this.preprocessKept = preprocessKept;
}
}
@@ -70,12 +73,14 @@ async function readJsonOrThrow<T>(response: Response): Promise<T> {
message?: string;
confirm?: string;
targets?: string[];
preprocess_kept?: boolean;
};
if (!response.ok) {
if (response.status === 409 && payload.confirm === "replace_outputs") {
throw new ReplaceOutputsConfirmRequired(
payload.message ?? "이미 만들어 둔 결과가 지워집니다.",
payload.targets ?? [],
payload.preprocess_kept === true,
);
}
throw new Error(payload.message ?? `HTTP ${response.status}`);
@@ -115,6 +120,7 @@ export async function createUploadSession(
completeUpload = false,
lasFree = false,
confirmReplace = false,
freshStart = false,
): Promise<ChunkSessionCreateResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/upload-sessions`, {
method: "POST",
@@ -128,6 +134,7 @@ export async function createUploadSession(
complete_upload: completeUpload,
las_free: lasFree,
confirm_replace: confirmReplace,
fresh_start: freshStart,
}),
});
return await readJsonOrThrow<ChunkSessionCreateResponse>(response);
@@ -160,6 +167,7 @@ export async function finalizeUploadSession(
fingerprint?: string | null,
lasFree = false,
confirmReplace = false,
freshStart = false,
): Promise<FileUploadResponse> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/finalize`, {
method: "POST",
@@ -172,6 +180,7 @@ export async function finalizeUploadSession(
fingerprint: fingerprint ?? null,
las_free: lasFree,
confirm_replace: confirmReplace,
fresh_start: freshStart,
}),
});
return await readJsonOrThrow<FileUploadResponse>(response);
@@ -113,6 +113,7 @@ async def create_project_upload_session(
project_id,
payload.las_free,
payload.confirm_replace,
payload.fresh_start,
)
await connection.commit()
except Exception:
@@ -309,6 +310,7 @@ async def finalize_project_upload(
project_id,
payload.las_free,
payload.confirm_replace,
payload.fresh_start,
)
await connection.commit()
except Exception:
+33 -6
View File
@@ -29,7 +29,7 @@ from B03_FileInput.B03_FileInput_Schema import (
)
from common_util.common_util_initial_snapshot import clear_designing, discard_initial_snapshot
from common_util.common_util_json import atomic_write_json
from common_util.common_util_project_reset import purge_project_outputs
from common_util.common_util_project_reset import PREPROCESS_STAGE_DIR, purge_project_outputs
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import (
complete_stage,
@@ -61,8 +61,10 @@ class OutputsWouldBeDiscarded(Exception):
로 다시 들어온다. 자동 절차는 이 예외를 만들지 않는다 — 부를 때 확인을 켜 준다.
"""
def __init__(self, targets: list[str]) -> None:
def __init__(self, targets: list[str], preprocess_kept: bool = False) -> None:
self.targets = targets
# 지형 파일이 그대로라 전처리 결과를 살리는지 — 화면이 「처음부터 다시」를 따로 묻는다.
self.preprocess_kept = preprocess_kept
super().__init__("설계 산출물이 있어 확인이 필요합니다.")
@@ -78,14 +80,17 @@ def _confirm_replace_response(exc: "OutputsWouldBeDiscarded") -> JSONResponse:
"아래가 지워지고 **되돌릴 수 없습니다**: " + " · ".join(exc.targets)
),
"targets": exc.targets,
"preprocess_kept": exc.preprocess_kept,
},
)
def describe_existing_outputs(project_root: Path) -> list[str]:
def describe_existing_outputs(project_root: Path, keep_preprocess: bool = False) -> list[str]:
"""새 자료를 받으면 **지워질 것**의 이름을 사람 말로 늘어놓는다. 없으면 빈 목록."""
targets: list[str] = []
for stage, label in _OUTPUT_STAGE_LABELS:
if keep_preprocess and stage == PREPROCESS_STAGE_DIR:
continue
stage_root = project_root / stage
if not stage_root.is_dir():
continue
@@ -201,16 +206,37 @@ async def _already_uploaded(
)
def preprocess_input_unchanged(project_root: Path, terrain_paths: list[Path]) -> bool:
"""지형 파일이 마지막 전처리 때와 같은지 — `processed/source_identity.json` 과 대조.
같은 지문(크기 + 앞·가운데·끝) 파일은 전송을 건너뛰어 파일이 그대로 남는다 —
이름·크기·수정시각이 전처리가 적어 둔 값과 같으면 같은 입력이다.
"""
if not terrain_paths:
return False
from B04_PreProcess.B04_PreProcess_Engine import _source_identity
identity_path = project_root / PREPROCESS_STAGE_DIR / "processed" / "source_identity.json"
try:
stored = json.loads(identity_path.read_text(encoding="utf-8"))
return stored == _source_identity(terrain_paths)
except (OSError, ValueError):
return False
async def _complete_file_input_if_ready(
connection: aiomysql.Connection,
project_id: UUID,
las_free: bool = False,
confirm_replace: bool = True,
fresh_start: bool = False,
) -> int:
"""새 자료로 갈아 끼운다. `confirm_replace=False` 면 지울 것이 있을 때 멈추고 묻는다.
기본이 `True` 인 까닭 — 자동 절차(체인·스크립트)는 물음에 걸리면 안 된다.
사람이 올리는 갈래(라우터)만 `False` 로 불러 확인을 받는다(2026-09-08 사용자 지시).
지형 파일이 그대로면 전처리 결과는 살리고 단계 표시만 되돌린다 —
`fresh_start=True`(확인 창의 「처음부터 다시」)면 전처리까지 지운다.
"""
file_types, point_cloud_input_id, route_csv_input_id = await get_project_input_readiness(
connection, project_id
@@ -234,14 +260,15 @@ async def _complete_file_input_if_ready(
gap_message = merge_gap_error(terrain_paths)
if gap_message:
raise ValueError(gap_message)
keep_preprocess = not fresh_start and preprocess_input_unchanged(project_root, terrain_paths)
# ⚠ 여기서부터는 되돌릴 수 없다 — 아래 셋이 파일까지 지운다. 지울 것이 있으면 먼저 묻는다.
if not confirm_replace:
targets = describe_existing_outputs(project_root)
targets = describe_existing_outputs(project_root, keep_preprocess)
if targets:
raise OutputsWouldBeDiscarded(targets)
raise OutputsWouldBeDiscarded(targets, keep_preprocess)
clear_designing(project_root)
discard_initial_snapshot(project_root)
await purge_project_outputs(connection, str(project_id), project_root)
await purge_project_outputs(connection, str(project_id), project_root, keep_preprocess)
async with connection.cursor(aiomysql.DictCursor) as cursor:
await reset_stages_after_input_change(cursor, str(project_id))
await complete_stage(cursor, str(project_id), 0)
+4
View File
@@ -65,6 +65,8 @@ class ChunkSessionCreateRequest(FileUploadDescriptor):
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
# 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다.
fresh_start: bool = False
class ChunkSessionCreateResponse(BaseModel):
@@ -109,6 +111,8 @@ class UploadFinalizeRequest(BaseModel):
# 이미 있는 설계 산출물을 지워도 좋다는 사람의 대답(2026-09-08). 거짓이면 지울 것이
# 있을 때 409 로 멈추고 무엇이 지워지는지 알린다 — 되돌릴 수 없는 삭제라서다.
confirm_replace: bool = False
# 확인 창의 「처음부터 다시」 — 지형 파일이 그대로여도 전처리 결과까지 지운다.
fresh_start: bool = False
class UploadStatusResponse(BaseModel):
+99 -70
View File
@@ -18,6 +18,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어
import asyncio
import logging
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -65,7 +66,11 @@ def _planned_route_points_in_project_crs(
async def _prepare_drainage_pipes_and_reprofile(
project_id: UUID, route_id: int, *, refresh: bool = False
project_id: UUID,
route_id: int,
*,
refresh: bool = False,
stored_options: dict[str, Any] | None = None,
) -> bool:
"""배수유역 분석 → 기본 관 지점 확정 저장 → 배관 정착 계획선으로 종횡단 재생성.
@@ -75,12 +80,13 @@ async def _prepare_drainage_pipes_and_reprofile(
들어왔을 때 배수유역도도 비어 있게 된다 — 여기서 미리 계산해 영구저장소에 남긴다.
실패해도 예외를 던지지 않는다 — 관 없이 만든 계획선이라도 남는 편이 낫다.
`stored_options` = 노선 [확인] 체인이 건너뛴 첫 생성의 옵션(342-2) — 반폭 · 재생성 옵션.
"""
from B04_PreProcess.B04_PreProcess_Router_Basins import put_pipe_points
from B04_PreProcess.B04_PreProcess_Router_Watershed import get_primary_region
from B06_Section.B06_Section_Repository import get_latest_section_options
from B06_Section.B06_Section_Router import regenerate_sections
from B06_Section.B06_Section_Schema import SectionRegenerateRequest
from B06_Section.B06_Section_Router import regenerate_route_sections
from config.config_db import get_db_pool
from config.config_system import SECTION_CROSS_HALF_WIDTH_M
@@ -113,14 +119,16 @@ async def _prepare_drainage_pipes_and_reprofile(
marks.append(("관 지점 확정", time.perf_counter()))
# 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다.
pool = get_db_pool()
async with pool.acquire() as connection:
stored_options = await get_latest_section_options(connection, project_id)
seeded = stored_options is not None
if not seeded:
pool = get_db_pool()
async with pool.acquire() as connection:
stored_options = await get_latest_section_options(connection, project_id)
half_width = float(
(stored_options or {}).get("cross_half_width_m") or SECTION_CROSS_HALF_WIDTH_M
)
regenerated = await regenerate_sections(
project_id, route_id, SectionRegenerateRequest(cross_half_width_m=half_width)
regenerated = await regenerate_route_sections(
project_id, route_id, half_width, stored_options=stored_options if seeded else None
)
if isinstance(regenerated, JSONResponse):
logger.error(
@@ -319,26 +327,8 @@ async def run_auto_design_chain(
route_id,
)
# 3D 예상형상(코리도) — 브라우저가 쓰는 TS 빌더를 서버에서 한 번 돌려 영구저장한다
# (2026-09-04 사용자 확정). 이걸 빼면 사용자가 B05에 처음 들어간 그 순간 브라우저가
# 만들어 첫 진입이 느리다. 실패는 비치명적 — 저장본이 없으면 브라우저가 예전처럼 만든다.
try:
from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor
await prebuild_corridor(project_id, route_id, project_root)
except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다
logger.exception("코리도 사전 생성 실패: project_id=%s", project_id)
# 구조물 폐회로 면적 + 유토곡선 — 화면이 쓰는 TS 를 서버에서 한 번 돌려 정본에
# 얹는다(2026-09-06). 이걸 빼면 B06 을 안 연 프로젝트의 수량이 구조물을 모르는
# 표준값으로 남고 유토곡선은 아예 없다. 초기값 스냅샷 **앞**이라 되돌릴
# 기준선에도 보정이 담긴다.
try:
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
await recompute_server_side(project_id, route_id)
except Exception: # noqa: BLE001 — 재계산 실패가 체인을 막지는 않는다
logger.exception("횡단 서버 재계산 실패: project_id=%s", project_id)
# 서버 재계산(구조물 면적 · 유토곡선)과 3D 길 모형 짓기는 [확정](confirm_sections) 끝에서
# 한 번씩 — 재계산 → 짓기 차례 · 초기값 스냅샷 **앞**이라 기준선에도 담긴다(342-2).
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
@@ -350,6 +340,18 @@ async def run_auto_design_chain(
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
# 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도
# 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다.
try:
from B03_FileInput.B03_FileInput_Router_Helpers import _schedule_background_task
from B05_Profile.B05_Profile_Router_RouteInitial import precompute_defaults
_schedule_background_task(
precompute_defaults(project_id), task_name=f"추천 노선 미리 셈 {project_id}"
)
except Exception: # noqa: BLE001 — 미리 셈이 체인을 막지는 않는다
logger.exception("추천 노선 미리 셈 예약 실패: project_id=%s", project_id)
return {
"route_id": route_id,
"length_m": float(solve_result.total_length_m or 0.0),
@@ -390,6 +392,53 @@ async def _ensure_initial_polyline(
logger.exception("초기 계획노선 폴리라인 생성 실패(계속 진행): %s", project_id)
def redesign_solve_request(
params: dict[str, Any], selection: dict[str, Any], surface_model_id: int
) -> Any:
"""stage 2 params(사용자 저장분) + 새 확정 지표면 → as_planned 풀기 요청(노선 저장도 씀)."""
from B05_Profile.B05_Profile_Schema import RouteSolveRequest
points = params.get("points") or {}
options = params.get("options") or {}
return RouteSolveRequest(
filter_key=str(selection.get("source_filter") or params.get("filter_key")),
method=str(selection.get("method") or params.get("method") or "dtm"),
smooth=bool(selection.get("smooth", params.get("smooth", False))),
surface_model_id=surface_model_id,
# 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사·
# 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측:
# 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음).
# PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다.
algorithm="as_planned",
bp=points["bp"],
ep=points["ep"],
cp=points.get("cp") or [],
ap=points.get("ap") or [],
fp=points.get("fp") or [],
station_interval_m=params.get("station_interval_m"),
cross_half_width_m=params.get("cross_half_width_m"),
cross_sample_interval_m=params.get("cross_sample_interval_m"),
long_sample_interval_m=params.get("long_sample_interval_m"),
**{key: options.get(key) for key in ("grade_class",) if options.get(key)},
paved=bool(options.get("paved", False)),
terrain_type=str(options.get("terrain_type") or "special"),
main_direction=str(options.get("main_direction") or "auto"),
min_curve_radius_m=options.get("min_curve_radius_m"),
max_uphill_grade=options.get("max_uphill_grade"),
max_downhill_grade=options.get("max_downhill_grade"),
min_uphill_grade=options.get("min_uphill_grade"),
min_downhill_grade=options.get("min_downhill_grade"),
weights=options.get("weights"),
allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)),
max_grade_pct=params.get("max_grade_pct"),
min_vertical_radius_m=params.get("min_vertical_radius_m"),
min_tangent_length_m=params.get("min_tangent_length_m"),
balance_segment_length_m=params.get("balance_segment_length_m"),
start_elevation_offset_m=params.get("start_elevation_offset_m"),
end_elevation_offset_m=params.get("end_elevation_offset_m"),
)
async def run_redesign_chain(
project_id: UUID,
surface_model_id: int,
@@ -414,13 +463,18 @@ async def run_redesign_chain(
"""
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Repository import get_latest_route
from B05_Profile.B05_Profile_Router import confirm_latest_route, solve_route
from B05_Profile.B05_Profile_Schema import RouteSolveRequest
from B05_Profile.B05_Profile_Router import (
_section_options,
confirm_latest_route,
solve_route_record,
)
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_latest_section_options,
get_longitudinal_section,
)
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router import regenerate_route_sections
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages, confirm_sections
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, compute_default_designs
from B06_Section.B06_Section_Schema import SectionConfirmRequest
@@ -464,12 +518,12 @@ async def run_redesign_chain(
state = await get_workflow_state(cursor, str(project_id))
old_longitudinal = await get_longitudinal_section(connection, project_id, old_route_id)
old_designs = await get_cross_section_designs(connection, old_route_id)
old_section_options = await get_latest_section_options(connection, project_id)
stage2 = next(
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
)
params = (stage2 or {}).get("params") or {}
points = params.get("points") or {}
options = params.get("options") or {}
if not points.get("bp") or not points.get("ep"):
logger.warning(
"재확정 체인 중단(stage 2 params에 제어점 없음): project_id=%s", project_id
@@ -479,44 +533,9 @@ async def run_redesign_chain(
marks.append(("준비·사용자입력 회수", time.perf_counter()))
# 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분.
request = RouteSolveRequest(
filter_key=str(selection.get("source_filter") or params.get("filter_key")),
method=str(selection.get("method") or params.get("method") or "dtm"),
smooth=bool(selection.get("smooth", params.get("smooth", False))),
surface_model_id=surface_model_id,
# 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사·
# 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측:
# 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음).
# PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다.
algorithm="as_planned",
bp=points["bp"],
ep=points["ep"],
cp=points.get("cp") or [],
ap=points.get("ap") or [],
fp=points.get("fp") or [],
station_interval_m=params.get("station_interval_m"),
cross_half_width_m=params.get("cross_half_width_m"),
cross_sample_interval_m=params.get("cross_sample_interval_m"),
long_sample_interval_m=params.get("long_sample_interval_m"),
**{key: options.get(key) for key in ("grade_class",) if options.get(key)},
paved=bool(options.get("paved", False)),
terrain_type=str(options.get("terrain_type") or "special"),
main_direction=str(options.get("main_direction") or "auto"),
min_curve_radius_m=options.get("min_curve_radius_m"),
max_uphill_grade=options.get("max_uphill_grade"),
max_downhill_grade=options.get("max_downhill_grade"),
min_uphill_grade=options.get("min_uphill_grade"),
min_downhill_grade=options.get("min_downhill_grade"),
weights=options.get("weights"),
allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)),
max_grade_pct=params.get("max_grade_pct"),
min_vertical_radius_m=params.get("min_vertical_radius_m"),
min_tangent_length_m=params.get("min_tangent_length_m"),
balance_segment_length_m=params.get("balance_segment_length_m"),
start_elevation_offset_m=params.get("start_elevation_offset_m"),
end_elevation_offset_m=params.get("end_elevation_offset_m"),
)
solve_result: Any = await solve_route(project_id, request)
request = redesign_solve_request(params, selection, surface_model_id)
# 종횡단은 관을 정한 뒤 한 번만 만든다(342-2) — 여기서 만들면 아래 4c 가 통째로 덮는 헛일.
solve_result: Any = await solve_route_record(project_id, request, generate_sections=False)
if isinstance(solve_result, JSONResponse):
# 사유까지 남긴다 — 상태 코드만으로는 무엇이 막았는지 못 짚는다(2026-09-06
# 노선 편집 [확인]이 조용히 400 으로 끊겨 종횡단만 옛 노선에 남았음).
@@ -559,7 +578,17 @@ async def run_redesign_chain(
marks.append(("B05 확정", time.perf_counter()))
# 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로
# 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다.
await _prepare_drainage_pipes_and_reprofile(project_id, new_route_id, refresh=True)
# 건너뛴 첫 생성이 남겼을 옵션(요청 → 옛 저장 → 기본) — 재생성이 그것을 읽어 결과가 같다.
seed = asdict(_section_options(request, old_section_options))
seed.pop("extra_stations", None)
piped = await _prepare_drainage_pipes_and_reprofile(
project_id, new_route_id, refresh=True, stored_options=seed
)
if not piped:
# 관 없이라도 종횡단은 세운다 — 새 노선엔 아직 종횡단이 없다(첫 생성을 건너뜀).
await regenerate_route_sections(
project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed
)
marks.append(("배수·관·측점 재생성", time.perf_counter()))
+1 -1
View File
@@ -1,5 +1,5 @@
.b03-file {
min-height: calc(100vh - var(--app-header-height, 0px));
min-height: calc(100dvh - var(--app-header-height, 0px));
background: linear-gradient(
180deg,
var(--color-canvas, #ffffff) 0%,
+1 -1
View File
@@ -74,7 +74,7 @@ export function createTempPicker(
const backdrop = document.createElement("div");
backdrop.className = "b03-file__modal-backdrop";
const modal = document.createElement("div");
modal.className = "b03-file__modal b03-file__temp-modal";
modal.className = "b03-file__modal b03-file__temp-modal ui-popup";
modal.setAttribute("role", "dialog");
modal.setAttribute("aria-modal", "true");
+18 -5
View File
@@ -83,9 +83,11 @@ export function confirmReplaceUpload(slotLabel: string, fileName: string): Promi
* 되돌릴 수 없는 삭제라 확인 없이는 진행하지 않는다(2026-09-08 사용자 지시).
* 자동 절차는 이 자리를 지나지 않는다 — 서버가 확인을 켜고 부른다.
*/
async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>): Promise<T> {
async function askReplaceOutputs<T>(
run: (confirmReplace: boolean, freshStart: boolean) => Promise<T>,
): Promise<T> {
try {
return await run(false);
return await run(false, false);
} catch (error) {
if (!(error instanceof ReplaceOutputsConfirmRequired)) throw error;
const detail = error.targets.length
@@ -99,7 +101,16 @@ async function askReplaceOutputs<T>(run: (confirmReplace: boolean) => Promise<T>
되돌릴 수 없습니다. 계속할까요?`,
);
if (!agreed) throw new Error("업로드를 취소했습니다 — 기존 결과는 그대로 있습니다.");
return await run(true);
// 지형 파일이 그대로면 전처리 결과는 살린다 — 「처음부터 다시」는 여기서 따로 고른다.
const freshStart =
error.preprocessKept &&
window.confirm(
`같은 지형 파일이라 전처리 결과(지표면)는 살려 다시 씁니다.
전처리까지 처음부터 다시 할까요?
[확인] 처음부터 다시 · [취소] 전처리 결과 살림`,
);
return await run(true, freshStart);
}
}
@@ -123,7 +134,7 @@ export async function uploadOneFile(
const fingerprint = state.uploadSessionId ? null : await fileFingerprint(file);
let session = state.uploadSessionId;
if (!session) {
const created = await askReplaceOutputs((confirmReplace) =>
const created = await askReplaceOutputs((confirmReplace, freshStart) =>
createUploadSession(
projectId,
file,
@@ -132,6 +143,7 @@ export async function uploadOneFile(
completeUpload,
lasFree,
confirmReplace,
freshStart,
),
);
if (created.already_uploaded) {
@@ -184,7 +196,7 @@ export async function uploadOneFile(
}
}
const response = await askReplaceOutputs((confirmReplace) =>
const response = await askReplaceOutputs((confirmReplace, freshStart) =>
finalizeUploadSession(
projectId,
session,
@@ -193,6 +205,7 @@ export async function uploadOneFile(
fingerprint,
lasFree,
confirmReplace,
freshStart,
),
);
localStorage.removeItem(storageKey);
+54 -16
View File
@@ -18,6 +18,7 @@ import {
API_TIMEOUT_MS,
} from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
@@ -244,17 +245,49 @@ export async function listSurfaceInputFiles(
});
}
/** 점군 미리보기 고정 표본 이진(IndexedDB 보관 · ETag) — 다시 열 때 받지 않는다. */
export async function fetchSurfacePointCloud(
projectId: string,
filter?: string,
): Promise<SurfacePointCloudSampleResponse> {
const query = filter ? `?filter=${encodeURIComponent(filter)}` : "";
return requestJson<SurfacePointCloudSampleResponse>(
`/projects/${projectId}/surface/point-cloud${query}`,
{
method: "GET",
},
const bytes = await fetchCachedBytes(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/point-cloud/binary${query}`,
);
return { status: "success", project_id: projectId, ...decodePointPreview(bytes) };
}
/**
* 점군 미리보기 이진 풀기 — 짝 = `B04_PreProcess_Engine_PointPreview.py` `ensure_point_preview`.
* [머리 길이 u32][머리 JSON][4 배수 채움][xyz f32 × 3n][rgb u16 × 3n].
*/
export function decodePointPreview(
bytes: ArrayBuffer,
): Omit<SurfacePointCloudSampleResponse, "status" | "project_id"> {
const headLength = new DataView(bytes).getUint32(0, true);
const head = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes, 4, headLength))) as {
point_count: number;
sampled_count: number;
bounds: SurfaceBounds;
has_rgb: boolean;
};
const count = head.sampled_count;
const offset = Math.ceil((4 + headLength) / 4) * 4;
const xyz = new Float32Array(bytes, offset, count * 3);
const points: [number, number, number][] = new Array(count);
for (let index = 0; index < count; index += 1) {
points[index] = [xyz[index * 3], xyz[index * 3 + 1], xyz[index * 3 + 2]];
}
let rgb: [number, number, number][] | undefined;
if (head.has_rgb) {
const colors = new Uint16Array(bytes, offset + xyz.byteLength, count * 3);
rgb = new Array(count);
for (let index = 0; index < count; index += 1) {
rgb[index] = [colors[index * 3], colors[index * 3 + 1], colors[index * 3 + 2]];
}
}
return { point_count: head.point_count, sampled_count: count, bounds: head.bounds, points, rgb };
}
/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB).
@@ -471,19 +504,14 @@ export async function fetchRoadInflow(
export type PipeSource = "stream" | "spacing" | "user";
/** 계곡 통과 시설 종류(2026-08-17 컨테이너 병합). 같은 계곡 교차점에서 유량·지형에
* 따라 택일한다 — 정의처는 백엔드 `common_util_drainage_pipes`. 교량은 임도용이 아니다. */
* 따라 택일한다 — 관 지점 정본 종류 = 구조물 표 정본(`catalogRow().owner` · 323-7) · 이름 = 표 이름 ·
* 형상마다 손코딩 폼이 있어 종류 더하기(본보기)에서 뺌. 교량은 임도용이 아니다. */
export type PipeFacility = "pipe" | "box_culvert" | "ford_pavement" | "ford_bridge" | "revetment";
export const PIPE_FACILITY_LABELS: ReadonlyArray<[PipeFacility, string]> = [
["pipe", "배수관"],
["box_culvert", "BOX암거"],
["ford_pavement", "물넘이포장"],
["ford_bridge", "세월교"],
["revetment", "기슭막이"],
];
export interface DetailPipePoint {
chainage_m: number;
/** 관 고정 id(347 덧 · 측점 열쇠 `pipe:<id>`) — 서버가 붙임 · 옮겨도 그대로. */
id?: string;
lonlat: [number, number];
source: PipeSource;
/** 시설 종류 — 응답에서 생략되면 기본 배관. */
@@ -560,6 +588,13 @@ export interface DetailBasinResponse {
upstream_lonlat: Array<Array<[number, number]>>;
/** [저장] 때 못 만든 구조물 조합 사본 폴더(142-2 · `*` = 통째 실패). */
snapshot_failed?: string[];
/** 지문(342-3) — 지금 노선 · 관 자리. */
route_signature?: string;
pipe_signature?: string;
/** 분석이 낡음(노선이 분석 때와 다름) — 까닭 글 · 세부 유역 없음 · 관 자리만. */
stale?: string | null;
/** 관 자리 후보(347-3) — 제안만 · [적용] 때 관 초안에. */
pipe_candidates?: Array<{ chainage_m: number; lonlat: [number, number] }>;
}
let lastNote = { text: "", at: 0 };
@@ -585,7 +620,7 @@ export async function fetchDetailPipePoints(projectId: string): Promise<DetailBa
/** 편집 중인 관 목록으로 세부유역을 다시 나눈다(저장하지 않는다).
*
* `points`를 비우면 저장분을 무시하고 기본 관 + 자동 보충으로 되돌린다. */
* 빈 목록 = 관 0개(312) · 기본 관 + 자동 보충으로 되돌리기는 `resetDetailPipePoints`(DELETE)만. */
export async function computeDetailBasins(
projectId: string,
points: DetailPipeInput[],
@@ -609,10 +644,13 @@ export async function resetDetailPipePoints(projectId: string): Promise<DetailBa
export async function saveDetailPipePoints(
projectId: string,
points: DetailPipeInput[],
recompute = false,
): Promise<DetailBasinResponse> {
// [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림.
const saved = await requestJson<DetailBasinResponse>(
`/projects/${projectId}/drainage/pipe-points`,
`/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`,
{ method: "PUT", body: JSON.stringify({ points }) },
recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
);
noteSnapshotFailed(saved.snapshot_failed);
return saved;
+106 -6
View File
@@ -22,6 +22,8 @@ from B04_PreProcess.B04_PreProcess_Engine_Ground import (
from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models
from B04_PreProcess.B04_PreProcess_Engine_Structurize import structurize_las
from common_util.common_util_atomic import atomic_write_npz
from common_util.common_util_derived_store import file_stamp
from common_util.common_util_derived_store import key_matches as _key_matches
from common_util.common_util_json import atomic_write_json
from config.config_system import (
SHEET_SURFACE_AUTO_METHODS,
@@ -55,6 +57,79 @@ def _source_identity(las_paths: list[Path]) -> dict[str, Any]:
return files[0] if len(files) == 1 else {"files": files}
class _LazyXyz:
"""structured.npz 의 xyz — 처음 값을 쓸 때만 푼다. 길이는 저장해 둔 점 수로 답한다."""
def __init__(self, structured: Any, count: int, array: np.ndarray | None = None) -> None:
self._structured = structured
self._count = count
self._array = array
def _load(self) -> np.ndarray:
if self._array is None:
self._array = self._structured["xyz"]
return self._array
def __len__(self) -> int:
return self._count
def __getitem__(self, key: Any) -> Any:
return self._load()[key]
def __array__(self, dtype: Any = None, copy: Any = None) -> np.ndarray:
return np.asarray(self._load(), dtype=dtype)
def __getattr__(self, name: str) -> Any:
return getattr(self._load(), name)
class _LazyStructured(dict):
"""구조화 결과 — 열쇠마다 처음 쓸 때만 푼다(저장본이 다 맞는 다시 돎은 점군을 안 풂)."""
def __init__(self, structured: Any, xyz: _LazyXyz) -> None:
super().__init__()
self._structured = structured
self["xyz"] = xyz
# 옛 dict 와 같은 열쇠만 보인다 — 필터가 열쇠 유무로 갈리는 일이 없게.
_KEYS = ("classification",)
def __missing__(self, key: str) -> Any:
if key not in self._KEYS:
raise KeyError(key)
value = self._structured[key]
self[key] = value
return value
def __contains__(self, key: object) -> bool:
return dict.__contains__(self, key) or key in self._KEYS
def _structured_stats(structured: Any, structured_path: Path) -> tuple[dict[str, Any], Any]:
"""점 수 · 평균 Z 를 `structured_stats.json` 에서 읽는다 — 없거나 낡으면 한 번 풀어 적는다.
돌려주는 둘째 값 = 이번에 푼 xyz(없으면 None) — 같은 부름 안에서 다시 풀지 않게.
"""
stats_path = structured_path.parent / "structured_stats.json"
stat = structured_path.stat()
stamp = {"size_bytes": int(stat.st_size), "mtime_ns": int(stat.st_mtime_ns)}
try:
stored = json.loads(stats_path.read_text(encoding="utf-8"))
if stored.get("structured") == stamp:
return stored, None
except (OSError, ValueError, AttributeError):
pass
xyz = structured["xyz"]
count = int(len(xyz))
stats = {
"structured": stamp,
"point_count": count,
"mean_z": float(np.mean(xyz[:, 2])) if count else None,
}
atomic_write_json(stats_path, stats)
return stats, xyz
def _relative_to_project(project_root: Path, path: Path) -> str:
"""프로젝트 루트 기준 posix 상대 경로 문자열."""
return path.relative_to(project_root).as_posix()
@@ -179,13 +254,14 @@ def run_surface_analysis(
_report(10, "structurize", "구조화 캐시 재사용")
logger.info("B04 구조화 캐시 재사용: %s", structured_path.name)
with np.load(structured_path) as structured:
xyz = structured["xyz"]
# 점 수 · 평균 Z 는 저장값 — 저장본이 다 맞으면 점군(841 MB 압축)을 풀지 않는다.
stored_stats, loaded_xyz = _structured_stats(structured, structured_path)
bounds = structured["bounds"]
total_points = int(len(xyz))
total_points = int(stored_stats["point_count"])
stats = {
"min_z": float(bounds[2, 0]),
"max_z": float(bounds[2, 1]),
"mean_z": float(np.mean(xyz[:, 2])) if total_points else None,
"mean_z": stored_stats["mean_z"],
}
bounds_dict = {
"x_min": float(bounds[0, 0]),
@@ -193,7 +269,8 @@ def run_surface_analysis(
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
}
data = {"xyz": xyz, "bounds": bounds, "classification": structured["classification"]}
data = _LazyStructured(structured, _LazyXyz(structured, total_points, loaded_xyz))
data["bounds"] = bounds
# 2. 지면 필터 실행 — mask_{filter}.npy 영구 캐시 우선 재사용 (PLAN A-1)
_report(40, "ground_filter", "지면 필터 적용 중")
@@ -242,6 +319,15 @@ def run_surface_analysis(
):
continue
cache_ground_points(structured_path, filter_key, mask)
# 점군 미리보기 고정 표본(이진) — 원천이 그대로면 머리만 보고 건너뜀
try:
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview
ensure_point_preview(structured_path, None, data["xyz"])
for filter_key in masks:
ensure_point_preview(processed_dir / f"ground_points_{filter_key}.npz", filter_key)
except Exception as exc: # noqa: BLE001 — 미리보기 실패가 분석을 막지 않음
logger.warning("B04 점군 미리보기 이진 생성 실패: %s", exc)
# 3. 지표면 5종 모델 빌드
_report(70, "surface_model", "지표면 모델 생성 중")
@@ -386,14 +472,21 @@ def download_geodata(
# 등고선은 국가 gpkg 추가 시점이 전처리보다 늦을 수 있으므로 개별로 존재 여부를 확인한다.
contour_geojson = processed_dir / "등고선_bounds.geojson"
# 열쇠 = 받은 범위 · 좌표계 — 노선이 바뀌어 범위가 달라지면 다시 받는다.
gis_key_path = processed_dir / "gis_vectors.key.json"
gis_key = json.loads(
json.dumps({"epsg": project_epsg, "bounds": bounds_dict_for_download}, default=float)
)
if (
rebuild
or not any(processed_dir.glob("*_bounds.geojson"))
or not contour_geojson.exists()
or not _key_matches(gis_key_path, gis_key)
):
try:
step_started = time.monotonic()
download_all_gis_vectors(prj_path, bounds_dict_for_download, processed_dir)
atomic_write_json(gis_key_path, gis_key)
logger.info(
"B04 국가 GIS 벡터 다운로드 완료 (%.1fs)", time.monotonic() - step_started
)
@@ -433,9 +526,13 @@ def download_geodata(
# 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함).
prune_sheets(sheet_store, sheet_grid)
# 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 (기존 산출물 있으면 스킵)
# 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록)가 같으면 스킵
merge_key_path = processed_dir / "sheet_merge.key.json"
merge_key = {"sheets": sorted(str(item) for item in sheet_result["available"])}
if sheet_result["available"] and (
rebuild or not any(processed_dir.glob("도엽_*.geojson"))
rebuild
or not any(processed_dir.glob("도엽_*.geojson"))
or not _key_matches(merge_key_path, merge_key)
):
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import (
parse_and_merge_sheets,
@@ -450,6 +547,7 @@ def download_geodata(
{k: v["count"] for k, v in merged.items()},
time.monotonic() - step_started,
)
atomic_write_json(merge_key_path, merge_key)
except Exception as exc:
logger.warning("B04 수치지형도 도엽 확보 실패: %s", exc)
except Exception as exc:
@@ -510,6 +608,8 @@ def _collect_analysis_result(
"source_filter": filter_key,
"representation": meta.get("representation"),
"footprint_area_m2": meta.get("footprint_area_m2"),
# 파일 지문 — 같으면 DB 행 id 를 살린다(화면 주소 · 노선 연결 유지).
"file_stamp": file_stamp(model_path),
},
"layers": layers,
}
@@ -18,7 +18,7 @@ try:
except ImportError:
mapbox_vector_tile = None
# 메모리 캐시: {project_id}_{layer_name}: GeoDataFrame
# 메모리 캐시: {project_id}_{layer_name}: ((mtime_ns, 크기), GeoDataFrame)
_gdf_cache: dict[str, Any] = {}
@@ -41,9 +41,13 @@ def get_tile_bbox(xtile: int, ytile: int, zoom: int) -> tuple[float, float, floa
def load_geojson_cached(filepath: Path, cache_key: str) -> Any:
"""GeoJSON 파일을 한 번 읽으면 메모리에 GeoDataFrame으로 캐싱합니다."""
global _gdf_cache
if cache_key not in _gdf_cache:
if not filepath.exists():
raise FileNotFoundError(f"File not found: {filepath}")
if not filepath.exists():
raise FileNotFoundError(f"File not found: {filepath}")
# 열쇠에 수정시각·크기 — 전처리를 다시 돌려 파일이 바뀌면 옛 타일을 버린다.
stat = filepath.stat()
stamp = (stat.st_mtime_ns, stat.st_size)
cached = _gdf_cache.get(cache_key)
if cached is None or cached[0] != stamp:
if gpd is None:
raise ImportError("geopandas가 설치되어 있지 않습니다.")
gdf = gpd.read_file(filepath)
@@ -51,8 +55,8 @@ def load_geojson_cached(filepath: Path, cache_key: str) -> Any:
gdf.set_crs(epsg=4326, inplace=True)
if gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(epsg=4326)
_gdf_cache[cache_key] = gdf
return _gdf_cache[cache_key]
_gdf_cache[cache_key] = (stamp, gdf)
return _gdf_cache[cache_key][1]
def generate_mvt_tile(
@@ -0,0 +1,97 @@
"""B04 점군 미리보기 — 고정 표본 이진 파일(전처리 때 한 번 · ETag 로 내보냄).
JSON(27.8 MB · 요청마다 점군 풀고 표본)을 대신한다. 표본 규칙은 JSON 길과 같다
(`sample_points` · 같은 씨앗) — 같은 점 · 같은 float32 값.
파일 = [머리 길이 uint32 LE][머리 JSON utf-8][0 채움 → 4 배수][xyz float32 × 3n][rgb uint16 × 3n].
머리 = {version, source(원천 npz mtime_ns:크기), point_count, sampled_count, bounds, has_rgb}.
TS 짝(읽기) = `B04_PreProcess_Api_Fetch.ts` `decodePointPreview`.
"""
from __future__ import annotations
import json
import struct
from pathlib import Path
from typing import Any
import numpy as np
from common_util.common_util_atomic import atomic_write_bytes
POINT_CLOUD_SAMPLE_LIMIT = 500_000
PREVIEW_VERSION = 1
_SAMPLE_SEED = 20260710
def sample_points(xyz: np.ndarray, rgb: np.ndarray | None) -> tuple[np.ndarray, np.ndarray | None]:
"""화면용 고정 표본 — 상한을 넘으면 같은 씨앗으로 고른다(열 때마다 같은 그림)."""
source_count = int(len(xyz))
if rgb is not None and (rgb.ndim == 0 or len(rgb) != source_count):
rgb = None
if source_count <= POINT_CLOUD_SAMPLE_LIMIT:
return xyz, rgb
indexes = np.random.default_rng(_SAMPLE_SEED).choice(
source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False
)
return xyz[indexes], (rgb[indexes] if rgb is not None else None)
def preview_path(processed_dir: Path, filter_key: str | None) -> Path:
return processed_dir / f"preview_points_{filter_key or 'raw'}.bin"
def _stamp(path: Path) -> str:
stat = path.stat()
return f"{stat.st_mtime_ns}:{stat.st_size}"
def read_preview_header(path: Path) -> dict[str, Any] | None:
"""머리만 읽는다 — 없거나 깨졌으면 None."""
try:
with path.open("rb") as file:
(length,) = struct.unpack("<I", file.read(4))
return json.loads(file.read(length).decode("utf-8"))
except (OSError, ValueError, struct.error):
return None
def ensure_point_preview(source_path: Path, filter_key: str | None, xyz_source: Any = None) -> Path:
"""원천 npz(structured · ground_points_*) 그대로면 있는 파일 · 아니면 한 번 만든다.
`xyz_source` — 이미 메모리에 있는 xyz(또는 쓸 때 푸는 대리자) · 없으면 npz 에서 푼다.
"""
target = preview_path(source_path.parent, filter_key)
stamp = _stamp(source_path)
header = read_preview_header(target)
if header and header.get("version") == PREVIEW_VERSION and header.get("source") == stamp:
return target
with np.load(source_path) as data:
xyz = np.asarray(data["xyz"] if xyz_source is None else xyz_source, dtype=np.float32)
bounds = np.asarray(data["bounds"], dtype=np.float64)
point_count = int(data["point_count"]) if "point_count" in data else int(len(xyz))
rgb = np.asarray(data["rgb"]) if "rgb" in data else None
sample, rgb_sample = sample_points(xyz, rgb)
header = {
"version": PREVIEW_VERSION,
"source": stamp,
"point_count": point_count,
"sampled_count": int(len(sample)),
"bounds": {
"x_min": float(bounds[0, 0]),
"x_max": float(bounds[0, 1]),
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
"z_min": float(bounds[2, 0]),
"z_max": float(bounds[2, 1]),
},
"has_rgb": rgb_sample is not None,
}
head = json.dumps(header).encode("utf-8")
pad = (-(4 + len(head))) % 4
parts = [struct.pack("<I", len(head)), head, b"\0" * pad]
parts.append(np.ascontiguousarray(sample, dtype="<f4").tobytes())
if rgb_sample is not None:
parts.append(np.ascontiguousarray(rgb_sample, dtype="<u2").tobytes())
atomic_write_bytes(target, b"".join(parts))
return target
@@ -15,6 +15,7 @@ LAS 없는 설계(2026-08-30 사용자 확정)의 지형 원천이자, LAS가
격자에서 뽑는 1m 등고선이 곧 2D 보간선이며, 메시(glb)는 그 격자의 표현일 뿐이다.
"""
import hashlib
import json
import logging
import time
@@ -36,6 +37,8 @@ from B04_PreProcess.B04_PreProcess_Engine_SheetMethods import (
SHEET_METHOD_LABELS,
)
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import grid_spec_from_bounds
from common_util.common_util_derived_store import file_stamp
from common_util.common_util_json import atomic_write_json
from config.config_system import (
SHEET_SURFACE_GRID_M,
SHEET_SURFACE_MARGIN_M,
@@ -54,6 +57,9 @@ _CONTOUR_FILE = "도엽_등고선.geojson"
# 산출 모델 식별자 — surface_models.generation_params.source_filter 및 파일 stem에 쓴다.
SHEET_SOURCE_FILTER = "sheet"
# 계산 판 번호 — 만드는 방식이 바뀌면 올림 · 열쇠가 어긋나 옛 저장본을 다시 셈.
SHEET_SURFACE_ALGO = 1
def _load_features_metric(
processed_dir: Path, crs: str, filename: str = _CONTOUR_FILE
@@ -378,6 +384,15 @@ def _write_method_model(
vertices, faces = _preview_mesh(x_coords, y_coords, z_grid, valid_grid)
write_glb(preview_path, vertices, faces, scene)
_write_smoothed(models_dir, stem, x_coords, y_coords, z_grid, valid_grid, bounds, scene)
return _method_model_entry(project_root, models_dir, method_key)
def _method_model_entry(project_root: Path, models_dir: Path, method_key: str) -> dict[str, Any]:
"""방식 하나의 등록용 dict — 저장본 그대로 쓸 때도 같은 모양."""
source_filter = f"{SHEET_SOURCE_FILTER}_{method_key}"
stem = f"dtm_{source_filter}"
model_path = models_dir / f"{stem}.npz"
preview_path = models_dir / f"{stem}_preview.glb"
return {
"model_type": "dtm",
"source_filter": source_filter,
@@ -391,6 +406,7 @@ def _write_method_model(
"interpolation": method_key,
"interpolation_label": SHEET_METHOD_LABELS.get(method_key, method_key),
"margin_m": SHEET_SURFACE_MARGIN_M,
"file_stamp": file_stamp(model_path),
},
"layers": [
{
@@ -403,6 +419,59 @@ def _write_method_model(
}
def _sheet_key(
project_root: Path, processed_dir: Path, route_xy: np.ndarray, crs: str, method_key: str
) -> dict[str, Any] | None:
"""도엽 3D 면 열쇠 — 등고선 파일 · 노선 · 좌표계 · 격자 · 여유 · 스무딩 · 화면 상자 · 판."""
contour = processed_dir / _CONTOUR_FILE
try:
stat = contour.stat()
except OSError:
return None
scene = _scene_bounds(project_root, None) # type: ignore[arg-type] # 라이다 없으면 None
key = {
"algo": SHEET_SURFACE_ALGO,
"method": method_key,
"contour": f"{contour.name}:{stat.st_mtime_ns}:{stat.st_size}",
"route": hashlib.sha1(np.round(route_xy, 3).tobytes()).hexdigest(),
"crs": str(crs),
"grid_m": SHEET_SURFACE_GRID_M,
"margin_m": SHEET_SURFACE_MARGIN_M,
"smoothing": [
SURFACE_SMOOTHING_DTM_SIGMA_M,
SURFACE_SMOOTHING_DTM_SPLINE_SMOOTH,
SURFACE_SMOOTHING_DTM_PREVIEW_RESOLUTION_M,
SURFACE_MAX_PREVIEW_VERTICES,
],
"scene_bounds": None if scene is None else np.round(scene, 3).tolist(),
}
return json.loads(json.dumps(key, default=float))
def _key_path(models_dir: Path, method_key: str) -> Path:
return models_dir / f"dtm_{SHEET_SOURCE_FILTER}_{method_key}.key.json"
def _cached_method_model(
project_root: Path, models_dir: Path, method_key: str, key: dict[str, Any] | None
) -> dict[str, Any] | None:
"""열쇠가 같고 결과 파일이 있으면 등록용 dict — 아니면 None(다시 셈)."""
if key is None:
return None
entry = _method_model_entry(project_root, models_dir, method_key)
if not all((project_root / item).is_file() for item in _entry_files(entry)):
return None
try:
stored = json.loads(_key_path(models_dir, method_key).read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return entry if stored == key else None
def _entry_files(entry: dict[str, Any]) -> list[str]:
return [entry["model_file_path"], *(layer["file_path"] for layer in entry["layers"])]
def build_sheet_surface_model(
project_root: Path,
processed_dir: Path,
@@ -420,6 +489,16 @@ def build_sheet_surface_model(
`route_xy`: (N, 2) 노선 정점 XY(사업지 CRS, m), `crs`: 그 좌표계(pyproj 입력 문자열).
"""
started = time.monotonic()
selected = methods or list(SHEET_SURFACE_METHODS)
# 열쇠가 같은 방식은 저장본 그대로 — 다시 셀 방식만 등고선을 읽고 굽는다.
keys = {key: _sheet_key(project_root, processed_dir, route_xy, crs, key) for key in selected}
cached = {
key: _cached_method_model(project_root, models_dir, key, keys[key]) for key in selected
}
pending = [key for key in selected if cached[key] is None]
if not pending:
logger.info("도엽 서피스: 열쇠 같음 — 저장본 그대로 (%s)", ", ".join(selected))
return [cached[key] for key in selected] # type: ignore[misc]
features = _load_features_metric(processed_dir, crs)
if not features:
return []
@@ -439,9 +518,8 @@ def build_sheet_surface_model(
# 등고 간격(m) — 마루 연장 상한의 근거. 레벨이 하나뿐이면 5m(주곡선) 폴백.
interval_m = float(np.diff(np.array(present)).min()) if len(present) > 1 else 5.0
selected = methods or list(SHEET_SURFACE_METHODS)
models: list[dict[str, Any]] = []
for method_key in selected:
built: dict[str, dict[str, Any]] = {}
for method_key in pending:
builder = SHEET_METHOD_BUILDERS.get(method_key)
if builder is None:
logger.warning("도엽 서피스: 알 수 없는 보간 방식 %s — 건너뜁니다.", method_key)
@@ -456,9 +534,13 @@ def build_sheet_surface_model(
except Exception as exc: # noqa: BLE001 — 한 방식이 죽어도 나머지는 만든다
logger.warning("도엽 서피스(%s) 생성 실패: %s", method_key, exc)
continue
# 쓰는 도중 끊기면 옛 열쇠가 새 파일 조각을 덮지 않게 먼저 지움.
_key_path(models_dir, method_key).unlink(missing_ok=True)
model = _write_method_model(project_root, models_dir, spec, surface, method_key)
if model is not None:
models.append(model)
built[method_key] = model
if keys[method_key] is not None:
atomic_write_json(_key_path(models_dir, method_key), keys[method_key])
logger.info("도엽 서피스(%s) 완료 (%.1fs)", method_key, time.monotonic() - step_started)
logger.info(
@@ -466,10 +548,10 @@ def build_sheet_surface_model(
spec.n_rows,
spec.n_cols,
len(present),
len(models),
len(built),
time.monotonic() - started,
)
return models
return [cached[key] or built[key] for key in selected if cached[key] or key in built]
def build_sheet_surface_from_route(

Some files were not shown because too many files have changed in this diff Show More