Merge remote-tracking branches 'origin/sub_laptop_2' and 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-27 20:21:42 +09:00
69 changed files with 5017 additions and 3916 deletions
+1
View File
@@ -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"],
+1 -1
View File
@@ -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" },
+7 -2
View File
@@ -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
+88 -81
View File
@@ -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)) };
}
+253
View File
@@ -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
-169
View File
@@ -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}
+64 -324
View File
@@ -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}
+141 -71
View File
@@ -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,
}
+39 -62
View File
@@ -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;
}
},
};
}
+175 -234
View File
@@ -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
+45 -44
View File
@@ -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,
},
];
});
}
+204 -200
View File
@@ -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";
+288
View File
@@ -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
+37 -6
View File
@@ -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)}
+131 -18
View File
@@ -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;
}
+61 -5
View File
@@ -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
)
+5 -2
View File
@@ -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") } })]
: [],
+66 -44
View File
@@ -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 로 알림 */
}
}
+113 -35
View File
@@ -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;
}
+11 -1
View File
@@ -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 {
+41 -2
View File
@@ -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);
}
/* ---- 안전율 색 ------------------------------------------------------------ */
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
+4 -3
View File
@@ -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:
+61 -21
View File
@@ -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
View File
@@ -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();
},
+57 -37
View File
@@ -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();
})();
+2 -2
View File
@@ -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();
},
}),
+15 -25
View File
@@ -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 = {
+2 -2
View File
@@ -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")));
+43 -8
View File
@@ -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 -5
View File
@@ -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();
},
+32 -26
View File
@@ -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();
-3
View File
@@ -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")
# 자동 전처리가 만드는 도엽 방식 — 기본 하나뿐이다. 여섯 방식을 매번 다 만들면
+37
View File
@@ -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);
+2
View File
@@ -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)
+63 -34
View File
@@ -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
+252 -142
View File
@@ -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
+62 -267
View File
@@ -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)
+77 -73
View File
@@ -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_엔진_지문은_코드_내용():
+39 -4
View File
@@ -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
+1 -1
View File
@@ -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",
+12
View File
@@ -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)
-210
View File
@@ -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)
-187
View File
@@ -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"]
+11 -4
View File
@@ -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
+203
View File
@@ -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]
+233
View File
@@ -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))
+3
View File
@@ -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",
+5
View File
@@ -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(상세 창 고르기 중) = 고르기 취소",
+33 -1
View File
@@ -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"],
+1 -3
View File
@@ -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: [
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
+2 -1
View File
@@ -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"],
+6 -4
View File
@@ -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"],
+2 -3
View File
@@ -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 · φ 아래 · 가장 젖음). 지형 격자 끝 밖 상류는 집수면적에 안 들어갑니다.",