fix(B05·B06): 노선 [확인] 이월은 사용자 입력만 · 추천 균형 종단 1차 종단 · 확정 노선 = 새 초기값(74-3·74-4·74-6)
- [확인] 체인 — 옛 설계 통째 이월 대신 사용자 입력 칸(CARRY_INPUT_KEYS)만 누가거리로 얹고 전 측점을 새 계획고로 다시 셈 · 새 노선에 없는 누가거리 행 안 만듦 - 「종단 반영」 켬으로 [적용]한 추천 그대로 [확인] = 서버가 같은 코드(route_profile)로 균형 종단을 다시 셈해 base_pvi 로 · 요청엔 켬/끔 표시만 - [확인] 성공 뒤 initial_snapshot 다시 뜸(save_initial_snapshot 재사용) - [확인] 안내 글(title) · 「종단 반영」 체크박스를 갈래 알약 줄 오른쪽 토글 단추로 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EYJJRCfHfdCGNhmXTjQKV7
This commit is contained in:
@@ -15,6 +15,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어
|
||||
의존성이 없는 순수 함수 시그니처라 서버 내부 호출이 가능하다.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -393,6 +394,7 @@ async def run_redesign_chain(
|
||||
project_id: UUID,
|
||||
surface_model_id: int,
|
||||
selection: dict[str, Any],
|
||||
profile_target: tuple[list[float], list[float]] | None = None,
|
||||
) -> str | None:
|
||||
"""B04 재확정 후 — **사용자 입력을 유지한 채** 새 지표면 기준으로 B05·B06 재계산·저장.
|
||||
|
||||
@@ -406,6 +408,9 @@ async def run_redesign_chain(
|
||||
측점은 확정 시 기본값으로 채워진다.
|
||||
|
||||
경로가 아예 없으면 신규 자동 체인(계획노선 정본 기본값)으로 되돌아간다.
|
||||
|
||||
`profile_target`(누가거리 · 계획고)이 오면 1차 종단을 그 계획고로 세운다 — 노선 추천
|
||||
「종단 반영」 켬으로 [적용]한 노선의 균형 종단(PLAN 74-4). 없으면 계획고 = 지반고.
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Repository import get_latest_route
|
||||
@@ -416,7 +421,8 @@ async def run_redesign_chain(
|
||||
get_longitudinal_section,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
|
||||
from B06_Section.B06_Section_Router_Confirm import confirm_sections
|
||||
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages, confirm_sections
|
||||
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS, compute_default_designs
|
||||
from B06_Section.B06_Section_Schema import SectionConfirmRequest
|
||||
from common_util.common_util_initial_snapshot import (
|
||||
clear_designing,
|
||||
@@ -557,14 +563,40 @@ async def run_redesign_chain(
|
||||
|
||||
marks.append(("배수·관·측점 재생성", time.perf_counter()))
|
||||
|
||||
# 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 설계를 **누가거리로** 얹는다.
|
||||
# 묶어 쓰므로 행 수와 무관하게 왕복 두 번이다(`merge_cross_section_designs`).
|
||||
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)
|
||||
marks.append(("균형 종단 반영", time.perf_counter()))
|
||||
|
||||
old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {}
|
||||
standard = old_options.get("standard_cross_section") or None
|
||||
|
||||
# 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 **입력**만 누가거리로 얹고 전 측점을
|
||||
# 새 계획고로 다시 셈한다(PLAN 74-3). 결과 칸(계획고 · 면적 · design_line …)을 통째
|
||||
# 옮기면 옛 노선 단면이 새 노선에 남아 누가토량이 두 배가 됐다(74-1). 새 노선에 없는
|
||||
# 누가거리는 행을 만들지 않는다.
|
||||
try:
|
||||
entries = [
|
||||
(float(record["chainage_m"]), record["design"])
|
||||
inputs = {
|
||||
round(float(record["chainage_m"]), 3): {
|
||||
key: record["design"][key]
|
||||
for key in CARRY_INPUT_KEYS
|
||||
if record["design"].get(key) is not None
|
||||
}
|
||||
for record in old_designs
|
||||
if isinstance(record.get("design"), dict)
|
||||
]
|
||||
}
|
||||
async with pool.acquire() as connection:
|
||||
new_longitudinal = await get_longitudinal_section(
|
||||
connection, project_id, new_route_id
|
||||
)
|
||||
long_path = str((new_longitudinal or {}).get("longitudinal_file_path") or "")
|
||||
chainages = await asyncio.to_thread(
|
||||
_rowless_station_chainages, project_root, long_path, []
|
||||
)
|
||||
entries = await asyncio.to_thread(
|
||||
compute_default_designs, project_root, long_path, chainages, standard, inputs
|
||||
)
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
@@ -587,11 +619,8 @@ async def run_redesign_chain(
|
||||
|
||||
marks.append(("설계 이월", time.perf_counter()))
|
||||
|
||||
old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {}
|
||||
section_request = (
|
||||
SectionConfirmRequest(standard_cross_section=old_options["standard_cross_section"])
|
||||
if old_options.get("standard_cross_section")
|
||||
else None
|
||||
SectionConfirmRequest(standard_cross_section=standard) if standard else None
|
||||
)
|
||||
sections_result = await confirm_sections(
|
||||
project_id, new_route_id, section_request, mark_stage_complete=False
|
||||
|
||||
@@ -128,6 +128,9 @@ class RouteReplanRequest(BaseModel):
|
||||
vertices: list[RouteVertexInput] = Field(default_factory=list)
|
||||
initial: RouteInitialRequest | None = None
|
||||
"""이 노드를 낳은 초기값(갈래 · 비중) — 성공하면 프로젝트에 적어 둔다(현재 초기값)."""
|
||||
balanced_profile: bool = False
|
||||
"""「종단 반영」 켬으로 [적용]한 추천 그대로인가 — 켜면 서버가 균형 종단을 다시 셈해
|
||||
1차 종단으로 쓴다(PLAN 74-4). 값은 받지 않는다 — 켬/끔 표시만."""
|
||||
|
||||
|
||||
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
|
||||
@@ -375,8 +378,16 @@ def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int
|
||||
return len(dense)
|
||||
|
||||
|
||||
async def _recompute(project_id: UUID, project_root: Path, stored_path: str) -> dict[str, Any]:
|
||||
"""노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다."""
|
||||
async def _recompute(
|
||||
project_id: UUID,
|
||||
project_root: Path,
|
||||
stored_path: str,
|
||||
balanced: RouteInitialRequest | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다.
|
||||
|
||||
`balanced`(추천 갈래 · 제약)가 오면 그 제약으로 균형 종단을 셈해 1차 종단으로 쓴다(PLAN 74-4).
|
||||
"""
|
||||
import aiomysql
|
||||
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import (
|
||||
@@ -454,7 +465,12 @@ async def _recompute(project_id: UUID, project_root: Path, stored_path: str) ->
|
||||
|
||||
marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter()))
|
||||
|
||||
failure = await run_redesign_chain(project_id, int(surface_model_id), selection)
|
||||
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)
|
||||
marks.append(("재확정 체인", time.perf_counter()))
|
||||
_log_steps("노선 [확인] 재계산", marks)
|
||||
if failure:
|
||||
@@ -590,7 +606,12 @@ async def replan_route(
|
||||
summary["violations"],
|
||||
radius_m,
|
||||
)
|
||||
result = await _recompute(project_id, project_root, stored_path)
|
||||
result = await _recompute(
|
||||
project_id,
|
||||
project_root,
|
||||
stored_path,
|
||||
(request.initial or RouteInitialRequest()) if request.balanced_profile else None,
|
||||
)
|
||||
if "error" in result:
|
||||
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
|
||||
if previous is None:
|
||||
@@ -617,6 +638,10 @@ 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
|
||||
|
||||
await refresh_initial_snapshot(project_id, project_root, result.get("route_id"))
|
||||
return {
|
||||
"status": "success",
|
||||
"project_id": str(project_id),
|
||||
|
||||
@@ -8,6 +8,7 @@ import asyncio
|
||||
import functools
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -118,3 +119,131 @@ async def initial_route(
|
||||
**result,
|
||||
"current_initial": current,
|
||||
}
|
||||
|
||||
|
||||
def _set_base_pvi(path: Path, stations: list[float], plan: list[float]) -> int:
|
||||
"""종단 정본의 1차 변화점(`base_pvi`) 계획고를 `stations` · `plan` 직선 보간으로 바꿔 쓴다."""
|
||||
import json
|
||||
|
||||
import numpy as np
|
||||
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||||
base = longitudinal["profile_alignment"]["base_pvi"]
|
||||
for item in base:
|
||||
item["elevation_m"] = round(float(np.interp(float(item["chainage_m"]), stations, plan)), 4)
|
||||
atomic_write_json(path, longitudinal)
|
||||
return len(base)
|
||||
|
||||
|
||||
async def apply_profile_target(
|
||||
project_id: UUID, route_id: int, target: tuple[list[float], list[float]]
|
||||
) -> None:
|
||||
"""[확인] 체인의 1차 종단을 추천 균형 종단(누가거리 · 계획고)으로 갈아 끼운다(PLAN 74-4).
|
||||
|
||||
1차 종단(`design_ground_following_profile`)은 측점마다 계획고 = 지반고로 맞춘다 — 그 맞춤 대신
|
||||
변화점(`base_pvi`)을 균형 종단에 놓고 `rebuild_alignment_profile` 로 계획선을 다시 낸다.
|
||||
구조물 · 관 측점은 앞뒤 20 m 측점 사이 직선 보간. 실패는 비치명적 — 지반고 종단이 남는다.
|
||||
"""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
|
||||
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
|
||||
from B06_Section.B06_Section_Repository import get_longitudinal_section
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
row = await get_longitudinal_section(connection, project_id, route_id)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
root = Path(resolve_stored_project_path(stored_path))
|
||||
relative = str(row["longitudinal_file_path"])
|
||||
count = await asyncio.to_thread(_set_base_pvi, root / relative, *target)
|
||||
_alignment, summary = await asyncio.to_thread(_apply_alignment_edits, root, relative, {})
|
||||
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()
|
||||
logger.info(
|
||||
"균형 종단 반영: project_id=%s route_id=%s 변화점 %d", project_id, route_id, count
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 지반고 종단이 남는다
|
||||
logger.exception("균형 종단 반영 실패(지반고 종단 유지): project_id=%s", project_id)
|
||||
|
||||
|
||||
async def balanced_target(
|
||||
project_id: UUID,
|
||||
project_root: Path,
|
||||
points: list[dict[str, float]],
|
||||
criteria: Any,
|
||||
) -> tuple[list[float], list[float]] | None:
|
||||
"""확정할 노선의 균형 종단(누가거리 · 계획고) — 추천 창과 같은 코드 · 같은 상한(PLAN 74-4).
|
||||
|
||||
화면 값을 받지 않고 노선 정점으로 다시 셈한다(CLAUDE.md 5). 못 세우면 None(지반고 종단).
|
||||
"""
|
||||
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
|
||||
|
||||
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
|
||||
if criteria is not None and criteria.max_grade_pct is not None:
|
||||
max_grade = criteria.max_grade_pct / 100.0
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
selection = await get_surface_confirmation_params(connection, str(project_id))
|
||||
try:
|
||||
terrain, _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,
|
||||
max_grade=max_grade,
|
||||
design_speed=design_speed or 20,
|
||||
)
|
||||
)
|
||||
except (FileNotFoundError, ValueError):
|
||||
logger.exception("균형 종단 셈 실패(지반고 종단으로): project_id=%s", project_id)
|
||||
return None
|
||||
logger.info(
|
||||
"균형 종단 셈: project_id=%s 측점 %d 절 %.0f 성 %.0f %.1fs",
|
||||
project_id,
|
||||
len(profile["stations_m"]),
|
||||
profile["cut_m3"],
|
||||
profile["fill_m3"],
|
||||
profile["seconds"],
|
||||
)
|
||||
return profile["stations_m"], profile["plan_m"]
|
||||
|
||||
|
||||
async def refresh_initial_snapshot(
|
||||
project_id: UUID, project_root: Path, route_id: int | None
|
||||
) -> None:
|
||||
"""노선 [확인] 결과로 초기값(initial_snapshot)을 다시 뜬다(PLAN 74-4) — 실패는 비치명적."""
|
||||
from common_util.common_util_initial_snapshot import (
|
||||
discard_initial_snapshot,
|
||||
save_initial_snapshot,
|
||||
)
|
||||
|
||||
if route_id is None:
|
||||
return
|
||||
try:
|
||||
# 스냅샷 안 `planned_route.csv` 는 예상노선(원본) 자리 — 확정 노선 아닌 예상노선을 둔다.
|
||||
expected = await asyncio.to_thread(
|
||||
replan._vertices_of, expected_route_csv_path(project_root)
|
||||
)
|
||||
points = [{"x": x, "y": y} for x, y in expected] or None
|
||||
await asyncio.to_thread(discard_initial_snapshot, project_root)
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
await save_initial_snapshot(connection, project_root, int(route_id), points)
|
||||
logger.info("노선 [확인] 초기값 다시 뜸: project_id=%s route_id=%s", project_id, route_id)
|
||||
except Exception: # noqa: BLE001 — 계산 결과는 그대로 남는다
|
||||
logger.exception("노선 [확인] 초기값 스냅샷 실패: project_id=%s", project_id)
|
||||
|
||||
@@ -26,7 +26,11 @@ export interface RouteApplyParams {
|
||||
/** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
|
||||
changed: () => boolean;
|
||||
/** 초기노선 갈래 — [확인]에 지금 초기값을 싣고, 결과 캐시를 비운 뒤 다시 적는다. */
|
||||
initial: { current: () => unknown; keep: () => void };
|
||||
initial: {
|
||||
current: () => unknown;
|
||||
keep: () => void;
|
||||
profiled: (nodes: ReturnType<RouteApplyParams["nodes"]>) => boolean;
|
||||
};
|
||||
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
|
||||
close: () => void;
|
||||
onApplied: () => void | Promise<void>;
|
||||
@@ -85,7 +89,10 @@ export function bindRouteApply(params: RouteApplyParams): void {
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
{ initial: params.initial.current() },
|
||||
{
|
||||
initial: params.initial.current(),
|
||||
balanced_profile: params.initial.profiled({ planned, curveOn, curveRadius }),
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -58,7 +58,8 @@ export function createRouteEditChrome(): RouteEditChrome {
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
|
||||
title="전처리 때 만든 초기값 노선(예상노선 추적)으로 되돌립니다 — 다시 계산은 [확인]에서">초기화</button>
|
||||
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-act="apply">확인</button>
|
||||
<button type="button" class="ui-btn ui-btn--filled" data-act="apply"
|
||||
title="이 노선으로 배수유역부터 다시 계산합니다 — 결과가 새 초기값이 되고 종횡단 · 구조물 · 관 옵션 편집은 새 노선 기본값으로 바뀝니다">확인</button>
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
|
||||
@@ -58,6 +58,8 @@ export interface InitialPanel {
|
||||
weights: RouteInitialWeights | null;
|
||||
criteria: RouteInitialCriteria | null;
|
||||
};
|
||||
/** 「종단 반영」 켬으로 [적용]한 추천 노선 그대로인가 — [확인]이 균형 종단을 1차 종단으로 쓸지(PLAN 74-4). */
|
||||
profiled: (nodes: Pick<RouteEditSnapshot, "planned" | "curveOn" | "curveRadius">) => boolean;
|
||||
/** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */
|
||||
tag: () => InitialChoice | null;
|
||||
restoreTag: (tag: unknown) => void;
|
||||
@@ -214,6 +216,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
const criteria = choice ? choice.criteriaOverrides : base.criteria;
|
||||
return { mode: now.logic, weights: now.weights, criteria };
|
||||
},
|
||||
profiled: (nodes) => {
|
||||
if (!choice?.balance) return false; // 끔 · [초기화] · 손 편집만 한 노선
|
||||
const { planned, curveOn, curveRadius } = choice.snapshot;
|
||||
return (
|
||||
JSON.stringify([planned, curveOn, curveRadius]) ===
|
||||
JSON.stringify([nodes.planned, nodes.curveOn, nodes.curveRadius])
|
||||
);
|
||||
},
|
||||
tag: () => choice,
|
||||
restoreTag(tag) {
|
||||
if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
|
||||
|
||||
@@ -14,7 +14,6 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createCheckbox } from "@ui/ui_template_checkbox";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type {
|
||||
RouteInitialCriteria,
|
||||
@@ -185,11 +184,16 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
bodyEl = body;
|
||||
body.classList.add("b05-routerec__body");
|
||||
body.innerHTML = `
|
||||
<div class="b05-routerec__pills" role="radiogroup" aria-label="추천 갈래">
|
||||
${PILLS.map(
|
||||
(key) => `<button type="button" class="b05-routerec__pill" role="radio"
|
||||
data-logic="${key}" title="${PILL_TITLE[key]}">${LOGIC_LABEL[key]}</button>`,
|
||||
).join("")}
|
||||
<div class="b05-routerec__pillrow">
|
||||
<div class="b05-routerec__pills" role="radiogroup" aria-label="추천 갈래">
|
||||
${PILLS.map(
|
||||
(key) => `<button type="button" class="b05-routerec__pill" role="radio"
|
||||
data-logic="${key}" title="${PILL_TITLE[key]}">${LOGIC_LABEL[key]}</button>`,
|
||||
).join("")}
|
||||
</div>
|
||||
<button type="button" class="b05-routerec__pill b05-routerec__profile-toggle"
|
||||
data-with-profile aria-pressed="true"
|
||||
title="켬 = 후보마다 균형 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(더 빠름) · 이 창 안에서만">종단 반영</button>
|
||||
</div>
|
||||
<div class="b05-routerec__main">
|
||||
<div class="b05-routerec__mapbox">
|
||||
@@ -219,10 +223,6 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
</div>
|
||||
<span class="b05-routerec__sum" data-weight-sum></span>
|
||||
</div>
|
||||
<div class="b05-routerec__profile-opt" data-profile-opt
|
||||
title="켬 = 후보마다 균형 종단을 세워 토공 점수에 넣음 · 끔 = 원지반만 보고 점수를 냄(더 빠름)">
|
||||
<small>끄면 원지반만 · 이 창 안에서만</small>
|
||||
</div>
|
||||
<div class="b05-routerec__criteria">
|
||||
${CRITERIA_KEYS.map(
|
||||
(key) => `<label title="${CRITERIA_FIELD[key].title}">${CRITERIA_FIELD[key].label}
|
||||
@@ -267,12 +267,7 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
const stateEl = q<HTMLElement>("[data-state]");
|
||||
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
|
||||
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
|
||||
const profileCheck = createCheckbox("종단 반영", {
|
||||
checked: params.profileMemory.withProfile,
|
||||
});
|
||||
profileCheck.input.dataset.withProfile = "";
|
||||
q<HTMLElement>("[data-profile-opt]").prepend(profileCheck.root);
|
||||
const profileBox = profileCheck.input;
|
||||
const profileToggle = q<HTMLButtonElement>("[data-with-profile]");
|
||||
bindRecommendHelp(body);
|
||||
|
||||
criteriaInputs.forEach((input) => {
|
||||
@@ -351,7 +346,8 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
input.disabled = busy;
|
||||
});
|
||||
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
|
||||
profileBox.disabled = busy;
|
||||
profileToggle.disabled = busy;
|
||||
profileToggle.setAttribute("aria-pressed", String(params.profileMemory.withProfile));
|
||||
rerun.hidden = !dirty;
|
||||
rerun.disabled = busy || invalid.size > 0;
|
||||
applyButton.disabled = busy || dirty || !preview || invalid.size > 0;
|
||||
@@ -517,8 +513,8 @@ export function openRecommendModal(params: RecommendParams): void {
|
||||
render();
|
||||
});
|
||||
});
|
||||
profileBox.addEventListener("change", () => {
|
||||
params.profileMemory.withProfile = profileBox.checked;
|
||||
profileToggle.addEventListener("click", () => {
|
||||
params.profileMemory.withProfile = !params.profileMemory.withProfile;
|
||||
dirty = preview !== null;
|
||||
render();
|
||||
});
|
||||
|
||||
@@ -117,16 +117,6 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b05-routerec__profile-opt {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-routerec__profile-opt small {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.b05-routerec__help-item p,
|
||||
.b05-routerec__help dd {
|
||||
margin: 2px 0;
|
||||
@@ -222,6 +212,34 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 갈래 알약 오른쪽 「종단 반영」 켬/끔 — 알약과 같은 모양 · 켬은 테두리 · 글자만 강조(고른 갈래와 갈림). */
|
||||
.b05-routerec__pillrow {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.b05-routerec__pillrow > .b05-routerec__pills {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b05-routerec__profile-toggle {
|
||||
flex: none;
|
||||
padding-inline: var(--spacing-12);
|
||||
}
|
||||
|
||||
.b05-routerec__profile-toggle[aria-pressed="true"] {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: inset 0 0 0 1px var(--color-primary);
|
||||
color: var(--color-primary-text, var(--color-primary));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.b05-routerec__profile-toggle[aria-pressed="true"]::before {
|
||||
content: "✓ ";
|
||||
}
|
||||
|
||||
.b05-routerec__pill:disabled {
|
||||
cursor: progress;
|
||||
opacity: 0.7;
|
||||
|
||||
@@ -145,6 +145,20 @@ USER_TOUCHED_KEYS = (
|
||||
"cut_slope_ratio_user",
|
||||
)
|
||||
|
||||
# 노선 [확인] 체인이 옛 노선에서 누가거리로 넘기는 **사용자 입력**(PLAN 74-3) — 계획고 · 면적 ·
|
||||
# design_line · road_edges 같은 결과 칸과 단면 유형 · 측구 방향(새 노선 지형이 정함) · 구조물
|
||||
# 조작값은 넘기지 않는다. 받는 쪽은 `compute_default_designs(carried=…)`.
|
||||
CARRY_INPUT_KEYS = (
|
||||
"ground_type",
|
||||
"ditch_type",
|
||||
"ditch_choice",
|
||||
"paved",
|
||||
"two_stage_slope",
|
||||
"rock_boundary_offset_m",
|
||||
"cut_slope_ratio_user",
|
||||
"berm",
|
||||
)
|
||||
|
||||
|
||||
def stored_standard_cross_section(longitudinal_row: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""확정 때 저장해 둔 표준횡단면 설정값(없으면 None = config 기본값).
|
||||
@@ -474,10 +488,13 @@ def compute_default_designs(
|
||||
longitudinal_file_path: str,
|
||||
chainages: list[float],
|
||||
standard: dict[str, Any] | None = None,
|
||||
carried: dict[float, dict[str, Any]] | None = None,
|
||||
) -> list[tuple[float, dict[str, Any]]]:
|
||||
"""미지정 측점들을 기본값(리핑암 + 암반 경계 0.5m/상단측 절토)으로 계산한 목록을 만든다.
|
||||
|
||||
standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다.
|
||||
`carried`(누가거리 round 3 → `CARRY_INPUT_KEYS` 값)가 오면 그 측점은 기본값 대신 그 입력으로
|
||||
계산한다 — 노선 [확인] 체인의 옛 설계 이월(PLAN 74-3). 결과 칸은 늘 새 계획고로 다시 낸다.
|
||||
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
|
||||
"""
|
||||
root = project_root.resolve()
|
||||
@@ -501,18 +518,30 @@ def compute_default_designs(
|
||||
if not isinstance(samples, list):
|
||||
continue
|
||||
suggested = pavement.get(round(chainage_m, 3), False)
|
||||
user = (carried or {}).get(round(chainage_m, 3)) or {}
|
||||
rock_offset = user.get("rock_boundary_offset_m")
|
||||
design = compute_cross_design(
|
||||
samples,
|
||||
design_elevation_from_longitudinal(longitudinal, chainage_m),
|
||||
ground_type="ripping_rock",
|
||||
ground_type=str(user.get("ground_type") or "ripping_rock"),
|
||||
section_mode=default_modes.get(round(chainage_m, 3), "left_cut"),
|
||||
ditch_type=str(user.get("ditch_type") or "standard"),
|
||||
# 포장은 사용자 구간 지정만 켠다 — 경사 제안은 경고로만 남는다(2026-08-28).
|
||||
paved=paved_at(chainage_m, paved_ranges),
|
||||
paved=paved_at(chainage_m, paved_ranges, user.get("paved")),
|
||||
standard=standard,
|
||||
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
|
||||
rock_boundary_offset_m=(
|
||||
STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M if rock_offset is None else rock_offset
|
||||
),
|
||||
two_stage_slope=bool(user.get("two_stage_slope", True)),
|
||||
cut_slope_ratio=stored_cut_slope(user),
|
||||
ditch_choice=user.get("ditch_choice"),
|
||||
surface_drop_m=ford_drop_at(chainage_m, ford_drops),
|
||||
berm=stored_berm(user),
|
||||
**curve_widening_args(cross),
|
||||
)
|
||||
for key in ("berm", "cut_slope_ratio_user"):
|
||||
if user.get(key) is not None:
|
||||
design[key] = user[key]
|
||||
design["status"] = "provisional"
|
||||
design["pavement_suggested"] = suggested
|
||||
results.append((chainage_m, design))
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
"""노선 [확인] 체인 이월 · 균형 종단 반영(PLAN 74-3 · 74-4).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_74_route_confirm_initial.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from B05_Profile.B05_Profile_Router_Replan import RouteReplanRequest
|
||||
from B05_Profile.B05_Profile_Router_RouteInitial import _set_base_pvi
|
||||
from B06_Section.B06_Section_Router_Design import CARRY_INPUT_KEYS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
def test_이월은_사용자_입력만() -> None:
|
||||
for result_key in (
|
||||
"design_elevation_m",
|
||||
"cut_area_m2",
|
||||
"fill_area_m2",
|
||||
"design_line",
|
||||
"road_edges",
|
||||
"slope_unclosed",
|
||||
"section_mode",
|
||||
"ditch_side",
|
||||
):
|
||||
assert result_key not in CARRY_INPUT_KEYS
|
||||
assert {"ground_type", "paved", "ditch_choice", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS)
|
||||
|
||||
|
||||
def test_체인이_옛_설계를_통째로_옮기지_않는다() -> None:
|
||||
source = (ROOT / "B03_FileInput" / "B03_FileInput_Service_Chain.py").read_text(encoding="utf-8")
|
||||
assert 'record["design"])' not in source # 옛 (누가거리, design 통째) 이월
|
||||
assert "compute_default_designs, project_root, long_path, chainages, standard, inputs" in (
|
||||
" ".join(source.split())
|
||||
)
|
||||
|
||||
|
||||
def test_변화점_계획고를_균형_종단으로(tmp_path: Path) -> None:
|
||||
path = tmp_path / "longitudinal.json"
|
||||
base = [{"chainage_m": s, "elevation_m": 100.0} for s in (0.0, 20.0, 30.0, 40.0)]
|
||||
path.write_text(json.dumps({"profile_alignment": {"base_pvi": base}}), encoding="utf-8")
|
||||
assert _set_base_pvi(path, [0.0, 20.0, 40.0], [10.0, 12.0, 16.0]) == 4
|
||||
got = [
|
||||
p["elevation_m"]
|
||||
for p in json.loads(path.read_text("utf-8"))["profile_alignment"]["base_pvi"]
|
||||
]
|
||||
assert got == [10.0, 12.0, 14.0, 16.0] # 구조물 측점(30 m)은 직선 보간
|
||||
|
||||
|
||||
def test_확인_요청_기본은_끔() -> None:
|
||||
assert RouteReplanRequest().balanced_profile is False
|
||||
assert RouteReplanRequest(balanced_profile=True).balanced_profile is True
|
||||
Reference in New Issue
Block a user