feat(509-5 · 509-2 B05 추천 지형): 분석 지형 고르기 = 추천 노선 창 안 · 지형마다 추천 미리 셈 · [적용] = 창 지형이 설계 지형 · 평면은 지형 이름만 · 설계 지형 격자 하나(수치지형도 적용이면 그 격자)

- 전처리 미리 셈이 고를 수 있는 지형마다(설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만) 다섯 갈래 + 초기 종단
- 창 지형 바꿈 = 그 지형 저장본만 · 없으면 409 안내(+[재계산]) · 기본값 판정에서 지형 뺌
- POST /route/initial terrain = 창 지형 · [적용] draft.terrain = 창 지형 · 손 [저장]은 지형 안 바꿈(서버도 as_initial 일 때만)
- 평면 분석 지형 고르기 · 화면 취향(routeedit-terrain) 걷음 · 지반고 · 묶음 초안 = 설계 지형(null) · 적용 뒤 받아 둔 격자 버림
- 시험 test_509_5 · test_509_2 · 걷은 고르기를 보던 옛 시험 새 동작으로(352_3 건너뜀)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-21 20:41:20 +09:00
co-authored by Claude Opus 5.5
parent 23fd2f6f8d
commit 4eec3a4b37
30 changed files with 441 additions and 169 deletions
-2
View File
@@ -72,8 +72,6 @@ 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" },
/** B05 계획노선 편집 창 — 측점 스냅 간격(10 · station · 280-4) */
"routeedit-snap-step": { bucket: "pref", scope: "project" },
"masshaul-open": {
+2 -6
View File
@@ -61,11 +61,7 @@ import {
savedPlusRows,
} from "../../B05_Profile/B05_Profile_Api_PlusStations";
import { pageReplaying } from "../../B05_Profile/B05_Profile_UI_Profile_History";
import {
analysisTerrain,
fetchRoutePlan,
type RoutePlanResponse,
} from "../../B05_Profile/B05_Profile_Api_Replan";
import { fetchRoutePlan, type RoutePlanResponse } from "../../B05_Profile/B05_Profile_Api_Replan";
import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
import { forgetPageData, pagePipes, pageStructures, trackPageBusy } from "./design_page_data";
import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types";
@@ -637,7 +633,7 @@ function createPagePipeline(
const wasDraft = pipe.input("route") !== null;
let draft: RouteDraft | null = null;
if (edit) {
draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: analysisTerrain(projectId) };
draft = { ...edit, rev: (stored?.rev ?? 0) + 1, terrain: null }; // 설계 지형(509-5)
if (stored && same({ ...stored, rev: 0 }, { ...draft, rev: 0 })) draft = stored;
}
if (draft && !wasDraft && !held) held = holdNow();
+8 -35
View File
@@ -6,51 +6,24 @@
* POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial)
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
*
* 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어
* 보낸다(PLAN 133-1) — [확인] 재계산은 확정 지표면 그대로라 안 실음.
* 편집 창 분석(지반고 · 횡단 미리보기)은 설계 지형 그대로 — 지형 고르기는 추천 창 안 · [적용]이
* 그 지형을 설계 지형으로(509-5).
*
* 재계산은 배수유역부터 전 단계를 다시 도는 무거운 작업이라(용화 67측점 기준 3분대)
* 타임아웃을 길게 잡는다 — 기본값으로 두면 중간에 끊긴다.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { readState, writeState } from "../A00_Common/b_page_state";
import { heightAt } from "../common_util/common_util_surface_sampler";
import { loadDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_types";
import type { RouteNodeEdit } from "./logic/B05_Profile_UI_RouteEdit_Edits";
import type { RouteReset } from "./logic/B05_Profile_Route_Reset";
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
/** 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. 고르기는 추천 노선 창 안(509-5). */
export type AnalysisTerrain = "las" | "sheet";
/** 편집기가 열 때 알리는 지형 사정(352-3) — 저장하지 않음. 고를 수 있나(라이다 · 수치지형도 둘) · 설계 지형. */
const terrainContext = new Map<string, { choosable: boolean; design: AnalysisTerrain }>();
export const setTerrainContext = (
projectId: string,
choosable: boolean,
design: AnalysisTerrain,
): void => void terrainContext.set(projectId, { choosable, design });
/** 지금 쓰는 분석 지형 — 하나뿐인 프로젝트는 그것 · 둘이면 사용자가 고른 값(화면 상태 보관소 pref 통 ·
* 노선 편집 고정과 같은 자리), 고른 적 없으면 데이터대로(= 설계 지형 · 라이다가 있으면 라이다).
* 고른 값은 [저장] 때 설계 지형이 됨(PLAN 133-2). */
export const analysisTerrain = (
projectId: string,
fallback: AnalysisTerrain = "las",
): AnalysisTerrain => {
const context = terrainContext.get(projectId);
if (context && !context.choosable) return context.design;
const saved = readState<string>("routeedit-terrain", projectId);
return saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback);
};
/** 사용자가 고를 때만 기억 — 열 때 기본값은 쓰지 않음. */
export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void =>
writeState("routeedit-terrain", terrain, projectId);
/** 이 프로젝트가 고를 수 있는 분석 지형(둘이면 고르기를 띄운다)과 지금 설계 지형. */
/** 이 프로젝트가 고를 수 있는 분석 지형(둘이면 추천 창에 고르기)과 지금 설계 지형. */
export async function fetchTerrainSources(
projectId: string,
): Promise<{ sources: AnalysisTerrain[]; design: AnalysisTerrain }> {
@@ -216,14 +189,14 @@ export async function replanRoute(
*
* 새 계산이 아니라 확정된 지표면을 **읽기만** 하므로 편집 중에 불러도 된다 — 다만 끄는 동안
* 프레임마다 부르지는 않는다(찍는 순간에만). 지표면 밖은 `null` 로 온다.
* 설계 지형 격자를 받아 두었고 분석 지형이 그것과 같으면 **브라우저 표본**(서버와 같은 값 ·
* PLAN 344-1) — 아니면 서버에 물음. 자릿수는 서버 응답과 같게 mm. */
* 설계 지형 격자를 받아 두었으면 **브라우저 표본**(서버와 같은 값 · PLAN 344-1) — 아니면
* 서버에 물음. 자릿수는 서버 응답과 같게 mm. */
export async function fetchRouteElevations(
projectId: string,
points: Array<[number, number]>,
): Promise<Array<number | null>> {
const local = await loadDesignTerrainGrid(projectId);
if (local && analysisTerrain(projectId) === local.design) {
if (local) {
return points.map(([x, y]) => {
const z = heightAt(local.grid, x, y);
return Number.isNaN(z) ? null : Math.round(z * 1000) / 1000;
@@ -231,7 +204,7 @@ export async function fetchRouteElevations(
}
const payload = await requestJson<{ z: Array<number | null> }>(
`/projects/${projectId}/route/elevations`,
{ method: "POST", body: JSON.stringify({ points, terrain: analysisTerrain(projectId) }) },
{ method: "POST", body: JSON.stringify({ points }) },
60000,
);
return payload.z;
+5 -3
View File
@@ -2,14 +2,14 @@
* B05_Profile_Api_RouteInitial.ts
* 초기노선 갈래 요청(계획서 20) — 로직 하나를 골라 서버가 새 계획노선 노드를 낸다.
*
* POST /projects/{id}/route/initial { mode, weights?, criteria? } → 노드 · 비교값 · 공통 제약 기준값
* POST /projects/{id}/route/initial { mode, weights?, criteria?, terrain? } → 노드 · 비교값 · 공통 제약 기준값
*
* 계산 자리는 **서버 단독**(CLAUDE.md 5장 ③) — 화면은 부르고, 받은 노드를 편집 캐시에 싣고,
* 받은 비교값·기준값을 보이기만 한다. 모양은 계획서 20 「API」 줄(서버 `initial_route`) —
* 답을 펴는 자리는 `normalizeRouteInitial`(`_RouteEdit_Initial_Model`) 한 곳이다.
* ========================================================================== */
import { analysisTerrain, requestJson } from "./B05_Profile_Api_Replan";
import { requestJson, type AnalysisTerrain } from "./B05_Profile_Api_Replan";
/** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */
const INITIAL_TIMEOUT_MS = 5 * 60 * 1000;
@@ -44,6 +44,8 @@ export interface RouteInitialRequest {
with_profile?: boolean;
/** [재계산] — 이 입력의 저장본을 지우고 새로 셈(421 · 서버 420). */
recompute?: boolean;
/** 창에서 고른 지형(509-5) — 지형마다 미리 셈 저장본 · 안 보내면 설계 지형. */
terrain?: AnalysisTerrain;
}
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
@@ -60,7 +62,7 @@ export async function fetchRouteInitial(
`/projects/${projectId}/route/initial`,
{
method: "POST",
body: JSON.stringify({ ...request, terrain: analysisTerrain(projectId) }),
body: JSON.stringify(request),
},
INITIAL_TIMEOUT_MS,
);
+7 -1
View File
@@ -17,7 +17,7 @@ import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_s
import type { GroundStageDeps } from "./logic/B05_Profile_Pipeline_RouteGround";
export interface DesignTerrainGrid {
/** 이 격자의 지형 — 편집 창 분석 지형이 이것과 같을 때만 씀. */
/** 이 격자의 지형 — 계산 묶음 노선 초안 지형(null = 설계)이 이것과 같을 때만 씀. */
design: "las" | "sheet";
grid: DtmGrid;
}
@@ -65,6 +65,12 @@ export function loadDesignTerrainGrid(projectId: string): Promise<DesignTerrainG
return pending;
}
/** 설계 지형이 바뀜(추천 [적용] · 509-5) — 받아 둔 격자를 버림 · 다음 부름에 새 지형 격자를 받음. */
export function forgetDesignTerrainGrid(projectId: string): void {
loaded.delete(projectId);
ready.delete(projectId);
}
/** 지금 쓸 수 있는 격자 — 받는 중이면 null 을 돌려주고 받기를 띄움(기다리지 않음). */
export function designTerrainGridNow(projectId: string): DesignTerrainGrid | null {
if (!ready.has(projectId)) void loadDesignTerrainGrid(projectId);
@@ -127,12 +127,11 @@ def is_default(
terrain: Any,
saved: dict[str, Any] | None = None,
) -> bool:
"""지킬 결과 — 기본값(빈 제약 · 50/25/25 · 설계 지형) 또는 저장된 제약(`initial_choice`) 그대로.
"""지킬 결과 — 기본값(빈 제약 · 50/25/25) 또는 저장된 제약(`initial_choice`) 그대로 · 지형은
안 따짐(지형마다 미리 셈 · 509-5 · `terrain` 은 부르는 쪽 꼴 맞춤).
모달이 여는 값이라 「최근 2벌」 정리에서 따로 센다(351-1 · 사용자 시도에 안 밀림).
"""
if terrain is not None:
return False
filled = {key: value for key, value in (criteria or {}).items() if value is not None}
used = resolve_weights(mode, weights)
if not filled and (mode != "weighted" or used == DEFAULT_WEIGHTS):
+1 -1
View File
@@ -637,7 +637,7 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any
if request.balanced_profile and as_initial
else None
),
request.terrain,
request.terrain if as_initial else None, # 지형 바꿈 = 추천 [적용]만(509-5)
(old_line, await asyncio.to_thread(_vertices_of, working_path)),
as_initial,
)
+47 -21
View File
@@ -6,6 +6,7 @@
import asyncio
import functools
import itertools
import json
import logging
import time
@@ -26,7 +27,7 @@ logger = logging.getLogger(__name__)
# 미리 셈 진행(364-4) — 프로젝트마다 하나만 돎 · 도는 동안 또 오면 끝난 뒤 한 번 더(입력이 바뀜)
_RUNNING: dict[str, dict[str, Any]] = {}
# 미리 셈 갈래인데 저장본이 없을 때(505-1) — 프로젝트 접근은 셈하지 않음
NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다."
NOT_PREPARED = "추천 저장본이 없습니다 — 전처리를 다시 하면 생깁니다 · [재계산]이면 지금 셉니다."
@replan.router.post("/{project_id}/route/initial", response_model=None)
@@ -249,7 +250,8 @@ async def project_standard(project_id: UUID) -> dict[str, Any] | None:
async def precompute_defaults(project_id: UUID) -> None:
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯.
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯 ×
고를 수 있는 지형(509-5 · 설계 지형 먼저 · 수치지형도는 DB 모델 있을 때만).
모달은 저장된 제약 · 비중으로 열리므로 그 값으로도 미리 셈한다. 있으면 꺼내기만.
다섯 갈래 모두 종단 켬(420) · 첫머리에 30일 지난 다른 판 · 소스 지문 저장본을 지운다(420-2).
@@ -260,11 +262,13 @@ async def precompute_defaults(project_id: UUID) -> None:
started = time.perf_counter()
variants: list[tuple[Any, Any]] = [(None, None)]
modes = list(DEFAULT_MODES)
terrains: list[Any] = [None]
try:
paths = await replan._project_paths(project_id)
except LookupError:
paths = None
if paths is not None:
terrains = await precompute_terrains(project_id, paths[0])
removed = await asyncio.to_thread(purge_outdated, paths[0])
if removed:
logger.info("추천 옛 판 저장본 지움: project_id=%s %d", project_id, removed)
@@ -276,29 +280,51 @@ async def precompute_defaults(project_id: UUID) -> None:
modes.sort(key=lambda mode: mode != saved["logic"] or mode == "follow")
progress = _RUNNING.get(str(project_id))
if progress is not None:
progress.update(total=len(variants) * len(modes))
for criteria, weights in variants:
for mode in modes:
if progress is not None:
progress["mode"] = mode
# 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 「종단 반영」 기본 켬(420)
request = replan.RouteInitialRequest(
mode=mode,
with_profile=True,
criteria=criteria,
weights=weights if mode == "weighted" else None,
progress.update(total=len(terrains) * len(variants) * len(modes))
for terrain, (criteria, weights), mode in itertools.product(terrains, variants, modes):
if progress is not None:
progress["mode"] = mode
# 화면과 같은 몸통 — 창 지형 · 비중은 비중 갈래만 · 「종단 반영」 기본 켬(420)
request = replan.RouteInitialRequest(
mode=mode,
with_profile=True,
criteria=criteria,
weights=weights if mode == "weighted" else None,
terrain=terrain,
)
outcome = await compute_initial(project_id, request, precompute=True)
if isinstance(outcome, JSONResponse):
logger.warning(
"추천 미리 셈 멈춤(%s %s %s): project_id=%s",
terrain,
mode,
outcome.status_code,
project_id,
)
outcome = await compute_initial(project_id, request, precompute=True)
if isinstance(outcome, JSONResponse):
logger.warning(
"추천 미리 셈 멈춤(%s %s): project_id=%s", mode, outcome.status_code, project_id
)
return
if progress is not None:
progress["done"] += 1
return
if progress is not None:
progress["done"] += 1
logger.info("추천 미리 셈 끝: project_id=%s %.1fs", project_id, time.perf_counter() - started)
async def precompute_terrains(project_id: UUID, project_root: Path) -> list[Any]:
"""미리 셈 지형(509-5) — 설계 지형 먼저 · 추천 창이 고를 수 있는 다른 지형."""
from B05_Profile.B05_Profile_Router_Terrain import _MODELS_SUBDIR, choosable_terrains
from common_util.common_util_surface_confirmation import (
design_terrain,
get_surface_confirmation_params,
)
pool = get_db_pool()
async with pool.acquire() as connection:
params = await get_surface_confirmation_params(connection, str(project_id))
sources = await choosable_terrains(
connection, project_id, params, Path(project_root) / _MODELS_SUBDIR
)
design = design_terrain(params)
return [design, *[source for source in sources if source != design]]
async def run_precompute(project_id: UUID) -> None:
"""미리 셈 한 줄(364-4) — 이미 돌면 「한 번 더」만 적고 돌아감(겹쳐 셈 막음)."""
key = str(project_id)
+21 -14
View File
@@ -13,7 +13,7 @@
GET /api/projects/{id}/surface/models/{mid}/grid → DTM 격자 원시 이진(브라우저 표본기 · ETag)
분석 지형(`terrain`, PLAN 133-1) — las = 라이다 · sheet = 수치지형도 · None = 지금 설계 지형.
모달 분석만 고른 지형을 쓴다 — 설계 지형(확정)은 [확인] 때만 바뀜(133-2 · `/route/replan`).
고르기는 추천 노선 창 안(509-5) — 설계 지형(확정)은 추천 [적용] 때만 바뀜(`/route/replan`).
"""
import asyncio
@@ -116,11 +116,7 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) ->
@router.get("/{project_id}/route/terrain-sources", response_model=None)
async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse:
"""편집 모달이 고를 수 있는 분석 지형 — 둘이면 고르기를 띄움 · 하나면 숨김.
설계 지형 아닌 쪽은 DB 완료 모델이 있어야 둠 — [확인]이 그 모델로 바꾸므로(505-2 5d)."""
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
"""추천 창이 고를 수 있는 분석 지형(509-5 · 둘이면 고르기) · 설계 지형 · 설계 지형 격자 자리."""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
@@ -132,14 +128,7 @@ async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse:
params = await get_surface_confirmation_params(connection, str(project_id))
confirmed = await _confirmed_model(connection, project_id)
models_dir = Path(resolve_stored_project_path(stored)) / _MODELS_SUBDIR
sources = await asyncio.to_thread(analysis_terrain_sources, params, models_dir)
for source in [s for s in sources if s != design_terrain(params)]:
try:
await find_surface_model_for_selection(
connection, project_id, analysis_terrain_params(params, source)
)
except LookupError:
sources.remove(source)
sources = await choosable_terrains(connection, project_id, params, models_dir)
return {
"status": "success",
"project_id": str(project_id),
@@ -149,6 +138,24 @@ async def read_terrain_sources(project_id: UUID) -> dict | JSONResponse:
}
async def choosable_terrains(
connection: aiomysql.Connection, project_id: UUID, params: dict, models_dir: Path
) -> list[str]:
"""고를 수 있는 분석 지형 — 설계 지형 아닌 쪽은 DB 완료 모델이 있어야 둠(505-2 5d) · 추천 창
고르기 · 지형마다 추천 미리 셈(509-5)이 같이 씀."""
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
sources = await asyncio.to_thread(analysis_terrain_sources, params, models_dir)
for source in [s for s in sources if s != design_terrain(params)]:
try:
await find_surface_model_for_selection(
connection, project_id, analysis_terrain_params(params, source)
)
except LookupError:
sources.remove(source)
return sources
async def _confirmed_model(
connection: aiomysql.Connection, project_id: UUID
) -> tuple[int, str, str] | None:
+12 -37
View File
@@ -31,11 +31,8 @@ import {
import { createContourLock } from "./B05_Profile_UI_RouteEdit_ContourLock";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import {
analysisTerrain,
fetchRoutePlan,
fetchTerrainSources,
setAnalysisTerrain,
setTerrainContext,
type AnalysisTerrain,
} from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
@@ -116,7 +113,7 @@ export async function mountRouteEditor(
const stationIntervalM = options.stationIntervalM ?? 20;
const chrome = createRouteEditChrome(parent, options.tools);
const { overlay, canvas, status, measureBox, measureText, measureButton } = chrome;
const { minorContourButton, terrainSelect, stationSnapButton } = chrome;
const { minorContourButton, terrainName, stationSnapButton } = chrome;
const context = canvas.getContext("2d")!;
let expected: Vertex[] = [];
@@ -161,11 +158,10 @@ export async function mountRouteEditor(
let meta: VWorldMeta | null = null;
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것. 고르기는 `_Contour` 몫. */
let contours: RouteEditContours | null = null;
/** 지금 설계 지형(PLAN 133-2) — 고른 분석 지형이 이와 다르면 [확인]이 설계 지형을 바꾼다. */
/** 지금 설계 지형 — 편집 분석이 쓰는 지형 · 바꾸기는 추천 창 [적용](509-5). */
let designTerrain: AnalysisTerrain = "las";
/** [확인]에 실을 분석 지형 — 고르기가 안 보이면 null(설계 지형 그대로). */
const pickedTerrain = (): AnalysisTerrain | null =>
terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId);
/** 추천 창이 고를 지형 — `/route/terrain-sources` 를 읽은 뒤. */
let terrainSources: { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null = null;
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
@@ -267,6 +263,7 @@ export async function mountRouteEditor(
planned: () => planned,
planMinRadiusM: () => minRadiusM,
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
terrains: () => terrainSources,
});
function resize(): void {
@@ -447,8 +444,8 @@ export async function mountRouteEditor(
historyControls.sync();
}
/** 계산 묶음에 알림 — 노선 · 분석 지형 · 스냅이 저장분과 다르면 초안 · 같으면 null(352-3 · 430 · 433-14 ②). */
const otherChanged = (): boolean => pickedTerrain() !== designTerrain || snap.changed();
/** 계산 묶음에 알림 — 노선 · 스냅이 저장분과 다르면 초안 · 같으면 null(430 · 433-14 ②). */
const otherChanged = (): boolean => snap.changed();
function tellDraft(): void {
if (!history) return;
const dirty = lineChanged(history) || otherChanged();
@@ -587,9 +584,7 @@ export async function mountRouteEditor(
const unbindApply = bindRouteApply({
projectId,
nodes: () => ({ planned, curveOn, curveRadius, curveLock, curveArc }),
// 분석 지형만 바꿔도 [확인]이 설계 지형을 바꿔 다시 셈한다(PLAN 133-2).
changed: () => (!!history && lineChanged(history)) || otherChanged(),
terrain: pickedTerrain,
snap: snap.draft,
initial,
onApplied,
@@ -633,21 +628,16 @@ export async function mountRouteEditor(
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
// 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 열 때 안 씀(352-3).
// 고른 적 없으면 데이터대로(설계 지형 · 라이다가 있으면 라이다) · 사용자가 바꿀 때만 기억.
// 설계 지형 이름만(509-5) — 고르기는 추천 창 안 · 둘 다 있을 때만 이름을 보임.
designTerrain = sources?.design ?? "las";
const choosable = !!sources?.sources.includes("las") && !!sources?.sources.includes("sheet");
terrainSelect.closest("label")!.hidden = !choosable;
setTerrainContext(projectId, choosable, designTerrain);
terrainSelect.value = analysisTerrain(projectId);
terrainSources = sources;
terrainName.hidden = !(sources?.sources.includes("las") && sources.sources.includes("sheet"));
terrainName.textContent = `지형 ${designTerrain === "sheet" ? "수치지형도" : "라이다"}`;
// 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
// 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽).
const loadBackground = () =>
loadRouteEditBackground(projectId, drainage, {
surfaceModelId:
analysisTerrain(projectId) === "las" && designTerrain === "las"
? (options.surfaceModelId ?? null)
: null,
surfaceModelId: designTerrain === "las" ? (options.surfaceModelId ?? null) : null,
intervalM: options.contourIntervalM ?? 1,
smooth: options.smooth ?? false,
});
@@ -670,21 +660,6 @@ export async function mountRouteEditor(
};
useBackground(await loadBackground());
if (closed) return;
// 고르면 모달 안 분석을 고른 지형으로 다시 그림 — 등고선 · 기울기 · 종단 띠 · 횡단 · 잰 값.
let terrainTicket = 0;
terrainSelect.addEventListener("change", () => {
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
tellDraft(); // 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
const mine = ++terrainTicket;
void loadBackground().then((background) => {
if (closed || mine !== terrainTicket) return;
useBackground(background);
measure.clear();
slope.refresh();
showStatus();
draw();
});
});
viewWanted = true;
resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때).
showStatus();
+14 -5
View File
@@ -6,7 +6,8 @@
* `route` 칸 = `RouteSaveBody`)에 싣는다 — 노선이 마지막 저장 그대로면 null(안 보냄 · 472-6 6-B).
* · 서버가 노선 저장 → 관 · 구조물 다시 얹기 → 세션 횡단 고침 넘김(`routeCarryPart` 가 낸 옛 노선 칸 · 같은 TS)까지
* 한 번에 · 다 저장한 뒤 `routeSaved` · 페이지는 새로고침 없이 다시 세운다(`rebuildAfterRouteSave`).
* · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정).
* · 추천 [적용] = `saveRecommendedRoute` — `as_initial: true` 로 바로 저장(초기값 = 추천 노선 · 사용자 확정) ·
* 창에서 고른 지형이 설계 지형(509-5 · 손 [저장]은 지형 안 바꿈).
* 편집기가 살아 있는 동안 여기 등록부에 「지금 초안」 · 「다시 세우기」를 건다(프로젝트 하나에 하나).
* 실패하면 서버가 옛 노선으로 되살리고 화면 초안은 그대로 남는다(오류 한 줄은 부르는 쪽).
* ========================================================================== */
@@ -30,6 +31,7 @@ import {
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import type { SavePart } from "../A00_Common/design/design_save_api";
import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch";
import { forgetDesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import { reloadSavedPlusStations } from "./B05_Profile_Api_PlusStations";
import {
replanRoute,
@@ -55,8 +57,6 @@ export interface RouteApplyParams {
nodes: () => RouteNodes;
/** 마지막 저장 뒤 노선이 바뀌었나 — 안 바뀌었으면 보내지 않는다. */
changed: () => boolean;
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
terrain: () => string | null;
/** [측점 스냅] 켬끔(요구 224-2) · 간격(390-5) — 곡선표에 적혀 측점 셈이 읽음. */
snap: () => SnapSetting;
/** 초기노선 갈래 — 저장에 지금 추천을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
@@ -95,7 +95,8 @@ export function routeDraftOf(nodes: RouteNodes, settings: RouteSettings): RouteD
/** 편집기를 등록부에 건다 — 같은 프로젝트에 새 편집기가 서면 그쪽으로 바뀐다. 돌려준 함수 = 편집기가 떠날 때 풂
* (안 풀면 노선 저장 뒤 다시 선 페이지에서 옛 편집기 「고친 것 있음」이 [저장] · [확정]마다 노선 저장을 다시 돌림 · 445-2). */
export function bindRouteApply(params: RouteApplyParams): () => void {
const settings = () => ({ ...params.snap(), terrain: params.terrain() });
// 손으로 고친 노선 = 설계 지형 그대로 — 지형 바꿈은 추천 [적용]만(509-5)
const settings = () => ({ ...params.snap(), terrain: null });
const source: RouteSource = {
pending: async () => {
const nodes = params.nodes();
@@ -191,18 +192,26 @@ export async function saveRecommendedRoute(
nodes: RouteNodes,
initial: unknown,
balancedProfile: boolean,
terrain: string | null = null,
): Promise<boolean> {
const settings = sources.get(projectId)?.settings() ?? { stationSnap: false, terrain: null };
showLoadingOverlay();
try {
const saved = await saveRouteDraft(projectId, {
// 383-1 — 추천 선은 곡선 MC 를 일반측점에 맞춘 선 → [측점 스냅] 켬 · 측점 No.(390-5)로 저장
draft: routeDraftOf(nodes, { ...settings, stationSnap: true, stationSnapStep: null }),
// 창에서 고른 지형 = 설계 지형(509-5 · 서버 `switch_design_terrain`)
draft: routeDraftOf(nodes, {
...settings,
stationSnap: true,
stationSnapStep: null,
terrain,
}),
initial,
balanced_profile: balancedProfile,
as_initial: true,
});
clearDrafts(projectId);
forgetDesignTerrainGrid(projectId); // 지형이 바뀌었으면 새 지형 격자로(509-2)
await reloadSavedPlusStations(projectId); // 페이지를 다시 세워도 남는 옛 저장분(493-5)
sources.get(projectId)?.saved();
const reset = routeResetNotice(saved.route_reset, saved.pipes?.crossing ?? 0);
+6 -10
View File
@@ -58,7 +58,8 @@ export interface RouteEditChrome {
/** [측점 스냅] 켬끔(요구 224-2) — `aria-pressed` · `is-active` 는 본체가 맞춤. */
stationSnapButton: HTMLButtonElement;
/** 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임(감싼 label 의 hidden). */
terrainSelect: HTMLSelectElement;
/** 설계 지형 이름(509-5) — 고르기는 추천 창 안. */
terrainName: HTMLElement;
}
/** 손잡이 자리(24) + 틈(8) — 접혀도 남는 폭(`.ui-side-panels` margin-left). */
@@ -119,13 +120,8 @@ export function createRouteEditChrome(
</select>
</label>
<i class="b05-route__view-separator" 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>
<span class="b05-routeedit__terrain" data-slot="terrain-name" hidden
title="설계 지형 — 바꾸기는 [노선 추천] 창에서 지형을 골라 [적용]"></span>
</div>
<div class="b05-routeedit__hint">
<div class="b05-routeedit__hint-body">
@@ -253,7 +249,7 @@ export function createRouteEditChrome(
const minorContourButton = overlay.querySelector<HTMLButtonElement>(
'[data-act="contour-minor"]',
)!;
const terrainSelect = overlay.querySelector<HTMLSelectElement>('[data-act="terrain"]')!;
const terrainName = overlay.querySelector<HTMLElement>('[data-slot="terrain-name"]')!;
const stationSnapButton = overlay.querySelector<HTMLButtonElement>('[data-act="station-snap"]')!;
bindFootprintToggle(overlay.querySelector<HTMLButtonElement>('[data-act="footprint"]')!);
bindMarkToggle(overlay.querySelector<HTMLButtonElement>('[data-act="stations"]')!, "stations");
@@ -269,6 +265,6 @@ export function createRouteEditChrome(
measureButton,
minorContourButton,
stationSnapButton,
terrainSelect,
terrainName,
};
}
@@ -17,7 +17,11 @@
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* ========================================================================== */
import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
import {
fetchRoutePlan,
type AnalysisTerrain,
type RoutePlanResponse,
} from "./B05_Profile_Api_Replan";
import {
fetchPrecomputeProgress,
fetchRouteInitial,
@@ -57,6 +61,8 @@ export interface InitialPanelParams {
planMinRadiusM: () => number;
/** 받은 노선을 편집값으로 싣는다 — 되돌리기 한 걸음으로 쌓인다. */
apply: (snapshot: RouteEditSnapshot, message: string) => void;
/** 추천 창이 고를 지형 · 지금 설계 지형(509-5) — 못 읽었으면 null(설계 지형 하나). */
terrains: () => { sources: AnalysisTerrain[]; design: AnalysisTerrain } | null;
}
export interface InitialPanel {
@@ -126,6 +132,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
terrain: AnalysisTerrain,
): Promise<InitialChoice> {
const response = await fetchRouteInitial(projectId, {
mode: logic,
@@ -133,8 +140,9 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
...(criteria ? { criteria } : {}),
with_profile: withProfile,
...(recompute ? { recompute: true } : {}),
terrain,
});
return normalizeRouteInitial(response, logic, weights, criteria, withProfile);
return { ...normalizeRouteInitial(response, logic, weights, criteria, withProfile), terrain };
}
recommendButton.addEventListener("click", () => {
@@ -158,6 +166,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: params.planned().map(([x, y]) => [x, y]),
request,
terrains: params.terrains()?.sources ?? [],
terrain: params.terrains()?.design ?? "las",
progress: () => fetchPrecomputeProgress(projectId),
confirmText: () => applyResetText(projectId),
onApply: (picked) => {
@@ -179,6 +189,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420)
},
choiceWithProfile(picked),
picked.terrain ?? null,
);
},
});
@@ -215,6 +215,8 @@ export interface InitialChoice {
/** 시종점 높이 차 ÷ 연장(%) · 상한을 넘나(종단으로 못 풂) — 서버 `ends_grade_pct` · `ends_over_limit`(419). */
endsGradePct?: number | null;
endsOverLimit?: boolean;
/** 추천 창에서 고른 지형(509-5) — [적용]이 설계 지형으로. 옛 캐시면 없음(설계 지형 그대로). */
terrain?: "las" | "sheet";
}
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
@@ -13,6 +13,8 @@
* 서버 부르기 · 캐시 · 「현재 추천」은 `_Initial` 몫이고 여기는 화면만 만든다.
* 균형 종단(계획서 63-4) — 후보마다 서버가 세운 종단의 한 줄 · 그림 · 셈한 후보 표는 이 모달
* 안에서만 보인다(`_Recommend_Profile`) · 닫으면 버린다.
* 지형 고르기(509-5 · 라이다 · 수치지형도 둘일 때) — 바꾸면 그 지형 미리 셈 저장본 · 없으면 안내 + [재계산] ·
* [적용]하면 그 지형이 설계 지형(초기값도 그 지형으로).
* 적용될 초기 종단(476) — 서버가 [적용]과 같은 코드로 세운 종단을 같은 그림에 선 · 한 줄로 더한다
* (끔이어도) · [적용]은 「종단 · 횡단 손 입력이 모두 지워짐」 확인 뒤(요구 277).
* ========================================================================== */
@@ -25,6 +27,7 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { toggleButton } from "./B05_Profile_UI_Panel_Fields";
import type { AnalysisTerrain } from "./B05_Profile_Api_Replan";
import type {
PrecomputeProgress,
RouteInitialCriteria,
@@ -125,6 +128,8 @@ const CRITERIA_FIELD: Record<
},
};
const TERRAIN_NAME: Record<AnalysisTerrain, string> = { las: "라이다", sheet: "수치지형도" };
export interface RecommendParams {
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
criteria: RouteInitialCriteria | null;
@@ -148,7 +153,11 @@ export interface RecommendParams {
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
terrain: AnalysisTerrain,
) => Promise<InitialChoice>;
/** 고를 수 있는 지형(509-5) — 둘이면 창에 고르기 · 처음 값 = 지금 설계 지형. */
terrains: AnalysisTerrain[];
terrain: AnalysisTerrain;
/** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */
onApply: (choice: InitialChoice) => void;
/** [적용] 확인 창 글 — 초기화로 지워질 수(492-2 · [저장]과 같은 글). */
@@ -169,6 +178,9 @@ export function openRecommendModal(params: RecommendParams): void {
let preview: InitialChoice | null = null;
/** 종단 반영(421) — 켬 = 후보마다 균형 종단 · 끔 = 계획고 = 지반고. */
let profileOn = params.withProfile;
/** 창 지형(509-5) — 바꾸면 그 지형 저장본 · [적용]이 설계 지형으로. */
let terrain = params.terrain;
const choosable = params.terrains.includes("las") && params.terrains.includes("sheet");
let busy = false;
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
let dirty = false;
@@ -211,6 +223,12 @@ export function openRecommendModal(params: RecommendParams): void {
<div class="b05-routerec__bars" data-slot="bars"></div>
<span class="b05-routerec__sum" data-weight-sum></span>
</div>
<label class="b05-routerec__terrain" data-terrain-row hidden
title="추천 · 초기 종단을 셀 지형 — [적용]하면 이 지형이 설계 지형(평면 · 종단 · 횡단)">지형
<select class="ui-select ui-select--sm" data-act="terrain">
<option value="las">라이다</option>
<option value="sheet">수치지형도</option>
</select></label>
<div class="b05-routerec__criteria" data-slot="criteria"></div>
<label class="b05-routerec__target" data-target-row hidden
title="영선 노선이 따라갈 종단기울기(%) — 비우면 위 종단기울기 상한">목표 기울기
@@ -336,6 +354,9 @@ export function openRecommendModal(params: RecommendParams): void {
rerun.title = "지금 칸 값으로 서버 저장본을 지우고 새로 셈";
rerun.before(profileToggle);
const stateEl = q<HTMLElement>("[data-state]");
const terrainSelect = q<HTMLSelectElement>('[data-act="terrain"]');
q<HTMLElement>("[data-terrain-row]").hidden = !choosable;
terrainSelect.value = terrain;
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
bindRecommendHelp(body);
@@ -420,6 +441,7 @@ export function openRecommendModal(params: RecommendParams): void {
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
// 종단 반영 오른쪽 · 늘 켜짐(입력 오류 · 셈 중 빼고 · 421)
rerun.disabled = busy || invalid.size > 0;
terrainSelect.disabled = busy;
profileToggle.disabled = busy;
profileToggle.setAttribute("aria-pressed", String(profileOn));
profileToggle.classList.toggle("is-active", profileOn);
@@ -498,7 +520,9 @@ export function openRecommendModal(params: RecommendParams): void {
preview = null;
// 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4).
const started = Date.now();
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}`;
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}${
choosable ? ` · ${TERRAIN_NAME[terrain]}` : ""
}`;
/** 서버가 뒤에서 미리 셈 중이면 그 진행(364-4) — 2초마다 읽음. */
let behind = "";
const tick = (): void => {
@@ -529,6 +553,7 @@ export function openRecommendModal(params: RecommendParams): void {
Object.keys(criteria).length ? criteria : null,
withProfile,
recompute,
terrain,
);
if (closed || mine !== seq) return;
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
@@ -617,6 +642,10 @@ export function openRecommendModal(params: RecommendParams): void {
});
});
rerun.addEventListener("click", () => void run(true));
terrainSelect.addEventListener("change", () => {
terrain = terrainSelect.value === "sheet" ? "sheet" : "las";
void run(); // 그 지형 저장본 — 없으면 안내 · [재계산](509-5)
});
applyButton.addEventListener("click", () => {
const picked = preview;
if (!picked) return;
@@ -372,6 +372,19 @@
font-weight: 600;
}
/* 지형 고르기(509-5) — 이름 · 고르기 한 줄. */
.b05-routerec__terrain {
display: grid;
grid-template-columns: minmax(0, 1fr) 7em;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-body);
}
.b05-routerec__terrain[hidden] {
display: none;
}
/* 공통 제약 네 칸 — 한 줄에 하나(이름 · 입력 · 단위). 비우면 placeholder 가 기본값. */
.b05-routerec__criteria {
display: grid;
@@ -50,7 +50,6 @@ const local = { grid, design: "las" };
let remoteCalls = 0;
STUBS["B05_Profile_Api_Replan"] = {
analysisTerrain: () => "las",
fetchRoutePlan: async () => ({ planned: job.line, curves: [], min_radius_m: job.minRadius }),
};
STUBS["B05_Profile_Api_Structures"] = {
+11 -2
View File
@@ -54,6 +54,10 @@ const STUBS = {
"./B05_Profile_Api_Fetch": {
clearRouteLatestCache: () => calls.push("latest:clear"),
},
// 509-2 — 추천 [적용] 뒤 받아 둔 설계 지형 격자 버림
"./B05_Profile_Api_TerrainGrid": {
forgetDesignTerrainGrid: () => calls.push("grid:forget"),
},
"./B05_Profile_Api_Replan": {
replanRoute: async (_p, body) => {
bodies.push(JSON.parse(JSON.stringify(body)));
@@ -115,7 +119,6 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap
curveRadius: [null, 25],
}),
changed: () => changed,
terrain: () => "sheet",
snap: () => ({ stationSnap: true, stationSnapStep: 20 }), // 390-5 — 켬끔 · 간격 한 벌
initial: { current: () => ({ mode: "follow" }), keep: () => (kept += 1) },
onApplied: () => (rebuilt += 1),
@@ -158,7 +161,13 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap
curveOn: [true, true],
curveRadius: [null, null],
};
out.recommendOk = await apply.saveRecommendedRoute("p1", snapshot, { mode: "earthwork" }, true);
out.recommendOk = await apply.saveRecommendedRoute(
"p1",
snapshot,
{ mode: "earthwork" },
true,
"sheet", // 509-5 — 창에서 고른 지형
);
out.recommendBody = bodies[bodies.length - 1];
out.recommendCalls = calls.filter((c) => !c.startsWith("toast:"));
out.recommendDraftsLeft = {
@@ -164,14 +164,10 @@ def test_화면_고르기_배선():
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?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main
assert 'saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback)' in api
# 모달 지반고가 고른 지형을 실어 보냄 · 횡단 미리보기 = 묶음(365-3) · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 1
assert "terrain: analysisTerrain(projectId)" in initial_api
# 509-5 — 평면은 설계 지형 이름만(둘 다 있을 때) · 고르기 · 지형 실음 = 추천 창(test_509_5)
assert 'data-slot="terrain-name" hidden' in chrome and 'data-act="terrain"' not in chrome
assert 'sources?.sources.includes("las") && sources.sources.includes("sheet")' in main
assert "analysisTerrain" not in api and "analysisTerrain" not in initial_api
assert "body: JSON.stringify(request)," in initial_api
replan_fn = api.split("export async function replanRoute")[1].split("export ")[0]
assert "terrain" not in replan_fn
@@ -291,9 +291,8 @@ def test_화면_확인이_고른_지형을_실음():
page = ROOT / "B05_Profile"
apply = (page / "B05_Profile_UI_RouteEdit_Apply.ts").read_text(encoding="utf-8")
main = (page / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
# 346-3 — 노선 초안(draft.terrain)에 실림 · [저장]이 보냄
assert "terrain: params.terrain() })" in apply and "terrain: settings.terrain," in apply
assert "pickedTerrain() !== designTerrain" in main # 지형만 바꿔도 [확인]이 다시 셈
assert (
"setTerrainContext(projectId, choosable, designTerrain)" in main
) # 고른 값 없으면 설계 지형
# 509-5 — 지형 바꿈 = 추천 [적용]만(창 지형) · 손 [저장] 초안 = 설계 지형(null)
assert "terrain: settings.terrain," in apply
assert "const settings = () => ({ ...params.snap(), terrain: null });" in apply
assert "stationSnapStep: null,\n terrain,\n" in apply # 창 지형(509-5)
assert "pickedTerrain" not in main and "terrainSelect" not in main
@@ -86,11 +86,10 @@ def test_체인이_만들면_화면이_꺼냄(wired) -> None: # noqa: F811
def test_화면이_실은_분석_지형이_설계_지형이면_기본_갈래(wired) -> None: # noqa: F811
"""화면은 늘 분석 지형을 실음 — 설계 지형(가짜 세계 = 라이다)과 같으면 저장본만 · 다르면 셈."""
"""창 지형 — 설계 지형이든 다른 지형이든 기본 갈래는 저장본만(509-5 · 지형마다 미리 셈)."""
assert _ask(False, mode="gentle", terrain="las").status_code == 409
assert _ask(False, mode="gentle", terrain="sheet").status_code == 409
assert wired.count == 0
assert _ask(False, mode="gentle", terrain="sheet")["stored"] is False
assert wired.count == 1
def test_사용자가_바꾼_값_재계산은_셈(wired) -> None: # noqa: F811
+1 -1
View File
@@ -186,7 +186,7 @@ def test_페이지_묶음_노선_초안_캐시_칸(r):
assert p["before"] == {"saved": None, "pipe": True}
assert (
p["cached"]["rev"] == 1
and p["cached"]["terrain"] == "las"
and p["cached"]["terrain"] is None # 설계 지형(509-5)
and p["cached"]["nodes"] == r["n"]
)
assert p["sameRev"] == 1 # 같은 노선 = 판 번호 그대로
@@ -57,7 +57,7 @@ def test_초안은_계약_RouteDraft_꼴(out):
len(draft[name]) == n for name in ("curveOn", "curveRadius", "curveLock", "curveArc")
)
assert draft["curveOn"] == [True, False, True] and draft["curveRadius"] == [None, 25, None]
assert draft["stationSnap"] is True and draft["terrain"] == "sheet"
assert draft["stationSnap"] is True and draft["terrain"] is None # 손 저장 = 설계 지형(509-5)
assert draft["stationSnapStep"] == 20 # 390-5 간격도 함께 실림
assert out["body"]["initial"] == {"mode": "follow"}
assert "balanced_profile" not in out["body"] # 손으로 고친 노선 = 계획고 옮김만(349-0v)
@@ -90,8 +90,15 @@ def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
body = out["recommendBody"]
assert body["as_initial"] is True and body["initial"] == {"mode": "earthwork"}
assert body["draft"]["nodes"] == [[1, 1], [9, 9]]
assert body["draft"]["terrain"] == "sheet" # 창 지형 = 설계 지형(509-5)
# 잠금 막 하나(472-6 6-B — 저장 직전 저장 관 떠 두기는 서버 한 본문 몫 · 추천은 초안을 비우니 안 받음)
assert out["recommendCalls"] == ["overlay:on", "replan", "commit", "overlay:off"] # 초안 전 시설 버림
assert out["recommendCalls"] == [
"overlay:on",
"replan",
"commit",
"grid:forget", # 새 설계 지형 격자로(509-2)
"overlay:off",
] # 초안 전 시설 버림
assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시
assert out["rebuilt"] == 1
@@ -79,7 +79,7 @@ def test_기본값_판정() -> None:
}
assert is_default("gentle", None, None, None)
assert is_default("gentle", None, {"min_radius_m": None}, None)
assert not is_default("gentle", None, None, "las")
assert is_default("gentle", None, None, "las") # 지형 안 따짐(509-5 · 지형마다 미리 셈)
assert not is_default("gentle", None, {"min_radius_m": 15.0}, None)
assert is_default("gentle", None, {"min_radius_m": 15.0}, None, saved)
assert not is_default("gentle", None, {"min_radius_m": 16.0}, None, saved)
@@ -11,6 +11,11 @@ from pathlib import Path
import pytest
# 509-5 — 평면 분석 지형 고르기(화면 취향 기억)를 걷음 · 지형 고르기 = 추천 창 안(test_509_5_*)
pytestmark = pytest.mark.skip(
reason="509-5 평면 분석 지형 고르기 걷음 — test_509_5_recommend_terrain"
)
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
@@ -102,6 +102,11 @@ def test_모달이_여는_갈래_먼저(monkeypatch) -> None:
lambda _root: {"logic": "gentle", "weights": None, "criteria": None},
)
monkeypatch.setattr(router, "compute_initial", compute)
async def terrains(_pid, _root):
return ["las"] # 지형 하나(509-5 지형마다 = test_509_5)
monkeypatch.setattr(router, "precompute_terrains", terrains)
asyncio.run(router.precompute_defaults(uuid4()))
assert asked[0] == "gentle" and asked[-1] == "follow"
assert sorted(asked) == sorted(DEFAULT_MODES)
@@ -161,7 +161,7 @@ def test_편집기_배선() -> None:
"lineChanged(history) || otherChanged()" in main.split("function tellDraft()", 1)[1][:400]
)
assert (
"const otherChanged = (): boolean => pickedTerrain() !== designTerrain || snap.changed();"
"const otherChanged = (): boolean => snap.changed();" # 지형 고르기 = 추천 창(509-5)
in main
) # 433-14 ②
assert "plan.node_edits" in main
+56
View File
@@ -0,0 +1,56 @@
"""509-2 수치지형도 격자 브라우저로 — 적용 지형(설계 지형) = 수치지형도일 때만 그 격자를 받음 · 노드
끌기 지반고 서버 묻기 걷음 · 횡단 비탈 넓힘도 같은 격자(509-4 G2).
./venv/Scripts/python.exe -m pytest resources/tester/test_509_2_sheet_grid.py -q
분석 지형 = 설계 지형 하나(509-5) — 받는 격자는 늘 설계 지형 것 · 추천 [적용]이 지형을 바꾸면 버림.
"""
from pathlib import Path
from B05_Profile.B05_Profile_Router_Terrain import _design_grid
from common_util.common_util_surface_confirmation import SHEET_ANALYSIS_FILTER
ROOT = Path(__file__).resolve().parents[2]
SHEET = {"source_filter": SHEET_ANALYSIS_FILTER, "method": "dtm", "smooth": True}
MODEL = f"B04_PreProcess/models/dtm_{SHEET_ANALYSIS_FILTER}.npz"
def test_설계_지형_수치지형도면_그_격자_자리(tmp_path) -> None:
grid = _design_grid(SHEET, tmp_path, (41, "dtm", MODEL))
assert grid == {"model_id": 41, "smooth": True, "signature": "."}
def test_확정_모델이_다른_파일이면_격자_없음(tmp_path) -> None:
las = "B04_PreProcess/models/dtm_classification.npz"
assert _design_grid(SHEET, tmp_path, (41, "dtm", las)) is None
assert _design_grid(SHEET, tmp_path, None) is None
def _ts(path: str) -> str:
return (ROOT / path).read_text(encoding="utf-8")
def test_지반고는_설계_지형_격자_서버_묻기_없음() -> None:
replan = _ts("B05_Profile/B05_Profile_Api_Replan.ts")
elevations = replan.split("export async function fetchRouteElevations", 1)[1][:700]
assert "if (local) {" in elevations # 받아 둔 격자면 늘 브라우저 표본
assert "JSON.stringify({ points })" in elevations # 서버 길도 설계 지형
# 계산 묶음 초안 지형 = null(설계) → ground 단계가 격자를 씀(terrain === null)
ground = _ts("B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts")
assert "(terrain === null || terrain === local.design)" in ground
pipeline = _ts("A00_Common/design/design_pipeline_page.ts")
assert "terrain: null };" in pipeline
# 횡단 비탈 넓힘 = ground 와 같은 격자(509-4 G2 · 적용 지형 것)
assert "createCrossStage({ grid: groundDeps.grid })" in pipeline
def test_적용으로_지형이_바뀌면_격자_버림() -> None:
grid = _ts("B05_Profile/B05_Profile_Api_TerrainGrid.ts")
forget = grid.split("export function forgetDesignTerrainGrid", 1)[1][:200]
assert "loaded.delete(projectId)" in forget and "ready.delete(projectId)" in forget
apply = _ts("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
recommend = apply.split("export async function saveRecommendedRoute", 1)[1]
assert recommend.index("forgetDesignTerrainGrid(projectId)") < recommend.index(
"await rebuildAfterRouteSave(projectId)"
)
@@ -0,0 +1,155 @@
"""509-5 분석 지형 고르기 = 추천 노선 창 안 — 지형마다 추천 미리 셈 · 창 지형 바꿈 = 그 지형 저장본 ·
없으면 안내 + [재계산] · [적용] = 창 지형이 설계 지형 · 평면 고르기 없음.
./venv/Scripts/python.exe -m pytest resources/tester/test_509_5_recommend_terrain.py -q
가짜 세계(`test_224_3` wired) — 지형마다 다른 격자(수치지형도 = 1 m 높임)라 저장 키가 갈림.
"""
import asyncio
import json
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
import pytest
from test_224_3_recommend_store import STREAMS, _ask, _terrain, wired # noqa: F401 — 픽스처
from B05_Profile import B05_Profile_Router_RouteInitial as router
from B05_Profile import B05_Profile_Router_Terrain as terrain_router
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES, store_dir
ROOT = Path(__file__).resolve().parents[2]
@pytest.fixture
def two(wired, monkeypatch): # noqa: F811
"""라이다 설계 · 수치지형도 고를 수 있음 — 지형마다 다른 격자."""
import B05_Profile.B05_Profile_Engine_RouteInitial as engine
import common_util.common_util_surface_confirmation as surface
async def terrains(_pid, _root):
return ["las", "sheet"]
monkeypatch.setattr(router, "precompute_terrains", terrains)
monkeypatch.setattr(surface, "analysis_terrain_params", lambda s, t: {**s, "t": t})
monkeypatch.setattr(
engine,
"load_terrain_and_streams",
lambda _root, selection: (_terrain(1.0 if selection.get("t") == "sheet" else 0.0), STREAMS),
)
return wired
def _saved(root: Path) -> list[dict]:
return [json.loads(p.read_text(encoding="utf-8")) for p in store_dir(root).glob("*.json")]
def test_미리_셈이_지형마다_추천_다섯(two) -> None:
asyncio.run(router.precompute_defaults(uuid4()))
assert two.count == 2 * len(DEFAULT_MODES)
saved = _saved(two.root)
assert sorted({item["request"]["terrain"] for item in saved}) == ["las", "sheet"]
assert all(item["default"] for item in saved)
# 창 지형 바꿈 = 그 지형 저장본(셈 없음) · 두 지형 답이 따로
for mode in DEFAULT_MODES:
assert _ask(False, mode=mode, terrain="sheet")["stored"] is True
assert _ask(False, mode=mode, terrain="las")["stored"] is True
assert two.count == 2 * len(DEFAULT_MODES)
def test_미리_셈_없는_지형은_안내_재계산은_셈(two) -> None:
"""기존 프로젝트(수치지형도 미리 셈 없음) — 안내 한 줄 · [재계산]이면 그 자리 셈."""
missing = _ask(False, mode="gentle", terrain="sheet")
assert missing.status_code == 409
message = json.loads(missing.body)["message"]
assert "전처리를 다시 하면" in message and "[재계산]" in message
assert two.count == 0
assert _ask(False, mode="gentle", terrain="sheet", recompute=True)["stored"] is False
assert two.count == 1
assert _ask(False, mode="gentle", terrain="sheet")["stored"] is True
def test_미리_셈_지형_차례_설계_먼저(monkeypatch) -> None:
import common_util.common_util_surface_confirmation as surface
class _Pool:
@asynccontextmanager
async def acquire(self):
yield None
async def params(_c, _pid):
return {"source_filter": surface.SHEET_ANALYSIS_FILTER}
async def choosable(_c, _pid, _params, _dir):
return ["las", "sheet"]
monkeypatch.setattr(router, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(surface, "get_surface_confirmation_params", params)
monkeypatch.setattr(terrain_router, "choosable_terrains", choosable)
assert asyncio.run(router.precompute_terrains(uuid4(), ROOT)) == ["sheet", "las"]
def test_고를_지형은_DB_모델_있을_때만(tmp_path, monkeypatch) -> None:
import B04_PreProcess.B04_PreProcess_Service as service
from common_util.common_util_surface_confirmation import SHEET_ANALYSIS_FILTER
(tmp_path / f"dtm_{SHEET_ANALYSIS_FILTER}.npz").write_bytes(b"")
params = {"source_filter": "classification", "method": "dtm", "smooth": False}
found: list[str] = []
async def missing(_c, _pid, selection):
found.append(selection["source_filter"])
raise LookupError
async def present(_c, _pid, _selection):
return 7
monkeypatch.setattr(service, "find_surface_model_for_selection", missing)
pick = terrain_router.choosable_terrains
assert asyncio.run(pick(None, uuid4(), params, tmp_path)) == ["las"]
assert found == [SHEET_ANALYSIS_FILTER]
monkeypatch.setattr(service, "find_surface_model_for_selection", present)
assert asyncio.run(pick(None, uuid4(), params, tmp_path)) == ["las", "sheet"]
def test_지형_바꿈은_추천_적용만_초기값_다시_세움() -> None:
replan = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8")
save = replan.split("async def _save_route(", 1)[1]
assert "request.terrain if as_initial else None," in save # 손 [저장]은 지형 안 바꿈
assert save.index("await _recompute(") < save.index("refresh_initial_snapshot(project_id")
recompute = replan.split("async def _recompute(", 1)[1].split("\nasync def ")[0]
assert "switch_design_terrain(connection, project_id, terrain)" in recompute
def _ts(name: str) -> str:
return (ROOT / "B05_Profile" / name).read_text(encoding="utf-8")
def test_창_지형_고르기_배선() -> None:
modal = _ts("B05_Profile_UI_RouteEdit_Recommend.ts")
assert 'data-act="terrain"' in modal and "data-terrain-row hidden" in modal
assert 'params.terrains.includes("las") && params.terrains.includes("sheet")' in modal
change = modal.split('terrainSelect.addEventListener("change"', 1)[1][:300]
assert "void run();" in change # 바꾸면 그 지형 저장본(없으면 안내 · [재계산])
assert "recompute,\n terrain,\n" in modal # 서버에 창 지형
initial = _ts("B05_Profile_UI_RouteEdit_Initial.ts")
assert "terrain: params.terrains()?.design" in initial # 처음 = 설계 지형
assert "return { ...normalizeRouteInitial(" in initial and "terrain };" in initial
assert "picked.terrain ?? null," in initial # [적용] = 창 지형
api = _ts("B05_Profile_Api_RouteInitial.ts")
assert "terrain?: AnalysisTerrain;" in api and "body: JSON.stringify(request)," in api
def test_평면_지형_고르기_없음_이름만() -> None:
chrome = _ts("B05_Profile_UI_RouteEdit_Chrome.ts")
main = _ts("B05_Profile_UI_RouteEdit.ts")
assert 'data-act="terrain"' not in chrome and 'data-slot="terrain-name"' in chrome
assert "terrainName.textContent = `지형 ${" in main
assert "terrainSelect" not in main and "setAnalysisTerrain" not in main
assert "routeedit-terrain" not in (ROOT / "A00_Common/b_page_state.ts").read_text(
encoding="utf-8"
)
pipeline = (ROOT / "A00_Common/design/design_pipeline_page.ts").read_text(encoding="utf-8")
assert "terrain: null }; // 설계 지형(509-5)" in pipeline
assert len(pipeline.splitlines()) <= 700 and len(main.splitlines()) <= 700
@@ -231,8 +231,8 @@ def test_노선_추천_다듬기_23_3():
"|",
'data-act="station-snap"',
'data-act="snap-step"', # 390-2 스냅 간격 = [측점 스냅] 오른쪽
"|", # 390-1 [측점 스냅] | 분석 지형
'class="b05-routeedit__terrain" hidden',
"|", # 390-1 [측점 스냅] | 설계 지형 이름(509-5)
'class="b05-routeedit__terrain" data-slot="terrain-name" hidden',
]
at = 0
for mark in order: