From 37295fd75f9dfed9ed762d641d08bd840e57efcc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:44:07 +0900 Subject: [PATCH 01/13] =?UTF-8?q?chore(plan):=2081~91=EC=9E=A5=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20=EA=B8=B0=EB=A1=9D=20=EC=98=AE=EA=B9=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- ...26-10-08_plan_81-91_전역_M02_노면공_B06.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/raw/plans/2026-10-08_plan_81-91_전역_M02_노면공_B06.md diff --git a/docs/raw/plans/2026-10-08_plan_81-91_전역_M02_노면공_B06.md b/docs/raw/plans/2026-10-08_plan_81-91_전역_M02_노면공_B06.md new file mode 100644 index 000000000..1ad9d2ea9 --- /dev/null +++ b/docs/raw/plans/2026-10-08_plan_81-91_전역_M02_노면공_B06.md @@ -0,0 +1,153 @@ +# 상시 계획서 + +**남은 작업만 두는 유일한 계획서.** 목록이 비면 프로젝트 최종 완료. + +81~91장 공통 근거 = `docs/raw/요구_1008/` — 요구 원문 `사용자요구_1008.md` · 정함과 창 사이 약속 `분석_정함.md` · 파일:줄 `조사_A~H_*.md`. + +## 81. 전역 글 선택 · 스프레드시트 우클릭 · 알약 일괄 해제 · 단위 Φ (데스크탑 메인) + +사용자 확정(10-8) — 우클릭은 크롬 스프레드시트 참고 · 알약 줄에 한꺼번에 연결 해제 · 면적산출 단위에 파이. +정함(브레인) — 드래그 글 선택 막음(입력칸 · 원문 본문 허용) · 메뉴는 기능 있는 것만(링크 · 댓글 · 보호 안 만듦) · 파이 = Φ 숫자 앞 · 일괄 해제 = 알약 줄 있는 곳 전부(분석_정함 1장). + +- [x] 81-1 B·M 페이지 드래그 글 선택 막음 · 허용 = 입력칸 · 칸 편집기 · 원문 본문(`ui_template` 전역 CSS 한 곳 + 허용 클래스) · 확인 = M02 · B06 화면 끌기 선택 0 · 입력칸 선택 됨 +- [x] 81-2 우클릭 속도 — 상태줄 통계 쓴 범위만 · 알약 점 읽기/쓰기 분리 · 전체 고름 행 머리 배열(`spreadsheet_statusbar.ts` · `spreadsheet_links.ts` · `spreadsheet_menu.ts`) · 확인 = 열 머리 우클릭 전·뒤 ms 수치 100 ms 안 · Ctrl+A 멈춤 없음 + 검증완료(81-1 · 81-2 · 데스크탑 메인 보고 · 브레인 기록) — typecheck · 시험 51 통과 · ORCA 화면 조작 확인 · 커밋 3f64d7c7 · 49a88678 +- [x] 81-3 (sub8 이 맡음) 우클릭 메뉴 — 붙여넣기 · 선택하여 붙여넣기 ▸ · 행/열 위아래 · 좌우 삽입 · 셀 삽입/삭제 ▸ · 크기 · 정렬 · 필터 · 고정 · 드롭다운 · 조건부 서식 · 범위 이름 · 그룹 · 구분선 · 읽기 전용 「복사」(`spreadsheet_menu.ts` · 공용 메뉴 구분선) · 확인 = 항목별 시험 · 크기 예산 시험 + - 검증완료 — test_stage4_menu_items 36 · size 화면 16.4 KB(예산 72) · d_input · typecheck · build:spreadsheet · 화면 M02 면적산출(sub8임시): 칸 · 행 머리 · 열 머리 메뉴 열기 1~8 ms · 행 위/아래 · 열 좌우 · 셀 밀기/당기기 · 행열 삭제 셀 수치 맞음 · 열 B 64→120 px · 맞추기 A 64→47 · 시트 정렬 행째 · 필터 A1:B · 고정 위 칸 0→20 px · 그룹 · 조건부 서식 · 유효성 창 · 범위 이름(같은 이름 거름) · 붙여넣기 · 값만 붙여넣기(localhost) · 메뉴 복사 대상 = 격자 · 미리보기 3곳 「복사」만 · 어두움 구분선 · 하위 펼침 화면 안 · 항목 지움 +- [x] 81-4 (sub2 가 맡음) 알약 줄 [연결 모두 해제] — 물음 한 번 · 되돌리기 한 번(`spreadsheet_links.ts`) · 확인 = 면적산출 연결 n → 0 · 되돌리기 n + 검증완료 — test_spreadsheet_set_link 35 통과(모두 해제 묶음 · 되돌리기 한 번 · 연결 없으면 null) · typecheck · build:spreadsheet / ORCA 5177 면적산출 상세: 연결 3 → [연결 모두 해제] 물음 「알약 연결 3개를 모두 풀까?」 → 취소 3 유지 · 해제 0 · 되돌리기 한 번에 3 · 카드 미리보기에도 단추 · 밝음 · 어두움 +- [x] 81-5 (sub2 가 맡음) 단위 Φ 숫자 앞에 붙임(`spreadsheet_toolbar.ts` · `spreadsheet_text.ts`) · 확인 = 칸 「300」 → 「Φ300」 + 검증완료 — test_spreadsheet_unit_prefix 2 통과(형식 코드 앞 · 뒤 단위) · typecheck / ORCA 5177: 「3」 → 「Φ3」 · 두 번 눌러도 한 번 · 서식으로 = 형식 코드 `"Φ"0` · 편집 중 「300」 → 「Φ300」 · 길이 묶음 끝에 Φ · 낫표 칸 「'300」 → 「'Φ300」(값 Φ300) 시험 추가(unit_prefix 3 · toolbar_unit 15) + +## 82. CAD 공용 탭 · M02 여러 장 도면 (sub6) + +사용자 확정(10-8) — CAD 에서 탭으로 여러 도면 분리 관리 · 구조물 조합 한 도면이 여러 장이면 탭 · 엑셀 → 도면 표 보내기 조심. +정함(브레인) — 탭 = CAD 를 감싸는 공용 탭 줄(CAD 안 상태 그대로) · M02 = 한 파일 `도면`(1장) + `더한장[]` · 장 도번 SD-000010-2 · B07 = 목록에서 연 도면 탭(분석_정함 1 · 2장). + +- [x] 82-1 공용 탭 줄 — 고르기 · [+] 새 장 · 이름 · 지우기 · 끌어 순서 · 바꿀 때 지금 장 받아 두고 싣기(새 `ui_template/cad_host/cad_host_tabs.ts`) · 확인 = 탭 셋 왕복 엔티티 수 그대로 + - 검증완료 — ORCA 5181 시스템 층 sub6임시 · 탭 셋 왕복 7번 엔티티 3·2·1 그대로 · 이름 빈 값 · 같은 이름 막음 · 1장 앞 끌기 막음 · 도번 다시 매김 · 밝음 · 어두움 · test_cad_host_tabs 9 +- [x] 82-2 M02 구조물 도면 여러 장 — 저장 · 읽기 · 복사 · 스냅샷 · 장 도번 · 도각 `{{도면번호}}`(`M02_MasterTemplete_Drawing.ts` · `Store.py` · `Template_Layers.py`) · 확인 = 둘째 장 저장 · 다시 읽기 · 복사본 장 수 + - 검증완료 — 저장 1장 3 · 더한장 [상세 1 · 2장 2] · 다시 읽기 같음 · 카드 「장 3」 · 도각 SD-000007-2 · 장 지우기 저장 더한장 1 · 붙여넣기 복사본 장 2 · test_m02_drawing_sheets 12 +- [x] 82-3 면적 → 도면 표 여러 장 — 놓을 장 = 지금 탭 · T번호 장 전체 · 연결표 `장` · 안 열린 장 갱신 = 서버(`AreaToDrawing.ts` · `M02_Area_Table.py` · `aislo-table-bridge.ts`) · 확인 = 둘째 장 표 값 갱신 수치 + - 검증완료 — 보낸 표를 둘째 탭에 놓음 · 연결표 T1 장 = 둘째 장id · 면적 B1 100→250 저장 뒤 안 열린 둘째 장 표 250 · 500 · 놓기 전 탭 바꿈 = 그 장에 다시 놓기 · test_m02_area_to_drawing 11 +- [x] 82-4 B07 — 목록에서 연 도면이 탭 · 닫기 · 전환(`B07_DesignDetail_UI_Page.ts`) · 확인 = 탭 전환 뒤 미저장 편집 안 잃음 + - 검증완료 — 검증_sub6 종단도(2)에 선 1 · 탭 셋 오가도 그 장만 796 · 미저장 탭 닫기 물음 · 깨끗한 탭은 안 물음 · 다음 = 그 도면 탭 · 마지막 탭 닫기 없음 · 버린 뒤 서버 795 + +## 83. 도면 → 엑셀(면적산출) 한 번 옮기기 (데스크탑 보조) + +사용자 확정(10-8) — 연동 아님 · 엑셀 완료 뒤 다시 도면으로 보내야 싱크 · 불러온 도면 정보가 표가 아닐 수 있음. +정함(브레인) — 면적산출 상세 도면 패널에서 고른 범위 → 새 시트(「도면에서 n」 · 기존 시트 안 덮음) · 표 엔티티 · 선+글 격자 인식 · 글 자리 폴백 · 지금 장만(분석_정함 1 · 2장). + +- [x] 83-1 고른 범위 → 표 자료(칸 글 · 병합 · 폭 · 높이 · 「1,500」 → 숫자)(새 `openwebcad/src/helpers/table-from-drawing.ts`) · 확인 = 견본 셋(표 · 선 표 · 글만) 칸 수치 시험 +- [x] 83-2 CAD [면적산출로 보내기] 명령 · 부모 메시지(새 `openwebcad/src/integration/aislo-table-export.ts`) · 확인 = bridge 시험 +- [x] 83-3 면적산출 새 시트로 넣음 · 화면 고침 표시 → [저장] · 되돌리기 한 번(공개 손잡이 addSheet 를 데스크탑 보조가 만듦) · 확인 = 화면 새 시트 칸 · 병합 수치 +- 검증완료(데스크탑 보조 보고 · 브레인 기록) — 견본 시험 · bridge 5/5 · test_m02_area_from_drawing 5/5 · 화면: 선 표 3×3 · 병합 1 · 열 폭 156/118/118 px · 되돌리기 → 시트 1장 · 탭 숨음 · 임시 항목 [저장] → 병합 B1:C1 · 다시 열면 탭 2장 · 빈 범위 · 글 없는 선 = 안 보냄 · 회전 표 = 글 폴백 · 밝음 · 어두움 · 커밋 d8c75722 · 4e9df0e4 · c7341ef4 · 152d97af + +## 84. 도면 불러오기 오류 · 호 방향 (sub8) + +사용자 확정(10-8) — 엄상준 개인 구조물 조합 중력식사방댐 도면 불러오기 오류 · 작은 호 반전 추정. +정함(브레인) — 저장본은 호 0개(선으로 펴짐) → 원본으로 재현 먼저 · 불러오기 bulge · OCS · 겹 블록 고침 · 이동 때 색 · 층 · 호 대칭 · 내보내기 호 같이(분석_정함 1장). + +- [x] 84-1 재현 — 저장본 사본(자기 시스템 층 「sub8임시」) 열기 오류 글 · 시간 + ⬜ 원본 DXF/DWG(사용자) 다시 불러오기 · 확인 = 재현 기록 + - 검증완료 — 저장본 사본(시스템 층 「sub8임시 중력식사방댐」 · 15.4 MB · 9,319 도형) M02 미리보기 열기 2.56 s · 편집창 열기 · 오류 글 없음 → 저장본으론 재현 안 됨 · 원본 DXF 불러오기 단계 의심(⬜ 원본 받으면 다시) · 항목 지움 · 시스템 층 HEAD 와 같음 +- [x] 84-2 불러오기 — LWPOLYLINE/POLYLINE bulge · OCS(압출 −Z) · TEXT 자리 · 겹 INSERT(`B07_DesignDetail_Engine_Frame_Import.py`) · 확인 = 견본 DXF 시험 넷 + - 검증완료 — 견본 DXF 시험(호 · bulge · 거울 블록 · 겹 블록 · TEXT 정렬점 · 내보내기 시계 호) 6 + dxf_clip · frame_export_table = 16 통과 · 옛 코드는 5 실패 +- [x] 84-3 MOVE/SCALE 색 · 층 유지 · 호 mirror 축 · DXF 내보내기 시계방향 호(`move-tool.ts` · `ArcEntity.ts` · `Frame_Export.py`) · 확인 = CAD 시험 + - 검증완료 — ArcEntity.test.ts 10(축 4 갈래 대칭 · 끝각 0 · 복제) · clone-keeps-look 7 · typecheck · CAD 빌드 · 화면 MOVE 9,107 도형(빨강 · 층 sub8시험 297 포함) 뒤 id · 색 · 층 · 폴리선 자식 그대로 · 새 id 0 +- [x] 84-4 큰 도면(15 MB) 열기 감시 시계(`cad_host.ts`) · 확인 = 사본 열기 시간 수치 · 오류 없음 + - 검증완료 — 15 MB 사본 열기 2.56 s(감시 시계 15 s 안) · 오류 없음 → 시계 고침 불필요 + +## 85. M02 일위대가 — 이름 규격 · 켜쌓기 · 순서 · 원문 다운로드 · 패널 스크롤 (sub3) + +사용자 확정(10-8) — 이름 + 규격 · 「골쌓기 기준」 주문 로직에 켜쌓기 적용율(%) 기본 100 · 세부항목 수량 × % · 화살표 작게 + 끌기 · 원문 제목 행 오른쪽 다운로드 · 오른쪽 패널 따로. +정함(브레인) — 대상 = 표 주문 자동 판정(지금 GC000260 · 261) · 세부항목 전부 · 0 초과 200 이하 · 사본 · 서고 같은 함수 · pdf 통째 · 로그인 사용자 모두 · 원문 패널 M02 길로(분석_정함 1장). + +- [x] 85-1 드롭다운 「이름 규격」(번호 풍선 · 저장 값 그대로 · 직종도)(`M02_Labor_Inputs.py` · `M02_Labor_Values*.ts` · `Labor_Diagram.ts`) · 확인 = 기계 · 집게 · 직종 고르기 글 + - 검증완료 — 화면 [옵션] 기계 6 · 집게 3 선택지 글 = 「굴착기(무한궤도) 0.6㎥」 · 「부착용 집게 0.2㎥」 · 풍선 = 원문번호 · 저장 값 번호 · 직종 「1048」 → 건설기계운전사(시험) · test_m02_labor_values 통과 +- [x] 85-2 켜쌓기 적용율 — `담은로직[].켜쌓기율` · `run_line` 곱 · 옵션 모달 한 줄 · 줄 아래 글(`M02_Labor_Doc.py` · `M02_Item_Prices.py` · `M02_Labor_Ref.py` · `Labor_Input.ts`) · 확인 = GC000260 90% 금액 = 100% × 0.9 · 사본 같음 + - 검증완료 — 화면 GC000260 90% 기준 계 71,515 → 64,364(× 0.9) · 비목 셋 같음 · 250 = 막힘 · 줄 아래 「켜쌓기 90%」 · 저장본 켜쌓기율 90 · 서버 계 64,363.76 · 프로젝트 사본 · 서고 승격 같은 값(test_m02_labor_layer 11) +- [x] 85-3 순서 ▲▼ 작게 + 손잡이 끌기 — 상세 · 카드 · 서고(`Labor_Rows.ts` · `Labor_Detail.ts` · `Labor.css`) · 확인 = 끌기 뒤 저장 순서 + - 검증완료 — 상세 · 카드 ▲▼(첫 줄 ▲ 꺼짐 · 풍선 위로/아래로) · 손잡이 끌기 2째 → 4째 · ▲ 한 번 → 저장본 순서 같음 · 줄 draggable 아님(별칭 칸 그대로) · test_m02_card_shell 통과 +- [x] 85-4 원문 다운로드 단추 · `GET /api/m02/logic-lib/source-pdf`(책 이름만) · 원문 읽기 대리 길(`LogicLab_Source.ts` · `Router_Combos.py`) · 확인 = 일반 사용자 내려받기 바이트 수 + - 검증완료 — 일반 사용자 내려받기 건설 6,996,919 B · 산림 6,129,293 B(%PDF) · 옛 M01 길 403 · 비로그인 401 · 책 밖 400 · 화면 제목 행 오른쪽 맞춤 단추(간격 0) · test_m01_logic_source 통과 +- [x] 85-5 일위대가 · 면적산출 상세 오른쪽 패널 따로 스크롤(`ui_template_side_panels.ts follow:false` · `AreaPanel.ts` · `Labor_Detail.ts` · `UI_Style.css`) · 확인 = 메인 스크롤 때 패널 이동 0 px + - 검증완료 — 메인 스크롤 때 패널 이동 0 px(일위대가 본문 987 px · 면적 5,352 px 굴림) · 패널 높이 = 칸 936 px(바닥 넘침 0) · 원문 줄 고름 때 창 · 본문 이동 0 · 패널 몸 끝까지 굴려도 본문 0 · test_ui_side_panels 통과 + +## 86. M02 구조물 조합 — 저장 후 넘어감 · 이름(1) · 일위대가 합계 행 (sub2) + +사용자 확정(10-8) — 버리고 넘어갈까 물음에 [저장 후 넘어감] · 같은 폴더 같은 이름 = 「이름(1)」로 저장 · 상단 표 일위대가 합계 행. +정함(브레인) — 단추 셋(기본 초점 취소 · M02 만) · 항목 · 폴더 · 붙여넣기 · 복사 생성도 「이름(1)」 꼴 통일 · 합계 = 반영합(없으면 기준합 「(기준)」)(분석_정함 1장). + +- [x] 86-1 3단추 공용 대화창 + M02 자리 전부 · settle 카드 저장 실패 흠(새 `ui_template/ui_template_leave_dialog.ts` · `UI_Main.ts` · `Structure.ts` · `ItemCards.ts`) · 확인 = 도면 · 일위대가 · 면적산출 미리보기 · 상세마다 저장 후 넘어감 · 실패면 머묾 + - 검증완료 — test_m02_leave_dialog(Node 거울) · card_sheet_edit · drawing_card · typecheck / ORCA 5177: 면적 카드 미리보기 · 면적 상세 · 일위대가 상세 · 도면 상세 [저장 후 넘어감] → 서버 값 저장(A1 7 · 55 · 단위 m) · 저장 실패 꾸밈(500) → 머묾 · 고침 표시 유지 · [버리고 넘어감] → 서버 값 그대로(null) · Esc = 취소 · 초점 = 취소 · 옵션 정의 화면 = 2단추 +- [x] 86-2 옮기기 같은 이름 = 「이름(1)」 · 항목 · 폴더 · 여러 개 · `_unique` 한 벌(`Tree.py` · `Item_Paste.py` · `Item_Personal.py` · `TreePanel.ts`) · 확인 = 옮기기 겹침 시험 + 화면 + - 검증완료 — test_m02_tree · tree_paste · treepanel · item_keys · item_copy_complete · pair_copy 54 통과(옮기기 겹침 시험 새로 · 옛 「(2)」 꼴 시험 고침) / ORCA: 같은 이름 옮기기 → 「같음(1)」 · 토스트 「같은 이름이 있어 「같음(1)」 로 저장」 · 키 · 번호 그대로 +- [x] 86-3 상단 표 「일위대가 합계」 읽기 전용 줄 · 카드 [저장] 뒤 다시 읽기(`Store.py structure_numbers` · sheet render) · 확인 = 줄 값 = 일위대가 상세 합계 + - 검증완료 — test_m02_combo_table(합계 · 기준 · 빈 칸) · structure · structure_combo · structure_panel · sheet 계열 / ORCA: 줄 여섯 칸 = labor 문서 계산값(2,658,983 기준 · — · 247,026 기준 · 560,292 · 332,047 · 283,597) 일치 · 카드 [저장] 뒤 numbers 다시 받음 · 어두운 화면 글 · 줄 머리 안 잘림 + +## 87. 노면공 패널 — 시작 · 종료 측점 · 길이 출력 · 확폭 구간 (sub5) + +사용자 확정(10-8) — 세부 옵션은 상세내용 안 · 시작 · 종료 측점 · 전/후 없음 · 길이 = 출력(포장사양 다음) · 확폭 구간 단추(켬 = 확폭 포함 · 끔 = 규격만). +정함(브레인) — 시작 · 종료 = 위치 줄 · 상세내용 = 포장사양 → 길이 → 높이 · 포장사양 = 콘크리트 · 아스콘 · 쇄석 · 높이 기본 0.20 m · 확폭 단추 = 구간마다(분석_정함 1 · 2장 약속). + +- [x] 87-1 시작 · 종료 측점 · 길이 출력 · 기준 · 전/후 · 길이 입력 없앰 · 옛 키 저장 때 뺌(`design_structure_road.ts` · `B05_Profile_UI_Structures_*.ts` · `구조물정의.json` 노면공 줄) · 확인 = B05 · B06 추가 · 수정 · 저장 수치 + - 검증완료 — test_52_10(명세 차례 · 포장사양 셋 · dropped · 정의 옵션) · registry_policy · define · m02_structure_definition · typecheck / ORCA 5180 검증_sub5: B05 · B06 노면공 위치 줄 = 시작 · 종료만(기준 칸 숨김) · 상세 내용 차례 포장사양 → 길이 출력 → 높이 · 3+0~8+0 길이 100 · 6+0~9+0 길이 60 · 높이 기본 0.2 · 저장 꼴 chainage = start · options pave_spec · height_m · widening_off(length · before · after 없음 · 판 20) · 옛 length_m 12 · before_m 4 · after_m 8 항목 [수정] 뒤 세 키 빠짐(thickness_cm 20 남음) · 빈 종료 → 안 들어감 + 붉은 칸 둘 · 시작 > 종료 → 안 들어감 · 길이 「—」 · 어두움 · 밝음 · 시험 항목 삭제 · 저장 되돌림(structures []) · ui_checks 52-10 paveOk · 52-15 b05 · handoff · pill 통과 +- [x] 87-2 M02 집계 길이 = 구간형은 시작~종료 먼저(`M02_Table_Fill.py _length`) · 확인 = 시험 + - 검증완료 — test_m02_table_fill 새 시험(시작 200~245 · 옛 length_m 30 → 45 · 점형 12 → 12 · 빈 값 None) 통과 · 옛 코드에서는 실패 확인 · 같은 파일 옛 흠 셋(rv 기본 높이 열 id 시험)은 내 변경 전에도 실패 + - 검증완료(메인 합친 뒤 시험 넷) — 원인 = 옵션 그룹 · 컨테이너 복사 건이 자동 맞춤에서 본체와 두 번 셈(46-3 · 54-6 · 87-2 아님) · 견본에서 본체만 · rv 열 id 1.5(54-36) · fb 열 2련(65-1) · 미지정 4건(배관 컨테이너 셋) · test_m02_table_fill · template_layers · 52_10 · 87_widening_runs · m02_structure_definition 39 통과 +- [x] 87-3 「확폭 구간」 상자 — 구간마다 단추 · 끈 구간 `options.widening_off`(`B05_Profile_UI_Structures_Form.ts` · `design_structure_pipe_calc.ts` 읽기 손잡이) · 확인 = 곡선 둘 걸친 범위 단추 둘 + - 검증완료 — test_87_widening_runs(구간 묶기 · 글 왕복 · 안 맞는 값 무시) · typecheck / ORCA 검증_sub5: 0+0~13+0 구간 넷 · 3+0~8+0 곡선 둘 = 단추 둘(4+0 ~ 4+5 · 확폭 2 m / 7+0 · 확폭 2 m) · 6+0~9+0 = 7+0 · 9+0(0.16 m) 단추 둘 · 첫 단추 끔 → widening_off 「80-85.05」 · 둘째 끔 → 「80-85.05,140-140」 · 모두 켬 → 키 빠짐 · 저장 파일 확인 · 다시 열면 끔 상태 그대로 · 범위 안 확폭 없음 → 「범위 안 확폭 없음」 · 범위 비면 「시작 · 종료 측점을 넣으면 보임」 · B06 도 같은 단추(아스콘 · 180-180 저장) +- [x] 87-4 포장사양 셋 · 물넘이 겹침 안내 문구 · 시험 고침(test_52_10 · ui_checks 52-10 · 53-1) · 확인 = 시험 + - 검증완료 — 물넘이 겹침 안내 = 「시작·종료 측점을 고쳐 주세요」 · 시험 고침 test_52_10 · ui_checks 52-10 · 52-15 · 53-1(노면공 = 시작 · 종료만 · 카드 23+0 · 목록 · 카드 ok · 알약 1) 통과 · 53-1 남은 bad = 세월교 벽 · outlet.form · ford.count(노면공 밖 · 이번 변경 전부터) + +## 88. 노면공 횡단 형상 (sub1) + +사용자 확정(10-8) — 높이만큼 노면 바닥 기준 형상 · 종류별 채움(흙막이 참고 · 신규 가능) · 범위 여러 측점 · 확폭 켬/끔. B06 끝 줄 「노면공 구조물군의 형상」 = 이 장. +정함(브레인) — 노면선 아래로 두께 · 무늬 셋(포장사양별) · 카드별 얹기 = ① 짝 · 저장 전 범위도 바로 · 토공 면적 영향 없음(분석_정함 1 · 2장 약속). + +- [x] 88-1 카드별 포장 얹기 짝 `section.pavement`(새 `common_util/common_util_structure_pavement.py` ↔ `.ts` · `B06_Section_Router.py` 한 줄 · 초안 `withDraftWalls` 옆) · 확인 = 거울 시험 + 검증완료 — 새 거울 시험(측점 9 · 경계 ±0.02 · 확폭 끔 · 겹침 둘) 같음 · walls 거울 · pavement_and_ford · recompute 11 통과 · typecheck 0 +- [x] 88-2 그리기 — 두께 · 무늬 셋 · 확폭 끔 = 규격 폭(`B06_Section_UI_Cross_Design_Surface.ts` · 해치 재사용) · B07 따라옴 · 확인 = 카드 두께 · 폭 수치 + 검증완료 — 검증_sub1 화면 12장(콘크리트 5 · 아스콘 4 · 쇄석 3 · 440.241 밖) · 두께 0.300/0.100/0.150 m = height_m · 확폭 켬 폭 = 차도폭(5.000 · 4.317 등) · 확폭 끔 280 = 3.000(규격 · 확폭 1.738 뺌) · 무늬 셋 밝음/어두움 · B07 수확 다각형 +1(무늬는 CAD 도면에 안 감 — 패턴 건너뜀) +- [x] 88-3 저장 전 범위 바꾸면 카드 바로 · 확인 = 범위 바꾼 뒤 형상 카드 수 + 검증완료 — 패널 길이 60→20 카드 12→10 · 100 → 13(340 두 겹 0.3+0.1) · 저장 안 하고 B05 다녀옴 · 새로고침 13 유지 · 되돌림 후 0 +- [x] 88-4 B07 CAD 수확이 SVG pattern 채움을 건너뜀 → 무늬(노면공 셋 · 물넘이 빗금)를 선 · 점으로 펴서 도면에 옮김(수확 코드 · 무늬 다각형으로 자름) · 확인 = B07 도면 무늬 엔티티 수 · DXF 내보내기 + 검증완료 — test_cad_pattern_harvest 4건(빗금 10줄 · 점 12 · 돌 · 밖으로 안 나감) · extent 3 통과 · typecheck 0 · 검증_sub1 B07 도면 2·3장 콘크리트 빗금 · 4장 아스콘 점 · 쇄석 돌(원 78) · 9장 물넘이 빗금 · 포장 없는 1장 무늬 0 · DXF 4장 CIRCLE 78 · 선 증가 · 건너뜀 3(종전과 같음) · 밝음/어두움 · 무늬 칸 px→종이 mm(25.4/96) + +## 89. B06 세월교 · 박스암거 — 관 끝 · 배관 선택 · 수평공간 (sub7) + +사용자 확정(10-8) — 흙막이에 관이 튀어나감(흙막이 사면 이용) · 배관 선택 = 좌우 유입 · 유출 구조 뺀 안쪽 · 유입 수평공간 = 바닥에서 수평 연장 뒤 절토 · 유출 수평공간 필요 없음. +정함(브레인) — 관 끝 횡단 + 3D · 유입 수평공간은 흙막이일 때만 · 유출 끝 묻히면 바로 절토(분석_정함 1장). + +- [x] 89-1 관 끝 = 흙막이 전면 사면 따라(`Body_Guard.ts` 240-253 · `Corridor_Structures.ts` 3D) · 확인 = 1:0.3 · Ø1.0 관 윗모서리 벽 밖 0 m + - 검증완료 — test_80 관 윗모서리 ↔ 그 높이 전면 0 m(기울기 있는 벽) · test_73_4 · test_79 · 3D 순서 시험 통과 · 화면(검증_sub7 8+15 Ø1.0 양쪽 흙막이) 관 윗모서리 x = 전면 선 x(175.47 · 427.22 px 일치 · 벽 밖 0 m) · 관 L11m +- [x] 89-2 세월교 배관 선택 = 측벽 · 흙막이 판정면 뺀 안쪽 전부(`Ford.ts`) · 확인 = 관 위 성토 · 벽-관 사이 클릭 = 배관 + - 검증완료 — 화면 8+15 관 위 · 벽-관 사이 클릭 = 「세월교 본체 선택」 → 세월교 상자 강조 · 흙막이 위 클릭 = 흙막이 · typecheck +- [x] 89-3 유입 수평공간 시작 = 본체 바닥(세월교 바닥판 상면 · 박스 저판 상면)(`Culvert_Geom.ts` 597-602 · `Culvert_Extra.ts` 413-418) · 확인 = 수평선 높이 = 본체 바닥 + - 검증완료 — test_79 유입 수평선 시작 = 본체 바닥(박스 831.855 · 세월교 848.844 일치) · test_73_4 · test_80 · test_65 통과 · 화면 세월교 수평선 y = 바닥판 상면 y(252.35 px) · 박스(16+12 임시) 수평선 y = 저판 상면 y(338.49 px) · 수평 1.00 m · 다단 사이 시작(`Culvert_Extra.ts` 413-418)은 관 규칙 그대로 +- [x] 89-4 유출 수평공간 없앰 — 칸 · 기하 · 세트 짝 · 구조물정의 이름 줄(`design_structure_guard.ts` · `Culvert_Tiers.ts` · `Engine_Culvert.py` ↔ `common_util_culvert_sets.ts`) · 확인 = 유출 칸 없음 · 시험 + - 검증완료 — test_65 유출 칸 berm 없음 · test_79 유출 절토 수평 0 m · 유입 1 m(2 m 적으면 2) · 세트 거울 · 합치기 · M02 정의 시험 통과 · 화면 세월교 · 박스 유출 칸 = 수평공간 없음 · 유입만 있음 · [저장] 뒤 파일에 `outlet_revet_berm_m` 없음 · `outlet_revet_berm_m` 은 합치기 목록에 남겨 다음 [저장] 때 지움(목록에서 빼면 옛 값이 남음) + +## 90. B06 유출 단 수 · 도수로 · 돌붙임 (sub7) + +사용자 확정(10-8) — 세월교 · 박스 유출 = 관보호공 유출 옵션과 같음(단 수 · 「흙막이 추가」 사라짐) · 도수로/돌붙임 = 유출 성토사면 따라 · 두께 수기 · 기본 30 cm · 선 오프셋 · 안에 돌. +정함(브레인) — 띠 = 사면 안쪽 · 유출 끝 ~ 원지반 · 배관 · 박스 · 세월교 · 물넘이포장 · 날개벽·없음 유출의 옛 상자는 숨김 · 집계 뺌(분석_정함 1장). + +- [x] 90-1 유출 「추가 흙막이(단)」 공용 · 「+ 흙막이」 없앰 · 집계 단 수로 상자(`Revet_Choice.ts` · `Drainage_Facility.ts` · `Page_Pipe_Options.ts` · `Adjust_Dock.ts` · `M02_Table_Fill_Spec.py`) · 확인 = 단 2 → 횡단 2 · 집계 상자 2 + - 검증완료 — test_54_27 세월교 · 박스 단 2 → 상자 2 · 0 → 0 · 「+ 흙막이」 없음 · 날개벽이면 칸 · 상자 숨김(값 3 남음) · test_65 집계 단 2 → 2건 · 1 → 1건 · 날개벽 → 0건 · test_54_10 통과 · 화면 8+15 단 2 → 흙막이 상자 2 · 횡단 추가 벽 2 · 「+ 흙막이」 없음(박스도 같음) · [저장] 뒤 집계 흙막이단 2 → 흙막이 상자 2건 · test_m02_table_fill 셋 실패는 고치기 전과 같음(무관) +- [x] 90-2 도수로 · 돌붙임 두께 칸 · 띠 + 돌(`design_structure_guard.ts CHUTE_ADD` · `ford_pavement` · 세트 짝 · 오버레이 셋 · `Wall_Hatch.ts` 돌 함수) · 확인 = 띠 두께 0.30 m · B07 따라옴 + - 검증완료 — test_90_chute_band 띠 두께 = 사면 직각 0.30 m · 사면 아래 · 돌 · 윗면 = 사면선 · 세트 거울(세월교 빈 두께 0.3 · 박스 45 cm → 0.45) · test_52 명세 · test_65 · test_b06_culvert_set 통과 · 구조물정의 「chute_add.thickness」 이름 줄 셋 더함 · 옛 apron_* · PITCHING_THICKNESS_M 지움 · 화면 8+15 돌붙임 띠 3조각 모든 꼭짓점 두께 0.300 m · 주인 카드만 · 박스 도수로 띠 2 · B07 덧그림 주인 측점 +69 엔티티 · 연동 측점 0(B07 연동 표시 넘김 고침) · 밝음 · 어두움 · 바꾼 값 · DB 행 · 관 지점 파일 되돌림 + +## 91. B06 원지반 · 암 제약 삭제 · 유출측 복합경사 (sub4) + +사용자 확정(10-8) — 세월교 하단부 원지반 연동 제약 삭제 · 흙막이 · 옹벽 공통 로직의 원지반 · 암 경계 영향 확인 뒤 삭제 · 배수군 유출측 절토사면 복합경사. +정함(브레인) — 세월교 바닥 = 하상 처음값 + 손 조정 칸 · 끝 연결선은 남김 · 그 밖 제약은 91-1 목록 뒤 브레인 판정 · 복합경사 공용 도우미(분석_정함 1장). + +- [x] 91-1 제약 목록 — 조사_G 1 표 「해당」 + 화면 재현(측점 · 구조물 · 바뀐 치수) → 브레인 판정 · 확인 = 목록 보고 + - 검증완료 — 화면 TS(Node) 재현 목록 보고 · 브레인 판정 ① 끊음 ② 끊음(사면 1.0 m) ③ 남김 ④ 끊음(0.01 m) ⑤ 끊음(칸 · 비면 성토) ⑥ 남김 +- [x] 91-2 세월교 바닥 손 조정 칸 · 원지반 바뀌어도 안 따라감 · 흙막이 기본 높이 같이(`Ford_Geom.ts` · `Body_Guard.ts` 83-95 · `Adjust_Dock.ts`) · 확인 = 원지반 바꾼 사본 바닥 높이 그대로 + - 검증완료 — ORCA 검증_sub4 8+13 유출 측벽 고름 → 「유출 구조 (세월교)」 칸 「측벽 높이 (자동) 1.5m」 · + = 1.6m · 초안 heightM 1.6(되돌림) · 원지반 −2 m 사본 손 값 바닥 848.344 · 848.064 그대로(자동은 847.454 로 따라감) · test_91 · test_65 +- [x] 91-3 판정분 지형 의존 끊음(다단 자동 자리 · 설계선 끝 픽셀 판정 등) · 옛 「지형에 막힘」 잔재 지움(`Culvert_Extra.ts` · `Cross_Design.ts`) · 확인 = 줌 · 지형 바꿔도 끝점 · 단 자리 같음 + - 검증완료 — ORCA 66측점 축척 5 · 20 · 80 px/m 설계선 끝점 같음(160: −5.79 / 3.85 m) · 관 4+5 다단 2단 원지반 0 · ±0.5 m 자리 같음(3.818 · 5.561 m) · 기울기 단면유형 넷 같은 값 · C군 바뀐 측점 0(검증_sub4 에 C군 없음) · test_91 +- [x] 91-4 유출측 절토 복합경사 공용 도우미 — 수평공간 뒤 · 박스 끝 · 세월교 측벽 끝 · 집수정 끝 · 절토 쪽 배관 유출 벽 뒤 · `cutLine` 점열 · 3D(`common_util_cross_berm.ts` 외) · 확인 = 암 + 복합경사 측점 무릎 · 소단 수치 + - 검증완료 — ORCA 카드 절토선 11곳 꺾임 = 암 1:0.4 → 토사 1:1.0 · 8+13 사본 소단 = 사면 3.0 m 뒤 폭 0.5 m · 절토 쪽 배관 유출 벽 뒤 절토선 살아남(1:0.4 → 1:1.0) · 3D 실루엣 소단 꼭짓점 받음(6 → 10점) · test_91 · test_79 From 311c43d83ae535c2c1a660aade3f550d8f36ea06 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:45:32 +0900 Subject: [PATCH 02/13] =?UTF-8?q?=EF=BB=BFfeat(B08):=20=EC=88=98=EB=A6=AC?= =?UTF-8?q?=20=ED=91=9C=20=EC=B8=A1=EC=A0=90=EB=A7=88=EB=8B=A4=20B05=20?= =?UTF-8?q?=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EA=B0=92=20=C2=B7=20=ED=91=9C=EC=A4=80=EA=B0=92=20B05=20?= =?UTF-8?q?=EB=B0=B0=EC=88=98=20=EC=84=A4=EC=A0=95=20=ED=95=98=EB=82=98?= =?UTF-8?q?=EB=A1=9C(96-1=20=C2=B7=2096-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 04_detailed_basins + pipe_points → linked(A · L · H · 관경 · 박스 내공 · 월류 폭) · 참고 b05(Qd · T) - 측점 짝 carry_indices(±0.05 m) · 하상경사 · 관 종류 · n 은 안 이음 - 유출계수 · 할증 · 재현기간 · 최소 도달시간 = config DRAINAGE_* import - 시험 test_96_b08_hydro_linked(연결값 · Qd ≈ B05 설계유량) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- B08_Quantity/B08_Quantity_Hydro_Defaults.py | 81 ++++++++++++--- B08_Quantity/B08_Quantity_Hydro_Site.py | 20 +++- B08_Quantity/B08_Quantity_Router_Hydro.py | 18 ++-- resources/tester/test_96_b08_hydro_linked.py | 102 +++++++++++++++++++ 4 files changed, 200 insertions(+), 21 deletions(-) create mode 100644 resources/tester/test_96_b08_hydro_linked.py diff --git a/B08_Quantity/B08_Quantity_Hydro_Defaults.py b/B08_Quantity/B08_Quantity_Hydro_Defaults.py index d226def3f..8a8931ab9 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Defaults.py +++ b/B08_Quantity/B08_Quantity_Hydro_Defaults.py @@ -1,6 +1,7 @@ -"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 한 곳 (PLAN 62-3 · 73-11 · 73-12). +"""B08 4탭 홍수량 계산서 — 기본값 · 선택지 · B05 연결값 한 곳 (PLAN 62-3 · 73-11 · 73-12 · 96). -임의로 정한 값 — 사용자가 나중에 고침. B05/B06 · 배수유역 값과 잇지 않음(자기 기본값만). +표준값(유출계수 · 할증 · 재현기간 · 최소 도달시간)은 B05 배수 설정(config DRAINAGE_*)과 같은 값. +측점 고유 값은 B05 배수유역 저장본의 연결값(linked · 96-1) · 하상경사 · 관 종류 · n 은 안 이음. 근거 후보: docs/raw/조사/2026-10-06_62-1_홍수량_방법.md (별표2 2.0배 · KDS 표 2.7-1 · 실무 견본). """ @@ -8,7 +9,14 @@ from __future__ import annotations from typing import Any +from common_util.common_util_drainage_pipes import carry_indices from config.config_system import FORD_MANNING_N, FORD_SLOPE +from config.config_system_design import ( + DRAINAGE_DESIGN_FLOW_FACTOR, + DRAINAGE_DESIGN_RETURN_PERIOD_YR, + DRAINAGE_RUNOFF_COEFFICIENT, + DRAINAGE_TC_MIN_MINUTES, +) #: 할증(Qd = Q × 할증) — 2.0 별표2 법정 · 1.5 견본(울진 2공구) · 1.2 구기준 FACTORS = (2.0, 1.5, 1.2) @@ -22,15 +30,15 @@ PIPE_KINDS = {"파형강관": 0.024, "흄관": 0.013, "콘크리트관": 0.015} #: 처음 값 = 표준 값만(69-6) · 측점 고유 값(유역면적 · L · H · D · S)은 None(빈칸) #: R24 · 지역은 공사지 자료(B08_Quantity_Hydro_Site) — 자료가 없을 때만 화면 입력 DEFAULTS: dict[str, Any] = { - "factor": 2.0, - "runoff": 0.80, # 유출계수 f — KDS 가파른 산지 + "factor": DRAINAGE_DESIGN_FLOW_FACTOR, + "runoff": DRAINAGE_RUNOFF_COEFFICIENT, # 유출계수 f — KDS 가파른 산지 "area_km2": None, "length_m": None, # 산정에서 하천시점까지 거리 "height_m": None, # 산정에서 하천시점까지 표고차 - "tc_min_minutes": 5.0, # 최소 도달시간(KDS 2.4) + "tc_min_minutes": DRAINAGE_TC_MIN_MINUTES, # 최소 도달시간(KDS 2.4) "r24_mm": None, "region": "", - "return_period": 100, + "return_period": DRAINAGE_DESIGN_RETURN_PERIOD_YR, "pipe_kind": "파형강관", "n": None, # None = 관 종류 값 "diameter_mm": None, @@ -58,15 +66,62 @@ SHEET_DEFAULTS: dict[str, dict[str, Any]] = { } -def station_rows(points: list[Any]) -> list[dict[str, Any]]: - """관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford).""" +def _positive(value: Any) -> float | None: + """양수만 — 글 '1000' 도 숫자로 · 0 · 빈 값 · 글자는 None(빈칸).""" + try: + number = float(value) + except (TypeError, ValueError): + return None + return number if number > 0 else None + + +def _linked(sheet: str, options: dict[str, Any], basin: dict[str, Any] | None) -> dict[str, Any]: + """B05 값 → 계산서 칸(96-1) — 값이 있는 칸만 · A · L · H 는 유역 · 치수는 시설 옵션.""" + values: dict[str, Any] = {} + if basin is not None: + area_m2 = _positive(basin.get("area_m2")) + values["area_km2"] = area_m2 / 1e6 if area_m2 else None + values["length_m"] = _positive(basin.get("flow_length_m")) + values["height_m"] = _positive(basin.get("relief_m")) + if sheet == "pipe": + values["diameter_mm"] = _positive(options.get("pipe_diameter_mm")) + elif sheet == "box": + values["box_width_m"] = _positive(options.get("body_width_m")) + values["box_height_m"] = _positive(options.get("body_height_m")) + else: + values["overflow_width_m"] = _positive(options.get("ford_width_m")) + return {k: v for k, v in values.items() if v is not None} + + +def station_rows( + points: list[Any], basins: list[dict[str, Any]] | None = None +) -> list[dict[str, Any]]: + """관 지점 → 보조 탭 줄(측점 차례) · 장 종류(pipe · box · ford). + + basins(B05 세부유역 속성) 를 주면 측점마다 연결값 `linked` · 비교용 `b05`(Qd · T) — 측점 짝은 + `carry_indices`(±0.05 m) · 안 주면 둘 다 None. + """ + points = sorted(points, key=lambda p: p.chainage_m) + picks = ( + carry_indices([p.chainage_m for p in points], [float(b["chainage_m"]) for b in basins]) + if basins is not None + else [None] * len(points) + ) rows = [] - for p in points: + for p, pick in zip(points, picks): sheet, kind = SHEETS.get(p.facility, ("pipe", "")) - rows.append( - {"chainage_m": p.chainage_m, "facility": p.facility, "kind": kind, "sheet": sheet} - ) - return sorted(rows, key=lambda s: s["chainage_m"]) + row = {"chainage_m": p.chainage_m, "facility": p.facility, "kind": kind, "sheet": sheet} + row["linked"] = row["b05"] = None + if basins is not None: + basin = basins[pick] if pick is not None else None + row["linked"] = _linked(sheet, getattr(p, "options", None) or {}, basin) + if basin is not None: + row["b05"] = { + "design_flow_m3s": basin.get("design_flow_m3s"), + "tc_minutes": basin.get("tc_minutes"), + } + rows.append(row) + return rows def options() -> dict[str, Any]: diff --git a/B08_Quantity/B08_Quantity_Hydro_Site.py b/B08_Quantity/B08_Quantity_Hydro_Site.py index 5ce27adf7..6a6f83234 100644 --- a/B08_Quantity/B08_Quantity_Hydro_Site.py +++ b/B08_Quantity/B08_Quantity_Hydro_Site.py @@ -1,7 +1,8 @@ """B08 4탭 홍수량 계산서 — 공사지 지역 · 확률강우량 · 저장 파일 (PLAN 69-5 · 69-6). 읽기만: 전처리 강우량표(`B04_PreProcess/drainage/rainfall_table.json` · 관측소 확률강우량) -· 행정구역(`B04_PreProcess/processed/행정구역_*_bounds.geojson` · WGS84) · 프로젝트 등록 지역(DB). +· 행정구역(`B04_PreProcess/processed/행정구역_*_bounds.geojson` · WGS84) · 프로젝트 등록 지역(DB) +· B05 세부유역(`B04_PreProcess/drainage/04_detailed_basins.geojson` · B05 [저장] 때 씀 · 96-1). 배수유역 계산 코드는 부르지 않음 — 이미 저장된 파일만 읽음. 쓰기: B08 자기 파일 `B08_Quantity/quantities/hydro_inputs.json` 하나([저장] 때만). @@ -16,7 +17,10 @@ from typing import Any from shapely.geometry import Point, shape -RAINFALL_FILE = Path("B04_PreProcess") / "drainage" / "rainfall_table.json" +from config.config_system_design import DRAINAGE_CACHE_DIRNAME, DRAINAGE_DETAIL_FILENAME + +RAINFALL_FILE = Path("B04_PreProcess") / DRAINAGE_CACHE_DIRNAME / "rainfall_table.json" +BASINS_FILE = Path("B04_PreProcess") / DRAINAGE_CACHE_DIRNAME / DRAINAGE_DETAIL_FILENAME PROCESSED_DIR = Path("B04_PreProcess") / "processed" EMD_FILE = "행정구역_읍면동_bounds.geojson" SGG_FILE = "행정구역_시군구_bounds.geojson" @@ -105,6 +109,18 @@ def site_r24(root: Path, return_period: float) -> float | None: return _r24_table(_read_json(root / RAINFALL_FILE)).get(int(return_period)) +def read_basins(root: Path) -> list[dict[str, Any]]: + """B05 세부유역 속성(kind detail_basin) — 파일이 없으면 빈 목록(연결값은 시설 옵션만).""" + basins = [] + for feature in _features(root / BASINS_FILE): + props = feature.get("properties") or {} + if props.get("kind") == "detail_basin" and isinstance( + props.get("chainage_m"), (int, float) + ): + basins.append(props) + return basins + + def read_saved(root: Path) -> dict[str, dict[str, Any]]: data = _read_json(root / SAVED_FILE) or {} stations = data.get("stations") diff --git a/B08_Quantity/B08_Quantity_Router_Hydro.py b/B08_Quantity/B08_Quantity_Router_Hydro.py index 1c442f995..d51f3294c 100644 --- a/B08_Quantity/B08_Quantity_Router_Hydro.py +++ b/B08_Quantity/B08_Quantity_Router_Hydro.py @@ -1,12 +1,12 @@ """B08 4탭 홍수량 계산서 길 (PLAN 62-2 · 62-3 · 69-5 · 69-6 · 73-11 · 73-12). - GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + 기본값 · 선택지 - + 공사지(지역 · 재현기간별 R24) + 저장한 입력 + GET /api/projects/{id}/quantity/hydro 배수지점 측점(저장된 pipe_points) + B05 연결값 + + 기본값 · 선택지 + 공사지(지역 · R24) + 저장본 POST /api/projects/{id}/quantity/hydro/calc 화면 입력 → 계산서 값(B08_Quantity_Engine_Hydro) PUT /api/projects/{id}/quantity/hydro [저장] — 측점별 입력을 B08 자기 파일에만 씀 -측점 목록만 관 지점 정본에서 읽음 — 관경 · 관 종류 등 B05/B06 값은 잇지 않음(자기 기본값). -B05/B06 · 배수유역 파일에는 쓰지 않음. +측점 목록 · 시설 치수는 관 지점 정본 · A · L · H 는 B05 세부유역 저장본에서 연결값(96-1) — +화면이 빈칸만 채움 · 하상경사 · 관 종류 · n 은 잇지 않음. B05/B06 · 배수유역 파일에는 쓰지 않음. """ from __future__ import annotations @@ -22,7 +22,13 @@ from B02_ProjRegister.B02_ProjRegister_Repository import get_project_by_id from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from B08_Quantity.B08_Quantity_Engine_Hydro import compute from B08_Quantity.B08_Quantity_Hydro_Defaults import FACTORS, PIPE_KINDS, options, station_rows -from B08_Quantity.B08_Quantity_Hydro_Site import read_saved, site_info, site_r24, write_saved +from B08_Quantity.B08_Quantity_Hydro_Site import ( + read_basins, + read_saved, + site_info, + site_r24, + write_saved, +) from common_util.common_util_drainage_pipes import pipe_points_path_in, read_pipe_points_file from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -75,7 +81,7 @@ async def _project_root(project_id: UUID) -> Path: @router.get("/{project_id}/quantity/hydro") async def hydro_sheet(project_id: UUID) -> dict[str, Any]: root = await _project_root(project_id) - stations = station_rows(read_pipe_points_file(pipe_points_path_in(root))) + stations = station_rows(read_pipe_points_file(pipe_points_path_in(root)), read_basins(root)) project = await get_project_by_id(str(project_id)) or {} return { "stations": stations, diff --git a/resources/tester/test_96_b08_hydro_linked.py b/resources/tester/test_96_b08_hydro_linked.py new file mode 100644 index 000000000..ffa10618d --- /dev/null +++ b/resources/tester/test_96_b08_hydro_linked.py @@ -0,0 +1,102 @@ +"""B08 수리 표 ← B05 배수유역 연결값 (PLAN 96-1 · 96-3). + +견본 세부유역 + 관 지점 → 측점마다 linked(A · L · H · 관경 · 박스 내공 · 월류 폭) · +그 값으로 계산한 Qd ≈ B05 설계유량 · 표준값은 B05 배수 설정 하나. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from B08_Quantity.B08_Quantity_Engine_Hydro import compute # noqa: E402 +from B08_Quantity.B08_Quantity_Hydro_Defaults import DEFAULTS, station_rows # noqa: E402 +from B08_Quantity.B08_Quantity_Hydro_Site import BASINS_FILE, read_basins # noqa: E402 +from common_util.common_util_drainage_detail import size_pipe # noqa: E402 +from common_util.common_util_drainage_pipes import PipePoint # noqa: E402 +from common_util.common_util_wamis_station import mononobe_idf # noqa: E402 +from config import config_system_design as cfg # noqa: E402 + +R24 = 231.56 +BASINS = [ + # B05 저장 꼴 — 측점 0.01 반올림 · 면적 0.1 · 표고차 0.01 · 길이 0.1 + {"kind": "detail_basin", "chainage_m": 85.05, "area_m2": 10717.0, "flow_length_m": 163.9, + "relief_m": 120.0}, + {"kind": "detail_basin", "chainage_m": 173.09, "area_m2": 141145.0, "flow_length_m": 603.3, + "relief_m": 320.0}, + {"kind": "detail_basin", "chainage_m": 300.0, "area_m2": 52000.0, "flow_length_m": 410.0, + "relief_m": 0.0}, +] # fmt: skip +for _b in BASINS: + _out = size_pipe( + _b["area_m2"], max(_b["relief_m"], 0.1), _b["flow_length_m"], mononobe_idf(R24) + ) + _b.update(design_flow_m3s=_out["design_flow_m3s"], tc_minutes=_out["tc_minutes"]) + +POINTS = [ + PipePoint(chainage_m=300.004, facility="box_culvert", + options={"body_width_m": 2.0, "body_height_m": 1.5}), + PipePoint(chainage_m=85.052, options={"pipe_diameter_mm": "1000", "pipe_kind": "파형강관"}), + PipePoint(chainage_m=173.09, facility="ford_bridge", options={"ford_width_m": 10}), + PipePoint(chainage_m=500.0, options={"pipe_diameter_mm": 800}), # 유역 짝 없음 +] # fmt: skip + + +def test_측점마다_연결값() -> None: + rows = station_rows(POINTS, BASINS) + assert [r["chainage_m"] for r in rows] == [85.052, 173.09, 300.004, 500.0] + pipe, ford, box, lone = (r["linked"] for r in rows) + assert pipe == { + "area_km2": 0.010717, + "length_m": 163.9, + "height_m": 120.0, + "diameter_mm": 1000.0, + } + assert ford == {"area_km2": 0.141145, "length_m": 603.3, "height_m": 320.0, + "overflow_width_m": 10.0} # fmt: skip + # 표고차 0 은 빈칸(저장 검사 gt 0) · 박스는 내공 + assert box == {"area_km2": 0.052, "length_m": 410.0, "box_width_m": 2.0, "box_height_m": 1.5} + # 유역 짝이 없으면 시설 치수만 · B05 참고값 없음 + assert lone == {"diameter_mm": 800.0} and rows[3]["b05"] is None + assert rows[0]["b05"] == {"design_flow_m3s": BASINS[0]["design_flow_m3s"], "tc_minutes": 5.0} + + +def test_잇지_않는_칸() -> None: + for row in station_rows(POINTS, BASINS): + assert not {"slope_denominator", "pipe_kind", "n"} & set(row["linked"]) + + +def test_basins_를_안_주면_linked_없음() -> None: + rows = station_rows(POINTS) + assert all(r["linked"] is None and r["b05"] is None for r in rows) + + +@pytest.mark.parametrize("index", [0, 1]) +def test_연결값으로_계산한_Qd_는_B05_설계유량(index: int) -> None: + row = station_rows(POINTS, BASINS)[index] + out = compute({**DEFAULTS, **row["linked"], "r24_mm": R24, "sheet": row["sheet"]}) + assert out["design_flow"] == pytest.approx(row["b05"]["design_flow_m3s"], abs=1e-4) + assert out["tc_hr"] * 60 == pytest.approx(row["b05"]["tc_minutes"], abs=0.05) + + +def test_표준값은_B05_배수_설정() -> None: + assert DEFAULTS["runoff"] == cfg.DRAINAGE_RUNOFF_COEFFICIENT == 0.8 + assert DEFAULTS["factor"] == cfg.DRAINAGE_DESIGN_FLOW_FACTOR == 2.0 + assert DEFAULTS["return_period"] == cfg.DRAINAGE_DESIGN_RETURN_PERIOD_YR == 100 + assert DEFAULTS["tc_min_minutes"] == cfg.DRAINAGE_TC_MIN_MINUTES == 5.0 + + +def test_세부유역_파일_읽기(tmp_path: Path) -> None: + assert read_basins(tmp_path) == [] # 분석 저장 전 + features = [{"type": "Feature", "properties": b, "geometry": None} for b in BASINS] + features.append({"type": "Feature", "properties": {"kind": "pipe", "chainage_m": 85.05}}) + path = tmp_path / BASINS_FILE + path.parent.mkdir(parents=True) + path.write_text(json.dumps({"type": "FeatureCollection", "features": features}), "utf-8") + assert [b["chainage_m"] for b in read_basins(tmp_path)] == [85.05, 173.09, 300.0] From 1c1226eb4c1becddf9f881d99a56609dc14bf26b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:45:35 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat(B07):=20=ED=8F=89=EB=A9=B4=EB=8F=84?= =?UTF-8?q?=20IP=20=ED=91=9C=20=EC=A1=B0=EA=B0=81=20=E2=80=94=20BP=20?= =?UTF-8?q?=C2=B7=20IP=20=C2=B7=20EP=20=EC=9E=90=EB=A3=8C=20=C2=B7=20?= =?UTF-8?q?=EC=9E=A5=20=EB=8F=84=EA=B3=BD=20=EC=99=BC=EC=AA=BD=20=EC=95=84?= =?UTF-8?q?=EB=9E=98=20=ED=91=9C=20=C2=B7=20=ED=91=9C=20=EB=B0=91=20?= =?UTF-8?q?=EC=84=A0=20=EC=9E=90=EB=A6=84(94-1=20=C2=B7=2094-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 Engine_Cad_IpTable — IA 곡선 접선 재계산 · TL · CL · SL · 도-분-초 · X 북 · Y 동 · 장마다 그 장 IP - PlanKinds 조각 ip_table 한 줄 · 그리기 갈래 · 노선배치도 기본 체크 - Router_Support_Plan build_pieces 한 갈래 - 시험 — 예시 검산 · 장 경계 · 잘림 · DXF Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP --- .../B07_DesignDetail_Engine_Cad_IpTable.py | 207 ++++++++++++++++++ .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 6 +- .../B07_DesignDetail_Router_Support_Plan.py | 5 + resources/tester/test_b07_plan_kinds.py | 67 ++++++ 4 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py new file mode 100644 index 000000000..8fea242bf --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_IpTable.py @@ -0,0 +1,207 @@ +"""B07 평면도 IP 표 — 곡선표에서 BP · IP · EP 자료를 셈해 장 도곽 왼쪽 아래 표로(PLAN 94장). + +IP 는 곡선이 있는 것만 · 번호는 평면도 「IPn」 과 같은 곡선표 차례다. IA 는 저장 내각(소수 2자리) +대신 곡선 자기 접선(시점→교점 · 교점→종점 방향각)으로 다시 셈하고, TL · CL · SL 은 R 과 그 IA 로 +셈한다. 좌표는 측량 관례대로 X = 북(y) · Y = 동(x). + +표 자리(종이 mm)는 자료를 만들 때 정한다 — 표 밑 등고선 · 노선을 같은 자리로 잘라 내야 해서다. +""" + +import math +from typing import Any + +import numpy as np +from shapely.geometry import LineString, MultiLineString +from shapely.geometry import box as rect +from shapely.ops import unary_union +from shapely.prepared import prep + +from B05_Profile.B05_Profile_Engine_Sections_Core import ( + DESIGN_CURVE_MATCH_TOLERANCE_M, + _project_chainage, +) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad import table_entity +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import _FONT_SIZE, MM + +LAYER_ID = "b07-plan-ip_table" +LINE_COLOR = "#ff4d4d" # 예시 표 — 선 빨강 · 글 파랑 +TEXT_COLOR = "#4d7dff" +_COLUMNS = (7.0, 6.0, 23.0) # [머리 | X·Y | 값] 종이 mm +_ROW_H = 5.0 +_MARGIN = 5.0 # 도곽 왼쪽 아래 모서리에서 띄움 +_LINE_GAP = 3.0 # 한 줄 넘쳐 위로 올린 줄 사이 +_IP_ROWS = 8 # IP · CO(X · Y) · IA · R · TL · CL · SL +_END_ROWS = 3 # BP · EP = IP · CO 만 +_VALUE_ROWS = (("I A", "ia"), ("R", "r"), ("T L", "tl"), ("C L", "cl"), ("S L", "sl")) +_CLIPPED = ("contour", "stream", "road_edge", "fill_outline", "cut_outline", "centerline") + + +def intersection_angle(start: Any, apex: Any, end: Any) -> float: + """교각 IA(도) — 시점→교점 · 교점→종점 방향각 차.""" + first = math.atan2(apex[1] - start[1], apex[0] - start[0]) + second = math.atan2(end[1] - apex[1], end[0] - apex[0]) + return abs(math.degrees((second - first + math.pi) % (2 * math.pi) - math.pi)) + + +def curve_values(radius: float, ia_deg: float) -> dict[str, float]: + """TL = R·tan(IA/2) · CL = R·IA(rad) · SL = R·(sec(IA/2) − 1).""" + half = math.radians(ia_deg) / 2.0 + return { + "tl": radius * math.tan(half), + "cl": radius * 2.0 * half, + "sl": radius * (1.0 / math.cos(half) - 1.0), + } + + +def dms(degrees: float) -> str: + """도-분-초 「94-59-58.38」 — 분 · 초 두 자리 · 초는 0.01″ 로 반올림해 60.00 이면 올림.""" + hundredths = round(degrees * 360000) + whole, rest = divmod(hundredths, 360000) + minutes, seconds = divmod(rest, 6000) + return f"{whole}-{minutes:02d}-{seconds // 100:02d}.{seconds % 100:02d}" + + +def ip_blocks( + curves: list[dict[str, Any]], + route: list[tuple[float, float, float]], + number: int, + total: int, + start_m: float, + end_m: float, +) -> list[dict[str, Any]]: + """그 장의 표 묶음 [BP?, IP…, EP?] — 곡선 가운데 누가거리가 그 장 [시작, 끝) 안인 것만. + + 장 경계 측점은 두 장이 나눠 가지므로 끝은 열어 둔다(마지막 장만 닫음) — 한 IP 는 한 장에만. + 노선에서 5 m 넘게 떨어진 옛 곡선은 버린다(B05 곡선 확폭과 같은 허용). + """ + if len(route) < 2: + return [] + points = np.asarray([(x, y) for _c, x, y in route], dtype=np.float64) + chainage = np.asarray([c for c, _x, _y in route], dtype=np.float64) + blocks: list[dict[str, Any]] = [] + if number == 1: + blocks.append({"name": "BP", "x": route[0][1], "y": route[0][2]}) + for index, curve in enumerate(curves, start=1): + start, apex, end = curve.get("start"), curve.get("apex"), curve.get("end") + radius = curve.get("radius_m") + if not (start and apex and end) or not isinstance(radius, (int, float)): + continue + start_gap, start_c = _project_chainage(points, chainage, tuple(start[:2])) + end_gap, end_c = _project_chainage(points, chainage, tuple(end[:2])) + if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M: + continue + middle = (start_c + end_c) / 2.0 + if (number > 1 and middle < start_m) or (number < total and middle >= end_m): + continue + ia = intersection_angle(start, apex, end) + blocks.append( + { + "name": str(index), + "x": float(apex[0]), + "y": float(apex[1]), + "ia": ia, + "r": float(radius), + **curve_values(float(radius), ia), + } + ) + if number == total: + blocks.append({"name": "EP", "x": route[-1][1], "y": route[-1][2]}) + return blocks + + +def _place(blocks: list[dict[str, Any]], width_mm: float) -> None: + """묶음마다 표 왼쪽 위 자리(종이 mm) `origin` — 왼쪽 아래부터 나란히 · 넘치면 위로 다음 줄.""" + block_w = sum(_COLUMNS) + per_line = max(1, int((width_mm - 2 * _MARGIN) // block_w)) + for order, block in enumerate(blocks): + line, column = divmod(order, per_line) + top = _MARGIN + line * (_IP_ROWS * _ROW_H + _LINE_GAP) + _IP_ROWS * _ROW_H + block["origin"] = [_MARGIN + column * block_w, top] + block["rows"] = _END_ROWS if "ia" not in block else _IP_ROWS + + +def _clip(pieces: dict[str, dict[str, Any]], blocks: list[dict[str, Any]], box: Any) -> None: + """표 칸 밑 선 조각(등고선 · 계류 · 노견선 · 외곽선 · 중심선)을 표 밖으로 잘라 낸다.""" + min_x, min_y = box[0], box[1] + width = sum(_COLUMNS) / MM + area = unary_union( + [ + rect( + min_x + b["origin"][0] / MM, + min_y + b["origin"][1] / MM - b["rows"] * _ROW_H / MM, + min_x + b["origin"][0] / MM + width, + min_y + b["origin"][1] / MM, + ) + for b in blocks + ] + ) + inside = prep(area) + for piece in _CLIPPED: + lines = (pieces.get(piece) or {}).get("lines") + if not lines: + continue + kept: list[list[list[float]]] = [] + for points in lines: + if len(points) < 2 or not inside.intersects(LineString(points)): + kept.append(points) + continue + rest = LineString(points).difference(area) + parts = rest.geoms if isinstance(rest, MultiLineString) else [rest] + kept += [ + [[round(x, 4), round(y, 4)] for x, y in part.coords] + for part in parts + if isinstance(part, LineString) and len(part.coords) >= 2 + ] + pieces[piece]["lines"] = kept + + +def ip_table_piece( + pieces: dict[str, dict[str, Any]], + curves: list[dict[str, Any]], + route: list[tuple[float, float, float]], + box: tuple[float, float, float, float], + chunk: dict[str, Any], + total: int, +) -> dict[str, Any]: + """그 장 IP 표 조각 {blocks} — 자리를 정하고 같은 자리 밑 선 조각을 `pieces` 에서 잘라 낸다.""" + blocks = ip_blocks( + curves, + route, + int(chunk["number"]), + total, + float(chunk["start_m"]), + float(chunk["end_m"]), + ) + _place(blocks, (box[2] - box[0]) * MM) + if blocks: + _clip(pieces, blocks, box) + return {"blocks": blocks} + + +def ip_table_entities(drawing_id: str, data: dict[str, Any]) -> list[dict[str, Any]]: + """묶음마다 표 하나 — 행 IP · CO(X · Y 2행 병합) · IA · R · TL · CL · SL(값 2 · 3열 병합).""" + out = [] + for block in data.get("blocks") or []: + cells: list[list[dict[str, Any] | None]] = [ + [{"text": "I P"}, {"text": block["name"], "colSpan": 2}, None], + [{"text": "C O", "rowSpan": 2}, {"text": "X"}, {"text": f"{block['y']:.5f}"}], + [None, {"text": "Y"}, {"text": f"{block['x']:.5f}"}], + ] + if "ia" in block: + for label, key in _VALUE_ROWS: + value = dms(block[key]) if key == "ia" else f"{block[key]:.5f}" + cells.append([{"text": label}, {"text": value, "colSpan": 2}, None]) + out.append( + table_entity( + f"{drawing_id}:ip_table:{block['name']}", + tuple(block["origin"]), + list(_COLUMNS), + [_ROW_H] * len(cells), + cells, + LAYER_ID, + LINE_COLOR, + _FONT_SIZE, + TEXT_COLOR, + ) + ) + return out diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index cd51607fb..664a009e1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -21,6 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, polyline_entity, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_entities from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_MARGIN, _COMPASS_SIZE, @@ -54,6 +55,7 @@ PIECES: tuple[tuple[str, str, bool], ...] = ( ("centerline", "중심선", False), ("ip", "IP", False), ("radius", "R", False), + ("ip_table", "IP 표", False), ("station", "측점 NO", False), ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), @@ -73,7 +75,7 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( { "id": "route", "name": "노선배치도", - "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints"], + "pieces": [p for p in _TERRAIN if p != "radius"] + ["station", "endpoints", "ip_table"], }, { "id": "layout", @@ -411,6 +413,8 @@ def _piece_entities( elif piece == "radius": for index, item in enumerate(data.get("items") or []): text(f"{index}", item["label"], paper(item["at"]), _LABEL_FONT) + elif piece == "ip_table": + out.extend(ip_table_entities(drawing_id, data)) elif piece == "station": for index, item in enumerate(data.get("items") or []): cx, cy = paper((item["x"], item["y"])) diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 1274d78e1..7453933c7 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -24,6 +24,7 @@ from B05_Profile.B05_Profile_Engine_Sections import ( from B05_Profile.B05_Profile_Structures_Repository import load_structures from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args from B07_DesignDetail.B07_DesignDetail_Engine_Cad import infer_station_interval, station_no_label +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ip_table_piece from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _structure_label, plan_area_mm, @@ -411,6 +412,10 @@ def build_pieces( pieces["lidar"] = {"image": image, "box": list(image_box)} except (FileNotFoundError, ValueError, OSError): pieces["lidar"] = {"image": None, "box": None} + if "ip_table" in wanted: + pieces["ip_table"] = ip_table_piece( + pieces, _load_design_curves(project_root), route, box, chunk, total + ) folder = _design_root(project_root) / "plan_pieces" / str(number) for piece, data in pieces.items(): diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index a3cc127a6..ec00730f5 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -6,21 +6,30 @@ ④ 고른 조각만 도면 층으로 얹힘 ⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 · 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 +⑥ IP 표(94장) — 예시 검산 · 도-분-초 올림 · 장마다 그 장 IP 한 번 · 표 밑 선 자름 · 층 """ import json import math from pathlib import Path +import ezdxf import pytest from B06_Section.B06_Section_Engine_Design import compute_cross_design +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_IpTable import ( + curve_values, + dms, + ip_blocks, + ip_table_piece, +) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( DEFAULT_KINDS, build_plan_kind_drawing, layer_id, slope_pieces, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Frame_Export import drawing_to_dxf from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _read_manifest, _write_manifest from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import ( _kinds_dir, @@ -230,3 +239,61 @@ def test_drainage_symbol_piece() -> None: mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")] assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2 assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine) + + +def test_ip_table_example_values_and_dms() -> None: + ia = 94 + 59 / 60 + 58.38 / 3600 # 예시 그림 IP1 + values = curve_values(15.0, ia) + assert dms(ia) == "94-59-58.38" + assert [f"{values[k]:.5f}" for k in ("tl", "cl", "sl")] == ["16.36950", "24.87082", "7.20271"] + assert dms(1 + 59 / 60 + 59.9999 / 3600) == "2-00-00.00" # 초 60.00 올림 + assert dms(5 + 3 / 60 + 7.2 / 3600) == "5-03-07.20" + + +# ㄱ자 노선 — (0,0)→(100,0)→(100,100) · 모서리 R 15 곡선(가운데 누가거리 100) · 앞에 노선 밖 옛 곡선 +_ROUTE = [(0.0, 0.0, 0.0), (85.0, 85.0, 0.0), (115.0, 100.0, 15.0), (200.0, 100.0, 100.0)] +_CURVES = [ + {"apex": [500.0, 500.0], "start": [490.0, 500.0], "end": [500.0, 510.0], "radius_m": 10.0}, + {"apex": [100.0, 0.0], "start": [85.0, 0.0], "end": [100.0, 15.0], "radius_m": 15.0}, +] + + +def test_ip_blocks_one_sheet_each_and_ends() -> None: + first = ip_blocks(_CURVES, _ROUTE, 1, 2, 0.0, 100.0) + second = ip_blocks(_CURVES, _ROUTE, 2, 2, 100.0, 200.0) + assert [b["name"] for b in first] == ["BP"] # 경계 IP 는 뒤 장에만 + assert [b["name"] for b in second] == ["2", "EP"] # 번호 = 곡선표 차례(옛 곡선은 빠짐) + ip = second[0] + assert dms(ip["ia"]) == "90-00-00.00" and ip["tl"] == pytest.approx(15.0) + assert [b["name"] for b in ip_blocks(_CURVES, _ROUTE, 1, 1, 0.0, 200.0)] == ["BP", "2", "EP"] + + +def test_ip_table_drawing_clips_lines_under_table(tmp_path: Path) -> None: + box = (-20.0, -20.0, 300.0, 200.0) + contour = [[-20.0, 0.0], [300.0, 0.0]] # 표(왼쪽 아래 · 높이 48 m) 밑을 지나는 등고선 + pieces = { + "contour": {"lines": [contour]}, + "centerline": {"lines": [[[0.0, 100.0], [9.0, 100.0]]]}, + } + pieces["ip_table"] = ip_table_piece( + pieces, _CURVES, _ROUTE, box, {"number": 1, "start_m": 0.0, "end_m": 200.0}, 1 + ) + # IP 표 칸(x 29.2 ~ 72.4 · y -14 ~ 34)만 빠짐 · BP · EP 칸(3행)은 y 0 위라 안 자름 + assert pieces["contour"]["lines"] == [[[-20.0, 0.0], [29.2, 0.0]], [[72.4, 0.0], [300.0, 0.0]]] + assert pieces["centerline"]["lines"] == [[[0.0, 100.0], [9.0, 100.0]]] # 표 밖은 그대로 + drawing = build_plan_kind_drawing("plan_ip", "시험", box, pieces) + mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("ip_table")] + assert [e["type"] for e in mine] == ["Table"] * 3 + bp, ip, _ep = (e["shapeData"] for e in mine) + assert len(bp["rowHeights"]) == 3 and len(ip["rowHeights"]) == 8 + assert bp["cells"][1][2]["text"] == "0.00000" and ip["cells"][1][2]["text"] == "0.00000" + assert ip["cells"][2][2]["text"] == "100.00000" # Y = 동(x) + assert ip["cells"][3][1]["text"] == "90-00-00.00" + assert {layer["id"] for layer in drawing["layers"]} >= {layer_id("ip_table")} + assert "ip_table" in next(k for k in DEFAULT_KINDS if k["id"] == "route")["pieces"] + # DXF — 표 → 선 + 글(IP 묶음: 바깥 4 · 세로 10 · 가로 20 · 글 17) + raw, skipped = drawing_to_dxf({"entities": [mine[1]], "layers": drawing["layers"]}) + (tmp_path / "ip.dxf").write_bytes(raw) + space = ezdxf.readfile(tmp_path / "ip.dxf").modelspace() + assert skipped == {} and len(space.query("LINE")) == 34 and len(space.query("TEXT")) == 17 + assert {"90-00-00.00", "15.00000"} <= {t.dxf.text for t in space.query("TEXT")} From c3af85113cb1cb86169cafc007bfa76de7300a66 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:46:09 +0900 Subject: [PATCH 04/13] =?UTF-8?q?feat(B08):=20=ED=83=AD=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=C2=B7=20=ED=86=A0=EA=B3=B5=20=C2=B7=20?= =?UTF-8?q?=EC=88=98=EB=A6=AC=20+=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=20=EB=84=B7=20=C2=B7=20=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=EB=8F=84=20=ED=83=AD=20=EC=A7=80=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EC=A1=B0=20=ED=83=AD=20=EC=9C=84=20=EA=B0=84=EA=B2=A9?= =?UTF-8?q?=208px(95-1=20=C2=B7=2095-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구조물도 모달(집계표 행 누름 · 그 측점 조합 전부 · 복사본 먼저) · 공용 표 onRowClick opt-in(95-3) · 수리 상세 폭 제한 풂 · 카드 높이 화면 따라 · @container(95-4). 화면 검증 전. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- B08_Quantity/B08_Quantity_Summary_Data.ts | 33 +++ B08_Quantity/B08_Quantity_UI_Drawing.css | 41 ++- B08_Quantity/B08_Quantity_UI_Drawing.ts | 249 ++++++++++-------- B08_Quantity/B08_Quantity_UI_Hydro.css | 21 +- B08_Quantity/B08_Quantity_UI_Page.css | 2 +- B08_Quantity/B08_Quantity_UI_Page.ts | 75 ++++-- B08_Quantity/B08_Quantity_UI_Summary.ts | 45 +++- resources/tester/helper_ui_data_table.cjs | 6 + .../tester/test_61_1_b08_summary_table.py | 22 +- .../tester/test_61_2_b08_structure_sheets.py | 27 +- resources/tester/test_ui_data_table.py | 9 + ui_template/ui_template_data_table.css | 4 + ui_template/ui_template_data_table.ts | 15 +- ui_template/ui_template_locale_b2.ts | 1 - ui_template/ui_template_locale_b4.ts | 11 + 15 files changed, 409 insertions(+), 152 deletions(-) diff --git a/B08_Quantity/B08_Quantity_Summary_Data.ts b/B08_Quantity/B08_Quantity_Summary_Data.ts index 24c0df084..370c5c307 100644 --- a/B08_Quantity/B08_Quantity_Summary_Data.ts +++ b/B08_Quantity/B08_Quantity_Summary_Data.ts @@ -17,6 +17,8 @@ import { /** 구조물 · 관 지점 한 건 — 필요한 칸만. 구간형은 기준점(`chainage_m`) · 없으면 시점. */ export interface PlacedItem { + /** 복사본 폴더를 찾는 구조물 id(관 지점은 `pipe@측점`) — 없으면 복사본 없이 원본 */ + structure_id?: string | null; chainage_m?: number | null; start_m?: number | null; summary_item?: unknown; @@ -82,6 +84,37 @@ export function summaryColumns( ); } +/** 한 측점에 놓인 조합 한 건 — 어느 구조물 것인지(복사본 찾기용)까지. */ +export interface StationCombo { + id: string; + item: SummaryItem; + structureId: string | null; +} + +/** 측점(1cm 안 같은 측점)에 놓인 조합들 — 구조물마다 본체 → 그룹 차례 · 같은 구조물의 같은 조합은 한 건. */ +export function combosAt(things: readonly PlacedItem[], station: number): StationCombo[] { + const seen = new Set(); + const found: StationCombo[] = []; + for (const thing of things) { + const at = stationOf(thing); + if (at === null || Math.round(at * 100) !== Math.round(station * 100)) continue; + const own = readSummaryItem(thing.summary_item); + for (const item of [ + ...(own ? [own] : []), + ...Object.values(readGroupItems(thing.group_items)), + ]) { + const structureId = thing.structure_id ?? null; + const key = `${structureId} +${comboId(item)}`; + if (!seen.has(key)) { + seen.add(key); + found.push({ id: comboId(item), item, structureId }); + } + } + } + return found; +} + /** 머리 첫 줄 — 이어진 같은 구분을 한 칸으로 `[구분, 열 수]`. */ export function groupSpans(columns: readonly ComboColumn[]): Array<[string, number]> { const spans: Array<[string, number]> = []; diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.css b/B08_Quantity/B08_Quantity_UI_Drawing.css index 716d0fb64..4d0dfee05 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.css +++ b/B08_Quantity/B08_Quantity_UI_Drawing.css @@ -1,17 +1,48 @@ -/* B08 2탭 구조물도(PLAN 61-2) — 보조 탭 · 카드 둘(도면 · 면적산출). 색은 테마 변수만. */ +/* B08 구조물도 모달(PLAN 95-3) — 조합마다 카드 둘(도면 · 면적산출). 색은 테마 변수만. */ -/* M02 에 없는 조합 — 빨간 글은 비어 있는 단추만(고른 단추 바탕 위에선 안 읽힘 · 아래 안내 글이 알림) */ -.b08-drawing__tabs .is-missing:not(.ui-btn--filled) { +/* 큰 창 — 공용 모달 css 는 그대로 두고 자기 class 로 */ +.ui-modal.b08-sheets { + width: min(1320px, 96vw); + --modal-max: 94vh; +} + +.b08-sheets .ui-modal__body { + gap: var(--spacing-24); +} + +.b08-sheets__combo { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.b08-sheets__title { + display: flex; + align-items: baseline; + gap: var(--spacing-8); + margin: 0; + font-size: var(--text-body); + color: var(--color-text); +} + +.b08-sheets__from { + font-size: var(--text-caption); + font-weight: var(--font-weight-regular); + color: var(--color-text-secondary); +} + +/* M02 에 없는 조합 안내 — 빨간 글 */ +.b08-sheets__combo > .ui-general-block__empty { color: var(--color-danger); } /* M02 카드 칸은 셋(도면 · 일위대가 · 면적산출) — 여기는 둘 · 도면을 넓게 */ -.b08-drawing__cards .m02-struct { +.b08-sheets .m02-struct { grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); } @media (max-width: 960px) { - .b08-drawing__cards .m02-struct { + .b08-sheets .m02-struct { grid-template-columns: minmax(0, 1fr); } } diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index d4c70a2e4..b3356214a 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -1,29 +1,30 @@ /* ============================================================================= * B08_Quantity_UI_Drawing.ts - * 2탭 구조물도(PLAN 61-2) — 집계표 열(저장된 구조물 조합)마다 보조 탭 · 한 장 = 그 조합의 - * M02 도면(도각 포함 · 보기 전용 CAD) + 면적산출 — M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로. + * 구조물 집계표 행 → 구조물도 모달(PLAN 95-3) — 그 측점에 놓인 조합 전부를 세로로 쌓음. + * 조합 한 개 = M02 항목 카드(`M02_MasterTemplete_ItemCards`) 그대로 — 도면(도각 포함 · 보기 전용 CAD · + * 여러 장이면 장 탭) + 면적산출. * - * 조합 목록 · 차례 = 집계표와 같음(`B08_Quantity_Summary_Data`) - * 읽기만 — 조합 원본 층(`readTemplate`)을 읽고 카드는 [저장] · [수정] 없음 · B05/B06 자료도 읽기만 - * 카드 · CAD · 스프레드시트는 이 탭을 열 때만 `import()` + * 원천 = 그 구조물의 프로젝트 복사본(같은 항목일 때) · 없으면 M02 원본 층 — 읽기만 + * 조합 목록 = 집계표와 같음(`B08_Quantity_Summary_Data` `combosAt`) + * 카드 · CAD · 스프레드시트는 모달을 열 때만 `import()` · 닫을 때 카드를 모두 정리 * ========================================================================== */ import "./B08_Quantity_UI_Drawing.css"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; -import { createButton } from "@ui/ui_template_elements"; -import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker"; -import { layerOf, pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data"; -import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; -import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; -import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; -import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { summaryTooltip } from "../common_util/common_util_summary_item"; +import { openModal } from "@ui/ui_template_modal"; +import { layerOf, type PickerRow } from "@ui/ui_template_item_picker_data"; import { - comboId, - summaryColumns, - type ComboColumn, - type ComboLabel, -} from "./B08_Quantity_Summary_Data"; + PART_OF, + projectItems, + sameItem, + structureFolder, + type ProjectItemInfo, +} from "@ui/ui_template_item_picker_project"; +import { readTemplate } from "../M02_MasterTemplete/M02_MasterTemplete_Api_Fetch"; +import type { ItemCardsHandle } from "../M02_MasterTemplete/M02_MasterTemplete_ItemCards"; +import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; +import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; +import type { ComboLabel, StationCombo } from "./B08_Quantity_Summary_Data"; function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; @@ -36,94 +37,136 @@ function note(text: string): HTMLElement { return node; } -/** 집계표와 같은 열 + 열마다 M02 줄(없으면 M02 에 없는 조합). */ -async function loadColumns( - projectId: string, -): Promise<{ columns: ComboColumn[]; rows: Map }> { - const [structures, pickerRows, pipes] = await Promise.all([ - fetchStructures(projectId), - loadPickerRows(), - // 배수 분석 전이면 관이 없음 — 구조물만으로 - fetchDetailPipePoints(projectId).then( - (response) => response.pipe_points, - () => [], - ), - ]); - const rows = new Map(pickerRows.map((row) => [rowId(row), row])); - const describe = (item: Parameters[0]): ComboLabel | null => { - const row = rows.get(comboId(item)); - if (!row) return null; - const [group = "", ...rest] = pathOf(row); - return { group, name: rest.join(" ") || group || row.key }; - }; - return { columns: summaryColumns([...structures.structures, ...pipes], describe), rows }; +export interface SheetsRequest { + projectId: string; + /** 모달 제목에 붙는 측점 글 */ + station: string; + combos: StationCombo[]; + /** `comboId` → M02 줄 */ + rows: Map; + describe: (item: SummaryItem) => ComboLabel | null; } -/** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ -export async function renderStructureSheets(host: HTMLElement, projectId: string): Promise { - host.replaceChildren(note(L("B08_Quantity_Drawing_Loading"))); - try { - const [{ columns, rows }, { createItemCards }] = await Promise.all([ - loadColumns(projectId), - import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), - ]); - if (!host.isConnected) return; // 그새 다른 탭으로 옮김 - if (!columns.length) return void host.replaceChildren(note(L("B08_Quantity_Summary_NoCombo"))); +/** 그 구조물의 복사본 목록 — 프로젝트 복사본을 못 읽으면 원본만(모달은 그대로 열림). */ +const loadCopies = (projectId: string): Promise => + projectItems(projectId) + .list() + .catch(() => []); - let row: PickerRow | null = null; - const source = (): ItemSource => { - const layer = row ? layerOf(row) : "system"; - return { - layer, - projectId: null, - read: (part, column) => readTemplate(layer, part, column, null), - }; +function sourceOf( + request: SheetsRequest, + combo: StationCombo, + row: PickerRow, + copies: ProjectItemInfo[], +): { source: ItemSource; copy: boolean } { + const own = combo.structureId + ? copies.find((info) => info.폴더 === structureFolder(combo.structureId!)) + : undefined; + if (own && sameItem(own, row)) { + const items = projectItems(request.projectId); + return { + copy: true, + source: { + layer: "project", + projectId: request.projectId, + read: async (part) => { + const doc = await items.read(own.폴더, PART_OF[part]); + if (doc == null) throw new Error(L("B08_Quantity_Sheets_NoCopy")); + return { 문서: doc }; + }, + }, }; - const cards = createItemCards({ - source, - editable: () => false, - onEdit: () => undefined, - warnOnFail: () => false, // 못 읽으면 카드에 「없음」 - }); - - const tabs = document.createElement("div"); - tabs.className = "b08-quantity__tabs b08-drawing__tabs"; - tabs.setAttribute("role", "tablist"); - const notice = document.createElement("div"); - const sheet = document.createElement("div"); - sheet.className = "b08-drawing__cards"; - sheet.append(cards.root); - - const select = (picked: ComboColumn): void => { - tabs.replaceChildren( - ...columns.map((column) => { - const button = createButton({ - label: column.group ? `${column.group} ${column.name}` : column.name, - variant: column === picked ? "filled" : "ghost", - size: "sm", - onClick: () => column !== picked && select(column), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(column === picked)); - button.dataset.b08Combo = column.id; - button.title = summaryTooltip(column.item); - if (column.missing) button.classList.add("is-missing"); - return button; - }), - ); - row = rows.get(picked.id) ?? null; - if (!row?.열) { - notice.replaceChildren(note(L("B08_Quantity_Drawing_Missing"))); - return void cards.load(null); - } - notice.replaceChildren(...(row.도번 ? [] : [note(L("B08_Quantity_Drawing_NoDrawing"))])); - void cards.load({ id: row.열, label: picked.name }); - }; - - host.replaceChildren(tabs, notice, sheet); - select(columns[0]); - } catch (error) { - const reason = error instanceof Error ? error.message : String(error); - host.replaceChildren(note(`${L("B08_Quantity_Drawing_Failed")} — ${reason}`)); } + const layer = layerOf(row); + return { + copy: false, + source: { + layer, + projectId: null, + read: (part, column) => readTemplate(layer, part, column, null), + }, + }; +} + +/** 조합 한 칸 — 제목줄(이름 · 원천) + 안내 + 카드. */ +function comboSection( + request: SheetsRequest, + combo: StationCombo, + copies: ProjectItemInfo[], + createItemCards: typeof import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards").createItemCards, + handles: ItemCardsHandle[], +): HTMLElement { + const label = request.describe(combo.item); + const name = label + ? [label.group, label.name].filter(Boolean).join(" ") + : combo.item.name || combo.item.key; + const row = request.rows.get(combo.id) ?? null; + const section = document.createElement("section"); + section.className = "b08-sheets__combo"; + section.dataset.b08Combo = combo.id; + const title = document.createElement("h4"); + title.className = "b08-sheets__title"; + title.textContent = name; + title.title = summaryTooltip(combo.item); + section.append(title); + if (!row?.열) { + section.append(note(L("B08_Quantity_Drawing_Missing"))); + return section; + } + const picked = sourceOf(request, combo, row, copies); + const from = document.createElement("span"); + from.className = "b08-sheets__from"; + from.textContent = L( + picked.copy ? "B08_Quantity_Sheets_FromCopy" : "B08_Quantity_Sheets_FromMaster", + ); + title.append(from); + if (!row.도번) section.append(note(L("B08_Quantity_Drawing_NoDrawing"))); + const cards = createItemCards({ + source: () => picked.source, + editable: () => false, + onEdit: () => undefined, + warnOnFail: () => false, // 못 읽으면 카드에 「없음」 + }); + handles.push(cards); + section.append(cards.root); + void cards.load({ id: row.열, label: name }); + return section; +} + +/** 모달을 엶 — 조합이 없으면 안내 한 줄 · 불러오다 실패하면 까닭 한 줄. */ +export function openStructureSheets(request: SheetsRequest): void { + const handles: ItemCardsHandle[] = []; + let closed = false; + openModal({ + title: `${request.station} ${L("B08_Quantity_Sheets_Title")}`, + closeLabel: L("Common_Btn_Close"), + dialogClass: "b08-sheets", + beforeClose: () => { + closed = true; // 아직 불러오는 중이면 뒤늦게 카드를 만들지 않음 + handles.forEach((cards) => cards.destroy()); + return true; + }, + mount: (body) => { + if (!request.combos.length) return void body.append(note(L("B08_Quantity_Summary_NoCombo"))); + body.append(note(L("B08_Quantity_Drawing_Loading"))); + void (async () => { + try { + const [{ createItemCards }, copies] = await Promise.all([ + import("../M02_MasterTemplete/M02_MasterTemplete_ItemCards"), + loadCopies(request.projectId), + ]); + if (closed) return; + body.replaceChildren( + ...request.combos.map((combo) => + comboSection(request, combo, copies, createItemCards, handles), + ), + ); + } catch (error) { + if (closed) return; + const reason = error instanceof Error ? error.message : String(error); + body.replaceChildren(note(`${L("B08_Quantity_Drawing_Failed")} — ${reason}`)); + } + })(); + }, + }); } diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.css b/B08_Quantity/B08_Quantity_UI_Hydro.css index 77a3f09ec..eb22af05b 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.css +++ b/B08_Quantity/B08_Quantity_UI_Hydro.css @@ -2,12 +2,6 @@ 색은 테마 토큰만(밝음 · 어두움 같이) · 테두리는 입력 · 선택 칸(공용 ui-input · ui-select)만 · 굵게 + 붉은 계열은 중요 결과(.is-key)만. */ -/* 오른쪽 「진행단계」 떠 있는 판에 안 가리게 */ -.b08-hydro__toolbar, -.b08-hydro__sheet { - max-width: 1000px; -} - .b08-hydro__toolbar { display: flex; align-items: flex-start; @@ -27,6 +21,7 @@ } .b08-hydro__sheet { + container-type: inline-size; /* 2열 ↔ 1열 = 창이 아닌 이 칸 폭 기준 */ color: var(--color-text-body); font-size: var(--text-body-sm); } @@ -38,12 +33,24 @@ gap: var(--spacing-16); } -@media (max-width: 960px) { +@container (max-width: 800px) { .b08-hydro__body { grid-template-columns: minmax(0, 1fr); } } +/* 카드 높이 = 화면 따라(B08 다른 표와 같은 calc(100vh − N)) · 넘치면 카드 몸만 스크롤 */ +.b08-hydro__card { + display: flex; + flex-direction: column; + max-height: calc(100vh - 300px); +} + +.b08-hydro__card .ui-card__body { + min-height: 0; + overflow: auto; +} + .b08-hydro__card .ui-card__title { margin-bottom: 0; } diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index 4cf1f57ca..a74e7d678 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -3,7 +3,7 @@ .b08-quantity { display: flex; flex-direction: column; - gap: var(--spacing-16); + gap: var(--spacing-8); /* 보조 탭 줄 위 = 아래(.b08-quantity__tabs padding-bottom) */ padding: var(--spacing-16) var(--spacing-24); /* M02 본문(.m02-main)과 같은 값 */ } diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index e783faee5..c2621bab7 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -2,9 +2,9 @@ * B08_Quantity_UI_Page.ts * 로그인 후 08: 5차 워크플로우 (수량 산출) — 위 탭 화면 (PLAN 61) * - * 탭 = 구조물 집계표 · 구조물도 · 토공 · 수리집수면적표 — 차례대로 만듦. - * 위 탭 단추 = 공용 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1). - * 1탭(구조물 집계표 · 61-1) · 4탭(수리집수면적표 · 홍수량 계산서 62-2) 내용 · 나머지는 빈 탭. + * 탭 = 구조물 · 토공 · 수리집수면적표 (PLAN 95-1). 위 탭 단추 = 공용 기본 크기(md) · 보조 탭 단추 = sm(PLAN 69-1). + * 구조물 아래 보조 탭 = 구조물 집계표(61-1) · 총괄자재수량집계표 · 콘크리트소요자재집계표 · + * 자재수량집계표(셋은 아직 빈 탭) — 구조물도는 집계표 행을 눌러 여는 모달(95-3). * 옛 수량 산출 코드(`old_code/B08_Quantity/`)는 가져오지 않음 — 새로 씀. * ========================================================================== */ @@ -18,7 +18,6 @@ import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav"; import { renderStructureSummary } from "./B08_Quantity_UI_Summary"; import { renderHydro } from "./B08_Quantity_UI_Hydro"; -import { renderStructureSheets } from "./B08_Quantity_UI_Drawing"; import { renderEarthwork } from "./B08_Quantity_UI_Earthwork"; function L(key: keyof typeof ui_locales): string { @@ -26,32 +25,68 @@ function L(key: keyof typeof ui_locales): string { } const TABS = [ - { id: "summary", label: "B08_Quantity_Tab_StructureSummary" }, - { id: "sheet", label: "B08_Quantity_Tab_StructureSheet" }, + { id: "structure", label: "B08_Quantity_Tab_Structure" }, { id: "earthwork", label: "B08_Quantity_Tab_Earthwork" }, { id: "hydrology", label: "B08_Quantity_Tab_Hydrology" }, ] as const; type TabId = (typeof TABS)[number]["id"]; +/** 구조물 탭 보조 탭 — 첫째만 내용이 있음. */ +const SHEETS = [ + { id: "summary", label: "B08_Quantity_Tab_StructureSummary" }, + { id: "material", label: "B08_Quantity_Tab_MaterialTotal" }, + { id: "concrete", label: "B08_Quantity_Tab_ConcreteMaterial" }, + { id: "quantity", label: "B08_Quantity_Tab_MaterialQuantity" }, +] as const; + +type SheetId = (typeof SHEETS)[number]["id"]; + +function pendingNote(): HTMLElement { + const pending = document.createElement("p"); + pending.className = "ui-general-block__empty"; + pending.textContent = L("B08_Quantity_Tab_Pending"); + return pending; +} + +/** 구조물 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */ +function renderStructure(host: HTMLElement, projectId: string | null): void { + const tabs = document.createElement("div"); + tabs.className = "b08-quantity__tabs b08-structure__tabs"; + tabs.setAttribute("role", "tablist"); + const view = document.createElement("div"); + const select = (picked: SheetId): void => { + tabs.replaceChildren( + ...SHEETS.map(({ id, label }) => { + const button = createButton({ + label: L(label), + variant: id === picked ? "filled" : "ghost", + size: "sm", + onClick: () => id !== picked && select(id), + }); + button.setAttribute("role", "tab"); + button.setAttribute("aria-selected", String(id === picked)); + button.dataset.b08StructureSheet = id; + return button; + }), + ); + const body = document.createElement("div"); + view.replaceChildren(body); + if (picked === "summary" && projectId) void renderStructureSummary(body, projectId); + else body.append(pendingNote()); + }; + host.replaceChildren(tabs, view); + select("summary"); +} + /** 탭마다 새 몸통 — 늦게 온 응답이 다른 탭 화면을 덮지 않게(떨어진 몸통에만 그림). */ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null): void { const view = document.createElement("div"); host.replaceChildren(view); - if (tab === "summary" && projectId) { - void renderStructureSummary(view, projectId); - return; - } - if (tab === "hydrology" && projectId) { - void renderHydro(view, projectId); - return; - } - if (tab === "sheet" && projectId) return void renderStructureSheets(view, projectId); + if (tab === "structure") return renderStructure(view, projectId); + if (tab === "hydrology" && projectId) return void renderHydro(view, projectId); if (tab === "earthwork" && projectId) return void renderEarthwork(view, projectId); - const pending = document.createElement("p"); - pending.className = "ui-general-block__empty"; - pending.textContent = L("B08_Quantity_Tab_Pending"); - view.append(pending); + view.append(pendingNote()); } export function renderB08Quantity(root: HTMLElement): void { @@ -84,7 +119,7 @@ export function renderB08Quantity(root: HTMLElement): void { const body = document.createElement("div"); body.className = "b08-quantity"; body.append(tabBar, panel); - select("summary"); + select("structure"); const layout = createWorkflowLayout({ title: L("B08_Quantity_Title"), diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 8fae5e08e..b1d954caa 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -5,23 +5,29 @@ * * 구조물 = GET …/route/structures · 배관 = GET …/drainage/pipe-points (읽기만) * 머리 두 줄 = 조합 구분(M02 경로 첫 칸) · 조합 이름(나머지 경로) — `GET /api/m02/items?all=1` + * 줄을 누르면 그 측점의 구조물도 모달(PLAN 95-3 · `B08_Quantity_UI_Drawing` 은 누를 때 `import()`) * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { loadRows as loadPickerRows } from "@ui/ui_template_item_picker"; -import { pathOf, rowId } from "@ui/ui_template_item_picker_data"; +import { pathOf, rowId, type PickerRow } from "@ui/ui_template_item_picker_data"; +import { showToast } from "@ui/ui_template_elements"; import { fetchStructures } from "../B05_Profile/B05_Profile_Api_Structures"; import { fetchLatestRoute, readRouteLatestCache } from "../B05_Profile/B05_Profile_Api_Fetch"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createDataTable } from "@ui/ui_template_data_table"; +import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import { stationLabel } from "../common_util/common_util_station"; -import { summaryTooltip } from "../common_util/common_util_summary_item"; +import { summaryTooltip, type SummaryItem } from "../common_util/common_util_summary_item"; import { + combosAt, comboId, summaryColumns, summaryStations, type ComboColumn, type ComboLabel, + type PlacedItem, + type StationCombo, } from "./B08_Quantity_Summary_Data"; function L(key: keyof typeof ui_locales): string { @@ -44,7 +50,12 @@ function note(text: string): HTMLElement { /** 머리 글 길이에 맞춘 열 폭 — 칸은 비어 있어 글이 폭을 정함. */ const widthOf = (label: string): number => Math.min(200, Math.max(96, label.length * 13 + 24)); -function buildTable(stations: number[], columns: ComboColumn[], interval: number): HTMLElement { +function buildTable( + stations: number[], + columns: ComboColumn[], + interval: number, + onRowClick: (station: number, label: string) => void, +): HTMLElement { const table = createDataTable({ columns: [ { key: "station", label: L("B08_Quantity_Summary_Station"), width: 96 }, @@ -59,6 +70,7 @@ function buildTable(stations: number[], columns: ComboColumn[], interval: number ], // 칸은 아직 비움 — 사용자가 채울 자리라 「-」 대신 빈칸 rows: stations.map((station) => ({ station: stationLabel(station, interval) })), + onRowClick: (i) => onRowClick(stations[i], stationLabel(stations[i], interval)), frozen: 1, empty: "", caption: L("B08_Quantity_Tab_StructureSummary"), @@ -67,6 +79,22 @@ function buildTable(stations: number[], columns: ComboColumn[], interval: number return table.root; } +/** 행 누름 — 카드 · CAD 묶음은 이때 처음 받음. */ +async function openSheets( + projectId: string, + station: string, + combos: StationCombo[], + rows: Map, + describe: (item: SummaryItem) => ComboLabel | null, +): Promise { + try { + const { openStructureSheets } = await import("./B08_Quantity_UI_Drawing"); + openStructureSheets({ projectId, station, combos, rows, describe }); + } catch (error) { + showToast(`${L("B08_Quantity_Drawing_Failed")} — ${String(error)}`, "error"); + } +} + /** 탭 몸통을 그림 — 불러오는 동안 한 줄 · 실패하면 까닭 한 줄. */ export async function renderStructureSummary(host: HTMLElement, projectId: string): Promise { host.replaceChildren(note(L("B08_Quantity_Summary_Loading"))); @@ -82,20 +110,25 @@ export async function renderStructureSummary(host: HTMLElement, projectId: strin stationInterval(projectId).catch(() => 20), ]); const m02 = new Map(pickerRows.map((row) => [rowId(row), row])); - const describe = (item: Parameters[0]): ComboLabel | null => { + const describe = (item: SummaryItem): ComboLabel | null => { const row = m02.get(comboId(item)); if (!row) return null; const [group = "", ...rest] = pathOf(row); return { group, name: rest.join(" ") || group || row.key }; }; - const things = [...structures.structures, ...pipes]; + const things: PlacedItem[] = [ + ...structures.structures, + ...pipes.map((pipe) => ({ ...pipe, structure_id: pipeStructureId(pipe.chainage_m) })), + ]; const stations = summaryStations(things); const columns = summaryColumns(things, describe); const parts: HTMLElement[] = []; if (!columns.length) parts.push(note(L("B08_Quantity_Summary_NoCombo"))); parts.push( stations.length - ? buildTable(stations, columns, interval) + ? buildTable(stations, columns, interval, (station, label) => + openSheets(projectId, label, combosAt(things, station), m02, describe), + ) : note(L("B08_Quantity_Summary_NoStation")), ); host.replaceChildren(...parts); diff --git a/resources/tester/helper_ui_data_table.cjs b/resources/tester/helper_ui_data_table.cjs index 1b7ddf2db..3dad134c2 100644 --- a/resources/tester/helper_ui_data_table.cjs +++ b/resources/tester/helper_ui_data_table.cjs @@ -82,6 +82,12 @@ console.log( frozen: 1, empty: "", }), + pick: m.renderDataTableHtml({ + columns: [{ key: "x", label: "X" }], + rows: [{ x: 1 }, { x: 2 }], + total: { x: 3 }, + onRowClick: () => undefined, + }), escaped: m.renderDataTableHtml({ columns: [{ key: "x", label: "" }], rows: [{ x: "a&<" }] }), noTotal: m.renderDataTableHtml({ columns: [tc], rows: [] }).includes(""), }), diff --git a/resources/tester/test_61_1_b08_summary_table.py b/resources/tester/test_61_1_b08_summary_table.py index fd1379584..bfba8a6f8 100644 --- a/resources/tester/test_61_1_b08_summary_table.py +++ b/resources/tester/test_61_1_b08_summary_table.py @@ -72,9 +72,29 @@ def test_빈_목록(): assert out == [[], [], []] +def test_측점_조합들(): + # 95-3 — 한 측점(1cm 안) 조합 전부 · 본체 → 그룹 차례 · 구조물 id 따라감 · 같은 구조물 같은 조합은 한 건 + things = [ + {"structure_id": "s1", "chainage_m": 40.004, "summary_item": _item("SS1"), + "group_items": {"g1": _item("SS3"), "g2": _item("SS1")}}, + {"structure_id": "pipe@40.0", "chainage_m": 40.0, "summary_item": _item("SS1")}, + {"structure_id": "s2", "chainage_m": 80.0, "summary_item": _item("SS2")}, + ] # fmt: skip + out = _run( + f"\nconst things = {json.dumps(things, ensure_ascii=False)};" + "\nconsole.log(JSON.stringify([combosAt(things, 40).map((c) => [c.id, c.structureId])," + " combosAt(things, 80).length, combosAt(things, 99).length]));" + ) + assert out == [ + [["system||SS1", "s1"], ["system||SS3", "s1"], ["system||SS1", "pipe@40.0"]], + 1, + 0, + ] + + def test_집계표는_공용_표(): # PLAN 70-2 — 공용 많은 자료 표 · 측점 열 고정 · 칸은 빈칸(「—」 아님) ui = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") assert "createDataTable(" in ui and "@ui/ui_template_data_table" in ui - assert "frozen: 1" in ui and 'empty: ""' in ui + assert "frozen: 1" in ui and 'empty: ""' in ui and "onRowClick" in ui assert 'document.createElement("table")' not in ui diff --git a/resources/tester/test_61_2_b08_structure_sheets.py b/resources/tester/test_61_2_b08_structure_sheets.py index aa077e161..05333c28e 100644 --- a/resources/tester/test_61_2_b08_structure_sheets.py +++ b/resources/tester/test_61_2_b08_structure_sheets.py @@ -1,15 +1,28 @@ -"""61-2 B08 2탭 구조물도 — 탭에 붙음 · 읽기만 · 집계표와 같은 조합 목록 · 안내 글 키.""" +"""61-2 · 95-3 B08 구조물도 모달 — 집계표 행에서 열림 · 읽기만 · 집계표와 같은 조합 목록 · 안내 글 키.""" import re from pathlib import Path DRAWING = Path("B08_Quantity/B08_Quantity_UI_Drawing.ts").read_text(encoding="utf-8") +SUMMARY = Path("B08_Quantity/B08_Quantity_UI_Summary.ts").read_text(encoding="utf-8") PAGE = Path("B08_Quantity/B08_Quantity_UI_Page.ts").read_text(encoding="utf-8") LOCALE = Path("ui_template/ui_template_locale_b4.ts").read_text(encoding="utf-8") +LOCALE_B2 = Path("ui_template/ui_template_locale_b2.ts").read_text(encoding="utf-8") -def test_sheet_tab_wired(): - assert 'tab === "sheet"' in PAGE and "renderStructureSheets(view, projectId)" in PAGE +def test_sheet_tab_gone_modal_wired(): + # 구조물도 탭 없음 · 집계표 행 누름이 모달을 늦게 불러 엶 + assert '"sheet"' not in PAGE and "renderStructureSheets" not in PAGE + assert "B08_Quantity_Tab_StructureSheet" not in PAGE + LOCALE + LOCALE_B2 + assert "onRowClick" in SUMMARY and 'import("./B08_Quantity_UI_Drawing")' in SUMMARY + assert "combosAt(things, station)" in SUMMARY + + +def test_modal_own_class_and_cleanup(): + css = Path("B08_Quantity/B08_Quantity_UI_Drawing.css").read_text(encoding="utf-8") + assert "min(1320px, 96vw)" in css and "--modal-max: 94vh" in css + assert "beforeClose" in DRAWING and "cards.destroy()" in DRAWING + assert 'dialogClass: "b08-sheets"' in DRAWING def test_read_only(): @@ -19,10 +32,12 @@ def test_read_only(): assert "editable: () => false" in DRAWING -def test_same_columns_as_summary(): - assert "summaryColumns(" in DRAWING and "./B08_Quantity_Summary_Data" in DRAWING +def test_copy_first_then_master(): + # 같은 항목의 프로젝트 복사본이 있으면 복사본 · 없으면 M02 원본 층 + assert "sameItem(own, row)" in DRAWING and "structureFolder(" in DRAWING + assert "readTemplate(layer, part, column, null)" in DRAWING def test_locale_keys_exist(): for key in set(re.findall(r'L\("([A-Za-z0-9_]+)"\)', DRAWING)): - assert f"{key}:" in LOCALE or key.startswith("B08_Quantity_Summary_"), key + assert f"{key}:" in LOCALE or key.startswith(("B08_Quantity_Summary_", "Common_")), key diff --git a/resources/tester/test_ui_data_table.py b/resources/tester/test_ui_data_table.py index cbbecc7c5..56fe2c74a 100644 --- a/resources/tester/test_ui_data_table.py +++ b/resources/tester/test_ui_data_table.py @@ -92,3 +92,12 @@ def test_빈_칸_글_풍선_꾸밈_옵션(r): assert '' in o # empty="" — 「-」 대신 빈칸 assert '메쌓기' in o assert 'rowspan="2" class="is-frozen is-edge" style="left:0px" title="측점">측점' in o + + +def test_줄_누름_옵션일_때만_data_i(r): + # 95-3 — onRowClick 을 주면 본문 줄에만 data-i · is-clickable(합계 줄 제외) · 안 주면 글 그대로 + pick = r["pick"] + assert '' in pick + assert '' in pick + assert pick.count("data-i=") == 2 + assert "data-i" not in r["opts"] and "is-clickable" not in r["foot"] diff --git a/ui_template/ui_template_data_table.css b/ui_template/ui_template_data_table.css index 251a5aa0f..c915b2519 100644 --- a/ui_template/ui_template_data_table.css +++ b/ui_template/ui_template_data_table.css @@ -133,3 +133,7 @@ color: var(--color-text); font-weight: var(--font-weight-bold); } + +.ui-data-table__table tbody tr.is-clickable { + cursor: pointer; +} diff --git a/ui_template/ui_template_data_table.ts b/ui_template/ui_template_data_table.ts index a20737edb..a77ef0e5d 100644 --- a/ui_template/ui_template_data_table.ts +++ b/ui_template/ui_template_data_table.ts @@ -43,6 +43,8 @@ export interface DataTableOptions { caption?: string; /** 빈 값 글(기본 「-」 · 사용자가 채울 칸은 "") */ empty?: string; + /** 줄 누름(본문 줄만 · 합계 제외) — 주면 줄에 `data-i`(줄 번호)·`is-clickable` 이 붙음 */ + onRowClick?: (index: number) => void; } export interface DataTable { @@ -112,6 +114,7 @@ function rowHtml( r: DataRow, alt = false, blank = EMPTY, + index: number | null = null, ): string { let tr = ""; for (let j = 0; j < cols.length; j++) { @@ -126,7 +129,9 @@ function rowHtml( (fmts[j] ? ' data-num=""' : ""); tr += `${esc(txt)}`; } - return alt ? `${tr}` : `${tr}`; + const cls = [alt && "is-alt", index !== null && "is-clickable"].filter(Boolean).join(" "); + const data = index !== null ? ` data-i="${index}"` : ""; + return cls || data ? `${tr}` : `${tr}`; } /** 그릴 줄 구간 — 밖의 줄은 빈 줄 높이(rowH px)로만 채움 */ @@ -163,7 +168,9 @@ export function renderDataTableHtml(o: DataTableOptions, win?: RowWindow): strin const to = win ? Math.max(from, Math.min(win.to, n)) : n; let body = win ? pad(from * win.rowH, cols.length) : ""; const blank = o.empty ?? EMPTY; - for (let i = from; i < to; i++) body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1, blank); + const pick = o.onRowClick !== undefined; + for (let i = from; i < to; i++) + body += rowHtml(cols, fmts, left, o.rows[i], i % 2 === 1, blank, pick ? i : null); if (win) body += pad((n - to) * win.rowH, cols.length); const foot = o.total ? `${rowHtml(cols, fmts, left, o.total, false, blank)}` : ""; const cap = o.caption ? `${esc(o.caption)}` : ""; @@ -199,6 +206,10 @@ export function createDataTable(opts: DataTableOptions): DataTable { } }; root.addEventListener("scroll", () => draw(false), { passive: true }); + root.addEventListener("click", (event) => { + const row = (event.target as Element).closest("tr[data-i]"); + if (row && root.contains(row)) state.onRowClick?.(Number(row.dataset.i)); + }); draw(true); return { root, diff --git a/ui_template/ui_template_locale_b2.ts b/ui_template/ui_template_locale_b2.ts index ee0678e37..8c553b080 100644 --- a/ui_template/ui_template_locale_b2.ts +++ b/ui_template/ui_template_locale_b2.ts @@ -615,7 +615,6 @@ export const ui_locales_b2 = { B08_Quantity_Tab_Summary: ["토공집계", "Earthwork Summary"], B08_Quantity_Tab_Haul: ["운반거리", "Haul Distance"], B08_Quantity_Tab_StructureSummary: ["구조물 집계표", "Structure Summary"], - B08_Quantity_Tab_StructureSheet: ["구조물도", "Structure Sheets"], B08_Quantity_Tab_UnitQuantity: ["구조물 원단위", "Structure Unit Quantity"], B08_Quantity_Tab_Material: ["자재총괄", "Material Summary"], B08_Quantity_Tab_Preparation: ["준비공·사방공", "Preparation & Erosion Control"], diff --git a/ui_template/ui_template_locale_b4.ts b/ui_template/ui_template_locale_b4.ts index 1a0698a4b..010dbce23 100644 --- a/ui_template/ui_template_locale_b4.ts +++ b/ui_template/ui_template_locale_b4.ts @@ -19,6 +19,17 @@ export const ui_locales_b4 = { ], B08_Quantity_Tab_Hydrology: ["수리집수면적표", "Hydraulic Catchment Table"], B08_Quantity_Tab_Pending: ["차례대로 만들 탭입니다.", "This tab comes later, in order."], + B08_Quantity_Tab_Structure: ["구조물", "Structure"], + B08_Quantity_Tab_MaterialTotal: ["총괄자재수량집계표", "Total Material Quantity Table"], + B08_Quantity_Tab_ConcreteMaterial: [ + "콘크리트소요자재집계표", + "Concrete Material Requirement Table", + ], + B08_Quantity_Tab_MaterialQuantity: ["자재수량집계표", "Material Quantity Table"], + B08_Quantity_Sheets_Title: ["구조물도", "Structure Sheets"], + B08_Quantity_Sheets_FromCopy: ["프로젝트 복사본", "Project copy"], + B08_Quantity_Sheets_FromMaster: ["M02 원본", "M02 original"], + B08_Quantity_Sheets_NoCopy: ["복사본에 없음", "Not in the project copy"], B08_Quantity_Drawing_Loading: ["구조물도를 불러오는 중…", "Loading structure sheets…"], B08_Quantity_Drawing_Failed: [ "구조물도를 불러오지 못했습니다", From e535cc3183a406e08490f76c1dd49422cda306cc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:46:59 +0900 Subject: [PATCH 05/13] =?UTF-8?q?feat(B08):=20=EC=88=98=EB=A6=AC=20?= =?UTF-8?q?=ED=91=9C=20=EA=B0=92=20=EC=B0=A8=EB=A1=80=20=EA=B8=B0=EB=B3=B8?= =?UTF-8?q?=20<=20=EC=9E=A5=20=EA=B8=B0=EB=B3=B8=20<=20B05=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20<=20=EC=A0=80=EC=9E=A5=20<=20=EC=B4=88=EC=95=88=20?= =?UTF-8?q?=C2=B7=20=EA=B0=99=EC=9D=80=20=EA=B0=92=20=EB=B9=84=EC=9B=8C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=C2=B7=20B05=20Qd=20=C2=B7=20T=20=EC=B0=B8?= =?UTF-8?q?=EA=B3=A0(96-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 저장본 · 초안의 연결 칸 null 은 건너뜀 · [저장] · 초안에서 연결값과 같은 칸은 null(연동 유지) - 연결된 칸 이름 옆 작은 「B05」(풍선 「B05 분석」) · Qd · T 옆에 B05 값 - 초안 version 2 → 3 · 옛 「B05 와 안 이음」 주석 고침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- A00_Common/b_page_state.ts | 4 +- B08_Quantity/B08_Quantity_UI_Hydro.ts | 30 ++++++++-- B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts | 62 ++++++++++++++++++++- 3 files changed, 88 insertions(+), 8 deletions(-) diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index d1b9b599f..75850c9cb 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -164,8 +164,8 @@ export const STATE_REGISTRY = { * 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고 * [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */ crossdesign: { bucket: "draft", scope: "route" }, - /** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · 배수유역 · B05/B06 값과 안 이음(69-6). */ - "b08-hydro": { bucket: "draft", scope: "project", version: 2 }, + /** B08 홍수량 계산서 입력 `{측점: 입력}` — [저장] 때 B08 자기 파일로 · B05 연결값과 같은 칸은 null(연동 · 96). */ + "b08-hydro": { bucket: "draft", scope: "project", version: 3 }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). * 옛 키는 노선 번호만 붙은 `b05-profile-alignment-draft:{노선}` 이었다. */ "alignment-draft": { diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index fea648f6d..80512c3b3 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -2,7 +2,8 @@ * B08_Quantity_UI_Hydro.ts * 4탭 수리집수면적표 — 배수지점 측점마다 보조 탭 · 한 장 = 홍수량 계산서(PLAN 62-2 · 69-4~6). * - * 측점 목록만 저장된 관 지점(pipe_points)에서 · 입력은 이 탭 자기 값(B05/B06 값과 안 이음) + * 측점 목록은 저장된 관 지점(pipe_points) · B05 연결값(A · L · H · 관경 · 내공 · 월류 폭 · 96)이 빈칸을 채움 + * 값 차례 = 기본 < 장 기본 < 연결 < 저장 < 초안 · 연결값과 같은 칸은 null 로 저장(연동 유지) * 박스암거 = 사각 단면 장(73-11) · 세월교 · 물넘이포장 = 월류 높이 장(73-12) · 그 밖 = 관 장 * 처음 값 = 표준 값 · 측점 고유 값은 빈칸 · 지역 · R24 = 공사지 자료(서버) * 조작 = 캐시(sessionStorage) · [저장] = B08 자기 파일(PUT …/quantity/hydro) · 뒤 캐시 비움 @@ -23,6 +24,7 @@ import { stationInterval } from "./B08_Quantity_UI_Summary"; import { buildHydroSheet, type HydroInputs, + type HydroLink, type HydroOptions, type HydroResult, } from "./B08_Quantity_UI_Hydro_Sheet"; @@ -34,6 +36,9 @@ interface HydroStation { kind: string; /** 계산서 장 — 시설이 정함(서버 station_rows) */ sheet: HydroInputs["sheet"]; + /** B05 연결값(값이 있는 칸만) · 비교용 B05 Qd · T — 서버 station_rows */ + linked: HydroLink["values"] | null; + b05: HydroLink["b05"]; } type Drafts = Record>; @@ -83,14 +88,26 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise } let drafts: Drafts = readState("b08-hydro", projectId) ?? {}; + const linkedOf = (station: HydroStation): Record => station.linked ?? {}; + /** 저장본 · 초안 한 겹 — 연결값이 있는 칸의 null 은 건너뜀(연결값을 덮지 않게) */ + const layer = (station: HydroStation, values: Partial = {}) => + Object.fromEntries( + Object.entries(values).filter(([k, v]) => v != null || !(k in linkedOf(station))), + ); const inputsOf = (station: HydroStation): HydroInputs => ({ ...sheet.defaults, ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), - ...sheet.saved[keyOf(station)], - ...drafts[keyOf(station)], + ...station.linked, + ...layer(station, sheet.saved[keyOf(station)]), + ...layer(station, drafts[keyOf(station)]), sheet: station.sheet, }) as HydroInputs; + /** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */ + const unlinked = (station: HydroStation, inputs: HydroInputs): HydroInputs => + Object.fromEntries( + Object.entries(inputs).map(([k, v]) => [k, linkedOf(station)[k] === v ? null : v]), + ) as unknown as HydroInputs; const tabs = document.createElement("div"); tabs.className = "b08-quantity__tabs b08-hydro__tabs"; @@ -134,13 +151,14 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise inputs, sheet, (next) => { - drafts[keyOf(station)] = next; + drafts[keyOf(station)] = unlinked(station, next); writeState("b08-hydro", drafts, projectId); // 단면 가정 · 관 종류는 그림 · 식이 바뀜 — 장을 다시 그림 · 그 밖은 값만 if (next.section !== inputs.section || next.pipe_kind !== inputs.pipe_kind) draw(next); else void calc(next, view.show); }, station.kind, + { values: station.linked ?? {}, b05: station.b05 }, ); page.replaceChildren(view.root); void calc(inputs, view.show); @@ -166,7 +184,9 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise /** [저장] — 측점 전부의 지금 입력을 B08 자기 파일로 · 성공하면 캐시를 비우고 저장본으로 다시 엶 */ const saveAll = async (): Promise => { - const stations = Object.fromEntries(sheet.stations.map((s) => [keyOf(s), inputsOf(s)])); + const stations = Object.fromEntries( + sheet.stations.map((s) => [keyOf(s), unlinked(s, inputsOf(s))]), + ); showLoadingOverlay(); try { const body = await request<{ saved: Drafts }>(`/projects/${projectId}/quantity/hydro`, { diff --git a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts index 60adc4a11..04cd6efbf 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro_Sheet.ts @@ -7,6 +7,7 @@ * 장(sheet) 갈래 — 박스 = 사각 내공 B · H · 수심비(73-11) · 세월교 · 물넘이 = 월류 폭 → 월류 높이(73-12) * 테두리는 입력 · 선택 칸만(공용 ui-input · ui-select) · 계산 칸은 글자만 * 굵게 + 붉은 계열은 중요 결과(Qd · 유출량 · 판정 · 유출율)만 · 빈칸에 기대는 계산 칸은 「-」. + * B05 연결값과 같은 칸은 이름 옆 작은 「B05」(풍선 「B05 분석」) · Qd · T 옆에 B05 값 참고(96-2). * * 입력 칸은 한 번 만들고 계산 결과만 제자리에 바꿈 — 고치는 중 칸 포커스를 잃지 않게. * ========================================================================== */ @@ -77,6 +78,12 @@ export interface HydroOptions { site: HydroSite; } +/** B05 배수유역 연결(96) — 칸 값 · 비교용 설계유량 · 도달시간(분) */ +export interface HydroLink { + values: Partial>; + b05: { design_flow_m3s: number | null; tc_minutes: number | null } | null; +} + export interface HydroSheet { root: HTMLElement; /** 계산값 칸을 바꿈 — null 이면 비움 + 까닭 한 줄 */ @@ -103,6 +110,14 @@ const DASH = "-"; const fmt = (value: number | null | undefined, digits: number, unit = ""): string => value == null || !Number.isFinite(value) ? DASH : `${value.toFixed(digits)}${unit}`; +/** 입력 칸이 놓인 줄의 이름 칸 */ +function sheetLabel(left: HTMLElement, right: HTMLElement, key: string): HTMLElement | null { + const input = [left, right] + .map((card) => card.querySelector(`input[data-b08-hydro="${key}"]`)) + .find(Boolean); + return input?.closest(".b08-hydro__row")?.querySelector(".b08-hydro__label") ?? null; +} + function node( tag: K, className = "", @@ -218,8 +233,15 @@ export function buildHydroSheet( onChange: (next: HydroInputs) => void, /** 시설 이름(박스암거 · 세월교 · 물넘이포장) — 관이면 "" */ kind = "", + link: HydroLink = { values: {}, b05: null }, ): HydroSheet { const site = options.site; + /** 연결된 칸의 「B05」 표 — 값이 연결값과 같을 때만 보임 */ + const marks = new Map(); + const markLinked = (key: Key): void => { + const mark = marks.get(key); + if (mark) mark.hidden = current[key] !== link.values[key]; + }; const out = new Map(); const outCell = (id: string, key = false): HTMLElement => { const cell = node("span", `b08-hydro__out${key ? " is-key" : ""}`, DASH); @@ -229,6 +251,7 @@ export function buildHydroSheet( let current = inputs; const commit = (key: Key, value: unknown): void => { current = { ...current, [key]: value } as HydroInputs; + markLinked(key); onChange(current); }; const numberInput = (key: Key, placeholder = ""): HTMLInputElement => { @@ -238,6 +261,14 @@ export function buildHydroSheet( input.dataset.b08Hydro = key; const value = inputs[key]; input.value = value == null ? "" : String(value); + const linked = link.values[key]; + if (linked != null) { + input.title = `B05 분석 ${linked}`; + const mark = node("span", "b08-hydro__hint", "B05"); + mark.title = "B05 분석"; + mark.dataset.b08HydroLinked = key; + marks.set(key, mark); + } input.addEventListener("change", () => { // 빈칸은 NULLABLE 만 · 최소 도달시간은 0 까지 · 그 밖은 0 보다 커야 함 — 아니면 되돌림 const parsed = input.value === "" ? null : Number(input.value); @@ -283,6 +314,18 @@ export function buildHydroSheet( }; const sub = (text: string): HTMLElement => node("h4", "b08-hydro__h2", text); const hint = (text: string): HTMLElement => node("span", "b08-hydro__hint", text); + /** B05 분석 값 참고(비교만) — 값이 없으면 칸 없음 */ + const b05Hint = ( + key: "design_flow_m3s" | "tc_minutes", + text: (value: number) => string, + ): HTMLElement | undefined => { + const value = link.b05?.[key]; + if (value == null) return undefined; + const el = hint(text(value)); + el.title = "B05 분석"; + el.dataset.b08HydroB05 = key; + return el; + }; // ── 왼쪽 1. 홍수량 계산 ── const factor = select( @@ -332,6 +375,7 @@ export function buildHydroSheet( return label; })(), outCell("design_flow", true), + b05Hint("design_flow_m3s", (v) => `B05 ${fmt(v, 2)}`), ), row("Q : 홍수량 (㎥/s)", outCell("flow")), row("f : 유출계수", numberInput("runoff")), @@ -339,7 +383,16 @@ export function buildHydroSheet( row("A : 유역면적 (㎢)", numberInput("area_km2", "입력"), outCell("area_ha")), sub("1-2. 홍수 도달시간 — 산지부 (Kirpich)"), formula("T = 0.0663 × L^0.77^ × s^-0.385^ (L : ㎞ · T : hr)"), - row("T : 홍수 도달시간 (hr)", outCell("tc_hr"), outCell("tc_note")), + row( + "T : 홍수 도달시간 (hr)", + outCell("tc_hr"), + (() => { + const note = node("span"); + const b05 = b05Hint("tc_minutes", (v) => ` B05 ${fmt(v, 1)}분`); + note.append(outCell("tc_note"), ...(b05 ? [b05] : [])); + return note; + })(), + ), row("L : 산정에서 하천시점까지 거리 (m)", numberInput("length_m", "입력")), row("H : 산정에서 하천시점까지 표고차 (m)", numberInput("height_m", "입력")), row("s : 지표면 경사 (H / L)", outCell("slope_ground")), @@ -443,6 +496,13 @@ export function buildHydroSheet( }); right.classList.add("b08-hydro__card"); + // 「B05」 표는 그 칸 이름 옆에 + for (const [key, mark] of marks) { + const label = sheetLabel(left, right, key); + label?.append(" ", mark); + markLinked(key); + } + const status = node("p", "b08-hydro__status"); const sheet = node("div", "b08-hydro__sheet"); sheet.dataset.b08HydroSheet = stationText; From 9f0ab53c538a90411f7494ac33e3a21e96ae32a7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:50:07 +0900 Subject: [PATCH 06/13] =?UTF-8?q?fix(B07-CAD):=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=B0=BD=20=EC=B9=B8=20=C2=B7=20=EA=B8=80=20?= =?UTF-8?q?=EC=83=89=EC=9D=B4=20=ED=85=8C=EB=A7=88=EB=A5=BC=20=EB=94=B0?= =?UTF-8?q?=EB=A6=84=20=E2=80=94=20=EC=9E=90=EB=8F=99=EC=83=89=20=EB=92=A4?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20=EA=B1=B4=EB=84=88=EB=9C=80(97-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 칸 = --cad-chrome-raised(밝음 흰 칸 + --cad-line 테두리 · 어두움 #251f38) · 글 = --cad-text · 안내 = --cad-text-dim - fillRectScreen · drawTextScreen 에 paintColor 건너뛰는 rawColor 선택 인자 - 입력창 그리기를 inputController/input-field-draw.ts 로 · 전체 맞춤 계산을 drawControllers/fit-view.ts 로(700줄) - App.consts 입력창 색 상수 = 토큰 폴백 · 틀린 주석 고침 - 시험 theme-auto-color: 칸 · 글 색이 두 테마 모두 다름 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- B07_DesignDetail/openwebcad/src/App.consts.ts | 18 +++-- .../src/drawControllers/fit-view.ts | 47 +++++++++++ .../screenCanvas.drawController.ts | 62 +++++---------- .../src/inputController/input-controller.ts | 72 ++--------------- .../src/inputController/input-field-draw.ts | 79 +++++++++++++++++++ resources/tester/cad/theme-auto-color.test.ts | 43 ++++++++++ 6 files changed, 205 insertions(+), 116 deletions(-) create mode 100644 B07_DesignDetail/openwebcad/src/drawControllers/fit-view.ts create mode 100644 B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts diff --git a/B07_DesignDetail/openwebcad/src/App.consts.ts b/B07_DesignDetail/openwebcad/src/App.consts.ts index 63613edbf..a27ef6276 100644 --- a/B07_DesignDetail/openwebcad/src/App.consts.ts +++ b/B07_DesignDetail/openwebcad/src/App.consts.ts @@ -176,14 +176,20 @@ export const CANVAS_INPUT_FIELD_HEIGHT = 20; /** * Canvas input field background color - * 자동색(검정) — 어두운 바탕 검정 칸에 흰 글 · 밝은 바탕 흰 칸에 검정 글(`theme.ts` paintColor) + * 토큰 `--cad-chrome-raised` 못 읽을 때 폴백(어두움 값) — 칸 · 글 · 테두리는 토큰 색을 그대로 칠함 + * (자동색 뒤집기 `theme.ts` paintColor 건너뜀 · `inputController/input-field-draw.ts`) */ -export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#000000'; +export const CANVAS_INPUT_FIELD_BACKGROUND_COLOR = '#251f38'; /** - * Canvas input field text color + * Canvas input field border color — 토큰 `--cad-line` 폴백 */ -export const CANVAS_INPUT_FIELD_TEXT_COLOR = '#FFF'; +export const CANVAS_INPUT_FIELD_BORDER_COLOR = '#322a45'; + +/** + * Canvas input field text color — 토큰 `--cad-text` 폴백 + */ +export const CANVAS_INPUT_FIELD_TEXT_COLOR = '#e4e0f0'; /** * Canvas input field background color when text is selected @@ -201,9 +207,9 @@ export const CANVAS_INPUT_FIELD_SELECTION_TEXT_COLOR = '#000'; export const CANVAS_INPUT_FIELD_FONT_SIZE = 12; /** - * Canvas input field instruction text color + * Canvas input field instruction text color — 토큰 `--cad-text-dim` 폴백 */ -export const CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR = '#999'; +export const CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR = '#b3aec6'; /** * Multiplier to determine the pdf line width from the in application line width diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/fit-view.ts b/B07_DesignDetail/openwebcad/src/drawControllers/fit-view.ts new file mode 100644 index 000000000..bdfe2e259 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/drawControllers/fit-view.ts @@ -0,0 +1,47 @@ +/** 전체 맞춤 계산 — 맞출 개체 고르기 · 배율 · 화면 원점(`screenCanvas.drawController.ts` zoomToFitScreen 이 부름). */ +import { Point } from '@flatten-js/core'; +import type { Entity } from '../entities/Entity.ts'; +import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts'; +import { getEntities, getPickableEntities } from '../state.ts'; + +/** M02 가 구조물 도면에 얹는 도각 층 — 서버 `STRUCTURE_FRAME_LAYER` 와 같음 */ +const STRUCTURE_FRAME_LAYER_ID = 'm02-structure-frame'; + +/** 맞출 개체 */ +export function fitEntities(): Entity[] { + // 도각·원지반 같은 잠금 도면층은 빼고 설계 콘텐츠에만 맞춘다 (2026-08-31 사용자 + // 확정). 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 간다. + // 구조물 도면(M02·B08)은 얹은 도각(작도 틀)이 있으면 도각 전체에 맞춘다 — 옛 DXF 가 자기 도곽· + // 표제 띠를 품어 범위가 크면 도각이 화면 밖으로 밀린다. + const structureFrame = getEntities().filter((e) => e.layerId === STRUCTURE_FRAME_LAYER_ID); + const pickable = getPickableEntities(); + return structureFrame.length ? structureFrame : pickable.length ? pickable : getEntities(); +} + +/** 개체 범위를 화면 가운데에 놓는 배율 · 화면 원점(월드 좌표) */ +export function fitView(entities: Entity[], canvasSize: Point): { zoom: number; offset: Point } { + const boundingBox = getBoundingBoxOfMultipleEntities(entities); + const boundingWidth = boundingBox.maxX - boundingBox.minX; + const boundingHeight = boundingBox.maxY - boundingBox.minY; + + // 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지) + const FIT_MARGIN = 0.9; + const scaleX = + boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY; + const scaleY = + boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY; + let zoomLevel = Math.min(scaleX, scaleY); + if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1; + + // screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면 + // offset = worldCenter - (화면 절반 픽셀) / zoom + const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2; + const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2; + return { + zoom: zoomLevel, + offset: new Point( + worldCenterX - canvasSize.x / 2 / zoomLevel, + worldCenterY - canvasSize.y / 2 / zoomLevel + ), + }; +} diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 0388acdb3..54b115088 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -1,12 +1,12 @@ import { Point, type Vector } from '@flatten-js/core'; import { WHEEL_ZOOM_EXPONENT } from '../App.consts'; import { getAngleWithXAxis } from '../helpers/get-angle-with-x-axis.ts'; -import { getBoundingBoxOfMultipleEntities } from '../helpers/get-bounding-box-of-multiple-entities.ts'; import { mapNumberRange } from '../helpers/map-number-range.ts'; import { StateVariable } from '../helpers/undo-stack.ts'; -import { getEntities, getGridEnabled, getPickableEntities, triggerReactUpdate } from '../state.ts'; +import { getGridEnabled, triggerReactUpdate } from '../state.ts'; import { paintColor, themeColor } from '../theme.ts'; import { DEFAULT_TEXT_OPTIONS, type DrawController } from './DrawController'; +import { fitEntities, fitView } from './fit-view.ts'; /** 토큰을 못 읽을 때만 쓰는 캔버스 배경 폴백. */ const CANVAS_BACKGROUND_FALLBACK = '#111'; @@ -31,8 +31,6 @@ const CANVAS_BACKGROUND_FALLBACK = '#111'; * * To convert between the 2 coordinate systems, you need the screenOffset and screenScale */ -/** M02 가 구조물 도면에 얹는 도각 층 — 서버 `STRUCTURE_FRAME_LAYER` 와 같음 */ -const STRUCTURE_FRAME_LAYER_ID = 'm02-structure-frame'; // Batch-mode stroke decimation: skip chain segments shorter than this (screen px) const BATCH_LOD_PX = 0.5; // Batch-mode text smaller than this (screen px) is unreadable — skip drawing it @@ -150,17 +148,7 @@ export class ScreenCanvasDrawController implements DrawController { * 월드 좌표 offset에 그대로 대입해 중심 배치가 어긋나는 문제가 있었다.) */ public zoomToFitScreen() { - // 도각·원지반 같은 잠금 도면층은 빼고 설계 콘텐츠에만 맞춘다 (2026-08-31 사용자 - // 확정). 도각까지 넣으면 A1 한 장 전체가 잡혀 화면의 40%가 여백으로 간다. - // 구조물 도면(M02·B08)은 얹은 도각(작도 틀)이 있으면 도각 전체에 맞춘다 — 옛 DXF 가 자기 도곽· - // 표제 띠를 품어 범위가 크면 도각이 화면 밖으로 밀린다. - const structureFrame = getEntities().filter((e) => e.layerId === STRUCTURE_FRAME_LAYER_ID); - const pickable = getPickableEntities(); - const entities = structureFrame.length - ? structureFrame - : pickable.length - ? pickable - : getEntities(); + const entities = fitEntities(); if (!entities.length) { // 빈 도면 — 원점(0,0)을 화면 가운데로. 안 옮기면 기준점 십자가 왼쪽 아래 귀퉁이에 잘린다. const canvasSize = this.getCanvasSize(); @@ -169,31 +157,9 @@ export class ScreenCanvasDrawController implements DrawController { this.viewFitted = true; return; } - const boundingBox = getBoundingBoxOfMultipleEntities(entities); - const boundingWidth = boundingBox.maxX - boundingBox.minX; - const boundingHeight = boundingBox.maxY - boundingBox.minY; - const canvasSize = this.getCanvasSize(); - - // 10% 여백을 남기고 두 축 중 더 빡빡한 쪽에 맞춘다 (종횡비 유지) - const FIT_MARGIN = 0.9; - const scaleX = - boundingWidth > 0 ? (canvasSize.x * FIT_MARGIN) / boundingWidth : Number.POSITIVE_INFINITY; - const scaleY = - boundingHeight > 0 ? (canvasSize.y * FIT_MARGIN) / boundingHeight : Number.POSITIVE_INFINITY; - let zoomLevel = Math.min(scaleX, scaleY); - if (!Number.isFinite(zoomLevel) || zoomLevel <= 0) zoomLevel = 1; - this.setScreenScale(zoomLevel); - - // screen = (world - offset) * zoom 이므로, 도면 중심을 화면 중심에 맞추려면 - // offset = worldCenter - (화면 절반 픽셀) / zoom - const worldCenterX = (boundingBox.minX + boundingBox.maxX) / 2; - const worldCenterY = (boundingBox.minY + boundingBox.maxY) / 2; - this.setScreenOffset( - new Point( - worldCenterX - canvasSize.x / 2 / zoomLevel, - worldCenterY - canvasSize.y / 2 / zoomLevel - ) - ); + const { zoom, offset } = fitView(entities, this.getCanvasSize()); + this.setScreenScale(zoom); + this.setScreenOffset(offset); this.viewFitted = true; } @@ -577,6 +543,8 @@ export class ScreenCanvasDrawController implements DrawController { fontFamily: string; bold: boolean; italic: boolean; + /** 색을 그대로 칠함 — 자동색 뒤집기(paintColor) 건너뜀(테마 토큰 색) */ + rawColor: boolean; }> = {} ): void { const opts = { @@ -596,7 +564,7 @@ export class ScreenCanvasDrawController implements DrawController { this.context.rotate(angle); this.context.font = `${opts.italic ? 'italic ' : ''}${opts.bold ? 'bold ' : ''}${opts.fontSize}px ${opts.fontFamily}`; this.context.textAlign = opts.textAlign; - this.context.fillStyle = paintColor(opts.textColor); + this.context.fillStyle = opts.rawColor ? opts.textColor : paintColor(opts.textColor); this.context.textBaseline = 'middle'; this.context.fillText(label, 0, 0); this.context.restore(); @@ -673,11 +641,19 @@ export class ScreenCanvasDrawController implements DrawController { * @param width * @param height * @param color + * @param rawColor 색을 그대로 칠함 — 자동색 뒤집기(paintColor) 건너뜀(테마 토큰 색) */ - public fillRectScreen(xMin: number, yMin: number, width: number, height: number, color: string) { + public fillRectScreen( + xMin: number, + yMin: number, + width: number, + height: number, + color: string, + rawColor = false + ) { if (this.batching) this.flushBatch(); // TODO see if we need to replace this with a call to fillPolygon - this.context.fillStyle = paintColor(color); + this.context.fillStyle = rawColor ? color : paintColor(color); // 화면 좌표는 아래에서 위로 재는 규약이다(getCanvasPoint·drawLineScreen과 같다). // yMin은 사각형의 아래 변이므로 캔버스 y로 옮길 때 높이를 함께 빼야 제자리다. // 안 빼서 선택 그립이 선보다 8px 아래에 찍혔고, 줌을 해도 화면 픽셀 어긋남이라 diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts index acec2ed3e..8e8a27c61 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-controller.ts @@ -2,13 +2,6 @@ import { Point } from '@flatten-js/core'; import { compact, round } from 'es-toolkit'; import { Actor } from 'xstate'; import { - CANVAS_INPUT_FIELD_BACKGROUND_COLOR, - CANVAS_INPUT_FIELD_FONT_SIZE, - CANVAS_INPUT_FIELD_HEIGHT, - CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR, - CANVAS_INPUT_FIELD_MOUSE_OFFSET, - CANVAS_INPUT_FIELD_TEXT_COLOR, - CANVAS_INPUT_FIELD_WIDTH, PINCH_ZOOM_EXPONENT, SNAP_POINT_DISTANCE, WHEEL_LINE_PX, @@ -66,6 +59,7 @@ import { type TextInputEvent, } from '../tools/tool.types.ts'; import { activeToolAcceptsText, ImeSink, isImeKey } from './ime-sink.ts'; +import { drawInputField } from './input-field-draw.ts'; import { ABSOLUTE_POINT_REGEXP, ABSOLUTE_POLAR_REGEXP, @@ -127,36 +121,7 @@ export class InputController { } public draw(drawController: ScreenCanvasDrawController) { - const screenMouseLocation = drawController.getScreenMouseLocation(); - - // draw input field - drawController.fillRectScreen( - screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET, - // fillRectScreen이 아래 변 기준으로 바로잡혔으므로, 칸이 있던 자리를 지키려고 - // 높이만큼 내려 잡는다. - screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT, - CANVAS_INPUT_FIELD_WIDTH, - CANVAS_INPUT_FIELD_HEIGHT, - CANVAS_INPUT_FIELD_BACKGROUND_COLOR - ); this.ime.sync(activeToolAcceptsText()); - // Draw text in input field (조합 중인 한글도 함께) - if (this.text || this.ime.composing) { - drawController.drawTextScreen( - this.text + this.ime.composing, - new Point( - screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2, - screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT - 2 - ), - { - textAlign: 'left', - textColor: CANVAS_INPUT_FIELD_TEXT_COLOR, - fontSize: CANVAS_INPUT_FIELD_FONT_SIZE, - } - ); - } - - const matchingCommands = this.getCommandSuggestions(); const toolInstruction = getLastStateInstructions(); const texts: string[] = []; if (toolInstruction) { @@ -166,11 +131,10 @@ export class InputController { const roundedY = round(drawController.getWorldMouseLocation().y, 2); texts.push(`${roundedX},${roundedY}`); } - if (matchingCommands.length) { - // 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO) - texts.push(...matchingCommands); - } - this.drawListBelowInputField(drawController, texts); + // 입력 중인 문자로 시작하는 명령 후보. 예: C => CIRCLE (C), COPY (CO) + texts.push(...this.getCommandSuggestions()); + // 입력 글(조합 중인 한글도 함께) + drawInputField(drawController, this.text + this.ime.composing, texts); } public submitText(value: string) { @@ -661,32 +625,6 @@ export class InputController { type: ActorEvent.ESC, }); } - - private drawListBelowInputField( - drawController: ScreenCanvasDrawController, - texts: string[] - ): void { - const screenMouseLocation = drawController.worldToTarget( - drawController.getWorldMouseLocation() - ); - const startY = - screenMouseLocation.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT * 2 - 2; - const offsetY = CANVAS_INPUT_FIELD_HEIGHT; - texts.forEach((text, index) => { - drawController.drawTextScreen( - text, - new Point( - screenMouseLocation.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET + 2, - startY - index * offsetY - ), - { - textAlign: 'left', - textColor: CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR, - fontSize: CANVAS_INPUT_FIELD_FONT_SIZE, - } - ); - }); - } } /** diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts b/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts new file mode 100644 index 000000000..f7e8a81c8 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts @@ -0,0 +1,79 @@ +/** + * 커서 따라다니는 입력창 — 캔버스에 직접 그림(`input-controller.ts` draw 가 부름). + * 칸 · 글은 테마 토큰 색을 그대로 칠함(자동색 뒤집기 건너뜀) — 명령줄 후보 상자(`App.css` + * `.cad-command-suggestions`)와 같은 색. + */ +import { Point } from '@flatten-js/core'; +import { + CANVAS_INPUT_FIELD_BACKGROUND_COLOR, + CANVAS_INPUT_FIELD_BORDER_COLOR, + CANVAS_INPUT_FIELD_FONT_SIZE, + CANVAS_INPUT_FIELD_HEIGHT, + CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR, + CANVAS_INPUT_FIELD_MOUSE_OFFSET, + CANVAS_INPUT_FIELD_TEXT_COLOR, + CANVAS_INPUT_FIELD_WIDTH, +} from '../App.consts.ts'; +import type { ScreenCanvasDrawController } from '../drawControllers/screenCanvas.drawController.ts'; +import { themeColor } from '../theme.ts'; + +/** 입력창 색 — 밝음 = 흰 칸 + 테두리 · 어두움 = 짙은 보라 칸 · 글 = CAD 글 색 */ +export function inputFieldColors(read: (name: string, fallback: string) => string = themeColor) { + return { + background: read('--cad-chrome-raised', CANVAS_INPUT_FIELD_BACKGROUND_COLOR), + border: read('--cad-line', CANVAS_INPUT_FIELD_BORDER_COLOR), + text: read('--cad-text', CANVAS_INPUT_FIELD_TEXT_COLOR), + dimText: read('--cad-text-dim', CANVAS_INPUT_FIELD_INSTRUCTION_TEXT_COLOR), + }; +} + +/** 입력 글 칸 + 그 아래 안내 · 좌표 · 명령 후보 줄 */ +export function drawInputField( + drawController: ScreenCanvasDrawController, + input: string, + lines: string[] +): void { + const mouse = drawController.getScreenMouseLocation(); + const colors = inputFieldColors(); + const left = mouse.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET; + // 화면 좌표는 위가 + — fillRectScreen 의 y 는 칸 아래 변 + const bottom = mouse.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT; + const width = CANVAS_INPUT_FIELD_WIDTH; + drawController.fillRectScreen( + left - 1, + bottom - 1, + width + 2, + CANVAS_INPUT_FIELD_HEIGHT + 2, + colors.border, + true + ); + drawController.fillRectScreen( + left, + bottom, + width, + CANVAS_INPUT_FIELD_HEIGHT, + colors.background, + true + ); + const textOptions = { + textAlign: 'left' as const, + fontSize: CANVAS_INPUT_FIELD_FONT_SIZE, + rawColor: true, + }; + if (input) { + drawController.drawTextScreen(input, new Point(left + 2, bottom - 2), { + ...textOptions, + textColor: colors.text, + }); + } + lines.forEach((line, index) => { + drawController.drawTextScreen( + line, + new Point( + left + 2, + bottom - CANVAS_INPUT_FIELD_HEIGHT - 2 - index * CANVAS_INPUT_FIELD_HEIGHT + ), + { ...textOptions, textColor: colors.dimText } + ); + }); +} diff --git a/resources/tester/cad/theme-auto-color.test.ts b/resources/tester/cad/theme-auto-color.test.ts index a10e809f5..34422b7ca 100644 --- a/resources/tester/cad/theme-auto-color.test.ts +++ b/resources/tester/cad/theme-auto-color.test.ts @@ -37,4 +37,47 @@ assert.equal(paintColor('#f5f7fa'), '#000000', '밝은 바탕: 기본색은 검 assert.equal(paintColor('#ff0000'), '#ff0000', '밝은 바탕: 빨강 그대로'); assert.notEqual(paintColor('#ffff00'), '#ffff00', '밝은 바탕: 노랑 등 밝은 색은 대비 보정(기존)'); +// 커서 입력창 — 칸 · 글 색이 두 테마 모두 달라야(자동색 둘을 쓰면 paintColor 가 같은 색으로 뒤집음) +const { inputFieldColors } = await import( + '../../../B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts' +); +const TOKENS: Record> = { + light: { + '--cad-chrome-raised': '#ffffff', + '--cad-line': '#e6e2e3', + '--cad-text': '#333333', + '--cad-text-dim': '#615e6e', + }, + dark: { + '--cad-chrome-raised': '#251f38', + '--cad-line': '#322a45', + '--cad-text': '#e4e0f0', + '--cad-text-dim': '#b3aec6', + }, +}; +for (const name of ['light', 'dark']) { + theme = name; + const colors = inputFieldColors( + (token: string, fallback: string) => TOKENS[name][token] ?? fallback + ); + assert.equal( + colors.background, + TOKENS[name]['--cad-chrome-raised'], + `${name}: 칸 = 토큰 색 그대로` + ); + assert.notEqual(colors.background, colors.text, `${name}: 칸 · 글 색 다름`); + assert.notEqual(colors.background, colors.dimText, `${name}: 칸 · 안내 글 색 다름`); + assert.notEqual(colors.background, colors.border, `${name}: 칸 · 테두리 색 다름`); + // 옛 상수 짝(#000000 칸 · #FFF 글)은 paintColor 를 거치면 같은 색 — 입력창은 건너뛰어야 + assert.equal( + paintColor('#000000'), + paintColor('#FFF'), + `${name}: 자동색 두 개는 같은 색으로 뒤집힘` + ); +} +// 토큰 못 읽으면 폴백도 칸 · 글 다름 +const fallback = inputFieldColors((_token: string, value: string) => value); +assert.notEqual(fallback.background, fallback.text, '폴백: 칸 · 글 색 다름'); +assert.ok(!isAutoColor(fallback.background) && !isAutoColor(fallback.text), '폴백: 자동색 아님'); + console.log('theme 자동색 시험 통과'); From e020cf71aa7c259d09cc8e75a343250b56b90b91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:53:24 +0900 Subject: [PATCH 07/13] =?UTF-8?q?fix(B07-CAD):=20=EC=BB=A4=EC=84=9C=20?= =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=B0=BD=20=EA=B8=80=EC=9D=B4=20=EC=B9=B8=20?= =?UTF-8?q?=EC=95=88=20=E2=80=94=20=EC=A4=84=20=EC=84=B8=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EC=9A=B4=EB=8D=B0=20=C2=B7=20=EC=B9=B8=20=ED=8F=AD=20?= =?UTF-8?q?=3D=20=EA=B0=80=EC=9E=A5=20=EA=B8=B4=20=EC=A4=84=20=C2=B7=20?= =?UTF-8?q?=ED=9B=84=EB=B3=B4=20=EB=AA=A9=EB=A1=9D=EB=8F=84=20=EB=B0=94?= =?UTF-8?q?=ED=83=95=20=EC=95=88(97-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 글 중심 = 줄 세로 가운데(옛 칸 아래 변 2px 밑) - 칸 폭 = max(150, 가장 긴 줄 글 폭 + 여백 8) · measureTextScreen(measureText 감쌈) - 안내 · 좌표 · 명령 후보(최대 6줄)도 바탕 한 장 안 · 입력 줄 아래 가는 줄 - 그리기 시험: 글 중심 · 12px 글 전체가 칸 안 · 폭 · 최소 폭 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_015KYZ2HWWdUwZWYnn3tQz7X --- .../screenCanvas.drawController.test.ts | 52 ++++++++++++++++- .../screenCanvas.drawController.ts | 9 +++ .../src/inputController/input-field-draw.ts | 57 +++++++++++-------- 3 files changed, 92 insertions(+), 26 deletions(-) diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts index e1e6e1fbe..4e1418c52 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.test.ts @@ -3,6 +3,7 @@ import { beforeAll, describe, expect, it } from 'vitest'; import { LineEntity } from '../entities/LineEntity'; import type { Layer } from '../App.types'; import { setEntities, setLayers } from '../state'; +import { drawInputField } from '../inputController/input-field-draw'; import { ScreenCanvasDrawController } from './screenCanvas.drawController'; /** translate·drawImage 인자만 기록하는 최소 2d 컨텍스트. */ @@ -12,12 +13,19 @@ function recordingContext() { rotate: [], image: [], }; + const rects: number[][] = []; + const texts: { label: string; at: number[] }[] = []; const context = { translate: (x: number, y: number) => calls.translate.push([x, y]), rotate: (a: number) => calls.rotate.push(a), drawImage: (_img: unknown, ...rest: number[]) => calls.image.push(...rest), + save: () => {}, + restore: () => {}, + fillRect: (...rect: number[]) => rects.push(rect), + fillText: (label: string) => texts.push({ label, at: calls.translate.at(-1) ?? [] }), + measureText: (label: string) => ({ width: label.length * 7 }), } as unknown as CanvasRenderingContext2D; - return { context, calls }; + return { context, calls, rects, texts }; } describe('ScreenCanvasDrawController.drawImage', () => { @@ -71,3 +79,45 @@ describe('ScreenCanvasDrawController.zoomToFitScreen', () => { } }); }); + +describe('커서 입력창(drawInputField)', () => { + it('글 중심이 칸 안 · 긴 줄에 맞춰 칸 폭이 늘고 목록도 바탕 안', () => { + const g = globalThis as Record; + g.document = { documentElement: {} }; + g.getComputedStyle = () => ({ getPropertyValue: () => '' }); + const { context, rects, texts } = recordingContext(); + const controller = new ScreenCanvasDrawController(context); + controller.setCanvasSize(new Point(800, 600)); + controller.setScreenMouseLocation(new Point(100, 300)); + const lines = ['LINE (L) 선', 'LINETYPE (LT) 선종류 — 아주 긴 이름의 명령 후보 줄']; + drawInputField(controller, 'LIN', lines); + + const [border, box, divider] = rects; // [x, 캔버스 y(위 변), w, h] + expect(border).toEqual([119, 319, box[2] + 2, box[3] + 2]); + expect(box[0]).toBe(120); + expect(box[1]).toBe(600 - 280); // 커서 아래 20px + expect(box[3]).toBe(20 * 3); // 입력 줄 + 목록 두 줄 + expect(box[2]).toBe(Math.ceil(lines[1].length * 7) + 8); // 가장 긴 줄 + 여백 + expect(divider).toEqual([120, 339, box[2], 1]); // 입력 줄 아래 끝 한 줄 + + expect(texts.map((text) => text.label)).toEqual(['LIN', ...lines]); + texts.forEach((text, row) => { + const [x, y] = text.at; + expect(x).toBe(124); + // 글 중심 = 그 줄 세로 가운데 · 12px 글 전체가 칸 안 + expect(y).toBe(box[1] + 20 * row + 10); + expect(y - 6).toBeGreaterThanOrEqual(box[1]); + expect(y + 6).toBeLessThanOrEqual(box[1] + box[3]); + }); + }); + + it('빈 입력 · 짧은 글은 최소 폭 150 · 한 줄', () => { + const { context, rects } = recordingContext(); + const controller = new ScreenCanvasDrawController(context); + controller.setCanvasSize(new Point(800, 600)); + controller.setScreenMouseLocation(new Point(100, 300)); + drawInputField(controller, '', []); + expect(rects).toHaveLength(2); + expect(rects[1]).toEqual([120, 320, 150, 20]); + }); +}); diff --git a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts index 54b115088..d48309e2e 100644 --- a/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts +++ b/B07_DesignDetail/openwebcad/src/drawControllers/screenCanvas.drawController.ts @@ -525,6 +525,15 @@ export class ScreenCanvasDrawController implements DrawController { }); } + /** 화면 글 폭(px) — drawTextScreen 과 같은 글꼴로 잼 */ + public measureTextScreen(label: string, fontSize: number): number { + this.context.save(); + this.context.font = `${fontSize}px ${DEFAULT_TEXT_OPTIONS.fontFamily}`; + const width = this.context.measureText(label).width; + this.context.restore(); + return width; + } + /** * Draw some text at the base location * The direction vector points from the bottom of the first letter towards the bottom of the last letter indicate the rotation of the text diff --git a/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts b/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts index f7e8a81c8..ab2d70848 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/input-field-draw.ts @@ -27,7 +27,13 @@ export function inputFieldColors(read: (name: string, fallback: string) => strin }; } -/** 입력 글 칸 + 그 아래 안내 · 좌표 · 명령 후보 줄 */ +/** 글 좌우 여백(px) */ +const INPUT_FIELD_PADDING = 4; + +/** + * 입력 글 칸 + 그 아래 안내 · 좌표 · 명령 후보 줄 — 바탕 한 장(명령줄 후보 상자 꼴). + * 줄마다 높이 CANVAS_INPUT_FIELD_HEIGHT · 글 중심 = 줄 세로 가운데 · 폭 = max(최소 폭, 가장 긴 줄 + 여백). + */ export function drawInputField( drawController: ScreenCanvasDrawController, input: string, @@ -35,33 +41,34 @@ export function drawInputField( ): void { const mouse = drawController.getScreenMouseLocation(); const colors = inputFieldColors(); + const rowHeight = CANVAS_INPUT_FIELD_HEIGHT; + const fontSize = CANVAS_INPUT_FIELD_FONT_SIZE; const left = mouse.x + CANVAS_INPUT_FIELD_MOUSE_OFFSET; - // 화면 좌표는 위가 + — fillRectScreen 의 y 는 칸 아래 변 - const bottom = mouse.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET - CANVAS_INPUT_FIELD_HEIGHT; - const width = CANVAS_INPUT_FIELD_WIDTH; + // 화면 좌표는 위가 + — 칸 위 변이 커서 아래 OFFSET · fillRectScreen 의 y 는 아래 변 + const top = mouse.y - CANVAS_INPUT_FIELD_MOUSE_OFFSET; + const height = rowHeight * (1 + lines.length); + const textWidth = Math.max( + 0, + ...[input, ...lines].map((text) => drawController.measureTextScreen(text, fontSize)) + ); + const width = Math.max(CANVAS_INPUT_FIELD_WIDTH, Math.ceil(textWidth) + INPUT_FIELD_PADDING * 2); drawController.fillRectScreen( left - 1, - bottom - 1, + top - height - 1, width + 2, - CANVAS_INPUT_FIELD_HEIGHT + 2, + height + 2, colors.border, true ); - drawController.fillRectScreen( - left, - bottom, - width, - CANVAS_INPUT_FIELD_HEIGHT, - colors.background, - true - ); - const textOptions = { - textAlign: 'left' as const, - fontSize: CANVAS_INPUT_FIELD_FONT_SIZE, - rawColor: true, - }; + drawController.fillRectScreen(left, top - height, width, height, colors.background, true); + if (lines.length) { + // 입력 줄과 목록 사이 가는 줄 + drawController.fillRectScreen(left, top - rowHeight, width, 1, colors.border, true); + } + const textOptions = { textAlign: 'left' as const, fontSize, rawColor: true }; + const rowCenter = (row: number) => top - rowHeight * row - rowHeight / 2; if (input) { - drawController.drawTextScreen(input, new Point(left + 2, bottom - 2), { + drawController.drawTextScreen(input, new Point(left + INPUT_FIELD_PADDING, rowCenter(0)), { ...textOptions, textColor: colors.text, }); @@ -69,11 +76,11 @@ export function drawInputField( lines.forEach((line, index) => { drawController.drawTextScreen( line, - new Point( - left + 2, - bottom - CANVAS_INPUT_FIELD_HEIGHT - 2 - index * CANVAS_INPUT_FIELD_HEIGHT - ), - { ...textOptions, textColor: colors.dimText } + new Point(left + INPUT_FIELD_PADDING, rowCenter(index + 1)), + { + ...textOptions, + textColor: colors.dimText, + } ); }); } From a632d4ef6b13622ec097d00dd5931b1d65cec980 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:53:36 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix(B08):=20=EC=88=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=86=92=EC=9D=B4=20100vh=20=E2=88=92=202?= =?UTF-8?q?40px=20=EB=A1=9C=20=EB=A7=9E=EC=B6=A4(95-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- B08_Quantity/B08_Quantity_UI_Hydro.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.css b/B08_Quantity/B08_Quantity_UI_Hydro.css index eb22af05b..0619750ab 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.css +++ b/B08_Quantity/B08_Quantity_UI_Hydro.css @@ -43,7 +43,7 @@ .b08-hydro__card { display: flex; flex-direction: column; - max-height: calc(100vh - 300px); + max-height: calc(100vh - 240px); } .b08-hydro__card .ui-card__body { From 3048dd96ddbdd0fbed4b24ab4d26b790b5bf838a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:54:15 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat(B07):=20=EC=9A=A9=EC=A7=80=EB=8F=84?= =?UTF-8?q?=20=3D=20=ED=8F=89=EB=A9=B4=EB=8F=84=20=EC=A2=85=EB=A5=98=20?= =?UTF-8?q?=C2=B7=20=ED=95=84=EC=A7=80=20=EC=83=81=EC=9E=90=20=EB=9D=BC?= =?UTF-8?q?=EB=B2=A8=20=EB=85=B8=EC=84=A0=20=EB=B9=84=ED=82=B4=20=C2=B7=20?= =?UTF-8?q?=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD=EB=8F=84=20=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=EB=AC=B6=EC=9D=8C=20=C2=B7=20=EC=A0=9C?= =?UTF-8?q?=EB=AA=A9=20=ED=8F=89=EB=A9=B4=EB=8F=84(93-1~93-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 조각 넷(필지 경계 · 필지 정보 · 읍면동 경계 · 시군구 경계) · 기본 종류 「용지도」 · 옛 종류 폴더에 한 번 더함 - 필지 라벨 = (필지 ∩ 도곽) − 노선 버퍼 대표점 + 상자 · 지시선 · 노선 · 상자끼리 안 겹침(새 Router_Support_Landuse) - 옛 따로 용지도 길(landuse_source · build_landuse_drawing) 지움 - 배수유역도 목록 항목에 plan_kind 실음 · 종류 제목 「평면도(…)」 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_Engine_Cad_Landuse.py | 372 ------------------ .../B07_DesignDetail_Engine_Cad_MassHaul.py | 38 +- .../B07_DesignDetail_Engine_Cad_PlanKinds.py | 42 ++ B07_DesignDetail/B07_DesignDetail_Router.py | 11 - .../B07_DesignDetail_Router_Support.py | 50 +-- .../B07_DesignDetail_Router_Support_Basin.py | 115 ------ ...B07_DesignDetail_Router_Support_Landuse.py | 204 ++++++++++ .../B07_DesignDetail_Router_Support_Plan.py | 41 +- .../tester/test_b07_landuse_parcel_info.py | 198 +++++++--- resources/tester/test_b07_plan_kinds.py | 63 ++- 10 files changed, 512 insertions(+), 622 deletions(-) delete mode 100644 B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py create mode 100644 B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py deleted file mode 100644 index 806d3c1b3..000000000 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_Landuse.py +++ /dev/null @@ -1,372 +0,0 @@ -"""B07 용지도 CAD 조립 — 수치등고선 배경 위에 연속지적도·행정구역을 얹는다. - -사용자 지시(2026-09-04) — 「용지도는 계획평면도와 같이 수치등고선을 배경으로 하고 -연속지적도·시군구·읍면동을 얹을 것. 색상은 변경하고, 배수유역도의 표 자리에 범례를 -넣을 것. 연속지적도에 지번 정보가 있는지 확인하고, 없으면 일단 그림만」. - -지번은 있다(2026-09-04 실측: 저장된 연속지적도 GeoJSON 필지마다 `jibun`·`jimok`· -`parea`·`owner_nm` + 시도·시군구·읍면동·리 이름). 필지마다 **지번·지목·면적·소유자** -네 줄을 적는다(사용자: 「연속지적도에 해당 정보를 띄우고 판단」) — 빈 값은 그 줄만 비운다. - -축척·도곽·장 나눔은 계획평면도와 **같다**(1/1,200 고정). 배경도 같은 창구를 쓴다. - -좌표 규약: 종이 mm = (사업지 좌표 m - 그 장 콘텐츠 최소점) x MM (1/1,200 -> 1 m = 5/6 mm). -""" - -from typing import Any - -from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( - DRAWING_FORMAT, - FRAME_LAYER_ID, - TABLE_LABEL_COLOR, - _layer, - _text_entity, - polyline_entity, -) -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( - _COMPASS_MARGIN, - _COMPASS_SIZE, - _FONT_SIZE, - _TITLE_FONT_SIZE, - MM, - plan_area_mm, -) -from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( - compass_entities, - entities_bbox, - frame_entities, - scale_fields, -) -from config.config_system import DRAWING_SCALE_PLAN - -LANDUSE_KIND = "landuse" -LANDUSE_LABEL = "용지도" - -CONTOUR_LAYER_ID = "b07-landuse-contour" -PARCEL_LAYER_ID = "b07-landuse-parcel" -JIBUN_LAYER_ID = "b07-landuse-jibun" -EMD_LAYER_ID = "b07-landuse-emd" -SGG_LAYER_ID = "b07-landuse-sgg" -ROUTE_LAYER_ID = "b07-landuse-route" -LEGEND_LAYER_ID = "b07-landuse-legend" -TITLE_LAYER_ID = "b07-landuse-title" - -# 도면용 색 — 화면용(유역도)보다 **가라앉힌** 색을 쓴다. 지적 경계가 주제이므로 배경 -# 등고선은 가장 옅게, 행정 경계는 굵고 진하게 가른다(2026-09-04 사용자 「색상은 변경」). -CONTOUR_COLOR = "#9aa3ad" -PARCEL_COLOR = "#8c6b4f" -JIBUN_COLOR = "#5c4632" -EMD_COLOR = "#2f7d4f" -SGG_COLOR = "#a63d3d" -ROUTE_COLOR = "#ffe066" -LEGEND_COLOR = TABLE_LABEL_COLOR - -_ROUTE_WIDTH = 3 -_SGG_WIDTH = 3 -_EMD_WIDTH = 2 -_JIBUN_FONT_SIZE = 1.8 -_INFO_LINE_GAP = _JIBUN_FONT_SIZE * 1.3 # 필지 정보 줄 간격(mm) -_LEGEND_FONT_SIZE = 2.4 - -_LEGEND_ROW_H = 6.0 -_LEGEND_SAMPLE_W = 12.0 -_LEGEND_GAP = 3.0 -_LEGEND_TOP_GAP = 8.0 - -# 범례 항목 (표기 이름, 색, 선굵기, 파선). -_LEGEND_ROWS: tuple[tuple[str, str, int, list[int] | None], ...] = ( - ("계획노선", ROUTE_COLOR, _ROUTE_WIDTH, None), - ("필지 경계", PARCEL_COLOR, 1, None), - ("읍면동·리 경계", EMD_COLOR, _EMD_WIDTH, [8, 4]), - ("시군구 경계", SGG_COLOR, _SGG_WIDTH, [14, 5, 3, 5]), - ("등고선", CONTOUR_COLOR, 1, None), -) - - -def _ring_center(ring: list[tuple[float, float]]) -> tuple[float, float]: - """고리의 bbox 중심 — 오목한 필지에서도 글자가 도면 밖으로 튀지 않는다.""" - xs = [x for x, _y in ring] - ys = [y for _x, y in ring] - return ((min(xs) + max(xs)) / 2.0, (min(ys) + max(ys)) / 2.0) - - -def _parcel_info_lines(props: dict[str, Any]) -> list[str]: - """지번·지목·면적·소유자 네 줄 — 값이 없으면 그 줄은 빈 문자열.""" - - def text(value: Any) -> str: - return value.strip() if isinstance(value, str) else "" - - area = props.get("parea") - try: - area_text = f"{float(area):,.0f}㎡" if area not in (None, "") else "" - except (TypeError, ValueError): - area_text = text(area) - return [ - text(props.get("jibun")), - text(props.get("jimok")), - area_text, - text(props.get("owner_nm")), - ] - - -def _enclosing_note(props: dict[str, Any]) -> str: - """구역 전체가 한 필지 안일 때 정보 묶음 맨 위 줄 — 소재지(시군구 읍면동 리)를 붙인다.""" - place = " ".join( - str(props[key]).strip() - for key in ("sgg_nm", "emd_nm", "ri_nm") - if isinstance(props.get(key), str) and props[key].strip() - ) - return f"구역 전체가 이 필지 안 ({place})" if place else "구역 전체가 이 필지 안" - - -def _legend_entities(drawing_id: str, origin: tuple[float, float]) -> list[dict[str, Any]]: - """범례 — 유역도에서 유역 정보표가 있던 자리(오른쪽 칸)에 놓는다.""" - entities: list[dict[str, Any]] = [] - x, y = origin - entities.append( - _text_entity( - f"{drawing_id}:legend:title", - "범 례", - x + _LEGEND_SAMPLE_W / 2.0 + 6.0, - y, - LEGEND_LAYER_ID, - _LEGEND_FONT_SIZE + 0.6, - LEGEND_COLOR, - ) - ) - for index, (label, color, width, dash) in enumerate(_LEGEND_ROWS): - row_y = y - _LEGEND_TOP_GAP - index * _LEGEND_ROW_H - sample = polyline_entity( - drawing_id, - [(x, row_y), (x + _LEGEND_SAMPLE_W, row_y)], - LEGEND_LAYER_ID, - color, - suffix=f":legend:{index}", - dash=dash, - width=width, - ) - if sample: - entities.append(sample) - entities.append( - _text_entity( - f"{drawing_id}:legend:label:{index}", - label, - x + _LEGEND_SAMPLE_W + _LEGEND_GAP, - row_y, - LEGEND_LAYER_ID, - _LEGEND_FONT_SIZE, - LEGEND_COLOR, - align="left", - ) - ) - return entities - - -def _boundary_entities( - drawing_id: str, - rings: list[list[tuple[float, float]]], - layer_id: str, - color: str, - width: int, - dash: list[int] | None, - paper: Any, - tag: str, -) -> list[dict[str, Any]]: - entities: list[dict[str, Any]] = [] - for index, ring in enumerate(rings): - line = polyline_entity( - drawing_id, - [paper(point) for point in ring], - layer_id, - color, - suffix=f":{tag}:{index}", - dash=dash, - width=width, - ) - if line: - entities.append(line) - return entities - - -def build_landuse_drawing( - drawing_id: str, - label: str, - route_xy: list[tuple[float, float]], - contours: list[list[tuple[float, float]]], - parcels: list[dict[str, Any]], - emd_rings: list[list[tuple[float, float]]], - sgg_rings: list[list[tuple[float, float]]], -) -> dict[str, Any]: - """용지도 한 장을 만든다. 좌표는 모두 사업지 CRS(m)로 받아 종이 mm로만 옮긴다. - - `parcels`는 {"ring": [(x, y)...], "props": {지적 속성}} 목록이다. 도곽에 걸친 필지는 - 라우터가 잘라 넘기므로 고리가 아니라 **열린 선**일 수 있다. - """ - everything = [ - *route_xy, - *(point for line in contours for point in line), - *(point for parcel in parcels for point in parcel.get("ring") or []), - ] - if not everything: - raise FileNotFoundError( - "용지도에 그릴 좌표가 없습니다. B04 전처리에서 연속지적도·수치지형도를 먼저 받으세요." - ) - min_x = min(x for x, _y in everything) - min_y = min(y for _x, y in everything) - - def paper(point: tuple[float, float]) -> tuple[float, float]: - return ((point[0] - min_x) * MM, (point[1] - min_y) * MM) - - entities: list[dict[str, Any]] = [] - # 배경 등고선이 가장 아래 — 지적 경계가 주제라 옅게 깐다. - entities.extend( - _boundary_entities( - drawing_id, contours, CONTOUR_LAYER_ID, CONTOUR_COLOR, 1, None, paper, "contour" - ) - ) - # 필지 경계 + 필지 정보. 도곽에 잘려 조각이 여럿인 필지도 정보는 한 번만 — - # 가장 큰 조각 가운데에 적는다(구역 안 필지 수 = 정보 묶음 수). - spots: dict[str, tuple[float, tuple[float, float], dict[str, Any]]] = {} - enclosing: set[str] = set() # 도곽을 통째로 감싼 필지 - for index, parcel in enumerate(parcels): - ring = parcel.get("ring") or [] - props = parcel.get("props") or {} - label_at = parcel.get("label_at") - if len(ring) >= 2: - paper_ring = [paper(point) for point in ring] - outline = polyline_entity( - drawing_id, paper_ring, PARCEL_LAYER_ID, PARCEL_COLOR, suffix=f":parcel:{index}" - ) - if outline: - entities.append(outline) - width = max(x for x, _y in paper_ring) - min(x for x, _y in paper_ring) - height = max(y for _x, y in paper_ring) - min(y for _x, y in paper_ring) - size, center = width * height, _ring_center(paper_ring) - elif label_at is not None: - # 도곽을 통째로 감싼 필지 — 경계선이 없으니 지정된 자리에 정보만 적는다. - size, center = 0.0, paper(tuple(label_at)) - enclosing.add(str(props.get("pnu") or f"#{index}")) - else: - continue - key = str(props.get("pnu") or f"#{index}") - if key not in spots or size > spots[key][0]: - spots[key] = (size, center, props) - # 필지 경계가 하나도 안 보이면(구역이 한 필지 안) 어느 필지인지 글로 밝힌다(68-2). - no_outline = not any(len(parcel.get("ring") or []) >= 2 for parcel in parcels) - jibun: list[dict[str, Any]] = [] - for key, (_size, center, props) in spots.items(): - lines = _parcel_info_lines(props) - if no_outline and key in enclosing: - lines = [_enclosing_note(props), *lines] - top = center[1] + (len(lines) - 1) / 2.0 * _INFO_LINE_GAP - for row, text in enumerate(lines): - if text: - jibun.append( - _text_entity( - f"{drawing_id}:parcel-info:{key}:{row}", - text, - center[0], - top - row * _INFO_LINE_GAP, - JIBUN_LAYER_ID, - _JIBUN_FONT_SIZE, - JIBUN_COLOR, - ) - ) - # 행정 경계는 필지 위에, 노선은 그 위에 — 아래에 깔리면 필지 선에 묻힌다. - entities.extend( - _boundary_entities( - drawing_id, emd_rings, EMD_LAYER_ID, EMD_COLOR, _EMD_WIDTH, [8, 4], paper, "emd" - ) - ) - entities.extend( - _boundary_entities( - drawing_id, - sgg_rings, - SGG_LAYER_ID, - SGG_COLOR, - _SGG_WIDTH, - [14, 5, 3, 5], - paper, - "sgg", - ) - ) - map_bbox = entities_bbox(entities) - route = polyline_entity( - drawing_id, - [paper(point) for point in route_xy], - ROUTE_LAYER_ID, - ROUTE_COLOR, - width=_ROUTE_WIDTH, - ) - if route: - entities.append(route) - entities.extend(jibun) # 필지 정보는 가장 위 — 선에 가리면 못 읽는다. - - # 오른쪽 칸: 방위표가 맨 위, 그 아래로 범례(유역도에서 유역 정보표가 있던 자리). - if map_bbox: - column_x = map_bbox[2] + _COMPASS_MARGIN - column_top = map_bbox[3] - entities.extend( - compass_entities( - drawing_id, - (column_x + _COMPASS_SIZE / 2.0, column_top - _COMPASS_SIZE / 2.0), - _COMPASS_SIZE, - ) - ) - entities.extend(_legend_entities(drawing_id, (column_x, column_top - _COMPASS_SIZE - 10.0))) - - bbox = entities_bbox(entities) - if bbox: - min_bx, _min_by, max_bx, max_by = bbox - entities.append( - _text_entity( - f"{drawing_id}:title", - label, - (min_bx + max_bx) / 2.0, - max_by + 12.0, - TITLE_LAYER_ID, - _TITLE_FONT_SIZE, - TABLE_LABEL_COLOR, - ) - ) - entities.append( - _text_entity( - f"{drawing_id}:scale", - f"S = 1/{DRAWING_SCALE_PLAN:,}", - max_bx, - max_by + 5.0, - TITLE_LAYER_ID, - _FONT_SIZE, - TABLE_LABEL_COLOR, - align="right", - ) - ) - entities.extend( - frame_entities( - drawing_id, - entities_bbox(entities) or bbox, - fit=False, - fields={"도면명": label, **scale_fields(("", DRAWING_SCALE_PLAN))}, - ) - ) - - return { - "format": DRAWING_FORMAT, - "entities": entities, - "layers": [ - _layer(CONTOUR_LAYER_ID, "등고선", locked=True), - _layer(PARCEL_LAYER_ID, "필지 경계"), - _layer(JIBUN_LAYER_ID, "필지 정보(지번·지목·면적·소유자)"), - _layer(EMD_LAYER_ID, "읍면동·리 경계"), - _layer(SGG_LAYER_ID, "시군구 경계"), - _layer(ROUTE_LAYER_ID, "계획노선"), - _layer(LEGEND_LAYER_ID, "범례"), - _layer(TITLE_LAYER_ID, "표제"), - _layer(FRAME_LAYER_ID, "도각", locked=True), - ], - } - - -def landuse_area_mm() -> tuple[float, float]: - """지적 배경이 차지할 수 있는 크기(mm) — 계획평면도와 같다(같은 축척·같은 도곽).""" - return plan_area_mm() diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py index ec9f25f23..c749dac21 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_MassHaul.py @@ -179,6 +179,14 @@ def _axis_entities(drawing_id: str, x0: float, min_v: float, max_v: float) -> li return entities +def balloon_size(lines: list[str], font: float = _FONT_SIZE) -> tuple[float, float]: + """balloon (폭, 높이) mm — 글 크기에 비례 · 한글 등 넓은 글자는 1.65 칸.""" + scale = font / _FONT_SIZE + chars = max((sum(1.65 if ord(c) > 0x2E7F else 1.0 for c in line) for line in lines), default=0) + width = (chars * _BALLOON_CHAR_W + 2 * _BALLOON_PAD_X) * scale + return width, (len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y) * scale + + def _balloon_entities( drawing_id: str, seed: str, @@ -186,10 +194,14 @@ def _balloon_entities( anchor: tuple[float, float], center: tuple[float, float], shape: str, + layer: str = BAND_LAYER_ID, + color: str = BAND_COLOR, + font: float = _FONT_SIZE, ) -> list[dict[str, Any]]: - """balloon 도형 + 지시선 + 6줄 문자. shape = hexagon | ellipse | rect.""" - width = max(len(line) for line in lines) * _BALLOON_CHAR_W + 2 * _BALLOON_PAD_X - height = len(lines) * _BALLOON_LINE_H + 2 * _BALLOON_PAD_Y + """balloon 도형 + 지시선 + 여러 줄 문자(빈 줄은 자리만). shape = hexagon | ellipse | rect.""" + scale = font / _FONT_SIZE + width, height = balloon_size(lines, font) + line_h, pad_y = _BALLOON_LINE_H * scale, _BALLOON_PAD_Y * scale cx, cy = center half_w, half_h = width / 2.0, height / 2.0 if shape == "hexagon": @@ -222,9 +234,7 @@ def _balloon_entities( ] entities: list[dict[str, Any]] = [] - shape_entity = polyline_entity( - drawing_id, outline, BAND_LAYER_ID, BAND_COLOR, suffix=f":balloon:{seed}" - ) + shape_entity = polyline_entity(drawing_id, outline, layer, color, suffix=f":balloon:{seed}") if shape_entity: entities.append(shape_entity) # 지시선은 **계단형**이고 balloon **모서리**에서 나간다(2026-09-03 사용자 확정 — @@ -234,23 +244,25 @@ def _balloon_entities( leader = polyline_entity( drawing_id, [(corner_x, corner_y), (anchor[0], corner_y), anchor], - BAND_LAYER_ID, - BAND_COLOR, + layer, + color, suffix=f":balloon:leader:{seed}", ) if leader: entities.append(leader) - first_y = cy + half_h - _BALLOON_PAD_Y - _BALLOON_LINE_H * 0.75 + first_y = cy + half_h - pad_y - line_h * 0.75 for index, line in enumerate(lines): + if not line: + continue entities.append( _text_entity( f"{drawing_id}:balloon:text:{seed}:{index}", line, cx, - first_y - index * _BALLOON_LINE_H, - BAND_LAYER_ID, - _FONT_SIZE, - BAND_COLOR, + first_y - index * line_h, + layer, + font, + color, ) ) return entities diff --git a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py index cd51607fb..0e992e71e 100644 --- a/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py +++ b/B07_DesignDetail/B07_DesignDetail_Engine_Cad_PlanKinds.py @@ -21,6 +21,7 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( _text_entity, polyline_entity, ) +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import _balloon_entities from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( _COMPASS_MARGIN, _COMPASS_SIZE, @@ -58,6 +59,11 @@ PIECES: tuple[tuple[str, str, bool], ...] = ( ("endpoints", "시종점", False), ("refuge", "대피소 · 사토장", False), ("drainage", "배수시설 기호", False), + # 용지도(93장) — 연속지적도 · 행정구역 + ("parcel", "필지 경계", False), + ("parcel_info", "필지 정보", False), + ("emd", "읍면동 경계", False), + ("sgg", "시군구 경계", False), ) PIECE_IDS: tuple[str, ...] = tuple(piece for piece, _name, _locked in PIECES) # 옛 조각 id → 나눈 조각들 — 옛 종류 파일을 읽을 때 펼친다. @@ -86,6 +92,12 @@ DEFAULT_KINDS: tuple[dict[str, Any], ...] = ( "name": "라이다", "pieces": ["lidar", *[p for p in _TERRAIN if p not in ("contour", "stream")], "station"], }, + # 용지도 = 옛 따로 도면과 같은 정보(등고선 · 노선 · 지적 · 행정 경계). + { + "id": "landuse", + "name": "용지도", + "pieces": ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"], + }, ) _COLORS = { @@ -102,7 +114,18 @@ _COLORS = { "endpoints": "#ff9d4d", "refuge": "#7ddc7d", "drainage": STRUCTURE_COLOR, + "parcel": "#8c6b4f", + "parcel_info": "#5c4632", + "emd": "#2f7d4f", + "sgg": "#a63d3d", } +# 행정 · 지적 경계 (선굵기, 파선) +_BOUNDARY_STYLE: dict[str, tuple[int, list[int] | None]] = { + "parcel": (1, None), + "emd": (2, [8, 4]), + "sgg": (3, [14, 5, 3, 5]), +} +_PARCEL_FONT = 1.8 TICK_STEP_M = 2.0 # 비탈 눈금 간격(실거리 m) — 긴 · 짧은 눈금이 번갈아 선다 _MAJOR_EVERY = 5 # 측점 NO 큰 글자 · 동그라미 간격(측점 수) _LABEL_FONT = 2.5 @@ -475,6 +498,25 @@ def _piece_entities( text( f"{index}", item["label"], (cx + size + 1.0, cy), _STRUCTURE_FONT_SIZE, align="left" ) + elif piece in _BOUNDARY_STYLE: + width, dash = _BOUNDARY_STYLE[piece] + for index, points in enumerate(data.get("lines") or []): + line(points, f":{index}", width=width, dash=dash) + elif piece == "parcel_info": + for index, item in enumerate(data.get("items") or []): + out.extend( + _balloon_entities( + f"{drawing_id}:{piece}", + str(index), + item["lines"], + paper(item["anchor"]), + paper(item["center"]), + "rect", + layer=layer, + color=color, + font=_PARCEL_FONT, + ) + ) return out diff --git a/B07_DesignDetail/B07_DesignDetail_Router.py b/B07_DesignDetail/B07_DesignDetail_Router.py index 790e55a86..9c0e8e38c 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router.py +++ b/B07_DesignDetail/B07_DesignDetail_Router.py @@ -38,7 +38,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import ( ) from B07_DesignDetail.B07_DesignDetail_Router_Support import ( CROSS_STANDARD_ID, - LANDUSE_ID, MASS_HAUL_ID, WATERSHED_ID, _cross_sheet_plan, @@ -47,7 +46,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support import ( _read_drawing, _read_json, _store_confirmed_drawing, - landuse_source, watershed_source, ) from B07_DesignDetail.B07_DesignDetail_Router_Support_Overlay import cross_overlay_extents @@ -355,15 +353,6 @@ async def get_design_drawing( source_design = await asyncio.to_thread( plan_kind_source, context, longitudinal, longitudinal_path, designs, drawing_id ) - elif LANDUSE_ID.fullmatch(drawing_id): - # 용지도도 같은 배경 창구를 쓴다 — 지적·행정 경계만 따로 읽는다. - context, reason = await load_drainage_context(project_id) - if context is None: - return JSONResponse(status_code=404, content={"status": "error", "message": reason}) - longitudinal = await asyncio.to_thread(_read_json, longitudinal_path) - source_design = await asyncio.to_thread( - landuse_source, context, longitudinal, drawing_id - ) kind, label, drawing, confirmed, quantity_table = await asyncio.to_thread( _read_drawing, project_root, diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support.py b/B07_DesignDetail/B07_DesignDetail_Router_Support.py index 3810e114c..31fe66b3b 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support.py @@ -24,16 +24,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import ( ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import build_watershed_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Cover import build_cover_drawing -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( - LANDUSE_LABEL, - build_landuse_drawing, -) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import ( build_longitudinal_drawing, longitudinal_chunks, ) from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import build_mass_haul_drawing -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import plan_chunks from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import build_plan_kind_drawing from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Sheet import ( CROSS_SHEET_ID, @@ -56,7 +51,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import add_title_fields from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F401 _BASIN_MAX_DISTANCE_M, CONTOUR_FILE, - LANDUSE_ID, LIDAR_ID, PLAN_ID, STREAM_FILE, @@ -67,7 +61,6 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import ( # noqa: F4 _geometry_lines, _too_far_from_route, clip_line_to_box, - landuse_source, lidar_source, plan_source, plan_stations, @@ -158,20 +151,6 @@ def _drawing_list( confirmed=bool(manifest_drawings.get(entry["id"], {}).get("confirmed")), ) ) - plan_sheets = plan_chunks(plan_stations(longitudinal)) - # 용지도 — 계획평면도와 같은 축척·같은 장 나눔을 쓴다. - for chunk in plan_sheets: - drawing_id = "landuse" if len(plan_sheets) <= 1 else f"landuse_{chunk['number']}" - drawings.append( - DesignDrawingItem( - id=drawing_id, - kind="landuse", - label=LANDUSE_LABEL - if len(plan_sheets) <= 1 - else f"{LANDUSE_LABEL} {chunk['number']}장", - confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), - ) - ) # 노선 전장 1장짜리 도면 — 자료가 없으면 여는 시점에 404로 알린다(목록에는 항상 둔다). for drawing_id, kind, label in ( (COVER_ID, "cover", "표지"), @@ -185,6 +164,12 @@ def _drawing_list( kind=kind, label=label, confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")), + # 배수유역도는 평면도 묶음 안 항목 — 축척(1/6,000 한 장)이 달라 종류는 아니다. + **( + {"plan_kind": WATERSHED_ID, "plan_kind_name": "배수유역도"} + if drawing_id == WATERSHED_ID + else {} + ), ) ) # 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④. @@ -424,8 +409,6 @@ def _read_drawing( kind = drawing_id # id와 kind가 같은 단장 도면 elif PLAN_KIND_ID.fullmatch(drawing_id): kind = "plan" - elif LANDUSE_ID.fullmatch(drawing_id): - kind = "landuse" else: kind = "longitudinal" if _LONG_ID.fullmatch(drawing_id) else "cross" label = str(manifest_entry.get("label") or drawing_id) @@ -478,27 +461,6 @@ def _read_drawing( None, ) - if LANDUSE_ID.fullmatch(drawing_id): - # stored_design = landuse_source()가 모아 준 노선·등고선·지적·행정 경계(사업지 CRS). - if not isinstance(stored_design, dict): - raise FileNotFoundError("용지도 자료가 없습니다.") - label = str(stored_design.get("label") or LANDUSE_LABEL) - return ( - "landuse", - label, - build_landuse_drawing( - drawing_id, - label, - stored_design.get("route_xy") or [], - stored_design.get("contours") or [], - stored_design.get("parcels") or [], - stored_design.get("emd_rings") or [], - stored_design.get("sgg_rings") or [], - ), - False, - None, - ) - if drawing_id == WATERSHED_ID: # stored_design = watershed_source()가 모아 준 노선·유역·배경 좌표(사업지 CRS). if not isinstance(stored_design, dict): diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py index 41c0b64e8..f55535be1 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Basin.py @@ -16,7 +16,6 @@ from pyproj import Transformer from B04_PreProcess.B04_PreProcess_Router_Watershed import CONTOUR_FILE, STREAM_FILE from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Basin import map_area_mm -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import LANDUSE_LABEL from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import LIDAR_LABEL, hillshade_png from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import ( plan_area_mm, @@ -366,14 +365,6 @@ def _plan_structures(context: Any) -> list[dict[str, Any]]: return [point for point in points if isinstance(point, dict)] -LANDUSE_ID = re.compile(r"^landuse(?:_(\d+))?$") - -# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84). -PARCEL_FILE = "연속지적도_bounds.geojson" -EMD_FILE = "행정구역_읍면동_bounds.geojson" -SGG_FILE = "행정구역_시군구_bounds.geojson" - - def _clip_rings( path: Path, crs: str, box: tuple[float, float, float, float] ) -> list[list[tuple[float, float]]]: @@ -384,112 +375,6 @@ def _clip_rings( return rings -def _contains(ring: list[tuple[float, float]], point: tuple[float, float]) -> bool: - """점이 고리 안에 드는지 (반직선 교차 판정).""" - x, y = point - inside = False - for index in range(len(ring)): - x1, y1 = ring[index - 1] - x2, y2 = ring[index] - if (y1 > y) != (y2 > y) and x < (x2 - x1) * (y - y1) / ((y2 - y1) or 1e-12) + x1: - inside = not inside - return inside - - -def _clip_parcels( - path: Path, crs: str, box: tuple[float, float, float, float] -) -> list[dict[str, Any]]: - """연속지적도를 사업지 좌표계로 돌려 도곽 안 필지만 남긴다 (지번 표기용 속성 포함). - - 필지는 지번을 적어야 하므로 경계선만 자르는 `_metric_lines` 캐시를 쓰지 못한다 — - 피처와 속성을 짝지어 읽는다. 도곽 밖 필지는 여기서 버려 도면이 무거워지지 않게 한다. - - 두 가지를 함께 낸다. - - `ring` : 도곽으로 자른 경계선(밖으로 나가는 부분은 버린다). 자르지 않으면 - 산지 대필지 하나가 도면을 10 km 로 벌린다(2026-09-04 실측: 콘텐츠 8,368 mm). - - `label_at` : 도곽을 **통째로 감싸는** 필지의 지번 자리. 임야 대필지 안에 노선이 - 들어앉으면 경계선이 도곽 안에 하나도 없어 지번이 사라진다(2026-09-04 실측: - 용화_LAS 노선이 「산77-1임 일월면 용화리」 한 필지 안에 통째로 들어감). - """ - if not path.is_file(): - return [] - transformer = Transformer.from_crs("EPSG:4326", crs, always_xy=True) - min_x, min_y, max_x, max_y = box - center = ((min_x + max_x) / 2.0, (min_y + max_y) / 2.0) - parcels: list[dict[str, Any]] = [] - for feature in _geojson_features(path): - properties = feature.get("properties") or {} - for ring in _geometry_lines(feature.get("geometry")): - converted = [ - (float(x), float(y)) - for x, y in (transformer.transform(point[0], point[1]) for point in ring) - ] - if len(converted) < 3: - continue - if max(x for x, _y in converted) < min_x or min(x for x, _y in converted) > max_x: - continue - if max(y for _x, y in converted) < min_y or min(y for _x, y in converted) > max_y: - continue - parts = [part for part in clip_line_to_box(converted, box) if len(part) >= 2] - for part in parts: - parcels.append({"ring": part, "props": properties}) - if not parts and _contains(converted, center): - parcels.append({"ring": [], "props": properties, "label_at": center}) - return parcels - - -def landuse_source(context: Any, longitudinal: dict[str, Any], drawing_id: str) -> dict[str, Any]: - """용지도 한 장의 입력(노선·등고선·연속지적도·행정구역)을 사업지 CRS(m)로 모은다. - - 축척·도곽·장 나눔은 계획평면도와 같다 — 배경도 같은 창구(`map_background`)를 쓴다. - """ - match = LANDUSE_ID.fullmatch(drawing_id) - if not match: - raise ValueError("올바르지 않은 용지도 ID입니다.") - chunks = plan_chunks(plan_stations(longitudinal)) - number = int(match.group(1)) if match.group(1) else 1 - chunk = next((item for item in chunks if item["number"] == number), None) - if chunk is None: - raise FileNotFoundError("요청한 용지도 장을 찾을 수 없습니다.") - total = len(chunks) - start_m, end_m = float(chunk["start_m"]), float(chunk["end_m"]) - - route_xy = [ - (vertex.x, vertex.y) - for vertex in context.vertices - if total <= 1 or start_m <= float(getattr(vertex, "chainage_m", 0.0) or 0.0) <= end_m - ] - background = map_background( - Path(context.project_root), - context.crs, - DRAWING_SCALE_PLAN, - plan_area_mm(), - route_xy, - ) - # 지적·행정 경계는 등고선과 **같은 범위**로 자른다 — 배경보다 넓으면 도면이 A1을 넘는다. - area_w_mm, area_h_mm = plan_area_mm() - half_w = area_w_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 - half_h = area_h_mm / 2.0 * DRAWING_SCALE_PLAN / 1000.0 - center_x = (min(x for x, _y in route_xy) + max(x for x, _y in route_xy)) / 2.0 - center_y = (min(y for _x, y in route_xy) + max(y for _x, y in route_xy)) / 2.0 - box = ( - min(center_x - half_w, min(x for x, _y in route_xy)), - min(center_y - half_h, min(y for _x, y in route_xy)), - max(center_x + half_w, max(x for x, _y in route_xy)), - max(center_y + half_h, max(y for _x, y in route_xy)), - ) - sheet_dir = Path(context.project_root) / "B04_PreProcess" / "processed" - label = LANDUSE_LABEL if total <= 1 else f"{LANDUSE_LABEL} {number}장" - return { - "label": label, - "route_xy": route_xy, - "contours": background["contours"], - "parcels": _clip_parcels(sheet_dir / PARCEL_FILE, context.crs, box), - "emd_rings": _clip_rings(sheet_dir / EMD_FILE, context.crs, box), - "sgg_rings": _clip_rings(sheet_dir / SGG_FILE, context.crs, box), - } - - LIDAR_ID = re.compile(r"^plan_lidar(?:_(\d+))?$") diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py new file mode 100644 index 000000000..36d81aca4 --- /dev/null +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Landuse.py @@ -0,0 +1,204 @@ +"""B07 용지도 조각 — 연속지적도 · 행정구역을 평면도 장 범위로 자르고 필지 라벨 자리를 잡는다(93장). + +조각 넷 = parcel(필지 경계) · parcel_info(필지 정보 상자) · emd(읍면동 경계) · sgg(시군구 경계). +필지마다 지번 · 지목 · 면적 · 소유자 네 줄을 상자 + 지시선으로 적는다(빈 값은 그 줄만 비움). +지시선 끝 = (필지 ∩ 도곽) − 노선 버퍼 의 대표점 · 상자 = 후보 자리 중 노선 버퍼 · 이미 놓은 +상자 · 도곽 밖과 안 겹치는 첫 자리. 좌표는 사업지 m — 그리기는 `Engine_Cad_PlanKinds`. +""" + +from pathlib import Path +from typing import Any + +import numpy as np +import shapely +from pyproj import Transformer +from shapely.errors import GEOSException +from shapely.geometry import LineString, Polygon, shape +from shapely.geometry import box as box_polygon +from shapely.validation import make_valid + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM +from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_rings, _geojson_features + +LANDUSE_PIECES = ("parcel", "parcel_info", "emd", "sgg") + +# B04 가 내려받아 저장하는 지적·행정구역 GeoJSON (전부 WGS84). +PARCEL_FILE = "연속지적도_bounds.geojson" +EMD_FILE = "행정구역_읍면동_bounds.geojson" +SGG_FILE = "행정구역_시군구_bounds.geojson" + +PARCEL_FONT = 1.8 # 필지 정보 글 크기(종이 mm) +ROUTE_CLEAR_M = 5.0 # 라벨이 비켜 갈 노선 버퍼(중심선에서 m) +_GAP_M = 2.0 # 지시선 끝과 상자 사이 틈(m) +_RINGS = 5 # 후보 자리 고리 수 +_DIRECTIONS = ((1, 1), (-1, 1), (1, -1), (-1, -1), (1, 0), (-1, 0), (0, 1), (0, -1)) + + +def parcel_info_lines(props: dict[str, Any]) -> list[str]: + """지번·지목·면적·소유자 네 줄 — 값이 없으면 그 줄은 빈 문자열.""" + + def text(value: Any) -> str: + return value.strip() if isinstance(value, str) else "" + + area = props.get("parea") + try: + area_text = f"{float(area):,.0f}㎡" if area not in (None, "") else "" + except (TypeError, ValueError): + area_text = text(area) + return [ + text(props.get("jibun")), + text(props.get("jimok")), + area_text, + text(props.get("owner_nm")), + ] + + +def _enclosing_note(props: dict[str, Any]) -> str: + """구역 전체가 한 필지 안일 때 정보 맨 위 줄 — 소재지(시군구 읍면동 리)를 붙인다.""" + place = " ".join( + str(props[key]).strip() + for key in ("sgg_nm", "emd_nm", "ri_nm") + if isinstance(props.get(key), str) and props[key].strip() + ) + return f"구역 전체가 이 필지 안 ({place})" if place else "구역 전체가 이 필지 안" + + +def _lines_of(geometry: Any) -> list[list[list[float]]]: + return [ + [[round(x, 4), round(y, 4)] for x, y in part.coords] + for part in shapely.get_parts(geometry) + if isinstance(part, LineString) and len(part.coords) >= 2 + ] + + +def clip_parcels( + path: Path, crs: str, box: tuple[float, float, float, float] +) -> list[dict[str, Any]]: + """도곽에 걸친 필지 [{key, props, area(필지 ∩ 도곽), whole(도곽을 통째로 감쌈), lines}]. + + `lines` = 도곽 안 경계선(밖은 버림 — 산지 대필지 하나가 도면을 10 km 로 벌리지 않게). + 같은 지번(pnu)이 여러 피처면 하나로 합친다. + """ + if not path.is_file(): + return [] + to_site = Transformer.from_crs("EPSG:4326", crs, always_xy=True) + + def project(coords: np.ndarray) -> np.ndarray: + xs, ys = to_site.transform(coords[:, 0], coords[:, 1]) + return np.column_stack([xs, ys]) + + frame = box_polygon(*box) + groups: dict[str, dict[str, Any]] = {} + for index, feature in enumerate(_geojson_features(path)): + try: + geometry = make_valid(shapely.transform(shape(feature.get("geometry")), project)) + except (AttributeError, TypeError, ValueError, GEOSException): + continue + if geometry.is_empty or not geometry.intersects(frame): + continue + area = geometry.intersection(frame) + if area.area <= 0: + continue + props = feature.get("properties") or {} + key = str(props.get("pnu") or f"#{index}") + group = groups.setdefault( + key, {"key": key, "props": props, "parts": [], "whole": False, "lines": []} + ) + group["parts"].append(area) + group["whole"] = group["whole"] or geometry.contains(frame) + group["lines"].extend(_lines_of(geometry.boundary.intersection(frame))) + for group in groups.values(): + group["area"] = shapely.union_all(group.pop("parts")) + return list(groups.values()) + + +def _anchor(area: Any, clear: Any) -> tuple[float, float]: + """지시선 끝 — 노선 버퍼를 뺀 필지 조각 중 가장 큰 것의 대표점(다 덮이면 필지 대표점).""" + free = area.difference(clear) + polygons = [part for part in shapely.get_parts(free) if isinstance(part, Polygon)] + target = max(polygons, key=lambda part: part.area) if polygons else area + point = target.representative_point() + return (point.x, point.y) + + +def place_labels( + parcels: list[dict[str, Any]], + route_xy: list[tuple[float, float]], + box: tuple[float, float, float, float], +) -> list[dict[str, Any]]: + """필지마다 라벨 [{lines, anchor, center}] — 큰 필지부터 자리를 잡는다.""" + clear = LineString(route_xy).buffer(ROUTE_CLEAR_M) if len(route_xy) >= 2 else Polygon() + shapely.prepare(clear) + no_outline = not any(parcel["lines"] for parcel in parcels) + placed: list[tuple[float, float, float, float]] = [] + items: list[dict[str, Any]] = [] + for parcel in sorted(parcels, key=lambda item: -item["area"].area): + lines = parcel_info_lines(parcel["props"]) + if no_outline and parcel["whole"]: + lines = [_enclosing_note(parcel["props"]), *lines] + if not any(lines): + continue + width_mm, height_mm = balloon_size(lines, PARCEL_FONT) + half_w, half_h = width_mm / MM / 2.0, height_mm / MM / 2.0 + ax, ay = _anchor(parcel["area"], clear) + free_of_route: tuple[float, float, float, float] | None = None + chosen = None + for ring in range(1, _RINGS + 1): + for sx, sy in _DIRECTIONS: + cx = ax + sx * (half_w + _GAP_M) * ring + cy = ay + sy * (half_h + _GAP_M) * ring + rect = (cx - half_w, cy - half_h, cx + half_w, cy + half_h) + inside = ( + rect[0] >= box[0] + and rect[1] >= box[1] + and rect[2] <= box[2] + and rect[3] <= box[3] + ) + if not inside or clear.intersects(box_polygon(*rect)): + continue + free_of_route = free_of_route or rect + if not any( + rect[0] < other[2] + and other[0] < rect[2] + and rect[1] < other[3] + and other[1] < rect[3] + for other in placed + ): + chosen = rect + break + if chosen: + break + # ponytail: 빈자리가 없으면 상자끼리 겹침은 받아들임 · 노선과는 끝내 안 겹침(못 놓으면 뺌). + rect = chosen or free_of_route + if rect is None: + continue + placed.append(rect) + items.append( + { + "lines": lines, + "anchor": [round(ax, 4), round(ay, 4)], + "center": [ + round((rect[0] + rect[2]) / 2.0, 4), + round((rect[1] + rect[3]) / 2.0, 4), + ], + } + ) + return items + + +def landuse_pieces( + project_root: Path, + crs: str, + box: tuple[float, float, float, float], + route_xy: list[tuple[float, float]], +) -> dict[str, dict[str, Any]]: + """그 장의 용지 조각 넷 — 노선은 라벨이 비켜 갈 전체 중심선(사업지 m).""" + folder = project_root / "B04_PreProcess" / "processed" + parcels = clip_parcels(folder / PARCEL_FILE, crs, box) + return { + "parcel": {"lines": [line for parcel in parcels for line in parcel["lines"]]}, + "parcel_info": {"items": place_labels(parcels, route_xy, box)}, + "emd": {"lines": _clip_rings(folder / EMD_FILE, crs, box)}, + "sgg": {"lines": _clip_rings(folder / SGG_FILE, crs, box)}, + } diff --git a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py index 1274d78e1..861121402 100644 --- a/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py +++ b/B07_DesignDetail/B07_DesignDetail_Router_Support_Plan.py @@ -53,6 +53,10 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import ( _read_manifest, _write_manifest, ) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( + LANDUSE_PIECES, + landuse_pieces, +) from common_util.common_util_json import atomic_write_json from common_util.common_util_route_profile import design_elevation_from_longitudinal from config.config_system import DRAWING_SCALE_PLAN @@ -65,7 +69,7 @@ _REFUGE_OFFSET_M = 8.0 # 대피소 · 사토장 라벨을 중심선에서 띄 def plan_title(name: str) -> str: - return f"계획평면도({name})" + return f"평면도({name})" # ── 종류 ───────────────────────────────────────────── @@ -75,11 +79,38 @@ def _kinds_dir(project_root: Path) -> Path: return _design_root(project_root) / "plan_kinds" +# 이관 표시 — 있으면 용지도 종류를 이미 더해 준 폴더(지운 뒤 되살리지 않음). +_LANDUSE_MARK = "_landuse_added" + + +def _add_landuse_once(folder: Path) -> None: + """93장 앞에 종류 파일을 내린 프로젝트 — 기본 종류 「용지도」를 한 번 더한다.""" + mark = folder / _LANDUSE_MARK + if mark.exists(): + return + landuse = next(kind for kind in DEFAULT_KINDS if kind["id"] == "landuse") + documents = [] + for path in folder.glob("*.json"): + try: + documents.append(_read_json(path)) + except (OSError, ValueError): + continue + names = {str(document.get("name")) for document in documents} + if not (folder / "landuse.json").exists() and landuse["name"] not in names: + order = max((int(document.get("order") or 0) for document in documents), default=-1) + 1 + atomic_write_json( + folder / "landuse.json", + {"name": landuse["name"], "pieces": list(landuse["pieces"]), "order": order}, + ) + mark.touch() + + def list_kinds(project_root: Path) -> list[dict[str, Any]]: - """종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 넷.""" + """종류 목록 [{id, name, pieces}] — 차례대로. 파일이 없으면 기본 종류.""" folder = _kinds_dir(project_root) if not folder.is_dir(): return [{**kind, "pieces": list(kind["pieces"])} for kind in DEFAULT_KINDS] + _add_landuse_once(folder) rows: list[tuple[int, str, dict[str, Any]]] = [] for path in folder.glob("*.json"): if not KIND_ID.fullmatch(path.stem): @@ -112,6 +143,7 @@ def _materialize(project_root: Path) -> list[dict[str, Any]]: folder / f"{kind['id']}.json", {"name": kind["name"], "pieces": kind["pieces"], "order": order}, ) + (folder / _LANDUSE_MARK).touch() return kinds @@ -411,6 +443,11 @@ def build_pieces( pieces["lidar"] = {"image": image, "box": list(image_box)} except (FileNotFoundError, ValueError, OSError): pieces["lidar"] = {"image": None, "box": None} + if set(LANDUSE_PIECES) & set(wanted): + # 라벨은 이 장 구간이 아니라 노선 전체를 비켜 간다 — 도곽 안에 다른 구간이 들 수 있다. + pieces.update( + landuse_pieces(project_root, context.crs, box, [(x, y) for _c, x, y in route]) + ) folder = _design_root(project_root) / "plan_pieces" / str(number) for piece, data in pieces.items(): diff --git a/resources/tester/test_b07_landuse_parcel_info.py b/resources/tester/test_b07_landuse_parcel_info.py index f44543a80..2ed7bb7f0 100644 --- a/resources/tester/test_b07_landuse_parcel_info.py +++ b/resources/tester/test_b07_landuse_parcel_info.py @@ -1,48 +1,89 @@ -"""B07 용지도 — 필지마다 지번·지목·면적·소유자 한 묶음 · 빈 값은 빈 줄 (59-6).""" +"""B07 용지도 — 필지마다 지번·지목·면적·소유자 상자 + 지시선 (59-6 · 68-2 · 93-3). -import pytest +① 필지마다 상자 하나 · 빈 값은 빈 줄 · 쪼개진 필지는 큰 조각에 지시선 +② 구역이 한 필지 안 — 경계 없이 어느 필지인지 한 번 +③ 상자 · 지시선 끝이 노선 버퍼와 안 겹침 · 상자끼리 안 겹침 · 도면에서도 노선과 안 겹침 +""" -from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Landuse import ( - JIBUN_LAYER_ID, - MM, - build_landuse_drawing, +import json +from pathlib import Path + +from shapely.geometry import LineString, Point +from shapely.geometry import box as box_polygon + +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_MassHaul import balloon_size +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Plan import MM +from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanKinds import ( + build_plan_kind_drawing, + layer_id, +) +from B07_DesignDetail.B07_DesignDetail_Router_Support_Landuse import ( + PARCEL_FONT, + ROUTE_CLEAR_M, + clip_parcels, + place_labels, ) - -def _square(x0: float, size: float) -> list[tuple[float, float]]: - return [(x0, 0.0), (x0 + size, 0.0), (x0 + size, size), (x0, size), (x0, 0.0)] +BOX = (0.0, 0.0, 400.0, 300.0) -def test_one_label_group_per_parcel() -> None: +def _square(x0: float, y0: float, size: float) -> list[list[float]]: + return [[x0, y0], [x0 + size, y0], [x0 + size, y0 + size], [x0, y0 + size], [x0, y0]] + + +def _geojson(tmp_path: Path, features: list[tuple[dict, dict]]) -> Path: + path = tmp_path / "parcels.geojson" + payload = { + "type": "FeatureCollection", + "features": [ + {"type": "Feature", "properties": props, "geometry": geometry} + for props, geometry in features + ], + } + path.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + return path + + +def _polygon(ring: list[list[float]]) -> dict: + return {"type": "Polygon", "coordinates": [ring]} + + +def _rect(item: dict) -> tuple[float, float, float, float]: + width, height = balloon_size(item["lines"], PARCEL_FONT) + cx, cy = item["center"] + return (cx - width / MM / 2, cy - height / MM / 2, cx + width / MM / 2, cy + height / MM / 2) + + +def test_one_box_per_parcel(tmp_path: Path) -> None: full = {"pnu": "A", "jibun": "95전", "jimok": "전", "parea": 4916, "owner_nm": "개인"} split = {"pnu": "B", "jibun": "산1임", "jimok": "임야", "parea": 12.4, "owner_nm": ""} - parcels = [ - {"ring": _square(0.0, 40.0), "props": full}, - # 도곽에 잘려 두 조각이 된 필지 — 정보는 큰 조각에 한 번만 - {"ring": _square(100.0, 10.0), "props": split}, - {"ring": _square(200.0, 30.0), "props": split}, - # 도곽을 통째로 감싼 필지 — 경계 없이 정보만 - {"ring": [], "props": {"pnu": "C", "jibun": "산77"}, "label_at": (500.0, 500.0)}, - ] - drawing = build_landuse_drawing("landuse", "용지도", [], [], parcels, [], []) - texts = [e for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID] - labels = [e["shapeData"]["label"] for e in texts] - assert labels == ["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡", "산77"] - groups = {round(e["shapeData"]["basePoint"]["x"], 6) for e in texts} - assert len(groups) == 3 - # 쪼개진 필지는 큰 조각(200~230 m) 가운데에 - split_x = { - e["shapeData"]["basePoint"]["x"] for e in texts if e["shapeData"]["label"] == "산1임" + two_parts = { + "type": "MultiPolygon", + "coordinates": [[_square(250.0, 200.0, 20.0)], [_square(300.0, 150.0, 80.0)]], } - assert split_x.pop() == pytest.approx(215.0 * MM) + path = _geojson(tmp_path, [(full, _polygon(_square(20.0, 20.0, 120.0))), (split, two_parts)]) + parcels = clip_parcels(path, "EPSG:4326", BOX) + items = place_labels(parcels, [], BOX) + assert sorted(item["lines"] for item in items) == [ + ["95전", "전", "4,916㎡", "개인"], + ["산1임", "임야", "12㎡", ""], + ] + # 쪼개진 필지는 큰 조각(300~380 · 150~230)에 지시선 끝 + (b,) = [item for item in items if item["lines"][0] == "산1임"] + assert 300.0 <= b["anchor"][0] <= 380.0 and 150.0 <= b["anchor"][1] <= 230.0 + # 빈 줄은 글 없이 자리만 — 도면 글은 일곱 + drawing = build_plan_kind_drawing( + "plan_landuse", "평면도(용지도)", BOX, {"parcel_info": {"items": items}} + ) + texts = [ + e["shapeData"]["label"] + for e in drawing["entities"] + if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text" + ] + assert sorted(texts) == sorted(["95전", "전", "4,916㎡", "개인", "산1임", "임야", "12㎡"]) -def test_zone_inside_one_parcel_is_named_once(tmp_path) -> None: - """작은 구역이 큰 필지 하나 안에 통째로 — 경계선이 없어도 어느 필지인지 한 번 적는다 (68-2).""" - import json - - from B07_DesignDetail.B07_DesignDetail_Router_Support_Basin import _clip_parcels - +def test_zone_inside_one_parcel_is_named_once(tmp_path: Path) -> None: props = { "pnu": "P1", "sgg_nm": "봉화군", @@ -53,27 +94,70 @@ def test_zone_inside_one_parcel_is_named_once(tmp_path) -> None: "parea": 1234567, "owner_nm": "국", } - big = [[0.0, 0.0], [10.0, 0.0], [10.0, 10.0], [0.0, 10.0], [0.0, 0.0]] - geojson = { - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "properties": props, - "geometry": {"type": "Polygon", "coordinates": [big]}, - } - ], - } - path = tmp_path / "parcels.geojson" - path.write_text(json.dumps(geojson, ensure_ascii=False), encoding="utf-8") - parcels = _clip_parcels(path, "EPSG:4326", (4.0, 4.0, 6.0, 6.0)) - assert len(parcels) == 1 and parcels[0]["ring"] == [] - - route = [(4.5, 4.5), (5.5, 5.5)] - drawing = build_landuse_drawing("landuse", "용지도", route, [], parcels, [], []) - labels = [ - e["shapeData"]["label"] for e in drawing["entities"] if e.get("layerId") == JIBUN_LAYER_ID + path = _geojson(tmp_path, [(props, _polygon(_square(-500.0, -500.0, 2000.0)))]) + parcels = clip_parcels(path, "EPSG:4326", BOX) + assert len(parcels) == 1 and parcels[0]["lines"] == [] and parcels[0]["whole"] + (item,) = place_labels(parcels, [(150.0, 100.0), (250.0, 200.0)], BOX) + assert item["lines"] == [ + "구역 전체가 이 필지 안 (봉화군 재산면 갈산리)", + "산77-1임", + "임야", + "1,234,567㎡", + "국", ] - notes = [label for label in labels if label.startswith("구역 전체가 이 필지 안")] - assert notes == ["구역 전체가 이 필지 안 (봉화군 재산면 갈산리)"] - assert labels[1:] == ["산77-1임", "임야", "1,234,567㎡", "국"] + + +def test_boxes_clear_route_and_each_other(tmp_path: Path) -> None: + # 노선이 가운데를 가로지르는 필지 열둘(4 × 3) + features = [ + ( + { + "pnu": f"P{i}{j}", + "jibun": f"{i}{j}-1", + "jimok": "임야", + "parea": 9999, + "owner_nm": "홍길동", + }, + _polygon(_square(i * 100.0, j * 100.0, 100.0)), + ) + for i in range(4) + for j in range(3) + ] + path = _geojson(tmp_path, features) + route = [(0.0, 140.0), (130.0, 160.0), (260.0, 120.0), (400.0, 170.0)] + items = place_labels(clip_parcels(path, "EPSG:4326", BOX), route, BOX) + assert len(items) == 12 + clear = LineString(route).buffer(ROUTE_CLEAR_M) + rects = [_rect(item) for item in items] + for item, rect in zip(items, rects): + assert not box_polygon(*rect).intersects(clear) # 상자 · 노선 겹침 0 + assert not clear.contains(Point(item["anchor"])) # 지시선 끝도 노선 밖 + assert BOX[0] <= rect[0] and rect[2] <= BOX[2] and BOX[1] <= rect[1] and rect[3] <= BOX[3] + for index, rect in enumerate(rects): + for other in rects[index + 1 :]: + assert not box_polygon(*rect).intersects(box_polygon(*other)) + # 도면(종이 mm)에서도 상자 테두리가 중심선과 안 닿음 + pieces = { + "centerline": {"lines": [[list(p) for p in route]]}, + "parcel_info": {"items": items}, + } + drawing = build_plan_kind_drawing("plan_landuse", "평면도(용지도)", BOX, pieces) + (center,) = [e for e in drawing["entities"] if e.get("layerId") == layer_id("centerline")] + center_line = _polyline(center) + # 상자 = 변 넷 · 지시선 = 계단 둘 + outlines = [ + e + for e in drawing["entities"] + if e.get("layerId") == layer_id("parcel_info") and len(e.get("children") or []) == 4 + ] + assert len(outlines) == 12 + for outline in outlines: + assert _polyline(outline).distance(center_line) > 0 + + +def _polyline(entity: dict) -> LineString: + children = entity["children"] + points = [children[0]["shapeData"]["startPoint"]] + [ + c["shapeData"]["endPoint"] for c in children + ] + return LineString([(p["x"], p["y"]) for p in points]) diff --git a/resources/tester/test_b07_plan_kinds.py b/resources/tester/test_b07_plan_kinds.py index a3cc127a6..f6837ff2f 100644 --- a/resources/tester/test_b07_plan_kinds.py +++ b/resources/tester/test_b07_plan_kinds.py @@ -6,6 +6,7 @@ ④ 고른 조각만 도면 층으로 얹힘 ⑤ 조각 잘게(67장) — 노견선 · 성토 외곽선 · 절토 외곽선 따로 · 기본 넷 처음 체크 = 옛 그림 · 옛 종류 파일의 slope_outline 펼침 · 종류 업데이트(기본 넷 포함) · 확정본 풀림 · 배수시설 기호 +⑥ 용지도 종류(93장) — 기본 종류 · 옛 종류 폴더에 한 번 더함(지우면 안 살아남) · 용지 조각 층 """ import json @@ -123,7 +124,13 @@ def test_outline_width_equals_cross_design_at_two_stations(tmp_path: Path) -> No def test_kinds_add_delete_persist(tmp_path: Path) -> None: root = tmp_path / "project" - assert [k["name"] for k in list_kinds(root)] == ["지형", "노선배치도", "대피소", "라이다"] + assert [k["name"] for k in list_kinds(root)] == [ + "지형", + "노선배치도", + "대피소", + "라이다", + "용지도", + ] added = add_kind(root, "내 평면도", ["centerline", "station", "nope"]) assert added["pieces"] == ["centerline", "station"] with pytest.raises(ValueError): @@ -132,14 +139,14 @@ def test_kinds_add_delete_persist(tmp_path: Path) -> None: add_kind(root, "빈 것", []) delete_kind(root, "terrain") # 기본 넷도 지움 again = list_kinds(root) # 다시 읽어 같음 - assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "내 평면도"] + assert [k["name"] for k in again] == ["노선배치도", "대피소", "라이다", "용지도", "내 평면도"] assert again[-1]["pieces"] == ["centerline", "station"] with pytest.raises(FileNotFoundError): delete_kind(root, "terrain") for kind in list(again): delete_kind(root, kind["id"]) assert list_kinds(root) == [] # 다 지우면 비어 있음(기본 넷이 되살아나지 않음) - assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar"} + assert {k["id"] for k in DEFAULT_KINDS} == {"terrain", "route", "layout", "lidar", "landuse"} def test_refuge_marks_same_spot(tmp_path: Path) -> None: @@ -184,16 +191,16 @@ def test_split_runs_switch_cut_fill_and_stay_joined() -> None: def test_default_kinds_keep_old_picture_and_legacy_file(tmp_path: Path) -> None: root = tmp_path / "project" outline = {"road_edge", "fill_outline", "cut_outline"} - for ( - kind - ) in DEFAULT_KINDS: # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음 - assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] + # 옛 slope_outline 자리를 나눈 셋이 그대로 채움 · 배수시설은 옛 그림에 없음(용지도는 따로) + for kind in DEFAULT_KINDS: + if kind["id"] != "landuse": + assert outline <= set(kind["pieces"]) and "drainage" not in kind["pieces"] folder = _kinds_dir(root) folder.mkdir(parents=True) (folder / "old.json").write_text( json.dumps({"name": "옛것", "pieces": ["slope_outline", "station"], "order": 0}), "utf-8" ) - (kind,) = list_kinds(root) + kind = next(k for k in list_kinds(root) if k["id"] == "old") # 용지도는 이관으로 더해짐 assert set(kind["pieces"]) == outline | {"station"} @@ -230,3 +237,43 @@ def test_drainage_symbol_piece() -> None: mine = [e for e in drawing["entities"] if e.get("layerId") == layer_id("drainage")] assert {e["type"] for e in mine} >= {"Text"} and len(mine) >= 2 assert any(e["type"] == "Text" and e["shapeData"]["label"] == "D800" for e in mine) + + +def test_landuse_kind_added_once_to_old_folder(tmp_path: Path) -> None: + root = tmp_path / "project" + folder = _kinds_dir(root) + folder.mkdir(parents=True) + for order, kind in enumerate(DEFAULT_KINDS[:4]): # 93장 앞에 내려 둔 기본 넷 + (folder / f"{kind['id']}.json").write_text( + json.dumps({"name": kind["name"], "pieces": kind["pieces"], "order": order}), "utf-8" + ) + kinds = list_kinds(root) + assert [k["id"] for k in kinds][-1] == "landuse" + assert kinds[-1]["pieces"] == ["contour", "centerline", "parcel", "parcel_info", "emd", "sgg"] + assert len(list_kinds(root)) == 5 # 두 번 읽어도 한 번 + delete_kind(root, "landuse") + assert "landuse" not in [k["id"] for k in list_kinds(root)] # 지운 것은 안 살아남 + + +def test_landuse_pieces_become_layers() -> None: + pieces = { + "parcel": {"lines": [[[0.0, 0.0], [10.0, 0.0]]]}, + "emd": {"lines": [[[0.0, 5.0], [10.0, 5.0]]]}, + "sgg": {"lines": [[[0.0, 9.0], [10.0, 9.0]]]}, + "parcel_info": { + "items": [ + {"lines": ["1-1", "임야", "", "국"], "anchor": [5.0, 2.0], "center": [20.0, 20.0]} + ] + }, + } + drawing = build_plan_kind_drawing( + "plan_landuse", "평면도(용지도)", (-50.0, -10.0, 50.0, 50.0), pieces + ) + names = {layer["id"] for layer in drawing["layers"]} + assert {layer_id(p) for p in ("parcel", "parcel_info", "emd", "sgg")} <= names + labels = [ + e["shapeData"]["label"] + for e in drawing["entities"] + if e.get("layerId") == layer_id("parcel_info") and e["type"] == "Text" + ] + assert labels == ["1-1", "임야", "국"] From 5b4560493a35a24f4b3afc10b133840a6595299b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:54:26 +0900 Subject: [PATCH 10/13] =?UTF-8?q?feat(B07):=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=ED=8F=89=EB=A9=B4=EB=8F=84=20=C2=B7=20?= =?UTF-8?q?=EC=A2=85=EB=8B=A8=EB=8F=84=20=C2=B7=20=ED=9A=A1=EB=8B=A8?= =?UTF-8?q?=EB=8F=84=20=C2=B7=20=EC=9C=A0=EC=97=AD=EB=8F=84=20=ED=8F=89?= =?UTF-8?q?=EB=A9=B4=EB=8F=84=20=EC=95=88=20=C2=B7=20=ED=91=9C=EC=A4=80=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=EB=A7=A8=20=EC=9C=84=20=C2=B7=20=EC=A2=85?= =?UTF-8?q?=EB=A5=98=20=ED=8C=A8=EB=84=90=20=ED=8E=BC=EC=B9=A8(93-1=20?= =?UTF-8?q?=C2=B7=2093-4~93-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 배수유역도 = 평면도 묶음 안 항목(× 없음) · 용지도 따로 항목 뺌 - 표준 횡단면도 = 횡단도 묶음 맨 위 전체 폭 한 줄 · 개수는 횡단 장만 · 한 장이어도 묶음 - 「종류 추가 · 업데이트 · 대피소 자리」 기본 펼침 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NPJGtfXo6GMbcpiboQC4uK --- .../B07_DesignDetail_UI_Panels.ts | 24 +++++++++++-------- .../B07_DesignDetail_UI_PlanKinds.ts | 19 +++++++++------ .../B07_DesignDetail_UI_Style.css | 5 ++-- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts index 59bfc6f72..3f49b784d 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_Panels.ts @@ -29,14 +29,12 @@ export const DRAWING_GROUPS: readonly { idPrefix?: string; }[] = [ { label: "표지", kind: "cover" }, - // 종류(지형 · 노선배치도 · 대피소 · 라이다 · 사용자 추가)를 한 묶음으로 — PLAN 60장. - { label: "계획평면도", kind: "plan" }, - { label: "종단면도", kind: "longitudinal" }, - { label: "표준 횡단면도", kind: "cross_standard" }, - { label: "횡단면도", kind: "cross" }, + // 종류(지형 · 노선배치도 · 대피소 · 라이다 · 용지도 · 사용자 추가) + 배수유역도를 한 묶음으로. + { label: "평면도", kind: "plan" }, + { label: "종단도", kind: "longitudinal" }, + // 표준 횡단면도는 횡단도 묶음 맨 위 한 줄(93-5). + { label: "횡단도", kind: "cross" }, { label: "토적도(유토곡선)", kind: "mass_haul" }, - { label: "유역도(배수 유역도)", kind: "watershed" }, - { label: "용지도", idPrefix: "landuse" }, ]; /** B06 확정 산출물 기반 도면 목록 패널. */ @@ -91,7 +89,7 @@ export function buildDrawingSidePanel( for (const group of DRAWING_GROUPS) { if (group.kind === "plan") { // 종류를 다 지워도 묶음은 남긴다 — 그 안에서 다시 더한다. - const plans = drawings.filter((item) => item.kind === "plan"); + const plans = drawings.filter((item) => item.kind === "plan" || item.kind === "watershed"); panel.append(buildPlanSection(plans, drawingButton, plan)); continue; } @@ -102,8 +100,9 @@ export function buildDrawingSidePanel( (item) => item.id === group.idPrefix || item.id.startsWith(`${group.idPrefix}_`), ) : drawings.filter((item) => item.id === group.blankId); - // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다. - if (items.length <= 1) { + const cross = group.kind === "cross"; + // 한 장짜리(와 아직 내용이 없는 도면)는 컨테이너 없이 버튼 하나로 둔다 — 횡단도는 늘 묶음. + if (items.length <= 1 && !cross) { const [drawing] = items; // 서버가 빈 도각을 내주지 못한 경우에만 회색 버튼으로 남는다. if (!drawing) { @@ -135,6 +134,11 @@ export function buildDrawingSidePanel( sectionTitle.className = "ui-collapsible__title"; sectionTitle.textContent = `${group.label} ${items.length}`; section.append(sectionTitle); + if (cross) { + // 표준 횡단면도 = 맨 위 한 줄(전체 폭) · 개수는 횡단 장만. + for (const standard of drawings.filter((item) => item.kind === "cross_standard")) + section.append(drawingButton(standard, standard.label)); + } for (const drawing of items) { // 횡단면도는 장 단위(여러 측점)라 서버가 준 "N장 (구간)" 라벨을 그대로 쓴다. section.append(drawingButton(drawing, drawing.label)); diff --git a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts index 7ec37a5f6..d1dd90109 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts +++ b/B07_DesignDetail/B07_DesignDetail_UI_PlanKinds.ts @@ -1,6 +1,6 @@ /* ============================================================================= * B07_DesignDetail_UI_PlanKinds.ts - * 좌측 도면 목록의 「계획평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · + * 좌측 도면 목록의 「평면도」 한 묶음 — 종류(지형 · 노선배치도 · 대피소 · 라이다 · * 사용자 추가)별 도면 단추, 종류 지우기 · 추가 · 업데이트, 대피소 · 사토장 자리 (PLAN 60 · 67장). * * 종류 = 정보 조각 고르기 목록이다. 고치면 서버 파일이 바뀌고 `onChanged` 로 화면을 다시 세운다. @@ -47,7 +47,7 @@ async function run(action: () => Promise, controls: PlanKindsControls): await action(); controls.onChanged(); } catch (error) { - showToast(error instanceof Error ? error.message : "계획평면도를 고치지 못했습니다.", "error"); + showToast(error instanceof Error ? error.message : "평면도를 고치지 못했습니다.", "error"); } } @@ -148,7 +148,7 @@ function marksForm(state: PlanKindsState, controls: PlanKindsControls): HTMLElem return form; } -/** 「계획평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */ +/** 「평면도」 묶음 — 종류마다 이름 줄(× 지우기) + 장 단추, 아래에 종류 · 대피소 자리. */ export function buildPlanSection( items: DesignDrawingItem[], makeButton: (drawing: DesignDrawingItem, label: string) => HTMLButtonElement, @@ -156,7 +156,7 @@ export function buildPlanSection( ): HTMLElement { const section = el("section", "b07-drawing-group b07-plan ui-collapsible ui-sidebar-section"); section.dataset.kind = "plan"; - section.append(el("h3", "ui-collapsible__title", `계획평면도 ${items.length}`)); + section.append(el("h3", "ui-collapsible__title", `평면도 ${items.length}`)); const kinds = new Map(); for (const item of items) { @@ -173,7 +173,8 @@ export function buildPlanSection( makeButton(drawing, sheets.length > 1 ? `${name} ${index + 1}장` : name), ); head.append(sheets.length > 1 ? el("span", "b07-plan-kind__name", name) : buttons[0]); - if (controls) { + // × 는 종류만 — 배수유역도는 묶음 안 고정 항목(지우면 서버가 404). + if (controls && sheets[0].kind === "plan") { const remove = el("button", "b07-plan-tools__remove", "×"); remove.type = "button"; remove.title = `「${name}」 종류 지우기`; @@ -192,7 +193,7 @@ export function buildPlanSection( // 종류 · 대피소 자리는 펼칠 때 서버에서 지금 상태를 받는다. const tools = el("details", "b07-plan-tools"); tools.append(el("summary", "", "종류 추가 · 업데이트 · 대피소 자리")); - tools.addEventListener("toggle", () => { + const load = (): void => { if (!tools.open || tools.dataset.loaded) return; tools.dataset.loaded = "true"; void fetchPlanKinds(controls.projectId) @@ -204,7 +205,11 @@ export function buildPlanSection( "error", ); }); - }); + }; + tools.addEventListener("toggle", load); + // 기본 펼침 — 종류마다 얹을 정보(사용자 지정)가 바로 보이게(93-6). + tools.open = true; + load(); section.append(tools); } return section; diff --git a/B07_DesignDetail/B07_DesignDetail_UI_Style.css b/B07_DesignDetail/B07_DesignDetail_UI_Style.css index 20dd06b03..fcc59ec17 100644 --- a/B07_DesignDetail/B07_DesignDetail_UI_Style.css +++ b/B07_DesignDetail/B07_DesignDetail_UI_Style.css @@ -80,8 +80,9 @@ cursor: not-allowed; } -/* 그리드 제목은 두 열을 가로지른다 */ -.b07-drawing-group[data-kind="cross"] h3 { +/* 그리드 제목 · 표준 횡단면도는 두 열을 가로지른다 */ +.b07-drawing-group[data-kind="cross"] h3, +.b07-drawing-group[data-kind="cross"] [data-drawing-id="cross_standard"] { grid-column: 1 / -1; } From 71670f470cb955b4a8152ddbd4c5aa8e1c0f2b45 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 13:55:15 +0900 Subject: [PATCH 11/13] =?UTF-8?q?chore(plan):=2098=EC=9E=A5=20B08=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=A7=91=EA=B3=84=ED=91=9C=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=EB=B0=B0=EC=88=98=20=EB=B0=B0=EA=B4=80=20?= =?UTF-8?q?=E2=80=94=20=EC=A1=B0=EC=82=AC=20=C2=B7=20=EC=A0=95=ED=95=A8=20?= =?UTF-8?q?=EB=8D=A7=EB=B6=99=EC=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY --- docs/raw/요구_1008_2/분석_정함.md | 11 +++ docs/raw/요구_1008_2/조사_M_B08집계표_배관.md | 68 +++++++++++++++++++ 2 files changed, 79 insertions(+) create mode 100644 docs/raw/요구_1008_2/조사_M_B08집계표_배관.md diff --git a/docs/raw/요구_1008_2/분석_정함.md b/docs/raw/요구_1008_2/분석_정함.md index e8bde0a2f..ac1838b1b 100644 --- a/docs/raw/요구_1008_2/분석_정함.md +++ b/docs/raw/요구_1008_2/분석_정함.md @@ -46,6 +46,17 @@ - **글 넘침(97-2)** — 글을 칸 세로 가운데로 · 칸 폭 = 글 폭에 맞춤(최소 150) · 단축키 후보 목록도 바탕 안(최대 6줄 그대로). - **Space(97-3)** — Space = Enter(명령 · 숫자 · 좌표 · 명령줄) · 빈 입력에서 Enter · Space = 마지막 명령 반복(오토캐드 꼴) · 글 단계(TEXT · MTEXT · 표 칸 · 이름)에선 띄어쓰기 · 한글 조합 중 · Shift/Ctrl+Space · 길게 누름은 제외 · 빈 우클릭은 반복 안 함(지금처럼 확정만). +## 98. B08 구조물 집계표 「횡단배수 | 배관」 실무 꼴 (덧붙임 · sub7 · Opus high) + +근거 = `사용자요구_1008_2b.md` · `조사_M_B08집계표_배관.md` · 참고 표 원본 = 실무문서 2024 간선임도 2공구 `4.2 수량산출(2공구).xlsx` 「구조물집계표」. + +- 배관 한 곳의 컨테이너 값으로 열을 펼침(설계에 쓰인 값만): 관 공 › Φ{관경}m/m › 관매설(m · B06 관 길이 1 m 올림 그대로) · 커플링(ea · 올림(L ÷ 1본) − 1 · 1본 길이 = M02 구조물집계표 변수 — 지금 8 m · 실무 표는 6 m 로 보임 → 사용자가 M02 변수에서 고침) · 관보호공(집수정 실제 모양별 개소 · 글 = M02 정의 이름 — 「돌집수정ㄷ형」으로 보이려면 M02 이름 고침 · 관경 묶음 안) / 기슭막이 › 형태 › H=높이(m · 유입 흙막이 · 유출 · 실제 선 흙막이 단까지 · 빈 높이 = 그림과 같은 관경 최소 높이) / 돌붙임 › 찰붙임 · 메붙임(㎡). +- 돌붙임 찰/메 · 면적은 원천이 없음 → 도수로/돌붙임 컨테이너에 「붙임 형식(찰붙임 · 메붙임)」 · 「면적 ㎡」 칸을 더함(빈칸이면 집계 안 함 · 설계자가 넣음). +- 계산 = 서버 단독(새 길 `GET /api/projects/{id}/quantity/summary` · B08 Router 새 파일 · M02 채움 함수 재사용) · 화면은 받은 칸만 그림. +- 머리 = 구조물 · 형식 · 규격 3단 + 단위 줄 · 맨 아래 계 줄 · 측점 글 「71+13」(실무 꼴) · 조합 잇기(「횡단배수 | 배관」)는 맨 윗 머리 이름 + 일위대가 · 도면 짝으로 남김(펼친 열에도 조합 id — 95-3 모달). +- 집수정 실제 모양(자동이면 지형이 세운 L 등)을 B06 [저장] 때 설계에 남김(관 길이 · 흙막이 단과 같은 길). +- 이번은 배관만 · 참고 표의 다른 구조물 열(포장 · 측구 · 개거 등)은 다음. + ## 2. 같은 파일 몫 | 파일 | 몫 | diff --git a/docs/raw/요구_1008_2/조사_M_B08집계표_배관.md b/docs/raw/요구_1008_2/조사_M_B08집계표_배관.md new file mode 100644 index 000000000..ea66b747e --- /dev/null +++ b/docs/raw/요구_1008_2/조사_M_B08집계표_배관.md @@ -0,0 +1,68 @@ +# 조사 M — B08 구조물 집계표 「횡단배수 | 배관」 실무 꼴 (10-8 오후 · 읽기 전용 조사 원문) + +결정은 `분석_정함.md` 98 절이 앞섬 · 경로는 루트 기준 · 추정 표시 그대로. + +## 1. 참고 표 재구성 +- 원본 = `resources/knowledge/original/실무문서/2024년 간선임도(기번3-울진.대흥)(2공구)/수량산출/4.2 수량산출(2공구).xlsx` 「구조물집계표」 시트(1~4행 · 병합 직접 읽음) · 요약 `…(2공구)/_분석보고서.md:38-41`. + +| 열 | ① 구조물 | ② 형식 | ③ 규격 | ④ 단위 | +|---|---|---|---|---| +| C · D | 기슭막이 | 찰쌓기 | H=1.5 · H=2.0 | m | +| E · F | 기슭막이 | 메쌓기 | H=1.5 · H=2.0 | m | +| G · H | 돌붙임 | 찰붙임 · 메붙임 | L3:35 | m2 | +| I · J | 반중력식옹벽 | — | H=2.0 · H=1.6 | m | +| K · L | 떼공 | 평떼 | 구조물 · 노견 | m2 | +| M · N | L형측구 | — | A형H=0.2 · C형H=0.7 | m | +| O · P | 포장난간 | — | A형H=0.2 · C형H=0.7(측구에서 복사 흔적 · 추정) | m | +| Q~U | 콘크리트포장 | B · L · A · 확폭 · 수축줄눈 | — | m · m · m2 · m2 · m | +| V~Y | 혼합석부설 | B · L · A · 확폭 | — | m · m · m2 · m2 | +| Z | 물넘이 포장 | — | H=1.0 | m | +| AA · AB | 방지턱 개거 · 수로관 개거 | — | 500*1000 · 400C | m | +| AC | 토사개거 | | | 개소 | +| AD | 레일형 개거 | — | 3W 460*4330 | 개소 | +| AE | 안전난간 | — | 250*200*400 | 개 | +| AF · AG · AH | 국가지점번호판 · 임도준공표지석 · 취수정 | | | 개소 | +| AI · AJ | 콘크리트깨기 | — | 무근30cm미만 | m3 · ton(추정) | +| AK~AN | 관 공 | Φ800m/m | 관매설 · 커플링 · 관보호공 | m · ea · 돌집수정ㄷ형 · 돌집수정ㄴ형 | +| AO · AP | 〃 | Φ1000m/m | 관매설 · 커플링 | m · ea | +| AQ · AR | 〃 | Φ1200m/m | 관매설 · 커플링 | m · ea | +- 관보호공 단위: 4행에 형식 · 실제 단위 개소(1공구 「관보호공 돌집수정 ㄷ형 3 개소」 `…(1공구)/_분석_수량산출.md:53-56`). +- 원본 값: 전구간 S=Q×R · U=INT(R/6.00000001)×Q(식) · AC 10 / 71+13 D10 F20 G10 H10 AQ22 AR3 / 75+0 AF1 / 78+7 F10 H20 AK18 AL2 AM1 / 84+0 F10 H20 AK16 AL2 AN1 / 89+10 E10 H10 AK18 AL2 AN1 / 100+0 AF1 / 102+0 F10 H10 AK16 AL1 AM1 / 105+12 D10 F10 G10 H10 AO16 AP2 / 112+0 E10 H10 AK16 AL2 AN1 / 계(SUM) AK84 AL9 AM2 AN3 AO16 AP2 AQ22 AR3 · 붙여넣기는 71+13 첫 값 10(D)이 빠지고 75+0 에 10 이 붙음(원본 75+0 은 번호판 1뿐). +- 관 한 곳 = 세트(1공구 `_분석_대조.md:22 · 31` 같음): 유입 집수정(Φ800 다섯 곳) = 관보호공 1 + 기슭막이 메쌓기 10 m(유출) + 메붙임 10~20 ㎡ / 유입 흙막이(Φ1200 71+13 · Φ1000 105+12) = 기슭막이 찰 H2.0 10 m + 메 H2.0 10~20 m + 찰붙임 10 + 메붙임 10 · 「유입 찰 · 유출 메」(추정 · 옛 보호공 기본값 `common_util/common_util_drainage_pipes.py:301-302` 와 같음). +- 커플링(추정): INT(L/6.00000001) = 「올림(L/6) − 1」 · 수축줄눈 식과 같은 6 m · 16 · 18 m → 2 · 22 m → 3 맞음 · 102+0(16 m → 1)만 어긋남(손 값 추정). + +## 2. 지금 B08 집계표 +- 화면 `B08_Quantity/B08_Quantity_UI_Summary.ts:71-106` — 구조물 · M02 조합 줄(GET /api/m02/items?all=1) · 관 지점(`fetchDetailPipePoints` :78 · options 까지) · 측점 간격. +- 줄 · 열(`B08_Quantity_Summary_Data.ts`): 줄 = 측점 1 cm 묶음(:51-58) · 열 = `summary_item` + `group_items` 모든 값(:61-83) · 구분 → 이름 가나다. +- 머리 2단(M02 경로[0] / 나머지 · `UI_Summary.ts:84-90`) · 단위 줄 없음(조합 `단위` 받으나 안 씀 `ui_template_item_picker_data.ts:29`) · 칸 값 채우는 곳 없음(rows 에 측점만 :60-61) · 합계 줄 없음. +- 「횡단배수 | 배관」 열 = 관 본체 · 컨테이너에 이은 M02 조합 경로가 「횡단배수 › 배관」(추정) · 윗 머리 「횡단배수」 · 아래 「배관」 · 단위 · 값 없음 · 컨테이너마다 다른 조합을 이으면 열 따로(관 `grv6lny` · 유입 `gnbf9m9` · 유출 `g9lq9uk` · 흙막이 n `gsoiladd{n}` · 도수로/돌붙임 `gchuteadd{n}` — `A00_Common/design/design_structure_pipe.ts:36-92` · 잇는 자리 `B05_Profile/B05_Profile_UI_Structures_Form.ts:152-158`). +- 열 펼치는 엔진은 M02 에: `M02_MasterTemplete/M02_Table_Fill.py`(펼침 · 묶음 · 머리틀 · 값꼴 · 값 = 개소|길이|관연장|옵션 키 :204-214 · 376-420 · 459-561) · 컨테이너 줄(`M02_Table_Fill_Spec.py:73-105`) · 흙막이는 선 단 수까지(:46-55 · 87-94) · 길 GET /api/m02/projects/{id}/tables/{name}/filled(`M02_MasterTemplete_Router_Layers.py:579-605`) · M02 화면만 부름(`UI_Main.ts:490`) · B08 은 안 씀 · M02 표 열 꼴(`머리`[] + `단위`) = 참고 표 4행 모양. +- 그 엔진이 값을 못 내는 까닭: 「지정 항목 모드」(:627-685) · 조합 복사본 열에 `바인딩` 필요 · 마스터 조합 `바인딩: null`(`resources/master_template/구조물집계표조합.json`) · M02 화면에 바인딩 편집 칸 없음(`M02_MasterTemplete_Api_Fetch.ts:208` 꼴만) · 옛 규칙 열(pp_len · pp_cp · pg_in · rv)은 시험 견본에만(`resources/tester/fixtures/m02_master_sample/table/구조물집계표.json`). + +## 3. 배관 컨테이너 값 → 칸 +| 참고 표 칸 | 컨테이너 · 값(키) | 원천 · 계산 자리 | 지금 B08 | +|---|---|---|---| +| 관 공 › Φ{D}m/m | 관 · `pipe_diameter_mm`(800 · 1000 · 1200 · 1500 · 기본 1000) | 폼 `design_structure_pipe.ts:42-49` → `pipe_points.json` options · 저장 `B05_Profile_Api_Pipes_Draft.ts:31-36` · B06 구간값 `B06_Section_Api_Culvert_Options.ts:64-82` | 옴 · 안 씀 | +| 관매설 m | 관 길이(자동 · 폼 보기만 `B05_Profile_UI_Drainage_Facility.ts:339-343`) | B06 기하 1 m 올림(`B06_Section_UI_Cross_Culvert_Geom.ts:287-288 · 505 · 569`) → 설계 `pipe_length_m` · 주인 측점만(`B06_Section_Structure_Layouts.ts:198-209` · Node `B06_Section_Server_Calc_Prebuild.py:63-69 · 314-325`) | 안 옴 | +| 커플링 ea | 없음 | 계산 없음 · M02 표 변수 `파형강관_1본_m: 8`(`resources/master_template/table/구조물집계표.json`)만 · 옛 식 「올림(L÷1본)−1」은 지워져 견본 설명에만(`test_m02_table_fill.py:133-140`) | 없음 | +| 관보호공 › 돌집수정ㄷ/ㄴ형 개소 | 유입 `inlet_type`=집수정(기본) + 모양 `inlet_structure` I · L · U(이름 I형 · L형 · ㄷ형) | 모양 차례: 횡단 patch `inlet_structure`(auto 아니면) > 옵션 · 안 고르면 집수정 없음 · 「자동」이면 지형 규칙이 L(`B06_Section_UI_Cross_Culvert_Basin.ts:40 · 83-102`) · 실제 선 모양은 저장 안 됨 · 재료 칸 은퇴(`design_structure_pipe.ts:95`) | 옵션만 · patch · 자동 결과 안 옴 | +| 기슭막이 › 찰/메쌓기 › H= m | 유입(흙막이) · 유출 · 흙막이 n — 형태 · 높이 · 길이(=전+후) `{inlet\|outlet}_revet_*` · `soil_add_{n}_*` | 폼 `design_structure_guard.ts:23-29 · 33-83 · 104-111` · B06 조작이 같은 키(`B06_Section_UI_Page_Structures_Spec.ts:155-173` · `B06_Section_UI_Page_Pipe_Options.ts:103-144 · 196-247`) · 높이 빈 칸 = 관경 최소(Φ800 1.8 · Φ1000 2.0 `B06_Section_Engine_Culvert.py:84-91`) · 흙막이 개수 = 실제 선 유출 단(설계 `extra_walls` `Structure_Layouts.ts:247-321`) | 옵션만 · `extra_walls` 안 옴 | +| 돌붙임 › 찰/메붙임 › L3:35 m2 | 도수로/돌붙임 · 구조 · 두께 cm(기본 30 · 하나만) | `design_structure_guard.ts:114-136` · B06 그림만(`B06_Section_Engine_Culvert.py:229-243` · `B06_Section_UI_Cross_Wall_Hatch.ts:326-362`) · 면적 · 찰/메 원천 없음(옛 `*_protection_area_m2` 옮기기만 `common_util_drainage_pipes.py:305-325`) | 옵션만 · 면적 없음 | +- 「B06 좌측 패널」 = B05 구조물 배치 폼과 같은 폼(일원화 `B06_Section_UI_Adjust_Dock.ts:1-15`) · 저장 두 곳: 폼 값 → `pipe_points.json` options / 조정값(높이 · 형태 h/m · 단 수 · 단 구간 · 유입 형식) → 횡단 설계 patch(`B06_Section_Schema.py:148-160`) · [저장] 때 서버 Node 가 `pipe_length_m` · `extra_walls` 를 같은 설계에 얹음. +- B08 이 이미 받는 것: 토공 길이 `get_cross_section_designs`(확정 노선 먼저 `B08_Quantity_Router_Earthwork.py:39-48`) — 같은 줄에 `pipe_length_m` · `extra_walls` 있으나 면적만 씀 · 새로 읽을 것: 그 둘 · 집수정 실제 모양 · 1본 길이 변수. + +## 4. 고칠 자리 · 크기(계산은 서버 단독 ③ · 지금 집계표는 브라우저 TS 뿐) +1. 머리 4단 + 단위(작음) `UI_Summary.ts:47-59 · 84-90` — 경로를 `group` 여러 단 · `label` = 조합 `단위` · 공용 표 여러 단 지원(`ui_template_data_table.ts:73-106` · 빈 단 세로 합치기는 없음 · 선택). +2. 배관 펼침 · 값(중간 · 서버 ~150줄 · TS ~40줄) — 새 길 `GET /api/projects/{id}/quantity/summary`(B08 Router 새 파일) · 재사용 `M02_Table_Fill.collect_items`(관 · 컨테이너 줄 · `관연장` · `흙막이단`) · `pipe_lengths_from_designs` · `soil_tiers_from_designs` · 배관 규칙(관경마다 관매설 · 커플링 · 관보호공 / 형태 · H 마다 기슭막이 길이 / 돌붙임) 자리 = `M02_Table_Fill_Spec.py` 컨테이너 표 옆 · 머리 글 = 정의 이름(`구조물정의.json` 종류.pipe.이름 · 사용자가 M02 에서 고침) · TS 는 `UI_Summary.ts` 칸 채움(rows :61) · `total` 로 합계 줄. +3. 집수정 실제 모양 정본 저장(작음) — `extraWallRows`(`B06_Section_Structure_Layouts.ts:318`)에 `inlet_basin`(모양|null) · Prebuild 합침(`B06_Section_Server_Calc_Prebuild.py:279-289`)에 한 칸(관 연장과 같은 길). +4. 커플링(작음) — 서버 식 한 줄(관마다 올림(L÷1본)−1) · 1본 길이는 프로젝트 층 M02 표 `변수`(읽는 꼴 `Router_Layers.py:588-594`). +5. 돌붙임 면적 · 찰/메(작음~중간 · 미결) — 원천 없음 · `CHUTE_ITEMS`(`design_structure_guard.ts:114-125`) + 정의 + `_chute` 에 칸 · 또는 B06 띠 길이 × 폭. +6. 경계(추정 권장): M02(사용자) = 조합 경로(머리 글) · 단위 · 정의 이름(ㄷ형 등) · 표 변수(1본 길이) / 코드 = 값 원천 읽기 · 펼침 · 계산 · 바인딩 규칙은 코드(M02 화면에 편집 칸 없음). + +## 5. 시험 · 겹침 · 주의 +- 시험: `test_61_1_b08_summary_table.py`(Summary_Data 를 common_util_summary_item 과 이어 node — 새 import 금지) · `test_61_2_b08_structure_sheets.py`(Drawing 이 `summaryColumns` 씀 `UI_Drawing.ts:59`) · `test_ui_data_table.py`(머리 html 글) · `test_m02_table_fill.py` · `test_52_1b_pipe_links.py` · `test_structures_summary_snapshot.py`(M02 채움 재사용 시) · `test_b06_pipe_length.py`(`STRUCTURE_ROW_KEYS` ↔ `_AREA_KEYS` 짝 · 새 키 같은 꼴) · `test_b08_extra_walls.py` 이미 깨짐(옛 엔진 `tmp/old_code/B08_Quantity/`). +- 겹침: PLAN 95-3(집계표 행 → 구조물도 모달)이 같은 `UI_Summary.ts` · `Summary_Data.ts` — 펼친 열에도 조합 id 남겨야 모달 동작. +- 주의(추정): B08 열은 실제 안 선 흙막이 n 잇기까지 셈(`Summary_Data.ts:68`) · M02 채움은 선 단까지 · M02 지정 모드 `_assigned_match`(`M02_Table_Fill.py:238-247`)는 종류 같은 첫 출처만(유출이 rv 꼴 열 고르면 유입 키) · 빈 높이: 그림은 관경 최소 · M02 `_with_defaults`(:75-81)는 정의 기본 1.5 · 노선: M02 채움은 작업 노선 · B08 토공은 확정 노선 먼저. + +## 미결(조사자 제시) +1. 조합 잇기(「횡단배수 | 배관」) 용도: 맨 윗 머리 이름 + 일위대가 · 도면 짝으로 남김(권장) vs 지움. 2. 커플링 1본 길이: 실무 6 m(2공구 8줄 중 7줄) vs 변수 8 m. 3. 관보호공 글: U = 돌집수정ㄷ형 · L = 돌집수정ㄴ형(추정) · I = ? · 「돌」 근거 · 자리 = 관경 묶음 안. 4. 기슭막이 형태(콘크리트 · 돌망태 · 통나무 · 바자) 별도 열 · 빈 높이 「미확정」 표시. 5. 돌붙임 찰/메 · 면적 입력 칸(옛 기본 유입 찰 · 유출 메 · 10 ㎡) vs B06 띠로 셈 · L3 = 두께? 6. 관매설 B06 1 m 올림 그대로. 7. 전구간 · 계 줄 · 측점 글 「NO.71+13」 vs 「71+13」. From 33aa3950f3ef286841eb18689ef7ede4c61bfc05 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 14:00:05 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat(B06):=20=EC=8B=A4=EC=A0=9C=EB=A1=9C?= =?UTF-8?q?=20=EC=84=A0=20=EC=9C=A0=EC=9E=85=20=EC=A7=91=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=AA=A8=EC=96=91=EC=9D=84=20=EC=84=A4=EA=B3=84=20=EC=A0=95?= =?UTF-8?q?=EB=B3=B8=EC=97=90=20=EB=82=A8=EA=B9=80=20=E2=80=94=20=EB=8B=A4?= =?UTF-8?q?=EB=8B=A8=20=EB=B2=BD=20=EC=A4=84=20inlet=5Fbasin=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=ED=95=A9=EC=B9=A8=20=ED=95=9C=20=EC=B9=B8?= =?UTF-8?q?=20=C2=B7=20=EC=A7=9D=20=EC=8B=9C=ED=97=98(98-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7 --- .../B06_Section_Server_Calc_Prebuild.py | 2 ++ B06_Section/B06_Section_Structure_Layouts.ts | 11 ++++++++-- resources/tester/test_b06_pipe_length.py | 20 ++++++++++++++++++- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index f14a3e08c..34ff50281 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -282,6 +282,8 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int: { "extra_walls": row["extra_walls"], "wall_fill_overlap": row.get("wall_fill_overlap") or {}, + # 실제로 선 유입 집수정 모양(I · L · U | None) — TS `ExtraWallRow.inlet_basin` 짝(98-2) + "inlet_basin": row.get("inlet_basin"), }, ) for row in output.get("extra_walls") or [] diff --git a/B06_Section/B06_Section_Structure_Layouts.ts b/B06_Section/B06_Section_Structure_Layouts.ts index c22bbefc1..b647cdb67 100644 --- a/B06_Section/B06_Section_Structure_Layouts.ts +++ b/B06_Section/B06_Section_Structure_Layouts.ts @@ -16,7 +16,7 @@ import { withoutGuardedFill } from "./B06_Section_UI_Cross_Body_Guard"; import { computeBoxLayout, DEFAULT_BOX_SIDE_ADJUST } from "./B06_Section_UI_Cross_Box_Geom"; import { REVET_EMBED_DEPTH_M } from "./B06_Section_UI_Cross_Culvert_Const"; import { DEFAULT_BASIN_ADJUST, ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types"; -import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; +import type { BasinShape, WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types"; import { computeCardCulvert, culvertLinkFor, @@ -265,6 +265,8 @@ export interface ExtraWallRow { chainage_m: number; extra_walls: BuiltExtraWall[]; wall_fill_overlap: Record; + /** 실제로 선 유입 집수정 모양(patch 형식 · 자동 지형 규칙 결과) · 안 섰으면 null — B08 관보호공(98-2). */ + inlet_basin: BasinShape | null; } /** @@ -315,7 +317,12 @@ export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] wall_fill_overlap[wall.role] = Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3)); } } - rows.push({ chainage_m: section.chainage_m, extra_walls: walls, wall_fill_overlap }); + rows.push({ + chainage_m: section.chainage_m, + extra_walls: walls, + wall_fill_overlap, + inlet_basin: culvert.basin?.shape ?? null, + }); } return rows; } diff --git a/resources/tester/test_b06_pipe_length.py b/resources/tester/test_b06_pipe_length.py index b1fbefd21..f49d38916 100644 --- a/resources/tester/test_b06_pipe_length.py +++ b/resources/tester/test_b06_pipe_length.py @@ -101,7 +101,25 @@ def test_소유_측점에만_싣는다(): layouts = _LAYOUTS.read_text(encoding="utf-8") assert "pipeOwnerChainage" in layouts, "소유 측점을 안 가리고 있다" assert "section.culvert?.chainage_m" in layouts, "관이 놓인 자리를 안 읽고 있다" - py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text(encoding="utf-8") + py = (PROJECT_ROOT / "B06_Section" / "B06_Section_Engine_Culvert.py").read_text( + encoding="utf-8" + ) ts = (PROJECT_ROOT / "common_util" / "common_util_culvert_sets.ts").read_text(encoding="utf-8") assert '"chainage_m": pipe_chainage' in py assert "chainage_m: pipeChainage" in ts + + +def test_집수정_실제_모양이_같은_길로_정본에_실린다(): + """98-2 — 실제로 선 집수정 모양(patch · 자동 지형 규칙 결과)이 다단 벽과 같은 줄로 정본에 감. + + TS 가 줄에 싣고(`ExtraWallRow.inlet_basin`) 서버가 합칠 때 받아야 함 — 한쪽만 있으면 조용히 버려짐. + """ + layouts = _LAYOUTS.read_text(encoding="utf-8") + block = layouts[layouts.index("export interface ExtraWallRow") :] + assert "inlet_basin: BasinShape | null" in block[: block.index("}")] + assert "inlet_basin: culvert.basin?.shape ?? null" in layouts + prebuild = (PROJECT_ROOT / "B06_Section" / "B06_Section_Server_Calc_Prebuild.py").read_text( + encoding="utf-8" + ) + merged = prebuild[prebuild.index("extra_walls = [") :][:900] + assert '"inlet_basin": row.get("inlet_basin")' in merged From 580c23a5625ddf17b0a7cb240a4c7237ec891838 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 8 Oct 2026 14:00:37 +0900 Subject: [PATCH 13/13] =?UTF-8?q?fix(B08):=20=EC=88=98=EB=A6=AC=20?= =?UTF-8?q?=ED=91=9C=20=EC=B4=88=EC=95=88=EC=9D=B4=20=EC=9E=88=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EC=A0=80=EC=9E=A5=EB=B3=B8=20=EB=8C=80=EC=8B=A0=20?= =?UTF-8?q?=E2=80=94=20=EC=97=B0=EA=B2=B0=EA=B0=92=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=90=98=EB=8F=8C=EB=A6=B0=20=EC=B9=B8=EC=9D=B4=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B3=B8=20=EC=86=90=20=EA=B0=92=EC=97=90=20=EA=B0=80?= =?UTF-8?q?=EB=A0=A4=EC=A7=80=EB=8D=98=20=EA=B2=83(96-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BVPp2o6Mh2FZJr72yW3ofZ --- B08_Quantity/B08_Quantity_UI_Hydro.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index 80512c3b3..07450ae0c 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -99,8 +99,8 @@ export async function renderHydro(host: HTMLElement, projectId: string): Promise ...sheet.defaults, ...(station.sheet === "pipe" ? {} : sheet.sheet_defaults[station.sheet]), ...station.linked, - ...layer(station, sheet.saved[keyOf(station)]), - ...layer(station, drafts[keyOf(station)]), + // 초안은 측점 입력 통째 — 있으면 저장본 대신(초안의 null = 연결값으로 되돌림) + ...layer(station, drafts[keyOf(station)] ?? sheet.saved[keyOf(station)]), sheet: station.sheet, }) as HydroInputs; /** 연결값과 같은 칸은 null — 저장 · 초안이 연동을 유지(B05 값이 바뀌면 따라옴) · 다르면 손 값 */