Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_4' into main_laptop_1
This commit is contained in:
@@ -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"],
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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<PlanPiece & { length_m: number; planned: Vertex[] }>;
|
||||
} | null;
|
||||
/** 옛 연도 노선을 선으로 바꾼 것인가. */
|
||||
legacy: boolean;
|
||||
/** 사업연도 시작(있으면). */
|
||||
year_start?: number | null;
|
||||
}
|
||||
|
||||
export interface PlanSurface {
|
||||
modelId: number;
|
||||
grid: DtmGrid;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const response = await fetch(`${API_BASE_URL}${path}`, {
|
||||
@@ -66,62 +86,49 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
}
|
||||
|
||||
const base = (projectId: string): string => `/projects/${projectId}/plan`;
|
||||
const pointsBody = (points: PlanPoints | null): string => JSON.stringify(points ? { points } : {});
|
||||
|
||||
export const fetchPlanPoints = (projectId: string): Promise<PlanPointsView> =>
|
||||
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<TerrainExtent> => {
|
||||
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<PlanJob> =>
|
||||
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<PlanAnalysisView> =>
|
||||
request(
|
||||
`${base(projectId)}/analysis` +
|
||||
(points ? `?points=${encodeURIComponent(JSON.stringify(points))}` : ""),
|
||||
);
|
||||
|
||||
export const savePlanAnalysis = (
|
||||
projectId: string,
|
||||
choices: Record<number, string>,
|
||||
points: PlanPoints | null,
|
||||
edits: Record<number, PlanEdit> = {},
|
||||
): Promise<{ chain: ChainRow[] }> =>
|
||||
request(`${base(projectId)}/analysis`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(points ? { choices, points, 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<Map<number, YearSurface>> {
|
||||
export const fetchPlanNetwork = (projectId: string): Promise<PlanNetworkView> =>
|
||||
request(`${base(projectId)}/network`);
|
||||
|
||||
export const savePlanNetwork = (
|
||||
projectId: string,
|
||||
network: ServerNetwork,
|
||||
): Promise<PlanNetworkView> =>
|
||||
request(`${base(projectId)}/network`, { method: "PUT", body: JSON.stringify(network) });
|
||||
|
||||
/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…` · 격자 = 평활본 · 없으면 null. */
|
||||
export async function fetchPlanSurface(projectId: string): Promise<PlanSurface | null> {
|
||||
const { models } = await listSurfaceModels(projectId);
|
||||
const out = new Map<number, YearSurface>();
|
||||
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)) };
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<void> => {
|
||||
const item = years.find((entry) => entry.year === year) ?? years[0];
|
||||
const load = async (index: number): Promise<void> => {
|
||||
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<string, unknown> = { year: item.year, done: false };
|
||||
const stat: Record<string, unknown> = { 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);
|
||||
}
|
||||
|
||||
@@ -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<ExpectStandard | null> {
|
||||
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<ExpectStandard | null> | 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<typeof lineAt> = 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<typeof planColors>,
|
||||
): 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<PlanMapScene>) => void;
|
||||
/** 지점 · 노선이 들어오게 다시 맞춤. */
|
||||
setLayers: (layers: PlanMapLayer[]) => void;
|
||||
setTools: (tools: Partial<PlanTools>) => 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<typeof createIconButton>[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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 화면이 모달 몸을 꽉 채움 · 몸 스크롤 없음 */
|
||||
|
||||
@@ -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<Vertex>): 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<Vertex>, 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<Vertex>, 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<Vertex>, 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;
|
||||
}
|
||||
@@ -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<number | null>;
|
||||
}
|
||||
|
||||
export type EditStep = { edit: PlanEdit; line: EditedPolyline } | { blocked: string };
|
||||
|
||||
const limitsOf = (minRadiusM: number): CurveLimits => ({
|
||||
minRadiusM,
|
||||
limitRadiusM: minRadiusM,
|
||||
limitArcM: 0,
|
||||
});
|
||||
|
||||
const columnsOf = (edit: PlanEdit): NodeColumns => ({
|
||||
planned: edit.nodes.map(([x, y]) => [x, y] as Vertex),
|
||||
curveOn: [...edit.curve_flags],
|
||||
curveRadius: [...edit.radii],
|
||||
curveLock: edit.nodes.map(() => null),
|
||||
curveArc: edit.nodes.map(() => null),
|
||||
apexLock: edit.nodes.map(() => false),
|
||||
});
|
||||
|
||||
/** 엔진 노선 → 고침 시작 마디(묶인 곡선은 교각점 하나로 · 서버가 고른 R 그대로). */
|
||||
export function editFromRoute(route: PlanRoute, mode: string, fingerprint: string): PlanEdit {
|
||||
const flat = flattenServerPlan(route.nodes ?? [], (route.curves ?? []) as EditedCurve[]);
|
||||
return {
|
||||
base_mode: mode,
|
||||
base_fingerprint: fingerprint,
|
||||
nodes: flat.planned,
|
||||
curve_flags: flat.curveOn,
|
||||
radii: flat.curveRadius,
|
||||
};
|
||||
}
|
||||
|
||||
/** 마디 → 선 — R 은 하한까지 끌어올린 값으로 고침에 되적음(서버에 같은 값을 보냄). */
|
||||
export function buildEdit(
|
||||
edit: PlanEdit,
|
||||
minRadiusM: number,
|
||||
): { edit: PlanEdit; line: EditedPolyline } {
|
||||
const columns = columnsOf(edit);
|
||||
const line = rebuildLine(columns, limitsOf(minRadiusM));
|
||||
return { edit: { ...edit, radii: columns.curveRadius }, line };
|
||||
}
|
||||
|
||||
/** 고친 뒤 — 지키던 하한을 새로 깨면 막음(그 걸음 버림). */
|
||||
function settle(before: PlanEdit, after: PlanEdit, minRadiusM: number): EditStep {
|
||||
const built = buildEdit(after, minRadiusM);
|
||||
const stop = floorStop(
|
||||
columnsOf(before),
|
||||
built.edit.nodes,
|
||||
built.line.nodes,
|
||||
limitsOf(minRadiusM),
|
||||
);
|
||||
return stop ? { blocked: stop } : built;
|
||||
}
|
||||
|
||||
const isEnd = (edit: PlanEdit, index: number): boolean =>
|
||||
index <= 0 || index >= edit.nodes.length - 1;
|
||||
const ENDS_LOCKED = "처음 · 끝 마디는 연도 경계 지점이라 여기서 못 고칩니다.";
|
||||
|
||||
/** 마디 끌기 — 처음 · 끝 잠금. */
|
||||
export function moveNode(edit: PlanEdit, index: number, to: Vertex, minRadiusM: number): EditStep {
|
||||
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
|
||||
const nodes = edit.nodes.map((node, i) => (i === index ? ([to[0], to[1]] as Vertex) : node));
|
||||
return settle(edit, { ...edit, nodes }, minRadiusM);
|
||||
}
|
||||
|
||||
/** 곡선 손잡이(접선점) 끌기 → 그쪽 직선이 돌고 교각점 · R 이 바뀜 · R 은 법정 최소 밑으로 안 내려감. */
|
||||
export function moveHandle(
|
||||
edit: PlanEdit,
|
||||
index: number,
|
||||
which: "start" | "end",
|
||||
to: Vertex,
|
||||
minRadiusM: number,
|
||||
): EditStep {
|
||||
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
|
||||
const got = dragHandleTo(
|
||||
edit.nodes[index - 1],
|
||||
edit.nodes[index],
|
||||
edit.nodes[index + 1],
|
||||
which,
|
||||
to,
|
||||
);
|
||||
if (!got) return { blocked: "곡선을 그 자리로 못 옮깁니다." };
|
||||
const nodes = edit.nodes.map((node, i) => (i === index ? got.apex : node));
|
||||
const radii = edit.radii.map((r, i) => (i === index ? Math.max(got.radius, minRadiusM) : r));
|
||||
const curve_flags = edit.curve_flags.map((on, i) => (i === index ? true : on));
|
||||
return settle(edit, { ...edit, nodes, radii, curve_flags }, minRadiusM);
|
||||
}
|
||||
|
||||
/** 마디 넣기 — `segment` 번째 마디와 다음 마디 사이. */
|
||||
export function insertNode(
|
||||
edit: PlanEdit,
|
||||
segment: number,
|
||||
at: Vertex,
|
||||
minRadiusM: number,
|
||||
): EditStep {
|
||||
if (segment < 0 || segment >= edit.nodes.length - 1) return { blocked: "선 위를 누르세요." };
|
||||
const columns = columnsOf(edit);
|
||||
spliceNode(columns, segment + 1, [at[0], at[1]]);
|
||||
const after = {
|
||||
...edit,
|
||||
nodes: columns.planned,
|
||||
curve_flags: columns.curveOn,
|
||||
radii: columns.curveRadius,
|
||||
};
|
||||
return settle(edit, after, minRadiusM);
|
||||
}
|
||||
|
||||
/** 마디 지우기 — 처음 · 끝 잠금. */
|
||||
export function removeNode(edit: PlanEdit, index: number, minRadiusM: number): EditStep {
|
||||
if (isEnd(edit, index)) return { blocked: ENDS_LOCKED };
|
||||
const columns = columnsOf(edit);
|
||||
spliceNode(columns, index);
|
||||
const after = {
|
||||
...edit,
|
||||
nodes: columns.planned,
|
||||
curve_flags: columns.curveOn,
|
||||
radii: columns.curveRadius,
|
||||
};
|
||||
return settle(edit, after, minRadiusM);
|
||||
}
|
||||
|
||||
/** 같은 고침인가(서버 저장값과 견줌 · 1 mm · R 1 mm). */
|
||||
export function sameEdit(a: PlanEdit | null | undefined, b: PlanEdit | null | undefined): boolean {
|
||||
if (!a || !b) return false;
|
||||
const near = (x: number | null, y: number | null): boolean =>
|
||||
x === null || y === null ? x === y : Math.abs(x - y) < 1e-3;
|
||||
return (
|
||||
a.base_mode === b.base_mode &&
|
||||
a.base_fingerprint === b.base_fingerprint &&
|
||||
a.nodes.length === b.nodes.length &&
|
||||
a.nodes.every((p, i) => near(p[0], b.nodes[i][0]) && near(p[1], b.nodes[i][1])) &&
|
||||
a.curve_flags.every((on, i) => on === b.curve_flags[i]) &&
|
||||
a.radii.every((r, i) => near(r, b.radii[i] ?? null))
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -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<string, PlanRoute>;
|
||||
}
|
||||
|
||||
/** 연도 하나의 고친 노선 — 갈래 · 선(그 갈래 노선을 갈아 끼움) · 서버가 잰 값(없으면 길이만). */
|
||||
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<n>`(있는 것 중 가장 큰 수 + 1). */
|
||||
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): 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<YearAnalysis>,
|
||||
choices: Readonly<Record<number, string | null | undefined>>,
|
||||
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<Vertex>): number {
|
||||
let total = 0;
|
||||
for (let i = 1; i < line.length; i += 1)
|
||||
total += Math.hypot(line[i][0] - line[i - 1][0], line[i][1] - line[i - 1][1]);
|
||||
return total;
|
||||
}
|
||||
|
||||
/** 고친 노선을 그 갈래 자리에 갈아 끼운 분석 사본 — 길이 = 고친 선 길이(이은 누가 · 측점이 따라감).
|
||||
* 그 갈래가 없거나 빈 갈래면 그대로. */
|
||||
export function withEdits(
|
||||
results: ReadonlyArray<YearAnalysis>,
|
||||
edits: Readonly<Record<number, RouteEdit | undefined>>,
|
||||
): YearAnalysis[] {
|
||||
return results.map((result) => {
|
||||
const edit = edits[result.year];
|
||||
const route = edit ? result.routes?.[edit.mode] : undefined;
|
||||
if (!edit || !route || "error" in route) return result;
|
||||
const metrics = { ...(edit.metrics ?? {}), length_m: lineLength(edit.planned) };
|
||||
return {
|
||||
...result,
|
||||
routes: {
|
||||
...result.routes,
|
||||
[edit.mode]: { ...route, planned: edit.planned, metrics, edited: true },
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const samePoint = (a: Vertex, b: Vertex): boolean =>
|
||||
Math.abs(a[0] - b[0]) < 1e-3 && Math.abs(a[1] - b[1]) < 1e-3;
|
||||
|
||||
/** 고른 갈래를 이은 중심선 — 경계점 한 번만(꺾인 채). */
|
||||
export function joinedLine(
|
||||
results: ReadonlyArray<YearAnalysis>,
|
||||
rows: ReadonlyArray<ChainRow>,
|
||||
): 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<ChainRow>, 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<Vertex>, 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<ChainRow>, 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<number>,
|
||||
nodes: ReadonlyArray<Vertex>,
|
||||
): 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<Vertex>,
|
||||
boxes: ReadonlyArray<Box | null>,
|
||||
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<Vertex>, b: ReadonlyArray<Vertex>): 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<PlanPiece>,
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
@@ -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)}
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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<GroundResponse> {
|
||||
async function request<T = GroundResponse>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<void> {
|
||||
while (root.isConnected) {
|
||||
const p = await request<RunProgress>(`/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<void> {
|
||||
if (!view?.editable) return;
|
||||
/** [해석 시작] — 서버가 검사 · 저장(잠금판은 이 순간만) → 체인은 뒤에서 */
|
||||
async function runChain(): Promise<void> {
|
||||
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<GroundResponse & RunProgress>(`/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<void> {
|
||||
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<RunProgress>(`/projects/${projectId}/stab/run/progress`);
|
||||
paintRun(p);
|
||||
if (p.running) void poll();
|
||||
} catch (error) {
|
||||
main.replaceChildren(
|
||||
hook(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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") } })]
|
||||
: [],
|
||||
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return body;
|
||||
}
|
||||
|
||||
const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`;
|
||||
const sleep = (ms: number): Promise<void> => 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<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
|
||||
const wallRecs = new Map<string, WallRecord>();
|
||||
const supportRecs = new Map<string, SupportRecord>();
|
||||
let repCache: Record<string, boolean | null> = JSON.parse(
|
||||
sessionStorage.getItem(repKey(pid)) ?? "{}",
|
||||
);
|
||||
let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390)
|
||||
const planView: PlanView = {};
|
||||
let repSaving: Promise<void> = 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<void> {
|
||||
try {
|
||||
/** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */
|
||||
function toggleRep(key: string): Promise<void> {
|
||||
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<string, boolean | null> = {};
|
||||
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<void> {
|
||||
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 로 알림 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/* ---- 안전율 색 ------------------------------------------------------------ */
|
||||
|
||||
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<StationPoint | null>(
|
||||
const nearest = overview?.stations.reduce<StationPoint | null>(
|
||||
(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<void> {
|
||||
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<Result3d>(`/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<void> {
|
||||
async function analyze(): Promise<void> {
|
||||
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<Settings>);
|
||||
remember();
|
||||
}
|
||||
const p = await api<Progress>(`/projects/${projectId}/stab/progress3d`).catch(() => null);
|
||||
if (p?.running) void poll();
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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<StationResponse>(
|
||||
`/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<void> {
|
||||
const body = await api<ResultResponse>(`/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<void> {
|
||||
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<void> {
|
||||
if (!view?.editable) return;
|
||||
const ok = await showConfirmDialog(
|
||||
@@ -353,6 +342,7 @@ export function renderE06StabRock(root: HTMLElement): void {
|
||||
try {
|
||||
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`);
|
||||
const stations = body.stations ?? [];
|
||||
interval = inferStationInterval(stations);
|
||||
projection = body.joints.settings.projection;
|
||||
paintProjection();
|
||||
view = {
|
||||
|
||||
@@ -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")));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
|
||||
@@ -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<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
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<void> {
|
||||
async function analyze(): Promise<void> {
|
||||
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<Progress>(`/projects/${projectId}/stab/area/progress`).catch(() => null);
|
||||
if (p?.running) void poll();
|
||||
|
||||
@@ -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")
|
||||
# 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면
|
||||
|
||||
@@ -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);
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
]
|
||||
|
||||
@@ -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_엔진_지문은_코드_내용():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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"]
|
||||
@@ -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 # 빌림 끊음
|
||||
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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))
|
||||
@@ -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",
|
||||
|
||||
@@ -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(상세 창 고르기 중) = 고르기 취소",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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: [
|
||||
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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 · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",
|
||||
|
||||
Reference in New Issue
Block a user