Merge remote-tracking branch 'origin/dev' into sub_laptop_6

This commit is contained in:
2026-09-29 10:03:44 +09:00
36 changed files with 2826 additions and 171 deletions
+3
View File
@@ -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) {
+8 -2
View File
@@ -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
+101 -8
View File
@@ -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}
+50 -17
View File
@@ -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();
},
+7 -1
View File
@@ -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();
// 관을 옮겼으면 담당 구간이 달라진다 — 세부유역을 즉시 다시 나눈다.
+2 -6
View File
@@ -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에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래
+47 -48
View File
@@ -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;
+17 -6
View File
@@ -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];
+38 -10
View File
@@ -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;
+51 -3
View File
@@ -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;
+7 -32
View File
@@ -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;
+37 -9
View File
@@ -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)
+128
View File
@@ -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]
+126
View File
@@ -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")