feat(B05): 계획노선 편집 분석 지형 고르기 — 라이다 있는 프로젝트도 수치지형도로 분석(133-1)
- 편집 모달 「분석 지형: 라이다 · 수치지형도」 고르기 · 라이다와 수치지형도 서피스가 둘 다 있을 때만 보임 · 기본 라이다 · 값은 화면 상태 pref(routeedit-terrain) - 고르면 등고선 · 구간 기울기 · 거리 재기 지반고 · 횡단 미리보기 · 종단 띠를 고른 지형으로 다시 그림 · 노선 추천도 고른 지형으로 요청 - 서버 통로(elevations · cross-preview · profile-preview · initial)가 terrain(las · sheet) 을 받아 sampler 를 고름 · GET route/terrain-sources 새 길 - 확정 params · [확인] 재계산 · 종 · 횡단 · 3D 정본은 안 바뀜 - 지표면 허용 목록에 sheet_* 여섯 넣음 — 54-22 뒤 LAS 없는 프로젝트 sheet_laplace 가 막혀 있던 길도 풀림 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UoSr2sqvYtBf1dtQdus88s
This commit is contained in:
@@ -76,6 +76,8 @@ export const STATE_REGISTRY = {
|
||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
||||
"routeedit-locks": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 분석 지형(las · sheet). 확정 지표면은 안 바꿈 */
|
||||
"routeedit-terrain": { bucket: "pref", scope: "project" },
|
||||
"masshaul-open": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -6,12 +6,37 @@
|
||||
* POST /projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
|
||||
* POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
||||
* POST /projects/{id}/route/profile-preview → 지금 노드의 균형 종단(편집 창 종단 띠)
|
||||
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
|
||||
*
|
||||
* 편집 창 분석(지반고 · 횡단 미리보기 · 종단 띠 · 추천)은 **고른 분석 지형**(`terrain`)을 실어
|
||||
* 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음.
|
||||
*
|
||||
* 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대)
|
||||
* 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
|
||||
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
|
||||
export type AnalysisTerrain = "las" | "sheet";
|
||||
|
||||
/** 고른 분석 지형 — 화면 상태 보관소 pref 통(노선 편집 고정과 같은 자리) · 기본 las. */
|
||||
export const analysisTerrain = (projectId: string): AnalysisTerrain =>
|
||||
readState<string>("routeedit-terrain", projectId) === "sheet" ? "sheet" : "las";
|
||||
|
||||
export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void =>
|
||||
writeState("routeedit-terrain", terrain, projectId);
|
||||
|
||||
/** 이 프로젝트가 고를 수 있는 분석 지형 — 둘이면 고르기를 띄운다. */
|
||||
export async function fetchTerrainSources(projectId: string): Promise<AnalysisTerrain[]> {
|
||||
const payload = await requestJson<{ sources: AnalysisTerrain[] }>(
|
||||
`/projects/${projectId}/route/terrain-sources`,
|
||||
{ method: "GET" },
|
||||
60000,
|
||||
);
|
||||
return payload.sources ?? [];
|
||||
}
|
||||
|
||||
/** 노선 재계산 대기 상한 — 배수유역 분석(90초대)까지 포함해 넉넉히 잡는다. */
|
||||
const REPLAN_TIMEOUT_MS = 15 * 60 * 1000;
|
||||
@@ -153,7 +178,7 @@ export async function fetchRouteElevations(
|
||||
): Promise<Array<number | null>> {
|
||||
const payload = await requestJson<{ z: Array<number | null> }>(
|
||||
`/projects/${projectId}/route/elevations`,
|
||||
{ method: "POST", body: JSON.stringify({ points }) },
|
||||
{ method: "POST", body: JSON.stringify({ points, terrain: analysisTerrain(projectId) }) },
|
||||
60000,
|
||||
);
|
||||
return payload.z;
|
||||
@@ -196,7 +221,10 @@ export async function fetchCrossPreview(
|
||||
): Promise<CrossPreviewResponse> {
|
||||
return requestJson<CrossPreviewResponse>(
|
||||
`/projects/${projectId}/route/cross-preview`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }),
|
||||
},
|
||||
120000,
|
||||
);
|
||||
}
|
||||
@@ -219,7 +247,10 @@ export async function fetchProfilePreview(
|
||||
): Promise<unknown> {
|
||||
const payload = await requestJson<{ profile: unknown }>(
|
||||
`/projects/${projectId}/route/profile-preview`,
|
||||
{ method: "POST", body: JSON.stringify({ vertices, criteria }) },
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ vertices, criteria, terrain: analysisTerrain(projectId) }),
|
||||
},
|
||||
120000,
|
||||
);
|
||||
return payload.profile;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* 답을 펴는 자리는 `normalizeRouteInitial`(`_RouteEdit_Initial_Model`) 한 곳이다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { requestJson } from "./B05_Profile_Api_Replan";
|
||||
import { analysisTerrain, requestJson } from "./B05_Profile_Api_Replan";
|
||||
|
||||
/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */
|
||||
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
@@ -56,7 +56,10 @@ export async function fetchRouteInitial(
|
||||
try {
|
||||
return await requestJson<unknown>(
|
||||
`/projects/${projectId}/route/initial`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }),
|
||||
},
|
||||
INITIAL_TIMEOUT_MS,
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -120,6 +120,8 @@ class RouteInitialRequest(BaseModel):
|
||||
criteria: RouteInitialCriteria | None = None
|
||||
# 추천 점수에 균형 종단(PLAN 63-4)을 넣을지 — 끄면 원지반만(63-4 전 점수). 파일에 안 적힘.
|
||||
with_profile: bool = True
|
||||
# 분석 지형(PLAN 133-1) — 추천만 고른 지형으로 · [확인] 재계산은 확정 지표면 그대로.
|
||||
terrain: Literal["las", "sheet"] = "las"
|
||||
|
||||
|
||||
class RouteReplanRequest(BaseModel):
|
||||
|
||||
@@ -9,7 +9,7 @@ import functools
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi.responses import JSONResponse
|
||||
@@ -38,7 +38,10 @@ async def initial_route(
|
||||
load_terrain_and_streams,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import project_pipes
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
analysis_terrain_params,
|
||||
get_surface_confirmation_params,
|
||||
)
|
||||
|
||||
weights = request.weights.model_dump() if request.weights else None
|
||||
if request.mode == "weighted" and weights and abs(sum(weights.values()) - 100.0) > 0.5:
|
||||
@@ -77,6 +80,7 @@ async def initial_route(
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||||
selection = analysis_terrain_params(selection, request.terrain)
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
terrain, streams = await asyncio.to_thread(
|
||||
@@ -207,12 +211,19 @@ async def balanced_profile_of(
|
||||
points: list[dict[str, float]],
|
||||
criteria: Any,
|
||||
design_curves: list[dict[str, Any]] | None = None,
|
||||
terrain: Literal["las", "sheet"] = "las",
|
||||
) -> dict[str, Any] | None:
|
||||
"""노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀."""
|
||||
"""노선 정점의 균형 종단 한 벌(`route_profile`) — [확인] 체인 · 편집 창 종단 띠가 함께 씀.
|
||||
|
||||
`terrain` 은 편집 창 종단 띠만 바꿈(PLAN 133-1) — [확인] 체인은 기본(확정 지표면).
|
||||
"""
|
||||
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial import load_terrain_and_streams
|
||||
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import route_profile
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
analysis_terrain_params,
|
||||
get_surface_confirmation_params,
|
||||
)
|
||||
|
||||
grade_class, design_speed, terrain_type = await replan._road_settings(project_id)
|
||||
max_grade = legal_grade_limit_pct(grade_class, terrain_type, False, design_speed) / 100.0
|
||||
@@ -221,15 +232,16 @@ async def balanced_profile_of(
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||||
selection = analysis_terrain_params(selection, terrain)
|
||||
try:
|
||||
terrain, _streams = await asyncio.to_thread(
|
||||
surface, _streams = await asyncio.to_thread(
|
||||
load_terrain_and_streams, project_root, selection
|
||||
)
|
||||
profile = await asyncio.to_thread(
|
||||
functools.partial(
|
||||
route_profile,
|
||||
[(p["x"], p["y"]) for p in points],
|
||||
terrain,
|
||||
surface,
|
||||
max_grade=max_grade,
|
||||
design_speed=design_speed or 20,
|
||||
design_curves=design_curves,
|
||||
@@ -281,6 +293,7 @@ class ProfilePreviewRequest(BaseModel):
|
||||
|
||||
vertices: list[replan.RouteVertexInput] = Field(..., min_length=2)
|
||||
criteria: replan.RouteInitialCriteria | None = None
|
||||
terrain: Literal["las", "sheet"] = "las"
|
||||
|
||||
|
||||
@replan.router.post("/{project_id}/route/profile-preview", response_model=None)
|
||||
@@ -308,7 +321,9 @@ async def profile_preview(
|
||||
# [확인]은 CSV(소수 4자리)로 적은 뒤 읽는다 — 같은 점을 셈하게 같은 자릿수로.
|
||||
points = [{"x": round(float(x), 4), "y": round(float(y), 4)} for x, y in built.vertices]
|
||||
curves = [curve.as_dict() for curve in built.curves]
|
||||
profile = await balanced_profile_of(project_id, project_root, points, request.criteria, curves)
|
||||
profile = await balanced_profile_of(
|
||||
project_id, project_root, points, request.criteria, curves, request.terrain
|
||||
)
|
||||
if profile is None:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
|
||||
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
|
||||
POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기
|
||||
GET /api/projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
|
||||
|
||||
분석 지형(`terrain`, PLAN 133-1) — las = 확정 지표면 · sheet = 수치지형도 서피스. 모달 분석만
|
||||
고른 지형을 쓴다 — 확정 지표면 · 종 · 횡단 정본은 안 바뀐다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -30,7 +34,12 @@ from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
AnalysisTerrain,
|
||||
analysis_terrain_params,
|
||||
analysis_terrain_sources,
|
||||
get_surface_confirmation_params,
|
||||
)
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
@@ -47,6 +56,7 @@ class ElevationRequest(BaseModel):
|
||||
"""사업지 좌표계(m) 점 묶음 [[x, y], …]."""
|
||||
|
||||
points: list[tuple[float, float]] = Field(..., min_length=1, max_length=MAX_POINTS)
|
||||
terrain: AnalysisTerrain = "las"
|
||||
|
||||
|
||||
def _sample(project_root: Path, params: dict, points: list[tuple[float, float]]):
|
||||
@@ -81,6 +91,7 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) ->
|
||||
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
params = analysis_terrain_params(params, request.terrain)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored))
|
||||
sampled = await asyncio.to_thread(_sample, project_root, params, request.points)
|
||||
@@ -120,6 +131,7 @@ class CrossPreviewRequest(BaseModel):
|
||||
station_interval_m: float | None = None
|
||||
#: 횡단 반폭(m) — 화면이 적용해 둔 값. 비면 서버 기본값.
|
||||
cross_half_width_m: float | None = Field(None, gt=0)
|
||||
terrain: AnalysisTerrain = "las"
|
||||
|
||||
|
||||
def _cross_preview(
|
||||
@@ -213,6 +225,7 @@ async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) ->
|
||||
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
params = analysis_terrain_params(params, request.terrain)
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored))
|
||||
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
|
||||
@@ -225,3 +238,20 @@ async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) ->
|
||||
},
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **preview}
|
||||
|
||||
|
||||
@router.get("/{project_id}/route/terrain-sources", response_model=None)
|
||||
async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse:
|
||||
"""편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김."""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
models_dir = Path(resolve_stored_project_path(stored)) / _MODELS_SUBDIR
|
||||
sources = await asyncio.to_thread(analysis_terrain_sources, params, models_dir)
|
||||
return {"status": "success", "project_id": str(project_id), "sources": sources}
|
||||
|
||||
@@ -28,7 +28,13 @@ import {
|
||||
type RouteEditContours,
|
||||
} from "./B05_Profile_UI_RouteEdit_Contour";
|
||||
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
|
||||
import { fetchRoutePlan } from "./B05_Profile_Api_Replan";
|
||||
import {
|
||||
analysisTerrain,
|
||||
fetchRoutePlan,
|
||||
fetchTerrainSources,
|
||||
setAnalysisTerrain,
|
||||
type AnalysisTerrain,
|
||||
} from "./B05_Profile_Api_Replan";
|
||||
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
|
||||
import {
|
||||
buildEditedPolyline,
|
||||
@@ -103,7 +109,7 @@ export async function openRouteEditModal(
|
||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||
const chrome = createRouteEditChrome();
|
||||
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
|
||||
const { minorContourButton } = chrome;
|
||||
const { minorContourButton, terrainSelect } = chrome;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
@@ -527,9 +533,10 @@ export async function openRouteEditModal(
|
||||
|
||||
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
|
||||
try {
|
||||
const [plan, drainage] = await Promise.all([
|
||||
const [plan, drainage, sources] = await Promise.all([
|
||||
fetchRoutePlan(projectId),
|
||||
fetchDrainageLayers(projectId, () => {}),
|
||||
fetchTerrainSources(projectId).catch((): AnalysisTerrain[] => []),
|
||||
]);
|
||||
if (closed) return;
|
||||
expected = plan.expected as Vertex[];
|
||||
@@ -553,28 +560,60 @@ export async function openRouteEditModal(
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
// 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
|
||||
const background = await loadRouteEditBackground(projectId, drainage, {
|
||||
surfaceModelId: options.surfaceModelId ?? null,
|
||||
intervalM: options.contourIntervalM ?? 1,
|
||||
smooth: options.smooth ?? false,
|
||||
});
|
||||
otherSheets = background.otherSheets;
|
||||
contours = background.contours;
|
||||
// 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 기본 라이다.
|
||||
const choosable = sources.includes("las") && sources.includes("sheet");
|
||||
terrainSelect.closest("label")!.hidden = !choosable;
|
||||
if (!choosable && analysisTerrain(projectId) === "sheet") setAnalysisTerrain(projectId, "las");
|
||||
terrainSelect.value = analysisTerrain(projectId);
|
||||
// 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
|
||||
const loadBackground = () =>
|
||||
loadRouteEditBackground(projectId, drainage, {
|
||||
surfaceModelId:
|
||||
analysisTerrain(projectId) === "sheet" ? null : (options.surfaceModelId ?? null),
|
||||
intervalM: options.contourIntervalM ?? 1,
|
||||
smooth: options.smooth ?? false,
|
||||
});
|
||||
const useBackground = (background: Awaited<ReturnType<typeof loadBackground>>): void => {
|
||||
otherSheets = background.otherSheets;
|
||||
contours = background.contours;
|
||||
pickedContour = -1;
|
||||
// 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다.
|
||||
minorContourButton.disabled = !contours?.minor;
|
||||
minorContourButton.title = contours?.minor
|
||||
? "1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)"
|
||||
: "LAS 등고선이 없어 1m 선이 없습니다";
|
||||
};
|
||||
const showStatus = (): void => {
|
||||
status.textContent =
|
||||
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
|
||||
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
|
||||
curveHint();
|
||||
};
|
||||
useBackground(await loadBackground());
|
||||
if (closed) return;
|
||||
// 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다.
|
||||
minorContourButton.disabled = !contours?.minor;
|
||||
if (!contours?.minor) minorContourButton.title = "LAS 등고선이 없어 1m 선이 없습니다";
|
||||
// 고르면 모달 안 분석을 고른 지형으로 다시 그림 — 등고선 · 기울기 · 종단 띠 · 횡단 · 잰 값.
|
||||
let terrainTicket = 0;
|
||||
terrainSelect.addEventListener("change", () => {
|
||||
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
|
||||
const mine = ++terrainTicket;
|
||||
void loadBackground().then((background) => {
|
||||
if (closed || mine !== terrainTicket) return;
|
||||
useBackground(background);
|
||||
measure.clear();
|
||||
slope.refresh();
|
||||
strip.refresh();
|
||||
void crossPreview.refresh();
|
||||
showStatus();
|
||||
draw();
|
||||
});
|
||||
});
|
||||
resize();
|
||||
// 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다.
|
||||
const opening = openingView(projectId, meta, view, planned);
|
||||
view = opening.view;
|
||||
rotation.set(opening.rotationRad);
|
||||
viewReady = true;
|
||||
status.textContent =
|
||||
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
|
||||
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
|
||||
curveHint();
|
||||
showStatus();
|
||||
draw();
|
||||
strip.refresh();
|
||||
} catch (error) {
|
||||
|
||||
@@ -36,6 +36,8 @@ export interface RouteEditChrome {
|
||||
measureText: HTMLElement;
|
||||
measureButton: HTMLButtonElement;
|
||||
minorContourButton: HTMLButtonElement;
|
||||
/** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */
|
||||
terrainSelect: HTMLSelectElement;
|
||||
}
|
||||
|
||||
/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
|
||||
@@ -51,6 +53,13 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
|
||||
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
|
||||
<i class="b05-routeedit__divider" aria-hidden="true"></i>
|
||||
<label class="b05-routeedit__terrain" hidden
|
||||
title="편집 창 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 종단 띠 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
|
||||
<select class="ui-select ui-select--sm" data-act="terrain">
|
||||
<option value="las">라이다</option>
|
||||
<option value="sheet">수치지형도</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
|
||||
aria-pressed="false" disabled
|
||||
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
|
||||
@@ -126,6 +135,7 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
const minorContourButton = overlay.querySelector<HTMLButtonElement>(
|
||||
'[data-act="contour-minor"]',
|
||||
)!;
|
||||
const terrainSelect = overlay.querySelector<HTMLSelectElement>('[data-act="terrain"]')!;
|
||||
|
||||
return {
|
||||
overlay,
|
||||
@@ -136,5 +146,6 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
measureText,
|
||||
measureButton,
|
||||
minorContourButton,
|
||||
terrainSelect,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
analysisTerrain,
|
||||
fetchProfilePreview,
|
||||
fetchRouteElevations,
|
||||
type RouteReplanVertex,
|
||||
@@ -68,7 +69,7 @@ export function createProfileStrip(params: ProfileStripParams): ProfileStrip {
|
||||
function refresh(): void {
|
||||
const on = params.withProfile();
|
||||
const { vertices, line, criteria } = params.now();
|
||||
const key = JSON.stringify([on, vertices, criteria]);
|
||||
const key = JSON.stringify([on, vertices, criteria, analysisTerrain(projectId)]);
|
||||
if (vertices.length < 2 || line.length < 2 || key === lastKey) return;
|
||||
lastKey = key;
|
||||
pending = draw(on, vertices, line, criteria);
|
||||
|
||||
@@ -141,6 +141,7 @@ export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
|
||||
return {
|
||||
refresh() {
|
||||
asking = null;
|
||||
cachedLine = null; // 분석 지형을 바꿔도 다시 묻게.
|
||||
},
|
||||
draw(context, args) {
|
||||
let first = "";
|
||||
|
||||
@@ -241,6 +241,19 @@
|
||||
background: var(--color-border);
|
||||
}
|
||||
|
||||
/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임. */
|
||||
.b05-routeedit__terrain {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4, 4px);
|
||||
font-size: 12px;
|
||||
color: var(--color-text-muted, inherit);
|
||||
}
|
||||
|
||||
.b05-routeedit__terrain[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */
|
||||
.b05-routeedit__measure {
|
||||
position: absolute;
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
"""WF1 지표면 확정 선택값의 기본값·DB 스냅샷 처리."""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import aiomysql
|
||||
|
||||
from config.config_system import (
|
||||
SHEET_SURFACE_DEFAULT_METHOD,
|
||||
SURFACE_CONFIRM_DEFAULT_FILTER,
|
||||
SURFACE_CONFIRM_DEFAULT_METHOD,
|
||||
SURFACE_CONFIRM_DEFAULT_SMOOTH,
|
||||
SURFACE_CONTOUR_INTERVAL_M,
|
||||
)
|
||||
|
||||
#: B05 계획노선 편집 모달의 분석 지형 — las = 확정 지표면 그대로 · sheet = 수치지형도 서피스
|
||||
AnalysisTerrain = Literal["las", "sheet"]
|
||||
#: 수치지형도 분석 지형 — LAS 없는 프로젝트 자동 확정(WF1)과 같은 모델
|
||||
SHEET_ANALYSIS_FILTER = f"sheet_{SHEET_SURFACE_DEFAULT_METHOD}"
|
||||
|
||||
SURFACE_CONFIRM_PARAM_KEYS = (
|
||||
"source_filter",
|
||||
"method",
|
||||
@@ -63,6 +70,26 @@ async def get_surface_confirmation_params(
|
||||
return resolved
|
||||
|
||||
|
||||
def _is_sheet(params: dict[str, Any]) -> bool:
|
||||
return str(params.get("source_filter") or "").startswith("sheet_")
|
||||
|
||||
|
||||
def analysis_terrain_params(params: dict[str, Any], terrain: AnalysisTerrain) -> dict[str, Any]:
|
||||
"""편집 모달 분석에 쓸 지표면 params — 확정 params 는 안 고침(정본 · 종 · 횡단 그대로)."""
|
||||
if terrain != "sheet" or _is_sheet(params):
|
||||
return params
|
||||
return {**params, "source_filter": SHEET_ANALYSIS_FILTER, "method": "dtm", "smooth": True}
|
||||
|
||||
|
||||
def analysis_terrain_sources(params: dict[str, Any], models_dir: Path) -> list[str]:
|
||||
"""고를 수 있는 분석 지형 — 확정이 LAS 이고 수치지형도 서피스가 있을 때만 둘."""
|
||||
if _is_sheet(params):
|
||||
return ["sheet"]
|
||||
if (Path(models_dir) / f"dtm_{SHEET_ANALYSIS_FILTER}.npz").is_file():
|
||||
return ["las", "sheet"]
|
||||
return ["las"]
|
||||
|
||||
|
||||
async def update_contour_interval_param(
|
||||
connection: aiomysql.Connection,
|
||||
project_id: str,
|
||||
|
||||
@@ -19,7 +19,20 @@ import numpy as np
|
||||
from scipy.interpolate import RegularGridInterpolator
|
||||
|
||||
#: 지면 필터 · 지표면 표현 허용 목록 — 파일 이름에 들어감(B04 `_FILTERS` · `MODEL_METHODS`)
|
||||
SurfaceFilter = Literal["classification", "grid_min_z", "csf", "pmf", "ransac"]
|
||||
#: `sheet_*` = 수치지형도 등고선 서피스(B04 `SHEET_METHOD_LABELS` 키 앞에 `sheet_`)
|
||||
SurfaceFilter = Literal[
|
||||
"classification",
|
||||
"grid_min_z",
|
||||
"csf",
|
||||
"pmf",
|
||||
"ransac",
|
||||
"sheet_tin_sheet",
|
||||
"sheet_tin",
|
||||
"sheet_biharmonic",
|
||||
"sheet_anudem",
|
||||
"sheet_multires",
|
||||
"sheet_laplace",
|
||||
]
|
||||
SurfaceMethod = Literal["dtm", "tin", "nurbs", "implicit", "meshfree"]
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
"""PLAN 133-1 — B05 계획노선 편집 분석 지형(라이다 · 수치지형도) 고르기.
|
||||
|
||||
라이다 있는 프로젝트 → 두 지형 다 고름 · sheet 고르면 dtm_sheet 로 표고 · 라이다 없는 프로젝트 →
|
||||
고르기 안 보임(지형 하나) · 허용 목록 밖 거절 · 확정 params 는 안 바뀜.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
from B05_Profile import B05_Profile_Router_RouteInitial as initial
|
||||
from B05_Profile import B05_Profile_Router_Terrain as terrain_router
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
SHEET_ANALYSIS_FILTER,
|
||||
analysis_terrain_params,
|
||||
analysis_terrain_sources,
|
||||
)
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler, check_surface_keys
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
LAS_PARAMS = {"source_filter": "csf", "method": "dtm", "smooth": True, "contour_interval_m": 1.0}
|
||||
SHEET_PARAMS = {
|
||||
"source_filter": SHEET_ANALYSIS_FILTER,
|
||||
"method": "dtm",
|
||||
"smooth": True,
|
||||
"contour_interval_m": 1.0,
|
||||
}
|
||||
LAS_Z = 100.0
|
||||
SHEET_Z = 250.0
|
||||
|
||||
|
||||
def _dtm(path: Path, z: float) -> None:
|
||||
x = np.arange(0.0, 11.0)
|
||||
y = np.arange(0.0, 11.0)
|
||||
np.savez(
|
||||
path,
|
||||
x=x,
|
||||
y=y,
|
||||
z=np.full((len(y), len(x)), z),
|
||||
valid_mask=np.ones((len(y), len(x)), dtype=bool),
|
||||
)
|
||||
|
||||
|
||||
def _project(tmp_path: Path, *, las: bool, sheet: bool) -> Path:
|
||||
models = tmp_path / "B04_PreProcess" / "models"
|
||||
models.mkdir(parents=True)
|
||||
if las:
|
||||
_dtm(models / "dtm_csf_smooth.npz", LAS_Z)
|
||||
if sheet: # B04 처럼 원본 · 평활 둘 다(평활 쪽을 읽음)
|
||||
_dtm(models / f"dtm_{SHEET_ANALYSIS_FILTER}.npz", SHEET_Z - 1.0)
|
||||
_dtm(models / f"dtm_{SHEET_ANALYSIS_FILTER}_smooth.npz", SHEET_Z)
|
||||
return tmp_path
|
||||
|
||||
|
||||
def _patch_db(monkeypatch, project_root: Path, params: dict) -> None:
|
||||
class _Pool:
|
||||
@asynccontextmanager
|
||||
async def acquire(self):
|
||||
yield object()
|
||||
|
||||
async def stored(_connection, _project_id):
|
||||
return "x"
|
||||
|
||||
async def confirmed(_connection, _project_id):
|
||||
return dict(params)
|
||||
|
||||
monkeypatch.setattr(terrain_router, "get_db_pool", lambda: _Pool())
|
||||
monkeypatch.setattr(terrain_router, "get_project_storage_relative_path", stored)
|
||||
monkeypatch.setattr(terrain_router, "get_surface_confirmation_params", confirmed)
|
||||
monkeypatch.setattr(terrain_router, "resolve_stored_project_path", lambda _s: project_root)
|
||||
|
||||
|
||||
PID = "1bb0f876-983f-4ca5-9cc7-f59ee1f77e0a"
|
||||
|
||||
|
||||
def _elevations(terrain: str) -> dict:
|
||||
request = terrain_router.ElevationRequest(points=[(5.0, 5.0)], terrain=terrain)
|
||||
return asyncio.run(terrain_router.read_route_elevations(PID, request))
|
||||
|
||||
|
||||
def test_라이다_있는_프로젝트_두_지형_다_고름(tmp_path, monkeypatch):
|
||||
root = _project(tmp_path, las=True, sheet=True)
|
||||
models = root / "B04_PreProcess" / "models"
|
||||
assert analysis_terrain_sources(LAS_PARAMS, models) == ["las", "sheet"]
|
||||
_patch_db(monkeypatch, root, LAS_PARAMS)
|
||||
answer = asyncio.run(terrain_router.read_terrain_sources(PID))
|
||||
assert answer["sources"] == ["las", "sheet"]
|
||||
|
||||
|
||||
def test_sheet_고르면_dtm_sheet_로_표고(tmp_path, monkeypatch):
|
||||
root = _project(tmp_path, las=True, sheet=True)
|
||||
_patch_db(monkeypatch, root, LAS_PARAMS)
|
||||
assert _elevations("las")["z"] == [LAS_Z]
|
||||
assert _elevations("sheet")["z"] == [SHEET_Z]
|
||||
# 기본값 = 지금 동작(확정 지표면 · 라이다)
|
||||
request = terrain_router.ElevationRequest(points=[(5.0, 5.0)])
|
||||
assert request.terrain == "las"
|
||||
|
||||
|
||||
def test_확정_params_는_안_바뀜():
|
||||
params = dict(LAS_PARAMS)
|
||||
picked = analysis_terrain_params(params, "sheet")
|
||||
assert picked["source_filter"] == SHEET_ANALYSIS_FILTER == "sheet_laplace"
|
||||
assert picked["method"] == "dtm" and picked["smooth"] is True
|
||||
assert params == LAS_PARAMS
|
||||
assert analysis_terrain_params(params, "las") is params
|
||||
|
||||
|
||||
def test_라이다_없는_프로젝트_고르기_안_보임(tmp_path, monkeypatch):
|
||||
root = _project(tmp_path, las=False, sheet=True)
|
||||
models = root / "B04_PreProcess" / "models"
|
||||
assert analysis_terrain_sources(SHEET_PARAMS, models) == ["sheet"]
|
||||
# 확정이 이미 수치지형도 — 어느 값을 받아도 같은 서피스
|
||||
assert analysis_terrain_params(SHEET_PARAMS, "sheet") is SHEET_PARAMS
|
||||
_patch_db(monkeypatch, root, SHEET_PARAMS)
|
||||
assert asyncio.run(terrain_router.read_terrain_sources(PID))["sources"] == ["sheet"]
|
||||
# 허용 목록에 sheet_* 가 들어가 LAS 없는 프로젝트 지반고가 읽힘(54-22 뒤 막혀 있던 길)
|
||||
assert _elevations("las")["z"] == [SHEET_Z]
|
||||
|
||||
|
||||
def test_수치지형도_서피스_없으면_라이다만(tmp_path):
|
||||
root = _project(tmp_path, las=True, sheet=False)
|
||||
assert analysis_terrain_sources(LAS_PARAMS, root / "B04_PreProcess" / "models") == ["las"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["lidar", "SHEET", "../x", ""])
|
||||
def test_허용_목록_밖_거절(bad):
|
||||
with pytest.raises(ValidationError):
|
||||
terrain_router.ElevationRequest(points=[(0.0, 0.0)], terrain=bad)
|
||||
with pytest.raises(ValidationError):
|
||||
terrain_router.CrossPreviewRequest(
|
||||
vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], chainage_m=0, terrain=bad
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
initial.ProfilePreviewRequest(vertices=[{"x": 0, "y": 0}, {"x": 1, "y": 1}], terrain=bad)
|
||||
with pytest.raises(ValidationError):
|
||||
replan.RouteInitialRequest(terrain=bad)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", ["sheet", "sheet_../x", "sheet_LAPLACE", "dtm_sheet_laplace"])
|
||||
def test_sheet_필터도_허용_목록_검사(bad, tmp_path):
|
||||
with pytest.raises(ValueError):
|
||||
check_surface_keys(bad)
|
||||
with pytest.raises(ValueError):
|
||||
build_surface_sampler(tmp_path, bad, "dtm", True)
|
||||
check_surface_keys("sheet_laplace")
|
||||
|
||||
|
||||
def test_화면_고르기_배선():
|
||||
page = ROOT / "B05_Profile"
|
||||
chrome = (page / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
|
||||
main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
|
||||
api = (page / "B05_Profile_Api_Replan.ts").read_text(encoding="utf-8")
|
||||
initial_api = (page / "B05_Profile_Api_RouteInitial.ts").read_text(encoding="utf-8")
|
||||
# 기본 숨김 · 둘 다 있을 때만 보임 · 기본 라이다
|
||||
assert '<label class="b05-routeedit__terrain" hidden' in chrome
|
||||
assert '<option value="las">라이다</option>' in chrome
|
||||
assert '<option value="sheet">수치지형도</option>' in chrome
|
||||
assert 'sources.includes("las") && sources.includes("sheet")' in main
|
||||
assert '=== "sheet" ? "sheet" : "las"' in api
|
||||
# 모달 분석 넷이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음
|
||||
assert api.count("terrain: analysisTerrain(projectId)") == 3
|
||||
assert "terrain: analysisTerrain(projectId)" in initial_api
|
||||
replan_fn = api.split("export async function replanRoute")[1].split("export ")[0]
|
||||
assert "terrain" not in replan_fn
|
||||
@@ -24,7 +24,7 @@ def _strip_points(monkeypatch, tmp_path: Path, criteria=None) -> tuple[list, obj
|
||||
async def plan_criteria(_pid):
|
||||
return RADIUS_M, 0.0, 0.0
|
||||
|
||||
async def profile_of(_pid, _root, points, crit, curves=None):
|
||||
async def profile_of(_pid, _root, points, crit, curves=None, _terrain="las"):
|
||||
seen["points"], seen["criteria"], seen["curves"] = points, crit, curves
|
||||
return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]}
|
||||
|
||||
@@ -69,7 +69,7 @@ def test_띠는_그_노선의_곡선표로_확폭을_맨다(monkeypatch, tmp_pat
|
||||
async def plan_criteria(_pid):
|
||||
return RADIUS_M, 0.0, 0.0
|
||||
|
||||
async def profile_of(_pid, _root, _points, _crit, curves=None):
|
||||
async def profile_of(_pid, _root, _points, _crit, curves=None, _terrain="las"):
|
||||
seen["curves"] = curves
|
||||
return {"stations_m": [0.0, 20.0], "plan_m": [1.0, 2.0]}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user