Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -174,6 +174,9 @@ export const STATE_REGISTRY = {
|
||||
scope: "project",
|
||||
legacy: (p) => `b06:rock-boundary-default:${p}`,
|
||||
},
|
||||
/** B05 계획노선 편집 창 — 마지막에 적용한 초기노선 갈래(로직 · 비중 · 노드 · 비교값).
|
||||
* 노선을 다시 계산해 결과를 버릴 때도 이 값은 새 노선의 출발점이라 되살린다(`_Apply`). */
|
||||
"routeedit-initial": { bucket: "result", scope: "project" },
|
||||
} as const satisfies Record<string, StateEntry>;
|
||||
|
||||
export type StateName = keyof typeof STATE_REGISTRY;
|
||||
|
||||
@@ -194,6 +194,9 @@ export function drawRidgeRing(
|
||||
* 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다.
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
/** 측점 라벨을 눈금에서 띄우는 거리(px) — 앞이 기본 자리, 뒤로 갈수록 비켜 놓는 자리. */
|
||||
const LABEL_REACH_PX = [16, 32, 48, 64, 80];
|
||||
|
||||
export interface StationTickOptions {
|
||||
/** 규칙 측점 간격(m). */
|
||||
intervalM: number;
|
||||
@@ -234,8 +237,8 @@ export function drawStationTicks(
|
||||
context.font = "11px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과
|
||||
// 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다).
|
||||
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다(2026-09-04 실측 4px) —
|
||||
// 이미 그린 라벨과 겹치는 자리는 비켜 놓는다.
|
||||
const drawn: Array<{ x: number; y: number; half: number }> = [];
|
||||
let cursor = 1;
|
||||
for (let chainage = 0; chainage <= total; chainage += interval) {
|
||||
@@ -268,14 +271,33 @@ export function drawStationTicks(
|
||||
context.stroke();
|
||||
|
||||
const label = stationLabel(chainage, interval);
|
||||
const lx = sx + ux * side * 16;
|
||||
const ly = sy + uy * side * 16;
|
||||
const width = context.measureText(label).width + 6;
|
||||
const half = width / 2;
|
||||
const collides = drawn.some(
|
||||
(item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16,
|
||||
);
|
||||
if (collides) continue;
|
||||
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
|
||||
// 찾는다. 끝내 못 찾으면 첫 자리에 그대로 둔다(눈금마다 이름이 있어야 한다).
|
||||
const free = (x: number, y: number): boolean =>
|
||||
!drawn.some((item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < 16);
|
||||
let reach = LABEL_REACH_PX[0] * side;
|
||||
for (const distance of LABEL_REACH_PX) {
|
||||
const found = [side, -side].find((way) =>
|
||||
free(sx + ux * way * distance, sy + uy * way * distance),
|
||||
);
|
||||
if (found !== undefined) {
|
||||
reach = distance * found;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const lx = sx + ux * reach;
|
||||
const ly = sy + uy * reach;
|
||||
if (Math.abs(reach) > LABEL_REACH_PX[0]) {
|
||||
// 멀리 비켜 선 라벨은 어느 눈금 것인지 가는 선으로 잇는다.
|
||||
context.beginPath();
|
||||
context.moveTo(sx + ux * Math.sign(reach) * 6, sy + uy * Math.sign(reach) * 6);
|
||||
context.lineTo(lx - ux * Math.sign(reach) * 8, ly - uy * Math.sign(reach) * 8);
|
||||
context.lineWidth = 0.8;
|
||||
context.strokeStyle = "rgba(40, 40, 40, 0.55)";
|
||||
context.stroke();
|
||||
}
|
||||
drawn.push({ x: lx, y: ly, half });
|
||||
context.save();
|
||||
if (options.uprightRad) {
|
||||
|
||||
@@ -76,7 +76,11 @@ export interface RouteReplanResponse {
|
||||
vertex_count: number;
|
||||
}
|
||||
|
||||
async function requestJson<T>(path: string, init: RequestInit, timeoutMs: number): Promise<T> {
|
||||
export async function requestJson<T>(
|
||||
path: string,
|
||||
init: RequestInit,
|
||||
timeoutMs: number,
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
@@ -121,13 +125,15 @@ export interface RouteReplanVertex {
|
||||
export async function replanRoute(
|
||||
projectId: string,
|
||||
vertices: Array<[number, number]> | RouteReplanVertex[],
|
||||
/** 함께 실어 보낼 값 — 지금은 마지막에 적용한 초기노선 갈래(`initial`, 계획서 20). */
|
||||
extra: Record<string, unknown> = {},
|
||||
): Promise<RouteReplanResponse> {
|
||||
const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) =>
|
||||
Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex,
|
||||
);
|
||||
return requestJson<RouteReplanResponse>(
|
||||
`/projects/${projectId}/route/replan`,
|
||||
{ method: "POST", body: JSON.stringify({ vertices: payload }) },
|
||||
{ method: "POST", body: JSON.stringify({ ...extra, vertices: payload }) },
|
||||
REPLAN_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Api_RouteInitial.ts
|
||||
* 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다.
|
||||
*
|
||||
* POST /projects/{id}/route/initial { logic, weights? } → 노드 · 비교값 · 공통 제약 기준값
|
||||
*
|
||||
* 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고,
|
||||
* 받은 비교값·기준값을 보이기만 한다. 서버 길은 sub3 몫이라 모양이 오면 `normalizeRouteInitial`
|
||||
* (`_RouteEdit_Initial_Model`) 한 곳만 맞춘다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { requestJson } from "./B05_Profile_Api_Replan";
|
||||
|
||||
/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */
|
||||
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추종(처음 초기값). */
|
||||
export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted";
|
||||
|
||||
/** 비중 계산의 세 비중(%) — 합 100. */
|
||||
export interface RouteInitialWeights {
|
||||
expected: number;
|
||||
earthwork: number;
|
||||
slope: number;
|
||||
}
|
||||
|
||||
export interface RouteInitialRequest {
|
||||
logic: RouteInitialLogic;
|
||||
weights?: RouteInitialWeights;
|
||||
}
|
||||
|
||||
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
|
||||
export const ROUTE_INITIAL_MISSING =
|
||||
"초기노선 갈래 서버 길이 아직 없습니다(계획서 20 · sub3 대기).";
|
||||
|
||||
/** 로직 하나로 초기노선을 묻는다. 답의 모양은 `normalizeRouteInitial` 이 편다. */
|
||||
export async function fetchRouteInitial(
|
||||
projectId: string,
|
||||
request: RouteInitialRequest,
|
||||
): Promise<unknown> {
|
||||
try {
|
||||
return await requestJson<unknown>(
|
||||
`/projects/${projectId}/route/initial`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
INITIAL_TIMEOUT_MS,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
if (/HTTP 40[45]|Not Found|Method Not Allowed/.test(message)) {
|
||||
throw new Error(ROUTE_INITIAL_MISSING);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
"""초기 계획노선 갈래 — 예상노선 추종 · 토공 최소 · 기울기 순한 노선 · 비중(PLAN 20장).
|
||||
|
||||
계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이
|
||||
받은 노드로 기존 `POST /route/replan`([확인])을 부른다.
|
||||
|
||||
갈래(`mode`):
|
||||
· follow — 지금 로직 그대로: 예상노선 점 묶음을 폴리라인화(`build_planned_polyline`).
|
||||
`B05_Profile_Router_Replan._ensure_planned_initial` 과 같은 입력 · 같은 결과.
|
||||
· earthwork — 절 · 성토 합 최소(DEM 옆경사로 가늠한 단면 토량 + 상한 넘는 지반 기울기).
|
||||
· gentle — 종단기울기 최소(지반 기울기 제곱 합).
|
||||
· weighted — 예상노선 이격 · 토공 · 기울기 세 목표를 **정규화해 가중합**(합 100).
|
||||
정규화 = 목표마다 복도 안 링크 평균값으로 나눔 — 단위가 다른 세 값을 1 m 당
|
||||
「보통 크기 1」로 맞춘 뒤 비중을 곱한다.
|
||||
탐색 = 예상노선 둘레 복도(`CORRIDOR_M`) 안 `SEARCH_CELL_M` 격자의 8-연결 Dijkstra(scipy csgraph).
|
||||
탐색 격자 경로는 follow 와 같은 폴리라인화(꺾임점 뽑기 + 원호 끼우기)를 지난다.
|
||||
|
||||
공통 제약(모든 갈래) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격:
|
||||
· 탐색 비용 — 상한 넘는 지반 기울기 링크 · 계류 버퍼 안 링크에 벌점(막지 않음 — 길이 없으면
|
||||
노선이 끊기므로).
|
||||
· 폴리라인화 — 최소 곡선반지름으로 원호를 끼우고 못 끼우면 위반 표시.
|
||||
· 결과 — `metrics.over_limit` 에 제약별 초과 구간 수.
|
||||
|
||||
⚠ 사용자 확정 대기 — 아래 임시값과 호출부가 넘기는 종단기울기 상한 · 최소 곡선반지름은 지식DB
|
||||
(`resources/knowledge/technical_info/01_임도`) 후보일 뿐이다(PLAN 20장 기준값 후보 표).
|
||||
확정되면 이 머리의 상수만 고친다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import shapely
|
||||
from scipy import ndimage
|
||||
from scipy.interpolate import RegularGridInterpolator
|
||||
from scipy.sparse import csr_matrix
|
||||
from scipy.sparse.csgraph import dijkstra
|
||||
from shapely.geometry import LineString, MultiLineString
|
||||
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
|
||||
# ── 사용자 확정 대기(지식DB 후보 · PLAN 20장) ─────────────────────────────────────
|
||||
# 계류 이격(m) — 법정 이격 수치 없음. 타당성평가 「산지계류 100 m 이내 구간 비율」(별표1 비고,
|
||||
# 1/25,000 주요 수계)을 후보로 씀.
|
||||
STREAM_OFFSET_M = 100.0
|
||||
# 이격을 따질 계류 — 도엽 하천중심선 `구분`. 「주요 수계」 가늠으로 세류 · 연결선은 뺌.
|
||||
STREAM_CLASSES = ("소하천", "지방하천", "국가하천")
|
||||
# 토공 가늠 폭(m) = 유효너비 3 + 길어깨 0.5 × 2(별표2 간선).
|
||||
ROAD_WIDTH_M = 4.0
|
||||
# 탐색 복도 반폭(m) — 예상노선에서 이보다 멀리는 가지 않음(법령 값 아님 · 알고리즘 값).
|
||||
CORRIDOR_M = 100.0
|
||||
PENDING = ("max_grade_pct", "min_radius_m", "stream_offset_m", "road_width_m", "corridor_m")
|
||||
|
||||
# ── 탐색 · 측정 값 ───────────────────────────────────────────────────────────────
|
||||
MODES = ("follow", "earthwork", "gentle", "weighted")
|
||||
DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
|
||||
MODE_WEIGHTS = {
|
||||
"follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
|
||||
"earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0},
|
||||
"gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0},
|
||||
}
|
||||
SEARCH_CELL_M = 4.0 # 탐색 격자 칸(m) — 비용면(2 m)을 솎아 씀
|
||||
SAMPLE_M = 2.0 # 결과 측정 간격(m)
|
||||
STATION_M = 20.0 # 종단기울기 측정 간격(m) — 타당성평가 20 m 간격 · 측점 체계
|
||||
LENGTH_COST = 0.05 # 1 m 당 거리 비용 — 목표가 평평한 곳에서 헤매지 않게
|
||||
# 지반 기울기가 상한을 넘는 링크 1 m 당 벌점 = 이 값 × (넘은 기울기 / 상한). 세게 두면(20)
|
||||
# 제약만 남고 비중이 안 먹음(랩탑_보조 실측: 비중 100/0/0 · 80/10/10 · 50/25/25 결과 같음).
|
||||
# 2 에서 비중을 올릴수록 이격 · 토공이 한 방향으로 바뀜(이격 4 → 16 m · 토공 4.9 → 4.0천 m³).
|
||||
GRADE_OVER_COST = 2.0
|
||||
STREAM_COST = 3.0 # 계류 버퍼 안 링크 1 m 당 벌점 — 건너기는 되고 나란히 달리기는 피함
|
||||
TURN_COST = 1.0 # 꺾을 때 벌점 = 최소 곡선반지름 원호 길이(R·θ) × 이 값
|
||||
MAX_TURN_STEPS = 2 # 한 걸음에 꺾는 방향 칸 수 한도(16 방향 중 ±2 = 약 45°)
|
||||
# 16 방향(행, 열) — +x 에서 반시계. 나이트 걸음(1, 2) 등을 넣어 22.5° 안팎 방향을 곧게 달림.
|
||||
DIRECTIONS = (
|
||||
(0, 1),
|
||||
(1, 2),
|
||||
(1, 1),
|
||||
(2, 1),
|
||||
(1, 0),
|
||||
(2, -1),
|
||||
(1, -1),
|
||||
(1, -2),
|
||||
(0, -1),
|
||||
(-1, -2),
|
||||
(-1, -1),
|
||||
(-2, -1),
|
||||
(-1, 0),
|
||||
(-2, 1),
|
||||
(-1, 1),
|
||||
(-1, 2),
|
||||
)
|
||||
# 계류 버퍼 안 한 구간이 건너기로 인정되는 길이 — 45° 이상으로 가로지를 때의 버퍼 안 길이.
|
||||
STREAM_CROSS_MAX_M = 2.0 * STREAM_OFFSET_M * math.sqrt(2.0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Terrain:
|
||||
"""지반 격자 — 탐색 비용면(`B05_Profile_Engine_Solver._load_or_build_cost_surface`) 그대로."""
|
||||
|
||||
x: np.ndarray # 열 좌표(오름차순)
|
||||
y: np.ndarray # 행 좌표(오름차순)
|
||||
z: np.ndarray # [행, 열] 표고
|
||||
valid: np.ndarray # [행, 열] 지형 있음
|
||||
|
||||
|
||||
def load_terrain_and_streams(
|
||||
project_root: Path, selection: dict[str, Any]
|
||||
) -> tuple[Terrain, list[list[tuple[float, float]]]]:
|
||||
"""지반 격자(탐색 비용면 캐시 그대로)와 이격을 따질 계류(사업지 좌표계)를 읽는다."""
|
||||
from pyproj import Transformer
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import (
|
||||
STREAM_FILE,
|
||||
_load_features,
|
||||
_reproject_features,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR, _load_or_build_cost_surface
|
||||
from common_util.common_util_crs import resolve_project_crs
|
||||
|
||||
x, y, z, valid, *_ = _load_or_build_cost_surface(
|
||||
project_root,
|
||||
project_root / _MODELS_SUBDIR,
|
||||
str(selection.get("source_filter")),
|
||||
str(selection.get("method") or "dtm"),
|
||||
bool(selection.get("smooth")),
|
||||
)
|
||||
to_metric = Transformer.from_crs("EPSG:4326", resolve_project_crs(project_root), always_xy=True)
|
||||
features = _load_features(project_root / "B04_PreProcess" / "processed", STREAM_FILE)
|
||||
streams = stream_lines(_reproject_features(features, to_metric))
|
||||
return Terrain(np.asarray(x), np.asarray(y), np.asarray(z), np.asarray(valid)), streams
|
||||
|
||||
|
||||
def resolve_weights(mode: str, weights: dict[str, float] | None) -> dict[str, float]:
|
||||
"""갈래의 비중(합 100). weighted 가 아니면 갈래가 정한 값."""
|
||||
if mode != "weighted":
|
||||
return dict(MODE_WEIGHTS[mode])
|
||||
merged = {**DEFAULT_WEIGHTS, **(weights or {})}
|
||||
return {key: float(merged[key]) for key in DEFAULT_WEIGHTS}
|
||||
|
||||
|
||||
def stream_lines(features: list[dict[str, Any]]) -> list[list[tuple[float, float]]]:
|
||||
"""이격을 따질 계류만 좌표 목록으로(사업지 좌표계로 이미 옮긴 피처)."""
|
||||
lines: list[list[tuple[float, float]]] = []
|
||||
for feature in features:
|
||||
if (feature.get("properties") or {}).get("구분") not in STREAM_CLASSES:
|
||||
continue
|
||||
geometry = feature.get("geometry") or {}
|
||||
parts = geometry.get("coordinates") or []
|
||||
if geometry.get("type") == "LineString":
|
||||
parts = [parts]
|
||||
elif geometry.get("type") != "MultiLineString":
|
||||
continue
|
||||
for part in parts:
|
||||
points = [(float(p[0]), float(p[1])) for p in part if len(p) >= 2]
|
||||
if len(points) >= 2:
|
||||
lines.append(points)
|
||||
return lines
|
||||
|
||||
|
||||
def generate_initial_route(
|
||||
expected: list[tuple[float, float]],
|
||||
terrain: Terrain,
|
||||
streams: list[list[tuple[float, float]]],
|
||||
*,
|
||||
mode: str,
|
||||
weights: dict[str, float] | None,
|
||||
max_grade: float,
|
||||
min_radius_m: float,
|
||||
) -> dict[str, Any]:
|
||||
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14)."""
|
||||
if mode not in MODES:
|
||||
raise ValueError(f"모르는 갈래: {mode}")
|
||||
if len(expected) < 2:
|
||||
raise ValueError("예상노선 정점이 2개 미만입니다.")
|
||||
used = resolve_weights(mode, weights)
|
||||
points = (
|
||||
list(expected)
|
||||
if mode == "follow"
|
||||
else _search_route(expected, terrain, streams, used, max_grade, min_radius_m)
|
||||
)
|
||||
outline = build_planned_polyline(points, min_radius_m=min_radius_m, simplify=True)
|
||||
metrics = route_metrics(
|
||||
outline.vertices,
|
||||
expected,
|
||||
terrain,
|
||||
streams,
|
||||
max_grade=max_grade,
|
||||
radius_runs=sum(1 for curve in outline.curves if curve.violations),
|
||||
)
|
||||
return {
|
||||
"mode": mode,
|
||||
"weights": used,
|
||||
"planned": [[round(x, 4), round(y, 4)] for x, y in outline.vertices],
|
||||
"nodes": [node.as_dict() for node in outline.nodes],
|
||||
"curves": [curve.as_dict() for curve in outline.curves],
|
||||
"metrics": metrics,
|
||||
"criteria": {
|
||||
"max_grade_pct": round(max_grade * 100.0, 2),
|
||||
"min_radius_m": round(min_radius_m, 2),
|
||||
"stream_offset_m": STREAM_OFFSET_M,
|
||||
"road_width_m": ROAD_WIDTH_M,
|
||||
"corridor_m": CORRIDOR_M,
|
||||
"pending": list(PENDING),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── 탐색 ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _cell_size(coords: np.ndarray) -> float:
|
||||
return float((coords[-1] - coords[0]) / (len(coords) - 1)) if len(coords) > 1 else 1.0
|
||||
|
||||
|
||||
def _distance_grid(
|
||||
lines: list[list[tuple[float, float]]], xs: np.ndarray, ys: np.ndarray, cell: float
|
||||
) -> np.ndarray:
|
||||
"""격자 칸마다 선까지 거리(m). 선이 격자에 안 닿으면 모두 무한."""
|
||||
marked = np.zeros((len(ys), len(xs)), dtype=bool)
|
||||
for line in lines:
|
||||
for (x0, y0), (x1, y1) in zip(line, line[1:]):
|
||||
count = max(2, int(math.hypot(x1 - x0, y1 - y0) / (cell * 0.5)) + 1)
|
||||
px = np.linspace(x0, x1, count)
|
||||
py = np.linspace(y0, y1, count)
|
||||
cols = np.rint((px - xs[0]) / cell).astype(int)
|
||||
rows = np.rint((py - ys[0]) / cell).astype(int)
|
||||
inside = (cols >= 0) & (cols < len(xs)) & (rows >= 0) & (rows < len(ys))
|
||||
marked[rows[inside], cols[inside]] = True
|
||||
if not marked.any():
|
||||
return np.full(marked.shape, np.inf)
|
||||
return ndimage.distance_transform_edt(~marked) * cell
|
||||
|
||||
|
||||
def _search_route(
|
||||
expected: list[tuple[float, float]],
|
||||
terrain: Terrain,
|
||||
streams: list[list[tuple[float, float]]],
|
||||
weights: dict[str, float],
|
||||
max_grade: float,
|
||||
min_radius_m: float,
|
||||
) -> list[tuple[float, float]]:
|
||||
"""복도 안 격자에서 진행 방향을 기억하는 Dijkstra — 시점 · 종점은 예상노선 양 끝 그대로.
|
||||
|
||||
상태 = (칸, 들어온 방향 16 가지). 한 걸음에 22.5° · 45° 까지만 꺾고, 꺾을 때마다 최소
|
||||
곡선반지름 원호 길이만큼 벌점을 매겨 곧게 달리게 한다. 16 방향(체스 나이트 걸음 포함)이라
|
||||
8 방향 격자가 급한 옆경사에서 등고선을 못 따라 계단처럼 꺾이던 것을 피한다.
|
||||
"""
|
||||
step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x))))
|
||||
xs, ys = terrain.x[::step], terrain.y[::step]
|
||||
z = np.asarray(terrain.z[::step, ::step], dtype=np.float64)
|
||||
cell = _cell_size(xs)
|
||||
d_expected = _distance_grid([expected], xs, ys, cell)
|
||||
d_stream = _distance_grid(streams, xs, ys, cell)
|
||||
corridor = (d_expected <= CORRIDOR_M) & np.asarray(terrain.valid[::step, ::step], dtype=bool)
|
||||
grad_y, grad_x = np.gradient(z, cell)
|
||||
rows, cols = z.shape
|
||||
index = np.full((rows, cols), -1, dtype=np.int64)
|
||||
cells = int(corridor.sum())
|
||||
index[corridor] = np.arange(cells)
|
||||
|
||||
# 방향마다 링크(칸 → 칸)와 세 목표 · 벌점(1 m 당)
|
||||
links: list[tuple[np.ndarray, np.ndarray, float, np.ndarray, np.ndarray]] = []
|
||||
for dr, dc in DIRECTIONS:
|
||||
first = (slice(max(0, -dr), rows - max(0, dr)), slice(max(0, -dc), cols - max(0, dc)))
|
||||
second = (slice(max(0, dr), rows - max(0, -dr)), slice(max(0, dc), cols - max(0, -dc)))
|
||||
a, b = index[first], index[second]
|
||||
ok = (a >= 0) & (b >= 0)
|
||||
norm = math.hypot(dr, dc)
|
||||
grade = np.abs(z[second][ok] - z[first][ok]) / (cell * norm)
|
||||
over = np.maximum(0.0, grade - max_grade)
|
||||
# 옆경사 = 진행 방향에 수직인 지반 기울기(두 칸 평균)
|
||||
cross = np.abs(
|
||||
(grad_x[first][ok] + grad_x[second][ok]) * -dr
|
||||
+ (grad_y[first][ok] + grad_y[second][ok]) * dc
|
||||
) / (2.0 * norm)
|
||||
# 토공 가늠(1 m 당 m³) — 옆경사 위 반절 · 반성 단면 W²·s/4 + 상한 넘는 기울기가 측점
|
||||
# 반 간격 동안 쌓는 높이 차 단면
|
||||
earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * STATION_M / 2.0
|
||||
offset = 0.5 * (d_expected[first][ok] + d_expected[second][ok])
|
||||
in_buffer = 0.5 * (
|
||||
(d_stream[first][ok] < STREAM_OFFSET_M).astype(float)
|
||||
+ (d_stream[second][ok] < STREAM_OFFSET_M)
|
||||
)
|
||||
penalty = GRADE_OVER_COST * over / max_grade + STREAM_COST * in_buffer
|
||||
links.append(
|
||||
(
|
||||
a[ok],
|
||||
b[ok],
|
||||
cell * norm,
|
||||
np.stack([offset, earth, (grade / max_grade) ** 2]),
|
||||
penalty,
|
||||
)
|
||||
)
|
||||
if not any(link[0].size for link in links):
|
||||
raise ValueError("예상노선 둘레에 탐색할 지형이 없습니다.")
|
||||
|
||||
# 세 목표 정규화 — 복도 안 링크 평균이 1 이 되게(길이 가중). 평균이 0 이면 그 목표는 0.
|
||||
lengths = np.concatenate([np.full(link[0].size, link[2]) for link in links])
|
||||
stacked = np.hstack([link[3] for link in links])
|
||||
scale = (stacked * lengths).sum(axis=1) / max(lengths.sum(), 1e-9)
|
||||
order = ("expected", "earthwork", "grade")
|
||||
share = np.array([weights[key] for key in order]) / max(sum(weights.values()), 1e-9)
|
||||
factor = np.divide(share, scale, out=np.zeros_like(share), where=scale > 1e-12)
|
||||
|
||||
count = len(DIRECTIONS)
|
||||
angles = [math.atan2(dr, dc) for dr, dc in DIRECTIONS]
|
||||
heads, tails, costs = [], [], []
|
||||
for out, (a, b, length, term, penalty) in enumerate(links):
|
||||
move = length * (LENGTH_COST + factor @ term + penalty)
|
||||
for turn in range(-MAX_TURN_STEPS, MAX_TURN_STEPS + 1):
|
||||
came = (out + turn) % count
|
||||
bend = abs((angles[out] - angles[came] + math.pi) % (2.0 * math.pi) - math.pi)
|
||||
heads.append(a * count + came)
|
||||
tails.append(b * count + out)
|
||||
costs.append(move + TURN_COST * min_radius_m * bend)
|
||||
graph = csr_matrix(
|
||||
(np.concatenate(costs), (np.concatenate(heads), np.concatenate(tails))),
|
||||
shape=(cells * count, cells * count),
|
||||
)
|
||||
cell_rows, cell_cols = np.nonzero(corridor)
|
||||
centres = np.column_stack([xs[cell_cols], ys[cell_rows]])
|
||||
|
||||
def nearest(point: tuple[float, float]) -> int:
|
||||
return int(np.argmin(np.hypot(centres[:, 0] - point[0], centres[:, 1] - point[1])))
|
||||
|
||||
start, end = nearest(expected[0]), nearest(expected[-1])
|
||||
distance, predecessors, _ = dijkstra(
|
||||
graph,
|
||||
indices=[start * count + d for d in range(count)],
|
||||
return_predecessors=True,
|
||||
min_only=True,
|
||||
)
|
||||
state = end * count + int(np.argmin(distance[end * count : (end + 1) * count]))
|
||||
if not np.isfinite(distance[state]):
|
||||
raise ValueError("복도 안에서 시점과 종점을 잇는 길을 찾지 못했습니다.")
|
||||
path = [state]
|
||||
while predecessors[path[-1]] >= 0:
|
||||
path.append(int(predecessors[path[-1]]))
|
||||
points = [
|
||||
(float(centres[s // count, 0]), float(centres[s // count, 1])) for s in reversed(path)
|
||||
]
|
||||
# 양 끝은 격자 칸 가운데가 아니라 예상노선 끝점 그대로 — 기점 · 종점은 옮기지 않는다.
|
||||
points[0], points[-1] = tuple(expected[0]), tuple(expected[-1])
|
||||
return points
|
||||
|
||||
|
||||
# ── 비교값 ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resample(vertices: list[tuple[float, float]], step: float) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""폴리라인을 `step` 간격 점으로 — (점 [n,2], 누가거리 [n])."""
|
||||
xy = np.asarray(vertices, dtype=np.float64)
|
||||
seg = np.hypot(*np.diff(xy, axis=0).T)
|
||||
chain = np.concatenate([[0.0], np.cumsum(seg)])
|
||||
total = float(chain[-1])
|
||||
at = np.append(np.arange(0.0, total, step), total) if total > 0 else np.array([0.0])
|
||||
return np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]), at
|
||||
|
||||
|
||||
def _runs(flags: np.ndarray) -> list[tuple[int, int]]:
|
||||
"""참이 이어진 구간들의 (첫, 끝) 번호."""
|
||||
runs: list[tuple[int, int]] = []
|
||||
start = None
|
||||
for i, flag in enumerate(flags):
|
||||
if flag and start is None:
|
||||
start = i
|
||||
elif not flag and start is not None:
|
||||
runs.append((start, i - 1))
|
||||
start = None
|
||||
if start is not None:
|
||||
runs.append((start, len(flags) - 1))
|
||||
return runs
|
||||
|
||||
|
||||
def grade_line(ground: np.ndarray, step: float, max_grade: float) -> np.ndarray:
|
||||
"""상한 안의 계획고 가늠 — 앞으로 · 뒤로 기울기를 누른 두 선의 평균(둘 다 상한 안 → 평균도)."""
|
||||
rise = max_grade * step
|
||||
forward, backward = ground.copy(), ground.copy()
|
||||
for i in range(1, len(ground)):
|
||||
forward[i] = min(max(ground[i], forward[i - 1] - rise), forward[i - 1] + rise)
|
||||
for i in range(len(ground) - 2, -1, -1):
|
||||
backward[i] = min(max(ground[i], backward[i + 1] - rise), backward[i + 1] + rise)
|
||||
return 0.5 * (forward + backward)
|
||||
|
||||
|
||||
def route_metrics(
|
||||
vertices: list[tuple[float, float]],
|
||||
expected: list[tuple[float, float]],
|
||||
terrain: Terrain,
|
||||
streams: list[list[tuple[float, float]]],
|
||||
*,
|
||||
max_grade: float,
|
||||
radius_runs: int,
|
||||
) -> dict[str, Any]:
|
||||
"""연장 · 최대/평균 종단기울기(지반) · 절성토량(가늠) · 기준 초과 구간 수 · 예상노선 이격."""
|
||||
sampler = RegularGridInterpolator(
|
||||
(terrain.y, terrain.x), terrain.z, bounds_error=False, fill_value=None
|
||||
)
|
||||
points, chain = _resample(vertices, SAMPLE_M)
|
||||
length = float(chain[-1])
|
||||
ground = sampler(points[:, ::-1])
|
||||
|
||||
# 종단기울기 — 측점(20 m) 사이 지반 기울기
|
||||
stations = np.append(np.arange(0.0, length, STATION_M), length)
|
||||
station_z = np.interp(stations, chain, ground)
|
||||
spans = np.diff(stations)
|
||||
keep = spans > 0.5
|
||||
grades = np.abs(np.diff(station_z))[keep] / spans[keep]
|
||||
max_grade_run = float(grades.max()) if grades.size else 0.0
|
||||
avg_grade = float((grades * spans[keep]).sum() / spans[keep].sum()) if grades.size else 0.0
|
||||
|
||||
# 절 · 성토(가늠) — 상한 안 계획고와 옆경사 단면을 폭 W 로 적분(비탈면 제외)
|
||||
grad_y, grad_x = np.gradient(terrain.z, _cell_size(terrain.y), _cell_size(terrain.x))
|
||||
gx = RegularGridInterpolator(
|
||||
(terrain.y, terrain.x), grad_x, bounds_error=False, fill_value=None
|
||||
)
|
||||
gy = RegularGridInterpolator(
|
||||
(terrain.y, terrain.x), grad_y, bounds_error=False, fill_value=None
|
||||
)
|
||||
tangent = np.gradient(points, axis=0)
|
||||
tangent /= np.maximum(np.hypot(tangent[:, 0], tangent[:, 1]), 1e-9)[:, None]
|
||||
lookup = points[:, ::-1]
|
||||
cross = np.abs(gx(lookup) * -tangent[:, 1] + gy(lookup) * tangent[:, 0])
|
||||
height = grade_line(ground, SAMPLE_M, max_grade) - ground
|
||||
across = np.linspace(-ROAD_WIDTH_M / 2.0, ROAD_WIDTH_M / 2.0, 9)
|
||||
depth = cross[:, None] * across[None, :] - height[:, None] # +: 지반이 노면 위 = 절토
|
||||
weight = np.gradient(chain) if len(chain) > 1 else np.zeros_like(chain)
|
||||
cut = float((np.maximum(depth, 0.0).mean(axis=1) * ROAD_WIDTH_M * weight).sum())
|
||||
fill = float((np.maximum(-depth, 0.0).mean(axis=1) * ROAD_WIDTH_M * weight).sum())
|
||||
|
||||
# 예상노선 이격
|
||||
route_points = shapely.points(points)
|
||||
offsets = shapely.distance(route_points, LineString(expected))
|
||||
|
||||
# 계류 이격 — 버퍼 안 구간 중 봐줄 길이를 넘는 것. 봐줌 = 건너기(계류에 닿음) 한 번의
|
||||
# 버퍼 안 길이 + 기점 · 종점이 버퍼 안이면 45° 로 빠져나가는 길이(끝점은 옮길 수 없음)
|
||||
stream_runs = 0
|
||||
if streams:
|
||||
near = shapely.distance(route_points, MultiLineString(streams))
|
||||
for first, last in _runs(near < STREAM_OFFSET_M):
|
||||
ends = int(first == 0) + int(last == len(near) - 1)
|
||||
crosses = float(near[first : last + 1].min()) <= SAMPLE_M
|
||||
allowance = ends * STREAM_OFFSET_M * math.sqrt(2.0) + crosses * STREAM_CROSS_MAX_M
|
||||
if chain[last] - chain[first] > allowance:
|
||||
stream_runs += 1
|
||||
grade_runs = len(_runs(grades > max_grade))
|
||||
return {
|
||||
"length_m": round(length, 2),
|
||||
"max_grade_pct": round(max_grade_run * 100.0, 2),
|
||||
"avg_grade_pct": round(avg_grade * 100.0, 2),
|
||||
"cut_m3": round(cut, 1),
|
||||
"fill_m3": round(fill, 1),
|
||||
"over_limit": {
|
||||
"grade": grade_runs,
|
||||
"radius": int(radius_runs),
|
||||
"stream": stream_runs,
|
||||
"total": grade_runs + int(radius_runs) + stream_runs,
|
||||
},
|
||||
"offset_avg_m": round(float(offsets.mean()), 2),
|
||||
"offset_max_m": round(float(offsets.max()), 2),
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
확정 경로 폴리라인과 표고 sampler로 CAD 인계 가능한 종단(longitudinal)·횡단
|
||||
(cross) 데이터를 생성한다. BP(0m)부터 station_interval 간격 측점을 만들고
|
||||
각 측점에서 경로 접선의 좌우 방향으로 횡단을 샘플링한다.
|
||||
|
||||
⚠ 파이썬·TS 짝 — `_interpolate_xy`·`_tangent_at` ↔ `B05_Profile_UI_RouteEdit_Frame.ts`
|
||||
(`stationFrameAt`). 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py`.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
|
||||
POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
|
||||
POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
||||
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(파일 안 씀)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -35,7 +36,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter
|
||||
@@ -84,6 +85,21 @@ class RouteVertexInput(BaseModel):
|
||||
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
|
||||
|
||||
|
||||
class RouteInitialWeights(BaseModel):
|
||||
"""비중 갈래의 세 목표 비중 — 합 100(PLAN 20장 · 기본 50/25/25)."""
|
||||
|
||||
expected: float = Field(50.0, ge=0, le=100)
|
||||
earthwork: float = Field(25.0, ge=0, le=100)
|
||||
grade: float = Field(25.0, ge=0, le=100)
|
||||
|
||||
|
||||
class RouteInitialRequest(BaseModel):
|
||||
"""초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted."""
|
||||
|
||||
mode: Literal["follow", "earthwork", "gentle", "weighted"] = "follow"
|
||||
weights: RouteInitialWeights | None = None
|
||||
|
||||
|
||||
class RouteReplanRequest(BaseModel):
|
||||
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
|
||||
|
||||
@@ -155,14 +171,25 @@ async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]:
|
||||
있는 것은 `road_type` 뿐). 못 읽으면 가장 완화된 조건으로 떨어진다 — 막지 않고 위반
|
||||
표시만 하기 때문이다.
|
||||
"""
|
||||
import aiomysql
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import (
|
||||
legal_plan_radius_min_m,
|
||||
plan_curve_length_limit_m,
|
||||
plan_radius_limit_m,
|
||||
resolve_design_speed,
|
||||
)
|
||||
|
||||
grade_class, design_speed, terrain = await _road_settings(project_id)
|
||||
return (
|
||||
legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain),
|
||||
plan_radius_limit_m(grade_class, design_speed, terrain),
|
||||
plan_curve_length_limit_m(grade_class),
|
||||
)
|
||||
|
||||
|
||||
async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]:
|
||||
"""(임도 종류, 설계속도, 지형) — 못 읽으면 가장 완화된 조건(작업 · 특수)."""
|
||||
import aiomysql
|
||||
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
|
||||
grade_class, design_speed, terrain = "work", None, "special"
|
||||
@@ -189,11 +216,7 @@ async def _plan_criteria(project_id: UUID) -> tuple[float, float, float]:
|
||||
terrain = str(params["terrain_type"])
|
||||
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
|
||||
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
|
||||
return (
|
||||
legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain),
|
||||
plan_radius_limit_m(grade_class, design_speed, terrain),
|
||||
plan_curve_length_limit_m(grade_class),
|
||||
)
|
||||
return grade_class, design_speed, terrain
|
||||
|
||||
|
||||
def _nodes_path(path: Path) -> Path:
|
||||
@@ -564,3 +587,73 @@ async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
status_code=409, content={"status": "error", "message": result["error"]}
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **result}
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/initial", response_model=None)
|
||||
async def initial_route(
|
||||
project_id: UUID, request: RouteInitialRequest
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""초기 계획노선 갈래 하나를 만들어 비교값과 함께 돌려준다(PLAN 20장).
|
||||
|
||||
**파일을 쓰지 않는다** — 화면 캐시용. 적용은 받은 `nodes` 로 `POST /route/replan`([확인]).
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||
generate_initial_route,
|
||||
load_terrain_and_streams,
|
||||
)
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
|
||||
weights = request.weights.model_dump() if request.weights else None
|
||||
if request.mode == "weighted" and weights and abs(sum(weights.values()) - 100.0) > 0.5:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "비중 세 값의 합은 100 이어야 합니다."},
|
||||
)
|
||||
paths = await _project_paths(project_id)
|
||||
if paths is None:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
project_root, _ = paths
|
||||
expected = await asyncio.to_thread(_vertices_of, expected_route_csv_path(project_root))
|
||||
if not expected:
|
||||
expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root))
|
||||
if len(expected) < 2:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "예상노선이 없어 초기노선을 만들 수 없습니다."},
|
||||
)
|
||||
radius_m, _, _ = await _plan_criteria(project_id)
|
||||
grade_class, design_speed, terrain_type = await _road_settings(project_id)
|
||||
# 비포장 기준 — 포장 예외(18 %)는 포장 여부가 정해진 뒤 종단 설계가 따진다(사용자 확정 대기).
|
||||
max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
terrain, streams = await asyncio.to_thread(
|
||||
load_terrain_and_streams, project_root, selection
|
||||
)
|
||||
result = await asyncio.to_thread(
|
||||
functools.partial(
|
||||
generate_initial_route,
|
||||
[(x, y) for x, y in expected],
|
||||
terrain,
|
||||
streams,
|
||||
mode=request.mode,
|
||||
weights=weights,
|
||||
max_grade=max_grade,
|
||||
min_radius_m=radius_m,
|
||||
)
|
||||
)
|
||||
except (FileNotFoundError, ValueError) as error:
|
||||
return JSONResponse(status_code=409, content={"status": "error", "message": str(error)})
|
||||
logger.info(
|
||||
"초기 계획노선 %s: project_id=%s %.1fs 연장 %.0fm 이격 평균 %.1fm",
|
||||
request.mode,
|
||||
project_id,
|
||||
time.perf_counter() - started,
|
||||
result["metrics"]["length_m"],
|
||||
result["metrics"]["offset_avg_m"],
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **result}
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
* 받는다 — 저장 키는 어느 쪽이든 body_width_m·body_height_m 숫자 그대로다.
|
||||
* 날개벽은 유입·유출이 같은 조각(createWingFields)을 쓰고 설치 있음·짧은쪽 높이
|
||||
* 1m·길이 2m·각도 45°가 기본이며, 설치를 "없음"으로 바꾸면 제원 칸이 접힌다.
|
||||
*
|
||||
* 짝: 시설 속성 붙이기 `carryIndices` ↔ 서버 `common_util/common_util_drainage_pipes.py`
|
||||
* `carry_indices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry.
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
@@ -75,8 +78,31 @@ function hasExtras(attributes: FacilityAttributes): boolean {
|
||||
return !!attributes.options && Object.keys(attributes.options).length > 0;
|
||||
}
|
||||
|
||||
/** 관 이동·재계산 왕복에서 시설 정보를 잃지 않게 chainage 최근접으로 되붙이는 보관소.
|
||||
* 백엔드 `carry_facility_attributes`와 같은 기준(정확 일치 → 최근접)이다. */
|
||||
/** 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭.
|
||||
* 서버 `common_util_drainage_pipes.CARRY_TOLERANCE_M` 와 짝. */
|
||||
export const CARRY_TOLERANCE_M = 0.05;
|
||||
|
||||
/** 관 측점마다 속성을 가져올 원본 번호(못 붙이면 null) — 허용 오차 안 가장 가까운 원본 ·
|
||||
* 한 원본은 한 번만. 서버 `carry_indices` 와 짝(거울 시험 test_b05_facility_carry). */
|
||||
export function carryIndices(
|
||||
targets: readonly number[],
|
||||
sources: readonly number[],
|
||||
): Array<number | null> {
|
||||
const free = new Set(sources.keys());
|
||||
return targets.map((target) => {
|
||||
let best: number | null = null;
|
||||
for (const index of free) {
|
||||
const gap = Math.abs(sources[index] - target);
|
||||
if (gap <= CARRY_TOLERANCE_M && (best === null || gap < Math.abs(sources[best] - target)))
|
||||
best = index;
|
||||
}
|
||||
if (best !== null) free.delete(best);
|
||||
return best;
|
||||
});
|
||||
}
|
||||
|
||||
/** 재계산 왕복에서 시설 정보를 잃지 않게 측점으로 되붙이는 보관소 — 붙이기는 `carryIndices`
|
||||
* (서버 `carry_facility_attributes` 와 같은 규칙) · 관을 옮기면 `move` · `follow` 가 속성을 옮김. */
|
||||
export function createFacilityStore() {
|
||||
let entries: Array<{ chainage: number; attributes: FacilityAttributes }> = [];
|
||||
|
||||
@@ -114,22 +140,29 @@ export function createFacilityStore() {
|
||||
const hit = entries.find((entry) => Math.abs(entry.chainage - chainageM) <= 0.005);
|
||||
return hit ? hit.attributes : null;
|
||||
},
|
||||
/** 요청 목록에 시설 정보를 되붙인다. 관이 끌려 이동한 뒤에도(정확 일치 없음)
|
||||
* 가장 가까운 엔트리를 한 번씩만 소비해 승계한다. */
|
||||
/** 옮긴 관의 속성을 새 측점으로 옮긴다(지도에서 끌기). */
|
||||
move(fromM: number, toM: number): void {
|
||||
const hit = entries.find((entry) => Math.abs(entry.chainage - fromM) <= 0.005);
|
||||
if (hit) hit.chainage = toM;
|
||||
},
|
||||
/** 관 목록이 통째로 바뀔 때(되돌리기 · 구조물 폼) — 사라진 관 하나 · 새 관 하나면 옮긴 것으로
|
||||
* 보고 속성을 따라 옮김. 둘 이상이 한꺼번에 바뀌면 옮기지 않음(제자리 관만 속성 유지). */
|
||||
follow(before: readonly number[], after: readonly number[]): void {
|
||||
const near = (list: readonly number[], value: number): boolean =>
|
||||
list.some((other) => Math.abs(other - value) <= CARRY_TOLERANCE_M);
|
||||
const gone = before.filter((value) => !near(after, value));
|
||||
const added = after.filter((value) => !near(before, value));
|
||||
if (gone.length === 1 && added.length === 1) this.move(gone[0], added[0]);
|
||||
},
|
||||
/** 요청 목록에 시설 정보를 되붙인다 — 허용 오차 안의 원본만 · 못 붙인 관은 속성 없음. */
|
||||
attach(points: ReadonlyArray<{ chainage_m: number; source: PipeSource }>): DetailPipeInput[] {
|
||||
const pool = [...entries];
|
||||
return points.map((point) => {
|
||||
if (!pool.length) return { ...point };
|
||||
let best = 0;
|
||||
for (let index = 1; index < pool.length; index += 1) {
|
||||
if (
|
||||
Math.abs(pool[index].chainage - point.chainage_m) <
|
||||
Math.abs(pool[best].chainage - point.chainage_m)
|
||||
)
|
||||
best = index;
|
||||
}
|
||||
const [entry] = pool.splice(best, 1);
|
||||
return { ...point, ...entry.attributes };
|
||||
const picks = carryIndices(
|
||||
points.map((point) => point.chainage_m),
|
||||
entries.map((entry) => entry.chainage),
|
||||
);
|
||||
return points.map((point, index) => {
|
||||
const pick = picks[index];
|
||||
return pick === null ? { ...point } : { ...point, ...entries[pick].attributes };
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -143,6 +143,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
rememberPipes();
|
||||
void analyze();
|
||||
},
|
||||
(fromM, toM) => facilityStore.move(fromM, toM),
|
||||
);
|
||||
// 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다
|
||||
// (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시).
|
||||
@@ -588,8 +589,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
selectBasin(picked ? picked.index : null, false);
|
||||
},
|
||||
setPipeChainages(chainages) {
|
||||
const before = pipeEditor.pipes().map((pipe) => pipe.chainage_m);
|
||||
const next = reconcilePipes(pipeEditor.pipes(), chainages);
|
||||
if (!next) return; // 같은 목록 — 되돌아온 것이므로 여기서 끊는다
|
||||
// 되돌리기 · 구조물 폼으로 옮겨진 관도 속성을 데려감
|
||||
facilityStore.follow(
|
||||
before,
|
||||
next.map((pipe) => pipe.chainage_m),
|
||||
);
|
||||
pipeEditor.setPipes(next);
|
||||
void analyze();
|
||||
},
|
||||
|
||||
@@ -56,10 +56,12 @@ export interface PipeEditor {
|
||||
}
|
||||
|
||||
/** `onChange`는 화면 갱신용, `onCommit`은 배치가 실제로 바뀌었을 때(추가·삭제·이동 완료).
|
||||
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시). */
|
||||
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시).
|
||||
* `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */
|
||||
export function createPipeEditor(
|
||||
onChange: () => void,
|
||||
onCommit: () => void = () => {},
|
||||
onMove: (fromM: number, toM: number) => void = () => {},
|
||||
): PipeEditor {
|
||||
let meta: VWorldMeta | null = null;
|
||||
let route: Array<{ x: number; y: number; chainage: number }> = [];
|
||||
@@ -68,6 +70,7 @@ export function createPipeEditor(
|
||||
let selectedIndex: number | null = null;
|
||||
let draggingIndex: number | null = null;
|
||||
let dragMoved = false;
|
||||
let dragFrom = 0;
|
||||
/** 마커를 잡은 화면 좌표 — 이 자리에서 DRAG_START_PX를 벗어나야 이동이 시작된다
|
||||
* (2026-08-19 사용자 보고: 고르려다 관이 옮겨지는 민감도 문제). */
|
||||
let dragDownAt: { x: number; y: number } | null = null;
|
||||
@@ -250,6 +253,7 @@ export function createPipeEditor(
|
||||
if (Math.hypot(screenX - screen.x, screenY - screen.y) <= HIT_RADIUS_PX) {
|
||||
selectedIndex = i;
|
||||
draggingIndex = i;
|
||||
dragFrom = pipeList[i].chainage_m;
|
||||
dragDownAt = { x: screenX, y: screenY };
|
||||
onChange();
|
||||
return true;
|
||||
@@ -276,9 +280,11 @@ export function createPipeEditor(
|
||||
},
|
||||
handleUp() {
|
||||
if (draggingIndex === null) return false;
|
||||
const dragged = pipeList[draggingIndex];
|
||||
draggingIndex = null;
|
||||
dragDownAt = null;
|
||||
if (dragMoved) {
|
||||
onMove(dragFrom, dragged.chainage_m);
|
||||
sortPipes();
|
||||
onChange();
|
||||
// 관을 옮겼으면 담당 구간이 달라진다 — 세부유역을 즉시 다시 나눈다.
|
||||
|
||||
@@ -171,15 +171,11 @@ export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
|
||||
);
|
||||
// B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다
|
||||
// (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다.
|
||||
// ⚠ 이동 키까지 넘긴다(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야
|
||||
// 한다). 종전에는 구간값 키만 넘겨, 옛 세션에 남은 관 이동 예약이 B05 [임시저장]에서는
|
||||
// 조용히 빠졌다. 관 목록 자체는 이제 두 페이지가 같은 스냅샷(`pipes`)을 쓴다.
|
||||
// 관 위치는 두 페이지가 같은 스냅샷(`pipes`)으로만 옮김 — 옛 이동 예약은 안 보냄.
|
||||
if (latest?.route?.id != null) {
|
||||
const routeId = latest.route.id;
|
||||
await flushCulvertOptions(
|
||||
projectId,
|
||||
stateKey("culvertopt", projectId, routeId),
|
||||
stateKey("culvertmove", projectId, routeId),
|
||||
stateKey("culvertopt", projectId, latest.route.id),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
// B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* 쓰고, **편집 중에는 아무 계산도 나가지 않는다**.
|
||||
*
|
||||
* 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침).
|
||||
* [예상노선으로]는 수정본을 버리고 원본으로 되돌린다(서버가 파일을 지우고 같은 재계산).
|
||||
* [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`).
|
||||
*
|
||||
* 그림은 배수유역도와 같은 지도 도구(`B04_PreProcess_UI_MapRender`)를 쓴다 — 등고선 도엽은
|
||||
* 위경도, 노선은 사업지 좌표계(m)지만 두 변환기가 같은 정규화 공간을 본다.
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
} from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render";
|
||||
import {
|
||||
bindNodeSplice,
|
||||
bindRouteEditNavigation,
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
@@ -59,6 +60,7 @@ import {
|
||||
viewPxPerMeter,
|
||||
} from "./B05_Profile_UI_RouteEdit_View";
|
||||
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial";
|
||||
import {
|
||||
applyArcLocks,
|
||||
applyCurveLimits,
|
||||
@@ -93,7 +95,7 @@ const CONTOUR_LEVEL_BUDGET = 1200;
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
|
||||
/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
|
||||
export interface RouteEditOptions {
|
||||
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
|
||||
stationIntervalM?: number;
|
||||
@@ -163,13 +165,13 @@ export async function openRouteEditModal(
|
||||
min_radius_m: minRadiusM || 12,
|
||||
station_interval_m: stationIntervalM,
|
||||
}),
|
||||
onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다.
|
||||
});
|
||||
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
/** 구간 재기 — 계획·예상 노선 위 두 점. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = createMeasureTool({
|
||||
projectId,
|
||||
stationIntervalM,
|
||||
line: () => (plannedLine.length ? plannedLine : planned),
|
||||
// 아래에 선언된 것을 감싸 넘긴다 — 부르는 시점은 늘 그 뒤다.
|
||||
lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }),
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
isClosed: () => closed,
|
||||
onChange: () => {
|
||||
@@ -227,8 +229,16 @@ export async function openRouteEditModal(
|
||||
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
||||
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close);
|
||||
/** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */
|
||||
const initial = createInitialPanel({
|
||||
overlay,
|
||||
projectId,
|
||||
planMinRadiusM: () => minRadiusM,
|
||||
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
|
||||
});
|
||||
const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다.
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel);
|
||||
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel);
|
||||
// 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게.
|
||||
|
||||
function resize(): void {
|
||||
@@ -277,6 +287,7 @@ export async function openRouteEditModal(
|
||||
dragging: dragNode >= 0 || dragHandle !== null,
|
||||
}),
|
||||
measure: measure.marks(),
|
||||
crossWidth: crossPreview.watched(),
|
||||
expected,
|
||||
plannedLine,
|
||||
planned,
|
||||
@@ -411,19 +422,16 @@ export async function openRouteEditModal(
|
||||
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
|
||||
}
|
||||
|
||||
/** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */
|
||||
function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void {
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
|
||||
applyEdit(message, record);
|
||||
}
|
||||
|
||||
const historyControls = bindHistoryControls({
|
||||
overlay,
|
||||
getHistory: () => history,
|
||||
restore: (snapshot, message) => {
|
||||
planned = snapshot.planned;
|
||||
curveOn = snapshot.curveOn;
|
||||
curveRadius = snapshot.curveRadius;
|
||||
curveLock = snapshot.curveLock;
|
||||
curveArc = snapshot.curveArc;
|
||||
apexLock = snapshot.apexLock;
|
||||
picked = snapshot.picked;
|
||||
applyEdit(message, false); // 되살리는 것은 새 걸음이 아니다.
|
||||
},
|
||||
restore: (snapshot, message) => restoreSnapshot(snapshot, message, false),
|
||||
});
|
||||
|
||||
// ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
@@ -591,28 +599,22 @@ export async function openRouteEditModal(
|
||||
canvas.addEventListener("pointerup", endDrag);
|
||||
canvas.addEventListener("pointercancel", endDrag);
|
||||
|
||||
canvas.addEventListener("dblclick", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
const segment = segmentAt(px, py);
|
||||
if (segment < 0) return;
|
||||
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
|
||||
picked = segment + 1;
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
});
|
||||
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const index = nodeAt(...rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top));
|
||||
if (index < 0) return;
|
||||
if (planned.length <= 2) {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
spliceNode(snapshotNow(), index);
|
||||
picked = -1;
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
bindNodeSplice({
|
||||
canvas,
|
||||
unrotate: rotation.unrotate,
|
||||
segmentAt,
|
||||
nodeAt,
|
||||
insert: (segment, px, py) => {
|
||||
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
|
||||
picked = segment + 1;
|
||||
applyEdit("새 노드를 넣었습니다(직선 추가).");
|
||||
},
|
||||
remove: (index) => {
|
||||
if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
spliceNode(snapshotNow(), index);
|
||||
picked = -1;
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
},
|
||||
});
|
||||
|
||||
// 확대·이동은 배수유역도와 같은 동작으로 — 휠은 당기면 확대, 팬은 가운데 버튼 전용.
|
||||
@@ -633,6 +635,8 @@ export async function openRouteEditModal(
|
||||
busy,
|
||||
projectId,
|
||||
nodes: () => ({ planned, curveOn, curveRadius }),
|
||||
changed: () => history?.isDirty() ?? false,
|
||||
initial,
|
||||
close,
|
||||
onApplied,
|
||||
});
|
||||
@@ -654,18 +658,13 @@ export async function openRouteEditModal(
|
||||
limitArcM = plan.limit_curve_length_m ?? 0;
|
||||
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
||||
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
||||
planned = flat.planned;
|
||||
nodeInfo = flat.nodes;
|
||||
curveInfo = flat.curves;
|
||||
curveOn = flat.curveOn;
|
||||
curveRadius = flat.curveRadius;
|
||||
curveLock = flat.curveLock;
|
||||
curveArc = flat.curveArc;
|
||||
apexLock = flat.apexLock;
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock } = flat);
|
||||
({ nodes: nodeInfo, curves: curveInfo } = flat);
|
||||
picked = -1;
|
||||
// 여기가 [초기화]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
historyControls.sync();
|
||||
initial.ready(snapshotNow(), plan.edited);
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Apply.ts
|
||||
* 계획노선 편집 모달의 **[확인]·[예상노선으로]** — 무거운 재계산과 대기 표시.
|
||||
* 계획노선 편집 모달의 **[확인]** — 무거운 재계산과 대기 표시.
|
||||
*
|
||||
* 다시 계산은 **노선이 바뀐 뒤 [확인]에서만** 돈다(계획서 20 ④). 노선이 창을 연 그대로면
|
||||
* 계산 없이 닫는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`).
|
||||
*
|
||||
* 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는
|
||||
* 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이
|
||||
@@ -9,7 +12,7 @@
|
||||
|
||||
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
import { replanRoute } from "./B05_Profile_Api_Replan";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
@@ -20,12 +23,16 @@ export interface RouteApplyParams {
|
||||
projectId: string;
|
||||
/** 지금 편집값 — 누른 순간에 읽는다. */
|
||||
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
|
||||
/** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
|
||||
changed: () => boolean;
|
||||
/** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
||||
initial: { current: () => unknown; keep: () => void };
|
||||
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
|
||||
close: () => void;
|
||||
onApplied: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
/** [확인]·[예상노선으로]를 붙인다. 리스너는 모달과 수명이 같다. */
|
||||
/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */
|
||||
export function bindRouteApply(params: RouteApplyParams): void {
|
||||
const { overlay, busy, projectId } = params;
|
||||
|
||||
@@ -46,6 +53,7 @@ export function bindRouteApply(params: RouteApplyParams): void {
|
||||
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
|
||||
clearDrafts(projectId);
|
||||
clearResults(projectId);
|
||||
params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다.
|
||||
showToast("노선을 다시 계산했습니다.", "success");
|
||||
params.close();
|
||||
await params.onApplied();
|
||||
@@ -63,6 +71,11 @@ export function bindRouteApply(params: RouteApplyParams): void {
|
||||
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
return;
|
||||
}
|
||||
if (!params.changed()) {
|
||||
showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info");
|
||||
params.close();
|
||||
return;
|
||||
}
|
||||
void runHeavy("계획노선 반영", () =>
|
||||
replanRoute(
|
||||
projectId,
|
||||
@@ -72,10 +85,8 @@ export function bindRouteApply(params: RouteApplyParams): void {
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
{ initial: params.initial.current() },
|
||||
),
|
||||
);
|
||||
});
|
||||
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
|
||||
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -52,8 +52,9 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
|
||||
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
|
||||
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset">예상노선으로</button>
|
||||
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>연 상태로</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
|
||||
title="마지막에 적용한 초기값으로 되돌립니다 — 다시 계산은 [확인]에서">초기화</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-act="apply">확인</button>
|
||||
</span>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_
|
||||
import {
|
||||
buildCrossSvg,
|
||||
CROSS_PLOT_PAD,
|
||||
designHalfWidths,
|
||||
summarizeCross,
|
||||
} from "./B05_Profile_UI_RouteEdit_Cross_Draw";
|
||||
import { formatStation } from "./B05_Profile_Util_Station";
|
||||
@@ -35,6 +36,15 @@ export interface CrossPreviewParams {
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
};
|
||||
/** 위 판에 횡단이 떴다 · 비었다 — 평면도의 반폭 선을 다시 그리라는 신호. */
|
||||
onShown?: () => void;
|
||||
}
|
||||
|
||||
/** 위 판에 떠 있는 측점과 그 좌·우 설계 반폭(m) — 평면도가 반폭 선을 그리는 재료. */
|
||||
export interface CrossWatch {
|
||||
chainageM: number;
|
||||
leftM: number;
|
||||
rightM: number;
|
||||
}
|
||||
|
||||
export interface CrossPreviewWindow {
|
||||
@@ -42,6 +52,8 @@ export interface CrossPreviewWindow {
|
||||
open: (chainageM: number) => Promise<void>;
|
||||
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
|
||||
refresh: () => Promise<void>;
|
||||
/** 위 판에 떠 있는 측점과 반폭. 떠 있지 않으면 null. */
|
||||
watched: () => CrossWatch | null;
|
||||
}
|
||||
|
||||
interface CrossPane {
|
||||
@@ -136,6 +148,7 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
shown = null;
|
||||
current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다.");
|
||||
}
|
||||
params.onShown?.();
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -150,5 +163,9 @@ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWind
|
||||
if (watching === null) return;
|
||||
await load(watching, true);
|
||||
},
|
||||
// 자리는 보던 측점 그대로 — 노선을 끄는 동안 평면도가 그 누가거리로 다시 짚는다.
|
||||
// 반폭은 마지막으로 셈한 횡단 것이고, 노드를 놓으면 `refresh` 가 새 값으로 바꾼다.
|
||||
watched: () =>
|
||||
watching === null || !shown ? null : { chainageM: watching, ...designHalfWidths(shown) },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +53,18 @@ export function summarizeCross(preview: CrossPreviewResponse): string {
|
||||
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`;
|
||||
}
|
||||
|
||||
/** 좌·우 설계 반폭(m) — 사면이 원지반과 만나는 자리까지. 못 만난 측은 기본 반폭(샘플 끝). */
|
||||
export function designHalfWidths(preview: CrossPreviewResponse): { leftM: number; rightM: number } {
|
||||
const offsets = preview.samples
|
||||
.map((sample) => Number(sample.offset_m))
|
||||
.filter((offset) => Number.isFinite(offset));
|
||||
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
|
||||
return {
|
||||
leftM: toes.left ?? Math.max(0, ...offsets), // +offset = 좌
|
||||
rightM: Math.abs(toes.right ?? Math.min(0, ...offsets)), // −offset = 우
|
||||
};
|
||||
}
|
||||
|
||||
/** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */
|
||||
function asSection(preview: CrossPreviewResponse): CrossSection {
|
||||
return { samples: preview.samples, design: preview.design } as unknown as CrossSection;
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Frame.ts
|
||||
* 측점 한 자리의 **평면 틀**(중심점 · 좌측 축) — 노선을 끄는 동안 브라우저가 바로 셈한다.
|
||||
*
|
||||
* ⚠ 파이썬·TS 짝 — `B05_Profile_Engine_Sections_Core.py` 의 `_interpolate_xy`·`_tangent_at`
|
||||
* (`generate_sections` 가 측점 자리·좌측 축을 내는 식). 한쪽을 고치면 다른 쪽도 고치고
|
||||
* 거울 시험 `resources/tester/test_b05_routeedit_frame_mirror.py` 를 돌림.
|
||||
* ========================================================================== */
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 접선을 재는 앞뒤 거리(m) — 파이썬 `max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5)` 와 같은 값. */
|
||||
export const SECTION_LONG_SAMPLE_INTERVAL_M = 1.0;
|
||||
|
||||
export interface StationFrame {
|
||||
origin: Vertex;
|
||||
/** 좌측(+offset) 단위 벡터 — 우측은 반대 방향. */
|
||||
left: Vertex;
|
||||
}
|
||||
|
||||
/** 길이 0 인 선분을 걸러 낸 정점과 누가거리 — 파이썬 `_clean_polyline` 과 같은 거름. */
|
||||
function cleanPolyline(line: ReadonlyArray<Vertex>): { points: Vertex[]; chainage: number[] } {
|
||||
const points: Vertex[] = [];
|
||||
const chainage: number[] = [];
|
||||
line.forEach((vertex, index) => {
|
||||
if (index > 0) {
|
||||
const last = points[points.length - 1];
|
||||
const step = Math.hypot(vertex[0] - last[0], vertex[1] - last[1]);
|
||||
if (step <= 1e-8) return;
|
||||
chainage.push(chainage[chainage.length - 1] + step);
|
||||
} else {
|
||||
chainage.push(0);
|
||||
}
|
||||
points.push([vertex[0], vertex[1]]);
|
||||
});
|
||||
return { points, chainage };
|
||||
}
|
||||
|
||||
/** `np.interp` 와 같은 한 점 보간 — 범위 밖은 끝값에 붙는다. */
|
||||
function interpolate(points: Vertex[], chainage: number[], target: number): Vertex {
|
||||
const last = chainage.length - 1;
|
||||
if (target <= chainage[0]) return points[0];
|
||||
if (target >= chainage[last]) return points[last];
|
||||
let index = 1;
|
||||
while (chainage[index] < target) index += 1;
|
||||
const span = chainage[index] - chainage[index - 1];
|
||||
const ratio = span > 0 ? (target - chainage[index - 1]) / span : 0;
|
||||
return [
|
||||
points[index - 1][0] + (points[index][0] - points[index - 1][0]) * ratio,
|
||||
points[index - 1][1] + (points[index][1] - points[index - 1][1]) * ratio,
|
||||
];
|
||||
}
|
||||
|
||||
/** 누가거리 `chainageM` 측점의 중심점과 좌측 축. 노선이 없거나 방향을 못 재면 null.
|
||||
* 노선보다 긴 누가거리는 끝점에 붙인다 — 노선이 짧아지면 서버도 마지막 측점을 준다. */
|
||||
export function stationFrameAt(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
target: number,
|
||||
probeM: number = Math.max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5),
|
||||
): StationFrame | null {
|
||||
const { points, chainage } = cleanPolyline(line);
|
||||
if (points.length < 2) return null;
|
||||
const total = chainage[chainage.length - 1];
|
||||
const chainageM = Math.min(Math.max(target, 0), total);
|
||||
let before = Math.max(0, chainageM - probeM);
|
||||
let after = Math.min(total, chainageM + probeM);
|
||||
if (after - before <= 1e-9) {
|
||||
before = Math.max(0, chainageM - 1e-3);
|
||||
after = Math.min(total, chainageM + 1e-3);
|
||||
}
|
||||
const from = interpolate(points, chainage, before);
|
||||
const to = interpolate(points, chainage, after);
|
||||
const length = Math.hypot(to[0] - from[0], to[1] - from[1]);
|
||||
if (length <= 1e-9) return null;
|
||||
const tangent: Vertex = [(to[0] - from[0]) / length, (to[1] - from[1]) / length];
|
||||
return { origin: interpolate(points, chainage, chainageM), left: [-tangent[1], tangent[0]] };
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Initial.ts
|
||||
* 계획노선 편집 창의 **초기노선 갈래**(계획서 20) — 로직 단추 셋 · 비중 슬라이더 · [초기화]
|
||||
* · 「현재 초기값」 · 결과 비교값 · 공통 제약 기준값.
|
||||
*
|
||||
* 로직 단추를 누르면 서버가 노선을 새로 셈하고(`/route/initial`), 받은 노드를 **편집값에
|
||||
* 싣는다**(되돌리기 한 걸음). 그 결과가 새 초기값이라 캐시(`routeedit-initial`)에 적고,
|
||||
* [초기화]는 서버를 다시 부르지 않고 그 초기값으로 돌아간다. 종단 · 배수유역 · 횡단 다시
|
||||
* 계산은 여기서 안 돈다 — 노선이 바뀐 뒤 [확인]에서만(`_Apply`).
|
||||
* 비교값 · 기준값은 서버가 준 것을 보이기만 한다(계산 자리 = 서버 단독).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import {
|
||||
fetchRouteInitial,
|
||||
ROUTE_INITIAL_MISSING,
|
||||
type RouteInitialLogic,
|
||||
type RouteInitialWeights,
|
||||
} from "./B05_Profile_Api_RouteInitial";
|
||||
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
import {
|
||||
choiceLabel,
|
||||
criteriaLine,
|
||||
DEFAULT_WEIGHTS,
|
||||
metricsLine,
|
||||
normalizeRouteInitial,
|
||||
readInitialChoice,
|
||||
rebalanceWeights,
|
||||
WEIGHT_KEYS,
|
||||
writeInitialChoice,
|
||||
type InitialChoice,
|
||||
type WeightKey,
|
||||
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
|
||||
|
||||
const WEIGHT_NAME: Record<WeightKey, string> = {
|
||||
expected: "예상",
|
||||
earthwork: "토공",
|
||||
slope: "기울기",
|
||||
};
|
||||
|
||||
export interface InitialPanelParams {
|
||||
overlay: HTMLElement;
|
||||
projectId: string;
|
||||
/** `/route/plan` 이 준 최소 R — 서버 갈래 기준값이 오기 전 보일 값. */
|
||||
planMinRadiusM: () => number;
|
||||
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
|
||||
apply: (snapshot: RouteEditSnapshot, message: string) => void;
|
||||
}
|
||||
|
||||
export interface InitialPanel {
|
||||
/** 노선을 읽었다 — 창을 연 편집값과 「고친 노선인가」를 받아 단추를 연다. */
|
||||
ready: (opening: RouteEditSnapshot, edited: boolean) => void;
|
||||
/** [확인]에 실어 보낼 지금 초기값. */
|
||||
current: () => { logic: RouteInitialLogic; weights: RouteInitialWeights | null };
|
||||
/** [취소] — 이 창에서 바꾼 초기값을 창을 열 때 것으로 되돌린다. */
|
||||
cancel: () => void;
|
||||
/** [확인] 뒤 결과 캐시를 비웠다 — 초기값은 새 노선의 출발점이라 다시 적는다. */
|
||||
keep: () => void;
|
||||
}
|
||||
|
||||
const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot =>
|
||||
JSON.parse(JSON.stringify(snapshot)) as RouteEditSnapshot;
|
||||
|
||||
export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
const { overlay, projectId } = params;
|
||||
const box = document.createElement("div");
|
||||
box.className = "b05-routeedit__initial";
|
||||
box.innerHTML = `
|
||||
<div class="b05-routeedit__initial-row">
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-logic="earthwork" disabled
|
||||
title="절토 · 성토가 가장 적은 노선을 서버가 셈해 초기값으로 싣습니다">토공 최소</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-logic="gentle" disabled
|
||||
title="종단기울기가 가장 순한 노선을 서버가 셈해 초기값으로 싣습니다">기울기 순한 노선</button>
|
||||
<button type="button" class="ui-btn ui-btn--glass" data-act="weights-open" disabled
|
||||
aria-expanded="false" title="예상 · 토공 · 기울기 비중(합 100)으로 셈합니다">비중 계산</button>
|
||||
</div>
|
||||
<div class="b05-routeedit__weights" hidden>
|
||||
${WEIGHT_KEYS.map(
|
||||
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
|
||||
<input type="range" min="0" max="100" step="1" data-weight="${key}" />
|
||||
<output data-weight-out="${key}"></output></label>`,
|
||||
).join("")}
|
||||
<div class="b05-routeedit__weights-foot">
|
||||
<span data-weight-sum></span>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-logic="weighted">계산</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="b05-routeedit__initial-now"></div>
|
||||
<div class="b05-routeedit__initial-line" data-line="metrics"></div>
|
||||
<div class="b05-routeedit__initial-line" data-line="criteria"></div>`;
|
||||
overlay.querySelector(".b05-routeedit__canvas-wrap")!.append(box);
|
||||
|
||||
const nowLine = box.querySelector<HTMLElement>(".b05-routeedit__initial-now")!;
|
||||
const metricsEl = box.querySelector<HTMLElement>('[data-line="metrics"]')!;
|
||||
const criteriaEl = box.querySelector<HTMLElement>('[data-line="criteria"]')!;
|
||||
const weightsBox = box.querySelector<HTMLElement>(".b05-routeedit__weights")!;
|
||||
const openWeights = box.querySelector<HTMLButtonElement>('[data-act="weights-open"]')!;
|
||||
const resetButton = overlay.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
|
||||
const buttons = [...box.querySelectorAll<HTMLButtonElement>("button"), resetButton];
|
||||
|
||||
/** 창을 열 때 캐시에 있던 초기값 — [취소]가 돌아갈 자리. */
|
||||
const openingChoice = readInitialChoice(projectId);
|
||||
let choice: InitialChoice | null = openingChoice;
|
||||
let weights: RouteInitialWeights = { ...(choice?.weights ?? DEFAULT_WEIGHTS) };
|
||||
let opening: RouteEditSnapshot | null = null;
|
||||
let edited = false;
|
||||
let busy = false;
|
||||
|
||||
function render(): void {
|
||||
const logic = choice?.logic ?? "follow";
|
||||
nowLine.textContent = `현재 초기값: ${choice ? choiceLabel(logic, choice.weights) : choiceLabel("follow", null)}`;
|
||||
metricsEl.textContent =
|
||||
choice && !choice.metrics
|
||||
? "비교값 — 서버 답에 없습니다."
|
||||
: metricsLine(choice?.metrics ?? null);
|
||||
criteriaEl.textContent = criteriaLine(choice?.criteria ?? null, params.planMinRadiusM());
|
||||
box.dataset.logic = logic;
|
||||
for (const key of WEIGHT_KEYS) {
|
||||
box.querySelector<HTMLInputElement>(`[data-weight="${key}"]`)!.value = String(weights[key]);
|
||||
box.querySelector<HTMLOutputElement>(`[data-weight-out="${key}"]`)!.value = `${weights[key]}`;
|
||||
}
|
||||
box.querySelector<HTMLElement>("[data-weight-sum]")!.textContent =
|
||||
`합 ${weights.expected + weights.earthwork + weights.slope}`;
|
||||
buttons.forEach((button) => {
|
||||
button.disabled = busy || !opening;
|
||||
});
|
||||
resetButton.title = `마지막에 적용한 초기값(${nowLine.textContent.replace("현재 초기값: ", "")})으로 되돌립니다 — 다시 계산은 [확인]에서`;
|
||||
}
|
||||
|
||||
/** 서버에 로직 하나를 묻고, 받은 노선을 싣고, 새 초기값으로 적는다. */
|
||||
async function run(logic: RouteInitialLogic, allowFallback = false): Promise<void> {
|
||||
if (busy || !opening) return;
|
||||
busy = true;
|
||||
const sent = logic === "weighted" ? { ...weights } : null;
|
||||
render();
|
||||
nowLine.textContent = `${choiceLabel(logic, sent)} 셈하는 중…`;
|
||||
try {
|
||||
const response = await fetchRouteInitial(projectId, {
|
||||
logic,
|
||||
...(sent ? { weights: sent } : {}),
|
||||
});
|
||||
if (!box.isConnected) return; // 그 사이 창이 닫혔다.
|
||||
choice = normalizeRouteInitial(response, logic, sent);
|
||||
writeInitialChoice(projectId, choice);
|
||||
params.apply(
|
||||
copy(choice.snapshot),
|
||||
`초기값을 「${choiceLabel(logic, sent)}」로 바꿨습니다 — 다시 계산은 [확인]에서.`,
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "초기노선을 셈하지 못했습니다.";
|
||||
// 서버 길이 오기 전이라도 고친 적 없는 노선이면 창을 연 노선이 곧 예상노선 추종이다.
|
||||
if (allowFallback && message === ROUTE_INITIAL_MISSING && !edited) {
|
||||
params.apply(
|
||||
copy(opening),
|
||||
"초기값(예상노선 추종)으로 되돌렸습니다 — 다시 계산은 [확인]에서.",
|
||||
);
|
||||
} else {
|
||||
showToast(message, "error");
|
||||
}
|
||||
} finally {
|
||||
busy = false;
|
||||
if (box.isConnected) render();
|
||||
}
|
||||
}
|
||||
|
||||
box.querySelectorAll<HTMLButtonElement>("[data-logic]").forEach((button) => {
|
||||
button.addEventListener("click", () => void run(button.dataset.logic as RouteInitialLogic));
|
||||
});
|
||||
openWeights.addEventListener("click", () => {
|
||||
weightsBox.hidden = !weightsBox.hidden;
|
||||
openWeights.setAttribute("aria-expanded", String(!weightsBox.hidden));
|
||||
});
|
||||
box.querySelectorAll<HTMLInputElement>("[data-weight]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
weights = rebalanceWeights(weights, input.dataset.weight as WeightKey, Number(input.value));
|
||||
render();
|
||||
});
|
||||
});
|
||||
// [초기화] — 마지막에 적용한 초기값으로. 아직 없으면 예상노선 추종을 서버에 묻는다.
|
||||
resetButton.addEventListener("click", () => {
|
||||
if (!choice) {
|
||||
void run("follow", true);
|
||||
return;
|
||||
}
|
||||
params.apply(
|
||||
copy(choice.snapshot),
|
||||
`초기값(${choiceLabel(choice.logic, choice.weights)})으로 되돌렸습니다 — 다시 계산은 [확인]에서.`,
|
||||
);
|
||||
});
|
||||
render();
|
||||
|
||||
return {
|
||||
ready(snapshot, wasEdited) {
|
||||
opening = copy(snapshot);
|
||||
edited = wasEdited;
|
||||
render();
|
||||
},
|
||||
current: () => ({ logic: choice?.logic ?? "follow", weights: choice?.weights ?? null }),
|
||||
cancel: () => writeInitialChoice(projectId, openingChoice),
|
||||
keep: () => writeInitialChoice(projectId, choice),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Initial_Model.ts
|
||||
* 초기노선 갈래(계획서 20)의 **셈만** — 비중 맞추기 · 서버 답 펴기 · 글 만들기 · 캐시.
|
||||
* 화면 배선은 `_RouteEdit_Initial` 몫이다(여기는 DOM 을 안 만진다 — 시험이 Node 로 돈다).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import type { RouteInitialLogic, RouteInitialWeights } from "./B05_Profile_Api_RouteInitial";
|
||||
import { flattenServerPlan } from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
|
||||
follow: "예상노선 추종",
|
||||
earthwork: "토공 최소",
|
||||
gentle: "기울기 순한 노선",
|
||||
weighted: "비중 계산",
|
||||
};
|
||||
|
||||
/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */
|
||||
export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, slope: 25 };
|
||||
export const WEIGHT_KEYS = ["expected", "earthwork", "slope"] as const;
|
||||
export type WeightKey = (typeof WEIGHT_KEYS)[number];
|
||||
|
||||
/** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */
|
||||
export function rebalanceWeights(
|
||||
weights: RouteInitialWeights,
|
||||
key: WeightKey,
|
||||
value: number,
|
||||
): RouteInitialWeights {
|
||||
const fixed = Math.min(100, Math.max(0, Math.round(Number.isFinite(value) ? value : 0)));
|
||||
const [first, second] = WEIGHT_KEYS.filter((name) => name !== key);
|
||||
const rest = 100 - fixed;
|
||||
const pool = weights[first] + weights[second];
|
||||
const firstShare = pool > 0 ? Math.round((rest * weights[first]) / pool) : Math.round(rest / 2);
|
||||
const out = { ...weights, [key]: fixed, [first]: firstShare, [second]: rest - firstShare };
|
||||
return { expected: out.expected, earthwork: out.earthwork, slope: out.slope };
|
||||
}
|
||||
|
||||
/** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */
|
||||
export interface InitialMetrics {
|
||||
lengthM: number | null;
|
||||
maxGradePct: number | null;
|
||||
meanGradePct: number | null;
|
||||
cutM3: number | null;
|
||||
fillM3: number | null;
|
||||
overLimitCount: number | null;
|
||||
offsetM: number | null;
|
||||
}
|
||||
|
||||
/** 공통 제약 기준값 — 서버가 준 값. `confirmed` 가 거짓이면 지식DB 후보(사용자 확정 대기). */
|
||||
export interface InitialCriteria {
|
||||
maxGradePct: number | null;
|
||||
minRadiusM: number | null;
|
||||
streamOffsetM: number | null;
|
||||
confirmed: boolean;
|
||||
}
|
||||
|
||||
/** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */
|
||||
export interface InitialChoice {
|
||||
logic: RouteInitialLogic;
|
||||
weights: RouteInitialWeights | null;
|
||||
snapshot: RouteEditSnapshot;
|
||||
metrics: InitialMetrics | null;
|
||||
criteria: InitialCriteria | null;
|
||||
}
|
||||
|
||||
const num = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
/** 여러 이름 중 먼저 있는 숫자 — 서버 낱말이 정해지기 전이라 흔한 이름을 함께 본다. */
|
||||
function pick(source: Record<string, unknown>, ...names: string[]): number | null {
|
||||
for (const name of names) {
|
||||
const value = num(source[name]);
|
||||
if (value !== null) return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 노드 목록을 편집값 한 벌로 편다 — 곡선 성분이 있으면 `/route/plan` 과 같은 규칙으로. */
|
||||
function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
|
||||
if (Array.isArray(curves) && curves.length) {
|
||||
const flat = flattenServerPlan(nodes as never, curves as never);
|
||||
return { ...flat, picked: -1 };
|
||||
}
|
||||
const rows = nodes as Array<Record<string, unknown> | Vertex>;
|
||||
const planned = rows.map((row): Vertex =>
|
||||
Array.isArray(row) ? [row[0], row[1]] : [Number(row.x), Number(row.y)],
|
||||
);
|
||||
return {
|
||||
planned,
|
||||
curveOn: rows.map((row) => (Array.isArray(row) ? true : row.curve !== false)),
|
||||
curveRadius: rows.map((row) => (Array.isArray(row) ? null : num(row.radius_m))),
|
||||
curveLock: planned.map(() => null),
|
||||
curveArc: planned.map(() => null),
|
||||
apexLock: planned.map(() => false),
|
||||
picked: -1,
|
||||
};
|
||||
}
|
||||
|
||||
/** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). */
|
||||
export function normalizeRouteInitial(
|
||||
response: unknown,
|
||||
logic: RouteInitialLogic,
|
||||
weights: RouteInitialWeights | null,
|
||||
): InitialChoice {
|
||||
const body = (response ?? {}) as Record<string, unknown>;
|
||||
const nodes = Array.isArray(body.nodes)
|
||||
? body.nodes
|
||||
: Array.isArray(body.vertices)
|
||||
? body.vertices
|
||||
: [];
|
||||
const snapshot = snapshotOf(nodes, body.curves);
|
||||
if (snapshot.planned.length < 2 || snapshot.planned.some(([x, y]) => !Number.isFinite(x + y))) {
|
||||
throw new Error("서버가 낸 초기노선에 노드가 모자랍니다.");
|
||||
}
|
||||
const m = (body.metrics ?? {}) as Record<string, unknown>;
|
||||
const c = (body.criteria ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
logic,
|
||||
weights,
|
||||
snapshot,
|
||||
metrics: body.metrics
|
||||
? {
|
||||
lengthM: pick(m, "length_m", "total_length_m"),
|
||||
maxGradePct: pick(m, "max_grade_pct", "max_slope_pct"),
|
||||
meanGradePct: pick(m, "mean_grade_pct", "mean_slope_pct", "avg_grade_pct"),
|
||||
cutM3: pick(m, "cut_m3", "cut_volume_m3"),
|
||||
fillM3: pick(m, "fill_m3", "fill_volume_m3"),
|
||||
overLimitCount: pick(m, "over_limit_count", "violation_count", "exceed_count"),
|
||||
offsetM: pick(m, "offset_mean_m", "expected_offset_m", "offset_m"),
|
||||
}
|
||||
: null,
|
||||
criteria: body.criteria
|
||||
? {
|
||||
maxGradePct: pick(c, "max_grade_pct", "max_slope_pct"),
|
||||
minRadiusM: pick(c, "min_radius_m"),
|
||||
streamOffsetM: pick(c, "stream_offset_m", "stream_clearance_m"),
|
||||
confirmed: c.confirmed === true,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 「현재 초기값: ○○」의 ○○ — 비중 계산이면 비중까지. */
|
||||
export function choiceLabel(logic: RouteInitialLogic, weights: RouteInitialWeights | null): string {
|
||||
if (logic !== "weighted" || !weights) return LOGIC_LABEL[logic];
|
||||
return `${LOGIC_LABEL.weighted} ${weights.expected}/${weights.earthwork}/${weights.slope}`;
|
||||
}
|
||||
|
||||
const fixed = (value: number | null, digits: number, unit: string): string =>
|
||||
value === null
|
||||
? "—"
|
||||
: `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`;
|
||||
|
||||
/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */
|
||||
export function metricsLine(metrics: InitialMetrics | null): string {
|
||||
if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다.";
|
||||
return [
|
||||
`연장 ${fixed(metrics.lengthM, 1, "m")}`,
|
||||
`기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`,
|
||||
`절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`,
|
||||
`기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`,
|
||||
`이격 ${fixed(metrics.offsetM, 1, "m")}`,
|
||||
].join(" · ");
|
||||
}
|
||||
|
||||
/** 공통 제약 기준값 한 줄 — 서버 값만 보인다. 서버 갈래가 없으면 `/route/plan` 의 최소 R 만. */
|
||||
export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string {
|
||||
const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null);
|
||||
const parts = [
|
||||
`종단기울기 ≤ ${fixed(criteria?.maxGradePct ?? null, 1, "%")}`,
|
||||
`최소 R ${fixed(radius, 0, "m")}`,
|
||||
`계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`,
|
||||
];
|
||||
const tail = criteria?.confirmed ? "" : " (후보 · 사용자 확정 대기)";
|
||||
return `공통 제약 ${parts.join(" · ")}${tail}`;
|
||||
}
|
||||
|
||||
/** 캐시에서 마지막 초기값을 읽는다. 없거나 꼴이 틀리면 null(= 예상노선 추종). */
|
||||
export function readInitialChoice(projectId: string): InitialChoice | null {
|
||||
const saved = readState<InitialChoice>("routeedit-initial", projectId);
|
||||
if (!saved || !saved.snapshot || !Array.isArray(saved.snapshot.planned)) return null;
|
||||
if (!(saved.logic in LOGIC_LABEL) || saved.snapshot.planned.length < 2) return null;
|
||||
return saved;
|
||||
}
|
||||
|
||||
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
|
||||
writeState("routeedit-initial", choice, projectId);
|
||||
}
|
||||
@@ -104,6 +104,37 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
|
||||
canvas.addEventListener("pointercancel", stop);
|
||||
}
|
||||
|
||||
export interface NodeSpliceParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
/** 돌린 지도 보정 — 화면 px 를 돌리기 전 그림 px 로. */
|
||||
unrotate: (px: number, py: number) => [number, number];
|
||||
segmentAt: (px: number, py: number) => number;
|
||||
nodeAt: (px: number, py: number) => number;
|
||||
/** 선(`segment` 번째 구간) 위 그림 px 자리에 노드를 끼운다. */
|
||||
insert: (segment: number, px: number, py: number) => void;
|
||||
/** `index` 번째 노드를 뺀다. */
|
||||
remove: (index: number) => void;
|
||||
}
|
||||
|
||||
/** 선 두 번 클릭 = 노드 추가 · 노드 오른쪽 클릭 = 삭제. 리스너는 캔버스와 수명이 같다. */
|
||||
export function bindNodeSplice(params: NodeSpliceParams): void {
|
||||
const { canvas } = params;
|
||||
const at = (event: MouseEvent): [number, number] => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
return params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
};
|
||||
canvas.addEventListener("dblclick", (event) => {
|
||||
const [px, py] = at(event);
|
||||
const segment = params.segmentAt(px, py);
|
||||
if (segment >= 0) params.insert(segment, px, py);
|
||||
});
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
const index = params.nodeAt(...at(event));
|
||||
if (index >= 0) params.remove(index);
|
||||
});
|
||||
}
|
||||
|
||||
/* ── 집기(hit test) — 화면 좌표에서 무엇을 잡았나. 모두 순수 함수다 ─────────────── */
|
||||
|
||||
export type ScreenOf = (point: [number, number]) => [number, number];
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Measure.ts
|
||||
* 계획노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤).
|
||||
* 노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤).
|
||||
*
|
||||
* Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든
|
||||
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다.
|
||||
* 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. 계획노선(실선)과 예상노선
|
||||
* (점선) 둘 다 잰다 — 두 점은 한 노선 위에 있어야 하고, 다른 노선을 짚으면 새로 시작한다.
|
||||
*
|
||||
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
|
||||
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
|
||||
@@ -16,10 +17,14 @@ import { formatStation } from "./B05_Profile_Util_Station";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 잰 노선 — 계획노선(실선) · 예상노선(점선). */
|
||||
export type MeasureLine = "planned" | "expected";
|
||||
|
||||
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
|
||||
const MEASURE_HIT_PX = 14;
|
||||
|
||||
interface MeasurePoint extends RoutePointHit {
|
||||
line: MeasureLine;
|
||||
/** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */
|
||||
z: number | null;
|
||||
}
|
||||
@@ -28,8 +33,8 @@ export interface MeasureToolParams {
|
||||
projectId: string;
|
||||
/** 규칙 측점 간격(m) — 측점 표기에 쓴다. */
|
||||
stationIntervalM: number;
|
||||
/** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
|
||||
line: () => Vertex[];
|
||||
/** 지금 그려지는 두 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */
|
||||
lines: () => Record<MeasureLine, Vertex[]>;
|
||||
toScreen: (vertex: Vertex) => [number, number];
|
||||
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
|
||||
isClosed: () => boolean;
|
||||
@@ -39,8 +44,9 @@ export interface MeasureToolParams {
|
||||
|
||||
export interface MeasureMark {
|
||||
point: Vertex;
|
||||
/** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
|
||||
/** 그 노선 시점에서 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
|
||||
chainageM: number;
|
||||
line: MeasureLine;
|
||||
}
|
||||
|
||||
export interface MeasureTool {
|
||||
@@ -56,6 +62,8 @@ export interface MeasureTool {
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
const LINE_NAME: Record<MeasureLine, string> = { planned: "", expected: "예상노선 " };
|
||||
|
||||
export interface MeasureControlsParams {
|
||||
measure: MeasureTool;
|
||||
/** 잰 값 작은 창·글·[거리 재기] 단추·작은 창 닫기 단추. */
|
||||
@@ -105,13 +113,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
const hint = (): string => {
|
||||
if (picked.length === 0) return "";
|
||||
const first = picked[0];
|
||||
const name = LINE_NAME[first.line];
|
||||
if (picked.length === 1) {
|
||||
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
||||
return `${name}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
||||
}
|
||||
const second = picked[1];
|
||||
const span = Math.abs(second.chainageM - first.chainageM);
|
||||
const head =
|
||||
`구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
|
||||
`${name}구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
|
||||
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
|
||||
if (first.z === null || second.z === null || span <= 1e-6) {
|
||||
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
|
||||
@@ -124,8 +133,26 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
);
|
||||
};
|
||||
|
||||
/** 클릭에 가장 가까운 노선 위 점. 재던 노선이 잡히면 그쪽이 먼저다(겹친 자리에서 튀지 않게). */
|
||||
const hitAt = (px: number, py: number): (RoutePointHit & { line: MeasureLine }) | null => {
|
||||
const lines = params.lines();
|
||||
const hits = (["planned", "expected"] as const)
|
||||
.map((line) => {
|
||||
const hit = routePointAtScreen(lines[line], params.toScreen, px, py, MEASURE_HIT_PX);
|
||||
if (!hit) return null;
|
||||
const [sx, sy] = params.toScreen(hit.point);
|
||||
return { ...hit, line, distance: Math.hypot(sx - px, sy - py) };
|
||||
})
|
||||
.filter((hit): hit is NonNullable<typeof hit> => hit !== null);
|
||||
if (!hits.length) return null;
|
||||
const keep = picked.length === 1 ? hits.find((hit) => hit.line === picked[0].line) : undefined;
|
||||
const best = keep ?? hits.reduce((a, b) => (b.distance < a.distance ? b : a));
|
||||
return { point: best.point, chainageM: best.chainageM, line: best.line };
|
||||
};
|
||||
|
||||
return {
|
||||
marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })),
|
||||
marks: () =>
|
||||
picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM, line: entry.line })),
|
||||
hint,
|
||||
active: () => picked.length > 0,
|
||||
clear() {
|
||||
@@ -134,13 +161,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
params.onChange();
|
||||
},
|
||||
async pick(px, py) {
|
||||
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
|
||||
const hit = hitAt(px, py);
|
||||
if (!hit) {
|
||||
picked = []; // 노선을 빗나가면 재던 것을 접는다.
|
||||
params.onChange();
|
||||
return;
|
||||
}
|
||||
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
|
||||
const fresh = picked.length >= 2 || (picked.length === 1 && picked[0].line !== hit.line);
|
||||
picked = fresh ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
|
||||
params.onChange();
|
||||
if (picked.length < 2) return;
|
||||
const asked = picked;
|
||||
|
||||
@@ -23,6 +23,9 @@ import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import { formatStation } from "./B05_Profile_Util_Station";
|
||||
|
||||
/** 노드 반지름(px). */
|
||||
@@ -40,6 +43,8 @@ const CURVE_HANDLE_PX = 5;
|
||||
const OUTWARD_PX = 26;
|
||||
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
|
||||
const MEASURE_COLOR = "#22c55e";
|
||||
/** 횡단 반폭 선 색 — 노선(주황)·재기(초록)·등고선(연보라)과 겹치지 않는 하늘색. */
|
||||
const CROSS_WIDTH_COLOR = "#0ea5e9";
|
||||
|
||||
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
|
||||
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
|
||||
@@ -81,8 +86,10 @@ export interface RouteEditScene {
|
||||
picked: number;
|
||||
/** 규칙 측점 간격(m). */
|
||||
stationIntervalM: number;
|
||||
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */
|
||||
measure: ReadonlyArray<{ point: Vertex; chainageM: number }>;
|
||||
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 그 노선의 누가거리(계획서 0-9 ⑤). */
|
||||
measure: ReadonlyArray<MeasureMark>;
|
||||
/** 오른쪽에 횡단이 떠 있는 측점과 좌·우 설계 반폭 — 없으면 null. */
|
||||
crossWidth: CrossWatch | null;
|
||||
/** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */
|
||||
rotationRad: number;
|
||||
/** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */
|
||||
@@ -253,6 +260,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
});
|
||||
context.restore();
|
||||
|
||||
drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다.
|
||||
drawStationMarks(context, scene, line);
|
||||
scene.overStations?.(context);
|
||||
drawMeasureMarks(context, scene, line);
|
||||
@@ -303,7 +311,8 @@ function drawMeasureMarks(
|
||||
if (scene.measure.length === 0) return;
|
||||
context.save();
|
||||
if (scene.measure.length >= 2) {
|
||||
const span = spanBetween(line, scene.measure[0], scene.measure[1]);
|
||||
const measured = scene.measure[0].line === "expected" ? scene.expected : line;
|
||||
const span = spanBetween(measured, scene.measure[0], scene.measure[1]);
|
||||
if (span.length >= 2) {
|
||||
context.strokeStyle = MEASURE_COLOR;
|
||||
context.lineWidth = 4;
|
||||
@@ -334,6 +343,45 @@ function drawMeasureMarks(
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단이 떠 있는 측점의 **좌·우 반폭 선** — 측점에서 노선에 직각, 길이 = 그 측점 설계 반폭.
|
||||
*
|
||||
* 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버
|
||||
* `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다.
|
||||
*/
|
||||
function drawCrossWidth(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
line: ReadonlyArray<Vertex>,
|
||||
): void {
|
||||
const watch = scene.crossWidth;
|
||||
if (!watch) return;
|
||||
const frame = stationFrameAt(line, watch.chainageM);
|
||||
if (!frame) return;
|
||||
const [ox, oy] = frame.origin;
|
||||
const [lx, ly] = frame.left;
|
||||
const ends: Vertex[] = [
|
||||
[ox + lx * watch.leftM, oy + ly * watch.leftM],
|
||||
[ox - lx * watch.rightM, oy - ly * watch.rightM],
|
||||
];
|
||||
const [cx, cy] = scene.toScreen(frame.origin);
|
||||
context.save();
|
||||
context.strokeStyle = CROSS_WIDTH_COLOR;
|
||||
context.fillStyle = CROSS_WIDTH_COLOR;
|
||||
context.lineWidth = 2.4;
|
||||
ends.forEach((end) => {
|
||||
const [x, y] = scene.toScreen(end);
|
||||
context.beginPath();
|
||||
context.moveTo(cx, cy);
|
||||
context.lineTo(x, y);
|
||||
context.stroke();
|
||||
context.beginPath();
|
||||
context.arc(x, y, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
});
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 0-9 ㉕).
|
||||
*
|
||||
|
||||
@@ -125,6 +125,78 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* 초기노선 갈래(계획서 20) — 회전 단추 아래 오른쪽. 로직 단추 · 비중 · 현재 초기값 · 비교값. */
|
||||
.b05-routeedit__initial {
|
||||
position: absolute;
|
||||
top: calc(var(--spacing-12) * 2 + 34px);
|
||||
right: var(--spacing-12);
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
max-width: min(420px, 44%);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
-webkit-backdrop-filter: blur(6px);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-row .ui-btn {
|
||||
padding: 4px var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-row [aria-expanded="true"] {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b05-routeedit__weights {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.b05-routeedit__weights label {
|
||||
display: grid;
|
||||
grid-template-columns: 3.5em 1fr 2.5em;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routeedit__weights output {
|
||||
text-align: right;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-routeedit__weights-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.b05-routeedit__weights-foot .ui-btn {
|
||||
padding: 4px var(--spacing-12);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-now {
|
||||
color: var(--color-text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b05-routeedit__initial-line {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.b05-routeedit__legend {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -58,50 +58,27 @@ function writePending(sessionKey: string | null, pending: PendingMap): void {
|
||||
* 세션에 쌓인 구간값을 `pipe_points.json`에 한 번에 쓴다. 예약분이 없으면 아무것도
|
||||
* 하지 않는다. 성공하면 세션을 비우고, 실패하면 **그대로 두어** 다음 [저장]에서
|
||||
* 다시 시도한다 — 조용히 잃으면 화면과 저장분이 갈린다.
|
||||
* 옛 「관 이동 예약」(culvertmove)은 읽지 않음 — 관 위치는 B05·B06 이 같은 관 목록 스냅샷으로만
|
||||
* 옮김. 옛 세션 잔값을 저장 때 적용하면 관이 이웃 측점으로 튀어 시설 속성이 넘어감.
|
||||
*/
|
||||
/** 예약된 이동 — 옛 측점 키 → 새 누가거리(m).
|
||||
* ⚠ **이제 쓰는 곳은 없다**(2026-09-12 일원화: 관 목록은 B05·B06 이 같은 스냅샷을 쓴다).
|
||||
* 옛 세션에 남아 있던 예약을 저장 때 한 번 흘려보내려고 읽기만 남겨 둔다. */
|
||||
type PendingMoves = Record<string, number>;
|
||||
|
||||
function readMoves(sessionKey: string | null): PendingMoves {
|
||||
if (!sessionKey) return {};
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(sessionKey);
|
||||
return raw ? (JSON.parse(raw) as PendingMoves) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function flushCulvertOptions(
|
||||
projectId: string | null,
|
||||
sessionKey: string | null,
|
||||
moveKey: string | null = null,
|
||||
): Promise<void> {
|
||||
const pending = readPending(sessionKey);
|
||||
const moves = readMoves(moveKey);
|
||||
if (!projectId || (Object.keys(pending).length === 0 && Object.keys(moves).length === 0)) return;
|
||||
if (!projectId || Object.keys(pending).length === 0) return;
|
||||
const current = await fetchDetailPipePoints(projectId);
|
||||
let touched = false;
|
||||
const points: DetailPipeInput[] = current.pipe_points.map((point) => {
|
||||
// 좌표는 서버가 다시 계산하므로 싣지 않는다(DetailPipeInput 규약).
|
||||
const { lonlat: _lonlat, ...input } = point;
|
||||
const key = keyOf(point.chainage_m);
|
||||
const patch = pending[key];
|
||||
const movedTo = moves[key];
|
||||
if (!patch && movedTo === undefined) return input;
|
||||
const patch = pending[keyOf(point.chainage_m)];
|
||||
if (!patch) return input;
|
||||
touched = true;
|
||||
const next: DetailPipeInput = patch
|
||||
? { ...input, options: { ...(input.options ?? {}), ...patch } }
|
||||
: { ...input };
|
||||
// 기준 측점 이동 — 관 위치가 바뀌면 서버가 세부 배수유역을 다시 나눠 응답한다.
|
||||
if (movedTo !== undefined) next.chainage_m = movedTo;
|
||||
return next;
|
||||
return { ...input, options: { ...(input.options ?? {}), ...patch } };
|
||||
});
|
||||
if (touched) await saveDetailPipePoints(projectId, points);
|
||||
writePending(sessionKey, {});
|
||||
writePending(moveKey, {});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,8 +89,6 @@ export function createCulvertOptionWriter(
|
||||
projectId: () => string | null,
|
||||
sessionKey: () => string | null,
|
||||
onError?: (message: string) => void,
|
||||
/** 이동 예약 세션 키 — 없으면 이동은 메모리에도 남지 않는다. */
|
||||
moveKey: () => string | null = () => null,
|
||||
): CulvertOptionWriter {
|
||||
return {
|
||||
queue(chainageM, patch) {
|
||||
@@ -124,7 +99,7 @@ export function createCulvertOptionWriter(
|
||||
},
|
||||
async flush() {
|
||||
try {
|
||||
await flushCulvertOptions(projectId(), sessionKey(), moveKey());
|
||||
await flushCulvertOptions(projectId(), sessionKey());
|
||||
} catch (error) {
|
||||
onError?.(error instanceof Error ? error.message : "배수관 구간값 저장 실패");
|
||||
throw error;
|
||||
|
||||
@@ -44,7 +44,6 @@ export interface StationControlDeps {
|
||||
| "extrawall"
|
||||
| "extraspan"
|
||||
| "culvertopt"
|
||||
| "culvertmove"
|
||||
| "revetlink"
|
||||
| "fordadjust"
|
||||
| "boxadjust",
|
||||
@@ -429,7 +428,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
deps.projectId,
|
||||
() => deps.sessionKey("culvertopt"),
|
||||
deps.onSaveError,
|
||||
() => deps.sessionKey("culvertmove"),
|
||||
);
|
||||
const linkSession = createLinkFlagSession(() => deps.sessionKey("revetlink"));
|
||||
const linkDetached = linkSession.detached;
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
|
||||
노선이 바뀌면(`route_signature` 불일치) 좌표가 있는 저장분은 새 노선에 투영해 이월하고,
|
||||
좌표가 없는 구 저장분만 버린다.
|
||||
|
||||
짝: 시설 속성 붙이기 `carry_indices` ↔ 화면 `B05_Profile/B05_Profile_UI_Drainage_Facility.ts`
|
||||
`carryIndices` — 같은 규칙(허용 오차 안 · 한 원본 한 번) · 거울 시험 test_b05_facility_carry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -352,21 +355,46 @@ def parse_pipe_points(values: Any) -> list[PipePoint]:
|
||||
return points
|
||||
|
||||
|
||||
# 시설 속성을 붙이는 허용 오차(m) — 측점 반올림(0.01m)을 덮되 이웃 관에는 안 닿는 폭.
|
||||
# 화면 `CARRY_TOLERANCE_M` 과 짝.
|
||||
CARRY_TOLERANCE_M = 0.05
|
||||
|
||||
|
||||
def carry_indices(targets: list[float], sources: list[float]) -> list[int | None]:
|
||||
"""관 측점마다 속성을 가져올 원본 번호(못 붙이면 None).
|
||||
|
||||
허용 오차 안 가장 가까운 원본만 · 한 원본은 한 번만 — 화면 `carryIndices` 와 같은 규칙.
|
||||
"""
|
||||
free = list(range(len(sources)))
|
||||
picked: list[int | None] = []
|
||||
for target in targets:
|
||||
best: int | None = None
|
||||
for index in free:
|
||||
gap = abs(sources[index] - target)
|
||||
if gap <= CARRY_TOLERANCE_M and (best is None or gap < abs(sources[best] - target)):
|
||||
best = index
|
||||
if best is not None:
|
||||
free.remove(best)
|
||||
picked.append(best)
|
||||
return picked
|
||||
|
||||
|
||||
def carry_facility_attributes(base: list[PipePoint], reference: list[PipePoint]) -> list[PipePoint]:
|
||||
"""계산기를 거쳐 재구성된 목록에 시설 종류·구간·옵션을 되붙인다.
|
||||
|
||||
세부유역 계산기는 chainage만 다루므로 계산에서 돌아온 목록은 전부 기본 배관이 된다.
|
||||
그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다. 좌표가 미세
|
||||
조정(스냅)될 수 있어 정확 일치가 없으면 가장 가까운 원본에서 승계한다 — 생성 사유를
|
||||
되붙이는 `_retag`(B04 라우터)과 같은 기준이다.
|
||||
세부유역 계산기는 chainage만 다루므로(반올림 0.01m) 계산에서 돌아온 목록은 전부 기본
|
||||
배관이 된다. 그대로 저장하면 사용자가 고른 세월교·BOX암거가 배관으로 되돌아간다.
|
||||
붙이기는 `carry_indices` — 허용 오차 밖의 관은 속성 없이 둔다(옆 관 속성을 가져가지 않음).
|
||||
"""
|
||||
if not reference:
|
||||
return base
|
||||
by_key = {round(ref.chainage_m, 2): ref for ref in reference}
|
||||
for point in base:
|
||||
ref = by_key.get(round(point.chainage_m, 2))
|
||||
if ref is None:
|
||||
ref = min(reference, key=lambda item: abs(item.chainage_m - point.chainage_m))
|
||||
picks = carry_indices(
|
||||
[point.chainage_m for point in base], [ref.chainage_m for ref in reference]
|
||||
)
|
||||
for point, pick in zip(base, picks):
|
||||
if pick is None:
|
||||
continue # 못 붙인 관 = 계산기가 돌려준 기본 배관 그대로(추천은 뒤에서 빈칸에만)
|
||||
ref = reference[pick]
|
||||
point.facility = ref.facility
|
||||
point.start_m = ref.start_m
|
||||
point.end_m = ref.end_m
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
/* 계획노선 편집 창 **초기노선 갈래**(계획서 20) 셈 단위검증 헬퍼 —
|
||||
* `B05_Profile_UI_RouteEdit_Initial_Model.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다.
|
||||
* 브라우저 전용인 저장소는 가짜로 채운다 — 셈만 본다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
_map: map,
|
||||
};
|
||||
}
|
||||
const session = memoryStorage();
|
||||
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
|
||||
globalThis.fetch = () => Promise.resolve({ ok: false });
|
||||
|
||||
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
|
||||
const { DEFAULT_WEIGHTS } = model;
|
||||
|
||||
// ① 비중 맞추기 — 하나를 옮기면 나머지 둘이 비율대로 나눠 합 100.
|
||||
const moves = [
|
||||
model.rebalanceWeights(DEFAULT_WEIGHTS, "expected", 70),
|
||||
model.rebalanceWeights(DEFAULT_WEIGHTS, "earthwork", 40),
|
||||
model.rebalanceWeights({ expected: 100, earthwork: 0, slope: 0 }, "expected", 60),
|
||||
model.rebalanceWeights({ expected: 20, earthwork: 30, slope: 50 }, "slope", 100),
|
||||
model.rebalanceWeights({ expected: 33, earthwork: 33, slope: 34 }, "earthwork", 33.4),
|
||||
model.rebalanceWeights(DEFAULT_WEIGHTS, "slope", -5),
|
||||
];
|
||||
|
||||
// ② 서버 답 펴기 — 노드 목록 꼴과 `/route/plan` 꼴(노드+곡선) 둘 다.
|
||||
const plain = model.normalizeRouteInitial(
|
||||
{
|
||||
nodes: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 50, y: 10, curve: true, radius_m: 30 },
|
||||
{ x: 100, y: 0, curve: false },
|
||||
],
|
||||
metrics: {
|
||||
length_m: 1234.56,
|
||||
max_grade_pct: 12.34,
|
||||
mean_grade_pct: 6.1,
|
||||
cut_m3: 1500.4,
|
||||
fill_m3: 900,
|
||||
over_limit_count: 2,
|
||||
offset_mean_m: 3.25,
|
||||
},
|
||||
criteria: { max_grade_pct: 14, min_radius_m: 12, stream_offset_m: 5 },
|
||||
},
|
||||
"earthwork",
|
||||
null,
|
||||
);
|
||||
const planShape = model.normalizeRouteInitial(
|
||||
{
|
||||
nodes: [
|
||||
{ x: 0, y: 0, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] },
|
||||
{ x: 40, y: 30, radius_m: 25, inner_angle_deg: 140, tangent_m: 9, violations: [] },
|
||||
{ x: 90, y: 30, radius_m: null, inner_angle_deg: null, tangent_m: null, violations: [] },
|
||||
],
|
||||
curves: [
|
||||
{
|
||||
apex: [40, 30],
|
||||
radius_m: 25,
|
||||
tangent_m: 9,
|
||||
inner_angle_deg: 140,
|
||||
start: [33, 25],
|
||||
end: [49, 30],
|
||||
node_first: 1,
|
||||
node_last: 1,
|
||||
violations: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
"weighted",
|
||||
{ expected: 60, earthwork: 20, slope: 20 },
|
||||
);
|
||||
let tooShort = "";
|
||||
try {
|
||||
model.normalizeRouteInitial({ nodes: [{ x: 1, y: 1 }] }, "gentle", null);
|
||||
} catch (error) {
|
||||
tooShort = error.message;
|
||||
}
|
||||
|
||||
// ③ 글 — 현재 초기값 · 비교값 · 기준값.
|
||||
const labels = {
|
||||
follow: model.choiceLabel("follow", null),
|
||||
weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, slope: 20 }),
|
||||
metrics: model.metricsLine(plain.metrics),
|
||||
metricsNone: model.metricsLine(null),
|
||||
criteria: model.criteriaLine(plain.criteria, 0),
|
||||
criteriaConfirmed: model.criteriaLine({ ...plain.criteria, confirmed: true }, 0),
|
||||
criteriaFallback: model.criteriaLine(null, 15),
|
||||
};
|
||||
|
||||
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
|
||||
model.writeInitialChoice("p-1", plain);
|
||||
const readBack = model.readInitialChoice("p-1");
|
||||
const other = model.readInitialChoice("p-2");
|
||||
const keys = [...session._map.keys()];
|
||||
session._map.set(
|
||||
keys[0],
|
||||
JSON.stringify({
|
||||
logic: "nope",
|
||||
snapshot: {
|
||||
planned: [
|
||||
[0, 0],
|
||||
[1, 1],
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
const badLogic = model.readInitialChoice("p-1");
|
||||
model.writeInitialChoice("p-1", null);
|
||||
const cleared = model.readInitialChoice("p-1");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
moves,
|
||||
plain,
|
||||
planShape,
|
||||
tooShort,
|
||||
labels,
|
||||
readBack,
|
||||
other,
|
||||
keys,
|
||||
badLogic,
|
||||
cleared,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
// 저장 왕복 시험용 — 화면 TS 를 node 로 묶어 저장 본문을 만든다.
|
||||
// node helper_b05_save_payload.mjs <저장소 뿌리> · 표준입력 = 작업 목록(JSON) · 표준출력 끝 줄 = 결과 목록
|
||||
// {op:"attach", entries:[관 응답], points:[{chainage_m, source}]} → attach 결과(DetailPipeInput[])
|
||||
// {op:"station", chainage, interval} → 구조물 측점 칸에 쓰고 다시 읽은 값
|
||||
// {op:"culvert", server:[관 응답], pending:{측점:조각}, moves:{측점:새 측점}} → PUT 본문 points 또는 null
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const root = path.resolve(process.argv[2] ?? ".");
|
||||
const { build } = await import(
|
||||
pathToFileURL(path.join(root, "config/node_modules/vite/dist/node/index.js")).href
|
||||
);
|
||||
|
||||
// 화면 칸 · 세션만 흉내 — 측점 칸은 값 · 클래스 · 이벤트만 쓴다
|
||||
class Node {
|
||||
constructor() {
|
||||
this.value = "";
|
||||
this.children = [];
|
||||
this.classList = { toggle() {}, add() {}, remove() {} };
|
||||
this.style = {};
|
||||
this.dataset = {};
|
||||
}
|
||||
append(...kids) {
|
||||
this.children.push(...kids);
|
||||
}
|
||||
appendChild(kid) {
|
||||
this.children.push(kid);
|
||||
return kid;
|
||||
}
|
||||
addEventListener() {}
|
||||
setAttribute() {}
|
||||
}
|
||||
const session = new Map();
|
||||
globalThis.document = { createElement: () => new Node(), createTextNode: () => new Node() };
|
||||
globalThis.window = {
|
||||
sessionStorage: {
|
||||
getItem: (k) => (session.has(k) ? session.get(k) : null),
|
||||
setItem: (k, v) => session.set(k, String(v)),
|
||||
removeItem: (k) => session.delete(k),
|
||||
},
|
||||
location: { origin: "http://localhost" },
|
||||
setTimeout: (...a) => setTimeout(...a),
|
||||
clearTimeout: (id) => clearTimeout(id),
|
||||
};
|
||||
globalThis.sessionStorage = globalThis.window.sessionStorage;
|
||||
globalThis.localStorage = globalThis.window.sessionStorage;
|
||||
|
||||
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), "aislo-save-payload-"));
|
||||
try {
|
||||
await build({
|
||||
configFile: false,
|
||||
logLevel: "silent",
|
||||
root,
|
||||
resolve: {
|
||||
alias: {
|
||||
"@ui": path.join(root, "ui_template"),
|
||||
"@config": path.join(root, "config"),
|
||||
"@util": path.join(root, "common_util"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir,
|
||||
minify: false,
|
||||
target: "es2022",
|
||||
lib: {
|
||||
entry: path.join(root, "resources/tester/helper_b05_save_payload_entry.ts"),
|
||||
formats: ["es"],
|
||||
fileName: "entry",
|
||||
},
|
||||
},
|
||||
});
|
||||
const mod = await import(pathToFileURL(path.join(outDir, "entry.js")).href);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf-8"));
|
||||
const results = [];
|
||||
for (const job of jobs) {
|
||||
if (job.op === "attach") {
|
||||
const store = mod.createFacilityStore();
|
||||
store.replaceFromResponse(job.entries);
|
||||
results.push(store.attach(job.points));
|
||||
} else if (job.op === "station") {
|
||||
const fields = mod.stationFields("측점", () => job.interval);
|
||||
fields.write(job.chainage, job.interval);
|
||||
results.push(fields.read(job.interval, true));
|
||||
} else if (job.op === "culvert") {
|
||||
session.clear();
|
||||
session.set("opt", JSON.stringify(job.pending ?? {}));
|
||||
session.set("move", JSON.stringify(job.moves ?? {}));
|
||||
let sent = null;
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
const body = init.method === "PUT" ? JSON.parse(init.body) : null;
|
||||
if (body) sent = body.points;
|
||||
const payload = body ? { pipe_points: body.points } : { pipe_points: job.server };
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
await mod.flushCulvertOptions("p", "opt", "move");
|
||||
results.push(sent);
|
||||
} else {
|
||||
throw new Error(`모르는 작업 ${job.op}`);
|
||||
}
|
||||
}
|
||||
console.log(JSON.stringify(results));
|
||||
} finally {
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/* =============================================================================
|
||||
* helper_b05_save_payload_entry.ts
|
||||
* 저장 왕복 시험(`test_b05_b06_save_roundtrip.py`)이 **화면 코드 그대로** 저장 본문을 만들 때
|
||||
* 묶는 진입점 — 관 시설 되붙이기 · 구조물 측점 칸(표시 → 다시 읽기) · 구간값 예약 흘려보내기.
|
||||
* ========================================================================== */
|
||||
|
||||
export { createFacilityStore } from "../../B05_Profile/B05_Profile_UI_Drainage_Facility";
|
||||
export { stationFields } from "../../B05_Profile/B05_Profile_UI_Structures_Fields";
|
||||
export { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
@@ -170,14 +170,28 @@ def test_carry_attributes_restores_facility_after_rebuild():
|
||||
assert carried[1].facility == PIPE_FACILITY_PIPE
|
||||
|
||||
|
||||
def test_carry_attributes_uses_nearest_when_chainage_shifted():
|
||||
"""계산기가 좌표를 미세 조정해도(스냅) 가장 가까운 원본에서 승계한다 — _retag과 동일 기준."""
|
||||
def test_carry_attributes_only_within_tolerance():
|
||||
"""계산기 반올림(0.01m) 차이는 승계 · 허용 오차(0.05m) 밖은 옆 관 속성을 안 가져감."""
|
||||
requested = parse_pipe_points([{"chainage_m": 150.0, "facility": "box_culvert"}])
|
||||
rebuilt = [PipePoint(chainage_m=150.37)]
|
||||
near = carry_facility_attributes([PipePoint(chainage_m=150.004)], requested)
|
||||
far = carry_facility_attributes([PipePoint(chainage_m=150.37)], requested)
|
||||
|
||||
assert near[0].facility == PIPE_FACILITY_BOX
|
||||
assert far[0].facility == PIPE_FACILITY_PIPE and far[0].options is None
|
||||
|
||||
|
||||
def test_carry_attributes_new_pipe_before_box_keeps_box_in_place():
|
||||
"""옵션 없는 관을 BOX 앞에 두어도 BOX 속성은 제 측점에 남음 — 관 수 > 속성 원본 수."""
|
||||
requested = parse_pipe_points(
|
||||
[{"chainage_m": 100.0, "facility": "box_culvert", "options": {"body_width_m": 2.0}}]
|
||||
)
|
||||
rebuilt = [PipePoint(chainage_m=97.0), PipePoint(chainage_m=100.0)]
|
||||
|
||||
carried = carry_facility_attributes(rebuilt, requested)
|
||||
|
||||
assert carried[0].facility == PIPE_FACILITY_BOX
|
||||
assert carried[0].facility == PIPE_FACILITY_PIPE and carried[0].options is None
|
||||
assert carried[1].facility == PIPE_FACILITY_BOX
|
||||
assert carried[1].options == {"body_width_m": 2.0}
|
||||
|
||||
|
||||
def test_carry_attributes_without_reference_is_noop():
|
||||
|
||||
@@ -0,0 +1,499 @@
|
||||
"""B05 · B06 저장 왕복 — 사용자가 바꾼 값이 저장 · 다시 읽기 뒤 그대로인가(PLAN 19장).
|
||||
|
||||
환경 배정 storage 프로젝트(랩탑_보조)의 **사본**에서 화면 저장 흐름과 같은 차례로 서버 API 를
|
||||
부르고, 다시 읽은 값(pipe_points.json · structures.json · 종단 파일 · 응답)을 사용자가 고른 값과
|
||||
0 차이로 대조. 저장 본문은 화면 코드 그대로(`helper_b05_save_payload.mjs` — 시설 되붙이기 ·
|
||||
구조물 측점 칸 · 구간값 예약).
|
||||
|
||||
- 파일: 프로젝트 폴더를 임시 폴더로 복사(큰 원본 · 점군 제외) · `STORAGE_BASE_DIR` 를 그리로 돌림.
|
||||
- DB: 읽기는 진짜 DB(같은 프로젝트 행) · 세션을 읽기 전용으로 열어 쓰기가 새지 않음 ·
|
||||
쓰는 함수는 받은 값을 적어 두는 가짜로 갈음(DB 에 남기는 값도 보낸 값과 대조).
|
||||
- 원본 프로젝트 · 사용자 프로젝트 · DB 는 바뀌지 않음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
PROJECT_ID = "936be972-11bc-46c2-8bf3-b15d8de7df0d" # OWNERS.md — laptop-sub `랩탑_보조`
|
||||
SOURCE = ROOT / "storage" / "1" / "3" / PROJECT_ID
|
||||
# 저장 흐름이 읽고 쓰는 것만 — 원본 입력(B03) · 점군(structured.npz 등)은 안 씀
|
||||
_COPY_DIRS = ("B04_PreProcess/drainage", "B05_Profile", "B06_Section")
|
||||
_COPY_MAX_BYTES = 20 * 1024 * 1024
|
||||
|
||||
pytestmark = pytest.mark.skipif(not SOURCE.is_dir(), reason="배정 storage 프로젝트가 없음")
|
||||
|
||||
|
||||
def _copy_project(dest: Path) -> None:
|
||||
for rel in _COPY_DIRS:
|
||||
shutil.copytree(SOURCE / rel, dest / rel)
|
||||
for rel in ("B04_PreProcess/models", "B04_PreProcess/processed"):
|
||||
(dest / rel).mkdir(parents=True, exist_ok=True)
|
||||
for item in (SOURCE / rel).iterdir():
|
||||
if item.is_file() and item.stat().st_size <= _COPY_MAX_BYTES:
|
||||
shutil.copy2(item, dest / rel / item.name)
|
||||
for item in SOURCE.iterdir():
|
||||
if item.is_file():
|
||||
shutil.copy2(item, dest / item.name)
|
||||
|
||||
|
||||
class Recorder:
|
||||
"""DB 에 쓰는 함수 대신 — 받은 값을 적어 둠."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||
|
||||
def fake(self, name: str, result: Any = None):
|
||||
async def _fake(*args: Any, **kwargs: Any) -> Any:
|
||||
self.calls.append((name, {"args": args[1:], **kwargs}))
|
||||
return result
|
||||
|
||||
return _fake
|
||||
|
||||
def of(self, name: str) -> list[dict[str, Any]]:
|
||||
return [call for key, call in self.calls if key == name]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def api(tmp_path, monkeypatch):
|
||||
aiomysql = pytest.importorskip("aiomysql")
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import B04_PreProcess.B04_PreProcess_Router_Basins as basins
|
||||
import B05_Profile.B05_Profile_Router as route
|
||||
import B05_Profile.B05_Profile_Router_Lifecycle as lifecycle
|
||||
import B05_Profile.B05_Profile_Structures_Router as structures
|
||||
import B06_Section.B06_Section_Repository_Bulk as bulk
|
||||
import B06_Section.B06_Section_Router_Confirm as sections
|
||||
import common_util.common_util_storage as storage
|
||||
import config.config_db as config_db
|
||||
from config import config_system as cfg
|
||||
|
||||
storage_root = tmp_path / "storage"
|
||||
_copy_project(storage_root / "1" / "3" / PROJECT_ID)
|
||||
monkeypatch.setattr(storage, "STORAGE_BASE_DIR", str(storage_root))
|
||||
|
||||
rec = Recorder()
|
||||
monkeypatch.setattr(route, "update_longitudinal_grade_summary", rec.fake("grade_summary"))
|
||||
monkeypatch.setattr(structures, "_invalidate_downstream", rec.fake("invalidate", True))
|
||||
monkeypatch.setattr(lifecycle, "confirm_route", rec.fake("confirm_route"))
|
||||
monkeypatch.setattr(lifecycle, "complete_stage", rec.fake("complete_stage"))
|
||||
monkeypatch.setattr(bulk, "merge_cross_section_designs", rec.fake("designs", 0))
|
||||
monkeypatch.setattr(sections, "merge_cross_section_designs", rec.fake("designs", 0))
|
||||
monkeypatch.setattr(sections, "merge_longitudinal_section_options", rec.fake("options"))
|
||||
monkeypatch.setattr(sections, "merge_longitudinal_section_data", rec.fake("data"))
|
||||
monkeypatch.setattr(sections, "confirm_sections_for_route", rec.fake("confirm_sections"))
|
||||
monkeypatch.setattr(sections, "confirm_route_status", rec.fake("confirm_route"))
|
||||
monkeypatch.setattr(sections, "complete_stage", rec.fake("complete_stage"))
|
||||
monkeypatch.setattr(sections, "start_stage", rec.fake("start_stage"))
|
||||
monkeypatch.setattr(sections, "_recompute_stored_designs", rec.fake("recompute"))
|
||||
real_known = sections.get_cross_section_chainages
|
||||
|
||||
async def spy_known(connection, route_id):
|
||||
rows = await real_known(connection, route_id)
|
||||
rec.calls.append(("known", {"chainages": list(rows)}))
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(sections, "get_cross_section_chainages", spy_known)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app):
|
||||
try:
|
||||
pool = await aiomysql.create_pool(
|
||||
host=cfg.DB_HOST,
|
||||
port=cfg.DB_PORT,
|
||||
db=cfg.DB_NAME,
|
||||
user=cfg.DB_USER,
|
||||
password=cfg.DB_PASSWORD,
|
||||
minsize=1,
|
||||
maxsize=6,
|
||||
autocommit=True,
|
||||
charset="utf8mb4",
|
||||
# 읽기 전용 세션 — 가짜로 못 갈음한 쓰기가 있으면 DB 를 바꾸지 않고 터짐
|
||||
init_command="SET time_zone='+00:00', SESSION tx_read_only=1",
|
||||
)
|
||||
except Exception as exc: # DB 가 안 닿는 곳
|
||||
pytest.skip(f"DB 에 안 닿음: {exc}")
|
||||
config_db.db_pool = pool
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
config_db.db_pool = None
|
||||
pool.close()
|
||||
await pool.wait_closed()
|
||||
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
for module in (basins, route, lifecycle, structures, sections):
|
||||
app.include_router(module.router)
|
||||
with TestClient(app) as client:
|
||||
client.rec = rec # type: ignore[attr-defined]
|
||||
client.root = storage_root / "1" / "3" / PROJECT_ID # type: ignore[attr-defined]
|
||||
yield client
|
||||
|
||||
|
||||
# ── 화면 코드 그대로 본문 만들기(node) ────────────────────────────────────────
|
||||
HELPER = Path(__file__).with_name("helper_b05_save_payload.mjs")
|
||||
STATION_INTERVAL_M = 20.0
|
||||
# 화면이 관 옵션 수정 끝에 측점을 다시 읽어 이만큼 넘게 다르면 관을 옮김
|
||||
# (`B05_Profile_UI_Drainage_Panel.ts` `updatePipeFacility`)
|
||||
PIPE_MOVE_THRESHOLD_M = 0.005
|
||||
|
||||
|
||||
def _screen(jobs: list[dict[str, Any]]) -> list[Any]:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
out = subprocess.run(
|
||||
["node", str(HELPER), str(ROOT)],
|
||||
input=json.dumps(jobs),
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=300,
|
||||
check=True,
|
||||
)
|
||||
return json.loads(out.stdout.strip().splitlines()[-1])
|
||||
|
||||
|
||||
# ── 비교 모양 ─────────────────────────────────────────────────────────────────
|
||||
LONGITUDINAL = "B06_Section/longitudinal/longitudinal.json"
|
||||
PIPES_FILE = "B04_PreProcess/drainage/edits/pipe_points.json"
|
||||
STRUCTURES_FILE = "B05_Profile/route/structures.json"
|
||||
|
||||
|
||||
def _key(chainage: float) -> str:
|
||||
return f"{chainage:.2f}"
|
||||
|
||||
|
||||
def _pipes_view(points: list[dict[str, Any]]) -> dict[str, dict[str, Any]]:
|
||||
"""관 — 측점(0.01) → 시설 · 구간 · 옵션(좌표 · 생성 사유는 뺌)."""
|
||||
return {
|
||||
_key(p["chainage_m"]): {
|
||||
"facility": p.get("facility", "pipe"),
|
||||
"span": (p.get("start_m"), p.get("end_m")),
|
||||
"options": p.get("options") or {},
|
||||
}
|
||||
for p in points
|
||||
}
|
||||
|
||||
|
||||
def _read(root: Path, rel: str) -> Any:
|
||||
return json.loads((root / rel).read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _strip(point: dict[str, Any]) -> dict[str, Any]:
|
||||
"""화면이 서버에 되보내는 관 — 좌표는 서버가 다시 냄(`DetailPipeInput`)."""
|
||||
return {k: v for k, v in point.items() if k != "lonlat"}
|
||||
|
||||
|
||||
class Flow:
|
||||
"""화면 저장 흐름 하나(B05 [임시저장] · B06 [저장]).
|
||||
|
||||
사용자가 고른 값(`want_*`)을 따로 적어 두고, 화면 코드가 만든 본문을 서버에 보낸 뒤
|
||||
다시 읽은 값을 고른 값과 대조. 바꿀 대상은 사본에 있는 것 중 자리 순번으로 고름.
|
||||
"""
|
||||
|
||||
def __init__(self, api) -> None:
|
||||
self.api = api
|
||||
self.base = f"/api/projects/{PROJECT_ID}"
|
||||
latest = api.get(f"{self.base}/route/latest").json()
|
||||
self.route_id = latest["route"]["id"]
|
||||
self.surface = latest["surface_params"]
|
||||
self.model_id = latest["route"]["surface_model_id"]
|
||||
self.diffs: list[str] = []
|
||||
self.sent_irregular: list[float] = []
|
||||
self._plan()
|
||||
|
||||
def server_pipes(self) -> list[dict[str, Any]]:
|
||||
got = self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
||||
return [_strip(p) for p in got]
|
||||
|
||||
# ── 사용자가 고른 값 ──
|
||||
def _plan(self) -> None:
|
||||
pipes = self.server_pipes()
|
||||
if len(pipes) < 9:
|
||||
pytest.skip(f"관이 {len(pipes)} 개 — 시험에 9 개 넘게 필요")
|
||||
self.pipes0 = pipes
|
||||
ch = [p["chainage_m"] for p in pipes]
|
||||
self.pipe_move = (ch[2], round(ch[2] + 5.35, 2)) # 관 옮김 — 시설 · 옵션이 따라가야 함
|
||||
self.pipe_option = (ch[5], {"pipe_diameter_mm": 1350}) # 옵션만 — 자리 그대로
|
||||
self.culvert = (ch[8], {"pipe_diameter_mm": 1200}) # B06 조정창 구간값 예약
|
||||
self.stale_move = (ch[4], round(ch[4] + 4.76, 2)) # 옛 세션에 남은 관 이동 예약
|
||||
self.pipe_new = round((ch[6] + ch[7]) / 2, 2) # 새 관 하나(맨 배관)
|
||||
want = []
|
||||
for p in pipes:
|
||||
item = dict(p)
|
||||
if item["chainage_m"] == self.pipe_move[0]:
|
||||
item.update(chainage_m=self.pipe_move[1], source="user")
|
||||
elif item["chainage_m"] == self.pipe_option[0]:
|
||||
item["options"] = {**(item.get("options") or {}), **self.pipe_option[1]}
|
||||
elif item["chainage_m"] == self.culvert[0]:
|
||||
item["options"] = {**(item.get("options") or {}), **self.culvert[1]}
|
||||
want.append(item)
|
||||
want.append({"chainage_m": self.pipe_new, "source": "user"})
|
||||
self.want_pipes = sorted(want, key=lambda x: x["chainage_m"])
|
||||
|
||||
got = self.api.get(f"{self.base}/route/structures").json()
|
||||
self.revision0, items = got["revision"], got["structures"]
|
||||
spans = [s for s in items if s.get("start_m") is not None and s.get("end_m") is not None]
|
||||
if len(spans) < 3:
|
||||
pytest.skip("구간 구조물이 3 개 넘게 필요")
|
||||
moved, optioned, memoed = spans[0], spans[1], spans[2]
|
||||
width = moved["end_m"] - moved["start_m"]
|
||||
to = round(moved["start_m"] + 1.37, 2) # 측점 칸에 소수 2자리로 쳐 넣음
|
||||
self.struct_move = (moved["structure_id"], to, round(to + width, 2))
|
||||
self.want_structures = json.loads(json.dumps(items))
|
||||
for s in self.want_structures:
|
||||
if s["structure_id"] == moved["structure_id"]:
|
||||
s.update(chainage_m=to, start_m=to, end_m=round(to + width, 2))
|
||||
s["memo"] = "옮긴 뒤 메모" # 옮긴 칸에서 메모만 한 번 더 고침
|
||||
elif s["structure_id"] == optioned["structure_id"]:
|
||||
s["options"] = {**s["options"], "height_m": 3}
|
||||
s["memo"] = "왕복 시험 메모"
|
||||
elif s["structure_id"] == memoed["structure_id"]:
|
||||
s["memo"] = "메모만 바꿈"
|
||||
|
||||
lon = _read(self.api.root, LONGITUDINAL)
|
||||
grid = [s for s in lon["stations"] if s.get("kind") == "regular"]
|
||||
g0, g1 = grid[4], grid[len(grid) // 2]
|
||||
self.offsets = {f"{g0['chainage_m']:.3f}": 0.35, f"{g1['chainage_m']:.3f}": -0.2}
|
||||
side = grid[3].get("uphill_side")
|
||||
self.uphill = (grid[3]["chainage_m"], "left" if side != "left" else "right")
|
||||
self.cross_patches = [
|
||||
{
|
||||
"chainage_m": g0["chainage_m"],
|
||||
"rock_boundary_offset_m": -0.8,
|
||||
"cut_slope_ratio_user": 0.5,
|
||||
},
|
||||
{"chainage_m": self.pipe_move[1], "pipe_length_m": 9.0},
|
||||
]
|
||||
|
||||
# ── 화면 코드가 만드는 본문 ──
|
||||
def b05_pipe_payload(self) -> list[dict[str, Any]]:
|
||||
"""B05 `savePipes` — 화면 관 목록(측점 · 사유) + 시설 보관소 `attach`."""
|
||||
(station,) = _screen(
|
||||
[{"op": "station", "chainage": self.pipe_option[0], "interval": STATION_INTERVAL_M}]
|
||||
)
|
||||
entries = [dict(p) for p in self.pipes0]
|
||||
screen_ch = {}
|
||||
for p in self.want_pipes:
|
||||
c = p["chainage_m"]
|
||||
if c == self.pipe_option[0] and abs(station - c) > PIPE_MOVE_THRESHOLD_M:
|
||||
c = station # 옵션 폼 확정이 측점을 표시값(0.1)에서 다시 읽어 관을 옮김
|
||||
screen_ch[p["chainage_m"]] = c
|
||||
for e in entries: # 폼에서 고친 시설 정보는 그 관의 (새) 자리로 보관소에 들어감
|
||||
if e["chainage_m"] == self.pipe_option[0]:
|
||||
e["chainage_m"] = screen_ch[self.pipe_option[0]]
|
||||
e["options"] = {**(e.get("options") or {}), **self.pipe_option[1]}
|
||||
points = [
|
||||
{
|
||||
"chainage_m": screen_ch[p["chainage_m"]],
|
||||
"source": "confirmed" if p.get("source") == "user" else p.get("source", "user"),
|
||||
}
|
||||
for p in self.want_pipes
|
||||
]
|
||||
(attached,) = _screen([{"op": "attach", "entries": entries, "points": points}])
|
||||
return attached
|
||||
|
||||
def b06_pipe_payload(self) -> list[dict[str, Any]]:
|
||||
"""B06 `persistPipes` — 화면 관 상태 그대로(구간값은 예약으로 따로 나감)."""
|
||||
before = {p["chainage_m"]: p.get("options") for p in self.pipes0}
|
||||
out = []
|
||||
for p in self.want_pipes:
|
||||
item = dict(p)
|
||||
if p["chainage_m"] == self.culvert[0]:
|
||||
item.pop("options", None)
|
||||
if before[self.culvert[0]]:
|
||||
item["options"] = before[self.culvert[0]]
|
||||
out.append(item)
|
||||
return out
|
||||
|
||||
def screen_structures(self, *, reread: bool) -> list[dict[str, Any]]:
|
||||
"""구조물 — 옮긴 칸에서 메모를 고치면 `commit` 이 측점 칸(표시 0.1)을 다시 읽음."""
|
||||
items = json.loads(json.dumps(self.want_structures))
|
||||
if not reread:
|
||||
return items
|
||||
sid, start, end = self.struct_move
|
||||
ch = next(s for s in items if s["structure_id"] == sid)
|
||||
a, b, c = _screen(
|
||||
[
|
||||
{"op": "station", "chainage": v, "interval": STATION_INTERVAL_M}
|
||||
for v in (start, ch["chainage_m"], end)
|
||||
]
|
||||
)
|
||||
ch.update(start_m=a, chainage_m=b, end_m=c)
|
||||
return items
|
||||
|
||||
def culvert_payload(self, current: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
|
||||
"""`flushCulvertOptions` — 구간값 예약 + 옛 세션 이동 예약을 서버 목록에 얹음."""
|
||||
pending = {_key(self.culvert[0]): self.culvert[1]}
|
||||
moves = {_key(self.stale_move[0]): self.stale_move[1]}
|
||||
(body,) = _screen(
|
||||
[{"op": "culvert", "server": current, "pending": pending, "moves": moves}]
|
||||
)
|
||||
return body
|
||||
|
||||
# ── API(화면 Api_Fetch 와 같은 경로 · 본문) ──
|
||||
def put_pipes(self, pipes: list[dict[str, Any]]) -> None:
|
||||
res = self.api.put(f"{self.base}/drainage/pipe-points", json={"points": pipes})
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def flush_culvert(self) -> None:
|
||||
body = self.culvert_payload(
|
||||
self.api.get(f"{self.base}/drainage/pipe-points").json()["pipe_points"]
|
||||
)
|
||||
if body is not None:
|
||||
self.put_pipes(body)
|
||||
|
||||
def put_alignment(self) -> None:
|
||||
body = {"route_id": self.route_id, "station_offsets": self.offsets, "curve_radii": {}}
|
||||
res = self.api.put(f"{self.base}/route/profile-alignment", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def put_structures(self, items: list[dict[str, Any]]) -> None:
|
||||
revision = self.api.get(f"{self.base}/route/structures").json()["revision"]
|
||||
body = {"base_revision": revision, "structures": items}
|
||||
res = self.api.put(f"{self.base}/route/structures", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def confirm(self, *, with_irregular: bool) -> None:
|
||||
body: dict[str, Any] = {
|
||||
"uphill_overrides": [{"chainage_m": self.uphill[0], "side": self.uphill[1]}]
|
||||
}
|
||||
if with_irregular: # B05 — 관 투영(물넘이포장 뺌) · A군 구조물
|
||||
self.sent_irregular = [
|
||||
p["chainage_m"] for p in self.server_pipes() if p.get("facility") != "ford_pavement"
|
||||
]
|
||||
body.update(
|
||||
filter_key=self.surface["source_filter"],
|
||||
method=self.surface["method"],
|
||||
smooth=self.surface["smooth"],
|
||||
surface_model_id=self.model_id,
|
||||
irregular_stations=[
|
||||
{"chainage_m": c, "structure": "배관"} for c in self.sent_irregular
|
||||
],
|
||||
)
|
||||
res = self.api.post(f"{self.base}/route/confirm?mark_stage_complete=false", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
def save_sections(self) -> None:
|
||||
body = {"cross_patches": self.cross_patches}
|
||||
res = self.api.post(f"{self.base}/sections/{self.route_id}/save", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
# ── 다시 읽기 · 대조(사용자가 고른 값 기준) ──
|
||||
def check(self) -> None:
|
||||
root, d = self.api.root, self.diffs
|
||||
want = _pipes_view(self.want_pipes)
|
||||
for where, got in (
|
||||
("응답", _pipes_view(self.server_pipes())),
|
||||
("pipe_points.json", _pipes_view(_read(root, PIPES_FILE)["points"])),
|
||||
):
|
||||
for k in sorted(set(want) | set(got), key=float):
|
||||
if want.get(k) != got.get(k):
|
||||
d.append(f"관 {k} ({where}) — 고름 {want.get(k)} · 읽음 {got.get(k)}")
|
||||
saved = {s["structure_id"]: s for s in _read(root, STRUCTURES_FILE)["structures"]}
|
||||
for s in self.want_structures:
|
||||
got_s = saved.get(s["structure_id"], {})
|
||||
for field in ("chainage_m", "start_m", "end_m", "options", "memo", "placement_source"):
|
||||
if s.get(field) != got_s.get(field):
|
||||
d.append(
|
||||
f"구조물 {s['type_id']} {field} — "
|
||||
f"고름 {s.get(field)!r} · 읽음 {got_s.get(field)!r}"
|
||||
)
|
||||
lon = _read(root, LONGITUDINAL)
|
||||
edits = (lon["profile_alignment"].get("edits") or {}).get("station_offsets") or {}
|
||||
if edits != self.offsets:
|
||||
d.append(f"계획고 수정 — 고름 {self.offsets} · 읽음 {edits}")
|
||||
up = next((s for s in lon["stations"] if abs(s["chainage_m"] - self.uphill[0]) < 0.005), {})
|
||||
if (up.get("uphill_side"), up.get("uphill_side_source")) != (self.uphill[1], "user"):
|
||||
d.append(
|
||||
f"상단측 {self.uphill[0]} — 고름 {self.uphill[1]} · "
|
||||
f"읽음 {up.get('uphill_side')}/{up.get('uphill_side_source')}"
|
||||
)
|
||||
for c in self.sent_irregular:
|
||||
near = [s["chainage_m"] for s in lon["stations"] if abs(s["chainage_m"] - c) < 1.0]
|
||||
if not any(abs(n - c) < 0.005 for n in near):
|
||||
d.append(f"관 측점 {c} — 종단 측점에 없음(가까운 측점 {near})")
|
||||
self._check_designs()
|
||||
|
||||
def _check_designs(self) -> None:
|
||||
"""횡단 patch — DB 로 가는 값이 고른 값과 같고, 받을 행이 있어야 함(없으면 DB 가 버림)."""
|
||||
rec = self.api.rec
|
||||
known = [c for call in rec.of("known") for c in call["chainages"]]
|
||||
designs = rec.of("designs")
|
||||
made = [e[0] for call in designs if call.get("replace") for e in call["entries"]]
|
||||
patched = {
|
||||
round(e[0], 2): e[1]
|
||||
for call in designs
|
||||
if call.get("replace") is False
|
||||
for e in call["entries"]
|
||||
}
|
||||
for patch in self.cross_patches:
|
||||
ch = patch["chainage_m"]
|
||||
body = {k: v for k, v in patch.items() if k != "chainage_m"}
|
||||
got = patched.get(round(ch, 2))
|
||||
if got is None or any(got.get(k) != v for k, v in body.items()):
|
||||
self.diffs.append(f"횡단 patch {ch} — 고름 {body} · DB 로 {got}")
|
||||
elif not any(abs(float(k) - ch) < 0.01 for k in [*known, *made]):
|
||||
self.diffs.append(f"횡단 patch {ch} — 받을 행이 없어 DB 가 버림")
|
||||
|
||||
|
||||
def _snapshot(root: Path) -> dict[str, Any]:
|
||||
lon = _read(root, LONGITUDINAL)
|
||||
return {
|
||||
"pipe_points.json": _read(root, PIPES_FILE)["points"],
|
||||
"structures.json": _read(root, STRUCTURES_FILE)["structures"],
|
||||
"계획고 수정": lon["profile_alignment"].get("edits"),
|
||||
"종단 측점": [
|
||||
(s["chainage_m"], s.get("kind"), s.get("uphill_side")) for s in lon["stations"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _resave_unchanged(flow: Flow) -> None:
|
||||
"""다시 열어 아무것도 안 바꾸고 [저장] — 저장본이 한 글자도 안 바뀌어야 함."""
|
||||
flow.put_pipes(flow.server_pipes())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.api.get(f"{flow.base}/route/structures").json()["structures"])
|
||||
|
||||
|
||||
def _b05_save(flow: Flow) -> None:
|
||||
"""B05 [임시저장] 차례 — 관 → 계획선 → 구조물 → 확정(관 측점 · 상단측) → 구간값 → 횡단."""
|
||||
flow.put_pipes(flow.b05_pipe_payload())
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=True))
|
||||
flow.confirm(with_irregular=True)
|
||||
flow.flush_culvert()
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
def _b06_save(flow: Flow) -> None:
|
||||
"""B06 [저장] 차례 — 관 → 구간값 → 상단측만 확정 → 계획선 → 구조물 → 횡단."""
|
||||
flow.put_pipes(flow.b06_pipe_payload())
|
||||
flow.flush_culvert()
|
||||
flow.confirm(with_irregular=False)
|
||||
flow.put_alignment()
|
||||
flow.put_structures(flow.screen_structures(reread=False))
|
||||
flow.save_sections()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("save", [_b05_save, _b06_save], ids=["B05", "B06"])
|
||||
def test_저장_왕복_0_차이(api, save):
|
||||
flow = Flow(api)
|
||||
save(flow)
|
||||
flow.check()
|
||||
before = _snapshot(api.root)
|
||||
_resave_unchanged(flow)
|
||||
after = _snapshot(api.root)
|
||||
flow.diffs += [f"다시 열어 그대로 저장 — {k} 가 바뀜" for k in before if before[k] != after[k]]
|
||||
assert not flow.diffs, "\n".join(flow.diffs)
|
||||
@@ -0,0 +1,128 @@
|
||||
"""시설 속성 붙이기 거울 시험 — 화면 `carryIndices`(B05_Profile_UI_Drainage_Facility.ts) ↔
|
||||
서버 `carry_indices`(common_util_drainage_pipes.py) 가 같은 답을 내는지.
|
||||
|
||||
규칙: 허용 오차(0.05m) 안 가장 가까운 원본만 · 한 원본은 한 번만 · 못 붙인 관은 속성 없음.
|
||||
화면 보관소는 관을 끌면(`move`) · 되돌리기로 옮기면(`follow`) 속성을 따라 옮김.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from common_util.common_util_drainage_pipes import CARRY_TOLERANCE_M, carry_indices
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
FACILITY = ROOT / "B05_Profile" / "B05_Profile_UI_Drainage_Facility.ts"
|
||||
CULVERT = ROOT / "B06_Section" / "B06_Section_Api_Culvert_Options.ts"
|
||||
|
||||
CASES = [
|
||||
([100.0, 200.0], [100.0, 200.0]), # 제자리
|
||||
([100.004], [100.0]), # 반올림 차이
|
||||
([95.0, 100.0], [100.0]), # 관 수 > 원본 수 — 앞 관이 BOX 를 가져가지 않음
|
||||
([150.37], [150.0]), # 허용 오차 밖
|
||||
([100.01, 100.02], [100.0]), # 한 원본은 한 번만
|
||||
([100.0], [99.97, 100.02]), # 허용 오차 안 가장 가까운 것
|
||||
([100.0, 100.0], [100.06, 100.04]), # 경계 — 0.06 은 밖
|
||||
([100.0], []), # 원본 없음
|
||||
]
|
||||
|
||||
|
||||
def _ts_block(body: str, head: str) -> str:
|
||||
"""최상위 선언 하나를 떼어 냄 — 상수는 한 줄 · 함수는 첫 칸 `}` 까지."""
|
||||
tail = r"[^\n]*;$" if head.startswith("const") else r"[\s\S]*?^\}$"
|
||||
found = re.search(rf"^export {head}{tail}", body, re.M)
|
||||
assert found, head
|
||||
return found.group(0).replace("export ", "")
|
||||
|
||||
|
||||
def _run_node(script: str) -> object:
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", script],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_서버_규칙():
|
||||
assert CARRY_TOLERANCE_M == 0.05
|
||||
assert [carry_indices(t, s) for t, s in CASES] == [
|
||||
[0, 1],
|
||||
[0],
|
||||
[None, 0],
|
||||
[None],
|
||||
[0, None],
|
||||
[1],
|
||||
[1, None],
|
||||
[None],
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_화면과_서버가_같은_답():
|
||||
body = FACILITY.read_text(encoding="utf-8")
|
||||
script = (
|
||||
_ts_block(body, "const CARRY_TOLERANCE_M")
|
||||
+ "\n"
|
||||
+ _ts_block(body, "function carryIndices")
|
||||
+ f"\nconst cases = {json.dumps(CASES)};"
|
||||
+ "\nconsole.log(JSON.stringify([CARRY_TOLERANCE_M,"
|
||||
+ " cases.map(([t, s]) => carryIndices(t, s))]));"
|
||||
)
|
||||
tolerance, picks = _run_node(script)
|
||||
assert tolerance == CARRY_TOLERANCE_M
|
||||
assert picks == [carry_indices(t, s) for t, s in CASES]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_화면_보관소_끌기_되돌리기_때_속성이_따라감():
|
||||
"""BOX(100) · 옵션 없는 관(80) — 관을 끌거나 되돌려도 BOX 속성은 BOX 관에만."""
|
||||
body = FACILITY.read_text(encoding="utf-8")
|
||||
script = (
|
||||
"type PipeFacility = string; type PipeSource = string; type DetailPipeInput = any;\n"
|
||||
"interface FacilityAttributes { facility: string; start_m?: number; end_m?: number;"
|
||||
" options?: Record<string, string | number> }\n"
|
||||
+ "\n".join(
|
||||
_ts_block(body, head)
|
||||
for head in (
|
||||
"const CARRY_TOLERANCE_M",
|
||||
"function carryIndices",
|
||||
"function createFacilityStore",
|
||||
)
|
||||
)
|
||||
+ "\n"
|
||||
+ re.search(r"^function hasExtras[\s\S]*?^\}$", body, re.M).group(0)
|
||||
+ """
|
||||
const store = createFacilityStore();
|
||||
store.replaceFromResponse([{ chainage_m: 80 }, { chainage_m: 100, facility: "box_culvert" }]);
|
||||
const kinds = (list) => store.attach(list.map((c) => ({ chainage_m: c, source: "user" })))
|
||||
.map((p) => p.facility ?? "-");
|
||||
const out = {};
|
||||
out.관을_BOX_곁으로 = kinds([99, 100]); // 옵션 없는 관을 BOX 곁으로 끌기(보관소 변화 없음)
|
||||
store.move(100, 130); // BOX 를 끌기
|
||||
out.BOX_끌기 = kinds([80, 130]);
|
||||
store.follow([80, 130], [80, 100]); // 되돌리기 — BOX 가 제자리로
|
||||
out.되돌리기 = kinds([80, 100]);
|
||||
store.follow([80, 100], [81, 120]); // 둘이 한꺼번에 바뀜 — 옮기지 않음
|
||||
out.둘_바뀜 = kinds([81, 120]);
|
||||
console.log(JSON.stringify(out));
|
||||
"""
|
||||
)
|
||||
assert _run_node(script) == {
|
||||
"관을_BOX_곁으로": ["-", "box_culvert"],
|
||||
"BOX_끌기": ["-", "box_culvert"],
|
||||
"되돌리기": ["-", "box_culvert"],
|
||||
"둘_바뀜": ["-", "-"],
|
||||
}
|
||||
|
||||
|
||||
def test_옛_관_이동_예약은_저장_때_안_씀():
|
||||
body = CULVERT.read_text(encoding="utf-8")
|
||||
assert "readMoves" not in body and "movedTo" not in body and "moveKey" not in body
|
||||
@@ -0,0 +1,138 @@
|
||||
"""측점 평면 틀 거울 — 파이썬(`B05_Profile_Engine_Sections_Core`) ↔ TS(`_RouteEdit_Frame.ts`).
|
||||
|
||||
짝: `_interpolate_xy`·`_tangent_at`(측점 중심 · 좌측 축) ↔ `stationFrameAt`.
|
||||
계획노선 편집 창이 노선을 끄는 동안 그리는 횡단 반폭 선이 [확인] 뒤 서버가 낸 측점 틀과
|
||||
같은 자리 · 같은 방향이어야 함.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile import B05_Profile_Engine_Sections_Core as core # noqa: E402
|
||||
from config.config_system import SECTION_LONG_SAMPLE_INTERVAL_M # noqa: E402
|
||||
|
||||
TS_FILE = ROOT / "B05_Profile" / "B05_Profile_UI_RouteEdit_Frame.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
_TOLERANCE = 1e-9
|
||||
|
||||
|
||||
def _arc(cx: float, cy: float, radius: float, start: float, end: float, count: int) -> list:
|
||||
return [
|
||||
[cx + radius * math.cos(angle), cy + radius * math.sin(angle)]
|
||||
for angle in np.linspace(start, end, count)
|
||||
]
|
||||
|
||||
|
||||
# 직선 · 꺾임 · 원호 · 겹친 정점 · 되꺾임 · 시종점 · 노선 밖 누가거리
|
||||
CASES = [
|
||||
{"line": [[0, 0], [100, 0]], "chainages": [0, 20, 40, 100]},
|
||||
{"line": [[0, 0], [40, 0], [40, 0], [40, 30], [10, 70]], "chainages": [0, 20, 40, 60, 99.9]},
|
||||
{"line": _arc(500, 500, 30, 0.2, 2.9, 41), "chainages": [0, 13.7, 20, 40, 60, 80]},
|
||||
{"line": [[10, 10], [60, 12], [15, 14], [70, 40]], "chainages": [20, 50.5, 51, 120, 999]},
|
||||
{"line": [[0, 0], [0.4, 0.3], [1.0, 0.1]], "chainages": [0, 0.5, 1.2]},
|
||||
]
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
stationFrameAt,
|
||||
SECTION_LONG_SAMPLE_INTERVAL_M,
|
||||
} from "./B05_Profile_UI_RouteEdit_Frame.js";
|
||||
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const out = {
|
||||
interval: SECTION_LONG_SAMPLE_INTERVAL_M,
|
||||
frames: cases.map((item) => item.chainages.map((c) => stationFrameAt(item.line, c))),
|
||||
};
|
||||
writeFileSync(process.argv[3], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def _python_frame(line: list, chainage: float) -> dict:
|
||||
points, route_chainage = core._clean_polyline(np.asarray(line, dtype=np.float64))
|
||||
total = float(route_chainage[-1])
|
||||
target = min(float(chainage), total)
|
||||
origin = core._interpolate_xy(points, route_chainage, np.array([chainage]))[0]
|
||||
tangent = core._tangent_at(
|
||||
points, route_chainage, target, max(SECTION_LONG_SAMPLE_INTERVAL_M, 0.5)
|
||||
)
|
||||
return {"origin": [float(origin[0]), float(origin[1])], "left": [-tangent[1], tangent[0]]}
|
||||
|
||||
|
||||
def _ts_frames(tmp_path: Path) -> dict:
|
||||
out = tmp_path / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(TS_FILE),
|
||||
"--outDir",
|
||||
str(out),
|
||||
"--module",
|
||||
"esnext",
|
||||
"--target",
|
||||
"es2022",
|
||||
"--moduleResolution",
|
||||
"bundler",
|
||||
"--ignoreConfig",
|
||||
],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.glob("*.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")
|
||||
payload = tmp_path / "input.json"
|
||||
result = tmp_path / "output.json"
|
||||
payload.write_text(json.dumps(CASES), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(payload), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not TSC.is_file(), reason="프론트엔드 의존성 미설치")
|
||||
def test_측점_틀이_두_쪽에서_같다(tmp_path: Path) -> None:
|
||||
actual = _ts_frames(tmp_path)
|
||||
assert actual["interval"] == SECTION_LONG_SAMPLE_INTERVAL_M
|
||||
problems: list[str] = []
|
||||
checked = 0
|
||||
for index, case in enumerate(CASES):
|
||||
for chainage, frame in zip(case["chainages"], actual["frames"][index], strict=True):
|
||||
# 노선 밖 누가거리는 파이썬이 끝값으로 붙인다 — TS 도 같아야 함.
|
||||
expected = _python_frame(case["line"], chainage)
|
||||
assert frame is not None, f"사례 {index} · {chainage}m: TS 가 틀을 못 냄"
|
||||
for key in ("origin", "left"):
|
||||
for axis in (0, 1):
|
||||
if abs(frame[key][axis] - expected[key][axis]) > _TOLERANCE:
|
||||
problems.append(
|
||||
f"사례 {index} · {chainage}m · {key}[{axis}]: "
|
||||
f"{frame[key][axis]!r} vs {expected[key][axis]!r}"
|
||||
)
|
||||
checked += 1
|
||||
assert checked >= 20
|
||||
assert not problems, "짝이 갈림:\n" + "\n".join(problems[:40])
|
||||
|
||||
|
||||
def test_짝_머리글이_서로를_가리킨다() -> None:
|
||||
assert "B05_Profile_Engine_Sections_Core.py" in TS_FILE.read_text(encoding="utf-8")
|
||||
assert "B05_Profile_UI_RouteEdit_Frame.ts" in (core.__doc__ or "")
|
||||
@@ -0,0 +1,98 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""계획노선 편집 창 — **초기노선 갈래**(계획서 20) 셈 회귀검증.
|
||||
|
||||
비중 슬라이더 셋은 합 100 을 지키고(하나를 옮기면 나머지가 비율대로), 서버 답은 노드 목록 꼴과
|
||||
`/route/plan` 꼴(노드+곡선) 둘 다 편집값 한 벌로 펴야 한다. 비교값 · 기준값은 서버 값을 글로만
|
||||
보이고, 확정 표시가 없으면 「사용자 확정 대기」를 붙인다. 마지막 초기값은 결과 통 프로젝트 칸.
|
||||
|
||||
Node 헬퍼(`helper_b05_routeedit_initial.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_routeedit_initial.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_비중은_늘_합_100_정수(results):
|
||||
for weights in results["moves"]:
|
||||
assert list(weights) == ["expected", "earthwork", "slope"]
|
||||
assert sum(weights.values()) == 100
|
||||
assert all(isinstance(v, int) and 0 <= v <= 100 for v in weights.values())
|
||||
|
||||
|
||||
def test_비중은_나머지를_지금_비율대로_나눔(results):
|
||||
moves = results["moves"]
|
||||
assert moves[0] == {"expected": 70, "earthwork": 15, "slope": 15}
|
||||
assert moves[1] == {"expected": 40, "earthwork": 40, "slope": 20}
|
||||
# 나머지가 모두 0 이면 반씩.
|
||||
assert moves[2] == {"expected": 60, "earthwork": 20, "slope": 20}
|
||||
assert moves[3] == {"expected": 0, "earthwork": 0, "slope": 100}
|
||||
assert moves[5]["slope"] == 0
|
||||
|
||||
|
||||
def test_노드_목록_꼴을_편집값으로(results):
|
||||
snap = results["plain"]["snapshot"]
|
||||
assert snap["planned"] == [[0, 0], [50, 10], [100, 0]]
|
||||
assert snap["curveOn"] == [True, True, False]
|
||||
assert snap["curveRadius"] == [None, 30, None]
|
||||
assert snap["curveLock"] == [None, None, None]
|
||||
assert snap["apexLock"] == [False, False, False]
|
||||
assert snap["picked"] == -1
|
||||
|
||||
|
||||
def test_노선계획_꼴도_같은_규칙으로(results):
|
||||
shape = results["planShape"]
|
||||
snap = shape["snapshot"]
|
||||
assert snap["planned"] == [[0, 0], [40, 30], [90, 30]]
|
||||
assert snap["curveOn"] == [False, True, False]
|
||||
assert snap["curveRadius"] == [None, 25, None]
|
||||
assert shape["weights"] == {"expected": 60, "earthwork": 20, "slope": 20}
|
||||
assert shape["metrics"] is None and shape["criteria"] is None
|
||||
|
||||
|
||||
def test_노드가_모자라면_싣지_않음(results):
|
||||
assert "모자랍니다" in results["tooShort"]
|
||||
|
||||
|
||||
def test_비교값과_기준값_글(results):
|
||||
labels = results["labels"]
|
||||
assert labels["follow"] == "예상노선 추종"
|
||||
assert labels["weighted"] == "비중 계산 60/20/20"
|
||||
assert labels["metrics"] == (
|
||||
"연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥"
|
||||
" · 기준 초과 2구간 · 이격 3.3m"
|
||||
)
|
||||
assert "로직을 누르면" in labels["metricsNone"]
|
||||
assert labels["criteria"] == (
|
||||
"공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m (후보 · 사용자 확정 대기)"
|
||||
)
|
||||
assert labels["criteriaConfirmed"].endswith("5.0m")
|
||||
assert labels["criteriaFallback"] == (
|
||||
"공통 제약 종단기울기 ≤ — · 최소 R 15m · 계류 이격 ≥ — (후보 · 사용자 확정 대기)"
|
||||
)
|
||||
|
||||
|
||||
def test_캐시는_결과_통_프로젝트_칸(results):
|
||||
assert results["keys"] == ["aislo:result:routeedit-initial:p-1"]
|
||||
assert results["readBack"]["logic"] == "earthwork"
|
||||
assert results["readBack"]["snapshot"]["planned"] == [[0, 0], [50, 10], [100, 0]]
|
||||
assert results["other"] is None
|
||||
assert results["badLogic"] is None
|
||||
assert results["cleared"] is None
|
||||
@@ -7,8 +7,8 @@ B05 는 관 목록 전체를 세션 스냅샷(`pipes`)으로 담고, B06 은 바
|
||||
여기서 지키는 것은 둘이다.
|
||||
① 저장기(`createCulvertOptionWriter`)에는 **관을 넣고 빼는 길이 없다** — 남아 있으면
|
||||
관 목록을 담는 자리가 다시 둘이 된다.
|
||||
② 옛 세션에 남아 있던 **이동 예약은 저장 때 흘려보낸다** — 합치기 전에 만들어 둔
|
||||
예약이 조용히 사라지면 안 된다.
|
||||
② 옛 세션에 남아 있던 **이동 예약은 저장 때 안 씀** — 관 위치는 스냅샷으로만 옮김
|
||||
(적용하면 관이 이웃 측점으로 튀어 BOX · 세월교 속성이 넘어감).
|
||||
|
||||
실제 화면 코드를 그대로 컴파일해 Node 로 돌린다(다단 하한 시험과 같은 방식).
|
||||
"""
|
||||
@@ -62,21 +62,17 @@ global.window = {
|
||||
};
|
||||
|
||||
const api = require(process.argv[3]);
|
||||
const writer = api.createCulvertOptionWriter(
|
||||
() => "p1",
|
||||
() => "opt",
|
||||
undefined,
|
||||
() => "move",
|
||||
);
|
||||
const writer = api.createCulvertOptionWriter(() => "p1", () => "opt", undefined);
|
||||
|
||||
// ① 관을 넣고 빼는 길이 저장기에 남아 있으면 안 된다.
|
||||
const methods = Object.keys(writer).sort();
|
||||
|
||||
// ② 합치기 전 세션에 남아 있던 이동 예약은 저장 때 흘려보낸다.
|
||||
// ② 옛 세션의 이동 예약은 남아 있어도 안 씀 — 구간값만 제 측점에 나감.
|
||||
store.set("move", JSON.stringify({ "120.00": 135 }));
|
||||
store.set("opt", JSON.stringify({ "120.00": { pipe_count: 2 } }));
|
||||
|
||||
api
|
||||
.flushCulvertOptions("p1", "opt", "move")
|
||||
.flushCulvertOptions("p1", "opt")
|
||||
.then(() => {
|
||||
writeFileSync(
|
||||
process.argv[2],
|
||||
@@ -134,6 +130,5 @@ def test_관_목록을_담는_자리는_하나뿐이다(tmp_path: Path) -> None:
|
||||
# ① 저장기에는 구간값 예약과 내보내기만 있다 — 관을 넣고 빼는 길은 스냅샷 한 곳뿐이다.
|
||||
assert result["methods"] == ["flush", "queue"]
|
||||
|
||||
# ② 옛 세션의 이동 예약은 저장 때 반영되고 비워진다.
|
||||
assert result["savedChainages"] == [135, 200]
|
||||
assert result["moveLeft"] is None
|
||||
# ② 옛 세션의 이동 예약은 저장 때 안 씀 — 관은 제 측점 그대로.
|
||||
assert result["savedChainages"] == [120, 200]
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""초기 계획노선 갈래(PLAN 20장) — `B05_Profile_Engine_RouteInitial` 합성 지형 시험.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import (
|
||||
MODES,
|
||||
PENDING,
|
||||
STREAM_OFFSET_M,
|
||||
Terrain,
|
||||
generate_initial_route,
|
||||
grade_line,
|
||||
resolve_weights,
|
||||
stream_lines,
|
||||
)
|
||||
|
||||
GRADE = 0.14
|
||||
RADIUS = 12.0
|
||||
|
||||
|
||||
def _terrain(z_of) -> Terrain:
|
||||
x = np.arange(0.0, 802.0, 2.0)
|
||||
y = np.arange(0.0, 402.0, 2.0)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
return Terrain(x, y, z_of(xx, yy), np.ones(xx.shape, dtype=bool))
|
||||
|
||||
|
||||
def _line(y: float) -> list[tuple[float, float]]:
|
||||
return [(float(x), y) for x in np.arange(50.0, 752.0, 4.0)]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hill() -> Terrain:
|
||||
"""가운데 솟은 언덕(높이 40 m) — 곧은 예상노선이 꼭대기를 넘는다."""
|
||||
return _terrain(
|
||||
lambda xx, yy: 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2)
|
||||
)
|
||||
|
||||
|
||||
def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict:
|
||||
return generate_initial_route(
|
||||
_line(200.0),
|
||||
terrain,
|
||||
list(streams),
|
||||
mode=mode,
|
||||
weights=weights,
|
||||
max_grade=GRADE,
|
||||
min_radius_m=RADIUS,
|
||||
)
|
||||
|
||||
|
||||
def test_follow_keeps_expected_route(hill: Terrain) -> None:
|
||||
result = _run(hill, "follow")
|
||||
assert result["metrics"]["offset_max_m"] < 1.0
|
||||
assert result["weights"] == {"expected": 100.0, "earthwork": 0.0, "grade": 0.0}
|
||||
assert result["criteria"]["pending"] == list(PENDING)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["earthwork", "gentle", "weighted"])
|
||||
def test_search_modes_beat_follow_on_hill(hill: Terrain, mode: str) -> None:
|
||||
follow = _run(hill, "follow")["metrics"]
|
||||
result = _run(hill, mode)
|
||||
metrics = result["metrics"]
|
||||
# 언덕을 돌아가 기울기 · 토공이 준다
|
||||
assert metrics["max_grade_pct"] < follow["max_grade_pct"]
|
||||
assert metrics["cut_m3"] + metrics["fill_m3"] < follow["cut_m3"] + follow["fill_m3"]
|
||||
assert metrics["over_limit"]["grade"] <= follow["over_limit"]["grade"]
|
||||
# 기점 · 종점은 옮기지 않고 복도(100 m) 밖으로 안 나감
|
||||
assert result["planned"][0] == [50.0, 200.0]
|
||||
assert result["planned"][-1] == [750.0, 200.0]
|
||||
assert metrics["offset_max_m"] <= result["criteria"]["corridor_m"] + 4.0
|
||||
assert result["nodes"] and result["curves"] is not None
|
||||
|
||||
|
||||
def test_weighted_expected_share_pulls_route_back(hill: Terrain) -> None:
|
||||
near = _run(hill, "weighted", weights={"expected": 90, "earthwork": 5, "grade": 5})
|
||||
far = _run(hill, "weighted", weights={"expected": 10, "earthwork": 45, "grade": 45})
|
||||
assert near["metrics"]["offset_avg_m"] < far["metrics"]["offset_avg_m"]
|
||||
|
||||
|
||||
def test_stream_offset_is_common_constraint() -> None:
|
||||
"""평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 추종은 초과 1 · 탐색은 비켜 감."""
|
||||
flat = _terrain(lambda xx, yy: np.zeros_like(xx))
|
||||
stream = [[(0.0, 220.0), (800.0, 220.0)]]
|
||||
follow = _run(flat, "follow", streams=stream)["metrics"]
|
||||
moved = _run(flat, "earthwork", streams=stream)["metrics"]
|
||||
assert follow["over_limit"]["stream"] == 1
|
||||
assert moved["over_limit"]["stream"] == 0
|
||||
assert moved["offset_avg_m"] > STREAM_OFFSET_M - 20.0 - 10.0
|
||||
|
||||
|
||||
def test_stream_crossing_is_allowed() -> None:
|
||||
flat = _terrain(lambda xx, yy: np.zeros_like(xx))
|
||||
crossing = [[(400.0, 0.0), (400.0, 400.0)]]
|
||||
assert _run(flat, "follow", streams=crossing)["metrics"]["over_limit"]["stream"] == 0
|
||||
|
||||
|
||||
def test_grade_line_stays_within_limit() -> None:
|
||||
ground = np.array([0.0, 0.0, 5.0, 10.0, 10.0, 2.0, 0.0])
|
||||
line = grade_line(ground, 2.0, GRADE)
|
||||
assert np.all(np.abs(np.diff(line)) <= GRADE * 2.0 + 1e-9)
|
||||
|
||||
|
||||
def test_weights_and_stream_filter() -> None:
|
||||
assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}
|
||||
assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0
|
||||
assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"}
|
||||
features = [
|
||||
{
|
||||
"properties": {"구분": "세류"},
|
||||
"geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]},
|
||||
},
|
||||
{
|
||||
"properties": {"구분": "소하천"},
|
||||
"geometry": {"type": "LineString", "coordinates": [[0, 0], [1, 1]]},
|
||||
},
|
||||
]
|
||||
assert len(stream_lines(features)) == 1
|
||||
|
||||
|
||||
def test_unknown_mode_rejected(hill: Terrain) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
_run(hill, "shortest")
|
||||
Reference in New Issue
Block a user