From c8da8b16237a9b03dc79283faa7ac98729b2b362 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 08:35:06 +0900 Subject: [PATCH 01/17] =?UTF-8?q?chore(=EC=9A=94=EA=B5=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20252=20=EC=9B=90=EB=AC=B8=20=E2=80=94=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=ED=94=84=EB=A1=9C=EC=A0=9D=ED=8A=B8=20id=20=C2=B7?= =?UTF-8?q?=20=EA=B3=A1=EC=84=A0=20=EC=B8=A1=EC=A0=90=20=C2=B7=20R=20?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=EB=92=A4=20=EC=B8=A1=EC=A0=90=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=C2=B7=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=EC=9C=84=20=C2=B7=20=EC=A2=8C=ED=91=9C=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=EA=B0=90=20=C2=B7=20=EC=98=AE=EA=B9=80=20=EC=83=88=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=9D=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/요구_1017/사용자요구_1017.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/raw/요구_1017/사용자요구_1017.md b/docs/raw/요구_1017/사용자요구_1017.md index affa7b7ef..1445503d6 100644 --- a/docs/raw/요구_1017/사용자요구_1017.md +++ b/docs/raw/요구_1017/사용자요구_1017.md @@ -128,3 +128,12 @@ - 합친곡선의 경우에는 측점을 현재 로직에서 못 찾을리 없음. 측점 위치로 이동되기 때문, 이떄 곡선의 길이가 길어져 여러군데의 측점을 있다면 이때는 가운데 지점만 신경쓰면 됨. - 평면노선에서 원래라는 의미가 없음. 한도를 정한 이유가 뭐임? - 직선은 존재해야함. + +## 요구 252 (10-18 · 251 답 뒤 사용자 · 「신규계획서를 만들기 위해 너가 검토를 많이 해볼것」) + +1. 생성된 프로젝트는 개별 워크트리의 에이전트들이 검증하기 위함. 아마 프로그램의 내부 관리 파일중에 워크트리의 에이전트들이 프로젝트 ID를 확인하기 위한 문서가 있다면 ID업데이트 없다면 그냥 무시 +2. 평면도의 R이 일반 측점이 아닌경우 곡선 측점을 추가 되어야함. +3. 평면도의 R을 변경할 경우 노선을 기준으로 높은 숫자의 측점 번호들이 자동으로 측점을 맞추는 로직이 들어가 있음? 없다면 제어로직을 재활용하여 수정 필요 +4. 구조물 측점의 경우 평면도에 마커를 노선상의 표현들 중 상위에 배치해 주고 선택된 이후에 다른 지점을 누르면 선택취소되게 변경 +5. 구조물 측점의 경우 노선의 번호를 유지하게 했는데 이걸 생각해보니 구조물의 위치는 최대한 등고선의 좌표를 기준으로 최대한 지정한 위치를 지키게 하는 맞겠어. +6. 구조물 측점을 앞 측점의 노선의 번호를 이동할때 신규로 구조물 측점이 생성이 되고 우측 횡단내용이 안나오고 있어. 확인해줘. From 0c2ce402f0b517fb72d6c81be37b1d488510fabf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 08:56:20 +0900 Subject: [PATCH 02/17] =?UTF-8?q?chore(=EC=A1=B0=EC=82=AC):=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20252=20=EB=B6=84=EC=84=9D=20=E2=80=94=20=EA=B3=A1?= =?UTF-8?q?=EC=84=A0=20=EC=B8=A1=EC=A0=90=20=C2=B7=20R=20=EB=92=A4=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=EB=A7=9E=EC=B6=A4=20=C2=B7=20=EB=A7=88?= =?UTF-8?q?=EC=BB=A4=20=EB=A7=A8=20=EC=9C=84=20=C2=B7=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=20=EB=94=B0=EB=9D=BC=EA=B0=90=20=C2=B7=20=EC=98=AE=EA=B9=80=20?= =?UTF-8?q?=ED=9D=A0=20=EC=9B=90=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01C6BNBFXFndbqYxu6sZuknK --- docs/raw/조사/2026-10-18_407_요구252_분석.md | 100 +++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 docs/raw/조사/2026-10-18_407_요구252_분석.md diff --git a/docs/raw/조사/2026-10-18_407_요구252_분석.md b/docs/raw/조사/2026-10-18_407_요구252_분석.md new file mode 100644 index 000000000..d28837f5e --- /dev/null +++ b/docs/raw/조사/2026-10-18_407_요구252_분석.md @@ -0,0 +1,100 @@ +# 요구 252 분석 — 곡선 측점 · R 바꿈 뒤 측점 맞춤 · 구조물 측점 마커 + +## 1. 검증 프로젝트 id (252-1) + +- 에이전트가 프로젝트 id 를 찾는 문서 = `OWNERS.md` 표(서브 여덟 폴더 = 메인 파일 링크 · git 밖 · 데스크탑은 시놀로지) → 새 id 13 개로 바꿈(10-18). +- 옛 id 를 쓰는 곳: 지난 기록(`docs/raw/plans` · `docs/raw/조사` · `요구_10xx`) = 이력이라 그대로 · `resources/tester` 일부 시험 · 견본(옛 프로젝트 자료에 맞춘 것) = 배정 프로젝트 없음으로 건너뜀(9 skipped) — 사용자 말 「없다면 무시」에 따라 손대지 않음. + +## 2. 곡선 측점 (252-2) + +지금: +- 덧 측점(이형 측점)을 심는 것 = 관 · 구조물(횡단도 부품 `anchor` · `span3`) · 사용자 덧 측점뿐 — 곡선 점(BC · MC · EC)은 어디서도 측점이 안 됨(`common_util_structure_station.py:121-216` ↔ `.ts:237-307`). +- 곡선 관련 측점 = 요구 224-2 「호 사이 측점은 호 가운데로」 하나 — [측점 스냅] 켬일 때 호 안 일반측점 하나를 MC 로 **옮김**(`B05_Profile_Engine_Sections_Core.py:298-324` `snap_curve_stations` ↔ `logic/B05_Profile_UI_RouteEdit_Frame.ts:165-183`) · 옮긴 측점은 일반측점 이름 그대로 · 원래 자리(20 m 배수)는 사라짐 · 측점 수 그대로(`test_224_2:239-244`). +- 빈틈: + - 스냅 끔 = MC 측점 없음. + - 호가 짧아 안에 일반측점이 없으면 = MC 측점 없음. + - 스냅 간격 0.1 · 1 · 10 m 또는 맞춤 실패 = 일반측점(예 No.5 = 100 m)이 107 m 로 옮겨져 20 m 격자가 깨짐. + +정함(브레인): +- 224-2 「호 안 측점은 호 가운데로」 그대로 — 스냅 켬 · 호 안에 일반측점이 있으면 지금처럼 그 측점을 MC 로 옮김. +- 그 밖에 MC 에 측점이 없으면(스냅 끔 · 호가 짧아 안에 일반측점 없음) MC 자리에 **곡선 측점**(새 종류 `curve`) 하나를 더함 · MC 가 일반측점 값(스냅 간격 자릿수로 반올림 · 20 m 배수)과 같으면 더하지 않음. +- 곡선 측점 값 = 스냅 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 · 스냅 끔 = 정수 m · 390-5 규칙). +- BC · EC 는 더하지 않음(지식DB `산림과임업기술(임도)/2. 임도/4. 노선측량.md:144` = BC · MC · EC 보조측점 · 사용자 규칙은 MC 하나 · 곡선마다 둘씩 늘면 횡단 계산이 크게 늚) — 사용자가 원하면 같은 길로 더함. +- 이름 = 「No.5+7 (MC)」 꼴 · 열쇠 = `curve:<누가거리>`(`common_util_station_key` 짝) · 종단 · 횡단(B06 카드) · 평면 눈금 모두 한 목록으로. + +바꿀 곳: +- 서버 `generate_sections`(Core.py:388-415) ↔ TS `buildStations`(`logic/B05_Profile_Sections_Stations.ts:281-311` · `gridIndices` 가 곡선 측점에 격자 번호를 주지 않게). +- 종류 · 열쇠: `StationKind`(`A00_Common/design/logic/design_pipeline_types.ts:156`) · `stationKey`(Sections_Stations.ts:72-82) · `common_util_station_key.py ↔ .ts`. +- `irregular` 로 두지 않음 — `_merge_irregular_into_longitudinal`(Router_Confirm.py:327) 이 지우고 `oneStationList` · `setGeneralStations` 가 걸러냄 · B06 `_stale_marks`(B06_Section_Router_Stations.py:153)가 낡음으로 셈. +- B06 종류 갈래: `B06_Section_Station_Keys.ts:118` · `B06_Section_Section_Drafts.ts:212` · `logic/B06_Section_Station_Swap.ts:145` · `B06_Section_UI_Longitudinal.ts:411`. +- 평면 눈금: `UI_RouteEdit_SnapStep.ts:93-100`(스냅 끔 = null) · `UI_RouteEdit_Render.ts:476-486`. +- 거울 시험: `test_344_3_pipeline_stations_mirror` · `test_344_3_station_key_mirror` · `test_224_2_station_snap`(옮김 그대로 · 더함 경우 추가) · `test_390_5_snap_step_ops`. +- 곡선 측점은 새 열쇠라 종단 · 횡단 초안 없음(자동 설계) · 노선이 바뀌면 MC 따라 새로 생김(옛 곡선 측점 초안은 400 이월 규칙 = 옆 1.0 m 안이면 이어받음). + +## 3. R 바꿈 뒤 뒤쪽 측점 맞춤 (252-3) + +답: **있음** — 반지름 · 곡선 길이 입력(`UI_RouteEdit_CurveBar.ts:66-87`) → `applyEdit`(`UI_RouteEdit.ts:435-445`) → `fitCurveStations(…, k-1)`(`StationFit.ts:142-177`) = 교점 끌기 · 방향키와 같은 함수 · 고친 곡선부터 끝 곡선까지 MC 를 다시 측점에 맞춤(뒤 교점을 들어오는 접선 따라 밂). + +빈틈: +- 조용히 빠짐 — 고정 교점(`apexLock`) · 풀이 실패(기울기 0.2 아래 · 10 걸음 안 수렴) · 곡선 한계 새로 깨짐(`shortfallCrossed`) · 앞 곡선 MC 가 tol/5 넘게 밀림 → 그 곡선은 말없이 「측점 밖」 개수로만(StationFit.ts:159-172) · 큰 R 은 접선이 직선 절반에 막혀 이 경우가 잦음. +- 상태줄에 「함께 밀린 교점」 보고 없음(방향키만 `KeyNudge.ts:63-96`). +- 입력 중 미리보기 없음(`change` 때만 · Label.ts:243). +- 시험 없음 — R 바꾼 뒤 뒤쪽 MC(`helper_398_2` 는 고친 곡선만). + +정함(브레인): +- 뒤쪽 맞춤은 지금 함수 그대로 씀(재활용) · 고칠 것 = ① R · 곡선 길이 입력 뒤 상태줄에 「실제 바뀜 · 함께 밀린 교점 N곳 · 못 맞춘 곡선 No.…」(KeyNudge 재사용) ② 못 맞춘 곡선은 252-2 곡선 측점으로 자연히 측점이 생김 ③ 시험 = R 키운 뒤 뒤쪽 곡선 MC 가 측점 위(측점 밖 개수 안 늘어남). +- 풀이 실패 줄이기(접선 절반 막힘)는 이번에 안 함 — 398-4 접선 규칙 그대로(251). + +## 4. 구조물 측점 마커 — 맨 위 · 다른 곳 누르면 고름 풀림 (252-4) + +지금(`B05_Profile_UI_RouteEdit_Render.ts:155-323` 한 캔버스 차례): +- 마커(`drawStructureMarks` :258) 위에 교점 원 · 삼각 · 곡선 손잡이 · 곡선 3점 · 마우스 올림 고리(:260-321)가 그려짐 → 마커가 가려짐. +- 누름 차례(`UI_RouteEdit_Pointer.ts:198-284`) = 교점(12 px) → 곡선 손잡이(14 px) → 마커(10 px · :242) → 측점 눈금 → 호 → 등고선 · 마우스 올림 · 커서도 교점 먼저(:361-374). +- 고름 풀기 없음 — 마커 고름 = 공용 세션 `structure-pick`(`A00_Common/design/design_selection.ts:112-190`) · 교점 · 손잡이 · 호 · 빈 곳 · 등고선을 눌러도 안 풀림 · Esc 없음. + +정함(브레인): +- 그리기 = 마커를 맨 마지막(마우스 올림 뒤 · `context.restore()` 앞) · 누름 · 마우스 올림 · 커서 = 마커 먼저(측정 · Shift 다음) · 마커를 누르면 고른 교점 풂(마커 방향키가 교점 고름 때 막힘 · Marks:358). +- 마커 아닌 곳을 누르면(교점 · 손잡이 · 호 · 눈금 · 빈 곳 · 등고선) 마커 고름 풀림 · Esc 도 풂 · 오른쪽 횡단 판은 마지막 측점 그대로(비우지 않음 · `PLAN_PICK_FROM`). +- 시험 `test_399_plan_structure_marks.py:73-86`(지금 차례를 잠근 것) 새 차례로 고쳐 씀. + +## 5. 구조물 자리 = 등고선 좌표(x · y) 지킴 (252-5) + +지금: +- 규칙 · 코드는 이미 좌표 따라감 — 초안(`A00_Common/design/design_pipeline_page.ts:312-324` → `common_util_route_follow.ts:59-192`) · 저장(`B05_Profile_Route_Save.py:383-387` → `common_util_route_follow.py:77-114` · 관 `common_util_drainage_pipes.py:271-297`). +- 그런데 사용자가 본 「번호 유지」 = 흠 둘이 만든 것: + - 마커로 옮긴 구조물은 누가거리만 바뀌고 x · y 는 옛 값(`B05_Profile_UI_Structures_Snap.ts:27-39` `movedStructure`) → 다음 교점 · R 고침 때 옛 자리로 튀어 돌아감. + - 배수 편집기(`B05_Profile_UI_Drainage_Panel.ts:150-153 · 405-408` `rememberPipes`)가 관 하나만 고쳐도 모든 관을 옛 노선 누가거리로 다시 써서 따라감 결과를 버림(PipePage:603) → 관 번호가 그대로 남음. +- 저장 x · y 채움(`B05_Profile_Structures_Router.py:356-363`)은 누가거리 · 시종점이 그대로일 때만 옛 x · y 유지 · 아니면 누가거리로 다시 셈. + +정함(브레인): +- 구조물 · 관 자리 = 처음 놓은(또는 마커로 옮긴) x · y · 노선이 바뀌면 그 x · y 를 새 노선에 투영해 누가거리를 다시 매김(정수 m 반올림 = 사용자가 말한 미세 이동) · 측점 번호는 따라 바뀜. +- 마커로 옮기면 그 자리 x · y 를 새로 적음(노선 위 점) — 사용자가 고른 새 자리가 기준. +- 배수 편집기는 따라간 누가거리를 받아 씀(옛 값으로 덮지 않음) · 관 초안에 x · y 실음. +- 사토장 · 토취장(측점 안 심는 「자리」 종류)은 지금처럼 가까운 일반측점에 붙음. +- 서버 저장도 같은 규칙(x · y 기준 · 파이썬 짝 `common_util_route_follow` · 거울 시험). + +## 6. 마커 옮기면 새 측점 · 오른쪽 횡단 빔 (252-6) + +원인(코드로 확인): +- **A 오른쪽 횡단 빔(확실)** — 구조물 · 관을 고르면 `Cross.follow` 가 `watching.structure = true` → `redraw()` 가 바로 돌아감(`B05_Profile_UI_RouteEdit_Cross.ts:272 · 343-347 · 394-395`) = 382-2 때 「구조물 고름 = 마름모만」 규칙의 남은 자리 · 399 가 마름모를 지우며 이 규칙을 남김 · 구조물 옮긴 뒤 다시 고름 없음(`B05_Profile_UI_Structures_Panel.ts:675-682`). +- **B 앞 관을 지나 옮기면 새 관(코드로 확인 · 화면 재현 필요)** — 옛 · 새 관 목록을 **정렬 차례로 짝지음**(`A00_Common/design/design_view_profile.ts:122-127` `shownPipes`) → 옮긴 관이 이웃 id 를 받고 이웃은 새 `pipe-sN`(`design_pipe_ids.ts:35-75`) → 새 알약 · 마커 · 이형 측점 · `followedMarks`(`logic/B05_Profile_Structure_Follow.ts:38-45` · ponytail 주석) · `followPipeReports`(`logic/B05_Profile_Pipeline_Follow.ts:22-24`)도 차례 짝. +- **C 노선 고친 초안에서 관 옮김** — 옮김은 따라간 누가거리로 관을 찾는데 배수 편집기는 옛 값(0.51 m 넘게 어긋나면 옮김이 말없이 안 됨 · 마커는 없는 자리 `to` 에 남음 = 새 측점처럼 보임 · Drain:572-581 · Marks:125-136) — 5장 고침으로 같이 풀림. +- **D B06 열쇠 갈래(추정 · 화면 확인)** — 서버 관 id(`p8017`)를 `pipe-` 머리로 가려 `structure:` 열쇠로 셈(`B06_Section_Station_Keys.ts:66-76` · `B06_Section_Section_Drafts.ts:184-185`) vs 파이프라인 `pipe:p8017`(`logic/B05_Profile_Sections_Stations.ts:297-307`) → 옮긴 관이 「새로 생긴 측점 · 계획고 0」(`logic/B06_Section_Station_Swap.ts:124-165`). + +정함(브레인): +- 오른쪽 판 = 구조물 · 관을 골라도 그 측점 횡단을 그림(일반측점과 같은 판 · 구조물 측점 횡단). +- 관 · 구조물 짝은 늘 id 로(정렬 차례 짝 걷음) · 옮겨도 id · 알약 · 측점 열쇠 그대로. +- B06 열쇠 갈래를 파이프라인과 같은 규칙으로(서버 관 id 도 `pipe:`). +- 끝 확인 = 사용자 길 그대로: 관 · 구조물 마커를 앞 측점 · 앞 관을 지나 옮김(노선 고친 초안 · 안 고친 상태 둘) → 측점 수 그대로 · 알약 수 그대로 · 오른쪽 횡단 그림 있음 · B06 카드 계획고 0 아님. + +## 7. 나눔 + +| 절 | 일 | 창 | 파일 경계 | +|---|---|---|---| +| 406 | 곡선 합치기(진행 중) | sub4 | 추천 엔진 | +| 407 | 곡선 측점(252-2) | sub6 | 측점 목록 짝(Core.py · Sections_Stations.ts · station_key · StationKind) · B06 종류 갈래(Station_Keys.ts:118 · Section_Drafts.ts:212 · Station_Swap.ts:145 · UI_Longitudinal.ts:411) · 평면 눈금(SnapStep · Render 눈금) | +| 408 | R 바꿈 뒤 보고 · 시험(252-3) | sub1 | CurveBar · KeyNudge · StationFit 시험 | +| 409 · 411-A | 마커 맨 위 · 고름 풀기 · 오른쪽 판(252-4 · 6A) | sub3 | Render(마커 차례) · Pointer · UI StructureMarks · Cross · Cross_Card | +| 410 · 411-B~D | 좌표 따라감 · id 짝 · B06 열쇠(252-5 · 6B~D) | sub7 | design_view_profile · design_pipe_ids · Structures_Snap · Drainage_Panel · Structure_Follow · Pipeline_Follow · route_follow 짝 · B06 Station_Keys.ts:66-76 · Section_Drafts.ts:184-185 | + +- 겹치는 파일(B06 `Station_Keys.ts` · `Section_Drafts.ts`) = 407(종류 갈래 곡선 추가) · 411-D(관 id 머리) 몫을 줄로 나눔 · 합칠 때 양쪽 살림. From 7846827fab50db8c65f40be4dda53df9d36b5f31 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:05:41 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat(B05=20=EB=85=B8=EC=84=A0=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20R=20=EC=9E=85=EB=A0=A5):=20=EB=B0=98=EC=A7=80?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EA=B3=A1=EC=84=A0=20=EA=B8=B8=EC=9D=B4=20?= =?UTF-8?q?=EB=92=A4=20=EC=83=81=ED=83=9C=EC=A4=84=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EB=B0=80=EB=A6=B0=20=EA=B5=90=EC=A0=90=20=C2=B7=20=EB=AA=BB=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=98=20=EA=B3=A1=EC=84=A0=20=EB=B3=B4=EA=B3=A0=20?= =?UTF-8?q?+=20=EB=92=A4=EC=AA=BD=20MC=20=EC=8B=9C=ED=97=98=20(408-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 맞춤 함수 그대로(fitCurveStations) · 스냅 켬일 때만 꼬리 — KeyNudge nudgeReport 재사용 fitNote - StationFit offStationMcs(측점 밖 MC 목록) · offStationCount 는 그 길이 - 시험 test_408_radius_downstream 새로 · test_398_2 뒤쪽 곡선 MC 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 2 + .../B05_Profile_UI_RouteEdit_CurveBar.ts | 49 ++++-- .../logic/B05_Profile_RouteEdit_KeyNudge.ts | 21 +++ .../logic/B05_Profile_RouteEdit_StationFit.ts | 20 ++- resources/tester/helper_398_2_curve_input.cjs | 5 + .../tester/helper_408_radius_downstream.cjs | 161 ++++++++++++++++++ resources/tester/test_398_2_curve_input.py | 18 +- .../tester/test_408_radius_downstream.py | 75 ++++++++ 8 files changed, 327 insertions(+), 24 deletions(-) create mode 100644 resources/tester/helper_408_radius_downstream.cjs create mode 100644 resources/tester/test_408_radius_downstream.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a8339783d..7a7aed022 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -418,6 +418,8 @@ export async function mountRouteEditor( apexLock, limitRadiusM, limitArcM, + line: plannedLine, + snap: stationSnap ? { stepM: snap(), stationM: stationIntervalM } : null, }), toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)), applyEdit: (message) => applyEdit(message), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index 87dc69dc4..6cace6057 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -22,6 +22,8 @@ import { createCurveLabel, type CurveLabel, } from "./B05_Profile_UI_RouteEdit_Label"; +import { fitNote } from "./logic/B05_Profile_RouteEdit_KeyNudge"; +import { offStationMcs } from "./logic/B05_Profile_RouteEdit_StationFit"; /** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */ export interface CurveBarState { @@ -38,6 +40,10 @@ export interface CurveBarState { /** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */ limitRadiusM: number; limitArcM: number; + /** 그린 계획노선(곡선 편 정점) — 측점 밖 곡선 MC 셈(408-1). */ + line: Vertex[]; + /** 측점 스냅 켬이면 맞춤 간격 · 측점 간격(m) · 끔이면 null(맞춤 없음 = 보고 없음). */ + snap: { stepM: number; stationM: number } | null; } export interface CurveBarParams { @@ -64,16 +70,16 @@ export function createCurveBar(params: CurveBarParams): CurveBar { { onClose: () => params.onUnselect(), onRadius: (value) => { - const { picked, curveRadius } = params.state(); + const { picked, curveRadius, planned } = params.state(); if (picked < 0) return; curveRadius[picked] = value; // 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다. const message = value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다."; params.applyEdit(message); - noteShortfall(message, "반지름"); + noteResult(message, "반지름", planned); }, onArcLength: (value) => { - const { picked, nodeInfo, curveArc, curveRadius } = params.state(); + const { picked, nodeInfo, curveArc, curveRadius, planned } = params.state(); if (picked < 0) return; // 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함). // 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다. @@ -83,7 +89,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar { const message = value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다."; params.applyEdit(message); - noteShortfall(message, "곡선 길이"); + noteResult(message, "곡선 길이", planned); }, onLock: (lock) => { const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state(); @@ -124,20 +130,31 @@ export function createCurveBar(params: CurveBarParams): CurveBar { ); /** 넣은 값을 다 못 그렸으면 상태줄 글을 넣은 값 → 그린 값 · 까닭으로(398-2 · 상태줄 두 줄 안) — 접선은 - * 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. */ - function noteShortfall(message: string, name: "반지름" | "곡선 길이"): void { - const { picked, curveRadius, curveInfo, nodeInfo } = params.state(); - const asked = curveRadius[picked]; - const drawn = curveInfo.find((entry) => entry.node_first === picked); - if (asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05) return; - const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1; - const m = (value: number): string => `${Math.round(value * 10) / 10}m`; + * 앞뒤 직선 짧은 쪽 절반까지(`buildEditedPolyline` · 서버 짝 같은 규칙)라 큰 R 은 조용히 눌렸다. + * 스냅 켬이면 맞춤이 민 교점 · 못 맞춘 곡선을 덧붙임(408-1 · `before` = 입력 전 교점). */ + function noteResult(message: string, name: "반지름" | "곡선 길이", before: Vertex[]): void { + const { picked, curveRadius, curveInfo, nodeInfo, planned, line, snap } = params.state(); const status = params.container.querySelector(".b05-routeedit__status"); if (!status) return; - status.textContent = (status.textContent ?? "").replace( - message, - `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`, - ); + const asked = curveRadius[picked]; + const drawn = curveInfo.find((entry) => entry.node_first === picked); + const per = name === "곡선 길이" ? deflectionRad(nodeInfo[picked]?.inner_angle_deg) : 1; + const m = (value: number): string => `${Math.round(value * 10) / 10}m`; + const head = + asked === null || asked === undefined || !drawn || drawn.radius_m >= asked - 0.05 + ? message.replace(/\.$/, "") + : `${name} ${m(asked * per)} → ${m(drawn.radius_m * per)}(앞뒤 직선이 짧아 접선 자리 모자람)`; + const tail = snap + ? fitNote( + before, + planned, + picked, + offStationMcs(line, curveInfo, snap.stepM), + snap.stationM, + snap.stepM, + ) + : ""; + status.textContent = (status.textContent ?? "").replace(message, `${head}${tail}.`); } /** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */ diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts b/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts index ed76e01d0..37756e05f 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_KeyNudge.ts @@ -5,9 +5,11 @@ * · 한 번 0.1 m · Shift ×10 · 위 = 화면 위(지도를 돌려도 화면 기준). * · 측점 스냅 켬이면 한 번 = 스냅 간격(0.1 · 1 · 10 m · 측점 No. · Shift 안 씀 · 390-5) · 걸음마다 * MC 를 그 간격 배수에 맞춘 자리로 보임 · 뗄 때 정본 맞춤 → 실제 이동 · 함께 밀린 IP 를 알림. + * · 반지름 · 곡선 길이 입력 뒤(408-1)도 같은 셈으로 맞춤이 민 교점 · 못 맞춘 곡선을 알림(`fitNote`). * 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Keys`. * ========================================================================== */ +import { stationLabel } from "../../common_util/common_util_station"; import type { Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; /** 방향키 한 번(m) · Shift 배수. */ @@ -94,3 +96,22 @@ export function nudgeMessage(report: NudgeReport, snapping: boolean): string { if (report.movedM < STAY_M) return "같은 측점 — 스냅 끄고 미세 조정하세요."; return `측점에 맞춰 실제 이동 ${report.movedM.toFixed(1)} m · 함께 밀린 IP ${report.pushed}곳.`; } + +/** 반지름 · 곡선 길이 입력 뒤 맞춤 결과 꼬리(408-1) — 손으로 옮긴 교점이 없으니 고른 IP 도 「밀림」에 셈 · + * 못 맞춘 곡선 = 측점 밖 MC(`offStationMcs`)를 측점 표기로(셋까지 · 나머지 「외 N」). */ +export function fitNote( + before: ReadonlyArray, + after: ReadonlyArray, + picked: number, + offMcs: ReadonlyArray, + stationM: number, + stepM: number, +): string { + const start = before[picked] ?? [0, 0]; + const report = nudgeReport(before, after, picked, start, start); + const pushed = report.pushed + (report.movedM > SAME_M ? 1 : 0); + const names = offMcs.map((mc) => `No.${stationLabel(mc, stationM, stepM < 1 ? 1 : 0)}`); + const more = names.length > 3 ? ` 외 ${names.length - 3}` : ""; + const off = names.length ? ` · 못 맞춘 곡선 ${names.slice(0, 3).join(", ")}${more}` : ""; + return ` · 함께 밀린 교점 ${pushed}곳${off}`; +} diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts index 23f1aa63e..aee19d122 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_StationFit.ts @@ -66,15 +66,25 @@ export function offStationCount( curves: ReadonlyArray, intervalM: number, ): number { - if (!(intervalM > 0)) return 0; - const spans = curveSpans(line, curves); + return offStationMcs(line, curves, intervalM).length; +} + +/** 측점 밖 곡선의 MC 누가거리(408-1 · 상태줄 「못 맞춘 곡선」) — `offStationCount` 와 같은 판정. */ +export function offStationMcs( + line: ReadonlyArray, + curves: ReadonlyArray, + intervalM: number, +): number[] { + if (!(intervalM > 0)) return []; const hits = new Set(); - for (const [start, end] of spans) { + const off: number[] = []; + for (const [start, end] of curveSpans(line, curves)) { const mc = (start + end) / 2; const n = Math.round(mc / intervalM); - if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM)) hits.add(n); + if (n >= 1 && Math.abs(mc - n * intervalM) <= mcTolM(intervalM) && !hits.has(n)) hits.add(n); + else off.push(mc); } - return spans.length - hits.size; + return off; } /** 맞춤을 시작할 꺾임점 — 지난 걸음과 처음 다른 자리 바로 앞(그 곡선 끝 접선도 바뀜) · 같으면 1. */ diff --git a/resources/tester/helper_398_2_curve_input.cjs b/resources/tester/helper_398_2_curve_input.cjs index 9a0f40ceb..1d6beeccd 100644 --- a/resources/tester/helper_398_2_curve_input.cjs +++ b/resources/tester/helper_398_2_curve_input.cjs @@ -69,6 +69,8 @@ function editor(snap) { apexLock: Array(n).fill(false), limitRadiusM: LIMITS.limitRadiusM, limitArcM: LIMITS.limitArcM, + line: [], + snap: snap ? { stepM: LIMITS.intervalM, stationM: LIMITS.intervalM } : null, }; const status = { textContent: "" }; const markEdited = () => { @@ -139,11 +141,14 @@ for (const snap of [false, true]) { ed.pick(1); h.onRadius(40); row.r40 = round(ed.drawn(1).radius_m); + const later = () => [2, 3].map((k) => round(ed.mc(k))); // 뒤쪽 곡선 MC(408-1) + row.r40Later = later(); h.onRadius(60); row.r60 = round(ed.drawn(1).radius_m); row.r60Shown = round(ed.lastShow().radiusShown); row.r60Status = ed.status.textContent; row.r60Mc = round(ed.mc(1)); + row.r60Later = later(); h.onArcLength(18); row.l18 = round(ed.arcOf(1)); h.onArcLength(25); diff --git a/resources/tester/helper_408_radius_downstream.cjs b/resources/tester/helper_408_radius_downstream.cjs new file mode 100644 index 000000000..72c8e9e68 --- /dev/null +++ b/resources/tester/helper_408_radius_downstream.cjs @@ -0,0 +1,161 @@ +/* 408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — `_CurveBar` 배선을 Node 로 돌려 JSON 한 줄 + * (`test_408_radius_downstream.py` 가 판정). 창(`_Label`)은 손잡이만 잡는 가짜 · 편집 창 `applyEdit` 는 + * 같은 길(스냅 켬 = `fitStart` → `fitCurveStations` → 길이 고정 R · 하한 → `buildEditedPolyline`)로 흉내. + * node helper_408_radius_downstream.cjs + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const B05 = path.join(ROOT, "B05_Profile"); +const cache = new Map(); +let handlers = null; +const STUBS = { + "./B05_Profile_UI_RouteEdit_Label": { + createCurveLabel: (given) => { + handlers = given; + return { show: () => {}, hide: () => {}, destroy: () => {} }; + }, + centerDirectionOf: () => null, + }, +}; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => + STUBS[request] ?? + (request.startsWith(".") + ? loadTs(path.join(path.dirname(full), `${request}.ts`)) + : require(request)); + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts")); +const edits = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Edits.ts")); +const frame = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Frame.ts")); +const fit = loadTs(path.join(B05, "logic", "B05_Profile_RouteEdit_StationFit.ts")); +const bar = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_CurveBar.ts")); + +// 곡선 넷(IP1~4) · 직선이 길어 맞춤이 접선 절반에 안 막힘 +const ROUTE = [ + [0, 0], + [110, 0], + [210, 60], + [320, 60], + [420, -10], + [540, -10], +]; +const LIMITS = { minRadiusM: 12, limitRadiusM: 12, limitArcM: 5, intervalM: 20 }; +const KEYS = ["planned", "curveOn", "curveRadius", "curveLock", "curveArc", "apexLock"]; +const copy = (state) => + Object.fromEntries( + KEYS.map((key) => [key, state[key].map((v) => (Array.isArray(v) ? [...v] : v))]), + ); + +function editor(snap, apexLock = []) { + const n = ROUTE.length; + const state = { + picked: -1, + planned: ROUTE.map((v) => [v[0], v[1]]), + nodeInfo: [], + curveInfo: [], + curveOn: Array(n).fill(true), + curveRadius: Array(n).fill(30), + curveLock: Array(n).fill(null), + curveArc: Array(n).fill(null), + apexLock: Array.from({ length: n }, (_, i) => apexLock.includes(i)), + limitRadiusM: LIMITS.limitRadiusM, + limitArcM: LIMITS.limitArcM, + line: [], + snap: snap ? { stepM: LIMITS.intervalM, stationM: 20 } : null, + }; + const status = { textContent: "" }; + const markEdited = () => { + edits.applyArcLocks(state.planned, state.curveLock, state.curveArc, state.curveRadius); + edits.applyCurveLimits( + state.planned, + state.curveOn, + state.curveRadius, + LIMITS.limitRadiusM, + LIMITS.limitArcM, + ); + const built = curve.buildEditedPolyline( + state.planned, + state.curveOn, + state.curveRadius, + LIMITS.minRadiusM, + ); + state.line = built.vertices; + state.curveInfo = built.curves; + state.nodeInfo = built.nodes; + }; + let committed = null; + const applyEdit = (message) => { + const from = fit.fitStart(committed, state); + if (snap) state.planned = fit.fitCurveStations(state, LIMITS, from); + markEdited(); + status.textContent = `예상노선 — ${message} 꼬리`; + committed = copy(state); + }; + bar.createCurveBar({ + canvas: { getBoundingClientRect: () => ({ left: 0, top: 0, right: 800, bottom: 600 }) }, + container: { querySelector: (sel) => (sel === ".b05-routeedit__status" ? status : null) }, + state: () => state, + toScreen: (v) => [v[0], v[1]], + applyEdit, + onUnselect: () => {}, + }); + // 처음 = 스냅 켤 때처럼 전체 맞춤 한 걸음 + markEdited(); + applyEdit("측점 스냅을 켰습니다."); + const mcs = () => + frame + .curveSpans(state.line, state.curveInfo) + .map(([a, b]) => Math.round(((a + b) / 2) * 1000) / 1000); + const off = () => fit.offStationCount(state.line, state.curveInfo, LIMITS.intervalM); + const pick = (k) => { + state.picked = k; + }; + return { state, status, h: () => handlers, mcs, off, pick }; +} + +const out = {}; +const row = (ed) => ({ + mcs: ed.mcs(), + off: ed.off(), + status: ed.status.textContent, + planned: ed.state.planned.map((v) => [...v]), +}); + +for (const snap of [true, false]) { + const key = snap ? "on" : "off"; + const ed = editor(snap); + out[`${key}Start`] = row(ed); + ed.pick(1); + ed.h().onRadius(60); // 앞쪽 곡선 R 키움 + out[`${key}R60`] = row(ed); + ed.h().onArcLength(50); // 곡선 길이 입력도 + out[`${key}L50`] = row(ed); + ed.h().onRadius(null); // 빈 값 = 자동 + out[`${key}Auto`] = row(ed); +} + +// IP3 고정 = 그 곡선은 못 맞춤 → 상태줄 「못 맞춘 곡선 No.…」 +{ + const ed = editor(true, [3]); + out.lockStart = row(ed); + ed.pick(1); + ed.h().onRadius(60); + out.lockR60 = row(ed); +} + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_398_2_curve_input.py b/resources/tester/test_398_2_curve_input.py index 4d3cce911..23294f93a 100644 --- a/resources/tester/test_398_2_curve_input.py +++ b/resources/tester/test_398_2_curve_input.py @@ -38,7 +38,7 @@ def test_반지름_40_60_곡선이_바뀜(got, snap) -> None: row = got[snap] assert row["r40"] == pytest.approx(40) and row["r60"] == pytest.approx(60) assert row["r60Shown"] == pytest.approx(60) - assert "반지름을 바꿨습니다." in row["r60Status"] + assert "반지름을 바꿨습니다" in row["r60Status"] @pytest.mark.parametrize("snap", ["snapOff", "snapOn"]) @@ -47,7 +47,7 @@ def test_곡선_길이_18_25_곡선이_바뀜(got, snap) -> None: tol = 0.1 if snap == "snapOn" else 1e-6 # 스냅 켬 = IP 맞춤으로 교각이 조금 바뀐 뒤 값 assert row["l18"] == pytest.approx(18, abs=tol) and row["l25"] == pytest.approx(25, abs=tol) assert row["l25Shown"] == pytest.approx(row["l25"]) - assert "곡선 길이를 바꿨습니다." in row["l25Status"] + assert "곡선 길이를 바꿨습니다" in row["l25Status"] def test_스냅_켬_MC_가_측점에(got) -> None: @@ -55,6 +55,18 @@ def test_스냅_켬_MC_가_측점에(got) -> None: assert got["snapOff"]["r60Mc"] != pytest.approx(120, abs=0.05) +def test_스냅_켬_R_바꾼_뒤_뒤쪽_곡선_MC(got) -> None: + """408-1 — 고친 곡선 뒤 곡선도 맞춤 · 측점 위였던 곡선은 R 바꾼 뒤에도 측점 위.""" + on = got["snapOn"] + + def hit(mc: float) -> bool: + return abs(mc - round(mc / 20) * 20) <= 0.05 + + assert [hit(mc) for mc in on["r60Later"]] >= [hit(mc) for mc in on["r40Later"]] + assert all(hit(mc) for mc in on["r60Later"]) + assert "함께 밀린 교점 " in on["r60Status"] and "함께 밀린" not in got["snapOff"]["r60Status"] + + @pytest.mark.parametrize("snap", ["snapOff", "snapOn"]) def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) -> None: row = got[snap] @@ -62,7 +74,7 @@ def test_접선_자리_모자람_칸은_그린_값_상태줄_까닭(got, snap) - want = f"반지름 60m → {round(row['capR'], 1)}m(앞뒤 직선이 짧아 접선 자리 모자람)" assert want in row["capStatus"] assert "곡선 길이 40m → " in row["capArcStatus"] - assert "반지름을 자동으로 되돌렸습니다." in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음 + assert "반지름을 자동으로 되돌렸습니다" in row["autoStatus"] # 빈 값 = 자동 · 까닭 없음 def test_고정(got) -> None: diff --git a/resources/tester/test_408_radius_downstream.py b/resources/tester/test_408_radius_downstream.py new file mode 100644 index 000000000..b19896c13 --- /dev/null +++ b/resources/tester/test_408_radius_downstream.py @@ -0,0 +1,75 @@ +"""408-1 R 바꿈 뒤 뒤쪽 측점 맞춤(요구 252-3) — 반지름 · 곡선 길이 입력 = 교점 끌기와 같은 +`fitCurveStations` 로 고친 곡선부터 끝 곡선까지 MC 를 측점에 다시 맞춤 · 상태줄 「함께 밀린 교점 N곳 · +못 맞춘 곡선 No.…」(`KeyNudge.fitNote`) · 스냅 끔 = 맞춤 · 보고 없음. + +TS 셈은 `helper_408_radius_downstream.cjs`(Node · `_CurveBar` 배선 그대로). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_408_radius_downstream.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HELPER = Path(__file__).with_name("helper_408_radius_downstream.cjs") +TOL = 0.05 # MC_TOL_M +STEPS = ["R60", "L50", "Auto"] + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def _on_station(mc: float) -> bool: + return abs(mc - round(mc / 20) * 20) <= TOL + + +@pytest.mark.parametrize("step", STEPS) +def test_스냅_켬_뒤쪽_곡선_MC_가_측점_위(got, step) -> None: + row = got[f"on{step}"] + assert len(row["mcs"]) == 4 and all(_on_station(mc) for mc in row["mcs"]) + assert row["off"] <= got["onStart"]["off"] + + +@pytest.mark.parametrize("step", STEPS) +def test_스냅_켬_뒤_교점이_밀림_시점은_그대로(got, step) -> None: + before = got["onStart"]["planned"] + after = got[f"on{step}"]["planned"] + assert after[0] == before[0] and after[-1] == before[-1] + assert all(after[k] != before[k] for k in (2, 3, 4)) # 고친 곡선 뒤 교점이 맞춤으로 밀림 + + +def test_상태줄_보고(got) -> None: + assert "반지름을 바꿨습니다 · 함께 밀린 교점 4곳." in got["onR60"]["status"] + assert "곡선 길이를 바꿨습니다 · 함께 밀린 교점 4곳." in got["onL50"]["status"] + assert "반지름을 자동으로 되돌렸습니다 · 함께 밀린 교점 " in got["onAuto"]["status"] + assert all("못 맞춘 곡선" not in got[f"on{step}"]["status"] for step in STEPS) + + +@pytest.mark.parametrize("step", STEPS) +def test_스냅_끔_맞춤_보고_없음(got, step) -> None: + row = got[f"off{step}"] + assert "함께 밀린" not in row["status"] + assert row["planned"] == got["offStart"]["planned"] # 교점 그대로 + assert not all(_on_station(mc) for mc in row["mcs"]) + + +def test_고정_교점_곡선은_못_맞춤으로(got) -> None: + row = got["lockR60"] + assert row["off"] == got["lockStart"]["off"] == 1 + assert row["planned"][3] == got["lockStart"]["planned"][3] # 고정 IP3 그대로 + mc = row["mcs"][2] + n, rest = int(mc // 20), round(mc - int(mc // 20) * 20) + assert f"함께 밀린 교점 3곳 · 못 맞춘 곡선 No.{n}+{rest}." in row["status"] + assert all(_on_station(row["mcs"][i]) for i in (0, 1, 3)) # 고정 곡선 뒤도 맞춤 From 24fe93ba6b209d20e84f39987fdcace7d1f76fbf Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:07:37 +0900 Subject: [PATCH 04/17] =?UTF-8?q?fix(B05=20=ED=8F=89=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EB=A7=88=EC=BB=A4):=20=EB=A7=88=EC=BB=A4?= =?UTF-8?q?=20=EB=A7=A8=20=EC=9C=84=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20?= =?UTF-8?q?=EA=B3=B3=20=EB=88=84=EB=A6=84=20=C2=B7=20Esc=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=ED=92=82=20=C2=B7=20=EC=98=A4=EB=A5=B8=EC=AA=BD=20?= =?UTF-8?q?=ED=8C=90=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=C2=B7=20=EA=B4=80=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=9A=A1=EB=8B=A8=20(409=20=C2=B7=20411-A?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 마커 그림 = 맨 마지막(교각점 · 손잡이 · 맴돔 위) · 누름 · 맴돔 · 커서 = 재기 다음 마커 먼저 · 마커 누름 = 고른 교각점 풂 - 마커 아닌 곳 누름 · Esc = 공용 고름을 b05-plan 으로 비움(오른쪽 횡단 판 그대로) - 오른쪽 판 = 구조물 · 관 고름도 그 측점 횡단 · 묶음 뒤 열쇠로 붙듦(옮긴 뒤 새 자리) - 시험 399 차례 고쳐 씀 · 새 409 · 411-A Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 27 +-- .../B05_Profile_UI_RouteEdit_Pointer.ts | 26 ++- .../B05_Profile_UI_RouteEdit_Render.ts | 2 +- ...B05_Profile_UI_RouteEdit_StructureMarks.ts | 31 +++- .../helper_399_plan_structure_marks.cjs | 41 ++++- .../tester/helper_411_a_cross_structure.cjs | 159 ++++++++++++++++++ .../tester/test_399_plan_structure_marks.py | 15 +- .../tester/test_409_mark_top_deselect.py | 76 +++++++++ .../tester/test_411_a_cross_structure.py | 58 +++++++ 9 files changed, 401 insertions(+), 34 deletions(-) create mode 100644 resources/tester/helper_411_a_cross_structure.cjs create mode 100644 resources/tester/test_409_mark_top_deselect.py create mode 100644 resources/tester/test_411_a_cross_structure.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index d693bcd98..517b04eb4 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -14,7 +14,8 @@ * 서버 왕복 없음. 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * * 고름 연동(382-2) — 공용 고름 한 칸(`design_selection`) 구독: 일반측점 고름 = 그 측점 횡단 · 평면 법선 · - * 구조물 고름 = 평면 그 자리 마름모만 · 평면 눈금 클릭 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름). + * 구조물 · 관 고름 = 그 측점 횡단(411-A · 법선은 평면 마커 몫) · 옮겨도 열쇠로 따라감 · + * 평면 눈금 클릭 · 마커 고름 풂 = 공용 고름에 적음(`from` b05-plan · 제 알림 거름 = 판 그대로). * 보는 측점 = 측점 열쇠(grid:k) — 묶음 차례마다 열쇠로 다시 짚음(노선을 고쳐도 같은 측점). * 파란 법선 폭 = 그 측점 발자국 기록(가게 `footprint.records`) 비탈 끝 좌 · 우 · 깎기 여부(색). * ========================================================================== */ @@ -74,7 +75,7 @@ export interface CrossWatch { /** 비탈 끝 깎기(절토) 여부 — 발자국 기록이 없으면 없음(옛 파랑 · 반폭). */ leftCut?: boolean; rightCut?: boolean; - /** 구조물 · 관 자리 — 법선 없이 마름모만. */ + /** 구조물 · 관 자리 — 법선은 평면 마커가 그림(399). */ structure?: true; } @@ -90,7 +91,7 @@ export interface CrossPreviewWindow { open: (chainageM: number) => Promise; /** 다시 그림(묶음 지금 값) — 노선이 바뀌었으면 전후로. 보던 것이 없으면 아무 일도 없다. */ refresh: () => Promise; - /** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 마름모. 고른 것 없으면 null. */ + /** 고른 측점과 폭(발자국 비탈 끝 · 없으면 반폭) · 구조물이면 `structure`. 고른 것 없으면 null. */ watched: () => CrossWatch | null; } @@ -247,7 +248,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind params.side.append(current.root, previous.root); const interval = (): number => params.request().station_interval_m; - /** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 고름(마름모만). */ + /** 지금 보는 측점 — 열쇠(묶음 전 null) · 누가거리(열쇠를 못 찾을 때) · 구조물 · 관 측점인가. */ let watching: { key: string | null; chainageM: number; structure: boolean } | null = null; /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료 · 그때 노선 초안(바뀌면 전후로). */ let shown: CrossPreviewResponse | null = null; @@ -269,7 +270,9 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind } function redraw(): void { - if (watching === null || watching.structure) return; + if (watching === null) return; + const station = located(); + if (station && watching.key === null) watching.key = station.key; // 묶음 뒤 열쇠로 붙듦(옮겨도 따라감) const route = pipe?.input("route") ?? null; if (route !== drawnRoute && shown) previous.show( @@ -284,7 +287,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind }, ); drawnRoute = route; - const at = located()?.chainageM ?? watching.chainageM; + const at = station?.chainageM ?? watching.chainageM; shown = pipelineCrossAt(pipe, at, interval(), appliedHalfWidth(params.projectId)); shownSource = shown && routeCrossSource(params.projectId, pipe, shown.chainage_m, "cur"); if (shown) current.show(shown, interval(), shownSource); @@ -316,11 +319,11 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind }; /** 그 측점 횡단을 위 판에 · 아래 판은 비움(전후 비교는 노선을 고쳤을 때만) · 접힌 판은 폄. */ - function showStation(key: string | null, chainageM: number): void { + function showStation(key: string | null, chainageM: number, structure = false): void { // 아래 판은 **노선을 고쳤을 때만** 채운다(2026-09-12 사용자 지시 ⑤). 예전에는 같은 // 측점을 두 번 누르면 위·아래에 **같은 그림 두 장**이 떠 전후 비교가 되지 않았다. previous.show(null, interval()); - watching = { key, chainageM, structure: false }; + watching = { key, chainageM, structure }; drawnRoute = pipe?.input("route") ?? null; shown = null; redraw(); @@ -328,7 +331,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind if (panels && panels.current() === null) panels.open("cross", false); // 열되 기억 안 함(393-1) } - /** 다른 보기(종단 · 3D · 목록 · 횡단 카드) 고름 → 일반측점이면 그 측점 횡단 · 구조물이면 마름모. */ + /** 다른 보기(종단 · 3D · 목록 · 횡단 카드 · 평면 마커) 고름 → 그 측점 횡단(구조물 · 관도 · 411-A). */ function follow(selection: DesignSelection): void { attach(); const at = selection.chainageM; @@ -341,10 +344,8 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind return; } const station = nearestWithin(stationsNow(), at, (entry) => entry.chainageM); - if (!selection.structureId && (!station || isGridStation(station))) - return showStation(station?.key ?? null, station?.chainageM ?? at); - watching = { key: station?.key ?? null, chainageM: station?.chainageM ?? at, structure: true }; - params.onShown?.(); + const structure = !!selection.structureId || (!!station && !isGridStation(station)); + showStation(station?.key ?? null, station?.chainageM ?? at, structure); } const stopPick = subscribeSelection(params.projectId, (selection, from) => { if (from !== PLAN_PICK_FROM) follow(selection); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index b9c89ec5e..358f29407 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -192,7 +192,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP let dragMoved = false; /** 스냅 미리보기 전 자리(390-5) — 하한 검사는 맞춘 선이 아닌 이것으로(맞춤이 IP 를 ±간격/2 밂). */ let dragRaw: Vertex[] | null = null; - /** 구조물 측점 마커(399) — 집기 차례 = 교각점 · 손잡이 다음 · 끌기 · 방향키 1 m. */ + /** 구조물 측점 마커(399) — 집기 차례 = 재기 다음 맨 앞(409) · 끌기 · 방향키 1 m. */ const marks = bindStructureMarks(params, () => dragNode >= 0 || dragHandle !== null); canvas.addEventListener("pointerdown", (event) => { @@ -206,6 +206,17 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP } const state = params.state(); hover(null); // 누르는 동안 · 끄는 동안 맴돔 끔(381-3) + // 구조물 마커가 먼저(409) — 교각점 · 손잡이와 겹쳐도 마커 · 고른 교각점은 풂(마커 방향키 몫) + if (marks.press(px, py)) { + if (state.picked >= 0) { + params.setPicked(-1); + params.syncCurveBar(); + params.draw(); + } + canvas.setPointerCapture(event.pointerId); + return; + } + marks.deselect(); // 마커 아닌 곳 = 마커 고름 풂(오른쪽 횡단 판은 그대로) const pick = pickAt(px, py); // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 @@ -239,8 +250,6 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP })() ) { /* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */ - } else if (marks.press(px, py)) { - /* 구조물 마커를 골랐다 — 끌면 노선 따라 1 m 씩(399). */ } else if ( // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. (() => { @@ -358,16 +367,19 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP const measuring = event.shiftKey || params.measureMode(); if (measuring && params.measure.preview(px, py)) params.draw(); // 맴돌 때 이길 쪽만 밝힘(381-3) — 누르면 집힐 것과 같은 셈 · 재는 중엔 끔. - const over = measuring ? null : pickAt(px, py); + // 마커 위면 마커 몫(409) — 밑에 깔린 교각점 · 눈금은 안 밝힘. + const onMark = !measuring && marks.over(px, py); + const over = measuring || onMark ? null : pickAt(px, py); hover(over); const overNode = over && "node" in over ? over.node : -1; // 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다. - canvas.style.cursor = - overNode >= 0 + canvas.style.cursor = onMark + ? "grab" + : overNode >= 0 ? state.apexLock[overNode] ? "not-allowed" : "grab" - : handleAt(px, py) || marks.over(px, py) + : handleAt(px, py) ? "grab" : over ? "pointer" diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 7c69b1794..21268d853 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -255,7 +255,6 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저 // 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다. drawContourLabels(context, scene, band, style); - drawStructureMarks(context, scene, line, style); // 구조물 측점 마커(399) — 라벨 위 · 교각점 아래 // 노드 · 손잡이는 라벨 뒤 맨 위(381-3) — 측점 · 기울기 · 높이 글자에 묻혀 안 집히던 자리. context.save(); context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; @@ -319,6 +318,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou context.restore(); drawPickedCurve(context, scene, style); drawPickHover(context, scene, line, style, NODE_R); + drawStructureMarks(context, scene, line, style); // 구조물 측점 마커 — 맨 위(409 · 교각점 · 손잡이 · 맴돔 위) context.restore(); // 회전 끝 } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts index ef3b5d847..f316717ab 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -8,13 +8,19 @@ * 고른 마커 = 알약 벌룬과 같은 창(`balloonElement` · `balloonLines`) + 그 측점 좌우 20 m 법선(391). * · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 · * 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본). - * · 집기 차례 = 교각점 · 곡선 손잡이 다음(381-3) — `_Pointer` 가 부름. + * · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름. + * · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로). * 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`. * ========================================================================== */ import { pickPx } from "../A00_Common/touch_bridge"; import { draftProjectId } from "../A00_Common/design/design_store"; -import { PIPE_MATCH_M, readSelection } from "../A00_Common/design/design_selection"; +import { + NO_SELECTION, + PIPE_MATCH_M, + readSelection, + selectDesign, +} from "../A00_Common/design/design_selection"; import { lastPointerWithin } from "../B06_Section/B06_Section_UI_Cross_Panel_Base"; import { structureAnchorM, @@ -32,6 +38,7 @@ import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; import { drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input"; import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer"; +import { PLAN_PICK_FROM } from "./B05_Profile_UI_RouteEdit_Cross"; import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; @@ -179,7 +186,7 @@ function paintMark( context.restore(); } -/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 라벨 뒤 · 교각점 아래에 부름. */ +/** 마커 · 고른 마커 법선 · 정보 창 — `_Render` 가 맨 마지막에 부름(409). */ export function drawStructureMarks( context: CanvasRenderingContext2D, scene: RouteEditScene, @@ -250,7 +257,7 @@ function showBalloon( } export interface StructureMarkInput { - /** 마커를 눌렀으면 고르고 끌기 시작 · true(교각점 · 손잡이 다음 차례). */ + /** 마커를 눌렀으면 고르고 끌기 시작 · true(재기 다음 맨 앞 · 409). */ press: (px: number, py: number) => boolean; /** 끄는 중이면 자리 바꿈 · true. */ move: (px: number, py: number) => boolean; @@ -258,6 +265,8 @@ export interface StructureMarkInput { release: () => boolean; /** 마커 위인가 — 커서 몫. */ over: (px: number, py: number) => boolean; + /** 고른 마커가 있으면 풂 · true(409 — 마커 아닌 곳 누름 · Esc). */ + deselect: () => boolean; } /** 끌기 · 방향키 배선 — `_Pointer` 가 부름(같은 재료 `params`). */ @@ -321,6 +330,14 @@ export function bindStructureMarks( return moving; }; + /** 고름 풂 — 공용 고름을 비움 · 오른쪽 횡단 판은 제 이름(`PLAN_PICK_FROM`)이라 안 비움. */ + const deselect = (): boolean => { + if (!planMarks(canvas)?.picked) return false; + selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM }); + params.draw(); + return true; + }; + // 방향키 — 마커를 골랐고 교각점을 안 골랐을 때 · 누름 ~ 뗌 한 번이 한 옮김(교각점 방향키와 같은 반복) let holdKey: ArrowKey | null = null; let timer = 0; @@ -351,9 +368,12 @@ export function bindStructureMarks( }; function onKeyDown(event: KeyboardEvent): void { if (!canvas.isConnected) return detach(); - if (!isArrowKey(event.key) || !lastPointerWithin(".b05-routeedit")) return; + const escape = event.key === "Escape"; + if ((!escape && !isArrowKey(event.key)) || !lastPointerWithin(".b05-routeedit")) return; const target = event.target as HTMLElement | null; if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return; + // Esc = 마커 고름만 풂(공용 Esc 풀기는 오른쪽 판까지 비우므로 앞에서 받음) + if (escape) return void (!activeMoving(canvas) && deselect() && event.preventDefault()); if (event.repeat || holdKey || dragging() || params.measureMode()) return; if (params.state().picked >= 0 || activeMoving(canvas)) return; // 교각점 방향키 몫 const found = planMarks(canvas); @@ -401,5 +421,6 @@ export function bindStructureMarks( }, release: () => !holdKey && finish(), over: (px, py) => markAt(px, py) !== null, + deselect, }; } diff --git a/resources/tester/helper_399_plan_structure_marks.cjs b/resources/tester/helper_399_plan_structure_marks.cjs index 2a25efea6..12a2a3e16 100644 --- a/resources/tester/helper_399_plan_structure_marks.cjs +++ b/resources/tester/helper_399_plan_structure_marks.cjs @@ -36,10 +36,19 @@ global.window = { clearTimeout: () => {}, clearInterval: () => {}, }; +let prevented = 0; const key = (type, name) => (listeners[type] || []).forEach((fn) => - fn({ key: name, repeat: false, target: null, preventDefault() {}, stopPropagation() {} }), + fn({ + key: name, + repeat: false, + target: null, + preventDefault: () => (prevented += 1), + stopPropagation() {}, + }), ); +/** 공용 고름에 적은 것(409 풀기) — [값, 낸 쪽]. */ +const written = []; const STUBS = { "../A00_Common/touch_bridge": { @@ -50,7 +59,16 @@ const STUBS = { "../A00_Common/input_prefs": { isTrackpadPinch: () => false }, "../B04_PreProcess/B04_PreProcess_UI_MapRender": { computeMaxScale: () => 16 }, "../A00_Common/design/design_store": { draftProjectId: () => "p1" }, - "../A00_Common/design/design_selection": { PIPE_MATCH_M: 0.51, readSelection: () => selection }, + "../A00_Common/design/design_selection": { + PIPE_MATCH_M: 0.51, + NO_SELECTION: { chainageM: null, structureId: null }, + readSelection: () => selection, + selectDesign: (projectId, value, options) => { + written.push([value.chainageM, options.from]); + selection = value.chainageM === null ? null : value; + }, + }, + "./B05_Profile_UI_RouteEdit_Cross": { PLAN_PICK_FROM: "b05-plan" }, "../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true }, "./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 }, "./B05_Profile_Api_Structures": { @@ -315,7 +333,24 @@ const was = redrawn; marks.planMarksChanged("p1"); out.redrawnOnChange = redrawn - was; -// ⑥ 순수 셈 +// ⑥ 고름 풀기(409) — 고른 것 없으면 아무 일 없음 · 있으면 공용 고름을 b05-plan 으로 비움 · Esc 도 +selection = null; +out.deselectNone = [input.deselect(), written.length]; +selection = { chainageM: 40, structureId: "A" }; +out.deselectPicked = [input.deselect(), ...written.slice(-1), selection]; +selection = { chainageM: 80.17, structureId: null }; // 관 고름(누가거리만) +prevented = 0; +key("keydown", "Escape"); +out.escapePicked = [...written.slice(-1), selection, prevented]; +const writes = written.length; +key("keydown", "Escape"); // 고른 것 없음 = 공용 Esc 몫(안 막음) +out.escapeNone = [written.length - writes, prevented]; +// 마커 누름 = 고름(공용 고름 길) · 법선 · 창 — 그린 차례는 `_Render` 맨 끝(시험이 원본으로 봄) +input.press(80, 0); +input.release(); +out.pressAfterRelease = selects[selects.length - 1]; + +// ⑦ 순수 셈 out.logic = { round: [logic.markChainage(41.4, 200), logic.markChainage(41.6, 200)], clamp: [logic.markChainage(-3, 200), logic.markChainage(250, 199.6)], diff --git a/resources/tester/helper_411_a_cross_structure.cjs b/resources/tester/helper_411_a_cross_structure.cjs new file mode 100644 index 000000000..aadd50bb2 --- /dev/null +++ b/resources/tester/helper_411_a_cross_structure.cjs @@ -0,0 +1,159 @@ +/* 411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단 · 옮긴 뒤에도 열쇠로 따라감. + * `_RouteEdit_Cross` 를 Node 로 돌려 JSON 한 줄(`test_411_a_cross_structure.py` 가 판정). 묶음 · 그림 · 가게는 가짜 · + * 고름 구독 · 측점 짚기 · 판 채우기는 진짜. + * node helper_411_a_cross_structure.cjs + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const B05 = path.join(__dirname, "..", "..", "B05_Profile"); +const out = {}; + +/* 가짜 DOM — 판(제목 · 상자 · 발) 글만 봄 */ +function fakeElement() { + const parts = new Map(); + const element = { + textContent: "", + children: [], + isConnected: true, + clientHeight: 150, + setAttribute() {}, + append: (...items) => element.children.push(...items), + replaceChildren: (...items) => (element.children = items), + getBoundingClientRect: () => ({ width: 240, height: 150 }), + querySelector: (selector) => { + if (!parts.has(selector)) parts.set(selector, fakeElement()); + return parts.get(selector); + }, + }; + return element; +} +global.document = { createElement: fakeElement }; +global.ResizeObserver = class { + observe() {} + disconnect() {} +}; + +let listener = null; +let stations = []; +const route = { id: 1 }; +const subscribers = []; +const pipe = { + output: (stage) => (stage === "ground" ? { stations } : {}), + input: () => route, + subscribe: (fn) => (subscribers.push(fn), () => {}), +}; +const crossStage = () => subscribers.forEach((fn) => fn({ kind: "stage", stage: "cross" })); + +const STUBS = { + "../A00_Common/b_svg_zoom_pan": { attachSvgZoomPan() {} }, + "../A00_Common/design/design_pipeline_page": { pagePipeline: () => pipe }, + "../A00_Common/design/design_selection": { + nearestWithin: (items, at, of) => { + let best; + let gap = 0.51; + for (const item of items) + if (Math.abs(of(item) - at) < gap) [best, gap] = [item, Math.abs(of(item) - at)]; + return best; + }, + selectDesign() {}, + subscribeSelection: (_projectId, fn) => ((listener = fn), () => {}), + }, + "../A00_Common/design/design_store": { + useDesignStore: () => ({ subscribe: () => () => {}, get: () => null }), + }, + "./logic/B05_Profile_Pipeline_Detail": { + stationSection: (station) => ({ chainage_m: station.chainageM, samples: [] }), + }, + "./B05_Profile_UI_RouteEdit_Cross_Draw": { + buildCrossSvg: () => ({ svg: fakeElement(), layer: null, width: 240, height: 150 }), + CROSS_PLOT_PAD: 0, + summarizeCross: (preview) => `횡단 ${preview.chainage_m}`, + }, + "../A00_Common/b_page_state": { readStateRaw: () => null }, + "../A00_Common/design/design_page_route": { pageRouteId: () => null }, + "../common_util/common_util_station": { stationLabel: (chainageM) => String(chainageM) }, + "./B05_Profile_UI_RouteEdit_Chrome": { sidePanelsOf: new Map() }, + "./B05_Profile_UI_RouteEdit_Cross_Card": { + routeCrossCard: () => fakeElement(), + routeCrossSource: () => null, + }, +}; +const cache = new Map(); +function loadTs(full) { + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const cross = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Cross.ts")); +const station = (key, chainageM, kind, id = null) => ({ key, chainageM, kind, id }); +const grid = [ + station("end:BP", 0, "end"), + station("grid:1", 20, "grid"), + station("grid:2", 40, "grid"), +]; +const side = fakeElement(); +const preview = cross.createCrossPreview({ + projectId: "p1", + side, + request: () => ({ vertices: [], min_radius_m: 15, station_interval_m: 20 }), +}); +const [current] = side.children; +const shownNow = () => ({ + station: current.querySelector(".b05-routeedit__cross-station").textContent, + foot: current.querySelector(".b05-routeedit__cross-foot").textContent, +}); +const pick = (chainageM, structureId, from) => listener({ chainageM, structureId }, from); + +// ① 구조물 고름(id) = 그 측점 횡단 · 법선은 마커 몫(structure) +stations = [ + ...grid, + station("structure:A", 45, "structure", "A"), + station("pipe:p1", 80.17, "pipe", "p1"), +]; +pick(45, "A", "b05"); +out.structurePick = { ...shownNow(), structure: preview.watched()?.structure ?? null }; +// ② 옮김 — 묶음이 새 자리로(같은 열쇠) · cross 차례 = 새 자리 횡단(고름은 옛 45 그대로) +stations = [ + ...grid, + station("structure:A", 52, "structure", "A"), + station("pipe:p1", 80.17, "pipe", "p1"), +]; +crossStage(); +out.structureMoved = { ...shownNow(), at: preview.watched()?.chainageM ?? null }; +// ③ 관 고름(누가거리만) = 그 측점 횡단 +pick(80.17, null, "b05"); +out.pipePick = { ...shownNow(), structure: preview.watched()?.structure ?? null }; +// ④ 평면 마커 고름 풂(b05-plan) = 판 그대로 +pick(null, null, "b05-plan"); +out.planRelease = shownNow(); +// ⑤ 일반측점 고름 = 그 측점 횡단 · 법선(structure 없음) +pick(20, null, "b05"); +out.gridPick = { ...shownNow(), structure: preview.watched()?.structure ?? null }; +// ⑥ 묶음 전 구조물 고름 — 열쇠 없이 기다렸다 묶음 뒤 열쇠로 붙듦 · 그 뒤 옮김도 따라감 +stations = []; +pick(60, "B", "b05"); +out.beforePipeline = shownNow(); +stations = [...grid, station("structure:B", 60, "structure", "B")]; +crossStage(); +out.afterPipeline = shownNow(); +stations = [...grid, station("structure:B", 66, "structure", "B")]; +crossStage(); +out.latchedMoved = shownNow(); +// ⑦ 다른 보기가 풂(null · b05) = 판 비움(382-2 그대로) +pick(null, null, "b05"); +out.otherRelease = { ...shownNow(), watched: preview.watched() }; +console.log(JSON.stringify(out)); diff --git a/resources/tester/test_399_plan_structure_marks.py b/resources/tester/test_399_plan_structure_marks.py index b1129ff5e..04acdd853 100644 --- a/resources/tester/test_399_plan_structure_marks.py +++ b/resources/tester/test_399_plan_structure_marks.py @@ -65,18 +65,23 @@ def test_공용_고름과_같은_길() -> None: assert " onStructureSelect,\n onStructureMarkMove,\n" in view # 알약 콜백 = 같은 함수 assert "select: onStructureSelect," in view and "move: onStructureMarkMove," in view render = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Render.ts") - labels = render.index("drawContourLabels(context, scene, band, style);\n drawStructureMarks") + labels = render.index("drawContourLabels(context, scene, band, style);") nodes = render.index("scene.planned.forEach((vertex, index) => {") - assert labels < nodes # 라벨 위 · 교각점 아래 + hover = render.index("drawPickHover(context, scene, line, style, NODE_R);") + marks = render.index("drawStructureMarks(context, scene, line, style);") + turned = render.index("context.restore(); // 회전 끝") + assert labels < nodes < hover < marks < turned # 맨 위(409 — 교각점 · 손잡이 · 맴돔 위) -def test_집기_차례는_교각점_손잡이_다음() -> None: +def test_집기_차례는_재기_다음_맨_앞() -> None: pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + measure = pointer.index("void params.measure.pick(px, py);") + mark = pointer.index("if (marks.press(px, py)) {") + release = pointer.index("marks.deselect();") node = pointer.index("if (hitNode >= 0) {") handle = pointer.index("} else if (dragHandle) {") - mark = pointer.index("} else if (marks.press(px, py)) {") tick = pointer.index('if (!pick || !("chainage" in pick)) return false;') - assert node < handle < mark < tick + assert measure < mark < release < node < handle < tick # 409 def test_끌기_1m_정수_놓을_때_한_번(got) -> None: diff --git a/resources/tester/test_409_mark_top_deselect.py b/resources/tester/test_409_mark_top_deselect.py new file mode 100644 index 000000000..4eb802637 --- /dev/null +++ b/resources/tester/test_409_mark_top_deselect.py @@ -0,0 +1,76 @@ +"""409 B05 평면 구조물 마커 맨 위 · 다른 곳 누르면 고름 풀림(요구 252-4). + +그림 = 맨 마지막(교각점 · 손잡이 · 맴돔 위) · 누름 · 맴돔 · 커서 = 재기 다음 마커 먼저 · +마커 누르면 고른 교각점 풂 · +마커 아닌 곳 누름 · Esc = 공용 고름을 `b05-plan` 으로 비움(오른쪽 횡단 판 그대로). +TS 셈은 `helper_399_plan_structure_marks.cjs`(⑥ 고름 풀기). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_409_mark_top_deselect.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +HELPER = Path(__file__).with_name("helper_399_plan_structure_marks.cjs") + + +def _read(*parts: str) -> str: + return (ROOT.joinpath(*parts)).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_풀기_고른_것_없으면_아무_일_없음(got) -> None: + assert got["deselectNone"] == [False, 0] + + +def test_풀기_공용_고름을_b05_plan_으로_비움(got) -> None: + assert got["deselectPicked"] == [True, [None, "b05-plan"], None] + + +def test_Esc_마커_고름만_풂(got) -> None: + assert got["escapePicked"] == [[None, "b05-plan"], None, 1] # 관 고름도 · 공용 Esc 막음 + assert got["escapeNone"] == [0, 1] # 고른 것 없음 = 공용 Esc 몫 + + +def test_마커_누름은_그대로_고름(got) -> None: + assert got["pressAfterRelease"] == "A" + + +def test_누름_마커_먼저_교각점_풂() -> None: + pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + down = pointer[pointer.index("if (marks.press(px, py)) {") :] + branch = down[: down.index("marks.deselect();")] + assert "params.setPicked(-1);" in branch and "params.syncCurveBar();" in branch + assert "canvas.setPointerCapture(event.pointerId);\n return;" in branch + assert "else if (marks.press(" not in pointer # 옛 차례(교각점 · 손잡이 다음) 걷힘 + + +def test_맴돔_커서_마커_먼저() -> None: + pointer = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Pointer.ts") + over = pointer.index("const onMark = !measuring && marks.over(px, py);") + pick = pointer.index("const over = measuring || onMark ? null : pickAt(px, py);") + cursor = pointer.index("canvas.style.cursor = onMark") + assert over < pick < cursor + + +def test_오른쪽_판은_제_이름_알림을_거름() -> None: + cross = _read("B05_Profile", "B05_Profile_UI_RouteEdit_Cross.ts") + assert "if (from !== PLAN_PICK_FROM) follow(selection);" in cross + marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") + assert "selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });" in marks diff --git a/resources/tester/test_411_a_cross_structure.py b/resources/tester/test_411_a_cross_structure.py new file mode 100644 index 000000000..f8abb0637 --- /dev/null +++ b/resources/tester/test_411_a_cross_structure.py @@ -0,0 +1,58 @@ +"""411-A B05 평면 오른쪽 횡단 판 — 구조물 · 관을 골라도 그 측점 횡단(요구 252-6) · +옮긴 뒤에도 그 측점. + +TS 셈은 `helper_411_a_cross_structure.cjs`(Node · `_RouteEdit_Cross` 진짜 · 묶음 · 그림 가짜). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_411_a_cross_structure.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +HELPER = Path(__file__).with_name("helper_411_a_cross_structure.cjs") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_구조물_고름_그_측점_횡단(got) -> None: + assert got["structurePick"] == {"station": "45", "foot": "횡단 45", "structure": True} + + +def test_옮긴_뒤_열쇠로_새_자리(got) -> None: + assert got["structureMoved"] == {"station": "52", "foot": "횡단 52", "at": 52} + + +def test_관_고름_그_측점_횡단(got) -> None: + assert got["pipePick"] == {"station": "80.17", "foot": "횡단 80.17", "structure": True} + + +def test_평면_마커_풂은_판_그대로(got) -> None: + assert got["planRelease"] == {"station": "80.17", "foot": "횡단 80.17"} + + +def test_일반측점_그대로(got) -> None: + assert got["gridPick"] == {"station": "20", "foot": "횡단 20", "structure": None} + + +def test_묶음_전_고름도_열쇠로_붙듦(got) -> None: + assert got["beforePipeline"]["foot"] == "계산 중…" + assert got["afterPipeline"] == {"station": "60", "foot": "횡단 60"} + assert got["latchedMoved"] == {"station": "66", "foot": "횡단 66"} + + +def test_다른_보기_풂은_판_비움(got) -> None: + assert got["otherRelease"]["station"] == "" and got["otherRelease"]["watched"] is None From fe893cfb8e4441f27e9f17f787d5adcaeaa6de28 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:13:52 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat(B05=20=EC=B8=A1=EC=A0=90):=20?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EC=B8=A1=EC=A0=90=20=E2=80=94=20MC=20?= =?UTF-8?q?=EC=97=90=20=EC=B8=A1=EC=A0=90=EC=9D=B4=20=EC=97=86=EC=9C=BC?= =?UTF-8?q?=EB=A9=B4=20=EA=B3=A1=EC=84=A0=20=EC=B8=A1=EC=A0=90=20=ED=95=98?= =?UTF-8?q?=EB=82=98=20=EB=8D=94=ED=95=A8=20(407-1=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20252-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버 generate_sections ↔ TS buildStations 짝 · curve_stations ↔ curveStations 짝 · 종류 curve · 열쇠 curve:<누가거리> · 이름 뒤 (MC) - 값 = 스냅 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 · 스냅 끔 = 정수) · 0.005 m 안 측점(일반 · 옮긴 · 관 · 구조물) 있으면 안 더함 · BC · EC 안 더함 · 224-2 옮김 그대로 - 격자 번호 안 가져감 · 노선 저장 이월 열쇠 · B06 종류(curve) · 종단 선 색 · 카드 · 종단 이름 (MC) · 평면 눈금 (MC) · 오른쪽 횡단 일반측점 갈래 - 시험: test_407_curve_station 새로 · 344_3 둘 · 224_2 · 346_1 헬퍼 저장 노선 곡선표 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- .../design/logic/design_pipeline_types.ts | 4 +- .../B04_PreProcess_UI_MapOverlays.ts | 7 +- .../B05_Profile_Engine_Sections_Core.py | 33 +++- B05_Profile/B05_Profile_Route_Save_Parts.py | 8 +- B05_Profile/B05_Profile_UI_RouteEdit.ts | 2 +- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 4 +- .../B05_Profile_UI_RouteEdit_Render.ts | 3 + .../B05_Profile_UI_RouteEdit_SnapStep.ts | 33 +++- .../logic/B05_Profile_Pipeline_Detail.ts | 4 +- .../logic/B05_Profile_Sections_Stations.ts | 20 +- .../logic/B05_Profile_UI_RouteEdit_Frame.ts | 21 +++ B06_Section/B06_Section_Api_Types.ts | 4 +- .../B06_Section_UI_Cross_Card_Chrome.ts | 3 +- B06_Section/B06_Section_UI_Longitudinal.ts | 16 +- B06_Section/B06_Section_UI_Style_Cross.css | 3 +- B06_Section/logic/B06_Section_Station_Swap.ts | 1 + common_util/common_util_station_key.py | 5 +- common_util/common_util_station_key.ts | 6 +- .../tester/helper_344_3_pipeline_stations.cjs | 1 + .../tester/helper_346_1_pipeline_page.cjs | 6 +- resources/tester/test_224_2_station_snap.py | 10 +- .../test_344_3_pipeline_stations_mirror.py | 8 +- .../tester/test_344_3_station_key_mirror.py | 3 + resources/tester/test_407_curve_station.py | 173 ++++++++++++++++++ 24 files changed, 339 insertions(+), 39 deletions(-) create mode 100644 resources/tester/test_407_curve_station.py diff --git a/A00_Common/design/logic/design_pipeline_types.ts b/A00_Common/design/logic/design_pipeline_types.ts index dae2d46c4..aa8b9045b 100644 --- a/A00_Common/design/logic/design_pipeline_types.ts +++ b/A00_Common/design/logic/design_pipeline_types.ts @@ -152,8 +152,8 @@ export interface FollowPatch { alignment?: AlignmentEdits; } -/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점). */ -export type StationKind = "grid" | "structure" | "pipe" | "end"; +/** 측점 종류 — 격자(간격) · 구조물 · 관 · 끝(시점 · 종점) · 곡선(MC · 252-2 · 격자 번호 없음). */ +export type StationKind = "grid" | "structure" | "pipe" | "end" | "curve"; /** 횡단 지반 표본 한 점 — 중심에서 거리(m · 좌 +) · 높이(null = 빈칸). */ export interface GroundSample { diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 8cbc5c527..2f86f503d 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -214,6 +214,8 @@ export interface StationTickOptions { /** 그릴 측점 누가거리(오름차순) — 안 주면 간격마다(B05 편집 창 측점 스냅 · 요구 224-2). * 주면 라벨은 그 값 자릿수로(소수 한 자리까지 · 스냅 간격 0.1 의 MC · 390-5) · 안 주면 정수. */ chainages?: ReadonlyArray; + /** 곡선 측점(MC · B05 요구 252-2) 누가거리 — 그 눈금 이름 뒤 「 (MC)」 · 늘 그림(솎지 않음). */ + mcChainages?: ReadonlyArray; } /** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */ @@ -260,8 +262,9 @@ export function drawStationTicks( if (options.chainages) marks.push(...options.chainages.filter((value) => value <= total)); else for (let chainage = 0; chainage <= total; chainage += interval) marks.push(chainage); for (const chainage of marks) { + const mc = options.mcChainages?.includes(chainage) ?? false; const stationNo = Math.round(chainage / interval); - if (stationNo % step !== 0) continue; + if (stationNo % step !== 0 && !mc) continue; while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; const back = points[cursor - 1]; const front = points[cursor]; @@ -288,7 +291,7 @@ export function drawStationTicks( context.strokeStyle = "rgba(40, 40, 40, 0.85)"; context.stroke(); - const label = stationLabel(chainage, interval, options.chainages ? 1 : 0); + const label = stationLabel(chainage, interval, options.chainages ? 1 : 0) + (mc ? " (MC)" : ""); const width = context.measureText(label).width + 6; const half = width / 2; // 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를 diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index 87d7080f1..cdeedbc5e 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -11,7 +11,7 @@ `snapCurveStations` · `mcScale`. 거울 시험 `resources/tester/test_224_2_station_snap.py` · 간격별 MC 값 `test_390_5_snap_step_ops.py`. ⚠ 파이썬·TS 짝 — `generate_sections` 지반 부분 ↔ `B05_Profile/logic/B05_Profile_Sections_Ground.ts`. -⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 측점 병합 · 종류 · 방위각) · +⚠ 파이썬·TS 짝 — `generate_sections` 측점 부분(격자 · 스냅 · 구조물 · 곡선 측점 · 종류 · 방위각) · `_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `B05_Profile/logic/B05_Profile_Sections_Stations.ts` (`buildStations`). 거울 시험 `resources/tester/test_344_3_pipeline_stations_mirror.py`. ⚠ 파이썬·TS 짝 — `generate_sections` 상단측(`uphill_side`) ↔ `B05_Profile/logic/B05_Profile_Pipeline_Detail.ts` @@ -324,6 +324,27 @@ def snap_curve_stations( return np.unique(np.asarray(values, dtype=np.float64)) +def curve_stations( + stations: np.ndarray | list[float], + spans: list[tuple[float, ...]], + total: float, + step_m: float | None = None, +) -> list[float]: + """곡선 측점(요구 252-2) — MC(floor(MC·s+0.5)/s · s = `_mc_scale(step_m)`)에 측점이 + 없으면 그 값 · 0.005 m 안 측점(일반 · 스냅으로 옮긴 · 관 · 구조물)이 있거나 노선 밖이면 뺌 · + BC · EC 는 안 더함. `curveStations` 짝.""" + scale = _mc_scale(step_m) + taken = [float(value) for value in stations] + out: list[float] = [] + for span in spans: + value = math.floor((float(span[0]) + float(span[1])) / 2.0 * scale + 0.5) / scale + if not 0.0 < value < total or any(abs(value - other) < 0.005 for other in taken): + continue + taken.append(value) + out.append(value) + return sorted(out) + + def _design_curve_widenings( station_chainage: np.ndarray, spans: list[tuple[float, float, float, str]], @@ -375,6 +396,7 @@ def generate_sections( `station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) · `station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.). + MC 에 측점이 없으면 곡선 측점(kind `curve` · `curve_stations` · 요구 252-2) 하나를 더함. """ options = options or SectionGenerationOptions() options.validate() @@ -413,6 +435,11 @@ def generate_sections( station_chainage = np.unique( np.r_[station_chainage, np.array(sorted(extras), dtype=np.float64)] ) + # 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m) + step = station_snap_step if station_snap else None + curves = {round(value, 6) for value in curve_stations(station_chainage, spans, total, step)} + if curves: + station_chainage = np.unique(np.r_[station_chainage, np.array(sorted(curves))]) station_xy = _interpolate_xy(points, route_chainage, station_chainage) tangents = np.vstack( [ @@ -477,6 +504,8 @@ def generate_sections( # 격자 자리는 `grid` 로 남겨 구조물이 떠나면 일반 측점으로 되돌림 # (`B05_Profile_Router_Confirm._merge_irregular_into_longitudinal`). kind = "irregular" + elif rounded in curves: + kind = "curve" # 곡선 측점(252-2) — 격자 번호 없음 · 열쇠 `curve:<누가거리>` else: kind = "regular" center_index = int(np.argmin(np.abs(offsets))) @@ -509,7 +538,7 @@ def generate_sections( station = { "station_id": station_id, "chainage_m": round(float(value), 6), - "label": format_station(float(value)), + "label": format_station(float(value)) + (" (MC)" if kind == "curve" else ""), "kind": kind, "center_x": round(float(station_xy[index, 0]), 6), "center_y": round(float(station_xy[index, 1]), 6), diff --git a/B05_Profile/B05_Profile_Route_Save_Parts.py b/B05_Profile/B05_Profile_Route_Save_Parts.py index c4f37273d..01422067f 100644 --- a/B05_Profile/B05_Profile_Route_Save_Parts.py +++ b/B05_Profile/B05_Profile_Route_Save_Parts.py @@ -117,16 +117,20 @@ def station_keys( ) -> dict[str, float]: """측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리. - 관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` · + 관 · 구조물 자리 > 곡선 측점(`curve:` · 252-2) > 시종점 > 일반 격자(간격 배수인 것만) · + id 없는 관 · 구조물은 `at:` · 같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음). """ owners = _facility_owners(pipes, structures) - values = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None] + rows = [row for row in stations if row.get("chainage_m") is not None] + values = [float(row["chainage_m"]) for row in rows] out: dict[str, float] = {} for index, chainage in enumerate(values): owner = owners.get(round(chainage, 2)) if owner is not None: key = station_key(owner[0], chainage, interval_m, owner[1]) + elif rows[index].get("kind") == "curve": + key = station_key("curve", chainage, interval_m) elif index == 0: key = station_key("bp", chainage, interval_m) elif index == len(values) - 1: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index a8339783d..ed71d530d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -348,7 +348,7 @@ export async function mountRouteEditor( locked: apexLock, picked, stationIntervalM, - stations: stations(), + ...snap.ticks(plannedLine.length ? plannedLine : planned, curveInfo), }); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index d693bcd98..4fb0ece4a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -81,9 +81,9 @@ export interface CrossWatch { /** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */ export const PLAN_PICK_FROM = "b05-plan"; -/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 포함). */ +/** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 · 곡선 측점 포함). */ const isGridStation = (station: PipelineStation): boolean => - station.kind === "grid" || station.kind === "end"; + station.kind === "grid" || station.kind === "end" || station.kind === "curve"; export interface CrossPreviewWindow { /** 그 측점의 횡단을 위 판에 낸다. 아래 판은 비운다 — 전후 비교는 노선을 고쳤을 때만. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 7c69b1794..ccb51d75e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -98,6 +98,8 @@ export interface RouteEditScene { stationIntervalM: number; /** 그릴 측점 누가거리 — 끔이면 null(간격마다 · 측점 스냅 요구 224-2). */ stations: ReadonlyArray | null; + /** 곡선 측점(MC · 252-2) 누가거리 — 눈금 이름 뒤 「(MC)」. */ + curveStations?: ReadonlyArray; /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */ measure: ReadonlyArray; /** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */ @@ -484,6 +486,7 @@ function drawStationMarks( uprightRad: scene.uprightRad, onLabel: (box) => labels.push(box), chainages: scene.stations ?? undefined, + mcChainages: scene.curveStations, }, ); } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts b/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts index 8ca3e6c87..cef28847c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_SnapStep.ts @@ -8,6 +8,7 @@ * 저장 노선이 스냅 켬이면 열 때 그 간격으로 고르기를 맞춤. 추천 · 서버 초기 노선은 측점 No. 그대로. * 스냅 끔이면 고르기는 흐림(CSS) · 고를 수는 있음. * 상태줄 글은 고른 간격 따라(390-4) — 측점 No. = 「일반측점에」 · 그 밖 = 「1 m 배수에」 꼴. + * 평면 눈금 = 일반측점(스냅 켬이면 옮긴 자리) + 곡선 측점(MC 에 측점 없을 때 · 252-2 · `ticks`). * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; @@ -16,6 +17,7 @@ import type { RouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; import type { EditedCurve, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; import { curveSpans, + curveStations, snapCurveStations, stationsAlong, } from "./logic/B05_Profile_UI_RouteEdit_Frame"; @@ -52,6 +54,11 @@ export type SnapStep = { changed: () => boolean; /** 편집 창 측점 — 끔이면 null · 켬이면 호 안 하나를 MC 로(자릿수 = 간격 · 셈은 서버 짝 `_Frame`). */ stations: (line: ReadonlyArray, curves: ReadonlyArray) => number[] | null; + /** 평면 눈금 — `stations` + 곡선 측점(252-2) · 곡선 측점이 없으면 `stations` 그대로. */ + ticks: ( + line: ReadonlyArray, + curves: ReadonlyArray, + ) => { stations: number[] | null; curveStations: number[] }; }; /** 단추 · 고르기 배선 — `onChange(true)` = 단추 누름(켬끔 바꿈) · `onChange(false)` = 간격 바꿈. */ @@ -73,6 +80,14 @@ export function bindStationSnap( const step = (): number | null => (select.value === "station" ? null : Number(select.value)); const draft = (): SnapSetting => ({ stationSnap: on(), stationSnapStep: on() ? step() : null }); let loaded: SnapSetting = { stationSnap: false, stationSnapStep: null }; + const stations = (line: ReadonlyArray, curves: ReadonlyArray) => + on() + ? snapCurveStations( + stationsAlong(line, stationIntervalM, false), + curveSpans(line, curves), + step(), + ) + : null; const stepM = () => step() ?? stationIntervalM; return Object.assign(stepM, { text: () => snapFitText(select.value), @@ -90,13 +105,15 @@ export function bindStationSnap( now.stationSnap !== loaded.stationSnap || now.stationSnapStep !== loaded.stationSnapStep ); }, - stations: (line: ReadonlyArray, curves: ReadonlyArray) => - on() - ? snapCurveStations( - stationsAlong(line, stationIntervalM, false), - curveSpans(line, curves), - step(), - ) - : null, + stations, + ticks: (line: ReadonlyArray, curves: ReadonlyArray) => { + const base = stations(line, curves) ?? stationsAlong(line, stationIntervalM, false); + // 노선 길이 = 무한 — 호(BC~EC)는 늘 노선 안 + const curve = curveStations(base, curveSpans(line, curves), Infinity, on() ? step() : null); + return { + stations: curve.length ? [...base, ...curve].sort((a, b) => a - b) : stations(line, curves), + curveStations: curve, + }; + }, }); } diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts index 3e33fdf8b..46372b979 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Detail.ts @@ -121,7 +121,9 @@ const kindOf = (station: PipelineStation): SectionStation["kind"] => ? "ep" : station.kind === "grid" ? "regular" - : "irregular"; + : station.kind === "curve" + ? "curve" + : "irregular"; /** 제원의 누가거리 = 새 측점(서버 부착은 따라간 관 · 구조물 자리 = 그 측점) — 옛 값이면 구조물 면적이 관 자리를 잘못 잡음(349-0n). */ function atChainage(own: SectionSpecs, chainageM: number): SectionSpecs { diff --git a/B05_Profile/logic/B05_Profile_Sections_Stations.ts b/B05_Profile/logic/B05_Profile_Sections_Stations.ts index c9b28861c..ff215d4ff 100644 --- a/B05_Profile/logic/B05_Profile_Sections_Stations.ts +++ b/B05_Profile/logic/B05_Profile_Sections_Stations.ts @@ -5,7 +5,7 @@ * 계약 = `A00_Common/design/logic/design_pipeline_types.ts` 머리). * * ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` `generate_sections` 의 측점 부분 - * (격자 · 스냅 · 구조물 측점 병합 · 종류 · 틀 · 방위각) ↔ `buildStations` · + * (격자 · 스냅 · 구조물 측점 병합 · 곡선 측점 · 종류 · 틀 · 방위각) ↔ `buildStations` · * `_plan_radii` · `_curve_widenings` · `_design_curve_widenings` ↔ `planRadii` · `curveWidenings` · * `designCurveWidenings` · `curve_widening_m` · `CURVE_WIDENING_TABLE_M` 등(config_system_design) ↔ * 같은 이름 상수 · `circumradius_2d`(Engine_Geometry) ↔ `circumradius`. @@ -17,6 +17,7 @@ import { curveSpans, + curveStations, snapCurveStations, stationFrameAt, stationsAlong, @@ -67,7 +68,8 @@ const round = (value: number, digits: number): number => Number(value.toFixed(di export const stationText = (chainageM: number): string => chainageM.toFixed(2); /** 측점 열쇠 — 격자 = `grid:k`(k = 원래 격자 번호 · 노선 모양이 바뀌어도 같은 번호) · 관 = `pipe:` · - * 구조물 = `structure:`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP`. + * 구조물 = `structure:`(구간형 시작 · 끝은 `:start` · `:end`) · 시종점 = `end:BP` · `end:EP` · + * 곡선 = `curve:<누가거리>`(ref = 누가거리). * 규칙 한 벌 = `common_util_station_key`(파이썬 짝) — 여기는 꼴만 옮김. */ export function stationKey( kind: StationKind, @@ -76,6 +78,7 @@ export function stationKey( ): string { if (kind === "end") return commonStationKey(ref === "BP" ? "bp" : "ep", 0, null); if (kind === "grid") return commonStationKey("regular", Number(ref), 1); // ref = 격자 번호(간격 1) + if (kind === "curve") return commonStationKey("curve", Number(ref), null); const base = commonStationKey(kind, 0, null, String(ref)); // ponytail: 구간형 꼬리 — sub2 가 공용 함수에 넣으면 그 인자로 옮김 return role === "anchor" ? base : `${base}:${role}`; @@ -308,7 +311,15 @@ export function buildStations( role: station.role, }); } - const chainages = [...new Set([...grid, ...planted.keys()])].sort((a, b) => a - b); + const withPlanted = [...new Set([...grid, ...planted.keys()])]; + // 곡선 측점(252-2) — MC 에 측점이 없으면 더함 · 값 자릿수 = 스냅 간격(스냅 끔 = 정수 m) · 격자 번호 없음 + const curved = curveStations( + withPlanted, + spans.map(([bc, ec]) => [bc, ec] as const), + total, + options.stationSnap ? options.stationSnapStep : null, + ); + const chainages = [...withPlanted, ...curved].sort((a, b) => a - b); const fromSpans = spans.length ? designCurveWidenings(chainages, spans) : null; const measured = fromSpans ? null : planRadii(line, chainages, total); @@ -329,6 +340,9 @@ export function buildStations( } else if (mark) { kind = mark.kind; key = stationKey(mark.kind, mark.id, mark.role as "start" | "anchor" | "end"); + } else if (curved.includes(chainage)) { + kind = "curve"; + key = stationKey("curve", chainage); } return { key, diff --git a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts index 5d33896c6..7d905afe3 100644 --- a/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts +++ b/B05_Profile/logic/B05_Profile_UI_RouteEdit_Frame.ts @@ -181,3 +181,24 @@ export function snapCurveStations( } return [...new Set(values)].sort((a, b) => a - b); } + +/** 곡선 측점(252-2) — MC(floor(MC·s+0.5)/s)에 측점이 없으면 그 값 · 0.005 m 안 측점(일반 · 옮긴 · 관 · 구조물)이 + * 있거나 노선 밖이면 뺌 · BC · EC 는 안 더함(오름차순). `curve_stations` 짝. */ +export function curveStations( + stations: ReadonlyArray, + spans: ReadonlyArray, + total: number, + stepM?: number | null, +): number[] { + const scale = mcScale(stepM); + const taken = [...stations]; + const out: number[] = []; + for (const [start, end] of spans) { + const value = Math.floor(((start + end) / 2) * scale + 0.5) / scale; + if (!(value > 0 && value < total) || taken.some((other) => Math.abs(value - other) < 0.005)) + continue; + taken.push(value); + out.push(value); + } + return out.sort((a, b) => a - b); +} diff --git a/B06_Section/B06_Section_Api_Types.ts b/B06_Section/B06_Section_Api_Types.ts index 927d12fcd..1f7f323f4 100644 --- a/B06_Section/B06_Section_Api_Types.ts +++ b/B06_Section/B06_Section_Api_Types.ts @@ -115,8 +115,8 @@ export interface SectionStation { station_id: string; chainage_m: number; label: string; - /** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속). */ - kind: "bp" | "ep" | "regular" | "irregular"; + /** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속) · curve = 곡선 측점(MC · 252-2). */ + kind: "bp" | "ep" | "regular" | "irregular" | "curve"; /** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */ structure?: string; /** 구조물 측점이 얹힌 격자 자리(같은 m 면 측점 하나 · 구조물이 위 · 272) — 구조물이 떠나면 일반 측점. */ diff --git a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts index 777744dba..118d43b52 100644 --- a/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts +++ b/B06_Section/B06_Section_UI_Cross_Card_Chrome.ts @@ -92,7 +92,8 @@ export function appendCardHeader( const title = document.createElement("div"); title.className = "b06-cross-card__title"; const label = document.createElement("strong"); - label.textContent = stationLabel(section.chainage_m, stationInterval); + label.textContent = + stationLabel(section.chainage_m, stationInterval) + (section.kind === "curve" ? " (MC)" : ""); const chainage = document.createElement("span"); chainage.textContent = `${section.chainage_m.toFixed(1)}m`; title.append(label, chainage); diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 19f1f1236..86335e0fc 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -428,12 +428,16 @@ export function createLongitudinalProfile( }), // 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점 // 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시). - svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), { - x: stationX, - y: heightPx - bottomInsetPx - 5, - "text-anchor": "middle", - class: "b06-chart__station-label", - }), + svgText( + offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset) + + (station.kind === "curve" ? " (MC)" : ""), + { + x: stationX, + y: heightPx - bottomInsetPx - 5, + "text-anchor": "middle", + class: "b06-chart__station-label", + }, + ), ); // 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다. // 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가 diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index 6ac2324fa..d463e6851 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -482,7 +482,8 @@ html.is-bm-route .b06-section__notice-bar { } .b06-chart__station-line--bp, -.b06-chart__station-line--regular { +.b06-chart__station-line--regular, +.b06-chart__station-line--curve { stroke: var(--color-warning); } diff --git a/B06_Section/logic/B06_Section_Station_Swap.ts b/B06_Section/logic/B06_Section_Station_Swap.ts index f32c8be10..8c007f05c 100644 --- a/B06_Section/logic/B06_Section_Station_Swap.ts +++ b/B06_Section/logic/B06_Section_Station_Swap.ts @@ -62,6 +62,7 @@ const freshDesign = () => function kindOf(station: PipelineStation): SectionStation["kind"] { if (station.kind === "end") return station.key.endsWith("EP") ? "ep" : "bp"; + if (station.kind === "curve") return "curve"; return station.kind === "grid" ? "regular" : "irregular"; } diff --git a/common_util/common_util_station_key.py b/common_util/common_util_station_key.py index 101121e31..f6c0194b9 100644 --- a/common_util/common_util_station_key.py +++ b/common_util/common_util_station_key.py @@ -1,7 +1,8 @@ """측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. 일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:`` · 구조물 = ``structure:`` · -시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``. +시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>`` · +곡선 측점(MC · 252-2) = ``curve:<누가거리 0.01 m>``. 구간형 구조물 시작 · 끝 측점 = ``structure::start`` · ``:end``(기준 측점은 ``structure:``). 한 목록에 같은 열쇠가 둘이면 둘째부터 ``at:<누가거리>``(``dedupe_station_keys``). @@ -50,6 +51,8 @@ def station_key( return ( f"end:{station_id if station_id is not None else ('BP' if chainage_m <= 0 else 'EP')}" ) + if kind == "curve": + return f"curve:{chainage_key(chainage_m)}" if kind in ("pipe", "structure"): return f"{kind}:{station_id}{suffix}" if station_id else loose if kind == "irregular": diff --git a/common_util/common_util_station_key.ts b/common_util/common_util_station_key.ts index ad6809f2f..afd0e4cd1 100644 --- a/common_util/common_util_station_key.ts +++ b/common_util/common_util_station_key.ts @@ -3,6 +3,7 @@ * 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. * 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:` · 구조물 = `structure:` · * 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫). + * 곡선 측점(MC · 252-2) = `curve:<누가거리 0.01 m>`. * 구간형 구조물의 시작 · 끝 측점 = `structure::start` · `:end`(기준 측점은 `structure:` 그대로). * 한 목록에 같은 열쇠가 둘이면 둘째부터 `at:<누가거리>`(`dedupeStationKeys`). * 쓰는 곳 = 서버 `B05_Profile_Route_Save_Parts` · 묶음 stations 단계 · B06 세션 횡단 초안(`stationKeysOf`). @@ -11,9 +12,9 @@ * 고치고 거울 시험 `resources/tester/test_344_3_station_key_mirror.py`. * ========================================================================== */ -/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular) · 묶음 측점 `kind`(grid · end · pipe · structure). */ +/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular · curve) · 묶음 측점 `kind`(grid · end · pipe · structure · curve). */ export type StationKeyKind = - "bp" | "ep" | "regular" | "irregular" | "grid" | "end" | "pipe" | "structure"; + "bp" | "ep" | "regular" | "irregular" | "grid" | "end" | "pipe" | "structure" | "curve"; /** 구간형 구조물 측점 자리 — 기준(anchor) · 시작 · 끝. */ export type StationKeyRole = "anchor" | "start" | "end"; @@ -40,6 +41,7 @@ export function stationKey( if (kind === "bp") return "end:BP"; if (kind === "ep") return "end:EP"; if (kind === "end") return `end:${id ?? (chainageM <= 0 ? "BP" : "EP")}`; + if (kind === "curve") return `curve:${chainageKey(chainageM)}`; if (kind === "pipe" || kind === "structure") return id ? `${kind}:${id}${suffix}` : loose; if (kind === "irregular") return id ? `${idKind}:${id}${suffix}` : loose; if (kind === "grid" && id !== null) return `grid:${id}`; diff --git a/resources/tester/helper_344_3_pipeline_stations.cjs b/resources/tester/helper_344_3_pipeline_stations.cjs index d5dd84ffd..45d4a74a5 100644 --- a/resources/tester/helper_344_3_pipeline_stations.cjs +++ b/resources/tester/helper_344_3_pipeline_stations.cjs @@ -46,6 +46,7 @@ const out = { return logic.buildStations(c.line, c.curves, job.pipes, job.structures, { types: job.types, stationSnap: c.snap, + stationSnapStep: c.step ?? null, // 390-5 · 252-2 곡선 측점 값 자릿수 }); }), text: [0, 12.345, 40, 699.996].map(logic.stationText), diff --git a/resources/tester/helper_346_1_pipeline_page.cjs b/resources/tester/helper_346_1_pipeline_page.cjs index 03151b31f..09ad8904d 100644 --- a/resources/tester/helper_346_1_pipeline_page.cjs +++ b/resources/tester/helper_346_1_pipeline_page.cjs @@ -116,7 +116,11 @@ function realStages(savedBase, remote = null) { return { route: routeGround.createRouteStage({ minRadiusM: () => job.minRadius, - savedLine: () => ({ line: job.line, curves: [] }), + // 저장 노선 곡선표 = 같은 노드의 편집 곡선(407 곡선 측점 · 빈 표면 옮긴 뒤에만 MC 측점이 생김) + savedLine: () => ({ + line: job.line, + curves: routeGround.draftLine(draftOf(job.nodes), job.minRadius).curves, + }), }), follow: grade.followStage, stations: stationsMod.createStationsStage({ types: () => job.types }), diff --git a/resources/tester/test_224_2_station_snap.py b/resources/tester/test_224_2_station_snap.py index dfb43a618..70cfa16b1 100644 --- a/resources/tester/test_224_2_station_snap.py +++ b/resources/tester/test_224_2_station_snap.py @@ -241,7 +241,15 @@ def test_켬은_640_없고_631_있음() -> None: assert 640.0 not in by_chainage and 631.0 in by_chainage assert by_chainage[631.0]["kind"] == "regular" assert by_chainage[631.0]["plan_radius_m"] == 40.0 # 호 안 = 곡선표 반경 - assert len(by_chainage) == len(_stations(False)) + # 끔 = 640 남고 MC(631)에 곡선 측점 하나 더(252-2) · 켬 = 640 을 631 로 옮김(측점 수 그대로) + assert len(by_chainage) == len(_stations(False)) - 1 + + +def test_끔은_MC_에_곡선_측점() -> None: + by_chainage = {s["chainage_m"]: s for s in _stations(False)} + assert by_chainage[640.0]["kind"] == "regular" + assert by_chainage[631.0]["kind"] == "curve" + assert by_chainage[631.0]["label"].endswith(" (MC)") def test_631_에_관이면_grid() -> None: diff --git a/resources/tester/test_344_3_pipeline_stations_mirror.py b/resources/tester/test_344_3_pipeline_stations_mirror.py index 60efa31d6..196fcfe58 100644 --- a/resources/tester/test_344_3_pipeline_stations_mirror.py +++ b/resources/tester/test_344_3_pipeline_stations_mirror.py @@ -192,7 +192,13 @@ def _close(a, b) -> bool: return abs(float(a) - float(b)) <= _TOL -_KIND = {"bp": {"end"}, "ep": {"end"}, "regular": {"grid"}, "irregular": {"pipe", "structure"}} +_KIND = { + "bp": {"end"}, + "ep": {"end"}, + "regular": {"grid"}, + "irregular": {"pipe", "structure"}, + "curve": {"curve"}, # 252-2 곡선 측점(MC) +} @pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES]) diff --git a/resources/tester/test_344_3_station_key_mirror.py b/resources/tester/test_344_3_station_key_mirror.py index 3b2587fb2..4f2316b76 100644 --- a/resources/tester/test_344_3_station_key_mirror.py +++ b/resources/tester/test_344_3_station_key_mirror.py @@ -47,6 +47,9 @@ CASES = [ ("irregular", 1.005, 20.0, None, "structure"), ("pipe", 12.344, 20.0, "pipe-s1", "structure"), ("structure", 12.345, 20.0, None, "structure"), + # 252-2 곡선 측점(MC) — curve:<누가거리 0.01 m> + ("curve", 107.0, 20.0, None, "structure"), + ("curve", 107.35, 20.0, None, "structure"), ] DEDUPE = (["grid:1", "structure:st-1", "grid:1", "structure:st-1"], [20.0, 25.0, 20.004, 25.006]) LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"] diff --git a/resources/tester/test_407_curve_station.py b/resources/tester/test_407_curve_station.py new file mode 100644 index 000000000..039f1aa8f --- /dev/null +++ b/resources/tester/test_407_curve_station.py @@ -0,0 +1,173 @@ +"""PLAN 407 · 요구 252-2 — 곡선 측점(MC 에 측점이 없으면 하나 더함). + +짝: 파이썬 `B05_Profile_Engine_Sections_Core.curve_stations` · `generate_sections` ↔ TS +`logic/B05_Profile_UI_RouteEdit_Frame.ts` `curveStations` · `logic/B05_Profile_Sections_Stations.ts` +`buildStations`(헬퍼 `helper_344_3_pipeline_stations.cjs`). 열쇠 `curve:<누가거리>` · +이름 뒤 「 (MC)」 · +값 = 스냅 간격 자릿수(0.1 = 소수 한 자리 · 그 밖 · 스냅 끔 = 정수) · 일반측점 값과 같으면 안 더함 · +스냅 켬 · 호 안 일반측점 = 224-2 대로 옮김(곡선 측점 없음) · BC · EC 안 더함. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +from pathlib import Path + +import numpy as np +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402 +from common_util.common_util_station_key import station_key # noqa: E402 + +LINE = [[0.0, 0.0], [1000.3, 0.0]] +LONG = {"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0} +SHORT = {"start": [101.0, 0.0], "apex": [107.3, 2.0], "end": [113.6, 0.0], "radius_m": 30.0} +ON_GRID = {"start": [195.0, 0.0], "apex": [200.1, 2.0], "end": [205.2, 0.0], "radius_m": 30.0} +CURVES = [LONG, SHORT, ON_GRID] +GRID = [float(value) for value in range(0, 1001, 20)] + + +class _Flat: + def sample_xy(self, xy): + count = len(np.asarray(xy, dtype=np.float64)) + return np.full(count, 100.0), np.ones(count, dtype=bool) + + +def _server(snap: bool, step: float | None = None, extras: tuple = ()) -> list[dict]: + data = core.generate_sections( + LINE, + _Flat(), + core.SectionGenerationOptions(extra_stations=extras), + design_curves=CURVES, + station_snap=snap, + station_snap_step=step, + ) + return data["longitudinal"]["stations"] + + +def _curves(stations: list[dict]) -> list[float]: + return [s["chainage_m"] for s in stations if s["kind"] == "curve"] + + +# ── 파이썬 규칙 ──────────────────────────────────────────────────────────────── + + +def test_정수_MC_하나() -> None: + assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3) == [107.0] + + +def test_스냅_간격_0_1_이면_소수_한_자리() -> None: + assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3, 0.1) == [107.3] + assert core.curve_stations(GRID, [(101.0, 113.6)], 1000.3, 10.0) == [107.0] + + +def test_일반측점_값이면_안_더함() -> None: + assert core.curve_stations(GRID, [(195.0, 205.2)], 1000.3) == [] + + +def test_MC_옆_관_측점이면_안_더함() -> None: + assert core.curve_stations([*GRID, 107.003], [(101.0, 113.6)], 1000.3) == [] + + +def test_노선_밖_MC_는_뺌() -> None: + assert core.curve_stations([0.0], [(995.0, 1010.0)], 1000.3) == [] # MC 1003 + + +def test_스냅_끔_MC_마다_곡선_측점() -> None: + stations = _server(False) + assert _curves(stations) == [107.0, 631.0] # 200 = 일반측점 위 → 안 더함 + by = {s["chainage_m"]: s for s in stations} + assert by[640.0]["kind"] == "regular" and by[200.0]["kind"] == "regular" + assert by[107.0]["label"] == "STA.0+107.000 (MC)" + assert len(stations) == len(_server(False, extras=())) == 51 + 1 + 2 # 격자 · EP · MC 둘 + assert 101.0 not in by and 113.6 not in by # BC · EC 안 더함 + + +def test_스냅_켬_짧은_호만_곡선_측점() -> None: + stations = _server(True) + by = {s["chainage_m"]: s for s in stations} + assert _curves(stations) == [107.0] + assert by[631.0]["kind"] == "regular" and 640.0 not in by # 224-2 옮김 그대로 + + +def test_스냅_켬_0_1_간격() -> None: + stations = _server(True, 0.1) + by = {s["chainage_m"]: s for s in stations} + assert _curves(stations) == [107.3] + assert 631.3 in by and by[200.1]["kind"] == "regular" # 옮긴 일반측점 + + +def test_MC_에_구조물이면_구조물_측점_하나() -> None: + stations = _server(False, extras=((107.0, "관"),)) + by = {s["chainage_m"]: s for s in stations} + assert by[107.0]["kind"] == "irregular" and _curves(stations) == [631.0] + + +def test_열쇠() -> None: + assert station_key("curve", 107.0, 20.0) == "curve:107.00" + assert station_key("curve", 107.3, 20.0) == "curve:107.30" + + +# ── 파이썬 ↔ TS 같은 목록 ───────────────────────────────────────────────────── + +CASES = [ + {"name": "끔", "snap": False, "step": None}, + {"name": "켬", "snap": True, "step": None}, + {"name": "켬0.1", "snap": True, "step": 0.1}, + {"name": "끔0.1", "snap": False, "step": 0.1}, # 끔이면 간격 무시 = 정수 +] + + +@pytest.fixture(scope="module") +def ts_rows() -> list[list[dict]]: + job = { + "cases": [{"line": LINE, "curves": CURVES, **case} for case in CASES], + "pipes": [], + "structures": [], + "types": [], + "move": { + "line": LINE, + "curves": CURVES, + "movedLine": LINE, + "movedCurves": CURVES, + "fromM": 0.0, + }, + } + with tempfile.TemporaryDirectory() as folder: + path = Path(folder) / "job.json" + path.write_text(json.dumps(job), encoding="utf-8") + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_344_3_pipeline_stations.cjs"), str(path)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout)["cases"] + + +@pytest.mark.parametrize("index", range(len(CASES)), ids=[c["name"] for c in CASES]) +def test_파이썬과_TS_같은_목록(ts_rows, index) -> None: + case = CASES[index] + server = _server(case["snap"], case["step"]) + ours = ts_rows[index] + assert [s["chainage_m"] for s in server] == pytest.approx( + [s["chainageM"] for s in ours], abs=1e-6 + ) + for py, ts in zip(server, ours, strict=True): + assert (py["kind"] == "curve") == (ts["kind"] == "curve"), py["chainage_m"] + if ts["kind"] == "curve": + assert ts["key"] == station_key("curve", py["chainage_m"], 20.0) + assert any(s["kind"] == "curve" for s in ours) + # 곡선 측점은 격자 번호를 안 가져감 — 격자 열쇠는 그대로 1 ~ 50 하나씩(0 = BP) + grid = sorted(int(s["key"][5:]) for s in ours if s["kind"] == "grid") + assert grid == list(range(1, 51)) From f91c59a2f3c705d48d6b9f2a1751a51b175387c7 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:19:22 +0900 Subject: [PATCH 06/17] =?UTF-8?q?fix(B05=20=ED=8F=89=EB=A9=B4=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=AC=BC=20=EB=A7=88=EC=BB=A4):=20=EB=A7=88=EC=BB=A4?= =?UTF-8?q?=20=EA=B3=A0=EB=A6=84=20=ED=92=82=20=3D=20=EC=A2=8C=EC=B8=A1=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=20=EA=B0=95=EC=A1=B0=20=C2=B7=20=ED=8F=BC?= =?UTF-8?q?=EB=8F=84=20=ED=92=82=20(409-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 평면 고름 이름 PLAN_PICK_FROM 을 design_pick_escape 로 옮김(Cross · 마커가 같이 씀) - 종단 고름 구독 = Esc 처럼 b05-plan 풂(값 없음)이면 폼 닫음 · 패널 접힘 · 펼침 그대로 · 오른쪽 판 그대로 - 시험 409 에 409-2 더함(새 helper_409_2) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XWh5wNNycMkbwMpACz1rte --- A00_Common/design/design_pick_escape.ts | 4 + A00_Common/design/design_pick_profile.ts | 10 +- B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts | 4 +- ...B05_Profile_UI_RouteEdit_StructureMarks.ts | 5 +- .../helper_399_plan_structure_marks.cjs | 2 +- .../tester/helper_409_2_plan_release_form.cjs | 102 ++++++++++++++++++ .../tester/helper_411_a_cross_structure.cjs | 1 + .../tester/test_409_mark_top_deselect.py | 46 ++++++++ 8 files changed, 164 insertions(+), 10 deletions(-) create mode 100644 resources/tester/helper_409_2_plan_release_form.cjs diff --git a/A00_Common/design/design_pick_escape.ts b/A00_Common/design/design_pick_escape.ts index 691c328e5..c7ccca581 100644 --- a/A00_Common/design/design_pick_escape.ts +++ b/A00_Common/design/design_pick_escape.ts @@ -12,6 +12,10 @@ import { NO_SELECTION, selectDesign } from "./design_selection"; /** Esc 로 푼 고름의 출처 이름 — 구독자가 폼까지 닫을지 가르는 데 씀. */ export const ESCAPE_FROM = "esc"; +/** 평면 노선 편집 창이 낸 고름(눈금 클릭 · 구조물 마커 고름 풂) — 오른쪽 횡단 판은 제 것이라 거름 · + * 풂(값 없음)이면 Esc 처럼 좌측 폼까지 닫음(409-2). */ +export const PLAN_PICK_FROM = "b05-plan"; + const OWN_ESCAPE = "input, select, textarea, [contenteditable='true'], [role='dialog'], .ui-modal__backdrop"; diff --git a/A00_Common/design/design_pick_profile.ts b/A00_Common/design/design_pick_profile.ts index 795dea194..a7a1454a8 100644 --- a/A00_Common/design/design_pick_profile.ts +++ b/A00_Common/design/design_pick_profile.ts @@ -15,7 +15,7 @@ import { subscribeSelection, type DesignSelection, } from "./design_selection"; -import { bindEscapeRelease, ESCAPE_FROM } from "./design_pick_escape"; +import { bindEscapeRelease, ESCAPE_FROM, PLAN_PICK_FROM } from "./design_pick_escape"; import { pipeIdAt } from "./design_pipe_ids"; import { graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection"; import { irregularStationId } from "../../B05_Profile/B05_Profile_UI_IrregularStations"; @@ -149,10 +149,12 @@ export function bindProfilePick( if (target.profile.drainage) paintMapPick(target.profile.drainage, selection); }; host.onDispose( - subscribeSelection(projectId, (_selection, from) => { + subscribeSelection(projectId, (selection, from) => { paint(); - if (from === ESCAPE_FROM && host.visible() && target.form?.hasSelection()) - target.form.selectById(null); + // 풀기 = Esc · 평면 마커 고름 풂(409-2) — 폼 닫기는 펴지 않음(좌측 패널 접힘 · 펼침 그대로 · 398-1) + const release = + from === ESCAPE_FROM || (from === PLAN_PICK_FROM && selection.chainageM === null); + if (release && host.visible() && target.form?.hasSelection()) target.form.selectById(null); }), ); bindEscapeRelease(projectId, host); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 6479e1049..177be2ab0 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -35,6 +35,7 @@ import { type DesignSelection, } from "../A00_Common/design/design_selection"; import { useDesignStore } from "../A00_Common/design/design_store"; +import { PLAN_PICK_FROM } from "../A00_Common/design/design_pick_escape"; import { stationSection } from "./logic/B05_Profile_Pipeline_Detail"; import { buildCrossSvg, @@ -79,9 +80,6 @@ export interface CrossWatch { structure?: true; } -/** 공용 고름에 적는 이름 — 평면 눈금 클릭(제 알림은 거름). */ -export const PLAN_PICK_FROM = "b05-plan"; - /** 법선을 긋는 측점 갈래 — 일반측점(시점 · 종점 · 곡선 측점 포함). */ const isGridStation = (station: PipelineStation): boolean => station.kind === "grid" || station.kind === "end" || station.kind === "curve"; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts index f316717ab..6c82835d8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_StructureMarks.ts @@ -9,7 +9,8 @@ * · 끌기 · 방향키(마커 고른 때 · 교각점 안 고른 때) = 노선 따라 정수 m · 1 m 씩 · 시점 · 종점 밖 안 감 · * 놓을 때 · 뗄 때 한 번 옮김(`move` = 알약 끌기와 같은 길 · 관 · 구조물 초안 · [저장]이 정본). * · 그림 · 집기 차례 = 맨 위 · 맨 앞(409 · 재기 다음) — `_Render` · `_Pointer` 가 부름. - * · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로). + * · 고름 풀기 = 마커 아닌 곳 누름 · Esc(409) — 공용 고름을 `PLAN_PICK_FROM` 으로 비움(오른쪽 횡단 판 그대로 · + * 좌측 목록 강조 · 폼은 풂 409-2). * 목록 · 고름 · 옮김은 종단 보기가 `setPlanMarkSource` 로 건넴. 걸음 셈은 `logic/…_StructureMarks`. * ========================================================================== */ @@ -38,7 +39,7 @@ import { HOLD_DELAY_MS, HOLD_INTERVAL_MS } from "./B05_Profile_UI_Profile_Edit"; import { drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover"; import { routePointAtScreen } from "./B05_Profile_UI_RouteEdit_Input"; import type { RouteEditPointerParams } from "./B05_Profile_UI_RouteEdit_Pointer"; -import { PLAN_PICK_FROM } from "./B05_Profile_UI_RouteEdit_Cross"; +import { PLAN_PICK_FROM } from "../A00_Common/design/design_pick_escape"; import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame"; import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; diff --git a/resources/tester/helper_399_plan_structure_marks.cjs b/resources/tester/helper_399_plan_structure_marks.cjs index 12a2a3e16..a33ee9a40 100644 --- a/resources/tester/helper_399_plan_structure_marks.cjs +++ b/resources/tester/helper_399_plan_structure_marks.cjs @@ -68,7 +68,7 @@ const STUBS = { selection = value.chainageM === null ? null : value; }, }, - "./B05_Profile_UI_RouteEdit_Cross": { PLAN_PICK_FROM: "b05-plan" }, + "../A00_Common/design/design_pick_escape": { PLAN_PICK_FROM: "b05-plan" }, "../B06_Section/B06_Section_UI_Cross_Panel_Base": { lastPointerWithin: () => true }, "./B05_Profile_UI_Profile_Edit": { HOLD_DELAY_MS: 500, HOLD_INTERVAL_MS: 100 }, "./B05_Profile_Api_Structures": { diff --git a/resources/tester/helper_409_2_plan_release_form.cjs b/resources/tester/helper_409_2_plan_release_form.cjs new file mode 100644 index 000000000..00f71f6ff --- /dev/null +++ b/resources/tester/helper_409_2_plan_release_form.cjs @@ -0,0 +1,102 @@ +/* 409-2 평면 마커 고름 풂(b05-plan · 값 없음) = 좌측 폼 · 목록 강조도 풂 — 진짜 `design_selection` · + * `design_pick_escape` · `design_pick_profile` 을 Node 로 돌림. 종단도 · 폼 · 오른쪽 횡단 판은 흉내. + * 표준출력 JSON 한 벌 · 짝 = test_409_mark_top_deselect.py. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request}`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +globalThis.window = { addEventListener() {}, removeEventListener() {} }; +globalThis.document = { querySelector: () => null }; + +const store = new Map(); +const DSel = loadTs("A00_Common/design/design_selection.ts", { + "../b_page_state": { + readState: (key, pid) => store.get(`${key}:${pid}`) ?? null, + writeState: (key, value, pid) => store.set(`${key}:${pid}`, value), + }, +}); +const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel }); +const IRR = { irregularStationId: (id) => `irregular:${id}`, isPipeStation: () => true }; +const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", { + "../A00_Common/design/design_selection": DSel, + "./B05_Profile_UI_IrregularStations": IRR, +}); +const PP = loadTs("A00_Common/design/design_pick_profile.ts", { + "./design_selection": DSel, + "./design_pipe_ids": { pipeIdAt: (at) => `pipe-${at.toFixed(2)}` }, + "./design_pick_escape": ESC, + "../../B05_Profile/B05_Profile_UI_Selection": SY, + "../../B05_Profile/B05_Profile_UI_IrregularStations": IRR, +}); + +const PID = "p409"; +const sel = (chainageM, structureId, from) => + DSel.selectDesign(PID, chainageM === null ? DSel.NO_SELECTION : { chainageM, structureId }, { + from, + }); + +/* 폼 — 관 폼을 닫으면 진짜 폼처럼 관 고름도 비움(`resetForm` → onPipeSelect(null) → b05 풂) */ +const form = { open: null, resets: 0 }; +const profile = { structure: null }; +PP.bindProfilePick( + PID, + { + profile: { + setSelectedStation: () => (profile.structure = null), + setSelectedStructure: (id) => (profile.structure = id), + }, + form: { + hasSelection: () => form.open !== null, + selectById: () => { + const wasPipe = form.open === "pipe"; + form.open = null; + form.resets += 1; + if (wasPipe) sel(null, null, "b05"); + }, + }, + }, + { visible: () => true, onDispose() {} }, + { irregular: () => [], pipes: () => [{ chainage_m: 80.17 }], sections: () => [] }, +); +/* 오른쪽 횡단 판 흉내 — 받은 알림(낸 쪽) */ +const crossSaw = []; +DSel.subscribeSelection(PID, (selection, from) => crossSaw.push([selection.chainageM, from])); + +const out = {}; +// 관 마커 고름 → 폼 열림 → 평면 마커 고름 풂 +sel(80.17, null, "b05"); +form.open = "pipe"; +crossSaw.length = 0; +sel(null, null, ESC.PLAN_PICK_FROM); +out.pipeRelease = { form: { ...form }, crossSaw: [...crossSaw], session: DSel.readSelection(PID) }; +// 구조물 고름 → 폼 → 풂 +sel(40, "A", "b05"); +form.open = "A"; +sel(null, null, ESC.PLAN_PICK_FROM); +out.structureRelease = { form: { ...form }, pill: profile.structure }; +// 평면 눈금 클릭(값 있음 · b05-plan) = 폼 안 닫음(옛 그대로) +form.open = "A"; +sel(20, null, ESC.PLAN_PICK_FROM); +out.tickKeepsForm = form.open; +// 폼 없음 = 안 부름 +form.open = null; +const resets = form.resets; +sel(null, null, ESC.PLAN_PICK_FROM); +out.noFormNoReset = form.resets - resets; +out.planName = ESC.PLAN_PICK_FROM; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_411_a_cross_structure.cjs b/resources/tester/helper_411_a_cross_structure.cjs index aadd50bb2..d5a6c4f98 100644 --- a/resources/tester/helper_411_a_cross_structure.cjs +++ b/resources/tester/helper_411_a_cross_structure.cjs @@ -60,6 +60,7 @@ const STUBS = { selectDesign() {}, subscribeSelection: (_projectId, fn) => ((listener = fn), () => {}), }, + "../A00_Common/design/design_pick_escape": { PLAN_PICK_FROM: "b05-plan" }, "../A00_Common/design/design_store": { useDesignStore: () => ({ subscribe: () => () => {}, get: () => null }), }, diff --git a/resources/tester/test_409_mark_top_deselect.py b/resources/tester/test_409_mark_top_deselect.py index 4eb802637..d8a4f23a5 100644 --- a/resources/tester/test_409_mark_top_deselect.py +++ b/resources/tester/test_409_mark_top_deselect.py @@ -74,3 +74,49 @@ def test_오른쪽_판은_제_이름_알림을_거름() -> None: assert "if (from !== PLAN_PICK_FROM) follow(selection);" in cross marks = _read("B05_Profile", "B05_Profile_UI_RouteEdit_StructureMarks.ts") assert "selectDesign(projectId, NO_SELECTION, { from: PLAN_PICK_FROM });" in marks + + +# ── 409-2 마커 고름 풂 = 좌측 목록 강조 · 폼도 풂 · 패널 접힘 · 펼침 그대로 · 오른쪽 판 그대로 ── + +HELPER_2 = Path(__file__).with_name("helper_409_2_plan_release_form.cjs") + + +@pytest.fixture(scope="module") +def got2() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER_2)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_풂_관_폼_닫힘_오른쪽_판은_제_알림만(got2) -> None: + release = got2["pipeRelease"] + assert release["form"] == {"open": None, "resets": 1} + assert release["crossSaw"] == [[None, "b05-plan"]] # 폼이 낸 b05 풂은 알림 안 감(되울림 막이) + assert release["session"] is None + + +def test_풂_구조물_폼_닫힘(got2) -> None: + assert got2["structureRelease"] == {"form": {"open": None, "resets": 2}, "pill": None} + + +def test_눈금_클릭은_폼_그대로(got2) -> None: + assert got2["tickKeepsForm"] == "A" + assert got2["noFormNoReset"] == 0 + assert got2["planName"] == "b05-plan" + + +def test_폼_닫기는_패널_안_폄() -> None: + panel = _read("B05_Profile", "B05_Profile_UI_Structures_Panel.ts") + head = panel[panel.index(" selectById(structureId) {") :] + assert head.index("if (structureId !== null) callbacks.onReveal?.();") < head.index( + "resetForm();" + ) + assert "is-collapsed" not in head[: head.index(" addAt(")] # 접힘 · 펼침 안 건드림 + pick = _read("A00_Common", "design", "design_pick_profile.ts") + assert "from === PLAN_PICK_FROM && selection.chainageM === null" in pick From c8601930903447cd7daf488bd2da91b6f0ff257a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:23:48 +0900 Subject: [PATCH 07/17] =?UTF-8?q?fix(B05=20=EB=85=B8=EC=84=A0=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EC=83=81=ED=83=9C=EC=A4=84):=20=EC=A2=85=EB=8B=A8?= =?UTF-8?q?=20=ED=8C=90=20=EC=97=B4=EB=A6=BC=20=EB=95=8C=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=20=C2=B7=20=EB=B2=94=EB=A1=80=EB=A5=BC=20=ED=8C=90=20?= =?UTF-8?q?=EC=9C=84=EB=A1=9C=20=EC=98=AC=EB=A6=BC=20(408-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 판이 지도 아래를 덮은 높이 = logic InfoLift infoLiftPx · Chrome 이 판 크기 관찰로 --b05-profile-cover 적음 - 접힘 · 창 분할 = 제자리 · 판을 끝까지 올려도 지도 안 120 px 남김 - 시험 test_408_2_status_lift 새로 · test_345_5c 자리 규칙 고쳐 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_UI_RouteEdit_Chrome.ts | 19 ++++++++ .../B05_Profile_UI_Style_RouteEdit.css | 4 +- .../logic/B05_Profile_RouteEdit_InfoLift.ts | 19 ++++++++ resources/tester/helper_408_2_status_lift.cjs | 33 +++++++++++++ .../tester/test_345_5c_info_hint_collapse.py | 4 +- resources/tester/test_408_2_status_lift.py | 47 +++++++++++++++++++ 6 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts create mode 100644 resources/tester/helper_408_2_status_lift.cjs create mode 100644 resources/tester/test_408_2_status_lift.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index d55e2e80a..46bf45d9e 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -2,6 +2,7 @@ import { createButton } from "@ui/ui_template_elements"; import { createSidePanels, type SidePanels } from "@ui/ui_template_side_panels"; import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks"; +import { infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift"; /* ============================================================================= * B05_Profile_UI_RouteEdit_Chrome.ts @@ -205,6 +206,24 @@ export function createRouteEditChrome( setInfo(infoCollapsed); infoToggle.addEventListener("click", () => setInfo(!infoCollapsed)); + // 뜬 종단 판(이웃 칸)이 지도 아래를 덮으면 상태 · 범례를 그 위로(408-2) — 판 높이 끌기 · 접기 · + // 창 분할(판이 다른 창으로 감 = 못 찾음)을 판 크기 관찰로 따라감. + let watchedProfile: Element | null = null; + const liftInfo = (): void => { + if (!overlay.isConnected) return; // 붙기 전 첫 알림 · 걷힌 뒤 — 판 관찰은 판과 함께 사라짐 + const profile = + overlay.closest(".design-page__main")?.querySelector(".b05-route-profile") ?? null; + if (profile && profile !== watchedProfile) lift.observe((watchedProfile = profile)); + const map = overlay.getBoundingClientRect(); + const top = profile?.isConnected ? profile.getBoundingClientRect().top : null; + overlay.style.setProperty( + "--b05-profile-cover", + `${infoLiftPx(map.bottom, map.height, top)}px`, + ); + }; + const lift = new ResizeObserver(liftInfo); + lift.observe(overlay); + const measureCloseButton = createButton({ label: "✕", variant: "ghost" }); measureCloseButton.classList.add("b05-routeedit__measure-close"); measureCloseButton.setAttribute("aria-label", "닫기"); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 135c686ac..7e643f4f0 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -88,10 +88,10 @@ display: grid; } -/* 상태 · 범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 가려진 채(349-0). +/* 상태 · 범례 — 지도 왼쪽 아래(사용자 지시 ⑫) · 뜬 종단 판이 덮으면 그 위로(408-2 · `_Chrome` 이 적음). 두 줄 안 · 좁은 폭(345-5 덧 3) — 왼쪽 상태 글(두 줄에서 자름) · 가운데 범례 두 줄 · 오른쪽 ▾. */ .b05-routeedit__info { - bottom: var(--spacing-12); + bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px)); left: var(--spacing-12); display: grid; grid-template-columns: minmax(0, 1fr) auto auto; diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts b/B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts new file mode 100644 index 000000000..85671a2c8 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts @@ -0,0 +1,19 @@ +/* ============================================================================= + * B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts + * 평면 상태 · 범례 올림(408-2) — 뜬 종단 판이 지도 아래를 덮은 높이만큼 위로. + * 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Chrome`. + * ========================================================================== */ + +/** 상태 글판이 이 높이(px)는 지도 위에 남게 — 판을 끝까지 올려도 지도 밖으로 안 나감. */ +const KEEP_PX = 120; + +/** 올릴 높이(px) — `profileTop` = 종단 판 윗변(없음 · 접힘 · 창 분할 = null) · 지도 칸 아래 · 높이. */ +export function infoLiftPx( + mapBottom: number, + mapHeight: number, + profileTop: number | null, +): number { + if (profileTop === null) return 0; + const cover = Math.max(0, mapBottom - profileTop); + return Math.round(Math.min(cover, Math.max(0, mapHeight - KEEP_PX))); +} diff --git a/resources/tester/helper_408_2_status_lift.cjs b/resources/tester/helper_408_2_status_lift.cjs new file mode 100644 index 000000000..887add89c --- /dev/null +++ b/resources/tester/helper_408_2_status_lift.cjs @@ -0,0 +1,33 @@ +/* 408-2 평면 상태줄 올림 — `logic/B05_Profile_RouteEdit_InfoLift.ts` 를 Node 로 돌려 JSON 한 줄 + * (`test_408_2_status_lift.py` 가 판정). + * node helper_408_2_status_lift.cjs + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join( + __dirname, + "..", + "..", + "B05_Profile", + "logic", + "B05_Profile_RouteEdit_InfoLift.ts", +); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const { infoLiftPx } = box.exports; + +// 화면 1078 px · 지도 칸 0~1078 +process.stdout.write( + JSON.stringify({ + open: infoLiftPx(1078, 1078, 430), // 종단 판 열림(648 px) + collapsed: infoLiftPx(1078, 1078, 1077), // 접힘(테두리 1 px) + popout: infoLiftPx(1078, 1078, null), // 창 분할 = 판 없음 + below: infoLiftPx(600, 600, 700), // 횡단 보기 = 판이 지도 아래 흐름 + tall: infoLiftPx(1078, 1078, 20), // 판을 끝까지 올림 = 지도 안에 남김 + }), +); diff --git a/resources/tester/test_345_5c_info_hint_collapse.py b/resources/tester/test_345_5c_info_hint_collapse.py index fb674d3b4..70d0c8b9d 100644 --- a/resources/tester/test_345_5c_info_hint_collapse.py +++ b/resources/tester/test_345_5c_info_hint_collapse.py @@ -56,8 +56,8 @@ def test_상태_범례_접기_한_줄_기억() -> None: def test_상태_글판_작게_반투명_자리() -> None: info = _rule(".b05-routeedit__info") - # 왼쪽 아래 제자리 — 뜬 판이 덮으면 가려진 채(349-0) · 폭 줄임 - assert "bottom: var(--spacing-12);" in info + # 왼쪽 아래 제자리 — 뜬 판이 덮으면 그 위로(408-2) · 폭 줄임 + assert "bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));" in info assert "max-width: min(460px, 46%);" in info and "grid-template-columns:" in info # 상태 글 두 줄에서 자름 · 범례는 옆 칸 두 줄 assert "-webkit-line-clamp: 2;" in _rule(".b05-routeedit__status") diff --git a/resources/tester/test_408_2_status_lift.py b/resources/tester/test_408_2_status_lift.py new file mode 100644 index 000000000..2d8bd3f49 --- /dev/null +++ b/resources/tester/test_408_2_status_lift.py @@ -0,0 +1,47 @@ +"""408-2 창 나눔(종단 판 열림) 때 평면 상태줄이 종단 표 밑에 깔림 — 판이 덮은 높이만큼 상태 · 범례를 +위로(`logic/B05_Profile_RouteEdit_InfoLift.ts` · `_Chrome` 이 판 크기 관찰로 `--b05-profile-cover` 적음). + +TS 셈은 `helper_408_2_status_lift.cjs`(Node). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_408_2_status_lift.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +B05 = ROOT / "B05_Profile" +HELPER = Path(__file__).with_name("helper_408_2_status_lift.cjs") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def test_올릴_높이(got) -> None: + assert got["open"] == 648 # 판 윗변 위로 + assert got["collapsed"] == 1 and got["popout"] == 0 and got["below"] == 0 + assert got["tall"] == 1078 - 120 # 지도 안에 120 px 남김 + + +def test_상태_글판이_올림_값을_씀() -> None: + css = (B05 / "B05_Profile_UI_Style_RouteEdit.css").read_text(encoding="utf-8") + block = css[css.index(".b05-routeedit__info {\n bottom") :] + assert block.startswith( + ".b05-routeedit__info {\n bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));" + ) + + +def test_편집기가_판_크기를_관찰() -> None: + chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8") + assert "new ResizeObserver(liftInfo)" in chrome and "lift.observe(overlay)" in chrome + assert '"--b05-profile-cover"' in chrome and ".b05-route-profile" in chrome + assert "lift.disconnect" not in chrome # 붙기 전 첫 알림에 끊으면 영영 안 올라감 From ccccb9f5273147481dfac221800836db55da0b02 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:26:18 +0900 Subject: [PATCH 08/17] =?UTF-8?q?fix(B05=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=20=EC=9E=90=EB=A6=AC):=20=EB=93=B1=EA=B3=A0?= =?UTF-8?q?=EC=84=A0=20=EC=A2=8C=ED=91=9C=20=EB=94=B0=EB=9D=BC=EA=B0=90=20?= =?UTF-8?q?=C2=B7=20=EA=B4=80=20=EC=A7=9D=20id=20=C2=B7=20B06=20=EA=B4=80?= =?UTF-8?q?=20=EC=97=B4=EC=87=A0=20pipe=20(410=20=C2=B7=20411-B=20=C2=B7?= =?UTF-8?q?=20411-D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 좌표 낡음 판정 — 좌표가 그 누가거리를 안 낳으면(마커 · 방향키 · 폼으로 누가거리만 옮김) 노선 위 자리로 다시 적음 · TS placeOnLine ↔ 파이썬 fill_structure_coordinates 짝(보낸 좌표가 누가거리를 낳으면 살림) - 따라감 단계 — 좌표 없는 관 초안은 앞 따라감 좌표 · site 간격 넘김 - 배수 편집기 — 노선 초안 follow 가 다시 매긴 관 자리를 id 짝으로 받음(시설 속성 · 고름 함께) · 초안 버림 = 저장분 다시 읽음 - 관 짝 차례 → id — 관 알림에 고정 id · followPipeReports · shownPipes(movePipeIds 한 번에 옮김) · followedMarks 같은 자리 먼저 - B06 측점 열쇠 — 서버 관 id 도 pipe: (계산 묶음과 같은 갈래) - 시험 test_410_follow_xy · test_411_b_pipe_id_pairing Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_pipe_ids.ts | 29 ++- A00_Common/design/design_pipeline_page.ts | 11 +- A00_Common/design/design_view_profile.ts | 29 ++- B05_Profile/B05_Profile_Api_Structures.ts | 3 + B05_Profile/B05_Profile_UI_Drainage_Panel.ts | 2 + .../B05_Profile_UI_Drainage_Panel_Types.ts | 4 + B05_Profile/B05_Profile_UI_Drainage_Parts.ts | 28 +++ B05_Profile/B05_Profile_UI_Drainage_Report.ts | 3 +- .../B05_Profile_UI_Profile_Panel_Types.ts | 2 + .../logic/B05_Profile_Pipeline_Follow.ts | 17 +- .../logic/B05_Profile_Structure_Follow.ts | 20 +- B06_Section/B06_Section_Section_Drafts.ts | 2 +- B06_Section/B06_Section_Station_Keys.ts | 8 +- common_util/common_util_route_follow.py | 27 ++- common_util/common_util_route_follow.ts | 35 ++- resources/tester/helper_410_follow_xy.cjs | 50 +++++ resources/tester/helper_411_b_entry.ts | 18 ++ .../tester/helper_411_b_pipe_id_pairing.mjs | 129 +++++++++++ resources/tester/test_410_follow_xy.py | 203 ++++++++++++++++++ .../tester/test_411_b_pipe_id_pairing.py | 104 +++++++++ 20 files changed, 674 insertions(+), 50 deletions(-) create mode 100644 resources/tester/helper_410_follow_xy.cjs create mode 100644 resources/tester/helper_411_b_entry.ts create mode 100644 resources/tester/helper_411_b_pipe_id_pairing.mjs create mode 100644 resources/tester/test_410_follow_xy.py create mode 100644 resources/tester/test_411_b_pipe_id_pairing.py diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts index 7ce5f39a6..c24a2d3d1 100644 --- a/A00_Common/design/design_pipe_ids.ts +++ b/A00_Common/design/design_pipe_ids.ts @@ -58,19 +58,26 @@ export function pipeChainageOf(id: string, projectId: string | null = active): n /** 관을 옮김 — 옛 자리 id 가 새 자리로(새 자리에 남은 옛 기록은 뺌) · 옮긴 관의 id. */ export function movePipeId(fromM: number, toM: number, projectId: string | null = active): string { if (!projectId) return `pipe-${toM.toFixed(2)}`; + return movePipeIds([[fromM, toM]], projectId)[0] ?? pipeIdAt(toM, projectId); +} + +/** 관 여럿을 한 번에 옮김(노선 따라감 · 411-B) — 옮길 기록을 먼저 다 찾고 옮김(앞 관이 뒤 관 옛 자리로 가도 + * 뒤 관 기록을 안 지움) · 새 자리에 남은 옮기지 않는 옛 기록은 뺌 · 옮긴 관마다 id(못 찾으면 null). */ +export function movePipeIds( + moves: ReadonlyArray, + projectId: string | null = active, +): Array { + if (!projectId) return moves.map(() => null); const book = bookOf(projectId); - const moving = nearestWithin(book.ids, fromM, (entry) => entry.at); - if (!moving) return pipeIdAt(toM, projectId); - const stale = nearestWithin( - book.ids.filter((entry) => entry !== moving), - toM, - (entry) => entry.at, - ); + const moving = moves.map(([fromM]) => nearestWithin(book.ids, fromM, (entry) => entry.at)); + const to = new Map(moving.flatMap((entry, i) => (entry ? [[entry, moves[i][1]]] : []))); + const still = book.ids.filter((entry) => !to.has(entry)); + const stale = new Set([...to.values()].map((at) => nearestWithin(still, at, (e) => e.at))); const ids = book.ids - .filter((entry) => entry !== stale) - .map((entry) => (entry === moving ? { ...entry, at: toM } : entry)); - writeState("pipe-ids", { next: book.next, ids }, projectId); - return moving.id; + .filter((entry) => !stale.has(entry)) + .map((entry) => (to.has(entry) ? { ...entry, at: to.get(entry)! } : entry)); + if (to.size) writeState("pipe-ids", { next: book.next, ids }, projectId); + return moving.map((entry) => entry?.id ?? null); } /** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */ diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index 99226eb25..a95ba7a9e 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -91,7 +91,10 @@ import { draftSectionDetail, type CrossInput, } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail"; -import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; +import { + buildStations, + SECTION_STATION_INTERVAL_M, +} from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage"; import { createMassHaulStage, @@ -315,10 +318,12 @@ function createPagePipeline( if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로) const line = ctx.before.route?.line ?? savedLine() ?? []; const { pipes, structures } = ctx.inputs; + // 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의 앞 노선 위(410) + const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[]; const inputs = { ...ctx.inputs, - pipes: placed(pipes, line, savedPipes as FollowingStructure[]), - structures: placed(structures, line, []), + pipes: placed(pipes, line, [...(savedPipes as FollowingStructure[]), ...followed]), + structures: placed(structures, line, [], SECTION_STATION_INTERVAL_M), }; return followStage({ ...ctx, inputs }); }, diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index fd1dde7ec..e7adc9de9 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css"; import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; -import { movePipeId, pipeIdAt } from "./design_pipe_ids"; +import { movePipeIds, pipeIdAt } from "./design_pipe_ids"; import { useDesignStore } from "./design_store"; import { setPlusStations } from "../../common_util/common_util_structure_station"; import { @@ -113,18 +113,21 @@ export async function mountProfileView( /** 배수유역도가 마지막으로 알린 관 목록 — 숨어 있다 다시 보일 때 폼 · 3D 에 다시 싣는다. */ let lastPipes: PipeReport | null = shared.lastPipes ?? null; const designStore = useDesignStore(activeProjectId, null); - /** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 id 도 옮김. */ + /** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 세션 id 도 + * 옮김 — 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 그 관) · id 없는 관은 안 옮김(막 놓은 관). */ let lastShown: PipeReport = []; const shownPipes = (pipes: PipeReport): PipeReport => { - const byChainage = (x: PipeReport[number], y: PipeReport[number]) => - x.chainage_m - y.chainage_m; - const shown = followPipeReports(pipes, designStore.get("pipes")).sort(byChainage); - const before = [...lastShown].sort(byChainage); - if (before.length === shown.length) - shown.forEach((pipe, i) => { - if (Math.abs(pipe.chainage_m - before[i].chainage_m) > 0.005) - movePipeId(before[i].chainage_m, pipe.chainage_m, activeProjectId); - }); + const shown = followPipeReports(pipes, designStore.get("pipes")).sort( + (x, y) => x.chainage_m - y.chainage_m, + ); + const was = new Map(lastShown.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : []))); + const moves = shown.flatMap((pipe) => { + const from = pipe.id ? was.get(pipe.id) : undefined; + return from === undefined || Math.abs(pipe.chainage_m - from) <= 0.005 + ? [] + : [[from, pipe.chainage_m] as const]; + }); + if (moves.length) movePipeIds(moves, activeProjectId); lastShown = shown; return shown; }; @@ -145,6 +148,10 @@ export async function mountProfileView( `구조물 ${far}건이 새 노선에서 ${STRUCTURE_ROUTE_DRIFT_MAX_M} m 넘게 멀어짐`, "warning", ); + // 배수 편집기도 따라간 관 자리(410) — 초안을 버려 가게가 비면 저장분 다시 읽음 + const followed = designStore.get("pipes"); + if (followed) profilePanel.drainage.followPipes(followed); + else if (profilePanel.drainage.pipeChainages().length) profilePanel.drainage.reload(); if (!host.visible()) return; if (lastPipes) applyPipes(lastPipes, false); bridge.takeStructures(designStore.get("structures")); diff --git a/B05_Profile/B05_Profile_Api_Structures.ts b/B05_Profile/B05_Profile_Api_Structures.ts index 005ac7089..3a1deaa1c 100644 --- a/B05_Profile/B05_Profile_Api_Structures.ts +++ b/B05_Profile/B05_Profile_Api_Structures.ts @@ -128,6 +128,9 @@ export interface StructureInstance { summary_item?: SummaryItem | null; /** 옵션 그룹마다 고른 집계 항목 `{그룹 id: 참조}`(PLAN 46-3) — 없으면 그룹 미지정. */ group_items?: GroupItems | null; + /** 땅 위 자리(344-2 · 410) — 서버가 채움 · 누가거리만 옮기면 낡음(따라감 · 저장이 노선 위로 다시). */ + x?: number | null; + y?: number | null; } interface StructureTypesResponse { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 62e613a0f..295dc6546 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -59,6 +59,7 @@ import { basinIndexOfPipe, pipeMarkerColor, adoptDraftPipes, + followEditorPipes, reconcilePipes, WIDTH_KEY, type DrainageLayer, @@ -569,6 +570,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra if (projectId && meta) void run(loadSaved); }, pipeChainages: () => pipeEditor.chainages(), + followPipes: (pipes) => followEditorPipes(pipeEditor, facilityStore, pipes), movePipe(fromChainage, toChainage) { const index = pipeEditor .pipes() diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts index 5a0586d03..f80f31a15 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel_Types.ts @@ -25,6 +25,8 @@ export interface DrainagePanel { setRoute: (points: ReadonlyArray) => void; /** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */ pipeChainages: () => number[]; + /** 노선 초안 follow 가 다시 매긴 관 자리를 받음(짝 = 관 id · 재계산 없음 · 410). */ + followPipes: (pipes: ReadonlyArray<{ id?: string | null; chainage_m: number }>) => void; /** 종단 테이블에서 배관 라인을 끌었을 때 — 그 자리로 옮기고 세부유역을 다시 나눈다. */ movePipe: (fromChainage: number, toChainage: number) => void; /** 관 목록을 통째로 맞춘다(사이드바 구조물 폼 편집 등 밖에서 바뀐 경우). @@ -64,6 +66,8 @@ export interface DrainagePanel { /** 관 하나의 보고 — 누가거리 + 담당 유역의 배수 유효직경(mm) + 시설 종류 · 구간 · 옵션 · 집계 항목. */ export interface DrainagePipeReport { chainage_m: number; + /** 관 고정 id(서버 · 새 관은 없음) — 따라감 · 옮김 짝(411-B). */ + id?: string; effective_diameter_mm: number | null; /** 담당 유역의 설계유량(㎥/s, 100년빈도·2.0배). 물넘이·세월교 개략 단면의 입력이다. */ design_flow_m3s?: number | null; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index babcebadf..c58a3704f 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -22,6 +22,7 @@ import { normalizeStrength, rampColor } from "../B04_PreProcess/B04_PreProcess_U import type { MapContextMenu, MapContextMenuItem } from "@ui/ui_template_context_menu"; import type { DetailBasin } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { PipeEditor, PipePoint } from "./B05_Profile_UI_Drainage_Pipes"; +import type { FacilityStore } from "./B05_Profile_UI_Drainage_Facility"; import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; import { viewOf } from "../common_util/common_util_view_window"; @@ -341,6 +342,33 @@ export function mountDrainageToggles( ); } +/** 노선 초안 follow 가 다시 매긴 관 자리(가게 관 초안)를 편집기에 얹음(410) — 짝 = 관 id · 시설 속성 · + * 고름도 따라감 · 재계산 · 초안 쓰기 없음(가게가 이미 그 값). 편집기가 옛 노선 누가거리로 초안을 덮지 않게. */ +export function followEditorPipes( + editor: PipeEditor, + facilities: FacilityStore, + followed: ReadonlyArray<{ id?: string | null; chainage_m: number }>, +): void { + const to = new Map(followed.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : []))); + const before = editor.pipes(); + const moves = before.flatMap((pipe) => { + const at = pipe.id ? to.get(pipe.id) : undefined; + return at === undefined || Math.abs(at - pipe.chainage_m) < 0.005 + ? [] + : [{ pipe, at, attributes: facilities.get(pipe.chainage_m) }]; + }); + if (!moves.length) return; + const picked = before[editor.selected() ?? -1]; + moves.forEach(({ pipe }) => facilities.set(pipe.chainage_m, null)); // 다 뺀 뒤 얹음 — 자리 겹침 막음 + moves.forEach(({ at, attributes }) => attributes && facilities.set(at, attributes)); + const moved = new Map(moves.map(({ pipe, at }) => [pipe, at])); + editor.setPipes( + before.map((pipe) => ({ ...pipe, chainage_m: moved.get(pipe) ?? pipe.chainage_m })), + ); + const pickedAt = picked && (moved.get(picked) ?? picked.chainage_m); + if (picked) editor.select(editor.pipes().findIndex((pipe) => pipe.chainage_m === pickedAt)); +} + /** 밖에서 바뀐 관 목록을 현재 목록과 맞춘다. 같으면 null — 되먹임 고리를 끊는 지점이다. * 다르면 새 목록을 돌려주되, 원래 생성 사유는 자리로 맞춰 이어 붙인다(기본/자동/수동 표시 보존). */ /** 되돌리기(369) — 되살린 걸음의 관 초안(세션)이 `next` 와 같은 자리면 그 초안(속성 정본) · id 도 diff --git a/B05_Profile/B05_Profile_UI_Drainage_Report.ts b/B05_Profile/B05_Profile_UI_Drainage_Report.ts index ea809b3a2..c26739c26 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Report.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Report.ts @@ -15,7 +15,7 @@ import { PIPE_MATCH_M } from "../A00_Common/design/design_selection"; * 시설 종류·구간·부속 옵션·출처(source)도 함께 — 통합 목록·사이드 폼이 * 이 목록만으로 편집 화면을 채운다(2026-08-17 단일 폼 통합). */ export function buildPipeReports( - pipes: ReadonlyArray<{ chainage_m: number; reason?: string }>, + pipes: ReadonlyArray<{ chainage_m: number; reason?: string; id?: string }>, basins: ReadonlyArray, facilityStore: ReturnType, ): DrainagePipeReport[] { @@ -25,6 +25,7 @@ export function buildPipeReports( const attributes = facilityStore.get(chainage); return { chainage_m: chainage, + ...(pipe.id ? { id: pipe.id } : {}), // 관 고정 id — 옮겨도 짝(411-B) effective_diameter_mm: basin && !basin.bridge_required ? (basin.pipe_diameter_mm ?? null) : null, // 설계유량은 세월교 검토 유역에도 붙인다 — 물넘이·세월교 단면이 이 값에서 나온다. diff --git a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts index c91aaa6c2..9c71a8d0d 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Panel_Types.ts @@ -18,6 +18,8 @@ export interface RouteProfilePanelCallbacks { onPipesChanged?: ( pipes: Array<{ chainage_m: number; + /** 관 고정 id(서버 · 새 관은 없음) — 따라감 · 옮김 짝(411-B). */ + id?: string; effective_diameter_mm: number | null; /** 담당 유역의 설계유량(㎥/s) — 물넘이·세월교 개략 단면의 입력. */ design_flow_m3s?: number | null; diff --git a/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts b/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts index 4d50e62f4..7db14dbe3 100644 --- a/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts +++ b/B05_Profile/logic/B05_Profile_Pipeline_Follow.ts @@ -1,8 +1,8 @@ /* ============================================================================= * B05_Profile/logic/B05_Profile_Pipeline_Follow.ts * 노선이 움직인 뒤 관 목록 화면 값(PLAN 349-0d) — 배수유역도가 알린 관 목록(옛 누가거리)을 가게 관 초안 - * (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 누가거리 차례(따라감은 좌표 투영이라 - * 차례가 거의 그대로) · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침). + * (계산 묶음 follow 가 좌표로 다시 매긴 누가거리)에 맞춤. 짝 = 관 고정 id(411-B · 옮겨 차례가 바뀌어도 + * 그 관) · id 없는 관(막 놓은 관)끼리는 누가거리 차례 · 관 수가 다르면 그대로(배수유역도가 아직 안 받은 고침). * 순수 계산(DOM · fetch · 세션 없음). * ========================================================================== */ @@ -10,6 +10,7 @@ interface Span { chainage_m: number; start_m?: number | null; end_m?: number | null; + id?: string | null; } /** 관 알림 목록의 측점 · 구간을 가게 관 초안 쪽으로 — 초안이 없으면(저장분) 그대로. */ @@ -19,9 +20,15 @@ export function followPipeReports( ): R[] { if (!pipes || pipes.length !== reports.length) return [...reports]; const byChainage = (a: Span, b: Span): number => a.chainage_m - b.chainage_m; - const to = [...pipes].sort(byChainage); - return [...reports].sort(byChainage).map((report, index) => { - const pipe = to[index]; + const byId = new Map(pipes.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe]] : []))); + const paired = (report: R): boolean => Boolean(report.id && byId.has(report.id)); + const loose = [...pipes].filter((pipe) => !reports.some((r) => r.id && r.id === pipe.id)); + const to = loose.sort(byChainage); + const sorted = [...reports].sort(byChainage); + let next = 0; + return sorted.map((report) => { + const pipe = paired(report) ? byId.get(report.id!)! : to[next++]; + if (!pipe) return report; const chainage = Math.round(pipe.chainage_m * 100) / 100; // 알림 목록 자릿수(`buildPipeReports`) const shift = chainage - report.chainage_m; const moved = (own: number | null | undefined, from: number | null | undefined) => diff --git a/B05_Profile/logic/B05_Profile_Structure_Follow.ts b/B05_Profile/logic/B05_Profile_Structure_Follow.ts index e1fd44a99..3857aefc9 100644 --- a/B05_Profile/logic/B05_Profile_Structure_Follow.ts +++ b/B05_Profile/logic/B05_Profile_Structure_Follow.ts @@ -1,8 +1,8 @@ /* ============================================================================= * B05_Profile_Structure_Follow.ts * 노선 초안 동안 종단 알약 자리(PLAN 346-2 덧 3) — 계산 묶음 follow 가 좌표로 따라간 관 · 구조물의 새 - * 누가거리를 알약 목록에 얹음. 구조물 = structure_id 로 짝 · 관 알약 id = 세션 id(`pipeIdAt`) 라 묶음 관 id - * (서버 고정 id)와 다르면 누가거리 차례로 짝. + * 누가거리를 알약 목록에 얹음. 구조물 = structure_id 로 짝 · 관 알약 id = 세션 id(`pipeIdAt` · 자리 장부)라 + * 묶음 관 id(서버 고정 id)와 다르면 같은 자리(장부가 따라간 자리 = 그 관 · 411-B) → 남은 것끼리 누가거리 차례. * * 순수 — 화면 · fetch · 세션 없음. * ========================================================================== */ @@ -22,6 +22,9 @@ interface Moved extends Placed { structure_id?: string | null; } +/** 같은 관으로 볼 자리 차이(m) — `PIPE_MATCH_M`(design_selection) 같은 값. */ +const SAME_PIPE_M = 0.51; + const list = (value: unknown): Moved[] => (Array.isArray(value) ? (value as Moved[]) : []); const at = (item: Placed): number => item.chainage_m ?? item.start_m ?? 0; @@ -35,14 +38,21 @@ export function followedMarks( for (const item of list(structures)) if (item.structure_id) byId.set(item.structure_id, item); const movedPipes = list(pipes).filter((pipe) => typeof pipe.chainage_m === "number"); for (const pipe of movedPipes) if (pipe.id) byId.set(pipe.id, pipe); - // ponytail: 차례 짝 — 노선을 고쳐 관 차례가 뒤바뀌면 틀림 · 알약이 서버 관 id 를 들면 id 짝으로 const loose = marks .filter((mark) => mark.structure_id?.startsWith("pipe-") && !byId.has(mark.structure_id)) .sort((a, b) => at(a) - at(b)); const ids = new Set(marks.map((mark) => mark.structure_id)); - const free = movedPipes.filter((pipe) => !ids.has(pipe.id)).sort((a, b) => at(a) - at(b)); + let free = movedPipes.filter((pipe) => !ids.has(pipe.id)).sort((a, b) => at(a) - at(b)); const paired = new Map(); - if (loose.length === free.length) loose.forEach((mark, index) => paired.set(mark, free[index])); + for (const mark of loose) { + const same = free.find((pipe) => Math.abs(at(pipe) - at(mark)) < SAME_PIPE_M); + if (!same) continue; + paired.set(mark, same); + free = free.filter((pipe) => pipe !== same); + } + // ponytail: 남은 것은 차례 짝 — 알약이 옛 자리인 채 노선 고쳐 관 차례가 뒤바뀌면 틀림 + const rest = loose.filter((mark) => !paired.has(mark)); + if (rest.length === free.length) rest.forEach((mark, index) => paired.set(mark, free[index])); return marks.map((mark) => { const moved = (mark.structure_id ? byId.get(mark.structure_id) : undefined) ?? paired.get(mark); if (!moved) return mark; diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index acf760890..856a372a5 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -182,7 +182,7 @@ export async function withDraftStations( if (inputs) { const [pipes, structures, types] = inputs; const planned = planStations(pipes, structures, types, registryDefaults(types)); - useStationOwners(projectId, planned); // 구조물 측점 열쇠 = 덮는 구조물 id(344-3) + useStationOwners(projectId, planned, pipes); // 측점 열쇠 = 덮는 구조물 id(344-3) · 관 = pipe: const planted = planned.map((station) => station.chainage_m); // 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고 // 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index f6c1cb6ae..f28f8f3e3 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -58,17 +58,21 @@ let current: { projectId: string; routeId: number } | null = null; const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`; -/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */ +/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 = `pipe-` 머리(세션 · 자리 이름) 또는 `pipes` 의 + * 서버 관 id(`p8017` · 계산 묶음 측점 열쇠 `pipe:p8017` 과 같은 규칙 · 411-D). */ export function useStationOwners( projectId: string, planned: ReadonlyArray<{ chainage_m: number; owners: string[]; role?: StationKeyRole }>, + pipes: ReadonlyArray<{ id?: string | null }> = [], ): void { + const pipeIds = new Set(pipes.map((pipe) => pipe.id)); owners.set( projectId, planned.flatMap((station) => station.owners.slice(0, 1).map((id) => ({ id, - kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const), + kind: + id.startsWith("pipe-") || pipeIds.has(id) ? ("pipe" as const) : ("structure" as const), at: station.chainage_m, role: station.role ?? "anchor", })), diff --git a/common_util/common_util_route_follow.py b/common_util/common_util_route_follow.py index 6d4acddcf..5eaebca5e 100644 --- a/common_util/common_util_route_follow.py +++ b/common_util/common_util_route_follow.py @@ -5,12 +5,12 @@ 새 노선에서 `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘게 멀면 「노선에서 멀어짐」 표시만(버리지 않음). - `fill_structure_coordinates` — 저장 때 노선 위 기준 자리로 x · y 채움(자리가 그대로면 옛 좌표 - 지킴) · 옛 자료는 읽을 때 없는 것만 채움. + 지킴 · 보낸 좌표가 그 누가거리를 낳으면 살림 · 410) · 옛 자료는 읽을 때 없는 것만 채움. - `follow_structures` — 좌표 → 새 노선 누가거리 · 구간 끝은 같은 만큼 밂 · 노선과의 거리. 짝 = TS `common_util/common_util_route_follow.ts` `followStructures`(구조물) · `projectPipePoints` -(관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · 거울 시험 = -`resources/tester/test_344_2_route_pairs.py`. +(관 — 파이썬 본체 = `common_util_drainage_pipes.project_pipe_points`) · `placeOnLine`(좌표 낡음 +판정) · 거울 시험 = `resources/tester/test_344_2_route_pairs.py` · `test_410_follow_xy.py`. """ from __future__ import annotations @@ -20,6 +20,7 @@ from typing import Any, Iterable, Sequence from shapely.geometry import LineString, Point from common_util.common_util_structure_station import ( + SAME_STATION_M, route_general_stations, snap_existing_station, snap_structure_station, @@ -41,6 +42,20 @@ def _positions(item: Any) -> tuple[Any, ...]: return tuple(getattr(item, key, None) for key in _POSITION_KEYS) +def _holds_station(item: Any, line: LineString) -> bool: + """좌표가 그 기준 측점을 낳는가 — 같은 노선 따라감이 누가거리를 안 옮김(TS `holdsStation`).""" + at = float(line.project(Point(float(item.x), float(item.y)))) + length = float(line.length) + target = ( + snap_existing_station(at, route_general_stations(length)) + if getattr(item, "placement", None) == "site" + else snap_structure_station(at, [length]) + ) + anchor = float(item.chainage_m) + shift = target - anchor + return abs(min(max(anchor + shift, 0.0), length) - anchor) <= SAME_STATION_M + + def fill_structure_coordinates( items: Iterable[Any], xy: Sequence[Sequence[float]], @@ -51,7 +66,9 @@ def fill_structure_coordinates( """구조물 x · y 를 노선 위 기준 자리(`chainage_m`)로 채운다(제자리) · 채운 수. `previous` = 저장 전 정본 {structure_id: 항목} — 자리(누가거리 셋)가 그대로면 옛 좌표를 지킨다 - (노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). `only_missing` = 읽을 때. + (노선이 바뀌어 멀어진 땅 자리를 다음 저장이 노선 위로 덮지 않게). 보낸 좌표가 그 누가거리를 + 낳으면(지금 노선 따라감 = 제자리) 그 좌표를 살린다(410 · 옮긴 자리 · 따라간 땅 자리) — 낡은 + 좌표(누가거리만 옮김)는 노선 위로. TS 짝 = `placeOnLine`. `only_missing` = 읽을 때. """ line = _line(xy) if line is None: @@ -68,6 +85,8 @@ def fill_structure_coordinates( if old is not None and _positions(old) == _positions(item) and old.x is not None: item.x, item.y = old.x, old.y continue + if has_xy and _holds_station(item, line): + continue point = line.interpolate(float(anchor)) item.x, item.y = round(float(point.x), 4), round(float(point.y), 4) filled += 1 diff --git a/common_util/common_util_route_follow.ts b/common_util/common_util_route_follow.ts index 180a79279..9641471c5 100644 --- a/common_util/common_util_route_follow.ts +++ b/common_util/common_util_route_follow.ts @@ -9,8 +9,10 @@ * - `followStructures` ↔ 파이썬 `common_util_route_follow.follow_structures`(+ 0 ~ 연장 자름 · * 노선과 거리 · `STRUCTURE_ROUTE_DRIFT_MAX_M` 넘으면 「노선에서 멀어짐」 · 버리지 않음). * - `projectOnPolyline` = shapely `LineString.project`(가장 가까운 첫 선분) · `distance`. - * - `placeOnLine` · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 전 자리로 · - * 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). + * - `placeOnLine` 좌표 낡음 판정 ↔ 파이썬 `fill_structure_coordinates`(좌표가 그 누가거리를 낳으면 살림 · + * 아니면 누가거리의 노선 위 자리 · 410) · 거울 시험 = resources/tester/test_410_follow_xy.py. + * - `placeOnLine` 의 좌표 없는 관 · `rebasePipeDraft` 는 TS 만 — 좌표 없는 세션 관 초안(정본 PUT 꼴)을 따라가기 + * 전 자리로 · 노선을 저장한 [저장]이 서버가 따라 옮긴 관 위에 초안을 얹음(349-0u). * 거울 시험 = resources/tester/test_344_2_route_pairs.py. * ========================================================================== */ @@ -53,18 +55,35 @@ export function projectOnPolyline( return best && walked > 0 ? { ...best, length_m: walked } : null; } -/** 좌표 없는 관 · 구조물(세션 초안 = 정본 PUT 꼴 · 좌표 뺌)에 자리를 붙인 사본 — 같은 id · 같은 누가거리인 - * `saved`(정본 · 좌표 있음)가 있으면 그 좌표(안 옮긴 것 = 서버 따라감과 같은 자리) · 없으면 그 누가거리의 - * `line` 위 자리(연장 밖은 끝 선분 위로 자름). 좌표가 있거나 누가거리가 없으면 그대로(같은 객체). */ +/** 좌표가 그 누가거리를 낳는가 — `line` 에서 따라감(같은 선 = 제자리)이 누가거리를 안 옮김(410 · 파이썬 + * `fill_structure_coordinates` 같은 규칙). 아니면 좌표가 낡음(마커 · 방향키 · 폼으로 누가거리만 옮김). */ +const holdsStation = ( + item: FollowingStructure, + line: ReadonlyArray, + intervalM: number | null, +): boolean => { + const [moved] = followStructures([item], line, Infinity, intervalM); + return ( + moved.drift_m === null || Math.abs(moved.item.chainage_m! - item.chainage_m!) <= SAME_STATION_M + ); +}; + +/** 관 · 구조물에 자리를 붙인 사본 — 좌표가 그 누가거리를 낳으면 그대로(같은 객체 · 410) · 좌표 없음(세션 관 + * 초안 = 정본 PUT 꼴 · 좌표 뺌)이면 같은 id · 같은 누가거리인 `saved`(정본 · 앞 따라감 · 좌표 있음)의 좌표(안 + * 옮긴 것 = 서버 따라감과 같은 자리) · 그 밖(옮김 · 좌표 낡음)은 그 누가거리의 `line` 위 자리(연장 밖은 끝 선분 + * 위로 자름 = 옮긴 자리가 새 기준). 누가거리가 없으면 그대로. `intervalM` = site 맞춤 간격(`followStructures`). */ export function placeOnLine( items: ReadonlyArray, line: ReadonlyArray, saved: ReadonlyArray = [], + intervalM: number | null = null, ): S[] { const byId = new Map(saved.map((item) => [item.id, item])); return items.map((item) => { - if ((item.x != null && item.y != null) || item.chainage_m == null) return item; - const was = item.id ? byId.get(item.id) : undefined; + if (item.chainage_m == null) return item; + const placed = item.x != null && item.y != null; + if (placed && holdsStation(item, line, intervalM)) return item; + const was = !placed && item.id ? byId.get(item.id) : undefined; if ( was?.x != null && was.y != null && @@ -196,11 +215,13 @@ export const placedOnLine = ( list: unknown, line: ReadonlyArray<[number, number]>, saved: FollowingStructure[], + intervalM: number | null = null, ): unknown => Array.isArray(list) ? placeOnLine( list as FollowingStructure[], line.map(([x, y]) => ({ x, y })), saved, + intervalM, ) : list; diff --git a/resources/tester/helper_410_follow_xy.cjs b/resources/tester/helper_410_follow_xy.cjs new file mode 100644 index 000000000..089cc32fb --- /dev/null +++ b/resources/tester/helper_410_follow_xy.cjs @@ -0,0 +1,50 @@ +/* 410 좌표 따라감 거울 — 진짜 TS(`common_util_route_follow`)를 Node 로 돌림. + * 입력 argv[2] JSON = { lines: [L1, L2, ...], structures, pipes, followed } · 표준출력 JSON 한 벌. + * 차례(구조물) = L1 에서 자리 붙임(placeOnLine) → L2 따라감 → L2 에서 자리 붙임 → L3 따라감 … + * 관 = 좌표 없는 초안 + 앞 따라감(followed) → L1 자리 붙임 → L2 투영. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); + +function loadTs(file, stubs = {}) { + const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + const localRequire = (request) => { + if (stubs[request]) return stubs[request]; + throw new Error(`stub 없음: ${request} (${file})`); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const catalog = require("./helper_322_catalog_module.cjs"); +const station = loadTs("common_util/common_util_structure_station.ts", { + "./common_util_structure_catalog": catalog, +}); +const follow = loadTs("common_util/common_util_route_follow.ts", { + "./common_util_structure_station": station, +}); + +const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +const lines = input.lines.map((line) => line.map(([x, y]) => ({ x, y }))); +const steps = []; +let items = input.structures; +for (let i = 0; i + 1 < lines.length; i += 1) { + const placed = follow.placeOnLine(items, lines[i], [], input.interval); + items = follow + .followStructures(placed, lines[i + 1], undefined, input.interval) + .map((moved) => moved.item); + steps.push({ placed, followed: items }); +} +const pipesPlaced = follow.placeOnLine(input.pipes, lines[0], input.followed); +const out = { + steps, + pipesPlaced, + pipesFollowed: follow.projectPipePoints(pipesPlaced, lines[1]), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/helper_411_b_entry.ts b/resources/tester/helper_411_b_entry.ts new file mode 100644 index 000000000..eec78f5c4 --- /dev/null +++ b/resources/tester/helper_411_b_entry.ts @@ -0,0 +1,18 @@ +/* 411-B · 411-D 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름. + * 관 짝(id) · 관 세션 id 장부 옮김 · 알약 따라감 짝 · 배수 편집기 따라감 · B06 측점 열쇠 갈래를 화면 · 서버 없이 잰다. + */ +export { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_Follow"; +export { followedMarks } from "../../B05_Profile/logic/B05_Profile_Structure_Follow"; +export { + movePipeId, + movePipeIds, + pipeChainageOf, + pipeIdAt, + usePipeIds, +} from "../../A00_Common/design/design_pipe_ids"; +export { stationKeysOf, useStationOwners } from "../../B06_Section/B06_Section_Station_Keys"; +export { followEditorPipes } from "../../B05_Profile/B05_Profile_UI_Drainage_Parts"; +export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility"; +export { planStations } from "../../common_util/common_util_structure_station"; +export { createPipeEditor } from "../../B05_Profile/B05_Profile_UI_Drainage_Pipes"; +export { stationKey as pipelineStationKey } from "../../B05_Profile/logic/B05_Profile_Sections_Stations"; diff --git a/resources/tester/helper_411_b_pipe_id_pairing.mjs b/resources/tester/helper_411_b_pipe_id_pairing.mjs new file mode 100644 index 000000000..7bc24f426 --- /dev/null +++ b/resources/tester/helper_411_b_pipe_id_pairing.mjs @@ -0,0 +1,129 @@ +/* 411-B · 411-D 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_411_b_entry.ts)을 부르고 결과를 JSON 한 벌로. + * node helper_411_b_pipe_id_pairing.mjs <묶음.mjs> + * 사용자 길: 관 p2 를 앞 관 p1 을 지나 옮김(150 → 80) · 노선 따라감이 두 관을 함께 밂. + */ +import { pathToFileURL } from "node:url"; + +const store = () => { + const m = new Map(); + return { + getItem: (k) => (m.has(k) ? m.get(k) : null), + setItem: (k, v) => m.set(k, String(v)), + removeItem: (k) => m.delete(k), + key: (i) => [...m.keys()][i] ?? null, + get length() { + return m.size; + }, + clear: () => m.clear(), + }; +}; +globalThis.window = globalThis; +globalThis.sessionStorage = store(); +globalThis.localStorage = store(); +const any = new Proxy(function () {}, { + get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any), + apply: () => any, + construct: () => any, +}); +globalThis.document = any; +globalThis.requestAnimationFrame = (run) => setTimeout(run, 0); + +const m = await import(pathToFileURL(process.argv[2]).href); +const out = {}; +const P = "p411"; +m.usePipeIds(P); + +/* 1. 알림 목록 ↔ 가게 관 초안 짝 = id — 옮겨 차례가 뒤바뀌어도 그 관 */ +const reports = [ + { id: "p10000", chainage_m: 100, facility: "pipe" }, + { id: "p15000", chainage_m: 150, facility: "pipe", start_m: 145, end_m: 155 }, +]; +const draft = [ + { id: "p15000", chainage_m: 80 }, + { id: "p10000", chainage_m: 100 }, +]; +out.reports = m + .followPipeReports(reports, draft) + .map((r) => [r.id, r.chainage_m, r.start_m ?? null]); + +/* 2. 세션 id 장부 — 끌어 옮김(followMovedPipe 길) 뒤 화면 목록 짝(shownPipes 규칙 = id) */ +const idA = m.pipeIdAt(100, P); +const idB = m.pipeIdAt(150, P); +m.movePipeId(150, 80, P); // 끌기 = 옮긴 관 id 그대로 +const was = new Map(reports.map((r) => [r.id, r.chainage_m])); +const shown = m.followPipeReports(reports, draft); +const moves = shown.flatMap((r) => { + const from = was.get(r.id); + return from === undefined || Math.abs(from - r.chainage_m) <= 0.005 ? [] : [[from, r.chainage_m]]; +}); +m.movePipeIds(moves, P); +out.ids = { idA, idB, at80: m.pipeIdAt(80, P), at100: m.pipeIdAt(100, P) }; +out.bookNext = m.pipeIdAt(999, P); // 새 관 = 다음 차례(앞에서 헛 id 가 안 생겼으면 s3) + +/* 3. 노선 따라감이 두 관을 함께 밂(앞 관이 뒤 관 옛 자리로) — 한 번에 옮김 · 기록 안 잃음 */ +const Q = "q411"; +const q1 = m.pipeIdAt(100, Q); +const q2 = m.pipeIdAt(103, Q); +m.movePipeIds( + [ + [100, 103], + [103, 106], + ], + Q, +); +out.batch = { + q1, + q2, + at103: m.pipeIdAt(103, Q), + at106: m.pipeIdAt(106, Q), + next: m.pipeIdAt(500, Q), +}; + +/* 4. 알약 따라감 — 알약이 이미 따라간 자리면 같은 자리 짝(차례 뒤바뀜에도 그 관) */ +const marks = [ + { structure_id: idB, chainage_m: 80 }, + { structure_id: idA, chainage_m: 100 }, + { structure_id: "st-1", chainage_m: 40 }, +]; +out.marks = m + .followedMarks(marks, [{ structure_id: "st-1", chainage_m: 42 }], draft) + .map((mk) => [mk.structure_id, mk.chainage_m]); + +/* 5. 배수 편집기 — 따라간 관 자리(id 짝)를 받음 · 시설 속성 · 고름도 따라감 */ +const editor = m.createPipeEditor(() => {}); +editor.setPipes([ + { chainage_m: 100, reason: "stream", id: "p10000" }, + { chainage_m: 150, reason: "confirmed", id: "p15000" }, +]); +editor.select(1); +const facilities = m.createFacilityStore(); +facilities.set(150, { facility: "ford", options: { 월류높이: 0.3 } }); +m.followEditorPipes(editor, facilities, [ + { id: "p10000", chainage_m: 103 }, + { id: "p15000", chainage_m: 98 }, +]); +out.editor = { + pipes: editor.pipes().map((p) => [p.id, p.chainage_m, p.reason]), + selected: editor.pipes()[editor.selected()]?.id ?? null, + ford98: facilities.get(98)?.facility ?? null, + at150: facilities.get(150), +}; +m.followEditorPipes(editor, facilities, [{ id: "p10000", chainage_m: 103 }]); // 같은 값 = 그대로 +out.editorSame = editor.pipes().map((p) => p.chainage_m); + +/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */ +const planned = [ + { chainage_m: 80.17, owners: ["p8017"], role: "anchor" }, + { chainage_m: 52.5, owners: ["st-1"], role: "anchor" }, +]; +const sections = [ + { chainage_m: 52.5, kind: "irregular" }, + { chainage_m: 80.17, kind: "irregular" }, +]; +m.useStationOwners(P, planned, [{ id: "p8017", chainage_m: 80.17 }]); +out.keys = m.stationKeysOf(P, sections, 20).map((k) => k.key); +m.useStationOwners(P, planned); +out.keysNoPipes = m.stationKeysOf(P, sections, 20).map((k) => k.key); +out.pipelineKey = m.pipelineStationKey("pipe", "p8017"); + +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_410_follow_xy.py b/resources/tester/test_410_follow_xy.py new file mode 100644 index 000000000..a0cd394c6 --- /dev/null +++ b/resources/tester/test_410_follow_xy.py @@ -0,0 +1,203 @@ +# -*- coding: utf-8 -*- +"""410 구조물 · 관 자리 = 등고선 좌표(x · y) 지킴 — 교점 옮김 · R 바꿈 뒤 좌표 그대로 · +누가거리 다시 매김. + +거울(파이썬 = 서버 정본 · TS = 브라우저 즉시 · 같은 값): + ① 좌표 낡음 판정 — `common_util_route_follow.fill_structure_coordinates` ↔ TS `placeOnLine` + (좌표가 그 누가거리를 낳으면 살림 · 누가거리만 옮긴 구조물 = 그 누가거리의 노선 위 자리). + ② 따라감 — `follow_structures` ↔ `followStructures`(L1 → L2 교점 옮김 → L3 곡선 바꿈). + ③ 관 — 좌표 없는 초안 관은 앞 따라감(같은 id · 같은 자리)의 좌표 · 옮긴 관은 노선 위 자리. +핵심: 마커로 옮긴 구조물은 다음 노선 고침에 옛 자리로 안 튐 · 안 옮긴 구조물 · 관은 좌표 그대로. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest +from shapely.geometry import LineString, Point + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from common_util.common_util_drainage_pipes import PipePoint, project_pipe_points # noqa: E402 +from common_util.common_util_route_follow import ( # noqa: E402 + fill_structure_coordinates, + follow_structures, +) +from common_util.common_util_route_geometry import RouteVertex # noqa: E402 + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "typescript").is_dir(), + reason="프론트엔드 의존성 미설치", +) + +L1 = [(0.0, 0.0), (100.0, 0.0), (200.0, 50.0)] +L2 = [(0.0, 0.0), (100.0, 12.0), (200.0, 50.0)] # 교점 옮김 +L3 = [(0.0, 0.0), (85.0, 4.0), (100.0, 10.0), (115.0, 18.0), (200.0, 50.0)] # 곡선(R) 바꿈 +LINES = [L1, L2, L3] +INTERVAL = 20.0 + + +def _at(line, chainage, offset=0.0): + """노선 위 누가거리 자리 + 왼쪽 법선 offset(m).""" + geo = LineString(line) + p = geo.interpolate(chainage) + q = geo.interpolate(min(chainage + 0.01, geo.length)) + dx, dy = q.x - p.x, q.y - p.y + norm = (dx * dx + dy * dy) ** 0.5 or 1.0 + return p.x - dy / norm * offset, p.y + dx / norm * offset + + +def _structure(sid, placement, chainage, xy, start=None, end=None): + return { + "structure_id": sid, + "placement": placement, + "chainage_m": chainage, + "start_m": start, + "end_m": end, + "x": xy[0], + "y": xy[1], + } + + +# S1 = 노선 밖 5 m 땅 자리(안 옮김) · S2 = 140 m 에 놓았다 마커로 60 m 로 옮김(좌표 옛 값) · +# S3 = 구간(안 옮김 · 3 m 밖) · S4 = 사토장(측점 80 · 땅 자리 84 m) · +# S5 = 구간을 폼으로 옮김(좌표 옛 값) +STRUCTURES = [ + _structure("S1", "point", 50.0, _at(L1, 50.0, 5.0)), + _structure("S2", "point", 60.0, _at(L1, 140.0)), + _structure("S3", "interval", 120.0, _at(L1, 120.0, -3.0), 110.0, 135.0), + _structure("S4", "site", 80.0, _at(L1, 84.0, 2.0)), + _structure("S5", "interval", 30.0, _at(L1, 170.0), 25.0, 40.0), +] + +# 관 — p1 = 앞 따라감 그대로(좌표 없는 초안 · 같은 자리) · p2 = 초안에서 옮김(앞 따라감과 자리 다름) +FOLLOWED = [ + {"id": "p1", "chainage_m": 70.0, "x": _at(L1, 70.0, 1.5)[0], "y": _at(L1, 70.0, 1.5)[1]}, + {"id": "p2", "chainage_m": 150.0, "x": _at(L1, 150.0)[0], "y": _at(L1, 150.0)[1]}, +] +PIPES = [{"id": "p1", "chainage_m": 70.0}, {"id": "p2", "chainage_m": 30.0}] + + +@pytest.fixture(scope="module") +def ts(tmp_path_factory) -> dict: + source = tmp_path_factory.mktemp("s410") / "in.json" + source.write_text( + json.dumps( + { + "lines": LINES, + "structures": STRUCTURES, + "interval": INTERVAL, + "pipes": PIPES, + "followed": FOLLOWED, + } + ), + encoding="utf-8", + ) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_410_follow_xy.cjs"), str(source)], + capture_output=True, + text=True, + encoding="utf-8", + cwd=str(ROOT), + check=False, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +@pytest.fixture(scope="module") +def py() -> list[dict]: + """서버 차례 — 저장(좌표 채움 · L1) → 노선 저장(따라감 · L2) → 저장(L2) → 노선 저장(L3).""" + items = [SimpleNamespace(**item) for item in STRUCTURES] + steps = [] + for index in range(len(LINES) - 1): + fill_structure_coordinates(items, LINES[index]) + placed = [dict(vars(item)) for item in items] + follow_structures(items, LINES[index + 1]) + steps.append({"placed": placed, "followed": [dict(vars(item)) for item in items]}) + return steps + + +def _by_id(rows): + return {row["structure_id"]: row for row in rows} + + +def _close(a, b, tol=1e-3): + return (a is None and b is None) or abs(float(a) - float(b)) <= tol + + +@pytest.mark.parametrize("step", [0, 1]) +def test_파이썬_TS_같은_값(ts, py, step): + for phase in ("placed", "followed"): + got, want = _by_id(ts["steps"][step][phase]), _by_id(py[step][phase]) + for sid, row in want.items(): + for key in ("chainage_m", "start_m", "end_m", "x", "y"): + assert _close(got[sid].get(key), row[key]), (step, phase, sid, key) + + +def test_안_옮긴_구조물_좌표_그대로(py): + for sid in ("S1", "S3", "S4"): + first = _by_id(py[0]["placed"])[sid] + for step in py: + for phase in ("placed", "followed"): + now = _by_id(step[phase])[sid] + assert _close(now["x"], first["x"]) and _close(now["y"], first["y"]), sid + + +def test_누가거리는_새_노선_투영(py): + for step, line in zip(py, LINES[1:]): + geo = LineString(line) + for sid in ("S1", "S2"): + row = _by_id(step["followed"])[sid] + assert row["chainage_m"] == float(round(geo.project(Point(row["x"], row["y"])))) + # 사토장 = 새 노선 일반 측점 중 가장 가까운 곳 + site = _by_id(step["followed"])["S4"] + assert site["chainage_m"] % INTERVAL == 0 + + +def test_마커로_옮긴_구조물은_옛_자리로_안_튐(py): + moved_xy = _at(L1, 60.0) + for step in py: + for phase in ("placed", "followed"): + row = _by_id(step[phase])["S2"] + assert _close(row["x"], moved_xy[0]) and _close(row["y"], moved_xy[1]) + assert 50 <= row["chainage_m"] <= 70 # 옛 자리 140 m 근처로 안 돌아감 + form = _by_id(py[1]["followed"])["S5"] + assert 20 <= form["chainage_m"] <= 40 and form["end_m"] - form["start_m"] == 15 + + +def test_서버는_보낸_좌표가_누가거리를_낳으면_살림(): + # 노선 고친 초안에서 옮긴 구조물 — 옛 정본과 자리가 달라도 보낸 좌표(노선 밖 4 m)가 + # 그 누가거리를 낳음 + xy = _at(L2, 90.0, 4.0) + item = SimpleNamespace(**_structure("S9", "point", 90.0, xy)) + old = SimpleNamespace(**_structure("S9", "point", 140.0, _at(L2, 140.0))) + fill_structure_coordinates([item], L2, previous={"S9": old}) + assert (item.x, item.y) == xy + stale = SimpleNamespace(**_structure("S9", "point", 30.0, xy)) # 누가거리만 옮김 = 낡은 좌표 + fill_structure_coordinates([stale], L2, previous={"S9": old}) + assert _close(stale.x, _at(L2, 30.0)[0]) and _close(stale.y, _at(L2, 30.0)[1]) + + +def test_관_초안은_앞_따라감_좌표_옮긴_관은_노선_위(ts): + placed = {row["id"]: row for row in ts["pipesPlaced"]} + assert _close(placed["p1"]["x"], FOLLOWED[0]["x"]) and _close( + placed["p1"]["y"], FOLLOWED[0]["y"] + ) + assert _close(placed["p2"]["x"], _at(L1, 30.0)[0]) and _close(placed["p2"]["y"], 0.0) + vertices = [RouteVertex(x=x, y=y, z=0.0, chainage_m=0.0) for x, y in L2] + points = [ + PipePoint(chainage_m=70.0, x=FOLLOWED[0]["x"], y=FOLLOWED[0]["y"], id="p1"), + PipePoint(chainage_m=30.0, x=_at(L1, 30.0)[0], y=0.0, id="p2"), + ] + want = {point.id: point.chainage_m for point in project_pipe_points(points, vertices)} + got = {row["id"]: row["chainage_m"] for row in ts["pipesFollowed"]} + assert got == want diff --git a/resources/tester/test_411_b_pipe_id_pairing.py b/resources/tester/test_411_b_pipe_id_pairing.py new file mode 100644 index 000000000..368b400b1 --- /dev/null +++ b/resources/tester/test_411_b_pipe_id_pairing.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +"""411-B · 411-D 관 짝 = id — 앞 관을 지나 옮겨도 알약 · 측점 수 · id 그대로 · +B06 열쇠 = 계산 묶음과 같음. + +사용자 길(요구 252-6): 관 마커를 앞 관을 지나 옮김 → 옛 차례 짝이 이웃 id 를 빼앗고 +새 `pipe-sN` 을 지어 새 측점 · 계획고 0 이 생기던 흠. 진짜 TS 를 vite ssr 로 +묶어(`helper_411_b_entry.ts`) Node 로 잰다: + ① `followPipeReports` 짝 = 관 고정 id + ② 세션 id 장부(`movePipeId` · `movePipeIds`) + ③ `followedMarks` + ④ 배수 편집기 `followEditorPipes`(410 · 따라간 자리 받음) + ⑤ B06 `useStationOwners` · `stationKeysOf`. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + +pytestmark = pytest.mark.skipif( + not (ROOT / "config" / "node_modules" / "vite").is_dir(), + reason="프론트엔드 의존성 미설치", +) + + +@pytest.fixture(scope="module") +def out(tmp_path_factory): + work = tmp_path_factory.mktemp("b411") + build = subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"), + "build", + "--configLoader", + "runner", + "--ssr", + "../resources/tester/helper_411_b_entry.ts", + "--outDir", + str(work), + "--logLevel", + "error", + ], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=300, + ) + assert build.returncode == 0, build.stderr + bundle = work / "bundle.mjs" + shutil.copy(work / "helper_411_b_entry.js", bundle) + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_411_b_pipe_id_pairing.mjs"), str(bundle)], + cwd=ROOT, + capture_output=True, + text=True, + encoding="utf-8", + timeout=120, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_알림_목록_짝은_id(out): + # 옮긴 관(p15000)이 앞 관 자리를 빼앗지 않음 · 구간도 그 관과 함께 밂 + assert sorted(out["reports"]) == [["p10000", 100, None], ["p15000", 80, 75]] + + +def test_앞_관_지나_옮겨도_세션_id_그대로(out): + ids = out["ids"] + assert ids["at80"] == ids["idB"] # 옮긴 관 = 제 id + assert ids["at100"] == ids["idA"] # 이웃 관 = 제 id(새 pipe-sN 아님) + assert out["bookNext"] == "pipe-s3" # 헛 id 없음 = 알약 · 측점 수 그대로 + + +def test_따라감이_여럿을_함께_밀어도_기록_안_잃음(out): + batch = out["batch"] + assert batch["at103"] == batch["q1"] and batch["at106"] == batch["q2"] + assert batch["next"] == "pipe-s3" + + +def test_알약은_그_관_자리(out): + assert out["marks"] == [["pipe-s2", 80], ["pipe-s1", 100], ["st-1", 42]] + + +def test_배수_편집기는_따라간_자리를_받음(out): + editor = out["editor"] + assert editor["pipes"] == [["p15000", 98, "confirmed"], ["p10000", 103, "stream"]] + assert editor["selected"] == "p15000" # 고름도 그 관 + assert editor["ford98"] == "ford" and editor["at150"] is None # 시설 속성도 새 자리 + assert out["editorSame"] == [98, 103] + + +def test_B06_열쇠는_서버_관_id_도_pipe(out): + assert out["keys"] == ["structure:st-1", "pipe:p8017"] + assert out["keys"][1] == out["pipelineKey"] # 계산 묶음 측점 열쇠와 같음 + assert out["keysNoPipes"][1] == "structure:p8017" # 관 목록을 안 주면 옛 갈래(대조) From e7adda95c7e62c7f53282e49b52baae337c9dc56 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:29:31 +0900 Subject: [PATCH 09/17] =?UTF-8?q?fix(B05=20=EB=85=B8=EC=84=A0=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=8F=89=EB=A9=B4=20=EC=95=84=EB=9E=98=20=EA=B8=80?= =?UTF-8?q?=ED=8C=90):=20=EC=9E=B0=20=EA=B0=92=20=EC=B0=BD=20=C2=B7=20?= =?UTF-8?q?=ED=9A=A1=EB=8B=A8=20=ED=8C=90=EB=8F=84=20=EC=A2=85=EB=8B=A8=20?= =?UTF-8?q?=ED=8C=90=20=EC=9C=84=EB=A1=9C=20(408-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 잰 값 창 · 오른쪽 횡단 판(손잡이 · 아래 반) bottom 에 --b05-profile-cover 더함(408-2 값 재사용) - 판 열림 때 횡단 손잡이가 종단 판 밑이라 횡단을 못 열던 흠도 함께 - 시험 test_408_3_measure_side_lift 새로 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_UI_Style_RouteEdit.css | 9 +++--- .../tester/test_408_3_measure_side_lift.py | 28 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 resources/tester/test_408_3_measure_side_lift.py diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 7e643f4f0..e998a8b6d 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -184,13 +184,14 @@ } /* 오른쪽 덮는 판 — 패널 양식(382-1 · `ui_template_side_panels` · 손잡이 · 머리 · 폭 끌기) 을 지도 위에 얹음 · - 위아래 반씩 **지금 횡단** · **이전 횡단**(352-1) · 아래 종단 판(z 3)이 그 위를 덮음 · 빈 자리는 지도 조작을 통과. */ + 위아래 반씩 **지금 횡단** · **이전 횡단**(352-1) · 뜬 종단 판이 덮으면 그 위까지(408-3 · 손잡이 · 아래 반 보임) · + 빈 자리는 지도 조작을 통과. */ .b05-routeedit__side-wrap { position: absolute; z-index: 2; top: calc(var(--spacing-16) + 34px + var(--spacing-8)); /* 도구줄 · 회전 단추 줄 아래 */ right: var(--spacing-12); - bottom: var(--spacing-12); + bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px)); display: flex; min-height: 0; pointer-events: none; @@ -364,11 +365,11 @@ opacity: 0.5; } -/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */ +/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 그 위로 · 408-3). 닫으면 잰 것이 지워진다. */ .b05-routeedit__measure { position: absolute; right: calc(var(--spacing-12) + var(--b05-side-open)); - bottom: var(--spacing-12); + bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px)); z-index: 1; display: flex; align-items: flex-start; diff --git a/resources/tester/test_408_3_measure_side_lift.py b/resources/tester/test_408_3_measure_side_lift.py new file mode 100644 index 000000000..f2c1f4b44 --- /dev/null +++ b/resources/tester/test_408_3_measure_side_lift.py @@ -0,0 +1,28 @@ +"""408-3 잰 값 창 · 오른쪽 횡단 판(손잡이)도 뜬 종단 판 위로 — 408-2 올림 값(`--b05-profile-cover`) 재사용. + +판 열림 때 잰 값 창은 판 밑에 깔리고, 횡단 판 손잡이(판 가운데 높이)도 판 밑이라 횡단을 못 열었음. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_408_3_measure_side_lift.py -q +""" + +from pathlib import Path + +import pytest + +CSS = Path(__file__).resolve().parents[2] / "B05_Profile" / "B05_Profile_UI_Style_RouteEdit.css" +LIFT = "bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));" + + +def _rules(selector: str) -> list[str]: + """그 선택자의 규칙 덩이 모두(같은 선택자가 여러 덩이에 나뉨).""" + css = CSS.read_text(encoding="utf-8") + parts = css.split(f"{selector} {{")[1:] + return [part[: part.index("}")] for part in parts] + + +@pytest.mark.parametrize( + "selector", + [".b05-routeedit__info", ".b05-routeedit__measure", ".b05-routeedit__side-wrap"], +) +def test_지도_아래_글판_판은_종단_판_위로(selector) -> None: + assert any(LIFT in rule for rule in _rules(selector)) From 5269859ee2052e57e99d1da83cf0c8e52032de91 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:31:14 +0900 Subject: [PATCH 10/17] =?UTF-8?q?fix(B05=C2=B7B06=20=EC=B8=A1=EC=A0=90):?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EB=86=93=EC=9D=8C=20=C2=B7=20?= =?UTF-8?q?=EB=BA=8C=EC=97=90=EB=8F=84=20=EC=84=9C=EB=B2=84=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=20=EA=B3=A1=EC=84=A0=20=EC=B8=A1=EC=A0=90=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EC=85=88=20(407-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - [저장] 횡단 차례 ensure_structure_stations 가 곡선 측점 어긋남(_curve_marks)도 셈 · 노선 저장 안 기다림 - 다시 뜰 때 곡선 측점도 뜸(sample_structure_sections with_curves) · 종단 병합이 옛 곡선 측점도 갈음 - 측점 자리 한 벌 _station_layout 으로 뺌 · planned_curve_stations = generate_sections 와 같은 목록(표본 없음) - 시험: test_407_3_curve_station_follow(놓음 = 구조물 측점 하나 · 뺌 = 곡선 측점 되살아남) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B05_Profile/B05_Profile_Engine_Sections.py | 12 +- .../B05_Profile_Engine_Sections_Core.py | 85 ++++++++---- B05_Profile/B05_Profile_Router_Confirm.py | 11 +- B06_Section/B06_Section_Router_Stations.py | 53 +++++++ .../tester/test_407_3_curve_station_follow.py | 130 ++++++++++++++++++ 5 files changed, 260 insertions(+), 31 deletions(-) create mode 100644 resources/tester/test_407_3_curve_station_follow.py diff --git a/B05_Profile/B05_Profile_Engine_Sections.py b/B05_Profile/B05_Profile_Engine_Sections.py index a69c0a568..2acef599e 100644 --- a/B05_Profile/B05_Profile_Engine_Sections.py +++ b/B05_Profile/B05_Profile_Engine_Sections.py @@ -553,14 +553,18 @@ def sample_structure_sections( extra_stations: tuple[tuple[float, str], ...], options: SectionGenerationOptions | None = None, crs: str | None = None, + with_curves: bool = False, ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: """구조물 측점만 샘플링해 (측점 dict, 횡단 dict) 목록을 돌려준다 — 파일은 안 씀. + `with_curves` — 곡선 측점(`curve` · 252-2)도 같이(정본 쓰기 · 구조물이 MC 를 비우면 + 되살림 · 407-3). + 격자 측점과 똑같은 접선·오프셋 샘플링을 거치되(`generate_sections` 재사용) 구조물 측점 (`irregular` · 격자 · 시종점에 얹힌 것 `grid` 포함)만 골라낸다. [저장] 때 정본 쓰기 (`generate_irregular_sections`)와 B06 미리 뜨기(`preview_structure_sections`)가 같은 길(273-2). """ - if not extra_stations: + if not extra_stations and not with_curves: return [], [] base = options or SectionGenerationOptions() merged_options = SectionGenerationOptions( @@ -587,6 +591,8 @@ def sample_structure_sections( ) def structural(entry: dict[str, Any]) -> bool: + if with_curves and entry.get("kind") == "curve": + return True return entry.get("kind") == "irregular" or bool(entry.get("structure")) return ( @@ -606,7 +612,8 @@ def generate_irregular_sections( options: SectionGenerationOptions | None = None, crs: str | None = None, ) -> list[dict[str, Any]]: - """구조물 측점만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 돌려준다(빈 목록 = 없음). + """구조물 측점(곡선 측점 포함 · 407-3)만 샘플링해 **횡단 파일을 쓰고** 측점 dict 목록을 + 돌려준다(빈 목록 = 없음). 기존 종단 계획선·규칙 측점·횡단은 건드리지 않는다 — 받는 쪽이 목록 전체로 다시 맞춤(272). 각각 `cross_*.json`으로 저장한다. B06 상세 조회가 이 폴더를 glob하고 종단 파일의 stations로 @@ -621,6 +628,7 @@ def generate_irregular_sections( extra_stations=extra_stations, options=options, crs=crs, + with_curves=True, ) if not irregular_stations: return [] diff --git a/B05_Profile/B05_Profile_Engine_Sections_Core.py b/B05_Profile/B05_Profile_Engine_Sections_Core.py index cdeedbc5e..b1ef38200 100644 --- a/B05_Profile/B05_Profile_Engine_Sections_Core.py +++ b/B05_Profile/B05_Profile_Engine_Sections_Core.py @@ -381,32 +381,17 @@ def _design_curve_widenings( return radii, sides, widenings -def generate_sections( - polyline: np.ndarray | list[list[float]], - sampler: SurfaceElevationSampler, - options: SectionGenerationOptions | None = None, - *, - source_snapshot: dict[str, Any] | None = None, - crs: str | None = None, - design_curves: list[dict[str, Any]] | None = None, - station_snap: bool = False, - station_snap_step: float | None = None, -) -> dict[str, Any]: - """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다. - - `station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) · - `station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.). - MC 에 측점이 없으면 곡선 측점(kind `curve` · `curve_stations` · 요구 252-2) 하나를 더함. - """ - options = options or SectionGenerationOptions() - options.validate() - points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64)) +def _station_layout( + points: np.ndarray, + route_chainage: np.ndarray, + options: SectionGenerationOptions, + design_curves: list[dict[str, Any]] | None, + station_snap: bool, + station_snap_step: float | None, +) -> tuple[np.ndarray, list, set[float], dict[float, str], set[float]]: + """측점 자리 한 벌(지반 표본 없음) — (측점 누가거리 · 곡선 구간 · 격자 자리 · 구조물 측점 · + 곡선 측점). `generate_sections` · `planned_curve_stations`(B06 측점 맞춤 판정 · 407-3)가 같은 길.""" total = float(route_chainage[-1]) - - long_chainage = _chainages(total, options.long_sample_interval_m, True) - long_xy = _interpolate_xy(points, route_chainage, long_chainage) - long_z, long_valid = sampler.sample_xy(long_xy) - station_chainage = _chainages(total, options.station_interval_m, options.include_endpoint) spans = _design_curve_spans(points, route_chainage, design_curves or []) if station_snap: @@ -440,6 +425,56 @@ def generate_sections( curves = {round(value, 6) for value in curve_stations(station_chainage, spans, total, step)} if curves: station_chainage = np.unique(np.r_[station_chainage, np.array(sorted(curves))]) + return station_chainage, spans, regular_rounded, extras, curves + + +def planned_curve_stations( + polyline: np.ndarray | list[list[float]], + options: SectionGenerationOptions | None = None, + *, + design_curves: list[dict[str, Any]] | None = None, + station_snap: bool = False, + station_snap_step: float | None = None, +) -> list[float]: + """지금 노선 · 관 · 구조물(`options.extra_stations`)이 세울 곡선 측점 누가거리(오름차순) — + `generate_sections` 와 같은 값 · 표본은 안 뜸.""" + options = options or SectionGenerationOptions() + points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64)) + layout = _station_layout( + points, route_chainage, options, design_curves, station_snap, station_snap_step + ) + return sorted(layout[4]) + + +def generate_sections( + polyline: np.ndarray | list[list[float]], + sampler: SurfaceElevationSampler, + options: SectionGenerationOptions | None = None, + *, + source_snapshot: dict[str, Any] | None = None, + crs: str | None = None, + design_curves: list[dict[str, Any]] | None = None, + station_snap: bool = False, + station_snap_step: float | None = None, +) -> dict[str, Any]: + """확정 경로로 CAD 인계 가능한 종단·횡단 원시 데이터를 생성한다. + + `station_snap` — 호 안 측점 하나를 곡선 중점으로 옮김(`snap_curve_stations` · 요구 224-2) · + `station_snap_step` — 그 MC 측점 값 자릿수를 정하는 스냅 간격(390-5 · None = 측점 No.). + MC 에 측점이 없으면 곡선 측점(kind `curve` · `curve_stations` · 요구 252-2) 하나를 더함. + """ + options = options or SectionGenerationOptions() + options.validate() + points, route_chainage = _clean_polyline(np.asarray(polyline, dtype=np.float64)) + total = float(route_chainage[-1]) + + long_chainage = _chainages(total, options.long_sample_interval_m, True) + long_xy = _interpolate_xy(points, route_chainage, long_chainage) + long_z, long_valid = sampler.sample_xy(long_xy) + + station_chainage, spans, regular_rounded, extras, curves = _station_layout( + points, route_chainage, options, design_curves, station_snap, station_snap_step + ) station_xy = _interpolate_xy(points, route_chainage, station_chainage) tangents = np.vstack( [ diff --git a/B05_Profile/B05_Profile_Router_Confirm.py b/B05_Profile/B05_Profile_Router_Confirm.py index 31f2c8a4f..f2fdbccb3 100644 --- a/B05_Profile/B05_Profile_Router_Confirm.py +++ b/B05_Profile/B05_Profile_Router_Confirm.py @@ -300,7 +300,8 @@ def _merge_irregular_into_longitudinal( `irregular_stations` = 지금 관 · 구조물로 새로 뜬 구조물 측점 전부(격자와 같은 m 인 것은 `grid` 표시 · `generate_irregular_sections`). 옛 구조물 측점은 다 빼고(빈 목록이어도), 구조물이 떠난 격자 자리(`grid`)는 일반 측점으로 되돌리고, 새 목록의 격자 자리 측점은 그 - 격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 표고 샘플은 손대지 않는다. + 격자 측점을 갈음한다(같은 m 측점 하나 · 구조물이 위). 곡선 측점(`curve`)도 새 목록이 통째로 + 갈음(구조물이 MC 를 비우면 되살아남 · 407-3). 표고 샘플은 손대지 않는다. B06 상세는 종단 파일의 stations 에 맞는 cross 파일만 노출하고 남은 파일을 지운다 (`prune_stale_cross_files`). 계획선은 사라진 구조물 측점 자리만 고친다(`_follow_moved_stations` — 바뀌면 새 등급 요약을 돌려줌). 파일 기반이라 다시 맞춰도 중복이 생기지 않는다. @@ -318,14 +319,16 @@ def _merge_irregular_into_longitudinal( before = [ float(station.get("chainage_m", 0.0)) for station in stations - if station.get("kind") == "irregular" + if station.get("kind") in ("irregular", "curve") ] fresh = {round(float(s.get("chainage_m", 0.0)), 6): s for s in irregular_stations} kept: dict[float, dict[str, Any]] = {} left: list[dict[str, Any]] = [] for station in stations: - if station.get("kind") == "irregular" and not station.get("grid"): - continue # 옛 구조물 측점 — 새 목록이 갈음 + if station.get("kind") == "curve" or ( + station.get("kind") == "irregular" and not station.get("grid") + ): + continue # 옛 구조물 측점 · 곡선 측점(407-3) — 새 목록이 갈음 key = round(float(station.get("chainage_m", 0.0)), 6) if key not in fresh and ("structure" in station or station.get("grid")): # 구조물이 떠난 격자 · 시종점 자리 — 일반 측점으로(옛 판 「regular + 글」 포함) diff --git a/B06_Section/B06_Section_Router_Stations.py b/B06_Section/B06_Section_Router_Stations.py index 3dcfde6c2..dd0ebd5e2 100644 --- a/B06_Section/B06_Section_Router_Stations.py +++ b/B06_Section/B06_Section_Router_Stations.py @@ -156,6 +156,47 @@ def _stale_marks(project_root: Path, route_data_path: str) -> int: return len(standing ^ planted) +def _curve_marks(project_root: Path, route_data_path: str, options: Any) -> int: + """종단 정본의 곡선 측점(`curve`)과 **지금 노선 · 관 · 구조물**이 세울 곡선 측점이 어긋난 수 + (407-3 — 구조물을 MC 위에 놓거나 빼면 바뀜 · 화면 초안 `buildStations` 와 같은 목록). + 종단 정본이 없으면 0.""" + import json + from dataclasses import replace + + from B05_Profile.B05_Profile_Engine_Sections import ( + _load_design_curves, + _load_pipe_points, + _load_route_polyline, + load_station_snap, + load_station_snap_step, + resolve_extra_stations, + ) + from B05_Profile.B05_Profile_Engine_Sections_Core import planned_curve_stations + + files = sorted((project_root / "B06_Section" / "longitudinal").glob("*.json")) + if not files: + return 0 + standing: set[float] = set() + for path in files: + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + for station in data.get("stations") or []: + if station.get("kind") == "curve": + standing.add(round(float(station.get("chainage_m") or 0.0), 2)) + polyline = _load_route_polyline(project_root, route_data_path) + extras = resolve_extra_stations(project_root, _load_pipe_points(project_root, polyline)) + planned = planned_curve_stations( + polyline, + replace(options, extra_stations=extras), + design_curves=_load_design_curves(project_root), + station_snap=load_station_snap(project_root), + station_snap_step=load_station_snap_step(project_root), + ) + return len({round(value, 2) for value in planned} ^ standing) + + async def _project_paths(project_id: UUID) -> tuple[Path, dict[str, Any]] | None: async def _load(connection): stored = await get_project_storage_relative_path(connection, project_id) @@ -250,6 +291,7 @@ async def _regenerate_structure_stations( async def ensure_structure_stations(project_id: UUID) -> int: """빠진 구조물 측점을 세우고 낡은 것을 지운다 — 횡단 저장마다 부름(맞출 것이 있을 때만 돎). + 곡선 측점이 지금 관 · 구조물과 어긋나도 맞춤(407-3 · [저장] 의 관 · 구조물 차례 뒤). 옮긴 관 · 구조물 측점은 B05 확정 전이면 종단 측점 · 횡단 파일 · DB 행이 없어, 그 자리 횡단 수정이 받을 곳 없이 버려진다. [측점 만들기] 단추와 같은 조건으로 세운다. @@ -262,6 +304,17 @@ async def ensure_structure_stations(project_id: UUID) -> int: missing = await asyncio.to_thread(_missing_marks, project_root, str(route["route_data_path"])) # 빠진 것만이 아니라 낡은 것(옮긴 관 옛 자리 · 떠난 격자 자리)도 맞춤 — 두 벌로 남지 않게(272) stale = await asyncio.to_thread(_stale_marks, project_root, str(route["route_data_path"])) + # 곡선 측점도 지금 관 · 구조물로 — MC 위 구조물을 빼면 되살아남(노선 저장 안 기다림 · 407-3) + from B05_Profile.B05_Profile_Router_Confirm import _section_options_from_stored + from B06_Section.B06_Section_Repository import get_latest_section_options + + stored = await run_with_connection(get_latest_section_options, project_id) + stale += await asyncio.to_thread( + _curve_marks, + project_root, + str(route["route_data_path"]), + _section_options_from_stored(stored), + ) if not missing and not stale: return 0 snapshot = await _sampling_conditions(project_id, project_root) diff --git a/resources/tester/test_407_3_curve_station_follow.py b/resources/tester/test_407_3_curve_station_follow.py new file mode 100644 index 000000000..be8fabe9d --- /dev/null +++ b/resources/tester/test_407_3_curve_station_follow.py @@ -0,0 +1,130 @@ +"""PLAN 407-3 — 구조물을 MC 위에 놓았다 빼면 서버 종단 정본의 곡선 측점이 되살아남. + +[저장] 차례(관 · 구조물 → 횡단) 의 `ensure_structure_stations` 길 — `_curve_marks`(어긋남 판정) · +`sample_structure_sections(with_curves=True)`(곡선 측점도 뜸) · `_merge_irregular_into_longitudinal` +(옛 곡선 측점도 새 목록이 갈음). 노선 저장을 안 기다림 · 놓은 동안엔 그 자리 구조물 측점 하나. +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import numpy as np +import pytest + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B05_Profile import B05_Profile_Engine_Sections as sections # noqa: E402 +from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402 +from B05_Profile.B05_Profile_Router_Confirm import ( # noqa: E402 + _merge_irregular_into_longitudinal, +) +from B06_Section.B06_Section_Router_Stations import _curve_marks # noqa: E402 + +LINE = [[0.0, 0.0, 100.0], [1000.3, 0.0, 100.0]] +SHORT = {"start": [101.0, 0.0], "apex": [107.3, 2.0], "end": [113.6, 0.0], "radius_m": 30.0} +LONG = {"start": [616.1, 0.0], "apex": [631.3, 3.0], "end": [646.5, 0.0], "radius_m": 40.0} + + +class _Flat: + def sample_xy(self, xy): + count = len(np.asarray(xy, dtype=np.float64)) + return np.full(count, 100.0), np.ones(count, dtype=bool) + + +@pytest.fixture +def project(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict: + """노선 · 곡선표 · 관 · 구조물 정본 대신 — 바꿀 수 있는 구조물 측점 하나(`extras`).""" + state: dict = {"extras": ()} + monkeypatch.setattr(sections, "_load_route_polyline", lambda *_: LINE) + monkeypatch.setattr(sections, "_load_design_curves", lambda *_: [SHORT, LONG]) + monkeypatch.setattr(sections, "load_station_snap", lambda *_: False) + monkeypatch.setattr(sections, "load_station_snap_step", lambda *_: None) + monkeypatch.setattr(sections, "build_surface_sampler", lambda *_, **__: _Flat()) + monkeypatch.setattr(sections, "_load_pipe_points", lambda *_: []) + monkeypatch.setattr(sections, "resolve_extra_stations", lambda *_: state["extras"]) + # 노선 저장 때 정본 = 전체 생성(곡선 측점 107 · 631) + full = core.generate_sections(LINE, _Flat(), design_curves=[SHORT, LONG])["longitudinal"] + folder = tmp_path / "B06_Section" / "longitudinal" + folder.mkdir(parents=True) + (tmp_path / "B06_Section" / "cross_sections").mkdir() + (folder / "longitudinal.json").write_text(json.dumps(full), encoding="utf-8") + state["root"] = tmp_path + return state + + +def _save(state: dict) -> list[dict]: + """[저장] 의 횡단 차례 — 어긋나면 다시 떠 병합(`ensure_structure_stations` 와 같은 길).""" + root = state["root"] + options = core.SectionGenerationOptions() + if _curve_marks(root, "route.json", options) == 0 and not state["extras"]: + return _stations(root) + fresh = sections.generate_irregular_sections( + root, "route.json", "f", "m", False, extra_stations=state["extras"], options=options + ) + _merge_irregular_into_longitudinal(root, "B06_Section/longitudinal/longitudinal.json", fresh) + return _stations(root) + + +def _stations(root: Path) -> list[dict]: + path = root / "B06_Section" / "longitudinal" / "longitudinal.json" + return json.loads(path.read_text(encoding="utf-8"))["stations"] + + +def _at(stations: list[dict], chainage: float) -> list[str]: + return [s["kind"] for s in stations if abs(s["chainage_m"] - chainage) < 0.01] + + +def test_처음은_맞음(project) -> None: + assert _curve_marks(project["root"], "route.json", core.SectionGenerationOptions()) == 0 + assert _at(_stations(project["root"]), 107.0) == ["curve"] + + +def test_MC_위_구조물_놓음_뺌(project) -> None: + root = project["root"] + project["extras"] = ((107.0, "관"),) + assert _curve_marks(root, "route.json", core.SectionGenerationOptions()) == 1 + placed = _save(project) + assert _at(placed, 107.0) == ["irregular"] # 놓은 동안 = 구조물 측점 하나 + assert _at(placed, 631.0) == ["curve"] # 다른 곡선 측점은 그대로 + assert list((root / "B06_Section" / "cross_sections").glob("cross_*.json")) + + project["extras"] = () + assert _curve_marks(root, "route.json", core.SectionGenerationOptions()) == 1 + removed = _save(project) + assert _at(removed, 107.0) == ["curve"] # 노선 저장 없이 되살아남 + assert _at(removed, 631.0) == ["curve"] + assert _curve_marks(root, "route.json", core.SectionGenerationOptions()) == 0 + assert len(removed) == len(_stations_full()) + + +def _stations_full() -> list[dict]: + return core.generate_sections(LINE, _Flat(), design_curves=[SHORT, LONG])["longitudinal"][ + "stations" + ] + + +def test_planned_curve_stations_는_생성과_같음() -> None: + extras = ((107.0, "관"), (300.0, "옹벽")) + for snap, step in [(False, None), (True, None), (True, 0.1)]: + options = core.SectionGenerationOptions(extra_stations=extras) + made = core.generate_sections( + LINE, + _Flat(), + options, + design_curves=[SHORT, LONG], + station_snap=snap, + station_snap_step=step, + )["longitudinal"]["stations"] + planned = core.planned_curve_stations( + LINE, + options, + design_curves=[SHORT, LONG], + station_snap=snap, + station_snap_step=step, + ) + assert planned == [s["chainage_m"] for s in made if s["kind"] == "curve"] From e8f2ae02d55f53b3239f042937f0e683739688cb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:31:15 +0900 Subject: [PATCH 11/17] =?UTF-8?q?fix(B06=20=EC=A2=85=EB=8B=A8):=20?= =?UTF-8?q?=EA=B0=80=EA=B9=8C=EC=9A=B4=20=EC=B8=A1=EC=A0=90=20=C2=B7=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=AC=BC=20=EC=9D=B4=EB=A6=84=20=EA=B2=B9?= =?UTF-8?q?=EC=B9=98=EB=A9=B4=20=EC=9C=84=20=EB=8B=A8=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EB=B9=84=EC=BC=9C=20=EA=B7=B8=EB=A6=BC=20(407-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logic/B06_Section_Label_Tiers 새로(labelTiers · labelWidthPx) · 이름은 지우지 않음 · 위 끝 3단 - 올린 측점 이름 = 맨 위 겹 · 바탕색 테두리 · 고른 측점 색 그대로 · 구조물 이름은 올린 측점 이름 위로 - B05 종단도 같은 함수(createLongitudinalProfile) - 시험: test_407_4_label_tiers Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B06_Section/B06_Section_UI_Longitudinal.ts | 58 ++++++++++---- B06_Section/B06_Section_UI_Style_Cross.css | 12 ++- B06_Section/logic/B06_Section_Label_Tiers.ts | 31 ++++++++ resources/tester/test_407_4_label_tiers.py | 79 ++++++++++++++++++++ 4 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 B06_Section/logic/B06_Section_Label_Tiers.ts create mode 100644 resources/tester/test_407_4_label_tiers.py diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index 86335e0fc..f157b44f7 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import type { DesignProfile, LongitudinalSection } from "./B06_Section_Api_Fetch"; +import { labelTiers, labelWidthPx } from "./logic/B06_Section_Label_Tiers"; import { emptyView, inferStationInterval, @@ -189,6 +190,8 @@ function attachStationDrag( /** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */ const STATION_DRAG_SLOP_PX = 3; +/** 겹친 이름표 한 단 높이(px · 10px 글 + 틈 1). */ +const LABEL_TIER_PX = 11; export function createLongitudinalProfile( data: LongitudinalSection, @@ -392,9 +395,38 @@ export function createLongitudinalProfile( // 구조물 이름은 **맨 마지막에** 붙인다 — 측점 마커 안에 넣으면 뒤이어 그리는 지반선· // 계획선이 그 위를 덮는다(2026-08-02 사용자 지시: 오버레이 순서 최상위). const structureLabels: SVGTextElement[] = []; - for (const station of data.stations) { + // 가까운 측점끼리 이름이 겹치면 위 단으로 비킴(407-4) — 구조물 이름은 올라간 측점 이름 위로. + const stationTexts = data.stations.map( + (station) => + offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset) + + (station.kind === "curve" ? " (MC)" : ""), + ); + const stationTiers = labelTiers( + data.stations.map((station, index) => ({ + x: x(station.chainage_m), + width: labelWidthPx(stationTexts[index], 10), + })), + ); + const named = data.stations.filter((station) => station.structure); + const namedTiers = labelTiers( + named.map((station) => ({ + x: x(station.chainage_m), + width: labelWidthPx(structureDisplayName(station.structure!), 9), + })), + ); + const structureLift = LABEL_TIER_PX * Math.max(0, ...stationTiers); + data.stations.forEach((station, stationIndex) => { const stationX = x(station.chainage_m); const selected = station.station_id === selectedStationId; + const tier = stationTiers[stationIndex]; + // 올라간 이름 = 선 · 지반선 위에 얹음(맨 끝 겹 · 바탕색 테두리) + const label = svgText(stationTexts[stationIndex], { + x: stationX, + y: heightPx - bottomInsetPx - 5 - LABEL_TIER_PX * tier, + "text-anchor": "middle", + class: `b06-chart__station-label${tier ? " b06-chart__station-label--lifted" : ""}${tier && selected ? " b06-chart__station-label--selected" : ""}`, + }); + if (tier) structureLabels.push(label); const marker = svgElement("g", { class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`, // B05 [직선화]·[쉬프트] 도구가 클릭한 측점의 누가거리를 여기서 읽는다(2026-09-02). @@ -426,19 +458,10 @@ export function createLongitudinalProfile( y2: heightPx - padBottom, class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.plus ? "plus" : station.kind}`, }), - // 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점 - // 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시). - svgText( - offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset) + - (station.kind === "curve" ? " (MC)" : ""), - { - x: stationX, - y: heightPx - bottomInsetPx - 5, - "text-anchor": "middle", - class: "b06-chart__station-label", - }, - ), ); + // 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점 + // 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시). + if (!tier) marker.append(label); // 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다. // 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가 // 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시). @@ -447,14 +470,19 @@ export function createLongitudinalProfile( // 배수관은 관종·관경 대신 표시 이름 하나로 적는다(2026-09-03 — 횡단 카드와 같은 규칙). svgText(structureDisplayName(station.structure), { x: stationX, - y: heightPx - padBottom - 5, + y: + heightPx - + padBottom - + 5 - + structureLift - + LABEL_TIER_PX * namedTiers[named.indexOf(station)], "text-anchor": "middle", class: "b06-chart__structure-label", }), ); } svg.append(marker); - } + }); const points = samples .map((sample) => { diff --git a/B06_Section/B06_Section_UI_Style_Cross.css b/B06_Section/B06_Section_UI_Style_Cross.css index d463e6851..7b5c83036 100644 --- a/B06_Section/B06_Section_UI_Style_Cross.css +++ b/B06_Section/B06_Section_UI_Style_Cross.css @@ -408,6 +408,15 @@ html.is-bm-route .b06-section__notice-bar { font-weight: var(--font-weight-medium); } +/* 겹쳐 위 단으로 올린 측점 이름(407-4) — 선 · 지반선 위라 바탕색 테두리로 읽히게. */ +.b06-chart__station-label--lifted { + paint-order: stroke; + stroke: var(--color-surface-raised); + stroke-width: 3px; + stroke-linejoin: round; + pointer-events: none; +} + /* 구조물(비정규) 측점 이름 — 종단면도 측점선 상단 표기. */ .b06-chart__structure-label { font-size: 9px; @@ -517,7 +526,8 @@ html.is-bm-route .b06-section__notice-bar { stroke: var(--color-royal-amethyst); } -.b06-chart__station--selected .b06-chart__station-label { +.b06-chart__station--selected .b06-chart__station-label, +.b06-chart__station-label--selected { fill: var(--color-danger); font-weight: var(--font-weight-bold); } diff --git a/B06_Section/logic/B06_Section_Label_Tiers.ts b/B06_Section/logic/B06_Section_Label_Tiers.ts new file mode 100644 index 000000000..1d7596dfc --- /dev/null +++ b/B06_Section/logic/B06_Section_Label_Tiers.ts @@ -0,0 +1,31 @@ +/* ============================================================================= + * B06_Section/logic/B06_Section_Label_Tiers.ts + * 종단 측점 · 구조물 이름표 비켜 그리기(PLAN 407-4) — 가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단으로 + * 올림 · 이름은 지우지 않음. 그리는 곳 = `B06_Section_UI_Longitudinal`(B05 종단도 같은 함수). + * 시험 `resources/tester/test_407_4_label_tiers.py`. 화면 · 세션 없음(순수). + * ========================================================================== */ + +/** 단 수 위 끝 — 넘으면 마지막 단에 겹쳐 둠(이름은 남김). */ +export const MAX_LABEL_TIER = 3; + +/** 글 폭 짐작(px) — 한글 · 한자 = 글자 크기 · 그 밖 = 0.6 배. */ +export const labelWidthPx = (text: string, fontPx: number): number => + [...text].reduce((sum, ch) => sum + (ch.charCodeAt(0) > 0x2e80 ? fontPx : fontPx * 0.6), 0); + +/** 가운데 맞춤 이름표마다 단(0 = 제자리) — 왼쪽부터 겹치지 않는 가장 낮은 단 · `gapPx` = 글 사이 틈. */ +export function labelTiers( + items: ReadonlyArray<{ x: number; width: number }>, + gapPx = 3, +): number[] { + const order = items.map((_, index) => index).sort((a, b) => items[a].x - items[b].x); + const rightEdge: number[] = []; // 단마다 마지막 이름표 오른쪽 끝 + const tiers = items.map(() => 0); + for (const index of order) { + const { x, width } = items[index]; + let tier = rightEdge.findIndex((edge) => edge + gapPx <= x - width / 2); + if (tier < 0) tier = Math.min(rightEdge.length, MAX_LABEL_TIER); + rightEdge[tier] = Math.max(rightEdge[tier] ?? -Infinity, x + width / 2); + tiers[index] = tier; + } + return tiers; +} diff --git a/resources/tester/test_407_4_label_tiers.py b/resources/tester/test_407_4_label_tiers.py new file mode 100644 index 000000000..9725a290c --- /dev/null +++ b/resources/tester/test_407_4_label_tiers.py @@ -0,0 +1,79 @@ +"""PLAN 407-4 — 종단 측점 · 구조물 이름표 비켜 그리기(`logic/B06_Section_Label_Tiers.ts`). + +가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단 · 안 겹치면 제자리(0) · 이름은 지우지 않음 · +단 수 위 끝(`MAX_LABEL_TIER`) 넘으면 마지막 단 · 글 폭 짐작(한글 = 글자 크기). +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS = ROOT / "B06_Section" / "logic" / "B06_Section_Label_Tiers.ts" +TYPESCRIPT = ROOT / "config" / "node_modules" / "typescript" + +SCRIPT = r""" +const ts = require(process.argv[1]); +const fs = require("fs"); +const js = ts.transpileModule(fs.readFileSync(process.argv[2], "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const m = box.exports; +const cases = JSON.parse(process.argv[3]); +process.stdout.write(JSON.stringify({ + max: m.MAX_LABEL_TIER, + tiers: cases.map((items) => m.labelTiers(items)), + widths: ["19+18 (MC)", "20+0", "배수관"].map((t) => m.labelWidthPx(t, 10)), +})); +""" + +W = 40.0 # 이름표 폭(px) +CASES = [ + [{"x": 100, "width": W}, {"x": 200, "width": W}, {"x": 300, "width": W}], # 안 겹침 + [{"x": 100, "width": W}, {"x": 108, "width": 24}], # 1.6 m 이웃 — 겹침 + [{"x": 108, "width": 24}, {"x": 100, "width": W}], # 차례 뒤바뀜(x 로 셈) + [{"x": 100 + i * 2, "width": W} for i in range(6)], # 여섯 겹 — 위 끝 + [{"x": 100, "width": W}, {"x": 110, "width": W}, {"x": 150, "width": W}], # 셋째는 제자리 +] + + +@pytest.fixture(scope="module") +def r() -> dict: + proc = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS), json.dumps(CASES)], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_안_겹치면_제자리(r) -> None: + assert r["tiers"][0] == [0, 0, 0] + + +def test_겹치면_위_단(r) -> None: + assert r["tiers"][1] == [0, 1] + assert r["tiers"][2] == [1, 0] # 왼쪽 것이 제자리 + + +def test_위_끝_넘으면_마지막_단(r) -> None: + assert r["tiers"][3] == [0, 1, 2, 3, 3, 3] # 이름은 남음(지우지 않음) + + +def test_빈_단은_다시_씀(r) -> None: + assert r["tiers"][4] == [0, 1, 0] + + +def test_글_폭(r) -> None: + mc, plain, korean = r["widths"] + assert mc == pytest.approx(60.0) and plain == pytest.approx(24.0) + assert korean == pytest.approx(30.0) From 98755730e320bf5eb6891d5f29071af0d141b970 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:34:12 +0900 Subject: [PATCH 12/17] =?UTF-8?q?fix(B06=20=EC=A2=85=EB=8B=A8):=20?= =?UTF-8?q?=EC=98=AC=EB=A6=B0=20=EC=B8=A1=EC=A0=90=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=ED=95=9C=20=EB=8B=A8=2013=20px=20=E2=80=94=20B05=20=EC=A2=85?= =?UTF-8?q?=EB=8B=A8=EC=97=90=EC=84=9C=20=EC=B6=95=EC=84=A0=EC=97=90=20?= =?UTF-8?q?=EC=95=88=20=EA=B1=B8=EC=B9=A8=20(407-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B06_Section/B06_Section_UI_Longitudinal.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/B06_Section/B06_Section_UI_Longitudinal.ts b/B06_Section/B06_Section_UI_Longitudinal.ts index f157b44f7..44b7f8fac 100644 --- a/B06_Section/B06_Section_UI_Longitudinal.ts +++ b/B06_Section/B06_Section_UI_Longitudinal.ts @@ -190,8 +190,8 @@ function attachStationDrag( /** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */ const STATION_DRAG_SLOP_PX = 3; -/** 겹친 이름표 한 단 높이(px · 10px 글 + 틈 1). */ -const LABEL_TIER_PX = 11; +/** 겹친 이름표 한 단 높이(px · 10px 글 + 틈 · B05 종단은 한 단 올리면 축선 바로 위). */ +const LABEL_TIER_PX = 13; export function createLongitudinalProfile( data: LongitudinalSection, From 75706f24f816d73c8ce184d10a99798e5c931539 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:37:26 +0900 Subject: [PATCH 13/17] =?UTF-8?q?fix(B05=20=EB=85=B8=EC=84=A0=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=ED=8F=89=EB=A9=B4=20=EC=95=84=EB=9E=98=20=EA=B8=80?= =?UTF-8?q?=ED=8C=90):=20=ED=9A=A1=EB=8B=A8=20=ED=8C=90=20=EB=84=93?= =?UTF-8?q?=EC=9D=84=20=EB=95=8C=20=EC=83=81=ED=83=9C=20=EA=B8=80=ED=8C=90?= =?UTF-8?q?=20=C2=B7=20=EC=9E=B0=20=EA=B0=92=20=EC=B0=BD=20=EC=95=88=20?= =?UTF-8?q?=EA=B2=B9=EC=B9=A8=20(408-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 횡단 판 왼쪽 빈 폭을 두 글판이 반씩(--b05-bottom-half) · 좁으면 줄바꿈 - 반 폭 200 px 아래면 잰 값 창을 상태 글판 위로 쌓음(is-narrow-bottom · --b05-info-h · logic bottomRowNarrow) - Chrome 관찰에 상태 글판 · 횡단 판 크기 더함 - 시험 test_408_4_bottom_row 새로 · 345_5c 폭 규칙 고쳐 씀 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Ji4nuyLVHcxrXWHTdb5nRD --- .../B05_Profile_UI_RouteEdit_Chrome.ts | 13 +++- .../B05_Profile_UI_Style_RouteEdit.css | 18 ++++- .../logic/B05_Profile_RouteEdit_InfoLift.ts | 11 ++++ resources/tester/helper_408_2_status_lift.cjs | 4 +- .../tester/test_345_5c_info_hint_collapse.py | 3 +- resources/tester/test_408_4_bottom_row.py | 65 +++++++++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 resources/tester/test_408_4_bottom_row.py diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 46bf45d9e..ad721cc0f 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -2,7 +2,7 @@ import { createButton } from "@ui/ui_template_elements"; import { createSidePanels, type SidePanels } from "@ui/ui_template_side_panels"; import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks"; -import { infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift"; +import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift"; /* ============================================================================= * B05_Profile_UI_RouteEdit_Chrome.ts @@ -207,7 +207,8 @@ export function createRouteEditChrome( infoToggle.addEventListener("click", () => setInfo(!infoCollapsed)); // 뜬 종단 판(이웃 칸)이 지도 아래를 덮으면 상태 · 범례를 그 위로(408-2) — 판 높이 끌기 · 접기 · - // 창 분할(판이 다른 창으로 감 = 못 찾음)을 판 크기 관찰로 따라감. + // 창 분할(판이 다른 창으로 감 = 못 찾음)을 판 크기 관찰로 따라감. 횡단 판이 넓어 아래 줄이 좁으면 잰 값 창을 + // 상태 글판 위로 쌓음(408-4 · 횡단 판 · 글판 크기도 관찰). let watchedProfile: Element | null = null; const liftInfo = (): void => { if (!overlay.isConnected) return; // 붙기 전 첫 알림 · 걷힌 뒤 — 판 관찰은 판과 함께 사라짐 @@ -220,9 +221,17 @@ export function createRouteEditChrome( "--b05-profile-cover", `${infoLiftPx(map.bottom, map.height, top)}px`, ); + const sideOpen = parseFloat(overlay.style.getPropertyValue("--b05-side-open")) || 0; + overlay.classList.toggle("is-narrow-bottom", bottomRowNarrow(map.width, sideOpen)); + overlay.style.setProperty( + "--b05-info-h", + `${Math.round(info.getBoundingClientRect().height)}px`, + ); }; const lift = new ResizeObserver(liftInfo); lift.observe(overlay); + lift.observe(info); + lift.observe(overlay.querySelector(".b05-routeedit__side-wrap")!); const measureCloseButton = createButton({ label: "✕", variant: "ghost" }); measureCloseButton.classList.add("b05-routeedit__measure-close"); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index e998a8b6d..4d4584636 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -5,6 +5,9 @@ (`.b05-route__view-controls` · ☰ 비킴 `--wf-burger-pad`) · 횡단 판은 오른쪽을 덮는 판. */ .b05-routeedit { --b05-side-open: 0px; /* 횡단 판이 지도를 덮는 폭(손잡이 포함 · `_Chrome` 이 적음) */ + /* 아래 줄 반 폭(408-4) — 횡단 판 왼쪽 빈 폭을 상태 글판 · 잰 값 창이 반씩(안 겹침 · 좁으면 줄바꿈) · + % 는 쓰는 자리(지도 칸) 기준 · 반이 모자라면(`is-narrow-bottom` · `_Chrome`) 빈 폭 전부 · 위아래로 쌓음. */ + --b05-bottom-half: max(0px, calc((100% - 3 * var(--spacing-12) - var(--b05-side-open)) / 2)); position: relative; display: flex; flex: 1 1 auto; @@ -97,7 +100,7 @@ grid-template-columns: minmax(0, 1fr) auto auto; align-items: start; gap: 0 var(--spacing-12); - max-width: min(460px, 46%); + max-width: min(460px, 46%, var(--b05-bottom-half)); padding: var(--spacing-4, 4px) var(--spacing-8); line-height: 1.45; } @@ -374,7 +377,7 @@ display: flex; align-items: flex-start; gap: var(--spacing-8); - max-width: 52%; + max-width: var(--b05-bottom-half); padding: var(--spacing-8) var(--spacing-12); border: 1px solid color-mix(in srgb, #22c55e 60%, transparent); border-radius: var(--radius-8, 6px); @@ -385,6 +388,17 @@ font-size: var(--text-caption); } +/* 아래 줄 좁음(408-4) — 잰 값 창은 상태 글판 위에 · 둘 다 횡단 판 왼쪽 빈 폭 전부. */ +.b05-routeedit.is-narrow-bottom { + --b05-bottom-half: max(0px, calc(100% - 2 * var(--spacing-12) - var(--b05-side-open))); +} + +.b05-routeedit.is-narrow-bottom .b05-routeedit__measure { + bottom: calc( + var(--spacing-12) + var(--b05-profile-cover, 0px) + var(--b05-info-h, 0px) + var(--spacing-8) + ); +} + /* 글이 길면 접힌다 — flex 자식은 기본으로 안 줄어들어 왼쪽으로 넘쳐 잘렸다(2026-09-12). */ .b05-routeedit__measure-text { min-width: 0; diff --git a/B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts b/B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts index 85671a2c8..5b57438a7 100644 --- a/B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts +++ b/B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts @@ -1,6 +1,7 @@ /* ============================================================================= * B05_Profile/logic/B05_Profile_RouteEdit_InfoLift.ts * 평면 상태 · 범례 올림(408-2) — 뜬 종단 판이 지도 아래를 덮은 높이만큼 위로. + * 아래 줄 좁음(408-4) — 횡단 판 왼쪽 빈 폭의 반이 모자라면 잰 값 창을 상태 글판 위로 쌓음. * 파이썬 짝 아님. 순수 계산 — 화면 · fetch · sessionStorage 없음. 부르는 쪽은 `_UI_RouteEdit_Chrome`. * ========================================================================== */ @@ -17,3 +18,13 @@ export function infoLiftPx( const cover = Math.max(0, mapBottom - profileTop); return Math.round(Math.min(cover, Math.max(0, mapHeight - KEEP_PX))); } + +/** 아래 줄 반 폭이 이보다 좁으면 나란히 두지 않고 쌓음(px). */ +const HALF_MIN_PX = 200; +/** 지도 칸 가장자리 · 두 글판 사이 · 횡단 판 앞 간격(px) — CSS `--spacing-12`. */ +const GAP_PX = 12; + +/** 상태 글판 · 잰 값 창을 나란히 못 두나 — `mapWidth` = 지도 칸 폭 · `sideOpen` = 횡단 판이 덮는 폭. */ +export function bottomRowNarrow(mapWidth: number, sideOpen: number): boolean { + return (mapWidth - 3 * GAP_PX - sideOpen) / 2 < HALF_MIN_PX; +} diff --git a/resources/tester/helper_408_2_status_lift.cjs b/resources/tester/helper_408_2_status_lift.cjs index 887add89c..5b5ba8d0e 100644 --- a/resources/tester/helper_408_2_status_lift.cjs +++ b/resources/tester/helper_408_2_status_lift.cjs @@ -19,7 +19,7 @@ const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { }).outputText; const box = { exports: {} }; new Function("exports", "module", "require", js)(box.exports, box, require); -const { infoLiftPx } = box.exports; +const { infoLiftPx, bottomRowNarrow } = box.exports; // 화면 1078 px · 지도 칸 0~1078 process.stdout.write( @@ -29,5 +29,7 @@ process.stdout.write( popout: infoLiftPx(1078, 1078, null), // 창 분할 = 판 없음 below: infoLiftPx(600, 600, 700), // 횡단 보기 = 판이 지도 아래 흐름 tall: infoLiftPx(1078, 1078, 20), // 판을 끝까지 올림 = 지도 안에 남김 + // 408-4 지도 칸 1172 px · 횡단 판 접힘(손잡이 32) · 460 열림 · 891 넓힘 — 반 폭 200 아래면 쌓음 + narrow: [32, 492, 891, 736, 737].map((side) => bottomRowNarrow(1172, side)), }), ); diff --git a/resources/tester/test_345_5c_info_hint_collapse.py b/resources/tester/test_345_5c_info_hint_collapse.py index 70d0c8b9d..7497d6665 100644 --- a/resources/tester/test_345_5c_info_hint_collapse.py +++ b/resources/tester/test_345_5c_info_hint_collapse.py @@ -58,7 +58,8 @@ def test_상태_글판_작게_반투명_자리() -> None: info = _rule(".b05-routeedit__info") # 왼쪽 아래 제자리 — 뜬 판이 덮으면 그 위로(408-2) · 폭 줄임 assert "bottom: calc(var(--spacing-12) + var(--b05-profile-cover, 0px));" in info - assert "max-width: min(460px, 46%);" in info and "grid-template-columns:" in info + assert "max-width: min(460px, 46%, var(--b05-bottom-half));" in info # 408-4 반 폭 + assert "grid-template-columns:" in info # 상태 글 두 줄에서 자름 · 범례는 옆 칸 두 줄 assert "-webkit-line-clamp: 2;" in _rule(".b05-routeedit__status") assert "flex-direction: column;" in _rule(".b05-routeedit__legend") diff --git a/resources/tester/test_408_4_bottom_row.py b/resources/tester/test_408_4_bottom_row.py new file mode 100644 index 000000000..da84dfab9 --- /dev/null +++ b/resources/tester/test_408_4_bottom_row.py @@ -0,0 +1,65 @@ +"""408-4 횡단 판이 넓을 때 상태 글판 · 잰 값 창 겹침 — 횡단 판 왼쪽 빈 폭을 반씩 나눠 쓰고, 반이 200 px +아래면(`bottomRowNarrow`) 잰 값 창을 상태 글판 위로 쌓음(둘 다 빈 폭 전부 · 좁으면 줄바꿈). + +TS 셈은 `helper_408_2_status_lift.cjs`(Node · `logic/B05_Profile_RouteEdit_InfoLift.ts`). + + ./venv/Scripts/python.exe -m pytest resources/tester/test_408_4_bottom_row.py -q +""" + +import json +import subprocess +from pathlib import Path + +import pytest + +B05 = Path(__file__).resolve().parents[2] / "B05_Profile" +CSS = B05 / "B05_Profile_UI_Style_RouteEdit.css" +HELPER = Path(__file__).with_name("helper_408_2_status_lift.cjs") + + +@pytest.fixture(scope="module") +def got() -> dict: + run = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=60 + ) + assert run.returncode == 0, run.stderr + return json.loads(run.stdout) + + +def _rules(selector: str) -> list[str]: + css = CSS.read_text(encoding="utf-8") + return [part[: part.index("}")] for part in css.split(f"{selector} {{")[1:]] + + +def test_반_폭이_모자라면_쌓음(got) -> None: + # (1172 - 36 - 736) / 2 = 200 → 나란히 · 737 → 쌓음 + assert got["narrow"] == [False, False, True, False, True] + + +def test_두_글판이_반_폭까지만() -> None: + assert any( + "max-width: min(460px, 46%, var(--b05-bottom-half));" in r + for r in _rules(".b05-routeedit__info") + ) + assert any("max-width: var(--b05-bottom-half);" in r for r in _rules(".b05-routeedit__measure")) + half = _rules(".b05-routeedit")[0] + assert ( + "--b05-bottom-half: max(0px, calc((100% - 3 * var(--spacing-12) - var(--b05-side-open)) / 2));" + in half + ) + + +def test_좁으면_잰_값_창이_상태_글판_위로() -> None: + narrow = _rules(".b05-routeedit.is-narrow-bottom")[0] + assert "calc(100% - 2 * var(--spacing-12) - var(--b05-side-open))" in narrow + measure = _rules(".b05-routeedit.is-narrow-bottom .b05-routeedit__measure")[0] + assert "var(--b05-info-h, 0px)" in measure and "var(--b05-profile-cover, 0px)" in measure + + +def test_편집기가_횡단_판_글판_크기를_관찰() -> None: + chrome = (B05 / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8") + assert ( + "lift.observe(info)" in chrome + and 'lift.observe(overlay.querySelector(".b05-routeedit__side-wrap")!)' in chrome + ) + assert '"is-narrow-bottom"' in chrome and '"--b05-info-h"' in chrome From 501d9ca9078b2a19937419b37a12f19a383253a3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:37:50 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix(B05=20=EC=A2=85=EB=8B=A8=20=ED=91=9C)?= =?UTF-8?q?:=20=EA=B0=80=EA=B9=8C=EC=9A=B4=20=EC=B8=A1=EC=A0=90=EB=81=BC?= =?UTF-8?q?=EB=A6=AC=20=EA=B2=B9=EC=B9=98=EB=8A=94=20=EA=B0=92=20=EC=B9=B8?= =?UTF-8?q?=EC=9D=80=20=EA=B7=B8=20=EC=9E=90=EB=A6=AC=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EC=84=B8=EC=9B=80=20(407-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 계획고 · 지반고 · 측점 행 = 이웃과 겹치는 칸만 세로쓰기 · 두께 = 이웃 사이 틈 − 1(6 ~ 16 px) · 겹침 없는 칸 그대로 · 지우지 않음 - 곡선 L · R 행도 같은 판정(crowdedThickness) — 1.6 m 이웃에서 16 px 칸끼리 겹치던 것 고침 - logic/B06_Section_Label_Tiers crowdedThickness 새로 · 시험 test_407_4_label_tiers 더함 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B05_Profile/B05_Profile_UI_Profile_Table.ts | 44 ++++++++++++++------ B05_Profile/B05_Profile_UI_Style_Table.css | 6 +++ B06_Section/logic/B06_Section_Label_Tiers.ts | 24 +++++++++++ resources/tester/test_407_4_label_tiers.py | 19 ++++++++- 4 files changed, 79 insertions(+), 14 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index b48c66550..687d051ea 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Table.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Table.ts @@ -19,6 +19,7 @@ import type { ProfileAlignment, } from "./logic/B05_Profile_UI_Profile_Alignment"; import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common"; +import { crowdedThickness } from "../B06_Section/logic/B06_Section_Label_Tiers"; import { irregularStationId, isPipeStation, @@ -74,7 +75,7 @@ const CHAR_WIDTH_EM = 0.6; const CELL_PADDING_PX = 2; /** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */ export const TABLE_TARGET_FONT_PX = 12; -/** 곡선 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭(px). */ +/** 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭 위 끝(px · 이웃 사이 틈이 더 좁으면 그 틈). */ const CURVE_ROTATED_WIDTH_PX = 16; /** * 같은 측점으로 볼 누가거리 차이(m). @@ -339,17 +340,17 @@ function buildCurveRows( : curveByChainage.get(key); // 직선화된 자리는 반경이 없다 — 빈 칸으로 둔다. const curve = found && !isStraightCurve(found) ? found : undefined; - return { center: centers[index], curve, rotated: false }; + return { center: centers[index], curve }; }); - const withCurve = curveEntries.filter((entry) => entry.curve); - for (let pair = 1; pair < withCurve.length; pair += 1) { - if (withCurve[pair].center - withCurve[pair - 1].center < options.cellWidth) { - withCurve[pair - 1].rotated = true; - withCurve[pair].rotated = true; - } - } + // 세운 칸 두께 = 이웃 사이 틈에 맞춤(407-5 — 1.6 m 이웃도 겹치지 않게 · 측점 값 행과 같은 규칙) + const thickness = crowdedThickness( + curveEntries.map((entry) => (entry.curve ? entry.center : null)), + options.cellWidth, + CURVE_ROTATED_WIDTH_PX, + ); - curveEntries.forEach(({ center, curve, rotated }) => { + curveEntries.forEach(({ center, curve }, entryIndex) => { + const rotated = thickness[entryIndex]; const lengthCell = element("span", "b05-profile-table__curve"); const radiusCell = element("span", "b05-profile-table__curve"); if (curve) { @@ -370,16 +371,16 @@ function buildCurveRows( ); radiusCell.append(radiusInput); lengthCell.append(lengthInput); - if (rotated) { + if (rotated !== null) { [lengthCell, radiusCell].forEach((cell) => { cell.classList.add("is-rotated"); - cell.style.width = `${CURVE_ROTATED_WIDTH_PX}px`; + cell.style.width = `${rotated}px`; }); [lengthInput, radiusInput].forEach((input) => { const byLength = input.value.length ? (rowHeightPx - 6) / (input.value.length * CHAR_WIDTH_EM) : fontPx; - input.style.fontSize = `${Math.max(7, Math.min(fontPx, CURVE_ROTATED_WIDTH_PX - 4, byLength))}px`; + input.style.fontSize = `${Math.max(6, Math.min(fontPx, rotated - 2, byLength))}px`; }); } @@ -585,6 +586,14 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { table.append( ...buildSegmentRows(alignment, x, fontSize, rowHeight, structureChainages, selectedChainageM), ); + // 이웃 칸과 겹치는 값 칸(1.6 m 이웃 등)은 그 자리에서 세움 — 두께 = 이웃 사이 틈(407-5 · 곡선 행과 같은 규칙) + const stationThickness = crowdedThickness( + alignment.stations.map((station, stationIndex) => + isNearChainage(station.chainage_m, structureChainageList) ? null : centers[stationIndex], + ), + cellWidth, + CURVE_ROTATED_WIDTH_PX, + ); buildStationRows(alignment, stationInterval, display).forEach((spec, index) => { const row = createRow( `${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`, @@ -600,6 +609,15 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { `b05-profile-table__cell${structural ? " is-structure" : ""}`, structural ? "" : spec.cell(stationIndex), ); + const rotated = stationThickness[stationIndex]; + if (rotated !== null) { + const text = cell.textContent ?? ""; + const byLength = text.length ? (rowHeight - 2) / (text.length * CHAR_WIDTH_EM) : fontSize; + cell.classList.add("is-rotated"); + cell.style.width = `${rotated}px`; + cell.style.fontSize = `${Math.max(6, Math.min(fontSize, rotated - 2, byLength))}px`; + cell.title = text; + } placeCell(row, centers[stationIndex], cell); }); table.append(row); diff --git a/B05_Profile/B05_Profile_UI_Style_Table.css b/B05_Profile/B05_Profile_UI_Style_Table.css index 3f1dedf96..71a35fa80 100644 --- a/B05_Profile/B05_Profile_UI_Style_Table.css +++ b/B05_Profile/B05_Profile_UI_Style_Table.css @@ -84,6 +84,12 @@ /* 이웃 곡선 셀과 겹치는 좁은 자리(배관 측점 등) — 셀을 얇게 줄이고 입력을 세로쓰기로 세운다. 자리는 측점 수직선 그대로다(옆으로 비키면 다른 열 값으로 오해, 2026-08-04). */ +/* 측점 값 칸도 같은 규칙(407-5) — 이웃과 겹치면 칸을 얇게 · 글을 세움 · 지우지 않음. */ +.b05-profile-table__cell.is-rotated { + writing-mode: vertical-rl; + white-space: nowrap; +} + .b05-profile-table__curve.is-rotated input { writing-mode: vertical-rl; width: 100%; diff --git a/B06_Section/logic/B06_Section_Label_Tiers.ts b/B06_Section/logic/B06_Section_Label_Tiers.ts index 1d7596dfc..ee3cba1e0 100644 --- a/B06_Section/logic/B06_Section_Label_Tiers.ts +++ b/B06_Section/logic/B06_Section_Label_Tiers.ts @@ -2,6 +2,7 @@ * B06_Section/logic/B06_Section_Label_Tiers.ts * 종단 측점 · 구조물 이름표 비켜 그리기(PLAN 407-4) — 가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단으로 * 올림 · 이름은 지우지 않음. 그리는 곳 = `B06_Section_UI_Longitudinal`(B05 종단도 같은 함수). + * B05 종단 아래 표 겹친 칸 세우기(407-5 · `crowdedThickness` · `B05_Profile_UI_Profile_Table`). * 시험 `resources/tester/test_407_4_label_tiers.py`. 화면 · 세션 없음(순수). * ========================================================================== */ @@ -29,3 +30,26 @@ export function labelTiers( } return tiers; } + +/** 겹친 칸 세우기(407-5 · B05 종단 아래 표) — 같은 폭(`width`) 칸이 이웃과 가로로 겹치면 세운 칸 두께 + * (px · 가까운 이웃까지 거리 − 1 · `minPx` ~ `maxPx`) · 안 겹치면 null(그대로) · x 가 null = 빈 칸(판정에서 뺌). */ +export function crowdedThickness( + xs: ReadonlyArray, + width: number, + maxPx: number, + minPx = 6, +): Array { + const order = xs + .map((x, index) => ({ x, index })) + .filter((item): item is { x: number; index: number } => item.x !== null) + .sort((a, b) => a.x - b.x); + const out: Array = xs.map(() => null); + order.forEach(({ x, index }, k) => { + const nearest = Math.min( + k > 0 ? x - order[k - 1].x : Infinity, + k < order.length - 1 ? order[k + 1].x - x : Infinity, + ); + if (nearest < width) out[index] = Math.max(minPx, Math.min(maxPx, Math.floor(nearest - 1))); + }); + return out; +} diff --git a/resources/tester/test_407_4_label_tiers.py b/resources/tester/test_407_4_label_tiers.py index 9725a290c..3aac3a881 100644 --- a/resources/tester/test_407_4_label_tiers.py +++ b/resources/tester/test_407_4_label_tiers.py @@ -1,4 +1,5 @@ -"""PLAN 407-4 — 종단 측점 · 구조물 이름표 비켜 그리기(`logic/B06_Section_Label_Tiers.ts`). +"""PLAN 407-4 — 종단 측점 · 구조물 이름표 비켜 그리기(`logic/B06_Section_Label_Tiers.ts`) · +407-5 — B05 종단 아래 표 겹친 칸 세우기(`crowdedThickness` · 두께 = 이웃 사이 틈 − 1). 가까운 측점(1.6 m 등)끼리 이름이 겹치면 위 단 · 안 겹치면 제자리(0) · 이름은 지우지 않음 · 단 수 위 끝(`MAX_LABEL_TIER`) 넘으면 마지막 단 · 글 폭 짐작(한글 = 글자 크기). @@ -30,6 +31,13 @@ process.stdout.write(JSON.stringify({ max: m.MAX_LABEL_TIER, tiers: cases.map((items) => m.labelTiers(items)), widths: ["19+18 (MC)", "20+0", "배수관"].map((t) => m.labelWidthPx(t, 10)), + thick: [ + m.crowdedThickness([0, 50, 100], 40, 16), + m.crowdedThickness([100, 108, 200], 40, 16), + m.crowdedThickness([100, 130, 200], 40, 16), + m.crowdedThickness([100, 104, null, 106], 40, 16), + m.crowdedThickness([100, null, 103], 40, 16), + ], })); """ @@ -77,3 +85,12 @@ def test_글_폭(r) -> None: mc, plain, korean = r["widths"] assert mc == pytest.approx(60.0) and plain == pytest.approx(24.0) assert korean == pytest.approx(30.0) + + +def test_표_칸_세우기(r) -> None: + no, near, wide, empty, floor = r["thick"] + assert no == [None, None, None] # 안 겹침 = 그대로 + assert near == [7, 7, None] # 1.6 m 이웃(8 px) = 둘 다 세움 · 두께 7 + assert wide == [16, 16, None] # 틈 넓으면 위 끝 16 + assert empty == [6, 6, None, 6] # 빈 칸(null) 건너 이웃 · 아래 끝 6 + assert floor == [6, None, 6] From dfb3cba240f838ec7d937a4b120a97ea0898318e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:42:41 +0900 Subject: [PATCH 15/17] =?UTF-8?q?fix(B05=20=EC=A2=85=EB=8B=A8=20=ED=91=9C)?= =?UTF-8?q?:=20=EA=B0=92=20=EC=B9=B8=20=EC=84=B8=EC=9B=80=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=3D=20=EA=B8=80=20=ED=8F=AD=20=E2=80=94=20=EC=B9=B8?= =?UTF-8?q?=20=ED=8F=AD=20=EA=B8=B0=EC=A4=80=EC=9D=B4=EB=A9=B4=20=EC=95=88?= =?UTF-8?q?=20=EA=B2=B9=EC=B9=98=EB=8A=94=2016=20m=20=EC=9D=B4=EC=9B=83?= =?UTF-8?q?=EA=B9=8C=EC=A7=80=20=EC=84=B8=EC=9B=8C=EC=A7=90=20(407-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG --- B05_Profile/B05_Profile_UI_Profile_Table.ts | 29 +++++++++++++++------ 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index 687d051ea..3dd442f14 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Table.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Table.ts @@ -586,24 +586,37 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { table.append( ...buildSegmentRows(alignment, x, fontSize, rowHeight, structureChainages, selectedChainageM), ); - // 이웃 칸과 겹치는 값 칸(1.6 m 이웃 등)은 그 자리에서 세움 — 두께 = 이웃 사이 틈(407-5 · 곡선 행과 같은 규칙) - const stationThickness = crowdedThickness( - alignment.stations.map((station, stationIndex) => - isNearChainage(station.chainage_m, structureChainageList) ? null : centers[stationIndex], + // 이웃 칸과 글이 겹치는 값 칸(1.6 m 이웃 등)만 그 자리에서 세움 — 판정 = 세 행 가운데 가장 긴 글 폭 · + // 두께 = 이웃 사이 틈(407-5 · 곡선 행과 같은 함수) + const stationSpecs = buildStationRows(alignment, stationInterval, display); + const isStructural = alignment.stations.map((station) => + isNearChainage(station.chainage_m, structureChainageList), + ); + const longest = Math.max( + 0, + ...stationSpecs.flatMap((spec) => + alignment.stations.map((_, stationIndex) => + isStructural[stationIndex] ? 0 : spec.cell(stationIndex).length, + ), ), - cellWidth, + ); + const stationThickness = crowdedThickness( + alignment.stations.map((_, stationIndex) => + isStructural[stationIndex] ? null : centers[stationIndex], + ), + longest * fontSize * CHAR_WIDTH_EM + CELL_PADDING_PX * 2, CURVE_ROTATED_WIDTH_PX, ); - buildStationRows(alignment, stationInterval, display).forEach((spec, index) => { + stationSpecs.forEach((spec, index) => { const row = createRow( `${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`, spec.label, spec.unit, ); // 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다. - alignment.stations.forEach((station, stationIndex) => { + alignment.stations.forEach((_, stationIndex) => { // 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다. - const structural = isNearChainage(station.chainage_m, structureChainageList); + const structural = isStructural[stationIndex]; const cell = element( "span", `b05-profile-table__cell${structural ? " is-structure" : ""}`, From 74ae3ac2634bf6db418eb44521068ce0a48260c5 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:54:04 +0900 Subject: [PATCH 16/17] =?UTF-8?q?fix(B05=20=EA=B4=80=20id=20=C2=B7=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=EA=B0=90):=20=ED=99=94=EB=A9=B4=20=EB=A7=9E?= =?UTF-8?q?=EC=B6=A4=20=3D=20=EA=B4=80=20=EA=B3=A0=EC=A0=95=20id=20?= =?UTF-8?q?=EA=B0=80=20=EC=A5=94=20=EC=84=B8=EC=85=98=20id=20=EB=A5=BC=20?= =?UTF-8?q?=EC=83=88=20=EC=9E=90=EB=A6=AC=EB=A1=9C=20=C2=B7=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=EB=91=94=20=EC=B1=84=20=EC=97=B4=EA=B8=B0=20?= =?UTF-8?q?=EC=9E=90=EB=A6=AC=20=C2=B7=20=EB=A7=89=20=EB=86=93=EC=9D=80=20?= =?UTF-8?q?=EA=B4=80=20=EB=94=B0=EB=9D=BC=EA=B0=90=20(411-B=20=C2=B7=20410?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 세션 id 맞춤 — 차례 · 가까운 자리 대신 관 고정 id → 세션 id 표로 새 자리에 놓음(placePipeIds) · 그 자리 헛 id(옛 측점 열쇠가 지은 것) 뺌 · 끌기 · 따라감 · 되돌림 · 초안 버림 모두 그 관 id - movePipeIds 걷음 · movePipeId 옛 꼴 그대로 - 따라감 단계 — 초안 둔 채 열기(앞 노선 없음)의 가게 값은 초안 노선 자리로 읽음(다시 열 때 두 번 밀림 막음) - 배수 편집기 따라감 — 초안에 id 없는 막 놓은 관끼리 차례 짝 - 시험 test_411_b 고쳐 씀(헛 id · 되돌림 · 막 놓은 관) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_pipe_ids.ts | 40 +++++++----- A00_Common/design/design_pipeline_page.ts | 17 ++++- A00_Common/design/design_view_profile.ts | 27 ++++---- B05_Profile/B05_Profile_UI_Drainage_Parts.ts | 17 +++-- resources/tester/helper_411_b_entry.ts | 2 +- .../tester/helper_411_b_pipe_id_pairing.mjs | 63 ++++++++++++++----- .../tester/test_411_b_pipe_id_pairing.py | 19 ++++-- 7 files changed, 133 insertions(+), 52 deletions(-) diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts index c24a2d3d1..d5c9ef567 100644 --- a/A00_Common/design/design_pipe_ids.ts +++ b/A00_Common/design/design_pipe_ids.ts @@ -58,26 +58,36 @@ export function pipeChainageOf(id: string, projectId: string | null = active): n /** 관을 옮김 — 옛 자리 id 가 새 자리로(새 자리에 남은 옛 기록은 뺌) · 옮긴 관의 id. */ export function movePipeId(fromM: number, toM: number, projectId: string | null = active): string { if (!projectId) return `pipe-${toM.toFixed(2)}`; - return movePipeIds([[fromM, toM]], projectId)[0] ?? pipeIdAt(toM, projectId); + const book = bookOf(projectId); + const moving = nearestWithin(book.ids, fromM, (entry) => entry.at); + if (!moving) return pipeIdAt(toM, projectId); + const stale = nearestWithin( + book.ids.filter((entry) => entry !== moving), + toM, + (entry) => entry.at, + ); + const ids = book.ids + .filter((entry) => entry !== stale) + .map((entry) => (entry === moving ? { ...entry, at: toM } : entry)); + writeState("pipe-ids", { next: book.next, ids }, projectId); + return moving.id; } -/** 관 여럿을 한 번에 옮김(노선 따라감 · 411-B) — 옮길 기록을 먼저 다 찾고 옮김(앞 관이 뒤 관 옛 자리로 가도 - * 뒤 관 기록을 안 지움) · 새 자리에 남은 옮기지 않는 옛 기록은 뺌 · 옮긴 관마다 id(못 찾으면 null). */ -export function movePipeIds( - moves: ReadonlyArray, +/** 관 세션 id 를 그 자리로(화면 목록 맞춤 · 411-B) — 짝 = 관 고정 id 가 쥔 세션 id(노선 따라감 · 끌기 · 되돌리기 + * 어느 길로 옮겨도 그 id) · 그 자리 근처의 다른 기록(옛 측점 열쇠가 지은 헛 id)은 뺌 · 장부에 없는 id 는 적음. */ +export function placePipeIds( + places: ReadonlyArray, projectId: string | null = active, -): Array { - if (!projectId) return moves.map(() => null); +): void { + if (!projectId || !places.length) return; const book = bookOf(projectId); - const moving = moves.map(([fromM]) => nearestWithin(book.ids, fromM, (entry) => entry.at)); - const to = new Map(moving.flatMap((entry, i) => (entry ? [[entry, moves[i][1]]] : []))); - const still = book.ids.filter((entry) => !to.has(entry)); - const stale = new Set([...to.values()].map((at) => nearestWithin(still, at, (e) => e.at))); + const to = new Map(places); const ids = book.ids - .filter((entry) => !stale.has(entry)) - .map((entry) => (to.has(entry) ? { ...entry, at: to.get(entry)! } : entry)); - if (to.size) writeState("pipe-ids", { next: book.next, ids }, projectId); - return moving.map((entry) => entry?.id ?? null); + .filter((entry) => to.has(entry.id) || !places.some(([, at]) => isNear(entry.at, at))) + .map((entry) => (to.has(entry.id) ? { ...entry, at: to.get(entry.id)! } : entry)); + for (const [id, at] of places) if (!ids.some((entry) => entry.id === id)) ids.push({ id, at }); + if (JSON.stringify(ids) !== JSON.stringify(book.ids)) + writeState("pipe-ids", { next: book.next, ids }, projectId); } /** 관 목록에 세션 id 를 얹은 사본 — 측점 계획의 덮는 구조물 id(종단 측점 id · 알약 짝)가 세션 id. */ diff --git a/A00_Common/design/design_pipeline_page.ts b/A00_Common/design/design_pipeline_page.ts index a95ba7a9e..a93da27c5 100644 --- a/A00_Common/design/design_pipeline_page.ts +++ b/A00_Common/design/design_pipeline_page.ts @@ -317,13 +317,26 @@ function createPagePipeline( replay = false; if (kept) return { alignment: kept }; // 관 · 구조물도 세션 그대로(가게 · 쓰기 그대로) const line = ctx.before.route?.line ?? savedLine() ?? []; + // 자리를 읽을 선 — 초안 둔 채 열기(앞 노선 없음)의 가게 값 = 이미 초안 노선 자리(앞 따라감이 씀 · 410) + const frame = (list: unknown, saved: unknown) => + !ctx.before.route && ctx.inputs.route && list !== saved + ? (ctx.out.route?.line ?? line) + : line; const { pipes, structures } = ctx.inputs; // 좌표 없는 관 = 앞 따라감(같은 id · 같은 자리) → 정본 좌표 · 옮긴 것 = 그 자리의 앞 노선 위(410) const followed = (ctx.before.follow?.pipes ?? []) as FollowingStructure[]; const inputs = { ...ctx.inputs, - pipes: placed(pipes, line, [...(savedPipes as FollowingStructure[]), ...followed]), - structures: placed(structures, line, [], SECTION_STATION_INTERVAL_M), + pipes: placed(pipes, frame(pipes, savedPipes), [ + ...(savedPipes as FollowingStructure[]), + ...followed, + ]), + structures: placed( + structures, + frame(structures, savedStructures), + [], + SECTION_STATION_INTERVAL_M, + ), }; return followStage({ ...ctx, inputs }); }, diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index e7adc9de9..2ee2a5f5f 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css"; import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; -import { movePipeIds, pipeIdAt } from "./design_pipe_ids"; +import { pipeIdAt, placePipeIds } from "./design_pipe_ids"; import { useDesignStore } from "./design_store"; import { setPlusStations } from "../../common_util/common_util_structure_station"; import { @@ -114,21 +114,24 @@ export async function mountProfileView( let lastPipes: PipeReport | null = shared.lastPipes ?? null; const designStore = useDesignStore(activeProjectId, null); /** 화면 관 목록 = 배수유역도 알림을 가게 관 초안 측점으로(노선 따라 다시 매김 · 349-0d) · 관 세션 id 도 - * 옮김 — 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 그 관) · id 없는 관은 안 옮김(막 놓은 관). */ - let lastShown: PipeReport = []; + * 그 자리로 — 짝 = 관 고정 id(411-B · 앞 관을 지나 옮겨도 · 되돌려도 그 관) · id 없는 관(막 놓은 관)은 자리로. */ + let sessionIds = new Map(); // 관 고정 id → 세션 id(알약 id) const shownPipes = (pipes: PipeReport): PipeReport => { const shown = followPipeReports(pipes, designStore.get("pipes")).sort( (x, y) => x.chainage_m - y.chainage_m, ); - const was = new Map(lastShown.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : []))); - const moves = shown.flatMap((pipe) => { - const from = pipe.id ? was.get(pipe.id) : undefined; - return from === undefined || Math.abs(pipe.chainage_m - from) <= 0.005 - ? [] - : [[from, pipe.chainage_m] as const]; - }); - if (moves.length) movePipeIds(moves, activeProjectId); - lastShown = shown; + placePipeIds( + shown.flatMap((pipe) => { + const sid = pipe.id && sessionIds.get(pipe.id); + return sid ? [[sid, pipe.chainage_m] as const] : []; + }), + activeProjectId, + ); + sessionIds = new Map( + shown.flatMap((pipe) => + pipe.id ? [[pipe.id, pipeIdAt(pipe.chainage_m, activeProjectId)] as const] : [], + ), + ); return shown; }; const applyPipes = (reported: PipeReport, carryEdits = true): void => { diff --git a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts index c58a3704f..e39ee5ee1 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Parts.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Parts.ts @@ -342,17 +342,26 @@ export function mountDrainageToggles( ); } -/** 노선 초안 follow 가 다시 매긴 관 자리(가게 관 초안)를 편집기에 얹음(410) — 짝 = 관 id · 시설 속성 · - * 고름도 따라감 · 재계산 · 초안 쓰기 없음(가게가 이미 그 값). 편집기가 옛 노선 누가거리로 초안을 덮지 않게. */ +/** 노선 초안 follow 가 다시 매긴 관 자리(가게 관 초안)를 편집기에 얹음(410) — 짝 = 관 id(막 놓아 초안에 id + * 없는 관끼리는 누가거리 차례 · 수가 같을 때만) · 시설 속성 · 고름도 따라감 · 재계산 · 초안 쓰기 없음(가게가 + * 이미 그 값). 편집기가 옛 노선 누가거리로 초안을 덮지 않게. */ export function followEditorPipes( editor: PipeEditor, facilities: FacilityStore, followed: ReadonlyArray<{ id?: string | null; chainage_m: number }>, ): void { - const to = new Map(followed.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : []))); + const to = new Map( + followed.flatMap((pipe) => (pipe.id ? [[pipe.id, pipe.chainage_m]] : [])), + ); const before = editor.pipes(); + const byOrder = (a: { chainage_m: number }, b: { chainage_m: number }) => + a.chainage_m - b.chainage_m; + const looseEditor = before.filter((pipe) => !(pipe.id && to.has(pipe.id))).sort(byOrder); + const looseFollowed = followed.filter((pipe) => !pipe.id).sort(byOrder); + if (looseEditor.length === looseFollowed.length) + looseEditor.forEach((pipe, i) => to.set(pipe, looseFollowed[i].chainage_m)); const moves = before.flatMap((pipe) => { - const at = pipe.id ? to.get(pipe.id) : undefined; + const at = (pipe.id ? to.get(pipe.id) : undefined) ?? to.get(pipe); return at === undefined || Math.abs(at - pipe.chainage_m) < 0.005 ? [] : [{ pipe, at, attributes: facilities.get(pipe.chainage_m) }]; diff --git a/resources/tester/helper_411_b_entry.ts b/resources/tester/helper_411_b_entry.ts index eec78f5c4..4023287b6 100644 --- a/resources/tester/helper_411_b_entry.ts +++ b/resources/tester/helper_411_b_entry.ts @@ -5,7 +5,7 @@ export { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_ export { followedMarks } from "../../B05_Profile/logic/B05_Profile_Structure_Follow"; export { movePipeId, - movePipeIds, + placePipeIds, pipeChainageOf, pipeIdAt, usePipeIds, diff --git a/resources/tester/helper_411_b_pipe_id_pairing.mjs b/resources/tester/helper_411_b_pipe_id_pairing.mjs index 7bc24f426..0af5598f8 100644 --- a/resources/tester/helper_411_b_pipe_id_pairing.mjs +++ b/resources/tester/helper_411_b_pipe_id_pairing.mjs @@ -46,28 +46,42 @@ out.reports = m .followPipeReports(reports, draft) .map((r) => [r.id, r.chainage_m, r.start_m ?? null]); -/* 2. 세션 id 장부 — 끌어 옮김(followMovedPipe 길) 뒤 화면 목록 짝(shownPipes 규칙 = id) */ +/* 2. 세션 id 장부 — 끌어 옮김(followMovedPipe 길) · 그 사이 옛 측점 열쇠가 옛 자리에 헛 id 를 지음 → + * 화면 목록 맞춤(shownPipes = 관 고정 id 가 쥔 세션 id 를 새 자리로 · placePipeIds) */ const idA = m.pipeIdAt(100, P); const idB = m.pipeIdAt(150, P); +const sessionIds = new Map([ + ["p10000", idA], + ["p15000", idB], +]); m.movePipeId(150, 80, P); // 끌기 = 옮긴 관 id 그대로 -const was = new Map(reports.map((r) => [r.id, r.chainage_m])); -const shown = m.followPipeReports(reports, draft); -const moves = shown.flatMap((r) => { - const from = was.get(r.id); - return from === undefined || Math.abs(from - r.chainage_m) <= 0.005 ? [] : [[from, r.chainage_m]]; -}); -m.movePipeIds(moves, P); -out.ids = { idA, idB, at80: m.pipeIdAt(80, P), at100: m.pipeIdAt(100, P) }; -out.bookNext = m.pipeIdAt(999, P); // 새 관 = 다음 차례(앞에서 헛 id 가 안 생겼으면 s3) +const stray = m.pipeIdAt(150, P); // 옛 측점 열쇠가 옛 자리를 물음 +const place = (shown, project) => + m.placePipeIds( + shown.map((r) => [sessionIds.get(r.id), r.chainage_m]), + project, + ); +place(m.followPipeReports(reports, draft), P); +out.ids = { idA, idB, stray, at80: m.pipeIdAt(80, P), at100: m.pipeIdAt(100, P) }; -/* 3. 노선 따라감이 두 관을 함께 밂(앞 관이 뒤 관 옛 자리로) — 한 번에 옮김 · 기록 안 잃음 */ +/* 2-1. 되돌림 — 관이 옛 자리로 돌아감 · 그 자리에 헛 id 가 먼저 있어도 그 관 id(헛 기록은 뺌) */ +place( + [ + { id: "p15000", chainage_m: 150 }, + { id: "p10000", chainage_m: 100 }, + ], + P, +); +out.back = { at150: m.pipeIdAt(150, P), strayAt: m.pipeChainageOf(stray, P) }; + +/* 3. 노선 따라감이 두 관을 함께 밂(앞 관이 뒤 관 옛 자리로) — 기록 안 잃음 · 헛 id 안 생김 */ const Q = "q411"; const q1 = m.pipeIdAt(100, Q); const q2 = m.pipeIdAt(103, Q); -m.movePipeIds( +m.placePipeIds( [ - [100, 103], - [103, 106], + [q1, 103], + [q2, 106], ], Q, ); @@ -111,6 +125,27 @@ out.editor = { m.followEditorPipes(editor, facilities, [{ id: "p10000", chainage_m: 103 }]); // 같은 값 = 그대로 out.editorSame = editor.pipes().map((p) => p.chainage_m); +/* 5-1. 막 놓은 관 — 초안엔 id 없음 · 편집기는 서버 id(재계산 응답) → id 없는 것끼리 차례 짝 */ +const fresh = m.createPipeEditor(() => {}); +fresh.setPipes([ + { chainage_m: 120, reason: "confirmed", id: "p12000" }, + { chainage_m: 351, reason: "stream", id: "p35100" }, +]); +m.followEditorPipes(fresh, m.createFacilityStore(), [ + { chainage_m: 120 }, + { id: "p35100", chainage_m: 353 }, +]); +const fresh2 = m.createPipeEditor(() => {}); +fresh2.setPipes([ + { chainage_m: 200, reason: "confirmed", id: "p20000" }, + { chainage_m: 351, reason: "stream", id: "p35100" }, +]); +m.followEditorPipes(fresh2, m.createFacilityStore(), [ + { chainage_m: 204 }, + { id: "p35100", chainage_m: 353 }, +]); +out.fresh = [fresh.pipes(), fresh2.pipes()].map((list) => list.map((p) => [p.id, p.chainage_m])); + /* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */ const planned = [ { chainage_m: 80.17, owners: ["p8017"], role: "anchor" }, diff --git a/resources/tester/test_411_b_pipe_id_pairing.py b/resources/tester/test_411_b_pipe_id_pairing.py index 368b400b1..2e0a47b5e 100644 --- a/resources/tester/test_411_b_pipe_id_pairing.py +++ b/resources/tester/test_411_b_pipe_id_pairing.py @@ -6,7 +6,7 @@ B06 열쇠 = 계산 묶음과 같음. 새 `pipe-sN` 을 지어 새 측점 · 계획고 0 이 생기던 흠. 진짜 TS 를 vite ssr 로 묶어(`helper_411_b_entry.ts`) Node 로 잰다: ① `followPipeReports` 짝 = 관 고정 id - ② 세션 id 장부(`movePipeId` · `movePipeIds`) + ② 세션 id 장부(`movePipeId` 끌기 · `placePipeIds` 화면 맞춤) ③ `followedMarks` ④ 배수 편집기 `followEditorPipes`(410 · 따라간 자리 받음) ⑤ B06 `useStationOwners` · `stationKeysOf`. @@ -75,15 +75,19 @@ def test_알림_목록_짝은_id(out): def test_앞_관_지나_옮겨도_세션_id_그대로(out): ids = out["ids"] - assert ids["at80"] == ids["idB"] # 옮긴 관 = 제 id + assert ids["at80"] == ids["idB"] # 옮긴 관 = 제 id(옛 자리 헛 id 를 안 받음) assert ids["at100"] == ids["idA"] # 이웃 관 = 제 id(새 pipe-sN 아님) - assert out["bookNext"] == "pipe-s3" # 헛 id 없음 = 알약 · 측점 수 그대로 + + +def test_되돌려_옛_자리로_가도_그_관_id(out): + assert out["back"]["at150"] == out["ids"]["idB"] + assert out["back"]["strayAt"] is None # 그 자리 헛 기록은 장부에서 빠짐 def test_따라감이_여럿을_함께_밀어도_기록_안_잃음(out): batch = out["batch"] assert batch["at103"] == batch["q1"] and batch["at106"] == batch["q2"] - assert batch["next"] == "pipe-s3" + assert batch["next"] == "pipe-s3" # 헛 id 없음 = 알약 · 측점 수 그대로 def test_알약은_그_관_자리(out): @@ -98,6 +102,13 @@ def test_배수_편집기는_따라간_자리를_받음(out): assert out["editorSame"] == [98, 103] +def test_막_놓은_관은_id_없는_것끼리_차례로_따라감(out): + assert out["fresh"] == [ + [["p12000", 120], ["p35100", 353]], + [["p20000", 204], ["p35100", 353]], + ] + + def test_B06_열쇠는_서버_관_id_도_pipe(out): assert out["keys"] == ["structure:st-1", "pipe:p8017"] assert out["keys"][1] == out["pipelineKey"] # 계산 묶음 측점 열쇠와 같음 From 274a0f892e019546d9ecfc9ce739223794603ade Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 18 Oct 2026 09:59:12 +0900 Subject: [PATCH 17/17] =?UTF-8?q?fix(B06=20=EC=B8=A1=EC=A0=90=20=EC=97=B4?= =?UTF-8?q?=EC=87=A0):=20=EC=98=9B=20=EC=9E=90=EB=A6=AC=20=EA=B4=80=20?= =?UTF-8?q?=EC=B8=A1=EC=A0=90=20=ED=97=9B=20=EC=84=B8=EC=85=98=20id=20?= =?UTF-8?q?=EA=B1=B7=EC=9D=8C=20=C2=B7=20=ED=97=9B=20id=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(411-E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 관 계획을 아는 동안(초안 · 관 주인 있음) 계획에 없는 관 같은 측점 = 옮겨 간 옛 자리 — pipeIdAt 대신 pipeIdNear(안 지음) · 장부에 없으면 at: 열쇠 - 초안 없음 = 옛 갈래 그대로(지음) - 화면 관 목록 맞춤 때 지금 관 자리 근처에 없는 기록 정리(prunePipeIds) - 시험 test_411_b 덧붙임(헛 id 0 · 옛 갈래 · 정리) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01TteK8mfYZetk5P3bEZkyVZ --- A00_Common/design/design_pipe_ids.ts | 14 ++++++++++++++ A00_Common/design/design_view_profile.ts | 6 +++++- B06_Section/B06_Section_Station_Keys.ts | 11 +++++++---- resources/tester/helper_411_b_entry.ts | 2 ++ .../tester/helper_411_b_pipe_id_pairing.mjs | 19 +++++++++++++++++++ .../tester/test_411_b_pipe_id_pairing.py | 10 ++++++++++ 6 files changed, 57 insertions(+), 5 deletions(-) diff --git a/A00_Common/design/design_pipe_ids.ts b/A00_Common/design/design_pipe_ids.ts index d5c9ef567..02c93b7b6 100644 --- a/A00_Common/design/design_pipe_ids.ts +++ b/A00_Common/design/design_pipe_ids.ts @@ -46,6 +46,20 @@ export function pipeIdAt(chainageM: number, projectId: string | null = active): return id; } +/** 그 자리 관의 id — 장부에 있을 때만(안 지음 · 411-E). */ +export function pipeIdNear(chainageM: number, projectId: string | null = active): string | null { + if (!projectId) return null; + return nearestWithin(bookOf(projectId).ids, chainageM, (entry) => entry.at)?.id ?? null; +} + +/** 지금 관 자리(`keep`) 근처에 없는 기록을 뺌 — 옮긴 사이 옛 측점이 지은 헛 id 정리(411-E). */ +export function prunePipeIds(keep: ReadonlyArray, projectId: string | null = active): void { + if (!projectId || !keep.length) return; // 빈 목록 = 아직 못 받음 · 다 지움 — 장부 그대로 + const book = bookOf(projectId); + const ids = book.ids.filter((entry) => keep.some((at) => isNear(entry.at, at))); + if (ids.length !== book.ids.length) writeState("pipe-ids", { next: book.next, ids }, projectId); +} + /** id 의 관 자리 — 장부 · 옛 자리 이름 순 · 관 id 가 아니면 null. */ export function pipeChainageOf(id: string, projectId: string | null = active): number | null { if (!id.startsWith("pipe-")) return null; diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 2ee2a5f5f..9799e068c 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -66,7 +66,7 @@ import "../../B05_Profile/B05_Profile_UI_Style.css"; import "../../B05_Profile/B05_Profile_UI_Style_Table.css"; import "../../B05_Profile/B05_Profile_UI_Style_Drainage.css"; import "../../B05_Profile/B05_Profile_UI_Style_Structures.css"; -import { pipeIdAt, placePipeIds } from "./design_pipe_ids"; +import { pipeIdAt, placePipeIds, prunePipeIds } from "./design_pipe_ids"; import { useDesignStore } from "./design_store"; import { setPlusStations } from "../../common_util/common_util_structure_station"; import { @@ -132,6 +132,10 @@ export async function mountProfileView( pipe.id ? [[pipe.id, pipeIdAt(pipe.chainage_m, activeProjectId)] as const] : [], ), ); + prunePipeIds( + shown.map((pipe) => pipe.chainage_m), + activeProjectId, + ); // 헛 id 정리(411-E) return shown; }; const applyPipes = (reported: PipeReport, carryEdits = true): void => { diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts index f28f8f3e3..3677b57e7 100644 --- a/B06_Section/B06_Section_Station_Keys.ts +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -16,7 +16,7 @@ * ========================================================================== */ import { readState, writeState } from "../A00_Common/b_page_state"; -import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import { pipeIdAt, pipeIdNear } from "../A00_Common/design/design_pipe_ids"; import { showToast } from "@ui/ui_template_elements"; import { chainageKey, @@ -116,15 +116,18 @@ export function stationKeysOf( intervalM: number | null, ): Keyed[] { const known = projectId ? (owners.get(projectId) ?? []) : []; + const planned = known.some((owner) => owner.kind === "pipe"); const keys = sections.map((section) => { const at = section.chainage_m; let key: string; if (section.kind === "irregular") { const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M); if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind, owner.role); - else if (looksLikePipe(section)) - key = stationKey("pipe", at, intervalM, pipeIdAt(at, projectId)); - else + else if (looksLikePipe(section)) { + // 관 계획을 아는 동안(초안) 계획에 없는 관 측점 = 옮겨 간 옛 자리 — 세션 id 를 새로 안 지음(411-E) + const id = planned ? pipeIdNear(at, projectId) : pipeIdAt(at, projectId); + key = id ? stationKey("pipe", at, intervalM, id) : stationKey("irregular", at, intervalM); + } else key = section.grid ? stationKey("regular", at, intervalM) : stationKey("irregular", at, intervalM); diff --git a/resources/tester/helper_411_b_entry.ts b/resources/tester/helper_411_b_entry.ts index 4023287b6..0e45be03d 100644 --- a/resources/tester/helper_411_b_entry.ts +++ b/resources/tester/helper_411_b_entry.ts @@ -5,7 +5,9 @@ export { followPipeReports } from "../../B05_Profile/logic/B05_Profile_Pipeline_ export { followedMarks } from "../../B05_Profile/logic/B05_Profile_Structure_Follow"; export { movePipeId, + pipeIdNear, placePipeIds, + prunePipeIds, pipeChainageOf, pipeIdAt, usePipeIds, diff --git a/resources/tester/helper_411_b_pipe_id_pairing.mjs b/resources/tester/helper_411_b_pipe_id_pairing.mjs index 0af5598f8..c41f8f15d 100644 --- a/resources/tester/helper_411_b_pipe_id_pairing.mjs +++ b/resources/tester/helper_411_b_pipe_id_pairing.mjs @@ -161,4 +161,23 @@ m.useStationOwners(P, planned); out.keysNoPipes = m.stationKeysOf(P, sections, 20).map((k) => k.key); out.pipelineKey = m.pipelineStationKey("pipe", "p8017"); +/* 7. 411-E — 관 계획을 아는 동안 옛 자리 관 측점(옮겨 간 자리)은 세션 id 를 안 지음 · 헛 id 정리 */ +const E = "e411"; +m.usePipeIds(E); +const e1 = m.pipeIdAt(80, E); // 옮긴 관(새 자리) +const bookSize = () => JSON.parse(sessionStorage.getItem(`aislo:draft:pipe-ids:${E}`)).ids.length; +m.useStationOwners(E, [{ chainage_m: 80, owners: ["p15000"], role: "anchor" }], [{ id: "p15000" }]); +const staleSections = [ + { chainage_m: 80, kind: "irregular", structure: "파형강관 D800", culvert: {} }, + { chainage_m: 150, kind: "irregular", structure: "파형강관 D800", culvert: {} }, // 옛 자리 +]; +out.staleKeys = m.stationKeysOf(E, staleSections, 20).map((k) => k.key); +out.staleBook = bookSize(); +m.useStationOwners(E, []); // 관 계획 모름(초안 없음) = 옛 갈래 그대로 지음 +out.legacyKey = m.stationKeysOf(E, [staleSections[1]], 20)[0].key; +out.legacyBook = bookSize(); +m.prunePipeIds([80], E); // 지금 관 자리만 남김 +out.pruned = { size: bookSize(), at80: m.pipeIdAt(80, E), e1 }; +m.usePipeIds(P); + process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_411_b_pipe_id_pairing.py b/resources/tester/test_411_b_pipe_id_pairing.py index 2e0a47b5e..f469b95a2 100644 --- a/resources/tester/test_411_b_pipe_id_pairing.py +++ b/resources/tester/test_411_b_pipe_id_pairing.py @@ -113,3 +113,13 @@ def test_B06_열쇠는_서버_관_id_도_pipe(out): assert out["keys"] == ["structure:st-1", "pipe:p8017"] assert out["keys"][1] == out["pipelineKey"] # 계산 묶음 측점 열쇠와 같음 assert out["keysNoPipes"][1] == "structure:p8017" # 관 목록을 안 주면 옛 갈래(대조) + + +def test_411_E_옛_자리_관_측점은_헛_id_안_지음(out): + assert out["staleKeys"] == ["pipe:p15000", "at:150.00"] + assert out["staleBook"] == 1 # 세션 id 표에 헛 id 0 + assert out["legacyKey"] == "pipe:pipe-s2" and out["legacyBook"] == 2 # 초안 없음 = 옛 갈래 + + +def test_411_E_헛_id_정리(out): + assert out["pruned"] == {"size": 1, "at80": out["pruned"]["e1"], "e1": "pipe-s1"}