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:
63 changed files with 4221 additions and 261 deletions
+93 -47
View File
@@ -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);
},
+496
View File
@@ -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),
}
+224
View File
@@ -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
+43 -41
View File
@@ -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 -7
View File
@@ -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;
}
+225
View File
@@ -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;
},
};
}
+24 -32
View File
@@ -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)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
+2 -2
View File
@@ -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;
}
+2 -2
View File
@@ -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 };
};
}