13 Commits
Author SHA1 Message Date
eomsangdon 75bb82d03f Merge remote-tracking branch 'origin/main_desktop_1' into sub_laptop_8 2026-10-26 14:23:18 +09:00
eomsangdon 2ce5bac9e3 chore(싱크): 시놀로지 사본 D E 폴더 · 뿌리 파일 = 랩탑 main 과 같은 내용 — 전역 깃 싱크 앞 커밋 2026-10-26 14:22:24 +09:00
eomsangdonandClaude Opus 5.5 a4f2ac928d chore(요구): 요구 365 — 전역 깃 싱크
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 14:15:10 +09:00
eomsangdonandClaude Opus 5.5 80c286740d chore(계획): PLAN 543~545 이관 — 예정노선 꺾임점 고침 · 예상 횡단 · 3D · 측점 표기 끝
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:53:14 +09:00
eomsangdonandClaude Opus 5.5 4753d88983 fix(D03): 끈 지점 분석이 저장 지점 분석본을 덮지 않음 — 파생 저장본 연도 + 지점 열쇠 파일 (PLAN 543-4)
- 분석본 = route_<연도>_<지점 열쇠>.json · GET 은 DB 지점 분석본 · 끈 지점 GET 은 그 분석
- [저장] 뒤 정본 = 끈 지점 분석본(DB 지점 따라감)
- 쌓임 거르기 = 연도마다 최근 3 개 + 저장 지점 것
- 옛 꼴 route_<연도>.json 은 읽기만(공유 storage · 임시 배포 옛 판 서버가 읽음 — 옮기지 않음)
- 시험 test_530_2 끈 지점 분석 · 저장 · 쌓임

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:51:48 +09:00
eomsangdonandClaude Sonnet 5.5 bef30eda25 chore(A05): D03 가이드 그림 열 장 다시 찍음 — 예정노선 세 연도 노선 되살림(365.3 · 400.7 · 375.1 m)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-26 13:46:33 +09:00
eomsangdonandClaude Opus 5.5 a5e895c66a chore(merge): main_laptop_1 받음 — D03 장면 목록 양쪽 살림(sub2 장면 00 · 05 · 08 + D03_09 꺾임점 고침)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:37:17 +09:00
eomsangdonandClaude Opus 5.5 612f622c76 feat(A05): D03 도움말 꺾임점 고치기 새 문서 · 평면 지도 읽기 맞춤 · 그림 D03_09 장면 (PLAN 543-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:36:33 +09:00
eomsangdon 58e94e3875 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-26 13:35:22 +09:00
eomsangdonandClaude Opus 5.5 47c3b65cc4 feat(D03): 꺾임점 고침 [저장] 정본 — 서버가 마디로 선 · 지표 다시 셈 · 이은 누가 고친 길이 · GET 은 분석 지문 같은 고침만 · 산림청만 · 시험 (PLAN 543-3)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:32:13 +09:00
eomsangdonandClaude Opus 5.5 7683b74524 feat(D03): 평면 꺾임점 고침 겹 — 마디 끌기 · 두 번 누름 넣기 · 오른쪽 누름 지우기 · 곡선 손잡이 R · 캐시 d03-plan edits · 되돌리기(Ctrl+Z) · 원래 노선 · 카드 연장 즉시 (PLAN 543-2)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:32:13 +09:00
eomsangdonandClaude Opus 5.5 084df5137c feat(D03): 예정노선 꺾임점 고침 순수 함수 — 마디 끌기 · 넣기 · 지우기 · 곡선 손잡이 R · 처음 · 끝 잠금 · 이은 누가 고친 길이 짝(withEdits ↔ with_edits) · 서버 선 짝 · 거울 시험 (PLAN 543-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:18:05 +09:00
eomsangdonandClaude Opus 5.5 0b8abe4f09 chore(요구): 요구 364 — sub3 계획서 원본 연결 · 꺾임점 고침 · 예상 횡단 · 3D 진행
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 13:10:25 +09:00
24 changed files with 1444 additions and 57 deletions
@@ -1,6 +1,6 @@
# 평면 지도 읽기
위성 사진 위에 지점 · 노선 · 측점을 겹쳐 보여 주는 가운데 지도. 지점을 끌어 옮길 수도 있음.
위성 사진 위에 지점 · 노선 · 측점을 겹쳐 보여 주는 가운데 지도. 지점을 끌어 옮기고, 고른 노선의 꺾임점도 고칠 수 있음.
## 여는 길
@@ -18,13 +18,15 @@
| 2 | 선 위 눈금 글(0+0 · 5+0 · 10+0 …)로 측점(20 m 간격)을 읽음. |
| 3 | 지점이 마음에 안 들면 지점(원)을 마우스 왼쪽 단추로 누른 채 끌어 옮김. |
| 4 | 옮긴 뒤 [분석]을 다시 눌러 새 지점으로 노선을 찾음. |
| 5 | 지도 바탕을 끌어 옮기고 휠로 확대 · 축소함. |
| 5 | 노선 모양만 바꾸려면 흰 네모(꺾임점)를 끌어 옮김. [꺾임점 고치기](D03/07_꺾임점_고치기) 참고. |
| 6 | 지도 바탕을 끌어 옮기고 휠로 확대 · 축소함. |
## 보이는 것
- **지점(원)의 색** — 초록 = 첫 지점(시점) · 빨강 = 마지막 지점(종점) · 파랑 = 연도가 바뀌는 중간 지점.
- **선** — 굵은 선 = 고른 후보. 흰 점선(옅음) = 고르지 않은 다른 후보. 분석 전에는 지점끼리 잇는 점선만 보임.
- **등고선** = 도엽(수치지형도) 등고선. 노란 선과 노란 숫자(높이). 확대하면 더 촘촘히 보임.
- **흰 네모 · 주황 점 · R 숫자** = 고른 노선의 꺾임점 · 곡선 손잡이 · 곡선 반지름(산림청 계정만 보임).
- 누른 측점은 보라색 고리로 표시됨.
- **누가** = 측점 번호. 앞 연도 끝 = 다음 연도 시작으로 이어서 셈. 시작 값은 등록 때 정한 노선 시작 누가거리.
- 끌기를 마치면 그 지점 앞 · 뒤 연도의 분석 결과가 비워지고 횡단 판에 「지점을 옮겼습니다 — [분석] 뒤 측점을 누르세요.」가 나옴.
@@ -0,0 +1,40 @@
# 꺾임점 고치기
추천된 노선의 꺾임점(IP)을 평면 지도에서 직접 옮기고, 넣고, 지우는 방법. 곡선 반지름(R)도 손잡이로 바꿈.
## 여는 길
- 노선 분석 화면의 가운데 평면 지도. 고른 후보 노선 위에 흰 네모(꺾임점) · 주황 점(곡선 손잡이) · 「R 숫자」 글이 보임.
![꺾임점 고치기](D03_09_ip_edit.webp)
그림 번호 — 1 평면 지도(꺾임점 하나를 옮긴 모습) · 2 「꺾임점 고침 — [저장] 전」 줄과 [원래 노선] · 3 고친 후보 카드(연장이 바로 바뀜) · 4 [되돌리기]
## 단계
| 단계 | 할 일 |
| --- | --- |
| 1 | 「연도별 노선」 카드에서 고칠 후보를 먼저 고름. 꺾임점은 고른 후보에만 보임. |
| 2 | 흰 네모(꺾임점)를 마우스 왼쪽 단추로 누른 채 끌어 옮김. 노선 · 연장 · 측점이 바로 다시 그려짐. |
| 3 | 꺾임점을 더 넣으려면 꺾임점 사이 선 위를 두 번 누름. |
| 4 | 꺾임점을 빼려면 흰 네모를 마우스 오른쪽 단추로 누름. |
| 5 | 곡선을 넓히거나 좁히려면 주황 점(곡선 시작 · 끝)을 끌어 옮김. 그쪽 직선 각도와 R 이 함께 바뀜. |
| 6 | 잘못 고쳤으면 [되돌리기](또는 Ctrl+Z)로 한 걸음씩 되돌림. 처음 노선으로 돌리려면 [원래 노선]. |
| 7 | 측점을 눌러 둔 상태면 아래 횡단도 고친 노선으로 바로 바뀜. |
| 8 | 다 고쳤으면 [저장]을 누름. 서버가 꺾임점으로 노선을 다시 그어 기울기 · 위반 수를 셈. |
## 보이는 것
- **흰 네모** = 꺾임점. 테두리가 빨가면 그 자리 곡선이 기준(최소 곡선 반지름 · 배향곡선)에 못 미침.
- **주황 점** = 곡선 시작 · 끝(손잡이). **R 숫자** = 그 곡선의 반지름(m). 기준 미달이면 붉은 글.
- 고친 연도에는 「꺾임점 고침 — [저장] 전」 줄이 보이고, 저장 뒤에는 「꺾임점 고침(저장됨)」으로 바뀜.
- 고친 후보 카드는 「연장 ○○ m · 기울기 · 위반 = 저장 때 셈」으로 연장만 바로 보임. [저장] 뒤에 최대 기울기 · 위반 수가 채워짐.
- 연장이 바뀌면 그 뒤 연도의 시작 · 끝 측점도 함께 밀림.
> 주의: 처음 · 끝 꺾임점은 연도가 바뀌는 지점이라 여기서 못 옮김. 지점(원)을 끌어 옮기고 다시 [분석]함. 지점을 옮기면 그 앞 · 뒤 연도의 꺾임점 고침은 버려짐.
> 주의: 곡선 반지름은 법정 최소 곡선 반지름 밑으로 내려가지 않음. 옮기다가 지키던 곡선이 기준 밑으로 떨어지면 그 걸음은 막히고 까닭이 알림으로 뜸.
> 참고: 고친 값은 [저장] 전까지 이 브라우저 탭에만 임시로 남음(새로 고침해도 유지). 다른 후보를 골랐다가 다시 고르면 고친 노선이 그대로 돌아옴. [분석]을 다시 해 분석 결과가 바뀐 연도는 고침이 버려짐.
> 참고: 산림청이 아닌 계정에는 꺾임점 · 손잡이가 보이지 않음. 보기만 가능.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1000 KiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 MiB

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 994 KiB

After

Width:  |  Height:  |  Size: 999 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 961 KiB

After

Width:  |  Height:  |  Size: 970 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

+2 -1
View File
@@ -7,5 +7,6 @@
{"name":"D03_05_station_cross","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"eval":"window.__d03Plan.pick(100)"},{"wait":".d03-plan__cross-body svg"},{"wait_ms":600}],"settle_ms":2000,"clip":".d03-plan__cross","callouts":[{"n":1,"sel":".d03-plan__cross-head"},{"n":2,"sel":".d03-plan__cross-body"}]},
{"name":"D03_06_cross_fold","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,900],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"eval":"window.__d03Plan.pick(400)"},{"wait":".d03-plan__cross-body svg"},{"wait_ms":600},{"click":".d03-plan__cross .ui-workflow-overlay__handle"},{"wait_ms":800}],"settle_ms":2000,"clip":null,"callouts":[{"n":1,"sel":".d03-plan__cross .ui-workflow-overlay__handle","pos":"tr"}]},
{"name":"D03_07_buttons","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000}],"settle_ms":2000,"clip":".ui-sidebar-actions","callouts":[{"n":1,"sel":".ui-sidebar-actions > button:nth-child(1)"},{"n":2,"sel":".ui-sidebar-actions > button:nth-child(2)"},{"n":3,"sel":".ui-sidebar-actions > button:nth-child(3)"},{"n":4,"sel":".ui-sidebar-actions > button:nth-child(4)"},{"n":5,"sel":".ui-sidebar-actions > button:nth-child(5)"},{"n":6,"sel":".ui-sidebar-actions > button:nth-child(6)"}]},
{"name":"D03_08_3d_modal","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"click":".ui-sidebar-actions > button:nth-child(6)"},{"wait":".b05-route__3d-modal"},{"wait_ms":9000},{"wait":".d03-plan__3d-corridor input:checked"}],"settle_ms":2000,"clip":null,"callouts":[{"n":1,"sel":".d03-plan__3d-year"},{"n":2,"sel":".d03-plan__3d-corridor"},{"n":3,"sel":".b05-route__3d-body .b05-route__viewport","pos":"br"}]}
{"name":"D03_08_3d_modal","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"click":".ui-sidebar-actions > button:nth-child(6)"},{"wait":".b05-route__3d-modal"},{"wait_ms":9000},{"wait":".d03-plan__3d-corridor input:checked"}],"settle_ms":2000,"clip":null,"callouts":[{"n":1,"sel":".d03-plan__3d-year"},{"n":2,"sel":".d03-plan__3d-corridor"},{"n":3,"sel":".b05-route__3d-body .b05-route__viewport","pos":"br"}]},
{"name":"D03_09_ip_edit","route":"#/d03-plan-analysis","project":"plan","viewport":[1600,1250],"before":[{"wait":".d03-plan__map canvas"},{"wait":".d03-plan__mode"},{"wait_ms":4000},{"eval":"window.__d03Plan.nudge(3, 18, 14)"},{"eval":"window.__d03Plan.pick(200)"},{"wait":".d03-plan__cross-body svg"},{"wait_ms":600}],"settle_ms":2000,"clip":null,"callouts":[{"n":1,"sel":".d03-plan__map","pos":"tr"},{"n":2,"sel":".d03-plan__year[data-year=\"2027\"] .d03-plan__year-head:nth-child(2)","pos":"tr"},{"n":3,"sel":".d03-plan__year[data-year=\"2027\"] .d03-plan__mode.is-picked","pos":"tr"},{"n":4,"sel":".ui-sidebar-actions > button:nth-child(7)","pos":"tr"}]}
]
+84 -13
View File
@@ -5,12 +5,15 @@
- 추천 기준 = 프로젝트 임도 종류 · 설계속도 → B 법정 함수 · 지형 구분은 B 폴백(`TERRAIN_TYPE`)
- 계류 이격 = 도엽 하천선(B 로더 그대로) · 복도 반폭 = `PLAN_CORRIDOR_M`(D 만)
- 연도 경계는 꺾인 채(경계점 = 측점) · 누가거리 = 등록 시작 누가부터 고른 갈래 길이로 이어 셈
- 파생 저장본 = `D03_PlanAnalysis/_derived/route_<연도>.json`(입력 지문 같으면 다시 안 셈)
- 파생 저장본 = `D03_PlanAnalysis/_derived/route_<연도>_<지점 열쇠>.json`(입력 지문 같으면 다시
안 셈) · 끈 지점 분석이 저장 지점 분석본을 덮지 않음 · 연도마다 최근 `KEEP_PER_YEAR` 개 + 저장
지점 것만 남김 · 옛 꼴 `route_<연도>.json` 은 읽기만(공유 storage — 옛 판 서버가 아직 읽음)
계산 자리 = ③ 서버 단독(B 추천과 같음).
⚠ 파이썬·TS 짝 — `pick_mode` · `chain` · `joined_line` ↔ `logic/D03_PlanAnalysis_Plan.ts`
`pickMode` · `chainRows` · `joinedLine`(화면이 고르는 즉시) · 거울 시험 `test_530_3_plan_logic.py`.
⚠ 파이썬·TS 짝 — `pick_mode` · `chain` · `joined_line` · `with_edits` ↔
`logic/D03_PlanAnalysis_Plan.ts` `pickMode` · `chainRows` · `joinedLine` · `withEdits`(화면이 고르는
즉시) · 거울 시험 `test_530_3_plan_logic.py` · `test_543_plan_edit.py`.
"""
from __future__ import annotations
@@ -56,6 +59,8 @@ ALGO = 1
# 계류 거르기 칸 · 지형 상자 여유(m) — `near_streams`
STREAM_TILE_M = 200.0
STREAM_PAD_M = 100.0
# 연도마다 남길 분석본 수 — 저장 지점 분석본은 따로 늘 남김
KEEP_PER_YEAR = 3
Progress = Callable[[int, str], None]
Loader = Callable[..., tuple[Any, list]]
@@ -85,17 +90,53 @@ def _same_point(a: Any, b: Any) -> bool:
return abs(float(a[0]) - float(b[0])) < 1e-3 and abs(float(a[1]) - float(b[1])) < 1e-3
def segment_path(project_root: Path, seg: dict[str, Any]) -> Path:
"""연도 · 지점 열쇠 파일 — 지점이 다르면 다른 파일(덮지 않음)."""
key = hashlib.blake2b(
repr((_point(seg["start"]), _point(seg["end"]))).encode(), digest_size=4
).hexdigest()
return derived_dir(project_root) / f"route_{int(seg['year'])}_{key}.json"
def _find(project_root: Path, seg: dict[str, Any]) -> tuple[Path, dict[str, Any]] | None:
"""지점이 지금과 같은 저장본(1 mm) — 열쇠 파일 · 같은 연도 다른 열쇠(새것부터) · 옛 꼴 차례."""
year = int(seg["year"])
folder = derived_dir(project_root)
exact = segment_path(project_root, seg)
others = sorted(
(p for p in folder.glob(f"route_{year}_*.json") if p != exact),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
for path in (exact, *others, folder / f"route_{year}.json"):
try:
stored = json.loads(path.read_text("utf-8"))
except (OSError, ValueError):
continue
if _same_point(stored["start"], seg["start"]) and _same_point(stored["end"], seg["end"]):
return path, stored
return None
def read_segment(project_root: Path, seg: dict[str, Any]) -> dict[str, Any] | None:
"""저장본 — 지점이 지금과 같을 때만(입력 지문은 분석 때 다시 봄)."""
try:
stored = json.loads(
(derived_dir(project_root) / f"route_{int(seg['year'])}.json").read_text("utf-8")
)
except (OSError, ValueError):
return None
if _same_point(stored["start"], seg["start"]) and _same_point(stored["end"], seg["end"]):
return stored
return None
found = _find(project_root, seg)
return found[1] if found else None
def _prune(project_root: Path, seg: dict[str, Any]) -> None:
"""연도 분석본 — 최근 `KEEP_PER_YEAR` 개 + 저장 지점(`saved` · 없으면 seg) 것만 남김 · 옛 꼴은
안 지움."""
start, end = seg.get("saved") or (seg["start"], seg["end"])
canon = _find(project_root, {"year": seg["year"], "start": start, "end": end})
files = sorted(
derived_dir(project_root).glob(f"route_{int(seg['year'])}_*.json"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
for path in files[KEEP_PER_YEAR:]:
if canon is None or path != canon[0]:
path.unlink(missing_ok=True)
def _route_view(result: dict[str, Any]) -> dict[str, Any]:
@@ -174,7 +215,8 @@ def analyze_segment(
},
"routes": routes,
}
atomic_write_json(derived_dir(project_root) / f"route_{int(seg['year'])}.json", result)
atomic_write_json(segment_path(project_root, seg), result)
_prune(project_root, seg)
return {**result, "stored": False}
@@ -318,6 +360,35 @@ def chain(
return rows
def line_length(line: list) -> float:
"""선 길이(m) — 짝: `lineLength`(같은 차례로 더함)."""
total = 0.0
for a, b in zip(line, line[1:]):
total += math.hypot(float(b[0]) - float(a[0]), float(b[1]) - float(a[1]))
return total
def with_edits(
results: list[dict[str, Any]], edits: dict[int, dict[str, Any]]
) -> list[dict[str, Any]]:
"""고친 노선(`{mode, planned, metrics?}`)을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선
길이. 그 갈래가 없거나 빈 갈래면 그대로."""
out = []
for result in results:
edit = edits.get(int(result["year"]))
route = (result.get("routes") or {}).get(edit["mode"]) if edit else None
if not edit or route is None or "error" in route:
out.append(result)
continue
metrics = {**(edit.get("metrics") or {}), "length_m": line_length(edit["planned"])}
routes = {
**result["routes"],
edit["mode"]: {**route, "planned": edit["planned"], "metrics": metrics, "edited": True},
}
out.append({**result, "routes": routes})
return out
def joined_line(results: list[dict[str, Any]], rows: list[dict[str, Any]]) -> list[list[float]]:
"""고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채)."""
line: list[list[float]] = []
+17 -4
View File
@@ -1,14 +1,19 @@
/* =============================================================================
* D03_PlanAnalysis_Api.ts
* D03 분석 화면 서버 요청(PLAN 530-3) — 지점 정본(D02) · 지형 받기 · 분석 · 진행 · 저장본 · [저장] ·
* 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄.
* 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄 · 꺾임점 고침은
* `edits`(마디 · 켬끔 · R — 선은 안 보냄 · 서버가 다시 그음).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
import type { ChainRow, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan";
import type { PlanEdit } from "./logic/D03_PlanAnalysis_Edit";
import type { ChainRow, RouteMetrics, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan";
/** 저장된 꺾임점 고침 — 입력 + 그때 서버가 잰 값. */
export type SavedEdit = PlanEdit & { metrics?: RouteMetrics | null };
export interface PlanPointsView {
year_start: number;
@@ -29,7 +34,14 @@ export interface PlanJob {
}
export interface PlanAnalysisView {
years: Array<{ year: number; start: Vertex; end: Vertex; analysis: YearAnalysis | null }>;
years: Array<{
year: number;
start: Vertex;
end: Vertex;
analysis: YearAnalysis | null;
/** 저장된 꺾임점 고침(분석 지문이 같을 때만). */
edit?: SavedEdit | null;
}>;
chain: ChainRow[] | null;
/** 등록 노선 시작 누가(m). */
start_chainage_m: number;
@@ -83,10 +95,11 @@ export const savePlanAnalysis = (
projectId: string,
choices: Record<number, string>,
points: PlanPoints | null,
edits: Record<number, PlanEdit> = {},
): Promise<{ chain: ChainRow[] }> =>
request(`${base(projectId)}/analysis`, {
method: "PUT",
body: JSON.stringify(points ? { choices, points } : { choices }),
body: JSON.stringify(points ? { choices, points, edits } : { choices, edits }),
});
export interface YearSurface {
+169
View File
@@ -0,0 +1,169 @@
"""D 예정노선 꺾임점 고침 [저장] 정본(요구 363-1 · PLAN 543-3).
- 받는 것 = 연도별 마디 · 곡선 켬끔 · R(화면이 그은 선은 안 받음)
- 선 = B05 편집 갈래 `node_polyline`(Node `route.polyline` = 화면 `buildEditedPolyline` 같은 TS) ·
못 돌림 = 파이썬 짝 `build_planned_polyline(simplify=False)`
- 지표 = 엔진과 같은 `route_metrics` → `hard_limits` → `with_station_misses`(연도 지형 · 계류)
- 처음 · 끝 마디 = 연도 경계 지점(1 mm) · 고친 갈래 · 분석 지문이 지금과 같아야 함
계산 자리 = ① 파이썬 · TS 짝 — 화면 `logic/D03_PlanAnalysis_Edit.ts`(조작 중 즉시) ↔
여기([저장] 정본) · 거울 시험 `resources/tester/test_543_plan_edit.py`.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from B05_Profile.B05_Profile_Engine_RouteInitial import (
ROAD_WIDTH_M,
STREAM_OFFSET_M,
load_terrain_and_streams,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import route_metrics
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import hard_limits
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import with_station_misses
from B05_Profile.B05_Profile_Route_Polyline_Write import node_polyline
from common_util.common_util_route_polyline import PlannedPolyline, build_planned_polyline
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
# 끝 마디 = 지점 판정(m) — 짝: `D02_PlanBase_Router.SAME_M`
SAME_M = 1e-3
INPUT_KEYS = ("base_mode", "base_fingerprint", "nodes", "curve_flags", "radii")
class EditError(ValueError):
"""고침을 받을 수 없음 — 까닭 그대로 화면에."""
def edited_line(edit: dict[str, Any], min_radius_m: float) -> PlannedPolyline:
"""마디 · 켬끔 · R → 선(화면과 같은 편집 갈래)."""
points = [(float(x), float(y)) for x, y in edit["nodes"]]
flags = [bool(on) for on in edit["curve_flags"]]
radii = [None if r is None else float(r) for r in edit["radii"]]
built = node_polyline(points, min_radius_m, flags, radii, None, None)
if built is not None:
return built[0]
return build_planned_polyline(
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
)
def check_edit(edit: dict[str, Any], result: dict[str, Any], mode: str) -> None:
"""고친 갈래 · 분석 지문 · 칸 수 · 끝 마디 = 지점."""
year = int(result["year"])
if edit["base_mode"] != mode:
raise EditError(f"{year}년 고침이 고른 갈래와 다릅니다.")
if edit["base_fingerprint"] != result.get("fingerprint"):
raise EditError(f"{year}년 분석이 바뀌어 고침이 낡았습니다 — 다시 고치세요.")
nodes = edit["nodes"]
if len(nodes) < 2 or not (len(nodes) == len(edit["curve_flags"]) == len(edit["radii"])):
raise EditError(f"{year}년 고침 마디 꼴이 다릅니다.")
if any(r is not None and not float(r) > 0 for r in edit["radii"]):
raise EditError(f"{year}년 R 은 0 보다 커야 합니다.")
for got, want in ((nodes[0], result["start"]), (nodes[-1], result["end"])):
if max(abs(float(a) - float(b)) for a, b in zip(got, want)) > SAME_M:
raise EditError(f"{year}년 처음 · 끝 마디가 연도 경계 지점과 다릅니다.")
def edit_metrics(
line: PlannedPolyline, result: dict[str, Any], terrain: Any, streams: list
) -> dict[str, Any]:
"""엔진과 같은 잼 — 원지반 기울기는 참고(못 지킨 수에서 뺌) · 측점 밖 곡선 더함."""
criteria = result["criteria"]
metrics = route_metrics(
line.vertices,
[tuple(result["start"]), tuple(result["end"])],
terrain,
streams,
line.curves,
max_grade=float(criteria["max_grade_pct"]) / 100.0,
min_radius_m=float(criteria["min_radius_m"]),
stream_offset_m=STREAM_OFFSET_M,
road_width_m=ROAD_WIDTH_M,
bound_pct=None,
)
return with_station_misses(hard_limits(metrics), line)
def saved_edit(route_json: Any) -> dict[str, Any] | None:
"""저장된 고침(route_json `edit` + 그때 선 · 지표) — 없으면 None."""
try:
value = json.loads(route_json) if isinstance(route_json, str) else route_json
except ValueError:
return None
edit = value.get("edit") if isinstance(value, dict) else None
if not isinstance(edit, dict) or not all(key in edit for key in INPUT_KEYS):
return None
return {
**{key: edit[key] for key in INPUT_KEYS},
"planned": value.get("planned"),
"nodes_info": value.get("nodes"),
"curves": value.get("curves"),
"metrics": value.get("metrics"),
}
def same_inputs(a: dict[str, Any] | None, b: dict[str, Any] | None) -> bool:
"""같은 고침 입력인가(1 mm) — 같으면 저장된 선 · 지표를 다시 씀."""
if not a or not b:
return False
def near(x: Any, y: Any) -> bool:
return (
x is None
and y is None
or (x is not None and y is not None and abs(float(x) - float(y)) < 1e-3)
)
return (
a["base_mode"] == b["base_mode"]
and a["base_fingerprint"] == b["base_fingerprint"]
and len(a["nodes"]) == len(b["nodes"])
and all(near(p[0], q[0]) and near(p[1], q[1]) for p, q in zip(a["nodes"], b["nodes"]))
and [bool(v) for v in a["curve_flags"]] == [bool(v) for v in b["curve_flags"]]
and all(near(x, y) for x, y in zip(a["radii"], b["radii"]))
)
def build_edits(
project_root: Path,
results: list[dict[str, Any]],
rows: list[dict[str, Any]],
edits: dict[int, dict[str, Any]],
saved: dict[int, dict[str, Any] | None],
loader: analyze.Loader = load_terrain_and_streams,
) -> dict[int, dict[str, Any]]:
"""연도 → 고친 노선 `{mode, planned, nodes, curves, metrics, edit}` — 저장된 고침과 입력이
같으면 그 선 · 지표 그대로(지형 안 읽음)."""
out: dict[int, dict[str, Any]] = {}
for result, row in zip(results, rows):
year = int(result["year"])
edit = edits.get(year)
if edit is None:
continue
check_edit(edit, result, row["mode"])
inputs = {key: edit[key] for key in INPUT_KEYS}
old = saved.get(year)
if old is not None and old.get("metrics") and same_inputs(inputs, old):
out[year] = {
"mode": row["mode"],
"planned": old["planned"],
"nodes": old["nodes_info"],
"curves": old["curves"],
"metrics": old["metrics"],
"edit": inputs,
}
continue
line = edited_line(inputs, float(result["criteria"]["min_radius_m"]))
terrain, streams = analyze._load(project_root, year, loader)
out[year] = {
"mode": row["mode"],
"planned": [[round(x, 4), round(y, 4)] for x, y in line.vertices],
"nodes": [node.as_dict() for node in line.nodes],
"curves": [curve.as_dict() for curve in line.curves],
"metrics": edit_metrics(line, result, terrain, streams),
"edit": inputs,
}
return out
+86 -10
View File
@@ -4,11 +4,14 @@
- `POST …/plan/analyze` — 연도 구간마다 노선 추천 갈래 넷(뒤에서 · 파생 저장본)
- `GET …/plan/progress` — 두 일의 진행률
- `GET …/plan/analysis` — 저장본 + 고른 갈래 · 이은 누가 · 이은 중심선
- `PUT …/plan/analysis` — [저장] 연도별 고른 갈래 → `plan_route_years.route_json` · `fingerprint`
- `PUT …/plan/analysis` — [저장] 연도별 고른 갈래(+ 꺾임점 고침) → `plan_route_years.route_json` ·
`fingerprint`
지점 정본은 `plan_route_years` 시점 · 종점 칸(529-3 몫). D03 화면에서 끈 지점(캐시 · PLAN 530-3)은
`points`(작업 좌표계 m)로 받아 그 지점으로 받기 · 분석 · 보기 · [저장] 때만 D02 규칙으로 정본에 씀.
계산 자리 = ③ 서버 단독 — [저장]은 노선을 받지 않고 갈래 이름만 받아 서버 저장본으로 다시 셈.
꺾임점 고침(543)은 마디 · 켬끔 · R 만 받아 서버가 선 · 지표를 다시 셈(`D03_PlanAnalysis_Edit`) ·
GET 은 분석 지문이 같은 저장 고침만 돌려줌.
"""
from __future__ import annotations
@@ -31,6 +34,7 @@ from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from D02_PlanBase import D02_PlanBase_Router as base
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
from D03_PlanAnalysis import D03_PlanAnalysis_Edit as plan_edit
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
logger = logging.getLogger(__name__)
@@ -54,8 +58,19 @@ class PointsBody(BaseModel):
points: list[PlanPoint] | None = None
class PlanEditBody(BaseModel):
"""연도 하나의 꺾임점 고침 — 고친 갈래 · 그 분석 지문 · 마디(작업 좌표계 m) · 곡선 켬끔 · R."""
base_mode: str
base_fingerprint: str
nodes: list[tuple[float, float]]
curve_flags: list[bool]
radii: list[float | None]
class ChoiceRequest(PointsBody):
choices: dict[int, str] # 연도 → 갈래
edits: dict[int, PlanEditBody] | None = None # 연도 → 꺾임점 고침(없는 연도 = 엔진 노선)
def _override(segments: list[dict[str, Any]], points: list[PlanPoint]) -> list[dict[str, Any]]:
@@ -64,7 +79,12 @@ def _override(segments: list[dict[str, Any]], points: list[PlanPoint]) -> list[d
if set(by_year) != {seg["year"] for seg in segments}:
raise HTTPException(status_code=400, detail="끈 지점의 연도가 사업연도와 다릅니다.")
out = [
{**seg, "start": tuple(by_year[seg["year"]].start), "end": tuple(by_year[seg["year"]].end)}
{
**seg,
"start": tuple(by_year[seg["year"]].start),
"end": tuple(by_year[seg["year"]].end),
"saved": (seg["start"], seg["end"]), # 저장 지점 — 그 분석본은 쌓임 거르기에서 남김
}
for seg in segments
]
for prev, seg in zip(out, out[1:]):
@@ -220,14 +240,46 @@ def _saved_mode(route_json: Any) -> str | None:
def _assemble(
root: Path, project: dict[str, Any], segments: list[dict[str, Any]], choices: dict[int, Any]
root: Path,
project: dict[str, Any],
segments: list[dict[str, Any]],
choices: dict[int, Any],
edits: dict[int, dict[str, Any]] | None = None,
) -> tuple[list[dict[str, Any] | None], list[dict[str, Any]] | None, list[list[float]]]:
"""(연도 저장본 · 이은 누가 · 이은 중심선) — 저장본이 하나라도 없으면 None · 빈 선."""
"""(연도 저장본 · 이은 누가 · 이은 중심선) — 저장본이 하나라도 없으면 None · 빈 선.
`edits` = 연도 → 고친 노선(`{mode, planned, metrics}`) — 누가 · 선만 고친 길이로(저장본은 엔진
그대로).
"""
results = [analyze.read_segment(root, seg) for seg in segments]
if any(result is None for result in results):
return results, None, []
rows = analyze.chain(results, choices, float(project["route_start_m"] or 0.0))
return results, rows, analyze.joined_line(results, rows)
view = analyze.with_edits(results, edits or {}) # type: ignore[arg-type]
rows = analyze.chain(view, choices, float(project["route_start_m"] or 0.0))
return results, rows, analyze.joined_line(view, rows)
def _saved_edits(
root: Path, segments: list[dict[str, Any]], choices: dict[int, Any]
) -> dict[int, dict[str, Any]]:
"""저장된 꺾임점 고침 — 저장 갈래 그대로 · 분석 지문이 지금과 같은 것만."""
out = {}
for seg in segments:
saved = plan_edit.saved_edit(seg["route_json"])
result = analyze.read_segment(root, seg)
if (
saved
and result
and saved["base_mode"] == choices.get(seg["year"])
and saved["base_fingerprint"] == result.get("fingerprint")
and saved.get("planned")
):
out[seg["year"]] = saved
return out
def _edit_view(saved: dict[str, Any]) -> dict[str, Any]:
return {key: saved[key] for key in (*plan_edit.INPUT_KEYS, "metrics")}
def _query_points(points: str | None) -> list[PlanPoint] | None:
@@ -247,11 +299,22 @@ async def plan_analysis(project_id: UUID, points: str | None = None) -> dict[str
"""
root, project, segments = await _plan_inputs(project_id, _query_points(points))
choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments}
results, rows, line = await asyncio.to_thread(_assemble, root, project, segments, choices)
saved = await asyncio.to_thread(_saved_edits, root, segments, choices)
edits = {year: {"mode": e["base_mode"], **e} for year, e in saved.items()}
results, rows, line = await asyncio.to_thread(
_assemble, root, project, segments, choices, edits
)
return {
"status": "success",
"years": [
{"year": seg["year"], "start": seg["start"], "end": seg["end"], "analysis": result}
{
"year": seg["year"],
"start": seg["start"],
"end": seg["end"],
"analysis": result,
# 저장된 꺾임점 고침(입력 + 그때 지표) — 분석 지문이 같을 때만
"edit": _edit_view(saved[seg["year"]]) if seg["year"] in saved else None,
}
for seg, result in zip(segments, results)
],
"chain": rows,
@@ -297,7 +360,7 @@ async def save_plan_analysis(
session: dict[str, Any] = Depends(require_company),
) -> dict[str, Any]:
"""[저장] — 끈 지점(있으면 정본으로) · 연도별 고른 갈래를 서버 저장본으로 다시 이어 씀."""
if request.points and not is_forest_service(session):
if (request.points or request.edits) and not is_forest_service(session):
raise HTTPException(status_code=403, detail="예정노선 지점은 산림청만 고칠 수 있습니다.")
root, project, segments = await _plan_inputs(project_id, request.points)
bad = [m for m in request.choices.values() if m not in analyze.PLAN_MODES]
@@ -313,6 +376,17 @@ async def save_plan_analysis(
lost = [r["year"] for r in rows if choices.get(r["year"]) not in (None, r["mode"])]
if lost:
raise HTTPException(status_code=409, detail=f"고른 갈래에 노선이 없습니다: {lost}년")
edited: dict[int, dict[str, Any]] = {}
if request.edits:
saved = {seg["year"]: plan_edit.saved_edit(seg["route_json"]) for seg in segments}
asked = {year: body.model_dump() for year, body in request.edits.items()}
try:
edited = await asyncio.to_thread(
plan_edit.build_edits, root, results, rows, asked, saved
)
except plan_edit.EditError as error:
raise HTTPException(status_code=409, detail=str(error)) from error
_, rows, _ = await asyncio.to_thread(_assemble, root, project, segments, choices, edited)
if request.points:
await _store_points(project_id, segments)
async with get_db_pool().acquire() as connection:
@@ -320,8 +394,10 @@ async def save_plan_analysis(
try:
async with connection.cursor() as cursor:
for result, row in zip(results, rows):
route = result["routes"][row["mode"]]
route = edited.get(row["year"]) or result["routes"][row["mode"]]
body = {**row, **{k: route[k] for k in ("planned", "nodes", "curves")}}
if row["year"] in edited: # 꺾임점 고침 — 입력 · 지표 함께(GET 이 돌려줌)
body.update(metrics=route["metrics"], edit=route["edit"])
await cursor.execute(
"UPDATE plan_route_years SET route_json = %s, fingerprint = %s "
"WHERE project_id = %s AND year = %s",
@@ -0,0 +1,186 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Edit.ts
* D03 평면 꺾임점 고침 겹(요구 363-1 · PLAN 543-2) — 고른 갈래 노선의 마디(네모) · 곡선 손잡이(동그라미) ·
* R 글을 그리고, 끌기 · 두 번 누름(넣기) · 오른쪽 누름(지우기)을 순수 셈(`logic/D03_PlanAnalysis_Edit`)
* 으로 넘김. 처음 · 끝 마디(연도 경계)는 지점 끌기 몫이라 안 그림 · 안 집음.
*
* 값은 페이지가 쥠(캐시 `d03-plan` · 되돌리기) — 여기는 고친 값을 `change` 로 올리기만.
* 집기는 B05 편집기 순수 부품 그대로(`nodeAtScreen` · `handleAtScreen` · `segmentAtScreen`).
* ========================================================================== */
import {
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
import type { EditedPolyline } from "../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
import type { PlanMapOverlay, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import {
insertNode,
moveHandle,
moveNode,
removeNode,
type EditStep,
type PlanEdit,
} from "./logic/D03_PlanAnalysis_Edit";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
/** 마디 · 손잡이 집는 반경 · 선 위 두 번 누름 거리(px). */
const NODE_HIT_PX = 10;
const HANDLE_HIT_PX = 8;
const SEGMENT_HIT_PX = 10;
/** 연도 하나 — 지금 고침(없으면 엔진 노선을 편 것) · 그 선 · 법정 최소 R. */
export interface EditTarget {
year: number;
edit: PlanEdit;
line: EditedPolyline;
minRadiusM: number;
}
export interface PlanEditEvents {
/** 고치기 직전 한 번(되돌리기 쌓기). */
begin: () => void;
/** 고친 값 — `final` = 끌기 끝 · 넣기 · 지우기(캐시 · 횡단 다시). */
change: (year: number, edit: PlanEdit, final: boolean) => void;
/** 막힌 까닭(하한 · 끝 잠금). */
blocked: (message: string) => void;
}
export interface PlanEditLayer extends PlanMapOverlay {
setTargets: (targets: EditTarget[]) => void;
targets: () => EditTarget[];
}
type Grab = { year: number; index: number; which: "node" | "start" | "end" };
export function createPlanEditLayer(events: PlanEditEvents): PlanEditLayer {
let targets: EditTarget[] = [];
let grab: Grab | null = null;
let moved = false;
let stop = "";
const interior = (edit: PlanEdit): Vertex[] => edit.nodes.slice(1, -1);
function apply(target: EditTarget, step: EditStep, final: boolean): void {
if ("blocked" in step) {
stop = step.blocked;
if (final) events.blocked(stop);
return;
}
target.edit = step.edit;
target.line = step.line;
events.change(target.year, step.edit, final);
}
return {
setTargets(next) {
targets = next;
},
targets: () => targets,
draw(context, map) {
context.save();
context.font = "11px system-ui, sans-serif";
context.textAlign = "left";
context.textBaseline = "bottom";
for (const target of targets) {
for (const curve of target.line.curves) {
for (const end of [curve.start, curve.end]) {
const [x, y] = map.toScreen(end);
context.beginPath();
context.arc(x, y, 4, 0, Math.PI * 2);
context.fillStyle = "#f59e0b";
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = "#ffffff";
context.stroke();
}
const [ax, ay] = map.toScreen(curve.apex);
context.lineWidth = 3;
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
context.strokeText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5);
context.fillStyle = curve.violations.length ? "#fca5a5" : "#ffffff";
context.fillText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5);
}
interior(target.edit).forEach((node, i) => {
const [x, y] = map.toScreen(node);
context.fillStyle = "#ffffff";
context.fillRect(x - 4.5, y - 4.5, 9, 9);
context.lineWidth = 2;
const bad = target.line.nodes[i + 1]?.violations.length;
context.strokeStyle = bad ? "#dc2626" : "#2563eb";
context.strokeRect(x - 4.5, y - 4.5, 9, 9);
});
}
context.restore();
},
grab(px, py, map) {
grab = null;
moved = false;
stop = "";
for (const target of targets) {
const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX);
if (node >= 0) {
grab = { year: target.year, index: node + 1, which: "node" };
return true;
}
}
for (const target of targets) {
const hit = handleAtScreen(target.line.curves, map.toScreen, px, py, HANDLE_HIT_PX);
if (hit) {
grab = { year: target.year, index: hit.node, which: hit.end };
return true;
}
}
return false;
},
move(point) {
const target = grab && targets.find((entry) => entry.year === grab!.year);
if (!grab || !target) return;
if (!moved) events.begin();
moved = true;
const step =
grab.which === "node"
? moveNode(target.edit, grab.index, point, target.minRadiusM)
: moveHandle(target.edit, grab.index, grab.which, point, target.minRadiusM);
if (!("blocked" in step)) stop = "";
apply(target, step, false);
},
release() {
const target = grab && targets.find((entry) => entry.year === grab!.year);
grab = null;
if (!moved || !target) return;
moved = false;
events.change(target.year, target.edit, true);
if (stop) events.blocked(stop);
},
insert(px: number, py: number, map: ScreenMap) {
for (const target of targets) {
const segment = segmentAtScreen(target.edit.nodes, map.toScreen, px, py, SEGMENT_HIT_PX);
if (segment < 0) continue;
events.begin();
apply(
target,
insertNode(target.edit, segment, map.toMetric(px, py), target.minRadiusM),
true,
);
return;
}
},
remove(px: number, py: number, map: ScreenMap) {
for (const target of targets) {
const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX);
if (node < 0) continue;
events.begin();
apply(target, removeNode(target.edit, node + 1, target.minRadiusM), true);
return;
}
},
};
}
+45 -6
View File
@@ -7,6 +7,7 @@
* `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` ·
* 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선
* `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시).
* 꺾임점 고침 겹(543 · `overlay`) = 지점 다음 차례로 집음 · 두 번 누름 = 넣기 · 오른쪽 누름 = 지우기.
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
@@ -60,10 +61,27 @@ export interface PlanMapScene {
editable: boolean;
}
/** 화면 px ↔ 작업 좌표계 m. */
export interface ScreenMap {
toScreen: (point: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
}
/** 평면 위 고침 겹 — 그리기 · 집기(참 = 잡음) · 끌기 · 놓기 · 넣기 · 지우기. */
export interface PlanMapOverlay {
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
grab: (px: number, py: number, map: ScreenMap) => boolean;
move: (point: Vertex) => void;
release: () => void;
insert: (px: number, py: number, map: ScreenMap) => void;
remove: (px: number, py: number, map: ScreenMap) => void;
}
export interface PlanMapEvents {
onDrag: (index: number, point: Vertex) => void;
onDragEnd: (index: number) => void;
onStation: (chainage: number) => void;
overlay?: PlanMapOverlay;
}
export interface PlanMap {
@@ -73,6 +91,8 @@ export interface PlanMap {
fit: () => void;
/** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */
stationScreen: (chainage: number) => [number, number] | null;
/** 검증 창구 — 작업 좌표계 점 → 화면 px(그림 좌표 · 그리기 전이면 null). */
screenOf: (point: Vertex) => [number, number] | null;
dispose: () => void;
}
@@ -133,6 +153,10 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
const toScreen = (point: Vertex): [number, number] =>
createMetricProjector(meta!, view).toScreen(point[0], point[1]);
const screenMap: ScreenMap = {
toScreen,
toMetric: (px, py) => screenToMetric(meta!, view, px, py),
};
function refit(): void {
const all = [...scene.nodes, ...scene.line];
@@ -228,6 +252,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
context.stroke();
}
}
if (scene.editable) events.overlay?.draw(context, screenMap);
scene.nodes.forEach((node, i) => {
const [sx, sy] = toScreen(node);
const last = i === scene.nodes.length - 1;
@@ -295,11 +320,12 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
canvas.addEventListener("pointerdown", (event) => {
press = [event.clientX, event.clientY];
if (!meta || !scene.editable || event.button !== 0) return;
const index = nodeAt(...at(event));
if (index < 0) return;
event.stopImmediatePropagation();
const [px, py] = at(event);
const [sx, sy] = toScreen(scene.nodes[index]);
// 지점 먼저 · 다음 고침 겹(index -1)
const index = nodeAt(px, py);
if (index < 0 && !events.overlay?.grab(px, py, screenMap)) return;
event.stopImmediatePropagation();
const [sx, sy] = index < 0 ? [px, py] : toScreen(scene.nodes[index]);
dragging = { index, id: event.pointerId, dx: sx - px, dy: sy - py, moved: false };
canvas.setPointerCapture(event.pointerId);
canvas.style.cursor = "grabbing";
@@ -315,14 +341,17 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
if (!dragging.moved && !far) return;
dragging.moved = true;
const [px, py] = at(event);
events.onDrag(dragging.index, screenToMetric(meta, view, px + dragging.dx, py + dragging.dy));
const point = screenToMetric(meta, view, px + dragging.dx, py + dragging.dy);
if (dragging.index < 0) events.overlay?.move(point);
else events.onDrag(dragging.index, point);
});
const endDrag = (event: PointerEvent): void => {
if (!dragging || event.pointerId !== dragging.id) return;
const { index, moved } = dragging;
dragging = null;
canvas.style.cursor = "";
if (moved) events.onDragEnd(index);
if (index < 0) events.overlay?.release();
else if (moved) events.onDragEnd(index);
};
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
@@ -353,6 +382,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
if (hit) events.onStation(hit.chainage);
});
canvas.addEventListener("dblclick", (event) => {
if (meta && scene.editable) events.overlay?.insert(...at(event), screenMap);
});
canvas.addEventListener("contextmenu", (event) => {
if (!meta || !scene.editable) return;
event.preventDefault();
events.overlay?.remove(...at(event), screenMap);
});
const observer = new ResizeObserver(() => resize());
observer.observe(root);
@@ -369,6 +407,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
draw();
},
stationScreen,
screenOf: (point) => (meta ? toScreen(point) : null),
dispose: () => observer.disconnect(),
};
}
+208 -13
View File
@@ -6,8 +6,10 @@
* 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D].
* 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」.
*
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 = 캐시(`d03-plan`) · [저장] = PUT plan/analysis
* (끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 다시 셈). 화면 전체 잠금 = [저장]만.
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 · 꺾임점 고침(543) = 캐시(`d03-plan`) · [저장] =
* PUT plan/analysis(끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 · 고친 노선은
* 마디로 서버가 다시 셈). 꺾임점 고침 = 브라우저 즉시 다시 그음 → 길이 · 측점 · 누른 측점 횡단 ·
* 되돌리기(Ctrl+Z). 화면 전체 잠금 = [저장]만.
* 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음.
* ========================================================================== */
@@ -45,11 +47,13 @@ import {
savePlanAnalysis,
startPlanJob,
type PlanAnalysisView,
type SavedEdit,
type PlanJob,
type YearSurface,
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross";
import { createPlanEditLayer, type EditTarget } from "./D03_PlanAnalysis_UI_Edit";
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import {
chainRows,
@@ -61,10 +65,19 @@ import {
rowAt,
sameNodes,
stationChainages,
withEdits,
type ChainRow,
type RouteEdit,
type Vertex,
type YearAnalysis,
} from "./logic/D03_PlanAnalysis_Plan";
import {
buildEdit,
editFromRoute,
moveNode,
sameEdit,
type PlanEdit,
} from "./logic/D03_PlanAnalysis_Edit";
const MODE_LABEL: Record<string, string> = {
gradeline: "영선",
@@ -76,18 +89,30 @@ const POLL_MS = 1000;
/** 등고선을 받아 올 지점 둘레(m). */
const CONTOUR_PAD_M = 600;
/** 되돌리기 쌓는 수. */
const UNDO_MAX = 50;
interface Draft {
nodes: Vertex[] | null;
choices: Record<number, string>;
/** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */
edits?: Record<number, PlanEdit>;
}
type Snapshot = { edits: Record<number, PlanEdit>; choices: Record<number, string> };
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
const errorText = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const map = createPlanMap({ onDrag, onDragEnd, onStation });
const editLayer = createPlanEditLayer({
begin: remember,
change: onEdit,
blocked: (message) => showToast(message, "info"),
});
const map = createPlanMap({ onDrag, onDragEnd, onStation, overlay: editLayer });
const cross = createPlanCross();
const main = document.createElement("div");
main.className = "d03-plan";
@@ -114,9 +139,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
});
const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() });
const viewButton = createButton({ label: "3D", variant: "ghost", onClick: () => open3d() });
const undoButton = createButton({ label: "되돌리기", variant: "ghost", onClick: () => undo() });
undoButton.title = "꺾임점 고침 · 갈래 고름 한 걸음 되돌림(Ctrl+Z)";
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
actions.append(analyzeButton, saveButton, viewButton);
actions.append(analyzeButton, saveButton, viewButton, undoButton);
const terrainCard = createPanelCard("지형", false);
terrainCard.append(terrainText, bar, more);
const routeCard = createPanelCard("연도별 노선", false);
@@ -142,6 +169,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let editable = false;
let busy: "terrain" | "analyze" | null = null;
let lastCross: StationGround | null = null;
// 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기
let edits: Record<number, PlanEdit> = {};
let serverEdits: Record<number, SavedEdit> = {};
let draftEdits: Record<number, PlanEdit> | undefined;
let history: Snapshot[] = [];
let lastSaveMs: number | null = null;
const moved = (): boolean => !sameNodes(nodes, savedNodes);
const sentPoints = () => (moved() ? pointsOf(years, nodes) : null);
@@ -161,11 +194,54 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}),
);
const keepDraft = (): void =>
writeState("d03-plan", { nodes: moved() ? nodes : null, choices } satisfies Draft, projectId);
writeState(
"d03-plan",
{ nodes: moved() ? nodes : null, choices, edits } satisfies Draft,
projectId,
);
/** 고치기 직전 — 되돌리기에 지금 값을 쌓음. */
function remember(): void {
history = [...history.slice(1 - UNDO_MAX), structuredClone({ edits, choices })];
}
function undo(): void {
const last = history.pop();
if (!last) return;
({ edits, choices } = last);
keepDraft();
refresh();
repick();
}
/** 누른 측점 횡단을 지금 노선으로 다시. */
function repick(): void {
if (selected !== null && rowAt(rows, selected)) onStation(selected);
}
function onEdit(year: number, edit: PlanEdit, final: boolean): void {
edits = { ...edits, [year]: edit };
refresh();
if (!final) return;
keepDraft();
repick();
}
/** 연도의 지금 고침 — 고른 갈래 · 분석 지문이 같을 때만. */
const editOf = (result: YearAnalysis, mode: string | null): PlanEdit | null => {
const own = edits[result.year];
return own && own.base_mode === mode && own.base_fingerprint === result.fingerprint
? own
: null;
};
/** 연도 목록에서 뺌. */
const without = (from: Record<number, PlanEdit>, drop: number[]): Record<number, PlanEdit> =>
Object.fromEntries(Object.entries(from).filter(([year]) => !drop.includes(Number(year))));
/** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */
function refresh(): void {
results = years.map(
const raw: YearAnalysis[] = years.map(
(year, i) =>
analysis?.years.find((entry) => entry.year === year)?.analysis ?? {
year,
@@ -176,7 +252,33 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
);
// 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름
const saved = Object.fromEntries((analysis?.chain ?? []).map((row) => [row.year, row.mode]));
rows = chainRows(results, { ...saved, ...choices }, startM);
const picks = { ...saved, ...choices };
// 꺾임점 고침 — 고른 갈래 노선의 마디(고침 · 없으면 엔진 노선) → 선 → 그 갈래 자리에 갈아 끼움
const targets: EditTarget[] = [];
const routed: Record<number, RouteEdit> = {};
chainRows(raw, picks, startM).forEach((row, i) => {
const result = raw[i];
const route = row.mode ? result.routes[row.mode] : undefined;
if (!row.mode || !route?.nodes || !result.fingerprint) return;
const minRadiusM = result.criteria?.min_radius_m ?? 0;
const own = editOf(result, row.mode);
const built = buildEdit(
own ?? editFromRoute(route, row.mode, result.fingerprint),
minRadiusM,
);
targets.push({ year: result.year, ...built, minRadiusM });
if (own)
routed[result.year] = {
mode: row.mode,
planned: built.line.vertices,
metrics: sameEdit(own, serverEdits[result.year])
? serverEdits[result.year].metrics
: null,
};
});
editLayer.setTargets(editable ? targets : []);
results = withEdits(raw, routed);
rows = chainRows(results, picks, startM);
const line = joinedLine(results, rows);
const others: Vertex[][] = results.flatMap((result, i) => {
const routes = PLAN_MODES.filter((mode) => mode !== rows[i].mode)
@@ -210,6 +312,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요."
: "";
viewButton.disabled = !rows.some((row) => row.mode && surfaces.has(row.year));
undoButton.hidden = !editable;
undoButton.disabled = !history.length;
}
function renderYears(): void {
@@ -226,6 +330,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
`${stationLabel(row.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} (${row.length_m.toFixed(1)} m)`
: `${result.year}년 · 분석 전 — [분석]을 누르세요`;
box.append(head);
if (row.mode && editOf(result, row.mode)) box.append(editLine(result.year));
for (const mode of PLAN_MODES) {
const route = result.routes[mode];
if (!route) continue;
@@ -239,11 +344,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const name = document.createElement("strong");
name.textContent = MODE_LABEL[mode];
const values = document.createElement("span");
values.textContent = metrics
? `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${(metrics.max_grade_pct ?? 0).toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`
: "노선 없음";
values.textContent = !metrics
? "노선 없음"
: metrics.max_grade_pct === undefined
? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈`
: `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`;
card.append(name, values);
card.addEventListener("click", () => {
if (row.mode === mode) return;
remember();
choices = { ...choices, [result.year]: mode };
keepDraft();
refresh();
@@ -256,6 +365,30 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
);
}
/** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */
function editLine(year: number): HTMLElement {
const line = document.createElement("div");
line.className = "d03-plan__year-head";
line.textContent = sameEdit(edits[year], serverEdits[year])
? "꺾임점 고침(저장됨) "
: "꺾임점 고침 — [저장] 전 ";
const reset = createButton({
label: "원래 노선",
variant: "ghost",
size: "sm",
onClick: () => {
remember();
edits = without(edits, [year]);
keepDraft();
refresh();
repick();
},
});
reset.hidden = !editable;
line.append(reset);
return line;
}
/** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */
function targetLine(): HTMLElement[] {
const last = rows[rows.length - 1];
@@ -276,7 +409,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}
function onDragEnd(index: number): void {
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석]
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] · 그 연도 꺾임점 고침도 버림
const lost = [years[index - 1], years[index]].filter(
(year) => year !== undefined && edits[year],
);
if (lost.length) {
edits = without(edits, lost);
history = [];
showToast(`지점을 옮겨 ${lost.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
}
if (analysis)
analysis = {
...analysis,
@@ -357,6 +498,25 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
analysis = await fetchPlanAnalysis(projectId, sentPoints());
startM = analysis.start_chainage_m;
targetM = analysis.end_chainage_target_m ?? null;
serverEdits = Object.fromEntries(
analysis.years.flatMap((entry) => (entry.edit ? [[entry.year, entry.edit]] : [])),
);
edits =
draftEdits ??
Object.fromEntries(
Object.entries(serverEdits).map(([year, { metrics: _m, ...edit }]) => [year, edit]),
);
// 분석이 바뀐 연도(지문 다름)의 고침은 버림
const prints = new Map(analysis.years.map((e) => [e.year, e.analysis?.fingerprint]));
const stale = Object.keys(edits)
.map(Number)
.filter((year) => prints.get(year) !== edits[year].base_fingerprint);
if (stale.length) {
edits = without(edits, stale);
if (draftEdits)
showToast(`분석이 바뀌어 ${stale.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
}
draftEdits = edits;
} catch (error) {
analysis = null;
showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error");
@@ -422,10 +582,20 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const picked = Object.fromEntries(
rows.filter((row) => row.mode).map((row) => [row.year, row.mode as string]),
);
const sent = Object.fromEntries(
results.flatMap((result, i) => {
const edit = editOf(result, rows[i].mode);
return edit ? [[result.year, edit]] : [];
}),
);
showLoadingOverlay();
try {
await savePlanAnalysis(projectId, picked, sentPoints());
const began = performance.now();
await savePlanAnalysis(projectId, picked, sentPoints(), sent);
lastSaveMs = Math.round(performance.now() - began);
clearState("d03-plan", projectId);
draftEdits = undefined;
history = [];
await loadPoints();
await loadAnalysis();
refresh();
@@ -469,17 +639,42 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const draft = readState<Draft>("d03-plan", projectId);
nodes = draft?.nodes?.length === savedNodes.length ? draft.nodes : savedNodes;
choices = draft?.choices ?? {};
draftEdits = draft?.edits;
return true;
}
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디(ORCA 판정)
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디 · 꺾임점 고침 · 저장 시간(ORCA 판정)
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
stationScreen: map.stationScreen,
screenOf: map.screenOf,
rows: () => rows,
nodes: () => nodes,
pick: onStation,
cross: () => lastCross,
edits: () => edits,
targets: () => editLayer.targets(),
results: () => results,
saveMs: () => lastSaveMs,
// 가이드 그림 · 검증 — 첫 고침 연도 마디 하나를 (dx, dy) m 옮김(끌기와 같은 길)
nudge: (index: number, dx: number, dy: number) => {
const target = editLayer.targets()[0];
const node = target?.edit.nodes[index];
if (!node) return;
const step = moveNode(target.edit, index, [node[0] + dx, node[1] + dy], target.minRadiusM);
if ("blocked" in step) return showToast(step.blocked, "info");
remember();
onEdit(target.year, step.edit, true);
},
};
const onKey = (event: KeyboardEvent): void => {
if (!root.isConnected) return window.removeEventListener("keydown", onKey);
const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select");
if (editable && !typing && (event.ctrlKey || event.metaKey) && event.key === "z") {
event.preventDefault();
undo();
}
};
window.addEventListener("keydown", onKey);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;
@@ -0,0 +1,173 @@
/* =============================================================================
* D03_PlanAnalysis_Edit.ts
* D 예정노선 꺾임점 고침 계산(PLAN 543-1) — 고른 갈래 노선의 마디(IP) 끌기 · 넣기 · 지우기 · 곡선 손잡이
* = R. DOM · fetch · sessionStorage 없음.
*
* 셈은 B05 편집기 순수 부품 그대로(import 만) — 서버 노드 펴기 `flattenServerPlan` · 다시 그음
* `rebuildLine`(R 하한 끌어올림 + `buildEditedPolyline`) · 마디 넣기 · 빼기 `spliceNode` · 손잡이 →
* 교각점 · R `dragHandleTo` · 하한 새로 깸 막기 `floorStop`.
* 처음 · 끝 마디 = 연도 경계 지점 → 잠금(옮기면 연도 사이가 끊김 · 지점은 지점 끌기로).
*
* ⚠ 서버 짝 — [저장] 때 `D03_PlanAnalysis_Edit.py` `edited_line` 이 같은 마디 · 켬끔 · R 로 선을 다시
* 그음(Node `route.polyline` = 같은 `buildEditedPolyline` · 폴백 `build_planned_polyline`) · 거울 시험
* `resources/tester/test_543_plan_edit.py`.
* ========================================================================== */
import {
flattenServerPlan,
floorStop,
rebuildLine,
spliceNode,
type CurveLimits,
type NodeColumns,
} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Edits";
import {
dragHandleTo,
type EditedCurve,
type EditedPolyline,
type Vertex,
} from "../../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
import type { PlanRoute } from "./D03_PlanAnalysis_Plan";
/** 연도 하나의 꺾임점 고침(캐시 · [저장] 입력) — 고친 갈래 · 그 분석 지문 · 마디 · 곡선 켬끔 · R. */
export interface PlanEdit {
base_mode: string;
base_fingerprint: string;
nodes: Vertex[];
curve_flags: boolean[];
/** 넣은 R(하한까지 끌어올린 값) · null = 법정 최소 R. */
radii: Array<number | null>;
}
export type EditStep = { edit: PlanEdit; line: EditedPolyline } | { blocked: string };
const limitsOf = (minRadiusM: number): CurveLimits => ({
minRadiusM,
limitRadiusM: minRadiusM,
limitArcM: 0,
});
const columnsOf = (edit: PlanEdit): NodeColumns => ({
planned: edit.nodes.map(([x, y]) => [x, y] as Vertex),
curveOn: [...edit.curve_flags],
curveRadius: [...edit.radii],
curveLock: edit.nodes.map(() => null),
curveArc: edit.nodes.map(() => null),
apexLock: edit.nodes.map(() => false),
});
/** 엔진 노선 → 고침 시작 마디(묶인 곡선은 교각점 하나로 · 서버가 고른 R 그대로). */
export function editFromRoute(route: PlanRoute, mode: string, fingerprint: string): PlanEdit {
const flat = flattenServerPlan(route.nodes ?? [], (route.curves ?? []) as EditedCurve[]);
return {
base_mode: mode,
base_fingerprint: fingerprint,
nodes: flat.planned,
curve_flags: flat.curveOn,
radii: flat.curveRadius,
};
}
/** 마디 → 선 — R 은 하한까지 끌어올린 값으로 고침에 되적음(서버에 같은 값을 보냄). */
export function buildEdit(
edit: PlanEdit,
minRadiusM: number,
): { edit: PlanEdit; line: EditedPolyline } {
const columns = columnsOf(edit);
const line = rebuildLine(columns, limitsOf(minRadiusM));
return { edit: { ...edit, radii: columns.curveRadius }, line };
}
/** 고친 뒤 — 지키던 하한을 새로 깨면 막음(그 걸음 버림). */
function settle(before: PlanEdit, after: PlanEdit, minRadiusM: number): EditStep {
const built = buildEdit(after, minRadiusM);
const stop = floorStop(
columnsOf(before),
built.edit.nodes,
built.line.nodes,
limitsOf(minRadiusM),
);
return stop ? { blocked: stop } : built;
}
const isEnd = (edit: PlanEdit, index: number): boolean =>
index <= 0 || index >= edit.nodes.length - 1;
const ENDS_LOCKED = "처음 · 끝 마디는 연도 경계 지점이라 여기서 못 고칩니다.";
/** 마디 끌기 — 처음 · 끝 잠금. */
export function moveNode(edit: PlanEdit, index: number, to: Vertex, minRadiusM: number): EditStep {
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
const nodes = edit.nodes.map((node, i) => (i === index ? ([to[0], to[1]] as Vertex) : node));
return settle(edit, { ...edit, nodes }, minRadiusM);
}
/** 곡선 손잡이(접선점) 끌기 → 그쪽 직선이 돌고 교각점 · R 이 바뀜 · R 은 법정 최소 밑으로 안 내려감. */
export function moveHandle(
edit: PlanEdit,
index: number,
which: "start" | "end",
to: Vertex,
minRadiusM: number,
): EditStep {
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
const got = dragHandleTo(
edit.nodes[index - 1],
edit.nodes[index],
edit.nodes[index + 1],
which,
to,
);
if (!got) return { blocked: "곡선을 그 자리로 못 옮깁니다." };
const nodes = edit.nodes.map((node, i) => (i === index ? got.apex : node));
const radii = edit.radii.map((r, i) => (i === index ? Math.max(got.radius, minRadiusM) : r));
const curve_flags = edit.curve_flags.map((on, i) => (i === index ? true : on));
return settle(edit, { ...edit, nodes, radii, curve_flags }, minRadiusM);
}
/** 마디 넣기 — `segment` 번째 마디와 다음 마디 사이. */
export function insertNode(
edit: PlanEdit,
segment: number,
at: Vertex,
minRadiusM: number,
): EditStep {
if (segment < 0 || segment >= edit.nodes.length - 1) return { blocked: "선 위를 누르세요." };
const columns = columnsOf(edit);
spliceNode(columns, segment + 1, [at[0], at[1]]);
const after = {
...edit,
nodes: columns.planned,
curve_flags: columns.curveOn,
radii: columns.curveRadius,
};
return settle(edit, after, minRadiusM);
}
/** 마디 지우기 — 처음 · 끝 잠금. */
export function removeNode(edit: PlanEdit, index: number, minRadiusM: number): EditStep {
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
const columns = columnsOf(edit);
spliceNode(columns, index);
const after = {
...edit,
nodes: columns.planned,
curve_flags: columns.curveOn,
radii: columns.curveRadius,
};
return settle(edit, after, minRadiusM);
}
/** 같은 고침인가(서버 저장값과 견줌 · 1 mm · R 1 mm). */
export function sameEdit(a: PlanEdit | null | undefined, b: PlanEdit | null | undefined): boolean {
if (!a || !b) return false;
const near = (x: number | null, y: number | null): boolean =>
x === null || y === null ? x === y : Math.abs(x - y) < 1e-3;
return (
a.base_mode === b.base_mode &&
a.base_fingerprint === b.base_fingerprint &&
a.nodes.length === b.nodes.length &&
a.nodes.every((p, i) => near(p[0], b.nodes[i][0]) && near(p[1], b.nodes[i][1])) &&
a.curve_flags.every((on, i) => on === b.curve_flags[i]) &&
a.radii.every((r, i) => near(r, b.radii[i] ?? null))
);
}
@@ -3,8 +3,8 @@
* D 예정노선 2단계 분석 화면 계산(PLAN 530-3) — 갈래 고름 · 이은 누가 · 이은 중심선 · 측점 ·
* 지점(연도 경계) · 받은 지형 범위 판정. DOM · fetch · sessionStorage 없음.
*
* ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` ↔
* `pickMode` · `chainRows` · `joinedLine`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험
* ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` · `with_edits` ↔
* `pickMode` · `chainRows` · `joinedLine` · `withEdits`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험
* `resources/tester/test_530_3_plan_logic.py`. 화면은 고르는 즉시 이 짝으로 · [저장]은 서버가 다시 셈.
* ========================================================================== */
@@ -18,13 +18,26 @@ export type PlanMode = (typeof PLAN_MODES)[number];
* 탐색 복도가 받은 지형 안에 듦(DEM = 구간 상자 + 복도 + 50 m). */
export const PLAN_REACH_M = 300;
export interface RouteMetrics {
length_m: number;
max_grade_pct?: number;
over_limit?: { total: number };
}
export interface PlanRoute {
planned?: Vertex[];
metrics?: {
length_m: number;
max_grade_pct?: number;
over_limit?: { total: number };
};
/** 엔진 노드 · 곡선(B05 `RouteNode` · `RouteCurve` 꼴) — 꺾임점 고침의 시작 마디. */
nodes?: Array<{
x: number;
y: number;
radius_m: number | null;
inner_angle_deg: number | null;
violations?: string[];
}>;
curves?: unknown[];
metrics?: RouteMetrics;
/** 꺾임점을 고친 노선(`withEdits`). */
edited?: boolean;
error?: string;
}
@@ -32,9 +45,19 @@ export interface YearAnalysis {
year: number;
start: Vertex;
end: Vertex;
/** 분석 입력 지문 — 꺾임점 고침이 이 분석 위에서 한 것인가. */
fingerprint?: string;
criteria?: { min_radius_m: number; max_grade_pct?: number };
routes: Record<string, PlanRoute>;
}
/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */
export interface RouteEdit {
mode: string;
planned: Vertex[];
metrics?: RouteMetrics | null;
}
export interface ChainRow {
year: number;
mode: string | null;
@@ -75,6 +98,35 @@ export function chainRows(
});
}
/** 선 길이(m) — 짝: `D03_PlanAnalysis_Analyze.line_length`(같은 차례로 더함). */
export function lineLength(line: ReadonlyArray<Vertex>): number {
let total = 0;
for (let i = 1; i < line.length; i += 1)
total += Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]);
return total;
}
/** 고친 노선을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 길이(이은 누가 · 측점이 따라감).
* 그 갈래가 없거나 빈 갈래면 그대로. */
export function withEdits(
results: ReadonlyArray<YearAnalysis>,
edits: Readonly<Record<number, RouteEdit | undefined>>,
): YearAnalysis[] {
return results.map((result) => {
const edit = edits[result.year];
const route = edit ? result.routes?.[edit.mode] : undefined;
if (!edit || !route || "error" in route) return result;
const metrics = { ...(edit.metrics ?? {}), length_m: lineLength(edit.planned) };
return {
...result,
routes: {
...result.routes,
[edit.mode]: { ...route, planned: edit.planned, metrics, edited: true },
},
};
});
}
const samePoint = (a: Vertex, b: Vertex): boolean =>
Math.abs(a[0] - b[0]) < 1e-3 && Math.abs(a[1] - b[1]) < 1e-3;
+29
View File
@@ -0,0 +1,29 @@
# PLAN 543~545 — 요구 363 · 364 예정노선 꺾임점 고침 · 예상 횡단 · 3D · 측점 표기 (2026-10-26 완료 · 이관)
## 543. 예정노선 노선 꺾임점 고침 (요구 363-1 · 사용자 진행 · sub3 · Opus high)
- 정함(브레인): 근거 `docs/raw/요구_1025/_분석_363_예정노선_지점_횡단.md` ③ · 고칠 자리 = D03 평면(측점 · 횡단 · 3D 가 한 화면) · D02 다음맵 = 지금대로 연도 경계 지점만(이미 끌기 됨) · 고침 = 고른 갈래 노선 꺾임점 끌기 · 넣기 · 지우기 · R 손잡이 · 처음 · 끝 잠금 · 법정 최소 R 밑 막음 · B05 순수 함수 가져다 씀(B 고침 0) · 브라우저 즉시 다시 셈 · [저장] = 서버 정본 · 권한 = 산림청 그대로.
- [x] 543-1 꺾임점 고침 순수 함수 + 이은 누가 짝(`logic/D03_PlanAnalysis_Edit.ts` 새 · `Plan.ts` · `Analyze.py`) · 시험(끝점 그대로 · 길이 · 거울).
- 검증완료 — test_543 6 · test_530_3 2 통과 · 끌기 · 손잡이 · 넣기 · 지우기 뒤 끝점 그대로 · 길이 = lineLength · 브라우저 선 = 서버 선 1 mm(Node · 파이썬 폴백)
- [x] 543-2 평면 마디 겹 · 캐시 · 되돌리기 · 카드(`UI_Map` · 새 `UI_Edit` · `UI_Page` · `Api`) · ORCA 끌기 뒤 연장 · 측점 · 횡단 바뀜.
- 검증완료 — 가이드 D 2027 끌기 연장 365.26→359.772 m · 끝 측점 18+5.3→17+19.8 · 10+0 횡단 지반고 847.22→854.67 m · 넣기 6→7 · 지우기 7→6 · 끝 마디 잠금 · 손잡이 R 55.97→61.02 · 새로고침 뒤 고침 유지 · 되돌리기 · 원래 노선 365.26(ORCA · Playwright · 저장 안 함 · 캐시 비움)
- [x] 543-3 [저장] 정본 + GET 돌려줌(`Router.py` · 새 `D03_PlanAnalysis_Edit.py`) · 시험(브라우저 선 = 서버 선 1 mm · 저장 시간).
- 검증완료 — 검증 D 「검증_sub3 예정노선」 끌기 뒤 [저장] 브라우저 선 = 서버 선 최대 0.06 mm · 점 60 = 60 · 연장 382.265 = 382.265 m · 저장 6.7 s(연도 지형 읽기 포함) · 새로고침 「저장됨」 + 서버 지표(최대 34.3 % · 위반 3) · test_530_2 8 통과
## 544. 예정노선 예상 횡단도 · 3D (요구 363-1 · 사용자 진행 · sub2 · Opus high)
- 정함(브레인): 예상 횡단 = B 공용 `computeCrossDesign`(파이썬 짝 있음) + 표준 횡단 기본값 + B 초기 설계 기본(리핑암 · 산쪽 절토 · 일반 측구) · 계획고 = 중심 지반고(B 첫 종단과 같음) · 3D = 같은 단면 → B05 `buildCorridor` → D03 3D 창 「예상 코리도」 켬끔 · 543 과 파일 안 겹침.
- [x] 544-1 예상 단면 순수 함수(`logic/D03_PlanAnalysis_Expect.ts` 새) · 시험(절 · 성토 ㎡ = 파이썬 값).
- 검증완료 — 시험 test_544_1 2 통과(비탈 표본 셋 · 미교차 넓힘 — TS 설계선 · 절 · 성토 ㎡ = 파이썬 compute_cross_design 1e-9) · typecheck(f3269f7f2)
- [x] 544-2 횡단 패널에 원지반선 + 예상 단면 · 절 · 성토 ㎡(`UI_Cross`) · ORCA 가이드 D 프로젝트 측점 누름 수치.
- 검증완료 — ORCA 가이드 D 2027 5+0 머리 「절토 3.30㎡ · 성토 3.92㎡」 = 파이썬 compute_cross_design 3.30 · 3.92 · 설계선 27 점(주황 rgb 249,115,22) · 15+0 미교차 넓힘 45 m 성토 174.19㎡ · 밝음 · 어두움(지반 rgb 179,174,198) · 도움말 04 · 그림 00 · 05(3e44791fe · 156afa57f)
- [x] 544-3 3D 예상 코리도(`UI_3D`) · ORCA 사진 · 노면 높이 = 지형.
- 검증완료 — ORCA 3D 측점 20 · 코리도 짓기 92 ms · 열기 0.85 s · 노면 중심 − 측점 지반고 0.000 m(19/19) · 끔 = 원지형 −0.10~+0.07 m · 켬끔 됨 · 지형 잘림 401400→419846 삼각형 · 도움말 06 · 그림 08(c96d559dc · 156afa57f)
## 545. 측점 표기 = No.+거리 (요구 363-2 · Sonnet high)
- 정함(브레인): 표기 = B 화면과 같은 공용 `stationLabel`(`61+14.5`) · D03 은 이미 같음 · 남은 날 누가 = D01 시작 · 종료 칸(m 입력 옆에 즉시 측점 표기 · 저장값 m 그대로) · D04 표 · D04 xlsx(측점 칸 덧 · m 칸 유지) · shp 숫자 칸 그대로 · 도움말 맞춤.
- [x] 545-1 D01 · D04 화면 · xlsx · locale · 도움말(D01 · D04) · 시험 + ORCA.
- 검증완료 — 시험 14(test_531 + 새 거울 test_545_1) · typecheck · ORCA D01 1234.5 → 「= 61+14.5」 · D04 표 「0+0 ~ 18+5.3 ; 18+5.3 ~ 38+6 ; 38+6 ~ 57+1.1」 · 도움말 그림 16 다시(852af2020 · 브레인 대리 체크)
- [x] 543-4 저장 안 한 지점 분석이 저장 지점 분석본을 덮는 흠 — `POST /plan/analyze` 가 끈 지점으로 연도별 파생 파일을 덮어 GET(DB 지점)이 「분석 없음」 · 파생 저장본을 입력 지문 열쇠로 나눔(CLAUDE.md 5장) · 시험(끈 지점 분석 뒤 DB 지점 GET 그대로) · sub3.
- 검증완료 — test_530_2 9 · test_543 6 · test_530_3 2 통과(끈 지점 분석 뒤 DB 지점 GET 그대로 · 끈 지점 GET = 그 분석 · [저장] 뒤 정본 바뀜 · 쌓임 연도마다 3 + 정본) · typecheck · 가이드 D GET 세 연도 365.26 · 400.73 · 375.07 m 그대로 · 파일 손대지 않음(4753d8898)
@@ -203,3 +203,12 @@ on 이 맞음. 그런데 shell command가 아님?
1. 예정노선
- 다음맵 페이지나 노선추천에서 각지점들의 수정이 가능해야함. 이때 측점을 누르면 보이는 횡단도는 원지반선 뿐만아니라 표준 횡단도의 프로그챔 기본값으로 반영된 예상횡단도 그림이 있어야 하면 이걸 3d에도 볼 수 있어야 함.
2. 측점의 표기는 프로그램 로직에서 초기테 누가거리 형태로 표기하는데 측점no. +거리 표기가 되어야함. 이 표현 방식은 b그룹 임도설계 로직에 많이 사용됨.
## 요구 364 (10-26 · 사용자 · 363 계획 답)
서브3 폴더계획서 원본 연결
꺽인점 고치기와 예상 횡단도, 3d 계획은 진행해
## 요구 365 (10-26 · 사용자)
전역 깃 싱크
+123 -1
View File
@@ -1,10 +1,12 @@
"""D 분석 API(PLAN 530-2 · 530-3) — GET 이은 누가 · PUT [저장] = 서버 저장본으로 다시 셈 · 모르는 갈래
거절 · 끈 지점(points) = 그 지점으로 보기 · 안 이어지면 거절 · [저장] 때만 지점 정본(산림청만).
거절 · 끈 지점(points) = 그 지점으로 보기 · 안 이어지면 거절 · [저장] 때만 지점 정본(산림청만) ·
꺾임점 고침(543-3) = 서버가 마디로 선 · 지표를 다시 셈 · 누가가 고친 길이로 · GET 은 지문 같은 고침만.
./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_router.py -q
"""
import json
import os
import pytest
from fastapi import FastAPI
@@ -13,6 +15,7 @@ from fastapi.testclient import TestClient
from common_util.common_util_auth import require_company
from common_util.common_util_json import atomic_write_json
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze
from D03_PlanAnalysis import D03_PlanAnalysis_Edit as plan_edit
from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
@@ -83,6 +86,7 @@ def client(tmp_path, monkeypatch):
"start": list(seg["start"]),
"end": list(seg["end"]),
"fingerprint": "f",
"criteria": {"min_radius_m": 12.0, "max_grade_pct": 14.0},
"routes": routes,
},
)
@@ -109,7 +113,23 @@ def client(tmp_path, monkeypatch):
stored.append([(s["year"], s["start"], s["end"]) for s in segs])
monkeypatch.setattr(router_mod, "_store_points", store_points)
# 꺾임점 고침 지표 — 지형 없이(선 길이만)
loads: list = []
monkeypatch.setattr(
analyze, "_load", lambda root, year, loader: loads.append(year) or (None, [])
)
monkeypatch.setattr(
plan_edit,
"edit_metrics",
lambda line, result, terrain, streams: {
"length_m": round(analyze.line_length(line.vertices), 2),
"max_grade_pct": 9.5,
"over_limit": {"total": 1},
},
)
test = TestClient(app)
test.segments = segments
test.loads = loads
test.log = log
test.session = session
test.stored = stored
@@ -179,3 +199,105 @@ def test_put_with_points_stores_points_forest_only(client) -> None:
moved = {"choices": {}, "points": _points((310.0, 400.0))}
client.session["email"] = "a@forest.go.kr"
assert client.put(url, json=moved).status_code == 409 and len(client.stored) == 1
def _edit(nodes, fingerprint="f") -> dict:
return {
"base_mode": "gentle",
"base_fingerprint": fingerprint,
"nodes": nodes,
"curve_flags": [True] * len(nodes),
"radii": [None] * len(nodes),
}
def test_put_edit_server_redraws_and_get_returns(client) -> None:
url = f"/api/projects/{PID}/plan/analysis"
edit = _edit([[300, 400], [600, 560], [900, 400]])
got = client.put(url, json={"choices": {}, "edits": {"2027": edit}})
assert got.status_code == 200, got.text
updates = {args[3]: json.loads(args[0]) for sql, args in client.log if sql.startswith("UPDATE")}
body = updates[2027]
line = plan_edit.edited_line(edit, 12.0)
assert len(body["planned"]) == len(line.vertices) and body["edit"]["nodes"] == edit["nodes"]
assert body["planned"][0] == [300, 400] and body["planned"][-1] == [900, 400]
assert body["metrics"]["max_grade_pct"] == 9.5 and len(body["curves"]) == 1
length = analyze.line_length(body["planned"])
assert got.json()["chain"][1]["length_m"] == round(length, 3) != 640.0
assert "edit" not in updates[2026] and client.loads == [2027]
# GET — 저장된 고침 + 고친 길이로 이은 누가 · 지문이 바뀌면 안 돌려줌
client.segments[1]["route_json"] = json.dumps(body)
view = client.get(url).json()
assert view["years"][1]["edit"]["nodes"] == edit["nodes"]
assert view["years"][1]["edit"]["metrics"]["over_limit"]["total"] == 1
assert view["chain"][1]["length_m"] == round(length, 3)
assert view["years"][1]["analysis"]["routes"]["gentle"]["metrics"]["length_m"] == 640.0
# 같은 입력 다시 [저장] = 저장된 선 · 지표 그대로(지형 안 읽음)
assert client.put(url, json={"choices": {}, "edits": {"2027": edit}}).status_code == 200
assert client.loads == [2027]
client.segments[1]["route_json"] = json.dumps(
{**body, "edit": {**body["edit"], "base_fingerprint": "old"}}
)
stale = client.get(url).json()
assert stale["years"][1]["edit"] is None and stale["chain"][1]["length_m"] == 640.0
def test_put_edit_rejects(client) -> None:
url = f"/api/projects/{PID}/plan/analysis"
off_end = _edit([[300, 400], [600, 560], [901, 400]])
stale = _edit([[300, 400], [600, 560], [900, 400]], "old")
other = {**stale, "base_fingerprint": "f", "base_mode": "gradeline"}
for bad in (off_end, stale, other):
assert client.put(url, json={"choices": {}, "edits": {"2027": bad}}).status_code == 409
client.session["email"] = "a@gmail.com"
good = _edit([[300, 400], [600, 560], [900, 400]])
assert client.put(url, json={"choices": {}, "edits": {"2027": good}}).status_code == 403
assert not [sql for sql, _ in client.log if sql.startswith("UPDATE")]
def _analyze_moved(client, tmp_path, monkeypatch, p1, length=700.0) -> None:
"""끈 지점 분석(`POST …/plan/analyze` 와 같은 길) — 엔진 · 지문은 가짜."""
monkeypatch.setattr(analyze, "segment_fingerprint", lambda seg, *a, **k: "g")
monkeypatch.setattr(
analyze,
"generate_initial_route",
lambda expected, *a, mode, **k: _route([list(expected[0]), list(expected[1])], length),
)
points = [router_mod.PlanPoint(**p) for p in _points(p1)]
segs = router_mod._override(client.segments, points)
analyze.analyze_plan(tmp_path, segs, analyze.plan_criteria("work", None), workers=1)
def test_moved_analysis_keeps_saved_store(client, tmp_path, monkeypatch) -> None:
"""543-4 — 끈 지점 분석은 지점 열쇠 파일로 · 저장 지점 GET 그대로 · 끈 지점 GET 은 그 분석 ·
[저장] 뒤 정본 = 끈 지점 분석본 · 옛 꼴 파일은 읽기만 · 쌓임 거르기는 저장 지점 것을 남김."""
url = f"/api/projects/{PID}/plan/analysis"
before = client.get(url).json()
moved = (310.0, 400.0)
_analyze_moved(client, tmp_path, monkeypatch, moved)
folder = analyze.derived_dir(tmp_path)
assert (folder / "route_2026.json").is_file() and (folder / "route_2027.json").is_file()
assert client.get(url).json() == before # 저장 지점 = 옛 분석 그대로
view = client.get(url, params={"points": json.dumps(_points(moved))}).json()
assert [y["analysis"]["fingerprint"] for y in view["years"]] == ["g", "g"]
assert view["chain"][1]["end_chainage_m"] == 2400.0
# [저장] = 끈 지점 정본 → 저장 지점 GET 이 그 분석
body = {"choices": {"2027": "gentle"}, "points": _points(moved)}
assert client.put(url, json=body).status_code == 200
for seg, (start, end) in zip(client.segments, ((P0, moved), (moved, P2))):
seg.update(start=start, end=end)
after = client.get(url).json()
assert [y["analysis"]["fingerprint"] for y in after["years"]] == ["g", "g"]
# 쌓임 — 저장 지점 것은 가장 옛것이어도 남음 · 연도마다 최근 KEEP_PER_YEAR 개
for path in folder.glob("route_*_*.json"):
os.utime(path, (1000.0, 1000.0))
for n in range(analyze.KEEP_PER_YEAR + 1):
_analyze_moved(client, tmp_path, monkeypatch, (320.0 + n, 400.0))
keyed = sorted(p.name for p in folder.glob("route_2026_*.json"))
assert len(keyed) == analyze.KEEP_PER_YEAR + 1
assert analyze.segment_path(tmp_path, client.segments[0]).name in keyed
assert client.get(url).json() == after
+210
View File
@@ -0,0 +1,210 @@
"""D03 꺾임점 고침 거울(PLAN 543-1 · 543-3) — 화면 `logic/D03_PlanAnalysis_Edit.ts` ↔ 서버
`D03_PlanAnalysis_Edit.py` · 이은 누가 짝 `withEdits` ↔ `with_edits`.
- 끌기 · 넣기 · 지우기 · 손잡이 뒤 처음 · 끝 마디 그대로 · 끝 마디 잠금 · R ≥ 법정 최소
- 고친 선 길이 = `lineLength` · 이은 누가가 고친 길이로 이어짐(파이썬 = TS)
- 브라우저 선 = 서버 선(1 mm) — Node 길 · 파이썬 폴백 둘 다
./venv/Scripts/python.exe -m pytest resources/tester/test_543_plan_edit.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
import sys
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from common_util.common_util_route_polyline import build_planned_polyline # noqa: E402
from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze # noqa: E402
from D03_PlanAnalysis import D03_PlanAnalysis_Edit as edit_mod # noqa: E402
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Edit.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
MIN_R = 12.0
NODES = [(0.0, 0.0), (120.0, 10.0), (200.0, 120.0), (330.0, 140.0), (420.0, 60.0)]
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as ed from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.js";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
const job = JSON.parse(readFileSync(process.argv[2], "utf8"));
const R = job.minR;
const base = ed.editFromRoute(job.route, "gentle", "f1");
const step = (s) =>
"blocked" in s ? { blocked: s.blocked } : { edit: s.edit, line: s.line.vertices };
const moved = ed.moveNode(base, 2, [215, 150], R);
const handle = ed.moveHandle(base, 1, "end", job.handleTo, R);
const inserted = ed.insertNode(base, 0, [60, 5], R);
const removed = ed.removeNode(base, 1, R);
const out = {
base,
baseLine: ed.buildEdit(base, R).line.vertices,
moved: step(moved),
handle: step(handle),
inserted: step(inserted),
removed: step(removed),
endLocked: step(ed.moveNode(base, 0, [5, 5], R)),
endRemove: step(ed.removeNode(base, base.nodes.length - 1, R)),
movedLength: "edit" in moved ? plan.lineLength(moved.line.vertices) : null,
};
const edits = { 2026: { mode: "gentle", planned: out.moved.line } };
const results = plan.withEdits(job.results, edits);
out.rows = plan.chainRows(results, {}, 1000);
out.line = plan.joinedLine(results, out.rows);
writeFileSync(process.argv[3], JSON.stringify(out));
"""
def _route() -> dict:
line = build_planned_polyline(list(NODES), min_radius_m=MIN_R, simplify=False)
return {
"planned": [list(v) for v in line.vertices],
"nodes": [n.as_dict() for n in line.nodes],
"curves": [c.as_dict() for c in line.curves],
"metrics": {"length_m": 999.0},
}
def _results(route: dict) -> list:
end = list(NODES[-1])
return [
{
"year": 2026,
"start": list(NODES[0]),
"end": end,
"fingerprint": "f1",
"criteria": {"min_radius_m": MIN_R, "max_grade_pct": 14.0},
"routes": {"gentle": route, "earthwork": {"error": "길 없음"}},
},
{
"year": 2027,
"start": end,
"end": [600.0, 60.0],
"routes": {"gentle": {"planned": [end, [600.0, 60.0]], "metrics": {"length_m": 180.0}}},
},
]
@pytest.fixture(scope="module")
def ts(tmp_path_factory) -> dict:
tmp = tmp_path_factory.mktemp("543")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
[
"node",
str(TSC),
str(TS_FILE),
str(ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts"),
"--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")
route = _route()
job = {"minR": MIN_R, "route": route, "results": _results(route), "handleTo": None}
# 손잡이 = 첫 곡선 끝 접선점을 나가는 직선 따라 교각점에서 1.8 배 자리로(접선 길이 ↑ = R ↑)
curve = next(c for c in route["curves"] if c["node_first"] == 1)
apex, end = curve["apex"], curve["end"]
job["handleTo"] = [apex[0] + 1.8 * (end[0] - apex[0]), apex[1] + 1.8 * (end[1] - apex[1])]
(tmp / "job.json").write_text(json.dumps(job), encoding="utf-8")
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(tmp / "job.json"), str(tmp / "out.json")],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return {**json.loads((tmp / "out.json").read_text(encoding="utf-8")), "route": route}
def _close(a: list, b: list, tol: float = 1e-3) -> bool:
return len(a) == len(b) and all(
abs(p[0] - q[0]) <= tol and abs(p[1] - q[1]) <= tol for p, q in zip(a, b)
)
def test_ends_kept_and_locked(ts) -> None:
for name in ("moved", "handle", "inserted", "removed"):
step = ts[name]
assert "edit" in step, (name, step)
assert step["edit"]["nodes"][0] == list(NODES[0])
assert step["edit"]["nodes"][-1] == list(NODES[-1])
assert _close([step["line"][0], step["line"][-1]], [list(NODES[0]), list(NODES[-1])])
assert "연도 경계" in ts["endLocked"]["blocked"]
assert "연도 경계" in ts["endRemove"]["blocked"]
count = len(ts["base"]["nodes"])
assert len(ts["inserted"]["edit"]["nodes"]) == count + 1
assert len(ts["removed"]["edit"]["nodes"]) == count - 1
# 손잡이 = 그 마디 R 이 바뀜 · 법정 최소 밑 아님
assert ts["handle"]["edit"]["radii"][1] >= MIN_R
assert ts["handle"]["edit"]["radii"][1] > ts["base"]["radii"][1] * 1.5
def test_unedited_base_redraws_engine_line(ts) -> None:
assert _close(ts["baseLine"], ts["route"]["planned"])
def test_length_and_chain_mirror(ts) -> None:
line = ts["moved"]["line"]
assert abs(ts["movedLength"] - analyze.line_length(line)) < 1e-6
results = analyze.with_edits(_results(ts["route"]), {2026: {"mode": "gentle", "planned": line}})
rows = analyze.chain(results, {}, 1000.0)
assert ts["rows"] == rows
assert rows[0]["length_m"] == round(analyze.line_length(line), 3)
assert rows[1]["start_chainage_m"] == rows[0]["end_chainage_m"]
assert ts["line"] == analyze.joined_line(results, rows)
@pytest.mark.parametrize("node_path", [True, False])
def test_browser_line_equals_server_line(ts, monkeypatch, node_path) -> None:
if not node_path:
monkeypatch.setattr(edit_mod, "node_polyline", lambda *a, **k: None)
for name in ("moved", "handle", "inserted", "removed"):
step = ts[name]
server = edit_mod.edited_line(step["edit"], MIN_R)
assert _close([list(v) for v in server.vertices], step["line"]), name
def test_check_edit_rejects() -> None:
result = _results(_route())[0]
good = {
"base_mode": "gentle",
"base_fingerprint": "f1",
"nodes": [list(p) for p in NODES],
"curve_flags": [True] * len(NODES),
"radii": [None] * len(NODES),
}
edit_mod.check_edit(good, result, "gentle")
for bad, mode in (
({**good, "base_fingerprint": "old"}, "gentle"),
(good, "earthwork"),
({**good, "nodes": [[1.0, 0.0], *good["nodes"][1:]]}, "gentle"),
({**good, "radii": [None, -3.0, None, None, None]}, "gentle"),
({**good, "curve_flags": [True]}, "gentle"),
):
with pytest.raises(edit_mod.EditError):
edit_mod.check_edit(bad, result, mode)