601 Commits
Author SHA1 Message Date
eomsangdon ac68232d28 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 21:29:14 +09:00
eomsangdonandClaude Opus 5 efaf2403a0 feat(b09): 301 유로폼 사용수량 12-38-2 — 임대료 또는 손료를 「자재 단가」 탭에서 설계자가 넣은 쪽으로 세움
· 손료 = 자재가 × 표 수량 곧장(패널 0.089 · 내부 패널 0.003매/㎡) + 부자재 24/52/79% · 소모자재 5%(주자재비 = 패널 + 내부 패널) — 봉화 2024 호표 재료비 보통 4,500 · 간단 3,697 과 일치
· 임대료 = 「유로폼 임대료 ㎡당」 한 줄 · 부자재 % 는 원문이 안 적어 안 걺
· 둘 다 · 한쪽만 반쯤 · 아무것도 — 안 세우고 사유(부모 12-38 까지 그대로)
· 12-38-1 잔존율은 안 곱함 — 표 수량이 이미 12회·25% 몫으로 보임(우리 역산 · 차 2.5% 는 추정이라 비고에 밝힘)
· 이중계상 ③ 가드에서 12-38-02 만 뺌(패널이 자재총괄에 줄 없음) · 유로폼이 자재총괄로 가면 빨강 시험
· 합산형 부모 — 갈래 벌이 같으면 여러 단계 갈래끼리 합산(12-38 = 사용수량 + 설치·해체)
· 작업조 표 7 은 밑수 없음 목록에서 뺌(1단위당 = 인원 ÷ 시공량) — 12-38-3 설치·해체가 막혀 있던 자리
· 마스터 재생성 — 12-38 · 12-38-02 갈래 키만 바뀜

기본 조립 제목 금액 변화 0 · 프로젝트 내역 변화 0 · 잴 시험 일곱 빨강→초록 · 끄기 넷 빨강 · 전체 시험 1677 + 318 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 21:29:04 +09:00
eomsangdonandClaude Opus 5 8f4ec48759 feat(b06·b08): ㉱ (나) 암 환산계수를 구성비 가중으로 — 토적표·유토곡선·운반표가 한 함수(mixed_conversion_factors)
- B06 암은 한 종류 자리표시라 토적표 보정량·유토곡선이 리핑암 C 하나로 쌓았음 · 구성비·갈래별 시공법이 다 서면 암 두 칸을 Σ몫×C(시공법)로 · 비면 종전 값(인계가 막힘 사유)
- 계수를 내는 곳 넷(B06 서버 재계산·곡선 문맥 · B08 토적표·사토 계수)이 같은 함수 · 화면 「무엇을 골랐나」는 갈래별 값 그대로
- 936be972 연암 60 리핑/보통암 40 발파 · 서버 재계산: 토적표 깎기 보정량 6,296.81 = 유토곡선 6,296.8 · 암 보정량 4,167.31 → 4,384.74 · 토취 7,259.84 → 7,053.83 · 사토 55.31 → 58.2 · 운반 검산 0 · 되돌려 재계산 원래 값 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 21:11:44 +09:00
eomsangdonandClaude Opus 5 27e068aa12 feat(b08): ㉱ (가) 암 운반량을 구성비로 가름 — B06 리핑암은 자리표시(8-1 사용자 확정 · 브레인 판정 「구성비가 정본」)
- 운반표를 만든 한 곳(Router_Earthwork)에서 암 줄을 흙깎기와 같은 구성비(_split_by_rock)·갈래별 시공법으로 가름 → 운반거리·토공집계 운반 줄·인계·사토가 한 값
- 구성비가 비면 「암」 한 줄로 막음(깎기와 같은 사유) · 시공법 안 고른 갈래만 막음 · 무대(내역 밖) 줄은 안 막음
- 유토곡선 거리·다짐 부피는 그대로(몫대로 나눔 · 검산 차이 0) — C 까지 구성비로 맞추는 것은 (나) 차례
- 936be972: 구성비 없음 → 운반·적재·사토 암 전부 입력 사유(종전 리핑암 금액 섬) · 연암 60 리핑/보통암 40 발파 → 흙깎기 2,060.043/1,373.362 · 도자 38.123/25.416 · 덤프 112.195/74.797 · 사토 28.858/19.238 같은 몫 · 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 20:54:28 +09:00
eomsangdon b6d5c9acc8 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 20:47:02 +09:00
eomsangdon 1e791b487d Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 20:32:32 +09:00
eomsangdonandClaude Opus 5 09247fd16f feat(b09): 300 발파 9-5-1 — 화약류(폭약·뇌관·비트) 자재 단가 칸 · 설계자 단가가 들면 표 수량으로 붙고 아니면 「규격 미정」 사유 · 착암기는 공기압축기 부수물(건설품셈 8-3-6 (5205) [주]① 「부수물은 별도 계상」)이라 압축기 손료에 안 들고 래그해머 손료 고시 없음 사유 · 잡재료 5% 사유(브레인 판정)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 20:32:30 +09:00
eomsangdonandClaude Opus 5 3ecc23ac93 feat(b08): 초류종자살포 잎 고르기 칸(브레인 ㉮) — 매핑이 부모 5-24(갈래 고르기형)라 고를 칸이 없어 금액이 영영 안 서던 자리
- 산출 조건 「초류종자살포 비탈면 토질」(5-24-1 기계/일반 · 5-24-2 기계/마사토 · 서버 선택지 · 제안값 없음) · 비면 금액 없이 입력 사유
- 매핑 `leaf_from`·`leaf_codes` 가 설정값으로 잎 코드를 고름(인계 한 곳 · 갈래 없는 매핑은 종전과 같음)
- 936be972 일반 고름 → 인계 FP-05-24-01 막힘 풀림 · B09 는 다음 막힘 「트럭 4.5ton(장비 줄) 카탈로그에 없음 · 종자 규격 미정」으로 아직 금액 없음 · 면고르기 성토면 12,350.32㎡ 40,373,196원 · 절토면 3,087.51㎡ 7,731,123원(파종 면적 그대로 따라감) · 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 20:25:41 +09:00
eomsangdon 2799cad714 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-14 20:15:55 +09:00
eomsangdonandClaude Opus 5 1400b7b761 feat(b09): 기계 수송비 회수(대수 × 왕복) 설계 입력 칸 — 원문에 공식이 없어 사유와 함께 엶
산림품셈 10-4 · 건설품셈 8-1-3 은 회당 단가 산출식만 주고 대수·횟수를 정하지 않음(전수 확인).
지어내지 않고 설계 입력으로 닫음.

· parse_trips — 비거나 0 이면 없음. 기본값을 두지 않음(1 회로 안 때움)
· transport_amount — 회수 × 회당 단가. 회수가 없으면 None(0 원으로 안 채움)
· TRIPS_NOTE — 「원문에 공식 없음 · 설계 입력 · 비우면 금액이 안 섬」 사유
· 도는 자리 넷 — 저장(PUT estimation/factors) · 산출 조건 ② · 산출 조건 화면(회당 N원 × M회) ·
  비면 transport_notes 에 사유가 붙어 못 채운 자리로 뜸
· 화면에 있던 「회수는 여기서 안 정합니다」 안내는 칸으로 바뀌어 걷음

잴 시험 먼저 빨강 확인한 뒤 고침. 전체 시험 1976 통과 · 28 건너뜀 · 1 xfail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 20:13:25 +09:00
eomsangdonandClaude Opus 5 c7f34b8c4b feat(b09): 사람이 가른 표 형태 66표 까닭을 화면에 — 일위대가 상세 회색 한 줄(form_basis_note) · 일위대가 없는 공종은 내역 비고 곁 · 판정표 한 벌(_Forms.FORM_JUDGMENTS)을 읽음(10-A ⑭ · 원문 근거 없는 SW 규칙)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 20:12:09 +09:00
eomsangdon 027c305e16 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 19:44:57 +09:00
eomsangdonandClaude Opus 5 475ad7875e feat(b08·b05·b09): 10-A 우리가 정한 SW 규칙을 화면에 드러냄 — 훑기 21건 중 안 보이거나 반만 보이던 13건 채움 + 9-21 제근 굴착기 크기 칸
- 문구 못박음 셋: ① 옹벽 높이 칸 밑 「기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음」(등록부 default_basis 를 모든 칸 밑 회색 한 줄로) · ② 일반관리비 「임도가 어느 쪽인지 규정이 없어 (주)공사 기본 · 칸에서 바꿀 수 있음」 · ③ 모르타르 배합 「산림품셈에 배합 절이 없어 건설품셈 [건축] 9-1-1 을 씀(교차 참조)」
- B08: 막자갈 비고 「칸을 새로 두는 것은 B05 등록부 몫」 · 일위대가 머리 5단 한도 · 야면석 계수 칸 밑 「돌 종류는 그대로」 · 식 저장 전 상태 줄 「제원을 바꿔도 따라감」 · 양식 장 머리 「반올림은 m당 값에 걸고 수량 = m당 × 연장」
- 산출 조건 제근 굴착기 크기 칸(임목축적 등급 밑 · 「안 정함」 · 회색 제안 0.7㎥ + 근거 + [제안값 넣기] 누른 때만) — 936be972 소림 + 0.7 → 뿌리뽑기 55원/㎡ × 17,873.80㎡ = 983,057원(되돌림)
- 라이브러리 이름표: [내 라이브러리에 저장]·[발행] 창이 장 이름(종류 + 제원 요약)을 채워 묻고 고친 이름으로 저장 · 취소면 안 씀 · 비우면 양식 이름
- B05 계곡 통과 시설 폼 머리 「[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸은 그대로 둠」 · B09 폐기물처리비 자리 칸 밑 「법정경비 밑수에는 안 넣음」 · 내역서 「금액을 못 세운 줄」 첫 줄 「사유는 아래 단계가 낸 것을 그대로 옮김」
- 남은 하나(표 형태 66표 까닭 form_basis 화면)는 자원 축 파일을 거쳐야 해 브레인 물음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 19:44:18 +09:00
eomsangdon af609a381e Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-14 19:38:29 +09:00
eomsangdonandClaude Opus 5 7c9f75ca12 feat(b08,b09): 12-15 집수정 조립 — 콘크리트 집수정만 묶음(when) · 조각 구체콘크리트 ㎥ · 버림콘크리트(성분 없음 막힘) · 합판거푸집 #4회 · 철근 #보통(12-15 표 정본 · 간단 추정 걷음) · 판정표 비고 칸 인력으로 #버림콘크리트(87,489원/㎥) · 구체는 다짐기 없어 안 세우고 사유 · 뚜껑·설치비 사유 · 돌집수정 종전대로 · 묶음은 타설·잡석 줄이 또 안 셈 · 마스터 재생성(바뀐 공종 12-15 하나)
금액은 아직 안 서고 막힌 까닭이 드러나는 일감(브레인 ㉯ ⑴~⑸) · 배정 프로젝트 변화 0 · 전체 1962 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 19:38:27 +09:00
eomsangdonandClaude Opus 5 5a016979c4 fix(b09): 안전관리비 제3조 적용 하한을 엔진이 읽게 함 · 1.2배 차례와 관급 ÷1.1 사유 명기
3. 제3조(적용범위) 「총공사금액 2천만 원 이상」 — 요율 데이터의
   minimum_total_construction_amount_krw 를 규모 기준액과 견주고 미달이면 줄 자체를 안 만듦
   (A·B 곁줄도 안 섬 · 0 원으로 안 채움). 잴 시험 먼저 빨강 확인한 뒤 고침.
   ⚠ 견주는 값은 규모 기준액(추정가격 · 없으면 수렴한 총원가) — 고시의 「총공사금액」과 딱 같은
     말은 아니나 계산 차례상 안전관리비 앞에 설 수 있는 값이 그것뿐. 보건관리자 문턱과 같은 축.

1. 1.2배 차례 = STmate 쪽 확정(브레인 판정) — 안 고른 갈래의 사유를 코드에 남김.
   거창 2025 원본은 버림(밑수 × 율 × 1.2) 로 1원 위지만 STmate 출력이 아니라 채택하지 않음.

4. 관급 ÷1.1 = 실무 따름 — 근거를 정확히 적음. 고시 제4조① 단서는 「해당 재료비를 대상액에
   포함」까지만이고 부가세를 말하지 않음 · ÷1.1 은 부가세 제외 환산으로 실무 6건 공통.

전체 시험 1952 통과 · 28 건너뜀 · 1 xfail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 19:22:08 +09:00
eomsangdonandClaude Opus 5 b23db36c78 feat(b08): 옹벽 조립 조각 합판거푸집이 사용횟수 갈래로(「FP-12-04#3회」 · 성분의 reuse_count 그대로) · 집수정 거푸집 6회 → 4회(12-15 표 「합판4회」 정본 · 사유 문구도 그 표) — 화면 자재 표·인계 성분 한 벌(브레인 ㉯ ①②)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 19:05:55 +09:00
eomsangdon 3ca1a45b2b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 18:37:03 +09:00
eomsangdonandClaude Opus 5 72ec6c2135 feat(b09): 12-4 합판거푸집 사용횟수 1~6회 갈래 — 기준수량(1회) × 원문 비율(재료 줄 재료 % · 인력 줄 노무비 %) · 설계로 막아 둔 표 풀림 · 사용고재 23%·동바리·소형구조물 할증은 값 안 짓고 사유 · 재료는 카탈로그 없음/규격 미정으로 못 붙은 줄(인력 품만) · 마스터 갈래 키 재생성(바뀐 공종 12-04 하나)
일위대가 새 제목 6(1회 81,321 → 4회 32,528 → 6회 26,023원/㎡) · 나머지 462 변화 0 (브레인 ㉮)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 18:27:36 +09:00
eomsangdonandClaude Opus 5 0bb14ce2b3 feat(b08): 라이브러리 공유 — [동료에게 보내기] 같은 회사 동료의 「받음」 단으로 복사(개인 단을 안 덮음 · 보낸 사람 기록 · 출처 공사명 그대로) · 받음 단 항목도 가져오기·복제 가능 · 회사 밖·나에게·내 것 아닌 항목은 막음(브레인 판정 ①)
화면: 복제로 만든 개인 항목 선택 → 동료 목록 받음 → 확인창 「받는 쪽 목록에 받음으로 뜸 · 그 사람 것은 안 덮음」 · 공용 storage 라 취소(동료 폴더 안 생김 확인) · 개인 항목 지워 되돌림 · 쓰기는 임시 storage 시험

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 18:25:22 +09:00
eomsangdonandClaude Opus 5 ecd25ae9ee fix(b08): 프로그램 기본 발행본엔 원문 공사명·파일명을 안 실음(브레인 ②) — 출처에 「원문에서 뽑음 — 공사명은 발행 시 가림」 · 원본(개인 단)·회사 단 발행은 그대로(③) · 발행 확인창에 「이 항목은 「○○」에서 뽑은 것 — 공사명은 빼고 발행됩니다」
화면(936be972 · 봉화 호표 가져온 장): 확인창 문구 확인 · 공용 storage 라 취소(쓰기 없음) · 가림은 임시 storage 시험 · 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 18:21:24 +09:00
eomsangdon 1550c7caf6 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 18:18:07 +09:00
eomsangdonandClaude Opus 5 99a79e8e0b feat(b08): 라이브러리 관리자 발행 — 프로그램 기본도 storage 작업본(git resources 는 씨앗 · 없는 코드만 · 덮어쓰기 금지) · [회사 라이브러리에 발행](마스터·시스템 관리자) · [프로그램 기본으로 발행](시스템 관리자) — [내 라이브러리에 저장]과 같은 모양 · 권한은 서버가 can_publish 로 내리고 화면은 그대로만 보임
화면(시스템 관리자 계정): 두 단추 보임 · 확인창 「모든 회사가 쓰는 프로그램 기본에 발행 — 같은 종류가 있으면 덮어씀」 · 공용 storage 라 취소로 멈춤(쓰기 없음 확인) · 쓰기는 시험(임시 storage)으로 잼

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 18:06:11 +09:00
eomsangdon 60d7c005bd Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 18:01:36 +09:00
eomsangdonandClaude Opus 5 26d9092a9b fix(b09): 원가계산 나머지 세 형식 사유를 요율표 셋 결론으로 — 실적형 「임도 미적용 — 100억 미만(예정가격작성기준 §37②)」 · 수공 세부 경비 「법정 요율 없음 — 값을 넣어 주십시오(§34①)」 · 실적 안전표는 고시 편입이나 실적형 미적용 · 탭 말풍선도 같은 말
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 18:01:34 +09:00
eomsangdon 29a0e64b30 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 17:57:43 +09:00
eomsangdonandClaude Opus 5 64e43463f4 feat(b08): 라이브러리 [복제해서 내 것으로] — 기본·회사 항목을 개인 단에 베낌(출처 cloned_from 기록 · 새 코드 · 프로젝트 작업본 안 바꿈 · 개인 단 항목에선 안 눌림 · 같은 종류 내 것은 덮어씀을 먼저 물음)
화면(936be972 돌쌓기(찰) 장): 기본 항목 복제 → 목록 「개인 · [양식형] 돌쌓기(찰)」 + 「내 라이브러리에 베낌 — 가져와 고친 뒤 [내 라이브러리에 저장]」 · 프로젝트 라이브러리 폴더 안 생김 · [내 것 지우기]로 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:45:46 +09:00
eomsangdon 254007573f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 17:42:51 +09:00
eomsangdonandClaude Opus 5 64a52df680 feat(b08): 9-21 제근 굴착기 크기 칸 — 크기(0.2·0.7)·임목축적 등급을 매핑 variant_template 로 한 갈래 값(「0.7·소림」)으로 엮고 범위 별칭(FP-09-21)이 B09 갈래로 이음 · 제안 0.7㎥(산림품셈 10-12-1 [주]①)은 칸 곁에만 · 비면 금액 없이 입력 사유(브레인 판정)
배정 프로젝트: 뿌리뽑기 줄만 사유가 「하위 후보 6건」→「입력이 필요합니다」 · 소림·0.7 넣으면 55원/㎡ × 17,698㎡ = 973,414(되돌림) · 전체 1935 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 17:42:49 +09:00
eomsangdonandClaude Opus 5 a744388276 fix(b08): 고르개 후보 0 이면 다음 길을 적음 — 「낱말·갈래를 바꿔 찾거나 수동 단가(미확정)」 · 조용히 「없음」으로 막히지 않게(936be972 PVC파이프는 단가표에 품셈·자원 모두 0)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:42:04 +09:00
eomsangdon 70d9329fb6 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 17:39:54 +09:00
eomsangdonandClaude Opus 5 42909a01b9 feat(b08): 고정형 장 사유(㉰) · 규격 다름(㉱) — 전개 사유를 걷고 항목 사유를 띄움(남긴 전개 줄에 걸린 사유만 머리 달아 둠) · 항목 제목 H 와 장 제원 H 가 다르면 빨간 테두리 + 「규격 다름」 배지 + 「벽 수량은 항목 박힌 값 · 토공·사토 공제는 장 제원으로 셈」 사유(금액은 섬 · 막지 않음)
936be972 화면: 돌쌓기(찰) H=2.5 장에 H=2.0 호표 → 배지 「규격 다름 H2 ↔ H2.5」 · 테두리 · 사유 첫 줄 규격 다름 + 원문 시점 수량 · 뒷길이·돌 종류 사유 걷힘 · 되돌려 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:27:53 +09:00
eomsangdon 5d2e1edbde Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 17:25:36 +09:00
eomsangdonandClaude Opus 5 8dd452d8f4 fix(b08): 관 벽·돌쌓기 줄 인계에 실제로 쓴 뒷길이 + 출처 — 안 고른 벽도 [주]⑩ 표준표 하한 값으로 13-04-02/05 갈래를 고름 · 고른 값은 원본 그대로(출처 저장 제원)
배정 프로젝트: 인계 줄 18(관 유입·유출부 기슭막이) 갈래 값·판정 근거만 바뀜 · 본체 105,512,060 그대로(모두 미확정) · 전체 1928 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 17:24:32 +09:00
eomsangdonandClaude Opus 5 31decd1558 feat(b08): 남길 성분 목록에 기초잡석·채집석 더함(②) — 실무 벽·돌쌓기 호표 57 전수: 기초잡석 0/57 · 채집석(사토 공제 부피)은 호표 채집 줄(품 · 41/57)과 다른 것
936be972 실측: 고정형 가져오기 → 기초잡석이 호표 밖 몫으로 따로 섬(내역 6-3 3.5 → 7.7㎥ · +515,466) · 채집석 17.76㎥ 사토 공제 그대로 · 되돌려 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:10:26 +09:00
eomsangdonandClaude Opus 5 9e240d886d fix(b08): 고르개 후보를 두 글자 이상 겹친 조각으로 넓힘(①) — 낱말 전부 든 항목 먼저, 그다음 겹친 글자 수 순 · 조각 비교는 글자·숫자만(괄호 안 셈) · 고르는 것은 사람(별칭표로 안 맞춤)
실측(936be972 단가표): 고정형 줄 이름 그대로 찾기 — 종전 8줄 전부 후보 0 → 깬잡석채집 50건(야면석 채집…) · 고임돌채집 14(막돌 채집…) · 레미콘타설(장비) 22(레디믹스트콘크리트 타설 먼저) · 기초다짐 및 뒤채움 16 · PVC파이프 0(자재라 자원 갈래) · 두 번째부터 40~80ms

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 17:08:14 +09:00
eomsangdon 9022640b5f Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-14 17:01:15 +09:00
eomsangdonandClaude Opus 5 00919e86e2 fix(b09): 안전관리비 B 절사 자리를 STmate 실증본 쪽으로 · SA 안전관리비 별도내역 확정
④ 1.2배의 대상을 「호별 산정이 끝난 금액」으로 바꿈 — evaluate 가
   버림(밑수 × 율 + 기초액) 을 먼저 맺고 배수를 곱함(종전엔 곱한 뒤 한 번만 버림).
   근거 둘이 같은 쪽 — 고시 제4조① 단서 문언 · 골든셋 실증(영월 2024 B 20,330,639 → 20,330,638).
   A(배수 1)는 어느 차례로 해도 같은 값.
   ⚠ 두 실증본이 갈림 — 거창 2025 원본(STmate 출력 아님)은 옛 규칙이라 B 가 1원 위.
     목표가 STmate 골든셋 재현이라 STmate 쪽으로 맞춤. 고정값 벌에 갈림을 적어 둠.

⑤ SA(실적수공) 안전관리비 「확인 대기」 → 별도내역. TA 와 같은 자리가 됨.
   PENDING_SAFETY 상수를 걷고 파일 머리 문구도 고침. 금액엔 안 닿음.

⑥ 고시 대조를 상시 벌로 세움 — test_b09_safety_notice.py 여덟 자리.
   별표1 표를 원문 md 에서 읽어 요율 데이터 16칸 전수 대조(값을 시험에 안 박음) ·
   제4조①1·2호 구간 경계 넷 · 단서 A·B·1.2배 · 제2조2호 대상액 구성 · 1.2배 차례 회귀.

잴 시험 먼저 빨강 확인한 뒤 고침. 전체 시험 1911 통과 · 28 건너뜀 · 1 xfail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 16:59:47 +09:00
eomsangdonandClaude Opus 5 b95ad5e838 feat(b08): 면고르기 성토면 회색 제안 + [제안값 넣기] — 서버 fill_suggested 그대로 · 누른 때만 칸에 들어가고 비워 두면 「안 정함」 · 메인 976e5fd9
화면: 칸 빈 값 · 회색 「제안(비우면 안 정함): 성토면 · 기계 · 무한궤도 — 영월 실무…」 · 단추 누르면 칸만 바뀌고 저장값은 그대로 빈 값(저장 안 함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 16:53:25 +09:00
eomsangdon 9d59a32898 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 16:50:40 +09:00
eomsangdon eff340aad3 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 16:49:33 +09:00
eomsangdonandClaude Opus 5 ef03daa482 refactor(b09): ResourceAxis.py 713→620줄 — 셀 거르기·물결표 목록·셀 정규화를 _Labels 로 순수 분리(옮기기만 · 바깥 이름 그대로 다시 내보냄)
옮긴 글 원문과 같음 · 자원 축 출력·일위대가 462 제목 금액 전후 같음 · 린트 종전과 같음 · 전체 1909 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 16:49:31 +09:00
eomsangdon fcb21eb945 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-14 16:45:03 +09:00
eomsangdon 9d8c3a6068 auto: 2026-09-14 16:44 (EOMSANGDON-HOME) 2026-09-14 16:44:53 +09:00
eomsangdonandClaude Opus 5 fc825dc9d3 feat(b08): 양식이 안 품은 토공·버림 성분은 전개 값을 남김(㉯) — 남길 목록을 코드에 못박음(터파기·되메우기·잔토처리·버림콘크리트) · 목록 밖은 양식으로 갈음 · 목록 안이라도 양식이 그 줄을 품으면 양식 값 · 구조물도 장에도 남긴 줄을 사유와 함께 보임
936be972 실측: 고정형(봉화 제6호표) 가져오기 → 구조물터파기·되메우기·잔토·버림 타설 전과 같음(전엔 −6,037,712원) · 본체 차이는 미완으로 금액 밖이 된 돌쌓기 2줄 −4,062,480 뿐 · 되돌려 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 16:38:48 +09:00
eomsangdon 33ff0c50f0 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 16:36:39 +09:00
eomsangdonandClaude Opus 5 3886f3bfc1 feat(b08): 고정형 항목도 일위대가 구성을 가짐 — 공종 코드는 비워 두고 사용자가 고르개로 고름(㉮) · 고르기 전엔 내역 줄이 서되 「미완 — 공종 코드 미정」 금액 밖 · 찾기는 코드 없는 줄 이름으로 후보만 띄움
936be972 실측: 봉화 제6호표 가져오기 → 내역 「기슭막이 H=2.0m 10m」 2줄이 「막힌 줄 9: 공종 코드 미정」으로 섬(전엔 줄이 사라짐) → 코드 3줄 고르고 6줄 수동 단가(검증용) → 일위대가 482,766원/m · 내역 4,827,660원 × 2줄 「수동 단가 6건 미확정」 · 되돌려 본체·인계·내역·원단위·장 전부 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 16:24:33 +09:00
eomsangdon 5c2ae377e0 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 16:20:52 +09:00
eomsangdonandClaude Opus 5 976e5fd992 feat(b09,b08): 9-19-1 성토면 기계 = 굴착기 0.6㎥ 형식 두 갈래(무한궤도 947원/㎡ · 타이어 1,014원/㎡) · 제안 무한궤도(영월 실무 「성토사면고르기 06M3 B/H」 · 브레인 ②) — 칸 곁 제안만, 스스로 안 고름 · 마스터 갈래 키 재생성(바뀐 공종 9-19-1 하나)
일위대가 나머지 460 제목 변화 0 · 9-19-1 못 붙은 줄(굴착기 0.6㎥) 걷힘

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 16:20:51 +09:00
eomsangdon 2745a41963 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 16:12:20 +09:00
eomsangdonandClaude Opus 5 91317295f5 feat(b08): 라이브러리 칸 [STmate 엑셀 읽기] → 호표 고르기 → [내 라이브러리에 넣기] — 읽은 호표 수·구성 줄 수를 보이고 넣기 전에 다시 물음(덮어씀·원문 시점 수량·단가 안 가져옴) · 넣기는 파일을 다시 보냄
배정 프로젝트 화면: 돌쌓기(찰) 장에서 봉화 엑셀 읽기 → 「호표 45개 · 구성 줄 209줄」 → 제6호표 기슭막이(깬잡석,찰쌓기) H=2.0m 구성 9줄 넣기 → 목록 「개인 · [고정형] …」 · [내 것 지우기]로 되돌림(목록 기본 한 줄)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 16:01:50 +09:00
eomsangdonandClaude Opus 5 38a5e785f2 feat(b08): STmate 엑셀 뽑기 창구 두 걸음 — [읽기]는 호표 수·구성 줄 수·못 읽은 사유만(안 씀) · [넣기]는 서버가 파일을 다시 읽어 고른 호표를 개인 단에 고정형으로(회사 없음 403 · 못 읽은 호표 404 · 모르는 종류 400) · main.py 에 창구 한 줄
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:59:11 +09:00
eomsangdonandClaude Opus 5 d6fd3c1796 feat(b08): STmate 출력 엑셀 「일위대가표」 읽개 + 고정형 항목 만들기 — 실무 6건 호표 170 전부 문제 0(코덱스 감사와 같음) · 봉화 45 · 기슭막이 H2.0 구성 9줄
머리 칸이 다르거나 수량이 수가 아니면 칸 주소를 짚은 사유로 그 호표를 통째로 뺌(추측 안 함) · 갈 곳은 구성 = unit_price · 별산자재 = material · % 가산 행 = reference + 항목 사유 · 계약단가·낙찰율 줄 안 뽑고 항목 사유 · 품셈 근거 줄은 formula_text 보존 · 원문 호표·자원 코드와 공사명은 출처로만(판정 Ⓑ~Ⓗ · ㉮㉯㉰)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:56:49 +09:00
eomsangdon 6f484eb6fa Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 15:55:45 +09:00
eomsangdonandClaude Opus 5 2e678ad511 fix(b09): 운전경비 표 규격 칸 괄호 딸림값을 규격 개수에 안 셈 — 공기압축기(5205) 여섯 등 63기종 운전경비가 섬(158→221 · 기존 158 값 변화 0) · 9-19-1 흙 갈래까지 막던 「일부만」(공기압축기 3.5) 풀림
일위대가 금액 바뀐 제목 4(9-5-1 · 9-19-1 연암·보통암ㆍ경암 · 9-19-3) · 나머지 456 변화 0 · 기계 층 미완성 6→4
배정 프로젝트: 면고르기 칸 비면 합계 그대로 · 칸 고르면 성토면 인력 점토 3,269원/㎡ · 절토면 호박돌 4,353원/㎡ 금액 섬(되돌림)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 15:55:44 +09:00
eomsangdonandClaude Opus 5 6c61f5d5ac feat(b08): 산출 조건 면고르기 칸 넷 — 절토면 토질 · 성토면 시공·토질 고르기(서버 선택지 그대로 · 제안값 없음) + 면적 덮어쓰기 둘(비우면 파종 면적) · 메인 ee9368ec
배정 프로젝트: 절토면 토질을 고르고 [저장] → 내역 3-8-2 사유 「입력이 필요합니다」 → 「단가가 일부만 — 공기압축기(이동식) 3.5」 · 「안 정함」으로 되돌려 저장(네 칸 빈 값)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:39:15 +09:00
eomsangdon 391301abf4 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 15:29:31 +09:00
eomsangdon 7b5f8bc5e2 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 15:25:04 +09:00
eomsangdonandClaude Opus 5 ee9368ec66 feat(b08,b09): 면고르기 줄 — 밑수 = 초류종자살포 면적(덮어쓰기 칸 · 파종 0 이면 0 + 사유) · 절토면은 사면 조각 토사/암으로 갈라 암은 시공법(9-19-2·3) · 절토면 토질 · 성토면 시공·토질 칸(제안값 없음 · 비면 입력 사유) · 성토면 기계는 규격 미정 사유(브레인 판정 Ⓐ~Ⓓ)
실무 대조: 오솔길 BOM 6벌 면적 셈법 ±0.11% · 거창 줄 없음 · 영월 성토면 × 50% · 원문 9-19-1 성토면·9-19-2·3 식재 조건부
배정 프로젝트: 인계 줄 +3(성토면 12,350㎡ · 절토면 토사 3,085.51 · 암 2,262.62) 외 변화 0 · 본체 합계 105,512,060 그대로 · 규격 글로 시작하는 갈래는 규격에 한 번만(도자 운반 「토사 토사」 → 「토사」 · 금액 불변)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 15:25:03 +09:00
eomsangdonandClaude Opus 5 b08b79bc6c feat(b08): 라이브러리 가져오기 목록에 항목 종류 배지 [양식형]·[고정형] — 명세 13장대로 식이 한 줄이라도 있나로만 가름(저장된 item_kind 표시는 안 믿음)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:16:55 +09:00
eomsangdonandClaude Opus 5 c7b9956bf3 feat(b06,b08): 관 기준벽도 벽 몸 ∩ 성토 폐회로 겹침을 줄 사유로 — 다단에만 뜨면 「다단만 문제」로 읽힘(㉡)
서버 Node 가 기준벽 역할별 넓이를 design.wall_fill_overlap 에 남기고 B08 이 다단 목록 통(base 표시 칸)으로 읽어 기준벽 줄 비고에 붙임 · 호출부(Router_Material) 안 늘림 · 936be972 빈 저장: 관 9 곳 유출 벽 258.12 1.417 · 720 1.403 · 804.18 1.166 · 982.64 0.807 · 620 0.136 · 440 0.046 · 533.98 0.004 · 85.05/900 0㎡ · 이 프로젝트 기준벽은 모두 미확정(금액 밖)이라 비고 안 뜸 · 벽 칸을 적으면 「1.42㎡ × 10m ≈ 14.2㎥」 · 금액 변화 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 15:02:43 +09:00
eomsangdonandClaude Opus 5 eb79091e00 feat(b06,b08): 다단 벽 몸이 성토 폐회로에도 든 겹침을 줄 사유로 드러냄 — 고치지 않음(2026-09-06 확정 「구조물 면적 안 뺌」 · 판정은 사용자)
서버 Node 가 벽 몸 ∩ 성토 폐회로 넓이를 재 design.extra_walls[].fill_overlap_m2 로 남기고 B08 다단 줄 비고에 「벽 몸 N㎡ × 연장 ≈ ㎥ 두 번 셈」 · 258.12 1단 1.335㎡ × 10m ≈ 13.3㎥ · 금액 변화 0 · 미확정 단은 안 붙임

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 14:47:38 +09:00
eomsangdon df547a90f8 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 14:39:58 +09:00
eomsangdonandClaude Opus 5 f4f9b77bdf chore(master): 9-19-1 갈래 키 8 — 표 읽기가 가른 절토면 6 · 성토면 인력 2 를 마스터에 실음(빌더 재생성 · 다른 공종 변화 0)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 14:37:47 +09:00
eomsangdonandClaude Opus 5 e9b8eacc90 test(b06,b08): 횡단도 벽 폭 ↔ 수량 벽 두께 대조 시험 — 지금 빨강(H2.0 메 · 윗폭 0.675/밑폭 1.375 ↔ 상부 0.75/하부 1.05) · 맞출 쪽 판정 전이라 xfail(strict)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 14:28:07 +09:00
eomsangdon ce8339f34a Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-14 14:19:10 +09:00
eomsangdon 52c7148927 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 14:19:09 +09:00
eomsangdonandClaude Opus 5 ae52a92753 feat(b09): 9-19-1 토사면 고르기 두 표를 갈래로 읽음 — 절토면 토질 6 · 성토면 인력 2(기계 굴착기 0.6㎥ 는 무한궤도·타이어 둘이라 규격 미정) · [주]① 규격은 범위 별칭 · 옆 칸이 규격을 적으면 별칭이 안 덮음 · 성토면 밀린 줄은 사람 판정표로만 · [주]②③ 는 비고(브레인 판정 Ⓐ~Ⓔ)
섞인 본표 B-FP-09-19-01(보통인부 한 줄 2,925원) 걷힘 · 일위대가 나머지 452 제목 금액 변화 0

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 14:19:08 +09:00
eomsangdon d8e6e52f75 auto: 2026-09-14 14:18 (ESD_LAPTOP) 2026-09-14 14:18:01 +09:00
eomsangdonandClaude Opus 5 8dfa68df32 fix(b06,b08): 높이 안 적은 다단 벽은 성토를 안 깎음 — 면적은 첫 미확정 단부터 아래를 없는 것으로 봄 · B08 도 그 아랫단을 「윗단(N단)이 미확정」 으로 같은 선에서 끊음(㉠)
258.12 실측 — 1단만 확정: 성토 감소 −60.36 → −52.50㎥(2단 몫 7.86㎥ 되살아남, +35,534원) · 1단 미확정: 성토·본공사비 다단 없을 때와 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 14:08:49 +09:00
eomsangdonandClaude Opus 5 d352774ce2 feat(b05): 원단위 표가 아직 안 읽는 칸에 「표에 안 쓰임」 — 골막이·바닥막이·큰돌쌓기·흙막이 23칸 · 값을 바꿔 돌려 보는 시험이 표시를 지킴(①)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 14:01:20 +09:00
eomsangdon 42d5566dc9 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 13:57:58 +09:00
eomsangdonandClaude Opus 5 c4a83dbb09 fix(b09): 9-21 제근 「밑수 미확보」 막힘 걷음 — 마스터 목록(F0294)은 원문 사실이라 두고 단가 조립이 사용자 확정 빌린 밑수 공종만 제외(÷1,000 은 ChooseOne) · 갈래 선 내역 줄 금액 섬 · 일위대가 453 금액 변화 0
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:56:51 +09:00
eomsangdonandClaude Opus 5 c59dbba482 fix(b05): 옹벽 높이 제안값 2.5 → 2.0 — 관측 원단위 자료가 반중력식 H=2.0 한 벌뿐(A4)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 13:48:24 +09:00
eomsangdonandClaude Opus 5 5f3c55f32b fix(b08): _collect_structures 가 횡단 설계를 받아 다단 벽 목록을 넘김 — 인자 하나 + 호출부 전달만(④ · 메인 파일 손질 따로 뗌)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 13:37:15 +09:00
eomsangdonandClaude Opus 5 8a17d8308b feat(b06,b08): 다단 추가 기슭막이 수량 — 서버 재계산이 선 다단 벽 목록(design.extra_walls)을 남기고 B08 이 줄을 세움 · 높이 안 적은 단은 미확정(④)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 13:37:15 +09:00
eomsangdon 6b7c093e7e Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 13:23:19 +09:00
eomsangdon e26ef204fc Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 13:17:22 +09:00
eomsangdonandClaude Opus 5 b80eabf4f1 fix(b09): 9-19-1 토사면 고르기 — 두 표(절토면·성토면)가 섞여 보통인부 한 줄만 선 값이라 「불완전 — 쓰지 말 것」 사유를 비고·본표에 먼저(표 모양 본작업 전 · 브레인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:17:20 +09:00
eomsangdonandClaude Opus 5 bba23376f1 fix(axis): 9-19-3 비탈면 고르기(발파암) 소형브레이커 = 5210-0010(1.0㎥/min) — 값 출처 9-19-1 표·[주]① 이 규격을 정함(브레인 승인) · 범위 별칭이 코드를 적으면 그 항목으로 곧장 이음 · 어어호스는 카탈로그 없어 그대로
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:15:09 +09:00
eomsangdonandClaude Opus 5 0e609e69a6 feat(b06): 횡단도 벽 전면 기울기도 품셈 13-4-4 [주]⑪ 표준경사 표 — 관 유입·유출·독립 기슭막이·다단·C군 벽 · TS 짝(common_util_masonry_slope) 거울 시험 · 번들이 표 JSON 도 봄(B5)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 13:14:20 +09:00
eomsangdonandClaude Opus 5 626f518064 fix(b09): 9-13-14·15 육상 발파암 들어내기 — 원문 「(1~2m)와 동일」 자기 참조를 0~1m(9-13-13)로 읽음(브레인 승인 · 고른 값·버린 값 근거 줄에) · 일부만 10% 풀림
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:11:41 +09:00
eomsangdonandClaude Opus 5 8a31f87412 fix(axis): 표 읽기 ② — 원본이 [주] 문장을 절 제목으로 읽은 표 14장은 앞 절을 이어받음(12-2 표면 마무리 가짜 표 7장·갈래 #설치·#철거 → 12-17-1) · 9-21 제근 안내는 못 붙은 줄이 아니라 비고로(빌린 밑수 교차참조)
마스터 전후 대조: 표가 옮겨 붙은 공종 8(12-02·12-17-01·01-01-02·05-34·08-02-03·09-07-01·09-11-01·14-02) · 일위대가 453 제목 금액 변화 0 · 12-02 가짜 갈래 둘이 12-17-01 로만 옮김

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 13:08:01 +09:00
eomsangdon e8cce87413 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 12:57:33 +09:00
eomsangdonandClaude Opus 5 e85ea2fa2c fix(b09): 9-4-1 평균 갈래 치즐 사유에 9-12-3 치즐 평균 선례(0.018)를 함께 적음 — 사용자가 유추 여부를 고르게
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 12:56:02 +09:00
eomsangdon 76b17098e3 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 12:55:04 +09:00
eomsangdonandClaude Opus 5 6b083ce3c5 feat(b09): 치즐 소모량 — 자원 목록 AR-M 치즐(본) · 대형브레이커 줄 옆 본/hr ÷ Q × 배분으로 같은 D 에 붙음(수동 단가가 있을 때만) · 칸을 채우면 치즐 13공종 온전 · 9-4-1 평균 갈래는 원문에 치즐 평균이 없어 사유
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 12:53:32 +09:00
eomsangdonandClaude Opus 5 ad63dd8f99 feat(b05,b06,b08): 독립 기슭막이 치수 한 벌(revetment_sabang) — 높이 설계자 입력·없으면 미확정 · B05 칸 밑 근거와 B08 사유가 같은 파일(사방교본 쪽·줄 번호) · 등록부 높이 2.5 걷음(B4)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 12:49:08 +09:00
eomsangdon db5822f4f5 Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-14 12:48:28 +09:00
eomsangdonandClaude Opus 5 946ac82b5b docs(b09): 축C 명세 — 다른 창 편집분 반영
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 12:48:18 +09:00
eomsangdonandClaude Opus 5 330421fe94 feat(b05,b08): 메쌓기 높이 두 기준 사유(교본 7-3:27 · 품셈 L7192 · 칸 경고만) · 성토/절토 칸 face_role — 비우면 자동 판정(B2·B3)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 12:42:49 +09:00
eomsangdon b0355d5ae1 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 12:38:19 +09:00
eomsangdonandClaude Opus 5 7e036ddd51 feat(b08): 안 고른 돌쌓기 뒷길이를 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한으로 — 범위 안 첫 규격(메 ~3m 36→45) · 전개·양식·구조물도 한 벌 · 사유에 원문 줄 번호(B1)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 12:28:36 +09:00
eomsangdon 2b464138ae Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 12:24:17 +09:00
eomsangdonandClaude Opus 5 560df4ac83 fix(b08): 관측 원단위 줄도 갈 곳 기본값 금지 — destination 없는 줄은 막고 사유(종전 or material 로 조용히 자재행) · 표 전 줄 갈 곳 시험
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 12:22:35 +09:00
eomsangdon f9ccc8f6fb Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 12:12:53 +09:00
eomsangdon 95ce24d31c auto: 2026-09-14 12:12 (ESD_LAPTOP) 2026-09-14 12:12:30 +09:00
eomsangdon 2604e9e914 Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1 2026-09-14 12:10:53 +09:00
eomsangdonandClaude Opus 5 acb377f5db fix(b08): 철근 이름·규격 가르기(이형철근 + D13·D16) — 대응표 조각은 이름+규격 키·이름 두 색인으로 집고 경계 검사도 두 키로 · 관측 원단위 spec 칸을 넘김 · 전후 물량 대조 시험(가드 없이 돌리면 빨강 확인)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 12:09:18 +09:00
eomsangdon beb30999e3 auto: 2026-09-14 11:57 (EOMSANGDON-HOME) 2026-09-14 11:57:37 +09:00
eomsangdonandClaude Opus 5 298133dce4 knowledge(축C 명세): 검산 계약에 「기준이 변경을 잴 수 있는지 먼저 볼 것」 보강
- 골든 금액 시험이 원본 수량을 bill_line 에 바로 넣어 새 길을 안 거침 — 초록이 증거가 아니던 자리
- 원본 321줄 자리 규칙 대조를 따로 만들어 철근 Ton 소수 3자리 4줄을 뽑아냄
- 재지 못한 것을 잰 척하지 않을 것

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-14 11:57:31 +09:00
eomsangdon ac801cedfe Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 11:57:09 +09:00
eomsangdonandClaude Opus 5 d6820db7b0 fix(b09): 내역 수량을 1-2-2 자리로 반올림해 확정한 뒤 금액(버림) — 표에 없는 종목 기본 2자리 · 철근 ton 소수 3자리(kg 정수 환산) · 단위 대소문자 흡수 · STmate 원본 수량 321줄 대조 시험
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 11:55:23 +09:00
eomsangdonandClaude Opus 5 ecf3b65b42 feat(b06): BOX·세월교·물넘이 기본값으로 그린 칸을 횡단 카드 머리에 「기본값(미확정)」 · 월류 높이 없어 파임 안 그리는 까닭(판정 ②③)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 11:50:31 +09:00
eomsangdonandClaude Opus 5 290eccbb92 fix(b05,b08): C군 벽 높이 없어도 놓임 — 높이가 밑수인 벽은 줄만 서고 미확정(판정 ①)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 11:44:01 +09:00
eomsangdonandClaude Opus 5 bd69aa4788 fix(b08): 자재총괄 화면·인계를 한 벌로 — material_table_for 가 규준틀 재료까지 얹고 두 라우터가 같이 부름(화면 6줄·인계 9줄 갈림 닫음)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 11:35:02 +09:00
eomsangdon 37791c5bfb Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-14 11:22:31 +09:00
eomsangdonandClaude Opus 5 a0e144aed5 fix(b08): 할증률 「이내」 칸 — 안 고른 칸은 비우고 회색 제안값(지금 붙는 표값)만 · 기본값을 값처럼 안 채움
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 11:22:26 +09:00
eomsangdonandClaude Opus 5 646081757c fix(b08): 산출 조건 타입에 material_surcharge 칸 — afea225a 뒤 typecheck 빨강 닫음
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 11:09:51 +09:00
eomsangdon 71831432d1 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-14 11:08:01 +09:00
eomsangdonandClaude Opus 5 c12a2bf826 fix(b08): 갈 곳 기본값 금지 — 표에 없는 성분 이름은 막고 사유(종전 quantity 로 떨어져 조용히 빠짐) · 드러난 넷(이형철근 D13·면목·떼 자재 · 유로폼 일위대가) 표에 올림
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 11:07:57 +09:00
eomsangdonandClaude Opus 5 afea225ae5 feat(b08): 자재총괄 할증률 「이내」 칸 초안 연결 — 불러오기·저장 본문·표 옵션(랩탑_메인 87083c33)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 10:54:37 +09:00
eomsangdonandClaude Opus 5 87083c3341 feat(b08): 자재총괄 할증률 「이내」 칸 — 표값 상한 안에서만 고름 · 비우면 표값(초안 연결은 Page 한 줄 대기)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 10:52:52 +09:00
eomsangdonandClaude Opus 5 e6e6f9b7d5 feat(b08,b09): 사급 자재총괄 줄이 수동 단가로 내역 본체 「자재(사급)」 줄이 됨 · 기본 사급 · 이중계상 가드(코드 우선) · 규준틀 재료 이름/규격 가름(할증 각재 5·판재 10) · 할증 보류·이름 없음 사유 · 할증 「이내」 칸 서버
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 10:41:19 +09:00
eomsangdonandClaude Opus 5 57a949c624 feat(b09): 자재 단가 탭 등록 — 자재단가대비표 옆(자재 수동 단가 · 랩탑_메인 273132f8)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 10:12:54 +09:00
eomsangdon aefb476577 Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-14 10:04:09 +09:00
eomsangdon fdb7e27bed Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 10:04:08 +09:00
eomsangdon 273132f8f6 auto: 2026-09-14 10:03 (ESD_LAPTOP) 2026-09-14 10:03:21 +09:00
eomsangdonandClaude Opus 5 aeb18266bd fix(b09): 총액 1,000원 미만 버림을 기본으로 켬 · 절사 자동보정 진동 고침
근거 — 산림청고시 제2025-82호 「금액의 단위표준」 「설계서의 총액 · 원 · 1,000 · 미만버림」.
실무 6건 원가계산서가 여섯 다 000 으로 끝나는 것이 증거.

· 기본 켬 — cut_basis="grand_total" · cut_unit_krw=1000
· 조용히 안 깎음(2026-09-08 합의의 정신) — 이윤 조정액 줄과 결과 메모에 고시 번호를 달고
  기준 입력판 11 에서 끌 수 있음을 함께 적음 · 칸 밑 안내도 고쳐 씀
· 끄는 값을 none 으로 바꿈 — 빈 값은 저장이 안 돼 기본으로 되돌아가 끌 수가 없었음
· 진동 고침 — 걸음마다 ÷1.1(종전엔 첫 걸음만). 이윤 1원이 총공사비 1.1원이라
  잔차를 그대로 빼면 경계를 지나침(울진 신설 654→999→900→910→909…)
· 안 앉으면 「끝자리 N원이 남음」을 메모로 보임
· 실무 6건 전수 000 판정을 새 벌로 지킴

곁 — tmp/tests/test_b09_cost_fixtures.py 를 resources/tester/ 로 옮김(픽스처 포함).
     tmp 쪽 사본은 안 지움 · 겹침은 파일 머리에 적음.
     기본이 바뀌어 깨진 남의 시험 6건은 「안 자른 값」 자리에 cut_basis="none" 을 넣어 고침.

전체 시험 1782 통과 · 28 건너뜀.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 09:40:24 +09:00
eomsangdonandClaude Opus 5 996300752d feat(b08): 사토 운반에도 「덤프 적재」 짝 줄 · 사토 줄이 갈래를 실어 보냄 — 종전 갈래 미지정으로 덤프 잎을 못 고르던 것
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 09:39:42 +09:00
eomsangdonandClaude Opus 5 d5c5fe7b40 fix(b06): 구조물 패널이 스펙 기본값을 관 옵션에 채우지 않음 — 조작·저장분만(폼 ④)
- 페이지를 열기만 해도 관종·관경·구조·벽 길이·전/후·집수정 길이 기본값이 옵션에 박혀 임시 저장·[저장]에 확정으로 굳던 자리
- 벽 형태·높이는 조정창에서 조작했을 때만 싣음(앞 조각 c4eccee5 auto 커밋에 먼저 쓸려 올라감)
- 세월교·BOX: 저장돼 있던 칸이거나 조정창 조작으로 등록부 기본값과 달라진 칸만 스펙 값을 싣음
- ORCA 936be972 B06: 관 폼 관종 「안 정함」 · 저장된 관경 1000 만 · 벽 칸 빔+제안 · 임시 저장에 기본값 안 생김

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:34:31 +09:00
eomsangdon c4eccee51f auto: 2026-09-14 09:30 (ESD_LAPTOP) 2026-09-14 09:30:59 +09:00
eomsangdonandClaude Opus 5 0a7f4a5008 test(b05): 폼 소스 시험을 새 규칙에 맞춤 — 고르기 칸은 늘 빈 보기 · 기본값은 제안으로만(폼 ①②)
- 옛 「기본값 있는 칸은 첫 보기」(2026-08-17)를 확인하던 줄을 브레인 판정(2026-09-14)대로 뒤집음
- 칸 만들기가 `_Structures_Fields.optionControl` 로 옮겨간 자리를 봄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:30:59 +09:00
eomsangdonandClaude Opus 5 fc2ecc0f39 fix(b05): 계곡 통과 시설 폼이 기본값을 값으로 안 보냄 — 관종·관경·구조·집수정 길이·날개벽·BOX·월류·설치 측(폼 ③)
- 관종 파형강관·관경 1000 · 유입/유출 구조 기슭막이 · 집수정 길이 2 · 날개벽 있음·1·2·45 · BOX 2.0×2.0 ·
  월류 폭 5/10 · 월류 높이 필요 수심 · 독립 기슭막이 양쪽 — 전부 「안 정함 (제안 …)」·회색 글씨로만
- [제안값 넣기] 누른 때만 빈 칸에 제안값 · 적은 월류 높이가 필요 수심보다 낮으면 여전히 되돌림
- BOX암거 새로 놓을 때 기본값 싣던 자리도 걷음 · 비운 칸은 횡단도가 등록부 기본값으로 그림(저장만 안 함)
- 700줄 한계로 물넘이·세월교 칸 묶음을 `_Drainage_Facility_Ford.ts` 로 뗌
- ORCA 936be972: 관종 「안 정함」 · 저장된 관경 1000 은 그대로 · 날개벽 고친 요청에 관경·날개벽만 실림 · 캐시 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:28:06 +09:00
eomsangdon 0244749d29 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 09:21:31 +09:00
eomsangdonandClaude Opus 5 2744f1bcbb fix(b05,b08): 구조물 목록 폼이 등록부 기본값을 값으로 안 채움 — 제안으로만(폼 ②)
- 칸에는 저장된 값만 · 기본값은 회색 글씨(숫자)·빈 보기 이름(고르기)으로 제안 · [제안값 넣기] 누른 때만 값
- 새 구조물 추가도 기본값을 안 실음(defaultOptions 로 채우던 자리) · 잠긴 칸(enabled:false)만 프로그램 값 유지
- C군(범위 계산 타입)은 길이처럼 높이도 비면 안 놓음 — 비면 횡단도 벽이 조용히 안 서던 길
- B08: 돌 조달을 안 정하면 「채집으로 셈」 사유를 보임(조용히 채집 되지 않게)
- ORCA 936be972: 돌쌓기(메) 고르면 높이·길이·전·설치측 빔 + 제안 · 단추로 채워짐 확인(저장 안 함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:21:27 +09:00
eomsangdon 48e3eeee08 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-14 09:17:35 +09:00
eomsangdon 7229c0ab4e Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 09:17:34 +09:00
eomsangdon 6a427e78f9 auto: 2026-09-14 09:14 (ESD_LAPTOP) 2026-09-14 09:14:57 +09:00
eomsangdonandClaude Opus 5 eb14ec1c69 refactor(b09): 갈래를 출처로 가름 — 식이 낸 갈래(formula_variants) · 짝 시험은 표에서 온 갈래만
- 제외 목록 대신 갈래마다 어디서 왔나를 한 칸: 덤프 운반·적재 10-12 제목 → 그 식(브레인 판정)
- 짝 시험: 표에서 온 갈래는 마스터 갈래 키에 있어야 함 · 식이 낸 갈래는 출처가 적혀 있고 마스터엔 없어야 함
- 마스터에는 「식 공종 갈래」 칸을 안 둠(표에서 뽑은 것만 담는 자리)
- 전체 1771 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 09:14:56 +09:00
eomsangdonandClaude Opus 5 7eecfacca3 fix(b05): 기본값 없는 고르기 칸은 「안 정함」이 실제 상태 — 첫 선택지를 미리 고르지 않음(폼 ①)
- 뿌리: 사용자가 고른 적 없는 첫 선택지가 저장돼 확정으로 굳음(옛 2026-08-17 「빈 보기 두지 않음」 걷음)
- 구조물 목록 일반 폼: 기본값 없는 고르기 칸은 늘 빈 보기(필수 「— 선택 —」 · 아니면 「— 안 정함 —」)
- 시설 폼 optionalSelect: 맨 앞 빈 보기(날개벽 「안 놓음」 등) — 기본값이 있는 칸은 다음 차례(②③)
- ORCA 936be972 B05 관 폼: 날개벽 「안 놓음」 빈 값으로 섬 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:14:50 +09:00
eomsangdon 4015c27c90 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 09:11:15 +09:00
eomsangdonandClaude Opus 5 8eb1189b4f fix(b05,b06,b08): 기슭막이 벽 높이를 관경 기준 한 값으로 · 폼은 기본값을 값으로 안 보냄
- 한 벽을 두고 값이 셋(등록부·표 H2.5 · 구조물도 H2.5 · 횡단도 관경 기준)이던 결함 — 근거 있는 관경 기준으로 맞춤
- 관 벽 높이 안 적으면 관경 + 여유 0.5 를 0.1m 로 올리고 근입 0.5 더함(Ø800 1.8 · Ø1000 2.0 · Ø1500 2.5)
- 파이썬(B06 엔진)·TS(세트 스펙·횡단 상수) 거울 시험 · 등록부 기본 2.5 걷고 empty_means
- 최소값이라 미확정·금액 밖은 그대로
- B05 벽 칸(관 유입·유출 · 독립 기슭막이)은 비워 두고 회색 제안값 · [제안값 넣기] 누른 때만 값 — 저장 한 번에 미확정이 확정으로 바뀌던 길을 막음
- 전체 시험: 받은 덤프 적재 커밋 몫 2개만 빨강(보고함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 09:10:47 +09:00
eomsangdon f07ff0033f Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/main_laptop_1' into sub_desktop_1 2026-09-14 09:02:25 +09:00
eomsangdonandClaude Opus 5 d5bdd2ee17 docs(b09): 축C 명세에 품셈 판 근거와 금액 단위표준 절 보탬 — 산림청고시 2025-82호
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 09:02:11 +09:00
eomsangdonandClaude Opus 5 9cd2920afc knowledge(축C 명세): 총액 1,000원 미만버림 실측 결과 반영
- 고시 다섯 줄 중 총액 줄만 우리가 안 따르고 있었음(기본값 꺼짐)
- 실무 6건 끝 세 자리 000 ↔ 우리 373·695·578·654·341·639
- 곁 버그 — 절사 자동보정이 둘째 걸음부터 ÷1.1 누락으로 진동
- 집계표 ROUND 는 설계서 소계와 다른 자리로 봄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-14 09:01:37 +09:00
eomsangdon 60aac3892d Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 08:57:29 +09:00
eomsangdonandClaude Opus 5 a440b4f919 feat(b08): 덤프 운반마다 「덤프 적재」 짝 줄 — 10-12 「1. 적재」 · 거리 무관 · 수량은 운반량 그대로
- _haul_rows 한 곳: dump_truck 내역 줄 뒤에 haul_equipment dump_loading 짝 줄(갈래·수량 같음)
- 흙깎기 9-3-2(㎝ 20초·135°)에 싣기가 안 들어 있어 운반과 한 벌로 냄(브레인 판정)
- B09 운반토량 검산(운반 ≤ 절취)에서 적재 줄은 뺌 — 같은 흙을 두 번 세지 않게
- 시험 기대값: 운반표 줄 4 → 6(적재 둘) · 내역 줄 3 → 5 · 전체 1763 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:57:24 +09:00
eomsangdonandClaude Opus 5 a2b3ae698f fix(test): 덤프 적재 뒤 낡은 시험 둘 — 가드 뜻은 지키고 새 모양에 맞춤
- v16 「덤프 운반은 아직 안 선다」가 지키던 것 = 운반거리(설계 입력) 없이 운반이 조용히 금액을 갖지 않기
  → 「거리가 와야 선다」로: 거리 없는 기본 조립엔 운반(#L…m) 없음 · 거리 무관 적재(#적재)만 섬
- 갈래 제목 ⊂ 마스터 갈래 키: 10-12 는 표가 아니라 본문 식이라 마스터 표에 갈래가 없음
  (#L…m 는 프로젝트 거리마다 생겨 마스터에 둘 수도 없음) → 식이 내는 FP-10-12 갈래만 까닭 적고 제외
- 58afe8f2 를 일부 시험만 돌리고 올려 난 빨강 · 전체 1763 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:57:24 +09:00
eomsangdonandClaude Opus 5 a1b1182b9b fix(b05): 계곡 통과 시설 [저장]이 폼이 모르는 칸을 지우던 것 — 폼 칸만 갈아 끼움
- 저장이 옵션을 통째로 갈아 끼워 집계표·구조물도로 적은 칸(관 기슭막이 기초 등)이 조용히 지워졌음
- 같은 시설 종류면 폼이 적는 칸만 갈고 나머지는 이어 붙임 · 종류를 바꿀 때만 통째로
- 폼이 아는 칸을 폼이 비우면 지움(사용자가 폼에서 한 일)
- 폼 칸 목록 거울 시험 — 폼에 칸을 늘리고 목록에 안 넣으면 시험이 잡음
- ORCA 936be972: 집계표로 기초유 적고 B05 폼 고침 → 재계산 요청에 기초유 남음 확인 · 값·캐시 되돌림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 08:49:38 +09:00
eomsangdon 128106e76d Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 08:41:53 +09:00
eomsangdonandClaude Opus 5 0eebb50c03 feat(b05,b08): 등록부 칸 채움 A2·A3 — 개거 연장 · 물넘이포장 두께·노폭 방향 길이
- 표가 읽는데 등록부에 없어 영영 안 서던 칸 — 옛 저장본은 안 깨지고 비면 사유(어디서 적는지)
- 개거(점 배치) length_m · 물넘이포장 thickness_cm·length_m(detail · 구조물 집계표에서도 고침)
- B05 시설 칸에 물넘이 두께·길이 입력 — 시설 저장이 칸을 통째로 갈아 끼워 집계표로 적은 값이 지워지던 길을 막음
- 700줄 한계로 유입구 형식 목록을 _Fields 로 옮김(부르는 쪽 그대로)
- 사유 문구: 「open_ditch 연장」 키 노출 · 「연장·면적이 0」 → 무엇을 적으면 서는지

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 08:40:49 +09:00
eomsangdonandClaude Opus 5 58afe8f2d2 feat(b09): 덤프 적재 10-12 「1. 적재」 — 굴착기 0.7㎥ Q1 호표(#적재) · E0 토사 0.60(본문 0.75 버림을 사유에)
- Q1 = 3600×0.7×K×f×E0/22초 · 토사 47.63 · 암절취 16.32 · 발파암 13.67 ㎥/hr
- E0 는 운반 식 Es 와 다른 기호 — 토사 0.60(표 「임도」·10-12-3 [주]③) 채택 · 본문 0.75 버림(브레인 판정)
- 거리 무관이라 늘 세움 · 내역은 인계 수단 표지 dump_loading 줄을 잎 #적재 로(B08 짝 줄은 다음 커밋)
- 시험 1건 보탬

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:37:58 +09:00
eomsangdon 999aeac755 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 08:33:32 +09:00
eomsangdonandClaude Opus 5 139e505843 feat(b09): 덤프 운반 10-12 — 운반거리마다 호표(X 덤프트럭 15ton → D → B) · 원문 어긋남은 고른 값·버린 값을 사유에
- 원문 본문 식(채워 넣는 서식)을 데이터로: Qt = T/γt×L · n = Qt/(0.7×K) · ㎝t = t1+t2+t3+t4+t5 · Q = 60×Qt×f×E/㎝t
- 브레인 판정: n 의 Qt 계산값(원문 표기 10 버림) · 발파암 E 0.9(원문 누락) · Es 운반 줄 값 · 거리별 갈래
- 거리는 B08 인계(유토곡선·사토장)에서 — 내역이 조립에 넘기고, 호표 한 장은 코드 #L…m 에서 되읽음
- 거리 없는 줄은 0 원 아니라 「운반거리 미입력」 · 갈래는 범위 별칭 리핑암 → 암절취
- 배정 프로젝트 164.23m: 토사 3,249원/㎥ · 암절취 8,882원/㎥ · 같은 인계 대조로 바뀐 줄은 이 둘뿐(+1,437,981원)
- 시험 5건 · 전체 1761 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:30:20 +09:00
eomsangdon e3dbef9700 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 08:23:22 +09:00
eomsangdon 6eb651d054 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 08:23:21 +09:00
eomsangdonandClaude Opus 5 81286400bd fix(b08,b09): 기본값으로 선 구조물은 줄만 서고 금액 밖 — 「미확정 N건 — 금액에 안 들어감」
- 브레인 판정: 값 없음을 감추면 조용히 빠지거나 조용히 부풂 — 없으면 없다고 보이기
- 관 지점 벽 치수를 등록부 기본값으로 채운 줄 = 미확정: 원단위 합·자재·터파기·되메우기·기초잡석·타설·잔토·채집석에서 뺌
- 인계 막힘 갈래 넷째 값 unconfirmed(계약 칸은 안 늘림) · B09 는 내역 제자리에 빈 금액 + 빨간 테두리 · 머리 뱃지
- 구조물도 그림에 표와 같은 기본값 사유 줄 · 「횡단도와 같은 값」 문구는 틀려 고침(B06 관 벽 높이는 관경 기준)
- 936be972 내역 184,291,506 → 124,424,547 · 미확정 18건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 08:22:14 +09:00
eomsangdonandClaude Opus 5 5bbb0aa53b fix(b08,b09): 한 줄 「암」 막힘 까닭 바로잡음 — 「공종코드 없음」이 아니라 암 갈래 구성비 입력 필요
- B08: 갈래로 안 나뉜 암 총량은 시공법만 골라서는 안 풀림 → 사유 「암 갈래 구성비(%)가 아직 없어…」 · 시공법 안내 목록에서 뺌
- B09 내역: 공종을 못 고른 줄에 B08 막힘 까닭이 있으면 그 문구 그대로(입력 필요 ↔ 매핑 구멍 가름)
- 배정 프로젝트 흙깎기 암 3,104.57㎥ · 측구터파기 암 160.48㎥ 가 이 자리 · 시험 1건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:10:30 +09:00
eomsangdon 7b2affe119 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 08:07:08 +09:00
eomsangdon fd324ce531 auto: 2026-09-14 08:03 (ESD_LAPTOP) 2026-09-14 08:03:13 +09:00
eomsangdonandClaude Opus 5 f3630ab1e7 feat(b09): 규격이 열로 선 표(관부설 12-11) — 규격 칸·원문 비고를 읽어 줄은 서고 못 넣는 줄은 까닭
- 원문 비고 「별산」·「별도계산」·「설계수량」(〃·병합 칸 포함)·「필요시적용」은 안 넣는 줄로 까닭을 남김
- 규격 칸(1:2)과 관경 열(∅800mm)을 조인 키로 — 접합몰탈·고무링이 자재 줄로 섬(단가 층은 아직 없음)
- 크레인은 기종이 여럿이라 고르지 않음 — 「규격 미정 · 원문 규격 10ton/5ton」(브레인 판정 대기)
- 옆 칸 규격 「40.64㎝」 = 카탈로그 절단기 40.64(기계 운전 자료가 없어 층은 못 섬 — 사유로 드러남)
- 카탈로그 밖 자원 목록에 고무링 ∅800/1000/1200mm · 지수활제 보탬(원문 F0346 · 단가 없음)
- 범위 81공종 상태 전후 같음(못 붙은 줄의 까닭만 바뀜) · 시험 4건 · 전체 1749 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 08:03:12 +09:00
eomsangdon bd571f0454 Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 07:58:31 +09:00
eomsangdon 27192c739a Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 07:58:29 +09:00
eomsangdonandClaude Opus 5 b97cb4e2e6 feat(b08): 계곡 통과 시설도 원단위 전개로 — 관 유입·유출부 기슭막이·집수정·독립 기슭막이·물넘이 (A1)
- 전개가 structures.json 만 읽던 구멍 — pipe_points.json 도 읽음(관 자체는 관 줄이 셈)
- 관 부설(품셈 12-11 m당)에 기슭막이 몫 없음 → 기슭막이는 전개 한 곳에서만 · 사유에 적음
- 안 적힌 벽 칸은 등록부 기본값(횡단도 그림과 같은 값) + 「기본값으로 섰음」 사유
- 구조물 목록에 남은 관 지점 종류 옛 저장분은 안 셈(두 번 방지)
- 기슭막이 공종 = 형태로 돌쌓기 찰·메 코드 · 독립 기슭막이 한쪽 설치에 두 칸이 다르면 사유만
- 산출식 없는 세월교가 「터파기 줄 없음」 거짓 사유를 안 내게 · 구조물도 제원 저장이 관 지점 시설엔 안 먹힘을 알림

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 07:52:55 +09:00
eomsangdonandClaude Opus 5 808cf0f289 fix(b09): 기계 카탈로그 이름 되살림 — 표 머리글 붙은 5건 · 빈 이름 29건(원천 쪽 넘김 파싱 병)
- 쪽 끝 기종 이름에 다음 쪽 머리글·다음 분류 이름이 붙고, 다음 분류 첫 기종들은 이름이 비던 병
- 이름·규격은 건설공사 표준품셈 제8장 기종 목록 줄에서 읽음 · 취득가·손료계수는 원천 그대로
- 대형 브레이커 되살림 표 옆에 같은 약속으로 둠(원천이 실으면 지움)
- 자원 축 781/386 · 범위 81공종 상태 전후 같음 · 시험 4건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 07:49:02 +09:00
eomsangdon b84ee14bea auto: 2026-09-14 07:48 (EOMSANGDON-HOME) 2026-09-14 07:48:39 +09:00
eomsangdonandClaude Opus 5 7be245db2b knowledge(축C 명세): 품셈 판 규칙·반올림 고시 근거 반영
- 2장 — FP- 는 산림사업 표준품셈 한 판, 건설품셈은 기계 층만. 고시 2025-82호 총칙 7 조문 근거
- 7장 — 금액의 단위표준 표 다섯 줄과 실측 대조. 넷은 맞고 「총액 1,000원 미만버림」만 미확인
- 7장 — 수량의 단위표준 [주] 셋. 「규정 없는 자리 소수 2자리」는 이 표에도 없는 자리에만
- 막히면 산림품셈을 먼저 볼 것 — 덤프 운반이 건설 8-2-8 만 보다 막혔던 자리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-14 07:48:34 +09:00
eomsangdon e37a425b2e Merge remote-tracking branch 'origin/CODEX' into sub_desktop_1 2026-09-14 07:40:49 +09:00
eomsangdon 1fbbd93a18 docs(stmate): remove unsupported stage guesses 2026-09-14 07:40:41 +09:00
eomsangdon 95c375cfcb docs(stmate): resolve cost analysis gaps 2026-09-14 07:38:03 +09:00
eomsangdonandClaude Opus 5 81b51de517 feat(b08): 층따기 [제안값 넣기] 단추 — 누른 때만 KDS 표준치수 토사 0.5 m
- 자동으로 안 채움: 임도기술교본 6-4 「설계도서에 명시되어 있는 높이와 폭」(브레인 판정)
- 칸·근거 줄·단추 묶음을 B08_Quantity_UI_Side_BenchCut.ts 로 뺌(페이지 700줄 넘음)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 07:36:49 +09:00
eomsangdonandClaude Opus 5 1e9ff999a4 feat(b09): 준공 탭 등록 — 기성 뒤 맨 끝(설계 → 계약 → 실행 → 기성 → 준공 · 랩탑_메인 a7a1b246)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 07:10:29 +09:00
eomsangdon ee3c60f76c Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-14 07:10:06 +09:00
eomsangdon c4ecf3bd2b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 07:10:05 +09:00
eomsangdonandClaude Opus 5 7b6fe3873b feat(b08): 구조물도 그림 2조각 — 집수정(□형 Ø800) · 물넘이포장 · 날개벽·돌집수정은 까닭
- 집수정 □형 Ø800 — 관측 줄에 원문 치수(소광리 「집수정구조도(800)」 설계조건 칸·산출식)를 싣고 그 치수로 표 수량이 원문 식대로 다시 나오는지 시험
  원문 값으로: 콘크리트 2.84 → 2.83752 · 합판거푸집 21.28 → 21.2843 · 철근 D13 4.78 → 4.776 · 면목 12.67 → 12.6728(터파기·되메우기·잔토 그대로)
  그림: 긴 벽 정면(바닥기초·관·방수로·터파기) + 평면 · 관·방수로 자리는 원문 입체도 모식이라 적음
- 물넘이포장 — 슬래브 T(규격) · 와이어메쉬 2/3T(소광리 「포장」 그림) · 터파기 · 새 사유 「원문 둘이 다름: 소광리 포장 와이어매쉬 1.0·비닐 2㎡ ↔ 이 표(울진 1공구) 1.16·비닐 없음」을 표·그림이 같이 씀
- 날개벽 5종 — 원문 탭에 구조도 그림이 없고 날개 모양이 손으로 적은 수뿐이라 까닭 · 돌집수정 ㄷ·ㄴ형 — 집계표 관측값이라 치수 원문 없음 까닭
- 사유 문구(브레인 판정): 개거 150×200 「원문 둘이 다름(산출식 0.3×0.1 ↔ 그림 홈 150×200 + 밑 보 400×200)」 · 바닥막이 「옹벽은 버림·잡석까지 팜 — 바닥막이는 정본이 돌 두께만이라 다름」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 07:09:53 +09:00
eomsangdonandClaude Opus 5 deac820044 test(b09): 골든셋을 원가계산서까지 넓힘 — 실무 6건 제비율 54자리·사슬 33자리 원 단위 재현
요율을 원본 「공사원가계산서」 비고에서 뽑아 그 원본 전용 판을 지어 엔진에 넣음 —
「요율 데이터가 달라서 틀림」을 없애고 남는 차이를 밑수·절사·사슬로 몰아 둠.

· 제비율 줄마다 54자리 전수 일치(안전관리비 A·B 와 작은 쪽 채택 포함)
· 사슬은 비고가 다 보이는 셋(봉화·영월·울진 공통) 순공사원가~총공사비 33자리 일치
· 이윤 — 자동절사가 맞춘 원본 둘(봉화 339 · 영월 632) · 설계자 명시 조정 하나(울진 905,980)
· 영월 도급공사비 차 762,000 = 원본에만 있는 국가지점번호판검증수수료 줄
· 고칠 자리 하나(배정 대기) — 안전관리비 B 절사 자리가 영월에서 1원 갈림(미채택 줄)
· 짝 파일이 700줄에 닿아 새 파일로 가름(원본 읽기는 공유)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 07:04:09 +09:00
eomsangdon fd344a5311 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 07:01:50 +09:00
eomsangdonandClaude Opus 5 a7a1b246b6 feat(b09): 준공 단계 — 계약금액 | 준공금액(기성 마지막 회차 누계를 옮기기만)
- 준공 별도 계산 규칙 미확인(35번 §3) — 규칙을 짓지 않고 기성 누계를 옮김 · 기성 한 장 불변
- 기성 회차가 없으면 준공금액 비움(0 원으로 안 채움)
- 기성 계산 조각(progress_for)을 떼어 준공이 같은 길로 받음
- 기성 간접재료비 확인 대기 닫음 — 예정가격작성기준 제17조·제39조② 근거(브레인 판정)
- 준공 탭 파일 · 사전 키(등록은 서브) · 시험 2건 · 전체 1730 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 07:01:46 +09:00
eomsangdonandClaude Opus 5 7d3b062c3e fix(b08): 층따기 칸 밑 — 「제안값(확정 아님)」 경고를 KDS 44 30 00 도로토공 2.3.4 (4) ② 근거 표시로
- 원문 표 그대로: 최소 높이 토사 500 mm · 암반 400 mm · 폭 1 m 이상(기계 작업 3 m 이상)
- 값·칸·자동 채움은 그대로 — 말만 고침(2026-09-14 브레인 정정)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 07:01:46 +09:00
eomsangdon a98689c966 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 06:55:25 +09:00
eomsangdon a88f9a625b auto: 2026-09-14 06:53 (ESD_LAPTOP) 2026-09-14 06:53:32 +09:00
eomsangdonandClaude Opus 5 951e39a923 feat(b08): 구조물도 그림 — 기슭막이 · 골막이 · 떼흙막이 · 바닥막이 · 개거 까닭
- 기슭막이(돌쌓기형) — 돌쌓기 그림 그대로 · 기울기 판정 대상에 넣고 찰/메는 형태에서(표와 한 벌)
- 골막이 — 정면 사다리꼴 · 방수로 관 · 바닥파기 띠 + 옆 단면 · 치수는 표와 같이 쓰는 erosion_check_section 한 벌로 뽑음(값 불변)
- 떼흙막이 — 정본 평균 붙박이 치수(dims)로 · 그 치수로 표 두 줄(떼 1.39 · 바닥파기 0.17)이 다시 나오는지 시험
- 바닥막이 — 돌 켜 · 버림 · 기초잡석(산출 조건) · 터파기 · 표 사유에 새 한 줄 「터파기는 돌 두께만 — 버림·기초잡석은 판 깊이에 안 들어감」을 그림도 같이 씀
- 표가 안 선 장은 표 사유를 그대로 까닭으로 · 개거 둘은 원문끼리 치수가 달라서/단면 그림이 없어서 까닭
- 화면 확인: 검증 프로젝트 936be972 에 넷을 잠깐 놓았다 되돌림(구조물 5개 원본과 같음 · 단계 상태 그대로 · 내역 124,424,547 그대로)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 06:53:31 +09:00
eomsangdon cdd3a29452 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 06:42:20 +09:00
eomsangdonandClaude Opus 5 efe55e9d34 feat(b09): 기성 2벌 — 계약잡비율 직접입력(사유·↺) · 단일율 곱 차이 표시 · 공급가액/총공사비 절사 9택 · 사정 칸
- 계약잡비율: 기본 역산값(도급액 ÷ 계약 직접공사비) · 계약서 값 직접입력이 이김(사유 필수)
- 줄별 절사 누적으로 단일율 곱과 벌어진 금회·누계 차이를 비고에 적음
- 공급가액 절사(총공사비에서 조정 + 10원~1억) · 총공사비 절사(이윤금액 직접입력 + 10원~1억)
  — 떨어지는 몫은 설계 원가계산서와 같은 식으로 이윤에서
- 사정: 뜻 미확인이라 칸만 받고 계산에 안 씀
- 시험 4건 보탬 · 전체 1723 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 06:42:16 +09:00
eomsangdon bb8b9405a4 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-14 06:33:16 +09:00
eomsangdon 632e47a1ba Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-14 06:33:12 +09:00
eomsangdonandClaude Opus 5 6454f4fefb docs(b09): 축C 명세 반올림 표에 중기시간금액집계표 줄 보탬 — 성분마다 반올림 후 합(131/144)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-14 06:32:56 +09:00
eomsangdonandClaude Opus 5 2c0ae48ac7 fix(b08): 구조물 표 구멍 넷 — 골막이·바닥막이 내역 수량 0 · 떼흙막이 m당 오표시 · 개거 L형 키 · 기슭막이 거짓 사유
구조물도 남은 종류 그림을 시작하며 시험 제원으로 표를 세워 보다 드러남:
- 골막이(개소당)·바닥막이(㎡당)가 「m · 연장 0」으로 읽혀 구조물도 전 줄 「단위당 못 냄」 · 인계 「물량이 0 이라 내역에 안 세움」 → 내역 단위 개소 1 / ㎡ 면적으로
- 떼흙막이 개소당 값이 연장으로 나뉘어 떼 0.139/m 로 보였음 → 개소당 1.39
- 개거 「L형수로 H=0.2」 — 엔진 키가 「콘크리트 L형수로 H=0.2」라 등록부에서 골라도 영영 안 섰음 → 등록부 글자로
- 기슭막이 사유 「돌 종류·뒷길이 칸이 없어」 — 칸이 선 뒤로 거짓 · 돌쌓기 한 줄과 같은 말 두 번 → 걷음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 06:32:14 +09:00
eomsangdonandClaude Opus 5 ba2c36e803 feat(b09): 기성 탭 마지막 회차 지우기 — 앞 회차는 뒤 회차 전회를 흔들어 마지막만
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 06:16:00 +09:00
eomsangdon c3060f9bb6 auto: 2026-09-14 06:12 (ESD_LAPTOP) 2026-09-14 06:12:19 +09:00
eomsangdon 0f5d7da1e2 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 06:11:51 +09:00
eomsangdonandClaude Opus 5 aba96f6116 fix(b08): 옹벽 기초 터파기 옆면 1:0.5 — 실무 식생옹벽블럭 기초 터파기와 같은 자리에서 기울기(브레인 판정 고침)
- 밑폭 2.2(기초 1.6 + 여유 0.3×2) · 윗폭 = 밑폭 + 0.5 × 판 깊이 × 2 = 2.9 · 깊이 0.7 → m당 터파기 1.54 → 1.785 · 되메우기 0.36 → 0.605 · 잔토 1.18 그대로
- 검증 프로젝트 내역 본체 124,202,681 → 124,424,547원(+221,866 = 구조물터파기 0~1m 15.4→17.85㎥ +213,375 · 되메우기 24.6→27.05㎥ +8,491)
- 그림 터파기 점선을 사다리꼴로 · 「밑폭 2.20 · 윗폭 2.90 × 깊이 0.70」 · 표 사유와 같은 말

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 06:11:47 +09:00
eomsangdon 2f2cc92d15 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 06:03:26 +09:00
eomsangdonandClaude Opus 5 1edde503fe feat(b09): 기성 단계 1벌 — 금회직접공사비 × 계약잡비율 · 계약/전회/금회/누계와 각 율 · 부가세 넷
- 계약내역·계약 원가계산서(같은 엔진에 계약 직접비)를 읽기만 해 파생 — 계약 불변
- 제잡비 줄마다 계약잡비율 = 도급액 ÷ 계약 직접공사비 (구조로 읽음, 확인 대기)
- 회차 목록 저장 · 기성(%) · 잔량 · 계약 수량 넘으면 알림
- 부가세: 직접입력 / 공급가액 / 재료비 / 재료비+산출경비 (설계 vat_base 같이 씀)
- 원가계산서 조립·계약본 받기 조각을 떼어 기성이 같은 길로 받음
- 기성 탭 파일 · 사전 키(등록은 서브) · 시험 6건 · 전체 1717 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 06:03:22 +09:00
eomsangdon ce5bfb7722 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 06:01:16 +09:00
eomsangdonandClaude Opus 5 650f660ed1 feat(b08): 옹벽 구멍 셋 — 터파기 기초분 · 원문 값 · 물빼기 칸이 표에 닿음(전후 대조)
① 터파기·되메우기·잔토 — 소광리 「옹벽2.0」에 줄이 없어 브레인 판정대로 기초 폭 + 양쪽 여유 0.3(같은 파일 식생옹벽블럭 기초 터파기)로 수직 · 깊이 기초 0.4 + 버림 0.1 + 기초잡석 · 비탈분 제외(지반선이 제원에 없음) · earthwork 로만
   m당 터파기 1.54 · 되메우기 0.36(터파기 − 기초·전단키·버림·잡석) · 잔토 1.18 · 인계 심도 구분은 판 깊이(0.7 m → 0~1m)
② 관측 값을 원문으로 — 콘크리트 1.35→1.345 · 버림 0.15→0.145 · 유로폼 3.2→3.205 ⇒ 기초잡석 0.30→0.29
③ 물구멍관 — 실무 관측 식(「옹벽2.0」 T31 벽 높이 ÷ 개소당 면적 × 관 0.4)에 제원 칸이 닿음 · 규격 Ø50 이 붙어 자재총괄에서 돌쌓기 물구멍관과 한 줄(33.924 + 3.2 → 37.124 m) · 관 길이는 관측값으로 표시
- 검증 프로젝트 내역 본체 122,848,989 → 124,202,681원(+1,353,692 = 구조물터파기 심도 0~1m 15.4㎥ 1,341,216 + 되메우기 21.0→24.6㎥ +12,476)
- 그림: 터파기 점선·「터파기 폭 2.20 × 깊이 0.70」 · 표 사유와 같은 말 · 옛 값을 박은 시험 8건을 원문 값으로(전 값은 주석)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 05:59:32 +09:00
eomsangdon 2ab3a65807 @
feat(b09): 실행예산 차액 보정 뜻을 화면에 — 기본보정은 「안 몰고 남김(뜻 확인 대기)」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
@
2026-09-14 05:47:36 +09:00
eomsangdonandClaude Opus 5 87cab83c60 feat(b09): 실행예산 탭 등록 — 계약내역 뒤 맨 끝(설계 → 계약 → 실행 · 랩탑_메인 f1f8482e)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 05:38:33 +09:00
eomsangdon 914bbce74c Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 05:29:57 +09:00
eomsangdonandClaude Opus 5 f1f8482e7d feat(b09): 실행예산 단계 1벌 — 중기 실행단가(1단위당 사용료 ÷ 시간 · 성분 비율 · 절사 · 차액 보정) · 실행수량
- 단가표 복사본의 중기(X) 호표만 실행단가로 갈아 끼워 일위대가 다시 조립 — 설계·계약 불변
- 조립값과 다른 줄은 설계 단가 그대로 + 비고에 까닭
- 「기본보정」은 뜻 미확인 — 차액을 안 몰고 남김(확인 대기)
- 계약 모듈의 다시 조립·묶음 합 조각을 두 단계가 같이 씀
- 실행예산 탭 파일 · 사전 키(등록은 서브) · 시험 7건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 05:29:05 +09:00
eomsangdonandClaude Opus 5 5e983018a3 feat(b08): 구조물도 옹벽 단면 그림 — 관측 원단위 줄에 단면 치수 · 표 사유와 같은 말
- 반중력식 H=2.0 관측 줄에 단면(기초 1.6×0.4 · 벽체 1.6 상단 0.3 앞면 0.118 뒷면 0.032 · 전단키 0.35×0.3 · 버림 0.1 · 물빼기 관 0.4)을 실음 — 출처 소광리 07-구조도 「옹벽2.0」 산출식·일반도
- 그 치수로 표 수량이 다시 나오는지 시험(콘크리트 1.345→1.35 · 버림 0.145→0.15 · 유로폼 3.205→3.2 · 관 0.32)
- 그림: 기초·벽체·전단키 + 버림(키 자리 비움) + 기초잡석(산출 조건 두께 · 버림과 같은 폭) + 물구멍
- 표 사유에 새 한 줄 둘(그림도 같은 말): 관측 줄에 터파기·되메우기·잔토가 없음 · 물빼기 칸을 채워도 관측 값이 붙박이라 안 닿음
- 까닭은 표가 못 선 장만(형식 미정 · 자료에 없는 규격 · 단면 치수 없는 규격) — 표 사유와 같은 말

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 05:27:49 +09:00
eomsangdon bc1b2fdad0 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 05:10:51 +09:00
eomsangdonandClaude Opus 5 ff6672a13a feat(b09): 계약 단계 2벌 — 기초단가 적용(단가표 복사본 다시 조립) · 계약 일위대가/산출근거 W 호표 · 0% = 0 원
- 기초단가 적용: 자재·노임·중기 취득가 채택 단가에 적용률 — 복사본만, 설계 단가표 불변
- 조립값과 다른 줄(할증·수동 단가)·단가표 밖 코드는 성분 곱셈 + 비고에 까닭
- 계약 호표: 설계 본표와 같은 꼴(detail_of)로 W-B·W-D 따로 — 기초단가 적용과 함께만
- 0% 는 0 원(브레인 판정) · 공내역 생성은 그 줄 이름만 가름
- 시험 11건 · 전체 1699 통과(골든셋 포함)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 05:10:41 +09:00
eomsangdonandClaude Opus 5 31675aaf21 refactor(b08): 원단위 전개 파일 쪼개기 — UnitQuantity 1,646줄 → 네 파일(기능별 · 값 불변)
- _Base(110줄) 성분 계약·갈 곳(DESTINATION)·배합 성분(MIX_COMPONENTS) — 이중계상 경계
- _StoneSpec(525줄) 돌쌓기 제원·계수표·표준경사 판정·벽 두께·버림/기초잡석 상수
- _Masonry(523줄) 돌쌓기·큰돌쌓기 전개식(버림·터파기 줄)
- UnitQuantity(581줄) 종류→전개식 표·관측 원단위·딸린 줄·build_table — 옮긴 이름은 다시 내보내 부르는 쪽 44곳 그대로
- 코드 줄은 옮기기만 함 · 검증 프로젝트 응답 여섯(원단위·구조물 집계·구조물도·운반·인계·내역) 지문이 쪼개기 전과 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 05:06:53 +09:00
eomsangdonandClaude Opus 5 1b48a16a18 feat(b09): 계약내역 탭 등록 — 설계 뒤 계약 단계라 틀 맨 끝(랩탑_메인 26097d69)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:51:31 +09:00
eomsangdon 8646c56185 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 04:43:04 +09:00
eomsangdonandClaude Opus 5 a9ddcd5b1b feat(b08): 구조물도 그림 — 뒷길이 미정 장도 표가 선 기본값으로 그림 · 표 사유와 같은 말
- 뒷길이를 안 고른 돌쌓기 장: 표가 쓴 그 뒷길이(양식 장은 양식이 푼 값, 전개 장은 기본 45㎝)로 그림
- 「⚠ 뒷길이를 안 골라 기본 N㎝ 로 섰습니다」 한 줄을 한 곳에 두고 표 사유·그림이 같이 씀(골막이 문구도 같은 함수)
- 돌쌓기 표 사유에 그 한 줄이 새로 붙음 — 전에는 기본값인 줄 표에 안 드러났음
- 그림 까닭은 정말 못 그리는 장(큰돌쌓기 직경 축·옹벽)만

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:42:28 +09:00
eomsangdon 52771faefe Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 04:41:14 +09:00
eomsangdonandClaude Opus 5 26097d6922 feat(b09): 계약 단계 1벌 — 노·재·경 단가 적용률 · 행별 적용 제외 · 0% 공내역 · W 코드
- STmate wM_Mk_Cont(27번 §2) 본뜸: 단가 적용율 【노】【재】【경】 · 적용 옵션 여섯(화면 표기 그대로) · 적용제외 공정
- 설계 내역(/estimation/bill) 줄을 복사해 성분마다 적용률 — 절사는 설계 내역 규칙(bill_line) 그대로 · 설계 불변
- W 코드: 동일코드 한 벌(기본) / 개별생성 켜면 줄마다 · 적용 제외 줄은 설계 단가·설계 코드
- 0%: 「공내역 생성」 켜면 0 원 공내역 줄 · 끄면 적용 안 함(판정 대기)
- 뜻 미확인 옵션(노무비율)·아직 안 선 옵션(기초단가 적용·일위대가 생성·비과세자재)은 칸만 받고 까닭을 화면에
- GET/PUT /estimation/contract · 탭 파일 B09_Estimation_UI_Tab_Contract.ts(등록은 서브) · 사전 b4
- ⚠ 계약 표본 0건 — 구조가 서는지까지만 시험

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 04:41:10 +09:00
eomsangdon 415a76a97b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 04:28:27 +09:00
eomsangdon 6926d7fd14 auto: 2026-09-14 04:26 (ESD_LAPTOP) 2026-09-14 04:26:48 +09:00
eomsangdonandClaude Opus 5 1d21253b7e feat(b08): 구조물도 상단 그림 — 돌쌓기 단면 SVG(읽기 전용) · 못 그리는 장은 까닭
- B07 표준도 그림 코드를 B08 로 옮겨 모양 목록(선·글자)으로 냄 · 화면이 SVG 로 그림(그림 6 : 표 4)
- 벽 두께를 수량표와 같은 함수로 — 상부·하부 두께를 넣어도 그림만 옛 두께이던 것 고침
- 터파기 점선을 수량식과 넓이가 같은 평행사변형으로(앞면 따라 기움)
- 큰돌쌓기(직경 축)·옹벽·뒷길이 미정 장은 그림 자리에 까닭 한 줄
- B07 CAD 표 조립은 같은 모양 목록을 CAD 엔티티로 옮겨 적음(CAD 편집 뒤 일감용)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:26:47 +09:00
eomsangdon 6a2c5aacae Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 04:18:58 +09:00
eomsangdonandClaude Opus 5 6683e85d94 feat(b09): 원가계산서 나머지 세 형식 서식 차례 — 수공·실적일반·실적수공 · 금액 없이 막힌 까닭
- 12번 §4~6 서식(35번 정정 반영) 차례·표기 · 줄마다 「공통 요율 / 요율표 미확보(RateB·RATE_RA·An_A) / 확인 대기」
- 확인 대기: 실적형 이윤 밑수(35번 §5-1) · 실적수공 안전관리비 존재(§5-2) — 값을 지어내지 않음
- 0 원으로 안 채움 — 금액 칸 「-」 + 까닭 · 머리에 「금액 미산출」
- 원가계산서 아래 형식 탭 넷을 눌러 바꿈(GET ?form=) · 기준 입력판은 그대로
- 시험: 금액·0원 없음 · 수공 경비 여덟 막힘 · 확인 대기 · 실적일반 보증·환경 밑수 · 모르는 형식 멈춤

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 04:18:54 +09:00
eomsangdon 63e715fd9f Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-14 04:08:04 +09:00
eomsangdon 0112f3fcab Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 04:08:03 +09:00
eomsangdonandClaude Opus 5 8046205039 fix(b09): 산업안전 50억 이상 구간 겹침 해소 · 하도급대금 지급보증수수료 칸(기본 꺼짐)
- 산업안전보건관리비: 고시 별표1 「보건관리자 선임 대상 건설공사의 적용비율」 열을 대상액이 아니라
  공사가 선임 대상인가(요율 데이터 manager_thresholds 800억 · 토목 1,000억)로 가름 — 두 줄이 겹쳐 멈추던 자리
- 하도급대금 지급보증수수료: 기본 꺼짐 — 실무 원가계산서 6건에 줄 없음 · 건설산업기본법 제34조 대상이면 켬
  (칸 밑에 「대상 아님인지 안 적음인지 못 가름」 사유)
- 시험: 50억 이상 2.60 / 선임 대상 2.73 · 하도급 꺼짐·켬 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 04:07:45 +09:00
eomsangdonandClaude Opus 5 b9d0ac0866 feat(b09): 2차 편집 ④ 내역 할증율·일괄보정 — 범위(선택 줄·내역서 전체) · 비목별 절사/반올림 · 소수 0~4 · 줄 ↺ · 할증 전부 되돌리기
- 서버가 내역 줄 성분 단가에 율을 얹어 다시 셈(줄 칸이 전체 칸보다 우선) · 수량은 그대로
- 공용 작은 버튼 글자색 상속 — 어두운 화면에서 검정 글씨로 묻히던 것

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 04:06:40 +09:00
eomsangdonandClaude Opus 5 8693cf0012 feat(b09): 원가계산서 기준 입력 13~16 — 사급비 위치 · 낙찰 · 이행보증 · 적용기준 · 이행보증 식 고침
- 13 사급비 위치: 재료비에 포함만(사급은 이미 내역 재료비 · 실무 거창·영월) — 칸만 받음
- 14 낙찰방식: 하도급대금 지급보증 요율 줄 — 종합심사 외(규모 구간) · 종합심사 대상(300억 이상 줄) · 턴키(0.084)
- 15 이행보증: 일반계약 = 추정가격 300억 이상만 공사이행보증수수료 · 최저가·기술제안 = 규모 무관
  (종전 늘 계산 — 제비율 기준 「소규모 통상 비대상」·실무 여섯 건 금액 없음과 어긋났음)
- 곁 고침: 70억 이상 구간 [기초액 + (직공비−기준액) × 율] × 공기 — 종전 백분율만 쓰고 기초액·기준액을 버렸음
- 16 적용기준: 칸만 받음(조달청 표 한 벌 · 제20조 국가 표 이미지 미확인 · 행자부 표 없음)
- 칸 밑 안내 · 시험: 300억 미만 안 섬 · 100억 식 965,000 · 요율 줄 셋 · 13·16 계산 불변 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 03:56:26 +09:00
eomsangdon d4ab146e02 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 03:38:00 +09:00
eomsangdonandClaude Opus 5 c3618fe872 feat(b09): 2차 편집 ② 일위대가·단산 구성행 수정 · ③ Q 식 편집 — 프로젝트 단위 · 서버가 다시 계산 · ↺
- 구성행(sheet_rows): 수량 고치기 · 줄 빼기(수량 0, 흐리게) · 줄 더하기(단가표 고르개) — 비율 줄·돌림 참조는 받지 않음
- Q 식(price_basis_q): 식은 명세 13장 식 언어 — B08 구조물도 풀이기(evaluate_sheets, Node 한 벌)를 그대로 부름 ·
  소수 2자리 사사오입 확정 → 새 수량 = 수량 × 옛 Q ÷ 새 Q · 비고에 「Q(사용자) = 식 = 값」
  · PriceDetail.output(시공능력 Q)을 Q 로 선 장비 줄 넷 자리(굴착기·도자·직접 작업량·암 잎)에서 실음 · 저장 모양에도 실음
- 따로 짰던 파이썬 식 셈(B09_Estimation_Expression)은 걷어냄 — 식 풀이 두 벌 금지(브레인 판정)
- 새 문: GET /estimation/edits/sheet/{code}(본표 + 줄마다 고친 값 표시) · GET /estimation/edits/search(줄 더하기 고르개)
- 화면: 본표 [편집] — 수량 칸 · ✕ · Q 식 칸 · 줄 더하기 · 고친 줄 「사용자」 + ↺
- 검증: 시험 1664 통과 · 골든셋 초록 · ORCA — 제 3 호표 보통인부 0.023→0.046 이면 5,652→9,610 · 내역 본체 122,848,989→122,857,198,
  ↺ 뒤 5,652 · 산근 2호표 Q 58.21→116.42 이면 1,695→847, ↺ 뒤 1,695 · 틀린 식 422 「모르는 이름: abc」 · 고친 값 {} 로 복구

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 03:37:18 +09:00
eomsangdonandClaude Opus 5 b02c51db71 refactor(b09): 라우터 쪼개기 — 산출 조건 문(GET/PUT /estimation/factors)을 Router_Factors 로 · Router.py 842 → 537줄
- get_bill · _project_root_of · _build_for 는 Router.py 에 그대로(랩탑_메인 CostSheet import 그대로)
- 새 파일은 본 라우터의 _project_root_of·_build_for 를 부를 때마다 빌려 씀 — 시험이 본 라우터 것을 바꿔 끼움
- 검증: 시험 1666 통과 · 골든셋 초록 · 재시작 뒤 B09 문 열하나(factors·base-data·price-sources·basis-sheet·machine-expense·
  design-doc-index·bill·unit-prices·edits·cost-sheet·PUT factors) 전부 200

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 03:24:53 +09:00
eomsangdon 35abb49d3c Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 03:24:36 +09:00
eomsangdonandClaude Opus 5 f704bc3bf8 feat(b09): 요율 덮어쓰기 — 프로젝트 단위 · 칸마다 사유 · 기본값↔고친 값 · ↺ 되돌리기
- estimation.rate_overrides 에 칸 주소(variable·table·match)·요율·사유·입력 시각을 한 줄씩 — 마스터 요율 파일은 안 건드림
- 엔진이 계산할 때 복사본에만 얹음 · 덮어쓰기 없으면 원본 그대로(골든셋 초록) · 원가계산서 비고에 「요율 덮어쓰기 N건 — 사유」
- 요율표: 칸마다 기본값·지금 값·주소 · [수 정]에서 입력 · 고친 칸 테두리 + (기본 X) · ↺ · 고친 요율 목록에 사유 칸(비면 저장 안 됨)
- PUT /estimation/rate-table: 사유 빈 줄·틀린 주소·같은 칸 두 번·음수면 아무것도 안 저장 · 값·사유 같으면 입력 시각 유지
- 판이 바뀌어 주소가 안 맞으면 조용히 버리지 않고 알림
- 시험: 마스터 불변 · 계산에 걸림 · 한 값짜리 · 거름 · 주소 틀리면 멈춤 · 화면 주소 왕복

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 03:24:33 +09:00
eomsangdon 6cfcafc2f4 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 03:16:14 +09:00
eomsangdonandClaude Opus 5 9cf01ab15c feat(b09): 2차 편집 ① 자재단가대비표 채택 바꾸기 — 프로젝트 단위 고친 값(estimation.edits) · 서버가 얹어 다시 계산 · ↺
- B09_Estimation_Edits: 고친 값 한 벌 — 기본 조립(cached_build) 복사본에 얹음(캐시 키에 고친 값) · 기본 벌은 그대로(골든셋 무관)
  · 얹지 못한 값은 edit_skipped 로 남김 · 값 없는 슬롯은 받지 않음(422 + 까닭)
- 새 라우터 B09_Estimation_Router_Edits(GET/PUT /estimation/edits) — Router.py 는 _build_for 가 고친 값을 얹게만 바꿈
- 화면: 줄마다 채택 슬롯 고르개 · 일괄(변동없음/1~5 단가/최소단가) · 고친 줄 「사용자」 + ↺(지우면 계산값으로)
- 곁: 사용자 식 셈(B09_Estimation_Expression — ROUND·ROUNDDOWN·INT·SQRT, eval 없음)과 본표 편집 칸(UI_DetailEdit)을 먼저 둠 — 서버 편집 문이 서기 전까지 잠자 있음
- 검증: 시험 1656 통과 · ORCA 채택 저장 → 「사용자」·↺ → ↺ 뒤 고친 값 {} 로 복구 · 값 없는 슬롯 422 「경유: 1번 원천에 값이 없어」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 03:16:10 +09:00
eomsangdon 267bad1dfd Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 03:01:59 +09:00
eomsangdonandClaude Opus 5 72046265fc feat(b09): 원가계산서 기준 입력 4·5 — 고용보험 등급 · 퇴직공제 적용 · 폐기물 「승률 밖」 자리 · 칸 밑 안내
- 4 고용보험료: 자동(추정금액 구간 · 종전) · 없음 · 1~7등급 직접
- 5 퇴직공제부금비: 자동(추정금액 1억 이상 · 종전) · 적용 · 미적용 — 토목·준설·건축·기타는 현행 제비율에서 율이 같아 칸 안 둠
- 폐기물처리비 자리: 경비·승률 안(법 문언 · 기본) / 이윤 뒤·총원가 안·승률 밖(실무 관행 울진소광·실정보고)
- 칸 밑 안내: 절사 기본 「안 함」과 실무 3건(총공사비 천원 미만 + 이윤 자동보정) · 폐기물 법/실무 갈라 적음 · 주/전문 규정 없음
- 시험: 등급 1.57% · 없음 · 없는 등급 멈춤 · 퇴직공제 1억 미만 적용 · 승률 밖 자리 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 03:01:54 +09:00
eomsangdon 3a250fb055 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 02:58:02 +09:00
eomsangdonandClaude Opus 5 964375d2a7 refactor(b09): 옛 화면 파일 셋 지움 — UI_Page(1,561줄) · UI_BaseData(1,161줄) · UI_Tab_Legacy
- 옛 탭 여섯은 모두 새 틀 탭 파일로 옮겨짐(설계서 구성 · 산출기초 · 관급·사급 · 중기경비계산서 · 기초자료, 원가계산서는 랩탑_메인)
- 화면 확인(지운 뒤 13 탭 전부): 설계내역서 53줄 · 본체 122,848,989 · 일위대가 18 · 산근 10 · 중기 표 19·줄 171 ·
  관급·사급 13 · 기초자료 표 6·줄 737 · 설계서 구성 13 · 산출기초 표 3·줄 192 — 옮기기 전과 같음 · tsc 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:56:27 +09:00
eomsangdonandClaude Opus 5 ef48e6edd3 refactor(b09): 옛 탭 옮기기 ⑤ 기초자료 — 산출 조건(UI_Factors) · 목록표 셋 · 자재단가대비표 · 환율및기초자료를 새 틀 탭 파일로
- 저장하면 이 탭 자료와 설계내역서 한 벌(UI_Store forgetBill)을 함께 새로 받음 — 값이 다시 섬
- 화면 확인: 옮기기 전후 같음 — 표 6 · 줄 737 · 고르개 32 개 고른 값 전부 같음 · 숫자 칸 2 · 글 35,074 자
- 계산 입력 확인: 기계 작업효율 E 평균 0.50 → 상한 0.55 를 화면에서 고르면 「사용자가 고른 값」 · 제 2 호표 2,511 → 2,284 ·
  되돌린 뒤(기본값) 2,511 그대로 — 검증 프로젝트 설정 원래대로 복구

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:47:03 +09:00
eomsangdon fb2de4a679 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 02:43:18 +09:00
eomsangdonandClaude Opus 5 fb4eb59cdb feat(b09): 원가계산서 기준 입력 10·11·12 — 일반관리비 주/전문 · 절사 · 부가세 방식
- 10 일반관리비: (주)공사/전문공사 = 요율 구간표만 갈림 · 밑수는 형식 칸(일반 = 순공사원가 + 관리품목자재대)
- 11 절사: 총공사비/공급가액 N원 미만 — 고른 때만 이윤 자동보정(총공사비·공급가액 비례 부가세면 ÷1.1 반올림, 실무 봉화 373→339·영월 695→632, 산림조합은 차액 그대로) · 잔차 맞춤
- 12 부가세: 공급가액·재료비·없음·산림조합 형식·산림조합-면세품 (칸 이름은 기성 선택지를 받을 수 있게 문자열)
- 기본값은 종전 계산과 같음 · 자동보정 줄은 「… 절사 자동보정」으로 적음
- 원가계산서 탭: 10·11·12 고르개 · 관리품목 자재대 · 면세품 금액 칸
- 시험: 기본값 불변 · 밑수·표 갈림 · 실무 절사 식 · 끝자리 · 골든셋 초록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 02:43:14 +09:00
eomsangdonandClaude Opus 5 71d643b456 refactor(b09): 옛 탭 옮기기 ④ 중기경비계산서 — 옛 BaseData 에서 UI_MachineExpense 로
- 화면 확인: 옮기기 전후 같음 — 중기 탭 표 19 · 줄 171 · 계산서 18 장(불도저 19 손료계수 1811 · 시간당 손료 35,884.965)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:05:34 +09:00
eomsangdonandClaude Opus 5 3327357f2e refactor(b09): 옛 탭 옮기기 ③ 관급·사급 — 새 틀 탭 파일로(내역 응답 한 벌을 봄)
- 화면 확인: 옮기기 전후 같음 — 사급 0 · 관급 0 · 안 갈린 것 13줄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:03:39 +09:00
eomsangdonandClaude Opus 5 baeaa26702 refactor(b09): 옛 탭 옮기기 ② 산출기초 — 새 틀 탭 파일로
- 화면 확인: 옮기기 전후 같음 — 표 3 · 줄 192 · 판 12 · 근거 공종 92 · 못 채운 자리 88

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 02:01:18 +09:00
eomsangdonandClaude Opus 5 800cb73be0 refactor(b09): 옛 탭 옮기기 ① 설계서 구성 — 새 틀 탭 파일로 · 옛 표 도우미(head·note·표·근거 호버)를 UI_Table 로
- 화면 확인: 설계서 구성 13줄 · 요약·법 문구 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:59:50 +09:00
eomsangdon 4c6268ffe8 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 01:57:42 +09:00
eomsangdonandClaude Opus 5 9158a88642 knowledge(축C): 집계표 단가 열·중기 반올림 정정 — 골든셋 실측
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-14 01:57:00 +09:00
eomsangdonandClaude Opus 5 3ce997fa69 feat(b09): 표 화면 넷 — 중기(목록표·중기사용료) · 자재단가대비표 · 집계표 넷 · 목록표 일곱
- 서버: 내역 응답에 집계표 넷(resources)·목록표(lists) — 내역이 쓴 자원을 처음 쓰인 차례로 되모음
  (묶음 줄·구조물도 호표 줄도 구성까지 풀어 셈) · 재료비 집계표에 자재대(사급·관급) 줄 이어 붙임
- 중기시간금액집계표: 단가 열 없이 합계·노무·재료·경비 — 성분마다 반올림한 뒤 합(골든셋 131/144, 합계 한 번 반올림 107/144)
- 재료·노무·경비 집계표: 반올림(수량 × 단가) 골든셋 279/279 — 시험 한 벌 더함
- 중기사용료 호표: 잡품 줄은 수량 칸 율(%) · 단가 칸 밑수(주연료비) · 금액 — 실무 표 그대로. 제잡비·공구손료 줄도 밑수를 단가 칸에
- 자재단가대비표: 슬롯 1~6 단가·페이지 · 채택 칸 굵게 · 최소단가 표시(서버 min_slot)
- 목록표 일곱(일위대가·단가산출근거·중기·재료비·노무비·경비·일식견적): 색인만 — 서버 번호·단가 그대로, 일위대가·산근·중기 줄은 누르면 들어감
- 옛 중기 탭 줄을 새 탭으로 바꿔 끼움(중기경비계산서는 옛 표를 아래에 이어 보임)
- 검증: ORCA — 중기 목록 9 · 굴착기 0.7 호표 합계 109,704 · 잡품 22 % × 21,418.1 = 4,711.9 · 노무 집계 벌목부 9,686,529 · 중기 집계 성분 합 · 시험 1638 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:56:02 +09:00
eomsangdon 155903306e Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 01:42:16 +09:00
eomsangdonandClaude Opus 5 a68a5e0214 fix(b09): 원가계산서·요율표 탭 단추 variant 를 ghost 로 · 「금액을 못 세운 줄 N건」·「미확정 N건」 표현을 내역서와 한 벌로
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 01:42:12 +09:00
eomsangdonandClaude Opus 5 b7af20f76d feat(b09): 틀에 원가계산서·제비율 요율표 탭 등록 — 랩탑_메인 탭 파일(cb33e81d), 옛 원가계산서 줄 바꿔 끼움
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:40:16 +09:00
eomsangdon 3057a751da Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 01:33:45 +09:00
eomsangdonandClaude Opus 5 cb33e81d98 feat(b09): 원가계산서(일반형식)·제비율 요율표 탭 파일 — STmate 배치 · 값은 우리 엔진
- 서버: /estimation/cost-sheet(GET·PUT) · /estimation/rate-table — 새 라우터 파일
- 직접비 = 설계내역서 합계 · 기준 입력 = estimation.cost_sheet · 폐기물처리비 = 임목폐기물 톤 × 수동 단가
- 서식 줄: 나.순공사원가 … 바.총공사비 (DFM TWM_KANJUB 차례·표기) · 경비 세목 번호 차례
- 기준 입력판: 형식(일반만) · 2.공사의종류 · 3.공사기간 · 6.관/사급 · 7.산업안전 · 8.환경보전 · 9.건설기계 — 선택지는 요율 데이터에서
- 요율표: 첫 탭 묶음 10개(보기 전용 · 수정은 잠김)
- 탭 계약 파일은 서브 314c0040 과 같은 내용(blob 동일) · 사전은 새 벌 b4
- 시험: 서식 차례·엔진 값 일치·폐기물 자리·저장값 거름·구간 표기

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 01:32:30 +09:00
eomsangdon 243c6eb3a9 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 01:31:56 +09:00
eomsangdonandClaude Opus 5 85f7c9c6b3 feat(b09): 원가계산 화면 새 틀 — 설계내역서·일위대가·단가산출근거 탭(실무 서식 · 내역 → 호표 → 산근 들어가기)
- 틀 B09_Estimation_UI_Shell: 탭 줄과 등록만 · 탭마다 파일 하나(계약 _Shell_Types)
- 설계내역서: 실무 열(합계/노무/재료/경비 단가·금액) · 머리글 접기·레벨 고르개 · 줄 누르면 제 N 호표 · 단산 N 단추 · 미확정 빨간 테두리
- 일위대가·단가산출근거: 목록표(내역에 처음 쓰인 차례) + 본표 · 줄 누르면 하위 호표·산근·중기로 · 자취 눌러 되돌아감 · Q 식 글자 그대로
- 옛 탭 여섯(원가계산서·중기·관급사급·기초자료·설계서 구성·산출기초)은 옛 코드 그대로 이어 붙임 — 새 탭 파일이 서면 등록 한 줄씩 바꿈
- 본표 합계 줄 = 호표 성분 소계 원 미만 절사 값 · 비율 줄 금액도 0.1원 절사 표시
- 사전 ui_template_locale_b3 새 벌(b2 700줄 넘음)
- 검증: ORCA 검증 프로젝트 — 본체 122,848,989 · 지장목제거 865·15,460,837 · 제 9 호표 합계 1,708 = 산근 8호표 합계 1,708 · 옛 탭 여섯 다 뜸 · 접기 53→51·레벨1 11줄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:30:01 +09:00
eomsangdonandClaude Opus 5 509660708b fix(b08): 뿌리분 직경 칸 이름에 「흉고직경 아님」 명시 — D 는 뿌리분 직경(2026-09-14 판정)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 01:10:58 +09:00
eomsangdon 3ab63eb29c auto: 2026-09-14 01:10 (ESD_LAPTOP) 2026-09-14 01:10:55 +09:00
eomsangdonandClaude Opus 5 314c00406e feat(b09): 화면 틀 ↔ 탭 파일 계약 — B09Tab · B09TabContext (탭마다 파일 하나 · 틀은 등록만)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 01:10:54 +09:00
eomsangdon 7b0a48f0cd Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 01:09:38 +09:00
eomsangdonandClaude Opus 5 35ba45f572 feat(b08): 임목폐기물 뿌리 산정법 고르개 · 지침 π 3.14 · 분리발주 대상 아님 안내
- 환경친화적인 도로건설 지침 부록1 확인: 뿌리분 체적 V = 0.3927D³ × 1,300㎏/㎥ — 고르개로 넣음(기본은 분배비 15/85)
- 뿌리분 직경 D 는 지침 표기 「뿌리분의 직경(DBH, m)」이 모호해 흉고직경과 따로 받음
- 지상부 식을 지침 원문대로 π 3.14 로 셈 — 실정보고 예 130.40㎥ 와 바로 맞음
- 분리발주 칸 밑·사유에 「임목폐기물은 분리발주 대상 아님(환경부 회신)」

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 01:09:23 +09:00
eomsangdon bdc08c3251 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 00:58:35 +09:00
eomsangdonandClaude Opus 5 1733bb6aac knowledge(축C): 작업 폴더에 먼저 들어온 명세 정정 반영 — dev 와 같은 내용(받기 막힘 풂)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 00:58:34 +09:00
eomsangdon ae9578a6dc Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 00:57:14 +09:00
eomsangdonandClaude Opus 5 7d856f37a3 feat(b08,b09): 폐기물처리비 — 경비 비목(일반관리비·이윤 밑수) · 임목폐기물 톤 · 분리발주 칸
- 원가계산서: 폐기물처리비를 경비 줄로(예정가격작성기준 제19조③18호) · 법정경비 뒤라 그 밑수엔 안 섞임
- 분리발주 칸(기본 아님) — 켜면 총원가 밖 · 총공사비에만 더함
- 준비공 「임목폐기물 처리」 톤: WA = 0.5·π·(B/2)²·h·1.3·W1·N · WR = WA × 15/85 (한국건설기술연구원 2012)
- 조사값 넷(1,000㎡당 본수·흉고직경·수고·단위체적중량) 칸 · 기본값 없음 · 5톤·100톤 경계 알림
- 수동 처리단가 빨간 테두리 + 「미확정 N건」 · 내역엔 안 서고 제외 사유 「경비」
- 시험: 승률 구조(경비·일반관리비·이윤 밑수 증가 · 법정경비 불변 · 분리발주) · 실정보고 부피 대조

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 00:55:41 +09:00
eomsangdon 54a17580da Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1 2026-09-14 00:55:31 +09:00
eomsangdon 49df2f10ef Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-14 00:55:27 +09:00
eomsangdonandClaude Opus 5 fc649316c6 feat(b09): 일위대가 호표 성분 소계 절사 · 내역 줄 성분별 절사 — 골든셋 네 벌 다 섬
- TRUNCATED_KINDS 에 UNIT_PRICE(B) 더함 · 비율 줄(공구손료·제잡비·품 할증)도 0.1원 절사
- 내역 줄 bill_line — 성분마다 절사(수량 × 성분 단가), 합계 = 셋의 합 · 직접비 밑수도 자른 성분
- 묶음 줄·구조물도 일위대가 표(하위 양식 포함)도 같은 규칙 — 성분 소계 원 미만 절사
- 골든셋 ① 일위대가표 성분 소계 477/510(93.5%) · 안 맞는 19 호표 = 영월 반올림 원본 17 · 영덕 적용률 줄 서식 2
- 골든셋 ① 설계내역서 줄 321 전수 일치
- 검증 프로젝트 내역 본체 122,870,975 → 122,848,989 · 전체 시험 1614 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 00:53:31 +09:00
eomsangdon 0cf8b41823 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-14 00:27:40 +09:00
eomsangdonandClaude Opus 5 d6916225de feat(b08): 노체다짐 칸 — 기본 꺼짐 · 켜면 토공집계 별도 줄(수량 = 성토량)
- 산출 조건 subgrade_compaction_enabled · 기본 False(9-16-2 [주]⑤ 「대규모 성토지로서 층다짐이 필요한 경우」 조건부)
- 토공집계 「노체다짐」 줄 · 규격 진동롤러(자주식 10ton) · 매핑 FP-09-16-02 · 갈래 없음
- [주]③ 굴착기+진동롤러 조합 중 굴착기는 성토(포설) 줄이 셈 — 두 번 안 셈(#조합 철회)
- 칸 밑에 [주]⑤ 근거

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 00:27:29 +09:00
eomsangdonandClaude Opus 5 af23a5d209 feat(b09): 단가산출 Q 식 — f·Q 소수 2자리 확정 뒤 나눔 · D 줄 0.1원·머리 원 미만 절사
- fix2(사사오입 0.01) — 굴착기·도자·다짐 작업량, 암 평균 Q, 표 직접 작업량
- TRUNCATED_KINDS 에 PRICE_BASIS(D) 더함 — B 는 다음 차례
- 골든셋 ③ 단가산출근거 실무 6건 208 호표 전수 일치(제외 소계·% 줄·산근 참조·계약단가 읽기) · 회귀 3,545.5 → 3,545
- 검증 프로젝트 내역 본체 122,924,846 → 122,870,975 · 전체 시험 1606 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 00:26:45 +09:00
eomsangdonandClaude Opus 5 9f72ad19b8 feat(b08): 표토제거 대상 고르개 — 노면 + 절토 사면(기본 · 법 문언) / 노면만
- 산출 조건 칸 topsoil_target · 기본 None = 별표2 Ⅰ.2.차.(6) 「노면·절토대상지」
- 노면만: 실무 영월 한 건(1m당 3.34㎡) 관측 — 사유에 법 문언이 아님을 함께 적음
- 칸 밑에 근거 두 줄 · 운반 부피도 고른 대상 따라감

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-14 00:14:46 +09:00
eomsangdon e5295dc181 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-14 00:03:10 +09:00
eomsangdonandClaude Opus 5 b876ba9c14 refactor(b09): 단가 층 차례를 X → D → B 로 — 금액 불변, 자원 집계 버림 결함 고침
- 시공능력 Q 로 선 기계 줄(굴착기·도자·직접 작업량·다짐·암 잎)은 D(단가산출)에 달고 B 는 D 를 수량 1 로 부름
- 종전 PriceBasis 의 껍데기 D(D → B) 걷음 — 「단산 N」은 D 를 품은 줄에만(갈래 줄도 실제 단가 코드로 찾음)
- 자원 집계가 B→B·B→D 참조를 끝까지 풀어 기계 사용료에 닿음(종전 조용히 버림) · 모르는 종류는 드러냄
- 조합 16% 바꿔 달기·공종 근거 문구가 D 줄도 봄
- 전후 대조: B·X 420 제목 금액 전부 같음 · 검증 프로젝트 내역 본체 122,924,846 그대로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-14 00:01:30 +09:00
eomsangdonandClaude Opus 5 82755260e2 feat(b08): 표토제거 실무대로 — 노면 + 절토 사면 ㎡ · 운반은 20 m 넘는 몫만 · 층따기 제안값 경고
- 표토제거: 답외구간 FP-09-15-02 · 밑수 ㎡ · 대상 노면·절토대상지(성토 사면 안 셈) · 노면 폭이 빈 측점이 있으면 안 섬
- 표토 운반·적치: 제거 ㎡ × 두께 · 거리 − 압토 20 m · 20 m 이내면 제거 품에 듦
- 층따기: 「제안값(확정 아님)」 경고 줄 · 칸에 자동으로 안 넣음
- 시험: 옛 ㎥ 기대값 넷 갱신 · 노면 면적 평균단면적 시험

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 23:58:38 +09:00
eomsangdonandClaude Opus 5 dfa99ed594 knowledge(축C): D 는 Q 쓰는 자리에만 섬 · 내역 코드 축은 B 하나
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 23:39:02 +09:00
eomsangdon c919a982b4 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 23:34:39 +09:00
eomsangdonandClaude Opus 5 7bd1dc1e3a feat(b08): 도자 한계거리를 산출 조건 칸으로 — 기본 60 m 와 근거 셋을 화면에 같이 보임
유토곡선 장비 경계의 도자 한계거리가 config 붙박이였고 설정 칸 haul_limits_m_override 는 아무도 안 읽었음.
dozer_haul_limit_m 칸으로 갈음해 B06 유토곡선 문맥·배분·B08 산출 조건이 같은 값을 읽음(common_util 한 곳).
화면에 기본값 60 m · 근거(품셈 8-1-1 · 산림과임업기술 5장 · 실무 EARTH.DAT 전수)를 보이고, 종무대 20 m 는 규정이라 값만 보임.
종무대 이하 값은 저장에서 막음 · 비우면 기본값.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 23:33:10 +09:00
eomsangdonandClaude Opus 5 50c3fb65f5 fix(b09): 중기 호표 잡품을 주연료비 × 율 가산 행으로·성분 소계 절사 — 골든셋 중기 143 호표 전수
- 잡품을 연료 수량에 접지 않고 주연료비의 % 가산 행으로(STmate 잡품 단가 칸 = 연료 금액)
- 시간당 중기사용료(X) 호표 — 줄 금액 0.1원 미만 · 성분 소계 원 미만 절사(명세 7장, 봉화 0.7㎥ 96,843)
- 조종원 시간당 노임 자르는 자리를 프로젝트 설정 operator_wage_digits 로(기본 0, 화면은 뒤)
- 골든셋 ④ 벌 — 실무 6건 중기사용료 시트 143 호표를 우리 단가표 층으로 되풀어 전수 일치

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 23:29:16 +09:00
eomsangdon 7d7b9cf8fa Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 23:14:35 +09:00
eomsangdonandClaude Opus 5 219c7068a9 knowledge(축C): 노임 환산 자릿수는 프로젝트 설정 — 18/18 원 단위는 틀린 전제
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 23:14:13 +09:00
eomsangdonandClaude Opus 5 fd30227b73 fix(b09): 조종원 시간당 노임을 식 좌→우 순차 + 절사로 — 골든셋 시험 첫 벌
- 계수로 미리 접으면 267,360 → 55,699 로 1원 틀림 — 식 1/8*16/12*25/20 을 차례로 풀어 55,700
- 중기 호표 운전원 줄은 시간당 노임 제목(#시간) × 인수로 부름(STmate 운전원 1 × 55,700 모양)
- 골든셋 시험 test_b09_golden_stmate — 실무 6건 환율및기초자료 시간당 칸 전수 재현
  (다섯 건 원 미만 절사 · 2025 울진 소광 0.1원 미만 절사 — 자리는 설정값)
- 28자리 끝 반올림 차례만 다른 합 비교 시험을 허용 오차로

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 23:13:20 +09:00
eomsangdon f296b8c88c Merge remote-tracking branch 'origin/안티그래비티' into main_laptop_1 2026-09-13 23:13:12 +09:00
eomsangdonandClaude Opus 5 5e76850d3b feat(axis): 마스터 갈래 키를 표 모양대로 — 단가표 갈래 제목 294 중 294 가 키에 있음(종전 86)
종전 variant_key 는 표 첫 열 값 24개라 머리행 갈래·두 단 머리를 놓치고 첫 열이 자원 이름인 표를 갈래로 적었음.
표 모양 가르기는 B09 표 읽기에 이미 있어 두 벌을 안 짜고, 그 읽기를 메모리의 마스터에 돌려 표마다 가른 갈래를 원문 표기로 실음.
못 가른 표는 빈칸. 공종마다 variant_keys(불도저 공식 갈래·합산형 단계 암질·[주]① 평균 · 부모는 단계 갈래).
짝 시험 — 단가표 #갈래 제목이 전부 마스터 키에 있어야 함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 23:11:42 +09:00
eomsangdonandClaude Opus 5 70885683eb knowledge(축C): 층 차례 정정 — D 가 먼저 서고 B 가 D 를 참조
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 22:57:57 +09:00
eomsangdon 61e743451f knowledge(원가): 예정가격작성기준·임목폐기물 산출근거 편입 2026-09-13 22:57:51 +09:00
eomsangdon edd9369e3b Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-13 22:56:11 +09:00
eomsangdon 0713dbefb0 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 22:56:10 +09:00
eomsangdonandClaude Opus 5 fb3442709f feat(axis): 이중계상 ④ — 양식 호표가 품은 성분을 모음 줄이 또 세면 멈춤
기초잡석 줄·콘크리트 타설 줄은 여러 구조물 성분을 이름으로 모아 한 줄로 세워 누구 것을 뺐는지 안 남김.
그래서 물량 보존으로 봄 — 모음 줄 + 양식 호표(B-AX-ST)가 품은 몫 + 묶음 조각이 원단위 합을 넘으면 오류.
빌더의 건너뛰기를 따라 짜지 않아, 다른 구조물에서 건너뛰기를 빠뜨리면 여기서 걸림. 배정 프로젝트 어긴 자리 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 22:55:43 +09:00
eomsangdonandClaude Opus 5 165bfed0a8 feat(b09): 갈래 느슨한 맞춤·수량 자리 못 맞춤을 로그로 — 조용히 다른 갈래에 붙는 자리 드러냄
- find_variant_code 가 글자 대신 수의 짝·구간으로 고른 자리를 한 번씩 로그(명세 14장 정정, 판정 (나))
- 수량 소수자리는 이름 그대로 두고 종목 이름을 못 맞춘 줄만 로그

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:55:27 +09:00
eomsangdonandClaude Opus 5 c34ab96763 knowledge(축C): 규정 없는 단수처리는 소수 2자리 기본값 — 법적 근거 없음 확인
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 22:54:34 +09:00
eomsangdon d56fa18971 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 22:47:35 +09:00
eomsangdonandClaude Opus 5 4ca757fadc knowledge(축C): 14장 정정 — variant_key 는 첫 열 24개뿐이라 정본으로 못 씀
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 22:46:56 +09:00
eomsangdonandClaude Opus 5 7bf5cbd1d1 fix(b09): 내역서·단가산출서도 프로젝트가 고른 단가표로 — 구조물도 미리보기와 한 벌
- 내역서 창구가 기본값 cached_build() 대신 _build_for(프로젝트) 를 씀 — 계수를 고른 프로젝트에서 화면과 내역이 갈리던 자리
- 단가산출서 한 장 창구도 같은 단가표

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:45:45 +09:00
eomsangdon 679f54853c Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 22:41:59 +09:00
eomsangdonandClaude Opus 5 66d57bb630 feat(axis): 품셈 판 pum_edition 칸 — 자원·공종 쪽, 판이 다르면 값을 안 씀
공종 마스터·자원 축 줄·B08 인계 줄마다 공종 코드가 본 판을 실음(명세 17장 규칙 3).
판이 다른 인계 줄은 내역서에서 「확인 대기」로 빠지고, 코드에 박힌 절 번호(합산 단계·표 형태 판정·가드 목록)는
마스터 판과 다르면 마스터 빌드·단가 조립을 멈춤(규칙 4). 판 기준은 common_util_pum_edition 한 곳.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 22:41:47 +09:00
eomsangdon e88877d81e Merge remote-tracking branches 'origin/CODEX' and 'origin/안티그래비티' into sub_laptop_1 2026-09-13 22:37:41 +09:00
eomsangdonandClaude Opus 5 41e0b5b7ea fix(b09): 구조물도 호표 내역 단가를 호표 계금으로 — 화면과 1원 갈림 없앰
- 내역 단가 = 구조물도 일위대가 표 계금(성분 칸 0.1원 절사 합의 1원 절사), 금액 = 성분 소계 × 연장
- 명세 7장 「호표 안 성분 소계는 절사」 — 안 자른 값을 쓰면 192,429 ↔ 192,430 으로 갈렸음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:35:41 +09:00
eomsangdon f8b24b354a docs(stmate): compare cost formats and stages 2026-09-13 22:30:17 +09:00
eomsangdon 846c4bba57 knowledge(법률): 폐기물 관계 법령 원문 편입 2026-09-13 22:30:15 +09:00
eomsangdon d1958d067b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 22:20:33 +09:00
eomsangdonandClaude Opus 5 c2e3c4cda2 feat(b09): 구조물도 일위대가를 내역으로 받는 문 — 호표 B-AX-ST-<8hex>#<제원 키>
- 양식 일위대가로 셀 구조물도 장은 인계 줄 하나(코드 AX-ST · 갈래 = 제원 키 · 수량 연장 m)
- 양식이 품은 줄(기초잡석)은 따로 인계 안 함 — 이중계상 막음
- B09 내역이 자기 단가표로 B08 일위대가 엔진을 돌려 금액 · 못 받거나 막히면 사유
- 수동 단가도 씀 — 내역 요약에 미확정 N건

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:20:26 +09:00
eomsangdon 6634e21713 Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_laptop_1' into main_laptop_1 2026-09-13 22:19:58 +09:00
eomsangdonandClaude Opus 5 938eef94bd feat(axis): 이중계상 경계를 갈 곳 칸으로 판정 — 어기면 내역서가 멈춤
① 구조물 터파기·되메우기는 토공집계로만 — 토공·사토 공제·참고로 가는 성분을 구조물 줄·묶음 조각·타설·기초잡석이 또 집으면 오류.
② 시멘트·모래·자갈이 자재총괄에 뜨면 오류. ③ 원단위표가 할증을 붙였거나, 자재 합계가 순수량 × (1+율) 과 다르거나,
할증 포함 재료량을 준 공종에 자재가 일위대가 재료비로 붙으면 오류.
판정은 B08 인계(double_count_violations), 멈춤은 B09 build_bill 한 자리. 배정 프로젝트 실측 어긴 자리 0건.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 22:18:00 +09:00
eomsangdon 004a1e21ab docs: STmate 원가 수량 화면 구조 정리 2026-09-13 22:16:59 +09:00
eomsangdonandClaude Opus 5 44b20ed21f fix(b09): 내역 받는 문 이름 맞춤 걷기 — 운반 줄 단가를 그 줄에서, 총 절취량을 코드로
- 운반 줄 단가를 이름으로 되찾던 자리를 인계 줄 → 내역 줄 짝으로(도자운반 토사·리핑암 같은 이름 실결함)
- 총 절취량 검산을 줄 이름 「깎기」·「절취」가 아니라 코드 9-3·9-4·9-5 로 셈

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 22:12:17 +09:00
eomsangdon fd4f00ab7e Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-13 21:57:22 +09:00
eomsangdon ec194ee05b Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 21:57:21 +09:00
eomsangdonandClaude Opus 5 cacbb08f10 fix(axis): 미판정 표 형태를 사람이 가름 — 66표 판정·까닭 한 줄, 찰쌓기(인력) 막힘 풀림
자동 판정이 못 가른 76표 중 64표와 오판 2표(13-4-4 뒷길이 표준·표준경사)를 표 머리·바로 위 본문 단위로 가름.
생산량형 8 · 소요량형 1 · 계수 23 · 참조 34. 못 가른 13표는 미판정으로 남김.
표 번호와 절 번호를 함께 대조해 원천 차례가 밀리면 빌드를 멈춤. 일위대가 금액 변화 없음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 21:56:39 +09:00
eomsangdonandClaude Opus 5 46b582762c feat(b08): 구조물 집계표 칸 고치기 — 정본에 바로 쓰고 손댄 칸을 표시
- 칸 조작은 캐시(sessionStorage)에, [저장] 때 structures.json·pipe_points.json 에 바로 씀
- 산출 조건에 손댄 칸 표(고치기 전 값) · ↺ 로 되돌리면 자동으로 돌아감
- 상세 칸만 고침 — 자리·길이·높이·관경은 시·종점과 한 벌이라 구조물 놓기(B05) 몫
- 틀린 값·놓기 칸·판번호 어긋남은 아무것도 안 씀 · 값 꼴은 구조물도 제원 저장과 같음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 21:55:50 +09:00
eomsangdon 644640ee59 Merge remote-tracking branch 'origin/CODEX' into main_laptop_1 2026-09-13 21:42:49 +09:00
eomsangdon e3c1119f5d Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 21:42:48 +09:00
eomsangdonandClaude Opus 5 1c79fc288c fix(axis): 표 읽기 — 콘크리트 포장 12-6 두께별 작업조 · 인력 돌쌓기 13-4-1/4 두 단 머리
12-6 은 두께 칸이 작업조 칸과 엇갈려 병합된 표라 두께 줄을 시공량 갈래로 읽음(20·30·40㎝, ㎥당).
후진 진입·경운기 칸은 「50%까지 감」 범위라 안 세움. 공종 단위가 빈 작업조 표는 시공량 열 머리 단위를 씀.
13-4-1·13-4-4 는 hwpx 병합대로 앞 세 돌 골·켜 두 잎 + 호박돌 및 야면석 한 잎으로 갈래를 세움(각 40 갈래).
13-4-4 는 같은 절의 표준경사 표 오판정으로 아직 막힘 — 형태 판정 일감으로 넘김.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 21:42:23 +09:00
eomsangdon 4a4f935c3f docs: 성토사면다짐 품셈 대응 확정 2026-09-13 20:38:00 +09:00
eomsangdon b139cc79b8 docs(경쟁사): 품셈 판 대응 확정 2026-09-13 20:26:31 +09:00
eomsangdon d35c5082d9 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 20:13:10 +09:00
eomsangdonandClaude Opus 5 d9c84aaaf0 feat(b08): 구조물 집계표 탭 — 측점별 한 줄·종류별 표·칸마다 출처
- 정본 둘(structures.json·pipe_points.json)과 B06 관 연장을 읽어 종류별 표로 세움
- 한 줄 = 측점 + 실치수(제원 칸) + 개소·연장 · 표마다 개소·연장 합·평균치수
- 칸 출처 자동·사용자·라이브러리(양식 기본값)·빈칸 · 배수관과 세월교는 다른 표
- 이 표에서 고친 값을 B05 가 바꾸면 빨간 테두리와 알림(조용히 안 사라짐)
- 구조물도 탭 앞에 탭 등록

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 20:12:24 +09:00
eomsangdon be1dfcd3a1 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 20:00:38 +09:00
eomsangdonandClaude Opus 5 980c5440a0 fix(axis): 표 읽기 — 단끊기 5-16-1 공정 줄 합산 · 「(인/100m당)」 꼴 밑수 22표
단끊기는 공정 네 줄을 토질 갈래마다 더하고 [주]⑤ 작업반장 1인/보통인부 20인을 더함.
원문 합계 줄은 절취를 ㎥당으로 더한 값이라 안 씀(까닭을 적은 공종만 섬). 같은 모양 뭉기기 13-12-1 은 안 더함.
「단위:」 없이 분모만 적은 본문 밑수를 읽어 조림·시비·떼심기 22표가 밑수 없음에서 풀림.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 20:00:15 +09:00
eomsangdonandClaude Opus 5 9a758c4d27 feat(axis): 모르터 줄 공종 코드 AX-WK-c0842a0d 모르타르 배합 1:3 — 찰쌓기 일위대가 표의 막힌 줄이 열림
산림 품셈엔 배합 절이 없어 건설 품셈 [건축] 9-1-1 을 옮김 — 보통인부 0.66인/㎥(모래체가름 포함).
시멘트 510kg·모래 1.10㎥ 는 자재 단가 층이 없어 안 붙은 줄로 드러냄(원문 할증 포함 값).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 19:51:40 +09:00
eomsangdon d72f6ed13a Merge remote-tracking branch 'origin/CODEX' into sub_laptop_1 2026-09-13 19:47:35 +09:00
eomsangdonandClaude Opus 5 aab43d652c feat(b08): 구조물도 일위대가 줄 고치기 — 줄 더하기·빼기·고르개·단가 수동, 저장 자리 둘
- 줄 조합은 양식+프로젝트(종류별), [내 라이브러리에 저장] 때 양식에 실림
- 수동 단가는 프로젝트만(값·출처·넣은 날짜) — 빨간 테두리 + 「미확정 N건」
- 고르개는 이 프로젝트 단가표에서 품셈·자원을 낱말로 찾음
- 다른 양식을 가져오면 그 종류의 줄 조합·수동 단가도 비움

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 19:46:29 +09:00
eomsangdon 200d230d5f docs(경쟁사): 임도품셈 판 대응표 추가 2026-09-13 19:44:56 +09:00
eomsangdon 628a5375c3 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-13 19:26:44 +09:00
eomsangdonandClaude Opus 5 054af7bde8 feat(b08): 구조물도 일위대가 재귀 — 하위 구조물 양식을 제원으로 풀어 윗 표에 넣음(5단까지)
- 줄이 B-AX-ST-* 를 가리키면 그 양식을 sub_vars 제원으로 L=1 풀고 일위대가를 먼저 세워 안 자른 단위당 금액을 씀
- 깊이 5단(PLAN 10장 판정) · 돌면 막힘 · 하위가 미완이면 윗 줄도 막힘 · 제원이 비면 막힘(0 으로 안 풂)
- 하위 양식은 프로젝트에 박힌 것 + 프로그램 기본에서만 찾음(개인·회사 단은 안 읽음)
- 시험 3개 추가 · 전체 1548 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 19:25:33 +09:00
eomsangdon d652f61093 Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1 2026-09-13 19:21:41 +09:00
eomsangdonandClaude Opus 5 488074151c fix(b09): 깨기 대형브레이커에 굴착기 몸체 시간도 붙임 — 부착 장비라 몸체 없이 못 돎
- 9-12·9-13 「대형브레이커(㎥/hr)」 줄에 같은 표의 굴착기를 같은 1/Q 로 붙이고 #조합(잡재료 16%)으로 바꿔 닮
- 구조물터파기·측구터파기 암·발파암 갈래 단가가 몸체 장비비만큼 오름

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 19:20:57 +09:00
eomsangdon 786d223463 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 19:16:46 +09:00
eomsangdonandClaude Opus 5 3c47120f2a feat(b08): 구조물도 하단 일위대가 표 틀 — 양식 줄 조합 × B09 단가표 미리보기
- 양식에 unit_price.rows(수량은 원단위 줄 from_row · 코드는 work_item_code+variant_from 또는 ref_code)
- 서버가 B09 단가표(PriceBook.resolve, 읽기만)로 줄마다 재료·노무·경비 · 금액란 0.1원 버림 · 계금 1원 버림
- 못 푼 줄은 막힘 + 까닭(0 아님), 원단위 줄이 안 선 장은 안 섬 · 단위 다름·하위 구조물(B-AX-ST)은 막힘
- 창구 /structure-sheets/unit-price 를 장 조회와 따로 둠(단가표 첫 조립 14초)
- 찰쌓기: 돌쌓기 B-FP-13-04-05#갈래 · 기초잡석 B-FP-12-25 · 모르터는 공종 코드 미정
- 시험 4개 추가 · 전체 1539 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 19:15:18 +09:00
eomsangdon a10432d201 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 19:12:20 +09:00
eomsangdonandClaude Opus 5 a81bc844fb feat(axis): 부모 공종 모양 parent_mode — 단계 합산형 조립으로 흙깎기 암절취 금액이 섬
- 마스터 부모 94곳에 parent_mode — 기본 choose_one, 합산형은 사람이 적은 셋(9-4·9-5·12-38)만 단계·가중치
- 9-4 암절취 = 암파쇄(연암·보통암·경암, 브레이커+굴착기 조합 1/Q) + 집토(굴착기 0.7 공식) 합으로 조립
- 표에 없는 암질은 9-4-1 [주]① 평균으로만 섬 · 9-5 발파암은 발파 단계(착암기 층 없음) 사유로 막힘
- B08 인계가 흙깎기 암 갈래 이름을 갈래 값으로 넘김 · 갈래 고르기형 부모를 가리키는 줄은 오류로 막음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 19:09:39 +09:00
eomsangdon a484783d6f Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-13 18:54:53 +09:00
eomsangdon bb479aaf98 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 18:54:14 +09:00
eomsangdon 5400096c8c auto: 2026-09-13 18:53 (ESD_LAPTOP) 2026-09-13 18:53:58 +09:00
eomsangdonandClaude Opus 5 c441acf364 knowledge(축C): 브레인 지침·작업기억을 .claude 로 옮기고 명세는 링크만
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 18:53:57 +09:00
eomsangdonandClaude Opus 5 1df51b0334 Merge origin/dev into sub_laptop_1 — 양식 파일 충돌 풂(돌 줄 부채 걷음 + 반올림 칸 둘 다 살림)
- dev 8210c2b7: 돌 줄 이름 「돌」 + 규격에 종류(_downstream_name 걷음) 그대로 취함
- sub_laptop_1 cf0ccfac: _user_basis(식·반올림 근거) · m당 풀이 × 연장 그대로 둠
- 병합 뒤 전체 1535 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 18:53:57 +09:00
eomsangdon d37dd2dc27 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-13 18:48:55 +09:00
eomsangdonandClaude Opus 5 d64f307b5d knowledge(축C): 브레인 인수인계 절 신설 — 요약·교대에도 이어지게
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 18:48:47 +09:00
eomsangdonandClaude Opus 5 cf0ccfac36 feat(b08): 구조물도 줄마다 반올림 칸 — 엑셀 함수 이름을 함께, 원단위도 m당 반올림 × 연장
- 식 칸 옆 반올림 고르개·자리수 · 고르개에 「버림 — 엑셀 ROUNDDOWN」처럼 엑셀 이름과 음수 보기
- 고친 반올림은 고친 식과 같은 자리(양식 + 프로젝트)에 저장 · 양식과 같으면 지움 · [양식대로]로 식·반올림 함께 되돌림
- build_table 도 양식을 m당(L=1)으로 풀고 연장을 곱함 — 실무 시트처럼 뒷줄이 반올림한 값을 보고 구조물도와 안 갈림
- 「대안 후보」(물구멍 2.5㎡ 등) 표 밑에 보임 · UI 700줄 넘어 식 칸 표를 _Formula.ts 로 뗌
- 시험 2개 추가 · 전체 1533 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 18:47:35 +09:00
eomsangdon cefbd0e3d1 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 18:37:30 +09:00
eomsangdonandClaude Opus 5 8210c2b756 fix(b08): 돌 줄 과도기 부채 걷음 — 이름 「돌」 + 규격에 종류, 자재총괄은 이름·규격·단위로 묶음
- 양식 갈음의 종류 이름 치환(_downstream_name) 삭제 · 돌쌓기·골막이·바닥막이(메) 전개도 「돌」+규격
- 자재총괄 줄에 spec·supply_key — 관급구분 열쇠는 이름+규격, 옛 열쇠(이름만·종류 이름)도 받음
- 화면 자재총괄 이름 칸·원단위 성분 칸에 규격 표시, 관급 선택은 supply_key 로 저장
- 원단위 합계 열쇠에 규격 포함 · 종류 이름 DESTINATION 항목 정리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 18:36:37 +09:00
eomsangdonandClaude Opus 5 13b29d620f feat(b08): 내 라이브러리에 저장·내 것 지우기 — 개인 단만 씀, 프로젝트 작업본은 그대로
- [내 라이브러리에 저장]: 그 장의 양식 + 고친 식을 로그인한 사람 개인 단에 한 벌로 씀(같은 종류는 그 코드로 덮어씀) · 저장 안 한 식이 있으면 막음
- [내 것 지우기]: 개인 단의 그 종류만 지움 · 박힌 양식·고친 식은 안 바뀜
- 기본 찰쌓기 양식에 pum_edition 2026-01-01(명세 17장 ③)
- 결함: 식 저장 뒤 다시 그린 표에 「저장 안 한 식」 표시가 남아 헛물음이 뜨던 것 — 장을 그릴 때 비움
- 시험 2개 추가 · 전체 1531 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 18:32:31 +09:00
eomsangdon 8adeae769c Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 18:21:44 +09:00
eomsangdonandClaude Opus 5 4f1521eea9 feat(b08): 구조물도 양식 가져오기 — 3단 목록은 고를 때만, 표는 프로젝트에 박힌 양식만 읽음
- 라이브러리 항목을 목록으로(항목마다 AX-ST-<8hex>.json) · 기본 찰쌓기에 코드 부여
- 가져오기: 로그인한 사람의 개인·회사 단 + 프로그램 기본에서 골라 {프로젝트}/B08_Quantity/library 에 박음 · 그 종류의 고친 식은 비움
- 표·원단위·자재총괄·인계는 박힌 양식 → 없으면 프로그램 기본만 읽음(여는 사람마다 값이 안 갈림)
- [확정] 때 아직 안 박힌 기본 양식을 박음 · 장 머리에 「어느 단에서 가져왔나 · 고친 식 N줄」
- 시험 test_b08_structure_library.py 6개 · 전체 1524 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 18:21:19 +09:00
eomsangdon 859acec6e9 Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1 2026-09-13 18:07:16 +09:00
eomsangdonandClaude Opus 5 6905cd1eb5 fix(axis): 표 읽기 2차 — 절 이름 밑수 · 조합 기종 앞머리 · 합판거푸집 막힘 사유
- 마스터 생성기: 밑수가 절 이름에만 있는 표를 이름 전체가 「수+단위+당」일 때만 읽음 —
  4-2-1 「100본당」·4-2-2 「1,000㎡당」 두 표만 바뀜(재생성 diff 2표 · basis_missing 140→138)
- 뭉친 줄 조합 기종: 앞머리를 공백 지우고 견줌 — 「굴착기+부착용집게」가 카탈로그 「부착용 집게」에 붙어
  단목베기 4-2-2 가 섬(X-0201-0020#조합 + 부착용 집게)
- 실무 대조: 단목베기 5m 미만 866원/㎡ ↔ 영월 설계내역 「잡관목제거 벌목(5m미만)」 882원/㎡(노임 연도 차 2%)
- 합판거푸집 12-4: 사용횟수별 비율 미구현이라 일부러 안 풂 — 막힌 사유를 그 까닭으로 갈아 끼움(판정)
- 일위대가 HEAD↔수정 대조: 바뀐 공종은 FP-04-02-02 하나 · 전체 시험 1522 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 18:05:02 +09:00
eomsangdonandClaude Opus 5 2ffb913c36 fix(b08): 고친 식을 장 이름 대신 양식 + 프로젝트에 묶음 — 제원을 고쳐도 식이 따라감
- 저장 칸 structure_formula_overrides 의 키를 장 이름에서 양식 type_id 로 바꿈(브레인 판정, PLAN 10장)
- 구조물도·build_table 모두 type_id 로 찾음 · 같은 양식의 장이 모두 같은 고친 식을 받음
- 화면 안내에 「같은 양식의 장 모두에 걸림」 · 시험: 뒷길이를 고쳐 장 이름이 바뀌어도 사용자 식 남음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 18:02:50 +09:00
eomsangdon f18059b23f Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 17:54:30 +09:00
eomsangdonandClaude Opus 5 eb12eec35a feat(b08): 구조물도 식 고치기 — 화면 즉시 계산 · 저장은 서버가 다시 풂
- 양식 장의 식 칸을 화면에서 고치면 같은 풀이기(TS)로 왕복 없이 즉시 다시 풂
- [식 저장]은 식만 보내고 서버가 Node 로 다시 푼 값을 돌려줌 — 브라우저 값을 받아 적지 않음
- 고친 식은 프로젝트 장 단위(산출 조건 structure_formula_overrides) · 양식 식과 같거나 비면 지움
- 원단위·자재총괄·인계·유토 입력(build_table)도 같은 고친 식을 봄 · 고친 줄 출처 user(「사용자 식」)
- 틀린 식도 막지 않고 저장하되 줄마다 오류를 돌려줌 · 양식에 없는 줄 차례는 400
- 개인 라이브러리 저장 단추는 4장(라이브러리 저장소) 몫으로 남김

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 17:52:20 +09:00
eomsangdonandClaude Opus 5 4eceedc7d9 fix(axis): 표 읽기 1차 — 뭉친 줄 표기 맞추기·비고 거름·「-」 칸 · 지장목제거를 품셈에 이음
- 뭉친 줄 해석기: 「굴 삭 기 (무한궤도)」를 굴착기(무한궤도)로 맞춰 큰돌쌓기 메쌓기(13-6-1)·
  13-7-2 장비 몫이 붙음(형식을 적은 이름만 · 형식 없는 굴삭기는 판정 Ⓒ대로 규격 미정)
- 비고·합계 줄을 자원으로 안 읽음(단목베기 4-2-2 가짜 막힘 제거) · 「-」 칸은 그 갈래에 품 없음
  (산림복원용 흙막이 13-13-2 가 특별인부·굴삭기 빠진 채 조용히 서던 것을 사유와 함께 막음)
- 지장목제거(판정 Ⓑ): 잡관목제거 → FP-04-02-02 #5m미만 · 뿌리뽑기 → FP-09-21 + 임목축적 등급 갈래 ·
  준비공 「제근·뿌리다듬기」는 같은 면적이라 참조로만(이중계상 막이) · 미결 목록에서 뺌
- 기계 이름 별칭(백호·굴삭기→굴착기)은 별칭표 대상이 아닌 카탈로그 이름 정규화로 주석 정리
- 일위대가 전후 대조(HEAD↔수정): 새로 선 공종 13-6-1·13-7-2 · 새로 막힌 공종 13-13-2(사유 있음) ·
  내역 금액: 단목베기·제근은 밑수 미확보로 계속 막혀 변화 없음 · 전체 시험 1513 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 17:52:04 +09:00
eomsangdonandClaude Opus 5 6f7ed9d09c feat(b08): 원단위·자재총괄·인계도 양식 값으로 — build_table 이 양식 있는 종류를 갈음
- build_table 이 양식 있는 종류(찰쌓기) 성분을 양식 풀이 값으로 갈음. 양식 없는 종류는 전개 그대로
- 구조물에 library_item — 원단위 탭 「양식」·구조물도 머리 「양식 있음/없음」으로 가림
- 뒤 단계가 아직 이름으로 찾으므로 돌 줄은 종류 이름으로 넘김(1장 코드 잇기 뒤 걷을 자리) ·
  자료 출처·근거 문구는 전개 것을 그대로 둠
- 검증 프로젝트 전·후 대조: 구조물 5 중 찰쌓기 2 갈음 · 움직인 값 없음 · 자재총괄 최대 차 7e-15 ·
  채집석 공제 58.674·잔토 91.75 그대로. Node 가 안 돌면 전개 값 + 사유

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 17:39:36 +09:00
eomsangdon 5a619e8a20 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-13 17:25:27 +09:00
eomsangdonandClaude Opus 5 1375d8c290 feat(b08): 구조물도 탭에 양식 식을 실음 — 산출 기초·식·갈 곳·안 섬
- 양식이 있는 종류(찰쌓기)는 장마다 양식으로 줄을 다시 세움(L=1 m당) — 식·설명·반올림·
  갈 곳·출처·안 섬 까닭을 줄에 실음. 양식 없는 종류는 지금 전개 그대로(고정형 모양)
- 화면 표: 산출 근거 밑에 식 한 줄 · 갈 곳 칸 · 안 선 줄은 「안 섬」과 까닭
- 전개 결함 둘 고침(브레인 판정): 「실무 관행」 계수가 돌종류를 지우던 것 ·
  기초잡석이 서는데 「두께가 없어 안 섬」 사유가 함께 뜨던 것
- 검증 프로젝트 찰쌓기 15줄 — 양식 m당×10 = 원단위 탭 합계 전부 일치

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 17:23:12 +09:00
eomsangdonandClaude Opus 5 a08938ea1a refactor(aliases): 별칭표를 한 벌로 — 범위(scope)·판을 붙여 B08·B09 가 같은 파일만 읽음
- 정본 resources/data_aliases/aliases_2026-01-01.json · 로더 common_util/common_util_aliases.py
  (칸 빠짐·모르는 축·겹친 범위의 두 대상은 읽을 때 오류 · 판이 다르면 안 씀 · 양방향 조회)
- 흩어진 세 벌을 옮김: 매핑표 ground_aliases(리핑암→파쇄암·암절취) 삭제 ·
  B09 UnitPrice 전역 VARIANT_ALIASES 삭제 · B08 인계본 ground_class_aliases 는 별칭표에서 만든 보기
- 범위: 리핑암→파쇄암 @FP-10-11 · 리핑암→암절취 @FP-10-12 · 화약공→1016 @FP-09-05 (근거 문구 줄마다)
- 자원 축 별칭 검사도 같은 로더로 합침(자원 목록 폴더에서 별칭 파일을 옮김)
- 시험 test_common_aliases.py 6건 · 전체 1509 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 17:22:17 +09:00
eomsangdon 2ebcf89f04 Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1 2026-09-13 17:09:49 +09:00
eomsangdonandClaude Opus 5 40e60f4947 fix(b08): 돌쌓기 물구멍관 개소당 길이를 평균두께로 — 전개·양식 함께
- 브레인 판정(PLAN 10장): 관이 벽을 가로지르므로 개소당 길이 = 평균두께(옛 붙박이 0.5m)
- 골막이는 그 정본이 0.5 라 그대로 둠
- 찰쌓기 양식 물구멍관 식도 같이 고쳐 대조 시험 121건 그대로 일치, 길이 시험 1건 추가

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 17:08:59 +09:00
eomsangdon 0f680bd39e Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 17:06:00 +09:00
eomsangdonandClaude Opus 5 f1ba36da5b chore(plan): 받기 앞 공용 문서 다섯을 dev 와 같은 내용으로 등록 (blob 대조 일치)
- 브레인 복구분(29180653) 넷 + 명세 갱신본 — origin/dev 와 hash-object 대조로 같은 내용임을 확인

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 17:05:56 +09:00
eomsangdonandClaude Opus 5 c541a6bd2f feat(b09): 자원 축 조인 키 — 카탈로그 밖 자원(AR-) 목록·규격 조인·범위 별칭
- 자재 0 해소 첫 걸음: 사급 자원 19건(AR-M 17·AR-X 2, 이름·규격·단위, 단가 칸 없음)
  resources/data_resource_catalog/ 신설 — 임도 60 + 사방 11 코드 범위의 진짜 자원만
- 규격이 조인 키: AR 항목은 후보가 하나여도 규격이 같아야 고름 · 품셈 칸에 규격이
  없으면 「규격 미정 — 후보 N」(성공으로 안 셈)
- 형식이 하나뿐인 계열만 규격으로 고름(공기압축기(이동식) 3.5·10.3) · 형식 둘이면 규격 미정
- 범위 별칭 한 벌: 화약공→화약취급공(1016) scope FP-09-05 · pum_edition 다르면 안 씀 ·
  scope 없음·겹친 두 코드는 읽을 때 오류
- 일위대가: 맞췄으나 단가 층 없는 줄은 조용히 안 빠지고 드러냄 · 기계 몫이면 막음
- ResourceAxis.py 700줄 초과분(조사용 덤프)을 _Dump.py 로 뗌
- 돌망태 품셈 절 표기 13-8 → 13-11 바로잡음
- 결과: 자원 줄 575→592(자재 0→11) · 못 맞춤 508→491 · 내역 금액 변화 0(막힌 공종 그대로)
- 시험 test_b09_resource_axis_join.py 8건 · 전체 1377 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 17:05:36 +09:00
eomsangdon 488941fd72 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 17:02:38 +09:00
eomsangdonandClaude Opus 5 31c2602f4b feat(b08): 찰쌓기 양식 한 벌 + 식 칸 셋(when·destination·spec) — 지금 전개와 값 대조
- 풀이기에 명세 13장 보강분: when(안 선 줄, 가리키면 오류) · destination 필수(기본값 없음) ·
  spec 의 {제원} 채움 · 반올림 trunc(ROUNDDOWN)·ceil_away(ROUNDUP)
- resources/library_structure/masonry_wet.json — 줄 15 · 뒷길이×돌종류 표 · 제원 vars ·
  물구멍 2.5㎡ 실무 관측은 대안 후보로
- 양식 제원 채우기 B08_Quantity_Engine_StructureTemplate.py(계산 없음)
- 대조 시험 121건: 높이·뒷길이·돌종류·기초·사용자 칸·when 갈래에서 전개와 한 줄도 안 갈림.
  알려진 차이 하나(실무 관행 계수가 돌종류를 지우는 전개 결함)는 시험에 드러냄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 17:02:26 +09:00
eomsangdonandClaude Opus 5 c31e6e71a4 knowledge(축C): 콘크리트 축 닫음 — 타설은 공종·레미콘은 자재, 기술정보DB 근거
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 16:55:49 +09:00
eomsangdon 755bb60185 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-13 16:51:42 +09:00
eomsangdonandClaude Opus 5 29180653ea fix(docs): auto 커밋 ccd1ec2a 가 지운 문서 4건 복구 (1,789줄)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 16:51:31 +09:00
eomsangdonandClaude Opus 5 f983f4566f knowledge(축C): 식 칸 셋 보강 — when·destination·이름고정. destination 기본값 금지
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 16:50:52 +09:00
eomsangdonandClaude Opus 5 848d3d1ddd knowledge(축C): 반올림 모드 둘 신설 — 엑셀 ROUNDDOWN·ROUNDUP 이 floor·ceil 과 음수에서 갈림
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 16:50:06 +09:00
eomsangdon 75b8532145 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 16:49:43 +09:00
eomsangdon 09f99fd1ae auto: 2026-09-13 16:49 (ESD_LAPTOP) 2026-09-13 16:49:07 +09:00
eomsangdon 6a5495e2db Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1 2026-09-13 16:48:45 +09:00
eomsangdon b13fb397ad auto: 2026-09-13 16:48 (ESD_LAPTOP) 2026-09-13 16:48:17 +09:00
eomsangdon e5f34ae0f0 Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_1 2026-09-13 16:47:07 +09:00
eomsangdon 4135496d2e Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 16:47:07 +09:00
eomsangdonandClaude Opus 5 197728d6c9 feat(b08): 구조물도 식 풀이기 — TS 한 벌을 화면·서버 Node 가 같이 씀
- 명세 13장 식 언어(사칙·^·비교·SQRT·MIN·MAX·SUM·IF·LOOKUP 근사/EXACT)를 직접 짠 파서로 풂
- 수는 BigInt 분수로 들고 적힌 차례대로 풂 — 부동소수 버림·반올림 1 틀어짐 막음
- 줄마다 반올림 floor·round(사사오입)·ceil·none·round_half_even, refs 는 앞 줄만
- 서버는 build:formula 번들을 Node 로 부름(B06 Server_Calc 본), 파이썬은 껍데기뿐
- 시험 15건 — 실무 찰쌓기 탭 12줄 엑셀 캐시값 재현 포함

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 16:46:54 +09:00
eomsangdonandClaude Opus 5 9bdc1a6593 knowledge(축C): 공종 코드가 품셈 판에 묶임 — 절 대응표·pum_edition 규칙
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 16:45:31 +09:00
eomsangdon ccd1ec2a50 auto: 2026-09-13 16:43 (EOMSANGDON-HOME) 2026-09-13 16:43:29 +09:00
eomsangdonandClaude Opus 5 8f19ba843f knowledge(축C): 일위대가 레시피 350개 편입 — 규격별 수량표로 접기·읽기 함정 둘
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 16:43:22 +09:00
eomsangdon d5949b1e33 Merge remote-tracking branch 'origin/CODEX' into main_desktop_1 2026-09-13 16:42:02 +09:00
eomsangdon 0a3cb8a33b docs(경쟁사): 특수산식과 현행품셈 대조 2026-09-13 15:37:07 +09:00
eomsangdonandClaude Opus 5 15c4204006 knowledge(축C): 별칭 후보 넷 판정 — 별칭 1·표읽기 1·규격고르기 2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 15:17:20 +09:00
eomsangdon 321a086084 Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1 2026-09-13 15:16:01 +09:00
eomsangdonandClaude Opus 5 68634e5f63 knowledge(경쟁사): 일위대가 레시피 사전 350개 — 누락 감사 포함
목적: 값이 아니라 조합을 확보하는 것. 표준일위대가 배포본은 암호화라 못
읽지만 실무에서 실제 쓴 조합은 출력 엑셀에 평문으로 남음.

누락 감사 — 호표 526개를 하나씩 셈
- 일위대가표 170개 전부 추출, 누락 0
- 중기사용료 143개 전부 추출, 누락 0
- 단가산출근거 213개 중 187 정상 + 빈호표 5 + 못잡음 21
- 못 잡은 21개는 전부 정체 확인함. 운반 계산(거리·속도 인자만 있음)과
  기계+인력 비율 조합이라 「자원 조합」이 아닌 것. 값을 못 읽은 게 아님
- 빈 호표 5개는 무대처리·흙쌓기 등 금액 0원 항목
⇒ 설명되지 않는 누락 0건

확보한 조합 350개 (중복 제거)
- 일위대가 132 · 단가산출 136 · 중기 82, 구성행 1,729 (조합당 평균 4.9)
- Q식을 가진 조합 107개
- 150/350 이 다른 호표를 참조 — 2단 이상으로 쌓임
-  규격이 바뀌면 구성은 같고 수량만 비례 (기슭막이 H=2.0 vs 2.5).
  한 조합 + 규격별 수량표로 접을 수 있음
- 건설표준품셈이 23개 조합에 근거로 적혀 있어 현행 갱신 시 대조점이 됨

⚠ 수량은 그 시점 품셈값이므로 조합은 그대로 쓰되 수량은 현행 품셈으로
다시 맞춰야 함.

30_원자료/일위대가_레시피.json (265.8 KB) 와 감사 기록 저장.
문서 31 신규.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 15:12:35 +09:00
eomsangdon ba2f051bd8 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 15:11:46 +09:00
eomsangdon af504d6694 auto: 2026-09-13 15:11 (ESD_LAPTOP) 2026-09-13 15:11:18 +09:00
eomsangdon d12a526012 Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_1 2026-09-13 15:11:00 +09:00
eomsangdon db681abada Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 15:11:00 +09:00
eomsangdonandClaude Opus 5 6a4507e596 chore(plan): 받기 앞 공용 진단 문서를 dev 와 같은 내용으로 등록 (blob 9ed418e8 대조 일치)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 15:10:56 +09:00
eomsangdonandClaude Opus 5 78b5ba0b71 knowledge(축C): 식 언어 확정(비교·SUM·LOOKUP 근사·round 사사오입) · 별칭과 대체 가름
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 15:09:54 +09:00
eomsangdon 3fbc132625 Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1 2026-09-13 15:07:47 +09:00
eomsangdonandClaude Opus 5 820767e4af knowledge(축C): 자재 0 과 못 맞춘 줄 진단 — 임도 범위 138줄·진짜 자원 40종
- 자재 0 의 뿌리: 사급 자재 카탈로그 부재(관급 나라장터뿐) · 품셈 칸 규격 없음 ·
  자재 칸에 섞인 경비·작업 단계 · 구조물 표는 값 없이 이름만
- 못 맞춘 508 중 임도 60코드 몫 138줄, 절반은 표 딱지를 이름으로 읽은 표 읽기 문제
- STmate 목록이 이름·규격을 대는 것 13종 · 판정 물음 넷
- 코드 무변경(메모리 실행 진단)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 15:06:34 +09:00
eomsangdon 0de395b209 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 15:04:42 +09:00
eomsangdonandClaude Opus 5 533187e875 chore(plan): 받기 앞 공용 명세를 dev 와 같은 내용으로 등록 (blob 6aaac6ab 대조 일치)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 15:04:39 +09:00
eomsangdonandClaude Opus 5 3c5aa96fc3 refactor(b07): 표준도 목록·제원 칸을 B07 에서 걷어냄 — 구조물도는 B08 탭으로
- 도면 목록·단건 조회·화면 제원 칸·[여기서 고치기]·창구 함수 제거
- 도면 kind 에서 standard 를 뺌. 표·그림 조립(build_standard_drawing·그림)은
  B08 상단 그림 일감이 다시 쓸 자리로 남김
- 옛 [여기서 고치기] 시험을 B08 구조물도 탭 폼 자리 확인으로 바꿈

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 15:04:31 +09:00
eomsangdon 07ee2bf5c5 Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1 2026-09-13 14:58:35 +09:00
eomsangdonandClaude Opus 5 d22b59ae5c knowledge(축C): 조사 실측 반영 판정 다섯 — 죽은 파일 정정·부모 두 모양·별칭 범위·갈래 키·콘크리트 보류
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 14:57:35 +09:00
eomsangdonandClaude Opus 5 f2819d513c feat(b08): 구조물도 탭을 수량산출 화면에 붙임
- 구조물 원단위 탭 앞에 「구조물도」 탭 등록(브레인 지정 배선 권한)
- 탭 차례 바꾸기·원단위 흡수는 PLAN 5장 몫이라 손대지 않음

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 14:56:01 +09:00
eomsangdon e27e60c136 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 14:53:58 +09:00
eomsangdonandClaude Opus 5 30d0351ae6 chore(plan): 받기 앞 공용 문서 두 벌을 dev 와 같은 내용으로 등록
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 14:53:55 +09:00
eomsangdon f2bb66720c Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1 2026-09-13 14:53:03 +09:00
eomsangdon d05debc12a Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 14:53:02 +09:00
eomsangdonandClaude Opus 5 3159043841 knowledge(축C): 식 칸 계약 신설 — 구조물도·라이브러리 양식형 공용
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 14:52:52 +09:00
eomsangdon b6005059da auto: 2026-09-13 14:52 (ESD_LAPTOP) 2026-09-13 14:52:44 +09:00
eomsangdonandClaude Opus 5 ea9dc220f8 knowledge(축C): 1장 첫 일감 — 공종 이름 잇기 전수 조사 (B05~B09)
- ① 이름 잇기 74자리 · ② 지반 갈래 어휘 8벌·별칭 사전 11곳 · ③ 코드 없는 공종 37
- 명세와 어긋나는 실측 다섯 — 자원 축 파일은 안 쓰는 옛것(지금 575줄·못 맞춤 508) ·
  부모 코드에 수량(FP-09-04·10-12·09-15 금액 안 섬) · 별칭은 표 범위에서만 참 ·
  #갈래 제목 202건이 한글 키 · 콘크리트 축 두 벌
- 코드 무변경. 곁 발견(자재총괄 누락·B07 확정 재계산 덮어쓰기)은 기록만

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BW6Jdsh18WPtYUR6THZJqn
2026-09-13 14:52:43 +09:00
eomsangdonandClaude Opus 5 0478fae9c2 knowledge(축C): 조달청 호환규정 XSD 근거 편입 · 규격이 조인 키임을 수치로 못 박음
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 14:50:35 +09:00
eomsangdon 385c89679d Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1 2026-09-13 14:49:36 +09:00
eomsangdon 2277d401fb Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-13 14:48:37 +09:00
eomsangdon 07aa13da34 knowledge(경쟁사): 조달청 호환규정 XSD 사본·안내문 보관 2026-09-13 14:48:36 +09:00
eomsangdon 0c5c606295 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 14:48:36 +09:00
eomsangdonandClaude Opus 5 a4a2ecac48 knowledge(경쟁사): 조달청 공사원가호환규정 XSD 발견 — 국가 표준 납품 규격 전문
최종 빈칸 점검에서 확장자 기준으로 한 번도 안 연 파일을 훑다가 나옴.
28번의 「XML 스키마는 실행 중 조립이라 못 얻음」은 틀렸음 — 정정함.

System/Valid_Xml.vsl = 조달청 공사원가호환규정 V1.0.5 XSD 전문 (1,429줄)
- 조달청 공사원가통합관리시스템(npccs.g2b.go.kr) 발표하는 공개 규격.
  STmate 고유가 아니라 국가 표준이라 Aislo 가 그대로 따르면 됨
- complexType 32 · simpleType 21 · 열거형 40곳
- 같은 폴더에 「호환규정코드(2026_09_01)」 안내문도 있음

문서 뼈대가 우리가 판 STmate 구조와 한 겹씩 대응
- 일위대가/상세 · 중기단가산출 · 기계경비/상세 · 자재 · 노무 · 경비항목 ·
  실적단가 · 제경비항목 · 내역서→공종→세부공종

 규격이 우리 계약을 뒷받침함
- 제경비항목에 적용산식·적용산식설명 칸이 있음 = 「식을 데이터로 저장하라」가
  납품 규격의 요구사항이기도 함
- 직접공사비정보에 제요율적용제외공종_금액 이 박혀 있음 = C* 제외금액 구역과
  낙찰율 적용제외가 국가 표준에도 있는 개념

 기계경비 산출 규격이 우리가 캔 것과 정확히 일치
- 시간당노임산출 H0-일반구간·H1-터널구간·H2-장대터널·H3-펌프준설선·
  H4-그래브준설선·H5-버킷준설선·H_-사용자정의
  = 22번의 wM_Set_Sys cmBx_Sel_RX 와 하나도 안 틀림.
  Start8x.Ini 의 RXXNM·RX1NM~RX4NM 이 이 자리임
- 환율형식(USD/EUR/100엔)도 EXNM_·EXEUR·EXYEN 과 맞음
- ⚠ 시간당손료산출이 E0-주간·E1-야간·E2-2교대·E3-3교대 네 갈래.
  17번에서 뽑은 손료율 25개는 E0 하나뿐임

열거형 — 주공사 9종(국가유산수리 포함)·공사현장지역코드 290·인도조건 40·
시설물유형 27·지역구분 3(산간도서 포함)·내진등급·에너지효율·녹색건축 등

XSD 사본과 구조 덤프를 30_원자료에 넣음. 최신판은 npccs.g2b.go.kr 에서
직접 받을 것.

문서 30 신규, 스크립트 2개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:48:14 +09:00
eomsangdon 90ff6d58de auto: 2026-09-13 14:48 (ESD_LAPTOP) 2026-09-13 14:48:13 +09:00
eomsangdonandClaude Opus 5 f20213592c feat(b08): 구조물도 엔진·창구·화면 조각을 B07 에서 B08 로 이관
- 장 나눔·제원 입력 엔진과 기울기 판정 대상을 B08 로 옮김
- 창구를 /quantity/structure-sheets 로 옮기고 기초잡석 두께·지반 갈래를 함께 넘김
  (옛 B07 창구는 두께를 안 넘겨 원단위 탭과 값이 갈렸음)
- 구조물도 탭 조각 renderStructureSheets 신설 — 탭 등록은 브레인 몫이라 안 붙임
- B07 표준도 목록은 탭 배선 날까지 B08 엔진·창구를 불러 그대로 둠

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 14:48:13 +09:00
eomsangdonandClaude Opus 5 8505f4b7be knowledge(경쟁사): 암호화 자료의 뼈대 — 값 없이 구조·규모를 잼
전제: 가격 값은 중요하지 않음. 반기마다 바뀌고 Aislo 는 자기 원천을 구독함.
중요한 것은 코드 체계·분류 깊이·명칭/규격/단위 자리·카탈로그 규모.

스키마는 암호화돼 있지 않음 — 막힌 것은 레코드 본문뿐
- JDCODE_*.BSC 12필드: 분류3단 + JDCODE(17자리) + NAME + SIZE(110자) +
  UNIT + EQCODE + GNCODE + TYP_STN + PSCODE + ST_NAME(표준명칭 별도)
- GRPCOD_*.BSC 7필드: CUR_COD/ROT_COD/LEV 부모-자식 트리 + 형제순서
- CSTA 32필드: 재·노·경 세 성분 두 벌 + _R 계열 + 비목별 요율

암호는 오프셋별 고정 변환
- 같은 필드 안에서 같은 평문은 늘 같은 암호문 (EQCODE 57,580행 단일 암호문)
- ⚠ 필드가 다르면 키가 다름. ROT_COD 가 CUR_COD 와 일치하는 비율 0.0%
  → 필드 안 동일성만 셀 수 있고 필드 사이는 못 이음. 트리 연결 복원 불가
- ②트랙의 STC 난독화 판정과 같은 결론이고 기준자료에서도 성립함을 확인

값을 못 읽어도 잰 것
- 자재 57,580건 전부 고유 코드인데 명칭은 1,761종뿐. 같은 이름이 규격만
  달리해 평균 33건씩 갈림 — 규격 매칭이 자원 축의 실제 난관임을 수치로 보임
- 단위 59종(자재)·130종(통합). 분류 4~5단 깊이, 상위 노드 596/5,792개
- 경비·중기는 EQCODE·PSCODE·ST_NAME 을 쓰고 자재는 안 씀
- 표준시장단가는 RATE_L(72종)만 채워지고 RATE_M·RATE_S 는 빈칸
  = 노무비만 요율 조정. 설치폴더의 「노임외 재료비적용」 파일명과 일치

⇒ 가격을 못 읽는 것이 축을 세우는 데 걸림돌이 되지 않음. 이름·코드가
필요하면 평문인 COSTN.DBF 쪽에서 얻음(894개 이미 추출).

문서 29 신규, 스크립트 2개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:43:24 +09:00
eomsangdonandClaude Opus 5 34a36ff1b6 knowledge(축C): 이름·코드 894개 확보 반영 · 골든셋 어느 창에서든 실행 가능
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 14:39:39 +09:00
eomsangdon 4aebe69665 Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1 2026-09-13 14:38:06 +09:00
eomsangdonandClaude Opus 5 7129700b2d knowledge(경쟁사): 스크립트 경로 이식성 · 자원 코드·명칭 목록 894개 추출
브레인(데스크탑 메인) 요청 둘 처리.

① 16번 골든셋 재현 의존성
- 골든셋·단가조립·자재채택 계열은 로컬 원본 C:\경영정보(주) 를 안 씀.
  저장소 안 실무문서/ 자료만 봄 → 랩탑에서도 그대로 돎
- 다만 D:\aislo-wt\sub 절대경로가 박혀 있어 창마다 깨졌음.
  스크립트 23개를 저장소 뿌리 자동탐색(_repo_root)으로 고침
- 고친 뒤 골든셋 재실행해 6건 전부 여전히 초록 확인
- 로컬 원본이 필요한 25개는 설치폴더 분석용이며 검산과 무관

② 자원 이름·코드 목록 (단가 없이도 축 매칭에 쓸 것)
- STC 의 COSTN.DBF 가 평문이라 코드·명칭·규격·단위가 그대로 읽힘
- 실무 7건 합쳐 서로 다른 코드 894개
  M 재료 228 · D 단가산출 273 · B 일위대가 158 · X 중기 96 · S 경비 92
  · L 노무 37 · W 일식견적 10
- 출력 XLSX 의 목록표 시트 6종에서도 317개가 평문으로 읽힘
- 30_원자료/stmate_code_name_list.csv 로 저장 (code·name·size·unit)
- 곁들여 ST_U_Convert 에 노임 직종 사전 106종이 평문 (28번 §3.1)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:37:16 +09:00
eomsangdonandClaude Opus 5 be2b77fe7a knowledge(경쟁사): 변환·수량산출·식 언어 — 남은 모듈 훑기
식 언어의 변수 선언이 드러남
- 「금액변수 대상:M, 저장:S, %」「소계변수 대상:M, 저장:L, %」
- 변수마다 어느 비목에서 가져와 어느 비목에 넣을지와 비율 여부를 함께 지정.
  단가 식은 숫자 계산이 아니라 비목 사이의 값 이동임
- 금액변수/소계변수가 갈리고 「금액을 계산후 제외처리」가 따로 있음
- ⚠ 중기공식(Q식)을 사용자가 등록·수정·삭제함. 10꼴은 고정 목록이 아님

STmateQty8 — 수량산출서가 별도 파일이고 엑셀 셀에 별명을 붙여 변수로 씀
- 기본수량변수(내역에 붙음) ↔ 확장수량변수(셀별명, 중간값)
- 수량산출서 → 단가산출 전송 경로. Aislo B08 과 직접 겹침
- 표본 QCODE 가 전부 빈 표라 파일 형식은 못 봄

ST_U_Convert — 조달청·XML 양방향 변환이 프로그램 안에 있음
- Conv_Jodal_ST8 / Conv_ST8_Jodal / Conv_ST8_XML / Conv_XML_ST8
- MK_K_FLD·CHK_K_FLD 호환규정 필드 생성·검사
- XML 스키마는 실행 중 조립이라 정적으로 못 얻음
-  같은 모듈에 노임 직종 사전 106종이 평문으로 있음. CONLD·COND_ 의
  직종 코드가 가리키는 이름표

식 문법 오류 메시지에서 규칙 여섯 (줄 단위 검사·거듭제곱·강제금액·
그림코드 제약·집계제외·비목별 군별단가)

⚠ EQSYNC.DBF 도 암호화. 암호화 파일이 셋으로 늘었음
(BDQTY·SYSINFO·EQSYNC)

문서 28 신규, 문자열 훑기 스크립트 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:35:11 +09:00
eomsangdonandClaude Opus 5 4deffb9316 knowledge(축C): 확장 코드 체계 확정 — AX·AR 난수 8자리, 층 글자 유지
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 14:32:02 +09:00
eomsangdon 8edeb4888d Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1 2026-09-13 14:30:00 +09:00
eomsangdonandClaude Opus 5 1fd8edc7e0 knowledge(경쟁사): 계약·실행예산·기성·준공 — 설계 뒤 네 단계 구조
26번에서 「우리가 판 8단계는 설계까지」임이 드러나 그 뒤를 팠음.

계약 (wM_Mk_Cont)
- 낙찰율이 하나가 아니라 노·재·경 세 갈래로 따로 들어감
- 적용율을 제외할 공정을 행 단위로 고름 (관급자재대 등).
  16번의 C* 제외금액 구역과는 다른 개념
- 단가적용율 0% 이면 계약단가를 0 으로 만들어 공내역 생성
- 예상적용율 찾기 — 목표 계약금액에서 역산

기성 (wM_KanJub_K)
-  간접비가 「금회직접공사비 x 계약제잡비율」 단일 요율로 바뀜.
  설계처럼 항목별 밑수x요율이 아님
- 전회·금회·누계와 각각의 비율을 함께 들고 감. 사정·잔량·기성고(%) 포함
- ⚠ 부가세 선택지가 설계와 다름 (재료비+산출경비의 10% 추가)
- BOQ11 의 QTY1_B/N/O/H 가 기성 수량 칸으로 보임 (설계 표본에선 전부 0)

실행예산 (wM_Boq_ExecX)
- 설계단가를 그대로 쓰지 않고 실행단가를 따로 만듦
-  차액 보정 순서 — 성분별 절사로 생긴 차액을 어느 비목에 몰아줄지 고름

준공은 계약금액·준공금액 두 열뿐. 별도 계산 없음.

계약제잡비율 계산은 ST_KanJub 한 곳에만 있음.

⚠ 설계는 원 단위까지 재현했으나 뒤 네 단계는 「구조는 알고 값은 못 맞춤」.
기성·계약 STC 표본이 0건이라 골든셋 검증 불가.

문서 27 신규, 스크립트 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:29:56 +09:00
eomsangdon bdf82e55e4 Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/sub_desktop_1' into main_laptop_1 2026-09-13 14:26:59 +09:00
eomsangdonandClaude Opus 5 f9bf0a9c34 knowledge(축C): 두 축과 일위대가 계약 명세 — 자원축 실측 반영
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 14:25:50 +09:00
eomsangdonandClaude Opus 5 540488de55 knowledge(경쟁사): Cm 사이클식·품셈 인자·식 편집기·낙찰율 — 계약/실행/기성 단계 발견
Cm(사이클 시간) 식 54꼴
- t1+t2+t3+t4 4단계가 최다. 5·6단계도 있음
- 운반은 L/V1+L/V2+0.25 — 거리/적차속도 + 거리/공차속도 + 상수.
  임도 토공 운반에 그대로 쓸 수 있음
- 굴삭기는 선회각별 사이클 초 (90도 18초, 135도 20초)

품셈 인자 실측 — q(버킷용량)·K(버킷계수)·f(토량환산)·E(작업효율)·V1~V4(속도)·
t1~t6(단계시간)·P=qo*eo(출력)
- ⚠ f 가 1/1.3 같은 분수 문자열로 적힘. 미리 소수로 접으면 자릿수가 어긋남

wM_Edit_San 은 단순 입력이 아니라 식 편집기
- 손료계산(F6)·계산(F9)·중기공식 편집·함수 입력·연산기호·변수명
- 문법 강조·문법 오류 검사·문법설명 보기
- COSTN.SMT 전개 텍스트가 이 편집기의 산출물. 재구현하려면 식 언어와 검사기 필요

⚠ 낙찰율 NAGCD 발견 — 계산 사슬에 없던 것
- wBoq 단가 그리드 23열의 마지막이 NAGCD(낙찰율)
- wM_Edit_San 에 결과값에 낙찰율적용·계약단가·결과금액조정
- 우리가 판 8단계는 설계까지임. 계약·실행예산·기성·준공 단계가 더 있고
  계산 규칙이 다를 수 있음 (기성 제잡비는 실제로 부가세 선택지가 다름)

그 밖
- 내역 계층 표기가 += ~ +===== 로 레벨을 나타냄
- Prn_* export 152개를 SET_REP 74종과 이어 64종 대응

문서 26 신규, 스크립트 3개·원자료 2개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:25:35 +09:00
eomsangdon a70cfc9180 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 14:23:28 +09:00
eomsangdonandClaude Opus 5 c845078570 chore(plan): 받기 앞 공용 보관 파일 두 벌을 dev 와 같은 내용으로 등록
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016VBGFXB9AbJBwXP19z75Qq
2026-09-13 14:23:24 +09:00
eomsangdon e165f93db5 Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-13 14:21:47 +09:00
eomsangdonandClaude Opus 5 e7c090d3fd knowledge(경쟁사): 설정키 241종·간접비 식 26개 전수 — 설정 사슬 닫힘
PRJLST.SMT 저장키 전수
- 241종. 접두어가 뜻을 가름 — C_(금액직접입력 62)·R_(요율 39)·I_(선택 41)·
  B_(켬끔 12)·D_(밑수 26)·S_(산출액 30)·ADD_(추가금액 22)
-  D_ 와 S_ 가 같은 항목 이름으로 짝을 이룸. 항목마다 「어떤 밑수에 곱했고
  얼마가 나왔는지」를 프로젝트에 남김. 09번의 indirect_result 계약과 맞음
- 추가금액 저장 구조 ADD_AMT_단계_행_칸 (11번이 화면 문자열로만 본 것)
- 고용개선지원비가 휴업수당·장려금·사회보험·지원금 네 갈래
- 수공 밑수 배분 계수 R_R_DM·DL·L·DMDL·DML 이 rGrd_RateB 열 이름과 일치

간접비 식 템플릿 26개 전수
- 밑수별 기본 꼴 여섯 (총액·노무·재료·노무+토큰·총액-재료·세 성분 합)
- ⚠ 식 안에 IF(...) 조건 분기가 있음. DP_Common 의 IIF·IFI·IFO 가 처리.
  사칙연산과 ROUND 만으로는 재현 불가
- ROUNDDOWN([C_KUNBO]*[RAT_1]) — 장기요양은 건강보험료 「직접입력 금액」에
  요율을 곱함. 19번의 「당초액 유지」와 이어짐
- 복합 보험식·변경설계 A/B·사급자재 /1.1 전부 포함

설정 사슬이 닫힘 — 화면 272축 → 저장키 241종 → 식 26개 → 함수 73개 →
D_/S_ 재저장 → 출력. 다섯 칸이 모두 이어짐.

문서 25 신규, 키 원자료와 스크립트 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:21:03 +09:00
eomsangdonandClaude Opus 5 50113cb6f5 knowledge(경쟁사): 모듈 46개 export 전수 — 계산 공용 라이브러리·동글·8차 해소
11번은 핵심 CPM 12개만 다뤘고 32개 모듈이 손대지 않은 채였음.

플러그인 규약
- 46개 중 31개 모듈이 Calc_Kanjub 를 내보냄. 재계산 진입점이 한 곳이 아니라
  모듈마다 있음. T8(UI 명령별 재계산 범위)이 어려운 까닭

DP_Common.CPM 73함수 = 계산 공용 라이브러리
- 식 평가 CALC_STR·CALC_STR_PART·CALC_STR_URC·GET_URC_TYP
- 중간변수 GET_VARI_VAL·SET_VARI_VAL (:=A01 라벨)
- 반올림 MROUNDUD·MROUNDUP·MTRUNC·MINT·MFRAC·GET_ROUND_CUT
- 조건 IIF·IFI·IFO·CHK_TRUE_CLSTR — 식에 조건 분기가 들어감
- AMTTOHAN 금액 한글 변환
- ⇒ 식 평가기는 사칙연산+ROUND 만으로 부족. 조건 함수와 중간변수 필요

ST_P_XLS.CPM 72함수 — 엑셀이 양방향 교환 경로
- CONV_XLS_2_ST(엑셀→STmate 역등록)·XLS_2_BDREC(엑셀→BDQTY)·Prn_FMLA_INFO
- 암호화된 BDQTY 를 엑셀에서 만들 수 있다는 뜻

SX32W.CPM 29함수 = Rainbow Sentinel SuperPro 동글 API
- 라이선스가 소프트키가 아니라 USB 동글. 통제시험이 영구 불가인 근거 확정

ST_Jabpum: Edt_Hal_Amt / Edt_Hal_Rate — 할증이 금액·율 두 갈래

변경 차수 8차 의문 해소
- PRJLST.SMT 섹션 최대 2, DBF 접미사 1~4, 바이너리에 1~3차변경만,
  BOQ2* 없음. 한 파일은 4벌이 맞고 8차는 분리형 파일 선택임

문서 24 신규, export 원자료와 스크립트 2개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:18:37 +09:00
eomsangdonandClaude Opus 5 6bcc872851 knowledge(경쟁사): 세 폴더 상호참조 · COSTN.SMT 전수 파싱
세 폴더 머리에 서로를 가리키는 블록을 넣음 — 또 갈라지지 않게.
technical_info/01_임도/05_원가정보/원가계산_체계.md,
original/원가계산/STmate/_meta.md,
original/경쟁사 프로그램/STmate 분석/README.md 셋 다.
2026-09-13 에 ③이 ①②를 모르고 시작해 중복 분석한 사실도 함께 적음.

COSTN.SMT 전수 파싱 — ②트랙이 해독해 둔 문법을 그대로 씀
- 맛보기 1건 + 실무 STC 7건에서 자원참조 1,570·품 1,460·Q식 187·
  중간라벨 162·할증률 92 검출
- 참조되는 자원은 중기(X) 1,504 로 압도적. 노무 58·재료 7·경비 1.
  단가산출은 거의 전부 중기 시간당 사용료를 작업량으로 나누는 계산임
- 중기 한 대를 참조하면 노무·재료·경비 세 성분을 각각 따로 끌어옴
  (L 548·S 524·M 498). 총액 하나로 저장하면 안 되는 이유
- ⚠ Q 식이 한 꼴이 아님. 최소 10가지. 3600 계열(초)과 60 계열(분),
  포설은 60*L*D*H*E*f/(N*Cm). 하드코딩 금지, 텍스트 평가 경로 필요
- 운반 계산이 적재시간+왕복시간+적하시간 으로 사이클을 쌓음.
  km/hr·km·m/min 인자가 함께 나옴. 임도 토공 운반에 직접 쓸모
- 할증률 92건이 품목마다 제각각 (2%·3%·5%·24%·52%·0.443%).
  BDQTY 입력 원장은 여전히 막혔으나 적용값은 읽힘

문서 23 신규, 파싱 스크립트 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:13:10 +09:00
eomsangdonandClaude Opus 5 f0319e311e knowledge(경쟁사): UI 폼 전수 분석 — 선택축 272개, 화면에 남아 있던 계산 규칙
폼 233개(고유 190개) 중 3개만 뽑아 쓰고 있었음. 전수로 훑어 폼 40개에서
선택축 272개를 뽑음. 계산설정 폼 하나에서 뽑았던 83축의 세 배가 넘음.

화면에서 새로 나온 계산 규칙
- wM_Edt_Hal: 적용단가 = 기준단가 x 율%, 소수 n 자리 미만 절사/반올림.
  「적용단가 0원인 것과 보호표시된 것은 수정 안 됨」 = T10 일괄변경 수동값
  우선순위가 화면 안내문에 있음
- wM_Rate: 단수처리가 전역이 아니라 비목(M·L·X·W)마다 절사/반올림 x 소수 0~4위
- wM_Sel_Prn: ACTTYP = 표준시장단가 적용 방식(노임외 비목 적용안함/재료비로/
  경비로), ACTOPT = 끝전 보정 우선순위. 설치폴더의 STBSC 파일명과 정확히 대응.
  봉화는 ACTYP=1 ACTOP=2 = 「노임외 재료비로 적용 + 소수 비교후 타금액 우선보정」
  ⚠ 변경 차수가 화면에서 8차까지임. COSTN 접미사 4벌과 어긋나므로 재확인 필요
- wM_Set_Sys: KCDNM·ACDNM·ZCDNM·JCDNM·ECDNM·BPNNM 이 전부 소수 자리수 설정.
  20번에서 미확정으로 둔 것이 풀림. EXRNM 은 환율 적용 범위, RX1NM~RX4NM 은
  작업조건별 노임 환산식 4벌(터널·장대터널·펌프/GRAB/버켓준설선)
- wM_KanJub_K: 기성 계산은 부가세 선택지가 설계용과 다름 (재료비+산출경비의 10% 추가)
- wM_Mrg_BSC: 반기 기준자료 갱신의 매칭 키 5단계와 덮어쓰기 정책
- wM_JabPum·wQtyList: 코드 종류 전체 목록에 「그림코드」가 따로 있음

문서 22 신규, UI 카탈로그 원자료와 스크립트 3개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:10:47 +09:00
eomsangdon 198cd3120f knowledge(경쟁사): 20번 요약표의 SMT 행도 정정 2026-09-13 14:04:47 +09:00
eomsangdonandClaude Opus 5 3640f737a9 knowledge(경쟁사): SMT 판정 정정 · 지식DB 트랙 셋 통합 · 암호화 성격
최종 확인에서 내 오류 하나와 큰 구멍 하나가 나옴.

정정
- *.SMT 를 암호화라 적은 것은 틀렸음. 앞 4KB 만 보고 필터를 세게 걸어
  놓쳤고 본문은 0x200 부터임. COSTN.SMT 276개·PRJLST.SMT 153개 문자열이
  평문으로 읽힘. 암호화는 BDQTY·SYSINFO 둘뿐이라는 판정은 그대로 유효

구멍 — 지식DB 에 STmate 트랙이 셋이고 서로 몰랐음
- technical_info/01_임도/05_원가정보 (계산 로직 10문서, 2026-08)
- original/원가계산/STmate (STC 포맷 8문서, 2026-08-15)
- original/경쟁사 프로그램/STmate 분석 (이번 것)
- 단수처리 규칙·INITIAL 시트·SMT 문법·BDQTY 난독화는 이미 있던 것을
  다시 한 셈. 다만 실행모듈 정적분석·폼 복원·골든셋 회귀·변경설계 모드·
  단가산출 Q식은 이번에만 나온 것

PRJLST.SMT — 가장 중요한 발견
- 프로젝트별 원가계산 설정이 INI 평문으로 들어 있음. 차수별 섹션
- 요율이 좌표가 아니라 값으로 저장됨 (R_KANNO=10.8, R_IYUN=15 등)
  → 프로젝트 읽기·검산에 요율 좌표(T1)가 필요 없음. 우선순위 격하
- 수공형식 세부경비 8개 항목 확정 (R_S_SUDO/BOKRI/SOMO/TONGSIN/SEGUM/
  PEGI/DOSEO/JIGUP). KAN_ORD_2 의 331~338 과 일치
- 직접입력 금액 C_KUNBO·C_YUNBO·C_IYUN_M 이 변경설계 「당초액 유지」와
  끝전조정 보정액의 저장 자리

암호화의 성격
- 암호화 41개는 전부 파일명에 출처 기관·반기가 박힌 유료 구독 데이터.
  평문 43개는 법정 공개 자료인 제비율 요율표. 후보 슬롯 이름이 국내
  물가지 이름 그대로임. 프로그램 로직이 아니라 사온 데이터를 지키는 것
- Aislo 는 노임·유가·환율·시설공통자재를 이미 직접 확보. 실질 격차는
  물가지 자재단가 구독 하나

문서 21 신규, SMT 덤프 스크립트 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 14:04:32 +09:00
eomsangdonandClaude Opus 5 9abd6a453d knowledge(경쟁사): Start8x.Ini·SET_REP 점검 — 간접비 코드 사전과 서식 75종
System 폴더만 보고 STmate 자료 폴더를 STC 와 같다고 넘겼던 것이 구멍이었음.
그 폴더에 STC 에 없는 Start8x.Ini·STini.MEM·QCODE.SMT 가 있었음.

Start8x.Ini 에서 나온 것
- 간접비 계산 순서가 KAN_ORD_1/KAN_ORD_2 코드 목록으로 저장돼 있음.
  두 목록의 차이가 321(기타경비 묶음 1개) 대 331~338(세부 경비 8개) 이라
  12번의 「일반형식 대 수공형식」 구분과 정확히 맞음. KAN_TYP 가 고름
- 코드 사전 301~370 을 봉화 출력 순서와 대조해 확정. 값이 0 인 항목은
  출력에서 빠진다는 규칙으로 하나도 어긋나지 않음. 등급 A
- ⚠ 후보 슬롯 이름이 설치본마다 다름. 설치 기본은 다섯째가 「유통 물가」인데
  실무 프로젝트는 「견적단가」임. 슬롯 번호에 기관명을 박으면 안 되는 근거
- COD_FMT_1~8 코드 종류 사전 (대가=일위대가 B, 산근=단가산출 D)
- [EQUIPMENT] 에 노임 환산식·환율·적용 직종의 설치 기본값
- [NAG_HAL] 할증률 설정 자리 발견 (표본은 전부 0)
- DISP_EX=제외금액 — C* 구역 이름의 출처

SET_REP.DBF 는 보고서 서식 75종 카탈로그. 표본 XLSX 에 나온 30종 말고
기성·견적·계약·준공·계획·실행예산 등 45종이 더 있음. 새 계산은 아니고
출력 변형. 수공양식·표준시장 서식이 카탈로그에 있어 그 형식이 실제로
지원됨을 확인

COSTN.SMT·QCODE.SMT·PRJLST.SMT 는 본문에 읽히는 문자열이 없어 암호화로
판정. 다만 같은 정보가 출력 XLSX 에 평문으로 있어 의존 그래프 복원에는
지장 없음

문서 20 신규, 스크립트 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 13:53:47 +09:00
eomsangdon 912092710e Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1 2026-09-13 13:48:56 +09:00
eomsangdonandClaude Opus 5 b42793eb58 knowledge(경쟁사): 일위대가 참조 그래프·설계변경 직접비 실증 — 지금 자료로 할 것 마무리
일위대가 호표 참조 그래프 (④단계)
- 봉화 2024 의 45개 호표를 그래프로 세움
- 구성행이 다른 호표를 가리키고 단가가 일치하는 것 22건, 이름은 같은데
  단가가 다른 것 0건
- 순환 참조 없음 -> 위상순서 계산 가능. 깊이 최대 2단
- 09번의 「의존 그래프·위상순서·순환은 오류」가 실데이터에서 성립함

설계변경 직접비
- 계류보전 2025(변경) 의 BOQ11 은 한 표에 차수1(당초)·차수2(변경) 를 나란히 담음
- 양쪽을 각각 다시 세워 직접재료비·직접노무비·산출경비 전부 원 단위 일치
- 행금액 절사 대조 294건 x 2 = 588건 불일치 0
- 제외금액 구역 판정은 차수와 무관하게 같음
- 19번 6장의 간접비 모드와 합쳐 변경설계가 직접비부터 간접비까지 전부 재현됨

진행률 장부 갱신 — 지금 자료로 할 수 있는 일은 사실상 소진. 남은 것은
새 표본이 있어야 열림.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 13:45:56 +09:00
eomsangdonandClaude Opus 5 3d4d2718ff chore(plan): 계산 사슬 재정립 전 구컨셉 계획서 전문 보관
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0154EMdwPYNPuKc9eZv8SLEX
2026-09-13 13:44:12 +09:00
eomsangdonandClaude Opus 5 5e2a40b17a knowledge(경쟁사): 출력 시트 30종 전수 훑기 완료 — 집계표 구조와 ±1 오차 원인
남은 16종을 전부 열어 본 결과 새 계산 규칙은 나오지 않음. 대신 둘이 밝혀짐.

- 착공내역서 178건 절사 100% — 설계내역서와 같은 규칙
- 목록표 7종·표지 2종·총괄내역서 2종은 계산 쌍이 없는 색인·표지 성격
- 집계표 4종에는 단가 열이 없음. 금액 = 절사(수량 x 해당 호표의 시간당
  성분단가). 굴삭기 0.2·0.7 은 전 성분 일치
- ⚠ 어긋나는 칸은 전부 -1 이며, 경비로 역산하면 표시 수량 940.69 가
  실제로는 940.68998... 임. 집계표 수량은 표시용 반올림값이라 그대로
  역산하면 1원씩 어긋남. 09번의 「입력 원값과 표시 반올림값을 분리」가
  이 자리

진행률 장부의 출력 시트 칸을 48% -> 100% 로 갱신. 훑기 스크립트 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 13:43:59 +09:00
eomsangdonandClaude Opus 5 46dc94af70 knowledge(경쟁사): 변경설계 계산모드 실증 · 진행률 장부 신설
T9 확정 — 계류보전 2025(변경) 의 공사원가계산서가 당초·변경·증감을 나란히
내는 것을 찾아 가름. 직접노무비가 +8,802,162 늘었는데도 건강보험료·
노인장기요양보험료·연금보험료는 증감 0 임.

- 재계산 모드: 간접노무비·산재·고용. 요율은 당초와 같고 변경 밑수로 다시
  계산하면 원 단위로 일치
- 당초액 유지 모드: 건강·장기요양·연금. 사후정산 대상이라 설계 단계에서
  당초액을 그대로 가져감. 재계산했다면 85만 원이 더 붙었을 것
- 전부 재계산으로 구현하면 그만큼 틀림

곁들여 출력 시트가 21종이 아니라 30종이었음. 설계변경 시트 3종이 있어
「변경 출력이 표본에 없다」던 앞선 서술이 틀렸음.

문서 19(진행률 장부) 신설 — 원천별로 파일·표·시트를 실제로 세어 진행률과
남은 것을 적음. 계수 스크립트 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 13:38:06 +09:00
eomsangdonandClaude Opus 5 94a9412ac8 knowledge(경쟁사): 누락 계산로직 점검 — 사슬 앞쪽 네 칸을 채움
출력 XLSX 21개 시트 중 검증한 것이 4개뿐이었음. 나머지를 전부 열어 본 결과
계산 사슬 앞쪽이 통째로 비어 있었음.

채운 것
- 노임 일단가 -> 시간당 환산식. 환율및기초자료 시트에 식이 있고 INITIAL 의
  RXNM_ 에 문자열로 저장됨. 적용 직종은 COND_
- ⚠ 식은 좌에서 우로 차례로 평가해야 함. 계수를 먼저 곱하면 부동소수점
  때문에 1원 틀어짐. 18건 중 좌우순차 18/18, 계수선계산 14/18
- 단가산출 D코드 공식. Q = 3600*q*K*f*E/Cm 로 시간당 작업량을 내고
  성분단가 = 절사(중기 시간당 성분단가 / Q, 소수1). Q 는 소수2 로 먼저 확정.
  73개 호표에서 발견. 품셈 근거·토질·조합기준이 문자열로 남아 있음
- 계산 설정 스냅샷이 출력 XLSX 의 INITIAL 시트에 통째로 있음.
  12번이 「화면 없이는 재현 불가」라 한 것을 정정. JMKN6 = 적용 단가 확인
- 집계표 행 금액은 사사오입 100%. 호표 성분 소계(절사)와 다른 자리임을
  수치로 가름

문서 18 신규, 검증 스크립트 2개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 13:16:49 +09:00
eomsangdonandClaude Opus 5 cd3aaef117 knowledge(경쟁사): 통제시험 수행 불가(라이선스 없음) 반영 · 요율 두 부류 구분
전제 정정
- STmate 라이선스가 없어 13번 T1~T11 을 띄울 수 없음. 「나중에 시험으로
  확정」 계획이 성립하지 않으므로 13번 머리에 못박고, 대체 경로 셋과
  그것으로 이미 푼 것을 적음. 목록 자체는 장부 겸 절차서로 남김
- 남은 것을 늘릴 유일한 수단은 표본 늘리기. 특히 수공(S)·실적(TA/SA)
  형식 표본이 하나도 없어 그 세 형식은 전혀 검증되지 않음

통제시험 없이 새로 밝힌 것
- 요율 항목이 두 부류임. 고정 칸(보험·산안비·이윤)과 규모·기간에 따라
  행이 움직이는 것(간접노무비·기타경비·일반관리비). 뒤쪽은 고정 좌표라는
  모델 자체가 틀림
- 이윤 좌표 RATE_R[20,16] 확정 (3건 일치)
- 요율이 그 프로젝트 표에 아예 없는 경우가 실재함. 간선임도 간접노무비
  14.5%, 소광 이윤 13.435% 가 자기 RATE_R 에 없음 = 사용자 수동 입력.
  재구현은 rate_source 를 table(coord)/manual 로 갈라 저장해야 함

교차검증 스크립트 2개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 12:42:53 +09:00
eomsangdon f45f5a9cd9 knowledge(경쟁사): 17번 다음할일에 호환성 차단점 여섯 현황 정리 2026-09-13 12:29:43 +09:00
eomsangdonandClaude Opus 5 017c5120b9 knowledge(경쟁사): STmate 자재 채택 규칙 확정 — 0 아닌 후보 중 최저가
07번이 C등급으로 두고 「최저가·최신가·특정 슬롯 우선 중 어느 하나라고 단정할
근거가 없다」 한 항목을 실증으로 확정.

출력 XLSX 의 자재단가대비표에 후보 다섯 칸(조달가격·물가자료·물가정보·
거래가격·견적단가)과 적용 단가·출처가 나란히 있어 직접 검정함.

- 실무 6건 전수 186건 중 186건이 후보 최저가와 일치
- 후보 값이 2종 이상인 76건 전부 최저가
- 후보 값이 3종 이상인 36건 전부 최저가
- 최저가가 아닌 후보를 택한 건 0, 후보 밖 값을 쓴 건 0

곁들여
- COSTN 의 여섯 칸 중 여섯 번째가 적용 단가 칸으로 보임
- 후보 없이 적용만 있는 25건은 유가 자료, 적용 0 인 49건은 별도계상
- 남은 것은 사용자 수동 고정 시의 우선순위 (T10)

09번이 꼽은 여섯 호환성 차단점 중 자재 자동채택이 풀림.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 12:29:19 +09:00
eomsangdonandClaude Opus 5 f76e5d659f knowledge(경쟁사): STmate 단가조립 검증 — 중기 손료율 실측, 성분 소계는 절사
15번이 「BDQTY 암호화로 단가 조립 불가」·「중기 손료 계수 어디에도 없음」이라
적은 것을 정정. 출력 XLSX 의 일위대가표·중기사용료 시트에 구성 자원·수량·
단가·성분별 금액이 전부 평문으로 있음. 입력 원장은 못 읽어도 조립 규칙은 읽힘.

밝힌 것
- 호표 성분 소계는 절사. 중기사용료 398건 100%, 일위대가 325/345.
  집계표 행 금액(ROUND)과는 다른 자리이므로 섞으면 안 됨
- 중기 시간당 사용료 = 손료(취득가 x 손료율) + 운전원 + 주연료 + 잡품(연료비 x 율)
  성분별로 절사한 뒤 합
- 잡품은 수량x단가가 아니라 연료비에 비율을 곱하는 가산 행
- 시간당 손료율 25개 장비 실측. 같은 장비도 암석 조건이면 손료율이 오르고
  (0.2085 -> 0.2405), 부착 장비는 취득가가 부착물만의 값임
- 일위대가 호표가 다른 호표를 참조하므로 위상순서 계산이 실제로 필요함

⚠ 손료율 값은 STmate 가 담은 것임. Aislo 기본값은 현행 건설기계 경비 산정
기준에서 확정할 것이며 이 표는 대조용.

문서 17 신규, 검증 스크립트 2개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 12:26:44 +09:00
eomsangdonandClaude Opus 5 109e3846d0 knowledge(경쟁사): STmate 차수 규칙 실증 — BOQ12 가 현재안, 차수 인계 확인
골든셋 회귀에서 소광 한 건이 어긋난 원인을 파고든 결과.

- 한 STC 에 BOQ11·BOQ12 가 함께 있으면 출력은 BOQ12 를 씀.
  BOQ11 을 현재안으로 잡으면 그 프로젝트만 통째로 틀림
- 변경 파일의 BOQ11 차수1 이 앞 파일의 BOQ12 와 원 단위로 같음.
  차수 인계 메커니즘 실증
- PRJLST.TCOSTn = MCOSTn+LCOSTn+SCOSTn 이며 직접공사비 합계
- 소광 변경1차 직접공사비 173,311,386 -> 257,410,433, 증감 +84,099,047

회귀 스크립트가 현재안 표를 고르도록 고침. 이로써 실무 프로젝트 6건 전부
원 단위 일치, 내역 행 1,515건 불일치 0.

08번 문서의 차수 경고를 실제 규칙으로 승격. 문서 16 보강, 차수 추적
스크립트 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 12:21:14 +09:00
eomsangdonandClaude Opus 5 87d4519ce0 knowledge(경쟁사): STmate 원가계산 골든셋 회귀 — 프로젝트 6건 원 단위 재현 성공
프로젝트 STC 의 BOQ11 만 입력으로 삼아 직접비부터 총공사비까지 다시 계산하고
STmate 가 뽑은 XLSX 공사원가계산서와 원 단위로 대조한 결과.

결과
- 실무 프로젝트 6건 전부, 모든 항목이 1원도 안 틀림
- 내역 행 금액 ROUNDDOWN(수량 x 단가) 대조 1,515건 중 불일치 0
- 봉화 2024 는 직접비부터 총공사비 1,414,283,000 까지 전 항목 일치

새로 밝힌 규칙 다섯 — 이것 없이는 맞지 않음
- 말단 행 판별: BDCODE1 접두 T*(제목)·E*(계)·C*(제외총계)·:(페이지) 가 구조 행
- C* 로 닫히는 구역은 제외금액 구역이라 직접비에서 뺌
- 산업안전보건관리비의 관급재 밑수 = 관급자재대 - 조달수수료
- 끝전조정 = 도급공사비 천원 차액을 ROUND(차액/1.1) 로 세전 환산해 이윤에서 차감
- 관급자재대 = ROUNDUP(원자재대, 천원)

이로써 09번 완료 정의의 원설계 재현 조건이 실증으로 충족됨.
끝전조정·간접비 순서·계층 집계·제외금액 취급이 B 에서 A 실증으로 올라감.

문서 16 신규, 회귀 스크립트 2개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 12:17:51 +09:00
eomsangdon d0c5bd644b Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1 2026-09-13 12:10:32 +09:00
eomsangdon 79a9c510bd auto: 2026-09-13 12:10 (EOMSANGDON-HOME) 2026-09-13 12:10:28 +09:00
eomsangdonandClaude Opus 5 b656e5944d knowledge(경쟁사): STmate 재구현 가능성 검증 — 문서 오류 셋 정정·요율 좌표 확정
문서 주장을 믿지 않고 원본 파일과 직접 대조한 결과.

바로잡은 것
- COSTN.DBF 는 암호화돼 있지 않음. 맛보기 71행·봉화 248행을 그대로 읽음.
  암호화된 것은 BDQTY 와 SYSINFO 둘뿐. 단가 네 성분이 평문이라 골든셋을
  단가 수준까지 뽑을 수 있음
- TAMT=MAMT+LAMT+SAMT 에 예외가 있음. 59행 중 2행(관급 흄관)이 어긋남.
  하드 오류로 막으면 실데이터를 거부하게 됨
- 프로젝트 RATE 표는 4종이 아니라 6종. RATE_A(25x10)·RATE_B(35x10) 추가 발견.
  값이 0~1 계수로 성격이 다르며 수공형식 밑수 배분표로 보임

새로 확정한 것 — 통제시험 T1 의 절반을 정적으로 풀었음
- 출력 XLSX 비고 칸의 실제 요율을 프로젝트 RATE 표에서 역추적해 좌표 확정
  산재 RATE_R[45,1] · 건강 [46,1] · 연금 [46,6] · 장기요양 [46,11]
  고용보험 등급1~7 [45,11..17] · 산안비 [20,17] 율 [20,18] 기초액
  간접노무비 [10,1]
- 5개 연도 시계열이 실제 법정요율 변천과 일치해 A등급
- RATE_R 행 45~49 가 부속표 영역, 행 0~44 가 규모·기간 행렬임을 가름

판정 — 기초단가 원천(자재 5.7만건·노임·중기·표준시장단가)이 전부 암호화라
STmate 복제는 불가. 계산 엔진은 구현 가능하고 Aislo 목적에는 충분함.
문서 15 신규, 검증 스크립트 5개 추가.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 12:08:44 +09:00
eomsangdon 7bd6d10145 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-13 11:33:05 +09:00
eomsangdonandClaude Opus 5 1230961c54 chore(auth): 개발 단계 이메일 인증 생략 스위치 추가
개발 중 로그인마다 인증 코드를 기다리던 것을 끄는 스위치.
AUTH_OTP_DISABLED 가 true 이고 ENVIRONMENT 가 development 일 때만 듣는다 —
운영에서는 켜져 있어도 무시된다.

- 로그인: 새 브라우저·주기 재인증 둘 다 코드 없이 통과, 생략마다 경고 로그
- 회원가입: 흐름은 그대로 두고 인증 코드를 서버 로그에 기록
- .env 에 AUTH_OTP_DISABLED=True (배포 전 False 로 되돌릴 것)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJC56e4osweKJ4vafm9ReM
2026-09-13 11:32:22 +09:00
eomsangdonandClaude Opus 5 62dc2d744c feat(b01): 회사 주소를 검색해서 고르는 칸으로 교체
손으로 정확히 쳐야만 되던 주소칸을 후보 검색·선택 방식으로 바꿈.
VWorld 주소검색을 서버가 중계하고, 고른 즉시 좌표를 알므로 지도를 다시
찾지 않고 그림. 못 찾는 주소는 「직접 입력」으로 옛 방식을 씀.

- 회사 목록 SQL 이 business_address·business_owner 를 안 가져와 수정 모달이
  비어 열리고 저장 시 빈 값으로 덮어쓰던 것 수정
- 지도 기본 배율 15 → 17, +·- 단계 조절 추가
- 라벨·입력·버튼을 한 줄에 눕혀 줄 어긋남 해소
- 모달 안 휠이 뒤 대시보드로 새던 것 차단
- 회사 패널에 주소 한 줄 표시

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TJC56e4osweKJ4vafm9ReM
2026-09-13 11:32:13 +09:00
eomsangdon fe28b2844f Merge remote-tracking branch 'origin/CODEX' into sub_desktop_1 2026-09-13 11:26:33 +09:00
eomsangdonandClaude Opus 5 38cd3003b4 knowledge(경쟁사): scratch 중복 사본 정리 — 정식 자리로 옮긴 뒤 남은 것
자동푸시가 scratch/orca_artifacts 사본까지 함께 커밋한 것을 걷어냄.
같은 내용이 resources/knowledge/original/경쟁사 프로그램/STmate 분석/ 에
바이트 동일로 들어가 있음(41개 동일, 2개는 경로만 고친 스크립트).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 11:25:24 +09:00
eomsangdon abd65d1662 auto: 2026-09-13 11:24 (EOMSANGDON-HOME) 2026-09-13 11:24:52 +09:00
eomsangdonandClaude Opus 5 91b2ddea86 knowledge(경쟁사): STmate 프로그램 정적분석 인계 — 문서 15·원자료·재현 스크립트
코덱스가 토큰 한도로 중지한 STmate 분석을 이어받아 마무리한 것.
scratch/orca_artifacts 에 있던 산출물을 지식DB 정식 자리로 옮김.

이번에 새로 확정한 것
- 내부 반올림이 은행가 반올림(최근접-짝수)임 — SimpleRoundTo·Ceil·SetRoundMode
  전부 미임포트, 반올림 함수 본체에 부호 처리 없음. 출력 XLSX 의 Excel 사사오입과
  20개 경계 중 10개가 원 단위에서 갈림
- 간접비 항목별 밑수를 RATE 화면 그리드 묶음 제목에서 확정.
  환경보전비 밑수가 직접공사비가 아니라 재료비+직접노무비+산출경비임을 정정
- JB_R 35열 확장 시점이 2020-05-27 이고 기존 1~20열은 한 칸도 안 바뀜
- 원가계산 설정 선택축 83개 전수. 화면 순번과 내부값이 뒤집힌 축 둘 확인
- 문서 13(통제시험 T1~T11)·14(최종 완료 보고서) 신규

원본 C:\경영정보(주) 는 읽기만 함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Prk9BCHG1EMAywk9k8wegA
2026-09-13 11:24:43 +09:00
eomsangdon 8e118bcd60 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-13 10:06:55 +09:00
eomsangdon 913923a0da fix(b07): 표준도 장 나눔에서 앞뒤 걸침 칸을 뺌 — 제원이 같으면 한 장
before_m·after_m(구간 규약)과 관 부속의 앞뒤 걸침 칸이 장 나눔 키에 들어가, 제원이 같은 구조물이 자리만 달라도 장이 갈리던 것 바로잡음. 랩탑 보조 실측 제보.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-13 10:06:52 +09:00
eomsangdonandClaude Opus 5 cfa7da2069 fix(b05): 비워 두는 것이 뜻인 칸에 빈 보기를 둠 — 첫 보기가 박히던 것
구조물 폼 select 는 「기본값 없는 필수」만 빈 보기를 두어, `empty_means` 가 적힌 칸도
첫 보기가 골라진 채 저장됐다. `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준값
210(확정 ⑩)과 갈렸다 — 채움콘크리트 규격·단가가 조용히 달라지는 자리.

빈 보기 판정을 「필수 + empty_means」 한 줄(`keepEmpty`)로 합치고 초기값도 같은 판정을 씀.
기본값이 있는 칸은 그대로 첫 보기를 쓴다(2026-08-17 지시 유지).
클라이언트 옵션 타입에 empty_means 를 더함 — 서버는 이미 실어 보내고 있었다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-13 10:04:52 +09:00
eomsangdon 54ac88a90b Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-13 09:53:55 +09:00
eomsangdon 9417281064 chore(env): 개발 단계 로그인 OTP 건너뛰기 설정 추가 — 2026-09-13 사용자 지시
다른 창이 넣은 네 줄을 사용자 확인 후 커밋.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-13 09:53:51 +09:00
eomsangdonandClaude Opus 5 af139d65cd feat(b07): 버림 콘크리트 끄는 칸 · 물빼기 주석 구조물 값 · 표준도 [여기서 고치기]
확정 8-2·14 — 버림 콘크리트 「넣음/안 넣음」 칸을 등록부(돌쌓기 찰·메·큰돌쌓기·기슭막이)와
표준도 입력(EDITABLE_KEYS)·제원 폼에 세움. 기본은 넣음(KDS 44 90 00 100㎜).
셈은 이미 wants_blinding 으로 서 있었고 끄는 자리만 없던 것임.

확정 5차 작은 것 2 — 표준도 그림 물빼기 주석이 기본 상수를 적던 것을
구조물 제원(weep_hole_*)에서 읽게 바꿈. 수량표와 같은 함수(weep_hole_spec)를 씀.

확정 6 — 표준도 장에서만 뜨는 [여기서 고치기] 단추를 고정 액션 줄에 둠.
표는 CAD 안이라 단추를 못 달고, 제원 폼은 도면 목록 아래라 밀림.
값 복사본은 만들지 않음 — 폼 한 벌이 그대로 정본 창구임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-13 09:49:17 +09:00
eomsangdonandClaude Opus 5 bfbd62b6da test(b07): 표준도 하단표에 날개벽·집수정·옹벽 칸이 서는지 굳힘
계획서 4-13 「칸만 오면 됨」 확인 — 관측 원단위가 든 종류는 하단표가 이미 섬.
날개벽 개소당 6줄 · 집수정 개소당 5줄 · 옹벽 반중력식 H=2.0 8줄.
식생옹벽블럭은 자료가 없어 줄 0개 + 사유만 — 값을 지어내지 않는 것을 함께 굳힘.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-13 09:28:41 +09:00
eomsangdon 5771cf1ef7 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-13 09:23:50 +09:00
eomsangdonandClaude Opus 5 8613c3b66e refactor(b06): 횡단 페이지 853줄을 셋으로 나눔 — 설계 재계산·계획선 편집 분리
700줄 한도 대응. 순수 이동으로 동작 불변.
- B06_Section_UI_Page_Design_Sync.ts — 설계 선택 반영·낡음 재계산·소단 동기화·[전체 반영]
- B06_Section_UI_Page_Grade_Edit.ts — 종단 계획선 ▲▼ 제공자
- 남은 B06_Section_UI_Page.ts 644줄

소스 문자열 감시 시험 셋이 분리 모듈까지 읽게 함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-13 09:23:38 +09:00
eomsangdon 5e09a86104 test(b09): 준비공 줄이 인계에서 내역서까지 안 새는지 잠금 — V-12
B08 인계의 준비공·부대시설 줄이 B09 에서 내역 본체 또는 제외 줄로 반드시 서는지, 표토제거 2,359.035㎥ 가 수량째 본체에 서는지 마주 걸어 확인.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-13 09:13:13 +09:00
eomsangdon 2e6cfe5d3b fix(b08): 돌 무게 설명 두 곳을 확정 5차 7번에 맞춤 — 값 불변
stone_kind 표의 「사용자 확정 대기」 문구를 확정된 계산식 기준으로 고침. 관측표 종류 주석이 코드(안 고르면 계산식)와 반대로 적혀 있던 것 바로잡음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-13 09:11:26 +09:00
eomsangdon b5f7f3c00c feat(b09): 야면석 채집 「인 부」를 보통인부로 준용 — 확정 8-1
노임표에 없는 직종명을 유사 직종으로 바꿔 읽는 자리를 공종 단위로 둠(FP-13-02-04 한 곳). 산출기초에 준용 근거(재경원 회계 45101-45) 표시. 뒷길이 다섯 × 밑수 둘, 일위대가 10벌이 새로 섬.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-13 09:08:10 +09:00
eomsangdon 4209c5c2cf Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-13 08:50:24 +09:00
eomsangdon 2ab4c90288 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-12 21:53:18 +09:00
eomsangdonandClaude Opus 5 1fafe2548c fix(b05): 곡선 길이(L) 하한도 지키게 — 내각 155° 이상은 예외
R 하한만 막고 L 하한은 칸 입력에서만 막던 것을 고침. L = R·Δ 라 한 자리의 하한을
반지름 하나로 모아 봄(`radiusFloorM` = max(R 하한, L 하한/Δ)) — 값 끌어올리기·
모자란 양 판정·칸 막기가 모두 이 값을 씀.

내각 155° 이상은 별표2 Ⅰ.2.다.(1) 상 곡선을 안 둬도 되는 자리라 L 하한을 안 걺 —
걸면 교각이 0 에 가까워 R 이 수십 배로 부풀어 안 고친 구간이 휨. 그 자리는 곡선
패널에 「155° 이상 — 곡선 생략 가능」으로 적어 냄.

`deflectionRad` 를 화면 파일(`_Label`)에서 셈 파일(`_Edits`)로 옮김 — 순수 함수가
DOM 파일에 얹혀 있어 시험이 화면째 들여와야 했음(부르던 자리는 재수출로 지킴).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-12 21:48:39 +09:00
eomsangdonandClaude Opus 5 abe956affc feat(b05): 계획노선 편집 손질 여섯 — 교각점 고정·횡단 벡터화·라벨 맨 위
- 교각점 고정 단추: 켜면 그 꺾임점이 노드·손잡이 어느 쪽으로도 안 끌림(`apexLock`)
- 계획 횡단선을 절·성토 교차점에서 끊음 — 그 밖은 원지반이라 지반선과 겹쳐 그려지던 것.
  교차점은 B06 이 쓰는 `toeOffsets` 를 열어 함께 씀
- 횡단 판을 캔버스에서 SVG 로 바꾸고 휠 줌·가운데 끌기 팬·더블클릭 원복을 붙임
- 줌·팬 본문을 `A00_Common/b_svg_zoom_pan.ts` 공용 조각으로 빼고 B06 은 감싸개만 남김
- 「이전 횡단」 판은 계획 평면선이 바뀔 때만 채움(같은 측점 두 번 누르기로는 안 채움)
- 「거리 재기」 단추의 눌린 꼴 추가 · 등고선 높이 라벨을 맨 위로 올리고 더 촘촘히

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DJffo4VwgTumVUM3kdbJzd
2026-09-12 20:37:13 +09:00
eomsangdonandClaude Opus 5 9da48d5e7f perf(b06): 계획선 ▲▼ 를 누른 즉시 반응 — 측점 수만큼 다시 그리던 것 제거
- 재계산 결과 반영을 `refreshCards` 로 묶음. 측점마다 `refreshCard` 를 부르면
  그때마다 종단 그래프·유토곡선까지 다시 그려, 62 측점 기준 한 번 누를 때
  종단도를 62 번 다시 그림.
- ▲▼ 는 계획선(`design_profiles`)만 먼저 갈아 끼우고 다시 그림 — 선이 즉시 움직임.
- 전 측점 횡단 재계산은 120ms 디바운스로 미룸(B05 프리뷰와 같은 규칙) — 길게
  누르는 동안은 선만 따라오고, 손을 뗀 뒤 한 번만 돎.

실측(62 측점, 같은 노선·같은 조작)
- 한 번 누름: 화면 갱신 920~955ms → 17~23ms · 최장 멈춤 879~971ms → 180~229ms
- 1.5초 길게 누름: 멈춤 954ms×3회·누적 2,577ms → 최장 213ms·누적 473ms,
  적용 단수 3 단 → 10 단(제 속도)
- 값 동일성: 누가토량 배지·카드 3장·계획고 라벨·세션 초안 전부 변경 전과 일치
- `npm run typecheck` 통과 · `pytest resources/tester/` 1,323 passed / 22 skipped

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 20:34:26 +09:00
eomsangdon ae5dab661e Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-12 19:55:33 +09:00
eomsangdon 5702ef05c8 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-12 19:54:24 +09:00
eomsangdon 6455f55d6d Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-12 19:09:20 +09:00
eomsangdon 2706e4148f Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1 2026-09-12 19:08:50 +09:00
eomsangdon 5a3a3cd0e5 auto: 2026-09-12 19:08 (EOMSANGDON-HOME) 2026-09-12 19:08:50 +09:00
eomsangdon f206070fd7 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-12 19:08:48 +09:00
eomsangdon 3fc19dbfc2 auto: 2026-09-12 19:07 (ESD_LAPTOP) 2026-09-12 19:07:36 +09:00
eomsangdonandClaude Opus 5 3518f346e4 fix(b06): 계획고를 누르는 동안 세로 창을 고정 — 선이 안 움직여 보이던 원인
B05 에는 있고 B06 에 없던 장치였음. ▲▼ 를 누를 때마다 세로 창이 새 계획선에 맞춰 다시
잡혀, 값은 바뀌는데 선은 제자리에 있는 것처럼 보였음. B05 와 같이 누르고 있는 동안
창을 고정하고 손을 뗀 뒤 한 번만 다시 맞춤.

실측(랩탑_보조 534.0m 측점): 계획고 837.24 → 839.84m 일 때 설계선이 3.2px 올라감.
종전에는 같은 조작에서 0.4px 만 움직였음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 19:07:30 +09:00
eomsangdon 38da942127 Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_laptop_1' into main_laptop_1 2026-09-12 18:58:13 +09:00
eomsangdon 0023e33349 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-12 18:58:12 +09:00
eomsangdon 788c6a95a6 auto: 2026-09-12 18:58 (ESD_LAPTOP) 2026-09-12 18:58:10 +09:00
eomsangdonandClaude Opus 5 83fdbe912d fix(b06): 계획선 ▼ 버튼이 알약 레인 위로 밀려나던 것을 그래프 안으로
편집 버튼층은 `inset: 0` 으로 부모를 꽉 채우는데, B05 는 부모가 그래프뿐이지만 B06 은
구조물 알약 레인도 같은 칸 안에 있어 ▼ 가 레인 위로 내려앉았음(실측: 그래프 바닥 231px
인데 버튼이 270~287px). 버튼층 높이를 그래프 높이로 잘라 B05 와 같은 자리에 세움
(실측 후 ▲ 72~89px · ▼ 212~229px, 그래프 70~231px 안).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 18:57:40 +09:00
eomsangdonandClaude Opus 5 3bd5ed2636 fix(B08): 등급색 토글을 탭 줄 끝으로 옮김
만들자마자 탭 줄에 붙이는 바람에 **탭 단추들보다 먼저** 서서, 「토적표」 앞에 탭이 하나 더
있는 것처럼 보였음(2026-09-12 실측: 토글 x=8 · 토적표 x=84). 표를 고르는 단추가 아니라
보기 방식을 바꾸는 단추라 섞이면 안 됨.

만드는 자리는 그대로 두고 **붙이는 것만 탭 단추를 다 세운 뒤로** 미룸.

자체검증(ORCA 5173, 로그인한 실제 화면) — 탭 줄 차례가
토적표(8) · 토공집계(74) · 운반거리(152) · 준비공·사방공(230) · 구조물 원단위(336) ·
자재총괄(441) · **등급색 토글(519)** 로 맨 끝에 섬. 토글을 켜면 칸 왼쪽 3px 띠와 옅은
배경이 붙고(측량 372칸 · 계산 868칸), 다시 누르면 사라짐. `tsc --noEmit` 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 18:56:14 +09:00
eomsangdon bd0753dbe5 docs(plan): archive remaining checked items 2026-09-12 18:54:38 +09:00
eomsangdon 90a73b8ceb Merge remote-tracking branches 'origin/CODEX' and 'origin/sub_desktop_1' into main_desktop_1 2026-09-12 18:52:13 +09:00
eomsangdon 2ac3d1d3bf docs(wiki): ingest completed plan items 2026-09-12 18:51:06 +09:00
eomsangdonandClaude Opus 5 25c523b72c fix(dev): 윈도우 리로드가 영원히 멈추던 uvicorn 경로를 terminate() 로 갈아 끼움
uvicorn 0.24 BaseReload.restart() 는 윈도우에서만 옛 워커를
os.kill(worker_pid, CTRL_C_EVENT) 로 내리려 함. CTRL_C_EVENT 는 프로세스 그룹을
지정해 보낼 수 없어(MSDN: 그룹 id 가 0 이 아니면 호출은 성공하지만 신호는 전달되지
않음) 워커에 아무것도 안 감. 바로 다음 join() 이 영원히 안 풀려 옛 워커가 살아서 옛
코드로 계속 응답하고, 새 워커는 태어나지 않으며, 그 뒤 파일 변경은 감지조차 안 됨.

실측(그 자리를 흉내 낸 부모·자식 한 쌍):
  CTRL_C_EVENT   → 8초 안에 안 풀림 (DETACHED · CREATE_NO_WINDOW · +NEWGROUP 모두)
  terminate()    → 0.00초
  CTRL_BREAK     → 0.00초 (자식을 그룹 우두머리로 띄웠을 때)

고침은 uvicorn 자신의 리눅스 경로를 그대로 씀. DEBUG + win32 개발 기동에서만 갈아
끼우고, uvicorn 이 위쪽에서 고치면 _patch_windows_reload 를 지우면 됨.

⚠ 앞선 두 커밋(빌드 고리·종료 시한)은 이번 멈춤의 원인이 아니었음. 빌드 고리는 실제
문제였고, 종료 시한은 다른 경우를 막는 값이 있어 남김. [boot] 표식이 이 진단을
가능하게 했으므로 함께 남김.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 18:47:49 +09:00
eomsangdon 9a9062f05c Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1 2026-09-12 18:42:45 +09:00
eomsangdonandClaude Opus 5 521e792a59 feat(b06): 종단 계획선 편집(▲/▼)을 B06 에도 붙임
B05·B06 종단을 같은 템플릿으로 맞추면서 **계획선 편집 버튼층만 빠져 있었음** — B06 에는
편집분을 얹어 계획선·전 측점 횡단을 다시 푸는 코드도, 편집 초안 세션도 이미 있었는데
누를 버튼이 없었음.

B05 와 같은 부품(createEditOverlay)을 종단 그래프에 얹고, 누른 값은 B05 와 같은 세션
초안에 쌓음. 누르는 즉시 계획선·횡단을 다시 풀어 카드에 반영함.

저장도 이었음 — 종전에는 B06 이 초안을 읽기만 해서, B06 에서 [저장]·[확정]하면 계획선
편집이 정본에 안 남고 다음 진입 때 사라졌음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 18:36:22 +09:00
eomsangdonandClaude Opus 5 57972659dd fix(dev): 종료가 멈추지 않게 시한을 두고, 리로드 멈춤 자리를 로그로 가름
- 백그라운드 태스크 셋을 cancel() 만 하고 지나가던 것을 gather 로 실제로 거둠
- 「백그라운드 작업 정리」·「DB 풀 종료」 두 걸음에 각 5초 시한. 못 끝내면 경고만
  남기고 넘어감 — 종료는 기다리는 곳이 아니라 끝내는 곳이고, 남은 정리는 OS 몫
- 끝에 「앱 종료 끝」을 찍어 종료가 끝까지 갔는지 로그로 갈림
- 모듈 맨 위에 [boot] 워커 프로세스 시작 pid 를 찍음. 리로드 뒤 이 줄이 찍히면
  새 워커는 뜬 것(포트를 못 잡은 자리), 안 찍히면 아예 시작도 못 한 것(리로더 자리).
  고칠 자리가 전혀 다른데 종전 로그로는 못 가렸음

⚠ 이번 멈춤의 원인이라는 증거는 아직 없음 — 로그에 「앱 종료 중」이 안 찍혀 종료
코드까지 가지도 못했을 수 있음. 위 [boot] 표식이 다음 멈춤 한 번으로 그것을 가름.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 18:27:07 +09:00
eomsangdon 2af795f5a4 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-12 18:19:12 +09:00
eomsangdonandClaude Opus 5 f952ac7ffd fix(git): 병합이 떨군 파일 22개와 되돌아간 파일 35개를 되살림
무슨 일이 있었나
랩탑 줄의 병합 `20ba886c`(Merge origin/main_desktop_1·main_laptop_1·sub_desktop_1 into
sub_laptop_1)가 우리 파일 22개를 떨구고 35개 파일의 내용을 옛것으로 되돌림. 손으로 지운
커밋은 없고 **병합 자체가 떨군 것**임. 그것이 `origin/dev`·`main_laptop_1`·`sub_laptop_1`·
`CODEX` 까지 퍼졌고(데스크탑 둘만 무사), 이 창의 병합 `d92c1f2b` 로 들어옴.

잃었던 것
- 공용 — `common_util_provenance.py` · `ui_template_provenance.ts`
- B08 — 근거 사전 · 좌측 패널 상자 모듈 · 토량환산계수 칸
- B09 — 근거 사전 셋
- B05 — 계획노선 편집 모듈 아홉 · 지형 라우터 · B04 지도 모듈
- 시험 셋과, 35개 파일 안의 최근 작업(환산계수 고르기 · 근거 호버 배선 등)

어떻게 되살렸나
`611a2b40`(병합 직전, 전부 온전)에서 `git show <커밋>:<경로>` 로 내용만 꺼내 되돌림.
이력은 안 건드림. ⚠ HEAD 에만 있던 「추가 816줄」은 랩탑의 새 작업이 아니라 **되살아난
옛 코드**였음(B05 편집은 모듈로 쪼개기 전 덩어리 · B08 라우터는 환산계수 고르기 전 옛
상수판). 되돌릴 시점 이후의 **진짜 새 커밋은 둘뿐**이라 그 둘만 패치로 다시 얹음 —
`9f827bf6`(리로드 빌드 고리 끊기, 데스크탑 보조) · `b9bca6b3`(B06 조정창 1px, 랩탑).
위키 여덟은 코덱스 몫이라 손대지 않음.

자체검증 — 양쪽 작업이 다 살아 있음을 짚어 확인: `main.py` 의 「개발 서버는 살려 둔다」 ·
`B05_Profile_Engine_Grade.py` 의 `plan_curve_length_limit_m` · `B08_..._EarthworkGrid.ts` 의
`attachProvenance`. `tsc --noEmit` 통과 · `pytest -q` **1317 passed, 28 skipped**
(되살리기 전에는 시험 둘이 수집 단계에서 깨져 있었음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 18:18:57 +09:00
eomsangdon 642b0ac18f Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-12 16:57:53 +09:00
eomsangdon 2940a8bff0 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-12 16:57:53 +09:00
eomsangdon 57c1ce77c1 auto: 2026-09-12 16:57 (ESD_LAPTOP) 2026-09-12 16:57:48 +09:00
eomsangdonandClaude Opus 5 333df15e2b refactor(b06): 종단 화면 본체를 700줄 아래로 — 그리는 몫을 따로 뺌
`B06_Section_UI_Section_View.ts` 가 817줄로 한도를 넘겼음. 종단 그래프·구조물 알약 레인·
우클릭 메뉴·누가토량 배지·고정 Y축을 세우는 몫과 가로 스크롤 세로창 배선을
`B06_Section_UI_Section_View_Draw.ts` 로 옮겨 693줄로 줄임.

높이 재기·되먹임 보정·선택 상태는 본체가 그대로 쥐고, 옮긴 쪽은 넘겨받은 인자만 쓰므로
화면 상태가 두 곳에 남지 않음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 16:46:37 +09:00
eomsangdon 216d027e9c Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1 2026-09-12 16:42:16 +09:00
eomsangdon d92c1f2b23 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-12 16:42:13 +09:00
eomsangdonandClaude Opus 5 9f827bf647 fix(dev): 리로드마다 프론트를 다시 빌드하던 고리를 끊음
- 개발 중에는 앱이 내려갈 때 Vite 를 죽이지 않음. Vite 는 한 앱 프로세스가 아니라
  개발 세션의 것이고, 다음 기동이 그대로 물려씀
- Vite 가 이미 그 포트에 살아 있으면 npm run build 를 건너뜀(첫 기동에서만 빌드)

까닭 — uvicorn 리로더가 *.py 마다 앱 프로세스를 갈아 끼우는데, 옛 프로세스가 종료하며
Vite 를 죽이면 새 프로세스가 「Vite 없음」으로 보고 npm run build(최대 300초 + B07 CAD)를
처음부터 다시 돌렸음. 그 사이 /api/health 가 stale: true 인 채 옛 코드를 계속 내주어
옛 화면을 보고 헛검증하게 됨. 2026-09-12 에 두 번 멈췄고 한 번은 두 시간 반 동안 안 끝남.

배포 환경은 그대로 — 빌드도 종료 정리도 종전과 같음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 16:41:20 +09:00
eomsangdon 611a2b407b auto: 2026-09-12 16:39 (EOMSANGDON-HOME) 2026-09-12 16:39:48 +09:00
eomsangdon 15085c2011 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-12 16:38:40 +09:00
eomsangdonandClaude Opus 5 b9bca6b332 fix(b06): 조정창이 면적표에 1px 닿던 것을 띄움
화면 실측에서 조정창 윗변이 면적표 아랫변과 1px 겹쳤음. 표 높이 기준값을 3.2rem 에서
3.6rem 으로 올려 6px 띄움.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 16:35:01 +09:00
eomsangdon 20ba886c86 Merge remote-tracking branches 'origin/main_desktop_1', 'origin/main_laptop_1' and 'origin/sub_desktop_1' into sub_laptop_1 2026-09-12 16:19:20 +09:00
eomsangdon 882d7ebaf3 Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/sub_laptop_1' into sub_desktop_1 2026-09-12 16:19:07 +09:00
eomsangdon 87da31df0a Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-12 16:19:05 +09:00
eomsangdonandClaude Opus 5 e192742384 feat(B08): 남은 표 다섯 장에 근거 사전과 등급색 배선 — 8-36 ⑥ B08 몫 닫음
토적표만 서 있던 근거 호버를 토공집계·운반·준비공·자재총괄·구조물 원단위까지 넓힘.
사전 여섯 장 48열이 됨(토적표 20 · 집계 5 · 운반 5 · 준비공 5 · 자재 7 · 원단위 6).

같은 열이라도 줄마다 성격이 갈리는 자리를 **칸 등급**으로 갈랐음
- 토공집계 「계」 — 무대(소운반 20m) 줄은 `final` 이 아니라 `excluded`. 집계에는
  오르되 내역 줄이 아님(품셈 1-2-7). 채우면 이중계상이라 「못 세움」과 뜻이 정반대임
- 운반표 — 같은 줄의 토량·거리 둘 다 `excluded`. 값은 검산에만 씀
- 준비공 「수량」 — 값을 못 세운 줄은 `blocked`. 근거가 오면 채워질 자리라 제외와 갈림

**B08 에서 `final`(최종)이 처음 서는 자리는 토공집계표 「계」** 임. 토적표는 중간
장부라 최종 열이 하나도 없음 — 그 갈림을 시험으로 박았음(등급 여섯은 한 장이 아니라
여러 장을 합쳐야 다 쓰임).

자체검증 — 시험 4개 추가(모두 12개). 집계표·운반표 사전 열 이름이 실제 엔진 줄에
있는지 대조하고(운반표 `average_distance_m` 은 필드가 아니라 property 라 따로 봄),
등급·이름·식 검사를 여섯 장 전부로 넓힘. `pytest -q` **1317 passed** · `tsc` 통과.
브라우저(ORCA 5173, 실제 사전을 물려 다섯 표를 그림) — 집계 정상줄 `final` /
무대줄 `excluded` · 운반 정상 `calc` / 무대 `excluded` · 준비공 정상 `calc` /
값 없는 줄 `blocked` · 자재 총수량 `final` · 관급사급 `input` · 원단위 수량 `calc`.
카드에 식·원천·채택·자리가 다 뜸.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 16:18:20 +09:00
eomsangdonandClaude Opus 5 b1ba37b004 refactor(b06): 관 목록을 담는 자리를 한 벌로 합침
B05 는 관 목록 전체를 세션 스냅샷으로, B06 은 바뀐 것만 따로 담고 있어 담는 꼴이 둘이었음.
저장 순서를 맞춰 결과는 같게 했으나 자리가 둘인 한 같은 사고가 다시 남.

B06 의 추가·삭제·이동을 모두 B05 가 쓰는 스냅샷(pipes)에 쓰도록 바꾸고, B06 전용 예약
경로(culvertedit 세션·queueAdd·queueRemove·queueMove)를 걷어 냄. 옛 세션에 남아 있던
이동 예약만 저장 때 한 번 흘려보내게 읽기를 남김.

이제 관을 넣고 빼고 옮기는 길이 좌측 폼·목록·종단 우클릭·측점선 끌기 모두 한 함수이고,
담기는 자리도 한 곳임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 16:16:19 +09:00
eomsangdonandClaude Opus 5 aadb4f6756 feat(B09): 근거 사전 네 장 추가·칸 단위 최종 등급·사전 파일 셋으로 분리
- 자재단가대비표: 적용 칸을 calc+rule(기.가·유.물·견적), 값 없는 원천 칸은 blocked
- 환율및기초자료: 공표·공시값 standard 와 사용자가 고른 유가 범위 input 을 가름
- 단가산출근거·산출기초: 모으는 표라 식을 비우고 source·등급만 채움
- 설계서 구성표·산출 조건 패널은 일부러 뺌(프로젝트 값이 아니거나 근거 한 줄과 중복)
- 원가계산서 금액은 총원가·도급금액·총계 세 줄에만 칸 단위 final 을 심음
- 700줄을 넘어 Provenance / _Sources / _Common 셋으로 가름(공통 조각을 한쪽에 두면
  불러들이기가 고리를 이룸)

사전 16장 92열, 등급 여덟이 모두 쓰임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 16:15:03 +09:00
eomsangdon 93cdf005ca Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-12 16:05:38 +09:00
eomsangdon 788270a498 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-12 16:05:37 +09:00
eomsangdonandClaude Opus 5 1953ea9dc8 feat(B05): 횡단을 오른쪽 붙박이 두 판으로, 전후 비교와 손질 열셋
- 메인 창을 왼쪽으로, 오른쪽 세로 칸에 「횡단」·「이전 횡단」 두 판 (계획서 0-9 ⑱)
- 노드를 놓으면 보던 측점을 다시 셈해 새것은 위, 옛것은 아래로 (⑲)
- 측점 표기를 구조물 목록과 같은 규칙으로, 긴 안내문 삭제 (⑳㉑)
- 좌하단에 예상노선·계획노선 길이 둘 (㉒)
- [거리 재기] 단추와 구분선, 잰 값은 작은 창에 내고 닫으면 지움 (㉓㉔)
- 잰 구간을 누가거리로 잘라 꼬임 해소 (㉕)
- 곡선 패널 닫기 단추와 빈 곳 누르기 해제, 하단 정보는 내각만 (㉖㉗)
- 고른 등고선에 높이 라벨, 회전 아이콘을 반원 화살표로 (㉘㉙)
- 돌려도 글자는 바로 세움 — `UPRIGHT_LABELS` 한 값으로 되돌릴 수 있음 (㉚)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
2026-09-12 16:04:04 +09:00
eomsangdonandClaude Opus 5 cb5ea7ede9 fix(b06): 관 편집이 어느 페이지에서 저장해도 같은 결과가 되게 맞춤
세 자리가 어긋나 있었음.
- B06 저장이 델타를 먼저 내보내고 B05 스냅샷을 나중에 써서, 두 화면을 오가며 만지면
  스냅샷이 B06 편집을 통째로 덮었음. B05 [임시저장]과 같은 순서(스냅샷 먼저, 델타 나중)로 바꿈.
- B05 저장이 구간값 키만 넘겨, B06 에서 예약한 관 이동·추가·삭제가 조용히 빠졌음. 키 셋을 다 넘김.
- B06 좌측 목록이 정본만 읽어 B05 에서 넣고 안 저장한 관이 안 보였음. 구조물과 같은 규칙으로
  미저장분을 먼저 씀.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 16:02:45 +09:00
eomsangdon 7068f59dff Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-12 16:02:13 +09:00
eomsangdonandClaude Opus 5 c94e77734e fix(공용): 근거 호버 배지를 칸 등급으로 그리고 빠진 등급은 열에서 채움
같은 열이라도 줄마다 성격이 갈리는 자리가 있음 — 원가계산서 「금액」은 중간줄
(간접노무비 따위)이 계산인데 마지막줄(총원가·도급금액·총계)은 최종임(데스크탑 보조
B09 배선에서 나옴). `markProvenanceCell` 은 이미 칸마다 등급을 받고 있었으나
카드 배지가 열 등급으로만 그려져 **띠는 최종인데 배지는 「계산」**으로 어긋났음.

- `fill()` 이 칸을 받아 배지를 `cell.dataset.provTier || column.tier` 로 그림
- 등급을 안 심은 칸은 `attachProvenance` 가 열 등급으로 채움 — 안 그러면 배지는
  뜨는데 띠가 안 붙는 「색 없는 칸」이 생김. 부르는 쪽이 등급을 빼먹기 쉬운 자리라
  여기서 맞춤. 줄마다 다른 칸에만 세 번째 인자를 주면 됨.

자체검증(ORCA 5173) — 같은 열 `amount_krw` 에서 등급 안 심은 칸은 배지 「계산」·띠
초록 rgb(31,138,76), `final` 심은 칸은 배지 「최종」·띠 금색 rgb(184,134,11) 3px inset.
배지와 띠가 같은 말을 함. `tsc --noEmit` 통과 · 병합 뒤 `pytest -q` 1313 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 16:00:19 +09:00
eomsangdon 6d1ef43491 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-12 15:55:07 +09:00
eomsangdonandClaude Opus 5 ce9a83a655 feat(b06): 종단 측점선·구조물 알약을 끌어 옮길 수 있게 함
B05 에서만 되던 끌어 옮기기를 B06 종단에도 붙임 — 측점선과 알약 어느 쪽을 끌어도 같은
길을 타고, 관은 예약 이동(저장·확정 때 정본과 배수유역 재분할), 구조물은 정본 이동임.

끌기 판별과 대상 찾기는 새 모듈(B06_Section_UI_Section_View_Menu)로 모아, 종단 화면
본체가 더 커지지 않게 둠.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 15:54:08 +09:00
eomsangdon de3f95b532 Merge remote-tracking branches 'origin/sub_desktop_1' and 'origin/sub_laptop_1' into main_desktop_1 2026-09-12 15:52:04 +09:00
eomsangdonandClaude Opus 5 d1a2508053 feat(B09): 칸 단위 근거 호버·등급색 배선 + 줄 사유 덮어쓰기 버그 수정
① 버그 — BillRow.note 한 칸에 덮어쓰던 13곳·이어 붙이던 7곳을 조각 배열 notes 로
   옮김. 한 줄에 사유가 둘이면 하나가 조용히 사라지던 것이 멈춤. 조각마다 닿는 열
   키를 함께 실어 호버가 그 칸에만 띄우게 함. 화면 비고 칸은 종전과 같은 꼴 유지.
② 사전 — B09_Estimation_Provenance.py 신설(기존 B09 파일이 700줄 초과). 8장 57열.
   이미 있던 formula_text·source_label·base_label 은 그대로 쓰고 없는 것만 새로 적음.
③ 배선 — 라우터 5개 응답에 _with_provenance(), 표 여섯에 markProvenanceCell +
   attachProvenance. 개발환경이 아니면 응답에 칸 자체가 안 생김(로직 보안).
④ 등급색 토글을 탭 줄 끝에. 사전이 안 오면 단추도 안 세움.

⚠ 원가계산서 금액은 같은 열에서 중간줄과 마지막줄의 성격이 갈리는데 등급이 열에
   하나뿐이라 calc 로 두고 rule 에 어느 줄이 final 인지 적음(PLAN 8-36 ① 에 남김).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 15:50:55 +09:00
eomsangdon fd3b38f846 auto: 2026-09-12 15:50 (ESD_LAPTOP) 2026-09-12 15:50:07 +09:00
eomsangdonandClaude Opus 5 11161659f5 feat(b06): 종단 그래프 우클릭으로 구조물·계곡 통과 시설을 넣고 빼게 함
B05 와 B06 은 한 페이지인데 구조물을 넣고 빼는 자리가 B05 뿐이라 화면을 오가야 했음.
종단 그래프 렌더러·알약 레인·좌측 배치 폼은 이미 공용이었으므로, 빠져 있던 조작층만
B05 와 같은 부품(mountStructureMenu)으로 B06 에도 붙임.

관 추가·삭제는 이동과 같은 예약 경로를 씀 — 세션에 쌓고 [저장]·[확정]에서 정본
(pipe_points.json)으로 나가며, 세부 배수유역 재분할은 그때 서버가 처리함. 넣었다 바로
빼거나 뺐다 다시 넣으면 예약만 걷어 내 정본이 흔들리지 않음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 15:50:02 +09:00
eomsangdon 3e93360285 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-12 15:24:45 +09:00
eomsangdon c308348b9a auto: 2026-09-12 15:23 (EOMSANGDON-HOME) 2026-09-12 15:23:51 +09:00
eomsangdonandClaude Opus 5 6f122d79e3 docs(B09): 줄 사유가 닿는 열 지도 추가 (㉮ 조각 배열 배선용)
- 내역서 note 가 이어 붙이는 조각 20개를 임자 열(규격·수량·단가·금액·줄전체)로 가름
- 덮어쓰는 자리가 더 많아 사유 둘이 겹치면 하나가 사라지는 것을 드러냄
- 화면 비고 칸은 그대로 두고 조각 배열은 호버 카드용으로 따로 실을 것

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 15:17:37 +09:00
eomsangdonandClaude Opus 5 6e8de27966 feat(B08): 토적표 근거 호버와 출처 등급 분류 — 개발환경 전용
화면에 뜬 숫자가 어디서 와서 어떻게 나왔는지 가릴 길이 없어 개발·검산이 막히던 것을
엶(PLAN 8-36 ①②③④). 칸에 마우스를 올리면 등급·값·식·원천·자리가 카드로 뜨고,
토글을 켜면 등급색이 칸 왼쪽 띠로 붙음.

- `common_util/common_util_provenance.py` — 등급 상수 여덟과 `ColumnProvenance`.
  사람이 고르는 여섯(입력·측량·기준·계산·최종·막힘)에 `excluded`(일부러 안 셈,
  채우면 이중계상)와 `unclassified`(판단 미룸)를 더함. `excluded` 는 데스크탑 보조의
  B09 조사에서 나온 것으로 `blocked` 와 뜻이 정반대라 갈라 둠. 값 안에 선택이 숨은
  열을 위해 `rule`(채택 규칙) 칸도 둠.
- ⚠ 로직 보안 — `provenance_payload()` 가 개발환경이 아니면 `None` 을 돌려주고
  라우터가 응답에서 칸을 통째로 뺌. 화면에서 숨기는 것이 아니라 안 보내는 것임.
  문의 정본은 `is_dev_environment()` 하나로 통일.
- `ui_template/ui_template_provenance.ts` — 호버 카드·등급색·토글 한 벌. B09 도 같이 씀.
- `B08_Quantity/B08_Quantity_Provenance.py` — 토적표 20열 사전. 사전은 **열 단위**이고
  줄마다 갈리는 사유만 줄 쪽에서 얹음(칸마다 지으면 한 장이 6천 칸이라 응답이 붐).
- 토적표에는 `final` 열이 하나도 없어 억지로 붙이지 않음 — 중간 장부이고 내역서로
  나가는 값은 토공집계표에서 섬.

자체검증 — 시험 8개 추가(`test_b08_provenance.py`). 값어치는 첫 번째에 있음:
사전 열 이름이 전부 실제 `EarthworkRow` 에 있는가(엔진이 이름을 갈면 사전만 옛것으로
남는데 화면에서는 카드가 그냥 안 떠 눈에 안 띔). 배포환경 `None` 도 시험으로 박음.
브라우저(ORCA 5173, 실제 사전을 물려 토적표를 그림) — 표시된 칸 40개(2줄×20열) ·
등급 갈래 survey 12 / calc 28 · 카드에 「절토 토사 보정량 · 계산 · 값 45.00 ·
식 절토 토사 입적 × 토량환산계수(다짐) · 자리 EarthworkTable.py:210」 · 토글에 배경이
투명 → 초록 7 % 로 바뀌고 왼쪽 띠 inset 3px. 줄 사유가 그 줄 모든 칸에 뜨던 것을
사유가 닿는 열(`ditch_*`)에만 뜨게 고침 — 「절토 보정량」 카드에 「측구 가름값이…」 가
떠서 읽는 사람을 속이던 자리임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 15:16:12 +09:00
eomsangdon 81b67aadad Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_1' into sub_desktop_1 2026-09-12 15:15:32 +09:00
eomsangdonandClaude Opus 5 4793eb4130 feat(B05): 계획노선 편집 화면 정리와 지도 회전
- 조작 설명을 지도 좌상단 2열 오버레이로, 상태·범례를 좌하단 오버레이로 (계획서 0-9 ⑩⑫)
- 단추를 제목행 오른쪽으로 옮기고 공용 `.ui-btn` 규격을 씀, 하단 정보행 삭제 (⑪⑬)
- LAS 등고선을 도엽과 같은 5m 단위로 그림 (⑭)
- 바탕이 LAS 면 세류선도 등고선 범위 안쪽만 그림 (⑮)
- 지도 오른쪽 위 반시계·시계 단추로 15°씩 그림 전체 회전, 집기도 따라감 (⑯)
- R·L 하한을 5m 로 채움. 다만 L 하한은 칸 입력만 막음 — 기하에 걸면 거의 곧은 자리에서
  R 이 부풀어 아무것도 안 고쳤는데 노선이 바뀜 (⑰)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
2026-09-12 15:09:09 +09:00
eomsangdonandClaude Opus 5 9b7ff762bb docs(B09): 원가계산 화면 데이터 원천 조사표 (PLAN 8-36 ⑥ 앞작업)
- 탭 열 개(살아 있는 아홉)의 화면 열마다 이름·원천(파일:줄)·식·등급 후보 정리
- 이미 있는 것 넷 가름 — formula_text · source_label · base_label · work_item_basis
- 등급 여섯에 안 맞는 일곱 갈래를 6장에 모음, 핵심은 ㉱ 「여기서 안 세는 줄」이
  막힘과 뜻이 정반대라 일곱째 등급 「제외」가 필요해 보이는 것

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 15:07:31 +09:00
eomsangdonandClaude Opus 5 c94d8c849a fix(b06): 암 절토 입력을 1:n 으로 바꾸고 기슭막이 대각선 제거·조정창 자리 내림
암 절토 칸은 속값이 이미 경사비인데 화면만 각도(°)로 바꿔 보여 줘, 도면·좌측 표준
설정·품셈이 모두 1:n 인 자리에서 이 칸만 머리로 환산해야 했음. 머리말 「1:」과 n 입력으로 바꿈.

기슭막이 상단에서 바닥으로 늘 긋던 대각 이음선은 사용자 지시로 뺌.

조정창이 좌측 상단 면적표를 덮어 수치가 안 보이던 것을 표 높이만큼 내려 세움.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 15:03:36 +09:00
eomsangdon ae9a053c59 Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-12 15:00:05 +09:00
eomsangdon d5b7955ce2 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-12 14:58:20 +09:00
eomsangdon 585d44b3a6 Merge remote-tracking branch 'origin/dev' into main_desktop_1 2026-09-12 14:51:35 +09:00
eomsangdon ee8b3baeed auto: 2026-09-12 14:46 (EOMSANGDON-HOME) 2026-09-12 14:46:52 +09:00
eomsangdonandClaude Opus 5 648bc52813 fix(B08): 좌측 산출조건 패널을 B03~B07 공통 상자 틀로 통일하고 짤림 제거
개발용 「확정 없이 다음으로」 줄이 공용 `ui-sidebar-actions` 를 쓰고 패널 맨 위에
있어, 공용 코드(`splitSidebarActions`)가 **첫 번째** 그 클래스를 패널 바닥 액션 줄로
집고 스크롤 영역을 그 줄 안에 가둠. 그 아래 조건 칸과 진짜 [저장]·[확정] 줄이
`overflow: hidden` 밖으로 밀려 잘리고 스크롤바도 안 생기던 것을 바로잡음.

- 개발용 줄 단추 묶음을 `b08-quantity__dev-actions` 로 바꿔 공용 클래스에서 뗌
- 조건 칸을 `ui-collapsible ui-sidebar-section` 상자로 묶는 `groupPanelSections` 신설
  (새 파일 `B08_Quantity_UI_SidePanel_Sections.ts` — 본문이 이미 700줄을 넘김)
- 토량환산계수 구획도 제 제목으로 접히게 `ui-collapsible` 부여
- 표를 못 받은 경우(`table === null`) `concrete_placing` 접근에서 터져 페이지가
  통째로 백지가 되던 것을 `?.` 로 막음

자체검증(ORCA 내장 브라우저 5173, 모듈 직접 로드) — 스크롤 래퍼가 패널 직계로 돌아옴
(`b08-quantity__panel ui-sidebar-fill`) · `ui-sidebar-actions` 1개만 남음 ·
[저장]·[확정] 줄 바닥 920px = 패널 바닥 920px = 창 높이 920px 로 잘림 없음 ·
상자 7개(제목 6 + 무제목 1) · 「구조물·사토」 제목 클릭에 511px → 42px → 511px 로
접힘·펼침 확인 · `tsc --noEmit` 통과 · `pytest -q` 1294 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017RANEBHns1S4tkmsYwewtk
2026-09-12 14:46:43 +09:00
eomsangdon 36b5bed256 Merge remote-tracking branches 'origin/sub_desktop_1' and 'origin/sub_laptop_1' into main_laptop_1 2026-09-12 14:40:01 +09:00
eomsangdon 617cf1a6b8 Merge remote-tracking branch 'origin/dev' into main_laptop_1 2026-09-12 14:40:01 +09:00
eomsangdonandClaude Opus 5 332948694e feat(B05): 측점 눈금을 누르면 그 측점 횡단 미리보기 창
- 원지반 횡단선·기본 계획 횡단선·성토사면 길이 셋만 보임, 구조물은 안 그림 (계획서 0-9 ⑧)
- 서버 `POST /route/cross-preview` 가 `generate_sections`+`compute_cross_design` 정본을 재사용
- 계획고는 편집 중에 없어 그 측점 지반고를 그대로 놓음(지반 추종) — 창에 그렇게 적음
- 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths` 를 그대로 부름
- 700줄 규정에 맞춰 곡선 패널 배선을 `_CurveBar.ts` 로 분리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
2026-09-12 14:39:35 +09:00
eomsangdonandClaude Opus 5 87ecf8a2e1 feat(B09): 좌측 패널을 B03~B07 공통 틀(접히는 상자·바닥 고정 액션 줄)로 교체
- 조건 상자 다섯을 `ui-collapsible ui-sidebar-section` 으로, 제목에 `ui-collapsible__title` 부여
- 자체 `b09-panel__actions` 를 공용 `ui-sidebar-actions` 로 바꿔 [계산]·[확정] 이 패널 바닥에 고정
- 패널 루트에 `attachCollapsible` 연결 — 제목 행 클릭으로 접힘·펼침
- 상자 꼴(패딩·모서리·배경)은 `.ui-sidebar-section` 이 붙은 것만 집어 기초자료 본문 표는 불변

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 14:39:31 +09:00
eomsangdonandClaude Opus 5 bd770fc351 fix(b06): 곡선부 확폭을 설계 곡선표 기준으로 바로잡고 횡단도에 노견 표시 추가
확폭을 측점마다 앞뒤 10m 세 점의 외접원 반경으로 다시 재던 것을 계획노선 곡선표
(시·종점·설계 반경)로 바꿈. 종전에는 같은 곡선 안에서도 측점마다 확폭이 갈리고,
곡선 밖 직선까지 확폭이 흘러나가며, 측점 간격보다 짧은 곡선은 통째로 빠졌음.
곡선 안은 설계 반경의 표값 한 값, 앞뒤 10m 는 0 으로 잇고, 그 밖은 확폭 없음.
곡선표가 없는 옛 프로젝트는 종전 방식으로 물러섬.

횡단도는 차도 끝에만 눈금이 있어 노면이 넓어져도 차도가 는 것인지 노견이 는 것인지
가릴 수 없었음. 노면 끝에도 옅은 눈금을 세우고 노폭 라벨에 노견 폭을 덧붙임.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fe1QWPTfw11PaKh2LjwXdR
2026-09-12 14:26:40 +09:00
eomsangdonandClaude Opus 5 d02f7cf6e2 feat(B05): 계획노선 편집에서 두 점 사이 거리·종단기울기 보기
- Shift+클릭으로 노선 위 두 점을 찍으면 구간 길이와 종단기울기 표기 (계획서 0-9 ⑤)
- 지반고 통로 `POST /route/elevations` 추가 — 종·횡단과 같은 sampler 를 읽기만 함
- 찍는 순간에만 서버를 부름, 끄는 동안에는 안 부름
- 700줄 규정에 맞춰 구간 재기와 [확인] 처리를 `_Measure.ts`·`_Apply.ts` 로 분리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
2026-09-12 14:24:02 +09:00
eomsangdonandClaude Opus 5 b076e006a7 feat(B05): 계획노선 곡선 R·L 하한을 화면에서 막음
- 임도 종류별 하한 표를 config 에 둠, 작업임도는 규정이 없어 0(제한 없음)
- 기본 반지름과 하한을 갈라 둠 — 한 값이면 하한 0 이 반지름 0 이 되어 곡선이 안 그려짐
- `/route/plan` 이 `limit_radius_m`·`limit_curve_length_m` 를 함께 내림
- 반지름 칸·곡선 길이 칸·손잡이 끌기가 하한에서 멈추고, 노드 이동은 그 걸음을 되돌림
- 이미 하한을 밑돌던 자리는 그대로 두고 지키던 자리가 넘어가는 것만 막음
- 시험 `resources/tester/test_plan_curve_limits.py` 추가

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
2026-09-12 14:09:46 +09:00
eomsangdon 43a9239803 Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_desktop_1' into main_desktop_1 2026-09-12 13:52:18 +09:00
eomsangdonandClaude Opus 5 082211462f feat(B05): 계획노선 편집 모달 바탕을 LAS 등고선으로 바꾸고 높이 라벨·선택 추가
- 확정 지표면이 있으면 LAS 등고선을 바탕으로 씀, 없으면 도엽 등고선 유지 (계획서 0-9 ⑥)
- 등고선 가닥마다 높이값 라벨, 겹치면 건너뜀 (③)
- 등고선을 누르면 그 가닥만 도드라지고 상태줄에 높이 표기 (⑦)
- 화면에 드는 가닥 수로 등고선 간격을 고름 — 1m 자료가 선으로 뭉개지던 것 해소
- `B04_PreProcess_UI_MapRender.ts` 700줄 초과로 사전 투영을 `_Prepare.ts` 로 분리

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
2026-09-12 13:49:37 +09:00
eomsangdonandClaude Opus 5 465dbb955d feat(B08): 토량환산계수를 프로젝트가 고를 수 있게 열되 기본값은 불변
- 기본값 정의처는 config_system_design 한 곳 그대로. 고른 값은 프로젝트 설정
  quantity.conversion_factors_override 에 얹고, earthwork_conversion_factors() 한 함수가
  기본값 위에 얹어 풀어 냄.
- 그 함수를 거치는 자리 여섯 — 토적표·운반표·기초단가(B08), 유토곡선 컨텍스트·
  배분 계산·[저장] 재계산(B06). 상수를 직접 드는 자리를 없앰.
- 산출 조건 패널에 갈래별 계수 칸 신설 — 기본값·품셈 범위를 함께 보이고,
  「유토곡선·운반표·기초단가에도 같이 닿음」 안내 한 줄. 범위 밖은 막지 않고 사유를 받음.
- 품셈 체적변화율 범위를 서버 상수로 두고 화면에 내려보냄(프론트에 다시 적지 않음).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 13:38:00 +09:00
eomsangdonandClaude Opus 5 18c276c6af feat(B05): 계획노선 편집 모달에 길이·측점 표기 추가와 곡선 패널 가둠
- 상태줄에 계획노선 길이 실시간 표기 (계획서 0-9 ①)
- 시점·종점 이름표와 규칙측점 눈금 표기, B04 지도와 같은 `drawStationTicks` 재사용 (②)
- 곡선 조작 패널을 모달 지도 칸 안으로 가둠 — 상자 밖·하단 정보행 위로 안 나감 (⑨)
- 그리기를 `B05_Profile_UI_RouteEdit_Render.ts` 로 분리 — 본체 703줄 → 627줄

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
2026-09-12 13:30:20 +09:00
eomsangdon 384625cec0 Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-12 13:18:26 +09:00
eomsangdonandClaude Opus 5 1bfe1aa6e7 fix(B08): 토적표가 설계 측구 가름값을 그대로 읽도록 하고 표기 자리수를 품셈에 맞춤
- 측구 토사·암을 절토 면적비로 다시 안분하지 않고 B06 이 낸
  ditch_soil_area_m2·ditch_rock_area_m2 를 그대로 씀. 근거 ditch_split_basis 도 줄에 실음.
- 설계값이 없는 저장분만 옛 안분으로 떨어지고 사유를 줄 주기에 남김.
- 표기 자리수 — 단면적 ㎡ 1자리 · 체적 계열 ㎥ 2자리 · 합계 정수 (계산은 전정밀 그대로).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
2026-09-12 13:14:13 +09:00
eomsangdon de8e9eedc9 docs: dev 합류점과 브라우저 운용 위키화 2026-09-12 10:44:33 +09:00
eomsangdon d6e45bb431 Merge remote-tracking branch 'origin/dev' into sub_desktop_1 2026-09-12 10:22:09 +09:00
eomsangdon 556cbcfdf3 auto: 2026-09-12 10:21 (EOMSANGDON-HOME) 2026-09-12 10:21:53 +09:00
eomsangdon 6824f03815 Merge remote-tracking branch 'origin/dev' into sub_laptop_1 2026-09-12 10:20:39 +09:00
eomsangdon bf6c4d8340 auto: 2026-09-12 10:20 (ESD_LAPTOP) 2026-09-12 10:20:33 +09:00
eomsangdonandClaude Opus 5 746d6fe0d6 docs(verification): 화면 검증 기본을 ORCA 내장 브라우저로 바꾸며 공용 브라우저 상세 보관
- 지침 4장은 ORCA 기본 · 공용은 사용자가 지시할 때만으로 줄임
- 되돌릴 때 필요한 시동·명령파일·캐시 비우기·재시작 절차를 원문 그대로 이 문서에 옮김
- ORCA 쪽 캐시 비우기는 전용 명령이 없어 미확인으로 남김 (첫 B07 검증 때 확정할 것)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
2026-09-12 10:14:01 +09:00
eomsangdon 3c37c4ed92 docs(verification): 들여오기를 코드 작업 전으로 한정한 개정 기록 2026-09-12 09:57:47 +09:00
eomsangdon aea787cc8d docs(verification): 깃 그물망에서 합류점 dev 로 전환 기록 2026-09-12 09:37:25 +09:00
eomsangdon cecd12e329 Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1 2026-09-11 22:49:18 +09:00
eomsangdon 92fc2450f2 Merge remote-tracking branch 'origin/CODEX' into main_desktop_1 2026-09-11 22:49:17 +09:00
eomsangdon c07b3e2354 Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-11 22:34:09 +09:00
eomsangdon ec68099557 Merge remote-tracking branch 'origin/CODEX' into main_laptop_1 2026-09-11 22:34:09 +09:00
eomsangdon 686dae6bf4 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-11 22:21:53 +09:00
eomsangdon 268b79fdcb Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-11 22:21:51 +09:00
eomsangdon 06883551f3 docs(wiki): archive worktree operations 2026-09-11 22:19:42 +09:00
eomsangdon a6ac664439 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-11 22:18:46 +09:00
eomsangdon 60fe4467b9 Merge remote-tracking branch 'origin/main_desktop_1' into main_laptop_1 2026-09-11 22:18:45 +09:00
eomsangdon 87d499c624 Merge remote-tracking branch 'origin/sub_laptop_1' into main_desktop_1 2026-09-11 22:17:35 +09:00
eomsangdon ed94be81b4 Merge remote-tracking branch 'origin/sub_desktop_1' into main_desktop_1 2026-09-11 22:17:33 +09:00
eomsangdon d0d3230112 Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1 2026-09-11 22:17:32 +09:00
eomsangdon 3651d5b6f2 auto: 2026-09-11 22:17 (EOMSANGDON-HOME) 2026-09-11 22:17:27 +09:00
eomsangdon e993814540 Merge remote-tracking branch 'origin/sub_desktop_1' into main_laptop_1 2026-09-11 22:09:10 +09:00
eomsangdon e0a6c21077 Merge remote-tracking branch 'origin/sub_desktop_1' into sub_laptop_1 2026-09-11 22:00:42 +09:00
eomsangdon e49e4d8526 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-11 21:56:14 +09:00
eomsangdon 3a044ff9f6 Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-11 21:38:14 +09:00
eomsangdon 9eb0d0186b Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-11 21:31:13 +09:00
eomsangdon 097824bd9e auto: 2026-09-11 21:31 (ESD_LAPTOP) 2026-09-11 21:31:09 +09:00
eomsangdon fe26837f36 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-11 21:14:01 +09:00
eomsangdonandClaude Opus 5 f4accad335 docs(verification): 데스크탑 AI 워크트리 둘 원격 생성·링크 기록
- orca 환경(--environment) 으로 상대 PC 오르카를 직접 부리는 길 확인
- CODEX·안티그래비티 워크트리 생성, 링크 일곱 다 섬, 추적 붙임
- 상대 창 승인 대기 없이 내 권한으로 수행

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011N7TKFmaPogzq9HUi6jKo7
2026-09-11 21:13:20 +09:00
eomsangdon 11c40853b6 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-11 20:52:06 +09:00
eomsangdonandClaude Opus 5 4674c02088 docs(verification): 랩탑 AI 워크트리 제거·장부 없는 워크트리 부트스트랩 기록
- orca terminal close 셋 + worktree rm 둘, 메인 파일·원격 브랜치 멀쩡함 확인
- 제거 전 정션 없음 확인 — 원본 삭제 위험 없었음
- 장부 못 찾으면 .claude 정션 대상을 메인으로 쓰는 길 추가
- 사용자 확정 셋(자동 커밋 유지·브랜치 이름 규칙·docs 문 유지)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011N7TKFmaPogzq9HUi6jKo7
2026-09-11 20:50:57 +09:00
eomsangdon 01b82ecc5b Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-11 20:50:30 +09:00
eomsangdon 29943d9ff1 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-11 20:50:21 +09:00
eomsangdon 786389518f Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-11 20:45:27 +09:00
eomsangdonandClaude Opus 5 daff6ba68a docs(verification): 링크 스크립트·하드링크 실측 4장 반영
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QCqqbnuccwP57S3LSA3bv
2026-09-11 20:44:07 +09:00
eomsangdon 48b8ca0414 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_desktop_1 2026-09-11 20:43:40 +09:00
eomsangdon a3e256de0b Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-11 20:43:39 +09:00
eomsangdonandClaude Opus 5 bebf6053ff docs(verification): 하드링크 끊김 실측·링크 스크립트 동작 기록
- main_laptop_1-2/PLAN.md 하드링크 이미 끊어져 갈라짐(107,270 ↔ 111,121)
- PowerShell 은 하드링크를 LinkType=HardLink 로 봄 — 옛 구멍은 「건너뜀」이었음
- 고친 스크립트 모래상자 시험 넷 통과
- 오르카 setup 문구는 CLI 로 못 바꿈

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011N7TKFmaPogzq9HUi6jKo7
2026-09-11 20:43:01 +09:00
eomsangdon cf03b1ce7a Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_1 2026-09-11 20:37:40 +09:00
eomsangdonandClaude Opus 5 8340b1d526 docs(verification): 오르카 워크트리 브랜치 이름 사용자 정정 반영
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019QCqqbnuccwP57S3LSA3bv
2026-09-11 20:36:35 +09:00
eomsangdonandClaude Opus 5 684cf22f14 docs(verification): 브랜치 이름은 사용자가 규칙에 맞춰 지은 것으로 정정
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011N7TKFmaPogzq9HUi6jKo7
2026-09-11 20:25:33 +09:00
eomsangdon 012c76df74 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-11 19:34:30 +09:00
eomsangdon d7b0e1c41b Merge remote-tracking branch 'origin/sub_desktop_1' into sub_desktop_1 2026-09-11 19:34:08 +09:00
eomsangdon bc270f533d docs(verification): 창 환경 워크트리 링크와 깃 운영 조사 기록 2026-09-11 19:33:30 +09:00
eomsangdonandClaude Opus 5 8ba960351f docs(verification): 창 환경 링크 구성·깃 운영 충돌 조사 기록
- 워크트리 넷의 링크 구성 실측(정션·하드링크·복사본·없음)
- 오르카는 무시 파일만 하드링크, 폴더는 안 만듦 — 그 워크트리에서 세션 훅 안 돎
- 오르카에 커밋·푸시 명령 없음 — 역할 나눔이 방향
- 토큰 실측(커밋 1회 191~338 · 따라오기 52~88)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011N7TKFmaPogzq9HUi6jKo7
2026-09-11 16:18:04 +09:00
eomsangdon d902d9f8dc auto: 2026-09-11 15:07 (EOMSANGDON-HOME) 2026-09-11 15:07:35 +09:00
eomsangdon e2c1ea7e05 Merge remote-tracking branch 'origin/main' into sub_desktop_1 2026-09-11 08:59:27 +09:00
eomsangdonandClaude Opus 5 9b35774c1c docs(verification): 경쟁사 오솔길 분석 기록 커밋
전 세션 산출물이 미커밋으로 남아 있던 것 — 싱크로 다른 창에 못 넘어가던 자리.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ikwZL5RDn97nEX5KU13qg
2026-09-10 23:31:16 +09:00
eomsangdonandClaude Opus 5 2b2c064df5 knowledge(경쟁사): 오솔길 도면양식 DXF 변환본 보관
DWG 56개(임도 표·심볼, 사방댐 표·상세도, 캐드 템플릿)를 LibreDWG로
DXF 변환해 10_원문/도면양식DXF/ 에 원본 폴더 구조 그대로 보관.
원본 DWG는 손대지 않음.

- 변환 성공 56 · 실패 0
- 폴더 README에 다시 만드는 명령, 들어 있는 것, 읽어 본 결과 기록
- 도면양식 개요 문서의 "저장소에 두지 않음" 문구를 실제와 맞게 수정

계산식은 없음 — 양식은 표 껍데기라 공식이 안 들어 있음.
건진 것은 열 구성과 항목 이름(볏집거적·새심기·오차적용경사·터파기조건).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTbyFkYhKNUYvxRV7DYfxZ
2026-09-10 23:09:49 +09:00
eomsangdonandClaude Opus 5 b7429cd88d knowledge(경쟁사): 오솔길 도면양식 DWG 분석 — 지역별 양식 차이 확인
저장소 안 LibreDWG(dwg2dxf)로 오솔길 도면양식 55개를 DXF 변환해 글자 추출.
변환물은 저장소에 두지 않고 명령만 문서에 남김.

- 제출 양식이 발주 지역(OSKid: KN·KP·JN·CN)마다 표 열 구성이 다름을 확인.
  JN은 저수·저사·토사조절선이 없고 평떼·줄떼·찰쌓기·메쌓기가 들어감.
  KP는 떼붙임·좌안·우안 추가, 댐 토공표에 터파기조건 열 추가.
- 표제란은 DWG 파일 교체로 자사화 — 도면 안에 방법이 글로 적혀 있음.
- 발주처 로고를 별도 폴더(발주처_시군_공사_마크)에서 갈아 끼움.
- 임도 물량표(Wchart)에 매뉴얼 BOM에 없던 볏집거적·새심기 확인.
- 곡선설치표 신버전에 곡선중심 X·Y 추가, RE 정보표에 오차적용경사 확인.

폴더를 10_원문(전사)과 20_분석(분석)으로 나누고 README 색인 갱신.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTbyFkYhKNUYvxRV7DYfxZ
2026-09-10 23:06:55 +09:00
eomsangdonandClaude Opus 5 eed223602d knowledge(경쟁사): 오솔길 엑셀매크로 VBA 계산로직 md화
경쟁사 프로그램 오솔길의 엑셀 매크로(PERSONAL.XLSB) VBA 7개 모듈을
모듈별 md 문서로 정리. 문서 안은 계산 로직 단위로 나눔.

- 임도 횡단물량: 평균단면적법, 보정계수(토사 0.9·암 1.0), 유용토=MIN(보정량계,성토입적),
  차인토량, 누가토량 수식 확보
- 야계사방댐: 터파기 유/무 두 갈래(지역 관행 차이), 유용토 상대가 성토+되메우기
- 사방댐 토공: 평균단면적법 / 3-4각형법 두 방식, 토사·암석 2열 분리
- 공통 보조 프로시저: 보정계수를 표에서 역산하는 방식 확인

단면적 산출·구조물 물량은 컴파일된 OSKi.arx 안이라 미확보 — 역공학 안 함.
VBA 원문은 저장하지 않고 재추출 방법만 README에 남김.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YTbyFkYhKNUYvxRV7DYfxZ
2026-09-10 22:06:46 +09:00
eomsangdon ccc8fec627 Merge remote-tracking branch 'origin/sub_laptop_1' into main_laptop_1 2026-09-10 21:08:59 +09:00
eomsangdonandClaude Opus 5 56f1810360 docs(plans): 완료 계획서 연결 줄에서 없어진 위키 문서 링크 제거
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 21:08:43 +09:00
eomsangdon f8fe0b7f44 auto: 2026-09-10 21:01 (ESD_LAPTOP) 2026-09-10 21:01:22 +09:00
eomsangdon fb7bab52a9 docs(wiki): ingest plan evidence e f g 2026-09-10 20:46:04 +09:00
eomsangdon 3250850328 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-09 23:58:30 +09:00
eomsangdon 44e9304134 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 23:58:28 +09:00
eomsangdon d9727ae82f Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 23:48:30 +09:00
eomsangdon 10f1594e2f Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-09 23:38:31 +09:00
eomsangdon f31a42f4a6 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 23:38:29 +09:00
eomsangdon c10e9ce581 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-09 23:18:31 +09:00
eomsangdon ab031415d2 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 23:18:29 +09:00
eomsangdon d3fe0e3344 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-09 23:08:31 +09:00
eomsangdon fcd1cdb266 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 23:08:29 +09:00
eomsangdon fc4370893b Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 22:58:28 +09:00
eomsangdon 2c01204b9d Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-09 22:48:32 +09:00
eomsangdon 057f040ab1 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 22:48:29 +09:00
eomsangdon a19609f803 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-09 22:28:31 +09:00
eomsangdon 80c83294e2 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 22:28:29 +09:00
eomsangdon 453013fe83 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 22:08:29 +09:00
eomsangdon 7104fe8eb9 Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-09 21:58:29 +09:00
eomsangdon 69aba34f67 Merge remote-tracking branch 'origin/main_desktop_1' into sub_desktop_1 2026-09-09 21:38:28 +09:00
eomsangdon bfa74978aa Merge remote-tracking branch 'origin/main_laptop_1' into sub_desktop_1 2026-09-09 21:28:30 +09:00
1544 changed files with 1388767 additions and 40376 deletions
+4
View File
@@ -75,3 +75,7 @@ VWORLD_SESSION_COOKIE=
# True면 대시보드 삭제 버튼이 DB 행과 storage/.../{프로젝트ID}/ 폴더를 실제로 지운다.
# 배포 전 반드시 False로 되돌릴 것.
PROJECT_DELETE_HARD_ENABLED=True
# 개발 단계에서 로그인 이메일 인증(OTP)을 건너뛴다 (2026-09-13 사용자 지시).
# ENVIRONMENT=development 일 때만 듣는다. 배포 전 반드시 False 로 되돌릴 것.
AUTH_OTP_DISABLED=True
+2
View File
@@ -65,6 +65,8 @@ tmp/
config/corridor_node/
# 횡단 서버 재계산 번들 — `npm run build:server-calc` 산출물(2026-09-06).
config/server_calc_node/
# 구조물도 식 풀이 번들 — `npm run build:formula` 산출물(2026-09-13).
config/formula_node/
# graphify 위키 산출물 — 창끼리 같은 위키를 찾게 **통째로** git 으로 나름
# (2026-09-09 사용자 확정 「출력물 전체를 공유해도 됨」).
+219
View File
@@ -0,0 +1,219 @@
/* =============================================================================
* b_svg_zoom_pan.ts
* SVG 그래프 한 장의 **확대·축소·팬** — 여러 화면이 한 코드를 쓴다.
*
* B06 횡단 카드(`B06_Section_UI_Cross_View_Zoom.ts`)에 있던 것을 옮겨 온 것이다
* (2026-09-12 사용자 지시 「공용화해줘」). 동작 규칙은 그때 정해진 것을 그대로 지킨다.
*
* · **팬은 가운데(휠) 버튼 전용** — 왼쪽 버튼은 그 화면의 고르기·조작 몫이다.
* · **더블클릭이면 원배율**로 돌아간다.
* · **휠은 화면마다 다르다** — 카드가 수십 장 깔리는 B06 은 휠을 줌에 묶지 않는다
* (묶으면 목록을 훑을 수가 없다, 2026-08-02 사용자 확정). 판이 둘뿐인 B05 계획노선
* 횡단은 휠 = 줌이다(2026-09-12 사용자 지시 ③). `wheelZoom` 한 칸으로 가른다.
*
* 그리는 일은 안 한다 — 넘겨받은 `layer` 에 `transform` 만 적는다.
* ========================================================================== */
/** 줌·팬 상태 — 다시 그릴 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
export interface ZoomPanState {
scale: number;
tx: number;
ty: number;
}
/**
* 그려 둔 도형이 실제로 차지하는 범위(원배율 SVG 좌표). 표시 범위 밖에 있어 잘려 있는
* **캐시 보유분**까지 포함한다 — 확대 상태의 팬은 이 범위까지 갈 수 있다
* (2026-08-23 사용자 지시).
*/
export interface ContentBounds {
x: number;
y: number;
width: number;
height: number;
}
export interface ZoomPanHandle {
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
zoom: (factor: number) => void;
reset: () => void;
/** 현재 배율 — 원배율(1)일 때는 부르는 쪽 버튼이 배율 대신 표시 폭을 조절한다. */
scale: () => number;
}
/** 축 안쪽 여백(px) — 확대·축소의 중심이자 이동 한계의 기준이 된다. */
export interface ZoomPanPad {
left: number;
right: number;
top: number;
bottom: number;
}
export interface SvgZoomPanOptions {
svg: SVGSVGElement;
/** 밀고 키울 도형 묶음 — 여기에만 `transform` 이 적힌다. */
layer: SVGGElement;
/** `viewBox` 의 폭·높이(원배율 SVG 좌표). */
widthPx: number;
heightPx: number;
pad: ZoomPanPad;
/** 배율 상한. 안 주면 8. */
maxScale?: number;
/** 이전 상태 — 다시 그려도 배율이 살아남게. */
initial?: ZoomPanState;
/** 상태가 바뀔 때마다 부른다 — 바깥이 담아 뒀다가 다음 그림에 되돌린다. */
onChange?: (state: ZoomPanState) => void;
/** 도형이 실제로 그려진 범위. 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds;
/** 휠을 줌에 묶을지(기본 안 묶음). 묶으면 **커서 아래 지점**을 붙잡고 확대한다. */
wheelZoom?: boolean;
}
/** 휠 한 칸에 얼마나 — 노선 편집 지도와 같은 계수(`_RouteEdit_Input`). */
const WHEEL_IN = 1.15;
const WHEEL_OUT = 0.87;
export function attachSvgZoomPan(options: SvgZoomPanOptions): ZoomPanHandle {
const { svg, layer, widthPx, heightPx, pad, initial, onChange, content } = options;
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
const plot = {
x: pad.left,
y: pad.top,
width: Math.max(widthPx - pad.left - pad.right, 1),
height: Math.max(heightPx - pad.top - pad.bottom, 1),
};
const maxScale = options.maxScale ?? 8;
let scale = initial?.scale ?? 1;
let tx = initial?.tx ?? 0;
let ty = initial?.ty ?? 0;
const applyTransform = (): void => {
layer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty });
};
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 범위 밖 캐시 보유분 포함)까지 늘어나,
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시).
const clampAxis = (
value: number,
start: number,
size: number,
from: number,
to: number,
): number =>
Math.min(
Math.max(value, Math.min(start + size - scale * to, start - scale * from)),
Math.max(start + size - scale * to, start - scale * from),
);
const clampPan = (): void => {
// 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그
// 범위까지 열어 둔다 — 캐시 보유분이 표시 폭보다 넓으면 1배에서도 끌어다 봐야 한다
// (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
const bounds = content
? {
x: Math.min(content.x, plot.x),
y: Math.min(content.y, plot.y),
right: Math.max(content.x + content.width, plot.x + plot.width),
bottom: Math.max(content.y + content.height, plot.y + plot.height),
}
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right);
ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom);
};
/** 그 자리를 붙잡고 확대·축소한다 — 버튼은 플롯 중앙을, 휠은 커서 자리를 준다. */
const zoomAt = (factor: number, cx: number, cy: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor));
tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next;
scale = next;
clampPan();
applyTransform();
};
// 버튼에는 마우스 자리가 없다 — 보이는 **플롯 영역의 중앙**을 붙잡는다.
const zoom = (factor: number): void =>
zoomAt(factor, plot.x + plot.width / 2, plot.y + plot.height / 2);
let panning = false;
let moved = false;
let lastX = 0;
let lastY = 0;
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
svg.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("auxclick", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 그 화면의 고르기 몫이다.
if (event.button !== 1) return;
event.preventDefault();
panning = true;
moved = false;
lastX = event.clientX;
lastY = event.clientY;
svg.classList.add("is-panning");
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener("pointermove", (event) => {
if (!panning) return;
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
const rect = svg.getBoundingClientRect();
tx += ((event.clientX - lastX) / rect.width) * widthPx;
ty += ((event.clientY - lastY) / rect.height) * heightPx;
lastX = event.clientX;
lastY = event.clientY;
clampPan();
applyTransform();
});
const endPan = (event: PointerEvent): void => {
if (!panning) return;
panning = false;
svg.classList.remove("is-panning");
try {
svg.releasePointerCapture(event.pointerId);
} catch {
/* 이미 해제됨 */
}
};
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
clampPan();
applyTransform();
}
svg.addEventListener("pointerup", endPan);
svg.addEventListener("pointercancel", endPan);
// 드래그(팬)로 끝난 클릭은 바깥(카드 고르기 등)으로 전파하지 않는다.
svg.addEventListener("click", (event) => {
if (moved) event.stopPropagation();
});
if (options.wheelZoom) {
svg.addEventListener(
"wheel",
(event) => {
event.preventDefault();
// 커서 아래 지점이 제자리에 남게 **그 자리**를 붙잡는다.
const rect = svg.getBoundingClientRect();
const cx = ((event.clientX - rect.left) / Math.max(rect.width, 1)) * widthPx;
const cy = ((event.clientY - rect.top) / Math.max(rect.height, 1)) * heightPx;
// 노선 편집 지도와 같은 방향 — **당기면 확대**(`deltaY > 0`).
zoomAt(event.deltaY > 0 ? WHEEL_IN : WHEEL_OUT, cx, cy);
},
{ passive: false },
);
}
const currentScale = (): number => scale;
const reset = (): void => {
scale = 1;
tx = 0;
ty = 0;
applyTransform();
};
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
event.stopPropagation();
reset();
});
return { zoom, reset, scale: currentScale };
}
+1 -1
View File
@@ -52,7 +52,7 @@ const routeTable: Partial<Record<RoutePath, () => Promise<PageRenderer>>> = {
[ROUTES.B08_QUANTITY]: async () =>
(await import("../B08_Quantity/B08_Quantity_UI_Page")).renderB08Quantity,
[ROUTES.B09_ESTIMATION]: async () =>
(await import("../B09_Estimation/B09_Estimation_UI_Page")).renderB09Estimation,
(await import("../B09_Estimation/B09_Estimation_UI_Shell")).renderB09Estimation,
[ROUTES.B10_PAYMENT]: async () =>
(await import("../B10_Payment/B10_Payment_UI_Page")).renderB10Payment,
[ROUTES.B11_STATUS]: async () =>
+18 -6
View File
@@ -1,5 +1,6 @@
"""로그인, 재인증, 세션 및 비밀번호 API."""
import logging
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Request
@@ -34,10 +35,12 @@ from common_util.common_util_auth_repository import (
)
from common_util.common_util_email import send_email_background
from common_util.common_util_email_templates import otp_email, security_alert_email
from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS
from config.config_system import ADMIN_EMAIL, AUTH_OTP_DISABLED, EMAIL_REVERIFY_DAYS
from .A06_Login_Schema import LoginRequest, OtpVerifyRequest, PasswordChangeRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
@@ -85,11 +88,20 @@ async def request_login(payload: LoginRequest, request: Request):
)
new_browser = not trusted_device
if periodic_reverify or new_browser:
await _send_otp(user, "LOGIN", "로그인")
return {
"status": "otp_required",
"reason": "PERIODIC" if periodic_reverify else "NEW_BROWSER",
}
# 개발 단계에서는 메일 인증을 건너뛴다 (2026-09-13 사용자 지시).
# 이 갈래는 ENVIRONMENT=development + AUTH_OTP_DISABLED=true 에서만 열린다.
if AUTH_OTP_DISABLED:
logger.warning(
"[auth] 개발용 OTP 생략 — %s (%s)",
email,
"PERIODIC" if periodic_reverify else "NEW_BROWSER",
)
else:
await _send_otp(user, "LOGIN", "로그인")
return {
"status": "otp_required",
"reason": "PERIODIC" if periodic_reverify else "NEW_BROWSER",
}
return await _finish_login(user, agent, device_token_hash)
+8
View File
@@ -1,5 +1,6 @@
"""회원가입, 이메일 인증 및 회사 검색 API."""
import logging
from datetime import datetime
from fastapi import APIRouter, HTTPException, Query, Request
@@ -27,9 +28,12 @@ from common_util.common_util_auth_repository import (
)
from common_util.common_util_email import send_email_background
from common_util.common_util_email_templates import otp_email
from config.config_system import AUTH_OTP_DISABLED
from .A07_Register_Schema import RegisterRequest, RegisterVerifyRequest
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/auth", tags=["Authentication"])
@@ -60,6 +64,10 @@ async def request_registration(payload: RegisterRequest):
await replace_otp(user_id, "REGISTER", hash_password(code))
subject, html = otp_email(code, "회원가입")
send_email_background(email, subject, html)
# 개발 단계에서는 메일을 못 받아도 가입을 이어갈 수 있게 코드를 로그에 찍는다
# (2026-09-13 사용자 지시). ENVIRONMENT=development + AUTH_OTP_DISABLED=true 에서만.
if AUTH_OTP_DISABLED:
logger.warning("[auth] 개발용 가입 인증 코드 — %s : %s", email, code)
return {"status": "success", "message": "인증 코드를 발송했습니다."}
+19
View File
@@ -316,6 +316,25 @@ export async function geocodeAddress(address: string): Promise<{ lat: number; lo
return request(`/dashboard/company/geocode?address=${encodeURIComponent(address)}`);
}
/** 주소 후보 한 줄 — 검색해서 골라 넣는 용도. */
export interface AddressCandidate {
zipcode: string;
road: string;
parcel: string;
building: string;
category: string;
lat: number;
lon: number;
}
/** 주소를 검색해 후보를 받는다 (도로명 먼저, 없으면 지번). */
export async function searchAddress(query: string): Promise<AddressCandidate[]> {
const body = await request<{ items?: AddressCandidate[] }>(
`/dashboard/company/address/search?query=${encodeURIComponent(query)}`,
);
return body.items ?? [];
}
/** 회사 정보 수정 — 시스템관리자만 companyId 로 남의 회사를 지정한다. */
export function updateCompany(
payload: {
+57
View File
@@ -57,3 +57,60 @@ async def geocode_address(address: str) -> dict[str, Any] | None:
async def fetch_base_tile(z: int, x: int, y: int) -> bytes:
url = _TILE_URL.format(key=VWORLD_API_KEY, z=z, y=y, x=x)
return await asyncio.to_thread(_fetch, url)
_SEARCH_URL = "https://api.vworld.kr/req/search"
def _search_sync(query: str, size: int) -> list[dict[str, Any]]:
"""도로명으로 먼저 찾고, 비면 지번으로 다시 찾는다 — 후보를 목록으로 돌려준다."""
for category in ("road", "parcel"):
params = urllib.parse.urlencode(
{
"service": "search",
"request": "search",
"version": "2.0",
"crs": "EPSG:4326",
"size": str(size),
"page": "1",
"query": query,
"type": "address",
"category": category,
"format": "json",
"errorformat": "json",
"key": VWORLD_API_KEY,
}
)
try:
body = json.loads(_fetch(f"{_SEARCH_URL}?{params}", timeout=8).decode("utf-8"))
except Exception:
continue
result = (body.get("response") or {}).get("result") or {}
items = result.get("items") or []
found: list[dict[str, Any]] = []
for item in items:
address = item.get("address") or {}
point = item.get("point") or {}
try:
lon = float(point["x"])
lat = float(point["y"])
except (KeyError, TypeError, ValueError):
continue
found.append(
{
"zipcode": address.get("zipcode") or "",
"road": address.get("road") or "",
"parcel": address.get("parcel") or "",
"building": address.get("bldnm") or "",
"category": category,
"lat": lat,
"lon": lon,
}
)
if found:
return found
return []
async def search_address(query: str, size: int = 10) -> list[dict[str, Any]]:
return await asyncio.to_thread(_search_sync, query.strip(), size)
@@ -254,6 +254,7 @@ async def list_all_companies() -> list[dict[str, Any]]:
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
c.business_address, c.business_owner,
c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count,
COUNT(DISTINCT p.id) AS project_count
FROM companies c
+11 -1
View File
@@ -25,7 +25,7 @@ from common_util.common_util_project_delete import hard_delete_project
from common_util.common_util_storage import read_stored_asset
from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
from .B01_Dashboard_Map import fetch_base_tile, geocode_address
from .B01_Dashboard_Map import fetch_base_tile, geocode_address, search_address
from .B01_Dashboard_Repository import (
assign_user_company,
change_user_role,
@@ -257,6 +257,16 @@ async def company_geocode(
return {"status": "success", **point}
@router.get("/company/address/search")
async def company_address_search(
query: str = Query(min_length=2, max_length=200),
session: dict[str, Any] = Depends(verify_session),
):
"""주소 후보를 찾아 준다 — 회사 등록·수정 화면에서 골라 넣는 용도."""
_ = session
return {"status": "success", "items": await search_address(query)}
@router.get("/map/tile/{z}/{x}/{y}")
async def map_tile(
z: int = Path(ge=0, le=19),
@@ -0,0 +1,172 @@
import { createButton, createInputField, showToast } from "@ui/ui_template_elements";
import { searchAddress, type AddressCandidate } from "./B01_Dashboard_Api_Fetch";
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
import { L } from "./B01_Dashboard_UI_Common";
/**
* 회사 주소 입력칸 — 검색해서 고르는 방식 (2026-09-13 사용자 지시).
*
* 손으로 정확히 치지 않아도 되게, 일부만 넣어 후보를 받고 고른다.
* 고른 즉시 좌표를 알므로 지도를 다시 찾지 않고 바로 그린다.
* 저장은 지금대로 한 줄 — 「기본주소 + 상세주소」. 우편번호는 화면 확인용.
*
* 줄 배치는 라벨·입력·버튼을 한 줄에 세운다(B06 횡단 설정과 같은 꼴) — ui-field 가
* 기본이 세로라 그대로 두면 오류 슬롯 높이만큼 버튼이 밀려 수평이 어긋난다.
* 그래서 오류 슬롯은 접고, 잘못 넣은 것은 토스트로 알린다.
*/
export interface AddressFieldHandle {
root: HTMLElement;
/** 저장할 한 줄 주소. 비면 null. */
value: () => string | null;
}
/** 지번으로 찾은 줄은 `parcel` 쪽이 시·도까지 갖춘 온전한 주소다. */
function candidateLine(item: AddressCandidate): string {
const main = item.category === "parcel" ? item.parcel : item.road;
return main || item.road || item.parcel;
}
function candidateHint(item: AddressCandidate): string {
const other = item.category === "parcel" ? item.road : item.parcel;
return [item.building, other, item.zipcode].filter(Boolean).join(" · ");
}
/** createButton 은 글자를 span 에 담는다 — textContent 로 덮으면 그 껍질이 사라진다. */
function setButtonLabel(button: HTMLButtonElement, label: string): void {
const slot = button.querySelector(".ui-btn__label");
if (slot) slot.textContent = label;
else button.textContent = label;
}
function row(...parts: HTMLElement[]): HTMLElement {
const line = document.createElement("div");
line.className = "b01-dashboard__address-row";
line.append(...parts);
return line;
}
export function buildAddressField(initial: string | null): AddressFieldHandle {
const root = document.createElement("div");
root.className = "b01-dashboard__address";
// 1) 검색 줄 — 일부만 넣어도 후보가 나온다.
const query = createInputField({
label: L("B01_Dashboard_Field_Address"),
placeholder: "도로명·지번·건물명 일부 (예: 판교역로 235)",
});
const findBtn = createButton({
label: "주소 찾기",
variant: "ghost",
onClick: () => void runSearch(),
});
const manualBtn = createButton({
label: "직접 입력",
variant: "ghost",
onClick: function onB01_Address_Manual_Click() {
manual = !manual;
setButtonLabel(manualBtn, manual ? "검색으로" : "직접 입력");
base.input.readOnly = !manual;
query.root.hidden = manual;
findBtn.hidden = manual;
mapBtn.hidden = !manual;
if (manual) base.input.focus();
},
});
const searchRow = row(query.root, findBtn, manualBtn);
// 2) 후보 목록 — 고르면 사라진다.
const list = document.createElement("div");
list.className = "b01-dashboard__address-list";
list.hidden = true;
// 3) 고른 주소 + 상세주소.
const base = createInputField({ label: "기본주소", value: initial ?? "" });
base.input.readOnly = true;
const zip = document.createElement("span");
zip.className = "b01-dashboard__address-zip";
zip.hidden = true;
const detail = createInputField({ label: "상세주소", placeholder: "동·층·호" });
// 4) 지도 — 고른 즉시 그린다. 직접 입력일 때만 버튼으로 확인한다.
const map = buildAddressMap();
const mapBtn = createButton({
label: "지도 확인",
variant: "ghost",
onClick: async function onB01_Address_Map_Click() {
await map.show(base.input.value.trim());
},
});
mapBtn.hidden = true;
root.append(searchRow, list, row(base.root, zip), row(detail.root), row(mapBtn), map.root);
let manual = false;
function pick(item: AddressCandidate): void {
base.input.value = candidateLine(item);
zip.textContent = item.zipcode ? `우편번호 ${item.zipcode}` : "";
zip.hidden = !item.zipcode;
list.hidden = true;
list.innerHTML = "";
map.showPoint(item.lat, item.lon);
detail.input.focus();
}
async function runSearch(): Promise<void> {
const text = query.input.value.trim();
if (text.length < 2) {
showToast("주소를 두 글자 이상 넣으십시오.", "error");
return;
}
findBtn.disabled = true;
setButtonLabel(findBtn, "찾는 중…");
try {
const items = await searchAddress(text);
list.innerHTML = "";
list.hidden = false;
if (items.length === 0) {
const empty = document.createElement("p");
empty.className = "b01-dashboard__modal-text";
empty.textContent = "찾은 주소가 없습니다. 다른 낱말로 넣거나 「직접 입력」을 쓰십시오.";
list.append(empty);
return;
}
for (const item of items) {
const option = document.createElement("button");
option.type = "button";
option.className = "b01-dashboard__address-item";
const line = document.createElement("strong");
line.textContent = candidateLine(item);
const hint = document.createElement("small");
hint.textContent = candidateHint(item);
option.append(line, hint);
option.addEventListener("click", () => pick(item));
list.append(option);
}
} catch {
showToast("주소를 찾지 못했습니다. 잠시 뒤 다시 하십시오.", "error");
} finally {
findBtn.disabled = false;
setButtonLabel(findBtn, "주소 찾기");
}
}
// 검색칸에서 Enter — 모달이 닫히지 않게 막고 찾기만 한다.
query.input.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
void runSearch();
});
// 이미 있는 주소는 열자마자 지도로 보여 준다.
if (initial && initial.trim()) void map.show(initial.trim());
return {
root,
value: () => {
const merged = `${base.input.value.trim()} ${detail.input.value.trim()}`.trim();
return merged || null;
},
};
}
+10
View File
@@ -157,6 +157,16 @@ export function attachModalDismiss(
if (ok) close();
};
// 바탕(패널 바깥) 위에서 굴린 휠이 뒤 화면을 움직이지 않게 막는다
// (2026-09-13 사용자 지시 — 모달을 열어 둔 채 대시보드가 함께 굴렀다).
modal.addEventListener(
"wheel",
(event) => {
if (event.target === modal) event.preventDefault();
},
{ passive: false },
);
// 패널 안에서 시작한 드래그가 바깥에서 끝나도 닫히지 않게 누른 자리까지 본다.
let downOnOverlay = false;
modal.addEventListener("mousedown", (event) => {
@@ -44,6 +44,8 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
createTag(`${state.company.name} (${state.user.status})`, "success"),
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
// 저장한 주소가 화면에 안 보여 「저장이 안 된다」로 읽혔다 (2026-09-13 사용자 지시).
text(`${L("B01_Dashboard_Field_Address")}: ${state.company.business_address ?? "-"}`),
);
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
if (state.user.role !== "USER" && state.company) {
+79 -24
View File
@@ -1,4 +1,5 @@
import { API_BASE_URL } from "@config/config_frontend";
import { createButton } from "@ui/ui_template_elements";
import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
/**
@@ -6,13 +7,23 @@ import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
*
* 지도 라이브러리를 얹지 않는다 — 배경지도 타일 3×3 장을 붙이고 가운데에 표식만 찍는다.
* 등록·수정 화면에서 "이 주소가 여기 맞나" 를 눈으로 보는 것이 목적이다.
* 건물을 알아볼 수 있어야 하므로 기본 배율을 17 로 두고 +·- 로 두 단계씩 움직인다
* (2026-09-13 사용자 지시 — 15 는 너무 멀었다).
*/
const ZOOM = 15;
const DEFAULT_ZOOM = 17;
const MIN_ZOOM = 13;
const MAX_ZOOM = 18;
const TILE = 256;
const GRID = 3;
/** 타일 판은 CSS 에서 절반으로 줄여 붙인다 — 표식 자리도 같은 비율로 잡는다. */
const SCALE = 0.5;
function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number; dy: number } {
const n = 2 ** ZOOM;
function tileIndex(
lat: number,
lon: number,
zoom: number,
): { x: number; y: number; dx: number; dy: number } {
const n = 2 ** zoom;
const rad = (lat * Math.PI) / 180;
const fx = ((lon + 180) / 360) * n;
const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
@@ -22,35 +33,59 @@ function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number
export function buildAddressMap(): {
root: HTMLElement;
show: (address: string) => Promise<void>;
/** 좌표를 이미 아는 경우 — 다시 찾지 않고 바로 그린다. */
showPoint: (lat: number, lon: number) => void;
} {
const root = document.createElement("div");
root.className = "b01-dashboard__map";
const note = document.createElement("p");
note.className = "b01-dashboard__modal-text";
note.textContent = "주소를 넣고 「지도 확인」을 누르십시오.";
root.append(note);
const show = async (address: string): Promise<void> => {
root.innerHTML = "";
if (!address.trim()) {
note.textContent = "주소를 먼저 입력하십시오.";
root.append(note);
return;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
note.textContent = "그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.";
root.append(note);
return;
}
const center = tileIndex(point.lat, point.lon);
const zoomOut = createButton({
label: "",
variant: "ghost",
onClick: () => step(-1),
});
const zoomIn = createButton({
label: "",
variant: "ghost",
onClick: () => step(1),
});
const controls = document.createElement("div");
controls.className = "b01-dashboard__map-zoom";
controls.append(zoomOut, zoomIn);
controls.hidden = true;
const stage = document.createElement("div");
root.append(note, controls, stage);
let zoom = DEFAULT_ZOOM;
let last: { lat: number; lon: number } | null = null;
function step(delta: number): void {
if (!last) return;
const next = Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, zoom + delta));
if (next === zoom) return;
zoom = next;
draw(last.lat, last.lon);
}
function draw(lat: number, lon: number): void {
last = { lat, lon };
note.hidden = true;
controls.hidden = false;
zoomOut.disabled = zoom <= MIN_ZOOM;
zoomIn.disabled = zoom >= MAX_ZOOM;
stage.innerHTML = "";
const center = tileIndex(lat, lon, zoom);
const grid = document.createElement("div");
grid.className = "b01-dashboard__map-grid";
const half = Math.floor(GRID / 2);
for (let row = -half; row <= half; row += 1) {
for (let col = -half; col <= half; col += 1) {
const img = document.createElement("img");
img.src = `${API_BASE_URL}/dashboard/map/tile/${ZOOM}/${center.x + col}/${center.y + row}`;
img.src = `${API_BASE_URL}/dashboard/map/tile/${zoom}/${center.x + col}/${center.y + row}`;
img.width = TILE;
img.height = TILE;
img.alt = "";
@@ -59,14 +94,34 @@ export function buildAddressMap(): {
}
const marker = document.createElement("span");
marker.className = "b01-dashboard__map-marker";
// 타일 판은 CSS 에서 절반으로 줄여 붙이므로 표식 자리도 절반으로 잡는다.
marker.style.left = `${(half + center.dx) * TILE * 0.5}px`;
marker.style.top = `${(half + center.dy) * TILE * 0.5}px`;
marker.style.left = `${(half + center.dx) * TILE * SCALE}px`;
marker.style.top = `${(half + center.dy) * TILE * SCALE}px`;
const frame = document.createElement("div");
frame.className = "b01-dashboard__map-frame";
frame.append(grid, marker);
root.append(frame);
stage.append(frame);
}
function fail(message: string): void {
last = null;
stage.innerHTML = "";
controls.hidden = true;
note.hidden = false;
note.textContent = message;
}
const show = async (address: string): Promise<void> => {
if (!address.trim()) {
fail("주소를 먼저 입력하십시오.");
return;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
fail("그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.");
return;
}
draw(point.lat, point.lon);
};
return { root, show };
return { root, show, showPoint: draw };
}
+5 -18
View File
@@ -33,7 +33,7 @@ import {
type Member,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
import { buildAddressField } from "./B01_Dashboard_UI_AddressField";
import {
attachModalDismiss,
buildUserFields,
@@ -438,28 +438,15 @@ function companyFields(company?: CompanyInfo): {
const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" });
logo.input.type = "file";
logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
const address = createInputField({
label: L("B01_Dashboard_Field_Address"),
value: company?.business_address ?? "",
});
// 주소가 맞는 자리인지 지도로 확인한다 (2026-09-06 사용자 지시).
const map = buildAddressMap();
const mapBtn = createButton({
label: "지도 확인",
variant: "ghost",
onClick: async function onB01_Company_Map_Click() {
await map.show(address.input.value.trim());
},
});
const addressRow = document.createElement("div");
addressRow.append(address.root, mapBtn, map.root);
// 주소는 검색해서 고른다 — 손으로 정확히 치지 않아도 되게 (2026-09-13 사용자 지시).
const address = buildAddressField(company?.business_address ?? null);
return {
rows: [number.root, name.root, owner.root, logo.root, addressRow],
rows: [number.root, name.root, owner.root, logo.root, address.root],
values: () => ({
name: name.input.value.trim(),
business_registration_number: number.input.value.trim(),
business_address: address.input.value.trim() || null,
business_address: address.value(),
business_owner: owner.input.value.trim() || null,
}),
logoFile: () => logo.input.files?.[0],
+95
View File
@@ -186,6 +186,8 @@
width: min(560px, 100%);
max-height: calc(100vh - 2 * var(--spacing-24));
overflow-y: auto;
/* 패널 끝까지 굴려도 뒤 대시보드로 넘어가지 않게 가둔다 (2026-09-13 사용자 지시). */
overscroll-behavior: contain;
background: var(--color-surface-raised);
border-radius: var(--radius-cards);
box-shadow: var(--shadow-lg);
@@ -291,6 +293,99 @@
}
}
/* 회사 주소 입력 — 검색해서 고르는 칸 (2026-09-13).
라벨·입력·버튼을 한 줄에 세운다. ui-field 가 기본이 세로(라벨 위·입력 아래·오류 슬롯)라
그대로 두면 버튼이 오류 슬롯 높이만큼 밀려 수평이 어긋난다 — 이 칸에서만 가로로 눕힌다. */
.b01-dashboard__address {
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
}
.b01-dashboard__address-row {
display: flex;
align-items: center;
gap: var(--spacing-8, 8px);
}
.b01-dashboard__address-row > .ui-field {
display: flex;
flex: 1 1 auto;
flex-direction: row;
align-items: center;
gap: var(--spacing-8, 8px);
min-width: 0;
margin: 0;
}
.b01-dashboard__address .ui-field__label {
flex: 0 0 64px;
margin: 0;
white-space: nowrap;
}
.b01-dashboard__address .ui-input {
flex: 1 1 auto;
min-width: 0;
}
.b01-dashboard__address .ui-field__error {
display: none;
}
.b01-dashboard__address-row > .ui-btn {
flex: 0 0 auto;
}
.b01-dashboard__address-zip {
flex: 0 0 auto;
color: var(--color-text-muted, #666);
font-size: 0.85rem;
white-space: nowrap;
}
.b01-dashboard__address-list {
display: flex;
flex-direction: column;
max-height: 220px;
overflow-y: auto;
overscroll-behavior: contain;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 8px);
}
.b01-dashboard__address-item {
display: flex;
flex-direction: column;
gap: 2px;
padding: var(--spacing-8, 8px);
border: 0;
border-bottom: 1px solid var(--color-border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.b01-dashboard__address-item:last-child {
border-bottom: 0;
}
.b01-dashboard__address-item:hover,
.b01-dashboard__address-item:focus-visible {
background: var(--color-surface-hover, rgba(0, 0, 0, 0.04));
}
.b01-dashboard__address-item small {
color: var(--color-text-muted, #666);
}
.b01-dashboard__map-zoom {
display: flex;
gap: var(--spacing-8, 8px);
margin-bottom: var(--spacing-8, 8px);
}
/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */
.b01-dashboard__map-frame {
position: relative;
@@ -202,6 +202,9 @@ export interface StationTickOptions {
toScreen: (x: number, y: number) => [number, number];
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
avoidChainages?: ReadonlyArray<number>;
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다.
* 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */
uprightRad?: number;
}
export function drawStationTicks(
@@ -274,11 +277,18 @@ export function drawStationTicks(
);
if (collides) continue;
drawn.push({ x: lx, y: ly, half });
context.save();
if (options.uprightRad) {
context.translate(lx, ly);
context.rotate(options.uprightRad);
context.translate(-lx, -ly);
}
// 배경을 깔아 등고선 위에서도 읽히게 한다.
context.fillStyle = "rgba(255, 255, 255, 0.78)";
context.fillRect(lx - half, ly - 8, width, 16);
context.fillStyle = "#222222";
context.fillText(label, lx, ly);
context.restore();
}
context.restore();
}
+178 -217
View File
@@ -42,14 +42,14 @@ export const ROUTE_LINE_WIDTH = 2.4;
* 렌더 시 "화면 오차 < LOD_PX가 되는 정점"만 제외해 어느 줌에서도 시각적 무손실 LOD를 얻는다.
* line 파트에만 존재하며 원본 GeoJSON은 변형하지 않는다.
*/
type PreparedPart = {
export type PreparedPart = {
coords: Float64Array;
closed: boolean;
weights: Float64Array | null;
};
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
type PreparedFeature = {
export type PreparedFeature = {
kind: "line" | "point";
parts: PreparedPart[];
minX: number;
@@ -60,6 +60,8 @@ type PreparedFeature = {
labelAnchorX: number;
labelAnchorY: number;
labelText: string | null;
/** 그 라벨의 표고(m). 어느 줄을 실제로 낼지는 `drawPreparedLabels` 가 줌을 보고 고른다. */
labelValue: number | null;
};
export type PreparedLayer = {
@@ -214,226 +216,81 @@ export function computeRouteView(
};
}
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
/** 화면 px 에 가장 가까운 선 피처의 자리. 그만큼 안에 없으면 -1(계획서 0-9 ⑦). */
export function hitPreparedLayer(
layer: PreparedLayer,
view: ViewState,
px: number,
py: number,
tolerancePx: number,
everyM?: number,
): number {
const affine = affineOf(view);
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
let best = -1;
let bestDistance = tolerancePx;
layer.features.forEach((feature, index) => {
if (feature.kind !== "line") return;
// **그리지 않은 줄은 집히지도 않는다** — 안 보이는 등고선이 골라지면 없던 선이 튀어나온다.
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) return;
// 화면 밖·멀리 있는 피처는 바운딩박스에서 먼저 떨군다 — 도엽 등고선은 수천 가닥이다.
const x0 = feature.minX * affine.ax + affine.bx - tolerancePx;
const x1 = feature.maxX * affine.ax + affine.bx + tolerancePx;
const y0 = feature.minY * affine.ay + affine.by - tolerancePx;
const y1 = feature.maxY * affine.ay + affine.by + tolerancePx;
if (px < x0 || px > x1 || py < y0 || py > y1) return;
for (const part of feature.parts) {
const coords = part.coords;
let lastX = NaN;
let lastY = NaN;
// 그릴 때와 **같은 LOD** 로 훑는다 — 화면에 없는 정점에 걸리면 눈과 손이 어긋난다.
const tolerance = LOD_PX / affine.ax;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// 계곡선(25m 배수)만 라벨 — 전체 표기 시 화면이 숫자로 뒤덮이는 것 방지
if (Number.isFinite(elevation) && elevation % 25 === 0) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
if (part.weights && part.weights[i / 2] < tolerance) continue;
const x = coords[i] * affine.ax + affine.bx;
const y = coords[i + 1] * affine.ay + affine.by;
if (Number.isFinite(lastX)) {
const distance = pointSegmentDistance(px, py, lastX, lastY, x, y);
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
}
lastX = x;
lastY = y;
}
}
features.push({ kind, parts, minX, minY, maxX, maxY, labelAnchorX, labelAnchorY, labelText });
}
return { features };
});
return best;
}
/**
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
},
],
};
/** 레이어 안의 피처 하나만 다시 그린다 — 고른 등고선을 도드라지게 할 때 쓴다. */
export function drawPreparedFeature(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
index: number,
view: ViewState,
): void {
const feature = layer.features[index];
if (!feature || feature.kind !== "line") return;
drawLineParts(context, feature, affineOf(view));
}
/** 점과 선분 사이 거리(px). */
function pointSegmentDistance(
px: number,
py: number,
ax: number,
ay: number,
bx: number,
by: number,
): number {
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared <= 1e-9) return Math.hypot(px - ax, py - ay);
const ratio = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
return Math.hypot(px - (ax + dx * ratio), py - (ay + dy * ratio));
}
/**
@@ -563,6 +420,9 @@ function drawPointParts(
/** 컬링 여백: 선 굵기·X 마커 팔 길이·라벨 폭을 감안한 화면 밖 판정 마진(px). */
const CULL_MARGIN = 32;
/** 등고 라벨끼리 이만큼(px)은 떨어져야 둘 다 낸다 — 가로 여백과 줄 높이. */
const LABEL_GAP_PX = 10;
const LABEL_ROW_PX = 14;
function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): boolean {
const margin = CULL_MARGIN;
@@ -578,41 +438,142 @@ function isVisible(feature: PreparedFeature, affine: Affine, view: ViewState): b
);
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다. */
/** 레이어가 차지하는 **화면 사각형**(px). 피처가 없으면 null.
*
* LAS 등고선처럼 도엽보다 좁은 자료 위에 다른 레이어를 겹칠 때, 그 자료가 있는 데까지만
* 그리려고 쓴다(계획서 0-9 ⑮ — 세류선이 등고선 밖까지 뻗던 자리). */
export function layerScreenBounds(
layer: PreparedLayer,
view: ViewState,
): { x: number; y: number; width: number; height: number } | null {
if (layer.features.length === 0) return null;
const affine = affineOf(view);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const feature of layer.features) {
minX = Math.min(minX, feature.minX);
minY = Math.min(minY, feature.minY);
maxX = Math.max(maxX, feature.maxX);
maxY = Math.max(maxY, feature.maxY);
}
const x0 = minX * affine.ax + affine.bx;
const x1 = maxX * affine.ax + affine.bx;
const y0 = minY * affine.ay + affine.by;
const y1 = maxY * affine.ay + affine.by;
return { x: x0, y: y0, width: x1 - x0, height: y1 - y0 };
}
/** 등고선을 몇 m 마다 낼지 고를 때 훑는 배수. 성긴 쪽으로 한 칸씩 물러난다. */
const LEVEL_STEP_MULTIPLES = [1, 2, 5, 10, 20, 50, 100];
/** 한 화면에 둘 등고선 가닥 수의 어림 상한 — 이보다 많으면 한 칸 성글게 간다. */
const LEVEL_BUDGET = 350;
/**
* 지금 화면에 **몇 m 간격**으로 등고선을 낼지 고른다.
*
* 간격을 줌으로만 정하면 가파른 데서는 여전히 선이 뭉개지고 완만한 데서는 너무 성기다.
* 그래서 **지금 화면에 실제로 들어오는 가닥 수**를 세어 상한을 넘지 않는 가장 촘촘한 간격을
* 고른다 — 확대하면 저절로 촘촘해지고 물러나면 성겨진다(2026-09-12 실화면: 1m LAS 등고선을
* 다 그리면 지형이 선으로 덮였다).
*/
export function pickLevelStep(
layer: PreparedLayer,
view: ViewState,
intervalM: number,
budget = LEVEL_BUDGET,
): number {
const interval = intervalM > 0 ? intervalM : 1;
const affine = affineOf(view);
let step = interval * LEVEL_STEP_MULTIPLES[LEVEL_STEP_MULTIPLES.length - 1];
for (const multiple of LEVEL_STEP_MULTIPLES) {
const candidate = interval * multiple;
let count = 0;
for (const feature of layer.features) {
if (feature.labelValue !== null && feature.labelValue % candidate !== 0) continue;
if (!isVisible(feature, affine, view)) continue;
count += 1;
if (count > budget) break;
}
if (count <= budget) return candidate;
step = candidate;
}
return step;
}
/** 레이어 1개를 그린다. context의 lineWidth/strokeStyle은 호출부에서 설정한다.
*
* `everyM` 을 주면 **그 배수의 표고만** 그린다. 1m 간격 LAS 등고선을 멀리서 다 그리면 화면이
* 선으로 뭉개져 지형이 안 읽힌다 — 확대에 따라 성긴 등고선부터 내보이려는 것이다. 안 주면
* 전부 그리므로 기존 화면(B04 지도·배수유역도)의 표기는 그대로다. */
export function drawPreparedLayer(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
marker: MarkerKind,
everyM?: number,
): void {
const affine = affineOf(view);
const step = everyM !== undefined && everyM > 0 ? everyM : 0;
for (const feature of layer.features) {
if (step && feature.labelValue !== null && feature.labelValue % step !== 0) continue;
if (!isVisible(feature, affine, view)) continue;
if (feature.kind === "point") drawPointParts(context, feature, affine, marker);
else drawLineParts(context, feature, affine);
}
}
/** 사전 계산된 계곡선 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다. */
/** 사전 계산된 등고 라벨을 그린다. 폰트·정렬은 호출부에서 설정한다.
*
* `everyM` 은 **몇 m 마다 한 줄을 라벨할지**다. 기본 25m(계곡선)는 B04 지도가 쓰던 값 그대로다
* — 전부 내면 화면이 숫자로 뒤덮인다. 확대가 큰 화면은 더 작은 값을 넘겨 촘촘히 낸다. */
export function drawPreparedLabels(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
color: string,
everyM = 25,
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. */
uprightRad = 0,
): void {
const affine = affineOf(view);
const margin = CULL_MARGIN;
const step = everyM > 0 ? everyM : 25;
// 이미 찍은 라벨과 겹치면 건너뛴다 — LAS 등고선은 **한 표고가 여러 가닥**으로 끊겨 있어
// 가닥마다 숫자를 내면 화면이 숫자로 덮인다(2026-09-12 실화면). 도엽 계곡선은 원래
// 드물어 이 규칙에 걸리지 않으므로 B04 지도의 표기는 그대로다.
const drawn: Array<{ x: number; y: number; half: number }> = [];
for (const feature of layer.features) {
if (feature.labelText === null) continue;
// 표고를 못 읽은 라벨(값 없음)은 솎지 않고 그대로 낸다.
if (feature.labelValue !== null && feature.labelValue % step !== 0) continue;
const x = feature.labelAnchorX * affine.ax + affine.bx;
const y = feature.labelAnchorY * affine.ay + affine.by;
if (x < -margin || x > view.width + margin) continue;
if (y < -margin || y > view.height + margin) continue;
const half = context.measureText(feature.labelText).width / 2 + LABEL_GAP_PX;
if (
drawn.some(
(item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < LABEL_ROW_PX,
)
) {
continue;
}
drawn.push({ x, y, half });
context.save();
if (uprightRad) {
// 글자 **자리는 그대로** 두고 글자만 되돌린다 — 180°에서 숫자가 뒤집혀 안 읽힌다.
context.translate(x, y);
context.rotate(uprightRad);
context.translate(-x, -y);
}
context.lineWidth = 3;
context.strokeStyle = haloColor();
context.strokeText(feature.labelText, x, y);
context.fillStyle = color;
context.fillText(feature.labelText, x, y);
context.restore();
}
}
@@ -0,0 +1,306 @@
/* =============================================================================
* B04_PreProcess_UI_MapRender_Prepare.ts
* 지도 레이어 **사전 투영** — GeoJSON·사업지 좌표 폴리라인을 정규화 좌표로 펴고,
* 줌 무손실 LOD 가중치(Douglas-Peucker)와 등고 라벨 앵커를 미리 잡아 둔다.
*
* `B04_PreProcess_UI_MapRender.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12).
* 본문 로직과 수치는 그대로다. 그리기는 그쪽, 준비는 이쪽 — 한 방향으로만 기대어
* 순환 참조가 생기지 않는다.
* ========================================================================== */
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
import type {
GeoJsonCollection,
GeoJsonGeometry,
Normalizer,
PreparedFeature,
PreparedLayer,
PreparedPart,
} from "./B04_PreProcess_UI_MapRender";
function isPoint(value: unknown): value is [number, number] {
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
}
/** lon/lat 배열 → 정규화 좌표 Float64Array. 유효 정점이 없으면 null. */
function projectRing(ring: unknown, normalizer: Normalizer): Float64Array | null {
if (!Array.isArray(ring) || ring.length === 0) return null;
const coords = new Float64Array(ring.length * 2);
let count = 0;
for (const point of ring) {
if (!isPoint(point)) continue;
coords[count * 2] = (point[0] - normalizer.lonMin) / normalizer.lonRange;
coords[count * 2 + 1] = 1 - (point[1] - normalizer.latMin) / normalizer.latRange;
count += 1;
}
if (count === 0) return null;
return count * 2 === coords.length ? coords : coords.slice(0, count * 2);
}
function collectParts(
geometry: GeoJsonGeometry,
normalizer: Normalizer,
parts: PreparedPart[],
): "line" | "point" {
const coordinates = geometry.coordinates;
if (!Array.isArray(coordinates)) return "line";
const push = (ring: unknown, closed: boolean): void => {
const projected = projectRing(ring, normalizer);
if (projected) parts.push({ coords: projected, closed, weights: null });
};
switch (geometry.type) {
case "Point":
push([coordinates], false);
return "point";
case "MultiPoint":
push(coordinates, false);
return "point";
case "LineString":
push(coordinates, false);
return "line";
case "MultiLineString":
for (const line of coordinates) push(line, false);
return "line";
case "Polygon":
for (const ring of coordinates) push(ring, true);
return "line";
case "MultiPolygon":
for (const polygon of coordinates) {
if (!Array.isArray(polygon)) continue;
for (const ring of polygon) push(ring, true);
}
return "line";
default:
return "line";
}
}
/**
* Douglas-Peucker 가중치 계산 (반복형, 스택 오버플로 방지).
* weights[i] = "허용 오차가 이 값보다 크면 정점 i를 버려도 되는" 임계값.
* 부모 구간의 오차로 상한을 걸어(cap) 어떤 허용 오차에서도 일관된 부분집합이 나오게 한다.
* y축은 1/aspect로 보정해 화면 픽셀 거리와 비례하는 좌표계에서 계산한다.
*/
function computeDpWeights(coords: Float64Array, aspect: number): Float64Array {
const n = coords.length / 2;
const weights = new Float64Array(n);
weights[0] = Infinity;
weights[n - 1] = Infinity;
if (n <= 2) return weights;
const stack: number[] = [0, n - 1];
const caps: number[] = [Infinity];
while (stack.length) {
const last = stack.pop()!;
const first = stack.pop()!;
const cap = caps.pop()!;
if (last - first < 2) continue;
const ax = coords[first * 2];
const ay = coords[first * 2 + 1] / aspect;
const bx = coords[last * 2];
const by = coords[last * 2 + 1] / aspect;
const dx = bx - ax;
const dy = by - ay;
const len = Math.sqrt(dx * dx + dy * dy);
let maxDist = -1;
let maxIndex = -1;
for (let i = first + 1; i < last; i += 1) {
const px = coords[i * 2] - ax;
const py = coords[i * 2 + 1] / aspect - ay;
const dist = len === 0 ? Math.sqrt(px * px + py * py) : Math.abs(px * dy - py * dx) / len;
if (dist > maxDist) {
maxDist = dist;
maxIndex = i;
}
}
const weight = Math.min(maxDist, cap);
weights[maxIndex] = weight;
stack.push(first, maxIndex, maxIndex, last);
caps.push(weight, weight);
}
return weights;
}
/** 등고 라벨 앵커: LineString/MultiLineString 첫 파트의 중앙 정점 (기존 동작 유지). */
function labelAnchorOf(geometry: GeoJsonGeometry, normalizer: Normalizer): [number, number] | null {
const coords = geometry.coordinates;
if (!Array.isArray(coords)) return null;
const line =
geometry.type === "LineString"
? coords
: geometry.type === "MultiLineString"
? coords[0]
: null;
if (!Array.isArray(line) || line.length === 0) return null;
const mid = line[Math.floor(line.length / 2)];
if (!isPoint(mid)) return null;
return [
(mid[0] - normalizer.lonMin) / normalizer.lonRange,
1 - (mid[1] - normalizer.latMin) / normalizer.latRange,
];
}
/**
* GeoJSON 컬렉션 1개를 사전 투영한다.
* labelKeys가 주어지면 계곡선(25m 배수) 피처에만 라벨 텍스트·앵커를 계산해 둔다.
*/
export function prepareLayer(
collection: GeoJsonCollection | undefined,
normalizer: Normalizer,
labelKeys?: string[],
): PreparedLayer {
const features: PreparedFeature[] = [];
for (const feature of collection?.features ?? []) {
if (!feature.geometry) continue;
const parts: PreparedPart[] = [];
const kind = collectParts(feature.geometry, normalizer, parts);
if (parts.length === 0) continue;
if (kind === "line") {
for (const part of parts) {
if (part.coords.length < 6) continue;
part.weights = computeDpWeights(part.coords, normalizer.aspect);
}
}
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
for (const part of parts) {
const coords = part.coords;
for (let i = 0; i < coords.length; i += 2) {
const x = coords[i];
const y = coords[i + 1];
if (x < minX) minX = x;
if (x > maxX) maxX = x;
if (y < minY) minY = y;
if (y > maxY) maxY = y;
}
}
let labelText: string | null = null;
let labelValue: number | null = null;
let labelAnchorX = 0;
let labelAnchorY = 0;
if (labelKeys && labelKeys.length > 0) {
const raw = labelKeys.map((key) => feature.properties?.[key]).find((value) => value != null);
const elevation = typeof raw === "number" ? raw : Number(raw);
// **모든 등고선**에 앵커를 잡아 둔다. 어느 줄을 실제로 낼지는 그릴 때 고른다 —
// 화면마다 솎는 눈금이 다르기 때문이다(B04 지도는 계곡선만, 계획노선 편집 모달은
// 확대에 따라 더 촘촘히). 준비 단계에서 걸러 버리면 확대해도 되살릴 수가 없다.
if (Number.isFinite(elevation)) {
const anchor = labelAnchorOf(feature.geometry, normalizer);
if (anchor) {
labelText = String(elevation);
labelValue = elevation;
labelAnchorX = anchor[0];
labelAnchorY = anchor[1];
}
}
}
features.push({
kind,
parts,
minX,
minY,
maxX,
maxY,
labelAnchorX,
labelAnchorY,
labelText,
labelValue,
});
}
return { features };
}
/**
* 사업지 좌표계(m) 폴리라인을 한 개 피처짜리 레이어로 사전 투영한다.
* meta의 x/y 범위와 lon/lat 범위는 같은 사각형을 가리키므로, 미터 좌표도 GeoJSON과 동일한
* 정규화 공간으로 들어간다 — 노선 선형을 도엽 레이어 위에 그대로 겹칠 수 있다.
*/
export function prepareMetricPolyline(
points: ReadonlyArray<{ x: number; y: number }>,
meta: VWorldMeta,
): PreparedLayer {
if (points.length < 2) return { features: [] };
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const coords = new Float64Array(points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
points.forEach((point, index) => {
const nx = (point.x - meta.x_min) / widthMeters;
const ny = 1 - (point.y - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
return {
features: [
{
kind: "line",
parts: [{ coords, closed: false, weights: null }],
minX,
minY,
maxX,
maxY,
labelAnchorX: 0,
labelAnchorY: 0,
labelText: null,
labelValue: null,
},
],
};
}
/**
* 사업지 좌표계(m) 폴리라인 **여러 개**를 한 레이어로 사전 투영한다(LAS 등고선 등).
*
* `prepareMetricPolyline` 의 여러 줄 판이다. 줄마다 `label`(표고 m)을 주면 가운데 정점을
* 앵커로 잡아 `drawPreparedLabels` 가 그대로 쓸 수 있다.
*/
export function prepareMetricPolylines(
lines: ReadonlyArray<{ points: ReadonlyArray<readonly [number, number]>; label?: number }>,
meta: VWorldMeta,
): PreparedLayer {
const widthMeters = meta.width_meters || 1;
const heightMeters = meta.height_meters || 1;
const features: PreparedFeature[] = [];
for (const line of lines) {
if (line.points.length < 2) continue;
const coords = new Float64Array(line.points.length * 2);
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
line.points.forEach((point, index) => {
const nx = (point[0] - meta.x_min) / widthMeters;
const ny = 1 - (point[1] - meta.y_min) / heightMeters;
coords[index * 2] = nx;
coords[index * 2 + 1] = ny;
if (nx < minX) minX = nx;
if (nx > maxX) maxX = nx;
if (ny < minY) minY = ny;
if (ny > maxY) maxY = ny;
});
const middle = Math.floor(line.points.length / 2) * 2;
features.push({
kind: "line",
parts: [
{ coords, closed: false, weights: computeDpWeights(coords, widthMeters / heightMeters) },
],
minX,
minY,
maxX,
maxY,
labelAnchorX: coords[middle],
labelAnchorY: coords[middle + 1],
labelText: line.label === undefined ? null : String(line.label),
labelValue: line.label ?? null,
});
}
return { features };
}
@@ -33,8 +33,6 @@ import {
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
prepareLayer,
prepareMetricPolyline,
routeLineColor,
ROUTE_LINE_WIDTH,
type GeoJsonCollection,
@@ -44,6 +42,7 @@ import {
type PreparedLayer,
type ViewState,
} from "./B04_PreProcess_UI_MapRender";
import { prepareLayer, prepareMetricPolyline } from "./B04_PreProcess_UI_MapRender_Prepare";
import { drawStationTicks } from "./B04_PreProcess_UI_MapOverlays";
import type { WatershedAnalysis } from "./B04_PreProcess_Api_Fetch";
+59 -1
View File
@@ -56,8 +56,12 @@ export interface RoutePlanResponse {
nodes: RoutePlanNode[];
/** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */
curves: RoutePlanCurve[];
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m) — **기본값·위반 표시 기준**. */
min_radius_m: number;
/** **못 넘는** 곡선반지름 하한(m). 0이면 제한 없음(작업임도). 기본값과 다른 값이다. */
limit_radius_m?: number;
/** **못 넘는** 곡선 길이 하한(m). 0이면 제한 없음 — 지금은 전부 0(법에 값이 없음). */
limit_curve_length_m?: number;
curve_count: number;
violation_count: number;
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
@@ -128,6 +132,60 @@ export async function replanRoute(
);
}
/** 점 묶음의 **지반고**를 묻는다(계획서 0-9 ⑤·⑧).
*
* 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안
* 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다. */
export async function fetchRouteElevations(
projectId: string,
points: Array<[number, number]>,
): Promise<Array<number | null>> {
const payload = await requestJson<{ z: Array<number | null> }>(
`/projects/${projectId}/route/elevations`,
{ method: "POST", body: JSON.stringify({ points }) },
60000,
);
return payload.z;
}
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
export interface CrossPreviewResponse {
status: string;
chainage_m: number;
label: string | null;
uphill_side: string | null;
plan_radius_m: number | null;
curve_widening_m: number | null;
/** 원지반 횡단 샘플. */
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
design: {
design_line: Array<{ offset_m: number; elevation_m: number }>;
cut_area_m2: number;
fill_area_m2: number;
[key: string]: unknown;
} | null;
}
export interface CrossPreviewRequest {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
chainage_m: number;
min_radius_m: number;
station_interval_m: number;
}
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
export async function fetchCrossPreview(
projectId: string,
request: CrossPreviewRequest,
): Promise<CrossPreviewResponse> {
return requestJson<CrossPreviewResponse>(
`/projects/${projectId}/route/cross-preview`,
{ method: "POST", body: JSON.stringify(request) },
120000,
);
}
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
return requestJson<RouteReplanResponse>(
+11
View File
@@ -31,6 +31,17 @@ export interface StructureOptionField {
/** 입력 시점 — B05는 유무·종류·위치만 받고 상세 치수(detail)는 B06/B07에서 받는다
* (2026-08-17 사용자 확정). detail이면 required여도 B05 폼에 그리지 않는다. */
phase?: "b05" | "detail";
/** **비워 두는 것이 뜻인 칸** — 비면 계산 쪽이 기준값으로 돌고 그 사실이 사유로 뜬다.
* ⚠ 이 칸의 select 는 **빈 보기를 둔다** — 첫 보기를 슬쩍 고르면 기준값과 다른 값이
* 조용히 저장된다(2026-09-13: `fill_concrete_mpa` 가 180 으로 박혀 엔진 기준 210 과 갈렸다). */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음) — 기준값과 까닭 한 줄. */
warn_above?: number | null;
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 칸 이름 옆 「표에 안 쓰임」 + 툴팁 사유. */
not_in_table?: string | null;
/** 기본값의 뜻(도메인 확정값이 아닐 때) — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/** 구조물 배치 폼을 어느 화면이 쓰는가 — B05 는 유무·종류·위치만, **B06/B07 은 상세
+31
View File
@@ -151,6 +151,37 @@ def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal")
return float(speeds[terrain])
def plan_radius_limit_m(
grade_class: str,
design_speed_kph: int | None = None,
terrain_type: str = "normal",
) -> float:
"""계획노선 편집 화면이 **못 넘게 막을** 평면 곡선반지름 하한(m). 0이면 제한 없음.
위 `legal_plan_radius_min_m` 은 **기본값·위반 표시 기준**이고 이것은 **제한**이다
(2026-09-12 사용자 확정: 「아예 못 넘게 막음」). 임도 종류별 칸이 비어 있으면(None)
법정 표를 그대로 하한으로 쓰고, 값이 적혀 있으면 그 값을 쓴다 — 작업임도는 별표2에
곡선반지름 규정이 없어 0(제한 없음)으로 열려 있다.
"""
table = FOREST_ROAD_PROFILE_CRITERIA["plan_radius_limit_by_grade_m"]
override = table.get(grade_class)
if override is not None:
return float(override)
return legal_plan_radius_min_m(
resolve_design_speed(grade_class, design_speed_kph), terrain_type
)
def plan_curve_length_limit_m(grade_class: str) -> float:
"""평면 **곡선 길이(L)** 하한(m). 0이면 제한 없음.
법령·교본에 값이 없어 지금은 임도 종류 전부 0이다 — 자리만 열어 둔 칸이라
실무값이 정해지면 `config_system_design` 의 표만 고치면 된다(2026-09-12 사용자 확정).
"""
table = FOREST_ROAD_PROFILE_CRITERIA["plan_curve_length_limit_by_grade_m"]
return float(table.get(grade_class) or 0.0)
def _pick(*candidates: Any) -> Any:
"""요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다."""
for value in candidates:
+38 -1
View File
@@ -37,7 +37,11 @@ from common_util.common_util_drainage_pipes import (
route_signature,
)
from common_util.common_util_json import atomic_write_json
from common_util.common_util_route_geometry import RouteVertex
from common_util.common_util_route_geometry import (
RouteVertex,
planned_route_initial_path,
planned_route_working_path,
)
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_system import (
DRAINAGE_CACHE_DIRNAME,
@@ -66,6 +70,34 @@ def _load_route_polyline(project_root: Path, route_data_path: str) -> list[list[
return [[float(c[0]), float(c[1]), float(c[2]) if len(c) > 2 else 0.0] for c in coords]
def _load_design_curves(project_root: Path) -> list[dict[str, Any]]:
"""설계가 쓰는 계획노선의 **곡선표** — 없으면 빈 목록.
곡선표는 폴리라인 파일 옆에 같은 이름으로 선다(`_curves.json`). 정점 목록만으로는
어디부터 어디까지가 곡선이고 반지름이 얼마인지 없어, 곡선부 확폭을 측점마다
반경을 다시 재는 방식으로 매기면 같은 곡선 안에서도 값이 갈린다(2026-09-12 실측).
수정본(`planned_route.csv`) 있으면 ** 곡선표만** 쓴다 노선을 고쳤는데 초기본
곡선표를 읽으면 있지도 않은 자리에 확폭이 붙는다. 곡선표가 없으면 목록을 돌려주고,
받는 쪽이 방식(측점별 실측 반경)으로 물러선다.
"""
route_path = planned_route_working_path(project_root)
if not route_path.is_file():
route_path = planned_route_initial_path(project_root)
target = route_path.with_name(f"{route_path.stem}_curves.json")
if not target.is_file():
return []
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선표를 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
return (
[curve for curve in curves if isinstance(curve, dict)] if isinstance(curves, list) else []
)
def cross_filename(chainage_m: float) -> str:
"""측점 chainage에 대응하는 횡단면 파일명(단일 규칙)."""
return f"cross_{int(round(float(chainage_m))):05d}m.json"
@@ -361,6 +393,9 @@ def run_section_generation(
replace(options or SectionGenerationOptions(), extra_stations=extras),
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
# 곡선부 확폭의 정본 — 설계 곡선표(시·종점·반지름)를 그대로 쓴다. 없으면 빈 목록이라
# 종전의 측점별 실측 반경으로 물러선다.
design_curves=_load_design_curves(project_root),
)
stage_root = project_root / _STAGE_SUBDIR
@@ -473,6 +508,8 @@ def generate_irregular_sections(
merged_options,
source_snapshot={"filter": filter_key, "method": method, "smooth": smooth},
crs=crs,
# 비정규(구조물) 측점도 같은 곡선표를 봐야 규칙 측점과 확폭이 어긋나지 않는다.
design_curves=_load_design_curves(project_root),
)
irregular_stations = [
station
+111 -5
View File
@@ -213,6 +213,101 @@ def _curve_widenings(
return widenings, sides
#: 곡선표의 시·종점이 노선 폴리라인에서 이만큼 떨어져 있으면 그 노선의 곡선이 아니라고 본다(m).
#: 노선을 잘라 쓰면(지표면 밖 트림) 곡선표에 남은 옛 곡선이 노선 밖에 뜬다.
DESIGN_CURVE_MATCH_TOLERANCE_M = 5.0
def _project_chainage(
points: np.ndarray, route_chainage: np.ndarray, xy: tuple[float, float]
) -> tuple[float, float]:
"""점을 노선 폴리라인 위로 내려 (떨어진 거리 m, 누가거리 m)."""
starts = points[:-1, :2]
vectors = points[1:, :2] - starts
lengths2 = np.einsum("ij,ij->i", vectors, vectors)
safe = np.where(lengths2 > 1e-12, lengths2, 1.0)
target = np.asarray(xy, dtype=np.float64)
ratios = np.clip(np.einsum("ij,ij->i", target - starts, vectors) / safe, 0.0, 1.0)
feet = starts + vectors * ratios[:, None]
distances = np.hypot(feet[:, 0] - target[0], feet[:, 1] - target[1])
index = int(np.argmin(distances))
span = route_chainage[index + 1] - route_chainage[index]
return float(distances[index]), float(route_chainage[index] + span * ratios[index])
def _design_curve_spans(
points: np.ndarray,
route_chainage: np.ndarray,
curves: list[dict[str, Any]],
) -> list[tuple[float, float, float, str]]:
"""설계 곡선표 → [(시점 누가거리, 종점 누가거리, 반지름 m, 곡선 **바깥쪽**)].
바깥쪽은 회전 방향으로 가른다 시점교점종점의 외적 z가 양수면 좌회전이라 안쪽이
좌측이고 바깥은 우측이다(`_plan_radii` 같은 규약). 노선에서 멀리 떨어진 곡선과
방향을 재는 곡선은 버린다.
"""
spans: list[tuple[float, float, float, str]] = []
for curve in curves:
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_chainage = _project_chainage(points, route_chainage, tuple(start[:2]))
end_gap, end_chainage = _project_chainage(points, route_chainage, tuple(end[:2]))
if max(start_gap, end_gap) > DESIGN_CURVE_MATCH_TOLERANCE_M:
continue
cross = (apex[0] - start[0]) * (end[1] - apex[1]) - (apex[1] - start[1]) * (
end[0] - apex[0]
)
if abs(cross) < 1e-12:
continue
spans.append(
(
min(start_chainage, end_chainage),
max(start_chainage, end_chainage),
float(radius),
"right" if cross > 0 else "left",
)
)
return sorted(spans)
def _design_curve_widenings(
station_chainage: np.ndarray,
spans: list[tuple[float, float, float, str]],
) -> tuple[list[float | None], list[str | None], list[float]]:
"""설계 곡선표로 측점별 (평면 곡선반경, 바깥쪽, 확폭량)을 낸다.
곡선 **** 곡선의 설계 반경이 그대로 반경이고 확폭도 표값 값이다. 곡선 앞뒤
`CURVE_WIDENING_TAPER_M` 구간은 0 으로 잇고(실무 관행 직선 테이퍼), 밖은 확폭이
없다. 곡선이 겹치면 **확폭이 ** 따르고, 반경은 작은 (급한 ) 남긴다.
"""
count = len(station_chainage)
radii: list[float | None] = [None] * count
sides: list[str | None] = [None] * count
widenings: list[float] = [0.0] * count
for start, end, radius, side in spans:
table = curve_widening_m(radius)
for index, value in enumerate(station_chainage):
chainage = float(value)
inside = start - 1e-9 <= chainage <= end + 1e-9
if inside and (radii[index] is None or radius < radii[index]):
radii[index] = round(radius, 3)
if table <= 0.0:
continue
if inside:
amount = table
else:
distance = start - chainage if chainage < start else chainage - end
if distance > CURVE_WIDENING_TAPER_M:
continue
amount = round(table * (1.0 - distance / CURVE_WIDENING_TAPER_M), 4)
if amount > widenings[index]:
widenings[index] = amount
sides[index] = side
return radii, sides, widenings
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
@@ -220,6 +315,7 @@ def generate_sections(
*,
source_snapshot: dict[str, Any] | None = None,
crs: str | None = None,
design_curves: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다."""
options = options or SectionGenerationOptions()
@@ -267,11 +363,21 @@ def generate_sections(
# 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반
# 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세
# 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다.
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
# **설계 곡선표가 있으면 그것이 정본**이다(2026-09-12 사용자 지시). 측점마다 반경을
# 다시 재면 같은 곡선 안에서도 확폭이 갈리고, 곡선이 측점 간격보다 짧으면 통째로
# 빠진다(실측: 설계 12m 곡선에 1.50m 이 붙고 곡선 밖 직선까지 흘러나갔다).
# 곡선표가 없는 옛 프로젝트만 종전의 측점별 실측으로 물러선다.
spans = _design_curve_spans(points, route_chainage, design_curves or [])
if spans:
plan_radii, plan_outer_sides, plan_widenings = _design_curve_widenings(
station_chainage, spans
)
else:
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
offsets = np.arange(
-options.cross_half_width_m,
+31 -6
View File
@@ -133,7 +133,19 @@ def _ensure_expected_route(project_root: Path) -> str:
async def _min_plan_radius_m(project_id: UUID) -> float:
"""이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다.
"""기본 반지름만 필요한 자리 — 하한까지 필요하면 `_plan_criteria` 를 쓸 것."""
criteria = await _plan_criteria(project_id)
return criteria[0]
async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]:
"""이 프로젝트의 **기본 반지름 · 반지름 하한 · 곡선 길이 하한**(m) 세 값.
기본 반지름은 곡선을 만들 쓰는 값이고, 하한 둘은 **화면이 넘게 막는** 값이다
(2026-09-12 사용자 확정). 둘을 값으로 묶으면 하한 0 반지름 0 되어 곡선이
아예 그려지므로 반드시 갈라 둔다.
기본 반지름은 임도 종류·설계속도·지형으로 고른다.
값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 .2.)이고 산식은 이미
`B05_Profile_Engine_Grade.legal_plan_radius_min_m` 있다 여기서 다시 짜지 않는다.
@@ -145,7 +157,12 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
"""
import aiomysql
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed
from B05_Profile.B05_Profile_Engine_Grade import (
legal_plan_radius_min_m,
plan_curve_length_limit_m,
plan_radius_limit_m,
resolve_design_speed,
)
from common_util.common_util_workflow_state import get_workflow_state
grade_class, design_speed, terrain = "work", None, "special"
@@ -172,7 +189,11 @@ async def _min_plan_radius_m(project_id: UUID) -> float:
terrain = str(params["terrain_type"])
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain)
return (
legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain),
plan_radius_limit_m(grade_class, design_speed, terrain),
plan_curve_length_limit_m(grade_class),
)
def _nodes_path(path: Path) -> Path:
@@ -404,7 +425,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
if not expected:
expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root))
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root))
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
@@ -445,6 +466,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
# (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다.
"curves": saved_curves or [curve.as_dict() for curve in outline.curves],
"min_radius_m": round(radius_m, 2),
# **못 넘는 하한** — 기본값(`min_radius_m`)과 다른 값이다. 0이면 제한 없음
# (작업임도는 별표2에 곡선반지름 규정이 없어 0으로 열려 있다, 2026-09-12 확정).
"limit_radius_m": round(radius_limit_m, 2),
"limit_curve_length_m": round(arc_limit_m, 2),
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
"violation_count": outline.violation_count,
"edited": bool(working),
@@ -468,7 +493,7 @@ async def replan_route(
# 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다.
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
@@ -527,7 +552,7 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working_path = planned_route_working_path(project_root)
if working_path.is_file():
+221
View File
@@ -0,0 +1,221 @@
"""계획노선 편집 중 **지반고만** 묻는 가벼운 통로.
편집 모달은 [확인] 전까지 아무 계산도 내보내지 않는다(계획서 0-2 확정 7). 다만 점을 찍어
**구간 길이와 종단기울기** (0-9 ) 측점의 **횡단도 미리보기**() 지반고가
있어야 한다. 계산이 아니라 **이미 확정된 지표면을 읽기만** 하는 통로라 규칙과 부딪히지
않는다 노선을 갈아 끼우지도, 정본을 건드리지도 않는다.
표고 조회는 ·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`) 연다.
화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
POST /api/projects/{id}/route/elevations 묶음의 지반고
POST /api/projects/{id}/route/cross-preview 고치던 노선의 측점 횡단 미리보기
"""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
import numpy as np
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_Sections_Core import (
SectionGenerationOptions,
generate_sections,
)
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from common_util.common_util_route_polyline import build_planned_polyline
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_surface_sampler import build_surface_sampler
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Terrain"])
_MODELS_SUBDIR = Path("B04_PreProcess") / "models"
#: 한 번에 물을 수 있는 점 수. 구간 재기는 수십 점, 횡단 한 장은 수백 점이면 넉넉하다 —
#: 상한을 두어 실수로 노선 전체를 밀어 넣는 일을 막는다.
MAX_POINTS = 4000
class ElevationRequest(BaseModel):
"""사업지 좌표계(m) 점 묶음 [[x, y], …]."""
points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS)
def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]):
"""확정 지표면에서 표고를 읽는다. 모델을 못 열면 None."""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("계획노선 편집: 지표면을 열지 못했습니다 — %s", exc)
return None
z, valid = sampler.sample_xy(np.asarray(points, dtype=np.float64))
return z, valid
@router.post("/{project_id}/route/elevations", response_model=None)
async def read_route_elevations(project_id: UUID, request: ElevationRequest) -> dict | JSONResponse:
"""점 묶음의 지반고(m)와 유효 여부를 돌려준다.
지표면 밖이거나 자료가 없는 자리는 `valid=false` 나가고 표고는 `null` 이다
**임의 표고로 메우지 않는다**(sampler 규칙 그대로). 화면은 자리를 모름으로 낸다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored))
sampled = await asyncio.to_thread(_sample, project_root, params, request.points)
if sampled is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 지반고를 읽을 수 없습니다.",
},
)
z, valid = sampled
return {
"status": "success",
"project_id": str(project_id),
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
"valid": [bool(ok) for ok in valid],
}
class PreviewVertex(BaseModel):
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
x: float
y: float
curve: bool = True
radius_m: float | None = None
class CrossPreviewRequest(BaseModel):
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
vertices: list[PreviewVertex] = Field(..., min_length=2)
chainage_m: float = Field(..., ge=0)
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
min_radius_m: float = Field(12.0, gt=0)
station_interval_m: float | None = None
def _cross_preview(
project_root: Path,
params: dict,
request: CrossPreviewRequest,
) -> dict | None:
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
**B05·B06 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 기본 로직은 B06에
존재함. 재사용) `generate_sections` 측점·접선·지반 샘플을, `compute_cross_design`
설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
**계획고는 아직 없다.** 계획고는 [확인] 체인이 낳는 값이라 편집 중에는 존재하지
않는다. 그래서 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) ·성토가 사면
기울기만으로 서는 기본 계획 횡단이며, 사용자가 보기로 것도 그것이다.
"""
try:
sampler = build_surface_sampler(
project_root / _MODELS_SUBDIR,
str(params["source_filter"]),
str(params["method"]),
bool(params["smooth"]),
)
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
return None
built = build_planned_polyline(
[(vertex.x, vertex.y) for vertex in request.vertices],
min_radius_m=request.min_radius_m,
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
simplify=False,
curve_flags=[vertex.curve for vertex in request.vertices],
radii=[vertex.radius_m for vertex in request.vertices],
)
interval = request.station_interval_m
options = (
SectionGenerationOptions(station_interval_m=float(interval))
if interval and interval > 0
else SectionGenerationOptions()
)
result = generate_sections(built.vertices, sampler, options)
sections = result["cross_sections"]
if not sections:
return None
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
design = None
center_z = section.get("center_z")
if center_z is not None:
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
design = compute_cross_design(
section["samples"],
float(center_z),
ground_type="soil",
section_mode=section_mode,
**curve_widening_args(section),
)
return {
"chainage_m": round(float(section["chainage_m"]), 3),
"label": section.get("label"),
"uphill_side": section.get("uphill_side"),
"plan_radius_m": section.get("plan_radius_m"),
"curve_widening_m": section.get("curve_widening_m"),
"samples": section["samples"],
"design": design,
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
if result.get("longitudinal", {}).get("total_length_m") is not None
else None,
}
@router.post("/{project_id}/route/cross-preview", response_model=None)
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
정본을 건드리지 않는다 파일도 DB 쓰지 않고 자리에서 셈해 돌려주기만 한다.
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
)
params = await get_surface_confirmation_params(connection, str(project_id))
project_root = Path(resolve_stored_project_path(stored))
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
if preview is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
},
)
return {"status": "success", "project_id": str(project_id), **preview}
+171 -35
View File
@@ -134,9 +134,10 @@
"label": "유입 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "outlet_type",
@@ -186,9 +187,10 @@
"label": "유출 기슭막이 높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "detail"
"phase": "detail",
"empty_means": "비우면 관경 기준 최소 높이(관경 + 여유 0.5 를 0.1m 로 올림 + 근입 0.5)로 그리고 셈 — 횡단도·구조물도·표가 같은 값 · 최소라 수량은 미확정(금액 밖) · 2026-09-14 브레인 판정(옛 기본 2.5 걷음)"
},
{
"key": "revet_foundation",
@@ -332,6 +334,26 @@
"unit": "%",
"default": null,
"required": false
},
{
"key": "thickness_cm",
"label": "포장 두께",
"input": "number",
"unit": "㎝",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 표가 안 서고 「두께를 적어야 섬」 사유가 뜸 — 두께로 관측 원단위(울진 20㎝)를 고름(2026-09-14 A3)"
},
{
"key": "length_m",
"label": "포장 길이(노폭 방향)",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 면적(월류 폭 × 길이)이 0 이라 표가 안 서고 사유가 뜸 — 노견까지 전폭을 적음(2026-09-14 A3)"
}
]
},
@@ -479,6 +501,15 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 붙박이(콘크리트 개거)로 돎"
},
{
"key": "length_m",
"label": "연장",
"input": "number",
"unit": "m",
"default": null,
"required": false,
"empty_means": "비우면 표가 안 서고 「연장을 적어야 섬」 사유가 뜸 — 개거는 m당 원단위라 연장이 곧 밑수(2026-09-14 A2)"
}
]
},
@@ -695,9 +726,11 @@
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": 2.0,
"default_basis": "기본값 · 소광리 도면 H=2.0 · 바꿀 수 있음",
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -783,7 +816,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -888,6 +922,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -924,6 +968,17 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "blinding_concrete",
"label": "버림 콘크리트",
"input": "select",
"choices": ["넣음", "안 넣음"],
"default": "넣음",
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
"required": false,
"phase": "detail",
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
}
]
},
@@ -945,7 +1000,10 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)",
"warn_above": 2.0,
"warn_message": "메쌓기 높이 기준을 넘음 — 임도기술교본 7-3:27 「메쌓기는 2.0m 이하」 · 품셈 13-4-4 [주]⑪(원문 L7192) 「높이 3m 이상이면 전부 또는 하부를 찰쌓기」 · 막지 않음(설계자 확인)"
},
{
"key": "length_m",
@@ -1050,6 +1108,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -1086,6 +1154,17 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "blinding_concrete",
"label": "버림 콘크리트",
"input": "select",
"choices": ["넣음", "안 넣음"],
"default": "넣음",
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
"required": false,
"phase": "detail",
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
}
]
},
@@ -1200,7 +1279,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "thickness_bottom_m",
@@ -1210,7 +1290,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "back_len_cm",
@@ -1220,7 +1301,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_kind",
@@ -1230,7 +1312,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 야면석 계수 열로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_supply",
@@ -1240,7 +1323,8 @@
"default": "채집",
"required": false,
"phase": "detail",
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음"
"default_basis": "「채집」 — 별표2 「야면석 등은 가급적 현장에서 채취·사용」 권고. 구조물마다 바꿀 수 있음",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1250,7 +1334,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1260,7 +1345,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1269,7 +1355,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "foundation",
@@ -1288,7 +1375,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "weep_hole_area_m2",
@@ -1298,7 +1386,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 흙막이는 「떼」만 서고 치수가 정본 평균 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
}
]
},
@@ -1320,7 +1409,8 @@
"unit": "m",
"default": 2.5,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "비워도 놓임 — 줄만 서고 미확정이라 금액에 안 들어감(높이를 적으면 섬 · 2026-09-14 브레인 판정 ①)"
},
{
"key": "length_m",
@@ -1374,7 +1464,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1384,7 +1475,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1394,7 +1486,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기(13-6)는 채움 콘크리트를 품에 포함 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1430,7 +1523,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 Ø50 으로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "weep_hole_area_m2",
@@ -1440,7 +1534,19 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」",
"not_in_table": "아직 표에 안 쓰임 — 큰돌쌓기는 물빼기 관을 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "blinding_concrete",
"label": "버림 콘크리트",
"input": "select",
"choices": ["넣음", "안 넣음"],
"default": "넣음",
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
"required": false,
"phase": "detail",
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
}
]
},
@@ -1557,7 +1663,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 실무 구조물도 식으로 돎(뒷길이 + 0.30) — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "thickness_bottom_m",
@@ -1567,7 +1674,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 상부 + 0.30×(H−1) 로 돎 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 골막이 두께는 정본 식(ℓ3+0.1H · ℓ3+0.4H)으로만 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "back_len_cm",
@@ -1585,7 +1693,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 골막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1595,7 +1704,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 골막이는 계수 열을 아직 안 가름 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1706,7 +1816,8 @@
"choices": ["채집", "구입"],
"default": "채집",
"required": false,
"phase": "detail"
"phase": "detail",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 채집석 공제를 아직 안 셈 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "stone_coeff_basis",
@@ -1716,7 +1827,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀"
"empty_means": "비우면 품셈 열로 돎 — 실무 관행 열은 골라야 씀",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "fill_concrete_mpa",
@@ -1726,7 +1838,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 계산 쪽 기준 강도로 돎"
"empty_means": "비우면 계산 쪽 기준 강도로 돎",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이 원단위는 돌붙임 정본 표 붙박이 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "face_slope_ratio",
@@ -1735,7 +1848,8 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김",
"not_in_table": "아직 표에 안 쓰임 — 바닥막이는 평면적(돌붙임 ㎡)이라 기울기 몫이 없음 · 넣어도 수량·금액이 안 바뀜(2026-09-14)"
},
{
"key": "foundation",
@@ -1877,7 +1991,7 @@
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 돌쌓기 기본값으로 서고 「기본값으로 섰음」이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
"empty_means": "비우면 품셈 13-4-4 [주]⑩ 뒷길이 표준 하한(높이·찰/메로 · 범위 안 가장 작은 규격 — 예 메쌓기 1.5~3m 는 36~45㎝ 라 45㎝)으로 서고 그 사실이 줄로 뜸 — 돌이 아닌 형태도 있어 필수로 못 검"
},
{
"key": "stone_kind",
@@ -1927,6 +2041,16 @@
"phase": "detail",
"empty_means": "비우면 품셈 표준경사로 자동 판정 — 값을 넣으면 그 값이 이김"
},
{
"key": "face_role",
"label": "성토/절토",
"input": "select",
"choices": ["성토", "절토"],
"default": null,
"required": false,
"phase": "detail",
"empty_means": "비우면 측점 단면유형 + 설치 측으로 자동 판정(계산이지 기본값이 아님) — 품셈 13-4-4 [주]⑪ 표준경사(원문 L7185~7191)가 성토/절토로 갈림 · 고르면 그 값이 이김"
},
{
"key": "foundation",
"label": "기초",
@@ -1941,9 +2065,10 @@
"label": "높이",
"input": "number",
"unit": "m",
"default": 2.5,
"default": null,
"required": false,
"phase": "b05"
"phase": "b05",
"empty_means": "설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181 · 사방(계류) 교본 기준). 비우면 줄만 서고 미확정이라 금액에 안 들어감 · 옛 기본 2.5 는 근거 없어 걷음(2026-09-14 브레인 판정)"
},
{
"key": "length_m",
@@ -1991,6 +2116,17 @@
"required": false,
"phase": "detail",
"empty_means": "비우면 국가기준 2㎡당 1개로 돎 — 사용자 확정 「국가기준 + 숫자 변경 가능하게」"
},
{
"key": "blinding_concrete",
"label": "버림 콘크리트",
"input": "select",
"choices": ["넣음", "안 넣음"],
"default": "넣음",
"default_basis": "사용자 확정 ⑭ 「기본은 넣음」 — 두께는 KDS 44 90 00 의 100㎜",
"required": false,
"phase": "detail",
"empty_means": "비워도 넣음 — 「안 넣음」을 골라야 그 줄이 빠짐(B08 `wants_blinding`)"
}
]
},
@@ -60,6 +60,13 @@ class StructureOptionField(BaseModel):
# 기본값이 **도메인 확정값이 아닐 때** 그 뜻을 적는다(예: 「다단 없음」·「안 더함」).
# 법정·확정 수치면 비워 둔다 — 비어 있는 것이 「확정값」이라는 뜻이다.
default_basis: str | None = None
# 이 값을 **넘으면** 칸이 경고색 + 툴팁 — 막지 않음(2026-09-14 브레인 판정 ①·㉰ 「놓기를 막지 말 것」).
# 넘는지 보는 기준값과 그 까닭 한 줄(원문 쪽·줄 번호).
warn_above: float | None = None
warn_message: str | None = None
# 칸은 있는데 **원단위 표가 아직 안 읽는** 칸 — 넣어도 수량·금액이 안 바뀜을 칸 옆에 보임(2026-09-14 ①).
# 참말인지는 `test_b05_not_in_table_marks` 가 값을 바꿔 돌려 보고 지킴.
not_in_table: str | None = None
class StructureType(BaseModel):
+104 -194
View File
@@ -26,10 +26,6 @@
* 1m· 2m· 45° , "없음" .
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import type {
DetailPipeInput,
PipeFacility,
@@ -42,10 +38,12 @@ import {
createRevetmentFields,
createRevetSideGroup,
createWingFields,
fordSection,
grid,
group,
INLET_KINDS,
INLET_REVET_KEYS,
inletKindOf,
type InletStructureKind,
labeled,
numberInput,
optionalSelect,
@@ -56,30 +54,10 @@ import {
WING_IN_KEYS,
WING_OUT_KEYS,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
import { pipeWallMinHeightM } from "../common_util/common_util_culvert_sets";
import { createFordFields } from "./B05_Profile_UI_Drainage_Facility_Ford";
/** B06 (2026-08-29 ).
* B05가 B06 . */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** "" (// I··)
* (2026-08-29 5: 일단 ).
* `inlet_type`(/) B06
* (`inlet_structure`). */
const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
export type { InletStructureKind }; // 목록은 700줄 한계로 `_Fields` 로 옮김 — 부르던 곳은 그대로
/** (chainage) . (pipe)
* . */
@@ -209,17 +187,13 @@ export function createFacilityOptionsForm(
root.hidden = true;
// ── 배관 본체 — 관종 > 관경 한 행(2026-08-17 사용자 지시 4) ──────────────
// 관종 기본값 = 파형강관(2026-08-17 사용자 확정) — 빈 항목 없이 셋 중 하나.
const pipeMaterial = document.createElement("select");
pipeMaterial.replaceChildren(
...["흄관", "VR관", "파형강관"].map((value) => new Option(value, value)),
);
pipeMaterial.value = "파형강관";
// 관종 파형강관·관경 1000(사용자 확정 기본)은 **제안으로만** — 「안 정함」이 실제 상태(판정 ③).
const pipeMaterial = optionalSelect("— 안 정함 (제안 파형강관) —", ["흄관", "VR관", "파형강관"]);
const pipeDiameter = document.createElement("select");
pipeDiameter.replaceChildren(
new Option("— 안 정함 (제안 Ø1000) —", ""),
...["800", "1000", "1200", "1500"].map((size) => new Option(`Ø${size}`, size)),
);
pipeDiameter.value = "1000"; // 별표2 원칙값 + 사용자 확정 기본 (2026-08-17)
// 관보호공 날개벽 — **개소당 원단위**가 형식마다 붙박이라 고르는 칸이 있어야 물량이 선다
// (2026-09-09, 계획서 4-13 · 소광리 원본 탭 다섯). 비우면 날개벽을 안 센다.
const wingWall = optionalSelect("안 놓음", ["A-TYPE", "C-TYPE", "A-TYPE+집수정"]);
@@ -228,11 +202,12 @@ export function createFacilityOptionsForm(
// ── 유입구 — 집수정 또는 기슭막이 택일(사용자 지시 5~9) ─────────────────
const inletGroup = group("유입구");
// 빈값(미지정)을 두면 하위 칸이 전부 숨어 기본값이 안 보인다 — 기슭막이를 기본으로
// 두고 저유량 지점에서 집수정으로 바꾼다(2026-08-17 사용자 지시).
// 「안 정함」이면 기슭막이 칸을 보이되(제안) 값은 안 실음 — 기본값을 몰래 확정으로 안 바꿈(판정 ③).
const inletType = document.createElement("select");
inletType.replaceChildren(...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)));
inletType.value = INLET_KINDS[0].label;
inletType.replaceChildren(
new Option("— 안 정함 (제안 기슭막이) —", ""),
...INLET_KINDS.map((kind) => new Option(kind.label, kind.label)),
);
const basinForm = optionalSelect("선택", [
"동물이동형",
"□형(기본형)",
@@ -248,14 +223,16 @@ export function createFacilityOptionsForm(
]);
const basinMaterialField = labeled("형태", basinMaterial);
// 집수정 종방향 길이 — 3D 예상형상용, 전후 동일 배분·기본 2m(2026-08-23 사용자).
const basinLength = numberInput("0.1");
basinLength.value = "2";
const basinLength = numberInput("0.1", "0", "제안 2");
const basinRows = [
grid(labeled("형식", basinForm), labeled("길이 (m)", stepper(basinLength, 1))),
];
const height = (): string =>
String(pipeWallMinHeightM(Number(pipeDiameter.value || "1000") / 1000));
const inletRevet = createRevetmentFields(INLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(찰)",
height,
});
// 첫 행 = [구조][형태] — 구조에 따라 집수정 형태와 기슭막이 형태가 갈아 끼워진다
// (2026-08-17 사용자 지시 3).
@@ -273,11 +250,11 @@ export function createFacilityOptionsForm(
// ── 유출구 — 구조 선택지는 기슭막이뿐이지만 양식을 맞춘다(사용자 지시 2·4) ──
const outletGroup = group("유출구");
const outletType = document.createElement("select");
outletType.replaceChildren(new Option("기슭막이", "기슭막이"));
const outletType = optionalSelect("— 안 정함 (제안 기슭막이) —", ["기슭막이"]);
const outletRevet = createRevetmentFields(OUTLET_REVET_KEYS, {
...REVET_COMMON_DEFAULTS,
form: "돌쌓기(메)",
height,
});
outletGroup.body.append(
grid(labeled("구조", outletType), outletRevet.formField),
@@ -299,8 +276,7 @@ export function createFacilityOptionsForm(
// 나눈다(2026-08-30 사용자). 설치 측이 한쪽이면 그 칸만 뜬다.
// 좌·우가 저장 채널(유입/유출) 중 어느 쪽인지는 측점 지형이 정하므로 이름표만
// 바꿔 단다(`setRevetSideLabels`) — 값은 언제나 그 채널 키로 오간다.
const revetSide = optionalSelect("양쪽", ["양쪽", "좌", "우"]);
revetSide.value = "양쪽";
const revetSide = optionalSelect("— 안 정함 (제안 양쪽) —", ["양쪽", "좌", "우"]);
const revetTopRow = grid(labeled("설치 측", revetSide));
const revetInlet = createRevetSideGroup(INLET_REVET_KEYS, "돌쌓기(메)");
const revetOutlet = createRevetSideGroup(OUTLET_REVET_KEYS, "돌쌓기(메)");
@@ -338,12 +314,12 @@ export function createFacilityOptionsForm(
// ── BOX암거 — 본체 규격(프리셋 + 사용자 지정)·날개벽(유입·유출 개별) ────
// 폭·높이 자유 입력은 "사용자 지정"을 골랐을 때만 펼친다(2026-08-17 사용자 확정).
const boxSize = optionalSelect("미지정", [
const boxSize = optionalSelect(`— 안 정함 (제안 ${BOX_SIZE_PRESETS[0][0]}) —`, [
...BOX_SIZE_PRESETS.map(([label]) => label),
BOX_SIZE_CUSTOM,
]);
const boxWidth = numberInput("0.1");
const boxHeight = numberInput("0.1");
const boxWidth = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][1]}`);
const boxHeight = numberInput("0.1", "0", `제안 ${BOX_SIZE_PRESETS[0][2]}`);
const boxCustomRow = grid(labeled("본체 폭 (m)", boxWidth), labeled("본체 높이 (m)", boxHeight));
const boxWrap = document.createElement("div");
boxWrap.append(grid(labeled("본체 규격 (m)", boxSize)), boxCustomRow);
@@ -358,98 +334,50 @@ export function createFacilityOptionsForm(
boxWidth.value = String(preset[1]);
boxHeight.value = String(preset[2]);
}
boxCustomRow.hidden = !!preset;
// 「안 정함」도 폭·높이 칸을 접음 — 값이 없는 상태 그대로(제안은 빈 보기 이름).
boxCustomRow.hidden = boxSize.value !== BOX_SIZE_CUSTOM;
}
// ── 세월교 — 구체 내 배관은 배수관과 같은 관종·관경 칸을 쓰고(2026-08-17 사용자
// 지시) 수량만 따로 받는다.
const fordCount = numberInput("1", "1", "련");
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음(2026-08-30 사용자 지시 3).
// 련은 정수라 소수 자릿수를 두지 않는다.
const fordRow = grid(labeled("수량 (련)", stepper(fordCount, 1, 0)));
// ── 물넘이·세월교 — 월류 폭·높이·경사·두께·길이·련 수(`_Ford` · 기본값은 제안으로만).
const ford = createFordFields(() => emit());
// ── 물넘이·세월교 개략 단면 — 월류 폭 + 월류 높이 한 행(2026-08-18 사용자 지시).
// 폭 기본값은 세월교 10m·물넘이 포장 5m(사용자 확정 — 지식DB 폭 수치 근거 없음).
// 높이는 설계유량·폭으로 되짚은 필요 수심을 자동으로 채우고, 사용자가 키울 수는
// 있으나 계산값(필요 최소 수심) 미만은 되돌린다.
const fordWidth = numberInput("0.1");
const fordHeight = numberInput("0.01");
const fordWidthRow = grid(
labeled("월류 폭 (m)", stepper(fordWidth, 0.1)),
labeled("월류 높이 (m)", stepper(fordHeight, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const fordSlope = numberInput("0.1");
fordSlope.placeholder = "노면 기울기";
const fordSlopeRow = grid(labeled("바닥 경사 유입→유출 (%)", stepper(fordSlope, 0.1)));
const fordSummary = document.createElement("p");
fordSummary.className = "b05-drainage__facility-note";
/** 담당 유역 설계유량(㎥/s) — 개략 단면의 입력. 유역이 없으면 null. */
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let fordMinDepthM: number | null = null;
/** (m) ""
* .
* (2026-08-30 사용자: 폭을 ). */
let fordAutoDepthM: number | null = null;
function syncFordSummary(): void {
const section =
designFlowM3s !== null
? fordSection(designFlowM3s, Number.parseFloat(fordWidth.value))
: null;
fordMinDepthM = section ? section.depthM : null;
if (section) {
// 표시 정밀도(0.01m)로 맞춘 최소값 — 비었거나, 그보다 작거나, 아직 직전
// 자동값 그대로면 계산값으로 다시 채운다.
const min = Number(section.depthM.toFixed(2));
const current = Number.parseFloat(fordHeight.value);
const untouched = fordAutoDepthM !== null && Math.abs(current - fordAutoDepthM) < 0.005;
if (!Number.isFinite(current) || current < min || untouched)
fordHeight.value = min.toFixed(2);
fordAutoDepthM = min;
}
if (designFlowM3s === null) {
fordSummary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
fordSummary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
fordSummary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 계산값 미만은 입력을 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다.
fordHeight.addEventListener("change", () => {
if (fordMinDepthM !== null) {
const min = Number(fordMinDepthM.toFixed(2));
const value = Number.parseFloat(fordHeight.value);
if (!Number.isFinite(value) || value < min) {
fordHeight.value = min.toFixed(2);
fordHeight.classList.add("is-invalid");
window.setTimeout(() => fordHeight.classList.remove("is-invalid"), 900);
}
}
// [제안값 넣기] — 누른 때만 빈 칸에 제안값(관종·관경·구조·집수정 길이·설치 측·BOX 규격·날개벽·월류).
// 벽 칸(기슭막이)은 벽마다 단추가 따로 있음. 기본값을 몰래 확정으로 바꾸지 않는다(2026-09-14 판정 ③).
const suggest = document.createElement("button");
suggest.type = "button";
suggest.className = "b05-structure__suggest";
suggest.textContent = "제안값 넣기";
suggest.addEventListener("click", () => {
if (!pipeMaterial.value) pipeMaterial.value = "파형강관";
if (!pipeDiameter.value) pipeDiameter.value = "1000";
if (!inletType.value) inletType.value = INLET_KINDS[0].label;
if (!outletType.value) outletType.value = "기슭막이";
if (!basinLength.value) basinLength.value = "2";
if (!revetSide.value) revetSide.value = "양쪽";
if (current === "box_culvert" && !boxSize.value) boxSize.value = BOX_SIZE_PRESETS[0][0];
wingInFields.fillSuggested();
wingOutFields.fillSuggested();
if (current === "ford_pavement" || current === "ford_bridge") ford.fillSuggested(current);
emit();
});
// 10-A ⑲ 저장 규칙을 폼 머리에 드러냄 — 병합은 `_Drainage_Facility_Merge`(2026-09-14 브레인 판정).
const saveNote = document.createElement("p");
saveNote.className = "b05-structure__owner-note";
saveNote.textContent =
"[저장]은 이 폼에 있는 칸만 바꿈 — 폼에 없는 칸(집계표·구조물도로 적은 값)은 그대로 둠 · 시설 종류를 바꾸면 새로 씀";
// 세월교·물넘이 항목은 **관종·관경 바로 다음**에 둔다(2026-08-30 사용자 지시 2) —
// 월류 폭·높이 → 바닥 경사 → 수량 → 개략 단면 결과. 다른 시설에서는 전부 숨는다.
root.append(
grid(suggest),
saveNote,
pipeRow,
wingRow,
fordWidthRow,
fordSlopeRow,
fordRow,
fordSummary,
ford.widthRow,
ford.slopeRow,
ford.countRow,
ford.summary,
inletGroup.root,
outletGroup.root,
extraGroup.root,
@@ -480,24 +408,23 @@ export function createFacilityOptionsForm(
wingInFields.root.hidden = !hasWing;
wingOutFields.root.hidden = !hasWing;
const isFord = current === "ford_pavement" || current === "ford_bridge";
fordRow.hidden = current !== "ford_bridge";
fordWidthRow.hidden = !isFord;
ford.countRow.hidden = current !== "ford_bridge";
ford.widthRow.hidden = !isFord;
// 바닥 경사는 물넘이포장만 쓴다 — 세월교는 구체 위 노면이라 파임이 없다.
fordSlopeRow.hidden = current !== "ford_pavement";
fordSummary.hidden = !isFord;
ford.slopeRow.hidden = current !== "ford_pavement";
ford.summary.hidden = !isFord;
const isRevet = current === "revetment";
revetTopRow.hidden = !isRevet;
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다.
revetInlet.root.hidden =
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.inlet);
revetOutlet.root.hidden =
!isRevet || (revetSide.value !== "양쪽" && revetSide.value !== revetSideLabels.outlet);
// 설치 측이 한쪽이면 그쪽 칸만 남긴다 — 이름표는 측점이 정한 좌·우다(안 정함 = 양쪽처럼 보임).
const side = revetSide.value || "양쪽";
revetInlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.inlet);
revetOutlet.root.hidden = !isRevet || (side !== "양쪽" && side !== revetSideLabels.outlet);
revetInlet.legend.textContent = `기슭막이 (${revetSideLabels.inlet})`;
revetOutlet.legend.textContent = `기슭막이 (${revetSideLabels.outlet})`;
// 추가 기슭막이 칸은 **내용이 있을 때만** 선다 — 고른 다단 벽의 행이 여기 오면 뜨고,
// 선택이 풀리면 스스로 사라진다.
extraGroup.root.hidden = extraSlot.childElementCount === 0;
if (isFord) syncFordSummary();
if (isFord) ford.sync();
if (isBox) syncBoxSize();
if (hasWing) {
wingInFields.syncVisibility();
@@ -516,12 +443,6 @@ export function createFacilityOptionsForm(
}
}
/** 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림). */
function putFordHeight(options: Record<string, string | number>): void {
const value = Number.parseFloat(fordHeight.value);
if (Number.isFinite(value) && value > 0) options.ford_height_m = Number(value.toFixed(2));
}
function emit(): void {
syncVisibility();
callbacks.onChange?.();
@@ -543,8 +464,7 @@ export function createFacilityOptionsForm(
boxHeight,
...wingInFields.inputs,
...wingOutFields.inputs,
fordCount,
fordWidth,
...ford.inputs,
].forEach((input) => input.addEventListener("change", emit));
/** 유입구 구조 변경 수신자(B06 유입측 형식 제어). B05는 비워 둔다. */
@@ -581,35 +501,36 @@ export function createFacilityOptionsForm(
},
setFacility(facility, options = {}, designFlow = null) {
current = facility;
designFlowM3s = designFlow ?? null;
ford.setDesignFlow(designFlow ?? null);
if (facility === null) {
syncVisibility();
return;
}
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
const isFord = facility === "ford_bridge";
pipeMaterial.value = text("pipe_kind") || "파형강관";
pipeDiameter.value = text("pipe_diameter_mm") || "1000";
// ⚠ 저장된 값만 칸에 — 없으면 「안 정함」 · 제안은 빈 보기 이름·회색 글씨(2026-09-14 판정 ③).
pipeMaterial.value = text("pipe_kind");
pipeDiameter.value = text("pipe_diameter_mm");
// 병합 목록: 저장된 정본 둘(inlet_type·inlet_structure)로 라벨을 되찾는다.
const savedType = text("inlet_type") || "기슭막이";
const savedType = text("inlet_type");
const savedStructure = text("inlet_structure");
inletType.value = (
INLET_KINDS.find(
(kind) =>
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
) ?? inletKindOf(savedType)
).label;
outletType.value = text("outlet_type") || "기슭막이";
inletType.value = savedType
? (
INLET_KINDS.find(
(kind) =>
kind.type === savedType && (!savedStructure || kind.structure === savedStructure),
) ?? inletKindOf(savedType)
).label
: "";
outletType.value = text("outlet_type");
wingWall.value = text("wing_wall_type");
basinForm.value = text("inlet_basin_form");
basinMaterial.value = text("inlet_basin_material");
basinLength.value = text("inlet_basin_length_m") || "2";
basinLength.value = text("inlet_basin_length_m");
inletRevet.write(options);
outletRevet.write(options);
// 저장된 폭·높이가 프리셋과 맞으면 그 프리셋을, 아니면 "사용자 지정"을 고른다.
// 값이 없으면 첫 프리셋(2.0×2.0)이 기본이다.
const [firstLabel, firstWidth, firstHeight] = BOX_SIZE_PRESETS[0];
// 값이 없으면 「안 정함」(첫 프리셋 2.0×2.0 은 제안으로만).
const savedWidth = Number.parseFloat(text("body_width_m"));
const savedHeight = Number.parseFloat(text("body_height_m"));
const hasSaved =
@@ -623,43 +544,38 @@ export function createFacilityOptionsForm(
Math.abs(width - savedWidth) < 0.05 && Math.abs(height - savedHeight) < 0.05,
)
: undefined;
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : firstLabel;
boxWidth.value = String(hasSaved ? savedWidth : firstWidth);
boxHeight.value = String(hasSaved ? savedHeight : firstHeight);
boxSize.value = hasSaved ? (preset?.[0] ?? BOX_SIZE_CUSTOM) : "";
boxWidth.value = hasSaved ? String(savedWidth) : "";
boxHeight.value = hasSaved ? String(savedHeight) : "";
wingInFields.write(options);
wingOutFields.write(options);
fordCount.value = isFord ? text("pipe_count") : "";
fordWidth.value = text("ford_width_m");
fordHeight.value = text("ford_height_m");
// 새 시설을 올리는 참이다 — 저장된 높이는 사용자 값으로 보고 자동 추적을 끊는다.
fordAutoDepthM = null;
fordSlope.value = text("ford_slope_pct");
revetSide.value = text("side") || "양쪽";
if (facility === "ford_pavement" || facility === "ford_bridge") ford.write(facility, options);
revetSide.value = text("side");
const spread = legacyRevetOptions(options);
revetInlet.fields.write(spread);
revetOutlet.fields.write(spread);
// 월류 폭 기본값 — 세월교 10m·물넘이 포장 5m(2026-08-18 사용자 확정, config 정의처).
if (!fordWidth.value && (facility === "ford_pavement" || facility === "ford_bridge")) {
fordWidth.value = String(
facility === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M,
);
}
syncVisibility();
},
setDesignFlow(designFlow) {
designFlowM3s = designFlow ?? null;
ford.setDesignFlow(designFlow ?? null);
syncVisibility();
},
readOptions() {
const options: Record<string, string | number> = {};
// 고른 칸만 싣는다 — 「안 정함」은 빈 값(기본값을 몰래 확정으로 안 바꿈).
const putPipe = (): void => {
if (pipeDiameter.value) options.pipe_diameter_mm = Number(pipeDiameter.value);
if (pipeMaterial.value) options.pipe_kind = pipeMaterial.value;
};
if (current === "pipe") {
options.pipe_diameter_mm = Number(pipeDiameter.value);
options.pipe_kind = pipeMaterial.value;
putPipe();
const inletKind = inletKindOf(inletType.value);
if (wingWall.value) options.wing_wall_type = wingWall.value;
options.inlet_type = inletKind.type;
// B06 유입측 형식(조정창과 같은 값) — 병합 드롭다운이 함께 정한다(2026-08-29).
options.inlet_structure = inletKind.structure;
if (inletType.value) {
options.inlet_type = inletKind.type;
options.inlet_structure = inletKind.structure;
}
if (inletKind.type === "집수정") {
if (basinForm.value) options.inlet_basin_form = basinForm.value;
if (basinMaterial.value) options.inlet_basin_material = basinMaterial.value;
@@ -667,7 +583,7 @@ export function createFacilityOptionsForm(
} else {
inletRevet.read(options);
}
options.outlet_type = outletType.value;
if (outletType.value) options.outlet_type = outletType.value;
outletRevet.read(options);
} else if (current === "box_culvert") {
// 프리셋을 골랐든 사용자 지정을 적었든 정본은 폭·높이 두 칸이다.
@@ -676,20 +592,14 @@ export function createFacilityOptionsForm(
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "ford_pavement") {
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putNumber(options, "ford_slope_pct", fordSlope.value);
ford.read(current, options);
} else if (current === "ford_bridge") {
options.pipe_kind = pipeMaterial.value;
options.pipe_diameter_mm = Number(pipeDiameter.value);
const count = Number.parseInt(fordCount.value, 10);
if (Number.isFinite(count) && count > 0) options.pipe_count = count;
putNumber(options, "ford_width_m", fordWidth.value);
putFordHeight(options);
putPipe();
ford.read(current, options);
wingInFields.read(options);
wingOutFields.read(options);
} else if (current === "revetment") {
options.side = revetSide.value;
if (revetSide.value) options.side = revetSide.value;
// 단 수는 폼이 갖지 않는다 — 횡단 설계 patch(extra_wall_counts)가 정본이고
// 조작은 [추가 기슭막이(단)] 행이 한다(2026-08-30 사용자: 중복이라 삭제).
// 좌·우가 각자 값을 갖는다 — 저장 채널은 유입/유출 키 그대로다(2026-08-30).
@@ -9,6 +9,33 @@
* ========================================================================== */
import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
import revetmentSabang from "../resources/data_masonry/revetment_sabang_2026-01-01.json";
/* ── 유입구 구조 ──────────────────────────────────────────────────────────── */
/** B06 (2026-08-29 ).
* B05가 B06 . */
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
/** "" (// I··)
* (2026-08-29 5: 일단 ).
* `inlet_type`(/) B06
* (`inlet_structure`). */
export const INLET_KINDS: ReadonlyArray<{
label: string;
type: "기슭막이" | "집수정";
structure: InletStructureKind;
}> = [
{ label: "기슭막이", type: "기슭막이", structure: "revet" },
{ label: "집수정", type: "집수정", structure: "auto" },
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
{ label: "집수정 I형", type: "집수정", structure: "I" },
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
];
export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
@@ -127,14 +154,18 @@ export function putNumber(target: Record<string, string | number>, key: string,
if (Number.isFinite(value) && value > 0) target[key] = Number(value.toFixed(1));
}
/** ("") . ,
* (2026-08-17 1). */
/** ** ( )** .
* (2026-09-14 · 2026-08-17 )
* . ( ). */
export function optionalSelect(
_blankLabel: string,
blankLabel: string,
choices: ReadonlyArray<string>,
): HTMLSelectElement {
const element = document.createElement("select");
element.replaceChildren(...choices.map((choice) => new Option(choice, choice)));
element.replaceChildren(
new Option(blankLabel, ""),
...choices.map((choice) => new Option(choice, choice)),
);
return element;
}
@@ -175,16 +206,20 @@ export interface RevetmentKeys {
after?: string;
}
/** 부위별 기본 구성 — 형태는 유입 돌쌓기(찰)·유출 돌쌓기(메), 길이 10m·높이 2.5m. */
/** **** ()· (), 10m.
* ( ).
* ** ** [ ]
* (2026-09-14 ).
* ** ** . */
export interface RevetmentDefaults {
form: string;
length: string;
height: string;
height: string | (() => string);
before: string;
after: string;
}
/** 부위와 무관한 공통 기본 치수 — 길이 10m(전 5·후 5)·높이 2.5m. */
/** 부위와 무관한 공통 제안 치수 — 길이 10m(전 5·후 5)·높이 2.5m(독립 기슭막이 · 관 벽은 관경 기준). */
export const REVET_COMMON_DEFAULTS = {
length: "10",
height: "2.5",
@@ -218,16 +253,43 @@ export interface RevetSideGroup {
slot: HTMLElement;
}
/** `revetment_sabang_*.json` (B08 · · ).
* import (`test_b05_revetment_sabang_basis`) . */
function sabangBasisLines(): string[] {
const { items, scope } = revetmentSabang;
const span = (values: number[], unit = ""): string => `${values[0]}~${values[1]}${unit}`;
return [
`높이 — ${items.height.rule}(${items.height.source}) · ${items.height.input}`,
`계획비탈 1:${span(items.face_slope.standard_ratio)}(${items.face_slope.source}) — 제안값은 품셈 표준경사`,
`둑마루 두께 ${span(items.crown_thickness.standard_m, "m")}(${items.crown_thickness.source}) — 콘크리트 기준 · 돌쌓기는 뒷길이 + 0.30 식`,
`뒷채움 조약돌 두께 — ${items.backfill_pebble.source}`,
scope,
];
}
export function createRevetSideGroup(keys: RevetmentKeys, formDefault: string): RevetSideGroup {
const box = group("기슭막이");
const kind = document.createElement("select");
kind.replaceChildren(new Option("기슭막이", "기슭막이"));
const fields = createRevetmentFields(keys, { ...REVET_COMMON_DEFAULTS, form: formDefault });
// 높이는 제안 없음 — 설계자 입력(계획홍수위 + 여유고 · 2026-09-14 브레인 판정). 옛 2.5 는 근거 없어 걷음.
const fields = createRevetmentFields(keys, {
...REVET_COMMON_DEFAULTS,
form: formDefault,
height: "",
});
const basis = document.createElement("p");
basis.className = "b05-route__note";
basis.replaceChildren(
...sabangBasisLines().flatMap((line, index) =>
index ? [document.createElement("br"), line] : [line],
),
);
const slot = document.createElement("div");
slot.className = "b05-structure__adjust-slot";
box.body.append(
grid(labeled("구조", kind), fields.formField),
...fields.rows,
basis,
// 단 수는 여기 두지 않는다 — 조정창에서 옮겨 오는 [추가 기슭막이(단)] 행과
// 같은 값이라 두 벌이면 헷갈린다(2026-08-30 사용자).
slot,
@@ -258,26 +320,32 @@ export function createRevetmentFields(
keys: RevetmentKeys,
defaults: RevetmentDefaults,
): RevetmentFields {
// ⚠ 제안값은 **회색 글씨(placeholder)로만** — 칸은 비워 둠(`RevetmentDefaults` 주석).
const suggestHeight = (): string =>
typeof defaults.height === "function" ? defaults.height() : defaults.height;
const form = document.createElement("select");
form.replaceChildren(...REVET_FORMS.map((value) => new Option(value, value)));
form.value = defaults.form;
const length = keys.length ? numberInput("0.1") : null;
if (length) length.value = defaults.length;
const height = numberInput("0.1");
height.value = defaults.height;
form.replaceChildren(
new Option(`제안 ${defaults.form}`, ""),
...REVET_FORMS.map((value) => new Option(value, value)),
);
const length = keys.length ? numberInput("0.1", "0", `제안 ${defaults.length}`) : null;
// 제안이 없는 칸(독립 기슭막이 높이)은 「설계자 입력」 — 값을 지어내지 않음.
const heightHint = (): string => (suggestHeight() ? `제안 ${suggestHeight()}` : "설계자 입력");
const height = numberInput("0.1", "0", heightHint());
// 관 벽 높이 제안은 관경을 따라감 — 칸을 볼 때마다 다시 셈.
height.addEventListener("focus", () => (height.placeholder = heightHint()));
// 기준측점 전/후(2026-08-23 사용자) — 구조물 패널(D군)과 같은 연동:
// 전 + 후 = 길이, 나중에 고친 쪽이 살아남고 반대쪽이 재배분된다.
const before = keys.before && length ? numberInput("0.1") : null;
const after = keys.after && length ? numberInput("0.1") : null;
if (before) before.value = defaults.before;
if (after) after.value = defaults.after;
const before = keys.before && length ? numberInput("0.1", "0", `제안 ${defaults.before}`) : null;
const after = keys.after && length ? numberInput("0.1", "0", `제안 ${defaults.after}`) : null;
let lastSplitEdit: "before" | "after" = "before";
const readNumber = (input: HTMLInputElement | null): number => {
const value = input ? Number.parseFloat(input.value) : Number.NaN;
return Number.isFinite(value) && value >= 0 ? value : 0;
};
const syncSplit = (source: "length" | "before" | "after"): void => {
if (!length || !before || !after) return;
// 길이가 비었으면 전/후를 0 으로 채우지 않음 — 빈 칸이 「0 으로 적음」이 되면 안 됨.
if (!length || !before || !after || length.value === "") return;
if (source !== "length") lastSplitEdit = source;
const total = readNumber(length);
const keep = lastSplitEdit === "after" ? after : before;
@@ -293,10 +361,25 @@ export function createRevetmentFields(
const beforeField = before ? labeled("기준측점 전 (m)", stepper(before, 0.5)) : null;
const afterField = after ? labeled("기준측점 후 (m)", stepper(after, 0.5)) : null;
// [제안값 넣기] — **누른 때만** 빈 칸에 제안값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양.
const fill = document.createElement("button");
fill.type = "button";
fill.className = "b05-structure__suggest";
fill.textContent = "제안값 넣기";
fill.addEventListener("click", () => {
if (!form.value) form.value = defaults.form;
if (length && !length.value) length.value = defaults.length;
if (!height.value) height.value = suggestHeight();
if (before && !before.value) before.value = defaults.before;
if (after && !after.value) after.value = defaults.after;
form.dispatchEvent(new Event("change"));
});
// 형태는 그룹 첫 행(구조 옆)에 따로 놓이므로 여기 행에서는 뺀다.
const rows = [
grid(...[lengthField, heightField].filter((item): item is HTMLLabelElement => item !== null)),
...(beforeField && afterField ? [grid(beforeField, afterField)] : []),
grid(fill),
];
return {
@@ -304,13 +387,15 @@ export function createRevetmentFields(
rows,
lengthInput: length,
write(options) {
// 저장된 값만 칸에 — 없으면 비워 두고 제안값은 회색 글씨로(몰래 채우지 않음).
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
form.value = text(keys.form) || defaults.form;
if (length && keys.length) length.value = text(keys.length) || defaults.length;
height.value = text(keys.height) || defaults.height;
if (before && keys.before) before.value = text(keys.before) || defaults.before;
if (after && keys.after) after.value = text(keys.after) || defaults.after;
form.value = text(keys.form);
if (length && keys.length) length.value = text(keys.length);
height.value = text(keys.height);
height.placeholder = heightHint();
if (before && keys.before) before.value = text(keys.before);
if (after && keys.after) after.value = text(keys.after);
syncSplit("length");
},
read(target) {
@@ -395,19 +480,18 @@ interface WingFields {
syncVisibility: () => void;
write: (options: Record<string, string | number>) => void;
read: (target: Record<string, string | number>) => void;
/** [제안값 넣기] — 빈 칸에만 기본 제원을 넣음. */
fillSuggested: () => void;
}
/** ( + ··) · .
* "없음" ( ). */
* "없음" ( ).
* (·1·2·45, ) **** (2026-09-14 ). */
export function createWingFields(title: string, keys: WingKeys): WingFields {
const install = optionalSelect("미지정", ["있음", "없음"]);
install.value = WING_DEFAULTS.install;
const height = numberInput("0.1");
height.value = WING_DEFAULTS.height;
const length = numberInput("0.1");
length.value = WING_DEFAULTS.length;
const angle = numberInput("1");
angle.value = WING_DEFAULTS.angle;
const install = optionalSelect(`— 안 정함 (제안 ${WING_DEFAULTS.install}) —`, ["있음", "없음"]);
const height = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.height}`);
const length = numberInput("0.1", "0", `제안 ${WING_DEFAULTS.length}`);
const angle = numberInput("1", "0", `제안 ${WING_DEFAULTS.angle}`);
// 숫자 칸은 기슭막이·집수정과 같은 [-][값][+] 묶음으로 맞춘다(2026-08-30 사용자).
const heightField = labeled("짧은쪽 높이 (m)", stepper(height, 0.1));
@@ -426,7 +510,6 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
dimsRow.hidden = off;
}
// 만들자마자 기본값("있음") 상태를 맞춘다.
syncVisibility();
return {
@@ -437,18 +520,25 @@ export function createWingFields(title: string, keys: WingKeys): WingFields {
write(options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
install.value = text(keys.install) || WING_DEFAULTS.install;
height.value = text(keys.height) || WING_DEFAULTS.height;
length.value = text(keys.length) || WING_DEFAULTS.length;
angle.value = text(keys.angle) || WING_DEFAULTS.angle;
install.value = text(keys.install);
height.value = text(keys.height);
length.value = text(keys.length);
angle.value = text(keys.angle);
syncVisibility();
},
read(target) {
target[keys.install] = install.value;
if (install.value !== "음") return;
if (install.value) target[keys.install] = install.value;
if (install.value === "음") return;
putNumber(target, keys.height, height.value);
putNumber(target, keys.length, length.value);
putNumber(target, keys.angle, angle.value);
},
fillSuggested() {
if (!install.value) install.value = WING_DEFAULTS.install;
if (!height.value) height.value = WING_DEFAULTS.height;
if (!length.value) length.value = WING_DEFAULTS.length;
if (!angle.value) angle.value = WING_DEFAULTS.angle;
syncVisibility();
},
};
}
@@ -0,0 +1,151 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Ford.ts
* · ·· · ·· + .
*
* (`_Drainage_Facility.ts`) 700 (2026-09-14).
* (2026-09-14 ):
* 10m· 5m( ) ** **, ·
* ** ** [ ] .
* ( ).
* ========================================================================== */
import {
FORD_BRIDGE_DEFAULT_WIDTH_M,
FORD_PAVEMENT_DEFAULT_WIDTH_M,
} from "@config/config_frontend";
import {
fordSection,
grid,
labeled,
numberInput,
putNumber,
stepper,
} from "./B05_Profile_UI_Drainage_Facility_Fields";
type FordKind = "ford_pavement" | "ford_bridge";
export interface FordFields {
widthRow: HTMLElement;
slopeRow: HTMLElement;
countRow: HTMLElement;
summary: HTMLElement;
/** 바뀌면 저장 흐름을 울릴 칸(높이는 자기 검사 뒤 스스로 울림). */
inputs: HTMLInputElement[];
setDesignFlow: (designFlow: number | null) => void;
sync: () => void;
write: (kind: FordKind, options: Record<string, string | number>) => void;
read: (kind: FordKind, target: Record<string, string | number>) => void;
fillSuggested: (kind: FordKind) => void;
}
export function createFordFields(emit: () => void): FordFields {
// 세월교 — 구체 내 배관 수량(련). 련은 정수라 소수 자릿수를 두지 않는다.
const count = numberInput("1", "1", "련");
const countRow = grid(labeled("수량 (련)", stepper(count, 1, 0)));
const width = numberInput("0.1");
const height = numberInput("0.01");
const widthRow = grid(
labeled("월류 폭 (m)", stepper(width, 0.1)),
labeled("월류 높이 (m)", stepper(height, 0.1)),
);
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
const slope = numberInput("0.1");
slope.placeholder = "노면 기울기";
// 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(A3).
const thickness = numberInput("1");
const length = numberInput("0.1");
const slopeRow = grid(
labeled("바닥 경사 유입→유출 (%)", stepper(slope, 0.1)),
labeled("포장 두께 (㎝)", stepper(thickness, 1, 0)),
labeled("포장 길이 노폭 방향 (m)", stepper(length, 0.1)),
);
const summary = document.createElement("p");
summary.className = "b05-drainage__facility-note";
let designFlowM3s: number | null = null;
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
let minDepthM: number | null = null;
const suggestedWidth = (kind: FordKind): number =>
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
function sync(): void {
const widthM = Number.parseFloat(width.value || width.dataset.suggested || "");
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
height.placeholder = minDepthM !== null ? `제안 필요 수심 ${minDepthM.toFixed(2)}` : "";
if (designFlowM3s === null) {
summary.textContent =
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
return;
}
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
if (!section) {
summary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
return;
}
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
summary.textContent =
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
`월류 높이는 필요 수심 이상만 입력됩니다.`;
}
// 적은 값이 계산값 미만이면 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다(빈 칸은 그대로 빈 칸).
height.addEventListener("change", () => {
const value = Number.parseFloat(height.value);
if (
minDepthM !== null &&
height.value !== "" &&
(!Number.isFinite(value) || value < minDepthM)
) {
height.value = minDepthM.toFixed(2);
height.classList.add("is-invalid");
window.setTimeout(() => height.classList.remove("is-invalid"), 900);
}
emit();
});
return {
widthRow,
slopeRow,
countRow,
summary,
inputs: [count, width],
setDesignFlow(designFlow) {
designFlowM3s = designFlow ?? null;
},
sync,
write(kind, options) {
const text = (key: string): string =>
options[key] !== undefined ? String(options[key]) : "";
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
width.value = text("ford_width_m");
width.dataset.suggested = String(suggestedWidth(kind));
width.placeholder = `제안 ${suggestedWidth(kind)}`;
height.value = text("ford_height_m");
slope.value = text("ford_slope_pct");
thickness.value = text("thickness_cm");
length.value = text("length_m");
},
read(kind, target) {
putNumber(target, "ford_width_m", width.value);
// 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림).
const value = Number.parseFloat(height.value);
if (Number.isFinite(value) && value > 0) target.ford_height_m = Number(value.toFixed(2));
if (kind === "ford_pavement") {
putNumber(target, "ford_slope_pct", slope.value);
putNumber(target, "thickness_cm", thickness.value);
putNumber(target, "length_m", length.value);
return;
}
const pipes = Number.parseInt(count.value, 10);
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
},
fillSuggested(kind) {
if (!width.value) width.value = String(suggestedWidth(kind));
sync();
if (!height.value && minDepthM !== null) height.value = minDepthM.toFixed(2);
},
};
}
@@ -0,0 +1,73 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Facility_Merge.ts
* [] ** , **
* (2026-09-14 ).
*
* **** · (
* `revet_foundation` · `foundation`·· ) B05
* ** **. [] []
* .
* ** ** .
* ( · )
* .
* . ** **
* ( ). `test_b05_facility_options_merge`.
* import .
* ========================================================================== */
const revetKeys = (side: "inlet" | "outlet"): string[] =>
["form", "length_m", "height_m", "before_m", "after_m"].map((key) => `${side}_revet_${key}`);
const wingKeys = (side: "in" | "out"): string[] => [
`wing_${side}`,
`wing_${side}_height_m`,
`wing_${side}_length_m`,
`wing_${side}_angle_deg`,
];
/** 시설 종류별로 **폼이 적는 칸** — `createFacilityOptionsForm().readOptions()` 가 쓰는 키 전부. */
export const FORM_OPTION_KEYS: Readonly<Record<string, readonly string[]>> = {
pipe: [
"pipe_diameter_mm",
"pipe_kind",
"wing_wall_type",
"inlet_type",
"inlet_structure",
"inlet_basin_form",
"inlet_basin_material",
"inlet_basin_length_m",
...revetKeys("inlet"),
"outlet_type",
...revetKeys("outlet"),
],
box_culvert: ["body_width_m", "body_height_m", ...wingKeys("in"), ...wingKeys("out")],
ford_pavement: ["ford_width_m", "ford_height_m", "ford_slope_pct", "thickness_cm", "length_m"],
ford_bridge: [
"pipe_kind",
"pipe_diameter_mm",
"pipe_count",
"ford_width_m",
"ford_height_m",
...wingKeys("in"),
...wingKeys("out"),
],
revetment: ["side", ...revetKeys("inlet"), ...revetKeys("outlet")],
};
interface MergeableAttributes {
facility: string;
start_m?: number;
end_m?: number;
options?: Record<string, string | number>;
}
/** 저장할 시설 정보 — 같은 종류면 폼이 모르는 옛 칸을 이어 붙임. */
export function mergeFacilityOptions<T extends MergeableAttributes>(
previous: T | null,
next: T,
): T {
if (!previous || previous.facility !== next.facility) return next;
const managed = new Set(FORM_OPTION_KEYS[next.facility] ?? []);
const kept = Object.entries(previous.options ?? {}).filter(([key]) => !managed.has(key));
const options = { ...Object.fromEntries(kept), ...(next.options ?? {}) };
return { ...next, options: Object.keys(options).length ? options : undefined };
}
+8 -3
View File
@@ -15,19 +15,22 @@ import {
computeMapRect,
MAP_STATION_INTERVAL_M,
createNormalizer,
prepareLayer,
prepareMetricPolyline,
type MapRect,
type Normalizer,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolyline,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import { type RoutePoint } from "./B05_Profile_Api_Fetch";
import type { FlowArrow } from "../B04_PreProcess/B04_PreProcess_UI_FlowArrows";
import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp";
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { mergeFacilityOptions } from "./B05_Profile_UI_Drainage_Facility_Merge";
import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
@@ -605,8 +608,10 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
pipeEditor.addAtChainage(chainageM);
},
updatePipeFacility(fromChainageM, toChainageM, attributes) {
// ⚠ 통째로 갈아 끼우지 않음 — 폼이 모르는 칸(집계표로 적은 기초 등)을 지키려 이어 붙임.
const previous = facilityStore.get(fromChainageM);
facilityStore.set(fromChainageM, null);
facilityStore.set(toChainageM, attributes);
facilityStore.set(toChainageM, mergeFacilityOptions(previous, attributes));
if (Math.abs(fromChainageM - toChainageM) > 0.005) {
// 기준점이 옮겨졌다 — 관을 이동시키면 onCommit이 재계산을 돌리고,
// attach가 새 위치로 시설 정보를 승계한다.
+14 -3
View File
@@ -281,9 +281,20 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
onEditPlannedRoute: () =>
void openRouteEditModal(activeProjectId, () => {
navigateTo(ROUTES.B05_PROFILE);
}),
void openRouteEditModal(
activeProjectId,
() => {
navigateTo(ROUTES.B05_PROFILE);
},
{
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: confirmedSurface?.model_id ?? null,
contourIntervalM: latest?.surface_params.contour_interval_m,
smooth: latest?.surface_params.smooth,
},
),
onTempSave: () => void tempSaveAction(actionContext),
onGoCross: () => {
// 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
+10 -3
View File
@@ -25,6 +25,7 @@ import {
} from "./B05_Profile_Api_Fetch";
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { stateKey } from "../A00_Common/b_page_state";
import {
invalidateSectionDetail,
saveCachedCrossPatches,
@@ -170,10 +171,16 @@ export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
);
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
// ⚠ 이동 키까지 넘긴다(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야
// 한다). 종전에는 구간값 키만 넘겨, 옛 세션에 남은 관 이동 예약이 B05 [임시저장]에서는
// 조용히 빠졌다. 관 목록 자체는 이제 두 페이지가 같은 스냅샷(`pipes`)을 쓴다.
if (latest?.route?.id != null) {
await flushCulvertOptions(projectId, `b06:culvertopt:${projectId}:${latest.route.id}`).catch(
() => undefined,
);
const routeId = latest.route.id;
await flushCulvertOptions(
projectId,
stateKey("culvertopt", projectId, routeId),
stateKey("culvertmove", projectId, routeId),
).catch(() => undefined);
}
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
// 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가
+322 -303
View File
@@ -16,36 +16,47 @@
import {
computeMapRect,
computeRouteView,
drawPreparedLayer,
createNormalizer,
hitPreparedLayer,
metricToScreen,
prepareLayer,
pickLevelStep,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
import type { RoutePlanCurve } from "./B05_Profile_Api_Replan";
import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve";
import { fetchRoutePlan } from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
import {
buildEditedPolyline,
dragHandleTo as curveDragTo,
type EditedCurve,
type EditedNode,
} from "./B05_Profile_UI_RouteEdit_Curve";
import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
import {
bindRouteEditNavigation,
contourBandRect,
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
stationAtScreen,
} from "./B05_Profile_UI_RouteEdit_Input";
import {
centerDirectionOf,
createCurveLabel,
deflectionRad,
} from "./B05_Profile_UI_RouteEdit_Label";
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
applyArcLocks,
applyCurveLimits,
curveShortfalls,
radiusFloorM,
curveSummary,
flattenServerPlan,
shortfallCrossed,
type CurveLock,
} from "./B05_Profile_UI_RouteEdit_Edits";
import {
@@ -58,63 +69,38 @@ import "./B05_Profile_UI_Style_RouteEdit.css";
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
const NODE_HIT_PX = 9;
/** 노드 반지름(px). */
const NODE_R = 4;
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
const SEGMENT_HIT_PX = 12;
/** ** **(m) (2026-09-07).
*
* . ** **
* ( ).
* `drawPreparedLayer` . */
const CONTOUR_BAND_M = 300;
/** · (px) ** ** .
* 3.5px (2026-09-07 ). */
const CURVE_HANDLE_PX = 5;
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
const CONTOUR_HIT_PX = 6;
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
const STATION_HIT_PX = 11;
/** B04 (350) ****
* (2026-09-12 ). 300m
* . */
const CONTOUR_LEVEL_BUDGET = 1200;
type Vertex = [number, number];
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
export interface RouteEditOptions {
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
stationIntervalM?: number;
/** 확정 지표면 모델 id — 있으면 바탕 등고선을 **LAS 것**으로 쓴다(계획서 0-9 ⑥). */
surfaceModelId?: number | null;
/** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */
contourIntervalM?: number;
smooth?: boolean;
}
export async function openRouteEditModal(
projectId: string,
onApplied: () => void | Promise<void>,
options: RouteEditOptions = {},
): Promise<void> {
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong> </strong>
<span class="b05-routeedit__hint">
= · = R · = ·
= · () = · =
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기"></button>
</div>
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
<div class="b05-routeedit__foot">
<span class="b05-routeedit__status"> </span>
<span class="b05-routeedit__legend">
<i class="is-expected"></i> ()
<i class="is-planned"></i>
</span>
<button type="button" class="b05-routeedit__btn" data-act="undo" title="되돌리기 (Ctrl+Z)"
disabled> </button>
<button type="button" class="b05-routeedit__btn" data-act="redo" title="다시하기 (Ctrl+Y)"
disabled> </button>
<button type="button" class="b05-routeedit__btn" data-act="history-reset"
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled></button>
<button type="button" class="b05-routeedit__btn" data-act="reset"></button>
<button type="button" class="b05-routeedit__btn" data-act="cancel"></button>
<button type="button" class="b05-routeedit__btn is-primary" data-act="apply"></button>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>`;
document.body.append(overlay);
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
const stationIntervalM = options.stationIntervalM ?? 20;
const chrome = createRouteEditChrome();
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
const context = canvas.getContext("2d")!;
let expected: Vertex[] = [];
@@ -123,14 +109,13 @@ export async function openRouteEditModal(
/** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */
let planned: Vertex[] = [];
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
let nodeInfo: Array<{
radius_m: number | null;
inner_angle_deg: number | null;
violations: string[];
}> = [];
let nodeInfo: EditedNode[] = [];
let minRadiusM = 0;
/** **못 넘는** 하한 — 0이면 제한 없음. 기본 반지름(`minRadiusM`)과 다른 값이다(계획서 0-9 ④). */
let limitRadiusM = 0;
let limitArcM = 0;
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
let curveInfo: RoutePlanCurve[] = [];
let curveInfo: EditedCurve[] = [];
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
let curveOn: boolean[] = [];
let curveRadius: Array<number | null> = [];
@@ -138,12 +123,74 @@ export async function openRouteEditModal(
* R (`_Edits.ts`). */
let curveLock: CurveLock[] = [];
let curveArc: Array<number | null> = [];
/** (2026-09-12 )
* · . */
let apexLock: boolean[] = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
/** 되돌리기 사진첩 — 노선을 읽은 뒤에 선다(그전에는 되돌릴 것이 없다). */
let history: RouteEditHistory | null = null;
let meta: VWorldMeta | null = null;
let sheets: PreparedLayer[] = [];
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
let contours: RouteEditContours | null = null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
let pickedContour = -1;
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
const crossPreview = createCrossPreview({
projectId,
side: overlay.querySelector<HTMLElement>(".b05-routeedit__side")!,
request: () => ({
vertices: planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
min_radius_m: minRadiusM || 12,
station_interval_m: stationIntervalM,
}),
});
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
const measure = createMeasureTool({
projectId,
stationIntervalM,
line: () => (plannedLine.length ? plannedLine : planned),
// 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다.
toScreen: (vertex) => toScreen(vertex),
isClosed: () => closed,
onChange: () => {
syncMeasureBox();
draw();
},
});
/** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */
function syncMeasureBox(): void {
const on = measure.active();
measureBox.hidden = !on;
measureText.textContent = measure.hint();
if (on && picked >= 0) {
picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다.
syncCurveBar();
}
}
/** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */
let measureMode = false;
measureButton.addEventListener("click", () => {
measureMode = !measureMode;
measureButton.classList.toggle("is-active", measureMode);
if (!measureMode) measure.clear();
});
overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => {
measure.clear(); // 닫으면 잰 것이 지워진다(㉔).
measureMode = false;
measureButton.classList.remove("is-active");
syncMeasureBox();
draw();
});
let view: ViewState = {
width: 0,
height: 0,
@@ -153,6 +200,15 @@ export async function openRouteEditModal(
mapRect: computeMapRect(null, 0, 0),
};
let closed = false;
/** 지도 회전 — 단추 배선과 좌표 되돌리기는 `_Rotate` 몫(계획서 0-9 ⑯). */
const rotation = createMapRotation({
overlay,
size: () => view,
onChange: () => {
syncCurveBar(); // 떠 있는 패널도 돌아간 노드 옆으로 따라가야 한다.
draw();
},
});
const close = (): void => {
closed = true;
@@ -199,111 +255,42 @@ export async function openRouteEditModal(
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
}
function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void {
if (points.length < 2) return;
context.save();
context.setLineDash(dash);
context.strokeStyle = color;
context.lineWidth = width;
context.beginPath();
points.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
context.restore();
/** 1m . `metricToScreen`
* 100m (1m ). */
function pxPerMeter(): number {
if (!meta) return 1;
const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min);
return Math.abs(x1 - x0) / 100;
}
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
const contourStepM = (): number =>
contours ? pickLevelStep(contours.layer, view, contours.intervalM, CONTOUR_LEVEL_BUDGET) : 0;
function draw(): void {
if (closed) return;
const style = getComputedStyle(document.documentElement);
context.clearRect(0, 0, view.width, view.height);
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
context.fillRect(0, 0, view.width, view.height);
context.save();
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
const band = meta
? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M)
: null;
if (band) {
context.beginPath();
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
context.lineWidth = 0.8;
for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot");
context.restore();
strokePolyline(
drawRouteEditScene(context, {
view,
toScreen,
pxPerMeter: pxPerMeter(),
hasMeta: meta !== null,
contours,
otherSheets,
pickedContour,
contourStepM: contourStepM(),
rotationRad: rotation.radians(),
uprightRad: rotation.uprightRad(),
measure: measure.marks(),
expected,
[6, 5],
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
plannedLine.length ? plannedLine : planned,
[],
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.lineWidth = 1;
planned.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0;
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
context.fill();
context.stroke();
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) {
context.save();
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.beginPath();
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
context.fill();
context.restore();
}
plannedLine,
planned,
nodeInfo,
curveInfo,
curveOn,
picked,
stationIntervalM,
});
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
context.lineWidth = 2;
curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
const on = curveOn[curve.node_first] !== false;
if (!on) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
syncCurveBar();
}
@@ -312,7 +299,10 @@ export async function openRouteEditModal(
const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null =>
handleAtScreen(
// 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5).
curveInfo.filter((entry) => (curveLock[entry.node_first] ?? null) === null),
curveInfo.filter(
(entry) =>
(curveLock[entry.node_first] ?? null) === null && apexLock[entry.node_first] !== true,
),
toScreen,
px,
py,
@@ -346,12 +336,29 @@ export async function openRouteEditModal(
function markEdited(): void {
// 길이를 붙든 자리는 교각이 바뀌었을 수 있다 — 그리기 전에 R 부터 다시 잡는다.
applyArcLocks(planned, curveLock, curveArc, curveRadius);
// 지정해 둔 값이 하한을 밑돌면 하한까지 끌어올린다(계획서 0-9 ④).
// 하한은 **R 과 L 을 함께 본 값**이다 — 내각 155° 이상은 R 만 본다(2026-09-12 확정).
applyCurveLimits(planned, curveOn, curveRadius, limitRadiusM, limitArcM);
const built = buildEditedPolyline(planned, curveOn, curveRadius, minRadiusM);
plannedLine = built.vertices;
curveInfo = built.curves;
nodeInfo = built.nodes;
}
/** ( 0-9 ).
* . */
const routeHead = (): string =>
`예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` +
`계획노선 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` +
`노드 ${planned.length}`;
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
const contourHint = (): string => {
if (pickedContour < 0) return "등고선을 누르면 그 줄의 높이가 보입니다.";
const level = contours?.layer.features[pickedContour]?.labelValue ?? null;
return level === null ? "등고선 한 줄을 골랐습니다." : `고른 등고선 ${level}m.`;
};
/** 상태줄 꼬리 — 셈은 `_Edits` 몫. */
const curveHint = (): string =>
curveSummary({
@@ -359,90 +366,45 @@ export async function openRouteEditModal(
curveOn,
curveRadius,
curveLock,
apexLock,
curveCount: curveInfo.length,
violationCount: nodeInfo.filter((node) => node.violations.length).length,
minRadiusM,
fresh: nodeInfo.length === 0,
});
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
const curveLabelBox = createCurveLabel({
onRadius: (value) => {
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
},
onLock: (lock) => {
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onCurveOn: (on) => {
if (picked < 0) return;
curveOn[picked] = on;
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
const curveBar = createCurveBar({
canvas,
state: () => ({
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
curveArc,
apexLock,
limitRadiusM,
limitArcM,
}),
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
applyEdit: (message) => applyEdit(message),
onUnselect: () => {
picked = -1;
syncCurveBar();
draw();
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function syncCurveBar(): void {
if (!(picked > 0 && picked < planned.length - 1)) {
curveLabelBox.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const rect = canvas.getBoundingClientRect();
const [screenX, screenY] = toScreen(planned[picked]);
curveLabelBox.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다.
at: [screenX + rect.left, screenY + rect.top],
centerDirection: pickedCurve
? centerDirectionOf(
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
toScreen(pickedCurve.start),
toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
});
}
const curveLabelBox = curveBar.label;
const syncCurveBar = curveBar.sync;
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
function applyEdit(message: string, record = true): void {
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length}${message} ${curveHint()}`;
status.textContent = `${routeHead()}${message} ${curveHint()}`;
draw();
if (record) history?.commit(snapshotNow());
historyControls.sync();
@@ -450,7 +412,7 @@ export async function openRouteEditModal(
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
function snapshotNow(): RouteEditSnapshot {
return { planned, curveOn, curveRadius, curveLock, curveArc, picked };
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
}
const historyControls = bindHistoryControls({
@@ -462,6 +424,7 @@ export async function openRouteEditModal(
curveRadius = snapshot.curveRadius;
curveLock = snapshot.curveLock;
curveArc = snapshot.curveArc;
apexLock = snapshot.apexLock;
picked = snapshot.picked;
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
},
@@ -479,37 +442,87 @@ export async function openRouteEditModal(
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (event.shiftKey || measureMode) {
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
void measure.pick(px, py);
return;
}
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
dragNode = nodeAt(px, py);
dragHandle = dragNode >= 0 ? null : handleAt(px, py);
const hitNode = nodeAt(px, py);
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && apexLock[hitNode] !== true ? hitNode : -1;
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
dragMoved = false;
if (dragNode >= 0) {
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
if (hitNode >= 0) {
picked = hitNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
syncCurveBar();
draw();
} else if (dragHandle) {
picked = dragHandle.node;
measure.clear();
syncCurveBar();
draw();
} else if (
// 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나
// 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다.
((): boolean => {
if (picked >= 0) {
picked = -1;
syncCurveBar();
}
return false;
})()
) {
/* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */
} else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => {
const chainage = stationAtScreen(
plannedLine.length ? plannedLine : planned,
toScreen,
stationIntervalM,
px,
py,
STATION_HIT_PX,
);
if (chainage === null) return false;
void crossPreview.open(chainage);
return true;
})()
) {
/* 횡단 창이 떴다 — 더 집지 않는다. */
} else if (contours) {
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
const hit = hitPreparedLayer(contours.layer, view, px, py, CONTOUR_HIT_PX, contourStepM());
if (hit !== pickedContour) {
pickedContour = hit;
status.textContent = `${routeHead()}${contourHint()} ${curveHint()}`;
draw();
}
}
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
const node = dragHandle.node;
const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py));
if (moved) {
planned[node] = moved.apex;
curveRadius[node] = Math.round(moved.radius * 100) / 100;
// 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④).
// 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다.
curveRadius[node] = Math.max(
radiusFloorM(nodeInfo[node]?.inner_angle_deg, limitRadiusM, limitArcM),
Math.round(moved.radius * 100) / 100,
);
curveOn[node] = true;
picked = node;
// 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이
@@ -517,22 +530,45 @@ export async function openRouteEditModal(
dragMoved = true;
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length} — 곡선을 잡는 중. ${curveHint()}`;
status.textContent = `${routeHead()} — 곡선을 잡는 중. ${curveHint()}`;
draw();
}
return;
}
if (dragNode >= 0) {
// 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로
// **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④).
const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM);
const previous = planned[dragNode];
dragMoved = true;
planned[dragNode] = toMetric(px, py);
markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다.
if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) {
// 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다.
planned[dragNode] = previous;
markEdited();
status.textContent =
`${routeHead()} — 하한에 걸려 더 못 옮깁니다` +
`(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`;
draw();
return;
}
// 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어
// 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②).
status.textContent = `노드 ${planned.length} — 옮기는 중. ${curveHint()}`;
status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`;
draw();
return;
}
canvas.style.cursor = nodeAt(px, py) >= 0 || handleAt(px, py) ? "grab" : "default";
const overNode = nodeAt(px, py);
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
overNode >= 0
? apexLock[overNode]
? "not-allowed"
: "grab"
: handleAt(px, py)
? "grab"
: "default";
});
const endDrag = (event: PointerEvent): void => {
@@ -542,6 +578,9 @@ export async function openRouteEditModal(
if (dragMoved) {
history?.commit(snapshotNow());
historyControls.sync();
// 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲).
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
void crossPreview.refresh();
}
dragNode = -1;
dragHandle = null;
@@ -552,8 +591,7 @@ export async function openRouteEditModal(
canvas.addEventListener("dblclick", (event) => {
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
const segment = segmentAt(px, py);
if (segment < 0) return;
planned.splice(segment + 1, 0, toMetric(px, py));
@@ -562,6 +600,7 @@ export async function openRouteEditModal(
curveRadius.splice(segment + 1, 0, null);
curveLock.splice(segment + 1, 0, null);
curveArc.splice(segment + 1, 0, null);
apexLock.splice(segment + 1, 0, false);
picked = segment + 1;
applyEdit("새 노드를 넣었습니다(직선 추가).");
});
@@ -569,7 +608,7 @@ export async function openRouteEditModal(
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top));
if (index < 0) return;
if (planned.length <= 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
@@ -580,6 +619,7 @@ export async function openRouteEditModal(
curveRadius.splice(index, 1);
curveLock.splice(index, 1);
curveArc.splice(index, 1);
apexLock.splice(index, 1);
picked = -1;
applyEdit("노드를 지웠습니다(직선 삭제).");
});
@@ -592,57 +632,18 @@ export async function openRouteEditModal(
view = next;
},
getMeta: () => meta,
// 돌린 지도에서는 손이 민 방향과 그림이 움직일 방향이 다르다 — 거꾸로 돌려 넘긴다.
unrotateDelta: rotation.unrotateDelta,
draw,
});
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
busy.hidden = false;
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
// 87.3 · 90.0 · 93.9 · 95.4초). 중간 취소를 안 만드는 대신, **얼마나 지났는지**를
// 보여 사람이 멈춘 것인지 도는 것인지 알 수 있게 한다(계획서 0-2).
const message = busy.querySelector("span")!;
const started = Date.now();
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
};
tick();
const timer = window.setInterval(tick, 1000);
try {
await task();
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
showToast("노선을 다시 계산했습니다.", "success");
close();
await onApplied();
} catch (error) {
busy.hidden = true;
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
} finally {
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
}
}
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
if (planned.length < 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
bindRouteApply({
overlay,
busy,
projectId,
nodes: () => ({ planned, curveOn, curveRadius }),
close,
onApplied,
});
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
@@ -658,6 +659,8 @@ export async function openRouteEditModal(
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
const nodes = plan.nodes ?? [];
minRadiusM = plan.min_radius_m ?? 0;
limitRadiusM = plan.limit_radius_m ?? 0;
limitArcM = plan.limit_curve_length_m ?? 0;
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
planned = flat.planned;
@@ -667,6 +670,7 @@ export async function openRouteEditModal(
curveRadius = flat.curveRadius;
curveLock = flat.curveLock;
curveArc = flat.curveArc;
apexLock = flat.apexLock;
picked = -1;
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
history = createRouteEditHistory(snapshotNow());
@@ -675,9 +679,23 @@ export async function openRouteEditModal(
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
const normalizer = createNormalizer(drainage.meta);
sheets = drainage.layers
// 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
otherSheets = drainage.layers
.filter(([layer]) => layer !== "도엽_등고선")
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
.filter((layer): layer is PreparedLayer => layer !== null);
contours = await loadRouteEditContours(
projectId,
drainage.meta,
normalizer,
drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null,
{
surfaceModelId: options.surfaceModelId ?? null,
intervalM: options.contourIntervalM ?? 1,
smooth: options.smooth ?? false,
},
);
if (closed) return;
resize();
const xs = planned.map((vertex) => vertex[0]);
const ys = planned.map((vertex) => vertex[1]);
@@ -694,7 +712,8 @@ export async function openRouteEditModal(
);
view = { ...view, ...fitted };
status.textContent =
`노드 ${planned.length} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
curveHint();
draw();
} catch (error) {
@@ -0,0 +1,81 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Apply.ts
* **[]·[]** .
*
* ·· ( 90).
* ( 0-2, 2026-09-09) ** **
* .
* ========================================================================== */
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
type Vertex = [number, number];
export interface RouteApplyParams {
overlay: HTMLElement;
/** 화면 전체를 덮는 대기 막. 안에 `<span>` 한 개가 글을 받는다. */
busy: HTMLElement;
projectId: string;
/** 지금 편집값 — 누른 순간에 읽는다. */
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
onApplied: () => void | Promise<void>;
}
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
export function bindRouteApply(params: RouteApplyParams): void {
const { overlay, busy, projectId } = params;
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
busy.hidden = false;
// ⚠ 「몇 분」은 옛 값이었다 — 0-11 로 **약 90초**가 됐다(2026-09-09 실측 네 번:
// 87.3 · 90.0 · 93.9 · 95.4초).
const message = busy.querySelector("span")!;
const started = Date.now();
const tick = (): void => {
const seconds = Math.round((Date.now() - started) / 1000);
message.textContent = `${label} — 배수유역부터 다시 계산 중입니다. 1분 반쯤 걸립니다 (${seconds}초 지남).`;
};
tick();
const timer = window.setInterval(tick, 1000);
try {
await task();
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
showToast("노선을 다시 계산했습니다.", "success");
params.close();
await params.onApplied();
} catch (error) {
busy.hidden = true;
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
} finally {
window.clearInterval(timer); // 성공·실패·닫힘 어느 쪽이든 멈춘다
}
}
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
const { planned, curveOn, curveRadius } = params.nodes();
if (planned.length < 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
});
}
@@ -0,0 +1,98 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Chrome.ts
* **** ·· .
*
* `B05_Profile_UI_RouteEdit.ts` 700 (2026-09-12).
* (`_Apply`·`_Rotate`·`_History`) .
*
* ****(2026-09-12 ~·) .
* , 2, · ,
* . .
* ========================================================================== */
/** ** **(2026-09-12 ).
* (``·``) . */
const HALF_TURN_ICON = {
ccw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 8a5 5 0 0 0-10 0" /><path d="M3 8 1.2 5.6" /><path d="M3 8 5.4 6.6" /></svg>`,
cw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8a5 5 0 0 1 10 0" /><path d="M13 8 14.8 5.6" /><path d="M13 8 10.6 6.6" /></svg>`,
};
export interface RouteEditChrome {
overlay: HTMLElement;
canvas: HTMLCanvasElement;
status: HTMLElement;
busy: HTMLElement;
measureBox: HTMLElement;
measureText: HTMLElement;
measureButton: HTMLButtonElement;
}
/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
export function createRouteEditChrome(): RouteEditChrome {
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong> </strong>
<span class="b05-routeedit__actions">
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)"> </button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
title="되돌리기 (Ctrl+Z)" disabled> </button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
title="다시하기 (Ctrl+Y)" disabled> </button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled></button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset"></button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"></button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply"></button>
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기"></button>
</div>
<div class="b05-routeedit__canvas-wrap">
<canvas class="b05-routeedit__canvas"></canvas>
<div class="b05-routeedit__hint">
<span> = </span><span> = R </span>
<span> = </span><span> = </span>
<span> = </span><span>Shift+ = ·</span>
<span>() = </span><span> = </span>
</div>
<div class="b05-routeedit__spin">
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
</div>
<div class="b05-routeedit__measure" hidden>
<span class="b05-routeedit__measure-text"></span>
<button type="button" class="b05-routeedit__measure-close" aria-label="닫기"
title="닫기"></button>
</div>
<div class="b05-routeedit__info">
<span class="b05-routeedit__status"> </span>
<span class="b05-routeedit__legend">
<i class="is-expected"></i> ()
<i class="is-planned"></i>
</span>
</div>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>
<div class="b05-routeedit__side"></div>`;
document.body.append(overlay);
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
const measureBox = overlay.querySelector<HTMLElement>(".b05-routeedit__measure")!;
const measureText = overlay.querySelector<HTMLElement>(".b05-routeedit__measure-text")!;
const measureButton = overlay.querySelector<HTMLButtonElement>('[data-act="measure"]')!;
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
return { overlay, canvas, status, busy, measureBox, measureText, measureButton };
}
@@ -0,0 +1,111 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Contour.ts
* ** ** .
*
* ** **(2026-09-12 ) LAS
* . ** LAS **
* , .
*
* GeoJSON( `등고수치` ), LAS
* (m) ( `level`). ** `PreparedLayer` **
* ·· .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import {
type GeoJsonCollection,
type Normalizer,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
prepareLayer,
prepareMetricPolylines,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
/** 도엽 등고선의 표고 속성 이름 — B04 지도가 쓰는 것과 같은 키. */
const SHEET_ELEVATION_KEYS = ["등고수치"];
/** 표고를 못 읽었을 때 라벨 솎기에 쓸 간격(m). */
const FALLBACK_INTERVAL_M = 5;
/** LAS ** m ** (2026-09-12 ).
*
* LAS 1m 1m·2m . 5m
* ** 1m 5 **
* . */
const LAS_CONTOUR_UNIT_M = 5;
export interface RouteEditContours {
layer: PreparedLayer;
/** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */
intervalM: number;
source: "las" | "sheet";
}
interface ContourResponse {
contours: Array<{ level: number; coordinates: Array<[number, number, number]> }>;
}
/**
* . LAS, .
*
* LAS ** ** ,
* `source` .
*/
export async function loadRouteEditContours(
projectId: string,
meta: VWorldMeta,
normalizer: Normalizer,
sheet: GeoJsonCollection | null,
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
): Promise<RouteEditContours> {
if (options.surfaceModelId !== null) {
// 받아 오는 간격은 프로젝트 설정 그대로(보관함에 이미 있는 파일을 쓰려는 것) —
// **보이는 눈금**은 아래에서 5m 로 맞춘다.
const interval = options.intervalM > 0 ? options.intervalM : 1;
try {
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
const data = await fetchCachedJson<ContourResponse>(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
`/contour?interval=${interval}&smooth=${options.smooth}`,
);
const lines = (data.contours ?? [])
// 5m 단위만 남긴다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다.
.filter((contour) => Math.abs(contour.level % LAS_CONTOUR_UNIT_M) < 1e-6)
.map((contour) => ({
points: contour.coordinates.map(([x, y]) => [x, y] as const),
label: contour.level,
}))
.filter((line) => line.points.length >= 2);
if (lines.length > 0) {
return {
layer: prepareMetricPolylines(lines, meta),
intervalM: LAS_CONTOUR_UNIT_M,
source: "las",
};
}
} catch {
/* 내려앉는다 — 아래 도엽 갈래로 이어 간다. */
}
}
const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS);
return { layer, intervalM: inferIntervalM(layer), source: "sheet" };
}
/** 도엽 등고선의 간격(m) — 표고 값들의 **가장 좁은 칸**을 간격으로 본다. */
function inferIntervalM(layer: PreparedLayer): number {
const levels = [
...new Set(
layer.features
.map((feature) => feature.labelValue)
.filter((value): value is number => value !== null),
),
].sort((a, b) => a - b);
let smallest = Infinity;
for (let index = 1; index < levels.length; index += 1) {
const gap = levels[index] - levels[index - 1];
if (gap > 0 && gap < smallest) smallest = gap;
}
return Number.isFinite(smallest) ? smallest : FALLBACK_INTERVAL_M;
}
@@ -0,0 +1,154 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross.ts
* ** ** ** **( 0-9 ).
*
* · = ** **. .
* · = ** **. , ** **
* .
*
* (2026-09-12 ) ** · ·
* **. .
*
* ** ** [] .
* ( ) .
*
* **B05·B06 ** · `generate_sections`,
* `compute_cross_design`(), B06 `fillSlopeLengths`.
* ========================================================================== */
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import {
buildCrossSvg,
CROSS_PLOT_PAD,
summarizeCross,
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
import { formatStation } from "./B05_Profile_Util_Station";
export interface CrossPreviewParams {
projectId: string;
/** 두 판이 들어앉을 오른쪽 세로 칸. */
side: HTMLElement;
/** 지금 편집값 — 셈을 부르는 순간에 읽는다. */
request: () => {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
min_radius_m: number;
station_interval_m: number;
};
}
export interface CrossPreviewWindow {
/** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */
open: (chainageM: number) => Promise<void>;
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
refresh: () => Promise<void>;
}
interface CrossPane {
root: HTMLElement;
/** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */
show: (preview: CrossPreviewResponse | null, intervalM: number) => void;
/** 기다리는 중임을 알린다. */
wait: (text: string) => void;
}
function createPane(title: string, empty: string): CrossPane {
const root = document.createElement("section");
root.className = "b05-routeedit__cross";
root.innerHTML = `
<div class="b05-routeedit__cross-head">
<strong class="b05-routeedit__cross-title">${title}</strong>
<span class="b05-routeedit__cross-station"></span>
</div>
<div class="b05-routeedit__cross-box"></div>
<div class="b05-routeedit__cross-foot">${empty}</div>`;
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
return {
root,
show(preview, intervalM) {
box.replaceChildren();
if (!preview) {
station.textContent = "";
foot.textContent = empty;
return;
}
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
station.textContent = formatStation(preview.chainage_m, intervalM);
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
const rect = box.getBoundingClientRect();
const plot = buildCrossSvg(
preview,
Math.max(Math.round(rect.width), 240),
Math.max(Math.round(rect.height), 150),
);
box.append(plot.svg);
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
});
foot.textContent = summarizeCross(preview);
},
wait(text) {
box.replaceChildren();
foot.textContent = text;
},
};
}
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다.");
const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다.");
params.side.append(current.root, previous.root);
/** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */
let watching: number | null = null;
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */
let shown: CrossPreviewResponse | null = null;
/** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */
let ticket = 0;
async function load(chainageM: number, keepPrevious: boolean): Promise<void> {
const mine = ++ticket;
const request = params.request();
if (keepPrevious && shown) previous.show(shown, request.station_interval_m);
current.wait("읽는 중…");
try {
const preview = await fetchCrossPreview(params.projectId, {
...request,
chainage_m: chainageM,
});
if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다.
shown = preview;
current.show(preview, request.station_interval_m);
} catch (error) {
if (mine !== ticket) return;
shown = null;
current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다.");
}
}
return {
async open(chainageM) {
// 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은
// 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다.
previous.show(null, params.request().station_interval_m);
watching = chainageM;
await load(chainageM, false);
},
async refresh() {
if (watching === null) return;
await load(watching, true);
},
};
}
@@ -0,0 +1,216 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross_Draw.ts
* **SVG ** · .
*
* `B05_Profile_UI_RouteEdit_Cross.ts` (2026-09-12, 700 ).
* B05·B06 .
*
* ** SVG **(2026-09-12 )
* . SVG . · **
* **(`A00_Common/b_svg_zoom_pan.ts`) B06 .
*
* ** · **(2026-09-12 ).
* `design_line` ** **,
* . B06 2026-08-20
* (`appendCrossDesignOverlay`)
* `toeOffsets` .
* ========================================================================== */
import { svgElement, svgText } from "@util/common_util_svg";
import type { ContentBounds } from "../A00_Common/b_svg_zoom_pan";
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
import { fillSlopeLengths, toeOffsets } from "./../B06_Section/B06_Section_UI_Cross_Fit";
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
/** 그림 가장자리 여백(px) — 위는 범례 한 줄, 아래는 눈금 안내 한 줄 몫이다. */
export const CROSS_PLOT_PAD = { left: 10, right: 10, top: 20, bottom: 20 };
/** 지은 한 판 — 확대·이동은 `layer` 에만 걸린다(범례·안내 글자는 제자리). */
export interface CrossPlot {
svg: SVGSVGElement;
layer: SVGGElement;
width: number;
height: number;
/** 실제로 선이 놓인 범위 — 확대했을 때 팬이 갈 수 있는 데까지. */
content: ContentBounds;
}
/** 성토사면 길이·절성토 면적 한 줄. */
export function summarizeCross(preview: CrossPreviewResponse): string {
const design = preview.design;
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
const lengths = fillSlopeLengths(asSection(preview));
const sides = (["left", "right"] as const)
.filter((side) => lengths[side] !== null)
.map((side) => {
const value = lengths[side]!;
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
return `${side === "left" ? "좌" : "우"} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
});
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}`;
}
/** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */
function asSection(preview: CrossPreviewResponse): CrossSection {
return { samples: preview.samples, design: preview.design } as unknown as CrossSection;
}
type Point = [number, number];
/**
* **· ** (2026-09-12 ).
*
* ** **
* .
* ( ) .
*/
function clipToToes(line: Point[], low: number, high: number): Point[] {
if (line.length < 2) return line;
const inside = (point: Point): boolean => point[0] >= low - 1e-9 && point[0] <= high + 1e-9;
const cutAt = (from: Point, to: Point, edge: number): Point => {
const span = to[0] - from[0];
const ratio = Math.abs(span) <= 1e-9 ? 0 : (edge - from[0]) / span;
return [edge, from[1] + (to[1] - from[1]) * ratio];
};
const out: Point[] = [];
line.forEach((point, index) => {
if (inside(point)) out.push(point);
const next = line[index + 1];
if (!next) return;
// 경계를 넘어가는 구간은 경계 자리에 점을 하나 세운다. 가까운 경계부터 넣어야 순서가 산다.
[low, high]
.filter((edge) => (point[0] - edge) * (next[0] - edge) < 0)
.sort((a, b) => Math.abs(a - point[0]) - Math.abs(b - point[0]))
.forEach((edge) => out.push(cutAt(point, next, edge)));
});
return out;
}
/**
* . (+offset)
* (`generate_sections` cad_exchange ).
*
* **· ** .
* (2026-09-12 실화면: 노면이 ).
*/
export function buildCrossSvg(
preview: CrossPreviewResponse,
width: number,
height: number,
): CrossPlot {
const svg = svgElement("svg", {
viewBox: `0 0 ${width} ${height}`,
width: "100%",
height: "100%",
preserveAspectRatio: "none",
class: "b05-routeedit__cross-svg",
});
const layer = svgElement("g", { class: "b05-routeedit__cross-plot" });
const ground = preview.samples
.filter((sample) => sample.valid && sample.elevation_m !== null)
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as Point);
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
// 계획 횡단선은 사면 끝에서 끊는다 — 그 바깥은 원지반이고 지반선이 이미 그린다.
const design = clipToToes(
(preview.design?.design_line ?? []).map(
(point) => [point.offset_m, point.elevation_m] as Point,
),
toes.right ?? -Infinity, // offset = 우
toes.left ?? Infinity, // +offset = 좌
);
const all = [...ground, ...design];
if (all.length < 2) {
svg.append(layer);
return { svg, layer, width, height, content: { x: 0, y: 0, width, height } };
}
const offsets = all.map((point) => point[0]);
const heights = all.map((point) => point[1]);
const minOffset = Math.min(...offsets);
const maxOffset = Math.max(...offsets);
const minZ = Math.min(...heights);
const maxZ = Math.max(...heights);
const spanX = maxOffset - minOffset || 1;
const spanZ = maxZ - minZ || 1;
const plotWidth = Math.max(width - CROSS_PLOT_PAD.left - CROSS_PLOT_PAD.right, 1);
const plotHeight = Math.max(height - CROSS_PLOT_PAD.top - CROSS_PLOT_PAD.bottom, 1);
const scale = Math.min(plotWidth / spanX, plotHeight / spanZ);
const centerOffset = (minOffset + maxOffset) / 2;
const centerZ = (minZ + maxZ) / 2;
const toScreen = (point: Point): Point => [
width / 2 + (centerOffset - point[0]) * scale,
height / 2 + (centerZ - point[1]) * scale,
];
const path = (points: Point[], className: string): void => {
if (points.length < 2) return;
layer.append(
svgElement("polyline", {
class: className,
points: points.map((point) => toScreen(point).join(",")).join(" "),
}),
);
};
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
const [centerX] = toScreen([0, centerZ]);
layer.append(
svgElement("line", {
class: "b05-routeedit__cross-axis",
x1: centerX,
y1: CROSS_PLOT_PAD.top / 2,
x2: centerX,
y2: height - CROSS_PLOT_PAD.bottom / 2,
}),
);
path(ground, "b05-routeedit__cross-ground");
path(design, "b05-routeedit__cross-design");
// 범례·눈금 안내는 **확대해도 제자리**다 — 확대 묶음 밖에 둔다.
svg.append(layer);
svg.append(
svgText("원지반", {
class: "b05-routeedit__cross-key is-ground",
x: CROSS_PLOT_PAD.left,
y: 13,
"text-anchor": "start",
}),
svgText("기본 계획 횡단", {
class: "b05-routeedit__cross-key is-design",
x: width - CROSS_PLOT_PAD.right,
y: 13,
"text-anchor": "end",
}),
svgText(
`${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
{
class: "b05-routeedit__cross-key",
x: width / 2,
y: height - 6,
"text-anchor": "middle",
},
),
);
const screens = all.map(toScreen);
const xs = screens.map((point) => point[0]);
const ys = screens.map((point) => point[1]);
return {
svg,
layer,
width,
height,
content: {
x: Math.min(...xs),
y: Math.min(...ys),
width: Math.max(...xs) - Math.min(...xs),
height: Math.max(...ys) - Math.min(...ys),
},
};
}
@@ -0,0 +1,176 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_CurveBar.ts
* **** ,
* . `_Label` .
*
* `B05_Profile_UI_RouteEdit.ts` 700 (2026-09-12).
* , `state()` .
*
* **R **(L = R·Δ) ** **
* . (`_Edits.ts` ).
* ========================================================================== */
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import {
curveOptionalAt,
deflectionRad,
radiusFloorM,
type CurveLock,
} from "./B05_Profile_UI_RouteEdit_Edits";
import {
centerDirectionOf,
createCurveLabel,
type CurveLabel,
} from "./B05_Profile_UI_RouteEdit_Label";
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
export interface CurveBarState {
picked: number;
planned: Vertex[];
nodeInfo: EditedNode[];
curveInfo: EditedCurve[];
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①). */
apexLock: boolean[];
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM: number;
limitArcM: number;
}
export interface CurveBarParams {
canvas: HTMLCanvasElement;
state: () => CurveBarState;
/** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
toScreen: (vertex: Vertex) => [number, number];
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
applyEdit: (message: string) => void;
/** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */
onUnselect: () => void;
}
export interface CurveBar {
label: CurveLabel;
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
sync: () => void;
}
export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel({
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
params.applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onApexLock: (locked) => {
const { picked, apexLock } = params.state();
if (picked < 0) return;
apexLock[picked] = locked;
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
params.applyEdit(locked ? "교각점을 고정했습니다." : "교각점 고정을 풀었습니다.");
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
function sync(): void {
const {
picked,
planned,
nodeInfo,
curveInfo,
curveOn,
curveRadius,
curveLock,
apexLock,
limitRadiusM,
limitArcM,
} = params.state();
if (!(picked > 0 && picked < planned.length - 1)) {
label.hide();
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null;
const optional = curveOptionalAt(innerAngle);
const deflection = deflectionRad(innerAngle);
const rect = params.canvas.getBoundingClientRect();
const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
bounds: {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
},
centerDirection: pickedCurve
? centerDirectionOf(
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
params.toScreen(pickedCurve.start),
params.toScreen(pickedCurve.end),
)
: null,
curveOn: curveOn[picked] !== false,
radiusShown: shown,
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
lock: curveLock[picked] ?? null,
apexLocked: apexLock[picked] === true,
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
curveOptional: optional,
// 칸이 막는 하한은 **그 자리 값**이다 — R 칸은 L 하한까지 환산해 함께 보고,
// 곡선을 생략해도 되는 자리(내각 155° 이상)는 L 하한을 안 건다(2026-09-12 확정).
limitRadiusM: radiusFloorM(innerAngle, limitRadiusM, limitArcM),
limitArcM: optional ? 0 : limitArcM,
});
}
return { label, sync };
}
+114 -2
View File
@@ -12,6 +12,12 @@
*
* R (`buildEditedPolyline`).
* ** ** .
*
* ** R L **(2026-09-12 ). L = R·Δ
* ** ** `radiusFloorM` .
* ** 155° **(2 .2..(1) 155
* ). L 0 R
* , ** **(실측: 내각 174.8° R 12m 55m).
* ========================================================================== */
import {
@@ -20,11 +26,43 @@ import {
type EditedNode,
type Vertex,
} from "./B05_Profile_UI_RouteEdit_Curve";
import { deflectionRad } from "./B05_Profile_UI_RouteEdit_Label";
/** 무엇을 붙들고 있나. */
export type CurveLock = "radius" | "arc" | null;
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
return ((180 - innerAngleDeg) * Math.PI) / 180;
}
/** 곡선을 **안 둘 수 있는** 내각(도) — 별표2 .2.다.(1) · .3.라.(1). */
export const CURVE_OPTIONAL_INNER_ANGLE_DEG = 155;
/** 그 자리에서 곡선을 생략해도 되나 — 내각이 155° 이상이면 그렇다. */
export function curveOptionalAt(innerAngleDeg: number | null | undefined): boolean {
return innerAngleDeg !== null && innerAngleDeg !== undefined
? innerAngleDeg >= CURVE_OPTIONAL_INNER_ANGLE_DEG
: false;
}
/**
* ** **(m) R L .
*
* L = R·Δ L R /Δ . .
* 155° ( ) **R ** .
*/
export function radiusFloorM(
innerAngleDeg: number | null | undefined,
limitRadiusM: number,
limitArcM: number,
): number {
if (!(limitArcM > 0) || curveOptionalAt(innerAngleDeg)) return limitRadiusM;
const deflection = deflectionRad(innerAngleDeg);
if (!(deflection > 1e-9)) return limitRadiusM;
return Math.max(limitRadiusM, limitArcM / deflection);
}
/**
* ** ** (`curveRadius` ).
*
@@ -48,11 +86,81 @@ export function applyArcLocks(
}
}
/**
* **(R·L) **( 0-9 , 2026-09-12 ).
*
* ** () ** ,
* R .
*
* `radiusFloorM` R·L 155° R .
*/
export function applyCurveLimits(
planned: Vertex[],
curveOn: ReadonlyArray<boolean>,
curveRadius: Array<number | null>,
limitRadiusM: number,
limitArcM = 0,
): void {
if (limitRadiusM <= 0 && limitArcM <= 0) return;
for (let seat = 1; seat < planned.length - 1; seat += 1) {
if (curveOn[seat] === false) continue;
const current = curveRadius[seat];
if (current === null || current === undefined) continue;
const inner = innerAngleDeg(planned[seat - 1], planned[seat], planned[seat + 1]);
const floor = radiusFloorM(inner, limitRadiusM, limitArcM);
if (current < floor) curveRadius[seat] = floor;
}
}
/**
* **(R·L)** (m). 0.
*
* `radiusFloorM` L .
* ( 155° ) R .
* ** **.
*
* R .
* , ** **
* (2026-09-12).
*
* . **
* ** ( 1 2
* ). .
*/
export function curveShortfalls(
nodes: ReadonlyArray<EditedNode>,
limitRadiusM: number,
limitArcM = 0,
): number[] {
return nodes.map((node) => {
if (node.radius_m === null) return 0;
const floor = radiusFloorM(node.inner_angle_deg, limitRadiusM, limitArcM);
if (floor <= 0) return 0;
return Math.max(0, floor - node.radius_m);
});
}
/**
* ** **. (·)
* .
*
* ** **
* 1px (2026-09-12
* ). ( ), **
* ** .
*/
export function shortfallCrossed(before: readonly number[], after: readonly number[]): boolean {
if (before.length !== after.length) return false;
return after.some((value, index) => value > 1e-6 && before[index] <= 1e-6);
}
export interface CurveSummaryInput {
nodeCount: number;
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
/** 교각점을 못 박은 자리 — 「고정 N곳」에 함께 센다(2026-09-12 사용자 지시 ①). */
apexLock: boolean[];
/** 그려 낸 곡선 수 — 아직 안 그렸으면 0. */
curveCount: number;
/** 법정 기준을 못 맞춘 자리 수. */
@@ -68,7 +176,8 @@ export function curveSummary(input: CurveSummaryInput): string {
(on, index) => !on && index > 0 && index < input.nodeCount - 1,
).length;
const forced = input.curveRadius.filter((value) => value !== null).length;
const locked = input.curveLock.filter((lock) => lock !== null).length;
const locked =
input.curveLock.filter((lock) => lock !== null).length + input.apexLock.filter(Boolean).length;
const edits = [
off ? `곡선 지움 ${off}` : "",
forced ? `R 지정 ${forced}` : "",
@@ -96,6 +205,7 @@ export interface FlattenedPlan {
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
apexLock: boolean[];
}
interface ServerNode {
@@ -137,6 +247,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
curveRadius: [],
curveLock: [],
curveArc: [],
apexLock: [],
};
nodes.forEach((node, index) => {
if (dropped.has(index)) return;
@@ -154,6 +265,7 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
out.curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
out.curveLock.push(null);
out.curveArc.push(null);
out.apexLock.push(false);
if (curve) out.curves.push({ ...curve, node_first: seat, node_last: seat });
});
return out;
@@ -21,6 +21,8 @@ export interface RouteEditSnapshot {
curveLock: Array<"radius" | "arc" | null>;
/** 길이를 붙들었을 때의 그 길이(m). */
curveArc: Array<number | null>;
/** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */
apexLock: boolean[];
picked: number;
}
@@ -50,6 +52,7 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
curveRadius: [...snapshot.curveRadius],
curveLock: [...snapshot.curveLock],
curveArc: [...snapshot.curveArc],
apexLock: [...snapshot.apexLock],
picked: snapshot.picked,
};
}
@@ -64,6 +67,7 @@ function sameRoute(a: RouteEditSnapshot, b: RouteEditSnapshot): boolean {
if (a.curveRadius[index] !== b.curveRadius[index]) return false;
if (a.curveLock[index] !== b.curveLock[index]) return false;
if (a.curveArc[index] !== b.curveArc[index]) return false;
if (a.apexLock[index] !== b.apexLock[index]) return false;
}
return true;
}
+102 -7
View File
@@ -29,12 +29,16 @@ export interface RouteEditNavigationParams {
getView: () => ViewState;
setView: (next: ViewState) => void;
getMeta: () => VWorldMeta | null;
/** (dx, dy) ** ** .
* ( ). */
unrotateDelta?: (dx: number, dy: number) => [number, number];
draw: () => void;
}
/** 캔버스에 휠 확대·가운데 버튼 팬을 붙인다. 리스너는 캔버스와 수명이 같다. */
export function bindRouteEditNavigation(params: RouteEditNavigationParams): void {
const { canvas, getView, setView, getMeta, draw } = params;
const unrotateDelta = params.unrotateDelta ?? ((dx: number, dy: number) => [dx, dy]);
let dragStart: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
canvas.addEventListener(
@@ -53,8 +57,10 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
const ratio = scale / view.scale;
const rect = canvas.getBoundingClientRect();
// 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다.
const cursorX = event.clientX - rect.left - rect.width / 2;
const cursorY = event.clientY - rect.top - rect.height / 2;
const [cursorX, cursorY] = unrotateDelta(
event.clientX - rect.left - rect.width / 2,
event.clientY - rect.top - rect.height / 2,
);
setView({
...view,
scale,
@@ -84,11 +90,8 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
canvas.addEventListener("pointermove", (event) => {
if (!dragStart) return;
setView({
...getView(),
offsetX: dragStart.offsetX + event.clientX - dragStart.x,
offsetY: dragStart.offsetY + event.clientY - dragStart.y,
});
const [dx, dy] = unrotateDelta(event.clientX - dragStart.x, event.clientY - dragStart.y);
setView({ ...getView(), offsetX: dragStart.offsetX + dx, offsetY: dragStart.offsetY + dy });
draw();
});
@@ -181,6 +184,98 @@ export function segmentAtScreen(
return best;
}
/** 노선 위 한 점 — 어디를 짚었나와 그 자리의 누가거리. */
export interface RoutePointHit {
/** 사업지 좌표(m). */
point: [number, number];
/** 시점에서 노선을 따라간 거리(m). */
chainageM: number;
}
/**
* ( ) ** ** . null.
*
* · ( 0-9 )
* . .
*/
export function routePointAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
px: number,
py: number,
maxPx: number,
): RoutePointHit | null {
let best: RoutePointHit | null = null;
let bestDistance = maxPx;
let travelled = 0;
for (let index = 0; index < line.length - 1; index += 1) {
const from = line[index];
const to = line[index + 1];
const segmentM = Math.hypot(to[0] - from[0], to[1] - from[1]);
const [ax, ay] = toScreen(from);
const [bx, by] = toScreen(to);
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
if (distance < bestDistance) {
bestDistance = distance;
best = {
point: [from[0] + (to[0] - from[0]) * t, from[1] + (to[1] - from[1]) * t],
chainageM: travelled + segmentM * t,
};
}
travelled += segmentM;
}
return best;
}
/**
* ** ** (m). null( 0-9 ).
*
* `drawStationTicks` ** **
* . .
*/
export function stationAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
intervalM: number,
px: number,
py: number,
maxPx: number,
): number | null {
if (line.length < 2 || !(intervalM > 0)) return null;
const cumulative: number[] = [0];
for (let index = 1; index < line.length; index += 1) {
cumulative.push(
cumulative[index - 1] +
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
);
}
const total = cumulative[cumulative.length - 1];
let best: number | null = null;
let bestDistance = maxPx;
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += intervalM) {
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = line[cursor - 1];
const front = line[cursor];
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
const [x, y] = toScreen([
back[0] + (front[0] - back[0]) * ratio,
back[1] + (front[1] - back[1]) * ratio,
]);
const distance = Math.hypot(x - px, y - py);
if (distance < bestDistance) {
bestDistance = distance;
best = chainage;
}
}
return best;
}
/** `bandM` . null.
*
* ** ** ·· .
+101 -21
View File
@@ -11,19 +11,20 @@
*
* ****(2026-09-07 )
* · `document.body` `position: fixed` `overflow: hidden`
* ****. .
* ****.
* · ** **(2026-09-12 )
* .
* .
* · ** **, **16** (4 ).
* · ** **. ,
* .
* ========================================================================== */
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
import { CURVE_OPTIONAL_INNER_ANGLE_DEG, type CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
/** 그 꺾임점의 **교각 Δ**(라디안) — 내각의 나머지. 곡선 길이 L = R·Δ 에 쓴다. */
export function deflectionRad(innerAngleDeg: number | null | undefined): number {
if (innerAngleDeg === null || innerAngleDeg === undefined) return 0;
return ((180 - innerAngleDeg) * Math.PI) / 180;
}
// `deflectionRad` 는 화면을 안 만지는 셈이라 `_Edits.ts` 로 옮겼다(2026-09-12) — 순수
// 함수가 DOM 파일에 얹혀 있으면 시험이 화면째 들여와야 한다. 재수출로 부르던 자리를 지킨다.
export { deflectionRad } from "./B05_Profile_UI_RouteEdit_Edits";
/** ** **() .
*
@@ -60,20 +61,33 @@ export interface CurveLabelState {
at: [number, number];
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */
centerDirection: [number, number] | null;
/** 패널이 넘어가면 안 되는 테두리(화면 좌표) — 보통 모달의 지도 칸. 없으면 안 가둔다. */
bounds?: { left: number; top: number; right: number; bottom: number };
curveOn: boolean;
radiusShown: number | null;
/** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */
arcLengthShown: number | null;
lock: CurveLock;
/** 교각점(이 꺾임점) 자리를 못 박았나 — 켜면 끌어서 못 옮긴다(2026-09-12 사용자 지시 ①). */
apexLocked: boolean;
/** 내각 155° 이상이라 **곡선을 안 둬도 되는** 자리인가(별표2 .2.다.(1)). */
curveOptional: boolean;
innerAngleDeg: number | null;
/** **못 넘는** 반지름·곡선 길이 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
limitRadiusM?: number;
limitArcM?: number;
}
export interface CurveLabelHandlers {
/** 닫기 단추 — 고른 꺾임점을 푼다(계획서 0-9 ㉖). */
onClose: () => void;
onRadius: (value: number | null) => void;
onArcLength: (value: number | null) => void;
onCurveOn: (on: boolean) => void;
/** 무엇을 붙들지 바꿨다 — 같은 것을 다시 누르면 null(품). */
onLock: (lock: CurveLock) => void;
/** 교각점 자리를 못 박거나 푼다. */
onApexLock: (locked: boolean) => void;
}
export interface CurveLabel {
@@ -106,6 +120,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<div class="b05-routeedit__label-head">
<span class="b05-routeedit__curve-label"></span>
<button type="button" class="b05-routeedit__label-toggle" data-act="curve-toggle"></button>
<button type="button" class="b05-routeedit__label-close" data-act="curve-close"
aria-label="닫기" title="닫기"></button>
</div>
<label class="b05-routeedit__curve-field">
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
@@ -119,8 +135,11 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다"></button>
</label>
<span class="b05-routeedit__curve-info"></span>
<span class="b05-routeedit__curve-note"> </span>`;
<div class="b05-routeedit__curve-field">
<button type="button" class="b05-routeedit__lock" data-act="lock-apex"
title="교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다"></button>
</div>
<span class="b05-routeedit__curve-info"></span>`;
document.body.append(root);
const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!;
@@ -130,6 +149,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
const arc = root.querySelector<HTMLInputElement>(".b05-routeedit__curve-arc")!;
const lockRadius = root.querySelector<HTMLButtonElement>('[data-act="lock-radius"]')!;
const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!;
const lockApex = root.querySelector<HTMLButtonElement>('[data-act="lock-apex"]')!;
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
@@ -139,20 +159,38 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
let curveOn = true;
let lock: CurveLock = null;
let apexLocked = false;
let seat = -1;
/** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */
let manual: [number, number] | null = null;
let anchor: [number, number] = [0, 0];
/** 지금 자리의 하한 — 칸이 여기서 멈춘다. 0이면 제한 없음. */
let limitRadius = 0;
let limitArc = 0;
/** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */
let limit: CurveLabelState["bounds"];
const numberOf = (input: HTMLInputElement): number | null => {
/** . ** , **
* (2026-09-12 ) . */
const numberOf = (input: HTMLInputElement, floor: number): number | null => {
const value = Number(input.value);
return input.value.trim() !== "" && Number.isFinite(value) && value > 0 ? value : null;
if (input.value.trim() === "" || !Number.isFinite(value) || value <= 0) return null;
if (floor > 0 && value < floor) {
input.value = String(floor);
return floor;
}
return value;
};
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc)));
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc)));
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
root
.querySelector('[data-act="curve-close"]')!
.addEventListener("click", () => handlers.onClose());
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
// 교각점 고정은 **곡선 유무와 무관**하다 — 곡선을 지운 자리도 꺾임점 자리는 못 박을 수 있다.
lockApex.addEventListener("click", () => handlers.onApexLock(!apexLocked));
// ── 머리를 잡아 옮기기 — 곡선을 가리면 손으로 치울 수 있어야 한다 ──
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
@@ -169,8 +207,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
});
head.addEventListener("pointermove", (event) => {
if (!dragFrom) return;
const left = dragFrom.left + event.clientX - dragFrom.x;
const top = dragFrom.top + event.clientY - dragFrom.y;
// 끄는 동안에도 테두리를 지킨다 — 놓은 뒤에만 가두면 손이 간 자리에서 패널이 튄다.
const [left, top] = clamp(
dragFrom.left + event.clientX - dragFrom.x,
dragFrom.top + event.clientY - dragFrom.y,
root.offsetWidth,
root.offsetHeight,
limit,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
// 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다.
@@ -183,7 +227,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
head.addEventListener("pointerup", stopDrag);
head.addEventListener("pointercancel", stopDrag);
/** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */
/** , 16. .
* ** ** . */
function place(state: CurveLabelState): void {
const width = root.offsetWidth;
const height = root.offsetHeight;
@@ -193,12 +238,32 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
: ([1, 0] as [number, number]);
const distance = GAP_PX + boxReach(away[0], away[1], width, height);
anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2];
const left = anchor[0] + (manual ? manual[0] : 0);
const top = anchor[1] + (manual ? manual[1] : 0);
const [left, top] = clamp(
anchor[0] + (manual ? manual[0] : 0),
anchor[1] + (manual ? manual[1] : 0),
width,
height,
state.bounds,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
}
/** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */
function clamp(
left: number,
top: number,
width: number,
height: number,
bounds: CurveLabelState["bounds"],
): [number, number] {
if (!bounds) return [left, top];
return [
Math.max(bounds.left, Math.min(left, bounds.right - width)),
Math.max(bounds.top, Math.min(top, bounds.bottom - height)),
];
}
return {
show(state) {
if (state.seat !== seat) {
@@ -207,6 +272,13 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
}
curveOn = state.curveOn;
lock = state.lock;
apexLocked = state.apexLocked;
limit = state.bounds;
limitRadius = state.limitRadiusM ?? 0;
limitArc = state.limitArcM ?? 0;
// 칸 자체에도 하한을 박아 화살표·스피너가 그 아래로 안 내려가게 한다.
radius.min = limitRadius > 0 ? String(limitRadius) : "1";
arc.min = limitArc > 0 ? String(limitArc) : "1";
root.hidden = false;
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
@@ -216,15 +288,23 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
lockArc.disabled = !state.curveOn;
lockRadius.classList.toggle("is-on", lock === "radius");
lockArc.classList.toggle("is-on", lock === "arc");
lockApex.classList.toggle("is-on", apexLocked);
radius.value =
state.radiusShown === null ? "" : String(Math.round(state.radiusShown * 10) / 10);
arc.value =
state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10);
const inner = state.innerAngleDeg;
const held =
lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음";
// 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로,
// 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다.
// 내각 155° 이상은 법이 곡선을 안 둬도 된다고 한 자리다 — L 하한을 안 거는 까닭이
// 여기서 보여야 한다(2026-09-12 사용자 확정).
info.textContent = state.curveOn
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}`
? inner
? `내각 ${Math.round(inner)}°` +
(state.curveOptional
? ` · ${CURVE_OPTIONAL_INNER_ANGLE_DEG}° 이상 — 곡선 생략 가능`
: "")
: ""
: "곡선 없음 — 직선이 그대로 꺾입니다";
place(state);
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
@@ -0,0 +1,120 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Measure.ts
* ** ** ( 0-9 ).
*
* Shift+ a·b . ·
* , .
*
* ** ** (`/route/elevations`).
* ( 0-2 7)
* .
* ========================================================================== */
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
type Vertex = [number, number];
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
const MEASURE_HIT_PX = 14;
interface MeasurePoint extends RoutePointHit {
/** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */
z: number | null;
}
export interface MeasureToolParams {
projectId: string;
/** 규칙 측점 간격(m) — 측점 표기에 쓴다. */
stationIntervalM: number;
/** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
line: () => Vertex[];
toScreen: (vertex: Vertex) => [number, number];
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
isClosed: () => boolean;
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
onChange: () => void;
}
export interface MeasureMark {
point: Vertex;
/** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
chainageM: number;
}
export interface MeasureTool {
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */
marks: () => MeasureMark[];
/** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */
hint: () => string;
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
active: () => boolean;
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>;
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
clear: () => void;
}
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
const hint = (): string => {
if (picked.length === 0) return "";
const first = picked[0];
if (picked.length === 1) {
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
}
const second = picked[1];
const span = Math.abs(second.chainageM - first.chainageM);
const head =
`구간 ${formatStation(first.chainageM, params.stationIntervalM)}` +
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (first.z === null || second.z === null || span <= 1e-6) {
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
}
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
const rise = second.z - first.z;
return (
`${head} · 지반고 ${first.z.toFixed(1)}${second.z.toFixed(1)}m` +
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
);
};
return {
marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })),
hint,
active: () => picked.length > 0,
clear() {
if (picked.length === 0) return;
picked = [];
params.onChange();
},
async pick(px, py) {
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) {
picked = []; // 노선을 빗나가면 재던 것을 접는다.
params.onChange();
return;
}
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
params.onChange();
if (picked.length < 2) return;
const asked = picked;
try {
const heights = await fetchRouteElevations(
params.projectId,
asked.map((entry) => entry.point),
);
if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다.
asked.forEach((entry, index) => {
entry.z = heights[index] ?? null;
});
} catch {
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
}
params.onChange();
},
};
}
@@ -0,0 +1,465 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Render.ts
* **** ···· ,
* ·· .
*
* `B05_Profile_UI_RouteEdit.ts` 700 (2026-09-12).
* , `scene` .
*
* **B04 · **(`drawStationTicks`)
* ( 0-9 ).
* ========================================================================== */
import {
drawPreparedFeature,
drawPreparedLabels,
drawPreparedLayer,
layerScreenBounds,
normalizedToScreen,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import { formatStation } from "./B05_Profile_Util_Station";
/** 노드 반지름(px). */
const NODE_R = 4;
/** ** **(m) (2026-09-07).
*
* . ** **
* ( ).
* `drawPreparedLayer` . */
const CONTOUR_BAND_M = 300;
/** · (px) ** ** .
* 3.5px (2026-09-07 ). */
const CURVE_HANDLE_PX = 5;
/** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */
const OUTWARD_PX = 26;
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
const MEASURE_COLOR = "#22c55e";
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
let total = 0;
for (let index = 1; index < points.length; index += 1) {
total += Math.hypot(
points[index][0] - points[index - 1][0],
points[index][1] - points[index - 1][1],
);
}
return total;
}
export interface RouteEditScene {
view: ViewState;
/** 사업지 좌표(m) → 캔버스 px. */
toScreen: (vertex: Vertex) => [number, number];
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 이 값으로 정한다. */
pxPerMeter: number;
/** 도엽 메타를 읽었나 — 못 읽었으면 등고선 띠를 씌우지 않는다. */
hasMeta: boolean;
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */
contours: RouteEditContours | null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
otherSheets: ReadonlyArray<PreparedLayer>;
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
pickedContour: number;
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */
contourStepM: number;
expected: ReadonlyArray<Vertex>;
/** 그려 보이는 계획노선(원호 포함). */
plannedLine: ReadonlyArray<Vertex>;
/** 잡아 옮기는 노드(꺾임점). */
planned: ReadonlyArray<Vertex>;
nodeInfo: ReadonlyArray<EditedNode>;
curveInfo: ReadonlyArray<EditedCurve>;
curveOn: ReadonlyArray<boolean>;
/** 지금 고른 꺾임점. 없으면 -1. */
picked: number;
/** 규칙 측점 간격(m). */
stationIntervalM: number;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */
measure: ReadonlyArray<{ point: Vertex; chainageM: number }>;
/** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */
rotationRad: number;
/** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */
uprightRad: number;
}
/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */
function upright(
context: CanvasRenderingContext2D,
radians: number,
x: number,
y: number,
paint: () => void,
): void {
if (!radians) {
paint();
return;
}
context.save();
context.translate(x, y);
context.rotate(radians);
context.translate(-x, -y);
paint();
context.restore();
}
export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void {
const { view, toScreen } = scene;
const style = getComputedStyle(document.documentElement);
const line = scene.plannedLine.length ? scene.plannedLine : scene.planned;
context.clearRect(0, 0, view.width, view.height);
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
context.fillRect(0, 0, view.width, view.height);
// 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯).
// 바탕칠은 돌리기 **전에** 해 두었다 — 돌린 뒤에 칠하면 모서리에 빈 곳이 생긴다.
context.save();
if (scene.rotationRad) {
context.translate(view.width / 2, view.height / 2);
context.rotate(scene.rotationRad);
context.translate(-view.width / 2, -view.height / 2);
}
context.save();
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
const band = scene.hasMeta ? contourBandRect(line as Vertex[], toScreen, CONTOUR_BAND_M) : null;
if (band) {
context.beginPath();
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.strokeStyle = style.getPropertyValue("--map-sheet-stream") || "#2563eb";
context.lineWidth = 1.2;
// 바탕이 LAS 면 세류선도 **등고선이 있는 데까지만** 그린다(2026-09-12 사용자 지시 ⑮) —
// 도엽 하천중심선은 도엽 전체를 덮어 LAS 자료 밖까지 길게 뻗는다.
const lasBox =
scene.contours?.source === "las" ? layerScreenBounds(scene.contours.layer, view) : null;
context.save();
if (lasBox) {
context.beginPath();
context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height);
context.clip();
}
for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot");
context.restore();
if (scene.contours) {
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
const everyM = scene.contourStepM;
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
context.lineWidth = 0.8;
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
if (scene.pickedContour >= 0) {
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
context.lineWidth = 2.6;
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
}
// 높이값 라벨은 여기서 안 낸다 — 노선·눈금 **뒤에** 그려야 안 묻힌다(맨 아래 참고).
}
context.restore();
strokePolyline(
context,
toScreen,
scene.expected,
[6, 5],
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
context,
toScreen,
line,
[],
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.lineWidth = 1;
scene.planned.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0;
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
context.fill();
context.stroke();
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
if (
scene.curveOn.length &&
!scene.curveOn[index] &&
index > 0 &&
index < scene.planned.length - 1
) {
context.save();
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.beginPath();
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
context.fill();
context.restore();
}
});
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
context.lineWidth = 2;
scene.curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === scene.picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
drawStationMarks(context, scene, line);
drawMeasureMarks(context, scene, line);
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
// 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
drawContourLabels(context, scene, band, style);
context.restore(); // 회전 끝
}
/** ** ** (2026-09-12 ).
*
* . ** **(`contourStepM`)
* . */
function drawContourLabels(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
band: { x: number; y: number; width: number; height: number } | null,
style: CSSStyleDeclaration,
): void {
if (!scene.contours) return;
context.save();
if (band) {
context.beginPath();
context.rect(band.x, band.y, band.width, band.height);
context.clip();
}
context.font = "10px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
drawPreparedLabels(
context,
scene.contours.layer,
scene.view,
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
scene.contourStepM,
scene.uprightRad,
);
if (scene.pickedContour >= 0) drawPickedContourLabel(context, scene, scene.view);
context.restore();
}
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
function drawMeasureMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
if (scene.measure.length === 0) return;
context.save();
if (scene.measure.length >= 2) {
const span = spanBetween(line, scene.measure[0], scene.measure[1]);
if (span.length >= 2) {
context.strokeStyle = MEASURE_COLOR;
context.lineWidth = 4;
context.beginPath();
span.forEach((vertex, index) => {
const [x, y] = scene.toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
}
}
context.lineWidth = 2.4;
context.strokeStyle = MEASURE_COLOR;
context.font = "bold 11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
scene.measure.forEach((mark, index) => {
const [x, y] = scene.toScreen(mark.point);
context.fillStyle = "rgba(255,255,255,0.95)";
context.beginPath();
context.arc(x, y, 7, 0, Math.PI * 2);
context.fill();
context.stroke();
context.fillStyle = "#14532d";
upright(context, scene.uprightRad, x, y, () => context.fillText(index === 0 ? "a" : "b", x, y));
});
context.restore();
}
/**
* **** ( 0-9 ).
*
* ** ** . a b
* , **
* **(2026-09-12 ). .
*/
function spanBetween(
line: ReadonlyArray<Vertex>,
from: { point: Vertex; chainageM: number },
to: { point: Vertex; chainageM: number },
): Vertex[] {
const low = Math.min(from.chainageM, to.chainageM);
const high = Math.max(from.chainageM, to.chainageM);
const head = from.chainageM <= to.chainageM ? from.point : to.point;
const tail = from.chainageM <= to.chainageM ? to.point : from.point;
const inside: Vertex[] = [];
let travelled = 0;
for (let index = 1; index < line.length; index += 1) {
const step = Math.hypot(
line[index][0] - line[index - 1][0],
line[index][1] - line[index - 1][1],
);
// 정점의 누가거리가 두 점 사이면 그대로 잇는다 — 사이에 없는 정점은 건너뛴다.
if (travelled > low && travelled < high) inside.push(line[index - 1]);
travelled += step;
}
return [head, ...inside, tail];
}
/** 고른 등고선의 **높이값을 크게** 붙인다(계획서 0-9 ㉘) — 색만 바뀌면 몇 m 인지 안 보인다. */
function drawPickedContourLabel(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
view: ViewState,
): void {
const feature = scene.contours?.layer.features[scene.pickedContour];
if (!feature || feature.labelValue === null) return;
const [x, y] = normalizedToScreen(view, feature.labelAnchorX, feature.labelAnchorY);
const text = `${feature.labelValue}m`;
upright(context, scene.uprightRad, x, y, () => {
context.save();
context.font = "bold 13px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
const width = context.measureText(text).width + 10;
context.fillStyle = "#7c3aed";
context.fillRect(x - width / 2, y - 9, width, 18);
context.fillStyle = "#ffffff";
context.fillText(text, x, y);
context.restore();
});
}
/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */
function drawStationMarks(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
if (line.length < 2) return;
// 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게.
drawStationTicks(
context,
line.map(([x, y]) => ({ x, y })),
{
intervalM: scene.stationIntervalM,
pxPerMeter: scene.pxPerMeter,
toScreen: (x, y) => scene.toScreen([x, y]),
uprightRad: scene.uprightRad,
},
);
const total = polylineLengthM(line);
const last = line.length - 1;
endLabel(context, scene, line[0], line[1], `시점 ${formatStation(0, scene.stationIntervalM)}`);
endLabel(
context,
scene,
line[last],
line[last - 1],
`종점 ${formatStation(total, scene.stationIntervalM)}`,
);
}
/** · .
*
* ** **( ).
* (0+0.0 · 50+0.0) ****
* (2026-09-12 ). */
function endLabel(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
at: Vertex,
inward: Vertex,
text: string,
): void {
const [x0, y0] = scene.toScreen(at);
const [x1, y1] = scene.toScreen(inward);
const length = Math.hypot(x0 - x1, y0 - y1) || 1;
const x = x0 + ((x0 - x1) / length) * OUTWARD_PX;
const y = y0 + ((y0 - y1) / length) * OUTWARD_PX;
context.save();
if (scene.uprightRad) {
context.translate(x, y);
context.rotate(scene.uprightRad);
context.translate(-x, -y);
}
context.font = "bold 12px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
const width = context.measureText(text).width + 10;
context.fillStyle = "rgba(255, 255, 255, 0.9)";
context.fillRect(x - width / 2, y - 26, width, 17);
context.strokeStyle = "#f97316";
context.lineWidth = 1;
context.strokeRect(x - width / 2, y - 26, width, 17);
context.fillStyle = "#111111";
context.fillText(text, x, y - 17.5);
context.restore();
}
function strokePolyline(
context: CanvasRenderingContext2D,
toScreen: (vertex: Vertex) => [number, number],
points: ReadonlyArray<Vertex>,
dash: number[],
color: string,
width: number,
): void {
if (points.length < 2) return;
context.save();
context.setLineDash(dash);
context.strokeStyle = color;
context.lineWidth = width;
context.beginPath();
points.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
context.restore();
}
@@ -0,0 +1,83 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Rotate.ts
* · , ** **
* ( 0-9 , 2026-09-12 ).
*
* ** **. (
* CAD ), ** ** .
* .
* ========================================================================== */
/** () 15° . 90° ,
* 5° . */
const ROTATE_STEP_DEG = 15;
/** ** **( 0-9 , 2026-09-12 ).
*
* 180° .
* .
*
* ** `false` **
* (CAD ). . */
export const UPRIGHT_LABELS = true;
export interface MapRotationParams {
/** 단추가 들어 있는 모달 — `[data-act="rotate-ccw"]`·`rotate-cw` 를 찾는다. */
overlay: HTMLElement;
/** 지금 캔버스 크기 — 회전축(한가운데)을 잡는 데 쓴다. */
size: () => { width: number; height: number };
/** 각이 바뀌었다 — 호출부가 다시 그린다. */
onChange: () => void;
}
export interface MapRotation {
/** 지금 돌린 각(라디안). 그리기가 캔버스 변환에 그대로 쓴다. */
radians: () => number;
/** 화면에 보이는 자리 → 그리기 좌표(돌리기 전). */
unrotate: (px: number, py: number) => [number, number];
/** 그리기 좌표 → 화면에 보이는 자리. 떠 있는 패널을 노드 옆에 붙일 때 쓴다. */
rerotate: (px: number, py: number) => [number, number];
/** 화면에서 민 만큼(dx, dy) → 그림 좌표의 만큼. 팬·휠 확대 보정용. */
unrotateDelta: (dx: number, dy: number) => [number, number];
/** 글자를 세울 각(라디안) — 그리기가 라벨마다 이만큼 되돌린다. 안 세우면 0. */
uprightRad: () => number;
}
export function createMapRotation(params: MapRotationParams): MapRotation {
let radians = 0;
const spin = (px: number, py: number, angle: number): [number, number] => {
if (!angle) return [px, py];
const { width, height } = params.size();
const cx = width / 2;
const cy = height / 2;
const cos = Math.cos(angle);
const sin = Math.sin(angle);
const dx = px - cx;
const dy = py - cy;
return [cx + dx * cos - dy * sin, cy + dx * sin + dy * cos];
};
for (const [act, sign] of [
["rotate-ccw", -1],
["rotate-cw", 1],
] as const) {
params.overlay.querySelector(`[data-act="${act}"]`)?.addEventListener("click", () => {
radians += (sign * ROTATE_STEP_DEG * Math.PI) / 180;
params.onChange();
});
}
return {
radians: () => radians,
unrotate: (px, py) => spin(px, py, -radians),
rerotate: (px, py) => spin(px, py, radians),
uprightRad: () => (UPRIGHT_LABELS ? -radians : 0),
unrotateDelta: (dx, dy) => {
if (!radians) return [dx, dy];
const cos = Math.cos(-radians);
const sin = Math.sin(-radians);
return [dx * cos - dy * sin, dx * sin + dy * cos];
},
};
}
@@ -9,12 +9,23 @@
import { chainageToStation, stationToChainage } from "./B05_Profile_Util_Station";
export function field(labelText: string, input: HTMLElement): HTMLLabelElement {
export function field(
labelText: string,
input: HTMLElement,
basis?: string | null,
): HTMLLabelElement {
const wrapper = document.createElement("label");
wrapper.className = "b05-route__field";
const caption = document.createElement("span");
caption.textContent = labelText;
wrapper.append(caption, input);
// 칸 밑 근거 한 줄 — 등록부 `default_basis`(기본값의 뜻 · 10-A 2026-09-14 사용자 확정).
if (basis) {
const note = document.createElement("small");
note.className = "b05-structure__basis";
note.textContent = basis;
wrapper.append(note);
}
return wrapper;
}
@@ -32,6 +43,88 @@ export function select(options: ReadonlyArray<[string, string]>): HTMLSelectElem
return element;
}
/** 등록부 옵션 한 칸의 모양 — `StructureOptionField` 에서 이 칸이 쓰는 몫만. */
interface OptionShape {
input: "select" | "number" | "text";
choices: string[];
default: string | number | null;
required?: boolean;
/** 비워 두면 어떻게 되나 — 칸에 마우스를 올리면 보임. */
empty_means?: string | null;
/** 이 값을 넘으면 칸이 경고색 + 툴팁(막지 않음). */
warn_above?: number | null;
warn_message?: string | null;
/** 원단위 표가 아직 안 읽는 칸 — 툴팁이 이 사유를 먼저 보임. */
not_in_table?: string | null;
/** 기본값의 뜻 — 칸 밑 근거 한 줄. */
default_basis?: string | null;
}
/**
* ** , **
* (· , ). 2026-09-14
* .
* .
*/
export function optionControl(
option: OptionShape,
stored: string | number | undefined,
): HTMLInputElement | HTMLSelectElement {
const value = stored === undefined || stored === null ? "" : String(stored);
const suggested = option.default === null || option.default === "" ? "" : String(option.default);
// 툴팁 — 표가 아직 안 읽는 칸이면 그 사유가 먼저(넣어도 안 바뀜을 알아야 함), 아니면 비울 때의 뜻.
const tip = option.not_in_table ?? option.empty_means ?? "";
if (option.input === "select") {
const blank = suggested
? `— 안 정함 (제안 ${suggested}) —`
: option.required
? "— 선택 —"
: "— 안 정함 —";
const element = select([["", blank], ...option.choices.map((c) => [c, c] as [string, string])]);
element.value = value;
if (tip) element.title = tip;
return element;
}
const element =
option.input === "number" ? numberInput("0.1", "0") : document.createElement("input");
if (option.input !== "number") element.type = "text";
element.value = value;
element.placeholder = suggested ? `제안 ${suggested}` : option.required ? "필수 입력" : "";
if (tip) element.title = tip;
const limit = option.warn_above;
if (typeof limit === "number") {
// 기준을 넘으면 알리기만 — 막지 않음(2026-09-14 브레인 판정 ①·㉰).
const warn = (): void => {
const over = element.value.trim() !== "" && Number(element.value) > limit;
element.classList.toggle("is-outside-standard", over);
element.title = over ? (option.warn_message ?? "") : tip;
};
element.addEventListener("input", warn);
warn();
}
return element;
}
/** [제안값 넣기] — **누른 때만** 빈 칸에 등록부 기본값을 넣음(적은 칸은 안 건드림) · 층따기 단추와 같은 모양. */
export function suggestButton(
entries: ReadonlyArray<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }>,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = "b05-structure__suggest";
button.textContent = "제안값 넣기";
button.addEventListener("click", () => {
const filled = entries.filter((entry) => entry.suggested && !entry.input.value);
filled.forEach((entry) => {
entry.input.value = entry.suggested;
entry.input.dispatchEvent(new Event("input", { bubbles: true }));
});
// 반영은 한 번만 — 칸마다 저장 흐름이 돌지 않게 마지막 칸만 change 를 울림.
filled.at(-1)?.input.dispatchEvent(new Event("change", { bubbles: true }));
});
return button;
}
/** 측점번호 + 잔여거리 두 칸 묶음. */
export interface StationFields {
wrap: HTMLElement;
+20 -29
View File
@@ -14,7 +14,6 @@
* ========================================================================== */
import {
defaultOptions,
isB05Option,
structureAnchorM,
type StructureInstance,
@@ -24,7 +23,7 @@ import {
} from "./B05_Profile_Api_Structures";
import type { PipeFacility } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import { field, numberInput, select } from "./B05_Profile_UI_Structures_Fields";
import { field, optionControl, suggestButton } from "./B05_Profile_UI_Structures_Fields";
import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form";
import {
bindStructuresEvents,
@@ -316,36 +315,24 @@ export function createStructuresSection(
return;
}
optionRow.hidden = false;
const suggestions: Array<{ input: HTMLInputElement | HTMLSelectElement; suggested: string }> =
[];
visible.forEach((option) => {
const preset = values[option.key] ?? option.default ?? "";
let input: HTMLInputElement | HTMLSelectElement;
if (option.input === "select") {
// 빈("선택하세요") 항목은 두지 않는다 — 첫 항목이 곧 기본값이고, 기본값은
// 구조물별로 레지스트리에서 지정한다(2026-08-17 사용자 지시 1).
const choices = option.choices.map((choice) => [choice, choice] as [string, string]);
// 기본값 없는 필수 항목은 **빈 칸으로** — 첫 항목을 슬쩍 고르면 근거 없는 값이 나간다.
const mustPick = option.required === true && (option.default ?? "") === "";
if (mustPick) choices.unshift(["", "— 선택 —"]);
input = select(choices);
input.value = String(preset || (mustPick ? "" : (option.choices[0] ?? "")));
} else if (option.input === "number") {
input = numberInput("0.1", "0");
input.value = String(preset ?? "");
if (option.required) (input as HTMLInputElement).placeholder = "필수 입력";
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = "text";
input.value = String(preset ?? "");
}
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다.
// ⭐ 저장된 값만 칸에 · 등록부 기본값은 제안(회색 글씨·빈 보기 이름)으로만 — 고르기 칸은
// 첫 보기를 미리 안 고름(2026-09-14 브레인 판정 ①②, `optionControl` 주석).
const input = optionControl(option, values[option.key]);
suggestions.push({ input, suggested: String(option.default ?? "") });
const label = `${option.unit ? `${option.label} (${option.unit})` : option.label}${option.not_in_table ? " · 표에 안 쓰임" : ""}`;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다
// (고를 수 없는 칸이라 제안이 아니라 프로그램 값).
const locked = option.enabled === false;
if (locked && !input.value) input.value = String(option.default ?? "");
if (locked) {
input.disabled = true;
input.classList.add("is-locked");
input.title = "지금은 고를 수 없는 항목입니다.";
}
optionRow.append(field(label, input));
optionRow.append(field(label, input, option.default_basis));
input.addEventListener("change", () => liveCommit());
optionInputs.push({
key: option.key,
@@ -384,6 +371,9 @@ export function createStructuresSection(
input.addEventListener("change", () => refresh(true));
}
});
if (suggestions.some((entry) => entry.suggested)) {
optionRow.append(suggestButton(suggestions));
}
syncOptionLock();
syncRangeDisplay();
}
@@ -671,10 +661,11 @@ export function createStructuresSection(
endFields.write(isInterval ? chainageM + DEFAULT_INTERVAL_LENGTH_M : null, step);
memoField.value = "";
syncPlacementFields();
renderOptionFields(type.managed_by ? undefined : defaultOptions(type));
// BOX암거는 레지스트리 기본값(본체 2.0×2.0·날개벽 있음 1m/2m/45°)을 실어 폼을
// 연다 — [추가]만 눌러도 하류가 제원을 받는다(2026-08-17 사용자 확정 유지).
syncFacilityForm(typeId === "box_culvert" ? defaultOptions(type) : {});
// 기본값을 값으로 안 채움 — 칸은 비우고 제안으로만(브레인 판정 ②).
renderOptionFields();
// BOX암거도 기본값을 안 실음 — 제안으로만(판정 ③ · 옛 「[추가]만 눌러도 제원」 걷음).
// 비운 칸은 하류(횡단도)가 등록부 기본값으로 그림 — 저장만 안 함.
syncFacilityForm({});
// 임시 배치도 즉시 진행(A군).
if (type.managed_by) commitTempPipe();
syncButtons();
@@ -231,6 +231,8 @@ export async function commit(ctx: StructuresCommitContext, live = false): Promis
ctx.optionInputs.find((entry) => entry.key === "length_m")?.input.focus();
return;
}
// 높이는 비워도 놓음 — 자리부터 잡고 치수를 뒤에 넣는 길을 막지 않음. 높이가 물량 밑수인
// 벽은 B08 에서 줄만 서고 미확정(금액 밖)으로 뜸(2026-09-14 브레인 판정 ①, `missing_height_reason`).
const before = ctx.readBeforeM();
if (anchor - before < -0.005) {
const beforeInput = ctx.optionInputs.find((entry) => entry.key === "before_m")?.input;
+216 -25
View File
@@ -6,16 +6,21 @@
inset: 0;
z-index: var(--z-modal, 1000);
display: flex;
gap: var(--spacing-12);
align-items: center;
justify-content: center;
padding: var(--spacing-12);
background: rgb(0 0 0 / 55%);
}
/* 메인 창은 **왼쪽**, 횡단 판은 오른쪽 세로 (2026-09-12 사용자 지시 ).
좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 가진다. */
.b05-routeedit__box {
position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
width: min(1200px, 94vw);
max-width: 1200px;
height: min(820px, 92vh);
overflow: hidden;
border: 1px solid var(--color-border);
@@ -33,10 +38,15 @@
border-bottom: 1px solid var(--color-border);
}
.b05-routeedit__hint {
/* 제목행 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ).
단추는 공용 규격(`.ui-btn`) 그대로 쓴다 모달이 따로 만든 크기를 걷어냈다. */
.b05-routeedit__actions {
display: flex;
flex: 1 1 auto;
color: var(--color-text-secondary);
font-size: var(--text-caption);
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-8);
}
.b05-routeedit__close {
@@ -62,27 +72,63 @@
touch-action: none;
}
.b05-routeedit__foot {
display: flex;
flex: none;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
border-top: 1px solid var(--color-border);
}
.b05-routeedit__status {
flex: 1 1 auto;
/* 지도 위에 얹는 판들 아래 정보행을 없애고 여기로 옮겼다(2026-09-12 사용자 지시 ··).
글판은 **클릭을 통과시킨다** 지도 조작을 가리면 된다. */
.b05-routeedit__hint,
.b05-routeedit__info {
position: absolute;
z-index: 1;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
border-radius: var(--radius-8, 6px);
background: color-mix(in srgb, var(--color-surface-raised) 78%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: var(--color-text-secondary);
font-size: var(--text-caption);
pointer-events: none;
}
/* 조작 설명 — **2열**로 묶는다(사용자 지시 ⑩). 한 줄로 늘어놓으면 창이 좁을 때 접힌다. */
.b05-routeedit__hint {
top: var(--spacing-12);
left: var(--spacing-12);
display: grid;
grid-template-columns: auto auto;
gap: 2px var(--spacing-16);
max-width: 52%;
}
/* 상태·범례 — 지도 왼쪽 아래(사용자 지시 ⑫). */
.b05-routeedit__info {
bottom: var(--spacing-12);
left: var(--spacing-12);
display: flex;
flex-direction: column;
gap: 4px;
max-width: 62%;
}
/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯). 여기만 클릭을 받는다. */
.b05-routeedit__spin {
position: absolute;
top: var(--spacing-12);
right: var(--spacing-12);
z-index: 1;
display: flex;
gap: var(--spacing-8);
}
.b05-routeedit__spin .ui-btn {
padding: var(--spacing-8) var(--spacing-12);
font-size: var(--text-body);
line-height: 1;
}
.b05-routeedit__legend {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routeedit__legend i {
@@ -101,20 +147,148 @@
border-top: 2px solid var(--map-route, #f97316);
}
.b05-routeedit__btn {
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. */
.b05-routeedit__side {
display: flex;
flex: none;
padding: var(--spacing-8) var(--spacing-16);
flex-direction: column;
gap: var(--spacing-12);
width: 452px;
height: min(820px, 92vh);
}
@media (width < 1500px) {
/* 자리가 모자라면 오른쪽 칸을 접는다 — 지도가 먼저다. */
.b05-routeedit__side {
display: none;
}
}
.b05-routeedit__cross {
display: flex;
flex: 1 1 0;
min-height: 0;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-16, 12px);
background: var(--color-surface-raised);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
}
.b05-routeedit__cross-head {
display: flex;
flex: none;
align-items: baseline;
gap: var(--spacing-8);
}
.b05-routeedit__cross-station {
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
/* 횡단 캔버스 그림이 아니라 **SVG **이다(2026-09-12 사용자 지시 ).
= 확대·축소, 가운데 버튼 끌기 = 이동, 더블클릭 = 원복(공용 조각이 맡음). */
.b05-routeedit__cross-box {
flex: 1 1 auto;
min-height: 150px;
overflow: hidden;
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface);
color: var(--color-text-body);
cursor: pointer;
}
.b05-routeedit__btn.is-primary {
border-color: transparent;
background: var(--color-primary, #7c3aed);
color: #fff;
.b05-routeedit__cross-svg {
display: block;
width: 100%;
height: 100%;
cursor: default;
}
.b05-routeedit__cross-svg.is-panning {
cursor: grabbing;
}
.b05-routeedit__cross-axis {
stroke: rgb(148 163 184 / 70%);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.b05-routeedit__cross-ground {
fill: none;
stroke: var(--color-text-secondary, #94a3b8);
stroke-width: 1.6;
vector-effect: non-scaling-stroke;
}
.b05-routeedit__cross-design {
fill: none;
stroke: var(--map-route, #f97316);
stroke-width: 2.2;
vector-effect: non-scaling-stroke;
}
.b05-routeedit__cross-key {
fill: var(--color-text-secondary, #94a3b8);
font-size: 11px;
}
.b05-routeedit__cross-key.is-design {
fill: var(--map-route, #f97316);
}
.b05-routeedit__cross-foot {
flex: none;
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.5;
}
/* ㉓ 거리 재기와 되돌리기 사이 구분선. */
.b05-routeedit__divider {
width: 1px;
height: 20px;
margin: 0 var(--spacing-4, 4px);
background: var(--color-border);
}
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */
.b05-routeedit__measure {
position: absolute;
right: var(--spacing-12);
bottom: var(--spacing-12);
z-index: 1;
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
max-width: 52%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, #22c55e 60%, transparent);
border-radius: var(--radius-8, 6px);
background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: var(--color-text-body);
font-size: var(--text-caption);
}
/* 글이 길면 접힌다 — flex 자식은 기본으로 안 줄어들어 왼쪽으로 넘쳐 잘렸다(2026-09-12). */
.b05-routeedit__measure-text {
min-width: 0;
line-height: 1.5;
}
.b05-routeedit__measure-close {
flex: none;
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */
@@ -164,6 +338,15 @@
touch-action: none;
}
.b05-routeedit__label-close {
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1;
cursor: pointer;
}
/* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */
.b05-routeedit__lock {
padding: 1px 6px;
@@ -241,3 +424,11 @@
color: var(--color-text-secondary);
line-height: 1.35;
}
/* 거리 재기 켜진 동안 단추가 눌린 꼴로 남는다(2026-09-12 사용자 지시 ).
`is-active` 예전에도 붙고 있었으나 꼴이 없어 겉모습이 그대로였다. */
.b05-routeedit__actions .ui-btn.is-active {
border-color: transparent;
background: var(--color-primary, #7c3aed);
color: #fff;
}
@@ -214,6 +214,18 @@
line-height: 1.5;
}
/* 칸 밑 근거 한 줄 — 등록부 `default_basis`(10-A). 회색 작은 글씨. */
.b05-structure__basis {
color: var(--color-text-muted, #9aa1ad);
font-size: var(--text-caption, 12px);
line-height: 1.3;
}
/* 근거 줄로 옆 칸이 길어져도 [제안값 넣기]가 세로로 늘지 않게. */
.b05-structure__suggest {
align-self: start;
}
/* 시작·기준·종료 측점 = 3행. 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
+3 -15
View File
@@ -28,9 +28,6 @@ type PendingMap = Record<string, CulvertOptionPatch>;
export interface CulvertOptionWriter {
/** 측점 옵션을 세션에 예약한다. 같은 측점의 앞선 예약과는 합쳐진다. */
queue: (chainageM: number, patch: CulvertOptionPatch) => void;
/** (2026-08-29 : B06 ).
* . */
queueMove: (fromChainageM: number, toChainageM: number) => void;
/** 예약분을 즉시 내보낸다([저장]·[확정]에서만 부른다). */
flush: () => Promise<void>;
}
@@ -62,7 +59,9 @@ function writePending(sessionKey: string | null, pending: PendingMap): void {
* . , ** ** []
* .
*/
/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m). */
/** (m).
* ** **(2026-09-12 일원화: B05·B06 ).
* . */
type PendingMoves = Record<string, number>;
function readMoves(sessionKey: string | null): PendingMoves {
@@ -123,17 +122,6 @@ export function createCulvertOptionWriter(
pending[keyOf(chainageM)] = { ...(pending[keyOf(chainageM)] ?? {}), ...patch };
writePending(key, pending);
},
queueMove(fromChainageM, toChainageM) {
const key = moveKey();
if (!key) return;
const moves = readMoves(key);
// 이미 옮긴 관을 또 옮기면 **원래 자리** 기준으로 최종 위치만 남긴다.
const origin =
Object.keys(moves).find((entry) => Math.abs(moves[entry] - fromChainageM) < 0.005) ??
keyOf(fromChainageM);
moves[origin] = toChainageM;
window.sessionStorage.setItem(key, JSON.stringify(moves));
},
async flush() {
try {
await flushCulvertOptions(projectId(), sessionKey(), moveKey());
+9 -1
View File
@@ -207,8 +207,12 @@ export interface CulvertSideSpec {
/** 집수정 기준측점 전/후 몫(m) — 기슭막이와 같은 체계(2026-08-24). 기본 각 1m. */
basin_before_m?: number | null;
basin_after_m?: number | null;
/** 기슭막이 전면 기울기(1:n). 돌쌓기 전면 1:0.3(교본 7-3). */
/** 기슭막이 전면 기울기(1:n) 종전값 — 그림은 `revetWallSpec().lean`(표준경사 표)을 씀. */
face_slope?: number;
/** 독립 기슭막이 기울기 판정 칸(저장 원본) — 설치 측 · 성토/절토 · 전면 기울기(B08 과 같은 칸). */
face_side?: string | null;
face_role?: string | null;
face_slope_ratio?: number | string | null;
/** 보호공(물받이) 길이 = 낙차고 × 2 (사방교본 교차 참조, 2026-08-19 사용자 확정). */
apron_length_m?: number;
/** 보호공 두께 1.0m 내외 (사방교본 교차 참조). */
@@ -265,6 +269,8 @@ export interface FordSet {
min_cover_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
/** BOX암거 측점의 세트 제원 — 상판·내공(유로)·저판 + 날개벽 투영 연장. */
@@ -283,6 +289,8 @@ export interface BoxSet {
span_m: number;
wing_in: FordWingSpec;
wing_out: FordWingSpec;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
export interface CrossSection extends SectionStation {
+101 -12
View File
@@ -74,6 +74,26 @@ APRON_THICKNESS_M = 0.45
# 1:0.3~0.5의 급한 쪽. 지식DB 근거가 있는 값이라 교차 참조 표시가 붙지 않는다.
REVET_FACE_SLOPE = 0.3
# ── 관 벽 최소 높이 — ⚠ TS 짝: `B06_Section_UI_Cross_Culvert_Const.ts`
# (`REVET_FREEBOARD_M`·`REVET_HEIGHT_STEP_M`·`REVET_EMBED_DEPTH_M`·`pipeWallMinPureHeight`).
# 횡단도가 관 벽을 그리는 그 높이 — 구조물 표·구조물도 그림도 이 값을 씀(2026-09-14 브레인 판정
# 「그림 둘이 같은 벽을 다르게 그리면 결함 · 근거 있는 관경 기준으로 맞춤」). 거울 시험
# `test_b06_pipe_wall_height_mirror`.
REVET_FREEBOARD_M = 0.5 # 관 상단 ~ 벽 상단 여유고(잠정 · 토피 0.5 와 같은 값)
REVET_HEIGHT_STEP_M = 0.1 # 높이 눈금
REVET_EMBED_DEPTH_M = 0.5 # 근입 깊이
def pipe_wall_min_height_m(diameter_m: float) -> float:
"""관 벽 **순수 높이**(근입 포함) 최소값 — 관경 + 여유고를 0.1m 로 올린 뒤 근입을 더함.
최소 실제 설계값은 있어 값으로 수량은 미확정으로 (금액 ).
"""
raw = float(diameter_m) + REVET_FREEBOARD_M
steps = math.ceil(raw / REVET_HEIGHT_STEP_M - 1e-9)
return round(steps * REVET_HEIGHT_STEP_M + REVET_EMBED_DEPTH_M, 3)
# (2026-08-20 사용자 확정) 배관은 **수평도 가능** — 그림에서 경사를 강제로 만들지
# 않는다. config `DRAINAGE_PIPE_SLOPE_DEG`(10도)는 유효직경 수리계산 전용으로 남는다.
@@ -139,7 +159,9 @@ def _number(value: Any, fallback: float | None) -> float | None:
return fallback
def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) -> dict[str, Any]:
def _side_spec(
options: dict[str, Any], defaults: dict[str, Any], side: str, diameter_m: float = 1.0
) -> dict[str, Any]:
"""유입("inlet")·유출("outlet") 한쪽의 부속 제원.
구조가 집수정이면 기슭막이·보호공을 만들지 않는다 집수정 단면은 후속 작업이라
@@ -164,9 +186,10 @@ def _side_spec(options: dict[str, Any], defaults: dict[str, Any], side: str) ->
)
return spec
# 높이를 안 적었으면 **횡단도가 그리는 관경 기준 최소 높이**(2026-09-14 · 옛 기본 2.5 걷음).
height = _number(
options.get(f"{side}_revet_height_m"),
_number(defaults.get(f"{side}_revet_height_m"), None),
_number(defaults.get(f"{side}_revet_height_m"), pipe_wall_min_height_m(diameter_m)),
)
length = _number(
options.get(f"{side}_revet_length_m"),
@@ -208,10 +231,11 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
values.get("pipe_diameter_mm"), _number(defaults.get("pipe_diameter_mm"), 1000.0)
)
kind = values.get("pipe_kind") or defaults.get("pipe_kind")
diameter_m = round((diameter_mm or 1000.0) / 1000.0, 3)
return {
"type": "pipe",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"diameter_m": diameter_m,
"min_cover_m": MIN_PIPE_COVER_M,
# 관 기슭막이 기초 축 — **유입·유출 한 칸으로** 받는다(2026-09-09). 한쪽만 기초유로
# 하는 일이 드물어 폼을 둘로 늘리지 않았다. 실무가 갈라 쓰면 그때 나눌 것.
@@ -219,8 +243,8 @@ def _culvert_set(options: dict[str, Any] | None) -> dict[str, Any]:
str(values.get("revet_foundation") or defaults.get("revet_foundation") or "").strip()
or None
),
"inlet": _side_spec(values, defaults, "inlet"),
"outlet": _side_spec(values, defaults, "outlet"),
"inlet": _side_spec(values, defaults, "inlet", diameter_m),
"outlet": _side_spec(values, defaults, "outlet", diameter_m),
}
@@ -249,6 +273,11 @@ def _revet_side(values: dict[str, Any], role: str) -> dict[str, Any]:
values.get(f"{role}_revet_after_m"), _number(values.get("after_m"), None)
),
"face_slope": REVET_FACE_SLOPE,
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본 그대로(2026-09-14 B5 · TS `revetSide`).
# 설치 측은 `side` 기본값(양쪽)을 붙이기 **전** 값이라야 B08(빈 칸 = 자동)과 같게 갈림.
"face_side": values.get("side"),
"face_role": values.get("face_role"),
"face_slope_ratio": values.get("face_slope_ratio"),
}
if height is not None and height > 0:
spec["apron_length_m"] = round(height * APRON_LENGTH_FACTOR, 3)
@@ -308,6 +337,36 @@ def _wing_spec(values: dict[str, Any], defaults: dict[str, Any], side: str) -> d
}
def _defaulted(values: dict[str, Any], fields: list[tuple[str, str, Any]]) -> list[str]:
"""정본에 안 적혀 **기본값으로 그린 칸** 「이름 값」 — 횡단도가 「기본값(미확정)」으로 적음.
2026-09-14 브레인 판정 저장만 하는 것으로는 모자람 · 그림이 설계값처럼 보이면 같은 .
TS `defaulted`(`common_util_culvert_sets.ts`).
"""
found = []
for key, label, used in fields:
if used is None or values.get(key) not in (None, ""):
continue
found.append(f"{label} {used:g}" if isinstance(used, float) else f"{label} {used}")
return found
def _wing_defaulted(values: dict[str, Any], wing: dict[str, Any], side: str) -> list[str]:
"""날개벽 한쪽의 기본값 칸 — 안 세운 날개벽은 치수를 안 봄."""
name = "유입" if side == "in" else "유출"
prefix = f"wing_{side}"
fields: list[tuple[str, str, Any]] = [
(prefix, f"{name} 날개벽", "있음" if wing["installed"] else "없음")
]
if wing["installed"]:
fields += [
(f"{prefix}_height_m", f"{name} 날개벽 높이", wing["height_m"]),
(f"{prefix}_length_m", f"{name} 날개벽 길이", wing["length_m"]),
(f"{prefix}_angle_deg", f"{name} 날개벽 각도", wing["angle_deg"]),
]
return _defaulted(values, fields)
def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"""세월교 1개소의 세트 제원(관 + 양측 측벽 + 바닥판 + 날개벽 연장).
@@ -323,12 +382,26 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
count = _number(values.get("pipe_count"), _number(defaults.get("pipe_count"), None))
depth = _number(values.get("ford_height_m"), None)
span = width if width and width > 0 else FORD_DEFAULT_WIDTH_M
pipe_count = max(int(count), 1) if count else 1
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
defaulted = _defaulted(
values,
[
("pipe_kind", "관종", str(kind) if kind else None),
("pipe_diameter_mm", "관경", diameter_mm),
("ford_width_m", "월류 폭", span),
("pipe_count", "배관 수량", pipe_count),
("ford_height_m", "월류 높이", 0.0),
],
)
return {
"type": "ford",
"pipe_kind": str(kind) if kind else None,
"diameter_m": round((diameter_mm or 1000.0) / 1000.0, 3),
"pipe_count": max(int(count), 1) if count else 1,
"span_m": width if width and width > 0 else FORD_DEFAULT_WIDTH_M,
"pipe_count": pipe_count,
"span_m": span,
# 월류 높이 — 구체 위 노면은 이만큼 낮게 앉는다(단면은 월류부 가장 아래를 자른
# 자리다). 계획고를 통째로 내려 측벽·바닥판·절성토 면적이 함께 따라간다
# (2026-08-30 사용자 확정). 값이 없으면 0 = 내리지 않는다.
@@ -336,8 +409,11 @@ def _ford_set(options: dict[str, Any] | None) -> dict[str, Any]:
"slab_thickness_m": FORD_SLAB_THICKNESS_M,
"wall_thickness_m": FORD_WALL_THICKNESS_M,
"min_cover_m": MIN_PIPE_COVER_M,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
"wing_in": wing_in,
"wing_out": wing_out,
"defaulted": defaulted
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
@@ -353,12 +429,14 @@ def _ford_pavement_set(options: dict[str, Any] | None) -> dict[str, Any]:
width = _number(values.get("ford_width_m"), _number(defaults.get("ford_width_m"), None))
depth = _number(values.get("ford_height_m"), None)
slope = _number(values.get("ford_slope_pct"), None)
span = width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M
return {
"type": "ford_pavement",
"span_m": width if width and width > 0 else FORD_PAVEMENT_DEFAULT_WIDTH_M,
"span_m": span,
# 노선 중심에서 잰 깊이. 없으면 화면이 파임을 그리지 않는다(수치를 지어내지 않는다).
"depth_m": depth if depth and depth > 0 else None,
"slope_pct": slope,
"defaulted": _defaulted(values, [("ford_width_m", "월류 폭", span)]),
}
@@ -375,6 +453,8 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
inner_height = _number(values.get("body_height_m"), _number(defaults.get("body_height_m"), 2.0))
wall = FORD_WALL_THICKNESS_M
slab = FORD_SLAB_THICKNESS_M
wing_in = _wing_spec(values, defaults, "in")
wing_out = _wing_spec(values, defaults, "out")
return {
"type": "box",
"inner_width_m": inner_width or 2.0,
@@ -385,8 +465,17 @@ def _box_set(options: dict[str, Any] | None) -> dict[str, Any]:
"cover_m": BOX_COVER_M,
# 도로 진행 방향 길이 = 내공 폭 + 측벽 두 장. 이 폭만큼 측점에 걸친다.
"span_m": (inner_width or 2.0) + 2 * wall,
"wing_in": _wing_spec(values, defaults, "in"),
"wing_out": _wing_spec(values, defaults, "out"),
"wing_in": wing_in,
"wing_out": wing_out,
"defaulted": _defaulted(
values,
[
("body_width_m", "본체 폭", inner_width or 2.0),
("body_height_m", "본체 높이", inner_height or 2.0),
],
)
+ _wing_defaulted(values, wing_in, "in")
+ _wing_defaulted(values, wing_out, "out"),
}
@@ -79,6 +79,9 @@ def load_wall_structures(project_root: Path) -> list[dict[str, Any]]:
"tiers": options.get("tiers"),
"lift_m": options.get("lift_m"),
"shift_m": options.get("shift_m"),
# 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 저장 원본(2026-09-14 B5 · TS 짝 같은 키).
"face_role": options.get("face_role"),
"face_slope_ratio": options.get("face_slope_ratio"),
}
)
return found
+5 -4
View File
@@ -73,14 +73,13 @@ from B06_Section.B06_Section_Schema import (
SectionRegenerateRequest,
SectionSummaryResponse,
)
from B06_Section.B06_Section_Server_Calc_Prebuild import conversion_factors_for, haul_limits_for
from common_util.common_util_auth import verify_session
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
EARTHWORK_CONVERSION_FACTORS,
EARTHWORK_HAUL_EQUIPMENT_LIMITS_M,
FOREST_ROAD_MIN_WIDTH_M,
NATURAL_SPOIL_MIN_GROUND_SLOPE,
SECTION_VERTICAL_EXAGGERATION,
@@ -144,10 +143,12 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
stored_standard_cross_section=stored_standard,
rock_boundary_default_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
rock_boundary_step_m=STANDARD_ROCK_BOUNDARY_STEP_M,
earthwork_conversion=EARTHWORK_CONVERSION_FACTORS,
# ⚠ 상수를 직접 들지 않는다 — 프로젝트가 고른 계수가 있으면 화면도 그 값으로
# 그려야 서버가 뒤에 다시 셈한 값과 갈리지 않는다(CLAUDE.md 5장).
earthwork_conversion=await conversion_factors_for(project_id),
haul_equipment_limits=[
HaulEquipmentLimit(key=key, max_distance_m=limit)
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in await haul_limits_for(project_id)
],
natural_spoil_min_ground_slope=NATURAL_SPOIL_MIN_GROUND_SLOPE,
)
+7 -1
View File
@@ -25,7 +25,9 @@ from fastapi.responses import JSONResponse
from B06_Section.B06_Section_Server_Calc_Prebuild import (
BUNDLE,
_mass_haul_context,
conversion_factors_for,
haul_inputs_for,
haul_limits_for,
)
from common_util.common_util_node_bundle import run_bundle_json
@@ -63,6 +65,10 @@ async def compute_haul_plan(
# 구조물 몫(공제·잔토)을 **넘겨야** 사토가 줄고 는다 — 인자 없이 부르면 늘 `None` 이라
# 통로만 있고 값이 안 흐른다(2026-09-09 실측으로 드러난 자리).
haul_inputs = await haul_inputs_for(project_id)
# 곡선이 쓰는 계수도 프로젝트가 고른 값으로 — 토적표·운반표와 같은 값이어야 한다.
factors = await conversion_factors_for(project_id)
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
limits = await haul_limits_for(project_id)
try:
output = await asyncio.to_thread(
run_bundle_json,
@@ -70,7 +76,7 @@ async def compute_haul_plan(
_NPM_SCRIPT,
{
"haul_plan_for": result,
"context": _mass_haul_context(haul_inputs),
"context": _mass_haul_context(haul_inputs, factors, limits),
},
)
except Exception:
+12 -3
View File
@@ -14,8 +14,10 @@
* 실행: node <번들> <입력.json> <출력.json>
* { detail: 종횡단 (API와 ), context: { earthwork_conversion,
* natural_spoil_min_ground_slope, haul_equipment_limits } }
* { areas: [{ chainage_m, cut_area_m2, }], mass_haul: {} | null }
* { areas: [{ chainage_m, cut_area_m2, }], mass_haul: {} | null,
* extra_walls: [{ chainage_m, extra_walls: [ ] }] }
* areas ** **. .
* extra_walls ( ) · B08 ().
* 코드: 0 / 2
* ========================================================================== */
@@ -23,7 +25,11 @@ import { readFileSync, writeFileSync } from "node:fs";
import { computeMassHaul, massHaulPayload } from "@util/common_util_mass_haul";
import { computeHaulPlan, haulPlanPayload } from "@util/common_util_mass_haul_balance";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
import {
applyStructureAreaRows,
extraWallRows,
structureAreaRows,
} from "./B06_Section_Structure_Layouts";
interface ServerCalcInput {
detail?: SectionDetailResponse;
@@ -112,4 +118,7 @@ const massHaul = result
? massHaulPayload(result, plan ? { haul_plan: haulPlanPayload(plan) } : null)
: null;
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul }));
// 선 다단 벽 목록(④) — 관 연장처럼 기하가 세운 결과를 정본에 남겨 B08 이 줄을 세움.
const extraWalls = extraWallRows(sections);
writeFileSync(outputPath, JSON.stringify({ areas, mass_haul: massHaul, extra_walls: extraWalls }));
@@ -38,6 +38,11 @@ from B06_Section.B06_Section_Repository import (
)
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from common_util.common_util_node_bundle import run_bundle_json
from common_util.common_util_project_settings import (
haul_equipment_limits,
mixed_conversion_factors,
quantity_settings,
)
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
@@ -79,7 +84,38 @@ async def haul_inputs_for(project_id: Any) -> dict[str, Any]:
return {}
def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, Any]:
async def conversion_factors_for(project_id: Any) -> dict[str, dict[str, float]]:
"""이 프로젝트가 쓸 토량환산계수. 못 읽으면 정본 기본값 — 화면은 그대로 선다.
곡선·운반·토적표가 **같은 계수** 서야 한다. 그래서 상수를 직접 들지 않고 함수를
거친다(고른 값은 프로젝트 설정 `conversion_factors_override` 산다).
"""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 계수로 진행: project_id=%s", project_id)
return {kind: dict(entry) for kind, entry in EARTHWORK_CONVERSION_FACTORS.items()}
# 암은 구성비 가중 C(㉱ (나)) — 토적표·운반표와 같은 함수.
return mixed_conversion_factors(quantity_settings(root))
async def haul_limits_for(project_id: Any) -> list[tuple[str, float | None]]:
"""이 프로젝트가 쓸 운반장비 거리 경계(도쟈 한계거리를 고쳤으면 그 값). 못 읽으면 기본값."""
try:
stored_path = await run_with_connection(get_project_storage_relative_path, project_id)
root = resolve_stored_project_path(stored_path)
except Exception:
logger.warning("B06 프로젝트 경로를 못 찾음 — 기본 경계로 진행: project_id=%s", project_id)
return list(EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
return haul_equipment_limits(quantity_settings(root))
def _mass_haul_context(
haul_inputs: dict[str, Any] | None = None,
factors: dict[str, dict[str, float]] | None = None,
limits: list[tuple[str, float | None]] | None = None,
) -> dict[str, Any]:
"""유토곡선 계산에 필요한 값 — 화면이 `sections/context`로 받는 것과 같은 상수다.
채집석 공제(`collected_stone_deduction_m3`) 상수가 아니라 **B08 내는 **이다.
@@ -93,11 +129,13 @@ def _mass_haul_context(haul_inputs: dict[str, Any] | None = None) -> dict[str, A
"""
inputs = haul_inputs or {}
return {
"earthwork_conversion": EARTHWORK_CONVERSION_FACTORS,
# 프로젝트가 고른 계수가 있으면 그것, 없으면 정본 기본값.
"earthwork_conversion": factors or EARTHWORK_CONVERSION_FACTORS,
"natural_spoil_min_ground_slope": NATURAL_SPOIL_MIN_GROUND_SLOPE,
# 프로젝트가 도쟈 한계거리를 고쳤으면 그 값, 없으면 정본 기본값.
"haul_equipment_limits": [
{"key": key, "max_distance_m": limit}
for key, limit in EARTHWORK_HAUL_EQUIPMENT_LIMITS_M
for key, limit in (limits or EARTHWORK_HAUL_EQUIPMENT_LIMITS_M)
],
# ⚠ B08 이 아직 이 값을 내지 않는다(2026-09-09) — 그때까지 `None`(아직 안 옴)이다.
# 값을 내기 시작하면 여기에 실어 주기만 하면 통로가 이어진다.
@@ -196,7 +234,11 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
_NPM_SCRIPT,
{
"detail": detail,
"context": _mass_haul_context(haul_inputs),
"context": _mass_haul_context(
haul_inputs,
mixed_conversion_factors(quantity_settings(project_root)),
haul_equipment_limits(quantity_settings(project_root)),
),
},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
@@ -204,7 +246,20 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
output = {}
rows = output.get("areas")
mass_haul = output.get("mass_haul")
if not fixed and not rows and not mass_haul:
# 선 다단 벽 목록(④) — 목록째 얹음(수가 아니라 `_AREA_KEYS` 로는 못 거름). 주인 측점엔 빈 목록도.
# 관 기준벽 벽 몸 겹침(㉡) — 역할별 ㎡ 한 벌. 빈 dict 도 실어 옛 값을 지움.
extra_walls = [
(
float(row["chainage_m"]),
{
"extra_walls": row["extra_walls"],
"wall_fill_overlap": row.get("wall_fill_overlap") or {},
},
)
for row in output.get("extra_walls") or []
if isinstance(row, dict) and isinstance(row.get("extra_walls"), list)
]
if not fixed and not rows and not mass_haul and not extra_walls:
return 0
updated = 0
@@ -240,6 +295,10 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
updated = await merge_cross_section_designs(
connection, route_id=route_id, entries=area_entries, replace=False
)
if extra_walls:
await merge_cross_section_designs(
connection, route_id=route_id, entries=extra_walls, replace=False
)
if isinstance(mass_haul, dict):
await merge_longitudinal_section_data(
connection, route_id=route_id, data_patch={"mass_haul": mass_haul}
+103 -12
View File
@@ -10,12 +10,17 @@
* DOM·SVG . Node .
* ========================================================================== */
import { computeStructureAreas } from "@util/common_util_cross_structure_areas";
import { computeStructureAreas, wallBodyInFillM2 } from "@util/common_util_cross_structure_areas";
import type { CrossSection } from "./B06_Section_Api_Fetch";
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 } from "./B06_Section_UI_Cross_Culvert_Types";
import { computeCardCulvert, culvertLinkFor } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { WallAdjust, WallLayout } from "./B06_Section_UI_Cross_Culvert_Types";
import {
computeCardCulvert,
culvertLinkFor,
tierSpanOf,
} from "./B06_Section_UI_Cross_Culvert_Wire";
import type {
ExtraWallControl,
InletStructureControl,
@@ -144,6 +149,22 @@ export const STRUCTURE_ROW_KEYS = [
"pipe_length_m",
] as const;
/** 폐회로 면적 입력(설계선·지반선·트림) — 트림이나 설계선이 모자라면 null. */
function areaInputOf(section: CrossSection, layouts: StoredLayouts) {
const trim = trimOfLayouts(layouts);
const designLine = layouts.design.design_line;
if (!trim || !Array.isArray(designLine) || designLine.length < 2) return null;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
return {
designLine: designLine as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
};
}
/** 측점 하나의 폐회로 면적 — 구조물이 없으면 null(표준 계산값이 이미 맞다). */
function areaRowOf(
section: CrossSection,
@@ -176,17 +197,11 @@ function areaRowOf(
typeof pipeLengthM === "number" && pipeLengthM > 0
? { chainage_m: section.chainage_m, pipe_length_m: Number(pipeLengthM.toFixed(4)) }
: null;
const trim = trimOfLayouts(layouts);
const areaInput = areaInputOf(section, layouts);
if (!areaInput) return pipeRow;
const design = layouts.design;
if (!trim || !Array.isArray(design.design_line) || design.design_line.length < 2) return pipeRow;
const ground = section.samples
.filter((sample) => sample.valid !== false && typeof sample.elevation_m === "number")
.map((sample) => ({ offset: sample.offset_m ?? 0, elevation: sample.elevation_m as number }))
.sort((a, b) => a.offset - b.offset);
const areas = computeStructureAreas({
designLine: design.design_line as Array<{ offset_m: number; elevation_m: number }>,
ground,
trim,
...areaInput,
rockBoundaryOffsetM:
typeof design.rock_boundary_offset_m === "number" ? design.rock_boundary_offset_m : null,
});
@@ -219,6 +234,82 @@ export function structureAreaRows(
.filter((row): row is Record<string, number> => !!row);
}
/** 선 다단 벽 한 매 — B08 이 줄을 세우는 값(파이썬 `B08_Quantity_Engine_Pipe.facility_structures`). */
export interface BuiltExtraWall {
key: string;
side: "outlet" | "basin";
form: string;
/** 순수 높이(m) — 바닥~상단(근입 0.5 포함). 지형에 맞춰 선 값. */
height_m: number;
/** 사용자가 높이·형태를 적었나 — 안 적었으면 B08 이 미확정으로 셈. */
height_set: boolean;
form_set: boolean;
before_m: number;
after_m: number;
/** () (2026-09-06 ) .
* B08 (2026-09-14 ). null. */
fill_overlap_m2: number | null;
}
export interface ExtraWallRow {
chainage_m: number;
extra_walls: BuiltExtraWall[];
wall_fill_overlap: Record<string, number>;
}
/**
* (· ) ** ** (, 2026-09-14).
*
* · (`extra_wall_counts`)
* ( `pipe_length_m` ). .
* `wall_fill_overlap` (). B08 .
*/
export function extraWallRows(sections: readonly CrossSection[]): ExtraWallRow[] {
const rows: ExtraWallRow[] = [];
for (const section of sections) {
if (!section.culvert) continue;
const owner = pipeOwnerChainage(section, sections);
if (owner !== null && Math.abs(owner - section.chainage_m) > CHAINAGE_TOLERANCE_M) continue;
const layouts = computeStoredLayouts(section, sections);
const culvert = layouts?.culvert;
if (!layouts || !culvert) continue;
const areaInput = areaInputOf(section, layouts);
const walls: BuiltExtraWall[] = [];
const built: Array<["outlet" | "basin", WallLayout[], WallAdjust[]]> = [
["outlet", culvert.extraWalls, culvert.revetShift.extras],
["basin", culvert.basinExtras, culvert.revetShift.basinExtras],
];
for (const [side, list, applied] of built) {
list.forEach((wall, index) => {
const key = `${side === "basin" ? "bextra" : "extra"}${index}`;
const span = tierSpanOf(section, key);
walls.push({
key,
side,
form: wall.form ?? "",
height_m: Number((wall.height + REVET_EMBED_DEPTH_M).toFixed(3)),
height_set: applied[index]?.h != null,
form_set: section.design?.revet_adjust?.[key]?.m != null,
before_m: span.beforeM,
after_m: span.afterM,
fill_overlap_m2: areaInput
? Number(wallBodyInFillM2(wall.points, areaInput).toFixed(3))
: null,
});
});
}
// 관 기준벽(유입·유출)도 같은 겹침 — 다단에만 사유가 뜨면 「다단만 문제」로 읽힘(㉡ 2026-09-14).
const wall_fill_overlap: Record<string, number> = {};
for (const wall of culvert.walls) {
if (areaInput && (wall.role === "inlet" || wall.role === "outlet")) {
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 });
}
return rows;
}
/** 낸 면적을 측점 자료에 도로 얹는다 — 유토곡선이 고쳐진 면적 위에서 쌓이도록. */
export function applyStructureAreaRows(
sections: readonly CrossSection[],
@@ -49,6 +49,40 @@ function fillSlopeInfo(section: CrossSection): HTMLElement | null {
return info;
}
/**
* ** ** `[보이는 글, 툴팁]` (2026-09-14 ).
* .
*/
function facilityNotes(section: CrossSection): Array<[string, string]> {
const notes: Array<[string, string]> = [];
const sets: Array<[string, string[] | undefined]> = [
["BOX암거", section.box?.defaulted],
["세월교", section.ford?.defaulted],
["물넘이포장", section.ford_pavement?.defaulted],
];
for (const [name, items] of sets) {
if (!items?.length) continue;
notes.push([
`${name} 기본값(미확정)`,
`B05 시설 칸에 안 적어 기본값으로 그림 — ${items.join(" · ")} · 적으면 그 값으로 그림`,
]);
}
const own = section.culvert?.hidden_pipe ? section.culvert : null;
if (own && [own.inlet, own.outlet].some((side) => !side.revet_height_m)) {
notes.push([
"⚠ 기슭막이 높이 없음 — 근입만 그림",
"독립 기슭막이 높이는 설계자 입력 — 계획홍수위 + 0.5~0.7m(사방기술교본 2-나:141 · 3-가:181) · 비우면 수량도 미확정 · B05 시설 칸에 적으면 그림",
]);
}
if (section.ford_pavement && !section.ford_pavement.depth_m) {
notes.push([
"⚠ 월류 높이 없음 — 파임 안 그림",
"물넘이포장 월류 높이를 안 적어 파인 노면을 안 그림(수치를 지어내지 않음) — B05 시설 칸에 월류 높이를 적으면 그림",
]);
}
return notes;
}
/**
* ( ).
*
@@ -86,6 +120,13 @@ export function appendCardHeader(
openSlope.title = L("B06_Cross_SlopeUnclosed_Tip");
meta.append(openSlope);
}
for (const [text, tip] of facilityNotes(section)) {
const note = document.createElement("span");
note.className = "b06-cross-card__warning";
note.textContent = text;
note.title = tip;
meta.append(note);
}
const structureName = section.structure;
if (structureName) {
const structure = document.createElement("span");
+1 -2
View File
@@ -13,7 +13,6 @@
import {
REVET_EMBED_DEPTH_M,
FILL_SLOPE_RATIO_MIN,
REVET_LEAN_RATIO,
pipeWallThicknessM,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Geom";
@@ -183,7 +182,7 @@ export function appendCulvertOverlay(
`${roleLabel(wall.role)} 기슭막이 ${wall.form ?? ""} H=${(
wall.height + REVET_EMBED_DEPTH_M
).toFixed(1)}m` +
`(상단 = 사면선 접점, 전면 1:${REVET_LEAN_RATIO}` +
`(상단 = 사면선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사)` +
`, 높이 한계 ${revetHeightLimit(wall.form).toFixed(1)}m — 교본 7-3)` +
(wall.floatGapM > 0.01
? ` · ⚠ 바닥 원지반 이격 ${wall.floatGapM.toFixed(2)}m — 하부 지지 구조물 별도(추가 예정)`
@@ -16,7 +16,8 @@ export const MIN_PIPE_COVER_M = 0.5;
/** 기슭막이 벽 두께(m) — 실무 견치돌 뒷길이 관측치 45㎝(울진 L3=45). 표시용 형상 값. */
export const REVET_THICKNESS_M = 0.45;
/** 기슭막이 전면 기울기(1:n) — 돌쌓기 전면 1:0.3(교본 7-3). 벽이 사면 쪽으로 기운다. */
/** (1:n) **** (·) /
* . `B06_Section_UI_Cross_Lean.leanOf`( 13-4-4 [] ). */
export const REVET_LEAN_RATIO = 0.3;
/** 돌붙임 두께(m) — 실무 돌붙임 L3=45(울진 1공구 수량집계표). */
@@ -6,7 +6,7 @@
* (2026-08-22 ):
* · ** 1:1.2** .
* · **( ) **.
* · = ** +0.5m와 (1:0.3)
* · = ** +0.5m와 (1:n · )
* **( ).
* · ( 0.5m ) ** ** .
* ========================================================================== */
@@ -48,6 +48,8 @@ export interface OutletExtrasInput {
ownerForm?: string | null;
/** 1회성 등간격 배치(2026-08-22 사용자 ①) — 단별 d를 사면 구간이 같아지게 재계산. */
equalize?: boolean;
/** 단 벽의 전면 기울기 — 형태·순수 높이로 표준경사를 고름(없으면 종전 1:0.3). */
leanFor?: (form: string, pureHeightM: number) => number;
}
export interface OutletExtrasResult {
@@ -167,9 +169,9 @@ export function inletGroundConnector(
return null;
}
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이에 따라 커진다. */
function jointRunOf(height: number): number {
return 0.25 * REVET_THICKNESS_M + (REVET_LEAN_RATIO / 2) * height;
/** 벽 중심(하단 중점)에서 이음선 상단점까지의 수평거리 — 높이·기울기에 따라 커진다. */
function jointRunOf(height: number, lean: number): number {
return 0.25 * REVET_THICKNESS_M + (lean / 2) * height;
}
/** 끝 성토부선 — src에서 1:1.2로 내려가며 원지반을 만나면 끝(지반이 높으면 지반 따름). */
@@ -226,16 +228,17 @@ function buildExtraWall(
material: RevetMaterial,
floatGapM: number,
form: string | null = null,
lean: number = REVET_LEAN_RATIO,
): WallLayout {
const thickness = REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
// 하단 = 기준선(anchor) 아래 근입 0.5m **고정** — 원지반에 묻혀도 바닥을 더
// 내리지 않는다. 벽 형상은 높이값으로만 정한다(2026-08-23 사용자 확정 —
// 종전 전면 발끝 지반 추적 수렴 삭제).
@@ -256,6 +259,7 @@ function buildExtraWall(
height,
floatGapM,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -281,7 +285,6 @@ function buildExtraWall(
export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult {
const { outward, groundAt, limitOffset } = input;
const thickness = REVET_THICKNESS_M;
const heightDenominator = 1 - REVET_LEAN_RATIO / 2 / FILL_SLOPE_RATIO_MIN;
/** 다단 1회 전개 — plan: "user"(조작값) / "greedy"(등간격 1차 근사) / d 명시 배열. */
const cascadeOnce = (plan: "user" | "greedy" | number[]): OutletExtrasResult => {
const walls: WallLayout[] = [];
@@ -315,6 +318,9 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
/** ( )~ · .
* **** . */
let exposed = height - REVET_EMBED_DEPTH_M;
// 전면 기울기 — 형태·요청 높이로 표준경사(2026-09-14 B5). 자리 닫힌식의 분모도 따라감.
const lean = input.leanFor?.(form, height) ?? REVET_LEAN_RATIO;
const heightDenominator = 1 - lean / 2 / FILL_SLOPE_RATIO_MIN;
/** 자리 x(벽 하단 중점)에 지반 안착 + 상단이 성토선에 닿는 데 필요한 높이. */
const heightAt = (x: number): number => {
@@ -350,7 +356,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
}
if (autoOffset === null) autoOffset = bestOffset;
if (autoOffset === null) break; // 세울 만한 지형이 아니다 — 되는 만큼만.
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed);
const autoJointRun = (autoOffset - src.offset) * outward - jointRunOf(exposed, lean);
/** 자동 자리(선반 0·수직 0)의 벽 상단 표고 — 조작은 여기서부터 잰다. */
const autoTop = src.elevation - autoJointRun / FILL_SLOPE_RATIO_MIN;
// 관통 금지: 상단 ≤ 윗단 하단 → 그만큼은 반드시 내려간다.
@@ -378,7 +384,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const placeable = (xShift: number, vShift: number): boolean => {
const topE = autoTop - vShift;
const aX = autoOffset + outward * xShift;
const jointX = aX - outward * jointRunOf(exposed);
const jointX = aX - outward * jointRunOf(exposed, lean);
if (
Math.max(groundAt(jointX), groundAt(jointX + outward * REVET_THICKNESS_M)) >=
topE - 0.01
@@ -388,7 +394,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const bottom = bottomAt(aX, baseE);
const startE = bottom + REVET_EMBED_DEPTH_M;
const topFrontX = jointX + outward * REVET_THICKNESS_M;
const startX = topFrontX + outward * REVET_LEAN_RATIO * (topE - startE);
const startX = topFrontX + outward * lean * (topE - startE);
if (groundAt(startX) < startE + 0.05) return true;
// 매몰 — 3도 절토선(계류 쪽 내림)이 원지반과 다시 만나면 허용, 아니면 이동 금지.
return (
@@ -438,6 +444,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
material,
Math.max(0, base - groundBase),
form,
lean,
);
// 요청한 좌우 이동을 지형이 막았으면 그 양을 남긴다 — 「눌러도 안 움직인다」의 까닭을
// 툴팁으로 알리기 위함이다(2026-09-06 실측: 다섯 측점 중 넷이 1.0m 요청에 0.0m 이동).
@@ -479,8 +486,7 @@ export function buildOutletExtras(input: OutletExtrasInput): OutletExtrasResult
const startElevation = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
src = {
offset:
wall.points[2].offset +
outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - startElevation),
wall.points[2].offset + outward * wall.lean * (wall.topJoint.elevation - startElevation),
elevation: startElevation,
};
prevBottom = wall.bottomBack.elevation;
@@ -2,7 +2,7 @@
* B06_Section_UI_Cross_Culvert_Geom.ts
* (··) ** ** (`_Cross_Culvert.ts`) .
* `section.culvert` ( ).
* 1:0.3 , ,
* 1:n (n = · `leanOf`), ,
* (designTrim). = . `_Basin.ts`, · `_Extra.ts`.
* ========================================================================== */
@@ -24,6 +24,7 @@ import {
revetTargetHeight,
} from "./B06_Section_UI_Cross_Culvert_Const";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { leanOf } from "./B06_Section_UI_Cross_Lean";
import { ZERO_ADJUST } from "./B06_Section_UI_Cross_Culvert_Types";
import type {
BasinLayout,
@@ -171,8 +172,19 @@ export function computeCulvertLayout(
const adjustOf = (value?: WallAdjust): WallAdjust => ({ ...ZERO_ADJUST, ...(value ?? {}) });
const adjInlet = adjustOf(revetShift?.inlet);
const adjOutlet = adjustOf(revetShift?.outlet);
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) =>
revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
// 전면 기울기 = 표준경사 표(단면유형 × 설치 측 × 형태 × 높이 — B08 과 한 벌, 2026-09-14 B5).
const pipeWallSpec = (spec: CulvertSideSpec, adjust: WallAdjust) => {
const wall = revetWallSpec(spec, adjust, culvert.hidden_pipe === true, diameter);
const lean = leanOf({
form: wall.form,
height_m: wall.pureHeight,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? spec.face_side : null,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
return { ...wall, lean };
};
const inletWallSpec = pipeWallSpec(culvert.inlet, adjInlet);
const outletWallSpec = pipeWallSpec(culvert.outlet, adjOutlet);
// 조정창 선택지 가용성 — 판정은 Basin이 맡는다.
@@ -204,14 +216,21 @@ export function computeCulvertLayout(
edge: { offset_m: number },
outward: number,
wallHeight: number,
): number => edge.offset_m + outward * (fillWallBaseWidth(wallHeight) / 2 - REVET_TRAP_TOP_M);
lean: number,
): number =>
edge.offset_m + outward * (fillWallBaseWidth(wallHeight, lean) / 2 - REVET_TRAP_TOP_M);
if (!basinReason && designAt) {
const inletBaseAt = (offset: number): number =>
Math.min(groundAt(offset), invertCap(inletInfo.edge));
// 4축 배치는 공용 풀이(placeInletWall — Solve). 유입을 내리면 유출도 따라
// 내려가므로, 유출이 못 받으면 유입도 못 내려간다(outletGuard).
const placed = placeInletWall({
autoOffset: slopeZeroAnchor(inletInfo.edge, inletInfo.outward, inletWallSpec.height),
autoOffset: slopeZeroAnchor(
inletInfo.edge,
inletInfo.outward,
inletWallSpec.height,
inletWallSpec.lean,
),
outward: inletInfo.outward,
height: inletWallSpec.height,
baseElevation0: inletInfo.edge.elevation_m - inletWallSpec.height,
@@ -238,6 +257,7 @@ export function computeCulvertLayout(
outletInfo.limit,
),
limitOffset: outletInfo.limit,
lean: outletWallSpec.lean,
},
});
appliedAdjust.inlet.x = placed.x;
@@ -308,6 +328,7 @@ export function computeCulvertLayout(
vertical: WallVertical | null = null,
material: RevetMaterial = "dry",
formLabel: string | null = null,
lean: number = REVET_LEAN_RATIO,
): WallLayout | null => {
// spec의 "집수정"은 ruleReason에 이미 반영 — 여기서 되살리면 revet 선택이 깨진다.
const reason: BasinLayout["reason"] | null = forceBasinReason;
@@ -340,13 +361,13 @@ export function computeCulvertLayout(
}
return null;
}
// 형상: 배면 수직 + 계류측 1:0.3 평행사변형 띠. 높이는 vertical(계산용)이 들고 온다.
// 형상: 배면 수직 + 계류측 1:n 평행사변형 띠(n = 표준경사). 높이는 vertical(계산용)이 들고 온다.
const height =
vertical?.height ?? Math.min(revetTargetHeight(diameter), revetHeightLimit(spec.revet_form));
if (!(height > 0.05)) return null;
const floatGapM = vertical?.floatGapM ?? 0;
// 자리 기준 = **하단선 중점**(2026-08-21) — anchor.elevation은 그 자리 관 invert.
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
@@ -357,7 +378,7 @@ export function computeCulvertLayout(
// 원지반에 묻혀도 바닥을 더 내리지 않는다. 벽 형상은 높이값으로만 정한다
// (2026-08-23 사용자 확정 — 종전 전면 발끝 지반 추적 수렴 삭제).
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
@@ -376,6 +397,7 @@ export function computeCulvertLayout(
height,
floatGapM,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -411,6 +433,7 @@ export function computeCulvertLayout(
wallVertical.inlet,
inletWallSpec.material,
inletWallSpec.form,
inletWallSpec.lean,
);
// 유출 벽 밑 = 그 자리 원지반(역경사는 유입 invert로 클램프 — 2026-08-21).
const invertAt = (offset: number): number => Math.min(groundAt(offset), inlet.elevation);
@@ -421,6 +444,7 @@ export function computeCulvertLayout(
outletInfo.edge,
outletInfo.outward,
outletWallSpec.height,
outletWallSpec.lean,
);
const outletZeroInvert = outletInfo.edge.elevation_m - outletWallSpec.height;
const outletLineInvertAt = (offset: number): number =>
@@ -461,6 +485,7 @@ export function computeCulvertLayout(
wallVertical.outlet,
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// ── 관 축 확정 — 관 하단선은 시작점과 유출 벽 전면 기준선 교차점을 잇는다.
const pipeStart = basinPipeEnd ?? inlet;
@@ -471,8 +496,7 @@ export function computeCulvertLayout(
const reference = wall.bottomBack.elevation + REVET_EMBED_DEPTH_M;
return {
offset:
wall.points[2].offset +
wall.outward * REVET_LEAN_RATIO * (wall.topJoint.elevation - reference),
wall.points[2].offset + wall.outward * wall.lean * (wall.topJoint.elevation - reference),
elevation: reference,
};
};
@@ -527,6 +551,7 @@ export function computeCulvertLayout(
},
outletWallSpec.material,
outletWallSpec.form,
outletWallSpec.lean,
);
// 관 하단선은 옮겨진 벽의 **전면 기준선 교차점**을 지나야 한다(사용자 ①).
const face = outletWall ? outletPipeEnd(outletWall) : outletWallAnchor;
@@ -599,9 +624,18 @@ export function computeCulvertLayout(
)
: null;
// 다단 벽 기울기 — 같은 측점·같은 설치 측 규칙으로 형태·높이마다 표준경사(B08 은 다단을 안 셈).
const extraLean = (form: string, pureHeightM: number): number =>
leanOf({
form,
height_m: pureHeightM,
section_mode: section.design?.section_mode,
side: culvert.hidden_pipe ? culvert.inlet.face_side : null,
});
// 성토부선 + 다단 기슭막이(2026-08-22 — 보호공 삭제, 윗면 선만 성토부선으로).
// 유출측과 **집수정 계류측**이 같은 체계를 쓴다: 5m 넘으면 다단을 둘 수 있다.
const extras = buildExtrasAt(outletWall ? pipeCorners.outlet.bottom : null, {
const outletStart = outletWall ? pipeCorners.outlet.bottom : null;
const outletInput = {
startBottomElevation: outletWall?.bottomBack.elevation ?? 0,
outward: outletInfo.outward,
groundAt,
@@ -609,23 +643,47 @@ export function computeCulvertLayout(
adjusts: (revetShift?.extras ?? []).map(adjustOf),
ownerForm: outletWallSpec.form,
equalize: equalizeExtras === true,
});
leanFor: extraLean,
};
const extras = buildExtrasAt(outletStart, outletInput);
// 독립 기슭막이(관 숨김)는 집수정이 없어 이 채널이 **유입측 벽의 성토부선·다단**이다 —
// 유출측(`extras`)과 같은 시작점(관 하단 꼭짓점)·같은 `bextra` 키(2026-08-29 사용자).
const basinExtras = buildExtrasAt(
culvert.hidden_pipe ? (inletWall ? pipeCorners.inlet.bottom : null) : basinFillStart,
{
startBottomElevation: culvert.hidden_pipe
? (inletWall?.bottomBack.elevation ?? 0)
: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
ownerForm: inletWallSpec.form,
const basinStart = culvert.hidden_pipe
? inletWall
? pipeCorners.inlet.bottom
: null
: basinFillStart;
const basinInput = {
startBottomElevation: culvert.hidden_pipe
? (inletWall?.bottomBack.elevation ?? 0)
: basinFillBottom,
outward: inletInfo.outward,
groundAt,
limitOffset: inletInfo.limit,
adjusts: (revetShift?.basinExtras ?? []).map(adjustOf),
ownerForm: inletWallSpec.form,
equalize: false,
leanFor: extraLean,
};
const basinExtras = buildExtrasAt(basinStart, basinInput);
/** ** ( ) ** (2026-09-14
* · ). .
* B08 (`facility_structures`) . */
const confirmedOnly = (
built: OutletExtrasResult,
start: OffsetPoint | null,
input: typeof outletInput,
): OutletExtrasResult => {
const firstUnset = built.appliedAdjusts.findIndex((applied) => applied.h == null);
if (firstUnset < 0) return built;
return buildExtrasAt(start, {
...input,
adjusts: input.adjusts.slice(0, firstUnset),
equalize: false,
},
);
});
};
const extrasForArea = confirmedOnly(extras, outletStart, outletInput);
const basinExtrasForArea = confirmedOnly(basinExtras, basinStart, basinInput);
// ── 성토 사면 구간 확정. 벽 자리가 굳은 뒤에 물매를 역산해야 관 길이 맞춤(벽 이동)이
// 접점을 다시 깨뜨리지 않는다 — 종전 어긋남의 직접 원인이 이 순서였다.
@@ -665,8 +723,8 @@ export function computeCulvertLayout(
trimMinSlope = { points: [...(trimMinSlope?.points ?? []), ...points] };
}
};
extendTrimSlope(drawnFillPoints(extras), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtras), inletInfo.outward);
extendTrimSlope(drawnFillPoints(extrasForArea), outletInfo.outward);
extendTrimSlope(drawnFillPoints(basinExtrasForArea), inletInfo.outward);
const outletWallFinal = walls.find((wall) => wall.role === "outlet") ?? null;
const outletSlope = outletWallFinal ? slopeOf(outletWallFinal) : null;
@@ -23,9 +23,10 @@ import type {
} from "./B06_Section_UI_Cross_Culvert_Types";
import { slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
/** 기슭막이 하단선 폭(m) — 사다리꼴 밑변. 자리 기준(하단선 중점) 환산에 쓴다. */
export function fillWallBaseWidth(height: number): number {
return REVET_THICKNESS_M * 1.5 + REVET_LEAN_RATIO * height;
/** (m) . ( ) .
* `lean` = ( · `revetWallSpec().lean`). */
export function fillWallBaseWidth(height: number, lean: number = REVET_LEAN_RATIO): number {
return REVET_THICKNESS_M * 1.5 + lean * height;
}
/** 유효 지반 샘플 → offset 오름차순 보간기. 범위 밖은 끝값 클램프, 샘플 없으면 null. */
@@ -252,6 +253,7 @@ export function minShoulderWallOffset(
baseAt: (offset: number) => number,
toeOffset: number,
limitOffset: number,
lean: number = REVET_LEAN_RATIO,
): number | null {
const span = (limitOffset - edge.offset_m) * outward;
if (!(span > 0)) return null;
@@ -260,7 +262,7 @@ export function minShoulderWallOffset(
const offset = edge.offset_m + outward * ((span * i) / steps);
const rise = edge.elevation_m - (baseAt(offset) + height);
if (rise < FILL_MIN_RISE_M) continue;
const joint = offset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
const joint = offset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
if (((joint - edge.offset_m) * outward) / rise < FILL_SLOPE_RATIO_MIN) continue;
if ((offset - toeOffset) * outward > 1e-6) {
const toeRise = edge.elevation_m - (baseAt(toeOffset) + height);
@@ -296,13 +298,14 @@ export function solveWallVertical(
groundBase: number,
minHeight: number,
limitHeight: number,
lean: number = REVET_LEAN_RATIO,
): WallVertical {
const gridUp = (value: number): number =>
Math.ceil(value / REVET_HEIGHT_STEP_M - 1e-9) * REVET_HEIGHT_STEP_M;
let height = minHeight;
let desiredTop = groundBase + minHeight;
for (let i = 0; i < 4; i += 1) {
const joint = anchorOffset - outward * (fillWallBaseWidth(height) / 2 - REVET_TRAP_TOP_M);
const joint = anchorOffset - outward * (fillWallBaseWidth(height, lean) / 2 - REVET_TRAP_TOP_M);
const run = Math.max(0, (joint - edge.offset_m) * outward);
desiredTop = edge.elevation_m - run / FILL_SLOPE_RATIO_MIN;
const next = Math.min(Math.max(gridUp(desiredTop - groundBase), minHeight), limitHeight);
@@ -438,6 +441,8 @@ export function outletReceivable(input: {
toeOffset: number;
limitOffset: number;
groundAt: (offset: number) => number;
/** 유출 벽 전면 기울기(표준경사). */
lean?: number;
}): boolean {
const invertAt = (offset: number): number =>
Math.min(input.groundAt(offset), input.inletElevation);
@@ -449,6 +454,7 @@ export function outletReceivable(input: {
invertAt,
input.toeOffset,
input.limitOffset,
input.lean,
) ?? input.toeOffset;
const invert = invertAt(auto);
if (invert >= input.groundAt(auto) - 0.01) return true;
@@ -486,6 +492,7 @@ export function placeInletWall(input: {
height: number;
toeOffset: number;
limitOffset: number;
lean?: number;
};
/** 매몰-무교차 자리 금지(유출 벽 규칙) — 독립 기슭막이는 유입도 같은 규칙을 탄다. */
requireCrossing?: boolean;
@@ -80,6 +80,8 @@ export interface WallLayout {
shiftFloorM?: number;
/** 재질(메/찰/콘크리트) — 높이 한계를 정한다(2026-08-22 사용자). */
material: RevetMaterial;
/** 전면 기울기 1:n 의 n — 품셈 13-4-4 [주]⑪ 표준경사(`wallLeanRatio`, B08 과 한 벌 · 2026-09-14). */
lean: number;
outward: number;
/** 합성 단면(하부 사다리꼴 + 상부 평행사변형) 꼭짓점 — 데이터 좌표. */
points: OffsetPoint[];
+31 -25
View File
@@ -6,8 +6,9 @@
* . ** **, **
* ** . ( ).
*
* **(°)** (1:n n)
* (°) . 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
* **(1:n n)** (2026-09-12 ). (°)
* , · [ ]· 1:n
* . 참고: 1:0.4 = 68.2° · 1:1.0 = 45° · 1:1.5 = 33.7°.
*
* ** **.
* ** **
@@ -37,13 +38,18 @@ export function degreesToRatio(degrees: number): number {
return 1 / Math.tan((clamped * Math.PI) / 180);
}
/** 0.1° 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */
function sameDegrees(a: number, b: number): boolean {
return Math.abs(a - b) < 0.05;
/** 경사비를 화면 자릿수(0.01)로 맞춘다 — 사면이 서지 않는 각도(1~89° 밖)는 잘라 받는다. */
export function clampRatio(ratio: number): number {
return Math.round(degreesToRatio(ratioToDegrees(ratio)) * 100) / 100;
}
/** 0.01 단위로 같은 값인가 — 표준값으로 되돌아왔는지 가리는 데 쓴다. */
function sameRatio(a: number, b: number): boolean {
return Math.abs(a - b) < 0.005;
}
/**
* 68.2° . · .
* 1:0.40 . · .
*
* () ** **
* ( ).
@@ -58,30 +64,34 @@ export function buildCutSlopeControl(section: CrossSection, control: CutSlopeCon
const group = document.createElement("div");
group.className = "b06-design__seg-buttons";
const ratio = control.ratioFor(section);
const standard = control.standardRatioFor(section);
const degrees = ratioToDegrees(ratio);
const ratio = clampRatio(control.ratioFor(section));
const standard = clampRatio(control.standardRatioFor(section));
// 「1:」은 고정 머리말 — 사용자가 넣는 것은 뒤의 n 하나다.
const prefix = document.createElement("span");
prefix.className = "b06-design__cutslope-unit";
prefix.textContent = "1:";
const input = document.createElement("input");
input.type = "number";
input.className = "b06-design__cutslope-input";
input.step = "0.5";
input.min = "1";
input.max = "89";
input.value = degrees.toFixed(1);
input.step = "0.05";
input.min = "0.02";
input.max = "57.29";
input.value = ratio.toFixed(2);
input.title =
`이 측점의 암 절토 경사 — 각도(°)로 넣는다.\n` +
`지금 1:${ratio.toFixed(2)} (${degrees.toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` +
`이 측점의 암 절토 경사 — 경사비(1:n 의 n)로 넣는다. n 이 작을수록 급하다.\n` +
`지금 1:${ratio.toFixed(2)} (${ratioToDegrees(ratio).toFixed(1)}°) · 표준 1:${standard.toFixed(2)} (${ratioToDegrees(standard).toFixed(1)}°)\n` +
`전체를 바꾸려면 좌측 [표준 횡단면 설정]을 쓴다.`;
const commit = (): void => {
const entered = Number(input.value);
if (!Number.isFinite(entered)) {
input.value = degrees.toFixed(1);
if (!Number.isFinite(entered) || entered <= 0) {
input.value = ratio.toFixed(2);
return;
}
// 표준값으로 되돌아온 입력은 사용자 값을 남기지 않는다 — 그래야 표준을 바꿀 때 따라간다.
const next = sameDegrees(entered, ratioToDegrees(standard)) ? null : degreesToRatio(entered);
control.set(section.chainage_m, next);
const next = clampRatio(entered);
control.set(section.chainage_m, sameRatio(next, standard) ? null : next);
};
input.addEventListener("change", commit);
input.addEventListener("keydown", (event) => {
@@ -93,22 +103,18 @@ export function buildCutSlopeControl(section: CrossSection, control: CutSlopeCon
// 카드 클릭(선택·줌)이 입력을 뺏지 않게 한다 — 암 경계선 버튼과 같은 태도.
input.addEventListener("pointerdown", (event) => event.stopPropagation());
const unit = document.createElement("span");
unit.className = "b06-design__cutslope-unit";
unit.textContent = "°";
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b06-design__rockb-btn is-reset";
reset.textContent = "↺";
reset.title = `표준값으로 되돌리기 (1:${standard.toFixed(2)} · ${ratioToDegrees(standard).toFixed(1)}°)`;
reset.disabled = sameDegrees(degrees, ratioToDegrees(standard));
reset.disabled = sameRatio(ratio, standard);
reset.addEventListener("click", (event) => {
event.stopPropagation();
control.set(section.chainage_m, null);
});
group.append(input, unit, reset);
group.append(prefix, input, reset);
wrap.append(group);
return wrap;
}
+31 -8
View File
@@ -721,32 +721,55 @@ export function appendCrossDesignOverlay(
}
// 차도·노견 경계 짧은 수직 틱(N-4-2): ±3.6px(기존 ±6의 60%). 노면 단일 기울기라 육안
// 구분이 안 되는 경계를 표시한다. 노견 바깥 끝(road_edges)은 측구·사면 꺾임으로 이미 구분됨.
// 구분이 안 되는 경계를 표시한다.
const edges = design.carriageway_edges;
if (edges) {
for (const edge of [edges.left, edges.right]) {
const cx = x(edge.offset_m);
const cy = toDisplayY(edge.elevation_m);
const tickAt = (offsetM: number, elevationM: number, className: string, half: number) => {
const cx = x(offsetM);
const cy = toDisplayY(elevationM);
const tick = document.createElementNS(SVG_NS, "line");
tick.setAttribute("x1", String(cx));
tick.setAttribute("y1", String(cy - 3.6));
tick.setAttribute("y1", String(cy - half));
tick.setAttribute("x2", String(cx));
tick.setAttribute("y2", String(cy + 3.6));
tick.setAttribute("class", "b06-chart__carriageway-tick");
tick.setAttribute("y2", String(cy + half));
tick.setAttribute("class", className);
svg.append(tick);
};
for (const edge of [edges.left, edges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__carriageway-tick", 3.6);
}
// 노견 바깥 끝에도 틱을 세운다(2026-09-12 사용자) — 종전에는 차도에만 표기가 있어
// **노견이 늘어난 건지 차도가 늘어난 건지 화면에서 못 가렸다**. 노견은 좌·우 0.5m 로
// 고정이고 확폭은 차도에만 붙으므로, 두 틱 사이 간격이 그 사실을 그대로 보여 준다.
const roadEdges = design.road_edges;
if (roadEdges) {
for (const edge of [roadEdges.left, roadEdges.right]) {
tickAt(edge.offset_m, edge.elevation_m, "b06-chart__shoulder-tick", 2.4);
}
}
// 노폭 라벨(2026-09-06 사용자 지시) — 확폭이 걸린 측점인지 눈으로 바로 알게 한다.
// 확폭이 없으면 규격 폭만, 있으면 「4.5m (규격 3.0 + 확폭 1.5)」로 적는다.
const widened = (design.widening_left_m ?? 0) + (design.widening_right_m ?? 0);
const standardWidth = design.carriageway_standard_width_m;
const shoulderLeft = roadEdges ? roadEdges.left.offset_m - edges.left.offset_m : null;
const shoulderRight = roadEdges ? edges.right.offset_m - roadEdges.right.offset_m : null;
const label = document.createElementNS(SVG_NS, "text");
label.setAttribute("x", String((x(edges.left.offset_m) + x(edges.right.offset_m)) / 2));
label.setAttribute("y", String(toDisplayY(edges.left.elevation_m) - 6));
label.setAttribute("class", "b06-chart__carriageway-label");
label.textContent =
const widthText =
widened > 0.001 && typeof standardWidth === "number"
? `노폭 ${design.carriageway_width_m.toFixed(2)}m (규격 ${standardWidth.toFixed(2)} + 확폭 ${widened.toFixed(2)})`
: `노폭 ${design.carriageway_width_m.toFixed(2)}m`;
// 노견은 좌우가 같으면 한 번만 적는다 — 라벨이 길어지면 옆 측점 라벨과 겹친다.
label.textContent =
shoulderLeft != null && shoulderRight != null
? `${widthText} · 노견 ${
Math.abs(shoulderLeft - shoulderRight) < 0.005
? `${shoulderLeft.toFixed(2)}m`
: `${shoulderLeft.toFixed(2)} / 우 ${shoulderRight.toFixed(2)}m`
}`
: widthText;
svg.append(label);
}
}
+32
View File
@@ -58,6 +58,38 @@ export function toeFitHalfWidth(section: CrossSection): number | null {
return Math.min(Math.ceil(extent + 1), MAX_FIT_HALF_WIDTH_M);
}
/**
* · ** ** · offset(m). null.
*
* `toeFitHalfWidth` ** ** (2026-09-12 )
* .
* ** **(null)
* .
*/
export function toeOffsets(section: CrossSection): { left: number | null; right: number | null } {
const out: { left: number | null; right: number | null } = { left: null, right: null };
const design = section.design;
if (!design) return out;
const groundAt = groundInterpolator(section.samples);
const designAt = designInterpolator(design.design_line);
const edges = design.road_edges;
if (!groundAt || !designAt || !edges) return out;
const lineOffsets = design.design_line.map((point) => point.offset_m);
if (lineOffsets.length < 2) return out;
const { protectMax, protectMin } = protectedSpan(design, edges);
for (const side of ["left", "right"] as const) {
const outward = side === "left" ? 1 : -1;
const start = side === "left" ? protectMax : protectMin;
const limit = side === "left" ? Math.max(...lineOffsets) : Math.min(...lineOffsets);
const meet = meetOffset(designAt, groundAt, start, limit, outward);
// 설계선 끝 밖으로 나간 값은 외삽이다 — 만난 것으로 치지 않는다.
if (Math.abs(meet) > Math.abs(limit) + 1e-9) continue;
if (Math.abs(designAt(meet) - groundAt(meet)) > MEET_TOLERANCE_M) continue;
out[side] = meet;
}
return out;
}
/**
* **** (m) null (2026-09-03 ).
*
@@ -27,6 +27,8 @@ export interface FordPavementSpec {
depth_m: number | null;
/** 유입 → 유출 바닥 경사(%). 비우면 노면 횡단경사를 쓴다. */
slope_pct: number | null;
/** 정본에 안 적혀 기본값으로 그린 칸 「이름 값」 — 카드 머리에 「기본값(미확정)」. */
defaulted?: string[];
}
interface Edge {
+18
View File
@@ -0,0 +1,18 @@
/* =============================================================================
* B06_Section_UI_Cross_Lean.ts
* 13-4-4 [] (`resources/data_masonry/masonry_slope_*`)
* (2026-09-14 B5 · B08 ). `common_util_masonry_slope.ts`( ).
*
* (JSON) Node
* . ** **,
* ( ··C군 ).
* import `test_b06_wall_lean_table` .
* ========================================================================== */
import { type WallLeanInput, wallLeanRatio } from "../common_util/common_util_masonry_slope";
import masonrySlope from "../resources/data_masonry/masonry_slope_2026-01-01.json";
/** 벽 전면 기울기 n — 표를 못 고르면 종전 0.3. */
export function leanOf(input: WallLeanInput): number {
return wallLeanRatio(masonrySlope, input);
}
+30 -3
View File
@@ -23,10 +23,10 @@ import {
PIPE_CONNECT_GRADE,
PIPE_WALL_DEFAULT_RUN_M,
REVET_EMBED_DEPTH_M,
REVET_LEAN_RATIO,
REVET_THICKNESS_M,
revetHeightLimit,
} from "./B06_Section_UI_Cross_Culvert_Const";
import { leanOf } from "./B06_Section_UI_Cross_Lean";
import type { RevetMaterial } from "./B06_Section_UI_Cross_Culvert_Const";
import { fillWallBaseWidth, groundInterpolator } from "./B06_Section_UI_Cross_Culvert_Solve";
import { buildExtrasAt, slopedCrossing } from "./B06_Section_UI_Cross_Culvert_Extra";
@@ -61,8 +61,17 @@ export interface RevetmentSpec {
/** (구 모델) 기준 올림·좌우 이동 — 배관식 전환 뒤 자리는 조정창 4축(x·d)이 정한다. */
lift_m?: number | null;
shift_m?: number | null;
/** 전면 기울기를 B08 과 같은 칸으로 가르는 값 — 성토/절토 · 전면 기울기(2026-09-14 B5). */
face_role?: string | null;
face_slope_ratio?: number | string | null;
}
/** 표준경사 표를 읽는 벽 종류 → 형태. B08 이 돌쌓기 식으로 세는 것만(큰돌쌓기·옹벽은 종전 0.3). */
const TABLE_FORM_BY_TYPE: Record<string, string> = {
masonry_wet: "돌쌓기(찰)",
masonry_dry: "돌쌓기(메)",
};
/** 조정창 조작값 중 이 벽이 쓰는 축 — 배관 벽과 같은 형태(x 좌우·d 사면·h 높이). */
export interface RevetmentAdjust {
x?: number;
@@ -187,9 +196,19 @@ export function computeRevetmentLayout(
/** 기준선(근입 위)~상단 — 도형 계산은 종전대로 이 값으로 한다. */
const height = pureHeight - REVET_EMBED_DEPTH_M;
// 전면 기울기 — B08 이 그 벽을 셀 때와 같은 칸(종류·요청 높이·단면유형·설치 측·사용자 칸)으로
// 표준경사를 고름(2026-09-14 B5). 높이는 형태 한계로 자르기 **전** 값 — B08 은 저장 높이를 씀.
const lean = leanOf({
form: TABLE_FORM_BY_TYPE[spec.type_id] ?? (spec.type_id === "revetment" ? spec.form : null),
height_m: requestedHeight,
section_mode: design.section_mode,
side: spec.side,
face_role: spec.face_role,
face_slope_ratio: spec.face_slope_ratio,
});
const roadSlope = roadSlopePerOutward(design, side, outward);
const autoOffset =
edge.offset_m + outward * (fillWallBaseWidth(height) / 2 - REVET_THICKNESS_M / 2);
edge.offset_m + outward * (fillWallBaseWidth(height, lean) / 2 - REVET_THICKNESS_M / 2);
// 자동 자리 = 성토사면(1:1.2) 위 기본 지점 — 배관 유출 벽과 같은 기준값.
const autoRun = PIPE_WALL_DEFAULT_RUN_M;
/**
@@ -244,6 +263,7 @@ export function computeRevetmentLayout(
form: spec.form,
lengthM,
floatGapM,
lean,
});
// 넣은 좌우 이동이 **각도 하한**에 눌려 통째로 무시됐으면 그 하한을 남긴다(2026-09-07).
// 실측 — d 2.6m 자리에서 하한이 1.0m 를 넘어 「1.0m 를 넣어도 0.00m 이동」이 났고,
@@ -267,6 +287,13 @@ export function computeRevetmentLayout(
// 그려졌다(2026-08-30 사용자: 형태별 모양이 누락되는 경우).
ownerForm: spec.form,
equalize: false,
leanFor: (form, pureHeightM) =>
leanOf({
form,
height_m: pureHeightM,
section_mode: design.section_mode,
side: spec.side,
}),
})
: { walls: [], segments: [], appliedAdjusts: [], addable: false };
@@ -375,7 +402,7 @@ export function appendRevetmentOverlay(
const keyId = index === 0 ? "own" : `own-extra${index - 1}`;
const tooltip =
`독립 기슭막이 ${wall.form ?? ""} H=${(wall.height + REVET_EMBED_DEPTH_M).toFixed(1)}m` +
`(상단 = 성토선 접점, 전면 1:${REVET_LEAN_RATIO}, 높이 한계 ${revetHeightLimit(
`(상단 = 성토선 접점, 전면 1:${wall.lean}(품셈 13-4-4 [주]⑪ 표준경사), 높이 한계 ${revetHeightLimit(
wall.form,
).toFixed(1)}m 7-3)` +
(wall.floatGapM > 0.01
+26 -159
View File
@@ -1,24 +1,23 @@
/* =============================================================================
* B06_Section_UI_Cross_View_Zoom.ts
* **··** . (`_UI_Cross_View.ts`)
* 700 . ( ) .
* **··** .
*
* · ** **(`A00_Common/b_svg_zoom_pan.ts`)
* (2026-09-12 ) .
* **B06 ** 남는다: 카드 (`CROSS_PAD`) , ,
* . ( )
* (2026-08-02 ).
* ========================================================================== */
import {
attachSvgZoomPan,
type ContentBounds,
type ZoomPanHandle,
type ZoomPanState,
} from "../A00_Common/b_svg_zoom_pan";
import { CROSS_PAD, L } from "./B06_Section_UI_Section_Common";
/**
* · . `zoom()` .
*
* ** **(2026-08-02 ).
* . · , , .
*/
export interface ZoomPanHandle {
/** 1보다 크면 확대, 작으면 축소. 플롯 영역의 중앙을 붙잡는다. */
zoom: (factor: number) => void;
reset: () => void;
/** 현재 배율 — 원배율(1)일 때는 버튼이 배율 대신 **표시 반폭**을 조절한다. */
scale: () => number;
}
export type { ContentBounds, ZoomPanHandle, ZoomPanState };
/**
* (2026-08-23 ). //
@@ -34,28 +33,10 @@ export interface CrossWidthActions {
fit: () => void;
}
/** 줌·팬 상태 — 카드가 다시 그려질 때 배율을 되살리는 데 쓴다(2026-08-22 사용자 ③). */
export interface ZoomPanState {
scale: number;
tx: number;
ty: number;
}
/**
* ( SVG ).
* ** **
* (2026-08-23 ).
*/
export interface ContentBounds {
x: number;
y: number;
width: number;
height: number;
}
/** 측점별 줌·팬 상태 — 카드를 다시 만들어도 배율이 살아남는다(2026-08-22 사용자 ③). */
export const cardZoomStates = new Map<string, ZoomPanState>();
/** 카드 SVG 에 줌·팬을 붙인다 — 여백만 씌워 공용 조각에 넘긴다. 부르는 꼴은 그대로다. */
export function attachZoomPan(
svg: SVGSVGElement,
plotLayer: SVGGElement,
@@ -68,132 +49,18 @@ export function attachZoomPan(
/** 도형이 실제로 그려진 범위(캐시 보유 폭). 확대 상태에서 팬 한계가 여기까지 늘어난다. */
content?: ContentBounds,
): ZoomPanHandle {
// 플롯 영역(축 안쪽) — 확대·축소의 중심이자 이동 한계의 기준이다.
const plot = {
x: CROSS_PAD.left,
y: CROSS_PAD.top,
width: Math.max(widthPx - CROSS_PAD.left - CROSS_PAD.right, 1),
height: Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1),
};
const maxScale = 8;
let scale = initial?.scale ?? 1;
let tx = initial?.tx ?? 0;
let ty = initial?.ty ?? 0;
const applyTransform = (): void => {
plotLayer.setAttribute("transform", `translate(${tx} ${ty}) scale(${scale})`);
onChange?.({ scale, tx, ty });
};
// 확대한 도형이 플롯 영역을 항상 덮게 이동량을 가둔다 — 원배율에서는 이동량이 0으로 묶인다.
// **확대 상태**에서는 한계가 그려진 도형 전체(표시 반폭 밖 캐시 보유분 포함)까지 늘어나,
// 가운데 버튼 팬으로 잘려 있던 지반·설계선을 끌어다 볼 수 있다(2026-08-23 사용자 지시).
const clampAxis = (
value: number,
start: number,
size: number,
from: number,
to: number,
): number =>
Math.min(
Math.max(value, Math.min(start + size - scale * to, start - scale * from)),
Math.max(start + size - scale * to, start - scale * from),
);
const clampPan = (): void => {
// 도형 범위를 모르면 플롯 영역 자신이 한계다(기존 규칙). 알면 **원배율에서도** 그
// 범위까지 열어 둔다 — 캐시 보유분이 표시 반폭보다 넓으면 1배에서도 끌어다 봐야 한다
// (2026-08-23 사용자 재보고: 확대해야만 움직이는 줄 모르고 안 된다고 판단).
// 캐시가 표시 폭과 같으면 범위가 플롯과 같아져 종전처럼 이동량 0으로 묶인다.
const bounds = content
? {
x: Math.min(content.x, plot.x),
y: Math.min(content.y, plot.y),
right: Math.max(content.x + content.width, plot.x + plot.width),
bottom: Math.max(content.y + content.height, plot.y + plot.height),
}
: { x: plot.x, y: plot.y, right: plot.x + plot.width, bottom: plot.y + plot.height };
tx = clampAxis(tx, plot.x, plot.width, bounds.x, bounds.right);
ty = clampAxis(ty, plot.y, plot.height, bounds.y, bounds.bottom);
};
// 보이는 **플롯 영역의 중앙**을 붙잡고 확대·축소한다 — 버튼에는 마우스 위치가 없다.
const zoom = (factor: number): void => {
const next = Math.min(maxScale, Math.max(1, scale * factor));
const cx = plot.x + plot.width / 2;
const cy = plot.y + plot.height / 2;
tx = cx - ((cx - tx) / scale) * next;
ty = cy - ((cy - ty) / scale) * next;
scale = next;
clampPan();
applyTransform();
};
let panning = false;
let moved = false;
let lastX = 0;
let lastY = 0;
// 가운데 버튼을 누르면 브라우저가 자동 스크롤(가운데 클릭 스크롤)을 켠다 — `mousedown`
// 기본동작이라 `pointerdown`에서는 못 막는다. 여기서 막아야 팬만 남는다(2026-08-02 사용자 지시).
svg.addEventListener("mousedown", (event) => {
if (event.button === 1) event.preventDefault();
return attachSvgZoomPan({
svg,
layer: plotLayer,
widthPx,
heightPx,
pad: CROSS_PAD,
initial,
onChange,
content,
// 휠은 카드 목록을 훑는 몫이다 — 줌에 안 묶는다.
wheelZoom: false,
});
svg.addEventListener("auxclick", (event) => {
if (event.button === 1) event.preventDefault();
});
svg.addEventListener("pointerdown", (event) => {
// 팬은 **가운데 버튼**만. 좌클릭은 측점 선택·면적 강조 몫이다.
if (event.button !== 1) return;
event.preventDefault();
panning = true;
moved = false;
lastX = event.clientX;
lastY = event.clientY;
svg.classList.add("is-panning");
svg.setPointerCapture(event.pointerId);
});
svg.addEventListener("pointermove", (event) => {
if (!panning) return;
if (Math.abs(event.clientX - lastX) + Math.abs(event.clientY - lastY) > 2) moved = true;
// 도형을 직접 미는 방식이라 커서를 따라간다(viewBox를 밀던 때와 부호가 반대다).
const rect = svg.getBoundingClientRect();
tx += ((event.clientX - lastX) / rect.width) * widthPx;
ty += ((event.clientY - lastY) / rect.height) * heightPx;
lastX = event.clientX;
lastY = event.clientY;
clampPan();
applyTransform();
});
const endPan = (event: PointerEvent): void => {
if (!panning) return;
panning = false;
svg.classList.remove("is-panning");
try {
svg.releasePointerCapture(event.pointerId);
} catch {
/* 이미 해제됨 */
}
};
if (initial && (scale !== 1 || tx !== 0 || ty !== 0)) {
clampPan();
applyTransform();
}
svg.addEventListener("pointerup", endPan);
svg.addEventListener("pointercancel", endPan);
// 드래그(팬)로 끝난 클릭은 카드 선택으로 전파하지 않는다.
svg.addEventListener("click", (event) => {
if (moved) event.stopPropagation();
});
const currentScale = (): number => scale;
const reset = (): void => {
scale = 1;
tx = 0;
ty = 0;
applyTransform();
};
// 더블클릭 원복.
svg.addEventListener("dblclick", (event) => {
event.stopPropagation();
reset();
});
return { zoom, reset, scale: currentScale };
}
/**
+6 -2
View File
@@ -55,6 +55,8 @@ export interface RevetWallGeometryInput {
thickness?: number;
floatGapM?: number;
extraIndex?: number;
/** 전면 기울기 1:n 의 n — 표준경사(`leanOf`). 없으면 종전 0.3. */
lean?: number;
}
/**
@@ -65,14 +67,15 @@ export interface RevetWallGeometryInput {
export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayout {
const { anchor, outward, height, material, role } = input;
const thickness = input.thickness ?? REVET_THICKNESS_M;
const baseWidth = thickness * 1.5 + REVET_LEAN_RATIO * height;
const lean = input.lean ?? REVET_LEAN_RATIO;
const baseWidth = thickness * 1.5 + lean * height;
const backOffset = anchor.offset - outward * (baseWidth / 2);
const topJoint = backOffset + outward * (thickness / 2);
const topElevation = anchor.elevation + height;
const topBack: OffsetPoint = { offset: backOffset, elevation: topElevation };
const topFront = topJoint + outward * thickness;
const frontXAt = (elevation: number): number =>
topFront + outward * REVET_LEAN_RATIO * (topElevation - elevation);
topFront + outward * lean * (topElevation - elevation);
const bottomElevation = anchor.elevation - REVET_EMBED_DEPTH_M;
const bottomBack: OffsetPoint = { offset: backOffset, elevation: bottomElevation };
const bottomFront: OffsetPoint = {
@@ -90,6 +93,7 @@ export function buildRevetWallGeometry(input: RevetWallGeometryInput): WallLayou
height,
floatGapM: input.floatGapM ?? 0,
material,
lean,
outward,
topBack,
topJoint: { offset: topJoint, elevation: topElevation },
@@ -172,15 +172,9 @@ export function appendWallHatch(
((offset - wall.bottomBack.offset) / bottomSpan)
: wall.bottomBack.elevation;
// 평행사변형 띠와 사다리꼴 사이 대각 이음선(사용자 스케치의 가운데 선) —
// 상단 변 중간점에서 전면과 나란히 바닥으로 내려온다. 형태와 무관하게 늘 긋는다.
const jointBaseOffset = wall.outerOffset - wall.outward * REVET_THICKNESS_M;
layer.append(
line(
[x(wall.topJoint.offset), toDisplayY(wall.topJoint.elevation)],
[x(jointBaseOffset), toDisplayY(bottomAtOffset(jointBaseOffset))],
),
);
// 벽을 가로지르던 대각 이음선은 **긋지 않는다**(2026-09-12 사용자: 필요 없음).
// 종전에는 상단 변 중간점에서 바닥까지 형태와 무관하게 늘 그어, 돌쌓기·콘크리트 해칭
// 위로 선이 하나 더 지나가 도면이 지저분했다.
clipSeq += 1;
const clipId = `b06-revet-clip-${keyId}-${clipSeq}`;
+45 -166
View File
@@ -1,4 +1,5 @@
import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session";
import { createDesignSync } from "./B06_Section_UI_Page_Design_Sync";
import { createGradeEdit } from "./B06_Section_UI_Page_Grade_Edit";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { readByKey, stateKey, writeByKey } from "../A00_Common/b_page_state";
@@ -23,26 +24,20 @@ import {
type StandardCrossSection,
} from "./B06_Section_Api_Fetch";
import { createStationControls } from "./B06_Section_UI_Page_Station_Controls";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import {
bermSpansFromStructures,
confirmCurrentSections,
createCutSlopeStore,
createRockBoundaryStore,
readBermSpans,
saveCurrentSections,
writeBermSpans,
type SectionPersistContext,
} from "./B06_Section_UI_Page_Persist";
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import {
readStructurePick,
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { applyStructurePick } from "./B06_Section_UI_Page_Structure_Pick";
import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { createSectionView } from "./B06_Section_UI_Section_View";
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
import {
applyPipeOptionsToCache,
@@ -171,6 +166,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
// 조작(조정창 높이)이 있을 때만 — 없으면 그려진 높이는 관경 기준 제안이라 옵션에 안 적음
// (2026-09-14 브레인 판정 ④ 「기본값을 몰래 확정으로 안 바꿈」).
if (adjust?.h == null) return null;
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m)
.pureHeight;
},
@@ -185,6 +183,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
if (!owner || !culvert) return null;
const spec = role === "outlet" ? culvert.outlet : culvert.inlet;
const adjust = stationControls.revetOffset.adjustFor(owner, role);
if (!adjust?.m) return null; // 조작한 형태만 — 기본 형태는 제안(판정 ④)
return revetWallSpec(spec, adjust, culvert.hidden_pipe === true, culvert.diameter_m).form;
},
// 좌·우 이름표 — +offset이 좌측이고 유입 벽은 오르막(계류) 쪽에 선다.
@@ -201,8 +200,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
queuePipeOptions: (chainageM, patch) => stationControls.queueCulvertOptions(chainageM, patch),
applyPipeOptions: (chainageM, patch) =>
applyPipeOptionsToCache(pipeOptionsContext, chainageM, patch),
movePipe: (fromChainageM, toChainageM) =>
stationControls.queueCulvertMove(fromChainageM, toChainageM),
});
const dockDivider = document.createElement("hr");
@@ -218,140 +215,19 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
// 그룹 제목 행 클릭 시 접기/펼치기(N-4-1). 액션 버튼 행은 collapsible 아님.
attachCollapsible(leftForm);
/**
* : (1) ( ),
* (2) · . .
* DB config .
*/
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
if (!projectId || currentRouteId === null || !sectionDetail) return;
const target = sectionDetail.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
if (!target) return;
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
if (target.design) {
target.design = {
...target.design,
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? target.design.ditch_side,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
ditch_choice: change.ditch_choice,
};
sectionView.refreshCard(chainageM);
}
// (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고,
// [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로
// 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다.
writeCrossDesignChoice(projectId, currentRouteId, chainageM, {
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? null,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
ditch_choice: change.ditch_choice,
});
// (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다.
await reconcileStaleDesigns({ force: true });
}
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
function changeFromDesign(chainageM: number): CrossDesignChange | null {
const target = sectionDetail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
const design = target?.design;
if (!design) return null;
return {
ground_type: design.ground_type,
section_mode: design.section_mode,
ditch_side: design.ditch_side ?? null,
ditch_type: design.ditch_type ?? "standard",
paved: design.paved,
two_stage_slope: design.two_stage_slope ?? true,
ditch_choice: design.ditch_choice ?? null,
};
}
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
function recomputeIfRock(chainageM: number): void {
const change = changeFromDesign(chainageM);
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
}
/**
* stale design을 · (E-1 + N-6).
* : (1) 2 (`two_stage_slope`) , (2) B05에서
* · (`design.design_elevation_m`)
* (`design_profiles`) . 0 API
* .
*
* ** 1** (2026-08-04
* for-await stale "이력 재생"
* ). B05 ( ),
* (profile_alignment.edits) . (··
* · ) , .
*/
async function reconcileStaleDesigns(options?: { force?: boolean }): Promise<void> {
if (!sectionDetail || !projectId || currentRouteId === null) return;
const draft = readAlignmentDraft(currentRouteId);
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
//
// 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
try {
const alignment = sectionDetail.longitudinal.profile_alignment as
| {
edits?: {
station_offsets?: Record<string, number>;
curve_radii?: Record<string, number>;
};
}
| undefined;
const edits = draft ?? {
station_offsets: alignment?.edits?.station_offsets ?? {},
curve_radii: alignment?.edits?.curve_radii ?? {},
};
// 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서
// 다른 값이 된다(2026-09-03 사용자 지시로 일원화).
const updated = await refreshCrossDesigns({
projectId,
routeId: currentRouteId,
detail: sectionDetail,
edits,
});
for (const chainageM of updated) sectionView.refreshCard(chainageM);
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
}
}
/**
* ****(C군 ) .
*
* (2026-09-07 ), .
* .
*/
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
if (!projectId || currentRouteId === null) return;
const next = bermSpansFromStructures(structures);
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
writeBermSpans(projectId, currentRouteId, next);
void reconcileStaleDesigns({ force: true });
}
// 설계 선택 반영·재계산·소단 동기화는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
const {
handleDesignChange,
recomputeIfRock,
reconcileStaleDesigns,
syncBermSpans,
applyPanelToAll,
} = createDesignSync({
projectId,
routeId: () => currentRouteId,
detail: () => sectionDetail,
view: () => sectionView,
});
/** 구조물(C군 벽)이 바뀌면 횡단 제원이 달라진다 — 초안을 얹고 다시 그린다. */
async function refreshDetailForStructures(): Promise<void> {
@@ -408,28 +284,6 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
showToast(L("B06_View_Apply_Success"), "success");
}
/** [ ](N-2-1): design .
* handleDesignChange가 standardPanel.getValues()
* . await로 API . */
let applyingAll = false;
async function applyPanelToAll(): Promise<void> {
if (applyingAll || !sectionDetail || !projectId || currentRouteId === null) return;
const targets = sectionDetail.cross_sections.filter((section) => section.design);
if (!targets.length) return;
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
// 대신 도는 동안 다시 누르는 것만 막는다.
applyingAll = true;
try {
for (const section of targets) {
const change = changeFromDesign(section.chainage_m);
if (change) await handleDesignChange(section.chainage_m, change);
}
showToast(L("B06_Std_ApplyAll_Success"), "success");
} finally {
applyingAll = false;
}
}
// 암 경계선 오프셋(측점별) 세션 저장소는 저장 흐름 모듈이 맡는다(2026-09-02 분리).
const rockStore = createRockBoundaryStore({
sessionKey: () => stateKey("rockb", projectId, currentRouteId),
@@ -483,6 +337,31 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
);
// 좌측 목록이 넘겨 준 구조물을 종단 알약 레인으로 보낸다(표시 통일).
structureMarksSink = (structures, types) => sectionView.setStructureMarks(structures, types);
// 계획선 편집(▲/▼) 제공자는 따로 뗀 모듈이 맡는다(2026-09-13 분리).
const gradeEditFor = createGradeEdit({
routeId: () => currentRouteId,
detail: () => sectionDetail,
view: () => sectionView,
reconcile: () => reconcileStaleDesigns({ force: true }),
});
sectionView.setGradeEdit(gradeEditFor);
// 종단 그래프 우클릭 — B05 와 같은 메뉴로 넣고 뺀다(2026-09-12 사용자: B05·B06 은 한
// 페이지라 같은 자리에서 되어야 한다). 어느 길로 들어와도 좌측 「구조물 배치」와
// 같은 함수를 타므로 목록·폼·알약이 함께 선다.
sectionView.setStructureEdit({
addPipe: (chainageM) => structuresPanel.addPipeAt(chainageM),
removePipe: (chainageM) => structuresPanel.removePipeAt(chainageM),
addStructureType: (chainageM, typeId) => structuresPanel.addStructureAt(chainageM, typeId),
removeStructure: (structureId) => {
structuresPanel.removeStructureById(structureId);
},
movePipe: (fromChainageM, toChainageM) =>
structuresPanel.movePipeTo(fromChainageM, toChainageM),
moveStructure: (structureId, toChainageM) => {
structuresPanel.moveStructureTo(structureId, toChainageM);
},
});
// 폼 → 횡단 캐시 반영은 따로 뗀 모듈이 맡는다(2026-09-02 분리).
const pipeOptionsContext: PipeOptionsContext = {
detail: () => sectionDetail,
@@ -0,0 +1,218 @@
/* =============================================================================
* B06_Section_UI_Page_Design_Sync.ts
* · . `_UI_Page` 700
* (2026-09-13 ).
* - `handleDesignChange` : + +
* - `recomputeIfRock` : ·
* - `reconcileStaleDesigns`: design (B05 )
* - `syncBermSpans` :
* - `applyPanelToAll` : [ ]
* ========================================================================== */
import { writeCrossDesignChoice } from "./B06_Section_Cross_Design_Session";
import { showToast } from "@ui/ui_template_elements";
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
import {
bermSpansFromStructures,
readBermSpans,
writeBermSpans,
} from "./B06_Section_UI_Page_Persist";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { L } from "./B06_Section_UI_Page_Common";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
export interface DesignSyncContext {
projectId: string | null;
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
/** 뷰는 이 모듈보다 **뒤에** 만들어지므로 그때 채워지는 참조를 통해 부른다. */
view: () => SectionViewController;
}
export interface DesignSyncController {
handleDesignChange: (chainageM: number, change: CrossDesignChange) => Promise<void>;
recomputeIfRock: (chainageM: number) => void;
reconcileStaleDesigns: (options?: { force?: boolean }) => Promise<void>;
syncBermSpans: (structures: ReadonlyArray<StructureInstance>) => void;
applyPanelToAll: () => Promise<void>;
}
export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
const { projectId } = ctx;
/**
* : (1) ( ),
* (2) · . .
* DB config .
*/
async function handleDesignChange(chainageM: number, change: CrossDesignChange): Promise<void> {
const sectionDetail = ctx.detail();
const currentRouteId = ctx.routeId();
if (!projectId || currentRouteId === null || !sectionDetail) return;
const target = sectionDetail.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < 0.01,
);
if (!target) return;
// (1) 즉시 로컬 반영: 선택 버튼만 갱신(숫자·설계선은 기존값 유지) → 해당 카드만 교체.
if (target.design) {
target.design = {
...target.design,
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? target.design.ditch_side,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
ditch_choice: change.ditch_choice,
};
ctx.view().refreshCard(chainageM);
}
// (2) 선택은 **세션 초안**으로 남긴다 — 화면을 오가거나 새로고침해도 남고,
// [저장]·[확정] 때 한 번에 정본으로 나간다(2026-09-06 사용자 확정: 캐시가 저절로
// 영구저장소로 새면 안 된다). 예전에는 여기서 서버가 계산하고 바로 저장했다.
writeCrossDesignChoice(projectId, currentRouteId, chainageM, {
ground_type: change.ground_type,
section_mode: change.section_mode,
ditch_side: change.ditch_side ?? null,
ditch_type: change.ditch_type,
paved: change.paved,
two_stage_slope: change.two_stage_slope,
ditch_choice: change.ditch_choice,
});
// (3) 계산은 브라우저 안에서 — B05·B06 이 같이 쓰는 창구 하나로 돌린다.
await reconcileStaleDesigns({ force: true });
}
/** 현재 design 값에서 재계산용 change를 복원한다(암 경계 오프셋 변경 시 재계산 트리거). */
function changeFromDesign(chainageM: number): CrossDesignChange | null {
const target = ctx
.detail()
?.cross_sections.find((section) => Math.abs(section.chainage_m - chainageM) < 0.01);
const design = target?.design;
if (!design) return null;
return {
ground_type: design.ground_type,
section_mode: design.section_mode,
ditch_side: design.ditch_side ?? null,
ditch_type: design.ditch_type ?? "standard",
paved: design.paved,
two_stage_slope: design.two_stage_slope ?? true,
ditch_choice: design.ditch_choice ?? null,
};
}
/** 암 경계 오프셋 변경 후 암 지반이면 2단계 무릎·단면적을 서버 재계산한다. */
function recomputeIfRock(chainageM: number): void {
const change = changeFromDesign(chainageM);
if (change && change.ground_type !== "soil") void handleDesignChange(chainageM, change);
}
/**
* stale design을 · (E-1 + N-6).
* : (1) 2 (`two_stage_slope`) , (2) B05에서
* · (`design.design_elevation_m`)
* (`design_profiles`) . 0 API
* .
*
* ** 1** (2026-08-04
* for-await stale "이력 재생"
* ). B05 ( ),
* (profile_alignment.edits) . (··
* · ) , .
*/
async function reconcileStaleDesigns(options?: { force?: boolean }): Promise<void> {
const sectionDetail = ctx.detail();
const currentRouteId = ctx.routeId();
if (!sectionDetail || !projectId || currentRouteId === null) return;
const draft = readAlignmentDraft(currentRouteId);
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
//
// 다만 **미저장 세션 편집이 있으면 판정을 건너뛰고 무조건 맞춘다**. 서버에서 갓 받은
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return;
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
// (CLAUDE.md 5장).
try {
const alignment = sectionDetail.longitudinal.profile_alignment as
| {
edits?: {
station_offsets?: Record<string, number>;
curve_radii?: Record<string, number>;
};
}
| undefined;
const edits = draft ?? {
station_offsets: alignment?.edits?.station_offsets ?? {},
curve_radii: alignment?.edits?.curve_radii ?? {},
};
// 재계산은 B05와 **같은 창구**를 쓴다 — 인자가 갈리면 같은 데이터가 두 화면에서
// 다른 값이 된다(2026-09-03 사용자 지시로 일원화).
const updated = await refreshCrossDesigns({
projectId,
routeId: currentRouteId,
detail: sectionDetail,
edits,
});
// 카드는 한꺼번에 갈아 끼운다 — 측점마다 `refreshCard` 를 부르면 그때마다 종단
// 그래프·유토곡선까지 다시 그려 측점 수만큼 화면이 멈췄다(2026-09-12).
ctx.view().refreshCards(updated);
} catch (error) {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`${L("B06_Design_Failed")}${detail}`, "error");
}
}
/**
* ****(C군 ) .
*
* (2026-09-07 ), .
* .
*/
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
const currentRouteId = ctx.routeId();
if (!projectId || currentRouteId === null) return;
const next = bermSpansFromStructures(structures);
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
writeBermSpans(projectId, currentRouteId, next);
void reconcileStaleDesigns({ force: true });
}
/** [ ](N-2-1): design .
* handleDesignChange가 standardPanel.getValues()
* . await로 API . */
let applyingAll = false;
async function applyPanelToAll(): Promise<void> {
const sectionDetail = ctx.detail();
if (applyingAll || !sectionDetail || !projectId || ctx.routeId() === null) return;
const targets = sectionDetail.cross_sections.filter((section) => section.design);
if (!targets.length) return;
// 화면을 잠그지 않는다 — 카드가 하나씩 갱신되는 것이 곧 진행 표시다(CLAUDE.md 5장).
// 대신 도는 동안 다시 누르는 것만 막는다.
applyingAll = true;
try {
for (const section of targets) {
const change = changeFromDesign(section.chainage_m);
if (change) await handleDesignChange(section.chainage_m, change);
}
showToast(L("B06_Std_ApplyAll_Success"), "success");
} finally {
applyingAll = false;
}
}
return {
handleDesignChange,
recomputeIfRock,
reconcileStaleDesigns,
syncBermSpans,
applyPanelToAll,
};
}
@@ -36,6 +36,38 @@ function wingOptions(role: "inlet" | "outlet", patch: WingPatch): Record<string,
return options;
}
/** 옵션 키 → 「기본값(미확정)」 목록의 이름(파이썬 `_defaulted` 이름표와 같은 글). */
const DEFAULTED_LABELS: Record<string, string> = {
pipe_kind: "관종",
pipe_diameter_mm: "관경",
ford_width_m: "월류 폭",
pipe_count: "배관 수량",
body_width_m: "본체 폭",
body_height_m: "본체 높이",
};
for (const [side, name] of [
["in", "유입"],
["out", "유출"],
]) {
DEFAULTED_LABELS[`wing_${side}`] = `${name} 날개벽`;
DEFAULTED_LABELS[`wing_${side}_height_m`] = `${name} 날개벽 높이`;
DEFAULTED_LABELS[`wing_${side}_length_m`] = `${name} 날개벽 길이`;
DEFAULTED_LABELS[`wing_${side}_angle_deg`] = `${name} 날개벽 각도`;
}
/** ()
* (2026-09-14 ). ( ). */
function dropDefaulted(spec: { defaulted?: string[] } | undefined, options: object): void {
if (!spec?.defaulted) return;
const labels = Object.keys(options).flatMap((key) => DEFAULTED_LABELS[key] ?? []);
spec.defaulted = spec.defaulted.filter(
(item) =>
!labels.some(
(label) => item.startsWith(`${label} `) && !item.slice(label.length + 1).includes(" "),
),
);
}
/** 좌측 폼이 낸 옵션에서 그 측 날개벽 조작값을 읽는다(없는 항목은 빼고 돌려준다). */
function wingPatchFrom(patch: Record<string, number | string>, prefix: string): WingPatch {
const num = (key: string): number | undefined => {
@@ -157,6 +189,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
// 월류 폭 = 구체의 도로 진행 방향 길이(`span_m`) — 백엔드 `_ford_set`과 같은 자리.
if (patch.ford_width_m) spec.span_m = patch.ford_width_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch);
deps.refreshCard(chainageM);
},
@@ -171,6 +204,7 @@ export function createFordControls(deps: FordControlDeps): FordControls {
if (patch.angle_deg !== undefined) wing.angle_deg = patch.angle_deg;
wing.slab_extend_m = wingSlabExtendM(wing.installed, wing.length_m, wing.angle_deg);
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -349,6 +383,7 @@ export function createBoxControls(deps: FordControlDeps): {
}
if (patch.body_height_m) spec.inner_height_m = patch.body_height_m;
}
dropDefaulted(spec, patch);
deps.queuePipeOptions(chainageM, patch as Record<string, number | string>);
deps.refreshCard(chainageM);
},
@@ -364,6 +399,7 @@ export function createBoxControls(deps: FordControlDeps): {
? Math.max((wing.length_m ?? 0) * Math.cos(((wing.angle_deg ?? 45) * Math.PI) / 180), 0)
: 0;
}
dropDefaulted(spec, wingOptions(role, patch));
deps.queuePipeOptions(chainageM, wingOptions(role, patch));
deps.refreshCard(chainageM);
},
@@ -0,0 +1,85 @@
/* =============================================================================
* B06_Section_UI_Page_Grade_Edit.ts
* (/) . `_UI_Page` 700
* (2026-09-13 ).
* ========================================================================== */
import {
createProfileEditStore,
type ProfileEditStore,
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
import {
adjustStation,
buildAlignment,
toAlignmentBase,
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { SectionViewController } from "./B06_Section_UI_Section_View";
type GradeEditProvider = NonNullable<Parameters<SectionViewController["setGradeEdit"]>[0]>;
export interface GradeEditContext {
routeId: () => number | null;
detail: () => SectionDetailResponse | null;
view: () => SectionViewController;
/** 전 측점 횡단 재계산 — 손을 뗀 뒤 한 번만 돈다. */
reconcile: () => Promise<void>;
}
/**
* (/) B05 ** ** (2026-09-12 사용자: B06
* ). · (`reconcile`),
* []·[] (CLAUDE.md 5).
*/
export function createGradeEdit(ctx: GradeEditContext): GradeEditProvider {
let gradeStore: ProfileEditStore | null = null;
let gradeRouteId: number | null = null;
/** 10(`HOLD_INTERVAL_MS`)
* , . */
const GRADE_RECONCILE_DEBOUNCE_MS = 120;
let gradeReconcileTimer = 0;
const scheduleGradeReconcile = (): void => {
window.clearTimeout(gradeReconcileTimer);
gradeReconcileTimer = window.setTimeout(() => {
void ctx.reconcile().then(() => ctx.view().setGradeEdit(gradeEditFor));
}, GRADE_RECONCILE_DEBOUNCE_MS);
};
const gradeEditFor: GradeEditProvider = () => {
const detail = ctx.detail();
const currentRouteId = ctx.routeId();
if (!detail || currentRouteId === null) return null;
const stored = readAlignment(detail.longitudinal);
if (!stored) return null; // 선형 저장분이 없는 옛 노선 — 편집할 기준선이 없다.
if (!gradeStore || gradeRouteId !== currentRouteId) {
gradeRouteId = currentRouteId;
gradeStore = createProfileEditStore(currentRouteId, stored.edits, () => undefined);
}
const store = gradeStore;
const base = toAlignmentBase(stored);
const alignment = buildAlignment(base, store.edits());
return {
alignment,
stepM: alignment.policy.edit_step_m,
onStation: (chainageM, delta) => {
store.replace(adjustStation(base, store.edits(), chainageM, delta));
// ① 선은 **그 자리에서** 움직인다 — 그래프가 읽는 계획선(`design_profiles`)만
// 갈아 끼우고 다시 그린다(재계산을 기다리면 누른 뒤 한참 뒤에 움직였다).
const current = ctx.detail();
if (current) {
current.longitudinal.design_profiles = [
toDesignProfile(
buildAlignment(base, store.edits()),
current.longitudinal.design_profiles?.[0],
),
];
}
ctx.view().setGradeEdit(gradeEditFor);
// ② 전 측점 횡단 재계산·카드 갱신은 무겁다 — 마지막 한 번만(B05 프리뷰와 같은 규칙).
// 편집분은 세션 초안에 있으므로 재계산이 그것을 그대로 읽는다.
scheduleGradeReconcile();
},
};
};
return gradeEditFor;
}
+28 -5
View File
@@ -18,6 +18,11 @@ import {
} from "./B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "./B06_Section_Section_Store";
import { flushPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { saveProfileAlignment } from "../B05_Profile/B05_Profile_Api_Fetch";
import {
clearAlignmentDrafts,
readAlignmentDraft,
} from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { flushPendingStructures } from "../B05_Profile/B05_Profile_Api_Structures";
import { flushUphillOverrides } from "../B05_Profile/B05_Profile_Api_Fetch";
import { buildCrossPatches, type CrossPatchSources } from "./B06_Section_UI_Page_Patches";
@@ -418,9 +423,26 @@ export function collectSectionEdits(ctx: SectionPersistContext): {
};
}
/** 계획선 편집 초안이 있으면 종단 정본에 쓰고 초안을 지운다. 없으면 아무 일도 하지 않는다. */
async function flushAlignmentDraft(projectId: string, routeId: number | null): Promise<void> {
if (routeId === null) return;
const draft = readAlignmentDraft(routeId);
if (!draft) return;
await saveProfileAlignment(projectId, routeId, draft);
clearAlignmentDrafts();
}
/** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */
async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise<void> {
// 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
// ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도
// 결과가 같아야 한다). 관 목록은 B05 가 **전체 스냅샷**으로, B06 이 **바뀐 것만**(추가·
// 삭제·이동·구간값) 담으므로, 스냅샷을 먼저 얹고 그 위에 델타를 적용해야 한다. 반대로
// 하면 스냅샷이 B06 편집을 통째로 덮는다.
await flushPendingPipes(projectId).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`배수관 저장에 실패했습니다.${detail}`, "error");
});
// 조정창 구간값·추가·삭제·이동은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만).
await ctx.flushCulvertOptions();
// B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만
@@ -431,11 +453,12 @@ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string):
// B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단
// 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다
// (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다).
// B05 배수유역도에서 고친 관 목록(추가·이동·삭제)도 여기서 정본에 남긴다 — 예전에는
// B05 [임시저장]에만 실려, B06 에서 저장하면 그 편집이 사라졌다(2026-09-06 대응표).
await flushPendingPipes(projectId).catch((error) => {
// 계획선 편집(▲/▼)은 세션 초안에만 있다 — B06 에서 고쳤든 B05 에서 고쳤든 여기서
// 종단 정본으로 내보낸다(2026-09-12). 종전에는 B06 이 초안을 **읽기만** 해서, B06 에서
// 저장하면 계획선 편집이 다음 진입 때 사라졌다.
await flushAlignmentDraft(projectId, ctx.routeId()).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
showToast(`배수관 저장에 실패했습니다.${detail}`, "error");
showToast(`계획선 저장에 실패했습니다.${detail}`, "error");
});
await flushPendingStructures(projectId).catch((error) => {
const detail = error instanceof Error ? ` ${error.message}` : "";
@@ -94,8 +94,6 @@ export interface StationControls {
flushCulvertOptions: () => Promise<void>;
/** 관 옵션 조각을 예약한다 — 좌측 폼의 [수정]도 이 경로로 정본에 간다(2026-08-29). */
queueCulvertOptions: (chainageM: number, patch: Record<string, number | string>) => void;
/** 기준 측점 이동을 예약한다 — B06 구조물 배치 폼의 측점 칸이 쓴다(2026-08-29). */
queueCulvertMove: (fromChainageM: number, toChainageM: number) => void;
load: () => void;
/** 전체 반영 — 개별 반폭을 전역값으로 덮는다(없으면 비운다). */
applyGlobalWidth: (requested: number | undefined, chainages: number[]) => void;
@@ -617,8 +615,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
},
flushCulvertOptions: () => culvertOptions.flush(),
queueCulvertOptions: (chainageM, patch) => culvertOptions.queue(chainageM, patch),
queueCulvertMove: (fromChainageM, toChainageM) =>
culvertOptions.queueMove(fromChainageM, toChainageM),
load: () => {
loadStationWidths();
loadRevetShifts();
@@ -24,12 +24,14 @@ import {
type StructureInstance,
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { readPendingPipes, writePendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
readStructurePick,
writeStructurePick,
} from "../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { showToast } from "@ui/ui_template_elements";
import { FORD_BRIDGE_DEFAULT_WIDTH_M } from "@config/config_frontend";
import {
adjustDockRoot,
refreshAdjustSlots,
@@ -39,8 +41,13 @@ import {
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
const PIPE_ADD_GUIDE = "계곡 통과 시설의 추가·삭제는 B05(종단) 화면에서 합니다.";
const PIPE_ADD_GUIDE = "계곡 통과 시설을 넣고 빼려면 프로젝트를 먼저 여세요.";
const PIPE_MOVE_GUIDE = "기준 측점 이동은 배수유역을 다시 나눠야 해 B05(종단) 화면에서 합니다.";
/** B05·B06 은 한 페이지라 넣고 빼는 자리도 같아야 한다(2026-09-12 사용자). 재분할은 저장 뒤. */
const PIPE_ADD_NOTICE =
"계곡 통과 시설을 넣었습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
const PIPE_REMOVE_NOTICE =
"계곡 통과 시설을 뺐습니다 — 세부 배수유역과 횡단도는 [저장]·[확정] 뒤에 다시 계산됩니다.";
/** 고른 것 없이 폼만 만졌을 때 — 값이 어디로도 가지 않으므로 이유를 알린다. */
const PIPE_PICK_GUIDE = "먼저 횡단도나 목록에서 구조물을 고르세요 — 고른 것에만 값이 반영됩니다.";
const PIPE_MOVE_NOTICE =
@@ -72,9 +79,6 @@ export interface B06StructuresPanelDeps {
/** (culvert )
* (2026-08-29 ). */
applyPipeOptions?: (chainageM: number, patch: Record<string, number | string>) => void;
/** []·[]
* (2026-08-29 사용자: B06에서도 ). */
movePipe?: (fromChainageM: number, toChainageM: number) => void;
/** (C) ** **
* (2026-09-06 ). */
onStructuresChanged?: () => void;
@@ -97,6 +101,13 @@ export interface B06StructuresPanel {
showAtChainage: (chainageM: number | null) => void;
/** 지금 폼에 올라온 계곡 통과 시설의 누가거리(없으면 null). */
currentChainage: () => number | null;
/** 종단 그래프 우클릭이 쓰는 길 — 좌측 폼·목록과 **같은 함수**를 탄다(2026-09-12). */
addStructureAt: (chainageM: number, typeId: string) => void;
removeStructureById: (structureId: string) => boolean;
addPipeAt: (chainageM: number) => void;
removePipeAt: (chainageM: number) => void;
movePipeTo: (fromChainageM: number, toChainageM: number) => void;
moveStructureTo: (structureId: string, toChainageM: number) => boolean;
/** 유입구 "구조"에 합쳐진 B06 유입측 형식 — 조정창 값과 맞춘다(2026-08-29 지시 5). */
facility: {
setInletStructure: (value: "auto" | "revet" | "I" | "L" | "U") => void;
@@ -115,105 +126,153 @@ function coversChainage(structure: StructureInstance, chainageM: number): boolea
return Math.abs(structureAnchorM(structure) - chainageM) < PIPE_MATCH_M;
}
type Merged = Record<string, string | number>;
/** 그 종류의 등록부 기본값 `{키: 기본값}` — 스펙 값이 기본값에서 왔는지 가르는 데 씀. */
function registryDefaults(types: StructureType[], typeId: string): Record<string, unknown> {
const options = types.find((type) => type.type_id === typeId)?.options ?? [];
return Object.fromEntries(options.map((option) => [option.key, option.default]));
}
/**
* ** **(`section.ford`)
* ,
* (2026-08-30 사용자: 수량() 1).
* ** , .**
* ·[]
* (2026-09-14 ). .
*/
function keepSpec(
merged: Merged,
stored: Merged | undefined,
key: string,
spec: string | number | null | undefined,
fromDefault: unknown,
): void {
if (spec === null || spec === undefined) return;
if (stored?.[key] !== undefined || String(spec) !== String(fromDefault ?? "")) merged[key] = spec;
}
type WingSpec = {
installed: boolean;
height_m: number | null;
length_m: number | null;
angle_deg: number | null;
};
function keepWings(
merged: Merged,
stored: Merged | undefined,
defaults: Record<string, unknown>,
wings: ReadonlyArray<readonly [WingSpec, "wing_in" | "wing_out"]>,
): void {
for (const [wing, prefix] of wings) {
// 백엔드 `_wing_spec` 과 같은 채움 — 설치는 「없음」이 아니면 있음 · 치수는 기본 0/0/45.
const install = String(defaults[prefix] ?? "") === "없음" ? "없음" : "있음";
keepSpec(merged, stored, prefix, wing.installed ? "있음" : "없음", install);
keepSpec(
merged,
stored,
`${prefix}_height_m`,
wing.height_m,
defaults[`${prefix}_height_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_length_m`,
wing.length_m,
defaults[`${prefix}_length_m`] ?? 0,
);
keepSpec(
merged,
stored,
`${prefix}_angle_deg`,
wing.angle_deg,
defaults[`${prefix}_angle_deg`] ?? 45,
);
}
}
/**
* ** **(`section.ford`) · .
* (2026-08-30 사용자: 수량()
* 1) 1 ** ** ( ).
*/
function withFordSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
ford: NonNullable<SectionDetailResponse["cross_sections"][number]["ford"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
if (ford.pipe_kind) merged.pipe_kind = ford.pipe_kind;
merged.pipe_diameter_mm = Math.round(ford.diameter_m * 1000);
merged.pipe_count = ford.pipe_count;
merged.ford_width_m = ford.span_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "pipe_kind", ford.pipe_kind, defaults.pipe_kind);
const diameter = Math.round(ford.diameter_m * 1000);
keepSpec(merged, options, "pipe_diameter_mm", diameter, defaults.pipe_diameter_mm ?? 1000);
const count = Number(defaults.pipe_count ?? 0);
keepSpec(
merged,
options,
"pipe_count",
ford.pipe_count,
count > 0 ? Math.max(Math.trunc(count), 1) : 1,
);
keepSpec(
merged,
options,
"ford_width_m",
ford.span_m,
defaults.ford_width_m ?? FORD_BRIDGE_DEFAULT_WIDTH_M,
);
keepWings(merged, options, defaults, [
[ford.wing_in, "wing_in"],
[ford.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* BOX암거 ** **(`section.box`)
* (2026-08-30 ). · , ·
* .
*/
/** BOX암거 폼 옵션 — 세월교와 같은 규칙(조작·저장분만). 구체 길이·표고는 조정 채널이라 여기 안 옴. */
function withBoxSpec(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
box: NonNullable<SectionDetailResponse["cross_sections"][number]["box"]>,
): Record<string, string | number> {
const merged: Record<string, string | number> = { ...(options ?? {}) };
merged.body_width_m = box.inner_width_m;
merged.body_height_m = box.inner_height_m;
for (const [wing, prefix] of [
defaults: Record<string, unknown>,
): Merged {
const merged: Merged = { ...(options ?? {}) };
keepSpec(merged, options, "body_width_m", box.inner_width_m, defaults.body_width_m ?? 2);
keepSpec(merged, options, "body_height_m", box.inner_height_m, defaults.body_height_m ?? 2);
keepWings(merged, options, defaults, [
[box.wing_in, "wing_in"],
[box.wing_out, "wing_out"],
] as const) {
merged[prefix] = wing.installed ? "있음" : "없음";
if (wing.height_m !== null) merged[`${prefix}_height_m`] = wing.height_m;
if (wing.length_m !== null) merged[`${prefix}_length_m`] = wing.length_m;
if (wing.angle_deg !== null) merged[`${prefix}_angle_deg`] = wing.angle_deg;
}
]);
return merged;
}
/**
* ** **
* (2026-08-29 ). ,
* ( ·) .
* ** ** (2026-08-29 ).
* (··· ·/· ) ** **
* (2026-09-14 ). .
*/
function withSpecDefaults(
options: Record<string, string | number> | undefined,
options: Merged | undefined,
detail: SectionDetailResponse | null,
chainageM: number,
types: StructureType[],
wallHeight?: (chainageM: number, role: "inlet" | "outlet") => number | null,
wallForm?: (chainageM: number, role: "inlet" | "outlet") => string | null,
): Record<string, string | number> | undefined {
): Merged | undefined {
const owner = detail?.cross_sections.find(
(section) => Math.abs(section.chainage_m - chainageM) < PIPE_MATCH_M,
);
// 세월교는 폼과 조정창이 **같은 캐시**(`section.ford`)를 본다 — 어느 쪽에서 만졌든
// 그 스펙이 지금 그려진 값이라 저장된 옵션보다 앞선다(2026-08-30 사용자: 프론트는
// 구조물 배치 상세 UI, 로직·값은 조정창 것).
if (owner?.ford) return withFordSpec(options, owner.ford);
if (owner?.box) return withBoxSpec(options, owner.box);
// 세월교·BOX는 폼과 조정창이 **같은 캐시**를 봄 — 조작한 값은 저장된 옵션보다 앞섬(2026-08-30).
if (owner?.ford) return withFordSpec(options, owner.ford, registryDefaults(types, "ford_bridge"));
if (owner?.box) return withBoxSpec(options, owner.box, registryDefaults(types, "box_culvert"));
const culvert = owner?.culvert;
if (!culvert) return options;
const merged: Record<string, string | number> = { ...(options ?? {}) };
const put = (key: string, value: string | number | null | undefined): void => {
if (merged[key] === undefined && value !== null && value !== undefined) merged[key] = value;
};
put("pipe_kind", culvert.pipe_kind);
put("pipe_diameter_mm", Math.round(culvert.diameter_m * 1000));
for (const [side, prefix] of [
[culvert.inlet, "inlet"],
[culvert.outlet, "outlet"],
] as const) {
put(`${prefix}_type`, side.structure);
// 형태·높이는 **지금 그려진 값**이 이긴다 — 조정값(조작)이 저장된 옵션보다
// 앞서기 때문이다. 저장값을 그대로 두면 폼과 그림이 갈린다(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다).
const merged: Merged = { ...(options ?? {}) };
for (const prefix of ["inlet", "outlet"] as const) {
// 형태·높이는 **조작한 값**이 이긴다 — 조정값이 저장된 옵션보다 앞서기 때문(2026-08-30 사용자:
// 배관 유출구 기슭막이 형태가 폼과 다르게 그려졌다). 조작이 없으면 부르는 쪽이 null 을 줌.
const drawnForm = wallForm?.(chainageM, prefix);
if (drawnForm) merged[`${prefix}_revet_form`] = drawnForm;
else put(`${prefix}_revet_form`, side.revet_form);
const drawnHeight = wallHeight?.(chainageM, prefix);
if (drawnHeight != null) merged[`${prefix}_revet_height_m`] = Number(drawnHeight.toFixed(1));
else put(`${prefix}_revet_height_m`, side.revet_height_m);
put(`${prefix}_revet_length_m`, side.revet_length_m);
put(`${prefix}_revet_before_m`, side.revet_before_m);
put(`${prefix}_revet_after_m`, side.revet_after_m);
}
put("inlet_basin_length_m", culvert.inlet.basin_length_m);
put("inlet_basin_before_m", culvert.inlet.basin_before_m);
put("inlet_basin_after_m", culvert.inlet.basin_after_m);
return merged;
}
@@ -248,29 +307,39 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
},
getInterval: deps.stationInterval,
onReveal: () => deps.reveal?.(),
onPipeAdd: () => showToast(PIPE_ADD_GUIDE, "error"),
// 값 수정은 B06에서도 받는다 — 조정창 구간값과 같은 저장 경로(캐시 예약 →
// [저장]·[확정])로 보낸다. 기준점 이동만 배수유역 재분할이 걸려 B05 몫이다.
// 관 추가 — 정본은 [저장]·[확정]에서 나간다. 화면 목록·알약은 바로 세운다.
onPipeAdd: (chainageM, attributes) => {
if (!deps.projectId) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = Number(chainageM.toFixed(2));
if (pipeFacilities.some((entry) => Math.abs(entry.chainage_m - at) < PIPE_MATCH_M)) {
showToast("그 자리에는 계곡 통과 시설이 이미 있습니다.", "error");
return;
}
pipeFacilities.push({
chainage_m: at,
facility: attributes.facility ?? "pipe",
source: "user",
options: attributes.options ?? {},
});
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
currentChainageM = at;
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_ADD_NOTICE, "success");
},
// 값 수정·기준점 이동 모두 B06 에서 받는다 — 이동은 종단 끌기와 **같은 함수**를 탄다
// (2026-09-12 일원화). 재분할은 [저장]·[확정] 뒤 서버 몫이다.
onPipeUpdate: (fromChainageM, toChainageM, attributes) => {
// 위치가 "옮겨졌다"고 볼 기준은 관 매칭과 같은 0.51m다 — 폼의 측점 표기는
// 0.1m로 반올림되므로 0.005m 기준으로 보면 값만 고쳐도 이동으로 잡힌다
// (2026-08-29 사용자 보고: 높이만 바꿨는데 이동 안내가 떴다).
const moved = Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M;
if (moved) {
if (deps.movePipe) {
deps.movePipe(fromChainageM, toChainageM);
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.chainage_m = toChainageM;
currentChainageM = toChainageM;
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_MOVE_NOTICE, "success");
} else {
showToast(PIPE_MOVE_GUIDE, "error");
}
}
if (Math.abs(fromChainageM - toChainageM) > PIPE_MATCH_M)
movePipeTo(fromChainageM, toChainageM);
const patch = attributes.options;
if (!patch || !Object.keys(patch).length) return;
if (!deps.queuePipeOptions) {
@@ -287,9 +356,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.options = { ...(hit.options ?? {}), ...patch };
persistPipes();
section.setPipeFacilities(pipeFacilities);
},
onPipeRemove: () => showToast(PIPE_ADD_GUIDE, "error"),
onPipeRemove: (chainageM) => removePipeAt(chainageM),
onPipeSelect: (chainageM) => {
// 목록에서 고른 것도 세션에 남긴다 — B05로 돌아가면 그 시설이 그대로 열린다.
writeStructurePick(deps.projectId, chainageM);
@@ -339,7 +409,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
structures = readPendingStructures(projectId) ?? stored.structures;
section.setStructures(structures);
const detail = deps.detail?.() ?? null;
pipeFacilities = pipeResponse.pipe_points.map((pipe) => ({
// 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며,
// 이래야 B05 에서 넣고 B06 으로 넘어와도 같은 목록이 보인다(2026-09-12 사용자).
const pendingPipes = readPendingPipes(projectId);
pipeFacilities = (pendingPipes ?? pipeResponse.pipe_points).map((pipe) => ({
chainage_m: pipe.chainage_m,
facility: pipe.facility ?? "pipe",
start_m: pipe.start_m,
@@ -350,6 +423,7 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
pipe.options,
detail,
pipe.chainage_m,
types,
deps.wallHeight,
deps.wallForm,
),
@@ -394,17 +468,84 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc
hit.options,
deps.detail?.() ?? null,
hit.chainage_m,
markTypes,
deps.wallHeight,
deps.wallForm,
);
section.setPipeFacilities(pipeFacilities);
}
/**
* ** ** B05 (2026-09-12
* 사용자: 담는 ). B06
* .
*/
function persistPipes(): void {
if (!deps.projectId) return;
writePendingPipes(
deps.projectId,
pipeFacilities.map((pipe) => ({
chainage_m: pipe.chainage_m,
source: pipe.source ?? "user",
facility: pipe.facility,
...(pipe.start_m !== undefined ? { start_m: pipe.start_m } : {}),
...(pipe.end_m !== undefined ? { end_m: pipe.end_m } : {}),
...(pipe.options ? { options: pipe.options } : {}),
})),
);
}
/** 관 옮기기 — 폼의 측점 칸과 종단 끌기가 같은 길을 탄다. 재분할은 [저장]·[확정] 뒤. */
function movePipeTo(fromChainageM: number, toChainageM: number): void {
if (!deps.projectId) {
showToast(PIPE_MOVE_GUIDE, "error");
return;
}
const to = Number(toChainageM.toFixed(2));
const hit = pipeFacilities.find(
(entry) => Math.abs(entry.chainage_m - fromChainageM) < PIPE_MATCH_M,
);
if (hit) hit.chainage_m = to;
if (currentChainageM !== null && Math.abs(currentChainageM - fromChainageM) < PIPE_MATCH_M)
currentChainageM = to;
pipeFacilities.sort((left, right) => left.chainage_m - right.chainage_m);
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
showToast(PIPE_MOVE_NOTICE, "success");
}
/** 관 빼기 — 목록·폼·종단 우클릭이 같은 길을 탄다. 정본은 [저장]·[확정]에서 나간다. */
function removePipeAt(chainageM: number): void {
if (!deps.projectId) {
showToast(PIPE_ADD_GUIDE, "error");
return;
}
const at = Number(chainageM.toFixed(2));
pipeFacilities = pipeFacilities.filter(
(entry) => Math.abs(entry.chainage_m - at) >= PIPE_MATCH_M,
);
if (currentChainageM !== null && Math.abs(currentChainageM - at) < PIPE_MATCH_M)
currentChainageM = null;
persistPipes();
section.setPipeFacilities(pipeFacilities);
pushMarks();
deps.onStructuresChanged?.();
showToast(PIPE_REMOVE_NOTICE, "success");
}
return {
root: section.root,
listRoot: section.listRoot,
load,
currentChainage: () => currentChainageM,
// 종단 우클릭 → 좌측 폼·목록과 같은 함수. 폼이 열리고 목록·알약도 함께 선다.
addStructureAt: (chainageM, typeId) => section.addAt(chainageM, typeId),
removeStructureById: (structureId) => section.removeById(structureId),
addPipeAt: (chainageM) => section.addAt(chainageM, "pipe"),
removePipeAt: (chainageM) => removePipeAt(chainageM),
movePipeTo: (fromChainageM, toChainageM) => movePipeTo(fromChainageM, toChainageM),
moveStructureTo: (structureId, toChainageM) => section.moveById(structureId, toChainageM),
facility: {
setInletStructure: (value) => section.facility.setInletStructure(value),
onInletStructureChange: (handler) => section.facility.onInletStructureChange(handler),
+63 -123
View File
@@ -17,8 +17,6 @@ import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
// 가로 스크롤 고정 Y축 — B05와 같은 오버레이를 쓴다(정의처: B05 MassHaul 모듈 + 그 CSS).
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import type {
CrossSection,
EarthworkConversion,
@@ -45,33 +43,24 @@ import {
effectiveCardHalfWidth,
} from "./B06_Section_UI_Cross_View_Metrics";
import { CROSS_HEIGHT } from "./B06_Section_UI_Section_Common";
import { LONG_PAD } from "./B06_Section_UI_Section_Common";
import { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
import type { SectionStructureEdit } from "./B06_Section_UI_Section_View_Menu";
import {
buildStructureLane,
STRUCTURE_LANE_HEIGHT_PX,
} from "../B05_Profile/B05_Profile_UI_Structures_Marks";
attachWindowScroll,
createElevationHold,
drawLongitudinalPanel,
type LongitudinalPanelInput,
} from "./B06_Section_UI_Section_View_Draw";
import {
structureAnchorM,
type StructureInstance,
type StructureType,
} from "../B05_Profile/B05_Profile_Api_Structures";
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal";
import {
buildLongitudinalChart,
visibleChainageRange,
visibleElevationRange,
} from "./B06_Section_UI_Section_View_Chart";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
import { badgeValuesFrom, createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import {
applyElevationWindow,
needsFullRedraw,
Y_AXIS_WINDOW_CLASS,
type ElevationWindowResult,
} from "@util/common_util_chart_ywindow";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { type ElevationWindowResult } from "@util/common_util_chart_ywindow";
import {
BASE_PANEL_HEIGHT,
chartHeights,
@@ -118,6 +107,8 @@ export interface SectionViewController {
) => void;
/** 측점 하나의 카드만 새로 만들어 교체한다 (전체 재렌더 없이 설계 변경 반영). */
refreshCard: (chainageM: number) => void;
/** 여러 측점을 한꺼번에 교체한다 — 상단 패널은 **마지막에 한 번만** 다시 그린다. */
refreshCards: (chainages: ReadonlyArray<number>) => void;
/**
* (2026-08-29 B05/B06 일원화: 목록 ·). */
focusStation: (stationId: string) => void;
@@ -131,6 +122,10 @@ export interface SectionViewController {
structures: ReadonlyArray<StructureInstance>,
types: ReadonlyArray<StructureType>,
) => void;
/** 종단 그래프 우클릭으로 구조물을 넣고 빼는 길(2026-09-12 B05·B06 일원화). */
setStructureEdit: (edit: SectionStructureEdit | null) => void;
/** 계획선 편집 ▲/▼ — 그릴 때마다 불러 선형·편집 함수를 받는다(null = 버튼 없음). */
setGradeEdit: (provider: (() => LongitudinalPanelInput["grade"]) | null) => void;
dispose: () => void;
}
@@ -163,6 +158,8 @@ export function createSectionView(
let currentNaturalSpoilSlope: number | undefined;
let markStructures: ReadonlyArray<StructureInstance> = [];
let markTypes: ReadonlyArray<StructureType> = [];
let structureEdit: SectionStructureEdit | null = null;
let gradeEdit: (() => LongitudinalPanelInput["grade"]) | null = null;
let renderWidth = 0;
let resizeTimer = 0;
let panelResizeTimer = 0;
@@ -504,94 +501,25 @@ export function createSectionView(
const laneHeight = markStructures.length && markTypes.length ? STRUCTURE_LANE_HEIGHT_PX : 0;
const heights = chartHeights(Math.max(lastChartAvailable - laneHeight, MIN_LONG_HEIGHT));
const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval);
const chartWidth = Math.max(renderWidth, minWidth);
const chart = buildLongitudinalChart({
updateChartWindow = drawLongitudinalPanel({
detail,
chartWrap,
renderWidth: Math.max(renderWidth, minWidth),
chartHeight: heights.long,
keepScrollLeft,
selectedStationId,
exaggeration: currentExaggeration,
selectStation: (stationId) => selectStation(stationId, true),
stationInterval: cachedStationInterval,
chartWidth,
chartHeight: heights.long,
minWidth,
scrollLeft: keepScrollLeft,
viewportWidth: chartWrap.clientWidth || chartWidth,
structures: markStructures,
types: markTypes,
edit: structureEdit,
conversion: currentConversion,
naturalSpoilSlope: currentNaturalSpoilSlope,
selectStation: (stationId) => selectStation(stationId, true),
setMassBadge: (values) => massBadge.set(values),
grade: gradeEdit?.() ?? null,
holdRange,
});
const longAxis = chart.axis;
const nodes: Element[] = [chart.node];
// 구조물 알약 레인 — B05 종단과 **같은 부품**(`buildStructureLane`)을 그대로 쓴다.
// 배수관·세월교도 여기 같은 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일).
if (markStructures.length && markTypes.length) {
nodes.push(
buildStructureLane({
structures: markStructures,
types: markTypes,
x: chart.toX,
chainageAt: chart.toChainage,
maxChainageM: chart.maxChainageM,
widthPx: chartWidth,
axisWidthPx: LONG_PAD.left,
stationIntervalM: cachedStationInterval,
selectedId: null,
// B06 에서 알약을 누르면 그 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
onSelect: (structureId) => {
if (!structureId) return;
const hit = markStructures.find((entry) => entry.structure_id === structureId);
if (!hit) return;
const at = structureAnchorM(hit);
// 그 자리에 가장 가까운 측점 카드를 고른다 — 좌측 목록 클릭과 같은 규칙.
let best: { id: string; gap: number } | null = null;
for (const section of currentDetail?.cross_sections ?? []) {
const gap = Math.abs(section.chainage_m - at);
if (!best || gap < best.gap) best = { id: section.station_id, gap };
}
if (best) selectStation(best.id, true);
},
// 알약 끌기는 B05 몫이다(배수유역 재분할이 걸린다) — 여기서는 자리만 보여 준다.
onMove: () => undefined,
}),
);
}
chartWrap.replaceChildren(...nodes);
// 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이
// 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서
// 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단).
if (currentConversion) {
const series = computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
currentConversion,
currentNaturalSpoilSlope,
);
const cross = series.find((entry) => entry.basis === "cross") ?? series[0];
massBadge.set(cross ? badgeValuesFrom(cross.result) : null);
} else {
massBadge.set(null);
}
// 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다).
if (longAxis) {
const overlay = buildStickyYAxis(longAxis, heights.long);
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
overlay.classList.add(Y_AXIS_WINDOW_CLASS);
chartWrap.prepend(overlay);
}
// 세로 창 갱신기 — 스크롤마다 종단은 변환으로, 유토곡선은 곡선만 다시 그려 따라온다.
updateChartWindow = () => {
const { fromM, toM } = visibleChainageRange(
chart.toChainage,
chart.maxChainageM,
chartWrap.scrollLeft,
chartWrap.clientWidth || chartWidth,
);
return applyElevationWindow(
chartWrap,
visibleElevationRange(detail, fromM, toM) ?? undefined,
);
};
chartWrap.scrollLeft = keepScrollLeft;
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
syncScrollMargin();
@@ -608,22 +536,8 @@ export function createSectionView(
}
}
/** 상단 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
let settledScrollLeft = 0;
let scrollSettleTimer = 0;
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정, B05와 같은 규칙) —
// 스크롤마다 겹 하나의 변환만 갈아 끼우므로 실시간으로 따라온다.
chartWrap.addEventListener("scroll", () => {
const fitted = updateChartWindow?.() ?? null;
window.clearTimeout(scrollSettleTimer);
if (!needsFullRedraw(fitted)) return;
scrollSettleTimer = window.setTimeout(() => {
if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = chartWrap.scrollLeft;
drawPanel();
}, SCROLL_SETTLE_MS);
});
attachWindowScroll(chartWrap, () => updateChartWindow, drawPanel);
const holdRange = createElevationHold(chartWrap, drawPanel);
const draw = (): void => {
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
@@ -689,18 +603,35 @@ export function createSectionView(
chartWrap.scrollLeft = keepScrollLeft;
};
const refreshCard = (chainageM: number): void => {
if (!currentDetail) return;
/** 카드 한 장만 갈아 끼운다(상단 패널은 안 건드린다). 못 찾으면 false. */
const rebuildCard = (chainageM: number): boolean => {
if (!currentDetail) return false;
const section = currentDetail.cross_sections.find(
(candidate) => Math.abs(candidate.chainage_m - chainageM) < 0.01,
);
if (!section) return;
if (!section) return false;
const existing = document.getElementById(`cross-${section.station_id}`);
// 단건 갱신은 draw에서 정해둔 행 높이를 재사용해 같은 행 카드와 높이를 유지한다.
if (existing)
existing.replaceWith(buildCrossCard(section, cachedRowHeight.get(section.station_id)));
return true;
};
const refreshCard = (chainageM: number): void => {
// 단면적이 바뀌면 유토곡선도 함께 흔들리므로 상단 패널만 다시 그린다(카드 전체 재렌더 없음).
drawPanel();
if (rebuildCard(chainageM)) drawPanel();
};
/**
* ** ** .
*
* `refreshCard` · ,
* ms (2026-09-12 B05 ).
*/
const refreshCards = (chainages: ReadonlyArray<number>): void => {
let touched = false;
for (const chainageM of chainages) if (rebuildCard(chainageM)) touched = true;
if (touched) drawPanel();
};
const resizeObserver = new ResizeObserver(() => {
@@ -746,6 +677,7 @@ export function createSectionView(
if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root));
},
refreshCard,
refreshCards,
focusStation(stationId) {
if (selectedStationId !== stationId) selectStation(stationId, true);
else revealCard(stationId, "smooth");
@@ -753,6 +685,14 @@ export function createSectionView(
setStationSelectListener(listener) {
stationSelectListener = listener;
},
setStructureEdit(edit) {
structureEdit = edit;
drawPanel();
},
setGradeEdit(provider) {
gradeEdit = provider;
drawPanel();
},
setStructureMarks(structures, types) {
markStructures = structures;
markTypes = types;
@@ -30,6 +30,15 @@ export interface LongitudinalChartInput {
/** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */
scrollLeft: number;
viewportWidth: number;
/** 구조물(비정규) 측점선을 끌어 옮겼다. 안 넘기면 그 선은 못 잡는다. */
onDragStation?: (stationId: string, toChainageM: number) => void;
/** X축·측점 라벨을 바닥에서 이만큼(px) 올린다 — 계획고 편집 ▼ 버튼과 겹치지 않게. */
bottomInsetPx?: number;
/** **** .
* ( ). */
holdRange?: (
next: { min: number; max: number } | null,
) => { min: number; max: number } | undefined;
}
export interface LongitudinalChartResult {
@@ -110,10 +119,13 @@ export function buildLongitudinalChart(input: LongitudinalChartInput): Longitudi
axis = next;
},
undefined,
undefined,
// 구조물(비정규) 측점선 끌어 옮기기 — B05 와 같은 조작이다(2026-09-12 일원화).
input.onDragStation,
input.bottomInsetPx ?? 0,
0,
0,
visibleElevationRange(detail, fromM, toM) ?? undefined,
input.holdRange
? input.holdRange(visibleElevationRange(detail, fromM, toM))
: (visibleElevationRange(detail, fromM, toM) ?? undefined),
),
);
return { node, axis, toChainage, toX, maxChainageM, viewFromM: fromM, viewToM: toM };
@@ -0,0 +1,282 @@
/* =============================================================================
* B06_Section_UI_Section_View_Draw.ts
* B06 ** ** · · ·
* · Y축, .
*
* (`_Section_View`) 700 (CLAUDE.md 4)
* . ** ** , · ·
* . .
* ========================================================================== */
import type { EarthworkConversion, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
import { buildStructureLane } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
import { computeMassHaulSeries } from "@util/common_util_mass_haul";
import { badgeValuesFrom } from "@util/common_util_mass_haul_badge";
import {
applyElevationWindow,
needsFullRedraw,
Y_AXIS_WINDOW_CLASS,
type ElevationWindowResult,
} from "@util/common_util_chart_ywindow";
import { LONG_PAD } from "./B06_Section_UI_Section_Common";
import {
buildLongitudinalChart,
visibleChainageRange,
visibleElevationRange,
} from "./B06_Section_UI_Section_View_Chart";
import {
mountSectionStructureMenu,
moveMarkById,
type SectionStructureEdit,
} from "./B06_Section_UI_Section_View_Menu";
import { createEditOverlay } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
/** 그래프 한 벌을 세우는 데 필요한 값 — 본체가 재고 고른 것을 그대로 넘긴다. */
export interface LongitudinalPanelInput {
detail: SectionDetailResponse;
/** 그래프가 들어앉는 칸. 내용은 이 함수가 통째로 갈아 끼운다. */
chartWrap: HTMLElement;
/** 본체가 잰 그릴 폭·높이. */
renderWidth: number;
chartHeight: number;
/** 갈아 끼우기 전 가로 스크롤 자리 — 되돌려 놓는다. */
keepScrollLeft: number;
selectedStationId: string | null;
exaggeration: number;
stationInterval: number;
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
/** 구조물을 넣고 빼고 옮기는 길(없으면 우클릭·끌기를 안 붙인다). */
edit: SectionStructureEdit | null;
conversion?: EarthworkConversion;
naturalSpoilSlope?: number;
selectStation: (stationId: string) => void;
/** 세로 창 가로채기 — 계획고를 만지는 동안 창을 고정한다(B05 와 같은 장치). */
holdRange?: (
next: { min: number; max: number } | null,
) => { min: number; max: number } | undefined;
/** 계획선 편집 — 넘기면 종단 그래프 위에 ▲/ (B05 ).
* ( ). */
grade?: {
alignment: ProfileAlignment;
/** 한 번 누를 때 오르내리는 양(m) — 선형 정책값. */
stepM: number;
onStation: (chainageM: number, delta: number) => void;
} | null;
/** 좌측 상단 누가토량 배지 — 값이 없으면 null 로 지운다. */
setMassBadge: (values: ReturnType<typeof badgeValuesFrom> | null) => void;
}
/** 알약 레인이 설 높이(px) — 본체가 그릴 높이를 셈할 때 같은 값을 써야 한다. */
export { STRUCTURE_LANE_HEIGHT_PX } from "../B05_Profile/B05_Profile_UI_Structures_Marks";
/**
* ** ** .
* .
*/
export function drawLongitudinalPanel(
input: LongitudinalPanelInput,
): () => ElevationWindowResult | null {
const { detail, chartWrap, structures, types, edit } = input;
const chartWidth = input.renderWidth;
/** 측점선·알약을 끌어 놓았을 때 — 관인지 구조물인지는 공용 판별이 가른다. */
const moveMark = (markId: string, toChainageM: number): void => {
if (!edit) return;
moveMarkById(markId, toChainageM, {
structures,
stations: detail.longitudinal.stations ?? [],
edit,
});
};
const chart = buildLongitudinalChart({
detail,
selectedStationId: input.selectedStationId,
exaggeration: input.exaggeration,
selectStation: input.selectStation,
stationInterval: input.stationInterval,
chartWidth,
chartHeight: input.chartHeight,
minWidth: chartWidth,
scrollLeft: input.keepScrollLeft,
viewportWidth: chartWrap.clientWidth || chartWidth,
// 계획고 편집 ▼ 버튼이 바닥에 붙으므로 X축·측점 라벨을 그만큼 밀어 올린다
// (B05 와 같은 값 15px). 버튼이 없으면 0 — 종전 여백 그대로다.
bottomInsetPx: input.grade ? 15 : 0,
holdRange: input.holdRange,
// 측점선을 끌면 그 구조물이 옮겨 간다 — 관은 예약 이동, 구조물은 정본 이동.
onDragStation: edit ? moveMark : undefined,
});
const nodes: Element[] = [chart.node];
// 구조물 알약 레인 — B05 종단과 **같은 부품**을 그대로 쓴다. 배수관·세월교도 같은
// 알약으로 선다(2026-09-07 사용자 지시 4 표시 통일).
if (structures.length && types.length) {
nodes.push(
buildStructureLane({
structures,
types,
x: chart.toX,
chainageAt: chart.toChainage,
maxChainageM: chart.maxChainageM,
widthPx: chartWidth,
axisWidthPx: LONG_PAD.left,
stationIntervalM: input.stationInterval,
selectedId: null,
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
onSelect: (structureId) => {
if (!structureId) return;
const hit = structures.find((entry) => entry.structure_id === structureId);
if (!hit) return;
const at = structureAnchorM(hit);
let best: { id: string; gap: number } | null = null;
for (const section of detail.cross_sections) {
const gap = Math.abs(section.chainage_m - at);
if (!best || gap < best.gap) best = { id: section.station_id, gap };
}
if (best) input.selectStation(best.id);
},
// 알약 끌기도 B05 와 같게 받는다(2026-09-12 일원화) — 정본은 [저장]·[확정]에서.
onMove: moveMark,
}),
);
}
chartWrap.replaceChildren(...nodes);
// 계획선 편집 버튼층 — B05 와 **같은 부품**(`createEditOverlay`)이다(2026-09-12 사용자:
// B05·B06 은 한 페이지인데 B06 에만 버튼이 없었다). 누른 값은 B05 와 같은 세션 초안에
// 쌓이고 [저장]·[확정]에서 종단 정본으로 나간다.
if (input.grade) {
const editLayer = createEditOverlay({
alignment: input.grade.alignment,
width: chartWidth,
x: chart.toX,
step: input.grade.stepM,
onStation: input.grade.onStation,
});
// 버튼층은 `inset: 0` 으로 부모를 꽉 채운다 — B05 는 부모가 그래프뿐이지만 B06 은
// **알약 레인도 같은 칸 안**에 있어, 그대로 두면 ▼ 가 레인 위로 밀려난다(실측:
// 그래프 바닥 231px 인데 버튼이 270px). 그래프 높이로 잘라 B05 와 같은 자리에 세운다.
editLayer.style.height = `${input.chartHeight}px`;
editLayer.style.bottom = "auto";
chartWrap.append(editLayer);
}
// 종단 그래프 우클릭 — B05 와 같은 메뉴다(가까운 구조물이 있으면 삭제, 없으면 구조물군
// → 종류 2단 추가). 그래프를 다시 그릴 때마다 붙인다(상태를 안 남긴다).
if (edit) {
mountSectionStructureMenu(chartWrap, {
structures,
types,
x: chart.toX,
chainageAt: chart.toChainage,
maxChainageM: chart.maxChainageM,
edit,
});
}
// 유토곡선 그래프는 B06 에서 **그리지 않는다**(2026-09-06 사용자 지시) — 자리를 많이
// 먹는데 정작 필요한 값은 마지막 지점 누가토량 하나다. 곡선은 B05 유토곡선 패널에서
// 펼쳐 본다. 여기서는 같은 계산으로 값만 내 좌측 상단 배지에 올린다(기준: 횡단).
if (input.conversion) {
const series = computeMassHaulSeries(
detail.longitudinal,
detail.cross_sections,
input.conversion,
input.naturalSpoilSlope,
);
const cross = series.find((entry) => entry.basis === "cross") ?? series[0];
input.setMassBadge(cross ? badgeValuesFrom(cross.result) : null);
} else {
input.setMassBadge(null);
}
// 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다).
if (chart.axis) {
const overlay = buildStickyYAxis(chart.axis, input.chartHeight);
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
overlay.classList.add(Y_AXIS_WINDOW_CLASS);
chartWrap.prepend(overlay);
}
chartWrap.scrollLeft = input.keepScrollLeft;
// 세로 창 갱신기 — 스크롤마다 종단을 변환으로 따라오게 한다(다시 그리지 않는다).
return () => {
const { fromM, toM } = visibleChainageRange(
chart.toChainage,
chart.maxChainageM,
chartWrap.scrollLeft,
chartWrap.clientWidth || chartWidth,
);
const next = visibleElevationRange(detail, fromM, toM);
return applyElevationWindow(
chartWrap,
input.holdRange ? input.holdRange(next) : (next ?? undefined),
);
};
}
/** 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
const SCROLL_SETTLE_MS = 80;
/**
* ** ** ( ).
*
* ****(2026-09-04 , B05 ).
* .
*/
export function attachWindowScroll(
chartWrap: HTMLElement,
updater: () => (() => ElevationWindowResult | null) | null,
redraw: () => void,
): void {
let settledScrollLeft = 0;
let timer = 0;
chartWrap.addEventListener("scroll", () => {
const fitted = updater()?.() ?? null;
window.clearTimeout(timer);
if (!needsFullRedraw(fitted)) return;
timer = window.setTimeout(() => {
if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return;
settledScrollLeft = chartWrap.scrollLeft;
redraw();
}, SCROLL_SETTLE_MS);
});
}
/**
* ** **(B05 ).
*
* ,
* (2026-09-12 : ).
* .
*/
export function createElevationHold(
chartWrap: HTMLElement,
redraw: () => void,
): (next: { min: number; max: number } | null) => { min: number; max: number } | undefined {
let editing = false;
let held: { min: number; max: number } | undefined;
chartWrap.addEventListener("pointerdown", (event) => {
if (!(event.target as HTMLElement).closest(".b05-profile-edit__btn")) return;
editing = true;
const release = (): void => {
editing = false;
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
redraw();
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
});
return (next) => {
if (editing) return held;
held = next ?? undefined;
return held;
};
}
@@ -0,0 +1,130 @@
/* =============================================================================
* B06_Section_UI_Section_View_Menu.ts
* B06 ** ** B05 (`mountStructureMenu`) .
*
* (2026-09-12 ) B05·B06 .
* .
*
* ·· ** ** []·[]
* (`pipe_points.json`) (CLAUDE.md 5).
* .
* ========================================================================== */
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations";
import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */
export interface SectionStructureEdit {
/** 관(계곡 통과 시설)을 그 자리에 넣는다. */
addPipe: (chainageM: number) => void;
/** 관을 뺀다. */
removePipe: (chainageM: number) => void;
/** 레지스트리 종류를 골라 구조물을 넣는다(좌측 「구조물 배치」와 같은 2단 메뉴). */
addStructureType: (chainageM: number, typeId: string) => void;
/** 구조물(관 아님)을 뺀다. */
removeStructure: (structureId: string) => void;
/** 관을 다른 측점으로 옮긴다(측점선·알약 끌기). */
movePipe: (fromChainageM: number, toChainageM: number) => void;
/** 구조물(관 아님)을 다른 측점으로 옮긴다. */
moveStructure: (structureId: string, toChainageM: number) => void;
}
/**
* · id .
* id ** id** ( id) .
* () .
*/
export function moveMarkById(
markId: string,
toChainageM: number,
input: {
structures: ReadonlyArray<StructureInstance>;
stations: ReadonlyArray<{ station_id: string; chainage_m: number }>;
edit: SectionStructureEdit;
},
): void {
const direct = input.structures.find((entry) => String(entry.structure_id) === markId);
if (direct) {
moveStructureMark(input.structures, input.edit, markId, toChainageM);
return;
}
const station = input.stations.find((entry) => String(entry.station_id) === markId);
if (!station) return;
// 측점선 id 로 온 경우 — 그 측점 자리에 선 구조물을 찾는다(관 매칭과 같은 0.51m).
const near = input.structures.find(
(entry) => Math.abs(structureAnchorM(entry) - station.chainage_m) < 0.51,
);
if (near) moveStructureMark(input.structures, input.edit, String(near.structure_id), toChainageM);
}
/** 측점선·알약을 끌었을 때 — 관인지 구조물인지 갈라 같은 예약 경로로 보낸다. */
function moveStructureMark(
structures: ReadonlyArray<StructureInstance>,
edit: SectionStructureEdit,
structureId: string,
toChainageM: number,
): void {
const hit = structures.find((entry) => String(entry.structure_id) === structureId);
if (!hit) return;
const from = structureAnchorM(hit);
if (Math.abs(from - toChainageM) < 0.005) return;
if (isPipeMark(hit)) edit.movePipe(from, toChainageM);
else edit.moveStructure(structureId, toChainageM);
}
/** 알약·우클릭이 쓰는 「관인가」 판정 — 관은 정본이 달라 지우는 길도 다르다. */
export function isPipeMark(structure: StructureInstance): boolean {
return String(structure.structure_id ?? "").startsWith("pipe-");
}
/**
* .
* (B05 ).
*/
export function mountSectionStructureMenu(
host: HTMLElement,
input: {
structures: ReadonlyArray<StructureInstance>;
types: ReadonlyArray<StructureType>;
x: (chainageM: number) => number;
chainageAt: (px: number) => number;
maxChainageM: number;
edit: SectionStructureEdit;
},
): void {
// 메뉴가 쓰는 최소 정보만 옮겨 담는다 — B06 에는 B05 의 비정규 측점 목록이 없다.
const stations: IrregularStation[] = input.structures.map((structure) => {
const chainage = structureAnchorM(structure);
const pipe = isPipeMark(structure);
return {
id: String(structure.structure_id),
station: 0,
remainder: 0,
chainage_m: chainage,
structure:
input.types.find((type) => type.type_id === structure.type_id)?.name ??
String(structure.type_id),
origin: pipe ? "pipe" : "user",
} as IrregularStation;
});
mountStructureMenu(host, {
stations,
x: input.x,
chainageAt: input.chainageAt,
maxChainageM: input.maxChainageM,
onRemove: (station) => {
if (station.origin === "pipe") input.edit.removePipe(station.chainage_m);
else input.edit.removeStructure(station.id);
},
onAddPipe: (chainageM) => input.edit.addPipe(chainageM),
structureTypes: input.types.map((type) => ({
type_id: type.type_id,
group: type.group,
name: type.name,
})),
onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId),
});
}
@@ -152,6 +152,14 @@
stroke-width: 1.2;
}
/* 노견 바깥 (2026-09-12) 차도 틱보다 짧고 옅다. 차도 틱과 사이가 노견이라,
노면이 넓어졌을 차도가 늘었는지 노견이 늘었는지 눈으로 바로 갈린다. */
.b06-chart__shoulder-tick {
stroke: var(--color-royal-amethyst);
stroke-width: 1;
opacity: 0.55;
}
/* 노폭 라벨(2026-09-06) — 차도 위 가운데. 확폭이 걸린 측점을 눈으로 가려내는 표기다. */
.b06-chart__carriageway-label {
fill: var(--color-royal-amethyst);
@@ -4,11 +4,14 @@
* 넘칠 만큼 길어지면 안에서 스크롤한다. z-index는 카드 오버레이 최상단. */
.b06-structure-panel {
position: absolute;
top: var(--spacing-8);
/* 좌측 상단에는 ·성토 면적표가 이미 있다 조정창을 같은 자리에 두면 표를 덮어
수치가 보인다(2026-09-12 사용자). 높이(머리글+절토+성토 )만큼 내려 시작한다.
아래를 기준으로 붙이지 않는 까닭은 종전 그대로다 낮은 횡단도에서 창이 잘린다. */
top: calc(var(--spacing-8) + var(--b06-areas-height, 3.6rem));
left: var(--spacing-8);
z-index: 20;
display: flex;
max-height: calc(100% - 2 * var(--spacing-8));
max-height: calc(100% - 2 * var(--spacing-8) - var(--b06-areas-height, 3.6rem));
flex-direction: column;
gap: 2px;
padding: var(--spacing-8);
+2 -48
View File
@@ -15,7 +15,6 @@ export interface DesignDrawingItem {
| "landuse"
| "plan_lidar"
| "cross_standard"
| "standard"
| "blank";
label: string;
chainage_m: number | null;
@@ -103,7 +102,6 @@ export interface DesignDrawingResponse {
| "landuse"
| "plan_lidar"
| "cross_standard"
| "standard"
| "blank";
label: string;
drawing: CadDrawing;
@@ -246,49 +244,5 @@ export function resetFrameTemplate(projectId: string): Promise<void> {
});
}
/** 표준도 장 목록 — 제원 입력 칸이 쓰는 것만 추린 꼴. */
export interface StandardSheetsResponse {
status: string;
sheet_count: number;
structure_count: number;
sheets: {
key: string;
title: string;
type_id: string;
member_count: number;
options: Record<string, unknown>;
}[];
}
export function fetchStandardSheets(projectId: string): Promise<StandardSheetsResponse> {
return requestJson(`/projects/${projectId}/standard-sheets`);
}
/** 장 하나의 제원 저장 — 빈 값(null)은 **그 칸을 지우라**는 뜻이다. */
export function putStandardSheetSpec(
projectId: string,
body: {
sheet_key: string;
base_revision: number;
stone_kind: string | null;
stone_supply: string | null;
back_len_cm: string | null;
face_slope_ratio: string | null;
foundation: string | null;
stone_coeff_basis: string | null;
fill_concrete_mpa: string | null;
thickness_top_m: string | null;
thickness_bottom_m: string | null;
},
): Promise<{ status: string; revision: number; changed: number; notes: string[] }> {
return requestJson(`/projects/${projectId}/standard-sheets/spec`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
/** 구조물 정본 판번호 — 제원을 저장할 때 함께 보내야 다른 창 덮어쓰기를 막는다. */
export function fetchStructureRevision(projectId: string): Promise<{ revision: number }> {
return requestJson(`/projects/${projectId}/route/structures`);
}
// 표준도(구조물도) 장 목록·제원 저장 창구는 2026-09-13 B08 「구조물도」 탭으로 이관
// (`B08_Quantity_UI_StructureSheet.ts`).
@@ -1,308 +0,0 @@
"""표준도 **위쪽 그림** — 돌쌓기 단면(2단계, 2026-09-09).
실무 원본 탭이 치수조서 + 아래 수량산출서인데, 아래 표는 1단계에서 섰고 여기가 위다.
**기울기를 값으로 받는다 0.3 박지 않는다.**
판정은 **B08 `face_slope_ratio()` ** 그대로 부른다(품셈 13-4-4 [] 표준경사
직고·메찰·성절토). 사용자가 정했으면 값이 이긴다(확정 ). 그림은 **수량이 바로
**으로 기울고, 근거 문구도 같이 받아 그림에 적는다 판정을 벌로 짜면 그림과
수량이 갈린다.
**수량이 쓰는 상수로 그린다** `B08_Quantity_Engine_UnitQuantity.STONE_MASONRY`
직접 읽는다. 치수를 여기서 다시 적으면 **그림과 표가 갈린다**(CLAUDE.md 5).
상부 두께 = 뒷길이 + 0.30 하부 두께 = 상부 + 0.30 × (H 1.0)
터파기 = 평균두께 + 0.2 기초 0.5×0.9 (기초유) · 0.1×0.7 (기초버림)
터파기 치수는 **`common_util_excavation` ** 읽는다 횡단도가 쓰는 상수다.
여기서 다시 적으면 도면이 다른 터파기를 그린다.
**뒷길이가 두께에 들어간다**(확정 2 , 실무 구조물도 ). 뒷길이가 다르면 벽이
두꺼워지고 그림도 그만큼 넓어진다 예전 (0.45+0.10H / 0.45+0.40H) 뒷길이를
아예 봐서 35 45 같은 그림이 나왔다.
뒷길이를 정한 장은 두께를 낸다 그때는 **그리지 않는다**(0 으로 때우면
거짓 그림이 된다).
**치수가 없는 것은 그리되 치수를 적지 않는다** 막자갈(뒷채움) 우리 식이 입적에서
몸통·고임돌을 이라 ·높이로 정의된 도형이 아니다. 자리만 보이고 값은 표를 가리킨다.
없는 치수를 그림에 적으면 **표와 다른 번째 정본** 생긴다.
"""
from __future__ import annotations
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
_line_entity,
_text_entity,
polyline_entity,
)
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import STONE_MASONRY, face_slope_ratio
from common_util.common_util_excavation import (
WALL_BLINDING_DEPTH_M,
WALL_BLINDING_WIDTH_M,
WALL_FOUNDATION_DEPTH_M,
WALL_FOUNDATION_WIDTH_M,
WALL_TRENCH_CLEARANCE_M,
)
#: 그림 축척 — 1/25(1m = 40㎜). 표(줄 높이 9㎜) 위에 얹어도 한 면에 드는 크기다.
SCALE_MM_PER_M = 40.0
#: 이 그림을 그리는 종류. 옹벽·집수정처럼 단면이 다른 것은 아직 그리지 않는다.
#: ⚠ **큰돌쌓기(`boulder_masonry`)를 뺐다**(2026-09-09). 여기 있었지만 그림이 두께를
#: **뒷길이**에서 내는데 큰돌쌓기는 규격 축이 **직경**(`stone_cm`)이라 **영영 못 그렸다** —
#: 목록에만 있고 결과는 늘 빈 그림이었다. 품셈 **13-6(직경) ↔ 13-4(뒷길이)** 로 축이
#: 갈리는 자리이고 수량 쪽에서 이미 갈라 둔 그것이다(`EXPANDERS` 주석).
#: ⇒ 「직경에서 두께를 내는 식」은 **도메인 판단이라 사용자 몫** — 계획서 4-12 답 대기 줄.
FIGURE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry"})
#: **전면 기울기 판정** 대상 — 그림 대상과 **다르다**. 큰돌쌓기는 그림은 못 그려도
#: 기울기는 판정된다(품셈 13-6 「1:0.3 이상」). 한 목록으로 묶어 두었더니 큰돌쌓기를
#: 그림에서 뺄 때 **장 제목의 「1:0.3」까지 사라졌다**(2026-09-09 화면 실측에서 잡음).
SLOPE_TYPE_IDS: frozenset[str] = frozenset({"masonry_wet", "masonry_dry", "boulder_masonry"})
#: 그림이 못 서는 장에 **까닭을 적는다** — 빈 자리를 그냥 두면 사용자가 「고장」으로 읽는다
#: (2026-09-09 사용자 지시). 「무엇을 받아야 서는지」까지 적는다.
_NO_FIGURE_REASONS: dict[str, str] = {
"boulder_masonry": (
"그림 없음 — 큰돌쌓기는 규격이 **직경**이라 두께를 낼 식이 아직 없습니다"
"(뒷길이로 내는 돌쌓기 식과 축이 다름 · 사용자 확정 대기)"
),
"retaining_wall": "그림 없음 — 옹벽은 단면이 달라 아직 그리지 않습니다(표준도 3단계)",
}
_NO_FIGURE_DEFAULT = "그림 없음 — 이 형식은 아직 단면을 그리지 않습니다"
def figure_reason(sheet: dict[str, Any]) -> str | None:
"""그림이 **안 서는 까닭** 한 줄. 서는 장이면 `None`.
무엇을 받아야 서는지까지 적는다 뒷길이만 고르면 서는 장과, 자체가 없는 장은
사용자가 일이 다르다.
"""
type_id = str(sheet.get("type_id") or "")
if type_id not in FIGURE_TYPE_IDS:
return _NO_FIGURE_REASONS.get(type_id, _NO_FIGURE_DEFAULT)
if float(sheet.get("height_m") or 0.0) <= 0:
return "그림 없음 — 높이가 없습니다(제원에서 높이를 넣으면 그림이 섭니다)"
if back_length_cm(sheet) is None:
return "그림 없음 — **뒷길이를 고르면 그림이 섭니다**(두께가 뒷길이에서 나옵니다)"
return None
_LABEL_FONT = 4.0
_DIM_FONT = 3.4
def slope_of(sheet: dict[str, Any]) -> tuple[float, str]:
"""장 하나의 전면 기울기와 근거 문구 — **판정은 B08 한 벌**을 그대로 쓴다.
/찰은 종류에서 온다. 큰돌쌓기는 `bond` (메쌓기/찰쌓기) 그것이고, 고르면
찰쌓기로 본다 품셈 13-6 1:0.3 **이상**이라 그림 기울기가 갈리지 않는다.
"""
options = sheet.get("options") or {}
type_id = str(sheet.get("type_id") or "")
if type_id == "masonry_dry":
wet = False
elif type_id == "boulder_masonry":
wet = options.get("bond") != "메쌓기"
else:
wet = True
return face_slope_ratio(
options,
wet=wet,
height_m=float(sheet.get("height_m") or 0.0),
# ⚠ 표를 만든 그 판정을 그대로 넘긴다 — 여기서 다시 가르면 근거를 못 받아
# 종전값으로 떨어지고 **표와 갈린다**(2026-09-09 실측).
face=sheet.get("face"),
face_reason=str(sheet.get("face_reason") or ""),
)
def wall_thickness(height_m: float, back_cm: float) -> tuple[float, float]:
"""(상부, 하부) 두께 — **수량이 쓰는 그 식**(`stone_masonry`)과 같은 상수를 읽는다."""
top_t = back_cm / 100.0 + STONE_MASONRY["thickness_top_add_m"]
bottom_t = top_t + STONE_MASONRY["thickness_slope_per_m"] * max(
height_m - STONE_MASONRY["thickness_height_base_m"], 0.0
)
return top_t, bottom_t
def back_length_cm(sheet: dict[str, Any]) -> float | None:
"""장의 뒷길이(㎝). 안 정했으면 `None` — 그때는 두께를 못 내므로 그리지 않는다."""
options = sheet.get("options") or {}
raw = options.get("back_len_cm") or options.get("stone_back_length_cm")
try:
return float(raw) if raw not in (None, "") else None
except (TypeError, ValueError):
return None
def section_points(
height_m: float, slope_ratio: float, back_cm: float
) -> list[tuple[float, float]]:
"""벽 단면 네 점(m 단위, 밑면 앞끝이 원점). 앞면이 뒤로 `n·H` 기운다."""
top_t, bottom_t = wall_thickness(height_m, back_cm)
lean = slope_ratio * height_m
return [
(0.0, 0.0),
(lean, height_m),
(lean + top_t, height_m),
(bottom_t, 0.0),
(0.0, 0.0),
]
def build_figure(
drawing_id: str,
sheet: dict[str, Any],
layer_id: str,
origin: tuple[float, float],
line_color: str,
label_color: str,
guide_color: str,
) -> tuple[list[dict[str, Any]], float]:
"""장 하나의 그림. `(엔티티, 그림이 차지한 높이 ㎜)` — 못 그리면 `([], 0)`."""
if str(sheet.get("type_id") or "") not in FIGURE_TYPE_IDS:
return [], 0.0
height_m = float(sheet.get("height_m") or 0.0)
if height_m <= 0:
return [], 0.0
back_cm = back_length_cm(sheet)
if back_cm is None:
# 뒷길이가 없으면 두께를 못 낸다 — 0 으로 때우지 않고 그리지 않는다.
return [], 0.0
slope, slope_note = slope_of(sheet)
scale = SCALE_MM_PER_M
ox, oy = origin
def mm(point: tuple[float, float]) -> tuple[float, float]:
return (ox + point[0] * scale, oy + point[1] * scale)
points = section_points(height_m, slope, back_cm)
top_t, bottom_t = wall_thickness(height_m, back_cm)
average_t = (top_t + bottom_t) / 2.0
dig_width = average_t + WALL_TRENCH_CLEARANCE_M
# 기초 몫 — 「기초유 / 기초버림」이 폭·깊이를 가른다(정본 탭 제목).
foundation = str((sheet.get("options") or {}).get("foundation") or "")
if foundation == "기초유":
base_w, base_d = WALL_FOUNDATION_WIDTH_M, WALL_FOUNDATION_DEPTH_M
elif foundation == "기초버림":
base_w, base_d = WALL_BLINDING_WIDTH_M, WALL_BLINDING_DEPTH_M
else:
base_w = base_d = 0.0 # 안 정한 장은 기초를 안 그린다 — 지어내지 않는다.
entities: list[dict[str, Any]] = []
wall = polyline_entity(
f"{drawing_id}:fig:wall", [mm(p) for p in points], layer_id, line_color, width=2
)
if wall is not None:
entities.append(wall)
# 터파기 — 우리 수량식(평균두께+0.2) × 높이 를 **그대로** 그린다. 점선.
dig = polyline_entity(
f"{drawing_id}:fig:dig",
[mm(p) for p in ((0.0, 0.0), (0.0, height_m), (dig_width, height_m), (dig_width, 0.0))],
layer_id,
guide_color,
dash=[4, 3],
)
if dig is not None:
entities.append(dig)
# 기초 — 벽 밑에 놓이는 칸. 「안 정함」이면 안 그린다.
if base_d > 0:
base = polyline_entity(
f"{drawing_id}:fig:base",
[mm(p) for p in ((0.0, 0.0), (0.0, -base_d), (base_w, -base_d), (base_w, 0.0))],
layer_id,
guide_color,
)
if base is not None:
entities.append(base)
# 물구멍 — 벽을 가로지르는 짧은 선 하나. 개소 간격은 글자로 적는다(면적당이라 그림에 못 씀).
weep_y = height_m * 0.5
entities.append(
_line_entity(
f"{drawing_id}:fig:weep",
mm((slope * weep_y, weep_y)),
mm((slope * weep_y + top_t, weep_y)),
layer_id,
guide_color,
)
)
labels: list[tuple[str, tuple[float, float], str]] = [
(f"H = {height_m:g} m", (-0.28, height_m / 2.0), "right"),
(f"상부 {top_t:.2f} m", (slope * height_m + top_t / 2.0, height_m + 0.14), "center"),
(f"하부 {bottom_t:.2f} m", (bottom_t / 2.0, -0.30), "center"),
(f"1 : {slope:g}", (slope * height_m / 2.0 - 0.30, height_m * 0.72), "right"),
(
f"터파기 폭 {dig_width:.2f} m (평균두께 {average_t:.2f} + 0.20)",
(dig_width + 0.16, height_m * 0.92),
"left",
),
(
f"기초 {base_w:g} × {base_d:g} m ({foundation})"
if base_d > 0
else "기초 — 안 정함(기초유/기초버림)",
(dig_width + 0.16, -base_d / 2.0 if base_d else 0.0),
"left",
),
(
f"물구멍 — {STONE_MASONRY['weep_hole_area_m2']:g}㎡당 1개소",
(slope * weep_y + top_t + 0.16, weep_y),
"left",
),
("막자갈(뒷채움) — 수량은 아래 표", (dig_width + 0.16, height_m * 0.55), "left"),
]
labels.append((f"뒷길이 ℓ₃ = {int(back_cm)}", (dig_width + 0.16, height_m * 0.72), "left"))
for index, (text, point, align) in enumerate(labels):
x, y = mm(point)
entities.append(
_text_entity(
f"{drawing_id}:fig:label:{index}",
text,
x,
y,
layer_id,
_DIM_FONT,
label_color,
align,
)
)
# 기울기 근거는 **그림 밑에** 한 줄 — 「사용자 지정」인지 「품셈 표준경사」인지 보여야 한다.
note_x, note_y = mm((0.0, -0.62))
entities.append(
_text_entity(
f"{drawing_id}:fig:slopenote",
f"전면 기울기 — {slope_note}",
note_x,
note_y,
layer_id,
_DIM_FONT,
label_color,
"left",
)
)
title_x, title_y = mm((0.0, height_m + 0.52))
entities.append(
_text_entity(
f"{drawing_id}:fig:title",
f"{sheet.get('title') or '돌쌓기'} (축척 1/{int(1000 / SCALE_MM_PER_M)})",
title_x,
title_y,
layer_id,
_LABEL_FONT,
label_color,
"left",
)
)
used_mm = (height_m + 0.95 + base_d) * scale
return entities, used_mm
@@ -10,44 +10,37 @@
계획평면도가 쓰는 나눔 본을 따라야 하고 목록·라우터 곳이 함께 움직여야 한다().
지금은 **값이 눈에 보이는 ** 먼저라 장에 쌓고, 나눔은 다음이다.
값을 여기서 셈하지 않는다 `_Engine_Standard_Sheet.build_standard_sheets` 것을
값을 여기서 셈하지 않는다 `B08_Quantity_Engine_StructureSheet.build_standard_sheets` 것을
글자로 옮길 뿐이다. 셈이 벌이 되면 도면과 수량서가 갈린다(CLAUDE.md 5).
**2026-09-13 B07 도면 목록에서 빠짐**(사용자 확정 구조물도는 B08 , PLAN 3 ).
목록·라우터 배선은 걷어냈고 ·그림 조립만 남김 B08 구조물도 상단 그림 일감이 다시 자리.
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
DRAWING_FORMAT,
_text_entity,
TABLE_LABEL_COLOR,
TABLE_LINE_COLOR,
TABLE_VALUE_COLOR,
_format,
_layer,
_text_entity,
polyline_entity,
table_entity,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import (
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
from B08_Quantity.B08_Quantity_Engine_StructureFigure import (
SCALE_MM_PER_M as FIGURE_SCALE_MM_PER_M,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardFigure import build_figure, figure_reason
from B07_DesignDetail.B07_DesignDetail_Engine_Template import usable_bbox
logger = logging.getLogger(__name__)
from B08_Quantity.B08_Quantity_Engine_StructureFigure import build_figure as figure_shapes
from B08_Quantity.B08_Quantity_Engine_StructureFigure import figure_reason
STANDARD_LAYER_ID = "standard"
#: 장 하나가 도면 하나 — 계획평면도와 같은 본을 따른다(`plan_lidar` / `plan_lidar_2`).
SHEET_ID_PREFIX = "standard_sheet"
def sheet_drawing_id(number: int, total: int) -> str:
"""장이 하나면 `standard_sheet`, 여럿이면 `standard_sheet_2` 처럼 번호를 붙인다."""
return SHEET_ID_PREFIX if total <= 1 and number <= 1 else f"{SHEET_ID_PREFIX}_{number}"
# 종이 밀리미터. 실무 시트가 「공종 | 산출근거 | 수량 | 단위」 넉 줄이라 그대로 간다.
# ⚠ **산출근거 칸을 넓게 잡는다** — 처음 176㎜ 로 냈더니 물구멍관·버림콘크리트처럼 단서가
@@ -75,6 +68,44 @@ def _figure_height(sheet: dict[str, Any]) -> float:
return (height_m + 0.95) * FIGURE_SCALE_MM_PER_M if height_m > 0 else 0.0
def build_figure(
drawing_id: str, sheet: dict[str, Any], origin: tuple[float, float]
) -> tuple[list[dict[str, Any]], float]:
"""B08 그림 모양 목록(m)을 CAD 엔티티(종이 ㎜)로. `(엔티티, 높이 ㎜)` — 못 그리면 `([], 0)`."""
shapes = figure_shapes(sheet) or []
ox, oy = origin
scale = FIGURE_SCALE_MM_PER_M
entities: list[dict[str, Any]] = []
for index, shape in enumerate(shapes):
x, y = (shape.get("at") or [0.0, 0.0])[:2]
if shape["kind"] == "text":
entities.append(
_text_entity(
f"{drawing_id}:fig:{index}",
shape["text"],
ox + x * scale,
oy + y * scale,
STANDARD_LAYER_ID,
shape["size"] * scale,
TABLE_LABEL_COLOR,
shape["align"],
)
)
continue
poly = polyline_entity(
f"{drawing_id}:fig:{index}",
[(ox + px * scale, oy + py * scale) for px, py in shape["points"]],
STANDARD_LAYER_ID,
TABLE_LINE_COLOR if shape["role"] == "wall" else TABLE_VALUE_COLOR,
dash=[4, 3] if shape["dash"] else None,
width=2 if shape["role"] == "wall" else 1,
)
if poly is not None:
entities.append(poly)
ys = [point[1] for shape in shapes for point in shape.get("points") or [shape["at"]]]
return entities, ((max(ys) - min(ys)) * scale if ys else 0.0)
def _cell(
text: str, color: str, *, align: str = "left", col_span: int = 1, bold: bool = False
) -> dict[str, Any]:
@@ -175,11 +206,7 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any])
figure, used = build_figure(
drawing_id,
sheet,
STANDARD_LAYER_ID,
(x0 + _FIGURE_INSET, top - _FIGURE_TOP_PAD - _figure_height(sheet)),
TABLE_LINE_COLOR,
TABLE_LABEL_COLOR,
TABLE_VALUE_COLOR,
)
if figure:
entities.extend(figure)
@@ -223,69 +250,3 @@ def build_standard_drawing(drawing_id: str, label: str, payload: dict[str, Any])
"entities": entities,
"layers": [_layer(STANDARD_LAYER_ID, "표준도")],
}
def standard_payload(
project_root: Path, section_modes: dict[float, str] | None = None
) -> dict[str, Any]:
"""프로젝트 구조물을 제원 조합으로 묶은 장 목록. 실패해도 **빈 목록**을 낸다.
늦게 부른다(함수 import) B08 B05 부르고 B05 다시 B07 부를 있어
모듈 위에서 부르면 맞물린다.
"""
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
try:
structures, names, _skipped = _collect_structures(str(project_root))
# ⚠ 단면유형을 넘겨야 성절토가 갈리고 표준경사 판정이 돈다. 안 넘기면 전 구조물이
# 「가를 근거 없음」으로 떨어져 종전값 1:0.3 으로 선다(2026-09-09 실측).
return build_standard_sheets(
build_unit_table(structures, names, section_modes), section_modes
)
except Exception:
logger.exception("B07 표준도 장 목록 실패 — 빈 목록으로 둔다: %s", project_root)
return {"sheets": [], "structure_count": 0}
def sheet_items(
project_root: Path, section_modes: dict[float, str] | None = None
) -> list[tuple[str, str]]:
"""좌측 목록에 설 `(도면 id, 이름)`. 장이 없으면 **빈 장 하나**를 남긴다.
구조물이 없다고 단추가 통째로 사라지면 없어진 처럼 보인다 눌러서 사유를 읽게 한다.
"""
sheets = standard_payload(project_root, section_modes).get("sheets") or []
if not sheets:
return [(SHEET_ID_PREFIX, "표준도")]
total = len(sheets)
return [
(sheet_drawing_id(index, total), f"표준도 {index}장 ({sheet.get('title') or ''})".strip())
for index, sheet in enumerate(sheets, start=1)
]
def standard_drawing_for(
project_root: Path,
drawing_id: str,
label: str,
section_modes: dict[float, str] | None = None,
) -> dict[str, Any]:
"""프로젝트 구조물을 읽어 표준도 한 장을 만든다 — 라우터가 부르는 문.
실패해도 도면은 연다 구조물 정본을 읽었다고 화면이 비면 사용자는 고장으로
읽는다. 그때는 대신 사유가 적힌 줄이 뜬다.
늦게 부른다(함수 import) B08 B05 부르고 B05 다시 B07 부를 있어
모듈 위에서 부르면 맞물린다.
"""
payload = standard_payload(project_root, section_modes)
sheets = payload.get("sheets") or []
# 이 도면 id 가 가리키는 장 하나만 남긴다 — 한 면에 한 장(2026-09-09, 그림이 붙으면서).
total = len(sheets)
picked = [
sheet
for index, sheet in enumerate(sheets, start=1)
if sheet_drawing_id(index, total) == drawing_id
]
return build_standard_drawing(drawing_id, label, {**payload, "sheets": picked})
+1 -6
View File
@@ -31,7 +31,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
use_company_templates,
use_title_fields,
)
from B07_DesignDetail.B07_DesignDetail_Router_Standard import section_modes_of
from B07_DesignDetail.B07_DesignDetail_Router_Support import (
CROSS_STANDARD_ID,
LANDUSE_ID,
@@ -271,10 +270,7 @@ async def get_design_drawing_list(
try:
route_id, project_root, longitudinal_path, bypass = await _confirmed_source(project_id)
designs = await _designs_by_chainage(route_id)
modes = await section_modes_of(route_id)
drawings = await asyncio.to_thread(
_drawing_list, project_root, longitudinal_path, designs, modes
)
drawings = await asyncio.to_thread(_drawing_list, project_root, longitudinal_path, designs)
return DesignDrawingListResponse(
project_id=str(project_id), route_id=route_id, drawings=drawings, dev_bypass=bypass
)
@@ -372,7 +368,6 @@ async def get_design_drawing(
longitudinal_path,
drawing_id,
source_design,
await section_modes_of(route_id),
)
return DesignDrawingResponse(
project_id=str(project_id),
@@ -1,195 +0,0 @@
"""B07 표준도(구조물도) 라우터 — 장 목록 조회와 **제원 입력**.
표준도는 도면이자 **입력 화면**이다(PLAN 4-5b). `phase: "detail"` ( 종류·조달·뒷길이·
전면 기울기) 그리는 화면이 없어(2026-09-09 실측) 자리를 여기가 맡는다.
** 하나 = 제원 조합 하나** 고치면 조합의 개소 전부에 걸린다.
값을 여기서 셈하지 않는다 정본(`structures.json`) 적기만 하고 ·그림은 다음 조회에서
정본으로 다시 선다. 표준도가 번째 정본이 되면 된다(CLAUDE.md 5).
"""
import asyncio
import logging
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, ConfigDict, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
async def section_modes_of_project(project_id: UUID) -> dict[float, str]:
"""프로젝트에서 노선을 찾아 단면유형 표를 낸다 — 노선을 모르면 빈 표."""
from B06_Section.B06_Section_Repository import get_workflow_route_context
from config.config_db import run_with_connection
try:
context = await run_with_connection(get_workflow_route_context, project_id)
route_id = int((context or {}).get("route_id") or 0)
except Exception:
logger.exception("B07 노선 조회 실패: project_id=%s", project_id)
return {}
return await section_modes_of(route_id) if route_id else {}
async def section_modes_of(route_id: int) -> dict[float, str]:
"""측점별 단면유형(`left_cut` 등) — 구조물이 **성토면인가 절토면인가**를 가르는 근거.
이것을 넘기면 판정이 통째로 가를 근거 없음으로 떨어져 ** 구조물이 종전값
1:0.3 으로 선다**(2026-09-09 실측). 값이 없는 것이 아니라 ** 넘긴 **이었다.
표를 만드는 셈은 `section_modes_from_designs` 벌을 쓴다 부르는 쪽마다 다시
짜면 B08 갈린다.
"""
from B06_Section.B06_Section_Repository import get_cross_section_designs
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import section_modes_from_designs
from config.config_db import run_with_connection
try:
designs = await run_with_connection(get_cross_section_designs, route_id)
except Exception:
logger.exception("B07 단면유형 조회 실패: route_id=%s", route_id)
return {}
return section_modes_from_designs(designs)
class StandardSheetSpecRequest(BaseModel):
"""표준도 장 하나의 제원. **빈 값(null)은 「정한 적 없음」**이라 그 칸을 지운다."""
model_config = ConfigDict(extra="forbid")
sheet_key: str
base_revision: int = Field(ge=0)
stone_kind: str | None = None
stone_supply: str | None = None
back_len_cm: int | str | None = None
face_slope_ratio: float | str | None = None
foundation: str | None = None
stone_coeff_basis: str | None = None
fill_concrete_mpa: str | None = None
thickness_top_m: float | str | None = None
thickness_bottom_m: float | str | None = None
@router.put("/{project_id}/standard-sheets/spec")
async def put_standard_sheet_spec(
project_id: UUID, payload: StandardSheetSpecRequest
) -> JSONResponse:
"""장 하나의 제원을 고쳐 **그 조합의 구조물 전부**에 반영한다.
값을 여기서 셈하지 않는다 정본(`structures.json`) 적기만 하고, ·그림은 다음
조회에서 정본으로 다시 선다. 표준도가 번째 정본이 되면 된다(CLAUDE.md 5).
"""
from B05_Profile.B05_Profile_Structures_Repository import load_structures, save_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import standard_payload
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Edit import (
apply_spec,
clean_spec,
drop_unregistered,
)
try:
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path)).resolve()
except Exception:
logger.exception("B07 표준도 제원 저장 실패(경로): project_id=%s", project_id)
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
modes = await section_modes_of_project(project_id)
payload_sheets = await asyncio.to_thread(standard_payload, project_root, modes)
sheets = payload_sheets.get("sheets") or []
picked = next((s for s in sheets if s.get("key") == payload.sheet_key), None)
if picked is None:
return JSONResponse(
status_code=404,
content={"status": "error", "message": "그 표준도 장을 찾지 못했습니다."},
)
member_ids = {
str(m.get("structure_id")) for m in picked.get("members") or [] if m.get("structure_id")
}
spec, notes = clean_spec(payload.model_dump(exclude={"sheet_key", "base_revision"}))
# ⚠ 등록부에 없는 칸은 저장소가 거절한다 — 한 칸 때문에 **전부** 못 저장되지 않게 거른다.
type_id = str(picked.get("type_id") or "")
definition = structure_type_map().get(type_id)
allowed = {field.key for field in definition.options} if definition else set()
spec, missing = drop_unregistered(type_id, spec, allowed)
notes.extend(missing)
try:
revision, stored = await asyncio.to_thread(load_structures, str(project_root))
updated, changed = apply_spec(stored, member_ids, spec)
new_revision = await asyncio.to_thread(
save_structures, str(project_root), updated, base_revision=payload.base_revision
)
except Exception as exc:
logger.exception("B07 표준도 제원 저장 실패: project_id=%s", project_id)
return JSONResponse(
status_code=409,
content={"status": "error", "message": f"제원을 저장하지 못했습니다 — {exc}"},
)
return JSONResponse(
content={
"status": "success",
"project_id": str(project_id),
"revision": new_revision,
"previous_revision": revision,
"changed": changed,
# 범위 밖 값·표에 없는 규격은 **막지 않고 알린다**(실무에 1:0.7 이 실재).
"notes": notes,
}
)
@router.get("/{project_id}/standard-sheets")
async def get_standard_sheets(project_id: UUID) -> JSONResponse:
"""표준도(구조물도) **장 목록 + 하단표**.
수량을 여기서 새로 셈하지 않는다 B08 원단위 전개를 그대로 받아 **제원 조합으로 묶고
단위당으로 접기만** 한다(계산 자리는 , CLAUDE.md 5).
"""
from B07_DesignDetail.B07_DesignDetail_Engine_Standard_Sheet import build_standard_sheets
from B08_Quantity.B08_Quantity_Engine_UnitQuantity import build_table as build_unit_table
from B08_Quantity.B08_Quantity_Router_Material import _collect_structures
try:
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = str(Path(resolve_stored_project_path(stored_path)).resolve())
except Exception:
logger.exception("B07 표준도 조회 실패(경로): project_id=%s", project_id)
return JSONResponse(
status_code=404,
content={"status": "error", "message": "프로젝트 저장 폴더를 찾지 못했습니다."},
)
modes = await section_modes_of_project(project_id)
try:
structures, names, skipped = await asyncio.to_thread(_collect_structures, project_root)
unit_table = await asyncio.to_thread(build_unit_table, structures, names, modes)
except Exception:
logger.exception("B07 표준도 전개 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "구조물 원단위를 전개하지 못했습니다."},
)
payload = build_standard_sheets(unit_table, modes)
payload["status"] = "success"
payload["project_id"] = str(project_id)
# 왜 안 실렸는지 — 「구조물이 없다」와 「걸러졌다」를 화면이 가릴 수 있어야 한다.
payload["skipped_structures"] = skipped
return JSONResponse(content=payload)
@@ -48,11 +48,6 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Standard import (
STANDARD_LABEL,
build_standard_cross_drawing,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_StandardSheet import (
SHEET_ID_PREFIX,
sheet_items,
standard_drawing_for,
)
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Table import (
QUANTITY_VALUE_KEYS,
)
@@ -113,7 +108,6 @@ def _drawing_list(
project_root: Path,
longitudinal_path: Path,
designs: dict[int, dict[str, Any]] | None = None,
section_modes: dict[float, str] | None = None,
) -> list[DesignDrawingItem]:
longitudinal = _read_json(longitudinal_path)
station_by_chainage = _station_map(longitudinal)
@@ -205,10 +199,7 @@ def _drawing_list(
confirmed=bool(manifest_drawings.get(drawing_id, {}).get("confirmed")),
)
)
# 표준도 — **제원 조합마다 한 장**이라 장이 나뉜다(2026-09-09). 구조물이 없어도 한 장은
# 남긴다: 단추가 사라지면 「없어진 것」처럼 보이고 사유를 읽을 자리도 없어진다.
for drawing_id, label in sheet_items(project_root, section_modes):
drawings.append(DesignDrawingItem(id=drawing_id, kind="standard", label=label))
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — 사용자 확정 ③, PLAN 3장 ④.
_store_drawing_numbers(project_root, drawings)
return drawings
@@ -410,7 +401,6 @@ def _read_drawing(
longitudinal_path: Path,
drawing_id: str,
stored_design: dict[str, Any] | None = None,
section_modes: dict[float, str] | None = None,
) -> tuple[str, str, dict[str, Any], bool, dict[str, float | None] | None]:
"""(kind, label, drawing, confirmed, quantity_table)를 반환한다.
@@ -442,11 +432,6 @@ def _read_drawing(
stored_table = manifest_entry.get("quantity_table")
table = stored_table if kind == "cross" and isinstance(stored_table, dict) else None
return kind, label, saved, True, table
if drawing_id == SHEET_ID_PREFIX or drawing_id.startswith(f"{SHEET_ID_PREFIX}_"):
# 표준도 — 제원 조합 한 벌이 한 장. 도각은 두르지 않는다(2026-09-08 사용자 지시).
label = dict(sheet_items(project_root, section_modes)).get(drawing_id, "표준도")
drawing = standard_drawing_for(project_root, drawing_id, label, section_modes)
return "standard", label, drawing, False, None
if drawing_id == COVER_ID:
# 표지는 설계 자료를 쓰지 않는다 — 템플릿 한 장이 곧 도면이다.
+2 -4
View File
@@ -20,8 +20,7 @@ class DesignDrawingItem(BaseModel):
"landuse",
"plan_lidar",
"cross_standard",
# standard: 표준도(구조물도) — 제원 조합 하나가 한 장.
"standard",
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌.
"blank",
]
label: str
@@ -59,8 +58,7 @@ class DesignDrawingResponse(BaseModel):
"landuse",
"plan_lidar",
"cross_standard",
# standard: 표준도(구조물도) — 제원 조합 하나가 한 장.
"standard",
# 표준도(구조물도)는 2026-09-13 B08 「구조물도」 탭으로 이관 — kind 도 뺌.
"blank",
]
label: str

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