diff --git a/A00_Common/app_settings.ts b/A00_Common/app_settings.ts index fc4e4d242..3991bdcaf 100644 --- a/A00_Common/app_settings.ts +++ b/A00_Common/app_settings.ts @@ -46,6 +46,7 @@ const KEY_ROWS: [LocaleKey, LocaleKey][] = [ ["App_Settings_Keys_All", "App_Settings_Keys_All_Keys"], ["App_Settings_Keys_Profile", "App_Settings_Keys_Profile_Keys"], ["App_Settings_Keys_RouteEdit", "App_Settings_Keys_RouteEdit_Keys"], + ["App_Settings_Keys_Plan", "App_Settings_Keys_Plan_Keys"], ["App_Settings_Keys_Section", "App_Settings_Keys_Section_Keys"], ["App_Settings_Keys_Cad", "App_Settings_Keys_Cad_Keys"], ["App_Settings_Keys_Table", "App_Settings_Keys_Table_Keys"], diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index 2c6be09c5..cd50cd0a0 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -198,7 +198,7 @@ export const STATE_REGISTRY = { "b08-options": { bucket: "draft", scope: "project" }, /** B08 거리이정표 입력 `{site, base, routes}` 한 벌 — [저장] 때 프로젝트 B08_Quantity/milestone 으로(244-1). */ "b08-milestone": { bucket: "draft", scope: "project" }, - /** D03 예정노선 분석 `{nodes: 끈 지점 P0…Pn | null, choices: {연도: 갈래}}` — [저장] 때 plan/analysis 로(530-3). */ + /** D03 예정노선 노선분석 `{version: 2, network: 그은 선 · 갈래 · 조각}` — [저장] 때 plan/network 로(563-1). */ "d03-plan": { bucket: "draft", scope: "project" }, /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ "b09-estimation": { bucket: "draft", scope: "project" }, diff --git a/B04_PreProcess/B04_PreProcess_Engine.py b/B04_PreProcess/B04_PreProcess_Engine.py index 53f567821..6e18bfc88 100644 --- a/B04_PreProcess/B04_PreProcess_Engine.py +++ b/B04_PreProcess/B04_PreProcess_Engine.py @@ -526,9 +526,14 @@ def download_geodata( # 선정에서 빠진 zip은 쓰이지 않으므로 정리한다(다른 지역 잔재 포함). prune_sheets(sheet_store, sheet_grid) - # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록)가 같으면 스킵 + from B04_PreProcess.B04_PreProcess_Engine_SheetParser import PARSER_VERSION + + # 확보된 도엽을 레이어별 병합 GeoJSON으로 산출 — 열쇠(도엽 번호 목록 · 파서 판)가 같으면 스킵 merge_key_path = processed_dir / "sheet_merge.key.json" - merge_key = {"sheets": sorted(str(item) for item in sheet_result["available"])} + merge_key = { + "sheets": sorted(str(item) for item in sheet_result["available"]), + "parser": PARSER_VERSION, + } if sheet_result["available"] and ( rebuild or not any(processed_dir.glob("도엽_*.geojson")) diff --git a/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py b/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py index ff575eeeb..8d1ef96dd 100644 --- a/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py +++ b/B04_PreProcess/B04_PreProcess_Engine_SheetParser.py @@ -37,6 +37,10 @@ SHEET_LAYERS: dict[str, tuple[str, str]] = { # 산출 파일 접두사 (기존 processed/의 국가 GIS geojson과 구분) _OUTPUT_PREFIX = "도엽" +# 파서 판 번호 — 병합 캐시 열쇠에 넣음 · 읽는 법이 바뀌면 올려 옛 병합본을 다시 지음 +PARSER_VERSION = 2 +# .cpg 없는 도엽 dbf 의 글자 인코딩(국토지리정보원 배포본) +DBF_FALLBACK_ENCODING = "cp949" def _read_layer_from_zip( @@ -57,7 +61,9 @@ def _read_layer_from_zip( with tempfile.TemporaryDirectory() as td: for n in wanted: (Path(td) / Path(n).name).write_bytes(zf.read(n)) - gdf = gpd.read_file(Path(td) / f"{member_base}.shp") + has_cpg = any(Path(n).suffix.lower() == ".cpg" for n in wanted) + options = {} if has_cpg else {"encoding": DBF_FALLBACK_ENCODING} + gdf = gpd.read_file(Path(td) / f"{member_base}.shp", **options) if gdf.empty: return None diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py b/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py deleted file mode 100644 index 35d2509a6..000000000 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Analyze.py +++ /dev/null @@ -1,400 +0,0 @@ -"""D 예정노선 2단계 분석 — 연도 구간마다 B05 노선 추천 엔진으로 지점을 이음 (요구 345 · PLAN 530-2). - -- 구간 = [P(i-1), P(i)] 한 번씩 `generate_initial_route` — 시종점을 옮기지 않아 연도 경계가 맞물림 -- 갈래 넷(`PLAN_MODES` · follow 뺌 — 두 점이면 직선) · 연도마다 고름 -- 추천 기준 = 프로젝트 임도 종류 · 설계속도 → B 법정 함수 · 지형 구분은 B 폴백(`TERRAIN_TYPE`) -- 계류 이격 = 도엽 하천선(B 로더 그대로) · 복도 반폭 = `PLAN_CORRIDOR_M`(D 만) -- 연도 경계는 꺾인 채(경계점 = 측점) · 누가거리 = 등록 시작 누가부터 고른 갈래 길이로 이어 셈 -- 파생 저장본 = `D03_PlanAnalysis/_derived/route_<연도>_<지점 열쇠>.json`(입력 지문 같으면 다시 - 안 셈) · 끈 지점 분석이 저장 지점 분석본을 덮지 않음 · 연도마다 최근 `KEEP_PER_YEAR` 개 + 저장 - 지점 것만 남김 · 옛 꼴 `route_<연도>.json` 은 읽기만(공유 storage — 옛 판 서버가 아직 읽음) - -계산 자리 = ③ 서버 단독(B 추천과 같음). - -⚠ 파이썬·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 - -import hashlib -import json -import logging -import math -import multiprocessing -from concurrent.futures import ProcessPoolExecutor, as_completed -from pathlib import Path -from typing import Any, Callable - -import numpy as np -import shapely -from shapely.geometry import LineString, MultiLineString - -from B05_Profile.B05_Profile_Engine_Grade import ( - legal_grade_limit_pct, - legal_plan_radius_min_m, - resolve_design_speed, -) -from B05_Profile.B05_Profile_Engine_RouteInitial import ( - _POOLS, - _TERRAINS, - generate_initial_route, - load_terrain_and_streams, -) -from B05_Profile.B05_Profile_Engine_RouteInitial_Store import request_fingerprint -from common_util.common_util_json import atomic_write_json -from common_util.common_util_surface_confirmation import SHEET_ANALYSIS_FILTER -from config.config_system_terrain import PLAN_ANALYZE_WORKERS -from D03_PlanAnalysis.D03_PlanAnalysis_Terrain import PLAN_CORRIDOR_M, models_dir_of - -logger = logging.getLogger(__name__) - -PLAN_MODES = ("gradeline", "earthwork", "gentle", "weighted") -# 도엽 DEM(스무딩) — B 「수치지형도」 분석 지형과 같은 고르기 -SELECTION = {"source_filter": SHEET_ANALYSIS_FILTER, "method": "dtm", "smooth": True} -# D 등록에 지형 구분 칸 없음 — B `_road_settings` 못 읽을 때 폴백 그대로(가장 완화) -TERRAIN_TYPE = "special" -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]] - - -def derived_dir(project_root: Path) -> Path: - return project_root / "D03_PlanAnalysis" / "_derived" - - -def plan_criteria(road_type: str | None, design_speed_kph: int | None) -> dict[str, Any]: - """추천 기준 — 종단 상한(비율) · 최소 R · 설계속도(B 법정 함수).""" - grade_class = str(road_type or "work") - speed = resolve_design_speed(grade_class, design_speed_kph) - return { - "max_grade": legal_grade_limit_pct(grade_class, TERRAIN_TYPE, False, speed) / 100.0, - "min_radius_m": legal_plan_radius_min_m(speed, TERRAIN_TYPE), - "design_speed": speed, - } - - -def _point(value: Any) -> tuple[float, float]: - """엔진 출력 자리수(0.1 mm)로 — 노선 끝점 = 지점 그대로 · 연도 경계가 정확히 맞물림.""" - return (round(float(value[0]), 4), round(float(value[1]), 4)) - - -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: - """저장본 — 지점이 지금과 같을 때만(입력 지문은 분석 때 다시 봄).""" - 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]: - keep = ("planned", "nodes", "curves", "metrics", "relaxed", "terrain_bound_pct") - return {key: result.get(key) for key in keep} - - -def _engine_inputs(criteria: dict[str, Any]) -> dict[str, Any]: - return { - "weights": None, - "max_grade": criteria["max_grade"], - "min_radius_m": criteria["min_radius_m"], - "stream_offset_m": None, - "grade_smooth_m": None, - "target_grade": None, - "with_profile": False, - "design_speed": criteria["design_speed"], - } - - -def segment_fingerprint( - seg: dict[str, Any], - terrain: Any, - streams: list, - criteria: dict[str, Any], - corridor_m: float = PLAN_CORRIDOR_M, -) -> str: - """구간 입력 지문 — 갈래 넷 B 저장 키 · 복도 · 판.""" - expected = [_point(seg["start"]), _point(seg["end"])] - inputs = _engine_inputs(criteria) - keys = [ - request_fingerprint(expected, terrain, streams, mode=mode, **inputs)[0] - for mode in PLAN_MODES - ] - return hashlib.blake2b( - repr((keys, float(corridor_m), ALGO)).encode(), digest_size=16 - ).hexdigest() - - -def analyze_segment( - project_root: Path, - seg: dict[str, Any], - terrain: Any, - streams: list, - criteria: dict[str, Any], - corridor_m: float = PLAN_CORRIDOR_M, -) -> dict[str, Any]: - """연도 구간 하나 · 갈래 넷 — 입력 지문이 저장본과 같으면 그대로(`stored` True).""" - expected = [_point(seg["start"]), _point(seg["end"])] - inputs = _engine_inputs(criteria) - fingerprint = segment_fingerprint(seg, terrain, streams, criteria, corridor_m) - stored = read_segment(project_root, seg) - if stored is not None and stored.get("fingerprint") == fingerprint: - return {**stored, "stored": True} - routes: dict[str, Any] = {} - for mode in PLAN_MODES: - try: - routes[mode] = _route_view( - generate_initial_route( - expected, terrain, streams, mode=mode, corridor_m=corridor_m, **inputs - ) - ) - except ValueError as error: # NoRoute 포함 — 그 갈래만 빔 - routes[mode] = {"error": str(error)} - result = { - "year": int(seg["year"]), - "start": list(expected[0]), - "end": list(expected[1]), - "fingerprint": fingerprint, - "corridor_m": float(corridor_m), - "criteria": { - "max_grade_pct": round(criteria["max_grade"] * 100.0, 2), - "min_radius_m": criteria["min_radius_m"], - "design_speed_kph": criteria["design_speed"], - "terrain_type": TERRAIN_TYPE, - }, - "routes": routes, - } - atomic_write_json(segment_path(project_root, seg), result) - _prune(project_root, seg) - return {**result, "stored": False} - - -def near_streams(terrain: Any, streams: list) -> list: - """지형 상자 안 어느 점에서든 가장 가까운 계류일 수 있는 선만 — 나머지는 엔진 값에 안 닿음. - - 칸마다 닿는 거리 = 칸 가운데 계류 거리 + 칸 반대각(거리는 1-립시츠) — 그 안에 드는 선만 남기면 - 상자 안 점의 계류 거리 · 건너기 · 격자 거리가 통째와 같음(통째 거리 = 추천 시간의 큰 몫). - """ - if not streams: - return streams - x0, x1 = float(np.min(terrain.x)) - STREAM_PAD_M, float(np.max(terrain.x)) + STREAM_PAD_M - y0, y1 = float(np.min(terrain.y)) - STREAM_PAD_M, float(np.max(terrain.y)) + STREAM_PAD_M - xs = np.linspace(x0, x1, max(1, math.ceil((x1 - x0) / STREAM_TILE_M)) + 1) - ys = np.linspace(y0, y1, max(1, math.ceil((y1 - y0) / STREAM_TILE_M)) + 1) - lo_x, lo_y = np.meshgrid(xs[:-1], ys[:-1]) - hi_x, hi_y = np.meshgrid(xs[1:], ys[1:]) - tiles = shapely.box(lo_x.ravel(), lo_y.ravel(), hi_x.ravel(), hi_y.ravel()) - reach = shapely.distance(shapely.centroid(tiles), MultiLineString(streams)) + 0.5 * math.hypot( - xs[1] - xs[0], ys[1] - ys[0] - ) - lines = np.array([LineString(line) for line in streams], dtype=object) - keep = (shapely.distance(lines[:, None], tiles[None, :]) <= reach[None, :]).any(axis=1) - return [line for line, kept in zip(streams, keep) if kept] - - -def _load(project_root: Path, year: int, loader: Loader) -> tuple[Any, list]: - terrain, streams = loader( - project_root, - SELECTION, - models_dir_of(project_root, year), - derived_dir(project_root) / f"cost_{year}", - ) - return terrain, near_streams(terrain, streams) - - -def _segment_job( - project_root: Path, - seg: dict[str, Any], - criteria: dict[str, Any], - corridor_m: float, - loader: Loader, -) -> dict[str, Any]: - """연도 구간 하나 — 지형 읽기부터(제 프로세스면 끝날 때 비용면 · 묶음 메모리를 놓음).""" - terrain, streams = _load(project_root, int(seg["year"]), loader) - return analyze_segment(project_root, seg, terrain, streams, criteria, corridor_m) - - -def _worker_job(*args: Any) -> dict[str, Any]: - """나란히 셈 프로세스 — 구간 하나 뒤 B 기억(비용면 · 묶음)을 비워 다음 구간 메모리를 놓음.""" - try: - return _segment_job(*args) - finally: - _POOLS.clear() - _TERRAINS.clear() - - -def analyze_plan( - project_root: Path, - segments: list[dict[str, Any]], - criteria: dict[str, Any], - *, - loader: Loader = load_terrain_and_streams, - corridor_m: float = PLAN_CORRIDOR_M, - on_progress: Progress | None = None, - workers: int = PLAN_ANALYZE_WORKERS, -) -> list[dict[str, Any]]: - """연도 구간마다(연도 DEM · 연도 비용면 캐시) `analyze_segment`. - - 다시 셀 구간이 둘 이상이면 프로세스 `workers` 개로 나란히 — 구간끼리 입력 · 저장본이 따로라 - 결과 같음. `workers` 1 = 이 프로세스(가짜 지형 시험).""" - - def report(percent: int, message: str) -> None: - if on_progress is not None: - on_progress(percent, message) - - results: list[dict[str, Any] | None] = [None] * len(segments) - todo = [] - for i, seg in enumerate(segments): - stored = read_segment(project_root, seg) - if stored is not None: # 저장본이 있을 때만 이 프로세스에서 지형을 읽어 지문을 봄 - terrain, streams = _load(project_root, int(seg["year"]), loader) - if stored.get("fingerprint") == segment_fingerprint( - seg, terrain, streams, criteria, corridor_m - ): - results[i] = {**stored, "stored": True} - continue - todo.append(i) - - count = min(workers, len(todo)) - if count <= 1: - for n, i in enumerate(todo): - report(100 * n // len(segments), f"{int(segments[i]['year'])}년 구간 노선 추천 중") - results[i] = _segment_job(project_root, segments[i], criteria, corridor_m, loader) - else: - years = "·".join(str(int(segments[i]["year"])) for i in todo) - report(0, f"{years}년 구간 노선 추천 중(나란히 {count})") - context = multiprocessing.get_context("spawn") - with ProcessPoolExecutor(count, mp_context=context) as pool: - futures = { - pool.submit(_worker_job, project_root, segments[i], criteria, corridor_m, loader): i - for i in todo - } - for n, future in enumerate(as_completed(futures), 1): - i = futures[future] - results[i] = future.result() - report( - 100 * n // len(todo), f"{int(segments[i]['year'])}년 구간 끝 ({n}/{len(todo)})" - ) - report(100, "분석 끝") - return results # type: ignore[return-value] - - -def pick_mode(result: dict[str, Any], wanted: str | None) -> str | None: - """고른 갈래 — 없거나 빈 갈래면 차례대로 첫 성공 갈래.""" - routes = result.get("routes") or {} - if wanted in routes and "error" not in routes[wanted]: - return wanted - return next((m for m in PLAN_MODES if m in routes and "error" not in routes[m]), None) - - -def chain( - results: list[dict[str, Any]], choices: dict[int, str | None], start_chainage_m: float -) -> list[dict[str, Any]]: - """연도별 고른 갈래 · 시작 · 끝 누가(이어 셈) — 앞 연도 끝 누가 = 다음 연도 시작 누가.""" - at = float(start_chainage_m or 0.0) - rows = [] - for result in results: - mode = pick_mode(result, choices.get(int(result["year"]))) - length = float(result["routes"][mode]["metrics"]["length_m"]) if mode else 0.0 - rows.append( - { - "year": int(result["year"]), - "mode": mode, - "length_m": round(length, 3), - "start_chainage_m": round(at, 3), - "end_chainage_m": round(at + length, 3), - } - ) - at += length - 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]] = [] - for result, row in zip(results, rows): - if row["mode"] is None: - continue - planned = result["routes"][row["mode"]]["planned"] - line.extend(planned[1:] if line and _same_point(line[-1], planned[0]) else planned) - return line diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 6284663e6..b8923ff97 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -1,29 +1,27 @@ /* ============================================================================= * D03_PlanAnalysis_Api.ts - * D03 분석 화면 서버 요청(PLAN 530-3) — 지점 정본(D02) · 지형 받기 · 분석 · 진행 · 저장본 · [저장] · - * 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄 · 꺾임점 고침은 - * `edits`(마디 · 켬끔 · R — 선은 안 보냄 · 서버가 다시 그음). + * D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 · + * 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점). * ========================================================================== */ 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 { PlanEdit } from "./logic/D03_PlanAnalysis_Edit"; -import type { ChainRow, RouteMetrics, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan"; +import type { PlanLine, PlanPiece, Vertex } from "./logic/D03_PlanAnalysis_Plan"; -/** 저장된 꺾임점 고침 — 입력 + 그때 서버가 잰 값. */ -export type SavedEdit = PlanEdit & { metrics?: RouteMetrics | null }; +export interface BasePoint { + lon: number; + lat: number; + x: number; + y: number; +} -export interface PlanPointsView { - year_start: number; - year_end: number; +/** D02 정본 — 전체 시점 · 종점(작업 좌표계 m + 경위도). */ +export interface PlanBaseView { epsg: number | null; - years: Array<{ - year: number; - start: { x: number; y: number } | null; - end: { x: number; y: number } | null; - }>; + start: BasePoint | null; + end: BasePoint | null; } export interface PlanJob { @@ -33,23 +31,45 @@ export interface PlanJob { error: string | null; } -export interface PlanAnalysisView { - years: Array<{ - year: number; - start: Vertex; - end: Vertex; - analysis: YearAnalysis | null; - /** 저장된 꺾임점 고침(분석 지문이 같을 때만). */ - edit?: SavedEdit | null; - }>; - chain: ChainRow[] | null; - /** 등록 노선 시작 누가(m). */ - start_chainage_m: number; - /** 등록 노선 종료 누가(계획 목표 · m) — 없으면 null. */ - end_chainage_target_m?: number | null; +/** 받기 전 범위(561-2) — 상자(작업 좌표계 m · 경위도 네 귀) · 받은 범위 · 도엽 · 칸 수 어림. */ +export interface TerrainExtent { + epsg: number; + margin_m: number; + box_m: [number, number, number, number]; + box: Array<[number, number]>; + have_m: [number, number, number, number] | null; + have: Array<[number, number]> | null; + covered: boolean; + sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>; + cells: number; } -export type PlanPoints = Array<{ year: number; start: Vertex; end: Vertex }>; +/** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */ +export interface ServerNetwork { + epsg: number; + lines: PlanLine[]; + marks: Array<{ line: string; at_m: number }>; + pieces: PlanPiece[]; +} + +export interface PlanNetworkView { + /** D02 정본 전체 시점 · 종점. */ + base: PlanBaseView; + network: ServerNetwork | null; + derived: { + lines: Array<{ id: string; length_m: number; stations: number[] }>; + pieces: Array; + } | null; + /** 옛 연도 노선을 선으로 바꾼 것인가. */ + legacy: boolean; + /** 사업연도 시작(있으면). */ + year_start?: number | null; +} + +export interface PlanSurface { + modelId: number; + grid: DtmGrid; +} async function request(path: string, init: RequestInit = {}): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -66,62 +86,49 @@ async function request(path: string, init: RequestInit = {}): Promise { } const base = (projectId: string): string => `/projects/${projectId}/plan`; -const pointsBody = (points: PlanPoints | null): string => JSON.stringify(points ? { points } : {}); -export const fetchPlanPoints = (projectId: string): Promise => - request(`${base(projectId)}/points`); - -export const startPlanJob = ( +/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */ +export const fetchTerrainExtent = ( projectId: string, - kind: "terrain" | "analyze", - points: PlanPoints | null, + marginM: number | null, + extra: Vertex[], +): Promise => { + const query = new URLSearchParams(); + if (marginM !== null) query.set("margin_m", String(marginM)); + if (extra.length) query.set("extra", JSON.stringify(extra)); + return request(`${base(projectId)}/terrain/extent?${query}`); +}; + +export const startTerrain = ( + projectId: string, + marginM: number | null, + extra: Vertex[], ): Promise => - request(`${base(projectId)}/${kind}`, { method: "POST", body: pointsBody(points) }); - -export const fetchPlanProgress = ( - projectId: string, -): Promise<{ terrain: PlanJob; analyze: PlanJob }> => request(`${base(projectId)}/progress`); - -export const fetchPlanAnalysis = ( - projectId: string, - points: PlanPoints | null, -): Promise => - request( - `${base(projectId)}/analysis` + - (points ? `?points=${encodeURIComponent(JSON.stringify(points))}` : ""), - ); - -export const savePlanAnalysis = ( - projectId: string, - choices: Record, - points: PlanPoints | null, - edits: Record = {}, -): Promise<{ chain: ChainRow[] }> => - request(`${base(projectId)}/analysis`, { - method: "PUT", - body: JSON.stringify(points ? { choices, points, edits } : { choices, edits }), + request(`${base(projectId)}/terrain`, { + method: "POST", + body: JSON.stringify({ margin_m: marginM, extra }), }); -export interface YearSurface { - modelId: number; - grid: DtmGrid; -} +export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> => + request(`${base(projectId)}/progress`); -/** 연도별 지형면 — 모델 행 경로 `…/models_<연도>/…` · 격자 = 분석과 같은 평활본. */ -export async function fetchYearSurfaces(projectId: string): Promise> { +export const fetchPlanNetwork = (projectId: string): Promise => + request(`${base(projectId)}/network`); + +export const savePlanNetwork = ( + projectId: string, + network: ServerNetwork, +): Promise => + request(`${base(projectId)}/network`, { method: "PUT", body: JSON.stringify(network) }); + +/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…` · 격자 = 평활본 · 없으면 null. */ +export async function fetchPlanSurface(projectId: string): Promise { const { models } = await listSurfaceModels(projectId); - const out = new Map(); - for (const model of models) { - const year = Number(/models_(\d+)\//.exec(model.model_file_path ?? "")?.[1]); - if (!year || out.has(year)) continue; - const stamp = String(model.generation_params?.file_stamp ?? ""); - const url = - `${API_BASE_URL}/projects/${projectId}/surface/models/${model.id}/grid` + - `?smooth=true&sig=${encodeURIComponent(stamp)}`; - out.set(year, { - modelId: model.id, - grid: parseDtmGrid(await fetchCachedBytes(projectId, url)), - }); - } - return out; + const model = models.find((entry) => /models_plan\//.test(entry.model_file_path ?? "")); + if (!model) return null; + const stamp = String(model.generation_params?.file_stamp ?? ""); + const url = + `${API_BASE_URL}/projects/${projectId}/surface/models/${model.id}/grid` + + `?smooth=true&sig=${encodeURIComponent(stamp)}`; + return { modelId: model.id, grid: parseDtmGrid(await fetchCachedBytes(projectId, url)) }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py b/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py new file mode 100644 index 000000000..d30968ecf --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Draw.py @@ -0,0 +1,253 @@ +"""D 예정노선 수동 직선 그리기 셈 — [저장] 정본 쪽 (PLAN 562-1 · 요구 389 12 · 13). + +구간 길이 · 원지반 기울기 · 길이 고정 · 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. +높이는 인자 `height_at(x, y)`(nan = 지형 밖). + +⚠ 파이썬·TS 짝 — `logic/D03_PlanAnalysis_Draw.ts` 같은 이름(camel). 한쪽을 고치면 다른 쪽도 고치고 +거울 시험 `resources/tester/test_562_1_plan_draw.py`. +""" + +from __future__ import annotations + +import math +from collections.abc import Callable, Sequence + +Vertex = tuple[float, float] +HeightAt = Callable[[float, float], float] + +# 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수 — 짝 TS 상수 +SLOPE_STEP_M = 1 +BISECT = 40 +COMPASS_SAMPLES = 360 +EPS = 1e-9 + + +def _finite(value: float) -> bool: + return math.isfinite(value) + + +def segment_length(a: Sequence[float], b: Sequence[float]) -> float: + """수평 길이(m).""" + dx = b[0] - a[0] + dy = b[1] - a[1] + return math.sqrt(dx * dx + dy * dy) + + +def ground_slope_pct(a: Sequence[float], b: Sequence[float], height_at: HeightAt) -> float | None: + """원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · None = 지형 밖 · 길이 0.""" + length = segment_length(a, b) + if length < EPS: + return None + za = height_at(a[0], a[1]) + zb = height_at(b[0], b[1]) + if not _finite(za) or not _finite(zb): + return None + return ((zb - za) / length) * 100 + + +def fixed_length_point( + start: Sequence[float], cursor: Sequence[float], length_m: float +) -> Vertex | None: + """길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 None.""" + d = segment_length(start, cursor) + if d < EPS: + return None + return ( + start[0] + ((cursor[0] - start[0]) / d) * length_m, + start[1] + ((cursor[1] - start[1]) / d) * length_m, + ) + + +def fixed_slope_point( + start: Sequence[float], + cursor: Sequence[float], + slope_pct: float, + height_at: HeightAt, + max_m: float = 1000, +) -> Vertex | None: + """기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) · + `max_m` 안에 없거나 지형 밖에 닿으면 None.""" + d = segment_length(start, cursor) + z0 = height_at(start[0], start[1]) + if d < EPS or not _finite(z0): + return None + ux = (cursor[0] - start[0]) / d + uy = (cursor[1] - start[1]) / d + grade = abs(slope_pct) / 100 + + def at(t: float) -> Vertex: + return (start[0] + ux * t, start[1] + uy * t) + + def f(t: float) -> float: + p = at(t) + return abs(height_at(p[0], p[1]) - z0) - grade * t + + lo = 0.0 + flo = math.nan + i = 1 + while i * SLOPE_STEP_M <= max_m: + t = i * SLOPE_STEP_M + ft = f(t) + if not _finite(ft): + return None + if ft == 0: + return at(t) + if i > 1 and (flo < 0) != (ft < 0): + hi = t + for _ in range(BISECT): + mid = (lo + hi) / 2 + fm = f(mid) + if not _finite(fm): + return None + if (fm < 0) == (flo < 0): + lo = mid + flo = fm + else: + hi = mid + return at((lo + hi) / 2) + lo = t + flo = ft + i += 1 + return None + + +def compass_candidates( + start: Sequence[float], length_m: float, slope_pct: float, height_at: HeightAt +) -> list[Vertex]: + """둘 다(양각기) — 반지름 `length_m` 원 위에서 |기울기| = 목표인 후보들 + (방위 표본 · 이분 · 동쪽부터 반시계).""" + z0 = height_at(start[0], start[1]) + if not length_m > EPS or not _finite(z0): + return [] + rise = (abs(slope_pct) / 100) * length_m + step = (2 * math.pi) / COMPASS_SAMPLES + + def at(angle: float) -> Vertex: + return (start[0] + math.cos(angle) * length_m, start[1] + math.sin(angle) * length_m) + + def h(angle: float) -> float: + p = at(angle) + return abs(height_at(p[0], p[1]) - z0) - rise + + values = [h(k * step) for k in range(COMPASS_SAMPLES)] + out: list[Vertex] = [] + for k in range(COMPASS_SAMPLES): + hk = values[k] + hn = values[(k + 1) % COMPASS_SAMPLES] + if not _finite(hk) or not _finite(hn): + continue + if hk == 0: + out.append(at(k * step)) + continue + if hn == 0 or (hk < 0) == (hn < 0): + continue + lo = k * step + hi = lo + step + flo = hk + ok = True + for _ in range(BISECT): + mid = (lo + hi) / 2 + fm = h(mid) + if not _finite(fm): + ok = False + break + if (fm < 0) == (flo < 0): + lo = mid + flo = fm + else: + hi = mid + if ok: + out.append(at((lo + hi) / 2)) + return out + + +def compass_point( + start: Sequence[float], + cursor: Sequence[float], + length_m: float, + slope_pct: float, + height_at: HeightAt, +) -> Vertex | None: + """둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 None.""" + best: Vertex | None = None + best_d = math.inf + for p in compass_candidates(start, length_m, slope_pct, height_at): + d = segment_length(p, cursor) + if d < best_d: + best = p + best_d = d + return best + + +def line_length(line: Sequence[Sequence[float]]) -> float: + """선 길이(m) — 앞에서부터 더함.""" + total = 0.0 + for i in range(1, len(line)): + total += segment_length(line[i - 1], line[i]) + return total + + +def point_at_distance(line: Sequence[Sequence[float]], m: float) -> Vertex: + """선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상.""" + if m <= 0: + return (line[0][0], line[0][1]) + acc = 0.0 + for i in range(1, len(line)): + a = line[i - 1] + b = line[i] + seg = segment_length(a, b) + if seg > 0 and acc + seg >= m: + t = (m - acc) / seg + return (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) + acc += seg + return (line[-1][0], line[-1][1]) + + +def distance_along(line: Sequence[Sequence[float]], point: Sequence[float]) -> float: + """점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간).""" + best = 0.0 + best_d = math.inf + acc = 0.0 + for i in range(1, len(line)): + a = line[i - 1] + b = line[i] + seg = segment_length(a, b) + t = 0.0 + if seg > 0: + t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / ( + seg * seg + ) + t = min(1.0, max(0.0, t)) + p = (a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t) + d = segment_length(p, point) + if d < best_d: + best_d = d + best = acc + seg * t + acc += seg + return best + + +def slice_line(line: Sequence[Sequence[float]], from_m: float, to_m: float) -> list[Vertex]: + """선 조각 — `from_m`~`to_m` 사이(끝점 + 그 사이 꺾임점).""" + lo = min(from_m, to_m) + hi = max(from_m, to_m) + out = [point_at_distance(line, lo)] + acc = 0.0 + for i in range(1, len(line) - 1): + acc += segment_length(line[i - 1], line[i]) + if lo + EPS < acc < hi - EPS: + out.append((line[i][0], line[i][1])) + out.append(point_at_distance(line, hi)) + return out + + +def station_chainages(length_m: float, interval_m: float = 20) -> list[float]: + """측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터).""" + out: list[float] = [0] + k = 1 + while k * interval_m < length_m - 1e-6: + out.append(k * interval_m) + k += 1 + if length_m > 1e-6: + out.append(length_m) + return out diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Edit.py b/D03_PlanAnalysis/D03_PlanAnalysis_Edit.py deleted file mode 100644 index 33daee3b5..000000000 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Edit.py +++ /dev/null @@ -1,169 +0,0 @@ -"""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 diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Network.py b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py new file mode 100644 index 000000000..cce67c4a2 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Network.py @@ -0,0 +1,436 @@ +"""D 예정노선 2단계 노선 정본 — 손으로 그은 직선 · 갈래 · 구역 +(요구 389 6 · 11 · 13 · 14 · 391 · PLAN 562-2). + +- `GET /api/projects/{id}/plan/network` — 정본(`plan_networks`) + 서버가 다시 센 길이 · + 측점 · 조각 · 저장 없으면 옛 연도 노선(`plan_route_years.route_json.planned`)을 한 선으로 바꿔 + 보임(legacy) +- `PUT …/plan/network` — [저장] · [초기화](빈 선) — 선 · 마크 · 조각 연도만 받아 서버가 정본으로 + 다시 셈 → `plan_networks` · 조각마다 한 줄(`plan_route_pieces`) · 연도 행(`plan_route_years`) · + 사업연도 끝 + +길이 · 측점 · 높이는 저장하지 않음(늘 다시 셈 · 높이는 지형에서). 측점 = 선마다 No.0 부터. +조각 = 선의 마크 사이 구간(구역 × 갈래) · 업체 지정은 조각 단위(`plan_route_assignees.piece_id`). +전체 시점 · 종점(`base`) = D02 정본(`plan_base.load_base`) — 비어 옛 연도 행을 접어 보이던 것은 +[저장] 전에 칸에 굳힘(연도 행이 바뀌어도 그대로). +계산 자리 = ① 파이썬·TS 짝(`D03_PlanAnalysis_Draw`) — 화면은 그리는 즉시 · [저장]은 여기서 다시 셈. +""" + +from __future__ import annotations + +import hashlib +import json +import math +from typing import Any +from uuid import UUID + +import aiomysql +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field + +from common_util.common_util_auth import is_forest_service, require_company +from config.config_db import get_db_pool +from D02_PlanBase import D02_PlanBase_Router as plan_base +from D03_PlanAnalysis import D03_PlanAnalysis_Draw as draw + +router = APIRouter(prefix="/api/projects", tags=["D03 Plan Network"]) + +ID = r"^[A-Za-z0-9_-]{1,64}$" +SAME_M = 0.001 # 1 mm 안 = 같은 점 · 같은 마크 +PIECE_TOL_M = 0.01 # 조각 끝 = 마크 경계(1 cm) +STATION_M = 20.0 # 측점 간격(지식DB 설계제원_총괄) + + +class Branch(BaseModel): + line: str = Field(pattern=ID) + vertex: int = Field(ge=0) + + +class Line(BaseModel): + id: str = Field(pattern=ID) + vertices: list[tuple[float, float]] = Field(min_length=1) + branch: Branch | None = None + + +class Mark(BaseModel): + line: str = Field(pattern=ID) + at_m: float + + +class Piece(BaseModel): + id: str = Field(pattern=ID) + line: str = Field(pattern=ID) + from_m: float + to_m: float + year: int + + +class Network(BaseModel): + epsg: int + lines: list[Line] = [] + marks: list[Mark] = [] + pieces: list[Piece] = [] + + +class NetworkError(ValueError): + pass + + +def _check(ok: bool, message: str) -> None: + if not ok: + raise NetworkError(message) + + +def derive(network: dict[str, Any], year_from: int | None = None) -> dict[str, Any]: + """선 · 마크 · 조각 → 선 길이 · 측점 · 조각(경계 = 마크 · 꺾은선 · 측점) · + 틀리면 `NetworkError`. + + `year_from` = 사업연도 시작(그 앞 연도 조각 거절 · None = 안 봄).""" + lines: dict[str, list] = {} + for line in network["lines"]: + vertices = line["vertices"] + _check(line["id"] not in lines, f"선 id 가 겹칩니다: {line['id']}") + _check( + all(math.isfinite(v) for point in vertices for v in point), + f"선 {line['id']} 점이 수가 아닙니다.", + ) + branch = line.get("branch") + if branch: + parent = lines.get(branch["line"]) + _check(parent is not None, f"갈래 {line['id']} 의 부모 선이 앞에 없습니다.") + _check(branch["vertex"] < len(parent), f"갈래 {line['id']} 의 부모 점이 없습니다.") + _check( + draw.segment_length(vertices[0], parent[branch["vertex"]]) <= SAME_M, + f"갈래 {line['id']} 첫 점이 부모 선 점과 다릅니다.", + ) + lines[line["id"]] = vertices + lengths = {key: draw.line_length(vertices) for key, vertices in lines.items()} + cuts: dict[str, list[float]] = {key: [] for key in lines} + for mark in network["marks"]: + _check(mark["line"] in lines, f"마크의 선이 없습니다: {mark['line']}") + at = mark["at_m"] + _check( + math.isfinite(at) and SAME_M < at < lengths[mark["line"]] - SAME_M, + f"마크가 선 {mark['line']} 밖입니다: {at}", + ) + cuts[mark["line"]].append(at) + tiles: dict[str, list[tuple[float, float]]] = {} + for key, marks in cuts.items(): + edges = [0.0, *sorted(marks), lengths[key]] + _check( + all(b - a > SAME_M for a, b in zip(edges[1:-1], edges[2:-1])), + f"선 {key} 마크가 겹칩니다.", + ) + tiles[key] = list(zip(edges, edges[1:])) if lengths[key] > SAME_M else [] + order = {key: i for i, key in enumerate(lines)} + stations = {key: draw.station_chainages(lengths[key], STATION_M) for key in lines} + used: set[tuple[str, int]] = set() + pieces = [] + for piece in network["pieces"]: + key = piece["line"] + _check(key in lines, f"조각 {piece['id']} 의 선이 없습니다.") + _check(year_from is None or piece["year"] >= year_from, f"조각 연도가 사업연도 앞: {piece}") + hit = next( + ( + i + for i, (a, b) in enumerate(tiles[key]) + if abs(a - piece["from_m"]) <= PIECE_TOL_M and abs(b - piece["to_m"]) <= PIECE_TOL_M + ), + None, + ) + _check(hit is not None, f"조각 {piece['id']} 끝이 마크 경계와 다릅니다.") + _check((key, hit) not in used, f"조각이 겹칩니다: {piece['id']}") + used.add((key, hit)) + a, b = tiles[key][hit] + pieces.append( + { + "id": piece["id"], + "line": key, + "year": int(piece["year"]), + "from_m": a, + "to_m": b, + "length_m": b - a, + "planned": [list(p) for p in draw.slice_line(lines[key], a, b)], + "stations": sorted( + {a, b, *(c for c in stations[key] if a - 1e-6 <= c <= b + 1e-6)} + ), + } + ) + _check(len({p["id"] for p in pieces}) == len(pieces), "조각 id 가 겹칩니다.") + pieces.sort(key=lambda p: (order[p["line"]], p["from_m"])) + return { + "lines": [ + {"id": key, "length_m": lengths[key], "stations": stations[key]} for key in lines + ], + "pieces": pieces, + } + + +def year_rows(pieces: list[dict[str, Any]]) -> dict[int, dict[str, Any]]: + """조각 → 연도 행(`plan_route_years`) — `planned` = 첫 조각(옛 읽는 곳 그대로) · + `pieces` = 그 연도 전부.""" + by_year: dict[int, list[dict[str, Any]]] = {} + for piece in pieces: + by_year.setdefault(piece["year"], []).append(piece) + out = {} + for year, items in sorted(by_year.items()): + first = items[0] + parts = [ + { + "id": p["id"], + "line": p["line"], + "planned": p["planned"], + "start_chainage_m": p["from_m"], + "end_chainage_m": p["to_m"], + "length_m": p["length_m"], + } + for p in items + ] + route = { + "mode": "manual", + "planned": first["planned"], + "start_chainage_m": first["from_m"], + "end_chainage_m": first["to_m"], + "length_m": sum(p["length_m"] for p in items), + "pieces": parts, + } + out[year] = { + "route": route, + "fingerprint": _sha1(parts), + "start": first["planned"][0], + "end": first["planned"][-1], + } + return out + + +def _sha1(value: Any) -> str: + return hashlib.sha1(json.dumps(value, sort_keys=True).encode()).hexdigest() # noqa: S324 + + +def _json(value: Any) -> Any: + if isinstance(value, (str, bytes)): + try: + return json.loads(value) + except ValueError: + return None + return value + + +def legacy_network(rows: list[dict[str, Any]]) -> dict[str, Any] | None: + """옛 연도 노선(연도 차례 · 직전 종점 = 다음 시점) → 본선 하나 + 연도 경계 마크 + 연도 조각.""" + vertices: list[list[float]] = [] + pieces: list[dict[str, Any]] = [] + epsg = None + for row in rows: + route = _json(row.get("route_json")) + planned = route.get("planned") if isinstance(route, dict) else None + if not planned or len(planned) < 2: + continue + epsg = epsg or row.get("epsg") + points = [[float(p[0]), float(p[1])] for p in planned] + if vertices and draw.segment_length(vertices[-1], points[0]) <= SAME_M: + points = points[1:] + start = draw.line_length(vertices) + vertices.extend(points) + end = draw.line_length(vertices) + pieces.append( + { + "id": f"y{row['year']}", + "line": "main", + "from_m": start, + "to_m": end, + "year": row["year"], + } + ) + if not pieces: + return None + return { + "epsg": int(epsg) if epsg is not None else None, + "lines": [{"id": "main", "vertices": vertices, "branch": None}], + "marks": [{"line": "main", "at_m": p["to_m"]} for p in pieces[:-1]], + "pieces": pieces, + } + + +async def _load(connection: Any, project_id: UUID) -> tuple[dict[str, Any] | None, bool]: + """(정본 · 옛 연도 노선을 바꾼 것인가) — 둘 다 없으면 (None, False).""" + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT network_json FROM plan_networks WHERE project_id = %s", (str(project_id),) + ) + row = await cursor.fetchone() + if row: + return _json(row["network_json"]), False + await cursor.execute( + "SELECT year, epsg, route_json FROM plan_route_years" + " WHERE project_id = %s ORDER BY year", + (str(project_id),), + ) + legacy = legacy_network(list(await cursor.fetchall())) + return legacy, legacy is not None + + +def _view( + network: dict[str, Any] | None, legacy: bool, base: dict[str, Any], year_start: int | None +) -> dict[str, Any]: + return { + "status": "success", + "base": base, + "year_start": year_start, + "network": network, + "derived": derive(network) if network else None, + "legacy": legacy, + } + + +@router.get("/{project_id}/plan/network", response_model=None) +async def get_plan_network(project_id: UUID) -> dict[str, Any]: + base = await plan_base.load_base(project_id) + async with get_db_pool().acquire() as connection: + network, legacy = await _load(connection, project_id) + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT project_year FROM projects WHERE id = %s", (str(project_id),) + ) + row = await cursor.fetchone() + try: + return _view(network, legacy, base, row["project_year"] if row else None) + except NetworkError as error: + raise HTTPException(status_code=409, detail=f"저장된 노선이 틀립니다 — {error}") from error + + +async def _store( + connection: Any, project_id: UUID, network: dict[str, Any], derived: dict[str, Any] +) -> None: + pid = str(project_id) + epsg = network["epsg"] + years = year_rows(derived["pieces"]) + async with connection.cursor() as cursor: + await cursor.execute( + "SELECT DISTINCT piece_id FROM plan_route_assignees" + " WHERE project_id = %s AND piece_id IS NOT NULL", + (pid,), + ) + assigned = {r[0] for r in await cursor.fetchall()} + await cursor.execute( + "SELECT DISTINCT year FROM plan_route_assignees" + " WHERE project_id = %s AND piece_id IS NULL", + (pid,), + ) + assigned_years = {int(r[0]) for r in await cursor.fetchall()} + lost = sorted(assigned - {p["id"] for p in derived["pieces"]}) + lost_years = sorted(assigned_years - set(years)) + if lost or lost_years: + raise HTTPException( + status_code=409, + detail=f"업체가 지정된 조각 · 연도를 지울 수 없습니다: {lost + lost_years}", + ) + await cursor.execute( + "INSERT INTO plan_networks (project_id, network_json) VALUES (%s, %s)" + " ON DUPLICATE KEY UPDATE network_json = VALUES(network_json)", + (pid, json.dumps(network, ensure_ascii=False)), + ) + await cursor.execute("DELETE FROM plan_route_pieces WHERE project_id = %s", (pid,)) + for p in derived["pieces"]: + route = {"planned": p["planned"], "stations": p["stations"]} + await cursor.execute( + "INSERT INTO plan_route_pieces (project_id, piece_id, line_id, year, from_m, to_m," + " length_m, epsg, route_json, fingerprint)" + " VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", + ( + pid, + p["id"], + p["line"], + p["year"], + p["from_m"], + p["to_m"], + p["length_m"], + epsg, + json.dumps(route), + _sha1(route), + ), + ) + for year, row in years.items(): + # 내용이 바뀐 연도만 확정 풂(confirmed_at 을 fingerprint 보다 먼저 셈) + await cursor.execute( + "INSERT INTO plan_route_years (project_id, year, start_x, start_y, end_x, end_y," + " epsg, route_json, fingerprint) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)" + " ON DUPLICATE KEY UPDATE start_x = VALUES(start_x), start_y = VALUES(start_y)," + " end_x = VALUES(end_x), end_y = VALUES(end_y), epsg = VALUES(epsg)," + " route_json = VALUES(route_json)," + " confirmed_at = IF(fingerprint <=> VALUES(fingerprint), confirmed_at, NULL)," + " fingerprint = VALUES(fingerprint)", + ( + pid, + year, + *row["start"], + *row["end"], + epsg, + json.dumps(row["route"], ensure_ascii=False), + row["fingerprint"], + ), + ) + if years: + marks = ", ".join(["%s"] * len(years)) + await cursor.execute( + "UPDATE plan_route_years SET route_json = NULL, fingerprint = NULL," + f" confirmed_at = NULL WHERE project_id = %s AND year NOT IN ({marks})", + (pid, *years), + ) + await cursor.execute( + "UPDATE projects SET project_year_end = %s WHERE id = %s", (max(years), pid) + ) + else: + await cursor.execute( + "UPDATE plan_route_years SET route_json = NULL, fingerprint = NULL," + " confirmed_at = NULL WHERE project_id = %s", + (pid,), + ) + + +async def _freeze_base(connection: Any, project_id: UUID, base: dict[str, Any]) -> None: + """옛 연도 행을 접은 시점 · 종점 → D02 정본 칸(비었을 때만).""" + stored = { + "epsg": base["epsg"], + **{key: {"x": base[key]["x"], "y": base[key]["y"]} for key in ("start", "end")}, + } + async with connection.cursor() as cursor: + await cursor.execute( + "UPDATE projects SET plan_base_json = %s WHERE id = %s AND plan_base_json IS NULL", + (json.dumps(stored), str(project_id)), + ) + + +@router.put("/{project_id}/plan/network", response_model=None) +async def put_plan_network( + project_id: UUID, + payload: Network, + session: dict[str, Any] = Depends(require_company), +) -> dict[str, Any]: + """[저장] · [초기화](lines 빈 것) — 서버가 정본으로 다시 셈 → 정본 · 조각 행 · 연도 행.""" + if not is_forest_service(session): + raise HTTPException(status_code=403, detail="예정노선은 산림청만 고칠 수 있습니다.") + network = payload.model_dump() + base = await plan_base.load_base(project_id) # 예정노선 프로젝트가 아니면 404 · 400 + if base["epsg"] is not None and int(base["epsg"]) != network["epsg"]: + raise HTTPException(status_code=400, detail="좌표계가 기준 지정과 다릅니다.") + async with get_db_pool().acquire() as connection: + async with connection.cursor(aiomysql.DictCursor) as cursor: + await cursor.execute( + "SELECT project_year, plan_base_json FROM projects WHERE id = %s", + (str(project_id),), + ) + project = await cursor.fetchone() + try: + derived = derive(network, project["project_year"]) + except NetworkError as error: + raise HTTPException(status_code=400, detail=str(error)) from error + await connection.begin() + try: + if project["plan_base_json"] is None and base["start"] and base["end"]: + await _freeze_base(connection, project_id, base) + await _store(connection, project_id, network, derived) + await connection.commit() + except Exception: + await connection.rollback() + raise + return {**_view(network, False, base, project["project_year"]), "derived": derived} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 94fbe4563..c4a4be961 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -1,17 +1,12 @@ -"""D 예정노선 2단계 분석 API (요구 345 · PLAN 530-1 · 530-2). +"""D 예정노선 2단계 지형 API (요구 345 · 389 · PLAN 530-1 · 561-2). -- `POST /api/projects/{id}/plan/terrain` — 지형 받기(뒤에서) · 끝나면 연도별 `surface_models` 행 -- `POST …/plan/analyze` — 연도 구간마다 노선 추천 갈래 넷(뒤에서 · 파생 저장본) -- `GET …/plan/progress` — 두 일의 진행률 -- `GET …/plan/analysis` — 저장본 + 고른 갈래 · 이은 누가 · 이은 중심선 -- `PUT …/plan/analysis` — [저장] 연도별 고른 갈래(+ 꺾임점 고침) → `plan_route_years.route_json` · - `fingerprint` +- `GET /api/projects/{id}/plan/terrain/extent` — 받기 전 범위(상자 · 도엽 · 칸 수 · 받은 범위) +- `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행 +- `GET …/plan/progress` — 일의 진행률 -지점 정본은 `plan_route_years` 시점 · 종점 칸(529-3 몫). D03 화면에서 끈 지점(캐시 · PLAN 530-3)은 -`points`(작업 좌표계 m)로 받아 그 지점으로 받기 · 분석 · 보기 · [저장] 때만 D02 규칙으로 정본에 씀. -계산 자리 = ③ 서버 단독 — [저장]은 노선을 받지 않고 갈래 이름만 받아 서버 저장본으로 다시 셈. -꺾임점 고침(543)은 마디 · 켬끔 · R 만 받아 서버가 선 · 지표를 다시 셈(`D03_PlanAnalysis_Edit`) · -GET 은 분석 지문이 같은 저장 고침만 돌려줌. +범위 = 전체 시점 · 종점(D02 정본 `load_base`) + 그은 점(`extra`) 상자 + 여유. +노선 정본(손 그은 선)은 +`D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음). """ from __future__ import annotations @@ -23,18 +18,13 @@ from pathlib import Path from typing import Any from uuid import UUID -import aiomysql -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, HTTPException from pydantic import BaseModel from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from common_util.common_util_auth import is_forest_service, require_company -from common_util.common_util_crs import tm_to_lonlat 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__) @@ -46,98 +36,35 @@ _TASKS: set[asyncio.Task] = set() KINDS = ("terrain", "analyze") -class PlanPoint(BaseModel): - """끈 지점(작업 좌표계 m) — 연도 하나의 시점 · 종점.""" +class TerrainBody(BaseModel): + """지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m).""" - year: int - start: tuple[float, float] - end: tuple[float, float] + margin_m: float | None = None + extra: list[tuple[float, float]] = [] -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]]: - """연도 구간 지점을 끈 지점으로 — 연도 같음 · 직전 종점 = 다음 시점(1 mm) 아니면 거절.""" - by_year = {p.year: p for p in points} - 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), - "saved": (seg["start"], seg["end"]), # 저장 지점 — 그 분석본은 쌓임 거르기에서 남김 - } - for seg in segments - ] - for prev, seg in zip(out, out[1:]): - if max(abs(a - b) for a, b in zip(prev["end"], seg["start"])) > base.SAME_M: - raise HTTPException( - status_code=400, detail=f"{seg['year']}년 시점이 직전 종점과 다릅니다." - ) - return out - - -async def _plan_inputs( - project_id: UUID, points: list[PlanPoint] | None = None -) -> tuple[Path, dict[str, Any], list[dict[str, Any]]]: - """(실경로, 프로젝트 칸, 연도 구간) — 빈 연도 · 좌표계 섞임 거절 · `points` = 끈 지점.""" +async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]: + """(실경로, 좌표계, [전체 시점, 전체 종점]) — D02 정본(빈 칸이면 옛 연도 행 접음).""" + plan = await base.load_base(project_id) + if not plan["start"] or not plan["end"]: + raise HTTPException( + status_code=400, detail="전체 시점 · 종점이 없습니다 — 1단계에서 지정하세요." + ) async with get_db_pool().acquire() as connection: stored = await get_project_storage_relative_path(connection, project_id) - async with connection.cursor(aiomysql.DictCursor) as cursor: - await cursor.execute( - "SELECT road_type, design_speed_kph, route_start_m, route_end_m FROM projects" - " WHERE id = %s", - (str(project_id),), - ) - project = await cursor.fetchone() - await cursor.execute( - "SELECT year, start_x, start_y, end_x, end_y, epsg, route_json, fingerprint " - "FROM plan_route_years WHERE project_id = %s ORDER BY year", - (str(project_id),), - ) - rows = await cursor.fetchall() - if not stored or not project: + if not stored: raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.") - if not rows: - raise HTTPException( - status_code=400, detail="연도별 지점이 없습니다 — 1단계에서 지정하세요." - ) - for row in rows: - if any(row[k] is None for k in ("start_x", "start_y", "end_x", "end_y", "epsg")): - raise HTTPException(status_code=400, detail=f"{row['year']}년 지점이 비었습니다.") - if len({int(row["epsg"]) for row in rows}) != 1: - raise HTTPException(status_code=400, detail="연도마다 좌표계가 다릅니다.") - segments = [ - { - "year": int(row["year"]), - "start": (float(row["start_x"]), float(row["start_y"])), - "end": (float(row["end_x"]), float(row["end_y"])), - "epsg": int(row["epsg"]), - "route_json": row["route_json"], - } - for row in rows - ] - if points: - segments = _override(segments, points) - return Path(resolve_stored_project_path(stored)), project, segments + ends = [(float(p["x"]), float(p["y"])) for p in (plan["start"], plan["end"])] + return Path(resolve_stored_project_path(stored)), int(plan["epsg"]), ends + + +def _query_xy(extra: str | None) -> list[tuple[float, float]]: + if not extra: + return [] + try: + return [(float(x), float(y)) for x, y in json.loads(extra)] + except (ValueError, TypeError) as error: + raise HTTPException(status_code=400, detail="extra 꼴이 다릅니다.") from error def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]: @@ -166,62 +93,59 @@ def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]: return {"status": "accepted", **job} -async def _save_models(project_id: UUID, years: dict[int, list[dict[str, Any]]], epsg: int) -> None: - """연도별 지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로).""" +async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int) -> None: + """지형면 → `surface_models` 행(같은 파일 · 같은 지문이면 id 살림 · 다른 행 그대로).""" from B04_PreProcess.B04_PreProcess_Repository import _existing_models, _upsert_surface_model async with get_db_pool().acquire() as connection: await connection.begin() try: existing = await _existing_models(connection, project_id) - for models in years.values(): - for model in models: - await _upsert_surface_model( - connection, - existing, - project_id=project_id, - model=model, - source_file_id=None, - processed_cloud_id=None, - crs_epsg=epsg, - ) + for model in models: + await _upsert_surface_model( + connection, + existing, + project_id=project_id, + model=model, + source_file_id=None, + processed_cloud_id=None, + crs_epsg=epsg, + ) await connection.commit() except Exception: await connection.rollback() raise +@router.get("/{project_id}/plan/terrain/extent", response_model=None) +async def plan_terrain_extent( + project_id: UUID, margin_m: float | None = None, extra: str | None = None +) -> dict[str, Any]: + """받기 전 범위 — 전체 시점 · 종점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위.""" + root, epsg, ends = await _plan_ends(project_id) + view = await asyncio.to_thread( + terrain.plan_extent, root, [*ends, *_query_xy(extra)], epsg, margin_m + ) + return {"status": "success", **view} + + @router.post("/{project_id}/plan/terrain", response_model=None) -async def plan_terrain(project_id: UUID, body: PointsBody | None = None) -> dict[str, Any]: - """지점 기준 수치지형도 · 위성 · 연도별 DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`.""" - root, _, segments = await _plan_inputs(project_id, body.points if body else None) - epsg = segments[0]["epsg"] +async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dict[str, Any]: + """사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`.""" + body = body or TerrainBody() + root, epsg, ends = await _plan_ends(project_id) + points = [*ends, *body.extra] async def work(report: Any) -> None: - got = await asyncio.to_thread(terrain.ensure_plan_terrain, root, segments, epsg, report) - await _save_models(project_id, got["years"], epsg) + got = await asyncio.to_thread( + terrain.ensure_plan_terrain, root, points, epsg, body.margin_m, report + ) + await _save_models(project_id, got["models"], epsg) report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매") return _start_job(project_id, "terrain", work) -@router.post("/{project_id}/plan/analyze", response_model=None) -async def plan_analyze(project_id: UUID, body: PointsBody | None = None) -> dict[str, Any]: - """연도 구간마다 노선 추천 갈래 넷(뒤에서 · 같은 입력이면 저장본).""" - root, project, segments = await _plan_inputs(project_id, body.points if body else None) - criteria = analyze.plan_criteria(project["road_type"], project["design_speed_kph"]) - missing = [s["year"] for s in segments if not terrain.models_dir_of(root, s["year"]).is_dir()] - if missing: - raise HTTPException(status_code=409, detail=f"지형을 먼저 받으세요: {missing}년") - - async def work(report: Any) -> None: - await asyncio.to_thread( - lambda: analyze.analyze_plan(root, segments, criteria, on_progress=report) - ) - - return _start_job(project_id, "analyze", work) - - @router.get("/{project_id}/plan/progress", response_model=None) async def plan_progress(project_id: UUID) -> dict[str, Any]: idle = {"running": False, "percent": 0, "message": None, "error": None} @@ -229,187 +153,3 @@ async def plan_progress(project_id: UUID) -> dict[str, Any]: "status": "success", **{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS}, } - - -def _saved_mode(route_json: Any) -> str | None: - try: - value = json.loads(route_json) if isinstance(route_json, str) else route_json - except ValueError: - return None - return value.get("mode") if isinstance(value, dict) else None - - -def _assemble( - 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 · 빈 선. - - `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, [] - 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: - if not points: - return None - try: - return [PlanPoint(**item) for item in json.loads(points)] - except (ValueError, TypeError) as error: - raise HTTPException(status_code=400, detail="points 꼴이 다릅니다.") from error - - -@router.get("/{project_id}/plan/analysis", response_model=None) -async def plan_analysis(project_id: UUID, points: str | None = None) -> dict[str, Any]: - """분석 저장본(지점이 지금과 같은 것만) + 고른 갈래(저장값 · 없으면 첫 성공 갈래). - - `points` = 끈 지점 JSON(`[{year, start, end}]` · 작업 좌표계 m) — 그 지점의 저장본을 봄. - """ - root, project, segments = await _plan_inputs(project_id, _query_points(points)) - choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments} - 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, - # 저장된 꺾임점 고침(입력 + 그때 지표) — 분석 지문이 같을 때만 - "edit": _edit_view(saved[seg["year"]]) if seg["year"] in saved else None, - } - for seg, result in zip(segments, results) - ], - "chain": rows, - "line": line, - "start_chainage_m": float(project["route_start_m"] or 0.0), - # 등록 목표 끝 누가(요구서 3-1) — 화면이 이은 끝 누가와 차이를 보임 - "end_chainage_target_m": ( - float(project["route_end_m"]) if project["route_end_m"] is not None else None - ), - } - - -async def _store_points(project_id: UUID, segments: list[dict[str, Any]]) -> None: - """끈 지점 → D02 정본 규칙(경위도 · 잇기 · 바뀐 연도만 · 그 연도 노선 비움).""" - epsg = segments[0]["epsg"] - - def lonlat(xy: tuple[float, float]) -> base.LonLat: - lon, lat = tm_to_lonlat(xy[0], xy[1], epsg) - return base.LonLat(lon=lon, lat=lat) - - project, old = await base._load(project_id) - start = int(project["project_year"]) - end = int(project["project_year_end"] or start) - items = [ - base.YearPoints(year=s["year"], start=lonlat(s["start"]), end=lonlat(s["end"])) - for s in segments - ] - new_epsg, rows = base.with_tm(base.chain_years(start, end, items)) - if new_epsg != epsg: - raise HTTPException( - status_code=409, - detail="끈 지점이 다른 좌표계 띠로 넘어갔습니다 — 1단계에서 지정하세요.", - ) - writes = [row for row in rows if base.changed(row, old.get(row["year"]), epsg)] - if writes: - await base._store(project_id, epsg, writes, []) - - -@router.put("/{project_id}/plan/analysis", response_model=None) -async def save_plan_analysis( - project_id: UUID, - request: ChoiceRequest, - session: dict[str, Any] = Depends(require_company), -) -> dict[str, Any]: - """[저장] — 끈 지점(있으면 정본으로) · 연도별 고른 갈래를 서버 저장본으로 다시 이어 씀.""" - 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] - if bad: - raise HTTPException(status_code=400, detail=f"모르는 갈래: {bad}") - choices = {seg["year"]: _saved_mode(seg["route_json"]) for seg in segments} - choices.update(request.choices) - results, rows, _ = await asyncio.to_thread(_assemble, root, project, segments, choices) - if rows is None: - raise HTTPException( - status_code=409, detail="분석 저장본이 없습니다 — [분석]을 먼저 하세요." - ) - 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: - await connection.begin() - try: - async with connection.cursor() as cursor: - for result, row in zip(results, rows): - 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", - ( - json.dumps(body, ensure_ascii=False), - result["fingerprint"], - str(project_id), - row["year"], - ), - ) - await connection.commit() - except Exception: - await connection.rollback() - raise - return {"status": "success", "chain": rows} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index 7d3be6c57..6f9681907 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -1,12 +1,14 @@ -"""D 예정노선 지형 받기 — 지점 기준 수치지형도 · 위성 · 연도 구간별 DEM (요구 345 · PLAN 530-1). +"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · PLAN 530-1 · 561-2). 지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0): -- 도엽 = 모든 지점 + 구간 표본(`SAMPLE_STEP_M`) 각 3×3 → `ensure_sheets`(없는 것만 · prune 없음) +- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) +- 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음) - 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀) -- 위성 = 기준 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀) -- DEM = 연도 구간마다 `models_<연도>/`(긴 노선 메모리 · 비용면 한도) · 범위 = 구간 상자 + 탐색 복도 +- 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀) +- DEM = 한 벌 `models_plan/`(연도는 D03 나눔 뒤에 정해짐) · 받은 범위 = `plan_terrain.json` - 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음) +- 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만 계산 자리 = ③ 서버 단독. DB 행(`surface_models`)은 라우터가 씀. """ @@ -22,11 +24,11 @@ from typing import Any, Callable import numpy as np from B04_PreProcess.B04_PreProcess_Engine_MapSheet import ( - neighbors_for_points, + SHEET5K_SIZE_DEG, sheet5k_to_bounds, sheets_for_points, ) -from B04_PreProcess.B04_PreProcess_Engine_SheetParser import parse_and_merge_sheets +from B04_PreProcess.B04_PreProcess_Engine_SheetParser import PARSER_VERSION, parse_and_merge_sheets from B04_PreProcess.B04_PreProcess_Engine_SheetStore import ( ensure_sheets, get_project_map_sheets_dir, @@ -35,50 +37,119 @@ from B04_PreProcess.B04_PreProcess_Engine_SheetStore import ( from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import build_sheet_surface_model from B04_PreProcess.B04_PreProcess_Engine_VWorld import download_vworld_satellite_map from common_util.common_util_json import atomic_write_json -from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD, SHEET_SURFACE_MARGIN_M +from config.config_system_terrain import ( + SHEET_SURFACE_DEFAULT_METHOD, + SHEET_SURFACE_GRID_M, + SHEET_SURFACE_MARGIN_M, +) logger = logging.getLogger(__name__) -# 구간 표본 간격 — 도엽(≈2.8 × 2.3 km) 3×3 묶음이 끊기지 않게 -SAMPLE_STEP_M = 1000.0 -# 탐색 복도 반폭(D 만 · B 기본 100 m) — 실측(530-2): 100 · 200 m 는 못 지킨 곳 남음 · 300 m 0~1 -PLAN_CORRIDOR_M = 300.0 -# DEM 이 복도 밖으로 더 덮는 폭(복도 끝 칸 기울기 · 횡단 표본 ±20 m) -DEM_EXTRA_M = 50.0 +# 여유 처음 값 = 옛 연도 구간 DEM 폭(탐색 복도 300 m + 횡단 표본 50 m) · 바닥 = B 절취 여유 +DEFAULT_MARGIN_M = 350.0 +MAX_MARGIN_M = 5000.0 STAGE = Path("B04_PreProcess") +PLAN_MODELS = "models_plan" +TERRAIN_META = "plan_terrain.json" PRJ_NAME = "plan.prj" Progress = Callable[[int, str], None] +Box = tuple[float, float, float, float] # x0, y0, x1, y1(작업 좌표계 m) +XY = tuple[float, float] -def models_dir_of(project_root: Path, year: int) -> Path: - """연도 구간 DEM 자리.""" - return project_root / STAGE / f"models_{int(year)}" +def models_dir_of(project_root: Path) -> Path: + """사업 전체 DEM 자리.""" + return project_root / STAGE / PLAN_MODELS -def segment_samples( - start: tuple[float, float], end: tuple[float, float] -) -> list[tuple[float, float]]: - """구간 위 표본(끝점 둘 포함 · `SAMPLE_STEP_M` 간격 이하).""" - length = math.hypot(end[0] - start[0], end[1] - start[1]) - count = max(1, math.ceil(length / SAMPLE_STEP_M)) - return [ - (start[0] + (end[0] - start[0]) * i / count, start[1] + (end[1] - start[1]) * i / count) - for i in range(count + 1) +def clamp_margin(margin_m: float | None) -> float: + """여유 — 빈 값 = 처음 값 · B 절취 여유 ~ 5 km.""" + if margin_m is None or not math.isfinite(margin_m): + return DEFAULT_MARGIN_M + return min(MAX_MARGIN_M, max(SHEET_SURFACE_MARGIN_M, float(margin_m))) + + +def received_box(project_root: Path) -> Box | None: + """받은 DEM 범위 — 받은 적 없거나 읽지 못하면 None.""" + meta = _read_json(models_dir_of(project_root) / TERRAIN_META) or {} + try: + x0, y0, x1, y1 = (float(v) for v in meta["box"]) + except (KeyError, TypeError, ValueError): + return None + return x0, y0, x1, y1 + + +def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box: + """받을 범위 = 점 상자 + 여유 ∪ 받은 범위.""" + xs, ys = [p[0] for p in points], [p[1] for p in points] + box = (min(xs) - margin_m, min(ys) - margin_m, max(xs) + margin_m, max(ys) + margin_m) + if have is None: + return box + return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3]) + + +def covers(have: Box | None, box: Box) -> bool: + return ( + have is not None + and all(have[i] <= box[i] for i in (0, 1)) + and all(have[i] >= box[i] for i in (2, 3)) + ) + + +def box_ring(box: Box, epsg: int) -> list[list[float]]: + """상자 네 귀 경위도([경도, 위도] · 왼아래부터 반시계).""" + from common_util.common_util_crs import tm_to_lonlat + + x0, y0, x1, y1 = box + return [list(tm_to_lonlat(x, y, epsg)) for x, y in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))] + + +def sheets_for_box(box: Box, epsg: int) -> list[str]: + """상자에 닿는 도엽 — 경위도 외접 상자를 도엽 반 칸 간격으로 짚음.""" + ring = box_ring(box, epsg) + lons, lats = [p[0] for p in ring], [p[1] for p in ring] + step = SHEET5K_SIZE_DEG / 2 + + def ticks(lo: float, hi: float) -> list[float]: + return [lo + step * i for i in range(int((hi - lo) / step) + 1)] + [hi] + + grid = [ + (lat, lon) for lat in ticks(min(lats), max(lats)) for lon in ticks(min(lons), max(lons)) ] + return sorted(set(sheets_for_points(grid))) -def reference_points(segments: list[dict[str, Any]], epsg: int) -> list[tuple[float, float]]: - """도엽 기준 좌표(위도, 경도) — 모든 지점 + 구간 표본.""" - from pyproj import Transformer - - to_wgs = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True) - points: list[tuple[float, float]] = [] - for seg in segments: - for x, y in segment_samples(seg["start"], seg["end"]): - lon, lat = to_wgs.transform(x, y) - points.append((lat, lon)) - return points +def plan_extent( + project_root: Path, points: list[XY], epsg: int, margin_m: float | None = None +) -> dict[str, Any]: + """받기 전 범위 보기 — 내려받기 없음. `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m).""" + margin = clamp_margin(margin_m) + have = received_box(project_root) + box = wanted_box(points, margin, have) + store = get_project_map_sheets_dir(project_root) + sheets = [] + for no in sheets_for_box(box, epsg): + lon0, lat0, lon1, lat1 = sheet5k_to_bounds(no) + sheets.append( + { + "no": no, + "ring": [[lon0, lat0], [lon1, lat0], [lon1, lat1], [lon0, lat1]], + "stored": get_sheet_path(store, no) is not None, + } + ) + return { + "epsg": int(epsg), + "margin_m": margin, + "box_m": list(box), + "box": box_ring(box, epsg), + "have_m": list(have) if have else None, + "have": box_ring(have, epsg) if have else None, + "covered": covers(have, box), + "sheets": sheets, + # DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 + "cells": int((box[2] - box[0]) * (box[3] - box[1]) / SHEET_SURFACE_GRID_M**2), + } def write_plan_prj(project_root: Path, epsg: int) -> Path: @@ -93,15 +164,6 @@ def write_plan_prj(project_root: Path, epsg: int) -> Path: return path -def dem_outline(start: tuple[float, float], end: tuple[float, float]) -> np.ndarray: - """DEM 상자 꼭짓점 둘 — B 여유(`SHEET_SURFACE_MARGIN_M`)를 더하면 복도 + `DEM_EXTRA_M`.""" - pad = max(0.0, PLAN_CORRIDOR_M + DEM_EXTRA_M - SHEET_SURFACE_MARGIN_M) - xs, ys = (start[0], end[0]), (start[1], end[1]) - return np.array( - [[min(xs) - pad, min(ys) - pad], [max(xs) + pad, max(ys) + pad]], dtype=np.float64 - ) - - def _read_json(path: Path) -> dict[str, Any] | None: try: return json.loads(path.read_text(encoding="utf-8")) @@ -114,7 +176,7 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st key_path = processed / "sheet_merge.key.json" old = (_read_json(key_path) or {}).get("sheets") or [] sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)}) - key = {"sheets": sheets} + key = {"sheets": sheets, "parser": PARSER_VERSION} if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))): parse_and_merge_sheets(store, sheets, processed) atomic_write_json(key_path, key) @@ -158,29 +220,30 @@ def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], ep def ensure_plan_terrain( project_root: Path, - segments: list[dict[str, Any]], + points: list[XY], epsg: int, + margin_m: float | None = None, on_progress: Progress | None = None, ) -> dict[str, Any]: - """지점 기준 지형 받기 — `segments` = [{year, start: (x, y), end: (x, y)}](작업 좌표계 m). + """사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m). - 돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 연도별 모델 등록용 dict 목록. + 돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 받은 범위 · 모델 등록용 dict 목록. """ def report(percent: int, message: str) -> None: if on_progress is not None: on_progress(percent, message) - if not segments: - raise ValueError("연도 구간이 없습니다.") + if not points: + raise ValueError("시점 · 종점이 없습니다.") processed = project_root / STAGE / "processed" processed.mkdir(parents=True, exist_ok=True) prj = write_plan_prj(project_root, epsg) - crs = f"EPSG:{int(epsg)}" + margin = clamp_margin(margin_m) + box = wanted_box(points, margin, received_box(project_root)) report(5, "수치지형도 도엽 고르는 중") - points = reference_points(segments, epsg) - wanted = neighbors_for_points(points) + wanted = sheets_for_box(box, epsg) store = get_project_map_sheets_dir(project_root) before = {s for s in wanted if get_sheet_path(store, s) is not None} report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중") @@ -190,24 +253,30 @@ def ensure_plan_terrain( report(45, "도엽 레이어 병합 중") merged = _merge_sheets(processed, store, list(got["available"])) report(60, "위성 지도 받는 중") - satellite = _satellite(processed, prj, points, epsg) + satellite = _satellite(processed, prj, [(p[1], p[0]) for p in box_ring(box, epsg)], epsg) - years: dict[int, list[dict[str, Any]]] = {} - for i, seg in enumerate(segments): - year = int(seg["year"]) - report(65 + 30 * i // len(segments), f"{year}년 구간 지형면 만드는 중") - models_dir = models_dir_of(project_root, year) - models_dir.mkdir(parents=True, exist_ok=True) - years[year] = build_sheet_surface_model( - project_root, - processed, - models_dir, - dem_outline(seg["start"], seg["end"]), - crs, - [SHEET_SURFACE_DEFAULT_METHOD], + report(65, "사업 지형면 만드는 중") + models_dir = models_dir_of(project_root) + models_dir.mkdir(parents=True, exist_ok=True) + # B 절취 = 넘긴 꼭짓점 상자 + `SHEET_SURFACE_MARGIN_M` → 그만큼 안쪽으로 넘김(여유 ≥ 그 값) + inner = SHEET_SURFACE_MARGIN_M + outline = np.array( + [[box[0] + inner, box[1] + inner], [box[2] - inner, box[3] - inner]], dtype=np.float64 + ) + models = build_sheet_surface_model( + project_root, + processed, + models_dir, + outline, + f"EPSG:{int(epsg)}", + [SHEET_SURFACE_DEFAULT_METHOD], + ) + if not models: + raise ValueError( + "사업 지형면을 만들지 못했습니다 — 범위 안 도엽 등고선 높이 확인" + f"(병합 도엽 {len(merged)}매)" ) - if not years[year]: - raise ValueError(f"{year}년 구간 지형면을 만들지 못했습니다 — 도엽 등고선 확인") + atomic_write_json(models_dir / TERRAIN_META, {"box": list(box), "margin_m": margin}) report(100, "지형 받기 끝") return { "sheets": { @@ -217,5 +286,6 @@ def ensure_plan_terrain( "merged": merged, }, "satellite": satellite, - "years": years, + "box_m": list(box), + "models": models, } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts index b1082a6fa..a8d934226 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts @@ -1,13 +1,11 @@ /* ============================================================================= * D03_PlanAnalysis_UI_3D.ts - * D03 [3D] 큰 모달(PLAN 530-3 · 544-3) — 한 연도 DEM + 그 연도 고른 노선 + 측점 막대 + 예상 코리도. - * 예상 코리도 = 측점마다 횡단 판과 같은 예상 단면(`logic/D03_PlanAnalysis_Expect`) → B05 `buildCorridor` - * → `setCorridor`(노면 · 측구 · 절성토면 · 지형 잘림) · 구조물 없음 · 「예상 코리도」 켬끔. - * 연도 고름 = 모달 위 칸 · 뷰어 = B05 `createRouteViewer` 그대로 · 닫으면 버림(WebGL 문맥까지). + * D03 [3D] 큰 모달(PLAN 530-3 · 563-1) — 사업 전체 DEM + 그은 선 하나(지반에 붙임) + 측점 막대. + * 선 고름 = 모달 위 칸(본선 · 갈래) · 뷰어 = B05 `createRouteViewer` 그대로 · 닫으면 버림(WebGL 문맥까지). + * 예상 코리도 없음(꺾은선 · 계획고 = 지반고라 절성토 0 — 요구서 389 3장 11). * ========================================================================== */ import "../B05_Profile/B05_Profile_UI_Style.css"; -import { createCheckbox } from "@ui/ui_template_checkbox"; import { createSelectField } from "@ui/ui_template_input"; import { openModal } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; @@ -15,22 +13,16 @@ import { showToast } from "@ui/ui_template_elements"; import type { DtmGrid } from "../common_util/common_util_surface_sampler"; import { createRouteViewer, type RouteViewer } from "../B05_Profile/B05_Profile_UI_Viewer"; import { sectionsGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; -import { buildCorridor } from "../B05_Profile/logic/B05_Profile_Corridor_Build"; -import type { CrossSection } from "../B06_Section/B06_Section_Api_Types"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; -import { expectSections } from "./logic/D03_PlanAnalysis_Expect"; -import { loadExpectStandard } from "./D03_PlanAnalysis_UI_Cross"; -export interface Plan3dYear { - year: number; - modelId: number; - grid: DtmGrid; +export interface Plan3dLine { + name: string; line: Vertex[]; - /** 그 연도 노선 위 측점 누가(연도 시작 = 0) · 등록 누가. */ - stations: Array<{ localM: number; chainage: number }>; + /** 선 위 측점 누가(선마다 No.0 부터). */ + stations: number[]; } -/** 격자 → 뷰어 상자(지형에 붙는 노선 · 막대는 높이 원점과 무관 · x · y 만 메시와 맞으면 됨). */ +/** 격자 → 뷰어 상자(지형에 붙는 선 · 막대는 높이 원점과 무관 · x · y 만 메시와 맞으면 됨). */ function gridBounds(grid: DtmGrid): { x: [number, number]; y: [number, number]; @@ -50,77 +42,62 @@ function gridBounds(grid: DtmGrid): { }; } -export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: number): void { - if (!years.length) return void showToast("3D 로 볼 연도 노선이 없습니다.", "warning"); +export function openPlan3d( + projectId: string, + surface: { modelId: number; grid: DtmGrid }, + lines: Plan3dLine[], +): void { + if (!lines.length) return void showToast("3D 로 볼 선이 없습니다.", "warning"); let viewer: RouteViewer | null = createRouteViewer(); + let loaded = false; const pick = createSelectField({ - options: years.map((item) => ({ value: String(item.year), text: `${item.year}년` })), - value: String(firstYear), + options: lines.map((item, i) => ({ value: String(i), text: item.name })), + value: "0", size: 28, - ariaLabel: "연도", + ariaLabel: "선", }).select; pick.classList.add("d03-plan__3d-year"); - const corridor = createCheckbox(" 예상 코리도(표준 횡단 기본값 · 계획고 = 지반고)", { - checked: true, - }); - const corridorBox = corridor.root; - corridorBox.classList.add("d03-plan__3d-corridor"); - const corridorOn = corridor.input; - corridorOn.addEventListener("change", () => viewer?.setCorridorVisible(corridorOn.checked)); const top = document.createElement("div"); top.className = "d03-plan__3d-top"; - top.append(pick, corridorBox); - const standard = loadExpectStandard(projectId); + top.append(pick); - const load = async (year: number): Promise => { - const item = years.find((entry) => entry.year === year) ?? years[0]; + const load = async (index: number): Promise => { + const item = lines[index] ?? lines[0]; if (!viewer) return; const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); progress.set(null); viewer.root.append(progress.root); const started = performance.now(); - const stat: Record = { year: item.year, done: false }; + const stat: Record = { line: item.name, done: false }; (window as unknown as { __d03Plan3d?: unknown }).__d03Plan3d = stat; try { - await viewer.loadSurface(projectId, item.modelId, "dtm", true, 5, gridBounds(item.grid)); + if (!loaded) { + await viewer.loadSurface( + projectId, + surface.modelId, + "dtm", + true, + 5, + gridBounds(surface.grid), + ); + loaded = true; + } if (!viewer) return; viewer.markers.renderRoute(item.line.map(([x, y]) => ({ x, y }))); - const ground = sectionsGround( - item.line, - item.stations.map((s) => s.localM), - item.grid, - ); + const ground = sectionsGround(item.line, item.stations, surface.grid); viewer.renderStationLines( ground.stations.map((station, i) => ({ - station_id: String(item.stations[i]?.chainage ?? station.chainage_m), + station_id: String(item.stations[i] ?? station.chainage_m), center_x: station.center_x, center_y: station.center_y, center_z: station.center_z, frame: { left_xy: station.left_xy }, kind: "regular", - chainage_m: item.stations[i]?.chainage ?? station.chainage_m, + chainage_m: item.stations[i] ?? station.chainage_m, })), 2.5, ); - const std = await standard; - if (!viewer) return; - const built = performance.now(); - const sections = std - ? (expectSections(ground.stations, std, item.grid) as unknown as CrossSection[]) - : []; - const build = buildCorridor( - sections, - item.line.map(([x, y]) => ({ x, y })), - ); - if (build) build.sections = sections; // 지형 잘림이 읽음 - viewer.setCorridor(build); - viewer.setCorridorVisible(corridorOn.checked); - Object.assign(stat, { - sections: sections.length, - corridor: !!build, - buildMs: Math.round(performance.now() - built), - centers: sections.map((c) => [c.center_x, c.center_y, c.center_z]), - }); + stat.stations = ground.stations.length; viewer.setView("iso"); } catch (error) { showToast(error instanceof Error ? error.message : "3D 지형을 불러오지 못했습니다.", "error"); @@ -133,7 +110,7 @@ export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: nu pick.addEventListener("change", () => void load(Number(pick.value))); openModal({ - title: "3D 보기 — 지형 + 노선", + title: "3D 보기 — 지형 + 그은 선", closeLabel: "닫기", wide: true, dialogClass: "d03-plan__3d-modal", @@ -148,5 +125,5 @@ export function openPlan3d(projectId: string, years: Plan3dYear[], firstYear: nu return true; }, }); - void load(Number(pick.value)); + void load(0); } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts deleted file mode 100644 index 1232ffa41..000000000 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Cross.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* ============================================================================= - * D03_PlanAnalysis_UI_Cross.ts - * D03 하 패널 — 누른 측점의 지반선 + 예상 횡단(PLAN 530-3 · 544-2). 계산 = 화면 TS 즉시(① 짝 `sectionsGround` ↔ - * `generate_sections` · 격자 = 그 연도 DEM 평활본) · 예상 = `logic/D03_PlanAnalysis_Expect`(표준 횡단 기본값 · - * 계획고 = 중심 지반고 · 표준값 = `sections/context` 세션 캐시) · 그림 = B05 `buildCrossSvg`. - * ========================================================================== */ - -import "../B05_Profile/B05_Profile_UI_Style_RouteEdit.css"; -import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; -import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; -import { createText } from "@ui/ui_template_text"; -import { stationLabel } from "../common_util/common_util_station"; -import type { DtmGrid } from "../common_util/common_util_surface_sampler"; -import { buildCrossSvg } from "../B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw"; -import { - sectionsGround, - type StationGround, -} from "../B05_Profile/logic/B05_Profile_Sections_Ground"; -import { fetchSectionContext } from "../B06_Section/B06_Section_Api_Context"; -import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; -import { expectCross, type ExpectStandard } from "./logic/D03_PlanAnalysis_Expect"; -import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; - -export interface PlanCross { - root: HTMLElement; - /** `chainage` = 등록 누가 · `localM` = 그 연도 노선 위 누가 · 돌려줌 = 그린 측점 지반(못 읽으면 null). */ - show: ( - chainage: number, - year: number, - line: Vertex[], - localM: number, - grid: DtmGrid, - ) => StationGround | null; - clear: (text: string) => void; -} - -/** 표준 횡단 기본값(config · 서버 한 번 · 세션 캐시) — 못 받으면 null(지반선만). */ -export function loadExpectStandard(projectId: string): Promise { - return fetchSectionContext(projectId).then( - (context) => ({ - standard: context.standard_cross_section as ExpectStandard["standard"], - rockBoundaryM: context.rock_boundary_default_offset_m, - }), - () => null, - ); -} - -export function createPlanCross(): PlanCross { - const root = document.createElement("section"); - root.className = "d03-plan__cross"; - const handle = createWorkflowPanelHandle("bottom", "down", "횡단"); - const head = document.createElement("div"); - head.className = "d03-plan__cross-head"; - const body = document.createElement("div"); - body.className = "d03-plan__cross-body"; - root.append(handle.root, head, body); - let open = true; - handle.setOpen(open); - handle.root.addEventListener("click", () => { - open = !open; - root.classList.toggle("is-collapsed", !open); - handle.setOpen(open); - }); - - const clear = (text: string): void => { - head.textContent = ""; - body.replaceChildren(createText(text, "muted", 14)); - delete root.dataset.chainage; - }; - clear("평면에서 측점을 누르면 그 자리 지반선 · 예상 횡단이 보입니다."); - let standard: ExpectStandard | null = null; - let asked: Promise | null = null; - - return { - root, - clear, - show(chainage, year, line, localM, grid) { - const station = sectionsGround(line, [localM], grid).stations[0]; - if (!station) { - clear("이 측점의 지반을 읽지 못했습니다."); - return null; - } - const name = stationLabel(chainage, PLAN_STATION_INTERVAL_M, 1); - const z = station.center_z === null ? "—" : `${station.center_z.toFixed(2)} m`; - root.dataset.chainage = String(chainage); - const draw = (): void => { - const expect = standard ? expectCross(station, standard, grid) : null; - const area = expect - ? ` · 예상(표준 횡단 기본값 · 계획고 = 지반고) 절토 ${expect.design.cut_area_m2.toFixed(2)}㎡ · 성토 ${expect.design.fill_area_m2.toFixed(2)}㎡` - : ""; - head.textContent = `${year}년 · ${name} · 중심 지반고 ${z}${area}`; - const rect = body.getBoundingClientRect(); - const plot = buildCrossSvg( - { - status: "success", - chainage_m: chainage, - label: name, - uphill_side: station.uphill_side, - plan_radius_m: null, - curve_widening_m: null, - cross_half_width_m: 20, - samples: expect?.samples ?? station.samples, - design: expect ? { ...expect.design } : null, - }, - Math.max(rect.width, 320), - Math.max(rect.height, 160), - !expect, - ); - body.replaceChildren(plot.svg); - }; - draw(); - if (!standard) { - const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - asked ??= loadExpectStandard(projectId); - void asked.then((value) => { - standard = value; - // 그사이 다른 측점을 눌렀으면 그 측점이 다시 그림 - if (value && root.dataset.chainage === String(chainage)) draw(); - }); - } - return station; - }, - }; -} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts new file mode 100644 index 000000000..484d8809b --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -0,0 +1,607 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Draw.ts + * D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 요구서 389 3장 8~10) — 시점에서 점을 찍어 직선으로 이음. + * + * 본선 = D02 시점에서 · D02 종점 둘레(SNAP_PX)에 오면 종점에 붙고 그 점에서 끝. + * 미리보기 = 마지막 점 → 마우스 길이(m) · 원지반 기울기(%, + 오르막) — 제약 칸 넷: + * 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것). + * 갈래 = 기존 선 꺾임점(선 위면 꺾임점 끼움)을 눌러 시작 · 구역 마크 = 선 위 누름(오른쪽 = 지움). + * 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄). + * 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` · + * 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지). + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { createSegment } from "@ui/ui_template_segment"; +import { createText } from "@ui/ui_template_text"; +import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import { + compassCandidates, + compassPoint, + distanceAlong, + fixedLengthPoint, + fixedSlopePoint, + groundSlopePct, + lineLength, + pointAtDistance, + segmentLength, + stationChainages, +} from "./logic/D03_PlanAnalysis_Draw"; +import { + addBranch, + appendVertex, + dropLastLine, + dropLastVertex, + insertVertex, + reachedEnd, + removeMark, + splitPiece, + type PlanNetwork, + type Vertex, +} from "./logic/D03_PlanAnalysis_Plan"; +import { + planColors, + PLAN_STATION_INTERVAL_M, + type PlanMapLayer, + type ScreenMap, +} from "./D03_PlanAnalysis_UI_Map"; + +/** 종점 · 꺾임점에 붙는 반경(px). */ +const SNAP_PX = 12; +/** 선 위 누름으로 볼 반경(px). */ +const LINE_HIT_PX = 8; +/** 구간 길이 라벨을 보일 화면 길이(px). */ +const LABEL_MIN_PX = 70; +/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */ +export const DRAW_DEFAULT_LENGTH_M = 20; +export const DRAW_DEFAULT_SLOPE_PCT = 9; + +export type DrawConstraint = "free" | "length" | "slope" | "both"; +export type DrawMode = "idle" | "draw" | "branch" | "mark"; + +export interface DrawPreview { + point: Vertex; + lengthM: number; + slopePct: number | null; + /** 붙음 · 자리 없음 안내(있으면 점을 못 찍음 = `blocked`). */ + note: string | null; + blocked: boolean; + snapped: boolean; +} + +export interface PlanDrawParams { + base: () => { start: Vertex; end: Vertex }; + network: () => PlanNetwork; + heightAt: () => (x: number, y: number) => number; + commit: (next: PlanNetwork) => void; + /** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */ + onMode: () => void; +} + +export interface PlanDraw { + layer: PlanMapLayer; + /** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */ + panel: HTMLElement; + mode: () => DrawMode; + setMode: (mode: DrawMode) => void; + setEditable: (editable: boolean) => void; + /** 키 — 받았으면 참(Enter · Esc · Backspace). */ + key: (event: KeyboardEvent) => boolean; + /** 되돌리기 뒤 — 그리던 선이 사라졌으면 그리기를 멈춤. */ + sync: () => void; + /** 검증 창구 — 지금 미리보기 · 제약. */ + preview: () => DrawPreview | null; + constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number }; + setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void; +} + +const fmt = (value: number): string => value.toFixed(1); +const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`; +const near = (a: [number, number], b: [number, number], px: number): boolean => + Math.hypot(a[0] - b[0], a[1] - b[1]) <= px; + +export function createPlanDraw(params: PlanDrawParams): PlanDraw { + let mode: DrawMode = "idle"; + let editable = false; + let active: number | null = null; // 그리는 선 차례 + let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴 + let preview: DrawPreview | null = null; + let candidates: Vertex[] = []; + let kind: DrawConstraint = "free"; + let lengthM = DRAW_DEFAULT_LENGTH_M; + let slopePct = DRAW_DEFAULT_SLOPE_PCT; + + // ── 좌측 칸 ───────────────────────────────────────────── + const mainButton = createButton({ + label: "본선 긋기", + variant: "filled", + size: 28, + onClick: () => setMode(mode === "draw" && active === 0 ? "idle" : "draw"), + }); + const branchButton = createButton({ + label: "갈래", + variant: "ghost", + size: 28, + onClick: () => setMode(mode === "branch" ? "idle" : "branch"), + }); + const markButton = createButton({ + label: "구역 마크", + variant: "ghost", + size: 28, + onClick: () => setMode(mode === "mark" ? "idle" : "mark"), + }); + const modeRow = document.createElement("div"); + modeRow.className = "d03-plan__row"; + modeRow.append(mainButton, branchButton, markButton); + const kindSegment = createSegment({ + options: [ + { value: "free", label: "자유" }, + { value: "length", label: "길이 고정" }, + { value: "slope", label: "기울기 고정" }, + { value: "both", label: "둘 다" }, + ], + value: kind, + size: 24, + ariaLabel: "긋기 제약", + onChange: (value) => { + kind = value as DrawConstraint; + fieldsState(); + }, + }); + kindSegment.root.dataset.role = "draw-constraint"; + const lengthField = createInputField({ + label: "길이", + type: "number", + value: String(lengthM), + min: 1, + unit: "m", + size: 24, + onInput: (value) => { + const next = Number(value); + if (next > 0) lengthM = next; + lengthField.setError(next > 0 ? undefined : "0 보다 큰 수"); + }, + }); + lengthField.input.dataset.role = "draw-length"; + const slopeField = createInputField({ + label: "기울기(+ 오르막)", + type: "number", + value: String(slopePct), + unit: "%", + size: 24, + onInput: (value) => { + const next = Number(value); + if (value.trim() !== "" && Number.isFinite(next)) slopePct = next; + slopeField.setError(value.trim() !== "" && Number.isFinite(next) ? undefined : "숫자"); + }, + }); + slopeField.input.dataset.role = "draw-slope"; + const fieldRow = document.createElement("div"); + fieldRow.className = "d03-plan__row"; + fieldRow.append(lengthField.root, slopeField.root); + const hint = createText("", "muted"); + hint.classList.add("d03-plan__hint"); + const panel = document.createElement("div"); + panel.className = "d03-plan__draw"; + panel.append(modeRow, kindSegment.root, fieldRow, hint); + + function fieldsState(): void { + lengthField.input.disabled = kind === "free" || kind === "slope"; + slopeField.input.disabled = kind === "free" || kind === "length"; + } + + function panelState(): void { + const network = params.network(); + const done = reachedEnd(network, params.base().end); + mainButton.querySelector(".ui-btn__label")!.textContent = + mode === "draw" && active === 0 ? "긋기 끝" : done ? "본선 끝남" : "본선 긋기"; + mainButton.disabled = !editable || (done && !(mode === "draw" && active === 0)); + branchButton.disabled = !editable || lineLength(network.lines[0]?.vertices ?? []) <= 0; + markButton.disabled = branchButton.disabled; + branchButton.setAttribute("aria-pressed", String(mode === "branch")); + markButton.setAttribute("aria-pressed", String(mode === "mark")); + panel.dataset.mode = mode; + hint.textContent = !editable + ? "보기만 — 산림청 계정에서 긋습니다." + : mode === "draw" + ? "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기" + : mode === "branch" + ? "갈래를 시작할 선 위 점을 누르세요." + : mode === "mark" + ? "선 위를 눌러 구역을 나눕니다 · 오른쪽 누름 = 마크 지움" + : done + ? "본선이 종점에 닿았습니다 — 갈래 · 구역 마크 · 연도를 정하세요." + : "[본선 긋기]로 시점에서 점을 찍어 갑니다."; + } + fieldsState(); + + // ── 셈 ────────────────────────────────────────────────── + const lastOf = (line: number): Vertex | null => { + const vertices = params.network().lines[line]?.vertices ?? []; + return vertices[vertices.length - 1] ?? null; + }; + + /** 마우스 자리 → 찍을 점 · 값 — 종점 붙기가 제약보다 먼저(본선만). */ + function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null { + if (active === null) return null; + const from = lastOf(active); + if (!from) return null; + const z = params.heightAt(); + const cursor = map.toMetric(px, py); + const end = params.base().end; + candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : []; + let point: Vertex | null = cursor; + let note: string | null = null; + let snapped = false; + if (active === 0 && near(map.toScreen(end), [px, py], SNAP_PX)) { + [point, note, snapped] = [end, "종점에 붙음", true]; + } else if (kind === "length") { + point = fixedLengthPoint(from, cursor, lengthM); + } else if (kind === "slope") { + point = fixedSlopePoint(from, cursor, slopePct, z); + if (!point) note = `이 방향엔 ${signed(slopePct)} % 자리 없음`; + } else if (kind === "both") { + point = compassPoint(from, cursor, lengthM, slopePct, z); + if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ${signed(slopePct)} % 자리 없음`; + } + const at = point ?? cursor; + const length = segmentLength(from, at); + return { + point: at, + lengthM: length, + slopePct: groundSlopePct(from, at, z), + note, + blocked: !point || length <= 0, + snapped, + }; + } + + /** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간. */ + function lineAt( + px: number, + py: number, + map: ScreenMap, + ): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null { + const cursor = map.toMetric(px, py); + let best: ReturnType = null; + let bestPx = LINE_HIT_PX; + params.network().lines.forEach((line, index) => { + if (line.vertices.length < 2) return; + const vertex = line.vertices.findIndex((v) => near(map.toScreen(v), [px, py], SNAP_PX)); + const atM = + vertex >= 0 + ? lineLength(line.vertices.slice(0, vertex + 1)) + : distanceAlong(line.vertices, cursor); + const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM); + const [sx, sy] = map.toScreen(point); + const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py); + if (gap > bestPx || (best && gap === bestPx)) return; + // 누가가 든 구간(꺾임점 끼울 자리) + let segment = 0; + for (let walked = 0; segment < line.vertices.length - 2; segment += 1) { + walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]); + if (walked >= atM) break; + } + best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment }; + bestPx = gap; + }); + return best; + } + + // ── 모드 ──────────────────────────────────────────────── + function stop(): void { + // 점 하나뿐인 갈래는 버림(본선 시점은 남김) + const network = params.network(); + if (active !== null && active > 0 && (network.lines[active]?.vertices.length ?? 0) < 2) + params.commit(dropLastLine(network)); + active = null; + strokeStart = null; + preview = null; + candidates = []; + } + + function setMode(next: DrawMode): void { + if (mode === "draw") stop(); + mode = editable ? next : "idle"; + if (mode === "draw") { + const network = params.network(); + if (reachedEnd(network, params.base().end)) { + showToast("본선이 이미 종점에 닿았습니다 — 갈래는 [갈래]로 긋습니다.", "info"); + mode = "idle"; + } else { + active = 0; + strokeStart = network; + } + } + panelState(); + params.onMode(); + } + + function addPoint(): boolean { + if (active === null || !preview) return false; + if (preview.blocked) { + showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); + return false; + } + const line = active; + const ended = preview.snapped; + params.commit(appendVertex(params.network(), line, preview.point)); + if (ended) { + showToast("본선이 종점에 닿았습니다.", "success"); + setMode("idle"); + } + return true; + } + + const layer: PlanMapLayer = { + draw(context, map) { + const colors = planColors(); + const { start, end } = params.base(); + const network = params.network(); + context.lineCap = "round"; + context.lineJoin = "round"; + network.lines.forEach((line, index) => { + if (line.vertices.length < 2) return; + context.strokeStyle = index === active ? colors.active : colors.line; + context.lineWidth = index === 0 ? 3 : 2.5; + context.beginPath(); + line.vertices.forEach((v, i) => { + const [sx, sy] = map.toScreen(v); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.stroke(); + drawStationTicks( + context, + line.vertices.map(([x, y]) => ({ x, y })), + { + intervalM: PLAN_STATION_INTERVAL_M, + pxPerMeter: map.pxPerMeter, + toScreen: (x, y) => map.toScreen([x, y]), + uprightRad: map.uprightRad, + chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), + }, + ); + segmentLabels(context, map, line.vertices); + }); + // 꺾임점 + context.fillStyle = "#ffffff"; + for (const line of network.lines) + for (const v of line.vertices.slice(1)) { + const [sx, sy] = map.toScreen(v); + context.beginPath(); + context.arc(sx, sy, 3, 0, Math.PI * 2); + context.fill(); + } + drawPieces(context, map, network); + drawPreview(context, map, colors); + // 시점(초록) · 종점(빨강) + for (const [point, fill] of [ + [start, "#16a34a"], + [end, "#dc2626"], + ] as const) { + const [sx, sy] = map.toScreen(point); + context.beginPath(); + context.arc(sx, sy, 7, 0, Math.PI * 2); + context.fillStyle = fill; + context.fill(); + context.lineWidth = 2; + context.strokeStyle = "#ffffff"; + context.stroke(); + } + }, + press(px, py, map, button) { + if (!editable) return false; + if (mode === "draw" && button === 0) { + preview = previewAt(px, py, map); + return addPoint(); + } + if (mode === "branch" && button === 0) { + const hit = lineAt(px, py, map); + if (!hit) return false; + let network = params.network(); + let vertex = hit.vertex; + if (vertex === null) { + network = insertVertex(network, hit.line, hit.segment, hit.point); + vertex = hit.segment + 1; + } + strokeStart = params.network(); + params.commit(addBranch(network, hit.line, vertex)); + mode = "draw"; + active = params.network().lines.length - 1; + panelState(); + params.onMode(); + return true; + } + if (mode === "mark") { + const hit = lineAt(px, py, map); + if (!hit) return false; + const network = params.network(); + const id = network.lines[hit.line].id; + const next = + button === 2 + ? removeMark(network, id, hit.atM, SNAP_PX / map.pxPerMeter) + : splitPiece(network, id, hit.atM); + if (next !== network) params.commit(next); + return true; + } + return false; + }, + hover(px, py, map) { + preview = mode === "draw" ? previewAt(px, py, map) : null; + }, + leave() { + preview = null; + }, + }; + + function segmentLabels( + context: CanvasRenderingContext2D, + map: ScreenMap, + vertices: Vertex[], + ): void { + const z = params.heightAt(); + for (let i = 1; i < vertices.length; i += 1) { + const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])]; + if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue; + const slope = groundSlopePct(vertices[i - 1], vertices[i], z); + const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`; + label(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px"); + } + } + + function drawPieces( + context: CanvasRenderingContext2D, + map: ScreenMap, + network: PlanNetwork, + ): void { + const colors = planColors(); + network.pieces.forEach((piece) => { + const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; + if (!vertices || vertices.length < 2) return; + if (piece.from_m > 0) { + const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m)); + context.beginPath(); + context.moveTo(sx, sy - 8); + context.lineTo(sx + 6, sy); + context.lineTo(sx, sy + 8); + context.lineTo(sx - 6, sy); + context.closePath(); + context.fillStyle = colors.active; + context.fill(); + context.lineWidth = 1.5; + context.strokeStyle = "#ffffff"; + context.stroke(); + } + const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2)); + label(context, map, mx, my + 16, `${piece.year}년`, "bold 12px"); + }); + } + + function drawPreview( + context: CanvasRenderingContext2D, + map: ScreenMap, + colors: ReturnType, + ): void { + if (active === null || !preview) return; + const from = lastOf(active); + if (!from) return; + const [ax, ay] = map.toScreen(from); + const [bx, by] = map.toScreen(preview.point); + if (kind === "both") { + context.strokeStyle = colors.active; + context.lineWidth = 1; + context.setLineDash([3, 4]); + context.beginPath(); + context.arc(ax, ay, lengthM * map.pxPerMeter, 0, Math.PI * 2); + context.stroke(); + context.fillStyle = colors.active; + for (const c of candidates) { + const [cx, cy] = map.toScreen(c); + context.beginPath(); + context.arc(cx, cy, 3, 0, Math.PI * 2); + context.fill(); + } + } + context.strokeStyle = preview.blocked ? colors.warn : colors.active; + context.lineWidth = 2; + context.setLineDash([6, 4]); + context.beginPath(); + context.moveTo(ax, ay); + context.lineTo(bx, by); + context.stroke(); + context.setLineDash([]); + context.beginPath(); + context.arc(bx, by, 5, 0, Math.PI * 2); + context.stroke(); + const value = + `${fmt(preview.lengthM)} m · ` + + (preview.slopePct === null ? "지형 밖" : `${signed(preview.slopePct)} %`); + label( + context, + map, + bx + 14, + by - 14, + preview.note ? `${value} · ${preview.note}` : value, + "12px", + "left", + ); + } + + function label( + context: CanvasRenderingContext2D, + map: ScreenMap, + x: number, + y: number, + text: string, + font: string, + align: CanvasTextAlign = "center", + ): void { + map.upright(context, x, y, () => { + context.font = `${font} system-ui, sans-serif`; + context.textAlign = align; + context.textBaseline = "middle"; + context.lineWidth = 3; + context.strokeStyle = planColors().halo; + context.strokeText(text, x, y); + context.fillStyle = "#ffffff"; + context.fillText(text, x, y); + }); + } + + panelState(); + + return { + layer, + panel, + mode: () => mode, + setMode, + setEditable(next) { + editable = next; + if (!editable) setMode("idle"); + panelState(); + }, + key(event) { + if (mode === "draw" && event.key === "Enter") { + setMode("idle"); + return true; + } + if (mode !== "idle" && event.key === "Escape") { + if (mode === "draw" && strokeStart && strokeStart !== params.network()) + params.commit(strokeStart); + active = null; // 되돌린 꼴이라 stop 의 갈래 버림은 안 함 + setMode("idle"); + return true; + } + if (mode === "draw" && active !== null && event.key === "Backspace") { + const network = params.network(); + const step = + active > 0 && network.lines[active].vertices.length < 2 + ? dropLastLine(network) + : dropLastVertex(network, active); + if (typeof step === "string") showToast(step, "info"); + else if (step !== network) params.commit(step); + if (active >= params.network().lines.length) setMode("idle"); + return true; + } + return false; + }, + sync() { + if (active !== null && active >= params.network().lines.length) { + active = null; + mode = "idle"; + } + panelState(); + }, + preview: () => preview, + constraint: () => ({ kind, lengthM, slopePct }), + setConstraint(next, length, slope) { + kind = next; + kindSegment.set(next); + if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)]; + if (slope !== undefined) [slopePct, slopeField.input.value] = [slope, String(slope)]; + fieldsState(); + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts deleted file mode 100644 index 4200793a9..000000000 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ /dev/null @@ -1,186 +0,0 @@ -/* ============================================================================= - * 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; - } - }, - }; -} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index e3fe536e3..0a84e4bcc 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -1,21 +1,19 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Map.ts - * D03 메인 평면(PLAN 530-3) — 위성 + 도엽 등고선 + 다른 갈래(옅게) + 고른 노선 + 측점 눈금 · 이름 + - * 지점(연도 경계) + 지점 끌기 겹. + * D03 메인 평면(PLAN 530-3 · 562-3) — 위성 + 도엽 등고선 바탕 · 위에 겹(그리기 · 거리재기) · 도구줄 여섯 + * (되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 거리재기). * - * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `metricToScreen` · `screenToMetric` · 맞춤 - * `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` · - * 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선 - * `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시). - * 꺾임점 고침 겹(543 · `overlay`) = 지점 다음 차례로 집음 · 두 번 누름 = 넣기 · 오른쪽 누름 = 지우기. + * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤 + * `computeRouteView` · 등고선 `drawPreparedLayer` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` · + * 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠). + * 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향). + * 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김. * ========================================================================== */ +import { createIconButton } from "@ui/ui_template_button"; import { themeColor } from "@ui/ui_template_palette"; +import { createToolbar } from "@ui/ui_template_toolbar"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { - drawStationTicks, - type StationLabelBox, -} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { computeMapRect, computeRouteView, @@ -28,71 +26,81 @@ import { type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; -import { - bindRouteEditNavigation, - stationAtScreen, -} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; +import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; +import { createMapRotation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Rotate"; import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View"; -import { leadInLine, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; /** 측점 간격(m) — B 규칙 측점과 같음. */ export const PLAN_STATION_INTERVAL_M = 20; -/** 지점 집는 반경(px). */ -const NODE_HIT_PX = 12; -/** 누름으로 볼 손 떨림(px) — 넘으면 지도 옮기기. */ -const CLICK_SLOP_PX = 4; -/** 위성이 없을 때 지점 둘레로 둘 가짜 도엽 여유(m). */ +/** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */ +export function planColors(): { line: string; active: string; warn: string; halo: string } { + return { + line: routeLineColor(), + active: themeColor("--draw-primary", "#6d28d9"), + warn: themeColor("--draw-danger", "#dc2626"), + halo: "rgba(0, 0, 0, 0.65)", + }; +} + +/** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(m). */ const FAKE_MARGIN_M = 600; export interface PlanMapScene { meta: VWorldMeta | null; image: HTMLImageElement | null; contours: PreparedLayer | null; - nodes: Vertex[]; - /** 마디마다 받은 지형 안인가 — 밖이면 빨간 고리. */ - reach: boolean[]; - /** 고르지 않은 갈래(옅게). */ - others: Vertex[][]; - /** 고른 갈래를 이은 중심선. */ - line: Vertex[]; - startM: number; - stations: number[]; - selected: number | null; - editable: boolean; + /** 처음 맞출 점(시점 · 종점 · 그은 선). */ + fit: Vertex[]; } -/** 화면 px ↔ 작업 좌표계 m. */ +/** 겹이 쓰는 좌표 — 그림 px(돌리기 전) ↔ 작업 좌표계 m · 글자 세우기. */ export interface ScreenMap { toScreen: (point: Vertex) => [number, number]; toMetric: (px: number, py: number) => Vertex; + pxPerMeter: number; + /** 글자를 세울 각(라디안 · 안 돌렸으면 0). */ + uprightRad: number; + /** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */ + upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void; } -/** 평면 위 고침 겹 — 그리기 · 집기(참 = 잡음) · 끌기 · 놓기 · 넣기 · 지우기. */ -export interface PlanMapOverlay { +/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁 · 나감. */ +export interface PlanMapLayer { 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; + press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean; + hover?: (px: number, py: number, map: ScreenMap) => void; + leave?: () => void; } export interface PlanMapEvents { - onDrag: (index: number, point: Vertex) => void; - onDragEnd: (index: number) => void; - onStation: (chainage: number) => void; - overlay?: PlanMapOverlay; + onUndo: () => void; + onRedo: () => void; + on3d: () => void; + onMeasure: () => void; +} + +export interface PlanTools { + undo: boolean; + redo: boolean; + view3d: boolean; + /** 거리재기 켜짐(눌림). */ + measure: boolean; } export interface PlanMap { root: HTMLElement; update: (patch: Partial) => void; - /** 지점 · 노선이 들어오게 다시 맞춤. */ + setLayers: (layers: PlanMapLayer[]) => void; + setTools: (tools: Partial) => void; + /** 다시 그림(겹 값이 바뀜). */ + redraw: () => void; + /** 맞출 점이 들어오게 다시 맞춤. */ fit: () => void; - /** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */ - stationScreen: (chainage: number) => [number, number] | null; - /** 검증 창구 — 작업 좌표계 점 → 화면 px(그림 좌표 · 그리기 전이면 null). */ + /** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */ screenOf: (point: Vertex) => [number, number] | null; + /** 검증 창구 — 지금 돌린 각(도). */ + rotationDeg: () => number; dispose: () => void; } @@ -124,24 +132,43 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { const root = document.createElement("div"); root.className = "d03-plan__map"; const canvas = document.createElement("canvas"); - root.append(canvas); - const context = canvas.getContext("2d")!; - let scene: PlanMapScene = { - meta: null, - image: null, - contours: null, - nodes: [], - reach: [], - others: [], - line: [], - startM: 0, - stations: [], - selected: null, - editable: false, + const tool = ( + act: string, + icon: Parameters[0]["icon"], + title: string, + onClick?: () => void, + ): HTMLButtonElement => { + const button = createIconButton({ icon, title, onClick }); + button.dataset.act = act; + return button; }; + const undoButton = tool("undo", "undo", "되돌리기(Ctrl+Z)", events.onUndo); + const redoButton = tool("redo", "redo", "다시하기(Ctrl+Y)", events.onRedo); + const viewButton = tool("view3d", "cube", "3D 보기", events.on3d); + const measureButton = tool("measure", "ruler", "거리재기", events.onMeasure); + measureButton.setAttribute("aria-pressed", "false"); + const toolbar = createToolbar({ + glass: true, + size: 32, + label: "평면 도구", + items: [ + undoButton, + redoButton, + "divider", + viewButton, + tool("rotate-ccw", "rotateCcw", "왼쪽으로 돌림(15°)"), + tool("rotate-cw", "rotateCw", "오른쪽으로 돌림(15°)"), + "divider", + measureButton, + ], + }); + toolbar.classList.add("d03-plan__tools"); + root.append(canvas, toolbar); + const context = canvas.getContext("2d")!; + let scene: PlanMapScene = { meta: null, image: null, contours: null, fit: [] }; + let layers: PlanMapLayer[] = []; let meta: VWorldMeta | null = null; let fitted = false; - let labels: StationLabelBox[] = []; let view: ViewState = { width: 0, height: 0, @@ -150,20 +177,37 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { offsetY: 0, mapRect: computeMapRect(null, 0, 0), }; + const rotation = createMapRotation({ + overlay: toolbar, + size: () => view, + onChange: () => draw(), + }); - 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), + const screenMap = (): ScreenMap => { + const projector = createMetricProjector(meta!, view); + return { + toScreen: (point) => projector.toScreen(point[0], point[1]), + toMetric: (px, py) => screenToMetric(meta!, view, px, py), + pxPerMeter: projector.pxPerMeter, + uprightRad: rotation.uprightRad(), + upright: (ctx, x, y, paint) => { + const angle = rotation.uprightRad(); + if (!angle) return paint(); + ctx.save(); + ctx.translate(x, y); + ctx.rotate(angle); + ctx.translate(-x, -y); + paint(); + ctx.restore(); + }, + }; }; function refit(): void { - const all = [...scene.nodes, ...scene.line]; - meta = scene.meta ?? fakeMeta(all); - if (!meta || !view.width || !all.length) return; - const xs = all.map(([x]) => x); - const ys = all.map(([, y]) => y); + meta = scene.meta ?? fakeMeta(scene.fit); + if (!meta || !view.width || !scene.fit.length) return; + const xs = scene.fit.map(([x]) => x); + const ys = scene.fit.map(([, y]) => y); const box = { x_min: Math.min(...xs), x_max: Math.max(...xs), @@ -178,20 +222,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { fitted = true; } - function polyline(points: Vertex[]): void { - context.beginPath(); - points.forEach((point, i) => { - const [sx, sy] = toScreen(point); - if (i) context.lineTo(sx, sy); - else context.moveTo(sx, sy); - }); - context.stroke(); - } - function draw(): void { context.clearRect(0, 0, view.width, view.height); if (!meta) return; - const projector = createMetricProjector(meta, view); + context.save(); + context.translate(view.width / 2, view.height / 2); + context.rotate(rotation.radians()); + context.translate(-view.width / 2, -view.height / 2); if (scene.image?.complete && scene.image.naturalWidth && scene.meta) { const [x0, y0] = normalizedToScreen(view, 0, 0); const [x1, y1] = normalizedToScreen(view, 1, 1); @@ -211,83 +248,24 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { context.font = "10px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; - drawPreparedLabels(context, scene.contours, view, "#fde68a", 25, 0, "rgba(0, 0, 0, 0.6)"); + drawPreparedLabels( + context, + scene.contours, + view, + "#fde68a", + 25, + rotation.uprightRad(), + "rgba(0, 0, 0, 0.6)", + ); context.restore(); } - context.save(); - context.lineCap = "round"; - context.lineJoin = "round"; - context.strokeStyle = "rgba(255, 255, 255, 0.55)"; - context.lineWidth = 1.5; - context.setLineDash([6, 4]); - for (const other of scene.others) if (other.length > 1) polyline(other); - context.setLineDash([]); - if (scene.line.length > 1) { - context.strokeStyle = routeLineColor(); - context.lineWidth = 3; - polyline(scene.line); + const map = screenMap(); + for (const layer of layers) { + context.save(); + layer.draw(context, map); + context.restore(); } context.restore(); - labels = []; - if (scene.line.length > 1) { - drawStationTicks( - context, - leadInLine(scene.line, scene.startM).map(([x, y]) => ({ x, y })), - { - intervalM: PLAN_STATION_INTERVAL_M, - pxPerMeter: projector.pxPerMeter, - toScreen: projector.toScreen, - chainages: scene.stations, - onLabel: (box) => labels.push(box), - }, - ); - } - if (scene.selected !== null) { - const at = stationScreen(scene.selected); - if (at) { - context.beginPath(); - context.arc(at[0], at[1], 7, 0, Math.PI * 2); - context.lineWidth = 2.5; - context.strokeStyle = themeColor("--draw-primary", "#6d28d9"); - 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; - context.beginPath(); - context.arc(sx, sy, 7, 0, Math.PI * 2); - context.fillStyle = i === 0 ? "#16a34a" : last ? "#dc2626" : "#2563eb"; - context.fill(); - context.lineWidth = 2; - context.strokeStyle = scene.reach[i] === false ? "#ef4444" : "#ffffff"; - context.stroke(); - if (scene.reach[i] === false) { - context.beginPath(); - context.arc(sx, sy, 12, 0, Math.PI * 2); - context.setLineDash([3, 3]); - context.stroke(); - context.setLineDash([]); - } - }); - } - - /** 측점 누가 → 화면 px — 앞붙임 선 위 보간(눈금과 같은 자리). */ - function stationScreen(chainage: number): [number, number] | null { - if (!meta || scene.line.length < 2) return null; - const line = leadInLine(scene.line, scene.startM); - let travelled = 0; - for (let i = 1; i < line.length; i += 1) { - const [a, b] = [line[i - 1], line[i]]; - const span = Math.hypot(b[0] - a[0], b[1] - a[1]); - if (travelled + span >= chainage - 1e-6) { - const t = span ? (chainage - travelled) / span : 0; - return toScreen([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]); - } - travelled += span; - } - return toScreen(line[line.length - 1]); } function resize(): void { @@ -298,98 +276,45 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { draw(); } + /** 누른 화면 자리 → 그림 px(돌리기 전). */ const at = (event: PointerEvent | MouseEvent): [number, number] => { const rect = canvas.getBoundingClientRect(); - return [event.clientX - rect.left, event.clientY - rect.top]; - }; - const nodeAt = (px: number, py: number): number => { - let best = -1; - let bestDistance = NODE_HIT_PX; - scene.nodes.forEach((node, i) => { - const [sx, sy] = toScreen(node); - const distance = Math.hypot(sx - px, sy - py); - if (distance < bestDistance) [best, bestDistance] = [i, distance]; - }); - return best; + return rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); }; - // 지점 끌기 — 지도 옮기기보다 먼저 듣고 지점을 집었으면 옮기기에 안 넘김 - // 집은 자리와 지점 가운데 차(px)를 지킴 · 손 떨림(CLICK_SLOP_PX) 안이면 끌기 아님(누름) - let dragging: { index: number; id: number; dx: number; dy: number; moved: boolean } | null = null; - let press: [number, number] | null = null; canvas.addEventListener("pointerdown", (event) => { - press = [event.clientX, event.clientY]; - if (!meta || !scene.editable || event.button !== 0) return; + if (!meta || event.button !== 0) return; const [px, py] = at(event); - // 지점 먼저 · 다음 고침 겹(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"; + const map = screenMap(); + if (layers.some((layer) => layer.press?.(px, py, map, 0))) draw(); }); canvas.addEventListener("pointermove", (event) => { - if (!dragging || event.pointerId !== dragging.id || !meta) { - if (meta && scene.editable && !dragging) - canvas.style.cursor = nodeAt(...at(event)) >= 0 ? "grab" : ""; - return; - } - if (!press) return; - const far = Math.hypot(event.clientX - press[0], event.clientY - press[1]) > CLICK_SLOP_PX; - if (!dragging.moved && !far) return; - dragging.moved = true; + if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중 const [px, py] = at(event); - 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 map = screenMap(); + for (const layer of layers) layer.hover?.(px, py, map); + draw(); + }); + canvas.addEventListener("pointerleave", () => { + for (const layer of layers) layer.leave?.(); + draw(); + }); + canvas.addEventListener("contextmenu", (event) => { + event.preventDefault(); + if (!meta) return; + const [px, py] = at(event); + const map = screenMap(); + if (layers.some((layer) => layer.press?.(px, py, map, 2))) draw(); }); - const endDrag = (event: PointerEvent): void => { - if (!dragging || event.pointerId !== dragging.id) return; - const { index, moved } = dragging; - dragging = null; - canvas.style.cursor = ""; - if (index < 0) events.overlay?.release(); - else if (moved) events.onDragEnd(index); - }; - canvas.addEventListener("pointerup", endDrag); - canvas.addEventListener("pointercancel", endDrag); bindRouteEditNavigation({ canvas, getView: () => view, setView: (next) => (view = next), getMeta: () => meta, - leftPan: true, + unrotateDelta: rotation.unrotateDelta, + rotateWheel: rotation.wheel, draw, }); - // 측점 누름 — 손을 거의 안 움직인 누름만(옮기기 끝은 안 셈) - canvas.addEventListener("click", (event) => { - if (!press || Math.hypot(event.clientX - press[0], event.clientY - press[1]) > CLICK_SLOP_PX) - return; - if (!meta || scene.line.length < 2) return; - const [px, py] = at(event); - const hit = stationAtScreen( - leadInLine(scene.line, scene.startM), - toScreen, - PLAN_STATION_INTERVAL_M, - px, - py, - 14, - labels, - scene.stations, - ); - 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); @@ -402,12 +327,28 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { if (metaChanged || !fitted) refit(); draw(); }, + setLayers(next) { + layers = next; + draw(); + }, + setTools(tools) { + if (tools.undo !== undefined) undoButton.disabled = !tools.undo; + if (tools.redo !== undefined) redoButton.disabled = !tools.redo; + if (tools.view3d !== undefined) viewButton.disabled = !tools.view3d; + if (tools.measure !== undefined) + measureButton.setAttribute("aria-pressed", String(tools.measure)); + }, + redraw: draw, fit() { refit(); draw(); }, - stationScreen, - screenOf: (point) => (meta ? toScreen(point) : null), + screenOf: (point) => { + if (!meta) return null; + const [x, y] = screenMap().toScreen(point); + return rotation.rerotate(x, y); + }, + rotationDeg: () => (rotation.radians() * 180) / Math.PI, dispose: () => observer.disconnect(), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts new file mode 100644 index 000000000..fb4dbe80a --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts @@ -0,0 +1,105 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Measure.ts + * D03 거리재기 겹(PLAN 562-3) — 두 점을 누르면 그 사이 길이(m) · 원지반 기울기(%) · 높이 차(m). + * 셋째 누름 = 새로 잼 · Esc = 지움. 지반고 = 받은 지형 `heightAt` · 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`). + * ========================================================================== */ + +import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw"; +import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; + +export interface PlanMeasure { + layer: PlanMapLayer; + /** 지움(Esc · 끔). */ + clear: () => void; + /** 검증 창구 — 잰 값(두 점이 다 있으면). */ + result: () => { lengthM: number; slopePct: number | null; riseM: number | null } | null; +} + +const fmt = (value: number, digits = 1): string => value.toFixed(digits); +const signed = (value: number, digits = 1): string => + `${value >= 0 ? "+" : ""}${fmt(value, digits)}`; + +export function createPlanMeasure(heightAt: () => (x: number, y: number) => number): PlanMeasure { + let from: Vertex | null = null; + let to: Vertex | null = null; + let fixed = false; + + const measure = (a: Vertex, b: Vertex) => { + const z = heightAt(); + const rise = z(b[0], b[1]) - z(a[0], a[1]); + return { + lengthM: segmentLength(a, b), + slopePct: groundSlopePct(a, b, z), + riseM: Number.isFinite(rise) ? rise : null, + }; + }; + + const layer: PlanMapLayer = { + draw(context, map: ScreenMap) { + if (!from) return; + const colors = planColors(); + const [ax, ay] = map.toScreen(from); + context.lineWidth = 2; + context.strokeStyle = colors.active; + context.fillStyle = colors.active; + context.setLineDash([6, 4]); + if (to) { + const [bx, by] = map.toScreen(to); + context.beginPath(); + context.moveTo(ax, ay); + context.lineTo(bx, by); + context.stroke(); + context.setLineDash([]); + for (const [x, y] of [ + [ax, ay], + [bx, by], + ]) { + context.beginPath(); + context.arc(x, y, 4, 0, Math.PI * 2); + context.fill(); + } + const { lengthM, slopePct, riseM } = measure(from, to); + const text = + `${fmt(lengthM)} m · ` + + (slopePct === null ? "지형 밖" : `${signed(slopePct)} % · Δh ${signed(riseM ?? 0)} m`); + const [mx, my] = [(ax + bx) / 2, (ay + by) / 2 - 14]; + map.upright(context, mx, my, () => { + context.font = "12px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + context.lineWidth = 3; + context.strokeStyle = colors.halo; + context.strokeText(text, mx, my); + context.fillStyle = "#ffffff"; + context.fillText(text, mx, my); + }); + } else { + context.beginPath(); + context.arc(ax, ay, 4, 0, Math.PI * 2); + context.fill(); + } + }, + press(px, py, map, button) { + if (button !== 0) return false; + const point = map.toMetric(px, py); + if (!from || fixed) { + [from, to, fixed] = [point, null, false]; + } else { + [to, fixed] = [point, true]; + } + return true; + }, + hover(px, py, map) { + if (from && !fixed) to = map.toMetric(px, py); + }, + }; + + return { + layer, + clear: () => { + [from, to, fixed] = [null, null, false]; + }, + result: () => (from && to && fixed ? measure(from, to) : null), + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 185ed67b9..56b090ffd 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -1,22 +1,23 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Page.ts - * D 예정노선 2단계 분석 화면(요구서 345 3-3 · PLAN 530-3) — B05~B09 틀(`mountGroupStep`). + * D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`). * - * 메인 = 평면(위성 + 도엽 등고선 + 지점 + 연도 구간 노선 · 측점) · 하 = 누른 측점 지반선 횡단 · - * 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D]. - * 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」. + * 메인 = 평면(위성 + 도엽 등고선 + 그은 선 · 측점 · 구역 마크) + 도구 여섯. + * 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) · + * 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화]. * - * 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 · 꺾임점 고침(543) = 캐시(`d03-plan`) · [저장] = - * PUT plan/analysis(끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 · 고친 노선은 - * 마디로 서버가 다시 셈). 꺾임점 고침 = 브라우저 즉시 다시 그음 → 길이 · 측점 · 누른 측점 횡단 · - * 되돌리기(Ctrl+Z). 화면 전체 잠금 = [저장]만. - * 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음. + * 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] = + * PUT plan/network(서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT. + * 되돌리기 · 다시하기 = 이 화면 스택(걸음 = `commit` 한 번). 화면 전체 잠금 = [저장] · [초기화]만. + * 공유 회사(산림청 아님) = 보기만 — 긋기 · 받기 · [저장] 없음. * ========================================================================== */ import "./D03_PlanAnalysis_UI_Style.css"; import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; -import { createCard } from "@ui/ui_template_card"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { editMap } from "@ui/ui_template_kakao_map"; +import { askConfirm, openModal } from "@ui/ui_template_modal"; import { createProgressBar } from "@ui/ui_template_progress"; import { createText } from "@ui/ui_template_text"; import { @@ -29,516 +30,367 @@ import { fetchCachedSheetLayer, DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_as 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"; -import { - fetchVWorldMeta, - getVWorldMapUrl, - type VWorldMeta, -} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { fetchVWorldMeta, getVWorldMapUrl } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createNormalizer, type GeoJsonCollection, - type PreparedLayer, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; +import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; import { stationLabel } from "../common_util/common_util_station"; -import type { StationGround } from "../B05_Profile/logic/B05_Profile_Sections_Ground"; import { - fetchPlanAnalysis, - fetchPlanPoints, + fetchPlanNetwork, fetchPlanProgress, - fetchYearSurfaces, - savePlanAnalysis, - startPlanJob, - type PlanAnalysisView, - type SavedEdit, + fetchPlanSurface, + fetchTerrainExtent, + savePlanNetwork, + startTerrain, type PlanJob, - type YearSurface, + type PlanNetworkView, + type PlanSurface, + type TerrainExtent, } 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 { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; +import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; +import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw"; import { - chainRows, - joinedLine, - nodesInReach, - nodesOf, - PLAN_MODES, - pointsOf, - rowAt, - sameNodes, - stationChainages, - withEdits, - type ChainRow, - type RouteEdit, + emptyNetwork, + fromServer, + lineName, + marksOf, + normalizePieces, + setPieceYear, + type PlanNetwork, 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 = { - gradeline: "영선", - earthwork: "토공 최소", - gentle: "기울기 최소", - weighted: "비중 계산", -}; const POLL_MS = 1000; -/** 등고선을 받아 올 지점 둘레(m). */ +/** 등고선을 받아 올 그은 선 둘레(m). */ const CONTOUR_PAD_M = 600; - /** 되돌리기 쌓는 수. */ -const UNDO_MAX = 50; +const UNDO_MAX = 100; +/** 조각 연도 고를 수 — 사업연도 시작부터. */ +const YEAR_SPAN = 10; +/** 캐시 꼴 판 — 옛 꼴(지점 · 갈래 고름)은 안 읽음. */ +const DRAFT_VERSION = 2; interface Draft { - nodes: Vertex[] | null; - choices: Record; - /** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */ - edits?: Record; + version: number; + network: PlanNetwork; } -type Snapshot = { edits: Record; choices: Record }; - const sleep = (ms: number): Promise => new Promise((done) => setTimeout(done, ms)); const errorText = (error: unknown): string => error instanceof Error ? error.message : String(error); +const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? ""; - const editLayer = createPlanEditLayer({ - begin: remember, - change: onEdit, - blocked: (message) => showToast(message, "info"), + let editable = false; + let start: Vertex = [0, 0]; + let end: Vertex = [0, 0]; + let epsg = 0; + let yearStart = new Date().getFullYear(); + let network: PlanNetwork = emptyNetwork(start); + let savedJson = ""; + let undoStack: PlanNetwork[] = []; + let redoStack: PlanNetwork[] = []; + let surface: PlanSurface | null = null; + let have: [number, number, number, number] | null = null; + let busy = false; + let failed: string | null = null; + let measuring = false; + let lastSaveMs: number | null = null; + + const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN); + const draw = createPlanDraw({ + base: () => ({ start, end }), + network: () => network, + heightAt: () => z, + commit, + onMode: () => { + if (draw.mode() !== "idle") setMeasure(false); + refresh(); + }, + }); + const measure = createPlanMeasure(() => z); + const map = createPlanMap({ + onUndo: undo, + onRedo: redo, + on3d: open3d, + onMeasure: () => setMeasure(!measuring), }); - const map = createPlanMap({ onDrag, onDragEnd, onStation, overlay: editLayer }); - const cross = createPlanCross(); const main = document.createElement("div"); main.className = "d03-plan"; - main.append(map.root, cross.root); + main.append(map.root); const terrainText = createText("", "muted"); terrainText.classList.add("d03-plan__hint"); const bar = createProgressBar(); bar.root.hidden = true; - const more = createButton({ - label: "범위 밖 — 더 받기", + const extentButton = createButton({ + label: "지형 범위 보기", variant: "filled", - onClick: () => void download(), + size: 28, + onClick: () => void openExtent([]), }); - more.hidden = true; - const yearList = document.createElement("div"); - yearList.className = "d03-plan__years"; - const analyzeButton = createButton({ - label: "분석", - variant: "ghost", - onClick: () => void analyze(), + const moreButton = createButton({ + label: "범위 밖 더 받기", + variant: "filled", + size: 28, + onClick: () => void openExtent(allVertices()), }); + const terrainCard = createPanelCard("지형", false); + terrainCard.append(terrainText, bar.root, extentButton, moreButton); + const drawCard = createPanelCard("그리기", false); + drawCard.append(draw.panel); + const pieceList = document.createElement("div"); + pieceList.className = "d03-plan__pieces"; + const pieceCard = createPanelCard("구역 · 연도", false); + pieceCard.append(pieceList); 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 resetButton = createButton({ + label: "초기화", + variant: "ghost", + onClick: () => void reset(), + }); const actions = document.createElement("div"); actions.className = "ui-sidebar-actions"; - actions.append(analyzeButton, saveButton, viewButton, undoButton); - const terrainCard = createPanelCard("지형", false); - terrainCard.append(terrainText, bar.root, more); - const routeCard = createPanelCard("연도별 노선", false); - routeCard.append(yearList); + actions.append(saveButton, resetButton); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel d03-plan__panel"; - panel.append(terrainCard, routeCard, actions); + panel.append(terrainCard, drawCard, pieceCard, actions); attachCollapsible(panel); - mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { mainContent: main, leftPanel: panel }); + // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 선만 저장 · 실패면 머묾 + mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { + mainContent: main, + leftPanel: panel, + beforeLeave: async () => !editable || !dirty() || (await save()), + }); root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); - let years: number[] = []; - let savedNodes: Vertex[] = []; - let nodes: Vertex[] = []; - let choices: Record = {}; - let startM = 0; - let targetM: number | null = null; // 등록 목표 끝 누가 - let analysis: PlanAnalysisView | null = null; - let surfaces = new Map(); - let rows: ChainRow[] = []; - let results: YearAnalysis[] = []; - let selected: number | null = null; - let editable = false; - let busy: "terrain" | "analyze" | null = null; - let lastCross: StationGround | null = null; - // 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기 - let edits: Record = {}; - let serverEdits: Record = {}; - let draftEdits: Record | undefined; - let history: Snapshot[] = []; - let lastSaveMs: number | null = null; - - const moved = (): boolean => !sameNodes(nodes, savedNodes); - const sentPoints = () => (moved() ? pointsOf(years, nodes) : null); - const reach = (): boolean[] => - nodesInReach( - nodes, - years.map((year) => { - const grid = surfaces.get(year)?.grid; - return grid - ? { - x0: grid.x[0], - x1: grid.x[grid.x.length - 1], - y0: grid.y[0], - y1: grid.y[grid.y.length - 1], - } - : null; - }), - ); + const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices); + const dirty = (): boolean => JSON.stringify(network) !== savedJson; const keepDraft = (): void => - writeState( - "d03-plan", - { nodes: moved() ? nodes : null, choices, edits } satisfies Draft, - projectId, - ); + dirty() + ? writeState("d03-plan", { version: DRAFT_VERSION, network } satisfies Draft, projectId) + : clearState("d03-plan", projectId); - /** 고치기 직전 — 되돌리기에 지금 값을 쌓음. */ - function remember(): void { - history = [...history.slice(1 - UNDO_MAX), structuredClone({ edits, choices })]; + /** 한 걸음 — 조각을 맞춰 넣고 되돌리기에 쌓음 · 캐시. */ + function commit(next: PlanNetwork): void { + undoStack = [...undoStack.slice(1 - UNDO_MAX), network]; + redoStack = []; + network = normalizePieces(next, yearStart); + keepDraft(); + refresh(); } function undo(): void { - const last = history.pop(); + const last = undoStack.pop(); if (!last) return; - ({ edits, choices } = last); + redoStack.push(network); + network = 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; + function redo(): void { + const next = redoStack.pop(); + if (!next) return; + undoStack.push(network); + network = next; keepDraft(); - repick(); + refresh(); } - /** 연도의 지금 고침 — 고른 갈래 · 분석 지문이 같을 때만. */ - 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; - }; + function setMeasure(on: boolean): void { + if (on === measuring) return; + measuring = on; + measure.clear(); + if (on) draw.setMode("idle"); + map.setLayers(measuring ? [draw.layer, measure.layer] : [draw.layer]); + refresh(); + } - /** 연도 목록에서 뺌. */ - const without = (from: Record, drop: number[]): Record => - Object.fromEntries(Object.entries(from).filter(([year]) => !drop.includes(Number(year)))); + /** 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */ + const outside = (): boolean => + !!have && + allVertices().some(([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3]); - /** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */ function refresh(): void { - const raw: YearAnalysis[] = years.map( - (year, i) => - analysis?.years.find((entry) => entry.year === year)?.analysis ?? { - year, - start: nodes[i], - end: nodes[i + 1], - routes: {}, - }, - ); - // 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름 - const saved = Object.fromEntries((analysis?.chain ?? []).map((row) => [row.year, row.mode])); - const picks = { ...saved, ...choices }; - // 꺾임점 고침 — 고른 갈래 노선의 마디(고침 · 없으면 엔진 노선) → 선 → 그 갈래 자리에 갈아 끼움 - const targets: EditTarget[] = []; - const routed: Record = {}; - 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, + draw.sync(); + map.setTools({ + undo: editable && undoStack.length > 0, + redo: editable && redoStack.length > 0, + view3d: !!surface && network.lines.some((line) => line.vertices.length > 1), + measure: measuring, + }); + map.redraw(); + renderPieces(); + extentButton.hidden = !editable || busy || !!surface; + moreButton.hidden = !editable || busy || !surface || !outside(); + if (!busy) + terrainText.textContent = failed + ? `지형 받기 실패 — ${failed}` + : !surface + ? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)." + : outside() + ? "그은 선이 받은 지형 밖으로 나갔습니다 — 더 받아야 기울기를 셉니다." + : "받은 지형 안"; + if (failed && editable && !busy) extentButton.hidden = false; + extentButton.querySelector(".ui-btn__label")!.textContent = failed + ? "다시 받기" + : "지형 범위 보기"; + saveButton.hidden = resetButton.hidden = !editable; + saveButton.disabled = busy || !dirty(); + } + + function renderPieces(): void { + const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => yearStart + i)); + network.pieces.forEach((piece) => years.add(piece.year)); + const options = [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); + const rows: HTMLElement[] = []; + network.lines.forEach((line, index) => { + const pieces = network.pieces.filter((piece) => piece.line === line.id); + if (!pieces.length) return; + const head = createText( + `${lineName(index)} · ${lineLength(line.vertices).toFixed(1)} m`, + "muted", ); - 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, - }; + head.classList.add("d03-plan__piece-head"); + rows.push(head); + for (const piece of pieces) { + const row = document.createElement("div"); + row.className = "d03-plan__piece"; + row.dataset.piece = piece.id; + const span = createText( + `${station(piece.from_m)} ~ ${station(piece.to_m)} (${(piece.to_m - piece.from_m).toFixed(1)} m)`, + "muted", + ); + const pick = createSelectField({ + options, + value: String(piece.year), + size: 24, + ariaLabel: "연도", + disabled: !editable, + onChange: (value) => + commit(setPieceYear(network, network.pieces.indexOf(piece), Number(value))), + }); + row.append(span, pick.root); + rows.push(row); + } }); - 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) - .map((mode) => result.routes[mode]?.planned) - .filter((planned): planned is Vertex[] => !!planned && planned.length > 1); - return rows[i].mode ? routes : [[nodes[i], nodes[i + 1]]]; - }); - const inReach = reach(); - map.update({ - nodes, - reach: inReach, - others, - line, - startM, - stations: stationChainages(rows, PLAN_STATION_INTERVAL_M), - selected, - editable, - }); - renderYears(); - const outside = inReach.some((ok) => !ok); - const missing = rows.some((row) => !row.mode); - more.hidden = !editable || !outside || busy !== null; - if (busy === null) - terrainText.textContent = outside - ? "빨간 고리 지점이 받은 지형 밖입니다 — 더 받아야 분석합니다." - : `받은 지형 안 · 연도 지형면 ${surfaces.size}/${years.length}`; - analyzeButton.hidden = saveButton.hidden = !editable; - analyzeButton.disabled = busy !== null || outside; - saveButton.disabled = busy !== null || missing; - saveButton.title = missing - ? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요." - : ""; - viewButton.disabled = !rows.some((row) => row.mode && surfaces.has(row.year)); - undoButton.hidden = !editable; - undoButton.disabled = !history.length; + if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted")); + pieceList.replaceChildren(...rows); } - function renderYears(): void { - yearList.replaceChildren( - ...results.map((result, i) => { - const row = rows[i]; - const box = document.createElement("div"); - box.className = "d03-plan__year"; - box.dataset.year = String(result.year); - const head = document.createElement("div"); - head.className = "d03-plan__year-head"; - head.textContent = row.mode - ? `${result.year}년 · ${stationLabel(row.start_chainage_m, PLAN_STATION_INTERVAL_M, 1)} ~ ` + - `${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; - const metrics = route.metrics; - const values = createText( - !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}`, - "muted", - ); - const card = createCard({ - title: MODE_LABEL[mode], - body: [values], - pad: 12, - selected: row.mode === mode, - onSelect: () => { - if (row.mode === mode) return; - remember(); - choices = { ...choices, [result.year]: mode }; - keepDraft(); - refresh(); - }, - }); - card.classList.add("d03-plan__mode"); - card.classList.toggle("is-picked", row.mode === mode); - card.dataset.mode = mode; - card.disabled = !!route.error || !metrics; - card.title = route.error ?? ""; - box.append(card); - } - return box; - }), - ...targetLine(), - ); - } - - /** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */ - 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: "원래 노선", + /** 받기 전 범위를 카카오 지도에 보임 → [이 범위 받기]. */ + async function openExtent(extra: Vertex[]): Promise { + let extent: TerrainExtent; + try { + extent = await fetchTerrainExtent(projectId, null, extra); + } catch (error) { + return void showToast(`받을 범위를 읽지 못했습니다 — ${errorText(error)}`, "error"); + } + const info = createText("", "muted"); + const kakao = editMap({ + onMove: () => undefined, + hint: "받을 범위(빨강) · 받은 범위(초록) · 도엽(옅게)", + }); + const show = (view: TerrainExtent): void => { + const ring = (points: Array<[number, number]>): Array<[number, number]> => + [...points, points[0]].map(([lon, lat]) => [lat, lon]); + const lines = [ + ring(view.box), + ...(view.have ? [ring(view.have)] : []), + ...view.sheets.map((s) => ring(s.ring)), + ]; + const colors = [ + "#E60046", + ...(view.have ? ["#16a34a"] : []), + ...view.sheets.map(() => "#94a3b8"), + ]; + kakao.set([], lines, colors); + const newSheets = view.sheets.filter((sheet) => !sheet.stored).length; + info.textContent = + `여유 ${view.margin_m.toFixed(0)} m · 도엽 ${view.sheets.length}매(새로 ${newSheets}) · ` + + `지형 칸 약 ${(view.cells / 1e6).toFixed(1)}백만` + + (view.covered ? " · 이미 받은 범위 안" : ""); + }; + const margin = createInputField({ + label: "여유", + type: "number", + value: extent.margin_m.toFixed(0), + unit: "m", + size: 24, + }); + const applyMargin = createButton({ + label: "다시 보기", variant: "ghost", - size: "sm", + size: 24, + onClick: () => + void fetchTerrainExtent(projectId, Number(margin.input.value) || null, extra) + .then((view) => show((extent = view))) + .catch((error) => showToast(errorText(error), "error")), + }); + const row = document.createElement("div"); + row.className = "d03-plan__row"; + row.append(margin.root, applyMargin); + let close = (): void => undefined; + const go = createButton({ + label: "이 범위 받기", + variant: "filled", onClick: () => { - remember(); - edits = without(edits, [year]); - keepDraft(); - refresh(); - repick(); + close(); + void download(extent.margin_m, extra); }, }); - reset.hidden = !editable; - line.append(reset); - return line; + ({ close } = openModal({ + title: "지형 받기 — 범위 미리보기", + closeLabel: "닫기", + width: "960px", + dialogClass: "d03-plan__extent-modal", + actions: [go], + mount: (body) => { + body.classList.add("d03-plan__extent"); + body.append(row, info, kakao.root); + show(extent); + }, + })); } - /** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */ - function targetLine(): HTMLElement[] { - const last = rows[rows.length - 1]; - if (targetM === null || !last || rows.some((row) => !row.mode)) return []; - const diff = last.end_chainage_m - targetM; - const line = document.createElement("div"); - line.className = "d03-plan__year-head d03-plan__target"; - line.textContent = - `끝 ${stationLabel(last.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} · ` + - `목표 ${stationLabel(targetM, PLAN_STATION_INTERVAL_M, 1)} · ` + - `차이 ${diff >= 0 ? "+" : ""}${diff.toFixed(1)} m`; - return [line]; - } - - function onDrag(index: number, point: Vertex): void { - nodes = nodes.map((node, i) => (i === index ? point : node)); - map.update({ nodes, reach: reach() }); - } - - 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"); + async function download(marginM: number, extra: Vertex[]): Promise { + try { + await startTerrain(projectId, marginM, extra); + } catch (error) { + // 이미 도는 일(다른 창)이면 그것을 지켜봄 + if (!/이미 돌고/.test(errorText(error))) return void showToast(errorText(error), "error"); } - if (analysis) - analysis = { - ...analysis, - years: analysis.years.map((entry, i) => - i === index - 1 || i === index ? { ...entry, analysis: null } : entry, - ), - }; - selected = null; - lastCross = null; - cross.clear("지점을 옮겼습니다 — [분석] 뒤 측점을 누르세요."); - keepDraft(); + await follow(); + } + + /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ + async function follow(): Promise { + const job = await watch(); + if (!job || !root.isConnected) return; + failed = job.error; + await Promise.all([loadSurface(), loadBackground()]); refresh(); } - function onStation(chainage: number): void { - const row = rowAt(rows, chainage); - const surface = row ? surfaces.get(row.year) : undefined; - if (!row?.mode || !surface) return; - const result = results.find((entry) => entry.year === row.year)!; - selected = chainage; - map.update({ selected }); - lastCross = cross.show( - chainage, - row.year, - result.routes[row.mode].planned ?? [], - Math.max(0, chainage - row.start_chainage_m), - surface.grid, - ); - } - - async function loadBackground(): Promise { - let meta: VWorldMeta | null = null; - try { - meta = await fetchVWorldMeta(projectId, "satellite"); - } catch { - meta = null; // 위성 못 받음 — 가짜 도엽 위에 그림 - } - let image: HTMLImageElement | null = null; - let contours: PreparedLayer | null = null; - if (meta) { - image = new Image(); - image.onload = () => map.update({}); - // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 - image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`; - const xs = nodes.map(([x]) => x); - const ys = nodes.map(([, y]) => y); - const bbox = [ - Math.min(...xs) - CONTOUR_PAD_M, - Math.min(...ys) - CONTOUR_PAD_M, - Math.max(...xs) + CONTOUR_PAD_M, - Math.max(...ys) + CONTOUR_PAD_M, - ].map((v) => v.toFixed(0)); - try { - const collection = await fetchCachedSheetLayer( - projectId, - DRAINAGE_SHEET_LAYERS[0], - "route", - bbox.join(","), - ); - contours = prepareLayer(collection, createNormalizer(meta), ["등고수치"]); - } catch { - contours = null; - } - } - map.update({ meta, image, contours }); - } - - async function loadSurfaces(): Promise { - try { - surfaces = await fetchYearSurfaces(projectId); - } catch (error) { - showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); - } - } - - async function loadAnalysis(): Promise { - try { - 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"); - } - } - - /** 뒤에서 도는 일을 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ - async function watch(kind: "terrain" | "analyze"): Promise { - busy = kind; + /** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ + async function watch(): Promise { + busy = true; bar.root.hidden = false; refresh(); try { for (;;) { if (!root.isConnected) return null; - const job = (await fetchPlanProgress(projectId))[kind]; + const job = (await fetchPlanProgress(projectId)).terrain; bar.set(job.percent / 100); - terrainText.textContent = `${kind === "terrain" ? "지형 받기" : "분석"} ${job.percent}% · ${job.message ?? ""}`; + terrainText.textContent = `지형 받기 ${job.percent}% · ${job.message ?? ""}`; if (!job.running) { if (job.error) showToast(job.error, "error"); return job; @@ -546,152 +398,188 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { await sleep(POLL_MS); } } catch (error) { - // 진행을 못 읽음(세션 끊김 등) — 서버 일은 그대로 돎 · 다시 열면 이어 지켜봄 showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error"); return null; } finally { - busy = null; + busy = false; bar.root.hidden = true; refresh(); } } - async function run(kind: "terrain" | "analyze"): Promise { + async function loadSurface(): Promise { try { - await startPlanJob(projectId, kind, sentPoints()); + surface = await fetchPlanSurface(projectId); + const extent = surface ? await fetchTerrainExtent(projectId, null, []) : null; + have = extent?.have_m ?? null; } catch (error) { - // 이미 도는 일(다른 창 · D02 [>])이면 그것을 지켜봄 - if (!/이미 돌고/.test(errorText(error))) { - showToast(errorText(error), "error"); - return null; - } + showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); } - return watch(kind); } - async function download(): Promise { - const job = await run("terrain"); - if (!job || !root.isConnected) return; - await Promise.all([loadSurfaces(), loadBackground()]); - refresh(); + async function loadBackground(): Promise { + const meta = await fetchVWorldMeta(projectId, "satellite").catch(() => null); + if (!meta) return map.update({ meta: null, image: null, contours: null }); + const image = new Image(); + image.onload = () => map.redraw(); + // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 + image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`; + const points = [start, end, ...allVertices()]; + const xs = points.map(([x]) => x); + const ys = points.map(([, y]) => y); + const bbox = [ + Math.min(...xs) - CONTOUR_PAD_M, + Math.min(...ys) - CONTOUR_PAD_M, + Math.max(...xs) + CONTOUR_PAD_M, + Math.max(...ys) + CONTOUR_PAD_M, + ].map((v) => v.toFixed(0)); + const contours = await fetchCachedSheetLayer( + projectId, + DRAINAGE_SHEET_LAYERS[0], + "route", + bbox.join(","), + ) + .then((collection) => prepareLayer(collection, createNormalizer(meta), ["등고수치"])) + .catch(() => null); + map.update({ meta, image, contours }); } - async function analyze(): Promise { - const job = await run("analyze"); - if (!job || !root.isConnected) return; - await loadAnalysis(); - refresh(); + /** 서버 응답 → 지금 값 · 저장본(빈 선 = 시점만). */ + function takeServer(view: PlanNetworkView): void { + if (view.year_start) yearStart = view.year_start; + const server = view.network; + network = + server && server.lines.length + ? fromServer(server.lines, server.marks, server.pieces, yearStart) + : emptyNetwork(start); + if (server?.epsg) epsg = server.epsg; + savedJson = JSON.stringify(network); } - async function save(): Promise { - 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]] : []; - }), - ); + function serverBody(): Parameters[1] { + return { epsg, lines: network.lines, marks: marksOf(network), pieces: network.pieces }; + } + + /** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */ + async function save(): Promise { + draw.setMode("idle"); showLoadingOverlay(); try { const began = performance.now(); - await savePlanAnalysis(projectId, picked, sentPoints(), sent); + takeServer(await savePlanNetwork(projectId, serverBody())); lastSaveMs = Math.round(performance.now() - began); clearState("d03-plan", projectId); - draftEdits = undefined; - history = []; - await loadPoints(); - await loadAnalysis(); + undoStack = []; + redoStack = []; refresh(); showToast("예정노선을 저장했습니다.", "success"); + return true; } catch (error) { showToast(`저장하지 못했습니다 — ${errorText(error)}`, "error"); + return false; + } finally { + hideLoadingOverlay(); + } + } + + /** [초기화] = 시점 · 종점만 남은 빈 선을 정본에. */ + async function reset(): Promise { + const ok = await askConfirm({ + title: "초기화", + message: + "그은 선 · 갈래 · 구역을 모두 지우고 시점 · 종점만 남깁니다. 저장된 노선도 바뀝니다.", + okLabel: "초기화", + danger: true, + }); + if (!ok) return; + draw.setMode("idle"); + showLoadingOverlay(); + try { + takeServer(await savePlanNetwork(projectId, { epsg, lines: [], marks: [], pieces: [] })); + clearState("d03-plan", projectId); + undoStack = []; + redoStack = []; + refresh(); + showToast("시점 · 종점만 남겼습니다.", "success"); + } catch (error) { + showToast(`초기화하지 못했습니다 — ${errorText(error)}`, "error"); } finally { hideLoadingOverlay(); } } function open3d(): void { - const items = rows.flatMap((row, i) => { - const surface = surfaces.get(row.year); - const line = row.mode ? results[i].routes[row.mode].planned : undefined; - if (!surface || !line) return []; - const stations = stationChainages([row], PLAN_STATION_INTERVAL_M).map((chainage) => ({ - chainage, - localM: chainage - row.start_chainage_m, - })); - return [{ year: row.year, modelId: surface.modelId, grid: surface.grid, line, stations }]; - }); - const first = selected !== null ? rowAt(rows, selected)?.year : undefined; - openPlan3d(projectId, items, first ?? items[0]?.year ?? 0); + if (!surface) return; + const lines = network.lines + .map((line, index) => ({ + name: lineName(index), + line: line.vertices, + stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), + })) + .filter((item) => item.line.length > 1); + openPlan3d(projectId, surface, lines); } - async function loadPoints(): Promise { - const view = await fetchPlanPoints(projectId); - const empty = view.years.find((entry) => !entry.start || !entry.end); - if (empty) { - terrainText.textContent = `${empty.year}년 지점이 비었습니다 — 1단계(기준 지정)에서 정하세요.`; - return false; - } - years = view.years.map((entry) => entry.year); - savedNodes = nodesOf( - view.years.map((entry) => ({ - start: [entry.start!.x, entry.start!.y] as Vertex, - end: [entry.end!.x, entry.end!.y] as Vertex, - })), - ); - const draft = readState("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, + network: () => network, + base: () => ({ start, end }), screenOf: map.screenOf, - rows: () => rows, - nodes: () => nodes, - pick: onStation, - cross: () => lastCross, - edits: () => edits, - targets: () => editLayer.targets(), - results: () => results, + preview: draw.preview, + constraint: draw.constraint, + setConstraint: draw.setConstraint, + mode: draw.mode, + setMode: draw.setMode, + measure: measure.result, + rotationDeg: map.rotationDeg, + history: () => ({ undo: undoStack.length, redo: redoStack.length }), + dirty, 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); - }, + heightAt: z, }; 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") { + if (typing || !editable) return; + const key = event.key.toLowerCase(); + const ctrl = event.ctrlKey || event.metaKey; + const handled = + ctrl && key === "z" && !event.shiftKey + ? (undo(), true) + : ctrl && (key === "y" || (key === "z" && event.shiftKey)) + ? (redo(), true) + : measuring && event.key === "Escape" + ? (setMeasure(false), true) + : !ctrl && draw.key(event); + if (handled) { event.preventDefault(); - undo(); + map.redraw(); } }; window.addEventListener("keydown", onKey); + map.setLayers([draw.layer]); try { editable = !!(await fetchSessionUser())?.is_forest_service; - if (!(await loadPoints())) return; - await Promise.all([loadSurfaces(), loadAnalysis()]); + draw.setEditable(editable); + const view = await fetchPlanNetwork(projectId); + const base = view.base; + if (!base.start || !base.end || !base.epsg) { + terrainText.textContent = "시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요."; + return; + } + start = [base.start.x, base.start.y]; + end = [base.end.x, base.end.y]; + epsg = base.epsg; + takeServer(view); + const draft = readState("d03-plan", projectId); + if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network; + map.update({ fit: [start, end, ...allVertices()] }); + refresh(); + await loadSurface(); refresh(); - map.fit(); await loadBackground(); - const progress = await fetchPlanProgress(projectId); - if (progress.analyze.running) await analyze(); - if (progress.terrain.running) await download(); - else if (editable && reach().some((ok) => !ok)) await download(); + if ((await fetchPlanProgress(projectId)).terrain.running) await follow(); } catch (error) { terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index faf721e4d..c493fe022 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -1,4 +1,4 @@ -/* D03 예정노선 2단계 분석(PLAN 530-3) — 평면이 본문을 채우고 아래 횡단 판 · 좌측 연도 카드. */ +/* D03 예정노선 2단계 노선분석(PLAN 530-3 · 563-1) — 평면이 본문을 채움 · 위에 도구줄 · 좌측 지형 · 그리기 · 구역 · 연도. */ .d03-plan-layout .ui-workflow-layout__main { height: calc(100dvh - var(--app-header-h)); @@ -25,65 +25,66 @@ touch-action: none; } -.d03-plan__cross { - position: relative; - flex: 0 0 240px; - display: flex; - flex-direction: column; - border-top: 1px solid var(--color-border); - background: var(--color-surface); -} - -.d03-plan__cross.is-collapsed { - flex-basis: 0; -} - -.d03-plan__cross.is-collapsed > :not(.ui-workflow-overlay__handle) { - display: none; -} - -.d03-plan__cross > .ui-workflow-overlay__handle { +.d03-plan__tools { position: absolute; - top: -28px; - left: 50%; - transform: translateX(-50%); -} - -.d03-plan__cross-head { - padding: 6px 12px 0; - font-size: 0.85rem; - color: var(--color-text-secondary); -} - -.d03-plan__cross-body { - flex: 1 1 auto; - min-height: 0; - padding: 4px 12px 8px; -} - -.d03-plan__cross-body p { - margin: 12px 0; + top: var(--spacing-8); + right: var(--spacing-8); } .d03-plan__hint { margin: 0 0 6px; } -.d03-plan__years { +.d03-plan__row { display: flex; - flex-direction: column; - gap: 10px; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: flex-end; + margin-bottom: 6px; } -.d03-plan__year { +.d03-plan__row > .ui-field { + flex: 1 1 6rem; + margin: 0; +} + +.d03-plan__draw { + display: flex; + flex-direction: column; + gap: 6px; +} + +.d03-plan__pieces { display: flex; flex-direction: column; gap: 4px; } -.d03-plan__year-head { +.d03-plan__piece-head { + margin: 6px 0 0; font-weight: 600; - font-size: 0.85rem; + color: var(--color-text); +} + +.d03-plan__piece { + display: flex; + gap: var(--spacing-8); + align-items: center; + justify-content: space-between; +} + +.d03-plan__piece > .ui-text { + margin: 0; +} + +.d03-plan__piece > .ui-field { + flex: 0 0 6.5rem; + margin: 0; +} + +/* 지형 받기 범위 미리보기 — 카카오 지도 */ +.d03-plan__extent > .ui-text { + margin: 0 0 6px; } /* 3D 큰 모달 — 3D 화면이 모달 몸을 꽉 채움 · 몸 스크롤 없음 */ diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts new file mode 100644 index 000000000..daa3a7fcd --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * D03_PlanAnalysis_Draw.ts + * D 예정노선 수동 직선 그리기 셈(PLAN 562-1 · 요구 389 12 · 13) — 구간 길이 · 원지반 기울기 · 길이 고정 · + * 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. 높이는 인자 `heightAt(x, y)`(NaN = 지형 밖). + * DOM · fetch · sessionStorage 없음. + * + * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Draw.py` 같은 이름(snake). 한쪽을 고치면 다른 쪽도 고치고 거울 시험 + * `resources/tester/test_562_1_plan_draw.py`. 화면은 그리는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. + * ========================================================================== */ + +export type Vertex = [number, number]; +export type HeightAt = (x: number, y: number) => number; + +/** 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수. */ +const SLOPE_STEP_M = 1; +const BISECT = 40; +const COMPASS_SAMPLES = 360; +const EPS = 1e-9; + +/** 수평 길이(m). */ +export function segmentLength(a: Vertex, b: Vertex): number { + const dx = b[0] - a[0]; + const dy = b[1] - a[1]; + return Math.sqrt(dx * dx + dy * dy); +} + +/** 원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · null = 지형 밖 · 길이 0. */ +export function groundSlopePct(a: Vertex, b: Vertex, heightAt: HeightAt): number | null { + const length = segmentLength(a, b); + if (length < EPS) return null; + const za = heightAt(a[0], a[1]); + const zb = heightAt(b[0], b[1]); + if (!Number.isFinite(za) || !Number.isFinite(zb)) return null; + return ((zb - za) / length) * 100; +} + +/** 길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 null. */ +export function fixedLengthPoint(from: Vertex, cursor: Vertex, lengthM: number): Vertex | null { + const d = segmentLength(from, cursor); + if (d < EPS) return null; + return [ + from[0] + ((cursor[0] - from[0]) / d) * lengthM, + from[1] + ((cursor[1] - from[1]) / d) * lengthM, + ]; +} + +/** 기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 첫 자리(1 m 걸음 · 이분) · + * `maxM` 안에 없거나 지형 밖에 닿으면 null. */ +export function fixedSlopePoint( + from: Vertex, + cursor: Vertex, + slopePct: number, + heightAt: HeightAt, + maxM: number = 1000, +): Vertex | null { + const d = segmentLength(from, cursor); + const z0 = heightAt(from[0], from[1]); + if (d < EPS || !Number.isFinite(z0)) return null; + const ux = (cursor[0] - from[0]) / d; + const uy = (cursor[1] - from[1]) / d; + const grade = Math.abs(slopePct) / 100; + const at = (t: number): Vertex => [from[0] + ux * t, from[1] + uy * t]; + const f = (t: number): number => { + const p = at(t); + return Math.abs(heightAt(p[0], p[1]) - z0) - grade * t; + }; + let lo = 0; + let flo = NaN; + for (let i = 1; i * SLOPE_STEP_M <= maxM; i += 1) { + const t = i * SLOPE_STEP_M; + const ft = f(t); + if (!Number.isFinite(ft)) return null; + if (ft === 0) return at(t); + if (i > 1 && flo < 0 !== ft < 0) { + let hi = t; + for (let k = 0; k < BISECT; k += 1) { + const mid = (lo + hi) / 2; + const fm = f(mid); + if (!Number.isFinite(fm)) return null; + if (fm < 0 === flo < 0) { + lo = mid; + flo = fm; + } else hi = mid; + } + return at((lo + hi) / 2); + } + lo = t; + flo = ft; + } + return null; +} + +/** 둘 다(양각기) — 반지름 `lengthM` 원 위에서 |기울기| = 목표인 후보들(방위 표본 · 이분 · 동쪽부터 + * 반시계). */ +export function compassCandidates( + from: Vertex, + lengthM: number, + slopePct: number, + heightAt: HeightAt, +): Vertex[] { + const z0 = heightAt(from[0], from[1]); + if (!(lengthM > EPS) || !Number.isFinite(z0)) return []; + const rise = (Math.abs(slopePct) / 100) * lengthM; + const step = (2 * Math.PI) / COMPASS_SAMPLES; + const at = (angle: number): Vertex => [ + from[0] + Math.cos(angle) * lengthM, + from[1] + Math.sin(angle) * lengthM, + ]; + const h = (angle: number): number => { + const p = at(angle); + return Math.abs(heightAt(p[0], p[1]) - z0) - rise; + }; + const values: number[] = []; + for (let k = 0; k < COMPASS_SAMPLES; k += 1) values.push(h(k * step)); + const out: Vertex[] = []; + for (let k = 0; k < COMPASS_SAMPLES; k += 1) { + const hk = values[k]; + const hn = values[(k + 1) % COMPASS_SAMPLES]; + if (!Number.isFinite(hk) || !Number.isFinite(hn)) continue; + if (hk === 0) { + out.push(at(k * step)); + continue; + } + if (hn === 0 || hk < 0 === hn < 0) continue; + let lo = k * step; + let hi = lo + step; + let flo = hk; + let ok = true; + for (let i = 0; i < BISECT; i += 1) { + const mid = (lo + hi) / 2; + const fm = h(mid); + if (!Number.isFinite(fm)) { + ok = false; + break; + } + if (fm < 0 === flo < 0) { + lo = mid; + flo = fm; + } else hi = mid; + } + if (ok) out.push(at((lo + hi) / 2)); + } + return out; +} + +/** 둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 null. */ +export function compassPoint( + from: Vertex, + cursor: Vertex, + lengthM: number, + slopePct: number, + heightAt: HeightAt, +): Vertex | null { + let best: Vertex | null = null; + let bestD = Infinity; + for (const p of compassCandidates(from, lengthM, slopePct, heightAt)) { + const d = segmentLength(p, cursor); + if (d < bestD) { + best = p; + bestD = d; + } + } + return best; +} + +/** 선 길이(m) — 앞에서부터 더함. */ +export function lineLength(line: ReadonlyArray): number { + let total = 0; + for (let i = 1; i < line.length; i += 1) total += segmentLength(line[i - 1], line[i]); + return total; +} + +/** 선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상. */ +export function pointAtDistance(line: ReadonlyArray, m: number): Vertex { + if (m <= 0) return [line[0][0], line[0][1]]; + let acc = 0; + for (let i = 1; i < line.length; i += 1) { + const a = line[i - 1]; + const b = line[i]; + const seg = segmentLength(a, b); + if (seg > 0 && acc + seg >= m) { + const t = (m - acc) / seg; + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + } + acc += seg; + } + const last = line[line.length - 1]; + return [last[0], last[1]]; +} + +/** 점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간). */ +export function distanceAlong(line: ReadonlyArray, point: Vertex): number { + let best = 0; + let bestD = Infinity; + let acc = 0; + for (let i = 1; i < line.length; i += 1) { + const a = line[i - 1]; + const b = line[i]; + const seg = segmentLength(a, b); + let t = 0; + if (seg > 0) { + t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / (seg * seg); + t = Math.min(1, Math.max(0, t)); + } + const p: Vertex = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; + const d = segmentLength(p, point); + if (d < bestD) { + bestD = d; + best = acc + seg * t; + } + acc += seg; + } + return best; +} + +/** 선 조각 — `fromM`~`toM` 사이(끝점 + 그 사이 꺾임점). */ +export function sliceLine(line: ReadonlyArray, fromM: number, toM: number): Vertex[] { + const lo = Math.min(fromM, toM); + const hi = Math.max(fromM, toM); + const out: Vertex[] = [pointAtDistance(line, lo)]; + let acc = 0; + for (let i = 1; i < line.length - 1; i += 1) { + acc += segmentLength(line[i - 1], line[i]); + if (acc > lo + EPS && acc < hi - EPS) out.push([line[i][0], line[i][1]]); + } + out.push(pointAtDistance(line, hi)); + return out; +} + +/** 측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터). */ +export function stationChainages(lengthM: number, intervalM: number = 20): number[] { + const out = [0]; + for (let k = 1; k * intervalM < lengthM - 1e-6; k += 1) out.push(k * intervalM); + if (lengthM > 1e-6) out.push(lengthM); + return out; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts deleted file mode 100644 index afa56b53e..000000000 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Edit.ts +++ /dev/null @@ -1,173 +0,0 @@ -/* ============================================================================= - * 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; -} - -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)) - ); -} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts deleted file mode 100644 index 3a6c10e0c..000000000 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Expect.ts +++ /dev/null @@ -1,124 +0,0 @@ -/* ============================================================================= - * 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, - }, - ]; - }); -} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index 445992c7e..7f48c26bc 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -1,232 +1,236 @@ /* ============================================================================= * D03_PlanAnalysis_Plan.ts - * D 예정노선 2단계 분석 화면 계산(PLAN 530-3) — 갈래 고름 · 이은 누가 · 이은 중심선 · 측점 · - * 지점(연도 경계) · 받은 지형 범위 판정. DOM · fetch · sessionStorage 없음. - * - * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Analyze.py` `pick_mode` · `chain` · `joined_line` · `with_edits` ↔ - * `pickMode` · `chainRows` · `joinedLine` · `withEdits`. 한쪽을 고치면 다른 쪽도 고치고 거울 시험 - * `resources/tester/test_530_3_plan_logic.py`. 화면은 고르는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. + * D 예정노선 2단계 손 긋기 꼴(PLAN 562-3) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기. + * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). + * [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음. * ========================================================================== */ +import { lineLength } from "./D03_PlanAnalysis_Draw"; + export type Vertex = [number, number]; -/** 짝: `D03_PlanAnalysis_Analyze.PLAN_MODES`(follow 뺌). */ -export const PLAN_MODES = ["gradeline", "earthwork", "gentle", "weighted"] as const; -export type PlanMode = (typeof PLAN_MODES)[number]; - -/** 짝: `D03_PlanAnalysis_Terrain.PLAN_CORRIDOR_M` — 지점이 연도 DEM 가장자리에서 이만큼 안이어야 - * 탐색 복도가 받은 지형 안에 듦(DEM = 구간 상자 + 복도 + 50 m). */ -export const PLAN_REACH_M = 300; - -export interface RouteMetrics { - length_m: number; - max_grade_pct?: number; - over_limit?: { total: number }; +/** 그은 선 하나 — 첫 선 = 본선(D02 시점에서) · 둘째부터 = 갈래(`branch` = 붙은 선 · 꺾임점 차례). */ +export interface PlanLine { + id: string; + vertices: Vertex[]; + branch: { line: string; vertex: number } | null; } -export interface PlanRoute { - planned?: Vertex[]; - /** 엔진 노드 · 곡선(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; -} - -export interface YearAnalysis { +/** 조각 = 선(id) 위 [from_m, to_m] 구역 · 연도(구역 마크 = 조각 경계 · id = 업체 지정이 붙는 열쇠라 나눠도 앞 조각이 지님). */ +export interface PlanPiece { + id: string; + line: string; + from_m: number; + to_m: number; year: number; - start: Vertex; - end: Vertex; - /** 분석 입력 지문 — 꺾임점 고침이 이 분석 위에서 한 것인가. */ - fingerprint?: string; - criteria?: { min_radius_m: number; max_grade_pct?: number }; - routes: Record; } -/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */ -export interface RouteEdit { - mode: string; - planned: Vertex[]; - metrics?: RouteMetrics | null; +export interface PlanNetwork { + lines: PlanLine[]; + pieces: PlanPiece[]; } -export interface ChainRow { - year: number; - mode: string | null; - length_m: number; - start_chainage_m: number; - end_chainage_m: number; +/** 마크가 끝 · 다른 마크와 이보다 가까우면 안 놓음(m). */ +export const MARK_GAP_M = 1; + +const round3 = (value: number): number => Math.round(value * 1000) / 1000; + +/** 새 조각 id — `p`(있는 것 중 가장 큰 수 + 1). */ +function nextPieceId(pieces: ReadonlyArray): string { + const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0)); + return `p${Math.max(0, ...used) + 1}`; } -const round3 = (value: number): number => Number(value.toFixed(3)); - -/** 고른 갈래 — 없거나 빈 갈래면 차례대로 첫 성공 갈래. */ -export function pickMode(result: YearAnalysis, wanted: string | null | undefined): string | null { - const routes = result.routes ?? {}; - const ok = (mode: string): boolean => mode in routes && !("error" in routes[mode]); - if (wanted && ok(wanted)) return wanted; - return PLAN_MODES.find(ok) ?? null; +/** 빈 선 — 본선 시점만. */ +export function emptyNetwork(start: Vertex): PlanNetwork { + return { lines: [{ id: "main", vertices: [start], branch: null }], pieces: [] }; } -/** 연도별 고른 갈래 · 시작 · 끝 누가(이어 셈) — 앞 연도 끝 누가 = 다음 연도 시작 누가. */ -export function chainRows( - results: ReadonlyArray, - choices: Readonly>, - startChainageM: number, -): ChainRow[] { - let at = startChainageM || 0; - return results.map((result) => { - const mode = pickMode(result, choices[result.year]); - const length = mode ? Number(result.routes[mode].metrics?.length_m ?? 0) : 0; - const row = { - year: result.year, - mode, - length_m: round3(length), - start_chainage_m: round3(at), - end_chainage_m: round3(at + length), - }; - at += length; - return row; - }); -} - -/** 선 길이(m) — 짝: `D03_PlanAnalysis_Analyze.line_length`(같은 차례로 더함). */ -export function lineLength(line: ReadonlyArray): 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, - edits: Readonly>, -): 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; - -/** 고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채). */ -export function joinedLine( - results: ReadonlyArray, - rows: ReadonlyArray, -): Vertex[] { - const line: Vertex[] = []; - results.forEach((result, i) => { - const mode = rows[i]?.mode; - if (!mode) return; - const planned = result.routes[mode].planned ?? []; - const head = line.length && planned.length && samePoint(line[line.length - 1], planned[0]); - line.push(...(head ? planned.slice(1) : planned)); - }); - return line; -} - -/** 측점 누가(오름차순) — 연도 경계마다(경계점 = 측점) + 간격 배수 + 끝. 노선 없는 연도는 건너뜀. */ -export function stationChainages(rows: ReadonlyArray, intervalM: number): number[] { - const out: number[] = []; - const push = (value: number): void => { - if (!out.length || value - out[out.length - 1] > 1e-6) out.push(round3(value)); - }; - for (const row of rows) { - if (!row.mode) continue; - push(row.start_chainage_m); - let next = Math.floor(row.start_chainage_m / intervalM + 1e-9) * intervalM + intervalM; - for (; next < row.end_chainage_m - 1e-6; next += intervalM) push(next); - push(row.end_chainage_m); +/** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 · + * 조각 없는 선 = 한 조각(`year`). 길이 0 선은 조각 없음. */ +export function normalizePieces(network: PlanNetwork, year: number): PlanNetwork { + const pieces: PlanPiece[] = []; + for (const line of network.lines) { + const length = round3(lineLength(line.vertices)); + if (!(length > 0)) continue; + const own = network.pieces + .filter((piece) => piece.line === line.id) + .sort((a, b) => a.from_m - b.from_m); + const starts = own + .filter((piece) => piece.from_m > MARK_GAP_M / 2 && piece.from_m < length - MARK_GAP_M / 2) + .map((piece) => ({ id: piece.id, from: round3(piece.from_m), year: piece.year })); + const head = own.find((piece) => piece.from_m <= MARK_GAP_M / 2) ?? { id: "", year }; + const cuts = [{ id: head.id, from: 0, year: head.year }, ...starts]; + cuts.forEach((cut, i) => + pieces.push({ + // id 빈 칸 · 연도 없음(서버 마크만 있던 구역) = 새 id · 처음 연도 + id: cut.id || nextPieceId([...network.pieces, ...pieces]), + line: line.id, + from_m: cut.from, + to_m: i + 1 < cuts.length ? cuts[i + 1].from : length, + year: Number.isFinite(cut.year) ? cut.year : year, + }), + ); } - return out; + return { ...network, pieces }; } -/** 측점 그리기 · 집기용 선 — 첫 점 앞에 첫 구간 방향으로 `startM` 만큼 뒤로 뺀 점을 붙여 선 위 누가 = - * 등록 시작 누가부터(`drawStationTicks` · `stationAtScreen` 은 선 첫 점을 0 으로 셈). */ -export function leadInLine(line: ReadonlyArray, startM: number): Vertex[] { - if (line.length < 2 || !(startM > 0)) return [...line]; - const [a, b] = line; - const length = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1; - const back: Vertex = [ - a[0] - ((b[0] - a[0]) / length) * startM, - a[1] - ((b[1] - a[1]) / length) * startM, - ]; - return [back, ...line]; -} - -/** 누가가 든 연도 행(경계는 앞 연도) — 노선 있는 연도만 · 밖이면 null. */ -export function rowAt(rows: ReadonlyArray, chainageM: number): ChainRow | null { - return ( - rows.find( - (row) => - row.mode && - chainageM >= row.start_chainage_m - 1e-6 && - chainageM <= row.end_chainage_m + 1e-6, - ) ?? null +/** 선 위 `atM` 에 마크 — 그 자리 조각을 둘로(같은 연도) · 끝 · 경계와 가까우면 그대로. */ +export function splitPiece(network: PlanNetwork, line: string, atM: number): PlanNetwork { + const index = network.pieces.findIndex( + (piece) => piece.line === line && atM > piece.from_m && atM < piece.to_m, ); + const piece = network.pieces[index]; + if (!piece || atM - piece.from_m < MARK_GAP_M || piece.to_m - atM < MARK_GAP_M) return network; + const at = round3(atM); + const pieces = [...network.pieces]; + pieces.splice( + index, + 1, + { ...piece, to_m: at }, + { ...piece, id: nextPieceId(pieces), from_m: at }, + ); + return { ...network, pieces }; } -/** 연도별 지점 → 마디 P0…Pn(직전 종점 = 다음 시점). */ -export function nodesOf(years: ReadonlyArray<{ start: Vertex; end: Vertex }>): Vertex[] { - return years.length ? [years[0].start, ...years.map((year) => year.end)] : []; +/** `atM` 둘레 `toleranceM` 안 마크를 지움 — 두 조각을 합침(앞 조각 연도). 없으면 그대로. */ +export function removeMark( + network: PlanNetwork, + line: string, + atM: number, + toleranceM: number, +): PlanNetwork { + const index = network.pieces.findIndex( + (piece) => + piece.line === line && piece.from_m > 0 && Math.abs(piece.from_m - atM) <= toleranceM, + ); + if (index < 1 || network.pieces[index - 1].line !== line) return network; + const pieces = [...network.pieces]; + const before = pieces[index - 1]; + pieces.splice(index - 1, 2, { ...before, to_m: pieces[index].to_m }); + return { ...network, pieces }; } -/** 마디 → 연도별 지점(서버 `points` 꼴). */ -export function pointsOf( - years: ReadonlyArray, - nodes: ReadonlyArray, -): Array<{ year: number; start: Vertex; end: Vertex }> { - return years.map((year, i) => ({ year, start: nodes[i], end: nodes[i + 1] })); +/** 조각 연도 바꿈. */ +export function setPieceYear(network: PlanNetwork, index: number, year: number): PlanNetwork { + return { + ...network, + pieces: network.pieces.map((piece, i) => (i === index ? { ...piece, year } : piece)), + }; } -export interface Box { - x0: number; - y0: number; - x1: number; - y1: number; +/** `line` 째 선의 `segment` 번째 구간 안에 꺾임점을 끼움 — 그 선에 붙은 갈래 차례를 한 칸 밈. + * 선 위 점이라 누가 · 조각은 그대로. */ +export function insertVertex( + network: PlanNetwork, + line: number, + segment: number, + point: Vertex, +): PlanNetwork { + const id = network.lines[line].id; + return { + ...network, + lines: network.lines.map((entry, i) => { + if (i === line) { + const vertices = [...entry.vertices]; + vertices.splice(segment + 1, 0, point); + return { ...entry, vertices }; + } + const branch = entry.branch; + return branch && branch.line === id && branch.vertex > segment + ? { ...entry, branch: { ...branch, vertex: branch.vertex + 1 } } + : entry; + }), + }; } -/** 마디마다 받은 지형 안인가 — 그 마디를 끝점으로 쓰는 연도(앞 · 뒤) DEM 상자를 `reachM` 만큼 줄인 안. - * 연도 DEM 이 없으면 밖. */ -export function nodesInReach( - nodes: ReadonlyArray, - boxes: ReadonlyArray, - reachM: number = PLAN_REACH_M, -): boolean[] { - const inside = (point: Vertex, box: Box | null): boolean => - !!box && - point[0] >= box.x0 + reachM && - point[0] <= box.x1 - reachM && - point[1] >= box.y0 + reachM && - point[1] <= box.y1 - reachM; - return nodes.map((point, i) => { - const years = [i - 1, i].filter((year) => year >= 0 && year < boxes.length); - return years.every((year) => inside(point, boxes[year])); - }); +/** 선 끝에 점을 더함. */ +export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork { + return { + ...network, + lines: network.lines.map((entry, i) => + i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry, + ), + }; } -/** 마디가 정본과 같은가(1 mm — 짝: `D02_PlanBase_Router.SAME_M`). */ -export function sameNodes(a: ReadonlyArray, b: ReadonlyArray): boolean { - return a.length === b.length && a.every((point, i) => samePoint(point, b[i])); +/** 선 끝 점을 뺌 — 첫 점(본선 시점 · 갈래 붙은 점)은 못 뺌 · 갈래가 붙은 점도 못 뺌(까닭 글). */ +export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork | string { + const entry = network.lines[line]; + if (!entry || entry.vertices.length < 2) return "더 뺄 점이 없습니다."; + const last = entry.vertices.length - 1; + if ( + network.lines.some((other) => other.branch?.line === entry.id && other.branch.vertex === last) + ) + return "갈래가 붙은 점은 뺄 수 없습니다 — 갈래를 먼저 지우세요."; + return { + ...network, + lines: network.lines.map((other, i) => + i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other, + ), + }; +} + +/** 마지막 선(그리던 갈래)을 지움 — 그 선 조각도. 갈래가 붙어 있으면 그대로. */ +export function dropLastLine(network: PlanNetwork): PlanNetwork { + const index = network.lines.length - 1; + const id = network.lines[index]?.id; + if (index < 1 || network.lines.some((line) => line.branch?.line === id)) return network; + return { + lines: network.lines.slice(0, index), + pieces: network.pieces.filter((piece) => piece.line !== id), + }; +} + +/** 새 갈래 — `line` 째 선의 `vertex` 꺾임점에서 시작. */ +export function addBranch(network: PlanNetwork, line: number, vertex: number): PlanNetwork { + const parent = network.lines[line]; + const from = parent.vertices[vertex]; + const used = new Set(network.lines.map((entry) => entry.id)); + let n = network.lines.length; + while (used.has(`branch-${n}`)) n += 1; + return { + ...network, + lines: [ + ...network.lines, + { id: `branch-${n}`, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } }, + ], + }; +} + +/** 서버 꼴(선 · 마크 · 조각 · 조각 없는 구역 있음) → 화면 꼴(조각이 선을 빈틈없이 덮음). */ +export function fromServer( + lines: PlanLine[], + marks: ReadonlyArray<{ line: string; at_m: number }>, + pieces: ReadonlyArray, + year: number, +): PlanNetwork { + const cuts: PlanPiece[] = marks + .filter( + (mark) => + !pieces.some( + (piece) => piece.line === mark.line && Math.abs(piece.from_m - mark.at_m) < 0.01, + ), + ) + .map((mark) => ({ id: "", line: mark.line, from_m: mark.at_m, to_m: mark.at_m, year: NaN })); + return normalizePieces({ lines, pieces: [...pieces, ...cuts] }, year); +} + +/** 서버 꼴 마크 — 조각 경계(선마다 0 빼고 시작 자리). */ +export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: number }> { + return network.pieces + .filter((piece) => piece.from_m > 0) + .map((piece) => ({ line: piece.line, at_m: piece.from_m })); +} + +/** 본선이 종점에 닿았나(1 mm). */ +export function reachedEnd(network: PlanNetwork, end: Vertex): boolean { + const main = network.lines[0]?.vertices ?? []; + const last = main[main.length - 1]; + return !!last && Math.hypot(last[0] - end[0], last[1] - end[1]) < 1e-3; +} + +/** 선 이름 — 본선 · 갈래 1 · 갈래 2 …(선 차례). */ +export function lineName(index: number): string { + return index === 0 ? "본선" : `갈래 ${index}`; } diff --git a/E01_StabRegister/E01_StabRegister_UI_Missing.ts b/E01_StabRegister/E01_StabRegister_UI_Missing.ts index eadd7b4bc..d711a3ccb 100644 --- a/E01_StabRegister/E01_StabRegister_UI_Missing.ts +++ b/E01_StabRegister/E01_StabRegister_UI_Missing.ts @@ -8,15 +8,17 @@ import { createBanner } from "@ui/ui_template_banner"; import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; +import { stationLabel } from "../common_util/common_util_station"; -/** 빠진 측점이 없으면 빈 칸(띠 없음) */ -export function buildMissingBanner(missing: number[] | undefined): HTMLElement { +/** 빠진 측점이 없으면 빈 칸(띠 없음) · 측점 글 = No.+거리 — interval = 측점 간격(부르는 쪽이 + * `inferStationInterval(측점 목록)` 으로 넘김 · 없으면 20 m) */ +export function buildMissingBanner(missing: number[] | undefined, interval = 20): HTMLElement { const list = missing ?? []; if (!list.length) return el("div"); const box = createBanner({ tone: "warning", text: t("E_Missing_Head").replace("{n}", String(list.length)), - details: el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }), + details: el("p", { text: list.map((m) => stationLabel(m, interval, 1)).join(" · ") }), }); box.classList.add("e-missing"); box.dataset.role = "missing-stations"; diff --git a/E02_StabGround/E02_StabGround_Chain.py b/E02_StabGround/E02_StabGround_Chain.py new file mode 100644 index 000000000..9c75c7f8c --- /dev/null +++ b/E02_StabGround/E02_StabGround_Chain.py @@ -0,0 +1,288 @@ +"""E02 [해석 시작] 체인 (요구 390 1 · PLAN 564-1). + +차례 = 2D → 3D(모든 구간 · 건기 · 우기) → 암반 → 광역 → 보고서. 계산 자리 = ③ 서버 단독. +각 페이지 해석 함수를 그대로 부름(그 파일 안 고침) · 단계마다 입력 지문이 저장본과 같으면 건너뜀 +· 결과는 각 페이지 저장 자리에 영구 저장 · 한 단계가 실패해도 다음 단계로. +도는 동안 그 페이지 진행 표(`_JOBS`)에 같은 일감을 꽂음 → E03 · E04 · E07 화면이 「해석 중」 +· 그 페이지 [해석] 은 409. [끊기] = 다음 확인 때 멈춤(3D 는 엔진 안 · 그 밖은 단계 사이). + +진행 꼴 = {running, percent, stage, stage_name, steps:[{key, name, state, seconds}], + message, error, cancelled} · state = wait · run · done · skip · error · cancel +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections.abc import Awaitable, Callable +from pathlib import Path +from typing import Any +from uuid import UUID + +logger = logging.getLogger(__name__) + +#: 단계 — (열쇠, 이름, 비율 몫) · 몫 = 1.2 km 실측 시간 비(2D 1~3 분 · 3D 건기 · 우기 약 25 분) +STEPS: tuple[tuple[str, str, float], ...] = ( + ("2d", "2D 측점 해석", 10.0), + ("3d", "3D 구간 해석", 84.0), + ("rock", "암반 해석", 2.0), + ("area", "광역 위험", 2.0), + ("report", "보고서", 2.0), +) +CONDITIONS_3D = ("dry", "wet") + +Report = Callable[[float, str], None] # (단계 안 비율 0~1, 글) +Stopped = Callable[[], bool] +Step = Callable[[Report, Stopped], Awaitable[str]] # → "done" | "skip" + +_JOBS: dict[str, dict[str, Any]] = {} +_STOPS: dict[str, bool] = {} +_TASKS: set[asyncio.Task] = set() + + +def idle() -> dict[str, Any]: + return { + "running": False, + "percent": 0, + "stage": None, + "stage_name": None, + "steps": [{"key": k, "name": n, "state": "wait", "seconds": None} for k, n, _ in STEPS], + "message": None, + "error": None, + "cancelled": False, + } + + +def _mark_stage(project_id: str, key: str, state: str) -> None: + """대시보드 단계 상태 — ponytail: 지금은 안 적음 · 565-1 함수가 들어오면 여기서 부름.""" + + +async def run_chain( + job: dict[str, Any], steps: dict[str, Step], stopped: Stopped, project_id: str = "" +) -> None: + """STEPS 차례로 돎 — job 을 제자리에서 고침(진행 GET 이 그대로 읽음).""" + total = sum(w for _, _, w in STEPS) + base = 0.0 + errors: list[str] = [] + for index, (key, name, weight) in enumerate(STEPS): + row = job["steps"][index] + if stopped(): + job.update(cancelled=True, message="끊음") + row["state"] = "cancel" + break + job.update(stage=key, stage_name=name, message=name) + row["state"] = "run" + _mark_stage(project_id, key, "run") + + def report(frac: float, message: str, base: float = base, weight: float = weight) -> None: + frac = min(1.0, max(0.0, float(frac))) + job.update(percent=min(99, int(100 * (base + weight * frac) / total)), message=message) + + started = time.perf_counter() + try: + row["state"] = await steps[key](report, stopped) + except InterruptedError: + row["state"] = "cancel" + job.update(cancelled=True, message="끊음") + break + except Exception as error: # noqa: BLE001 — 단계 실패 격리 · 까닭은 그대로 올림 + logger.exception("E02 체인 %s 실패: project_id=%s", key, project_id) + row["state"] = "error" + errors.append(f"{name}: {getattr(error, 'detail', None) or error}") + finally: + row["seconds"] = round(time.perf_counter() - started, 1) + _mark_stage(project_id, key, row["state"]) + base += weight + job["percent"] = min(99, int(100 * base / total)) + job["error"] = " · ".join(errors) or None + if not job["cancelled"]: + job.update(percent=100, message="끝") + job.update(stage=None, stage_name=None) + + +# --------------------------------------------------------------------------- +# 실제 단계 — 페이지 함수 부르기(늦은 import · E03 · E06 라우터가 E02 라우터를 import 함) +# --------------------------------------------------------------------------- + + +def _plug(jobs: dict[str, dict[str, Any]], pid: str, extra: dict[str, Any]) -> dict[str, Any]: + """그 페이지 진행 표에 체인 일감을 꽂음 — 페이지 화면 「해석 중」 · 페이지 [해석] 409.""" + job = {"running": True, "percent": 0, "message": "한 번에 해석", "error": None, **extra} + jobs[pid] = job + return job + + +def build_steps(project_id: UUID, project_root: Path) -> dict[str, Step]: + from E01_StabRegister import E01_StabRegister_Source as source + from E03_StabSection import E03_StabSection_Router as e03 + from E04_Stab3D import E04_Stab3D_Router as e04 + from E04_Stab3D import E04_Stab3D_Run as run3d + from E05_StabReport import E05_StabReport_Build as e05_build + from E05_StabReport import E05_StabReport_Router as e05 + from E05_StabReport import E05_StabReport_Store as e05_store + from E06_StabRock import E06_StabRock_Router as e06 + from E07_StabArea import E07_StabArea_Router as e07 + + pid = str(project_id) + root = project_root + + async def step_2d(report: Report, _stopped: Stopped) -> str: + plan = await asyncio.to_thread(e03.plan_run, root, e03._ground(root)) + if e03.read_derived(root, e03.KIND_SUMMARY, plan["fp"]): + return "skip" + page = _plug(e03._JOBS, pid, {"computed": 0}) + + def relay(percent: int, message: str) -> None: + page.update(percent=int(percent), message=message) + report(percent / 100, f"2D {message}") + + try: + page["computed"] = await asyncio.to_thread(e03.run_all, root, plan, relay) + page.update(percent=100, message="끝") + finally: + page["running"] = False + return "done" + + def search_settings() -> dict[str, Any]: + """3D 탐색 설정 = 가장 최근 저장본 설정 · 없으면 기본값(구간 · 조건 빼고).""" + files = sorted(run3d.results_dir(root).glob("*.json"), key=lambda p: p.stat().st_mtime_ns) + saved = run3d.load(root, files[-1].stem) if files else None + fields = e04.SearchSettings.model_fields + values = {k: v for k, v in ((saved or {}).get("settings") or {}).items() if k in fields} + return e04.SearchSettings(**values).model_dump() + + async def step_3d(report: Report, stopped: Stopped) -> str: + src = e04._source_root(root) + stations = await asyncio.to_thread(source.stations, src) + sections = run3d.auto_sections(stations) + search = await asyncio.to_thread(search_settings) + units = [(s, c) for s in sections for c in CONDITIONS_3D] + computed = 0 + for index, (section, condition) in enumerate(units): + if stopped(): + raise InterruptedError + settings = { + "start_m": round(section["start_m"], 2), + "end_m": round(section["end_m"], 2), + "condition": condition, + **search, + } + key = run3d.section_key(settings["start_m"], settings["end_m"], condition) + label = f"3D {index + 1} / {len(units)} 구간" + finger = await asyncio.to_thread(e04._current_fingerprint, root, src, settings) + saved = await asyncio.to_thread(run3d.load, root, key) + if saved and saved["fingerprint"] == finger: + report((index + 1) / len(units), label) + continue + page = _plug(e04._JOBS, pid, {**e04._IDLE, "running": True, "key": key}) + e04._STOPS[pid] = False + + def relay(percent: int, message: str, index: int = index, page=page) -> None: + page.update(percent=max(0, min(99, int(percent))), message=message) + report((index + percent / 100) / len(units), label) + + try: + page["summary"] = await asyncio.to_thread( + run3d.analyze, + root, + src, + settings, + finger, + relay, + lambda: stopped() or e04._STOPS.get(pid, False), + ) + page.update(percent=100, message="끝") + except InterruptedError: + page.update(cancelled=True, message="끊음") + raise + finally: + page["running"] = False + computed += 1 + return "done" if computed else "skip" + + async def step_rock(_report: Report, _stopped: Stopped) -> str: + out = await asyncio.to_thread(e06._run, root) + return "skip" if out["status"] == "fresh" else "done" + + async def step_area(report: Report, _stopped: Stopped) -> str: + src = e07._source_root(root) + saved = await asyncio.to_thread(e07.load, root) + base = (saved or {}).get("settings") or e07.defaults(root) + settings = e07.AreaRequest(**base).model_dump(exclude={"force"}) + finger = e07.fingerprint(source.manifest(src)["source_fingerprint"], settings) + if saved and saved["fingerprint"] == finger: + return "skip" + page = _plug(e07._JOBS, pid, {}) + + def relay(percent: int, message: str) -> None: + page.update(percent=max(0, min(99, int(percent))), message=message) + report(percent / 100, f"광역 {message}") + + try: + await asyncio.to_thread(e07.run, root, src, settings, finger, relay) + page.update(percent=100, message="끝") + finally: + page["running"] = False + return "done" + + async def step_report(_report: Report, _stopped: Stopped) -> str: + async with e05._lock(project_id): + book, meta = e05_store.load(root) + fresh = await asyncio.to_thread(e05_build.analysis_fingerprint, root) + if book is not None and meta and meta.get("fingerprint") == fresh: + return "skip" + await asyncio.to_thread(e05_store.refresh, root) + return "done" + + return { + "2d": step_2d, + "3d": step_3d, + "rock": step_rock, + "area": step_area, + "report": step_report, + } + + +def busy(project_id: UUID) -> bool: + """체인 · 페이지 해석 하나라도 도는지 — 한 프로젝트에 체인 하나만.""" + from E03_StabSection import E03_StabSection_Router as e03 + from E04_Stab3D import E04_Stab3D_Router as e04 + from E07_StabArea import E07_StabArea_Router as e07 + + pid = str(project_id) + return any( + (jobs.get(pid) or {}).get("running") for jobs in (_JOBS, e03._JOBS, e04._JOBS, e07._JOBS) + ) + + +def start(project_id: UUID, project_root: Path) -> dict[str, Any]: + """뒤에서 체인 시작 — 부르는 쪽이 busy 를 먼저 봄.""" + pid = str(project_id) + job = {**idle(), "running": True, "message": "시작"} + _JOBS[pid] = job + _STOPS[pid] = False + steps = build_steps(project_id, project_root) + + async def work() -> None: + try: + await run_chain(job, steps, lambda: _STOPS.get(pid, False), pid) + finally: + job["running"] = False + + task = asyncio.create_task(work(), name=f"E02 chain {pid}") + _TASKS.add(task) + task.add_done_callback(_TASKS.discard) + return job + + +def progress(project_id: UUID) -> dict[str, Any]: + return dict(_JOBS.get(str(project_id)) or idle()) + + +def cancel(project_id: UUID) -> bool: + pid = str(project_id) + running = bool((_JOBS.get(pid) or {}).get("running")) + if running: + _STOPS[pid] = True + return running diff --git a/E02_StabGround/E02_StabGround_Router.py b/E02_StabGround/E02_StabGround_Router.py index aee593430..66fe01cc6 100644 --- a/E02_StabGround/E02_StabGround_Router.py +++ b/E02_StabGround/E02_StabGround_Router.py @@ -5,6 +5,8 @@ - `PUT …/stab/ground` — [저장] 검사 뒤 작업본 `E02_StabGround/ground.json` · 측점(B06 암 경계)은 서버 값 - `POST …/stab/ground/reset` — [초기화] 초기값을 작업본에 덮어씀(재계산 아님) +- `POST …/stab/run` — [해석 시작] 지반 저장 → 한 번에 해석 체인(`E02_StabGround_Chain` · PLAN 564-1) + · `GET …/stab/run/progress` · `POST …/stab/run/cancel` 초기값 = E 생성 직후 마스터 기본값 + 복사본 B06 측점(`ensure_initial`) · 그 밖에는 쓰지 않음. 공유 회사(보기만)는 `require_project_access` 가 PUT · POST 를 막음. @@ -29,6 +31,7 @@ from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from config.config_system_design import STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M from E01_StabRegister.E01_StabRegister_Source import db_snapshot, missing_stations, source_dir +from E02_StabGround import E02_StabGround_Chain as chain from E02_StabGround import E02_StabGround_Model as model router = APIRouter(prefix="/api/projects", tags=["E02 Stab Ground"]) @@ -130,20 +133,25 @@ async def get_ground( } -@router.put("/{project_id}/stab/ground") -async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: - root, _ = await _project(project_id) +def _save(root: Path, payload: dict[str, Any]) -> dict[str, Any]: + """검사 뒤 작업본 쓰기 — 틀리면 422.""" defaults = model.load_defaults() - errors = model.check_ground(payload.ground, defaults["limits"]) + errors = model.check_ground(payload, defaults["limits"]) if errors: raise HTTPException(status_code=422, detail={"errors": errors}) - ground = model.clean(payload.ground) + ground = model.clean(payload) # 측점(B06 암 경계)은 서버 값 — 브라우저 값을 받아 적지 않음 base = _read_working(root) or ensure_initial(root) ground["stations"] = base.get("stations", []) ground["version"] = defaults["version"] atomic_write_json(model.ground_path(root), ground) - return {"ground": ground} + return ground + + +@router.put("/{project_id}/stab/ground") +async def put_ground(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: + root, _ = await _project(project_id) + return {"ground": _save(root, payload.ground)} @router.post("/{project_id}/stab/ground/reset") @@ -152,3 +160,26 @@ async def reset_ground(project_id: UUID) -> dict[str, Any]: initial = ensure_initial(root) atomic_write_json(model.ground_path(root), initial) return {"ground": model.upgrade(initial, model.load_defaults())} + + +@router.post("/{project_id}/stab/run") +async def run_chain(project_id: UUID, payload: GroundRequest) -> dict[str, Any]: + """[해석 시작] — 지반 저장 → 체인(뒤에서) · 진행은 `GET …/stab/run/progress`.""" + root, _ = await _project(project_id) + if chain.busy(project_id): + raise HTTPException(status_code=409, detail="이미 해석 중입니다.") + ground = _save(root, payload.ground) + return {"ground": ground, **chain.start(project_id, root)} + + +@router.get("/{project_id}/stab/run/progress") +async def run_progress(project_id: UUID) -> dict[str, Any]: + await _project(project_id) + return chain.progress(project_id) + + +@router.post("/{project_id}/stab/run/cancel") +async def run_cancel(project_id: UUID) -> dict[str, Any]: + """[끊기] — 다음 확인 때 멈춤 · 끝난 단계 저장본은 그대로.""" + await _project(project_id) + return {"stopping": chain.cancel(project_id)} diff --git a/E02_StabGround/E02_StabGround_UI_Page.ts b/E02_StabGround/E02_StabGround_UI_Page.ts index a483e2d71..a32bae7c5 100644 --- a/E02_StabGround/E02_StabGround_UI_Page.ts +++ b/E02_StabGround/E02_StabGround_UI_Page.ts @@ -2,8 +2,10 @@ * E02_StabGround_UI_Page.ts * E02 지반 조건(요구서 345 4-3 · 요구 347-2 · 3 · 4 · PLAN 532-3) — B05~B09 틀(group_workflow). * GET /api/projects/{id}/stab/ground → 작업본(없으면 초기값) · 기본값 · 고칠 수 있는지. - * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT(서버가 다시 검사 · 측점은 서버 값) · - * [초기화] = POST …/reset(초기값 덮어씀). 검사 = logic/E02_StabGround_Check(파이썬 짝). + * 조작 = sessionStorage 캐시만(자동저장 없음) · [해석 시작] = POST …/stab/run(서버가 다시 검사 · + * 저장 → 2D · 3D · 암반 · 광역 · 보고서 체인 · PLAN 564-3) · 진행 = GET …/stab/run/progress(다른 + * 페이지에 갔다 와도 이어 보임) · [끊기] · [초기화] = POST …/reset(초기값 덮어씀). + * 검사 = logic/E02_StabGround_Check(파이썬 짝). * 칸 위 마우스 = 틀린 까닭 · 기본값 · 근거(지식DB 07 · KDS 11 70 05). * ========================================================================== */ @@ -18,6 +20,8 @@ import { showToast, } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { createListRow } from "@ui/ui_template_list_row"; +import { createProgressBar } from "@ui/ui_template_progress"; import { createText } from "@ui/ui_template_text"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; @@ -38,8 +42,33 @@ interface GroundResponse { missing_stations?: number[]; } +interface RunStep { + key: string; + state: string; + seconds: number | null; +} + +/** 체인 진행(`E02_StabGround_Chain` 진행 꼴) */ +interface RunProgress { + running: boolean; + percent: number; + steps: RunStep[]; + message: string | null; + error: string | null; + cancelled: boolean; +} + const cacheKey = (projectId: string): string => `e02_ground_cache:${projectId}`; +/** 단계 줄 왼쪽 띠 색 — 기다림 = 없음 */ +const STEP_TONE: Record = { + run: "var(--color-accent)", + done: "var(--color-success)", + skip: "var(--color-success)", + error: "var(--color-danger)", + cancel: "var(--color-warning)", +}; + /** 검사 열쇠 → 범위 열쇠(limits) */ function limitKey(path: string): string { const last = path.split(".").pop() ?? ""; @@ -56,13 +85,13 @@ function errorText(error: CheckError, defaults: GroundDefaults): string { return error.code === "range" && bound ? `${text} (${bound[0]} ~ ${bound[1]})` : text; } -async function request(path: string, init?: RequestInit): Promise { +async function request(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, ...init, }); - const body = (await response.json().catch(() => ({}))) as GroundResponse & { detail?: unknown }; + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; if (!response.ok) { const detail = body.detail; if (typeof detail === "string") throw new Error(detail); @@ -76,29 +105,95 @@ export function renderE02StabGround(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const main = el("div", { className: "e02-page" }); const status = el("div", { className: "e02-status" }); - const saveButton = createButton({ - label: t("Common_Btn_Save"), + const runButton = createButton({ + label: t("E02_Run"), variant: "filled", - onClick: () => void save(), + onClick: () => void runChain(), }); const resetButton = createButton({ label: t("Common_Btn_Reset"), variant: "danger-ghost", onClick: () => void reset(), }); - saveButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 + runButton.disabled = resetButton.disabled = true; // 읽어 온 뒤 켬 const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(resetButton, saveButton); + actions.append(resetButton, runButton); + const progressBar = createProgressBar(); + const progressText = createText(""); + const stepList = el("div", { className: "e02-run__steps" }); + const cancelButton = createButton({ + label: t("E02_Run_Cancel"), + variant: "danger-ghost", + size: "sm", + onClick: () => void cancelChain(), + }); + const runBox = el("div", { className: "e02-run" }); + runBox.append(progressBar.root, progressText, stepList, cancelButton); + runBox.hidden = true; const panel = el("div", { className: "ui-sidebar-panel e02-panel" }); - panel.append(hook(createText(t("E02_Note")), "e02-hint"), status, actions); + panel.append( + hook(createText(t("E02_Note")), "e02-hint"), + hook(createText(t("E02_Run_Hint")), "e02-hint"), + status, + runBox, + actions, + ); mountGroupStep(root, ROUTES.E02_STAB_GROUND, { mainContent: main, leftPanel: panel }); if (!projectId) return void main.append(hook(createText(t("E02_NoProject")), "e02-empty")); let view: E02View | null = null; let refreshStations = (): void => {}; let dirty = false; + let running = false; let missing: number[] = []; + const paintButtons = (): void => { + runButton.disabled = resetButton.disabled = !view?.editable || running; + }; + + const paintRun = (p: RunProgress): void => { + running = p.running; + runBox.hidden = !p.running && p.steps.every((s) => s.state === "wait"); + progressBar.set(p.percent / 100, `${p.percent}%`); + progressText.textContent = p.running ? `${t("E02_Run_Running")} · ${p.message ?? ""}` : ""; + stepList.replaceChildren( + ...p.steps.map((s) => + createListRow({ + label: t(`E02_Run_Step_${s.key}` as LocaleKey), + trailing: [ + t(`E02_Run_State_${s.state}` as LocaleKey), + s.seconds !== null && s.state !== "wait" ? `${s.seconds} s` : "", + ] + .filter(Boolean) + .join(" · "), + stripe: STEP_TONE[s.state] ?? "transparent", + active: s.state === "run", + size: 28, + }), + ), + ); + cancelButton.hidden = !p.running; + paintButtons(); + }; + + /** 체인이 끝날 때까지 — 다른 페이지로 가면 멈춤(체인은 서버에서 계속 · 다시 오면 이어 봄) */ + async function poll(): Promise { + while (root.isConnected) { + const p = await request(`/projects/${projectId}/stab/run/progress`).catch( + () => null, + ); + if (!p) return; + paintRun(p); + if (!p.running) { + if (p.error) showToast(p.error, "error"); + else if (p.cancelled) showToast(t("E02_Run_Cancelled"), "info"); + else showToast(t("E02_Run_Done"), "success"); + return; + } + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + const paintStatus = (errors: CheckError[]): void => { const lines: string[] = []; if (view && !view.editable) lines.push(t("E02_ReadOnly")); @@ -158,12 +253,13 @@ export function renderE02StabGround(root: HTMLElement): void { view.ground = ground; view.defaults = body.defaults; view.editable = body.editable ?? false; - saveButton.disabled = resetButton.disabled = !view.editable; + paintButtons(); render(); }; - async function save(): Promise { - if (!view?.editable) return; + /** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */ + async function runChain(): Promise { + if (!view?.editable || running) return; const errors = mark(); if (errors.length) { showToast( @@ -174,16 +270,30 @@ export function renderE02StabGround(root: HTMLElement): void { } showLoadingOverlay(); try { - const body = await request(`/projects/${projectId}/stab/ground`, { - method: "PUT", + const body = await request(`/projects/${projectId}/stab/run`, { + method: "POST", body: JSON.stringify({ ground: view.ground }), }); - adopt({ ...body, defaults: view.defaults, editable: view.editable }, false); - showToast(t("E02_Saved"), "success"); + adopt({ ground: body.ground, defaults: view.defaults, editable: view.editable }, false); + paintRun(body); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + return; + } finally { + hideLoadingOverlay(); + } + void poll(); + } + + async function cancelChain(): Promise { + cancelButton.disabled = true; + try { + await request(`/projects/${projectId}/stab/run/cancel`, { method: "POST" }); + showToast(t("E02_Run_Stopping"), "info"); } catch (error) { showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); } finally { - hideLoadingOverlay(); + cancelButton.disabled = false; } } @@ -246,6 +356,9 @@ export function renderE02StabGround(root: HTMLElement): void { }, }; adopt(body, true); + const p = await request(`/projects/${projectId}/stab/run/progress`); + paintRun(p); + if (p.running) void poll(); } catch (error) { main.replaceChildren( hook( diff --git a/E02_StabGround/E02_StabGround_UI_Style.css b/E02_StabGround/E02_StabGround_UI_Style.css index bade5d2fd..0bf50e4be 100644 --- a/E02_StabGround/E02_StabGround_UI_Style.css +++ b/E02_StabGround/E02_StabGround_UI_Style.css @@ -121,3 +121,20 @@ gap: 2px; align-items: center; } + +/* [해석 시작] 진행 — 막대 · 단계 줄 · [끊기] */ +.e02-run { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: var(--spacing-8) 0; +} + +.e02-run[hidden] { + display: none; +} + +.e02-run__steps { + display: flex; + flex-direction: column; +} diff --git a/E03_StabSection/E03_StabSection_Router.py b/E03_StabSection/E03_StabSection_Router.py index d7da920a5..9b86ec5ed 100644 --- a/E03_StabSection/E03_StabSection_Router.py +++ b/E03_StabSection/E03_StabSection_Router.py @@ -21,6 +21,7 @@ import base64 import hashlib import json import logging +import math import os import subprocess import sys @@ -32,8 +33,10 @@ from typing import Any from uuid import UUID import numpy as np +import shapely from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel +from scipy.spatial import cKDTree from common_util.common_util_auth import verify_session from common_util.common_util_derived_store import ( @@ -73,6 +76,9 @@ ENGINE_FILES = ( ) #: 3D 보기 노선면 솎음 간격(m) CORRIDOR_STEP_M = 1.0 +TERRAIN_MARGIN_M = 80.0 # 노선면 둘레 지형 띠 폭(B05 지형 띠 여유와 같음) +TERRAIN_STEP_M = 2.0 # 지형 칸(이보다 촘촘히 안 함) +TERRAIN_MAX_CELLS = 250_000 # 응답 크기 상한 — 넘으면 칸을 키움 #: 프로세스 수 — None = 코어 − 2 · 1 = 서버 안에서 차례로(시험) WORKERS: int | None = None @@ -435,10 +441,49 @@ async def put_representative(project_id: UUID, payload: RepresentativeRequest) - return {"overrides": overrides} +def _terrain(dtm: str, origin: list[float], strips: list[np.ndarray]) -> dict[str, Any] | None: + """설계 지형 격자(E 복사본 DTM) — 노선면 둘레 띠만 · 노면 발자국 안 = None(깎기 지형이 노면을 + 덮지 않게 · 가장자리 틈 한 칸 허용). 꼴 = E04 Grid + z(행 우선 iy·nx+ix) · 리본과 같은 원점.""" + try: + sampler = DtmGridSampler.open_cached(dtm) + except (OSError, ValueError, KeyError): + logger.warning("E03 3D 지형 격자 못 엶: %s", dtm) + return None + pts = np.vstack([p.reshape(-1, 3)[:, :2] for p in strips]) + lo = pts.min(axis=0) - TERRAIN_MARGIN_M + hi = pts.max(axis=0) + TERRAIN_MARGIN_M + area = float(np.prod(hi - lo)) + dx = max(TERRAIN_STEP_M, math.ceil(math.sqrt(area / TERRAIN_MAX_CELLS) * 10) / 10) + nx, ny = (np.floor((hi - lo) / dx).astype(int) + 1).tolist() + xx, yy = np.meshgrid(lo[0] + np.arange(nx) * dx, lo[1] + np.arange(ny) * dx) + flat = np.column_stack([xx.ravel(), yy.ravel()]) + near = np.isfinite(cKDTree(pts).query(flat, distance_upper_bound=TERRAIN_MARGIN_M)[0]) + # 발자국 = 리본마다 첫 열 + 끝 열(거꾸로) 다각형의 합집합 + rings = [np.vstack([p[:, 0, :2], p[::-1, -1, :2]]) for p in strips if len(p) >= 2] + foot = shapely.union_all([shapely.make_valid(shapely.Polygon(r)) for r in rings]) + hole = shapely.contains_xy(foot, flat[:, 0], flat[:, 1]) + z, valid = sampler.sample_xy(flat + np.asarray(origin[:2])) + keep = near & valid & ~hole & np.isfinite(z) + local = np.round(z - origin[2], 2) + return { + "grid": { + "x0": round(float(lo[0]), 3), + "y0": round(float(lo[1]), 3), + "dx": dx, + "nx": nx, + "ny": ny, + }, + "z": [float(v) if k else None for v, k in zip(local.tolist(), keep.tolist())], + } + + @lru_cache(maxsize=2) -def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]: +def _corridor3d( + path: str, _stamp: tuple[int, int], dtm: str | None, _dtm_stamp: tuple[int, int] | None +) -> dict[str, Any]: data = json.loads(Path(path).read_text(encoding="utf-8")) ribbons = [] + strips = [] origin = None for ribbon in data.get("ribbons") or []: cols = int(ribbon["colCount"]) @@ -453,6 +498,7 @@ def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]: if keep[-1] != len(chain) - 1: keep = np.r_[keep, len(chain) - 1] local = pos[keep] - np.asarray(origin) + strips.append(local) ribbons.append( { "kind": ribbon["kind"], @@ -462,15 +508,25 @@ def _corridor3d(path: str, _stamp: tuple[int, int]) -> dict[str, Any]: "positions": np.round(local, 3).reshape(-1).tolist(), } ) - return {"origin": origin, "ribbons": ribbons} + terrain = _terrain(dtm, origin, strips) if dtm and origin and strips else None + return {"origin": origin, "ribbons": ribbons, "terrain": terrain} @router.get("/{project_id}/stab/corridor3d", response_model=None) async def corridor3d(project_id: UUID) -> dict[str, Any]: - """복사본 노선면 — 원점 빼고 1 m 솎음(모델 좌표 x · y · z).""" + """복사본 노선면 — 원점 빼고 1 m 솎음(모델 x · y · z) + 설계 지형 격자(노면 구멍).""" root, _ = await e02._project(project_id) - path = src.corridor_path(src.source_dir(root)) + source = src.source_dir(root) + path = src.corridor_path(source) if not path: raise HTTPException(status_code=404, detail="복사본에 노선면이 없습니다.") stat = path.stat() - return await asyncio.to_thread(_corridor3d, str(path), (stat.st_mtime_ns, stat.st_size)) + try: + dtm = src.terrain_grid_path(source) + dtm_stat = dtm.stat() + dtm_key = (str(dtm), (dtm_stat.st_mtime_ns, dtm_stat.st_size)) + except (OSError, FileNotFoundError): + dtm_key = (None, None) + return await asyncio.to_thread( + _corridor3d, str(path), (stat.st_mtime_ns, stat.st_size), *dtm_key + ) diff --git a/E03_StabSection/E03_StabSection_UI_List.ts b/E03_StabSection/E03_StabSection_UI_List.ts index 9cfcf82ed..db51f4c4d 100644 --- a/E03_StabSection/E03_StabSection_UI_List.ts +++ b/E03_StabSection/E03_StabSection_UI_List.ts @@ -9,6 +9,7 @@ import { createIconButton } from "@ui/ui_template_button"; import { el } from "@ui/ui_template_elements"; import { createEmpty } from "@ui/ui_template_empty"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { stationLabel } from "../common_util/common_util_station"; import { e03Table } from "./E03_StabSection_UI_Table"; import { listRows, @@ -29,7 +30,9 @@ export interface ListDraw { sort: SortMode; ngOnly: boolean; active: string | null; - /** 사용자 켬/끔 — 저장 전 캐시 포함 · 없으면 서버 값 */ + /** 측점 간격(m) — 측점 글 「No.+거리」 의 분모 */ + interval: number; + /** 사용자 켬/끔 — 서버 값(별 누름 = 바로 저장) */ representative: (row: StationRow) => { on: boolean; user: boolean | null }; editable: boolean; } @@ -81,7 +84,7 @@ export function buildList( ); verdict.classList.add("e03-verdict", `e03-verdict--${v}`); const station = el("span", { - text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key, + text: stationLabel(row.chainage_m, draw.interval, 1), children: row.walls ? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })] : [], diff --git a/E03_StabSection/E03_StabSection_UI_Page.ts b/E03_StabSection/E03_StabSection_UI_Page.ts index b7baa1711..4d5621753 100644 --- a/E03_StabSection/E03_StabSection_UI_Page.ts +++ b/E03_StabSection/E03_StabSection_UI_Page.ts @@ -24,7 +24,8 @@ import { t, type LocaleKey } from "@ui/ui_template_locale"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing"; import { buildList } from "./E03_StabSection_UI_List"; -import { buildPlan } from "./E03_StabSection_UI_Plan"; +import { buildPlan, type PlanView } from "./E03_StabSection_UI_Plan"; +import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts"; import { buildResult } from "./E03_StabSection_UI_Result"; import { @@ -84,7 +85,6 @@ async function api(path: string, init?: RequestInit): Promise { return body; } -const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`; const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); export function renderE03StabSection(root: HTMLElement): void { @@ -111,9 +111,9 @@ export function renderE03StabSection(root: HTMLElement): void { const slopeRecs = new Map(); // 측점 열쇠 → 마지막 가정 const wallRecs = new Map(); const supportRecs = new Map(); - let repCache: Record = JSON.parse( - sessionStorage.getItem(repKey(pid)) ?? "{}", - ); + let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390) + const planView: PlanView = {}; + let repSaving: Promise = Promise.resolve(); // 별 누름마다 차례로 저장 // ---- 좌측 ---- const condField = createSelectField({ @@ -158,15 +158,10 @@ export function renderE03StabSection(root: HTMLElement): void { variant: "ghost", onClick: () => void show3d(), }); - const repButton = createButton({ - label: t("E03_RepSave"), - variant: "ghost", - onClick: () => void saveRep(), - }); const controls = el("div", { className: "e03-panel__row" }); controls.append(condField.root, sortField.root); const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(repButton, view3dButton, analyzeButton); + actions.append(view3dButton, analyzeButton); panel.append(controls, ngBox, status, listHost, actions); // ---- 본문 ---- @@ -187,11 +182,8 @@ export function renderE03StabSection(root: HTMLElement): void { const repOf = (row: StationRow): { on: boolean; user: boolean | null } => { const server = row.representative ?? { auto: [], user: null, on: false }; - if (!(row.key in repCache)) return { on: server.on, user: server.user }; - const user = repCache[row.key]; - return { on: user === null ? server.auto.length > 0 : user, user }; + return { on: server.on, user: server.user }; }; - const repDirty = (): boolean => Object.keys(repCache).length > 0; const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active); @@ -217,10 +209,8 @@ export function renderE03StabSection(root: HTMLElement): void { if (!result.fresh) lines.push(t("E03_Stale")); } if (result && !result.editable) lines.push(t("E03_ReadOnly")); - if (repDirty()) lines.push(t("E03_RepUnsaved")); status.textContent = lines.join("\n"); analyzeButton.disabled = !result?.editable; - repButton.disabled = !result?.editable || !repDirty(); view3dButton.disabled = result?.status !== "ok"; }; @@ -235,22 +225,12 @@ export function renderE03StabSection(root: HTMLElement): void { sort, ngOnly, active, + interval, representative: repOf, editable: result.editable, }, (key) => void select(key), - (key) => { - const row = result!.stations.find((r) => r.key === key)!; - const now = repOf(row).on; - const auto = (row.representative?.auto.length ?? 0) > 0; - // 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌 - const next = !now === auto ? null : !now; - if (next === (row.representative?.user ?? null)) delete repCache[key]; - else repCache[key] = next; - sessionStorage.setItem(repKey(pid), JSON.stringify(repCache)); - paintList(); - paintStatus(); - }, + (key) => void toggleRep(key), ), ); }; @@ -286,7 +266,7 @@ export function renderE03StabSection(root: HTMLElement): void { }); const title = el("h3", { className: "e03-card__title", - text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`, + text: `${stationLabel(view.chainage_m, interval, 1)} · ${t(`E03_Cond_${condition}` as LocaleKey)}`, }); const flags = el("div", { className: "e03-panel__row" }); flags.append( @@ -399,7 +379,16 @@ export function renderE03StabSection(root: HTMLElement): void { const paintPlan = (): void => { planHost.replaceChildren( - result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"), + result + ? buildPlan( + result.stations, + condition, + active, + interval, + planView, + (key) => void select(key), + ) + : el("div"), ); }; @@ -438,7 +427,10 @@ export function renderE03StabSection(root: HTMLElement): void { condition, ); const keys = result.stations.map((r) => r.key); - repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k))); + interval = inferStationInterval( + result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m), + ); + delete planView.zoom; // 다시 읽음 = 띠 원배율 if (!active || !keys.includes(active)) { // 처음 = 그 조건 가장 낮은 안전율 측점 const worst = result.stations @@ -488,20 +480,50 @@ export function renderE03StabSection(root: HTMLElement): void { } } - async function saveRep(): Promise { - try { + /** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */ + function toggleRep(key: string): Promise { + repSaving = repSaving.then(async () => { + const row = result?.stations.find((r) => r.key === key); + if (!row) return; + const server = row.representative ?? { auto: [], user: null, on: false }; + const auto = server.auto.length > 0; + const next = !server.on === auto ? null : !server.on; const overrides: Record = {}; - for (const row of result?.stations ?? []) { - const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null); - if (user !== null) overrides[row.key] = user; + for (const r of result?.stations ?? []) { + const user = r.key === key ? next : (r.representative?.user ?? null); + if (user !== null) overrides[r.key] = user; } - await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) }); - repCache = {}; - sessionStorage.removeItem(repKey(pid)); - showToast(t("E03_RepSaved"), "success"); - await load(); - } catch (error) { - showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + try { + await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) }); + row.representative = { ...server, user: next, on: next === null ? auto : next }; + paintList(); + void refreshReport(); + } catch (error) { + showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); + } + }); + return repSaving; + } + + /** 보고서 갱신 — E05 [보고서 갱신] 과 같은 길(GET 이 작업본 없으면 처음 만듦 · 뒤에서) */ + let reportRun = 0; + async function refreshReport(): Promise { + const run = ++reportRun; + await sleep(800); // 별을 잇달아 누르면 마지막 한 번만 + if (run !== reportRun) return; + try { + const state = await api<{ book: unknown; rev: string | null }>( + `/projects/${pid}/stab/report`, + ); + if (!state.book || run !== reportRun) return; + const body = await api<{ overwritten?: string[] }>(`/projects/${pid}/stab/report/refresh`, { + method: "POST", + body: JSON.stringify({ rev: state.rev }), + }); + const over = body.overwritten ?? []; + if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000); + } catch { + /* 보고서는 E05 를 열 때 stale 로 알림 */ } } diff --git a/E03_StabSection/E03_StabSection_UI_Plan.ts b/E03_StabSection/E03_StabSection_UI_Plan.ts index cb38e360c..cb1bacaea 100644 --- a/E03_StabSection/E03_StabSection_UI_Plan.ts +++ b/E03_StabSection/E03_StabSection_UI_Plan.ts @@ -3,14 +3,20 @@ * E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이 * 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점. * 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`. + * 확대 · 이동 = 공용 `attachSvgZoomPan`(휠 · 가운데 끌기 · 두 번 누름 원배율 · 요구 390) · 측점 글 솎음 + * = logic `labelStride` · `planLabelKeys` · 배율 · 폭은 바깥 `PlanView` 가 쥠(다시 지어도 살아남음). * ========================================================================== */ import { createBadge } from "@ui/ui_template_badge"; +import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; +import { stationLabel } from "../common_util/common_util_station"; import { el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; import { bandSegments, + labelStride, planFit, + planLabelKeys, verdictOf, type Condition, type Point, @@ -21,11 +27,20 @@ import { svgEl } from "./E03_StabSection_UI_Section"; const H = 96; const PAD = 10; +const LABEL_GAP = 64; // 측점 글 사이 최소 화면 간격(px) + +/** 띠 배율 · 그때 폭 — 폭이 바뀌면 원배율로 */ +export interface PlanView { + zoom?: ZoomPanState; + width?: number; +} export function buildPlan( rows: StationRow[], condition: Condition, active: string | null, + interval: number, + state: PlanView, onSelect: (key: string) => void, ): HTMLElement { const box = card("e03-plan"); @@ -49,47 +64,110 @@ export function buildPlan( const host = el("div"); box.append(host); + const ro = new ResizeObserver(() => draw()); const draw = (): void => { + if (!host.isConnected) return void ro.disconnect(); // 다시 지은 띠 — 옛 것은 손 뗌 const W = Math.max(host.clientWidth, 300); const segs = bandSegments(rows, condition); const all: Point[] = segs.flatMap((s) => s.points); const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" }); - if (all.length) { - const fit = planFit(all, W, H, PAD); - const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; - for (const seg of segs) { - svg.append( - svgEl("polyline", { - points: seg.points.map((p) => scr(p).join(",")).join(" "), - class: `e03-plan__seg e03-plan__seg--${seg.verdict}`, - "data-verdict": seg.verdict, - }), - ); - } - const centers = rows.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null); - const picked = centers.find((r) => r.key === active); - if (picked) { - const [x, y] = scr([picked.center![0]!, picked.center![1]!]); - svg.append( - svgEl("circle", { cx: x, cy: y, r: 6, class: "e03-plan__pick", "data-key": picked.key }), - ); - } - svg.addEventListener("click", (ev) => { - const box2 = svg.getBoundingClientRect(); - const px = ((ev.clientX - box2.left) / box2.width) * W; - const py = ((ev.clientY - box2.top) / box2.height) * H; - let best: StationRow | null = null; - let dist = Infinity; - for (const r of centers) { - const [x, y] = scr([r.center![0]!, r.center![1]!]); - const d = Math.hypot(x - px, y - py); - if (d < dist) [best, dist] = [r, d]; - } - if (best && dist < 30) onSelect(best.key); - }); - } host.replaceChildren(svg); + if (!all.length) return; + const fit = planFit(all, W, H, PAD); + const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty]; + const layer = svgEl("g", { "data-role": "plan-layer" }) as SVGGElement; + for (const seg of segs) { + layer.append( + svgEl("polyline", { + points: seg.points.map((p) => scr(p).join(",")).join(" "), + class: `e03-plan__seg e03-plan__seg--${seg.verdict}`, + "data-verdict": seg.verdict, + }), + ); + } + const centers = rows + .filter((r) => r.center && r.center[0] !== null && r.center[1] !== null) + .sort((a, b) => a.chainage_m - b.chainage_m); + const at = (r: StationRow): Point => scr([r.center![0]!, r.center![1]!]); + // 측점 간격 하나의 화면 길이(원배율) — 중심 이은 길이 ÷ 누가 길이 × 간격 + const span = + centers.length > 1 ? centers[centers.length - 1].chainage_m - centers[0].chainage_m : 0; + let len = 0; + for (let i = 1; i < centers.length; i += 1) { + const [a, b] = [at(centers[i - 1]), at(centers[i])]; + len += Math.hypot(b[0] - a[0], b[1] - a[1]); + } + const intervalPx = span > 0 ? (len / span) * interval : 0; + const maxScale = Math.min(500, Math.max(8, Math.ceil(240 / Math.max(intervalPx, 1e-3)))); + const picked = centers.find((r) => r.key === active); + const pick = picked + ? svgEl("circle", { + cx: at(picked)[0], + cy: at(picked)[1], + r: 6, + class: "e03-plan__pick", + "data-key": picked.key, + }) + : null; + const labels = svgEl("g", { class: "e03-plan__labels" }) as SVGGElement; + layer.append(labels, ...(pick ? [pick] : [])); + svg.append(layer); + let stride = 0; + const apply = (s: ZoomPanState): void => { + state.zoom = s; + labels.style.setProperty("--e03-label-k", String(1 / s.scale)); // 글 · 표시는 확대 안 함 + pick?.setAttribute("r", String(6 / s.scale)); + const next = intervalPx > 0 ? labelStride(intervalPx * s.scale, LABEL_GAP) : 100; + if (next === stride) return; + stride = next; + const keep = new Set(planLabelKeys(centers, interval, stride, active)); + labels.replaceChildren( + ...centers + .filter((r) => keep.has(r.key)) + .map((r) => + svgEl( + "text", + { + x: at(r)[0], + y: at(r)[1], + dy: "-0.9em", + class: "e03-label e03-plan__label", + "data-key": r.key, + }, + stationLabel(r.chainage_m, interval, 1), + ), + ), + ); + }; + const initial = state.width === W ? state.zoom : undefined; + state.width = W; + attachSvgZoomPan({ + svg, + layer, + widthPx: W, + heightPx: H, + pad: { left: 0, right: 0, top: 0, bottom: 0 }, + wheelZoom: true, + maxScale, + initial, + onChange: apply, + }); + apply(initial ?? { scale: 1, tx: 0, ty: 0 }); + svg.addEventListener("click", (ev) => { + const box2 = svg.getBoundingClientRect(); + const px = ((ev.clientX - box2.left) / box2.width) * W; + const py = ((ev.clientY - box2.top) / box2.height) * H; + const s = state.zoom ?? { scale: 1, tx: 0, ty: 0 }; + let best: StationRow | null = null; + let dist = Infinity; + for (const r of centers) { + const [x, y] = at(r); + const d = Math.hypot(s.scale * x + s.tx - px, s.scale * y + s.ty - py); + if (d < dist) [best, dist] = [r, d]; + } + if (best && dist < 30) onSelect(best.key); + }); }; - new ResizeObserver(draw).observe(host); + ro.observe(host); return box; } diff --git a/E03_StabSection/E03_StabSection_UI_Style.css b/E03_StabSection/E03_StabSection_UI_Style.css index f0de0ff7b..598b87117 100644 --- a/E03_StabSection/E03_StabSection_UI_Style.css +++ b/E03_StabSection/E03_StabSection_UI_Style.css @@ -27,6 +27,7 @@ --e03-critical: #0b0b0b; --e03-surface-line: #52514e; --e03-plot: #fcfcfb; + --e03-terrain: #c9c2a8; } :root[data-theme="dark"] { @@ -49,6 +50,7 @@ --e03-critical: #f0efec; --e03-surface-line: #c3c2b7; --e03-plot: #1a1a19; + --e03-terrain: #5b5646; } @media (prefers-color-scheme: dark) { @@ -72,13 +74,14 @@ --e03-critical: #f0efec; --e03-surface-line: #c3c2b7; --e03-plot: #1a1a19; + --e03-terrain: #5b5646; } } /* ---- 틀 ---- */ .e03-page { display: grid; - grid-template-rows: auto minmax(0, 1fr); + grid-template-rows: auto auto minmax(0, 1fr); gap: var(--spacing-8); height: calc(100dvh - var(--app-header-h)); padding: var(--spacing-8) var(--spacing-12); @@ -207,6 +210,7 @@ .e03-plan__seg { fill: none; stroke-width: 7; + vector-effect: non-scaling-stroke; stroke-linecap: butt; stroke-linejoin: round; cursor: pointer; @@ -228,6 +232,12 @@ fill: var(--e03-plot); stroke: var(--e03-critical); stroke-width: 2; + vector-effect: non-scaling-stroke; +} + +.e03-plan__label { + text-anchor: middle; + pointer-events: none; } .e03-legend { diff --git a/E03_StabSection/E03_StabSection_UI_View3D.ts b/E03_StabSection/E03_StabSection_UI_View3D.ts index 5ea6b5006..70518fe4b 100644 --- a/E03_StabSection/E03_StabSection_UI_View3D.ts +++ b/E03_StabSection/E03_StabSection_UI_View3D.ts @@ -3,6 +3,7 @@ * E03 [3D 보기](PLAN 532-3) — 복사본 노선면(GET …/stab/corridor3d · 1 m 솎음)에 측점 안전율 색칠(표시만). * 색 = 가장 가까운 측점의 그 조건 최소 (안전율 ÷ 기준) 띠(횡단 등안전율과 같은 띠 · logic `bandIndex`) · * 해석 없는 측점 = 회색. createRouteViewer 는 DB 지표면 · 색 고리가 없어 쓰지 않고 three 를 바로 씀. + * 설계 지형 = 응답 `terrain`(E 복사본 DTM · 노면 자리 구멍 · 요구 390) → E04 `terrainMesh` · 한 색 · 켬끔. * ========================================================================== */ import * as THREE from "three"; @@ -10,7 +11,9 @@ import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js"; import { el } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; -import { legend, muted } from "./E03_StabSection_UI_Parts"; +import { terrainMesh } from "../E04_Stab3D/logic/E04_Stab3D_Mesh"; +import type { Grid } from "../E04_Stab3D/logic/E04_Stab3D_Raster"; +import { check, legend, muted } from "./E03_StabSection_UI_Parts"; import { bandIndex, RATIO_BREAKS, @@ -27,6 +30,8 @@ interface Corridor3d { chainages: number[]; positions: number[]; }[]; + /** 설계 지형 — z 행 우선(iy·nx+ix) · null = 노면 구멍 · 띠 밖 · 복사본에 격자 없으면 null */ + terrain?: { grid: Grid; z: (number | null)[] } | null; } /** 측점 하나 그 조건 최소 비(안전율 ÷ 기준)의 띠 */ @@ -61,9 +66,16 @@ export async function openView3d( color: `var(--e03-band-${i})`, })); items.push({ label: t("E03_Verdict_none"), color: "var(--e03-none)" }); + let ground: THREE.Mesh | null = null; const legendRow = el("div", { className: "e03-legend", - children: [muted(`${conditionLabel} · ${t("E03_View3d_Legend")}`), legend(items)], + children: [ + muted(`${conditionLabel} · ${t("E03_View3d_Legend")}`), + legend(items), + ...(data.terrain + ? [check(t("E03_View3d_Terrain"), true, (v) => ground && (ground.visible = v))] + : []), + ], }); const host = el("div", { className: "e03-view3d__canvas" }); let teardown = (): void => undefined; @@ -145,6 +157,30 @@ export async function openView3d( } scene.add(group); + // 설계 지형 — 모델 [x, y, z] → three [x, z, −y](리본과 같음) · 노면과 겹치는 곳은 뒤로 밀어 둠 + if (data.terrain) { + const mesh = terrainMesh(data.terrain.grid, data.terrain.z); + const pos = new Float32Array(mesh.positions.length); + for (let i = 0; i < pos.length; i += 3) { + pos.set([mesh.positions[i], mesh.positions[i + 2], -mesh.positions[i + 1]], i); + } + const geometry = new THREE.BufferGeometry(); + geometry.setAttribute("position", new THREE.BufferAttribute(pos, 3)); + geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1)); + geometry.computeVertexNormals(); + ground = new THREE.Mesh( + geometry, + new THREE.MeshLambertMaterial({ + color: cssColor("--e03-terrain"), + side: THREE.DoubleSide, + polygonOffset: true, + polygonOffsetFactor: 1, + polygonOffsetUnits: 1, + }), + ); + scene.add(ground); + } + const box = new THREE.Box3().setFromObject(group); const sphere = box.getBoundingSphere(new THREE.Sphere()); const camera = new THREE.PerspectiveCamera(45, 1, 0.5, Math.max(sphere.radius * 20, 1000)); @@ -178,12 +214,15 @@ export async function openView3d( (n, m) => n + ((m as THREE.Mesh).geometry.getAttribute("position").count ?? 0), 0, ), + terrain: ground?.geometry.getAttribute("position").count ?? 0, }; const dispose = (): void => { cancelAnimationFrame(frame); observer.disconnect(); controls.dispose(); + ground?.geometry.dispose(); + (ground?.material as THREE.Material | undefined)?.dispose(); group.traverse((o) => { if (o instanceof THREE.Mesh) { o.geometry.dispose(); diff --git a/E03_StabSection/logic/E03_StabSection_Logic.ts b/E03_StabSection/logic/E03_StabSection_Logic.ts index c869cb37a..969d97871 100644 --- a/E03_StabSection/logic/E03_StabSection_Logic.ts +++ b/E03_StabSection/logic/E03_StabSection_Logic.ts @@ -190,6 +190,31 @@ export function planFit( return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 }; } +/** 평면 띠 측점 글 솎음 걸음 — 측점 간격 화면 길이(px) × 걸음이 minPx 넘는 가장 작은 것 */ +export const PLAN_LABEL_STRIDES = [1, 2, 5, 10, 25, 50, 100] as const; + +export function labelStride(intervalPx: number, minPx: number): number { + return PLAN_LABEL_STRIDES.find((s) => s * intervalPx >= minPx) ?? 100; +} + +/** 측점 글 붙일 열쇠 — 본 측점(간격 배수) 중 걸음 배수 · 걸음 1 이면 보조 측점도 · 고른 측점은 늘 */ +export function planLabelKeys( + rows: StationRow[], + interval: number, + stride: number, + active: string | null, +): string[] { + const step = interval > 0 ? interval : 1; + return rows + .filter((r) => r.center && r.center[0] !== null && r.center[1] !== null) + .filter((r) => { + if (r.key === active || stride === 1) return true; + const n = Math.round(r.chainage_m / step); + return Math.abs(r.chainage_m - n * step) < 0.05 && n % stride === 0; + }) + .map((r) => r.key); +} + /* ---- 안전율 색 ------------------------------------------------------------ */ /** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */ diff --git a/E04_Stab3D/E04_Stab3D_Router.py b/E04_Stab3D/E04_Stab3D_Router.py index 2416a95e8..374ba4eab 100644 --- a/E04_Stab3D/E04_Stab3D_Router.py +++ b/E04_Stab3D/E04_Stab3D_Router.py @@ -5,7 +5,7 @@ - `POST …/stab/analyze3d/cancel` — 도는 해석 끊기(다시 시작 = analyze3d 다시) - `GET …/stab/progress3d` — 진행률 - `GET …/stab/result3d` — 자동 구간 · 노선 · 측점 · 저장본 요약(+ 지문이 지금과 다른지) -- `GET …/stab/result3d?key=` — 한 구간 저장본(래스터 · 임계 활동면) +- `GET …/stab/result3d?key=` — 한 구간 · 조건 저장본(래스터 · 임계 활동면) · 열쇠 = `시작_끝_조건` 계산 자리 = ③ 서버 단독. 권한 = main.py `protected_with_company`(지정받은 회사 = GET 만 · 그 밖 403). """ @@ -14,6 +14,7 @@ from __future__ import annotations import asyncio import logging +import re from pathlib import Path from typing import Any, Literal from uuid import UUID @@ -126,7 +127,7 @@ async def analyze3d(project_id: UUID, payload: Analyze3dRequest) -> dict[str, An project_root = await _project_root(project_id) root = _source_root(project_root) settings = _settings(payload) - key = run.section_key(settings["start_m"], settings["end_m"]) + key = run.section_key(settings["start_m"], settings["end_m"], settings["condition"]) finger = await asyncio.to_thread(_current_fingerprint, project_root, root, settings) saved = await asyncio.to_thread(run.load, project_root, key) if not payload.force and saved and saved["fingerprint"] == finger: @@ -229,7 +230,7 @@ async def result3d( overview = await asyncio.to_thread(_overview, project_root, root) editable = await _editable(project_id, session) return {"status": "success", "editable": editable, **overview} - if not all(ch.isdigit() or ch in "._" for ch in key): + if not re.fullmatch(r"\d+\.\d{2}_\d+\.\d{2}(_(dry|wet))?", key): raise HTTPException(status_code=400, detail="구간 열쇠가 틀렸습니다.") doc = await asyncio.to_thread(run.load, project_root, key) if doc is None: diff --git a/E04_Stab3D/E04_Stab3D_Run.py b/E04_Stab3D/E04_Stab3D_Run.py index fcd600263..2b40fcaaf 100644 --- a/E04_Stab3D/E04_Stab3D_Run.py +++ b/E04_Stab3D/E04_Stab3D_Run.py @@ -1,8 +1,9 @@ """E04 3D 해석 한 번 — 입력 모으기 · 입력 지문 · 엔진 부르기 · 파생 저장본 (PLAN 533-3). 계산 자리 = ③ 서버 단독. 엔진(탐색 · 기둥 솔버 · 합성 지형) 주인 = 533-1 · 2 — 여기는 부르기만. -저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 입력 지문(복사본 지문 · E02 지반 · -요청 · 엔진 판)이 같으면 다시 셈하지 않음. +저장본 = E 프로젝트 `E04_Stab3D/results/<구간 열쇠>.json` · 열쇠 = `시작_끝_조건` +(예 `0.00_100.00_wet` · 옛 `시작_끝` 파일은 그 안 조건으로 읽음) · 입력 지문(복사본 지문 · +E02 지반 · 요청 · 엔진 판 = 엔진 파일 해시)이 같으면 다시 셈하지 않음. 래스터 꼴(화면 `logic/E04_Stab3D_Raster.ts` 와 같은 열쇠): grid = {x0, y0, dx, nx, ny} — 셀 (iy, ix) 중심 = (x0 + ix·dx, y0 + iy·dx) · 줄 우선 평탄 배열 @@ -22,6 +23,7 @@ import math import time from collections.abc import Callable from datetime import datetime +from functools import lru_cache from pathlib import Path from typing import Any @@ -34,7 +36,13 @@ from E03_StabSection.E03_StabSection_Types import DEFAULT_ROAD_SURCHARGE_KPA from E04_Stab3D import E04_Stab3D_Engine_Grid as grid_engine from E04_Stab3D import E04_Stab3D_Engine_Search as search_engine -ENGINE_VERSION = "e04-4" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_FILES = ( + "E04_Stab3D_Engine_Column.py", + "E04_Stab3D_Engine_Grid.py", + "E04_Stab3D_Engine_Search.py", + "E04_Stab3D_Run.py", +) +CONDITIONS = ("dry", "wet") STAGE_DIR = "E04_Stab3D" SECTION_LENGTH_M = 100.0 # 자동 구간 길이 MAX_SECTION_M = 500.0 # 한 번에 셀 구간 상한(시간 · 메모리) @@ -48,8 +56,23 @@ def results_dir(project_root: Path) -> Path: return Path(project_root) / STAGE_DIR / "results" -def section_key(start_m: float, end_m: float) -> str: - return f"{start_m:.2f}_{end_m:.2f}" +@lru_cache(maxsize=1) +def engine_hash() -> str: + """엔진 판 = 엔진 코드 지문 — 코드가 바뀌면 지문이 달라져 다시 셈.""" + digest = hashlib.sha1() + for name in ENGINE_FILES: + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def section_key(start_m: float, end_m: float, condition: str = "dry") -> str: + return f"{start_m:.2f}_{end_m:.2f}_{condition}" + + +def _doc_key(doc: dict[str, Any]) -> str: + summary = doc["summary"] + condition = summary.get("condition") or doc["settings"].get("condition") or "dry" + return section_key(float(summary["start_m"]), float(summary["end_m"]), condition) def auto_sections(stations: list[dict[str, Any]]) -> list[dict[str, float]]: @@ -85,7 +108,7 @@ def fingerprint( "source": source_fingerprint, "ground": ground, "settings": settings, - "engine": ENGINE_VERSION, + "engine": engine_hash(), }, ensure_ascii=False, sort_keys=True, @@ -308,7 +331,7 @@ def pack( ) return { "fingerprint": finger, - "engine": ENGINE_VERSION, + "engine": engine_hash(), "settings": settings, "computed_at": datetime.now().isoformat(timespec="seconds"), "elapsed_s": round(elapsed_s, 2), @@ -346,24 +369,37 @@ def save(project_root: Path, key: str, result: dict[str, Any]) -> None: def load(project_root: Path, key: str) -> dict[str, Any] | None: - path = results_dir(project_root) / f"{key}.json" - return json.loads(path.read_text(encoding="utf-8")) if path.is_file() else None + """열쇠 파일 → 없으면 옛 이름(`시작_끝`) 파일이 같은 조건일 때.""" + folder = results_dir(project_root) + path = folder / f"{key}.json" + if path.is_file(): + return json.loads(path.read_text(encoding="utf-8")) + base, _, condition = key.rpartition("_") + legacy = folder / f"{base}.json" + if condition in CONDITIONS and legacy.is_file(): + doc = json.loads(legacy.read_text(encoding="utf-8")) + if _doc_key(doc) == key: + return doc + return None def saved_summaries(project_root: Path) -> list[dict[str, Any]]: - """저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용.""" - out = [] + """저장본 요약(래스터 빼고) — 구간 목록 색 · 판정용. + + 열쇠 = 구간 + 조건 · 같은 열쇠면 새 이름 파일이 옛 이름 파일을 이김.""" + found: dict[str, dict[str, Any]] = {} for path in sorted(results_dir(project_root).glob("*.json")): doc = json.loads(path.read_text(encoding="utf-8")) - out.append( - { - "key": path.stem, - "fingerprint": doc["fingerprint"], - "settings": doc["settings"], - **doc["summary"], - } - ) - return out + key = _doc_key(doc) + if key in found and path.stem != key: + continue + found[key] = { + "key": key, + "fingerprint": doc["fingerprint"], + "settings": doc["settings"], + **doc["summary"], + } + return [found[k] for k in sorted(found)] def analyze( @@ -385,5 +421,9 @@ def analyze( ground = read_ground(project_root) raw = run_engine(root, around, ground, settings, on_progress, should_stop) result = pack(raw, picked, settings, finger, time.perf_counter() - started) - save(project_root, section_key(settings["start_m"], settings["end_m"]), result) + save( + project_root, + section_key(settings["start_m"], settings["end_m"], settings["condition"]), + result, + ) return result["summary"] diff --git a/E04_Stab3D/E04_Stab3D_UI_Map.ts b/E04_Stab3D/E04_Stab3D_UI_Map.ts index 43c0d8435..95d4f978d 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Map.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Map.ts @@ -7,6 +7,7 @@ import { el } from "@ui/ui_template_elements"; import { tooltipBox } from "@ui/ui_template_hover_info"; +import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { t } from "@ui/ui_template_locale"; import { cutCells, footprintCenter } from "./logic/E04_Stab3D_Mesh"; import { @@ -43,8 +44,6 @@ interface View { const RANGE_COLOR = "#2563eb"; // 고른 구간 — 3D 노선 관과 같은 색 · 안전율 색 단계와 겹치지 않음 -const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); - const css = (name: string, fallback: string): string => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; @@ -60,6 +59,7 @@ export function createPlanMap(): PlanMap { let line: number[][] = []; let stations: StationPoint[] = []; + let interval = 20; // 측점 간격 — setRoute 때 측점 목록에서 추정 let range: [number, number] | null = null; let result: Result3d | null = null; let layer: MapLayer = "fs"; @@ -140,8 +140,8 @@ export function createPlanMap(): PlanMap { ctx.stroke(); } // 측점 — 화면 간격이 좁으면 다섯 개마다 이름 - // 이름 = "STA.0+020.000" → "0+020" · 이름끼리 70 px 이상 떨어지게 1 · 2 · 5 · 10 … 개마다 - const gap = stations.length > 1 ? 20 * view.scale : 999; + // 이름 = No.+거리(예 61+14.5) · 이름끼리 70 px 이상 떨어지게 1 · 2 · 5 · 10 … 개마다 + const gap = stations.length > 1 ? interval * view.scale : 999; const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100; ctx.font = "11px sans-serif"; ctx.textBaseline = "middle"; @@ -152,7 +152,7 @@ export function createPlanMap(): PlanMap { ctx.beginPath(); ctx.arc(px, py, i % every === 0 ? 3 : 1.5, 0, Math.PI * 2); ctx.fill(); - if (i % every === 0 && s.label) ctx.fillText(shortLabel(s.label), px + 6, py - 8); + if (i % every === 0) ctx.fillText(stationLabel(s.chainage_m, interval, 1), px + 6, py - 8); }); } @@ -311,7 +311,7 @@ export function createPlanMap(): PlanMap { tip.textContent = [ `${t("E04_Layer_FS")} ${f.toFixed(3)}`, `${t("E04_Layer_Volume")} ${v === null ? "-" : `${Math.round(v).toLocaleString()} m³`}`, - near ? `${t("E04_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "", + near ? `${t("E04_Tip_Station")} ${stationLabel(near.chainage_m, interval, 1)}` : "", ] .filter(Boolean) .join("\n"); @@ -351,6 +351,7 @@ export function createPlanMap(): PlanMap { setRoute(nextLine, nextStations) { line = nextLine; stations = nextStations; + interval = inferStationInterval(stations); fit(); draw(); }, diff --git a/E04_Stab3D/E04_Stab3D_UI_Page.ts b/E04_Stab3D/E04_Stab3D_UI_Page.ts index e0a3dbbff..d855b06a8 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Page.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Page.ts @@ -4,7 +4,8 @@ * 좌 = 구간 목록 · 탐색 설정(기본 · 고급 접힘) · [해석] · 진행률 · [끊기] * 가운데 = 평면 지도(셀별 최소 안전율 · 임계 체적) · [3D 보기](지표 + 노선면 + 임계 활동면) * 우 = 구간 결과(3D 최소 · 2D 최소(같은 구간 E03 측점 · 3D 와 같은 조건 안전율 최소) · 3D/2D 비 · 체적 · 방법) - * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage. + * 계산 = 서버 단독(POST …/stab/analyze3d · 뒤에서) · 화면은 값 · 그림만. 탐색 설정 = sessionStorage + * · 없으면 저장된 결과의 설정 · 결과 열쇠 = 구간 + 조건(건기 · 우기 따로 남음). * ========================================================================== */ import "./E04_Stab3D_UI_Style.css"; @@ -23,6 +24,7 @@ import { createSegment } from "@ui/ui_template_segment"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { createText } from "@ui/ui_template_text"; import { mountGroupStep } from "../A00_Common/group_workflow"; +import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map"; import { createView3D } from "./E04_Stab3D_UI_View3D"; import { buildSettingsCards, type Settings } from "./E04_Stab3D_UI_Settings"; @@ -44,6 +46,7 @@ interface SavedSummary { f_min: number; volume: number; method: string; + condition: string; stale: boolean; } @@ -67,7 +70,10 @@ interface Progress { } const cacheKey = (projectId: string): string => `e04_settings:${projectId}`; -const sectionKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; +const CONDITIONS = ["dry", "wet"] as const; +const rangeKey = (start: number, end: number): string => `${start.toFixed(2)}_${end.toFixed(2)}`; +const sectionKey = (start: number, end: number, condition: string): string => + `${rangeKey(start, end)}_${condition}`; async function api(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -218,16 +224,11 @@ export function renderE04Stab3D(root: HTMLElement): void { const analyzeButton = createButton({ label: t("E04_Analyze"), variant: "filled", - onClick: () => void analyze(false), + onClick: () => void analyze(), }); - const reanalyzeButton = createButton({ - label: t("E04_Reanalyze"), - variant: "ghost", - onClick: () => void analyze(true), - }); - analyzeButton.disabled = reanalyzeButton.disabled = true; + analyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(reanalyzeButton, analyzeButton); + actions.append(analyzeButton); const readOnly = note(t("E04_ReadOnly"), "e04-hint"); readOnly.hidden = true; panel.append( @@ -246,46 +247,55 @@ export function renderE04Stab3D(root: HTMLElement): void { let result: Result3d | null = null; let stations2d: Station2d[] = []; let running = false; + /** 측점 글 = No.+거리(소수 한 자리) · 간격 = 측점 목록 추정 */ + const station = (chainage: number): string => + stationLabel(chainage, inferStationInterval(overview?.stations ?? []), 1); const remember = (): void => sessionStorage.setItem(cacheKey(projectId), JSON.stringify(settings.read())); let shownKey = ""; settings.onChange(() => { remember(); - const { start_m, end_m } = settings.read(); + const { start_m, end_m, condition } = settings.read(); map.setRange(start_m, end_m); paintSections(); - if (shownKey !== sectionKey(start_m, end_m)) void openResult(); + if (shownKey !== sectionKey(start_m, end_m, condition)) void openResult(); }); function paintButtons(): void { const editable = Boolean(overview?.editable); - analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + analyzeButton.disabled = !editable || running || !overview; readOnly.hidden = !overview || editable; } function paintSections(): void { if (!overview) return; - const { start_m, end_m } = settings.read(); + const { start_m, end_m, condition } = settings.read(); const savedOf = new Map(overview.saved.map((s) => [s.key, s])); - const custom = overview.saved.filter( - (s) => !overview!.sections.some((x) => sectionKey(x.start_m, x.end_m) === s.key), - ); - const items = [ - ...overview.sections, - ...custom.map((s) => ({ start_m: s.start_m, end_m: s.end_m })), - ]; + const items = new Map(overview.sections.map((x) => [rangeKey(x.start_m, x.end_m), x])); + for (const s of overview.saved) { + const key = rangeKey(s.start_m, s.end_m); + if (!items.has(key)) items.set(key, { start_m: s.start_m, end_m: s.end_m }); + } sectionList.replaceChildren( - ...items.map((section) => { - const key = sectionKey(section.start_m, section.end_m); - const saved = savedOf.get(key); + ...[...items].map(([key, section]) => { + const byCondition = CONDITIONS.map((c) => savedOf.get(`${key}_${c}`)); + const saved = savedOf.get(`${key}_${condition}`); const row = createListRow({ - label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`, - trailing: saved - ? `Fs ${saved.f_min.toFixed(2)}${saved.stale ? ` · ${t("E04_Stale")}` : ""}` - : "", + label: `${station(section.start_m)} – ${station(section.end_m)}`, + trailing: byCondition + .flatMap((x) => + x + ? [ + `${t(`E04_Short_${x.condition}` as LocaleKey)} ${x.f_min.toFixed(2)}${ + x.stale ? ` ${t("E04_Stale")}` : "" + }`, + ] + : [], + ) + .join(" · "), stripe: saved ? stepColor(FS_STEPS, saved.f_min) : "transparent", - active: key === sectionKey(start_m, end_m), + active: key === rangeKey(start_m, end_m), onClick: () => settings.setRange(section.start_m, section.end_m), }); row.classList.add("e04-section"); @@ -307,7 +317,7 @@ export function renderE04Stab3D(root: HTMLElement): void { s.end_m, ); const ratio = ratio3d2d(s.f_min, f2d); - const station = overview?.stations.reduce( + const nearest = overview?.stations.reduce( (best, x) => !best || Math.abs(x.chainage_m - s.chainage_m) < Math.abs(best.chainage_m - s.chainage_m) ? x @@ -329,7 +339,7 @@ export function renderE04Stab3D(root: HTMLElement): void { ["E04_Result_Bishop", s.by_method.bishop.toFixed(3)], ["E04_Result_Janbu", s.by_method.janbu_corrected.toFixed(3)], ["E04_Result_Hovland", s.by_method.hovland.toFixed(3)], - ["E04_Result_Where", station?.label ?? `${s.chainage_m.toFixed(1)} m`], + ["E04_Result_Where", station(nearest?.chainage_m ?? s.chainage_m)], ["E04_Result_Cells", s.cells.toLocaleString()], ["E04_Result_Trials", s.trials.toLocaleString()], ["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`], @@ -364,15 +374,19 @@ export function renderE04Stab3D(root: HTMLElement): void { resultBody.replaceChildren(list, ...notes); } + const currentKey = (): string => { + const { start_m, end_m, condition } = settings.read(); + return sectionKey(start_m, end_m, condition); + }; + async function openResult(): Promise { - const { start_m, end_m } = settings.read(); - const key = sectionKey(start_m, end_m); + const key = currentKey(); shownKey = key; const saved = overview?.saved.some((s) => s.key === key); result = saved ? await api(`/projects/${projectId}/stab/result3d?key=${key}`).catch(() => null) : null; - if (key !== sectionKey(settings.read().start_m, settings.read().end_m)) return; // 그새 바뀜 + if (key !== currentKey()) return; // 그새 바뀜 map.setResult(result); if (mode === "3d") view3d.show(result, overview?.line ?? [], layer); paintResult(); @@ -405,13 +419,13 @@ export function renderE04Stab3D(root: HTMLElement): void { } } - async function analyze(force: boolean): Promise { + async function analyze(): Promise { const values = settings.read(); const { start_m, end_m, condition, ...search } = values; try { const body = await api<{ status: string }>(`/projects/${projectId}/stab/analyze3d`, { method: "POST", - body: JSON.stringify({ start_m, end_m, condition, search, force }), + body: JSON.stringify({ start_m, end_m, condition, search }), }); if (body.status === "cached") { showToast(t("E04_Cached"), "info"); @@ -446,7 +460,7 @@ export function renderE04Stab3D(root: HTMLElement): void { ...ov.defaults, start_m: first?.start_m ?? 0, end_m: first?.end_m ?? 100, - condition: "dry", + condition: ov.saved[0]?.condition ?? "dry", }, ); map.setRoute(ov.line, ov.stations); @@ -455,6 +469,12 @@ export function renderE04Stab3D(root: HTMLElement): void { paintSections(); stations2d = await fetch2d(projectId); await openResult(); + // 캐시가 없으면 저장된 결과의 탐색 설정을 되살림 + const opened = result as Result3d | null; // openResult 가 바꿈(흐름 좁힘 풀기) + if (!cached && opened?.settings) { + settings.write(opened.settings as Partial); + remember(); + } const p = await api(`/projects/${projectId}/stab/progress3d`).catch(() => null); if (p?.running) void poll(); })(); diff --git a/E04_Stab3D/E04_Stab3D_UI_Settings.ts b/E04_Stab3D/E04_Stab3D_UI_Settings.ts index 215201303..e27573cfd 100644 --- a/E04_Stab3D/E04_Stab3D_UI_Settings.ts +++ b/E04_Stab3D/E04_Stab3D_UI_Settings.ts @@ -98,8 +98,8 @@ export function buildSettingsCards() { variant: "ghost", size: "sm", onClick: () => { - const range = { start_m: read().start_m, end_m: read().end_m }; - write({ ...read(), ...defaults, ...range, condition: "dry" }); + const { start_m, end_m, condition } = read(); // 구간 · 조건은 그대로 + write({ ...read(), ...defaults, start_m, end_m, condition }); changed(); }, }), diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts index fa66bf777..03840d0e0 100644 --- a/E06_StabRock/E06_StabRock_UI_Page.ts +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -1,11 +1,11 @@ /* ============================================================================= * E06_StabRock_UI_Page.ts * E06 암반 평사투영 해석(요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-1) — B05~B09 틀(group_workflow). - * 좌 = 절리군 표 · 해석 설정 · [초기화] [저장] [해석] + * 좌 = 절리군 표 · 해석 설정 · [초기화] [해석] * 가운데 = 위험 띠 · 스테레오넷(등각 · 등면적 · 보기 = 전체 · 평면 · 쐐기 · 전도) * 우 = 측점별 모드 수 · 최소 안전율 · 판정 · 고른 측점 안전율 표 - * 조작 = sessionStorage 캐시만(자동저장 없음) · [저장] = PUT · [초기화] = POST …/reset · - * [해석] = 저장 안 한 고침이 있으면 먼저 저장 → POST …/analyze(서버 단독) → 결과 GET. + * 조작 = sessionStorage 캐시만(자동저장 없음) · [초기화] = POST …/reset · + * [해석] = 저장(고침이 있으면 PUT) → POST …/analyze(서버 단독) → 결과 GET · 저장 단추 없음. * ========================================================================== */ import "./E06_StabRock_UI_Style.css"; @@ -22,6 +22,7 @@ import { } from "@ui/ui_template_elements"; import { t } from "@ui/ui_template_locale"; import { createSidePanels } from "@ui/ui_template_side_panels"; +import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { buildJointCards, @@ -88,11 +89,6 @@ export function renderE06StabRock(root: HTMLElement): void { // ---- 좌 ---- const cards = el("div", { className: "e06-cards" }); const status = el("div", { className: "e06-status" }); - const saveButton = createButton({ - label: t("Common_Btn_Save"), - variant: "ghost", - onClick: () => void save(), - }); const resetButton = createButton({ label: t("Common_Btn_Reset"), variant: "danger-ghost", @@ -103,9 +99,9 @@ export function renderE06StabRock(root: HTMLElement): void { variant: "filled", onClick: () => void analyze(), }); - saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true; + resetButton.disabled = analyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(resetButton, saveButton, analyzeButton); + actions.append(resetButton, analyzeButton); panel.append(note(t("E06_Note")), cards, status, actions); attachCollapsible(cards); @@ -163,6 +159,11 @@ export function renderE06StabRock(root: HTMLElement): void { let result: RockResult | null = null; let active: string | null = null; let shown: StationResponse | null = null; + let interval = 20; // 측점 간격 — 처음 GET 의 측점 목록에서 추정 + /** 측점 글 = No.+거리(소수 한 자리) — 서버 label(STA. 꼴) 대신 */ + const relabel = (e: StationEntry): void => { + e.label = stationLabel(e.chainage_m, interval, 1); + }; const invalidCount = (): number => cards.querySelectorAll('[aria-invalid="true"]').length; const paintStatus = (): void => { @@ -246,6 +247,7 @@ export function renderE06StabRock(root: HTMLElement): void { shown = await api( `/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`, ); + relabel(shown.entry); } catch (error) { shown = null; showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); @@ -256,6 +258,7 @@ export function renderE06StabRock(root: HTMLElement): void { async function loadResult(): Promise { const body = await api(`/projects/${projectId}/stab/rock/result`); result = body.result; + result?.stations.forEach(relabel); staleBar.hidden = !body.stale; const stations = result?.stations ?? []; const keep = stations.find((e) => entryId(e) === active); @@ -274,7 +277,7 @@ export function renderE06StabRock(root: HTMLElement): void { if (!cached) sessionStorage.removeItem(cacheKey(projectId)); view.doc = cached ? (JSON.parse(cached) as JointsDoc) : body.joints; dirty = Boolean(cached); - saveButton.disabled = resetButton.disabled = analyzeButton.disabled = !view.editable; + resetButton.disabled = analyzeButton.disabled = !view.editable; renderCards(); }; @@ -292,20 +295,6 @@ export function renderE06StabRock(root: HTMLElement): void { return true; } - async function save(): Promise { - showLoadingOverlay(); - try { - if (await putJoints()) { - showToast(t("E06_Saved"), "success"); - await loadResult(); - } - } catch (error) { - showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error"); - } finally { - hideLoadingOverlay(); - } - } - async function reset(): Promise { if (!view?.editable) return; const ok = await showConfirmDialog( @@ -353,6 +342,7 @@ export function renderE06StabRock(root: HTMLElement): void { try { const body = await api(`/projects/${projectId}/stab/rock`); const stations = body.stations ?? []; + interval = inferStationInterval(stations); projection = body.joints.settings.projection; paintProjection(); view = { diff --git a/E06_StabRock/E06_StabRock_UI_Result.ts b/E06_StabRock/E06_StabRock_UI_Result.ts index 5619c350c..0cb7bd2ca 100644 --- a/E06_StabRock/E06_StabRock_UI_Result.ts +++ b/E06_StabRock/E06_StabRock_UI_Result.ts @@ -113,8 +113,8 @@ export function buildBand( }); const ticks = el("div", { className: "e06-band__ticks" }); ticks.append( - el("span", { text: `${lo.toFixed(0)} m` }), - el("span", { text: `${hi.toFixed(0)} m` }), + el("span", { text: list[0].label ?? `${lo.toFixed(0)} m` }), + el("span", { text: list[list.length - 1].label ?? `${hi.toFixed(0)} m` }), ); box.append(legend, strip, ticks); } else box.append(legend, note(t("E06_NoRock"))); diff --git a/E07_StabArea/E07_StabArea_Router.py b/E07_StabArea/E07_StabArea_Router.py index 159cf7d51..9ab353736 100644 --- a/E07_StabArea/E07_StabArea_Router.py +++ b/E07_StabArea/E07_StabArea_Router.py @@ -1,7 +1,7 @@ """E07 광역 자연사면 위험 지도 API (요구 345 · 351 · 요구서 4-2 2차 · PLAN 536-2). -- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문이 저장본과 같으면 - `cached` · force = 다시 +- `POST /api/projects/{id}/stab/area/analyze` — 해석(뒤에서) · 입력 지문(복사본 · 설정 · E02 물성 · + 엔진 파일 해시)이 저장본과 같으면 `cached` · force = 다시(시험 · 관리용) - `GET …/stab/area/progress` — 진행률 - `GET …/stab/area` — 기본값(E02 붕적토) · 물성 목록 · 노선 · 측점 · 저장본(격자 · 측점표 · 요약) @@ -26,6 +26,7 @@ import logging import time from collections.abc import Callable from datetime import datetime +from functools import lru_cache from pathlib import Path from typing import Any, Literal from uuid import UUID @@ -46,7 +47,8 @@ from E07_StabArea import E07_StabArea_Engine as engine logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/projects", tags=["E07 Stab Area"]) -ENGINE_VERSION = "e07-3" # 엔진 판 — 바뀌면 지문이 달라져 다시 셈 +ENGINE_FILES = ("E07_StabArea_Engine.py", "E07_StabArea_Router.py") # 엔진 판 = 이 파일들 해시 +MATERIAL_KEYS = ("gamma_t", "gamma_sat", "c", "phi") RESULT = Path("E07_StabArea") / "_derived" / "area.json" DEFAULT_MATERIAL = "colluvium" # 표층 붕괴에 가장 취약(지식DB 안정성평가/04 붕적토) RANGE_C_KPA, RANGE_PHI_DEG = ( @@ -122,8 +124,33 @@ def defaults(project_root: Path) -> dict[str, Any]: } -def fingerprint(source_fingerprint: str, settings: dict[str, Any]) -> str: - body = {"source": source_fingerprint, "settings": settings, "engine": ENGINE_VERSION} +@lru_cache(maxsize=1) +def engine_hash() -> str: + """엔진 코드 지문 — 코드가 바뀌면 지문이 달라져 다시 셈.""" + digest = hashlib.sha1() + for name in ENGINE_FILES: + digest.update((Path(__file__).parent / name).read_bytes()) + return digest.hexdigest()[:12] + + +def ground_material(project_root: Path, material_id: str | None) -> dict[str, Any] | None: + """E02 물성 값(고른 id · 없으면 붕적토) — 지문에 넣어 E02 가 바뀌면 옛 결과가 됨.""" + materials = _ground(project_root).get("materials") or [] + mat = next((m for m in materials if m.get("id") == material_id), None) or next( + (m for m in materials if m.get("id") == DEFAULT_MATERIAL), None + ) + return {k: mat.get(k) for k in ("id", *MATERIAL_KEYS)} if mat else None + + +def fingerprint( + source_fingerprint: str, settings: dict[str, Any], ground: dict[str, Any] | None = None +) -> str: + body = { + "source": source_fingerprint, + "settings": settings, + "ground": ground, + "engine": engine_hash(), + } text = json.dumps(body, ensure_ascii=False, sort_keys=True) return hashlib.sha1(text.encode("utf-8")).hexdigest() @@ -216,7 +243,7 @@ def run( } doc = { "fingerprint": finger, - "engine": ENGINE_VERSION, + "engine": engine_hash(), "settings": settings, "computed_at": datetime.now().isoformat(timespec="seconds"), "elapsed_s": round(time.perf_counter() - started, 2), @@ -275,7 +302,11 @@ async def analyze(project_id: UUID, payload: AreaRequest) -> dict[str, Any]: project_root = await _project_root(project_id) root = _source_root(project_root) settings = payload.model_dump(exclude={"force"}) - finger = fingerprint(source.manifest(root)["source_fingerprint"], settings) + finger = fingerprint( + source.manifest(root)["source_fingerprint"], + settings, + ground_material(project_root, settings["material_id"]), + ) saved = await asyncio.to_thread(load, project_root) if not payload.force and saved and saved["fingerprint"] == finger: return {"status": "cached", "summary": saved["summary"]} @@ -311,7 +342,11 @@ async def progress(project_id: UUID) -> dict[str, Any]: def _overview(project_root: Path, root: Path) -> dict[str, Any]: saved = load(project_root) if saved: - current = fingerprint(source.manifest(root)["source_fingerprint"], saved["settings"]) + current = fingerprint( + source.manifest(root)["source_fingerprint"], + saved["settings"], + ground_material(project_root, saved["settings"].get("material_id")), + ) saved["stale"] = saved["fingerprint"] != current return { "defaults": defaults(project_root), diff --git a/E07_StabArea/E07_StabArea_UI_Map.ts b/E07_StabArea/E07_StabArea_UI_Map.ts index 196b02ff4..062e604ca 100644 --- a/E07_StabArea/E07_StabArea_UI_Map.ts +++ b/E07_StabArea/E07_StabArea_UI_Map.ts @@ -7,6 +7,7 @@ import { el } from "@ui/ui_template_elements"; import { tooltipBox } from "@ui/ui_template_hover_info"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster"; export type MapLayer = "cls" | "fs"; @@ -34,8 +35,6 @@ interface View { scale: number; // px / m } -const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, ""); - const css = (name: string, fallback: string): string => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback; @@ -51,6 +50,7 @@ export function createAreaMap(): AreaMap { let line: number[][] = []; let stations: StationPoint[] = []; + let interval = 20; // 측점 간격 — setRoute 때 측점 목록에서 추정 let band = 100; let data: Decoded | null = null; let layer: MapLayer = "cls"; @@ -112,7 +112,7 @@ export function createAreaMap(): AreaMap { ctx.lineWidth = 2; path(); ctx.stroke(); - const gap = stations.length > 1 ? 20 * view.scale : 999; + const gap = stations.length > 1 ? interval * view.scale : 999; const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100; ctx.font = "11px sans-serif"; ctx.textBaseline = "middle"; @@ -124,7 +124,8 @@ export function createAreaMap(): AreaMap { ctx.beginPath(); ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2); ctx.fill(); - if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9); + if (on || i % every === 0) + ctx.fillText(stationLabel(s.chainage_m, interval, 1), px + 7, py - 9); }); } @@ -227,7 +228,7 @@ export function createAreaMap(): AreaMap { t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey), `SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`, `${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`, - near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "", + near ? `${t("E07_Tip_Station")} ${stationLabel(near.chainage_m, interval, 1)}` : "", ] .filter(Boolean) .join("\n"); @@ -265,6 +266,7 @@ export function createAreaMap(): AreaMap { setRoute(nextLine, nextStations) { line = nextLine; stations = nextStations; + interval = inferStationInterval(stations); fit(); draw(); }, diff --git a/E07_StabArea/E07_StabArea_UI_Page.ts b/E07_StabArea/E07_StabArea_UI_Page.ts index 2cee76c17..f310f504f 100644 --- a/E07_StabArea/E07_StabArea_UI_Page.ts +++ b/E07_StabArea/E07_StabArea_UI_Page.ts @@ -4,7 +4,8 @@ * 좌 = 토층 물성(E02 물성 고름 · 범위) · 시나리오(표준 습윤지수 T/R · 최악 완전 포화) · 노선 띠 · [해석] · 진행률 * 가운데 = 평면 지도(SINMAP 등급 · 안전율) · 범례 * 우 = 요약(등급별 면적 · 위험 면적) · 측점별 위험 표(누름 = 지도 그 측점) - * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage. + * 계산 = 서버 단독(POST …/stab/area/analyze · 뒤에서) · 화면은 값 · 그림만. 조건 = sessionStorage + * · 없으면 지난 결과 조건 + 물성은 지금 E02 값. * ========================================================================== */ import "./E07_StabArea_UI_Style.css"; @@ -29,6 +30,7 @@ import { createSegment } from "@ui/ui_template_segment"; import { createTable } from "@ui/ui_template_table"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { createText } from "@ui/ui_template_text"; +import { inferStationInterval, stationLabel } from "../common_util/common_util_station"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map"; import { @@ -86,8 +88,6 @@ const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [ ]; const cacheKey = (projectId: string): string => `e07_settings:${projectId}`; -const shortLabel = (label: string | null, ch: number): string => - (label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, ""); async function api(path: string, init?: RequestInit): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { @@ -239,15 +239,10 @@ export function renderE07StabArea(root: HTMLElement): void { const analyzeButton = createButton({ label: t("E07_Analyze"), variant: "filled", - onClick: () => void analyze(false), - }); - const reanalyzeButton = createButton({ - label: t("E07_Reanalyze"), - variant: "ghost", - onClick: () => void analyze(true), + onClick: () => void analyze(), }); const actions = el("div", { className: "ui-sidebar-actions" }); - actions.append(defaultsButton, reanalyzeButton, analyzeButton); + actions.append(defaultsButton, analyzeButton); const readOnly = note(t("E07_ReadOnly"), "e07-hint"); readOnly.hidden = true; panel.append( @@ -295,24 +290,31 @@ export function renderE07StabArea(root: HTMLElement): void { remember(); }); fields.get("band_m")!.input.addEventListener("input", paintScenario); - material.select.addEventListener("change", () => { - const m = overview?.materials.find((x) => x.id === material.select.value); - if (!m) return; - const set = (key: string, v: number) => (fields.get(key)!.input.value = String(v)); + /** E02 물성 값(범위 위 = 기본값과 같은 폭) — 없는 id 면 빈 것 */ + function materialValues(id: unknown): Settings { + const m = overview?.materials.find((x) => x.id === id); + if (!m) return {}; const rangeC = Number(overview!.defaults.c_hi) - Number(overview!.defaults.c); const rangePhi = Number(overview!.defaults.phi_hi) - Number(overview!.defaults.phi); - set("gamma_t", m.gamma_t); - set("gamma_sat", m.gamma_sat); - set("c", m.c); - set("c_hi", m.c + rangeC); - set("phi", m.phi); - set("phi_hi", m.phi + rangePhi); + return { + gamma_t: m.gamma_t, + gamma_sat: m.gamma_sat, + c: m.c, + c_hi: m.c + rangeC, + phi: m.phi, + phi_hi: m.phi + rangePhi, + }; + } + + material.select.addEventListener("change", () => { + const values = materialValues(material.select.value); + for (const [key, v] of Object.entries(values)) fields.get(key)!.input.value = String(v); remember(); }); function paintButtons(): void { const editable = Boolean(overview?.editable); - analyzeButton.disabled = reanalyzeButton.disabled = !editable || running || !overview; + analyzeButton.disabled = !editable || running || !overview; readOnly.hidden = !overview || editable; } @@ -376,7 +378,7 @@ export function renderE07StabArea(root: HTMLElement): void { { label: t("E07_Col_RiskArea"), align: "end" }, ], result.stations.map((r) => [ - shortLabel(r.label, r.chainage_m), + stationLabel(r.chainage_m, inferStationInterval(overview?.stations ?? []), 1), r.fs_min === undefined ? "-" : r.fs_min.toFixed(2), r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1), r.level ? chipCell(LEVEL_COLORS[r.level], t(`E07_Level_${r.level}` as LocaleKey)) : "", @@ -445,11 +447,11 @@ export function renderE07StabArea(root: HTMLElement): void { } } - async function analyze(force: boolean): Promise { + async function analyze(): Promise { try { const body = await api<{ status: string }>(`/projects/${projectId}/stab/area/analyze`, { method: "POST", - body: JSON.stringify({ ...read(), force }), + body: JSON.stringify(read()), }); if (body.status === "cached") { showToast(t("E07_Cached"), "info"); @@ -478,8 +480,12 @@ export function renderE07StabArea(root: HTMLElement): void { const cached = JSON.parse( sessionStorage.getItem(cacheKey(projectId)) ?? "null", ) as Settings | null; - // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 - write({ ...ov.defaults, ...(cached ?? (ov.result?.settings as Settings | undefined)) }); + // 옛 조건(빠진 칸 · 없어진 시나리오)은 기본값으로 채움 · 캐시가 없으면 물성 = 지금 E02 값 + const last = ov.result?.settings as Settings | undefined; + write({ + ...ov.defaults, + ...(cached ?? { ...last, ...materialValues(last?.material_id ?? ov.defaults.material_id) }), + }); map.setRoute(ov.line, ov.stations); const p = await api(`/projects/${projectId}/stab/area/progress`).catch(() => null); if (p?.running) void poll(); diff --git a/config/config_system_terrain.py b/config/config_system_terrain.py index 62d543a72..113e576e5 100644 --- a/config/config_system_terrain.py +++ b/config/config_system_terrain.py @@ -149,9 +149,6 @@ SHEET_SURFACE_METHODS = [ ).split(",") if method.strip() ] -# D 예정노선 [분석] 연도 구간을 나란히 셀 프로세스 수 상한(프로세스 하나 약 0.8 GB) — 여럿이 동시에 -# 돌리면 줄임. -PLAN_ANALYZE_WORKERS = int(os.getenv("PLAN_ANALYZE_WORKERS", "3")) # 확정에 쓸 기본 방식 — 라플라스 (2026-08-30 사용자 확정). SHEET_SURFACE_DEFAULT_METHOD = os.getenv("SHEET_SURFACE_DEFAULT_METHOD", "laplace") # 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면 diff --git a/db_management/021_plan_network.sql b/db_management/021_plan_network.sql new file mode 100644 index 000000000..3e87b7838 --- /dev/null +++ b/db_management/021_plan_network.sql @@ -0,0 +1,37 @@ +-- 021_plan_network.sql +-- 예정노선 2단계 노선 정본 — 손으로 그은 선 · 갈래 · 구역 마크 · 조각(구역 × 갈래) (요구 389 · 391 · PLAN 562-2) +-- +-- 추가만 함 — plan_route_years 는 조각에서 만든 연도 행으로 그대로 둠(아래 단계가 읽음). + +USE aislo_db; + +CREATE TABLE IF NOT EXISTS plan_networks ( + project_id CHAR(36) NOT NULL PRIMARY KEY COMMENT 'projects.id (PLAN)', + network_json JSON NOT NULL COMMENT '{epsg, lines, marks, pieces} — 길이 · 측점 · 높이는 저장 안 함', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + CONSTRAINT fk_plan_networks_project FOREIGN KEY (project_id) + REFERENCES projects (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예정노선 노선 정본'; + +CREATE TABLE IF NOT EXISTS plan_route_pieces ( + project_id CHAR(36) NOT NULL COMMENT 'projects.id (PLAN)', + piece_id VARCHAR(64) NOT NULL COMMENT '조각 id(network_json.pieces.id)', + line_id VARCHAR(64) NOT NULL COMMENT '선 id(본선 · 갈래)', + year INT NOT NULL, + from_m DOUBLE NOT NULL COMMENT '선 위 시작 거리(선마다 No.0 부터)', + to_m DOUBLE NOT NULL, + length_m DOUBLE NOT NULL, + epsg INT NOT NULL, + route_json JSON NOT NULL COMMENT '{planned: [[x, y]…], stations: [m…]}', + fingerprint CHAR(40) NOT NULL COMMENT '조각 내용 지문(sha1)', + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (project_id, piece_id), + KEY idx_plan_route_pieces_year (project_id, year), + CONSTRAINT fk_plan_route_pieces_project FOREIGN KEY (project_id) + REFERENCES projects (id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='예정노선 조각(구역 × 갈래)'; + +-- 업체 지정 = 조각 단위(요구 391) — NULL = 옛 연도 단위 지정 +ALTER TABLE plan_route_assignees + ADD COLUMN IF NOT EXISTS piece_id VARCHAR(64) NULL COMMENT 'plan_route_pieces.piece_id' AFTER year, + ADD INDEX IF NOT EXISTS idx_plan_route_assignees_piece (project_id, piece_id); diff --git a/main.py b/main.py index b70813046..c61eef869 100644 --- a/main.py +++ b/main.py @@ -77,6 +77,7 @@ from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_dashboard_router from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router from D02_PlanBase.D02_PlanBase_Router import router as d02_plan_base_router +from D03_PlanAnalysis.D03_PlanAnalysis_Network import router as d03_plan_network_router from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router from D04_PlanConfirm.D04_PlanConfirm_Link import dashboard_router as d04_link_dashboard_router from D04_PlanConfirm.D04_PlanConfirm_Link import router as d04_plan_link_router @@ -496,6 +497,7 @@ app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(d02_plan_base_router, dependencies=protected_with_company) app.include_router(d03_plan_analysis_router, dependencies=protected_with_company) +app.include_router(d03_plan_network_router, dependencies=protected_with_company) app.include_router(d04_plan_confirm_router, dependencies=protected_with_company) app.include_router(d04_plan_link_router, dependencies=protected_with_company) app.include_router(e02_stab_ground_router, dependencies=protected_with_company) diff --git a/resources/tester/test_530_1_plan_terrain.py b/resources/tester/test_530_1_plan_terrain.py index 3958cbb58..0e61d34ce 100644 --- a/resources/tester/test_530_1_plan_terrain.py +++ b/resources/tester/test_530_1_plan_terrain.py @@ -1,10 +1,10 @@ -"""D 예정노선 지형 받기(PLAN 530-1) — 가짜 내려받기 · 옛 도엽 유지 · 새 도엽만 · 빠짐 없음. +"""D 예정노선 지형 받기(PLAN 530-1 · 561-2) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 · +옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q """ import json -import math from pathlib import Path import pytest @@ -60,56 +60,85 @@ def _zips(root): return sorted(p.stem for p in store_mod.get_project_map_sheets_dir(root).glob("*.zip")) -def test_more_points_keep_old_sheets_and_fetch_only_new(fake) -> None: +def _dem_box(seen) -> list[float]: + """가짜 DEM 이 받은 꼭짓점 상자 + B 절취 여유 = 실제 DEM 상자.""" + (x0, y0), (x1, y1) = seen["dem"][-1][1] + m = SHEET_SURFACE_MARGIN_M + return [x0 - m, y0 - m, x1 + m, y1 + m] + + +def test_one_set_and_extend_keeps_old(fake) -> None: root, seen = fake - first = terrain.ensure_plan_terrain(root, [{"year": 2026, "start": A, "end": B}], EPSG) + first = terrain.ensure_plan_terrain(root, [A, B], EPSG) old = _zips(root) assert sorted(seen["download"]) == old == sorted(first["sheets"]["downloaded"]) - assert first["satellite"] == "done" and list(first["years"]) == [2026] + assert first["satellite"] == "done" and len(first["models"]) == 1 + assert seen["dem"][-1][0] == "models_plan" # 연도 없는 한 벌 + m = terrain.DEFAULT_MARGIN_M + box = [min(A[0], B[0]) - m, min(A[1], B[1]) - m, max(A[0], B[0]) + m, max(A[1], B[1]) + m] + assert _dem_box(seen) == pytest.approx(box) == first["box_m"] + assert terrain.received_box(root) == pytest.approx(tuple(box)) # 작업 좌표계 .prj — B 좌표계 창구가 읽음 assert resolve_project_crs(root) == f"EPSG:{EPSG}" + # 그은 점이 범위 밖 — 더 받기 = 옛 범위 ∪ 새 범위 · 새 도엽만 seen["download"].clear() - segs = [{"year": 2026, "start": A, "end": B}, {"year": 2027, "start": B, "end": C}] - second = terrain.ensure_plan_terrain(root, segs, EPSG) + second = terrain.ensure_plan_terrain(root, [A, B, C], EPSG) now = _zips(root) assert set(old) <= set(now) # 옛 도엽 그대로(prune 없음) - assert sorted(seen["download"]) == sorted(set(now) - set(old)) # 새 도엽만 받음 + assert sorted(seen["download"]) == sorted(set(now) - set(old)) assert seen["merge"][-1] == now # 병합 = 옛 ∪ 새 - # 위성 = 옛 범위 ∪ 새 범위 - first_box, union = seen["satellite"][0], seen["satellite"][-1] - assert union["x"][0] <= first_box["x"][0] and union["x"][1] >= first_box["x"][1] - assert union["y"][0] <= first_box["y"][0] and union["y"][1] >= first_box["y"][1] - assert [d[0] for d in seen["dem"][-2:]] == ["models_2026", "models_2027"] + grown = second["box_m"] + assert grown[0] <= box[0] and grown[1] <= box[1] and grown[2] >= C[0] + m + first_sat, union = seen["satellite"][0], seen["satellite"][-1] + assert union["x"][0] <= first_sat["x"][0] and union["x"][1] >= first_sat["x"][1] assert second["sheets"]["failed"] == {} - # 같은 지점 다시 — 받기 0 · 위성 건너뜀 · 병합 열쇠 같음 + # 같은 범위 다시 — 받기 0 · 위성 건너뜀 · 병합 열쇠 같음 · 작은 점이어도 범위 줄지 않음 seen["download"].clear() merges = len(seen["merge"]) - third = terrain.ensure_plan_terrain(root, segs, EPSG) + third = terrain.ensure_plan_terrain(root, [A, B], EPSG) assert seen["download"] == [] and third["satellite"] == "skip" - assert len(seen["merge"]) == merges + assert len(seen["merge"]) == merges and third["box_m"] == grown -def test_far_points_leave_no_gap(fake) -> None: +def test_extent_no_download_and_covered(fake) -> None: + root, seen = fake + before = terrain.plan_extent(root, [A, B], EPSG) + assert seen["download"] == [] and before["have"] is None and before["covered"] is False + assert before["margin_m"] == terrain.DEFAULT_MARGIN_M and len(before["box"]) == 4 + x0, y0, x1, y1 = before["box_m"] + assert before["cells"] == int((x1 - x0) * (y1 - y0)) + assert all(not s["stored"] and len(s["ring"]) == 4 for s in before["sheets"]) + terrain.ensure_plan_terrain(root, [A, B], EPSG) + after = terrain.plan_extent(root, [A, B], EPSG) + assert after["covered"] and after["have_m"] == after["box_m"] + assert all(s["stored"] for s in after["sheets"]) + assert {s["no"] for s in after["sheets"]} == {s["no"] for s in before["sheets"]} + assert not terrain.plan_extent(root, [A, C], EPSG)["covered"] # 그은 점이 밖 = 더 받기 + # 여유 — 바닥 B 절취 여유 · 빈 값 = 처음 값 + assert terrain.clamp_margin(100.0) == SHEET_SURFACE_MARGIN_M + assert terrain.clamp_margin(None) == terrain.DEFAULT_MARGIN_M + assert terrain.clamp_margin(1e9) == terrain.MAX_MARGIN_M + + +def test_box_sheets_leave_no_gap(fake) -> None: root, _ = fake - far = (A[0] + 9000.0, A[1] + 7000.0) # 11 km — 3×3 두 벌이 안 닿는 거리 - got = terrain.ensure_plan_terrain(root, [{"year": 2026, "start": A, "end": far}], EPSG) + far = (A[0] + 9000.0, A[1] + 7000.0) # 11 km + got = terrain.ensure_plan_terrain(root, [A, far], EPSG) + x0, y0, x1, y1 = got["box_m"] to_wgs = Transformer.from_crs(f"EPSG:{EPSG}", "EPSG:4326", always_xy=True) - on_line = [] - for i in range(201): - lon, lat = to_wgs.transform( - A[0] + (far[0] - A[0]) * i / 200, A[1] + (far[1] - A[1]) * i / 200 - ) - on_line.append((lat, lon)) - assert set(sheets_for_points(on_line)) <= set(got["sheets"]["merged"]) + inside = [] + for i in range(41): + for j in range(41): + lon, lat = to_wgs.transform(x0 + (x1 - x0) * i / 40, y0 + (y1 - y0) * j / 40) + inside.append((lat, lon)) + assert set(sheets_for_points(inside)) <= set(got["sheets"]["merged"]) -def test_dem_outline_covers_corridor() -> None: - box = terrain.dem_outline(A, B) - reach = terrain.PLAN_CORRIDOR_M + terrain.DEM_EXTRA_M - assert box[0][0] - SHEET_SURFACE_MARGIN_M <= min(A[0], B[0]) - reach + 1e-9 - assert box[1][1] + SHEET_SURFACE_MARGIN_M >= max(A[1], B[1]) + reach - 1e-9 - samples = terrain.segment_samples(A, C) - steps = [math.dist(p, q) for p, q in zip(samples, samples[1:])] - assert samples[0] == A and samples[-1] == C and max(steps) <= terrain.SAMPLE_STEP_M +def test_empty_surface_raises_and_keeps_no_box(fake, monkeypatch) -> None: + root, _ = fake + monkeypatch.setattr(terrain, "build_sheet_surface_model", lambda *a, **k: []) + with pytest.raises(ValueError, match="사업 지형면을 만들지 못했습니다"): + terrain.ensure_plan_terrain(root, [A, B], EPSG) + assert terrain.received_box(root) is None diff --git a/resources/tester/test_530_2_plan_analyze.py b/resources/tester/test_530_2_plan_analyze.py index cbab24f0f..d42ac5832 100644 --- a/resources/tester/test_530_2_plan_analyze.py +++ b/resources/tester/test_530_2_plan_analyze.py @@ -1,168 +1,278 @@ -"""D 예정노선 2단계 분석(PLAN 530-2) — 합성 지형 두 구간 · 경계 · 누가 · 저장본 · 복도 인자. +"""D 예정노선 2단계 노선 정본(PLAN 562-2) — 손 그은 선 · 갈래 · 마크 · 조각 → 서버가 다시 셈 · +연도 행 · 옛 연도 노선을 선으로 · GET · PUT(산림청만 · 틀린 꼴 거절 · 업체 지정된 조각 지움 막음). + +옛 추천 엔진 시험(합성 지형 갈래 넷 · 저장본 · 나란히 셈)은 엔진과 함께 걷음. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_analyze.py -q """ -import numpy as np +import json + import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient -from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS, Terrain -from B05_Profile.B05_Profile_Engine_RouteInitial_Search import CORRIDOR_M, SearchGrid -from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze +from common_util.common_util_auth import require_company +from D03_PlanAnalysis import D03_PlanAnalysis_Network as net -CRITERIA = {"max_grade": 0.14, "min_radius_m": 12.0, "design_speed": 20} -SEGMENTS = [ - {"year": 2026, "start": (60.0, 200.0), "end": (560.123456789, 230.987654321)}, - {"year": 2027, "start": (560.123456789, 230.987654321), "end": (1040.0, 160.0)}, -] +PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" +MAIN = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m +BRANCH = [[30.0, 100.0], [30.0, 150.0]] # 본선 점 2 에서 50 m -def _hill() -> Terrain: - x = np.arange(0.0, 1102.0, 2.0) - y = np.arange(0.0, 402.0, 2.0) - xx, yy = np.meshgrid(x, y) - z = 30.0 * np.exp(-((xx - 300.0) ** 2 + (yy - 210.0) ** 2) / 80.0**2) + 0.02 * xx - return Terrain(x, y, z, np.ones(xx.shape, dtype=bool)) +def _network(**over) -> dict: + body = { + "epsg": 5186, + "lines": [ + {"id": "main", "vertices": MAIN, "branch": None}, + {"id": "b1", "vertices": BRANCH, "branch": {"line": "main", "vertex": 2}}, + ], + "marks": [{"line": "main", "at_m": 75.0}], + "pieces": [ + {"id": "p1", "line": "main", "from_m": 0, "to_m": 75.0, "year": 2026}, + {"id": "p2", "line": "main", "from_m": 75.004, "to_m": 210.0, "year": 2027}, + {"id": "p3", "line": "b1", "from_m": 0, "to_m": 50.0, "year": 2026}, + ], + } + body.update(over) + return body -def hill_loader(root, selection, models_dir, cache_dir): - """나란히 셈 프로세스가 이름으로 부르는 지형(모듈 맨 위 함수).""" - return _hill(), [] +def test_derive_pieces_lengths_stations() -> None: + got = net.derive(_network(), 2026) + assert [(x["id"], x["length_m"]) for x in got["lines"]] == [("main", 210), ("b1", 50)] + assert got["lines"][0]["stations"][-2:] == [200, 210] and got["lines"][1]["stations"] == [ + 0, + 20, + 40, + 50, + ] + pieces = {p["id"]: p for p in got["pieces"]} + assert [p["id"] for p in got["pieces"]] == ["p1", "p2", "p3"] # 선 차례 · 앞에서부터 + # 경계 = 마크 그대로(보낸 75.004 아님) + assert pieces["p2"]["from_m"] == 75.0 and pieces["p2"]["length_m"] == 135.0 + assert pieces["p1"]["planned"] == [[0, 0], [30, 40], [30, 65]] + assert pieces["p1"]["stations"] == [0, 20, 40, 60, 75] + assert pieces["p2"]["stations"][:2] == [75, 80] + assert pieces["p3"]["planned"] == BRANCH # 갈래 측점 = 갈래마다 No.0 -@pytest.fixture(scope="module") -def hill() -> Terrain: - return _hill() +@pytest.mark.parametrize( + "over", + [ + {"marks": [{"line": "main", "at_m": 210.0}]}, # 마크가 끝 + {"marks": [{"line": "zz", "at_m": 10.0}]}, + {"marks": [{"line": "main", "at_m": 75.0}, {"line": "main", "at_m": 75.0005}]}, + {"pieces": [{"id": "p1", "line": "main", "from_m": 0, "to_m": 80.0, "year": 2026}]}, + {"pieces": [{"id": "p1", "line": "main", "from_m": 0, "to_m": 75.0, "year": 2025}]}, + { + "pieces": [ + {"id": "p1", "line": "main", "from_m": 0, "to_m": 75.0, "year": 2026}, + {"id": "p9", "line": "main", "from_m": 0, "to_m": 75.0, "year": 2027}, + ] + }, + { + "lines": [ + {"id": "b1", "vertices": BRANCH, "branch": {"line": "main", "vertex": 2}}, + {"id": "main", "vertices": MAIN, "branch": None}, + ], + "marks": [], + "pieces": [], + }, # 부모가 뒤 + { + "lines": [ + {"id": "main", "vertices": MAIN, "branch": None}, + {"id": "b1", "vertices": BRANCH, "branch": {"line": "main", "vertex": 1}}, + ], + }, # 갈래 첫 점 ≠ 부모 점 + {"lines": [{"id": "main", "vertices": [[0, 0], [float("nan"), 1]], "branch": None}]}, + ], +) +def test_derive_rejects(over) -> None: + with pytest.raises(net.NetworkError): + net.derive(_network(**over), 2026) + + +def test_year_rows_group_pieces() -> None: + rows = net.year_rows(net.derive(_network(), 2026)["pieces"]) + assert list(rows) == [2026, 2027] + first = rows[2026]["route"] + assert first["mode"] == "manual" and [p["id"] for p in first["pieces"]] == ["p1", "p3"] + assert first["planned"] == [[0, 0], [30, 40], [30, 65]] and first["length_m"] == 125.0 + assert rows[2026]["start"] == [0, 0] and rows[2026]["end"] == [30, 65] + assert rows[2027]["route"]["start_chainage_m"] == 75.0 + + +def test_legacy_rows_become_one_line() -> None: + rows = [ + {"year": 2026, "epsg": 5186, "route_json": json.dumps({"planned": [[0, 0], [30, 40]]})}, + {"year": 2027, "epsg": 5186, "route_json": None}, + {"year": 2028, "epsg": 5186, "route_json": {"planned": [[30, 40], [30, 100]]}}, + ] + got = net.legacy_network(rows) + assert got["lines"][0]["vertices"] == [[0, 0], [30, 40], [30, 100]] + assert got["marks"] == [{"line": "main", "at_m": 50.0}] + assert [(p["year"], p["from_m"], p["to_m"]) for p in got["pieces"]] == [ + (2026, 0, 50), + (2028, 50, 110), + ] + assert net.derive(got)["pieces"][1]["planned"] == [[30, 40], [30, 100]] + assert net.legacy_network([{"year": 2026, "epsg": 5186, "route_json": None}]) is None + + +class _Cursor: + def __init__(self, db): + self.db = db + self.last = "" + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def execute(self, sql, args=None): + self.last = sql + self.db.log.append((sql, args)) + + async def fetchone(self): + return self._rows()[0] if self._rows() else None + + async def fetchall(self): + return self._rows() + + def _rows(self): + for key, rows in self.db.answers.items(): + if key in self.last: + return rows + return [] + + +class _Connection: + def __init__(self, db): + self.db = db + + async def __aenter__(self): + return self + + async def __aexit__(self, *exc): + return False + + async def begin(self): + pass + + async def commit(self): + self.db.log.append(("COMMIT", None)) + + async def rollback(self): + self.db.log.append(("ROLLBACK", None)) + + def cursor(self, *args): + return _Cursor(self.db) + + +class _Db: + def __init__(self): + self.log: list = [] + self.answers: dict = {"FROM projects": [{"project_year": 2026, "plan_base_json": "{}"}]} + + def acquire(self): + return _Connection(self) @pytest.fixture -def run(tmp_path, hill): - _POOLS.clear() - calls = [] +def client(monkeypatch): + db = _Db() + monkeypatch.setattr(net, "get_db_pool", lambda: db) + base = {"epsg": 5186, "start": {"x": 0.0, "y": 0.0}, "end": {"x": 90.0, "y": 180.0}} - def loader(root, selection, models_dir, cache_dir): - calls.append((models_dir.name, cache_dir.name)) - return hill, [] + async def load_base(project_id): + return base - def go(criteria=CRITERIA, corridor_m=150.0): - return analyze.analyze_plan( - tmp_path, SEGMENTS, criteria, loader=loader, corridor_m=corridor_m, workers=1 - ) - - go.calls = calls - yield go - _POOLS.clear() + monkeypatch.setattr(net.plan_base, "load_base", load_base) + app = FastAPI() + app.include_router(net.router) + session = {"role": "USER", "email": "a@forest.go.kr"} + app.dependency_overrides[require_company] = lambda: session + test = TestClient(app) + test.db = db + test.session = session + test.base = base + return test -def test_boundary_chainage_and_line(run) -> None: - results = run() - # 연도 구간마다 제 DEM · 제 비용면 캐시 - assert run.calls == [("models_2026", "cost_2026"), ("models_2027", "cost_2027")] - for seg, result in zip(SEGMENTS, results): - assert set(result["routes"]) == set(analyze.PLAN_MODES) - for route in result["routes"].values(): - assert "error" not in route - # 시종점 그대로 — 연도 경계가 정확히 맞물림 - assert route["planned"][0] == result["start"] == pytest.approx(seg["start"], abs=1e-4) - assert route["planned"][-1] == result["end"] == pytest.approx(seg["end"], abs=1e-4) - assert results[0]["routes"]["gradeline"]["planned"][-1] == results[1]["start"] - - rows = analyze.chain(results, {2026: "earthwork", 2027: None}, 1234.5) - assert [r["mode"] for r in rows] == ["earthwork", "gradeline"] - assert rows[0]["start_chainage_m"] == 1234.5 - assert rows[1]["start_chainage_m"] == rows[0]["end_chainage_m"] - for result, row in zip(results, rows): - length = result["routes"][row["mode"]]["metrics"]["length_m"] - assert row["end_chainage_m"] - row["start_chainage_m"] == pytest.approx(length, abs=1e-3) - - line = analyze.joined_line(results, rows) - first = results[0]["routes"]["earthwork"]["planned"] - second = results[1]["routes"]["gradeline"]["planned"] - assert len(line) == len(first) + len(second) - 1 # 경계점 한 번만(꺾인 채) - assert line[len(first) - 1] == results[0]["end"] == results[1]["start"] +URL = f"/api/projects/{PID}/plan/network" -def test_same_input_reads_store(run, monkeypatch) -> None: - first = run() - assert [r["stored"] for r in first] == [False, False] - - def boom(*args, **kwargs): - raise AssertionError("저장본이 있는데 다시 셈") - - monkeypatch.setattr(analyze, "generate_initial_route", boom) - again = run() - assert [r["stored"] for r in again] == [True, True] - assert again[1]["routes"] == first[1]["routes"] - # 입력(복도 · 기준)이 바뀌면 다시 셈 - with pytest.raises(AssertionError, match="다시 셈"): - run(corridor_m=120.0) - with pytest.raises(AssertionError, match="다시 셈"): - run(criteria={**CRITERIA, "max_grade": 0.12}) +def test_get_saved_legacy_or_empty(client) -> None: + assert client.get(URL).json()["network"] is None + client.db.answers["FROM plan_route_years"] = [ + {"year": 2026, "epsg": 5186, "route_json": '{"planned": [[0, 0], [30, 40]]}'} + ] + got = client.get(URL).json() + assert got["legacy"] is True and got["derived"]["pieces"][0]["length_m"] == 50 + client.db.answers["FROM plan_networks"] = [{"network_json": json.dumps(_network())}] + got = client.get(URL).json() + assert got["legacy"] is False and len(got["derived"]["pieces"]) == 3 + assert got["base"]["end"] == {"x": 90.0, "y": 180.0} and got["year_start"] == 2026 -def test_parallel_years_match_sequential(tmp_path) -> None: - """연도 구간 나란히(프로세스 둘) = 차례로 — 갈래 넷 노선 좌표 · 잰 값 차 0(531-6).""" - _POOLS.clear() - seq = analyze.analyze_plan( - tmp_path / "a", SEGMENTS, CRITERIA, loader=hill_loader, corridor_m=150.0, workers=1 +def test_put_stores_network_pieces_years(client) -> None: + got = client.put(URL, json=_network()) + assert got.status_code == 200 and got.json()["derived"]["pieces"][1]["from_m"] == 75.0 + assert got.json()["year_start"] == 2026 + log = client.db.log + assert log[-1][0] == "COMMIT" + pieces = [a for s, a in log if s.startswith("INSERT INTO plan_route_pieces")] + assert [(a[1], a[2], a[3]) for a in pieces] == [ + ("p1", "main", 2026), + ("p2", "main", 2027), + ("p3", "b1", 2026), + ] + years = [a for s, a in log if s.startswith("INSERT INTO plan_route_years")] + assert [a[1] for a in years] == [2026, 2027] + assert json.loads(years[0][7])["pieces"][1]["id"] == "p3" + assert ("UPDATE projects SET project_year_end = %s WHERE id = %s", (2027, PID)) in log + stale = [a for s, a in log if "year NOT IN" in s] + assert stale == [(PID, 2026, 2027)] + assert not [s for s, _ in log if "plan_base_json = %s" in s] # 정본 칸 있음 = 안 굳힘 + + +def test_put_freezes_folded_base_first(client) -> None: + client.db.answers["FROM projects"] = [{"project_year": 2026, "plan_base_json": None}] + assert client.put(URL, json=_network()).status_code == 200 + sqls = [(s, a) for s, a in client.db.log if s.startswith("UPDATE projects SET plan_base_json")] + assert len(sqls) == 1 and json.loads(sqls[0][1][0]) == client.base + first_year = next(i for i, (s, _) in enumerate(client.db.log) if "plan_route_years" in s) + assert client.db.log.index(sqls[0]) < first_year + + +def test_put_reset_clears_year_routes(client) -> None: + got = client.put(URL, json={"epsg": 5186, "lines": [], "marks": [], "pieces": []}) + assert got.status_code == 200 and got.json()["derived"] == {"lines": [], "pieces": []} + sqls = [s for s, _ in client.db.log] + assert any(s.startswith("UPDATE plan_route_years SET route_json = NULL") for s in sqls) + assert not any("project_year_end" in s for s in sqls) + + +def test_put_rejects(client) -> None: + bad = _network(marks=[{"line": "main", "at_m": 500.0}]) + assert client.put(URL, json=bad).status_code == 400 + assert ( + client.put(URL, json=_network(lines=[{"id": "a b", "vertices": MAIN}])).status_code == 422 ) - _POOLS.clear() - steps = [] - par = analyze.analyze_plan( - tmp_path / "b", - SEGMENTS, - CRITERIA, - loader=hill_loader, - corridor_m=150.0, - workers=2, - on_progress=lambda p, m: steps.append(m), + # 업체가 지정된 조각 · 옛 연도를 지우면 막음 + client.db.answers["piece_id IS NOT NULL"] = [("p2",)] + one = _network( + lines=[{"id": "main", "vertices": MAIN}], + marks=[], + pieces=[{"id": "p1", "line": "main", "from_m": 0, "to_m": 210, "year": 2026}], ) - assert [r["routes"] for r in par] == [r["routes"] for r in seq] - assert [r["fingerprint"] for r in par] == [r["fingerprint"] for r in seq] - assert "나란히 2" in steps[0] and steps[-1] == "분석 끝" - # 저장본은 제 프로세스에서 지문만 보고 그대로 - again = analyze.analyze_plan( - tmp_path / "b", SEGMENTS, CRITERIA, loader=hill_loader, corridor_m=150.0, workers=2 - ) - assert [r["stored"] for r in again] == [True, True] - _POOLS.clear() - - -def test_near_streams_keeps_every_nearest_line(hill) -> None: - """계류 거르기 — 지형 상자 안 점의 계류 거리 · 건너기가 통째와 같고 먼 선은 빠짐(531-6).""" - import shapely - from shapely.geometry import LineString, MultiLineString - - rng = np.random.default_rng(7) - streams = [] - for _ in range(80): # 지형(1.1 × 0.4 km) 둘레 5 km 에 흩은 계류 - start = rng.uniform(-2000.0, 3000.0, 2) - streams.append([tuple(start + step) for step in np.cumsum(rng.normal(0, 60, (6, 2)), 0)]) - kept = analyze.near_streams(hill, streams) - assert 0 < len(kept) < len(streams) - points = shapely.points(rng.uniform((0.0, 0.0), (1100.0, 400.0), (4000, 2))) - whole, part = MultiLineString(streams), MultiLineString(kept) - assert np.array_equal(shapely.distance(points, whole), shapely.distance(points, part)) - route = LineString([(0.0, 0.0), (1100.0, 400.0)]) - assert route.intersection(whole).equals(route.intersection(part)) - assert analyze.near_streams(hill, []) == [] - - -def test_corridor_argument_keeps_b_default(hill) -> None: - line = [SEGMENTS[0]["start"], SEGMENTS[0]["end"]] - base = SearchGrid(line, hill, [], stream_offset_m=10.0) - wide = SearchGrid(line, hill, [], stream_offset_m=10.0, corridor_m=180.0) - assert base.corridor_m == CORRIDOR_M == 100.0 - assert wide.cells > base.cells - - -def test_criteria_from_project() -> None: - from B05_Profile.B05_Profile_Engine_Grade import ( - legal_grade_limit_pct, - legal_plan_radius_min_m, - ) - - got = analyze.plan_criteria("work", 99) # 못 고르는 속도 → 종류 기본 - assert got["design_speed"] == 20 - assert got["max_grade"] == legal_grade_limit_pct("work", "special", False, 20) / 100.0 - assert got["min_radius_m"] == legal_plan_radius_min_m(20, "special") + assert client.put(URL, json=one).status_code == 409 + client.db.answers["piece_id IS NOT NULL"] = [] + client.db.answers["piece_id IS NULL"] = [(2027,)] + assert client.put(URL, json=one).status_code == 409 + assert not [s for s, _ in client.db.log if s.startswith("INSERT")] + assert client.put(URL, json=_network(epsg=5187)).status_code == 400 # 기준 지정과 다른 좌표계 + client.session["email"] = "a@gmail.com" + assert client.put(URL, json=_network()).status_code == 403 diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 0ade27d52..08db5bb3b 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -1,303 +1,98 @@ -"""D 분석 API(PLAN 530-2 · 530-3) — GET 이은 누가 · PUT [저장] = 서버 저장본으로 다시 셈 · 모르는 갈래 -거절 · 끈 지점(points) = 그 지점으로 보기 · 안 이어지면 거절 · [저장] 때만 지점 정본(산림청만) · -꺾임점 고침(543-3) = 서버가 마디로 선 · 지표를 다시 셈 · 누가가 고친 길이로 · GET 은 지문 같은 고침만. +"""D 지형 API(PLAN 530-1 · 561-2) — 범위 = 전체 시점 · 종점(D02 정본) 상자 + 여유 · 받기 = 한 벌. + +옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) · +노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_2_plan_router.py -q """ +import asyncio import json -import os +from contextlib import asynccontextmanager import pytest from fastapi import FastAPI 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" -P0, P1, P2 = (0.0, 0.0), (300.0, 400.0), (900.0, 400.0) - - -def _route(points: list, length: float) -> dict: - return {"planned": points, "nodes": [], "curves": [], "metrics": {"length_m": length}} - - -class _Cursor: - def __init__(self, log): - self.log = log - - async def __aenter__(self): - return self - - async def __aexit__(self, *exc): - return False - - async def execute(self, sql, args=None): - self.log.append((sql, args)) - - -class _Connection(_Cursor): - async def begin(self): - pass - - async def commit(self): - self.log.append(("COMMIT", None)) - - async def rollback(self): - pass - - def cursor(self, *args): - return _Cursor(self.log) +P0, P2 = (0.0, 0.0), (900.0, 400.0) +URL = f"/api/projects/{PID}/plan/terrain" class _Pool: - def __init__(self, log): - self.log = log - - def acquire(self): - return _Connection(self.log) + @asynccontextmanager + async def acquire(self): + yield None @pytest.fixture def client(tmp_path, monkeypatch): - segments = [ - {"year": 2026, "start": P0, "end": P1, "epsg": 5186, "route_json": None}, - {"year": 2027, "start": P1, "end": P2, "epsg": 5186, "route_json": '{"mode": "gentle"}'}, - ] - for seg, routes in ( - (segments[0], {"gradeline": _route([[0, 0], [100, 200], [300, 400]], 530.0)}), - ( - segments[1], - { - "gradeline": _route([[300, 400], [900, 400]], 600.0), - "gentle": _route([[300, 400], [600, 500], [900, 400]], 640.0), - "earthwork": {"error": "길 없음"}, - }, - ), - ): - atomic_write_json( - analyze.derived_dir(tmp_path) / f"route_{seg['year']}.json", - { - "year": seg["year"], - "start": list(seg["start"]), - "end": list(seg["end"]), - "fingerprint": "f", - "criteria": {"min_radius_m": 12.0, "max_grade_pct": 14.0}, - "routes": routes, - }, - ) - project = { - "road_type": "work", - "design_speed_kph": None, - "route_start_m": 1000.0, - "route_end_m": 2500.0, + plan = { + "epsg": 5186, + "start": {"lon": 0, "lat": 0, "x": P0[0], "y": P0[1]}, + "end": {"lon": 0, "lat": 0, "x": P2[0], "y": P2[1]}, } - async def inputs(project_id, points=None): - return tmp_path, project, router_mod._override(segments, points) if points else segments + async def load_base(project_id): + return plan - log: list = [] - monkeypatch.setattr(router_mod, "_plan_inputs", inputs) - monkeypatch.setattr(router_mod, "get_db_pool", lambda: _Pool(log)) + async def stored(connection, project_id): + return "1/3/p" + + monkeypatch.setattr(router_mod.base, "load_base", load_base) + monkeypatch.setattr(router_mod, "get_db_pool", lambda: _Pool()) + monkeypatch.setattr(router_mod, "get_project_storage_relative_path", stored) + monkeypatch.setattr(router_mod, "resolve_stored_project_path", lambda s: str(tmp_path)) app = FastAPI() app.include_router(router_mod.router) - session = {"role": "USER", "email": "a@forest.go.kr"} - app.dependency_overrides[require_company] = lambda: session - stored: list = [] - - async def store_points(project_id, segs): - 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 + test.plan = plan return test -def test_get_chains_saved_choice(client) -> None: - body = client.get(f"/api/projects/{PID}/plan/analysis").json() - chain = body["chain"] - assert [r["mode"] for r in chain] == ["gradeline", "gentle"] # 저장값 · 없으면 첫 성공 - assert chain[0]["start_chainage_m"] == 1000.0 - assert chain[1]["start_chainage_m"] == chain[0]["end_chainage_m"] == 1530.0 - assert chain[1]["end_chainage_m"] == 2170.0 - assert body["line"] == [[0, 0], [100, 200], [300, 400], [600, 500], [900, 400]] - assert body["end_chainage_target_m"] == 2500.0 # 등록 목표 — 화면이 차이를 보임 +def test_extent_uses_whole_ends(client) -> None: + got = client.get(f"{URL}/extent?margin_m=400").json() + m = 400.0 + assert got["margin_m"] == m and got["have"] is None and got["covered"] is False + # 전체 시점 · 종점(D02 정본) — 연도 구간 아님 + assert got["box_m"] == [P0[0] - m, P0[1] - m, P2[0] + m, P2[1] + m] + extra = client.get(f"{URL}/extent", params={"extra": json.dumps([[2000, 0]])}).json() + assert extra["box_m"][2] == 2000 + extra["margin_m"] + assert client.get(f"{URL}/extent", params={"extra": "[1]"}).status_code == 400 + client.plan["end"] = None # 종점 빔 — 1단계로 + assert client.get(f"{URL}/extent").status_code == 400 -def test_put_saves_server_routes(client) -> None: - got = client.put(f"/api/projects/{PID}/plan/analysis", json={"choices": {"2027": "gradeline"}}) - assert got.status_code == 200 - assert got.json()["chain"][1]["end_chainage_m"] == 2130.0 - updates = [args for sql, args in client.log if sql.startswith("UPDATE plan_route_years")] - saved = {args[3]: json.loads(args[0]) for args in updates} - assert saved[2027]["mode"] == "gradeline" and saved[2027]["planned"] == [[300, 400], [900, 400]] - assert saved[2026]["start_chainage_m"] == 1000.0 and updates[0][1] == "f" - assert client.log[-1][0] == "COMMIT" +def test_post_runs_one_set(client, monkeypatch) -> None: + calls: list = [] + saved: list = [] + works: list = [] + def ensure(root, points, epsg, margin_m, report): + calls.append((points, epsg, margin_m)) + return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]} -def test_put_rejects_unknown_or_empty_mode(client) -> None: - url = f"/api/projects/{PID}/plan/analysis" - assert client.put(url, json={"choices": {"2027": "follow"}}).status_code == 400 - assert client.put(url, json={"choices": {"2027": "earthwork"}}).status_code == 409 - assert not [sql for sql, _ in client.log if sql.startswith("UPDATE")] + async def save(project_id, models, epsg): + saved.append((models, epsg)) + def start(project_id, kind, work): + works.append(work) + return {"status": "accepted", "kind": kind} -def _points(p1) -> list: - return [ - {"year": 2026, "start": list(P0), "end": list(p1)}, - {"year": 2027, "start": list(p1), "end": list(P2)}, - ] + def post(body): + res = client.post(URL, json=body) + asyncio.run(works.pop()(lambda percent, message: None)) + return res - -def test_points_override_sees_matching_store_only(client) -> None: - url = f"/api/projects/{PID}/plan/analysis" - same = client.get(url, params={"points": json.dumps(_points(P1))}).json() - assert same["chain"] is not None and same["line"][-1] == [900, 400] - moved = client.get(url, params={"points": json.dumps(_points((310.0, 400.0)))}).json() - assert moved["chain"] is None and moved["years"][0]["analysis"] is None # 끈 지점 = 저장본 없음 - - -def test_points_must_chain_and_cover_years(client) -> None: - url = f"/api/projects/{PID}/plan/analysis" - broken = _points(P1) - broken[1]["start"] = [301.0, 400.0] - assert client.get(url, params={"points": json.dumps(broken)}).status_code == 400 - assert client.get(url, params={"points": json.dumps(_points(P1)[:1])}).status_code == 400 - - -def test_put_with_points_stores_points_forest_only(client) -> None: - url = f"/api/projects/{PID}/plan/analysis" - body = {"choices": {"2027": "gradeline"}, "points": _points(P1)} - assert client.put(url, json=body).status_code == 200 - assert client.stored == [[(2026, P0, P1), (2027, P1, P2)]] - assert client.put(url, json={"choices": {}}).status_code == 200 and len(client.stored) == 1 - client.session["email"] = "a@gmail.com" - assert client.put(url, json=body).status_code == 403 - 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 + monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure) + monkeypatch.setattr(router_mod, "_save_models", save) + monkeypatch.setattr(router_mod, "_start_job", start) + res = post({"margin_m": 500, "extra": [[5, 6]]}) + assert res.json() == {"status": "accepted", "kind": "terrain"} + assert calls == [([P0, P2, (5.0, 6.0)], 5186, 500.0)] + assert saved == [([{"model_file_path": "m"}], 5186)] + # 옛 화면 꼴(points 몸) — 여유 처음 값 · 전체 시점 · 종점 + post({"points": []}) + assert calls[-1] == ([P0, P2], 5186, None) diff --git a/resources/tester/test_530_3_plan_logic.py b/resources/tester/test_530_3_plan_logic.py index 34b4578f2..727ea3077 100644 --- a/resources/tester/test_530_3_plan_logic.py +++ b/resources/tester/test_530_3_plan_logic.py @@ -1,7 +1,7 @@ -"""D03 화면 계산 거울(PLAN 530-3) — 파이썬 `D03_PlanAnalysis_Analyze` ↔ TS `logic/D03_PlanAnalysis_Plan.ts`. +"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1) — TS `logic/D03_PlanAnalysis_Plan.ts`. -짝: `pick_mode` · `chain` · `joined_line` ↔ `pickMode` · `chainRows` · `joinedLine`. -TS 만: 측점 누가(경계 = 측점 · 간격 배수) · 앞붙임 선(선 위 누가 = 등록 시작 누가) · 받은 지형 판정. +조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 · 서버 꼴 읽기 · +화면이 보내는 꼴(선 · 마크 · 조각)을 서버 셈(`D03_PlanAnalysis_Network.derive`)이 그대로 받음. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q """ @@ -18,61 +18,47 @@ ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from D03_PlanAnalysis import D03_PlanAnalysis_Analyze as analyze # noqa: E402 +from D03_PlanAnalysis import D03_PlanAnalysis_Network as server # noqa: E402 TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" _RUNNER = """ -import { readFileSync, writeFileSync } from "node:fs"; +import { writeFileSync } from "node:fs"; import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; -const job = JSON.parse(readFileSync(process.argv[2], "utf8")); -const out = job.cases.map((c) => { - const rows = plan.chainRows(job.results, c.choices, c.start); - return { - picks: job.results.map((r) => plan.pickMode(r, c.choices[r.year])), - rows, - line: plan.joinedLine(job.results, rows), - stations: plan.stationChainages(rows, 20), - }; -}); -const lead = plan.leadInLine([[0, 0], [3, 4], [10, 4]], 1234.5); -const reach = plan.nodesInReach( - [[500, 500], [900, 500], [1700, 500]], - [{ x0: 0, y0: 0, x1: 1300, y1: 1000 }, null], +const Y = 2026; +let n = plan.emptyNetwork([0, 0]); +n = plan.appendVertex(n, 0, [100, 0]); +n = plan.appendVertex(n, 0, [100, 100]); +n = plan.normalizePieces(n, Y); +const one = n; +n = plan.normalizePieces(plan.splitPiece(n, "main", 50), Y); +const split = n; +const tooClose = plan.splitPiece(n, "main", 50.5); +n = plan.normalizePieces(plan.setPieceYear(n, 1, 2027), Y); +n = plan.addBranch(n, 0, 1); +n = plan.appendVertex(n, 1, [160, 0]); +n = plan.normalizePieces(n, Y); +const branched = n; +const inserted = plan.insertVertex(n, 0, 0, [40, 0]); +const blocked = plan.dropLastVertex(inserted, 0); +const dropped = plan.dropLastVertex(n, 1); +const guard = plan.dropLastVertex(plan.dropLastVertex(n, 0), 0); +const removed = plan.normalizePieces(plan.removeMark(n, "main", 50.4, 1), Y); +const grown = plan.normalizePieces(plan.appendVertex(n, 0, [100, 150]), Y); +const read = plan.fromServer( + n.lines, + [{ line: "main", at_m: 50 }, { line: "main", at_m: 150 }], + [{ id: "p9", line: "main", from_m: 0, to_m: 50, year: 2028 }], + Y, ); -writeFileSync(process.argv[3], JSON.stringify({ out, lead, reach })); +writeFileSync(process.argv[2], JSON.stringify({ + one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read, + marks: plan.marksOf(branched), + reached: plan.reachedEnd(n, [100, 100]), +})); """ -_P0, _P1, _P2 = [0.0, 0.0], [300.0, 400.0], [900.0, 400.0] -_RESULTS = [ - { - "year": 2026, - "start": _P0, - "end": _P1, - "routes": { - "gradeline": {"planned": [_P0, [100.0, 200.0], _P1], "metrics": {"length_m": 530.0}}, - "earthwork": {"error": "길 없음"}, - "gentle": {"planned": [_P0, _P1], "metrics": {"length_m": 500.004}}, - }, - }, - { - "year": 2027, - "start": _P1, - "end": _P2, - "routes": { - "gradeline": {"error": "길 없음"}, - "gentle": {"planned": [_P1, [600.0, 500.0], _P2], "metrics": {"length_m": 640.333}}, - "weighted": {"planned": [_P1, _P2], "metrics": {"length_m": 600.0}}, - }, - }, -] -_CASES = [ - {"choices": {}, "start": 1000.0}, - {"choices": {"2026": "gentle", "2027": "weighted"}, "start": 17.25}, - {"choices": {"2026": "earthwork", "2027": "follow"}, "start": 0.0}, -] - def _run_ts(tmp_path: Path) -> dict: out = tmp_path / "js" @@ -103,11 +89,9 @@ def _run_ts(tmp_path: Path) -> dict: 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({"results": _RESULTS, "cases": _CASES}), encoding="utf-8") subprocess.run( # noqa: S603 - ["node", str(out / "runner.mjs"), str(job), str(result)], + ["node", str(out / "runner.mjs"), str(result)], cwd=str(ROOT), check=True, capture_output=True, @@ -115,28 +99,48 @@ def _run_ts(tmp_path: Path) -> dict: return json.loads(result.read_text(encoding="utf-8")) -def test_ts_matches_python_chain(tmp_path: Path) -> None: +def _spans(network: dict, line: str = "main") -> list[tuple[float, float, int]]: + return [(p["from_m"], p["to_m"], p["year"]) for p in network["pieces"] if p["line"] == line] + + +def test_pieces_cover_and_split(tmp_path: Path) -> None: got = _run_ts(tmp_path) - for case, ts in zip(_CASES, got["out"]): - choices = {int(k): v for k, v in case["choices"].items()} - rows = analyze.chain(_RESULTS, choices, case["start"]) - assert ts["picks"] == [analyze.pick_mode(r, choices.get(r["year"])) for r in _RESULTS] - assert ts["rows"] == rows - assert ts["line"] == analyze.joined_line(_RESULTS, rows) - - # 측점 = 경계(1530) · 20 배수 · 끝 — 첫 연도 시작 1000 · 끝 2170.333 - stations = got["out"][0]["stations"] - assert stations[:3] == [1000.0, 1020.0, 1040.0] - assert 1530.0 in stations and 1520.0 in stations and 1540.0 in stations - assert stations[-2:] == [2160.0, 2170.333] - # 시작 17.25 → 첫 규칙 측점 20 - assert got["out"][1]["stations"][:2] == [17.25, 20.0] + assert _spans(got["one"]) == [(0, 200, 2026)] + assert _spans(got["split"]) == [(0, 50, 2026), (50, 200, 2026)] + assert [p["id"] for p in got["split"]["pieces"]] == ["p1", "p2"] # 앞 조각이 id 를 지님 + assert got["tooClose"] == got["split"] # 마크 1 m 안 = 안 놓음 + assert _spans(got["removed"]) == [(0, 200, 2026)] # 마크 지움 = 앞 조각 연도 + assert _spans(got["grown"]) == [(0, 50, 2026), (50, 250, 2027)] # 선이 늘면 끝 조각이 늚 + assert got["reached"] is True -def test_lead_in_and_reach(tmp_path: Path) -> None: +def test_branch_order_and_guard(tmp_path: Path) -> None: got = _run_ts(tmp_path) - back = got["lead"][0] - assert abs(back[0] + 1234.5 * 0.6) < 1e-9 and abs(back[1] + 1234.5 * 0.8) < 1e-9 - assert got["lead"][1:] == [[0, 0], [3, 4], [10, 4]] - # 마디 0 = 첫 연도 안 · 마디 1 = 첫 연도 안 + 둘째 연도 DEM 없음 = 밖 · 마디 2 = 밖 - assert got["reach"] == [True, False, False] + branch = got["branched"]["lines"][1] + assert branch["branch"] == {"line": "main", "vertex": 1} and branch["vertices"][0] == [100, 0] + assert _spans(got["branched"], branch["id"]) == [(0, 60, 2026)] + # 본선 앞 구간에 꺾임점을 끼우면 갈래가 붙은 차례가 한 칸 밀림 + assert got["inserted"]["lines"][0]["vertices"][1] == [40, 0] + assert got["inserted"]["lines"][1]["branch"]["vertex"] == 2 + assert got["blocked"]["lines"][0]["vertices"][-1] == [100, 0] # 끝 점은 갈래 없음 → 빠짐 + assert "갈래가 붙은 점" in got["guard"] # 갈래가 붙은 점은 못 뺌 + assert got["dropped"]["lines"][1]["vertices"] == [[100, 0]] + + +def test_from_server_fills_marks(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + # 조각 없는 구역(마크만) = 처음 연도 · 새 id + assert _spans(got["read"]) == [(0, 50, 2028), (50, 150, 2026), (150, 200, 2026)] + assert got["read"]["pieces"][0]["id"] == "p9" + assert len({p["id"] for p in got["read"]["pieces"]}) == 4 # 갈래 조각 포함 겹침 없음 + + +def test_server_accepts_screen_body(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + body = {**got["branched"], "marks": got["marks"], "epsg": 5186} + derived = server.derive(body, 2026) + assert [(p["line"], p["from_m"], p["to_m"], p["year"]) for p in derived["pieces"]] == [ + ("main", 0.0, 50.0, 2026), + ("main", 50.0, 200.0, 2027), + ("branch-1", 0.0, 60.0, 2026), + ] diff --git a/resources/tester/test_532_3_e03_section.py b/resources/tester/test_532_3_e03_section.py index 7f1a08727..4e051d996 100644 --- a/resources/tester/test_532_3_e03_section.py +++ b/resources/tester/test_532_3_e03_section.py @@ -271,6 +271,39 @@ def test_노선면_솎음(project): assert max(abs(v) for v in ribbon["positions"]) < 5000 # 원점 뺀 자리 +def test_노선면_설계_지형(project): + """지형 = E04 Grid 꼴 · 리본과 같은 원점 · 노면 발자국 안 = None · 노면 옆 표고 ≈ 비탈 끝.""" + body = asyncio.run(router.corridor3d(UUID(PID))) + terrain = body["terrain"] + g, z = terrain["grid"], terrain["z"] + assert len(z) == g["nx"] * g["ny"] and g["dx"] >= router.TERRAIN_STEP_M + assert g["nx"] * g["ny"] <= router.TERRAIN_MAX_CELLS * 1.05 + have = [v for v in z if v is not None] + assert have and max(abs(v) for v in have) < 500 # 원점 표고 뺀 값 + + def cell(x, y): + ix = round((x - g["x0"]) / g["dx"]) + iy = round((y - g["y0"]) / g["dx"]) + return z[iy * g["nx"] + ix] + + # 노면 리본 가운데 열 = 발자국 안 → 구멍 + road = next(r for r in body["ribbons"] if r["colCount"] >= 3) + cols = road["colCount"] + mid = [ + road["positions"][(i * cols + cols // 2) * 3 : (i * cols + cols // 2) * 3 + 2] + for i in range(len(road["chainages"])) + ] + holes = [cell(*p) is None for p in mid] + assert sum(holes) / len(holes) > 0.9 + # 지형 있는 칸의 원래 표고 = DTM 표본 − 원점 표고 + sampler = router.DtmGridSampler.from_npz(src.terrain_grid_path(src.source_dir(project))) + i = next(k for k, v in enumerate(z) if v is not None) + x = g["x0"] + (i % g["nx"]) * g["dx"] + body["origin"][0] + y = g["y0"] + (i // g["nx"]) * g["dx"] + body["origin"][1] + want, ok = sampler.sample_xy([[x, y]]) + assert ok[0] and z[i] == pytest.approx(want[0] - body["origin"][2], abs=0.02) + + class _Cursor: def __init__(self, rows): self.rows = rows @@ -411,6 +444,9 @@ process.stdout.write(JSON.stringify({ stress: m.sliceStress([input.slice]), ticks: m.niceTicks(-3, 47, 5), zAt: [m.zAt([[0, 0], [10, 10]], 2.5), m.zAt([[10, 10], [0, 0]], -5)], + stride: [m.labelStride(100, 64), m.labelStride(40, 64), m.labelStride(10, 64), m.labelStride(0.1, 64)], + labels: [1, 2, 5].map((k) => m.planLabelKeys(input.rows, 20, k, null)), + labelsActive: m.planLabelKeys(input.rows, 20, 5, "e"), })); """ @@ -541,6 +577,10 @@ def test_logic_TS(): ) assert out["ticks"][0] <= -3 and out["ticks"][-1] >= 47 and 0 in out["ticks"] assert out["zAt"] == [2.5, 0] + # 평면 띠 측점 글 솎음 — 간격 px × 걸음 ≥ 64 · 본 측점 중 걸음 배수 · 고른 측점은 늘 + assert out["stride"] == [1, 2, 10, 100] + assert out["labels"] == [["a", "b", "c", "d", "e"], ["a", "c", "e"], ["a"]] + assert out["labelsActive"] == ["a", "e"] def test_엔진_지문은_코드_내용(): diff --git a/resources/tester/test_533_3_e04_router.py b/resources/tester/test_533_3_e04_router.py index 0e7793b5d..1b418129e 100644 --- a/resources/tester/test_533_3_e04_router.py +++ b/resources/tester/test_533_3_e04_router.py @@ -107,7 +107,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): monkeypatch.setattr(run, "run_engine", counted) first = client.post(f"/api/projects/{PID}/stab/analyze3d", json=BODY).json() - assert first == {"status": "accepted", "key": "0.00_100.00"} + assert first == {"status": "accepted", "key": "0.00_100.00_dry"} done = _wait(client) assert done["error"] is None and done["percent"] == 100 assert done["summary"]["cells"] == 5 and done["summary"]["verdict"] == "ng" @@ -133,7 +133,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): _wait(client) assert len(calls) == 3 - doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).json() + doc = client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"}).json() grid = doc["grid"] assert len(doc["f"]) == len(doc["z"]) == len(doc["v"]) == grid["nx"] * grid["ny"] f = np.array([np.nan if v is None else v for v in doc["f"]]) @@ -143,7 +143,7 @@ def test_analyze_cached_force_and_result(client, project, monkeypatch): assert doc["stale"] is False over = client.get(f"/api/projects/{PID}/stab/result3d").json() - assert [s["key"] for s in over["saved"]] == ["0.00_100.00"] and over["editable"] is True + assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry"] and over["editable"] is True assert len(over["line"]) == len(STATIONS) and over["sections"][0]["end_m"] == 100.0 # E02 지반을 바꾸면 저장본이 「옛 것」 (project / "E02_StabGround").mkdir() @@ -179,7 +179,9 @@ def test_cancel_and_restart(client, monkeypatch): body = _wait(client) assert body["cancelled"] is True and body["error"] is None assert ( - client.get(f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00"}).status_code + client.get( + f"/api/projects/{PID}/stab/result3d", params={"key": "0.00_100.00_dry"} + ).status_code == 404 ) # 다시 시작 @@ -191,6 +193,39 @@ def test_cancel_and_restart(client, monkeypatch): assert body["cancelled"] is False and body["percent"] == 100 +def test_dry_then_wet_both_kept(client, project, monkeypatch): + """건기 → 우기 = 다른 열쇠 · 앞 결과가 덮이지 않음 · 옛 이름(조건 없음) 파일도 읽음.""" + monkeypatch.setattr(run, "run_engine", _fake_engine) + for condition in ("dry", "wet"): + body = client.post( + f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": condition} + ).json() + assert body == {"status": "accepted", "key": f"0.00_100.00_{condition}"} + _wait(client) + over = client.get(f"/api/projects/{PID}/stab/result3d").json() + assert [(s["key"], s["condition"]) for s in over["saved"]] == [ + ("0.00_100.00_dry", "dry"), + ("0.00_100.00_wet", "wet"), + ] + folder = run.results_dir(project) + # 옛 이름 — 우기 파일을 조건 없는 이름으로 옮기면 우기 열쇠로 읽힘 + (folder / "0.00_100.00_wet.json").rename(folder / "0.00_100.00.json") + over = client.get(f"/api/projects/{PID}/stab/result3d").json() + assert [s["key"] for s in over["saved"]] == ["0.00_100.00_dry", "0.00_100.00_wet"] + url = f"/api/projects/{PID}/stab/result3d" + assert client.get(url, params={"key": "0.00_100.00_wet"}).json()["stale"] is False + again = client.post( + f"/api/projects/{PID}/stab/analyze3d", json={**BODY, "condition": "wet"} + ).json() + assert again["status"] == "cached" + + +def test_engine_hash_in_fingerprint(monkeypatch): + before = run.fingerprint("a", None, {}) + monkeypatch.setattr(run, "engine_hash", lambda: "changed") + assert run.fingerprint("a", None, {}) != before + + def test_bad_requests(client): url = f"/api/projects/{PID}/stab/analyze3d" assert client.post(url, json={"start_m": 100, "end_m": 50}).status_code == 400 diff --git a/resources/tester/test_534_3_e05_report.py b/resources/tester/test_534_3_e05_report.py index 41da1f402..f130b9a86 100644 --- a/resources/tester/test_534_3_e05_report.py +++ b/resources/tester/test_534_3_e05_report.py @@ -170,7 +170,7 @@ def project(tmp_path, monkeypatch) -> Path: path.write_text(json.dumps(ground), encoding="utf-8") r3 = root / "E04_Stab3D" / "results" r3.mkdir(parents=True) - (r3 / "0.00_200.00.json").write_text( + (r3 / "0.00_200.00_wet.json").write_text( json.dumps( { "fingerprint": "3d-1", diff --git a/resources/tester/test_536_2_e07_area.py b/resources/tester/test_536_2_e07_area.py index 6bfe08965..d35050163 100644 --- a/resources/tester/test_536_2_e07_area.py +++ b/resources/tester/test_536_2_e07_area.py @@ -266,3 +266,15 @@ def test_길_띠_측점표_저장본(project): "s", {**settings, "c": 6} ) json.dumps(doc) # 그대로 JSON + + +def test_지문_E02_물성_엔진_해시(project, monkeypatch): + """광역 물성 = E02 값 — E02 붕적토가 바뀌거나 엔진 파일이 바뀌면 지문이 달라 옛 결과.""" + settings = router_mod.defaults(project) + ground = router_mod.ground_material(project, settings["material_id"]) + assert ground["id"] == "colluvium" and ground["c"] == settings["c"] + assert router_mod.ground_material(project, "없는것") == ground # 모르는 id = 붕적토 + before = router_mod.fingerprint("s", settings, ground) + assert before != router_mod.fingerprint("s", settings, {**ground, "c": ground["c"] + 1}) + monkeypatch.setattr(router_mod, "engine_hash", lambda: "바뀜") + assert before != router_mod.fingerprint("s", settings, ground) diff --git a/resources/tester/test_543_plan_edit.py b/resources/tester/test_543_plan_edit.py deleted file mode 100644 index eabd0d00c..000000000 --- a/resources/tester/test_543_plan_edit.py +++ /dev/null @@ -1,210 +0,0 @@ -"""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) diff --git a/resources/tester/test_544_1_plan_expect.py b/resources/tester/test_544_1_plan_expect.py deleted file mode 100644 index eadfcde49..000000000 --- a/resources/tester/test_544_1_plan_expect.py +++ /dev/null @@ -1,187 +0,0 @@ -"""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"] diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 0de454c09..89db18e28 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -31,12 +31,19 @@ def test_d03_parts(): ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts") three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts") css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css") + draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts") + tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") assert 'createElement("progress")' not in ts and "createProgressBar()" in ts - assert "createCard({" in ts and "onSelect:" in ts - assert 'classList.toggle("is-picked"' in ts # 촬영 장면이 `.d03-plan__mode.is-picked` 로 잡음 - assert "card.dataset.mode = mode" in ts + # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 + for src in (ts, draw, tools): + for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): + assert raw not in src, raw + assert "createToolbar({" in tools and "createIconButton(" in tools + assert '"rotateCcw"' in tools and '"rotateCw"' in tools and "leftPan" not in tools + assert "createSegment({" in draw and draw.count("createInputField(") == 2 + assert "createSelectField(" in ts # 조각 연도 assert 'createElement("select")' not in three and "createSelectField" in three - assert 'createElement("input")' not in three and "createCheckbox" in three + assert "createCheckbox" not in three and "buildCorridor" not in three # 예상 코리도 걷음 for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"): assert dead not in css, dead assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음 diff --git a/resources/tester/test_561_1_sheet_encoding.py b/resources/tester/test_561_1_sheet_encoding.py new file mode 100644 index 000000000..3ced79cfd --- /dev/null +++ b/resources/tester/test_561_1_sheet_encoding.py @@ -0,0 +1,74 @@ +"""PLAN 561-1 — `.cpg` 없는 도엽 dbf = cp949 로 읽음 · 병합 열쇠에 파서 판 번호. + + ./venv/Scripts/python.exe -m pytest resources/tester/test_561_1_sheet_encoding.py -q + +`.cpg` 없는 dbf 를 그냥 읽으면 latin-1 → 칸 이름 「등고수치」가 깨져 등고 높이가 빔 +(D 프로젝트 4250c7bc 「등고 라인 94단을 격자에 굽어 0셀」). +""" + +from __future__ import annotations + +import sys +import zipfile +from pathlib import Path + +import geopandas as gpd +from shapely.geometry import LineString + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import B04_PreProcess.B04_PreProcess_Engine_SheetParser as parser # noqa: E402 + +BASE = "N3L_F0010000" + + +def _sheet_zip(tmp_path: Path, with_cpg: bool) -> Path: + """등고선 두 줄짜리 도엽 zip — dbf 는 cp949 · `.cpg` 는 고름.""" + shp_dir = tmp_path / ("cpg" if with_cpg else "nocpg") + shp_dir.mkdir() + gdf = gpd.GeoDataFrame( + {"등고수치": [100.0, 105.0], "구분": ["주곡선", "주곡선"]}, + geometry=[LineString([(0, 0), (10, 0)]), LineString([(0, 5), (10, 5)])], + crs="EPSG:5186", + ) + gdf.to_file(shp_dir / f"{BASE}.shp", encoding="cp949") + cpg = shp_dir / f"{BASE}.cpg" + if with_cpg: + cpg.write_text("CP949", encoding="ascii") + else: + cpg.unlink(missing_ok=True) + zip_path = tmp_path / f"{shp_dir.name}.zip" + with zipfile.ZipFile(zip_path, "w") as zf: + for f in shp_dir.iterdir(): + zf.write(f, f.name) + return zip_path + + +def _read(zip_path: Path) -> gpd.GeoDataFrame: + gdf = parser._read_layer_from_zip(zip_path, "N3L", "F0010000", "EPSG:5186") + assert gdf is not None + return gdf + + +def test_no_cpg_reads_cp949(tmp_path): + gdf = _read(_sheet_zip(tmp_path, with_cpg=False)) + assert "등고수치" in gdf.columns + assert sorted(gdf["등고수치"].tolist()) == [100.0, 105.0] + assert set(gdf["구분"]) == {"주곡선"} + + +def test_with_cpg_unchanged(tmp_path): + gdf = _read(_sheet_zip(tmp_path, with_cpg=True)) + assert sorted(gdf["등고수치"].tolist()) == [100.0, 105.0] + + +def test_merge_keys_carry_parser_version(): + assert parser.PARSER_VERSION >= 2 + for rel in ( + "B04_PreProcess/B04_PreProcess_Engine.py", + "D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py", + ): + text = (ROOT / rel).read_text(encoding="utf-8") + assert '"parser": PARSER_VERSION' in text, rel diff --git a/resources/tester/test_562_1_plan_draw.py b/resources/tester/test_562_1_plan_draw.py new file mode 100644 index 000000000..e42bff53d --- /dev/null +++ b/resources/tester/test_562_1_plan_draw.py @@ -0,0 +1,203 @@ +"""D03 수동 그리기 셈 거울(PLAN 562-1) — 파이썬 `D03_PlanAnalysis_Draw` ↔ +TS `logic/D03_PlanAnalysis_Draw.ts`. + +같은 높이 함수(`hill` · x > 800 = 지형 밖)로 두 쪽 값이 같은지 + 손셈 값. + +./venv/Scripts/python.exe -m pytest resources/tester/test_562_1_plan_draw.py -q +""" + +from __future__ import annotations + +import json +import math +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 D03_PlanAnalysis import D03_PlanAnalysis_Draw as draw # noqa: E402 + +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + + +def hill(x: float, y: float) -> float: + if x > 800: + return math.nan + return 0.0005 * x * x + 3 * math.sin(y / 40) + + +def plane(x: float, y: float) -> float: + return 0.1 * x + + +LINE = [[0.0, 0.0], [30.0, 40.0], [30.0, 100.0], [90.0, 180.0]] # 50 + 60 + 100 = 210 m + +# (함수 이름, 인자) — 높이 자리는 "hill" · "plane" 글자로 넘김 +CASES = [ + ("segmentLength", [[1, 2], [4, 6]]), + ("groundSlopePct", [[0, 0], [100, 30], "hill"]), + ("groundSlopePct", [[100, 30], [0, 0], "hill"]), + ("groundSlopePct", [[700, 0], [900, 0], "hill"]), + ("groundSlopePct", [[5, 5], [5, 5], "hill"]), + ("fixedLengthPoint", [[10, 10], [13, 14], 20]), + ("fixedLengthPoint", [[10, 10], [10, 10], 20]), + ("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill"]), + ("fixedSlopePoint", [[0, 0], [50, 0], -9, "hill"]), + ("fixedSlopePoint", [[100, 30], [130, 70], 12, "hill"]), + ("fixedSlopePoint", [[0, 0], [50, 0], 9, "hill", 100]), + ("fixedSlopePoint", [[600, 0], [700, 0], 1, "hill"]), + ("compassCandidates", [[0, 0], 50, 5, "plane"]), + ("compassCandidates", [[200, 10], 60, 15, "hill"]), + ("compassCandidates", [[0, 0], 50, 50, "plane"]), + ("compassPoint", [[0, 0], [10, 40], 50, 5, "plane"]), + ("compassPoint", [[0, 0], [10, 40], 50, 50, "plane"]), + ("lineLength", [LINE]), + ("pointAtDistance", [LINE, -5]), + ("pointAtDistance", [LINE, 75]), + ("pointAtDistance", [LINE, 500]), + ("distanceAlong", [LINE, [40, 70]]), + ("distanceAlong", [LINE, [-10, -10]]), + ("sliceLine", [LINE, 25, 160]), + ("sliceLine", [LINE, 160, 25]), + ("stationChainages", [210]), + ("stationChainages", [40, 20]), + ("stationChainages", [0]), +] + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as draw from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js"; +const heights = { + hill: (x, y) => (x > 800 ? NaN : 0.0005 * x * x + 3 * Math.sin(y / 40)), + plane: (x, y) => 0.1 * x, +}; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = cases.map(([name, args]) => + draw[name](...args.map((a) => (typeof a === "string" ? heights[a] : a))), +); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + +_HEIGHTS = {"hill": hill, "plane": plane} +_PY = { + "segmentLength": draw.segment_length, + "groundSlopePct": draw.ground_slope_pct, + "fixedLengthPoint": draw.fixed_length_point, + "fixedSlopePoint": draw.fixed_slope_point, + "compassCandidates": draw.compass_candidates, + "compassPoint": draw.compass_point, + "lineLength": draw.line_length, + "pointAtDistance": draw.point_at_distance, + "distanceAlong": draw.distance_along, + "sliceLine": draw.slice_line, + "stationChainages": draw.station_chainages, +} + + +def _py(name: str, args: list) -> object: + return _PY[name](*(_HEIGHTS[a] if isinstance(a, str) else a for a in args)) + + +def _close(a: object, b: object) -> bool: + if a is None or b is None: + return a is b + if isinstance(a, (int, float)): + return isinstance(b, (int, float)) and abs(a - b) < 1e-6 + return len(a) == len(b) and all(_close(x, y) for x, y in zip(a, b)) # type: ignore[arg-type] + + +@pytest.fixture(scope="module") +def ts_out(tmp_path_factory: pytest.TempPathFactory) -> list: + tmp = tmp_path_factory.mktemp("draw") + out = tmp / "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 / "job.json" + result = tmp / "out.json" + job.write_text(json.dumps(CASES), 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 test_ts_matches_python(ts_out: list) -> None: + for (name, args), ts in zip(CASES, ts_out): + assert _close(ts, _py(name, args)), (name, args, ts, _py(name, args)) + + +def test_hand_values() -> None: + assert draw.segment_length((1, 2), (4, 6)) == 5 + # 오르막 + · 내리막 − · 지형 밖 · 길이 0 + up = draw.ground_slope_pct((0, 0), (100, 0), hill) + assert up == pytest.approx(5.0) + assert draw.ground_slope_pct((100, 0), (0, 0), hill) == pytest.approx(-5.0) + assert draw.ground_slope_pct((700, 0), (900, 0), hill) is None + assert draw.ground_slope_pct((5, 5), (5, 5), hill) is None + assert draw.fixed_length_point((10, 10), (13, 14), 20) == pytest.approx((22, 26)) + # z = 0.0005 x² · x0 = 0 → 평균 기울기 0.0005 t = 9 % 첫 자리 t = 180 + # 부호 무관 · 한도 100 m = 없음 + assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill) == pytest.approx((180, 0), abs=1e-6) + assert draw.fixed_slope_point((0, 0), (50, 0), -9, hill) == pytest.approx((180, 0), abs=1e-6) + assert draw.fixed_slope_point((0, 0), (50, 0), 9, hill, 100) is None + # 지형 밖에 닿음 + assert draw.fixed_slope_point((600, 0), (700, 0), 1, hill) is None + # 평면 10 % · 반지름 50 · 5 % → cos θ = ±0.5 · 60 · 120 · 240 · 300° + got = draw.compass_candidates((0, 0), 50, 5, plane) + angles = sorted(round(math.degrees(math.atan2(y, x)) % 360, 6) for x, y in got) + assert angles == [60, 120, 240, 300] + assert draw.compass_candidates((0, 0), 50, 50, plane) == [] + near = draw.compass_point((0, 0), (10, 40), 50, 5, plane) + assert near == pytest.approx((25, 25 * math.sqrt(3)), abs=1e-6) + assert draw.compass_point((0, 0), (10, 40), 50, 50, plane) is None + + +def test_line_values() -> None: + assert draw.line_length(LINE) == 210 + assert draw.point_at_distance(LINE, -5) == (0, 0) + assert draw.point_at_distance(LINE, 75) == (30, 65) + assert draw.point_at_distance(LINE, 500) == (90, 180) + assert draw.distance_along(LINE, (40, 70)) == 80 + assert draw.distance_along(LINE, (-10, -10)) == 0 + assert draw.slice_line(LINE, 25, 160) == pytest.approx( + [(15, 20), (30, 40), (30, 100), (60, 140)] + ) + assert draw.slice_line(LINE, 160, 25) == draw.slice_line(LINE, 25, 160) + assert draw.station_chainages(210) == [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200, 210] + assert draw.station_chainages(40, 20) == [0, 20, 40] + assert draw.station_chainages(0) == [0] diff --git a/resources/tester/test_564_1_e02_chain.py b/resources/tester/test_564_1_e02_chain.py new file mode 100644 index 000000000..edeb43f8f --- /dev/null +++ b/resources/tester/test_564_1_e02_chain.py @@ -0,0 +1,233 @@ +# -*- coding: utf-8 -*- +"""564-1 E02 [해석 시작] 체인 — 가짜 단계로 차례 · 비율 · 건너뜀 · 끊기 · 실패 격리 · +3D 단계 = 구간 × 건기 · 우기 · 지문 같은 조각 건너뜀 · 구간 수로 나눈 진행률 · +길 셋(지반 저장 → 시작 · 두 번째 409 · 끊기). +""" + +import asyncio +import copy +import sys +from pathlib import Path +from uuid import UUID + +import pytest +from fastapi import HTTPException + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from E01_StabRegister import E01_StabRegister_Source as source # noqa: E402 +from E02_StabGround import E02_StabGround_Chain as chain # noqa: E402 +from E02_StabGround import E02_StabGround_Model as model # noqa: E402 +from E02_StabGround import E02_StabGround_Router as router # noqa: E402 +from E04_Stab3D import E04_Stab3D_Router as e04 # noqa: E402 +from E04_Stab3D import E04_Stab3D_Run as run3d # noqa: E402 + +PID = UUID("9872e9a7-e483-427b-a6b2-72cdbc063bc3") +KEYS = [k for k, _, _ in chain.STEPS] +TOTAL = sum(w for _, _, w in chain.STEPS) + + +def _fake(log, states=None, fail=(), interrupt=(), stop_after=None, flag=None): + """가짜 단계 — 부른 차례 · 단계 안 반쯤 진행 · 정한 상태 · 실패 · 끊김.""" + states = states or {} + + def make(key): + async def step(report, stopped): + log.append(key) + report(0.5, f"{key} 반") + if key in fail: + raise RuntimeError(f"{key} 망가짐") + if key in interrupt: + raise InterruptedError + if key == stop_after: + flag[0] = True + return states.get(key, "done") + + return step + + return {k: make(k) for k in KEYS} + + +class _Job(dict): + """update 마다 진행률을 적어 둠(뒤로 안 가는지 봄).""" + + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self.seen = [] + + def update(self, *a, **kw): + super().update(*a, **kw) + self.seen.append(self["percent"]) + + +def _run(steps, stopped=lambda: False): + job = _Job({**chain.idle(), "running": True}) + asyncio.run(chain.run_chain(job, steps, stopped)) + return job, job.seen + + +def test_차례_비율_건너뜀(): + log = [] + job, seen = _run(_fake(log, states={"rock": "skip", "report": "skip"})) + assert log == KEYS + assert [s["state"] for s in job["steps"]] == ["done", "done", "skip", "done", "skip"] + assert all(s["seconds"] is not None for s in job["steps"]) + assert job["percent"] == 100 and job["message"] == "끝" and job["error"] is None + assert job["stage"] is None and not job["cancelled"] + assert seen == sorted(seen) # 진행률은 뒤로 안 감 + # 3D 반쯤 = 2D 몫 + 3D 몫 반(몫 = STEPS 비율) + half_3d = int(100 * (chain.STEPS[0][2] + chain.STEPS[1][2] / 2) / TOTAL) + assert half_3d in seen + + +def test_끊기_다음_단계_안_돎(): + log, flag = [], [False] + job, _ = _run(_fake(log, stop_after="3d", flag=flag), stopped=lambda: flag[0]) + assert log == ["2d", "3d"] + assert [s["state"] for s in job["steps"]] == ["done", "done", "cancel", "wait", "wait"] + assert job["cancelled"] and job["percent"] < 100 + + +def test_단계_안에서_끊김(): + log = [] + job, _ = _run(_fake(log, interrupt={"3d"})) + assert log == ["2d", "3d"] + assert [s["state"] for s in job["steps"]][:3] == ["done", "cancel", "wait"] + assert job["cancelled"] and job["message"] == "끊음" + + +def test_실패는_그_단계만(): + log = [] + job, _ = _run(_fake(log, fail={"2d", "area"})) + assert log == KEYS + assert [s["state"] for s in job["steps"]] == ["error", "done", "done", "error", "done"] + assert "2D 측점 해석: 2d 망가짐" in job["error"] and "광역 위험" in job["error"] + assert job["percent"] == 100 + + +# --------------------------------------------------------------------------- +# 3D 단계 — 구간 × 조건 · 지문 같으면 건너뜀 · 구간 수로 나눈 진행률 · E04 [끊기] +# --------------------------------------------------------------------------- + +STATIONS = [{"chainage_m": float(c)} for c in range(0, 320, 20)] # 0~300 m → 구간 셋 + + +@pytest.fixture +def fake_3d(tmp_path, monkeypatch): + calls = [] + fresh = {"0.00_100.00_dry"} # 이 조각만 저장본 지문이 지금과 같음 + monkeypatch.setattr(e04, "_source_root", lambda root: root) + monkeypatch.setattr(source, "stations", lambda src: STATIONS) + monkeypatch.setattr(e04, "_current_fingerprint", lambda *a: "fp") + monkeypatch.setattr( + run3d, "load", lambda root, key: {"fingerprint": "fp"} if key in fresh else None + ) + + def analyze(root, src, settings, finger, on_progress, should_stop): + calls.append((settings["start_m"], settings["end_m"], settings["condition"])) + assert (e04._JOBS.get(str(PID)) or {}).get("running") # E04 화면 「해석 중」 + on_progress(50, "탐색") + if should_stop(): + raise InterruptedError + return {"min_fs": 1.5} + + monkeypatch.setattr(run3d, "analyze", analyze) + e04._JOBS.pop(str(PID), None) + return tmp_path, calls + + +def test_3D_구간_조건_건너뜀_진행률(fake_3d): + root, calls = fake_3d + step = chain.build_steps(PID, root)["3d"] + seen = [] + state = asyncio.run(step(lambda f, m: seen.append((round(f, 4), m)), lambda: False)) + sections = run3d.auto_sections(STATIONS) + assert len(sections) == 3 + units = len(sections) * len(chain.CONDITIONS_3D) + assert state == "done" and len(calls) == units - 1 # 지문 같은 조각 하나 건너뜀 + assert calls[0] == (0.0, 100.0, "wet") and {c for *_, c in calls} == {"dry", "wet"} + assert seen[0] == (round(1 / units, 4), f"3D 1 / {units} 구간") # 건너뛴 조각 = 바로 한 칸 + assert (round(1.5 / units, 4), f"3D 2 / {units} 구간") in seen # 둘째 조각 반쯤 + assert [f for f, _ in seen] == sorted(f for f, _ in seen) + assert not e04._JOBS[str(PID)]["running"] + + +def test_3D_E04_끊기(fake_3d): + root, calls = fake_3d + step = chain.build_steps(PID, root)["3d"] + + def report(_f, _m): + e04._STOPS[str(PID)] = True # E04 화면 [끊기] + + with pytest.raises(InterruptedError): + asyncio.run(step(report, lambda: False)) + assert len(calls) == 1 and e04._JOBS[str(PID)]["cancelled"] + + +def test_3D_모두_최신이면_건너뜀(fake_3d, monkeypatch): + root, calls = fake_3d + monkeypatch.setattr(run3d, "load", lambda root, key: {"fingerprint": "fp"}) + step = chain.build_steps(PID, root)["3d"] + assert asyncio.run(step(lambda f, m: None, lambda: False)) == "skip" and not calls + + +# --------------------------------------------------------------------------- +# 길 — [해석 시작] = 지반 저장 → 시작 · 도는 중 두 번째 409 · 끊기 +# --------------------------------------------------------------------------- + + +def test_길_저장_시작_409_끊기(tmp_path, monkeypatch): + async def fake_project(_project_id): + return tmp_path, {"company_id": 1, "project_kind": "STABILITY"} + + monkeypatch.setattr(router, "_project", fake_project) + gate = {} + + def fake_steps(_pid, _root): + async def slow(report, stopped): + await gate["open"].wait() + return "done" + + async def quick(report, stopped): + return "skip" + + return {k: (slow if k == "2d" else quick) for k in KEYS} + + monkeypatch.setattr(chain, "build_steps", fake_steps) + ground = model.build_initial(model.load_defaults(), []) + ground["materials"][0]["c"] = 7.0 + + async def scenario(): + gate["open"] = asyncio.Event() + first = await router.run_chain(PID, router.GroundRequest(ground=copy.deepcopy(ground))) + assert first["running"] and first["ground"]["materials"][0]["c"] == 7.0 + assert model.ground_path(tmp_path).is_file() # 지반 저장 먼저 + await asyncio.sleep(0) + with pytest.raises(HTTPException) as busy: + await router.run_chain(PID, router.GroundRequest(ground=copy.deepcopy(ground))) + assert busy.value.status_code == 409 + assert (await router.run_progress(PID))["stage"] == "2d" + assert (await router.run_cancel(PID))["stopping"] is True + gate["open"].set() + for _ in range(50): + await asyncio.sleep(0) + return await router.run_progress(PID) + + done = asyncio.run(scenario()) + assert not done["running"] and done["cancelled"] + assert [s["state"] for s in done["steps"]][:2] == ["done", "cancel"] + + +def test_틀린_지반은_시작_안_함(tmp_path, monkeypatch): + async def fake_project(_project_id): + return tmp_path, {"company_id": 1, "project_kind": "STABILITY"} + + monkeypatch.setattr(router, "_project", fake_project) + chain._JOBS.pop(str(PID), None) + ground = model.build_initial(model.load_defaults(), []) + ground["materials"][0]["gamma_t"] = 9.9 + with pytest.raises(HTTPException) as bad: + asyncio.run(router.run_chain(PID, router.GroundRequest(ground=ground))) + assert bad.value.status_code == 422 and not chain._JOBS.get(str(PID)) diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index aa72a5466..6385499b0 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -93,6 +93,9 @@ const PATHS = { menu: "M4 6l16 0|M4 12l16 0|M4 18l16 0", undo: "M9 14l-4 -4l4 -4|M5 10h11a4 4 0 1 1 0 8h-1", redo: "M15 14l4 -4l-4 -4|M19 10h-11a4 4 0 1 0 0 8h1", + // 회전 둘 — B05 반 바퀴 화살표(한 칸씩 돎 · 온 바퀴 그림 아님)를 24 칸으로 옮김 + rotateCcw: "M19.5 12a7.5 7.5 0 0 0 -15 0|M4.5 12l-2.7 -3.6|M4.5 12l3.6 -2.1", + rotateCw: "M4.5 12a7.5 7.5 0 0 1 15 0|M19.5 12l2.7 -3.6|M19.5 12l-3.6 -2.1", alert: "M12 9v4|M10.363 3.591l-8.106 13.534a1.914 1.914 0 0 0 1.636 2.871h16.214a1.914 1.914 0 0 0 1.636 -2.87l-8.106 -13.536a1.914 1.914 0 0 0 -3.274 0z|M12 16h.01", lock: "M5 13a2 2 0 0 1 2 -2h10a2 2 0 0 1 2 2v6a2 2 0 0 1 -2 2h-10a2 2 0 0 1 -2 -2v-6z|M11 16a1 1 0 1 0 2 0a1 1 0 0 0 -2 0|M8 11v-4a4 4 0 1 1 8 0v4", diff --git a/ui_template/ui_template_locale_common.ts b/ui_template/ui_template_locale_common.ts index 3aa65d7c6..de94262a0 100644 --- a/ui_template/ui_template_locale_common.ts +++ b/ui_template/ui_template_locale_common.ts @@ -158,6 +158,11 @@ export const ui_locales_common = { "Ctrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+Shift+Z = 다시", "Ctrl+Z = undo\nCtrl+Y = redo\nCtrl+Shift+Z = redo", ], + App_Settings_Keys_Plan: ["D03 노선분석", "D03 route analysis"], + App_Settings_Keys_Plan_Keys: [ + "Enter(그리기) = 끝\nEsc(그리기) = 취소\nBackspace(그리기) = 마지막 점 빼기\nCtrl+Z = 되돌림\nCtrl+Y = 다시\nCtrl+Shift+Z = 다시", + "Enter (drawing) = finish\nEsc (drawing) = cancel\nBackspace (drawing) = remove last point\nCtrl+Z = undo\nCtrl+Y = redo\nCtrl+Shift+Z = redo", + ], App_Settings_Keys_Section: ["B06 횡단", "B06 cross section"], App_Settings_Keys_Section_Keys: [ "방향키 = 측점 옮김\n▲▼ = 측점 옮김\nEnter = 고름\nSpace = 고름\nEsc = 고름 풀기\nEsc(상세 창 고르기 중) = 고르기 취소", diff --git a/ui_template/ui_template_locale_e.ts b/ui_template/ui_template_locale_e.ts index d262b9d94..b05652da1 100644 --- a/ui_template/ui_template_locale_e.ts +++ b/ui_template/ui_template_locale_e.ts @@ -114,7 +114,10 @@ export const ui_locales_e = { E02_Col_Points: ["점 (offset · 깊이)", "Points (offset · depth)"], /* --- 상태 · 알림 --- */ - E02_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], + E02_Unsaved: [ + "해석 전 — 바뀐 값은 [해석 시작] 때 저장됩니다.", + "Not analyzed — changes are saved on [Run analysis].", + ], E02_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], E02_Saved: ["저장했습니다.", "Saved."], E02_ResetDone: ["초기값으로 되돌렸습니다.", "Restored to initial values."], @@ -127,6 +130,35 @@ export const ui_locales_e = { E02_UserLayer: ["사용자 지층 — 출처 없음", "User layer — no source"], E02_NoProject: ["프로젝트를 먼저 고르세요.", "Pick a project first."], + /* --- [해석 시작] 체인(PLAN 564-3) --- */ + E02_Run: ["해석 시작", "Run analysis"], + E02_Run_Hint: [ + "[해석 시작] = 지반 값 저장 → 2D · 3D(모든 구간 · 건기 · 우기) · 암반 · 광역 · 보고서를 차례로 셈. 입력이 같은 단계는 건너뜀 · 1.2 km 약 25~30 분 · 다른 페이지로 가도 계속 돎.", + "[Run analysis] = save ground values → 2D · 3D (all sections · dry · wet) · rock · area · report in turn. Unchanged steps are skipped · about 25–30 min per 1.2 km · keeps running on other pages.", + ], + E02_Run_Running: ["해석 중", "Running"], + E02_Run_Cancel: ["끊기", "Stop"], + E02_Run_Stopping: [ + "끊는 중 — 지금 조각이 끝나면 멈춥니다.", + "Stopping — halts after the current piece.", + ], + E02_Run_Done: ["해석을 마쳤습니다.", "Analysis finished."], + E02_Run_Cancelled: [ + "해석을 끊었습니다 — 끝난 단계는 저장됨.", + "Analysis stopped — finished steps are saved.", + ], + E02_Run_Step_2d: ["2D 측점 해석", "2D station analysis"], + E02_Run_Step_3d: ["3D 구간 해석", "3D section analysis"], + E02_Run_Step_rock: ["암반 해석", "Rock analysis"], + E02_Run_Step_area: ["광역 위험", "Area hazard"], + E02_Run_Step_report: ["보고서", "Report"], + E02_Run_State_wait: ["기다림", "Waiting"], + E02_Run_State_run: ["도는 중", "Running"], + E02_Run_State_done: ["끝남", "Done"], + E02_Run_State_skip: ["최신 · 건너뜀", "Up to date · skipped"], + E02_Run_State_error: ["실패", "Failed"], + E02_Run_State_cancel: ["끊음", "Stopped"], + /* --- 입력 검사(코드 = 파이썬 check_ground 와 같은 낱말) --- */ E02_Err_number: ["숫자를 넣으세요", "Enter a number"], E02_Err_range: ["범위 밖", "Out of range"], diff --git a/ui_template/ui_template_locale_e03.ts b/ui_template/ui_template_locale_e03.ts index 2051255c8..48138007e 100644 --- a/ui_template/ui_template_locale_e03.ts +++ b/ui_template/ui_template_locale_e03.ts @@ -25,9 +25,7 @@ export const ui_locales_e03 = { E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."], E03_View3d: ["3D 보기", "3D View"], E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"], - E03_RepSave: ["대표 단면 저장", "Save Representative"], - E03_RepSaved: ["대표 단면을 저장했습니다.", "Representative sections saved."], - E03_RepUnsaved: ["대표 단면 바뀜 — 저장 안 됨", "Representative changes not saved"], + E03_View3d_Terrain: ["설계 지형", "Design terrain"], E03_Loading: ["불러오는 중…", "Loading…"], E03_NotYet: [ "아직 해석하지 않았습니다 — [해석]을 누르세요.", diff --git a/ui_template/ui_template_locale_e04.ts b/ui_template/ui_template_locale_e04.ts index 49c898651..73d26b435 100644 --- a/ui_template/ui_template_locale_e04.ts +++ b/ui_template/ui_template_locale_e04.ts @@ -39,7 +39,6 @@ export const ui_locales_e04 = { E04_Condition_wet: ["우기 (지표까지 포화)", "Wet (saturated to surface)"], E04_Defaults: ["기본값", "Defaults"], E04_Analyze: ["해석", "Analyze"], - E04_Reanalyze: ["다시 해석", "Re-analyze"], E04_Cancel: ["끊기", "Stop"], E04_Running: ["해석 중", "Analyzing"], E04_Cancelled: ["해석을 끊었습니다.", "Analysis stopped."], @@ -48,6 +47,8 @@ export const ui_locales_e04 = { E04_ReadOnly: ["보기만 허용된 프로젝트입니다.", "View-only project."], E04_Saved: ["결과 있음", "Saved"], E04_Stale: ["옛 결과", "Outdated"], + E04_Short_dry: ["건기", "Dry"], + E04_Short_wet: ["우기", "Wet"], /* --- 가운데 지도 · 3D --- */ E04_View_Plan: ["평면 지도", "Plan map"], diff --git a/ui_template/ui_template_locale_e06.ts b/ui_template/ui_template_locale_e06.ts index 83273a815..8888b1d95 100644 --- a/ui_template/ui_template_locale_e06.ts +++ b/ui_template/ui_template_locale_e06.ts @@ -10,8 +10,8 @@ import type { LocaleEntry } from "./ui_template_locale_common"; export const ui_locales_e06 = { /* --- E06 좌측 --- */ E06_Note: [ - "암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [저장] → [해석]. 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.", - "Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, [Save] → [Analyze]. Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).", + "암 측점(리핑암 · 발파암) 깎기 비탈만 봅니다. 절리군을 넣고 [해석](넣은 값 저장 + 해석). 사면 방향 · 경사 · 높이는 복사본 횡단, 물성 · 지진 · 기준안전율은 지반 조건(E02)을 씁니다.", + "Only cut slopes at rock stations (rippable · blasting rock). Enter joint sets, then [Analyze] (saves and analyzes). Slope orientation, angle and height come from the copied sections; strength, seismic and required FS from Ground (E02).", ], E06_Card_Joints: ["절리군", "Joint sets"], E06_JointsHint: [ @@ -61,8 +61,10 @@ export const ui_locales_e06 = { E06_Running: ["해석 중…", "Analyzing…"], E06_Done: ["암반 해석을 마쳤습니다.", "Rock analysis done."], E06_Fresh: ["입력이 같아 지난 결과를 씁니다.", "Inputs unchanged — previous result kept."], - E06_Saved: ["저장했습니다.", "Saved."], - E06_Unsaved: ["저장 안 된 변경이 있습니다.", "There are unsaved changes."], + E06_Unsaved: [ + "저장 안 됨(해석 전) — [해석]을 누르면 저장됩니다.", + "Not saved (before analysis) — [Analyze] saves.", + ], E06_ReadOnly: ["보기만 허용된 프로젝트입니다.", "This project is view-only."], E06_Invalid: ["틀린 칸", "Invalid fields"], E06_Err_Range: ["범위 밖", "Out of range"], diff --git a/ui_template/ui_template_locale_e07.ts b/ui_template/ui_template_locale_e07.ts index a405ebc12..577d387d2 100644 --- a/ui_template/ui_template_locale_e07.ts +++ b/ui_template/ui_template_locale_e07.ts @@ -44,7 +44,6 @@ export const ui_locales_e07 = { E07_LevelHi: ["측점 높음 문턱 (SI<1 비율 %)", "Station high threshold (SI<1 share %)"], E07_Defaults: ["기본값", "Defaults"], E07_Analyze: ["해석", "Analyze"], - E07_Reanalyze: ["다시 해석", "Re-analyze"], E07_Running: ["해석 중", "Analyzing"], E07_Done: ["광역 해석을 마쳤습니다.", "Area analysis finished."], E07_Cached: ["같은 조건의 결과가 있습니다.", "A result with the same inputs exists."], @@ -67,8 +66,8 @@ export const ui_locales_e07 = { E07_Result_Title: ["광역 결과", "Area result"], E07_Result_None: ["아직 해석 결과가 없습니다.", "No result yet."], E07_Result_Stale: [ - "엔진이 바뀌어 결과가 옛것입니다 — [다시 해석].", - "The engine changed — re-analyze.", + "입력(E02 물성 · 엔진)이 바뀌어 결과가 옛것입니다 — [해석]을 누르세요.", + "Inputs (E02 soil · engine) changed — press [Analyze].", ], E07_Result_Note: [ "문헌 추정치 · 현장 시험 없음 · 기본값은 바꿀 수 있음. 표준 = 포화비 W = min(R·a / (T·sinθ), 1). SI = c · φ · T/R 범위 안에서 Fs > 1 일 확률(하한 Fs ≥ 1 이면 그 값 · 상한 Fs < 1 이면 0). Fs = 가장 나쁜 쪽(c · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",