1243 Commits
Author SHA1 Message Date
eomsangdonandClaude Fable 5.1 d39eece45e merge(dev): origin/main_desktop_1 합침 — tools/est-test · stmate-test 는 dev(CODEX · 안티그래비티 워크트리 판) 채택 · 데스크탑 메인 폴더 사본 버림(두 번째)
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 15:02:46 +09:00
eomsangdon 53aeccc179 Merge remote-tracking branch 'origin/CODEX' into sub_laptop_8 2026-11-08 15:02:17 +09:00
eomsangdon 389ab245e7 chore(tools): 데스크탑 메인 tools 변경 — est-test 개발분 · line-stack-test 걷힘(804-7) — 전역 깃 싱크(요구 689-3) 2026-11-08 15:01:36 +09:00
eomsangdon 87433105f7 wip(est-test): 개발 중 저장 2 — 전역 깃 싱크(요구 689-3) 2026-11-08 15:01:33 +09:00
eomsangdonandClaude Opus 5.5 eb64bf64ac feat(804-8): M02 단가산출 수식 탭 = M01 과 같은 단추 · 저장 한 번 · 머리 단추 줄바꿈 막음(요구 689)
- 공용 줄 쌓기 화면: 페이지가 저장을 맡는 꼴(`page`)을 M02 단가산출 화면에도 — 탭 위 [고침 버림] · [수식 저장] 걷음 · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지는 그대로 · 「줄 쌓기에서 못 고치는 칸」 안내 줄 걷음(금액 줄 기호 = 율 줄이 앞 줄 금액을 부르는 속 이름 · 화면에서 고칠 값 아님 · 보존 그대로)
- M02 머리 단추 = [초기화] · [삭제] · [되돌림] · [저장] — 단가산출 상세는 [새로고침] 숨김 · [되돌림] · [저장] = 고침 있을 때만(기본정보 + 수식) · [저장] 한 번 = 수식 먼저 저장(새 판 · 수식 칸 받음) → 단가산출 문서 저장 · [되돌림] · 버리고 떠남 = 수식 캐시도 비움
- 보기 고르기(수식 · 단가산출 상세) = M01 처럼 머리 줄 · [항목 추가] · [율 줄 추가] 걷음(줄 더하기 = 수식 탭 [+] 단가산출 · 일위대가 복사) · 일위대가 줄 모달 안 상세는 그대로
- 머리 단추 줄 맞춤(부품 상자 `fitButtonRow`): 단추가 아래로 떨어지거나 제목이 여러 줄이면 아이콘 단추는 아이콘만(글 = 풍선 도움말) · M01 단추 다섯 · M02 단추 넷 아이콘 · 단가산출 상세 긴 이름은 제목 안에서 줄바꿈
- 사본 할일 막힘 알림 글 = [저장] 또는 [되돌림] 먼저
- 시험: test_804_7 에 M02 단추 · 저장 한 번 · 줄 맞춤 지킴 더함 · test_804_4 권한 시험에 수식 저장 판 = 페이지 저장 판 이어 씀 확인 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 14:56:31 +09:00
eomsangdon ef5fb0632f Merge branch 'main_laptop_1' of C:/Program_coding/임도설계 및 견적자동화 프로그램 개발 into sub_laptop_3 2026-11-08 14:24:47 +09:00
eomsangdon fcf5f457f0 Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_3 2026-11-08 14:23:15 +09:00
eomsangdon 1fee1e950c Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-11-08 14:23:14 +09:00
eomsangdonandClaude Fable 5.1 e699d95857 knowledge(요구689): M02 보기 = M01 · 저장 단추 · 전역 싱크
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 14:23:10 +09:00
eomsangdon ef3cc96509 Merge remote-tracking branch 'origin/CODEX' into main_laptop_1 2026-11-08 14:21:03 +09:00
eomsangdon af2d9826cf Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-08 13:36:24 +09:00
eomsangdonandClaude Opus 5.5 abe722f378 feat(804-7): 줄 쌓기 「수식」 탭 수정 2차(요구 687 · 688)
- 로직 삭제 = 서버 정본에서 바로(로직 줄 + 수식 문서 · `/logic/remove`) → 로직 목록 · 부르는 곳(다른 수식 문서 · 일위대가 조합 · M02 단가산출 바탕)이 있으면 확인 창 전에 막고 까닭(`/logic/users`)
- 머리 단추 다섯 — [신규 로직] · [로직 복사/생성] · [로직 삭제] · [되돌림] · [저장] · [저장] 한 번 = 기본정보 + 수식 · [되돌림] = 둘 다 저장 뒤 상태 · 줄 쌓기 [고침 버림] · [수식 저장]은 M01 에서 걷음(M02 사본은 그대로)
- 합계 상자 화면 아래 고정 · 원문 손잡이 브라우저 오른쪽 끝
- 왼쪽: 그룹 머리 한 줄(접어도 [+] · X 자리 그대로) · 「비우면 번호 없음」 · 가나다 체크 걷음 · 금액 줄 한 줄 배치 · 이름 단추 = 그 이름 찾은 찾기 창 · 수량 기호 = 값 + 출처 꼬리표 · [+] 메뉴(단가산출 · 율 · 식 · 근거 글 · 틀 찾기 걷음) · 식 글 눌러 그 자리 고침([식 고치기] 걷음) · 단위 = 공용 단위 고르기(친 글도 씀) · 만약 안 탄 갈래 숨김
- 오른쪽 기계 줄: 비목 수 링크 걷음 · 제목 줄 누름 = 시간당 경비 펼침 · 펼친 제목 보통 글
- 요소 키가 기호 이름으로 샌 것(`k_EQ0006`) — 변환기가 자원 이름으로(`k_하베스터`) · 수식 문서 다시 빌드(10 파일 · 78 기호 · 금액 같음)
- 시험 페이지 `tools/line-stack-test` · main.py 마운트 · vite 프록시 · M01 왼쪽 「시험: 줄 쌓기」 걷음
- 단위 표준 표 읽기 = 로그인만(M02 일반 사용자 단위 목록)
- 시험: test_804_7 새로 · 804_2 · 804_4 이음 글 고침(createStackScreen 줄 나뉨) · 552_3 여는 단추 기본 ⋯ 꼴로 고침(부르는 쪽이 [+] 로 바꿀 수 있음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 13:33:36 +09:00
eomsangdon 944f52cd6f Merge commit 'e5e34d82fb9a2361b8bb3f3eebffe20a61ededef' into CODEX 2026-11-08 12:53:06 +09:00
eomsangdonandClaude Fable 5.1 fd188fdf2b knowledge(요구688): 그룹 닫으면 X 밀림
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 12:46:16 +09:00
eomsangdonandClaude Fable 5.1 89292a75df knowledge(요구687): 줄 쌓기 수정 18 · 그림 셋
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 12:45:09 +09:00
eomsangdonandClaude Fable 5.1 e5e34d82fb merge(dev): origin/main_desktop_1 합침 — tools/est-test · stmate-test 는 dev(CODEX · 안티그래비티 워크트리 판) 채택 · 데스크탑 메인의 묵은 사본 17 파일 버림 · 경쟁사 프로그램 폴더 재편은 그대로 받음
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 12:16:18 +09:00
eomsangdon 53a33b07c1 Merge remote-tracking branch 'origin/안티그래비티' into sub_laptop_8 2026-11-08 12:15:03 +09:00
eomsangdon ebcec5ffa0 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_8 2026-11-08 12:15:02 +09:00
eomsangdon 47f4a729b4 chore(tools): line-stack-test 시험 페이지 — 시놀로지가 복사한 랩탑 판(메인 3e64aa2a7 과 같은 내용 · 합칠 때 같은 추가로 풀림) 2026-11-08 12:13:58 +09:00
eomsangdon 640903d6ef wip(stmate-test): 개발 중 저장 — 전역 깃 싱크(요구 686-2) · tools/stmate-test 2026-11-08 12:13:22 +09:00
eomsangdon 1beaefa529 wip(est-test): 개발 중 저장 — 전역 깃 싱크(요구 686-2) · tools/est-test · resources/tester 2026-11-08 12:12:37 +09:00
eomsangdon 2885a51854 knowledge(경쟁사): 경쟁사 프로그램 폴더 재편(STmate 분석 → STmate · 오솔길 분석 → 오솔길/ · 다산소프트 · EST example) + tools est-test · stmate-test 시험 프로그램 — 데스크탑 메인 미커밋 자료(요구 686-2) 2026-11-08 12:08:45 +09:00
eomsangdonandClaude Fable 5.1 db1d976ea8 merge(sub_laptop_2): 802-4b-1 멈춘 wip(5799ccbe9 · 8406fd236 · 32208c357) 보관하고 메인 판 채택 — 충돌 M02_Doc_Sk · Doc_Sk_Path 는 804 흐름(메인)으로 · wip 는 tag wip/802-4b-1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 11:47:53 +09:00
eomsangdon cf7f62069e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-11-08 11:46:46 +09:00
eomsangdonandClaude Opus 5.5 7480aebc96 feat(804-6): 옛 수식 탭 걷음 · 줄 쌓기 = 첫 「수식」 탭(요구 686-1)
- M01 단가산출 기초 · M02 단가산출 상세 보기 = 수식(줄 쌓기 · 기본) · 단가산출 상세 둘
- 줄 쌓기에 M02 사본 [단가 갱신] · [재생성](미리 셈 → 알림 → 저장 · 고침 있으면 막음) · 판 바뀜(409) 문서 그대로면 새 판으로 다시 저장 옮김
- 옛 수식 문서 화면 걷음(formula_doc_screen · defs · edit · lines · math · math_model · rows · sub · formula_doc.css) · 이미 안 부르던 796-2 수식 나무 화면(LogicLab_Equation · Defs · Var · css · M01_Logic_Equation_Model · sheet_formula_cell · ui_template_equation_defs · edit) 걷음 — 18 파일 4,860 줄
- formula_doc_render = 줄 쌓기가 쓰는 reach 만 · 옵션 가로 CSS 는 line_stack.css 로
- 남김 = 계산기 common_util_formula_doc* · 서버 길 · 옵션 · 사본 표지 · 합계 상자 · KaTeX · 시험 페이지 tools/line-stack-test
- 시험: test_796_2 지움(걷은 화면 원본 검사뿐) · 799_1 · 800_2 걷은 화면 줄만 뺌 · 799_2 · 802_4b2 · 804_2 보기 · 화면 파일 대상 바꿈 · test_804_6 새로(걷은 파일 · 부르는 곳 없음 · 첫 탭 · 사본 할일)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 11:09:44 +09:00
eomsangdonandClaude Fable 5.1 04caf618bb knowledge(요구686): 수식 탭 삭제 · 줄 쌓기 기본 · 전역 깃 싱크
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 10:45:22 +09:00
eomsangdon d4fbe755b0 Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-08 08:43:18 +09:00
eomsangdonandClaude Opus 5.5 6f678fb91b feat(804-5): 줄 쌓기 표 값 이름 · 산출근거 고정 · 그룹 글 · 짝 강조 채우기 · 틀로 저장 걷음(요구 683~685)
- 표 값 기호 화면 이름 = 고른 줄 이름(Q_수량2^표 → Q_특별인부 · 겹치면 2 · 3) — 왼쪽 · 산출근거 같음 · 칸 글은 화면 이름 ↔ 기호로 오감(문서 기호 그대로)
- 단위 「줄 따름」 안 보임 · 표 값만 든 묶음 머리 = 「표 값」 · [식으로 풀기] → 「식 고치기」
- 산출근거 = 넓을 때 화면 고정 + 자기 스크롤(높이 = 스크롤 영역) · 나란히 놓는 탭 폭 1240 → 1040
- 글 「항목」 → 「그룹」(그룹 이름 · + 그룹 · 그룹 지움)
- 짝 강조 = 왼쪽 카드 · 그룹 머리 · 오른쪽 줄 모두 같은 바탕 채우기(테두리 걷음)
- [틀로 저장] · 내 틀 목록(브라우저 저장) 걷음 · 공식 틀 고르기 = M01 단가산출 결과만
- 시험 test_804_4 표 값 화면 이름 1,518 전수(겹침 없음 · 식 글 왕복 같음) 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:42:41 +09:00
eomsangdonandClaude Fable 5.1 c18c3036eb knowledge(요구685): 내 틀 저장 안 함
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 08:34:16 +09:00
eomsangdonandClaude Fable 5.1 25bbc084ec knowledge(요구684): 짝 강조 색상 채우기 통일
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 08:29:40 +09:00
eomsangdonandClaude Opus 5.5 af576e5a7a feat(804-5): 줄 쌓기 왼쪽 손질 — 자원 이름 단추 · 항목 띠 · 끌어 옮기기 · X 지움 되돌리기 · 율 번지 읽는 이름
- 인력 · 기계 · 재료 · 단가산출 줄 자원 이름 = 단추(이름 · 규격) → 찾아 넣기 창 → 바꿈 · [찾아 바꾸기] 걷음
- 항목 머리 = 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 한 단 더 · 접기
- 순서 = 손잡이 끌기(줄 = 항목 안 · 사이 · 항목 끌기 · 자리 선 표시) · [↑][↓] 걷음
- 지움 = X 아이콘 · 확인 없이 지우고 알림 눌러 되돌리기(그 뒤 고침 있으면 못 함)
- 산출근거 율 줄 「번지.복사N.노무비」 → 그 조각 이름 · 값 식 = 번지 값 · 합계 덧셈 항 넷 넘으면 항목 소계끼리
- 알림 부품: 누름 동작 알림은 같은 문구 버림 안 함(두 번째 지움 되돌리기 안 뜨던 흠)
- 글 · 근거 카드 높이 늘던 흠 고침 · 시험 test_804_4 끌어옮김 · 번지 이름 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:29:31 +09:00
eomsangdonandClaude Fable 5.1 6a9a98d6d3 knowledge(요구683): 줄 쌓기 이름 · 스크롤 · 그룹 · 그림
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 08:29:10 +09:00
eomsangdonandClaude Fable 5.1 ca11090104 knowledge(요구682): 줄 쌓기 왼쪽 손질
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 08:07:47 +09:00
eomsangdon d7bc5cac83 Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-08 08:04:56 +09:00
eomsangdonandClaude Opus 5.5 9c43296145 feat(804-4): 줄 쌓기 다른 단가산출 · 일위대가 복사해 펼침 — M01 은 M01 전체만
- M02 단가산출 줄 쌓기 [+ 줄] 「다른 단가산출 · 일위대가 복사」 = 찾기 창 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 막음)
- 고름 = 그 문서 전체를 복사해 이 단가산출 항목 · 줄로 펼침(참조 줄 없음 · 원본 연결 없음 · 머리 글 「○○에서 복사」 · 옵션 = 고른 값으로 굳힘 · 기호 겹침 = 새 이름)
- 일위대가 = 로직 줄 → M01 문서 × 수량 ÷ 배 · 참조 SK → 그 SK 문서 × 수량 ÷ 배 · 고정 노임 · 자재 · 기계 · 손 단가 · 율(비목 기준) · 못 옮김 = 「⚠」 글 줄
- 율 줄 밑 = 그 조각 줄만(금액번지 복사N) — 앞 줄 · 다른 조각 안 섞임
- 서버 조각 길 GET …/sangeun/{키}/doc/take?src= (M02_Doc_Take) · 화면 넣기 common_util_line_stack_copy.ts
- 빈 SK(바탕 · 줄 없음)에 수식 길로 저장한 문서가 빈 씨로 덮이던 것 고침(M02_Doc_Sk.settle 차례)
- [단가 갱신] = 사본 값 줄 자원 단가만 지금 M01(구조 · 품 · 식 그대로)
- 시험 test_804_4_line_stack_take 6 · helper_804_2 「복사」 할일

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 08:04:16 +09:00
eomsangdonandClaude Fable 5.1 feae4e60d7 knowledge(요구681): 참조 아니라 복사 · 전체 보임 · 기계경비 설명
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 07:35:48 +09:00
eomsangdonandClaude Fable 5.1 f9cafaa89b knowledge(요구680): M01 안 부름 · M02 부름 · 일위대가 여러 항목
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 07:28:49 +09:00
eomsangdon 71e7165250 Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-08 01:27:57 +09:00
eomsangdonandClaude Opus 5.5 0eb7822772 feat(804-3): 줄 쌓기 수정 1차 — 표 값 고르기 · 산출근거 들여쓰기 · 찾아 넣기 창 · 짝 강조 · 기계 이름
- 표 값(요구 678-1): 「표」 꼬리표가 글뿐이라 눌러도 안 열림 → [표 · 고른 줄 · 칸 ▾] 단추 = 품셈 표 창(값 칸 누름 = 그 줄 · 칸)
  옵션에 매인 조건(폼타이규격 등) = 그 줄만 고름 · 다른 줄 막힘 + 「위 옵션에서 고름」 안내 · M02 길 표 값 글 수도 고름
- 산출근거 들여쓰기(678-2): 머리 0 · 항목 1 · 가나다 2 · 줄 = 항목 + 1 · 펼친 블록 = 그 줄 + 1(수준 = stackLevels)
- 찾아 넣기(678-3): [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 = M02 [항목 추가] 창 그대로(그 종류만) · 고르면 줄 생김(닫으면 없음)
  있는 줄 = [찾아 바꾸기](같은 로직 기계 = 지역 · 보정작업 넘김 그대로) · 옛 찾기 칸 + 고름 목록 · 기종 코드 고르개 걷음
  빈 틀 = [찾아 넣기](돈 아닌 단가산출 결과만 — Find_Pick results)
- 짝 강조(679-1): 오른쪽 줄 누름 = 왼쪽 카드 강조 + 보이는 자리로 · 왼쪽 누름 = 오른쪽 같은 줄 강조
- 기계 이름(679-2): 기계 줄 = 기종 이름 · 규격 · machine-hourly 답에 로직 키 · 원문번호 더함(기존 칸 그대로)
- 모델 common_util_line_stack_pick.ts(boundConds · currentRow · pickCell · addPicked · repick · stackLevels)
- 시험 test_804_2 넓힘 3(표 고름 = 값 + 출처 · 서버 셈 같음 · 찾아 넣기 넷 = 요소 키 · 서버 금액 · 다시 고름 · 수준)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 01:27:20 +09:00
eomsangdonandClaude Fable 5.1 a0f0c15caf knowledge(요구679): 줄 쌓기 추가 수정 — 하이라이트 · 기계 이름
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 01:09:13 +09:00
eomsangdonandClaude Fable 5.1 6c14e73a5c knowledge(요구678): 줄 쌓기 탭 수정 1차
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-08 00:57:07 +09:00
eomsangdonandClaude Opus 5.5 c11baa441c feat(804-2): 단가산출 「줄 쌓기」 새 탭 — M01 단가산출 기초 · M02 단가산출 · 기계 시간당 경비 수식 펼침 · KaTeX
- 공용 화면 A00_Common/line_stack_* — 위 옵션 · 왼쪽 줄 쌓기 편집 · 오른쪽 산출근거(경쟁사 시트 꼴) · 맨 아래 합계 상자
- 모델 common_util_line_stack.ts — 수식 문서 ↔ 줄 쌓기 한 벌 왕복(차림 = 문서 칸 `쌓기` · 기본과 다를 때만)
- 기계 · 단가산출 줄 단가 수 누름 = 그 문서를 같은 계산기로 셈해 펼침(손료 식 · 운전경비 · 노 · 재 · 경 · 계)
- 공식 묶음 · 틀 풀기(단가산출 결과 → 이 문서 식) · 내 틀(이 브라우저) · 「내 틀 (… 에서 변경)」
- 수식 탭과 같은 길 · 같은 캐시 · 탭 바꾸면 다시 읽음 · [수식 저장] = 서버 정본 셈
- KaTeX(MIT) — vite 미리 묶기에서 뺌(rolldown 이 글자 규칙 서로게이트 범위를 깨뜨림)
- 시험 test_804_2 6(왕복 1,518 · 금액 · 기계 펼침 = hourly · 틀 풀기 · 저장 · 이음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 00:34:29 +09:00
eomsangdonandClaude Fable 5.1 26bd46041e knowledge(요구677): 메인 새 탭 · 기계 수식 · 라이브러리
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 23:40:22 +09:00
eomsangdonandClaude Opus 5.5 3e64aa2a79 feat(804-1): 시험 페이지 「줄 쌓기 단가산출」 tools/line-stack-test + M01 왼쪽 패널 단추
- 왼쪽 줄 쌓기(항목 · 줄 일곱 종류 · 공식 틀 셋 · 식으로 풀기 · 틀로 저장 · 표에서 고르기 · 끌어 순서) · 오른쪽 경쟁사 꼴 산출근거(분수 기호식 = 값식 = 결과 · 소계 · 합계) · 아래 비목 상자 · 엑셀 글 붙여넣기 · 저장 = localStorage
- 견본 셋 금액 = 거창 파일 원 단위(5,323 · 52,796 · 94,215 비목 넷) · 확인 = node tools/line-stack-test/check.mjs
- 접근 = main.py 정적 마운트 /tools/line-stack-test · vite 프록시 한 줄(개발 포트에서 열림) · M01 왼쪽 패널 맨 아래 「시험: 줄 쌓기」 새 탭
- 라이브러리 0(분수 = CSS) · 지우기 = 폴더 + 표시 주석 셋 덩이

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 23:19:38 +09:00
eomsangdonandClaude Fable 5.1 d59874d4b4 knowledge(요구676): 시험 페이지 · M01 왼쪽 패널 접근
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 23:02:57 +09:00
eomsangdonandClaude Fable 5.1 a77d257b1c knowledge(요구675): 줄 쌓기 좋음 · 새 공식 · 공식 변경
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 23:00:56 +09:00
eomsangdonandClaude Fable 5.1 1a7e6d5eb7 knowledge(요구674): 조각 안 어려움 · 경쟁사 출력 파일
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 22:54:36 +09:00
eomsangdonandClaude Fable 5.1 889931cc55 knowledge(요구673): M01 기초 구조 변경 가능
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 22:42:01 +09:00
eomsangdonandClaude Fable 5.1 973872fe43 knowledge(요구672): tool 폴더 · 품셈 구조 분석 · 지정 방식 + 자유도
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 22:40:47 +09:00
eomsangdonandClaude Fable 5.1 ff9d2941c1 knowledge(요구671): 시험 폴더 새 페이지 · 품셈 조합 변수 · 외부 라이브러리 조사
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 22:13:13 +09:00
eomsangdonandClaude Fable 5.1 3a7ddb8583 knowledge(요구670): 엑셀 · 기호식 = 값식 출력 · 옵션 유지 · tool 참고 · 폭넓게
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-07 21:57:26 +09:00
eomsangdonandClaude Opus 5.5 1bb9ba389e knowledge(요구669): 작업 중지 · 대화 · 경쟁사 대비 사용자 입장
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:45:19 +09:00
eomsangdonandClaude Opus 5.5 824168c1ca fix(803-2b): 기호 정의 = 세 번째 칸 그대로(좁은 화면에서 아래로 떨어짐 걷음)
- 까닭 — 수식 탭 너비 900px 아래(1366 · 원문 펼침)에서 세 영역을 한 줄로 쌓는 규칙이 기호 정의를 기호 식 · 값 식 아래로 내림(요구 667-3 수평 구조 어긋남)
- 그 규칙 걷음 — 좁으면 칸 너비만 줄고 칸 안에서 줄바꿈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:30:48 +09:00
eomsangdonandClaude Opus 5.5 970fd441ba fix(803-2b): 저장 전 새 로직 기호 식 영역 누름 = 입력 · 편집 줄 너비 전부
- 까닭 — 수식 화면 고침 조건이 「관리자 + 로직 키」 · 신규 로직은 저장 전 키가 없어 영역 누름 처리가 아예 안 붙음(GC000211 같은 저장된 로직은 누름 됨)
- M01 저장 전 새 로직도 고침 · 고친 문서는 캐시 「""」 열쇠 · 미리 셈은 임시 키 · [수식 저장] · [고침 버림]은 키가 생긴 뒤
- 페이지 [저장]으로 새 키가 생기면 고친 문서를 그 키(빈 문서)로 이어 감(이름 · 단위 = 새 로직) · 신규 로직을 다시 열면 옛 고침 걷음
- M02 저장 전 새 SK = 자리 표시(저장하면 M01 사본) — 읽기만 그대로
- 편집 중 줄 = 그 줄 너비 전부(빈 줄 입력 칸이 2em 으로 줄던 것 · 「a =」 「3」 꺾임 고침)
- 서버 · 계산기 · 문서 꼴 그대로 · 새 라이브러리 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:27:54 +09:00
eomsangdonandClaude Opus 5.5 dcae2eb705 knowledge(요구668): 기호 식 눌러도 입력 안 나옴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:11:12 +09:00
eomsangdonandClaude Opus 5.5 9f969f0c3f fix(803-2a): 수식 편집기 쉼표 글 꼴(「a, b」)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:09:03 +09:00
eomsangdonandClaude Opus 5.5 f5545dd62b feat(803-2a): 수식 탭 세 영역 · 하나의 수식 영역 · 합계 상자 · 설명 글 지움
- 세 영역 가로 — 기호 식(줄바꿈으로 이어진 하나의 수식 영역) | 값 식(같은 높이 줄) | 기호 정의
- 맨 아래 합계 상자 = 단가산출 상세 탭 부품(노무비 · 재료비 · 경비 · 계) · 옵션 아래 계 줄 · 설명 글 · 표지 안내 줄 걷음(오류 = 그 기호 빨강)
- 기호 식 줄 누름 = 그 자리 편집기(치는 대로 조판 — / 분수 · _ 아래첨자 · ^ 위첨자 · 괄호 · 화살표 구조 이동 · Enter 새 줄 · Esc 나옴 · 한글 조합) · 편집 중만 영역 위 수식 도구 줄
- 금액 줄 = 영역 안 식 줄 「노무비 형틀목공 = w × Q」(머리 첫 말 = 비목) · 「+ 식 줄」 「+ 금액 줄」 「+ 묶음」 · 비목 고르기 칸 걷음 · 묶음 = 도구 줄 [묶음]
- 저장 전 새 로직 = 세 영역 빈 화면 · 끊은 단가산출 블록도 같은 편집기
- 새 라이브러리 없음 · 서버 길 · 계산기 · 문서 꼴 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 21:07:59 +09:00
eomsangdon 2057e0ec02 Merge branch 'main_laptop_1' of C:/Program_coding/임도설계 및 견적자동화 프로그램 개발 into sub_laptop_3 2026-11-07 20:48:19 +09:00
eomsangdonandClaude Opus 5.5 0b94ece486 knowledge(요구667): 수식 탭 사용법 1차 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:47:41 +09:00
eomsangdonandClaude Opus 5.5 32208c3577 wip(802-4b-1): 중지 — 빈 산근 = 엑셀 0 원 줄(전후 다름 6 블록 고침 · 시험 한 건)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:30:28 +09:00
eomsangdonandClaude Opus 5.5 cbd02c09e9 wip(802-4b-3): 중지 — 펼친 단가산출 블록 격자 전체 폭(정의 영역 전체 폭 · 기호 줄 오른쪽 칸 자리) · 금액 줄 비목 합 참조 값(moneyScope = 앞 줄 누계) · 빨강 헬퍼 틀(시험 · ORCA 안 돌림)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:30:13 +09:00
eomsangdonandClaude Opus 5.5 6a47356e67 knowledge(요구666): 전체 중지 · 프론트엔드 사용법 먼저 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:30:03 +09:00
eomsangdonandClaude Opus 5.5 8406fd2367 fix(802-4b-1): M02 파생 SK 옵션 값 = M01 문서 기본(자재지역 자동값 0 → 포항) — 받은 값이 이김 · 금액 그대로
- `M02_Doc_Copy.given_of` = M01 수식 탭 기본(`master_equation.given_of` · 자동값 › 기본 제안) › SK 받은 값(빈 값 = 기본) — 옛 = 엔진 자동값만(입력 기본 없는 자재지역 = 0)
- SK 파생 · 일위대가 · SC 줄 문서 · 가져온 식 문서 모두 같은 길 · 8004 GC000211 바탕 SK 자재지역 포항 · 계 124,419 그대로
- 시험 test_802_4a 옵션 값 한 건(빈 입력 · 빈 글 = M01 기본 · 울진 = 울진 · 금액 = 옛 셈) · 795_1 · 795_2 · 795_3 · 795_4 · 802_4a · 802_4b1 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:29:18 +09:00
eomsangdonandClaude Opus 5.5 c80b5ae9d0 feat(802-4b-2): M02 단가산출 수식 탭 = 공용 수식 문서 화면 · 꺼진 줄 = 계산 변수 거침
- M02 SK 「수식」 탭 = M01 과 같은 공용 화면(`createDocScreen` · 길 앞머리만 `/m02/layers/{층}/sangeun/{키}/doc`) — 옛 M02 수식 나무 화면(`_Sangeun_Equation.ts` · 796-2b · 2c · 2e · 800 · 801 꼴) 걷음
  · 옵션 = SK 문서 값(저장) · [단가 갱신] · [재생성](미리 셈 → 알림 계 전 → 후 · 버리는 것 → 저장) · 「M01 새 판 있음」 배지(`formula_doc_copy.ts`)
  · 단가산출 연결 펼침 머리 「연결 / 끊음」 · [연결 끊기] · [다시 잇기] · 끊은 문서 안 줄 그 자리 고침(`formula_doc_sub.ts`) · 저장 몸 {문서, 끊음, 판}
  · 층 쓰기 권한 없음 = 읽기만(옵션 잠금 · 단추 없음 · 「읽기만」) · 판 바뀜 409 = 사본 같으면 새 판으로 한 번 더 · 저장 뒤 페이지 판 · 상세 탭 `수식` 칸 새로
  · 저장 안 한 SK(일위대가 줄 모달) = 「저장한 단가산출만」 글
- 서버 — 수식 길 답에 `연결`(화면 캐시 기록 · `M02_Doc_Screen.record`) · 연결 후보 · 표 길(층 읽기 · M01 후보 · 표 그대로 · main 등록)
- 꺼진 줄(요구 663-1 마저) — 곱 인수 하나가 선택으로 꺼진 0(만약 0 갈래 · 식 줄 기호면 그 식으로 거침)이면 숨김 · 입력 · 글 0 은 그대로
  · 판정을 순수 파일로(`common_util_formula_doc_view.ts` · Node 로 시험) · 1,518 문서 기본 옵션 꺼짐 158 → 203 줄(꺼진 줄 금액 모두 0) · GC000211 폼타이 아님 = 세 규격 줄 모두
- 화면 파일 700 줄 — 고침 함수 · 옵션 가로 · 연결 펼침을 새 파일로(`formula_doc_edit` · `_options` · `_sub`)
- 시험 test_802_4b2(5 · 헬퍼 helper_802_4b2_off.cjs) · 옛 시험 둘 고침 — test_793_3b · test_796_2b 가 걷은 옛 M02 수식 화면 글을 읽음 → 없음 확인으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:20:52 +09:00
eomsangdonandClaude Opus 5.5 5799ccbe90 feat(802-4b-1): 엑셀 불러오기 가져온 식 SK = 수식 문서 — 숫자 값 · 이음 = 연결 + 엑셀 값 기억 · 산근 속 산근 = 형제 단가산출 연결 · 고른 소요량 = 표 연결 · 표지
- 새 `M02_Doc_Import` — 엑셀 단가 기준 엔진 줄(가져온 줄 + SK 고침)을 `master_doc_convert` 로 문서로 옮김(손 값 = 숫자 · 중간 = 식 줄 · EST 얼린 변수 = 숫자 · 덧줄 · 단계 · 끝전 · 자리 · 합밖 그대로)
  · 인력 · 재료 · 유가 · 환율 이음 = 요소 · 기계 EQ = 기계경비 단가산출(칸 = 그 줄 비목 · 시간당 자료 없음 = 숫자 + 설명) · 로직 이음 = 단가산출 · 참조 = 형제 SK 단가산출(칸 = 참조비목)
  · 연결 칸 `엑셀` = 엑셀 값 기억 · 머리 `가져온` {파일, 시트, 행, 갈래, 코드, 표지(797-1a · 못 읽음 까닭 그대로)}
  · 소요량 · 계수 고른 칸(797-2) = 그 수 자리에 표 연결 기호(꼴 pct · kgt = ÷ 배)
- 셈 = 수식 문서 계산기 — [엑셀 단가 그대로] = 엑셀 값 기억 연결을 숫자로 · [우리 단가로] = 연결 값 · 형제 SK = 부르는 쪽 단가기준(안쪽이 바뀌면 따라감)
- SK 금액 = 문서 셈(`M02_Doc_Sk.on_run` · 가져온 식도 수식 사본 · 파생 = 셈마다 지음 · 따로 = 수식 길 저장본 · 가져온 줄 · 이음 · 고름 고침 409)
- 수식 길(…/sangeun/{키}/doc · preview · save) 가져온 식도 · 사본 표지 단가기준 · 단가갱신 = 문서 그대로
- TS 짝 `common_util_formula_doc_import.ts` `importView`(브라우저 계산기 엑셀 보기 · 거울 시험)
- 옮기기 `M02_Doc_Migrate` 가 가져온 식 SK 도 대조 · 씀(--apply --backup) — 실제 storage 보기만: 개인 가져온 식 1 같음
- 시험 test_802_4b1(9) · 불러오기 시험 793_1 · 793_2 · 793_3a · 793_3b · 797_1a · 797_1 · 797_2 · 802_4a 147 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 20:03:26 +09:00
eomsangdon c07cd1d133 Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-07 19:44:02 +09:00
eomsangdonandClaude Opus 5.5 a003587aca fix(802-3): 선택으로 꺼진 금액 줄 숨김 · 값 식 한 번 · 옮김 군더더기 걷음(브레인 사진 지적)
- 663-1: 수량 · 단가가 만약으로 0 을 탄 금액 줄 = 안 보임(GC000211 폼타이 9.5 · 7.9 ㎜) · 규격 바꾸면 그 줄만 · [선택으로 꺼진 줄 보기] = 고침 모드에서만 · 정의 영역도 꺼진 줄 기호 뺌
  1,518 문서 금액 줄 5,912 중 기본 옵션에서 꺼짐 145 줄(107 문서) · 옵션에 따라 꺼질 수 있는 줄 305 줄(141 문서)
- 식 안 만약이 0 · 1 을 타면 그 항(+ 0 · × 1)도 안 보임 — 고침 글은 그대로
- 수 하나 · 기호 하나 줄 값 식 = 한 번만(「= 1 = 1」 없음) · 수량 없음 「× 1」 자리는 줄에 마우스 올릴 때만
- 옮김 slim(master_doc_convert) — + 0 · × 1 · ÷ 1 지움 · A × k ÷ 100 → A × k% · 빌더 다시(목록 xlsx 는 안 씀) · 대조 그대로(기본 1,519 같음 · 옵션 다름 1 · 저장 1,518) · 31 파일 글만
- 시험 test_802_3 + 군더더기 없음 1(12) · 802_1 · 802_2 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:43:00 +09:00
eomsangdonandClaude Opus 5.5 1f7525e502 chore(802-4a): M02 마스터 층 일위대가 · 조합 수식 문서 사본 옮김 — M02_Doc_Migrate --apply --backup tmp/802_4a · 같음(빈 SK 2 = 「—」 · 가져온 식 1 = 802-4b) · 요구 659-1
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:42:47 +09:00
eomsangdon d98e2204e8 Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1 2026-11-07 19:40:40 +09:00
eomsangdonandClaude Opus 5.5 040fc6458e feat(802-4a): M02 단가산출 · 일위대가 · SC · B09 사본 = 수식 문서 사본 — 문서 셈 · 수식 길 · 옮기기 도구 · 대조
- 문서 칸 `수식`(옛 `사본` 은 802-5 까지 옆에) = 값 사본(로직 · 자원 단가 줄) + 부름 닫힌 묶음 + SK 문서 · 끊음 · 따로 — `M02_Doc_Copy`(새)
  · 부름 문서 = 값 줄이 지금 M01 줄과 같으면 M01 수식 문서 · 다르면 만든 때 줄 변환
- SK 금액 = 문서 셈(`M02_Doc_Sk.on_run`) — 옛 칸(덮개 · 더한줄 …)은 파생(엔진에 넣던 고친 줄 → 문서 · 덮개 없는 바탕 = M01 문서 사본) · 수식 길 고침 = 따로(옛 칸 고침 저장 409 · 재생성 = 파생으로)
- 일위대가 · SC 로직 줄 = 문서 셈(`M02_Labor_Copy.line`) · 줄 옵션 = 문서 규칙(켜쌓기율 변수 r · 고른 자재 = 요소 키 · 별도계상 = 합밖 + 뒤 「재료비의 %」 밑엔 그 줄 금액 더함)
- [단가 갱신] = 자원 단가 줄만(문서 그대로) · [재생성] = 지금 M01 문서로(고친 문서 · 끊음 버림 알림) · 빈 SK = 빈 문서 「—」
- 수식 길 GET/POST …/sangeun/{키}/doc · /preview · /save(층 쓰기 · 서버 정본 셈) — `M02_Doc_Sk_Path` · `M02_Doc_Router`(새 · main 등록)
- B09 구조물 · 서고 사본 = `수식.json` 함께 굳힘(`M02_Doc_B09` · 원본판 안 듦) · B09 셈 그대로(문서 셈 전환 = 802-6)
- 옮기기 `M02_Doc_Migrate`(보기만 · --apply --backup · --restore) · 목록 `docs/raw/요구_1104/_목록_802-4a_M02대조.xlsx` — 층 SK 15 · 일위대가 21 · SC 4 · B09 서고 사본 3 같음 · 빈 SK 2 = 옛 0 → 「—」 · 가져온 식 1 = 802-4b
- 시험 test_802_4a(12) · test_204_2 서고 사본 파일 목록에 수식.json · test_795_1 `_fake` 가 M01 수식 문서도 같이 바꿈(M01 정본 = 수식 문서 — 옛 줄만 바꾼 흉내는 문서 사본에 안 닿음) · 옛 SK 흉내 = 사본 · 수식 둘 다 뗌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:40:04 +09:00
eomsangdonandClaude Opus 5.5 54fb55487c feat(802-3): M01 단가산출 기초 수식 문서 화면 — 왼쪽 기호 식 · 오른쪽 값 식 · 빈 문서 시작 · 연결 고르기
- 공용 화면 A00_Common/formula_doc_*(M01 · 뒤에 M02 = 길 앞머리만 바꿈) — 옛 수식 나무 화면은 M01 에서 걷음(M02 는 그대로)
- 보이는 수식 그 자리 고침(공용 editEq · 전체 고르기 = 한 줄 글) · 분수 · 첨자 · 만약 = 탄 갈래만 · 반올림 자리 숨김 · 값 식 단위
- 값 식 숫자 누름 = 그 자리 값 · 기호 값 누름 = 정의 줄 · 정의 영역 = 기호 : 이름 — 연결(키 · 값 · 단위 없음 · 탄 갈래로 닿는 기호만)
- 새 기호 = 정의 필요 → 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식 연결 · 기호 · 첨자 · 이름 고침 · 정의 지움
- 금액 = 경쟁사 꼴 이름 : 단가 × 수량 = 금액 · 묶음 하나 = 계 · 둘 이상 = 가. 이름 소계 + 합계 · 단가산출 연결 펼침 = 그 문서 식 · 값 식
- 셈 = 브라우저 계산기 즉시 · 연결 값 = 서버 기록 캐시(common_util_formula_doc_cache.ts ↔ master_doc_record.py) · 없는 연결만 뒤에서 미리 셈
- 서버 길 GET /api/m01/logic/doc · POST preview · save(서버가 정본으로 다시 셈 · 수식 문서 파일에 씀) · links · GET table (M01_MasterData_Doc.py)
- 연결 대상 안 고른 연결(표 · 요소 · 단가산출) = 연결 끊김 표지(파이썬 · TS 짝 가드 — 서버 500 걷음)
- 시험 test_802_3_doc_screen 11(읽기 · 빈 문서 · 저장 = 서버 다시 셈 · 관리자 아니면 막힘 · 지움 견딤 · 표지 갈래 · 후보)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 19:23:57 +09:00
eomsangdonandClaude Opus 5.5 d51c0b2200 test(791-1): M01 로직 지문 마스터 해시만 다시 뜸 — 801-5 단위 글로 해시 바뀜 · 로직 1,519 지문은 모두 그대로(한 줄만 바뀜)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:55:15 +09:00
eomsangdonandClaude Opus 5.5 9cf93a7488 chore(802-2): 메인 801-5 단위 글 받은 뒤 수식 문서 다시 지음 — 단위 글만 바뀜 · 대조 그대로(기본 1,519 같음 · 옵션 다름 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:45:44 +09:00
eomsangdon 329dcaa61c Merge branch 'main_laptop_1' of C:/Program_coding/임도설계 및 견적자동화 프로그램 개발 into sub_laptop_3 2026-11-07 18:42:02 +09:00
eomsangdonandClaude Opus 5.5 154a044427 feat(802-2): 옛 M01 로직 1,519 → 수식 문서 변환 빌더 · 금액 전수 대조 · 같은 것만 저장 · 다름 목록
- 빌더 build_수식문서_802_2.py(다시 돌리면 같음 · --check) — 기호 = 수식 탭 기호(800-1 · 800-5) · 기본 옵션 = 수식 탭 기본값
- 대조 = 새 계산기 대 옛 엔진 노 · 재 · 경 · 계(결과) 원 단위: 기본 옵션 1,519/1,519 같음 · 옵션 표본 18,716 회(로직마다 고르기 갈래 모두) 중 같음 18,715 · 다름 1 · 둘 다 못 셈 134(옵션 바꾸면 표에 맞는 줄 없는 기계 작업량)
- 다름 1 = GC000100 지역=경북 재료비 1 원 — 식 속 단가산출 부름 값이 끝수 뒤(옛 엔진 = 끝수 전 날 값) · 보정 없음
- 저장 = 같은 것만 1,518 문서 — resources/master_data/수식문서/수식_<로직 파일 꼬리>.json 58 파일(옛 로직 JSON 안 씀)
- 목록 = docs/raw/요구_1104/_목록_802-2_변환대조.xlsx(다름 · 요약 · 둘 다 못 셈 · 변환 알림)
- 시험 test_802_2_convert 9(--check · 전수 기본 · 옵션 표본 모든 갈래 · 목록 · 저장본 다시 셈 · 791 실무 견본 넷 = 엑셀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:41:50 +09:00
eomsangdonandClaude Opus 5.5 f7038cc280 feat(802-1): 단가산출 수식 문서 꼴 · 계산기(파이썬 · TS 짝) · 표지 · 꼴 문서
- 꼴: 변수표(기호 · 첨자 · 이름 · 단위 · 연결 = 숫자 · 선택 · 입력 · 요소 · 표 · 단가산출 · 식) + 식 줄 + 금액 줄 + 끝수 칸 — resources/master_data/_수식문서_틀.md
- 풀이 · 글로 되돌림 · 기호 · 이름 바꿈 전파 · 정적 검사 = master_doc.py ↔ common_util_formula_doc.ts
- 셈 = master_doc_calc.py ↔ common_util_formula_doc_calc.ts — 기호는 필요할 때 셈 · 정의 필요 · 순환 · 0 나눔 · 식 틀림 · 빈 문서 = 표지 + 금액 없음 · 값 없음 = 그 줄만 비움 · 묶음 소계 · 합계 · 줄 자리 · 끝전 큰나머지 · 계 끝수 · 조정 · 번지 · 단계 · 합밖
- M01 연결 풀이(표 찾기 · 요소 · 단가산출 문서) = master_doc_link.py ↔ common_util_formula_doc_table.ts(표 찾기) · 옛 엔진 셈 안 부름
- 옛 로직 변환 = master_doc_convert.py(802-2 빌더가 부름)
- 시험 test_802_1_formula_doc 48(꼴 · 셈 · 지움 견딤 300 넘는 지운 문서 예외 0 · 표지 · 거울 파이썬 ↔ TS 60 문서)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 18:41:39 +09:00
eomsangdon d1bd9d765a Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1 2026-11-07 18:01:57 +09:00
eomsangdonandClaude Sonnet 5.5 d7e6fe5cf8 feat(801-5): M01 로직 단위 글 건설품셈 원문 꼴 — 시간당 ㎥ · ㎡ → hr · 초 → sec · mm · cm · kg → ㎜ · ㎝ · ㎏ · TON · t → ton · km/hr → ㎞/hr
- 대응표 master_unit_text.RULES(규칙마다 원문 근거 절) · 빌더 build_단위표기_801_5(--check · --list · 셈 지문 전후 같을 때만 씀)
- M01 로직 53 개 · 칸 79 개 글만 바꿈(변수 입력 · 중간 단위 · 결과단위) · 계수 · 소요량 표 값칸 · 호표 줄 단위 · 원문이 두 꼴을 다 쓰는 단위(㎞ · km · 분 · min)는 안 건드림
- M02 사본 새로 씀 도구 M02_Copy_Units(M02_Copy_Symbols 의 fill 만 바꿔 끼움) — 사본 16 문서 28 칸 · 금액 전후 같음
- 검토 목록 docs/raw/요구_1104/_목록_801-5_단위표기.xlsx
- 옛 시험 기대 고침: test_796_3 굴착기 cm 단위 초 → sec(원문 표 머리 「싸이클시간(Sec)」)
- 새 test_801_5_units 6

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 18:00:30 +09:00
eomsangdonandClaude Opus 5.5 2376e70fbb revert(802-1): 801-1 wip 옛 나무 위 고침 걷음 — 요구 664 수식 문서로 다시 짬
- 걷음: 43784a4c0 의 옛 수식 나무 위 고침(서버 · 화면 · css) = 그 앞 판으로 되돌림
- 남김: master_equation_sign.py · ui_template_equation_text.ts · ui_template_equation_edit.ts(다시 쓸 조각 · 지금 안 부름) · text 는 `입력글` 을 제 타입에
- 지움: test_801_1_equation_inline.py — 걷은 옛 나무 고침을 재는 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:55:58 +09:00
eomsangdon 6c7618c54a Merge branch 'main_laptop_1' of C:/Program_coding/임도설계 및 견적자동화 프로그램 개발 into sub_laptop_3 2026-11-07 17:53:54 +09:00
eomsangdonandClaude Opus 5.5 b84d56f13b knowledge(요구665): 664 승인 · 요구서_664 수식 문서
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:53:11 +09:00
eomsangdonandClaude Opus 5.5 43784a4c09 wip(801-1): 수식 그 자리 고침 · 한 마디 줄 · 로직 부름 기호 · 반올림 · 안 탄 갈래 숨김 — 요구 664 로 멈춤(메인 안 합침)
- 서버: 한 마디 줄(표 찾기 · 요소 · 로직 부름 하나) = 줄 기호 · 「적용 · 값 · 기준」 칸 글 대신 조건 값 기호 · 로직 부름 기호(master_equation_sign) · 값 식 숫자 단위 · 있는 계산 변수 연결 바꿈 = 그 줄 식 · 식 속 원자 키보드 글(입력글)
- 화면: 박스 없는 조판 · 반올림 감싸개 · 안 탄 만약 숨김(감싸개 글로 구조 지킴) · 보이는 식 그 자리 고침(contenteditable · 원자 data-tok) · 값 숫자 그 자리 고침(valueEdit)
- 시험 test_801_1 7 통과 · 넓은 왕복 199 로직 중 13 걸림(식 300 자 상한 · 요소 EQ/MO 글 · 표 칸 Q 기호 맞물림) · test_796_2 옛 createFormulaCell 기대 안 고침(멈춤)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:50:36 +09:00
eomsangdonandClaude Opus 5.5 d257d7783c knowledge(요구664): 단가산출 기초 = 수식에서 시작 · 근본 방향 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:50:00 +09:00
eomsangdon 4baf69803e Merge branch 'main_laptop_1' of C:/Program_coding/임도설계 및 견적자동화 프로그램 개발 into sub_laptop_2 2026-11-07 17:47:48 +09:00
eomsangdonandClaude Sonnet 5.5 47f260e012 feat(801-3): 수식 탭 소계 · 합계 묶음 다시 — 돈 묶음 하나 = 머리 없음 · 계 / 둘 이상 = 소계 / 단가산출 둘 이상 = 합계 · 안쪽 말 0
- 묶음 한 곳 master_equation_group.bundles: 부른 · 더한 단가산출 하나씩 + 직접 줄(인력 · 재료 · 기계 · 덧줄) 한 묶음 · 돈 없는 줄 · 뺀 줄 = 묶음 아님 · 합계 밖 줄 = 밖 글
- M01 subtotals · M02 M02_Sangeun_Equation_Full 같은 꼴 · 끝수 셈 그대로 · 금액 전후 같음
- 부품 ui_template_equation_sum: 자기 자원 · 덧줄 글 0 · 묶음 하나 = 머리 없음 · 계 · 둘 이상 = 가. 머리 · 소 계 · 단가산출 둘 이상 = 합계
- 옛 시험 기대 고침: test_799_1(갈래 자원 · 덧줄 → 직접 한 묶음) · test_796_2c(줄마다 한 행 → 묶음 · 낱줄 · 밖) — 묶음 꼴이 바뀜
- 새 test_801_3_groups 5

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:44:49 +09:00
eomsangdonandClaude Opus 5.5 4185bf8baf knowledge(요구663): 만약 안 탄 갈래 · 원문 단위 표기 · 값 식 단위 요구 원문
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:39:21 +09:00
eomsangdon 90478a43c3 Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1 2026-11-07 17:31:51 +09:00
eomsangdonandClaude Opus 5.5 6fa3a3ce7b knowledge(요구662): 수식 그 자체 고침 · 묶음 다시 · 적용 · 반올림 요구 원문 · 그림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:29:35 +09:00
eomsangdonandClaude Sonnet 5.5 2a41b989a2 feat(800-5): 호표 줄 금액 참조 기호 — 정의 영역도 씌운 기호(C_합판) · M02 옛 사본 변수표 새로 쓰는 한 번 도구
- 정의 영역: 줄 참조 기호를 이름으로 덮던 줄 뺌 → 수식 영역과 같은 글(master_symbol 한 곳)
- M02_Copy_Symbols: 옛 사본 줄 변수표가 비었거나 열쇠 그대로면 지금 M01 줄 변수표로 채움 · 고친 기호 그대로 · 금액 대조 같을 때만 씀 · --backup
- test_800_5 새 4: 줄 참조 · M01 정의 한글(하위 로직 칩 빼고) 0 · 옛 사본 새로 씀 · 사용자 고침 보존 · 백업 되돌림

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:26:57 +09:00
eomsangdonandClaude Sonnet 5.5 bb5afa4125 chore(800-5): M02 마스터 층 옛 일위대가 · 사전조합 사본 변수표 새로 씀 — 전후 금액 다름 0
- M02_Copy_Symbols --apply --backup tmp/800_5 결과 중 git 추적 마스터 층 7 파일
- 개인 층은 storage(git 밖)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:26:54 +09:00
eomsangdonandClaude Opus 5.5 2732cf0f41 merge(800): sub2 800-1 변수 기호 기본값 · sub3 800-2~4 수식 영역 / 정의 영역 합침
- master_equation.export: 기호 씌움(msym.apply) 먼저 → 정의(view.definitions)는 기호 씌운 줄로 · _clean 마지막
- test_800_2: 800-1 기호로 중간 · 표 찾기가 다른 기호 = 다른 정의(옛 「한 줄로 합침」 기대 낡음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 17:17:13 +09:00
eomsangdonandClaude Sonnet 5.5 7191dbabcd feat(800-1): M01 로직 변수 기호 기본값 전부 — 입력 · 중간 6,664 칸 채움 + 찾기 · 요소 · 합 · 결과 기호 씌움
- master_symbol.py(이름 끝말 → 머리글자 규칙 · 아래첨자 · 갈래별 찾기 위 「표」 · 요소 P · w · 합 C · 결과 P · Q · 한 로직 안 겹침 숫자) + build_변수기호_800_1.py(--check · 채우기만 · 원문 796-3 · 사용자 고침 안 덮음)
- 로직 줄 변수표 6,664 칸(1,403 로직) · master_equation.export 끝 한 줄 msym.apply(보이기만 · 저장 안 함) · 셈 열쇠 · 식 · 값 그대로(1,519 로직 셈 지문 전후 같음)
- 검토 목록 xlsx 첫 시트 「기호 기본값(800-1)」 · 읽는 법(관례 줄 위 · 원문 줄 아래)
- test_800_1_var_symbols 8 · 지문 파일 마스터 해시만 갱신(결과 같음)
- 낡은 시험 고침: test_796_1(작업시간 T · 운반량 Q · 운반회수 n · 운반량 N 겹침은 기본 아래 비움) · test_796_2b(형틀목공 입력글 Q_형틀목공) — 기호 기본값이 생겨 기대 글이 바뀜

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:11:44 +09:00
eomsangdon 43c2bbb8a4 Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-07 17:10:32 +09:00
eomsangdonandClaude Sonnet 5.5 0254be9532 style(800-2): 수식 탭 정의 영역 폭 · 쌓임 기준 조정(M02 좁은 칸에서도 나란히)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:10:05 +09:00
eomsangdonandClaude Sonnet 5.5 59f0dd90a8 feat(800-4): 소계 · 합계를 기준 단가산출별 묶음으로 — 가. 이름 → 노 · 재 · 경 → 소 계 · 맨 끝 소 계(묶음 소계를 더한 식) · 블록 식 줄 아래 · 블록 줄 곁 열쇠 글 걷음
- 수식 영역: 블록 묶음은 그 블록 식 줄 바로 아래(createEqChain after) · 남은 묶음 · 소 계 · 끝수 · 합계는 맨 끝
- 금액 셈 그대로(보이기만) · 묶음 소계 합 + 끝수 = 합계 시험 추가(test_800_2)
- 정의 영역: 로직 부르기 정의의 이름 되풀이 걷음 · 상수 연결 설명

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 17:03:00 +09:00
eomsangdonandClaude Sonnet 5.5 f47991f698 feat(800-2): 수식 영역 / 변수 정의 영역 화면 · 식 바로 고침 · 새 기호 연결 지정(800-3)
- 수식 영역 줄 번호 · 구분선 · 줄마다 풀이 걷음 · 오른쪽 변수 정의 영역 부품 ui_template_equation_defs
- 식 칸을 눌러 그 자리 고침(줄 선택 · 아이콘 줄 걷음) · 틀린 식 그 자리 빨강 + 까닭 · 미리 셈
- 새 기호 = 정의 영역 맨 위 줄에서 연결(입력 · 표 · 요소 · 로직 · 상수) · 이름 · 기호 · 첨자 고침 · 지움(쓰는 변수 막음)
- 낡은 시험 test_796_2_equation_screen 두 건: 아이콘 줄 · 줄 선택 고침 → 바로 고침 꼴로 바꿈(동작 걷음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:58:55 +09:00
eomsangdonandClaude Sonnet 5.5 a262b43e83 feat(800-2): 수식 변수 정의 영역 서버 — 정의 칸(안 탄 갈래 뺌 · 겹침 합침 · 열쇠 글 0) · 새 기호 연결 고침 · 후보 길
- 수식 응답 새 칸 정의 — 만약 탄 갈래 표지 걸림 그대로 · 같은 기호 한 줄 · 연결 사람 말 · 값 · 단위 · DB 열쇠 없음
- 고침 할일 연결(입력 · 표 · 요소 · 로직 · 상수) · 모르는 기호 까닭 · 후보 길 M01 · M02 equation/links
- 낡은 시험 test_796_1 「모르는 이름」 → 「모르는 기호」(까닭 문구 바뀜)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 16:46:39 +09:00
eomsangdon 5e72aff136 fix(est): apply head cost classification safely 2026-11-07 16:39:55 +09:00
eomsangdonandClaude Opus 5.5 f6405eaee4 knowledge(요구661): 소계 · 합계 기준 단가산출별 묶음 요구 원문 · 그림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:36:25 +09:00
eomsangdonandClaude Opus 5.5 972002417a knowledge(요구660): 수식 탭 추가 수정 요구 원문 · 그림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 16:35:54 +09:00
eomsangdon 9eae77d8f2 fix(est): apply selected estimate classification safely 2026-11-07 16:30:44 +09:00
eomsangdon c530ac694c feat(est): replace cost calculation text 2026-11-07 16:18:30 +09:00
eomsangdon cc1c1c659d fix(est): ignore stale Excel quantity selections 2026-11-07 16:13:02 +09:00
eomsangdon 132371f2f3 test(est): cover Korean quantity labels 2026-11-07 16:09:01 +09:00
eomsangdon 36589d1a36 test(est): verify Excel links in new projects 2026-11-07 16:08:02 +09:00
eomsangdon fb54335811 feat(est): preserve mismatched quantity results 2026-11-07 16:06:33 +09:00
eomsangdon 7d6d9769d5 fix(est): reserve deleted Excel link targets 2026-11-07 16:01:55 +09:00
eomsangdon 041d4e653e feat(est): exchange Excel quantity links 2026-11-07 15:59:24 +09:00
eomsangdonandClaude Opus 5.5 e4394d2cd8 chore(795-5): M02 마스터 층 옛 일위대가 · 구조물 조합에 로직 사본 찍음 · 요구 659 장부
- M02_Copy_Migrate --apply --backup tmp/795_5 결과 중 git 추적 마스터 층 7 파일(일위대가 6 · 조합 1)
- 전후 금액 다름 0 · 개인 층은 storage(git 밖)
- 사용자요구_1104 요구 659 원문

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 15:57:21 +09:00
eomsangdon fd6e2e6933 feat(est): persist and refresh Excel quantity links 2026-11-07 15:56:04 +09:00
eomsangdon f746154016 Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1 2026-11-07 15:55:19 +09:00
eomsangdonandClaude Sonnet 5.5 33655934f0 feat(796-3): M01 로직 변수 국문 이름 · 기호 · 첨자 · 단위 기본값 — 빌더로 채움 + 검토 목록
- 변수표_796_3.json(결정 자료 · 변수마다 근거 · 확신 원문 332 · 추정 223) + build_변수표_796_3.py(--check · 채우기만 · 사용자 고침 안 덮음)
- 로직 줄 변수표 555 칸(이름 442 · 기호 173 · 아래 첨자 90) · 빈 단위 24 칸 · 셈 열쇠 · 식 · 값 그대로(1,519 로직 셈 지문 전후 같음)
- 검토 목록 docs/raw/요구_1104/_목록_796-3_변수_이름기호단위.xlsx(추정 · 비움 줄 위)
- test_796_3_var_names 5 · 지문 파일 마스터 해시만 갱신(결과 같음)
- 낡은 시험 고침: test_796_1 M01 길 저장 — 샘플 로직에 기본 변수표가 생겨 「기존 칸 + 고친 칸」 으로 비교

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 15:54:55 +09:00
eomsangdon aac94d6508 feat(est): apply quantity from an Excel cell 2026-11-07 15:49:57 +09:00
eomsangdonandClaude Sonnet 5.5 c47e6dc832 feat(796-2e-2): 더한 기초 블록 [연결 끊기] · [다시 잇기] 화면 — 수식 탭 소계 블록 머리 상태 · 단추 · 끊은 블록 속 수식 고침
- 소계 표 부품에 덧단추 자리(EqSumHooks head · body) · EqPage.sum 으로 페이지가 끼움
- M02 수식 탭: 블록 머리 「연결」 · 「끊음」 + [연결 끊기] · [다시 잇기](확인창) · 끊은 블록 속 = 같은 수식 부품 · [단가 갱신] · [재생성] 보고에 끊은 기초 한 줄
- test_799_1_subtotals 낡은 문자열 비교(createEqSum(eq, open) 통째)를 인자 앞부분 비교로 고침 — 부품 호출에 덧단추 인자가 붙어서

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 15:47:37 +09:00
eomsangdon c788960bf2 fix(est): create quantity sheets on remote HTTP 2026-11-07 15:44:05 +09:00
eomsangdon f6e7095e3e test(est): cover combined quantity edits 2026-11-07 15:41:32 +09:00
eomsangdon d02fa37723 fix(est): preserve quantity results on label changes 2026-11-07 15:39:23 +09:00
eomsangdonandClaude Sonnet 5.5 be43dad64e feat(796-2e-1): 더한 기초 블록 [연결 끊기] · [다시 잇기] 서버 — 끊음 = 이 SK 안 자기 사본 줄 · 수식 고침 · 재생성 때 줄 안 바꿈
- M02_Sangeun_Block 신설: 끊기(닫힌 묶음 굳힘 · 금액 그대로) · 다시 잇기(고침 버림 + 원본 갈음) · 끊은 기초 수식 읽기 · 미리 셈 · 저장 검사
- Calc.sub_run 이 끊은 기초 자기 줄 + 수식 고침을 얹어 셈 · Copy.settle 이 다시 잇기 표지 처리 · [단가 갱신] · [재생성] 응답 끊은기초 한 줄
- Router: block/unlink · relink · equation 몸 블록 · 새 시험 test_796_2e_unlink_basis 6

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 15:38:36 +09:00
eomsangdon 8d4d23e783 feat(est): save quantity item labels in ESTX 2026-11-07 15:37:20 +09:00
eomsangdon 1d1f27bdff feat(est): configure separate print margins 2026-11-07 15:33:06 +09:00
eomsangdon 7d9837a081 feat(est): locate cost formula errors 2026-11-07 15:29:31 +09:00
eomsangdonandClaude Sonnet 5.5 e171dda4e3 feat(797-2): 엑셀 불러오기 소요량 · 계수 이음 — 값 역색인 후보 + 사람 확인 · 고름 = 표 칸 참조 기록
- 새 `M02_Sangeun_QtyLinks` — 가져온 식 줄(호표 수량 · 덧줄 식)의 수를 M01 소요량(QC/QF) · 계수(CC/CF) 칸과 값 역색인(`San_Basis_Index.lookup`)으로 맞춤 · 같은 값 + 같은 단위 성격 + 공종 문맥(인력 = 직종 · 재료 = 칸 이름 추가) · 일반 수 · 비교 글자 · 중간 변수는 자리 아님 · 유일해도 자동 확정 없음
- 고름 = `가져온.연결` 기록(층 M01 · 표 · 줄 · 칸 · 값 · 꼴) · 엑셀 수 = 칸 값일 때만(다르면 400 까닭) · 버림 · 되돌림 · 기록만(셈 · 금액 · 단가 갱신 안 건드림) · 저장 거름(`clean`) = 자리 · 수 맞는 기록만
- 길 `…/sangeun/imported/{qty|qty-pick|qty-drop}` · 가져온 식 만들 때 표지 「참고 · 소요량 후보 N · 사람 확인」 · 「표 칸 없음」(못 옮김 표지 아님)
- 화면 — 가져온 식 모달 머리 판 한 줄(집계 · [이음 보기]) + 모달(줄마다 후보 · [고르기] · [풀기] · [버리기] · [되돌리기] · 부품 상자만)
- 시험 test_797_2_qty_coef_links 9 · 낡은 시험 없음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 15:23:12 +09:00
eomsangdon ceb62d65c5 Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-07 14:34:06 +09:00
eomsangdonandClaude Sonnet 5.5 709880953f test(743-1): 불러오기 잊기 시험 — 실무자료 안 HP 파일 수에 산근 시트(793-3b)를 셈
797-1 탓 아님 — 올림 때 산근 시트(`실무자료/산근/HP-*.json`)를 두는 것은 793-3b 부터이고 `_files` 가 하위까지 훑어 둘로 센 옛 기대가 낡음. 기대를 셋(HP · 격자 · 산근)으로 고침.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 14:33:50 +09:00
eomsangdon 319b0798a6 Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-07 14:17:28 +09:00
eomsangdonandClaude Sonnet 5.5 430aaa9aae feat(797-1): 엑셀 불러오기 요소 연결 — 기계 이음 [우리 단가로] 셈 · 유가 · 환율 이음 · 산근 속 산근 형제 참조 · 읽기 불가 사유 응답
- 기계(EQ) 이음 줄 [우리 단가로] = 시간당 기계경비의 그 비목(옛 Decimal × dict 실패 387 블록 풀림) · 시간당 자료 없는 EQ = 엑셀 단가 그대로
- 내보내기 `_m01` = 유가(MO) · 환율(FX) 유일 짝만 자동 · 여럿 = 표지 · 화폐는 이름 그대로일 때만(「유로폼」 오판 막음)
- 산근 속 산근 = 같은 엑셀 안쪽 블록을 형제 가져온 식 SK 로 먼저 만들어 `참조`(비목 한 줄 부름 = `참조비목`) · 단가기준 따라감 · 못 잇는 줄 = 얼림 + 표지
- 쓰는 곳 · 층 복사 · 프로젝트 굳힘이 가져온 호표 참조를 봄(`Labor_Ref.refs_in` · `_move_doc`)
- 올림 응답 `읽기불가` + 화면 토스트
- 시험 test_797_1_import_links 9 · 낡은 시험 없음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 14:17:06 +09:00
eomsangdonandClaude Sonnet 5.5 cf411c5f0a feat(796-2d): 단가산출 상세 탭 소계 · 합계가 합밖 · 제외 단계 줄을 안 더함 — 엔진 줄 표지 합밖 · 상세 줄 합 밖 배지 · 박스 소계 · 합계 표지 줄 뺌(금액 셈 그대로)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 13:38:54 +09:00
eomsangdonandClaude Sonnet 5.5 137cfb701d feat(796-2c): M02 수식 탭이 SK 셈 답 전체를 그림 — 더한 기초 · 뺀줄 · 덮개 · 율 줄 · 합밖 소계표 · 합계 = SK 금액 · 고친 칸 표지
- 서버 M02_Sangeun_Equation_Full(새 파일) — 합계 = SK 셈 답 기준 · 소계 = 셈 답 줄마다 한 행(바탕 · 덮음 · 더함 · 뺌 · 합밖) + 끝수 · 더한 기초 수식 나무 · 줄 표지(식 · 상수 · 표값 · 품 · 단가 · 변수줄 · 뺌 + 원래 → 지금)
- M02_Sangeun_Equation — read · preview 가 얹음(전 = 고침 없는 SK 금액) · 수식 입력 = 자동값(SK 셈과 같은 입력)
- 부품 createEqSum(tree, open) 상태 알약 · 원래 · 접힘 기초 수식 · 줄 표지 · 흐림 CSS
- 시험 test_796_2c(견본 넷) · 낡은 시험 정리 — test_792_1 TS 거울 시험(799-2 에 걷은 모델 파일 읽음) 지움 · test_799_1 호출 글 갱신
- SK 금액 전후 같음 · M01 수식 탭 그대로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 13:24:35 +09:00
eomsangdon 94c7d29b11 fix(est): name copied projects when saving 2026-11-07 13:18:48 +09:00
eomsangdon 07a292a009 feat(est): open backups and support remote creation 2026-11-07 13:16:11 +09:00
eomsangdonandClaude Sonnet 5.5 c9bdf6d891 feat(799-2): M01 로직 상세 · M02 단가산출 상세 옛 탭 넷(시트 · 인력/재료/경비 · 시험 계산 · 텍스트 수식) 걷음 — 보기는 수식 · 단가산출 상세 · 셈 구동 따로
- 셈 구동 떼기: 새 M01_MasterData_UI_Logic_Drive.ts(driveRun · previewText · autoFill · createDrive) — M01 상세는 숨은 시험 계산 칸 단추 대신 구동 함수 · 시험 계산 칸(Logic_Calc)은 로직 복사 화면이 쓰므로 남기고 같은 구동을 부름 · 재료 고름은 rerun 고리
- M02: 새 _Sangeun_Tabs.ts(수식 · 단가산출 상세 보기 고르기) · Detail 의 시험 계산 · 인력/재료/경비 걷음
- 걷은 파일(git rm): M01 LogicLab_Sheet · Logic_Sheet_Model · LogicLab_Formula(.ts .css) · FormulaTokens · LogicLab_Est / M02 Sangeun_Sheet(.ts .css) · SheetEdit · SheetFormula · logic Sheet_Model · Const_Model · Formula_Model · 부품 시트 줄(createCalcSheetRow) · Cover_Model.sheetGroups · Calc 의 M02 고리(answer · shown · held) · 죽은 CSS · 글
- 서버 길 · 문서 칸(변수줄 · 글줄 · 덮개)은 남김 — 금액 그대로
- 낡은 시험: test_790_1(시트 화면 전부) · test_714_3 · test_748_2(+ helper)는 화면이 없어져 지움 · test_794_1 · test_790_2 는 화면 · TS 거울만 덜고 서버 시험 남김 · test_714_2 · 717_2b · 721 · 722(원래 틀린 한 줄 고침) · 734 · 737 · 750_2 · 793_3b · 796_2 · 796_2b 는 보기 둘 · 새 파일에 맞춤 · 새 test_799_2_old_tabs_gone

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:57:30 +09:00
eomsangdonandClaude Sonnet 5.5 d13b5b1753 feat(799-1): 수식 탭 단가산출 소계 · 끝수 · 합계 — 블록마다 노 · 재 · 경 소계 보이기만(금액 그대로)
- 서버 master_equation.subtotals — 호표 줄(하위 로직 블록 한 줄씩 · 자원 · 덧줄)의 버린 줄 비목 + 끝수(합계 − 줄 합)
- 부품 ui_template_equation_sum — 줄 이음 아래 소계 표(M01 · M02 수식 탭 같이)
- 시험 test_799_1_subtotals — 줄 합 + 끝수 = 합계 · 합계 = 엔진 끝수 입힌 비목 합(전후 같음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:29:26 +09:00
eomsangdon b608d2aa3d Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1 2026-11-07 12:22:18 +09:00
eomsangdonandClaude Opus 5.5 0959ec3c98 feat(796-2b): M02 단가산출 「수식」 첫 탭 — M01 수식 탭 부품 다시 씀(페이지 갈래) · 사본 [단가 갱신] · [재생성] · M01 새 판 배지 · 엑셀 원래
- M01 수식 탭 부품에 `page` 갈래 — 길 · 고침 목록 · 옵션 값을 페이지가 줌 · 저장 = 페이지 [저장] · M01 동작 그대로(판 바뀌면 다시 셈만 더함)
- 새 M02_MasterTemplete_Sangeun_Equation — 문서 `수식고침` 캐시 · 사본 표지 줄 · 가져온 식 「엑셀 원래」 · 일위대가 줄 모달 = 보기만
- 단가산출 상세 보기 = 수식(첫 · 기본) · 단가산출 상세 · 시트 · 옛 셋 뒤로(걷지 않음) · [저장] 뒤 할일 칸 뗌
- 낡은 시험 test_796_2 — 길 글이 앞머리 + 갈래로 바뀜(`/logic/equation${suffix}`) → 고침 · 화면 지킴 시험 하나 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:20:39 +09:00
eomsangdonandClaude Sonnet 5.5 29e60a3ad6 feat(798-1): M01 요율 조달청 제비율 내장 엑셀 — 빌더 · 읽기 전용 화면 · RT 줄 → 칸 이동
- build_제비율책.py: 조달청 xlsx → 내장 엑셀 책 JSON(병합 · 채움 · 글꼴 · 테두리 · 열 폭 · 행 높이 · 시트 둘 · 수식 결과) · --check(판 바뀜 · RT 칸 = 정본 값)
- 제비율엑셀/조달청_제비율_책.json · /api/m01/rate-sheet · 요율 화면 위 엑셀 + 아래 RT 표 · 확대 · 축소
- 정본 요율_조달청제비율.json · B09 요율 셈 그대로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:20:32 +09:00
eomsangdon c48580d6c9 Merge branch 'sub_laptop_3' of C:/Aislo_wt/sub_laptop_3 into main_laptop_1 2026-11-07 12:07:59 +09:00
eomsangdonandClaude Sonnet 5.5 0792a17e2c feat(797-1a): 엑셀 불러오기 빠진 정보 표지 다섯 + 줄 단위 합격 시험 · 표본 밖 고정
- 금액 줄 못 읽음: 결과 뒤 꼬리 글은 떼고 금액 줄로 읽음(2016 틀린 줄 2 → 0) · 못 읽는 글끝 · 식못읽음 = 표지 + 보인 값 보존
- 단가 후보 여럿 · 사람 확인 / 추정(문맥 · 기계 세 줄) 표지 — 임의 선택 대신 표지
- 당초 / 적용 칸 표지(Book.applied_how) · 조정 비율 · 낙찰률 미지원 표지
- FromHopyo.keep 이 읽기 불가 사유(빈 파일 · DRM 암호 · 형식 오류)를 돌려줌
- 사람 확인 표지는 「참고 · 」 머리 — 못 옮김 표지와 가름 · test_793_2 는 못 옮김 표지만 셈(낡은 기대 수정)
- test_797_1a 신설: 작은 견본 · 표지 없는 틀린 줄 0 · 표본 밖 수목 142 · 대흥공통 46 · 2016 374 고정 · 버림1 일치율 감시
- 금액 결과 그대로(엔진 · 수식 · Copy 손대지 않음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 12:07:36 +09:00
eomsangdonandClaude Opus 5.5 f02c39c98a feat(796-2b): M02 단가산출 수식 서버 길 — 사본 줄 수식 읽기 · 고침 미리 셈 · 사본 위 고침 기록(수식고침) · [저장] 정본 셈
- 새 M02_Sangeun_Equation — 문서 덧칸 `수식고침`(M01 수식 고침 꼴 + 까닭) = 사본 위 기록 · 사본은 찍은 그대로(원래 대비)
- 셈 = _base_of 가 바탕 줄(사본 · 가져온 식)에 얹음 → 모든 셈 길 한 벌 · 못 얹음 = 짝잃음 「수식」 · 저장 = 400
- 길 POST …/sangeun/equation(층 읽기) · /equation/preview(층 쓰기 · 저장 없음) · 저장 = SK [저장]
- 가져온 식(SK 키) = 잠깐 키 GX999997 로 엔진에 얹음 · 사본 색인 Copy 얕은 사본(__copy__)
- 원래(sheet) = 수식고침 뺀 셈 · 수식고침 SK 펼치지 않음 · 더한 입력 이름 입력값에 남김
- 시험 test_796_2b_m02_equation 6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 12:05:48 +09:00
eomsangdon 04b5592091 Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1 2026-11-07 11:54:38 +09:00
eomsangdonandClaude Opus 5.5 3e1dc294b2 feat(795-4): B09 사본 규칙 — 만든 때 그대로 · [단가 갱신] 값만 · [재생성] 따로 · 시점 섞임 고침
- 구조물 · 서고 사본 단가 묶음 = M02 일위대가 · SC · SK 로직 사본 줄 먼저(`collect(over)`) · M01 에서 지워진 키 = `사본_남은줄.json`
- 단가 책 새 줄([저장] · 첫 굳힘) = 연결 사본 `단가.json` 자원 줄(구조물 지정 때 값 · `Linked.frozen_rows`) · 없는 자원만 지금 M01 + 책 `now_keys` · 입력 `now` · 알림 한 줄
- POST …/estimation/prices 기본 = 단가 갱신(값만 · 사본 재적용 없음) · job 재생성 = 옛 [업데이트] · 미리 보기 단가 갱신 = 바뀐 자원 · 총괄 · 대가 합 전 → 후
- B09 화면 단추 [업데이트] → [단가 갱신] · 미리 보기 창 [재생성] → 재생성 미리 보기 → [적용] · 안내 글 둘 이름 맞춤
- 옮기기 도구(보기만) — 프로젝트 층 못셈 14 = 「못셈(옛 SP 참조)」 갈래 · B09 구조물 · 서고 사본 대조(그대로 · 재생성 금액)
- 새 시험 test_795_4_b09_copy(8) · 낡은 시험 고침: test_203_1 · test_296 = 단추 이름 「업데이트」 · 호버 글 · 몸 `{ user }` 를 박아 둔 것 → 795-4 이름 · job 덧칸으로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:54:10 +09:00
eomsangdon 02bd87bb4e Merge branch 'main_laptop_1' of C:/Program_coding/임도설계 및 견적자동화 프로그램 개발 into sub_laptop_3 2026-11-07 11:46:34 +09:00
eomsangdonandClaude Sonnet 5.5 eaadb48a2b feat(796-2a): 수식 탭 화면 — 공통 수식 부품 · M01 수식 탭(첫 탭) · 고침 · 변수 창 · 옵션 가로
- ui_template_equation(.ts · _line.ts · .css): 분수 · 첨자 · 괄호 · % · 조건 칩 · 하위 로직 블록(접힘) · 결과명 = 이름 식 = 값 식 = 결과 + 변수 풀이 · 부품 모음 견본 한 줄
- M01 LogicLab_Equation: 옵션 가로(도구줄 + 값 칸 · 찾기형 · 말줄임) · 줄 고르면 식 칸 + 숨은 아이콘 줄 · 서버 미리 셈 다시 그림 · 고침 목록 캐시 → [수식 저장](개정 확인) · 떠나기 전 버림 물음
- 변수 창(이름 · 기호 · 위 · 아래첨자 · 단위 · 값 · 보이는 자리) · 변수 더함 · 지움(쓰는 곳 있으면 서버 까닭 그대로)
- 보기 고르기 = 수식 첫 탭 · 단가산출 상세 · 시트 · 인력재료경비 · 시험계산 · 텍스트 수식은 뒤로(걷지 않음)
- test_796_2_equation_screen 새로 · test_794_1 기대 갱신(첫 탭 수식 · LogicLab 700 줄 상한 — 수식 이음 14 줄)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-07 11:46:28 +09:00
eomsangdon 2bcb8bf9b9 feat(est): allow access on configured network address 2026-11-07 11:37:13 +09:00
eomsangdon 600d7b0ab1 feat(est): show standard price design changes 2026-11-07 11:35:02 +09:00
eomsangdonandClaude Opus 5.5 16d7eede50 feat(795-3): 옛 SK · SC · 일위대가 사본 옮기기 도구 — 전후 금액 대조 · 같음만 씀 · 백업 · 되돌림
- 새 M02_Copy_Migrate — 사본 없는 마스터 · 회사 · 개인 층 문서에 지금 M01 사본 찍어 대조(SK 기준 · SC · 일위대가 기준합 · 반영합)
- 같음 = 사본 칸만 더해 씀 · 다름 · 못 셈 = 안 씀(목록) · 프로젝트 층 일위대가 = 세기 · 대조만(795-4)
- --apply 는 --backup 폴더 필수 · --restore 로 다시 덮음 · 기본 = 보기만(표)
- 실제 storage 보기만 결과 — 다름 0 · 층 문서 같음 41 · 필요없음 6 · 프로젝트 같음 70 · 못셈 14(옛 SP 참조) · 옮기지 않음
- 시험 test_795_3_migrate 3(임시 storage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:32:22 +09:00
eomsangdonandClaude Opus 5.5 cc259d1121 feat(795-2): 일위대가 · 사전조합 로직 사본 — 저장 때 찍음 · [단가 갱신] · [재생성] · 줄 덮개 다시 얹힘
- 새 M02_Labor_Copy — SK 사본(795-1) 한 벌 · 담은로직 로직 줄 · 줄 덮개 더한줄 · 고정 이음 · 자재 MT 가 닿는 M01 닫힌 묶음
- 셈 = Labor_Doc.calc 층 셈 첫머리 사본 덮개 · SC 1 단위 값(Expand) = 그 SC 사본 · 프로젝트 층 · B09 = 옛 길(795-4)
- 사본 정함 = 받은 사본 먼저(열 복사 · 층 복사 = 원본 사본 그대로) → 저장본 → [저장] 때 찍음 · 새 줄만 더함
- 길 POST …/labor/{열 | SC 키}/copy/price|regen · 셈 답 덧칸 사본 표지 · 같은 내용 지문 SC 사본 몫
- Sangeun_Copy 찍기 갈래 나눔(reach · snap · grow) · SK 더한줄 인력 · 재료 · 기계 요소도 사본에
- Labor_Doc 줄 수 그대로(686) · 시험 test_795_2_sc_copy 6 · 기존 일위대가 · SC · SK 시험 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:32:15 +09:00
eomsangdon f915e0c33b feat(est): inspect standard price usage 2026-11-07 11:31:12 +09:00
eomsangdon ba95a6d606 fix(est): preserve notes on added report forms 2026-11-07 11:27:41 +09:00
eomsangdon fb460cd0a3 fix(est): keep progress round names unique 2026-11-07 11:25:41 +09:00
eomsangdon 20ef5a6a7f fix(est): reject oversized attachments early 2026-11-07 11:23:39 +09:00
eomsangdon d2a8889475 fix(est): reject duplicate quantity sheet codes 2026-11-07 11:19:28 +09:00
eomsangdon cb0a4fbfec feat(est): clear estimate quantities 2026-11-07 11:17:58 +09:00
eomsangdon acf6b33921 Merge branch 'sub_laptop_2' of C:/Aislo_wt/sub_laptop_2 into main_laptop_1 2026-11-07 11:17:15 +09:00
eomsangdonandClaude Opus 5.5 5112bc1425 feat(795-1): SK 로직 사본 — 저장 때 M01 닫힌 묶음 찍음 · [단가 갱신] · [재생성] · M01 새 판 배지
- 새 M02_Sangeun_Copy — 사본 = 로직 · 하위 로직 · 찾기 표 · 그 밖 요소(로직 갈래) + 자원 단가 LB · MT · EQ · MO · FX(단가 기준 시점 갈래)
- 셈 = M01 색인 위 사본 줄 덮개(Copy) · Calc 바탕 줄 · run · 저장 · 셈 길 첫머리만 고리(줄 수 그대로)
- 사본은 서버가 정함(저장본 사본 · 없으면 받은 사본 · 그것도 없으면 저장 때 찍음) · 할일 칸 사본할일 = [저장] 때 적용 · 자동 갱신 없음
- 지워진 M01 키 = 사본 마지막 값 · 표지 지운키 · 사본 없는 옛 SK = 옛 길
- 같은 내용 지문에 사본 몫(지금 M01 과 다른 판만) · 길 POST …/sangeun/copy/price|regen
- 시험 test_795_1_sk_copy 6 · 기존 SK · 지문 시험 294 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:16:39 +09:00
eomsangdon 46b3c93f66 feat(est): reset all progress rounds 2026-11-07 11:15:31 +09:00
eomsangdonandClaude Opus 5.5 771e89c983 feat(796-1): 수식 모델 — 엔진 로직 ↔ 수식 나무 양방향 · 변수 표 · 추가 · 삭제 · 이름 전파 · 검사 · M01 길
- scripts/master_equation.py: 로직 줄 → 그림용 나무(이름식 · 값식 · 분수 · 퍼센트 · 조건 · 블록) · 풀이 · 변수 표 · 원문 「여기서」 기호 후보
- scripts/master_equation_edit.py: 나무 · 키보드 한 줄 식 → 엔진 식 · 더함 · 지움(쓰는 곳 막음) · 이름 · 기호 · 첨자(변수표 칸 · 식 그대로) · 고친 중간 줄 표시 4 자리 · 검사
- M01 길 셋(/logic/equation · preview · save — 기존 /save · 개정확인) · M02 사본 이음 export_row · edit_row
- _틀.md 로직 열 차례 끝 변수표 · 셈 값 바뀜 0(791-1 지문 같음)
- 시험 test_796_1_formula_model.py 17

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 11:14:25 +09:00
eomsangdon 083ad3b46b feat(est): manage local project attachments 2026-11-07 11:13:59 +09:00
eomsangdon 2cf1f17c2a feat(est): set precision on selected rows 2026-11-07 11:08:48 +09:00
eomsangdon 539aa04712 fix(est): avoid duplicate copied group codes 2026-11-07 11:06:05 +09:00
eomsangdon 83d3d43f10 feat(est): apply all quantity sheets 2026-11-07 11:04:23 +09:00
eomsangdon b6a3392bc4 feat(est): add data issue search 2026-11-07 11:02:20 +09:00
eomsangdon ad01861c48 feat(stmate-test): 100% COSTN.SMT interpreter, 5-tier X/D/B/BOQ/Kanjub cascade engine, 30-sheet reports & 6-sheet Excel/XML export 2026-11-07 11:01:37 +09:00
eomsangdon db02fa9994 feat(est): edit review summary in workflow 2026-11-07 10:56:50 +09:00
eomsangdonandClaude Opus 5.5 785f5793a7 knowledge(요구): 분석 658 — 경쟁사 분석 · 분석 엔진 검토(근거 · 과적합 · 표본 밖 · 빠진 정보 처리 · 고칠 목록)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:56:13 +09:00
eomsangdon cfe78e2e3b fix(est): guard invalid contract review totals 2026-11-07 10:54:02 +09:00
eomsangdon 12950205f6 feat(est): reorder manual quantity formulas 2026-11-07 10:51:42 +09:00
eomsangdon 0098ba58e2 feat(est): apply progress to schedule 2026-11-07 10:50:03 +09:00
eomsangdon 078fe9034f feat(est): export report results to Excel 2026-11-07 10:47:39 +09:00
eomsangdonandClaude Opus 5.5 a496ca3832 knowledge(요구): 요구서 655 — 자릿수 일단 반영 · 엑셀 누락 주의 · 경쟁사 분석 · 엔진 검토 절
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:45:31 +09:00
eomsangdonandClaude Opus 5.5 cb78132f10 knowledge(요구): 요구 658 — 자릿수 일단 반영 · 엑셀 누락 정보 주의 · 경쟁사 분석 · 분석 엔진 검토
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:45:17 +09:00
eomsangdon 9787b4b1f6 feat(est): print annual item allocations 2026-11-07 10:42:16 +09:00
eomsangdon 7422a70ac4 fix(est): match repeated escalation items 2026-11-07 10:40:30 +09:00
eomsangdon c1a4b9a178 fix(est): reject ambiguous progress inputs 2026-11-07 10:38:36 +09:00
eomsangdon 448b92276f fix(est): preserve unmatched progress exclusions 2026-11-07 10:36:30 +09:00
eomsangdonandClaude Opus 5.5 aea492c0d7 knowledge(요구): 요구서 655 고침 — 핵심 원칙(수식 = 계산 로직 · 변수 추가 삭제 · 이름 전파) · 단계 차례 바탕 → 수식 → 둘레
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:36:27 +09:00
eomsangdonandClaude Opus 5.5 d870615279 knowledge(요구): 요구 657 — 새 요구서 · 계획서 · 수식 고침 = 계산 로직 바뀜 · 변수 추가 삭제 · 코드는 요청 때만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:35:49 +09:00
eomsangdonandClaude Opus 5.5 600fd82241 knowledge(요구): 요구서 655 — 단가산출 수식 탭 · 로직 사본 · 불러오기 요소 연결 · 제비율 엑셀(확정 · 정함 · 물결 1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:35:27 +09:00
eomsangdonandClaude Opus 5.5 899ebd64f9 knowledge(요구): 요구 656 — 값 갱신 경쟁사 동일 · B09 필요하면 값만 · 기호 원문 꼴 · 자동갱신 없음 · 단가산출 상세 탭 둠
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:34:52 +09:00
eomsangdon 5302684c93 fix(est): guard cross-table row paste 2026-11-07 10:34:40 +09:00
eomsangdon 4f33cafb12 feat(est): move estimate rows safely 2026-11-07 10:33:22 +09:00
eomsangdon 0f635cef0f fix(est): distinguish repeated change items 2026-11-07 10:31:12 +09:00
eomsangdon eb3dba0e9e feat(est): compare design change cost items 2026-11-07 10:29:31 +09:00
eomsangdon 5f9b5432ec feat(est): print progress and adjustment costs 2026-11-07 10:27:09 +09:00
eomsangdonandClaude Opus 5.5 7d6c9f3244 knowledge(요구): 분석 655-B — 수식 탭(PPT 수식 꼴) · 여러 단계 · 옵션 가로 · 제비율 내장 엑셀
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:25:48 +09:00
eomsangdonandClaude Opus 5.5 af80736561 knowledge(요구): 분석 655-A — 로직 사본 · 복수 단가산출 소계 · 불러오기 값 연결(기계 이음 312 블록 흠 포함)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:24:52 +09:00
eomsangdonandClaude Opus 5.5 e6d36c3cd7 knowledge(요구): 요구 655 — 이슈관리대장 엄상돈 탭 의견 원문 · 수식 표현 예시 그림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 10:11:03 +09:00
eomsangdon 69eca5b224 auto: 2026-11-07 09:36 (ESD_LAPTOP) 2026-11-07 09:36:37 +09:00
eomsangdonandClaude Opus 5.5 a8eaaca2db knowledge(요구): 분석 654-B — STmate 단가산출 DB 구성 · 분리 방식
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 09:02:27 +09:00
eomsangdonandClaude Opus 5.5 2ccd151728 knowledge(요구): 분석 654-A — EST 단가산출 DB 구성 · 기초와 사용자 자료 분리 방식
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 09:01:16 +09:00
eomsangdon e4b7322bb6 feat(est): exchange price adjustment inputs 2026-11-07 08:53:42 +09:00
eomsangdonandClaude Opus 5.5 23f2a6f6a9 knowledge(요구): 요구 654 — 코드 작업 중지 · 방향 다시 세움 · 경쟁사 단가산출 기본 DB 구성 확인 · 케이브맨 중지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 08:50:31 +09:00
eomsangdon 95c7acb7d4 feat(est): print annual allocations 2026-11-07 08:49:33 +09:00
eomsangdon cffeb84ce4 feat(est): print progress and price adjustments 2026-11-07 08:48:25 +09:00
eomsangdon b3bd093d42 fix(est): preserve fixed review amounts 2026-11-07 08:45:49 +09:00
eomsangdon 3a10256e69 feat(est): reset individual review entries 2026-11-07 08:30:16 +09:00
eomsangdon 35d3cc936c fix(est): validate imported review targets 2026-11-07 08:28:29 +09:00
eomsangdon a0461bf042 fix(est): retain legacy quantity links in Excel 2026-11-07 08:25:13 +09:00
eomsangdon bc41c8716f feat(est): exchange annual and draft progress 2026-11-07 08:24:00 +09:00
eomsangdon 50d66fae78 feat(est): exchange contract review inputs 2026-11-07 08:20:03 +09:00
eomsangdon 2f20e29d31 feat(est): record contract review reasons 2026-11-07 08:18:19 +09:00
eomsangdon 1d02b0205a fix(est): block missing quantity markers 2026-11-07 08:14:32 +09:00
eomsangdon ab5a07ff34 feat(est): exchange quantity documents in Excel 2026-11-07 08:12:27 +09:00
eomsangdon a8c20c3f19 feat(est): exchange quantity and schedule sheets 2026-11-07 08:07:11 +09:00
eomsangdon f20cbe44d1 feat(est): print contract review summary 2026-11-07 08:00:37 +09:00
eomsangdon 922c9a05a1 feat(est): add contract review workflow 2026-11-07 07:59:24 +09:00
eomsangdon 4e50e2fe94 feat(est): reorder annual work stages 2026-11-07 07:53:54 +09:00
eomsangdon b279b964ce feat(est): link quantity to individual rows 2026-11-07 07:51:42 +09:00
eomsangdon b6d98ac9d5 feat(est): print quantity formulas and results 2026-11-07 07:47:58 +09:00
eomsangdon 9380080afe fix(est): show changes without item codes 2026-11-07 07:46:09 +09:00
eomsangdon 19d7093050 fix(est): avoid reused quantity references 2026-11-07 07:44:29 +09:00
eomsangdon 8c1da2c29e feat(est): populate schedule from estimates 2026-11-07 07:42:34 +09:00
eomsangdon 4c6bb9d70a fix(est): apply selected quantity calculations 2026-11-07 07:37:40 +09:00
eomsangdon 864ff1f55b feat(est): manage quantity calculation sheets 2026-11-07 07:33:57 +09:00
eomsangdon 6cea0c97f2 fix(est): reject reserved annual names 2026-11-07 02:43:36 +09:00
eomsangdon 042d574fee test(est): cover original text style boundary 2026-11-07 02:41:05 +09:00
eomsangdon 19097b7255 fix(est): match prior unnamed cost codes 2026-11-07 02:38:42 +09:00
eomsangdon c03929e369 fix(est): combine quantity text edits 2026-11-07 02:35:12 +09:00
eomsangdon 170086a5b1 fix(est): reject ambiguous quantity targets 2026-11-07 02:33:07 +09:00
eomsangdon 06d47eda29 feat(est): save edits across text styles 2026-11-07 02:29:39 +09:00
eomsangdon 0df87683e3 fix(est): preserve zero workflow totals 2026-11-07 02:23:26 +09:00
eomsangdon b14c4fccaf feat(est): show existing quantity links 2026-11-06 23:04:58 +09:00
eomsangdon a138efa5f4 fix(est): parse approximate value annotations 2026-11-06 22:59:17 +09:00
eomsangdon ed2d95745e feat(est): save multiple quantity text edits 2026-11-06 22:51:01 +09:00
eomsangdon 0880665f28 feat(est): save print settings to ESTX 2026-11-06 22:45:11 +09:00
eomsangdon c4c3be0a7c feat(est): read project print settings 2026-11-06 22:37:43 +09:00
eomsangdon 8f9251d627 fix(est): parse marine quantity formulas 2026-11-06 22:12:04 +09:00
eomsangdon 064bc6df4c feat(est): export basic JDL unit details 2026-11-06 21:53:46 +09:00
eomsangdon 2a90c78aac feat(est): import basic JDL unit details 2026-11-06 21:48:08 +09:00
eomsangdon d0f96ff1d6 fix(est): retain unchanged JDL content 2026-11-06 21:42:13 +09:00
eomsangdon 8253fb8424 feat(est): save quantity calculation results 2026-11-06 21:32:02 +09:00
eomsangdon fe9f6af161 feat(est): preserve formatted quantity edits 2026-11-06 18:34:04 +09:00
eomsangdon bd702cf948 feat(est): save native options and expand blobs 2026-11-06 17:48:44 +09:00
eomsangdon a5157dd3a9 feat(est): add document and exchange workflows 2026-11-06 15:13:29 +09:00
eomsangdon 74ab66b00e feat(est): complete four business workflows 2026-11-06 05:51:38 +09:00
eomsangdonandClaude Opus 5.5 4e08509700 knowledge(계획): 계획 이관 790~794 — 단가산출 줄 식 시트 · 엔진 의미 · 식 칸 · 경쟁사 글 식 호환 · 근거 제안 · M01 시트 · 흐름도 걷음 · M01 보강 · 판 표지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:15:32 +09:00
eomsangdonandClaude Sonnet 5.5 43e061ba2a test(791-1): 마스터 지문 해시가 줄바꿈(CRLF/LF) 무관 + 지문 fixture 마스터 칸 재저장(결과 칸 그대로)
폴더마다 체크아웃 줄바꿈이 달라 메인 폴더에서 test_794_2b 셈 지문 시험이 깨짐.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 20:09:38 +09:00
eomsangdonandClaude Opus 5.5 32c200264e chore(794-2a2): dev 받은 뒤 마무리 — 개정 칸 다시 채움 · 지문 1,519 · 낡은 기대 셋
- build_로직_개정.py 다시 돌림 — 새 GF000335 · GF000336 개정 [2026](GX 출처 「자체」 = 빈칸)
- test_791_1 지문 fixture 1,516 → 1,519(기존 줄 같음 · GF000284 = 중간값 칸만 늘고 금액 같음) · test_794_2b 마스터 해시 같은 fixture
- test_713_3 — 로직 수 1,515 → 1,519(794-2b 잔토처리 1 + 794-2a2 셋) · 묶음 335 → 337(794-2b 3-2-1) · 부르는 512 → 513(채집 GX) — 낡은 수
- test_263_2 — GF000284 입력 넷 뒤에 적재기계 · 덤프(794-2a2 고르기 덧) — 낡은 기대

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 20:05:18 +09:00
eomsangdonandClaude Opus 5.5 373e2121b4 Merge remote-tracking branch 'origin/dev' into sub_laptop_2
- _키대장 「다음」 겹침 — 양쪽 큰 값(GF 337 · GC 1195 · GX 5)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:56:57 +09:00
eomsangdon 26d1b41f3f Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-11-05 19:56:33 +09:00
eomsangdon 51c864d212 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-11-05 19:56:32 +09:00
eomsangdonandClaude Opus 5.5 254d275d21 feat(794-2a2): M01 보강 — 자재 운반 GF 둘 · 기계 채집 자체 GX · 덤프운반 GF000284 적재기계 · 덤프 고르기
- GF000335 시멘트운반 · GF000336 철근운반(산림 10-1 · 10-2) — 운임 = 자재 구역화물(톤급 · 거리 줄) ÷ 1.1 ÷ 적재 톤 · 하차 품 = 입력(기본값 없음 · 옛 판 값은 설명 참고만)
- GX000004 깬잡석 · 야면석 · 고임돌 · 파쇄석 채집(기계) — 굴착기 1회 N개 · 건설 공통 8-2-3(GC001147) · 상차 · 소운반 덤프 8-2-8(GC001155) · 실무 봉화#3 Q 12.38 · 9.44 재현
- GF000284 입력 끝에 적재기계(굴착기 0.2~1.0 · 로더 타이어 1.34 · 1.72) · 덤프(15 · 8 · 24ton) — 첫째 = 원문 · 기존 셈 그대로(54 조합 전후 같음)
- 빌더 build_로직_M01보강_794_2a2.py = M01 [저장] 길 · 지킴 시험 test_794_2a2_m01_add.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:56:16 +09:00
eomsangdon 5246cfdfed Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-05 19:51:46 +09:00
eomsangdon f5f9ad1075 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-05 19:48:32 +09:00
eomsangdonandClaude Opus 5.5 28ed218d34 test(794-2b): 근거 제안 listing · block 이 같은 폴더 다른 HP 저장본과 대조해 판 차이 강함 표지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:46:05 +09:00
eomsangdonandClaude Opus 5.5 2101601f39 feat(794-2c): 근거 제안 엔진 후속 — 같은 말 묶음 · 옛 번호 대조표 · 기계 작업량 길 넓힘 · 섞기 오인 넷 고침
- 새 M02_San_Basis_Map — 옛 번호 대조표(건설 옛 11-3 → 8-2-3 · 3-7 → 3-9-1 · 8-2-35 → 8-2-32 · 근거 = 원문 표 값 · 실무 글) · 사방(옛) = 판 대응표 2018 통합 판 · 기계 상수 줄 글 읽기(여러 대입 한 줄 · 괄호 설명 · 끝값 없는 나눗셈) · 작업량당 기계경비 로직 낱말 표
- 엔진 — 같은 말(휠라 = 채움재 · 평떼 = 평잔디 · 육샹 = 육상) · 건설(옛) 같은 번호는 같은 절 + 제목 뜻 겹침만 · Q 식 절 기호 채움(고르는 차례는 채우기 전) · 값 · 제목 길이 품 검산으로 안 받쳐지면 그 기계 작업량당 기계경비(차례 「기계」 · 번호 길은 그대로)
- 검산 probe — 고르기가 범위뿐이면 용량 · 규격 값이 드는 범위 하나로(버킷용량 → 규격구분)
- 459 블록: 없음 85 → 58 · 확정 125 → 164 · 근접 135 → 145 · 사람 114 → 92 · 확정 · 근접 → 없음 0 · 섞기(GF000132) 오인 넷 → GC001148 · GC001189
- 시험 test_793_3a 넓힘 — 없음 ≤ 58 · 확정 ≥ 164 · 손 확인 견본 열 · 1/3 적용 지어 넣지 않음 · 상수 읽기 · 대조표
- 근거후보 꼴 그대로 · M01 · 지식DB 고침 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:45:21 +09:00
eomsangdon 4634a9a871 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
# Conflicts:
#	M01_MasterData/M01_MasterData_UI_LogicLab_Detail.ts
2026-11-05 19:43:52 +09:00
eomsangdonandClaude Opus 5.5 616dfa698a feat(794-2b): 근거 제안 까닭 「판 차이」에 판 번호 · 둘 이상 파일 같은 배 = 「판 차이 강함」
- M02_San_Basis_Check 새 함수 edition · strong(causes 안 고침 · 셈 · 단 · 꼴 그대로) — 블록 판 = 글 속 「20xx년 품셈」 › 파일 이름 연도 · 기초 로직 줄 「개정」 가운데 그 뒤 것 → 근거후보 덧칸 `판` · 까닭 「판 차이('24 보완 · 파일 2024)」
- 같은 기초 · 같은 배(±2 %)가 같은 폴더 다른 HP 저장본에도 판 표지와 함께 있으면 판 차이 · 현장 값 · 그 밖 → 「판 차이 강함(…)」(읽을 때 · 저장본 안 고침)
- M02_San_Basis_Store — one 끝 edition(HP 파일 이름) · listing · block 끝 strong · 저장본 VERSION 794-2b.1
- 지킴 시험 test_794_2b_edition 7 — 개정 칸 = 빌더 값 · 셈 지문 건너뜀 없음 · 잔토처리 ㎥당 보통인부 0.2 · 봉화#98 철근공 ×0.9714 「판 차이('24 보완 · 파일 2024)」 · 봉화#8 · 거창#28 뒤채움 ×1.98 판 차이 강함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:42:34 +09:00
eomsangdonandClaude Opus 5.5 e1a1db7797 feat(794-2b): 로직 줄 칸 「개정」 자동 채움 + 잔토처리(인력) 단독 GC001194 · M01 기본정보 · 시트 근거 줄에 개정 표시
- 빌더 scripts/build_로직_개정.py — 원문 절 머리 「('08, '20, 25년 보완)」 연도 목록 · 산림 [2026] 제정 · 연도 없는 머리는 윗절 · 글 꼴 그대로 출처 뒤에 끼움 · 채움 1,260 / 1,516(빈칸 256 = 머리에 연도 없음 246 · 못 찾음 10)
- 빌더 scripts/build_로직_잔토처리.py — 건설 공통 3-2-1 비고 ㎥당 보통인부 0.2인 · 호표 QC000172 「잔토처리 보통인부」(GC000083 옵션과 같은 표) · 키 GC001194
- _틀.md 7 장 열 차례 · 칸 설명 · _갱신절차.md 로직 빌더 줄
- M01 셈 지문 1,515 전후 같음 · fixture 다시 뜸(새 줄 하나 더함 1,516)
- M01 화면 읽기만 — 기본정보 「개정」 칸 · 시트 근거 줄 「개정 '08 · '20 · '25」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:42:25 +09:00
eomsangdon d654e055c3 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-05 19:39:25 +09:00
eomsangdonandClaude Opus 5.5 598b80eb26 refactor(794-1b): M01 흐름도 패널 걷음 — 로직 상세 오른쪽 = 원문만 · [신규 로직] = 빈 시트 줄 · 흐름 서버 길 · DataFlow 화면 지움
요구 651 · 653 — 계산 공식 참고용 흐름도(① M01 로직 상세 오른쪽 접는 패널)를 걷음. ② M02 일위대가 다이어그램은 그대로.

지움(64 파일 · 18,666 줄 · 되돌릴 길 = git 이력)
- M01 6 파일 1,064 줄 — Store_Flow.py · UI_LogicLab_Flow.ts/.css · FlowRows.ts · FlowView.ts/.css
- M02_MasterTemplete/DataFlow 43 파일 12,799 줄 — 흐름도 화면 · 셈(Compute · Convert · Lookup · Model · Eval …)
- 시험 15 파일 4,803 줄 — test_m01_logic_flow{view,_engine,_project,_save} · test_m02_df_*(값 찾기 values_old 뺌) — 지운 흐름 코드만 지키던 것

남김(바닥) — DataFlow M02_DF_UnitPick(일위대가 셋) · M02_DF_Values(M02_Item_Prices) · M02_DF_Round · _standard 표 ·
  M02_DF_Router = GET /api/m02/df/standards 하나 · M02_DF_Api = cfStandards 하나 · CfRound 꼴은 Round 안으로

고침
- M01 Router — /save = Store_Make.save(개정 확인 · 자체 파일 새 줄 GX · added 그대로) · /calc /text /logic/auto = 옛 셈만 · /logic/flow 둘 지움
- master_formula — 흐름 훅 지움 · 줄에 「흐름」 이 남으면 「흐름도 로직은 걷음」 까닭 오류(저장된 흐름 로직 0 — 마스터 · storage 다시 확인)
- Sangeun_Calc._defaults = 옛 자동값 · M02_Logic_Lib_Read /logic/flow 지움 · main.py 주석
- 화면 — LogicLab(startFlow · scheduleFlow · 예시값 잇기 지움 · [신규 로직] 줄 = 입력 · 중간 · 호표 빈 목록 + 끝수 null) ·
  Side(원문 패널 하나) · Source(머리 칸 이름 m01lab-src__top/__title · DF_Panel.css 안 씀) · Detail · View(nowInputs 지움) · Sheet(흐름 갈래 지움) · 글
- 시험 — 지운 파일을 가리키던 목록 넷(spreadsheet_size · 208_1 · 588 · input_prefs) · 717_1 흐름 길 두 줄 · values_old 흐름 셈 부분 지움
- test_713_3_list 기대 — 로직 1,461 → 1,515 · 묶음 307 → 335 · 작업량 21 → 65 · 부르는 506 → 512(782-1 · 2 · 3 건설 기초 로직 54 더함 ·
  옛 키만 세면 307 · 21 · 506 그대로)
- 새 지킴 test_794_1b_flow_gone — 흐름 길 404 · 표준 표 남음 · 신규 로직 자체 GX · 흐름 남은 줄 까닭 오류

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:38:39 +09:00
eomsangdonandClaude Opus 5.5 d7d1b695f3 knowledge(요구): 요구 653 — M01 보강 여덟 · 다이어그램 ① 만 · 경쟁사 원본 사본 지움(제안대로)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:14:28 +09:00
eomsangdonandClaude Opus 5.5 9456935d2d feat(793-3b): 시트 상태줄 원래 금액 글 — 가져온 식이면 「엑셀 원래」(브레인 승인 · sub3 시트 파일 두 줄)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:51:36 +09:00
eomsangdon 141c45061a Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-11-05 18:49:35 +09:00
eomsangdonandClaude Opus 5.5 fb51c09832 style(793-3b): 근거 제안 단마다 한 줄(단 배지 · 후보)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:48:42 +09:00
eomsangdon 6633784fc1 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-05 18:46:47 +09:00
eomsangdonandClaude Opus 5.5 2e7328d3a5 feat(794-1): M01 관리자 줄 식 시트 — 로직 상세 첫 보기 「시트」 · 식 칸 칠 때마다 검사 · 기존 [저장] 길
- 로직 상세 보기 첫 칸 「시트」(기본) — 이름 | 식 | 값 | 비목 · 근거 · 옵션(시험 값) · 변수 · 계산 · 자원 · 율 · 덧줄 · 단계 · 끝수 · 소계 · 합계 · 상태줄 지금 · 고치기 전
- 식 글 = 이름 꼴(M02 단가산출 시트와 같은 번역 · 새 서버 M01_MasterData_Sheet.py · POST /api/m01/logic/sheet · /logic/sheet/check · 저장 때와 같은 엔진 검사 · 쓰기 없음)
- 식 고침 = 시스템 관리자만 · 되는 식만 줄 사본에 · 캐시 → 기존 [저장](개정 확인 · 서버 검사 422) · [원래로]
- 식 칸 몸 = 공용 A00_Common/sheet_formula_cell.ts — M02 단가산출 시트도 같은 것을 씀(동작 그대로)
- 자체 로직(GX) 편집기 식 날 입력칸 → 읽기만(시트 식 칸으로 갈음)
- 시험: test_794_1_m01_sheet 새로 · test_790_1 식 칸 문구 자리를 공용 파일로 옮김(식 칸 몸이 공용으로 감) · test_717_2b · test_722 LogicLab.ts 줄 상한 627 → 637(시트 이음 10 줄 · 셈 · 고침은 시트 파일로 뺌 · 700 아래)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:45:48 +09:00
eomsangdon f9bd0d71bb Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into main_laptop_1 2026-11-05 18:44:42 +09:00
eomsangdonandClaude Opus 5.5 80c3f72a24 perf(792-2): 시트 다시 셈 서버 다듬기 — 자재 색인 · 찾기 답을 M01 색인에 한 번 · 합판 1.2~1.6 s → 62 ms
- `_Over` 덮개가 자재 색인(`_market` · `_by_class`) · 찾기 답(`_found`)을 속 M01 색인에 둠 — 셈마다 3만 줄 다시 안 지음 · M01 파일 자국이 바뀌면 새 색인 = 새로
- `master_pick.search` = 마스터 · 조건 · 지역 · 단위마다 한 번(`_search` 가 옛 몸) · 사본을 줌
- `calc_doc` = M01 `steady()` 안 — 한 셈 동안 파일 자국 한 번(37 번 → 1)
- 측정(8001 · 10 번 중앙값 · 옵션/상수/식/변수 줄) 합판 62/62/62/66 · 지게 30 · 굴착기 31~36 · 봉화 27 ms · 금액 전후 같음
- 엔진 일곱 번은 남김 — 한 번 ~5 ms · 목표 아래
- 시험 test_792_2_calc_speed(색인 다시 안 지음 · 자국 바뀌면 다시 · 층 섞임 없음 · 셈 답 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:44:03 +09:00
eomsangdonandClaude Opus 5.5 9b92d67189 feat(793-3b): 근거 제안 세 단을 793-3a 길로 이음 · 이 줄에 잇기 고른 것 = 옮길 층 SK · 환산 계수 넣을 때 서버가 다시 바꿈
- 가져온 식 만들 때 SK 가져온.근거후보 = 793-3a 블록 제안 근거후보(San_Basis_Store.block · 못 셈 = 빈 칸)
- 머리 판 후보 = 원문번호 · 옵션 · 품검산 · 입력필요 n · 까닭 [표지 글] · [우리 기초로 바꾸기] 입력 필요 = 바인딩 + 후보 입력필요 + 환산
- 환산 계수 = 가져온 식에서 to-base 다시(바꿈 전 · 후 서버 정본) · 계수 없을 땐 시트 안 엶(셈 길 400 없음)
- 단산 줄 고른 것 층 · 주인 뺌 — 층을 주면 옮기기가 SK 를 못 찾아 고정 줄로 옮겨짐
- 시험 test_793_3b 9(근거후보 채움 · SK 참조 옮기기)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:41:43 +09:00
eomsangdonandClaude Opus 5.5 ab36bcd18c knowledge(요구): 분석 794-2a — M01 보강 후보 · 품셈 판 차이 · 판 표지 꼴 · 사용자 고를 것 여덟
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:38:29 +09:00
eomsangdon 538151177e Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_2 2026-11-05 18:26:15 +09:00
eomsangdon a66de39845 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-11-05 18:26:13 +09:00
eomsangdonandClaude Opus 5.5 5a6cccd997 fix(793-3b): 우리 기초로 바꾸기 — 옵션 = 블록 풀이 후보 바인딩 · 단위 성격 다르면 환산 계수 입력 필요 · 가져온 식 시트 M01 바뀜 표지 걷음
- to-base 몸 환산? · 환산 없이 단위 성격 다름 = 후 None · 필요 ["환산"](막지 않음 · 화면 붉은 칸)
- 화면 bind = 블록 풀이 그 기초 후보 바인딩(엑셀 값 · 기본) + 블록 검산 입력 필요 · 줄 검산(빈 입력값)은 옵션을 못 읽었음
- 가져온 식 SK = M01 짝 없음 — 시트 M01바뀜 · 짝잃음 안 띄움(저장 M01판이 바탕 줄과 달라 늘 떴음)
- 시험 test_793_3b 8

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:26:07 +09:00
eomsangdonandClaude Opus 5.5 d8834c1d63 feat(792-4): B09 일위대가 대가 블록이 엔진 새 의미를 따름 — 다섯 길 같은 금액 · 가져온 식 SK 내역 = 엑셀
- 새 B09_Estimation_Labor_Rule: 새 의미 로직만 대가 블록 줄 `place`(줄 자리) · `out`(합밖 · 제외 단계) · 덧줄 = 수 하나 × 밑 합 꼴이면 `rows:` % 줄(줄.'이름' · 앞 덧줄 · 번지 · 단계 · 비목 합 · 793-2a 괄호 꼴) · 이름 없는 수 식 덧줄 = 고정 줄 · 블록 `rule` {끝전, 조정, 끝수} · 다른 꼴 = 빈 줄 그대로(틀린 값 대신 빈칸)
- Labor_Source: 품셈 블록에 표지 · 단가산출 블록이 줄을 바꿔도 표지 이어 받음(657 줄)
- Calc_Labor .py · .ts 짝: 줄 금액 `cut`(place) · `out` 줄은 비목 합 · part: 밑에서 뺌 · 블록 비목 합 `settle`(큰나머지 → 끝수 → 조정 → 원 버림 · master_cut.settle 차례) · 옛 블록 = 표지 없음 = 전과 같음
- 산근 글 로직 부르기 줄(중기 세 줄 · 하위 산근) 금액도 줄 자리 규칙(792-3 남은 것)
- test_792_3: 다섯째 길 B09 대가(SC → 굳힌 SK 수량 1 → 내역 단가 · 파이썬 = TS 거울) · 실무 봉화 D00102 · 북부청임도 18 을 793-2 길(임시 층)로 가져온 식 SK → 내역 단가 = 엑셀
- test_712_3 낡은 기대: 표 비율 0.78 → 단위 수 1,601 이상 · 0.76 — 782-1 · 782-2 · 782-3 계수 표 94 가 로직에 붙음(그중 단위 기준 25 · 시공능력 · 적용기준 계수 = 단위 기준 없음) · 712-3 때 2,023 중 1,601 그대로 · 마스터 자료 안 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:26:03 +09:00
eomsangdon 3ac9aff2f0 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1
# Conflicts:
#	M02_MasterTemplete/M02_Hopyo_Router.py
2026-11-05 18:21:35 +09:00
eomsangdon be28927a65 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-05 18:20:30 +09:00
eomsangdonandClaude Opus 5.5 532cc52c8e feat(793-3a): 근거 제안 엔진 — 번호 ∩ 값 ∩ 제목 ∩ 기계 상수 → M01 기초 + 옵션 · 품 검산 · 세 단
- M01 값 역색인 · 숫자 정규화 한 벌(N1~N10 · 단위 기준 · % ↔ 소수 · t ↔ kg · 인/양 몫) · M01 지문마다 한 번 · 서버 시작 때 뒤에서 지음
- 값 길(상수 지문 → 로직 · 우연 보정 유의 4) · 합친 길(분석 648-D2 차례 · 후보 2~6 엔진 품 대조) · 검산 · 옵션(B1~B6 → 값 길 V → 고르기 맞댐 → 입력 필요)
- 세 단 확정 · 근접(판 차이 · 현장 값 · 감률 · 할증 배수 · 단위 기준 차이) · 사람(+ 값 약한 1 등) · 없음(M01 보강 목록)
- 길 GET hopyo-basis/{키} · hopyo-basis/{키}/{번} · 파생 저장본 · 올림 때 HP 숨은식(STmate G 식) · 끝내기 때 지움 · 판정 길 그대로
- 보정 자료 = 여덟 파일 상수 1,094 · 시험 test_793_3a 11(459 블록 기초 260 · 옵션 전부 52 · 품 같음 141 · 없음 85)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:19:26 +09:00
eomsangdonandClaude Opus 5.5 ca72718e26 feat(793-3b): 불러오기 단산 줄 = 가져온 식 단가산출 모달 — 블록 → 가져온 식 SK 길 · 검산 · 엑셀/우리 단가 · 이음 고르기 · 근거 세 단 · 우리 기초로 바꾸기 · 입력 필요 · 줄 잇기 · 결과 칩
- 서버: 올림 때 산근 시트 둠(실무자료/산근/<HP>.json · HP 지울 때 같이) · POST …/sangeun/from-hopyo(블록 → 793-2a 내보내기 → 793-2 만들기 · 같은 내용 = 그 SK 다시 씀 · 옛 HP 409) · STmate 끝전 = 엑셀 계와 맞는 쪽
- 화면: [수정] 모달 단산 줄 · 오른쪽 블록 판 [가져온 식으로 열기](새) → 시트 모달 + 머리 판 · [이 줄에 잇기] = 줄 고른 것 SK(입력 필요 남으면 확인 막힘) · 단산 줄 결과 칩
- 시험: test_793_3b_import_screen 6

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:09:00 +09:00
eomsangdon 71c02e6704 Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_laptop_1' into main_laptop_1 2026-11-05 18:05:11 +09:00
eomsangdon 8ad1853242 fix(est): preserve fees in independent annual copies 2026-11-05 18:04:56 +09:00
eomsangdonandClaude Opus 5.5 a0bcf5b48a feat(792-3): run_line · B09 산근 셈이 엔진 새 의미를 따름 — 네 길 같은 금액
- master_cut: 엔진 답 덧칸 `셈법`(새 칸 뗀 작은 로직) · `plain(got, gone)` 이 셈법 있으면 줄 자리 · 합밖 · 단계 · 블록 끝전 · 조정 그대로(켜쌓기율 · 별도계상 뒤 다시 버림이 큰나머지 · 조정을 덮던 흠) · `kept`
- M02_Labor_Doc: run_line 답이 단계 · 셈법을 넘김 · drop_apart 가 plain 에 뺄 줄 차례를 줌(686 줄 · 늘지 않음)
- 새 B09_Estimation_Sangeun_Sum: 산근 풀이 · 산근 글이 새 의미 로직만 엔진 규칙(표시 · 줄 자리 · 합밖 · 번지 · 단계 · 끝전 · 조정) · 옛 로직 = Q ROUND 2 · ROUNDDOWN 1 · 0 그대로
- B09 산근 글: 로직 줄(중기 세 줄) 금액을 줄 하나로 묶어 차례를 호표와 맞춤(합 같음)
- 지킴 시험 test_792_3_engine_paths: 견본 넷(봉화 · 소광 · 영월 · 임도) + 단위 로직 = 엔진 · M02 SK · SC 한 줄 · B09 산근(글 계 포함) 같은 금액 · 다시 버림 셈법 지킴
- test_791_1: 견본 넷을 만들기 함수로 떼어 냄(같은 기대)
- test_222_1 낡은 기대 셋 고침: 751-3 끝수 뒤 별도계상 뺀 기준 = 남은 줄 비목 합 원 버림(소수 없음) · 옛 기대는 「원 버린 기준 − 소수 재료 금액」이라 0.6 원 어긋남
- test_145_1: 작업량 Q 이름 규칙이 B09_Estimation_Sangeun_Sum 으로 옮김

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 18:04:11 +09:00
eomsangdon f255ebc0fe fix(est): preserve precision and parse source notes 2026-11-05 17:57:49 +09:00
eomsangdonandClaude Opus 5.5 2a2822d650 feat(793-2): 가져온 식 793-2a 내보내기 꼴 받음 — 자원 덧칸 · 글줄 이름 → cid · 자리들 합 · 단계 · 중간 검산 · 빈 산근 받음
실무 여덟 파일 455 블록 검산 같음 · 상수 자리 찾기 「(11)」 겹침 고침(M01 식엔 그 꼴 없음 = 옛 상수덮개 차례 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:51:33 +09:00
eomsangdon e3262d2b49 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-11-05 17:44:39 +09:00
eomsangdonandClaude Opus 5.5 e393d67d98 feat(793-2): 가져온 식 단가산출 SK 담기 — 바탕 갈래 · 원본 메타 · 검산 · 우리 단가로 · 이음 고름 · 우리 기초로 바꾸기 · 만들기 길
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:44:31 +09:00
eomsangdon badbd33708 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-05 17:44:11 +09:00
eomsangdonandClaude Opus 5.5 b8cad4f92e feat(793-2a): 내보내기 꼴을 793-2 가져온 식 SK 만들기 몸에 맞춤 — {로직, 원본}
- 호표 줄 = 비목 하나 인력 · 재료 · 기계 → 종류 · 비목 · 단가(엑셀) · 이음(M01 키) / 그 밖 = 로직 값묶음
- 원본 = 파일 · 시트 · 행 · 갈래 · 엑셀금액 · 글줄 {행, 글, 자리, 값} · 표지 · 계약 · 로직 키 · 요소 뺌
- STmate 비목 꼬리 없는 참조도 값 든 비목 하나면 단가 줄
- test_793_2 — 원본 글줄 줄마다 엔진 = 엑셀(금액 줄 1,315 · 계표시 695 · EST 2,903) · 단가 줄은 서버처럼 값을 얹어 셈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:43:20 +09:00
eomsangdon b989fe1724 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-05 17:41:55 +09:00
eomsangdonandClaude Opus 5.5 09a67ce91f feat(792): 단가산출 시트 식 칸 화면 — 줄 식 고침 · 칠 때마다 검사 · 빨간 칸 까닭 · 변수 줄
- 새 M02_MasterTemplete_Sangeun_SheetFormula.ts — 줄마다 [식 고침] → 이름 꼴 식 칸 · 칠 때마다 formula-check(옛 요청 끊기) · 틀리면 빨간 칸 + 까닭 · 바꿀 때 엔진 식을 식덮개로(같은 자리 상수 · 표값 · 품 덮개 걷음) · 빈 칸 · M01 식 = 풂 · 더한줄 수량식(율 · 로직 줄 뺌)
- 변수 줄 — [+ 변수 줄] · 이름 · 식 · 값 · 빼기(Formula_Model addVar · editVar · dropVar) · 자리 뒤에 그림
- Sheet — 식 덮개 줄은 품 칸 안 둠 · 숫자 칸 대신 식 칸 · [전체 M01 원래로]에 식 덮개 셈
- Detail — 시트 옵션에 층(scope)만(줄 수 그대로)
- test_790_1 — 식 칸 정적 단언

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:40:07 +09:00
eomsangdon f033abdf6e Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_4 2026-11-05 17:37:35 +09:00
eomsangdon 03d2fe765f Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-11-05 17:37:34 +09:00
eomsangdonandClaude Opus 5.5 193c3992a9 feat(793-2a): 경쟁사 산근 식 → 엔진 로직 줄 꼴 내보내기 · 여덟 파일 엔진 금액 = 엑셀
- 새 M02_San_Emit.py — 793-1 줄 나무 → 로직 JSON(중간 · 호표 값묶음 · 덧줄 · 번지 · 단계 · 합밖 · 자리 · 끝전 · 조정 · 글줄 · 가져옴 검산 · 계약)
- 자원 이음 열쇠(품명 · 규격 · 단위 · 경쟁사 코드 · 1차 줄 판정 M01 키) · reprice 단가 전파
- STmate 누계 % 밑 = 그 줄 앞까지(뒤 호표 빼고 · 안 빠진 제외 단계 더함) · 율 범위 C = 앞 줄 부름 합
- 지킴 시험 test_793_2 — STmate 233 · EST 222 블록 엔진 셈 = 엑셀 = 참조 평가기 · 줄 4,218 · 단계 695 · 전파 견본 7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:37:26 +09:00
eomsangdonandClaude Opus 5.5 c0604b917f feat(793-2): 가져온 식 단가산출 이음 꼴 — 로직 줄 · 원본 · SK 덧칸 가져온 · 길
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:36:34 +09:00
eomsangdon f4bdd30126 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-11-05 17:34:10 +09:00
eomsangdonandClaude Opus 5.5 93b13e975f feat(790-3): 단가산출 시트 숫자 넓힘 화면 — 식 속 상수 · 표값 칸 · 글 줄 · 까닭 · M01 원래로 · M01 바뀜
- 새 M02_MasterTemplete_Sangeun_SheetEdit.ts — 790-2 셈 답 시트.자리 를 칸으로: 식 속 숫자 = 상수 덮개(식 모양 그대로 · 옵션 따라감) · 표 꼬리표 값 = 표값 덮개(「표 연결 끊김」) · 글 줄 더함 · 고침 · 지움(줄마다 [+ 글] · 근거 머리 [+ 근거 글]) · 까닭 칸(고친 줄 · 뺀 줄 · 더한 줄) · [M01 원래로] 줄마다 · 상태줄 전체 · 「M01 바뀜」 배지 · 짝잃음 띠
- Sheet_Model — 식 조각(서버 scan 숫자 · 표 찾기 차례 · 안 맞으면 칸 안 그림) · 산출서에 글 줄 · 되살린 더한줄 열쇠 밂(liftAddKeys)
- Sheet — 옛 [되돌림] 단추를 [M01 원래로]로 · 더한줄 빼면 까닭 · 글줄 열쇠 당김(dropAddKeys) · 문서 고침 = 캐시 → 서버 셈 · 자동저장 없음
- 더한줄 줄 사이 끼움은 SK 모델에 자리 칸이 없어 덩이 끝 그대로(글 줄만 자리 뒤)
- test_790_1 늘림(정적 단언 + 식 조각 · 글줄 산출서 · 열쇠 밂)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:34:02 +09:00
eomsangdonandClaude Opus 5.5 bb39bacd26 feat(792-1): 단가산출 식 칸 TS 캐시 모델 · 지킴 시험 · 같은 내용 지문에 식 칸
- 새 logic/M02_Sangeun_Formula_Model.ts — setFormula · addVar · editVar · dropVar(서버 clean 이 받는 꼴)
- Const_Model restoreM01 · coverKinds 에 식덮개(서버 restore · kinds 거울)
- M02_Library_Same SK 지문에 상수 · 표값 · 식 덮개 · 변수줄(식만 다른 SK 를 같은 것 409 로 막던 흠 · 빈 칸 = 옛 지문 그대로)
- Formula 검사 길 0 나눔 알림이 못 읽는 식에서 멈추던 흠
- 새 test_792_1_sangeun_formula.py 13 — 할증율 변수 줄 51,501 · 합판거푸집 공구손료 식 51,802 · 수량식 · 이름 꼴 왕복 · 검사 까닭 · 상한 · 순환 · 옛 SK · 저장 400 · B09 굳힘 · TS

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:29:40 +09:00
eomsangdon 61ebb81971 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-11-05 17:23:17 +09:00
eomsangdonandClaude Opus 5.5 710827e901 feat(792-1): 단가산출 사용자 식 칸 서버 — 변수줄 · 식덮개 · 더한줄 수량식 · 이름 꼴 ↔ 엔진 식 · 검사 길
- 새 M02_Sangeun_Formula.py — settle(바탕 줄 + 식 칸 → 엔진 줄 · 틀린 자리 = M01 식 + 짝잃음 · 저장 = 400) · to_name · to_engine(× ÷ · [노임 …] · [별칭]) · check_doc · 상한(길이 300 · 괄호 12 · 변수 100) · 셈 답 덧칸(식글 · 원식글 · 식덮개 · 변수 줄 · 이름표)
- Const formulas 첫머리 · clean_more 끝 · stamp(저장 엔진 식) · restore · kinds · no_flat · covers_n 에 식덮개
- Doc patched · _add_item 두 줄 고침(줄 수 그대로) · Calc stamp 한 줄
- 길 POST /api/m02/layers/{층}/sangeun/formula-check
- M01 로직 1,515 식 9,228 이름 꼴 왕복 같음 · 기존 SK 시험 99 통과

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:23:05 +09:00
eomsangdonandClaude Opus 5.5 d26f7a7e1a fix(790-1): 시트 화면 검증 흠 고침 — 글 옵션 칸 · 품 소수 여섯 자리 · 저장 뒤 고치기 전 · 근거 값 칸
- 고르기 없는 옵션(자재지역 「포항」) = 글 칸(숫자로 읽어 NaN 뜨던 흠) · 단위 겹침 뺌
- 품 · 단가 칸 보임 = 소수 여섯 자리(0.1111111111111111 → 0.111111)
- [저장] 뒤 상태줄 「고치기 전」 바로 새로 그림
- 근거 줄 값 칸 「—」 뺌

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:17:36 +09:00
eomsangdon c20a18bf7a Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3 2026-11-05 17:05:59 +09:00
eomsangdon 29b8205df8 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-11-05 17:05:57 +09:00
eomsangdonandClaude Opus 5.5 48d3d4e1af feat(790-1): 단가산출 줄 식 시트 — 상세 첫 탭 「시트」 · 이름 | 식 | 값 | 비목 · 산출서 보기 · 상태줄 금액 셋
- 새 M02_MasterTemplete_Sangeun_Sheet.ts(+ .css) — 보기 고르기(시트 + 옛 넷 그대로) · 줄 갈래(근거 · 옵션 · 변수 · 계산 · 자원 · 율 · 소계 · 합계) · 칸에서 옵션 · 품 · 단가 · 율 % · 빼기 · 되살림 · 되돌림 · [+ 자원 줄] · [+ 율 줄] · 고칠 때마다 서버 셈(최신 응답만) · M01 과 다른 줄 색 띠
- 새 logic/M02_Sangeun_Sheet_Model.ts — 시트 줄 · 읽기 쉬운 식(텍스트 수식 이름글 · 별칭) · 엔진 날식 숨김 · 산출서 한 줄 글 · 상태줄(M01 원래 · 지금 · 고치기 전)
- ui_template_calc_row 시트 줄(createCalcSheetRow · is-changed) + 부품 모음 견본 한 줄
- Sangeun_Detail = 탭 등록만(createViewPicker → createSangeunSheet)
- 시험 test_790_1 새 · test_721 단추 자리 단언을 새 보기 고르기 이름으로 고침(createViewPicker 걷음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:05:43 +09:00
eomsangdon 09cdf42ebb Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-05 17:04:25 +09:00
eomsangdonandClaude Opus 5.5 19ac55f9c6 feat(790-2): 단가산출 숫자 넓힘 서버 — 상수 · 표값 덮개 · 글줄 · 까닭 · M01 원래로 · M01판
- 새 M02_Sangeun_Const.py — 식 n 번째 숫자 · 표 찾기 덮개(엔진 파서 차례 · 식 구조 그대로라 옵션 따라감) · 글줄 · 까닭 꼴 검사 · 저장 때 M01판(자리마다 지문) · 셈 답 덧칸 원래 {노 · 재 · 경 · 계} · 시트 {자리 · 글줄 · 짝잃음}
- Doc.patched 첫머리 · clean 끝 · Calc.clean_doc · calc_doc 한 줄씩 끼움(줄 수 그대로) — 화면 · 저장 원값 · B09 · 펼침 겹침 한 벌
- Library_Flat — 상수 · 표값 덮개 SK 는 안 펼침(참조 그대로 · 값 그대로) · [초기화]가 새 덮개 · 까닭도 걷음
- 새 logic/M02_Sangeun_Const_Model.ts — 캐시 고치기(setConst · setTable · 글줄 · setReason · restoreM01 · dropAddKeys · coverKinds) · 파이썬 짝 거울 시험
- 새 test_790_2_sangeun_const.py — 견본 금액 = _분석_650-H2(61,088 · 51,802 · 38,696 · 35,764) · 옛 SK 꼴 · 금액 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:03:46 +09:00
eomsangdonandClaude Opus 5.5 c05139a971 feat(791-1): 엔진 셈 의미 넓힘 — 금액번지 · 소계 단계 · 제외 · 줄 자리 · 블록 끝전 · 결과 조정 · 변수 표시
- 새 모듈 master_formula_sum.py — 덧줄 고리(master_formula.run 에서 옮김) · 번지 · 단계 · 합밖 · 조정 · 표시 · 검사
- master_cut.py — 줄 자리 {방법, 자리} · 블록 끝전(비목버림 | 큰나머지) · 결과 조정 · 답 단계 목록
- master_formula_check.py — 새 칸 검사(없는 번지 · 단계 순서 · 자리 범위 · 끝전 · 조정)
- _틀.md 7장 「셈 의미 넓힘」 · 8장 덧줄 이름
- 지킴 시험 test_791_1 — M01 로직 1,515 지문 전후 같음 · 새 의미 단위 · 실무 견본 넷(봉화 D00102 · 울진소광 D01489 · 동부청영월 D00003 · 북부청임도 18) = 엑셀
- master_formula.py 690 → 669 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 17:03:36 +09:00
eomsangdon 66e0728d91 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-05 17:00:25 +09:00
eomsangdonandClaude Opus 5.5 428d35cbd6 feat(793-1): 경쟁사 산근 글 식 파서 · 참조 평가기 · 실무 여덟 파일 지킴 시험
- M02_San_Expr — eval 없는 식 나무 · 셈 · 줄 자리 규칙(버림 · 반올림 · 올림 × 0~3)
- M02_San_Stmate — 숨은 G 열 원문 → 줄 나무(계 5 단 · (-=) · 대입 · [A] · ;; · &L/M/S/X/D& · &P/R& 율 · :=A01 · -> · // 덧글)
- M02_San_Est — 보이는 글 → 식 복원(단가 · 앞줄 · 상수 역할 · 값 줄 숨은 합 · 변수 얼림 · 단가 정체 · 모호 표지)
- M02_San_Eval — STmate 셈(특수 % · 끝전 두 갈래 · 결과금액 조정 · 계약단가) · EST 다시 셈 · 줄마다 검산 · 단가 전파
- M02_San_Book — 같은 책 블록 · 단가표 읽기(읽기만)
- test_793_1 — STmate 233/233 · 금액 줄 1,315 · 계 줄 695 · EST 222/222 · 줄 2,901/2,903 · 모호 9 · 전파 견본 7

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:59:56 +09:00
eomsangdonandClaude Opus 5.5 bd34ba7bda knowledge(요구): 요구서 652 — 단가산출 줄 식 시트 · 경쟁사 글 식 호환(사용자 확정 · 정함 · 물결 1 일감)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:48:04 +09:00
eomsangdonandClaude Opus 5.5 3f071c9726 knowledge(요구): 요구 652 — 단가산출 줄 식 시트 · 경쟁사 글 식 호환 권장대로 반영
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:47:27 +09:00
eomsangdonandClaude Opus 5.5 17b6ee5460 knowledge(계획): 계획 이관 789 — 단가산출 엑셀처럼 식 편집 검토
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:33:53 +09:00
eomsangdonandClaude Opus 5.5 a4c28aab09 knowledge(요구): 분석 650-H1 · H3 — 경쟁사 산근 편집 흐름 · 단가산출 엑셀처럼 줄 식 시트 제안(E1~E7)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:33:52 +09:00
eomsangdonandClaude Opus 5.5 bae8ca4c9c knowledge(요구): 분석 650-H2 — 우리 단가산출 엑셀처럼 편집 가능성 · 다이어그램 진단 · 견본 셋
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:30:48 +09:00
eomsangdonandClaude Opus 5.5 c7a8d706ec knowledge(요구): 요구 651 — 다이어그램(흐름도) 방식 못 씀 · 650 덧말
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:19:51 +09:00
eomsangdonandClaude Opus 5.5 5ce6249a95 knowledge(요구): 요구 650 — 649 권장 채택 · 단가산출 엑셀처럼 식 편집 검토
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 16:18:43 +09:00
eomsangdonandClaude Opus 5.5 ab6cb89641 knowledge(계획): 계획 이관 788 — 엑셀 불러오기 개발 방향 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:49:09 +09:00
eomsangdonandClaude Opus 5.5 4e91631464 knowledge(요구): 분석 649-G2 · G3 — 경쟁사 산근 글 그대로 다시 셈 100 % 재현 실측 · 개발 방향(갈래 C 섞음 권함 · M01 고칠 것 M1~M7 · 단계 P1~P5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:49:08 +09:00
eomsangdonandClaude Opus 5.5 22b56940b0 knowledge(요구): 분석 649-G1 — 우리 모델 ↔ 경쟁사 모델 차이 · 경쟁사 불러오기 선례 · 규정 제약
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:43:23 +09:00
eomsangdonandClaude Opus 5.5 48812e3996 knowledge(요구): 요구 649 — 경쟁사 방식 호환 · 단가산출 기초 고침 · 개발 방향 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 15:22:59 +09:00
eomsangdon 25827d4039 Merge remote-tracking branch 'origin/CODEX' into main_laptop_1 2026-11-05 15:22:09 +09:00
eomsangdon 8001d63852 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-05 15:22:08 +09:00
eomsangdonandClaude Opus 5.5 737c2f678c knowledge(계획): 계획 이관 787 — 엑셀 불러오기 근본 원인 분석
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:46:45 +09:00
eomsangdonandClaude Opus 5.5 904815a1e0 knowledge(요구): 분석 648-D2 · 648-F — 합친 길 실측 · 엑셀 불러오기 근본 원인 일곱 · 해결 방향 일곱
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:46:45 +09:00
eomsangdonandClaude Opus 5.5 c725694d58 knowledge(요구): 분석 648-D — 수 지문 → M01 표 역조회 전수 실측(블록 459 · 절 964 · U/P/A/N)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 14:27:33 +09:00
eomsangdon 3e9fb28714 fix(est): propagate assigned prices into final totals 2026-11-05 14:23:21 +09:00
eomsangdon a6fc896655 fix(est): parse units in original calculation notes 2026-11-05 14:13:43 +09:00
eomsangdon 114a353b9e fix(est): retain undo history for large projects 2026-11-05 14:08:10 +09:00
eomsangdon b15f7cbf09 fix(est): keep large contract snapshots saveable 2026-11-05 14:05:41 +09:00
eomsangdon 9f9151f56d fix(est): preserve active edits during queued saves 2026-11-05 13:53:12 +09:00
eomsangdon 9a4070fae5 fix(est): compare imported original contract values 2026-11-05 13:46:42 +09:00
eomsangdon c6118c97ed fix(est): preserve component rates in resource totals 2026-11-05 13:39:04 +09:00
eomsangdonandClaude Opus 5.5 8a7991cf33 knowledge(요구): 분석 648-E — EST 원본 54 건 ↔ 실무 엑셀 출처 대조 · BookData · 라이브러리 → M01 대응 어림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:38:29 +09:00
eomsangdon a007d6358a fix(est): allocate annual component amounts 2026-11-05 13:22:24 +09:00
eomsangdon 216e2ae2a7 fix(est): propagate text subtotals and bid rates 2026-11-05 13:17:44 +09:00
eomsangdon f3821043d4 Merge remote-tracking branch 'origin/CODEX' into sub_desktop_1 2026-11-05 13:14:23 +09:00
eomsangdonandClaude Opus 5.5 429a4ddf4b knowledge(요구): 분석 648-C — 실무 엑셀 여덟 파일 단산 블록 459 틀 · 규칙성 · M01 대응 · 근본 원인 신호
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:13:37 +09:00
eomsangdonandClaude Opus 5.5 6937882375 knowledge(요구): 분석 648-A · 648-B — EST · STmate 산근 생성 방식(라이브러리 · 복사 계보 · 식 문법 · 정체 열쇠 · 데스크탑 REA 빈칸)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 13:08:18 +09:00
eomsangdon 1454b3c315 fix(est): print complete tables beyond page limits 2026-11-05 12:58:19 +09:00
eomsangdon 516d2ae329 fix(est): preserve links and formula precision 2026-11-05 12:51:49 +09:00
eomsangdonandClaude Opus 5.5 bea9bf49c7 knowledge(요구): 요구 648 — 근본 원인 분석 시작 · 단계적 정밀 · REA 는 데스크탑
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 12:45:00 +09:00
eomsangdonandClaude Opus 5.5 f0fcf7497d knowledge(요구): 요구 647 — 646 답(입력 필요 · 모달) · 기초 없음 뜻 물음 · 근본 원인 분석 먼저
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 11:07:45 +09:00
eomsangdonandClaude Opus 5.5 bc5fe1c674 knowledge(요구): 요구 646 — 단가산출 기초 줄 옵션 지정 화면 검토(대화만)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 09:49:59 +09:00
eomsangdonandClaude Opus 5.5 bc67ddef8e knowledge(계획): 계획 이관 786 — 불러오기 [수정] 모달 되살림 · 별도계상 품목 고르기 없앰
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:47:10 +09:00
eomsangdonandClaude Opus 5.5 56b73b79b8 fix(786-2): 화면 검증 흠 고침 — [구조물 조합으로] 참조 주인 · 실패 되돌림 · 표 머리 칸 · 이름 칸 덧글 줄
- ToItem: SC 참조 주인 = 실제 주인 id(개인 「회사/사용자」 · Find_Pick.upper 와 같은 ownOwner 길) — null 이면 서버 400
  「참조 층 · 주인 꼴이 틀림」 · 일위대가 못 담으면 방금 더한 항목 지움(빈 항목 안 남김)
- CheckRows: [확인] · 행 머리 칸 = 표마다 새 노드(두 표가 같은 노드를 써 로직 표 머리가 두 칸 밀림)
- Hopyo.css: 이름 칸 덧글(셈 못 함 · 입력 필요) 한 줄씩

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:41:33 +09:00
eomsangdonandClaude Opus 5.5 b6a18d68e0 fix(786-2): 별도계상 줄 표지 = 786-1 apartPick(서버 제안 고정 줄 하나 · 별도계상) — 고름 칩 없이 「별도계상 · 품목은 프로젝트에서」
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:29:05 +09:00
eomsangdon 16661de18e Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-11-05 07:26:46 +09:00
eomsangdonandClaude Opus 5.5 e91fd4398e feat(786-2): 불러오기 [수정] → 모달 「확인 — 제N호표」 옛 꼴 되살림 · 새 기능 「새」 배지
- 새 Hopyo_Check(모달 껍데기 · 머리 = 호표 · 엑셀 단가 · 제안 n / 줄 m · 확인 n · 만든 일위대가 ·
  [호표 제안 모두 확인](새) · [일위대가 생성] = 옮기기 길 이 호표만 · [구조물 조합으로] · [엑셀 보기])
- 새 Hopyo_CheckRows(로직 표 + 자재 표 = 구조물 조합 상세 꼴 · Labor_Rows moneyCell · PRICE_SLOTS ·
  Labor_MatMarks 빌려 씀 · 칸마다 엑셀 / 우리 단가 · 품 비교 · [고르기] · [원문 보기] · [옵션] · 추가 / 로직없음)
- 새 Hopyo_ToItem(구조물 조합 나무 add-item → 그 일위대가 문서에 SC 참조 한 줄 · 옮겨진 뒤만 켬)
- Hopyo_Rows: 시트 아래 줄 판 몸 걷음 → 줄 칸 부품 createRowKit(판정 · 고름 칩 · 블록 풀이 · 단위 환산 · [확인]) ·
  옮기기 한 번 runMove(오른쪽 [옮기기] · 모달 [일위대가 생성] 한 벌)
- Router: POST /hopyo/{키}/price 우리 금액 미리 셈(저장 없음 · Make.Move.pick · Lines.picks → Labor_Doc.calc 재사용)
- Sheet: [수정] = openHopyoCheck · 시트 아래 자리는 단가산출 블록 판(SheetSk) 몫으로 남김
- 안 살림: 맞음·별칭 일괄 체크 · 품셈으로 바꾸기 · 모두 고정으로(지운 서버 길 몫) · 사용안함(새 옮기기 길에 뺌 없음)
- 시험: 새 test_786_2_check_modal(우리 금액 예시 1 = 94,227 · 예시 3 입력 필요 → 0 · 쓰기 0 · 모달 · 새 배지 ·
  공용 import 만 · 날 button/input 0) · test_642_6 단언 = 줄 판 createJudgeRows → 모달 openHopyoCheck(까닭: 줄 판 몸 걷음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:26:37 +09:00
eomsangdon 343436d816 Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_laptop_1' into main_laptop_1 2026-11-05 07:18:54 +09:00
eomsangdon 4d9e4530a1 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-05 07:18:53 +09:00
eomsangdonandClaude Sonnet 5.5 e23fec8c9c feat(786-1): 별도계상 줄 품목 고르기 없앰 — 후보 고정 줄 하나 · SC 합 밖 고정 줄
- Row.judge: 별도계상 줄은 M01 · 사전 · 장부 후보 안 찾고 고정 줄 하나(품목 「프로젝트에서」 표지) · 갈래 수 그대로
- Make: 별도계상 고정 줄 = 단가 0 · 메모 「별도계상 · 품목은 프로젝트에서」(합 밖)
- Picks.ts: apartPick 표지 셈
- 시험 642_3 · 642_5 · 642_6 Picks 몫을 새 꼴로 고침(옛 단언: 레미콘 자재 목록 별도계상 칸)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 07:17:50 +09:00
eomsangdon d922ad13f3 fix(est): match project costs and stored formulas 2026-11-05 07:12:08 +09:00
eomsangdon bcc570ff31 Merge remote-tracking branches 'origin/CODEX' and 'origin/안티그래비티' into sub_laptop_1 2026-11-05 07:07:41 +09:00
eomsangdonandClaude Opus 5.5 bb0f7134b5 knowledge(요구): 요구 645 — 수정 모달 옛 방식 + 새 기능 표기 · 별도계상 품목지정 없음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 07:04:50 +09:00
eomsangdonandClaude Opus 5.5 eae688ccb3 knowledge(요구): 분석 644 — 불러오기 [수정] 모달이 줄 판으로 바뀐 경위 · 구조물 조합 공용 부품 · 갈래 셋
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 06:37:15 +09:00
eomsangdonandClaude Opus 5.5 6fd55df168 knowledge(요구): 요구 644 — 일위대가 수정 모달 되돌림 검토(구조물 조합 공용 조심)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 06:31:10 +09:00
eomsangdon 1856aa897d feat(est): connect original quantity sheets 2026-11-05 06:19:15 +09:00
eomsangdon 91d5f478cc fix(stmate-test): resolve 5 UI/calculation issues found during browser control verification 2026-11-05 06:11:43 +09:00
eomsangdonandClaude Opus 5.5 d99f3a1fc1 knowledge(요구): 요구 643 — 검증 결과 아티팩트 공유 링크
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 06:10:58 +09:00
eomsangdon 60ccedcd9a feat(est): reproduce native tables and calculations 2026-11-05 06:07:05 +09:00
eomsangdonandClaude Sonnet 5.5 11d1f5116d test(785-8): 낡은 시험 둘 가름 — 울진소광 S1 해시 다시 굳힘 · 구조물 조합 제목 줄 단언 지금 꼴로
- test_621 울진소광 S1: 785-1(새 M02_Hopyo_Clause 출처 풀이)부터 단산 1479 「옆도랑파기(토사)」 출처 「건설품셈(옛) 8-2-3」 → 「건설품셈 공통 8-2-3」(같은 번호 형제 블록 1477 · 이미 공통) — 뜻 있는 변화 · 바뀐 칸 이 한 곳뿐 · 수 · 갈래 · 참조 그래프 그대로 · 이 해시 한 칸만 새로 굳힘(나머지 일곱 파일 · S2 칸 그대로)
- test_m02_layer_screen 구조물 조합 제목 줄: 738-2 · 740-2 가 제목 줄에 [초기화] [삭제](actions.reset · actions.del)를 끼운 뒤 낡은 단언 — 지금 꼴(reload › reset › del › save › 새 항목)로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-05 01:56:07 +09:00
eomsangdon 1ca1940272 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/안티그래비티' into sub_laptop_4 2026-11-05 01:48:19 +09:00
eomsangdonandClaude Opus 5.5 4f36664df7 knowledge(계획): 계획 이관 782 · 784 · 785 — 건설 공통부문 기초 · 우리 품 · 엑셀 불러오기 다시 짬
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:47:54 +09:00
eomsangdonandClaude Opus 5.5 c8f3d244ae test(785-7): test_728_2 돈 아닌 기초 수 = 지금 M01 65(낡은 기대 21 · M01 기초 늘어 깨짐)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:32:48 +09:00
eomsangdonandClaude Opus 5.5 cf4e2a0339 refactor(785-7): 옛 맞춤 화면 TS 지움 · 옛 [저장] · [모두 고정] 단추 걷음 · 390 줄 판 높이
- 지움(git rm) 15 파일 3,318 줄: Hopyo_Check · CheckRows · Choose · WizardSk · SkApi · SkLines · logic/SkPick · logic/Draft ·
  Left · Lost · Pick · SheetMake · Kids · ToItem · Compare(모두 옛 확인 창 · 마법사 단가산출 · 안 이은 줄 길 몫 · 부르는 곳 0)
- 단추 판단: 엑셀 보기 [저장](체크 호표) · [모두 고정으로 들여오기] 화면 단추 걷음 → 만들기 = [옮기기] 하나 ·
  모두 고정 서버 길(Builder · build_all)은 B09 · 붙여넣기가 써서 남김 · 목록 체크 칸 · 모달 껍데기(openHopyoSheet · raise · lower) 걷음
- Import: 「안 이은 줄」 띠 · [안 이은 줄 고르기] · 남은 줄 셈 걷음 · [끝내기] = 물음(확인하고 안 옮긴 수 = 판정 고름 캐시 moveBody)
- Api: 옛 맞춤 타입 · 길(readHopyoOne · guessHopyo · buildHopyo · buildHopyoAll · leftHopyo · listHopyoPicks · hopyoSiblings ·
  buildHopyoItem …) 걷음 · POLL_MS · HOPYO_BUILT 옮겨 옴 · 목록 꼴 = 1차 판정 줄상태(Judge.view)
- Labor_Pick: withHopyo(실무 호표 덧줄) → withSangeun(찾아 넣기 창 표시만) · Detail · Table 부름 바꿈
- Ledger: unitField 옮겨 옴 · SheetNav: 옛 확인 캐시 칠 걷음 · UI_Page: [구조물 조합으로] 듣기 걷음
- CSS: 옛 확인 창 · 고르기 창 규칙 34 걷음(481 → 287 줄) · 390 폭 = 줄 판 열리면 위 칸 넓힘 · 줄 판 높이 바닥 16rem

시험 정리(까닭 = 지운 화면 파일만 읽음):
- 지움: test_729_3_hopyo_left_screen · test_746_2_hopyo_compare_screen · test_778_5_sk_pick_screen · helper_729_3 · helper_m02_hopyo_check · pick · seam
- test_m02_hopyo_screen = 좌측 판 · 끝내기 둘만 · 627 · helper_778_5 = SheetSk 판 바꿈만 · 785_4 · 721 · 717_4 · 720 · 739_1 = 지금 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:32:17 +09:00
eomsangdonandClaude Opus 5.5 48848bb287 refactor(785-7): 옛 맞춤 층 Python 지움 · 서버 새 길로 정리 · 낡은 시험 정리
- 지움(git rm) 10 파일 3,374 줄: M02_Hopyo_Finger · Ours · Option · Fit · Combine · Verdict · Line · SkLink · SkPatch · Basis
- Match: 흐름 몫(candidates · name_cands · fix_typo · clause · b_order · _logic_specs · _line_mod · units_of · _state · view · guess · picks · chain) 지움 · 이름 도구 몫 · line_key 남김
- Words: conds · cond_score · close · cosine · jacc 지움 / Shares: sections 지움(trade · eq_key 남김)
- 옮김: Finger.kind_of → Fixed.kind_of · SkLink(sk_line · kept · left · LEFT · WHY) → Sangeun · SkPatch 기계 몫(MACH_IN · MACHINE · SOLO · _mach_logics) → Units.mach_logics
- Sangeun: 옛 /sangeun 몸(build · _sure · Maker.basis · judge · plan · make) 지움 · Kids: first · proposal · order 지움 · Build: build() · unlinked() 지움(모두 고정 Builder · build_all · _order · _origins 남김)
- Router: 옛 길 지움(GET hopyo/{키}/{번} · guess · hopyo-picks · hopyo-build · hopyo-unlinked · hopyo/{키}/sangeun) · GET hopyo/{키} = Judge.view(1차 줄 판정 줄상태 · 만든 · 블록 풀이 셈)
- Marks: 칠 = Judge.judged 만 · Forget: 맞춤 캐시 지움 줄 걷음 · Ledger: Ours.version 판 칸 걷음 · Item: Kids.first 걷음
- Row(덧): 단산 줄 = 장부 짝이 있어도 늘 「2차 필요」(장부 = 1차 후보 힌트만)

시험 정리(까닭 = 지운 길 · 모듈만 시험함):
- 지움 11 파일: test_m02_hopyo_finger · fit · golden · option · ours · basis · seam · flow · draft_calc · test_635 · test_628(지문 · 순위 · 옵션 거꾸로 · 맡은 절 · 옛 확인 흐름)
- 함수 지움: /sangeun · /hopyo-build · /hopyo-unlinked · hopyo-picks · 옛 줄 맞춤 보기를 부르던 것(572 · 718_1 · 729 · 739_1 · 777_1 · 778_5 · 780_1 · labor_national · 705_3 · m02_hopyo_match · 746_1)
- test_621 S2(지문 해시) 지움 · S1 남김 · test_m02_hopyo_build = 장부 길 시험만(PUT 으로 심음)
- 새 길로: 779_2(줄상태 = 1차 판정 · 칠 셈 전 None) · grid · stmate(hopyo-judge 줄 판) · 778_1 · 778_2(Units.mach_logics) · 642_6(Marks 원문) · 642_3 장부 짝 단산 줄 2차 지킴

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 01:15:20 +09:00
eomsangdon 267b5a693f feat(stmate-test): implement full STMATE w 8.0 UI, COSTN.SMT binary parser, and 100% lossless DBF/STC engine 2026-11-04 23:45:20 +09:00
eomsangdonandClaude Opus 5.5 0ec931dcde fix(785-6): 화면 검증 흠 고침 — 장부 짝 단산 줄 · 기초 칩 갈음 · 단가산출 탭 [옮기기] · 확인 · 만든 줄 칠
- logic/Hopyo_Picks.ts: 장부 짝 줄 제안 = 고른 것 모두(기초 둘 · 입력값 그대로)
- Hopyo_Rows.ts: 단산 줄 = 판정과 상관없이 블록 풀이 칩(장부 짝 제안 있음 줄도) · 이름 없는 후보 = 키
- Hopyo_Block.ts: 기초 칩 = 키로 끄고 켬(장부 고른 것 → 블록 풀이 바인딩 · 입력 필요 다시 보임)
- Hopyo_SheetSk.ts: 단가산출 판 아래에도 [옮기기](같은 캐시) — 호표 목록 판이 숨는 탭
- logic/Hopyo_SheetMap.ts · SheetNav · Sheet: 판정 화면 확인함 · 만든 SC 호표 줄 = 초록(밖 줄 그대로) · 확인 바뀜 = 다시 칠
- 시험: test_642_6 장부 짝 · 줄 칠 · helper_642_6 칠 셈 · helper_778_5 Rows 대역

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:39:51 +09:00
eomsangdonandClaude Opus 5.5 65bafe0050 feat(785-6): 785-5 실제 길 꼴로 맞춤 — 칠 = Judge.judged · 옮기기 덮음 물음 · 목록 셈 중 다시 읽음
- M02_Hopyo_Marks.py: 블록 판정 = M02_Hopyo_Judge.judged(셈 중 None = 옛 판정)
- Hopyo_Rows.ts: [옮기기] 409(이미 옮김) = 물음 한 번 뒤 덮음
- Hopyo_SheetSk.ts: 목록 셈중 = 3 초마다 다시 읽음(판정 · 고를 것 실림)
- JudgeApi · logic/Picks: 절 몫 = 수 · 목록 셈중 · 몸 덮음
- 시험: test_642_6 Marks 부름 대역 · helper_778_5 Check 대역

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:23:26 +09:00
eomsangdon fa08640b36 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-11-04 23:20:11 +09:00
eomsangdonandClaude Opus 5.5 dcecc0b326 feat(785-6): 엑셀 보기에 줄 판 · 블록 판 · [옮기기] 이음
- Hopyo_Sheet.ts: [수정] = 시트 아래 줄 판(createJudgeRows · 그 호표 자리 뜀) · 오른쪽 판 아래 [옮기기](createJudgeMove) · 고름 캐시 한 벌 · 옮긴 뒤 목록 · 칠 · 단가산출 목록 다시 읽음
- Hopyo_SheetSk.ts: 단가산출 목록 = 새 블록 판정(hopyo-judge 목록 · 판정 · 고를 것 n · 표지 · 부름 · 만든 SK · 캐시 확인) · 줄 누름 = 그 자리 + 블록 판(createJudgeBlock)
- Hopyo_JudgeApi.ts: 목록 블록 줄 판정 · 고를수 · Block.ts 타입 흠 둘
- 낡은 시험 고침(까닭 = 785-6 이 옛 마법사 단가산출 칸 · 확인 창을 새 판으로 갈음): test_627_sheet_sk(판 바꿈 · 강조 · 뛰기를 새 목록으로 · helper_778_5 sheetSk 대역 새 꼴) · test_785_4 줄 판 자리 손잡이 · test_m02_hopyo_screen [수정] = 줄 판
- test_642_6: 엑셀 보기 이음 · 가운데 페이지 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:16:43 +09:00
eomsangdon e562818c4f Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-04 23:14:19 +09:00
eomsangdonandClaude Opus 5.5 a22af84b62 feat(785-5): 만들기 · 길 — 확인한 고른 것 → SC · SK · 장부 · 새 M02_Hopyo_Router · 판정 · 검산 · 옮기기
- 새 M02_Hopyo_Router — 옛 hopyo 길 모두 M02_Labor_Router 에서 옮김(옛 화면 785-7 까지 돎) · 새 길 hopyo-judge 목록 · 호표 · 블록 · hopyo/{키}/check · hopyo/{키}/move · main 등록 · 시동 warm · Fit.ensure 걷음 · 올림 때 엑셀 사전(Row.lexicon) · 블록 풀이 뒤 셈 · 끝내기 때 저장본 지움
- 새 M02_Hopyo_Judge — 1차 줄 판(Row) + 단산 줄 2차 풀이(Clause → Solve) · 엑셀 칸(이름 · 규격 · 단위 · 단가) · 블록 금액 · 절 몫 · 블록 풀이 파생 저장본(HP 폴더 _derived · 입력 지문 · 지문 다를 때만) · judged(folder, key) · 검산(입력값 = 엔진이 쓴 칸 모두 · 지역 · 자재지역 · 조종원직종 비움 · 맡은 절 단위 줄 · 몫과 품 대조 · 금액 = 일위대가 로직 줄과 같은 셈)
- 새 M02_Hopyo_Make — 고른 것 → SC 줄(같은 별칭 · 줄 본뜬 {호표, 차례, 행} · 수량 = 엑셀 × 몫 × 배 · 같은 기초 둘째 = 한 줄 SC 사본 · 별도계상 자재 · 이음 · 율 · 고정 · 참조) · SK 더한줄(바탕 없음 · SkUnit.adds · Sangeun.Maker.write) · 하위 먼저 · 입력 필요 · 단위 환산 못 정함 = 그 줄(블록)만 막힘 · 장부 고른 것 꼴 짝 · 이미 옮긴 것 = 덮음 없으면 409(남의 SC 덮지 않음) · 확인한 줄 몸(Lines) = Build 에서 옮김
- Build 나눔 — 모두 고정 길 · Builder · _order · _origins · build_all 그대로(B09 빌림) · 확인한 줄 몸 → Make.Lines
- SkUnit · Sangeun — SkPatch · Fit · Basis 부름 걷음(기계경비 기초 차례 SkUnit 으로 · 근거 꼬리표 = Clause.basis · 맞춤 셈 409 없앰 · 재료 고름 MO 됨) · Kids 가 _same_mark 가짐
- 시험 새 test_642_5_make(판정 · 검산 · 옮기기 · 예시 1 · 2 · 3 · 덮음 409 · 막는 길) · test_m02_hopyo_excel 마당에 새 Router 더함 · HOPYO_MAX_BYTES 자리(길 옮긴 까닭)
- 낡은 시험 고침: test_718_1 · test_729 「맞춤 셈 전 409」 뺌(맞춤 뒤 셈 안 기다림) · test_628 맞춤셈 캐시 넣기 뺌(근거 = 조항 색인) · test_729 근거 없음 꼴 · 고정 이음 유가 칸(785-3 LINKS 늘어난 까닭)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 23:13:53 +09:00
eomsangdon 8fde93b910 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-11-04 23:10:54 +09:00
eomsangdon 8775e935dc Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-04 23:09:19 +09:00
eomsangdon 73f113ba34 feat(785-4): 불러오기 화면 껍데기 — 가운데 상세 페이지(마법사) 없앰 · 왼쪽 [불러오기] → 바로 본 칸 엑셀 + 오른쪽 목록
- 새 Hopyo_Import.ts(머리 막대 층 · 안 이은 줄 배지 · [끝내기] · 떠남 지움) · Hopyo_Wizard.ts 지움
- Hopyo_Side.ts = 올림 · 층 작은 고름(둘 이상일 때만) · 작업 중 줄 · 다른 엑셀 물음
- Hopyo_Sheet.ts = 몸(buildHopyoSheet) + 모달 껍데기 · 만들기 셋 → 새 Hopyo_SheetMake.ts · [삭제] 걷음 · 찾기 · 체크 부품으로
- 흠 셋: 안 이은 줄 0 오표시(빈 호표 번) · 맞춤 셈 중 멈춤(3 초 저절로 다시) · 좁은 판 줄 짓눌림(시트 아래 줄 판 자리)
- 시험: 785-4 지킴 시험 새로 · 낡은 마법사 · 모달 [삭제] · 좁은 꼴 인자 대조를 새 파일 · 새 길로 고침(Wizard 삭제 · SheetMake 이동 · narrow 걷음)
2026-11-04 23:08:39 +09:00
eomsangdonandClaude Opus 5.5 fa50fe05d3 feat(785-6): 블록 칠 새 판정 — Marks 가 Make.judged 저장본을 읽음 · SheetMap 제안 칠
- M02_Hopyo_Marks.py: 블록 판정 = M02_Hopyo_Make.judged(folder, key) 파생 저장본(785-5) · 고를수 실음 · 저장본 없으면 옛 맞춤셈 판정(785-7 에서 걷음)
- logic/Hopyo_SheetMap.ts: 기초로 풂 · 기초+덧 = 초록 · 줄 조합 · 고를 것 n = 파랑 · 일위대가 밖 글
- test_642_6_screen.py: 블록 칠 새 판정 · Marks 옛 길 대체 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:45:49 +09:00
eomsangdonandClaude Opus 5.5 0117dbdf2e feat(785-6): 판정 화면 새 파일 — 줄 판 · 블록 판 · 고름 캐시 · 새 길 타입
- Hopyo_JudgeApi.ts: 785-5 길 다섯 타입 · 부름(hopyo-judge 목록 · 호표 · 블록 · check · move)
- logic/Hopyo_Picks.ts: 고름 캐시 꼴 · 제안 = 미리 체크 · 호표 제안 모두 확인 = 다 풀린 제안만 · 입력 필요 남은 줄 막음 · 옮기기 몸 = 확인한 것만
- Hopyo_Rows.ts: 시트 아래 줄 판 — 1차 판정 칩 · 갈래 · 후보 복수 고름 · 수량 · 단위 환산 칸 · 엑셀 행 뛰기 · [확인] · [옮기기]
- Hopyo_Block.ts: 블록 판 — 절마다 기초 후보 · 맡은 절 · 입력값 칸(출처 꼬리표 · 입력 필요 · 엇갈림 · 기본) · 줄 대조 · 블록 판정 · 금액 참고 · [검산]
- 사전 ui_template_locale_m2_judge.ts · 자리 CSS Hopyo_Judge.css
- 시험 test_642_6_screen.py + helper_642_6_picks.cjs(북부청 예시 1 · 2 · 3 실제 풀이로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:41:42 +09:00
eomsangdon b2c2bb7ad3 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-11-04 22:30:34 +09:00
eomsangdon a966ef3e91 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-04 22:28:19 +09:00
eomsangdonandClaude Opus 5.5 553c9169cd feat(785-3): 1차 줄 판정 · 엑셀 사전 · 후보 · 수량 환산 + 유가전력 MO 이음
- 새 M02_Hopyo_Row — 단가표 시트(자재단가 · 노임단가 · 기계경비총괄표 · 기계경비적용기준 · 단가산출 · 일위대가 총괄표 · STmate 목록표 코드) → 엑셀 사전(갈래 + M01 키 후보 한 번) · 세부행 4 단계(비고 번호 → 별도계상 → 단위 → 사전) · 후보 차례 장부 → M01 표마다(LB · MT · MO · EQ · FX) → M02 층 SK · SC · 판정 넷(제안 있음 · 2차 필요 · 후보 여럿 · 후보 없음) · 고른 것 꼴 · 수량 = 엑셀 × 몫 × 배
- factor 를 Row 로 옮김(Match.factor 는 부름만)
- Elem 유가 후보(oil_rows · oils · lost 유가) · Ledger 짝 고른 것 꼴 넓힘(갈래 · 키 머리 · M01 있음 · 율 비목 · 기준 재검사 · 옛 꼴 그대로)
- 고정 줄 이음 유가 MO(LINKS · GROUPS 재료 · 재료비 = MO 값) · 일위대가 자재 목록 MO 허용(Labor_Doc 두 줄 · 줄 수 그대로)
- 화면 찾아 넣기 「M01 전체」 탭 유가 줄(oil) · 일위대가 = 고정 줄 이음 유가 · 단가산출 = 더한줄 재료 요소 MO · TS GROUPS 짝
- 시험 새 test_642_3_row(북부청 415 줄 = 단산 268 · 일위 1 · 별도계상 64 · 재료 78 · 기계 3 · 율 1 · 사방 201 · 거창 68 모두 갈래 하나 · 사전 · 장부 짝 · 환산 · MO 고정 셈 · 율 TS 거울)
- 낡은 시험 고침: test_721 의 FindKind 글 대조에 oil 더함(종류 하나 늘어난 까닭)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:27:48 +09:00
eomsangdon 646eab6245 Merge remote-tracking branches 'origin/CODEX', 'origin/main_laptop_1' and 'origin/안티그래비티' into sub_laptop_1 2026-11-04 22:26:19 +09:00
eomsangdon 586fe3e163 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-11-04 22:26:16 +09:00
eomsangdon b635ee57ae Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-04 22:24:36 +09:00
eomsangdon 8715ec9206 feat: expand EST calculation coverage 2026-11-04 22:23:40 +09:00
eomsangdon 1cc9dca9fc feat(tools): STMATE 원본 DBF/SMT/STC/JB_R 직접 읽기·쓰기 브리지 서버 및 UI 연동 추가 (tools/stmate-test) 2026-11-04 22:23:34 +09:00
eomsangdonandClaude Opus 5.5 d516d09fd9 feat(785-2): Bind B2 · B3 · B4 785-1 꼴 맞춤 · Solve 풂 절 parts · 지킴 시험 test_642_2
- Bind: B2 선택값 줄 = 낱말 같음 고름(「사용횟수」 ≠ 「사용」) · B3 상수 이름 · 덧(90˚) · 단위 맞춤(sec = 초 · km → m) · 경사도 → 경사각도 · B4 식 분자 · 분모 {수|기호} + 대입
- Solve.parts: Formula.works 절 + Clause 후보 {키, 절} → 풂 절(절 표 없는 후보뿐이면 블록 통째)
- 새 test_642_2_bind_solve: 예시 1 · 2 · 3 + 단산46 · 39 입력값 · 출처 꼬리표 · 입력 필요 · 엇갈림 · 지어 넣은 값 0 · 품 같음 · defaults 규칙

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:23:07 +09:00
eomsangdonandClaude Opus 5.5 932acba909 feat(785-1): 출처 풀이 · 조항 색인 새 M02_Hopyo_Clause — 블록 근거(머리 › 몸 › 절 제목) · 같음 › 윗마디 · 절마다 나눔 · 결과단위 거름 · 준용 · 옛 번호 · 기초 없음 · 번호 없음 표지
- Basis 앞 몫(_ours · ours · clause · road_map · _grams · _sections · _named · _part · resolve · settle · has_logic · _has · no_basis) Clause 로 옮김 · Basis = 다시 내보내는 얇은 껍데기(순위 몫 candidates · merge · pick 은 785-7 까지 남김)
- Excel: settle 부름 Clause 로 · ReadEst 별칭 hopyo_basis → hopyo_est
- 새 지킴 시험 test_642_1_clause_formula(설계 642-A 5-3 첫 줄 기대 — 단산 10 · 46 · 24 · 47 · 39 후보 · 33 준용 · 6 옛 · 3 번호 없음 · 절 나무 · 상수 · 대입식 · 절 몫)
- test_628: hb = Clause(정본) · 여덟 파일 표지 수 「기초 없음」 18 → 3 · 「옛 번호」 121 → 120 — 782-1 · 782-2 · 782-3 새 M01 기초로 낡은 수(옛 Basis 로도 같은 실패 확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:21:59 +09:00
eomsangdon b0f22ba7b1 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/안티그래비티' into sub_laptop_2 2026-11-04 22:15:45 +09:00
eomsangdonandClaude Opus 5.5 bf140c29fa feat(785-2): 옵션값 읽기 Bind · 검산 판정 Solve · master_auto.defaults — 1차
- 새 M02_Hopyo_Bind: B1 규격 · 품명 괄호 · B6 같은 호표 레미콘 a-b-c · B2 선택값 줄 + 글에 없는 켜기 칸 = 아님 · B3 상수(기호표 · 이름 · 90˚ · 표 칸 = 기호) · B4 대입식(분자 · 분모 자리 + 단위) · B5 표 거꾸로 + 감률 배수 · 출처 꼬리표 · 엇갈림 · 안씀 · 입력필요 · 비움 · 기본 제안 · 엔진 몫 quantities(품만)
- 새 M02_Hopyo_Solve: 후보마다 바인딩 → 줄 짝(L · X · M · R) → 품 같음(자릿수 반올림 폭 · 0.5 %) · 절 · 블록 판정 · 덧 줄 Units · 금액 참고(입력 필요 없을 때 엔진 한 번)
- master_auto.defaults: 기본 칸 › 고르기 첫째 › 가드 0 › 0 을 품은 범위 0 › 표 첫 줄 › None(새 맞춤만 부름 · values · run_line 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:15:36 +09:00
eomsangdonandClaude Opus 5.5 02cabeccf2 feat(785-1): 단가산출 블록 글 → 절 나무 · 상수 · 대입식 · 선택값 · 절 몫(새 M02_Hopyo_Formula) · Book 단산 블록 글행 · 절 칸
- 새 M02_Hopyo_Formula — tree · head · works · line_of · factors · find(순수 · M01 · 엔진 안 부름)
- ReadEst 글행 남김 · 절 나무 칸 · Excel Book 단산 keep 에 글행 · 절
- 꼴 = PLAN 785 이음 줄(785-1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:13:41 +09:00
eomsangdon a56da7bb2b feat(tools): STMATE 계산 사슬 및 제어 옵션 독립 테스트 웹앱 추가 (tools/stmate-test) 2026-11-04 22:08:28 +09:00
eomsangdon c7d2e99a25 Merge remote-tracking branches 'origin/CODEX' and 'origin/main_laptop_1' into sub_laptop_1 2026-11-04 22:02:09 +09:00
eomsangdonandClaude Opus 5.5 2afe579e15 knowledge(요구): 요구서 642 · 설계 642-A · 분석 642-B · C · D — 엑셀 불러오기 다시 짬
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 22:00:54 +09:00
eomsangdon 9d0ebb71da feat: expand EST test workspace 2026-11-04 21:58:59 +09:00
eomsangdon 173b719826 feat: add standalone EST test app 2026-11-04 21:48:51 +09:00
eomsangdonandClaude Opus 5.5 dcc0e1b75a knowledge(요구): 요구 642 원문 — 엑셀 불러오기 분석 · 매칭 로직 다시 정의 · UI 단순화 · 밤 맡김
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 21:25:23 +09:00
eomsangdon 3070c9e193 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-04 21:24:34 +09:00
eomsangdon f82b1ff14a auto: 2026-11-04 21:15 (ESD_LAPTOP) 2026-11-04 21:15:38 +09:00
eomsangdonandClaude Opus 5.5 d90df58f94 knowledge(요구): 요구 641 원문 — 전체 중지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:43:49 +09:00
eomsangdonandClaude Opus 5.5 a8ed3390e9 feat(782-2): 건설 공통 1장 나머지 기초 — 계수 표 33 · 환경관리비 · 공사용수 로직 · 성격 칸 · check_master 목차 대조
- 계수_건설품셈_01장_적용기준.json 덧(CC000495~527): 규칙 · 계수만 절(1-2-2 · 1-2-3 · 1-2-4 · 1-2-5 · 1-2-6 · 1-2-8 · 1-3-2 · 1-4-1 · 1-4-2 · 1-5-1~10)마다 「규칙 글」 표(원문 글 그대로) + 수 표(단위 · 소수 표준 · 금액 단위 · 체적 변화율 L · C · 체적환산계수 f · 파쇄된 돌 10∼40% · 잡재료 2∼5% · 작업반장 · 공구손료 3% · 시공단위 품 소수자리 · 품 산정 식 · 예시 · 건설폐기물 발생량 · 공사용수) · 1-2-9 소규모 적용시공량 · 다수 규격 예시 = 계수로만
- 표 칸 「성격」 — 기존 1-3 · 1-4 표 33 + 1-2-9 둘(같은 성격 묶음 · 1-2-9 ↔ 1-4-6 작업시간 제한 한 성격) · 기존 표의 다른 글자 그대로
- 로직_건설품셈_01장_적용기준.json 덧: GC001192 건설폐기물 발생량(1-5-4 · 면적 × TON/㎡ × 폐콘크리트 증가율) · GC001193 공사용수(1-5-6 · 작업량 × 단위용수 × 잡용수율) · 원문 「-」 칸 = 줄 없음(멈춤)
- 1-2-9 소규모 · 1-4 품의 할증 로직 = 만들지 않음(PLAN 783-1)
- 빌더 덧 build_로직_건설품셈_01장_적용기준.py 나머지(+ _적용기준_나머지_표 · _적용기준_나머지_로직) · 키 = master_keys.issue(adopt 는 로직을 받지 않음)
- check_master 「목차」(check_master_toc.py) — 공통 1장 원문 절 목록 ↔ 마스터 전수 · 통째 빠진 절 · 로직 없는 품·표·식 절 · 1-2-9 · 1-4-2 = 783-1 로 미룸 표지
- 지킴 시험 test_782_2_common_ch1.py · test_782_1_carry_127.py 로직 줄 목록 = 앞 셋만 봄(같은 파일에 1-5 로직이 붙어 낡음)
- check_master 전후 같음(+ 목차 0) · 기존 로직 1513 셈 값 전후 같음 · 계산전부 1515

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:42:38 +09:00
eomsangdon 83270acdbc Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-04 20:41:37 +09:00
eomsangdonandClaude Opus 5.5 57af9be22f fix(784-1): 「우리 품」 잘못 보임 뿌리 고침 — 못 맞춤 빈칸 · 가드 칸 자동값 0 · 수 칸 짝 · 자재 단위 배 1 · 절 하나 덧 없음 · 엑셀 값 표지
- A 옵션 찾기 못 맞춤(수준 9 · 거리 ≥ 1) = 우리 품 · 입력 안 냄 · 「못맞춤」 표지 · 꼬리표 「→ –(우리 셈 조건 못 찾음)」
- B 자동값 「만약(X > 0, X, 기본)」 칸 = 나눗수 퍼뜨림에서 뺌(0 = 원문 기본) · 옵션 후보엔 옛 1 도 둠
- C 하나씩 조합 — 후보 ≤ 4 수 칸도 둘씩 짝
- D 엑셀이 값으로 넣은 「0 = 식」 칸 출처 「엑셀 값 · 식 아님」 · 꼬리표 「원문 식이면 품 · 금액(조건)」
- E 결과단위 「자재」 = 배 1 · 공/㎥ ≡ 공㎥
- F 품 비교 우리 품 × 대상 몫
- G 절 하나 블록 = 덧 로직은 후보만(이중 계상 막음)
- H 1위 옵션 수준 ≤ 2 면 그 입력을 대상 입력값으로
- I 규격 「L=…cm」 를 거리 칸에 안 넣음 · 수 고르기는 제 단위 붙은 수만 · 엑셀 수 「1 ㎡」 를 m 로 안 읽음
- 우리지문 · 맞춤 캐시 판 올림(FP 2 · CALC 3)
- 새 지킴 시험 test_635_hopyo_ours_qty
- 낡은 시험 둘(test_628 표지 수 18 · test_621 S1 울진소광 해시)은 HEAD 에서도 실패 — 782-3 마스터 합침 뒤 값 · 손대지 않음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:41:09 +09:00
eomsangdonandClaude Opus 5.5 a2b6e11661 knowledge(요구): 분석 639 — 견본 자동값 1 이 셈 길 한가운데(run_line) · 견본 ↔ 기본값 나눔 정책
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:40:20 +09:00
eomsangdonandClaude Opus 5.5 523a83ade3 knowledge(요구): 요구 640 원문 — 제1호표 합판거푸집 재료비 · 노무비 차 원인 분석(수정은 나중)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:30:01 +09:00
eomsangdon b753fbd5e7 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-04 20:17:47 +09:00
eomsangdonandClaude Sonnet 5.5 0b81a6c072 fix(784-2): 단위 환산 칸 — 몫 1 = 몫 없음 · 칸 한 덩이로 묶음 · 화면 확인 뒤 다듬음
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:17:12 +09:00
eomsangdonandClaude Opus 5.5 ef9fb96bb1 knowledge(요구): 분석 638 — 철근 현장조립 두 키 = 가공 + 조립 원문 두 절 · 조립 옵션을 블록 통째로 맞춤 흠
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:13:21 +09:00
eomsangdonandClaude Opus 5.5 6ffa353fea knowledge(요구): 요구 639 원문 — 값에 전부 1 을 넣는 게 문제
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:12:08 +09:00
eomsangdonandClaude Opus 5.5 8ddb5bbda7 fix(782-3): 8-2 시공능력 로직 「0 이면 기본」 가드 칸 — 견본 자동값 1 로도 엉뚱한 값 안 나오게(분석 635)
- 가드 칸에 범위 [0, 원문 끝] — 견본 자동값 = 0(원문 기본): E입력 [0, 1](원문 E 표 모두 1 이하) · 굴착기 싸이클입력 [0, 30](원문 3 표 13∼30초) · 디젤 해머 α입력 [0, 4.0] · 워터제트 α입력 [0, 1.20] · 펌프준설 q입력 [0, 387] · 그래브 f입력 · k입력 [0, 1.00] · 콘크리트 운반 배출 [0, 4] · 대기 [0, 10] · 하역 [0, 2]
- 범위 없는 가드 칸(트랜처 전진 · 후진거리 · 로더 주행거리 · 스테이빌라이저 혼합횟수 · 플랜트싸이클)은 1 이어도 기본의 3배 안 — 그대로
- 압쇄기 · 골재세척 q입력 뺌(원문 q 한 값 · 1 이면 60배 어긋남) · 스테이빌라이저 유효혼합폭 최대(로터폭 − 0.4, 0)(자동값 음수 막음)
- 유압 파일 해머 강관 Ta 표 「16이하」 ↔ β 표 「16 이하」 글자 맞춤(16m 이하 줄 못 찾던 흠) · 그래브 N값 고르기 「해당 없음」 끝으로(첫 값 = 표 줄)
- 지킴 시험 test_782_3 가드 칸 자동값 덧 · 기존 로직 · 표 · 키 그대로(로직_08장 새 줄만 바뀜) · check_master 전후 같음 · 계산전부 1513 멈춤 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:09:23 +09:00
eomsangdonandClaude Sonnet 5.5 6e24b50c94 feat(784-2): 확인 창 배 칸 → 단위 환산 칸 — 같으면 숨김 · 몫 줄 몫만 · 빈칸 추정만 입력 · 장부 같은 꼴
- 새 logic/M02_MasterTemplete_Hopyo_Unit.ts: unitView(숨김 판정 · 두 단위 · 추정 두께)
- CheckRows: 「단위 같음 ×1」 · 「엑셀 1 ㎡ = 우리 [ ] 본」 · 「두께 0.2m 로 추정」 · Ledger 같은 입력 꼴
- 지킴 시험 test_단위_환산_칸_보이기_784_2 + helper_m02_hopyo_unit.cjs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 20:07:50 +09:00
eomsangdon 53e2c22df6 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-11-04 20:06:04 +09:00
eomsangdon f9cfbef5e7 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-04 20:03:49 +09:00
eomsangdon 2c53f46fbf Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_1 2026-11-04 20:03:12 +09:00
eomsangdonandClaude Opus 5.5 4e3edf5eb6 knowledge(요구): 분석 635 — 우리 품 잘못 보임 뿌리 · 같은 흠 전수 · 배수 칸
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:02:40 +09:00
eomsangdonandClaude Opus 5.5 d389bca29b feat(782-3): 건설 공통 8-1-4 · 8-2 시공능력 기초 — 계수 표 146 · 로직 48 · 기계설비 13-2-7 로직 1
- 계수_건설품셈_08장_건설기계.json 덧(CC000349~494): 8-2-2 ~ 8-2-33 절마다 원문 md 표 전부 + 표 밖 글 속 상수(산정식 상수) — 기존 표 글자 그대로 · 절 차례 자리에 끼움 · 범위 값 = [아래, 위] · 로직이 가운데를 집는 표(죠 크러셔 · 콘크리트 운반 상수)는 아래 · 위 두 칸
- 로직_건설품셈_08장_건설기계.json 덧(GC001143~1190): 8-1-4 기본식 · 8-2 시간당 작업량 · 1본당 시공시간 40 · 수중펌프 설치 해체 품 1 · 작업량당 기계경비 6(시간당 경비 × 1/Q — 굴착기 · 불도저 · 로더 · 덤프트럭 · 롤러 · 대형브레이커)
- 로직_건설품셈_13장_플랜트설비공사.json 덧(GC001191): 13-2-7 아세틸렌량의 환산
- 정함: 원문 범위 값 = 0 이면 가운데 기본 · 입력으로 바꿈 · 조건 = 고르기 · 짐작 값 없음 · 기존 브레이커 능력 표(CC000065 · 067)는 범위 한 칸이라 가운데 자동 안 됨 — 작업능력 입력
- 새 빌더 build_로직_건설품셈_08장_건설기계.py(+ _건설기계_읽기 · 표 · 표2 · 표3 · 로직 · 로직2 · 로직3 · 로직_플랜트 · 로직_준설 · 로직_경비) · 키 = master_keys.issue(adopt 는 로직을 받지 않음)
- 지킴 시험 test_782_3_capacity_82.py — 실무 엑셀 Q 재현 굴착기 12 · 덤프트럭 4 · 롤러 · 임목파쇄 0.5% 안 · 영덕계류 셋 = 엑셀이 0.6∼0.8㎥ 줄 싸이클 씀 · check_master
- check_master 전후 같음(틀 · 본문 · 로직 · 조합 0 · 단위 27 · 배 7) · 계산전부 1464 → 1513 멈춤 0 · 기존 로직 1464 셈 값 전후 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:57:27 +09:00
eomsangdonandClaude Opus 5.5 a09c2f6b7f knowledge(요구): 요구 638 원문 — 소규모 · 할증 B09 에서 더함 · 예시 2 제1호표 철근 현장조립 두 키
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:51:04 +09:00
eomsangdonandClaude Sonnet 5.5 a3b27ef5f5 fix(782-1): 불러오기 끝내기 확인창 인자 자리 · 로직 편집 소유 선택지(현장·공용)
- 끝내기: 안 옮긴 단가산출 글을 본문 끝에 붙임 · 단추 글 자리 복구
- 소유: 서버 값(현장·공용)과 맞춤
- 시험: test_782_1_small_flaws

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:41:11 +09:00
eomsangdonandClaude Opus 5.5 000e79f431 knowledge(요구): 제안 634 — 소규모 · 할증 프로젝트 조정(제안만)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:39:25 +09:00
eomsangdonandClaude Opus 5.5 7176043804 knowledge(요구): 요구 637 원문 — 품 비교 흠 하나만 보지 말고 관련 로직 전수
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:38:04 +09:00
eomsangdonandClaude Opus 5.5 3620eaa8e4 knowledge(요구): 요구 636 원문 — 품 비교 열 0.5476 > 25 흠
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:36:26 +09:00
eomsangdonandClaude Opus 5.5 6658c9cd25 knowledge(요구): 요구 635 원문 — 야면석 소운반 우리 단가 ≠ 엑셀 · 오른쪽 판 수정 배수 칸
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:33:54 +09:00
eomsangdonandClaude Opus 5.5 103ecadcc2 knowledge(요구): 요구 634 원문 — 1-2-9 소규모 · 1-4-2 할증 = 프로젝트에서 고름 · M01 기초는 만들되 M01 · M02 넣을 때 경고 · 제안만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:23:20 +09:00
eomsangdonandClaude Opus 5.5 928bcecd49 knowledge(계획): 계획 이관 781 — 예시 1 야면석 소운반 출처 책 · 부문 가림 · 근거 1-2-7
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:21:46 +09:00
eomsangdonandClaude Sonnet 5.5 02380bb7d1 fix(781-2): 예시 1 근거 = 건설 공통 1-2-7 인력운반(지게) — 조항 같음 덤 · 조항 점수 · 근거 칩 툴팁
- Fit: 인용 조항 같음 로직에 이름 1위와 같은 덤(fuse) · 조항 가중 1.5 → 2.0 — 북부청 임도 단산10 1위 GF000214 야면석 채집 → GC001140 인력운반(지게) · 골든 1위 162 → 163(사람 120 · 새 43) · 다른 점 틀림 0 → 1(N006 정답표가 짐작 GF000280 인 줄 — 새 로직이 맞는 근거)
- 화면: 근거 칩 툴팁 로직 null 일 때 「null · 기초 없음」 → 「기초 없음」
- test_628: 예시1 시험을 새 뜻(근거 GC001140 · 기초 없음 표지 없음)으로 · 표지 수 19 → 18(공통 1-2-7 인용 블록은 여덟 파일 중 단산10 하나) · 우리 지문표 없으면 만들고 시작

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 19:06:36 +09:00
eomsangdon 9f86977293 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-04 18:46:36 +09:00
eomsangdon e43d8e27ca Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-04 18:44:51 +09:00
eomsangdonandClaude Opus 5.5 187fda1055 feat(782-1): 건설 공통 1-2-7 운반 기초 — 계수 표 여덟 · 인력운반 로직 셋
- 계수_건설품셈_01장_적용기준.json 1-2-7 표 덧(CC000341~348): 소운반 거리 · 인력운반 기본공식 상수 · 지게운반 t · V · 벽돌운반 · 인력운반(기계설비) 왕복평균속도 · 준비작업시간 · 경사지 환산계수 α · 화물자동차 적재량 — 옛 표 목록이 1-2-5 밑에 붙였던 1-2-7 표 바로잡음
- 새 로직_건설품셈_01장_적용기준.json(차례 13 · 공통 2장 앞): GC001140 인력운반(지게) · GC001141 인력운반(기계설비) · GC001142 벽돌운반
- 지게 = N = V × T ÷ (120 × α × L + V × t) 또는 운반회수 직접 · 보통인부 = 무게 ÷ 1회 운반량 ÷ N · 무게 0 = 1-2-4 3 단위중량 표
- 새 빌더 build_로직_건설품셈_01장_적용기준.py · 지킴 시험 test_782_1_carry_127.py(재현 넷 0.547619 · 0.163556 · 0.654222 · 0.0132 · check_master)
- 기존 표 · 로직 글자 그대로 · check_master 전후 같음 · 계산전부 1461 → 1464 멈춤 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:44:25 +09:00
eomsangdonandClaude Opus 5.5 a8acaed6f5 feat(781-1 · 781-2): 출처 책 · 부문 · 옛 책 가림 · 「근거 조항 우리 기초에 없음」 표지(요구 628 · 629 · 630)
- 책 낱말 표(ReadEst.book_of) — 임도 · 사방 = 옛 책 「(옛)」 · 공통 · 토목 · 건축 · 기계설비 · 유지관리 = 「건설품셈 <부문>」 · 그 밖 건설품셈(부문 모름) 그대로
- 출처 풀이(Basis.settle · Excel.read_book 끝 한 곳 · 두 형식) — 건설품셈(부문 모름) 번호 → 블록 이름(안 맞으면 글 두 묶음)이 원문 제목 · 같은 번호 로직 이름과 뜻 낱말을 나누는 부문 하나 = 그 부문 · 둘 이상 = 모름 · 없음 = (옛) · 임도 옛 번호 → 지식DB 대응표 확정 한 대응(가 · 나 항 = 현행 윗마디 · 일대다 안 옮김)
- 조항 견줌(Basis.clause) 한 줄 — 책 또는 구분 같음 · (옛) = 없음
- 기초 없음 표지(Basis.no_basis) — 출처 조항(윗 · 아랫마디 포함) 로직 0 = 「근거 조항 우리 기초에 없음 — 건설품셈 공통 1-2-7 운반」 · 옛 번호 = 「옛 번호 — 대응 미확인」 · 맞춤셈 꼬리표 · 맞음 아닌 후보 글 「참고 후보 — 근거 조항 아님」 · 단가산출 근거 꼬리표 1위 대신 표지(1위 맞음이면 1위)
- 시험: 새 test_628 · test_621 단계 해시 다시 굳힘(달라진 칸 = 출처뿐 · S1 수 · 그래프 그대로) · test_m02_hopyo_basis · finger · stmate · excel 출처 기대값 새 책 꼴로(낡은 기대 「임도 = 산림품셈」 · 「건설품셈」 부문 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:42:07 +09:00
eomsangdonandClaude Opus 5.5 83860e7ac4 knowledge(요구): 대조 632 — 건설품셈 목차 ↔ 우리 구분 · 계산 대상인데 없음 65 절
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:31:40 +09:00
eomsangdonandClaude Opus 5.5 d65244b9a5 knowledge(요구): 요구 633 원문 — 기초 없는데 어떻게 로직을 고치나(순서 물음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:29:17 +09:00
eomsangdonandClaude Opus 5.5 2999a66a9d knowledge(계획): 계획 이관 780 — 단가산출 탭 오른쪽 목록 · 일위대가 밖 단가산출
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:26:59 +09:00
eomsangdonandClaude Opus 5.5 52725e4165 knowledge(요구): 요구 632 원문 — 품셈 목차 ↔ 우리 구분 비교 · 1-2-2 부터 전체 = 기초 확보 대상 · 앞은 가이드
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:19:44 +09:00
eomsangdonandClaude Opus 5.5 226ee92ff7 knowledge(요구): 요구 631 원문 — 건설품셈 원문 공통부문 1장 있나 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:15:06 +09:00
eomsangdonandClaude Opus 5.5 b616541df6 knowledge(요구): 분석 628 — 야면석 소운반 1-2-7 · 출처 책 · 부문 가림 · 기초 없음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:14:16 +09:00
eomsangdon 68e35a5fa8 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1 2026-11-04 18:12:47 +09:00
eomsangdonandClaude Opus 5.5 9018227d71 fix(780-2): 단가산출 목록 판 — 목록만 굴림(거르기 · 옮기기 막대 늘 보임) · 좁은 화면(900 이하) 쌓임 유지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:12:15 +09:00
eomsangdonandClaude Opus 5.5 75db009175 feat(780-1): 일위대가 밖 단가산출도 판정 · 확인 · 칠 — 모든 블록 · 부름 칸 · 빈 블록 「줄 없음」(요구 627)
- Sangeun: 판정 · 확인 몸 = 모든 블록(북부청 임도 49 → 95) · 줄마다 `부름 [호표 번]`([] = 일위대가 밖) ·
  밖 블록 = 이름 · 단위 블록 것 · 근거 없음 · 없는 번만 404 · 하위 밖 블록(울진소광 492 → 83)은 확인 목록에 함께 = 하위 먼저
- SkUnit: 줄 0 블록 = 판정 「줄 없음」 고를줄 1 → 확인해도 못만듦(빈 SK 없음)
- Marks: 블록마다 `부름` · `줄` · `어긋남` = 모든 블록(밖 포함) · 맞춤셈 `판정` 밖 = None 그대로
- 시험 고침(낡은 기대 = 일위대가 안 블록만): 778-5 units 49 → 95(안 49 따로) · 779-2 어긋남 + 밖 4 · 44 · 48 · 77 · 78 ·
  779-5 줄 칠 모든 블록 · 778-5 excel misses 밖 블록 5 · 새 시험 test_780_1_sangeun_outside(밖 확인 · 빈 블록 · 울진소광 · 여덟 파일 459)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:08:50 +09:00
eomsangdonandClaude Opus 5.5 5e5f5d4b8c knowledge(요구): 분석 629 — 건설품셈 공통부문 단가산출 기초 내재화 = 1장 · 8-2 누락
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:06:56 +09:00
eomsangdonandClaude Opus 5.5 8a5689c400 feat(780-2): 엑셀 보기 단가산출 탭 오른쪽 = 단가산출 목록 — 판 바꾸기 · 거르기 · 목록 ↔ 시트 · 두 벌 맞춤(요구 627)
- 새 Hopyo_SheetSk: 탭(역할) 따라 오른쪽 판 바꿈 · 일위대가표 = 호표 목록 그대로 · 단가산출표 · 상세 = 마법사 단가산출 칸 좁은 꼴 한 벌(처음 올 때 지음 · 다른 층 · 권한 없음 = 보기만) · 시트 칸 → 목록 강조 · 목록 줄 → 지금 탭 블록 자리
- WizardSk: 좁은 꼴 옵션 · 부름 칸(일위대가 n 곳 · 일위대가 밖) · 거르기(전체 · 안 · 밖 · 고를 줄 있음) · 같은 캐시 두 벌 맞춤 알림 · 옮김 뒤 HOPYO_BUILT(열린 엑셀 보기 칠 다시)
- SkPick: 줄 0 블록 = 「줄 없음」 확인 막음 · 거르기 · 끝내기 글 재료에 일위대가 밖 수
- SheetNav: role · blockAt · spotOf 내보냄 · SheetMap: 판정 없음 글 → 「일위대가 밖」
- Wizard [끝내기]: 막지 않음 · 물음창에 안 옮긴 단가산출 n(일위대가 밖 m)
- 시험: 새 test_627_sheet_sk(판 바꿈 · 목록 지음 · 줄 0 막음 · 캐시 공유) · test_778_5 끝내기 재료 꼴에 밖 칸 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:01:06 +09:00
eomsangdonandClaude Opus 5.5 00d28be647 knowledge(요구): 분석 627 — 단가산출 탭 오른쪽 목록 · 일위대가 밖 단가산출
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:53:19 +09:00
eomsangdonandClaude Opus 5.5 34288c1980 knowledge(요구): 요구 630 원문 — 원문 절 범위 = 다음 같은 단계 머리 앞까지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:46:11 +09:00
eomsangdonandClaude Opus 5.5 3bf79b657f knowledge(요구): 요구 629 원문 — 건설품셈 공통부문 단가산출 기초 내재화 누락 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:44:08 +09:00
eomsangdonandClaude Opus 5.5 c81533eef4 knowledge(요구): 요구 628 원문 — 예시 1 제2호표 야면석 소운반 · 건설품셈 1-2-7 · 기초 로직 없음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:42:43 +09:00
eomsangdonandClaude Opus 5.5 850d2231c8 knowledge(요구): 요구 627 원문 — 사용자가 엑셀 보며 하나씩 맞춤 · 단가산출표 · 상세 탭 = 단가산출 오른쪽 목록 · 일위대가 밖 단가산출도 잇기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:33:48 +09:00
eomsangdonandClaude Opus 5.5 f2b49675a0 knowledge(요구): 요구 626 원문 — 4173 도 빌드
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:22:17 +09:00
eomsangdonandClaude Opus 5.5 9f6b7de26f knowledge(요구): 요구 625 원문 — 전역 깃 싱크
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 15:20:02 +09:00
eomsangdonandClaude Opus 5.5 a556a8a346 knowledge(계획): 계획 이관 776 ~ 779 — 경쟁사 출력 불러오기 단계 체계 · 호표 참조 · 줄 단위 단가산출 · 엑셀 세 보기 · 하이라이트
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:53:19 +09:00
eomsangdonandClaude Sonnet 5.5 838acdd8c1 refactor(778-5 D-3 덧): 안 이은 줄 창 기초 고르기 · SK 만들기 걷고 [단가산출 걸음에서 확인 ▸] 단추로 교체
- Hopyo_Left: 단산 줄 기초 select · makeSk 걷음 → 단추 하나(onSk — 마법사 단가산출 칸 그 블록 펼침 · 화면 가운데로)
- Hopyo_WizardSk: SkStep.show(번) 손잡이 추가 · Hopyo_Wizard: openLeft 에 onSk 연결
- Hopyo_Api: 옛 몸(`바탕`) makeSangeun · SangeunPick 걷음(서버 새 몸은 Hopyo_SkApi)
- locale: M02_LeftMakeSk · M02_LeftSkNone 걷고 M02_LeftSkGo 추가
- test_721_find_pick: 낡은 단언 `[views.root, addButton]` → 778-4 율 줄 추가 단추 포함 꼴
- test_746_2: 걷힌 기초 후보 필터 단언 → 걷음 단언 · test_729_3: 새 단추 단언 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 14:52:02 +09:00
eomsangdonandClaude Opus 5.5 3ffeff84c1 feat(778-5 D-5): 마법사 단가산출 줄 고름 — 제안 · 블록 [확인] 뒤 확인한 것만 옮김(요구 621 · 622 · 623 · 624)
- 단가산출 칸 = 서버 줄 판정(빈 확인) 블록 표: 판정 · 우리 금액 · 엑셀 금액(참고) · 근거 꼬리표 · 만든 SK · [확인] 칩(고를 줄 0 일 때만) · [줄] 펼침 · [보기]
- 줄 표(새 Hopyo_SkLines) = 묶음 · 이름 · 규격 · 매개값 · 상태 칩(엑셀 칠과 한 규칙 unitTone) · 꼬리표 · 방법 · 금액 · 차 · 고름(후보 · 찾기 · 율 기준 · 뺌 + 까닭 · 지움) · 엑셀 보기 뛰기
- 고름 · 확인 = 캐시 m02-hopyo-sk · [확인한 것 옮기기] = 몸 {확인: [{번, 고름}]} · 서버가 다시 셈 · 자동 확인 · 「모두 확인」 없음
- 순수 꼴 = 새 logic/Hopyo_SkPick · 길 = 새 Hopyo_SkApi(Hopyo_Api 안 늘림)
- 마법사 [끝내기] 막힘 글 앞에 「단가산출 확인 안 한 블록 n · 고를 줄 m」(Hopyo_Wizard 한 곳)
- 걷음: 옛 고름 대기 기초 고름 · 줄 비교(Compare.compareView · compareBrief · Api SangeunCompare · CompareLook · CSS) — 쓰는 곳 없음 · 못옮김 창 잇기 만들기(옛 바탕 몸 = 서버가 걷어 422) → 보기만 + 줄 찾기 단추 lineFind 로 다시 씀 · 안 쓰는 문구 39
- 낡은 시험 고침: test_746_2(줄 비교 · 못옮김 요소 단추 시험 걷음 — 기능 걷음) · test_729_3(candLabel · 옛 잇기 몸 기대값 걷음) · test_m02_hopyo_screen(sangeunStep 인자) · 새 test_778_5_sk_pick_screen + helper_778_5_sk_pick.cjs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:41:48 +09:00
eomsangdon 8380fa7334 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
# Conflicts:
#	resources/tester/test_779_2_hopyo_marks.py
2026-11-04 14:17:12 +09:00
eomsangdonandClaude Opus 5.5 4cdb41f80b feat(778-5): 블록 = 줄 판정 모음 · 확인한 블록만 단위 SK(바탕 없음 · 근거 꼬리표) · 확인 목록 받는 길(D-3 · 요구 623 · 624)
- 새 M02_Hopyo_SkUnit: 줄 판정(Units 잇기 + 매개값 · 사용자 고름 {차례: 노임|기계|자재|기준|뺌} · 꼬리표 「내가 고름」) · 단가 = M01 지금 값으로 다시 셈(우리금액) · 엑셀 금액 차 = 참고 · 블록 판정 「옮길 수 있음」 | 「고를 줄 n」 · 단위 SK 더한줄 = 인력 · 기계경비 로직 줄 · 재료(손료율 든 수량 · 별도계상) · 율 줄(778-4 꼴 · 줄 목록 = 그 행 add:#k) · 하위 참조 · 값 고정 줄 · 저장 꼴로 다듬어 같은 내용 SK 지문 같음
- Sangeun: 기초 · SK · 고름 대기 갈림(SkPatch.plan) 걷고 단위 SK 하나 · 확인 [{번, 고름?}] 에 든 블록만 만듦 · 하위 단산 먼저 · 고를 줄 남으면 못만듦 · 일위대가 만들기가 부르는 Maker.plan(번) = 「확인 전」 고름 대기(자동 없음) · 별칭 제안 걷음(바탕 기초 없음)
- Router: sangeun 몸 바탕 → 확인(줄 수 그대로)
- 시험 새 뜻: test_729(기초 · SK · 고름 대기 · 계수 · 못옮김 잇기 · 729-5 차이 SK 바탕 길 = 바탕 없는 단위 SK 로 사라짐 → 확인 블록 단위 SK · 줄 고름 · 뺌으로 갈음) · test_718_1 · test_572 · test_779_2 · test_m02_hopyo_flow(확인 목록을 보내야 SK 생김 · 같은 파일 안 같은 내용 단위 SK 없을 수 있음 · 바탕 없는 SK = SC 에 참조 그대로)
- 새 test_778_5: 작업반장 = 건설업 · 규격 차례 무관 · 글자 규격 = 후보 · 손료율 · 설치비 · 제경비 율 줄 · 여덟 파일 줄 연결 표 · 확인 안 한 블록 안 만듦 · 고름 · 400 · 404 · 다시 올림 같은 SK 재사용 · 일위대가 확인 전 못 이음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:16:12 +09:00
eomsangdonandClaude Opus 5.5 68401ed02f feat(779-5): 블록 안 줄 칠 — Marks 블록 덧칸 줄 [[출처 행, 상태, 차]](D-2 줄 잇기 상태)
- 상태 = M02_Hopyo_Units.line(이어짐 · 고를 것 · 못 이음) · 셈 밖 = 밖 · 불리는 블록만
- 차 = 이어진 노무 줄의 엑셀 노임 ≠ M01 지금 노임(주황 「엑셀과 다름」 = 금액 참고 차만) · 기계 · 자재 참고 차는 D-3 셈 뒤
- SheetMap 상태 이름 「고를 것」 맞춤 · 실물 시험(북부청 임도 231 · 영월 135 줄 칠 = 단위 줄 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:58:28 +09:00
eomsangdon 1abe1673d7 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-04 13:44:17 +09:00
eomsangdonandClaude Opus 5.5 215cc1531d feat(778-5): 줄 잇기 한 벌 — 단위 줄 → 우리 계산 단위 + 상태(이어짐 · 고를 것 · 못 이음) · 일위대가 자재 맞춤 같은 함수(D-2 · 요구 622 · 623 · 624)
- 새 M02_Hopyo_Units: 노무 LB(같은 이름 여럿 = 건설업 먼저 · 작업반장) · 기계 EQ(Fixed.Linker) → 기계경비 기초 로직 줄(778-2) · 없는 규격 = 가까운 후보만 · 자재 MT(별칭 장부 › 이름 · 규격 › 바탕 이름 · 숫자 규격 · 이름에 붙은 규격 · 글자 규격 = 고를 것) · 손료율 방법 · 율 778-4 꼴(기준꼴 → 묶음 · 비목 · 줄 목록) · 하위 · 값
- Material: 숫자 규격 치수 차례 · 구분자 무관 견줌(dims) · 글자 규격 판별(lettered) · 단위 없는 줄(EST 블록) = 단위 안 봄
- Line._material = Units.material — 일위대가 자재 체크 제안 147 → 146(봉화 광명단조합페인트 「1종2류(철재)」 = 글자 규격 → 후보 · 요구 624-2)
- 여덟 파일 불리는 블록 줄 842: 노무 366/366 · 기계 180/190 · 자재 71/130 · 율 150/152 · 하위 3/3 · 값 1/1 · 줄 다 이어진 블록 156/206

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:43:51 +09:00
eomsangdonandClaude Opus 5.5 2260227b5d feat(779-5): 블록 안 줄 칠 순수 셈 — 블록 줄 [[행, 상태, 차]] → 출처 행만 · 머리 행 = 가장 나쁜 줄
- SheetMap `unitTone`(이어짐 초록 · 금액 참고 차 = 주황 · 후보 파랑 · 못 이음 빨강 · 밖 흐림) · 줄 재료 없으면 옛 블록 칠
- 서버 줄 상태(D-2 뒤 Marks 덧칸)는 다음 커밋
- 시험 새 test_779_5_block_line_tone

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:35:28 +09:00
eomsangdonandClaude Opus 5.5 4231902d57 feat(778-5 D-6): 엑셀 셈 어긋남 띠 — 불리는 블록 재현 못 한 줄 · 덮음 실패 · 금액 차 · 까닭
- Marks 블록 덧칸 `어긋남 {줄합, 금액, 차, 덮음, 줄 [{행, 이름, 묶음, 셈, 금액, 차, 까닭}], 까닭}` · 읽기만
- SheetMap `missesOf` = 상세 시트 행 차례(덮음 실패 = 블록 머리 행 · 재현 못 한 줄 = 그 줄 행) · 칠 안 바꿈
- SheetNav 머리 막대 아래 띠(부품 createBanner) 「엑셀 셈 어긋남 n」 + [보기 i/m] 차례로 뛰기 · 그 행 까닭은 막대 글
- 북부청 임도 = 2 · 33(엑셀 합이 진동로울러 노무 뺌 · 차 2,637.1 · 3,418.7) · 64 · 95(글 꼴 밖 줄) · 81 맞음 · 영월 0
- 시험 새 test_778_5_hopyo_excel_misses · test_779_2 어긋남 한 줄 · helper_779_4 missesOf

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:34:02 +09:00
eomsangdon bc29bf6964 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-04 13:21:19 +09:00
eomsangdonandClaude Opus 5.5 022d6375e1 refactor(776-3): 지문 형식 모름 — 단위 줄 투영 한 벌 · 몫 한 벌 · 블록 단가 = 항목 · clause 대체 몸 · 오타 정규식 걷음 · 맞춤 캐시 = HP 지문 칸
- Finger — 풀이 두 벌(_text · _stmate) 걷고 블록 단위 줄 투영 하나(_units) · 글은 조건 · 옛 판 · 계수 · 「…의 N %」 만 · 셈 밖 줄 안 씀
- Shares — 몫 = 단위 줄을 절(제목 · 윗절)로 묶음 한 벌 · 식 다시 풀기(qty_of · sections_stm) 걷음 · trade = ReadEst.trade
- Elem.prices = 단위 줄 단가(글 다시 안 풂 · machines 갈래 안 건드림)
- Match.clause = Basis.clause 만 · 대체 몸 · _TYPO_CLAUSE 걷음(오타는 읽기 ReadEst.sources) · fix_typo = 사본(Line 이 부름)
- Fit.cache_path — HP 지문 칸(올린 엑셀 sha256 + 판) · 파일 그대로면 다시 안 읽음(lru) · 붙여넣기 HP 만 파일 해시
- 지문 전후 = 셈 단위 275 중 150 같음 · 다름 = 장비 절 · 글(쓰는 곳 없음) 100 · 고침(STmate 기계 두 묶음 · 거창 자재 · 끝 점 직종 · 옛 몫 금액 ÷ 단가 엉터리 · 제외금액 줄) — 골든 1위 줄마다 · 불리는 블록 206 갈래 · 1위 · 상태 전후 같음(162/177 · 기초 80 · SK 37 · 대기 89)
- S1 다시 굳힘 = 봉화 · 울진대흥 · 울진소광 자원(같은 코드 두 묶음 따로) · S2 다시 굳힘 125/275(위 까닭)
- 시험 고침 — finger(블록 = ReadEst.items · STmate 단위 줄 · 셈 밖) · fit 옛 HP(항목 · 글 없음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:20:17 +09:00
eomsangdonandClaude Opus 5.5 660e3318a9 feat(778-3): 단위 줄 정규화 — 단가산출 블록 · 일위대가 줄 = 노무 · 기계 · 자재 · 율 · 하위 · 값 줄(D-1 · 요구 622 · 623 · 624)
- 새 M02_Hopyo_UnitLine — 단위 줄 꼴 · 자체 검산(줄 재현 0.5 % · 줄합 = 블록 금액 덮음) · 값 줄 → 하위 가름 · 율 줄 기준꼴 · 기준줄 · 할증 풀이 · 일위대가 줄 투영(묶음 · 재현 · % 기준) · 두 어댑터 끝에 settle
- ReadEst.items — 줄 금액 · 율 · 값 줄 · 합계 · 소계 절 = 밖 · 끝 점 「28,500. ×」 · 이름 속 = · / 자재 · 「(지주)」 · 「\/㎡」 금액 꼬리 · 「직종 : P」 선언 줄 뺌 · 자재 손료율 표지 · 기계 비목마다 매개값 계수 · 절 = 제목
- Stmate — 같은 기계 코드 두 묶음 따로(울진대흥 1129 · 2119 · 울진소광 1448 단가 두 배 흠) · 비목마다 H 다르면 계수(봉화 덤프 (Cm-t1)/Cm · 영월 물탱크 ×0.722) · PR · RR 율 행(기준 표지 PA01M 꼴) · 이름 없는 율 행 = 위 라벨 줄 이름 · 기준글 · G 열 「* N'%'」 자원 = 율 줄 · 자재 손료율 · D 행 = 하위 · N 열 C = 밖
- Store.BOOK 3 — 읽기 꼴 바뀜(옛 판 HP = 다시 올림)
- 여덟 파일 불리는 블록: 줄 재현 811/836 → 836/842 · 덮음 201 → 204/206(남은 둘 = 엑셀 합이 진동로울러 노무 뺌) · 일위대가 줄 1,344/1,350
- 새 시험 test_778_3_hopyo_unit_lines

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:20:03 +09:00
eomsangdon 33951baeb0 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
# Conflicts:
#	ui_template/ui_template_locale_m2.ts
2026-11-04 13:16:06 +09:00
eomsangdonandClaude Opus 5.5 fd9cce0b12 feat(778-4): 율 기준 줄 목록 열쇠@비목 — 그 줄 그 비목 몫(절 노무비 · 기계 노무 포함)
- 밑 공용 함수 rate_base ↔ rateBase · 검사 · 짝잃음 · B09 rows:n@labor(파이썬 · TS) · 굳힘 · 화면 열쇠 옮김이 비목 꼬리 그대로
- 여덟 파일 율 줄 152 중 기준 찾은 147 재현(인력 82 · 재료 35 · 절 소계 16 · 노무비 11 · 장비 2 · 자재+율 1)
- 시험 test_778_4_rate_line 29 · 전후 대조 148/148 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:15:13 +09:00
eomsangdonandClaude Opus 5.5 b8772a4172 fix(778-4): 율 줄 화면 = 그 비목 상자 % 줄 — 엔진 줄 비목 · 상세 덧줄 자리
- 율 더한줄 엔진 줄에 비목(묶음 = 경비 등) → 글 보기 · 상자가 「하위 로직」 아닌 그 비목 상자
- 단가산출 상세(hoView) = 율 줄을 덧줄 자리(율 · 「인력의 %」) · 수량 고침 = 율 그대로(화면배 안 나눔)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:09:58 +09:00
eomsangdonandClaude Opus 5.5 8cbde56a60 knowledge(요구): 요구서 621 9장 — 판단 여덟 답 반영 · 3번 예시 다시
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:05:18 +09:00
eomsangdonandClaude Opus 5.5 30ad267576 knowledge(요구): 요구 624 원문 — 아티팩트 판단 여덟 답(건설업 작업반장 · 규격 순서 무관 · 주변 행열 · 로직 기준 · 손율 = 세부 로직)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 13:03:21 +09:00
eomsangdonandClaude Opus 5.5 d9a581659f feat(778-4): 율 줄을 단가산출(SK)에 담음 — 고정 비율 더한줄 · 기준 묶음 · 줄 목록 · 율 위 율
- SK 더한줄 율 줄 허용(일위대가 비율 줄과 같은 꼴 `고정 비율 {기준, 비목}` · 수량 = 율)
- 기준 = 비목(노무비 · 재료비 · 경비) · 묶음(인력 · 재료 · 장비 = 그 줄 종류만 · 기계 노무 섞인 블록도 인력 줄만) ·
  줄 목록(cid · add:#k · 앞 율 줄 = 율 위 율) · 검사 = 자기 · 뒤 율 줄 막음
- 밑 공용 함수 `M02_Labor_Fixed.rate_base` ↔ 짝 `Labor_Calc.ts` `rateBase`(일위대가 비율 줄 · Expand 같이 씀)
- SK 셈 `_rated` = 엔진 한 번 돈 줄로 밑 → 율 줄 값묶음 → 엔진 다시(율 줄 없는 문서 = 한 번 · 값 그대로)
- B09 pct 밑수 `bundle:` · `rows:`(파이썬 · TS 짝) · SK 블록 율 줄 = pct
- 굳힘 — 바탕 없는 SK 율 줄은 한 줄로 안 펼침(참조 그대로) · 겹침 때 기준 열쇠 새 차례
- 화면 — [율 줄 추가] 창(새 `M02_MasterTemplete_Sangeun_Rate.ts` · 부품 상자만) · 율 줄 = % 줄로 보임 ·
  더한줄 지움 · 되살림 때 기준 열쇠 옮김
- 시험 — 새 test_778_4_rate_line(25) · 거울 헬퍼에 rateBase 길 · 전후 대조 SK 16 · 일위대가 118 · B09 대가 입력 14 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:59:48 +09:00
eomsangdon 0c7b0948f9 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-04 12:55:10 +09:00
eomsangdonandClaude Opus 5.5 c4c541838e feat(779-4 · 779-6): 엑셀 보기 창 세 보기 · 연결 칠 · 범례 · 뛰기 · 뒤로 · 열린 창 다시 씀
- 탭 셋 일위대가표 · 단가산출표 · 단가산출 상세 = 서버 역할 id(이름으로 안 찾음 · STmate 단가산출근거 뛰기 풀림) · 두 형식 같은 탭 글 · 원본 시트 이름은 막대
- 새 logic/M02_MasterTemplete_Hopyo_SheetMap.ts(순수 — 줄 칠 · 블록 칠 · 머리 행 = 가장 나쁜 칠 · 뛸 곳) · 「이어짐」 = 확인 창 체크 규칙(캐시가 이김)
- 새 M02_MasterTemplete_Hopyo_SheetNav.ts — 범례 createLegend · 맥락 단추(상세 · 목록 · 하위 호표 · 부르는 줄 차례로) · 뒤로 · 두 번 누름 = 첫 단추 · 다시 칠(목록 다시 읽음 · 캐시 고침 · 만들기) · 칠은 A 열 ~ 끝 열 · 띠는 A 열
- Hopyo_Sheet.ts = 잇는 줄만(580 → 609) · 줄 행 누름 = 그 줄 그대로 · 머리 행 = 호표 영역
- 779-6 — 같은 파일 창은 새 모달 대신 그 창을 앞으로 꺼내 그 자리로 · 닫기 = 제자리로 내림 · 마지막 격자 다시 씀(확인 · 고르기 창 [엑셀 보기] 모두 openSheet 한 곳으로 지나감)
- Hopyo_Check.ts saveDraft = 다시 칠 알림 한 줄(663 → 662) · Hopyo_Api.ts at 내보냄(676 그대로)
- 서버 Match.view — 목록 「후보」 중 이름 후보 안 찾은 줄만 다시 셈(뒤 셈 끝 뒤) → 확인 창 「없음」과 같음(빨강) · 북부청 임도 목록 응답 2.81 → 3.49 초(다시 셈 48 줄 · 없음 11)
- 시험: 새 test_779_4_sheet_map(꼴 표 · 블록 칠 · 실물 EST · STmate 칠 행 수 · 뛸 곳) · test_m02_hopyo_screen 779 지킴 더함
- 낡은 시험 고침: test_779_2 기대 「후보 ≠ 없음 허용」 → 「모두 같음」(까닭 = 이번 덧 셈) · 도우미 helper_m02_hopyo_check · helper_729_3_hopyo_left 에 document 대역(까닭 = saveDraft 알림이 document 를 씀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:54:23 +09:00
eomsangdon 2f93dd8b11 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-11-04 12:32:01 +09:00
eomsangdonandClaude Opus 5.5 443fcedbe5 knowledge(요구): 요구서 621 8장 — 사용자 확정(모두 줄로만 · 율 줄 담음 · 자동 없음) · 걸음 D-1~D-6
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:31:05 +09:00
eomsangdon bcdc29415f Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-04 12:26:24 +09:00
eomsangdonandClaude Opus 5.5 32f5196b17 knowledge(요구): 요구 623 원문 — 모두 줄로만 · 율 줄 담음 · 자동 없음 사용자 확인 뒤 이동 · 안 맞는 부분 아티팩트
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:25:33 +09:00
eomsangdonandClaude Opus 5.5 697bd62c89 knowledge(요구): 요구서 621 7장 · 분석 621-D — 단가산출 = 세부항목 계산 단위(여섯 방법) · 줄 단위 분석 권함
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:18:49 +09:00
eomsangdon 7b4f5fc4f6 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-11-04 12:13:51 +09:00
eomsangdonandClaude Opus 5.5 42230cf3d6 fix(778-2): 기계 잇기 — 연료 · 조종원 없는 기종은 그 기계경비 기초(272 · 273 · 276)
- SkPatch SOLO · _mach_logics: (주연료, 조종원) 없는 기종 = GC000273 손료만 · GC000272 조종원 없음 · GC000276 연료 없음(M01 로직이 부르는 것과 같음) · 까닭 = 부착용 집게를 GC000268 로 이으면 「없는 요소 MO:None」 셈 오류(ORCA 북부청사방 단산59 실측)
- EQ 630 중 570 셈 됨(남은 60 = M01 기계 자료 빈칸 · 이 걸음 밖) · 여덟 파일 장비 더한줄 180 줄 모두 셈 됨 · 기초 80 · SK 37 · 고름 대기 89 그대로
- test_778_2: 부착용 집게 = GC000273 · 셈 됨

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:11:00 +09:00
eomsangdonandClaude Opus 5.5 3a1ce9ca4f refactor(776-2): 엑셀 한 번 열기(격자 = 읽은 책) · 줄 갈래 한 규칙 · EST 블록 금액 · 항목 · 옛 판 HP 409 「다시 올림」
- Excel — read_book 이 연 책(xls = formatting_info · 목록 시트까지)을 Grid.read 가 이어 씀(opened) · 거창 여는 횟수 2 → 1 · 여덟 파일 올림 시간 17.8 → 7.8 초
- Excel.kind_of = 두 형식 · 붙여넣기 한 규칙(STmate 코드 머리 · EST 칸 · 끝에 노임 올림) · Stmate._kind 걷음
- Excel — EST 블록에 금액 · 항목(ReadEst) 실음 · Basis 대신 ReadEst 직접
- Grid — 읽은 책 그대로 · xlsx 열 폭은 보기 시트 머리 <cols> 만(sheetData 에서 멈춤) · 여덟 파일 격자 결과 전과 같음
- Store.read 다른 판 = 409 「다시 올림」 · Forget.finish 는 옛 판도 끝냄(old=True)
- S1 · S2 해시 그대로 · test_718_1 옛 판 열기 409 · 끝내기 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:09:24 +09:00
eomsangdonandClaude Opus 5.5 b665269093 fix(778-1): 엑셀만 장비 — 괄호 낱말로 하나면 그 기계를 고르기에 둔 기계경비 기초 바로
- SkPatch._add_machine: 기계가 하나로 가려지면 옵션 거꾸로 찾기 없이 _mach_logics 첫 기초 + 기계 입력 · 까닭 = GC000268 옵션 거꾸로 찾기가 규격 수로 기계 고르기를 좁혀 굴착기를 놓치고 GC000267(굴착기(무한궤도) 기계경비)로 떨어짐(타이어 줄 17)
- 굴착기(타이어형) 줄 44 = 모두 GC000268 + 0211-0060 · 기계 못 찾음 4 줄 이음(트럭탑재형 크레인 5ton · 진동기 · 동력분무기 · 대형 브레이커 0.7) · 기계경비 잇기 176 → 180 / 183
- 여덟 파일 기초 80 · SK 37 · 고름 대기 89 그대로 · SK 셈 오류 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 12:03:03 +09:00
eomsangdon a363bbc63a Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_1 2026-11-04 11:56:46 +09:00
eomsangdon f0506c0628 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-11-04 11:56:44 +09:00
eomsangdonandClaude Opus 5.5 026f6c667a fix(778-2): 못옮김 기계 잇기 = 기계경비 기초 로직 줄 · 목록 밖 기계 셈 문맥 고침
- SkPatch LINK 기계 = 로직 · _Plan.link: EQ 키 → 기계경비 기초(그 기계를 고르기에 둔 기초 먼저) + 입력 기계 = 원문번호 · EQ 키 바로 더한줄 셈 오류 없앰
- master_pick resolve · machine · master_formula _inputs: 셈에 넘어온 마스터로 기계 찾음 — 까닭 = mp.EQ ContextVar 는 마스터를 지은 문맥에만 있어 서버 둘째 요청(스레드 풀 · 새 문맥)부터 목록 밖 기계(굴착기(타이어) 0.6㎥ 등)가 「고르기 밖」으로 막힘 · ContextVar 는 대신 길로 남김
- 새 시험 test_778_2_hopyo_machine_link(잇기 뒤 SK 셈 · 서버 두 요청 · 문맥 밖 스레드)
- 여덟 파일 기초 80 · SK 37 · 고름 대기 89 그대로 · SK 셈 오류 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:54:09 +09:00
eomsangdonandClaude Opus 5.5 4b3e9016be feat(776-2): 실무자료 HP 에 읽기 꼴 판 찍음 — 다른 판 = 지난 것(목록 숨김 · 다음 올림 때 끝냄)
- Store.BOOK = 2 · save 가 「판」 찍음 · stale = 하루 지남 또는 판 다름
- 작업 중 열기 409 「다시 올림」은 Forget.finish 옛 판 읽기 한 줄과 함께(받기 뒤)
- 시험 test_718_1 판 지킴 한 개

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:51:33 +09:00
eomsangdon 0fbd1b1c91 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-04 11:48:14 +09:00
eomsangdon 54653eeeef Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-04 11:48:05 +09:00
eomsangdon 3d25775c05 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-04 11:48:02 +09:00
eomsangdon 173a53c595 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-04 11:48:01 +09:00
eomsangdon ea4cb92e79 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-04 11:47:59 +09:00
eomsangdonandClaude Opus 5.5 c4cdc66de3 knowledge(요구): 요구 622 원문 — 단가산출 = 노무 · 경비 · 자재 세부항목마다 계산 방법 · 이것으로 분석 · 778 다시 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 11:47:09 +09:00
eomsangdonandClaude Opus 5.5 eaa8064f1a feat(777-1): 호표가 다른 호표를 부를 때 = 일위대가 둘 — 하위 함께 만들기 · 같은 SC 재사용 · 결과 글 · 납작 길 막음
- 부모 저장 때 확인 안 된 하위 호표(깊이 끝까지)를 하위 먼저 그 체크대로 함께 만듦(409 「먼저 확인」 걷음)
  · 서버 = 체크 제안 몸(M02_Hopyo_Kids.proposal ↔ 화면 bodyFor 짝) · 화면 = 확인 창 캐시 고름(Hopyo_Kids.ts)
  · 하위 실패 = 409 「하위 제N호표 실패 — …」 · 하위 · 하위남은줄 → 결과 표 [고르기]가 그 하위를 엶
  · [모두 고정] 연쇄 실패 까닭 따로 · 미리 보기는 하위 호표 줄을 이음으로 셈
- 같은 엑셀 다시 불러오기 = 같은 이름 · 같은 내용(수량까지) SC 재사용 — 본뜬.또(SK 꼴) · 끝내기는 그 몫만 뺌
- 결과 표 글 「SC-12 · 하위 제4호표 SC-11(사본 담음)」 · 확인 창 호표 줄 글 「하위 일위대가 SC-11 사본」
- [구조물 조합으로] 도 하위 먼저(못 만들면 지금처럼 고정 줄) · 붙여넣기 · B09 통째 = 이미 하위 먼저(안 고침)
- 엑셀 대조(CUTS · _excel_round · excel_given · compare) → 새 M02_Hopyo_Compare.py(Build 608 → 540)
- M02_Labor_Router 병행 흔적 가드 걷음(_later · inspect · 몸 칸 새로)
- STmate [엑셀 보기] 단가산출근거로 감(형식별 블록 시트 이름)
- 낡은 시험 고침: 「제46호표 먼저 확인」 409 · 글 → 하위 함께(777-1 정함) · 718-1 실패 = 남은줄 또는 하위 ·
  화면 시험 StaleError 안내 → unmadeKids · leftAt · 새 지킴 시험 test_777_1_hopyo_kids

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 09:24:48 +09:00
eomsangdonandClaude Sonnet 5.5 f9aa8832bb feat(779-2): 불러오기 엑셀 하이라이트 재료 — 목록 호표마다 줄상태 · 만든 SC 키 + 새 hopyo-marks 길(블록 판정 · SK · 갈래 · 못옮김, 읽기만)
- Match.view 목록: 호표마다 줄상태 [[차례, 상태, 체크]] · 만든 (이미 센 줄 상태 실음 · 덧 셈 0)
- 새 M02_Hopyo_Marks.py: 블록 [{번, 판정, SK, 갈래, 못옮김}] = Sangeun.made + Fit.result 읽기만 · 머리에 칠 꼴 대응표
- Router: GET /layers/{layer}/hopyo-marks/{key} (파일 끝에 한 덩이)
- 시험: test_779_2_hopyo_marks (줄상태 수 = 줄 수 · 읽기만 = 파일 변화 0 · 판정 = 캐시)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 08:55:56 +09:00
eomsangdonandClaude Opus 5.5 560e2e0928 fix(778-1): 장비 → EQ 괄호 낱말 규칙 — 굴착기(타이어형) = 타이어 기계 · 부속 뺌
- Elem: 괄호 낱말(타이어 · 무한궤도 · 습지 · 트럭탑재) 어긋남 · 부속(자동덮개 · 부착용 · 콤팩터 · 습지) 깎음 · narrow · machine(하나로 가름)
- Fixed Linker._machine: 같은 갈래 · 규격 여럿 = 괄호 낱말로 가름
- SkPatch._add_machine: 기계 = 괄호 낱말로 가른 그 기계 · 기계경비 기초 = 그 기계(같은 이름)를 고르기에 둔 기초 먼저
- 여덟 파일 장비 줄 잇기 59 → 172 / 183(32 → 94 %) · 굴착기(타이어형) 42 줄 = 세 길 모두 타이어 · 기초 80 · SK 37 · 고름 대기 89 그대로
- 새 시험 test_778_1_hopyo_machine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:49:46 +09:00
eomsangdonandClaude Opus 5.5 0c2f12c23f refactor(776-2): EST 단가산출 읽기 어댑터 떼어 냄 · 두 형식 블록 항목 · STmate 블록 단가 · 금액
- 새 M02_Hopyo_ReadEst.py — Basis.read(엑셀 「단가산출」 블록) · trade_names · sources 옮김 + 글 → 항목 풀이(L 직종 · X 장비 · M 자재 · 줄 율에 기대지 않는 것만)
- Basis — 근거 후보만 남김 · 옛 이름(read · SHEET · GLINES · GCHARS · sources · trade_names)은 그대로 다시 냄
- Stmate — 블록 금액(머리 줄 C~F) · 항목(자원 + 단가 I~L · 금액 C~F · 기계 셋 줄 더함 · 행) · 항목 단가 639 중 635
- 항목만으로 지문 인력 · 장비 · 자재 다시 세움 = 지금 지문과 544 쌍 모두 같음(tmp 대조 · 776-3 길)
- S1 · S2 해시 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:48:10 +09:00
eomsangdonandClaude Sonnet 5.5 1c1d756a1e feat(779-1): 격자 세 보기 — 목록표 둘 · 보기 · 원본 · 목록 · 줄 행 대응
- Grid SHEETS 에 단가산출총괄표 · 단가산출근거목록표 추가(탭 차례 일위대가표 · 단가산출표 · 상세)
- 응답에 보기 {역할: 시트 id} · 원본 · 목록 {호표, 단산: [{번, 행}]} · 줄 [{번, 차례, 행, 단산?, 일위?}] 더함(옛 칸 그대로)
- STmate 목록은 숨은 COD 코드로 짝(차례 번 ≠ 블록 번)
- Excel._sheets 에 wanted 인자만(기본 그대로)
- 시험: test_779_grid_views 새로(여덟 파일) · test_m02_hopyo_grid 시트 목록 기대값 세 장으로 고침(탭이 늘어서)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 08:46:50 +09:00
eomsangdonandClaude Sonnet 5.5 67c1d1f13d feat(779-3): 공용 격자 칠 옵션 — 칸 data-tone(success · warning · info · danger · muted · mine 띠) · repaint() · 읽기만 시트 탭 「+」 숨김
- 안 주면 data-tone 없음 · 보이는 칸만 부름 · 색은 --color-* 만
- 새 시험 test_779_3_grid_fill.py

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-04 08:38:33 +09:00
eomsangdonandClaude Opus 5.5 9d26e5fcd6 test(776-1): 경쟁사 출력 단계 고정 시험 — 여덟 파일 S1(정규화 투영 해시 · 수 · 갈래 · 호표 참조 그래프) · S2(셈 단위 275 지문 해시)
- 새 test_621_hopyo_stages.py + fixtures/m02_hopyo_stages.json · 15 초 · --write 로 새 판 굳힘
- 투영 = 지금 HP 칸만 — Book 에 칸 더함은 그대로 · 빼기 · 이름 바꿈은 깨짐
- helper_m02_hopyo_golden 죽은 갈래 걷음 — EST 블록 글 다시 채움(읽기가 이미 글 붙임 · 223 블록 모두)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:36:49 +09:00
eomsangdonandClaude Opus 5.5 4e0ea28aec knowledge(요구): 요구서 621 · 분석 셋 — 경쟁사 출력 불러오기 단계 체계 · 요소 기준 연결 · 엑셀 세 보기 · 호표 참조 · 하이라이트
PLAN 776~779 짝 · 정함(브레인) 여섯 · 사용자 물음 둘(778-3 · 4 만 기다림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:30:09 +09:00
eomsangdonandClaude Opus 5.5 cfd1c6070d knowledge(요구): 요구 621 원문 — M02 경쟁사 출력 불러오기 체계화 · 요소 기준 연결 · 엑셀 보기 · 호표 참조 · 하이라이트
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 08:02:22 +09:00
eomsangdonandClaude Opus 5.5 bc8d669e7f knowledge(요구): 요구 620 원문 — M02 엑셀 불러오기 분석 · 매칭률 · 매칭 로직 설명 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 07:04:44 +09:00
eomsangdonandClaude Opus 5.5 c3699e9e8c knowledge(요구): 요구 619 원문 — 브레인 메모리 정리 · 다음 일감 = M02 경쟁사 엑셀 불러오기 로직
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 06:52:54 +09:00
eomsangdonandClaude Opus 5.5 c24d81e267 knowledge(계획): 계획 이관 771 ~ 775 — 예정노선 수정 취소 · 중간 노드 긋기 · ☰ 서랍 · 평면계획선 방법 M
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 21:00:24 +09:00
eomsangdonandClaude Sonnet 5.5 42e505b325 test(756-1): 스냅 한 아이콘 뒤 낡은 글 대조 제거
Chrome 에서 빠진 「MC 를 고른 간격 배수에」 글 대조 한 줄 삭제

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 20:59:44 +09:00
eomsangdonandClaude Opus 5.5 a04a8f4223 feat(775-2): B05 예정노선 수정 끝 — 지금 편집기 곡선 수를 keep_curves 로 넘김 · 못 지키면 curve_note 안내 토스트
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:46:23 +09:00
eomsangdon 3c28a15ce5 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-11-03 20:43:44 +09:00
eomsangdonandClaude Opus 5.5 c7d63c1b78 feat(775-1·775-2): 평면계획선 예정노선 맞춤 — 곡선 수 그대로 교각점 · R 최소제곱 + 측점 · 최소 R 벌점 · 수정 끝 = 수정 전 곡선 수 틀
- 새 B05_Profile_Engine_RouteInitial_Fit: 성긴 최소제곱 맞춤 · 측점 벌점 1 → 1000 · 끝 확인(곡선 수 · MC 측점 어김 0 · R ≥ 최소 R) · 수정 끝 틀 고르기(예정노선 거리 덜 늘어나는 합침)
- merged_follow: 합치기 뒤 측점 맞춤 대신 맞춤 · 끝 확인 못 지나면 지금 측점 맞춤으로 물러남 · keep_curves
- /route/follow: 수정 전 곡선 수 = 본문 keep_curves 또는 저장 노선 곡선표 · 응답 keep_curves · curve_note
- 773 여덟 꼴 평균 최대 26.5 → 6.9 m · 평균 3.80 → 1.24 m · N 유지 8/8 · 측점 어김 0 · R 미달 0 · 물러남 0
- 지킴 시험 test_775_route_fit 새로 · test_758_1 그대로 통과(입력 덧점 정리로 체인 · 추적 같은 선)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:42:48 +09:00
eomsangdon d575fa25e5 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-03 20:41:02 +09:00
eomsangdonandClaude Opus 5.5 f6f29fdee1 feat(774-2): B05 예정노선 = 늘 노선 하나 — 끊긴 토막은 끝끼리만 붙고 맞닿으면 한 줄
- 한 선 페이지(기능 표 branch 숨김) 긋기 붙을 자리 = 다른 토막 끝만(가운데 노드 · 선 위 = 안 붙음 → 갈래 안 생김)
- 긋기 마침 = 맞닿은 토막 모두 이음(joinTouching · 꼬리↔머리 · 꼬리↔꼬리 · 머리↔머리) · D03 = 지금대로
- 끊긴 채 수정 끝냄 = 기존 막음 · 안내 그대로
- 시험: test_771_2 에 joinTouching 대조 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:40:22 +09:00
eomsangdonandClaude Opus 5.5 2a67a7092d feat(774-1): 다시 긋기 = 중간 노드 고름으로 켬 · 다른 중간 노드에 이어야 끝
- 노드 속성 판 · 우클릭 = 중간 노드에 [다시 긋기](선 토막 고름의 [다시 긋기] 뺌) · B05 긋기 중 중간 노드 누름 = 그대로 켬
- 끝 = 같은 선 다른 중간 노드만(rejoinMiss) · 시작 노드 · 끝점 = 못 찍음 + 안내(계속 긋기) · 빈 곳 = 점 찍고 계속
- 고리: 시작 노드 · 이을 중간 노드 · 마우스 올린 중간 노드(paintRejoin · --draw-map-*) · 옛 구간 흐림(dim) 뺌
- 시험: test_771_2 에 끝 판정 · 중간 노드 대조 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:31:21 +09:00
eomsangdonandClaude Opus 5.5 bc739cbcc2 knowledge(요구): 요구 618 원문 — B05 예정노선은 늘 한 줄
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 20:30:11 +09:00
eomsangdonandClaude Opus 5.5 258a59ade5 knowledge(요구): 요구 617 원문 — R 개수 = 수정 전 평면계획선 곡선 수 · 방법 M 둘 다
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 20:29:12 +09:00
eomsangdonandClaude Opus 5.5 9ab3ae5035 knowledge(요구): 분석 773 — 평면계획선 예정노선 맞춤 · 차이 주범 = 합치기 · 측점 맞춤 · 갈래 9 시험 · 권함 M(교각점 · R 맞춤 + 측점 벌점)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 20:26:06 +09:00
eomsangdonandClaude Opus 5.5 ea6207d587 knowledge(요구): 요구 616 원문 — 다시 긋기 = 노드 고르면 켜짐 · 다른 중간 노드에 이어야 끝
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 20:21:35 +09:00
eomsangdon f2322854c1 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-03 20:06:24 +09:00
eomsangdonandClaude Opus 5.5 f96907fb8a fix(772-1): ☰ 서랍 진행 단계 · 바로가기 칸 빠짐 — 진행 판 공용 자리에서 맡김 · 안 맡긴 쪽 기본 목록
- 까닭: B03 파일입력 = 진행 판을 직접 조립(createWorkflowOverlays)해 서랍 맡김(setDrawerSteps)이 빠짐 ·
  B02 · B10 · B11 · D01 · E01 = 일반 판이라 맡기는 쪽이 없음 · B01 = 10-26 새 틀(e505f4014)에서 옛 ☰ 구역 바로가기를 걷음
- 진행 판(createWorkflowOverlays)이 progressContent 를 받으면 서랍에 맡김 — 워크플로 판 · B03 한 자리
- ☰ 를 열 때 맡긴 목록이 없으면 기본 목록 = 🏠 대시보드 + 권한별 M01 · M02(mountSettingsDrawer 와 같은 한 벌) · 늦게 온 쪽 목록이 이김
- 터치 켬 서랍 진행 단계 줄 40 → 44(옆 규칙이 더 세어 덮던 것)
- 시험: helper_280_2 의 app_shell 대역은 workflow_layout 이 더는 안 불러 지움 · helper_359_2 에 overlay 용 대역 더함 · test_772_1 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 20:05:47 +09:00
eomsangdonandClaude Opus 5.5 8ec6aea315 knowledge(요구): 요구 615 원문 — 예정노선 수정 뒤 평면계획선 차이 · R 개수 유지 · 제약 · 스냅 절대 · 시험하며 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 20:00:12 +09:00
eomsangdon 16a1b10b9d Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-03 19:49:23 +09:00
eomsangdonandClaude Opus 5.5 df879ffcb9 feat(771-1·771-2): B05 예정노선 수정 — 중간 노드에서 긋기 · 닿은 노드 사이 갈음 · 지움 뒤 긋기 Esc = 지움 전
- 771-2: 한 선 페이지(B05) 중간 노드 누름 = 그 노드에서 긋기 · 같은 선 다른 노드에 닿으면 그 사이 갈음(rejoinSpan · 거꾸로 됨 · 한 줄) · 같은 노드 · 안 닿고 끝냄 = 꼴 그대로 + 안내 · 되돌리기 한 걸음 · D03 는 중간 꺾임점 = 갈래 그대로
- 771-1: 긋기 Esc = 긋기 직전 꼴 · 지움(노드 · 토막 · 구역 · 전체 · 잇단 지움) 바로 뒤 긋기면 지움 전 꼴(D03 같은 부품)
- 시험: test_771_2_rejoin_span 새로 · test_626_3 의 lineLimitBlock 줄 문자열 대조를 새 꼴(branches 변수)로 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:48:42 +09:00
eomsangdonandClaude Opus 5.5 ee5125f012 knowledge(요구): 요구 614 원문 — 햄버거 메뉴 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 19:32:12 +09:00
eomsangdonandClaude Opus 5.5 b13eb584fb knowledge(요구): 요구 613 원문 — B05 예정노선 수정 취소 = 직전으로 · 중간 노드에서 다시 긋기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 19:31:02 +09:00
eomsangdonandClaude Opus 5.5 da408c942f knowledge(계획): 계획 이관 770 — 안정성평가 단면 면적 시험 낡음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 19:05:04 +09:00
eomsangdonandClaude Opus 5.5 bc8bc78fa2 test(770-1): 532-2 B06 면적 대조 = E03 분석 길(관 · 벽 측점 그려지는 설계선) · 검증_sub4_E 복사본
- 까닭: 768 새 자료 관 측점 340 · 342 · 1200 · 1201 m 에서 B06 [저장] 면적 = 관 트림 뒤 「그려지는 설계선」 기준(`structure.layouts` 폐회로 면적) · 시험만 원 설계선으로 재 어긋남(옛 검증_sub4 엔 관 측점 없음)
- E03 분석 길(`walls_by_station` · `design_with_walls`)로는 71 측점 모두 B06 과 같음(검증_sub4_E · 검증_sub6_E) — 코드 흠 아님 · 코드 안 고침
- 시험 = 옛 B 초기값 스냅샷 대신 E 복사본 + 분석 길 그대로 · 관 측점 있어야 함 확인

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 19:04:03 +09:00
eomsangdonandClaude Opus 5.5 0987a8e400 knowledge(계획): 계획 이관 761 ~ 769 — 서피스 확정 · 다듬기 · 추천 흐름 · 지형마다 설계 한 벌 · 필터 지움 · 프로젝트 새로 만듦
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 18:58:47 +09:00
eomsangdonandClaude Opus 5.5 208d755123 test(768-4): 검증 프로젝트 다시 만든 뒤 시험 상수 새 id 로 · 기슭막이 없는 검증_sub2 벽 시험 건너뜀
- 시험 20 파일의 옛 프로젝트 id → 같은 이름 새 id(B 검증_sub1~8 · D 가이드_예정노선 · 검증_sub1_D · E 가이드_안정성평가)
- test_536_3 벽 시험 둘 — 새 검증_sub2 = 구조물 0(옛 사용자 기슭막이 351 m 없음) · 벽 측점 없으면 건너뜀
- 고정 자료 golden_472 · helper_472_1_dump 는 옛 id 그대로(살아 있는 프로젝트 안 읽음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:54:09 +09:00
eomsangdonandClaude Opus 5.5 5852c93606 style(765-8): 체인 주석 줄 길이 맞춤
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:56:16 +09:00
eomsangdonandClaude Opus 5.5 57a6c0ee22 fix(765-8): 벌마다 평면노선 따로 — 받은 노선을 그 벌 지형으로 잘라 벌 예정노선으로 두고 따라감(요구 612)
- 벌 예정노선 terrain_route.csv(벌 몫) — 체인이 받은 노선을 그 지형으로 잘라 한 번 적음 · 초기 폴리라인(merged_follow) · 화면 예상노선 · 고칠 선 끝이 이것을 바탕으로(route_source_path · 없으면 공유 예상노선)
- 공유 예상노선 expected_route.csv = 설계 지형으로 자른 하나(추천 열쇠 그대로) · 고친 예정노선 expected_route_edit.csv 는 벌 몫(다른 벌에 안 번짐)
- 검증_sub5: 라이다 벌 1,352.9 m · 곡선 19(옛 값과 같음) · 수치지형도 벌 2,110.2 m · 곡선 28(예정노선 끝까지) · 칩 왕복 · 벌마다 [초기화] 값 같음 · 공유 예상노선 그대로
- 시험 test_765_design_sets — 벌 몫 가름에 벌 예정노선 · 고친 예정노선 · 벌 예정노선 자름 · 초기 폴리라인 바탕 둘 덧 · 지운 csf 필터 글자를 ptd · classification 으로(637 · 763_3 · 765)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:56:10 +09:00
eomsangdon 61c6f0e9df Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_8' into main_laptop_1 2026-11-03 16:36:06 +09:00
eomsangdonandClaude Opus 5.5 1f990f2db6 knowledge(요구): 요구 612 원문 — 벌마다 평면노선 따로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 16:36:04 +09:00
eomsangdonandClaude Opus 5.5 11c75f022a refactor(767-1): 안 쓰는 지면 필터 · 서피스 지움 — 필터 넷(CSF · 격자 최저 · PMF · RANSAC) · 표현 셋(TIN + Taubin 평활 · NURBS · meshfree) · 수치지형도 비교 방식 다섯 · 안 부르는 길 정리
- 필터 = 업체 분류 · 점진 TIN · 둘의 다듬기 판만 · 분석 허용 목록 = Ground 등록부(available_filters) 한 벌
- 표현 = DTM · implicit 만 · 평활 = DTM 만 · config 기본 목록 · PRECOMPUTE · 설정 사전 맞춤(설정 지문 바뀜 — 768 에서 다시 지음)
- 등고선 = 격자 · 굳힌 격자만(삼각망 굽기 · 외곽 맞춤 · implicit RBF 폴백 걷음) · 표본기 · 경로 찾기 tin · nurbs · meshfree 갈래 걷음
- 수치지형도 = 라플라스만(TIN 둘 · TPS · ANUDEM형 · 다중해상도 걷음) · B04 단추 하나
- 안 부르는 길 — B04 JSON 점군 GET · ground-stats GET + TS · B07 옛 계획평면도(라이다) 짓기 · lidar_source · LIDAR_ID · plan_lidar 종류 이름 · SOURCE_FILTER_LABELS · MODEL_METHODS(py) · MESH_* 설정
- 옛 manifest 의 지운 갈래 줄은 DB 로 되살리지 않음 · 옛 csf 행은 미리보기 · 등고선 그대로 열림 · 지운 이름 요청 = 400
- 낡은 글 — Ground · Pipeline 머리 · WF1 「없으면 csf」 · B07 음영 기본 「csf」 → 빈 값 · 등고선 폴백 「csf」 → 404 · B04 3D 보기 처음값 · B05 Schema · B06 Prebuild · 씨닝 설명
- 시험 지움 2 — test_b04_contour_raster · test_b04_contour_equivalence(지운 삼각망 굽기 · 없는 프로젝트를 봐 건너뛰던 것)
- 시험 덜어냄 — test_b04_ground_filters CSF · 격자 최저 네 경우(지운 필터)
- 시험 고침 — 허용 목록 · 형 검사에 걸린 csf · tin · grid_min_z · meshfree · tin_sheet 견본을 ptd · implicit · laplace 로(54_22 · 238_1 · 443 · 342_2 · 346_3 · 351_1 · 442_2 · 271 + helper · 344_1 · 133_1 · 343_2 · 637 · 754_1 · 763_1 깨진 tin → 깨진 implicit · 763_3 · b05 둘 · b06 · sheet_surface TIN(도엽선) 경우 지움) · 이름만 바꿈(273_2 · 343_1 · 343_3 · 624_1)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:35:12 +09:00
eomsangdonandClaude Opus 5.5 f7bd8dbf06 fix(769-1): 추천 4 갈래 = 계산 로직 결과 그대로 — 같은 쪽 곡선 합치기 · 측점 맞춤 뺌
- 요구 611: 추천노선 = 평면계획선을 긋는 가이드 선 · 합치기 · 스냅은 예정노선 수정 뒤 평면계획선에만
- _trace = 경로 탐색 → 곡선 넣기(curve_every_kink) → 큰 R(widen) 까지 · merge_same_side · settle 뺌
- 예상노선 추적(merged_follow) · 체인 · /route/plan · replan · follow 는 합치기 · 스냅 그대로
- Pool.snap · Pool.cross · build_pool snap · cross 인자 · 엔진 fit · route_earth · merge_delta 걷음(쓰는 곳 없음)
- ALGO_VERSION 9 → 10
- 낡은 시험 고침: test_419_1 · test_351_2 추천 갈래 측점 위 기대 뺌(추적만 측점 위 · 갈래는 안 맞춤 확인 추가) ·
  test_route_initial 영선 · test_351_2 비중 기울기 상한 기대 뺌(원지반 기울기 참고만 · 비중은 벌점만) ·
  test_224_4 build_pool 엿봄 인자

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:30:29 +09:00
eomsangdon db91aece63 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-03 16:27:29 +09:00
eomsangdonandClaude Opus 5.5 e54ee5314a fix(765-3): 두 벌 = 같은 평면 노선 — 예상노선을 설계 지형으로 먼저 잘라 적고 다른 지형 벌도 그 노선으로 지음
- 검증_sub5 실측: 수치지형도 벌이 수치지형도로 자른 긴 노선(2110 m)으로 지어져 공유 예상노선(라이다로 자름 · 1377 m)과 어긋남 → 그 벌 [초기화] 뒤 초기 폴리라인이 예상노선(라이다)으로 다시 지어져 노선 1359.6 m · 종횡단 2110 m 로 갈림
- 예상노선(공유 · 추천 열쇠)은 하나 · 설계 지형으로 자름 → 옛 지형 칩처럼 평면 노선 같고 지형만 다름
- 다른 지형 벌 끝에 예상노선 지우던 길 걷음(먼저 적으니 필요 없음)
- 시험 test_765_design_sets 체인 차례에 예상노선 먼저 적기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:21:04 +09:00
eomsangdon 2937ba6208 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_3 2026-11-03 16:15:10 +09:00
eomsangdonandClaude Opus 5.5 968db57bdb knowledge(요구): 요구 611 원문 — 추천노선 = 가이드 선 · 합치기 · 스냅 안 함 · 예정노선 수정 뒤 평면계획선에만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 16:14:40 +09:00
eomsangdonandClaude Opus 5.5 bdaca728fe fix(765-6): 초기값 사본 · 복원에서 예상노선(공유) 뺌 — 수치지형도 벌 [초기화]가 라이다 예상노선을 덮던 것
- 검증_sub5 실측: 칩으로 수치지형도 벌 → [초기화] → 칩 라이다 뒤 예상노선이 수치지형도로 자른 것(643 점 · 2136.2 m)으로 남음 · 라이다로 자른 것 = 415 점 · 1377.4 m
- 예상노선은 지형마다 벌이 있어도 하나(공유 · 추천이 씀) — 초기값 사본에 안 넣고 복원이 안 건드림(옛 사본 안 것도 건너뜀)
- 시험 test_765_design_sets 에 덮지 않음 검사 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:12:24 +09:00
eomsangdonandClaude Opus 5.5 2a8f244ee0 knowledge(요구): 766 구멍 메움 전후 그림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 16:08:32 +09:00
eomsangdon 462a2f50fa Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into main_laptop_1 2026-11-03 16:07:02 +09:00
eomsangdonandClaude Opus 5.5 8a50a155ab perf(765-2): 벌 꺼내 끼우기 자식 행을 표마다 한 번에 넣음 — 칩 5.5~8 s → 약 2 s
- 검증_sub5 실측: _load_db 3.1~5.2 s(원격 DB 에 횡단 · 정점 줄마다 INSERT) → 0.5 s
- test_637_lidar 줄 길이 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:06:23 +09:00
eomsangdonandClaude Opus 5.5 fe6f67d123 fix(765 이음매): LAS 나중 올리기 옛 필터 지우기가 새로 지은 다듬기 모델까지 지우던 것 — 지은 필터가 옛 이름을 앞머리로 품으면 남김
- 766-1(기본 = 업체 분류 + 다듬기 · classification_refine) 과 637-3 drop_stale_filters 가 만나 classification 을 지우며 dtm_classification_refine.npz · implicit_classification_refine*.npz 까지 지움(라이다 짓기 직후 모델 없음)
- test_637_lidar B 뿌리 짓기: 기대 필터를 다듬기 이름으로(옛 csf · ptd 기본 아님) · 모델 npz 만 봄 · 긴 이름 지은 필터 지킴 시험 하나 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:04:17 +09:00
eomsangdonandClaude Opus 5.5 08281bd712 feat(766-2): 외곽 안 구멍을 둘레 지면에서 매끈하게 메움 — 다듬기 판 외곽 = 바탕 필터 외곽 · 쌍조화 메움 · implicit 길잡이 · 품질 지표 「메운 넓이」
- 다듬기 판(classification_refine · ptd_refine)은 바탕 필터 지면점으로 외곽을 그림 — 다듬기가 지운 자리 = 외곽 안 구멍 · 외곽 자체는 안 넓힘(엔진이 바탕 마스크를 모델 짓기에 넘김 · 지면점 저장본은 안 지음)
- dtm 격자: 땅 칸에서 틈 닫기(3 m) 넘게 먼 칸 + 그 둘레 빈 칸을 쌍조화(scikit-image inpaint_biharmonic · 이미 씀)로 메움 · 작은 틈은 지금처럼 가까운 칸 값
- 라플라스(막 펴기)는 버림 — 홀로 남은 땅 점마다 고깔이 생겨 용화 새 자리 튐 8.2 %(쌍조화 6.4 %)
- implicit: 메운 dtm 칸을 3 m 간격 길잡이 중심점으로 더함 — 구멍 칸 튐 3.1 → 0.8 %(dtm 과 같은 면) · 중심점 뽑기 칸은 자기 외곽 넓이 그대로
- 품질 지표 ⑤ 메운 넓이 m² · % — dtm `filled_mask` · B04 값 목록 한 줄 · QUALITY_VERSION 2
- 모델 지문에 hole_fill 더함 — 다음 전처리 때 모델을 다시 지음
- 지킴 시험 test_766_2_hole_fill — 평면 · 곡면 비탈 그대로 이음 · 작은 틈 안 건드림 · 바탕 외곽 그대로 · dtm · implicit 구멍 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:03:51 +09:00
eomsangdon 5523522df4 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-11-03 16:01:53 +09:00
eomsangdonandClaude Opus 5.5 c89c9223e3 fix(765-6): 초기값 사본 · 복원의 캐시 규칙을 B05 비용면에도 — 사본에 안 넣고 복원이 안 지움
- 검증_sub5 실측: 초기값 사본 B05_Profile__route 4.4~5.6 MB 의 대부분이 추천 비용면(cost_surface_*) — 배수 캐시와 같은 공유 캐시
- 트리마다 공유 캐시 앞머리(_TREE_CACHES) · 뜨기 = 뺌 · 복원 = 설계 파일만 갈음(예상노선은 그대로 뜸)
- 시험 test_765_design_sets 하나 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 16:00:07 +09:00
eomsangdon 91985adf29 Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_5' into main_laptop_1 2026-11-03 15:59:37 +09:00
eomsangdon 4781a14768 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-03 15:59:34 +09:00
eomsangdonandClaude Opus 5.5 c29d72639d knowledge(요구): 분석 608 — 안 쓰는 필터 넷 · 서피스 셋 · 수치지형도 비교 다섯 · 안 부름 여섯 지움 검토 · 약 1,800 줄
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 15:54:40 +09:00
eomsangdonandClaude Opus 5.5 9b12d4ff5d feat(765-6): 초기값 사본에서 배수 공유 캐시 뺌 · 복원은 노선 몫만 갈음(캐시 제자리) · 복제가 설계 벌 묶음 안 지표면 id 고침
- common_util_initial_snapshot: 배수 사본 = 노선 몫만(하강장 · 도엽 좌표 · 강우 뺌 · 약 105 → 20 MB) · 복원은 폴더 통째 지우지 않음 · 캐시 든 옛 사본의 캐시 칸은 건너뜀
- common_util_project_copy._fix_files(통째 복제 · --replace 같은 길): design_sets/<지형>/ 의 db.json · 그 벌 초기값 · B06 표본 설정 · meta.json 지표면 id → 새 id · 노선 id 는 꺼낼 때 늘 새로 받아 안 고침 · 코리도 안 건드림
- 시험 test_765_design_sets 에 둘 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:52:46 +09:00
eomsangdonandClaude Opus 5.5 0dbe4d5f31 feat(765-4): 지형 칩 = 벌 맞바꿈 · LAS 나중 올리기 뒤 = 확인창 → 전처리 다시(두 벌 · B05~B09 초기화)
- B05_Profile_Router_TerrainSwitch: 칩 = swap_design_set(떠난 벌 [저장] 고침 살림 · 묶음 없음 · 묵음 = 한 번 지음 · 실패 = 통째 되돌림) · force = switch_terrain → run_surface_change_chain(765-5)
- 확인창 글: 바꿈 = 미리 지어 둔 그 지형 설계로 바꿔 끼움 · 전처리 다시 = 두 벌 새로 · B05~B09 초기화 · LAS 올림 끝 = 늘 전처리 다시
- 시험: test_626_1 칩 시험을 맞바꿈 · 전처리 다시 기준으로(옛 노선 그대로 다시 셈 길 없음) · test_637 force 시험 · 화면 줄 · test_763_3 서피스 바꾸는 자리 목록에 지형 칩

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:50:38 +09:00
eomsangdonandClaude Opus 5.5 7ada1a643c knowledge(요구): 요구 610 원문 — 지울 범위 B · D · E 모두 · OWNERS 새 id 는 브레인이 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 15:49:54 +09:00
eomsangdonandClaude Opus 5.5 d9e8dc4177 fix(764-1): 추천 측점 맞춤 덜 나쁜 자리에 원지반 기울기 늘어남 · 옮긴 거리 따짐
- 요구 606-2 · 분석 761-6 — 설계 지형에서 측점 맞춤이 후보 7/8 을 비탈로 옮겨 추천 셋이 같은 선
- 덜 나쁜 자리 순위 = 계류 나란히 → 기울기 늘어남 → 옮긴 거리 · 꼴(합친 꼴)을 가로질러 견줌
- 크게 망침(기울기 넘친 만큼 +5 %p 초과 · 원래 선에서 10 m 초과)은 그것 없이 다 놓는 길이 없을 때만 · 그때 가장 덜 망치는 자리(곡선 가운데 = 측점 유지)
- 놓을 자리 풀이 기억(층 · 다시 셈 공통) · ALGO_VERSION 9(옛 저장본 안 꺼냄)
- 지킴 시험 test_764_1_snap_grade.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:48:26 +09:00
eomsangdonandClaude Opus 5.5 a44fd98adf feat(765-3): 전처리 체인 = 지형마다 두 벌 — 다른 지형 먼저 짓고 묶음으로 떠 둠 → 설계 지형 → 추천 미리 셈 한 번 · 배수 늘 다시 셈 · 비우기에 묶음 · 죽은 run_redesign_chain 걷음
- run_preprocess_chain: WF1 자동 확정 · 서피스 바꿈 체인이 부름 · 다른 지형 없음 · 실패 = 한 벌 + meta.json 까닭 · 예상노선(공유)은 설계 지형 벌이 씀
- 자동 체인 배수 refresh=True — 저장분이 노선 바뀐 줄 모르던 틈 막음
- purge_output_folders 가 design_sets 도 지움
- run_redesign_chain(앱에서 부르는 곳 0) 걷음 — 시험 정리: test_342_2 의 그 함수 시험 셋 지움(함수 없음) · test_763_3 · test_351_1 은 두 벌 체인으로 · test_74 · test_user_values_survive 의 이월 차례 검사는 살아 있는 자리(B05_Profile_Route_Save)로 옮김
- 시험 test_765_design_sets 에 체인 차례 · 한 벌 · 다른 벌 실패 · 비우기 넷 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:47:20 +09:00
eomsangdonandClaude Opus 5.5 a64d0fc57e feat(765-2): 지형마다 설계 한 벌 묶음 모듈 — 떠 두기 · 꺼내 끼우기(새 노선 id · 자식 다시 잇기 · 코리도 이름) · 지문 · 맞바꿈(실패 = 통째 되돌림)
- common_util_design_sets: 활성 벌 = 제자리 · 쉬는 벌 = design_sets/<las|sheet>/(db.json · files/ · initial_snapshot/ · meta.json)
- 벌 몫 = B05 노선 정본 · 코리도 · B06~B09 · 배수 노선 몫 · 초기값 · 공유 캐시(비용면 · 예정노선 · 하강장 · 도엽 좌표 · 강우)는 안 옮김 · 안 지움
- 옮김 = 같은 볼륨 이름 바꿈 · 단계 2 이후 행 · projects.status 함께
- 묶음 없음 · 지문 어긋남 = 그 자리에서 자동 설계 체인 한 번(inner = 마커 · 미리 셈은 부르는 쪽)
- 시험 test_765_design_sets 6 (왕복 값 같음 · 새 id · 묵은 벌 · 실패 되돌림)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:42:28 +09:00
eomsangdonandClaude Opus 5.5 8b49d9cd92 knowledge(요구): 요구 609 원문 — 업무 끝난 뒤 프로젝트 전체 지우고 같은 이름으로 검증 프로젝트 다시 만듦
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 15:41:00 +09:00
eomsangdonandClaude Opus 5.5 2d69a494bd knowledge(요구): 요구 608 원문 — 초기값 사본 캐시 · 응답 속도 물음 · 안 쓰는 필터 · 서피스 지움 검토
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 15:36:49 +09:00
eomsangdon dc16c500d0 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_5 2026-11-03 15:30:40 +09:00
eomsangdonandClaude Opus 5.5 7876465f8a knowledge(요구): 분석 765 — 지형마다 설계 한 벌 · 한 벌 약 20 MB · 약 30 s · 활성만 DB · 다른 벌 묶음 · 칩 = 맞바꿈
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 15:30:08 +09:00
eomsangdonandClaude Opus 5.5 579e337892 feat(766-1): 다듬기 흐름을 기본으로 — 스위치 SURFACE_REFINE_FLOW 걷음 · 자동 전처리 = 업체 분류 + 다듬기(분류 있음) · 점진 TIN + 다듬기(늘) · 확정 기본 ptd_refine
- resolve_auto_source_filters 가 늘 `_refine` 판을 돌려줌 — 바탕 마스크(업체 분류 · 점진 TIN)는 다듬기 입력으로 그대로 저장(엔진 길 그대로)
- SURFACE_CONFIRM_DEFAULT_FILTER 기본 = ptd_refine · WF1 자동 확정은 엔진이 고른 첫 필터 그대로
- B04 드롭다운 갈래 넷 그대로(관리자 비교용)
- 지킴 시험 고침: test_640_lidar_default · test_b04_ground_filters 의 자동 목록 기대값을 다듬기 판으로 · test_762_1_refine 의 스위치 시험은 스위치가 없어져 「기본 흐름」 시험으로 바꿈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 15:20:19 +09:00
eomsangdonandClaude Opus 5.5 c82f301e2b knowledge(요구): 요구 607 원문 — 다듬기 씀 · 구멍은 자연스럽게 메움
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 15:14:51 +09:00
eomsangdonandClaude Opus 5.5 8811fbf85a knowledge(요구): 요구 606 원문 — 지형마다 설계 한 벌 미리 · 측점 맞춤 옮길 자리에 기울기 따짐
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 15:13:40 +09:00
eomsangdonandClaude Opus 5.5 136d1e014d knowledge(요구): 분석 761-6 추천 셋 같은 선(측점 맞춤 덜 나쁜 자리) · 762 점 필터 다듬기 시범 보고 · 그림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 14:53:31 +09:00
eomsangdon e9435881d7 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into main_laptop_1 2026-11-03 14:51:29 +09:00
eomsangdonandClaude Opus 5.5 11c8bd4aa5 fix(762-2): B04 품질 지표 값 목록 — 땅 점 빈 곳 · 그중 급경사 두 줄 · 필터 차는 % 만(짝 이름은 마우스 올림 글) · 값이 두 줄로 깨지던 것 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:15:12 +09:00
eomsangdonandClaude Opus 5.5 e2b7133225 feat(763-4): 추천 미리 셈 끊기면 이어 셈 · 화면 진행 n/m · 저장본 없으면 [지금 셈]
- 진행 기록 `B05_Profile/route_recommend_job.json`(저장본 폴더 밖 — 저장본 glob 에 안 섞임) · `note_job` 재사용 · 갈래마다 갱신 · 끝 = running 거짓
- 진행 묻기 = 이 서버 메모리 먼저 · 없으면 기록(10 분 안 = 다른 서버가 셈 · 넘음 = 끊김 `interrupted`)
- 추천 요청 409 + 「끊김」 기록 = 이어 셈 예약 한 번(기록을 곧바로 「도는 중」으로 · 뒤 요청은 안 겹침)
- 미리 셈 한 갈래 409 = 그 갈래만 건너뜀(옛: 전부 멈춤)
- 409 글 = 「추천 저장본 없음 — 지표면이 바뀌어 다시 셈해야 함」(없는 [재계산] 단추 안내 걷음)
- 화면(RecPreview) — 셈 중이면 줄 마우스 글 「추천 셈 중 n/m」 · 알림 한 번 · 3 초마다 다시 물어 끝나면 그림 · 아니면 확인창 [지금 셈](그 갈래만 recompute) · 지형 바뀌면 받아 둔 선 비움
- 시험 309_3 · 364_4 · 509_5 · 626_1 을 새 글 · 진행 꼴 · [지금 셈]에 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:01:28 +09:00
eomsangdonandClaude Opus 5.5 6d0b50aee6 feat(762-2): 서피스 품질 지표 넷 — 튐 넓이 % · 1 m 등고선 꺾임 /km · 땅 점 빈 곳 % · 필터 차 % · B04 모델마다 표시
- 새 Engine_Quality — 599 정의 그대로(5 × 5 중간값 0.5 m · 2 m 다시 찍어 30° · 2 m 안 지면점 없음 · 짝 필터 |차| > 0.5 m)
- 파이프라인이 dtm · implicit 을 지을 때 셈해 models/quality_{필터}_{표현}.json 에 저장 · 모델 · 짝 모델 지문이 다를 때만 다시 셈
- GET /surface/models/{id}/quality · B04 지표면 분석 칸에 값 목록(부품 kv) · 문턱 판정 없음(사용자 협의)
- B04 필터 드롭다운 · 이름을 새 UI_Filter 로 옮김(UI_Page 679 → 661 줄) · 업체 분류 + 다듬기 · 점진 TIN + 다듬기
- 지킴 시험 test_762_1_refine — 덤불 혹 지움 · 능선 안 깎음 · 스위치 끔 = 지금 길 · 튐 지표

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:46:51 +09:00
eomsangdonandClaude Opus 5.5 c0f4c766b6 feat(762-1): 지면점 다듬기(SMRF 꼴) · 고를 갈래 업체 분류 + 다듬기 · 점진 TIN + 다듬기 · 제안 흐름 스위치(기본 끔)
- 새 Engine_Filter_Refine — 1 m 칸 최저 · 낮은 잡점(뒤집어 열림) · 창 키움 열림(최대 18 m · 기울기 0.3) · 걸린 칸 빼고 임시 지면 다시 그림 · 점마다 0.5 m + 1.25 × 기울기
- 필터 이름 classification_refine · ptd_refine — Ground _FILTERS · Schema · sampler SurfaceFilter 에 더함 · 엔진은 바탕 마스크 저장본을 다듬음(점진 TIN 두 번 안 돌림)
- 스위치 SURFACE_REFINE_FLOW(프로세스 환경 변수 · 기본 끔) — 켜면 자동 전처리 · 확정 기본 = 다듬기 판 · 끄면 지금 길 그대로
- 기울기 0.3 = 용화 훑기에서 고름(PDAL 0.15 는 급경사 능선을 더 깎음 · 0.45 · 0.6 은 덤불 혹 더 남김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:46:33 +09:00
eomsangdonandClaude Opus 5.5 cf09d53924 fix(763-3): B04 분석 · 조합 짓기 = 보기만 · 다른 서피스 [확정] = 업로드와 같은 전처리 체인 · 체인 실패에도 미리 셈
- B04 [분석](조합 짓기) — 단계 시작 · 확정 해제 · 실패 단계 기록 걷음 · 1 단계 값(las_selection 포함) 그대로 · 미리 셈 없음 · 진행 글 = 확정 있으면 「확정 그대로」
- 분석 결과 DB 고쳐 쓰기가 확정 모델을 COMPLETE 로 내리던 것 — CONFIRMED 그대로(`_upsert_surface_model`) · 안 쓰는 `clear_confirmed_surface_models` 지움
- B04 [확정] — 같은 서피스(필터 · 표현 · 평활) = 등고선 간격만 · 다른 서피스 = `run_surface_change_chain`(새 파일): 초기값 스냅샷 버림 · B05~B09 지움(같은 지형 재업로드 길 keep_preprocess) · 단계 2 이후 처음 → 자동 설계 체인
- 자동 · 재확정 체인 미리 셈 = finally(실패 · 건너뜀 길에도) · B05 LAS 나중 올리기 끝 미리 셈 예약
- 추천 [적용](as_initial) = 선만 · 설계 지형 그대로(요구 605-2 · 브레인 승인) · 지형 칩은 사용자 답 기다림(지금대로)
- 지킴 시험 test_763_3 — B05~B09 파이썬에서 확정 서피스 쓰는 부름은 지형 칩 · 라이다 적어 둠 둘만
- 시험 309_3 · 351_1 · 509_5 를 새 길에 맞춤(미리 셈 부르는 곳 둘 더 · finally · 추천 [적용] 지형 안 넘김)
- 재확정 체인(`run_redesign_chain`) 부르는 곳 0 — 지움은 따로(시험 342_2 셋 함께)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:45:30 +09:00
eomsangdonandClaude Opus 5.5 a2b2ae57b2 fix(761-5): 복제 · 바꿔 넣기 뒤 노선 · 지표면 id 박힌 storage 파일 새 id 로
- common_util_project_copy.rekey_id_files: 코리도 이름 corridor_{노선 id:04d} → 새 노선 id(겹침 대비 임시 이름 거침) · 짝 없는 옛 코리도 지움 · B06_Section/sampling.json surface_model_id → 새 지표면 id
- _insert_rows · _insert_full 이 (지표면 · 노선) id 짝을 함께 돌려줌 · full 복제 · --replace · --storage-only 셋 다 _fix_files 에서 rekey
- id_maps_by_order: 이미 복제된 두 프로젝트의 id 짝(차례 · 행 수 다르면 ValueError) — storage-only 가 씀
- storage 경로 함수 전수: id 박힌 이름 = 코리도뿐(B08 이정표 그림 이름 = 거래처 상자 id · 추천 저장본 = 해시) · 내용 = sampling.json 지표면 id(초기값 스냅샷 지표면 id 는 종전대로)
- 지킴 시험 test_761_2: 노선 id 짝 단정 · rekey(이름 겹침 · 짝 없음 지움 · sampling)
- 바꿔 넣은 B 13 개 고침(tmp 스크립트 · 차례 짝 + 내용 칸 대조): 코리도 셋씩 새 id · corridor_0370 지움 · sampling 911 → 각 새 id

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:38:20 +09:00
eomsangdon 47f9d90f97 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-11-03 13:28:10 +09:00
eomsangdonandClaude Opus 5.5 6af3f990ff fix(763-2): 같은 서피스 = 파일 내용 지문 — 비용면 · 계류 · 초기 종단 · 초기 종단 목표 = 고른 지형
- `file_digest()`(derived_store) — blake2b 내용 지문 · 한 프로세스 안 경로 · 시각 · 크기로 기억
- 비용면 서명 · 계류 지문 · 초기 종단 `stat_marks` = 내용 지문(옮겨 심기 · 동기화 · 다시 짓기로 시각만 바뀌면 같은 열쇠)
- 초기 종단 표식 = 확정 지형이 읽는 파일만(`_source_npz_paths` · `*_{필터}*.npz` glob 걷음)
- `balanced_profile_of` 에 selection — 추천 초기 종단 목표 계획고 = 지반선과 같은 고른 지형(3h)
- 열쇠 꼴이 바뀌어 기존 추천 · 초기 종단 저장본은 다시 셈(763-5 몫)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:23:31 +09:00
eomsangdon 6e30772e43 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-03 13:18:30 +09:00
eomsangdon 0f810fe9bf Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-03 13:18:28 +09:00
eomsangdonandClaude Opus 5.5 80c2aabfcf fix(763-1): 추천노선 = 확정 지표면에서만 — 확정 전 409 · 비용면 = 굳힌 격자 마디 · 조용한 DTM 대체 걷음
- `confirmed_surface_params()` — stage 1 COMPLETE · params 에 source_filter 있을 때만 · 아니면 None
- compute_initial = 확정 전 409 「지표면 확정 전 — B04 [확정] 뒤 추천이 생김」 · 미리 셈 지형 = [](건너뜀 로그)
- Solver implicit 갈래 = `implicit_grid_npz` 마디 그대로(RBF 다시 안 셈 · 짓기 16.2 s → 0.02 s · 유효 칸 최대차 0.00003 m)
- 모델 파일 없음 · 깨짐 = 까닭 그대로 FileNotFoundError · ValueError(409) · 서명에 굳힌 격자(서명 전에 지음)
- 시험 224_3 · 351_1 · 509_5 의 확정 갈아 끼우기를 새 함수로 옮김(라우터가 새 함수를 부름)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 13:17:31 +09:00
eomsangdonandClaude Opus 5.5 106d179be2 knowledge(요구): 요구 605 원문 — B04 = 관리자 특수 경우용 · 서피스 바꾸면 전처리 다시 · B05~B09 초기화 · B05~B09 확정은 서피스 안 바꿈
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 13:17:20 +09:00
eomsangdon 92f74671ad Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_laptop_2 2026-11-03 13:02:58 +09:00
eomsangdonandClaude Opus 5.5 5043f29a62 knowledge(요구): 분석 603 — 전처리 로직 점검 · 확정 없을 때 기본값 셈 · B04 조합 짓기가 확정 풂 · 미리 셈 거는 곳 셋뿐 · 끊김 이어 셈 없음 · 고칠 걸음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 13:02:16 +09:00
eomsangdonandClaude Opus 5.5 4db52b5504 feat(761-2): 복제 도구 자리 바꿔 넣기 --replace — 대상 id · 이름 · 주인 · 참여자 · 종류 유지
- common_util_project_copy_replace: 대상 옛 행(복제 표 목록) 지우고 원본 행으로 채움 · projects 는 유지 칸 밖만 원본 값 · storage 를 원본과 똑같이(robocopy /MIR) · 넣기 전 대상 행 덤프(--backup-dir)
- common_util_project_copy: 행 넣기를 _insert_rows 로 떼어 full 복제 · 바꿔 넣기가 함께 씀 · robocopy 출력 글자 깨짐 무시
- 지킴 시험 test_761_2_clone_replace(유지 칸 · 지우는 표 = 복제 표 · id 다시 잇기)
- test_532_1 낡은 단정 고침: 지표면 지워진 옛 노선의 NULL(ON DELETE SET NULL)을 새 id 밖 값으로 잘못 셈
- 실제 한 번: 가이드 → 임시 복제 → 그 자리에 --replace → 행 수 · 파일 같음 · 추천 4 꺼내짐 → 앱 영구 삭제 길로 지움

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 12:57:40 +09:00
eomsangdonandClaude Opus 5.5 a16fb1c039 knowledge(요구): 요구 604 원문 — sub8 에 점 필터 · 서피스 제안 코드 시범 · 프로젝트 하나로 보여 줌
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 12:56:32 +09:00
eomsangdonandClaude Opus 5.5 28f222a3dd knowledge(요구): 요구 603 원문 — 가이드 확정 업체 분류 · implicit · B 프로젝트 모두 가이드 복제 · 전처리 로직 점검
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 12:44:31 +09:00
eomsangdonandClaude Opus 5.5 56b05665f6 knowledge(요구): 분석 602 — 프로젝트별 서피스 출처 · 추천 저장본 안 꺼내짐 셋 · 까닭 = 파일 시각 열쇠 · 확정 풀림 · 미리 셈 끊김
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 12:33:49 +09:00
eomsangdonandClaude Opus 5.5 91cd80378a knowledge(요구): 분석 601-1 — 라이다 지면 · 식생 가름 외부 조사 · 품질 확인 지표 · SMRF 꼴 다듬기 1 순위
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 12:24:09 +09:00
eomsangdonandClaude Opus 5.5 983551381b knowledge(요구): 분석 601-2 — 라이다 점 필터 · 서피스 코드 목록 · 안 부름 6 · 정리 후보
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 12:20:52 +09:00
eomsangdonandClaude Opus 5.5 c482d0cc2c knowledge(요구): 요구 602 원문 — 프로젝트별 서피스 필터 · 생성법 · B05 추천노선 보기 데이터 없음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 12:16:07 +09:00
eomsangdonandClaude Opus 5.5 f40fa2b767 knowledge(요구): 요구 601 원문 — 라이다 지면 · 식생 가름 외부 조사 · 점 필터 · 서피스 코드 정리 표
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 12:06:36 +09:00
eomsangdonandClaude Opus 5.5 9f3b3d9ac1 knowledge(계획): 계획 이관 758 ~ 760 — 따라가기 곡선 최적 · 사용자 셋 지표면 · 암상 지질도
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 08:39:38 +09:00
eomsangdon 8ed718ed75 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-03 08:39:04 +09:00
eomsangdonandClaude Opus 5.5 c187f30e8e feat(758-1): 예정노선 따라가기에도 토공 최소 곡선 최적 — 큰 R · 같은 쪽 곡선 합치기 · 직접 잼 측점 맞춤
- 새 `B05_Profile_Engine_RouteInitial_Follow.merged_follow` — 예정노선 폴리라인 → 큰 R(widen) → 꺾임만 곡선 → 같은 쪽 합치기(merge_same_side · 노선 횡단 토공 +10 % 멈춤) → 곡선 MC 일반측점 맞춤 · 잼 = 토공 최소와 같은 Guard(지형 · 계류 격자) · 같은 입력 기억
- 한 함수 — 체인 처음 · /route/plan · /route/replan(`initial_land` · 초기선 낡을 때만 지형 읽음) · /route/follow(`follow_land`) · 추천 「예상노선 추적」(follow_line · 묶음 격자 재사용)
- 지형 못 읽으면 옛 길(순수 기하) · 기존 확정 노선 · 초기선 · 저장본은 안 바꿈(다음 체인 · 끌 때부터)
- 화면 — 따라가기 뒤 지금 추천 = 예상노선 추적(「토공 최소」 꼬리표 남던 흠) · 걸음 사진에 실려 되돌리기 짝
- 엔진 합치기 잣대 셈(cross_total · cross_delta)은 새 파일 한 벌로 옮김(엔진 줄 줄어듦)
- 시험: test_758_1 새 지킴 · test_383_1 은 체인 초기선도 같은 지형을 넘기게 고침(추천 추적 = 곡선 최적이라 순수 기하와 달라짐) · test_626_3 은 onFollow 새 꼴로 글 대조 고침
- 검증_sub1: 곡선 25 → 16 · 측점 16/16 · 예정노선과 최대 10.6 m · 끌 때 1.4~1.9 s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:38:17 +09:00
eomsangdonandClaude Opus 5.5 18e080f75e knowledge(요구): 분석 599 — PTD · CSF 서피스 품질 비교 · 튐 까닭 = 남은 덤불점 · 지면점 열림 거름 제안
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 08:38:15 +09:00
eomsangdon 9feb507a71 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-03 08:33:30 +09:00
eomsangdonandClaude Opus 5.5 f6cfaf446c feat(760-1): 암상 지질도 겹 · 노선 따라 바위 종류 구간 · 밖 겹 출처 줄
- 서버: 지오빅데이터(KIGAM) 수치지질도 1:50,000 — WFS 암상 면(깨진 한글 칸은 기호별 점 조회로 채움) · 받은 범위 ∪ 노선으로 자름 · 저장본 · 노선 따라 1 m 표본 구간 · 키는 서버만(.env GEOBIGDATA_API_KEY · 쿼리 key)
- 그림 조각 대리에 geology(키 쿼리 이름 칸 key_param · 옛 겹 serviceKey 그대로)
- 화면(D03 · B05): 「제한·재해」에 「암상 지질도」 — 그림 조각 + 갈래 색 면 · 「기호 · 암상」 글 · 범례 · 노선 구간 표(참고 — 토사 · 암 판정 아님)
- 켠 밖 겹마다 범례 맨 아래 출처 줄(브이월드 · 국토지리정보원 · 산림청 · 환경부 · 생활안전지도 공공누리 · 한국지질자원연구원 CC BY-NC)
- 범례 칸 높이 한도 · 밀기(낮은 화면)
- 시험: test_760_1 새로 · test_634_2 · test_553_4 겹 차례 표에 geology 한 줄 더함(새 겹 · 낡은 표)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 08:32:53 +09:00
eomsangdonandClaude Opus 5.5 9685ba70d4 knowledge(요구): 요구 600 — 599 조사만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 08:11:06 +09:00
eomsangdonandClaude Opus 5.5 080938f562 knowledge(요구): 요구 599 — 점진 TIN 튀는 구간 · CSF 와 서피스 품질로 비교 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 08:10:37 +09:00
eomsangdonandClaude Opus 5.5 2efea6da90 knowledge(요구): 요구 598 — 예정노선에도 토공 최소 곡선 최적 · 지표면 새로 지어 옮김 · 지질도 개발용 · 출처 표기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 08:04:00 +09:00
eomsangdonandClaude Opus 5.5 f28268f050 knowledge(요구): 분석 597 — B05 둘째 단계 안 빠짐 · 지표면 옮겨 심기 · 지오빅데이터 접속 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 06:55:11 +09:00
eomsangdonandClaude Opus 5.5 018297a34a chore(설정): 지오빅데이터(KIGAM) 오픈API 키 .env GEOBIGDATA_API_KEY 로 · 요구 597
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 06:43:40 +09:00
eomsangdonandClaude Opus 5.5 06eaaaafe7 knowledge(요구): 요구 597 — B05 둘째 단계 확인 · 지표면 채우기 확인 · 지오빅데이터 키
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 06:43:40 +09:00
eomsangdonandClaude Opus 5.5 cd9e209174 knowledge(요구): 요구 596 — 지질도 위치 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 06:36:22 +09:00
eomsangdonandClaude Opus 5.5 b9a486f29a knowledge(계획): 계획 이관 751 — 원화 표기 · 셈 끝수 품셈 1-2-2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 06:19:28 +09:00
eomsangdonandClaude Opus 5.5 edb2596c63 test(751-3): 받은 753-2 시험(이어 붙인 모델)에 B09 rounddown 같은 식 — 모델 소계 1 원 버림
- test_750_2 753-2 시험이 Cover_Model 을 import 빼고 이어 붙여 돌림 → 751-3 이 더한 `rounddown` 가져옴이 빠져 ReferenceError · 같은 식 한 줄 더함(704_3 · 717_2b 와 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:17:52 +09:00
eomsangdon 0ef349463d Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-11-03 06:16:21 +09:00
eomsangdon 037b9de76d Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-11-03 06:16:18 +09:00
eomsangdonandClaude Opus 5.5 f4be6782a5 feat(751-3): M01 · M02 셈 끝수 = 품셈 1-2-2 버림 — 줄 0.1 원 · 비목 소계 1 원 · 계 = 셋 합 · B09 와 같은 값
- 끝수 한 곳 `master_cut.py`(B09 `rounddown` 재사용) — 엔진 답에 입힘: 줄 비목마다 0.1 원 버림 · 하위 로직 줄 = 하위 끝수 계 × 수량 · 덧줄 = 버린 줄 합으로 다시 셈 · 비목 합 1 원 버림 · 중간 칸도 버린 값
- 쓰는 곳: M01 시험 계산(`check_master.calc`) · 읽는 식(`master_text.lines`) · 옛 조합 미리보기(`master_combo`) · M02 `run_line`(단가산출 · 일위대가 · 펼침) · 별도계상 뺀 기준 · 켜쌓기율 곱 뒤 · 일위대가 반영 · 반영합 · 비율 줄 · 사전조합 안 줄
- 일위대가 반영 TS 짝(`Labor_Calc.ts`) 같은 끝수 · 거울 헬퍼가 상대 경로 .ts 가져옴
- B09 = `run_line(raw=True)` 엔진 날 값(제 단가로 다시 버림 · % 덧줄 율 비 그대로) — 검증_sub3 B09 247 칸 전후 같음
- `master_text` 꼴 셋 = `common_util_money` 로 갈음(줄 금액 `fmt_line_money` · 식 안 금액 = 버린 0.1 원)
- 화면: 상세 줄 금액 · 시험 계산 줄 · 중간 돈 = `formatLineMoney` · 일위대가 반영 칸 0.1 원 · 상세 바닥 · 박스 · 인력재료경비 비목 소계 = 줄 합 1 원 버림
- 테스트1 네 보기 · 서고 목록 = 124,419(옛 124,420 / 124,419.85) · 재료 줄 11,694.6 + 23,425.6 + 1,404.8 = 소계 36,525
- 새 지킴 시험 test_751_3_money_cut
- 옛 날 금액(끝수 없음)을 대조하던 시험 24 파일 고침 — 까닭 = 금액 규칙이 줄 0.1 원 · 소계 1 원 버림으로 바뀜(고정 값 → 새 값 · 줄 합 = 계 → 줄 합 아래 비목마다 1 원 안 · 곱 비 = 끝수 폭 안) · 실무 xlsx 영월 엑셀 같음 22 → 23 · 이어 붙이는 TS 시험(704_3 · 717_2b)에 `rounddown` 같은 식

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 06:16:01 +09:00
eomsangdonandClaude Opus 5.5 486e9887eb knowledge(요구): 요구 595 — sub3 끝나면 전역 깃 싱크 · 랩탑 메인 4173 다시 짓기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 06:03:02 +09:00
eomsangdonandClaude Opus 5.5 d5196d64d1 knowledge(요구): 요구 594 — 최근 일주일 지우기 대상 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 05:48:03 +09:00
eomsangdonandClaude Opus 5.5 adf14f0ebc knowledge(요구): 요구 593 — tmp 폴더는 마음대로 · 정리 길
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 05:41:48 +09:00
eomsangdonandClaude Opus 5.5 75a902a7e1 knowledge(요구): 요구 592 — 작업 창 bash 허락 막힘 대책
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 05:34:27 +09:00
eomsangdonandClaude Opus 5.5 e4d8c958d1 knowledge(요구): 요구 591 — sub3 허락 창 사용자가 승인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 05:33:35 +09:00
eomsangdonandClaude Opus 5.5 04365e28d3 knowledge(요구): 요구 590 — 감시 고리 점검 · sub3 물음 진짜
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-03 05:31:53 +09:00
eomsangdon 2d3a713512 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-02 23:35:04 +09:00
eomsangdonandClaude Sonnet 5.5 94b6869b99 refactor(751-2): calc_sheet 직접 꼴 공용 꼴로 · test_719_4 공용 꼴 파일 더함
- calc_sheet.ts won = toLocaleString 직접 꼴 → formatMoney(0 자리) · formatLineMoney(1 자리) · 보이는 값 같음
- test_719_4 낡은 시험: node 로 붙이는 TS 목록에 common_util_money.ts 더함(751-1 뒤 formatNumber 미정의 ReferenceError) · 재는 것은 그대로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:34:37 +09:00
eomsangdonandClaude Opus 5.5 4286b744af knowledge(계획): 계획 이관 754 — B04 등고선 외곽 · 횡단배수 첫 열림 빈 폼
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 23:33:16 +09:00
eomsangdon d478761d19 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-11-02 23:32:40 +09:00
eomsangdonandClaude Opus 5.5 79c53be5d3 fix(754-2): B05/B06 구조물 폼 — 종류 목록이 늦게 와도 고른 관 폼 다시 엶
- 까닭: 공용 폼 setTypes 가 군 · 종류를 빈칸으로 돌리고 고른 관(selectedPipeChainage)은 남김
  · B06 에서 관 고른 뒤 처음 B05 를 열면(B05 보기 첫 구조물 불러오기) 5/5 빈 폼
  · B05 에서 종류 받기 전 관을 누르면 3/5 빈 폼(카드 · 목록 고름은 남고 종류 목록 0)
- 고침: setTypes 끝에서 고른 관이 있으면 그 관 폼 다시 엶 · 임시 배치 중은 둠
  · 구조물 폼은 뒤따르는 setStructures 가 이미 다시 엶
- 시험: test_754_2_types_late_form.py
- 검증: 새 탭 54 번(B05 · B06 시작 · 이른/늦은 고름 · 예정노선 수정 켬끔 · 새로고침) 빈 폼 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:32:05 +09:00
eomsangdonandClaude Opus 5.5 a9f4b0c88d knowledge(계획): 계획 이관 736 ~ 757 — 단가산출 상세 · 서로 독립 · 불러오기 · 셈 통일 · B05 예정노선 수정
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 23:31:01 +09:00
eomsangdon 8f36cd9d55 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into main_laptop_1 2026-11-02 23:29:24 +09:00
eomsangdonandClaude Opus 5.5 9d5778bf57 feat(755-3): 예정노선 수정 켠 채 판 고침 — [저장] · [확정] 막고 알림 · 떠남 물음 · 탭 닫기 브라우저 물음
- design_save 저장 막음 자리(holdDesignSave · 화면에서 빠지면 풂) · 막음 = 저장 줄 · 가림막 앞
- ExpectedDraw = 켠 채 판 고침(사진첩 한 걸음 이상)이면 막음 글 · 공용 떠남 물음(guardProjectLeave) · 되돌려 고침 없으면 안 물음
- 시험 test_755 지킴 한 건 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:28:47 +09:00
eomsangdonandClaude Sonnet 5.5 a773a9d2cd feat(753-2): 옵션으로 빠진 줄 숨김 — 상세 · 인력재료경비 · 시험 계산 · 텍스트 수식(M01 · M02 공용) 모두 안 그림
- 753-1 서버 줄 덧칸 `옵션꺼짐` 을 화면 넷이 읽어 그 줄을 안 그림 — 공용 상세 박스(`LogicLab_Ho` boxCard · 장비 · 하위 몫은 다 빠졌을 때만) · 텍스트 수식(`LogicLab_Formula`) · 시험 계산 표(`Logic_Calc` answerView) · 공용 계산서(`calc_sheet` · M01 인력재료경비 · M02 `sheetGroups`)
- 옵션 칸 · 소계 · 금액 · 차례 · cid 그대로(옵션 칸은 빠진 줄의 쓰는 입력도 포함) — 옵션을 바꾸면 보이는 줄이 바뀜
- M02 표 줄 `CoverRow.off` · 계산서 줄 `옵션꺼짐`
- 새 지킴 test_750_2 에 753-2 대조(표 줄 off → 계산서 줄 · 소계 그대로)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:28:01 +09:00
eomsangdonandClaude Opus 5.5 8b3560c4ce fix(755-3): 받은 노선 원본 꼭짓점을 체인 예정노선 파일 양끝에서 자름 — 지표면 넓어진 뒤 280 m 긴 흠
- /route/plan/source = 지표면 트림 뒤 expected_route.csv 시점 · 종점 투영 자리에서 자름(파일 없음 = 그대로) · 0.1 mm 반올림 겹친 점 하나만
- 검증_sub2 source 1,377.4 → 1,097.4 m(= csv) · follow 1,356.5 → 1,080.2 m · 검증_sub1 은 전후 같음(1,097.4)
- 시험 test_635_2 양끝 자름 · 파일 없음 그대로 한 건 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:21:40 +09:00
eomsangdon 39652baf40 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-02 23:18:58 +09:00
eomsangdonandClaude Sonnet 5.5 2372181940 refactor(751-2): 불러오기 화면 직접 꼴 갈음 — CheckRows 수량 · Compare 수량 · 후보 단가 공용 꼴
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:18:35 +09:00
eomsangdon ad9062dec7 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_7' into main_laptop_1 2026-11-02 23:14:46 +09:00
eomsangdonandClaude Sonnet 5.5 da45997ebe feat(752-1): 단가산출 상세 오른쪽 원문 패널 — 기초마다 탭 · 일위대가 줄 모달 안은 끔 · 좁은 폭 접힘
- 새 `Sangeun_Side` — 일위대가 상세와 같은 서랍(`createSidePanels`) + 원문 보기(`createSourceView` · M02 길 `/m02/logic-lib/source` · 원문 다운로드) · 바탕 · 둘째 기초(더한줄 로직)마다 탭 · 기초 하나 = 탭 없음 · 바탕 없음 = 「원문 연결 없음」 · 넓은 화면(1500 이상)만 처음 열림 · 열림 · 폭 기억 `m02-sangeun-side`
- Sangeun_Detail — 옵션 `source`(단가산출 화면 View 만 켬 · 줄 모달 RowSk 는 안 켬) · 「시험 계산 · 텍스트 수식 = 바탕 기초 + …」 안내 글 걷음(750-2 로 네 보기가 같은 답이라 낡음 · 키 M02_SangeunBaseViews 지움)
- 낡은 시험 고침 — test_721_find_pick(안내 걷음) · 새 지킴 test_750_2 에 752-1 대조

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:14:28 +09:00
eomsangdon d0e8fcb5ee Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1
# Conflicts:
#	B05_Profile/B05_Profile_UI_RouteEdit.ts
2026-11-02 23:14:27 +09:00
eomsangdonandClaude Opus 5.5 3f4fd188e8 fix(754-1): B04 등고선 · 격자 외곽 = 지금 지표면 외곽 — 어느 표현을 지어도 낡은 dtm 같이 다시 지음 · 등고선 캐시 판단에 dtm · 굳힌 격자 때
- 지표면 짓기 차례에 dtm 을 늘 맨 앞 — manifest 항목 없음 · 지문 다름이면 같이 다시 지음(화면 [서피스 생성] = 표현 하나만 짓던 길)
- 등고선 캐시 낡음 = 모델 · 같은 필터 dtm · implicit 굳힌 격자 중 하나라도 더 새것(짓기 차례 · 등고선 API 한 함수)
- 등고선 추출이 모델 · dtm 파일을 닫음 — 실패 때 열린 채 남아 Windows 에서 다시 짓기(교체)가 막히던 흠
- ptd 처럼 dtm 없던 필터도 dtm · 굳힌 격자가 생겨 ptd · implicit 확정 길 B05 · B06 열림
- 새 시험 test_754_1_contour_outline.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:13:13 +09:00
eomsangdonandClaude Opus 5.5 dfba0e7b57 feat(755-1·755-2): B05 예정노선 수정 — 켠 동안 판만 고침 · 끌 때 한 번 추적 · 재기 · 받은 노선 색 · 설명 · 위치 칸 걷음
- 755-1 켠 동안 끊김 허용(가운데 묶음 지움) · 고칠 때마다 다시 추적 걷음 · 끌 때 끊김 = 알림하고 안 끔 · 바뀜 = 확인창 → /route/follow 한 걸음 · 켠 동안 되돌리기 = 예정노선 판 사진첩(Ctrl+Z · 도구줄 단추 · bindHistoryControls local) · [잇기] 기능 표 b05 사용
- 755-2 켠 동안 재기 = D03 재기 겹(createPlanMeasure · 선 따라 · 두 점) · 받은 노선 = 분홍 점선 + 검은 테(기존 --draw-map-route-3 · theme.css 안 고침) · 켬 설명 걷음 · 위치 칸 걷음(기능 표 site b05 숨김 · 칸 자리 그리기 하나 · 찾기 이동 · toPlan 걷음)
- 낡은 시험 고침: test_626_3(되돌리기 = 판 사진첩 · join b05 사용) · test_635_3(칸 하나 · 위치 숨김 · 끌 때 한 번 · RouteEdit 줄 한도 683 → 690) · test_635_5(찾기 이동 걷음) · 새 지킴 시험 test_755_expected_flow

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:11:21 +09:00
eomsangdon d8af573f1c Merge remote-tracking branches 'origin/sub_laptop_4' and 'origin/sub_laptop_5' into main_laptop_1 2026-11-02 23:03:40 +09:00
eomsangdonandClaude Sonnet 5.5 011046c2a2 feat(757-1): 그리기 칸 긋기 단추 걷음 · 방식 넷 켬끔 = 긋기 시작 · 끝
- 분할 고르기 부품에 풂 꼴(clearable) · 부품 모음 견본에 더함
- 방식 칸(map_draw_kind)에 켬끔 꼴(onActive · setActive) — 켬 = 긋기 시작 · 고른 방식 다시 누름 = 긋기 끝 · 긋는 중 다른 방식 = 방식만 바뀜
- D03 · B05 그리기 칸 [긋기] 단추 걷음 · 긋기가 Enter · Esc · 붙어 끝나도 칸 켬 맞춤 · 노드 판은 그대로
- 시험 757_1 새로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:03:15 +09:00
eomsangdonandClaude Opus 5.5 a36092fad8 feat(753-1): 옵션으로 빠진 줄 표시 — 엔진 줄 덧칸 옵션꺼짐
- 새 master_gate: 호표 줄 수량 0 이 만약 간 갈래(상수 0) 탓이면 옵션꺼짐 true
  (고르기 · 수 입력 문턱 · 중간값 거침) · 입력 0 · 표 칸 0 · 원문 0 = 칸 없음
- master_formula.run 한 곳에서 붙임 → M01 /calc · SK 셈 답 줄 저절로 실림
- master_sheet_line: /text · M02 sheet 줄에 같은 칸(KEYS)
- M01 정본 자료 안 고침 · 금액 · 차례 · cid 그대로(돈 로직 1,440 전후 같음)
- 지킴 시험 test_753_1_option_off(GC000211 폼타이 · 진짜 0 · 문턱 · 금액 · /text · SK)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 23:03:04 +09:00
eomsangdon 34a2b8d163 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_6 2026-11-02 23:02:41 +09:00
eomsangdonandClaude Sonnet 5.5 db590e6fa8 feat(750-2): 단가산출 보기 넷 = SK 셈 답 하나 — 시험 계산 · 텍스트 수식도 SK 답(글 · 중간) · 바탕 M01 로직 따로 셈 걷음 · 옵션 바꿈 서버 셈 한 번
- Sangeun_Detail — 텍스트 수식 = 답 `글` · 시험 계산 = 답 `줄` · `기준` · `중간`(`buildCalc` 덧칸 `answer` · `shown` · `held`) · `/logic-lib/calc` · `/text` · 미리보기 부름 · `baseCovers` · `dropText` · `applyCovers` 걷음 · 바탕 원값 셈 = `보기: false`
- 재료 고름(줄 모달 · 시험 계산 재료 칸) = SK 문서 `고름`(cid 열쇠 · 둘째 기초 세부 줄은 보기만)
- LogicLab_Ho — 읽는 식 줄을 `cid` 로 이음(`HoContext.cids` · 이름 차례 추측은 M01 만) · LogicLab_Formula — 단위 환산 `나눔` 머리 한 줄
- 낡은 시험 고침 — test_717_2b(`baseCovers` · `dropText` 단정 뺌 · 함수 지움) · test_725_1(`void recalc()` 새 꼴) · 새 지킴 test_750_2
- M01 단가산출 기초 = 덧칸 안 넘겨 옛 길 그대로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 23:01:57 +09:00
eomsangdon 7937846caf Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into main_laptop_1 2026-11-02 23:01:25 +09:00
eomsangdonandClaude Sonnet 5.5 1288e21e55 feat(756-1): 추천 선 진하게 · 스냅 한 아이콘 · 추천노선 여럿 고르기 · 켠 동안 등고선 띠 넓힘
- 예정노선 수정 켬에도 추천 선 불투명 그대로(Render)
- 스냅 + 스냅 간격 = 고르기 메뉴 하나(측점 No. · 10 m · 끔) · 도구줄 표 snap-step 줄 걷음
- 추천노선 = 여럿 고르기(공용 부품 multi · 줄 색 점 PickMenuItem.color · 끔 줄 없음) · 선 색 --draw-map-rec-1~4 · 예정노선 가이드 선은 켬끔 한 줄
- 켠 동안 등고선 띠 = LAS 는 자르지 않음 · 도엽은 계획선 + 예정노선으로 넓힘(캔버스 · GL · 도엽 다시 받기)
- 시험 624_2 · 634_2 · 626_1 · 636_2 · 390_4 · b05_routeedit_initial 을 새 꼴로 고침 · 새 시험 756_1

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:58:28 +09:00
eomsangdonandClaude Sonnet 5.5 9e0746058e fix(751-1): 원화 꼴 흠 — M01 · M02 · 서버 글 — 공용 꼴 갈음 · 율 「4.0」 · 중간 값 · 마법사 콤마 · 서고 금액
- 파이썬 짝을 common_util/common_util_money.py 로 옮김(TS 와 같은 이름 · 서버 어디서나 불러옴)
- 시험 계산: 율 글자 「4.0 %」 → 숫자 4(서버 _pct) · 중간 값 경비 · 공구손료 등 비목 이름은 1,000 미만도 돈 꼴(870.2455 → 870)
- 기초 편집 단가 = 공용 단가 꼴 · 금액 = 소수 1 · 서고 기준 금액 · 구조물 조합 일위대가 금액 = 공용 정수 꼴(124,419.85 → 124,420)
- 마법사(확인 · 고르기 · 별칭 장부 · 엑셀 · 우리 금액): 콤마 없음 · 소수 셋 기본 · 부동소수 꼬리 걷음 · 품 비교 꼬리표 지수 꼴(1.235e+05) 걷음
- 다시 잇기 · 붙여넣기 대조 · 찾아 넣기 금액 열 끝 0 없음(22.9 원이 23 으로 숨지 않음) · 흐름 로직 텍스트 수식 비목 소계 정수
- 낡은 시험 고침: test_m02_hopyo_screen 품 비교 보통인부 0.0364 → 0.03636(수량 꼴 유효숫자 넷 규칙으로 파이썬 · TS 통일)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:54:02 +09:00
eomsangdonandClaude Opus 5.5 8a60bca9fe knowledge(요구): 분석 589-A — B05 평면 예정노선 수정 열두 가지 지금 동작 · 까닭 · 고칠 길
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 22:43:37 +09:00
eomsangdonandClaude Opus 5.5 60ff62ed3f knowledge(요구): 분석 589-B — B04 등고선이 옛 dtm 외곽 · 횡단배수 옵션 재현 안 됨(첫 열림 빈 폼 한 번)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 22:42:17 +09:00
eomsangdonandClaude Sonnet 5.5 d87c7b14fd test(740-2): M02 구조물 패널 낡은 대조 두 건 새 꼴로 — 738-1 첫 화면 갈래(단가산출 · 일위대가 · 그 밖 home) · 머리 단추 줄에 초기화 · 삭제 추가
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:35:53 +09:00
eomsangdon 5674892c8d Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-02 22:33:34 +09:00
eomsangdon 17f9585b92 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_6' into main_laptop_1 2026-11-02 22:32:33 +09:00
eomsangdonandClaude Opus 5.5 9d2b8e4021 feat(750-1): SK 셈 답 하나에 보기 넷 — 텍스트 수식 글 · 시험 계산 중간 · 일위대가 줄 모달 줄모드
- 새 `M02_Sangeun_Views` — SK 고친 줄(`_Over`) · 같은 입력 · 같은 고름 위에서 `master_text.lines` + `annotate` = `글` · 줄마다 cid · 금액 · 소계 · 계 = SK 줄 · 기준(별도계상 뺌 · 단위 환산 ÷배 · 못 셈 까닭) · `중간` = 시험 계산 중간값
- `calc_doc` — 문서 `줄모드` true = 일위대가 그 줄 셈(`line` · 별도계상 기본) · 몸 `보기` false = 덧칸 안 지음(바탕 원값 셈)
- `master_text.lines` · `master_sheet_line.annotate` — `files` 자리에 Master 도 받음(파일 묶음 길 그대로 · M01 `/text` 답 같음)
- `lineDoc` — `줄모드: true`(일위대가 줄 모달 89,299.60 = 그 줄 금액)
- 시험 `test_717_3` — lineDoc = as_doc 대조에서 `줄모드` 칸 따로 확인(서버 as_doc 은 `line` 인자로 줄 셈 · 문서 칸 없음)
- 새 지킴 `test_750_1_sk_views` — 변형 일곱 · 참조 바탕 · 줄 모달 · 보기 false

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 22:32:23 +09:00
eomsangdonandClaude Sonnet 5.5 1da41d6613 test(748-2): 낡은 시험 고침 — test_717_2b 상세 부품 길 대조(cover 열기 · hoCtx)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:23:20 +09:00
eomsangdonandClaude Sonnet 5.5 27f33a8c53 fix(751-1): 원화 꼴 흠 — B09 · 일위대가표 격자 「.0」 꼬리 걷음 · 단가 소수 셋 · 환율표 · 빈 값 · 금액 정수 꼴 공용 갈음
- 격자 단가 칸 소수 셋 · 금액 칸 소수 하나 끝 0 없음(275,790.0 → 275,790)
- B09 money() 소수 자리 있으면 끝 0 없음 · 환율표 59,020.00 → 59,020 · 단가 열 소수 셋(1,846.39 · 22.9)
- 엑셀 불러오기 대조 글 빈 값 = 빈칸(0 아님) · en-US 정수 → 공용 formatMoney · 재료 창고 금액 공용 꼴

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:20:43 +09:00
eomsangdonandClaude Sonnet 5.5 b58b01a007 feat(748-3): 텍스트 수식 바닥 노무비 · 재료비 · 경비 고정 줄(계 없음) · 비목 상자 사이 간격 = 바닥 줄 위 여유
- 새 `LogicLab_Foot` — 바닥 비목 소계 줄 한 부품(단가산출 상세 4 칸 · 텍스트 수식 3 칸 계 칸 없음) · 상자 소계는 그대로
- 비목 상자 사이 간격 = 바닥 줄 위 여유(공통 값 `--m01lab-box-gap` 12px · 두 보기)
- 줄마다 붙던 옵션 값 칩 걷음은 748-2 커밋에 함께(위 옵션 칸이 대신)
- 낡은 시험 고침: test_737 바닥 줄 부품 · 칸 수 · 상자 간격 대조

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:20:09 +09:00
eomsangdon 8270441d6f Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-02 22:18:14 +09:00
eomsangdonandClaude Sonnet 5.5 b472f200b6 feat(751-2): 원화 꼴 공용 한 벌 — common_util_money.ts · master_fmt.py 짝 · 거울 시험
- formatMoney(정수) · formatLineMoney(소수 1 · 끝 0 없음) · formatPrice(단가 소수 3 · 200 미만 유효숫자 넷) · formatNumber(수량 · 율 유효숫자 넷)
- M01 Logic_Money · Logic_Edit 는 다시 내보내기만(부르는 쪽 그대로)
- 파이썬 짝 master_fmt.py(절반 올림 · 은행가 반올림 어긋남 해소) · master_text 갈아 끼움은 받기 뒤

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:18:11 +09:00
eomsangdonandClaude Sonnet 5.5 6732842a8e feat(746-2): 고르기 창 후보 블록 줄 비교 표 · 못옮김 · 안 이은 기계 줄 M01 요소 후보 단추
- 고름 대기 칸에 후보(앞 셋)마다 줄 비교 표(줄 · 엑셀 품 · 기초 품 · 꼴 배지) · 후보 고름 칸과 동기 · 칸 글에 같음 · 품 다름 수 · 판정 · 미리 고름 그대로
- 못옮김 잇기 창 · 안 이은 줄 창에 M01 요소 후보 셋 단추(누르면 이음 고름) · 안 이은 줄 창 기초 후보 칸에는 요소 후보가 안 섞이게 가름
- 새 파일 Hopyo_Compare.ts · 새 지킴 시험 test_746_2_hopyo_compare_screen

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:17:48 +09:00
eomsangdon b2f2262b60 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-02 22:15:47 +09:00
eomsangdon f5a5bbd37e Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-02 22:15:45 +09:00
eomsangdonandClaude Opus 5.5 59a99c98a5 knowledge(요구): 요구 589 원문 기록 — B05 평면 예정노선 수정 · 도구줄 · 그리기 · 등고선 · B04 등고선 서피스 · 횡단배수 옵션
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 22:15:42 +09:00
eomsangdonandClaude Sonnet 5.5 0c0d0ce629 feat(748-2): 텍스트 수식 — 행 고르기 걷음 · 변수 이름 · 값 하나하나 누르면 줄 모달(748-1) · 줄마다 옵션 값 칩 걷음(748-3 한 조각)
- 텍스트 수식 줄 hover · 누름 고정(행 단위 선택) 걷음
- 변수 이름 식 = 변수 이름마다 낮은 단추 · 값 든 식 = 수 하나하나 낮은 단추 → 같은 줄의 상세 모달(적용 · 삭제 · 닫기)
- 변수가 시험 입력(옵션)이면 옵션 고르기 칩으로
- 공용 상세 `lineOpener` 새로 — M02 SK 상세 · M01 단가산출 기초 같은 길(M01 = 시험 고침 고리)
- 상세 보기 줄마다 붙던 옵션 값 칩(`쓰는입력`) 걷음 — 위 옵션 칸이 대신(748-3)
- 새 파일 `LogicLab_FormulaTokens`(조각 나누기 순수 셈) · 시험 test_748_2 · 낡은 시험 고침 test_722(inputChip 줄 걷음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:15:33 +09:00
eomsangdon 09f241b2b4 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_3 2026-11-02 22:14:16 +09:00
eomsangdonandClaude Opus 5.5 bc34df27ec knowledge(요구): 분석 588-2 — 원화 표기 · 품셈 1-2-2 금액 단위표준 · 꼴 함수 한 벌
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 22:14:11 +09:00
eomsangdonandClaude Opus 5.5 e5fd227592 knowledge(요구): 분석 588-134 — 보기 넷 셈 길 둘 · 통일 · 원문 패널 · 옵션 0 줄 153 로직
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 22:13:15 +09:00
eomsangdonandClaude Sonnet 5.5 6af23d42e1 feat(748-1): 단가산출 상세 줄 모달 — 제목 오른쪽 연결 키 · [적용] [삭제] [닫기] 차례 · 요소 단가 정보 칸 걷음
- 제목 오른쪽 (연결 키 · 예 보통인부 (LB000002)) — 요소 · 고른 품목 · 부른 로직 키
- [적용] · [삭제](옛 빼기 이름 바꿈)를 모달 머리 [닫기] 왼쪽으로 · [기초 빼기] · 별도계상은 고침 칸 아래 그대로
- 고침 칸이 있는 모달은 요소 단가 정보 칸(이름 · 키 · 값 · 출처 · 마스터 보기) 걷음 — 자재품목 물가 넷 · 후보 목록 · 한 줄 풀이는 그대로
- M01 단가산출 기초 상세 모달도 같은 꼴([적용] [닫기])
- 낡은 시험 고침: test_722 모달 edit 인자(close, head)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 22:10:18 +09:00
eomsangdon 94e330a5f3 feat(748-0): 단가산출 둘째 기초 — 세부 품 M01 원문 · 머리 수량 칸 하나 · 되돌림 셋(기초 빼기 · 더한 항목 빼기 · 더한 항목 수량)
- 둘째 기초 세부 줄 품 칸 = 서버 `원문`(0.871 그대로) · 금액 · 비목은 상자 합 몫 그대로 · 세부 고침도 원문 몫(머리 수량 · 화면배 안 나눔)
- 더한 기초 머리 상자(새 `Sangeun_BaseHead`) — 기초마다 이름 · 수량(기준단위 몇 개) 칸 · [되돌림] · [기초 빼기]
- 새 `logic/M02_Sangeun_Undo` — 뺀더한줄 기억 · 되살림(열쇠 당김 · 밂) · 처음수량 되돌림 · 넣을 때 처음수량 적음
- 되살림 띠에 뺀 더한줄도 · 일위대가 줄 패치 뺀더한줄 이음(RowSk · LinePatch)
- 낡은 시험 고침: test_727 세부 고침 ÷수량 기대 → 원문 그대로 · test_717_2b 모델 인라인에 Undo 추가
- 새 시험 test_748_0_sk_undo(거울 helper_748_0)
2026-11-02 22:05:31 +09:00
eomsangdon 1c26a6f791 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_2 2026-11-02 21:49:03 +09:00
eomsangdonandClaude Opus 5.5 5cc598bdae knowledge(요구): 요구서 588 — 단가산출 상세 정리(PLAN 748 · 750~753)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 21:48:35 +09:00
eomsangdonandClaude Opus 5.5 a2da6cb069 Merge remote-tracking branch 'origin/dev' into sub_laptop_8
- 충돌 시험 셋(717-4 · 719-2 · hopyo build) = 740-1 펼침 단언 + 742-1 저장 수량 단언 합침
- 740-2 일위대가표 풍선 줄의 body(742-1 이 걷은 휘발 셈 몸) → cur

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:47:51 +09:00
eomsangdonandClaude Opus 5.5 8e54145c0e knowledge(요구): 요구 588 원문 기록 — 단가산출 상세 보기 넷 셈 통일 · 원화 표기 · 원문 보기 · 옵션 재료 · 모달 · 텍스트 수식 변수 · 간격 · 쉬러 감
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 21:47:30 +09:00
eomsangdonandClaude Opus 5.5 bf7f31fa63 fix(740-1): 747-1 겹침 맞춤 — SK 를 일위대가 줄로 펼칠 때 바탕 재료 별도계상 빈 칸 = 직접계산 적어 둠
- 까닭: 747-1 뒤 SK 셈은 바탕 재료 빈 칸 = 직접계산 · 일위대가 줄(덮개 줄 line=True)은 별도계상 기본 → 펼친 줄이 재료비를 잃음(합판거푸집 GC000211 SK 를 일위대가에 넣으면 금액 줄어듦)
- Library_Flat.sk_line: 바탕 재료 줄마다 자재표시 {별도계상: 직접계산} + 고른 두 칸 그대로
- 지킴 시험: 740-1 에 747 겹침(덮개 있음 · 없음 두 갈래 전후 금액 같음 · 고치기 전 실패 확인)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:44:49 +09:00
eomsangdon cfc070e228 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_8 2026-11-02 21:25:15 +09:00
eomsangdon 2b460854ba Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-11-02 21:18:53 +09:00
eomsangdonandClaude Opus 5.5 26b25626bd fix(747-1): M02 단가산출 바탕 재료 = M01 기초 그대로 셈 — 별도계상 빈 칸 = 직접계산
- 까닭: SK 셈이 바탕 로직 재료 줄에 일위대가 기본(별도계상 · 162-1)을 써 drop_apart 가 줄 · 재료비에서 뺌
- M02_Sangeun_Calc.resolve · run 에 line 인자 — SK = 바탕 재료 별도계상 빈 칸을 직접계산으로(고른 「별도계상」 만 밖)
- 일위대가 덮개 줄(Labor_Patch.run · Item_Sangeun._freeze_lines)은 line=True — 일위대가 기본 그대로
- 테스트1(GC000211) 재료비 1,404.81 → 36,525.06 · 계 = M01 124,419.85 · 기존 SK 17 중 GC000211 바탕 3 만 바뀜
- 새 지킴 시험 test_747_1_sk_material

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 21:15:03 +09:00
eomsangdonandClaude Opus 5.5 bcde009fb9 knowledge(요구): 요구 587 원문 기록 — 747 시험은 테스트1 로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 20:48:53 +09:00
eomsangdon a4b4564b9b Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-02 20:46:35 +09:00
eomsangdon 8436fc81eb Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7 2026-11-02 20:46:23 +09:00
eomsangdon 3036ae6f90 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7 2026-11-02 20:46:22 +09:00
eomsangdon 54551a6bf9 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_7 2026-11-02 20:46:21 +09:00
eomsangdon b50ba90dba Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_7 2026-11-02 20:46:20 +09:00
eomsangdonandClaude Opus 5.5 6fe66e51e4 knowledge(요구): 요구 586 원문 기록 — M02 단가산출 재료비 수량 빔(테스트1 · 6-3-1 합판거푸집)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 20:45:56 +09:00
eomsangdonandClaude Sonnet 5.5 52c8692fdd feat(738-2 · 740-2): 단가산출 · 일위대가 상세 머리 [초기화] [삭제] · 넣은 줄 출처 꼬리표
- 머리 단추 차례 … [새로고침] [초기화] [삭제] [저장] — 새 Main_Actions(쓰기 권한 있는 층만 보임)
- [삭제] = 735 경고 창(쓰는 곳 표 · 키) → 나무 지우기 → 목록으로 · 쓰는 곳 없음 = 한 줄 물음
- [초기화] = 서버 -reset 미리 보기(걷힐 고침 수 · 뺀 줄 되살림 수) → 물음 → 그 층에 씀 → 다시 그림 ·
  저장 안 한 고침은 먼저 물음 · 옵션 · 기본정보 그대로
- 출처 꼬리표(본뜬) — 순수 logic/M02_Born_Tag · 단가산출 = 이름 옆 글 꼬리표(공용 Ho 꼬리표 칸을
  이름 줄 안으로 · 첫 줄 보임 · 둘째 줄 풍선) · 일위대가표 격자 = 비고 칸 글 + 풍선 · 줄 표 = 이름 끝
- 시험: 새 test_740_2 · 낡은 시험 4 고침(test_727 · 717_2b · 704_3 · 704_4 — 모델 · 격자 파일을
  이어 붙여 도는 시험이라 새 꼬리표 글 파일을 목록에 더함)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:36:15 +09:00
eomsangdonandClaude Opus 5.5 82037faeaa feat(743-1): 불러오기 엑셀 보관 안 함 — 그 불러오기 동안만 · 끝내기 · 화면 떠남 · 하루 지남 지움 · 같은 엑셀 다시 올림 · 만든 문서 엑셀 사본 걷음
- 끝냄 한 길 M02_Hopyo_Forget.finish — HP · 격자 · 맞춤셈 캐시 지움 · 만든 SC · SK 본뜬 = 출처(파일 · 때 · 누가 + 전국자재 · 복사원본)만 · SC 줄 메모 「실무 …」 걷음 · 실무수량 = 저장 수량.손 과 같을 때만 걷음(742 전 저장본은 남김 = 금액 그대로) · SK 또 = 그 HP 몫만
- 같은 엑셀 다시 올림 409 걷음(끝낸 기록 지문 안 씀 · 키 번은 그대로 이어감)
- 하루 지난 HP = 목록 숨김 · 올림 때 끝냄 · 붙여넣기 만듦 뒤 실무자료 끝냄
- 마법사 떠남(다른 화면 · 페이지 닫음) = 이 화면이 쥔 파일 지움(keepalive DELETE)
- 뒤 셈이 끝낸 HP 캐시를 다시 쓰지 않음(Fit._put) · 끝냄은 HP 먼저 지우고 캐시
- 옛 파일 정리 tidy(미리 보기 표 · --apply) — 옛 HP · 지문 기록 · HP 없는 문서 사본
- 본뜬 곳 칸 = 끝낸 실무자료는 「실무자료 파일」(originText)
- 시험: 718-1 「끝낸 같은 엑셀 409」 → 「다시 올림 새 키」 · 705-2 붙여넣기 실무자료 「남음」 → 「지움」(요구 584-2 로 뜻 바뀜) · 372-2 화면 시험 onDeleted 꼴 바뀜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:22:42 +09:00
eomsangdonandClaude Sonnet 5.5 8596d383ec feat(745-1): 고르기 창 미리 고름 점 — 「다른 점」 1위 + 출처 같음만 · 누르기는 사용자 · 낡은 시험 하나 고침
- 서버 후보 묶음(`_grouped`)에 `미리` 칸 — 판정 「다른 점」 1위가 엑셀 인용 조항 = 기초 조항이면 · 「후보」 줄 점 없음
- 고르기 창 후보 줄 앞에 점 + 「미리 고름」 글 · 체크 · 선택은 그대로 비어 있음
- 북부청 표본(임도 · 사방) 다른 점 28 줄 전부 점 · 1위 맞음 28/28
- 시험 — 점 규칙 한 건 추가(test_m02_hopyo_match) · test_m02_precombo_scope 낡음 고침(735 뒤 삭제 확인이 askLibraryDelete 로 바뀜)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 20:18:55 +09:00
eomsangdonandClaude Opus 5.5 6893599fa9 feat(740-1): 일위대가 안 일위대가 = 사본 블록(원본 연결 끊음 · 하위 호표 끝수 그대로) · 초기화 이음 기계 다시 받음
- SC 안 SC = 안쪽 SC 몸 통째를 그 줄 참조 꼴 {층: 사본, 주인: 몸 지문, 키: 본 SC, 몸} 에 담음(Labor_Ref.copy_ref · read_ref 가 몸을 돌려줌 → 셈 · 알약 · B09 하위 대가 블록 · 지문 = 옛 참조 줄과 같은 길) · 엑셀 대조 거창 같음 14 그대로 · 같은 기초 겹침도 블록 안(400 없음)
- 저장 · 읽기 · 옮기기 · 찾아 넣기 모두 사본 블록 · 자기 자신을 담은 줄 = 안 펼침(저장 422 그대로)
- 단가산출에 SC 넣기 = 사본 블록 줄도 더한줄로 펼침(바깥 × 안 수량)
- 초기화 = 사본 몸 안까지 · 이음 기계 고정 줄(736-3) 단가 = M01 기계 한 시간 값 다시 받음
- 옮기기 = 쓰는 곳 검사 걷음(같은 자리 한 줄이라 안 줄 알약 차례 그대로) · 실 storage 보기만 = 1/3 SC-000002 사본 블록 · 전후 1,446,078 같음
- 낡은 시험 고침(까닭 = 하위 호표 참조가 사본 블록으로): m02_labor_ref(순환 대신 사본 · 복사 딸림 없음) · 733-2(딸림 재사용 = 구조물 조합 복사) · 735-1(쓰는 곳 = 구조물 조합 둘) · hopyo_build · 707-7(하위 호표 줄 = 사본 블록 · 본뜬)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:17:17 +09:00
eomsangdonandClaude Opus 5.5 6c0211af1f feat(744-1): 둘째 기초 세부 원문 품 · 되돌리기 기억(처음수량 · 뺀더한줄) — 서버
- 둘째 기초 펼침 줄 덧칸 원문 {수량, 금액, 비목} = 그 기초 1 기준단위 몫(M01 원문 품 · 머리 수량 안 곱함) · 줄 수량 · 금액 = 머리 수량 곱한 몫(상자 합) 그대로
- 더한줄 항목 처음수량 = 넣을 때 수량(없으면 저장 때 지금 수량) · SK · 일위대가 줄 패치 모두
- 뺀더한줄 [{차례, 줄}] = [기초 빼기] · 더한 항목 빼기 기억(셈 안 함 · 못 읽는 줄 버림) · 일위대가 줄 패치도
- 733 지문에서 처음수량 · 뺀더한줄 뺌 · Labor_Patch.as_doc = 셈 칸(CALC)만(화면 짝 꼴 그대로)
- 이음 = PLAN 744 아래 줄(화면 = sub2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:17:06 +09:00
eomsangdonandClaude Opus 5.5 c0319a4f58 feat(746-1): 불러오기 고름 대기 후보 블록 줄비교 · 못옮김 · 기계 줄 M01 요소 후보 · 단위 별칭 hr = 시간 · 개소 = 개
- 고름 대기 후보 앞 셋에 그 기초와 줄마다 견준 `줄비교`(같음 · 품다름 · 엑셀만 · 기초만 · 1 단위 품 · 못옮김) — 고르면 만드는 SK 와 같은 셈(`_Plan` 기록) · 판정 · 순위 · 미리 고름 그대로
- 못옮김 `후보` · 안 이은 기계 줄 `남은줄[].후보` = M01 요소 셋(새 `M02_Hopyo_Elem` — 이름 · 규격 점수 › 엑셀 단가 가까운 차례)
- 단위 모듈 짝(master_unit.py ↔ master_unit_swap.ts) 별칭 hr = 시간 · 개수 묶음에 개소 — 거울 시험 사례 셋 더함
- 실측(여덟 파일): 고름 대기 89 중 1위 줄비교 72(셋 중 하나 82) · 골든 1위 163 그대로 · 판정 수 그대로 · 개 ↔ 개소 1 블록 견줌 풀림 · 못옮김 후보 8/9 · 기계 줄 후보 32/32 · M01 로직 3,992 값 전후 같음
- 새 지킴 시험 test_746_1_hopyo_compare

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:04:48 +09:00
eomsangdonandClaude Opus 5.5 b68a246602 feat(742-1): 일위대가 안 줄 수량 = 사용자 입력 저장 · 기준단위 단가 / 수량 반영 금액 열
- 723-1 「안 줄 수량 null」 걷음 — SC 줄 · 자재 수량 {손} 저장(새 줄 1 · 빈 칸 = 「수량 입력」 · 금액 빠짐)
- 셈 차례: 층 = 알약 값 → 저장 수량 · 공사 굳힌 사본 = 굳힌 손 → 알약 → 저장(with_pills 가 {저장} 칸으로 옮김)
- 불러오기로 만든 일위대가 = 엑셀 수량을 저장 수량으로(본뜬.실무수량 같은 값 남김)
- 단가산출 안 일위대가 = 저장 수량으로 금액(수량 없음 없어짐) · B08 자재 · B09 내 자료집 저장도 같은 차례
- 일위대가표 수량 칸 = 문서에 저장(휘발 임시 칸 걷음) · 합계 열 「기준단위 단가」 · 「수량 반영 금액」
- 낡은 시험(723 · 175 · 204-2 · 702-3 · 703 · 717-4 · 719-1 · 719-2 · hopyo build · flow · labor ref)의 null 단언을
  저장 수량 단언으로 고침 — 723-1 결정이 742-1(요구 584-1)로 바뀜 · 새 지킴 시험 test_742_1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 20:03:48 +09:00
eomsangdon 25b3a465d7 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-11-02 19:51:10 +09:00
eomsangdon 7a2dc068be Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-02 19:49:09 +09:00
eomsangdon 57142f4963 Merge remote-tracking branch 'origin/dev' into sub_laptop_3 2026-11-02 19:48:03 +09:00
eomsangdonandClaude Opus 5.5 4c8a06559a feat(740-1): 단가산출 · 일위대가 서로 독립 — 넣기 펼침(M01 키 줄 + 덮개 사본 · 본뜬) · 초기화 길 · 옮기기 함수 · B09 덮개 줄 굳힘
- 새 M02_Library_Flat: SK 에 SK = 더한줄 로직 한 줄에 원본 몸 통째(덮개 · 뺀줄 · 고름 · 더한줄 · % 덧줄까지 같은 금액) · SC 에 SK = 로직 줄 + 패치 · SK 에 SC = 줄마다 더한줄(수량 = 저장 수량.손 › 실무수량 › 1) · 바탕 SK · 패치 SK 참조 = 덮개 겹침 · 줄 본뜬 {층, 주인, 키, 이름, 때}
- 저장 · 읽기 · 층 복사 때 서버가 펼침(못 펼친 줄 = 옛 참조 그대로) · SC 안 SC(하위 호표)는 저장 · 읽기에서 참조 그대로(엑셀 하위 호표 끝수 · B09 하위 대가) · 찾아 넣기 길만 펴 넣음
- 길: POST {kind}-flat(넣을 줄 · 못 펼침 400) · POST {kind}-reset(미리 보기 걷힐 수 · 씀)
- 초기화 = 덮개 · 뺀줄 · 더한줄 단가 걷음(안쪽 넣은 몸까지) · 항목 · 수량 · 입력값 · 고름 남김
- 옮기기 = migrate(apply) · 전후 1 단위 금액 같을 때만 씀 · 공유 storage 는 브레인
- 둘째 기초 세부 셈 = 넣은 SK 몸이면 이름 없는 SK 로(Sangeun_Calc.sub_run)
- B09: SC 사본 덮개 줄 = 굳힌 줄(굳힌 {키, 줄 …}) → 사본 셈 · 대가 블록이 덮개를 읽음(옛 산근 길은 덮개 무시 · 실측 6,451 대 M02 210,866)
- 733 지문 = 옛 참조 줄 펼친 꼴로 대조
- 낡은 시험 고침(까닭 = 단가산출이 낀 참조가 이제 펼쳐 담김): 715 store(바탕 · 사전조합 SK · 층 복사) · 717-2a 목록 바탕 · 717-4 SC 안 SK · 719-1 원본판(원본 고쳐도 그대로) · 719-2 속 일위대가(빠진 줄 없음 · 쓰지 않는 _calc_given 지움) · 725-1 SK 안 SC · 729 SK 줄 본뜬 · 733-3 쓰는 곳 = 구조물 조합(SK 안 SK 참조 없음) · 572 지문 대조 대상

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 19:47:33 +09:00
eomsangdonandClaude Opus 5.5 2fb39bd50f knowledge(요구): 분석 585 — 불러오기 항목별 맞춤 검토(지금도 줄마다 · 요소로 기초 거꾸로 찾기는 나빠짐 · 후보 블록 줄 비교 넓힘 권함)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 19:29:51 +09:00
eomsangdon 62330320c1 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-02 19:10:37 +09:00
eomsangdonandClaude Opus 5.5 005640da3d knowledge(요구): 요구 585 원문 기록 — 불러오기 맞춤 = 일위대가 항목 하나하나를 M01 요소 · 기초와 견줌 아이디어
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 19:10:33 +09:00
eomsangdon c61fe32c2c Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into sub_laptop_8 2026-11-02 19:09:52 +09:00
eomsangdonandClaude Opus 5.5 7eb176eb00 knowledge(요구): 분석 582 둘 — 수량 · 휘발 · 비고 · 경쟁사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 19:09:05 +09:00
eomsangdonandClaude Opus 5.5 f23fab697e knowledge(요구): 요구 584 원문 기록 — 일위대가 안 줄 수량 = 사용자 입력 · 불러온 엑셀 보관 안 함 · 미리 고름 다른 점만 · 강관비계 그대로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 19:09:04 +09:00
eomsangdonandClaude Sonnet 5.5 afb9710423 feat(741-1): 불러오기 엑셀 보기 열 폭 — xlsx 파일 열 폭 · 폭 없는 열은 글 길이로 · 머리 경계 끌기 · 두 번 눌러 맞춤
- 서버: xlsx 시트 XML 열 폭 · 숨김을 보냄(엑셀 보기 격자 `열`)
- 화면: 폭 없는 열은 글 길이로 처음 폭(최소 6 · 최대 60 · 여러 열 병합 제목 칸은 뺌)
- 읽기 전용 격자도 열 폭 끌기 · 경계 두 번 누름 맞춤(보기만 · 저장 · 되돌리기 · onChange 없음)
- 열 경계 오른쪽 반에서도 경계로 읽음 · 단가 카드 미리보기는 사본으로 보임
- 시험: test_741_sheet_widths · test_stage2_autofit(esbuild 묶어 node)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 18:56:36 +09:00
eomsangdonandClaude Opus 5.5 78be3966d9 knowledge(요구): 요구 583 원문 기록 — 조사 끝나면 결정 사항 한 번에
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 18:55:16 +09:00
eomsangdonandClaude Sonnet 5.5 5e2ac2ae79 feat(736-3): 단가산출 · 일위대가 [항목 추가] 기계(M01 EQ) 고름 — 고정 줄(이음 기계 · 단가 = 한 시간 노 · 재 · 경)
- 찾아 넣기 창 기계 줄 누름 = 서버 `/logic-lib/machine-hourly` 로 한 시간 값을 받아 고정 줄 단가로
- 일위대가 = 이음 기계 고정 줄 · 단가산출 = 더한줄 고정 줄(수량 1) — 같은 금액
- 서버 `M02_Machine_Hourly`(기계경비 로직 짝 차례 첫째 · 읽기만) · 시험 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 18:44:12 +09:00
eomsangdonandClaude Opus 5.5 b1b9794946 feat(739-1): 별칭 장부 중앙 관리 — 자동 짝 = 마스터(승인) · 이 불러오기에서 이은 짝만 · 회사 · 개인 층 짝 = 내 제안
- 장부 읽는 차례: 자동 정확 열쇠(마스터 · 같은 불러오기 `불러오기` 표시) → 제안 정확 열쇠(`제안` True · 자동 아님) → 품명|단위 느슨
- 만들기 · 단가산출 만들기가 이은 짝에 불러오기 열쇠(`doc_id` 층/주인/HP 키) 남김 · 장부 화면 고침은 그 표시 그대로 둠
- 줄 맞춤: 내 제안 = 숫자 판정 그대로 + 꼬리표 「내 제안」 + `제안`(고르기 창 맨 위 · 한 번 눌러 짝 통째) · 호표 통째 제안 = 체크 안 함
- 장부 화면(회사 · 개인 층) = 「내 제안 목록」 · 승인 칸(대기 · 승인 · 반려 = M01 서명 · 반려 기억) · [삭제] → [거두기] · 마스터 층(M01) 그대로
- M01 별칭 장부 모으기 · 승인 · 반려 길 그대로 · 기존 층 장부 파일 안 지움(제안으로 읽힘)
- 낡은 시험 고침: hopyo ledger(아래 층이 이김 → 마스터만 자동 · 제안 · 승인 상태) · hopyo match(별칭 자동 규칙 = 마스터 층 장부로) · hopyo screen(머리 글 · 거두기 단추)
- 새 시험 test_739_1_alias_central(층 짝 자동 0 · 내 제안 · 이은 짝 → 제안 · M01 후보 · 승인 뒤 자동 · 반려 상태)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 18:40:45 +09:00
eomsangdon aaea39e23f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-02 18:39:44 +09:00
eomsangdonandClaude Opus 5.5 d46fafd1ba knowledge(요구): 요구 582 원문 기록 — 단가산출 기초 수량 원복 · 불러오기 수량 뜻 다시 · 비고 열 · 불러온 자료 휘발 · 경쟁사 REA 분석 · 엑셀 열 폭
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 18:39:41 +09:00
eomsangdon aa26860358 Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_6' into sub_laptop_3 2026-11-02 18:32:24 +09:00
eomsangdonandClaude Opus 5.5 14b0b4ae7d knowledge(요구): 요구 581 원문 기록 · 분석 579-2 — 단가산출 · 일위대가 서로 독립 · 연결은 M01 키만 · 초기화 = M01 키 값으로
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 18:31:35 +09:00
eomsangdonandClaude Sonnet 5.5 10984109b4 feat(737): 단가산출 상세 옵션 6 열 격자 · 합계 한 줄 바닥 고정 · 텍스트 수식 비목 상자 · 두 보기 같은 제목
- 옵션 칸 = 상자 폭 기준 1 → 2 → 3 → 4 → 6 열(calc_sheet optionGrid · container query) · 공통 옵션 따로 상자 걷고 모든 상자 맨 위 옵션 칸에 같이
- 맨 아래 노무비 · 재료비 · 경비 · 계 = 한 줄 4 칸 · 화면(모달) 바닥 sticky · 메인 칸 overflow clip(굴림 칸이면 안 붙음)
- 텍스트 수식 = 비목 구분 상자 · 상자 위 옵션 칸 · 줄마다 붙던 옵션 꼬리표 걷음
- 두 보기 상자 제목 「단가산출 상세」 · 「지금 입력」 꼬리표 걷음
- 신규 지킴 시험 test_737_option_grid_foot

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 18:25:09 +09:00
eomsangdonandClaude Sonnet 5.5 baeed9d18e feat(738-1): M02 들어올 때 단가산출 · 일위대가 = 그 컨테이너 기본 층(개인) 목록표 · 구조물 조합 길 그대로
시험 test_738_m02_entry_personal 추가 · test_m02_precombo_scope 한 건(삭제 확인 글 찾기)은 이 일과 무관한 기존 실패

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 18:13:23 +09:00
eomsangdonandClaude Opus 5.5 36f24abf8a knowledge(요구): 요구 580 원문 기록 — 별칭 장부 중앙 관리 진행 · 앞으로 조사 뒤 바로 작업 · 고를 것만 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 18:08:47 +09:00
eomsangdonandClaude Opus 5.5 88815881da knowledge(요구): 요구 579 원문 기록 — M02 열기 기본 개인 층 · 검색 추가 = 출처 기억 + 수정 가능 사본 · 단가산출 상세 삭제 단추
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 18:06:03 +09:00
eomsangdonandClaude Opus 5.5 7f0472e964 knowledge(요구): 요구 578 원문 기록 — 상자 제목 단가산출 상세 · 입력 꼬리표 걷음 · 공통 옵션 구분 상자에 같이
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 17:56:29 +09:00
eomsangdon 0ae48d0a89 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-02 17:53:39 +09:00
eomsangdonandClaude Opus 5.5 3830afc7be knowledge(요구): 요구 577 원문 기록 — 텍스트 수식 보기 구분 상자 · 상단 옵션 6 열 · 항목별 옵션 꼬리표 걷음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 17:53:32 +09:00
eomsangdonandClaude Opus 5.5 56204ae91a knowledge(요구): 요구 576 원문 기록 — 단가산출 상세 옵션 6 열 · 합계 한 줄 바닥 고정
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 17:50:37 +09:00
eomsangdonandClaude Sonnet 5.5 bc479bb3d0 feat(736-2): 빠진 길 둘 — 찾아 넣기 창 [신규 자재](개인 층) · 기계 탭 · 엑셀 연결 줄 갈래대로 탭
- 개인 층 재료 고름 = 창 머리 [신규 자재] — 만들어 바로 자재 목록에 넣음(`openNewMaterial`)
- 찾아 넣기 창 기계(M01 EQ) 탭 — 「M01 전체」 묶음 안 · 부르는 쪽이 `machine` 을 줄 때만 보임
- 엑셀 연결 = 줄 갈래대로 탭(노임 = 인력 · 기계 = 기계 · 자재 = 재료 · 그 밖 = 기초) — 불러오기 고르기 · 남은 줄 · 못옮김 · 안 이은 줄 연결 모두 같은 창 · 따로 [기계] 단추 걷음
- 낡은 시험 고침(721 · 729-3: 기계 고름 길이 찾아 넣기 창으로 옮김) + 736-2 글 시험 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 17:42:05 +09:00
eomsangdonandClaude Sonnet 5.5 f97ea96c08 feat(736-1): 일위대가 [항목 추가] 하나 · 찾아 넣기 창 하나로 — 자재 추가 단추 걷음 · 엑셀 연결 · 별칭 장부 · 견본
- 일위대가표 · 구조물 조합 상세 [로직 추가] → [항목 추가] · [자재 추가] 걷음 · 재료 고름 = 자재 목록(로직 안 자재 · 이미 있는 자재 막힘 그대로)
- 찾아 넣기 창 탭 막기(kinds) · 찾기 칸 미리 넣기(query) · 재료 길(material)
- 불러오기 엑셀 연결 = 「다른 항목 검색…」 한 창(기초 · 재료) · 별칭 장부 = 기초 · 재료 둘
- 부품 모음 단추 견본에 갈래 색(옵션값 · 요소 연결) 칸
- 낡은 시험 고침: 175(머리 단추) · 721(부르는 곳) · 자재 개인(상세 단추) · 호표 화면(장부 단추 글) · 헬퍼 넷(Find_Pick 대역)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 16:50:34 +09:00
eomsangdonandClaude Opus 5.5 f82e64ce29 knowledge(요구): 요구 575 원문 기록 — 답 다섯(찾기 창 하나로 동의 · 랩탑메인 그대로 · 보기 넷 유지 · lidar_lab 지움 · 견본 물음 모름)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHFZkNcUa1SR4cNcENM
2026-11-02 16:14:27 +09:00
eomsangdonandClaude Opus 5.5 5b9a1e8720 knowledge(계획): PLAN 734 · 735 이관 — 계산식 열 단추 · 일위대가 삭제 경고 끝
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:53:51 +09:00
eomsangdonandClaude Sonnet 5.5 47af68b6c4 fix(ui-kit): 지도 도구줄 · 범위 바 부품에 숨김 짝 규칙 더함
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 15:53:13 +09:00
eomsangdon 1bdd3ce99a Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-02 15:51:39 +09:00
eomsangdonandClaude Opus 5.5 1d8642fd76 feat(735-1): 서고 나무 지우기 — 쓰는 곳 경고 표 뒤 그 줄째 지움 · 되돌림
- 새 M02_Library_Delete: 쓰는 줄 미리 보기(종류 · 이름 · 키 · 줄 · 층) · 권한 밖 409 막힘 목록 ·
  확인 몸이면 쓰는 문서의 그 줄 지움(store.write 정본 다시 셈) 뒤 문서 지움 · _합친것/ 기록
  (지운 문서 · 자리 · 고친 문서 앞판) · 되돌림(그새 고친 문서는 건너뜀) · 단가산출 바탕 · 더한줄 열쇠 당김
- 미리 보기 길 POST {kind}-tree/delete-check · undo 길이 지움 기록도 되돌림 · 합친 것 목록에 「지움」
- 나무 고침 몸 확인 칸 · 세션을 나무 고침에 넘김 · 옛 _refuse_used 걷음
- 화면: 지우기 앞 경고 창(쓰는 곳 N · 표 · 프로젝트 사본 영향 없음 · 되돌림 자리) · 부품 상자
  확인창에 detail 칸(넓은 판 · 안 스크롤)
- 시험: 새 test_735_1 · test_716_4 의 쓰는 곳 409 시험을 확인 없음 409 + 목록 꼴로 고침
  (지움이 막힘에서 경고 뒤 지움으로 바뀜)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:50:56 +09:00
eomsangdon e4f4dc2b33 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-02 15:40:35 +09:00
eomsangdonandClaude Sonnet 5.5 8cd30cc64d feat(734-1): 계산식 열 단추 낮은 단(20) · 옵션값 / 요소 연결 갈래 색 · 풍선 글
- 부품 상자 단추에 갈래 색(tone: option · link) 더함 — 공통 값(--color-warning · --color-info) 혼합 · 밝음 4.5 이상 · 어두움 6 이상
- 알약(요소 연결) · 입력 칩(옵션값) 24 → 20 · 마우스 풍선 「요소 연결」 · 「옵션」
- 지킴 시험 test_734_calc_buttons 새로

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 15:40:02 +09:00
eomsangdonandClaude Opus 5.5 bc9dcb865a knowledge(요구): 요구 574 원문 기록 — 일위대가 나무 삭제 경고 뒤 삭제 · 참조 줄 지움 · 4173 은 사용자 요청 때만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:32:15 +09:00
eomsangdonandClaude Opus 5.5 1e8d05cea1 knowledge(요구): 요구 573 원문 기록 — 단가산출 상세 계산식 열 단추 낮게 · 색 구분
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 15:27:38 +09:00
eomsangdonandClaude Opus 5.5 a72673cd1c knowledge(계획): PLAN 730~733 이관 — 엑셀 보기 테두리 · 모바일 · 일위대가 줄 삭제 · 실무문서 칸 걷음 · 중복 확인 끝
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:18:30 +09:00
eomsangdon 91137a6196 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-02 14:15:24 +09:00
eomsangdonandClaude Opus 5.5 42de907aa2 feat(733-1b): 불러오기 단가산출 같은 내용 SK 씀 · 위 층 같은 것 알림 · 결과 화면 꼬리표
- M02_Hopyo_Sangeun — Maker.write 가 이 층 같은 내용 SK(M02_Library_Same 지문)가 있으면 새로 안 만들고 그것을 씀 · 이름 = 먼저 만든 것 · 뒤 출처 = 본뜬.또[그 블록 본뜬 + 이름] · made 가 또 읽음 · origin_of = 출처마다 갈래 · 못옮김 · 놓음
- 다른 출처가 쓰는 SK 내용은 안 바꿈 — 고른 기초 · 잇기로 바뀌면 떼어 냄(앞 출처면 뒤 출처에게 넘겨줌) · 혼자 쓰던 SK 만 제자리 덮음
- 응답 같은것씀 [{번, 키}] · 위층같음 [{번, 층, 주인, 키}](회사 · 마스터 · 알림만)
- M02_Hopyo_SkLink — 못옮김 · 갈래를 그 블록 출처에서 읽음(같은 내용이라도 엑셀마다 못옮김이 다름 · 금액 지킴)
- 불러오기 ② 단가산출 표 갈래 칸 꼬리표 「같은 것 씀」 · 「위 층에 같은 것 — 연결 권함」 · 요약 글에 수
- 새 시험 test_572_hopyo_same.py — 바이트만 다른 사본 새 SK 0 · 위 층 알림 · 출처마다 못옮김 · 떼어 냄 · 넘겨줌
- 낡은 시험 고침: test_718_1(만든 SK 수 = 만듦 수 가정 → 같은 내용 SK 한 벌이라 문서 수 + 같은것씀 = 만듦) · test_729(_sk_docs 가 본뜬 키만 봄 → 또 출처도 블록에 매김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 14:14:42 +09:00
eomsangdonandClaude Opus 5.5 4de5a3416b feat(733-3): 같은 내용 물음 — 호표 [구조물 조합으로]
- buildHopyoItem 에 다시 보냄 덧칸(씀 · 새로) · 물을 때는 가림막 걷음 · 그것을 씀 = 「새로 안 만듦」 알림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:58:31 +09:00
eomsangdon a42f44ec26 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-11-02 13:57:14 +09:00
eomsangdonandClaude Opus 5.5 2604a5073a feat(733-3): 같은 내용 물음 화면 — 단가산출로 저장 · 공유 승인 · B09 내 자료집
- 부품 모달에 갈래 묻기 askChoice(취소 + 단추 여럿 · 고른 값 · 취소 = null) · 부품 모음 견본 한 칸
- 새 M02_MasterTemplete_Library_SameAsk.ts — 409 같은것 → 「그것을 씀 / 그래도 새로」 → 몸 씀 · 새로 로 다시 부름
- from-line(단가산출로도 저장 · 단가산출로 저장) · 공유 승인 · B09 내 자료집에 물음 · 취소 = 저장 안 함
- 새로 만들기(나무 · 목록 [생성])는 빈 문서라 서버가 묻지 않음 — 화면 손대지 않음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:57:07 +09:00
eomsangdon 746a1f6050 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-02 13:55:08 +09:00
eomsangdonandClaude Opus 5.5 40980910fa feat(733-2): 사람 길 만들 때 같은 내용 409 물음 · 복사 딸림 재사용
- 새 M02_Library_Ask — 같은 층 같은 내용(733-1 지문) = 409 {같은것: [{키, 이름, 층, 주인}], 까닭} · 몸 새로 = 그래도 새로 · 씀 = 그 키(새 문서 0 · 같은 내용 아닌 키 = 다시 409)
- 길 = 새로 만들기(빈 문서 · 같은 층 복사 = 안 물음) · [단가산출로 저장] from-line · B09 내 자료집(같은이름 409 와 한 몸 · 하위 호표 지문 = 굳힌 문서) · 공유 요청 승인(일위대가 · 씀 = 사본 키) · 실무 호표 [구조물 조합으로](구조물 조합 지문 = 줄 수량 넣음)
- 복사 딸림 carry — 받는 층에 같은 원본 + 같은 내용 사본이 있으면 그 키 재사용(refs.copied_from) · B09 하위 호표 재사용 목록 = 실무자료 한정 → 모든 사본 원본
- 낡은 시험 고침: test_703 자료집 같은 이름 물음 — 새 이름이라도 같은 내용이면 409(정함 3장 3) → new=True 로 새로 · 씀 확인 더함
- 새 시험 test_733_2_library_ask(새로 · from-line · 승인 · carry) · test_705_2 구조물 조합으로 409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:54:28 +09:00
eomsangdonandClaude Opus 5.5 320db86802 fix(733-3): 중복 찾기 창 — 꼬리표 배지 늘어남 · 합치기 단추 크기 · 그새 바뀜 토스트 글
- 묶음 머리 이름 칸 CSS 가 배지(span)까지 늘리던 것 — 이름 칸 class 로 좁힘
- [합치기] = 기본 단추 크기 · 판 다름 409 = 「그새 바뀜 — 다시 찾음」만(HTTP 409 꼬리 뺌) 뒤 다시 찾음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:53:24 +09:00
eomsangdonandClaude Opus 5.5 b9e6f77dab feat(733-3): 서고 중복 찾기 · 합치기 서버 등록 · 지문 모듈 연결 · 시험
- M02_Library_Same_Router — 733-1a 지문(Same 한 벌 · above) 으로 묶음 · 합칠 때 지문 다시 셈(다르면 409)
- main.py 에 라우터 등록(로그인만 · 층 쓰기 권한은 길 안에서)
- 새 시험 test_733_3_library_same_merge(합치기 참조 옮김 · 금액 같음 · 되돌림 · 지문 · 판 409 · 권한 밖 남김 · 위 층 키 · 일반 사용자 회사 층 403 · SC 수량만 다름)
- 새 시험 test_733_3_library_same_screen(부품만 · 날 색 없음 · 나무 메뉴 · 서버 길 짝)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:44:00 +09:00
eomsangdon 16c7b2c782 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-11-02 13:40:03 +09:00
eomsangdonandClaude Opus 5.5 6cf57dc289 feat(733-3): 서고 중복 찾기 틀 — 서버 길(목록 · 합치기 · 되돌림) · 화면 창 · 나무 메뉴 (등록 전)
- 새 M02_Library_Same_Router.py — GET {kind}-same · POST merge · POST undo (main.py 등록 · 시험은 다음 걸음)
- 새 M02_MasterTemplete_Library_Same.ts · .css — 묶음 표 · 남김 고르기 · 합치기 물음 · 합친 것 되돌림
- 서고 나무 뿌리 메뉴 [중복 찾기]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:39:47 +09:00
eomsangdonandClaude Opus 5.5 8588743906 feat(733-1a): 서고 문서 내용 지문 · 같은 층 · 위 층 같은 것 찾기
- 새 M02_Library_Same.py — 단가산출(SK) · 일위대가(SC) 내용 지문(derived_key) · 이름 · 본뜬 · 메모 · 바탕판 · 원값 · 고정 출처 뺌 · 수 · 단위 정규화 · 줄 차례 무시 · SC 수량 null(비율 줄 율만) · 빈 문서 None · 참조 = 가리킨 문서 지문
- Same 한 벌 = 한 요청 안 메모 · find_same(folder, kind, doc, skip) = 같은 층 키 · find_above = 위 층 참조
- 새 시험 test_572_library_same.py — 지문 규칙 · 같은 층 · 위 층 · 참조 지문

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:38:07 +09:00
eomsangdonandClaude Opus 5.5 8c5ef34365 knowledge(요구): 요구서 572 · 분석 — M02 단가산출 · 일위대가 중복 확인(PLAN 733)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:29:33 +09:00
eomsangdon 3449a434da Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_5' into main_laptop_1 2026-11-02 13:24:26 +09:00
eomsangdonandClaude Sonnet 5.5 d386198f7f fix(730-2): 엑셀 보기 좁은 폭(900 px 이하)에서 시트 위 · 목록 아래로 쌓음
- 390 px 에서 오른쪽 목록 22rem 이 시트 칸을 폭 0 으로 짓누름 → 좁은 폭에서 한 열 · 두 줄(시트 3 : 목록 2)
- 넓은 폭(1366 · 1920) 생김 그대로
- 지킴 시험 test_730_2_hopyo_sheet_narrow.py 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:22:59 +09:00
eomsangdonandClaude Sonnet 5.5 5f8f909b65 fix(731-1): 일위대가표 줄 끝 ⋯ 단추 — 우클릭 · 길게 누름 없이도 위로 · 아래로 · 별칭 · 삭제
- 마우스 우클릭 · 길게 누름 삭제는 개인 · 마스터 층 모두 이미 되었음(재현 0) — 삭제 길이 눈에 안 보이는 것이 까닭
- 공용 표(ui_template_data_table)에 rowMenu 옵션 추가(첫 칸 오른쪽 ⋯ · 누르면 줄 누름 없이 메뉴만) · 일위대가표가 같은 메뉴를 연결
- 지킴 시험 test_ui_data_table 에 rowMenu 한 건

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:19:34 +09:00
eomsangdon 5b3ffe5089 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-02 13:17:54 +09:00
eomsangdonandClaude Sonnet 5.5 e1b17a523c feat(732-1): M02 실무문서 칸 · 복사 탭 · 서버 길 걷음
- 왼쪽 「실무문서」 칸(Practice.ts) · 일위대가 가져오기 「실무문서」 탭 · 복사 원본 고르기 · 낱 CSS · locale 열쇠 걷음
- 서버 `from_layer=practice`(_copy_practice) · `DELETE /api/m02/practice`(drop_practice) · 조합 목록 · 키 하나 읽기 길 걷음 · 조합 길 시스템 관리자 문 걷음(담을 로직 읽기는 그대로)
- 옛 마스터 조합 파일 `일위대가조합.json`(UA 37) = 쓰는 곳 있음(구조물집계표 일위대가 칸 · labor 본뜬키 · 프로젝트 초기값 사본 · 불러오기 맞춤 씨앗) → 파일 · 읽기 길 유지 · 지우지 않음
- 낡은 시험: test_m02_practice 지움(화면 · 길 없음) · 조합 길 읽던 시험(combo · combo_inputs · combo_layers)은 저장소 함수로 · 실무문서 복사 시험(labor_copy · pair_copy) 줄 지움 · 탭 · 차례 단정(layer_screen · structure_options · structure_panel · 720 · precombo_scope · ui_loading) 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:17:24 +09:00
eomsangdon 934c5c730d Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-02 13:08:10 +09:00
eomsangdonandClaude Sonnet 5.5 fe53e0c71d fix(730-1): 엑셀 격자 고른 범위 · 참조 테두리 · 채우기 손잡이를 굴리는 영역 밖에서 자름
- 굴림 때 테두리가 머리 칸 · 모서리 · 엑셀 화면 밖으로 벗어나던 흠(요구 566)
- 테두리 층에 clip-path(고정 칸 · 굴리는 영역 크기 기준) · 손잡이는 밖이면 감춤
- 지킴 시험 test_730_grid_sel_clip 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 13:07:33 +09:00
eomsangdonandClaude Opus 5.5 e1ce919daa knowledge(요구): 요구 572 원문 기록 — 중복 확인 기능 조사 뒤 수정
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:06:58 +09:00
eomsangdonandClaude Opus 5.5 4222481cb3 knowledge(요구): 요구 571 원문 기록 — 실무문서 복사 · 생성 중복 거름 의견
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 13:01:28 +09:00
eomsangdonandClaude Opus 5.5 c7d82a6519 knowledge(요구): 요구 570 원문 기록 — 불러오기 엑셀 연결 찾기 = 전체 찾기 일원화 의견
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:59:57 +09:00
eomsangdonandClaude Opus 5.5 788b0c36f1 knowledge(요구): 요구 569 원문 기록 — M02 실무문서 컨테이너 걷음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:58:05 +09:00
eomsangdonandClaude Opus 5.5 23b9757cea knowledge(요구): 요구 568 원문 기록 — 일위대가표 로직 추가 → 항목 추가 · 자재 추가 걷음 의견
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:55:20 +09:00
eomsangdonandClaude Opus 5.5 a4b636528e knowledge(요구): 요구 567 원문 기록 — M02 일위대가 항목 삭제 · 실무문서 컨테이너 역할
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:53:53 +09:00
eomsangdonandClaude Opus 5.5 5afe82d430 knowledge(요구): 요구 566 원문 기록 — 일위대가 불러오기 엑셀 보기 고른 칸 테두리 벗어남
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:50:02 +09:00
eomsangdonandClaude Opus 5.5 7c70559374 knowledge(계획): PLAN 726~729 이관 — 단가산출 기초 이름 · 기초 넣기 · 단위 환산 · 실무문서 연결 끝
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:38:31 +09:00
eomsangdonandClaude Opus 5.5 8aba750fec knowledge(요구): 요구 565 원문 기록 — 개발 반영 뒤 랩탑 4173 다시 켬
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:36:19 +09:00
eomsangdonandClaude Opus 5.5 33b7b93537 fix(729-3): 기계 찾기 일반 사용자 403 · 일위대가표(Labor_Table) 안 이은 줄 띠 · 띠 글
- 남은 줄 · 못옮김 · [연결] 기계 찾기 = M02 로그인 길 /api/m02/logic-lib/elements(옛 M01 길은 시스템 관리자만 = 일반 사용자 403 · 0 건)
- 서고 일위대가 상세 표(Labor_Table)에도 「M01 연결 없음 n 줄」 띠 · [연결](Labor_Grid 는 구조물 조합 상세만)
- 띠 글에서 「옛 자료」 뺌(우리에 없음 고른 고정 줄도 셈)
- 시험 test_729_3 소스 확인 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:33:02 +09:00
eomsangdon 56b3247435 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_5' into main_laptop_1 2026-11-02 12:14:22 +09:00
eomsangdonandClaude Opus 5.5 0c8c31cadd fix(729-5): 별칭 체크 줄이 못옮김 남은 차이 SK 를 가리키면 남은 줄 · 만들기 응답 남은못옮김
- M02_Hopyo_SkLink.kept: 확인한 로직 줄이 차이 SK 바탕일 때 그 SK 에 못옮김이 남으면 남은 줄 {까닭, 단산, SK, 못옮김}(sk_line 과 같은 규칙) — hopyo-unlinked 에 나오고 hopyo-build 409
- M02_Hopyo_Sangeun.build: 응답에 남은못옮김 [{번, 이름, 키, 못옮김}](그대로 SK 중 못옮김 남은 것) · 잇기만 보냈는데 1위 기초가 고름 대기면 그 SK 바탕으로 다시 셈(고른 기초 SK 잇기)
- 시험: test_729 에 별칭 체크 줄 · 그대로 SK 남은못옮김 · 잇기 뒤 사라짐 · 차이 SK 참조

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 12:13:54 +09:00
eomsangdonandClaude Opus 5.5 fe78849463 feat(729-3): 단가산출 못옮김 잇기 창 · 단위 성격 다른 후보 계수 칸 · 남은 줄 표 못옮김 단추(729-4 이음)
- 새 Hopyo_Lost: 못옮김 줄마다 찾기(노임 LB · 자재 MT) · 기계(EQ) · 우리에 없음(뺌) → 캐시 m02-hopyo-lost 모두를 [잇기 만들기] 한 번에(서버가 부를 때마다 그 블록 잇기 전부로 다시 셈) · 바탕 = 그 SK 문서 바탕(로직 · 입력값 · 환산)
- 단가산출 칸: 만든 줄 표 못옮김 열 · [잇기 (n)] · 만든 줄 모아 둠 · 고름 대기 후보 계수(성격 다른 단위) = 계수 칸 → 환산 {계수} · 729-5 남은못옮김 칸 미리 받음
- 안 이은 줄 표: 못옮김 남은 SK 를 쓰는 줄(단산 · SK · 못옮김) = [못옮김 잇기 (n)]
- 시험 test_729_3_hopyo_left_screen 소스 확인 더함(계수 · 잇기 모두 · 바탕)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:57:47 +09:00
eomsangdon ee2c5b1de3 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_5' into main_laptop_1 2026-11-02 11:34:04 +09:00
eomsangdonandClaude Opus 5.5 b63d000822 fix(729-4): 단가산출 만들기 단위 성격 다름 500 걷음 · 못옮김 잇기 서버 길
- 728-2 단위 환산 뒤 [단가산출 만들기] 500 · 400(100개 ↔ ㎡ · ㎥ ↔ m2 · 본 ↔ M · 개소 ↔ m) 걷음 — 견줌 SK 배 = ratio_of(728 규칙) · 성격 다름 = 환산 계수 = 우리 품 ÷ 엑셀 품(인력 · 장비 둘 이상 같은 비일 때만) · 못 구함 = 고름 대기(까닭 「단위 성격 다름 · 계수 필요」 · 후보[].계수 칸)
- 맞음 블록도 단위 견줌(전엔 셈 없이 기초로 써서 뒤 셈에서 막힘)
- 못옮김 = [{id, 갈래 노임|기계|자재, 이름, 규격, 단위, 수량, 글, 후보}] · 응답 만듦[].못옮김 · 환산
- 몸 바탕[번] = {로직?, 입력값?, 환산?, 잇기?} · 잇기 [{id, 노임|기계|자재: 키} | {id, 뺌}] → SK 더한줄 인력 · 기계 · 재료(엑셀 수량 × 배) · 뺌 = 본뜬 놓음 · 틀린 id · 키 400
- 못옮김 남은 SK 를 쓰는 일위대가 = 남은줄(단산 · SK · 못옮김) · hopyo-build · build-all 409
- 못옮김 남은 블록 = SK(같지 않음)
- 시험 test_729 에 단위 · 잇기 · 409 시험 하나 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:33:31 +09:00
eomsangdonandClaude Opus 5.5 1e6ec26dd0 feat(729-3): 불러오기 마법사 안 이은 줄 고르기 · [끝내기] 막음 · 단가산출 단계 · 일위대가표 띠 · 목록 열
- 새 Hopyo_Left: 안 만든 호표마다 /hopyo-unlinked 남은 줄 표 · 줄마다 고름(고르기 창 · 찾기 = 기초 · 노임 · 자재 · 기계 EQ · % 밑수 · 우리에 없음 · 고름 대기 후보 → SK 만들기) = 확인 창 캐시 → [저장] · 생성 몸 그대로
- 마법사: 단가산출 칸(새 Hopyo_WizardSk — 기초 · SK 차이 있음 갈래 표 · 고름 대기 후보 고름 → 고른 기초로 만들기 · 별칭 제안 + M01 별칭 장부 링크) · 안 이은 줄 띠 · [끝내기] = 다시 셈 → 남으면 막음 + 표
- 확인 창 · 엑셀 보기 창: 만들기 409 {까닭, 남은줄} → 고름 표 · [고르기 (n)] · 대상 꼴 노임 · 기계 · 비율 · % 줄 밑수 고른 것만 체크
- Api_Fetch StaleError.detail(409 몸) · 까닭 글
- 새 Labor_Unlinked: 일위대가표 「M01 연결 없음 n 줄(옛 자료)」 띠 · [연결] = 고정 줄 이음(노임 · 자재 · 기계)만 · 목록 열 「안 이은 줄」
- 시험: 새 test_729_3_hopyo_left_screen(409 → 남은 줄 · 만든 호표 안 물음 · 고름 → 몸 · 0 · 띠 줄 · 소스) · 낡은 시험 고침 — test_m02_hopyo_screen(마법사 /sangeun 부름이 단가산출 칸으로 옮김) · 헬퍼 셋(새 Hopyo_Left 를 대역 — DOM · 찾기 창)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 11:25:45 +09:00
eomsangdon a70539c161 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_5 2026-11-02 10:58:09 +09:00
eomsangdonandClaude Opus 5.5 08ad680188 feat(728-2): SK 단위 환산 — 기초 기준단위 제안 · 바꾸면 배 · 계수 칸 · 둘째 기초 수량 처음값 · 돈 아닌 기초 뺌
- 서버 Sangeun_Calc: 배 = 바탕 기준단위 → SK 단위(master_unit.swap) · 성격 다름 = 문서 환산 {계수 | 단위중량} · 없으면 셈 · 저장 막음(까닭 글) · 「식」 · 빈 단위 = 옛 셈 그대로(일위대가 줄 모드)
- 화면 셈 답 줄 · 계 = SK 1 단위 몫(per_unit · 화면배) · 머리 띠 「100본 당」 · 「1 본 당」
- 서버 Sangeun_Doc: 환산 칸 검사(계수 양수 · 단위중량 종별 · 형상)
- B09 Labor_Source: SK 참조 배 = 같은 ratio_of(옛 바탕 앞 수 대신 · 기존 문서 같은 값)
- 화면 Sangeun_Detail: SK 단위 처음 값 = 기초 기준단위(「100본」) · 둘째 기초 수량 처음값 = 바탕 기준단위 ÷ 그 기초 기준단위(swapUnit) · 계 칸 원/SK 단위
- 새 Sangeun_Unit: 근거 글 · 환산 계수 칸 · 단위중량 표(CC000337) 고르기
- Cover_Model: 화면 품 × 화면배 = 문서 품(덮개 · 더한줄 · 세부) · 율 그대로
- Find_Pick: 돈 아닌 기초(결과단위 「원」 아님 21 개) 목록에서 뺌
- 낡은 시험 test_715_sangeun_engine(바탕 원/주 + SK ㎡) · test_727(바탕 원/㎡ + SK ㎥) = 성격 다른 단위가 이제 계수 없으면 막혀 SK 단위를 바탕 단위로 고침(금액 같음)
- 새 시험 test_728_2_sk_unit(100본 → 본 1,538.89 · ㎡ 44,791.32 · 계수 · 단위중량 · 식 · 둘째 기초 · 돈 아닌 기초 · 고침 꼴)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:52:05 +09:00
eomsangdon 5843c0343d Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-02 10:51:53 +09:00
eomsangdonandClaude Opus 5.5 68820e80f3 feat(729-2): 일위대가 불러오기 이음 — 단가산출 줄 = SK 참조 · 노임 이름 · 기계 EQ · 사용자 이음 · 남은 줄 있으면 만들기 막음
- M02_Hopyo_Build · 새 M02_Hopyo_SkLink: 확인 안 한 단가산출 줄 = 그 블록 SK 참조(없으면 그 블록만 만듦) · 확인한 로직이 차이 SK 바탕이면 SK 참조 · 남은 줄 있으면 409 {까닭, 남은줄} · 미리 보기 unlinked(쓰기 없음) · build_all strict = 남은 줄 호표 실패(붙여넣기 길은 막지 않음)
- M02_Hopyo_Fixed: Linker by_name(불러오기 길만) = 노임 이름 같은 LB · 기계 이름 · 규격 같은 EQ · 사용자 이음 force {노임|기계} · {비율} · linked()
- M02_Labor_Router: hopyo-unlinked 길 · 대상 칸 노임 · 기계 · 비율 · 단가산출 몸 바탕 · hopyo-build-all strict
- M02_Labor_Ref: 일위대가 목록 summary 안이은줄
- 시험: 새 test_729_hopyo_sk_link(북부청 기초잡석 · 철근 Type-Ⅰ · Ⅱ · 되메우기 SK 품 = 엑셀 품 · 기초 + 별칭 · 고름 대기 · 막힘 · 노임 · 기계 이음) · 낡은 시험 고침 — 718-1(모두 들여오기 = 남은 줄 호표 실패 · 단가산출 새 꼴) · hopyo_build · flow(남은 줄 「우리에 없음」 고른 뒤 만듦 · 빈 몸 = 409) · 705-3 · 705-2(모두 고정 = 막지 않는 build_all 직접)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:51:18 +09:00
eomsangdonandClaude Opus 5.5 8a0fd4d03d feat(729-1): 실무 단가산출 → 기초 · SK · 고름 대기 — 블록 맞춤 판정 → 엑셀 품 그대로 덮개 · 더한줄 · 뺀줄 · 별칭 제안
- 새 M02_Hopyo_SkPatch: 판정 맞음 = 기초(바탕 = 1위 기초 · 덮개 없음) · 다른 점 = 바탕 기초 + 인력 덮개(qty · 배율) · 엑셀만 직종 · 장비(GC000267 · GC000268 기계 옵션 찾기) 더한줄 · 우리만 줄 뺀줄 · 블록 안 자재 덮개 · 옮기지 못한 차이 = 본뜬.못옮김 · 후보 = 고름 대기
- M02_Hopyo_Sangeun: 바탕 없는 SK + 고정 한 줄 걷음 · 블록마다 기초 · SK · 고름 대기 · 고른 기초(바탕 {번: 로직}) · 별칭 장부 짝 = 기초까지만 · 맞춤 셈 전 409
- 여덟 파일 206 블록 = 기초 82 · SK 40 · 고름 대기 84 · SK 품 어긋남 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:51:04 +09:00
eomsangdon de2775d554 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-11-02 10:25:21 +09:00
eomsangdonandClaude Opus 5.5 81a7f8d801 feat(727-2 · 727-3): 둘째 기초 세부 줄 펼침 · 세부 덮개 · 뺀줄 · 출처 꼬리표 · 넣은 뒤 고른 옵션 SK 입력값 저장
- 서버 Sangeun_Doc: 더한줄 로직 = 덮개{세부 cid} · 뺀줄[세부 cid] 받음 · SK 입력값 = 바탕 입력 + 더한줄 기초 입력(727-3)
- 서버 Sangeun_Calc: 고친 둘째 기초 = 고친 기초 줄을 엔진으로 셈한 값묶음 줄(B09 굳힘도 같은 값) · 화면 셈 길만 펼침(세부 × 수량) · 뺀세부 · 바탕판 · 꼬리표(원문번호) · 저장 때 세부 원값 · 바탕판
- 화면 Cover_Model: 세부 줄 열쇠 add:#k/세부 cid · 품 = 화면 품 ÷ 수량 · 줄 빼기 · 되살림 · 기초 빼기 · 꼬리표 = 기초 둘 이상일 때만
- 화면 Sangeun_Detail: 옵션 칸 = 바탕 입력 + 더한줄 기초 입력 · 고르면 SK 입력값(열린입력만 시험 값) · M01 개정 알림에 둘째 기초 · [기초 빼기]
- 공용 상세 LogicLab_Ho: 줄 덧칸 꼬리표 배지(칸 폭 고정 · 말줄임 + 풍선 · M01 화면은 꼬리표 없음 = 전후 같음)
- 이음: 둘째 기초 수량 = 그 기초 기준단위 몇 개(처음 1) — 단위 · 배는 728
- 새 시험 test_727_sk_base_add(세부 둘 0.023 · 0.01546 · 기초 둘 계 = 서버 셈 · 덮개 · 뺀줄 · 굳힘 같은 값 · 암석작업 옵션 5,346.93) · 기존 SK 넷 금액 전후 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:24:50 +09:00
eomsangdonandClaude Opus 5.5 76ac1c499e feat(727-1): 고르기 창 기초 = 로직마다 한 줄 · [기본 로직 선택] 단추 걷음 · 첫 기초 = 바탕
- 기준 목록 옵션 줄(바꿈) 뺀 바탕 줄만 · 규격 칸 = 「옵션 N」(새 순수 함수 logic/M02_Find_Heads.ts) — 찾아 넣기 창 · 옛 창(Labor_Pick) 같이 · 옵션 줄 골라도 옵션 버리던 흠 걷음 · 서버 목록 그대로
- 단가산출 상세 [항목 추가] 기초 = 바탕 비었으면 바탕(옛 단추 동작 · 단위 비거나 식이면 기초 결과단위) · logicOnly 걷음 · locale M02_SangeunPickBase 지움
- 낡은 시험 test_721_find_pick 721-1 · 721-2 = 단추 걷음 · logicOnly 없음으로 고침 · 새 시험 test_727_sk_base_add
- 기초 줄 1,461(옛 10,053) · GF000099 한 줄 「옵션 1」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:14:07 +09:00
eomsangdon 1001676e5e Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-02 10:09:33 +09:00
eomsangdonandClaude Sonnet 5.5 54f06c8b88 feat(726-1): 화면 글 「단가산출 로직」 → 「단가산출 기초」 · 「바탕 M01 로직」 → 「바탕 단가산출 기초」
- 13 파일 화면 글 20 곳 + 바탕 3 곳 · 서버 까닭 글 M02_Labor_Doc 포함 · 열쇠 · 저장 꼴 · API 그대로
- 낡은 시험 고침: test_m02_item_list 표 머리 글 · ui_checks 넷(M01 제목 일치 글)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 10:08:27 +09:00
eomsangdonandClaude Opus 5.5 c201a1269d feat(728-1): SK 단위 바꿈 환산 순수 함수 · 파이썬 · TS 짝 · 거울 시험
- master_unit.swap · split — 같은 성격 = 배 자동 · 부피 ↔ 무게 = 단위중량 · 그 밖 = 사용자 계수 · 계수 없으면 배 없음 + 까닭 글
- A00_Common/master_unit_swap.ts swapUnit — 같은 답(단위 표 · 별칭 · 분모 읽기 옮김)
- 지킴 시험 test_728_1_unit_swap.py(거울 20 사례)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 10:08:02 +09:00
eomsangdonandClaude Opus 5.5 638d3f446d knowledge(요구): 요구서 내역 19 · 분석 둘 — 단가산출 기초 · M02 단가산출 항목 추가 · 단위 환산 · 실무문서 연결(PLAN 726~729)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 08:58:48 +09:00
eomsangdonandClaude Opus 5.5 6df8f93d91 knowledge(요구): 내역 20 원문 기록 — 기초 넣는 꼴 · 단위 환산 · 다른 단가산출 · 연결 누락 답
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 08:56:43 +09:00
eomsangdonandClaude Opus 5.5 52be8a3c07 knowledge(요구): 내역 19 원문 기록 — 단가산출 기초 이름 · M02 단가산출 항목 추가 기준단위 · 옵션 목록 · 기본 로직 선택 걷음 · 실무문서 연결 점검
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:17:39 +09:00
eomsangdonandClaude Opus 5.5 690726a387 knowledge(계획): PLAN 645 이관 — 데스크탑 검증 프로젝트 둘 새 지표면(분류 + implicit) 다시 확정 끝
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 07:05:20 +09:00
eomsangdonandClaude Opus 5.5 5639d651e3 knowledge(요구): 요구 564 원문 기록 — 데스크탑 검증 프로젝트 새 지표면
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 06:44:35 +09:00
eomsangdonandClaude Opus 5.5 d2b3f6eec8 knowledge(요구): 요구 562 · 563 원문 기록 — 전역 깃 싱크 · 측구터파기 원문 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 06:35:58 +09:00
eomsangdon da8593e17e Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_8 2026-11-02 06:26:33 +09:00
eomsangdon b737078ac2 chore(마스터): 데스크탑 검증 사용자 화면 편집 - 구조물 조합 item3_3(면적 · 노임 · 구조물) 11-01 13:20 2026-11-02 06:25:47 +09:00
eomsangdon c9bef8ce9f chore(데스크탑): 자동 커밋 346a70d9b 에 쓸려 든 사용자 config 둘 · Orca 끌어놓기 그림 둘을 추적에서 뺌 - 파일은 그대로 2026-11-02 06:25:46 +09:00
eomsangdonandClaude Opus 5.5 e3053134f4 knowledge(계획): PLAN 640~644 · 720~725 이관 — 라이다 지표면 · 내역 16 · 17 · 브레인 화면 검증 흠 모두 끝
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 00:51:03 +09:00
eomsangdon bf95ee6341 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-11-02 00:50:10 +09:00
eomsangdonandClaude Sonnet 5.5 6a429560fb fix(725-6): 바탕 없는 단가산출 공통 옵션 칸(더한줄 로직 입력 모음) · 상세 못 셈 줄 금액 「—」 + 까닭 풍선
- 바탕 없는 SK = 더한줄 로직 입력을 모아(같은 이름 하나) 공통 옵션 칸으로 보임 · 값 자리 = 시험 입력 그대로 · 바탕 있으면 지금 그대로
- 입력 바꾸면 SK 셈 바로(바탕 없음 = 시험 계산 단추 없음) · 셈 때 SK 줄 입력 아닌 이름은 거름(공통 옵션 지역이 SK 줄에서 「모르는 입력」으로 멈춤)
- 공용 상세 줄 금액 칸 = 까닭 있는 줄만 「—」 + 풍선(M01 줄은 까닭 없음 = 전후 같음)
- 시험 test_725_1 에 바탕 없음 지역 서울 5,297.37 · 화면 짜임 단언 더함

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 00:49:31 +09:00
eomsangdonandClaude Sonnet 5.5 05234f1f6a test(725-5): M02 낡은 시험 8 파일 고침 - 소스 글·시간 표기 변화를 새 글로 따라감 (코드 안 고침)
- test_m02_item_list: 헬퍼 가짜 모듈에 진짜 common_util_time 연결 · TZ=Asia/Seoul 고정 · 서버 수정일 = UTC Z 20자(593-1)
- test_m02_hopyo_screen: 도면 양식 칸이 조건 없이 놓임(720-4) - 찾는 글 drawing ? 를 drawing, 로
- test_m02_layer_screen: const drawing = opt.isSystem 를 조건 없는 section(L("M02_KindDrawing")) 글로
- test_m02_practice: 차례 확인 글 drawing ? 를 drawing, 로
- test_m02_structure_def_screen: 지난 판 저장일 = UTC Z 초 단위(593-1) - 길이 16 을 꼴 확인으로
- test_m02_structure_option_groups: 옵션 줄은 716-3 에서 좌측 칸을 떠남 - 있다는 단언을 없다는 단언으로
- test_m02_structure_options: 같은 까닭 - 옵션 줄 단언을 없음으로, 구조물 조합 → 도면 양식 → 실무문서 차례만 확인
- test_m02_df_convert: 옛 사본 폴더가 낡아 어긋남 - 정본과 다르면 다시 복사 · 전수 대조는 열 줄에 하나(약 1분, AISLO_DF_FULL=1 이면 전수 약 12분) · 일꾼 동시 Node 호출로 드문 실패는 혼자 한 번 더

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 00:47:00 +09:00
eomsangdon 79c48ee480 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
# Conflicts:
#	M02_MasterTemplete/M02_MasterTemplete_Sangeun_Detail.ts
#	resources/tester/test_717_4_sangeun_pick.py
2026-11-02 00:15:31 +09:00
eomsangdonandClaude Opus 5.5 d33706ceea fix(725-1·725-2): 단가산출 [항목 추가] 못 셈 줄 = 그 줄만 금액 없음 · 나머지 셈 계속
- 더한줄 로직 입력 = 줄 값 › 공통 옵션(바탕 입력값 · 화면 시험 입력) › 로직 기본값(M01 견본) 차례로 채움 — 셈 때 채워 문서엔 안 적음(공통 옵션 바꾸면 따라감)
- 그래도 빈 입력 · 수량 없는 일위대가(723-1) · 못 셈 참조 = 0 값묶음 줄(차례 그대로) + 줄 까닭 · 답 `빠진줄` · 계 = 나머지 합 · 줄 남아 빼기 됨
- 쓰는 자리(`unit` · 층 목록 기준금액 · 일위대가 펼침) = 빠진 줄 있으면 통째 기준 없음(부분 값 안 넘김)
- 화면 = 띠 「못 셈 줄 — 이름」 · 계산서 금액 — · M01 strict 셈 · Pick.ts 그대로
- 낡은 시험 고침: 715 store · 717-4 = 723-1 뒤 안 줄 수량을 알약(given)으로 · 719-2 = 수량 없음이 통째 까닭 아닌 빠진줄로
- 새 지킴 시험 test_725_1_sangeun_add_inputs(임시 storage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-02 00:04:10 +09:00
eomsangdon 48b73b1f9f Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-02 00:03:41 +09:00
eomsangdonandClaude Sonnet 5.5 e526840671 test(723-1): 단가산출 고르기 시험 = 일위대가 안 줄 수량 저장 안 함 꼴로
723-1 · 내역 17-1 로 일위대가(SC) 안 줄 수량은 저장하지 않음(null).
test_list_amount_and_labor_line_holds_upper_sk 가 옛 꼴(SC 안 줄 손 3 저장)을 믿어 빨강.
- 저장본 안 줄 수량 null · 참조 = 회사 SK 그대로
- 금액: 바깥이 SK 를 손 3 으로 바로 참조 = SK 1 단위 × 3 · SC 를 손 1 로 담고 안 줄 미연결 = 0 · 안 줄 알약에 3 을 이으면 = SK 1 단위 × 3

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 00:02:56 +09:00
eomsangdonandClaude Sonnet 5.5 b1cba8ed14 fix(725-3·725-4): 바탕 없는 SK 거짓 M01 개정 막음 · 단가산출로도 저장 뒤 나무 다시 읽음 · 더한 줄 단가 찾아 옴
- 725-3 Sangeun_Detail — 개정 판정(배지 · 띠)은 바탕이 있을 때만(바탕 없는 SK 지문은 이름 · 단위 따라 바뀜)
- 725-4 Labor_RowModal — 단가산출로도 저장 성공 뒤 서고 목록 알림(HOPYO_BUILT) · 단가산출 나무 바로 갱신
- 725-4 LogicLab_Modal — prices 에 없는 요소(M02 더한 석공 · 합판)도 키로 찾아 단가 보임(lookup 하나로 합침)
- 시험 test_717_2b 에 725 단언 하나 더함

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-02 00:00:45 +09:00
eomsangdon 0001ef818c Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_4 2026-11-01 23:20:26 +09:00
eomsangdonandClaude Opus 5.5 0ec0b68492 fix(642): 점진 TIN 처짐 칸 씨앗 묶음 걷음 · 면 위 판정 0.12 m — 검사점 RMS 1.008 → 0.496 m
- 굵게 → 가늘게 다짐에서 「간격 절반보다 높이 뜬 칸 제외」 묶음을 걷음 — 급한 볼록 비탈이 계속 처져 implicit 검사점 RMS 가 CSF(0.587)보다 나빴음
- 마지막 판정 면 위 문턱 0.2 → 0.12 m — 덤불 점을 덜 받음
- 용화(분류 숨김) 업체 분류 대비 맞음 83.4 · 놓침 16.6 · 잘못 넣음 11.8 % · implicit 검사점 RMS 0.496 m · 4,900 만 점 205 초 · +1.2 GB

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:19:44 +09:00
eomsangdon 1dbae93408 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_1 2026-11-01 23:17:34 +09:00
eomsangdon b84ee6f9c1 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-01 23:16:12 +09:00
eomsangdonandClaude Opus 5.5 82e6e8584b feat(724-1): M02 단가산출 상세 · 일위대가 줄 모달 = 722 공용 상세(LogicLab_Ho) — 줄 모달 그 자리 고침 → M02 덮개 · 옛 고침 표 둘 · 이름 같은 모델 파일 걷음
- SK 셈 답 → 공용 상세 줄(logic/M02_Sangeun_Cover_Model.hoView · 정체 차례 · 바탕 줄 그대로 · 더한줄) · 고침 고리 HoEdit → setLineCover(덮개 · 더한줄 · 열린입력 = 배율)
- HoEdit 덧칸 more(줄 빼기 · 별도계상) · M02 = 덧줄 율 고침 · 꼬리표 「변경 항목」 · 뺀 줄 띠 = Sangeun_Cover.removedStrip
- 시험 계산 · 텍스트 수식 = 바탕 로직 + 덮개 품 · 단가 사본(applyCovers) · 뺀 바탕 줄 글 거름(dropText — 같은 이름 더한줄이 안 받음)
- 717-3 openRowSangeun = 줄 모드 상세(mountRowSk) 얇은 모달(Labor_Detail 부름 그대로) · skCoverTable · coverTable · 뿌리 M02_Sangeun_Cover_Model.ts 지움(Labor_Api import 한 줄)
- 낡은 시험: test_717_2b(setQty · setPrice → setLineCover · hoView · dropText) · test_717_3(모델 경로 · setCover · priceKey · sheetGroups 걷힘 → cleanPatch · 화면 이음 새 꼴)
- 안내 글(M02_SangeunBaseViews) = 덮개 사본 반영

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:15:16 +09:00
eomsangdonandClaude Opus 5.5 773544e5cb feat(642): 분류 없을 때 확정 기본 · B04 처음 고름 = ptd(점진 TIN) — 이음 몫
- SURFACE_CONFIRM_DEFAULT_FILTER csf → ptd · B04 DEFAULT_FILTER csf → ptd(확정본 있으면 확정 필터가 먼저 = 분류 LAS 는 classification 그대로)
- Engine_Ground resolve_auto_source_filters 설명 글 csf → ptd
- 낡은 시험 고침 — test_640_lidar_default · test_637_lidar 의 자동 필터 · 지워지는 모델 이름 csf → ptd(요구 559 사용자 확정)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:08:18 +09:00
eomsangdon 31b8131b50 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-11-01 23:05:20 +09:00
eomsangdon 2874c0fab2 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-11-01 23:05:18 +09:00
eomsangdon 05044fc3bd Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-01 23:03:44 +09:00
eomsangdonandClaude Opus 5.5 3a29e0480f feat(642): 점진 TIN(PTD) 내장 지면 필터 — 분류 없는 LAS 기본 필터 ptd · 화면 이름 「점진 TIN」
- 새 B04_PreProcess_Engine_Filter_PTD.py — 0.5 m 칸 최저 후보 · 시작 간격(기본 10 m) 칸 최저 씨앗 · 자료 가장자리 1/4 간격 씨앗 · 삼각형마다 문턱(장면 셋 평지 3°·0.3 m / 완경사 6°·0.5 m / 급경사 10°·0.7 m · 기본 급경사) 안 가장 가까운 후보를 더함 · 굵게 → 가늘게 다짐(후보가 모두 면 위로 뜬 칸에 반 간격 씨앗 · 2.5 m 까지) · 외딴 가시 · 잡점 꼭짓점 걷음 · 마지막 판정 = 면 아래 최대 거리 · 면 위 0.2 m · numpy · scipy 만 · 4,900 만 점 덩이 처리
- 삼각형 찾기 · 무게 직접 셈 — scipy find_simplex · transform 이 이 PC 에서 BLAS 스레드 다툼으로 수십 배 느림
- 필터 목록 ptd 한 줄(Engine_Ground · SURFACE_MODEL_SOURCE_FILTERS · 표본기 SurfaceFilter · B04 드롭다운) · SURFACE_AUTO_FALLBACK_FILTER = ptd(요구 559) · 설정 SURFACE_PTD_SCENE · SURFACE_PTD_SEED_SPACING_M
- 용화(분류 숨김) 업체 분류 대비 맞음 · 놓침 · 잘못 넣음 = CSF 55.1 · 44.9 · 12.2 → PTD 88.6 · 11.4 · 12.0 % · 급경사 놓침 60.9 → 19.3 % · 180 초 · +1.2 GB
- 새 지킴 시험 test_642_ptd_filter(합성 비탈 + 나무 · 급경사 놓침 상한)
- 낡은 시험 고침 test_b04_ground_filters — 분류 없을 때 자동 필터가 csf → ptd(요구 559 사용자 확정)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 23:02:31 +09:00
eomsangdonandClaude Opus 5.5 6db98d6b14 feat(643-1): B04 implicit 등고선 = 굳힌 격자 그대로 — 등고선 · 종단 · 횡단 같은 면
- implicit 등고선이 RBF 를 다시 세던 길 대신 implicit_{필터}_grid.npz 를 바로 씀(같은 dtm 없을 때만 옛 길)
- 용화(검증_sub4) WF1 다시 = classification + implicit 자동 확정 · 등고선 꼭짓점 − 표본 면 최대 3 mm(전 1.28 m) · 등고선 16.7 s
- 지킴 시험 test_641_1 에 등고선 = 표본 면 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:44:40 +09:00
eomsangdonandClaude Sonnet 5.5 5bd2df81a4 test(716-3): M02 왼쪽 칸 차례 시험 갱신 - 도면 양식 칸이 720-4 로 조건 없이 놓여 찾는 글 drawing ? 를 drawing, 로 교체
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 22:32:31 +09:00
eomsangdon c42c454c00 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-01 22:30:06 +09:00
eomsangdonandClaude Opus 5.5 8cbbe41d99 feat(723-3): 일위대가 줄 모달 = 단가산출 상세 줄 모드(보기 넷) — [적용] = 그 줄 덮개 + 옵션 · 단가산출로도 저장(기본 꺼짐)
- 새 `Labor_RowSk.ts` = 줄 → 이름 없는 SK 문서(lineDoc · 바탕 줄 정체) → 717-2b `mountSangeunDetail` 그대로 얹음 ·
  take = 패치 {덮개, 뺀줄, 더한줄}(M02 변경 항목 · M01 안 바뀜) + 옵션(손댄 값만 · 자동값 안 남김)
- `Labor_RowModal.ts` = 717-3 두 보기 갈음 · [적용] 뒤 닫힘 · [단가산출로도 저장] = 개인 층 새 SK(from-line)
- `Sangeun_Detail.ts` handle 에 getValues 한 줄(브레인 허락 · 724 공용 부품이 살림)
- `Labor_Table.ts` = 우클릭 메뉴 두 그림 뒤 엶(칸 초점 밀림에 닫히던 것) · 원문 패널 처음 접힘(표 넓게) · 머리 단추 묶음
- `Hopyo_Relink._total` = 엑셀 수량(본뜬.실무수량)으로 — 705-2 다시 잇기 전 · 후 금액 되살림(723-1 번짐)
- 새 지킴 시험 test_723_labor_table(저장 수량 걷음 · 이은 알약 · 안 줄 알약 · 화면 이음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:27:51 +09:00
eomsangdon a94cd33865 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-11-01 22:26:15 +09:00
eomsangdonandClaude Opus 5.5 9633e97c3c feat(723-4): 723-1 뒤 B09 일위대가 수량 연결 마무리 — 알약 값 쓴 SC 만 덮개 나눔 · SK 속 SC · 공사 불러오기 수량 · 비고 경고
- Labor_Source = 덮개 열쇠 `C:참조|S:사본` 은 알약 값 쓴 SC 블록만(빈 수량 · 옛 손 = `C:참조` 그대로 = 703-1 지난 공사 가져옴 꼴) ·
  SK 속 SC 안 줄 수량 없음 = 엔진엔 0 값묶음(`_Loose`) · 그 줄 = 사전조합 블록(이 공사 입력) — SK 통째 빈칸 안 됨
- Bill_Hopyo(707-7 공사 통째 불러오기) = 이 공사 엑셀 수량을 굳힌 손으로 남김(723-1 `with_pills` 수량 걷기는 M02 저장용)
- Daega_Personal 원천 = 블록 origin 참조(덮개 열쇠 꼬리 무관) · 내 자료집 저장 알림 글 = 줄 수량 저장 안 함
- 비고(펼침 · 일위대가표) = 수량 없는 SC 안 줄은 「산근 n호표 · 수량 없음 — 이 공사에서 입력」(`noteOf`)
- 낡은 시험 고침(사유 = 723-1 SC 안 줄 수량 저장 안 함 · 이 공사 입력 · 알약으로 이음):
  test_204_2(서고 SC 수량 없음 · ask) · test_703(자료집 저장 손 걷힘) · test_719_1(서고 사본 = 알약 값으로 같은 값 · 없으면 수량 없음) ·
  test_719_2(SK 속 SC = 빈 줄 → 이 공사 입력 1 = M02 알약 1 금액) · test_702_3(서고 SC 빈 줄 3 → 입력 뒤 금액)
- test_723_4 = 구조물 사본 면적 알약 굳힘 · 설계 연동 · 알약 안 이음 = 빈 줄 시험 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 22:25:30 +09:00
eomsangdonandClaude Sonnet 5.5 23d8e2d7a2 fix(720-4): 도면 양식 칸 = 누구나 늘 보임(읽기 · 고르기) · 새로 · 본떠 · 지우기 단추만 시스템 관리자 · 실무문서 그대로
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 21:59:18 +09:00
eomsangdonandClaude Opus 5.5 6490b999e3 feat(723-2): M02 일위대가 상세 = 일위대가표만 — 수량 칸 휘발 임시 입력 · 우클릭(위로 · 아래로 · 별칭 · 삭제) · 줄 누름 모달
- 새 `Labor_Table.ts` = 서고(사전조합) 상세 — 정보 칸 · 일위대가표(laborBlock 격자) · 원문 패널 · 머리 [로직 추가] [자재 추가]
  · 수량 칸 = 확인용 임시 입력(저장 · dirty 없음 · 짝 셈 settle 로 금액 미리 보기) · 「빈 값 n」 = 비고 붉게
  · 붙여넣기 엑셀 수량 = 임시 칸으로 · 옛 문서 손 수량은 읽을 때 걷음(723-1) · lib.fixed(721-2 인력 · 재료 고정 줄)
- 새 `Labor_RowModal.ts` = 줄 누름 모달(옵션 · 메모 · 켜쌓기율 · 로직 자재 바꾸기 · 세 칸 · 단가산출 보기) · 자재 줄 모달(세 칸)
- `Labor_Detail.ts` = mode precombo 면 Labor_Table 로 · 서고 갈래 걷음(679 → 649 줄)
- 낡은 시험 고침: test_175 화면 글 대조(서고 갈래가 Labor_Table · RowModal 로 옮김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:58:10 +09:00
eomsangdon 2fb57be255 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-11-01 21:56:53 +09:00
eomsangdon 6609192ef4 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_6' into sub_laptop_8 2026-11-01 21:55:23 +09:00
eomsangdon 43fdd37ab5 Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-11-01 21:55:20 +09:00
eomsangdon 6fbb1c3f65 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-01 21:55:18 +09:00
eomsangdonandClaude Opus 5.5 cf41e303d0 feat(723-4): B09 사본 일위대가(SC) 안 줄 수량 = 굳힌 손 → 구조물 사본 면적 알약 값 → 없음(빈 줄 · 이 공사에서 입력)
- Labor_Source.precombo = 빈 손 1 로 셈 걷음 · 안 줄 · 자재 수량 = 굳힌 옛 손 → 쓰는 구조물 사본 면적 알약 값
  (723-1 「참조 id#차례」 · 「참조 id#자재|코드」 · 짝 = M02_Labor_Expand._qty 차례) → 없음 = qty null · `ask`
- 알약 · 없음 쓴 SC 블록 덮개 열쇠 = `C:참조|S:사본` · `C:참조|L:서고`(쓰는 곳별 · 옛 손뿐 = `C:참조` 그대로)
- 셈 짝(Calc_Labor .py ↔ .ts) = `ask` 줄 수량 비면 비고 · 연결 없음 「수량 없음 — 이 공사에서 입력」 · 702 덮개 qty 입력 뒤 금액 · 글 없음
- 지킴 시험 test_723_4_b09_sc_qty(경고 · 입력 금액 · 알약 굳힘 · 옛 손 · 거울)
- 기존 공사 금액 전후 같음(검증_랩탑메인 · 검증_sub8 B09 셈 답 통째 같음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:55:12 +09:00
eomsangdon d301ceb086 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-01 21:51:57 +09:00
eomsangdonandClaude Opus 5.5 7d007e4feb feat(723-1): 일위대가(사전조합) 안 줄 수량 저장 안 함 — 구조물 조합 안 줄 알약 연결 · 불러오기 수량 = 본뜬 출처 기록
- 저장(`M02_Labor_Ref.no_qty`) = 안 줄 · 자재 수량 늘 null(비율 고정 줄 율만) · 기본 {손: 1} 걷음 · 굳힌 사본도 걷음
- 펼침(`Expand`) = 안 줄 수량 = 쓰는 쪽 given 알약 「참조|층|주인|키#차례」 · 「…#자재|코드」 · 층 원본 옛 손 안 씀 ·
  굳힌 사본(프로젝트) 손 · 비율 줄 율만 · 없으면 「수량 없음」(1 아님 · 그 일위대가 기준 없음)
- 구조물 조합 알약(`pills`) = 참조 줄 + 안 줄 알약(`inner_pills`) — 면적산출 칸에 이음
- 불러오기(`Hopyo_Build`) = 수량 안 들임 · 엑셀 수량 = `본뜬.실무수량`(셈 안 씀 · 엑셀 대조만 · 하위 호표까지)
- B08 `_ref_mats` = 안 줄 수량 = 굳힌 손 · 아니면 사본 면적 알약 값
- 낡은 시험 고침(사유 = 수량 저장 안 함 · 이은 값으로 셈): test_m02_labor_ref · test_175_precombo_mats ·
  test_715_3_labor_patch · test_m02_hopyo_build · test_m02_hopyo_flow · test_160_3(굳힌 옛 사본 꼴 손 채움)
- 남김: B09 시험 5(test_204_2 · test_703 · test_719_1 · test_719_2) = sub8 723-4 가 B09 코드와 함께

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:51:27 +09:00
eomsangdonandClaude Opus 5.5 e9106ae4d6 fix(720-2): 단가산출 · 일위대가 층 줄 「+」 = 구조물 조합과 같은 자리(줄 오른쪽 끝 m02-tree__tools)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:47:26 +09:00
eomsangdon 2cc20c6a60 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_6' into sub_laptop_7 2026-11-01 21:46:48 +09:00
eomsangdon e9a4c05ce4 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-11-01 21:46:46 +09:00
eomsangdon 168e34f2f9 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-11-01 21:45:51 +09:00
eomsangdonandClaude Opus 5.5 0e24c7a2aa feat(721-2): M02 찾아 넣기 창 — 탭 M01 전체(로직 · 인력 · 재료) · 단가산출 · 일위대가 · 찾기 칸 하나 · 거름 값 = 구분 · 출처 열
- 새 파일 M02_MasterTemplete_Find_Pick.ts — 있는 길 넷 이음(기준 목록 · 로직 · 층 서고 목록 · /logic-lib/pick 인력 job · 재료 price 서버 찾기) · 기계 · 실무 호표 뺌
- 부르는 곳 셋: 단가산출 [기본 로직 선택](로직 줄만) · [항목 추가](더한줄 인력 · 재료 = 요소 키 LB · MT 수량 1) · 일위대가 [로직 추가](Labor_Pick 이 lib.sangeun 이면 새 창)
- 일위대가 인력 · 재료 = 고정 줄 이음 꼴 fixedOf · lib.fixed 받는 칸은 723(sub1) — 그 전엔 회색 막힘
- 옛 창(B09 둘 · 불러오기 둘 · 프로젝트 층) 그대로 · 옛 창의 단가산출 탭 코드 걷음(부르는 곳 없음)
- 부품 상자: 데이터 표 흐린 줄 is-dim 더함
- 낡은 시험 test_717_4_sangeun_pick: 옛 창 단가산출 탭 · 재료 숨은 한 줄 글 대조 → 새 창 꼴로 고침
- 새 지킴 시험 test_721_find_pick(부르는 곳 · 옛 창 그대로 · 인력 · 재료 줄이 서버 셈에서 노임 · 자재 값)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:45:11 +09:00
eomsangdon 19849ab08a Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-01 21:41:26 +09:00
eomsangdonandClaude Opus 5.5 190789cdf7 feat(722-1·722-2): M01 단가산출 상세 개편 — 공용 상세 부품(하위 박스 · 계산식 · 박스 옵션) · 줄 모달 그 자리 고침(시험 값)
- 새 공용 단가산출 상세 `M01_MasterData_UI_LogicLab_Ho.ts` — 하위 박스 = 서버 묶음(인력 · 재료 · 경비(장비) · 하위) · 장비 · 하위 로직 = 한 줄 + 펼침(부른 로직 시간당 줄 · 몫 줄 · 다시 셈해도 펼친 채) · 「계산식」 열 = 값 든 식 + 알약 + 그 줄 입력 칩 · 이름 식 = 풍선 · 박스 제목 아래 옵션 고르기 · 어느 박스도 안 쓰는 입력 = 공통 옵션 · 맨 아래 비목 소계 셋 + 계
- 새 `M01_MasterData_UI_LogicLab_Cover.ts` — 줄 모달 「그 자리 고침」(수량 · 단가 · 로직 줄은 노 · 재 · 경) · M01 = 시험 값만(로직 줄 객체 열쇠 메모리 · 시험 계산 줄 사본에 손 단가 · 값묶음 · 수량 덮음 · 정본 · 요소 · 캐시 안 바뀜) · 고침 꼴 = M02 덮개 칸(724 고침 고리 `HoEdit`)
- `LogicLab_Detail.ts` 단가산출 상세 = 공용 부품(호표 묶음 표 코드 옮김 · 551 → 252 줄) · `LogicLab.ts` 시험 계산 줄 = trial.apply · dirty 에 시험 고침 · onTrial(줄 수 627 그대로)
- `calc_sheet.ts` varRow 내보냄 · 값 없으면 빈 첫 칸 · `LogicLab_Formula.ts` inputChip 내보냄 · `LogicLab_Modal.ts` 줄 모달 edit 칸
- 보기 고르기 넷 그대로(지우기는 사용자 확인 뒤)
- 지킴 시험 test_722_ho_detail(박스 묶기 · 고침 덮기 순수 함수 · 캐시 밖 · 부품 상자)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:39:17 +09:00
eomsangdonandClaude Opus 5.5 ace439ad04 feat(720-4): 구조물 조합 층 단추 = 제자리 바꿈(해시로 페이지 통째 다시 세움 없음) · 도면 양식 · 실무문서 칸 = 시스템 관리자면 늘
- 페이지: 층 고름(resolve) · 층 맞춤(settleLayer) · 띠 · 나무 · 층 줄 갈아 끼움(build) 한 길 · 주소 replaceState · 새 층 구조물 표
- 편집 칸 권한 = 함수(UI_Main isAdmin) · 「새 항목」 = 고른 것 권한으로 가림 · 불러오기 장부 = 열 때 층 · 권한(Hopyo_Side)
- 왼쪽 칸 펼침 · 단가산출 · 일위대가 층 그대로
- 낡은 시험(층 단추 해시 이동 · 사람 나무 · 도면 칸 층 묶음 · 새 항목 · 머리 고침 · 불러오기 권한 · 실무문서 차례 · home) 새 꼴로 — test_m02_practice 도면 차례 글은 이미 낡아 실패하던 것

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:37:10 +09:00
eomsangdon 956269964c Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_3' and 'origin/sub_laptop_6' into sub_laptop_4 2026-11-01 21:29:42 +09:00
eomsangdon ca42faf873 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-11-01 21:29:40 +09:00
eomsangdonandClaude Opus 5.5 b85add0a51 feat(640-3): 외곽 틈 닫기 3 m — 성긴 숲 띠까지 스캔 범위 살림
- SURFACE_FOOTPRINT_GAP_CLOSE_M 1.0 → 3.0 · 「가장 큰 덩이만」 그대로
- 설정 지문에 들어가 다음 짓기 때 모든 표현 · 등고선 · 유효 범위가 새 외곽으로 다시 지어짐(기존 확정 파일 · DB 는 그대로)
- 지킴 시험 test_640_lidar_default 틈 닫기

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:27:24 +09:00
eomsangdon 73d5084037 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-01 21:26:02 +09:00
eomsangdonandClaude Opus 5.5 a0c2db507f feat(720-3): 단가산출 · 일위대가 나무 문서 줄 [이름 바꾸기] — 읽고 이름만 갈아 저장 · 저장 뒤 상세 머리 제목 새 이름
- 열린 문서면 저장 안 한 고침 물음 뒤 새 이름으로 다시 엶 · 서버 새 길 없음
- 새 지킴 시험 test_720_m02_folder_layer(임시 storage 이름 바꾸기 왕복 · 화면 글 대조)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:25:12 +09:00
eomsangdonandClaude Opus 5.5 437333c7af feat(640-2): implicit 점 수 = 지면점 비율 · 격자 칸 뽑기 · 설정 지문 바뀌면 다시 지음
- SURFACE_IMPLICIT_POINT_RATIO 0.175 · _MIN_POINTS 5 만 · _MAX_POINTS 200 만(옛 _MAX_POINTS_PER_TILE 걷음)
- TerrainContext.grid_sample — 칸 = √(외곽 넓이 / 점 수) · 씨앗 558 · 칸마다 하나 · 모자라면 무작위 채움(sample() 그대로)
- 짓기 캐시 흠 — manifest 항목 완료일 때만 재사용(지문 바뀐 뒤 implicit · nurbs · meshfree 옛 파일 재사용 막음)
- 지킴 시험 test_640_lidar_default 점 수 · 격자 뽑기 · 지문 다시 지음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:25:08 +09:00
eomsangdonandClaude Opus 5.5 b3e62c7ae4 feat(641-2): B07 유역 음영 · D03 지형 — implicit 확정이어도 평활 DTM 읽음 · 지킴 시험
- 읽는 자리 한 줄씩(계산 코드 그대로) · dtm 확정은 전과 같음
- test_641_1_implicit_grid: 굳힌 격자 = RBF · 다시 셈 · 서버 · Node · B06 · 브라우저 같은 파일 · B07 · D03 전후

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:22:09 +09:00
eomsangdonandClaude Opus 5.5 f912efd36a feat(641-1): implicit 굳힌 격자 — 확정 지표면 B05 · B06 화면 일치
- implicit_{필터}_grid.npz = 같은 필터 dtm 축 · 유효 칸 · 유효 칸만 RBF · 밖 = dtm 값 · implicit · dtm 보다 옛것이면 다시 셈(파생 저장본)
- 서버 표본기 implicit = 굳힌 격자 DtmGridSampler(매번 RBF 다시 짓기 없앰) · Node 측점이 source 로 같은 격자
- B05 브라우저 격자 통로 · B06 서버 재계산 격자 = implicit 확정이면 같은 굳힌 격자(새 짝 없음 · 기존 heightAt 짝)
- 짓기 끝(build_implicit)에서 한 번 굳힘 · 못 지으면 처음 읽을 때

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:21:57 +09:00
eomsangdonandClaude Opus 5.5 194e1a9c39 feat(720-2): 최상위 폴더 만들기 — 폴더 · 문서 줄 메뉴 + 단가산출 · 일위대가 층 줄 「+」(구조물 조합과 같은 addFolder([]))
- 구조물 조합 나무 「〇〇로 복사…」 몸을 TreePanel_CopyTo 로 뗌(TreePanel 700 → 688 줄 · 동작 같음)
- 낡은 시험 test_m02_layer_screen(복사 대상 글 자리 = CopyTo) · test_m02_precombo_scope(층 줄 add 인자) 새 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:21:29 +09:00
eomsangdonandClaude Opus 5.5 366fd7a700 feat(640-1): 라이다 기본 고름 — 확정 implicit · 평활 끔 · 분류 덮임 조건 · implicit 기본 필터에만
- 확정 기본 표현 implicit · 평활 False · WF1 자동 짓기에 확정 표현 더함(SURFACE_AUTO_METHODS 는 dtm 그대로)
- 「분류 있음」 자동 고름 = 비율 0.2 % + 10 m 칸 덮임 50 % · 덮임 저장본(classification_coverage.json · 점군 안 풂)
- 분류 LAS 의 implicit 는 기본 필터에만 지음(csf 두 번 안 지음)
- B04 화면 확정 없을 때 기본 표현 implicit
- 지킴 시험 test_640_lidar_default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:20:24 +09:00
eomsangdon 6ff98c9e96 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_1 2026-11-01 21:15:17 +09:00
eomsangdonandClaude Opus 5.5 0a86b4b060 knowledge(요구): 요구 561 원문 · PLAN 644(기존 라이다 프로젝트 영구저장소 · 새 기본 미리 지음) · 723-4(B09 일위대가 안 줄 수량 연결)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:15:14 +09:00
eomsangdonandClaude Opus 5.5 7621fff233 feat(720-1): 단가산출 · 일위대가 층 단추 = 저장 안 한 고침 물음 뒤 그 층 나무 + 상세 목록표
- 낡은 시험 test_m02_precombo_scope 층 단추 줄 글 대조를 새 꼴로(옛 go = 나무만)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:11:43 +09:00
eomsangdonandClaude Opus 5.5 931793d031 knowledge(요구): 요구 560 · 내역 18 원문 기록 — 밤 맡김(요구 반영 원문 대조 · 브레인 브라우저 검증 · 아침 아티팩트 보고서)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:11:12 +09:00
eomsangdonandClaude Opus 5.5 f7ebf1831f feat(721-1): M02 단가산출 상세 단추 이름 · 자리 — 「기본 로직 선택」을 「항목 추가」 왼쪽 · 「줄 더하기」 → 「항목 추가」
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:08:07 +09:00
eomsangdonandClaude Opus 5.5 887ca4a035 knowledge(요구): 요구서 내역16 · 558 사용자 확정 반영 — 일위대가 수량 연결(조사 먼저) · M02 고친 값 보관 · 점진 TIN 내장
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:03:50 +09:00
eomsangdonandClaude Opus 5.5 899c6a01ab knowledge(요구): 내역 17(일위대가 수량 = 저장 안 함 · 구조물 조합 알약 · 프로젝트 계산값 연결 · 만들 때 휘발 확인 · M02 고친 값 = M02 변경 항목) · 요구 559(CSF = 처음부터 점진 TIN 내장) 원문 기록
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 21:03:17 +09:00
eomsangdonandClaude Opus 5.5 4ba8ca793b knowledge(요구): 요구서 558(라이다 내장 개선) · 내역16(M02 정리 · 일위대가표 · M01 상세) · 조사 셋 · PLAN 640~643 · 720~724
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 20:55:56 +09:00
eomsangdonandClaude Opus 5.5 f189832ed5 knowledge(요구): 요구 558(라이다 내장 개선 · classification/csf + implicit · 점 수 비율 · 틈 닫기 3 m · B07 유역 · D03 안 건드림) · 내역 16(M02 폴더 · 층 · 단가산출 이름 · 단추 · 항목 추가 · 일위대가표만 · M01 상세 하위 박스 · 검색) 원문 기록
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 20:29:53 +09:00
eomsangdonandClaude Opus 5.5 a3d24ce738 knowledge(요구): 요구 557 implicit 코드 조사 — 쓰는 곳 갈림(일부 화면 DTM) · 누락 = 외곽 틈 닫기 1 m + 가장 큰 덩이 · 점 수가 품질 · 틈 닫기 3 m 면 덮임 65→94 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:44:57 +09:00
eomsangdonandClaude Opus 5.5 a1c33a2d9d knowledge(요구): 요구 557 implicit 시험 결과 — 20만 점이면 매끈 + 세부 지킴 · 누락 = 외곽 마스크(DJI 지면점 없는 띠 36 %) · 빈 띠 메우기는 정답 없음 · 외부 값과 비교 됨(폭)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:35:20 +09:00
eomsangdonandClaude Opus 5.5 f27587b823 knowledge(요구): 요구 557 덧 「조사부터」
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:12:21 +09:00
eomsangdonandClaude Opus 5.5 82356b3ee5 knowledge(요구): 요구 557 원문 기록 — 분류 있으면 implicit · 없으면 내장 csf + implicit 확인 · implicit 누락 처리 · 외부 라이브러리 값과 비교
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 19:10:39 +09:00
eomsangdonandClaude Opus 5.5 63f3a80029 knowledge(요구): 요구 556 조사 둘 — DJI Terra · Agisoft Metashape 지표면 단계 · 코어 QGIS 아님 · 용화.las = DJI L1(Terra 분류)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:40:41 +09:00
eomsangdonandClaude Opus 5.5 48fcbbc5c9 knowledge(요구): 요구 556 원문 기록 — 테스트 사용자 상용 프로그램(DJI · 러시아) 라이다 지표면 조사 · 코어가 QGIS 면 방향 바꿈
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:24:21 +09:00
eomsangdonandClaude Opus 5.5 c68535227d knowledge(요구): 요구 555 원문 기록 — 라이다 지표면 3단계 · 정합성 기준 물음 · M01/M02 화면 검증 미룸
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 18:17:58 +09:00
eomsangdonandClaude Opus 5.5 a66d2c2161 knowledge(계획): PLAN 709~719 이관 — 내역 12~15 끝(M01 단가산출 보기 · 엔진 바닥 · 원문 대조 · M02 단가산출 · 메뉴 · 불러오기 · 별칭 · 프로젝트 복사) · 넓은 시험 276 통과
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:46:43 +09:00
eomsangdon c9194dc84b test(716-4): 낡은 시험 고침 — 쓰는 일위대가 자료에 단위(㎡) 추가
715-3 줄 있는 일위대가 단위 빈칸 400 규칙과 이음 · 기능 흠 아님
2026-11-01 17:46:01 +09:00
eomsangdon 3e563609b1 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-01 17:40:18 +09:00
eomsangdonandClaude Opus 5.5 df31e51660 test(717-2b): 낡은 시험 test_좌측_고름_표시는_열린_것_한_줄뿐 지금 꼴로
- 716-3 이 M02 좌측 옵션 줄을 뺌 — paint 끝 줄이 options.clear() 에서 practice?.clear()(실무자료 판)로 바뀜 · 찾는 글을 지금 꼴로 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:34:37 +09:00
eomsangdonandClaude Opus 5.5 a1342124c6 feat(717-2b): SK 더한 재료 줄 = 기본 계 안(재료 소계 · 계에 더함) · 줄 「별도계상」 고름만 계 밖
- 서버 M02_Sangeun_Doc — 더한줄 재료 칸 별도계상(true 만 남김) · patched 가 더한 재료 줄 자재표시를 직접계산(기본) | 별도계상(칸 켬)으로 둠 · 원문 로직 줄 별도계상은 그대로
- 화면 — SK 상세 고침 표 더한 재료 줄 로직 칸에 「별도계상」 칩(켬 · 끔) · 순수 함수 setApart · 행 apartPick
- 지킴 시험 — test_715_sangeun_engine 더한 재료 계 안 · 별도계상 계 밖 · test_717_2b 칩 꼴

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:34:36 +09:00
eomsangdon 83b853c060 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-11-01 17:29:27 +09:00
eomsangdonandClaude Opus 5.5 51f07499f5 feat(719-4 · 719-3): B09 단가산출근거 탭 = 공용 계산서 카드 · 이 공사 산근 덮개
- 719-4 근거 탭 = 산근 블록마다 카드 · 공용 calcSheetRows 묶음 꼴(인력 · 재료 · 경비(장비 한 줄 + 중기사용료 펼침 넉 줄) · 하위 = 산근 n호표 한 줄) · 서버 줄 덧칸 sheet{쓰는입력 · 기준단위 · 환산근거}(master_sheet_line 한 벌) · 블록 기준단위 · 입력 · 중간값 글은 고치기 펼침 때 · 옛 풀이표는 곁 창에만
- 719-3 산근 덮개 sg_cover {산근 키: {「n:이름」: {qty · 노 · 재 · 경 단가}}} · 셈 짝 B09_Estimation_Sangeun_Cover.py ↔ .ts(서버 풀이 뒤 덧씌움 · 하위 산근 이어짐 · % 덧줄 밑수 비례 · 총계 · 목록표 · 빈칸 목록 · raw 로 두 번 덮어도 같음 · 짝 잃음 sg_orphans 알림)
- 고침 표(702 꼴) = 품 · 단가 두 번 눌러 고침 · 고친 칸 색 · 원래 값 칸 · 「이 공사에서 고침」 꼬리표 · 우클릭 · 카드 단추 [단가산출 값으로] · 고친 뒤 보던 자리 지킴
- 저장 다듬기 sg_cover · [업데이트] 알림 고친 칸 수에 산근 칸 · 셈 길(compute_sheets) · 화면 짝(computeSheets) 둘 다 덮개
- 금액 전후 같음 — 덮개 없음 = 서버 풀이 그대로 · 덧칸 뺀 옛 풀이와 13 블록 같음 · 일위대가 합 검증_랩탑메인 13,662,560 · 검증_sub5 10,149,562 그대로
- 시험 새로 test_719_3_sangeun_cover(8 · 거울 · 대가 · 원가계산서 · 업데이트 뒤 지킴) · test_719_4_sangeun_sheet(2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:28:11 +09:00
eomsangdonandClaude Opus 5.5 04f3489481 merge(717-2b): origin/main_laptop_1 합침 — Sangeun_Cover.ts 같은 이름 새 파일 충돌(AA) 풂 · 두 표 한 파일
- 셈 길 calcSangeun 한 벌 — 답 꼴 제네릭(기본 717-2b SkAnswer · 717-3 모달은 SkCalc) · 시험 입력 칸(입력값) 그대로 · owner 는 시스템 층 밖만
- 717-3 일위대가 모달 표 = coverTable · sangeunFromLine 그대로 · 717-2b SK 상세 표 = skCoverTable(옛 coverTable 이름 바꿈 · 옵션 = SkCoverOptions)
- 모달 부르는 자리 셋에 <SkCalc> 만 더함
- 시험 test_717_2b · test_717_3 · 715 · 719 통과 · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:27:50 +09:00
eomsangdon 27d88eaeb3 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_2 2026-11-01 17:24:45 +09:00
eomsangdonandClaude Opus 5.5 53bca68ea4 feat(717-2b): M02 단가산출 상세 · 고침 — M01 상세 부품(보기 넷)을 M02 길로 · 품 · 단가 칸 덮개 · 줄 더하기 · 빼기 · 바탕 M01 로직 고름
- 새 Sangeun_Detail(mountSangeunDetail) · Sangeun_Cover(고침 표 · 셈 길 최신 응답만) · logic/M02_Sangeun_Cover_Model(순수 꼴)
- 고친 칸 = 칸 색 + 바탕 값 곁 글 + 「단가산출에서 고침」 · M01 개정 배지(바탕판 · 원값) · 짝잃음 띠 · 별도계상 재료 = 계 밖 표기
- M01 buildLabDetail 덧칸 cover(단가산출 상세 · 인력·재료·경비 덩이 갈음) · 마스터 이동 단추 = 등록 없으면 숨김(hasMasterJump)
- Sangeun_View 자리표 · 옛 글 열쇠(준비 중 · 덮개 · 더한 줄 · 열린 입력) 걷음
- 시험 test_717_2b_sangeun_detail 3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 17:23:02 +09:00
eomsangdon 1a73413254 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-01 16:53:48 +09:00
eomsangdonandClaude Opus 5.5 c818dcae5e feat(717-3): 일위대가 줄 [단가산출 보기] 모달 — 인력·재료·경비 · 단가산출 상세 고침 = 그 일위대가 줄 덮개 · [단가산출로 저장]
- 줄 단추 = 로직 줄 · SK 참조 줄(Labor_Rows `sangeun`) · 프로젝트 복사본(셈 주입)엔 없음
- 모달(Labor_SangeunModal) = 일위대가 머리 「1 ㎡ 당」 · 이 줄 수량 · 1 단위 금액 · 보기 고르기(기본 인력·재료·경비 = 공용 계산서 calc_sheet · 상세 = 품 · 단가 칸 고침)
- 고친 줄 = 표 부품 「바뀜」 + 원래 값 곁 + 「이 일위대가에서 고침」 꼬리표 · [적용] = 줄 칸 패치(화면 캐시 · 저장은 [저장]) · 적용 안 하고 닫으면 물음
- [단가산출로 저장] = 내 개인 층 새 SK(from-line) · 자기 개인 층 일위대가면 그 줄을 새 SK 참조 줄로
- 셈 = 서버 /sangeun/calc(저장 안 함 · 최신 응답만) · 화면 문서 = 서버 as_doc 꼴(Sangeun_Cover_Model · 고름 · 자재표시 cid)
- 고침 표 · 셈 길 부름 = Sangeun_Cover(717-2b 도 씀) · normalizeLabor 가 줄 패치를 지킴(떨구면 [저장]에서 사라짐)
- 시험 test_717_3 5 통과 · 고친 대상 166 통과(1 실패 = test_175 B09 Store_Combo._PER · 전부터 · 719-2 몫) · typecheck 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:52:56 +09:00
eomsangdon 565f437d2e Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-01 16:48:09 +09:00
eomsangdonandClaude Opus 5.5 c0f3d21908 feat(713-3): M01 로직 목록 묶음 접기 — 같은 절 · 작업량 로직은 머리 줄 밑으로 접음(접기 순수 함수 logic/list_fold) · 찾는 중엔 펼침 · 「관련」 열 = 비고가 가리키는 로직 링크(계산 밖) · 서버 덧칸 인재경 줄 수(712-1 master_bundle) · 지킴 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:47:28 +09:00
eomsangdon 54ab449a9c Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-11-01 16:47:06 +09:00
eomsangdon 27caa818f1 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-11-01 16:47:04 +09:00
eomsangdon aced540f14 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-01 16:41:59 +09:00
eomsangdon 117d5e25b1 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-01 16:41:57 +09:00
eomsangdon 1a3d50be68 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-01 16:41:56 +09:00
eomsangdon fa836163fa Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-11-01 16:41:54 +09:00
eomsangdonandClaude Opus 5.5 dcf476d504 feat(714-2): 장비 펼침 넉 줄 — 서버 펼침(714-1b · 부른 기계경비 로직 시간당 줄 조종원 · 주연료 · 잡재료비 · 기계손료 · 원/시간 계) 머리 글 + 넉 줄 → 그 장비 노 · 재 · 경 몫 식
- calc_sheet.ts SheetUnfold · 펼침 줄 · calc_sheet.css 펼침 글 들여씀
- 지킴 시험 test_714_2_calc_sheet 펼침 글 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:39:11 +09:00
eomsangdonandClaude Opus 5.5 328cb46d92 fix(719-2): 산근 결과단위 배 = 712-2 단위 한 벌(master_unit.per) — 받은 712-2 가 Store_Combo._PER 를 걷음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:38:24 +09:00
eomsangdonandClaude Opus 5.5 c60b87c111 feat(715-1b): 엔진 입력 덧칸 — 호표 줄 단가(손 단가) · 로직 줄 값묶음(노 · 재 · 경) · SK 고친 줄 셈 마무리
- master_pick — _priced 앞 「단가」 덧칸 = 손 단가(찾기 없음 · 출처 「손 단가」) · bundled(값묶음 셋 = 그것 · 일부 = 엔진 값 위 · 계 = 셋 합)
- master_formula — 로직 줄 묶음 부르는 줄 하나(mp.bundled) · 712-1 출력 묶음과 다른 이름
- M01 호표 5,266 줄에 덧칸 없음 · M01 1,469 로직 값 전후 같음
- 시험 test_715_sangeun_engine.py 다시 넣음(손 단가 · 3 % 다시 셈 · 값묶음 일부 · 참조 · 고정 더한줄)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:38:19 +09:00
eomsangdon 1b830c9e8b Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-01 16:37:26 +09:00
eomsangdon 1e6092fad8 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-11-01 16:37:08 +09:00
eomsangdonandClaude Opus 5.5 f6a4296517 fix(713-4): 정본 로직 원문 대조 정리(내역 15) — 조종원 시간 환산 = 하루 노임 × 1/8(품셈 1-1-3 · 질의회신 상여 · 휴지계수 뺌 · 기계경비 8) · 구조물터파기 1~6 = 7~18 꼴(작업량 로직 6 지움 · 관부설 가산 비고) · 플랜트 배관 13-1-1 쪽 표 다섯 · 로직 다섯 → 한 표 · 한 로직 · 기초다짐 13-3-1 셋 → 하나 · 방제 8-3 빠진 로직 넷 채움 · 713-1 확인 길로 저장 · 지킴 시험
낡은 시험 고침: test_masterdata_check 굴착기 · 암석 보정 · 노체포설 · 집게 기대값 = 조종원 1/8 꼴 값(노무비 × 0.6) · test_713_3_list 목록 수(1,461 · 묶음 307 · 작업량 21 · 터파기 작업량 지움으로 표본 GF000098)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:36:45 +09:00
eomsangdon ebc6ac25a9 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_3 2026-11-01 16:34:47 +09:00
eomsangdon 6479145d20 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-01 16:33:49 +09:00
eomsangdon 6a73a4efd5 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-01 16:33:47 +09:00
eomsangdon 63ac37759e Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_3' into sub_laptop_8 2026-11-01 16:33:31 +09:00
eomsangdon 0ed745e98c Merge remote-tracking branch 'origin/dev' into sub_laptop_8 2026-11-01 16:33:28 +09:00
eomsangdonandClaude Opus 5.5 6126f5ee20 fix(719-2): 업데이트 뒤 서고 사본 줄 연결 다시 이음 · 단가산출 블록 안내 글 되돌림 이름
- Shell: [업데이트]가 서고 사본을 다시 굳혀 블록 id 가 바뀌면 화면 캐시 줄 연결을 사본 폴더(lib)로 다시 이음(짝 Bill_Library.fix_keys) — 캐시가 옛 id 를 잡아 단가 빈칸 되던 것
- Bill_Fold: 단가산출 블록 안내 글 「우클릭 [단가산출 값으로]」

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:33:17 +09:00
eomsangdonandClaude Opus 5.5 8c519c0ab2 feat(714-1b): 장비 줄 펼침 덧칸 — 부른 기계경비 로직 계산서 줄 · 시간당 소계 · 계 · 키 · 인자
- master_sheet_line 장비(기계경비) 줄에 「펼침」 = {키, 이름, 단위, 입력, 줄[비목 · 이름 · 글 · 금액], 소계, 계} · 못 셈 = 까닭
- 셈 결과(master_text.lines)를 그대로 실음 — 조종원 환산 같은 규칙을 여기 두지 않음
- annotate 가 files 를 받음(펼침 계산서) · M01 /text · M02 /logic-lib/sheet 같은 KEYS
- 시험 test_714_1_sheet_line 펼침 · 두 길 같은 수(JSON 수 규칙)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:32:57 +09:00
eomsangdonandClaude Opus 5.5 f8338194c6 refactor(717-3): 일위대가 상세 · 줄 표 나눔 — 자재목록 표(Labor_MatTable) · 서고 구분 고르기(Labor_LibMeta) 떼어 냄 · 동작 같음
- Labor_Rows 671 → 566 줄 · Labor_Detail 700 → 650 줄(717-3 모달 단추 자리)
- 낡은 시험 셋 고침: mat_marks · precombo_class = 옮긴 파일을 읽음 · hopyo_screen 의 Detail 「꼭 700 줄」 = 「700 줄 넘지 않음」(나눔으로 줄어듦)
- 시험 158 통과 · 1 실패(test_175 B09 겹 대가 = Store_Combo._PER 없음 · 나눔 전부터 · 719-2 몫)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:32:32 +09:00
eomsangdonandClaude Opus 5.5 dd4bbb3c61 knowledge(요구): 요구 554 라이다 외부 도구 시험 결과 — 평활해도 0.77→0.75 m · 업체 분류+TIN 0.12 m · SMRF 0.27 · 권함 = 격자만 TIN
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:31:57 +09:00
eomsangdonandClaude Opus 5.5 702ab1daa8 feat(714-2): 공용 단가산출 계산서 묶음 꼴 — 인력 · 재료 · 경비(장비) · 하위(714-1 줄 덧칸 묶음) · 장비 · 하위 로직 = 한 줄 + 노 · 재 · 경 칸 · 누르면 몫 식 석 줄 펼침 · 묶음 머리 기준단위 · 환산 근거 · 쓰는 입력 칸 · 맨 아래 비목 소계 셋 + 계
- calc_sheet.ts bundle 꼴 · bundleLines(순수 · 같은 이름 k 번째끼리 짝) · 새 calc_sheet.css(장비 줄 누름 · 몫 들여씀)
- M01 「인력·재료·경비」 = bundle 꼴 · 변수 칸 = 서버 쓰는입력
- 714-3 텍스트 수식 단추도 서버 쓰는입력 먼저(조건 · 재료 고르기 입력까지) · 없으면 조각
- 704-2 M02 계산서 보기 = 비목 꼴 그대로(DOM 전후 같음)
- 장비 펼침 넉 줄(손료 · 주연료 · 잡품 · 운전사) = sub3 「펼침」 덧칸 뒤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:30:33 +09:00
eomsangdonandClaude Sonnet 5.5 cf6e2cf725 test(717-4): 이음매 시험 자료에 일위대가 단위 「㎡」 추가
715-3 일위대가 단위 빈칸 막기와 맞물려 단위 없는 시험 자료가 실패하던 것 수정

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 16:21:28 +09:00
eomsangdon 4df7f1eb4e Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_laptop_7 2026-11-01 16:20:02 +09:00
eomsangdonandClaude Opus 5.5 5f36e85d7d fix(합침): ui_template_locale_m2 충돌 표시 걷음 — 717-2a 단가산출 키 · 717-4 고르기 키 양쪽 살림(6561c90ab 합침에 표시가 남았던 것)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:17:38 +09:00
eomsangdon 6561c90ab5 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1
# Conflicts:
#	ui_template/ui_template_locale_m2.ts
2026-11-01 16:15:34 +09:00
eomsangdon 8c62765fd7 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-01 16:14:19 +09:00
eomsangdon 1765901ad2 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-11-01 16:14:05 +09:00
eomsangdon 301a434402 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_2 2026-11-01 16:13:05 +09:00
eomsangdon 68bc3bd0c8 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_2 2026-11-01 16:12:59 +09:00
eomsangdon 290f087c73 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-11-01 16:12:56 +09:00
eomsangdon 472c78cd77 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5 2026-11-01 16:12:55 +09:00
eomsangdon 87545ea4c7 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_5 2026-11-01 16:12:48 +09:00
eomsangdon 431973435b Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_5 2026-11-01 16:12:45 +09:00
eomsangdon cbeec5a1cd Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-11-01 16:12:41 +09:00
eomsangdon 8f7fefe438 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-11-01 16:12:26 +09:00
eomsangdon 5188ec92ec Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-11-01 16:12:15 +09:00
eomsangdonandClaude Opus 5.5 36a21591e2 fix(715 · 712-2 이음): M02_Sangeun_Calc.times_of — 옛 Store_Combo._PER 대신 단위 한 벌 master_unit.per(712-2 가 옮김) · 합친 뒤 시험 63 통과
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:11:09 +09:00
eomsangdonandClaude Opus 5.5 ccabbad6fb feat(719-2): 공사 쪽 M02 단가산출(SK) — B09 대가 블록 · 산근 · [업데이트] · 내 자료집 SK
- Labor_Source: 굳힌 SK = 품셈 꼴 대가 블록(사본 단가.json 굳힌 키 줄 · M01 안 읽음) · 단가산출 속 일위대가 · SK 줄 = 그 대가 블록 부르는 줄 · 고정 줄 = fixed · 단가 고친 줄 = fixed(M02 셈 단가) · cover = P:+SK 이름표(SK 고쳐도 B09 덮개 그대로) · 줄 sk · poom(품셈 값) · 부르는 줄 수량 ÷ 굳힌 줄 배 · 사전조합 알약 = 굳힌 알약 · 서고 sangeun 사본
- Sangeun: SK 하위 로직 = 산근 · 단가산출 속 참조 걸음 · 결과단위 배 = 사본 줄에서(_unit(row) · unit_of(whole, code))
- Linked: 굳힌 SK 자원 키(굳힌 키 로직 줄 · 고정 이음)
- Refresh: 미리 보기 단가산출(바뀌는 SK 줄) · 서고 사본 원본판 다르면 다시 굳힘
- Bill_Library: 종류 sangeun(내 자료집 SK 불러오기) · changed · refresh_copy(같은 폴더 · 값 그대로 · 원본판 없는 옛 사본 그대로)
- Daega_Personal: 품셈 블록(굳힌 SK 포함) → 개인 층 SK(B09 품 · 단가 덮개 = SK 덮개 · 더한줄 수량 · 단가)
- M02_Item_Sangeun: 굳힌 SK 정체(굳힌 줄 차례 → 원본 cid)
- 화면: 일위대가 펼침 SK 줄 되돌림 [단가산출 값으로] · 곁 글 품셈 값 · 내 자료집 단가산출 글 · 업데이트 미리 보기 SK 줄 · 단가연결 내 것 단가산출(Labor_Api fetchLibrary 종류 넓힘)
- 시험 test_719_2_sk_project 5 통과 + 1 건너뜀(715-1b 엔진 단가 · 값묶음 뒤 자동 실행)
- test_155_5: _unit 인자 = 로직 줄로 바뀜(사본 줄에서 배 읽음) — 부르는 줄 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:09:53 +09:00
eomsangdon 9ca48fa581 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-01 16:06:33 +09:00
eomsangdonandClaude Opus 5.5 9d72d80b53 feat(717-4): 일위대가 [로직 추가] 「단가산출」 탭 · B09 재료 창고 단가산출 출처 · 단가산출 더한줄 고르기
- 새 M02_MasterTemplete_Sangeun_Pick — listSangeun = 위 층 SK 목록(마스터 › 회사 › 개인 · 금액 = 층 목록 기준금액) · openSangeunLinePicker = SK 더한줄 고르기(M01 로직 · 일위대가 · 단가산출 · M01 재료 → 715 더한줄 꼴)
- Labor_Pick — lib.sangeun 이면 탭 [기준 목록 | 단가산출] · 단가산출 탭 = 기준 목록 표 꼴(출처 = 단가산출 · 층) · 줄 누름 = 참조 {층, 주인, SK 키}
- withHopyo(일위대가 [로직 추가]) 에 sangeun 켬 · B09 단가연결 창은 그대로(SK 공사 연결 = 719-1 뒤)
- B09 재료 창고 출처 탭 「단가산출」 — SK 목록 · 연결은 알림만
- locale_m2 키 넷 · 시험 test_717_4_sangeun_pick

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:06:31 +09:00
eomsangdonandClaude Sonnet 5.5 ea1f749d99 test(717-2a): 낡은 시험 339 고침 — 불러오기 끝 새로고침이 Promise.all 두 목록(일위대가 · 상은)으로 바뀐 꼴에 맞춤
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 16:05:26 +09:00
eomsangdonandClaude Opus 5.5 dfa72ac292 test(715-1b): auto 커밋에 쓸려 올라간 엔진 덧칸 시험을 뺌 — 엔진 덧칸(sub3 712 뒤) 전엔 실패 · 715-1b 때 다시 넣음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:05:18 +09:00
eomsangdon 60f03604c3 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
# Conflicts:
#	M01_MasterData/M01_MasterData_Router.py
2026-11-01 16:05:01 +09:00
eomsangdon fdd781ca46 auto: 2026-11-01 16:04 (ESD_LAPTOP) 2026-11-01 16:04:58 +09:00
eomsangdonandClaude Opus 5.5 4585690465 style(715-3): 715 시험 머리 글 줄 길이 · fixture 받아 씀 noqa
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:04:57 +09:00
eomsangdonandClaude Opus 5.5 b62a1d9356 feat(715-3): 일위대가 줄 덮개(패치) · SK 참조 줄 · [단가산출로 저장] 길 · 일위대가 단위 빈칸 막기 · 「1 단위 당」 머리
- 새 M02_Labor_Patch.py — 줄 칸 패치 = SK 꼴 {덮개, 뺀줄, 더한줄}(한 벌) · 로직 줄 · SK 참조 줄만 · 줄 고름 · 자재표시(차례)를 cid 로 바꿔 뺀줄 뒤 제자리 · 셈 = 이름 없는 SK 로 M02_Sangeun_Calc
- M02_Labor_Doc — clean 이 패치 정리 · calc 로직 줄 · 참조 줄 패치 갈래(몇 줄 · 690 줄)
- M02_Labor_Expand — 사전조합 안 줄 패치 갈래 · 머리 「1 ㎡ 당」
- M02_Labor_Ref — 사전조합 줄에 패치 남김 · 줄 있는 일위대가 단위 빈칸 400(빈 새 문서는 받음 — 화면 새로 단추가 단위 null) · 409 글 종류 이름 사전조합 → 일위대가 · labor → 구조물 조합
- POST /api/m02/layers/{층}/sangeun/from-line(층 쓰기 권한) — 새 SK + 바꿀 참조 줄 · 셈 답에 머리
- Labor_Calc.ts 짝은 그대로(settle 은 서버 기준을 받음 · 거울 시험 통과)
- 낡은 시험 고침: test_m02_labor_ref · test_175_precombo_mats · test_204_2_b09_bill_library 의 줄 있는 사전조합에 단위(빈칸 막기) · 참조 줄 분모 = 그 사전조합 단위
- 새 시험 test_715_3_labor_patch.py(임시 storage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:04:31 +09:00
eomsangdonandClaude Opus 5.5 9875743181 feat(714-1): 계산서 줄 덧칸 — 쓰는 입력 · 묶음 · 기준단위 · 환산 근거 · M01 /text 와 M02 계산서 같은 줄 꼴
- 새 master_sheet_line.annotate — master_text.lines 답에 줄마다 덧칸(줄 차례 = 호표 _shares 비목 · 뒤 덧줄) · 머리 기준단위(결과단위 분모)
- 쓰는입력 = 수량 식 · 요소 · 덧줄 식이 쓰는 로직 입력(중간값 거슬러 감) · 기준단위 = 줄이 찾는 표 기준(712-3 basis) · 환산근거 = 표 기준배 ↔ 결과단위 배 · 재료 품목 단위 ↔ 줄 단위(712-2 note)
- M01 /text = 새 Store_Sheet 가 감쌈(Store.py · master_text.py 700 줄 근처라 안 늘림) · M02 /logic-lib/sheet 줄 칸에 같은 KEYS
- 지킴 시험 test_714_1_sheet_line(두 길 같은 덧칸)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:04:02 +09:00
eomsangdonandClaude Opus 5.5 881c204ca8 feat(712-2): 단위 한 벌 master_unit — 별칭 · 개수 묶음 · 결과단위 배 읽기 · 같은 성격 환산 · 근거 글
- 별칭(master_material) · 같은 성격 환산(master_pick) · 결과단위 배 읽기 셋(Store_Combo · Hopyo_Ours · master_basis) · 보이는 꼴 별칭(master_basis) 갈아 끼움 · 엑셀 맞춤(Hopyo_Match)은 master_pick 이름 그대로 이음
- 개수 묶음(개 · EA · 본 · 매) 1:1 — 본 ↔ 개 단위경고 4 줄 사라짐 · 값 같음
- 부피 ↔ 무게 = 단위중량 표 CC000337 고른 줄 받을 때만(기본 줄 없음) · 밀도 · ㎾h 경고 글만
- B08 /1000 · /40 손 식 = 실무 엑셀 G열 식이라 그대로(시험이 같은 값 대조)
- 지킴 시험 test_712_2_unit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:03:54 +09:00
eomsangdonandClaude Opus 5.5 cddb28b63a feat(714-2): 공용 단가산출 계산서 틀 — A00_Common/calc_sheet.ts(704-2 계산서 줄 올림 · 묶음 머리 변수 칸 · 바꾸면 onInput) · M02 계산서 보기 · M01 「인력·재료·경비」 보기가 한 벌로 씀(장비 한 줄 · 기준단위 · 환산 근거는 714-1 뒤)
- 704-2 Labor_Sheet 줄 · 소계 · 계 = calcSheetRows(표본 셋 DOM 전후 같음)
- 새 M01_MasterData_UI_LogicLab_Est.ts — /text 묶음 그대로 · 묶음이 쓰는 시험 입력(714-3 usedInputs) = 머리 변수 칸 · 「준비 중」 자리 걷음
- 낡은 시험 test_704_logic_standard 고침 — 계산서가 계산 줄 부품을 공용 계산서 안에서 씀(import 자리만 바뀜)
- 새 지킴 시험 test_714_2_calc_sheet

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:03:36 +09:00
eomsangdon 8e2aac9717 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_2' into sub_laptop_6 2026-11-01 16:02:56 +09:00
eomsangdonandClaude Opus 5.5 6d5b78f83e feat(712-1): 인력 · 재료 · 경비 묶음 셈 때 파생 · 기계경비 판별 한 곳
- 새 master_bundle — 호표 · 덧줄 = 비목 따라 · 비목 없는 로직 줄 = 기계경비면 경비 + 장비 · 아니면 하위
- 기계경비 판별 = 결과단위 원/시간(is_machine) — M02 엑셀 맞춤(이름 「시간당」) · B09 산근 · 산근 텍스트(입력 「기계」) 갈아 끼움
- 엔진 run 줄 덧칸 「묶음」 · 기계경비 줄 「장비」(출력 꼬리표만 · 노 · 재 · 경 몫은 줄 비목 그대로)
- 정본 칸 안 더함 · 5,911 줄 중 하위 31 · 기계경비 줄 1,044
- 지킴 시험 test_712_1_bundle

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:02:13 +09:00
eomsangdon 835a98bad9 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-01 16:01:07 +09:00
eomsangdonandClaude Opus 5.5 a64e3ca398 feat(717-2a): M02 왼쪽 맨 위 「단가산출」 칸 — 층 단추 따로(기본 개인) · 폴더 나무 · 목록표 · 상세 자리 · 대시보드 줄
- 새 UI_Side_Sangeun.ts — 서고 칸(716-4) kind sangeun · 목록표 고름 sangeunList
- 새 Sangeun_View.ts — 이름 · 단위 · 바탕 · 덮개 · 더한 줄 · 뺀 줄 · 열린 입력 요약 + 상세 자리표(717-2b 가 갈음)
- UI_Main 종류 sangeun 갈래 · 목록표 조건에 sangeun · Library_List 열 표(단가산출 = 바탕 · 고친 줄 · 쓰는 곳) · sangeunBlank(단위 식 · 바탕 없음)
- UI_Page 단가산출 층 상태 sk · UI_Side 맨 위 칸 · reveal 「단가산출」 · Layer open 「단가산출」 · Api Kind sangeun
- m_settings_nav 대시보드 마스터 템플릿 묶음 「단가산출」 줄(?open=단가산출) · locale_m2 키
- 낡은 글 대조 시험 넷 고침(197_1 · b01_settings_cards · m02_layer_screen · m02_precombo_scope) — 대시보드 줄 하나 늘고 OPENS · reveal · 목록 열 표 · 칸 차례가 바뀜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 16:00:03 +09:00
eomsangdonandClaude Opus 5.5 c0851ee63b feat(717-2a): 단가산출 목록 줄 덧칸 — 바탕(로직 키 · 이름 | 참조 SK | 없음) · 고친 줄 수 · 쓰는 곳 수
- M02_Labor_Ref.summary sangeun 갈래만(브레인 답 A · 그 함수 밖 안 만짐)
- 지킴 시험 test_717_2a_sangeun_list 2(임시 storage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:59:51 +09:00
eomsangdonandClaude Opus 5.5 915feb436e feat(714-3): M01 텍스트 수식 입력 단추 — 줄이 쓰는 시험 입력(표 찾기 행 열쇠 · 이름 조각)을 값 식 아래 단추로 · 누르면 그 자리 고르기/글 칸 · 바꾸면 시험 계산 입력 고침 + 다시 셈 · 정본 · 캐시 안 바뀜
- Formula.ts usedInputs(순수) · inputChip(부품 상자 단추 · 고르기 · 입력 칸)
- Detail → Formula 에 입력 · 값 · onInput 넘김 · LogicLab syncLeft(loud) = 흐름 예시값도 같은 길 · 줄 수 그대로(627)
- Logic_Api TextPiece 에 이름 조각 값 칸
- 새 지킴 시험 test_714_3_formula_inputs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:49:52 +09:00
eomsangdon cf7461749d Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-01 15:44:19 +09:00
eomsangdon 288af5cf9e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-11-01 15:43:13 +09:00
eomsangdon b9373d6c7f Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-11-01 15:42:21 +09:00
eomsangdonandClaude Opus 5.5 cfc5309a4d fix(716-3): Hopyo 화면 글 「사전조합」 → 「일위대가」 — M02_PrecomboTitle 키로
- 확인 창 [일위대가 생성] 단추 · 엑셀 보기 · 다시 잇기 · 장부 · 고르기 알림 · 구조물 조합으로 설명
- 서버 줄 상태 글 「확인된 일위대가」
- test_m02_hopyo_screen 단추 글 단언을 키 꼴로 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:42:14 +09:00
eomsangdon f8c628552c Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-11-01 15:42:12 +09:00
eomsangdon b4eb4b7521 Merge remote-tracking branch 'origin/dev' into sub_laptop_2 2026-11-01 15:42:09 +09:00
eomsangdonandClaude Opus 5.5 3d4bcaea59 feat(713-2): M01 로직 정본 개정 확인 창 — /save 개정 409 받으면 바뀌는 정본 줄(파일 · 로직 · 바뀜) 창 · 「원문 개정 반영」 확인 뒤 같은 몸에 개정확인 true 다시 보냄 · 취소 = 캐시 그대로
- 새 M01_MasterData_UI_LogicLab_Revise.ts(reviseHits · askRevise · saveRevised) · 부품 상자 모달 · 목록 줄 · 배지
- LogicLab [저장](흐름 저장 · 지우기 · 더하기 같은 길)이 saveRevised 로 · 줄 수 그대로(627)
- Logic_Api saveFiles 에 개정확인 인자(기본 false)
- 옛 409 stale 은 토스트 그대로
- 새 지킴 시험 test_713_2_revise_ui

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:42:00 +09:00
eomsangdon ec49810665 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-01 15:41:50 +09:00
eomsangdon f59bae54b1 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-01 15:40:19 +09:00
eomsangdon 94d8f1261d Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-11-01 15:39:58 +09:00
eomsangdonandClaude Opus 5.5 69d989e5c8 feat(717-1): 단가산출 상세 부품 길 주입 · M02 logic-lib 읽기 · 셈 길 넓힘
- Logic_Api.ts · Api_Fetch.ts · Logic_Note.ts — `/m01` 박힘 대신 setLogicApiPaths({ read, calc }) 길 묶음 · 안 넘기면 /m01 그대로 · 저장 · 만들기 · 관리자 길은 늘 /m01
- 새 M02_Logic_Lib_Read.py — /api/m02/logic-lib/ 로그인 길(logic/auto · logic/flow · elements · pick · materials · rows(인력 · 재료 · 기계만) · table(소요량 · 계수만) · calc · text) · M01 길 함수 그대로 부름 · row = 저장 안 한 줄 셈만 · 쓰기 없음
- main.py 등록(로그인만) · 지킴 시험 test_717_1_logic_lib.py + helper

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:39:36 +09:00
eomsangdon 1124cf26f6 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-11-01 15:36:19 +09:00
eomsangdon a005244a17 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-11-01 15:35:33 +09:00
eomsangdon 878667f7bd Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-11-01 15:35:31 +09:00
eomsangdonandClaude Opus 5.5 ceec2a99da feat(716-4): 서고 나무 폴더 — 종류 인자 · 안에 든 것째 지움 · 쓰는 곳 409 · 여럿 · 복사 붙여넣기
- 서버 `M02_Precombo_Folders` — 종류 인자(precombo · sangeun · 자리 파일 `{종류}_폴더.json`) · 폴더 지우기 = 안의 문서 파일째(모두 검사 뒤 한 번에 · 바깥에서 쓰면 409 + 그 이름 · 지우는 것끼리 참조는 됨) · 문서 지우기 · `대상` 여럿 옮기기 · 지우기 · 붙여넣기(새 키 · 본뜬 = 원본 · 같은 폴더 이름 「(1)」)
- 길 `M02_Labor_Router` — `/layers/{층}/{종류}-tree` GET · POST · `/paste`(옛 precombo-tree 주소 그대로)
- 화면 새 `UI_Side_Library.ts` — 일위대가 · 단가산출 두 칸이 같이 쓰는 서고 칸(Ctrl · Shift 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V · Delete · 지우기 확인 창 · 끌어 옮기기 여럿) · `UI_Side_Precombo.ts` = 일위대가 몫만 넘김
- `Api_Fetch` — 나무 부르기 종류 인자 · `pasteLibraryTree` · `newLibrary` 종류 sangeun
- 시험 — 새 `test_716_4_library_folders.py`(임시 storage) · 낡은 것 고침: `test_m02_precombo_folders`(빈 폴더만 지움 → 안에 든 것째 · 「없는 사전조합」 → 「없는 문서」 · FILE → file_of) · `test_m02_precombo_scope` + helper(나무 짜기 · 서고 길 글이 Library.ts 로 옮김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:35:23 +09:00
eomsangdon 75b65e03a2 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_2 2026-11-01 15:35:19 +09:00
eomsangdonandClaude Opus 5.5 b54950d28b feat(719-1): 프로젝트 사본 SK 풀어 굳힘 — 고친 줄 = 사본 단가.json 로직 줄 · 굳힌 키 GX9##### (SK 이름표 해시 · 같은 SK = 같은 키) · B09 서고 manifest 원본판
- 새 M02_Item_Sangeun: 참조사본 SK → M02_Sangeun_Calc.resolve 고친 줄 → 로직_단가산출.json 줄 · 사본 SK = 바탕 굳힌 키 · 덮개 0 · 원본 · 바탕판 · 짝잃음 근거
- M02_Item_Source: labor_doc_rows 가 굳힘 · price_bundle 이 굳힌 줄과 그 줄이 닿는 마스터 줄 · 고른 자재 담음
- M02_Item_Prices: 수집기 줄 하나 길(row) 떼어 둠 · 요소 없는 줄(참조 · 고정 더한줄) 건너뜀
- B09 Bill_Library: 서고 사본 manifest 원본판 = 일위대가.json 지문(source_version)
- 시험 test_719_1_sk_freeze 4(임시 storage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:34:29 +09:00
eomsangdon 28c773e1cd Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-11-01 15:33:59 +09:00
eomsangdon bebd0af063 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-01 15:33:58 +09:00
eomsangdon 7128579f92 Merge remote-tracking branch 'origin/dev' into sub_laptop_7
# Conflicts:
#	resources/tester/test_m02_hopyo_screen.py
2026-11-01 15:33:12 +09:00
eomsangdon 43eb6b7ad6 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_7 2026-11-01 15:32:06 +09:00
eomsangdon 9444c936f1 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7 2026-11-01 15:32:02 +09:00
eomsangdon 77a0ae5a10 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_7 2026-11-01 15:32:00 +09:00
eomsangdon ab32f21521 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-11-01 15:31:58 +09:00
eomsangdon 89d8caf942 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-11-01 15:31:56 +09:00
eomsangdon e4f65e7021 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_1 2026-11-01 15:30:42 +09:00
eomsangdon 808a07eab2 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_1 2026-11-01 15:30:40 +09:00
eomsangdonandClaude Opus 5.5 0c956a6c61 feat(711-2): M01 재료 모달 하나 — 상세 표 줄 · 시험 계산 자재 칸 같은 모달 · 물가 넷 가로 · 찾기 · 고르기
- 상세 쪽 openMaterialModal 기준 — 위 물가자료 · 유통물가 · 물가정보 · 거래가격 가로 넷(빈 칸 「-」 · 시험 계산이 쓴 값 굵게)
- 후보 = /materials(조건 · 단위 거름) · 조건 없으면 이름 찾기 · 찾기 칸(검색어로 먼저 거름 · 없으면 재료 전체 찾기) · 목록 값 열 뺌
- 줄 누르면 고름(시험 입력 · 저장 안 함) → 다시 셈 · 상세 표 재료 줄에서 골라도 시험 계산 자재 칸 따라감
- 기계 고르기 · M02 일위대가 [바꾸기]는 PickTable 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:26:22 +09:00
eomsangdonandClaude Opus 5.5 cc588889f6 feat(718-1): 불러오기 결과 「단가산출」 켬 — 산근 블록마다 바탕 없는 단가산출(715-2 받음)
- 새 M02_Hopyo_Sangeun.py — 단산 블록을 부르는 첫 엑셀 줄 1 단위 단가를 고정 더한줄 하나로 · 본뜬 「HP-###### 단산N」 · 같은 본뜬은 그대로(겹침 없음)
- 길 POST /layers/{층}/hopyo/{키}/sangeun · [끝내기] 지문 기록에 단가산출도 셈
- 마법사: 결과 「단가산출」 체크 켬 → ② 걸음 [단가산출 만들기] · 알림 글 「일위대가 · 단가산출」
- 시험: test_718_1 단가산출 더함 · 낡은 대조 고침 — 716-3 왼쪽 판 차례(extras 없어짐) · SANGEUN_READY 대조
- 고정 더한줄 셈 값(엔진 값묶음)은 715-1b 뒤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:22:27 +09:00
eomsangdonandClaude Opus 5.5 d27dba4f23 test(716-3): 낡은 시험 둘을 716-3 꼴로 고침 — M02 왼쪽 칸 차례 · reveal 옵션 갈래 없음
- test_m02_hopyo_screen::test_좌측_판_자리와_권한 — 옛 `extras ?` 자리 대신 일위대가 › 구조물 조합 › 구분선 › 불러오기 차례 확인
- test_197_1_dashboard_option_card::test_reveal_옵션만_줄을_누름 → test_reveal_옵션은_M01_로 — 옵션 정의가 M01 로 옮겨 M02 reveal 은 일위대가 · 도면만 펼침
- 기능 흠 아님 · 716-3 이 UI_Side.ts 를 바꿔 옛 꼴 기대가 낡음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:21:56 +09:00
eomsangdon da14e24154 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 2026-11-01 15:21:30 +09:00
eomsangdonandClaude Opus 5.5 54fc156f18 feat(713-3): 로직 목록 덧칸 서버 — /logics 줄마다 묶음(같은 절) · 작업량 부모 · 부르는/불리는 수 · 관련(비고 절 번호) · 정본 그대로 · 새 파일 Store_List · 인재경 줄 수는 712-1 뒤
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:21:17 +09:00
eomsangdon 28704549d4 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-01 15:21:05 +09:00
eomsangdon 4f8b92b270 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-01 15:21:04 +09:00
eomsangdon ee908fae28 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_6 2026-11-01 15:19:50 +09:00
eomsangdon 1a931e2ea1 feat(709-2): M01 표 · M02 목록 줄 수 = 화면 높이 · 쪽 단추 표 아래 · 한 장 표 화면 안 스크롤
- 쪽 있는 표(요소 · 표 목록 · 로직 목록 · 재료 고르기 모달 · M02 서고 · 새 항목 목록): 줄 수를 표 칸 위치 ~ 화면 아래로 셈(watchFit) · 창 크기 · 글꼴 늦게 실림에도 다시 셈 · 쪽 크기 바뀌어도 첫 줄 유지
- 쪽 단추 = 표 바로 아래 · 줄이 모자라면 표와 함께 위로 붙음
- 쪽 없는 한 장 표(소요량 · 계수): 높이를 화면 아래로 묶고 안에서 스크롤(watchHeight · 표 아래 주 높이 뺌)
- UI_Style.css 의 max-height calc(100dvh - 300px) 제거 · m01-master__pager 규칙 제거(부품 ui-pager 로)
- 낡은 점검 51-10(쪽 단추가 거름 줄 안에 있다고 봄) = 표 바로 아래 ui-pager 로 고침
2026-11-01 15:19:35 +09:00
eomsangdon 3a125d6088 Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_2' and 'origin/sub_laptop_4' into sub_desktop_1 2026-11-01 15:19:11 +09:00
eomsangdonandClaude Opus 5.5 c888a995b7 knowledge(요구): 요구 554 원문 기록 — 라이다 지금 로직 그대로 · 데스크탑 서브 git 밖 폴더에서 외부 도구 시험 · 결과 아티팩트
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:17:34 +09:00
eomsangdonandClaude Opus 5.5 4953848768 feat(711-1): M01 시험 계산 — 옵션 · 자재 격자 둘 + 수평 구분선 · 기본 4 열 · 자재 이름 누르면 고르기 모달
- 옵션 고르기 · 자재 고르기 사이 709-1 수평 구분선 부품(createDivider)
- 기본 4 열 · 긴 이름 칸 두 칸
- [품목 고르기] 단추 걷음 — 잡힌 품목 이름 링크를 누르면 모달 · 고르면 다시 셈

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:16:23 +09:00
eomsangdonandClaude Opus 5.5 7cf504a7c7 feat(713-1): 로직 정본 개정 확인 서버 — /save · 흐름 저장 · 지우기 · 더하기가 로직 정본 줄을 바꾸면 개정확인 없이는 409(개정 · 까닭) · 같은 줄 다시 보냄 · 요소 · 자체는 대상 밖 · 줄 바탕 지문 row_print(계산 칸) · GET /logic 지문 · 새 파일 Store_Revise
낡은 시험 고침: test_m01_api · test_m01_cache · test_m01_logic_flow_save · test_208_1_logic_alias · test_m01_logic_flow_project 가 임시 사본의 정본 로직 줄을 고쳐 저장 — 이제 개정 확인이 필요하므로 몸에 개정확인 true(뜻 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:15:30 +09:00
eomsangdonandClaude Opus 5.5 a0bd269d7f feat(710-2): M01 단가산출 상세 보기 고르기 — 머리줄 분할 고르기 · 고른 덩이만 · 기본정보 접힘 한 줄 · 지금 입력
- 새 M01_MasterData_UI_LogicLab_View.ts — 보기 넷(단가산출 상세 기본 · 인력·재료·경비 준비 중 · 시험 계산 · 텍스트 수식) · [신규 로직] 왼쪽 세로 나눔선
- 덩이 이름표 data-block + 그릇 data-view CSS 숨김 — 시험 계산은 숨어도 그려 두어 자동 계산 돎 · 로직 바꿔도 보기 유지
- 흐름 로직(FlowView) 두 덩이도 같은 이름표
- 기본정보 = 맨 위 접힌 한 줄(키 · 원문번호 · 이름 · 결과단위) · 막힘 까닭은 접어도 보임
- 단가산출 상세 머리 「지금 입력」 한 줄(읽기만)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:13:23 +09:00
eomsangdon 5068b6d3f7 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-11-01 15:10:05 +09:00
eomsangdon c55c2fb828 Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-11-01 15:10:04 +09:00
eomsangdon f7b0a96923 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7
# Conflicts:
#	ui_template/ui_template_locale_m2.ts
2026-11-01 15:09:17 +09:00
eomsangdonandClaude Opus 5.5 c6df183983 feat(718-3): M01 별칭 장부 화면 — 왼쪽 칸 · 모인 후보 표(이름 · 짝 · 회사 수 · 사람 수 · 충돌) · 승인 · 반려 · 마스터 장부 보기 · 지움 · 대시보드 줄
- 새 M01_MasterData_UI_Alias — 모인 후보 / 마스터 장부 탭 · 승인 · 반려 · 지움 = 확인 창 뒤 서버(718-2 길) · 마스터 장부 = M02 별칭 장부 부품 그대로(마스터 층) + 마스터 공사 내역 맞춤
- M01 왼쪽 판 = 구조물 옵션 정의 아래 「별칭 장부」 칸(본 칸 = 옵션 정의와 같은 자리) · 주소 ?open=별칭(M01 페이지 한 줄)
- 대시보드 설계 기본값 마스터 데이터 묶음에 「별칭 장부」 줄(m_settings_nav 한 줄 · 화면 글 키 하나)
- 시험 test_b01_settings_cards · test_197_1 = 줄 하나 늘어난 자리 · 별칭 칸 대조로 고침(낡은 줄 번호)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:09:14 +09:00
eomsangdon a5e235e08f Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_7 2026-11-01 15:08:56 +09:00
eomsangdon bee3c898ac Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_7 2026-11-01 15:08:52 +09:00
eomsangdon 295a6a73e5 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_7 2026-11-01 15:08:50 +09:00
eomsangdon 9304130c62 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_7 2026-11-01 15:08:49 +09:00
eomsangdon 1bbffcd2ae Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_7 2026-11-01 15:08:47 +09:00
eomsangdonandClaude Opus 5.5 f49b55a3d8 feat(718-1): 일위대가 불러오기 = 한시 마법사 — 결과 층 고름 · [끝내기] 때 올린 파일 지움 · 같은 엑셀 겹침 막음
- 좌측 「일위대가 실무자료」 → 「일위대가 불러오기」(locale M02_HopyoImport) · 옛 파일 목록 걷고 [불러오기] → 본 칸 마법사(새 M02_MasterTemplete_Hopyo_Wizard.ts)
- 마법사 = ① 결과 층(쓸 수 있는 층만 · 기본 개인) · 결과 체크(일위대가 · 단가산출은 715-2 뒤) · 엑셀 고르기 · 작업 중 이어서 → ② 엑셀 보기 창(모두 고정으로 들여오기 · 구조물 조합으로 그대로) → ③ 끝내기
- 서버: 올림 지문(sha256) · 작업 중 같은 엑셀 = 이어서 · 끝낸 같은 엑셀 409 · 지우기 = 끝내기(HP · 격자 · 맞춤셈 캐시 지움 · 만든 일위대가 있으면 지문 기록 · 별칭 장부 남음) · HP 번 다시 안 씀 · 목록 = 지문 있는 것만(옛 HP · 붙여넣기 HP 파일 그대로 숨김)
- 시험: 새 지킴 test_718_1_hopyo_import(임시 storage) · 낡은 시험 고침 — 같은 엑셀 다시 올림이 새 키를 내던 가정(excel · grid · flow) · 붙여넣기 HP 목록 가정(705_2) · 좌측 판 글 대조(screen)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:08:17 +09:00
eomsangdon cacc16ba03 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-01 15:05:42 +09:00
eomsangdonandClaude Sonnet 5.5 ccc2fe080f feat(716-3): M02 왼쪽 칸 차례 · 이름 · 구분선 — 일위대가 › 구조물 조합 › 구분선 › 불러오기 · 옵션 줄 뺌
- 일위대가(옛 사전조합) 제목 locale 키 M02_PrecomboTitle · 나무 안 글 · 마우스 오른쪽 메뉴 · 빈 글을 일위대가로
- 구조물 옵션 정의 읽기 줄 뺌(M01 에 입구 있음) · open=옵션 은 M01 로 그대로 돌려보냄
- 시험: PRECOMBO_TITLE 상수 기대 → locale 키 기대로 고침 · 칸 차례 시험 더함

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 15:04:41 +09:00
eomsangdon 287f0824fc Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-01 15:02:35 +09:00
eomsangdonandClaude Opus 5.5 9387f2642a knowledge(요구): 내역 15 원문 기록 · 14-5 원문 대조 — 조종원 환산 빼고 본문대로 · 쪼갠 12 합침 · 터파기 한 꼴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:02:07 +09:00
eomsangdonandClaude Opus 5.5 368216594f fix(715-2): 단가산출 셈 길도 저장 전 문서 참조를 위 층만 · 있는 문서 · 순환 검사(남의 개인 층 읽기 막음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 15:00:44 +09:00
eomsangdonandClaude Opus 5.5 9bcd8483af feat(710-1): M01 단가산출 상세 열 — 이름 · 수량 · 단위 · 로직 · 단가 · 금액 · 단가 1 행
- 수량 = 계산 답 고른 수량(% 덧줄은 율) · 계산 전 「—」
- 로직 열 = 옛 수량 칸 식 알약(단가 왼쪽)
- 단가 2 행 = 표 칸 돈 꼴 block 흠 — td 는 칸 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:58:52 +09:00
eomsangdonandClaude Opus 5.5 0b50669d4e feat(715-2): M02 단가산출 서고 종류 sangeun(SK-) · 참조 · 순환 422 · 지우기 막음 · 층 복사 + SK 셈 길(715-1b 몫)
- M02_Labor_Ref — PREFIX · TITLE · 키 SK · kind_of 셋째 갈래 · refs_in(담은로직 · 바탕 · 더한줄) 로 users · _no_loop · carry · frozen 이 SK 참조도 훑음 · clean_doc SK 갈래
- Expand 를 새 M02_Labor_Expand.py 로 뗌(refs.Expand 이름 그대로) · SK 갈래 = 1 단위 값
- 새 M02_Sangeun_Calc.py — 바탕 사슬 → 고친 줄 → M01 색인 덧씌움 + run_line(엔진 한 벌) · 저장 때 원값 · 바탕판 서버가 셈
- 새 M02_Sangeun_Router.py POST /api/m02/layers/{층}/sangeun/calc(저장 안 함 · 층 읽기 권한) · main.py 등록 한 줄
- Template_Layers LIBRARY_KINDS += sangeun · 뼈대 = 더한줄 · Labor_Router 서고 새로 길 Literal 한 줄
- 낡은 시험 test_m02_labor_ref 「LIBRARY_KINDS == (precombo,)」 → 새 종류 반영(점검 29)
- 새 시험 test_715_sangeun_store.py(임시 storage)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:58:36 +09:00
eomsangdon 27dc456d73 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_8 2026-11-01 14:55:12 +09:00
eomsangdon f32a9b3ecf Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-11-01 14:53:04 +09:00
eomsangdon 5e709d0c6b Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-01 14:53:03 +09:00
eomsangdon c27aaaf8ab Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_5', 'origin/sub_laptop_6' and 'origin/sub_laptop_8' into sub_laptop_3 2026-11-01 14:51:53 +09:00
eomsangdon b903a0db49 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-01 14:50:48 +09:00
eomsangdonandClaude Opus 5.5 a36b1051d4 feat(712-3): 표 기준 → 기준배 · 기준단위 읽을 때 파생 — 표 기준 글 · 빈 기준은 원문 절 머리 · 정본 표 안 씀 · API 표 밖 칸 기준파생 · 판정규(판 = 읽을 때 파생) · check_master 「배」 대조 7 건 · 지킴 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:50:06 +09:00
eomsangdonandClaude Opus 5.5 28e9a2a667 feat(718-2): 별칭 장부 시스템 모으기 서버 — 회사 · 개인 층 맞춤 + 공사 내역맞춤 후보 · 익명 · 충돌 · 승인 → 마스터 · 반려 기억
- 새 M01_MasterData_Alias(/api/m01/alias-ledger · 시스템 관리자만 · main.py 한 줄) — 공유 storage 읽기만 · 같은 짝 묶음(서명) · 회사 수 · 사람 수만 · 같은 열쇠 다른 짝 · 마스터와 다름 = 충돌 · 승인 = 서버가 다시 모아 찾음 → 맞춤 ledger.put 재검사 · 내역맞춤 마스터 기억 · 반려 = 마스터 층 별칭반려 기억 · 자동 반영 없음
- B09 잇기 기억 읽기에 마스터 층을 맨 아래로(브레인 허락) · test_707_4 기억 층 시험에 마스터 줄 한 줄
- 새 시험 test_718_2_alias_gather(임시 storage — 모음 수 · 익명 · 충돌 · 승인 → 마스터 줄 · 다른 회사 새 사용자가 씀 · 반려 기억 · 권한)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:50:02 +09:00
eomsangdonandClaude Opus 5.5 dd5e00f0ba knowledge(요구): 내역 14-6 EST 장비 줄 조사 — 장비 한 줄 + 노 · 재 · 경 칸 · 요구서 정함 6 반영
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:48:02 +09:00
eomsangdonandClaude Sonnet 5.5 a2742d528b feat(716-1·716-2): 옵션 칸 위 구분선 부품 · 시험 정리
- M01 로직 칸 아래 구분선 = 부품 createDivider
- 시험 셋 새 꼴로: 대시보드 줄 칸별(M01 3 · M02 3 · 계산 자리) · ☰ 페이지 둘(역할별) · 옛 open=옵션 돌려보냄 · M01 open 인자
- 낡은 시험 고침 까닭: 옵션 줄이 M01 로 옮겨 카드 순서 · 해시가 바뀜(test_b01_settings_cards · test_197_1 · test_309_2)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 14:46:48 +09:00
eomsangdonandClaude Opus 5.5 2939bff074 style(715-1a): M02_Sangeun_Doc 머리 글 줄 길이
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:44:30 +09:00
eomsangdonandClaude Opus 5.5 a9242671da feat(715-1a): M02 단가산출(SK) 문서 꼴 · 검사 · 줄 정체 kind:키#n · 덮개 · 더한줄 · 뺀줄 · 고친 줄 순수 함수
- 새 M02_Sangeun_Doc.py — clean(꼴 400 · 열린입력 = 배율만 · 원값 버림) · line_ids · patched(고름 · 자재표시 cid 열쇠 → 새 차례 · 짝잃음 남김)
- 엔진 안 만짐 · 시험 test_715_sangeun_doc.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:44:14 +09:00
eomsangdon d9d8cbc8e6 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-11-01 14:43:32 +09:00
eomsangdonandClaude Sonnet 5.5 a961e9cb69 feat(716-1·716-2): 구조물 옵션 정의 입구 M01 · 메뉴 표 둘 · M01 open 인자 (중간 — 구분선 부품 · 시험 정리 전)
- M01 왼쪽 단가산출 로직 칸 아래 구조물 옵션 정의 칸 · 본 칸 셋째 모드(저장 안 한 고침 물음)
- M02 옛 open=옵션 주소 = 시스템 관리자면 M01 로 돌려보냄
- settingLinks = 대시보드 칸별 줄(group · ?open=) · pageLinks = ☰ 페이지 둘
- 새 글 키 덧만: B01_Dashboard_CardLogic · CardStructOptionDef · CardPrecombo

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 14:43:23 +09:00
eomsangdon 2e43011069 feat(709-1): 쪽 단추 부품 · 수평 구분선 부품 · 부품 모음 견본
- ui_template_pager.ts: M01 buildPager 이동 + 화면 높이 줄 수 셈(fitRows) · 첫 줄 유지(pageKeepFirst) · 화면 크기 알림(watchFit)
- ui_template_divider.ts + overlay.css .ui-divider: 수평 구분선
- M01 buildPager = 새 부품을 부르는 옛 이름(PickTable · M02 목록 그대로)
- 부품 모음 견본 두 줄 · 지킴 시험 test_709_pager_fit.py
2026-11-01 14:40:53 +09:00
eomsangdonandClaude Opus 5.5 9827926a20 knowledge(요구): 요구 553 조사 둘 — 라이다 지금 코드 품질 · 무료 라이다 도구 비교
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:34:11 +09:00
eomsangdonandClaude Opus 5.5 c9d449f9e9 knowledge(요구): 요구서 내역12 사용자 확정 반영 — 이름 일위대가 · 고침 범위 · 모달 고침 자리 · 별칭 승인 · B09 근거 보기 · 수식 변경 = 시험 값
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:32:41 +09:00
eomsangdonandClaude Opus 5.5 f43b61b246 knowledge(요구): 내역 14 원문 기록 — 내역12 물음 여덟 답 · 갈린 로직 원문 대조 · EST 장비 줄 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:31:16 +09:00
eomsangdonandClaude Opus 5.5 c0b32805ed knowledge(요구): 요구서 내역12 점검 반영 — 정함 26 · 사용자 물음 8 · 빠진 걸음 셋 · 창 차례 · 점검 둘(구현 · 방향)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:22:58 +09:00
eomsangdonandClaude Opus 5.5 41be251ac5 knowledge(요구): 요구 553 원문 기록 — 라이다 입력 로직 무료 도구(QGIS 등) 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:05:53 +09:00
eomsangdonandClaude Opus 5.5 860dbe6408 knowledge(요구): 요구서 내역12 · 조사 넷 — M01 단가산출 로직 보기 · 엔진 바닥 · M02 단가산출 신설 · 메뉴 · 불러오기 · 프로젝트 복사(PLAN 709~719)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:01:06 +09:00
eomsangdonandClaude Opus 5.5 08def1d3db knowledge(요구): 내역 13 원문 기록 — 요구서 · 계획서 작성 뒤 구현 · 방향 점검
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:57:58 +09:00
eomsangdonandClaude Opus 5.5 23772023df knowledge(요구): 내역 12 원문 기록 — M01 단가산출 로직 보기 개선 · M02 단가산출 신설 · 폴더 · 연결 정보
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:30:09 +09:00
eomsangdonandClaude Opus 5.5 6dc0d27d4e knowledge(요구): 요구 552 원문 기록 — sub1 연결 전 백업 지움 허락
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:26:05 +09:00
eomsangdonandClaude Opus 5.5 294c126a39 knowledge(계획): PLAN 634~639 이관 — 요구 546~551 끝(아이콘 그룹화 · 노선 그리기 한 벌 · 가이드 추천선 · B05 라이다 · 노선 세트 · 작은 고침) · 메인 시험 374 통과 · PLAN 에는 621 만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:24:51 +09:00
eomsangdon 2fd90b7754 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-01 13:22:28 +09:00
eomsangdonandClaude Opus 5.5 cb5c26d424 fix(637-5): 설계 지형이 라이다인 B 에 노선 못 덮는 LAS 올림 = 거절 · 설계 필터 파일 · 행 그대로(요구 546)
- B05 LAS 올리기: 설계 라이다 B 는 시험 자리(lidar_try)에 따로 지어 노선 덮임 검사 → 다 덮을 때만 본 자리로 옮김
- 못 덮음 = 「노선을 ○ % 만 덮음 — 설계 지형이 라이다라 바꾸지 않음」 · 옛 LAS · 모델 · 메타 · DB 행 그대로 · 시험 자리 지움
- 공용 promote_build — 입력 LAS 갈음 · processed · models 덮어 옮김(수치지형도 · 지도 그대로 · 수정시각 살아 행 지문 그대로)
- 수치지형도 설계 B(덧붙임) · 예정노선 길 그대로
- 시험: 반쪽 LAS 거절 · 파일 지문 · 메타 그대로 / 다 덮는 LAS 갈음 · 행 지문 = 옮긴 파일

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 13:21:51 +09:00
eomsangdon 6567d126f1 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1
# Conflicts:
#	B05_Profile/B05_Profile_UI_ExpectedDraw.ts
2026-11-01 12:52:48 +09:00
eomsangdonandClaude Opus 5.5 e6a420788f feat(635-5): B05 경위도 ↔ 사업지 좌표 서버 변환 길 · 찾기 이동 정확 · 속성 판 경위도(요구 546)
- POST /api/projects/{id}/route/coords — 작업 좌표계(resolve_project_crs · WKT 그대로) pyproj 묶음 변환
- 찾기 이동 = 서버 변환 좌표 · 바탕 tm_grid 늘려 쓰기(ponytail) 걷음
- 키트 lonLat = 노드 고를 때 한 번 묻기 · 같은 점 캐시
- test_635_3 찾기 줄 = 서버 변환 부름으로 고침(옛 tm_grid 부름 걷힘)
- 실측 검증_sub6 B: 50.4 km · 181.2 km 찾기 이동 어긋남 0.000 m · 경위도 = pyproj

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:51:03 +09:00
eomsangdon 5a7128ac3d Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-01 12:49:44 +09:00
eomsangdonandClaude Sonnet 5.5 e8f3cb8a9d fix(639-1): 낡은 시험 둘 고침 · vite 지켜보기에서 코드 아닌 폴더 제외
- test_551_2: 선 그리기 색 읽는 자리 = A00_Common/map/map_route_preview.ts (636-2 가 RecPreview 에서 옮김)
- test_624_2: D03 도구줄에 추천(recommend) 묶음 더해짐(636-2)
- vite.config.ts: server.watch.ignored = tmp · storage · docs · venv · logs · resources/tester · *.log

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-01 12:49:12 +09:00
eomsangdonandClaude Opus 5.5 8d3f854daf feat(635-4): B05 예정노선 수정에 흐린 다른 세트 겹 · 이음점 붙기 · 벗어남 안내 배선(요구 546 · 547)
- ExpectedDraw — plan-context 겹 읽음 · 그림(켬끔 상관없이 보기만) · 키트 snaps = 남 세트 끝 · 그리기 칸 벗어남 안내 한 줄 · 검증 창구 others
- 시험 test_635_4 배선 줄 대조 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:37:46 +09:00
eomsangdon 946304e9bb Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-11-01 12:30:21 +09:00
eomsangdon 021dca8bae Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-11-01 12:29:17 +09:00
eomsangdonandClaude Opus 5.5 a83db2c6f8 feat(635-3): B05 왼쪽 위치 · 그리기 칸(예정노선 키트) · 예정노선 수정 켬끔 · 받은 노선 비교 · 받은 노선으로 · 켬 때 어둡게(요구 546)
- ExpectedDraw = 키트 createPlanKit 「b05」 한 벌 — 위치 칸(찾기 → 지도 이동만) · 그리기 칸 머리 「예정노선 수정」 · 받은 노선 켬끔 · 길이 비교 한 줄 · [받은 노선으로](받은 꼭짓점 다시 추적 + 초안 expected [] · 한 걸음)
- 고칠 선 = 받은 원본 꼭짓점(GET /route/plan/source · 635-2) · 고친 뒤엔 고친 꼭짓점
- 새 B05_Profile_UI_Panel_Plan — registerPanelSection order 1 · 2 빈 자리 + 편집기가 채움
- 켬 = 발자국 ~ 구조물 마커 불투명 --draw-map-dim(0.35 · 새 토큰) · 등고 높이값 · 예정노선 그림은 그대로 · 장면 overAll = { paint, dim }
- 도구줄 「예정노선 변경」 · 「예정노선 원본」 걷음(map_toolbar · map_tools · Chrome · css)
- RouteEdit 683 → 682 줄(주석 한 줄 걷고 frame 한 줄) · Render 648 → 653
- 시험: 새 test_635_3 · 626_3 키트 · 아이콘 걷음 대조로 고침 · 551_2 불투명 값(색 아님) 받음 · 568_1 · 634_2 아이콘 목록에서 둘 뺌(걷음)
- 검증_sub6 B: 픽셀 불투명 255 → 89 · 끌기 · 지움 · 다시 긋기 · 받은 노선으로 · 저장 · 다시 열기 · 초기화 = 받은 노선 · 지킴 지문 110/110 · 되돌림

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:28:43 +09:00
eomsangdonandClaude Opus 5.5 23059f598b feat(635-4): B05 흐린 다른 세트 겹 · 이음점 셈 · 키트 붙기 칸(요구 546 · 547)
- 새 logic/B05_Profile_PlanContext.ts — 흐릴 세트(내 것 빼고) · 이음점(남 세트 끝) · 받을 때 이어진 이음점 · 벗어남 안내 · 이름표 자리
- 새 B05_Profile_UI_PlanContext.ts — plan-context 실시간 읽기 · 옅은 선 + 세트 이름표 · 누름 칸 없음(보기만)
- 키트 · 긋기 · 고르기 snaps 덧칸 — 긋기 · 노드 끌기가 이음점에 붙기만
- 기능 표 others 줄(B05 보기만)
- 시험 test_635_4(6)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:23:28 +09:00
eomsangdon 499f2fb7ec Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-01 12:12:08 +09:00
eomsangdonandClaude Opus 5.5 ad428e4b4d fix(638-3): [잇기] 판 · 메뉴로 시작하면 지도 고리를 바로 그림
- startJoin 뒤 onSelect(페이지 refresh · map.redraw) — 마우스가 움직이기 전엔 고리가 안 보이던 것

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:11:21 +09:00
eomsangdon 23b136a6c0 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-01 12:06:01 +09:00
eomsangdon 0b8a2b734f Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-01 12:05:55 +09:00
eomsangdon e6ea6db51d auto: 2026-11-01 12:05 (ESD_LAPTOP) 2026-11-01 12:05:30 +09:00
eomsangdon 4432c18816 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-11-01 12:04:19 +09:00
eomsangdonandClaude Opus 5.5 a7abcd6c5f fix(636-2): 가이드 선 글 자리 = 끝 지점 핀 아래 · 화면 오른쪽 반이면 오른쪽 맞춤(지점 이름 · 화면 끝에 안 걸림)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:03:48 +09:00
eomsangdonandClaude Opus 5.5 eba5223356 feat(638-3): 예정노선 노선 세트 화면 — 속성 판 세트 칸 · 연도 밑 세트 줄 · 이름표 「2027-1」 · [잇기] · 저장 뒤 끊김 경고 · D04 세트 줄(요구 547 · 548)
- 속성 판 · 왼쪽 칸 세트 고름 = setChoices(있는 세트 + 해마다 새 세트) → assignSet(토막 · 구역 · 구간 고름 모두) · 옛 setPiecesYear · setLineYear 자리 갈음
- 왼쪽 해 줄 밑 세트 줄(길이 · 구간 수 · 덩이 수 = 마지막 서버 셈) · 같은 세트 고름 · 되돌리기 뒤 길이 글 옛 값 흠 고침(열쇠에 구간 꼴)
- 지도 이름표 「2027-1」 · 속 빈 마름모 = 같은 세트 · 마커 이름 「구간 나눔」
- [잇기] = 선 끝 노드 속성 판 · 우클릭 → 다른 선 끝 누름 = joinEnds 한 걸음 · Esc 그만
- [저장] 뒤 derived.sets parts > 1 · branch = 알림 「2027-1 이 2 덩이 · 빈틈 35.0 m」(Group.setWarnings · setGapM)
- D04 표 첫 칸 = 세트 · 선 · 덩이 · 갈래 + 확정 막힘 알림 띠
- 기능 표 set(B05 보기만) · join(B05 숨김) 줄 · 시험 638_group 빈틈 · 626_3 표 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 12:00:21 +09:00
eomsangdonandClaude Opus 5.5 932bab4248 fix(예정노선): 634-3 한 행 자리 클래스 d03-plan__icons — 그리기 칸 d03-plan__row 와 겹침 풂
- 한 행 자리 CSS(absolute 위 · 좌 · 우)가 그리기 칸 줄(긋기 · 연도 구역 마커)에도 걸려 머리 위로 뜸 → 이름 바꿈
- 시험 553_4 · 625_draw 클래스 이름 맞춤

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:57:41 +09:00
eomsangdonandClaude Opus 5.5 527e50180b feat(636-2): 추천노선 메뉴 · 선 그리기 공용(map_route_preview) · 예정노선 가이드 선(지점 잇기 · 20 m · ±14 % · 급경사 문턱 = 범위 둘째 경계 · 보기만)(요구 546)
- B05 추천 미리보기 = 공용 메뉴 · 그리기 부름(저장본 넷 · 동작 그대로)
- 도구 표 recommend 줄 = 두 페이지
- test_626_1 = 메뉴 검사를 공용 파일로 옮김(B05 파일이 createPickMenu 를 직접 안 부름)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:55:21 +09:00
eomsangdon db99c46a25 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-11-01 11:54:21 +09:00
eomsangdonandClaude Opus 5.5 b3dcb73784 feat(637-3): B05 지형 칩 옆 [LAS 올리기] · 진행 서클 · 확인창 → 설계 지형 바꿈 · 공용 올리기 부품 map_lidar_upload(예정노선 같은 단추 · 흐름) · 갈음 때 안 지은 라이다 필터 파일 · 행 지움(요구 546)
- 공용 A00_Common/map/map_lidar_upload — 파일 고름 · POST multipart · 1 초 묻기 · 서클 · 끝난 뒤 일(주소 · 진행 묻기만 인자)
- 예정노선 UI_LidarUpload = 부품 부르기(화면 · 동작 그대로) · 안 쓰는 uploadPlanLidar 걷음
- B05 UI_RouteEdit_Terrain — 칩 · 단추 · 서클 한 칸 · 끝나면 확인창 「설계 지형을 라이다로 바꿈?」(이미 라이다 = force 다시 셈) · covered 거짓 = 바꿈 막고 알림 · 들어올 때 도는 일 이어 봄
- common_util_lidar_upload.drop_stale_filters — 갈음 뒤 안 지은 라이다 필터(옛 LAS) 모델 · 등고선 · 마스크 · 지면점 · manifest 줄 지움 · save_models drop = 그 행만(확정 행 남김)
- 시험 test_637_lidar(필터 지움 · 행 지움 · B05 화면) · test_624_4b(올리기 = 공용 부품으로 옮김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:53:48 +09:00
eomsangdonandClaude Opus 5.5 6a67384ff9 knowledge(요구): 요구서 546 3장 — 가이드 선 걸음 · 기울기 = 고정 20 m · ±14 %(그리기 칸 값과 따로)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:52:40 +09:00
eomsangdon 205669f439 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-11-01 11:50:43 +09:00
eomsangdonandClaude Opus 5.5 f48be66c9a fix(637-4): [초기화] 때 설계 지형도 초기값으로 — 초기값에 stage 1 지형 고름 담음 · 되돌릴 때 stage 1 확정값 · CONFIRMED 모델(노선 행이 가리키는 것) 함께 · 옛 초기값 = 지형 그대로 + 로그 한 줄(요구 546)
- 재현(검증_sub4 B · 라이다 → 수치지형도 → [초기화]): stage 1 = 수치지형도 · 노선 행 = 라이다 모델 · 횡단 지반선 대 stage 1 지형 평균 3.78 m · 최대 13.67 m
- 고친 뒤 같은 길(API · ORCA 칩 → [초기화]): 세 자리 라이다 · 어긋남 0
- las_selection · 등고선 간격 등 지형 밖 칸 안 건드림 · stage 1 행 없으면 안 씀
- 시험 test_637_4_reset_terrain 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:50:22 +09:00
eomsangdon 1ee98dd8ca Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_6 2026-11-01 11:49:42 +09:00
eomsangdonandClaude Opus 5.5 cd0f68946b fix(부품): 묶음 단추 켠 수 원형 숫자 걷음(요구 551)
- createPickMenu 여럿 = 아이콘 + ▾ · 켠 표시는 단추 눌림(하나라도 켜면) 그대로
- 배지 count accent 덧 · 메뉴 css 배지 줄 걷음 · 견본 · 겹 줄 머리 글 맞춤
- 시험 634_1 고침 — 배지 대신 「배지 없음 · 눌림 = 켠 줄 있음」을 잡음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:49:26 +09:00
eomsangdon 4eb4fba5d7 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-11-01 11:49:17 +09:00
eomsangdonandClaude Opus 5.5 def02db1fe knowledge(요구): 요구서 546 3장 — 묶음 단추 켠 수 원형 숫자 없음(요구 551)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:49:05 +09:00
eomsangdon ba82499843 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-11-01 11:48:25 +09:00
eomsangdon 0e4ce3cec1 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_6 2026-11-01 11:47:37 +09:00
eomsangdonandClaude Opus 5.5 067dc7d461 knowledge(요구): 요구 551 원문 기록 — 지도 아이콘 묶음 단추 켠 수 원형 숫자 걷음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:47:15 +09:00
eomsangdonandClaude Opus 5.5 e70201a626 feat(635-2): 받은 세트 문맥 GET plan-context · 원본 꼭짓점 GET route/plan/source · 다시 추적 전 조밀화 = 체인 초기선 거리 0(요구 546 · 547)
- 세트 연결 = 638-1 그대로(B 하나 = 세트 하나 · 열쇠만 세트)
- 새 D04_PlanConfirm_Context — 같은 예정노선 세트 모두 · 내 세트 · 연도 · 임도 좌표계로 pyproj 변환 · 업체 이름 없음 · 보기 전용
- 새 B05_Profile_Router_PlanSource — 원본 꼭짓점 = 범위 + 지표면 트림 · 조밀화만 뺌(Router_Replan 안 늘림 · 덧칸 대신 새 길)
- RouteFollow 추적 전 체인과 같은 3.6 m 조밀화(브레인 허락 · 옛 촘촘 입력은 그대로 = 626-2 같음)
- 실측 검증_sub3: 원본 64 점 → follow = 초기선 181 점 거리 0 · 파일 안 바뀜

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:46:53 +09:00
eomsangdon e478fe9067 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-11-01 11:44:11 +09:00
eomsangdonandClaude Opus 5.5 ae6eace1ad refactor(635-1): 예정노선 키트 · 기능 표 · 지도 입력 한 함수 — oneLine · 가짜 값 걷음(요구 546)
- 새 D03_PlanAnalysis_UI_Features.ts — 노선 그리기 기능 표(요구서 546 4장 · pages d03 · b05 = use · view · hide · 까닭) · featureUse · featureOn · featureShown
- 새 D03_PlanAnalysis_UI_Kit.ts — createPlanKit(부품 엮기 · 왼쪽 칸 위치 · 지점 · 그리기 · 구역·연도 · 겹 차례 · 키 · sync) · bindPlanInput(누름 · 맴돎 · 놓음 · 우클릭 · 연도 마커 끌어 놓기 · capture = B05 켠 동안 먼저 받음)
- 예정노선 UI_Page 680 → 620 줄 · 키트로 엮음(화면 · 저장 · 되돌리기 그대로) · UI_Map 지도 입력 줄 → map.input
- UI_Draw · UI_Menu · UI_Props · UI_DrawPanel — oneLine 플래그 → 기능 표 page 읽기 · 지점 · 경위도(lonLat) · 연도 시작 = 없으면 숨김(빈 지점 · epsg 0 가짜 값 걷음) · oneLineBlock → lineLimitBlock
- B05 ExpectedDraw — page: "b05" 넘김 · NO_POINTS · 가짜 값 걷음 · 지도 입력 = bindPlanInput capture(동작 그대로) · 속성 판 경위도 빈칸 → 칸 숨김
- 시험 고침: test_626_3(oneLine 글 대조 → 기능 표 · 키트 capture 대조) · test_475(위치 칸 서클 = 키트 site.title)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:42:49 +09:00
eomsangdon d9cc2144f2 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-11-01 11:42:16 +09:00
eomsangdonandClaude Opus 5.5 61733d064d feat(지도): 634-2 · 634-3 아이콘 표 두 장 · 묶음 메뉴 · 한 행 배선 · 호버 = 표(요구 546)
- 겹 표(map_layer_list) = 그룹1 22 줄 표 차례 · title = 호버제목 · group(수계망도 · 도로 · 토지·건물 · 제한·재해) · pages · 숨김 표 걷음 · 도로300m 한 줄(「도로」 묶음 · 기능 남김)
- 겹 줄(map_layer_bar) = 단독 넷 | 묶음 넷(여럿 고르기 메뉴 · 켠 수 배지 · 안 닫힘) | 그래픽 카드 · sync 가 단추 · 메뉴 줄 막힘 · 글을 맞춤(why = 페이지 까닭)
- 도구 표(map_toolbar) = 그룹2 17 줄 title · mapToolGroups 가 글을 표로 맞춤 · 기본 단추 생성 · 지형 칩 막힘 「수치지형도/라이다 — 까닭」
- 띠 = 한 자료 · 겹 둘(GL fillEdge 거름 · 옛 캔버스 · 범례 줄 따로) · B05 띠 받기(D03 과 같은 서버 길 · bandCollection 공용)
- D03 = 겹 줄 · 도구줄 한 행(createMapToolRow) · 222px 기댐 · 줄바꿈 CSS 걷음 · 측점 · 기울기 호버 덮어쓰기 걷음 · LAS 올리기 호버 걷음
- B05 = 세로 두 줄 → 한 행 · 호버 덮어쓰기 걷음(도구줄 · 1m 등고선 · 3D · 추천) · 겹 막힘 querySelector 덮어쓰기 → 공용 sync · 임도(업로드) 보임 + 「받은 임도 자료 없음」 막힘 · 스냅 간격 날 select → 고르기 메뉴(하나)
- 켬끔 기억 열쇠 · 측점 · 기울기 처음 값 · 그래픽 카드 취향 열쇠 둘 그대로
- 낡은 시험 고침(568_1 · 622_2 · 624_2 · 624_3 · 553_4 · 625_draw · b05_routeedit_initial · 626_3 · 390_4 · 346_2 · 353_1 · 581_2 · 584_1 · 584_3) — 표 꼴 row() · 호버 글 = 표 · 한 행 · 고르기 메뉴로 바뀐 글을 잡음 · 새 지킴 634_2

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:40:26 +09:00
eomsangdon 480c325934 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-11-01 11:36:14 +09:00
eomsangdonandClaude Opus 5.5 3753a5cdb4 feat(638-2): 예정노선 Api 꼴 — derived.pieces[].set_key(638-1 서버 꼴 0b738b20e)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:36:03 +09:00
eomsangdon fd99b91b93 Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_4 2026-11-01 11:35:52 +09:00
eomsangdonandClaude Opus 5.5 0b738b20e4 feat(638-1): 예정노선 노선 세트(연도-번호) 서버 — 조각 set · 같은 세트 합침 · derived.sets 덩이 · 갈래 · 판 2 · 업체 열쇠 세트 · 확정 막음 · 연결 = 세트 한 줄(요구 547 · 548)
- 파일 pieces[].set · 판 2(옛 서버는 409 로 세트를 못 덮음) · 옛 조각 = 그 해 선 · 거리 차례 번호
- normalize — 같은 선 이웃 같은 세트 하나(앞 id · 마크 걷음) · TS Group.ts 짝 · 거울 사례 = sub6 fixtures 그대로
- derived.sets[] {key, year, no, parts, branch, length_m, pieces}
- D04 단위 = 세트(이름 「2027-1」 · 폴더 「2027-1_<선>」) · 덩이 2 이상 · 갈래 세트는 확정 409
- 업체 열쇠 = 세트 · 옛 조각 id 풀어 봄 · D03 [저장] 때 세트 열쇠로 · 사라진 세트 409
- 연결 = 세트 이은 한 줄 · 아니면 409 · D01 받은 목록 · B01 홈 = 세트 열쇠
- 낡은 시험 고침: 530_2 · 590_1(판 2) · 531_3 · 531_d04 · 563_2(조각 이름 → 세트 이름)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:34:39 +09:00
eomsangdon 690524d328 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into main_laptop_1 2026-11-01 11:34:00 +09:00
eomsangdonandClaude Opus 5.5 85dade9a21 feat(637-2): B05 라이다 나중 올리기 창구 — POST · GET /route/lidar(뿌리 B04_PreProcess/models 짓기 · 입력 lidar_las · 갈음 · 넣기/고치기만 keep · 수치지형도 B = las_selection 적기 · 노선 2 m 덮임 covered · 저장 잠금) · 지형 바꿈 force(같은 지형 다시 셈) · main 등록 · 시험(요구 546)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:32:57 +09:00
eomsangdonandClaude Opus 5.5 e2da944c78 feat(636-1): 예정노선 가이드 추천선 셈 — 2 m 격자 A*(요구 546)
- 새 A00_Common/map/logic/map_guide_route.ts — 지점 차례 구간마다 A* · 20 m 걸음 56 방향 · ±14 % 막음 · 급경사 35°+ · 기울기 · 꺾임 벌점 · 가중 어림 × 3 · 상자 두 점 + 여유(끝에 닿았을 때만 두 배 한 번) · 펼침 30 만 + 450 ms 상한 · 첫 · 끝 걸음 정확 · 1 m 격자 2 칸 솎음 · 못 닿음 = 가장 가까이 간 데까지
- 화면 가이드 전용 · 파이썬 짝 없음 · 저장 없음
- 지킴 시험 test_636_guide_route — 평지 · 지그재그 · 못 닿음 · 지점 셋 · 급경사 벌점 · 1 km · 솎음 · 지형 밖

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:32:40 +09:00
eomsangdonandClaude Opus 5.5 83f3bc0a5c feat(638-2): 예정노선 노선 세트 화면 셈 — 세트 지정 · 끝에서 나눔 · 이웃 합침 · 고를 세트 · [잇기](요구 547 · 548)
- 새 logic/D03_PlanAnalysis_Group.ts: assignSet(고른 구간 끝 나눔 → 세트 → 같은 선 이웃 같은 세트 하나 · 앞 id) · mergeSets(서버 Network.derive 짝) · setChoices(있는 세트 + 해마다 새 번호) · joinEnds(닿음 바로 · 떨어짐 곧은 토막 덧 = 앞 선 세트)
- Plan.ts 세 곳: PlanPiece.set · 새 선 처음 세트(사업연도 시작 해 새 번호 · 갈래 = 붙은 자리 세트 · 옛 꼴 = 그 해 최댓값 + 1) · 이음 합침 비교 = 세트 · 661 줄 그대로
- Select.ts 끌어 고르기 시험 조각에 set 칸(타입 맞춤)
- Api.ts 꼴: derived.sets(PlanSetView · 638-1 서버 셈)
- 시험 test_638_group.py · 거울 사례 fixtures/d03_638_set_cases.json(sub3 638-1 과 같은 파일)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:31:17 +09:00
eomsangdonandClaude Opus 5.5 f6708c6818 refactor(637-1): 예정노선 LAS 올리기 · 짓기 공용 모듈 common_util_lidar_upload — 이름 검사 · 좌표계 · 옮겨 놓기 · 받기 · 갈음 · 짓기(maps=False) · 뒤 일 · 진행 기록 · DB 넣기/고치기만(keep = 갈음 때 id · 확정 · 입력 연결 살림) · 예정노선은 그걸 부름(동작 · 응답 그대로) · 지킴 시험 test_637_lidar(요구 546)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:28:57 +09:00
eomsangdonandClaude Opus 5.5 2265ac4f7c feat(부품): 634-1 여럿 고르기 메뉴 · 한 행 두 영역 · 막힘 글 · 새 아이콘(요구 546)
- createPickMenu multi — menuitemcheckbox · 눌러도 안 닫힘 · values() · 줄 icon · disabled · title · setItem · 단추 ▾ + 켠 수 배지(0 = 없음) · 하나라도 켜면 눌림 · 밀기 · 창 크기 바뀌면 닫힘 · offLabel 없으면 끔 줄 · 눌림 없음
- createMapToolRow — 그룹1 왼쪽(행 반까지) · 그룹2 오른쪽 · 영역마다 min-width 0 + 자기 가로 밀기 · [?] 판은 행 아래
- mapToolTip — 평소 제목만 · 막힘 때만 「제목 — 까닭」 · MAP_TOOLS 글 = 표 호버제목
- 아이콘 — 묶음 넷(streams · signpost · parcelHouse · ban) · 겹친 셋 대신(contours · cutFill · lineament) · 스냅 간격 spacing
- 배지 count · accent 덧 · 부품 모음 견본 「고르기 메뉴(하나 · 여럿)」 · 지도 도구줄 한 행 견본
- 시험 568_1 고침 — 예정노선 둘 줄이 한 줄 꼴이 되어 목록에 잡힘 · 새 지킴 634_1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:26:52 +09:00
eomsangdonandClaude Opus 5.5 bbe3a05201 knowledge(요구): 요구서 546 8장 노선 세트(연도-번호) · 조사 547 — 받기 단위 = 세트 · 끊김 경고 · 잇기 · PLAN 638 · 635-2 세트 연결 · 착수(요구 549)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:17:39 +09:00
eomsangdonandClaude Opus 5.5 bea1cd92b9 knowledge(요구): 요구 550 원문 기록 — 착수 뒤 브레인 메모장 압축(내역서 · 평면도 몫은 남김)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:11:38 +09:00
eomsangdonandClaude Opus 5.5 f06ae457c1 knowledge(요구): 요구 549 원문 기록 — 조사 끝나면 요구서 · 계획서 고치고 착수
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:09:29 +09:00
eomsangdonandClaude Opus 5.5 74536f1c86 knowledge(요구): 요구 548 원문 기록 — 중복 연도 없음 = 한 선 한 연도 · 같은 연도 안 선 세트 1 · 2 구분 · 같은 번호끼리 같은 노선
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:08:37 +09:00
eomsangdonandClaude Opus 5.5 91654d5041 knowledge(요구): 요구 547 원문 기록 — 예정노선 노선 그룹 · 연도 정의(지운 뒤 같은 연도 · 골라 연도 지정 · 저장 때 끊김 경고 · 병합) · 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 11:05:29 +09:00
eomsangdonandClaude Opus 5.5 7a28fb8bd0 knowledge(요구): 요구서 546 · 조사 셋 — 지도 아이콘 그룹화 · 노선 그리기 한 벌 · 예정노선 가이드 추천선 · B05 라이다 · PLAN 634~637 승인 대기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:27:40 +09:00
eomsangdonandClaude Opus 5.5 7b0eb34fb0 knowledge(요구): 요구 546 원문 기록 — B05 평면 · 예정노선 지도 아이콘 그룹화 · 로직 공용화 · 작업 전 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 10:07:25 +09:00
eomsangdonandClaude Opus 5.5 d6805f41f6 knowledge(계획): PLAN 632 이관 — 데스크탑 메인 venv 라이브러리 이미 깔림 확인(632-2) · PLAN 에는 621 만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHDcYVshd8kicU5oARJ
2026-11-01 08:46:00 +09:00
eomsangdon 346a70d9bd auto: 2026-11-01 00:30 (EOMSANGDON-HOME) 2026-11-01 00:30:44 +09:00
eomsangdonandClaude Opus 5.5 5518a5bba6 knowledge(계획): PLAN 607 · 623~626 · 625-5 · 631 · 633 이관 — 끝난 절 docs/raw/plans 로 · PLAN 에는 621 · 632 만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHDcYVshd8kicU5oARJ
2026-11-01 00:28:52 +09:00
eomsangdonandClaude Opus 5.5 331a12b267 chore(설정): 시놀로지 전달용 임시 폴더 /_sync_data_forest/ git 밖(요구 544)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHDcYVshd8kicU5oARJ
2026-10-31 23:42:20 +09:00
eomsangdonandClaude Opus 5.5 defb98dd1c knowledge(요구): 요구 543 · 544 원문 기록 — 1 m 유지 · 큰 산림 자료는 git 대신 시놀로지로 · 올리지 못한 데이터 커밋 되돌림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHDcYVshd8kicU5oARJ
2026-10-31 23:41:28 +09:00
eomsangdon 18bfd28412 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-31 23:40:56 +09:00
eomsangdon 3cabb6e5f0 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-31 22:52:54 +09:00
eomsangdonandClaude Opus 5.5 a1092b6c7c fix(633): 격자 칸 = 좌표 간격 — B05 급경사 · 음영 · 0차곡 칸 크기 틀림 고침(요구 538)
- 공용 grid_cell_m — 적힌 칸과 좌표 간격이 1 % 넘게 다르면 좌표 간격 · 로그 한 줄
- D03 급경사 · 음영 · 0차곡(상자 · DEM 길) · B07 도곽 음영이 공용 함수로
- 판 올림 — 급경사 · 음영 2 → 3 · 0차곡 DEM 길 따로 판 4
- B04 평활 격자는 넓힌 실제 칸을 적음
- 검증_sub2 B 평활: 급경사 중앙 146 → 67 % · 빨강 89.4 → 26.7 % · 0차곡 21 가닥 989 m → 99 가닥 4,110 m · D · B 수치지형도 그림 전후 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHXDsevXBHePbAoqUv
2026-10-31 22:52:53 +09:00
eomsangdonandClaude Sonnet 5.5 e3eaacb9a7 fix(D03 그림): − 노드 색 진한 파랑 #3b6bff — 민트 노선 선과 구분(대비 3.18 : 1) (PLAN 625-5 · 요구 540)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHDqStFX129bJDAJXB1
2026-10-31 22:52:12 +09:00
eomsangdonandClaude Sonnet 5.5 f67d868d85 fix(산림 조각): 원자료 지문을 경로 · 크기로 바꿈 — 수정 시각 뺌 (PLAN 632-0 · 요구 542)
git 으로 옮겨도 지문이 같아 데스크탑에서 올린 색인 · 조각을 그대로 씀.
새 지문: forest_road 3f4fcf3e · eco_forest 1d125434 · forest_soil a9a02742
시험: 같은 내용 · 다른 시각 = 같은 지문 · 크기 바뀜 = 다른 지문.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHoPiWty5oQce1oAXD
2026-10-31 22:50:32 +09:00
eomsangdonandClaude Opus 5.5 76b262706a knowledge(요구): 요구 542 원문 기록 — 산림 자료 빼지 말고 색인까지 다 올림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:49:05 +09:00
eomsangdon 812f97e88c Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-31 22:46:44 +09:00
eomsangdonandClaude Opus 5.5 1460ca22fe feat(공용 지도 급경사): 범위 설정 창 = 수평 바 — 칸 사이 경계값 한 번 · 칸 눌러 색 · 칠 안 함 칸 · 단계 추가(고른 칸 반으로) · 지움(이웃과 합침) · 8 단계까지 · 같은 값 · 거꾸로 · 빈 값 · 0 · 250 막음 · 새 부품 범위 바 + 부품 모음 줄 · 결과 꼴 · 그림 · 범례 그대로 (PLAN 631 · 요구 537)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHEAjkLLn21sdLiKAkn
2026-10-31 22:46:18 +09:00
eomsangdon 2d30525e9b Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_laptop_2 2026-10-31 22:46:07 +09:00
eomsangdonandClaude Opus 5.5 1f3a880dca knowledge(요구): 요구 538 조사 기록 — B05 급경사 빨강 = 칸 크기 틀림(0.5 대 1.078 m) · 라이다 잔요철
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:45:54 +09:00
eomsangdonandClaude Sonnet 5.5 306a88e964 fix(D03 긋기): 긋는 중 놓인 점이 부채꼴 · 점선 원 위에 보임 · 좁은 폭에서 도구줄 줄바꿈으로 [LAS 올리기] 안 잘림 (PLAN 625-4 · 점검 535-3)
- DrawPaint `paintNetwork` 미리보기(부채꼴 · 원) 먼저 · 놓인 점 그 위
- D03 도구줄 CSS 줄바꿈(공용 한 줄 밀기 · B05 그대로)
- 시험 test_625_draw 순서 · 줄바꿈 지킴 하나 덧

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHDYKk4dyt7bEAQKcVU
2026-10-31 22:45:14 +09:00
eomsangdonandClaude Opus 5.5 0f2445fa89 knowledge(요구): 요구 541 원문 기록 — 산림 자료 원본 zip 도 git 에
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:44:00 +09:00
eomsangdon 821beacc31 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-31 22:43:49 +09:00
eomsangdonandClaude Opus 5.5 e250517adf feat(B05 평면): 예정노선 변경 = 예정노선 페이지 긋기 · 고르기 · 메뉴 · 속성 판 그대로(한 선) · 원본 옅은 점선 켬끔 (PLAN 626-3 · 요구 535-1 · 536 · 535-3)
- B05 「예정노선 변경」 켬끔 = D03 createPlanDraw · createPlanEdit · createPlanMenu · createPlanProps 를 한 선 network 로 얹음(같은 함수 · 단추 · 단축키 · 노드 끌기 · 고른 토막 끌어 꺾임점 · Delete · [다시 긋기]) — 갈래 · 둘째 선 · 연도 · 지점은 막음/숨김
- 한 번 고침(긋기 끝 · 놓음 · 지움) → 켠 뒤 첫 고침만 확인창 → `/route/follow` → 편집기 한 걸음 · 다 지우면 새로 그을 때까지 평면 그대로
- 도구줄 「예정노선(원본)」 켬끔 — 파일입력 예정노선(안 덮음)을 옅은 점선으로(고친 것과 다를 때만)
- D03 덧 인자 `oneLine`(UI_Draw · UI_DrawPanel 연도 마커 · UI_Menu · UI_Props) — 기본값 = 지금 그대로 · 긋기 칸 · 속성 판 꼴 CSS 를 `D03_PlanAnalysis_UI_DrawParts.css` 로 옮겨 그 TS 가 불러옴(D03 꼴 같음)
- 옛 자체 긋기 셈 `logic/B05_Profile_ExpectedDraw.ts` 지움(D03 셈으로 갈음) · 시험 test_626_3 다시 씀(5)
- 낡은 글 고침 — 설계 지형 바꿈 = 지형 칩(`/route/design-terrain`) · 「추천 [적용] 때만」 글 걷음(Router_Terrain · Router_Replan · Api_Replan · RouteEdit_Apply · 동작 그대로)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:42:36 +09:00
eomsangdonandClaude Opus 5.5 26705681b8 knowledge(요구): 요구 540 원문 기록 — 작은 것까지 고쳐서 전역 깃 싱크
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:39:54 +09:00
eomsangdonandClaude Opus 5.5 f0ed479666 knowledge(요구): 요구 539 원문 기록 — 이번 수정 반영 뒤 전역 깃 싱크
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:39:21 +09:00
eomsangdonandClaude Opus 5.5 74d653b4a3 knowledge(요구): 요구 535-3 점검 기록 — 빠진 요구 셋(B05 예정노선 쪽) · 작은 것
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:36:55 +09:00
eomsangdonandClaude Opus 5.5 a6be7f9691 knowledge(요구): 요구 538 원문 기록 — B05 급경사 색이 수치지형도와 너무 다름 · 범위 바꿔도 거의 빨강 · 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:34:47 +09:00
eomsangdonandClaude Opus 5.5 7d196c4854 knowledge(요구): 요구 537 원문 기록 — 급경사 범위 설정 = 수평 바 · 경계값 · 칸 눌러 색 · 단계 추가
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:31:39 +09:00
eomsangdon 0ec76ade56 Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_4' into main_laptop_1 2026-10-31 22:29:50 +09:00
eomsangdonandClaude Opus 5.5 3de262a4a9 fix(D03 긋기): 놓인 노드 +/− 색 보이게 — 선보다 굵은 부호 색 점 + 검은 테 · 고른 선은 노랑 강조 위에 점 다시 · 손잡이 = 빈 네모 (PLAN 625-3 · 요구 535-2)
까닭 — 놓인 점 반지름 3 px · 테 없음 = − 하늘(#29e0ff)이 노선 민트 선(#3df5c4)에 묻힘 · 고른 선은 흰 네모 손잡이와 노랑 강조(8 px)가 점을 덮음.
- DrawPaint `paintNodes` 떼어 냄(점 5 px · fillCased) · Edit 가 고른 선만 강조 위에 다시 그림(heightAt 인자 덧)
- 시험 test_625_draw 놓인 노드 지킴 하나 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:28:41 +09:00
eomsangdonandClaude Opus 5.5 8bf9156d19 knowledge(요구): 요구 536 원문 기록 — B05 예정노선 변경 활성화 · 선 긋기 컨테이너 가져옴
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:22:57 +09:00
eomsangdonandClaude Opus 5.5 8a7d972ce8 fix(B05 평면): 예정노선 그리기 — 확인창 동안 새 예정노선 보임 · 지형 격자 받은 뒤 미리보기 다시 · 검증 창구 (PLAN 626-3)
- 확인창이 떠 있는 동안 바뀔 예정노선 통째를 그림(묻는 동안 무엇이 바뀌는지)
- 설계 지형 격자를 받기 전 부채꼴이 「자리 없음」으로 남던 것 — 받은 뒤 미리보기 다시 셈
- 지도 돌림을 함수 하나로 받음(RouteEdit 683 줄) · 검증 창구 `expectedProbe`(상태 · 보이는 예정노선 · 화면 px)
- 예정노선 판 자리 = 도구 줄 아래(떠 있는 판 absolute · 높이 100 % 풂)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:20:13 +09:00
eomsangdon b85eb258e4 auto: 2026-10-31 22:19 (ESD_LAPTOP) 2026-10-31 22:19:11 +09:00
eomsangdon 48cf88cc93 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_3' into sub_laptop_1 2026-10-31 22:17:03 +09:00
eomsangdonandClaude Opus 5.5 73d0844720 knowledge(요구): 요구 535 원문 기록 — B05 예정노선 긋기 = 예정노선 페이지와 같음 · 공무원 원본 남김 · 놓인 노드 색 · 빠진 요구 조사
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:16:37 +09:00
eomsangdonandClaude Opus 5.5 a6ef53e8a3 feat(공용 지도 겹): 임도 짐작 걷음 · 그 자리 = 임도망도(전국 국가임도 · route 아이콘 · 범례 「임도망도 간선 · 작업 · 산불」 · 줌 14 부터 연도 · 종류 글) · 올린 임도망도 = 임도(업로드)(위치 칸 단추 · 목록 · 알림 · 서버 글) (PLAN 630 · 요구 532 · 534)
- 열쇠 그대로(nationalRoad · forestRoadFile) — 옛 켬끔 취향 이어짐 · forestRoad 열쇠 · 도로 소로 거름은 B05 자료 셈 이음으로 남김(겹 목록 · 꼴 · 범례만 걷음)
- 기존 도로(road) 겹 · 소스 · 범례 그대로
- 시험 553_4 겹 차례 · 584_3 범례 기대값을 새 이름으로 고침 · 622_2 에 630 · 534 지킴 덧

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 22:10:58 +09:00
eomsangdonandClaude Opus 5.5 36cde038b1 knowledge(요구): 요구 534 원문 기록 — 임도망도 줌인 글(개설 연도 · 종류) · 코드 일원화 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:08:05 +09:00
eomsangdonandClaude Opus 5.5 c932a18479 knowledge(요구): 요구 533 원문 기록 — 기존 도로 겹 유지
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:04:26 +09:00
eomsangdon a80367a2c2 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3 2026-10-31 22:03:56 +09:00
eomsangdonandClaude Opus 5.5 893e4e2d86 knowledge(요구): 요구 532 원문 기록 — 임도 짐작 걷고 임도망도 · 올린 임도망도 = 임도(업로드) · 임도망도 칸 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 22:03:10 +09:00
eomsangdonandClaude Opus 5.5 84e9151b7c feat(B05 평면): 예정노선 그리기 — 새로 긋기 · 구간 다시 긋기 · 지움 → 평면선 다시 추적 = 편집기 한 걸음 → [저장] (PLAN 626-3 · 요구 530 · 531 · D3 · D4 · D9 · D10)
- 도구줄 「예정노선 그리기」 칸(공용 표 b05 · 연필 아이콘) → 떠 있는 판(단추 셋 + 공용 방식 칸) · 고치는 예정노선 맨 위 · 판 켠 동안 캔버스 누름을 먼저 받음(계획선 · 구조물 · 등고선 고르기 · Shift 재기 · 노드 넣기 · 지움 막음 · 가운데 끌기 · 휠 그대로)
- 긋기 = D03 과 같은 방식 칸 · 부채꼴 · +/− 색 · Shift 사이 기울기 · Tab · Enter · Esc · Backspace(셈 `logic/B05_Profile_ExpectedDraw` · 그림 D03 `paintPreview` 그대로)
- 끝 → 확인창(직접 고친 평면 버림) → `POST /route/follow`(저장 0) → `restoreSnapshot(…, true)` 한 걸음(되돌리기 됨) · 지움 = 파일입력 예정노선으로 다시 추적
- 걸음 사진 · 노선 초안에 `expected`(같은 선 판정 · 세션 되살림 · 페이지 되돌리기) · [저장] = 손 편집과 같은 길 + 초안 `expected`(null 안 실음 · [] 지움) · 다시 열면 `expected_edited`
- `/route/replan` · `as_initial` · `expected_route.csv` · 초기값 · 추천 저장본 안 건드림 · 시험 test_626_3_expected_edit(7)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:58:15 +09:00
eomsangdonandClaude Opus 5.5 1ed836abfb chore(계획): PLAN 612 · 618 이관 — 전국 미리 계산 12,904 도엽 끝 · 두 PC 나눠 돌림
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 21:56:14 +09:00
eomsangdonandClaude Opus 5.5 62a8ee362e refactor(B05 평면 · 지도 공용): RouteEdit 마우스 배선 · 재기 한 벌 떼어냄 · 긋기 방식 칸 공용 자리로 (PLAN 626-3 · 요구 530 · 531)
- B05_Profile_UI_RouteEdit.ts 684 → 655 줄 — 두 번 클릭 삽입 · 오른쪽 클릭 삭제 · 지도 이동 · 휠 = 새 `_RouteEdit_Mouse.ts` · 재기 도구 + 단추 한 벌 = `_Measure.ts` `createRouteMeasure` (동작 그대로)
- 긋기 방식 칸(`createDrawKindFields` · 기본값 · 이름) = 새 `A00_Common/map/map_draw_kind.ts` · `.css` — D03 DrawPanel 은 다시 내보냄(D03 화면 · 동작 같음 · 칸 꼴 선택자만 공용 이름)
- D03 `paintPreview` · `planLabel` 받는 지도 꼴을 쓰는 칸만(Pick)으로 — B05 평면이 그대로 부름
- 시험 test_553_4 — 방식 칸 대조를 공용 파일로 옮김(낡은 자리 대조)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:51:58 +09:00
eomsangdonandClaude Opus 5.5 48554430e4 chore(계획): PLAN 612 · 628 · 629 이관 — 전국 미리 계산 끝(지형 · 0차곡 · 0차곡 선 12,904 · 실패 0) · 넓은 줌 조각 · 낡은 시험
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 21:48:49 +09:00
eomsangdonandClaude Sonnet 5.5 dec08b1eb5 test(629-1): test_298_3_save_shape 6 오류 고침 — 헬퍼에 design_pipeline_page 가짜 더함
까닭: b92fa06f3(10-28, PLAN 498-2)가 design_save.ts 에 routeGradeTouch 불러오기를 더했는데 헬퍼 가짜가 빠짐 → stub 없음 오류 6 건. 코드 의도대로 바뀐 것 = 낡은 시험. 가짜값만 고침(B 코드 안 고침).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 21:47:27 +09:00
eomsangdonandClaude Opus 5.5 3aac80765d chore(계획): PLAN 701~708 이관 — 내역서 개편(한시 브레인 sub8 끝)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 21:36:26 +09:00
eomsangdon 63ea2c181d Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_4 2026-10-31 21:35:52 +09:00
eomsangdonandClaude Opus 5.5 d05e36631b knowledge(요구): 내역 11 원문 기록 — 한시 브레인 업무 종료
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:35:23 +09:00
eomsangdon a39ab266d6 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-31 21:34:14 +09:00
eomsangdon 2b02b0dfd0 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-31 21:34:12 +09:00
eomsangdonandClaude Opus 5.5 99ec4b8efe feat(B05 평면 지도): 공용 도구줄 표 · 겹 줄 · 범례 · 지형 칩 붙임 · 노선추천 = 도구줄 미리보기(넷 · 하나만 · 끔) · [적용] · 추천 모달 · 좌측 칸 걷음 · 지형 바꿈 = 지형 칩 (PLAN 624-3 · 626-1 · 요구 530 · 531)
- 624-3 도구줄 = 공용 표 「b05」 · 아래 줄 = 공용 겹 줄(띠 · 올린 임도망도 숨김 · 처음 = 등고선 · 하천) · 범례 = 공용 legendRows(상태 글판 위)
- 노선 띠 300 m 자르기 = B05 자기 칸(등고선 · 하천 · 1 m)만 · 그 밖 겹 = 안 자르는 둘째 GL 칸(맨 밑 · 켠 겹 있을 때만 · GL 만) · 등고선 끔 = 등고 글도 숨김
- 626-1 추천 보기 = 도구줄 아이콘 + 아래 판(영선 · 토공 최소 · 기울기 최소 · 비중 계산 · 끔) — route_recommend 저장본(지금 설계 지형) 선만 · 다시 셈 · 쓰기 없음
- 지형 칩 → 확인창 → 새 POST /route/design-terrain(설계 지형만 바꿈 · 노선 저장 체인 다시 · 노선 · 초기값 · 추천 저장본 그대로 · 잠금 · 실패 되돌림)
- 걷음 — 추천 모달(_Recommend · _Help · _Map · CSS) · 좌측 「노선 추천」 칸 · saveRecommendedRoute · UI_RouteReset(서버 as_initial 길 · 시험은 남김)
- 부품 상자 — createPickMenu(아이콘 고르기 메뉴 · 판 = body fixed · ✓)
- 낡은 시험 고침 · 지움 — 모달 · 좌측 칸 · [적용] 화면 배선을 보던 시험(283 · 346_3 · 349 · 364_4 · 383_1 · 390_5 · 418 · 421 · 421_2 · 476_2 · 509_2 · 509_5 · 551_2 · 600_3 · b05_routeedit_initial · design_view_parity · modal_top · 133_1 · 133_2) · test_508_2 · helper_283 지움(모달 전용)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:30:49 +09:00
eomsangdon af6e921440 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8 2026-10-31 21:28:42 +09:00
eomsangdonandClaude Opus 5.5 472bf98ba2 feat(B09 원가계산서 · 708-2): 현행 비목 다섯 셈 — 퇴직공제 · 임금채권 · 석면 · 하도급 요율 셈 · 공사이행보증 금액 손입력
- 셈 짝(Calc_Total py ↔ ts) — STmate 일반형식 식 · 자리(지식DB 12 · 35 분석) · 경비 묶음 안 · 나.소계에 듦
  퇴직공제부금비 = 직접노무비 × 율 · 임금채권 · 석면 = (직노 + 간노) × 율 · 하도급 = 순공사비 × 율 ·
  공사이행보증 = 금액 직접 입력(performance_fee) · 자동 요율 없음 · 요율 빈칸 = 해당 없음(0)
- 요율 출처 「불러옴」 = rates_imported(707-2 불러오기가 실음 · 요율 고치면 사용자)
- 707-2 불러오기 — 「셈 없음」 알림 뺌 · 공사이행보증 줄 금액 = 손입력 칸
- 총괄 · 원가계산서 탭 · 인쇄 미리보기 = 같은 줄 짜기
- 낡은 시험 고침 — 현행 비목 기대값 빈칸 → 0 · 퇴직공제 요율 칸 고침 가능 · 거창 총공사비 = 엑셀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:27:40 +09:00
eomsangdon d66b54cb0c Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-31 21:16:53 +09:00
eomsangdonandClaude Opus 5.5 92fdf334b2 feat(D03): 예정노선 지형 칩 라이다 · LAS 올리기 — 칩 바꾸면 급경사 · 음영 · 0차곡 · 높이 다시 받음 (PLAN 624-4 화면 · 요구 530 · 531)
- 새 D03_PlanAnalysis_UI_LidarUpload — 공용 칩(안 고침) + 라이다 없으면 [LAS 올리기](부품 단추 · LAS · LAZ · .prj) · 진행 = 위치 칸 서클 「라이다」 · 고른 원천 = 세션 d03-terrain(등록부 한 줄)
- 공용 겹 받기 fetchPlanRaster · fetchPlanZeroOrder 에 terrain 인자(안 넘김 = 옛 그대로 · B05 영향 없음)
- 겹 reterrain = 지형 겹 셋만 비우고 다시 · 높이 = 고른 원천 격자(라이다 범위 밖 = 빈칸)
- 지도는 지형 칸을 넘겨받기만 · 낡은 시험 둘(475 · 624_2)은 글 대조 줄만 새 꼴로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:15:14 +09:00
eomsangdonandClaude Opus 5.5 74d8ae356a knowledge(요구): 내역 10 원문 기록 — 원가계산서 현행 비목 다섯 셈 진행
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:59:36 +09:00
eomsangdonandClaude Opus 5.5 a5179e4dfe fix(공용 지형): 전국 0차곡 이웃 도엽 받기 실패 36 가름 · 면 도곽 · zip 없는 도엽 · 어긋난 도곽 = 빈 지형 (PLAN 628-3)
- 도곽 겹 N3A_H0010000(면) 도 읽음 — 섬 · 해안 작은 zip 은 선 도곽 · 도로 면 없이 면 도곽뿐
- Ver2.0 이 NGI · NDA 뿐인 도엽 = LookupError(zip 없음) — 옛 길은 NGI 글을 zip 으로 받다 로그인 실패로 오판
- 공용 지형 받기: ValueError(도곽이 번호 자리와 어긋남 = 제주 · 울릉 번호 체계) 도 빈 지형 · 로그인 · 망 실패는 그대로 실패
- 시험 test_628_3_sheet_fetch_fallback 새로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:58:41 +09:00
eomsangdon 216e7c565b Merge remote-tracking branch 'origin/sub_laptop_3' into sub_laptop_2 2026-10-31 20:48:27 +09:00
eomsangdonandClaude Opus 5.5 c6a1efad0c refactor(공용 지도): 겹 받기 · 범례 줄 · 급경사 범위 · 위성 밝기 · 겹 자료 도움을 A00_Common/map 으로 — D03 은 다시 내놓기 · 동작 그대로 (PLAN 624-3 · 요구 530)
- D03_Api 겹 받기 → map_layer_api.ts(D03_Api 가 다시 내놓음)
- UI_SlopeBands · logic SlopeBands · UI_SatLight → map_slope_bands · logic/map_slope_bands · map_sat_light
- 범례 줄 legendRows → map_legend.ts(받은 자료 꼴 LegendData · D03 LayerLegend 가 다시 내놓음)
- glImage · glTagged · shadeImage · loadImage · WATER_KEYS · FOREST_ROAD_KINDS · hectare → map_layer_data.ts
- 시험 경로 고침 — test_603(TS 자리) · test_606_1(밝기 파일) · test_584_3(범례 글 = 공용 파일도 읽음)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:47:50 +09:00
eomsangdonandClaude Opus 5.5 7cd09426df chore(계획): PLAN 627 이관 — 깨진 시험 6 모두 낡은 시험 · 가짜값만 고침
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 20:37:43 +09:00
eomsangdonandClaude Opus 5.5 db45b05420 test(627-1): 낡은 시험 6 고침 — test_283 손 고침 저장 · test_347_2 배수유역 실시간 가짜값 현행화
- test_347_2 다섯(세대 둘 · 저장 노선 답 · 초안 답 · 잠금) — 59ae735ed(609-1 공용 하강장)가 _prepare 에 좌표계 칸 crs 를 더함 · 시험 가짜 _prepare 에 crs 없어 KeyError · 가짜에 crs None 더함(코드 의도대로)
- test_283 한 본문 손 고침 — b92fa06f3(498-2)가 _save_alignment 에 계획고 본문 꼴 검사(ProfileAlignmentSaveRequest 아니면 약속으로 기다림)를 더함 · 시험 가짜 model_copy 가 SimpleNamespace 를 내어 await 실패 · 가짜가 ProfileAlignmentSaveRequest.model_construct 를 내게 고침
- 코드 흠 없음 · B05 · B06 · B04 코드 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:36:38 +09:00
eomsangdon 82c19c96f5 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-31 20:33:55 +09:00
eomsangdonandClaude Opus 5.5 de8f2fee4a feat(D03): 예정노선 라이다 — LAS 올리기 · B04 엔진 지형 · terrain=lidar (PLAN 624-4 · 요구 530 · 531)
- POST /plan/lidar(LAS · LAZ + .prj · 예정노선만 · 다시 올리면 갈음) → 뒤에서 지형(진행 = /plan/progress lidar) · GET /plan/lidar
- 자리 = D 의 B04_PreProcess/lidar/(안은 B 와 같은 꼴) · LAS 좌표계 다르면 x · y 옮겨 적음 · DTM = surface_models 행(높이 격자)
- terrain=lidar 급경사 · 음영 · 0차곡 = 올린 DTM · 범위 밖 = 빈칸(수치지형도로 안 메움 · 결정 A⑥)
- B04 run_surface_analysis 에 maps 인자 덧(기본 True = B 그대로 · False = 지도 · GIS 받기 · 도엽 서피스 건너뜀)
- 시험 test_624_4_plan_lidar 4

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:33:47 +09:00
eomsangdon a34c3c04bd Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3 2026-10-31 20:32:02 +09:00
eomsangdon c9f5eb70ca Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-31 20:30:56 +09:00
eomsangdonandClaude Opus 5.5 85ab474320 feat(D03 긋기): 길이·기울기 고정 부채꼴 · 정방향 채움 · +/− 노드 색 · Shift 사이 기울기 · 완만한 땅 흠 (PLAN 625-2 · 요구 530 · 531)
- 새 셈 logic/D03_PlanAnalysis_Fan.ts(화면 고르기 전용 · 파이썬 짝 없음 · B05 626-3 이 그대로 씀) — 이웃 노드 사이 허용 호 · 정방향 = 앞 구간 쪽(첫 구간 없음 · 갈래 = 붙은 선 · 거의 같으면 마우스 쪽) · 완만한 땅 = 어느 방향이나 · Shift = 호 위 마우스 방위(길이 그대로 · 기울기 실제 지형 · 호 밖 = 호 끝)
- 미리보기 paintFan — 정방향 반투명 채움 · 역방향 테두리 · 노드 + 따뜻한 색 · − 찬 색 · 고리 = 고른 점 부호 색
- 놓인 꺾임점 = 들어온 구간 기울기 부호 색(저장 없이 셈)
- Shift · 완만한 땅으로 찍은 노드 기록 = 길이 고정(저장 꼴 그대로)
- 색 토큰 --draw-map-slope-up · --draw-map-slope-down · test_551_2 토큰 8 → 10
- 시험 test_625_draw(부채꼴 · 정방향 · +/− · 노드 2/4/6/8 · 완만한 땅 · Shift 실제 지형)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:30:53 +09:00
eomsangdonandClaude Opus 5.5 fea37b4ce8 feat(B05): 예정노선 다시 추적 POST /route/follow · 고친 예정노선 expected_route_edit.csv (PLAN 626-2 · 요구 530 D3 · D4 · D8)
- 새 `B05_Profile_Router_RouteFollow.py` — 고친 예정노선(TM · 경위도) → 평면선(노드 · 곡선 · 편집표 · GET /route/plan 칸 이름) · 저장 0
- 같은 함수 — `Route_Polyline_Write.followed_table` 을 체인 초기 폴리라인(`_ensure_planned_initial`)과 함께 씀 · 셈 몫 `polyline_table` 을 쓰기에서 나눔
- [저장] — 노선 초안 `expected`(없음 그대로 · [] 지움 · 점 묶음 씀) → `B05_Profile/route/expected_route_edit.csv` · GET /route/plan 덧칸 `expected_edited`
- [초기화] — 고친 예정노선 지움 · `expected_route.csv` · 초기값 · 추천 저장본 · `/route/replan` · `as_initial` 그대로
- 시험 — 새 `test_626_2_route_follow`(follow = 체인 초기선 · 저장 0 · 경위도 · edit csv 씀 · 지움 · initial_snapshot 지문 · 초기화 지움)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:30:32 +09:00
eomsangdonandClaude Opus 5.5 7fbc4b51a9 fix(공용 지도): 지형 칩 값 las → lidar — 서버 겹 길 terrain= 값(624-1)과 같게 (PLAN 624-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:30:05 +09:00
eomsangdonandClaude Opus 5.5 6c25b864ac feat(공용 지도): 도구줄 표 · 지형 칩 — D03 평면 도구줄을 표로 · 끝에 지형 칩(수치지형도 고름 · 라이다 막힘) (PLAN 624-2 · 요구 530 · 결정 A④ · A⑤ · A⑩)
- map_toolbar = 단추 열쇠 · 할 일 · 보일 페이지(d03 · b05) 표 · mapToolGroups(page, parts) — D03 단추 차례 · 묶음 그대로
- map_terrain_chip = 분할 고르기(수치지형도 · 라이다) · 이름 D03 「분석 지형」 · B05 「설계 지형」 · 없는 원천 막힘 — 바꾸는 일은 onChange(624-1 · 624-4 · 626-1)
- 시험 test_624_2_map_parts 새로 · 553-4 돌리기 자리 확인 = 표

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:28:50 +09:00
eomsangdon 41c950dda9 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_8 2026-10-31 20:22:20 +09:00
eomsangdon c5b20c5c57 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_1 2026-10-31 20:21:30 +09:00
eomsangdonandClaude Opus 5.5 c93554d331 fix(B09 불러오기): 코드로만 가리킨 내역 줄도 일위대가에 이음 · 끝수 줄 꼬리표 · 요율 없음 총공사비 숨김 (707-7)
- 영월 꼴(STmate 엑셀 내역 줄 = 호표 코드만) = 일위대가표 같은 코드 호표 번 채움 → 서고 사본 21 · 이음 34
- 이은 줄 차가 비목마다 1 원/단위 안 = 「끝수」(STmate 하위 호표 반올림) · 끝 알림 한 줄 「엑셀 합계와 차 n 원 = 하위 호표 반올림 끝수 m 줄」(브레인 정함 = 끝수 줄도 이음)
- 요율 못 읽은 공사(.estx · .STC) = 총공사비 칸 안 보임
- 시험 test_707_7 11(영월 끝수 · 일위대가 시트 없음 까닭)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:21:15 +09:00
eomsangdon 8c1498a160 Merge remote-tracking branches 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_4' into main_laptop_1 2026-10-31 20:20:31 +09:00
eomsangdon 8eb4dbee13 Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3 2026-10-31 20:19:14 +09:00
eomsangdonandClaude Opus 5.5 e073e575f3 feat(D03): 예정노선 겹 길 임도 프로젝트도 · 지형 원천 인자 · 받은 겹 D03 몫 자리 (PLAN 624-1 · 요구 530 · 531)
- 겹 길(`/plan/layers/*`)이 프로젝트 종류를 안 가림 — 예정노선 = 받은 범위 · 수치지형도 · 임도 = B04 배경 지도 상자 · 설계 지형
- `terrain=sheet|lidar|design` 인자(raster · zero-order) · 응답 `source` · 파생 저장본 열쇠 = 원천 격자 파일 이름
- 라이다 0차곡 = 격자 통째(2 m 솎음 · 파생 저장본) · 수치지형도 = 창고 도엽 선 그대로
- 받은 겹(지적 · 보호구역 · 산림입지 · 단층 · 지질구조선 · 산사태) 자리 = `D03_PlanAnalysis/layers/` — B04 `processed/` 파일 덮음 0 · D 옛 자리 파일은 한 번 복사
- 새 `D03_PlanAnalysis_TerrainSource.py` · 라우터 661 → 658 줄 · 시험 `test_624_1_layers_any_project.py`

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:19:14 +09:00
eomsangdonandClaude Opus 5.5 b96479fd7e refactor(공용 지도): 겹 목록 · GL 겹 꼴 · 겹 줄 · 범례 · 산림 조각 겹을 새 공용 폴더 A00_Common/map 으로 — D03 은 부르기만 · 겉모습 · 동작 그대로 (PLAN 624-2 · 요구 530 · 결정 A⑧)
- map_layer_list = 겹 스물셋 · 처음 값 · 페이지 표(d03 · b05 보일 겹 · 처음 켬 · 취향 열쇠 — A② · A⑨)
- map_layer_specs = GL 겹 꼴 · 차례(D03 UI_MapGl 에서 옮김 · 선 겹 꼴은 LINE_LAYERS 한 벌)
- map_layer_bar = 겹 단추 줄 · 켬끔 · 취향 · 그래픽 카드 단추 / map_legend = 범례 칸 · 조각 겹 범례
- map_forest_layers = D03_UI_ForestLayers 옮김(이름만)
- D03 UI_Layers 683 → 639 줄 · UI_MapGl 423 → 115 줄 · UI_LayerDefs 는 캔버스 도움 + 옛 이름 다시 내놓음
- 시험 길 고침(553-4 · 581-2 · 584-1 · 584-3 · 606-1 · 615 · 622-2) — 옮긴 파일 자리 · 553-4 겹 목록은 622-2 때 낡은 것(국유림 임도 · 경제림 · 생태자연도 빠짐) 같이 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:19:01 +09:00
eomsangdonandClaude Opus 5.5 77a7697768 fix(B04): 새 관 기본값 — 유입 집수정이면 유입 흙막이 칸 안 채움 · 동물이동형 유 · 관경 수 (PLAN 623 · 요구 530 E1)
- `_apply_new_defaults` — 집수정 갈래면 `inlet_revet_*` 기본값 · 높이 안 채움 · `inlet_basin_animal` "true" · 등록부 관경 글 "1000" → 수
- 새 자동 관이 폼을 처음 거쳐도 파일이 안 바뀜(620 증상) · 옛 프로젝트 이주 없음
- 시험 — 새 `test_623_inlet_defaults`(폼 왕복 차 0 · `helper_623_inlet_defaults.cjs`) · 기대값 고침 `test_b05_new_structure_defaults` · `test_211_1`(기슭막이 관으로) · `test_b05_drainage_recommend`(관경 수)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:16:48 +09:00
eomsangdonandClaude Opus 5.5 5d4737210a feat(D03 긋기): 기본값 길이·기울기 고정 · 버튼 자유지정 오른쪽 · Tab 방식 바꿈 · 안 쓰는 칸 숨김 (PLAN 625-1 · 요구 530 · 531)
- 방식 차례 = 자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정 · 「자유」 → 「자유지정」
- 긋기 처음 방식 = 길이·기울기 고정(기억 안 함)
- Tab(긋는 중) = 자유지정 ↔ 길이·기울기 고정 · 등록부 plan.drawKind · 긋기 아닐 때 = 초점 이동 그대로
- 길이 · 기울기 칸 = 방식에 안 쓰면 숨김 · 막힘 = 권한 없음만
- 지도 안내 글 Tab · Shift 줄 · 겹 hover 에 shift 인자(625-2 몫)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:12:48 +09:00
eomsangdonandClaude Opus 5.5 689727fc98 knowledge(요구): 요구 531 원문 기록 — 요구서 530 진행(결정 권함대로)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 20:07:06 +09:00
eomsangdonandClaude Opus 5.5 993cae613e chore(계획): PLAN 622 이관 — 국유림 임도 · 산림입지도 · 경제림 · 생태자연도 · 산사태 지도 겹(줌별 조각)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 20:04:09 +09:00
eomsangdon 55dccd0078 Merge remote-tracking branches 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_7' into main_laptop_1 2026-10-31 20:03:20 +09:00
eomsangdonandClaude Opus 5.5 a19425e721 feat(D03 지도 겹): 산사태위험 범례 「산사태 n등급」 이름 붙임 · 검증 창구 layers().zoom(GL 줌) 덧 (PLAN 622-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:01:57 +09:00
eomsangdonandClaude Opus 5.5 76dc4a6143 feat(B09 불러오기): 경쟁사 공사 통째 불러오기 — 창 한 번 · 일위대가 이 공사 서고 사본 (707-7)
- 불러오기 창 = 불러올 것 체크(일위대가 · 단가산출 / 내역 줄 / 원가계산서 요율 / 단가표 · 기본 다 켬 · .estx · .STC 일위대가 · 요율 흐림 + 까닭)
- [불러오기] 한 번 = 서버 차례 — 일위대가(705-3 고정 줄 · 하위 먼저 · 임시 폴더에서 짓고 서고 사본 precombo@project · 층 쓰기 0) → 내역 줄 → 요율 → 단가표 → 셈
- 내역 단가 ≠ 일위대가표 단가 줄 = 잇지 않고 내역 단가 그대로(경쟁사 금액 재현 · 목록)
- 끝 알림 한 장 — 호표 · 내역 줄 · 직접비 · 총공사비 엑셀 = 우리 · 어긋난 줄 · 다음 걸음 · 줄 어긋남 없으면 알림 한 줄
- 내 자료집에 저장(703-2) = 불러온 하위 호표도 지금 층에 같이 씀(한 번만)
- 시험 test_707_7 10

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:59:08 +09:00
eomsangdonandClaude Sonnet 5.5 78878e13f4 fix(M02 카드 번호 배지): 카드 머리 번호(SD · SU · SA) 배지 = 시스템 관리자에게만 · 그 밖은 이름 풍선말로만
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:48:27 +09:00
eomsangdonandClaude Opus 5.5 4110257460 chore(계획): PLAN 616 · 620 이관 — 0차곡 첫 번 0.4 s · 급경사 칠하기 · B06 관 옵션 칸 까닭 분석
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 19:38:59 +09:00
eomsangdon 4603a08c6c Merge remote-tracking branches 'origin/sub_laptop_2', 'origin/sub_laptop_3' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-31 19:37:29 +09:00
eomsangdonandClaude Opus 5.5 476f239141 knowledge(요구): 저장 속도 재기(sub6) 기록
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:37:09 +09:00
eomsangdon d272474def Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8 2026-10-31 19:36:03 +09:00
eomsangdonandClaude Opus 5.5 3bd3bcc5a8 perf(B09 [저장] · 708-1): 같은 입력 되풀이 셈 묶음 — 화면 [저장] 3.7 s → 1.7 s · 결과 바이트 같음
- M01 캐시 `steady()` 범위 — 요청 한 번 안 파일 자국 한 번(77 번 × 19 ms → 1 번) · stale · forget 뒤 다시 · 범위 밖 그대로
- 두 번 셈(183-1) 2차 산근 = 1차가 본 단가(있음 · 값)가 같을 때만 1차 풀이 사본 · 다르면 다시 셈
- [저장] 단가 수요 한 번(셈 답에 넘김) · 단계 표시 = 셈과 함께
- B08 `once()` 범위 — B09 원천 읽기 때 토공 채집석 공제가 자재 집계표 답을 다시 씀(두 번 셈 → 한 번)
- 시험: test_b09_save_speed 새로 · test_183 바꿔 끼운 셈 함수 = 새 memo 인자 받음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:34:44 +09:00
eomsangdonandClaude Opus 5.5 31c8279d2c chore(설정): 실수로 올라간 산림 원자료 링크 resources/data_forest 뺌 — git 밖 연결(.gitignore 대상)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:32:54 +09:00
eomsangdon bd8acf8213 auto: 2026-10-31 19:31 (ESD_LAPTOP) 2026-10-31 19:31:46 +09:00
eomsangdonandClaude Opus 5.5 e1537dde96 fix(D03 지도 겹): 생태자연도 · 산사태위험 범례 색 = 조각 그림 색 그대로 — 생태자연도 1 · 2등급 · 별도관리(3등급 칠 없음) · 산사태 범례 전용 토큰 landslide-grade-1~5 (PLAN 622-2)
- 산사태 옛 토큰 landslide-1~5 = 급경사 범위 색이 함께 써서 그대로 둠

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:31:44 +09:00
eomsangdonandClaude Opus 5.5 5d6d7bbb83 perf(D03): 0차곡 무거운 셈 일꾼으로 · 급경사 값 그림 바이너리 · 칠하기 toDataURL (PLAN 616-3 · 616-4)
- 0차곡 길 = 창고 선으로 바로(스레드) · 셈 필요하면 서버 본체 밖 일꾼 · 응답 = json.dumps 바로(큰 선 묶음 jsonable_encoder 건넘)
- 급경사 = PNG 바이트 + 머리 X-Plan-Raster(칸 수 · 범위) · Content-Encoding identity(PNG 다시 gzip 안 함) · 음영기복 · 산사태 = JSON 그대로
- Api fetchPlanRaster 급경사만 바이너리(blob 주소 · slopeValues 가 다 읽고 놓음)
- 칠하기 = canvas.toDataURL(toBlob 한가 작업 기다림 최대 1 s 없앰)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:18:52 +09:00
eomsangdonandClaude Opus 5.5 66f9c68a1e perf(D03): 0차곡 = 창고 격자 · 도엽 선 미리 뽑아 이어 붙임 · 무거운 셈 일꾼 (PLAN 616-1 · 616-2 · 616-3)
- 0차곡 셈 = 사업 DEM 대신 창고 격자(받은 범위 도엽 누적) · 옛 격자 통째 갈래 걷음
- 기본 임계 선 = 도엽마다 창고 `zero_lines_<epsg>_2m.npz`(여유 200 m 에서 뽑아 끝점 도엽만 · 단순화 끝) · 요청 = 이어 붙여 받은 범위로 자름(파생 저장본 안 씀) · 다른 임계 = 누적 이어 붙여 셈 · 저장본
- 창고 누적 열쇠 판 그대로(ACC_VERSION 2 · 전국 누적 다시 셈 없음) · 파생 판 3
- compute=False = 셈 필요하면 None · 서버 본체 밖 일꾼 하나(`heavy_pool` · 띄울 때 빈 __main__ · 서버 끝나면 따라 끝남)
- 미리 계산 단계 zero_lines 덧(zero 뒤 · 기본 단계에 듦)
- 시험 test_616 새로 · test_608 반 칸 어긋난 DEM 통째 셈 확인 걷음(갈래 없어짐) · test_584_1 사업 DEM 통째 0차곡 겹 시험 걷음(창고 길로 바뀜 · 616 시험이 대신)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:18:43 +09:00
eomsangdon 647f850ad8 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-10-31 19:18:31 +09:00
eomsangdon ad40433bf1 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-31 19:18:27 +09:00
eomsangdon 0d3fc3a471 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_3 2026-10-31 19:17:12 +09:00
eomsangdonandClaude Opus 5.5 251dfd9a5b feat(D03): 받은 산림 · 환경 자료 줌별 조각 — 벡터(임도 · 산림토양도 · 경제림) · 그림 대리(생태자연도 · 산사태) (PLAN 622-1 · 요구 529)
- 새 common_util_vector_tiles: SHP(cp949 · .prj) → 3857 색인 GeoPackage(data_forest/_index) → z8~15 MVT · 원자료마다 규칙 한 줄(줌 · 단순화 · 작은 면 거름 · 남길 칸) · 창고 storage/_shared/vector_tiles/<name>/v1_<지문> · 615 꼴 잠금 · .part
- 새 common_util_wms_tiles: WMS → 256 px PNG(3857 bbox) · 처음 받고 storage/_shared/wms_tiles 기억 · 빈 · 오류 = 투명 PNG 300 s 기억 · 키 없음 = 404 · 키 안 샘
- 새 D03_PlanAnalysis_Router_Tiles: /plan/vt/{name}/{z}/{x}/{y}.pbf · /plan/wmt/{name}/{z}/{x}/{y}.png · main.py 등록
- 새 tools/precompute_vector_tiles.py: 원자료 이름 · 줌 · 경위도 상자 · 일꾼
- 시험 test_622_vector_tiles.py

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:16:05 +09:00
eomsangdonandClaude Opus 5.5 d6def61f48 knowledge(요구): 요구서 530 — 예정노선 · B05 평면도 지도 일원화 · 노선 긋기 · 노선추천 미리보기 · 620 고침 + 조사 셋
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 19:14:39 +09:00
eomsangdon c3c79c59ca Merge remote-tracking branches 'origin/sub_laptop_3' and 'origin/sub_laptop_8' into sub_laptop_5 2026-10-31 19:13:12 +09:00
eomsangdon bf2dfed6c1 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_8 2026-10-31 19:08:38 +09:00
eomsangdonandClaude Opus 5.5 57ff92262b feat(D03 지도 겹): 받은 산림 · 환경 자료 겹 다섯 화면 — 국유림 임도 · 산림입지도 · 경제림 · 생태자연도 · 산사태 = 서버 줌별 조각 고정 소스 · 범례 · 받는 동안 서클 (PLAN 622-2 · 요구 529)
- 공용 부품 tileSource(벡터 pbf · 그림 png) 덧 · 새 파일 UI_ForestLayers(꼴 · 소스 · 범례 · 토양 무리 색)
- 산림입지도 그림 = forest_soil 조각(z10 이상) · 브이월드 받기 · 요약 그대로 · 범례 · 옛 캔버스 색 = 같은 토양 무리 색
- 산사태위험 GL = 조각 그림(받기 없음) · 옛 캔버스 = 받은 범위 한 장 그대로(끌 때 받음)
- 새 겹 셋(국유림 임도 · 경제림 · 생태자연도) 처음 끔 · 옛 캔버스엔 안 보임 · 색 토큰 --draw-map-* 덧 · 아이콘 trees · plant 덧
- UI_Layers 688 → 680 줄(GL 글꼴 미리 받기 = UI_MapGl 로 · 브이월드 토양 GL 꼬리표 뺌)
- 지킴 시험 test_622_2_forest_layers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:08:01 +09:00
eomsangdon 18d5452a25 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8 2026-10-31 19:03:14 +09:00
eomsangdonandClaude Opus 5.5 1ba5d49043 knowledge(요구): 620 분석 기록 — B06 관 옵션 칸 지워짐 까닭 커밋 · 고칠 길(데이터 손실 아님)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 19:03:08 +09:00
eomsangdonandClaude Sonnet 5.5 1cab899ecf test(번호 노출): 사전조합 대가 블록 규격 = 층 낱말 · 번호는 spec_raw 시험 (PLAN 704 딸림 ⑤)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 19:02:20 +09:00
eomsangdonandClaude Opus 5.5 64dfd2565c knowledge(요구): 요구 530 원문 기록 — 예정노선 · B05 평면도 지도 일원화 · 노선 긋기 · 노선추천 미리보기 · 조사부터
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 18:55:17 +09:00
eomsangdon 2648c92b70 Merge remote-tracking branches 'origin/sub_laptop_5', 'origin/sub_laptop_7' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-31 18:49:50 +09:00
eomsangdon 3f224788e5 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-31 18:49:45 +09:00
eomsangdonandClaude Opus 5.5 9f3bd27a4c knowledge(요구): 요구 529 원문 기록 — 받은 자료 겹 · 616 진행 동의 · 620 까닭 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 18:49:33 +09:00
eomsangdonandClaude Opus 5.5 ca6db2ac0f knowledge(요구): 요구 528 원문 기록 — 지질 오픈API 후보 물음 · 오늘 자료 확보 끝
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 18:43:46 +09:00
eomsangdonandClaude Sonnet 5.5 51f6eeffb1 fix(B09 통합 점검): 단가연결 낱말 별칭 · 줄 0 개 일위대가 = 빈 줄 · 단위 다름 알림 · 잊은 짝 짝 표 제안 제외 (통합 점검 흠 ① ③ ④ + 잊기)
- 기준 목록 별칭에 「터파기」→「파기」 낱말(측구파기 → 측구터파기) · 목록 모양 SHAPE 올림
- 줄 0 개 일위대가 = 빈 줄 하나 · 빈 줄 있는 대가를 이은 내역 줄 = 단가 모름(금액 0 아님) · py · ts 짝 + 거울 시험
- 단가연결 때 줄 단위 ≠ 일위대가 단위면 「단위 다름」 알림(연결은 됨)
- 잊은 짝은 짝 표 제안에서도 안 나옴(다시 이으면 풀림)
- 낡은 시험 여섯: 707-4 잇기 메뉴 묶음(UI_Link) 빠진 소스 목록 보충

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:43:32 +09:00
eomsangdonandClaude Sonnet 5.5 63fd282771 fix(M02 올리기 요청): 회사 관리자는 어느 층을 열어도 「올리기 요청 n」 띠가 보임 (PLAN 706-1 딸림 ⑦)
- 띠 조건에서 「회사 층일 때만」 뺌 — 회사를 고칠 수 있는 사람이면 개인 · 마스터 층에서도 보임 · 누르면 요청 목록

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:34:48 +09:00
eomsangdonandClaude Sonnet 5.5 5dc014e9ee fix(M02 구조물 조합): [구조물 조합으로] 만든 항목이 열린 표(행렬)에 바로 보임 — [새로고침] 없이 (PLAN 704-4 딸림 ⑥)
- 만들기 알림이 오면 나무 · 옆 목록에 더해 구조물 조합 표가 열려 있으면 다시 읽음(저장 안 한 고침은 먼저 물음)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:34:47 +09:00
eomsangdonandClaude Sonnet 5.5 8c65a323cc fix(번호 노출): 복사 창 규격 · 재료 창고 규격 · B09 줄 비고 · M02 격자 머리 · 비고 = 사람 말(층 낱말 · 이름) · 번호는 풍선만 (PLAN 704 딸림 ⑤)
- 사전조합 대가 블록 규격 = 「내 자료집 · 회사 · 마스터」 · 키는 spec_raw(풍선) · 복사해 새 일위대가 창 규격은 빈 칸
- B09 설계내역서 구조물 줄 비고 = 「층 낱말 · 조합 이름」(옛 「personal|1/3|SP3-000015」)
- M02 일위대가 격자 머리 = 번호꼴 일위대가번이면 이름 · 「일위대가」 · 참조 줄 비고 = 층 낱말 · 참조 창 제목 · 이미 담음 알림에서 키 뺌
- 내 자료집 저장 알림 · 같은 이름 물음에서 키 뺌
- 시험: test_704_3 격자 · test_701_1 비고 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:30:51 +09:00
eomsangdonandClaude Sonnet 5.5 3c2c82c78e fix(M02 기준 목록·계산서): 끝수 = B09 산근 규칙(줄마다 버림 · 비목마다 버림)으로 맞춤 — 계산서 5,654 대 B09 5,651 어긋남 (PLAN 704-2 딸림)
- 새 `M02_Logic_Settle` — 엔진 구조 그대로 줄 ROUNDDOWN 1 · 비목 ROUNDDOWN 0 · 작업량 Q ROUND 2 · 하위 로직 같은 규칙
- 기준 목록 금액 · 계산서 줄 · 소계 · 계가 같은 값 · 목록 저장본 꼴 번호 올림 · 머리 글에 지식DB 단수처리 근거
- 시험: 거울 test_704_settle(B09 _money · block_totals) · 옛 목록 시험은 끝수 차 3 원 안으로 고침

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 18:19:09 +09:00
eomsangdon 01334a5730 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_6 2026-10-31 18:16:53 +09:00
eomsangdonandClaude Opus 5.5 8adf98f401 knowledge(요구): 요구 527 원문 기록 — 지질구조선 출처 · 마지막 남은 자료 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 18:16:36 +09:00
eomsangdonandClaude Opus 5.5 fd76c5b76e knowledge(요구): 내역 9 원문 기록 — 현행 비목 물음 · 저장 속도 · 이어서 진행
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:15:30 +09:00
eomsangdonandClaude Opus 5.5 141de5bd69 knowledge(요구): 요구 526 원문 기록 — 생태자연도 오픈API 승인(키 값 빼고)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 18:12:03 +09:00
eomsangdon 8626b1a922 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_5 2026-10-31 18:11:07 +09:00
eomsangdonandClaude Opus 5.5 ab5e5c8277 knowledge(요구): 요구 525 원문 기록 — 임상도 제외 · 다음 보호 · 규제 구역 · 생태자연도 · 지질도
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 18:09:40 +09:00
eomsangdon edeb06698b Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8 2026-10-31 18:09:11 +09:00
eomsangdonandClaude Opus 5.5 36200c2756 knowledge(요구): 통합 길 점검(sub6) 기록 — 대표 일 최종 누름 · 이음매 흠
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:09:10 +09:00
eomsangdonandClaude Opus 5.5 481363e792 knowledge(요구): 요구 524 원문 기록 — 국가임도만 = 맞음 · 지방 · 민간임도는 사용자 입력 · 임도망도 전체 받음 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 18:03:10 +09:00
eomsangdonandClaude Opus 5.5 e0b90876ce knowledge(요구): 요구 523 원문 기록 — 받은 셋 = 오픈API · 임도망도 경북 47.zip 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 17:51:31 +09:00
eomsangdon 1d2425ee53 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_8 2026-10-31 17:46:53 +09:00
eomsangdonandClaude Sonnet 5.5 74f3fb8009 feat(B09 설계내역서): 불러오기 끝 = 지난번 이음 바로 잇고 남은 제안만 창에(남은 0 = 창 없음) · 잊은 짝 곁 느슨한 기억 안 좁힘 (PLAN 707-4 덧)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:46:00 +09:00
eomsangdonandClaude Opus 5.5 b3f3aed0a5 fix(M02 통합 점검 · 704-4 × 706-1): 회사에 올리기 승인 · 회사로 복사 때 규격 열 수량이 버려지던 흠
- 흠: 개인 층 하위 호표(사전조합)를 담은 구조물 조합을 회사로 올리면 참조가 회사 새 키로 바뀌는데
  규격 열 수량 열쇠(`참조|personal|…`)는 그대로 → 그 줄 열 값이 버려져 기본 수량으로 셈
  (실측: 거창 제5 · 6호표 묶음 2.5x2.0 열 1,270,715.6 → 회사 사본 1,259,136.2 · 차 11,579.4)
- 고침: 참조 따라 복사(carry)할 때 규격 수량 열쇠도 새 참조로 바꿈 · 따라 복사되는 하위 문서도 같게
- 고친 파일: M02_MasterTemplete/M02_Labor_Ref.py · resources/tester/test_m02_labor_ref.py
- 확인: 시험 test_m02_labor_ref · test_706_1 27 · test_704_4_spec 10 통과 · 브레인검증회사 다시 요청 · 승인 → 회사 사본 2.5 열 1,270,715.6 = 원본

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:39:41 +09:00
eomsangdon 3747c3388a Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8 2026-10-31 17:38:06 +09:00
eomsangdonandClaude Opus 5.5 0e8270518b knowledge(요구): 요구 522 원문 기록 — 화면 확인은 나중에 한 번에 · 다음 찾을 자료 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 17:35:01 +09:00
eomsangdonandClaude Opus 5.5 f37a521136 feat(B09 불러오기): .STC(STmate 원본) 바로 읽기 — 내역 BOQ11 · 단가 COSTN · 호표 목록 · 불러오기 길 · 창 *.STC (PLAN 707-6)
- B09_Estimation_Stc — DBF 필드 이름으로 읽음 · 묶음 T · 소계 E · 제외 C · 호표 번 = 같은 글자 코드 정렬 차례
- 다공구(BOQ12) · 변경 차수 = 당초만 읽고 알림 · BDQTY · COSTN.SMT · 요율 = UNKNOWN(같은 공사 엑셀로)
- Router_Import 이름 끝 갈림(.estx · .stc) · 미리 보기 알림 · UI_Import 파일 고르기 *.stc · 창 알림 줄
- 시험 test_707_6_stc 16 — 실무 STC 일곱 직접비 = PRJLST · 기번3 · 영월 · 봉화 엑셀과 줄마다 같음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:29:24 +09:00
eomsangdonandClaude Sonnet 5.5 0959f28717 feat(B09 설계내역서): 이 짝 잊기 = 잊음 표시를 기억에 남김 — 느슨한 기억(품명 · 단위 같음)으로도 다시 제안 안 됨 · 다시 이으면 풀림 (PLAN 707-4 덧)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-31 17:29:04 +09:00
eomsangdonandClaude Opus 5.5 1d71b66dea chore(설정): 생활안전지도 키 .env 에 넣음 · 요구 520 · 521 원문 기록(키 값 빼고) — 오류신고 취소
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 17:28:40 +09:00
eomsangdonandClaude Opus 5.5 a5b5dc1b50 knowledge(요구): 요구 517 조사 기록 — 전국 산림 주제도 다른 창구 · 상용 조건 · 공식 요청 길
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 17:27:09 +09:00
eomsangdon ee6f05bd00 Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5 2026-10-31 17:25:36 +09:00
eomsangdon 7d03b6f369 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_5 2026-10-31 17:25:31 +09:00
eomsangdon c99da6237f Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-31 17:25:29 +09:00
eomsangdonandClaude Opus 5.5 9a6fd80e75 knowledge(요구): 요구 519 원문 기록 — 산사태위험지도 WMS 포털 활용신청 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 17:24:35 +09:00
eomsangdonandClaude Opus 5.5 39684cc404 Merge origin/sub_laptop_5 into sub_laptop_8 — PLAN 707-4 공종 잇기 화면 · 불러오기 창 끝 겹침 양쪽 살림(단가표 같이 · 잇기 창 이어 뜸)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:24:21 +09:00
eomsangdonandClaude Opus 5.5 5655c628b9 feat(B09 설계내역서): 내역 줄 ↔ 우리 공종 잇기 창 — 모두 제안대로 · 줄마다 잇기 · 잇기 풀기 · 이 짝 잊기 (PLAN 707-4)
- 새 화면 B09_Estimation_UI_Link — 제안(지난번 이음 · 짝 표 · 이름 같음 꼬리표 · 후보 고름) · 수량 갈래(불러올 때 고른 것 · 줄에서 바꿈) · 묶음 개수 칸 · 제안 없는 줄 = 단가연결 고르기 창 재사용(우리 토공 · 운반 · 구조물 줄만) · 이은 줄 풀기 · 잊기 · 남은 안 이은 줄 수
- 고리 둘 — 불러오기 끝 = 잇기 창 이어 뜸 · 설계내역서 우클릭 = 잇기 · 풀기 · 잊기
- 「고름」 줄 기본 = 앞 줄이 안 쓴 후보 · 모두 제안대로 = 같은 우리 줄 둘째부터 남김
- apply 알림에 안 이은 불러온 항목 수

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:21:01 +09:00
eomsangdonandClaude Opus 5.5 ebd33de9c9 fix(B09 통합 점검 · 703-2 × 703-3): 사전조합에서 복사한 일위대가 — [업데이트] 뒤 금액 0 · [저장] 때 원본 사본 지움 막음
- 흠: 사전조합 연결 줄을 「복사해 새 일위대가」 → [바꿈] 하면 복사본 원본 열쇠가 `C:참조…` 꼴인데 쓰는 사본 셈(used_folders)이 `L:` 만 봄
  → [업데이트] 수요에서 원본 사본 노임 줄이 빠져 책에서 지워짐(금액 40,490 → 0 · 빈 줄 3) · [저장] 정리가 원본 사본 폴더를 지울 수 있음
- 고침: 복사본의 복사본(`K:`)은 맨 처음 원본까지 따라감 · `C:참조` = 그 참조를 담은 사본 폴더도 지킴(프로젝트 자리 줄 때)
- 고친 파일: B09_Estimation/B09_Estimation_Bill_Library.py · B09_Estimation/B09_Estimation_Router.py · resources/tester/test_703_b09_daega_copy.py
- 확인: 시험 test_703 · 703_1 · 703_3 · 204_2 · 251_1 29 통과 · 검증_sub6 [업데이트] 뒤 측구파기(토사) 40,490 그대로 · [저장] 뒤 서버 161,960 = 화면 · 사본 폴더 남음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:20:45 +09:00
eomsangdonandClaude Opus 5.5 02dc4f5842 knowledge(요구): 요구 518 원문 기록 — 경제림육성단지 파일 저장 · 오픈API 승인 확인(키 값 빼고)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 17:20:08 +09:00
eomsangdon 185ab410e1 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_8' into sub_laptop_7 2026-10-31 17:18:30 +09:00
eomsangdon ab5d1c96b5 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-31 17:18:26 +09:00
eomsangdon 66bec3205f Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8 2026-10-31 17:16:02 +09:00
eomsangdonandClaude Opus 5.5 a8cf79cee0 fix(공용 표 높이): 창 끝까지 셈(spaceBelow)이 열린 창 가림막 · 알림(fixed · absolute)을 표 아래 자리로 재던 흠 (PLAN 707-5 딸림)
- body 끝 가림막(창 높이)이 뒤 형제로 잡혀 창이 열린 채 다시 그리면 표가 최소 120px 로 짜부
- 흐름 밖(position fixed · absolute) 형제 = 자리 안 씀
- 부품 모음 견본(화면 끝 표 · 견본 창): 전 794 → 120px · 뒤 794 → 794px(밝음 · 어두움)
- 시험 test_202_1 가림막 줄 덧붙임

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:14:51 +09:00
eomsangdonandClaude Opus 5.5 1bb3b43064 test(낡은 시험): 합친 판 실패 넷 — 코드 흠 아님 · 모두 뒤 일감이 바꾼 자리 (PLAN 707-5 딸림)
- test_224_1 · helper_280_1: 고르기 창 몸 = 위 표 · 규격 표(704-4 specHost) · 열 — 둘째 자리 → 셋째 자리
- test_280_1 · test_b05_structures_form_summary 도우미: 부품 상자 글꼴 import(ui_template_icons.css)를 .ts 로 찾던 흠 — .css = 빈 모듈
- test_m02_precombo_scope: precombo 배선에 706-1 `more` 덧붙음 — 한 줄 글 대조 → scope · bar 줄 대조
- test_structures_summary_snapshot: robocopy 가 복제 도구 본체(common_util_project_copy)로 옮겨 감 — 껍데기 대신 본체 읽음
- test_202_1 · test_704_4: 합친 판에서 통과 — 고칠 것 없음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:07:57 +09:00
eomsangdon 4cc39a1e56 auto: 2026-10-31 17:07 (ESD_LAPTOP) 2026-10-31 17:07:54 +09:00
eomsangdonandClaude Opus 5.5 f1e59f0e5b knowledge(요구): 요구 516 원문 기록 — 산림 자료 git 에 올림 · 견본 물음 · 공공데이터포털 받기 물음
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 17:01:51 +09:00
eomsangdonandClaude Opus 5.5 56ebe76e7f feat(B09 불러오기): .estx 고리 — 내역 줄 · 단가표 같은 길 · 요율 = 엑셀 안내 · 호표 목록 = 이음 후보 수 (PLAN 707-5)
- 길: 이름 끝 .estx = `B09_Estimation_Estx` read_bill · read_prices(같은 그릇) · 요율 = 400 「같은 공사 엑셀에서」
- 미리 보기 `호표` = 대가 · 산근 목록 수 + 내역 줄이 코드로 가리킨 수(구성 줄 귀속은 안 함)
- 창: 파일 고르기 *.estx · [단가표도 같이](받은 patch 를 캐시에 함께) · 지난 파일 요율 실패 = 까닭 알림 뒤 다시 고름
- 시험 test_707_5 8(길 갈림 · 호표 수 · 깨진 파일 400)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:59:53 +09:00
eomsangdon e22fd23c6c Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_6 2026-10-31 16:58:29 +09:00
eomsangdonandClaude Opus 5.5 3eda7f1d26 chore(계획): PLAN 619 이관 — 공공데이터포털 키 .env · 산림 주제도 공개 파일 받기 (요구 514 · 515)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 16:58:24 +09:00
eomsangdonandClaude Opus 5.5 1a4c1b5178 feat(B09 설계내역서): 내역 잇기 서버 길 끼움 — 잇기 풀기 · 이 짝 잊기 · 줄마다 수량 갈래 · 잇기 판 (PLAN 707-4)
- Router 에 Router_Link 끼움
- 이은 자동 줄 was = 불러온 줄 · price 안 줄 · 자동 줄 덮개 · 같은 부모 앞 뒤 줄 키 → 풀기 = 제자리
- link/undo · link/forget 길 · suggest 답에 남은 줄 · 빈 자리 · 이은 줄(구조물 묶음에 딸린 줄 뺌)
- 시험 test_707_4 4 더함(풀기 차례 바꿔도 제자리 · 줄마다 갈래 · 판 · 일반 계정 회사 기억 못 지움)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:58:12 +09:00
eomsangdon 2d05b431d2 Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_8' into main_laptop_1 2026-10-31 16:57:36 +09:00
eomsangdonandClaude Opus 5.5 bf0f01d760 chore(secrets): 공공데이터포털 키 .env DATA_GO_KR_API_KEY 로 옮김 · 산림 주제도 폴더 추적 제외
- read_g2b_key 가 .env 의 DATA_GO_KR_API_KEY 읽음(없으면 멈춤)
- .secrets.local.md 키 값 줄 → 옮김 안내 한 줄
- .gitignore 에 resources/data_forest/ (산림청 공개 파일 원본)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:54:39 +09:00
eomsangdon 31cb4ba3af Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8 2026-10-31 16:53:45 +09:00
eomsangdonandClaude Opus 5.5 2e0552c9d5 fix(704-4 구조물 조합으로): 만든 알림 이름 = 적은 이름(폴더 이름 겹침 없앰)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:51:56 +09:00
eomsangdon f1323eff60 Merge remote-tracking branches 'origin/sub_laptop_6' and 'origin/sub_laptop_8' into sub_laptop_5 2026-10-31 16:48:55 +09:00
eomsangdon 0e106f36bf Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-31 16:48:51 +09:00
eomsangdon 8cbd069cc1 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_8 2026-10-31 16:46:48 +09:00
eomsangdonandClaude Opus 5.5 77c6f9105c feat(B09 설계내역서): 내역 줄 ↔ 우리 공종 잇기 서버 몫 — 토공 이름 짝 표 · 맞춤 기억 · 구조물 묶음 통째 잇기 (PLAN 707-4)
- B09_Estimation_Bill_Link: 짝 표(우리 토공 17 줄 · EST · STmate 이름 24 짝 · 암 갈래 = 후보 둘) · 제안(지난번 이음 · 짝 표 · 하나 아님 = 고름) · 잇기(707-1 붙임 꼴 · take = 엑셀 수량 덮개 / price = B08 · 불러온 줄 지움 = 두 번 셈 0) · 묶음 ↔ 구조물 줄(take = 안 줄이 그 아래 / price = 1 단위 고정 단가)
- B09_Estimation_Router_Link: suggest · apply 길(끼워 넣기 = 화면 고리 일감) · 맞춤 기억 = 층 실무자료/내역맞춤.json(회사 관리자 = 회사 · 아니면 개인 · 읽기 = 회사 + 개인)
- M02_Hopyo_Store: memory · remember 에 기억 파일 이름 칸(기본 = 맞춤)
- 시험 test_707_4 8(거창 엑셀 기억 → 장수 · 진안 .estx 저절로 11 · 두 번 셈 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:45:36 +09:00
eomsangdon ae90737cf7 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_2 2026-10-31 16:42:32 +09:00
eomsangdonandClaude Opus 5.5 86f40dedbd fix(705-2 붙여넣기 · 다시 잇기): 화면 검증에서 나온 흠 — 첫 줄 머리 짐작 · 열 짝 한 칸 밀림 · 저절로 고름 · 칸 폭 · B09 최신 캐시
- 첫 줄이 수량 없음이어도 금액 없으면(레미콘 별도계산) 줄 — 머리는 금액 있을 때만
- 기억한 열 짝을 다른 꼴(엑셀꼴 호표 칸 · STmate 꼴)에 붙이면 서버가 한 칸 밀어 맞춤
- 다시 잇기 저절로 고름 = 후보 하나 · 붙인 단가 있음만(별도계상 0 원 · 후보 여럿은 사람이 고름) · 빈 칸 글 없앰 · 고름 칸 앞으로 · 칸 폭 1100 안
- B09 알림에서 연 다시 잇기가 붙이기 전 캐시를 읽던 흠 — 붙인 뒤 캐시를 들고 감

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:42:28 +09:00
eomsangdonandClaude Opus 5.5 76c2b1cf49 knowledge(요구): 요구 515 원문 기록 — 공공데이터포털 키 .env 로 · 산림 자료 resources 에 받기 · CPU 표시 차 까닭
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 16:41:55 +09:00
eomsangdonandClaude Opus 5.5 6b6b1575eb Merge origin/sub_laptop_7 into sub_laptop_8 — PLAN 707-2 · 707-3 요율 · 단가표 불러오기 · M02 낱말 · B09 Store 머리 설명 겹침 양쪽 살림(705-2 @add · 707-3 노임 · 기계 덮개)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:39:23 +09:00
eomsangdonandClaude Opus 5.5 f64111ec53 merge(707): sub_laptop_8(sub5 707-1 내역 줄 불러오기) 합침 — 불러오기 길 · 화면 한 파일 · file_key 하나 (PLAN 707-1 · 707-2 · 707-3)
- Router_Import = import/bill · bill/apply(sub5) + import/rates · import/prices · 올린 파일 = 프로젝트 import/{표지} 한 자리
- UI_Import = [엑셀 내역 불러오기] + [엑셀에서 요율 · 단가표 불러오기] · 마지막 표지 함께 씀
- test_145_3 · test_166_2 TS 거울 = 글 stdin(Calc_Base · Calc_Bill 길어져 node -e 32 K 넘김)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:30:43 +09:00
eomsangdonandClaude Opus 5.5 6aa06d618b feat(M02 고르기 창 낱말): ★ 12 → 1 — 옵션 고치기 · 이 구조물에서 고치기 · 내 자료집에 복사 · 일위대가 금액 · 값 확인 전 · 면적산출 연결 칸 · 세부 구분 · 실무 엑셀 일위대가 · 옵션 머리 · 카드 · 목록 번호 숨김 (PLAN 707 ③)
- 남김 1 = 분류1~5(구조물집계표 층 이름 = 마스터 템플릿 자료 · 코드 밖)
- ui_template 네 파일 글만(한시 브레인 승인) · 키 · 동작 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:24:12 +09:00
eomsangdonandClaude Opus 5.5 550c0adcce feat(B09 설계내역서): .estx(EST 원본) 바로 읽기 모듈 — 내역 · 호표 목록 · 노임 · 자재 · 기계 평문 표 (PLAN 707-5)
- B09_Estimation_Estx: ZIP 안 DBISAM 고정 레코드 표를 메모리에서 읽음 · 엑셀 읽기와 같은 꼴(707-1 내역 · 707-3 단가표)
- 구성 줄 → 호표 귀속 · 제N호표 번호 · 원가계산서 행 경계는 안 읽음(UNKNOWN · 데스크탑 REA 필요)
- 시험 test_707_5_estx: 거창 = 엑셀과 줄마다 같음 · 직접비 384,916,227 · 장수 · 진안 줄 수 · 합 기록

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:24:02 +09:00
eomsangdon 3d59685e93 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_5 2026-10-31 16:18:26 +09:00
eomsangdon 5db7eba0e5 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_5 2026-10-31 16:18:24 +09:00
eomsangdonandClaude Opus 5.5 f9ffd5fc77 feat(M02 사전조합 → 구조물 조합 한 단계): 실무자료 확인 창 [구조물 조합으로] · 규격 묶음 제안 (PLAN 704-4 뒷부분)
- 확인 창 몸 그대로(체크 = 품셈 줄 · 나머지 = 고정 줄)로 구조물 조합 항목(SU)을 바로 만듦 — 사전조합 SC 문서 안 씀 · 옛 [사전조합 생성] 길 그대로
- 규격 묶음 제안 — 같은 이름 · 단위 · 줄 구성 · 규격만 다른 호표 → 한 항목의 규격 열(H=1.5 → 이름 H · 열 1.5) · 열마다 같은 몸으로 만든 줄 수량 · 기본 열 = 연 호표
- 서버 M02_Hopyo_Item · /hopyo/{키}/{번}/siblings · /hopyo-item(이 층 쓰기 · 「실무자료」 폴더) · Builder.build(save=False)
- 규격 열 줄 id 가 고정 줄을 못 알아보던 흠 — line_key = fixed_id(TS lineId 와 같음)
- 만든 뒤 나무 · 옆 목록 다시
- 시험: 울진 대흥 돌기슭막이 H 1.5 / 2.0 / 2.5 = 한 항목 열 셋 · 열마다 = 그 호표 엑셀 합계 · 줄 구성 다른 호표 400 · 남의 층 403 · 사전조합 0 · 마스터 그대로

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:14:59 +09:00
eomsangdonandClaude Opus 5.5 8956a6c902 test(707-1): 내역 줄 이음 시험 — 같은 책 일위대가 이음 단가 · 어긋남 알림 · 이음 찾기(같은 파일 이름 본뜬만)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:14:36 +09:00
eomsangdonandClaude Opus 5.5 c33f6837f2 fix(B09 설계내역서): 엑셀 내역 불러오기 화면 검증 흠 — 단가만 빈칸 · 운반 줄 오붙음 · 차례 · 표 높이 (PLAN 707-1)
- 단가만 갈래 = 붙인 자동 줄에 B08 수량이 없으면 엑셀 수량(빈칸 금액 막음)
- 운반 자동 줄(「모래」 · 「시멘트」 = 자재대 줄 이름) = 붙이지 않음 · 지우기만
- 불러온 공종 = 관급자재대 앞(엑셀 차례 · 표 끝에 묻히지 않음)
- 결과 창 = 표를 다시 그린 뒤 따로 엶(열린 창 가림막을 표 아래 자리로 재어 표가 한 줄로 줄던 것) · 어긋난 줄 표 칸 폭 줄임
- 못 읽은 파일(빈 · DRM · 내역 시트 없음) = 프로젝트 import 폴더에 안 둠

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:13:34 +09:00
eomsangdonandClaude Opus 5.5 a5f87812e6 feat(B09 불러오기 화면): 총괄 · 원가계산서 [엑셀에서 요율 불러오기] · 노무 · 중기 · 자재단가대비표 [엑셀에서 단가표 불러오기] · 확인 창 · [적용] = 캐시 (PLAN 707-2 · 707-3)
- 확인 창 = 짝 표 · 우리 식 대조(같음 · 식 다름 · 안 고른 쪽) · 짝 없음 n · 마지막 파일 열쇠 다시 씀 · [다른 파일]
- 단가표 이음 수요 = 화면 단가 목록(GET)과 같은 사본 전부
- test_243_1 B09 표 목록 — 702-1 펼침 표(받기 전부터 실패) · 707 확인 창(보기 전용) 더함

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:06:42 +09:00
eomsangdonandClaude Opus 5.5 175857de81 feat(B09 펼친 일위대가 붙여넣기): Ctrl+V = 지금 호표 끝 고정 줄 · 이 공사 단가로 다시 잇기 (PLAN 705-2)
- 펼친 일위대가 표를 누르고 Ctrl+V → 서버 /estimation/daega/paste(붙인 글 · M02 와 같은 해석 · 열 짝 물음 · 기억) → 덮개 @add 줄(캐시 · [저장] 때 서버가 다시 셈)
- 셈 짝(파이썬 · TS) — @add 줄을 그 cover 블록 끝에 더함(복사본 앞 · 뒤 · 같은 cid 한 번) · 짝 잃음에서 뺌 · 저장 정리 clean_added
- 붙인 줄 이음 키 = 단가 책 수요(added_keys) · 답에 그 캐시로 셈한 원가 자료
- 알림 누름 = 다시 잇기 창(후보 = M02 이음 짝 · 단가 = 이 공사 단가 책 · 바꿈 = 캐시 + 서버 셈)
- 시험: 거울(파이썬 = TS) · 같은 글 두 번 = 「 (2)」 가름 · 정리 · 수요 키

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:04:16 +09:00
eomsangdonandClaude Opus 5.5 32de488401 feat(M02 붙여넣기 화면 · 다시 잇기): 격자 Ctrl+V · 열 짝 한 번 묻고 기억 · 이 공사 단가로 다시 잇기 (PLAN 705-2)
- 일위대가 격자(ui_template_hopyo_grid onPaste) 누르고 Ctrl+V → 서버 /hopyo-paste · 열 짝 = 처음 한 번 묻고 이 브라우저에 기억 · 엑셀 머리 글이면 묻지 않음
- 호표 머리 없음 = 열린 문서에 고정 줄(알림 누름 = 다시 잇기) · 머리 있음 = 호표마다 사전조합 + 결과 창 [다음 — 이 공사 단가로 다시 잇기]
- 다시 잇기 창(EST 신 · 구 단가 비교 꼴) — 붙인 단가 · 최신 판 단가 · 후보(705-3 이음 짝) · 금액 차이 · 줄마다 · 모두 고름 · [바꿈] · [되돌리기]
- 서버 M02_Hopyo_Relink — 후보 표 · 이음 칸만 고쳐 씀(숫자 안 받음) · 전후 1 단위 값 · 되돌림
- 실무자료 [모두 고정으로 들여오기] 결과에도 다음 걸음 단추
- M02 상세가 고정 줄을 읽을 때 떨구던 흠 — normalizeLabor 가 고정 칸 지킴 · 로직 표 · 격자에 고정 줄 이름 · 규격 · 단위 · 「고정」 꼬리표 · 이음 / 손 단가

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:57:39 +09:00
eomsangdonandClaude Opus 5.5 4cd4fdb3a3 feat(B09 불러오기): 원가계산서 요율 · 기초 단가표 읽기 · 이 공사 단가 책 이음 · 올린 파일 열쇠 길 (PLAN 707-2 · 707-3)
- POST estimation/import/rates · import/prices (file | file_key · 저장 안 함 · patch 만)
- 요율 = 행 이름 → 줄 키 · 손입력 칸(안전 기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) · 엑셀 대조
- 단가표 = 노임 · 자재 · 기계 → labor_cover · machine_cover · materials.applied · 짝 없음 · 짝 여럿

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:53:33 +09:00
eomsangdonandClaude Opus 5.5 4e190bad1a knowledge(요구): 요구 514 원문 기록 — 1:5,000 은 나중 정식 요청 · 되는 범위 받기 · 용량 · 공공데이터포털 키 확인
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 15:51:37 +09:00
eomsangdonandClaude Opus 5.5 02886d5d4d feat(B09 설계내역서): [엑셀 내역 불러오기] 창 — 수량 갈래 · 두 번 셈 막음 · 어긋난 줄 목록 (PLAN 707-1)
- 설계내역서 도구 줄 끝 단추 → 파일 고름 → 창 한 번(넘겨받기 · 단가만 · 이름 같은 자동 줄 붙임 · 남는 자동 줄 지움) → [불러오기] = 화면 캐시(저장 안 함 · [저장]해야 남음)
- 어긋난 줄(엑셀 금액 ≠ 우리 금액) = 같은 창 목록 · 없으면 닫고 알림 · DRM = 「읽을 수 없음」
- 단가만 갈래 = 불러온 수량과 10 % 넘게 다른 수량 칸 붉게 · 풍선 = 불러온 수량
- 시험 243-1(표 만드는 길 목록 = 불러오기 창 표 더함 · 보기 전용) · 200-2(도구 줄 배선 = 불러오기 단추 끝) 고침 — 낡은 글자 대조

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:50:19 +09:00
eomsangdonandClaude Opus 5.5 137b8a3a45 feat(M02 엑셀 붙여넣기): 붙인 글 서버 해석 · 호표마다 사전조합 · 줄만 고정 줄 (PLAN 705-2)
- M02_Hopyo_Paste — 탭 · 줄 글 → 실무자료 꼴(파일 올리기와 같은 꼴) · 머리 글 줄이면 열 짝 저절로 · 없으면 기억한 열 짝 · 둘 다 없으면 짐작 + 물음
- 「제N호표」 엑셀꼴(같은 줄 머리) · STmate 꼴(다음 줄 머리) · 첫 줄 수량 없음 = 머리 · 합계 · 계 · 소계 · 계약단가 · 근거 줄 거름 · 쉼표 · 0 빈칸
- /hopyo-paste — 물음 · 줄(열린 문서 · 있는 줄 가름 피함) · 만듦(실무자료 「붙여넣기 …」 + build_all 그대로)
- build_all 답에 만듦 [{번, 이름, 키}]
- 시험: 실무 xlsx 넷 시트 통째 붙여넣기 = 파일과 호표 · 줄 · 합계 같음 · 만듦 엑셀 합계 = 우리 합계(14 · 23 · 34 · 25)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:46:32 +09:00
eomsangdonandClaude Opus 5.5 c0ea73501f feat(B09 설계내역서): 경쟁사 내역 엑셀 줄 불러오기 서버 — 읽기 · 짜기 · 고정 단가 칸 (PLAN 707-1)
- 읽기(Bill_Excel) — STmate 「설계내역서」 숨은 코드 · 부모 코드 열 · EST 「내역서」 공종 번호 깊이 · 비고 대가 · 산근 · 단산 · 일위 · 제외 묶음(관급 · 제외금액) · 조달수수료 = 율 · DRM = 「읽을 수 없음」 · 열 머리 = M02 실무자료 칸 읽기 재사용
- 짜기(Bill_Import) — 공종 = 새 묶음 · 항목 = 새 줄 + 고정 단가 칸 · 수량 갈래 take · price · 이름 같은 자동 줄 붙임 · 남은 자동 줄 지움 · 같은 파일 다시 = 갈아 끼움 · 어긋난 줄 목록
- 길(Router_Import) — import/bill(file · file_key) 미리 보기 · import/bill/apply(같은 파일 이름 실무자료 사전조합 → 서고 사본 연결 · 층 쓰기 0) · 저장 안 함
- 셈 짝(Calc_Bill py · ts) — 줄 price = 연결이 단가를 못 낼 때 금액 · fixed · src_qty · Store 다듬기
- 확인 = 기번3 710,359,136 · 영월 767,148,700 · 봉화 781,283,415 · 거창 384,916,227 직접비 합 = 엑셀 · 빈 줄 0 · 어긋남 0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:43:51 +09:00
eomsangdonandClaude Opus 5.5 0b2330aee6 fix(B09 단가 덮개): TS 이름 겹침(PARTS → COVER_PARTS) · 내역 거울 시험 글을 stdin 으로 (PLAN 707-3)
test_145_3 블록 나누기 시험 — Calc_Base.ts 가 길어져 node -e 명령 줄이 Windows 32 K 를 넘음 · 같은 글을 stdin 으로 넘김(판정 그대로).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:41:17 +09:00
eomsangdonandClaude Opus 5.5 39e0ee6d85 feat(B09 단가 덮개): 노임 · 기계 단가 덮개 labor_cover · machine_cover — 셈 짝 · 저장 다듬기 · 거울 시험 (PLAN 707-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:39:16 +09:00
eomsangdonandClaude Opus 5.5 df5da11b37 test(243-1): B09 표 만드는 길 시험 — 702-1 펼침 표 더함
702-1 B09_Estimation_UI_Tabs_Bill_Fold.ts 가 펼친 일위대가 구성 표를 제 표로 만듦(editOnDouble: true · 두 번 눌러 고침).
코드 흠 아님 · 시험이 팝업 · Shell 두 곳만 알던 낡은 목록 — 펼침 표를 목록에 더하고 두 번 눌러 고침 확인 한 줄.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:28:05 +09:00
eomsangdon 4f4348c80e Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5 2026-10-31 15:27:12 +09:00
eomsangdonandClaude Opus 5.5 9b543077ff knowledge(요구): 705-4 경쟁사 결과물 불러오기 범위 조사(sub7) 기록
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:26:10 +09:00
eomsangdon 70ddc55650 Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_8 2026-10-31 15:25:01 +09:00
eomsangdon bbbf963d05 Merge remote-tracking branches 'origin/sub_laptop_5' and 'origin/sub_laptop_8' into sub_laptop_6 2026-10-31 15:24:20 +09:00
eomsangdon 459002dc4f Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-31 15:24:17 +09:00
eomsangdonandClaude Opus 5.5 3435922131 feat(B09·B08): 화면 낱말 실무 말로 · 번호 숨김 · ☰ 서랍 내 자료집 길(PLAN 703-4)
- B09 데이터 점검 → 내역 점검 · 공기 옵션 → 공사기간 조건 · 박스보기/호표보기 → 카드 보기/표 보기 ·
  하이퍼링크/팝업 → 탭 이동/곁 창 · 굳힌 단가 → 이 공사 단가 책 · 산식/요율 출처 → 계산식/요율 근거 ·
  빈 줄 → 금액 빈 줄 · 단가연결 칸 램프 → 단가연결 칸 ● · 새 산근 → 새 단가산출(산근) · 연결 없음 → 일위대가 없음
- 단가연결 창 종류 일위대가 사전조합 → 일위대가(자료집) · 일위대가 호표 → 이 공사 일위대가 · 단가산출 호표 →
  이 공사 단가산출 · 대가N/산근 N호표 → 일위대가 N호/단가산출 N호 · 구조물 조합 줄 번호(SS…) 숨김 ·
  넣을 값 창 제목 번호 → 알림 글 끝
- B08 조합 사본 · 조합 연결 · 노랑 램프 → 구조물 조합 · 구조물 조합 고르기 · 노랑 ●(locale_b4 B08_Quantity_* 글만 · 한시 브레인 승인)
- ☰ 서랍 · 진행 판 끝 「내 자료집(일위대가 · 구조물 조합)」 = M02 일위대가조합 개인 층(떠남 물음 뒤)
- 낡은 시험 글 고침(새 낱말): 143-1 · 157-2 · 182 · 203-1 · 204-2 · 304 · 311 · 701-3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:18:10 +09:00
eomsangdon 47e1d5687d Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8 2026-10-31 15:16:26 +09:00
eomsangdonandClaude Opus 5.5 2789afb102 feat(M02 실무자료 빠짐 0): 확인 안 한 줄 · 노임 · % = 고정 줄 · [모두 고정으로 들여오기] · [품셈으로 바꾸기] (PLAN 705-3)
- 만들기 — 체크 안 한 줄 · 노임 · 「우리에 없음」 = 고정 줄(엑셀 숫자 그대로 · 서버가 파일에서) · % 줄 = 비율 고정 줄(밑수 = 다른 줄 합일 때 · 아니면 금액 그대로) · 합계만 = 재료비 · 같은 이름 = 차례 가름 · 하위 호표 = 확인된 사전조합 참조
- 이음 = 이름 · 규격 · 단위 · 단가 다 같은 노임 · 자재만 저절로 · 다른 단가 = 이음 후보 수
- 시트 통째 — /hopyo-build-all(하위 먼저 · 확인된 호표 그대로) · 알림 「호표 · 줄 · 품셈 줄 · 고정 줄 · 빠짐 · 엑셀 합계 = 우리 합계」 · 비교 = 엑셀 끝수 규칙(EST 내림 · STmate 반올림)
- 비율 고정 줄 셈 — M02 settle 짝(파이썬 · TS ratioLines) · 사전조합 셈 · B09 pct 줄
- 확인 창 — 만든 사전조합 품셈 줄 = 첫 체크 · [품셈으로 바꾸기] · [모두 고정으로] · 생성 물음에 전후 금액 · 품 다른 줄 수
- 낡은 시험 고침 — 빈 몸 400 · 빠진 줄(확인 안 함 · %) 단정 = 고정 줄로 바뀐 규칙

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 15:10:16 +09:00
eomsangdon 69d70a3f68 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_7 2026-10-31 15:09:33 +09:00
eomsangdonandClaude Opus 5.5 fdb3eb9b77 feat(B09): 오른쪽 재료 창고 · 지난 공사 일위대가 가져오기(PLAN 703-1)
- 설계내역서 표 오른쪽 서랍(공용 side_panels · 접고 펴기 · 좁은 화면 = 아래) — 찾기 하나 + 출처 탭
  이 공사 · 내 것 · 회사 · 품셈(704-1 기준 목록 · 우클릭 계산서 보기) · 지난 공사
- 두 번 누름 · Enter = 고른 내역 줄에 단가연결 · 끌어 놓기 = 놓은 줄 · 연결 = 단가연결 모달과 같은 길(702-3 즉시 금액)
- 품셈 = 넣을 값 창에 계산서 입력 전부(목록 바꿈은 기본과 다른 것만 — 석축헐기 뒷길이 60 이 0 으로 열리던 것)
- 서버 B09_Estimation_Past + GET/POST /estimation/past — 같은 회사 다른 공사 구조물 사본 · 서고 사본 →
  이 공사 서고 사본(폴더 통째 · 가져온곳 · 덮개째 · 단가 = 이 공사 책) · 남의 회사 403
- 표 높이 셈(spaceBelow)이 뒤 형제만 보므로 서랍 = DOM 앞 · CSS order 로 오른쪽
- 시험 test_703_1 6 · 셸 꼴 글 시험 200-2 · 202-1 = 표 칸 + 서랍 꼴로 고침(낡은 글)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:59:58 +09:00
eomsangdonandClaude Opus 5.5 bd4d421668 knowledge(요구): 요구 513 조사 기록 — 산림청 주제도 다섯 받는 길 · 이용 조건
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 14:58:42 +09:00
eomsangdonandClaude Opus 5.5 f0be3cd766 feat(회사에 올리기 화면): 나무 우클릭 요청 · 요청 중 · 회사 층 「올리기 요청 n」 띠 · 목록 미리 보기 · 승인 · 거절 · 결과 띠 (PLAN 706-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:55:46 +09:00
eomsangdonandClaude Opus 5.5 74352ec6b8 feat(회사에 올리기): 요청 장부 · 요청 · 목록 · 승인 · 거절 · 알림 봄 길 + 임시 storage 시험 (PLAN 706-1)
- 회사 층 폴더 올리기요청.json 하나 · DB 안 바꿈
- 승인 = 「회사로 복사…」와 같은 길(구조물 = 붙여넣기 · 사전조합 = 서고 복사 + 폴더 자리) · 원본 개인 것 그대로
- 권한 = M02_Layer_Access 재사용 · 일반 승인 403 · 다른 회사 장부 안 봄 · 같은 것 다시 요청 409

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:52:30 +09:00
eomsangdonandClaude Opus 5.5 4ed0509c7a knowledge(요구): 요구 513 원문 기록 — 산림청 map.forest.go.kr 주제도 API 확인만
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 14:47:08 +09:00
eomsangdon f91ad85826 Merge remote-tracking branch 'origin/dev' into sub_laptop_7 2026-10-31 14:45:31 +09:00
eomsangdon 87d8638a5b Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_7 2026-10-31 14:45:14 +09:00
eomsangdon 289c000cd7 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7 2026-10-31 14:45:08 +09:00
eomsangdonandClaude Opus 5.5 042ea971b6 Merge origin/sub_laptop_7 into sub_laptop_8 — PLAN 704-4 규격 표 1차 · M02 일위대가 셈 짝 머리 설명 겹침 양쪽 살림(705-1 고정 줄 · 704-4 규격 열)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:44:09 +09:00
eomsangdonandClaude Opus 5.5 90273507c5 fix(규격 표 화면): 열 합계 한 자리 · 규격 줄 칸 폭 · B08 · B09 금액 = 열 수량 × 단가 시험 (PLAN 704-4)
- 격자 합계 줄 규격 칸 = 금액 칸처럼 소수 한 자리
- 규격 이름 · 열 이름 칸 6em — 좁은 카드에서 한 줄
- 임시 storage 시험 — 사본 셈 반영합 = 층 문서 그 열 셈(B08) · 대가 줄 금액 = 열 수량 × 단가(B09)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:41:13 +09:00
eomsangdonandClaude Opus 5.5 56e2c0b515 feat(규격 표 화면): 격자 규격 열 · M02 규격 표 편집 · 고르기 창 열 단추 (PLAN 704-4)
- 공용 격자 — 첫 호표 specs 면 수량 칸을 열마다(머리 「H 열」 · 기본 표시) · 합계 줄 그 칸 = 열마다 1 단위 합계
- M02 일위대가 카드 · 상세 — 격자 위 규격 줄(이름 · 열 이름 · 기본 · 열 지움 · [+ 열]) · 격자 수량 칸 열마다 고침(짝 셈 바로)
- 새 파일 M02_MasterTemplete_Labor_Spec.ts — 상세(700 줄)는 한 줄만 바꿈
- 고르기 창 — 규격 열 둘 이상이면 열 단추(열마다 합계) · [적용] = spec 붙은 참조 · 이름 뒤 「(H=열)」 · 열 하나 = 단추 없음
- 시험 704-3 격자 시험 두 줄 고침(laborGrid 넷째 인자 · Labor_Spec 이어 붙임) · 704-4 화면 자료 시험 둘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:23:46 +09:00
eomsangdon 4290edb8bc Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_6 2026-10-31 14:20:37 +09:00
eomsangdonandClaude Opus 5.5 94cbf28f16 test(298-2): B09 [>] 시험 = 빈 줄 0 · 빈 줄 n(409) 두 갈래
- 까닭: 701-3 「빈 줄 있으면 409」 뒤 시험의 root = Path(".") 가 실제 저장소 뿌리로 내역을 셈 → 빈 줄 생겨 409 로 실패
- payload 를 갈아 끼워 빈 줄 0 = 단계 6 next · 빈 줄 3 = 409 + mark_stage 안 부름

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:19:34 +09:00
eomsangdon a6b54f04e5 Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_5 2026-10-31 14:18:05 +09:00
eomsangdon abb8729dbd Merge remote-tracking branch 'origin/sub_laptop_6' into sub_laptop_8 2026-10-31 14:17:55 +09:00
eomsangdon 1dc48b06ac Merge remote-tracking branch 'origin/dev' into sub_laptop_5 2026-10-31 14:17:51 +09:00
eomsangdonandClaude Opus 5.5 3f2bb61bc7 Merge origin/sub_laptop_5 into sub_laptop_8 — PLAN 702-3 · B09 Router 머리 설명 겹침 양쪽 살림([업데이트] 703-3 · calc 702-3)
남은 것: test_298_2 test_b08_b09_next[B09] 가 701-3 빈 줄 409 와 부딪힘(root=Path(".") 로 실제 셈) — sub5 다음 일 첫 걸음에서 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:16:31 +09:00
eomsangdonandClaude Opus 5.5 425a4a9dfb feat(규격 열 고르기): summary_item spec · 사본이 고른 열 굳힘 · 같은 항목 = 열까지 · B07 한 장 = 항목 + 열 · 쓰는 열 지움 409 (PLAN 704-4)
- summary_item spec(파이썬 · TS 짝) — 없으면 기본 열 · 모르는 키 거절 그대로
- 사본 = 고른 열 수량을 손으로 굳힘 · manifest 규격 · same_item 에 spec(열 바꿈 = 새 사본 · 덮개 열쇠 S:폴더 그대로)
- B07 구조물 도면 키 = 항목 + 열 해시 — 열이 다르면 따로 장
- M02 일위대가 [저장] — 쓰는 사본 있는 열 지움 · 이름 바꿈 409
- 항목 목록 labor.규격(열 · 열마다 합계) — 고르기 창 열 단추
- 시험 test_704_4_spec 7(열 바꿈 뒤 덮개 남음 · 짝 잃음 0)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:16:06 +09:00
eomsangdonandClaude Opus 5.5 59a694a7ed feat(B09): 단가연결 즉시 금액(뒤에서 서버 셈) · [저장] 단추 · 옮길 때 물음(PLAN 702-3)
- 새 산근(로직 연결 · 입력값) = 연결 즉시 뒤에서 POST …/estimation/calc(화면 캐시로 셈 ·
  저장 안 함) · 차례 번호 · 옛 요청 끊기 · 오는 동안 그 줄 금액 칸만 「셈 중」 · 잠금 없음
  (새 `B09_Estimation_UI_Live.ts`) · 기준 = payload.user(서버가 그 셈에 쓴 값)
- 조작 중 셈 = 마지막 GET · [저장] 의 B08 원천 다시 씀(120 s · 0.6 → 0.15 s) · [저장] · GET 은 늘 새로 읽음
- [저장] 단추 = 왼쪽 아래 [초기화] 옆(B08 과 같은 부품 · 꼴) · 고침 없으면 알림만
- 단계 이동 · [>] = 저장 안 한 고침 있으면 공용 떠남 물음(취소 · 폐기 · 저장) — 옛 자동 저장 없앰
- 서고 사본 payload 의 user 받음(서버 기준) — 화면 캐시와 다른 로직 줄은 뒤에서 다시 셈
- 낡은 시험 고침: 481-2 · 298-2(자동 저장 → 물음 · [저장] 단추) · 184-3 · 304(단추 줄) ·
  204-2(user 받음) · 200-2(701-3 띠 · 702-1 펼침 뒤 셸 꼴)
- 확인 = test_702_3 3 · B09 관련 312 통과 · typecheck 0 · 검증_sub5 T6 11 누름 · 연결 뒤 0.32~0.49 s 금액

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:11:44 +09:00
eomsangdonandClaude Opus 5.5 3028801cd8 feat(일위대가 규격 열): 문서 「규격」 칸 검사 · settle 열 끼우기 짝 · 열마다 반영합 (PLAN 704-4)
- M02_Labor_Spec 새 파일 — 규격 칸 정리 · 열 끼우기 · 사본 굳히기 · 쓰는 사본 찾기
- settle(파이썬 · TS) 에 col — 그 열 수량이 있는 줄 = 손 자리 · 규격 없음 = 그대로
- 계산값 규격합 = 열마다 반영합(고르기 창 열 단추)
- 거울 시험 — 규격 열 · 모르는 열 = 기본 · 꼴 틀림 400 · 열 없는 옛 문서

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:10:04 +09:00
eomsangdonandClaude Opus 5.5 a24e0d622c feat(M02·B09 고정 줄): 일위대가 줄 「고정」 갈래 · B09 대가 fixed 줄 · 이음 단가 · 집계표 빠짐 수 (PLAN 705-1)
- M02 일위대가 줄 {고정: 이름 · 규격 · 단위 · 단가 · 이음(노임 · 자재 · 기계) · 출처} — 정리 · 셈(새 파일 M02_Labor_Fixed) · 줄 가름 짝 TS lineId
- 사본 단가 묶음 · B09 단가 책 수요에 이음 키
- B09 대가 줄 fixed — 이은 비목만 단가 책 값 · 못 이음 = 줄 단가 · 덮개 먹음 · 빈칸 안 됨(파이썬 · TS 짝)
- 집계표 — 이은 고정 줄 = 자원 셈 · 이음 없는 줄 = 빠짐 수(summary.fixed · 안내 「고정 줄 n」)
- B09 일위대가표 줄 꼬리표 「고정」 값 넘김
- 시험 test_705_1 12 · test_145_5 응답 칸 목록에 fixed(낡은 단정 고침)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 14:05:47 +09:00
eomsangdon 492dd05a9c Merge remote-tracking branch 'origin/sub_laptop_8' into main_laptop_1 2026-10-31 14:01:04 +09:00
eomsangdon d9d7724d94 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-10-31 14:00:59 +09:00
eomsangdonandClaude Opus 5.5 13de94b9dc knowledge(요구): 704-4 규격 표 설계 제안(sub7) 기록
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 13:59:37 +09:00
eomsangdon 66a9e71c26 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8 2026-10-31 13:57:53 +09:00
eomsangdonandClaude Opus 5.5 ac25855ae6 knowledge(요구): 요구 512 원문 기록 — 임상도 물음(브이월드 · 공공데이터포털)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015uTVHHDv583hbLQAFeBmd9
2026-10-31 13:52:14 +09:00
9633 changed files with 934008 additions and 105193 deletions
+4 -1
View File
@@ -89,4 +89,7 @@ AUTH_OTP_DISABLED=True
# 대시보드 지식 챗봇(B01_Dashboard_Knowledge_Chat) NVIDIA 키
NVIDIA_API_KEY=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
# 노선분석 산사태위험지도(행안부 생활안전지도 WMS IF_0046 · D03_PlanAnalysis_Landslide) 키 — 비우면 겹 꺼짐
SAFEMAP_API_KEY=
SAFEMAP_API_KEY=MFO8CY4O-MFO8-MFO8-MFO8-MFO8CY4O6X
DATA_GO_KR_API_KEY=0j5WZHeNxSkdPVSLXUsx9kVikXEN%2BDc%2Byv68jz8IpKFffjbZkRiQrJO78y6rxPCTqhiSOW4lDxpid0twkSVQ9g%3D%3D
# 지오빅데이터(KIGAM) 오픈API 키 · 요구 597
GEOBIGDATA_API_KEY=FVLh4UQc9x4bvYhXJWRITxF4v3y2ns
+4
View File
@@ -42,6 +42,8 @@ storage/
#resources/
# 전국 등고선 gpkg 22GB — 영구 추적 제외 (배포서버 구성 시 직접 배치)
resources/data_global_contours/national_contours.gpkg
# 산림 주제도 원본(산림청 공개 파일) — 추적 제외
resources/data_forest/
# 로그 파일
*.log
@@ -79,3 +81,5 @@ config/e03_walls_node/
# graphify 결과(폐기 도구) — 추적 안 함
graphify-out/
# 큰 자료 시놀로지 전달용 임시 폴더(git 밖)
/_sync_data_forest/
+12 -3
View File
@@ -133,7 +133,7 @@ function buildBrand(): HTMLButtonElement {
* 서랍을 열 때 서랍으로 옮기고 닫을 때 제자리(진행 판)로 — 그리기 · 누름 코드는 한 벌. */
let drawerSteps: { node: HTMLElement; home: HTMLElement | null } | null = null;
/** 집(진행 판) 없는 요소 = 서랍이 늘 가짐 · 닫을 때 안 옮김(B01 · M01 · M02 바로가기) */
/** 집(진행 판) 없는 요소 = 서랍이 늘 가짐 · 닫을 때 안 옮김(M01 · M02 바로가기 · 기본 목록) */
export function setDrawerSteps(node: HTMLElement | null): void {
drawerSteps = node ? { node, home: node.parentElement } : null;
}
@@ -178,11 +178,20 @@ function buildDrawer(header: HTMLElement): HTMLElement[] {
const setOpen = (open: boolean): void => {
header.classList.toggle("is-drawer-open", open);
burger.setAttribute("aria-expanded", String(open));
if (open && !drawerSteps) void fillDefaultSteps();
if (!drawerSteps) return;
if (open) steps.append(drawerSteps.node);
if (open)
steps.replaceChildren(drawerSteps.node); // 앞서 맡은 기본 목록이 있으면 갈아 끼움
else if (drawerSteps.home && steps.contains(drawerSteps.node))
drawerSteps.home.append(drawerSteps.node);
};
// 진행 판 · 바로가기를 안 맡긴 쪽(772-1) = 기본 목록(🏠 대시보드 + 권한별 M01 · M02) · 늦게 온 쪽 목록이 이김
const fillDefaultSteps = async (): Promise<void> => {
const bar = await import("./m_settings_nav").then((m) => m.defaultSettingsBar());
if (!bar || drawerSteps || !steps.isConnected) return;
setDrawerSteps(bar);
if (header.classList.contains("is-drawer-open")) steps.replaceChildren(bar);
};
burger.addEventListener("click", () => setOpen(!header.classList.contains("is-drawer-open")));
scrim.addEventListener("click", () => setOpen(false));
header.addEventListener("keydown", (e) => e.key === "Escape" && setOpen(false)); // ☰ 에 포커스가 있어도 닫힘
@@ -485,7 +494,7 @@ html:is([data-burger="on"], .is-bm-route) .app-burger { display: inline-flex; }
.app-drawer__steps .ui-workflow-layout__steps.is-vertical .ui-workflow-layout__step { min-height: var(--spacing-40); margin-inline: 8px; width: auto; padding-left: 7px; }
.app-drawer :is(.ui-btn__label, .ui-workflow-layout__step-label) { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.app-drawer__steps .ui-workflow-layout__step--home { margin-bottom: 0; }
html[data-touch="on"] :is(.app-drawer__nav > *, .app-drawer__site > summary, .app-drawer__steps .ui-workflow-layout__step, .app-drawer__row > *) { min-height: 44px; }
html[data-touch="on"] :is(.app-drawer__nav > *, .app-drawer__site > summary, .app-drawer__steps .ui-workflow-layout__steps.is-vertical .ui-workflow-layout__step, .app-drawer__row > *) { min-height: 44px; }
.app-drawer__row {
display: flex;
flex-wrap: wrap;
+2
View File
@@ -186,6 +186,8 @@ export const STATE_REGISTRY = {
"b08-milestone": { bucket: "draft", scope: "project" },
/** D03 예정노선 노선분석 `{version: 2, network: 그은 선 · 갈래 · 조각}` — [저장] 때 plan/network 로(563-1). */
"d03-plan": { bucket: "draft", scope: "project" },
/** D03 예정노선 분석 지형 원천 `"sheet" | "lidar"`(624-4 · 지형 칩) — 화면 캐시만 · 서버로 안 감. */
"d03-terrain": { bucket: "draft", scope: "project" },
/** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */
"b09-estimation": { bucket: "draft", scope: "project" },
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
+66
View File
@@ -0,0 +1,66 @@
/* 공용 단가산출 계산서(714-2) — 장비 · 하위 로직 한 줄 = 누르면 펼침 */
[data-sheet-equip] {
cursor: pointer;
border-radius: var(--radius-sm);
}
[data-sheet-equip]:hover,
[data-sheet-equip]:focus-visible {
background: var(--color-hover);
}
[data-sheet-part] .ui-calc-row__label {
padding-left: var(--spacing-16);
}
p[data-sheet-part] {
margin: 0;
padding-left: var(--spacing-16);
}
/* 737-1 옵션 칸 격자 — 상자 폭 따라 1 → 2 → 3 → 4 → 6 열(300 · 450 · 600 · 900) · 칸마다 이름 위 · 고르기 아래 */
.calc-sheet__opts {
container-type: inline-size;
}
.calc-sheet__opts-grid {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-8);
}
@container (min-width: 300px) {
.calc-sheet__opts-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@container (min-width: 450px) {
.calc-sheet__opts-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@container (min-width: 600px) {
.calc-sheet__opts-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@container (min-width: 900px) {
.calc-sheet__opts-grid {
grid-template-columns: repeat(6, minmax(0, 1fr));
}
}
.calc-sheet__opts-grid > .ui-calc-row {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
gap: var(--spacing-4);
}
.calc-sheet__opts-grid > .ui-calc-row > :is(.ui-calc-row__select, .ui-calc-row__in, .ui-field) {
grid-column: 1;
justify-self: stretch;
width: 100%;
}
+302
View File
@@ -0,0 +1,302 @@
/* =============================================================================
* calc_sheet.ts
* 공용 「단가산출 계산서」(PLAN 714-2 · 요구서 내역12 4장 5 · 6) — 704-2 M02 계산서 보기를 올린 것.
* 쓰는 곳 = M01 「인력·재료·경비」 보기 · M02 기준 목록 계산서 보기(704-2) · (뒤에) 일위대가 모달 · B09 단가산출근거
* 꼴 둘 —
* 비목(기본 · 704-2) = 비목 묶음마다 식 줄(이름 | 단가 × 품 | 금액) · 소계 · 맨 아래 계
* 묶음(`bundle`) = 인력 · 재료 · 경비(장비) · 하위 — 줄의 `묶음`(714-1 서버 덧칸) 으로 다시 묶음 ·
* 장비 · 하위 로직 = 한 줄(노 · 재 · 경 칸 · 합) + 누르면 펼침(그 줄의 노 · 재 · 경 식) ·
* 묶음 머리 = 이름 · 기준단위 · 환산 근거 · 그 묶음이 쓰는 입력 칸(`쓰는입력`) · 맨 아래 비목 소계 셋 + 계
* 값 = 서버 셈 그대로(묶음 소계만 서버 줄 금액의 합) · 변수 칸 바꾸면 `onInput(이름, 값)` — 부르는 쪽이 다시 셈
* 부품 = `ui_template_calc_row`(값 줄 · 식 줄) · 입력 · 고르기 · 글 — 날 단추 · 표 · 페이지 CSS 없음
* ========================================================================== */
import { createCalcFormula, createCalcFormulaRow, createCalcRow } from "@ui/ui_template_calc_row";
import { el } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { formatLineMoney, formatMoney } from "../common_util/common_util_money";
import "./calc_sheet.css";
export interface SheetLine {
이름: string;
/** 서버 값 식 「단가 * 품 = 금액」 — 「= 금액」 은 떼고 「*」 → 「×」 */
글: string;
금액: number | null;
/** 714-1 서버 덧칸 — 그 줄 값이 쓰는 로직 입력 · 묶음 · 장비 · 찾는 표 기준 · 환산 근거 */
쓰는입력?: string[];
묶음?: string | null;
장비?: boolean | null;
기준단위?: string | null;
환산근거?: string;
/** (753-1) 옵션으로 빠진 줄 — 그리지 않음(소계 · 쓰는 입력 칸은 그대로) */
옵션꺼짐?: boolean;
/** 714-1b 장비 줄 펼침 — 부른 기계경비 로직의 시간당 줄(조종원 · 주연료 · 잡재료비 · 기계손료) */
펼침?: SheetUnfold | null;
}
export interface SheetUnfold {
키: string;
이름: string;
단위: string;
줄: { 비목: string; 이름: string; 글: string; 금액: number | null }[];
계: number | null;
}
export interface SheetGroup {
비목: string;
소계: number | null;
끝수?: string;
줄: SheetLine[];
}
export interface SheetInput {
이름: string;
단위?: string;
고르기?: (string | number)[];
}
export interface SheetOptions {
묶음: SheetGroup[];
계: number | null;
/** 맨 아래 계 줄 이름(「계 (원/㎡)」) */
sumLabel: string;
/** 인력 · 재료 · 경비(장비) 묶음으로 다시 묶음(없으면 서버 비목 묶음 그대로 · 704-2) */
bundle?: boolean;
/** 변수 칸 — 입력 꼴 · 지금 값 · 바꾼 뒤 할 일(없으면 변수 칸 없음) · 칸 = 묶음 줄들의 `쓰는입력` */
inputs?: SheetInput[];
values?: Record<string, string>;
onInput?: (name: string, value: string) => void;
}
/* ── 순수 셈 ── */
/** 값 식 → 식 칸 글 — 「268,908 * 0.12 = 32,269」 → 「268,908 × 0.12」 */
export const sheetFormula = (text: string): string =>
text.replace(/ = [^=]*$/, "").replaceAll(" * ", " × ");
/** 장비 · 하위 로직 한 줄 — 비목마다 몫 줄(서버 줄 그대로) · 합 */
export interface SheetMerged {
이름: string;
몫: { 비목: string; 줄: SheetLine }[];
금액: number | null;
}
export interface SheetBundle {
묶음: string;
줄: (SheetLine | SheetMerged)[];
소계: number;
쓰는입력: string[];
기준단위: string[];
환산근거: string[];
}
const ORDER = ["인력", "재료", "경비", "하위"];
const OF_COST: Record<string, string> = { 노무비: "인력", 재료비: "재료", 경비: "경비" };
const add = (a: number | null, b: number | null): number | null =>
a === null && b === null ? null : (a ?? 0) + (b ?? 0);
const merges = (l: SheetLine): boolean => !!l.장비 || l.묶음 === "하위";
/** 비목 묶음 → 인력 · 재료 · 경비 · 하위 묶음 — 장비 · 하위 줄은 비목마다 갈린 몫을 한 줄로
* (같은 이름 k 번째끼리 짝 — 서버 줄 차례 = 호표 차례 · 비목마다 같은 차례) */
export function bundleLines(groups: SheetGroup[]): SheetBundle[] {
const bundles = new Map<string, SheetBundle>();
const merged = new Map<string, SheetMerged>();
const take = (name: string): SheetBundle => {
let b = bundles.get(name);
if (!b) {
b = { 묶음: name, 줄: [], 소계: 0, 쓰는입력: [], 기준단위: [], 환산근거: [] };
bundles.set(name, b);
}
return b;
};
for (const g of groups) {
const seen = new Map<string, number>();
for (const line of g.줄) {
const b = take(line.묶음 || OF_COST[g.비목] || g.비목);
b.소계 += line.금액 ?? 0;
for (const n of line.쓰는입력 ?? []) if (!b.쓰는입력.includes(n)) b.쓰는입력.push(n);
if (line.기준단위 && !b.기준단위.includes(line.기준단위)) b.기준단위.push(line.기준단위);
if (line.환산근거 && !b.환산근거.includes(line.환산근거)) b.환산근거.push(line.환산근거);
if (!merges(line)) {
b.줄.push(line);
continue;
}
const k = seen.get(line.이름) ?? 0;
seen.set(line.이름, k + 1);
const id = `${b.묶음}\n${line.이름}\n${k}`;
let m = merged.get(id);
if (!m) {
m = { 이름: line.이름, 몫: [], 금액: null };
merged.set(id, m);
b.줄.push(m);
}
m.몫.push({ 비목: g.비목, 줄: line });
m.금액 = add(m.금액, line.금액);
}
}
const rank = (x: string): number => (ORDER.includes(x) ? ORDER.indexOf(x) : ORDER.length);
return [...bundles.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음));
}
/* ── 순수 셈 끝 ── */
const TEXT = {
인력: ["인력", "Labor"],
재료: ["재료", "Material"],
경비: ["경비(장비)", "Expense (equipment)"],
하위: ["하위 로직", "Sub logic"],
Basis: ["기준", "Basis"],
Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"],
Fold: ["누르면 펼침", "Click to expand"],
Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"],
} as const satisfies Record<string, readonly [string, string]>;
const t = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
const bundleName = (b: string): string => (b in TEXT ? t(b as keyof typeof TEXT) : b);
const won = (v: number | null, digits = 0): string =>
v === null ? "-" : digits ? formatLineMoney(v) : formatMoney(v);
const out = (v: number | null, key = false, digits = 0): HTMLElement =>
el("span", { className: `ui-calc-row__out${key ? " is-key" : ""}`, text: won(v, digits) });
const lineRow = (line: SheetLine, label = line.이름): HTMLElement =>
createCalcFormulaRow(label, createCalcFormula(sheetFormula(line.글)), out(line.금액, false, 1));
/** 변수 칸 하나 — 고르기 있으면 고르기(값 없으면 빈 첫 칸) · 아니면 글 칸(Enter · 떠남 때 넘김) ·
* 722-1 공용 단가산출 상세 박스 옵션도 씀 */
export function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement {
let control: HTMLElement;
if (spec.고르기?.length) {
control = createSelectField({
options: [...(value ? [] : [""]), ...spec.고르기.map(String)].map((o) => ({
value: o,
text: o,
})),
value,
compact: true,
ariaLabel: spec.이름,
onChange: onInput,
}).root;
} else {
const box = createInputField({ value, compact: true });
box.input.setAttribute("aria-label", spec.이름);
box.input.addEventListener("change", () => {
if (box.input.value.trim() && box.input.value !== value) onInput(box.input.value.trim());
});
control = box.root;
}
const row = createCalcRow(spec.단위 ? `${spec.이름} (${spec.단위})` : spec.이름, control);
row.dataset.sheetVar = spec.이름;
return row;
}
/** 옵션 칸들 한 덩이(737-1) — 상자 폭 따라 6 → 4 → 3 → 2 → 1 열 격자(`calc_sheet.css`) · 없으면 빈 목록 */
export const optionGrid = (rows: HTMLElement[]): HTMLElement[] =>
rows.length
? [
el("div", {
className: "calc-sheet__opts",
children: [el("div", { className: "calc-sheet__opts-grid", children: rows })],
}),
]
: [];
/** 장비 · 하위 한 줄 — 노 · 재 · 경 칸 + 합 · 누르면(Enter · Space) 몫 식 줄 펼침 */
function mergedRows(m: SheetMerged): HTMLElement[] {
const share = (cost: string): number | null =>
m.몫.filter((x) => x.비목 === cost).reduce<number | null>((s, x) => add(s, x.줄.금액), null);
const short = t("Short")
.replace("{a}", won(share("노무비"), 1))
.replace("{b}", won(share("재료비"), 1))
.replace("{c}", won(share("경비"), 1));
const head = createCalcFormulaRow(`▸ ${m.이름}`, createCalcFormula(short), out(m.금액, false, 1));
// 펼침 = 부른 기계경비 로직 시간당 줄(손료 · 주연료 · 잡품 · 운전사 · 714-1b) → 이 줄 노 · 재 · 경 몫 식
const unfold = m.몫.find((x) => x.줄.펼침)?.줄.펼침;
const parts = [
...(unfold
? [
createText(
t("Per")
.replace("{n}", unfold.이름)
.replace("{v}", won(unfold.계, 1))
.replace("{u}", unfold.단위 || "-"),
"hint",
),
...unfold.줄.map((x) => lineRow(x, `${x.이름} (${x.비목})`)),
]
: []),
...m.몫.map((x) => lineRow(x.줄, `${x.비목}`)),
];
for (const p of parts) {
p.hidden = true;
p.dataset.sheetPart = m.이름;
}
head.dataset.sheetEquip = m.이름;
head.title = t("Fold");
head.tabIndex = 0;
head.setAttribute("role", "button");
head.setAttribute("aria-expanded", "false");
const label = head.firstElementChild as HTMLElement;
const toggle = (): void => {
const open = head.getAttribute("aria-expanded") !== "true";
head.setAttribute("aria-expanded", String(open));
label.textContent = `${open ? "▾" : "▸"} ${m.이름}`;
parts.forEach((p) => (p.hidden = !open));
};
head.addEventListener("click", toggle);
head.addEventListener("keydown", (ev) => {
if (ev.key !== "Enter" && ev.key !== " ") return;
ev.preventDefault();
toggle();
});
return [head, ...parts];
}
/** 묶음 머리 — 이름 · 기준 · 환산 근거 · 쓰는 입력 칸 */
function headRows(
name: string,
uses: string[],
o: SheetOptions,
specs: Map<string, SheetInput>,
basis: string[] = [],
notes: string[] = [],
): HTMLElement[] {
const vars = o.onInput ? uses.filter((n) => specs.has(n)) : [];
if (!vars.length && !o.bundle) return [];
const title = [name, basis.length ? `${t("Basis")} ${basis.join(" · ")}` : ""].filter(Boolean);
const head = createText(title.join(" — "), "muted", 14);
head.dataset.sheetHead = name;
const rows: HTMLElement[] = [head];
if (notes.length) rows.push(createText(notes.join(" · "), "hint"));
const onInput = o.onInput;
if (onInput)
for (const n of vars)
rows.push(varRow(specs.get(n)!, o.values?.[n] ?? "", (v) => onInput(n, v)));
return rows;
}
const subtotal = (g: SheetGroup): HTMLElement =>
createCalcFormulaRow(`${g.비목}${g.끝수 ? ` (${g.끝수})` : ""}`, null, out(g.소계, true));
/** 옵션으로 빠진 줄(753-1)은 안 그림 — 장비 · 하위 몫 줄은 몫이 다 빠졌을 때만 */
const shown = (l: SheetLine | SheetMerged): boolean =>
"몫" in l ? l.몫.some((x) => !x.줄.옵션꺼짐) : !l.옵션꺼짐;
/** 계산서 줄들 — 부르는 쪽 세로 칸(flex column)에 그대로 붙임 */
export function calcSheetRows(o: SheetOptions): HTMLElement[] {
const rows: HTMLElement[] = [];
const specs = new Map((o.inputs ?? []).map((s) => [s.이름, s]));
if (o.bundle) {
for (const b of bundleLines(o.묶음)) {
const name = bundleName(b.묶음);
rows.push(...headRows(name, b.쓰는입력, o, specs, b.기준단위, b.환산근거));
for (const line of b.줄.filter(shown))
rows.push(...("몫" in line ? mergedRows(line) : [lineRow(line)]));
rows.push(createCalcFormulaRow(name, null, out(b.소계, true, 1)));
}
rows.push(...o.묶음.map(subtotal)); // 맨 아래 비목 소계(서버 끝수 그대로) — 장비 노무는 여기서 인력 노무와 더해짐
} else {
for (const g of o.묶음) {
const uses = [...new Set(g.줄.flatMap((l) => l.쓰는입력 ?? []))];
rows.push(...headRows(g.비목, uses, o, specs));
rows.push(...g.줄.filter(shown).map((line) => lineRow(line)), subtotal(g));
}
}
rows.push(createCalcFormulaRow(o.sumLabel, null, out(o.계, true)));
return rows;
}
+18
View File
@@ -52,6 +52,22 @@ import { designSignature, markDesignClean } from "./design_leave";
import { routeGradeTouch } from "./design_pipeline_page";
import type { GradeTouch } from "../../B05_Profile/logic/B05_Profile_Pipeline_Grade";
/** [저장] · [확정] 막음(755-3) — 글을 돌려주면 알림하고 안 냄 · `root` 가 화면에서 빠지면 풂. */
const saveHolds: Array<{ root: HTMLElement; hold: () => string | null }> = [];
export function holdDesignSave(root: HTMLElement, hold: () => string | null): void {
saveHolds.push({ root, hold });
}
function heldSave(): string | null {
for (let index = saveHolds.length - 1; index >= 0; index -= 1) {
if (!saveHolds[index].root.isConnected) saveHolds.splice(index, 1);
}
for (const { hold } of saveHolds) {
const why = hold();
if (why) return why;
}
return null;
}
export interface DesignSaveContext {
projectId: string;
routeId: () => number | null;
@@ -249,6 +265,8 @@ export async function saveDesign(
const confirm = options.confirm ?? false;
const routeId = ctx.routeId();
if (routeId === null) return false;
const held = heldSave();
if (held) return (showToast(held, "warning"), false);
saving.add(ctx.projectId);
let locked = confirm;
if (locked) showLoadingOverlay();
-1
View File
@@ -546,7 +546,6 @@ export async function mountProfileView(
contourIntervalM: latestResponse.surface_params.contour_interval_m,
smooth: latestResponse.surface_params.smooth,
tools: [view3d.modal.button],
recommend: panel.recommend,
// 계산 묶음(346-1) — 페이지가 세운 다리(365-1) · 노선을 고칠 때마다 종단이 따라옴(미리보기 · [저장]이 정본).
draft: routeDraftBridge(activeProjectId, profilePanel),
});
@@ -74,10 +74,7 @@ export function createViewer3dModal(deps: Viewer3dModalDeps) {
}
// 평면 도구줄 아이콘 단추(568-2) — 클래스는 가이드 촬영이 잡음.
const button = mapToolButton("view3d", {
title: "3D 화면을 큰 창으로 엽니다 — 닫으면 그리기를 멈춥니다",
onClick: open,
});
const button = mapToolButton("view3d", { onClick: open }); // 마우스 글 = 공용 표(634-2)
button.classList.add("b05-route__3d-open");
return {
@@ -76,6 +76,8 @@ export interface RouteDraft {
stationSnapStep?: number | null;
/** 분석 지형(`las` · `sheet` · null = 확정 지표면) — ground 단계가 고를 격자. */
terrain: string | null;
/** 고친 예정노선(626-3 · B05 평면 그리기) — 없음 · null = 그대로 · [] = 지움 · 점 묶음 = [저장] 때 `expected_route_edit.csv`. */
expected?: Vertex[] | null;
}
/** 묶음이 받는 입력 다섯. 없으면 null(= 저장분 그대로). cross · pipes · structures 모양은 주인 몫. */
+89
View File
@@ -0,0 +1,89 @@
/* =============================================================================
* formula_doc_copy.ts
* 수식 문서 사본 몫(PLAN 802-4b-2 · 요구 655-2 · 656 · 659-3) — M02 단가산출(SK) 수식 탭 머리
* 「M01 새 판 있음」 배지(값 줄 · 부름 문서가 지금 M01 과 다름) · [단가 갱신](자원 단가만 · 식 그대로) ·
* [재생성](지금 M01 문서로 다시 찍음 · 버리는 것 알림) — 누를 때만(자동 갱신 없음)
* 흐름 = 미리 셈(`{길}/preview` {할일}) → 확인 창(계 전 → 후 · 바뀐 자원 · 버리는 것) → 저장(`{길}/save` {할일, 판})
* 층 쓰기 권한 없으면 「읽기만」 배지
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { showConfirmDialog } from "@ui/ui_template_elements";
import { ft } from "./formula_doc_text";
export type CopyJob = "단가갱신" | "재생성";
/** 사본 표지(서버 `M02_Doc_Copy.state` + 시점 · 따로) */
export interface CopyMark {
M01새판?: boolean;
지운키?: string[];
단가새값?: boolean;
문서새판?: string[];
따로?: boolean;
}
export interface CopyOptions {
/** SK 키 · 꼬리(`""` · `/preview` · `/table?key=…`) → 길(`/m02/layers/{층}/sangeun/{키}/doc…` · 주인 물음) */
route: (key: string, path: string) => string;
/** 저장 뒤 그 SK 새 판(페이지 [저장] 409 막음) */
onSaved?: (version: string) => void;
}
type Money = Record<string, number | null> | null;
export interface JobReply {
전?: Money;
후?: Money;
바뀐자원?: unknown[];
버림?: { 문서고침?: boolean; 끊음?: string[] };
}
/** 「M01 새 판 있음」 — 값 줄 · 단가 · 부름 문서 어느 하나라도 지금 M01 과 다름 */
export function isStale(m: CopyMark | undefined): boolean {
return !!m && (!!m.M01새판 || !!m.단가새값 || !!m.지운키?.length || !!m.문서새판?.length);
}
export function copyBadges(m: CopyMark | undefined, edit: boolean): HTMLElement[] {
const out: HTMLElement[] = [];
if (isStale(m)) {
const b = createBadge(ft("New_M01"), "danger");
b.dataset.copyStale = "";
out.push(b);
}
if (!edit) out.push(createBadge(ft("Read_Only"), "neutral"));
return out;
}
export function copyTools(edit: boolean, run: (job: CopyJob) => void): HTMLElement[] {
if (!edit) return [];
return (["단가갱신", "재생성"] as const).map((job) => {
const b = createButton({
label: job === "단가갱신" ? ft("Price") : ft("Regen"),
variant: "ghost",
size: 28,
onClick: () => run(job),
});
b.dataset.copyJob = job;
return b;
});
}
const sum = (m: Money | undefined): string =>
typeof m?.계 === "number" ? `${eqText(m.계)} 원` : "—";
/** 할일 알림 — 계 전 → 후 · 바뀐 자원 수 · 재생성이 버리는 것(이름) → 확인이면 true */
export function askJob(job: CopyJob, got: JobReply, names: Map<string, string>): Promise<boolean> {
const bits = [
job === "단가갱신" ? ft("Price_Ask") : ft("Regen_Ask"),
`${ft("Before_After")} ${sum(got.전)} → ${sum(got.후)}`,
];
if (job === "단가갱신") bits.push(`${ft("Changed")} ${got.바뀐자원?.length ?? 0}`);
const lost = [
...(got.버림?.문서고침 ? [ft("Regen_Edit")] : []),
...(got.버림?.끊음 ?? []).map((k) => `${ft("Cut")} ${names.get(k) || k}`),
];
if (job === "재생성" && lost.length) bits.push(`${ft("Regen_Lost")} — ${lost.join(" · ")}`);
return showConfirmDialog(bits.join(" · "), job === "단가갱신" ? ft("Price") : ft("Regen"));
}
+20
View File
@@ -0,0 +1,20 @@
/* =============================================================================
* formula_doc_foot.ts
* 수식 탭 맨 아래 합계 상자(PLAN 803-2a · 요구 667-2) — 단가산출 상세 탭과 같은 부품(`buildFoot` · 노무비 · 재료비 · 경비 · 계)
* 값 = 계산기 셈 그대로(여기서 다시 셈 안 함) · 셈 못 함 = 「—」
* ========================================================================== */
import type { DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { buildFoot } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Foot";
import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css";
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
export function docFoot(a: DocAnswer): HTMLElement {
const done = !!a.셈됨 && typeof a.계 === "number";
return buildFoot(
{ labor: num(a.노무비), material: num(a.재료비), cost: num(a.경비) },
done,
num(a.계),
);
}
+55
View File
@@ -0,0 +1,55 @@
/* =============================================================================
* formula_doc_options.ts
* 수식 문서 옵션 가로(PLAN 802-3 · 802-4b-2 · 요구 655 「옵션 선택은 상단에 수평」) — 선택 · 입력 기호마다 칸 하나
* 값 = 화면 값(`given`) › 문서 값 · 고침은 부른 쪽 `set`(M01 = 화면 값만 · M02 사본 = 문서 값) · `lock` = 읽기만
* ========================================================================== */
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type DocVar, type FormulaDoc } from "../common_util/common_util_formula_doc";
export function buildOptions(
doc: FormulaDoc | null,
given: Record<string, unknown>,
lock: boolean,
set: (key: string, v: DocVar, value: unknown) => void,
): HTMLElement[] {
const out: HTMLElement[] = [];
for (const v of doc?.변수 ?? []) {
const kind = v.연결?.갈래;
const k = v.열쇠;
if (!k || (kind !== "선택" && kind !== "입력")) continue;
const label = v.이름 || varId(v);
const now = k in given ? given[k] : v.연결?.값;
if (kind === "선택") {
const list = (v.연결?.목록 ?? []).map(String);
const f = createSelectField({
label,
options: list.map((x) => ({ value: x, text: x })),
value: String(now ?? ""),
compact: true,
size: 28,
disabled: lock,
onChange: (x) => set(k, v, x),
});
f.root.dataset.option = k;
out.push(f.root);
} else {
const f = createInputField({
label,
value: now === undefined || now === null ? "" : String(now),
compact: true,
size: 28,
unit: v.단위 || undefined,
});
f.input.disabled = lock;
f.input.addEventListener("change", () => {
const t = f.input.value.trim();
const n = Number(t.replace(/,/g, ""));
set(k, v, t !== "" && Number.isFinite(n) ? n : t);
});
f.root.dataset.option = k;
out.push(f.root);
}
}
return out;
}
+82
View File
@@ -0,0 +1,82 @@
/* =============================================================================
* formula_doc_render.ts
* 수식 문서 닿는 기호(PLAN 802-3 · 804-6) — 줄 쌓기 산출근거(`line_stack_out.ts`)가 씀
* reach = 지금 탄 갈래로 닿는 기호(만약 안 탄 갈래 · 숨김 자리 인자 빼고)
* 옛 수식 문서 탭의 조판 그림(renderNode · 값 식 · 수 바꿈)은 804-6 에 걷음
* ========================================================================== */
import {
linkNames,
varId,
type FormulaDoc,
type Node,
} from "../common_util/common_util_formula_doc";
import {
offLine,
takenOf,
tryParse,
type Values,
} from "../common_util/common_util_formula_doc_view";
/** 탄 갈래로 부르는 이름(만약 안 탄 갈래 · 숨김 자리 인자 빼고) */
function namesTaken(n: Node, vals: Values, out: string[] = []): string[] {
const add = (x: string): void => {
if (!out.includes(x)) out.push(x);
};
switch (n[0]) {
case "var":
add(n[1]);
break;
case "bin":
namesTaken(n[2], vals, out);
namesTaken(n[3], vals, out);
break;
case "neg":
case "pct":
case "field":
namesTaken(n[1], vals, out);
break;
case "call": {
const pick = takenOf(n, vals);
const items = pick ? [n[2][0], n[2][pick]] : n[2];
items.forEach((x) => namesTaken(x, vals, out));
break;
}
}
return out;
}
const RESERVED_MONEY = new Set(["노무비", "재료비", "경비", "번지", "단계"]);
/** 정의 영역에 보일 기호 — 식 줄 · 금액 줄 · 결과에서 지금 탄 갈래로 닿는 것(보이는 차례) */
export function reach(doc: FormulaDoc, vals: Values, showOff = false): string[] {
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
const money = new Set((doc.금액 ?? []).map((l) => String(l.기호 ?? "")).filter(Boolean));
const out: string[] = [];
const visit = (name: string): void => {
if (!name || out.includes(name) || RESERVED_MONEY.has(name) || money.has(name)) return;
out.push(name);
if (lines.has(name)) {
const n = tryParse(lines.get(name));
if (n) namesTaken(n, vals).forEach(visit);
}
const v = vars.get(name);
if (v?.연결 && v.연결.갈래 !== "식") linkNames(v.연결).forEach(visit);
};
const from = (text: unknown): void => {
const n = tryParse(text);
if (n) namesTaken(n, vals).forEach(visit);
};
for (const l of doc.식 ?? []) {
visit(String(l.기호 ?? ""));
from(l.식);
}
for (const l of doc.금액 ?? []) {
if (!showOff && offLine(l, vals, lines)) continue;
from(l.수량);
from(l.단가);
}
if (doc.결과) visit(String(doc.결과));
return out;
}
+96
View File
@@ -0,0 +1,96 @@
/* =============================================================================
* formula_doc_text.ts
* 수식 문서 화면 글(PLAN 802-3 · 803-2a) — [국문, 영문]
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Sym_Head: ["기호 식", "Symbol equation"],
Val_Head: ["값 식", "Value equation"],
Defs_Head: ["기호 정의", "Symbols"],
New_Group: ["새 묶음", "New group"],
Unnamed: ["이름 없음", "Unnamed"],
Subtotal: ["소계", "Subtotal"],
Sum: ["계", "Sum"],
Total: ["합계", "Total"],
Need: ["정의 필요", "Needs definition"],
Unused: ["쓰지 않는 기호", "Unused symbols"],
Show_Off: ["꺼진 줄 보기", "Show off lines"],
Hide_Off: ["꺼진 줄 숨김", "Hide off lines"],
Save: ["수식 저장", "Save equation"],
Discard: ["고침 버림", "Discard"],
Saved: ["수식 저장됨", "Equation saved"],
Save_Failed: ["저장 못 함", "Save failed"],
Load_Failed: ["수식 문서를 못 읽음", "Failed to load"],
Leave: ["저장 안 한 수식 고침이 있음 — 버리고 나갈까요?", "Discard unsaved edits?"],
Leave_Ok: ["버리고 나감", "Discard"],
Result: ["결과", "Result"],
Result_Sym: ["결과 기호", "Result symbol"],
Dirty: ["저장 안 한 고침", "Unsaved edits"],
Sign: ["기호", "Symbol"],
Low: ["아래첨자", "Subscript"],
Up: ["위첨자", "Superscript"],
Name: ["국문 이름", "Name"],
Unit: ["단위", "Unit"],
Link: ["연결", "Link"],
Drop_Var: ["정의 지움", "Delete definition"],
Value: ["값", "Value"],
List: ["목록(· 로 나눔)", "Options (· separated)"],
Default: ["기본", "Default"],
Low_Bound: ["아래 끝", "Min"],
High_Bound: ["위 끝", "Max"],
Find: ["찾기", "Search"],
Column: ["칸", "Column"],
Cond: ["조건", "Condition"],
Inputs: ["넘길 입력", "Inputs"],
Region: ["지역 기호", "Region symbol"],
Make_Line: ["식 줄 만들기", "Add line"],
By_Line: ["식 줄로 정의", "Defined by a line"],
Pick_First: ["찾아 고름", "Search and pick"],
Material: ["재료 고르기", "Material pick"],
None: ["없음", "None"],
Open_Sub: ["식 펼침", "Show equation"],
Bad_Sign: ["기호 꼴 아님 · 같은 기호 있음", "Bad or duplicate symbol"],
More: ["더 있음 — 찾는 말을 더 넣으세요", "More — refine search"],
Nothing: ["찾은 것 없음", "Nothing found"],
Linked: ["연결", "Linked"],
Cut: ["끊음", "Unlinked"],
Unlink: ["연결 끊기", "Unlink"],
Relink: ["다시 잇기", "Relink"],
Ask_Relink: [
"이 기초에서 고친 것을 버리고 부르던 문서로 돌아감",
"Discard edits to this basis and follow the copy again",
],
New_M01: ["M01 새 판 있음", "New M01 version"],
Price: ["단가 갱신", "Refresh prices"],
Regen: ["재생성", "Regenerate"],
Price_Ask: ["자원 단가만 지금 M01 값으로 — 식 · 연결 그대로", "Prices only — equation unchanged"],
Regen_Ask: ["지금 M01 수식 문서로 다시 찍음", "Re-copy from the current M01 equation"],
Regen_Lost: ["버리는 것", "Discarded"],
Regen_Edit: ["수식 고침", "Equation edits"],
Changed: ["바뀐 자원", "Changed resources"],
Before_After: ["계", "Sum"],
Job_Done: ["적용함", "Applied"],
Dirty_First: [
"저장 안 한 고침이 있음 — [저장] 또는 [되돌림] 먼저",
"Save or discard edits first",
],
No_Key: [
"저장한 단가산출만 수식 문서를 엶 — [단가산출 상세] 탭",
"Saved items only — use the detail tab",
],
Read_Only: ["읽기만", "Read only"],
Tools: ["수식 도구", "Equation tools"],
Tool_Frac: ["분수", "Fraction"],
Tool_Sub: ["아래첨자", "Subscript"],
Tool_Sup: ["위첨자", "Superscript"],
Tool_Paren: ["( )", "( )"],
Tool_If: ["만약", "If"],
Tool_Round: ["반올림", "Round"],
Tool_Group: ["묶음", "Group"],
} as const;
export type TextKey = keyof typeof TEXT;
export const ft = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
+2
View File
@@ -138,6 +138,8 @@ export const KEY_ACTIONS: readonly KeyAction[] = [
A("plan.redo", "plan", ["Ctrl+Shift+Z", "Ctrl+Y"], ["다시", "Redo"]),
A("plan.drawFinish", "plan", ["Enter"], ["그리기 끝", "Finish drawing"]),
A("plan.drawBack", "plan", ["Backspace"], ["그리기 마지막 점 빼기", "Drawing: remove last point"]),
A("plan.drawKind", "plan", ["Tab"], ["긋는 중 방식 자유지정 ↔ 길이·기울기 고정", "Drawing: free ↔ length + slope"]),
A("plan.drawBetween", "plan", ["Shift 누른 채"], ["길이·기울기 고정 긋는 중 부채꼴 사이 기울기 자리(길이 그대로)", "Drawing: pick between-slope on fan (length kept)"], F),
A("plan.delete", "plan", ["Delete"], ["고른 노드 · 선 · 구역 · 전체 · 마커 지움", "Delete picked node · segment · region · all · marker"]),
A("plan.escape", "plan", ["Escape"], ["그리기 취소 · 고름 풀기 · 재기 끝", "Cancel drawing · clear selection · end measure"], F),
A("plan.zoom", "plan", ["휠"], ["커서 자리 확대 · 축소", "Zoom at cursor"], F),
+397
View File
@@ -0,0 +1,397 @@
/* 줄 쌓기 탭(804-2) — 위 옵션 · 가운데 왼쪽 편집 | 오른쪽 산출근거 · 맨 아래 합계 상자 · 색은 공통 값만 */
.lstk {
display: flex;
flex-direction: column;
gap: var(--spacing-12);
min-width: 0;
container-type: inline-size;
}
.lstk__head {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.lstk__bar,
.lstk__badges,
.lstk__tools {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8);
}
.lstk__areas {
display: grid;
grid-template-columns: minmax(0, 5fr) minmax(0, 6fr);
gap: var(--spacing-12);
align-items: start;
}
.lstk:not([data-edit="1"]) .lstk__areas {
grid-template-columns: minmax(0, 1fr);
}
.lstk:not([data-edit="1"]) .lstk__left {
display: none;
}
/* 합계 상자(요구 687-3) — 스크롤해도 화면 아래 고정 */
.lstk__foot {
position: sticky;
inset-block-end: 0;
z-index: 2;
padding-block: var(--spacing-8);
background: var(--color-surface);
border-block-start: 1px solid var(--color-border);
}
.lstk__foot:empty {
display: none;
}
/* 산출근거(요구 683) — 왼쪽을 내려도 화면에 고정 · 길면 자기 스크롤(높이 = 스크롤 영역 − 합계 상자 · fit()) */
.lstk[data-edit="1"] .lstk__right {
position: sticky;
inset-block-start: 0;
max-height: var(--lstk-view, 100vh);
overflow: auto;
}
/* 탭 폭이 좁으면(1366 화면 등) 편집 위 · 산출근거 아래(고정 없음) */
@container (max-width: 1040px) {
.lstk__areas {
grid-template-columns: minmax(0, 1fr);
}
.lstk[data-edit="1"] .lstk__right {
position: static;
max-height: none;
overflow: visible;
}
}
/* ── 왼쪽 편집 ── */
.lstk-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-16);
}
/* 항목(804-5) — 머리 띠(바탕 · 왼쪽 굵은 띠 · 번호) · 줄 = 들여쓰기 + 왼쪽 세로 선 · 가나다 = 한 단 더 들여씀 */
.lstk-item {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.lstk-item.is-sub {
margin-inline-start: var(--spacing-24);
}
.lstk-item + .lstk-item.is-sub {
margin-block-start: calc(-1 * var(--spacing-8));
}
/* 그룹 머리 한 줄(요구 687-6 · 688) — 이름 칸이 늘고 줄어 [+] · X 는 늘 오른쪽 끝(접어도 그대로) */
.lstk-ihead {
flex-wrap: nowrap;
align-items: center;
padding: var(--spacing-4) var(--spacing-8);
border-inline-start: 4px solid var(--color-accent);
border-radius: var(--radius-sm);
background: var(--color-surface-sunken);
}
.lstk-item.is-sub > .lstk-ihead {
border-inline-start-color: var(--color-border-strong);
}
.lstk-ihead > .lstk-f-l {
flex: 1 1 auto;
min-width: 4em;
}
.lstk-count {
flex: none;
white-space: nowrap;
}
.lstk-no {
flex: none;
min-width: 1.5em;
font-weight: var(--font-weight-bold);
color: var(--color-text);
}
.lstk-lines {
display: flex;
flex-direction: column;
gap: var(--spacing-4);
margin-inline-start: var(--spacing-12);
padding-inline-start: var(--spacing-12);
border-inline-start: 2px solid var(--color-border-strong);
}
.lstk-ihead,
.lstk-fhead,
.lstk-frow,
.lstk-find {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4) var(--spacing-8);
min-width: 0;
}
.lstk-frow {
align-items: center;
}
/* 줄 카드 — 금액 · 글 = 한 줄(요구 687-7 · [손잡이][종류][이름][수량][단위][비목][X] · 좁으면 접힘) · 공식 = 머리 줄 + 식 줄 */
.lstk-line {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--spacing-4) var(--spacing-8);
padding: var(--spacing-4) var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-surface-raised);
}
.lstk-line.k-공식 {
flex-direction: column;
flex-wrap: nowrap;
align-items: stretch;
position: relative;
padding-inline-start: var(--spacing-24);
}
.lstk-line > .lstk-x,
.lstk-fhead > .lstk-x {
margin-inline-start: auto;
}
.lstk-line > .lstk-f-l,
.lstk-line > .lstk-qty {
flex: 1 1 8em;
min-width: 6em;
}
.lstk-line > .lstk-f-m {
flex: 1 1 7em;
min-width: 6em;
width: auto;
}
.lstk-line .lstk-kind {
flex: none;
min-width: 2.5em;
}
.lstk-wide {
flex-basis: 100%;
}
.lstk-qty.ui-btn {
justify-content: flex-start;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lstk-upick {
flex: none;
}
/* 보이는 글을 눌러 고침(요구 687-14) */
.lstk-tap {
cursor: text;
border-radius: var(--radius-sm);
}
.lstk-tap:hover,
.lstk-tap:focus-visible {
background: var(--color-surface-sunken);
text-decoration: underline dotted;
}
/* 끌기(804-5) — 손잡이 · 끄는 것 반투명 · 놓을 자리 = 위 · 아래 선 · 빈 항목 = 점선 */
.lstk-grip {
touch-action: none;
}
.lstk-line > .lstk-grip {
flex: none;
}
.lstk-line.k-공식 > .lstk-grip {
position: absolute;
inset-block-start: var(--spacing-8);
inset-inline-start: var(--spacing-4);
}
.lstk-edit[data-dragging] {
cursor: grabbing;
user-select: none;
}
.is-dragging {
opacity: 0.45;
}
.lstk-line[data-drop="before"],
.lstk-item[data-drop="before"] {
box-shadow: 0 -3px 0 0 var(--color-accent);
}
.lstk-line[data-drop="after"],
.lstk-item[data-drop="after"] {
box-shadow: 0 3px 0 0 var(--color-accent);
}
.lstk-item[data-drop="in"] .lstk-empty {
outline: 2px dashed var(--color-accent);
outline-offset: 2px;
}
.lstk-res {
flex: 0 1 auto;
min-width: 0;
max-width: 100%;
}
.lstk-kind {
font-size: var(--text-caption);
font-weight: var(--font-weight-medium);
color: var(--color-text-secondary);
}
.lstk-fhead[data-tpl="changed"] .lstk-kind {
color: var(--color-accent);
}
.lstk-f-s {
width: 6em;
}
.lstk-f-m {
width: 9em;
}
.lstk-f-l {
flex: 1 1 12em;
min-width: 10em;
}
.lstk-sym {
font-weight: var(--font-weight-medium);
}
.lstk-val,
.lstk-unit,
.lstk-tag,
.lstk-hint {
font-size: var(--text-body-sm);
color: var(--color-text-secondary);
}
.lstk-tag {
padding: 0 var(--spacing-4);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
}
.lstk-empty {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
.lstk-miss,
.lstk-tex .lstk-miss {
color: var(--color-danger);
}
/* ── 오른쪽 산출근거 ── */
.lstk-out .lstk-h {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0 var(--spacing-4);
padding-inline-start: calc(var(--lstk-depth, 0) * var(--spacing-16));
white-space: normal;
}
.lstk-out tr.head,
.lstk-out tr.item,
.lstk-out tr.total {
font-weight: var(--font-weight-medium);
}
.lstk-out tr.subsum,
.lstk-out tr.total {
background: var(--color-surface-sunken);
}
.lstk-out tr.is-sub .lstk-h {
color: var(--color-text-secondary);
}
/* 펼친 문서 제목 = 보통 글(링크 꼴 없음 · 요구 687-11) */
.lstk-out tr.subhead .lstk-h {
font-weight: var(--font-weight-medium);
color: var(--color-text);
}
.lstk-ftitle {
cursor: pointer;
}
.lstk-gap {
display: inline-block;
width: var(--spacing-24);
}
.lstk-pack {
display: inline-flex;
align-items: center;
gap: var(--spacing-4);
}
.lstk-tex .katex {
font-size: 1.05em;
}
/* 짝 강조(679 · 684) — 왼쪽 카드 · 그룹 머리 · 오른쪽 줄 = 같은 바탕 채우기 */
.lstk-line.is-pair,
.lstk-item.is-pair > .lstk-ihead,
.lstk-out tr.is-pair > td {
background: var(--color-accent-soft);
}
.lstk-out tr[data-loc] {
cursor: pointer;
}
.lstk-tpick {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
/* 옵션 가로(옛 수식 문서 CSS 에서 옮김 · 804-6) */
.fdoc__options {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-12);
align-items: flex-end;
}
.fdoc__options > * {
width: auto;
min-width: 8em;
}
+80
View File
@@ -0,0 +1,80 @@
/* =============================================================================
* line_stack_drag.ts
* 줄 쌓기 왼쪽 끌어 옮기기(PLAN 804-5 · 요구 682-4) — 손잡이(⋮⋮)를 누르고 끌면 놓을 자리에 선 표시 · 놓으면 `drop`
* 줄 = 항목 안 · 항목 사이 어디든(빈 항목 = 그 안) → `k.p`(항목 k 의 p 자리) · 항목 = 항목 사이 → `p`
* 포인터 끌기(마우스 · 터치 같음) · Esc = 그만 · 자리 표시 = 클래스(`data-drop`)만 — 끄는 동안 배치 안 바뀜
* ========================================================================== */
export interface DragOpts {
/** 끄는 것 — 줄 카드 · 항목 상자 */
what: "line" | "item";
/** 끌기 영역(`.lstk-edit`) */
root: HTMLElement;
/** 끄는 요소(반투명) */
self: HTMLElement;
/** 놓음 — line `k.p` · item `p` */
drop: (to: string) => void;
}
const mid = (r: DOMRect): number => r.top + r.height / 2;
/** 포인터 y 의 놓을 자리 + 표시할 요소 · 꼴 */
function spotOf(o: DragOpts, y: number): { to: string; at: HTMLElement; side: string } | null {
const boxes = [...o.root.querySelectorAll<HTMLElement>(":scope > .lstk-item")];
if (!boxes.length) return null;
if (o.what === "item") {
const k = boxes.findIndex((b) => y < mid(b.getBoundingClientRect()));
return k < 0
? { to: String(boxes.length), at: boxes[boxes.length - 1], side: "after" }
: { to: String(k), at: boxes[k], side: "before" };
}
// 줄 — 포인터가 든 항목(위 · 아래 밖 = 처음 · 끝 항목)
let k = boxes.findIndex((b) => y < b.getBoundingClientRect().bottom);
if (k < 0) k = boxes.length - 1;
const cards = [...boxes[k].querySelectorAll<HTMLElement>(":scope .lstk-lines > .lstk-line")];
if (!cards.length) return { to: `${k}.0`, at: boxes[k], side: "in" };
const p = cards.findIndex((c) => y < mid(c.getBoundingClientRect()));
return p < 0
? { to: `${k}.${cards.length}`, at: cards[cards.length - 1], side: "after" }
: { to: `${k}.${p}`, at: cards[p], side: "before" };
}
/** 손잡이에 끌기를 붙임 */
export function attachDrag(grip: HTMLElement, o: DragOpts): void {
grip.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
grip.setPointerCapture(e.pointerId);
let spot: ReturnType<typeof spotOf> = null;
const clear = (): void => {
for (const x of o.root.querySelectorAll("[data-drop]")) x.removeAttribute("data-drop");
};
o.self.classList.add("is-dragging");
o.root.dataset.dragging = o.what;
const move = (ev: PointerEvent): void => {
clear();
spot = spotOf(o, ev.clientY);
if (spot) spot.at.dataset.drop = spot.side;
};
const end = (ok: boolean): void => {
grip.removeEventListener("pointermove", move);
grip.removeEventListener("pointerup", up);
grip.removeEventListener("pointercancel", cancel);
window.removeEventListener("keydown", esc);
clear();
o.self.classList.remove("is-dragging");
delete o.root.dataset.dragging;
if (ok && spot) o.drop(spot.to);
};
const up = (): void => end(true);
const cancel = (): void => end(false);
const esc = (ev: KeyboardEvent): void => {
if (ev.key === "Escape") end(false);
};
grip.addEventListener("pointermove", move);
grip.addEventListener("pointerup", up);
grip.addEventListener("pointercancel", cancel);
window.addEventListener("keydown", esc);
});
}
+464
View File
@@ -0,0 +1,464 @@
/* =============================================================================
* line_stack_edit.ts
* 줄 쌓기 왼쪽 편집(PLAN 804-2 · 요구 670~677) — 항목(띠 · 번호 · 제목 · 가나다 · [+ 줄] · 접기 · X) 안 줄 카드(⋮⋮ · X)
* 차림(804-5 · 요구 682) = 항목 머리 띠 · 줄 = 들여쓰기 + 왼쪽 세로 선 · 순서 = 손잡이 끌기(`line_stack_drag.ts`) ·
* X = 확인 없이 지우고 알림 눌러 되돌리기 한 번 · 자원 이름 = 단추(누르면 찾아 넣기 창 → 바꿈)
* 줄 = 인력 · 재료 · 기계 · 다른 단가산출 · 율 · 식(금액 줄 — 이름 · 단위 · 비목 · 수량 식 · 단가 고르기 또는 식)
* · 공식 묶음(`line_stack_rows.ts`) · 글 줄
* 고침 = 부른 쪽 `change(fn)` — fn 이 문서 · 차림을 고치면 부른 쪽이 `docOf` 로 굳히고 미리 셈 · 다시 그림(입력은 change 때)
* 자원 고르기(804-3) = 찾아 넣기 창(`line_stack_pick.ts` — M02 [항목 추가] 창 그대로) · [+ 줄] 인력 · 재료 · 기계 · 다른 단가산출 =
* 고르면 줄이 생김(닫으면 안 생김) · 있는 줄 = 자원 이름 단추
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
import { createRowMenu } from "@ui/ui_template_context_menu";
import { eqText } from "@ui/ui_template_equation";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { varId, type FormulaDoc, type MoneyLine } from "../common_util/common_util_formula_doc";
import { isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import { lineMap, offLine, type Values } from "../common_util/common_util_formula_doc_view";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import {
dropSyms,
docOf,
freshId,
kindOf,
moveItem,
moveLine,
newVar,
ownedOf,
stackOf,
type MoneyKind,
type StackDoc,
type StackItem,
} from "../common_util/common_util_line_stack";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import {
addPicked,
aliasText,
repick,
shownUnit,
tableAlias,
} from "../common_util/common_util_line_stack_pick";
import { createUnitPick, NO_UNIT } from "../M02_MasterTemplete/DataFlow/M02_DF_UnitPick";
import { reach } from "./formula_doc_render";
import { attachDrag } from "./line_stack_drag";
import { openStackPick, type StackTake } from "./line_stack_pick";
import { buildBlock } from "./line_stack_rows";
import { lt, type LsKey } from "./line_stack_text";
export interface EditHooks {
doc: StackDoc;
answer: DocAnswer;
items: StackItem[];
change: (fn: (d: StackDoc, items: StackItem[]) => void) => void;
/** 품셈 표 하나(표 고르기 창) */
table: (key: string) => Promise<DocTable | null>;
subDoc: (key: string) => FormulaDoc | null;
name: (key: string) => string;
/** 공식 묶음 펼친 자리(`항목.줄`) — 화면 상태만 */
open: Set<string>;
/** 줄 · 항목 누름 — 오른쪽 짝 강조(679) */
pair: (loc: string) => void;
/** M02 단가산출(804-4) — 다른 단가산출 · 일위대가 = 복사해 펼침 · M01 = 없음(M01 전체만) */
take?: StackTake;
}
const field = (
label: string,
value: unknown,
set: (t: string) => void,
size: "s" | "m" | "l" = "m",
): HTMLElement => {
const f = createInputField({
placeholder: label,
value: value === undefined || value === null ? "" : String(value),
compact: true,
size: 24,
});
f.root.classList.add(`lstk-f-${size}`);
f.input.setAttribute("aria-label", label);
f.input.title = label;
f.input.addEventListener("change", () => set(f.input.value));
return f.root;
};
const btn = (label: string, onClick: () => void) =>
createButton({ label, variant: "ghost", size: 24, onClick });
const KIND_KEY: Record<MoneyKind, LsKey> = {
인력: "K_인력",
재료: "K_재료",
기계: "K_기계",
단가산출: "K_단가산출",
율: "K_율",
};
/** 금액 줄 몸 — 한 줄(804-7 · 요구 687-7): [종류][이름 단추 | 이름 칸 · 단가 식][수량][단위][비목] · 좁으면 접힘 */
function moneyBody(h: EditHooks, i: number): HTMLElement[] {
const d = h.doc;
const line = (d.금액 ?? [])[i];
const kind = kindOf(d, i, h.subDoc);
const own = ownedOf(d).get(i);
const v = own ? d.변수?.find((x) => varId(x) === own) : undefined;
const put = (fn: (l: MoneyLine & { 규격?: string }) => void): void =>
h.change((doc) => fn(doc.금액![i]));
// 표 값 기호 = 화면 이름(요구 683) — 칸 글은 화면 이름 ↔ 기호로 오감
const alias = tableAlias(d);
const shown = (t: unknown): string => aliasText(String(t ?? ""), alias);
const real = (t: string): string => aliasText(t.trim(), alias, true);
const head: HTMLElement[] = [];
if (own && v) {
// 이름 단추(요구 687-9) — 누르면 찾아 넣기 창이 그 이름을 찾은 채 열림 · 고르면 바꿈
const link = v.연결 ?? {};
const mat = link.재료 as Record<string, unknown> | undefined;
const size = (line as { 규격?: string }).규격;
const name = String(line.이름 ?? "") || String(mat?.검색어 ?? mat?.이름 ?? "");
const res = link.키
? link.갈래 === "단가산출"
? (h.subDoc(link.키)?.이름 ?? h.name(link.키))
: h.name(link.키)
: "";
const pick = createButton({
label: (size && !name.includes(size) ? `${name} ${size}` : name) || "—",
variant: "ghost",
tone: "link",
size: 24,
iconStart: "search",
onClick: () =>
openStackPick(
kind,
(p) => h.change((doc) => repick(doc, i, own, kind, p)),
undefined,
String(mat?.검색어 ?? line.이름 ?? ""),
),
});
pick.classList.add("lstk-res");
pick.title = `${lt("Repick")}${res ? ` — ${res}` : ""}`;
pick.dataset.repick = String(i);
head.push(pick);
} else
head.push(
field(lt("Name"), line.이름, (t) => put((l) => (l.이름 = t)), "m"),
field(lt("Price"), shown(line.단가), (t) => put((l) => (l.단가 = real(t))), "l"),
);
const why = (h.answer.줄?.[i] as { 까닭?: string } | undefined)?.까닭;
// 율 밑 번지(복사해 온 조각) — 칸 아래 읽는 이름(804-5)
const places = /번지\./.test(`${line.단가} ${line.수량 ?? ""}`) ? placeNames(d, h.items) : null;
const read = (t: unknown): string =>
shown(t).replace(/번지\.([^.\s()]+)\./g, (all, p: string) =>
places?.[p] ? `${places[p]} ` : all,
);
const unit = el("span", { className: "lstk-upick" });
const drawUnit = (): void =>
unit.replaceChildren(
createUnitPick({
value: String(line.단위 ?? "") || NO_UNIT,
size: 24,
free: true,
ariaLabel: lt("Unit"),
extra: [...new Set((d.금액 ?? []).map((m) => String(m.단위 ?? "")).filter(Boolean))],
onPick: (u) => put((l) => (u === NO_UNIT ? delete l.단위 : (l.단위 = u))),
}),
);
drawUnit();
return [
el("span", { className: "lstk-kind", text: lt(KIND_KEY[kind]) }),
...head,
// 율 · 식 줄(자원 없음)에 수량이 없으면 칸 안 보임(한 줄 유지)
...(own || String(line.수량 ?? "") ? [qtyBox(h, i, shown, real)] : []),
unit,
...(line.비목 === "묶음"
? []
: [
createSelectField({
options: ["노무비", "재료비", "경비"].map((x) => ({ value: x, text: x })),
value: line.비목 ?? "노무비",
compact: true,
size: 24,
ariaLabel: lt("Cost"),
onChange: (x) => put((l) => (l.비목 = x)),
}).root,
]),
...(places
? [
el("div", {
className: "lstk-hint lstk-wide",
text: `= ${[line.수량, line.단가].filter(Boolean).map(read).join(" × ")}`,
}),
]
: []),
...(why ? [el("div", { className: "lstk-miss lstk-wide", text: why })] : []),
];
}
/** 수량 — 기호 하나면 값 + 출처 꼬리표(「0.39 ← 표 · 잡석 수량」 · 요구 687-12 · 기호는 마우스 글) · 누르면 그 자리에서 식 고침 */
function qtyBox(
h: EditHooks,
i: number,
shown: (t: unknown) => string,
real: (t: string) => string,
): HTMLElement {
const line = (h.doc.금액 ?? [])[i];
const text = String(line.수량 ?? "");
const v = h.doc.변수?.find((x) => varId(x) === text);
const at = `qty:${i}`;
const edit = (): HTMLElement => {
const f = field(lt("Qty"), shown(text), (t) =>
h.change((doc) => {
h.open.delete(at);
const l = doc.금액![i];
if (t.trim()) l.수량 = real(t);
else delete l.수량;
}),
);
f.classList.add("lstk-qty");
f.dataset.qty = String(i);
return f;
};
if (!v || h.open.has(at)) return edit();
const val = (h.answer.값 ?? {})[text];
const link = v.연결 ?? {};
const kind = link.갈래 ?? "식";
const from =
v.이름 || (link.키 || link.표 ? h.name(String(link.키 ?? link.표)) : "") || shown(text);
const b = createButton({
label: `${isNum(val) ? eqText(val) : "—"}${v.단위 && shownUnit(v.단위) ? ` ${shownUnit(v.단위)}` : ""} ← ${kind} · ${from}`,
variant: "ghost",
size: 24,
onClick: () => {
h.open.add(at);
h.change(() => undefined);
requestAnimationFrame(() =>
document.querySelector<HTMLInputElement>(`[data-qty="${i}"] input`)?.focus(),
);
},
});
b.classList.add("lstk-qty");
b.title = `${lt("Qty")} = ${shown(text)}`;
b.dataset.qty = String(i);
return b;
}
/** [+ 줄] 고르개 값 → 새 줄 */
function addLine(h: EditHooks, at: number, kind: string): void {
if (["인력", "재료", "기계", "단가산출"].includes(kind))
return openStackPick(
kind as MoneyKind,
(p) => h.change((d, items) => void addPicked(d, items[at], kind as MoneyKind, p)),
h.take,
);
h.change((d, items) => {
const it = items[at];
const n = (d.금액 ??= []).length;
const money = (line: MoneyLine): void => {
d.금액!.push(line);
it.줄.push({ 꼴: "금액", i: n });
};
if (kind === "율")
money({ 이름: "공구손료 및 경장비", 단위: "식", 비목: "경비", 단가: "노무비 × 3 %" });
else if (kind === "공식") {
const id = freshId(d, "x");
(d.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
it.줄.push({ 꼴: "공식", 기호: [id] });
h.open.add(`${at}.${it.줄.length - 1}`);
} else if (kind === "글") it.줄.push({ 꼴: "글", 글: "" });
});
}
/** 줄 지움 — 금액 줄은 그 줄이 가진 연결 기호까지 · 공식 묶음은 기호까지 */
function dropLine(d: StackDoc, items: StackItem[], i: number, j: number): void {
const line = items[i].줄[j];
if (line.꼴 === "금액") {
const own = ownedOf(d).get(line.i);
if (own) dropSyms(d, [own]);
} else if (line.꼴 === "공식") dropSyms(d, line.기호);
items[i].줄.splice(j, 1);
}
export function buildEdit(h: EditHooks): HTMLElement {
// [+] 줄 더함 메뉴(요구 687-13 · 18) — 인력 · 재료 · 기계 · 단가산출 · 율 · 식 · 근거 글
const add = (at: number): HTMLElement => {
const kinds: [string, LsKey][] = [
["인력", "K_인력"],
["재료", "K_재료"],
["기계", "K_기계"],
["단가산출", h.take ? "K_복사" : "K_단가산출"],
["율", "Add_율"],
["공식", "Add_식"],
["글", "K_글"],
];
const menu = createRowMenu(
kinds.map(([k, label]) => ({ label: lt(label), onClick: () => addLine(h, at, k) })),
{ icon: "plus", title: lt("Add_Line"), size: 24 },
);
menu.classList.add("lstk-add");
return menu;
};
// X 지움 — 확인 없이 · 알림 누르면 되돌림(그 뒤 다른 고침이 없을 때만)
const drop = (what: string, fn: (d: StackDoc, items: StackItem[]) => void): void => {
const snap = JSON.stringify(h.doc);
let after = "";
h.change((d, items) => {
fn(d, items);
after = JSON.stringify(docOf(d, items));
});
showToast(`${what || lt("K_줄")} ${lt("Dropped")}`, "info", 6000, () =>
h.change((d, items) => {
if (JSON.stringify(docOf(d, items)) !== after)
return void showToast(lt("Undo_Late"), "warning");
const back = JSON.parse(snap) as StackDoc;
for (const k of Object.keys(d)) delete (d as Record<string, unknown>)[k];
Object.assign(d, back);
items.splice(0, items.length, ...stackOf(back));
}),
);
};
const x = (title: string, onClick: () => void): HTMLElement => {
const b = createIconButton({ icon: "x", title, size: 24, onClick });
b.classList.add("lstk-x");
return b;
};
const grip = (): HTMLElement =>
el("span", { className: "ui-list-row__grip lstk-grip", attrs: { title: lt("Drag") } });
const root = el("div", { className: "lstk-edit" });
/** 항목 접힘 표시(화면 상태)를 항목 차례 바뀜에 맞춤 */
const refold = (fn: (flags: boolean[]) => void): void => {
const flags = h.items.map((_, k) => h.open.has(`fold:${k}`));
fn(flags);
h.items.forEach((_, k) => h.open.delete(`fold:${k}`));
flags.forEach((f, k) => f && h.open.add(`fold:${k}`));
};
// 항목 번호 — 산출근거와 같은 셈(제목 있는 항목 1. 2. · 옛 가나다 항목 = 가. 나. · 체크 걷음 요구 687-5)
let n = 0;
let sub = 0;
const nos = h.items.map((it) => {
if (!it.제목.trim()) return "";
const no = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"}.` : `${++n}.`;
if (!it.가나다) sub = 0;
return no;
});
// 만약(IF) 안 탄 갈래(요구 687-10) — 갈래를 다 열면 닿고 지금 값으로는 안 닿는 기호 · 꺼진 금액 줄 = 숨김(산출근거와 같음)
const vals = (h.answer.값 ?? {}) as Values;
const taken = new Set(reach(h.doc, vals));
const every = new Set(reach(h.doc, {}, true));
const steps = lineMap(h.doc);
const off = (line: StackItem["줄"][number]): boolean =>
line.꼴 === "금액"
? offLine((h.doc.금액 ?? [])[line.i] ?? {}, vals, steps)
: line.꼴 === "공식" &&
line.기호.length > 0 &&
line.기호.every((id) => every.has(id) && !taken.has(id));
const items = h.items.map((it, i) => {
const folded = h.open.has(`fold:${i}`);
const itemGrip = grip();
// 그룹 머리 한 줄(요구 687-6 · 688) — 손잡이 · 접기 · 번호 · 이름 · [+] · X · 접어도 자리 그대로
const head = el("div", {
className: "lstk-ihead",
children: [
itemGrip,
createIconButton({
icon: folded ? "chevronRight" : "chevronDown",
title: folded ? lt("Unfold_Item") : lt("Fold_Item"),
size: 24,
onClick: () => {
if (folded) h.open.delete(`fold:${i}`);
else h.open.add(`fold:${i}`);
h.change(() => undefined);
},
}),
el("b", { className: "lstk-no", text: nos[i] }),
field(lt("Title"), it.제목, (t) => h.change((_, items) => (items[i].제목 = t.trim())), "l"),
el("span", {
className: "lstk-hint lstk-count",
text: folded ? `${lt("K_줄")} ${it.줄.length}` : "",
}),
add(i),
x(lt("Del_Item"), () =>
drop(it.제목.trim() || lt("Item"), (d, items) => {
refold((f) => f.splice(i, 1));
for (let j = items[i].줄.length - 1; j >= 0; j--) dropLine(d, items, i, j);
items.splice(i, 1);
}),
),
],
});
const lines = it.줄.flatMap((line, j) => {
if (off(line)) return [];
const lineGrip = grip();
const del = x(lt("Del"), () =>
drop(line.꼴 === "금액" ? String(h.doc.금액?.[line.i]?.이름 ?? "") : "", (d, items) =>
dropLine(d, items, i, j),
),
);
const body =
line.꼴 === "금액"
? moneyBody(h, line.i)
: line.꼴 === "글"
? [
el("span", { className: "lstk-kind", text: lt("K_글") }),
field(
lt("Text_Ph"),
line.글,
(t) => h.change((_, items) => ((items[i].줄[j] as { 글: string }).글 = t)),
"l",
),
]
: buildBlock(h, i, j, line, (id) => every.has(id) && !taken.has(id));
// 금액 · 글 = 한 줄(X 끝) · 공식 묶음 = 머리 줄 끝에 X
const card = el("div", {
className: `lstk-line k-${line.꼴}`,
children:
line.꼴 === "공식"
? [lineGrip, ...body.slice(0, 1).map((hd) => (hd.append(del), hd)), ...body.slice(1)]
: [lineGrip, ...body, del],
});
card.dataset.loc = `${i}.${j}`;
card.addEventListener("pointerdown", () => h.pair(`${i}.${j}`));
attachDrag(lineGrip, {
what: "line",
root,
self: card,
drop: (to) => {
const [k, p] = to.split(".").map(Number);
h.change((_, items) => moveLine(items, i, j, k, p));
},
});
return [card];
});
const box = el("div", {
className: `lstk-item${it.가나다 ? " is-sub" : ""}${folded ? " is-folded" : ""}`,
attrs: { "data-loc": String(i) },
children: [
head,
...(folded
? []
: [
el("div", {
className: "lstk-lines",
children: lines.length
? lines
: [el("div", { className: "lstk-empty", text: lt("Empty_Item") })],
}),
]),
],
});
head.addEventListener("pointerdown", () => h.pair(String(i)));
attachDrag(itemGrip, {
what: "item",
root,
self: box,
drop: (to) => {
const p = Number(to);
refold((f) => moveItem(f, i, p));
h.change((_, items) => moveItem(items, i, p));
},
});
return box;
});
root.append(
...items,
btn(lt("Add_Item"), () => h.change((_, items) => items.push({ 제목: "", 줄: [] }))),
);
return root;
}
+424
View File
@@ -0,0 +1,424 @@
/* =============================================================================
* line_stack_out.ts
* 줄 쌓기 산출근거 출력(PLAN 804-2 · 요구 670-1 · 677) — 경쟁사 「단가산출」 시트 꼴 표(산출근거 · 합계 · 노무비 · 재료비 · 경비)
* 공식 줄 = 「기호 = 기호식 = 값식 = 결과 단위」(값만인 줄은 넷까지 한 줄) · 금액 줄 = 「이름 : 단가 × 수량 단위 = 금액」
* 기계 · 단가산출 줄 = 비목마다 「노무비 : 59,020 × … = 금액」 — 제목 줄(「굴착기 0.6㎥」) 누름 = 그 문서(기계 시간당 경비 등)를
* 같은 계산기로 셈해 같은 꼴로 펼침(손료 식 · 연료 · 운전사 · 노 · 재 · 경 · 계) · 비목 수는 글(링크 꼴 없음 · 요구 687-11) ·
* 공식 줄 단가산출 결과 = 수 누름(▸) · 접으면 한 줄 · 출력엔 펼친 것만
* 값 = 계산기 셈 답 그대로(`calcDoc` · 줄 `answer.줄` · 합 `노무비` …) — 여기서 다시 셈 안 함 · 조판 = KaTeX(`line_stack_tex.ts`)
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el } from "@ui/ui_template_elements";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { COSTS, varId, type FormulaDoc } from "../common_util/common_util_formula_doc";
import { calcDoc, isNum, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocCache } from "../common_util/common_util_formula_doc_cache";
import {
lineMap,
moneyScope,
offLine,
type Values,
} from "../common_util/common_util_formula_doc_view";
import { reach } from "./formula_doc_render";
import {
kindOf,
nodeOf,
priceSym,
stackOf,
subInputs,
type StackDoc,
} from "../common_util/common_util_line_stack";
import { placeNames } from "../common_util/common_util_line_stack_copy";
import { shownUnit, stackLevels, tableAlias } from "../common_util/common_util_line_stack_pick";
import { katexNode, symTex, tex, valTex, type TexCtx } from "./line_stack_tex";
import { lt } from "./line_stack_text";
type Cols = Partial<Record<"sum" | (typeof COSTS)[number], number>>;
interface Row {
h: (string | HTMLElement)[];
cols?: Cols;
cls?: string;
won?: boolean;
depth: number;
/** 들여쓰기 수준(804-3) — 머리 0 · 항목 1 · 가나다 2 · 줄 = 항목 + 1 · 펼친 블록 = 그 줄 + 1 */
lv: number;
/** 왼쪽 짝 자리(679) — 항목 `k` · 줄 `k.j`(펼친 블록 = 그 줄) */
loc?: string;
}
export interface OutHooks {
/** 줄 누름 — 왼쪽 짝 자리(679) */
pair?: (loc: string) => void;
cache: DocCache;
/** 펼친 자리(`부모/기호`) */
open: Set<string>;
toggle: (at: string) => void;
/** 캐시에 없는 연결 — 뒤에서 서버에 물음 */
miss: () => void;
}
const COST_SHOW: Record<string, string> = { 노무비: "노무비", 재료비: "재료비", 경비: "경 비" };
const won = (n: number): string => eqText(Math.round(n));
const one = (n: number): string =>
n.toLocaleString("en-US", { minimumFractionDigits: 1, maximumFractionDigits: 1 });
const MAX_DEPTH = 3;
const LONG_SUM = 4; // 합계 덧셈 항이 이보다 많으면 항목 소계끼리(경쟁사 「노무비 : 2,523.3 + 919.5 = 3,442」)
/** 식 글 → 조판(못 풀면 글) */
function exprNode(
text: unknown,
side: "sym" | "val",
vals: Values,
inline = false,
cost?: string,
more: Partial<TexCtx> = {},
) {
const n = nodeOf(text);
return n
? katexNode(tex(n, { side, vals, inline, cost, ...more }))
: el("span", { text: String(text ?? "") });
}
const hasVar = (text: unknown): boolean => {
const n = nodeOf(text);
return !!n && JSON.stringify(n).includes('["var"');
};
function rows(
doc: StackDoc,
a: DocAnswer,
h: OutHooks,
depth: number,
at: string,
base = 0,
): Row[] {
const out: Row[] = [];
let lv = base + 1; // 지금 쌓는 줄 수준
let loc: string | undefined; // 지금 쌓는 왼쪽 자리(맨 위 문서만)
const vals = (a.값 ?? {}) as Values;
const vars = new Map((doc.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((doc.식 ?? []).map((l) => [String(l.기호 ?? ""), l.식]));
// 펼친 문서 줄 = 금액 칸 없이(부른 쪽 칸과 섞이지 않게)
const push = (r: Omit<Row, "depth" | "lv">): void =>
void out.push({ ...r, depth, lv, loc, ...(depth ? { cols: undefined } : {}) });
const shown = new Set(reach(doc, vals)); // 지금 탄 갈래로 닿는 기호만
const steps = lineMap(doc);
const place = placeNames(doc, stackOf(doc)); // 번지 읽는 이름(804-5)
const unit = (id: string): string => shownUnit(vars.get(id)?.단위);
const alias = tableAlias(doc); // 표 값 기호 화면 이름(요구 683)
const subOf = (id: string): FormulaDoc | null => {
const k = vars.get(id)?.연결;
if (k?.갈래 !== "단가산출" || !k.키 || k.키.includes("{")) return null;
try {
return h.cache.links.doc(k.키) ?? null;
} catch {
if (h.cache.takeMissing()) h.miss();
return null;
}
};
/** ▸ 단추 — 펼친 자리면 그 문서 줄을 뒤에 끼움 */
const opener = (id: string, label: string): HTMLElement => {
const path = `${at}/${id}`;
const b = createButton({
label: `${label} ${h.open.has(path) ? "▾" : "▸"}`,
variant: "link",
size: 20,
onClick: () => h.toggle(path),
});
b.dataset.expand = path;
return b;
};
/** 기계 · 단가산출 제목 줄 — 접기 단추 + 제목(누르면 펼침 · 요구 687-11) */
const fold = (id: string, title: string): HTMLElement[] => {
const path = `${at}/${id}`;
const on = h.open.has(path);
const b = createIconButton({
icon: on ? "chevronDown" : "chevronRight",
title: lt(on ? "Fold_Sub" : "Unfold_Sub"),
size: 20,
onClick: (e) => (e.stopPropagation(), h.toggle(path)),
});
b.dataset.expand = path;
const t = el("span", { className: "lstk-ftitle", text: title });
t.addEventListener("click", (e) => (e.stopPropagation(), h.toggle(path)));
return [b, t];
};
const expand = (id: string): void => {
const path = `${at}/${id}`;
const sub = subOf(id);
if (!h.open.has(path) || !sub || depth >= MAX_DEPTH) return;
const got = calcDoc(sub, h.cache.links, subInputs(vars.get(id)?.연결, vals));
if (h.cache.takeMissing()) h.miss();
out.push({
depth: depth + 1,
lv: lv + 1,
loc,
h: [`${sub.이름 ?? ""}${sub.결과단위 ? ` [${sub.결과단위}]` : ""}`],
cls: "subhead",
});
out.push(...rows(sub, got, h, depth + 1, path, lv + 1).map((r) => ({ ...r, loc })));
};
/** 공식 묶음 — 값만인 줄은 넷까지 한 줄 */
const block = (ids: string[]): void => {
let pack: HTMLElement[] = [];
const flush = (): void => {
if (pack.length)
push({ h: pack.flatMap((x, k) => (k ? [el("span", { className: "lstk-gap" }), x] : [x])) });
pack = [];
};
for (const id of ids.filter((x) => shown.has(x))) {
const v = vals[id];
const t = lines.get(id);
const head = katexNode(symTex(alias[id] ?? id));
if (t !== undefined && hasVar(t)) {
flush();
const res = isNum(v)
? katexNode(`${valTex(v)}\\ ${unit(id) ? `\\text{${unit(id)}}` : ""}`)
: missing(a, id);
// 기호식 = 값식 = 결과 — 같은 꼴이 이어지면 한 번만(만약이 수 하나를 탄 줄)
const n = nodeOf(t)!;
const sym = tex(n, { side: "sym", vals, alias });
const val = tex(n, { side: "val", vals, alias });
const same = isNum(v) && val === valTex(v);
push({
h: [
head,
...(sym === val ? [] : [" = ", katexNode(sym)]),
...(same ? [] : [" = ", katexNode(val)]),
" = ",
res,
],
});
continue;
}
const sub = subOf(id);
if (sub || (v && typeof v === "object")) {
flush();
const label =
v && typeof v === "object"
? COSTS.map((c) => `${COST_SHOW[c]} ${eqText((v as Values)[c])}`).join(" · ")
: isNum(v)
? `${eqText(v)} ${unit(id) || sub?.결과단위 || ""}`
: "—";
push({
h: [head, " = ", sub ? opener(id, label) : label, sub ? ` ${sub.이름 ?? ""}` : ""],
});
expand(id);
continue;
}
const expr =
t !== undefined && !isNum(Number(t))
? [" = ", exprNode(t, "sym", vals, false, undefined, { alias })]
: [];
const cell = el("span", {
className: "lstk-pack",
children: [
head,
...expr,
" = ",
isNum(v) ? `${eqText(v)} ${unit(id)}` : typeof v === "string" ? v : missing(a, id),
],
});
pack.push(cell);
if (pack.length >= 4) flush();
}
flush();
};
const money = doc.금액 ?? [];
const lineRows = (i: number): { sub: boolean; cols: Cols } => {
const line = money[i];
const r = (a.줄?.[i] ?? {}) as {
비목?: Record<string, number>;
금액?: number | null;
까닭?: string;
단가?: unknown;
};
const scope: Values = { ...vals, ...moneyScope(a.줄, i) };
const at: Record<string, Values> = {}; // 앞 줄 번지 합(계산기와 같음 — 번지 값을 값 식에 · 804-5)
money.slice(0, i).forEach((m, k) => {
const got = a.줄?.[k] as { 금액?: unknown; 비목?: Values } | undefined;
if (m.기호) scope[m.기호] = got?.금액; // 앞 금액 줄 기호 = 그 줄 금액
if (m.번지)
for (const c of COSTS) {
const box = (at[String(m.번지)] ??= { 노무비: 0, 재료비: 0, 경비: 0 });
if (isNum(got?.비목?.[c]))
box[c] = Math.round(((box[c] as number) + (got!.비목![c] as number)) * 1e6) / 1e6;
}
});
if (Object.keys(at).length) scope.번지 = at;
const name = [line.이름, (line as { 규격?: string }).규격].filter(Boolean).join(" ");
const qty =
line.수량 !== undefined && line.수량 !== null && line.수량 !== "" ? line.수량 : null;
const qtyNode = (cost?: string): (string | HTMLElement)[] =>
qty === null
? []
: [
" × ",
exprNode(qty, "val", scope, true, cost, { alias }),
line.단위 ? ` ${line.단위}` : "",
];
if (r.까닭 && r.금액 === null) {
push({ h: [`${name} : — `, el("span", { className: "lstk-miss", text: r.까닭 })] });
return { sub: false, cols: {} };
}
if (line.비목 === "묶음") {
const s = priceSym(line);
const bundle = (r.단가 ?? {}) as Values;
const cols: Cols = {};
const title = name || (s ? (subOf(s)?.이름 ?? s) : "");
push({ h: s && subOf(s) ? fold(s, title) : [title], cls: "lname" });
for (const c of COSTS) {
const part = r.비목?.[c];
if (!isNum(part) || (!part && !bundle[c])) continue;
cols[c] = part;
push({
h: [`${COST_SHOW[c]} : `, eqText(bundle[c]), ...qtyNode(c), ` = ${one(part)}`],
});
}
if (s) expand(s);
return { sub: true, cols };
}
const amt = r.금액 ?? 0;
const price =
hasVar(line.단가) && kindOf(doc, i) === "율"
? [
exprNode(line.단가, "sym", scope, true, undefined, { place, alias }),
" = ",
exprNode(line.단가, "val", scope, true, undefined, { alias }),
]
: [exprNode(line.단가, "val", scope, true, undefined, { alias })];
const c = String(line.비목);
push({
h: [`${name} : `, ...price, ...qtyNode(), ` = ${one(amt)}`],
cols: { sum: amt, [c]: amt },
});
return { sub: false, cols: { [c]: amt } };
};
const items = stackOf(doc);
const terms: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
const perItem: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] }; // 덧셈이 길면 항목 소계끼리(804-5)
let n = 0;
let sub = 0;
const levels = stackLevels(items);
for (const [k, it] of items.entries()) {
lv = base + levels[k].item;
loc = depth ? undefined : String(k);
if (it.제목.trim()) {
const label = it.가나다 ? `${"가나다라마바사아자차카타파하"[sub++] ?? "?"} . ` : `${++n}. `;
if (!it.가나다) sub = 0;
push({ h: [label + it.제목], cls: it.가나다 ? "item sub" : "item" });
}
const sum: Record<string, number> = { 노무비: 0, 재료비: 0, 경비: 0 };
const own: Record<string, number[]> = { 노무비: [], 재료비: [], 경비: [] };
let hasSub = false;
lv = base + levels[k].line;
for (const [j, l] of it.줄.entries()) {
loc = depth ? undefined : `${k}.${j}`;
if (l.꼴 === "글") push({ h: [l.글] });
else if (l.꼴 === "공식") block(l.기호);
else {
if (offLine(money[l.i], vals, steps)) continue; // 선택으로 꺼진 줄(수식 탭과 같음)
const got = lineRows(l.i);
hasSub ||= got.sub;
if ((a.줄?.[l.i] as { 합밖?: boolean } | undefined)?.합밖) continue;
for (const [c, v] of Object.entries(got.cols)) {
sum[c] = Math.round((sum[c] + (v ?? 0)) * 10) / 10;
if (v) own[c].push(v);
}
}
}
loc = depth ? undefined : String(k);
if (hasSub && COSTS.some((c) => sum[c])) {
const s = Math.round((sum.노무비 + sum.재료비 + sum.경비) * 10) / 10;
push({ h: [`${lt("Sub_Sum")} : ${one(s)}`], cols: { sum: s, ...sum }, cls: "subsum" });
}
for (const c of COSTS) {
terms[c].push(...(hasSub ? (sum[c] ? [sum[c]] : []) : own[c]));
if (sum[c]) perItem[c].push(sum[c]);
}
}
lv = base + 1;
loc = undefined;
if (doc.결과) {
const r = a.결과;
push({
h: [
katexNode(symTex(doc.결과)),
` = ${isNum(r) ? `${eqText(r)} ${doc.결과단위 ?? ""}` : "—"}`,
],
cls: "total",
});
return out;
}
if (!money.length) return out;
push({ h: [`${++n}. ${lt("Total")}`], cls: "item" });
lv = base + 2;
for (const c of COSTS) {
const t = terms[c].length > LONG_SUM ? perItem[c] : terms[c];
const v = a[c];
const body = !isNum(v)
? "—"
: t.length > 1
? `${t.map(one).join(" + ")} = ${won(v)}`
: t.length
? won(v)
: "";
push({ h: [`${COST_SHOW[c]} : ${body}`] });
}
push({
h: [`${lt("Sum")} : ${isNum(a.계) ? won(a.계) : "—"}`],
cols: isNum(a.계)
? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 }
: {},
cls: "total",
won: true,
});
return out;
}
const missing = (a: DocAnswer, id: string): HTMLElement =>
el("span", { className: "lstk-miss", text: `— ${a.빈값?.[id] ?? ""}`.trim() });
/** 산출근거 표 */
export function buildOut(doc: StackDoc, a: DocAnswer, h: OutHooks): HTMLElement {
const columns: Column[] = [
{ label: lt("Basis") },
{ label: lt("Total"), width: "92px", align: "end" },
{ label: COST_SHOW.노무비, width: "84px", align: "end" },
{ label: COST_SHOW.재료비, width: "84px", align: "end" },
{ label: COST_SHOW.경비, width: "84px", align: "end" },
];
const { root, tbody } = tableShell(columns, { density: "data" });
const head: Row = {
h: [`${doc.이름 ?? ""}${doc.결과단위 ? ` [${doc.결과단위}]` : ""}`],
cols: isNum(a.계)
? { sum: a.계, 노무비: a.노무비 ?? 0, 재료비: a.재료비 ?? 0, 경비: a.경비 ?? 0 }
: {},
cls: "head",
won: true,
depth: 0,
lv: 0,
};
for (const r of [head, ...rows(doc, a, h, 0, "")]) {
const cell = (k: "sum" | (typeof COSTS)[number]): string => {
const v = r.cols?.[k];
if (v === undefined || (r.won && !v && k !== "sum")) return "";
return r.won ? won(v) : one(v);
};
const first = el("div", { className: "lstk-h", children: r.h });
first.style.setProperty("--lstk-depth", String(r.lv));
first.dataset.lv = String(r.lv);
const tr = tableRow(columns, [first, cell("sum"), ...COSTS.map(cell)]);
tr.className = `lstk-orow ${r.cls ?? ""} ${r.depth ? "is-sub" : ""}`;
if (r.loc !== undefined) {
tr.dataset.loc = r.loc;
tr.addEventListener("click", () => h.pair?.(r.loc!));
}
tbody.append(tr);
}
root.classList.add("lstk-out");
return root;
}
+157
View File
@@ -0,0 +1,157 @@
/* =============================================================================
* line_stack_pick.ts
* 줄 쌓기 고르기 창(PLAN 804-3 · 804-4 · 요구 678-1 · 678-3 · 680-1 · 681)
* 찾아 넣기 = M02 [항목 추가] 창 그대로(`openFindPicker` · 탭 M01 전체 · 찾기 칸 · 표) — 줄 갈래마다 그 종류만
* 인력(노임 LB) · 재료(자재 MT) · 기계(EQ → 시간당 경비 로직 + 기계='원문번호') · 다른 단가산출(M01 단가산출 로직)
* M01 단가산출 기초 줄 쌓기 = 「M01 전체」 탭만 — M01 은 원천 기준값이라 M02 단가산출 · 일위대가를 부르지 않음(요구 680-1)
* M02 단가산출 줄 쌓기(`take` 있음) · 다른 단가산출 = 탭 [M01 전체 | M02 단가산출 | 일위대가](위 층 · 자기 SK 뺌) —
* 고름 = 복사해 펼침(`take(src)` · src = M01 키 | `참조|층|주인|키`) · 참조 줄 없음(요구 681)
* 표 고르기 = 품셈 표(조건 칸 + 값 칸) · 값 칸 누름 = 그 줄 · 칸 · 지금 닿는 칸 강조 ·
* 옵션에 매인 조건 = 그 줄만 고를 수 있음(다른 줄은 막힘 + 「위 옵션에서 고름」 안내)
* ========================================================================== */
import { createButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el } from "@ui/ui_template_elements";
import { openModal } from "@ui/ui_template_modal";
import { tableRow, tableShell, type Column } from "@ui/ui_template_table";
import { createText } from "@ui/ui_template_text";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import type { MoneyKind } from "../common_util/common_util_line_stack";
import type { Picked } from "../common_util/common_util_line_stack_pick";
import { showToast } from "@ui/ui_template_elements";
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { openFindPicker, type FindKind } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import { lt } from "./line_stack_text";
const KINDS: Partial<Record<MoneyKind, FindKind>> = {
인력: "labor",
재료: "material",
기계: "machine",
단가산출: "logic",
};
/** M02 단가산출 복사 길(804-4) — 그 SK 층 · 키 · 복사해 넣기 */
export interface StackTake {
layer: Layer;
owner: string | null;
me: string;
take: (src: string) => void;
}
/** 찾아 넣기 — 고르면 `done(고른 것)` · 닫으면 아무것도 안 함 · `m02` 있고 다른 단가산출 = 복사해 펼침 ·
* `query` = 찾기 칸에 넣고 연 결과(이름 단추 · 요구 687-9) */
export function openStackPick(
kind: MoneyKind,
done: (p: Picked) => void,
m02?: StackTake,
query = "",
): void {
const want = KINDS[kind];
if (!want) return;
const spec = (e: { 규격: string }) => (e.규격 ? { 규격: e.규격 } : {});
const copy = kind === "단가산출" && m02 ? m02 : null;
openFindPicker({
layer: copy?.layer ?? "system",
owner: copy?.owner ?? null,
title: `${lt("Find_Title")} — ${lt(copy ? "K_복사" : (`K_${kind}` as never))}`,
kinds: copy ? ["logic", "sk", "pre"] : [want], // M01 = M01 전체만(680-1)
query: query.trim() || undefined,
ref: copy
? (r) =>
r.키 === copy.me && r.층 === copy.layer && (!copy.owner || r.주인 === copy.owner)
? showToast(lt("Self_Take"), "warning")
: copy.take(`참조|${r.층}|${r.주인 ?? ""}|${r.키}`)
: undefined,
logic: (x) =>
copy
? copy.take(x.키)
: done({ 키: x.키, 이름: x.이름, 단위: x.결과단위.replace(/^원\/?/, "") }),
element: (e) => done({ 키: e.코드, 이름: e.이름, 단위: e.단위, ...spec(e) }),
machine: (e) =>
e.로직 && e.원문번호
? done({ 키: e.로직, 이름: e.이름, 입력: { 기계: `'${e.원문번호}'` }, ...spec(e) })
: undefined,
});
}
const isNum = (v: unknown): boolean =>
typeof v === "number" || (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v)));
const cellText = (v: unknown): string =>
Array.isArray(v)
? `${v[0] ?? ""} ~ ${v[1] ?? ""}`
: isNum(v)
? eqText(Number(v))
: String(v ?? "");
export interface TablePick {
table: DocTable;
/** 지금 닿는 줄(-1 = 없음) · 칸 */
now: number;
col: string;
/** 옵션에 매인 조건 — {조건 칸: 옵션 열쇠들} */
bound: Record<string, string[]>;
done: (row: Record<string, unknown>, col: string) => void;
}
/** 표 고르기 창 — 값 칸 누름 = 고름 */
export function openTablePick(o: TablePick): void {
const t = o.table;
const conds = Object.keys(t.조건 ?? {});
const valueCols = [
...new Set([...Object.keys(t.값칸 ?? {}), ...t.줄.flatMap((r) => Object.keys(r))]),
].filter((k) => !conds.includes(k));
const locked = Object.keys(o.bound).length > 0;
const columns: Column[] = [
...conds.map((c) => ({ label: c })),
...valueCols.map((c) => {
const unit = (t.값칸 ?? {})[c];
return { label: unit ? `${c} (${String(unit)})` : c, align: "end" as const };
}),
];
openModal({
title: [t.원문번호, (t as { 이름?: string }).이름].filter(Boolean).join(" "),
closeLabel: lt("Close"),
width: "960px",
mount: (body, close) => {
const { root, tbody } = tableShell(columns, { density: "data" });
t.줄.forEach((row, k) => {
const off = locked && k !== o.now;
const tr = tableRow(columns, [
...conds.map((c) => cellText(row[c])),
...valueCols.map((c) =>
isNum(row[c]) // 수만 고름(글 칸 = 단위 등은 보기만 · M02 길은 수를 글로 줌)
? createButton({
label: cellText(row[c]),
variant: k === o.now && c === o.col ? "filled" : "ghost",
size: 24,
disabled: off,
onClick: () => {
close();
o.done(row, c);
},
})
: cellText(row[c]),
),
]);
tr.dataset.row = String(k);
tbody.append(tr);
});
const note = locked
? [
createText(
`${lt("Tbl_Bound")} — ${[...new Set(Object.values(o.bound).flat())].join(" · ")}`,
),
]
: [];
body.append(
el("div", {
className: "lstk-tpick",
attrs: { "data-table-pick": t.키 ?? "" },
children: [...note, createText(lt("Tbl_Hint")), root],
}),
);
},
});
}
+327
View File
@@ -0,0 +1,327 @@
/* =============================================================================
* line_stack_rows.ts
* 줄 쌓기 공식 묶음(PLAN 804-2 · 요구 675) — 「이름 = 값」 · 「이름 = 식」 줄 묶음 · 틀 이름표 · [식으로 풀기] · [틀로 저장]
* 보임 = 값 줄은 값 칸(연결 값을 고쳐 적으면 숫자 = 고정값 · 요구 664) · 식 줄은 「이름 = 식 = 값」 글 · 단가산출 결과 기호 = [식으로 풀기]
* 고침(804-7 · 요구 687-14) = [식 고치기] 걷음 — 이름 · 식 글을 누르면 그 줄만 그 자리에서 이름 · 식 · 단위 칸 + 지움 ·
* 바꾸면 닫힘 · 머리 [+] = 식 줄 더함 — 이름 바꿈 = 문서 전부(`rename`) · 식 = 식 줄 · 수 = 숫자 연결
* 만약 안 탄 갈래 기호(`hide` · 요구 687-10) = 안 보임(식 글은 그대로)
* 틀 = 단가산출 결과 문서(굴착기 Q 등)를 이 문서 식으로 옮김(`unfold`) — 꼴이 바뀌면 「내 틀 (틀 이름 에서 변경)」 ·
* 빈 틀 = [찾아 넣기](M01 단가산출 결과 — 작업량 등) · [틀로 저장] · 내 틀 목록 걷음(요구 685) · 표 값만 = 머리 「표 값」
* 표 값(804-3) = [표 · 칸] 단추 → 표 고르기 창(`line_stack_pick.ts`) · 값 칸 누름 = 그 줄 · 칸(`pickCell`)
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
import { eqText } from "@ui/ui_template_equation";
import { el, showToast } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import {
DocError,
rename,
splitId,
varId,
type FormulaDoc,
} from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import {
dropSyms,
freshId,
newVar,
setRow,
shapeOf,
unfold,
type StackItem,
type StackLine,
} from "../common_util/common_util_line_stack";
import {
aliasText,
boundConds,
currentRow,
pickCell,
shownUnit,
tableAlias,
} from "../common_util/common_util_line_stack_pick";
import { openFindPicker } from "../M02_MasterTemplete/M02_MasterTemplete_Find_Pick";
import type { EditHooks } from "./line_stack_edit";
import { openTablePick } from "./line_stack_pick";
import { lt } from "./line_stack_text";
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
type Block = Extract<StackLine, { 꼴: "공식" }>;
const LINK_TAG: Record<string, string> = {
표: "표",
요소: "요소",
단가산출: "단가산출",
선택: "선택",
입력: "입력",
};
const input = (
value: string,
placeholder: string,
cls: string,
set: (t: string) => void,
): HTMLElement => {
const f = createInputField({ value, placeholder, compact: true, size: 24 });
f.root.classList.add(cls);
f.input.addEventListener("change", () => set(f.input.value));
return f.root;
};
export function buildBlock(
h: EditHooks,
i: number,
j: number,
line: Block,
hide: (id: string) => boolean = () => false,
): HTMLElement[] {
const d = h.doc;
const at = `${i}.${j}`;
const editing = (id: string): boolean => h.open.has(`${at}:${id}`);
/** 그 줄 고침 켬 · 끔(화면 상태) */
const flip = (id: string, on: boolean): void => {
if (on) h.open.add(`${at}:${id}`);
else h.open.delete(`${at}:${id}`);
};
const vals = (h.answer.값 ?? {}) as Record<string, unknown>;
const vars = new Map((d.변수 ?? []).map((v) => [varId(v), v]));
const lines = new Map((d.식 ?? []).map((l) => [String(l.기호 ?? ""), String(l.식 ?? "")]));
const blockOf = (items: StackItem[]): Block => items[i].줄[j] as Block;
// 표 값 기호 = 화면 이름(요구 683 · 문서 기호 그대로) — 칸 글은 화면 이름 ↔ 기호로 오감
const alias = tableAlias(d);
const show = (id: string): string => alias[id] ?? id;
const shownText = (t: string): string => aliasText(t, alias);
const realText = (t: string): string => aliasText(t, alias, true);
const valText = (id: string): string => {
const v = vals[id];
if (isNum(v) || typeof v === "string")
return `${eqText(v)} ${shownUnit(vars.get(id)?.단위)}`.trim();
if (v && typeof v === "object")
return ["노무비", "재료비", "경비"]
.map((c) => `${c} ${eqText((v as Record<string, unknown>)[c])}`)
.join(" · ");
return `— ${h.answer.빈값?.[id] ?? ""}`.trim();
};
// ── 이름표 ──
const tplName = (k: string): string =>
k.startsWith("my:") ? lt("My_Tpl") : (h.subDoc(k)?.이름 ?? (h.name(k) || k)); // my: = 옛 내 틀(걷음 · 요구 685)
const changed = !!line.틀 && !!line.틀꼴 && shapeOf(d, line.기호) !== line.틀꼴;
const tableOnly = line.기호.every((id) => vars.get(id)?.연결?.갈래 === "표" && !lines.has(id));
const label = !line.틀
? lt(tableOnly && line.기호.length ? "K_표값" : "K_공식")
: changed
? `${lt("My_Tpl")} (${tplName(line.틀)} ${lt("Changed_From")})`
: tplName(line.틀);
const head = el("div", {
className: "lstk-fhead",
children: [
el("span", { className: "lstk-kind", text: label }),
createIconButton({
icon: "plus",
title: lt("Add_Row"),
size: 24,
onClick: () =>
h.change((doc, items) => {
const id = freshId(doc, "x");
(doc.변수 ??= []).push(newVar(id, { 갈래: "숫자", 값: 0 }));
blockOf(items).기호.push(id);
flip(id, true);
}),
}),
],
});
head.dataset.tpl = changed ? "changed" : line.틀 ? "tpl" : "";
// ── 표 값 — [표 · 칸] → 표 고르기 ──
const tableBtn = (id: string, link: NonNullable<FormulaDoc["변수"]>[number]["연결"] & object) => {
const b = createButton({
label:
[
lt("Tbl"),
...Object.values(link.조건 ?? {}).flatMap((t) =>
/^'.*'$/.test(String(t)) ? [String(t).slice(1, -1)] : [],
),
link.칸,
]
.filter(Boolean)
.join(" · ") + " ▾", // 고른 줄(글 조건) · 칸
variant: "ghost",
size: 24,
onClick: () =>
void h.table(String(link.표 ?? "")).then((t) => {
if (!t) return void showToast(lt("Tbl_Missing"), "warning");
openTablePick({
table: t,
now: currentRow(t, link, vals),
col: String(link.칸 ?? ""),
bound: boundConds(d, link),
done: (row, col) => h.change((doc) => pickCell(doc, id, t, row, col)),
});
}),
});
b.dataset.tablePick = id;
b.title = h.name(String(link.표 ?? "")) || String(link.표 ?? "");
return b;
};
// ── 줄 ──
const renameRow = (old: string, now: string): void => {
const t = now.trim();
if (!t || t === old) return;
try {
const copy = clone(d);
const [s, lo, up] = splitId(t);
rename(copy, old, s, lo, up);
} catch (e) {
if (e instanceof DocError) return void showToast(lt("Bad_Name"), "warning");
throw e;
}
h.change((doc, items) => {
const [s, lo, up] = splitId(t);
rename(doc, old, s, lo, up);
for (const it of items)
for (const l of it.줄) if (l.꼴 === "공식") l.기호 = l.기호.map((x) => (x === old ? t : x));
});
};
const unfoldRow = (id: string, sub: FormulaDoc, key: string): void =>
h.change((doc, items) => {
const moved = unfold(doc, sub, id);
const blk = blockOf(items);
blk.기호 = blk.기호.filter((x) => x !== id);
items[i].줄.splice(j + 1, 0, { 꼴: "공식", 기호: moved, 틀: key, 틀꼴: shapeOf(doc, moved) });
if (!blk.기호.length) items[i].줄.splice(j, 1);
});
/** 누르면 그 줄 고침(보이는 글을 고침) */
const tap = (node: HTMLElement, id: string): HTMLElement => {
node.classList.add("lstk-tap");
node.tabIndex = 0;
node.setAttribute("role", "button");
node.title = lt("Edit_Hint");
const go = (): void => {
flip(id, true);
h.change(() => undefined);
};
node.addEventListener("click", go);
node.addEventListener("keydown", (e) => e.key === "Enter" && go());
return node;
};
/** 고친 뒤 닫음 */
const done = (id: string, fn: () => void): void => {
flip(id, false);
fn();
};
const rows = line.기호
.filter((id) => !hide(id))
.map((id) => {
const v = vars.get(id);
const link = v?.연결;
const expr = lines.get(id);
const sub = link?.갈래 === "단가산출" && link.키 ? h.subDoc(link.키) : null;
const tag =
link?.갈래 === "표" && !expr
? tableBtn(id, link)
: link && LINK_TAG[link.갈래 ?? ""] && !expr
? el("span", {
className: "lstk-tag",
text: `${LINK_TAG[link.갈래!]}${link.키 && !link.키.includes("{") ? ` · ${sub?.이름 ?? h.name(link.키) ?? ""}` : ""}`,
})
: null;
const tools: HTMLElement[] = [];
if (sub?.결과 && link?.키)
tools.push(
createButton({
label: lt("Unfold"),
variant: "ghost",
size: 24,
onClick: () => unfoldRow(id, sub, link.키!),
}),
);
if (link?.갈래 === "단가산출" && !link.키)
tools.push(
createButton({
label: lt("Find_Title"),
variant: "ghost",
size: 24,
onClick: () =>
openFindPicker({
layer: "system",
owner: null,
title: lt("K_단가산출"),
kinds: ["logic"],
results: true,
logic: (x) =>
h.change((doc) => {
const y = doc.변수?.find((z) => varId(z) === id);
if (y) y.연결 = { 갈래: "단가산출", 키: x.키, 입력: {} };
}),
}),
}),
);
const name = tap(el("b", { className: "lstk-sym", text: show(id) }), id);
const valueOf =
expr ??
(link?.갈래 === "숫자" ? String(link.값 ?? "") : isNum(vals[id]) ? String(vals[id]) : "");
let body: (HTMLElement | string)[];
const open = editing(id);
if (open)
body = [
input(show(id), lt("Row_Name"), "lstk-f-s", (t) =>
done(id, () => renameRow(id, t.trim() === show(id) ? id : t)),
),
"=",
input(shownText(valueOf), lt("Row_Expr"), "lstk-f-l", (t) =>
done(id, () => h.change((doc) => setRow(doc, id, realText(t)))),
),
input(v?.단위 ?? "", lt("Unit"), "lstk-f-s", (t) =>
done(id, () =>
h.change((doc) => {
let x = doc.변수?.find((y) => varId(y) === id);
if (!x) (doc.변수 ??= []).push((x = newVar(id, { 갈래: "식" })));
x.단위 = t.trim();
}),
),
),
el("span", { className: "lstk-val", text: `= ${valText(id)}` }),
createButton({
label: "×",
variant: "danger-ghost",
size: 24,
onClick: () => done(id, () => h.change((doc) => dropSyms(doc, [id]))),
}),
createIconButton({
icon: "check",
title: lt("Close"),
size: 24,
onClick: () => done(id, () => h.change(() => undefined)),
}),
];
else if (expr !== undefined || sub || link?.갈래 === "단가산출")
body = [
name,
"=",
...(expr
? [tap(el("span", { className: "lstk-expr", text: shownText(expr) }), id), "="]
: []),
el("span", { className: "lstk-val", text: valText(id) }),
];
else
body = [
name,
"=",
input(shownText(valueOf), valText(id), "lstk-f-m", (t) =>
h.change((doc) => setRow(doc, id, realText(t))),
),
...(shownUnit(v?.단위)
? [el("span", { className: "lstk-unit", text: shownUnit(v?.단위) })]
: []),
];
const row = el("div", {
className: `lstk-frow ${open ? "is-open" : ""}`,
children: [...body, ...(tag ? [tag] : []), ...tools],
});
row.dataset.sym = id;
return row;
});
return [head, ...rows];
}
+496
View File
@@ -0,0 +1,496 @@
/* =============================================================================
* line_stack_screen.ts
* 단가산출 「줄 쌓기」 탭(PLAN 804-2 · 요구 670~677) — 공용(M01 단가산출 기초 · M02 단가산출 = 길 앞머리만 바꿈)
* 위 = 옵션 가로(`formula_doc_options.ts`) · M02 사본 [단가 갱신] · [재생성] · 「M01 새 판」 배지(층 쓰기)
* `page` 있음(M01 804-7 · M02 804-8) = 저장 · 되돌림 단추 없음 — 페이지 [저장] · [되돌림]이 `save` · `discard` 를 부름 ·
* 고침이 바뀌면 `page()` · `page` 없음(일위대가 줄 모달 등) = 위에 [고침 버림] · [수식 저장]
* 가운데 = 왼쪽 줄 쌓기 편집(`line_stack_edit.ts`) | 오른쪽 산출근거 출력(`line_stack_out.ts` · 기계 시간당 경비 펼침)
* 아래 = 합계 상자(노무비 · 재료비 · 경비 · 계 · 화면 아래 고정 · 요구 687-3)
* 정본 = 수식 문서(탭을 바꾸면 다시 읽음) · 줄 쌓기 차림 = 문서 칸 `쌓기` · 804-6 옛 수식 문서 탭 걷음 = 이 화면이 「수식」 탭
* 셈 = 브라우저 계산기(`calcDoc`) 즉시 · 캐시에 없는 연결만 뒤에서 `POST {길}/preview`(옛 요청 끊음)
* 고침 = 캐시(sessionStorage)에 쌓고 [저장] 한 번에 `POST {길}/save`(서버가 정본으로 다시 셈) · 자동저장 없음
* M02 사본(`copy`) — 저장 몸 {문서, 끊음(받은 그대로), 판} · 판 바뀜(409)은 문서가 그대로면 새 판으로 한 번 더
* [단가 갱신] · [재생성] = 미리 셈 → 알림 → 저장(`formula_doc_copy.ts` · 고친 것 먼저 저장)
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createBadge } from "@ui/ui_template_badge";
import { createButton } from "@ui/ui_template_button";
import {
el,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { blankDoc, type FormulaDoc } from "../common_util/common_util_formula_doc";
import { calcDoc, type DocAnswer } from "../common_util/common_util_formula_doc_calc";
import type { DocTable } from "../common_util/common_util_formula_doc_table";
import { createDocCache, type DocRecord } from "../common_util/common_util_formula_doc_cache";
import {
docOf,
stackOf,
type StackDoc,
type StackItem,
} from "../common_util/common_util_line_stack";
import {
askJob,
copyBadges,
copyTools,
type CopyJob,
type CopyMark,
type CopyOptions,
type JobReply,
} from "./formula_doc_copy";
import { docFoot } from "./formula_doc_foot";
import { buildOptions } from "./formula_doc_options";
import { ft } from "./formula_doc_text";
import { addTaken, type Taken } from "../common_util/common_util_line_stack_copy";
import type { Layer } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch";
import { buildEdit } from "./line_stack_edit";
import { buildOut } from "./line_stack_out";
import { lt } from "./line_stack_text";
import "./line_stack.css";
export interface StackScreenOptions {
/** 길 앞머리(`/m01/logic/doc`) — GET ?key · POST /preview · /save · /links */
base: string;
admin: boolean;
/** sessionStorage 열쇠 */
cache: string;
copy?: CopyOptions;
/** M02 SK 층(804-4) — 줄 쌓기 다른 단가산출 · 일위대가 복사 길 · 없으면(M01) 복사 없음 */
scope?: { layer: Layer; owner: string | null };
/** 페이지가 저장 · 되돌림을 맡음(M01 804-7 · M02 804-8) — 고침 있음이 바뀔 때 부름 · 화면 위 [고침 버림] · [수식 저장] 없음 */
page?: () => void;
}
type Docs = Record<string, FormulaDoc>;
interface Loaded {
문서: StackDoc;
셈: DocAnswer;
연결: DocRecord;
새?: boolean;
끊음?: Docs;
사본?: CopyMark;
판?: string;
}
const clone = <T>(v: T): T => JSON.parse(JSON.stringify(v)) as T;
export function createStackScreen(opt: StackScreenOptions) {
const head = el("div", { className: "lstk__head" });
const left = el("div", { className: "lstk__left" });
const right = el("div", { className: "lstk__right" });
const foot = el("div", { className: "lstk__foot" });
const root = el("div", {
className: "lstk",
attrs: { "data-line-stack": "" },
children: [head, el("div", { className: "lstk__areas", children: [left, right] }), foot],
});
// 산출근거 고정 높이(요구 683) — 스크롤 조상(없으면 창) 높이 · 넓을 때만 고정(line_stack.css)
const fit = (): void => {
let p = root.parentElement;
while (p && !/(auto|scroll)/.test(getComputedStyle(p).overflowY)) p = p.parentElement;
const view = (p?.clientHeight ?? innerHeight) - foot.offsetHeight;
root.style.setProperty("--lstk-view", `${Math.max(240, view)}px`);
};
window.addEventListener("resize", fit);
const cp = opt.copy;
let key = "";
let shown: string | null = null; // 연 키(같은 키 다시 그림 = 안 읽음 · 탭 바꿈 = force)
let saved = "";
let doc: StackDoc | null = null;
let answer: DocAnswer | null = null;
let cache = createDocCache();
let given: Record<string, unknown> = {};
let cut: Docs = {};
let mark: CopyMark | undefined;
let version = "";
let seq = 0;
let asked = "";
let ctl: AbortController | null = null;
const tables = new Map<string, Promise<DocTable | null>>(); // 표 고르기(문서 열 때 비움)
const opened = new Set<string>(); // 산출근거 펼친 자리
let paired = ""; // 짝 강조 자리(679 — 항목 `k` · 줄 `k.j`)
const blocks = new Set<string>(); // 풀린 공식 묶음
const call = async <T>(path: string, body?: unknown, signal?: AbortSignal): Promise<T> => {
const r = await fetch(`${API_BASE_URL}${cp ? cp.route(key, path) : opt.base + path}`, {
method: body === undefined ? "GET" : "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
signal,
});
const data = (await r.json().catch(() => ({}))) as T & { detail?: unknown };
if (!r.ok) {
const why = data.detail;
throw Object.assign(new Error(typeof why === "string" ? why : r.statusText), {
status: r.status,
});
}
return data;
};
// ── 캐시 ──
const drafts = (): Record<string, FormulaDoc> => {
try {
return JSON.parse(sessionStorage.getItem(opt.cache) ?? "{}") as Record<string, FormulaDoc>;
} catch {
return {};
}
};
const dirty = (): boolean => !!doc && JSON.stringify(doc) !== saved;
const keep = (): void => {
const all = drafts();
if (dirty()) all[key] = doc!;
else delete all[key];
try {
sessionStorage.setItem(opt.cache, JSON.stringify(all));
} catch {
/* 캐시가 막혀도 화면 안 고침은 남음 */
}
opt.page?.();
};
// ── 셈 ──
const usable = (): Record<string, unknown> => {
const out: Record<string, unknown> = {};
for (const v of doc?.변수 ?? []) {
const k = v.열쇠;
if (!k || !(k in given)) continue;
const kind = v.연결?.갈래;
if (kind === "입력") out[k] = given[k];
if (kind === "선택" && (v.연결?.목록 ?? []).some((x) => String(x) === String(given[k])))
out[k] = (v.연결!.목록 ?? []).find((x) => String(x) === String(given[k]));
}
return out;
};
const recalc = (): void => {
if (!doc) return;
answer = calcDoc(doc, cache.links, usable());
if (cache.takeMissing()) ask();
};
/** 캐시에 없는 연결 — 뒤에서 서버 기록을 받아 더함 */
const ask = (): void => {
const snap = JSON.stringify([doc, usable()]);
if (snap === asked) return;
asked = snap;
ctl?.abort();
ctl = new AbortController();
const mine = ++seq;
void call<{ 연결: DocRecord }>(
"/preview",
cp ? { 문서: doc, 끊음: cut } : { 문서: key ? doc : { ...doc, 키: "_새" }, 입력: usable() },
ctl.signal,
)
.then((got) => {
if (mine !== seq) return;
cache.merge(got.연결);
cache.merge({ 문서: cut });
recalc();
draw();
})
.catch(() => undefined);
};
const subDoc = (k: string): FormulaDoc | null => {
try {
return cache.links.doc(k) ?? null;
} catch {
if (cache.takeMissing()) ask();
return null;
}
};
/** 표 하나(`/table`) — 받은 표는 계산기 캐시에도 */
const tableOf = (k: string): Promise<DocTable | null> => {
if (!tables.has(k))
tables.set(
k,
call<DocTable & { 끊김?: string }>(`/table?key=${encodeURIComponent(k)}`)
.then((t) => {
if (t.끊김) return null;
cache.putTable(k, t);
return t;
})
.catch(() => null),
);
return tables.get(k)!;
};
/** 고침 — 문서 · 차림을 고친 뒤 문서로 굳힘 */
const change = (fn: (d: StackDoc, items: StackItem[]) => void): void => {
if (!doc) return;
const items = stackOf(doc);
fn(doc, items);
doc = docOf(doc, items);
keep();
recalc();
draw();
};
/** 짝 강조 — 왼쪽 카드 · 오른쪽 줄(같은 자리) · from = 누른 쪽(반대쪽을 보이는 자리로) */
const pair = (loc: string, from?: "left" | "right"): void => {
paired = loc;
for (const x of root.querySelectorAll(".is-pair")) x.classList.remove("is-pair");
if (!loc) return;
const sel = `[data-loc="${CSS.escape(loc)}"]`;
const l = left.querySelector<HTMLElement>(sel);
const r = [...right.querySelectorAll<HTMLElement>(sel)];
l?.classList.add("is-pair");
r.forEach((x) => x.classList.add("is-pair"));
if (from === "right") l?.scrollIntoView({ block: "nearest", behavior: "smooth" });
if (from === "left") r[0]?.scrollIntoView({ block: "nearest", behavior: "smooth" });
};
/** 복사해 펼침(804-4) — 서버 조각 → 새 항목(고침 = 캐시 · [저장] 때 서버가 다시 셈) */
const takeIn = async (src: string): Promise<void> => {
try {
const got = await call<Taken>(`/take?src=${encodeURIComponent(src)}`);
change((d, items) => addTaken(d, items, got));
} catch (e) {
showToast(`${lt("Take_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
}
};
const takeCtx = () =>
cp && opt.scope && key
? { ...opt.scope, me: key, take: (src: string) => void takeIn(src) }
: undefined;
// ── 그림 ──
const edit = (): boolean => opt.admin && (!!key || !cp);
const draw = (): void => {
if (!doc || !answer) {
[head, left, right, foot].forEach((x) => x.replaceChildren());
return;
}
const options = buildOptions(doc, given, !!cp && !opt.admin, (k, v, x) => {
const at = doc?.변수?.indexOf(v) ?? -1;
if (cp) return change((d) => at >= 0 && (d.변수![at].연결 = { ...v.연결!, 값: x }));
given[k] = x;
recalc();
draw();
});
const busy = dirty();
const tools = [
...(cp && edit() && key ? copyTools(true, (job) => void runJob(job)) : []),
...(edit() && key && !opt.page
? [
createButton({
label: ft("Discard"),
variant: "ghost",
size: 28,
disabled: !busy,
onClick: () => void discard(),
}),
createButton({
label: ft("Save"),
variant: "filled",
size: 28,
disabled: !busy,
onClick: () => void save(),
}),
]
: []),
];
head.replaceChildren(
...(options.length ? [el("div", { className: "fdoc__options", children: options })] : []),
el("div", {
className: "lstk__bar",
children: [
el("div", {
className: "lstk__badges",
children: [
...(busy ? [createBadge(ft("Dirty"), "warning")] : []),
...(cp ? copyBadges(mark, opt.admin) : []),
],
}),
el("div", { className: "lstk__tools", children: tools }),
],
}),
);
const items = stackOf(doc);
root.dataset.edit = edit() ? "1" : "";
left.replaceChildren(
...(edit()
? [
buildEdit({
doc,
answer,
items,
change,
table: tableOf,
subDoc,
name: (k) => cache.names.get(k) ?? "",
open: blocks,
pair: (loc) => pair(loc, "left"),
take: takeCtx(),
}),
]
: []),
);
right.replaceChildren(
items.length || doc.결과
? buildOut(doc, answer, {
cache,
open: opened,
toggle: (at) => {
if (opened.has(at)) opened.delete(at);
else opened.add(at);
draw();
},
miss: ask,
pair: (loc) => pair(loc, "right"),
})
: el("p", { className: "ui-text ui-text--muted", text: lt("Empty") }),
);
foot.replaceChildren(...(doc.결과 ? [] : [docFoot(answer)]));
pair(paired);
fit();
};
const take = (got: Loaded): void => {
cache.merge(got.연결);
saved = JSON.stringify(got.문서);
doc = clone(got.문서);
cut = clone(got.끊음 ?? {});
mark = got.사본;
version = got.판 ?? "";
};
/** 사본 [단가 갱신] · [재생성] — 미리 셈 → 알림 → 저장(고친 것 먼저 저장 · 버림) */
const runJob = async (job: CopyJob): Promise<void> => {
if (!doc || !cp) return;
if (dirty()) return void showToast(ft("Dirty_First"), "warning");
showLoadingOverlay();
let got: JobReply;
try {
got = await call<JobReply>("/preview", { 할일: job });
} catch (e) {
hideLoadingOverlay();
return void showToast(
`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`,
"error",
);
}
hideLoadingOverlay();
if (await askJob(job, got, cache.names)) await save({ 할일: job, 판: version }, ft("Job_Done"));
};
let loading: Promise<void> = Promise.resolve();
const save = async (job?: Record<string, unknown>, done = ft("Saved")): Promise<boolean> => {
await loading; // 새 로직 저장 뒤 여는 중이면 다 읽고(캐시 이어 받음)
if (!doc || (!job && !dirty())) return true;
const send = job ?? (cp ? { 문서: doc, 끊음: cut, 판: version } : { 문서: doc });
const was = saved;
showLoadingOverlay();
try {
const got = await call<Loaded>("/save", send).catch(async (e: { status?: number }) => {
// 사본 판 바뀜(페이지 [저장] 등) — 문서가 받은 그대로면 새 판으로 한 번 더
if (!cp || e.status !== 409) throw e;
const now = await call<Loaded>("");
if (JSON.stringify(now.문서) !== was) throw e;
return call<Loaded>("/save", { ...send, 판: (version = now.판 ?? "") });
});
take(got);
keep();
recalc();
draw();
if (cp && got.판) cp.onSaved?.(got.판);
if (done) showToast(done, "success");
return true;
} catch (e) {
showToast(`${ft("Save_Failed")} — ${e instanceof Error ? e.message : ""}`, "error");
return false;
} finally {
hideLoadingOverlay();
}
};
const discard = async (): Promise<void> => {
if (!doc || !dirty()) return void draw();
doc = JSON.parse(saved) as StackDoc;
keep();
recalc();
draw();
};
/** 문서 열기 — 서버 · 캐시에서 다시 읽음(수식 탭 고침 · 저장을 이어 받음) */
const open = async (next: string): Promise<void> => {
const was = key;
shown = next;
if (next !== key) {
paired = "";
opened.clear();
blocks.clear();
given = {};
}
key = next;
doc = answer = null;
asked = "";
cache = createDocCache();
tables.clear();
[cut, mark, version] = [{}, undefined, ""];
draw();
if (!next) {
if (cp)
return void right.replaceChildren(
el("p", { className: "ui-text ui-text--muted", text: lt("Unsaved_New") }),
);
doc = (drafts()[""] as StackDoc | undefined) ?? blankDoc();
saved = JSON.stringify(blankDoc());
recalc();
return draw();
}
const mine = ++seq;
try {
const got = await call<Loaded>(cp ? "" : `?key=${encodeURIComponent(next)}`);
if (mine !== seq || key !== next) return;
take(got);
const all = drafts();
if (!was && got.새 && all[""] && !all[next]) {
all[next] = { ...all[""], 키: next, 이름: got.문서.이름, 결과단위: got.문서.결과단위 };
delete all[""];
sessionStorage.setItem(opt.cache, JSON.stringify(all));
}
if (all[next]) doc = clone(all[next] as StackDoc);
recalc();
draw();
opt.page?.(); // 캐시에서 이어 받은 고침 — 페이지 [저장] · [되돌림] 켬
} catch (e) {
if (mine === seq)
head.replaceChildren(
el("p", {
className: "ui-text ui-text--muted",
text: `${ft("Load_Failed")} — ${e instanceof Error ? e.message : ""}`,
}),
);
}
};
const canLeave = async (): Promise<boolean> => {
if (!dirty()) return true;
if (!(key in drafts())) return true; // 다른 화면에서 이미 버림
if (!(await showConfirmDialog(ft("Leave"), ft("Leave_Ok")))) return false;
doc = JSON.parse(saved) as StackDoc;
keep();
return true;
};
return {
root,
/** 로직 · SK 키로 그림(빈 키 = 저장 전 새 것) — 같은 키는 그대로 · `force` = 다시 읽음(탭 바꿈) */
draw: (next: string, force = false): void => {
if (force || next !== shown) loading = open(next);
},
canLeave,
dirty,
/** 페이지 [저장](804-7) — 고침 있으면 서버 정본 저장 · 알림은 페이지가(`done` 빈 글) · 실패 = false */
save: (): Promise<boolean> => save(undefined, ""),
/** 페이지 [되돌림] — 저장 뒤 상태로(캐시 지움) */
discard: (): Promise<void> => discard(),
/** 시험 · 확인용 */
state: () => ({ doc, answer, dirty: dirty(), items: doc ? stackOf(doc) : [] }),
};
}
+152
View File
@@ -0,0 +1,152 @@
/* =============================================================================
* line_stack_tex.ts
* 줄 쌓기 산출근거 수식 조판(PLAN 804-2 · 요구 677) — 식 나무 → TeX → KaTeX(MIT) · 분수 · 첨자 · 괄호
* 기호 식(side "sym") = 기호(첨자) · 값 식(side "val") = 값(모르면 기호 빨강) · 만약 = 탄 갈래만 · 반올림 · 올림 · 버림 감싸개 숨김
* 금액 줄 꼴(`inline`) = ÷ 한 줄(경쟁사 「노무비 : 59,020 ÷ Q」) · 공식 줄 = 분수
* 규칙은 수식 탭 그림(`formula_doc_render.ts` renderNode)과 같음 — 셈 정본은 계산기
* ========================================================================== */
import katex from "katex";
import "../config/node_modules/katex/dist/katex.min.css";
import { eqText } from "@ui/ui_template_equation";
import { splitId, type Node } from "../common_util/common_util_formula_doc";
import { isNum } from "../common_util/common_util_formula_doc_calc";
import { leafNum, takenOf, type Values } from "../common_util/common_util_formula_doc_view";
export interface TexCtx {
side: "sym" | "val";
vals: Values;
inline?: boolean;
/** 묶음 값의 비목(기계 · 단가산출 줄 노 · 재 · 경) */
cost?: string;
/** 번지 읽는 이름(804-5) — 「번지.복사N.노무비」 → 「항목 이름 노무비」 */
place?: Record<string, string>;
/** 표 값 기호 화면 이름(요구 683 — 「Q_수량2^표」 → 「Q_작업반장」) */
alias?: Record<string, string>;
}
const HIDE = new Set(["반올림", "올림", "버림"]);
const OPS: Record<string, string> = {
"+": "+",
"-": "-",
"*": "\\times",
"/": "\\div",
"<": "<",
"<=": "\\le",
">": ">",
">=": "\\ge",
"==": "=",
"!=": "\\ne",
그리고: "\\;\\text{그리고}\\;",
또는: "\\;\\text{또는}\\;",
};
const PREC: Record<string, number> = {
또는: 1,
그리고: 2,
"<": 3,
"<=": 3,
">": 3,
">=": 3,
"==": 3,
"!=": 3,
"+": 4,
"-": 4,
"*": 5,
"/": 5,
};
const prec = (n: Node): number => (n[0] === "bin" ? PREC[n[1]] : n[0] === "neg" ? 6 : 9);
/** TeX 글 안전 — \text{} 안 */
const txt = (s: string): string => `\\text{${s.replace(/[\\{}$&#^_%~]/g, (c) => `\\${c}`)}}`;
const word = (s: string): string =>
/^[A-Za-z]$/.test(s) ? s : /^[A-Za-z]+$/.test(s) ? `\\mathit{${s}}` : txt(s);
/** 기호 글(`Q_작업^표`) → TeX(첨자) */
export function symTex(id: string): string {
const [s, lo, up] = splitId(id);
return `${word(s)}${lo ? `_{${word(lo)}}` : ""}${up ? `^{${word(up)}}` : ""}`;
}
/** 값 → TeX(천 자리 쉼표) */
export const valTex = (v: unknown): string => {
const t = eqText(v);
return isNum(v) ? t.replace(/,/g, "{,}") : txt(t);
};
/** 칸 값 — `번지.복사1.노무비` 처럼 겹친 칸까지 */
const look = (n: Node, vals: Values): unknown => {
if (n[0] === "var") return vals[n[1]];
if (n[0] !== "field") return undefined;
const v = look(n[1], vals);
return v && typeof v === "object" ? (v as Values)[n[2]] : undefined;
};
const paren = (t: string): string => `\\left(${t}\\right)`;
const miss = (t: string): string => `\\htmlClass{lstk-miss}{${t}}`;
export function tex(n: Node, c: TexCtx): string {
const go = (x: Node): string => tex(x, c);
switch (n[0]) {
case "num":
return valTex(n[1]);
case "str":
return txt(`'${n[1]}'`);
case "var": {
const id = c.alias?.[n[1]] ?? n[1];
if (c.side === "sym") return symTex(id);
let v = c.vals[n[1]];
if (v && typeof v === "object" && c.cost) v = (v as Values)[c.cost];
return v === null || v === undefined || typeof v === "object" ? miss(symTex(id)) : valTex(v);
}
case "field": {
const [, of, key] = n;
if (c.side === "val") {
const got = look(n, c.vals);
if (isNum(got)) return valTex(got);
}
if (of[0] === "field" && of[1][0] === "var" && of[1][1] === "번지" && c.place?.[of[2]])
return txt(`${c.place[of[2]]} ${key}`);
return `${n[1][0] === "var" ? symTex(n[1][1]) : go(n[1])}.${txt(n[2])}`;
}
case "neg":
return `-${n[1][0] === "bin" ? paren(go(n[1])) : go(n[1])}`;
case "pct":
return `${prec(n[1]) < 9 ? paren(go(n[1])) : go(n[1])}\\,\\%`;
case "call": {
const [, name, items] = n;
if (name === "만약") {
const pick = takenOf(n, c.vals);
if (pick) return go(items[pick]);
}
if (HIDE.has(name) && items.length >= 1 && items.length <= 2) return go(items[0]);
const fn = name === "최소" ? "\\min" : name === "최대" ? "\\max" : txt(name);
return `${fn}${paren(items.map(go).join(",\\,"))}`;
}
case "bin": {
const [, o, a, b] = n;
const na = leafNum(a, c.vals);
const nb = leafNum(b, c.vals);
if ((o === "+" || o === "-") && nb === 0) return go(a);
if ((o === "*" || o === "/") && nb === 1) return go(a);
if ((o === "+" && na === 0) || (o === "*" && na === 1)) return go(b);
if (o === "/" && !c.inline) return `\\dfrac{${go(a)}}{${go(b)}}`;
const mine = PREC[o];
const left = prec(a) < mine || (mine === 3 && prec(a) === 3) ? paren(go(a)) : go(a);
const right = prec(b) <= mine ? paren(go(b)) : go(b);
return `${left} ${OPS[o] ?? txt(o)} ${right}`;
}
}
}
/** TeX → 조판 요소(못 그리면 글 그대로) */
export function katexNode(t: string): HTMLElement {
const box = document.createElement("span");
box.className = "lstk-tex";
katex.render(t, box, {
throwOnError: false,
strict: "ignore",
trust: (x) => x.command === "\\htmlClass",
output: "html",
});
return box;
}
+78
View File
@@ -0,0 +1,78 @@
/* =============================================================================
* line_stack_text.ts
* 줄 쌓기 화면 글(PLAN 804-2) — [국문, 영문] · 수식 탭과 같은 글은 `formula_doc_text.ts`
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
const TEXT = {
Tab: ["줄 쌓기", "Line stack"],
Basis: ["산 출 근 거", "Basis"],
Total: ["합 계", "Total"],
Sum: ["계", "Sum"],
Sub_Sum: ["소 계", "Subtotal"],
Add_Item: ["+ 그룹", "+ Group"],
Add_Line: ["줄 더함", "Add line"],
Add_율: ["율 — 그룹 소계의 %", "Rate — % of subtotal"],
Add_식: ["식 — 공식 틀", "Formula"],
Del_Item: ["그룹 지움", "Delete group"],
Del: ["지움", "Delete"],
Item: ["그룹", "Group"],
K_줄: ["줄", "Line"],
Drag: ["끌어 옮기기", "Drag to move"],
Fold_Item: ["그룹 접기", "Collapse group"],
Unfold_Item: ["그룹 펼치기", "Expand group"],
Unfold_Sub: ["시간당 경비 · 산출 펼치기", "Show breakdown"],
Fold_Sub: ["접기", "Hide breakdown"],
Dropped: ["지움 — 눌러 되돌리기", "deleted — click to undo"],
Undo_Late: ["그 뒤 고친 것이 있어 못 되돌림", "Changed since — cannot undo"],
Title: ["그룹 이름", "Group title"],
K_인력: ["인력", "Labor"],
K_재료: ["재료", "Material"],
K_기계: ["기계", "Machine"],
K_단가산출: ["단가산출", "Unit price"],
K_복사: ["단가산출 · 일위대가 복사", "Copy unit price · combo"],
Self_Take: ["지금 단가산출 자신은 못 가져옴", "Cannot copy itself"],
Take_Failed: ["복사 못 함", "Copy failed"],
K_율: ["율", "Rate"],
K_공식: ["공식", "Formula"],
K_표값: ["표 값", "Table values"],
K_글: ["근거 글", "Basis note"],
Name: ["이름", "Name"],
Unit: ["단위", "Unit"],
Cost: ["비목", "Cost"],
Qty: ["수량", "Qty"],
Price: ["단가", "Price"],
Machine: ["기계", "Machine"],
Find: ["찾기", "Find"],
Pick: ["고름 ▾", "Pick ▾"],
None_Found: ["찾은 것 없음", "Nothing found"],
Unfold: ["식으로 풀기", "Unfold"],
Edit_Hint: ["눌러 고침", "Click to edit"],
Add_Row: ["식 줄 더함", "Add row"],
New_Formula: ["새 공식", "New formula"],
My_Formula: ["내 공식", "My formula"],
My_Tpl: ["내 틀", "My template"],
Changed_From: ["에서 변경", "changed"],
Row_Name: ["이름", "Name"],
Row_Expr: ["값 또는 식", "Value or expr"],
Bad_Name: ["못 쓰는 이름 · 같은 이름 있음", "Bad or duplicate name"],
Empty_Item: ["줄 없음 — [+]", "No lines — [+]"],
Empty: ["빈 단가산출 — [+ 그룹] 으로 시작", "Empty — start with [+ Group]"],
Text_Ph: ["< 품셈 근거 > · ※ 메모 · 별산", "Note"],
Unsaved_New: ["저장한 단가산출만 — 페이지 [저장] 뒤", "Saved unit prices only"],
Find_Title: ["찾아 넣기", "Find and add"],
Repick: ["찾아 바꾸기", "Find and change"],
Close: ["닫기", "Close"],
Tbl: ["표", "Table"],
Tbl_Missing: ["표를 못 읽음", "Table not found"],
Tbl_Hint: ["값 칸을 누르면 그 값이 들어감", "Click a value cell to use it"],
Tbl_Bound: [
"이 값의 줄은 위 옵션이 고름(다른 줄은 옵션에서 바꿈 · 여기선 칸만)",
"Row follows the option above (change it there; pick a column here)",
],
} as const;
export type LsKey = keyof typeof TEXT;
export const lt = (key: LsKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
+62 -17
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* m_settings_nav.ts
* 시스템 설정 바로가기(334 · 요구 225-2) — 대시보드 「시스템 설정」 카드 · M01 · M02 ☰ 가 같이 쓰는
* 역할 규칙 한 벌(`settingLinks`) · ☰ 진행 단계 자리에 🏠 대시보드 + 카드(`mountSettingsDrawer`).
* 시스템 설정 바로가기(334 · 요구 225-2 · 716-2) — 대시보드 설계 기본값 줄(`settingLinks` · 칸마다 한 줄 ·
* `?open=`) / M01 · M02 ☰ 메뉴(`pageLinks` · 페이지 둘 · `mountSettingsDrawer`) /
* 진행 판 없는 쪽 ☰ 기본 목록(`defaultSettingsBar` · 772-1).
* ========================================================================== */
import { ROUTES, type RoutePath } from "@config/config_frontend";
@@ -16,52 +17,65 @@ interface SettingUser {
company_id?: number | null;
}
/** key = M01 은 「m01」 · 계산 자리 창은 「calcSites」(대시보드 카드만 · 504-2) · M02 는 진입 인자 `open` */
/** key = 페이지 열쇠(☰ · 「m01」 「m02」) 또는 칸 열쇠(대시보드 줄 · 계산 자리 「calcSites」 = 창 504-2) ·
* group = 대시보드 설계 기본값 묶음 제목 */
export interface SettingLink {
key: string;
title: string;
desc: string;
route: RoutePath;
query?: Record<string, string>;
group?: string;
}
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
const ICONS: Record<string, string> = { m01: "🗄️", 구조물조합: "🧱", 옵션: "🎛️", 도면: "📑" };
const ICONS: Record<string, string> = { m01: "🗄️", m02: "🧱" };
/** 시스템 설정 — 역할로 카드 숨김 · M02 카드는 `?open=&root=` 로 진입(27-1d) */
/** 설계 기본값 줄(대시보드) — 칸마다 한 줄 · 역할로 숨김 · M01 줄 `?open=`(칸 고름) · M02 줄 `?open=&root=`(27-1d).
* 칸을 더할 때는 아래 표에 한 줄(m01 / m02 호출)만 더함. */
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 m01 = (key: string, title: string, desc: string, open?: string): SettingLink => ({
key,
title,
desc,
route: ROUTES.M01_MASTER_DATA,
query: open ? { open } : undefined,
group: "마스터 데이터",
});
const m02 = (title: string, open: string, root: string, desc: string): SettingLink => ({
key: open,
title,
desc,
route: ROUTES.M02_MASTER_TEMPLATE,
query: { open, root, ...company },
group: "마스터 템플릿",
});
// 구조물 조합 기본 층 = 개인(123-1 · 누구든) · 개인 층 없음(회사 없음) = 아래 역할 규칙
const comboRoot = (fallback: string): string => (user.company_id ? "personal" : fallback);
if (user.role === "SYSTEM_ADMIN") {
const desc = L("B01_Dashboard_PermMasterWrite");
const read = L("B01_Dashboard_PermSourceRead");
return [
{
key: "m01",
title: L("B01_Dashboard_MasterElements"),
desc: L("B01_Dashboard_PermSourceRead"),
route: ROUTES.M01_MASTER_DATA,
},
m01("m01", L("M01_Title"), read),
m01("로직", L("B01_Dashboard_CardLogic"), read, "로직"),
m01("옵션", L("B01_Dashboard_CardStructOptionDef"), desc, "옵션"),
m01("별칭", L("B01_Dashboard_CardAliasLedger"), desc, "별칭"),
m02(L("B01_Dashboard_CardSangeun"), "단가산출", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardPrecombo"), "일위대가조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot("master"), desc),
m02(L("B01_Dashboard_CardStructOption"), "옵션", "master", desc),
m02(L("B01_Dashboard_CardDrawing"), "도면", "master", desc),
{
key: "calcSites",
title: L("B01_CalcSites_Title"),
desc: L("B01_CalcSites_Desc"),
route: ROUTES.B01_ACCOUNT,
group: "계산",
},
];
}
@@ -76,15 +90,47 @@ export function settingLinks(user: SettingUser, companyId: number | string | nul
return [m02(L("B01_Dashboard_CardStructCombo"), "구조물조합", comboRoot(root), desc)];
}
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 카드 · 지금 칸 강조 · 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
/** ☰ 메뉴 — 페이지 둘만(M01 = 시스템 관리자 · M02 = 누구나) · 열쇠 「m01」 「m02」 */
export function pageLinks(user: SettingUser, companyId: number | string | null): SettingLink[] {
const combo = settingLinks(user, companyId).find((link) => link.key === "구조물조합");
const links: SettingLink[] = [];
if (user.role === "SYSTEM_ADMIN")
links.push({
key: "m01",
title: L("B01_Dashboard_MasterElements"),
desc: "",
route: ROUTES.M01_MASTER_DATA,
});
if (combo)
links.push({ ...combo, key: "m02", title: L("M02_Title"), desc: "", group: undefined });
return links;
}
/** ☰ 진행 단계 자리 — 🏠 대시보드 + 페이지 둘 · 지금 페이지 강조(`activeKey` = 「m01」 「m02」) ·
* 같은 길(M02 → M02)은 머리 가드가 안 물어 여기서 먼저 */
export function mountSettingsDrawer(
user: SettingUser,
activeKey: string,
companyId: number | string | null,
): void {
// 계산 자리 = 대시보드 창(504-2) — 페이지가 아니라 ☰ 에 안 둠
const links = settingLinks(user, companyId).filter((link) => link.key !== "calcSites");
const bar = createStepBar(
setDrawerSteps(settingsBar(user, activeKey, companyId));
}
/** ☰ 기본 목록(772-1) — 진행 판 · 바로가기를 안 세운 로그인 뒤 쪽(B02 · D01 · E01 등) = 🏠 대시보드 + 권한별 페이지 둘 ·
* 로그인 전 = 없음 */
export async function defaultSettingsBar(): Promise<HTMLElement | null> {
const { fetchSessionUser } = await import("../A06_Login/A06_Login_Api_Fetch");
const user = await fetchSessionUser().catch(() => null);
return user ? settingsBar(user, "", null) : null;
}
function settingsBar(
user: SettingUser,
activeKey: string,
companyId: number | string | null,
): HTMLElement {
const links = pageLinks(user, companyId);
return createStepBar(
links.map((link) => link.title),
links.findIndex((link) => link.key === activeKey),
{
@@ -95,7 +141,6 @@ export function mountSettingsDrawer(
onStepClick: (index) => void go(links[index]),
},
);
setDrawerSteps(bar);
}
async function go(link: SettingLink): Promise<void> {
+434
View File
@@ -0,0 +1,434 @@
/* =============================================================================
* map_guide_route.ts(logic)
* 예정노선 가이드 추천선 셈(PLAN 636-1 · 요구 546) — 기준지점을 차례로 거치는 참고용 선.
* 화면 가이드 전용 · 파이썬 짝 없음 · 저장 안 함(설계 값 아님 · 선례 `D03_PlanAnalysis_Fan.ts:8`).
* 2 m 격자 A*: 걸음 = 반지름 걸음/칸 ± 0.5 격자점(20 m = 56 방향) · |Δz| > 상한 × 걸음 길이 막음 ·
* 비용 = 길이 × (1 + 급경사 × 급경사 표본 비율 + 기울기 × (g/상한)²) + 꺾임 × 걸음 × (꺾임각/π)² ·
* 어림 = 직선 거리 × 가중(가중 A*) · 구간마다 상자 = 두 점 + 여유(못 닿으면 두 배 한 번) · 펼침 상한.
* 첫 걸음 = 정확한 시작점에서 · 끝 걸음도 기울기 검사 · 꼭짓점 그대로(20 m 다시 나누기 금지).
* 1 m 격자 = 2 칸 솎아 2 m · 급경사 = 격자 높이에서 바로 셈 · 못 닿음 = 가장 가까이 간 데까지.
* 근거 = `docs/raw/요구_1101/_분석_546-C_추천노선_라이다.md` C1 · DOM · fetch · three 없음.
* ========================================================================== */
import {
heightAt as gridHeightAt,
type DtmGrid,
} from "../../../common_util/common_util_surface_sampler";
export type Point2 = [number, number];
export type Point3 = [number, number, number];
/** 높이 원천 — heightAt(NaN = 밖 · 빈칸) · 원천 격자 칸(m) · 격자 원점(칸 꼭짓점 맞춤). */
export interface GuideTerrain {
heightAt: (x: number, y: number) => number;
/** 원천 격자 칸 m(기본 2) — 2 m 보다 잘면 솎아 2 m 안팎으로. */
cell?: number;
origin?: Point2;
}
export interface GuideWeights {
/** 급경사 표본 비율 벌점(기본 3) */
steep: number;
/** (g/상한)² 벌점(기본 0.3) */
grade: number;
/** 꺾임 벌점(기본 1) */
turn: number;
/** 어림 가중(기본 3 · 1 = 정확 A*) */
heuristic: number;
}
export interface GuideOptions {
/** 걸음 m(기본 20 · 그리기 「길이·기울기 고정」 칸) */
step?: number;
/** 기울기 상한 비율(기본 0.14) */
maxGrade?: number;
/** 급경사 문턱 %(기본 70 = 35° · 급경사 겹 둘째 경계) */
steepPct?: number;
weights?: Partial<GuideWeights>;
/** 구간마다 펼침 상한(기본 30 만 ≈ 0.4 s · 못 닿음 ≤ 0.5 s) */
maxExpand?: number;
/** 구간마다 시간 상한 ms(기본 450 · 못 닿음 ≤ 0.5 s · 느린 기계 막음) */
maxMs?: number;
}
export interface GuideSegment {
/** 꼭짓점 [x, y, z] — 닿음이면 끝 = 다음 지점 · 아니면 가장 가까이 간 데까지 */
vertices: Point3[];
reached: boolean;
/** 평면 길이 m */
length: number;
/** 걸음 가운데 가장 큰 |기울기| 비율 */
maxGrade: number;
/** 급경사(문턱 이상) 지나는 길이 비율 0~1 */
steepRatio: number;
expanded: number;
}
export interface GuideResult {
segments: GuideSegment[];
ms: number;
}
export const GUIDE_DEFAULTS = {
step: 20,
maxGrade: 0.14,
steepPct: 70,
weights: { steep: 3, grade: 0.3, turn: 1, heuristic: 3 } as GuideWeights,
maxExpand: 300_000,
maxMs: 450,
};
const MARGIN_MIN = 200;
const MARGIN_MAX = 800;
const STEEP_SAMPLES = [0.25, 0.5, 0.75, 1];
/** 서버 지형 격자(`parseDtmGrid`) → 가이드 높이 원천(원천 칸 · 원점 그대로). */
export function guideTerrainFromGrid(grid: DtmGrid): GuideTerrain {
const n = grid.x.length;
const cell = n > 1 ? (grid.x[n - 1] - grid.x[0]) / (n - 1) : 2;
return {
heightAt: (x, y) => gridHeightAt(grid, x, y),
cell,
origin: [grid.x[0], grid.y[0]],
};
}
/** 지점 차례로 잇는 가이드 선 — 지점 사이 구간마다 A*. */
export function guideRoute(
points: Point2[],
terrain: GuideTerrain,
options: GuideOptions = {},
): GuideResult {
const started = performance.now();
const segments: GuideSegment[] = [];
for (let index = 0; index + 1 < points.length; index++) {
segments.push(guideSegment(points[index], points[index + 1], terrain, options));
}
return { segments, ms: performance.now() - started };
}
/** 두 점 사이 가이드 선 하나. */
export function guideSegment(
start: Point2,
goal: Point2,
terrain: GuideTerrain,
options: GuideOptions = {},
): GuideSegment {
const step = options.step ?? GUIDE_DEFAULTS.step;
const gmax = options.maxGrade ?? GUIDE_DEFAULTS.maxGrade;
const weights = { ...GUIDE_DEFAULTS.weights, ...options.weights };
const steepGrade = (options.steepPct ?? GUIDE_DEFAULTS.steepPct) / 100;
const source = terrain.cell ?? 2;
const cell = source * Math.max(1, Math.round(2 / source));
const origin = terrain.origin ?? [0, 0];
const za = terrain.heightAt(start[0], start[1]);
const zb = terrain.heightAt(goal[0], goal[1]);
if (!Number.isFinite(za) || !Number.isFinite(zb)) {
return measure([[start[0], start[1], za]], false, 0, null);
}
const span = Math.hypot(goal[0] - start[0], goal[1] - start[1]);
const margin = Math.min(MARGIN_MAX, Math.max(MARGIN_MIN, span / 2));
const limit = options.maxExpand ?? GUIDE_DEFAULTS.maxExpand;
let spent = 0; // 두 상자 합친 펼침 수
let late = false;
const deadline = performance.now() + (options.maxMs ?? GUIDE_DEFAULTS.maxMs);
let edge = false; // 상자 끝에 닿음 = 넓히면 길이 있을 수 있음
let found = search(margin);
if (!found.reached && edge && spent < limit && !late) found = search(margin * 2);
found.expanded = spent;
return found;
function search(pad: number): GuideSegment {
const i0 = Math.floor((Math.min(start[0], goal[0]) - pad - origin[0]) / cell);
const j0 = Math.floor((Math.min(start[1], goal[1]) - pad - origin[1]) / cell);
const w = Math.ceil((Math.max(start[0], goal[0]) + pad - origin[0]) / cell) - i0 + 1;
const h = Math.ceil((Math.max(start[1], goal[1]) + pad - origin[1]) / cell) - j0 + 1;
const n = w * h;
const START = n;
const GOAL = n + 1;
const zs = new Float32Array(n).fill(Infinity); // Infinity = 아직 안 읽음 · NaN = 밖
const steepState = new Uint8Array(n); // 0 모름 · 1 급경사 · 2 아님
const g = new Float64Array(n + 2).fill(Infinity);
const parent = new Int32Array(n + 2).fill(-1);
const dir = new Float32Array(n + 2).fill(NaN);
const closed = new Uint8Array(n + 2);
const heap = new MinHeap();
const nx = (id: number) => origin[0] + (i0 + (id % w)) * cell;
const ny = (id: number) => origin[1] + (j0 + Math.floor(id / w)) * cell;
const zAt = (id: number): number => {
let value = zs[id];
if (value === Infinity) {
value = terrain.heightAt(nx(id), ny(id));
zs[id] = Number.isFinite(value) ? value : NaN;
}
return value;
};
const steepSq = (2 * cell * steepGrade) ** 2;
const steepAt = (id: number): boolean => {
if (steepState[id] === 0) {
const i = id % w;
const j = (id - i) / w;
if (i < 1 || j < 1 || i >= w - 1 || j >= h - 1) steepState[id] = 2;
else {
const gx = zAt(id + 1) - zAt(id - 1);
const gy = zAt(id + w) - zAt(id - w);
steepState[id] = gx * gx + gy * gy >= steepSq ? 1 : 2; // NaN = 아님
}
}
return steepState[id] === 1;
};
const radius = step / cell;
// 걸음 표 — 격자 차 · 길이 · 방위 · 급경사 표본 차(1/4 · 1/2 · 3/4 · 끝)
const offI: number[] = [];
const offJ: number[] = [];
const offLen: number[] = [];
const offDir: number[] = [];
const sampleD: number[] = []; // 칸 번호 차
const reach = Math.ceil(radius + 0.5);
for (let dj = -reach; dj <= reach; dj++) {
for (let di = -reach; di <= reach; di++) {
if (Math.abs(Math.hypot(di, dj) - radius) > 0.5) continue;
offI.push(di);
offJ.push(dj);
offLen.push(Math.hypot(di, dj) * cell);
offDir.push(Math.atan2(dj, di));
for (const t of STEEP_SAMPLES) {
sampleD.push(Math.round(dj * t) * w + Math.round(di * t));
}
}
}
const steepShare = weights.steep / STEEP_SAMPLES.length;
const turnWeight = (weights.turn * step) / (Math.PI * Math.PI);
// 걸음 비용 — 막히면 Infinity
const cost = (
ax: number,
ay: number,
az: number,
bx: number,
by: number,
bz: number,
inDir: number,
): number => {
const len = Math.hypot(bx - ax, by - ay);
const dz = Math.abs(bz - az);
if (!(dz <= gmax * len)) return Infinity;
let hits = 0;
for (const t of STEEP_SAMPLES) {
const si = Math.round((ax + (bx - ax) * t - origin[0]) / cell) - i0;
const sj = Math.round((ay + (by - ay) * t - origin[1]) / cell) - j0;
if (si >= 0 && sj >= 0 && si < w && sj < h && steepAt(sj * w + si)) hits++;
}
const grade = len > 0 ? dz / len / gmax : 0;
let value = len * (1 + steepShare * hits + weights.grade * grade * grade);
if (Number.isFinite(inDir) && len > 0) {
let turn = Math.abs(Math.atan2(by - ay, bx - ax) - inDir);
if (turn > Math.PI) turn = 2 * Math.PI - turn;
value += turnWeight * turn * turn;
}
return value;
};
const relax = (
from: number,
to: number,
c: number,
ax: number,
ay: number,
bx: number,
by: number,
) => {
if (!Number.isFinite(c) || g[from] + c >= g[to]) return;
g[to] = g[from] + c;
parent[to] = from;
dir[to] = Math.atan2(by - ay, bx - ax);
heap.push(g[to] + weights.heuristic * Math.hypot(goal[0] - bx, goal[1] - by), to);
};
// 끝 걸음 — 한 걸음 안이면 정확한 끝점으로(기울기 검사)
const tryGoal = (from: number, ax: number, ay: number, az: number) => {
if (Math.hypot(goal[0] - ax, goal[1] - ay) <= step + 0.5 * cell) {
relax(
from,
GOAL,
cost(ax, ay, az, goal[0], goal[1], zb, dir[from]),
ax,
ay,
goal[0],
goal[1],
);
}
};
g[START] = 0;
tryGoal(START, start[0], start[1], za);
// 첫 걸음 — 정확한 시작점에서 반지름 걸음 고리
const ci = Math.round((start[0] - origin[0]) / cell) - i0;
const cj = Math.round((start[1] - origin[1]) / cell) - j0;
for (let j = cj - reach - 1; j <= cj + reach + 1; j++) {
for (let i = ci - reach - 1; i <= ci + reach + 1; i++) {
if (i < 0 || j < 0 || i >= w || j >= h) continue;
const id = j * w + i;
const bx = nx(id);
const by = ny(id);
if (Math.abs(Math.hypot(bx - start[0], by - start[1]) - step) > 0.5 * cell) continue;
const bz = zAt(id);
if (Number.isNaN(bz)) continue;
relax(START, id, cost(start[0], start[1], za, bx, by, bz, NaN), start[0], start[1], bx, by);
}
}
let best = START;
let bestDist = span;
while (heap.size > 0 && spent < limit && !late) {
const u = heap.pop();
if (closed[u]) continue;
closed[u] = 1;
if (u === GOAL) return finish(GOAL, true);
spent++;
if ((spent & 1023) === 0 && performance.now() > deadline) late = true;
const ui = u % w;
const uj = Math.floor(u / w);
const ux = nx(u);
const uy = ny(u);
const uz = zs[u];
const left = Math.hypot(goal[0] - ux, goal[1] - uy);
if (left < bestDist) {
bestDist = left;
best = u;
}
tryGoal(u, ux, uy, uz);
const gu = g[u];
const du = dir[u];
for (let k = 0; k < offI.length; k++) {
const vi = ui + offI[k];
const vj = uj + offJ[k];
if (vi < 0 || vj < 0 || vi >= w || vj >= h) {
edge = true;
continue;
}
const v = vj * w + vi;
if (closed[v]) continue;
let vz = zs[v];
if (vz === Infinity) vz = zAt(v);
if (vz !== vz) continue; // NaN = 밖
const len = offLen[k];
const dz = Math.abs(vz - uz);
if (dz > gmax * len) continue;
let hits = 0;
for (let s = 4 * k; s < 4 * k + 4; s++) {
const at = u + sampleD[s];
const state = steepState[at];
if (state === 1 || (state === 0 && steepAt(at))) hits++;
}
const grade = dz / len / gmax;
let c = len * (1 + steepShare * hits + weights.grade * grade * grade);
if (du === du) {
let turn = Math.abs(offDir[k] - du);
if (turn > Math.PI) turn = 2 * Math.PI - turn;
c += turnWeight * turn * turn;
}
const next = gu + c;
if (next >= g[v]) continue;
g[v] = next;
parent[v] = u;
dir[v] = offDir[k];
const ex = goal[0] - (ux + offI[k] * cell);
const ey = goal[1] - (uy + offJ[k] * cell);
heap.push(next + weights.heuristic * Math.sqrt(ex * ex + ey * ey), v);
}
}
return finish(best, false);
function finish(last: number, reached: boolean): GuideSegment {
const chain: Point3[] = [];
for (let id = last; id !== -1; id = parent[id]) {
if (id === START) chain.push([start[0], start[1], za]);
else if (id === GOAL) chain.push([goal[0], goal[1], zb]);
else chain.push([nx(id), ny(id), zs[id]]);
}
chain.reverse();
return measure(chain, reached, 0, (x, y) => {
const si = Math.round((x - origin[0]) / cell) - i0;
const sj = Math.round((y - origin[1]) / cell) - j0;
return si >= 0 && sj >= 0 && si < w && sj < h && steepAt(sj * w + si);
});
}
}
// 길이 · 최대 기울기 · 급경사 지남 비율(걸음마다 칸 간격 표본)
function measure(
vertices: Point3[],
reached: boolean,
expanded: number,
steepAt: ((x: number, y: number) => boolean) | null,
): GuideSegment {
let length = 0;
let maxGrade = 0;
let steepLength = 0;
for (let k = 1; k < vertices.length; k++) {
const [ax, ay, az] = vertices[k - 1];
const [bx, by, bz] = vertices[k];
const len = Math.hypot(bx - ax, by - ay);
if (len <= 0) continue;
length += len;
maxGrade = Math.max(maxGrade, Math.abs(bz - az) / len);
if (!steepAt) continue;
const parts = Math.max(1, Math.ceil(len / cell));
for (let p = 0; p < parts; p++) {
const t = (p + 0.5) / parts;
if (steepAt(ax + (bx - ax) * t, ay + (by - ay) * t)) steepLength += len / parts;
}
}
return {
vertices,
reached,
length,
maxGrade,
steepRatio: length > 0 ? steepLength / length : 0,
expanded,
};
}
}
/** 이진 힙(값 작은 것 먼저) — 타입 배열 · 늘어남. */
class MinHeap {
private keys = new Float64Array(1024);
private ids = new Int32Array(1024);
size = 0;
push(key: number, id: number): void {
if (this.size === this.keys.length) {
const keys = new Float64Array(this.size * 2);
keys.set(this.keys);
const ids = new Int32Array(this.size * 2);
ids.set(this.ids);
this.keys = keys;
this.ids = ids;
}
let at = this.size++;
while (at > 0) {
const up = (at - 1) >> 1;
if (this.keys[up] <= key) break;
this.keys[at] = this.keys[up];
this.ids[at] = this.ids[up];
at = up;
}
this.keys[at] = key;
this.ids[at] = id;
}
pop(): number {
const top = this.ids[0];
const key = this.keys[--this.size];
const id = this.ids[this.size];
let at = 0;
for (;;) {
let child = 2 * at + 1;
if (child >= this.size) break;
if (child + 1 < this.size && this.keys[child + 1] < this.keys[child]) child++;
if (this.keys[child] >= key) break;
this.keys[at] = this.keys[child];
this.ids[at] = this.ids[child];
at = child;
}
this.keys[at] = key;
this.ids[at] = id;
return top;
}
}
@@ -1,10 +1,11 @@
/* =============================================================================
* D03_PlanAnalysis_SlopeBands.ts
* D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이).
* map_slope_bands.ts(logic)
* 공용 지도 급경사 범위(624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이).
* 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) ·
* 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저.
* 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`.
* 부르는 곳 = `D03_PlanAnalysis_UI_SlopeBands.ts` · `D03_PlanAnalysis_UI_LayerLegend.ts`.
* 수평 바(631) = 경계 한 줄 ↔ 범위 · 칸 나눔 · 합침 · 경계 검사.
* 부르는 곳 = `map_slope_bands.ts` · `map_legend.ts` · D03 `UI_LayerLegend`.
* ========================================================================== */
export interface SlopeBand {
@@ -96,3 +97,74 @@ export function bandRatios(counts: number[], bands: SlopeBand[]): number[] {
});
return out.map((n) => n / total);
}
/** 수평 바 꼴(631 · 요구 537) — 경계 = [0, …, 250] 한 줄 · 칸 i = [경계 i, 경계 i+1) · 색 빈 글 = 칠 안 함. */
export interface SlopeBar {
edges: number[];
colors: string[];
}
/** 범위 → 바(사이 빈 곳 = 칠 안 함 칸). */
export function bandsToBar(bands: SlopeBand[]): SlopeBar {
const edges = [0];
const colors: string[] = [];
for (const band of [...bands].sort((a, b) => a.low - b.low)) {
if (band.low > edges.at(-1)!) {
edges.push(band.low);
colors.push("");
}
edges.push(band.high);
colors.push(band.color);
}
if (edges.at(-1)! < SLOPE_MAX_PCT) {
edges.push(SLOPE_MAX_PCT);
colors.push("");
}
return { edges, colors };
}
/** 바 → 범위(칠 안 함 칸은 뺌). */
export const barToBands = (bar: SlopeBar): SlopeBand[] =>
bar.colors.flatMap((color, i) =>
color ? [{ low: bar.edges[i], high: bar.edges[i + 1], color }] : [],
);
/** 경계 고칠 거리 — 없으면 null(빈 값 · 0 < 값 < 250 밖 · 같은 값 · 거꾸로). */
export function checkEdges(edges: number[]): string | null {
for (let i = 1; i < edges.length - 1; i++) {
const at = `${i}번째 경계`;
if (!Number.isFinite(edges[i])) return `${at} — 값이 비었습니다`;
if (edges[i] <= 0 || edges[i] >= SLOPE_MAX_PCT)
return `${at} — 0 보다 크고 ${SLOPE_MAX_PCT} % 보다 작게`;
if (edges[i] === edges[i - 1]) return `${at} — 옆 경계와 같은 값입니다`;
if (edges[i] < edges[i - 1]) return `${at} — 왼쪽 경계보다 커야 합니다`;
}
return null;
}
const colored = (bar: SlopeBar): number => bar.colors.filter(Boolean).length;
/** 칸 i 를 반으로(위 반 = 새 색) — 못 나누면 null(색 칸 SLOPE_MAX_BANDS · 너무 좁음 · 경계 틀림). */
export function splitBar(bar: SlopeBar, i: number): SlopeBar | null {
if (colored(bar) >= SLOPE_MAX_BANDS || checkEdges(bar.edges)) return null;
const [low, high] = [bar.edges[i], bar.edges[i + 1]];
const mid = Math.round((low + high) * 5) / 10;
if (!(mid > low && mid < high)) return null;
const color =
SLOPE_COLORS.find((c) => !bar.colors.includes(c.color))?.color ??
SLOPE_COLORS[colored(bar) % SLOPE_COLORS.length].color;
return {
edges: [...bar.edges.slice(0, i + 1), mid, ...bar.edges.slice(i + 1)],
colors: [...bar.colors.slice(0, i + 1), color, ...bar.colors.slice(i + 1)],
};
}
/** 칸 i 를 이웃과 합침(왼쪽 · 첫 칸은 오른쪽 · 이웃 색 남음) — 칸 하나면 null. */
export function mergeBar(bar: SlopeBar, i: number): SlopeBar | null {
if (bar.colors.length <= 1) return null;
const cut = i > 0 ? i : 1;
return {
edges: bar.edges.filter((_, k) => k !== cut),
colors: bar.colors.filter((_, k) => k !== i),
};
}
+28
View File
@@ -0,0 +1,28 @@
/* 공용 긋기 방식 칸(626-3 · `map_draw_kind.ts`) — 고름 아래 길이 · 기울기 두 열 */
.map-draw-kind {
display: flex;
flex-direction: column;
gap: 6px;
}
.map-draw-kind__fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-8);
align-items: start;
}
.map-draw-kind__fields[hidden] {
display: none;
}
.map-draw-kind__fields .ui-field {
margin: 0;
min-width: 0;
}
.map-draw-kind__fields .ui-input-unit,
.map-draw-kind__fields .ui-input {
width: 100%;
min-width: 0;
}
+161
View File
@@ -0,0 +1,161 @@
/* =============================================================================
* map_draw_kind.ts
* 공용 긋기 방식 칸(PLAN 626-3 · 599-1 · 625-1) — 예정노선(D03) 그리기 칸 · 노드 판 · B05 평면 예정노선 그리기가 같이 씀.
* 꼴 = 마디 고름 2 × 2(자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정) + 길이 · 기울기 두 열(기울기 아래 ≈ °) ·
* 방식에 안 쓰는 칸은 숨김(막힘 = 권한 없음만) · 설명 글 없음(요구 463-6).
* 쓰는 법 = `createDrawKindFields({ value, onChange })` → { root, get, set, setDisabled, setActive }.
* 켬끔 꼴(589-9) = `onActive` 를 주면 고른 방식을 다시 누르면 풀림 · 방식을 고르면 켜짐 — 켬 = 긋기 시작 · 끔 = 긋기 끝
* (지금 켬인지는 `setActive` 로 맞춤 · 노드 판처럼 `onActive` 없는 곳은 늘 하나 고름).
* ========================================================================== */
import type { UiSize } from "@ui/ui_template_button";
import { createInputField } from "@ui/ui_template_input";
import { createSegment } from "@ui/ui_template_segment";
import { createText } from "@ui/ui_template_text";
import "./map_draw_kind.css";
/** 기울기 % → 각(°) 한 자리 글. */
const slopeDegText1 = (pct: number): string => ((Math.atan(pct / 100) * 180) / Math.PI).toFixed(1);
/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */
export const DRAW_DEFAULT_LENGTH_M = 20;
export const DRAW_DEFAULT_SLOPE_PCT = 9;
export type DrawConstraint = "free" | "length" | "slope" | "both";
export interface DrawKindValue {
kind: DrawConstraint;
lengthM: number;
slopePct: number;
}
export const DRAW_KIND_LABELS: Record<DrawConstraint, string> = {
free: "자유지정",
both: "길이·기울기 고정",
length: "길이 고정",
slope: "기울기 고정",
};
export interface DrawKindFields {
root: HTMLElement;
get: () => DrawKindValue;
/** 값을 바꿈(onChange 는 안 부름). */
set: (value: Partial<DrawKindValue>) => void;
setDisabled: (disabled: boolean) => void;
/** 켬끔 꼴에서 지금 켬인지 맞춤(onActive 는 안 부름 · 끔 = 모두 풀린 모양). */
setActive: (on: boolean) => void;
}
/** 긋기 방식 칸 — 고름 · 길이 · 기울기가 바뀔 때마다 `onChange(지금 값)`(빈 · 틀린 수는 칸 오류만). */
export function createDrawKindFields(opts: {
value: DrawKindValue;
onChange: (value: DrawKindValue) => void;
/** 켬끔 꼴 — 방식 고름 = true · 고른 방식 다시 누름 = false. */
onActive?: (on: boolean) => void;
size?: UiSize;
}): DrawKindFields {
const size = opts.size ?? 24;
let value = { ...opts.value };
let disabled = false;
let active = !opts.onActive;
const changed = (): void => opts.onChange({ ...value });
const segment = createSegment({
options: (Object.keys(DRAW_KIND_LABELS) as DrawConstraint[]).map((kind) => ({
value: kind,
label: DRAW_KIND_LABELS[kind],
})),
value: active ? value.kind : "",
size,
columns: 2,
clearable: !!opts.onActive,
ariaLabel: "긋기 방식",
onChange: (next) => {
if (next === "") return void ((active = false), opts.onActive?.(false));
value.kind = next as DrawConstraint;
fieldsState();
changed();
if (opts.onActive && !active) ((active = true), opts.onActive(true));
},
});
segment.root.dataset.role = "draw-constraint";
const lengthField = createInputField({
label: "길이",
type: "number",
value: String(value.lengthM),
min: 1,
unit: "m",
size,
onInput: (text) => {
const next = Number(text);
lengthField.setError(next > 0 ? undefined : "0 보다 큰 수");
if (!(next > 0)) return;
value.lengthM = next;
changed();
},
});
lengthField.input.dataset.role = "draw-length";
const slopeField = createInputField({
label: "기울기",
type: "number",
value: String(value.slopePct),
unit: "%",
size,
onInput: (text) => {
const next = Number(text);
const ok = text.trim() !== "" && Number.isFinite(next);
slopeField.setError(ok ? undefined : "숫자");
if (!ok) return;
value.slopePct = next;
degText();
changed();
},
});
slopeField.input.dataset.role = "draw-slope";
const slopeDegText = createText("", "muted");
slopeDegText.dataset.role = "draw-slope-deg";
const degText = (): void => {
slopeDegText.textContent = `≈ ${slopeDegText1(Math.abs(value.slopePct))}°`;
};
const slopeColumn = document.createElement("div");
slopeColumn.append(slopeField.root, slopeDegText);
const fields = document.createElement("div");
fields.className = "map-draw-kind__fields";
fields.append(lengthField.root, slopeColumn);
const root = document.createElement("div");
root.className = "map-draw-kind";
root.append(segment.root, fields);
function fieldsState(): void {
for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled;
lengthField.input.disabled = slopeField.input.disabled = disabled;
fields.hidden = value.kind === "free";
lengthField.root.hidden = value.kind === "slope";
slopeColumn.hidden = value.kind === "length";
}
degText();
fieldsState();
return {
root,
get: () => ({ ...value }),
set(next) {
value = { ...value, ...next };
segment.set(active ? value.kind : "");
lengthField.input.value = String(value.lengthM);
slopeField.input.value = String(value.slopePct);
lengthField.setError(undefined);
slopeField.setError(undefined);
degText();
fieldsState();
},
setDisabled(next) {
disabled = next;
fieldsState();
},
setActive(on) {
if (!opts.onActive) return;
active = on;
segment.set(active ? value.kind : "");
},
};
}
+235
View File
@@ -0,0 +1,235 @@
/* =============================================================================
* map_forest_layers.ts
* 공용 지도 — 받은 산림 · 환경 자료 겹(PLAN 622-2 · 624-2 · 요구 529 · 530) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴).
* 벡터 = 임도망도(forest_road · 전국 국가임도 · 간선 · 작업 · 산불 색 · 줌 따라 굵기 · 줌 14 부터 연도 · 종류 글) · 산림입지도(forest_soil · 토양 무리 색 ·
* 반투명 · z10 아래 숨김) · 경제림(eco_forest · 국유 · 사유 테두리) /
* 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로) · 암상 지질도(geology_map · 760-1 · 도형 = `map_geology`).
* 조각만 쓰는 겹(`TILE_ONLY`) = 받기 없음 · 옛 캔버스(그래픽 카드 끔)엔 안 보임.
* 토양 무리 색(`soilColor`) = 조각(`code` = SLTP_CD 두 자리) · 브이월드 요약(기호 「B₁」) 같은 색 — 범례 · 옛 캔버스도 이것.
* 부르는 곳 = `map_layer_specs`(꼴) · D03 `UI_MapGl`(소스) · `UI_Layers`(받기 거름) · `map_legend`(범례).
* ========================================================================== */
import type { ExpressionSpecification, SourceSpecification } from "maplibre-gl";
import type { LegendItem } from "@ui/ui_template_flow_legend";
import { GL_FONT, tileSource, type GlSpec } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import type { MapLayerKey } from "./map_layer_list";
/** 조각만 쓰는 겹 — 받을 자료 없음(위성 포함). */
const TILE_ONLY = ["satellite", "nationalRoad", "ecoForest", "ecology"] as const;
export type TileOnlyKey = (typeof TILE_ONLY)[number];
export const tileOnly = (key: MapLayerKey): key is TileOnlyKey =>
(TILE_ONLY as readonly string[]).includes(key);
/** 받는 동안 서클에 세는 조각 소스(등고선 포함). */
export const TRACKED_SOURCES = [
"contour",
"forest_road",
"forest_soil",
"eco_forest",
"ecology",
"landslide",
"geology_map",
];
/** 고정 소스 — 벡터 z8~15(서버 줌별 단순화) · 그림 z6~19. */
export function forestSources(projectId: string): Record<string, SourceSpecification> {
const vt = (name: string) => tileSource(projectId, `vt/${name}`, "pbf", 8, 15);
const wmt = (name: string) => tileSource(projectId, `wmt/${name}`, "png", 6, 19);
return {
forest_road: vt("forest_road"),
forest_soil: vt("forest_soil"),
eco_forest: vt("eco_forest"),
ecology: wmt("ecology"),
landslide: wmt("landslide"),
geology_map: wmt("geology"),
};
}
/** 토양형 코드(산림입지토양도 SLTP_CD) → 기호. 무리 = 갈색 · 적황색 · 암적색 · 회갈색 · 화산회 · 침식 · 미숙 · 그 밖(비산림). */
const SOIL_SYMBOLS: Record<string, string> = {
"01": "B1", "02": "B2", "03": "B3", "04": "B4",
"05": "rB1", "06": "rB2", "07": "R1", "08": "R2", "09": "Y",
"10": "DR1", "11": "DR2", "12": "DR3", "13": "DRb1", "14": "DRb2",
"15": "GrB1", "16": "GrB2", "17": "Va1", "18": "Va2", "19": "Va3", "20": "Va4",
"21": "Va-R1", "22": "Va-R2", "23": "Va-gr",
"24": "Er1", "25": "Er2", "26": "Er-c", "27": "Im", "28": "Li", "29": "Bs",
}; // prettier-ignore
/** 무리 첫 코드(차례 = `--draw-map-soil-1..6`). */
const SOIL_GROUP_START = [1, 5, 10, 15, 24, 30];
const soilToken = (group: number): string => `--draw-map-soil-${group + 1}`;
const SUB = /[₀-₉]/g;
/** 토양형(조각 두 자리 코드 · 브이월드 기호 「B₁」) → 무리 색 값 · 모름 = 「그 밖」. */
export function soilColor(code: string): string {
const plain = code.trim().replace(SUB, (c) => String(c.charCodeAt(0) - 0x2080));
const found = /^\d+$/.test(plain)
? Number(plain)
: Number(Object.keys(SOIL_SYMBOLS).find((key) => SOIL_SYMBOLS[key] === plain) ?? 99);
return soilToken(Math.max(SOIL_GROUP_START.filter((start) => found >= start).length - 1, 0));
}
/** 임도 사업 구분(`kind` 1 간선 · 2 작업 · 3 산불) 색 값. */
const ROAD_KINDS = [
{ kind: 1, label: "간선", color: "--draw-map-national-road-main" },
{ kind: 2, label: "작업", color: "--draw-map-national-road-work" },
{ kind: 3, label: "산불", color: "--draw-map-national-road-fire" },
];
const ECO_KINDS = [
{ own: "국유", color: "--draw-map-eco-national" },
{ own: "사유", color: "--draw-map-eco-private" },
];
/** 생태자연도 범례 = 환경부 WMS 그림 색(3등급 = 칠 없음). */
const ECOLOGY_LEGEND = [
{ label: "1등급", color: "--draw-map-ecology-1" },
{ label: "2등급", color: "--draw-map-ecology-2" },
{ label: "별도관리지역", color: "--draw-map-ecology-x" },
];
const HALO = "rgba(0, 0, 0, 0.55)";
const ROUND = { "line-join": "round", "line-cap": "round" } as const;
const zoomWidth = (z8: number, z12: number, z15: number): ExpressionSpecification =>
["interpolate", ["linear"], ["zoom"], 8, z8, 12, z12, 15, z15] as ExpressionSpecification;
type Spec = GlSpec<MapLayerKey>;
const raster = (
key: "landslide" | "ecology" | "geology",
opacity: number,
source: string = key,
): Spec =>
({
key,
id: source === key ? key : source,
type: "raster",
source,
paint: { "raster-opacity": opacity, "raster-fade-duration": 0 },
}) as Spec;
/** 그림 겹(암상 지질도 · 산사태위험 · 생태자연도) — 아래 차례(급경사 위). */
export const forestRasterSpecs = (): Spec[] => [
raster("geology", 0.5, "geology_map"),
raster("landslide", 0.6),
raster("ecology", 0.55),
];
/** 면 겹(산림입지도 · 경제림) — 보호구역 아래. */
export function forestAreaSpecs(): Spec[] {
const soilAt = (group: number): string => themeColor(soilToken(group), "#e879f9");
const soil = [
"step",
["to-number", ["get", "code"], 99],
soilAt(0),
...SOIL_GROUP_START.slice(1).flatMap((start, i) => [start, soilAt(i + 1)]),
] as ExpressionSpecification;
const eco = [
"match",
["get", "own"],
...ECO_KINDS.flatMap(({ own, color }) => [own, themeColor(color, "#34d399")]),
themeColor(ECO_KINDS[1].color, "#a78bfa"),
] as unknown as ExpressionSpecification;
const vector = (source: string) => ({ source, "source-layer": source });
return [
{
key: "soils",
id: "soils",
type: "fill",
...vector("forest_soil"),
minzoom: 10,
paint: { "fill-color": soil, "fill-opacity": 0.35 },
} as Spec,
{
key: "soils",
id: "soils-edge",
type: "line",
...vector("forest_soil"),
minzoom: 10,
layout: ROUND,
paint: { "line-color": soil, "line-width": 0.8, "line-opacity": 0.9 },
} as Spec,
{
key: "ecoForest",
id: "ecoForest",
type: "line",
...vector("eco_forest"),
layout: ROUND,
paint: { "line-color": eco, "line-width": zoomWidth(1, 1.8, 2.4) },
} as Spec,
];
}
/** 임도망도(전국 국가임도) 선 — 검은 테 + 사업 구분 색(도로 위 · 임도(업로드) 아래). */
export function forestRoadSpecs(): Spec[] {
const color = [
"match",
["get", "kind"],
...ROAD_KINDS.flatMap(({ kind, color }) => [kind, themeColor(color, "#ff6b00")]),
themeColor(ROAD_KINDS[0].color, "#ff6b00"),
] as unknown as ExpressionSpecification;
const line = (id: string, paint: Record<string, unknown>): Spec =>
({
key: "nationalRoad",
id,
type: "line",
source: "forest_road",
"source-layer": "forest_road",
layout: ROUND,
paint,
}) as Spec;
return [
line("nationalRoad-halo", { "line-color": HALO, "line-width": zoomWidth(2.2, 3.6, 5.6) }),
line("nationalRoad", { "line-color": color, "line-width": zoomWidth(1, 2, 3.6) }),
];
}
/** 임도망도 글(요구 534) — 줌 14 부터 선 따라 「개설 연도 · 종류」(연도 0 = 종류만) · 겹치면 뺌 · 꼴 = 지명 글. */
export function forestRoadLabelSpec(): Spec {
const kind = [
"match",
["get", "kind"],
...ROAD_KINDS.flatMap(({ kind, label }) => [kind, label]),
"",
] as unknown as ExpressionSpecification;
return {
key: "nationalRoad",
id: "nationalRoad-label",
type: "symbol",
source: "forest_road",
"source-layer": "forest_road",
minzoom: 14,
layout: {
"symbol-placement": "line",
"symbol-spacing": 300,
"text-field": [
"concat",
["case", [">", ["get", "year"], 0], ["concat", ["to-string", ["get", "year"]], " · "], ""],
kind,
],
"text-font": [GL_FONT],
"text-size": 11,
"text-max-angle": 30,
"text-padding": 2,
},
paint: {
"text-color": themeColor("--draw-map-place", "#f8fafc"),
"text-halo-color": "rgba(0, 0, 0, 0.7)",
"text-halo-width": 1.5,
},
} as unknown as Spec;
}
/** 범례 — 켠 조각 겹(산사태위험 = 범례 자리 `LANDSLIDE_LEGEND` 따로). */
export function forestLegendRows(on: Record<MapLayerKey, boolean>): LegendItem[][] {
const item = (label: string, color: string, kind?: LegendItem["kind"]): LegendItem => ({
label,
color: `var(${color})`,
...(kind ? { kind } : {}),
});
const rows: LegendItem[][] = [];
if (on.nationalRoad)
rows.push(ROAD_KINDS.map(({ label, color }) => item(`임도망도 ${label}`, color, "line")));
if (on.ecoForest)
rows.push(ECO_KINDS.map(({ own, color }) => item(`경제림 ${own}`, color, "line")));
if (on.ecology)
rows.push(ECOLOGY_LEGEND.map(({ label, color }) => item(`생태자연도 ${label}`, color)));
return rows;
}
+161
View File
@@ -0,0 +1,161 @@
/* =============================================================================
* map_geology.ts
* 공용 지도 암상 지질도 겹(PLAN 760-1 · 요구 597-3 · 598-3) — D03 예정노선 · B05 평면이 같이 씀.
* 그림 = KIGAM 5만 WMS 조각(`map_forest_layers` 「geology_map」) · 도형 = 서버 `/plan/layers/geology`
* (기호별 요약 · 경위도 면 · 노선마다 암상 구간 · 키는 서버만).
* 색 = 바위 갈래(서버 `ROCK_GROUPS` 차례) → `--draw-map-geology-1..6` · 면 위 글 = 「기호 · 첫 암상 낱말」.
* 범례 줄(`geologyRow`) · 노선 구간 표(`geologyTail` · 참고 — 토사 · 암 판정 아님) = 범례 칸.
* ========================================================================== */
import type { LegendItem } from "@ui/ui_template_flow_legend";
import { GL_FONT, type GlSpec } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import { createTable } from "@ui/ui_template_table";
import { el } from "@ui/ui_template_elements_base";
import { stationLabel } from "@util/common_util_station";
import { request, type GeoJsonCollection } from "./map_layer_api";
import { hectare } from "./map_layer_data";
import type { MapLayerKey } from "./map_layer_list";
/** 기호 하나(받은 범위 안). */
export interface GeologyUnit {
symnum: string;
symbol: string;
litho: string;
strata: string;
age: string;
sheet: string;
group: number;
count: number;
area_m2: number;
}
/** 노선 구간 — `i` = 요약 차례 · -1 = 자료 없음. */
export interface GeologySegment {
from_m: number;
to_m: number;
i: number;
}
export interface PlanGeology extends GeoJsonCollection {
source: string;
summary: GeologyUnit[];
groups: string[];
routes: Array<{ name: string; segments: GeologySegment[] }>;
}
const GROUP_FALLBACK = ["#f472b6", "#fb923c", "#fde047", "#818cf8", "#e5e7eb", "#5eead4"];
const groupToken = (group: number): string => `--draw-map-geology-${Math.min(group, 5) + 1}`;
const firstWord = (text: string): string => text.split(/\s+/)[0] ?? "";
/** 긴 암상 글 줄임(범례 · 표 폭). */
const short = (text: string, max = 22): string =>
text.length > max ? `${text.slice(0, max)}…` : text;
/** 받기 — 면 위 글 꼴을 먼저 읽어 둠(한글 글자). */
export async function fetchPlanGeology(projectId: string): Promise<PlanGeology> {
const got = await request<PlanGeology>(`/projects/${projectId}/plan/layers/geology`);
const text = got.summary.map((unit) => unit.symbol + unit.litho).join("") || "가";
await document.fonts.load(`600 24px "${GL_FONT}"`, text).catch(() => null);
return got;
}
/** GL 자료 — 지물마다 색(`_color`) · 글(`_label`). */
export function geologyCollection(info: PlanGeology): GeoJsonCollection {
return {
features: (info.features ?? []).map((feature) => {
const unit = info.summary[Number(feature.properties?.["i"])];
const group = unit?.group ?? 5;
return {
...feature,
properties: {
...feature.properties,
_color: themeColor(groupToken(group), GROUP_FALLBACK[group] ?? GROUP_FALLBACK[5]),
_label: unit ? `${unit.symbol} · ${firstWord(unit.litho)}` : "",
},
};
}),
};
}
/** GL 꼴 — 연한 채움 · 갈래 색 테두리 · 면 위 글(겹치면 뺌). */
export function geologySpecs(): GlSpec<MapLayerKey>[] {
const color = ["get", "_color"];
return [
{
key: "geology",
id: "geology",
type: "fill",
source: "geology",
paint: { "fill-color": color, "fill-opacity": 0.2 },
},
{
key: "geology",
id: "geology-edge",
type: "line",
source: "geology",
layout: { "line-join": "round" },
paint: { "line-color": color, "line-width": 1.6, "line-opacity": 0.95 },
},
{
key: "geology",
id: "geology-label",
type: "symbol",
source: "geology",
layout: {
"text-field": ["get", "_label"],
"text-font": [GL_FONT],
"text-size": 12,
"text-padding": 4,
},
paint: {
"text-color": color,
"text-halo-color": "rgba(0, 0, 0, 0.75)",
"text-halo-width": 1.5,
},
},
] as unknown as GlSpec<MapLayerKey>[];
}
const unitColor = (unit: GeologyUnit | undefined): string =>
unit ? `var(${groupToken(unit.group)})` : "transparent";
/** 범례 줄 — 기호마다 「기호 대표암상 넓이」(갈래 색) · 없으면 한 줄. */
export function geologyRow(info: PlanGeology): LegendItem[] {
if (!info.summary.length)
return [{ label: "암상 지질도 없음(받은 범위 안)", color: "transparent", off: true }];
return info.summary.map((unit) => ({
label: `${unit.symbol} ${short(unit.litho || unit.strata)} ${hectare(unit.area_m2)}`,
color: unitColor(unit),
}));
}
/** 노선 따라 바위 종류 구간 표(노선마다) + 참고 글 — 켬 · 받음 · 노선 있을 때만. */
export function geologyTail(on: Record<MapLayerKey, boolean>, info?: PlanGeology): HTMLElement[] {
if (!on.geology || !info?.routes.length) return [];
const columns = [
{ label: "측점" },
{ label: "길이", align: "end" as const },
{ label: "기호" },
{ label: "대표암상" },
];
const tables = info.routes.map((route) => {
const rows = route.segments.map((seg) => {
const unit = info.summary[seg.i];
return [
`${stationLabel(seg.from_m, 20)} ~ ${stationLabel(seg.to_m, 20)}`,
`${(seg.to_m - seg.from_m).toFixed(0)} m`,
unit?.symbol ?? "—",
unit ? short(unit.litho || unit.strata, 30) : "자료 없음",
];
});
return el("div", {
className: "ui-legend ui-legend--vertical",
children: [`암상 구간 — ${route.name}`, createTable(columns, rows, { density: "data" })],
});
});
const note = el("div", {
className: "ui-legend",
children: ["참고 — 토사 · 암 판정 아님(1:50,000 바위 종류 · 경계 수십 m 어긋날 수 있음)"],
});
return [...tables, note];
}
+234
View File
@@ -0,0 +1,234 @@
/* =============================================================================
* map_layer_api.ts
* 공용 지도 겹 받기(PLAN 569 · 581 · 584 · 603 · 605 · 606 · 624-3 · 624-4 · 요구 530) — D03 예정노선 · B05 평면이 같이 씀(D03_Api 에서 옮김).
* 서버 길 = `/api/projects/{id}/plan/…`(624-1 · 임도 프로젝트도 · 띠 · 올린 임도망도 = 예정노선만).
* 급경사 · 음영 · 0차곡 = 지형 원천 인자 `terrain`(지형 칩 값 · 624-4).
* 지적도 · 도엽 겹 GeoJSON 경위도 · 도엽 조각 · 그림 겹(급경사 · 음영기복 · 산사태위험) · 보호구역 · 산림입지도 ·
* 선 겹(단층 · 지질구조선) · 0차곡 · 띠 · 올린 임도망도(올리기 · 지움) · 산사태취약지역.
* D03 = `D03_PlanAnalysis_Api` 가 다시 내놓음(부르는 곳 그대로).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { TerrainSource } from "./map_terrain_chip";
/** 경위도 GeoJSON 묶음 — B04 `GeoJsonCollection` 과 같은 꼴(공용 폴더는 페이지 파일을 안 부름). */
export type GeoJsonCollection = {
features?: Array<{
geometry?: { type: string; coordinates: unknown } | null;
properties?: Record<string, unknown> | null;
}>;
};
export async function request<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
});
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
if (!response.ok) {
const detail = typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`;
throw new Error(detail);
}
return body;
}
const base = (projectId: string): string => `/projects/${projectId}/plan`;
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북). */
export interface PlanRaster {
box_m: [number, number, number, number];
/** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */
box?: Array<[number, number]>;
image: string;
/** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */
counts?: number[];
/** 쓴 지형 원천(624-1 · 급경사 · 음영). */
source?: string;
}
/** 지형 원천 인자(624-1) — 안 넘김 = 서버 기본(D = 수치지형도 · B = 설계 지형). */
const terrainQuery = (terrain?: TerrainSource): string => (terrain ? `&terrain=${terrain}` : "");
export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/layers/cadastral`);
/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */
export const fetchPlanSheetLayer = (projectId: string, key: string): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`);
/** 보이는 도엽(606-2) — 지도 보기 경위도 상자 → 수 · 번호(가운데부터 · 30 장 넘음 = 빈 목록). */
export const fetchPlanSheetTiles = (
projectId: string,
bbox: [number, number, number, number],
): Promise<{ count: number; sheets: string[] }> =>
request(`${base(projectId)}/sheet-tiles?bbox=${bbox.map((v) => v.toFixed(5)).join(",")}`);
/** 도엽 조각 하나(606-1) — 공용 창고 · 없으면 서버가 만듦. */
export const fetchPlanSheetTile = (
projectId: string,
sheet: string,
name: string,
): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`);
/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면
* `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */
export const fetchPlanRaster = async (
projectId: string,
kind: "slope" | "hillshade" | "landslide",
terrain?: TerrainSource,
): Promise<PlanRaster> => {
const path = `${base(projectId)}/layers/raster?kind=${kind}${terrainQuery(terrain)}`;
if (kind !== "slope") return request(path);
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster;
return { ...meta, image: URL.createObjectURL(await response.blob()) };
};
/** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */
export interface PlanZones extends GeoJsonCollection {
summary: Record<string, { label: string; count: number; area_m2: number; failed?: boolean }>;
}
export const fetchPlanZones = (projectId: string): Promise<PlanZones> =>
request(`${base(projectId)}/layers/zones`);
/** 산림입지도(581-2) — 지물 `code` · `name` · `area_m2` + 토양형별 개수 · 넓이(코드 차례). */
export interface PlanSoils extends GeoJsonCollection {
summary: Array<{ code: string; name: string; count: number; area_m2: number }>;
}
export const fetchPlanSoils = (projectId: string): Promise<PlanSoils> =>
request(`${base(projectId)}/layers/soils`);
/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */
export interface PlanBand {
dist_m: number;
lines: number;
area_m2: number;
ratio: number;
collection: GeoJsonCollection;
}
/** 선 겹(584-1) — 받은 범위 안 선 + 가닥 · 길이 m. */
export interface PlanLines extends GeoJsonCollection {
summary: { count: number; length_m: number };
}
/** 단층 · 지질구조선(브이월드 WFS · 서버 `LINE_KINDS` 열쇠). */
export const fetchPlanLines = (projectId: string, kind: string): Promise<PlanLines> =>
request(`${base(projectId)}/layers/lines?kind=${encodeURIComponent(kind)}`);
/** 0차곡(584-1 · 지형 추정) — 위 집수 넓이 [min_m2, max_m2) 선 + 가닥 · 길이 m. */
export interface PlanZeroOrder {
min_m2: number;
max_m2: number;
cell_m: number;
count: number;
length_m: number;
collection: GeoJsonCollection;
/** 쓴 지형 원천(624-1). */
source?: string;
}
export const fetchPlanZeroOrder = (
projectId: string,
terrain?: TerrainSource,
): Promise<PlanZeroOrder> =>
request(`${base(projectId)}/layers/zero-order?${terrainQuery(terrain).slice(1)}`);
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
request(`${base(projectId)}/layers/bands`);
/** 올린 묶음 한 줄(605-1) — 올릴 때마다 하나 · 자른 가닥 · 길이 m · 원본 선 수 · 잘린 상자 · 기준점 거리. */
export interface PlanRoadBundle {
id: string;
name: string;
files: string[];
bytes: number | null;
uploaded_at: string | null;
count: number;
length_m: number;
total: number;
bounds: [number, number, number, number] | null;
distance_m: number | null;
}
/** 올린 임도망도(584-3 · 595-4 · 605-1) — 묶음 모두를 합친 값: 기준점 ±5 km ∪ 받은 지형으로 자른 선
* (선마다 `__bundle` 묶음 번호 · `__line` 묶음 안 차례) + 가닥 · 길이 m · 원본 선 수 · 좌표계 ·
* 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 마지막 올린 파일 · 묶음 줄. */
export interface PlanForestRoads {
bundles: PlanRoadBundle[];
count: number;
length_m: number;
total: number;
crs: string[];
bounds: [number, number, number, number] | null;
distance_m: number | null;
source: { files: string[]; bytes: number; uploaded_at: string } | null;
collection: GeoJsonCollection;
}
/** 올린 적 없으면 오류(404 글 그대로). */
export const fetchPlanForestRoads = (projectId: string): Promise<PlanForestRoads> =>
request(`${base(projectId)}/layers/forest-roads`);
/** 묶음 하나 지움(605-1) — 남은 묶음 합친 값 · 하나도 안 남으면 null. */
export async function deletePlanForestRoad(
projectId: string,
bundle: string,
): Promise<PlanForestRoads | null> {
const response = await fetch(
`${API_BASE_URL}${base(projectId)}/layers/forest-roads/${encodeURIComponent(bundle)}`,
{ method: "DELETE", credentials: "include" },
);
const body = (await response.json().catch(() => ({}))) as PlanForestRoads & {
detail?: unknown;
};
if (!response.ok)
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
return body.bundles.length ? body : null;
}
/** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */
export async function uploadPlanForestRoads(
projectId: string,
files: File[],
): Promise<PlanForestRoads> {
const form = new FormData();
for (const file of files) form.append("files", file);
const response = await fetch(`${API_BASE_URL}${base(projectId)}/layers/forest-roads`, {
method: "POST",
credentials: "include",
body: form,
});
const body = (await response.json().catch(() => ({}))) as PlanForestRoads & {
detail?: unknown;
};
if (!response.ok)
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
return body;
}
/** 산사태취약지역(584-3) — 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 표 없는 시도. */
export interface PlanLandslideZones {
points: Array<{
lon: number;
lat: number;
kind: string | null;
area_m2: number | null;
place: string;
}>;
parcels: GeoJsonCollection;
sidos: Array<{ code: string; name: string; rows: number; located: number; basis?: string }>;
missing: string[];
/** 지적도를 받았나(못 받으면 점만). */
cadastral: boolean;
}
export const fetchPlanLandslideZones = (projectId: string): Promise<PlanLandslideZones> =>
request(`${base(projectId)}/layers/landslide-zones`);
+133
View File
@@ -0,0 +1,133 @@
/* =============================================================================
* map_layer_bar.ts
* 공용 지도 겹 줄(PLAN 569-2 · 586-2 · 624-2 · 634-2 · 요구 530 · 546) — 겹 단추 한 줄(공용 지도 도구줄 `createMapTools`):
* 단독 단추(표 묶음 없음) | 묶음 단추(여럿 고르기 메뉴 · 하나라도 켜면 눌림 · 골라도 안 닫힘) | 그래픽 카드 단추(GL ↔ 옛 캔버스).
* 보일 겹 · 묶음 · 호버제목 · 처음 켬 · 취향 열쇠 = 표(`map_layer_list`) · 누르면 켬끔 뒤집고 취향에 적고 `onToggle`.
* 막힘 = 지형을 아직 안 받음(`ready: false`) · 조각만 쓰는 겹은 옛 캔버스에서 · 페이지 까닭(`why`) — 「제목 — 까닭」.
* 받기 · 그리기 · 범례는 페이지 몫 — 켬끔 값(`on`)은 같은 객체를 같이 씀.
* 쓰는 법 = `createMapLayerBar({ page: "d03", glOn, onToggle, onGl })` → `{ root, bar, on, toggle, save, sync }`.
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createPickMenu, type PickMenu } from "@ui/ui_template_context_menu";
import { createMapTools, mapToolTip } from "@ui/ui_template_map_tools";
import { writeByKey } from "../b_page_state";
import { tileOnly } from "./map_forest_layers";
import {
LAYER_GROUPS,
MAP_LAYER_PAGES,
pageLayers,
readLayerPref,
type LayerGroup,
type MapLayerKey,
type MapPage,
} from "./map_layer_list";
/** 그래픽 카드 단추 호버(표 밖 · 그룹1 끝 단독). */
const GL_TITLE = "그래픽 카드";
export interface MapLayerBar {
/** 도구줄 감싼 것(자리는 페이지 CSS · 한 행 = `createMapToolRow`). */
root: HTMLElement;
/** 도구줄 자체(폭 재기). */
bar: HTMLElement;
/** 켬끔 — 페이지가 바꾸면 `save` · 받기 실패로 끄는 것은 안 적음. */
on: Record<MapLayerKey, boolean>;
/** 뒤집고 적고 `onToggle`. */
toggle: (key: MapLayerKey) => void;
save: () => void;
/** 단추 · 메뉴 줄의 눌림 · 막힘 · 글을 켬끔 값에 맞춤 · `why` = 페이지 막힘 까닭(겹마다). */
sync: (state: {
ready: boolean;
glOn: boolean;
why?: Partial<Record<MapLayerKey, string>>;
}) => void;
}
export function createMapLayerBar(opts: {
page: MapPage;
glOn: boolean;
onToggle: (key: MapLayerKey) => void;
/** 그래픽 카드 단추 누름. */
onGl: () => void;
}): MapLayerBar {
const { page } = opts;
const on = readLayerPref(page);
const save = (): void => writeByKey(MAP_LAYER_PAGES[page].pref, JSON.stringify(on));
const toggle = (key: MapLayerKey): void => {
on[key] = !on[key];
save();
opts.onToggle(key);
};
const defs = pageLayers(page);
const buttons = new Map<MapLayerKey, HTMLButtonElement>();
const menus = new Map<LayerGroup, PickMenu>();
const singles: HTMLElement[] = [];
for (const def of defs) {
if (!def.group) {
const button = createIconButton({
icon: def.icon,
title: def.title,
size: 32,
pressed: on[def.key],
onClick: () => toggle(def.key),
});
button.dataset.layer = def.key;
button.dataset.tip = def.title;
buttons.set(def.key, button);
singles.push(button);
continue;
}
if (menus.has(def.group)) continue;
const group = def.group;
const menu = createPickMenu({
multi: true,
icon: LAYER_GROUPS[group].icon,
title: LAYER_GROUPS[group].name,
items: defs
.filter((one) => one.group === group)
.map((one) => ({ value: one.key, label: one.title, icon: one.icon })),
onPick: (key) => key && toggle(key as MapLayerKey),
});
menu.root.dataset.layerGroup = group;
menus.set(group, menu);
}
for (const def of defs)
if (def.group) menus.get(def.group)!.setItem(def.key, { checked: on[def.key] });
const glButton = createIconButton({
icon: "gauge",
title: GL_TITLE,
size: 32,
pressed: opts.glOn,
onClick: opts.onGl,
});
glButton.dataset.layer = "gl";
const tools = createMapTools({
label: "지도 겹",
groups: [singles, [...menus.values()].map((menu) => menu.root), [glButton]],
});
function sync({ ready, glOn, why = {} }: Parameters<MapLayerBar["sync"]>[0]): void {
for (const def of defs) {
const reason = !ready
? "지형 받기 뒤 보임"
: (why[def.key] ??
(tileOnly(def.key) && !glOn ? "옛 그림(그래픽 카드 끔)엔 안 보임" : null));
const button = buttons.get(def.key);
if (button) {
button.setAttribute("aria-pressed", String(on[def.key]));
button.disabled = !!reason;
mapToolTip(button, reason);
continue;
}
menus.get(def.group!)!.setItem(def.key, {
checked: on[def.key],
disabled: !!reason,
title: reason ? `${def.title} — ${reason}` : def.title,
});
}
glButton.setAttribute("aria-pressed", String(glOn));
}
return { root: tools.root, bar: tools.bar, on, toggle, save, sync };
}
+76
View File
@@ -0,0 +1,76 @@
/* =============================================================================
* map_layer_data.ts
* 공용 지도 겹 자료 도움(PLAN 569-2 · 581 · 586-2 · 624-3) — D03 예정노선 · B05 평면이 같이 씀(D03 `UI_LayerDefs` 에서 옮김).
* 도로 소로 · 미분류 구분 · 물 면 도엽 이름 · 넓이 글 · 그림 읽기 · GL 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림).
* ========================================================================== */
import type { GlImage } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import type { GeoJsonCollection, PlanBand, PlanRaster } from "./map_layer_api";
/** 도로 중 소로 · 미분류 구분(옛 임도 짐작 · 겹 걷음 · 페이지 자료 셈만 씀). */
export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]);
/** 물 면 = 도엽 수계(E) 면 셋. */
export const WATER_KEYS = ["실폭하천", "수계면E003", "수계면E005"];
export const hectare = (m2: number): string => `${(m2 / 10000).toFixed(1)} ha`;
export const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((done, fail) => {
const image = new Image();
image.onload = () => done(image);
image.onerror = () => fail(new Error("그림을 읽지 못했습니다"));
image.src = src;
});
/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */
export function glTagged(
collection: GeoJsonCollection,
color: (props: Record<string, unknown>) => string,
extra: Record<string, unknown> = {},
): GeoJsonCollection {
return {
...collection,
features: (collection.features ?? []).map((feature) => ({
...feature,
properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) },
})),
};
}
/** 띠 GL 자료(581-1 · 634-2) — 도로 300 m 먼저 · 계류 100 m 위 · `_band` = 겹 가름(계류 = `bands` · 도로 = `roadBands`). */
export function bandCollection(info: { stream: PlanBand; road: PlanBand }): GeoJsonCollection {
const band = (name: "stream" | "road", fallback: string): GeoJsonCollection =>
glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), {
_band: name,
});
return {
features: [
...(band("road", "#facc15").features ?? []),
...(band("stream", "#22d3ee").features ?? []),
],
};
}
/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */
export function glImage(raster: PlanRaster, url = raster.image): GlImage | null {
const box = raster.box;
return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null;
}
/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */
export function shadeImage(image: HTMLImageElement): string {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
const px = pixels.data;
for (let i = 0; i < px.length; i += 4) {
px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255));
px[i] = px[i + 1] = px[i + 2] = 0;
}
context.putImageData(pixels, 0, 0);
return canvas.toDataURL("image/png");
}
+169
View File
@@ -0,0 +1,169 @@
/* =============================================================================
* map_layer_list.ts
* 공용 지도 겹 목록(PLAN 624-2 · 요구 530 · 결정 A② · A⑧ · A⑨) — 예정노선(D03) · B05 평면이 같이 씀.
* 겹 = 요구 546 그룹1 표 한 줄 — 열쇠 · 아이콘 · 호버제목 · 아이콘 묶음(`LAYER_GROUPS` · 없음 = 단독) · 보일 페이지 · 처음 값(`on` = 예정노선).
* 페이지 표(`MAP_LAYER_PAGES`) = 처음 켬(없으면 겹의 `on`) · 켬끔 취향 열쇠.
* 선 겹 꼴 · 보호구역 색 · 산사태위험 범례 = GL 꼴(`map_layer_specs`) · 범례(`map_legend`) · 옛 캔버스가 같이 읽음.
* 쓰는 법 = 겹 줄 `createMapLayerBar({ page })`(`map_layer_bar`) · 새 겹 = `LAYERS` 한 줄 + 꼴 한 벌.
* ========================================================================== */
import type { IconName } from "@ui/ui_template_icons";
import type { LegendItem } from "@ui/ui_template_flow_legend";
import { readByKey } from "../b_page_state";
export type MapLayerKey =
| "satellite"
| "hillshade"
| "contour"
| "slope"
| "stream"
| "water"
| "road"
| "forestRoad" // 걷은 겹(임도 짐작 · 요구 532) — 목록 · 꼴 없음 · 페이지 자료 셈 이음만 남음
| "forestRoadFile"
| "nationalRoad"
| "ecoForest"
| "ecology"
| "cadastral"
| "zones"
| "soils"
| "landslide"
| "landslideZones"
| "geology" // 암상 지질도(760-1 · KIGAM 5만 · 그림 조각 + 도형)
| "faults"
| "lineaments"
| "zeroOrder"
| "bands" // 계류 100 m 띠(자료 = 띠 둘 한 벌 · `_band` 로 가름)
| "roadBands" // 도로 300 m 띠(같은 자료 · 「도로」 묶음)
| "building"
| "place";
/** 임도(업로드) 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */
export interface RoadPick {
bundle: string;
line?: number;
}
/** 지도가 있는 페이지. */
export type MapPage = "d03" | "b05";
/** 아이콘 묶음(요구 546 표 「아이콘 그룹명」) — 묶음 단추 = 아이콘 + ▾ + 켠 수 · 여럿 고르기 메뉴. */
export type LayerGroup = "water" | "road" | "land" | "limit";
export const LAYER_GROUPS: Record<LayerGroup, { name: string; icon: IconName }> = {
water: { name: "수계망도", icon: "streams" },
road: { name: "도로", icon: "signpost" },
land: { name: "토지·건물", icon: "parcelHouse" },
limit: { name: "제한·재해", icon: "ban" },
};
export interface LayerDef {
key: MapLayerKey;
icon: IconName;
/** 호버제목(요구 546 표) — 마우스 글 · 메뉴 줄 글. */
title: string;
/** 아이콘 묶음 — null = 단독 단추. */
group: LayerGroup | null;
/** 보일 페이지(표 「사용」). */
pages: readonly MapPage[];
/** 예정노선 처음 값 — 다른 페이지는 `MAP_LAYER_PAGES[page].on`. */
on: boolean;
}
const BOTH: readonly MapPage[] = ["d03", "b05"];
const row = (
key: MapLayerKey,
icon: IconName,
title: string,
group: LayerGroup | null,
on = false,
): LayerDef => ({ key, icon, title, group, pages: BOTH, on });
/** 그룹1 표(요구 546 · 차례 = 단추 차례) — 도로 300 m 띠는 표 밖 한 줄(「도로」 묶음 · 기능 남김). */
export const LAYERS: readonly LayerDef[] = [
row("satellite", "satellite", "위성사진", null, true),
row("hillshade", "sun", "음영기복도", null),
row("contour", "mountain", "등고선", null, true),
row("slope", "angle", "급경사", null),
row("stream", "droplet", "세류", "water", true),
row("water", "ripple", "하천", "water"),
row("zeroOrder", "arrowsJoin", "0차곡", "water"),
row("bands", "borderOuter", "계류100m", "water"),
row("road", "road", "도로", "road"),
row("nationalRoad", "route", "임도망도", "road"),
row("forestRoadFile", "map", "임도(업로드)", "road"),
row("roadBands", "roadBand", "도로300m", "road"),
row("cadastral", "borderAll", "지적도", "land"),
row("building", "building", "건물", "land"),
row("place", "mapPin", "지명", "land"),
row("zones", "shield", "보호규제구역", "limit"),
row("soils", "leaf", "산림입지도", "limit"),
row("ecoForest", "trees", "국유림·사유림", "limit"),
row("ecology", "plant", "생태자연도", "limit"),
row("landslide", "alert", "산사태위험지도", "limit"),
row("landslideZones", "alertOctagon", "산사태취약지역", "limit"),
row("geology", "strata", "암상 지질도", "limit"),
row("faults", "bolt", "단층", "limit"),
row("lineaments", "lineament", "지질구조선", "limit"),
];
/** 페이지 표 — 처음 켬(없음 = 겹의 `on`) · 켬끔 취향 열쇠. */
export const MAP_LAYER_PAGES: Record<MapPage, { on?: readonly MapLayerKey[]; pref: string }> = {
d03: { pref: "aislo:pref:d03.layers" },
// A② 처음 = 지금 그대로(등고선 · 하천)
b05: { on: ["contour", "stream"], pref: "aislo:pref:b05.layers" },
};
/** 그 페이지에 보일 겹(표 차례). */
export const pageLayers = (page: MapPage): LayerDef[] =>
LAYERS.filter((def) => def.pages.includes(page));
/** 켬끔 취향 — 없거나 깨지면 페이지 처음 값 · 안 보일 겹 = 끔. */
export function readLayerPref(page: MapPage): Record<MapLayerKey, boolean> {
const { on: first, pref } = MAP_LAYER_PAGES[page];
const on = Object.fromEntries(
LAYERS.map((def) => [def.key, first ? first.includes(def.key) : def.on]),
) as Record<MapLayerKey, boolean>;
try {
const saved = JSON.parse(readByKey(pref) ?? "{}") as Partial<Record<MapLayerKey, unknown>>;
for (const def of LAYERS)
if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean;
} catch {
// 깨진 값 = 처음 값
}
for (const def of LAYERS) if (!def.pages.includes(page)) on[def.key] = false;
return on;
}
/** 보호구역 종류 → 그림 색 값(서버 `ZONE_KINDS` 열쇠). */
export const ZONE_COLORS: Record<string, string> = {
forest: "--draw-map-zone-forest",
baekdu: "--draw-map-zone-baekdu",
water: "--draw-map-zone-water",
park: "--draw-map-zone-park",
heritage: "--draw-map-zone-heritage",
wetland: "--draw-map-zone-wetland",
wildlife: "--draw-map-zone-wildlife",
greenbelt: "--draw-map-zone-greenbelt",
};
/** 산사태위험지도 범례(1 등급 = 가장 위험 · 색 = WMS 그림 색). */
export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({
label: `산사태 ${grade}등급`,
color: `var(--draw-map-landslide-grade-${grade})`,
}));
/** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */
export type LineLayerKey = "faults" | "lineaments" | "zeroOrder";
export const LINE_LAYERS: Record<
LineLayerKey,
{ label: string; kind?: string; color: string; width: number; dash: number[] }
> = {
faults: { label: "단층", kind: "faults", color: "--draw-map-fault", width: 2.4, dash: [] },
lineaments: {
label: "지질구조선",
kind: "lineaments",
color: "--draw-map-lineament",
width: 1.8,
dash: [8, 4],
},
zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] },
};
export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[];
+31
View File
@@ -0,0 +1,31 @@
/* =============================================================================
* map_layer_source.ts
* 공용 지도 밖 겹 출처(PLAN 760-1 · 요구 598-4 「받아온 곳의 정보를 표기」) — 켠 겹의 받아온 곳을 범례 맨 아래 한 줄씩.
* 받아온 곳 = 서버가 부르는 곳 그대로(브이월드 WFS · 위성 · 산림청 원자료 · 생활안전지도 WMS · 지오빅데이터 …) ·
* 자체 셈 겹(음영 · 급경사 · 0차곡 · 띠) · 사용자 올림(임도(업로드)) · 자기 지형(등고선 · 세류)은 안 적음.
* ========================================================================== */
import type { LegendItem } from "@ui/ui_template_flow_legend";
import { LAYERS, type MapLayerKey } from "./map_layer_list";
/** 받아온 곳 → 겹(차례 = 범례 차례). */
export const LAYER_SOURCES: ReadonlyArray<[string, readonly MapLayerKey[]]> = [
["브이월드", ["satellite", "cadastral", "zones", "faults", "lineaments"]],
["국토지리정보원 수치지형도", ["water", "road", "building", "place"]],
["산림청", ["nationalRoad", "ecoForest", "soils"]],
["시도 산사태취약지역 현황(공공데이터포털)", ["landslideZones"]],
["환경부 생태자연도(공공데이터포털)", ["ecology"]],
["행정안전부 생활안전지도(공공누리 제4유형)", ["landslide"]],
["한국지질자원연구원 수치지질도 1:50,000(CC BY-NC)", ["geology"]],
];
/** 켠 밖 겹의 출처 줄 — 「출처: 곳 — 겹 · 겹」 · 없으면 빈 것. */
export function sourceRow(on: Record<MapLayerKey, boolean>): LegendItem[] {
const title = (key: MapLayerKey): string => LAYERS.find((def) => def.key === key)?.title ?? key;
return LAYER_SOURCES.flatMap(([where, keys]) => {
const shown = keys.filter((key) => on[key]);
return shown.length
? [{ label: `출처: ${where} — ${shown.map(title).join(" · ")}`, color: "transparent" }]
: [];
});
}
+317
View File
@@ -0,0 +1,317 @@
/* =============================================================================
* map_layer_specs.ts
* 공용 지도 GL 겹 꼴(PLAN 586-2 · 624-2 · 요구 530) — 바탕 겹 스물의 MapLibre 꼴 · 차례(아래 → 위):
* 위성 · 그림 겹(음영 · 급경사 · 산사태위험 · 생태자연도) · 면 겹(산림입지 · 경제림 · 보호구역 · 띠 · 물 면 · 건물) ·
* 선 겹(지적 · 등고선 · 도로 · 임도망도 · 임도(업로드) · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지) ·
* 점 · 글(산사태취약 · 임도망도 연도 · 종류 · 지명 · 등고 수치).
* 자료 = 받는 쪽이 지물에 붙인 `_color` · 고정 소스(위성 · 등고 · 산림 조각) 이름 = `map_forest_layers` · `ui_template_gl_map`.
* 카메라 · 지도 세우기는 페이지 몫(D03 `UI_MapGl.createGlBase` · B05 `createTmGl`) — 여기는 꼴만.
* ========================================================================== */
import type { ExpressionSpecification, FilterSpecification } from "maplibre-gl";
import { GL_FONT, type GlSpec as GlMapSpec } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import {
forestAreaSpecs,
forestRasterSpecs,
forestRoadLabelSpec,
forestRoadSpecs,
} from "./map_forest_layers";
import { geologySpecs } from "./map_geology";
import {
LINE_KEYS,
LINE_LAYERS,
type LineLayerKey,
type MapLayerKey,
type RoadPick,
} from "./map_layer_list";
/** 자료 소스(위성 = 조각 · 걷은 임도 짐작 = 소스 없음). */
export type GlSource = Exclude<MapLayerKey, "satellite" | "forestRoad">;
const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT];
type GlSpec = GlMapSpec<MapLayerKey>;
const HALO = "rgba(0, 0, 0, 0.55)";
const ROUND = { "line-join": "round", "line-cap": "round" } as const;
const COLOR: ExpressionSpecification = ["get", "_color"];
/** 등고 조각 굵은 선(서버가 줌마다 정함) — 가는 선은 모두라 굵은 선은 두 번 겹쳐 짙음(캔버스처럼). */
const BOLD = ["==", ["get", "k"], 1] as unknown as FilterSpecification;
/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */
const dash = (px: number[], width: number): number[] => px.map((v) => v / width);
/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */
function haloLine(
key: MapLayerKey,
source: GlSource,
color: string,
width: number,
pattern: number[] = [],
halo = { color: HALO, width: width + 1.6 },
filter?: FilterSpecification,
): GlSpec[] {
const line = (id: string, paint: Record<string, unknown>, w: number): GlSpec =>
({
key,
id,
type: "line",
source,
...(filter ? { filter } : {}),
layout: ROUND,
paint: {
...paint,
"line-width": w,
...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}),
},
}) as GlSpec;
return [
line(`${key}-halo`, { "line-color": halo.color }, halo.width),
line(key, { "line-color": color }, width),
];
}
/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */
/** 띠 테두리 — 계류 · 도로 같은 꼴. */
const BAND_EDGE = { color: COLOR, width: 1.2, dash: [6, 4] };
function fillEdge(
key: MapLayerKey,
source: GlSource,
fill: string | ExpressionSpecification,
opacity: number | ExpressionSpecification,
edge: { color: string | ExpressionSpecification; width: number; dash?: number[] },
filter?: ExpressionSpecification,
): GlSpec[] {
const only = filter ? { filter } : {};
return [
{
key,
id: key,
type: "fill",
source,
...only,
paint: { "fill-color": fill, "fill-opacity": opacity },
} as GlSpec,
{
key,
id: `${key}-edge`,
type: "line",
source,
...only,
layout: ROUND,
paint: {
"line-color": edge.color,
"line-width": edge.width,
"line-opacity": 0.9,
...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}),
},
} as GlSpec,
];
}
/** 겹 차례(아래 → 위)와 꼴 — 옛 캔버스(D03 `UI_LayerPaint`)의 색 · 굵기 · 투명도 · 차례 그대로 · `light` = 위성 밝기 상한. */
export function mapLayerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
const contour = themeColor("--draw-map-contour", "#ffd68c");
const place = themeColor("--draw-map-place", "#f8fafc");
const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48");
const raster = (
key: "satellite" | "hillshade" | "slope",
opacity: number,
extra: Record<string, number> = {},
): GlSpec =>
({
key,
id: key,
type: "raster",
source: key,
paint: { "raster-opacity": opacity, "raster-fade-duration": 0, ...extra },
}) as GlSpec;
const lineKind = (key: LineLayerKey): GlSpec[] => {
const { color, width, dash } = LINE_LAYERS[key];
return haloLine(key, key, themeColor(color, "#f43f5e"), width, dash);
};
return [
// 위성 = 조금 어둡고 연하게(요구 475 · 노선 선 대비) — 밝기 상한은 사용자 조절
raster("satellite", 1, { "raster-brightness-max": light, "raster-saturation": SAT_SATURATION }),
raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈)
raster("slope", 0.55),
...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2)
...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각
...geologySpecs(), // 암상 지질도 면 · 글(760-1)
...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }),
// 띠 = 한 자료(`_band`) · 겹 둘(634-2 · 도로 300 m 아래 · 계류 100 m 위 — 옛 한 겹 차례 그대로)
...fillEdge("roadBands", "bands", COLOR, 0.16, BAND_EDGE, ["==", ["get", "_band"], "road"]),
...fillEdge("bands", "bands", COLOR, 0.18, BAND_EDGE, ["==", ["get", "_band"], "stream"]),
{
key: "cadastral",
id: "cadastral",
type: "line",
source: "cadastral",
layout: ROUND,
paint: {
"line-color": themeColor("--draw-map-parcel", "#f0abfc"),
"line-width": 0.8,
"line-opacity": 0.8,
},
} as GlSpec,
...((): GlSpec[] => {
const water = themeColor("--draw-map-water", "#0ea5e9");
return fillEdge("water", "water", water, 0.45, { color: water, width: 1 });
})(),
...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, {
color: HALO,
width: 0.6,
}),
{
key: "contour",
id: "contour-minor",
type: "line",
source: "contour",
"source-layer": "contour",
layout: ROUND,
paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 },
} as GlSpec,
{
key: "contour",
id: "contour-index",
type: "line",
source: "contour",
"source-layer": "contour",
filter: BOLD,
layout: ROUND,
paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 },
} as GlSpec,
{
key: "contour",
id: "contour-label",
type: "symbol",
source: "contour",
"source-layer": "contour",
filter: BOLD,
layout: {
"symbol-placement": "line",
"symbol-spacing": 320,
"text-field": ["to-string", ["get", "h"]],
"text-font": [GL_FONT],
"text-size": 10,
"text-max-angle": 30,
"text-padding": 2,
},
paint: {
"text-color": contour,
"text-halo-color": "rgba(0, 0, 0, 0.6)",
"text-halo-width": 1.5,
},
} as GlSpec,
...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], {
color: HALO,
width: 3.2,
}),
...forestRoadSpecs(), // 임도망도(전국 국가임도) = 벡터 조각 · 임도(업로드) 아래
...haloLine(
"forestRoadFile",
"forestRoadFile",
themeColor("--draw-map-forest-road-file", "#ff2fd0"),
2.4,
[],
{ color: HALO, width: 4 },
),
// 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게
...["-pick-halo", "-pick"].map(
(tail, i) =>
({
key: "forestRoadFile",
id: `forestRoadFile${tail}`,
type: "line",
source: "forestRoadFile",
filter: pickFilter(pick),
layout: ROUND,
paint: {
"line-color": i ? themeColor("--draw-map-sketch", "#ffe94d") : HALO,
"line-width": i ? 4.2 : 7.4,
},
}) as GlSpec,
),
{
key: "stream",
id: "stream",
type: "line",
source: "stream",
layout: ROUND,
paint: {
"line-color": themeColor("--draw-map-stream", "#38bdf8"),
"line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4],
},
} as GlSpec,
...LINE_KEYS.flatMap(lineKind),
// 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리
...haloLine(
"landslideZones",
"landslideZones",
landslideZone,
1.8,
[6, 3],
{ color: "rgba(255, 255, 255, 0.7)", width: 3.2 },
["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification,
),
{
key: "landslideZones",
id: "landslideZones-point",
type: "circle",
source: "landslideZones",
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-radius": 5,
"circle-color": landslideZone,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5,
},
} as GlSpec,
forestRoadLabelSpec(), // 임도망도 글 — 지명 아래(겹치면 지명이 앞섬)
{
key: "place",
id: "place-dot",
type: "circle",
source: "place",
paint: { "circle-radius": 2.5, "circle-color": place },
} as GlSpec,
{
key: "place",
id: "place",
type: "symbol",
source: "place",
layout: {
"text-field": ["get", "name"],
"text-font": GL_FONT_BOLD,
"text-size": 11,
"text-anchor": "bottom",
"text-offset": [0, -0.36],
"text-padding": 2,
},
paint: {
"text-color": place,
"text-halo-color": "rgba(0, 0, 0, 0.7)",
"text-halo-width": 1.5,
},
} as GlSpec,
];
}
/** 고른 묶음 · 선만 거름(고름 없음 = 아무것도 안 맞는 거름). */
export function pickFilter(pick: RoadPick | null): FilterSpecification {
const all: unknown[] = ["all", ["==", ["get", "__bundle"], pick?.bundle ?? ""]];
if (pick?.line !== undefined) all.push(["==", ["get", "__line"], pick.line]);
return all as unknown as FilterSpecification;
}
/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */
export const MAP_THEME_NAMES = [
"--draw-map-contour",
"--draw-map-road",
"--draw-map-stream",
"--draw-map-parcel",
] as const;
/** 위성 채도(−1~1) — 조금 연하게. */
const SAT_SATURATION = -0.3;
+215
View File
@@ -0,0 +1,215 @@
/* =============================================================================
* map_legend.ts
* 공용 지도 범례(PLAN 569-2 · 622-2 · 624-2 · 요구 530) — 켠 겹 범례 칸 하나(줄마다 `createLegend`) + 페이지가 붙이는 덧 줄.
* 조각 겹 범례(산사태위험 · 임도망도 · 경제림 · 생태자연도) = 받을 자료 없음 → 켬끔만 보고 여기서 만듦(`tileLegendRows`).
* 받은 자료 범례 줄(수 · 넓이 · 급경사 범위 · 624-3 D03 에서 옮김) = `legendRows(on, data)` — 받은 자료 꼴은 `LegendData`
* (D03 옛 캔버스 자료 · B05 GL 자료 둘 다 맞음 · 받는 일은 페이지 몫).
* 암상 지질도 줄 · 노선 구간 표 = `map_geology`(760-1) · 켠 밖 겹 출처 줄 = 맨 아래(`map_layer_source` · 598-4).
* 쓰는 법 = `const legend = createMapLegend("d03-plan__legend")` → `legend.show(rows, [덧 줄])` · 줄 · 덧 없으면 숨김.
* ========================================================================== */
import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend";
import { forestLegendRows } from "./map_forest_layers";
import { geologyRow, type PlanGeology } from "./map_geology";
import type {
PlanBand,
PlanForestRoads,
PlanLandslideZones,
PlanRaster,
PlanSoils,
PlanZones,
} from "./map_layer_api";
import { hectare } from "./map_layer_data";
import { sourceRow } from "./map_layer_source";
import {
LANDSLIDE_LEGEND,
LINE_KEYS,
LINE_LAYERS,
ZONE_COLORS,
type MapLayerKey,
} from "./map_layer_list";
import { SLOPE_MAX_PCT, bandRatios, pctToDeg, type SlopeBand } from "./logic/map_slope_bands";
/** 지물 수만 보는 겹(옛 캔버스 자료 · GeoJSON 둘 다). */
interface Counted {
features?: ArrayLike<unknown>;
}
/** 범례가 읽는 받은 자료 — 받은 겹만. */
export interface LegendData {
slope: { raster: PlanRaster; bands: SlopeBand[] };
landslideZones: { info: PlanLandslideZones };
zones: { summary: PlanZones["summary"] };
/** 토양형(코드 차례) 색 값 · 요약. */
soils: { kinds: Array<{ color: string }>; summary: PlanSoils["summary"] };
bands: { info: { stream: PlanBand; road: PlanBand } };
water: Counted;
building: Counted;
road: { small: Counted };
forestRoadFile: { info: PlanForestRoads };
faults: { count: number; length_m: number };
lineaments: { count: number; length_m: number };
zeroOrder: { count: number; length_m: number };
geology: PlanGeology;
}
export interface MapLegend {
root: HTMLElement;
show: (rows: LegendItem[][], tail?: HTMLElement[]) => void;
}
/** 켠 조각 겹 범례 — 산사태위험 다음 산림 셋. */
export const tileLegendRows = (on: Record<MapLayerKey, boolean>): LegendItem[][] => [
...(on.landslide ? [LANDSLIDE_LEGEND] : []),
...forestLegendRows(on),
];
/** `className` = 자리 · 꼴(페이지 CSS). */
export function createMapLegend(className: string): MapLegend {
const root = document.createElement("div");
root.className = className;
root.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
return {
root,
show(rows, tail = []) {
root.replaceChildren(...rows.map((row) => createLegend(row)), ...tail);
root.hidden = !rows.length && !tail.length;
},
};
}
const km = (m: number): string => `${(m / 1000).toFixed(2)} km`;
/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */
function slopeRow({ raster, bands }: LegendData["slope"]): LegendItem[] {
const ratios = bandRatios(raster.counts ?? [], bands);
const deg = (pct: number): string => pctToDeg(pct).toFixed(0);
return bands.map((band, i) => {
const open = band.high >= SLOPE_MAX_PCT;
const range = open
? `${band.low} %↑(${deg(band.low)}°↑)`
: `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`;
return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` };
});
}
/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */
function landslideZoneRow({ info }: LegendData["landslideZones"]): LegendItem[] {
const color = "var(--draw-map-landslide-zone)";
const items: LegendItem[] = [
{
label: `산사태취약지역 점 ${info.points.length}곳`,
color,
kind: "dot",
off: !info.points.length,
},
{
label: `지번 맞는 필지 ${info.parcels.features?.length ?? 0}곳(테두리)`,
color,
kind: "dash",
off: !info.parcels.features?.length,
},
];
for (const sido of info.sidos)
if (!sido.located) items.push({ label: `${sido.name} 위치 없음 — 필지만`, color, off: true });
for (const name of info.missing)
items.push({ label: `${name} — 이 시도 자료 없음`, color: "transparent", off: true });
if (!info.cadastral)
items.push({ label: "지적도 못 받음 — 필지 테두리 없음", color: "transparent", off: true });
return items;
}
/** 켠 겹의 받은 자료 범례 줄(급경사 · 조각 겹 · 산사태취약 · 보호구역 · 토양 · 띠 · 선 겹 · 물 면 · 건물 · 임도). */
export function legendRows(
on: Record<MapLayerKey, boolean>,
data: Partial<LegendData>,
): LegendItem[][] {
const rows: LegendItem[][] = [];
if (on.slope && data.slope) rows.push(slopeRow(data.slope));
rows.push(...tileLegendRows(on)); // 조각 겹 = 받기 없음(622-2 · 공용 범례)
if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones));
if (on.zones && data.zones) {
const items = Object.entries(data.zones.summary)
.filter(([, kind]) => kind.count || kind.failed)
.map(([key, kind]) => ({
label: kind.failed
? `${kind.label} 받지 못함`
: `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`,
color: `var(${ZONE_COLORS[key]})`,
off: !!kind.failed,
}));
rows.push(
items.length
? items
: [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }],
);
}
if (on.soils && data.soils) {
const { kinds, summary } = data.soils;
rows.push(
summary.length
? summary.map((soil, i) => ({
label: `${soil.name} ${hectare(soil.area_m2)}`,
color: `var(${kinds[i].color})`,
}))
: [{ label: "산림입지도 없음(받은 범위 안)", color: "transparent", off: true }],
);
}
// 계류 100 m 띠 · 도로 300 m 띠 = 겹 둘(634-2 · 자료 한 벌)
if ((on.bands || on.roadBands) && data.bands) {
const { stream, road } = data.bands.info;
rows.push([
...(on.bands
? [
{
label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`,
color: "var(--draw-map-band-stream)",
},
]
: []),
...(on.roadBands
? [
{
label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`,
color: "var(--draw-map-band-road)",
},
]
: []),
]);
}
const lines = LINE_KEYS.flatMap((key) => {
const got = on[key] && data[key];
if (!got) return [];
const { label, color, dash } = LINE_LAYERS[key];
return [
{
label: `${label} ${got.count}가닥 ${km(got.length_m)}`,
color: `var(${color})`,
kind: dash.length ? ("dash" as const) : ("line" as const),
off: !got.count,
},
];
});
if (lines.length) rows.push(lines);
if (on.geology && data.geology) rows.push(geologyRow(data.geology));
const tail: LegendItem[] = [];
if (on.water && data.water)
tail.push({
label: `물 면 ${data.water.features?.length ?? 0}곳`,
color: "var(--draw-map-water)",
});
if (on.building && data.building)
tail.push({
label: `건물 ${(data.building.features?.length ?? 0).toLocaleString()}동`,
color: "var(--draw-map-building)",
});
if (on.forestRoadFile && data.forestRoadFile)
tail.push({
label: `임도(업로드) ${data.forestRoadFile.info.count}가닥 ${km(data.forestRoadFile.info.length_m)}`,
color: "var(--draw-map-forest-road-file)",
kind: "line",
});
if (tail.length) rows.push(tail);
rows.push(...sourceRow(on).map((item) => [item]));
return rows;
}
+117
View File
@@ -0,0 +1,117 @@
/* =============================================================================
* map_lidar_upload.ts
* 공용 [LAS 올리기](PLAN 637-3 · 요구 546) — 예정노선(D03) · B05 지형 칩 옆 같은 단추 · 같은 흐름.
* 단추 → 파일 고름(LAS · LAZ + .prj) → `POST <path>`(multipart `files` · 못 받으면 서버 글 그대로 알림)
* → 1 초마다 `poll` 로 진행(`show` = 페이지 진행 서클) → 끝(오류면 알림) → 서클 · 단추 풀고 `onDone(마지막 진행)`.
* 주소 · 진행 묻기 · 서클 · 끝난 뒤 일은 페이지 인자(D = `/plan/lidar` · B = `/route/lidar`).
* 지켜보는 동안 단추 막힘 · 페이지를 떠나면(단추가 문서 밖) 그침.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_button";
import { pickFiles } from "@ui/ui_template_drop";
import { showToast } from "@ui/ui_template_elements";
const POLL_MS = 1000;
/** 서버 뒤 일 진행(`common_util_lidar_upload.start_job` 꼴). */
export interface LidarJob {
running: boolean;
percent: number;
message?: string | null;
error?: string | null;
interrupted?: boolean;
}
export interface LidarUpload {
/** [LAS 올리기] 단추(보임은 페이지 몫). */
button: HTMLButtonElement;
/** 도는 일을 끝날 때까지 지켜봄(들어올 때 이어 보기). */
follow: () => Promise<void>;
/** 지켜보는 중. */
busy: () => boolean;
}
const errorText = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
/** LAS 묶음 올리기 — 못 받으면 서버 `detail` 글 그대로 오류. */
async function postFiles(path: string, files: File[]): Promise<void> {
const form = new FormData();
for (const file of files) form.append("files", file);
const response = await fetch(`${API_BASE_URL}${path}`, {
method: "POST",
credentials: "include",
body: form,
});
if (response.ok) return;
const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
export function createLidarUpload<J extends LidarJob>(opts: {
/** 올리기 주소(`API_BASE_URL` 뒤). */
path: string;
/** 진행 한 번 묻기. */
poll: () => Promise<J>;
/** 진행 보임(0~1 · 글) · null = 끝. */
show: (state: { ratio: number; text: string } | null) => void;
/** 지켜보기를 감쌈(예: 서클 일 셈). */
track?: (job: Promise<void>) => Promise<void>;
/** 지켜보기 시작 · 끝. */
onBusy?: (busy: boolean) => void;
/** 짓기가 끝난 뒤(오류 알림 뒤 · 단추 풀린 뒤 · 마지막 진행). */
onDone: (job: J) => Promise<void> | void;
}): LidarUpload {
let busy = false;
const button = createButton({
label: "LAS 올리기",
variant: "ghost",
size: 32,
iconStart: "upload",
onClick: () =>
pickFiles({ accept: ".las,.laz,.prj", multiple: true }, (files) => void send(files)),
});
const setBusy = (on: boolean): void => {
busy = on;
button.disabled = on;
opts.onBusy?.(on);
};
async function send(files: File[]): Promise<void> {
try {
await postFiles(opts.path, files);
} catch (error) {
return showToast(`LAS 올리지 못함 — ${errorText(error)}`, "error");
}
await follow();
}
async function watch(): Promise<void> {
let last: J | null = null;
setBusy(true);
opts.show({ ratio: 0, text: "" });
try {
while (button.isConnected) {
const job = await opts.poll();
opts.show({ ratio: job.percent / 100, text: job.message ?? "" });
if (!job.running) {
if (job.error) showToast(`라이다 지형 못 지음 — ${job.error}`, "error");
last = job;
break;
}
await new Promise((done) => setTimeout(done, POLL_MS));
}
} catch (error) {
showToast(`라이다 진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
} finally {
opts.show(null);
setBusy(false);
}
if (last) await opts.onDone(last);
}
const follow = (): Promise<void> => (opts.track ? opts.track(watch()) : watch());
return { button, follow, busy: () => busy };
}
+57
View File
@@ -0,0 +1,57 @@
/* =============================================================================
* map_route_preview.ts
* 공용 「추천노선」 미리보기(PLAN 626-1 → 636-2 공용 · 요구 530 · 546) — B05 평면 · 예정노선(D03)이 같이 씀.
* 메뉴 = 도구줄 아이콘 하나(compass · 호버 「추천노선」) + 아래 판(여럿 고르기 · 줄마다 색 점 · 「끔」 줄 없음 ·
* 다 끄면 꺼짐 · 부품 `createPickMenu({ multi })` · 589-12).
* 그리기 = 지도 위 선(노랑 `--draw-map-sketch` · 검은 테 · 기본 점선) — 위성 · 어두운 바탕 모두 보임.
* 항목 · 선 셈은 페이지 몫: B05 = 노선 추천 저장본 넷(`B05_Profile_UI_RouteEdit_RecPreview`) ·
* 예정노선 = 가이드 선(`D03_PlanAnalysis_UI_Guide`).
* ========================================================================== */
import { createPickMenu, type PickMenu, type PickMenuItem } from "@ui/ui_template_context_menu";
import { themeColor } from "@ui/ui_template_palette";
type Vertex = [number, number] | [number, number, number];
/** 「추천노선」 메뉴 — 줄 누름 = `onPick(값)`(켬끔 바뀜 · 지금 켠 줄은 `menu.values()`). */
export function createRoutePreviewMenu(opts: {
items: PickMenuItem[];
onPick: (value: string) => void;
}): PickMenu {
const menu = createPickMenu({
multi: true,
icon: "compass",
title: "추천노선", // 마우스 글 = 공용 표 호버제목(634-2 · `mapToolGroups` 가 맞춤)
items: opts.items,
onPick: (value) => value && opts.onPick(value),
});
menu.root.dataset.act = "recommend";
return menu;
}
/** 미리보기 선 하나 — 검은 테(5 px) 위 색 선(3 px) · 기본 노랑 점선. */
export function paintRoutePreview(
context: CanvasRenderingContext2D,
line: readonly Vertex[],
toScreen: (vertex: [number, number]) => [number, number],
style: { dash?: number[]; color?: string } = {},
): void {
if (line.length < 2) return;
context.save();
context.setLineDash(style.dash ?? [10, 6]);
context.lineJoin = "round";
context.beginPath();
line.forEach((vertex, index) => {
const [x, y] = toScreen([vertex[0], vertex[1]]);
if (index) context.lineTo(x, y);
else context.moveTo(x, y);
});
// 지도 위 그림 색(테마 안 따름) + 검은 테 — 위성 · 어두운 바탕 모두 보임
context.strokeStyle = "rgba(0, 0, 0, 0.6)";
context.lineWidth = 5;
context.stroke();
context.strokeStyle = style.color ?? themeColor("--draw-map-sketch", "#ffe94d");
context.lineWidth = 3;
context.stroke();
context.restore();
}
@@ -1,11 +1,11 @@
/* =============================================================================
* D03_PlanAnalysis_UI_SatLight.ts
* D03 위성 바탕 밝기(요구 475) — 노선 선 대비를 위해 위성을 조금 어둡게 · 사용자가 범례에서 조절.
* 값 = 밝기 상한(0.4~1) · 계정 화면 취향으로 기억. 칠하기 = `D03_PlanAnalysis_UI_MapGl` `setSatelliteLight`.
* map_sat_light.ts
* 공용 지도 위성 바탕 밝기(D03 · B05 · 624-3 공용으로 옮김)(요구 475) — 노선 선 대비를 위해 위성을 조금 어둡게 · 사용자가 범례에서 조절.
* 값 = 밝기 상한(0.4~1) · 계정 화면 취향으로 기억. 칠하기 = D03 `UI_MapGl` `setSatelliteLight` · B05 `UI_RouteEdit_Layers`.
* ========================================================================== */
import { createSlider } from "@ui/ui_template_input";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { readByKey, writeByKey } from "../b_page_state";
const PREF_KEY = "aislo:pref:d03.satellite-light";
export const SAT_LIGHT_MIN = 0.4;
+201
View File
@@ -0,0 +1,201 @@
/* =============================================================================
* map_slope_bands.ts
* 공용 지도 급경사 범위 옵션(D03 · B05 · 624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달
* (631 · 요구 537 = 수평 바 `ui_template_range_bar` — 칸 사이 경계값 한 번 · 아래 ° 환산 · 칸 눌러 색 · 칠 안 함 칸 ·
* [단계 추가] = 고른 칸 반으로 · [단계 지움] = 이웃과 합침 · [기본값] · [적용] · 빈 값 · 같은 값 · 거꾸로 막음).
* 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관).
* 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장).
* 범위 · 비율 셈 = `logic/map_slope_bands.ts` · 부르는 곳 = D03 `UI_Layers` · B05 `UI_RouteEdit_Layers`.
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
import { openModal } from "@ui/ui_template_modal";
import { themeColor } from "@ui/ui_template_palette";
import { createRangeBar } from "@ui/ui_template_range_bar";
import { createText } from "@ui/ui_template_text";
import { readByKey, writeByKey } from "../b_page_state";
import { loadImage } from "./map_layer_data";
import {
DEFAULT_SLOPE_BANDS,
SLOPE_COLORS,
SLOPE_MAX_BANDS,
SLOPE_MAX_PCT,
bandOf,
bandsToBar,
barToBands,
checkBands,
checkEdges,
mergeBar,
parseBands,
pctToDeg,
splitBar,
type SlopeBand,
type SlopeBar,
} from "./logic/map_slope_bands";
const PREF_KEY = "aislo:pref:d03.slope-bands";
/** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */
const ALPHA = 150;
export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY));
export const writeSlopeBands = (bands: SlopeBand[]): void =>
writeByKey(PREF_KEY, JSON.stringify(bands));
/** 값 그림 → 칸 값(빨강 자리 = 회색 값) · 받은 blob 주소는 다 읽었으니 놓음(616-4). */
export function slopeValues(image: HTMLImageElement): ImageData {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d", { willReadFrequently: true })!;
context.drawImage(image, 0, 0);
if (image.src.startsWith("blob:")) URL.revokeObjectURL(image.src);
return context.getImageData(0, 0, canvas.width, canvas.height);
}
/** 범위대로 칠한 그림(주소 = data URL · toBlob 의 한가 작업 기다림 최대 1 s 없앰 616-4 ·
* 부르는 쪽 `URL.revokeObjectURL` 은 그대로 둬도 됨). */
export async function paintSlope(
values: ImageData,
bands: SlopeBand[],
): Promise<{ url: string; image: HTMLImageElement }> {
const rgb = bands.map((band) => hexRgb(themeColor(band.color, "#facc15")));
const lut = new Uint32Array(256);
const view = new DataView(lut.buffer);
for (let v = 0; v < 256; v++) {
const i = bandOf(v, bands);
if (i >= 0) view.setUint32(v * 4, ((rgb[i] << 8) | ALPHA) >>> 0, false);
}
const out = new ImageData(values.width, values.height);
const src = values.data;
const dst = new Uint32Array(out.data.buffer);
for (let p = 0; p < dst.length; p++) dst[p] = lut[src[p * 4]];
const canvas = document.createElement("canvas");
canvas.width = values.width;
canvas.height = values.height;
canvas.getContext("2d")!.putImageData(out, 0, 0);
const url = canvas.toDataURL("image/png");
return { url, image: await loadImage(url) };
}
/** `#rrggbb` → 0xrrggbb(못 읽으면 노랑). */
function hexRgb(color: string): number {
const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())?.[1];
return hex ? parseInt(hex, 16) : 0xfacc15;
}
/** 범례 급경사 줄 ⚙. */
export const slopeGear = (onClick: () => void): HTMLButtonElement => {
const button = createIconButton({
icon: "settings",
title: "급경사 범위 바꾸기(기울기 %)",
size: 20,
onClick,
});
button.dataset.role = "slope-bands";
return button;
};
/** 옵션 모달 — 수평 바(631 · 요구 537) · [적용] = 막을 것 없으면 `onApply` 후 닫음. */
export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void {
const error = createText("", "error");
error.hidden = true;
const bar = createRangeBar({
...bandsToBar(current),
palette: SLOPE_COLORS.map((c) => ({ value: c.color, name: c.name, color: `var(${c.color})` })),
noneLabel: "칠 안 함",
unit: "%",
ends: ["0 %", `${SLOPE_MAX_PCT} % 이상`],
hint: (pct) => `≈ ${pctToDeg(pct).toFixed(1)}°`,
ariaLabel: "급경사 범위",
onChange: () => refresh(),
onSelect: () => refresh(),
});
bar.root.dataset.role = "slope-bar";
/** 고칠 거리 — 경계 먼저 · 다음 범위(색 칸 없음 · 너무 많음). */
function problem(): string | null {
const state = bar.get();
return checkEdges(state.edges) ?? checkBands(barToBands(state));
}
function refresh(): void {
const state = bar.get();
const why = problem();
error.lastChild!.textContent = why ?? "";
error.hidden = !why;
const bad = checkEdges(state.edges) ? /(\d+)번째 경계/.exec(why ?? "")?.[1] : undefined;
bar.setError(bad ? Number(bad) : undefined);
addButton.disabled = !splitBar(state, bar.selected());
removeButton.disabled = !mergeBar(state, bar.selected());
}
const step =
(make: (state: SlopeBar, i: number) => SlopeBar | null, next: (i: number) => number) => () => {
const i = bar.selected();
const got = make(bar.get(), i);
if (!got) return;
bar.set(got, next(i));
refresh();
};
const addButton = createButton({
label: "단계 추가",
variant: "ghost",
size: 28,
iconStart: "plus",
onClick: step(splitBar, (i) => i + 1),
});
addButton.dataset.role = "slope-band-add";
addButton.title = "고른 칸을 반으로 나눔";
const removeButton = createButton({
label: "단계 지움",
variant: "ghost",
size: 28,
iconStart: "trash",
onClick: step(mergeBar, (i) => Math.max(0, i - 1)),
});
removeButton.dataset.role = "slope-band-remove";
removeButton.title = "고른 칸을 이웃 칸과 합침";
openModal({
title: "급경사 범위(기울기 %)",
closeLabel: "닫기",
width: "960px",
mount: (body, close, actions) => {
const reset = createButton({
label: "기본값",
variant: "ghost",
size: 28,
onClick: () => {
bar.set(bandsToBar(DEFAULT_SLOPE_BANDS), 0);
refresh();
},
});
reset.dataset.role = "slope-band-default";
const apply = createButton({
label: "적용",
size: 28,
onClick: () => {
refresh();
if (problem()) return;
onApply(barToBands(bar.get()));
close();
},
});
apply.dataset.role = "slope-band-apply";
actions.append(apply);
const foot = document.createElement("div");
foot.className = "d03-plan__row";
foot.append(addButton, removeButton, reset);
body.append(
createText(
`칸 사이 = 경계값(아래 칸 끝 = 위 칸 처음 · 아래 포함) · 칸 눌러 고르고 색 · 단계 ${SLOPE_MAX_BANDS}개까지 · 기본 = 15 · 35 · 40°(지식DB)`,
"hint",
),
bar.root,
foot,
error,
);
refresh();
},
});
}
+65
View File
@@ -0,0 +1,65 @@
/* =============================================================================
* map_terrain_chip.ts
* 공용 지도 지형 칩(PLAN 624-2 · 요구 530 · 결정 A④ · A⑤ · D2) — 도구줄 끝 분할 고르기 하나: 수치지형도 · 라이다.
* 마우스 글 = 도구줄 표 호버제목(「수치지형도/라이다」 · 634-2) · 확인창은 B05 몫.
* 없는 원천 = 막힘 + 「제목 — 까닭」(D03 라이다 = D 프로젝트에 LAS 올린 뒤 · 624-4).
* 화면 칩만 — 바꾸는 일(서버 원천 인자 624-1 · B05 설계 지형 바꿈 626-1)은 `onChange` 로 페이지가 붙임.
* 쓰는 법 = `createTerrainChip({ page: "d03", value: "sheet", have: ["sheet"] })` → `{ root, set, setHave }` · root 를 도구줄 `terrain` 칸에.
* ========================================================================== */
import { createSegment } from "@ui/ui_template_segment";
import type { MapPage } from "./map_layer_list";
import { mapToolTitle } from "./map_toolbar";
/** 서버 겹 길 `terrain=` 값과 같음(624-1 · 빈 값 = 설계 지형). */
export type TerrainSource = "sheet" | "lidar";
export const TERRAIN_NAMES: Record<TerrainSource, string> = {
sheet: "수치지형도",
lidar: "라이다",
};
/** 막힘 까닭(마우스 글 = 「수치지형도/라이다 — 까닭」 · 요구 546). */
const MISSING: Record<TerrainSource, string> = {
sheet: "이 프로젝트 범위에 도엽 지형 없음",
lidar: "올린 LAS 없음",
};
const TITLE = mapToolTitle("terrain");
export interface TerrainChip {
root: HTMLElement;
/** 고름을 바꿈(onChange 는 안 부름). */
set: (value: TerrainSource) => void;
/** 고를 수 있는 원천 — 없는 것은 막힘. */
setHave: (have: readonly TerrainSource[]) => void;
}
export function createTerrainChip(opts: {
page: MapPage;
value: TerrainSource;
have: readonly TerrainSource[];
onChange?: (value: TerrainSource) => void;
}): TerrainChip {
const sources = Object.keys(TERRAIN_NAMES) as TerrainSource[];
const segment = createSegment({
options: sources.map((value) => ({ value, label: TERRAIN_NAMES[value] })),
value: opts.value,
size: 32,
ariaLabel: TITLE,
onChange: (value) => opts.onChange?.(value as TerrainSource),
});
const root = segment.root;
root.dataset.act = "terrain";
root.title = TITLE;
const setHave = (have: readonly TerrainSource[]): void => {
for (const value of sources) {
const item = root.querySelector<HTMLButtonElement>(`[data-value="${value}"]`)!;
item.disabled = !have.includes(value);
// 열림 = 글 없음(칩 글 「수치지형도/라이다」 그대로) · 막힘 = 「제목 — 까닭」
if (item.disabled) item.title = `${TITLE} — ${TERRAIN_NAMES[value]} ${MISSING[value]}`;
else item.removeAttribute("title");
}
};
setHave(opts.have);
return { root, set: (value) => segment.set(value), setHave };
}
+95
View File
@@ -0,0 +1,95 @@
/* =============================================================================
* map_toolbar.ts
* 공용 지도 도구줄 표(PLAN 624-2 · 634-2 · 요구 530 · 546) — 예정노선(D03) · B05 평면이 같이 씀.
* 한 줄 = 요구 546 그룹2 표 한 줄 — 열쇠(`ui_template_map_tools` 단추 이름 · 아이콘) · 호버제목(`title`) · 할 일 · 보일 페이지.
* 묶음 = 나눔선 · 차례 = 도구줄 차례 · 단추 아닌 칸(간격 고르기 · 추천 노선 보기 · 지형 칩)은 페이지가 만든 요소.
* 마우스 글 = 이 표 `title` 하나(페이지 덮어쓰기 없음) · 막힘이면 페이지가 `mapToolTip(단추, 까닭)` → 「제목 — 까닭」.
* 누름 동작은 페이지 몫 — 만든 요소를 `parts` 로 넘기면 그 자리에 · 안 넘긴 단추는 기본 단추(돌리기 둘 = 공용 돌림이 `data-act` 로 찾음).
* [?] 도움말 단추 = `createMapTools` 의 `hint`(맨 앞 · 글 = 이 표 첫 줄).
* 쓰는 법 = `createMapTools({ label, hint, groups: mapToolGroups("d03", { undo: undoButton, terrain: chip.root }) })`.
* ========================================================================== */
import { mapToolButton, mapToolTip, type MapToolAct } from "@ui/ui_template_map_tools";
import type { MapPage } from "./map_layer_list";
export type MapToolKey = MapToolAct | "hint-toggle" | "recommend" | "terrain";
export interface MapToolRow {
key: MapToolKey;
/** 호버제목(요구 546 표). */
title: string;
does: string;
pages: readonly MapPage[];
}
const BOTH: readonly MapPage[] = ["d03", "b05"];
const B05: readonly MapPage[] = ["b05"];
export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [
[{ key: "hint-toggle", title: "도움말", does: "조작 도움말 펼치기 · 접기(`hint`)", pages: BOTH }],
[
{ key: "undo", title: "되돌리기", does: "편집 한 걸음 되돌림(Ctrl+Z)", pages: BOTH },
{ key: "redo", title: "다시하기", does: "되돌린 걸음 다시(Ctrl+Y)", pages: BOTH },
],
[
{ key: "view3d", title: "3D", does: "3D 큰 창(동작 = 페이지 몫)", pages: BOTH },
{ key: "rotate-ccw", title: "좌 회전", does: "지도 반시계로 돌림(Ctrl+휠도)", pages: BOTH },
{ key: "rotate-cw", title: "우 회전", does: "지도 시계로 돌림", pages: BOTH },
],
[
{
key: "contour-minor",
title: "등고선 1m",
does: "라이다 1 m 등고선 켬끔(라이다일 때만 열림)",
pages: B05,
},
{ key: "stations", title: "측점", does: "측점 눈금 · 번호 켬끔", pages: BOTH },
{ key: "slopes", title: "길이·기울기", does: "구간 길이 · 기울기 글 켬끔", pages: BOTH },
{ key: "footprint", title: "절성토선", does: "도로 끝 · 비탈 끝 켬끔", pages: B05 },
{ key: "measure", title: "길이측정", does: "거리 재기(동작 = 페이지 몫 · A⑩)", pages: BOTH },
],
[
{
key: "station-snap",
title: "스냅",
does: "곡선 가운데(MC)를 간격 배수에 맞춤 · 고르기 메뉴 하나(측점 No. · 10 m · 끔 · 589-4)",
pages: B05,
},
],
[
{
key: "recommend",
title: "추천노선",
does: "B05 = 노선 추천 저장본 선 여럿 고르기(색 구분) · 예정노선 = 가이드 선(지점 잇기 · 636-2)",
pages: BOTH,
},
],
[{ key: "terrain", title: "수치지형도/라이다", does: "지형 원천 보기 · 고르기", pages: BOTH }],
];
const ROWS = MAP_TOOLBAR.flat();
/** 표 호버제목 — 페이지 칸(지형 칩 막힘 글 등)이 씀. */
export const mapToolTitle = (key: MapToolKey): string =>
ROWS.find((row) => row.key === key)?.title ?? key;
const isAct = (key: MapToolKey): key is MapToolAct =>
key !== "hint-toggle" && key !== "station-snap" && key !== "recommend" && key !== "terrain";
/** 그 페이지 도구줄 묶음 — 넘긴 요소 · 기본 단추(단추 열쇠만) · 마우스 글 = 표 · 빈 묶음 뺌. */
export function mapToolGroups(
page: MapPage,
parts: Partial<Record<MapToolKey, HTMLElement>> = {},
): HTMLElement[][] {
return MAP_TOOLBAR.map((group) =>
group
.filter((row) => row.pages.includes(page))
.flatMap((row): HTMLElement[] => {
const made = parts[row.key] ?? (isAct(row.key) ? mapToolButton(row.key) : null);
if (!made) return [];
made.dataset.tip = row.title;
mapToolTip(made);
return [made];
}),
).filter((group) => group.length);
}
+133
View File
@@ -0,0 +1,133 @@
/* =============================================================================
* master_unit_swap.ts
* SK 단위 바꿈 환산(728-1) — 짝(①): `resources/master_data/scripts/master_unit.py` `swap` · `split`.
* 거울 시험 `resources/tester/test_728_1_unit_swap.py`. 단위 표 · 별칭 · 분모 읽기는 그 모듈 그대로 옮김
* (한 벌 — 고칠 때 두 파일 함께). 화면 · fetch · 저장 없음.
* ========================================================================== */
const ALIAS: Record<string, string> = {
ea: "개",
ton: "톤",
t: "톤",
l: "ℓ",
리터: "ℓ",
set: "조",
세트: "조",
hr: "시간",
};
const UNITS: Record<string, Record<string, number>> = {
무게: { g: 1, kg: 1000, 톤: 1000000 },
길이: { mm: 1, cm: 10, m: 1000, km: 1000000 },
넓이: { cm2: 1, m2: 10000 },
부피: { cm3: 1, ℓ: 1000, m3: 1000000 },
개수: { 개: 1, 본: 1, 매: 1, 개소: 1 },
};
const SCALE: Record<string, [string, number]> = {};
for (const [kind, group] of Object.entries(UNITS)) {
for (const [u, n] of Object.entries(group)) SCALE[u] = [kind, n];
}
const WEIGHT_TABLE = "CC000337";
const PER = /원\s*\/\s*(\d[\d,]*\.?\d*|\.\d+)?\s*([^\s·(\d,.][^\s·(]*)/;
export type UnitNeed = null | "단위중량" | "사용자";
export interface UnitSwap {
가: [number, string];
나: [number, string];
성격: [string | null, string | null];
/** have 1 개가 want 몇 개 — 1 단위 당 값은 ÷배 · 수량은 ×배 · 계수 없으면 null(셈 막음) */
배: number | null;
계수: UnitNeed;
근거: string;
}
export function plainUnit(unit: unknown): string {
const s = String(unit ?? "")
.normalize("NFKC")
.toLowerCase()
.replace(/\s+/g, "")
.replace(/ℓ/g, "l");
return ALIAS[s] ?? s;
}
export function unitKind(unit: unknown): string | null {
return SCALE[plainUnit(unit)]?.[0] ?? null;
}
function sameKind(a: unknown, b: unknown): boolean {
const k = unitKind(a);
return plainUnit(a) === plainUnit(b) || (k !== null && k === unitKind(b));
}
function weighs(a: unknown, b: unknown): boolean {
const ks = new Set([unitKind(a), unitKind(b)]);
return ks.size === 2 && ks.has("무게") && ks.has("부피");
}
/** `have` 1 단위가 `want` 몇 단위 — 부피 ↔ 무게는 단위중량(㎏/㎥) 있을 때만 · 못 하면 null */
function factor(have: string, want: string, density?: number | null): number | null {
const a = plainUnit(have);
const b = plainUnit(want);
if (a === b) return 1;
if (sameKind(a, b)) return SCALE[a][1] / SCALE[b][1];
if (density && weighs(a, b)) {
const gramsPerCm3 = density / 1000;
const base = SCALE[a][1] * (unitKind(a) === "부피" ? gramsPerCm3 : 1 / gramsPerCm3);
return base / SCALE[b][1];
}
return null;
}
/** 열두 자리로 다듬음 — 파이썬 Decimal 셈과 같은 값(뜬수 끝 흔들림 없앰) */
function tidy(x: number): number {
return Number(x.toPrecision(12));
}
function num(x: number): string {
return Number.isInteger(x) ? String(x) : String(x).slice(0, 10);
}
/** 기준단위 앞 수 떼기 — 「100본」 → [100, 본] · 「㎥」 → [1, ㎥] */
export function splitUnit(unit: unknown): [number, string] {
const text = String(unit ?? "").trim();
const got = text ? PER.exec(`원/${text}`) : null;
if (!got) return [1, text];
return [got[1] ? Number(got[1].replace(/,/g, "")) : 1, got[2]];
}
/** SK 단위 `have` → `want` 환산 — 같은 성격 = 배 자동 · 부피 ↔ 무게 = 단위중량 · 그 밖 = 사용자 계수
* (have 앞 수 뗀 1 단위 = want 앞 수 뗀 몇 단위 · 0 보다 커야). */
export function swapUnit(
have: string,
want: string,
density?: number | null,
coef?: number | null,
): UnitSwap {
const [na, ua] = splitUnit(have);
const [nb, ub] = splitUnit(want);
const base = { 가: [na, ua], 나: [nb, ub], 성격: [unitKind(ua), unitKind(ub)] } as Pick<
UnitSwap,
"가" | "나" | "성격"
>;
if (!ua || !ub) return { ...base, 배: 1, 계수: null, 근거: "" };
const need: UnitNeed = sameKind(ua, ub) ? null : weighs(ua, ub) ? "단위중량" : "사용자";
const f = need === "사용자" ? (coef && coef > 0 ? coef : null) : factor(ua, ub, density);
if (f === null) {
const why =
need === "단위중량"
? `단위중량(${WEIGHT_TABLE}) 골라야 셈`
: "성격 다름 · 환산 계수 넣어야 셈";
return { ...base, 배: null, 계수: need, 근거: `${have} ↔ ${want} — ${why}` };
}
const ratio = tidy((na * f) / nb);
const how =
need === "단위중량"
? ` · 단위중량 ${density}㎏/㎥`
: need === "사용자"
? ` · 계수 1 ${ua} = ${coef} ${ub}`
: "";
let step = "";
if (ratio === 1) step = plainUnit(have) === plainUnit(want) ? "" : `${have} = ${want}`;
else
step = `${have} 당 → ${want} 당 ${ratio > 1 ? `÷${num(ratio)}` : `×${num(tidy(1 / ratio))}`}`;
return { ...base, 배: ratio, 계수: need, 근거: step + how };
}
+10 -3
View File
@@ -72,6 +72,9 @@ export interface SpreadsheetOptions {
conditions?: ConditionOptions;
/** 그림 열쇠 → 주소(요구 347-5 · 시트 `그림`) — 없으면 이름 적힌 빈 상자 */
figureSrc?: FigureSrc;
/** 칸 칠(요구 621-5) — 시트 id · 행 · 열(0 부터) → 꼴 이름 `success|warning|info|danger|muted`(흐림) + `mine`(왼쪽 띠) ·
* 공백으로 여럿 · 칸 `data-tone` 으로 나감 · 보이는 칸만 부름 · 안 주면 화면 그대로 */
칠?: (시트: string, r: number, c: number) => string | null | undefined;
}
export interface SpreadsheetHandle {
@@ -93,6 +96,8 @@ export interface SpreadsheetHandle {
/** HTML 표를 지금 시트 고른 칸(왼위)부터 붙임(105-1 도면 → 면적) — 붙여넣기와 같은 길(병합 · 되돌리기 한 번) ·
* `check` = 덮을 자리의 값 칸 · 알약 칸 수 · 범위 → false 면 안 붙임 · 붙인 범위 A1 · 읽기만 · 못 하면 null */
pasteTable(html: string, check?: PasteCheck): Promise<string | null>;
/** 보이는 칸만 다시 그림(`칠` 결과가 바뀐 뒤) — 문서는 안 읽음 */
repaint(): void;
/** 범위 고름 · 보이게 굴림(329-2 엑셀 보기 창) — `시트` = id 나 이름(없으면 지금 시트 · 다르면 그 탭으로) ·
* 범위 여럿 = 여러 고름 · 시트가 없거나 주소가 틀리면 false(고름 그대로) */
select(범위: string | string[], 시트?: string): boolean;
@@ -138,6 +143,7 @@ export function createSpreadsheet(
root,
book,
readOnly,
fill: opts.칠,
engine: createCalcEngine(book),
history: createHistory(),
selection: selectCell(firstSheet(book), 0, 0),
@@ -225,10 +231,10 @@ export function createSpreadsheet(
}
function dispatch(command: Command): void {
if (readOnly) return;
if (readOnly && command.종류 !== "열폭") return; // 읽기 전용도 열 폭은 바꿈(보기만 · 저장 · 되돌리기 없음)
const eff = apply(command);
if (!eff) return;
ctx.history.push(command, eff.undo);
if (!readOnly) ctx.history.push(command, eff.undo);
after(command, eff);
}
@@ -252,7 +258,7 @@ export function createSpreadsheet(
else grid.render();
}
select(ctx.selection, false, true); // 명령이 바꾼 뒤 — 알약 점 등 문서 종속 부품도 다시(81-2)
opts.onChange?.(structuredClone(ctx.book));
if (!readOnly) opts.onChange?.(structuredClone(ctx.book));
}
/** 칸 명령이 건드린 칸(서식만 바뀐 칸도 다시 그리게) */
@@ -369,6 +375,7 @@ export function createSpreadsheet(
return 이름;
},
pasteTable: (html, check) => pasteTableAt(ctx, html, check),
repaint: () => grid.render(),
select(range, sheetKey) {
const target = sheetKey
? ctx.book.시트.find((s) => s.id === sheetKey || s.이름 === sheetKey)
+33 -12
View File
@@ -13,7 +13,8 @@
* · 확대 · 축소 — 값 자체를 저장하지 않는 화면 전용 CSS 배율(C 격자 뿌리에 transform) · 계약 밖.
* ========================================================================== */
import type { Workbook } from "./spreadsheet_types";
import { colIndex, colName, parseRange } from "./spreadsheet_address";
import type { Sheet, Workbook } from "./spreadsheet_types";
import type { SpreadsheetContext } from "./spreadsheet_view_types";
export type TextMeasurer = (text: string) => number;
@@ -30,13 +31,27 @@ const defaultMeasure: TextMeasurer = (text) => {
const colFromKey = (key: string): number => {
const m = /^([A-Z]+)\d+$/.exec(key);
if (!m) return -1;
let col = 0;
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
return col - 1;
return m ? colIndex(m[1]) : -1;
};
/** 그 열에 든 칸 글자(식 칸은 식 글로 어림 — 계산값은 여기서 모름) 중 가장 넓은 것 + 여백. */
/** 열마다 가장 넓은 칸 글자 폭(식 칸은 식 글로 어림 — 계산값은 여기서 모름) · 여러 열 병합 칸은 뺌(제목 줄). */
function widestByColumn(sheet: Sheet, measure: TextMeasurer): Map<number, number> {
const wide = new Set<string>();
for (const text of sheet.병합 ?? []) {
const r = parseRange(text);
if (r && r.c1 > r.c0) wide.add(`${colName(r.c0)}${r.r0 + 1}`);
}
const out = new Map<number, number>();
for (const [key, cell] of Object.entries(sheet.칸)) {
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
const col = colFromKey(key);
if (!text || col < 0 || wide.has(key)) continue;
out.set(col, Math.max(out.get(col) ?? 0, measure(text)));
}
return out;
}
/** 그 열에 든 칸 글자 중 가장 넓은 것 + 여백. */
export function estimateColumnWidth(
book: Workbook,
sheetId: string,
@@ -45,13 +60,19 @@ export function estimateColumnWidth(
): number {
const sheet = book.시트.find((s) => s.id === sheetId);
if (!sheet) return MIN_WIDTH;
let widest = 0;
for (const [key, cell] of Object.entries(sheet.칸)) {
if (colFromKey(key) !== col) continue;
const text = cell.식 !== undefined ? cell.식 : cell.값 !== undefined ? String(cell.값) : "";
if (text) widest = Math.max(widest, measure(text));
return Math.max(MIN_WIDTH, Math.ceil((widestByColumn(sheet, measure).get(col) ?? 0) + PADDING));
}
/** 폭 정보 없는 열(엑셀 폭을 못 읽은 파일)에 글 길이로 처음 폭을 채움 — 최소 · 최대 안(읽기 전용 보기). */
export function fillMissingWidths(book: Workbook, min = 6, max = 60): void {
for (const sheet of book.시트) {
for (const [col, widest] of widestByColumn(sheet, defaultMeasure)) {
const letter = colName(col);
if (sheet.열?.[letter]?.폭 !== undefined) continue;
const 폭 = Math.min(max, Math.max(min, Math.ceil(widest + PADDING)));
sheet.열 = { ...sheet.열, [letter]: { ...sheet.열?.[letter], 폭 } };
}
}
return Math.max(MIN_WIDTH, Math.ceil(widest + PADDING));
}
/** 폭을 자동 맞춤 값으로 바꿈(열넓이 명령 하나). */
@@ -276,3 +276,40 @@ html[data-touch="on"] .aislo-grid__fill-handle {
touch-action: pan-x pan-y;
}
}
/* 칸 칠(옵션 `칠` · 요구 621-5) — 칸 원래 채움(인라인 background-color) 위에 겹침 · 어두움은 --color-* 가 따라감. */
.aislo-cell[data-tone~="success"] {
background-image: linear-gradient(
color-mix(in srgb, var(--color-success) 18%, transparent),
color-mix(in srgb, var(--color-success) 18%, transparent)
);
}
.aislo-cell[data-tone~="warning"] {
background-image: linear-gradient(
color-mix(in srgb, var(--color-warning) 18%, transparent),
color-mix(in srgb, var(--color-warning) 18%, transparent)
);
}
.aislo-cell[data-tone~="info"] {
background-image: linear-gradient(
color-mix(in srgb, var(--color-info) 18%, transparent),
color-mix(in srgb, var(--color-info) 18%, transparent)
);
}
.aislo-cell[data-tone~="danger"] {
background-image: linear-gradient(
color-mix(in srgb, var(--color-danger) 18%, transparent),
color-mix(in srgb, var(--color-danger) 18%, transparent)
);
}
.aislo-cell[data-tone~="muted"] {
color: var(--color-text-muted) !important;
}
.aislo-cell[data-tone~="mine"] {
box-shadow: inset 3px 0 0 var(--color-accent);
}
/* 읽기만 — 시트 탭 「+」 숨김 */
.ss--readonly .ss-tabs__add {
display: none;
}
+36 -2
View File
@@ -437,6 +437,16 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
};
}
/** 굴리는 영역 밖 테두리 자르기(`overlay` 좌표) — 고정 머리 칸 · 모서리 · 스크롤바 위로 안 나가게(요구 566). */
function clipOf(b: CellBox, inFrozenCol: boolean, inFrozenRow: boolean): string {
const pad = (n: number): number => Math.max(0, Math.ceil(n));
const top = inFrozenRow ? 0 : pad(frozenH - b.y);
const left = inFrozenCol ? 0 : pad(frozenW - b.x);
const bottom = pad(b.y + b.h - (frozenH + paneBR.clientHeight));
const right = pad(b.x + b.w - (frozenW + paneBR.clientWidth));
return `inset(${top}px ${right}px ${bottom}px ${left}px)`;
}
function renderSelection(): void {
let selLayer = overlay.querySelector<HTMLElement>(".aislo-grid__sel-layer");
if (!selLayer) {
@@ -470,6 +480,7 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
node.style.height = `${screen.h}px`;
node.style.borderColor = "var(--aislo-grid-selected)";
node.style.borderWidth = i === 0 ? "2px" : "1px";
node.style.clipPath = clipOf(screen, inFrozenCol, inFrozenRow);
selLayer!.append(node);
}
// 활성 칸 테두리 — 칸 다시 그리기(`renderRowOfCells`) 없이 고름만 바뀌어도 옛 칸에 남지 않게.
@@ -487,6 +498,17 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
handle.className = "aislo-grid__fill-handle";
handle.style.left = `${corner.x + corner.w - 4}px`;
handle.style.top = `${corner.y + corner.h - 4}px`;
const hx = corner.x + corner.w;
const hy = corner.y + corner.h;
const hFC = handleAt.c < frozenCols;
const hFR = handleAt.r < frozenRows;
if (
(!hFC && hx < frozenW) ||
(!hFR && hy < frozenH) ||
hx > frozenW + paneBR.clientWidth ||
hy > frozenH + paneBR.clientHeight
)
handle.style.display = "none";
selLayer!.append(handle);
}
@@ -525,6 +547,16 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
node.style.width = `${merged.w}px`;
node.style.height = `${merged.h}px`;
node.style.borderColor = REF_COLORS[ref.색번호 % REF_COLORS.length];
node.style.clipPath = clipOf(
{
x: parseFloat(node.style.left),
y: parseFloat(node.style.top),
w: merged.w,
h: merged.h,
},
inFrozenCol,
inFrozenRow,
);
refLayer!.append(node);
}
}
@@ -540,8 +572,10 @@ export function createGrid(ctx: SpreadsheetContext): GridHandle {
const contentY =
y < headRowH() ? -1 : y - headRowH() + (y - headRowH() > frozenH ? paneBR.scrollTop : 0);
if (y < headRowH()) {
const c = colGeo.indexAt(contentX);
const edge = Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE;
const at = colGeo.indexAt(contentX);
const after = at > 0 && contentX - colGeo.offset(at) <= RESIZE_HOTZONE; // 경계 오른쪽 반 = 앞 열 경계
const c = after ? at - 1 : at;
const edge = after || Math.abs(colGeo.offset(c + 1) - contentX) <= RESIZE_HOTZONE;
return { kind: edge ? "colEdge" : "colHead", r: 0, c };
}
if (x < headColW()) {
@@ -319,6 +319,9 @@ export function renderCells(
if (need > w * 0.9) longs.push({ r: r0, c: c0, el: cellEl, own: w, need, align });
}
}
const tone = ctx.fill?.(sheet.id, r0, c0);
if (tone) cellEl.dataset.tone = tone;
else delete cellEl.dataset.tone; // 재사용 div — 앞 칸 꼴을 지움
cellEl.classList.toggle("is-error", display.isError);
cellEl.classList.toggle("has-comment", !!comment);
cellEl.classList.toggle(
+2 -2
View File
@@ -233,9 +233,9 @@ export function attachMouse(
}
function onDbl(e: MouseEvent): void {
if (e.target === ed.ta || ctx.readOnly) return;
if (e.target === ed.ta) return;
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
if (!hit) return;
if (!hit || (ctx.readOnly && hit.kind !== "colEdge")) return; // 읽기 전용 = 열 경계 맞춤만
if (hit.kind === "fillHandle") return fillDown();
if (hit.kind === "colEdge") {
// 고른 열 전체의 경계면 고른 열 모두(엑셀)
@@ -94,6 +94,8 @@ export interface SpreadsheetContext {
/** 지금 문서 — 명령만이 바꿈(직접 고치지 말 것) */
book: Workbook;
readOnly: boolean;
/** 칸 칠(옵션 `칠`) — 시트 id · 행 · 열 → 꼴 이름(공백으로 여럿 · 없으면 칠 없음) · 보이는 칸만 부름 */
fill?: (시트: string, r: number, c: number) => string | null | undefined;
engine: CalcEngine;
history: History;
selection: Selection;
+6 -3
View File
@@ -140,7 +140,8 @@ async def _company(cursor: Any, company_id: int) -> dict[str, Any]:
async def _forest(cursor: Any, company_id: int) -> dict[str, Any]:
"""예정노선 수 — 분석 전 = 노선을 아직 안 그은 프로젝트 · 분석됨 · 확정 = 연도(설계 내용 파일) ·
업체 지정 대기 = 확정 연도 중 그 연도 조각에 업체가 하나도 없는 것."""
업체 지정 대기 = 확정 연도 중 그 연도 세트에 업체가 하나도 없는 것(업체 줄 = 세트 열쇠 ·
옛 조각 id = 든 세트)."""
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
await cursor.execute(
@@ -161,11 +162,13 @@ async def _forest(cursor: Any, company_id: int) -> dict[str, Any]:
continue
if not state["plan"]["lines"]:
status["draft"] += 1
found = (store.find_unit(state["units"], key) for key in pieces)
held = {unit["id"] for unit in found if unit}
for year, row in state["years"].items():
key = "confirmed" if row["confirmed_at"] else "analyzed"
status[key] += 1
ids = {p["id"] for p in row["route"]["pieces"]}
waiting += bool(row["confirmed_at"] and not ids & pieces)
ids = {u["id"] for u in state["units"] if u["year"] == year}
waiting += bool(row["confirmed_at"] and not ids & held)
return {"plan_status": status, "assign_waiting": waiting}
@@ -1,7 +1,8 @@
/* =============================================================================
* B01_Dashboard_UI_View_SysDefaults.ts
* 시스템 관리 › 설계 기본값(549-2) — 마스터 데이터 · 구조물 조합 · 구조물 옵션 · 도면 양식 · 계산 자리.
* 카드 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2).
* 시스템 관리 › 설계 기본값(549-2 · 716-2) — 마스터 데이터(마스터 요소 · 단가산출 로직 · 구조물 옵션 정의) ·
* 마스터 템플릿(일위대가 · 구조물 조합 · 도면 양식) · 계산 자리. 칸마다 한 줄(누르면 그 칸을 고른 채 열림 · `?open=`).
* 줄 · 묶음 규칙은 `settingLinks` 한 벌(334) — 여기는 모양만(묶음 제목 + 둥근 상자 안 줄) · 계산 자리 = 창(504-2).
* ========================================================================== */
import { createButton } from "@ui/ui_template_elements";
@@ -12,22 +13,13 @@ import { pageFrame, settingCard, settingRow } from "./B01_Dashboard_UI_PageKit";
import { group } from "./B01_Dashboard_UI_View_Parts";
import type { ViewCtx } from "./B01_Dashboard_UI_Views";
/** 카드 열쇠 → 묶음 제목 — 모르는 열쇠는 「그 밖」 */
const GROUP_OF: Record<string, string> = {
m01: "마스터 데이터",
구조물조합: "구조물 · 도면",
옵션: "구조물 · 도면",
도면: "구조물 · 도면",
calcSites: "계산",
};
const open = (link: SettingLink): void =>
link.key === "calcSites" ? void openCalcSitesDialog() : navigateTo(link.route, link.query);
export function mountSysDefaults(host: HTMLElement, ctx: ViewCtx): void {
const groups = new Map<string, HTMLElement[]>();
for (const link of settingLinks(ctx.me, ctx.companyId)) {
const title = GROUP_OF[link.key] ?? "그 밖";
const title = link.group ?? "그 밖";
const openBtn = createButton({ label: "열기", variant: "ghost", onClick: () => open(link) });
groups.set(title, [...(groups.get(title) ?? []), settingRow(link.title, link.desc, openBtn)]);
}
+148 -257
View File
@@ -13,12 +13,13 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어
사용자는 그 페이지에서 수동으로 이어서 진행할 수 있다.
- 라우터 함수를 직접 호출한다(HTTP 재진입 없음). solve/confirm 엔드포인트는 인증
의존성이 없는 순수 함수 시그니처라 서버 내부 호출이 가능하다.
- **지형마다 한 벌**(765-3): 전처리 체인 = `run_preprocess_chain` — 다른 지형 벌을 먼저 지어
`design_sets/` 묶음으로 떠 두고 설계 지형 벌을 지음(`common_util_design_sets`).
"""
import asyncio
import logging
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -155,10 +156,12 @@ async def _prepare_drainage_pipes_and_reprofile(
async def run_auto_design_chain(
project_id: UUID, surface_model_id: int | None = None
project_id: UUID, surface_model_id: int | None = None, *, inner: bool = False
) -> dict[str, Any] | None:
"""B05 기본 경로 계산·확정 → B06 기본 횡단 설계 확정을 기본값으로 이어 실행한다.
`inner` = 두 벌 짓기 · 지형 칩 안에서 부름(765) — 진입 막음 마커 · 추천 미리 셈은 부르는 쪽.
WF1 자동 확정 직후 같은 백그라운드 태스크에서 호출된다. 어떤 단계가 실패해도
예외를 밖으로 던지지 않는다 — 로그와 각 단계의 workflow 상태 기록으로 남긴다.
@@ -228,7 +231,8 @@ async def run_auto_design_chain(
route_range = (range_row[0], range_row[1]) if range_row else None
seeded_speed = range_row[2] if range_row else None
points = _planned_route_points_in_project_crs(project_root, defaults, route_range)
# 벌 예정노선 = 받은 노선을 이 벌 지형으로 자름(765-8 · 요구 612) — 벌마다 길이 · 곡선 다름
points = await asyncio.to_thread(_terrain_route_points, project_root, defaults, route_range)
if not points:
logger.warning("자동 설계 체인 중단(계획노선 없음): project_id=%s", project_id)
mark_design_failed(project_root, "계획노선이 없어 초기 노선을 세울 수 없습니다.")
@@ -309,7 +313,8 @@ async def run_auto_design_chain(
# 4.5) 배수유역 분석 → 관 지점 확정 → 배관 정착 계획선 재산출.
# B06 확정 전에 끝내야 확정 스냅샷에 배관 정착 계획선이 담긴다.
await _prepare_drainage_pipes_and_reprofile(project_id, route_id)
# 배수는 늘 다시 셈(765-3) — 저장분은 노선이 바뀐 줄 모름(두 벌 · 서피스 바꿈 뒤 묵음).
await _prepare_drainage_pipes_and_reprofile(project_id, route_id, refresh=True)
# 5) B06 횡단 설계 확정 — 미지정 측점을 기본값으로 채워 저장. stage 3은
# IN_PROGRESS(스텝바 노란 표시)로 남겨 사용자 검토·확정을 기다린다.
@@ -347,12 +352,6 @@ async def run_auto_design_chain(
logger.exception("초기값 스냅샷 실패: project_id=%s", project_id)
mark_design_failed(project_root, f"초기값 스냅샷 저장 실패: {exc}")
# 노선 추천 기본 다섯 알약 미리 셈(PLAN 327-2) — 예약만 · 체인이 [초기화] 요청 안에서도
# 불리므로 기다리지 않는다. 같은 입력이면 저장본이 있어 셈 없이 끝난다.
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
schedule_precompute(project_id)
return {
"route_id": route_id,
"length_m": float(solve_result.total_length_m or 0.0),
@@ -365,21 +364,157 @@ async def run_auto_design_chain(
mark_design_failed(project_root, f"초기 설계 처리 중 오류: {exc}")
finally:
# 성공·실패·중단 어느 쪽이든 문은 연다 — 마커가 남으면 영영 못 들어간다.
if project_root is not None:
if project_root is not None and not inner:
clear_designing(project_root)
# 성공 · 실패 · 건너뜀 어느 쪽이든 추천 미리 셈 예약(763-3) — 추천은 지형 · 예정노선만 씀
if not inner:
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
schedule_precompute(project_id)
async def run_preprocess_chain(
project_id: UUID, surface_model_id: int | None = None
) -> dict[str, Any] | None:
"""전처리 체인 = 지형마다 설계 한 벌(765-3 · 요구 605 · 606-1) — 결과 = 설계 지형 요약.
다른 지형 먼저 자동 설계 → 묶음으로 떠 둠(B05~B09 비움) → 설계 지형 자동 설계 → 추천 미리 셈
한 번(실패 길에도). 다른 지형이 없으면(LAS 만 · 수치지형도 못 받음 · 수치지형도만) 한 벌 ·
묶음 meta.json 에 까닭. 부르는 곳 = WF1 자동 확정 · 서피스 바꿈 체인(B04 다른 서피스 [확정] ·
LAS 나중 올리기 뒤 전처리 다시). 묶음은 처음부터 다시(옛 벌 버림).
"""
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
from common_util import common_util_design_sets as sets
from common_util.common_util_initial_snapshot import (
clear_designing,
mark_designing,
read_design_failure,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import (
design_terrain,
get_surface_confirmation_params,
)
from config.config_db import get_db_pool
pool = get_db_pool()
root: Path | None = None
marks = [("시작", time.perf_counter())]
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
params = await get_surface_confirmation_params(connection, str(project_id))
if not stored_path:
return None
root = Path(resolve_stored_project_path(stored_path))
mark_designing(root)
sets.discard_sets(root)
design = design_terrain(params)
other = sets.other_terrain(params, root)
if other is None:
missing = "sheet" if design == "las" else "las"
sets.note_unbuilt(root, missing, f"{missing} 지표면 모델 없음 — 한 벌만")
else:
# 예상노선(공유 · 추천 열쇠)은 설계 지형으로 자른 것 하나 — 벌 평면노선은 벌마다
# 그 지형으로 자른 벌 예정노선(`terrain_route.csv`)을 따라감(765-8 · 요구 612)
await _seed_expected_route(project_id, root, params)
try:
async with pool.acquire() as connection:
model_id = await sets.switch_terrain(connection, project_id, other)
built = await run_auto_design_chain(project_id, model_id, inner=True)
async with pool.acquire() as connection:
fingerprint = await sets.set_fingerprint(
connection, str(project_id), root, other
)
if built and fingerprint:
await sets.park_active_set(connection, root, str(project_id), fingerprint)
else:
await sets.clear_active_set(connection, root, str(project_id))
sets.note_unbuilt(root, other, read_design_failure(root) or "짓기 실패")
except Exception as exc: # noqa: BLE001 — 다른 벌 실패가 설계 지형 벌을 막지 않음
logger.exception("두 벌 짓기 — %s 벌 실패: project_id=%s", other, project_id)
async with pool.acquire() as connection:
await sets.clear_active_set(connection, root, str(project_id))
sets.note_unbuilt(root, other, f"짓기 오류: {exc}")
finally:
async with pool.acquire() as connection:
await sets.switch_terrain(connection, project_id, design)
marks.append((f"{other} 벌", time.perf_counter()))
summary = await run_auto_design_chain(project_id, surface_model_id, inner=True)
marks.append((f"{design} 벌", time.perf_counter()))
_log_steps("전처리 체인 두 벌", marks)
return summary
except Exception:
logger.exception("전처리 체인 실패: project_id=%s", project_id)
return None
finally:
if root is not None:
clear_designing(root)
schedule_precompute(project_id) # 추천은 지형 · 예정노선만 씀 — 실패 길에도(763-3)
def _terrain_route_points(
root: Path, surface: dict[str, Any], route_range: tuple[Any, Any] | None
) -> list[dict[str, float]] | None:
"""벌 예정노선(`terrain_route.csv` · 벌 몫) — 있으면 그것 · 없으면 받은 노선을 이 지형으로
잘라 적음.
받은 노선 파일이 없는 옛 프로젝트 = 정본 차례(`load_design_route`) 그대로."""
from common_util.common_util_route_geometry import (
load_design_route,
read_planned_route_csv,
terrain_route_csv_path,
write_route_csv,
)
path = terrain_route_csv_path(root)
stored = read_planned_route_csv(path) if path.is_file() else None
if stored is not None and len(stored.vertices) >= 2:
return [{"x": v.x, "y": v.y} for v in stored.vertices]
planned = load_design_route(root, surface, route_range, from_source=True)
points = (
[{"x": v.x, "y": v.y} for v in planned.vertices]
if planned is not None
else _planned_route_points_in_project_crs(root, surface, route_range)
)
if points:
write_route_csv(path, points)
return points
async def _seed_expected_route(project_id: UUID, root: Path, surface: dict[str, Any]) -> None:
"""예상노선 정본을 설계 지형으로 잘라 적음(없을 때만 · 자동 체인 2.5 와 같은 값)."""
from common_util.common_util_route_geometry import expected_route_csv_path, write_route_csv
from config.config_db import get_db_pool
path = expected_route_csv_path(root)
if path.is_file():
return
async with get_db_pool().acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT route_start_m, route_end_m FROM projects WHERE id = %s", (str(project_id),)
)
row = await cursor.fetchone()
points = await asyncio.to_thread(
_planned_route_points_in_project_crs, root, surface, (row[0], row[1]) if row else None
)
if points:
write_route_csv(path, points)
async def _ensure_initial_polyline(
project_id: UUID, project_root: Path, points: list[dict[str, float]]
) -> None:
"""계획노선 초기 폴리라인을 세운다 — 이미 있으면 그대로 둔다. 실패는 비치명적."""
import asyncio
from B05_Profile.B05_Profile_Router_FollowLand import initial_land
from B05_Profile.B05_Profile_Router_Replan import _ensure_planned_initial, _min_plan_radius_m
try:
radius_m = await _min_plan_radius_m(project_id)
summary = await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
land = await initial_land(project_id, project_root)
summary = await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m, land)
if summary:
logger.info(
"초기 계획노선 폴리라인: project_id=%s 노드 %d · 곡선 %d · 위반 %d (R %.1fm)",
@@ -442,247 +577,3 @@ def redesign_solve_request(
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,
selection: dict[str, Any],
profile_target: tuple[list[float], list[float]] | None = None,
) -> str | None:
"""B04 재확정 후 — **사용자 입력을 유지한 채** 새 지표면 기준으로 B05·B06 재계산·저장.
관리자가 B04에서 다른 지표면 모델로 재확정하면(2026-08-04 사용자 확정) 그 값을
기준으로 다음 페이지들도 함께 갱신돼야 한다. 이때 일반 사용자가 이미 쓰던 설정은
버리지 않는다:
- B05: 저장된 stage 2 params(제어점 BP/EP/CP·회피/금지원·경사 옵션·측점 간격 등)를
그대로 쓰고 **지표면(filter/method/smooth/model id)만** 새 확정값으로 바꾼다.
- B06: 옛 경로의 측점별 설계(지반유형·단면유형·측구·암 경계)를 chainage 매칭으로
새 경로에 이월하고, 표준단면 설정(data.options)도 함께 넘긴다. 나머지 미지정
측점은 확정 시 기본값으로 채워진다.
경로가 아예 없으면 신규 자동 체인(계획노선 정본 기본값)으로 되돌아간다.
`profile_target`(누가거리 · 계획고)이 오면 1차 종단을 그 계획고로 세운다 — 노선 추천
「종단 반영」 켬으로 [적용]한 노선의 균형 종단(PLAN 74-4). 없으면 계획고 = 지반고.
"""
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 (
_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
from common_util.common_util_initial_snapshot import (
clear_designing,
discard_initial_snapshot,
mark_designing,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool
pool = get_db_pool()
project_root: Path | None = None
marks = [("시작", time.perf_counter())]
try:
async with pool.acquire() as connection:
latest = await get_latest_route(connection, project_id)
stored_path = await get_project_storage_relative_path(connection, project_id)
if stored_path:
project_root = Path(resolve_stored_project_path(stored_path))
mark_designing(project_root)
# 지표면이 바뀌면 옛 초기값은 다른 지형 기준이라 더는 기준이 아니다. 여기서
# 다시 뜨지는 않는다 — 이 체인은 **사용자 입력을 유지한 채** 재계산하므로 그
# 결과를 찍으면 편집이 섞인 가짜 초기값이 된다(2026-08-29 사용자 확정).
if discard_initial_snapshot(project_root):
logger.info("재확정 체인: 초기값 스냅샷 무효화 (project_id=%s)", project_id)
if not latest:
logger.info(
"재확정 체인 → 기존 경로 없음, 신규 자동 체인으로: project_id=%s", project_id
)
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
return
# 1) 사용자 입력 회수 — 마지막 경로 계산의 stage 2 params가 정본이다.
old_route_id = int(latest["id"])
async with pool.acquire() as connection:
import aiomysql
async with connection.cursor(aiomysql.DictCursor) as cursor:
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 {}
if not points.get("bp") or not points.get("ep"):
logger.warning(
"재확정 체인 중단(stage 2 params에 제어점 없음): project_id=%s", project_id
)
return "노선 제어점(BP·EP)이 없어 재계산할 수 없습니다."
marks.append(("준비·사용자입력 회수", time.perf_counter()))
# 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분.
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 으로 끊겨 종횡단만 옛 노선에 남았음).
try:
reason = bytes(solve_result.body).decode("utf-8", "replace")[:300]
except Exception: # noqa: BLE001 — 로그용이라 실패해도 흐름을 막지 않는다
reason = "(본문 없음)"
logger.error(
"재확정 체인 중단(B05 재계산 실패): project_id=%s status=%s reason=%s",
project_id,
solve_result.status_code,
reason,
)
return f"노선 재계산 실패({solve_result.status_code}): {reason}"
new_route_id = int(solve_result.route_id)
marks.append(("노선 재계산(solve)", time.perf_counter()))
logger.info(
"재확정 체인 B05 재계산 완료: project_id=%s %s→%s",
project_id,
old_route_id,
new_route_id,
)
# 3) 옛 측점별 사용자 설계 이월은 **측점을 다시 만든 뒤**(아래 5) 한다.
# 여기서 하면 `_prepare_drainage_pipes_and_reprofile(refresh=True)` 가 측점을
# 새로 만들며 덮어써 값이 사라진다(2026-09-06 실측: 다단 구간값 `extra_spans` 가
# 노선 변경 뒤 0건이 됐음). 옛 설계는 `old_designs` 로 이미 손에 있다.
carried = 0
# 4) B05 확정 → B06 확정(옛 표준단면 설정 이월, 미지정 측점 기본값 채움).
# 재확정 후에도 stage 2·3은 IN_PROGRESS로 남겨 사용자 재검토를 받는다.
confirm_result = await confirm_latest_route(project_id, None, mark_stage_complete=False)
if isinstance(confirm_result, JSONResponse):
logger.error(
"재확정 체인 중단(B05 확정 실패): project_id=%s status=%s",
project_id,
confirm_result.status_code,
)
return f"노선 확정 실패({confirm_result.status_code})"
marks.append(("B05 확정", time.perf_counter()))
# 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로
# 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다.
# 건너뛴 첫 생성이 남겼을 옵션(요청 → 옛 저장 → 기본) — 재생성이 그것을 읽어 결과가 같다.
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()))
if profile_target is not None:
from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target
await apply_profile_target(project_id, new_route_id, profile_target)
marks.append(("균형 종단 반영", time.perf_counter()))
old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {}
standard = old_options.get("standard_cross_section") or None
# 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 **입력**만 누가거리로 얹고 전 측점을
# 새 계획고로 다시 셈한다(PLAN 74-3). 결과 칸(계획고 · 면적 · design_line …)을 통째
# 옮기면 옛 노선 단면이 새 노선에 남아 누가토량이 두 배가 됐다(74-1). 새 노선에 없는
# 누가거리는 행을 만들지 않는다.
try:
inputs = {
round(float(record["chainage_m"]), 3): {
key: record["design"][key]
for key in CARRY_INPUT_KEYS
if record["design"].get(key) is not None
}
for record in old_designs
if isinstance(record.get("design"), dict)
}
async with pool.acquire() as connection:
new_longitudinal = await get_longitudinal_section(
connection, project_id, new_route_id
)
long_path = str((new_longitudinal or {}).get("longitudinal_file_path") or "")
chainages = await asyncio.to_thread(
_rowless_station_chainages, project_root, long_path, []
)
entries = await asyncio.to_thread(
compute_default_designs, project_root, long_path, chainages, standard, inputs
)
async with pool.acquire() as connection:
await connection.begin()
try:
carried = await merge_cross_section_designs(
connection,
route_id=new_route_id,
entries=entries,
replace=True,
project_id=project_id,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
except Exception:
logger.exception(
"재확정 체인 — 옛 설계 이월 실패(계속 진행): project_id=%s", project_id
)
logger.info("재확정 체인 설계 이월: project_id=%s %d건", project_id, carried)
marks.append(("설계 이월", time.perf_counter()))
section_request = (
SectionConfirmRequest(standard_cross_section=standard) if standard else None
)
sections_result = await confirm_sections(
project_id, new_route_id, section_request, mark_stage_complete=False
)
if isinstance(sections_result, JSONResponse):
logger.error(
"재확정 체인 중단(B06 확정 실패): project_id=%s status=%s",
project_id,
sections_result.status_code,
)
return f"종횡단 확정 실패({sections_result.status_code})"
marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter()))
_log_steps("재확정 체인", marks)
# 지형이 바뀌면 추천 키도 바뀜 — 첫 누름이 저장본이게 미리 셈(351-1)
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
schedule_precompute(project_id)
logger.info(
"재확정 체인 완료: project_id=%s route %s→%s (설계 %d건 이월)",
project_id,
old_route_id,
new_route_id,
carried,
)
return None
except Exception as exc:
logger.exception("재확정 체인 실패: project_id=%s", project_id)
return f"재계산 중 오류: {exc}"
finally:
if project_root is not None:
clear_designing(project_root)
@@ -0,0 +1,49 @@
"""서피스가 바뀐 뒤 전처리 체인 다시(763-3 · 요구 605) — 업로드와 같은 길 하나.
부르는 곳 = B04 다른 서피스 [확정] · B05 LAS 나중 올리기 뒤 전처리 다시(765-5). B05~B09 초기화 =
같은 지형 재업로드 길(`purge_project_outputs(keep_preprocess=True)` · 설계 벌 묶음까지 · 단계 2 이후
처음 · 초기값 스냅샷 버림) → `run_preprocess_chain`(지형마다 한 벌 · 끝에 추천 미리 셈 ·
실패 길에도). 전처리 결과(B04) · 확정은 그대로.
"""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
logger = logging.getLogger(__name__)
async def run_surface_change_chain(project_id: UUID, surface_model_id: int | None) -> None:
"""B05~B09 를 지우고 자동 설계 체인을 새 확정 서피스로 다시 탐 — 실패는 로그만."""
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B03_FileInput.B03_FileInput_Service_Chain import run_preprocess_chain
from B05_Profile.B05_Profile_Router_RouteInitial import schedule_precompute
from common_util.common_util_initial_snapshot import discard_initial_snapshot
from common_util.common_util_project_reset import purge_project_outputs
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_workflow_state import reset_stages_after_input_change
from config.config_db import get_db_pool
try:
async with get_db_pool().acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
if not stored_path:
return
root = Path(resolve_stored_project_path(stored_path))
await asyncio.to_thread(discard_initial_snapshot, root)
await connection.begin()
try:
await purge_project_outputs(connection, str(project_id), root, keep_preprocess=True)
async with connection.cursor() as cursor:
await reset_stages_after_input_change(cursor, str(project_id), 2)
await connection.commit()
except Exception:
await connection.rollback()
raise
except Exception: # noqa: BLE001 — 체인은 응답 뒤 · 추천은 새 서피스로 셈
logger.exception("서피스 바꿈 체인 — B05~B09 초기화 실패: project_id=%s", project_id)
schedule_precompute(project_id)
return
logger.info("서피스 바꿈 체인 — B05~B09 초기화 뒤 자동 설계: project_id=%s", project_id)
await run_preprocess_chain(project_id, surface_model_id)
+8 -7
View File
@@ -24,6 +24,7 @@ from config.config_system import (
SEND_ANALYSIS_COMPLETION_EMAIL,
SHEET_SURFACE_DEFAULT_METHOD,
SURFACE_AUTO_METHODS,
SURFACE_CONFIRM_DEFAULT_METHOD,
SURFACE_CONTOUR_INTERVAL_M,
)
@@ -65,9 +66,10 @@ async def trigger_wf1_analysis_and_email(
project_info: dict[str, Any] | None = None
try:
# 자동 전처리는 기본 조합만 만든다 — 필터는 엔진이 입력 LAS를 보고 정하고
# (분류 있으면 classification, 없으면 csf), 표현은 SURFACE_AUTO_METHODS.
# (분류 있으면 업체 분류, 없으면 점진 TIN · 둘 다 다듬기 판), 표현은 SURFACE_AUTO_METHODS.
# 다른 조합은 관리자가 B04 드롭다운에서 요청할 때 만든다 (2026-09-01 사용자 확정).
methods = list(SURFACE_AUTO_METHODS)
# 확정 표현(implicit)도 함께 지음 — 엔진이 기본 필터에만 지음(요구 558).
methods = list(dict.fromkeys([*SURFACE_AUTO_METHODS, SURFACE_CONFIRM_DEFAULT_METHOD]))
params = {
"input_file_id": str(input_file_id),
"source_filters": None,
@@ -176,10 +178,10 @@ async def trigger_wf1_analysis_and_email(
else:
# 자동 확정은 엔진이 정한 기본 필터를 그대로 따른다 — 이제 그 필터
# 하나만 만들어 두므로 config 기본값과 어긋나면 확정할 모델이 없다.
# 표현 · 평활은 config 확정 기본값(implicit · 평활 끔 — 요구 558).
selection = surface_confirmation_defaults()
if resolved_filters:
selection["source_filter"] = resolved_filters[0]
selection["method"] = methods[0]
try:
model_id = await find_surface_model_for_selection(
connection, project_id, selection
@@ -215,11 +217,10 @@ async def trigger_wf1_analysis_and_email(
# 횡단 설계 체인을 잇는다(2026-08-04 사용자 확정). 체인은 실패를 스스로 격리한다.
design_summary: dict[str, Any] | None = None
if auto_confirmed and AUTO_DESIGN_CHAIN_ENABLED:
from B03_FileInput.B03_FileInput_Service_Chain import run_auto_design_chain
from B03_FileInput.B03_FileInput_Service_Chain import run_preprocess_chain
design_summary = await run_auto_design_chain(
project_id, surface_model_id=confirmed_model_id
)
# 지형마다 설계 한 벌(765-3) — 다른 지형 벌은 묶음 · 요약은 설계 지형 벌
design_summary = await run_preprocess_chain(project_id, confirmed_model_id)
# 알림 메일은 여기 한 통뿐이다 — 업로드 직후와 분석 직후로 나눠 두 통을 보내던 것을
# 초기 설계(B04~B06)까지 마친 시점의 통합 메일로 합쳤다(2026-08-08 사용자 지시).
@@ -97,12 +97,6 @@ export interface SurfacePointCloudSampleResponse {
rgb?: [number, number, number][];
}
export interface SurfaceGroundStatsResponse {
status: string;
project_id: string;
filters: Record<string, Record<string, unknown>>;
}
export interface SurfaceStatusResponse {
project_id: string;
status: "pending" | "in_progress" | "completed" | "failed";
@@ -298,14 +292,6 @@ export async function fetchConfirmedSurface(projectId: string): Promise<SurfaceC
});
}
export async function fetchSurfaceGroundStats(
projectId: string,
): Promise<SurfaceGroundStatsResponse> {
return requestJson<SurfaceGroundStatsResponse>(`/projects/${projectId}/surface/ground-stats`, {
method: "GET",
});
}
export async function fetchSurfaceStatus(projectId: string): Promise<SurfaceStatusResponse> {
return requestJson<SurfaceStatusResponse>(`/projects/${projectId}/surface/status`, {
method: "GET",
+62 -33
View File
@@ -14,11 +14,14 @@ from typing import Any
import numpy as np
from B04_PreProcess.B04_PreProcess_Engine_Ground import (
available_filters,
base_filter,
build_ground_masks,
resolve_auto_source_filters,
run_ground_filter,
summarize_masks,
)
from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import BUILDERS
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
@@ -197,9 +200,12 @@ def run_surface_analysis(
methods: list[str],
force: bool = False,
on_progress: ProgressCallback | None = None,
maps: bool = True,
) -> dict[str, Any]:
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
`maps=False` = 지도 · GIS 받기와 도엽 서피스를 건너뜀(D 라이다 · PLAN 624-4).
`las_path`는 지형 파일 한 장 또는 여러 장이다 — 여러 장이면 합친 범위로 한 벌을
만든다(2026-09-06 사용자 확정).
@@ -276,27 +282,36 @@ def run_surface_analysis(
_report(40, "ground_filter", "지면 필터 적용 중")
# 자동 전처리는 목록을 넘기지 않는다 — 기본 필터는 입력 LAS를 보고 여기서 정한다.
if not source_filters:
source_filters = resolve_auto_source_filters(data)
source_filters = resolve_auto_source_filters(data, processed_dir)
logger.info("B04 자동 전처리 기본 필터: %s", ", ".join(source_filters))
masks: dict[str, np.ndarray] = {}
for filter_key in source_filters:
def _mask_for(filter_key: str) -> np.ndarray:
mask_path = processed_dir / f"mask_{filter_key}.npy"
mask: np.ndarray | None = None
if not rebuild and mask_path.is_file():
stored_mask = np.load(mask_path)
if len(stored_mask) == total_points:
mask = np.asarray(stored_mask, dtype=bool)
logger.info("B04 지면 필터 캐시 재사용: %s", mask_path.name)
if mask is None:
step_started = time.monotonic()
mask = np.asarray(run_ground_filter(filter_key, data), dtype=bool)
np.save(mask_path, mask)
logger.info(
"B04 지면 필터 계산 완료: %s (%.1fs)",
filter_key,
time.monotonic() - step_started,
)
masks[filter_key] = mask
return np.asarray(stored_mask, dtype=bool)
# 다듬기 판(762)은 바탕 필터 마스크(저장본)를 다듬음 — 점진 TIN 을 두 번 돌리지 않음
base = base_filter(filter_key)
base_mask = (masks.get(base) if base in masks else _mask_for(base)) if base else None
step_started = time.monotonic()
mask = np.asarray(run_ground_filter(filter_key, data, base_mask), dtype=bool)
np.save(mask_path, mask)
logger.info(
"B04 지면 필터 계산 완료: %s (%.1fs)",
filter_key,
time.monotonic() - step_started,
)
return mask
for filter_key in source_filters:
masks[filter_key] = _mask_for(filter_key)
# 다듬기 판의 바탕 마스크 — 모델 외곽용(766-2) · 지면점 저장본은 안 지음
outer_masks = {
b: _mask_for(b) for k in source_filters if (b := base_filter(k)) not in (None, *masks)
}
ground_summary = summarize_masks(data, masks)
# 지면점 비율은 필터가 조용히 실패해도 유일하게 드러나는 신호다 — 반드시 남긴다.
for filter_key, entry in ground_summary.items():
@@ -340,7 +355,12 @@ def run_surface_analysis(
_report(70 + int(percent * 0.2), "surface_model", detail)
manifest = build_all_terrain_models(
data, masks, models_dir, config, force=rebuild, progress=_model_progress
data,
{**outer_masks, **masks},
models_dir,
config,
force=rebuild,
progress=_model_progress,
)
logger.info(
"B04 지표면 모델 빌드 완료: status=%s (%.1fs)",
@@ -349,31 +369,38 @@ def run_surface_analysis(
)
# 3-2·3-3. VWorld 지도·국가 GIS 벡터·수치지형도 도엽 (공용 블록 — 도엽 서피스도 사용)
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
las_bounds_dict = {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
download_geodata(
project_root, processed_dir, las_bounds_dict, las_paths[0].parent, rebuild, report=_report
)
if maps:
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
download_geodata(
project_root,
processed_dir,
las_bounds_dict,
las_paths[0].parent,
rebuild,
report=_report,
)
# 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다
# (2026-08-30 사용자 확정). 실패해도 분석은 계속한다.
_report(94, "surface_model", "도엽등고선 3D 서피스 생성 중")
sheet_models: list[dict[str, Any]] = []
try:
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
build_sheet_surface_from_route,
)
if maps:
_report(94, "surface_model", "도엽등고선 3D 서피스 생성 중")
try:
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
build_sheet_surface_from_route,
)
# 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때.
sheet_models = build_sheet_surface_from_route(
project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS)
)
except Exception as exc:
logger.warning("도엽등고선 서피스 생성 실패: %s", exc)
# 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때.
sheet_models = build_sheet_surface_from_route(
project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS)
)
except Exception as exc:
logger.warning("도엽등고선 서피스 생성 실패: %s", exc)
_report(95, "saving", "결과 저장 중")
@@ -583,8 +610,10 @@ def _collect_analysis_result(
# manifest에서 저장된 모델별 정보 추출 (필터별 대표 모델)
models: list[dict[str, Any]] = []
for filter_key, filter_entry in manifest.get("source_filters", {}).items():
if filter_key not in available_filters():
continue # 지운 갈래(608 · csf 등) 옛 줄은 DB 로 되살리지 않음
for method, meta in filter_entry.get("methods", {}).items():
if meta.get("status") != "completed":
if meta.get("status") != "completed" or method not in BUILDERS:
continue
model_file = meta.get("model_file")
model_path = (models_dir / model_file) if model_file else None
@@ -593,11 +622,11 @@ def _collect_analysis_result(
layers.append(
{
"layer_name": f"{method}_{filter_key}_preview",
"geometry_type": "MESH" if method != "meshfree" else "POINTCLOUD",
"geometry_type": "MESH",
"file_path": _relative_to_project(
project_root, models_dir / meta["preview_file"]
),
"file_format": "glb" if method != "meshfree" else "ply",
"file_format": "glb",
}
)
models.append(
+42 -310
View File
@@ -1,16 +1,13 @@
"""B04 등고선 추출 엔진.
5종 표현(regular_grid/triangular_mesh/bspline_surface/local_rbf_height_field/
meshfree_surfels)의 npz 모델에서 표고 격자를 환원하고, marching squares로
지정 간격 등고선 라인을 추출한다. DTM valid_mask를 footprint로 사용해
경계 누출을 차단한다.
표현(regular_grid · local_rbf_height_field 굳힌 격자)의 표고 격자에서 marching
squares로 지정 간격 등고선 라인을 추출한다. 격자 valid_mask가 경계 누출을 막는다.
"""
from pathlib import Path
from typing import Any
import numpy as np
from scipy.interpolate import RBFInterpolator
from skimage import measure
# 등고선 캐시 형식/추출 규칙이 바뀔 때 증가시킨다.
@@ -122,231 +119,21 @@ def extract_contours_from_grid(
return contours_geojson_list
def _load_footprint_mask(
model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray
) -> np.ndarray | None:
"""같은 source filter의 DTM valid_mask를 현재 격자에 최근접 리샘플한다."""
stem = Path(model_npz_path).stem
if stem.endswith("_smooth"):
stem = stem[:-7]
parts = stem.split("_", 1)
if len(parts) < 2:
return None
filter_key = parts[1]
dtm_path = Path(model_npz_path).parent / f"dtm_{filter_key}.npz"
if not dtm_path.exists():
return None
try:
d = np.load(dtm_path)
dtm_x = np.asarray(d["x"]).ravel()
dtm_y = np.asarray(d["y"]).ravel()
dtm_mask = np.asarray(d["valid_mask"], dtype=bool)
except Exception:
return None
if len(dtm_x) < 2 or len(dtm_y) < 2:
return None
def contour_cache_is_stale(contour_path: Path, model_path: Path) -> bool:
"""등고선 캐시가 없거나 그 면을 낳은 파일보다 옛것이면 True(754-1).
def _nearest_idx(axis: np.ndarray, coords: np.ndarray) -> np.ndarray:
ascending = bool(axis[0] <= axis[-1])
a = axis if ascending else axis[::-1]
idx = np.clip(np.searchsorted(a, coords), 1, len(a) - 1)
idx = np.where(np.abs(a[idx - 1] - coords) <= np.abs(a[idx] - coords), idx - 1, idx)
return idx if ascending else (len(axis) - 1 - idx)
xi = _nearest_idx(dtm_x, np.asarray(x_coords, dtype=np.float64))
yi = _nearest_idx(dtm_y, np.asarray(y_coords, dtype=np.float64))
return dtm_mask[np.ix_(yi, xi)]
def _apply_footprint(
model_npz_path: Path, x_coords: np.ndarray, y_coords: np.ndarray, valid_mask: np.ndarray
) -> np.ndarray:
"""valid_mask에 DTM footprint를 교집합으로 적용한다 (형상 다르면 최근접 리샘플)."""
fp = _load_footprint_mask(model_npz_path, x_coords, y_coords)
if fp is not None:
if fp.shape == valid_mask.shape:
return valid_mask & fp
from scipy.ndimage import zoom
zoom_y = valid_mask.shape[0] / fp.shape[0]
zoom_x = valid_mask.shape[1] / fp.shape[1]
fp_resized = zoom(fp.astype(float), (zoom_y, zoom_x), order=0) > 0.5
if fp_resized.shape == valid_mask.shape:
return valid_mask & fp_resized
return valid_mask
def _tin_face_coverage_mask(
vertices: np.ndarray, faces: np.ndarray, xx: np.ndarray, yy: np.ndarray
) -> np.ndarray:
"""저장된 TIN 면이 실제로 덮는 XY 영역만 True로 반환한다."""
vertices = np.asarray(vertices)
faces = np.asarray(faces, dtype=np.int64)
if vertices.ndim != 2 or vertices.shape[1] < 2 or not len(faces):
return np.zeros(xx.shape, dtype=bool)
edges = np.vstack((faces[:, [0, 1]], faces[:, [1, 2]], faces[:, [2, 0]]))
edges = np.sort(edges, axis=1)
unique_edges, counts = np.unique(edges, axis=0, return_counts=True)
boundary_edges = unique_edges[counts == 1]
if not len(boundary_edges):
return np.zeros(xx.shape, dtype=bool)
import affine
import rasterio.features
from shapely import get_parts, linestrings, polygonize
boundary_lines = linestrings(vertices[boundary_edges, :2])
polygons = list(get_parts(polygonize(boundary_lines)))
if not polygons:
return np.zeros(xx.shape, dtype=bool)
x_coords = xx[0, :]
y_coords = yy[:, 0]
dx = float(x_coords[1] - x_coords[0]) if len(x_coords) > 1 else 1.0
dy = float(y_coords[1] - y_coords[0]) if len(y_coords) > 1 else 1.0
transform = affine.Affine(dx, 0.0, x_coords[0] - dx / 2.0, 0.0, dy, y_coords[0] - dy / 2.0)
mask = rasterio.features.rasterize(
polygons, out_shape=xx.shape, transform=transform, fill=0, default_value=1, dtype="uint8"
)
return mask.astype(bool)
def _grid_axes(x_min: float, x_max: float, y_min: float, y_max: float, target_grid_m: float):
cols = max(2, int(np.ceil((x_max - x_min) / target_grid_m)) + 1)
rows = max(2, int(np.ceil((y_max - y_min) / target_grid_m)) + 1)
x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32)
y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32)
return x_coords, y_coords
# 후보 셀 배열이 한 번에 커지지 않도록 삼각형을 나눠 처리하는 단위.
_RASTER_CHUNK = 200_000
# 격자점이 변 위에 놓였을 때 양쪽 삼각형 모두에서 탈락하지 않도록 두는 여유.
_BARYCENTRIC_EPS = 1e-9
def rasterize_triangle_mesh(
vertices: np.ndarray,
triangles: np.ndarray,
x_coords: np.ndarray,
y_coords: np.ndarray,
) -> np.ndarray:
"""삼각망을 규칙격자에 직접 래스터화해 표고 격자를 만든다.
삼각형마다 자기가 덮는 격자 셀만 무게중심 선형보간으로 채우므로, 질의점별
위치탐색(scipy `find_simplex`)이 사라진다. 삼각망 밖 셀은 NaN으로 남아
기존 `griddata` 결과와 같은 유효 판정을 유지한다.
낳은 파일 = 모델 npz · 같은 필터 `dtm_{필터}.npz`(외곽 자르기) · implicit 굳힌 격자.
"""
rows, cols = len(y_coords), len(x_coords)
z_grid = np.full((rows, cols), np.nan, dtype=np.float64)
vertices = np.asarray(vertices, dtype=np.float64)
triangles = np.asarray(triangles, dtype=np.int64)
if not len(triangles) or vertices.ndim != 2 or vertices.shape[1] < 3:
return z_grid
axis_x = np.asarray(x_coords, dtype=np.float64)
axis_y = np.asarray(y_coords, dtype=np.float64)
def _index_range(axis: np.ndarray, low: np.ndarray, high: np.ndarray, size: int):
"""축 배열을 직접 탐색해 [low, high]를 덮는 인덱스 구간을 낸다.
`(좌표 - 원점) / 간격` 식은 쓰지 않는다. `_grid_axes`가 float32 축을 만드는데
UTM 좌표(18만대)에서 float32 해상도가 0.015625m라 격자 간격이 0.984~1.0으로
흔들리고, 등간격으로 환산하면 최대 0.81셀까지 어긋난다. 삼각형이 평균 0.3m라
그만큼 어긋나면 격자점을 통째로 놓친다 (2026-08-17 실측).
"""
ascending = size < 2 or axis[-1] >= axis[0]
sorted_axis = axis if ascending else axis[::-1]
lo = np.searchsorted(sorted_axis, low, side="left")
hi = np.searchsorted(sorted_axis, high, side="right") - 1
if ascending:
return lo, hi
return size - 1 - hi, size - 1 - lo
for begin in range(0, len(triangles), _RASTER_CHUNK):
chunk = triangles[begin : begin + _RASTER_CHUNK]
corners = vertices[chunk]
ax, ay, az = corners[:, 0, 0], corners[:, 0, 1], corners[:, 0, 2]
bx, by, bz = corners[:, 1, 0], corners[:, 1, 1], corners[:, 1, 2]
cx, cy, cz = corners[:, 2, 0], corners[:, 2, 1], corners[:, 2, 2]
denom = (by - cy) * (ax - cx) + (cx - bx) * (ay - cy)
col_lo, col_hi = _index_range(
axis_x, np.minimum(np.minimum(ax, bx), cx), np.maximum(np.maximum(ax, bx), cx), cols
)
row_lo, row_hi = _index_range(
axis_y, np.minimum(np.minimum(ay, by), cy), np.maximum(np.maximum(ay, by), cy), rows
)
# 클립 전에 격자 밖 삼각형을 걸러야 경계에 눌러붙지 않는다.
selected = (
np.isfinite(denom)
& (np.abs(denom) > 1e-12)
& (col_hi >= col_lo)
& (row_hi >= row_lo)
& (col_hi >= 0)
& (row_hi >= 0)
& (col_lo <= cols - 1)
& (row_lo <= rows - 1)
)
if not selected.any():
continue
col_lo = np.clip(col_lo[selected], 0, cols - 1).astype(np.int64)
col_hi = np.clip(col_hi[selected], 0, cols - 1).astype(np.int64)
row_lo = np.clip(row_lo[selected], 0, rows - 1).astype(np.int64)
row_hi = np.clip(row_hi[selected], 0, rows - 1).astype(np.int64)
width = col_hi - col_lo + 1
counts = width * (row_hi - row_lo + 1)
total = int(counts.sum())
if total <= 0:
continue
# 삼각형별 후보 셀 수가 제각각이라 반복 인덱스를 펼쳐서 한 번에 계산한다.
starts = np.concatenate(([0], np.cumsum(counts)[:-1]))
local = np.arange(total, dtype=np.int64) - np.repeat(starts, counts)
width_rep = np.repeat(width, counts)
offset_row = local // width_rep
cell_row = np.repeat(row_lo, counts) + offset_row
cell_col = np.repeat(col_lo, counts) + (local - offset_row * width_rep)
point_x = axis_x[cell_col]
point_y = axis_y[cell_row]
ax_r, ay_r, az_r = (
np.repeat(ax[selected], counts),
np.repeat(ay[selected], counts),
np.repeat(az[selected], counts),
)
bx_r, by_r, bz_r = (
np.repeat(bx[selected], counts),
np.repeat(by[selected], counts),
np.repeat(bz[selected], counts),
)
cx_r, cy_r, cz_r = (
np.repeat(cx[selected], counts),
np.repeat(cy[selected], counts),
np.repeat(cz[selected], counts),
)
denom_r = np.repeat(denom[selected], counts)
weight_a = ((by_r - cy_r) * (point_x - cx_r) + (cx_r - bx_r) * (point_y - cy_r)) / denom_r
weight_b = ((cy_r - ay_r) * (point_x - cx_r) + (ax_r - cx_r) * (point_y - cy_r)) / denom_r
weight_c = 1.0 - weight_a - weight_b
hit = (
(weight_a >= -_BARYCENTRIC_EPS)
& (weight_b >= -_BARYCENTRIC_EPS)
& (weight_c >= -_BARYCENTRIC_EPS)
)
if not hit.any():
continue
z_grid[cell_row[hit], cell_col[hit]] = (
weight_a[hit] * az_r[hit] + weight_b[hit] * bz_r[hit] + weight_c[hit] * cz_r[hit]
)
return z_grid
stem = Path(model_path).stem.removesuffix("_smooth")
folder = Path(model_path).parent
sources = [Path(model_path), folder / f"dtm_{stem.split('_', 1)[-1]}.npz"]
if stem.startswith("implicit_"):
sources.append(folder / f"{stem}_grid.npz")
try:
built = Path(contour_path).stat().st_mtime_ns
except OSError:
return True
return any(path.is_file() and path.stat().st_mtime_ns > built for path in sources)
def extract_contours(
@@ -360,7 +147,21 @@ def extract_contours(
model_npz_path = Path(model_npz_path)
if not model_npz_path.exists():
raise FileNotFoundError(f"모델 파일이 존재하지 않습니다: {model_npz_path}")
data = np.load(model_npz_path)
# 실패해도 모델 파일을 닫음 — 열린 채 남으면 Windows 에서 다시 짓기(교체)가 막힘(754-1)
with np.load(model_npz_path) as data:
return _extract_loaded(
data, model_npz_path, representation, interval, target_grid_m, scene_center
)
def _extract_loaded(
data: Any,
model_npz_path: Path,
representation: str,
interval: float,
target_grid_m: float,
scene_center: tuple[float, float, float] | None,
) -> list[dict[str, Any]]:
if representation == "regular_grid":
x_coords, y_coords, z_grid, valid_mask = (
@@ -384,88 +185,19 @@ def extract_contours(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
if representation == "triangular_mesh":
vertices, faces = data["vertices"], data["faces"]
x_min, x_max = float(np.min(vertices[:, 0])), float(np.max(vertices[:, 0]))
y_min, y_max = float(np.min(vertices[:, 1])), float(np.max(vertices[:, 1]))
x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m)
xx, yy = np.meshgrid(x_coords, y_coords)
# 저장된 faces를 그대로 쓴다 — griddata는 정점을 다시 삼각분할하느라 긴 변
# 제거·외곽 클리핑이 반영되지 않은 면까지 되살렸다 (PLAN 2026-08-17).
z_grid = rasterize_triangle_mesh(vertices, faces, x_coords, y_coords)
face_mask = _tin_face_coverage_mask(vertices, faces, xx, yy)
valid_mask = np.isfinite(z_grid) & face_mask
valid_mask = _apply_footprint(model_npz_path, x_coords, y_coords, valid_mask)
return extract_contours_from_grid(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
if representation == "bspline_surface":
# 모델 빌더와 같은 곡면을 다시 만든다 — 평활 계수·발산 방어를 공유한다.
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
evaluate_nurbs_spline,
fit_nurbs_spline,
)
control_x, control_y, control_z = data["control_x"], data["control_y"], data["control_z"]
degree = int(data["degree"][0])
spline, z_range = fit_nurbs_spline(control_x, control_y, control_z, degree)
x_coords, y_coords = _grid_axes(
float(control_x[0]),
float(control_x[-1]),
float(control_y[0]),
float(control_y[-1]),
target_grid_m,
)
z_grid = evaluate_nurbs_spline(
spline, y_coords, x_coords, z_range, Path(model_npz_path).stem
).astype(np.float32)
valid_mask = _apply_footprint(
model_npz_path, x_coords, y_coords, np.ones_like(z_grid, dtype=bool)
)
return extract_contours_from_grid(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
if representation == "local_rbf_height_field":
centers_xy, center_z = data["centers_xy"], data["center_z"]
smoothing = float(data["smoothing"][0])
interpolator = RBFInterpolator(
centers_xy.astype(np.float64),
center_z.astype(np.float64),
neighbors=min(64, len(centers_xy)),
smoothing=smoothing,
kernel="thin_plate_spline",
)
x_min, x_max = float(np.min(centers_xy[:, 0])), float(np.max(centers_xy[:, 0]))
y_min, y_max = float(np.min(centers_xy[:, 1])), float(np.max(centers_xy[:, 1]))
x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m)
xx, yy = np.meshgrid(x_coords, y_coords)
z_values = interpolator(np.column_stack([xx.ravel(), yy.ravel()])).astype(np.float32)
z_grid = z_values.reshape(len(y_coords), len(x_coords))
valid_mask = _apply_footprint(
model_npz_path, x_coords, y_coords, np.ones_like(z_grid, dtype=bool)
)
return extract_contours_from_grid(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
# 굳힌 격자(641-1) 그대로 — 등고선 = 서버 표본 · 종횡단과 같은 면(643) · dtm 늘 먼저(754-1)
from common_util.common_util_surface_sampler import implicit_grid_npz
if representation == "meshfree_surfels":
from scipy.spatial import Delaunay
points = data["points"]
x_min, x_max = float(np.min(points[:, 0])), float(np.max(points[:, 0]))
y_min, y_max = float(np.min(points[:, 1])), float(np.max(points[:, 1]))
x_coords, y_coords = _grid_axes(x_min, x_max, y_min, y_max, target_grid_m)
# Delaunay 1회로 삼각분할과 hull 판정을 함께 해결한다. 기존에는 griddata가
# 내부에서 한 번, hull 판정용으로 또 한 번 만들었다 (PLAN 2026-08-17).
triangulation = Delaunay(np.asarray(points[:, :2], dtype=np.float64))
z_grid = rasterize_triangle_mesh(points, triangulation.simplices, x_coords, y_coords)
# hull 밖은 어느 삼각형에도 안 닿아 NaN으로 남는다 = 기존 hull_inside와 같다.
valid_mask = np.isfinite(z_grid)
valid_mask = _apply_footprint(model_npz_path, x_coords, y_coords, valid_mask)
return extract_contours_from_grid(
x_coords, y_coords, z_grid, valid_mask, interval, scene_center=scene_center
)
filter_key = model_npz_path.stem.removeprefix("implicit_")
with np.load(implicit_grid_npz(model_npz_path.parent, filter_key)) as grid:
return extract_contours_from_grid(
grid["x"],
grid["y"],
grid["z"],
grid["valid_mask"],
interval,
scene_center=scene_center,
)
raise ValueError(f"지원하지 않는 표현 방식입니다: {representation}")
@@ -1,122 +0,0 @@
"""B04 CSF(Cloth Simulation Filter) 지면 필터.
Pure NumPy 기반 CSF. 지형을 반전시킨 뒤 가상의 천을 중력으로 낙하시켜
원 지면(반전 최하단)에 밀착시키고, 천과의 오차가 임계값 이내인 포인트를
지면으로 분류한다.
"""
import math
from typing import Any
import numpy as np
from config.config_system import (
SURFACE_CSF_CLASS_THRESHOLD_M,
SURFACE_CSF_CLOTH_RESOLUTION_M,
SURFACE_CSF_ITERATIONS,
SURFACE_CSF_MAX_ITERATIONS,
SURFACE_CSF_RIGIDNESS,
SURFACE_CSF_SLOPE_SMOOTH,
SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M,
SURFACE_CSF_TIME_STEP,
)
# rigidness 단계별 스프링 완화 계수 (1: 산악 밀착 ~ 3: 부드럽게 덮음)
_RIGIDNESS_SPRING_COEFF = {1: 0.25, 2: 0.45, 3: 0.65}
# 중력 하강 계수 (time_step에 곱해 반복당 낙하량 산출)
_GRAVITY_BASE = 9.8 * 0.05
def filter_csf(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
cloth_resolution: float = SURFACE_CSF_CLOTH_RESOLUTION_M,
rigidness: int = SURFACE_CSF_RIGIDNESS,
time_step: float = SURFACE_CSF_TIME_STEP,
class_threshold: float = SURFACE_CSF_CLASS_THRESHOLD_M,
iterations: int = SURFACE_CSF_ITERATIONS,
slope_smooth: bool = SURFACE_CSF_SLOPE_SMOOTH,
slope_smooth_threshold: float = SURFACE_CSF_SLOPE_SMOOTH_THRESHOLD_M,
max_iterations: int = SURFACE_CSF_MAX_ITERATIONS,
) -> np.ndarray:
"""CSF로 지면 포인트의 불리언 마스크를 반환한다."""
if not math.isfinite(cloth_resolution) or cloth_resolution <= 0:
raise ValueError("천 해상도는 0보다 큰 유한한 값이어야 합니다.")
if rigidness not in _RIGIDNESS_SPRING_COEFF:
raise ValueError("rigidness는 1, 2, 3 중 하나여야 합니다.")
if not math.isfinite(time_step) or time_step <= 0:
raise ValueError("time_step은 0보다 큰 유한한 값이어야 합니다.")
if not math.isfinite(class_threshold) or class_threshold < 0:
raise ValueError("분류 임계값은 0 이상의 유한한 값이어야 합니다.")
if iterations <= 0:
raise ValueError("반복 횟수는 1 이상이어야 합니다.")
if max_iterations < iterations:
raise ValueError("반복 상한은 정착 여유 반복 수보다 작을 수 없습니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if xyz.shape[0] == 0:
return np.zeros(0, dtype=bool)
xs, ys, zs = xyz[:, 0], xyz[:, 1], xyz[:, 2]
# 1. 지형 반전 — 지표면 추출을 위해 높이를 뒤집는다.
z_max = float(np.max(zs))
inverted_zs = (z_max - zs).astype(np.float32)
# 2. 2D 가상 천 격자 설정 (바운더리 밀착 매핑)
x_min, x_max = float(np.min(xs)), float(np.max(xs))
y_min, y_max = float(np.min(ys)), float(np.max(ys))
cols = int(np.ceil((x_max - x_min) / cloth_resolution)) + 1
rows = int(np.ceil((y_max - y_min) / cloth_resolution)) + 1
# 천 노드 초기 높이 — 반전 지형 최고점보다 약간 높은 곳에서 낙하 시작
start_height = float(np.max(inverted_zs)) + 1.0
cloth_z = np.full((rows, cols), start_height, dtype=np.float32)
# 3. 격자 충돌 타겟 구성 (Drape Target)
collision_grid = np.full((rows, cols), -np.inf, dtype=np.float32)
gx = np.clip(((xs - x_min) / cloth_resolution).astype(np.int32), 0, cols - 1)
gy = np.clip(((ys - y_min) / cloth_resolution).astype(np.int32), 0, rows - 1)
np.maximum.at(collision_grid, (gy, gx), inverted_zs)
collision_grid[collision_grid == -np.inf] = 0.0
# 4. 천 시뮬레이션 반복 루프 (물리 하강)
# 반복 수는 현장 기복에서 뽑는다 — 고정 횟수는 곧 고정 하강 예산이라
# (0.3185m × 150회 = 47.8m) 기복이 그보다 큰 산악지에서 천이 지면에 닿지
# 못하고 공중에 멈춘다. 낙하 구간 + 스프링 정착 여유(iterations)로 잡는다.
gravity = _GRAVITY_BASE * time_step
spring_coeff = _RIGIDNESS_SPRING_COEFF[rigidness]
required_drop = start_height - float(np.min(collision_grid))
total_iterations = int(math.ceil(max(required_drop, 0.0) / gravity)) + iterations
total_iterations = min(total_iterations, max_iterations)
for _ in range(total_iterations):
cloth_z -= gravity
cloth_z = np.maximum(cloth_z, collision_grid)
# 노드 간 스프링 제약 완화 (가로/세로 인접 교정)
diff_h = cloth_z[:, 1:] - cloth_z[:, :-1]
correction_h = diff_h * spring_coeff * 0.5
cloth_z[:, :-1] += correction_h
cloth_z[:, 1:] -= correction_h
diff_v = cloth_z[1:, :] - cloth_z[:-1, :]
correction_v = diff_v * spring_coeff * 0.5
cloth_z[:-1, :] += correction_v
cloth_z[1:, :] -= correction_v
cloth_z = np.maximum(cloth_z, collision_grid)
# 5. 시뮬레이션 천 높이와 원본 대조 → 오차 이내면 지면
simulated_inverted_z = cloth_z[gy, gx]
height_diff = np.abs(inverted_zs - simulated_inverted_z)
mask = height_diff <= class_threshold
# 6. 수목 노이즈 2차 필터 보정 — 셀 지면 후보(반전고 최대 = 원표고 최저)보다
# 얼마나 위에 떠 있는지를 본다. 반전 공간에서 높이는 (지면 후보 - 자기 반전고)다.
# 반대로 빼면 항상 0 이하가 나와 조건이 늘 참이 된다(= 필터가 무력화).
if slope_smooth:
local_ground_z = collision_grid[gy, gx]
mask = mask & ((local_ground_z - inverted_zs) < slope_smooth_threshold)
return np.asarray(mask, dtype=bool)
@@ -5,11 +5,18 @@
쓸 수 있는지는 has_classified_ground()로 먼저 판정한다.
"""
import json
from pathlib import Path
from typing import Any
import numpy as np
from config.config_system import SURFACE_CLASSIFIED_GROUND_MIN_RATIO
from common_util.common_util_json import atomic_write_json
from config.config_system import (
SURFACE_CLASSIFIED_COVERAGE_CELL_M,
SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO,
SURFACE_CLASSIFIED_GROUND_MIN_RATIO,
)
# ASPRS 표준 지면 클래스 코드
GROUND_CLASS_CODE = 2
@@ -38,3 +45,51 @@ def has_classified_ground(
return False
ground = int(np.count_nonzero(classification == GROUND_CLASS_CODE))
return ground / total >= minimum_ratio
def classified_coverage(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
cell_meters: float = SURFACE_CLASSIFIED_COVERAGE_CELL_M,
) -> float:
"""점 있는 칸 중 class 2 가 있는 칸의 비율 — 일부만 분류된 LAS 를 가려냄."""
if "classification" not in structured_data:
return 0.0
classification = np.asarray(structured_data["classification"])
xyz = structured_data["xyz"]
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
cell = max(float(cell_meters), 0.1)
cols = int((bounds[0, 1] - bounds[0, 0]) // cell) + 1
rows = int((bounds[1, 1] - bounds[1, 0]) // cell) + 1
occupied = np.zeros((rows, cols), dtype=bool)
ground = np.zeros((rows, cols), dtype=bool)
for start in range(0, len(classification), 5_000_000):
points = np.asarray(xyz[start : start + 5_000_000, :2], dtype=np.float64)
gx = np.clip(((points[:, 0] - bounds[0, 0]) // cell).astype(np.int32), 0, cols - 1)
gy = np.clip(((points[:, 1] - bounds[1, 0]) // cell).astype(np.int32), 0, rows - 1)
occupied[gy, gx] = True
is_ground = classification[start : start + len(points)] == GROUND_CLASS_CODE
ground[gy[is_ground], gx[is_ground]] = True
total = int(np.count_nonzero(occupied))
return float(np.count_nonzero(ground)) / total if total else 0.0
def has_classified_coverage(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
processed_dir: Path | None = None,
minimum_ratio: float = SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO,
) -> bool:
"""분류 덮임 문턱 판정 — `processed_dir` 면 structured.npz 지문으로 저장본 재사용."""
if processed_dir is None:
return classified_coverage(structured_data) >= minimum_ratio
stat = (processed_dir / "structured.npz").stat()
stamp = [int(stat.st_size), int(stat.st_mtime_ns)]
path = processed_dir / "classification_coverage.json"
try:
stored = json.loads(path.read_text(encoding="utf-8"))
coverage = float(stored["coverage"]) if stored.get("structured") == stamp else None
except (OSError, ValueError, KeyError, TypeError, AttributeError):
coverage = None
if coverage is None:
coverage = classified_coverage(structured_data)
atomic_write_json(path, {"structured": stamp, "coverage": coverage})
return coverage >= minimum_ratio
@@ -1,50 +0,0 @@
"""B04 grid minimum-Z 지면 필터."""
import math
from typing import Any
import numpy as np
from config.config_system import SURFACE_GRID_CELL_SIZE_M, SURFACE_GRID_HEIGHT_THRESHOLD_M
def filter_grid_min_z(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
cell_size: float = SURFACE_GRID_CELL_SIZE_M,
height_threshold: float = SURFACE_GRID_HEIGHT_THRESHOLD_M,
) -> np.ndarray:
"""격자 최저 표면에서 높이 임계값 이내인 포인트의 불리언 마스크를 반환한다."""
if not math.isfinite(cell_size) or cell_size <= 0:
raise ValueError("격자 크기는 0보다 큰 유한한 값이어야 합니다.")
if not math.isfinite(height_threshold) or height_threshold < 0:
raise ValueError("높이 임계값은 0 이상의 유한한 값이어야 합니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if bounds.shape != (3, 2):
raise ValueError("bounds 배열은 (3, 2) 형태여야 합니다.")
if xyz.shape[0] == 0:
return np.zeros(0, dtype=bool)
x_min, y_min = bounds[0, 0], bounds[1, 0]
x_max, y_max = bounds[0, 1], bounds[1, 1]
grid_width = int(np.ceil((x_max - x_min) / cell_size)) + 2
grid_height = int(np.ceil((y_max - y_min) / cell_size)) + 2
# float32로 담으면 셀 최저점(가장 확실한 지면점)이 반올림 때문에 자기 기준면보다
# 낮아져 height_above < 0 으로 탈락한다 — 원본 좌표와 같은 float64로 둔다.
minimum_z = np.full((grid_height, grid_width), np.inf, dtype=np.float64)
grid_x = np.clip(((xyz[:, 0] - x_min) / cell_size).astype(np.int64), 0, grid_width - 1)
grid_y = np.clip(((xyz[:, 1] - y_min) / cell_size).astype(np.int64), 0, grid_height - 1)
np.minimum.at(minimum_z, (grid_y, grid_x), xyz[:, 2])
# 기준면은 점이 실제로 들어 있는 셀에서만 읽는다 — 3x3 최소필터로 이웃 셀 값을
# 끌어오면 급경사에서 기준면이 경사만큼 파고들어(중앙 1.88m > 임계 1.5m) 지면점이
# 통째로 탈락한다. 빈 셀은 아래 인덱싱에서 참조되지 않으므로 채울 필요가 없다.
height_above = xyz[:, 2] - minimum_z[grid_y, grid_x]
return np.asarray(
(height_above >= 0.0) & (height_above <= height_threshold),
dtype=bool,
)
@@ -1,91 +0,0 @@
"""B04 PMF(Progressive Morphological Filter) 지면 필터.
XY 평면을 격자로 투영해 Z-min 지형 맵을 만든 뒤, 윈도우 폭을 단계적으로
키워가며 형태학적 열림(Opening) 연산으로 수목·구조물을 제거하고, 최종 지면
대비 높이차가 임계값 이내인 포인트를 지면으로 분류한다. Pure NumPy 구현.
"""
import math
from typing import Any
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
from config.config_system import (
SURFACE_PMF_CELL_SIZE_M,
SURFACE_PMF_INITIAL_WINDOW_SIZE,
SURFACE_PMF_MAX_DISTANCE_M,
SURFACE_PMF_MAX_WINDOW_SIZE,
SURFACE_PMF_SLOPE,
)
def _min_max_window_filter(grid: np.ndarray, w_size: int, mode: str = "min") -> np.ndarray:
"""순수 NumPy 2D 이동 윈도우 최솟값/최댓값 필터 (경계는 edge 패딩)."""
pad_val = w_size // 2
padded = np.pad(grid, pad_val, mode="edge")
windows = sliding_window_view(padded, (w_size, w_size))
if mode == "min":
return np.min(windows, axis=(2, 3))
return np.max(windows, axis=(2, 3))
def filter_pmf(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
cell_size: float = SURFACE_PMF_CELL_SIZE_M,
max_window_size: int = SURFACE_PMF_MAX_WINDOW_SIZE,
slope: float = SURFACE_PMF_SLOPE,
initial_window_size: int = SURFACE_PMF_INITIAL_WINDOW_SIZE,
max_distance: float = SURFACE_PMF_MAX_DISTANCE_M,
) -> np.ndarray:
"""PMF로 지면 포인트의 불리언 마스크를 반환한다."""
if not math.isfinite(cell_size) or cell_size <= 0:
raise ValueError("격자 크기는 0보다 큰 유한한 값이어야 합니다.")
if initial_window_size < 1 or max_window_size < initial_window_size:
raise ValueError("윈도우 크기는 1 이상이고 최대가 초기값 이상이어야 합니다.")
if not math.isfinite(max_distance) or max_distance < 0:
raise ValueError("최대 거리 임계값은 0 이상의 유한한 값이어야 합니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if bounds.shape != (3, 2):
raise ValueError("bounds 배열은 (3, 2) 형태여야 합니다.")
if xyz.shape[0] == 0:
return np.zeros(0, dtype=bool)
xs, ys, zs = xyz[:, 0], xyz[:, 1], xyz[:, 2]
x_min, y_min, z_min = bounds[0, 0], bounds[1, 0], bounds[2, 0]
x_max, y_max = bounds[0, 1], bounds[1, 1]
grid_w = int(np.ceil((x_max - x_min) / cell_size)) + 2
grid_h = int(np.ceil((y_max - y_min) / cell_size)) + 2
z_grid = np.full((grid_h, grid_w), np.inf, dtype=np.float32)
gx = np.clip(((xs - x_min) / cell_size).astype(np.int32), 0, grid_w - 1)
gy = np.clip(((ys - y_min) / cell_size).astype(np.int32), 0, grid_h - 1)
# 1. Z-min 지형 구성
np.minimum.at(z_grid, (gy, gx), zs.astype(np.float32))
z_grid[z_grid == np.inf] = z_min
# 2. 점진적 형태학 필터 (Opening = Dilation of Erosion)
current_grid = z_grid.copy()
w_sizes = []
w = initial_window_size
while w <= max_window_size:
w_sizes.append(w)
w = w * 2 + 1
for w_size in w_sizes:
eroded = _min_max_window_filter(current_grid, w_size, mode="min")
opened = _min_max_window_filter(eroded, w_size, mode="max")
t_dist = min(slope * w_size * cell_size * 0.15 + 0.5, max_distance)
mask_elev = (current_grid - opened) > t_dist
current_grid[mask_elev] = opened[mask_elev]
# 3. 마스크 매핑 및 원본 비교
simulated_z = current_grid[gy, gx]
mask = (zs >= simulated_z - 0.4) & (zs <= simulated_z + max_distance)
return np.asarray(mask, dtype=bool)
@@ -0,0 +1,246 @@
"""B04 점진 TIN(PTD · Axelsson 꼴) 지면 필터 — 분류 없는 LAS 의 기본 필터.
1. 작은 칸(후보 칸) 최저점 = 후보 · 시작 간격 칸 최저점 = 씨앗.
2. 씨앗 삼각망 → 후보마다 삼각면까지 연직 거리 · 세 꼭짓점 각을 보고 문턱 안이면
삼각형마다 가장 가까운 하나를 지면에 더함 → 더 안 들어올 때까지 되풀이.
삼각망 밖 후보 · 격자는 맞닿은 테두리 삼각형 평면을 늘여 봄(밖으로 차례로 넓어짐).
굵게 → 가늘게: 후보가 모두 면 위로 뜬 칸(면이 처짐)에 반 간격 씨앗을 더해 다시 다짐.
끝에 둘레보다 최대 거리 넘게 튀거나 꺼진 외딴 꼭짓점(가시 · 잡점)을 걷음.
3. 마지막 지면 TIN 을 후보 칸 격자로 굳히고 모든 점을 면까지 연직 거리로 판정 —
면 아래 최대 거리 · 면 위 0.12 m(꼭짓점 = 칸 최저라 땅은 면 곁 · 위는 덤불).
장면 셋(DJI Terra 꼴) = 평지 3° · 0.3 m / 완경사 6° · 0.5 m / 급경사 10° · 0.7 m
· 기본 급경사(임도).
큰 파일 = 점을 덩이로 나눠 칸 최저만 모음 · 후보가 상한을 넘으면 후보 칸을 키움(메모리 상한).
"""
import math
from typing import Any
import numpy as np
from scipy import ndimage
from scipy.spatial import Delaunay, cKDTree
from config.config_system import SURFACE_PTD_SCENE, SURFACE_PTD_SEED_SPACING_M
# 장면 → (최대 각 °, 최대 거리 m)
PTD_SCENES = {"flat": (3.0, 0.3), "gentle": (6.0, 0.5), "steep": (10.0, 0.7)}
_CHUNK_POINTS = 5_000_000
def _cell_minima(xyz: np.ndarray, origin: tuple[float, float], cell: float, cols: int):
"""칸마다 가장 낮은 점의 (칸 번호, 점 번호)를 덩이로 나눠 모은다."""
keys, picks = [], []
for start in range(0, len(xyz), _CHUNK_POINTS):
part = xyz[start : start + _CHUNK_POINTS]
gx = ((part[:, 0] - origin[0]) / cell).astype(np.int64)
gy = ((part[:, 1] - origin[1]) / cell).astype(np.int64)
key = gy * cols + gx
order = np.lexsort((part[:, 2], key))
first = np.r_[True, key[order][1:] != key[order][:-1]]
keys.append(key[order][first])
picks.append(order[first] + start)
key = np.concatenate(keys)
pick = np.concatenate(picks)
order = np.lexsort((xyz[pick, 2], key))
first = np.r_[True, key[order][1:] != key[order][:-1]]
return key[order][first], pick[order][first]
def _lone_vertices(points: np.ndarray, gap: float, rounds: int = 10) -> np.ndarray:
"""둘레 꼭짓점 모두보다 gap 넘게 높거나(덤불 가시) 낮은(땅 밑 잡점) 꼭짓점 번호.
능선 · 골은 축 따라 비슷한 이웃이 있어 안 걸림."""
keep = np.arange(len(points))
for _ in range(rounds):
if len(keep) < 4:
break
indptr, nb = Delaunay(points[keep, :2]).vertex_neighbor_vertices
z = points[keep, 2]
low = np.minimum.reduceat(z[nb], indptr[:-1])
high = np.maximum.reduceat(z[nb], indptr[:-1])
lone = (z > high + gap) | (z < low - gap)
if not lone.any():
break
keep = keep[~lone]
return np.setdiff1d(np.arange(len(points)), keep)
def _lowest_per_cell(points: np.ndarray, origin: tuple[float, float], size: float):
"""칸마다 가장 낮은 점 — (칸 열, 칸 행, 점 번호)."""
gx = ((points[:, 0] - origin[0]) / size).astype(np.int64)
gy = ((points[:, 1] - origin[1]) / size).astype(np.int64)
key = gy * (gx.max() + 1) + gx
order = np.lexsort((points[:, 2], key))
first = np.r_[True, key[order][1:] != key[order][:-1]]
pick = order[first]
return gx[pick], gy[pick], pick
def _barycentric(tri: Delaunay, simplex: np.ndarray, q: np.ndarray) -> np.ndarray:
"""세 꼭짓점 무게(차례 = simplices 차례). tri.transform 은 BLAS 스레드 다툼으로
이 PC 에서 1 만 삼각형에 14 초 → 직접 셈."""
p = tri.points[tri.simplices[simplex]]
v0, v1, v2 = p[:, 1] - p[:, 0], p[:, 2] - p[:, 0], q - p[:, 0]
den = v0[:, 0] * v1[:, 1] - v1[:, 0] * v0[:, 1]
den = np.where(np.abs(den) < 1e-12, 1e-12, den)
l1 = (v2[:, 0] * v1[:, 1] - v1[:, 0] * v2[:, 1]) / den
l2 = (v0[:, 0] * v2[:, 1] - v2[:, 0] * v0[:, 1]) / den
return np.column_stack([1.0 - l1 - l2, l1, l2])
def _locate(tri: Delaunay, q: np.ndarray, max_steps: int = 200) -> np.ndarray:
"""점이 든 삼각형 번호 · 삼각망 밖이면 맞닿은 테두리 삼각형(그 평면으로 늘여 봄).
가장 가까운 꼭짓점에서 이웃으로 걸음 — scipy find_simplex 는 점마다 0 번에서 걸어
백만 점에 수십 분이 걸린다."""
_, nearest = cKDTree(tri.points).query(q)
simplex = tri.vertex_to_simplex[nearest].astype(np.int64)
active = np.arange(len(q))
for _ in range(max_steps):
if not len(active):
break
bary = _barycentric(tri, simplex[active], q[active])
worst = bary.argmin(axis=1)
moving = bary[np.arange(len(active)), worst] < -1e-9
step = tri.neighbors[simplex[active], worst]
walk = moving & (step >= 0)
simplex[active[walk]] = step[walk]
active = active[walk]
return simplex
def _plane_distance(tri: Delaunay, gz: np.ndarray, simplex: np.ndarray, q: np.ndarray):
"""점 q 의 삼각면까지 연직 거리와 세 꼭짓점에서 본 최대 각(°).
연직 = 비탈에서 수직 거리보다 관목을 덜 받음(수직은 45° 비탈 0.7 m 가 연직 1 m)."""
vertex = tri.simplices[simplex]
weight = _barycentric(tri, simplex, q[:, :2])
distance = np.abs(q[:, 2] - (weight * gz[vertex]).sum(axis=1))
reach = np.linalg.norm(q[:, None, :2] - tri.points[vertex], axis=2).min(axis=1)
angle = np.degrees(np.arctan(distance / np.maximum(reach, 1e-9)))
return distance, angle
def _tin_dz(ground: np.ndarray, q: np.ndarray) -> np.ndarray:
"""q 높이 − 지면 TIN 높이(연직)."""
tri = Delaunay(ground[:, :2])
simplex = _locate(tri, q[:, :2])
weight = _barycentric(tri, simplex, q[:, :2])
return q[:, 2] - (weight * ground[tri.simplices[simplex], 2]).sum(axis=1)
def _densify(
cand: np.ndarray, is_ground: np.ndarray, max_angle: float, max_distance: float, rounds: int
) -> None:
"""삼각형마다 문턱 안 가장 가까운 후보 하나를 지면에 더함 — 더 안 들어올 때까지."""
for _ in range(rounds):
ground = cand[is_ground]
tri = Delaunay(ground[:, :2])
pending = np.flatnonzero(~is_ground)
if not len(pending):
return
simplex = _locate(tri, cand[pending, :2])
distance, angle = _plane_distance(tri, ground[:, 2], simplex, cand[pending])
# 꼭짓점 곁 점은 각이 커지기만 함 → 최대 거리 절반 안이면 각을 안 봄
ok = (distance <= max_distance) & ((angle <= max_angle) | (distance <= max_distance / 2))
pending, simplex, distance = pending[ok], simplex[ok], distance[ok]
if not len(pending):
return
order = np.lexsort((distance, simplex))
first = np.r_[True, simplex[order][1:] != simplex[order][:-1]]
is_ground[pending[order[first]]] = True
if first.sum() < max(10, 0.001 * is_ground.sum()):
return
def filter_ptd(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
scene: str = SURFACE_PTD_SCENE,
seed_spacing: float = SURFACE_PTD_SEED_SPACING_M,
max_angle: float | None = None,
max_distance: float | None = None,
above_distance: float = 0.12,
candidate_cell: float = 0.5,
max_candidates: int = 3_000_000,
max_iterations: int = 60,
refine_min_spacing: float = 2.5,
) -> np.ndarray:
"""점진 TIN 으로 지면 포인트의 불리언 마스크를 반환한다."""
if scene not in PTD_SCENES:
raise ValueError(f"점진 TIN 장면은 {', '.join(PTD_SCENES)} 중 하나여야 합니다.")
scene_angle, scene_distance = PTD_SCENES[scene]
max_angle = scene_angle if max_angle is None else float(max_angle)
max_distance = scene_distance if max_distance is None else float(max_distance)
for name, value in (("시작 간격", seed_spacing), ("후보 칸", candidate_cell)):
if not math.isfinite(value) or value <= 0:
raise ValueError(f"{name}은 0보다 큰 유한한 값이어야 합니다.")
if not (0 < max_angle < 90) or max_distance <= 0 or above_distance < 0:
raise ValueError("최대 각은 0~90°, 최대 거리는 0보다 커야 합니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if len(xyz) < 3:
return np.ones(len(xyz), dtype=bool)
x_min, y_min = float(xyz[:, 0].min()), float(xyz[:, 1].min())
x_max, y_max = float(xyz[:, 0].max()), float(xyz[:, 1].max())
width, height = max(x_max - x_min, 1e-6), max(y_max - y_min, 1e-6)
# ponytail: 후보 상한은 칸을 키워 지킴(공간 타일 아님) · 수 km² 넘으면 겹침 타일로
cell = max(candidate_cell, math.sqrt(width * height / max_candidates))
cols = int(width / cell) + 1
cand_key, cand_idx = _cell_minima(xyz, (x_min, y_min), cell, cols)
cand = xyz[cand_idx]
# 씨앗 = 시작 간격 칸마다 가장 낮은 후보 + 자료 가장자리는 1/4 간격 칸마다(삼각망이
# 가장자리까지 닿게 — 밖은 늘여 볼 테두리 삼각형이 가늘어 땅을 놓침)
origin = (x_min, y_min)
is_ground = np.zeros(len(cand), dtype=bool)
is_ground[_lowest_per_cell(cand, origin, seed_spacing)[2]] = True
gx, gy, pick = _lowest_per_cell(cand, origin, seed_spacing / 4)
occupied = np.zeros((gy.max() + 1, gx.max() + 1), dtype=bool)
occupied[gy, gx] = True
edge = occupied & ~ndimage.binary_erosion(occupied, iterations=1, border_value=0)
is_ground[pick[edge[gy, gx]]] = True
_densify(cand, is_ground, max_angle, max_distance, max_iterations)
# 굵게 → 가늘게: 칸 안 후보가 모두 면보다 최대 거리 넘게 위면(땅이 모든 반사점 밑일 수
# 없음 = 성긴 씨앗 사이에서 면이 처짐) 면에 가장 가까운 후보를 씨앗으로 더하고 다시 다짐
spacing = seed_spacing / 2
while spacing >= refine_min_spacing:
dz = np.zeros(len(cand))
pending = np.flatnonzero(~is_ground)
dz[pending] = _tin_dz(cand[is_ground], cand[pending])
gx, gy, pick = _lowest_per_cell(np.column_stack([cand[:, :2], dz]), origin, spacing)
sag = pick[dz[pick] > max_distance]
if len(sag):
is_ground[sag] = True
_densify(cand, is_ground, max_angle, max_distance, max_iterations)
spacing /= 2
ground_idx = np.flatnonzero(is_ground)
is_ground[ground_idx[_lone_vertices(cand[ground_idx], max_distance)]] = False
# 마지막 지면 TIN → 후보 칸 격자 → 모든 점 연직 거리 판정
ground = cand[is_ground]
tri = Delaunay(ground[:, :2])
rows = int(height / cell) + 2
mesh_x, mesh_y = np.meshgrid(x_min + np.arange(cols + 1) * cell, y_min + np.arange(rows) * cell)
node = np.column_stack([mesh_x.ravel(), mesh_y.ravel()])
simplex = _locate(tri, node)
weight = _barycentric(tri, simplex, node)
grid = (weight * ground[tri.simplices[simplex], 2]).sum(axis=1).reshape(mesh_x.shape)
mask = np.zeros(len(xyz), dtype=bool)
for start in range(0, len(xyz), _CHUNK_POINTS):
part = xyz[start : start + _CHUNK_POINTS]
fx = np.clip((part[:, 0] - x_min) / cell, 0, cols - 1e-6)
fy = np.clip((part[:, 1] - y_min) / cell, 0, rows - 1 - 1e-6)
ix, iy = fx.astype(np.int64), fy.astype(np.int64)
tx, ty = fx - ix, fy - iy
z_tin = (
grid[iy, ix] * (1 - tx) * (1 - ty)
+ grid[iy, ix + 1] * tx * (1 - ty)
+ grid[iy + 1, ix] * (1 - tx) * ty
+ grid[iy + 1, ix + 1] * tx * ty
)
dz = part[:, 2] - z_tin
mask[start : start + len(part)] = (dz >= -max_distance) & (dz <= above_distance)
return mask
@@ -1,116 +0,0 @@
"""B04 RANSAC 지면 필터 (Local plane fitting).
공간을 로컬 격자로 분할하고, 각 격자에서 RANSAC 평면 피팅을 수행해
평면과의 거리가 임계값 이내인 인라이어(지면)를 취합한다. Pure NumPy 구현.
"""
import math
from collections.abc import Callable
from typing import Any
import numpy as np
from config.config_system import (
SURFACE_RANSAC_DISTANCE_THRESHOLD_M,
SURFACE_RANSAC_ITERATIONS,
SURFACE_RANSAC_LOCAL_GRID_SIZE_M,
SURFACE_RANSAC_N,
SURFACE_RANSAC_SEED,
)
def fit_plane_ransac(
points: np.ndarray,
distance_threshold: float = SURFACE_RANSAC_DISTANCE_THRESHOLD_M,
ransac_n: int = SURFACE_RANSAC_N,
num_iterations: int = SURFACE_RANSAC_ITERATIONS,
seed: int = SURFACE_RANSAC_SEED,
) -> np.ndarray:
"""평면 ax+by+cz+d=0을 RANSAC으로 피팅하고 인라이어 마스크를 반환한다."""
n_points = len(points)
if n_points < ransac_n:
return np.ones(n_points, dtype=bool)
best_inliers = np.zeros(n_points, dtype=bool)
max_inlier_count = -1
rng = np.random.default_rng(seed)
for _ in range(num_iterations):
idx = rng.choice(n_points, ransac_n, replace=False)
p0, p1, p2 = points[idx]
normal = np.cross(p1 - p0, p2 - p0)
norm = np.linalg.norm(normal)
if norm < 1e-6:
continue # 세 점이 일직선상 → 스킵
normal = normal / norm
d = -np.dot(normal, p0)
distances = np.abs(np.dot(points, normal) + d)
inliers = distances < distance_threshold
inlier_count = int(np.sum(inliers))
if inlier_count > max_inlier_count:
max_inlier_count = inlier_count
best_inliers = inliers
if max_inlier_count > 0:
return best_inliers
return np.ones(n_points, dtype=bool)
def filter_ransac(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
distance_threshold: float = SURFACE_RANSAC_DISTANCE_THRESHOLD_M,
ransac_n: int = SURFACE_RANSAC_N,
num_iterations: int = SURFACE_RANSAC_ITERATIONS,
local_grid_size: float = SURFACE_RANSAC_LOCAL_GRID_SIZE_M,
seed: int = SURFACE_RANSAC_SEED,
progress_callback: Callable[[int], None] | None = None,
) -> np.ndarray:
"""로컬 격자별 RANSAC 평면 분할로 지면 포인트 마스크를 반환한다."""
if not math.isfinite(local_grid_size) or local_grid_size <= 0:
raise ValueError("로컬 격자 크기는 0보다 큰 유한한 값이어야 합니다.")
if ransac_n < 3:
raise ValueError("RANSAC 샘플 수는 3 이상이어야 합니다.")
xyz = np.asarray(structured_data["xyz"], dtype=np.float64)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
if xyz.ndim != 2 or xyz.shape[1] != 3:
raise ValueError("xyz 배열은 (N, 3) 형태여야 합니다.")
if bounds.shape != (3, 2):
raise ValueError("bounds 배열은 (3, 2) 형태여야 합니다.")
n_points = len(xyz)
mask = np.zeros(n_points, dtype=bool)
if n_points == 0:
return mask
x_min, y_min = bounds[0, 0], bounds[1, 0]
x_max, y_max = bounds[0, 1], bounds[1, 1]
grid_w = int(np.ceil((x_max - x_min) / local_grid_size)) + 1
grid_h = int(np.ceil((y_max - y_min) / local_grid_size)) + 1
xs, ys = xyz[:, 0], xyz[:, 1]
gx = np.clip(((xs - x_min) / local_grid_size).astype(np.int32), 0, grid_w - 1)
gy = np.clip(((ys - y_min) / local_grid_size).astype(np.int32), 0, grid_h - 1)
grid_indices = gy * grid_w + gx
unique_grids = np.unique(grid_indices)
total_grids = len(unique_grids)
for i, grid_id in enumerate(unique_grids):
cell_points_idx = np.where(grid_indices == grid_id)[0]
if len(cell_points_idx) < ransac_n:
mask[cell_points_idx] = True
continue
cell_inliers = fit_plane_ransac(
xyz[cell_points_idx],
distance_threshold=distance_threshold,
ransac_n=ransac_n,
num_iterations=num_iterations,
seed=seed,
)
mask[cell_points_idx[cell_inliers]] = True
if progress_callback:
progress_callback(int(((i + 1) / total_grids) * 100))
if progress_callback:
progress_callback(100) # 모든 격자 처리 완료 보장
return mask
@@ -0,0 +1,116 @@
"""B04 지면점 다듬기 — 필터가 고른 지면점에서 덤불 · 풀 혹과 낮은 잡점을 한 번 더 거름(762-1).
SMRF 꼴(Pingel 2013 · PDAL filters.smrf) — 입력 = 필터 지면 마스크 · 출력 = 줄어든 마스크.
① 1 m 칸 최저면 → 뒤집어 열림(낮은 잡점 칸)
② 창 반지름 1 칸부터 최대까지 키우며 열림 · 문턱 = 기울기 × 칸 × 반지름(물체 칸)
③ 걸린 칸 빼고 남은 칸으로 임시 지면 다시 그림(줄여 메우고 늘려 붓는 다층 보간)
④ 점마다 |z − 임시 지면| ≤ 문턱 + 배수 × 그 자리 기울기 이면 지면.
"""
from typing import Any
import numpy as np
from scipy import ndimage
from config.config_system import (
SURFACE_REFINE_CELL_M,
SURFACE_REFINE_LOW_SLOPE,
SURFACE_REFINE_SCALAR,
SURFACE_REFINE_SLOPE,
SURFACE_REFINE_THRESHOLD_M,
SURFACE_REFINE_WINDOW_M,
)
_RELAX_STEPS = 30
def _fill_nearest(grid: np.ndarray, missing: np.ndarray) -> np.ndarray:
if not missing.any():
return grid
index = ndimage.distance_transform_edt(missing, return_distances=False, return_indices=True)
return grid[tuple(index)]
def _progressive(surface: np.ndarray, cell: float, slope: float, max_radius: int) -> np.ndarray:
"""창을 한 칸씩 키운 열림이 한 단계에 문턱(기울기 × 칸 × 반지름)보다 깎은 칸."""
flagged = np.zeros(surface.shape, dtype=bool)
last = surface
for radius in range(1, max_radius + 1):
# ponytail: 정사각 창(분리형 최소 · 최대 거름) — 원판보다 빠름 · 모서리 방향만 조금 넓음
# (네 방향 선분 열림은 능선을 더 지키나 덤불 판을 3 배 남겨 버림 — 762 훑기)
current = ndimage.grey_opening(last, size=(2 * radius + 1, 2 * radius + 1))
flagged |= (last - current) > slope * cell * radius
last = current
return flagged
def _inpaint(values: np.ndarray, known: np.ndarray) -> np.ndarray:
"""빈 칸을 아는 칸으로 메움 — 2 배씩 줄여 메운 뒤 늘려 붓고 이웃 평균으로 폄."""
if known.all():
return values
if not known.any():
return np.zeros_like(values)
rows, cols = values.shape
pad = ((0, rows % 2), (0, cols % 2))
weight = np.pad(known.astype(np.float64), pad)
total = np.pad(np.where(known, values, 0.0), pad)
shape = (weight.shape[0] // 2, 2, weight.shape[1] // 2, 2)
coarse_weight = weight.reshape(shape).sum(axis=(1, 3))
coarse_known = coarse_weight > 0
coarse = total.reshape(shape).sum(axis=(1, 3)) / np.maximum(coarse_weight, 1.0)
coarse = _inpaint(coarse, coarse_known)
up = ndimage.zoom(coarse, 2, order=1, mode="nearest", grid_mode=True)[:rows, :cols]
result = np.where(known, values, up)
missing = ~known
for _ in range(_RELAX_STEPS):
padded = np.pad(result, 1, mode="edge")
mean = (padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:]) / 4
result[missing] = mean[missing]
return result
def refine_ground_mask(
xyz: Any,
mask: np.ndarray,
*,
cell: float = SURFACE_REFINE_CELL_M,
slope: float = SURFACE_REFINE_SLOPE,
window_m: float = SURFACE_REFINE_WINDOW_M,
threshold: float = SURFACE_REFINE_THRESHOLD_M,
scalar: float = SURFACE_REFINE_SCALAR,
low_slope: float = SURFACE_REFINE_LOW_SLOPE,
) -> np.ndarray:
"""필터 지면 마스크를 다듬어 돌려줌 — 지면이던 점만 지면으로 남음(늘지 않음)."""
mask = np.asarray(mask, dtype=bool)
index = np.flatnonzero(mask)
refined = np.zeros(mask.shape, dtype=bool)
if index.size < 3:
refined[index] = True
return refined
points = np.asarray(xyz[index], dtype=np.float64)
fx = (points[:, 0] - points[:, 0].min()) / cell
fy = (points[:, 1] - points[:, 1].min()) / cell
gx = fx.astype(np.int64)
gy = fy.astype(np.int64)
shape = (int(gy.max()) + 1, int(gx.max()) + 1)
zmin = np.full(shape, np.inf)
np.minimum.at(zmin, (gy, gx), points[:, 2])
empty = ~np.isfinite(zmin)
filled = _fill_nearest(np.where(empty, 0.0, zmin), empty)
# ① 낮은 잡점 — 뒤집은 면에서 한 칸 창 열림이 문턱 넘게 깎는 칸(PDAL 기본 기울기 5)
low = _progressive(-filled, cell, low_slope, 1) & ~empty
surface = _fill_nearest(filled, empty | low)
# ② 물체 — 창을 최대 반지름까지 키우며
objects = _progressive(surface, cell, slope, max(1, int(round(window_m / cell))))
# ③ 임시 지면 · 그 자리 기울기(rise / run)
provisional = _inpaint(filled, ~empty & ~low & ~objects)
grad_y, grad_x = np.gradient(provisional, cell)
gradient = np.hypot(grad_x, grad_y)
# ④ 점마다 판정 — 칸 가운데 기준 쌍선형
coords = [fy - 0.5, fx - 0.5]
ground_z = ndimage.map_coordinates(provisional, coords, order=1, mode="nearest")
local_slope = ndimage.map_coordinates(gradient, coords, order=1, mode="nearest")
keep = np.abs(points[:, 2] - ground_z) <= threshold + scalar * local_slope
refined[index[keep]] = True
return refined
+41 -15
View File
@@ -1,35 +1,44 @@
"""B04 지면 필터 오케스트레이션.
구조화된 포인트클라우드(structured.npz)에 대해 grid_min_z/csf/pmf/ransac
구조화된 포인트클라우드(structured.npz)에 대해 ptd(점진 TIN)/classification(업체 분류)
(+ `_refine` 다듬기 판)
필터를 실행하여 지면 마스크 딕셔너리를 만든다. 필터 선택은 config의
source_filters를 따른다.
"""
from pathlib import Path
from typing import Any
import numpy as np
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
filter_classification,
has_classified_coverage,
has_classified_ground,
)
from B04_PreProcess.B04_PreProcess_Engine_Filter_CSF import filter_csf
from B04_PreProcess.B04_PreProcess_Engine_Filter_Grid import filter_grid_min_z
from B04_PreProcess.B04_PreProcess_Engine_Filter_PMF import filter_pmf
from B04_PreProcess.B04_PreProcess_Engine_Filter_RANSAC import filter_ransac
from B04_PreProcess.B04_PreProcess_Engine_Filter_PTD import filter_ptd
from B04_PreProcess.B04_PreProcess_Engine_Filter_Refine import refine_ground_mask
from config.config_system import SURFACE_AUTO_FALLBACK_FILTER
# 다듬기 판(762) — `{기본 필터}_refine` = 기본 필터 지면점을 SMRF 꼴로 한 번 더 거름
REFINE_SUFFIX = "_refine"
_REFINABLE = ("classification", "ptd")
# 필터 키 → 함수 매핑
_FILTERS = {
"grid_min_z": filter_grid_min_z,
"csf": filter_csf,
"pmf": filter_pmf,
"ransac": filter_ransac,
"ptd": filter_ptd,
"classification": filter_classification,
"classification_refine": lambda data: refine_ground_mask(
data["xyz"], filter_classification(data)
),
"ptd_refine": lambda data: refine_ground_mask(data["xyz"], filter_ptd(data)),
}
# LAS에 지면분류가 실제로 들어 있을 때만 쓰는 필터 (미분류 LAS에서는 마스크가 빈다)
_CONDITIONAL_FILTERS = {"classification": has_classified_ground}
_CONDITIONAL_FILTERS = {
"classification": has_classified_ground,
"classification_refine": has_classified_ground,
}
def available_filters() -> tuple[str, ...]:
@@ -45,23 +54,40 @@ def usable_conditional_filters(
def resolve_auto_source_filters(
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
processed_dir: Path | None = None,
) -> list[str]:
"""자동 전처리가 만들 필터를 입력 LAS를 보고 정한다. 첫 항목이 기본 확정값이다.
업체가 이미 지면을 분류해 놨으면 그것을 기본으로 쓴다 — 계산이 없고 품질도 가장
안정적이다. **csf는 분류가 있어도 함께 만든다** — csf 처리 결과를 눈으로 검증할 수
있어야 하기 때문이다 (2026-09-01 사용자 확정). 분류가 없으면 csf 하나만 남는다.
안정적이다. **ptd(점진 TIN)는 분류가 있어도 함께 만든다** — 내장 필터 결과를 눈으로
검증할 수 있어야 하기 때문이다 (2026-09-01 사용자 확정). 분류가 없으면 ptd 하나만 남는다.
둘 다 다듬기 판(`_refine`)으로 지음 — 업체 분류 + 다듬기 · 점진 TIN + 다듬기(요구 607).
「분류 있음」 = 비율 문턱 + 덮임(일부만 분류된 LAS 거름) — 자동 고름에만(요구 558).
"""
usable = usable_conditional_filters(structured_data)
return [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER]
if usable and not has_classified_coverage(structured_data, processed_dir):
usable = []
filters = [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER]
# 다듬기 기본(요구 607) — 같은 기본 필터에 다듬기를 붙인 판을 지음(바탕 마스크도 저장)
return [f"{key}{REFINE_SUFFIX}" if key in _REFINABLE else key for key in filters]
def base_filter(filter_key: str) -> str | None:
"""다듬기 판이면 바탕 필터 이름 — 엔진이 바탕 마스크 저장본을 다시 쓰게."""
base = filter_key.removesuffix(REFINE_SUFFIX)
return base if base != filter_key and base in _REFINABLE else None
def run_ground_filter(
filter_key: str, structured_data: dict[str, Any] | np.lib.npyio.NpzFile
filter_key: str,
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
base_mask: np.ndarray | None = None,
) -> np.ndarray:
"""단일 지면 필터를 실행해 불리언 마스크를 반환한다."""
"""단일 지면 필터를 실행해 불리언 마스크를 반환한다 — 다듬기 판은 `base_mask` 를 다듬음."""
if filter_key not in _FILTERS:
raise ValueError(f"알 수 없는 지면 필터입니다: {filter_key}")
if base_mask is not None and base_filter(filter_key):
return refine_ground_mask(structured_data["xyz"], base_mask)
return np.asarray(_FILTERS[filter_key](structured_data), dtype=bool)
@@ -1,15 +1,15 @@
"""B04 지표면 5종 표현 빌더 (TIN/DTM/NURBS/implicit/meshfree).
"""B04 지표면 표현 빌더 (DTM/implicit).
TerrainContext에서 파생 격자·샘플을 받아 각 표현의 모델(npz)과 프리뷰
(GLB/PLY)를 저장하고 메타데이터를 반환한다.
(GLB)를 저장하고 메타데이터를 반환한다.
"""
from contextlib import suppress
from pathlib import Path
from typing import Any
import numpy as np
from scipy.interpolate import RBFInterpolator
from scipy.spatial import Delaunay
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
ProgressCallback,
@@ -17,60 +17,12 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
artifact_size,
atomic_npz,
clip_and_compact_mesh,
evaluate_nurbs_spline,
fit_nurbs_spline,
grid_faces,
grid_vertices,
with_footprint,
write_binary_ply,
write_glb,
)
def build_tin(
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
) -> dict[str, Any]:
progress(5)
points = context.sample(int(context.config["tin_max_input_points"]))
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
points = points[unique_indices]
if len(points) < 3:
raise ValueError("TIN 생성에 필요한 포인트가 부족합니다.")
progress(25)
faces = np.asarray(Delaunay(unique_xy).simplices, dtype=np.uint32)
if len(faces):
triangle_xy = points[faces, :2]
edges = np.stack(
[
np.linalg.norm(triangle_xy[:, 0] - triangle_xy[:, 1], axis=1),
np.linalg.norm(triangle_xy[:, 1] - triangle_xy[:, 2], axis=1),
np.linalg.norm(triangle_xy[:, 2] - triangle_xy[:, 0], axis=1),
],
axis=1,
)
faces = faces[np.max(edges, axis=1) <= float(context.config["tile_size_meters"]) * 2]
valid_vertices = context.contains_xy(points[:, 0], points[:, 1])
points, faces = clip_and_compact_mesh(points, faces, valid_vertices)
if not len(faces):
raise ValueError("외곽 안쪽 기준 적용 후 TIN 면이 남지 않았습니다.")
progress(65)
model_path = output_dir / f"{stem}.npz"
preview_path = output_dir / f"{stem}_preview.glb"
atomic_npz(model_path, vertices=points, faces=faces)
write_glb(preview_path, points, faces, context.bounds)
progress(100)
return with_footprint(
context,
{
"representation": "triangular_mesh",
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "model/gltf-binary",
"vertex_count": int(len(points)),
"face_count": int(len(faces)),
"artifact_bytes": artifact_size(model_path, preview_path),
},
)
from common_util.common_util_surface_sampler import implicit_grid_npz
def build_dtm(
@@ -96,6 +48,7 @@ def build_dtm(
y=y_coords,
z=z_grid,
valid_mask=valid_grid,
filled_mask=context.filled(resolution) & valid_grid,
resolution=np.array([resolution], np.float32),
)
progress(75)
@@ -118,61 +71,21 @@ def build_dtm(
)
def build_nurbs(
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
) -> dict[str, Any]:
degree = max(1, min(5, int(context.config["nurbs_degree"])))
patch_size = float(context.config["nurbs_patch_size_meters"])
controls = max(degree + 1, int(context.config["nurbs_control_points_per_axis"]))
control_resolution = max(patch_size / max(controls - 1, 1), 0.25)
x_control, y_control, z_control = context.grid(control_resolution)
progress(30)
spline, z_range = fit_nurbs_spline(x_control, y_control, z_control, degree)
x_preview, y_preview, _ = context.preview_grid(
float(context.config["dtm_grid_resolution_meters"])
)
z_preview = evaluate_nurbs_spline(spline, y_preview, x_preview, z_range, stem).astype(
np.float32
)
progress(65)
vertices = grid_vertices(x_preview, y_preview, z_preview)
faces = grid_faces(len(y_preview), len(x_preview))
valid_preview = context.contains_xy(vertices[:, 0], vertices[:, 1])
vertices, faces = clip_and_compact_mesh(vertices, faces, valid_preview)
model_path = output_dir / f"{stem}.npz"
preview_path = output_dir / f"{stem}_preview.glb"
atomic_npz(
model_path,
control_x=x_control,
control_y=y_control,
control_z=z_control,
degree=np.array([degree], np.int16),
patch_size_meters=np.array([patch_size], np.float32),
)
write_glb(preview_path, vertices, faces, context.bounds)
progress(100)
return with_footprint(
context,
{
"representation": "bspline_surface",
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "model/gltf-binary",
"degree": degree,
"control_rows": int(len(y_control)),
"control_columns": int(len(x_control)),
"vertex_count": int(len(vertices)),
"face_count": int(len(faces)),
"artifact_bytes": artifact_size(model_path, preview_path),
},
)
def implicit_point_count(ground_count: int, config: dict[str, Any]) -> int:
"""implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부."""
minimum = int(config["implicit_min_points"])
if ground_count <= minimum:
return int(ground_count)
ratio = round(ground_count * float(config["implicit_point_ratio"]))
return int(min(max(ratio, minimum), int(config["implicit_max_points"])))
def build_implicit(
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
) -> dict[str, Any]:
maximum = max(100, int(context.config["implicit_max_points_per_tile"]))
points = context.sample(maximum)
count = implicit_point_count(context.source_count, context.config)
# 구멍(766-2)은 메운 dtm 칸을 중심점으로 더해 dtm 과 같은 면으로 이음
points = np.concatenate([context.grid_sample(count), context.hole_guides()])
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
points = points[unique_indices]
if len(points) < 4:
@@ -209,6 +122,8 @@ def build_implicit(
smoothing=np.array([float(context.config["implicit_smoothing"])], np.float32),
)
write_glb(preview_path, vertices, faces, context.bounds)
with suppress(OSError, ValueError): # 641-1 굳힌 격자 — 못 지으면 처음 읽을 때 지음
implicit_grid_npz(output_dir, stem.removeprefix("implicit_"))
progress(100)
return with_footprint(
context,
@@ -225,57 +140,7 @@ def build_implicit(
)
def build_meshfree(
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
) -> dict[str, Any]:
points = context.sample(int(context.config["meshfree_max_model_points"]))
points = points[context.contains_xy(points[:, 0], points[:, 1])]
if not len(points):
raise ValueError("외곽 안쪽 기준 적용 후 Meshfree 포인트가 남지 않았습니다.")
resolution = float(context.config["dtm_grid_resolution_meters"])
x_grid, y_grid, z_grid = context.grid(resolution)
dz_dy, dz_dx = np.gradient(z_grid, resolution, resolution)
gx = np.clip(np.searchsorted(x_grid, points[:, 0]), 0, len(x_grid) - 1)
gy = np.clip(np.searchsorted(y_grid, points[:, 1]), 0, len(y_grid) - 1)
normals = np.column_stack([-dz_dx[gy, gx], -dz_dy[gy, gx], np.ones(len(points), np.float32)])
normals /= np.maximum(np.linalg.norm(normals, axis=1, keepdims=True), 1e-9)
progress(55)
preview_max = int(context.config["max_preview_vertices"])
if len(points) > preview_max:
selection = np.linspace(0, len(points) - 1, preview_max, dtype=np.int64)
preview_points, preview_normals = points[selection], normals[selection]
else:
preview_points, preview_normals = points, normals
model_path = output_dir / f"{stem}.npz"
preview_path = output_dir / f"{stem}_preview.ply"
radius = float(context.config["meshfree_point_radius_meters"])
atomic_npz(
model_path,
points=points,
normals=normals.astype(np.float32),
radius=np.array([radius], np.float32),
)
write_binary_ply(preview_path, preview_points, preview_normals, context.bounds)
progress(100)
return with_footprint(
context,
{
"representation": "meshfree_surfels",
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "application/octet-stream",
"point_count": int(len(points)),
"preview_point_count": int(len(preview_points)),
"point_radius_meters": radius,
"artifact_bytes": artifact_size(model_path, preview_path),
},
)
BUILDERS = {
"tin": build_tin,
"dtm": build_dtm,
"nurbs": build_nurbs,
"implicit": build_implicit,
"meshfree": build_meshfree,
}
@@ -1,91 +1,34 @@
"""B04 지표면 모델 공통 컨텍스트 및 메시 유틸리티.
지면 마스크가 적용된 포인트에서 footprint(외곽), 격자, 프리뷰 격자를 만들고,
GLB/PLY 프리뷰 및 npz 모델을 원자적으로 저장하는 공통 기능을 제공한다.
5개 표현(TIN/DTM/NURBS/implicit/meshfree) 빌더가 이 컨텍스트를 공유한다.
GLB 프리뷰 및 npz 모델을 원자적으로 저장하는 공통 기능을 제공한다.
표현(DTM/implicit) 빌더가 이 컨텍스트를 공유한다.
"""
import hashlib
import json
import logging
import math
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Any, Callable
import numpy as np
import trimesh
from scipy import ndimage
from scipy.interpolate import RectBivariateSpline
from skimage.restoration import inpaint_biharmonic
from common_util.common_util_atomic import atomic_write_bytes, atomic_write_npz
logger = logging.getLogger(__name__)
MODEL_VERSION = 1
MODEL_METHODS = ("tin", "dtm", "nurbs", "implicit", "meshfree")
SOURCE_FILTER_LABELS = {"grid_min_z": "Grid Min-Z", "csf": "CSF", "pmf": "PMF"}
ProgressCallback = Callable[[int], None]
# 대용량 포인트 배치 처리 크기
_BATCH_SIZE = 500_000
# NURBS 평활 계수 — 제어점당 허용 잔차제곱(m²).
# 0.01(RMS 0.1m)은 요구가 너무 빡빡해 FITPACK이 제어점 수(122x113)보다 많은
# knot(126x117)을 밀어넣고 계수가 발산했다 — csf 표고 1e105, grid_min_z 1e10
# (2026-08-17 실측). 1.0(RMS 1m)이면 knot이 17~35로 떨어지고 세 지면 필터 모두
# 데이터 표고 범위 안에 머문다. NURBS는 평활 곡면 표현이라 이 정도 완화가 맞다.
NURBS_RESIDUAL_PER_CONTROL_POINT = 1.0
def fit_nurbs_spline(
x_control: np.ndarray, y_control: np.ndarray, z_control: np.ndarray, degree: int
) -> tuple[RectBivariateSpline, tuple[float, float]]:
"""제어 격자에 B-spline 곡면을 맞추고, 발산 판정용 표고 허용 범위를 함께 준다.
모델 빌더와 등고선 엔진이 같은 곡면을 각각 만들므로 여기서 한 번에 정의한다.
"""
spline = RectBivariateSpline(
y_control,
x_control,
z_control,
kx=min(degree, len(y_control) - 1),
ky=min(degree, len(x_control) - 1),
s=float(len(x_control) * len(y_control)) * NURBS_RESIDUAL_PER_CONTROL_POINT,
)
z_min = float(np.min(z_control))
z_max = float(np.max(z_control))
margin = max((z_max - z_min) * 0.5, 1.0)
return spline, (z_min - margin, z_max + margin)
def evaluate_nurbs_spline(
spline: RectBivariateSpline,
y_coords: np.ndarray,
x_coords: np.ndarray,
z_range: tuple[float, float],
label: str = "",
) -> np.ndarray:
"""스플라인을 평가하고 표고 허용 범위를 벗어난 값을 잘라낸다.
평활 스플라인은 제어점이 성긴 구석에서 발산할 수 있다. 그대로 두면 float32
캐스팅에서 inf가 되고 프리뷰 색상(nan)과 등고선 레벨 산출(`np.arange`가
"Maximum allowed size exceeded"로 실패)까지 번진다.
"""
z_values = np.asarray(spline(y_coords, x_coords), dtype=np.float64)
low, high = z_range
outside = np.count_nonzero(~np.isfinite(z_values) | (z_values < low) | (z_values > high))
if outside:
logger.warning(
"NURBS 곡면이 표고 범위를 벗어나 잘라냈습니다: %s%d개 셀 (허용 %.1f~%.1fm)",
f"{label} " if label else "",
int(outside),
low,
high,
)
z_values = np.nan_to_num(z_values, nan=low, posinf=high, neginf=low)
return np.clip(z_values, low, high)
# implicit 격자 칸 뽑기 씨앗 — 고정이라 다시 지어도 같은 중심점
IMPLICIT_SAMPLE_SEED = 558
# 서명에서 빼는 키 — 지오메트리를 만드는 방법이 아니라 "무엇을 만들지"를 고르는 값들.
# source_filters·precompute를 넣으면 조합을 바꿀 때마다 manifest가 통째로 폐기돼
@@ -152,44 +95,6 @@ def write_glb(path: Path, vertices: np.ndarray, faces: np.ndarray, bounds: np.nd
atomic_write_bytes(path, payload)
def write_binary_ply(
path: Path, vertices: np.ndarray, normals: np.ndarray, bounds: np.ndarray
) -> None:
verts = scene_vertices(vertices, bounds)
scene_normals = np.empty_like(normals, dtype=np.float32)
scene_normals[:, 0] = normals[:, 0]
scene_normals[:, 1] = normals[:, 2]
scene_normals[:, 2] = -normals[:, 1]
colors = height_colors(vertices)
dtype = np.dtype(
[
("x", "<f4"),
("y", "<f4"),
("z", "<f4"),
("nx", "<f4"),
("ny", "<f4"),
("nz", "<f4"),
("red", "u1"),
("green", "u1"),
("blue", "u1"),
("alpha", "u1"),
]
)
records = np.empty(len(vertices), dtype=dtype)
records["x"], records["y"], records["z"] = verts.T
records["nx"], records["ny"], records["nz"] = scene_normals.T
records["red"], records["green"], records["blue"], records["alpha"] = colors.T
header = (
"ply\nformat binary_little_endian 1.0\n"
f"element vertex {len(vertices)}\n"
"property float x\nproperty float y\nproperty float z\n"
"property float nx\nproperty float ny\nproperty float nz\n"
"property uchar red\nproperty uchar green\nproperty uchar blue\nproperty uchar alpha\n"
"end_header\n"
).encode("ascii")
atomic_write_bytes(path, header + records.tobytes())
def grid_faces(rows: int, cols: int) -> np.ndarray:
"""정규 격자의 삼각형 면 인덱스를 만든다."""
if rows < 2 or cols < 2:
@@ -219,6 +124,29 @@ def clip_and_compact_mesh(
return vertices[used], remap[kept_faces].astype(np.uint32)
def fill_holes_smooth(
z: np.ndarray, known: np.ndarray, inside: np.ndarray, min_gap_cells: float
) -> np.ndarray:
"""외곽 안 큰 구멍을 둘레 지면에서 매끈하게(쌍조화 · 판 굽힘) 이어 메움(766-2).
구멍 = 땅 칸에서 `min_gap_cells` 넘게 떨어진 칸 + 그 둘레 `min_gap_cells` 안 빈 칸(외곽 안) ·
작은 틈은 지금처럼 가까운 칸 값 · 둘레 높이 · 기울기를 이어 받음(평면 · 곡면 비탈 그대로) ·
외곽 밖 이웃도 같이 풀어 가까운 칸 값에 끌리지 않게 함.
돌려줌 = 땅 칸에서 `min_gap_cells` 넘게 먼 메운 칸(메운 넓이 지표 · implicit 길잡이).
"""
gap = ~known & inside
far = (ndimage.distance_transform_edt(~known) > min_gap_cells) & gap
if not far.any():
return far
square = np.ones((3, 3), dtype=bool)
reach = max(1, int(math.ceil(min_gap_cells)))
solve = ndimage.binary_dilation(far, square, iterations=reach) & gap
free = solve | (ndimage.binary_dilation(solve, square, iterations=reach) & ~inside & ~known)
# 라플라스(막 펴기)는 홀로 남은 땅 점마다 고깔이 생겨 튐이 늘어 버림(용화 실측)
z[free] = inpaint_biharmonic(z.astype(np.float64), free)[free]
return far
def grid_vertices(x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray) -> np.ndarray:
xx, yy = np.meshgrid(x_coords, y_coords)
return np.column_stack([xx.ravel(), yy.ravel(), z_grid.ravel()]).astype(np.float32)
@@ -236,10 +164,13 @@ class TerrainContext:
mask: np.ndarray
bounds: np.ndarray
config: dict[str, Any]
# 외곽을 그릴 지면점(다듬기 판 = 바탕 필터 마스크) — 다듬기가 지운 자리는 외곽 안 구멍으로 메움
outer_mask: np.ndarray | None = None
_indices: np.ndarray | None = None
_samples: dict[int, np.ndarray] = field(default_factory=dict)
_grids: dict[float, tuple[np.ndarray, np.ndarray, np.ndarray]] = field(default_factory=dict)
_footprint: tuple[float, float, float, np.ndarray] | None = None
_filled: dict[float, np.ndarray] = field(default_factory=dict)
@property
def source_count(self) -> int:
@@ -262,6 +193,36 @@ class TerrainContext:
self._samples[maximum] = points
return points
def grid_sample(self, count: int, seed: int = IMPLICIT_SAMPLE_SEED) -> np.ndarray:
"""implicit 전용 — 격자 칸(√(외곽 넓이 / 점 수))마다 하나 · 모자라면 무작위로 채움.
파일 차례 뽑기는 점 많은 트인 곳에 몰려 숲 밑 성긴 곳이 빔(요구 558). 씨앗 고정.
"""
indices = self.indices()
if len(indices) <= count:
return np.asarray(self.xyz[indices], dtype=np.float32)
# 칸 = 이 필터 자기 외곽 넓이 기준 — 바탕 외곽(766-2)으로 넓혀도 원래 자리 중심점 그대로
own = (
self
if self.outer_mask is None
else replace(self, outer_mask=None, _footprint=None, _grids={}, _filled={})
)
_, _, resolution, footprint = own.footprint()
cell = max(math.sqrt(float(footprint.sum()) * resolution * resolution / count), 0.01)
xy = np.asarray(self.xyz[indices][:, :2], dtype=np.float64)
rng = np.random.default_rng(seed)
order = rng.permutation(len(indices))
gx = ((xy[order, 0] - self.bounds[0, 0]) // cell).astype(np.int64)
gy = ((xy[order, 1] - self.bounds[1, 0]) // cell).astype(np.int64)
_, first = np.unique(gy * (int(gx.max()) + 1) + gx, return_index=True)
chosen = order[first]
if len(chosen) > count:
chosen = rng.choice(chosen, count, replace=False)
elif len(chosen) < count:
rest = np.setdiff1d(np.arange(len(indices)), chosen, assume_unique=True)
chosen = np.concatenate([chosen, rng.choice(rest, count - len(chosen), replace=False)])
return np.asarray(self.xyz[indices[np.sort(chosen)]], dtype=np.float32)
def footprint(self) -> tuple[float, float, float, np.ndarray]:
if self._footprint is not None:
return self._footprint
@@ -271,7 +232,7 @@ class TerrainContext:
cols = max(2, int(math.ceil((x_max - x_min) / resolution)) + 1)
rows = max(2, int(math.ceil((y_max - y_min) / resolution)) + 1)
occupied = np.zeros((rows, cols), dtype=bool)
indices = self.indices()
indices = self.indices() if self.outer_mask is None else np.flatnonzero(self.outer_mask)
for start in range(0, len(indices), _BATCH_SIZE):
points = np.asarray(self.xyz[indices[start : start + _BATCH_SIZE]], dtype=np.float32)
gx = np.clip(((points[:, 0] - x_min) / resolution).astype(np.int32), 0, cols - 1)
@@ -359,10 +320,32 @@ class TerrainContext:
grid = grid[tuple(nearest)]
x_coords = np.linspace(x_min, x_max, cols, dtype=np.float32)
y_coords = np.linspace(y_min, y_max, rows, dtype=np.float32)
inside = self.contains_xy(*np.meshgrid(x_coords, y_coords)).reshape(rows, cols)
gap_cells = float(self.config.get("footprint_gap_close_meters", 1.0)) / resolution
self._filled[resolution] = fill_holes_smooth(grid, ~missing, inside, gap_cells)
result = (x_coords, y_coords, grid)
self._grids[resolution] = result
return result
def filled(self, resolution: float) -> np.ndarray:
"""`grid(resolution)` 가 막 펴기로 메운 칸(766-2)."""
self.grid(resolution)
return self._filled[max(float(resolution), 0.05)]
def hole_guides(self) -> np.ndarray:
"""implicit 이 구멍도 같은 면으로 잇게 — 메운 dtm 칸을 틈 닫기 간격마다 하나씩 (N, 3)."""
resolution = float(self.config["dtm_grid_resolution_meters"])
x_coords, y_coords, z_grid = self.grid(resolution)
filled = self.filled(resolution)
spacing = float(self.config.get("footprint_gap_close_meters", 1.0))
step = max(1, int(round(spacing / resolution)))
lattice = np.zeros_like(filled)
lattice[::step, ::step] = True
rows, cols = np.nonzero(filled & lattice)
return np.column_stack([x_coords[cols], y_coords[rows], z_grid[rows, cols]]).astype(
np.float32
)
def preview_grid(
self, preferred_resolution: float
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
@@ -377,6 +360,7 @@ class TerrainContext:
def clear_caches(self) -> None:
self._samples.clear()
self._grids.clear()
self._filled.clear()
self._indices = None
@@ -1,6 +1,6 @@
"""B04 지표면 모델 파이프라인 오케스트레이터.
세 지면 필터(grid_min_z/csf/pmf)와 다섯 표현(TIN/DTM/NURBS/implicit/meshfree)의
지면 필터(업체 분류 · 점진 TIN · 둘의 다듬기 판)와 표현(DTM/implicit)의
캐시를 만들고 manifest.json을 관리한다. 캐시 유효성 검증, 스무딩/등고선 연동,
동일 출력 폴더의 중복 실행 취소를 포함한다.
"""
@@ -16,8 +16,10 @@ import numpy as np
from B04_PreProcess.B04_PreProcess_Engine_Contour import (
CONTOUR_EXTRACTOR_VERSION,
contour_cache_is_stale,
extract_contours,
)
from B04_PreProcess.B04_PreProcess_Engine_Ground import base_filter
from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import BUILDERS
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
MODEL_VERSION,
@@ -25,6 +27,7 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
bounds_dict,
config_signature,
)
from B04_PreProcess.B04_PreProcess_Engine_Quality import quality_after_build
from B04_PreProcess.B04_PreProcess_Engine_Smooth import (
compute_smoothing_signature,
run_smoothing,
@@ -85,10 +88,9 @@ def _cache_contours(
smooth_model_path = output_dir / f"{stem}_smooth.npz"
smooth_meta = metadata.get("smooth", {})
if smooth_model_path.exists() and smooth_meta.get("status") == "completed":
smooth_rep = "regular_grid" if method == "dtm" else "triangular_mesh"
smooth_contours = extract_contours(
smooth_model_path,
representation=smooth_rep,
representation="regular_grid",
interval=interval,
target_grid_m=target_grid_m,
scene_center=None,
@@ -140,10 +142,7 @@ def _cache_contours_safely(
_REPRESENTATIONS = {
"meshfree": "meshfree_surfels",
"dtm": "regular_grid",
"tin": "triangular_mesh",
"nurbs": "bspline_surface",
"implicit": "local_rbf_height_field",
}
@@ -151,14 +150,19 @@ _REPRESENTATIONS = {
def _cache_is_valid(
output_dir: Path, stem: str, method: str, entry: dict[str, Any], config: dict[str, Any]
) -> bool:
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다."""
ext = "ply" if method == "meshfree" else "glb"
files_exist = (output_dir / f"{stem}_preview.{ext}").exists() and (
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다.
manifest 항목이 완료여야 함 — 설정 지문이 바뀌면 manifest 가 비므로 파일만 남은
옛 결과(평활 없는 implicit)를 다시 짓는다(요구 558).
"""
if entry.get("status") != "completed":
return False
files_exist = (output_dir / f"{stem}_preview.glb").exists() and (
output_dir / f"{stem}.npz"
).exists()
if not files_exist:
return False
if method in config.get("smoothing_methods", ("dtm", "tin")):
if method in config.get("smoothing_methods", ("dtm",)):
smooth_entry = entry.get("smooth", {})
smooth_exist = (output_dir / f"{stem}_smooth.npz").exists() and (
output_dir / f"{stem}_smooth_preview.glb"
@@ -181,15 +185,15 @@ def _build_all_terrain_models(
force: bool = False,
progress: ProgressReporter | None = None,
) -> dict[str, Any]:
"""세 지면 필터와 다섯 표현의 캐시를 만들고 manifest를 반환한다."""
"""지면 필터와 표현의 캐시를 만들고 manifest를 반환한다."""
output_dir.mkdir(parents=True, exist_ok=True)
manifest_path = output_dir / "manifest.json"
filters = tuple(key for key in config["source_filters"] if key in ground_masks)
methods_list = [key for key in config["precompute"] if key in BUILDERS]
if "dtm" in methods_list:
methods_list.remove("dtm")
methods_list.insert(0, "dtm")
methods = tuple(methods_list)
# dtm 은 늘 맨 앞 — 같은 필터 외곽 · implicit 굳힌 격자 · 등고선 자르기의 바탕이라
# 어느 표현을 지어도 낡았으면(manifest 항목 없음 · 지문 다름) 같이 다시 지음(754-1).
methods = ("dtm", *(key for key in config["precompute"] if key in BUILDERS and key != "dtm"))
# 무거운 implicit 은 기본 필터(첫 필터)에만 — 둘째 필터엔 안 지음(요구 558).
later_methods = tuple(method for method in methods if method != "implicit")
signature = config_signature(config)
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
xyz = structured_data["xyz"]
@@ -211,7 +215,7 @@ def _build_all_terrain_models(
}
started_at = time.monotonic()
timeout = max(0, int(config.get("sync_timeout_seconds", 0)))
total_units = max(1, len(filters) * len(methods))
total_units = max(1, len(methods) + (len(filters) - 1) * len(later_methods))
done_units = 0
failures = 0
@@ -223,41 +227,29 @@ def _build_all_terrain_models(
mask = np.asarray(ground_masks[filter_key], dtype=bool)
if len(mask) != len(xyz):
raise ValueError(f"{filter_key} 마스크 길이가 XYZ 데이터와 다릅니다.")
context = TerrainContext(xyz=xyz, mask=mask, bounds=bounds, config=config)
# 다듬기 판은 바탕 필터 지면점으로 외곽을 그림 — 다듬기가 지운 자리 = 외곽 안 구멍(766-2)
outer = ground_masks.get(base_filter(filter_key) or "")
context = TerrainContext(xyz=xyz, mask=mask, bounds=bounds, config=config, outer_mask=outer)
filter_entry = manifest["source_filters"].setdefault(
filter_key, {"source_point_count": context.source_count, "methods": {}}
)
filter_entry["source_point_count"] = context.source_count
for method in methods:
for method in later_methods if filter_index else methods:
stem = f"{method}_{filter_key}"
entry = filter_entry["methods"].get(method, {})
if not force and _cache_is_valid(output_dir, stem, method, entry, config):
if entry.get("status") != "completed":
entry.update(
{
"status": "completed",
"representation": _REPRESENTATIONS[method],
"model_file": f"{stem}.npz",
"preview_file": f"{stem}_preview."
+ ("ply" if method == "meshfree" else "glb"),
"preview_media_type": "application/octet-stream"
if method == "meshfree"
else "model/gltf-binary",
"error": None,
}
)
filter_entry["methods"][method] = entry
_write_json_file(manifest_path, manifest)
interval = float(config.get("contour_interval_meters", 1.0))
contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json"
needs_contours = not contour_path.exists()
if method in config.get("smoothing_methods", ("dtm", "tin")):
needs_contours = contour_cache_is_stale(contour_path, output_dir / f"{stem}.npz")
if method in config.get("smoothing_methods", ("dtm",)):
smooth_contour_path = (
output_dir / f"contour_{filter_key}_{method}_smooth_{interval}m.json"
)
needs_contours = needs_contours or not smooth_contour_path.exists()
needs_contours = needs_contours or contour_cache_is_stale(
smooth_contour_path, output_dir / f"{stem}_smooth.npz"
)
if needs_contours:
_cache_contours_safely(
output_dir,
@@ -269,6 +261,7 @@ def _build_all_terrain_models(
manifest.get("bounds", {}),
entry,
)
quality_after_build(output_dir, filter_key, method, context, fresh=False)
done_units += 1
_report(f"{filter_key}-{method} 캐시 재사용")
continue
@@ -304,7 +297,7 @@ def _build_all_terrain_models(
"error": None,
}
)
if method in config.get("smoothing_methods", ("dtm", "tin")):
if method in config.get("smoothing_methods", ("dtm",)):
original_model_path = output_dir / f"{stem}.npz"
if original_model_path.exists():
try:
@@ -327,6 +320,7 @@ def _build_all_terrain_models(
manifest.get("bounds", {}),
metadata,
)
quality_after_build(output_dir, filter_key, method, context, fresh=True)
except Exception as exc:
failures += 1
filter_entry["methods"][method] = {
@@ -0,0 +1,226 @@
"""B04 서피스 품질 지표 넷 — 기준 없이 재는 값(762-2 · _분석_599 정의 · _분석_601-1 3-3).
① 튐 넓이 % — 5 × 5 칸 중간값 대비 0.5 m 넘는 칸(8 방향 묶음 = 곳 수)
② 1 m 등고선 꺾임 /km — 2 m 마다 다시 찍어 30° 넘게 꺾인 곳
③ 땅 점 빈 곳 % — 2 m 안 지면점 없는 칸 · 그중 기울기 > 15° 칸
④ 필터 차 % — 같은 표현 · 짝 필터 모델과 |차| > 0.5 m 넓이
⑤ 메운 넓이 m² · % — 외곽 안 구멍을 막 펴기로 메운 칸(766-2 · 같은 필터 dtm `filled_mask`)
저장 = `models/quality_{필터}_{표현}.json`(파생 저장본 — 모델 · 짝 모델 지문이 다를 때만 다시 셈).
"""
import json
import logging
from pathlib import Path
from typing import Any
import numpy as np
from scipy import ndimage
from skimage.measure import find_contours
from common_util.common_util_derived_store import file_stamp
from common_util.common_util_json import atomic_write_json
logger = logging.getLogger(__name__)
QUALITY_VERSION = 2
SPIKE_M = 0.5
SPIKE_WINDOW = 5
CONTOUR_INTERVAL_M = 1.0
KINK_STEP_M = 2.0
KINK_DEG = 30.0
GAP_M = 2.0
GAP_STEEP_DEG = 15.0
DIFF_M = 0.5
GRID_METHODS = ("dtm", "implicit")
# 필터 차 짝 — 업체 분류 ↔ 점진 TIN(다듬기 판끼리) · 없으면 다듬기 전 ↔ 뒤
_PAIRS = {
"classification": ("ptd", "classification_refine"),
"ptd": ("classification", "ptd_refine"),
"classification_refine": ("ptd_refine", "classification"),
"ptd_refine": ("classification_refine", "ptd"),
}
def _grid_path(models_dir: Path, filter_key: str, method: str) -> Path:
suffix = "_grid" if method == "implicit" else ""
return models_dir / f"{method}_{filter_key}{suffix}.npz"
def _load_grid(path: Path) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
with np.load(path) as data:
z = np.asarray(data["z"], dtype=np.float64)
valid = np.asarray(data["valid_mask"], dtype=bool) & np.isfinite(z)
cell = float(np.asarray(data["resolution"]).ravel()[0]) if "resolution" in data else 1.0
return np.where(valid, z, 0.0), valid, np.asarray(cell)
def spike_stats(z: np.ndarray, valid: np.ndarray) -> dict[str, Any]:
residual = z - ndimage.median_filter(z, size=SPIKE_WINDOW, mode="nearest")
inner = ndimage.binary_erosion(valid, iterations=SPIKE_WINDOW // 2)
spike = (np.abs(residual) > SPIKE_M) & inner
_, count = ndimage.label(spike, structure=np.ones((3, 3), dtype=bool))
area = int(inner.sum())
return {
"spike_pct": round(100.0 * int(spike.sum()) / area, 2) if area else None,
"spike_count": int(count),
}
def contour_kinks(z: np.ndarray, valid: np.ndarray, cell: float) -> dict[str, Any]:
if not valid.any():
return {"contour_km": 0.0, "kinks_per_km": None, "bubbles": 0}
low, high = float(z[valid].min()), float(z[valid].max())
total, kinks, bubbles = 0.0, 0, 0
step = KINK_STEP_M / cell
for level in np.arange(np.ceil(low / CONTOUR_INTERVAL_M) * CONTOUR_INTERVAL_M, high, 1.0):
for line in find_contours(z, level, mask=valid):
seg = np.hypot(*np.diff(line, axis=0).T)
length = float(seg.sum())
if length < step * 2:
continue
total += length
if np.allclose(line[0], line[-1]) and length * cell < 30.0:
bubbles += 1
along = np.r_[0.0, np.cumsum(seg)]
t = np.arange(0.0, length, step)
rows, cols = np.interp(t, along, line[:, 0]), np.interp(t, along, line[:, 1])
angle = np.arctan2(np.diff(rows), np.diff(cols))
turn = np.abs((np.diff(angle) + np.pi) % (2 * np.pi) - np.pi)
kinks += int((np.degrees(turn) > KINK_DEG).sum())
km = total * cell / 1000.0
return {
"contour_km": round(km, 2),
"kinks_per_km": round(kinks / km, 1) if km else None,
"bubbles": bubbles,
}
def gap_stats(
z: np.ndarray, valid: np.ndarray, occupied: np.ndarray, cell: float
) -> dict[str, Any]:
gap = (ndimage.distance_transform_edt(~occupied) * cell > GAP_M) & valid
grad_y, grad_x = np.gradient(z, cell)
steep = np.degrees(np.arctan(np.hypot(grad_x, grad_y))) > GAP_STEEP_DEG
area = int(valid.sum())
return {
"gap_pct": round(100.0 * int(gap.sum()) / area, 2) if area else None,
"gap_steep_pct": round(100.0 * int((gap & steep).sum()) / area, 2) if area else None,
}
def ground_occupancy(xyz: Any, indices: np.ndarray, bounds: np.ndarray, shape: tuple) -> np.ndarray:
"""지면점이 하나라도 든 칸 — dtm 격자와 같은 칸 나눔(`TerrainContext.grid`)."""
rows, cols = shape
cell_x = (bounds[0, 1] - bounds[0, 0]) / max(cols - 1, 1)
cell_y = (bounds[1, 1] - bounds[1, 0]) / max(rows - 1, 1)
occupied = np.zeros(shape, dtype=bool)
for start in range(0, len(indices), 2_000_000):
points = np.asarray(xyz[indices[start : start + 2_000_000]], dtype=np.float64)
gx = np.clip(((points[:, 0] - bounds[0, 0]) / cell_x).astype(np.int64), 0, cols - 1)
gy = np.clip(((points[:, 1] - bounds[1, 0]) / cell_y).astype(np.int64), 0, rows - 1)
occupied[gy, gx] = True
return occupied
def diff_pct(z: np.ndarray, valid: np.ndarray, other_path: Path) -> float | None:
other_z, other_valid, _ = _load_grid(other_path)
if other_z.shape != z.shape:
return None
both = valid & other_valid
area = int(both.sum())
if not area:
return None
return round(100.0 * int(((np.abs(z - other_z) > DIFF_M) & both).sum()) / area, 2)
def filled_stats(
models_dir: Path, filter_key: str, valid: np.ndarray, cell: float
) -> dict[str, Any]:
with np.load(models_dir / f"dtm_{filter_key}.npz") as data:
filled = np.asarray(data["filled_mask"], dtype=bool) if "filled_mask" in data else None
if filled is None or filled.shape != valid.shape:
return {"filled_m2": None, "filled_pct": None}
count, area = int((filled & valid).sum()), int(valid.sum())
return {
"filled_m2": round(count * cell * cell, 1),
"filled_pct": round(100.0 * count / area, 2) if area else None,
}
def _pair(models_dir: Path, filter_key: str, method: str) -> Path | None:
for other in _PAIRS.get(filter_key, ()):
path = _grid_path(models_dir, other, method)
if path.is_file():
return path
return None
def ensure_quality(
models_dir: Path,
filter_key: str,
method: str,
occupied: np.ndarray | None = None,
) -> dict[str, Any] | None:
"""지표 넷을 돌려줌 — 저장본 지문이 맞으면 그대로 · ③ 은 지을 때 넘긴 지면점 칸만."""
if method not in GRID_METHODS:
return None
grid_path = _grid_path(models_dir, filter_key, method)
if not grid_path.is_file():
return None
pair = _pair(models_dir, filter_key, method)
key = {
"version": QUALITY_VERSION,
"model": file_stamp(grid_path),
"pair": [pair.name, file_stamp(pair)] if pair else None,
}
path = models_dir / f"quality_{filter_key}_{method}.json"
try:
stored = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
stored = {}
if stored.get("key") == key and occupied is None:
return stored
z, valid, cell = _load_grid(grid_path)
cell = float(cell)
same_model = (stored.get("key") or {}).get("model") == key["model"]
result: dict[str, Any] = {"key": key, "filter": filter_key, "method": method}
if same_model and occupied is None:
result.update({k: stored.get(k) for k in ("spike_pct", "spike_count", "contour_km")})
result.update({k: stored.get(k) for k in ("kinks_per_km", "bubbles")})
else:
result.update(spike_stats(z, valid))
result.update(contour_kinks(z, valid, cell))
if occupied is not None and occupied.shape == z.shape:
result.update(gap_stats(z, valid, occupied, cell))
elif same_model:
result.update({k: stored.get(k) for k in ("gap_pct", "gap_steep_pct")})
result["diff_against"] = pair.name if pair else None
result["diff_pct"] = diff_pct(z, valid, pair) if pair else None
result.update(filled_stats(models_dir, filter_key, valid, cell))
atomic_write_json(path, result)
return result
def ensure_quality_safely(models_dir: Path, filter_key: str, method: str, **kwargs: Any) -> None:
"""지을 때 부름 — 실패가 모델 짓기를 막지 않음."""
try:
ensure_quality(models_dir, filter_key, method, **kwargs)
except Exception as exc: # noqa: BLE001
logger.warning("서피스 품질 지표 실패: %s-%s %s", filter_key, method, exc)
def quality_after_build(
models_dir: Path, filter_key: str, method: str, context: Any, fresh: bool
) -> None:
"""파이프라인이 표현 하나를 짓거나 다시 쓴 뒤 부름 — ③ 은 지면점이 손에 있을 때만 셈."""
if method not in GRID_METHODS:
return
occupied = None
if fresh or not (models_dir / f"quality_{filter_key}_{method}.json").is_file():
try:
resolution = float(context.config.get("dtm_grid_resolution_meters", 1.0))
shape = context.grid(resolution)[2].shape
occupied = ground_occupancy(context.xyz, context.indices(), context.bounds, shape)
except Exception as exc: # noqa: BLE001
logger.warning("서피스 품질 지면점 칸 실패: %s %s", filter_key, exc)
ensure_quality_safely(models_dir, filter_key, method, occupied=occupied)
@@ -1,57 +1,23 @@
"""도엽등고선 → 표고 격자 보간 방식 모음 (비교용).
"""도엽등고선 → 표고 격자 보간 — 라플라스(거리 비례 초기값 + 이웃 평균 다듬기).
문헌(Hutchinson 1988/89 ANUDEM; Chaplot 2006; Arun 2013 등)은 지형 복잡도·자료 밀도에
따라 우열이 갈리며 단일 최적해가 없다고 본다. 그래서 방식을 하나로 고르지 않고 여기
모아 두고 B04 화면에서 바꿔 가며 보게 한다(2026-08-30 사용자 지시).
비교용 다섯 방식(TIN 둘 · TPS · ANUDEM형 · 다중해상도)은 걷음(608) — 확정 · 자동 = 라플라스.
각 builder는 `(spec, burned, features, cell_m) -> (R, C) float32` 격자를 돌려준다.
builder는 `(spec, burned, features, cell_m) -> (R, C) float32` 격자를 돌려준다.
`burned`는 등고 라인이 구워진 격자(라인 셀 = 표고, 그 외 NaN)다. 폐합 링 안쪽 처리와
프리뷰·저장은 호출측(`_SheetSurface`)이 방식과 무관하게 똑같이 해 준다.
"""
import logging
import warnings
from typing import Any, Callable
import numpy as np
logger = logging.getLogger(__name__)
# TIN(도엽선)이 물어 오는 격자 밖 여유 — 테두리가 삼각망 밖으로 나가지 않을 만큼만.
SHEET_TIN_CLIP_MARGIN_M = 100.0
# 화면 버튼에 쓰는 이름 — 키는 surface_models.generation_params.source_filter 접미사다.
SHEET_METHOD_LABELS: dict[str, str] = {
"tin_sheet": "TIN(도엽선)",
"tin": "TIN 격자",
"biharmonic": "TPS(박판)",
"anudem": "ANUDEM형",
"multires": "다중해상도",
"laplace": "라플라스",
}
def _laplacian(values: np.ndarray) -> np.ndarray:
padded = np.pad(values, 1, mode="edge")
return (
padded[:-2, 1:-1] + padded[2:, 1:-1] + padded[1:-1, :-2] + padded[1:-1, 2:] - 4.0 * values
)
def _contour_vertices(burned: np.ndarray, spec: Any) -> tuple[np.ndarray, np.ndarray]:
"""등고 라인 셀을 (N,2) 세계좌표와 표고로 바꾼다."""
rows, cols = np.nonzero(np.isfinite(burned))
xs = spec.cell_centers_x()[cols]
ys = spec.cell_centers_y()[rows]
return np.column_stack([xs, ys]), burned[rows, cols].astype(np.float64)
def _grid_points(spec: Any) -> tuple[np.ndarray, np.ndarray]:
grid_x, grid_y = np.meshgrid(spec.cell_centers_x(), spec.cell_centers_y())
return grid_x, grid_y
# ── ① 거리 비례 (버튼에서는 뺐지만 다른 방식의 초기추정으로 계속 쓴다) ───────
# ── 거리 비례 (라플라스 초기값) ─────────────────────────────────────────────
def build_distance(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""가장 가까운 서로 다른 표고 두 라인 사이를 거리 비례로 나눈다.
@@ -120,394 +86,17 @@ def relax_laplace(surface: np.ndarray, fixed: np.ndarray, iterations: int) -> No
surface[colour] = neighbours[colour]
# ── ② 라플라스(조화) ─────────────────────────────────────────────────────────
# ── 라플라스(조화) ─────────────────────────────────────────────────────────────
def build_laplace(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""등고선을 경계값으로 두고 Δz=0을 푼다.
면이 매끈해지지만 z=r(원뿔·능선)은 조화함수가 아니라 마루가 눌린다. 비교 기준으로
남겨 둔다 — ANUDEM이 라플라스 대신 박판 스플라인을 쓰는 이유를 눈으로 보기 위함.
면이 매끈해지지만 z=r(원뿔·능선)은 조화함수가 아니라 마루가 조금 눌린다.
"""
surface = build_distance(spec, burned, features, cell_m)
relax_laplace(surface, np.isfinite(burned), 400)
return surface
# ── ③ 박판 스플라인(중조화) ──────────────────────────────────────────────────
def solve_min_curvature(constrained: np.ndarray, guess: np.ndarray) -> np.ndarray:
"""제약 셀을 고정하고 Δ²z=0(박판 스플라인)을 최소곡률 최소제곱으로 푼다.
ANUDEM/Topo to Raster가 쓰는 박판 스플라인과 같은 연산자다. 라플라스와 달리 z=r을
그대로 통과시켜 능선·마루가 눌리지 않고, 경사가 등고선 너머로 자연스럽게 이어진다.
`constrained`: 값이 고정된 셀(등고 라인, 필요하면 구조선 앵커) — 그 외는 NaN.
`guess`: 시작값이자 감쇠 기준(보통 거리 보간 결과).
"""
from scipy.sparse.linalg import LinearOperator, lsmr
guess = guess.astype(np.float64)
burned = constrained
# 제약 셀 + **격자 테두리**를 고정한다. 최외곽 등고선 바깥이 통째로 자유면
# 1차함수가 Δ²의 영공간에 남아 해가 하나로 정해지지 않고 켤레기울기가 발산한다
# (2026-08-30 실측: |Δz| 2092m). 테두리는 거리 보간값으로 묶는다.
fixed = np.isfinite(burned)
fixed[0, :] = fixed[-1, :] = True
fixed[:, 0] = fixed[:, -1] = True
free = ~fixed & np.isfinite(guess)
if not free.any():
return guess.astype(np.float32)
index = np.flatnonzero(free.ravel())
values = np.where(np.isfinite(burned), burned, guess)
base = np.where(fixed, np.nan_to_num(values), 0.0)
# Δ²z=0을 정규방정식(CG)으로 풀면 조건수가 격자변 4제곱이라 발산한다(실측).
# 대신 **최소곡률** 최소제곱으로 세운다 — 자유 셀에 대해 ‖Δz‖를 최소화하며,
# 그 정상해가 곧 Δ²z=0이다. 조건수가 제곱으로 줄어 LSMR이 안정적으로 푼다
# (Briggs 1974의 최소곡률 격자화와 같은 목적함수).
# 최소곡률만으로는 제약(등고선)에서 먼 영역이 정해지지 않아 해가 폭주한다.
# ANUDEM의 거칠기 벌점과 같은 취지로 감쇠항을 붙여 거리 보간값에 묶어 둔다:
# minimize ‖Δz‖² + λ‖z − 거리보간‖²
# λ가 작을수록 더 매끈하고 클수록 거리 보간에 가깝다.
total_cells = base.size
free_count = len(index)
damping = np.float64(np.sqrt(0.02))
anchor = guess.ravel()[index]
def forward(vector: np.ndarray) -> np.ndarray:
# 반드시 **선형**이어야 한다 — 고정 셀 기여(base)를 여기서 더하면 아핀이 되어
# LSMR의 전제가 깨지고 해가 폭주한다. base 몫은 우변으로만 넘긴다.
scattered = np.zeros_like(base)
scattered.ravel()[index] = vector
return np.concatenate([_laplacian(scattered).ravel(), damping * vector])
def adjoint(vector: np.ndarray) -> np.ndarray:
curvature = _laplacian(vector[:total_cells].reshape(base.shape)).ravel()[index]
return curvature + damping * vector[total_cells:]
rhs = np.concatenate([-_laplacian(base).ravel(), damping * anchor])
linear = LinearOperator(
(total_cells + free_count, free_count),
matvec=forward,
rmatvec=adjoint,
dtype=np.float64,
)
result = lsmr(linear, rhs, x0=anchor, maxiter=400, atol=1e-8, btol=1e-8)
solution, info = result[0], result[1]
surface = base.copy()
surface.ravel()[index] = solution
surface[fixed] = values[fixed]
# 안전장치 — 발산하면 조용히 틀린 지형을 넘기지 말고 거리 보간으로 되돌린다.
drift = float(np.nanmax(np.abs(surface - guess)))
span = float(np.nanmax(guess) - np.nanmin(guess))
if not np.isfinite(drift) or drift > max(span, 1.0):
logger.warning(
"도엽 서피스(TPS): 해가 발산해(최대 %.1fm) 거리 보간으로 되돌립니다 (info=%s).",
drift,
info,
)
return guess.astype(np.float32)
logger.info("도엽 서피스(TPS): 최소제곱 info=%s, 최대 변화 %.2fm", info, drift)
return surface.astype(np.float32)
def build_biharmonic(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""등고선만 제약으로 둔 박판 스플라인."""
return solve_min_curvature(burned, build_distance(spec, burned, features, cell_m))
# ── ④ TIN ───────────────────────────────────────────────────────────────────
def _triangulate(
spec: Any, shape_: tuple[int, int], points: np.ndarray, values: np.ndarray
) -> np.ndarray:
"""정점 구름을 Delaunay 삼각망 선형 보간해 격자로 편다. 삼각망 밖은 NaN."""
from scipy.interpolate import LinearNDInterpolator
if len(points) < 3:
return np.full(shape_, np.nan, dtype=np.float32)
interpolator = LinearNDInterpolator(points, values)
grid_x, grid_y = _grid_points(spec)
surface = np.empty(shape_, dtype=np.float32)
chunk = max(1, int(4_000_000 // max(spec.n_cols, 1)))
for start in range(0, spec.n_rows, chunk):
stop = min(start + chunk, spec.n_rows)
surface[start:stop] = interpolator(grid_x[start:stop], grid_y[start:stop]).astype(
np.float32
)
return surface
def build_tin(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""격자에 구운 등고 라인 셀을 Delaunay 삼각망 선형 보간한다.
정점이 셀 중심에 맞춰져 있어 1m 계단이 삼각망에 그대로 실린다. 같은 표고 정점
3개로 이루어진 평탄 삼각형이 굴곡부·마루에 계단을 만든다. 비교 기준으로 남긴다.
"""
points, values = _contour_vertices(burned, spec)
if len(points) > 120_000: # 삼각망 비용은 정점 수에 비례한다
step = int(np.ceil(len(points) / 120_000))
points, values = points[::step], values[::step]
return _triangulate(spec, burned.shape, points, values)
def build_tin_sheet(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""**원본 5m 도엽 등고선 정점**을 그대로 이은 고전 TIN (2026-08-30 사용자 지시).
`tin`은 격자에 구운 라인 셀(=1m 계단으로 뭉개진 정점)을 쓰지만, 이쪽은 벡터
등고선의 정점을 좌표 그대로 쓴다 — 도면 등고선을 삼각망으로 잇는 측량 관행 그대로다.
격자 밖 등고선은 물지 않는다(삼각망 비용만 커지고 결과는 같다). 다만 격자 테두리가
삼각망 밖으로 나가지 않도록 여유를 두고 자른다.
길이 필터는 두지 않는다 — 짧은 봉우리 폐합 등고선을 버리면 마루가 통째로 평평해진다.
"""
from shapely.geometry import shape as to_shape
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Grid import (
ELEVATION_KEYS,
iter_linestrings,
)
xs, ys = spec.cell_centers_x(), spec.cell_centers_y()
margin = max(SHEET_TIN_CLIP_MARGIN_M, cell_m * 2.0)
x_lo, x_hi = xs[0] - margin, xs[-1] + margin
y_lo, y_hi = ys[-1] - margin, ys[0] + margin
coords: list[np.ndarray] = []
levels: list[np.ndarray] = []
for feature in features or []:
properties = feature.get("properties") or {}
elevation = next(
(float(properties[key]) for key in ELEVATION_KEYS if properties.get(key) is not None),
None,
)
geometry = feature.get("geometry")
if elevation is None or not geometry:
continue
try:
parsed = to_shape(geometry)
except Exception: # noqa: BLE001 — 손상된 피처는 건너뛴다
continue
for line in iter_linestrings(parsed):
point = np.asarray(line.coords, dtype=np.float64)[:, :2]
inside = (
(point[:, 0] >= x_lo)
& (point[:, 0] <= x_hi)
& (point[:, 1] >= y_lo)
& (point[:, 1] <= y_hi)
)
if not inside.any():
continue
coords.append(point[inside])
levels.append(np.full(int(inside.sum()), elevation, dtype=np.float64))
if not coords:
logger.warning("도엽 서피스: TIN(도엽선)에 쓸 등고선 정점이 없습니다.")
return np.full(burned.shape, np.nan, dtype=np.float32)
points = np.vstack(coords)
values = np.concatenate(levels)
# 도엽 이음매에서 같은 정점이 겹쳐 들어온다 — Qhull 비용만 늘어 미리 접는다.
_, unique = np.unique(np.round(points, 3), axis=0, return_index=True)
points, values = points[unique], values[unique]
logger.info("도엽 서피스: TIN(도엽선) 정점 %d개", len(points))
return _triangulate(spec, burned.shape, points, values)
# ── ⑦ ANUDEM형 (구조선 + 배수 강제) ─────────────────────────────────────────
def _contour_corner_anchors(
spec: Any, burned: np.ndarray, guess: np.ndarray, cell_m: float
) -> tuple[np.ndarray, np.ndarray] | None:
"""등고선의 국소 최대 곡률점(코너)에서 능선·계곡 구조선 앵커를 만든다.
ANUDEM은 등고선 자체의 곡률에서 능선·계곡망을 먼저 뽑아 흐름 구조를 세운다
(Hutchinson 1988/89). 여기서도 같은 순서를 따른다.
① 라인마다 정점 곡률을 재 국소 최대점(V자 꼭짓점)을 고른다
② 굽은 안쪽이 더 높으면 **계곡**(등고선 V가 상류를 가리킴), 낮으면 **능선**
③ 같은 종류의 코너를 이웃 표고끼리 이어 그 사이를 선형 보간해 앵커로 심는다
앵커는 박판 해의 제약으로 들어가 계곡 바닥이 이어져 내려가고 능선 마루가 선다.
"""
from scipy.ndimage import label
from scipy.spatial import cKDTree
levels = np.unique(burned[np.isfinite(burned)])
if len(levels) < 2:
return None
interval = float(np.diff(levels).min())
xs = spec.cell_centers_x()
ys = spec.cell_centers_y()
corners: list[tuple[float, float, float, int]] = [] # x, y, level, +1 계곡 / -1 능선
span = 6 # 곡률을 재는 정점 간격(px) — 짧으면 노이즈, 길면 꼭짓점을 놓친다
for level in levels:
labelled, count = label(burned == level)
for component_id in range(1, count + 1):
line_rows, line_cols = np.nonzero(labelled == component_id)
if len(line_rows) < 3 * span:
continue
# 라인 셀을 한 줄로 세운다 — 좌표 정렬로 근사한다(정밀 추적은 과하다).
order = np.argsort(line_cols + line_rows * 1e-3)
path = np.column_stack([line_cols[order], line_rows[order]]).astype(np.float64)
before = np.roll(path, span, axis=0)
after = np.roll(path, -span, axis=0)
first = path - before
second = after - path
first_len = np.hypot(first[:, 0], first[:, 1])
second_len = np.hypot(second[:, 0], second[:, 1])
valid = (first_len > 1e-6) & (second_len > 1e-6)
cosine = np.ones(len(path))
cosine[valid] = (first[valid] * second[valid]).sum(axis=1) / (
first_len[valid] * second_len[valid]
)
sharp = np.flatnonzero(valid & (cosine < 0.3)) # 70도 이상 꺾인 자리
for i in sharp[:: max(1, span)]:
# 굽은 안쪽 방향 = 두 변 단위벡터 합의 반대
inward = -(first[i] / first_len[i] + second[i] / second_len[i])
norm = float(np.hypot(inward[0], inward[1]))
if norm < 1e-6:
continue
probe = path[i] + inward / norm * 6.0
probe_col = int(round(probe[0]))
probe_row = int(round(probe[1]))
if not (0 <= probe_row < guess.shape[0] and 0 <= probe_col < guess.shape[1]):
continue
inside = float(guess[probe_row, probe_col])
if not np.isfinite(inside) or abs(inside - level) < interval * 0.15:
continue
corners.append(
(
float(xs[int(path[i, 0])]),
float(ys[int(path[i, 1])]),
float(level),
1 if inside > level else -1,
)
)
if len(corners) < 4:
logger.info("도엽 서피스(ANUDEM형): 등고선 코너가 부족해 구조선을 건너뜁니다.")
return None
array = np.asarray(corners, dtype=np.float64)
anchor_xy: list[np.ndarray] = []
anchor_z: list[np.ndarray] = []
reach = interval * 20.0 # 이보다 먼 코너는 같은 구조선으로 보지 않는다
for level in levels[:-1]:
upper = level + interval
lower_set = array[np.abs(array[:, 2] - level) < 1e-6]
upper_set = array[np.abs(array[:, 2] - upper) < 1e-6]
if not len(lower_set) or not len(upper_set):
continue
tree = cKDTree(upper_set[:, :2])
distance, index = tree.query(lower_set[:, :2], k=1)
for i in range(len(lower_set)):
j = int(index[i])
if distance[i] > reach or lower_set[i, 3] != upper_set[j, 3]:
continue
start = lower_set[i, :2]
end = upper_set[j, :2]
steps = max(2, int(distance[i] / max(cell_m, 1e-6) / 4))
fraction = np.linspace(0.0, 1.0, steps + 1)[1:-1]
if not len(fraction):
continue
anchor_xy.append(start + (end - start) * fraction[:, None])
anchor_z.append(level + interval * fraction)
if not anchor_xy:
return None
return np.vstack(anchor_xy), np.concatenate(anchor_z)
def _enforce_drainage(surface: np.ndarray, epsilon: float = 0.01) -> int:
"""가짜 웅덩이를 메운다 — ANUDEM의 배수 강제와 같은 목적.
등고선만으로 만든 면에는 흐름이 끊기는 웅덩이가 남는다. 형태학적 재구성(erosion)
으로 채우되 완전 평탄해지지 않게 아주 작은 값을 얹는다. 채운 셀 수를 반환한다.
"""
from skimage.morphology import reconstruction
if not np.isfinite(surface).all():
return 0
seed = np.full(surface.shape, float(surface.max()), dtype=np.float64)
seed[0, :] = surface[0, :]
seed[-1, :] = surface[-1, :]
seed[:, 0] = surface[:, 0]
seed[:, -1] = surface[:, -1]
filled = reconstruction(seed, surface.astype(np.float64), method="erosion")
raised = filled > surface + 1e-6
if not raised.any():
return 0
# 그냥 채우면 웅덩이가 통째로 평탄해져 흐름 방향이 없어진다. 채운 영역 안쪽으로
# 갈수록 아주 조금 높아지게 해서 물이 가장자리(넘침점)로 빠져나가게 둔다
# (Garbrecht·Martz의 평탄면 해소를 간단히 옮긴 것 — 표고 변화는 cm 단위다).
from scipy.ndimage import distance_transform_edt
inner = distance_transform_edt(raised)
surface[raised] = (filled[raised] + epsilon * inner[raised]).astype(surface.dtype)
return int(raised.sum())
def build_anudem(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""ANUDEM형 — 등고선 곡률에서 능선·계곡 구조선을 뽑아 제약에 더하고, 박판으로 풀고,
가짜 웅덩이를 메운다. Topo to Raster가 밟는 세 단계를 그대로 옮긴 것이다."""
guess = build_distance(spec, burned, features, cell_m).astype(np.float64)
constrained = burned.astype(np.float64).copy()
anchors = _contour_corner_anchors(spec, burned, guess, cell_m)
if anchors is not None:
xy, z = anchors
row, col = spec.world_to_rc(xy[:, 0].copy(), xy[:, 1].copy())
inside = (row >= 0) & (col >= 0)
row, col, z = row[inside], col[inside], z[inside]
free = ~np.isfinite(constrained[row, col])
constrained[row[free], col[free]] = z[free]
logger.info("도엽 서피스(ANUDEM형): 구조선 앵커 %d셀", int(free.sum()))
surface = solve_min_curvature(constrained, guess).astype(np.float32)
logger.info("도엽 서피스(ANUDEM형): 가짜 웅덩이 %d셀 메움", _enforce_drainage(surface))
return surface
# ── ⑧ 다중해상도 (coarse → fine) ────────────────────────────────────────────
def build_multires(spec: Any, burned: np.ndarray, features: Any, cell_m: float) -> np.ndarray:
"""성긴 격자에서 풀고 점차 세밀화한다 — ANUDEM의 다중해상도 전략.
전체 형상은 성긴 격자에서 싸게 잡고, 세밀한 격자에서는 등고선 근처만 다듬는다.
한 해상도에서만 풀 때보다 넓은 밴드가 고르게 퍼지고 값싸게 수렴한다.
"""
from scipy.ndimage import zoom
surface: np.ndarray | None = None
for factor in (8, 4, 2, 1):
if factor == 1:
coarse = burned
else:
# 성긴 격자의 제약 — 블록 안 등고 라인의 평균 표고를 대표로 쓴다.
rows = burned.shape[0] // factor * factor
cols = burned.shape[1] // factor * factor
blocks = burned[:rows, :cols].reshape(rows // factor, factor, cols // factor, factor)
# 라인이 하나도 없는 블록은 NaN이 정상이라 경고를 삼킨다.
with warnings.catch_warnings():
warnings.simplefilter("ignore", RuntimeWarning)
coarse = np.nanmean(blocks, axis=(1, 3)).astype(np.float32)
level = build_distance(spec, coarse, features, cell_m * factor)
if surface is not None:
# 앞 단계 해를 지금 해상도로 올려 절반씩 섞는다 — 성긴 단계의 넓은 추세를
# 이어받되 이번 해상도의 등고선 정보를 덮지 않는다.
scale = (level.shape[0] / surface.shape[0], level.shape[1] / surface.shape[1])
upscaled = zoom(surface, scale, order=1)
free = ~np.isfinite(coarse)
level[free] = (level[free] + upscaled[free]) * 0.5
relax_laplace(level, np.isfinite(coarse), 8)
surface = level
assert surface is not None
if surface.shape != burned.shape: # 블록 자르기로 남은 가장자리 보정
scale = (burned.shape[0] / surface.shape[0], burned.shape[1] / surface.shape[1])
surface = zoom(surface, scale, order=1)
line = np.isfinite(burned)
surface[line] = burned[line]
return surface.astype(np.float32)
SHEET_METHOD_BUILDERS: dict[str, Callable[[Any, np.ndarray, Any, float], np.ndarray]] = {
"tin_sheet": build_tin_sheet,
"tin": build_tin,
"biharmonic": build_biharmonic,
"anudem": build_anudem,
"multires": build_multires,
"laplace": build_laplace,
}
@@ -39,8 +39,10 @@ from .B04_PreProcess_Engine_MapSheet import latlon_to_sheet5k, sheet5k_to_bounds
logger = logging.getLogger(__name__)
# 도곽선 레이어 코드 (수치지형도 v2.0 도엽본) — 도곽선 N3L_H0010000 이 없는 zip 은 옛 A0010000(도로 면)로 대신
# 도곽선 레이어 코드 (수치지형도 v2.0 도엽본) — 선 N3L_H0010000 · 면 N3A_H0010000(섬 · 해안 작은 도엽)
# · 둘 다 없는 zip 은 옛 A0010000(도로 면)로 대신
_SHEET_FRAME_CODE = "N3L_H0010000"
_SHEET_FRAME_AREA_CODE = "N3A_H0010000"
_SHEET_FRAME_FALLBACK_CODE = "A0010000"
_SHEET_NO_RE = re.compile(r"(\d{8})")
_SHARED_DIRNAME = "_shared" # storage/_shared 아래 = 여러 프로젝트가 같이 쓰는 창고
@@ -153,7 +155,11 @@ def download_sheet(store_dir: str | Path, sheet_no: str, version: int = 2) -> di
rows = search_sheet_files(sheet_no, version=version)
if not rows:
raise LookupError(f"도엽 {sheet_no}: 브이월드 검색 결과 없음")
row = rows[0]
# Ver2.0 이 zip 아닌 NGI · NDA 뿐인 도엽(작은 섬) — 받을 zip 없음
row = next((r for r in rows if str(r.get("file_nm", "")).lower().endswith(".zip")), None)
if row is None:
kinds = ", ".join(sorted({str(r.get("file_extsn", "")) for r in rows}))
raise LookupError(f"도엽 {sheet_no}: Ver2.0 zip 없음({kinds})")
cookie = _session_cookie_cache or VWORLD_SESSION_COOKIE
payload = None
@@ -318,7 +324,7 @@ def ingest_zip(store_dir: str | Path, src_path: str | Path, sheet_no: str | None
def _read_frame_bounds_wgs84(zip_path: Path) -> tuple[tuple[float, float, float, float], str]:
"""zip 내 도곽선(N3L_H0010000 · 없으면 A0010000) SHP bounds를 WGS84로 반환. (bounds, 사용된 CRS).
"""zip 내 도곽선(N3L_H0010000 · N3A_H0010000 · 없으면 A0010000) SHP bounds를 WGS84로 반환. (bounds, 사용된 CRS).
선언된 PRJ가 오라벨(중부/동부원점 뒤바뀜)인 실측 사례가 있어, 파일명 도엽번호
기준 예측 도곽과 대조해 정합한 CRS를 자동 판별한다.
@@ -328,7 +334,7 @@ def _read_frame_bounds_wgs84(zip_path: Path) -> tuple[tuple[float, float, float,
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
shp: list[str] = []
for code in (_SHEET_FRAME_CODE, _SHEET_FRAME_FALLBACK_CODE):
for code in (_SHEET_FRAME_CODE, _SHEET_FRAME_AREA_CODE, _SHEET_FRAME_FALLBACK_CODE):
shp = [n for n in names if n.upper().endswith(f"{code}.SHP")]
if shp:
break
+3 -42
View File
@@ -1,7 +1,7 @@
"""B04 지표면 스무딩 엔진 (DTM 가우시안+B-Spline, TIN Taubin).
"""B04 지표면 스무딩 엔진 (DTM 가우시안+B-Spline).
원본 모델(npz)을 로드해 표고 Z만 평활화한 스무딩 모델과 프리뷰(GLB)를 만든다.
XY 위치와 삼각형 연결은 원본을 유지한다.
XY 격자는 원본을 유지한다.
"""
import hashlib
@@ -10,7 +10,6 @@ from pathlib import Path
from typing import Any
import numpy as np
import trimesh
from scipy import ndimage
from scipy.interpolate import RectBivariateSpline
@@ -90,7 +89,7 @@ def smooth_dtm(
y=y_coords,
z=z_smooth,
valid_mask=valid_grid,
resolution=np.array([preview_res], np.float32),
resolution=np.array([x_coords[1] - x_coords[0]], np.float32), # 넓힌 실제 칸(633)
)
write_glb(preview_path, vertices_compact, faces_compact, context.bounds)
return {
@@ -104,48 +103,10 @@ def smooth_dtm(
}
def smooth_tin(
context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path
) -> dict[str, Any]:
"""TIN 삼각망에 Taubin 저수축 스무딩을 적용한다 (Z만 평활화)."""
data = np.load(original_model_path)
vertices, faces = data["vertices"], data["faces"]
if len(vertices) < 3 or not len(faces):
raise ValueError("TIN 스무딩을 수행할 삼각망 메쉬 데이터가 올바르지 않습니다.")
iterations = int(context.config.get("smoothing_tin_taubin_iterations", 10))
lamb = float(context.config.get("smoothing_tin_taubin_lambda", 0.5))
mu = float(context.config.get("smoothing_tin_taubin_mu", -0.53))
mesh = trimesh.Trimesh(vertices=vertices, faces=faces, process=False)
if iterations > 0:
trimesh.smoothing.filter_taubin(mesh, lamb=lamb, nu=mu, iterations=iterations)
vertices_smooth = np.asarray(mesh.vertices, dtype=np.float32)
# 지표면 스무딩은 리토폴로지가 아니다: XY·연결은 원본, Z만 평활화.
vertices_smooth[:, :2] = np.asarray(vertices[:, :2], dtype=np.float32)
faces_smooth = np.asarray(mesh.faces, dtype=np.uint32)
model_path = output_dir / f"{stem}_smooth.npz"
preview_path = output_dir / f"{stem}_smooth_preview.glb"
atomic_npz(model_path, vertices=vertices_smooth, faces=faces_smooth)
write_glb(preview_path, vertices_smooth, faces_smooth, context.bounds)
return {
"model_file": model_path.name,
"preview_file": preview_path.name,
"preview_media_type": "model/gltf-binary",
"vertex_count": int(len(vertices_smooth)),
"face_count": int(len(faces_smooth)),
"artifact_bytes": artifact_size(model_path, preview_path),
"smoothing_signature": compute_smoothing_signature(context.config),
}
def run_smoothing(
method: str, context: TerrainContext, output_dir: Path, stem: str, original_model_path: Path
) -> dict[str, Any]:
"""방식에 따라 적절한 스무딩 엔진을 수행한다."""
if method == "dtm":
return smooth_dtm(context, output_dir, stem, original_model_path)
if method == "tin":
return smooth_tin(context, output_dir, stem, original_model_path)
raise ValueError(f"스무딩이 불가능한 지형 표현 방식입니다: {method}")
@@ -4,7 +4,7 @@
만들고, 뒤 단계(지면필터·모델·등고선·배수)는 받는 형식이 그대로라 손대지 않는다.
점이 임계를 넘으면 **칸(기본 0.5m)마다 최저점 하나만** 남긴다(씨닝). 설계가 쓰는 격자가
1m(지면필터 2m·CSF 천 1.5m)라 0.5m 는 설계보다 촘촘해 결과 표고가 사실상 같고, 30GB 두
1m(다듬기 칸 1m)라 0.5m 는 설계보다 촘촘해 결과 표고가 사실상 같고, 30GB 두
장이 메모리 29GB → 1.4GB 로 내려간다. 임계 아래면 원본 점을 그대로 쓴다 — 작은 자료의
결과는 바뀌지 않는다.
"""
@@ -157,8 +157,8 @@ class _ThinGrid:
버리지 않으므로 **지면 결과는 씨닝 전과 완전히 같다**(2026-09-06 용화 실측: 1m 지면
격자 141,969칸 전부 표고 차이 0). 지면점은 원본의 2~3%뿐이라 남겨도 가볍다.
나머지(수목·구조물·잡음)는 칸마다 최저점만 남긴다 — 분류가 없는 자료에서 CSF·PMF가
지면을 찾을 밑그림으로 충분하다(CSF 천 간격 1.5m > 칸 0.5m).
나머지(수목·구조물·잡음)는 칸마다 최저점만 남긴다 — 분류가 없는 자료에서 점진 TIN이
지면을 찾을 밑그림으로 충분하다(씨앗 간격 10m · 다듬기 칸 1m > 칸 0.5m).
한 청크 안에서 같은 칸이 여러 번 나오면 뒤에 쓴 값이 이겨 최저점이 아니게 된다.
그래서 청크를 (칸, 표고)로 정렬해 **칸마다 첫 점**만 골라 낸 뒤 격자와 견준다.
+3 -15
View File
@@ -287,19 +287,6 @@ async def list_surface_models(
return models
async def clear_confirmed_surface_models(connection: aiomysql.Connection, project_id: UUID) -> None:
"""재분석 시 기존 확정을 해제하여 새 분석 결과의 재확정을 요구한다."""
async with connection.cursor() as cursor:
await cursor.execute(
"""
UPDATE surface_models
SET status = 'COMPLETE'
WHERE project_id = %s AND status = 'CONFIRMED'
""",
(str(project_id),),
)
async def confirm_surface_model(
connection: aiomysql.Connection, project_id: UUID, model_id: int
) -> None:
@@ -384,7 +371,7 @@ async def _upsert_surface_model(
processed_cloud_id: int | None,
crs_epsg: int | None,
) -> int:
"""파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결이 그대로.
"""파일 지문이 같은 옛 행이면 id 를 살려 고쳐 씀 — 화면 주소 · 노선 연결 · 확정이 그대로(763-3).
지문이 다르거나 없으면 옛 행을 지우고 새로 넣는다(새 지표면 = 노선 연결도 끊김).
"""
@@ -397,7 +384,8 @@ async def _upsert_surface_model(
await cursor.execute(
"""
UPDATE surface_models
SET source_file_id = %s, processed_cloud_id = %s, status = 'COMPLETE',
SET source_file_id = %s, processed_cloud_id = %s,
status = IF(status = 'CONFIRMED', 'CONFIRMED', 'COMPLETE'),
crs_epsg = %s, resolution_m = %s, generation_params = %s,
completed_at = CURRENT_TIMESTAMP
WHERE id = %s
+49 -125
View File
@@ -1,7 +1,6 @@
"""B04 지표면 분석 FastAPI 라우터."""
import asyncio
import json
import logging
from pathlib import Path
from typing import Any
@@ -22,9 +21,8 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
project_epsg_from_prj,
)
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview, sample_points
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview
from B04_PreProcess.B04_PreProcess_Repository import (
clear_confirmed_surface_models,
get_input_file,
list_project_point_cloud_inputs,
list_project_point_cloud_paths,
@@ -55,25 +53,21 @@ from B04_PreProcess.B04_PreProcess_Schema import (
SurfaceConfirmedResponse,
SurfaceConfirmRequest,
SurfaceConfirmResponse,
SurfaceGroundStatsResponse,
SurfaceInputFileListResponse,
SurfaceInputFileSummary,
SurfaceModelListResponse,
SurfaceModelSummary,
SurfacePointCloudSampleResponse,
)
from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection
from common_util.common_util_auth import require_system_admin
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import (
confirmed_surface_params,
get_surface_confirmation_params,
surface_confirmation_defaults,
)
from common_util.common_util_workflow_state import (
fail_stage,
start_stage,
update_stage_progress,
terrain_changes,
update_contour_interval_param,
)
from config.config_db import get_db_pool
@@ -89,7 +83,10 @@ router.include_router(status_router)
async def analyze_surface(
project_id: UUID, request: SurfaceAnalyzeRequest
) -> SurfaceAnalyzeResponse | JSONResponse:
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다."""
"""LAS 구조화·지면 필터·지표면 모델 생성을 실행하고 DB에 기록한다 — 보기만(763-3 · 요구 605).
확정 · 단계 상태 · 1 단계 값(`las_selection` 포함)을 안 바꿈 · 미리 셈도 없음. 서피스를 바꾸는
길 = 다른 모델 [확정](전처리 체인 다시)."""
try:
source_filters = request.resolved_filters()
methods = request.resolved_methods()
@@ -97,19 +94,9 @@ async def analyze_surface(
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
pool = get_db_pool()
project_root: Path | None = None
try:
params = {
"input_file_id": request.input_file_id,
"source_filters": source_filters,
"methods": methods,
"force": request.force,
}
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await start_stage(cursor, str(project_id), 1, params)
await clear_confirmed_surface_models(connection, project_id)
await connection.commit()
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
input_file = await get_input_file(connection, project_id, request.input_file_id)
@@ -151,19 +138,23 @@ async def analyze_surface(
analysis_result=result,
source_filters=source_filters,
)
async with connection.cursor() as cursor:
await update_stage_progress(cursor, str(project_id), 1, 100)
await connection.commit()
confirmed = await confirmed_surface_params(connection, str(project_id))
except Exception:
await connection.rollback()
raise
write_surface_progress(
project_root,
100,
"awaiting_confirmation",
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
)
if confirmed is not None:
write_surface_progress(
project_root, 100, "completed", "조합을 지었습니다 — 확정 그대로."
)
else:
write_surface_progress(
project_root,
100,
"awaiting_confirmation",
"WF1 분석이 완료되었습니다. 사용할 모델을 확정하세요.",
)
return SurfaceAnalyzeResponse(
project_id=str(project_id),
@@ -173,16 +164,14 @@ async def analyze_surface(
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except (OSError, ValueError) as exc:
async with pool.acquire() as connection, connection.cursor() as cursor:
await fail_stage(cursor, str(project_id), 1, str(exc))
await connection.commit()
except (OSError, ValueError) as exc: # 보기만 — 단계 상태 그대로 · 진행 파일만 끝(763-3)
if project_root is not None:
write_surface_progress(project_root, 100, "failed", str(exc))
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
except Exception as exc:
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
async with pool.acquire() as connection, connection.cursor() as cursor:
await fail_stage(cursor, str(project_id), 1, str(exc))
await connection.commit()
if project_root is not None:
write_surface_progress(project_root, 100, "failed", str(exc))
return JSONResponse(
status_code=500,
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
@@ -214,7 +203,10 @@ async def confirm_surface(
request: SurfaceConfirmRequest,
_session: dict[str, Any] = Depends(require_system_admin),
) -> SurfaceConfirmResponse | JSONResponse:
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다."""
"""사용자가 선택한 지표면 모델을 확정하고 WF1을 완료한다.
같은 서피스(필터 · 표현 · 평활) 다시 [확정] = 등고선 간격만 · 체인 없음. 다른 서피스 = 업로드와
같은 전처리 체인(B05~B09 초기화 → 자동 설계 · 추천 미리 셈 · 763-3 · 요구 605)."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
@@ -247,24 +239,32 @@ async def confirm_surface(
),
}
)
await confirm_surface_selection(
connection,
project_id,
request.model_id,
selection,
)
current = await confirmed_surface_params(connection, str(project_id))
same = current is not None and not terrain_changes(current, selection)
if same: # 같은 서피스 — 아무것도 안 다시 셈(등고선 간격만 · 763-3)
await update_contour_interval_param(
connection, str(project_id), float(selection["contour_interval_m"])
)
else:
await confirm_surface_selection(
connection,
project_id,
request.model_id,
selection,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
# 재확정 체인(2026-08-04 사용자 확정) — 새 지표면 기준으로 B05·B06을 백그라운드
# 재계산·저장한다. 사용자 저장 입력(제어점·경사 옵션·측점별 설계)은 유지·이월된다.
if same:
return SurfaceConfirmResponse(project_id=str(project_id), model_id=request.model_id)
# 서피스가 바뀜 — 업로드와 같은 전처리 체인 하나(B05~B09 초기화 · 763-3 · 요구 605).
# 응답을 막지 않도록 백그라운드로 돌리고, 체인은 실패를 스스로 격리한다.
from B03_FileInput.B03_FileInput_Service_Chain import run_redesign_chain
from B03_FileInput.B03_FileInput_Service_SurfaceChange import run_surface_change_chain
redesign_task = asyncio.create_task(
run_redesign_chain(project_id, request.model_id, dict(selection)),
name=f"redesign-chain-{project_id}",
run_surface_change_chain(project_id, request.model_id),
name=f"surface-change-chain-{project_id}",
)
redesign_task.add_done_callback(
lambda task: task.exception() # 체인 내부에서 이미 로깅 — 미회수 예외 경고만 방지
@@ -353,51 +353,6 @@ async def get_surface_point_cloud_binary(
)
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
async def get_surface_point_cloud(
project_id: UUID,
filter: str | None = None,
) -> SurfacePointCloudSampleResponse | JSONResponse:
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다(JSON · 화면은 이진 길을 씀)."""
try:
source_path = await _point_cloud_source(project_id, filter)
if isinstance(source_path, JSONResponse):
return source_path
with np.load(source_path) as structured:
xyz = np.asarray(structured["xyz"], dtype=np.float32)
bounds = np.asarray(structured["bounds"], dtype=np.float64)
point_count = (
int(structured["point_count"]) if "point_count" in structured else int(len(xyz))
)
rgb = np.asarray(structured["rgb"]) if "rgb" in structured else None
sample, rgb_sample = sample_points(xyz, rgb)
return SurfacePointCloudSampleResponse(
project_id=str(project_id),
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]),
},
points=sample.astype(float).tolist(),
rgb=rgb_sample.astype(int).tolist() if rgb_sample is not None else None,
)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 포인트클라우드 샘플 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "포인트클라우드 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/confirmed", response_model=SurfaceConfirmedResponse)
async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | JSONResponse:
"""확정 지표면 구성과 지형 가장자리만 반환한다(포인트 배열 없음).
@@ -493,34 +448,3 @@ async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse |
status_code=500,
content={"status": "error", "message": "확정 지표면 정보를 불러오지 못했습니다."},
)
@router.get("/{project_id}/surface/ground-stats", response_model=SurfaceGroundStatsResponse)
async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse:
"""manifest에서 필터별 지면 포인트 통계를 반환한다."""
pool = get_db_pool()
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
manifest_path = project_root / "B04_PreProcess" / "models" / "manifest.json"
if not manifest_path.is_file():
return SurfaceGroundStatsResponse(project_id=str(project_id), filters={})
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
filters = {
key: {
"source_point_count": value.get("source_point_count"),
"methods": value.get("methods", {}),
}
for key, value in manifest.get("source_filters", {}).items()
if isinstance(value, dict)
}
return SurfaceGroundStatsResponse(project_id=str(project_id), filters=filters)
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 지면 통계 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "지면 통계 조회 중 오류가 발생했습니다."},
)
+12 -7
View File
@@ -257,21 +257,26 @@ def _apply_new_defaults(
if definition is None:
continue
options = dict(point.options or {})
given = set(options)
for field in definition.options:
if field.key not in options and field.default not in (None, ""):
options[field.key] = field.default
# 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`)
if options.get("inlet_type") == "집수정":
if "pipe_diameter_mm" not in given and isinstance(options.get("pipe_diameter_mm"), str):
options["pipe_diameter_mm"] = int(_float(options["pipe_diameter_mm"])) # 폼 = 수(623)
# 집수정 = 모양 L(54-39 · `DEFAULT_BASIN_SHAPE`) · 동물이동형 유 · 유입 흙막이 없음(623)
basin = options.get("inlet_type") == "집수정"
if basin:
options.setdefault("inlet_structure", "L")
# 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · 화면 짝 `PIPE_HEIGHT_KEYS`)
options.setdefault("inlet_basin_animal", "true")
for key in [k for k in options if k.startswith("inlet_revet_") and k not in given]:
del options[key]
# 보호공 · 집수정 높이 = 관경 + 0.5(139-1 · `PIPE_HEIGHT_KEYS`) · 유입 흙막이 + 0.7(211-1)
keys = {field.key for field in definition.options}
diameter_m = _float(options.get("pipe_diameter_mm")) / 1000
if diameter_m > 0:
for key in PIPE_HEIGHT_KEYS:
if key in keys:
side = (
"inlet" if key == "inlet_revet_height_m" else ""
) # 유입 흙막이 + 0.7(211-1)
if key in keys and not (basin and key.startswith("inlet_revet_")):
side = "inlet" if key == "inlet_revet_height_m" else ""
options.setdefault(key, pipe_wall_min_height_m(diameter_m, side))
if point.facility == "ford_bridge" and "ford_height_m" not in options:
flow = (flows or {}).get(round(point.chainage_m, 2))
+41 -17
View File
@@ -16,6 +16,7 @@ from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_PreProcess.B04_PreProcess_Engine_Contour import (
CONTOUR_EXTRACTOR_VERSION,
contour_cache_is_stale,
extract_contours,
)
from common_util.common_util_atomic import atomic_write_bytes
@@ -28,19 +29,16 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Contour"])
MODEL_REPRESENTATIONS = {
"meshfree": "meshfree_surfels",
"dtm": "regular_grid",
"tin": "triangular_mesh",
"nurbs": "bspline_surface",
"implicit": "local_rbf_height_field",
}
def _is_contour_cache_current(contour_path: Path, model_path: Path) -> bool:
"""캐시가 현재 추출기 버전이고 기반 모델 npz보다 최신인지 검사한다."""
"""캐시가 현재 추출기 버전이고 모델 · 같은 필터 dtm · 굳힌 격자보다 최신인지 검사한다."""
if contour_cache_is_stale(contour_path, model_path):
return False
try:
if contour_path.stat().st_mtime < model_path.stat().st_mtime:
return False
with contour_path.open("rb") as cache_file:
head = cache_file.read(256).decode("utf-8", errors="ignore")
except OSError:
@@ -78,7 +76,7 @@ async def get_surface_model_contour(
status_code=404,
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
)
model_type, model_file_path = row[0], row[1]
model_file_path = row[1]
project_root = Path(resolve_stored_project_path(stored_path))
if not model_file_path:
@@ -91,21 +89,16 @@ async def get_surface_model_contour(
models_dir = model_path.parent
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
stem = model_path.stem
parts = stem.split("_")
if len(parts) >= 2:
method = parts[0]
filter_key = "_".join(parts[1:])
else:
method = model_type
filter_key = "csf"
if smooth and method in ("dtm", "tin"):
# 파일 이름 = `{표현}_{필터}` · 지운 표현(608 · tin 등)이면 404
method, _, filter_key = stem.partition("_")
if smooth and method == "dtm":
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
contour_model_path = models_dir / f"{stem}_smooth.npz"
representation = "regular_grid" if method == "dtm" else "triangular_mesh"
representation = "regular_grid"
else:
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
contour_model_path = model_path
representation = MODEL_REPRESENTATIONS.get(method)
representation = MODEL_REPRESENTATIONS.get(method) if filter_key else None
contour_path = models_dir / contour_filename
if recalculate or not _is_contour_cache_current(contour_path, contour_model_path):
if not math.isfinite(interval) or interval < 0.5:
@@ -189,3 +182,34 @@ async def get_surface_model_contour(
status_code=500,
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/models/{model_id}/quality")
async def get_surface_model_quality(project_id: UUID, model_id: int) -> JSONResponse:
"""서피스 품질 지표 넷(762-2) — 저장본 지문이 다르면 다시 셈 · 격자 표현 밖이면 null."""
from B04_PreProcess.B04_PreProcess_Engine_Quality import ensure_quality
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT model_file_path FROM surface_models WHERE id = %s AND project_id = %s",
(model_id, str(project_id)),
)
row = await cursor.fetchone()
if not row or not row[0]:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
)
model_path = Path(resolve_stored_project_path(stored_path)) / row[0]
method, _, filter_key = model_path.stem.partition("_")
try:
quality = await asyncio.to_thread(ensure_quality, model_path.parent, filter_key, method)
except Exception:
logger.exception("서피스 품질 지표 실패: project_id=%s, model_id=%s", project_id, model_id)
quality = None
if quality:
quality = {key: value for key, value in quality.items() if key != "key"}
return JSONResponse(content={"status": "success", "quality": quality})
@@ -188,11 +188,10 @@ async def get_surface_model_preview(
models_dir = model_path.parent
stem = model_path.stem
ext = "ply" if model_type == "meshfree" else "glb"
if smooth and model_type in ("dtm", "tin"):
if smooth and model_type == "dtm":
preview_filename = f"{stem}_smooth_preview.glb"
else:
preview_filename = f"{stem}_preview.{ext}"
preview_filename = f"{stem}_preview.glb"
preview_path = models_dir / preview_filename
if not preview_path.is_file():
@@ -204,11 +203,7 @@ async def get_surface_model_preview(
},
)
media_type = "application/octet-stream"
if ext == "glb":
media_type = "model/gltf-binary"
elif ext == "ply":
media_type = "application/ply"
media_type = "model/gltf-binary"
# 브라우저가 이미 같은 파일을 갖고 있으면 304만 돌려준다(새로고침이 빨라진다).
return cached_file_response(request, preview_path, media_type, preview_filename)
+3 -23
View File
@@ -4,14 +4,14 @@ from typing import Any
from pydantic import BaseModel, ConfigDict, Field
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
from config.config_system import (
SURFACE_MODEL_PRECOMPUTE,
SURFACE_MODEL_SOURCE_FILTERS,
)
# classification은 LAS가 지면분류를 싣고 있을 때 엔진이 자동으로 붙이지만,
# 재계산 요청에서 이름으로 지정하는 것도 허용한다.
_ALLOWED_FILTERS = set(SURFACE_MODEL_SOURCE_FILTERS) | {"ransac", "classification"}
# 허용 필터 = 지면 필터 등록부 하나(Ground `_FILTERS`)
_ALLOWED_FILTERS = set(available_filters())
_ALLOWED_METHODS = set(SURFACE_MODEL_PRECOMPUTE)
@@ -100,18 +100,6 @@ class SurfaceInputFileListResponse(BaseModel):
files: list[SurfaceInputFileSummary]
class SurfacePointCloudSampleResponse(BaseModel):
"""B04 3D 미리보기용 포인트클라우드 샘플."""
status: str = "success"
project_id: str
point_count: int
sampled_count: int
bounds: dict[str, float]
points: list[list[float]]
rgb: list[list[int]] | None = None
class SurfaceConfirmedResponse(BaseModel):
"""확정 지표면 요약 — 화면 진입 판정·준비화면·B05가 공통으로 쓰는 단일 출처.
@@ -133,14 +121,6 @@ class SurfaceConfirmedResponse(BaseModel):
route_bounds: dict[str, float] | None = None
class SurfaceGroundStatsResponse(BaseModel):
"""필터별 지면 포인트 통계 응답."""
status: str = "success"
project_id: str
filters: dict[str, dict[str, Any]]
class SurfaceAnalyzeResponse(BaseModel):
"""지표면 분석 실행 결과."""
+117
View File
@@ -0,0 +1,117 @@
/* =============================================================================
* B04_PreProcess_UI_Filter.ts
* B04 지면 필터 드롭다운(고를 갈래 · 이름) + 고른 모델의 서피스 품질 지표 넷(762-2).
* 지표 = GET /api/projects/{id}/surface/models/{model}/quality — 서버가 지을 때 셈해 둔 값
* (튐 넓이 % · 1 m 등고선 꺾임 /km · 땅 점 빈 곳 % · 필터 차 %) · 문턱 판정은 안 함(사용자 협의).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createSelectField } from "@ui/ui_template_elements";
import { createKv } from "@ui/ui_template_kv";
import type { SurfaceModelSummary } from "./B04_PreProcess_Api_Fetch";
// 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가
// 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정).
// `_refine` = 다듬기 판(762) — 바탕 필터 바로 뒤에 둠(이름 찾기는 가장 긴 것이 이김).
export const SOURCE_FILTERS = [
"classification",
"classification_refine",
"ptd",
"ptd_refine",
] as const;
const FILTER_LABELS: Record<string, string> = {
ptd: "점진 TIN",
classification_refine: "업체 분류 + 다듬기",
ptd_refine: "점진 TIN + 다듬기",
};
function optionLabel(value: string): string {
return FILTER_LABELS[value] ?? value.replaceAll("_", " ").toUpperCase();
}
/** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */
export function buildSelectField(
label: string,
values: readonly string[],
defaultValue: string,
): { root: HTMLElement; select: HTMLSelectElement } {
const { root, select } = createSelectField({
label,
options: values.map((value) => ({ value, text: optionLabel(value) })),
value: defaultValue,
size: 36,
});
root.classList.add("b04-surface__field");
return { root, select };
}
interface SurfaceQuality {
spike_pct?: number | null;
spike_count?: number | null;
kinks_per_km?: number | null;
gap_pct?: number | null;
gap_steep_pct?: number | null;
diff_pct?: number | null;
diff_against?: string | null;
filled_m2?: number | null;
filled_pct?: number | null;
}
function fmt(value: number | null | undefined, unit: string): string | null {
return value == null ? null : `${value.toLocaleString()} ${unit}`;
}
function rows(quality: SurfaceQuality | null): ReturnType<typeof createKv> {
const against = quality?.diff_against
?.replace(/^(dtm|implicit)_/, "")
.replace(/(_grid)?\.npz$/, "");
const list = createKv([
{
label: "튐 넓이",
value:
quality?.spike_pct == null
? null
: `${quality.spike_pct} % · ${quality.spike_count ?? 0} 곳`,
},
{ label: "등고선 꺾임", value: fmt(quality?.kinks_per_km, "/km") },
{ label: "땅 점 빈 곳", value: fmt(quality?.gap_pct, "%") },
{ label: "그중 급경사", value: fmt(quality?.gap_steep_pct, "%") },
// 대상 이름은 길어 줄을 깨뜨림 — 값은 % 만 · 대상은 마우스 올림 글
{ label: "필터 차", value: fmt(quality?.diff_pct, "%") },
// 외곽 안 구멍을 둘레 지면에서 이어 메운 자리(766-2)
{
label: "메운 넓이",
value:
quality?.filled_m2 == null
? null
: `${fmt(quality.filled_m2, "m²")} · ${quality.filled_pct ?? 0} %`,
},
]);
if (against) list.title = `필터 차 = ${optionLabel(against)} 모델과 0.5 m 넘게 다른 넓이`;
return list;
}
/** 고른 모델의 품질 지표(넷 + 메운 넓이) — 늦게 온 옛 응답은 버림. */
export function createQualityPanel(): {
root: HTMLElement;
show: (projectId: string, model: SurfaceModelSummary | undefined) => void;
} {
const root = document.createElement("div");
root.append(rows(null));
let ticket = 0;
function show(projectId: string, model: SurfaceModelSummary | undefined): void {
const mine = ++ticket;
root.replaceChildren(rows(null));
if (!model) return;
void fetch(`${API_BASE_URL}/projects/${projectId}/surface/models/${model.id}/quality`, {
credentials: "include",
})
.then((response) => (response.ok ? response.json() : null))
.then((payload: { quality?: SurfaceQuality | null } | null) => {
if (mine === ticket) root.replaceChildren(rows(payload?.quality ?? null));
})
.catch(() => undefined);
}
return { root, show };
}
+10 -28
View File
@@ -40,6 +40,7 @@ import {
type SurfacePointCloudSampleResponse,
type SurfaceStatusResponse,
} from "./B04_PreProcess_Api_Fetch";
import { buildSelectField, createQualityPanel, SOURCE_FILTERS } from "./B04_PreProcess_UI_Filter";
import { buildSurfaceGroup } from "./B04_PreProcess_UI_Group";
import { createSurfaceMapViewer } from "./B04_PreProcess_UI_MapViewer";
import { createSurfaceTerrainViewer } from "./B04_PreProcess_UI_TerrainViewer";
@@ -49,15 +50,12 @@ import "./B04_PreProcess_UI_Style.css";
import "./B04_PreProcess_UI_Style_Map.css";
import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_switch";
// 고를 수 있는 필터. 자동 전처리는 이 중 기본 하나만 만들고, 나머지는 관리자가
// 드롭다운에서 고를 때 그 조합만 계산한다 (2026-09-01 사용자 확정).
const SOURCE_FILTERS = ["classification", "grid_min_z", "csf", "pmf"] as const;
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
const DEFAULT_FILTER = "csf";
const DEFAULT_METHOD = "dtm";
const MODEL_METHODS = ["dtm", "implicit"] as const;
const DEFAULT_FILTER = "ptd";
const DEFAULT_METHOD = "implicit"; // 확정 기본 표현(요구 558) — 확정본이 있으면 그것이 먼저
const ROUTE_STAGE = ROUTES.B05_PROFILE;
// 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다.
const SHEET_METHOD_ORDER = ["tin_sheet", "tin", "biharmonic", "anudem", "multires", "laplace"];
const SHEET_METHOD_ORDER = ["laplace"];
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -67,30 +65,11 @@ function infoRow(label: string, value: unknown): KvRow {
return { label, value: value == null || value === "" ? "-" : String(value) };
}
/** 라벨 한 줄 + 드롭다운. 지표면 분석 컨테이너 안의 항목 하나를 만든다. */
function buildSelectField(
label: string,
values: readonly string[],
defaultValue: string,
): { root: HTMLElement; select: HTMLSelectElement } {
const { root, select } = createSelectField({
label,
options: values.map((value) => ({
value,
text: value.replaceAll("_", " ").toUpperCase(),
})),
value: defaultValue,
size: 36,
});
root.classList.add("b04-surface__field");
return { root, select };
}
function getModelFilter(model: SurfaceModelSummary): string {
const configured = model.generation_params?.source_filter;
if (typeof configured === "string") return configured.toLowerCase();
const path = model.model_file_path?.toLowerCase() ?? "";
return SOURCE_FILTERS.find((filter) => path.includes(filter)) ?? "";
return SOURCE_FILTERS.filter((filter) => path.includes(filter)).pop() ?? "";
}
export async function renderB04Surface(root: HTMLElement): Promise<void> {
@@ -138,6 +117,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
let appliedFilter = DEFAULT_FILTER;
let appliedMethod = DEFAULT_METHOD;
const viewer = createSurfacePointCloudViewer();
const quality = createQualityPanel(); // 762-2 고른 모델의 서피스 품질 지표 넷
const terrainViewer = createSurfaceTerrainViewer();
const mapViewer = createSurfaceMapViewer();
// 도엽등고 3D 서피스 — 전처리에서 함께 생성되는 참고 서피스(LAS 없는 설계의 지형 원천,
@@ -268,6 +248,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
filterGroup.root,
methodGroup.root,
terrainViewer.smoothingField,
quality.root,
]);
// 표시 옵션은 3D 모델 토글과 포인트 슬라이더를 한 칸에 둔다(슬라이더가 맨 아래).
const displayGroup = buildSurfaceGroup(L("B04_Surface_Group_Display"), [
@@ -460,6 +441,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
if (!projectId) return;
terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value);
terrainViewer.render(projectId, models);
quality.show(projectId, findSelectedModel());
confirmButton.disabled = !findSelectedModel();
}
@@ -494,7 +476,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
]);
models = modelResponse.models;
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
// 확정 이력이 없을 때만 개발 기본값(ptd·implicit)으로 둔다.
if (confirmed.model_id) {
if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter;
if (confirmed.method) methodGroup.select.value = confirmed.method;
@@ -1,7 +1,6 @@
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
@@ -75,7 +74,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
minValSpan,
progress,
} = buildTerrainViewerChrome();
let activeFilter = "csf";
let activeFilter = "";
let activeMethod = "dtm";
/** 진행률(0~1, 모르면 null)과 문구를 표시한다. label이 null이면 서클을 감춘다. */
@@ -348,7 +347,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
function syncSmoothingSupport(): void {
const supported = activeMethod === "tin" || activeMethod === "dtm";
const supported = activeMethod === "dtm";
smoothSelect.disabled = !supported;
smoothSelect.value = supported && smoothPreferred ? "on" : "off";
smoothLabel.classList.toggle("is-disabled", !supported);
@@ -372,8 +371,8 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
showProgress(null, "모델 조회 중…");
// 1. Find matching model in list
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
// model_type is DTM / Implicit (we match activeMethod)
// model_file_path contains the activeFilter (e.g. ptd_refine, classification)
const match = currentModelsList.find((m) => {
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
const configuredFilter = m.generation_params?.source_filter;
@@ -391,7 +390,7 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
}
const modelId = match.id;
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothingOn();
const isSmooth = activeMethod === "dtm" && smoothingOn();
currentModelId = modelId;
currentModelSmooth = isSmooth;
const generation = ++loadGeneration;
@@ -410,52 +409,34 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
});
if (generation !== loadGeneration) return;
if (activeMethod === "meshfree") {
const geometry = new PLYLoader().parse(buffer);
geometry.computeBoundingSphere();
const material = new THREE.PointsMaterial({
size: 0.35,
vertexColors: geometry.hasAttribute("color"),
sizeAttenuation: true,
});
const points = new THREE.Points(geometry, material);
points.visible = surfCheck.checked;
terrainMesh = points;
scene.add(points);
fitCamera(points);
showProgress(1, "등고선을 그리는 중…");
await loadSelectedContours(modelId, isSmooth);
showProgress(null, null);
} else {
new GLTFLoader().parse(
buffer,
"",
async (gltf) => {
if (generation !== loadGeneration) {
disposeObject(gltf.scene);
return;
new GLTFLoader().parse(
buffer,
"",
async (gltf) => {
if (generation !== loadGeneration) {
disposeObject(gltf.scene);
return;
}
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.side = THREE.DoubleSide;
child.material.vertexColors = child.geometry.hasAttribute("color");
}
gltf.scene.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.material.side = THREE.DoubleSide;
child.material.vertexColors = child.geometry.hasAttribute("color");
}
});
gltf.scene.visible = surfCheck.checked;
terrainMesh = gltf.scene;
scene.add(gltf.scene);
fitCamera(gltf.scene);
showProgress(1, "등고선을 그리는 중…");
await loadSelectedContours(modelId, isSmooth);
showProgress(null, null);
},
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
}
});
gltf.scene.visible = surfCheck.checked;
terrainMesh = gltf.scene;
scene.add(gltf.scene);
fitCamera(gltf.scene);
showProgress(1, "등고선을 그리는 중…");
await loadSelectedContours(modelId, isSmooth);
showProgress(null, null);
},
() => {
if (generation !== loadGeneration) return;
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
showProgress(null, null);
},
);
} catch (e) {
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
showProgress(null, null);
+4 -2
View File
@@ -6,8 +6,8 @@
* POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial)
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
*
* 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 지형 고르기는 추천 창 안 · [적용]이
* 그 지형을 설계 지형으로(509-5).
* 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 설계 지형 바꿈 = 평면 도구줄 지형 칩
* (`/route/design-terrain` · 626-1 · `_Terrain`).
*
* 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대)
* 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다.
@@ -73,6 +73,8 @@ export interface RoutePlanResponse {
project_id: string;
/** 예상노선(원본) **점 묶음** [[x, y], …] — 사업지 좌표계(m). 폴리라인이 아니다. */
expected: Array<[number, number]>;
/** 고친 예정노선(626-2 · [저장] 때 `expected_route_edit.csv`) — 없으면 빈 목록 · 원본 `expected` 는 그대로. */
expected_edited?: Array<[number, number]>;
/** 계획노선 폴리라인(원호 포함) — 그려 보이는 선. 잡는 대상이 아니다. */
planned: Array<[number, number]>;
/** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */
@@ -81,6 +81,8 @@ export interface PrecomputeProgress {
done: number;
total: number;
mode: RouteInitialLogic | null;
/** 기록은 「도는 중」인데 서버가 다시 켜져 끊김(763-4) — 추천 요청 409 가 이어 셈을 예약. */
interrupted?: boolean;
}
/** 미리 셈 진행 — 못 읽으면 null(진행 표시만 빠짐). */
@@ -213,15 +213,10 @@ def source_fingerprint() -> str:
def stat_marks(paths: list[Path]) -> list[str]:
"""큰 자료(지형 npz · 도엽) 자국 — 이름 · 크기 · 수정시각."""
marks = []
for path in sorted(paths):
try:
info = path.stat()
marks.append(f"{path.name}:{info.st_size}:{info.st_mtime_ns}")
except OSError:
marks.append(f"{path.name}:-")
return marks
"""큰 자료(지형 npz · 도엽) 자국 — 이름 · 내용 지문(763-2 · 시각만 바뀌면 같음)."""
from common_util.common_util_derived_store import file_digest
return [f"{path.name}:{file_digest(path) or '-'}" for path in sorted(paths)]
def fingerprint(parts: dict[str, Any], source: str | None = None) -> str:
+20 -66
View File
@@ -28,7 +28,8 @@
가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수
있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 최소 곡선반지름 · 계류 이격 · 곡선 MC = 일반측점:
공통 제약(follow 밖 모든 갈래 · **넘지 못함** · 측점 맞춤 · 합치기 없음 = 가이드 선 · 요구 611) —
최소 곡선반지름 · 계류 이격:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌(비탈 곧장 오르는 길 막이). 곡선
맞춤 뒤 측점(20 m) 사이 **원지반 기울기는 참고만**(계획선 높이로 푸는 값 · 요구 253-1 · 419) —
못 지킨 곳 수(`over_limit.total`)에 안 듦 · 목록엔 남음. 시종점 높이 차가 상한을 넘으면
@@ -55,29 +56,27 @@ from __future__ import annotations
import hashlib
import json
import math
from dataclasses import replace
from pathlib import Path
from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import (
cross_earthwork,
cross_earthwork_delta,
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import cross_earthwork
from B05_Profile.B05_Profile_Engine_RouteInitial_Follow import (
FollowLand,
merged_follow,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import RouteEarth
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
Pool,
build_pool,
choose,
hard_limits,
settle,
similar,
weighted,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
STATION_M,
_reference,
attach_profile,
cover_shortfall,
@@ -93,9 +92,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
off_station_count,
snap_curves_to_stations,
with_station_misses,
)
from common_util.common_util_route_polyline import build_planned_polyline
__all__ = ["NoRoute", "Terrain", "generate_initial_route"]
@@ -158,6 +156,7 @@ def load_terrain_and_streams(
_load_or_build_cost_surface,
)
from common_util.common_util_crs import resolve_project_crs
from common_util.common_util_derived_store import file_digest
filter_key = str(selection.get("source_filter"))
method = str(selection.get("method") or "dtm")
@@ -165,11 +164,7 @@ def load_terrain_and_streams(
models_dir = project_root / _MODELS_SUBDIR if models_dir is None else Path(models_dir)
stream_path = project_root / "B04_PreProcess" / "processed" / STREAM_FILE
crs = resolve_project_crs(project_root)
try:
stat = stream_path.stat()
stream_stamp = f"{stat.st_mtime_ns}:{stat.st_size}"
except OSError:
stream_stamp = "-"
stream_stamp = file_digest(stream_path) or "-" # 내용 지문(763-2)
signature = "|".join(
(
filter_key,
@@ -354,18 +349,13 @@ def generate_initial_route(
)
)
def fit(outline: Any, guard: Any = None) -> Any:
# R = 일반측점(383-1) — 갈래 후보는 직접 잼(`guard` · 406 · 거리 한도 없음) · 추적은 제약을
# 안 따짐(새 초기 노선 `snapped_initial` 과 같은 잣대 · 거리 한도 그대로).
moved = snap_curves_to_stations(outline, min_radius_m, guard=guard)
return outline if moved is None else moved
def follow_line() -> tuple[Any, dict[str, Any]]:
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv` · 383-1 측점 맞춤)과 같은
# 규칙 · 같은 입력 → 같은 선. 제약은 따지지 않고 넘은 곳만 목록에 뜸.
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
route = (outline, measure(outline, bound=None, follow=True))
return settle(route, fit, measure, bound=None, follow=True)
def follow_line(grid: Any = None) -> tuple[Any, dict[str, Any]]:
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 함수(곡선 최적 · 측점
# 맞춤 · 758-1 `..._Follow.merged_follow`) · 같은 입력 → 같은 선. 넘은 곳만 목록에 뜸.
land = FollowLand(terrain, streams, max_grade, stream_offset, grade_smooth, goal_grade)
land = replace(land, standard=standard, corridor_m=corridor_m)
outline = merged_follow(expected, min_radius_m, land, grid)
return outline, with_station_misses(measure(outline, bound=None, follow=True), outline)
def profiled(route: Any, exact: bool = True) -> None:
if route is not None:
@@ -393,32 +383,6 @@ def generate_initial_route(
design_curves=[curve.as_dict() for curve in line.curves],
)
def route_earth(line: Any) -> float:
# 합치기 멈춤 잣대 = 계획고 지반고 횡단(1차 종단 규칙 · 종단 끔과 같음)
try:
found = cross(line)
except ValueError:
return math.inf
return found["cut"] + found["fill"]
def merge_delta(before: Any, after: Any, window: tuple[float, float]) -> float:
# 바뀐 자리 측점만 — 뒤 측점은 같은 선을 따라 밀린 것뿐(옛 선 끝 s1 → 새 선 s1 + 연장 차)
shift = LineString(after.vertices).length - LineString(before.vertices).length
first = math.ceil(window[0] / STATION_M) * STATION_M
try:
return cross_earthwork_delta(
before.vertices,
after.vertices,
np.arange(first, window[1] + shift + 1e-9, STATION_M),
terrain,
standard,
before_chainages=np.arange(first, window[1] + 1e-9, STATION_M),
design_curves_before=[curve.as_dict() for curve in before.curves],
design_curves_after=[curve.as_dict() for curve in after.curves],
)["earth"]
except ValueError:
return math.inf
def cross_score(route: Any, capped: bool) -> float:
# 후보 줄 세움(419) — 켬 = 상한 누른 계획선(`_reference`) 횡단 · 끔 = 계획고 지반고 횡단
held = pool.scores if pool is not None else {}
@@ -466,16 +430,7 @@ def generate_initial_route(
graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m)
while len(_POOLS) >= POOL_CACHE_SIZE:
_POOLS.pop(next(iter(_POOLS)))
# 합치기 멈춤 토공 = 노선 횡단 + 바뀐 측점 횡단 차이(420 `..._CrossEarth` · 419)
_POOLS[key] = build_pool(
graph,
follow_line(),
max_grade,
min_radius_m,
measure,
fit,
cross=RouteEarth(route_earth, merge_delta),
)
_POOLS[key] = build_pool(graph, follow_line(grid), max_grade, min_radius_m, measure)
pool = _POOLS[key]
bound = pool.bound
if target == "weighted":
@@ -484,8 +439,7 @@ def generate_initial_route(
# 토공 · 공사비 = 횡단 토공으로 줄 세움 · 종단 반영 켬이면 위 후보만 균형 종단(빠른
# 판) · 고른 하나만 아래에서 바로 셈(419)
outline, metrics = choose(target, pool.candidates, with_profile, quick, cross_score)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle` · 맞추기 전 선은 안 냄 · 419)
snapped = (outline, metrics)
# 추천 = 계산 로직 결과 그대로(합치기 · 측점 맞춤 없음 · 요구 611) · 추적만 평면계획선 길
def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]:
# 정밀 종단은 낼 사본에만 — 묶음 캐시 후보는 빠른 판 그대로 둬 다음 고르기가 같은 잣대끼리
@@ -506,7 +460,7 @@ def generate_initial_route(
"metrics": values,
}
shown = finish(snapped)
shown = finish((outline, metrics))
ends = ends_grade(expected, terrain, shown["metrics"]["length_m"])
return {
"mode": mode,
@@ -0,0 +1,196 @@
"""평면계획선 예정노선 맞춤(775 · 요구 615 · 617 · 분석 773 갈래 M) — 곡선 수 그대로 교각점 · R.
`..._Follow.merged_follow` 가 같은 쪽 합치기 뒤 부름(체인 처음 · `/route/plan` · `/route/replan` ·
`/route/follow` · 추천 「예상노선 추적」).
- `fit_to_expected` = 틀(곡선 수) 그대로 교각점 · R 를 예정노선 1 m 점 거리 제곱합 최소로(성긴
자코비안 · 이웃 곡선만) → 가까운 일반측점 고름 → 「이웃 MC 사이 = 측점 사이」 · 「R ≥ 최소 R」
벌점을 1 → 1000 으로 키우며 다시 맞춤 → 4 자리 · R 2 자리 반올림 → 끝 확인(MC 측점 어김 0 ·
R ≥ 최소 R · 곡선 수 그대로) · 못 지나면 None(부르는 쪽이 지금 측점 맞춤으로 물러남 · 스냅 절대).
- `frame_for_count` = 수정 끝 틀 고르기 — 꺾임마다 곡선인 선에서 예정노선 거리가 가장 덜 늘어나는
합침(교각점 지움 · 같은 쪽 둘 → 바깥 직선 교점)을 차례로 해 곡선 수 N · 굽이가 N 보다 적으면
그대로.
"""
from __future__ import annotations
import numpy as np
from scipy.optimize import least_squares
from scipy.spatial import cKDTree
from shapely.geometry import LineString, Point
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
STATION_M,
curve_mc_chainages,
off_station_count,
)
from common_util.common_util_route_polyline import PlannedPolyline, build_planned_polyline
Xy = tuple[float, float]
PENALTY_STEPS = (1.0, 10.0, 100.0, 1000.0)
def _build(points: list[Xy], radii: list[float], min_radius_m: float) -> PlannedPolyline:
n = len(points)
return build_planned_polyline(
points,
min_radius_m=min_radius_m,
simplify=False,
curve_flags=[False] + [True] * (n - 2) + [False],
radii=[None, *radii, None],
)
def _along(coords: np.ndarray) -> np.ndarray:
return np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(coords, axis=0).T))])
def _samples(vertices, step: float) -> np.ndarray:
c = np.asarray(vertices, float)
ch = _along(c)
s = np.arange(0.0, ch[-1] + 1e-9, step)
return np.c_[np.interp(s, ch, c[:, 0]), np.interp(s, ch, c[:, 1])]
def _plain(expected: list[Xy]) -> list[Xy]:
"""곧은 줄 위 덧점 지움 — 조밀화한 예정노선 · 원본이 같은 맞춤(체인 · `/route/follow`)."""
return list(LineString(expected).simplify(1e-6).coords)
def _frame(line: PlannedPolyline, min_radius_m: float) -> tuple[list[Xy], list[float]]:
points, _, radii = _flat(line)
return points, [r if r else min_radius_m for r in radii[1:-1]]
def _fit(points0, radii0, expected, min_radius_m, targets=None, w_mc=0.0, r_pen=5.0, nfev=100):
"""교각점(끝 둘 고정) · R 최소제곱 — targets 주면 이웃 MC 사이 길이 = 측점 사이 벌점."""
P = np.asarray(points0, float)
n = len(P) - 2
E = _samples(expected, 1.0)
sE = np.arange(len(E), dtype=float)
eline = LineString(expected)
s_ip = np.array([eline.project(Point(p)) for p in P])
m, n_t = len(E), (n if targets is not None else 0)
sparsity = np.zeros((m + n + n_t, 3 * n), dtype=bool)
for k in range(1, n + 1):
lo, hi = s_ip[max(0, k - 2)] - 30, s_ip[min(n + 1, k + 2)] + 30
rows = np.where((sE >= lo) & (sE <= hi))[0]
for c in (2 * (k - 1), 2 * (k - 1) + 1, 2 * n + k - 1):
sparsity[rows, c] = True
sparsity[m + max(1, k - 1) - 1 : m + min(n, k + 1), c] = True
if n_t:
sparsity[m + n + max(1, k - 2) - 1 : m + n + min(n, k + 2), c] = True
gaps = np.diff(np.concatenate([[0.0], targets])) if n_t else None
def unpack(x):
pts = [tuple(P[0]), *(tuple(p) for p in x[: 2 * n].reshape(n, 2)), tuple(P[-1])]
return pts, list(x[2 * n :])
def resid(x):
pts, radii = unpack(x)
line = _build(pts, radii, min_radius_m)
d = cKDTree(_samples(line.vertices, 0.25)).query(E)[0]
ok = len(line.curves) == n
built = np.array([c.radius_m for c in line.curves]) if ok else np.zeros(n)
out = [d, r_pen * np.maximum(0.0, min_radius_m + 0.01 - built)]
if n_t:
mc = np.array(curve_mc_chainages(line)) if ok else np.full(n, 1e4)
out.append(w_mc * (np.diff(np.concatenate([[0.0], mc])) - gaps))
return np.concatenate(out)
x0 = np.concatenate([P[1:-1].ravel(), np.maximum(np.asarray(radii0, float), min_radius_m)])
lower = np.concatenate([np.full(2 * n, -np.inf), np.full(n, min_radius_m)])
scale = np.concatenate([np.full(2 * n, 5.0), np.full(n, 10.0)])
found = least_squares(
resid, x0, jac_sparsity=sparsity, bounds=(lower, np.inf), x_scale=scale, max_nfev=nfev
)
return unpack(found.x)
def _targets(line: PlannedPolyline) -> np.ndarray:
"""MC 마다 가까운 일반측점(No.1 이상 · 앞 측점보다 뒤)."""
out, last = [], 0.0
for mc in curve_mc_chainages(line):
last = max(last + STATION_M, round(mc / STATION_M) * STATION_M)
out.append(last)
return np.array(out)
def passes(line: PlannedPolyline, count: int, min_radius_m: float) -> bool:
"""끝 확인 — 곡선 수 그대로 · MC 측점 어김 0 · R ≥ 최소 R."""
return (
len(line.curves) == count
and off_station_count(line) == 0
and all(c.radius_m >= min_radius_m - 1e-6 for c in line.curves)
)
def fit_to_expected(
frame: PlannedPolyline, expected: list[Xy], min_radius_m: float
) -> PlannedPolyline | None:
"""틀 곡선 수 그대로 예정노선에 맞추고 MC 를 일반측점에 놓은 선 · 끝 확인 못 지나면 None."""
count = len(frame.curves)
if count == 0:
return None
expected = _plain(expected)
try:
points, radii = _fit(*_frame(frame, min_radius_m), expected, min_radius_m)
targets = _targets(_build(points, radii, min_radius_m))
for w in PENALTY_STEPS:
points, radii = _fit(
points, radii, expected, min_radius_m, targets, w, max(5.0, w), nfev=60
)
except (ValueError, np.linalg.LinAlgError):
return None
points = [(round(x, 4), round(y, 4)) for x, y in points]
line = _build(points, [round(r, 2) for r in radii], min_radius_m)
return line if passes(line, count, min_radius_m) else None
def _gap(line: PlannedPolyline, tree: cKDTree) -> float:
return float(np.square(tree.query(_samples(line.vertices, 1.0))[0]).sum())
def _crossing(a: Xy, b: Xy, c: Xy, d: Xy) -> Xy | None:
"""a→b 반직선 · d→c 반직선(b · c 넘어) 교점 — 둘 다 앞쪽일 때만."""
u, v = np.subtract(b, a), np.subtract(c, d)
den = u[0] * v[1] - u[1] * v[0]
if abs(den) < 1e-9:
return None
w = np.subtract(d, a)
t, s = (w[0] * v[1] - w[1] * v[0]) / den, (w[0] * u[1] - w[1] * u[0]) / den
if t <= 1.0 or s <= 1.0:
return None
return (float(a[0] + u[0] * t), float(a[1] + u[1] * t))
def frame_for_count(
kinked: PlannedPolyline, expected: list[Xy], min_radius_m: float, count: int
) -> PlannedPolyline:
"""꺾임마다 곡선인 선 → 예정노선 거리가 가장 덜 늘어나는 합침을 차례로 해 곡선 수 `count`."""
tree = cKDTree(_samples(_plain(expected), 1.0))
points, radii = _frame(kinked, min_radius_m)
line = _build(points, radii, min_radius_m)
# ponytail: 한 걸음마다 후보 전부 다시 짬(곡선 30 개 · 수 초) · 느리면 바뀐 곡선 둘레만 잼
while len(points) - 2 > max(count, 0):
best = None
for k in range(1, len(points) - 1):
trials = [(points[:k] + points[k + 1 :], radii[: k - 1] + radii[k:])]
if k + 1 < len(points) - 1:
at = _crossing(points[k - 1], points[k], points[k + 1], points[k + 2])
if at is not None:
r = max(radii[k - 1], radii[k])
trials.append(
(points[:k] + [at] + points[k + 2 :], radii[: k - 1] + [r] + radii[k + 1 :])
)
for pts, rr in trials:
trial = _build(pts, rr, min_radius_m)
if len(trial.curves) != len(pts) - 2:
continue
cost = _gap(trial, tree)
if best is None or cost < best[0]:
best = (cost, pts, rr, trial)
if best is None:
break
_, points, radii, line = best
return line if len(line.curves) == len(points) - 2 else kinked
@@ -0,0 +1,195 @@
"""예정노선 따라가기 곡선 최적(758-1 · 요구 598-1) — 따라간 선에 「토공 최소」와 같은 둘째 단계.
`B05_Profile_Route_Polyline_Write.followed_table`(체인 처음 · `/route/follow` · 받은 노선으로) ·
추천 「예상노선 추적」(`..._RouteInitial.generate_initial_route` `follow_line`)이 같이 부름.
차례(`merged_follow`) = ① 예정노선 폴리라인(꺾임점 · 묶기 · R 맞춤 · 기본 규칙) → 큰 R
(`..._Radius.widen`) → 꺾임만 곡선으로(`curve_every_kink`) → 같은 쪽 이웃 곡선 합치기
(`..._Merge.merge_same_side`) → ② 예정노선 맞춤(775 · `..._Fit` — 곡선 수 그대로 교각점 · R 를
예정노선에 맞추며 MC = 일반측점 · 끝 확인 못 지나면 지금 측점 맞춤 · 수정 끝 `keep_curves` = 수정 전
곡선 수 틀). 큰 R · 합치기 · 측점 맞춤(물러남) 모두 직접 잼
(`Guard` · 지형 · 계류 격자 · 토공 최소 갈래와 같은 판 — 이격도 판 `keeping_offset` 은 합치기 0 ·
측점 맞춤이 더 벗어나 안 씀) · 합치기 멈춤 = 못 지킨 곳 수 · 노선 횡단 토공 +10 %.
지형이 없으면(`FollowLand` None) 부르는 쪽이 옛 길(순수 기하 `snapped_initial`)을 씀.
"""
from __future__ import annotations
import math
from collections import OrderedDict
from dataclasses import dataclass
from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import (
cross_earthwork,
cross_earthwork_delta,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Fit import fit_to_expected, frame_for_count
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import STATION_M, route_metrics
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import hard_limits
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import curve_every_kink, widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CORRIDOR_M,
ROAD_WIDTH_M,
SearchGrid,
Terrain,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snap_curves_to_stations
from common_util.common_util_route_polyline import PlannedPolyline, build_planned_polyline
Xy = tuple[float, float]
# 같은 입력 다시 셈 막기(끌 때 · 체인 · 추천이 같은 선을 거듭 부름) — 최근 몇 벌만
MEMO_SIZE = 8
_MEMO: OrderedDict[tuple, PlannedPolyline] = OrderedDict()
@dataclass(frozen=True)
class FollowLand:
"""따라가기 곡선 최적의 지형 · 제약 — 추천 엔진과 같은 풀이 값(`resolve_inputs` 뒤)."""
terrain: Terrain
streams: list[list[Xy]]
max_grade: float
stream_offset_m: float
grade_smooth_m: float
target_grade: float
standard: dict[str, Any] | None = None
corridor_m: float = CORRIDOR_M
def cross_total(line: PlannedPolyline, terrain: Terrain, standard: dict[str, Any] | None) -> float:
"""합치기 멈춤 잣대 — 계획고 지반고 횡단 토공(1차 종단 규칙 · 종단 끔과 같음) · 못 재면 inf."""
try:
found = cross_earthwork(
line.vertices,
None,
None,
terrain,
standard,
design_curves=[curve.as_dict() for curve in line.curves],
)
except ValueError:
return math.inf
return found["cut"] + found["fill"]
def cross_delta(
before: PlannedPolyline,
after: PlannedPolyline,
window: tuple[float, float],
terrain: Terrain,
standard: dict[str, Any] | None,
) -> float:
"""바뀐 자리 측점만 횡단 토공 차 — 뒤 측점은 같은 선 따라 밀림(옛 끝 s1 → 새 s1 + 연장 차)."""
shift = LineString(after.vertices).length - LineString(before.vertices).length
first = math.ceil(window[0] / STATION_M) * STATION_M
try:
return cross_earthwork_delta(
before.vertices,
after.vertices,
np.arange(first, window[1] + shift + 1e-9, STATION_M),
terrain,
standard,
before_chainages=np.arange(first, window[1] + 1e-9, STATION_M),
design_curves_before=[curve.as_dict() for curve in before.curves],
design_curves_after=[curve.as_dict() for curve in after.curves],
)["earth"]
except ValueError:
return math.inf
def merged_follow(
points: list[Xy],
min_radius_m: float,
land: FollowLand,
grid: SearchGrid | None = None,
keep_curves: int | None = None,
) -> PlannedPolyline:
"""예정노선 점 묶음 → 곡선 최적 · 측점 맞춘 폴리라인(못 맞추면 맞추기 전 선). `grid` = 이미 짠
탐색 격자(같은 예정노선 · 추천 묶음) · 없으면 짬. `keep_curves` = 지킬 곡선 수(수정 끝 = 수정 전
평면계획선 곡선 수) · 없으면 합친 뒤 곡선 수."""
key = (
tuple((float(x), float(y)) for x, y in points),
round(min_radius_m, 6),
land.terrain.signature or id(land.terrain),
land.max_grade,
land.stream_offset_m,
land.grade_smooth_m,
land.target_grade,
land.corridor_m,
repr(land.standard),
len(land.streams),
keep_curves,
)
if key in _MEMO:
_MEMO.move_to_end(key)
return _MEMO[key]
def measure(line: PlannedPolyline) -> dict[str, Any]:
# 추천 「예상노선 추적」 잣대(follow · 못 지킨 곳 = R · 계류 · 측점 · 원지반 기울기 참고)
return hard_limits(
route_metrics(
line.vertices,
points,
land.terrain,
land.streams,
line.curves,
max_grade=land.max_grade,
min_radius_m=min_radius_m,
stream_offset_m=land.stream_offset_m,
road_width_m=ROAD_WIDTH_M,
bound_pct=None,
grade_smooth_m=land.grade_smooth_m,
follow=True,
target_grade=land.target_grade,
)
)
if grid is None:
grid = SearchGrid(
points,
land.terrain,
land.streams,
stream_offset_m=land.stream_offset_m,
grade_smooth_m=land.grade_smooth_m,
target_grade=land.target_grade,
corridor_m=land.corridor_m,
)
guard = Guard(grid, land.max_grade)
outline = build_planned_polyline(points, min_radius_m=min_radius_m)
route = (outline, measure(outline))
larger = widen(outline, points, min_radius_m, guard=guard)
if larger is not outline:
trial = (larger, measure(larger))
if trial[1]["over_limit"]["total"] <= route[1]["over_limit"]["total"]:
route = trial
kinked = curve_every_kink(route[0], min_radius_m)
if kinked is not route[0]:
route = (kinked, measure(kinked))
route = merge_same_side(
route,
guard,
min_radius_m,
measure,
RouteEarth(
lambda line: cross_total(line, land.terrain, land.standard),
lambda before, after, window: cross_delta(
before, after, window, land.terrain, land.standard
),
),
)
frame = route[0]
if keep_curves is not None and keep_curves != len(frame.curves):
frame = frame_for_count(kinked, points, min_radius_m, keep_curves)
found = fit_to_expected(frame, points, min_radius_m)
if found is None: # 끝 확인 못 지남 — 지금 측점 맞춤(스냅 절대)
snapped = snap_curves_to_stations(route[0], min_radius_m, guard=guard)
found = route[0] if snapped is None else snapped
_MEMO[key] = found
while len(_MEMO) > MEMO_SIZE:
_MEMO.popitem(last=False)
return found
@@ -167,6 +167,11 @@ class Guard:
def ok(self, local: list[Xy], reference: list[Xy]) -> bool:
return self.judge(local, reference)[0]
def rise(self, local: list[Xy], reference: list[Xy]) -> float:
"""원지반 기울기가 상한을 넘은 만큼의 늘어남(옛 조각 대비) — 측점 맞춤 덜 나쁜 자리 고르기만
씀(요구 606-2 · 그 밖 잼은 419 그대로)."""
return self._look(local)[0] - self._look(reference)[0]
@staticmethod
def piece(before: Xy, apex: Xy, after: Xy, radius: float) -> list[Xy] | None:
"""곡선 하나가 쓰는 조각 — 앞 직선 가운데 → 원호 → 뒤 직선 가운데(직선 절반 규칙)."""
@@ -12,9 +12,9 @@
· 못 지킨 곳 수(R · 계류 · 측점 — 원지반 기울기는 참고만 · 419 `hard_limits`)가 기준(엄격: 후보
최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. 탐색 링크 상한(비탈 곧장 오르는 길 막이)은
남김.
· R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고
고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움.
꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 측점 맞춤 · 419).
· 추천 = 평면계획선을 긋는 가이드 선(요구 611 · 769-1) — 계산 로직 결과 그대로(경로 탐색 → 곡선
넣기 → 큰 R) · 같은 쪽 곡선 합치기 · 측점 맞춤은 안 함(평면계획선 길 `..._Follow.merged_follow`
몫). 꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 419).
갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면
평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) ·
@@ -38,7 +38,7 @@ from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
@@ -52,7 +52,6 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
NoRoute,
RouteGraph,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import with_station_misses
from common_util.common_util_route_geometry import densify_route
from common_util.common_util_route_polyline import build_planned_polyline
@@ -87,8 +86,6 @@ TRACE_DENSIFY_M = 2.0
Measure = Callable[..., dict[str, Any]]
Route = tuple[Any, dict[str, Any]]
# (선, 직접 잼) → 곡선 MC 를 일반측점에 맞춘 선(못 맞추면 그 선 그대로)
Snap = Callable[[Any, Any], Any]
@dataclass
@@ -96,7 +93,6 @@ class Pool:
"""같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다."""
graph: RouteGraph
snap: Snap
candidates: list[Route] = field(default_factory=list)
bound: float | None = None
limit: int = 0 # 후보로 남길 못 지킨 곳 수 상한
@@ -111,10 +107,8 @@ class Pool:
follow: Route | None = None
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
band_width_m: float = 0.0
# 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
# 큰 R 이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
guard: Guard | None = None
# 합치기 멈춤 토공 = 횡단(노선 + 바뀐 측점 차이 · 420 · 없으면 띠 가늠 · `merge_same_side`)
cross: RouteEarth | None = None
# 후보 줄 세움 횡단 토공 기억(잰 값 id · 종단 반영) — 갈래 사이 다시 안 셈(419)
scores: dict[tuple[int, bool], float] = field(default_factory=dict)
@@ -127,15 +121,6 @@ def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]:
return {**metrics, "over_limit": {**over, "total": over["total"] - over.get("grade", 0)}}
def settle(route: Route, snap: Snap, measure: Measure, **kw: Any) -> Route:
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1)."""
outline, metrics = route
moved = snap(outline)
if moved is not outline:
outline, metrics = moved, measure(moved, **kw)
return outline, with_station_misses(metrics, outline)
def _trace(
pool: Pool, nodes: list[tuple[float, float]], min_radius_m: float, measure: Measure, **kw: Any
) -> Route:
@@ -144,9 +129,9 @@ def _trace(
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반(R · 계류 · 원지반 기울기 뺌)이
# 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시
# 짜는 꼴. 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄).
# 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) → 같은 쪽 이웃 곡선 합치기(406 · `..._Merge`) →
# 측점에 맞춤(`settle` · 383-1). 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접
# 잼(`pool.guard`) · 비중 계산은 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
# 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) — 끝. 같은 쪽 곡선 합치기 · 측점 맞춤은 안 함
# (추천 = 가이드 선 · 요구 611). 큰 R 은 거리 한도 대신 직접 잼(`pool.guard`) · 비중 계산은
# 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
traced = pool.traced
guard = pool.guard
if guard is not None and kw.get("weighted"):
@@ -176,10 +161,7 @@ def _trace(
line = curve_every_kink(chosen[0], min_radius_m)
if line is not chosen[0]:
chosen = (line, measure(line, **kw))
chosen = merge_same_side(
chosen, guard, min_radius_m, lambda line: measure(line, **kw), pool.cross
)
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw)
traced[key] = chosen
return traced[key]
@@ -252,13 +234,11 @@ def build_pool(
max_grade: float,
min_radius_m: float,
measure: Measure,
snap: Snap,
cross: RouteEarth | None = None,
) -> Pool:
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.
`measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`)."""
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), cross=cross)
pool = Pool(graph, follow=follow, guard=Guard(graph.grid, max_grade))
pool.candidates, pool.bound, pool.limit = _candidates(pool, max_grade, min_radius_m, measure)
candidates, bound, limit = pool.candidates, pool.bound, pool.limit
ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]]
@@ -24,7 +24,9 @@ R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마
(`..._Merge.Guard` · 복도 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐 · 원지반 기울기는
참고만 · 419)으로 거름. MC = 일반측점 필수 · 꺾임만(R 없는 교점 · 삼각) 없음(419):
· 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터.
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에.
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에. 덜 나쁜 놓기는 원지반
기울기 늘어남 · 옮긴 거리를 따져 고르고 크게 망치는 놓기는 맨 뒤(다 막힐 때 그중 가장 덜
망치는 것 · 요구 606-2).
· 곡선 끔 없음 — 내각 155° 이상도 곡선 · 꺾임만으로 들어온 자리도 곡선으로 놓아 봄 · 곡선을 못
끼우는 자리(나란함)는 교점 지움 · 이웃과 한 측점을 다투면 교점째 뺌.
· 그래도 못 놓으면 그 곡선은 그 자리(측점 밖 · 마지막 수).
@@ -80,6 +82,10 @@ RADIUS_STEPS = 8
BACKTRACK_LIMIT = 30
# 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로
PER_VARIANT = 3
# 덜 나쁜 놓기(직접 잼 못 지남)가 「크게 망침」 — 원지반 기울기 상한 넘은 만큼이 옛 자리보다 이만큼
# 넘게 늘거나 원래 선에서 이만큼(m) 넘게 옮김 · 다른 놓기가 다 막힐 때만 씀(요구 606-2)
RUIN_RISE = 0.05
RUIN_GAP_M = STATION_M / 2
@dataclass
@@ -316,6 +322,9 @@ class _Snapper:
self.excess = excess
self.guard = guard
self.keep = False
# 놓기 층 — 0 잼 지남 · 1 덜 나쁨 · 2 크게 망침(2 는 없이 다 놓는 길이 없을 때만 · 606-2)
self.tiers: tuple[int, ...] = (0, 1) if guard is not None else (0,)
self.memo: dict[tuple, list[tuple]] = {} # 놓을 자리(`_found`)
self.merged_first = guard is not None # 같은 쪽으로 합친 꼴부터 놓아 봄(406)
def _steeper(self, local: list[Xy]) -> bool:
@@ -338,15 +347,54 @@ class _Snapper:
end_s = state.end_s + math.dist(state.end_xy, at)
return _State(plan, state.k + 1, at, end_s, 0.0, state.last_station, state.placed)
def _placements(self, state: _State, plan: _Plan, soft: bool = False) -> Iterator[_State]:
def _placements(
self, state: _State, plan: _Plan, tier: int = 0
) -> Iterator[tuple[tuple, _State]]:
"""곡선 k 를 남은 측점에 놓은 걸음들 — 가까운 측점 · 지금 R 에 가까운 것부터.
직접 잼(406)이면 잼을 지나는 자리만 · `soft` 면 못 지나는 자리(복도 안)만 — 잣대 나은 순."""
직접 잼(406)이면 `tier` 층 자리만 — 0 잼 지남 · 1 못 지남(복도 안 · 덜 나쁨) · 2 그중 크게
망침(`RUIN_RISE` · `RUIN_GAP_M`) — 잣대 나은 순 · 1 · 2 층은 원지반 기울기 늘어남 · 옮긴
거리도 따짐(요구 606-2 · 419 「기울기 참고만」을 이 자리만 풂)."""
k = state.k
given = 0
for _, rank, local, new_at, new_corner, new_mc, station in sorted(
(item for item in self._found(state, plan) if item[0] == tier),
key=lambda item: item[1],
):
if self.guard is None and (self._far(local) or self._steeper(local)):
continue
placed = plan.copy()
placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3)
yield (
rank,
_State(
placed,
k + 1,
new_corner.arc[-1],
new_mc + new_corner.length / 2,
new_corner.tangent,
station,
state.placed + 1,
),
)
given += 1
if given >= PER_VARIANT:
return
def _found(self, state: _State, plan: _Plan) -> list[tuple]:
"""곡선 k 를 놓을 수 있는 자리 모두 — (층, 순위, 조각, IP, 곡선, MC, 측점) · 층 · 판(망침
다시 셈)마다 다시 풀지 않게 기억."""
k = state.k
memo = (k, *state.end_xy, state.end_s, state.prev_tangent, state.last_station)
memo += (*plan.points[k - 1 : k + 2], plan.radii[k])
if memo in self.memo:
return self.memo[memo]
found: list[tuple] = []
self.memo[memo] = found
before, at, after = plan.points[k - 1], plan.points[k], plan.points[k + 1]
start = _try(state, plan, k, at, plan.radii[k] or self.min_radius)
if start is None:
return
return found
corner, mc = start
floor_r = min(self.min_radius, corner.radius)
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, at, after)) / 2)
@@ -360,7 +408,6 @@ class _Snapper:
max(lowest, STATION_M * math.floor(mc / STATION_M)),
max(lowest, STATION_M * math.ceil(mc / STATION_M)),
}
found: list[tuple[tuple, list[Xy], Xy, _Corner, float, float]] = []
for station in stations:
for radius in radii:
solved = _solve(state, plan, k, station, radius)
@@ -372,35 +419,25 @@ class _Snapper:
middle = ((new_at[0] + after[0]) / 2, (new_at[1] + after[1]) / 2)
local = [state.end_xy, *new_corner.arc, middle]
rank: tuple = (tight, abs(station - mc), abs(radius - corner.radius))
tier = 0
if self.guard is not None:
# 직접 잼(406) — 잣대 가장 나은 자리부터 · 복도 밖은 버림
passed, (out, along, earth) = self.guard.judge(
local, self.reference.window(local)
)
if out or passed == soft:
window = self.reference.window(local)
passed, (out, along, earth) = self.guard.judge(local, window)
if out:
continue
rank = (along, tight, rank[1], earth, rank[2])
found.append((rank, local, new_at, new_corner, new_mc, station))
given = 0
for _, local, new_at, new_corner, new_mc, station in sorted(
found, key=lambda item: item[0]
):
if self.guard is None and (self._far(local) or self._steeper(local)):
continue
placed = plan.copy()
placed.points[k], placed.radii[k] = new_at, round(new_corner.radius, 3)
yield _State(
placed,
k + 1,
new_corner.arc[-1],
new_mc + new_corner.length / 2,
new_corner.tangent,
station,
state.placed + 1,
)
given += 1
if given >= PER_VARIANT:
return
if not passed:
# 덜 나쁜 자리 — 기울기 늘어남 · 옮긴 거리를 순위 · 문턱에(요구 606-2)
rise = self.guard.rise(local, window)
gap = self.reference.gap(local)
ruin = max(rise / RUIN_RISE, gap / RUIN_GAP_M)
tier = 2 if ruin > 1 else 1
rank = (along, round(rise, 2), tight, round(gap), earth, rank[2])
if tier == 2: # 크게 망침 — 가장 덜 망치는 것부터
rank = (round(ruin, 1), *rank)
found.append((tier, rank, local, new_at, new_corner, new_mc, station))
return found
def _options(self, state: _State) -> Iterator[_State]:
"""꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔."""
@@ -440,9 +477,14 @@ class _Snapper:
same += 1
if self.merged_first:
variants = variants[:same][::-1] + variants[same:]
for soft in (False, True) if self.guard is not None else (False,):
for trial in variants:
yield from self._placements(state, trial, soft)
for tier in self.tiers:
if not tier:
for trial in variants:
yield from (placed for _, placed in self._placements(state, trial))
continue
# 덜 나쁨 · 크게 망침 — 꼴(합친 꼴 먼저)을 가로질러 순위대로(606-2)
found = [item for trial in variants for item in self._placements(state, trial, tier)]
yield from (placed for _, placed in sorted(found, key=lambda item: item[0]))
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만(추천 후보는
# 안 함 · 155° 이상도 곡선 · 419)
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
@@ -507,7 +549,7 @@ class _Snapper:
if small == k:
yield self._kink(state, off)
else:
yield from self._placements(state, off)
yield from (placed for _, placed in self._placements(state, off))
yield from self._kept(state, off)
def _kept(self, state: _State, plan: _Plan | None = None) -> Iterator[_State]:
@@ -599,7 +641,12 @@ def snap_curves_to_stations(
def _snap(snapper: _Snapper, min_radius_m: float) -> PlannedPolyline | None:
"""다 놓는 길 → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
state = snapper.run(keep=False) or snapper.run(keep=True)
state = snapper.run(keep=False)
if state is None and snapper.guard is not None:
# 크게 망치는 놓기는 그것 없이 다 놓는 길(이웃 곡선 다른 배치 포함)이 없을 때만(606-2)
snapper.tiers = (0, 1, 2)
state = snapper.run(keep=False)
state = state or snapper.run(keep=True)
if state is None:
return None
snapped = build_planned_polyline(
@@ -40,8 +40,10 @@ from common_util.common_util_time import utc_now_text
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) ·
# 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) ·
# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420)
ALGO_VERSION = 8
# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420) ·
# 9 = 측점 맞춤 덜 나쁜 자리에 원지반 기울기 늘어남 · 옮긴 거리(요구 606-2) ·
# 10 = 추천 4 갈래 합치기 · 측점 맞춤 뺌(가이드 선 · 요구 611)
ALGO_VERSION = 10
# 엔진 소스 지문에 드는 파일(저장소 뿌리 기준) — 추천 결과를 낳는 코드
SOURCE_GLOBS = (
"B05_Profile/B05_Profile_Engine_RouteInitial*.py",
@@ -7,7 +7,7 @@
(화면 묶음 stations · ground 단계와 같은 함수). 파이썬은 머리(schema_version · source ·
coordinate_reference · cad_exchange · options)만 붙임.
DTM 지표면만(격자 이진 = `dtm_grid_file`) — DTM 아닌 지표면(tin · nurbs · implicit · meshfree) ·
DTM · implicit(굳힌 격자 641-1) 지표면만(격자 이진 = `dtm_grid_file`) — 그 밖 ·
Node 못 돌림 = None → 부르는 쪽이 파이썬 `generate_sections`(짝 · 로그 남김).
측점 열쇠(`key`) = 노선 저장 이월 열쇠(`B05_Profile_Route_Save._carry_cross`).
"""
+25 -60
View File
@@ -19,7 +19,12 @@ from B05_Profile.B05_Profile_Engine_Geometry import (
resample_polyline_2d,
single_segment_dijkstra,
)
from common_util.common_util_surface_sampler import check_surface_keys
from common_util.common_util_derived_store import file_digest
from common_util.common_util_surface_sampler import (
DtmGridSampler,
check_surface_keys,
implicit_grid_npz,
)
from config.config_system import (
FOREST_ROAD_MAX_GRADE,
FOREST_ROAD_MIN_CURVE_R_M,
@@ -71,14 +76,12 @@ def _sample_surface_on_grid(
y_coords: np.ndarray,
dtm_z: np.ndarray,
) -> np.ndarray:
"""확정 지표면 모델의 표고를 DTM 격자에 샘플링한다(실패 시 DTM으로 폴백)."""
"""확정 지표면 표고를 DTM 격자에 샘플링 — 파일 없음 · 깨짐은 까닭 그대로 던짐(763-1)."""
check_surface_keys(filter_key, method)
if method == "dtm":
return dtm_z
models_dir = Path(models_dir)
xx, yy = np.meshgrid(x_coords, y_coords)
query = np.column_stack([xx.ravel(), yy.ravel()])
def _finalize(z_flat: np.ndarray) -> np.ndarray:
z = np.asarray(z_flat, dtype=np.float64).reshape(len(y_coords), len(x_coords))
@@ -88,60 +91,20 @@ def _sample_surface_on_grid(
return z
try:
suffix = "_smooth" if smooth else ""
if method == "tin":
from scipy.interpolate import LinearNDInterpolator
path = models_dir / f"tin_{filter_key}{suffix}.npz"
if not path.exists():
path = models_dir / f"tin_{filter_key}.npz"
d = np.load(path)
verts = np.asarray(d["vertices"], dtype=np.float64)
interp = LinearNDInterpolator(verts[:, :2], verts[:, 2])
return _finalize(interp(query))
if method == "nurbs":
from scipy.interpolate import RectBivariateSpline
d = np.load(models_dir / f"nurbs_{filter_key}.npz")
cx = np.asarray(d["control_x"], dtype=np.float64)
cy = np.asarray(d["control_y"], dtype=np.float64)
cz = np.asarray(d["control_z"], dtype=np.float64)
degree = int(d["degree"][0]) if "degree" in d else 3
spline = RectBivariateSpline(
cy, cx, cz, kx=min(degree, len(cy) - 1), ky=min(degree, len(cx) - 1)
)
return _finalize(spline(y_coords, x_coords).ravel())
if method == "implicit":
from scipy.interpolate import RBFInterpolator
d = np.load(models_dir / f"implicit_{filter_key}.npz")
centers = np.asarray(d["centers_xy"], dtype=np.float64)
cz = np.asarray(d["center_z"], dtype=np.float64)
smoothing = float(d["smoothing"][0]) if "smoothing" in d else 0.0
interp = RBFInterpolator(
centers,
cz,
neighbors=min(64, len(centers)),
smoothing=smoothing,
kernel="thin_plate_spline",
)
out = np.empty(len(query), dtype=np.float64)
for s in range(0, len(query), 50_000):
e = min(s + 50_000, len(query))
out[s:e] = interp(query[s:e])
return _finalize(out)
if method == "meshfree":
from scipy.interpolate import griddata
d = np.load(models_dir / f"meshfree_{filter_key}.npz")
pts = np.asarray(d["points"], dtype=np.float64)
z = griddata(pts[:, :2], pts[:, 2], query, method="linear")
return _finalize(z)
except Exception:
return dtm_z
# 굳힌 1 m 격자(641-1) 마디 그대로 — B05 종횡단 표본기와 같은 값 · RBF 다시 안 셈(763-1)
grid = DtmGridSampler.open_cached(implicit_grid_npz(models_dir, filter_key))
ix = np.abs(grid.x[None, :] - np.asarray(x_coords)[:, None]).argmin(axis=1)
iy = np.abs(grid.y[None, :] - np.asarray(y_coords)[:, None]).argmin(axis=1)
off = max(np.abs(grid.x[ix] - x_coords).max(), np.abs(grid.y[iy] - y_coords).max())
if off > 1e-3:
raise ValueError(f"굳힌 격자 축이 DTM 과 다름: implicit_{filter_key}_grid.npz")
return _finalize(grid.z[np.ix_(iy, ix)])
except FileNotFoundError as error:
name = Path(error.filename).name if error.filename else f"{method}_{filter_key}"
raise FileNotFoundError(f"지표면 모델 파일이 없습니다: {name}") from error
except Exception as error: # noqa: BLE001 — 깨진 파일 = 까닭 그대로 409
raise ValueError(f"지표면 모델을 읽지 못함({method}_{filter_key}): {error}") from error
return dtm_z
@@ -157,6 +120,9 @@ def _source_npz_paths(models_dir: Path, filter_key: str, method: str, smooth: bo
if method != "dtm":
candidates.append(models_dir / f"{method}_{filter_key}{suffix}.npz")
candidates.append(models_dir / f"{method}_{filter_key}.npz")
# 비용면이 읽는 굳힌 격자 — 서명 전에 지음(763-1)
if method == "implicit" and candidates[-1].exists():
candidates.append(implicit_grid_npz(models_dir, filter_key))
seen: set[Path] = set()
out: list[Path] = []
for p in candidates:
@@ -167,11 +133,10 @@ def _source_npz_paths(models_dir: Path, filter_key: str, method: str, smooth: bo
def _cost_surface_signature(models_dir: Path, filter_key: str, method: str, smooth: bool) -> str:
"""비용면 재빌드 필요 시 바뀌는 서명(격자해상도 + 소스파일 mtime/size)."""
"""비용면 서명(격자해상도 + 소스파일 이름 · 내용 지문 · 763-2 — 시각만 바뀌면 같음)."""
parts = [f"res={ROUTE_GRID_RES_M}", f"method={method}", f"smooth={smooth}"]
for p in _source_npz_paths(models_dir, filter_key, method, smooth):
st = p.stat()
parts.append(f"{p.name}:{int(st.st_mtime)}:{st.st_size}")
parts.append(f"{p.name}:{file_digest(p)}")
return "|".join(parts)
@@ -146,6 +146,33 @@ def _write_planned_polyline(
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
"""
result, table = polyline_table(
points,
radius_m,
simplify=simplify,
curve_flags=curve_flags,
radii=radii,
station_snap=station_snap,
station_snap_step=station_snap_step,
locks=locks,
arcs=arcs,
)
return _write_table(path, result, table)
def polyline_table(
points: list[tuple[float, float]],
radius_m: float,
*,
simplify: bool = True,
curve_flags: list[bool] | None = None,
radii: list[float | None] | None = None,
station_snap: bool | None = None,
station_snap_step: float | None = None,
locks: list[str | None] | None = None,
arcs: list[float | None] | None = None,
) -> tuple[PlannedPolyline, dict[str, Any]]:
"""`_write_planned_polyline` 의 셈 몫 — (폴리라인, 곡선표) · 파일 안 씀(626-2)."""
edited = curve_flags is not None or radii is not None
made = (
node_polyline(points, radius_m, curve_flags, radii, locks, arcs)
@@ -163,8 +190,6 @@ def _write_planned_polyline(
radii=radii,
)
edits = node_edits(points, curve_flags, radii, locks, arcs) if edited else None
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
table: dict[str, Any] = {
"min_radius_m": round(radius_m, 3),
"curves": [c.as_dict() for c in result.curves],
@@ -175,6 +200,38 @@ def _write_planned_polyline(
table["station_snap_step"] = float(station_snap_step)
if edits is not None:
table["edits"] = edits
return result, table
def followed_table(
points: list[tuple[float, float]],
radius_m: float,
land: Any = None,
keep_curves: int | None = None,
) -> tuple[PlannedPolyline, dict[str, Any]]:
"""예정노선 추적(383-1) — 점 묶음 → 곡선 MC 를 일반측점에 맞춘 폴리라인 + 곡선표(스냅 켬).
체인 초기 폴리라인(`_ensure_planned_initial`) · `/route/follow`(626-2)가 같이 씀 · 못 맞추면
지금 선. `land`(`..._RouteInitial_Follow.FollowLand`) 주면 곡선 최적(큰 R · 같은 쪽 합치기 ·
758-1 · 추천 「예상노선 추적」과 같은 함수) · 없으면 순수 기하.
`keep_curves` = 지킬 곡선 수(775 · 수정 끝 = 수정 전 곡선 수).
"""
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
if land is not None:
from B05_Profile.B05_Profile_Engine_RouteInitial_Follow import merged_follow
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat
fit = _flat(merged_follow(points, radius_m, land, keep_curves=keep_curves))
else:
fit = snapped_initial(points, radius_m)
shape = {"simplify": False, "curve_flags": fit[1], "radii": fit[2]} if fit else {}
return polyline_table(fit[0] if fit else points, radius_m, station_snap=True, **shape)
def _write_table(path: Path, result: PlannedPolyline, table: dict[str, Any]) -> dict:
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return {
"nodes": len(result.nodes),
+2
View File
@@ -75,6 +75,8 @@ class RouteDraftInput(BaseModel):
stationSnap: bool = False # noqa: N815
stationSnapStep: float | None = None # noqa: N815 — 390-5
terrain: Literal["las", "sheet"] | None = None
#: 고친 예정노선(626-2) — 없음 = 그대로 · [] = 지움 · 점 묶음 = `expected_route_edit.csv`
expected: list[tuple[float, float]] | None = None
class RouteSaveRequest(RouteReplanRequest):

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