Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_8
This commit is contained in:
@@ -12,6 +12,8 @@
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
|
||||
import type { StageContext } from "../A00_Common/design/design_pipeline_types";
|
||||
import type { MassHaulStageOutput } from "./logic/B05_Profile_MassHaul_Stage";
|
||||
|
||||
/** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다.
|
||||
* `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */
|
||||
@@ -24,6 +26,84 @@ const TIMEOUT_MS = 20000;
|
||||
/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */
|
||||
const KEPT_PLANS = 8;
|
||||
|
||||
/** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입 · 묶음 단계가 함께 씀. */
|
||||
const kept = new Map<string, HaulPlanPayload>();
|
||||
|
||||
/**
|
||||
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
|
||||
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
|
||||
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
|
||||
*/
|
||||
function keyOf(result: unknown): string {
|
||||
const points = (result as { points?: unknown[] })?.points;
|
||||
if (!Array.isArray(points) || !points.length) return "empty";
|
||||
return JSON.stringify(points);
|
||||
}
|
||||
|
||||
const keptKey = (projectId: string, route: number, key: string): string =>
|
||||
`${projectId}:${route}|${key}`;
|
||||
|
||||
function keep(at: string, plan: HaulPlanPayload): void {
|
||||
kept.delete(at);
|
||||
kept.set(at, plan);
|
||||
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
|
||||
}
|
||||
|
||||
/** 서버에 배분을 물음 — 받으면 기억 · 못 받으면(실패 · 끊김) undefined. `signal` 이 끊으면 함께 끊음. */
|
||||
async function postHaulPlan(
|
||||
projectId: string,
|
||||
route: number,
|
||||
result: unknown,
|
||||
key: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<HaulPlanPayload | undefined> {
|
||||
if (signal?.aborted) return undefined;
|
||||
const controller = new AbortController();
|
||||
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const relay = () => controller.abort();
|
||||
signal?.addEventListener("abort", relay);
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result }),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) return undefined;
|
||||
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
|
||||
const plan = payload.haul_plan ?? null;
|
||||
keep(keptKey(projectId, route, key), plan);
|
||||
return plan;
|
||||
} catch {
|
||||
return undefined; // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
|
||||
} finally {
|
||||
window.clearTimeout(abort);
|
||||
signal?.removeEventListener("abort", relay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계산 묶음 `haul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — masshaul 의 횡단 기준 결과를 서버
|
||||
* Node 로 배분 · 같은 열쇠면 안 물음 · 새 조작이 오면 `ctx.signal` 로 끊김(옛 번호 결과는 묶음이 버림).
|
||||
* 못 받으면 앞 배분(`ctx.prev`) 그대로.
|
||||
*/
|
||||
export function createHaulStage(projectId: string, routeId: () => number | undefined) {
|
||||
return async (ctx: StageContext<"haul">): Promise<HaulPlanPayload> => {
|
||||
const result = (ctx.out.masshaul as MassHaulStageOutput | null)?.haulInput;
|
||||
const route = routeId();
|
||||
if (!result || !route) return (ctx.prev as HaulPlanPayload) ?? null;
|
||||
const key = keyOf(result);
|
||||
const at = keptKey(projectId, route, key);
|
||||
if (kept.has(at)) return kept.get(at) ?? null;
|
||||
const plan = await postHaulPlan(projectId, route, result, key, ctx.signal);
|
||||
return plan === undefined ? ((ctx.prev as HaulPlanPayload) ?? null) : plan;
|
||||
};
|
||||
}
|
||||
|
||||
export interface HaulPlanPrefetch {
|
||||
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
|
||||
schedule: (result: unknown) => void;
|
||||
@@ -50,19 +130,6 @@ export function createHaulPlanPrefetch(
|
||||
let sentKey: string | null = null;
|
||||
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
|
||||
let applying = false;
|
||||
/** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */
|
||||
const kept = new Map<string, HaulPlanPayload>();
|
||||
|
||||
/**
|
||||
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
|
||||
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
|
||||
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
|
||||
*/
|
||||
function keyOf(result: unknown): string {
|
||||
const points = (result as { points?: unknown[] })?.points;
|
||||
if (!Array.isArray(points) || !points.length) return "empty";
|
||||
return JSON.stringify(points);
|
||||
}
|
||||
|
||||
/** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */
|
||||
function forget(seq: number): void {
|
||||
@@ -72,41 +139,19 @@ export function createHaulPlanPrefetch(
|
||||
async function send(result: unknown, seq: number, key: string): Promise<void> {
|
||||
const route = routeId();
|
||||
if (!route) return forget(seq);
|
||||
const controller = new AbortController();
|
||||
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
|
||||
const got = await postHaulPlan(projectId, route, result, key);
|
||||
if (got === undefined) return forget(seq);
|
||||
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
|
||||
if (seq !== sequence) return;
|
||||
plan = got;
|
||||
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
|
||||
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
|
||||
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
|
||||
applying = true;
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ result }),
|
||||
signal: controller.signal,
|
||||
},
|
||||
);
|
||||
if (!response.ok) return forget(seq);
|
||||
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
|
||||
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
|
||||
if (seq !== sequence) return;
|
||||
plan = payload.haul_plan ?? null;
|
||||
kept.delete(key);
|
||||
kept.set(key, plan);
|
||||
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
|
||||
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
|
||||
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
|
||||
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
|
||||
applying = true;
|
||||
try {
|
||||
onReady();
|
||||
} finally {
|
||||
applying = false;
|
||||
}
|
||||
} catch {
|
||||
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
|
||||
forget(seq);
|
||||
onReady();
|
||||
} finally {
|
||||
window.clearTimeout(abort);
|
||||
applying = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +166,8 @@ export function createHaulPlanPrefetch(
|
||||
const seq = sequence;
|
||||
window.clearTimeout(timer);
|
||||
// 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다.
|
||||
const known = kept.get(key);
|
||||
const route = routeId();
|
||||
const known = route ? kept.get(keptKey(projectId, route, key)) : undefined;
|
||||
if (known !== undefined) return void (plan = known);
|
||||
timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS);
|
||||
},
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
"""노선 저장 서버 길(346-3) — 노선 초안을 정본으로: 노선 → 풀기 → 관 · 구조물 따라감 →
|
||||
종 · 횡단 한 번 → 계획고 옮김 → 횡단 이월 → B06 확정(서버 재계산 · 3D). 배수유역은 체인에서 뗌.
|
||||
|
||||
API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan_route`):
|
||||
요청 `RouteSaveRequest` = 옛 칸(`vertices` · `initial` · `balanced_profile` · `terrain` ·
|
||||
`station_snap`) +
|
||||
`draft` — 계약 `RouteDraft`(`A00_Common/design/design_pipeline_types.ts`) 그대로
|
||||
`{rev, nodes: [[x, y]…], curveOn: [bool…], curveRadius: [m | null…], curveLock, curveArc,
|
||||
stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것.
|
||||
`as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로).
|
||||
응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial,
|
||||
initial_refreshed, pipes: {kept, crossing, concave}, structures: {moved, far}, cross_dropped,
|
||||
drainage_stale: true, seconds}`.
|
||||
오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) ·
|
||||
409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을
|
||||
옛것으로 되살린 뒤).
|
||||
|
||||
규칙:
|
||||
- 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함
|
||||
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
|
||||
- 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴
|
||||
(`B05_Profile_Engine_Grade_Carry`) · 추천 「종단 반영」 켬이면 균형 종단
|
||||
(`apply_profile_target`).
|
||||
- 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import (
|
||||
add_crossing_pipes,
|
||||
carry_cross_inputs,
|
||||
concave_chainages,
|
||||
fill_concave_pipes,
|
||||
follow_pipes,
|
||||
station_keys,
|
||||
vertices_of,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import RouteReplanRequest, RouteVertexInput
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RouteDraftInput(BaseModel):
|
||||
"""노선 초안 — 계약 `RouteDraft` 모양 그대로(칸 이름 camelCase)."""
|
||||
|
||||
rev: int = 0
|
||||
nodes: list[tuple[float, float]] = Field(default_factory=list)
|
||||
curveOn: list[bool] = Field(default_factory=list) # noqa: N815 — 계약 칸 이름
|
||||
curveRadius: list[float | None] = Field(default_factory=list) # noqa: N815
|
||||
curveLock: list[str | None] = Field(default_factory=list) # noqa: N815
|
||||
curveArc: list[float | None] = Field(default_factory=list) # noqa: N815
|
||||
stationSnap: bool = False # noqa: N815
|
||||
terrain: Literal["las", "sheet"] | None = None
|
||||
|
||||
|
||||
class RouteSaveRequest(RouteReplanRequest):
|
||||
"""노선 저장 요청 — 옛 [노선 반영] 칸 + 초안(`draft`) · 추천 [적용](`as_initial`)."""
|
||||
|
||||
draft: RouteDraftInput | None = None
|
||||
as_initial: bool = False
|
||||
|
||||
def resolved(self) -> RouteReplanRequest:
|
||||
"""초안이 오면 노드 · 곡선 · 스냅 · 지형을 옛 칸으로 편다. 칸 길이가 어긋나면 ValueError."""
|
||||
if self.draft is None:
|
||||
return self
|
||||
draft = self.draft
|
||||
count = len(draft.nodes)
|
||||
for name in ("curveOn", "curveRadius"):
|
||||
values = getattr(draft, name)
|
||||
if values and len(values) != count:
|
||||
raise ValueError(
|
||||
f"노선 초안 {name} 길이({len(values)})가 노드 수({count})와 다릅니다."
|
||||
)
|
||||
vertices = [
|
||||
RouteVertexInput(
|
||||
x=x,
|
||||
y=y,
|
||||
curve=draft.curveOn[index] if draft.curveOn else True,
|
||||
radius_m=draft.curveRadius[index] if draft.curveRadius else None,
|
||||
)
|
||||
for index, (x, y) in enumerate(draft.nodes)
|
||||
]
|
||||
return self.model_copy(
|
||||
update={
|
||||
"vertices": vertices,
|
||||
"station_snap": draft.stationSnap,
|
||||
"terrain": draft.terrain,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class _Backup:
|
||||
"""체인이 고치는 파일을 손에 쥠 — 실패하면 그대로 되돌림(노선 · 관 · 구조물 · 종횡단)."""
|
||||
|
||||
def __init__(self, project_root: Path, paths: list[Path], folders: list[Path]) -> None:
|
||||
self.held = tempfile.mkdtemp(prefix="route_save_", dir=str(project_root))
|
||||
self.files = {path: path.read_bytes() if path.is_file() else None for path in paths}
|
||||
self.folders: dict[Path, Path | None] = {}
|
||||
for index, folder in enumerate(folders):
|
||||
copy = Path(self.held) / f"dir{index}"
|
||||
self.folders[folder] = shutil.copytree(folder, copy) if folder.is_dir() else None
|
||||
|
||||
def restore(self) -> None:
|
||||
for path, saved in self.files.items():
|
||||
if saved is None:
|
||||
path.unlink(missing_ok=True)
|
||||
else:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(saved)
|
||||
for folder, copy in self.folders.items():
|
||||
shutil.rmtree(folder, ignore_errors=True)
|
||||
if copy is not None:
|
||||
shutil.copytree(copy, folder)
|
||||
|
||||
def drop(self) -> None:
|
||||
shutil.rmtree(self.held, ignore_errors=True)
|
||||
|
||||
|
||||
class _Fail(RuntimeError):
|
||||
"""체인 한 단계 실패 — 사유를 그대로 화면까지(까닭을 덮지 않음)."""
|
||||
|
||||
|
||||
def _reason(response: JSONResponse) -> str:
|
||||
try:
|
||||
return bytes(response.body).decode("utf-8", "replace")[:300]
|
||||
except Exception: # noqa: BLE001 — 사유는 로그 · 응답용
|
||||
return f"status={response.status_code}"
|
||||
|
||||
|
||||
def _read_json(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
|
||||
|
||||
def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]:
|
||||
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
|
||||
|
||||
if not route_data_path:
|
||||
return []
|
||||
try:
|
||||
return [[x, y] for x, y, _ in _load_route_polyline(project_root, route_data_path)]
|
||||
except (OSError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def _ground_along(project_root: Path, selection: dict[str, Any], vertices: list) -> tuple:
|
||||
"""새 노선 지반 종단(2 m) — 오목 지점 찾기. 지표면을 못 열면 빈 배열(보충 건너뜀)."""
|
||||
from shapely.geometry import LineString
|
||||
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import CONCAVE_STEP_M
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
|
||||
try:
|
||||
sampler = build_surface_sampler(
|
||||
project_root / "B04_PreProcess" / "models",
|
||||
str(selection.get("source_filter")),
|
||||
str(selection.get("method") or "dtm"),
|
||||
bool(selection.get("smooth")),
|
||||
)
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError):
|
||||
logger.warning("노선 저장: 지표면을 못 열어 오목 지점 보충을 건너뜀")
|
||||
return np.zeros(0), np.zeros(0)
|
||||
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
chainage = np.arange(0.0, line.length + 1e-9, CONCAVE_STEP_M)
|
||||
xy = np.array([line.interpolate(float(s)).coords[0] for s in chainage], dtype=np.float64)
|
||||
z, valid = sampler.sample_xy(xy)
|
||||
return chainage[valid], np.asarray(z, dtype=np.float64)[valid]
|
||||
|
||||
|
||||
async def _place_pipes(
|
||||
project_id: UUID,
|
||||
project_root: Path,
|
||||
stored_path: str,
|
||||
selection: dict[str, Any],
|
||||
old_pipes: list,
|
||||
old_xy: list[list[float]],
|
||||
points: list[dict[str, float]],
|
||||
) -> tuple[list, dict[str, int]]:
|
||||
"""관 따라감 → 새 교차점 기본 관 → 오목 지점 보충 → 정본 저장(새 노선 지문)."""
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
|
||||
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
|
||||
from common_util.common_util_drainage_pipes import route_signature, save_pipe_points
|
||||
|
||||
prepared = await _prepare(project_id)
|
||||
if isinstance(prepared, JSONResponse): # 도엽 · 노선 파일 없음 — 따라감만
|
||||
vertices, streams = vertices_of([(p["x"], p["y"]) for p in points]), []
|
||||
else:
|
||||
vertices, streams = prepared["vertices"], prepared["streams"]
|
||||
pairs = follow_pipes(old_pipes, vertices)
|
||||
total = float(vertices[-1].chainage_m)
|
||||
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
|
||||
chainage, ground = await asyncio.to_thread(_ground_along, project_root, selection, vertices)
|
||||
concave = fill_concave_pipes(pairs, concave_chainages(chainage, ground), vertices, old_xy)
|
||||
await asyncio.to_thread(
|
||||
save_pipe_points,
|
||||
stored_path,
|
||||
route_signature(vertices),
|
||||
[pipe for _, pipe in pairs],
|
||||
vertices,
|
||||
)
|
||||
return pairs, {"kept": len(old_pipes), "crossing": crossing, "concave": concave}
|
||||
|
||||
|
||||
async def _carry_profile(
|
||||
project_root: Path,
|
||||
relative: str,
|
||||
route_id: int,
|
||||
old_alignment: dict[str, Any],
|
||||
old_xy: list[list[float]],
|
||||
new_xy: list[list[float]],
|
||||
) -> None:
|
||||
"""바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 · 종단곡선 R 은 길이 차만큼 밀어 지킴."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_profile_edits, changed_span
|
||||
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
|
||||
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
if not old_alignment.get("base_pvi"):
|
||||
return # 옛 선형 없음 — 새 지반 첫 계획선 그대로
|
||||
path = project_root / relative
|
||||
longitudinal = _read_json(path)
|
||||
alignment = longitudinal.get("profile_alignment") or {}
|
||||
span = changed_span([tuple(p) for p in old_xy], [tuple(p) for p in new_xy])
|
||||
if span is None: # 같은 노선 — 옛 계획선 통째
|
||||
base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {}
|
||||
else:
|
||||
base, edits = carry_profile_edits(
|
||||
old_alignment["base_pvi"], old_alignment.get("edits"), span, alignment["base_pvi"]
|
||||
)
|
||||
alignment["base_pvi"] = base
|
||||
await asyncio.to_thread(atomic_write_json, path, longitudinal)
|
||||
_alignment, summary, _dropped = await asyncio.to_thread(
|
||||
_apply_alignment_edits, project_root, relative, edits
|
||||
)
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
await update_longitudinal_grade_summary(
|
||||
connection, route_id=route_id, grade_summary=summary
|
||||
)
|
||||
await connection.commit()
|
||||
|
||||
|
||||
async def _carry_cross(
|
||||
project_id: UUID,
|
||||
project_root: Path,
|
||||
route_id: int,
|
||||
relative: str,
|
||||
old_long: dict[str, Any],
|
||||
old_designs: list[dict[str, Any]],
|
||||
old_pipes: list,
|
||||
old_structures: list,
|
||||
pairs: list,
|
||||
interval_m: float,
|
||||
standard: dict[str, Any] | None,
|
||||
) -> int:
|
||||
"""측점 열쇠로 옛 횡단 입력을 새 측점에 얹고 전 측점을 새 계획고로 셈 · 버린 수 돌려줌."""
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
CARRY_INPUT_KEYS,
|
||||
STRUCTURE_ADJUST_KEYS,
|
||||
compute_default_designs,
|
||||
)
|
||||
|
||||
new_long = _read_json(project_root / relative)
|
||||
_, new_structures = load_structures(str(project_root))
|
||||
old_keys = station_keys(
|
||||
old_long.get("stations") or [], list(enumerate(old_pipes)), old_structures, interval_m
|
||||
)
|
||||
new_keys = station_keys(new_long.get("stations") or [], pairs, new_structures, interval_m)
|
||||
designs = {
|
||||
round(float(row["chainage_m"]), 3): row["design"]
|
||||
for row in old_designs
|
||||
if isinstance(row.get("design"), dict)
|
||||
}
|
||||
inputs, adjusts, dropped = carry_cross_inputs(
|
||||
old_keys, new_keys, designs, CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS
|
||||
)
|
||||
chainages = await asyncio.to_thread(_rowless_station_chainages, project_root, relative, [])
|
||||
entries = await asyncio.to_thread(
|
||||
compute_default_designs, project_root, relative, chainages, standard, inputs
|
||||
)
|
||||
entries = [(at, {**design, **adjusts.get(round(at, 3), {})}) for at, design in entries]
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
await merge_cross_section_designs(
|
||||
connection, route_id=route_id, entries=entries, replace=True, project_id=project_id
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return dropped
|
||||
|
||||
|
||||
async def _rollback(project_id: UUID, old_route_id: int, params: dict[str, Any]) -> None:
|
||||
"""새 노선 행(종횡단 함께 지워짐)을 지우고 stage 2 params 를 옛것으로 — 최신 노선 = 옛 노선."""
|
||||
from common_util.common_util_workflow_state import start_stage
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"DELETE FROM routes WHERE project_id = %s AND id > %s",
|
||||
(str(project_id), old_route_id),
|
||||
)
|
||||
await start_stage(cursor, str(project_id), 2, params)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
logger.exception("노선 저장 되살림(DB) 실패: project_id=%s", project_id)
|
||||
|
||||
|
||||
async def run_route_save_chain(
|
||||
project_id: UUID,
|
||||
project_root: Path,
|
||||
stored_path: str,
|
||||
surface_model_id: int,
|
||||
selection: dict[str, Any],
|
||||
old_route: dict[str, Any],
|
||||
points: list[dict[str, float]],
|
||||
previous_params: dict[str, Any],
|
||||
profile_target: tuple[list[float], list[float]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤)."""
|
||||
import aiomysql
|
||||
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request
|
||||
from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR
|
||||
from B05_Profile.B05_Profile_Router import (
|
||||
_section_options,
|
||||
confirm_latest_route,
|
||||
solve_route_record,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Structures_Repository import load_structures, structures_file_path
|
||||
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
get_cross_section_designs,
|
||||
get_latest_section_options,
|
||||
get_longitudinal_section,
|
||||
)
|
||||
from B06_Section.B06_Section_Router import regenerate_route_sections
|
||||
from B06_Section.B06_Section_Router_Confirm import confirm_sections
|
||||
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
|
||||
from B06_Section.B06_Section_Schema import SectionConfirmRequest
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file
|
||||
from common_util.common_util_initial_snapshot import clear_designing, mark_designing
|
||||
from common_util.common_util_workflow_state import get_workflow_state
|
||||
|
||||
marks = [("시작", time.perf_counter())]
|
||||
old_route_id = int(old_route["id"])
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
state = await get_workflow_state(cursor, str(project_id))
|
||||
old_long_row = await get_longitudinal_section(connection, project_id, old_route_id)
|
||||
old_designs = await get_cross_section_designs(connection, old_route_id)
|
||||
old_section_options = await get_latest_section_options(connection, project_id)
|
||||
stage2 = next((s for s in state.get("stages", []) if int(s.get("stage_no", -1)) == 2), None)
|
||||
params = (stage2 or {}).get("params") or {}
|
||||
relative_old = str((old_long_row or {}).get("longitudinal_file_path") or "")
|
||||
old_long = _read_json(project_root / relative_old) if relative_old else {}
|
||||
old_xy = _xy(project_root, old_route.get("route_data_path"))
|
||||
old_pipes = read_pipe_points_file(pipe_points_path(stored_path))
|
||||
_, old_structures = load_structures(str(project_root))
|
||||
stage_dir = project_root / _STAGE_SUBDIR
|
||||
backup = _Backup(
|
||||
project_root,
|
||||
[
|
||||
project_root / str(old_route.get("route_data_path") or "B05_Profile/route/x"),
|
||||
pipe_points_path(stored_path),
|
||||
Path(structures_file_path(str(project_root))),
|
||||
],
|
||||
[stage_dir / "longitudinal", stage_dir / "cross_sections"],
|
||||
)
|
||||
mark_designing(project_root)
|
||||
try:
|
||||
# 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움)
|
||||
try:
|
||||
structures = await asyncio.to_thread(
|
||||
follow_structures_to_route, project_root, old_route.get("route_data_path"), points
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명)
|
||||
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
|
||||
structures = {"moved": 0, "far": 0}
|
||||
request = redesign_solve_request(params, selection, surface_model_id)
|
||||
solved: Any = await solve_route_record(project_id, request, generate_sections=False)
|
||||
if isinstance(solved, JSONResponse):
|
||||
raise _Fail(f"노선 풀기 실패({solved.status_code}): {_reason(solved)}")
|
||||
new_route_id = int(solved.route_id)
|
||||
confirmed = await confirm_latest_route(project_id, None, mark_stage_complete=False)
|
||||
if isinstance(confirmed, JSONResponse):
|
||||
raise _Fail(f"노선 확정 실패({confirmed.status_code})")
|
||||
new_xy = _xy(project_root, solved.route_data_path)
|
||||
marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter()))
|
||||
|
||||
pairs, pipes = await _place_pipes(
|
||||
project_id, project_root, stored_path, selection, old_pipes, old_xy, points
|
||||
)
|
||||
marks.append(("관 따라감 · 교차점 · 오목 보충", time.perf_counter()))
|
||||
|
||||
seed = asdict(_section_options(request, old_section_options))
|
||||
seed.pop("extra_stations", None)
|
||||
made = await regenerate_route_sections(
|
||||
project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed
|
||||
)
|
||||
if isinstance(made, JSONResponse):
|
||||
raise _Fail(f"종횡단 생성 실패({made.status_code}): {_reason(made)}")
|
||||
async with pool.acquire() as connection:
|
||||
new_long_row = await get_longitudinal_section(connection, project_id, new_route_id)
|
||||
relative = str((new_long_row or {}).get("longitudinal_file_path") or "")
|
||||
marks.append(("종횡단 한 번", time.perf_counter()))
|
||||
|
||||
if profile_target is not None:
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target
|
||||
|
||||
await apply_profile_target(project_id, new_route_id, profile_target)
|
||||
else:
|
||||
await _carry_profile(
|
||||
project_root,
|
||||
relative,
|
||||
new_route_id,
|
||||
old_long.get("profile_alignment") or {},
|
||||
old_xy,
|
||||
new_xy,
|
||||
)
|
||||
marks.append(("계획고 옮김", time.perf_counter()))
|
||||
|
||||
standard = stored_standard_cross_section(old_long_row)
|
||||
dropped = await _carry_cross(
|
||||
project_id,
|
||||
project_root,
|
||||
new_route_id,
|
||||
relative,
|
||||
old_long,
|
||||
old_designs,
|
||||
old_pipes,
|
||||
old_structures,
|
||||
pairs,
|
||||
float(seed["station_interval_m"]),
|
||||
standard,
|
||||
)
|
||||
marks.append(("횡단 이월(측점 열쇠)", time.perf_counter()))
|
||||
|
||||
done = await confirm_sections(
|
||||
project_id,
|
||||
new_route_id,
|
||||
SectionConfirmRequest(standard_cross_section=standard) if standard else None,
|
||||
mark_stage_complete=False,
|
||||
)
|
||||
if isinstance(done, JSONResponse):
|
||||
raise _Fail(f"종횡단 확정 실패({done.status_code})")
|
||||
marks.append(("B06 확정 · 서버 재계산 · 3D", time.perf_counter()))
|
||||
except Exception as exc: # noqa: BLE001 — 어느 단계든 옛 노선으로 되살림
|
||||
if not isinstance(exc, _Fail):
|
||||
logger.exception("노선 저장 체인 실패: project_id=%s", project_id)
|
||||
await asyncio.to_thread(backup.restore)
|
||||
await _rollback(project_id, old_route_id, previous_params)
|
||||
return {"error": str(exc) if isinstance(exc, _Fail) else f"노선 저장 중 오류: {exc}"}
|
||||
finally:
|
||||
backup.drop()
|
||||
clear_designing(project_root)
|
||||
_log_steps("재확정 체인(노선 저장)", marks)
|
||||
return {
|
||||
"route_id": new_route_id,
|
||||
"total_length_m": float(solved.total_length_m or 0.0),
|
||||
"pipes": pipes,
|
||||
"structures": structures,
|
||||
"cross_dropped": dropped,
|
||||
"drainage_stale": True,
|
||||
"seconds": round(marks[-1][1] - marks[0][1], 2),
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
"""노선 저장 길(346-3) 순수 부품 — 관 따라감 · 새 교차점 기본 관 · 오목 지점 보충 · 측점 열쇠 ·
|
||||
횡단 이월.
|
||||
|
||||
DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름). 차례 · 저장 · 되살림은
|
||||
`B05_Profile_Route_Save.py`.
|
||||
|
||||
- 관 = 좌표가 정본 — 새 노선에 투영해 누가거리만 다시(`project_pipe_points` · 정수 m). 지우지 않음
|
||||
(사용자 관 편집 지킴).
|
||||
- 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음).
|
||||
- 관 최대 간격 보충 = 옛 노선에서 벗어난 자리의 **오목 지점**(지반 종단 낮은 곳)에만 — 격자 없음.
|
||||
- 측점 열쇠(계약 `design_pipeline_types.ts` 머리) = 일반 `grid:k`(누가거리 ÷ 측점 간격 반올림) ·
|
||||
관 `pipe:<id>` · 구조물 `structure:<id>`(구간 시작 · 끝은 `:start` · `:end`) · 시종점
|
||||
`end:BP` · `end:EP`. 관 id = 저장 파일 차례(노선 저장 한 번 안에서 옛 · 새가 같음).
|
||||
- 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 · 관 · 구조물 열쇠면
|
||||
`STRUCTURE_ADJUST_KEYS` 도(구조물 조정은 구조물 따라) · 열쇠가 사라진 측점 수 =
|
||||
「노선 밖 고침」.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import math
|
||||
from typing import Any, Sequence
|
||||
|
||||
import numpy as np
|
||||
from shapely.geometry import LineString, Point
|
||||
|
||||
from common_util.common_util_drainage_pipes import (
|
||||
PIPE_SOURCE_SPACING,
|
||||
PIPE_SOURCE_STREAM,
|
||||
PipePoint,
|
||||
project_pipe_points,
|
||||
)
|
||||
from common_util.common_util_route_geometry import RouteVertex
|
||||
from common_util.common_util_structure_station import plan_stations, snap_structure_station
|
||||
from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M
|
||||
|
||||
# 옛 노선에서 이만큼(m) 넘게 떨어진 자리 = 노선이 바뀐 자리(교차점 · 보충 대상).
|
||||
MOVED_AWAY_M = 0.5
|
||||
# 오목 지점을 찾을 지반 표본 간격(m) · 고름 창(표본 수) — DTM 잔물결을 오목으로 안 읽게.
|
||||
# ponytail: 이동 평균 + 국소 최저 · 계곡이 얕아 놓치면 창을 줄이거나 흐름 강도와 섞을 것.
|
||||
CONCAVE_STEP_M = 2.0
|
||||
CONCAVE_WINDOW = 5
|
||||
_SAME_M = 0.01
|
||||
|
||||
|
||||
def vertices_of(xy: Sequence[Sequence[float]]) -> list[RouteVertex]:
|
||||
"""평면 점열 → 누가거리 붙은 정점(z 0)."""
|
||||
out: list[RouteVertex] = []
|
||||
walked = 0.0
|
||||
for index, point in enumerate(xy):
|
||||
x, y = float(point[0]), float(point[1])
|
||||
if index:
|
||||
walked += math.hypot(x - out[-1].x, y - out[-1].y)
|
||||
out.append(RouteVertex(x=x, y=y, z=0.0, chainage_m=walked))
|
||||
return out
|
||||
|
||||
|
||||
def follow_pipes(
|
||||
old_pipes: list[PipePoint], vertices: list[RouteVertex]
|
||||
) -> list[tuple[int, PipePoint]]:
|
||||
"""관이 새 노선 좌표를 따라감 → [(관 id = 옛 차례, 새 관)] 누가거리 순 · 옛 목록 그대로."""
|
||||
pairs = [(index, copy.deepcopy(pipe)) for index, pipe in enumerate(old_pipes)]
|
||||
for _, pipe in pairs:
|
||||
project_pipe_points([pipe], vertices)
|
||||
pairs.sort(key=lambda pair: pair[1].chainage_m)
|
||||
return pairs
|
||||
|
||||
|
||||
def _moved_away(old_line: LineString | None, x: float, y: float) -> bool:
|
||||
return old_line is None or old_line.distance(Point(x, y)) > MOVED_AWAY_M
|
||||
|
||||
|
||||
def add_crossing_pipes(
|
||||
pairs: list[tuple[int, PipePoint]],
|
||||
crossings: Sequence[Any],
|
||||
old_xy: Sequence[Sequence[float]] | None,
|
||||
total_m: float,
|
||||
) -> int:
|
||||
"""옛 노선에 없던 세류선 교차점에 관이 없으면 기본 관을 더함 · 더한 수."""
|
||||
old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None
|
||||
added = 0
|
||||
for crossing in crossings:
|
||||
if not _moved_away(old_line, crossing.x, crossing.y):
|
||||
continue # 옛 교차점 — 사용자가 지운 관을 되살리지 않음
|
||||
at = snap_structure_station(float(crossing.chainage_m), [total_m])
|
||||
if any(abs(pipe.chainage_m - at) < DRAINAGE_PIPE_MIN_SPACING_M for _, pipe in pairs):
|
||||
continue
|
||||
pairs.append(
|
||||
(-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_STREAM, x=crossing.x, y=crossing.y))
|
||||
)
|
||||
added += 1
|
||||
pairs.sort(key=lambda pair: pair[1].chainage_m)
|
||||
return added
|
||||
|
||||
|
||||
def concave_chainages(chainage: np.ndarray, ground: np.ndarray) -> list[tuple[float, float]]:
|
||||
"""지반 종단의 오목 지점(이동 평균 국소 최저) → [(누가거리, 고른 지반고)]."""
|
||||
if ground.size < 3:
|
||||
return []
|
||||
kernel = np.ones(CONCAVE_WINDOW) / CONCAVE_WINDOW
|
||||
pad = CONCAVE_WINDOW // 2
|
||||
smooth = np.convolve(np.pad(ground, pad, mode="edge"), kernel, mode="valid")
|
||||
return [
|
||||
(float(chainage[i]), float(smooth[i]))
|
||||
for i in range(1, smooth.size - 1)
|
||||
if smooth[i] < smooth[i - 1] and smooth[i] <= smooth[i + 1]
|
||||
]
|
||||
|
||||
|
||||
def fill_concave_pipes(
|
||||
pairs: list[tuple[int, PipePoint]],
|
||||
concave: list[tuple[float, float]],
|
||||
vertices: list[RouteVertex],
|
||||
old_xy: Sequence[Sequence[float]] | None,
|
||||
) -> int:
|
||||
"""관 사이(시종점 포함)가 최대 간격을 넘으면 옛 노선 밖 오목 지점 중 가장 낮은 곳에 관."""
|
||||
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
|
||||
old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None
|
||||
total = float(line.length)
|
||||
added = 0
|
||||
while True:
|
||||
edges = [0.0, *(pipe.chainage_m for _, pipe in pairs), total]
|
||||
best: tuple[float, float] | None = None
|
||||
for start, end in zip(edges, edges[1:]):
|
||||
if end - start <= DRAINAGE_PIPE_MAX_SPACING_M:
|
||||
continue
|
||||
for at, z in concave:
|
||||
if not (
|
||||
start + DRAINAGE_PIPE_MIN_SPACING_M < at < end - DRAINAGE_PIPE_MIN_SPACING_M
|
||||
):
|
||||
continue
|
||||
point = line.interpolate(at)
|
||||
if _moved_away(old_line, point.x, point.y) and (best is None or z < best[1]):
|
||||
best = (at, z)
|
||||
if best is None:
|
||||
return added
|
||||
at = snap_structure_station(best[0], [total])
|
||||
point = line.interpolate(at)
|
||||
pairs.append(
|
||||
(-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_SPACING, x=point.x, y=point.y))
|
||||
)
|
||||
pairs.sort(key=lambda pair: pair[1].chainage_m)
|
||||
concave = [item for item in concave if abs(item[0] - best[0]) > _SAME_M]
|
||||
added += 1
|
||||
|
||||
|
||||
def _facility_keys(
|
||||
pipes: Sequence[tuple[int, PipePoint]], structures: Sequence[Any]
|
||||
) -> dict[float, str]:
|
||||
"""측점 자리(0.01 m) → 관 · 구조물 열쇠. 같은 자리면 관이 먼저(`plan_stations` 이름 규칙)."""
|
||||
keys: dict[float, str] = {}
|
||||
|
||||
def put(marks: list[dict[str, object]], head: str) -> None:
|
||||
for mark in marks:
|
||||
role = str(mark.get("role") or "anchor")
|
||||
keys.setdefault(
|
||||
round(float(mark["chainage_m"]), 2), head if role == "anchor" else f"{head}:{role}"
|
||||
)
|
||||
|
||||
for pipe_id, pipe in pipes:
|
||||
if pipe_id >= 0:
|
||||
put(plan_stations([pipe], []), f"pipe:{pipe_id}")
|
||||
for item in structures:
|
||||
if getattr(item, "structure_id", None):
|
||||
put(plan_stations([], [item]), f"structure:{item.structure_id}")
|
||||
return keys
|
||||
|
||||
|
||||
def station_keys(
|
||||
stations: Sequence[dict[str, Any]],
|
||||
pipes: Sequence[tuple[int, PipePoint]],
|
||||
structures: Sequence[Any],
|
||||
interval_m: float,
|
||||
) -> dict[str, float]:
|
||||
"""측점 열쇠 → 누가거리. 관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만)."""
|
||||
facility = _facility_keys(pipes, structures)
|
||||
values = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None]
|
||||
out: dict[str, float] = {}
|
||||
for index, chainage in enumerate(values):
|
||||
key = facility.get(round(chainage, 2))
|
||||
if key is None and index == 0:
|
||||
key = "end:BP"
|
||||
elif key is None and index == len(values) - 1:
|
||||
key = "end:EP"
|
||||
elif key is None and interval_m > 0:
|
||||
k = round(chainage / interval_m)
|
||||
key = f"grid:{k}" if abs(chainage - k * interval_m) < _SAME_M else None
|
||||
if key is not None:
|
||||
out.setdefault(key, chainage)
|
||||
return out
|
||||
|
||||
|
||||
def carry_cross_inputs(
|
||||
old_keys: dict[str, float],
|
||||
new_keys: dict[str, float],
|
||||
old_designs: dict[float, dict[str, Any]],
|
||||
input_keys: Sequence[str],
|
||||
adjust_keys: Sequence[str],
|
||||
) -> tuple[dict[float, dict[str, Any]], dict[float, dict[str, Any]], int]:
|
||||
"""같은 열쇠 측점으로 횡단 입력을 넘김 → (입력{새 누가거리 0.001: 값}, 구조물 조정{…}, 버린 수).
|
||||
|
||||
`old_designs` = 옛 노선 행 {누가거리 0.001: design}. 버린 수 = 설계가 있던 옛 측점 중 새 노선에
|
||||
같은 열쇠가 없는 것(「노선 밖 고침」).
|
||||
"""
|
||||
inputs: dict[float, dict[str, Any]] = {}
|
||||
adjusts: dict[float, dict[str, Any]] = {}
|
||||
dropped = 0
|
||||
for key, old_at in old_keys.items():
|
||||
design = old_designs.get(round(old_at, 3))
|
||||
if not isinstance(design, dict):
|
||||
continue
|
||||
new_at = new_keys.get(key)
|
||||
if new_at is None:
|
||||
dropped += 1
|
||||
continue
|
||||
carried = {name: design[name] for name in input_keys if design.get(name) is not None}
|
||||
if carried:
|
||||
inputs[round(new_at, 3)] = carried
|
||||
if key.startswith(("pipe:", "structure:")):
|
||||
adjust = {name: design[name] for name in adjust_keys if design.get(name) is not None}
|
||||
if adjust:
|
||||
adjusts[round(new_at, 3)] = adjust
|
||||
return inputs, adjusts, dropped
|
||||
@@ -14,17 +14,13 @@
|
||||
노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 초기 폴리라인을 읽으므로 「초기본을
|
||||
수정본으로 복사」와 결과가 같다.
|
||||
|
||||
[확인]을 눌렀을 때만 계산이 돈다. 재계산은 **초기 업로드 체인의 로직을 그대로 재사용**한다
|
||||
(2026-09-06 사용자 제안) — `run_redesign_chain`이 배수유역 다시 분석 → 기본 관 저장 → 관
|
||||
정착 계획선 재산출 → 종횡단 재생성 → 옛 측점 설계를 누가거리로 이월 → B05·B06 확정까지 한
|
||||
줄로 돈다. 여기서 하는 일은 그 앞에 **노선을 갈아 끼우는 것**뿐이다.
|
||||
|
||||
시설 처리(2026-09-06 사용자 확정) — 횡단배수 지점은 노선 자리에 따라 생기고 없어지므로
|
||||
저장분을 버리고 **새로 계산한 값**을 쓴다. 그 밖의 구조물은 **옛 측점값을 그대로** 이어받는다
|
||||
(`structures.json`은 프로젝트 정본이라 손대지 않으면 그대로 남는다).
|
||||
노선 저장(346-3) — 여기서 노선을 갈아 끼운 뒤 `B05_Profile_Route_Save.run_route_save_chain` 이
|
||||
풀기 → 관 · 구조물 좌표 따라감 → 종횡단 한 번 → 계획고 옮김 → 횡단 이월(측점 열쇠) → B06 확정까지
|
||||
돈다. 배수유역은 다시 안 셈(낡음) · 관 정본은 안 지움 · 초기값은 추천 [적용](`as_initial`)일 때만.
|
||||
요청 · 응답 · 오류 모양 = 그 모듈 머리.
|
||||
|
||||
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
|
||||
POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
|
||||
POST /api/projects/{id}/route/replan → 노선 초안 저장(노선 저장 체인)
|
||||
POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
||||
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀)
|
||||
"""
|
||||
@@ -45,6 +41,7 @@ from fastapi.responses import JSONResponse
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
|
||||
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
|
||||
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
|
||||
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
|
||||
RouteInitialCriteria,
|
||||
RouteInitialRequest,
|
||||
@@ -340,22 +337,19 @@ async def _recompute(
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import (
|
||||
_log_steps,
|
||||
_planned_route_points_in_project_crs,
|
||||
run_redesign_chain,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Service import (
|
||||
find_surface_model_for_selection,
|
||||
switch_design_terrain,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Repository import get_latest_route
|
||||
from common_util.common_util_drainage_pipes import clear_pipe_points
|
||||
from B05_Profile.B05_Profile_Route_Save import run_route_save_chain
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_workflow_state import get_workflow_state, start_stage
|
||||
|
||||
marks = [("시작", time.perf_counter())]
|
||||
|
||||
# 횡단배수 지점은 새 노선에서 새로 계산한다 — 저장분을 남기면 옛 자리가 살아난다.
|
||||
await asyncio.to_thread(clear_pipe_points, stored_path)
|
||||
|
||||
# 관 정본은 지우지 않음(346-3) — 사용자 관 편집을 지키고 좌표로 새 노선을 따라감(체인 안).
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin() # 고른 지형 = 설계 지형 — 뒤 단계가 모두 이 확정값을 읽음
|
||||
@@ -408,7 +402,8 @@ async def _recompute(
|
||||
stage2 = next(
|
||||
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
|
||||
)
|
||||
params = dict((stage2 or {}).get("params") or {})
|
||||
previous_params = dict((stage2 or {}).get("params") or {}) # 실패 때 되살림
|
||||
params = dict(previous_params)
|
||||
params["points"] = {
|
||||
"bp": points[0],
|
||||
"ep": points[-1],
|
||||
@@ -418,23 +413,28 @@ async def _recompute(
|
||||
await start_stage(cursor, str(project_id), 2, params)
|
||||
await connection.commit()
|
||||
|
||||
marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter()))
|
||||
|
||||
# 구조물은 땅 위 자리(좌표)를 따라감(344-2) — 옛 노선 경로 파일은 체인 전이라 아직 옛 것
|
||||
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
|
||||
|
||||
try:
|
||||
old_path = (latest or {}).get("route_data_path")
|
||||
await asyncio.to_thread(follow_structures_to_route, project_root, old_path, points)
|
||||
except Exception: # 따라감 실패 = 옛 누가거리 그대로(지금까지와 같음)
|
||||
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
|
||||
marks.append(("재계산 준비(노선 읽기·stage 기록)", time.perf_counter()))
|
||||
|
||||
target = None
|
||||
if balanced is not None:
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import balanced_target
|
||||
|
||||
target = await balanced_target(project_id, project_root, points, balanced.criteria)
|
||||
failure = await run_redesign_chain(project_id, int(surface_model_id), selection, target)
|
||||
if not latest:
|
||||
return {"error": "저장된 노선이 없어 노선을 저장할 수 없습니다(자동설계 먼저)."}
|
||||
# 노선 저장 체인(346-3) — 배수유역 뗌 · 관 · 구조물 따라감 · 종횡단 한 번 · 계획고 옮김
|
||||
saved = await run_route_save_chain(
|
||||
project_id,
|
||||
project_root,
|
||||
stored_path,
|
||||
int(surface_model_id),
|
||||
selection,
|
||||
latest,
|
||||
points,
|
||||
previous_params,
|
||||
target,
|
||||
)
|
||||
failure = saved.get("error")
|
||||
marks.append(("재확정 체인", time.perf_counter()))
|
||||
_log_steps("노선 [확인] 재계산", marks)
|
||||
if failure and switched: # 노선과 함께 설계 지형도 되돌림
|
||||
@@ -448,14 +448,7 @@ async def _recompute(
|
||||
# 부르는 쪽이 계획노선을 되돌리도록 사유를 올려 보낸다.
|
||||
return {"error": failure}
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
made = await get_latest_route(connection, project_id)
|
||||
return {
|
||||
"route_id": int(made["id"]) if made else None,
|
||||
"total_length_m": float(made.get("total_length_m") or 0.0) if made else None,
|
||||
"vertex_count": len(points),
|
||||
}
|
||||
return {**saved, "vertex_count": len(points)}
|
||||
|
||||
|
||||
@router.get("/{project_id}/route/plan", response_model=None)
|
||||
@@ -530,10 +523,16 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/replan", response_model=None)
|
||||
async def replan_route(
|
||||
project_id: UUID, request: RouteReplanRequest
|
||||
) -> dict[str, Any] | JSONResponse:
|
||||
"""고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다(모달 [확인])."""
|
||||
async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any] | JSONResponse:
|
||||
"""노선 초안을 정본으로 저장한다(346-3 · 요청 · 응답 모양 = `B05_Profile_Route_Save` 머리).
|
||||
|
||||
배수유역은 다시 안 셈(낡음 표시) · 초기값은 추천 [적용](`as_initial`)일 때만 다시 뜬다.
|
||||
"""
|
||||
as_initial = bool(getattr(body, "as_initial", False)) # 옛 요청 꼴(파이썬 호출)도 받음
|
||||
try:
|
||||
request = body.resolved() if isinstance(body, RouteSaveRequest) else body
|
||||
except ValueError as error:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
|
||||
if len(request.vertices) < 2:
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
@@ -616,15 +615,18 @@ async def replan_route(
|
||||
if choice
|
||||
else await asyncio.to_thread(read_initial_choice, project_root)
|
||||
)
|
||||
# 확정 노선 = 새 초기값(PLAN 74-4) — [저장] 없이 이 결과를 [초기화]가 돌아갈 자리로 뜬다.
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot
|
||||
# 초기값 = 자동설계 체인 직후 + 추천 노선 [적용] 때만(346-3 · 사용자 확정 10-16) — 손으로 고친
|
||||
# 노선 저장은 [초기화]가 돌아갈 자리를 안 바꿈.
|
||||
if as_initial:
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot
|
||||
|
||||
await refresh_initial_snapshot(project_id, project_root, result.get("route_id"))
|
||||
await refresh_initial_snapshot(project_id, project_root, result.get("route_id"))
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
**result,
|
||||
"current_initial": current,
|
||||
"initial_refreshed": as_initial,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ async def balanced_profile_of(
|
||||
async def refresh_initial_snapshot(
|
||||
project_id: UUID, project_root: Path, route_id: int | None
|
||||
) -> None:
|
||||
"""노선 [확인] 결과로 초기값(initial_snapshot)을 다시 뜬다(PLAN 74-4) — 실패는 비치명적."""
|
||||
"""추천 노선 [적용](as_initial) 결과로 초기값을 다시 뜬다(346-3) — 실패는 비치명적."""
|
||||
from common_util.common_util_initial_snapshot import (
|
||||
discard_initial_snapshot,
|
||||
save_initial_snapshot,
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
* 1) 짓기 = 서버 Node(`B05_Profile_Corridor_Prebuild`) — 자동 체인 · 노선 [확인] 체인 ·
|
||||
* B06 [저장] · [확정] 끝. 브라우저는 올리지 않는다(PUT 없음).
|
||||
* 2) 받기: 저장본 GET → 버전 해시가 현 정본과 맞으면 그대로 표시.
|
||||
* 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] 때만 브라우저가 미리보기로
|
||||
* 지음 — 메모리에만 · 올리지 않음.
|
||||
* 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] · 낡은 채 3D 모달을 열 때만 브라우저가
|
||||
* 미리보기로 지음 — **웹 워커**(`_Corridor_Worker_Client` · 345-4 · 주 스레드 멈춤 0) · 메모리에만 ·
|
||||
* 올리지 않음.
|
||||
* 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이
|
||||
* 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외).
|
||||
* ========================================================================== */
|
||||
@@ -21,7 +22,8 @@ import {
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client";
|
||||
import {
|
||||
corridorHash,
|
||||
ENVELOPE_VERSION,
|
||||
@@ -169,7 +171,7 @@ export async function ensureCorridor(
|
||||
// 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다.
|
||||
// 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는
|
||||
// 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③).
|
||||
const build = buildCorridor(
|
||||
const build = await buildCorridorOffThread(
|
||||
detail.cross_sections,
|
||||
routePoints,
|
||||
designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples,
|
||||
@@ -184,9 +186,6 @@ export async function ensureCorridor(
|
||||
return build;
|
||||
}
|
||||
|
||||
/** 옛 올림 자리 — 3D 길 모형은 서버가 [저장]·[확정] 때 지음(342-2). 부르는 곳이 지워지면 함께 지움. */
|
||||
export async function saveCorridorIfDirty(_projectId: string, _routeId: number): Promise<void> {}
|
||||
|
||||
/** ③ 사면이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */
|
||||
function withSections(
|
||||
build: CorridorBuildResult | null,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Worker.ts
|
||||
* 3D 길 모형 **웹 워커**(345-4) — 저장 안 한 초안의 미리보기를 주 스레드 밖에서 짓는다.
|
||||
*
|
||||
* 짓는 셈은 `buildCorridor` 그대로(서버 Node 묶음 `B05_Profile_Corridor_Node` 와 같은 순수 계산) —
|
||||
* 새로 짜지 않는다. 결과의 리본 좌표(Float64Array)는 복사 없이 넘긴다(Transferable).
|
||||
* 부르는 쪽은 `_Corridor_Worker_Client`. 이 파일 맨 위에 DOM · window 를 두지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
|
||||
export interface CorridorWorkerRequest {
|
||||
id: number;
|
||||
crossSections: CrossSection[];
|
||||
routePoints: RoutePoint[];
|
||||
designSamples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>;
|
||||
}
|
||||
|
||||
export interface CorridorWorkerResponse {
|
||||
id: number;
|
||||
build: CorridorBuildResult | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 넘길 버퍼 — 리본 좌표(가장 큼). 나머지는 구조화 복제. */
|
||||
export function corridorTransfers(build: CorridorBuildResult | null): ArrayBuffer[] {
|
||||
return build ? build.ribbons.map((ribbon) => ribbon.positions.buffer as ArrayBuffer) : [];
|
||||
}
|
||||
|
||||
/** 요청 하나 — 워커 안에서도 시험에서도 같은 함수. */
|
||||
export function handleCorridorRequest(request: CorridorWorkerRequest): CorridorWorkerResponse {
|
||||
try {
|
||||
const build = buildCorridor(request.crossSections, request.routePoints, request.designSamples);
|
||||
return { id: request.id, build };
|
||||
} catch (error) {
|
||||
return {
|
||||
id: request.id,
|
||||
build: null,
|
||||
error: error instanceof Error ? error.message : "짓기 실패",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface WorkerScope {
|
||||
onmessage: ((event: { data: CorridorWorkerRequest }) => void) | null;
|
||||
postMessage: (message: CorridorWorkerResponse, transfer: ArrayBuffer[]) => void;
|
||||
}
|
||||
|
||||
// 워커로 돌 때만 듣는다(모듈을 시험 · 주 스레드가 들여와도 아무 일 없음).
|
||||
if (typeof (globalThis as { WorkerGlobalScope?: unknown }).WorkerGlobalScope !== "undefined") {
|
||||
const scope = globalThis as unknown as WorkerScope;
|
||||
scope.onmessage = (event) => {
|
||||
const response = handleCorridorRequest(event.data);
|
||||
scope.postMessage(response, corridorTransfers(response.build));
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Worker_Client.ts
|
||||
* 3D 길 모형 워커 부르기(345-4) — `buildCorridorOffThread` 가 워커(`_Corridor_Worker`)에 맡겨 주 스레드
|
||||
* 멈춤 없이 짓는다. 워커를 못 쓰는 곳(시험 · 옛 브라우저) · 워커가 깨지면 주 스레드에서 같은 함수로 짓는다.
|
||||
* 워커는 처음 부를 때 하나 세워 페이지 수명 동안 다시 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import type {
|
||||
CorridorWorkerRequest,
|
||||
CorridorWorkerResponse,
|
||||
} from "./B05_Profile_UI_Corridor_Worker";
|
||||
|
||||
type Samples = CorridorWorkerRequest["designSamples"];
|
||||
|
||||
let worker: Worker | null = null;
|
||||
let seq = 0;
|
||||
const waits = new Map<number, (response: CorridorWorkerResponse | null) => void>();
|
||||
|
||||
function settleAll(): void {
|
||||
waits.forEach((resolve) => resolve(null));
|
||||
waits.clear();
|
||||
}
|
||||
|
||||
function getWorker(): Worker | null {
|
||||
if (worker) return worker;
|
||||
if (typeof Worker === "undefined") return null;
|
||||
try {
|
||||
worker = new Worker(new URL("./B05_Profile_UI_Corridor_Worker.ts", import.meta.url), {
|
||||
type: "module",
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
worker.onmessage = (event: MessageEvent<CorridorWorkerResponse>) => {
|
||||
const resolve = waits.get(event.data.id);
|
||||
waits.delete(event.data.id);
|
||||
resolve?.(event.data);
|
||||
};
|
||||
// 워커가 깨지면 버리고 기다리던 것은 주 스레드로 — 다음 부름은 새 워커.
|
||||
worker.onerror = () => {
|
||||
worker?.terminate();
|
||||
worker = null;
|
||||
settleAll();
|
||||
};
|
||||
return worker;
|
||||
}
|
||||
|
||||
/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). */
|
||||
export async function buildCorridorOffThread(
|
||||
crossSections: CrossSection[],
|
||||
routePoints: RoutePoint[],
|
||||
designSamples?: Samples,
|
||||
): Promise<CorridorBuildResult | null> {
|
||||
const onMain = (): CorridorBuildResult | null =>
|
||||
buildCorridor(crossSections, routePoints, designSamples);
|
||||
const target = getWorker();
|
||||
if (!target) return onMain();
|
||||
const id = ++seq;
|
||||
const response = await new Promise<CorridorWorkerResponse | null>((resolve) => {
|
||||
waits.set(id, resolve);
|
||||
try {
|
||||
target.postMessage({ id, crossSections, routePoints, designSamples });
|
||||
} catch {
|
||||
waits.delete(id); // 복제 못 하는 값 — 주 스레드로
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
if (!response || response.error) return onMain();
|
||||
return response.build;
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Live.ts
|
||||
* 배수유역 자동/수동 다시 셈(347-2) — 묶음 drainage 단계 · ⚙ 설정 창 · 흐림 + [업데이트] · 구석
|
||||
* 「계산 중」 · 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`.
|
||||
*
|
||||
* - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 —
|
||||
* 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음).
|
||||
* - 노선 초안이 있으면 「낡음」 — 자동 = 3 s 조용한 뒤 셈 · 수동 = 칸만 흐림 + 가운데 [업데이트].
|
||||
* - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s.
|
||||
* - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림.
|
||||
* - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
||||
import type { StageFn } from "../A00_Common/design/design_pipeline_types";
|
||||
import {
|
||||
computeDetailBasins,
|
||||
type DetailBasinResponse,
|
||||
type DetailPipeInput,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
acceptLive,
|
||||
draftMakesStale,
|
||||
liveRequestBody,
|
||||
parseDrainageMode,
|
||||
QUIET_MS,
|
||||
type DrainageMode,
|
||||
} from "./logic/B05_Profile_Drainage_Live";
|
||||
import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run";
|
||||
import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest";
|
||||
|
||||
const MODE_KEY = "drainage-mode";
|
||||
|
||||
export interface DrainageLiveDeps {
|
||||
/** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */
|
||||
root: HTMLElement;
|
||||
/** 제안 목록을 이 앞에 둠. */
|
||||
basinList: HTMLElement;
|
||||
pipeRequest: () => DetailPipeInput[];
|
||||
runner: DetailRunner;
|
||||
/** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담음. */
|
||||
rememberPipes: () => void;
|
||||
}
|
||||
|
||||
export interface DrainageLive {
|
||||
/** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */
|
||||
stage: StageFn<"drainage">;
|
||||
/** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */
|
||||
refresh(): void;
|
||||
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */
|
||||
markFresh(): void;
|
||||
readonly stale: boolean;
|
||||
suggest: DrainageSuggest;
|
||||
}
|
||||
|
||||
/** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */
|
||||
async function fetchLive(
|
||||
projectId: string,
|
||||
body: ReturnType<typeof liveRequestBody>,
|
||||
signal: AbortSignal,
|
||||
): Promise<DetailBasinResponse & { status?: string; generation?: number }> {
|
||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal,
|
||||
});
|
||||
const payload = await response.json();
|
||||
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function readDrainageMode(): DrainageMode {
|
||||
return parseDrainageMode(readStateRaw(MODE_KEY));
|
||||
}
|
||||
|
||||
export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
||||
const viewport = deps.root.querySelector<HTMLElement>(".b05-drainage__viewport") as HTMLElement;
|
||||
const header = deps.root.querySelector<HTMLElement>(".b05-drainage__header") as HTMLElement;
|
||||
const suggest = createDrainageSuggest(deps.pipeRequest, (points) => {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
void deps.runner
|
||||
.run(() => computeDetailBasins(projectId, points))
|
||||
.then((ok) => ok && deps.rememberPipes());
|
||||
});
|
||||
deps.basinList.before(suggest.root);
|
||||
let mode = readDrainageMode();
|
||||
let route: unknown = null;
|
||||
let stale = false;
|
||||
let generation = 0;
|
||||
let timer = 0;
|
||||
let inflight: AbortController | null = null;
|
||||
|
||||
const veil = document.createElement("div");
|
||||
veil.className = "b05-drainage__veil";
|
||||
veil.hidden = true;
|
||||
const update = document.createElement("button");
|
||||
update.type = "button";
|
||||
update.className = "b05-drainage__analyze b05-drainage__update";
|
||||
update.textContent = "업데이트";
|
||||
update.title = "노선 · 관 자리가 바뀌어 배수유역이 낡았습니다 — 지금 노선으로 다시 셉니다.";
|
||||
veil.append(update);
|
||||
const busy = createProgressCircle({ size: 22, label: "계산 중" });
|
||||
busy.root.classList.add("b05-drainage__busy");
|
||||
busy.root.hidden = true;
|
||||
viewport.append(veil, busy.root);
|
||||
|
||||
const gear = document.createElement("button");
|
||||
gear.type = "button";
|
||||
gear.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__gear";
|
||||
gear.textContent = "⚙";
|
||||
gear.title = "배수유역 계산 설정 — 수동(기본) / 자동";
|
||||
header.append(gear);
|
||||
|
||||
const paint = (): void => {
|
||||
deps.root.classList.toggle("is-stale", stale);
|
||||
veil.hidden = !stale || mode === "auto" || inflight !== null;
|
||||
busy.root.hidden = inflight === null;
|
||||
};
|
||||
|
||||
function cancel(): void {
|
||||
window.clearTimeout(timer);
|
||||
timer = 0;
|
||||
inflight?.abort();
|
||||
inflight = null;
|
||||
}
|
||||
|
||||
function refresh(): void {
|
||||
const projectId = deps.runner.projectId();
|
||||
if (!projectId) return;
|
||||
cancel();
|
||||
const sent = generation;
|
||||
const controller = new AbortController();
|
||||
inflight = controller;
|
||||
paint();
|
||||
const body = liveRequestBody(sent, route, deps.pipeRequest());
|
||||
void deps.runner
|
||||
.run(async () => {
|
||||
const response = await fetchLive(projectId, body, controller.signal);
|
||||
// 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌.
|
||||
if (!acceptLive(response, sent, generation))
|
||||
throw new DOMException("superseded", "AbortError");
|
||||
return response;
|
||||
}, true)
|
||||
.then((applied) => {
|
||||
if (inflight !== controller) return;
|
||||
inflight = null;
|
||||
if (applied) stale = false;
|
||||
paint();
|
||||
});
|
||||
}
|
||||
|
||||
/** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */
|
||||
function invalidate(): void {
|
||||
generation += 1;
|
||||
cancel();
|
||||
stale = true;
|
||||
if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
}
|
||||
|
||||
function openSettings(): void {
|
||||
openModal({
|
||||
title: "배수유역 계산",
|
||||
closeLabel: "닫기",
|
||||
mount(body, close) {
|
||||
const choices: Array<[DrainageMode, string, string]> = [
|
||||
["manual", "수동(기본)", "노선 · 관 자리가 바뀌면 칸만 흐려지고 [업데이트]를 누를 때 셈"],
|
||||
["auto", "자동", "조작이 3초 멈추면 뒤에서 조용히 셈(화면을 막지 않음)"],
|
||||
];
|
||||
choices.forEach(([value, label, hint]) => {
|
||||
const row = document.createElement("label");
|
||||
row.className = "b05-drainage__mode";
|
||||
const input = document.createElement("input");
|
||||
input.type = "radio";
|
||||
input.name = "b05-drainage-mode";
|
||||
input.checked = mode === value;
|
||||
input.addEventListener("change", () => {
|
||||
setMode(value);
|
||||
close();
|
||||
});
|
||||
const text = document.createElement("span");
|
||||
text.textContent = `${label} — ${hint}`;
|
||||
row.append(input, text);
|
||||
body.append(row);
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setMode(next: DrainageMode): void {
|
||||
mode = next;
|
||||
writeStateRaw(MODE_KEY, next);
|
||||
window.clearTimeout(timer);
|
||||
timer = 0;
|
||||
if (stale && next === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS);
|
||||
paint();
|
||||
}
|
||||
|
||||
update.addEventListener("click", refresh);
|
||||
gear.addEventListener("click", openSettings);
|
||||
|
||||
return {
|
||||
stage(ctx) {
|
||||
route = ctx.inputs.route;
|
||||
if (draftMakesStale(route) || stale) invalidate();
|
||||
return null;
|
||||
},
|
||||
refresh,
|
||||
suggest,
|
||||
markFresh() {
|
||||
cancel();
|
||||
stale = false;
|
||||
paint();
|
||||
},
|
||||
get stale() {
|
||||
return stale;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Dra
|
||||
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
|
||||
import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
|
||||
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
|
||||
import { createDetailRunner } from "./B05_Profile_UI_Drainage_Run";
|
||||
import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live";
|
||||
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
|
||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
|
||||
@@ -47,7 +49,6 @@ import {
|
||||
DRAINAGE_LAYERS,
|
||||
fetchDrainageLayers,
|
||||
createProgressReporter,
|
||||
analyzeFailed,
|
||||
fitCanvasToViewport,
|
||||
fitViewToRoute,
|
||||
observeViewportSize,
|
||||
@@ -180,7 +181,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
/** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */
|
||||
let frameView: Window = window;
|
||||
let loadSequence = 0;
|
||||
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
|
||||
/** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */
|
||||
const fordWaits = () => fordWaitsOf(projectId);
|
||||
let canvasWidth = 0;
|
||||
@@ -276,6 +276,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
markedChainage,
|
||||
stationIntervalM: MAP_STATION_INTERVAL_M,
|
||||
intervalSpans,
|
||||
candidates: live.suggest.candidates(),
|
||||
});
|
||||
updateImageTransform();
|
||||
}
|
||||
@@ -338,6 +339,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
* — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */
|
||||
function apply(response: DetailBasinResponse): void {
|
||||
basins = response.basins;
|
||||
live.suggest.update(response);
|
||||
mainBoundary = response.main_polygon_lonlat ?? [];
|
||||
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
|
||||
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
|
||||
@@ -374,32 +376,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
syncPipeSelection();
|
||||
}
|
||||
|
||||
/** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */
|
||||
async function run(request: () => Promise<DetailBasinResponse>): Promise<void> {
|
||||
if (!projectId) return;
|
||||
analyzeButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = L("B05_Drainage_Status_Analyzing");
|
||||
showProgress(null, L("B05_Drainage_Status_Analyzing"));
|
||||
const seq = ++requestSeq;
|
||||
try {
|
||||
const response = await request();
|
||||
if (seq !== requestSeq) return; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
|
||||
apply(response);
|
||||
status.hidden = basins.length > 0;
|
||||
if (!basins.length) status.textContent = response.stale ?? L("B05_Drainage_Status_NoBasin");
|
||||
scheduleDraw();
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq) return;
|
||||
status.hidden = false;
|
||||
status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed"));
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
analyzeButton.disabled = false;
|
||||
showProgress(null, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
/** 세부유역 요청 한 번 — 버튼 잠금 · 진행 문구 · 오류 · 최신 응답만(347-2 에 나눔). */
|
||||
const runner = createDetailRunner({
|
||||
projectId: () => projectId,
|
||||
analyzeButton,
|
||||
status,
|
||||
showProgress,
|
||||
apply,
|
||||
basinCount: () => basins.length,
|
||||
scheduleDraw,
|
||||
label: L,
|
||||
});
|
||||
const { run } = runner;
|
||||
|
||||
/** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */
|
||||
const pipeRequest = () =>
|
||||
@@ -418,7 +406,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
|
||||
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
|
||||
* 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */
|
||||
const loadSaved = (): Promise<void> =>
|
||||
const loadSaved = (): Promise<boolean> =>
|
||||
run(() => {
|
||||
const pending = readPendingPipes(projectId as string);
|
||||
return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감
|
||||
@@ -428,12 +416,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
|
||||
/** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때
|
||||
* 옛 관이 살아난다(2026-08-02 사용자 보고). */
|
||||
const resetPipes = (): Promise<void> => run(() => resetDetailPipePoints(projectId as string));
|
||||
const resetPipes = (): Promise<boolean> => run(() => resetDetailPipePoints(projectId as string));
|
||||
|
||||
/** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김).
|
||||
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
|
||||
const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest())));
|
||||
|
||||
// 자동/수동 다시 셈(347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔).
|
||||
const live = createDrainageLive({ root, basinList, pipeRequest, runner, rememberPipes });
|
||||
analyzeButton.addEventListener("click", () => void analyze());
|
||||
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
|
||||
autoButton.addEventListener("click", () => void resetPipes());
|
||||
@@ -668,10 +658,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
syncPipeSelection(notify);
|
||||
scheduleDraw();
|
||||
},
|
||||
async savePipes() {
|
||||
async savePipes(confirm = false) {
|
||||
if (!projectId) return 0;
|
||||
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
|
||||
const response = await saveDetailPipePoints(projectId, pipeRequest());
|
||||
const response = await saveDetailPipePoints(projectId, pipeRequest(), confirm);
|
||||
if (confirm && !response.stale) live.markFresh();
|
||||
apply(response);
|
||||
scheduleDraw();
|
||||
return response.pipe_count;
|
||||
@@ -695,5 +686,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
widthResizer.dispose();
|
||||
},
|
||||
rehome: () => resizeObserver.rehome(),
|
||||
pipelineStage: live.stage,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
|
||||
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
|
||||
import type { StageFn } from "../A00_Common/design/design_pipeline_types";
|
||||
|
||||
export interface DrainagePanel {
|
||||
root: HTMLElement;
|
||||
@@ -40,7 +41,10 @@ export interface DrainagePanel {
|
||||
) => void;
|
||||
removePipe: (chainageM: number) => void;
|
||||
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
|
||||
savePipes: () => Promise<number>;
|
||||
/** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */
|
||||
savePipes: (confirm?: boolean) => Promise<number>;
|
||||
/** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */
|
||||
pipelineStage: StageFn<"drainage">;
|
||||
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
||||
selectBasinByChainage: (chainageM: number | null) => void;
|
||||
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
|
||||
|
||||
@@ -61,8 +61,8 @@ export interface PipeEditor {
|
||||
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시).
|
||||
* `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */
|
||||
/** 같은 틱에 여러 번 불러도 한 번만 돈다 — 옛 소수 관을 한꺼번에 옮겨도 세부유역 요청은 한 번(342-3). */
|
||||
export function batched(work: () => Promise<void>): () => Promise<void> {
|
||||
let queued: Promise<void> | null = null;
|
||||
export function batched<T>(work: () => Promise<T>): () => Promise<T> {
|
||||
let queued: Promise<T> | null = null;
|
||||
return () =>
|
||||
(queued ??= Promise.resolve().then(() => {
|
||||
queued = null;
|
||||
|
||||
@@ -70,6 +70,8 @@ export interface DrainageScene {
|
||||
stationIntervalM: number;
|
||||
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */
|
||||
intervalSpans: ReadonlyArray<RouteSpanBand>;
|
||||
/** 관 자리 후보 누가거리(347-3 · 제안만) — 계획선 위 점선 원. */
|
||||
candidates: ReadonlyArray<number>;
|
||||
}
|
||||
|
||||
export function drawDrainageScene(
|
||||
@@ -168,6 +170,20 @@ export function drawDrainageScene(
|
||||
scene.maxHotspotArea,
|
||||
);
|
||||
}
|
||||
// 관 자리 후보 — 점선 원(배관 마커 아래 · 제안만이라 채우지 않음).
|
||||
scene.candidates.forEach((chainage) => {
|
||||
const sample = scene.strengthSamples[Math.round(chainage)];
|
||||
if (!sample) return;
|
||||
const [x, y] = projector.toScreen(sample.x, sample.y);
|
||||
context.save();
|
||||
context.setLineDash([3, 3]);
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#1e88e5";
|
||||
context.beginPath();
|
||||
context.arc(x, y, 9, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
});
|
||||
// 배관(관 매설) 마커 — 계획선 위.
|
||||
scene.pipeEditor.draw(context, view, scene.pipeColor);
|
||||
// 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Run.ts
|
||||
* 세부유역 요청 한 번 감싸기 — 버튼 잠금 · 진행 문구 · 오류 표기 · 최신 응답만 채택(차례 번호).
|
||||
*
|
||||
* 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄이라 나눔(347-2). 「지금 노선으로 셈」 답도
|
||||
* 같은 차례 번호를 타서, 늦게 온 세부유역 답이 새 답을 덮지 않음 — `quiet` 이면 버튼 · 가운데 진행
|
||||
* 원 · 상태 글을 안 건드림(칸 구석 「계산 중」은 부르는 쪽 · 조작을 막지 않음).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DetailBasinResponse } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import { analyzeFailed } from "./B05_Profile_UI_Drainage_Parts";
|
||||
|
||||
type StatusKey =
|
||||
| "B05_Drainage_Status_Analyzing"
|
||||
| "B05_Drainage_Status_NoBasin"
|
||||
| "B05_Drainage_Status_AnalyzeFailed";
|
||||
|
||||
export interface DetailRunnerDeps {
|
||||
projectId: () => string | null;
|
||||
analyzeButton: HTMLButtonElement;
|
||||
status: HTMLElement;
|
||||
showProgress: (ratio: number | null, label: string | null) => void;
|
||||
apply: (response: DetailBasinResponse) => void;
|
||||
basinCount: () => number;
|
||||
scheduleDraw: () => void;
|
||||
label: (key: StatusKey) => string;
|
||||
}
|
||||
|
||||
export interface DetailRunner {
|
||||
projectId: () => string | null;
|
||||
/** 요청 하나 — 답을 화면에 올렸으면 true(뒤에 보낸 요청이 있으면 false). */
|
||||
run(request: () => Promise<DetailBasinResponse>, quiet?: boolean): Promise<boolean>;
|
||||
}
|
||||
|
||||
export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner {
|
||||
const { analyzeButton, status, showProgress, label } = deps;
|
||||
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
|
||||
return {
|
||||
projectId: deps.projectId,
|
||||
async run(request, quiet = false) {
|
||||
if (!deps.projectId()) return false;
|
||||
if (!quiet) {
|
||||
analyzeButton.disabled = true;
|
||||
status.hidden = false;
|
||||
status.textContent = label("B05_Drainage_Status_Analyzing");
|
||||
showProgress(null, label("B05_Drainage_Status_Analyzing"));
|
||||
}
|
||||
const seq = ++requestSeq;
|
||||
try {
|
||||
const response = await request();
|
||||
if (seq !== requestSeq) return false; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
|
||||
deps.apply(response);
|
||||
status.hidden = deps.basinCount() > 0;
|
||||
if (!deps.basinCount())
|
||||
status.textContent = response.stale ?? label("B05_Drainage_Status_NoBasin");
|
||||
deps.scheduleDraw();
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (seq !== requestSeq) return false;
|
||||
if (error instanceof DOMException && error.name === "AbortError") return false;
|
||||
status.hidden = false;
|
||||
status.textContent = analyzeFailed(error, label("B05_Drainage_Status_AnalyzeFailed"));
|
||||
return false;
|
||||
} finally {
|
||||
if (seq === requestSeq) {
|
||||
analyzeButton.disabled = false;
|
||||
showProgress(null, null);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Suggest.ts
|
||||
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관 초안으로.
|
||||
* 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음.
|
||||
* 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판).
|
||||
* ========================================================================== */
|
||||
|
||||
import type {
|
||||
DetailBasinResponse,
|
||||
DetailPipeInput,
|
||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
applySuggestions,
|
||||
collectSuggestions,
|
||||
type Suggestion,
|
||||
} from "./logic/B05_Profile_Drainage_Suggest";
|
||||
|
||||
const FACILITY_NAME: Record<string, string> = {
|
||||
pipe: "배관",
|
||||
box_culvert: "BOX암거",
|
||||
ford_bridge: "세월교",
|
||||
};
|
||||
|
||||
const facilityName = (id: string): string => FACILITY_NAME[id] ?? id;
|
||||
|
||||
/** 제안 한 줄 글. */
|
||||
export function suggestionText(item: Suggestion): string {
|
||||
const at = `측점 ${Math.round(item.chainageM)} m`;
|
||||
if (item.kind === "position") return `${at} — 관 자리 후보(흐름이 모이는 오목 자리)`;
|
||||
if (item.kind === "facility")
|
||||
return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`;
|
||||
return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`;
|
||||
}
|
||||
|
||||
export interface DrainageSuggest {
|
||||
root: HTMLElement;
|
||||
update(response: DetailBasinResponse): void;
|
||||
/** 관 자리 후보 누가거리 — 배수유역도 점선 원. */
|
||||
candidates(): number[];
|
||||
}
|
||||
|
||||
export function createDrainageSuggest(
|
||||
pipeRequest: () => DetailPipeInput[],
|
||||
applyPoints: (points: DetailPipeInput[]) => void,
|
||||
): DrainageSuggest {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-drainage__suggest";
|
||||
root.hidden = true;
|
||||
const head = document.createElement("div");
|
||||
head.className = "b05-drainage__suggest-head";
|
||||
const title = document.createElement("span");
|
||||
const apply = document.createElement("button");
|
||||
apply.type = "button";
|
||||
apply.className = "b05-drainage__analyze";
|
||||
apply.textContent = "적용";
|
||||
apply.title =
|
||||
"제안을 관 초안에 한 번에 얹습니다(관 더함 · 관경 · 시설 바꿈) — [저장] 때 남습니다.";
|
||||
head.append(title, apply);
|
||||
const list = document.createElement("ul");
|
||||
list.className = "b05-drainage__suggest-list";
|
||||
root.append(head, list);
|
||||
let suggestions: Suggestion[] = [];
|
||||
|
||||
apply.addEventListener("click", () => {
|
||||
if (suggestions.length) applyPoints(applySuggestions(pipeRequest(), suggestions));
|
||||
});
|
||||
|
||||
return {
|
||||
root,
|
||||
update(response) {
|
||||
suggestions = response.stale
|
||||
? []
|
||||
: collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []);
|
||||
root.hidden = suggestions.length === 0;
|
||||
title.textContent = `제안 ${suggestions.length}건`;
|
||||
list.replaceChildren(
|
||||
...suggestions.map((item) => {
|
||||
const row = document.createElement("li");
|
||||
row.textContent = suggestionText(item);
|
||||
return row;
|
||||
}),
|
||||
);
|
||||
},
|
||||
candidates: () =>
|
||||
suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM),
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
invalidateSectionDetail,
|
||||
saveCachedCrossPatches,
|
||||
} from "../B06_Section/B06_Section_Section_Store";
|
||||
import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
|
||||
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
|
||||
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
|
||||
import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
|
||||
@@ -87,9 +86,6 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave {
|
||||
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다.
|
||||
invalidateSectionDetail(projectId);
|
||||
ctx.renderLatest(await ctx.loadLatest(true));
|
||||
// 저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 저장은 성공 처리.
|
||||
const routeId = ctx.latest()?.route?.id;
|
||||
if (routeId) void saveCorridorIfDirty(projectId, routeId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
* 그리기·라벨·집기가 출처를 안 가리게 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import {
|
||||
createNormalizer,
|
||||
type GeoJsonCollection,
|
||||
@@ -71,8 +70,7 @@ export async function loadRouteEditContours(
|
||||
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
|
||||
const data = await fetchCachedJson<ContourResponse>(
|
||||
projectId,
|
||||
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
|
||||
`/contour?interval=${interval}&smooth=${options.smooth}`,
|
||||
contourAssetUrl(projectId, options.surfaceModelId, interval, options.smooth),
|
||||
);
|
||||
const all = data.contours ?? [];
|
||||
const onUnit = (level: number): boolean => Math.abs(level % LAS_CONTOUR_UNIT_M) < 1e-6;
|
||||
|
||||
@@ -266,3 +266,70 @@
|
||||
.b05-drainage[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 배수유역 낡음(347-2) — 칸만 흐림 · 가운데 [업데이트] · 구석 「계산 중」(조작은 막지 않음). */
|
||||
.b05-drainage.is-stale .b05-drainage__canvas,
|
||||
.b05-drainage.is-stale .b05-drainage__image,
|
||||
.b05-drainage.is-stale .b05-drainage__summary,
|
||||
.b05-drainage.is-stale .b05-drainage__basins,
|
||||
.b05-drainage.is-stale .b05-drainage__suggest {
|
||||
filter: blur(2px);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.b05-drainage__veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-drainage__veil[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-drainage__veil .b05-drainage__update {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.b05-drainage__busy {
|
||||
position: absolute;
|
||||
right: var(--spacing-8);
|
||||
bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-drainage__busy[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-drainage__mode {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
padding: var(--spacing-8) 0;
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
/* 제안 목록(347-3) — 유역 목록 위 · [적용] 한 번. */
|
||||
.b05-drainage__suggest {
|
||||
flex: 0 0 auto;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding: var(--spacing-8);
|
||||
font-size: var(--text-caption);
|
||||
color: var(--color-text-body);
|
||||
}
|
||||
|
||||
.b05-drainage__suggest-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.b05-drainage__suggest-list {
|
||||
max-height: 6em;
|
||||
margin: var(--spacing-8) 0 0;
|
||||
padding-left: 1.2em;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { createTerrainCompass } from "@ui/ui_template_compass";
|
||||
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import { contourAssetUrl, fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import { bindCursorPivotControls, TOP_VIEW_TILT } from "../B04_PreProcess/B04_PreProcess_UI_Camera";
|
||||
import {
|
||||
createRouteMarkers,
|
||||
@@ -325,7 +325,7 @@ export function createRouteViewer(): RouteViewer {
|
||||
contours: Array<{ level: number; coordinates: [number, number, number][] }>;
|
||||
}>(
|
||||
current.projectId,
|
||||
`${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`,
|
||||
contourAssetUrl(current.projectId, current.modelId, interval, current.smooth),
|
||||
);
|
||||
clearContours();
|
||||
// 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Drainage_Live.ts
|
||||
* 배수유역 자동/수동 다시 셈(347-2) — 순수 규칙만. 화면 · fetch 는 `B05_Profile_UI_Drainage_Live.ts`.
|
||||
*
|
||||
* - 대상 = 평면(노선 초안) · 관 자리만 — 종단 · 횡단 조작은 배수유역 결과를 안 바꿈(묶음 deps 와 같음).
|
||||
* - 노선 초안이 없으면(저장 노선 그대로) 낡음 아님 — 관 조작은 세부 유역 나눔(0.2 s)이 바로 맞춤.
|
||||
* - 자동 = 조용한 뒤 `QUIET_MS` · 수동 = 흐림 + [업데이트] · 기본 수동.
|
||||
* - 답 채택 = 보낸 차례가 지금 차례와 같고 서버가 그만두지 않았을 때만(늦은 답 버림).
|
||||
* ========================================================================== */
|
||||
|
||||
export type DrainageMode = "manual" | "auto";
|
||||
|
||||
/** 자동 모드 — 마지막 조작 뒤 이만큼 조용하면 셈(ms · 341 결정 거리 6 · 3 초). */
|
||||
export const QUIET_MS = 3000;
|
||||
|
||||
/** 계정 화면 설정 글자 → 모드(없거나 모르는 값 = 수동). */
|
||||
export const parseDrainageMode = (raw: string | null | undefined): DrainageMode =>
|
||||
raw === "auto" ? "auto" : "manual";
|
||||
|
||||
/** 묶음 drainage 단계 입력 → 낡았는가 — 노선 초안이 있을 때만. */
|
||||
export const draftMakesStale = (route: unknown): boolean => route !== null && route !== undefined;
|
||||
|
||||
/** 「지금 노선으로 셈」 몸통 — 서버 `post_drainage_live`. */
|
||||
export function liveRequestBody<P>(
|
||||
generation: number,
|
||||
route: unknown,
|
||||
points: P[],
|
||||
): { generation: number; route: unknown; points: P[] } {
|
||||
return { generation, route: route ?? null, points };
|
||||
}
|
||||
|
||||
/** 답을 화면에 올리는가 — 서버가 그만둔 답 · 옛 차례 답은 버림. */
|
||||
export function acceptLive(
|
||||
response: { status?: string; generation?: number },
|
||||
sentGeneration: number,
|
||||
currentGeneration: number,
|
||||
): boolean {
|
||||
return (
|
||||
response.status !== "superseded" &&
|
||||
sentGeneration === currentGeneration &&
|
||||
(response.generation === undefined || response.generation === sentGeneration)
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Drainage_Suggest.ts
|
||||
* 배수유역 제안(347-3) — 관경 · 시설 종류 · 관 자리 후보를 모으고 [적용] 때 관 초안에 한 번에 얹음.
|
||||
* 순수 함수만(화면 = `B05_Profile_UI_Drainage_Suggest.ts`).
|
||||
*
|
||||
* - 관경 = 유역 추천이 배관이고 추천 관경이 지금 관경과 다를 때(「Ø→Ø 배관 제안」).
|
||||
* - 시설 = 유역 추천이 BOX암거 · 세월교이고 지금 관이 그 종류가 아닐 때.
|
||||
* - 관 자리 = 서버 `suggest_pipe_positions`(간격 보충 규칙 · 기존 관에서 먼 곳).
|
||||
* - 자동 바꿈 없음 — 모으기만 하고 바꾸는 것은 `applySuggestions`([적용])뿐.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 관 자리가 같은 유역으로 볼 거리(m) — 화면 `PIPE_MATCH_M` 과 같은 값. */
|
||||
const SAME_PIPE_M = 0.5;
|
||||
|
||||
interface PipeLike {
|
||||
chainage_m: number;
|
||||
source?: string;
|
||||
facility?: string;
|
||||
options?: Record<string, string | number>;
|
||||
}
|
||||
|
||||
interface BasinLike {
|
||||
chainage_m: number;
|
||||
recommended_facility?: string;
|
||||
recommended_diameter_mm?: number | null;
|
||||
}
|
||||
|
||||
export type Suggestion =
|
||||
| { kind: "diameter"; chainageM: number; from: number | null; to: number }
|
||||
| { kind: "facility"; chainageM: number; from: string; to: string }
|
||||
| { kind: "position"; chainageM: number };
|
||||
|
||||
const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_PIPE_M;
|
||||
|
||||
/** 응답(유역 · 관 자리 후보) + 지금 관 목록 → 제안 목록(관 자리 차례). */
|
||||
export function collectSuggestions(
|
||||
pipes: ReadonlyArray<PipeLike>,
|
||||
basins: ReadonlyArray<BasinLike>,
|
||||
candidates: ReadonlyArray<{ chainage_m: number }>,
|
||||
): Suggestion[] {
|
||||
const found: Suggestion[] = [];
|
||||
basins.forEach((basin) => {
|
||||
const pipe = pipes.find((item) => near(item.chainage_m, basin.chainage_m));
|
||||
if (!pipe) return;
|
||||
const facility = pipe.facility ?? "pipe";
|
||||
const want = basin.recommended_facility ?? "pipe";
|
||||
if (want !== "pipe" && facility !== want) {
|
||||
found.push({ kind: "facility", chainageM: pipe.chainage_m, from: facility, to: want });
|
||||
return;
|
||||
}
|
||||
const to = basin.recommended_diameter_mm;
|
||||
const raw = pipe.options?.pipe_diameter_mm;
|
||||
const from = raw === undefined || raw === "" ? null : Number(raw);
|
||||
if (want === "pipe" && facility === "pipe" && to != null && from !== to)
|
||||
found.push({ kind: "diameter", chainageM: pipe.chainage_m, from, to });
|
||||
});
|
||||
candidates
|
||||
.filter((item) => !pipes.some((pipe) => near(pipe.chainage_m, item.chainage_m)))
|
||||
.forEach((item) => found.push({ kind: "position", chainageM: item.chainage_m }));
|
||||
return found.sort((a, b) => a.chainageM - b.chainageM);
|
||||
}
|
||||
|
||||
/** [적용] — 제안을 관 초안에 한 번에(관 더함 · 관경 · 시설 바꿈). 입력 목록은 안 고침. */
|
||||
export function applySuggestions<P extends PipeLike>(
|
||||
pipes: ReadonlyArray<P>,
|
||||
suggestions: ReadonlyArray<Suggestion>,
|
||||
): P[] {
|
||||
const next = pipes.map(
|
||||
(pipe) => ({ ...pipe, options: pipe.options && { ...pipe.options } }) as P,
|
||||
);
|
||||
suggestions.forEach((item) => {
|
||||
if (item.kind === "position") {
|
||||
if (!next.some((pipe) => near(pipe.chainage_m, item.chainageM)))
|
||||
next.push({ chainage_m: item.chainageM, source: "spacing" } as P);
|
||||
return;
|
||||
}
|
||||
const pipe = next.find((candidate) => near(candidate.chainage_m, item.chainageM));
|
||||
if (!pipe) return;
|
||||
if (item.kind === "facility") pipe.facility = item.to;
|
||||
else pipe.options = { ...(pipe.options ?? {}), pipe_diameter_mm: item.to };
|
||||
});
|
||||
return next.sort((a, b) => a.chainage_m - b.chainage_m);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_MassHaul_Stage.ts
|
||||
* 계산 묶음 `masshaul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — cross(측점 열쇠마다 면적) ·
|
||||
* profile → 유토곡선 계열(`computeMassHaulSeries` · 화면 유토곡선 · B06 과 같은 계산).
|
||||
*
|
||||
* 출력 = `MassHaulStageOutput`(계열 + 흙 배분에 보낼 횡단 기준 결과) · 순수 계산(DOM · fetch · 세션 없음).
|
||||
* 토량환산계수 · 자연 사토 기울기는 부르는 쪽이 넘김(`context`) — 없으면 null.
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeMassHaulSeries, type MassHaulSeries } from "@util/common_util_mass_haul";
|
||||
import type { EarthworkConversion, MassHaulSection } from "@util/common_util_mass_haul_types";
|
||||
import type { StageContext } from "../../A00_Common/design/design_pipeline_types";
|
||||
|
||||
export interface MassHaulStageContext {
|
||||
conversion: EarthworkConversion;
|
||||
naturalSpoilMinSlope?: number;
|
||||
}
|
||||
|
||||
export interface MassHaulStageOutput {
|
||||
series: MassHaulSeries[];
|
||||
/** 흙 배분(haul 단계)에 보낼 결과 — 횡단 기준(없으면 첫 계열) · 계열이 없으면 null. */
|
||||
haulInput: MassHaulSeries["result"] | null;
|
||||
}
|
||||
|
||||
/** `masshaul` 단계 만들기 — `context()` 는 부를 때마다 읽음(설정이 바뀌면 다음 계산부터). */
|
||||
export function createMassHaulStage(context: () => MassHaulStageContext | null) {
|
||||
return (ctx: StageContext<"masshaul">): MassHaulStageOutput | null => {
|
||||
const cross = ctx.out.cross;
|
||||
const settings = context();
|
||||
if (!cross || !settings) return null;
|
||||
const sections: MassHaulSection[] = Object.values(cross.byKey)
|
||||
.sort((a, b) => a.chainageM - b.chainageM)
|
||||
.map((entry) => ({
|
||||
station_id: entry.key,
|
||||
chainage_m: entry.chainageM,
|
||||
design: entry.detail as MassHaulSection["design"],
|
||||
}));
|
||||
const samples = ctx.out.profile?.samples ?? [];
|
||||
const longitudinal = {
|
||||
length_m:
|
||||
samples[samples.length - 1]?.chainage_m ?? sections[sections.length - 1]?.chainage_m ?? 0,
|
||||
design_profiles: samples.length ? [{ samples }] : [],
|
||||
};
|
||||
const series = computeMassHaulSeries(
|
||||
longitudinal,
|
||||
sections,
|
||||
settings.conversion,
|
||||
settings.naturalSpoilMinSlope,
|
||||
);
|
||||
const picked = series.find((entry) => entry.basis === "cross") ?? series[0];
|
||||
return { series, haulInput: picked?.result ?? null };
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user