4 Commits
Author SHA1 Message Date
eomsangdonandClaude Opus 5.5 f3269f7f20 feat(D03): 예상 횡단 순수 함수 — 표준 횡단 기본값 · 계획고 = 중심 지반고 · 미교차 넓힘 · 코리도 측점 꼴(PLAN 544-1)
- 새 logic/D03_PlanAnalysis_Expect.ts — 공용 computeCrossDesign + B 초기 기본(리핑암 · 산쪽 절토 · 일반 측구 · 암 경계 기본) · extendUnclosed
- 새 시험 test_544_1_plan_expect.py — TS 설계선 · 절 · 성토 ㎡ = 파이썬 compute_cross_design

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:16:05 +09:00
eomsangdonandClaude Sonnet 5.5 852af20206 feat(D01·D04): 예정노선 측점 표기 No.+거리(61+14.5 꼴) — D01 입력 옆 표기 · D04 표 · xlsx 측점 칸
- D01 시작 · 종료 칸 이름 측점 말로 · 입력 옆 즉시 `= 61+14.5` (저장값 m 그대로)
- D04 표 측점 열 · xlsx 시점 · 종점 측점 칸 덧(m 칸 · shp 숫자 칸 유지)
- 파이썬 station_label ↔ stationLabel 거울 시험 · 도움말 D01 · D04 글 · 그림 열여섯 장 다시 찍음

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 12:48:33 +09:00
eomsangdonandClaude Opus 5.5 c02d4ade6e chore(요구): 분석 363 예정노선 지점 고침 · 예상 횡단 · 3D · 측점 표기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:42:22 +09:00
eomsangdonandClaude Opus 5.5 4e1dc5c1fd chore(요구): 요구 363 — 예정노선 지점 고침 · 예상 횡단도(표준 횡단 기본값) · 3D · 측점 No.+거리 표기
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 12:21:54 +09:00
30 changed files with 549 additions and 30 deletions
+2 -2
View File
@@ -17,7 +17,7 @@
| 2 | 사업 지역 | 사업이 있는 지역 이름을 적음. 꼭 넣어야 하는 칸 |
| 3 | 임도 종류 · 설계속도 | 임도 종류를 고르면 옆 설계속도 목록이 따라 바뀜 |
| 4 | 프로젝트명 | 위 칸을 채우면 이름이 저절로 만들어짐. 직접 고쳐도 됨 |
| 5 | 노선 누가거리 | 노선 시작 · 종료 누가거리(m). 비워 두어도 됨 |
| 5 | 노선 시작 · 종료 측점 | 거리(누가거리 m)로 넣으면 옆에 측점(`61+14.5`)이 보임. 비워 두어도 됨 |
| 6 | 단추 줄 | [?] 도움말 · [취소] 대시보드로 돌아감 · [등록] 프로젝트를 만듦 |
## 작업 순서
@@ -26,7 +26,7 @@
2. 사업 연도 시작 · 끝과 사업 지역을 넣음. [사업 연도 · 지역](D01/02_사업연도_지역) 참고.
3. 임도 종류와 설계속도를 고름. [임도 종류 · 설계속도](D01/03_임도종류_설계속도) 참고.
4. 프로젝트명을 확인함. [프로젝트명](D01/04_프로젝트명) 참고.
5. 필요하면 노선 시작 · 종료 누가거리를 넣음. [노선 누가거리](D01/05_누가거리) 참고.
5. 필요하면 노선 시작 · 종료 측점을 넣음. [노선 시작 · 종료 측점](D01/05_누가거리) 참고.
6. [등록]을 누름. 문제가 있는 칸은 아래에 빨간 안내가 나옴. [입력 오류와 등록](D01/06_입력오류와_등록) 참고.
7. 등록되면 바로 다음 화면인 [기준 지정](D02/00_설명서)이 열림.
+8 -8
View File
@@ -1,27 +1,27 @@
# 노선 누가거리
# 노선 시작 · 종료 측점
노선 시작과 끝의 측점 값(누가거리, m)을 미리 정해 두는 칸. 비워 두어도 등록됨.
노선 시작과 끝의 측점을 미리 정해 두는 칸. 거리(누가거리, m)로 넣으면 칸 옆에 측점(No.+거리, 예 `61+14.5`)이 바로 보임. 비워 두어도 등록됨.
## 여는 길
- 예정노선 등록 화면 맨 아래 「노선 시작 누가거리 (m)」 · 「노선 종료 누가거리 (m)」.
- 예정노선 등록 화면 맨 아래 「노선 시작 측점 (누가거리 m 입력)」 · 「노선 종료 측점 (누가거리 m 입력)」.
## 단계
| 단계 | 할 일 |
| --- | --- |
| 1 | 노선 첫 지점의 측점 값을 「노선 시작 누가거리」에 넣음. 0 이상의 수. |
| 2 | 노선 끝에서 목표로 하는 거리를 「노선 종료 누가거리」에 넣음. 시작 값보다 커야 함. |
| 1 | 노선 첫 지점의 거리(m)를 「노선 시작 측점」 칸에 넣음. 0 이상의 수. 칸 옆에 `= 측점` 이 바로 나옴. |
| 2 | 노선 끝에서 목표로 하는 거리(m)를 「노선 종료 측점」 칸에 넣음. 시작 값보다 커야 함. |
| 3 | 모르면 두 칸 모두 비워 둠. |
![누가거리 입력](D01_05_chainage.webp)
그림 번호 — 1 노선 시작 누가거리 · 2 노선 종료 누가거리
그림 번호 — 1 노선 시작 측점 · 2 노선 종료 측점
## 보이는 것
- 시작 값은 [노선 분석](D03/00_설명서) 화면의 측점 번호가 어디서부터 세는지를 정함. 비우면 0 부터.
- 종료 값은 분석을 마친 뒤 실제 이은 끝 누가와 비교해 「목표 · 차이」로 보임.
- 종료 값이 시작 값 이하이면 [등록] 때 「종료 누가거리는 시작보다 커야 합니다.」 안내가 나옴.
- 종료 값이 시작 값 이하이면 [등록] 때 「종료 측점은 시작보다 커야 합니다.」 안내가 나옴.
> 참고: 누가거리 = 노선을 따라 잰 거리.
> 참고: 누가거리 = 노선을 따라 잰 거리(m). 측점 = 20 m 마다 번호(No.)를 매기고 남는 거리를 더한 표기(1234.5 m = `61+14.5`). 저장되는 값은 m 그대로.
@@ -17,7 +17,7 @@
![입력 오류 안내](D01_06_errors.webp)
그림 번호 — 1 끝 연도가 시작 연도보다 앞 · 2 사업 지역 비어 있음 · 3 종료 누가거리가 시작 이하
그림 번호 — 1 끝 연도가 시작 연도보다 앞 · 2 사업 지역 비어 있음 · 3 종료 측점이 시작 이하
![단추 줄](D01_07_buttons.webp)
@@ -30,7 +30,7 @@
| 숫자 범위 안내 | 시작 연도가 2000 ~ 2100 밖 |
| 「끝 연도는 시작 연도와 같거나 뒤여야 합니다.」 | 끝 연도가 시작 연도보다 앞 |
| 「필수 항목을 입력하세요.」 | 사업 지역 또는 프로젝트명이 빔 |
| 「종료 누가거리는 시작보다 커야 합니다.」 | 종료 값이 시작 값 이하 |
| 「종료 측점은 시작보다 커야 합니다.」 | 종료 값이 시작 값 이하 |
- 산림청이 아닌 계정이거나 회사에 연결되지 않은 계정이면 서버가 「예정노선은 산림청만 만들 수 있습니다.」로 막고, 이 화면에 머묾.
- 등록에 실패하면 서버가 알려 준 까닭이 알림으로 뜸.
+3 -3
View File
@@ -13,8 +13,8 @@
| 임도 종류 | 간선임도 · 산불진화임도 · 작업임도 중 하나 | 간선임도(목록 맨 위) | 설계속도 선택지와 이름 조합에 쓰임 |
| 설계속도 | 노선을 설계하는 기준 속도 | 20 km/h | 아래 표 참조 |
| 프로젝트명 | 대시보드에 보이는 이름 | 자동 조합 | 필수 · 글자 255 자까지 |
| 노선 시작 누가거리 (m) | 노선 첫 지점의 측점 값 | 비어 있음 | 0 이상 · 선택 |
| 노선 종료 누가거리 (m) | 노선 끝 지점의 계획 목표 거리 | 비어 있음 | 시작 값보다 커야 함 · 선택 |
| 노선 시작 측점 (누가거리 m 입력) | 노선 첫 지점의 거리(m) · 옆에 측점 표기 | 비어 있음 | 0 이상 · 선택 |
| 노선 종료 측점 (누가거리 m 입력) | 노선 끝 지점의 계획 목표 거리(m) · 옆에 측점 표기 | 비어 있음 | 시작 값보다 커야 함 · 선택 |
## 설계속도 선택지
| 임도 종류 | 고를 수 있는 값 |
@@ -36,5 +36,5 @@
- 시작 연도가 2000 ~ 2100 밖이면 숫자 범위 안내가 나옴.
- 끝 연도가 시작보다 앞이면 「끝 연도는 시작 연도와 같거나 뒤여야 합니다.」 가 나옴.
- 지역이나 이름이 비면 필수 입력 안내가 나옴.
- 종료 누가거리가 시작 이하이면 「종료 누가거리는 시작보다 커야 합니다.」 가 나옴.
- 종료 측점이 시작 이하이면 「종료 측점은 시작보다 커야 합니다.」 가 나옴.
- 산림청이 아닌 계정으로 등록하면 서버가 「예정노선은 산림청만 만들 수 있습니다.」 로 막음. 회사에 연결되지 않은 계정도 막힘.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

+2 -2
View File
@@ -14,13 +14,13 @@
| 번호 | 구역 | 하는 일 |
| --- | --- | --- |
| 1 | 연도 표 | 연도마다 연장 · 시점 · 종점 · 누가 · 확정 시각 · 지정 업체를 한 줄로 보여 줌. 업체를 여기서 추가 · 삭제함 |
| 1 | 연도 표 | 연도마다 연장 · 시점 · 종점 · 측점 · 확정 시각 · 지정 업체를 한 줄로 보여 줌. 업체를 여기서 추가 · 삭제함 |
| 2 | 「확정할 연도」 카드 | 확정할 연도를 고름. 분석 [저장]이 된 연도만 나옴 |
| 3 | 단추 줄 | [?] · [<] · [>] · [내보내기] · [확정] · [저장] |
## 작업 순서
1. 연도 표에서 연도마다 연장과 누가를 확인함. [연도 표](D04/01_연도_표) 참고.
1. 연도 표에서 연도마다 연장과 측점을 확인함. [연도 표](D04/01_연도_표) 참고.
2. 지정 업체 칸에서 업체를 추가함. 등록 회사는 이름을 찾아 고르고, 아니면 업체명과 메일을 직접 적음. [지정 업체](D04/02_지정_업체) · [업체 직접 추가](D04/03_업체_직접_추가) 참고.
3. [저장]을 눌러 지정 업체를 서버에 저장함. [단추와 확정할 연도](D04/04_단추와_확정할_연도) 참고.
4. 「확정할 연도」에서 확정할 연도를 고르고 [확정]을 누름. 확인 창에서 진행을 누르면 확정과 메일 발송이 이뤄짐. [확정과 메일](D04/05_확정과_메일) 참고.
+3 -3
View File
@@ -8,7 +8,7 @@
![연도 표](D04_01_year_table.webp)
그림 번호 — 1 연장(m) · 2 시점(위도, 경도) · 3 누가(m) · 4 확정 시각 · 5 지정 업체
그림 번호 — 1 연장(m) · 2 시점(위도, 경도) · 3 측점 · 4 확정 시각 · 5 지정 업체
## 단계
@@ -16,13 +16,13 @@
| --- | --- |
| 1 | 연도 열과 연장(m)으로 연도별 노선 길이를 확인함. |
| 2 | 시점 · 종점(위도, 경도)으로 노선 양 끝 위치를 확인함. |
| 3 | 누가(m) 열로 그 연도 노선이 전체 노선의 어디부터 어디까지인지 확인함. |
| 3 | 측점 열(`61+14.5` 꼴)로 그 연도 노선이 전체 노선의 어디부터 어디까지인지 확인함. |
| 4 | 확정 열이 비어 있으면 아직 확정 전. 날짜와 시각이 있으면 확정이 끝난 연도. |
| 5 | 지정 업체 열은 [지정 업체](D04/02_지정_업체) 참고. |
## 보이는 것
- 분석을 저장하지 않은 연도는 누가 칸에 「노선 분석 [저장] 전」이 나옴. 「확정할 연도」 목록에도 나타나지 않음.
- 분석을 저장하지 않은 연도는 측점 칸에 「노선 분석 [저장] 전」이 나옴. 「확정할 연도」 목록에도 나타나지 않음.
- 확정 열 = 확정한 날짜와 시각(한국 시간). 비어 있으면 아직 확정 전.
- 시점 · 종점은 위도 · 경도 순서로 소수 6 자리까지 보임.
@@ -31,8 +31,8 @@
| 대상지 | 「사업지역.연장km」 글자(예: 지역.2.2). 20바이트(한글 10자 안팎)를 넘으면 뒤가 잘림 | 글자 20 |
| 길이 | 노선 연장, 단위 km(소수 셋째 자리) | 숫자 11.3 |
| 연도 | 사업 연도. 전체 노선은 비어 있음 | 정수 4 |
| 시점누가 | 그 선이 시작하는 측점 값(m) | 숫자 11.2 |
| 종점누가 | 그 선이 끝나는 측점 값(m) | 숫자 11.2 |
| 시점누가 | 그 선이 시작하는 누가거리(m) · 기계용 숫자 칸 | 숫자 11.2 |
| 종점누가 | 그 선이 끝나는 누가거리(m) · 기계용 숫자 칸 | 숫자 11.2 |
- 대상지 · 길이 두 칸은 용화 양식과 같고, 연도 · 시점누가 · 종점누가 는 이 프로그램이 덧붙인 칸임.
- 누가 값은 2단계에서 이어 셈한 값 그대로임. 앞 연도 종점 누가 = 다음 연도 시점 누가.
@@ -50,7 +50,8 @@
| 종점 X · 종점 Y | 끝점 UTM-K 좌표(m) |
| 시점 경도 · 시점 위도 | 시작점 위경도(도) |
| 종점 경도 · 종점 위도 | 끝점 위경도(도) |
| 시점누가(m) · 종점누가(m) | 이어 셈한 측점 값 |
| 시점 측점 · 종점 측점 | 측점(No.+거리 · 예 `61+14.5` · 20 m 마다 번호) |
| 시점누가(m) · 종점누가(m) | 이어 셈한 누가거리(m) |
| 연장(m) | 그 연도 노선 길이 |
## 보는 사람에 따른 차이
Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

After

Width:  |  Height:  |  Size: 91 KiB

@@ -18,6 +18,7 @@ import {
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { createHelpButton } from "@ui/ui_template_help";
import { t } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
import { createGroupProject } from "../A00_Common/group_workflow";
import { navigateTo } from "../A00_Common/router";
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
@@ -26,6 +27,9 @@ import { SPEED_CHOICES } from "../B05_Profile/logic/B05_Profile_Grade_Criteria";
/** B02 임도 종류 → 설계속도 기준 등급(B05 grade_class_of 와 같음) */
const GRADE_CLASS: Record<string, string> = { main: "trunk", fire: "fire", work: "work" };
/** 측점 간격 — D03 `PLAN_STATION_INTERVAL_M` 와 같음 */
const STATION_INTERVAL_M = 20;
const numberOrNull = (text: string): number | null =>
text.trim() === "" ? null : Number.parseFloat(text);
@@ -64,6 +68,20 @@ export function renderD01PlanRegister(root: HTMLElement): void {
const name = createInputField({ label: t("DE_Reg_Name"), required: true });
const startM = createInputField({ label: t("DE_Reg_StartM"), type: "number", min: 0 });
const endM = createInputField({ label: t("DE_Reg_EndM"), type: "number", min: 0 });
// 누가거리 칸 옆 즉시 측점 표기(저장값 · 서버 검사는 m 그대로)
for (const field of [startM, endM]) {
const shown = document.createElement("span");
shown.style.cssText = "font-size:12px;color:var(--color-text-muted)";
shown.dataset.station = "";
field.root.insertBefore(shown, field.root.lastElementChild);
field.input.addEventListener("input", () => {
const value = numberOrNull(field.input.value);
shown.textContent =
value !== null && Number.isFinite(value) && value >= 0
? `= ${stationLabel(value, STATION_INTERVAL_M, 1)}`
: "";
});
}
// 설계속도 = 종류별 고를 수 있는 값 · 기본 = 가장 낮은 값(20)
const syncSpeeds = (): void => {
@@ -0,0 +1,124 @@
/* =============================================================================
* D03_PlanAnalysis_Expect.ts
* D03 예상 횡단(PLAN 544-1 · 요구 363-1) — 측점 지반 → 표준 횡단 기본값 설계선 · 절 · 성토 ㎡.
* 셈 = 공용 `computeCrossDesign`(① 짝 `compute_cross_design` · 거울 `test_b06_cross_design_mirror.py`)
* · 기본 = B 초기 설계와 같음(리핑암 · 산쪽 절토 · 일반 측구 · 암 경계 = 서버 기본) · 계획고 = 측점 중심 지반고
* · 미교차 = `extendUnclosed`(격자 있을 때) · 곡선 확폭 없음 · 보기만(저장 없음).
* 3D = 같은 단면을 B05 `buildCorridor` 가 읽는 측점 꼴로(`expectSections`).
* 시험 `resources/tester/test_544_1_plan_expect.py`.
* ========================================================================== */
import {
computeCrossDesign,
type CrossDesignOptions,
type CrossDesignResult,
type StandardCrossSectionSpec,
} from "../../common_util/common_util_cross_design";
import type { DtmGrid } from "../../common_util/common_util_surface_sampler";
import {
extendUnclosed,
type CrossGroundPoint,
type StationGround,
} from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
/** 서버 `sections/context` 의 표준 횡단(config) · 암 경계 기본 오프셋. */
export interface ExpectStandard {
standard: StandardCrossSectionSpec;
rockBoundaryM: number | null;
}
export interface ExpectCross {
design: CrossDesignResult;
/** 설계에 쓴 지반 표본(미교차로 넓혔으면 넓힌 것). */
samples: CrossGroundPoint[];
}
/** B 초기 설계 기본 — 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구. */
export function expectOptions(
uphill: "left" | "right" | null,
std: ExpectStandard,
): CrossDesignOptions {
return {
groundType: "ripping_rock",
sectionMode: `${uphill ?? "left"}_cut`,
ditchType: "standard",
standard: std.standard,
rockBoundaryOffsetM: std.rockBoundaryM,
};
}
/** 측점 하나 예상 단면 — 지반고 · 표본이 없으면 null. */
export function expectCross(
station: StationGround,
std: ExpectStandard,
grid?: DtmGrid | null,
): ExpectCross | null {
const elevation = station.center_z;
if (elevation === null) return null;
const options = expectOptions(station.uphill_side, std);
try {
let samples = station.samples;
let design = computeCrossDesign(samples, elevation, options);
const wide =
design.slope_unclosed && grid
? extendUnclosed(
samples,
[station.center_x, station.center_y],
station.left_xy,
(next) => computeCrossDesign(next, elevation, options),
grid,
)
: null;
if (wide) {
samples = wide.samples as CrossGroundPoint[];
design = computeCrossDesign(samples, elevation, options);
}
return { design, samples };
} catch {
return null; // 표본 부족 — B 와 같은 태도(그 측점 건너뜀)
}
}
/** B06 `CrossSection` 중 `buildCorridor` 가 읽는 칸(B06 형 파일은 별칭 import 라 여기 안 끌어옴). */
export interface ExpectSection {
station_id: string;
chainage_m: number;
label: string;
kind: "regular";
center_z: number | null;
azimuth_deg: null;
center_x: number;
center_y: number;
uphill_side: "left" | "right" | null;
frame: { left_xy: [number, number] };
samples: CrossGroundPoint[];
design: CrossDesignResult;
}
/** 3D 코리도 입력 — 측점마다 예상 단면(못 세운 측점 뺌) · 누가 = 연도 노선 위 누가. */
export function expectSections(
stations: StationGround[],
std: ExpectStandard,
grid?: DtmGrid | null,
): ExpectSection[] {
return stations.flatMap((station) => {
const cross = expectCross(station, std, grid);
if (!cross) return [];
return [
{
station_id: String(station.chainage_m),
chainage_m: station.chainage_m,
label: "",
kind: "regular",
center_z: station.center_z,
azimuth_deg: null,
center_x: station.center_x,
center_y: station.center_y,
uphill_side: station.uphill_side,
frame: { left_xy: station.left_xy },
samples: cross.samples,
design: cross.design,
},
];
});
}
+22 -1
View File
@@ -7,6 +7,9 @@
계산 자리 ③ 서버 단독 — 화면이 만지지 않는다. 순수 함수(값 → 바이트)만 두고
파일은 임시 폴더에서만 쓴다.
짝: `station_label` ↔ `common_util/common_util_station.ts` `stationLabel(…, 1)`
(거울 시험 `test_545_1_station_label_mirror.py`).
"""
from __future__ import annotations
@@ -49,6 +52,20 @@ _SCHEMA = {
},
}
_BAD_FILENAME = re.compile(r'[\\/:*?"<>|\r\n\t]')
STATION_INTERVAL_M = 20.0 # D03 `PLAN_STATION_INTERVAL_M` 와 같음
def station_label(chainage_m: float, interval_m: float = STATION_INTERVAL_M) -> str:
"""측점 표기 `61+14.5` — TS `stationLabel(chainage, interval, 1)` 짝(소수 한 자리 · 반올림 위로)."""
safe = interval_m if interval_m > 0 else 1.0
number = math.floor((chainage_m + 1e-6) / safe)
rest = chainage_m - number * safe
if abs(rest) < 0.05:
rest = 0.0
if rest >= safe - 0.05:
number, rest = number + 1, 0.0
tenth = math.floor(rest * 10 + 0.5) / 10
return f"{number}+{int(tenth) if tenth == int(tenth) else tenth}"
@dataclass
@@ -130,7 +147,7 @@ def _write_shapefile(
def _coordinate_xlsx(project_name: str, rows: list[dict]) -> bytes:
"""사람이 읽는 좌표표 — 연도별 시점 · 종점(UTM-K · 위경도) · 누가 · 연장."""
"""사람이 읽는 좌표표 — 연도별 시점 · 종점(UTM-K · 위경도) · 측점 · 누가 · 연장."""
to_ll = Transformer.from_crs(EXPORT_EPSG, 4326, always_xy=True)
book = Workbook()
sheet = book.active
@@ -147,6 +164,8 @@ def _coordinate_xlsx(project_name: str, rows: list[dict]) -> bytes:
"시점 위도",
"종점 경도",
"종점 위도",
"시점 측점",
"종점 측점",
"시점누가(m)",
"종점누가(m)",
"연장(m)",
@@ -166,6 +185,8 @@ def _coordinate_xlsx(project_name: str, rows: list[dict]) -> bytes:
round(slat, 7),
round(elon, 7),
round(elat, 7),
station_label(row["start_station"]),
station_label(row["end_station"]),
round(row["start_station"], 2),
round(row["end_station"], 2),
round(row["length_m"], 2),
+7 -2
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* D04_PlanConfirm_UI_Page.ts
* D04 확정 · 업체 · 내보내기(요구서 345 3-4 · PLAN 531-3) — B05~B09 틀(group_workflow).
* 본문 = 연도 표(연장 · 시점 · 종점 · 누가 · 확정 · 지정 업체) · 연도마다 업체 여럿
* 본문 = 연도 표(연장 · 시점 · 종점 · 측점 · 확정 · 지정 업체) · 연도마다 업체 여럿
* (등록 회사 찾기 = 가입 화면 회사 검색 · 아니면 업체명 + 메일 수기).
* 좌측 = 확정할 연도 고르기 + [내보내기] [확정] [저장].
* 조작 = 캐시 `d04-assignees` 즉시 · [저장] = PUT …/plan/assignees(연도 전부 맞춤) → 캐시 비움 ·
@@ -23,6 +23,7 @@ import {
} from "@ui/ui_template_elements";
import { createPanelCard } from "@ui/ui_template_collapsible";
import { t } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch";
@@ -77,6 +78,10 @@ async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const fmt = (value: number | undefined, digits = 1): string =>
value === undefined || value === null ? "—" : value.toFixed(digits);
/** 측점 간격 — D03 `PLAN_STATION_INTERVAL_M` 와 같음 */
const STATION_INTERVAL_M = 20;
const station = (m: number | undefined): string =>
m === undefined || m === null ? "—" : stationLabel(m, STATION_INTERVAL_M, 1);
const where = (p?: EndPoint): string => (p ? `${p.lat.toFixed(6)}, ${p.lon.toFixed(6)}` : "—");
/** 같은 업체 — 등록 회사 = id · 수기 = 메일(서버 `_key` 와 같음) */
const sameFirm = (a: Assignee, b: Assignee): boolean =>
@@ -221,7 +226,7 @@ export async function renderD04PlanConfirm(root: HTMLElement): Promise<void> {
const rows = summary.years.map((row) => {
const firms = list.filter((item) => item.year === row.year).map(firmChip);
const chain = row.saved
? `${fmt(row.start_chainage_m)} ~ ${fmt(row.end_chainage_m)}`
? `${station(row.start_chainage_m)} ~ ${station(row.end_chainage_m)}`
: t("D04_NotSaved");
return el("tr", {
attrs: { "data-year": String(row.year) },
@@ -0,0 +1,93 @@
# 분석 363 — 예정노선 지점 고침 · 예상 횡단 · 3D · 측점 표기
읽기 전용 조사 · 코드 고침 없음 · 줄 번호 = main_laptop_1 `4e1dc5c1f` 기준.
## ① 결론 다섯 줄
1. 연도 경계 지점은 이미 고쳐짐(D02 카카오 끌기 · 누름 · 경위도 칸, D03 끌기) — 단 산림청 계정만. 비어 있는 것 = 추천 노선 꺾임점(IP) 고침 → D03 평면에 B05 순수 함수를 import 만 해서 얹음. D02 카카오는 브라우저 좌표 변환이 없어 비권.
2. 꺾임점 고침 = 캐시에 마디만 · 브라우저가 B05 `rebuildLine` 으로 선 → 길이 · 측점 · 지반 즉시 · 재분석 없음(경계 지점 옮김만 앞뒤 연도 재분석) · [저장] = 서버가 마디로 선(`node_polyline`) · 지표(`route_metrics`) 다시 셈.
3. 예상 횡단 = `computeCrossDesign`(common_util · 파이썬 짝 있음) + config 표준 횡단 + B 초기 설계 기본(리핑암 · 암 경계 −0.5 · 산쪽 절토 · 일반 측구) · 계획고 = 중심 지반고(B 첫 종단 규칙) · `buildCrossSvg` 의 groundOnly 끔.
4. 3D = 같은 예상 단면 → B05 `buildCorridor` → `viewer.setCorridor`(D03 모달이 이미 B05 뷰어) — 새 엔드포인트 · 새 메시 코드 없음 · B 그룹 코드 고침 0.
5. 측점 표기 — D03 화면은 이미 B 화면과 같은 `stationLabel`(`61+14.5`) · 날 누가가 남은 곳 = D01 시작 · 종료 입력 · D04 표 · D04 xlsx(shp 숫자 칸은 그대로 권함) · 「No.」 접두사 여부만 사용자 확인.
## ② 항목별 현황
### A. 지점 · 노선 고침
- D02 지점 P0..Pn = 연도 경계 · 카카오 표지 끌기 `onMove` · 지도 누름 `onClick` · 경위도 칸(`D02_PlanBase_UI_Page.ts:101-117`, `:150`) · 표지 끌기 `ui_template_kakao_map.ts:180-184` · 선 = 지점 사이 직선만(`D02_..._UI_Page.ts:121-137`) · 추천 노선은 안 그림.
- 고침 권한 = 산림청(시스템관리자 · `*.go.kr`)만 — `D02_..._UI_Page.ts:85`, `:242` · `common_util_auth.py:156-158` · D03 PUT `D03_PlanAnalysis_Router.py:300-301`. 일반 회사 계정이면 화면이 보기만 → 「고칠 수 없음」으로 보였을 수 있음.
- D03 지점 끌기 `D03_PlanAnalysis_UI_Map.ts:291-328` → `D03_PlanAnalysis_UI_Page.ts:273-292`(끈 지점 앞 · 뒤 연도 분석 비움 → 다시 [분석]) · 캐시 `d03-plan` {nodes, choices}(`UI_Page.ts:79-82`, `:163-164`) · [저장] = 지점 + 갈래 이름만 PUT(`UI_Page.ts:421-438` · `Router.py:293-339`).
- 노선 꺾임점 고침 = 어디에도 없음.
- 저장 꼴 — 파생 `D03_PlanAnalysis/_derived/route_<연도>.json` = 갈래 넷 × {planned, nodes, curves, metrics}(`D03_PlanAnalysis_Analyze.py:64-65`, `:101-103`, `:177`) · 입력 지문 `:119-135` · [저장] = `plan_route_years.route_json` ← {이은 누가 행 + planned · nodes · curves}(`Router.py:322-334`) · 측점 목록은 저장 안 함(화면 `stationChainages` · `logic/D03_PlanAnalysis_Plan.ts:98-111`).
- 이은 누가 길이 = 엔진 `metrics.length_m`(`Analyze.py:307` ↔ `Plan.ts:65` · ① 짝) — 선을 고치면 선 길이로 바꿔야 함.
- TS `PlanRoute` 에 nodes · curves 칸 없음(`Plan.ts:21-30`) — 서버는 이미 내려 줌.
- B05 편집기 화면 `mountRouteEditor`(`B05_Profile_UI_RouteEdit.ts`) = B05 페이지 · `/route/plan` · 설계 파이프라인에 묶임 → 통째 재사용 불가.
- 순수 부품(export 됨 · DOM · fetch 없음): `buildEditedPolyline` `B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve.ts:250` · `dragHandleTo` `:96` · `rebuildLine` `logic/B05_Profile_UI_RouteEdit_Edits.ts:172` · `flattenServerPlan` `:380` · `spliceNode` `:441` · `radiusFloorM` `:61` · `curveShortfalls` `:136` · `lineLength` `logic/B05_Profile_Sections_Stations.ts:242` · 집기 `nodeAtScreen` · `handleAtScreen` · `segmentAtScreen`(`B05_Profile_UI_RouteEdit_Input.ts:166`, `:192`, `:251` — D03 가 이미 이 파일 import · `UI_Map.ts:30-33`).
- 서버 정본 부품: `node_polyline`(`B05_Profile_Route_Polyline_Write.py:57` · Node `route.polyline` = 같은 TS) · 폴백 `build_planned_polyline(simplify=False, curve_flags, radii)`(`common_util_route_polyline.py:290`) · 지표 `route_metrics`(`B05_Profile_Engine_RouteInitial_Metrics.py:115`) · 최소 R = 분석 저장본 `criteria.min_radius_m`(`Analyze.py:169-171`).
- D02 카카오 = 경위도 · B05 셈 = TM m · 브라우저 좌표 변환 없음(proj4 · TS 변환 함수 없음) → D02 에서 꺾임점을 끌면 끌 때마다 서버 왕복 → 5장 「조작 중 네트워크 왕복 금지」 어김.
### B. 예상 횡단
- 지금: `sectionsGround(line, [localM], grid)` → `buildCrossSvg({…, design: null}, w, h, true)`(`D03_PlanAnalysis_UI_Cross.ts:60-85`) · 깃발 `B05_Profile_UI_RouteEdit_Cross_Draw.ts:100-107`.
- groundOnly 끄면: `design.design_line` 을 사면 끝에서 잘라 그림 + 「기본 계획 횡단」 범례(`Cross_Draw.ts:121-132`, `:194`) · 그리기만, 셈 없음 · `calcChecked` 는 계산 자리가 server 일 때만 뒤에서 대조(`design_calc_server.ts:164-192`).
- 표준 횡단 기본값 = `config/config_system_design.py:277-311` `STANDARD_CROSS_SECTION`(soil · rock · paved · 노폭 3.0 · 노견 0.5×2 · 절토 1:1.0 / 암 1:0.4 · 성토 1:1.2 · 측구) · 암 경계 −0.5 `:313` · `resources/master_template` · M02 에는 없음 · 프로젝트별 고친 값 = B06 표준 패널 → 종단 options(D 엔 없음 → config 값만).
- 셈: `computeCrossDesign(samples, 계획고, options)` `common_util/common_util_cross_design.ts:267`(config 값을 품지 않음 · standard 를 넘김) · 파이썬 짝 `compute_cross_design` `B06_Section_Engine_Design.py:197` · 거울 시험 `test_b06_cross_design_mirror.py`.
- B 초기 기본: `optionsFor` 리핑암 · left_cut · 일반 측구(`B06_Section/logic/B06_Section_Cross_Stage.ts:49-64`) · 서버 `attach_default_designs` 리핑암 · 산쪽 절토 · 암 경계 −0.5(`B06_Section_Router_Design.py:359-395`) · 사면이 ±20 m 안에서 안 닿으면 `extendUnclosed`(`Cross_Stage.ts:139-150` · `logic/B05_Profile_Sections_Ground.ts:269`).
- 브라우저에 표준값 — B = `fetchSectionContext`(`B06_Section_Api_Context.ts:18` · 서버 `B06_Section_Router.py:125-127`, config 값을 늘 실음). D 프로젝트는 노선 문맥이 None 일 뿐이라 될 것으로 봄 — 실측 필요 · 안 되면 D03 GET 에 두 칸.
- 계획고 — D 는 종단 설계 없음 · 분석은 `with_profile: False`(`Analyze.py:114`). 후보 ① 중심 지반고 = B 첫 종단 규칙(`logic/B05_Profile_Grade_Ground.ts:144` · `B05_Profile_Engine_RouteInitial_CrossEarth.py:7-8`) — 즉시 · 짝 이미 있음 ② 엔진 `balanced_profile`(`RouteInitial_Profile.py:325-474`) — 켜면 지문이 바뀌어 전 연도 재분석 · 고친 선엔 무효 ③ 브라우저 `groundFollowingBasePvi` + 기울기 고르기 — B 종단 정책까지 끌어옴.
- 서버 Node `cross.design`(`A00_Common/design/logic/design_calc_node.ts` · `package.json:14` build:design-calc) = 같은 `computeCrossDesign` — D 가 예상 단면 값을 저장할 때만 필요(지금은 보기만 → 저장 없음 · 5장 위반 아님).
### C. 3D
- 지금: B05 `createRouteViewer` · 연도 DEM GLB `loadSurface` · 노선 선 · 측점 막대만 · `setCorridor` 안 씀(`D03_PlanAnalysis_UI_3D.ts:3-5`, `:64-83`).
- B05 코리도 = 순수 `buildCorridor(sections, routePoints, designSamples?)`(`B05_Profile/logic/B05_Profile_Corridor_Build.ts:140`) — 측점마다 chainage_m(선 첫 점 = 0) · center_x/y · frame.left_xy · samples · design(design_line · road_edges · ditch · section_mode) → `viewer.setCorridor(build)`(`B05_Profile_UI_Viewer.ts:84`) → 노면 메시 + 지형을 잘라 절 · 성토면(`B05_Profile_UI_Viewer_Corridor.ts:183-222` · `B05_Profile_UI_Corridor_Tin.ts:181` — `build.sections` 를 붙여야 잘림).
- 서버 저장 코리도 `corridor_{route}.json`(`B05_Profile_Router_Corridor.py:27-29`) = route_id 묶임 → D 엔 안 맞음 · E03 · E04 3D = 날 three.js · 다른 꼴 → 비권.
### D. 측점 표기
- B 화면 표기 한 벌 = `stationLabel(ch, interval, decimals)` `common_util/common_util_station.ts:11` → `n+r`(접두사 없음 · `:8`) · `inferStationInterval` `:34`. `No.` / `NO.` 는 B07 CAD · M02 표 · B05 알림 하나만(`B07_DesignDetail_Engine_Cad.py:319` · `M02_Table_Fill.py:297` · `logic/B05_Profile_RouteEdit_KeyNudge.ts:115`).
- 간격 — B = 프로젝트 `station_interval_m` 기본 20(`config_system_design.py:254`) · 시점 0. D = 20 고정 `PLAN_STATION_INTERVAL_M`(`UI_Map.ts:38`) · 번호 = D01 시작 누가(`route_start_m`)부터 절대 누가(`Router.py:229`, `:259` · `Plan.ts:98-124`) · 연도 경계 = 측점(`+r` 붙음).
- D 이미 `n+r`: 연도 머리 `UI_Page.ts:225-226` · 끝 · 목표 `:267-268` · 평면 눈금 `UI_Map.ts:209-218` → `B04_PreProcess_UI_MapOverlays.ts:294` · 횡단 머리 `UI_Cross.ts:65` · 3D 막대 `UI_3D.ts:72-81` → `B05_Profile_UI_Markers.ts:342`, `:415`(정수 · 간격 추정 → 평면 `61+14.5` 대 3D `61+15`).
- D 날 누가: D01 「노선 시작 · 종료 누가거리 (m)」 입력(`D01_PlanRegister_UI_Page.ts:65-66`) — 「초기에 누가거리 형태」에 맞는 자리 · D04 표 `시점 ~ 종점` 수 + 머리 「누가 (m)」(`D04_PlanConfirm_UI_Page.ts:224` · `ui_template_locale_de.ts:29`) · D04 xlsx `시점누가(m)` · `종점누가(m)`(`D04_PlanConfirm_Export.py:150-151`, `:169-170`) · shp dbf `시점누가` · `종점누가` N(11,2)(`Export.py:47-48`, `:116-117` · 345-C 양식 덧칸 · B03 은 무시).
- 도움말: `A05_EduDetail/guide/D03/03_평면_지도_읽기.md:11,18,29` · `04_측점_횡단.md:17` = `n+r` 로 적힘 · `guide/D01/05_누가거리.md:3,23-24` = m 입력.
- E(참고 · 이번 범위 밖): 대개 B06 저장 label `STA.0+120.000`(km 식 · `B05_Profile_Engine_Sections_Core.py:70`) 또는 `0+120`(`E03_StabSection_UI_List.ts:86` · E04 · E07 지도) · E02 표만 `stationLabel`(`E02_StabGround_UI_Tables.ts:306`) · E05 xlsx · PDF = label + 날 수(`E05_StabReport_Build.py:360-361`).
### E. 위험
- B 그룹 고침 = 0(모두 import). 선택 1건 — 범례 「기본 계획 횡단」을 「예상 횡단」으로 바꾸려면 `buildCrossSvg` 인자 하나(`Cross_Draw.ts:194`) · 그대로 둬도 뜻 맞음.
- 성능 — 꺾임점 고침 · 예상 횡단은 재분석 없음(288 s = 3 연도 6.5 km 전체 분석 · `완료보고_345_예정노선_안정성평가.md:12` · 경계 지점 옮길 때만) · 단면 하나 = ms 수준 · 3D `buildCorridor` 한 연도 ≈ 100 측점 × 0.2 m 쪼갬 + 지형 TIN 자름 = 메인 스레드 멈칫 가능 → 실측 뒤 길면 Worker.
- [저장] — 고친 연도마다 지형 · 계류 읽기 + `route_metrics`(비용면 캐시 `_derived/cost_<연도>` 재사용) · 실측 필요 · 저장 잠금 안이라 허용 범위.
- 데이터 — route_json 덧칸(`edited` · `base_mode` · `base_fingerprint` · 입력 마디)만 · `mode` 는 PLAN_MODES 그대로 → `_saved_mode` · D04 Link · Export 는 `planned` 만 읽음(`D04_PlanConfirm_Link.py:74-91`) → 그대로 흐름 · Link 지문 바뀜 = 연결된 B 프로젝트에 「바뀜」 표시(의도).
- 재분석(지문 다름 · 경계 지점 옮김) = 그 연도 고침 버림 → 알림 필요 · 처음 · 끝 마디 = 연도 경계 → 잠금(안 그러면 연도 사이 끊김).
- 3D 높이 — D03 `gridBounds` z = 격자 최소 · 최대(`UI_3D.ts:27-44`) · 코리도는 절대 좌표 → 지형과 높이 맞는지 스크린샷 확인.
- 짝 — `chain` ↔ `chainRows` 길이 출처 바뀜 → 거울 시험 `test_530_3_plan_logic.py` 함께.
- B 연결 뒤 B 프로젝트 측점은 0 부터(`요구서_345_예정노선_안정성평가.md:91`, `:103`) → D 번호와 다름(의도).
## ③ 권하는 설계
- 지점 고침 자리 = D03 평면(TM · 측점 · 횡단 · 3D 가 한 화면) · D02 = 지금대로 연도 경계만.
- 고침 대상 = 고른 갈래 노선의 마디(IP) — 끌기 · 선 위 두 번 누름 = 넣기 · 오른쪽 누름 = 지우기 · 곡선 손잡이 = R · 처음 · 끝 마디 잠금 · 되돌리기 = 열 배열 쌓기 · 법정 최소 R 밑이면 막음(`criteria.min_radius_m`) · 권한 = 지금 규칙(산림청).
- 캐시 — `d03-plan` 에 `edits{연도: {base_mode, base_fingerprint, nodes, curve_flags, radii}}` 더함 · 자동저장 없음.
- 다시 셈(브라우저 즉시) — `rebuildLine` → planned → `lineLength` → `chainRows`(고친 길이) → `stationChainages` → 평면 · 카드(연장 즉시 · 최대 기울기 · 위반 = 「저장 때 셈」 · TS 짝 없는 ③ 서버 값) · 누른 측점 횡단 다시.
- [저장] — PUT 에 edits(마디 입력만 · 선 안 받음) → 서버 `node_polyline`(폴백 `build_planned_polyline`) → 끝점 = 지점 확인 → `route_metrics`(연도 지형) → `chain`(선 길이) → route_json. GET 은 저장된 고침을 지문이 같을 때만 돌려줌.
- 예상 횡단 — 새 순수 `logic/D03_PlanAnalysis_Expect.ts`: `sectionsGround` 측점 → `computeCrossDesign(samples, center_z, {ripping_rock · ${uphill}_cut · standard 측구 · standard · 암 경계 −0.5})` + `extendUnclosed` · `UI_Cross` 는 design 넘기고 groundOnly 끔 · 머리에 「예상 — 표준 횡단 기본값 · 계획고 = 지반고」 · 절 · 성토 ㎡ 줄은 `buildCrossSvg` 가 이미 그림 · 곡선 확폭은 첫 판에 뺌.
- 3D — 같은 Expect 결과로 측점 묶음(chainage = 연도 선 위 localM) → `buildCorridor` → `build.sections` 붙임 → `setCorridor` · 모달에 「예상 코리도」 켬끔(`setCorridorVisible`).
- 측점 표기 — D 화면 = `stationLabel` 한 벌(B 화면과 같음) · D01 = m 입력 옆에 즉시 「= 61+14.5」(저장값 m 그대로 · 서버 검사 그대로) · D04 표 · xlsx = 측점 표기 칸(+ m 숫자 칸 유지) · shp = 숫자 그대로(기계용 양식 덧칸) · 「No.」를 붙이기로 하면 D 전용 감쌈 함수 하나(B 안 고침).
## ④ 계획 걸음 후보
창1 = 지점 고침(D03 Plan · Analyze · Router · Api · UI_Map · UI_Page · 새 Edit 둘) · 창2 = 예상 횡단 · 3D(UI_Cross · UI_3D · 새 Expect) · 창3 = 측점 표기(D01 · D04 · locale · 도움말). 창2 는 `cross.show` · `openPlan3d` 인자를 안 바꿈 → 창1 과 파일 안 겹침.
1. 창2 · 예상 단면 순수 함수 · `D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts`(새) · 새 시험 — 비탈 표본 → design_line · 절 · 성토 ㎡ = 파이썬 `compute_cross_design` 값 대조.
2. 창2 · 횡단 패널 예상 단면(표준값 = `fetchSectionContext` · D 프로젝트 실측) · `D03_PlanAnalysis_UI_Cross.ts` · ORCA — 가이드 D 프로젝트(`_projects.json` plan) 측점 누름 → SVG 설계선 좌표 · ㎡ 수치.
3. 창2 · 3D 예상 코리도 · `D03_PlanAnalysis_UI_3D.ts` · ORCA 스크린샷 — 노면 높이 = 지형 · 절 · 성토 색 · 시간 실측.
4. 창1 · 마디 고침 순수(연도별 열 · 다시 그음 · 길이 · 끝 잠금) + `PlanRoute` nodes · curves · `logic/D03_PlanAnalysis_Edit.ts`(새) · `logic/D03_PlanAnalysis_Plan.ts` · 새 시험(끝점 그대로 · 길이 = `lineLength`).
5. 창1 · `chain` ↔ `chainRows` 고친 길이 받기(짝) · `D03_PlanAnalysis_Analyze.py` · `Plan.ts` · `test_530_3_plan_logic.py` 거울.
6. 창1 · 평면 마디 겹(끌기 · 넣기 · 지우기 · R 손잡이) · `D03_PlanAnalysis_UI_Map.ts`(700 줄 넘으면 새 `D03_PlanAnalysis_UI_Edit.ts`) · ORCA eval — 끌기 뒤 연장 · 측점 · 횡단 바뀜.
7. 창1 · 캐시 · 카드 · 되돌리기 · 재분석 알림 · `D03_PlanAnalysis_UI_Page.ts` · `D03_PlanAnalysis_Api.ts` · ORCA — 새로고침 뒤 캐시 살아 있음.
8. 창1 · [저장] 고침 정본 + GET 돌려줌 · `D03_PlanAnalysis_Router.py` · 새 `D03_PlanAnalysis_Edit.py` · `test_530_2_plan_router.py` + 새 시험(브라우저 선 = 서버 선 1 mm · 저장 시간 실측).
9. 창3 · D01 입력 옆 측점 표기 · D04 표 · xlsx 측점 칸 · `D01_PlanRegister_UI_Page.ts` · `D04_PlanConfirm_UI_Page.ts` · `D04_PlanConfirm_Export.py` · `ui_template_locale_de.ts` · `test_531_d04_export_finalize.py` + ORCA.
10. 창3(끝에) · 도움말 · `A05_EduDetail/guide/D01/05_누가거리.md` · `guide/D03/03_평면_지도_읽기.md` · `04_측점_횡단.md` · `06_3D_보기.md` · 새 고침 · 예상 횡단 절 · 눈 확인.
## ⑤ 정할 거리(사용자)
1. 「각지점」 = 추천 노선 꺾임점(IP) 고침으로 봄 — 연도 경계 지점은 이미 D02 · D03 에서 끌기 됨(산림청 계정만). 맞는지.
2. 예상 횡단 계획고 = 중심 지반고(B 첫 종단과 같음 · 즉시) 권함 — 종단 기울기를 고른 선이 필요하면 별건.
3. 측점 표기 = B 화면처럼 `61+14.5`(지금 D03 그대로 · D01 · D04 만 맞춤) 권함 · 또는 D 전부 `No.61+14.5`.
브레인 몫(묻지 않음): 지반 기본 = 리핑암(B 초기와 같음) · 고침 권한 = 산림청 유지 · shp 숫자 칸 유지 · E 표기는 범위 밖.
@@ -197,3 +197,9 @@ on 이 맞음. 그런데 shell command가 아님?
## 요구 362 (10-26 · 사용자 · 완료 보고 359 답)
깃 싱크 진행. 사용자가이드 코드 저장위치 공유
## 요구 363 (10-26 · 사용자 · 예정노선 고침)
1. 예정노선
- 다음맵 페이지나 노선추천에서 각지점들의 수정이 가능해야함. 이때 측점을 누르면 보이는 횡단도는 원지반선 뿐만아니라 표준 횡단도의 프로그챔 기본값으로 반영된 예상횡단도 그림이 있어야 하면 이걸 3d에도 볼 수 있어야 함.
2. 측점의 표기는 프로그램 로직에서 초기테 누가거리 형태로 표기하는데 측점no. +거리 표기가 되어야함. 이 표현 방식은 b그룹 임도설계 로직에 많이 사용됨.
@@ -160,6 +160,13 @@ def test_zip_and_coordinate_table():
sheet = load_workbook(io.BytesIO(bundle.all["좌표표_시험 프로젝트.xlsx"])).active
rows = list(sheet.iter_rows(min_row=3, values_only=True))
assert [r[0] for r in rows] == [2026, 2027]
head = [c.value for c in next(sheet.iter_rows(min_row=2, max_row=2))]
assert head[9:11] == ["시점 측점", "종점 측점"] and head[11:13] == [
"시점누가(m)",
"종점누가(m)",
]
# 100 m → 5+0 · m 칸은 그대로
assert rows[0][9] == "5+0" and rows[0][11] == pytest.approx(100.0)
# 업체용 좌표표는 그 연도만
one = load_workbook(io.BytesIO(bundle.by_year[2026]["좌표표_시험 프로젝트.xlsx"])).active
assert [r[0] for r in one.iter_rows(min_row=3, values_only=True)] == [2026]
+187
View File
@@ -0,0 +1,187 @@
"""D03 예상 횡단(PLAN 544-1) — TS `logic/D03_PlanAnalysis_Expect.ts` 값 = 파이썬 `compute_cross_design`.
기본 = 리핑암 · 산쪽 절토(모르면 좌) · 일반 측구 · 암 경계 = config 기본 · 계획고 = 측점 중심 지반고.
미교차 = 격자로 넓힌 표본 · 넓힌 표본으로 파이썬이 다시 셈한 값과 같음 · 코리도 측점 꼴.
./venv/Scripts/python.exe -m pytest resources/tester/test_544_1_plan_expect.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B06_Section.B06_Section_Engine_Design import compute_cross_design # noqa: E402
from config.config_system_design import ( # noqa: E402
STANDARD_CROSS_SECTION,
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Expect.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as expect from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.js";
const job = JSON.parse(readFileSync(process.argv[2], "utf8"));
const std = { standard: job.standard, rockBoundaryM: job.rock };
// 평면 격자 — z = 100 + slope * y(좌 축 = +y) · 1 m · ±100 m
const n = 201;
const axis = Float64Array.from({ length: n }, (_, i) => i - 100);
const z = new Float64Array(n * n);
for (let iy = 0; iy < n; iy += 1)
for (let ix = 0; ix < n; ix += 1) z[iy * n + ix] = 100 + job.wideSlope * axis[iy];
const grid = { x: axis, y: axis, z, validBits: new Uint8Array(Math.ceil((n * n) / 8)).fill(255) };
const cross = job.stations.map((s) => expect.expectCross(s, std));
const wide = expect.expectCross(job.wide, std, grid);
const sections = expect.expectSections([...job.stations, job.wide], std, grid);
writeFileSync(process.argv[3], JSON.stringify({ cross, wide, sections }));
"""
def _station(slope: float, uphill: str | None, *, half: float = 20.0) -> dict:
samples = []
count = int(half * 2 / 0.5) + 1
for i in range(count):
offset = -half + i * 0.5
z = round(100.0 + slope * offset, 6)
samples.append(
{"offset_m": offset, "x": 0.0, "y": offset, "z": z, "elevation_m": z, "valid": True}
)
return {
"chainage_m": 40.0,
"center_x": 0.0,
"center_y": 0.0,
"center_z": 100.0,
"tangent_xy": [1.0, 0.0],
"left_xy": [0.0, 1.0],
"uphill_side": uphill,
"samples": samples,
}
_STATIONS = [
_station(0.30, "left"),
_station(-0.45, "right"),
_station(0.05, None),
{**_station(0.2, "left"), "center_z": None},
]
_WIDE_SLOPE = 0.8
def _run_ts(tmp_path: Path) -> dict:
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
"--outDir",
str(out),
"--rootDir",
str(ROOT),
"--module",
"esnext",
"--target",
"es2022",
"--moduleResolution",
"bundler",
"--ignoreConfig",
],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
job = tmp_path / "job.json"
result = tmp_path / "out.json"
job.write_text(
json.dumps(
{
"standard": STANDARD_CROSS_SECTION,
"rock": STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
"stations": _STATIONS,
"wide": _station(_WIDE_SLOPE, "left"),
"wideSlope": _WIDE_SLOPE,
}
),
encoding="utf-8",
)
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(job), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _python(samples: list[dict], elevation: float, uphill: str | None) -> dict:
return compute_cross_design(
samples,
elevation,
ground_type="ripping_rock",
section_mode=f"{uphill or 'left'}_cut",
ditch_type="standard",
standard=STANDARD_CROSS_SECTION,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
)
def _same(ts: dict, py: dict) -> None:
assert ts["section_mode"] == py["section_mode"]
assert ts["ground_type"] == "ripping_rock" and ts["ditch_type"] == "standard"
assert abs(ts["cut_area_m2"] - py["cut_area_m2"]) < 1e-9
assert abs(ts["fill_area_m2"] - py["fill_area_m2"]) < 1e-9
assert len(ts["design_line"]) == len(py["design_line"])
for a, b in zip(ts["design_line"], py["design_line"]):
assert abs(a["offset_m"] - b["offset_m"]) < 1e-9
assert abs(a["elevation_m"] - b["elevation_m"]) < 1e-9
def test_expect_matches_python(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
for station, ts in zip(_STATIONS, got["cross"]):
if station["center_z"] is None:
assert ts is None # 지반고 없음 = 못 세움
continue
py = _python(station["samples"], station["center_z"], station["uphill_side"])
_same(ts["design"], py)
assert ts["design"]["design_elevation_m"] == station["center_z"]
# 산쪽 절토 · 모르면 좌
assert [c and c["design"]["section_mode"] for c in got["cross"]] == [
"left_cut",
"right_cut",
"left_cut",
None,
]
# 비탈 표본 — 절토 · 성토 둘 다 섬
assert got["cross"][0]["design"]["cut_area_m2"] > 0
assert got["cross"][0]["design"]["fill_area_m2"] > 0
def test_unclosed_extends_and_sections(tmp_path: Path) -> None:
got = _run_ts(tmp_path)
wide = got["wide"]
narrow = _python(_station(_WIDE_SLOPE, "left")["samples"], 100.0, "left")
assert narrow["slope_unclosed"] is True # ±20 m 안에선 안 닫힘
assert wide["design"]["slope_unclosed"] is False
assert min(s["offset_m"] for s in wide["samples"]) < -20.0
_same(wide["design"], _python(wide["samples"], 100.0, "left"))
# 코리도 측점 = 세운 측점만(지반고 없는 측점 뺌) · 좌 축 · 설계 실음
sections = got["sections"]
assert len(sections) == 4
assert all(s["frame"]["left_xy"] == [0.0, 1.0] and s["design"]["design_line"] for s in sections)
assert sections[-1]["samples"] == wide["samples"]
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""545-1 측점 표기 거울 — D04 `station_label`(py) ↔ `common_util_station.ts` `stationLabel(…, 1)`."""
import json
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from D04_PlanConfirm.D04_PlanConfirm_Export import station_label # noqa: E402
TS = PROJECT_ROOT / "common_util" / "common_util_station.ts"
TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript"
VALUES = [
0,
19.96,
20,
39.99,
100,
1234.5,
1234.56,
1234.44,
1234.05,
1234.95,
61 * 20 + 14.5,
812.345,
]
SCRIPT = r"""
const ts = require(process.argv[1]);
const src = require("fs").readFileSync(process.argv[2], "utf8");
const js = ts.transpileModule(src, { compilerOptions: { module: ts.ModuleKind.CommonJS } }).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
process.stdout.write(JSON.stringify(JSON.parse(process.argv[3]).map((v) => box.exports.stationLabel(v, 20, 1))));
"""
def test_파이썬과_TS_측점_표기_같음():
proc = subprocess.run(
["node", "-e", SCRIPT, str(TYPESCRIPT), str(TS), json.dumps(VALUES)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
assert json.loads(proc.stdout) == [station_label(v) for v in VALUES]
def test_표기_꼴():
assert station_label(1234.5) == "61+14.5"
assert station_label(0) == "0+0"
assert station_label(39.99) == "2+0"
+4 -4
View File
@@ -26,7 +26,7 @@ export const ui_locales_de = {
D04_Col_Length: ["연장 (m)", "Length (m)"],
D04_Col_Start: ["시점 (위도, 경도)", "Start (lat, lon)"],
D04_Col_End: ["종점 (위도, 경도)", "End (lat, lon)"],
D04_Col_Chainage: ["누가 (m)", "Chainage (m)"],
D04_Col_Chainage: ["측점", "Station"],
D04_Col_Confirmed: ["확정", "Confirmed"],
D04_Col_Assignees: ["지정 업체", "Assigned firms"],
D04_NotSaved: ["노선 분석 [저장] 전", "Route not saved"],
@@ -91,9 +91,9 @@ export const ui_locales_de = {
DE_Reg_KindPlan: ["노선 계획", "Route plan"],
DE_Reg_DesignSpeed: ["설계속도", "Design speed"],
DE_Reg_Name: ["프로젝트명", "Project name"],
DE_Reg_StartM: ["노선 시작 누가거리 (m)", "Route start chainage (m)"],
DE_Reg_EndM: ["노선 종료 누가거리 (m)", "Route end chainage (m)"],
DE_Reg_ChainOrder: ["종료 누가거리는 시작보다 커야 합니다.", "End chainage must exceed start."],
DE_Reg_StartM: ["노선 시작 측점 (누가거리 m 입력)", "Route start station (enter chainage m)"],
DE_Reg_EndM: ["노선 종료 측점 (누가거리 m 입력)", "Route end station (enter chainage m)"],
DE_Reg_ChainOrder: ["종료 측점은 시작보다 커야 합니다.", "End station must exceed start."],
DE_Reg_Source: ["대상 임도 프로젝트", "Source forest road project"],
DE_Reg_SourceEmpty: [
"횡단 설계(B06)까지 끝난 임도 프로젝트가 없습니다.",