Merge remote-tracking branch 'origin/dev' into sub_laptop_7

This commit is contained in:
2026-10-18 13:16:20 +09:00
88 changed files with 3351 additions and 571 deletions
+7
View File
@@ -35,6 +35,9 @@ import { pinDraftProject } from "./design_store";
import { setRouteShiftOpener } from "../../B06_Section/B06_Section_UI_Cross_Center";
import { reconcilePagePipeline } from "./design_pipeline_page";
import { forgetPageData, onPageBusy } from "./design_page_data";
import { pageRouteId } from "./design_page_route";
import { setPipeSectionsSource } from "./design_structure_pipe_calc";
import { peekSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import "./design_page.css";
export type { DesignPageShared } from "./design_shared";
@@ -113,6 +116,10 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
pinDraftProject(projectId); // 연 프로젝트 고정(388-3) — 다른 탭이 바꿔도 이 탭 초안 칸 그대로
usePipeIds(projectId || null); // 관 세션 id 장부 = 이 프로젝트(323-5 · 카드 · 알약 · 종단 · 3D 같은 id)
// 관 길이 · 흙막이 개수 = 공용 캐시(페이지 노선) 한 벌 — 마지막 연 보기의 사본이 아님(416 · B05 = B06)
setPipeSectionsSource(
() => peekSectionDetail(projectId, pageRouteId(projectId))?.cross_sections ?? null,
);
// 한 벌 틀 — 좌측 자리 · 가운데(위 칸 + 아래 종단도). 진행 단계 틀은 단계 상태가 오면 감싼다.
const left = document.createElement("div");
+32 -11
View File
@@ -6,10 +6,11 @@
* 묶음 측점과 같은 열쇠 규칙(관 = 저장 관 id).
*
* 넘기는 칸 = 서버 노선 저장(`CARRY_INPUT_KEYS`)과 같은 일곱(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사 ·
* 암 경계 · 암 경사 · 소단) + 얹힌 시설 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구
* 방향은 새 지형이 정함(`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`) · 노견 확장 · 소단
* 깊이는 안 넘김. 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 · 지반 유형 · 측구 · 2단 경사 · 암 경계 ·
* 암 경사 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에 안 얹혀도 입력에 들어감(365-2 · 365-5). 부를 때마다 모음.
* 암 경계 · 암 경사 · 소단) + 사면 쉬프트(노견 확장) · 소단 깊이(417 · 서버 `ROUTE_CARRY_KEYS`) + 얹힌 시설
* 맥락(포장 · 물넘이 기울임 · 세월교 노면 하강). 단면 유형 · 측구 방향은 새 지형이 정함
* (`logic/B05_Profile_Pipeline_Detail.ts` `crossWithUphill`). 값 = 상세에 얹힌 설계 위에 세션 고침(측점 열쇠 칸 ·
* 지반 유형 · 측구 · 2단 경사 · 암 경계 · 암 경사 · 사면 쉬프트 · 소단 깊이) — 노선 초안 동안 B06 고침은 저장 상세에
* 안 얹혀도 입력에 들어감(365-2 · 365-5 · 417). 부를 때마다 모음.
* ========================================================================== */
import { readState } from "../b_page_state";
@@ -85,12 +86,28 @@ function carried(section: CrossSection, saved?: CrossSection["design"]): Record<
tilted: Boolean(section.ford_pavement || section.underdrain),
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
surfaceDropM: drop > 0 ? drop : 0,
// 저장분 사면 쉬프트 · 소단 깊이(417 L4) — 세션 고침이 없어도 저장 뒤 그대로
...sides(design, "shoulder_extend", "shoulderExtend"),
...sides(design, "berm_depth", "bermDepth"),
};
}
/** 저장분 쪽마다 값(`{field}_{left|right}_m`) → 입력 칸(`{name}LeftM` · `{name}RightM`) — 있는 쪽만. */
function sides(design: Record<string, unknown>, field: string, name: string) {
const out: Record<string, number> = {};
for (const [side, tail] of [
["left", "LeftM"],
["right", "RightM"],
] as const) {
const value = finite(design[`${field}_${side}_m`]);
if (value !== null) out[`${name}${tail}`] = value;
}
return out;
}
/** 세션 칸 하나를 측점 열쇠마다 — 옛 열쇠(누가거리 · `at:`)는 읽는 쪽이 옮긴 뒤에 들어옴(빼고 봄). */
function sessionCells(
name: "crossdesign" | "rockb" | "cutslope" | "bermdepth",
name: "crossdesign" | "rockb" | "cutslope" | "bermdepth" | "shoulderext",
projectId: string,
routeId: number,
): Array<[string, unknown]> {
@@ -117,12 +134,16 @@ function sessionCarried(projectId: string, routeId: number): CrossInput["byKey"]
const ratio = finite(value);
if (ratio !== null) (out[key] ??= {}).cutSlopeRatio = ratio > 0 ? ratio : null;
}
// 소단 쪽 깊이(194-1) — 소단 제원은 구조물 구간이 정함(`crossWithBerm` · 365-5)
for (const [key, value] of sessionCells("bermdepth", projectId, routeId)) {
const sides = (value ?? {}) as Record<string, unknown>;
if (finite(sides.left) !== null) (out[key] ??= {}).bermDepthLeftM = sides.left;
if (finite(sides.right) !== null) (out[key] ??= {}).bermDepthRightM = sides.right;
}
// 소단 쪽 깊이(194-1) — 소단 제원은 구조물 구간이 정함(`crossWithBerm` · 365-5) · 사면 쉬프트(139-6 · 417 M3)
for (const [state, name] of [
["bermdepth", "bermDepth"],
["shoulderext", "shoulderExtend"],
] as const)
for (const [key, value] of sessionCells(state, projectId, routeId)) {
const sides = (value ?? {}) as Record<string, unknown>;
if (finite(sides.left) !== null) (out[key] ??= {})[`${name}LeftM`] = sides.left;
if (finite(sides.right) !== null) (out[key] ??= {})[`${name}RightM`] = sides.right;
}
return out;
}
+5 -2
View File
@@ -14,7 +14,7 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { clearDrafts, clearResults } from "./../b_page_state";
import { clearResults, clearState, namesInBucket } from "./../b_page_state";
import { clearRouteLatestCache, resetRouteDesign } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { clearAlignmentDrafts } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
@@ -43,7 +43,10 @@ export async function resetDesign(
// 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은
// 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다.
// 복원은 새 노선 번호를 내므로 모든 노선의 초안 · 결과를 버린다(PLAN 77).
clearDrafts(projectId);
// 저장 안 한 B08 · B09 초안은 남김 — [초기화]는 B05 · B06 범위만(418).
namesInBucket("draft")
.filter((name) => !name.startsWith("b08-") && !name.startsWith("b09-"))
.forEach((name) => clearState(name, projectId));
clearResults(projectId);
onCleared?.();
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
+21 -4
View File
@@ -37,8 +37,10 @@ import {
applyStructureAreaRows,
structureAreaRows,
} from "../../B06_Section/B06_Section_Structure_Layouts";
import { withDraftAdjusts } from "../../B06_Section/B06_Section_Section_Adjusts";
import {
holdSectionDetail,
invalidateSectionDetail,
restoreSectionDetail,
swapSectionStations,
} from "../../B06_Section/B06_Section_Section_Store";
@@ -166,6 +168,13 @@ function toAll<K extends keyof RouteProfilePanelCallbacks>(
)) as NonNullable<RouteProfilePanelCallbacks[K]>;
}
/** 횡단 설계가 다시 셈됨 — 두 보기에 알리고 좌측 시설 폼 자동 칸(관 길이 · 흙막이 수)을 공용 캐시 새 값으로 다시
* 그림(416 · 관을 옮긴 뒤 측점 횡단이 늦게 서도 B05 좌측 = B06 카드). */
function crossDesignsUpdated(shared: DesignPageShared, updated: ReadonlyArray<number>): void {
toAll(shared, "onCrossDesignsUpdated")(updated);
shared.structures?.facility.recount();
}
/** 종단도 한 벌 — 처음 부를 때 만들어 종단도 자리에 붙인다. */
export function sharedProfile(shared: DesignPageShared): DesignProfile {
if (shared.profile) return shared.profile;
@@ -182,7 +191,7 @@ export function sharedProfile(shared: DesignPageShared): DesignProfile {
shared.lastPipes = pipes;
toAll(shared, "onPipesChanged")(pipes);
},
onCrossDesignsUpdated: toAll(shared, "onCrossDesignsUpdated"),
onCrossDesignsUpdated: (updated) => crossDesignsUpdated(shared, updated),
onStructureMove: toView(shared, "onStructureMove"),
onStructureRemove: toView(shared, "onStructureRemove"),
onPipeAdd: toView(shared, "onPipeAdd"),
@@ -226,13 +235,18 @@ function startPagePipeline(shared: DesignPageShared, profile: DesignProfile): vo
if (!detailCross(detail, stations)) return null;
const edits = profile.pipelineSource().edits;
const updated = await refreshCrossDesigns({ projectId, routeId, detail, edits });
if (updated.length) toAll(shared, "onCrossDesignsUpdated")(updated);
if (updated.length) crossDesignsUpdated(shared, updated);
profile.redraw();
return detailCross(detail, stations);
},
plantedBase, // 저장 노선 종단 기준 = 입력이 심는 측점(386-3 · 보기 심기 무관)
structureAreas: ({ cross_sections: rows }) =>
applyStructureAreaRows(rows, structureAreaRows(rows)),
structureAreas: (detail) => {
// 초안 상세에도 저장 안 한 조절(옹벽 · 세월 · BOX · 집수 · 417 M3) — 면적 얹기 앞
const routeId = routeIdOf(shared);
if (routeId !== null) withDraftAdjusts(detail, projectId, routeId);
const rows = detail.cross_sections;
applyStructureAreaRows(rows, structureAreaRows(rows));
},
// 초안 시작 — 초안 전 상태를 떠 둠 · 버리면 그대로(측점이 달라도 · 349-0o) · 상세가 없으면 묶음 측점으로 갈아끼움
holdDraft: (routeId, saved) => {
const hold = saved && holdSectionDetail(projectId, routeId, saved);
@@ -382,6 +396,9 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
return;
}
// 서버가 정본(계획선 · 횡단 · 면적)을 다시 썼다 — 공유 캐시를 한 번 비우고 두 보기가 새 상세 한 객체를
// 받음(416 · 보기마다 비우면 앞 보기가 받은 새 상세를 뒤 보기가 또 버림)
invalidateSectionDetail(shared.projectId);
await profileSave?.done?.(false);
await crossSave?.done?.(false);
showToast(L("B06_Profile_Save_Success"), "success");
-3
View File
@@ -79,7 +79,6 @@ import {
setPipeExtraCountSource,
setPipeInletSource,
setPipeRevetChoiceBridge,
setPipeSectionsSource,
} from "./design_structure_pipe_calc";
import { pipeRevetChoiceBridge } from "../../B06_Section/B06_Section_UI_Page_Link_Session";
import {
@@ -103,8 +102,6 @@ export async function mountCrossView(
const projectId = shared.projectId || null;
let currentRouteId: number | null = null;
let sectionDetail: SectionDetailResponse | null = null;
// 배관 명세 관 길이(자동) = 이 횡단 자료의 기하(PLAN 52-1)
setPipeSectionsSource(() => sectionDetail?.cross_sections ?? null);
let stationInterval: number | undefined;
// 표준 횡단면 설정 패널 자리. context 로드 후 config 기본값으로 채운다.
+1 -3
View File
@@ -59,7 +59,7 @@ import {
type PageActionContext,
} from "../../B05_Profile/B05_Profile_UI_Page_Actions";
import { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { setPipeCrossOpener, setPipeSectionsSource } from "./design_structure_pipe_calc";
import { setPipeCrossOpener } from "./design_structure_pipe_calc";
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
import "../../B05_Profile/B05_Profile_UI_Style.css";
// 700줄 제한으로 잘라낸 조각 — 본체 **다음에** 불러야 캐스케이드 순서가 같다(2026-09-04).
@@ -245,8 +245,6 @@ export async function mountProfileView(
routeReady: false,
restoring: true,
};
// 배관 관 길이 · 흙막이 개수(54-1) — 종단 보기도 같은 횡단 자료로(B05 = B06)
setPipeSectionsSource(() => state.detail?.cross_sections ?? null);
// 좌측 단 수를 바꾸면 횡단 보기를 숨긴 채 세워 같은 단 수 제어로 씀(54-27)
setPipeCrossOpener(() => shared.ensureView("cross"));
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
-2
View File
@@ -121,8 +121,6 @@ export interface RoutePlanResponse {
station_snap_step?: number | null;
/** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */
current_initial?: { logic: string; weights: Record<string, number> | null };
/** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */
initial_route?: { nodes: RoutePlanNode[]; curves: RoutePlanCurve[] };
}
export interface RouteReplanResponse {
@@ -42,6 +42,8 @@ export interface RouteInitialRequest {
criteria?: RouteInitialCriteria;
/** 추천 점수에 균형 종단(63-4)을 넣을지 — 끄면 원지반만. 안 보내면 켬(서버 기본). */
with_profile?: boolean;
/** [재계산] — 이 입력의 저장본을 지우고 새로 셈(421 · 서버 420). */
recompute?: boolean;
}
/** 서버 길이 아직 없다(404·405) — 화면은 이 글로 알린다. */
+139 -36
View File
@@ -28,9 +28,11 @@
가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수
있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이
지반이 상한을 넘으면 탐색 상한을 조금씩 낮춰 다시 찾음(`GRADE_CAP_STEPS`).
공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 최소 곡선반지름 · 계류 이격 · 곡선 MC = 일반측점:
· 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌(비탈 곧장 오르는 길 막이). 곡선
맞춤 뒤 측점(20 m) 사이 **원지반 기울기는 참고만**(계획선 높이로 푸는 값 · 요구 253-1 · 419) —
못 지킨 곳 수(`over_limit.total`)에 안 듦 · 목록엔 남음. 시종점 높이 차가 상한을 넘으면
`ends_over_limit`(종단으로도 못 풂 · 화면 알림).
· 최소 곡선반지름 — 한 번에 이웃 방향(약 22.5°)으로만 꺾고, 꺾은 뒤 원호 두 개의 접선 길이만큼
곧게 가야 다시 꺾음 → 노드마다 최소 반지름 원호가 들어감.
· 계류 이격 — 버퍼 안에서는 계류 쪽 · 반대쪽 ±45° 로만 움직임(건너기 · 빠져나가기).
@@ -52,21 +54,31 @@ from __future__ import annotations
import hashlib
import json
import math
from pathlib import Path
from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import (
cross_earthwork,
cross_earthwork_delta,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import RouteEarth
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import (
Pool,
build_pool,
pick,
choose,
hard_limits,
settle,
similar,
weighted,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
STATION_M,
_reference,
attach_profile,
cover_shortfall,
plain_metrics,
@@ -188,6 +200,7 @@ def read_initial_choice(project_root: Path) -> dict[str, Any]:
`criteria` 는 **사용자가 겹쳐 쓴 값만**(비운 칸은 안 담김) — 나머지는 프로젝트 기본값이라
화면이 다시 열 때 겹친 칸만 채우고 나머지는 placeholder(기본값)로 비워 둔다.
`with_profile` = [적용] 때 「종단 반영」(없으면 켬 · 420).
"""
try:
data = json.loads(_choice_path(project_root).read_text(encoding="utf-8"))
@@ -197,9 +210,15 @@ def read_initial_choice(project_root: Path) -> dict[str, Any]:
"logic": logic,
"weights": resolve_weights(logic, data.get("weights")),
"criteria": criteria if isinstance(criteria, dict) and criteria else None,
"with_profile": data.get("with_profile") is not False,
}
except (OSError, ValueError, KeyError, TypeError, AttributeError):
return {"logic": "follow", "weights": resolve_weights("follow", None), "criteria": None}
return {
"logic": "follow",
"weights": resolve_weights("follow", None),
"criteria": None,
"with_profile": True,
}
def write_initial_choice(
@@ -207,6 +226,7 @@ def write_initial_choice(
logic: str,
weights: dict[str, float] | None,
criteria: dict[str, float] | None = None,
with_profile: bool = True,
) -> dict[str, Any]:
"""[확인] 때만 부름 — 작업본 한 칸. 다시 열기 · 다른 PC 에서도 같은 초기값.
@@ -217,6 +237,7 @@ def write_initial_choice(
"logic": logic,
"weights": resolve_weights(logic, weights),
"criteria": criteria or None,
"with_profile": bool(with_profile),
}
path = _choice_path(project_root)
path.parent.mkdir(parents=True, exist_ok=True)
@@ -280,6 +301,7 @@ def generate_initial_route(
design_speed: int = 20,
pipes: list[tuple[float, float, float]] | None = None,
with_profile: bool = True,
standard: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14).
@@ -287,9 +309,10 @@ def generate_initial_route(
후보 묶음(`..._Pool`)에서 제 목표 비교값이 가장 좋은 것. `stream_offset_m`·`grade_smooth_m` 을
안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정).
후보마다 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 토공 점수를 그것으로 고른다 — 편집 창
추천 점수 · 미리 보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다.
`with_profile` 끄면(종단 반영 끔) 종단 없이 63-4 전 점수(원지반 가늠)로 고른다.
토공 · 공사비 갈래는 후보를 노선 횡단 토공(`..._CrossEarth` · 상한 누른 계획선)으로 줄 세워 위
몇 개에만 균형 종단(`..._Profile` · PLAN 63-4)을 붙여 고른다(419) — 편집 창 추천 점수 · 미리
보기에만 쓰고 확정 종단과 무관. `pipes`(x, y, 덮개 여유)는 덮개 부족 수만 센다.
`with_profile` 끄면(종단 반영 끔) 종단 없이 계획고 지반고 횡단 토공으로 고른다.
"""
if mode not in MODES:
raise ValueError(f"모르는 갈래: {mode}")
@@ -303,21 +326,24 @@ def generate_initial_route(
def measure(
outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False
) -> dict[str, Any]:
return route_metrics(
outline.vertices,
expected,
terrain,
streams,
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
# 못 지킨 곳 수 = R · 계류 · 측점 — 원지반 기울기는 참고만(목록엔 남음 · 419)
return hard_limits(
route_metrics(
outline.vertices,
expected,
terrain,
streams,
outline.curves,
max_grade=max_grade,
min_radius_m=min_radius_m,
stream_offset_m=stream_offset,
road_width_m=ROAD_WIDTH_M,
bound_pct=None if bound is None else bound * 100.0,
grade_smooth_m=grade_smooth,
follow=follow,
weighted=weighted,
target_grade=goal_grade,
)
)
def fit(outline: Any, guard: Any = None) -> Any:
@@ -326,14 +352,12 @@ def generate_initial_route(
moved = snap_curves_to_stations(outline, min_radius_m, guard=guard)
return outline if moved is None else moved
unsnapped: dict[int, Any] = {}
def follow_line() -> tuple[Any, dict[str, Any]]:
# 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv` · 383-1 측점 맞춤)과 같은
# 규칙 · 같은 입력 → 같은 선. 제약은 따지지 않고 넘은 곳만 목록에 뜸.
outline = build_planned_polyline(expected, min_radius_m=min_radius_m)
route = (outline, measure(outline, bound=None, follow=True))
return settle(route, fit, measure, unsnapped, bound=None, follow=True)
return settle(route, fit, measure, bound=None, follow=True)
def profiled(route: Any, exact: bool = True) -> None:
if route is not None:
@@ -345,8 +369,64 @@ def generate_initial_route(
design_speed=design_speed,
design_curves=[curve.as_dict() for curve in route[0].curves],
exact=exact,
standard=standard,
)
def quick(route: Any) -> None:
profiled(route, exact=False)
def cross(line: Any, at: Any = None, design_z: Any = None) -> dict[str, Any]:
return cross_earthwork(
line.vertices,
at,
design_z,
terrain,
standard,
design_curves=[curve.as_dict() for curve in line.curves],
)
def route_earth(line: Any) -> float:
# 합치기 멈춤 잣대 = 계획고 지반고 횡단(1차 종단 규칙 · 종단 끔과 같음)
try:
found = cross(line)
except ValueError:
return math.inf
return found["cut"] + found["fill"]
def merge_delta(before: Any, after: Any, window: tuple[float, float]) -> float:
# 바뀐 자리 측점만 — 뒤 측점은 같은 선을 따라 밀린 것뿐(옛 선 끝 s1 → 새 선 s1 + 연장 차)
shift = LineString(after.vertices).length - LineString(before.vertices).length
first = math.ceil(window[0] / STATION_M) * STATION_M
try:
return cross_earthwork_delta(
before.vertices,
after.vertices,
np.arange(first, window[1] + shift + 1e-9, STATION_M),
terrain,
standard,
before_chainages=np.arange(first, window[1] + 1e-9, STATION_M),
design_curves_before=[curve.as_dict() for curve in before.curves],
design_curves_after=[curve.as_dict() for curve in after.curves],
)["earth"]
except ValueError:
return math.inf
def cross_score(route: Any, capped: bool) -> float:
# 후보 줄 세움(419) — 켬 = 상한 누른 계획선(`_reference`) 횡단 · 끔 = 계획고 지반고 횡단
held = pool.scores if pool is not None else {}
key = (id(route[1]), capped)
if key not in held:
try:
found = cross(route[0])
if capped:
at = np.asarray([row["chainage_m"] for row in found["per_station"]])
ground = np.asarray([row["ground_m"] for row in found["per_station"]])
found = cross(route[0], at, _reference(ground, np.diff(at), max_grade))
held[key] = found["cut"] + found["fill"]
except ValueError:
held[key] = math.inf
return held[key]
bound = None
pool: Pool | None = None
target = _weighted_end(used) if mode == "weighted" else mode
@@ -369,19 +449,26 @@ def generate_initial_route(
graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m)
while len(_POOLS) >= POOL_CACHE_SIZE:
_POOLS.pop(next(iter(_POOLS)))
_POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure, fit)
# 합치기 멈춤 토공 = 노선 횡단 + 바뀐 측점 횡단 차이(420 `..._CrossEarth` · 419)
_POOLS[key] = build_pool(
graph,
follow_line(),
max_grade,
min_radius_m,
measure,
fit,
cross=RouteEarth(route_earth, merge_delta),
)
pool = _POOLS[key]
bound = pool.bound
if target == "weighted":
outline, metrics = weighted(pool, used, min_radius_m, measure)
else:
if with_profile:
for route in [*pool.candidates, pool.follow]:
profiled(route, exact=False) # 줄 세우기 — 고른 하나만 아래에서 바로 셈
outline, metrics = pick(target, pool.candidates, with_profile)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle`) — 모달 「측점 맞춤」 끔 = 맞추기 전 선
# 토공 · 공사비 = 횡단 토공으로 줄 세움 · 종단 반영 켬이면 위 후보만 균형 종단(빠른
# 판) · 고른 하나만 아래에서 바로 셈(419)
outline, metrics = choose(target, pool.candidates, with_profile, quick, cross_score)
# 후보는 이미 측점에 맞춘 선(383-1 · `..._Pool.settle` · 맞추기 전 선은 안 냄 · 419)
snapped = (outline, metrics)
raw = unsnapped.get(id(outline)) or (pool.raw.get(id(outline)) if pool else None)
def finish(route: tuple[Any, dict[str, Any]]) -> dict[str, Any]:
line, values = route
@@ -401,6 +488,7 @@ def generate_initial_route(
}
shown = finish(snapped)
ends = ends_grade(expected, terrain, shown["metrics"]["length_m"])
return {
"mode": mode,
"logic": mode,
@@ -409,10 +497,13 @@ def generate_initial_route(
# 지형상 불가능할 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%) · 그 위는 막았음
"terrain_bound_pct": None if bound is None else round(bound * 100.0, 2),
**shown,
# 측점 맞춤 끔 = 고른 선 그대로(맞출 곡선이 없거나 못 맞췄으면 None — 위와 같음)
"unsnapped": None if raw is None else finish(raw),
# 시종점 높이 차 ÷ 노선 연장(%) — 상한 넘으면 종단으로도 못 풂(노선을 늘려야 함 · 화면 알림)
"ends_grade_pct": ends,
"ends_over_limit": ends > max_grade * 100.0 + 1e-6,
# 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만
"similar": None if pool is None else similar(pool, target, outline, with_profile),
"similar": None
if pool is None
else similar(pool, target, outline, with_profile, quick, cross_score),
"with_profile": with_profile,
"band_width_m": None if pool is None else round(pool.band_width_m, 1),
"criteria": {
@@ -429,6 +520,18 @@ def generate_initial_route(
}
def ends_grade(expected: list[tuple[float, float]], terrain: Terrain, length_m: float) -> float:
"""시종점 지반 높이 차 ÷ 노선 연장(%) — 균형 종단이 끝점을 지반고에 두므로 이 값이 상한을 넘으면
어떤 계획선도 상한 안에 못 듦(419 · 원지반 기울기를 참고로 돌린 뒤 남는 알림)."""
from scipy.interpolate import RegularGridInterpolator
sample = RegularGridInterpolator(
(terrain.y, terrain.x), terrain.z, bounds_error=False, fill_value=None
)
z = sample([expected[0][::-1], expected[-1][::-1]])
return round(abs(float(z[1] - z[0])) / max(length_m, 1e-9) * 100.0, 2)
def _weighted_end(weights: dict[str, float]) -> str:
"""비중이 한쪽 100 이면 그 갈래(끝값 = 갈래와 같은 선 · PLAN 23-3e) · 아니면 비중 탐색."""
for name, mode in (("expected", "follow"), ("earthwork", "earthwork"), ("grade", "gentle")):
@@ -0,0 +1,177 @@
"""노선 추천 횡단 토공(PLAN 420 · 요구 254).
측점마다 공식 횡단 엔진(B06 `compute_cross_design` — [저장] 정본과 같은 파이썬)으로 절 · 성
단면적을 재고 평균단면법으로 토량을 낸다. 띠 가늠(폭 4 m · 비탈 뺌 · `..._Metrics`)을 갈음하는
추천 토공 — 후보 줄 세움 · 합치기 판정 · 결과 표 절 · 성.
계산 자리 = 서버 단독(③). 측점 횡단 뜨기 · 단면적은 균형 종단(`..._Profile`)과 같은 함수 —
종단 반영 끔 = 계획고 = 지반고([적용] 뒤 1차 종단과 같음).
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import numpy as np
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
STATION_M,
_station_areas,
route_sections,
)
if TYPE_CHECKING:
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import Terrain
from common_util.common_util_surface_sampler import SurfaceElevationSampler
def _end_area_weights(at: np.ndarray) -> np.ndarray:
"""평균단면법 무게 — 측점마다 앞 · 뒤 구간 반씩(Σ (A_i + A_i+1) / 2 · L = Σ A_i · w_i)."""
spans = np.diff(at)
weight = np.zeros(len(at))
weight[:-1] += spans / 2.0
weight[1:] += spans / 2.0
return weight
def _areas(
line: list[tuple[float, float]],
chainages: Any,
design_z: Any,
terrain: Terrain | SurfaceElevationSampler,
standard: dict[str, Any] | None,
design_curves: list[dict[str, Any]] | None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]:
"""(누가거리, 지반고, 계획고, [절 자연 · 절 다짐 · 성] ㎡ 행렬)."""
at, ground, sections, modes, widen = route_sections(line, terrain, design_curves, at=chainages)
plan = ground if design_z is None else np.asarray(design_z, dtype=np.float64)
if plan.shape != at.shape:
raise ValueError("계획고 수가 측점 수와 다릅니다.")
rows = np.asarray(
[
_station_areas(sections[i], modes[i], float(plan[i]), widen[i], standard)
for i in range(len(at))
]
).reshape(-1, 3)
return at, ground, plan, rows
def cross_earthwork(
line: list[tuple[float, float]],
chainages: Any,
design_z: Any,
terrain: Terrain | SurfaceElevationSampler,
standard: dict[str, Any] | None = None,
*,
design_curves: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""노선 하나의 횡단 토공 — `{cut, cut_compacted, fill, per_station}`(㎥ · 절 = 자연 상태).
`chainages` = 잴 누가거리(None = 20 m 측점 + 종점 · 균형 종단과 같은 틀) · `design_z` =
그 측점 계획고(None = 지반고 · 종단 반영 끔) · `standard` = 프로젝트 저장 표준 횡단면 설정값
(None = 설정 기본값) · `design_curves` = 곡선표(확폭 · 없으면 측점별 실측 반경)."""
at, ground, plan, rows = _areas(line, chainages, design_z, terrain, standard, design_curves)
weight = _end_area_weights(at)
cut, cut_c, fill = (float((weight * rows[:, k]).sum()) for k in range(3))
return {
"cut": round(cut, 1),
"cut_compacted": round(cut_c, 1),
"fill": round(fill, 1),
"per_station": [
{
"chainage_m": round(float(s), 2),
"ground_m": round(float(g), 3),
"plan_m": round(float(p), 3),
"cut_m2": round(float(r[0]), 4),
"cut_compacted_m2": round(float(r[1]), 4),
"fill_m2": round(float(r[2]), 4),
}
for s, g, p, r in zip(at, ground, plan, rows)
],
}
def cross_earthwork_delta(
before: list[tuple[float, float]] | dict[str, Any],
after: list[tuple[float, float]],
changed_chainages: Any,
terrain: Terrain | SurfaceElevationSampler,
standard: dict[str, Any] | None = None,
*,
before_chainages: Any = None,
z_before: Any = None,
z_after: Any = None,
design_curves_before: list[dict[str, Any]] | None = None,
design_curves_after: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""바뀐 측점만 횡단으로 잰 토공 차(새 − 옛 · ㎥) — 합치기 판정(3~6 측점).
측점 하나 = `STATION_M` 몫(가운데 측점 평균단면법 무게). `before` = 옛 노선 정점 또는 그
노선의 `cross_earthwork` 결과(그 측점 단면적을 다시 안 셈 · 누가거리 가장 가까운 것) ·
`before_chainages` = 옛 노선에서 잴 누가거리(없으면 `changed_chainages`) · `z_*` = 그 측점
계획고(None = 지반고). 결과 `cut` · `fill` · `earth` = 차 · `before` 가 결과면 `*_total` =
고친 뒤 노선 전체 어림(옛 합 + 차)."""
at_after = np.asarray(changed_chainages, dtype=np.float64).reshape(-1)
at_before = at_after if before_chainages is None else np.asarray(before_chainages, float)
if at_after.size == 0:
new = old = np.zeros((0, 3))
else:
new = _areas(after, at_after, z_after, terrain, standard, design_curves_after)[3]
if isinstance(before, dict):
rows = before["per_station"]
held = np.asarray([row["chainage_m"] for row in rows])
pick = [int(np.argmin(np.abs(held - s))) for s in at_before]
old = np.asarray(
[[rows[i]["cut_m2"], rows[i]["cut_compacted_m2"], rows[i]["fill_m2"]] for i in pick]
).reshape(-1, 3)
else:
old = _areas(before, at_before, z_before, terrain, standard, design_curves_before)[3]
cut, cut_c, fill = (float(STATION_M * (new[:, k].sum() - old[:, k].sum())) for k in range(3))
out = {
"cut": round(cut, 1),
"cut_compacted": round(cut_c, 1),
"fill": round(fill, 1),
"earth": round(cut + fill, 1),
"stations": int(at_after.size),
}
if isinstance(before, dict):
out["cut_total"] = round(before["cut"] + cut, 1)
out["fill_total"] = round(before["fill"] + fill, 1)
return out
def attach_cross_earth(
result: dict[str, Any],
terrain: Terrain | SurfaceElevationSampler,
standard: dict[str, Any] | None = None,
) -> None:
"""추천 결과 표 절 · 성 = 횡단 값(420) — 종단 켬 = 균형 종단 횡단 ㎥ · 끔 = 계획고 지반고.
띠 가늠은 `band_cut_m3` · `band_fill_m3` 로 남긴다. 맞추기 전 선(`unsnapped`)도 같이."""
for item in (result, result.get("unsnapped")):
if not item or not item.get("planned"):
continue
metrics = item["metrics"]
if "band_cut_m3" in metrics:
continue
profile = metrics.get("profile")
if profile and "error" not in profile:
cut, fill = float(profile["cut_m3"]), float(profile["fill_m3"])
else:
try:
earth = cross_earthwork(
[tuple(p) for p in item["planned"]],
None,
None,
terrain,
standard,
design_curves=item.get("curves") or None,
)
except ValueError: # 지반을 못 뜬 노선 — 띠 가늠 그대로
continue
cut, fill = earth["cut"], earth["fill"]
metrics["band_cut_m3"] = metrics.get("cut_m3")
metrics["band_fill_m3"] = metrics.get("fill_m3")
metrics["cut_m3"] = round(cut, 1)
metrics["fill_m3"] = round(fill, 1)
metrics["surplus_m3"] = round(cut - fill, 1)
@@ -9,12 +9,13 @@
거리 한도(이탈 m) 없음 — 「원래 선」이 없어 한도가 대신하던 것을 직접 잰다(`Guard`):
· 탐색 복도(`CORRIDOR_M`) 안 · 계류 버퍼 안 나란히(`along`) 안 늘어남 · 최소 R(별표2) 이상.
· 옮긴 조각이 같은 자리 옛 조각보다 지반 기울기 상한을 더 넘지 않음(`GRADE_NOISE`) · 토공
가늠이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선 이격도(`keeping_offset`).
· 옮긴 조각 토공 가늠(띠 · 빠른 거름)이 `EARTH_TOL` 넘게 늘지 않음 · 비중 계산이면 예상노선
이격도(`keeping_offset`). 원지반 기울기는 참고만(419 · 막지 않음 · 자리 잣대에도 안 씀).
· 합친 곡선은 MC 가 측점 사이 어디에 떨어져도 IP 를 밀어 측점에 놓은 꼴이 잼을 지나야 함
(`PLACE_OFFSETS_M` · 측점 맞춤이 합친 곡선을 늘 측점에 · 합치기 풀기 없음).
· 합치기는 노선 전체 잣대(`route_metrics`)도 — 넘은 곳 수 · 기울기 넘은 곳 안 늘어남 · 최대
기울기 `GRADE_TOL_PCT` · 절성토 `TOTAL_EARTH_TOL` 안(합치기 전 선 기준 · 되풀이해도 누적 안 됨).
· 합치기 멈춤 = 노선 전체 잣대 — 못 지킨 곳 수(R · 계류) 안 늘어남 · 노선 토공이 합치기 전 선보다
`TOTAL_EARTH_TOL` 넘게 늘지 않음(엔진 상수 · 사용자 옵션 아님 · 되풀이해도 누적 안 됨). 토공 =
바뀐 측점 횡단 차이(`cross_earthwork_delta` · 420) · 없으면 노선 잣대 토공(띠 가늠).
큰 R(`..._Radius.widen`) · 측점 맞춤(`..._Station`)도 같은 `Guard` 로 잰다(거리 한도 대신).
"""
@@ -23,11 +24,12 @@ from __future__ import annotations
import copy
import math
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
import numpy as np
import shapely
from shapely.geometry import LineString
from shapely.geometry import LineString, Point
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import SAMPLE_M, STATION_M, _resample
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat
@@ -52,11 +54,8 @@ Route = tuple[Any, dict[str, Any]]
# 옮긴 조각 토공 가늠(탐색 몫 · 옆경사 단면)이 옛 조각보다 늘어도 되는 몫 · 바닥(㎥ · 짧은 조각)
EARTH_TOL = 0.05
EARTH_FLOOR_M3 = 5.0
# 옮긴 조각 지반 기울기가 상한을 더 넘어도 「안 늘어남」으로 볼 잴 잡음(비율 · 0.2 %p)
GRADE_NOISE = 0.002
# 합치기 노선 전체 잣대 — 최대 기울기(%p) · 절성토 늘어도 되는 몫
GRADE_TOL_PCT = 0.5
TOTAL_EARTH_TOL = 0.02
# 합치기 멈춤 — 노선 토공(횡단)이 합치기 전 선보다 늘어도 되는 몫(곡선 수 ↔ 토공 · 요구 254)
TOTAL_EARTH_TOL = 0.10
# R 후보 수(자리 R ~ 최소 R 사이 · 큰 것부터)
RADIUS_STEPS = 12
# 합친 곡선 MC 가 앞 측점에서 이만큼(m) 뒤일 때 — 앞 측점으로 당기거나(−t) 뒤 측점으로 미는(20 − t)
@@ -150,27 +149,19 @@ class Guard:
self.seen[key] = found
return found
def judge(
self, local: list[Xy], reference: list[Xy]
) -> tuple[bool, tuple[bool, float, float, float]]:
"""(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 기울기 넘음 안 늘어남 · 토공
`EARTH_TOL` 안(`offset` 이면 예상노선 이격도 같은 폭). 잣대(작을수록 좋음) = 복도 밖 ·
늘어난 계류 나란히(m) · 더 넘은 기울기 · 늘어난 토공 가늠(㎥)."""
excess, earth, along, inside, offset = self._look(local)
old_excess, old_earth, old_along, _, old_offset = self._look(reference)
def judge(self, local: list[Xy], reference: list[Xy]) -> tuple[bool, tuple[bool, float, float]]:
"""(지나나, 자리 고르기 잣대) — 지남 = 복도 안 · 계류 나란히 · 토공 `EARTH_TOL` 안(`offset`
이면 예상노선 이격도 같은 폭) · 원지반 기울기는 안 봄(419). 잣대(작을수록 좋음) = 복도 밖 ·
늘어난 계류 나란히(m) · 늘어난 토공 가늠(㎥)."""
_, earth, along, inside, offset = self._look(local)
_, old_earth, old_along, _, old_offset = self._look(reference)
passed = (
inside
and excess <= old_excess + GRADE_NOISE
and along <= old_along + 1e-6
and earth <= old_earth * (1 + EARTH_TOL) + EARTH_FLOOR_M3
and (not self.offset or offset <= old_offset * (1 + EARTH_TOL) + EARTH_FLOOR_M3)
)
cost = (
not inside,
round(max(0.0, along - old_along), 3),
round(max(0.0, excess - old_excess), 4),
earth - old_earth,
)
cost = (not inside, round(max(0.0, along - old_along), 3), earth - old_earth)
return passed, cost
def ok(self, local: list[Xy], reference: list[Xy]) -> bool:
@@ -243,15 +234,13 @@ def _earth(metrics: dict[str, Any]) -> float:
return metrics["cut_m3"] + metrics["fill_m3"]
def _worse(new: dict[str, Any], base: dict[str, Any]) -> bool:
"""노선 전체 잣대가 합치기 전보다 나빠짐(허용 폭 밖)."""
over, was = new["over_limit"], base["over_limit"]
return (
over["total"] > was["total"]
or over["grade"] > was["grade"]
or new["max_grade_pct"] > base["max_grade_pct"] + GRADE_TOL_PCT
or _earth(new) > _earth(base) * (1 + TOTAL_EARTH_TOL)
)
@dataclass
class RouteEarth:
"""합치기 멈춤 잣대 — 노선 토공(횡단 ㎥ · `..._CrossEarth`)과 바뀐 측점 횡단 차이."""
total: Callable[[PlannedPolyline], float]
# (합치기 전 선, 합친 선, 바뀐 자리 누가거리(앞 선 기준 s0, s1)) → 노선 토공 차(㎥)
delta: Callable[[PlannedPolyline, PlannedPolyline, tuple[float, float]], float]
def merge_same_side(
@@ -259,14 +248,23 @@ def merge_same_side(
guard: Guard,
min_radius_m: float,
measure: Callable[[PlannedPolyline], dict[str, Any]],
route_earth: RouteEarth | None = None,
) -> Route:
"""같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로."""
"""같은 쪽 이웃 꺾임 합치기를 되풀이한 선과 잰 값 · 합칠 것 없으면 받은 그대로.
멈춤 = 못 지킨 곳 수(R · 계류 · 원지반 기울기 뺌) 늘어남 · 노선 토공이 합치기 전 선의
1 + `TOTAL_EARTH_TOL` 넘음. `route_earth` 주면 토공 = 횡단(합치기 전 선 전체 + 바뀐 측점 차이
누적) · 없으면 노선 잣대 토공(`measure` 절 + 성 · 띠 가늠)."""
outline, base = route
if len(outline.curves) < 2:
return route
points, flags, radii = _flat(outline)
points = [(round(x, 4), round(y, 4)) for x, y in points]
current = route
earth = (
_earth(base) if route_earth is None else route_earth.total(outline)
) # 지금 선 토공 — 차이를 누적
limit = earth * (1 + TOTAL_EARTH_TOL)
blocked: set[tuple[Xy, ...]] = set()
while True:
reference = _Reference(current[0].vertices)
@@ -287,9 +285,9 @@ def merge_same_side(
LineString(local), LineString(reference.window(local)), densify=0.1
)
)
found.append((gap, k, cross, radius))
found.append((gap, k, cross, radius, local))
merged = None
for _, k, cross, radius in sorted(found):
for _, k, cross, radius, local in sorted(found, key=lambda item: item[:2]):
trial_points = points[:k] + [cross] + points[k + 2 :]
trial_flags = flags[:k] + [True] + flags[k + 2 :]
trial_radii = radii[:k] + [radius] + radii[k + 2 :]
@@ -301,11 +299,16 @@ def merge_same_side(
radii=trial_radii,
)
metrics = measure(line)
if _worse(metrics, base):
if route_earth is None:
total = _earth(metrics)
else:
window = sorted(reference.line.project(Point(xy)) for xy in (local[0], local[-1]))
total = earth + route_earth.delta(current[0], line, (window[0], window[1]))
if metrics["over_limit"]["total"] > base["over_limit"]["total"] or total > limit:
blocked.add(tuple(points[k - 1 : k + 3]))
continue
merged = (trial_points, trial_flags, trial_radii, (line, metrics))
merged = (trial_points, trial_flags, trial_radii, (line, metrics), total)
break
if merged is None:
return current
points, flags, radii, current = merged
points, flags, radii, current, earth = merged
@@ -4,18 +4,23 @@
후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다:
· 제약 탐색 후보 — 목표 하나씩(`TARGETS` · 토공 · 기울기 · 이격 · 목표 기울기 편차 · 위험 ·
계류 횡단 · 공사비) × 탐색 상한 단계(지킬 수 있을
때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로.
계류 횡단 · 공사비) × 탐색 링크 상한(지킬 수 있을 때 · 한 번) 또는 벌점 크기(지형상 불가능할
때 · 막은 기울기 안) — PLAN 23-1.
· 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌.
갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤
쓸기 최대 기울기).
· 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다.
· 못 지킨 곳 수(R · 계류 · 측점 — 원지반 기울기는 참고만 · 419 `hard_limits`)가 기준(엄격: 후보
최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. 탐색 링크 상한(비탈 곧장 오르는 길 막이)은
남김.
· R = 일반측점이 기본 제약(383-1) — 후보마다 곡선 MC 를 일반측점에 맞춘 선(`snap`)으로 재고
고르고 거름. 못 맞춘 곡선은 「못 지킨 구간」(station) · 고르기는 측점 밖 0 후보를 앞세움.
꺾임만(R 없는 교점)은 안 남김(`..._Radius.curve_every_kink` · 측점 맞춤 · 419).
갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면
평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) ·
계류 횡단 최소 = 횡단 수(같으면 계류에서 먼 쪽) · 공사비 어림 최소 = 상대 공사비(PLAN 23-5).
종단 반영 켬 토공 · 공사비(`choose`) = 상한 누른 계획선 토공으로 줄 세운 위 `SHORTLIST` 개만 균형
종단(빠른 판)을 붙여 고름(419).
비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a):
· 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 ·
@@ -33,14 +38,14 @@ from typing import Any
import numpy as np
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, RouteEarth, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
COST_CROSSING,
COST_EARTH_M3,
COST_LENGTH_M,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import curve_every_kink, widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import (
CROSSING_BAND,
OBJECTIVES,
@@ -62,8 +67,9 @@ TARGETS: dict[str, dict[str, float]] = {
"crossings": {"crossing": 100.0},
"cost": {},
}
# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음
GRADE_CAP_STEPS = (1.0, 0.93, 0.85)
# 종단 반영 켬 토공 · 공사비 고르기 — 균형 종단(빠른 판)을 붙일 위 후보 수(419 · 검증_sub4 = 상한 누른
# 계획선 횡단 5 위가 균형 종단 토공 최소 → 5)
SHORTLIST = 5
# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게
# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨)
SOFT_GRADE_COST = 500.0
@@ -102,20 +108,30 @@ class Pool:
follow_cap: float = np.inf
searched: dict[tuple[float, ...], Route] = field(default_factory=dict)
traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`)
raw: dict[int, Route] = field(default_factory=dict) # 맞춘 선 id → 맞추기 전(「측점 맞춤」 끔)
follow: Route | None = None
# 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m)
band_width_m: float = 0.0
# 큰 R · 합치기 · 측점 맞춤이 거리 한도 대신 쓰는 직접 잼(406 · `..._Merge.Guard`)
guard: Guard | None = None
# 합치기 멈춤 토공 = 횡단(노선 + 바뀐 측점 차이 · 420 · 없으면 띠 가늠 · `merge_same_side`)
cross: RouteEarth | None = None
# 후보 줄 세움 횡단 토공 기억(잰 값 id · 종단 반영) — 갈래 사이 다시 안 셈(419)
scores: dict[tuple[int, bool], float] = field(default_factory=dict)
def settle(route: Route, snap: Snap, measure: Measure, raw: dict[int, Route], **kw: Any) -> Route:
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1) · 맞추기 전 선은 `raw` 에."""
def hard_limits(metrics: dict[str, Any]) -> dict[str, Any]:
"""원지반 기울기 = 참고만(419) — 못 지킨 곳 수(`over_limit.total`)에서 뺀 잰 값.
기울기 넘은 곳은 목록(`violations` · kind grade)과 `over_limit.grade` 에 참고로 남는다."""
over = metrics["over_limit"]
return {**metrics, "over_limit": {**over, "total": over["total"] - over.get("grade", 0)}}
def settle(route: Route, snap: Snap, measure: Measure, **kw: Any) -> Route:
"""측점에 맞춘 선 + 잰 값(측점 밖 곡선 = 못 지킨 구간 · 383-1)."""
outline, metrics = route
moved = snap(outline)
if moved is not outline:
raw[id(moved)] = (outline, with_station_misses(metrics, outline))
outline, metrics = moved, measure(moved, **kw)
return outline, with_station_misses(metrics, outline)
@@ -125,12 +141,12 @@ def _trace(
) -> Route:
# 큰 R(PLAN 328) — 2단 판(허용 이탈 안 가장 큰 R · `..._Radius.widen`) → 2단을 최소 R 판
# 노드에 바로 건 판(단순화가 기울기 위반을 늘리는 급한 지형 · 검증_sub8 실측) → 1단 판(탐색
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반이 최소 R 판(노드마다 곡선 ·
# 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시 짜는 꼴.
# 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로).
# 고른 판의 같은 쪽 이웃 곡선을 합치고(406 · `..._Merge`) 측점에 맞춤(`settle` · 383-1).
# 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접 잼(`pool.guard`) · 비중 계산은 예상노선
# 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
# 경로를 예정노선 규칙으로 단순화 · 묶음 · R 맞춤) 차례로, 위반(R · 계류 · 원지반 기울기 뺌)이
# 최소 R 판(노드마다 곡선 · 옛 규칙)보다 많지 않은 첫 판. 모두 화면이 [확인] 때 같은 선으로 다시
# 짜는 꼴. 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄).
# 고른 판의 꺾임만을 곡선 · 교점 지움으로(419) → 같은 쪽 이웃 곡선 합치기(406 · `..._Merge`) →
# 측점에 맞춤(`settle` · 383-1). 큰 R · 합치기 · 측점 맞춤 모두 거리 한도 대신 직접
# 잼(`pool.guard`) · 비중 계산은 예상노선 이격도(그 갈래 잣대 — 예상 몫을 줄이면 이격이 늘기만).
traced = pool.traced
guard = pool.guard
if guard is not None and kw.get("weighted"):
@@ -157,8 +173,13 @@ def _trace(
least,
)
if guard is not None:
chosen = merge_same_side(chosen, guard, min_radius_m, lambda line: measure(line, **kw))
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, pool.raw, **kw)
line = curve_every_kink(chosen[0], min_radius_m)
if line is not chosen[0]:
chosen = (line, measure(line, **kw))
chosen = merge_same_side(
chosen, guard, min_radius_m, lambda line: measure(line, **kw), pool.cross
)
traced[key] = settle(chosen, lambda line: pool.snap(line, guard), measure, **kw)
return traced[key]
@@ -184,22 +205,18 @@ def _candidates(
) -> tuple[list[Route], float | None, int]:
"""제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한.
제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 ·
없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들.
제약을 지키는 길이 있으면 목표마다 탐색 링크 상한으로 찾은 노선들(곡선 맞춤 뒤 원지반
기울기는 참고만 — 상한 낮춰 다시 찾지 않음 · 419) · 없으면 막은 기울기 안에서 벌점 크기 ·
목표를 바꿔 찾은 노선들.
"""
graph = pool.graph
found: list[Route] = []
for target in TARGETS:
for step in GRADE_CAP_STEPS:
try:
nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True)
except NoRoute:
break
found.append(_trace(pool, nodes, min_radius_m, measure, bound=None))
if found[-1][1]["over_limit"]["total"] == 0:
break
if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
break
try:
nodes = _solve(graph, target, cap=max_grade, strict_stream=True)
except NoRoute:
break # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음
found.append(_trace(pool, nodes, min_radius_m, measure, bound=None))
if found:
least = min(item[1]["over_limit"]["total"] for item in found)
return [item for item in found if item[1]["over_limit"]["total"] == least], None, least
@@ -236,9 +253,12 @@ def build_pool(
min_radius_m: float,
measure: Measure,
snap: Snap,
cross: RouteEarth | None = None,
) -> Pool:
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함."""
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade))
"""후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.
`measure` 는 원지반 기울기를 뺀 못 지킨 곳 수를 내야 함(`hard_limits`)."""
pool = Pool(graph, snap, follow=follow, guard=Guard(graph.grid, max_grade), cross=cross)
pool.candidates, pool.bound, pool.limit = _candidates(pool, max_grade, min_radius_m, measure)
candidates, bound, limit = pool.candidates, pool.bound, pool.limit
ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]]
@@ -259,14 +279,22 @@ def build_pool(
return pool
def similar(pool: Pool, mode: str, outline: Any, with_profile: bool = True) -> list[dict[str, Any]]:
def similar(
pool: Pool,
mode: str,
outline: Any,
with_profile: bool = True,
profile: Callable[[Route], None] | None = None,
earth: CrossScore | None = None,
) -> list[dict[str, Any]]:
"""이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m).
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다."""
화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.
다른 갈래도 그 갈래와 같은 고르기(`choose` · `profile` · `earth`)."""
here = LineString(outline.vertices)
others = {
"follow": pool.follow,
**{name: pick(name, pool.candidates, with_profile) for name in GOALS},
**{name: choose(name, pool.candidates, with_profile, profile, earth) for name in GOALS},
}
found = []
for name, (other, _) in others.items():
@@ -333,6 +361,40 @@ GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = {
}
# (후보, 종단 반영) → 노선 횡단 토공 ㎥ — 켬 = 상한 누른 계획선 · 끔 = 계획고 지반고(420)
CrossScore = Callable[[Route, bool], float]
def choose(
mode: str,
candidates: list[Route],
with_profile: bool,
profile: Callable[[Route], None] | None,
earth: CrossScore | None = None,
) -> Route:
"""갈래 고르기 — 토공 · 공사비는 노선 횡단 토공(`earth` · 없으면 띠 가늠)으로 줄 세움(419):
종단 반영 켬 = 상한 누른 계획선 횡단 위 `SHORTLIST` 개만 `profile`(균형 종단 빠른 판)을 붙여
그 사이에서 고름(후보마다 종단을 세우지 않음) · 끔 = 계획고 지반고 횡단으로 바로 고름.
나머지 갈래는 `pick` 그대로."""
if mode not in ("earthwork", "cost"):
return pick(mode, candidates, with_profile)
unique = list({id(item[1]): item for item in candidates}.values()) # 같은 노선 겹침 뺌
def score(item: Route, profiled: bool) -> tuple:
plain = plain_metrics(item[1])
value = _earth(plain) if earth is None else earth(item, profiled)
if mode == "cost": # 공사비 어림의 토공 몫을 횡단 값으로 갈음
value += plain["cost_index"] - _earth(plain)
return (item[1]["over_limit"].get("station", 0) > 0, value)
if not (with_profile and profile is not None):
return min(unique, key=lambda item: score(item, False))
shortlist = sorted(unique, key=lambda item: score(item, True))[:SHORTLIST]
for route in shortlist:
profile(route)
return pick(mode, shortlist, with_profile)
def pick(mode: str, candidates: list[Route], with_profile: bool = True) -> Route:
"""갈래 목표 비교값이 가장 좋은 후보 — 측점 밖 곡선 없는 후보 먼저(383-1). 같으면 앞 후보.
`with_profile` 끔 = 원지반 가늠 점수."""
@@ -85,20 +85,25 @@ def route_sections(
vertices: list[tuple[float, float]],
terrain: Terrain | SurfaceElevationSampler,
design_curves: list[dict[str, Any]] | None = None,
at: Any = None,
) -> tuple[np.ndarray, np.ndarray, list[list[dict[str, Any]]], list[str], list[dict[str, Any]]]:
"""측점 누가거리 · 중심 지반고 · 측점별 지반 횡단 샘플 · 단면유형(상단측 절토) · 곡선부 확폭.
샘플은 `CROSS_WIDE_M` 까지 — ±`CROSS_HALF_M` 밖은 지표면이 끊기기 전까지만 담는다."""
`at` = 뜰 누가거리(없으면 20 m 측점 + 종점) · 샘플은 `CROSS_WIDE_M` 까지 — ±`CROSS_HALF_M`
밖은 지표면이 끊기기 전까지만 담는다."""
xy = np.asarray(vertices, dtype=np.float64)
keep = np.r_[True, np.hypot(*np.diff(xy, axis=0).T) > 1e-9]
xy = xy[keep]
seg = np.hypot(*np.diff(xy, axis=0).T)
chain = np.concatenate([[0.0], np.cumsum(seg)])
total = float(chain[-1])
at = np.arange(0.0, total, STATION_M)
if len(at) > 1 and total - at[-1] < TAIL_MERGE_M:
at = at[:-1]
at = np.append(at, total)
if at is not None:
at = np.clip(np.asarray(at, dtype=np.float64), 0.0, total)
else:
at = np.arange(0.0, total, STATION_M)
if len(at) > 1 and total - at[-1] < TAIL_MERGE_M:
at = at[:-1]
at = np.append(at, total)
index = np.clip(np.searchsorted(chain, at, side="right") - 1, 0, len(seg) - 1)
tangent = np.diff(xy, axis=0)[index] / seg[index][:, None]
left = np.column_stack([-tangent[:, 1], tangent[:, 0]])
@@ -165,10 +170,15 @@ def _widenings(
def _station_areas(
samples: list[dict[str, Any]], mode: str, elevation: float, widen: dict[str, Any]
samples: list[dict[str, Any]],
mode: str,
elevation: float,
widen: dict[str, Any],
standard: dict[str, Any] | None = None,
) -> tuple[float, float, float]:
"""측점 하나 · 계획고 하나의 (절토 자연 ㎡, 절토 다짐 환산 ㎡, 성토 ㎡) — 본 설계 기본 설계와
같은 입력. 반폭에서 비탈이 안 닫히면 넓힌 샘플로 다시 셈(본 설계 `extend_unclosed`)."""
같은 입력. 반폭에서 비탈이 안 닫히면 넓힌 샘플로 다시 셈(본 설계 `extend_unclosed`).
`standard` = 프로젝트에 저장된 표준 횡단면 설정값(없으면 설정 기본값 · 420)."""
from B06_Section.B06_Section_Engine_Design import compute_cross_design
def design(pick: list[dict[str, Any]]) -> dict[str, Any]:
@@ -177,6 +187,7 @@ def _station_areas(
elevation,
ground_type=GROUND_TYPE,
section_mode=mode,
standard=standard,
rock_boundary_offset_m=STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M,
**widen,
)
@@ -204,6 +215,7 @@ def _area_curves(
ground: np.ndarray,
levels: np.ndarray,
curves: list[dict[str, Any]] | None = None,
standard: dict[str, Any] | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""측점 · 계획고 후보마다 (절토 자연 ㎡, 절토 다짐 ㎡, 성토 ㎡) — `AREA_STEP_M` 마디 보간."""
shape = levels.shape
@@ -213,7 +225,9 @@ def _area_curves(
delta = levels[i] - ground[i]
lo = np.floor(delta.min() / AREA_STEP_M) * AREA_STEP_M
at = np.arange(lo, delta.max() + AREA_STEP_M, AREA_STEP_M)
table = np.asarray([_station_areas(samples, mode, float(ground[i] + d), widen) for d in at])
table = np.asarray(
[_station_areas(samples, mode, float(ground[i] + d), widen, standard) for d in at]
)
# 단면적은 계획고에 대해 볼록(대략 2차)이라 직선 보간은 크게 잡힌다 — 단조 3차(PCHIP)
if len(at) > 1:
curve = PchipInterpolator(at, table, axis=0)(delta)
@@ -277,11 +291,13 @@ def balanced_profile(
design_speed: int = 20,
curves: list[dict[str, Any]] | None = None,
exact: bool = True,
standard: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""상한(종단기울기 · 종단곡선)을 지키며 절 · 성토를 횡단 ㎥ 로 맞춘 계획선과 토량.
시점 · 종점은 지반고(확정 체인 기본 오프셋 0 과 같음) — 그 길이 없을 때만 풀어 다시 찾는다.
`exact` 끄면 보간값 그대로(후보 줄 세우기 — 빠름) · 켜면 고른 선을 바로 셈해 누가토량을 맞춘다.
`standard` = 프로젝트 저장 표준 횡단면 설정값(없으면 설정 기본값).
"""
started = time.perf_counter()
spans = np.diff(chainage)
@@ -291,7 +307,7 @@ def balanced_profile(
offsets = np.arange(-LEVEL_RANGE_M, LEVEL_RANGE_M + 1e-9, LEVEL_STEP_M)
levels = _reference(ground, spans, max_grade)[:, None] + offsets[None, :]
cut, cut_c, fill = (
weight[:, None] * a for a in _area_curves(sections, modes, ground, levels, curves)
weight[:, None] * a for a in _area_curves(sections, modes, ground, levels, curves, standard)
)
criteria = (
FOREST_ROAD_PROFILE_CRITERIA["design_speed"].get(design_speed)
@@ -312,7 +328,9 @@ def balanced_profile(
plan = levels[np.arange(len(chainage)), result["path"]]
rows = np.asarray(
[
_station_areas(sections[i], modes[i], float(plan[i]), curves[i] if curves else {})
_station_areas(
sections[i], modes[i], float(plan[i]), curves[i] if curves else {}, standard
)
for i in range(len(chainage))
]
)
@@ -423,6 +441,7 @@ def route_profile(
design_speed: int,
design_curves: list[dict[str, Any]] | None = None,
exact: bool = True,
standard: dict[str, Any] | None = None,
) -> dict[str, Any]:
"""후보 노선 하나의 균형 종단 — 측점 횡단을 뜨고 `balanced_profile`.
@@ -439,6 +458,7 @@ def route_profile(
design_speed=design_speed,
curves=curves,
exact=exact,
standard=standard,
)
@@ -496,6 +516,7 @@ def attach_profile(
design_speed: int,
design_curves: list[dict[str, Any]] | None = None,
exact: bool = True,
standard: dict[str, Any] | None = None,
) -> None:
"""후보 비교값에 균형 종단을 붙이고 토공 점수를 그것으로 바꾼다(묶음 캐시에 남음).
@@ -513,6 +534,7 @@ def attach_profile(
design_speed=design_speed,
design_curves=design_curves,
exact=exact,
standard=standard,
)
except ValueError as error:
metrics["profile"] = {"error": str(error)}
@@ -10,6 +10,8 @@
복도 밖 · 기울기 · 계류 · 토공이 나빠지지 않는 가장 큰 R(`..._Merge.Guard` · 자리 R ~ 맞춘 R 사이
`GUARD_STEPS` 벌 · 1 m 내림).
꺾임만(`curve_every_kink` · 419) — 추천 후보에 R 없는 교점을 안 남김(곡선 또는 교점 지움).
고른 R 은 편집 갈래(`simplify=False` · `radii`)로 못박아 다시 짠다 — 화면이 곡선을 교각점 하나로
펴서(`flattenServerPlan`) [확인]이 다시 짜는 것과 같은 입력이라 저장 뒤 R 이 응답 R 과 같다.
"""
@@ -51,6 +53,30 @@ def _flat(outline: PlannedPolyline) -> tuple[list[Xy], list[bool], list[float |
return points, flags, radii
def curve_every_kink(outline: PlannedPolyline, min_radius_m: float) -> PlannedPolyline:
"""꺾임만(R 없는 교점 · 삼각) 없앤 선(419) — 최소 R 원호가 들어가면 곡선(내각 155° 이상도) ·
아니면 교점 지움(곧게). 꺾임만이 없으면 받은 그대로."""
points, flags, radii = _flat(outline)
if all(flags[1:-1]):
return outline
index = 1
while index < len(points) - 1:
if flags[index]:
index += 1
continue
before, apex, after = points[index - 1 : index + 2]
half_tan = math.tan(math.radians(180.0 - _inner_angle_deg(before, apex, after)) / 2)
room = min(math.dist(before, apex), math.dist(apex, after)) / 2
if half_tan > 1e-9 and room / half_tan >= min_radius_m:
flags[index] = True
index += 1
else:
del points[index], flags[index], radii[index]
return build_planned_polyline(
points, min_radius_m=min_radius_m, simplify=False, curve_flags=flags, radii=radii
)
def _cross(u: np.ndarray, v: np.ndarray) -> np.ndarray:
return u[..., 0] * v[..., 1] - u[..., 1] * v[..., 0]
@@ -21,11 +21,13 @@ R = 일반측점이 기본 제약(383-1 · 요구 241-13) — 추천 후보마
· 그것도 못 하면 None — 부르는 쪽이 원래 선을 씀.
추천 후보(`guard` 줌 · PLAN 406 · 요구 251-2)는 이탈 한도 없음 — 옮긴 조각을 직접 잼
(`..._Merge.Guard` · 복도 · 기울기 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐)으로 거름:
(`..._Merge.Guard` · 복도 · 계류 · 토공이 같은 자리 원래 조각보다 안 나빠짐 · 원지반 기울기는
참고만 · 419)으로 거름. MC = 일반측점 필수 · 꺾임만(R 없는 교점 · 삼각) 없음(419):
· 같은 쪽으로 가장 많이 합친 꼴부터 놓아 봄(S자는 안 합침) · 자리는 그 잣대가 가장 나은 곳부터.
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안 · 더 넘는 기울기 `SOFT_SLACKS` 단계까지) —
MC 를 다 측점에 놓는 첫 단계 판.
· 그래도 못 놓으면 더 안 넘기는 놓기만 쓰고 그 곡선은 그 자리(측점 밖 · 기울기 먼저).
· 잼을 지나는 놓기가 없으면 덜 나쁜 놓기(복도 안) — MC 는 늘 측점에.
· 곡선 끔 없음 — 내각 155° 이상도 곡선 · 꺾임만으로 들어온 자리도 곡선으로 놓아 봄 · 곡선을 못
끼우는 자리(나란함)는 교점 지움 · 이웃과 한 측점을 다투면 교점째 뺌.
· 그래도 못 놓으면 그 곡선은 그 자리(측점 밖 · 마지막 수).
· 합친 꼴 먼저 판에 측점 밖이 생기면 옛 차례(그대로 → 합침) 판도 맞춰 측점 밖이 적은 쪽(406-4).
예상노선 추적 · 새 초기 노선은 안 줌(이탈 한도 그대로).
@@ -78,9 +80,6 @@ RADIUS_STEPS = 8
BACKTRACK_LIMIT = 30
# 한 꺾임 꼴(그대로 · 합친 꼴마다)에서 볼 놓기 수 — 막히면 R · 측점만 바꾸기보다 합치기로
PER_VARIANT = 3
# 직접 잼(406) — 잼을 못 지나는 놓기가 더 넘겨도 되는 기울기(비율) 단계 · 앞 단계로 다 놓으면
# 그 판 · 다 못 놓으면 더 안 넘기는 놓기만 쓰고 못 놓는 곡선은 그 자리(부분 맞춤)
SOFT_SLACKS = (0.0, 0.01, 0.03)
@dataclass
@@ -317,7 +316,6 @@ class _Snapper:
self.excess = excess
self.guard = guard
self.keep = False
self.slack = math.inf # 못 지나는 놓기가 더 넘겨도 되는 기울기(`SOFT_SLACKS`)
self.merged_first = guard is not None # 같은 쪽으로 합친 꼴부터 놓아 봄(406)
def _steeper(self, local: list[Xy]) -> bool:
@@ -362,7 +360,7 @@ class _Snapper:
max(lowest, STATION_M * math.floor(mc / STATION_M)),
max(lowest, STATION_M * math.ceil(mc / STATION_M)),
}
found: list[tuple[tuple[bool, float, float], Xy, _Corner, float, float]] = []
found: list[tuple[tuple, list[Xy], Xy, _Corner, float, float]] = []
for station in stations:
for radius in radii:
solved = _solve(state, plan, k, station, radius)
@@ -376,12 +374,12 @@ class _Snapper:
rank: tuple = (tight, abs(station - mc), abs(radius - corner.radius))
if self.guard is not None:
# 직접 잼(406) — 잣대 가장 나은 자리부터 · 복도 밖은 버림
passed, (out, along, steeper, earth) = self.guard.judge(
passed, (out, along, earth) = self.guard.judge(
local, self.reference.window(local)
)
if out or passed == soft or steeper > self.slack:
if out or passed == soft:
continue
rank = (along, steeper, tight, rank[1], earth, rank[2])
rank = (along, tight, rank[1], earth, rank[2])
found.append((rank, local, new_at, new_corner, new_mc, station))
given = 0
for _, local, new_at, new_corner, new_mc, station in sorted(
@@ -408,7 +406,19 @@ class _Snapper:
"""꺾임점 k 의 갈래 — 측점에 놓기 → 뒤 꺾임과 합쳐 놓기 → (완만하면) 곡선 끔."""
plan, k = state.plan, state.k
before, at, after = plan.points[k - 1 : k + 2]
if not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None:
if self.guard is not None:
# 추천 후보(419) — 꺾임만 없음: 곡선 못 끼우는 자리(나란함)는 교점 지움 · 꺾임만으로
# 들어온 자리도 곡선으로 놓아 봄
if _corner(before, at, after, plan.radii[k] or 1.0) is None:
gone = plan.copy()
del gone.points[k], gone.flags[k], gone.radii[k]
yield replace(state, plan=gone)
return
if not plan.flags[k]:
plan = plan.copy()
plan.flags[k] = True
state = replace(state, plan=plan)
elif not plan.flags[k] or _corner(before, at, after, plan.radii[k] or 1.0) is None:
kink = plan.copy()
kink.flags[k] = False # 꺾임만(나란한 직선 등 곡선 못 끼움 포함)
yield self._kink(state, kink)
@@ -433,10 +443,13 @@ class _Snapper:
for soft in (False, True) if self.guard is not None else (False,):
for trial in variants:
yield from self._placements(state, trial, soft)
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만
# 곡선 끔 — 법이 곡선을 안 둬도 되는 완만한 꺾임 · 원래 선에서 한도 안일 때만(추천 후보는
# 안 함 · 155° 이상도 곡선 · 419)
middle = ((at[0] + after[0]) / 2, (at[1] + after[1]) / 2)
if _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG and not self._far(
[state.end_xy, at, middle]
if (
self.guard is None
and _inner_angle_deg(before, at, after) >= STRAIGHT_INNER_ANGLE_DEG
and not self._far([state.end_xy, at, middle])
):
kink = plan.copy()
kink.flags[k], kink.radii[k] = False, None
@@ -486,7 +499,8 @@ class _Snapper:
at,
((at[0] + b[0]) / 2, (at[1] + b[1]) / 2),
]
if self._far(piece) or self._steeper(piece):
# 꺾임만 남김 — 추천 후보는 안 함(삼각 없음 · 419)
if self.guard is not None or self._far(piece) or self._steeper(piece):
continue
off = plan.copy()
off.flags[small], off.radii[small] = False, None
@@ -584,13 +598,8 @@ def snap_curves_to_stations(
def _snap(snapper: _Snapper, min_radius_m: float) -> PlannedPolyline | None:
"""다 놓는 길(직접 잼이면 `SOFT_SLACKS` 단계) → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
state = None
for slack in SOFT_SLACKS if snapper.guard is not None else (math.inf,):
snapper.slack = slack
state = state or snapper.run(keep=False)
snapper.slack = 0.0
state = state or snapper.run(keep=True)
"""다 놓는 길 → 부분 맞춤 차례로 맞춘 선 · 못 하면 None."""
state = snapper.run(keep=False) or snapper.run(keep=True)
if state is None:
return None
snapped = build_planned_polyline(
@@ -8,16 +8,20 @@
[초기화]에 안 휩쓸리고, 자료 교체(`purge_output_folders`) · 프로젝트 삭제 때 폴더째 지워진다.
키 = 후보 묶음 지문(`_pool_key` — 지형 · 예상노선 · 계류 · 상한 · 최소 R · 이격 · 고름 · 목표
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · `ALGO_VERSION`. 관 지점은 뺀다 — 덮개 수는 꺼낼 때
다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을 올린다(옛 파일은 다른 키라 안 쓰임).
기울기) + 갈래 · 비중 · 종단 반영 · 설계속도 · 표준 횡단면 · `ALGO_VERSION` · 엔진 소스 지문. 관
지점은 뺀다 — 덮개 수는 꺼낼 때 다시 센다. 추천 결과를 바꾸는 코드 고침은 `ALGO_VERSION` 을
올린다 — 빠뜨려도 엔진 소스 지문이 바뀌어 새로 셈. 다른 판 · 지문 파일은 안 읽고 남기며 30일 지난
것만 미리 셈 때 지운다(420-2 · storage 를 두 PC · 여러 워크트리가 같이 씀).
기본값 · 저장된 제약 결과(`default`)는 「최근 벌」을 따로 센다(351-1).
"""
from __future__ import annotations
import asyncio
import functools
import hashlib
import json
import time
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
@@ -33,22 +37,45 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import (
from common_util.common_util_json import atomic_write_json
# 3 = 곡선 MC 일반측점(351-2) · 4 = 다투는 곡선 끔(364-5) · 5 = 후보마다 맞춤(383-1) ·
# 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4)
ALGO_VERSION = 7
# 6 = 같은 쪽 곡선 합치기 · 거리 한도 대신 직접 잼(406) · 7 = 측점 밖이 늘면 옛 차례 맞춤(406-4) ·
# 8 = 횡단 토공 · 표준 횡단면 = 저장 패널값 · 엔진 소스 지문(420)
ALGO_VERSION = 8
# 엔진 소스 지문에 드는 파일(저장소 뿌리 기준) — 추천 결과를 낳는 코드
SOURCE_GLOBS = (
"B05_Profile/B05_Profile_Engine_RouteInitial*.py",
"B05_Profile/B05_Profile_Engine_Sections_Core.py",
"B06_Section/B06_Section_Engine_Design*.py",
"common_util/common_util_route_polyline.py",
)
STORE_DIR = "B05_Profile/route_recommend"
# 종류(갈래 · 지형) 마다 남길 벌 수 — 꺼낼 때 수정시각을 새로 해 최근에 쓴 것이 남음
KEEP_PER_KIND = 2
# 다른 판 · 소스 지문 저장본을 이 날수 지나면 미리 셈 때 지움(420-2)
STALE_DAYS = 30
# 업로드 체인 끝에 미리 셈할 기본 다섯 알약(화면 차례) · 추적은 묶음이 선 뒤라 `similar` 가 채워짐
DEFAULT_MODES = ("gradeline", "earthwork", "gentle", "weighted", "follow")
# 묶음 지문마다 잠금 — 진행 중 같은 묶음(같은 키 포함)은 기다렸다 꺼냄(두 번 셈 막음)
_LOCKS: dict[str, asyncio.Lock] = {}
# 묶음 지문마다 그 묶음을 짤 때 표준 횡단면(`forget_pool_on_standard`)
_POOL_STANDARDS: dict[str, str] = {}
def store_dir(project_root: Path) -> Path:
return Path(project_root) / STORE_DIR
@functools.cache
def source_fingerprint() -> str:
"""엔진 소스 지문 — `SOURCE_GLOBS` 파일 내용(줄끝 LF 로 맞춤 · 두 PC 같은 코드 = 같은 지문)."""
root = Path(__file__).resolve().parents[1]
digest = hashlib.blake2b(digest_size=8)
for path in sorted({p for pattern in SOURCE_GLOBS for p in root.glob(pattern)}):
digest.update(path.name.encode())
digest.update(path.read_bytes().replace(b"\r\n", b"\n"))
return digest.hexdigest()
def request_fingerprint(
expected: list[tuple[float, float]],
terrain: Terrain,
@@ -63,6 +90,7 @@ def request_fingerprint(
target_grade: float | None,
with_profile: bool,
design_speed: int,
standard: dict[str, Any] | None = None,
) -> tuple[str, str]:
"""(저장 키, 묶음 지문) — `generate_initial_route` 와 같은 기본값 풀이로 같은 입력 = 같은 키."""
pool = _pool_key(
@@ -74,7 +102,15 @@ def request_fingerprint(
*resolve_inputs(max_grade, stream_offset_m, grade_smooth_m, target_grade),
).hex()
used = resolve_weights(mode, weights)
tail = (mode, sorted(used.items()), bool(with_profile), int(design_speed), ALGO_VERSION)
tail = (
mode,
sorted(used.items()),
bool(with_profile),
int(design_speed),
json.dumps(standard, sort_keys=True, default=str) if standard else None,
ALGO_VERSION,
source_fingerprint(),
)
key = hashlib.blake2b((pool + repr(tail)).encode(), digest_size=16).hexdigest()
return key, pool
@@ -102,14 +138,22 @@ def is_default(
)
def _current(data: Any) -> bool:
return (
isinstance(data, dict)
and data.get("version") == ALGO_VERSION
and data.get("source") == source_fingerprint()
)
def read_stored(project_root: Path, key: str) -> dict[str, Any] | None:
"""저장본 한 벌 — 없거나 못 읽거나 판이 다르면 None."""
"""저장본 한 벌 — 없거나 못 읽거나 판 · 소스 지문이 다르면 None."""
path = store_dir(project_root) / f"{key}.json"
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
if not isinstance(data, dict) or data.get("version") != ALGO_VERSION:
if not _current(data):
return None
try:
path.touch() # 최근에 쓴 것 = 정리 때 남김
@@ -134,6 +178,7 @@ def write_stored(
path,
{
"version": ALGO_VERSION,
"source": source_fingerprint(),
"key": key,
"pool": pool,
"default": default,
@@ -154,14 +199,15 @@ def _kind(request: Any, default: Any) -> tuple[Any, Any, bool]:
def prune_stored(project_root: Path, request: dict[str, Any], default: bool = False) -> int:
"""같은 종류(갈래 · 지형 · 기본값 여부) 저장본을 최근 `KEEP_PER_KIND` 벌만 남기고 지운다.
기본값 · 저장된 제약 결과(`default`)는 따로 셈 — 사용자 시도가 밀어내지 않음(351-1). 지운 수.
기본값 · 저장된 제약 결과(`default`)는 따로 셈 — 사용자 시도가 밀어내지 않음(351-1). 같은 판 ·
소스 지문 안에서만 셈 — 다른 지문(다른 PC · 워크트리 코드) 파일은 안 건드림(420-2). 지운 수.
"""
kind = _kind(request, default)
same: list[tuple[int, Path]] = []
for path in store_dir(project_root).glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
if _kind(data.get("request"), data.get("default")) == kind:
if _current(data) and _kind(data.get("request"), data.get("default")) == kind:
same.append((path.stat().st_mtime_ns, path))
except (OSError, ValueError, AttributeError):
continue
@@ -171,6 +217,59 @@ def prune_stored(project_root: Path, request: dict[str, Any], default: bool = Fa
return max(0, len(same) - KEEP_PER_KIND)
def _remove_where(project_root: Path, drop: Any) -> int:
"""저장본 중 `drop(data, path)` 가 참인 파일을 지운다(못 읽는 파일 = data None) — 지운 수."""
removed = 0
for path in store_dir(project_root).glob("*.json"):
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
data = None
if drop(data, path):
path.unlink(missing_ok=True)
removed += 1
return removed
def purge_outdated(project_root: Path, now: float | None = None) -> int:
"""다른 판 · 소스 지문 저장본 중 `STALE_DAYS` 지난 것만 지운다(미리 셈 첫머리 · 420-2).
storage 를 두 PC · 여러 워크트리가 같이 써 코드가 다르면 지문이 다름 — 새것은 안 읽고 둠.
지운 수."""
limit = (time.time() if now is None else now) - STALE_DAYS * 86400.0
def stale(data: Any, path: Path) -> bool:
try:
return not _current(data) and path.stat().st_mtime < limit
except OSError:
return False
return _remove_where(project_root, stale)
def drop_pool_results(project_root: Path, pool: str) -> int:
"""[재계산] — 이 후보 묶음(다섯 갈래 · 종단 켬끔)의 저장본을 지우고 메모리 묶음도 버린다.
`pool_lock(pool)` 안에서 부름(미리 셈이 다시 써 버리지 않게) · 지금 판 · 소스 지문 것만
(다른 지문 = 다른 PC · 워크트리 것 · 420-2). 지운 수."""
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
_POOLS.pop(bytes.fromhex(pool), None)
return _remove_where(
project_root, lambda data, _path: _current(data) and data.get("pool") == pool
)
def forget_pool_on_standard(pool: str, standard: dict[str, Any] | None) -> None:
"""메모리 묶음 후보에 붙은 균형 종단은 그때 표준 횡단면으로 셈 — 바뀌면 묶음을 버린다."""
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
mark = json.dumps(standard, sort_keys=True, default=str)
if _POOL_STANDARDS.get(pool, mark) != mark:
_POOLS.pop(bytes.fromhex(pool), None)
_POOL_STANDARDS[pool] = mark
@asynccontextmanager
async def pool_lock(pool: str) -> AsyncIterator[None]:
# ponytail: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로
+2 -1
View File
@@ -248,6 +248,7 @@ async def _carry_cross(
) -> int:
"""측점 열쇠로 옛 횡단 입력을 새 측점에 얹고 전 측점을 새 계획고로 셈 · 버린 수 돌려줌."""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Engine_Design import ROUTE_CARRY_KEYS
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages
from B06_Section.B06_Section_Router_Design import (
@@ -268,7 +269,7 @@ async def _carry_cross(
if isinstance(row.get("design"), dict)
}
inputs, adjusts, dropped = carry_cross_inputs(
old_keys, new_keys, designs, CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS
old_keys, new_keys, designs, (*CARRY_INPUT_KEYS, *ROUTE_CARRY_KEYS), STRUCTURE_ADJUST_KEYS
)
chainages = await asyncio.to_thread(_rowless_station_chainages, project_root, relative, [])
entries = await asyncio.to_thread(
+2 -1
View File
@@ -11,7 +11,8 @@ DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름
- 측점 열쇠 = `common_util_station_key.station_key` 한 벌(sub2 짝) — 일반 `grid:k` ·
관 `pipe:<id>` · 구조물 `structure:<id>` · 시종점 `end:BP` · `end:EP` ·
id 없으면 `at:<누가거리 0.01 m>`.
- 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 · 관 · 구조물 열쇠면
- 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 + `ROUTE_CARRY_KEYS`(사면 쉬프트 ·
소단 깊이 · 측점 폭 · 메모 · 상세 표 · 417) · 관 · 구조물 열쇠면
`STRUCTURE_ADJUST_KEYS` 도(구조물 조정은 구조물 따라) · 열쇠가 사라진 측점 수 =
「노선 밖 고침」.
"""
+1 -19
View File
@@ -225,24 +225,6 @@ def _read_curves(path: Path) -> list[dict]:
return curves if isinstance(curves, list) else []
def _initial_shape(project_root: Path, fallback: list, radius_m: float) -> dict[str, list]:
"""전처리가 만든 **초기 노선**의 노드 · 곡선 — 화면 [초기화]가 재계산 없이 놓는 자리.
수정본이 있어도 초기 폴리라인 파일은 그대로라 항상 같은 노선이 나온다.
"""
path = planned_route_initial_path(project_root)
saved_nodes = _vertices_of(_nodes_path(path))
outline = build_planned_polyline(
[(x, y) for x, y in (saved_nodes or _vertices_of(path) or fallback)],
min_radius_m=radius_m,
simplify=not saved_nodes,
)
return {
"nodes": [node.as_dict() for node in outline.nodes],
"curves": _read_curves(path) or [curve.as_dict() for curve in outline.curves],
}
def _write_planned_polyline(
path: Path,
points: list[tuple[float, float]],
@@ -545,7 +527,6 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"station_snap": await asyncio.to_thread(load_station_snap, project_root),
"station_snap_step": await asyncio.to_thread(load_station_snap_step, project_root),
"current_initial": await asyncio.to_thread(read_initial_choice, project_root),
"initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m),
}
@@ -646,6 +627,7 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
choice.mode,
choice.weights.model_dump() if choice.weights else None,
(choice.criteria.model_dump(exclude_none=True) or None) if choice.criteria else None,
request.balanced_profile, # 「종단 반영」 — 다시 열면 되살림(420)
)
if choice
else await asyncio.to_thread(read_initial_choice, project_root)
+45 -4
View File
@@ -6,6 +6,7 @@
import asyncio
import functools
import json
import logging
import time
from pathlib import Path
@@ -44,17 +45,21 @@ async def compute_initial(
"""같은 입력이면 저장본을 꺼내고 없으면 셈해 더한다(PLAN 327) · 덮개 수는 늘 지금 관으로.
응답 `stored` = 저장본에서 꺼냈나. 진행 중인 같은 묶음은 기다렸다 꺼낸다(두 번 셈 막음).
`recompute` = [재계산] — 묶음 잠금 안에서 이 묶음 저장본 · 메모리 묶음을 버리고 새로 셈(420).
"""
from B05_Profile.B05_Profile_Engine_Grade import legal_grade_limit_pct
from B05_Profile.B05_Profile_Engine_RouteInitial import (
generate_initial_route,
load_terrain_and_streams,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import attach_cross_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import (
cover_shortfall,
project_pipes,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
drop_pool_results,
forget_pool_on_standard,
is_default,
pool_lock,
read_stored,
@@ -115,6 +120,8 @@ async def compute_initial(
"target_grade": None if target_pct is None else target_pct / 100.0,
"design_speed": design_speed or 20,
"with_profile": request.with_profile,
# 균형 종단 · 횡단 토공 표준 횡단면 = 프로젝트 저장 패널값(420)
"standard": await project_standard(project_id),
}
started = time.perf_counter()
try:
@@ -126,6 +133,10 @@ async def compute_initial(
functools.partial(request_fingerprint, route, terrain, streams, **inputs)
)
async with pool_lock(pool_key):
if request.recompute: # 잠금 안 — 밖이면 미리 셈이 지운 것을 다시 써 버림
removed = await asyncio.to_thread(drop_pool_results, project_root, pool_key)
logger.info("추천 [재계산]: project_id=%s 저장본 %d 지움", project_id, removed)
forget_pool_on_standard(pool_key, inputs["standard"])
stored = await asyncio.to_thread(read_stored, project_root, key)
if stored is not None:
result = stored["result"]
@@ -136,12 +147,14 @@ async def compute_initial(
generate_initial_route, route, terrain, streams, pipes=None, **inputs
)
)
# 결과 표 절 · 성 = 횡단(420) — 저장본에 남김
await asyncio.to_thread(attach_cross_earth, result, terrain, inputs["standard"])
await asyncio.to_thread(
write_stored,
project_root,
key,
pool_key,
request.model_dump(),
request.model_dump(exclude={"recompute"}),
result,
is_default(
request.mode,
@@ -180,13 +193,36 @@ async def compute_initial(
}
async def project_standard(project_id: UUID) -> dict[str, Any] | None:
"""프로젝트에 저장된 표준 횡단면 설정값(B06 패널 · 확정 때 종단 options) — 없으면 None.
공식 재계산(`B06_Section_Server_Calc_Prebuild` · `stored_standard_cross_section`)과 같은
자리 · 같은 읽기 — 마지막 종단 정본 행."""
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT data FROM longitudinal_sections WHERE project_id = %s "
"ORDER BY id DESC LIMIT 1",
(str(project_id),),
)
row = await cursor.fetchone()
data = row[0] if row else None
if isinstance(data, str):
data = json.loads(data)
return stored_standard_cross_section({"data": data if isinstance(data, dict) else None})
async def precompute_defaults(project_id: UUID) -> None:
"""체인 끝 예약(PLAN 327-2 · 351-1) — 기본 다섯 알약 + 저장된 제약(initial_choice)의 다섯.
모달은 저장된 제약 · 비중으로 열리므로 그 값으로도 미리 셈한다. 있으면 꺼내기만.
다섯 갈래 모두 종단 켬(420) · 첫머리에 30일 지난 다른 판 · 소스 지문 저장본을 지운다(420-2).
"""
from B05_Profile.B05_Profile_Engine_RouteInitial import DEFAULT_WEIGHTS
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import DEFAULT_MODES, purge_outdated
started = time.perf_counter()
variants: list[tuple[Any, Any]] = [(None, None)]
@@ -196,6 +232,9 @@ async def precompute_defaults(project_id: UUID) -> None:
except LookupError:
paths = None
if paths is not None:
removed = await asyncio.to_thread(purge_outdated, paths[0])
if removed:
logger.info("추천 옛 판 저장본 지움: project_id=%s %d", project_id, removed)
saved = await asyncio.to_thread(read_initial_choice, paths[0])
weights = saved["weights"] if saved["logic"] == "weighted" else None
if saved["criteria"] or (weights and weights != DEFAULT_WEIGHTS):
@@ -209,10 +248,10 @@ async def precompute_defaults(project_id: UUID) -> None:
for mode in modes:
if progress is not None:
progress["mode"] = mode
# 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 추적만 종단 끔(`withProfileOf`)
# 화면과 같은 몸통 — 설계 지형 · 비중은 비중 갈래만 · 「종단 반영」 기본 켬(420)
request = replan.RouteInitialRequest(
mode=mode,
with_profile=mode != "follow",
with_profile=True,
criteria=criteria,
weights=weights if mode == "weighted" else None,
)
@@ -416,6 +455,7 @@ 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))
standard = await project_standard(project_id) # 추천 창과 같은 저장 패널값(420)
try:
# 측점 생성과 같은 1 m 쌍선형 표본(추천 점수의 2 m 비용면 아님)
surface = await asyncio.to_thread(
@@ -433,6 +473,7 @@ async def balanced_profile_of(
max_grade=max_grade,
design_speed=design_speed or 20,
design_curves=design_curves,
standard=standard,
)
)
except (FileNotFoundError, ValueError):
+2
View File
@@ -367,6 +367,8 @@ class RouteInitialRequest(BaseModel):
with_profile: bool = True
# 분석 지형(PLAN 133-1) — None = 지금 설계 지형 · [확인] 때 설계 지형이 됨(133-2).
terrain: Literal["las", "sheet"] | None = None
# [재계산](420) — 이 후보 묶음 저장본 · 메모리 묶음을 지우고 새로 셈. 저장본 request 엔 안 적힘.
recompute: bool = False
class RouteReplanRequest(BaseModel):
+4 -3
View File
@@ -9,7 +9,6 @@
import { confirmRoute, type RouteLatestResponse } from "./B05_Profile_Api_Fetch";
import type { DesignViewSave } from "../A00_Common/design/design_shared";
import { invalidateSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { pageRouteId } from "../A00_Common/design/design_page_route";
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
@@ -74,10 +73,12 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave {
},
},
// 횡단 본문은 늘 횡단 보기 몫 — [저장]이 숨긴 채라도 세움(386-6 · 옛 캐시 패치 길 지움)
// 공유 캐시는 한 벌(`design_shared`)이 저장 끝에 한 번 비움 — 여기서는 새 자료를 다시 읽어 종단 ·
// 평면 오른쪽 판을 그림(416 · 노선 안 바뀐 [저장]도 · B06 다녀오지 않아도 면적 = B06)
done: async () => {
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다.
invalidateSectionDetail(projectId);
ctx.renderLatest(await ctx.loadLatest(true));
const routeId = pageRouteId(projectId);
if (routeId !== null) await ctx.restoreSections(routeId);
},
};
}
@@ -1,13 +1,12 @@
/* =============================================================================
* B05_Profile_UI_Panel_Recommend.ts
* 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」 ·
* [초기화]. 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창 · 초기값 싣기)은 편집기의
* `_RouteEdit_Initial` 이 이 요소를 받아 건다 — 여기는 생김새만.
* 좌측 「노선 추천」 칸 재료(345-4 · 지운 「계획노선」 자리) — [노선 추천] 단추 · 「현재 추천: ○○」.
* 노선 편집 머리에 있던 것을 옮겼다. 동작(추천 창)은 편집기의 `_RouteEdit_Initial` 이 이 요소를
* 받아 건다 — 여기는 생김새만. 칸 [초기화]는 지움(418) — 되돌림은 좌측 아래 [초기화] 하나.
* ========================================================================== */
export interface RecommendPart {
recommendButton: HTMLButtonElement;
resetButton: HTMLButtonElement;
nowLine: HTMLElement;
/** 칸 몸 — 화면 순서대로. */
nodes: HTMLElement[];
@@ -20,10 +19,7 @@ export function createRecommendPart(): RecommendPart {
actions.className = "b05-route__rec-actions";
actions.innerHTML = `
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다 — [적용]은 바로 저장 · 새 초기값">노선 추천</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
title="초기값 노선(전처리 · 추천 [적용] 때 정한 것)으로 되돌립니다 — 다시 계산은 [저장]에서">초기화</button>`;
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다 — [적용]은 바로 저장 · 새 초기값">노선 추천</button>`;
const recommendButton = actions.querySelector<HTMLButtonElement>('[data-act="recommend"]')!;
const resetButton = actions.querySelector<HTMLButtonElement>('[data-act="reset"]')!;
return { recommendButton, resetButton, nowLine, nodes: [nowLine, actions] };
return { recommendButton, nowLine, nodes: [nowLine, actions] };
}
+1 -1
View File
@@ -618,7 +618,7 @@ export async function mountRouteEditor(
({ nodes: nodeInfo, curves: curveInfo } = flat);
picked = -1;
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
initial.ready(snapshotNow(), plan.current_initial ?? null);
history = createRouteEditHistory(snapshotNow());
history = draftLink.watch(history, options.draft, snap.draft);
draftLink.restore(options.draft, restoreSnapshot);
+40 -4
View File
@@ -21,7 +21,14 @@ import {
loadSectionDetail,
saveCachedCrossPatches,
} from "../B06_Section/B06_Section_Section_Store";
import { carryStationDrafts, useStationKeys } from "../B06_Section/B06_Section_Station_Keys";
import {
carryChainageStates,
carryStationDrafts,
useStationKeys,
} from "../B06_Section/B06_Section_Station_Keys";
import { withCarriedEdits, withDraftAdjusts } from "../B06_Section/B06_Section_Section_Adjusts";
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { pipeChainageOf } from "../A00_Common/design/design_pipe_ids";
import { clearRouteLatestCache } from "./B05_Profile_Api_Fetch";
import {
replanRoute,
@@ -112,6 +119,7 @@ export async function saveRouteDraft(
projectId: string,
body: RouteSaveBody,
): Promise<RouteReplanResponse> {
pipesBeforeSave.set(projectId, await savedPipes(projectId));
const saved = await replanRoute(projectId, body);
useDesignStore(projectId, null).set("routeDraft", null);
// 노선이 바뀌면 조회 결과 캐시는 옛 노선 것 — 추천은 새 노선의 출발점이라 다시 적는다.
@@ -134,9 +142,18 @@ export function routeSaveSummary(saved: RouteReplanResponse, outside = 0): strin
return `노선 저장 — ${parts.join(" · ")}`;
}
type SavedPipe = { id?: string; chainage_m: number };
/** 노선 저장 직전 저장 관(id · 옛 누가거리) — 저장 뒤 같은 id 의 새 자리로 조절을 옮김(초안으로 안 따라간 고침 · 417). */
const pipesBeforeSave = new Map<string, SavedPipe[]>();
const savedPipes = (projectId: string): Promise<SavedPipe[]> =>
fetchDetailPipePoints(projectId)
.then((res) => res.pipe_points)
.catch(() => []);
/**
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠) → 새 노선으로 넘기고 그 고침을 저장 — 초안을 통째로
* 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠 · 누가거리 열쇠 조절 · 417) → 새 노선으로 넘기고 그 고침을 저장 —
* 초안을 통째로 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
*/
export async function carryAfterRouteSave(
projectId: string,
@@ -146,7 +163,26 @@ export async function carryAfterRouteSave(
invalidateSectionDetail(projectId, toRouteId);
const detail = await loadSectionDetail(projectId, toRouteId);
useStationKeys(projectId, toRouteId, detail);
const outside = carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true });
// 관 열쇠 → 새 자리 — 세션 id(`pipe-s3`) = 관 장부 · 저장 관 id(`p8017`) = 서버가 옮긴 관
const saved = await savedPipes(projectId);
const before = pipesBeforeSave.get(projectId) ?? [];
pipesBeforeSave.delete(projectId);
const newAt = (id: string | undefined): number | null =>
saved.find((pipe) => id !== undefined && pipe.id === id)?.chainage_m ?? null;
const pipeAt = (key: string): number | null => {
const id = key.startsWith("pipe:") ? key.slice(5) : null;
if (!id) return null;
return id.startsWith("pipe-") ? pipeChainageOf(id, projectId) : newAt(id);
};
// 옛 저장 관 자리에 남은 고침(초안으로 안 따라감) = 같은 관 id 의 새 자리
const movedAt = (chainageM: number): number | null =>
newAt(before.find((pipe) => Math.abs(pipe.chainage_m - chainageM) < 0.01)?.id);
const outside =
carryStationDrafts(projectId, fromRouteId, toRouteId, { quiet: true }) +
carryChainageStates(projectId, fromRouteId, toRouteId, { keyAt: pipeAt, movedAt });
// 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417)
withDraftAdjusts(detail, projectId, toRouteId);
withCarriedEdits(detail, projectId, toRouteId);
await saveCachedCrossPatches(projectId, toRouteId);
return outside;
}
@@ -32,6 +32,7 @@ import type { FordControl } from "../B06_Section/B06_Section_UI_Cross_Ford_Panel
import { createCrossSectionCard } from "../B06_Section/B06_Section_UI_Cross_View";
import type { RockBoundaryControl } from "../B06_Section/B06_Section_UI_Cross_Design";
import { designElevationAt } from "../B06_Section/B06_Section_UI_Section_Common";
import { readRockBoundaryDefault } from "../B06_Section/B06_Section_UI_Standard_Panel";
/** 판 하나가 그릴 측점 — 그때 사본(이전 판으로 내려도 그대로). */
export interface RouteCrossSource {
@@ -40,18 +41,24 @@ export interface RouteCrossSource {
link: CulvertLink | undefined;
/** 보기 반폭(m) — B06 입력칸과 같은 값 · 그릴 때마다 읽음(B06 에서 바꾸면 다음 그림부터). */
halfWidthM: () => number;
/** 암 경계 기본 오프셋(m · config). */
rockDefaultM: number;
}
const none = (): void => undefined;
/** 암 경계선 = 정본 오프셋(없으면 기본 −0.5 · 0 을 넘지 않음 — B06 저장소 규칙) · 조정 없음. */
const rockBoundary: RockBoundaryControl = {
/** 암 경계선 = 정본 오프셋(없으면 config 기본 · 0 을 넘지 않음 — B06 저장소 규칙) · 조정 없음. */
const rockBoundary = (defaultOffsetM: number): RockBoundaryControl => ({
stepM: 0.1,
defaultOffsetM: -0.5,
offsetFor: (section) => Math.min(section.design?.rock_boundary_offset_m ?? -0.5, 0),
defaultOffsetM,
offsetFor: (section) => Math.min(section.design?.rock_boundary_offset_m ?? defaultOffsetM, 0),
adjust: none,
reset: none,
};
});
/** config 암 경계 기본(`STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M` · 종단 보기가 context 로 기억 · 416) —
* context 전이면 서버 Schema 기본과 같은 값. */
const CONTEXT_PENDING_ROCK_M = -0.5;
/** 그 측점 단면 사본 — 상세가 없거나(초안 중 새 상세 전) 측점이 없으면 null. */
export function routeCrossSource(
@@ -60,8 +67,10 @@ export function routeCrossSource(
chainageM: number,
pane: string,
): RouteCrossSource | null {
const routeId = pageRouteId(projectId);
const detail =
pipelineDetail(projectId) ?? (pipe?.input("route") ? null : peekSectionDetail(projectId));
pipelineDetail(projectId) ??
(pipe?.input("route") ? null : peekSectionDetail(projectId, routeId));
const sections = detail?.cross_sections ?? [];
const found = nearestWithin(sections, chainageM, (entry) => entry.chainage_m);
if (!detail || !found) return null;
@@ -78,6 +87,7 @@ export function routeCrossSource(
? undefined
: culvertLinkFor(found, sections, (at) => designElevationAt(profiles, at) ?? null),
halfWidthM: () => displayHalfWidth(projectId, pageRouteId(projectId), sections),
rockDefaultM: readRockBoundaryDefault(projectId) ?? CONTEXT_PENDING_ROCK_M,
};
}
@@ -122,7 +132,7 @@ export function routeCrossCard(
plotHeightPx,
source.designZ,
undefined,
rockBoundary,
rockBoundary(source.rockDefaultM),
null,
undefined,
undefined,
+28 -57
View File
@@ -1,13 +1,13 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Initial.ts
* 계획노선 편집의 **노선 추천 이음**(계획서 20 · 23-2) — 좌측 「노선 추천」 칸(345-4) [노선 추천] 단추 →
* 모달(`_Recommend`) · [초기화] · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는
* 모달(`_Recommend`) · 「현재 추천: ○○」 · [저장]에 실을 지금 추천. 칸 요소는
* 밖(`_Panel_Recommend`)에서 받는다.
*
* 모달의 알약이 서버에 갈래를 묻고(`/route/initial`) 미리 보기만 한다. [적용]을 눌러야 받은 노드를
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. [초기화]는 서버를 다시
* 셈하지 않고 **전처리 때 만든 초기값 노선**(예상노선 추적)을 놓는다 — 그 노드는 `GET /route/plan` 의
* `initial_route` 한 곳에서 읽는다. 손으로 고친 노선의 다시 계산은 페이지 [저장]에서(`_Apply` · 346-3) —
* **편집값에 싣고**(되돌리기 한 걸음) 캐시(`routeedit-initial`)에 적는다. 칸 [초기화]는 없음(418) —
* 되돌림은 좌측 아래 [초기화](`design_reset` · 마지막 [적용] 노선). 손으로 고친 노선의 다시 계산은
* 페이지 [저장]에서(`_Apply` · 346-3) —
* 추천 [적용]만 그 자리에서 서버 노선을 `as_initial` 로 저장한다(초기값 = 추천 노선 · 사용자 확정 · 잠금 막 ·
* 끝나면 페이지 다시 세움). 비교값 · 기준값은 서버가 준 것을 보이기만 한다.
*
@@ -17,7 +17,6 @@
* 요청했을 때 서버가 답으로 준 값(진짜 기본값)을 기억해 둔 것이다.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { fetchRoutePlan, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
import {
fetchPrecomputeProgress,
@@ -29,14 +28,13 @@ import {
import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History";
import {
choiceLabel,
choiceWithProfile,
DEFAULT_WEIGHTS,
NO_WEIGHT_LOCKS,
initialRouteSnapshot,
normalizeRouteInitial,
readInitialChoice,
sameBase,
serverBase,
withProfileOf,
writeInitialChoice,
type CriteriaKey,
type InitialBase,
@@ -49,7 +47,6 @@ export interface InitialPanelParams {
/** 단추 · 글 요소 — 좌측 「노선 추천」 칸 것(345-4). */
controls: {
recommendButton: HTMLButtonElement;
resetButton: HTMLButtonElement;
nowLine: HTMLElement;
};
projectId: string;
@@ -62,16 +59,17 @@ export interface InitialPanelParams {
}
export interface InitialPanel {
/** 노선을 읽었다 — 창을 연 편집값 · 「고친 노선인가」 · 서버가 적어 둔 현재 초기값(`current_initial`). */
ready: (opening: RouteEditSnapshot, edited: boolean, server?: unknown) => void;
/** 노선을 읽었다 — 창을 연 편집값 · 서버가 적어 둔 현재 초기값(`current_initial`). */
ready: (opening: RouteEditSnapshot, server?: unknown) => void;
/** [저장]에 실어 보낼 지금 추천. */
current: () => {
mode: RouteInitialLogic;
weights: RouteInitialWeights | null;
criteria: RouteInitialCriteria | null;
with_profile: boolean;
};
/** 지금 노선이 종단을 반영하는가 — 지금 추천 갈래가 정한다(예상노선 추적만 아님 · PLAN 77-2).
* 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 갈래대로). */
/** 지금 노선이 종단을 반영하는가 — 지금 추천의 「종단 반영」 값(421 · 옛 기록이면 갈래로).
* 종단 띠 · [확인]이 함께 본다(추천을 손으로 고쳤어도 그 값대로). */
withProfile: () => boolean;
/** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */
tag: () => InitialChoice | null;
@@ -87,7 +85,7 @@ const copy = (snapshot: RouteEditSnapshot): RouteEditSnapshot =>
export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const { projectId } = params;
const { recommendButton, resetButton, nowLine } = params.controls;
const { recommendButton, nowLine } = params.controls;
/** 창을 열 때 캐시에 있던 추천 — [취소]가 돌아갈 자리. */
let openingChoice = readInitialChoice(projectId);
@@ -97,14 +95,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
/** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — placeholder 로 보인다(진짜 기본값). */
const defaults: Partial<Record<CriteriaKey, number>> = {};
let opening: RouteEditSnapshot | null = null;
let edited = false;
let busy = false;
/** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */
const weightMemory = {
weights: null as RouteInitialWeights | null,
locks: { ...NO_WEIGHT_LOCKS },
};
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */
/** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕). 창이 사는 동안 안 바뀐다. */
let planRead: Promise<RoutePlanResponse> | null = null;
function loadPlan(): Promise<RoutePlanResponse> {
@@ -119,7 +116,6 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
const now = choice ?? base;
nowLine.textContent = `현재 추천: ${choiceLabel(now.logic, now.weights)}`;
recommendButton.disabled = busy || !opening;
resetButton.disabled = busy || !opening;
}
/** 모달 알약 하나 — 서버에 갈래를 묻고 편집값 한 벌로 펴 돌려준다. 싣는 것은 [적용] 뒤. */
@@ -128,14 +124,16 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
): Promise<InitialChoice> {
const response = await fetchRouteInitial(projectId, {
mode: logic,
...(weights ? { weights } : {}),
...(criteria ? { criteria } : {}),
...(withProfile ? {} : { with_profile: false }),
with_profile: withProfile,
...(recompute ? { recompute: true } : {}),
});
return normalizeRouteInitial(response, logic, weights, criteria);
return normalizeRouteInitial(response, logic, weights, criteria, withProfile);
}
recommendButton.addEventListener("click", () => {
@@ -153,6 +151,8 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) },
weightMemory,
logic: (choice ?? base).logic,
// 적용한 추천의 값 · 없으면 켬(421 — 옛 기록의 예상노선 추적도 켬에서 시작)
withProfile: choice?.withProfile ?? base.withProfile ?? true,
planMinRadiusM: params.planMinRadiusM(),
expected: (plan?.expected ?? []).map(([x, y]) => [x, y]),
current: params.planned().map(([x, y]) => [x, y]),
@@ -174,54 +174,20 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
mode: picked.logic,
weights: picked.weights,
criteria: picked.criteriaOverrides,
with_profile: choiceWithProfile(picked), // initial_choice 에 남김(420)
},
withProfileOf(picked.logic),
choiceWithProfile(picked),
);
},
});
})();
});
// [초기화] — 재계산 없이 전처리 때 만든 초기값 노선(예상노선 추적)을 다시 놓는다.
resetButton.addEventListener("click", () => {
void (async () => {
if (busy || !opening) return;
busy = true;
render();
try {
const plan = await loadPlan().catch(() => null);
// 서버가 초기값 노선을 못 주면, 고친 적 없는 노선일 때만 창을 연 노선이 곧 초기값이다.
const snapshot = initialRouteSnapshot(plan?.initial_route) ?? (edited ? null : opening);
if (!snapshot) {
showToast("전처리 초기값 노선을 읽지 못했습니다.", "error");
return;
}
if (!resetButton.isConnected) return;
choice = {
logic: "follow",
weights: null,
snapshot: copy(snapshot),
metrics: null,
criteria: null,
criteriaOverrides: null,
};
writeInitialChoice(projectId, choice);
params.apply(
copy(snapshot),
"초기값(예상노선 추적)으로 되돌렸습니다 — 다시 계산은 [저장]에서.",
);
} finally {
busy = false;
if (resetButton.isConnected) render();
}
})();
});
render();
return {
ready(snapshot, wasEdited, server) {
ready(snapshot, server) {
opening = copy(snapshot);
edited = wasEdited;
// 「현재 추천」은 서버가 [확인] 때 적어 둔 것이 정본이다 — 캐시가 다르면(확인 없이 닫음)
// 캐시를 버린다.
if (server) {
@@ -235,9 +201,14 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
current: () => {
const now = choice ?? base;
const criteria = choice ? choice.criteriaOverrides : base.criteria;
return { mode: now.logic, weights: now.weights, criteria };
return {
mode: now.logic,
weights: now.weights,
criteria,
with_profile: choiceWithProfile(now),
};
},
withProfile: () => withProfileOf((choice ?? base).logic),
withProfile: () => choiceWithProfile(choice ?? base),
tag: () => choice,
restoreTag(tag) {
if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
@@ -27,9 +27,15 @@ export const LOGIC_LABEL: Record<RouteInitialLogic, string> = {
weighted: "비중 계산",
};
/** 종단을 늘 반영하는 갈래 — 예상노선 추적만 종단 없음(전처리 계획선 = 지반고 · PLAN 77-2). */
/** 옛 꼴(종단 반영 값 없음)의 갈래별 종단 — 예상노선 추적만 종단 없음(PLAN 77-2). */
export const withProfileOf = (logic: RouteInitialLogic): boolean => logic !== "follow";
/** 이 추천의 종단 반영 — 추천 창 「종단 반영」 단추 값(421) · 옛 캐시 · 옛 서버 답이면 갈래로. */
export const choiceWithProfile = (choice: {
logic: RouteInitialLogic;
withProfile?: boolean | null;
}): boolean => choice.withProfile ?? withProfileOf(choice.logic);
/** 비중 기본값(%) — 계획서 20 사용자 확정 50/25/25. */
export const DEFAULT_WEIGHTS: RouteInitialWeights = { expected: 50, earthwork: 25, grade: 25 };
export const WEIGHT_KEYS = ["expected", "earthwork", "grade"] as const;
@@ -154,7 +160,7 @@ export interface InitialMetrics {
violations: InitialViolation[] | null;
}
/** 제약 못 지킨 구간 하나 — 종단기울기 · 곡선반지름 · 계류 이격. */
/** 서버 목록 한 줄 — 곡선반지름 · 계류 이격 · 측점 밖(못 지킴) · 원지반 기울기(`grade` · 참고만 · 419). */
export interface InitialViolation {
kind: string;
station: string;
@@ -197,8 +203,11 @@ export interface InitialChoice {
line?: Vertex[] | null;
/** MC 가 일반측점 밖인 곡선 수(계획서 351-2) — 서버 `off_station_curves`. 옛 답이면 null. */
offStationCurves?: number | null;
/** 측점 맞춤 끔 = 맞추기 전 선(계획서 351-2) — 모달에만 · 캐시에는 안 적는다. 같으면 null. */
unsnapped?: InitialChoice | null;
/** 추천 창 「종단 반영」 값(421) — [적용] 균형 종단 · 다시 열 때 단추. 옛 캐시면 없음. */
withProfile?: boolean;
/** 시종점 높이 차 ÷ 연장(%) · 상한을 넘나(종단으로 못 풂) — 서버 `ends_grade_pct` · `ends_over_limit`(419). */
endsGradePct?: number | null;
endsOverLimit?: boolean;
}
/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */
@@ -233,13 +242,6 @@ function snapshotOf(nodes: unknown[], curves: unknown): RouteEditSnapshot {
};
}
/** 서버 `/route/plan` 의 `initial_route`(전처리 초기값 노선)를 편집값으로 편다. 없거나 모자라면 null. */
export function initialRouteSnapshot(initialRoute: unknown): RouteEditSnapshot | null {
const body = (initialRoute ?? {}) as Record<string, unknown>;
if (!Array.isArray(body.nodes) || body.nodes.length < 2) return null;
return snapshotOf(body.nodes, body.curves);
}
/** 미리 보기 지도 한 겹 — 점 목록과 종류. */
export interface MapLayer {
kind: "expected" | "current" | "preview";
@@ -299,6 +301,7 @@ export function normalizeRouteInitial(
logic: RouteInitialLogic,
weights: RouteInitialWeights | null,
criteriaOverrides: RouteInitialCriteria | null = null,
withProfile?: boolean,
): InitialChoice {
// 모양은 계획서 20 「API」 줄 — 노드 · 곡선은 `/route/plan` 과 같은 꼴이다.
const body = (response ?? {}) as Record<string, unknown>;
@@ -328,7 +331,12 @@ export function normalizeRouteInitial(
gradeDevPct: pick(m, "grade_dev_pct"),
costIndex: pick(m, "cost_index"),
surplusM3: pick(m, "surplus_m3"),
overLimitCount: pick(over, "total"),
// 「못 지킨 구간」과 같은 목록에서 셈(원지반 기울기 뺌 · 421-2) — 목록 없는 옛 답만 서버 수
overLimitCount: Array.isArray(m.violations)
? (m.violations as Array<Record<string, unknown>>).filter(
(item) => item.kind !== "grade",
).length
: pick(over, "total"),
offsetM: pick(m, "offset_avg_m"),
violations: Array.isArray(m.violations)
? (m.violations as Array<Record<string, unknown>>).map((item) => ({
@@ -346,15 +354,10 @@ export function normalizeRouteInitial(
bandWidthM: num(body.band_width_m),
stored: body.stored === true,
offStationCurves: num(body.off_station_curves),
unsnapped:
body.unsnapped && typeof body.unsnapped === "object"
? normalizeRouteInitial(
{ ...body, ...(body.unsnapped as Record<string, unknown>), unsnapped: null },
logic,
weights,
criteriaOverrides,
)
: null,
endsGradePct: num(body.ends_grade_pct),
endsOverLimit: body.ends_over_limit === true,
// 서버가 셈에 쓴 값이 먼저(420) · 옛 서버면 보낸 값
withProfile: typeof body.with_profile === "boolean" ? body.with_profile : withProfile,
balance: parseBalance(m.profile),
line: Array.isArray(body.planned)
? (body.planned as unknown[][]).map((xy): Vertex => [Number(xy[0]), Number(xy[1])])
@@ -379,6 +382,8 @@ export interface InitialBase {
weights: RouteInitialWeights | null;
/** 사용자가 그때 화면 칸에 넣었던 제약 겹침만(비운 칸은 안 담김). */
criteria: RouteInitialCriteria | null;
/** [적용] 때 「종단 반영」(421 · 서버 `with_profile`) — 옛 기록이면 없음. */
withProfile?: boolean;
}
/** 겹침 값 원시 객체를 편다 — 유한수인 칸만 담는다. 빈 결과는 null. */
@@ -406,7 +411,8 @@ export function serverBase(server: unknown): InitialBase {
? (body.logic as RouteInitialLogic)
: "follow";
const criteria = parseCriteriaOverrides(body.criteria);
if (logic !== "weighted") return { logic, weights: null, criteria };
const withProfile = typeof body.with_profile === "boolean" ? body.with_profile : undefined;
if (logic !== "weighted") return { logic, weights: null, criteria, withProfile };
const raw = (body.weights ?? {}) as Record<string, unknown>;
const weights = {
expected: Math.round(Number(raw.expected)),
@@ -414,7 +420,7 @@ export function serverBase(server: unknown): InitialBase {
grade: Math.round(Number(raw.grade)),
};
const valid = WEIGHT_KEYS.every((key) => Number.isFinite(weights[key]));
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria };
return { logic, weights: valid ? weights : { ...DEFAULT_WEIGHTS }, criteria, withProfile };
}
/** 캐시의 초기값이 서버 것과 같은 갈래 · 같은 비중 · 같은 제약 겹침인가. */
@@ -426,6 +432,9 @@ export function sameBase(choice: InitialChoice, base: InitialBase): boolean {
) {
return false;
}
// 서버가 종단 반영을 적어 둔 때만 견줌(옛 기록은 갈래 · 비중 · 제약만)
if (base.withProfile !== undefined && choiceWithProfile(choice) !== base.withProfile)
return false;
return criteriaEqual(choice.criteriaOverrides, base.criteria);
}
@@ -556,9 +565,17 @@ export function violationText(item: InitialViolation): string {
return `${item.station} ${value}${head ? `(${head})` : ""}`;
}
/** 원지반 기울기 — 참고만(419 · 계획선 높이로 푸는 값) · 「못 지킨 구간」 · 「기준 초과」에 안 듦. */
const isGroundGrade = (item: InitialViolation): boolean => item.kind === "grade";
/** 제약 못 지킨 구간(R · 계류 · 측점) — 서버 목록에서 원지반 기울기를 뺀 것. 옛 답(목록 없음)이면 null. */
export function brokenItems(metrics: InitialMetrics | null): InitialViolation[] | null {
return metrics?.violations?.filter((item) => !isGroundGrade(item)) ?? null;
}
/** 제약 못 지킨 구간 한 줄 — 없으면 「모두 지킴」. 서버가 목록을 안 줬으면 빈 글. */
export function violationsLine(metrics: InitialMetrics | null): string {
const items = metrics?.violations;
const items = brokenItems(metrics);
if (!items) return "";
if (!items.length) return "제약 못 지킨 구간 없음 — 모두 지킴";
const shown = items.slice(0, VIOLATION_SHOWN).map(violationText).join(" · ");
@@ -566,6 +583,25 @@ export function violationsLine(metrics: InitialMetrics | null): string {
return `제약 못 지킨 구간 ${items.length}곳: ${shown}${more}`;
}
/** 원지반 기울기(참고) 한 줄 — 최대 · 상한 넘는 곳 수. 서버가 목록을 안 줬으면 빈 글. */
export function groundGradeLine(metrics: InitialMetrics | null): string {
const items = metrics?.violations;
if (!items) return "";
const over = items.filter(isGroundGrade).length;
return `원지반 기울기(참고) 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 상한 넘는 곳 ${over}`;
}
/** 원지반 기울기 넘는 곳 전부 — 참고 줄 풍선. */
export function groundGradeTitle(metrics: InitialMetrics | null): string {
return (metrics?.violations ?? []).filter(isGroundGrade).map(violationText).join("\n");
}
/** 시종점 높이 차가 상한을 넘을 때만 — 종단으로 못 맞춤(노선을 늘려야 함 · 419). 아니면 빈 글. */
export function endsLine(choice: InitialChoice | null): string {
if (!choice?.endsOverLimit) return "";
return `시종점 높이 차가 상한을 넘음(평균 ${fixed(choice.endsGradePct ?? null, 1, "%")}) — 종단으로 못 맞춤 · 노선을 늘려야 함`;
}
/** 상한을 풀었을 때만 — 「지형상 14% 로 못 이어져 상한을 23.5% 로 풂」. 아니면 빈 글. */
export function relaxedLine(choice: InitialChoice | null): string {
const bound = choice?.terrainBoundPct;
@@ -583,10 +619,10 @@ export function readInitialChoice(projectId: string): InitialChoice | null {
}
export function writeInitialChoice(projectId: string, choice: InitialChoice | null): void {
// 균형 종단 · 서버 선 · 맞추기 전 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다.
// 균형 종단 · 서버 선은 편집 창 안에서만(계획서 63-4 · 351-2) — 캐시에 안 싣는다.
writeState(
"routeedit-initial",
choice && { ...choice, balance: undefined, line: undefined, unsnapped: undefined },
choice && { ...choice, balance: undefined, line: undefined },
projectId,
);
}
@@ -3,7 +3,8 @@
* 계획노선 편집 창의 [노선 추천] 모달(계획서 23-2 · 77) — 알약 다섯 한 줄(예상노선 추적 · 영선 ·
* 토공 최소 · 기울기 최소 · 비중 계산) · 지도 안 옵션 겹창(제약 · 목표 기울기 · 비중) · 비교값 ·
* 못 지킨 구간 · 오른쪽 위 [적용] · [닫기].
* 종단은 갈래가 정한다 — 예상노선 추적만 종단 없음, 나머지는 늘 균형 종단(`withProfileOf`).
* 종단은 「종단 반영」 단추가 정한다(421 · 기본 켬 · 다섯 갈래) — 누르면 바로 다시 셈 · [적용] 균형 종단도 이 값.
* [재계산]은 늘 켜짐(입력 오류 · 셈 중 빼고) — 서버 저장본을 지우고 새로 셈(`recompute`).
*
* 알약을 누르면 서버가 그 갈래를 셈해 주고(`request`) 지도에 미리 보기로 그린다. 노선 편집의 노선은
* 건드리지 않는다 — [적용]을 누르면 `onApply` 로 바로 서버 저장(새 초기값 · 346-3), [닫기]는
@@ -38,9 +39,12 @@ import {
SIMILAR_HINT,
similarLine,
violationsLine,
brokenItems,
endsLine,
groundGradeLine,
groundGradeTitle,
violationText,
WEIGHT_KEYS,
withProfileOf,
type CriteriaKey,
type InitialChoice,
type InitialCriteria,
@@ -67,7 +71,7 @@ type Vertex = [number, number];
const PILLS = ["follow", "gradeline", "earthwork", "gentle", "weighted"] as const;
const PILL_TITLE: Record<(typeof PILLS)[number], string> = {
follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 · 종단 없음(계획고 = 지반고)",
follow: "전처리가 만든 기본 노선 — 예상노선을 따라감 + 종단(종단 반영 끄면 계획고 = 지반고)",
gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) + 종단",
earthwork: "절토 · 성토가 가장 적은 노선 + 종단",
gentle: "종단기울기가 가장 낮은 노선 + 종단",
@@ -121,6 +125,8 @@ export interface RecommendParams {
weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks };
/** 처음 고른 갈래 — 적용한 추천이 있으면 그 갈래(PLAN 77-8). */
logic: RouteInitialLogic;
/** 「종단 반영」 처음 값 — 적용한 추천의 값 · 없으면 켬(421). */
withProfile: boolean;
planMinRadiusM: number;
/** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */
expected: Vertex[];
@@ -131,6 +137,7 @@ export interface RecommendParams {
weights: RouteInitialWeights | null,
criteria: RouteInitialCriteria | null,
withProfile: boolean,
recompute: boolean,
) => Promise<InitialChoice>;
/** [적용] — 미리 본 노선을 바로 저장한다(새 초기값). */
onApply: (choice: InitialChoice) => void;
@@ -148,10 +155,8 @@ export function openRecommendModal(params: RecommendParams): void {
/** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */
const invalid = new Set<WeightKey>();
let preview: InitialChoice | null = null;
/** 측점 맞춤(계획서 351-2) — 켬 = 곡선 MC 를 일반측점에 맞춘 선 · 끔 = 맞추기 전 선. */
let snapOn = true;
const shownChoice = (): InitialChoice | null =>
preview && !snapOn ? (preview.unsnapped ?? preview) : preview;
/** 종단 반영(421) — 켬 = 후보마다 균형 종단 · 끔 = 계획고 = 지반고. */
let profileOn = params.withProfile;
let busy = false;
/** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */
let dirty = false;
@@ -218,6 +223,8 @@ export function openRecommendModal(params: RecommendParams): void {
<div class="b05-routerec__cands" data-line="candidates" hidden></div>
</div>
<div data-line="violations" hidden></div>
<div data-line="ground" hidden></div>
<div class="is-warn" data-line="ends" hidden></div>
<div class="is-warn" data-line="relaxed" hidden></div>
<div class="is-warn" data-line="stations" hidden></div>
<div data-line="similar" hidden></div>
@@ -307,13 +314,14 @@ export function openRecommendModal(params: RecommendParams): void {
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
const snapToggle = toggleButton("측점 맞춤", snapOn, (active) => {
snapOn = active;
render();
const profileToggle = toggleButton("종단 반영", profileOn, (active) => {
profileOn = active;
void run(); // 누르면 바로 — 저장본 있으면 그것
});
snapToggle.dataset.act = "snap";
snapToggle.title = "곡선 가운데(MC)를 일반측점(20 m) 위에 — 끄면 맞추기 전 선";
rerun.before(snapToggle);
profileToggle.dataset.act = "profile";
profileToggle.title = "켬 = 후보마다 측점 횡단으로 균형 종단 · 끔 = 계획고 = 지반고";
rerun.title = "지금 칸 값으로 서버 저장본을 지우고 새로 셈";
rerun.before(profileToggle);
const stateEl = q<HTMLElement>("[data-state]");
const line = (name: string): HTMLElement => q<HTMLElement>(`[data-line="${name}"]`);
const criteriaInputs = body.querySelectorAll<HTMLInputElement>("[data-criteria]");
@@ -345,9 +353,9 @@ export function openRecommendModal(params: RecommendParams): void {
{ kind: "expected", points: params.expected },
{ kind: "current", points: params.current },
];
// 서버가 짠 원호 그대로(351-2 · 측점 맞춤 켬끔) — 옛 답이면 꺾임점
const view = shownChoice();
if (view) layers.push({ kind: "preview", points: view.line ?? view.snapshot.planned });
// 서버가 짠 원호 그대로(351-2) — 옛 답이면 꺾임점
if (preview)
layers.push({ kind: "preview", points: preview.line ?? preview.snapshot.planned });
map?.setLayers(layers);
}
@@ -397,9 +405,12 @@ export function openRecommendModal(params: RecommendParams): void {
input.disabled = busy;
});
q<HTMLElement>("[data-target-row]").hidden = logic !== "gradeline";
// 측점 맞춤 오른쪽 · 늘 보임 — 바꾼 것이 있을 때만 켜짐(383-3)
rerun.disabled = busy || !dirty || invalid.size > 0;
const view = shownChoice();
// 종단 반영 오른쪽 · 늘 켜짐(입력 오류 · 셈 중 빼고 · 421)
rerun.disabled = busy || invalid.size > 0;
profileToggle.disabled = busy;
profileToggle.setAttribute("aria-pressed", String(profileOn));
profileToggle.classList.toggle("is-active", profileOn);
const view = preview;
applyButton.disabled = busy || dirty || !view || invalid.size > 0;
const shown = metricsLines(view?.metrics ?? null);
line("metrics").textContent = view
@@ -418,10 +429,18 @@ export function openRecommendModal(params: RecommendParams): void {
line("candidates").hidden = seen.size === 0;
const violations = violationsLine(view?.metrics ?? null);
line("violations").textContent = violations;
line("violations").title = (view?.metrics?.violations ?? [])
line("violations").title = (brokenItems(view?.metrics ?? null) ?? [])
.map((item) => `${violationText(item)} — ${item.reason}`)
.join("\n");
line("violations").hidden = !violations;
// 원지반 기울기 = 참고 줄 따로 · 시종점 높이 차 알림(421-2 · 엔진 419)
const ground = groundGradeLine(view?.metrics ?? null);
line("ground").textContent = ground;
line("ground").title = groundGradeTitle(view?.metrics ?? null);
line("ground").hidden = !ground;
const ends = endsLine(view);
line("ends").textContent = ends;
line("ends").hidden = !ends;
const relaxed = relaxedLine(view);
line("relaxed").textContent = relaxed;
line("relaxed").hidden = !relaxed;
@@ -442,7 +461,8 @@ export function openRecommendModal(params: RecommendParams): void {
drawMap();
}
async function run(): Promise<void> {
/** `recompute` = [재계산] — 이 입력의 서버 저장본을 지우고 새로 셈. */
async function run(recompute = false): Promise<void> {
if (logic === "weighted" && invalid.size > 0) {
showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error");
return;
@@ -450,13 +470,13 @@ export function openRecommendModal(params: RecommendParams): void {
const mine = ++seq;
const sentWeights = logic === "weighted" ? { ...weights } : null;
const criteria = currentCriteria();
const withProfile = withProfileOf(logic);
const withProfile = profileOn;
busy = true;
dirty = false;
preview = null;
// 후보마다 균형 종단을 셈해 처음 한 번은 수십 초 — 지난 초를 보인다(계획서 63-4).
const started = Date.now();
const label = choiceLabel(logic, sentWeights);
const label = `${choiceLabel(logic, sentWeights)}${withProfile ? "" : " · 종단 끔"}`;
/** 서버가 뒤에서 미리 셈 중이면 그 진행(364-4) — 2초마다 읽음. */
let behind = "";
const tick = (): void => {
@@ -486,6 +506,7 @@ export function openRecommendModal(params: RecommendParams): void {
sentWeights,
Object.keys(criteria).length ? criteria : null,
withProfile,
recompute,
);
if (closed || mine !== seq) return;
// 비운 칸을 물었을 때 서버가 준 값 — 그 칸의 진짜 기본값으로 기억해 둔다.
@@ -497,7 +518,7 @@ export function openRecommendModal(params: RecommendParams): void {
preview = choice;
if (choice.balance) seen.set(label, choice.balance);
else seen.delete(label); // 종단 없는 갈래(예상노선 추적)
stateEl.textContent = `미리 보기: ${choiceLabel(logic, sentWeights)}${
stateEl.textContent = `미리 보기: ${label}${
choice.stored ? " (저장된 결과)" : ""
} — [적용]하면 바로 저장 · 새 초기값이 됩니다.`;
} catch (error) {
@@ -574,11 +595,10 @@ export function openRecommendModal(params: RecommendParams): void {
render();
});
});
rerun.addEventListener("click", () => void run());
rerun.addEventListener("click", () => void run(true));
applyButton.addEventListener("click", () => {
const view = shownChoice(); // 보이는 쪽(측점 맞춤 켬끔)
if (!view) return;
params.onApply(view);
if (!preview) return;
params.onApply(preview);
closed = true;
close();
});
@@ -30,12 +30,13 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
how: [
"새로 길을 찾지 않음 — 예상노선 꺾임점에 맞춘 곡선반지름(최소 이상 · 예상노선에 가장 가까운 값)으로 곡선만 넣음",
"전처리 초기 계획노선과 같은 규칙 — 「측점 맞춤」을 끄면 같은 선",
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
"전처리 초기 계획노선과 같은 규칙 · 곡선 가운데(MC)는 일반측점(20m) 위로 맞춤",
"계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음 · 원지반 기울기는 참고 줄",
],
when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴",
caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인",
profile: "없음 — 전처리 계획선처럼 계획고 = 지반고",
caution:
"계류 이격 · 기울기를 보장하지 않음 — 못 지킨 구간 · 원지반 기울기(참고) · 시종점 알림을 꼭 확인",
profile: "켬 = 균형 종단 · 끔 = 전처리 계획선처럼 계획고 = 지반고",
},
gradeline: {
summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선",
@@ -83,11 +84,11 @@ const LOGIC_HELP: Record<ShownLogic, HelpEntry> = {
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
{
name: "종단 반영",
text: "예상노선 추적을 뺀 네 갈래는 늘 반영. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [적용] 뒤 본 설계 값과 차이가 남. [적용]하면 이 종단이 그대로 첫 계획선이 되고 노선과 함께 바로 저장 · 새 초기값이 됨",
text: "단추로 켬 · 끔(기본 켬 · 다섯 갈래 모두) — 누르면 바로 다시 셈(저장본 있으면 그것). 끄면 계획고 = 지반고로 흙 양만 셈. 원지반선과 계획선 사이 넓이를 적분한 값이 아니라, 측점(20m)마다 횡단을 세워 절토 · 성토 면적을 보고 그 측점 계획고를 정한 종단 — 그래서 갈래 · 노선에 따라 값의 편차가 큼. 구조물을 뺀 빠른 셈이라 [적용] 뒤 본 설계 값과 차이가 남. [적용]하면 이 종단이 그대로 첫 계획선이 되고 노선과 함께 바로 저장 · 새 초기값이 됨",
},
{
name: "종단기울기 상한(%)",
text: "이보다 가파른 구간을 만들지 않음. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%). 곡선을 넣은 뒤 넘으면 상한을 조금씩 낮춰 다시 찾음",
text: "지반이 이보다 가파른 쪽으로 곧장 오르는 길은 찾지 않음. 측점(20m) 사이 원지반 기울기는 참고만 — 계획선 높이(종단)로 푸는 값이라 「못 지킨 구간」에 안 듦. 시종점 높이 차가 상한을 넘으면 종단으로도 못 맞춰 알림. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%)",
},
{
name: "최소 반지름(m)",
@@ -102,8 +103,12 @@ const CRITERIA_HELP: Array<{ name: string; text: string }> = [
text: "기울기를 잴 때 지형 잔굴곡을 고르는 폭. 클수록 매끈한 지반으로 봄. 비우면 5m",
},
{
name: "측점 맞춤",
text: "켬(기본) = 곡선 가운데(MC)를 일반측점(20m) 위로 옮긴 선 — 교각점을 밀고 반지름을 맞추며, 같은 쪽으로 도는 이웃 곡선은 큰 곡선 하나로 합침(S자 사이 직선은 남김). 미는 거리 한도는 없고 탐색 복도 · 계류 이격 · 기울기 · 토공이 나빠지지 않는 자리만(예상노선 추적은 6m 안). 못 옮긴 곡선은 그 자리(「측점 밖 곡선 N개」). 비교값은 옮긴 선으로 다시 셈. 끔 = 옮기기 전 선. [적용]은 보이는 쪽",
name: "측점 맞춤(늘)",
text: "모든 갈래에서 곡선 가운데(MC)를 일반측점(20m) 위로 옮긴 선 — 교각점을 밀고 반지름을 맞추며, 같은 쪽으로 도는 이웃 곡선은 큰 곡선 하나로 합침(S자 사이 직선은 남김). 못 옮긴 곡선은 그 자리(「측점 밖 곡선 N개」). 비교값은 옮긴 선으로 셈",
},
{
name: "재계산",
text: "지금 칸 값으로 서버 저장본을 지우고 새로 셈(덮어쓰기 아님) — 셈하는 동안 진행 줄이 보임. [적용]한 노선 · [초기화] 자리는 안 바뀜",
},
];
@@ -114,7 +119,15 @@ const LINES_HELP: Array<{ name: string; text: string }> = [
},
{
name: "못 지킨 구간",
text: "제약을 넘은 측점(5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
text: "최소 반지름 · 계류 이격 · 측점 밖 곡선을 넘은 곳만(「기준 초과」 수와 같음 · 5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
},
{
name: "원지반 기울기(참고)",
text: "측점(20m) 사이 원지반 기울기 최대 · 상한 넘는 곳 수 — 종단으로 푸는 값이라 판정에 안 듦(넘는 곳은 마우스를 올리면 보임)",
},
{
name: "시종점 높이 차",
text: "시점 · 종점 높이 차 ÷ 노선 연장이 상한을 넘으면 알림 — 종단으로 못 맞춤 · 노선을 늘려야 함",
},
{
name: "상한을 풂",
+26 -23
View File
@@ -236,6 +236,32 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
// 예상노선 뒤 · 노선 앞(414-2) — 노선이 네모 위를 지나감 · 고른 곡선은 `drawPickedCurve` 가 위에.
context.save();
context.lineWidth = 2;
scene.curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === scene.picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
@@ -294,29 +320,6 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
}
});
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
context.lineWidth = 2;
scene.curveInfo.forEach((curve) => {
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
const isPicked = curve.node_first === scene.picked;
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
context.rect(x - size, y - size, size * 2, size * 2);
context.fillStyle = isPicked
? style.getPropertyValue("--map-route") || "#f97316"
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
drawPickedCurve(context, scene, style);
drawPickHover(context, scene, line, style, NODE_R);
@@ -409,7 +409,7 @@
align-self: flex-start;
}
/* 측점 맞춤 · 재계산 한 줄(383-3) */
/* 종단 반영 · 재계산 한 줄(421) */
.b05-routerec__row {
display: flex;
gap: var(--spacing-8);
@@ -24,6 +24,7 @@ import type {
PipelineStation,
} from "../../A00_Common/design/logic/design_pipeline_types";
import type { ProfileAlignment } from "./B05_Profile_UI_Profile_Alignment";
import { toDesignProfile } from "./B05_Profile_UI_Profile_Data";
import { stationLabel } from "../../common_util/common_util_station";
import { stationIdOf } from "./B05_Profile_Pipeline_Grade";
@@ -212,7 +213,8 @@ export function stationSection(
};
}
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름 · 계획선 묶음)는 그대로 · 측점 · 지반 · 횡단은 새 노선. */
/** 묶음 출력 → 상세. 저장 상세의 종단 머리(이름)는 그대로 · 측점 · 지반 · 횡단 · 계획선 표본은 새 노선
* (십자선 대체 높이도 초안 종단 · 413). */
export function draftSectionDetail(
saved: SectionDetailResponse,
ground: GroundOutput,
@@ -242,6 +244,7 @@ export function draftSectionDetail(
})),
stations: sections.map(({ samples: _s, design: _d, preview: _p, ...head }) => head),
profile_alignment: profile,
design_profiles: [toDesignProfile(profile, saved.longitudinal.design_profiles?.[0])],
},
cross_sections: sections,
balloon_offsets: saved.balloon_offsets,
+16
View File
@@ -153,6 +153,22 @@ def stored_shoulder_extend(stored: dict[str, Any] | None) -> dict[str, float]:
return out
# 노선 저장(B05 `_carry_cross`)이 측점 열쇠마다 더 넘기는 사용자 손질(417) — 사면 쉬프트 ·
# 소단 깊이(기하 입력) · 측점 폭 · 메모 · 상세 표. 구조물 조절 = `STRUCTURE_ADJUST_KEYS`(관 · 구조물
# 열쇠만). 브라우저 짝 = `B06_Section_Station_Keys.ts` `ROUTE_CARRY_STATES`
# (거울 시험 test_417_route_carry_mirror).
ROUTE_CARRY_KEYS = (
"shoulder_extend_left_m",
"shoulder_extend_right_m",
"berm_depth_left_m",
"berm_depth_right_m",
"display_half_width_m",
"structure_note",
"structure_note_cad",
"detail_table",
)
def stored_tilt(stored: dict[str, Any] | None) -> dict[str, Any]:
"""저장분의 구조물 자리 기울기(287-4 · 288-1) — `compute_cross_design(**...)` 로 넘긴다."""
value = (stored or {}).get("tilt_pct")
+6 -3
View File
@@ -14,6 +14,7 @@ from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B05_Profile.B05_Profile_Structures_Schema import structure_type_map
from B06_Section.B06_Section_Engine_Culvert import load_culvert_sets
from B06_Section.B06_Section_Engine_Design import (
ROUTE_CARRY_KEYS,
compute_cross_design,
curve_widening_args,
stored_shoulder_extend,
@@ -630,8 +631,9 @@ def compute_default_designs(
없는 B06 미리 뜨기 자리(273-2) · 상단측도 그 횡단 값(종단 정본에 측점이 없음).
standard가 오면(확정 요청의 패널 편집값) 그 값으로 표준단면 기하를 계산한다.
`carried`(누가거리 round 3 → `CARRY_INPUT_KEYS` 값)가 오면 그 측점은 기본값 대신 그 입력으로
계산한다 — 노선 [확인] 체인의 옛 설계 이월(PLAN 74-3). 결과 칸은 늘 새 계획고로 다시 낸다.
`carried`(누가거리 round 3 → `CARRY_INPUT_KEYS` · `ROUTE_CARRY_KEYS` 값)가 오면 그 측점은
기본값 대신 그 입력으로 계산한다 — 노선 [확인] 체인의 옛 설계 이월(PLAN 74-3 · 417). 결과 칸은
늘 새 계획고로 다시 낸다.
계획고 부재 등으로 계산 불가한 측점은 조용히 건너뛴다(확정을 막지 않기 위함).
"""
root = project_root.resolve()
@@ -683,9 +685,10 @@ def compute_default_designs(
surface_drop_m=ford_drop_at(chainage_m, ford_drops),
berm=stored_berm(user),
**curve_widening_args(cross),
**stored_shoulder_extend(user), # 노선 저장이 넘긴 사면 쉬프트(417)
**tilt_args(chainage_m, tilts),
)
for key in ("berm", "cut_slope_ratio_user"):
for key in ("berm", "cut_slope_ratio_user", *ROUTE_CARRY_KEYS):
if user.get(key) is not None:
design[key] = user[key]
design["status"] = "provisional"
@@ -22,6 +22,7 @@ import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import { applySpanPatch, WALL_MIN_PURE_HEIGHT_M } from "./logic/B06_Section_UI_Cross_Culvert_Const";
import { tierSpanOf } from "./B06_Section_UI_Cross_Culvert_Wire";
import { computeStoredLayouts } from "./B06_Section_Structure_Layouts";
import { readBermDepths, readShoulderExtends } from "./B06_Section_Cross_Design_Session";
type Cells = Record<string, unknown>;
type Obj = Record<string, unknown>;
@@ -91,6 +92,38 @@ export function withDraftAdjusts(
}
}
/** 노선 저장 뒤 새 노선으로 옮긴 세션 고침(417) — 사면 쉬프트 · 소단 깊이 · 측점 폭 · 메모 · 상세 표를 설계 칸에 얹음 ·
* 같은 [저장]의 횡단 패치(`crossPatchesFromCache`)가 실음. 조절 4축 · 형식은 `withDraftAdjusts`. */
export function withCarriedEdits(
detail: SectionDetailResponse,
projectId: string,
routeId: number,
): void {
const read = (name: Parameters<typeof readState>[0]): Cells =>
readState<Cells>(name, projectId, routeId) ?? {};
const cells: Array<[string, Cells]> = [
["display_half_width_m", read("crossw")],
["structure_note", read("structure-notes")],
["structure_note_cad", read("structure-note-cads")],
["detail_table", read("detail-table")],
];
const sides: Array<[string, ReturnType<typeof readShoulderExtends>]> = [
["shoulder_extend", readShoulderExtends(projectId, routeId)],
["berm_depth", readBermDepths(projectId, routeId)],
];
for (const section of detail.cross_sections) {
const design = section.design as (CrossDesign & Obj) | null | undefined;
if (!design) continue;
const at = section.chainage_m.toFixed(2);
for (const [field, values] of cells) if (values[at] !== undefined) design[field] = values[at];
for (const [field, values] of sides) {
const own = values.get(Number(at));
for (const side of ["left", "right"] as const)
if (own?.[side] !== undefined) design[`${field}_${side}_m`] = own[side];
}
}
}
/** 관 옵션 조정값 몫 얹기(387-4b) — 관 초안 ?? 저장 관(페이지 자료 줄). 가게 · 자료 줄은 부를 때 늦게 불러옴 —
* 이 파일을 끄는 시험 · 묶음이 그 줄기(fetch · 가게)를 안 끌게. 못 받으면 그대로.
* `reshape` = 관 제원도 형상 얹기 규칙으로 다시(다시 셈 창구 앞 · B06 폼 되보내기가 옵션 값을 제원에 그대로
+6 -9
View File
@@ -304,15 +304,12 @@ export function invalidateSectionDetail(projectId: string, routeId?: number): vo
}
}
/** 받아 둔 상세 중 노선 번호가 가장 큰 것(최신) — 노선 번호를 모르는 쪽(B05 평면 횡단 판 · 393-2). */
export function peekSectionDetail(projectId: string): SectionDetailResponse | null {
const prefix = `${projectId}:`;
let best: { routeId: number; detail: SectionDetailResponse } | null = null;
for (const [key, detail] of cache) {
const routeId = Number(key.slice(prefix.length));
if (key.startsWith(prefix) && (!best || routeId > best.routeId)) best = { routeId, detail };
}
return best?.detail ?? null;
/** 받아 둔 그 노선 상세(받지 않고 · 없으면 null) — 노선 = 페이지 노선 id(B05 평면 횡단 판 · 관 길이 · 416). */
export function peekSectionDetail(
projectId: string,
routeId: number | null,
): SectionDetailResponse | null {
return routeId === null ? null : (cache.get(keyOf(projectId, routeId)) ?? null);
}
/**
+115 -5
View File
@@ -11,11 +11,13 @@
* · 칸마다 열쇠 → 쓴 때 누가거리 `@at` 을 함께 담음 — 구조물 id 를 아직 모를 때(첫 열기)도 그 자리 측점이면 읽음.
* · 옛 초안(누가거리 열쇠 `"85.05"`) · `at:` 열쇠는 읽을 때 지금 측점 열쇠로 옮겨 다시 씀.
* · 노선이 바뀜 = `carryStationDrafts` — 넘기는 칸 = 서버 `CARRY_INPUT_KEYS` 일곱(지반 유형 · 측구 종류 ·
* 측구 선택 · 2단 경사 · 암 경계 · 암 경사 · 소단 구간은 구간 그대로) · 단면 유형 · 측구 방향은 새 지형이
* 정함 · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」 한 줄.
* 측구 선택 · 2단 경사 · 암 경계 · 암 경사 · 소단 구간은 구간 그대로) + 사면 쉬프트 · 소단 깊이(417 · 서버
* `ROUTE_CARRY_KEYS`) · 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」.
* · 노선 저장 = `carryChainageStates` — 누가거리 열쇠 칸(옹벽 · 세월 · BOX · 집수 조절 · 측점 폭 · 메모 · 상세 표 ·
* `ROUTE_CARRY_STATES`)도 측점 열쇠로 새 노선 측점에 옮김(구조물 조절 = 관 · 구조물 열쇠만 · 서버와 같음).
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { readState, writeState, type StateName } from "../A00_Common/b_page_state";
import { pipeIdAt, pipeIdNear } from "../A00_Common/design/design_pipe_ids";
import { showToast } from "@ui/ui_template_elements";
import {
@@ -40,8 +42,41 @@ export const KEYED_STATES: readonly KeyedState[] = [
/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */
const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const;
/** 노선이 바뀔 때 통째로 넘기는 칸 · 버리는 칸(노견 확장 · 소단 깊이 = 그 측점 기하 손질). */
const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"];
/** 노선이 바뀔 때 통째로 넘기는 칸 — 사면 쉬프트(노견 확장) · 소단 깊이도(417 · 서버 `ROUTE_CARRY_KEYS`). */
const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope", "shoulderext", "bermdepth"];
/**
* 노선 저장 때 새 노선 측점으로 옮기는 **누가거리 열쇠** 칸(측점 옮김 목록 · 417) → 담긴 설계 칸.
* ⚠ 짝 — 설계 칸 합 = 서버 `ROUTE_CARRY_KEYS`(측점 열쇠마다) + `STRUCTURE_ADJUST_KEYS`(관 · 구조물 열쇠만) ·
* 거울 시험 `resources/tester/test_417_route_carry_mirror.py`.
*/
export const ROUTE_CARRY_STATES = {
revetx: ["revet_adjust"],
basinadjust: ["basin_adjust"],
inletstruct: ["inlet_structure"],
extrawall: ["extra_wall_counts"],
extraspan: ["extra_spans"],
revetlink: ["revet_link_detached", "revet_follow_grade"],
fordadjust: ["ford_adjust"],
boxadjust: ["box_adjust"],
crossw: ["display_half_width_m"],
"structure-notes": ["structure_note"],
"structure-note-cads": ["structure_note_cad"],
"detail-table": ["detail_table"],
shoulderext: ["shoulder_extend_left_m", "shoulder_extend_right_m"],
bermdepth: ["berm_depth_left_m", "berm_depth_right_m"],
} as const;
/** 구조물 조절 칸 — 관 · 구조물 열쇠 측점만 따라감(서버 `carry_cross_inputs` 와 같음). */
const STRUCTURE_STATES = new Set<string>([
"revetx",
"basinadjust",
"inletstruct",
"extrawall",
"extraspan",
"revetlink",
"fordadjust",
"boxadjust",
]);
/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */
const NEAR_M = 0.01;
@@ -356,3 +391,78 @@ function carriedValue(name: KeyedState, value: unknown): unknown {
);
return Object.keys(picked).length ? picked : null;
}
/** 노선 저장 때 시설 측점 풀이(`carryChainageStates`). */
export interface CarryFacilities {
/** 옛 저장 시설 측점 누가거리 → 새 노선 그 시설 측점 누가거리(없으면 null). */
movedAt?: (chainageM: number) => number | null | undefined;
/** 관 · 구조물 열쇠 → 새 노선 누가거리(없으면 null). */
keyAt?: (key: string) => number | null | undefined;
}
/** 누가거리 열쇠 머리(`b` · 0.01 m 글 · `:역할`) — 조정창 맵 열쇠 꼴(`renameChainageKeys` 와 같음). */
const CHAINAGE_HEAD = /^(b?)(-?\d+(?:\.\d+)?)(:.*)?$/;
/**
* 노선 저장 뒤(새 노선 측점 열쇠를 세운 뒤) 누가거리 열쇠 칸(`ROUTE_CARRY_STATES` 중 측점 열쇠 칸 빼고)을 옛 노선
* → 새 노선 측점으로 옮김 — 옛 누가거리 → 옛 측점 열쇠 → 새 누가거리. 구조물 조절은 관 · 구조물 열쇠만 ·
* 못 옮긴 고침은 버림. 돌려주는 값 = 버린 측점 수(「노선 밖 고침」 · 측점 단위).
* 시설 풀이(`facilities`) = 옛 저장 시설 측점 → 새 노선 그 시설 측점(좌표 짝 · 초안으로 안 따라간 고침) · 관 ·
* 구조물 열쇠 → 새 누가거리(관 id 규칙이 갈림 — 세션 `pipe-s3` · 계산 줄 `pipe:p8017`).
*/
export function carryChainageStates(
projectId: string,
fromRouteId: number,
toRouteId: number,
facilities: CarryFacilities = {},
): number {
const facilityAt = facilities.keyAt ?? (() => null);
const outside = new Set<string>();
const moveAt = (state: string, text: string): string | null => {
const moved = facilities.movedAt?.(Number(text));
if (moved !== null && moved !== undefined) return chainageKey(moved);
const stable = keyAt(projectId, fromRouteId, Number(text));
const structural = stable.startsWith("pipe:") || stable.startsWith("structure:");
// 새 목록에 그 열쇠가 없으면 그 시설의 새 자리 · 없으면 같은 자리 측점(초안 측점 = 저장 측점)
const toAt =
stable.startsWith("at:") || (STRUCTURE_STATES.has(state) && !structural)
? null
: chainageOfKey(
projectId,
toRouteId,
stable,
(structural ? facilityAt(stable) : null) ?? Number(text),
);
if (toAt === null) outside.add(stable);
return toAt === null ? null : chainageKey(toAt);
};
const rekey = (state: string, cells: Record<string, unknown>): Record<string, unknown> => {
const next: Record<string, unknown> = {};
for (const [key, value] of Object.entries(cells)) {
const head = CHAINAGE_HEAD.exec(key);
const to = head && moveAt(state, head[2]);
if (head && to !== null) next[`${head[1]}${to}${head[3] ?? ""}`] = value;
}
return next;
};
for (const state of Object.keys(ROUTE_CARRY_STATES)) {
if ((KEYED_STATES as readonly string[]).includes(state)) continue; // 측점 열쇠 칸 = carryStationDrafts
const name = state as StateName;
const raw = readState<Record<string, unknown>>(name, projectId, fromRouteId);
if (!raw || typeof raw !== "object") continue;
// 연동 = { detached: {누가거리: …}, grade: {…} } 두 겹
const next =
state === "revetlink"
? Object.fromEntries(
Object.entries(raw).map(([part, cells]) => [
part,
cells && typeof cells === "object"
? rekey(state, cells as Record<string, unknown>)
: cells,
]),
)
: rekey(state, raw);
writeState(name, next, projectId, toRouteId);
}
return outside.size;
}
@@ -441,7 +441,7 @@ export function attachDesignLinePick(input: {
stationId?: string;
/** 있으면 중심점 고름(끝 조각 · 키 `center`) — 그 측점 누가거리(356-3). */
center?: number;
/** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · 늘 보임 · 370-2). */
/** 있으면 쪽마다 교차점 손잡이(설계선 ∩ 원지반 · 키 `toe:left|right` · `bare` 쪽만 · 370-2 · 414-1). */
toes?: ReturnType<typeof toePoints>;
/** 있으면 쪽마다 노견 끝 = 원지반(절·성토 없음 · 379-1) — 손잡이 값 앞 알림. */
bare?: ReturnType<typeof edgeOnGround>;
@@ -476,7 +476,10 @@ export function attachDesignLinePick(input: {
: edges.right.elevation_m -
((edges.left.elevation_m - edges.right.elevation_m) * edges.right.offset_m) /
(edges.left.offset_m - edges.right.offset_m);
const toeSides = (["left", "right"] as const).filter((at) => input.toes?.[at]);
// 손잡이 = 그 쪽이 절 ↔ 성 바뀜 자리(노견 끝 = 원지반 · `bare`)일 때만(414-1)
const toeSides = (["left", "right"] as const).filter(
(at) => input.toes?.[at] && input.bare?.[at],
);
const rockLines = input.rock ? kids.filter((el) => el.classList.contains(ROCK_LINE_CLASS)) : [];
const keys = [
...pieceKeys(design, pieces),
@@ -502,7 +505,7 @@ export function attachDesignLinePick(input: {
// 중심점(356-3) = 끝 조각 · 판정 원은 고르기 차례가 보라 선 위로(`stackPickTargets`)
const center = centerZ === null ? null : centerHit(x0, toDisplayY(centerZ));
if (center) hits.push(center);
// 교차점 손잡이(370-2) — 쪽마다 원 · 늘 보임 · 고르면 그 쪽 사면 ◀▶(중심점 원과 같은 고르기 차례)
// 교차점 손잡이(370-2) — 바뀜 자리 쪽 원 · 평소 투명 · 고르면 그 쪽 사면 ◀▶(중심점 원과 같은 고르기 차례)
for (const at of toeSides) {
const toe = input.toes?.[at] as { offset_m: number; elevation_m: number };
const hit = centerHit(x(toe.offset_m), toDisplayY(toe.elevation_m));
+11 -6
View File
@@ -37,6 +37,7 @@ import {
type ZoomPanState,
} from "../A00_Common/b_svg_zoom_pan";
import { buildZoomControls } from "./B06_Section_UI_Cross_View_Zoom";
import { unscaleAt } from "./B06_Section_UI_Cross_View_Frame";
import { detailHomeView, layerPoints } from "./B06_Section_UI_Cross_Detail_Home";
import { attachDetailTable, designForTable, groupSpan } from "./B06_Section_UI_Cross_Detail_Table";
import { createCrossAxes, IDENTITY_VIEW, type CrossAxesOptions } from "./B06_Section_UI_Cross_Axes";
@@ -209,16 +210,20 @@ export function attachDetailZoom(
const [, , width, height] = (clone.getAttribute("viewBox") ?? "").split(/[\s,]+/).map(Number);
const axisLayer = clone.querySelector<SVGGElement>("g.b06-chart__axes");
const renderAxes = axes && axisLayer ? createCrossAxes(axisLayer, axes) : null;
// 십자 중심 = 세로 선 x1 · y1 + 18 · 계획 십자가 첫 보기 중심 · 원지반 십자도 크기 되돌림(413)
const centerOf = (marker: Element | null): [number, number] => {
const line = marker?.querySelector("line");
return [Number(line?.getAttribute("x1")), Number(line?.getAttribute("y1")) + 18];
};
const marker = layer.querySelector<SVGGElement>("g.b06-chart__center");
const markerLine = marker?.querySelector("line");
const cx = Number(markerLine?.getAttribute("x1"));
const cy = Number(markerLine?.getAttribute("y1")) + 18;
const [cx, cy] = centerOf(marker);
const ground = layer.querySelector<SVGGElement>("g.b06-chart__ground-center");
const [gx, gy] = centerOf(ground);
const follow = (state: ZoomPanState): void => {
renderAxes?.(state);
marker?.setAttribute(
"transform",
`translate(${cx} ${cy}) scale(${1 / Math.max(state.scale, 1e-6)}) translate(${-cx} ${-cy})`,
);
marker?.setAttribute("transform", unscaleAt(cx, cy, state.scale));
ground?.setAttribute("transform", unscaleAt(gx, gy, state.scale));
};
follow(initial ?? IDENTITY_VIEW);
const plot = {
@@ -2,7 +2,7 @@
* B06_Section_UI_Cross_Detail_Pick.ts
* [상세] 수량표 칸 누름 → 그림 복제 안 그 항목 강조(150-4).
*
* 칸 키 → 그림 요소: 면적 띠(깍기 · 쌓기) · 사토장 선 · 중심 십자(높이 넷) · 보라 판정 선 구역
* 칸 키 → 그림 요소: 면적 띠(깍기 · 쌓기) · 사토장 선 · 계획 · 원지반 십자(높이 넷) · 보라 판정 선 구역
* (사면 · 노면 · 측구). 판정 선은 화면 좌표라 십자(x(0)) · `data-px-per-m` 로 m 로 되돌려
* `crossZoneAt` 으로 가름 — 측구 = 단면유형을 성토 양쪽으로 둬도 절토로 남는 자리.
* 편책 · 제근 · 표토제거는 늘 빈칸 = 누름 없음(짝 표에서 뺌) · 대상이 그 카드에 없는 칸 = `presentKeys` 밖.
@@ -16,10 +16,10 @@ import { crossZoneAt, type CrossZone } from "./B06_Section_UI_Cross_Design_Pick"
type LineZone = CrossZone | "ditch" | "cut_slope";
/** 칸 키 → 강조 대상(선택자 = 그 요소 · zone = 그 구역 판정 선). 없는 키 = 누름 없음. */
export const DETAIL_TARGETS: Readonly<Record<string, { selector: string } | { zone: LineZone }>> = {
ground: { selector: "g.b06-chart__center" },
ground: { selector: "g.b06-chart__ground-center" }, // 원지반 십자(413)
planned: { selector: "g.b06-chart__center" },
fill: { selector: "g.b06-chart__center" },
cut: { selector: "g.b06-chart__center" },
fill: { selector: "g.b06-chart__center, g.b06-chart__ground-center" },
cut: { selector: "g.b06-chart__center, g.b06-chart__ground-center" },
cut_soil: { selector: "g.b06-chart__area--cut_soil" },
cut_rock: { selector: "g.b06-chart__area--cut_rock" },
embankment: { selector: "g.b06-chart__area--fill" },
+6 -3
View File
@@ -344,7 +344,9 @@ export function createCrossSectionCard(
} else {
const { sourceSamples, maxOffset, elevationMid, displayMax, pixelsPerMeter, heightPx } =
metrics;
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
// 십자선 · 계획 상자 = 형상과 한 높이(그 측점 설계 높이 · 413) · 설계 없을 때만 넘겨받은 종단 표본
const designZ = section.design?.design_elevation_m ?? designElevation;
const hasDesign = designZ !== undefined && Number.isFinite(designZ);
const valid = sourceSamples.filter(validElevation);
const exaggeration = Math.max(verticalExaggeration, 0.1);
// 좌표 규약(+offset=좌, -offset=우)을 표준 횡단면도 관례에 맞춘다: 진행방향을 바라보는
@@ -378,7 +380,7 @@ export function createCrossSectionCard(
elevationMid,
exaggeration,
centerZ: section.center_z ?? null,
designZ: hasDesign ? (designElevation as number) : null,
designZ: hasDesign ? (designZ as number) : null,
});
renderAxes(cardZoomStates.get(section.station_id) ?? IDENTITY_VIEW);
@@ -610,7 +612,8 @@ export function createCrossSectionCard(
const scaleCenter = appendCardFrame(svg, plotLayer, {
valid,
centerX: x(0),
designElevation: hasDesign ? (designElevation as number) : null,
designElevation: hasDesign ? (designZ as number) : null,
groundElevation: section.center_z ?? null,
toY: toDisplayY,
widthPx,
heightPx,
+34 -24
View File
@@ -15,6 +15,8 @@ export interface CardFrameInput {
centerX: number;
/** 계획고(없으면 null — 옛 자료는 지반고 폴백). */
designElevation: number | null;
/** 중심 원지반고(`section.center_z` · 없으면 null = 중심에 가장 가까운 지반고). */
groundElevation?: number | null;
/** 표고 → 화면 y(높이 배율 반영). */
toY: (elevation: number) => number;
widthPx: number;
@@ -34,27 +36,30 @@ export function appendCardFrame(
if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0)) return sample;
return nearest;
}, null);
const groundElevation = input.groundElevation ?? centerSample?.elevation_m ?? null;
// 십자선 = 계획 노선 자리(구 데이터는 지반고 폴백).
const centerElevation = hasDesign ? designElevation : (centerSample?.elevation_m ?? null);
const centerElevation = hasDesign ? designElevation : groundElevation;
const centerY = centerElevation !== null ? toY(centerElevation) : heightPx / 2;
const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`;
const marker = svgElement("g", { class: "b06-chart__center" });
marker.append(
svgElement("line", {
x1: centerX,
y1: centerY - 18,
x2: centerX,
y2: centerY + 18,
class: centerMarkerClass,
}),
svgElement("line", {
x1: centerX - 18,
y1: centerY,
x2: centerX + 18,
y2: centerY,
class: centerMarkerClass,
}),
const cross = (groupClass: string, y: number, lineClass: string): SVGElement => {
const group = svgElement("g", { class: groupClass });
group.append(
svgElement("line", { x1: centerX, y1: y - 18, x2: centerX, y2: y + 18, class: lineClass }),
svgElement("line", { x1: centerX - 18, y1: y, x2: centerX + 18, y2: y, class: lineClass }),
);
return group;
};
// 원지반 십자선(413) — 계획 십자선보다 먼저(겹치면 보라가 위) · 다른 무리(고름 · 강조 선택자 안 닿음)
const groundY = groundElevation !== null ? toY(groundElevation) : null;
const ground =
hasDesign && groundY !== null
? cross("b06-chart__ground-center", groundY, "b06-chart__ground-center-marker")
: null;
const marker = cross(
"b06-chart__center",
centerY,
`b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`,
);
if (ground) plotLayer.append(ground);
plotLayer.append(marker);
svg.append(
svgElement("line", {
@@ -79,12 +84,17 @@ export function appendCardFrame(
class: "b06-chart__axis-label",
}),
);
// 도형 레이어 배율을 십자선 중심에서 되돌림 — 자리는 따라가고 길이 18px 은 그대로.
return (scale) =>
marker.setAttribute(
"transform",
`translate(${centerX} ${centerY}) scale(${1 / Math.max(scale, 1e-6)}) translate(${-centerX} ${-centerY})`,
);
// 도형 레이어 배율을 십자선 중심에서 되돌림 — 자리는 따라가고 길이 18px 은 그대로(두 십자 모두).
return (scale) => {
marker.setAttribute("transform", unscaleAt(centerX, centerY, scale));
if (ground && groundY !== null)
ground.setAttribute("transform", unscaleAt(centerX, groundY, scale));
};
}
/** (cx, cy) 를 축으로 배율을 되돌리는 transform — 카드 · [상세] 창 십자선이 같이 씀. */
export function unscaleAt(cx: number, cy: number, scale: number): string {
return `translate(${cx} ${cy}) scale(${1 / Math.max(scale, 1e-6)}) translate(${-cx} ${-cy})`;
}
/** 원지반선 — 끊긴 샘플(무효 표고)마다 폴리선을 나눠 그린다. `toY` = 표고 → 화면 y(배율 반영). */
+1 -3
View File
@@ -14,7 +14,6 @@ import {
type SectionContextResponse,
type SectionDetailResponse,
} from "./B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "./B06_Section_Section_Store";
import { onAnySwap } from "./B06_Section_Swap_Bus";
import type { DesignViewSave } from "../A00_Common/design/design_shared";
import { buildCrossPatches, type CrossPatchSources } from "./logic/B06_Section_UI_Page_Patches";
@@ -520,8 +519,7 @@ export function crossSaveParts(ctx: SectionPersistContext): DesignViewSave {
// 얹은 사본이라, 그대로 두면 새로 저장된 구조물의 벽이 다음 조회까지 안 붙는다
// (2026-09-06 실측: 8+0.0 흙막이가 저장 뒤 화면 사본에만 빠져 있었음).
// 저장 도중 바뀜 신호는 안 그렸음 — 비운 캐시로 서버 새 상세를 받아 한 번 다시(323-9)
const routeId = ctx.routeId();
if (ctx.projectId && routeId !== null) invalidateSectionDetail(ctx.projectId, routeId);
// 캐시 비우기 = 한 벌(`design_shared`)이 저장 끝에 한 번(416 · 두 보기가 같은 새 객체를 받게)
await ctx.redraw();
},
};
+3 -3
View File
@@ -335,13 +335,13 @@ export function createSectionView(
applySelection(previous);
};
/** 십자선이 설 계획고 — 세월교 측점은 구체 위 노면이 월류 높이만큼 낮게 앉으므로
* 그만큼 내려 찍는다(2026-08-30 사용자 확정). 하강량은 백엔드가 계획고를 내려
* 잡으며 실어 준 값(`design.surface_drop_m`)이고, 원래 노면은 점선이 남긴다. */
/** 십자선이 설 계획고 = 그 측점 설계 높이(형상과 한 값 · 413). 설계가 없을 때만 종단 표본 —
* 세월교 측점은 월류 높이(`design.surface_drop_m`)만큼 내려 찍는다(2026-08-30 사용자 확정). */
const cardDesignElevation = (
detail: SectionDetailResponse,
section: CrossSection,
): number | undefined => {
if (section.design?.design_elevation_m !== undefined) return section.design.design_elevation_m;
const planZ = designElevationAt(detail.longitudinal.design_profiles, section.chainage_m);
if (planZ === undefined) return undefined;
return planZ - (section.design?.surface_drop_m ?? 0);
@@ -85,10 +85,17 @@
}
/* 중심점 고름(356-3) — 십자 자리 투명 원 · 고르면 보라 테두리 · 끌면 좌우. 십자는 누름을 원으로 넘김. */
.b06-chart__center {
.b06-chart__center,
.b06-chart__ground-center {
pointer-events: none;
}
/* 원지반 십자선(413) = 원지반선 색 · 계획 십자선 아래(먼저 그림) */
.b06-chart__ground-center-marker {
stroke: var(--color-chart-1);
stroke-width: 2.5;
}
.b06-chart__center-hit {
fill: transparent;
stroke: transparent;
@@ -102,16 +109,15 @@
cursor: ew-resize;
}
/* 교차점 손잡이(370-2) — 사면 ∩ 원지반 작은 원 · 늘 보임 · 고르면 채움 · 올리면 같은 모양(379-1). */
/* 교차점 손잡이(370-2 · 414-1) — 절 ↔ 성 바뀜 자리 작은 원 · 평소 투명 · 올리거나 고르면 채움. */
.b06-chart__toe-hit {
fill: var(--color-surface);
stroke: var(--color-royal-amethyst);
stroke-width: 1.5;
}
.b06-chart__toe-hit:hover,
.b06-chart__toe-hit.is-active {
fill: var(--color-royal-amethyst);
stroke: var(--color-royal-amethyst);
cursor: pointer;
}
@@ -154,6 +154,7 @@
stroke-width: 9;
}
.b06-detail__cad .b06-chart__ground-center.is-active line,
.b06-detail__cad .b06-chart__center.is-active line {
stroke: var(--color-danger);
stroke-width: 3;
@@ -0,0 +1,69 @@
# 요구 254 — 노선 추천 엔진 · 추천 창 상세 설계
분석 = 브레인 + 클로드 에이전트 둘(Opus 5.5 · 읽기 전용 · 시제품은 scratchpad). 앞 분석 = `2026-10-18_412_요구253_분석.md` 1 · 2 · 4장.
## 1. 사용자 확정(254)
- 곡선 수 줄이기는 노선 추천 안의 기능 — 사용자 옵션 아님.
- 종단 포함 계산 · 추천 창 「측점 맞춤」 단추 → 「종단 반영」 단추 · 측점 맞춤은 필수.
- 오른쪽 [재계산] = 영구저장소의 그 결과를 지우고 다시 만듦(덮어쓰기 아님).
- 토공 = 측점별 횡단 기준(종단 기준 아님).
- 「노선 추천」 칸 [초기화] 지움 · 아래 [초기화] 기준 = 사용자가 추천으로 [적용]한 노선(바꾸면 그것) → PLAN 418.
## 2. 지금 사실(코드)
- 측점 맞춤: 엔진은 이미 늘 맞춤(`B05_Profile_Engine_RouteInitial_Pool.py:113-120, 161` · `B05_Profile_Engine_RouteInitial.py:323-336`) · 창 「측점 맞춤」 = 맞추기 전 선 보기 전환만(`B05_Profile_UI_RouteEdit_Recommend.ts:151-154, 310-316` · 서버로 가는 것 없음).
- 종단 반영: 창에 단추 없음 · 갈래로 정함(follow 끔 · 나머지 켬 · `B05_Profile_UI_RouteEdit_Initial_Model.ts:31`) · 켬 = 균형 종단(`B05_Profile_Engine_RouteInitial_Profile.py`) — 20 m 마다 **공식 횡단 엔진**(`B06_Section_Engine_Design.py:179` `compute_cross_design` · [저장] 정본과 같은 파이썬) 단면적 → 동적 계획 종단 · 평균단면법 토량 · 후보마다 빠른 판 · 고른 것만 정밀.
- 띠 가늠(노폭 4 m · 비탈 뺌 · 종단 기준)이 남은 곳: 결과 표 절 · 성 값(`Initial_Model.ts:321-322` ← `Metrics.py:224-240`) · 종단 끔 점수 · 합치기 판정(`Merge.py:246-254`) · 탐색 · 국소 잼 · 공사비 어림 — 이 지형에서 실제 횡단 토량보다 5~10배 작음(검증_sub4 · 같은 선 띠 3,941 ㎥ 대 균형 종단 횡단 21,577 ㎥) · 순위가 뒤집히기도 함.
- 공식 엔진 비용: 한 번 2~5 ms · 노선 전체(60 측점) 0.4 s · 균형 종단 빠른 판 2.8 s · 정밀 5.6 s · 추천 지형(2 m)과 공식 지형(1 m) 차 +5.8 %.
- 균형 종단이 프로젝트에 저장된 표준단면 패널값 대신 설정 기본값을 씀(`Profile.py:175-182` · 공식은 `B06_Section_Server_Calc_Prebuild.py:269`).
- [적용] 뒤 종단 = 추천과 같은 균형 종단(1 m 지형) · 종단 끔이면 계획고 = 지반고.
- [재계산] = 바뀐 것 있을 때만 켜짐 · 같은 입력이면 저장본을 돌려줌(`Recommend.ts:401` · `Router_RouteInitial.py:128-131`) · 저장본은 덮어쓰지 않고 판(`ALGO_VERSION`)이 다르면 안 읽음 · 옛 판 파일이 남음.
## 3. 곡선 수와 토공이 이어지는 까닭(사용자 물음 1)
- 이 산지에서 곡선이 많은 까닭 = 노선이 등고선을 따라 굽이굽이 돌기 때문.
- 곡선을 합쳐 줄이면 노선이 곧아져 비탈을 가로지름 → 깎기 · 쌓기가 커짐(시제품: 곡선 33 → 20 이면 절성토 +10 %).
- 그래서 「얼마나 합칠지」를 엔진 안에서 토공 증가 한도로 멈춰야 함 — **사용자 옵션 아님 · 엔진 안 상수**.
## 4. 설계(브레인 정함 · 바꿀 수 있음)
엔진(추천만 · 손 편집 · 공용 선 짜기 짝 안 바뀜):
- 원지반 기울기 = 참고만:
- 후보 거름 · `_trace` 판 고르기 · 큰 R · 합치기 · 측점 맞춤의 원지반 기울기 막기를 걷음.
- 위반 수 = R · 계류 · 측점만.
- 탐색 링크 상한은 남김(비탈 곧장 오르는 길 막이).
- 시종점이 상한을 넘으면 화면에 알림.
- 토공 = 측점별 공식 횡단:
- 후보 줄 세움 = 모든 후보를 「상한 누른 계획선」 횡단으로(후보당 0.4 s) → 위 3개만 균형 종단 빠른 판 → 고른 하나 정밀.
- 합치기 판정 = 바뀐 측점(3~6개)만 횡단 차이로 셈.
- 국소 잼(수십만 번)은 띠 가늠 그대로 — 엔진 안 빠른 거름일 뿐.
- 결과 표 절 · 성 = 횡단 값.
- 표준단면 = 프로젝트 저장 패널값.
- 종단 반영 끔 = 계획고 = 지반고로 횡단 토공([적용] 뒤 값과 같음).
- 곡선 합치기 멈춤 = 노선 전체 횡단 토공 증가 ≤ +10 %(엔진 상수 · 사용자 옵션 아님).
- 삼각(R 없는 교점) 없음 = 내각 155° 이상도 곡선 둠(09-07 사용자 「곡선은 늘 둠」) · 못 두면 교점 지움(곧게) · MC = 일반측점 필수(측점 밖 0 목표).
- 맞추기 전 선(`raw` · `unsnapped`) 걷음.
- 판: `ALGO_VERSION` 8 · 엔진 소스 지문을 열쇠에 더함(손으로 올리기 빠뜨려도 새로 셈) · 옛 판 · 옛 지문 저장본은 미리 셈 때 지움.
- 시간 예산 = 첫 갈래 지금 + 20 s 안.
추천 창(B05 노선 추천 모달):
- 「측점 맞춤」 단추 지움 → 그 자리에 「종단 반영」 단추(기본 켬 · 다섯 갈래 모두) · 누르면 바로 다시 보임(저장본 있으면 그것).
- [적용] 때 균형 종단 = 이 단추 값 · `initial_choice.json` 에 `with_profile` 저장 · 다시 열면 되살림.
- 미리 셈 = 종단 켬 기본 다섯 갈래.
- [재계산] = 늘 켜짐(입력 오류 · 셈 중 빼고):
- 지금 입력의 저장본(다섯 갈래 같은 후보 묶음)을 지우고 메모리 후보 묶음도 버린 뒤 새로 셈.
- 지우기는 묶음 잠금 안에서(미리 셈과 겹침 막이).
- 진행 줄은 지금 것.
- [적용]한 노선 · 초기값 층은 안 건드림.
- 도움말 글 = 측점 맞춤 필수 · 종단 반영 · 재계산 뜻으로.
## 5. 나눔
| 절 | 일 | 창 | 파일 경계 |
|---|---|---|---|
| 419 | 원지반 기울기 참고만 · 삼각 없음 · 측점 필수 · raw 걷음 · 합치기 판정 = 횡단 차이(420 함수 부름) · 후보 줄 세움 | sub4 | Pool · Merge · Station · Radius · Search · Engine(RouteInitial.py) |
| 420 | 횡단 토공 함수(새 `B05_Profile_Engine_RouteInitial_CrossEarth.py`) · 균형 종단 표준단면 = 저장 패널값 · 결과 절 · 성 = 횡단 · 판 8 + 소스 지문 · 옛 저장본 정리 · [재계산] 서버(요청 `recompute` · 잠금 안 지움 · `_POOLS` 버림) · `initial_choice` with_profile · 미리 셈 종단 켬 | sub2 | Profile · Metrics · Store · Router_RouteInitial · Schema · Replan(initial_choice) |
| 421 | 추천 창 단추(측점 맞춤 → 종단 반영) · [재계산] 늘 켜짐 + recompute · [적용] with_profile · 도움말 · unsnapped 화면 걷음 | sub1(418 뒤 이어) | Recommend.ts · Initial.ts · Initial_Model.ts · Recommend_Help.ts · Api_RouteInitial.ts |
- 420 함수 이름 · 모양을 먼저 정함: `cross_earthwork(line, chainages, design_z | None, terrain, standard) -> {cut, fill, per_station}` · `cross_earthwork_delta(before, after, changed_chainages, …)` — 419 는 이 이름으로 부르고, 420 이 올리기 전엔 지금 띠 가늠으로 둠.
+9 -1
View File
@@ -58,7 +58,9 @@ const matches = (el, selector) => {
};
function fakeSvg(ppm, x0) {
const center = node("g", ["b06-chart__center"]);
const ground = node("g", ["b06-chart__ground-center"]); // 원지반 십자(413)
const els = [
ground,
center,
node("line", [], { x1: String(x0) }, center),
node("g", ["b06-chart__area", "b06-chart__area--cut_soil"]),
@@ -104,7 +106,11 @@ const activeOf = (svg) => {
if (el.set.has("is-active") && !el.set.has("b06-chart__design-hit"))
names.push(
[...el.set].find(
(c) => c.includes("--") || c === "b06-chart__center" || c === "b06-chart__spoil-fill",
(c) =>
c.includes("--") ||
c === "b06-chart__center" ||
c === "b06-chart__ground-center" ||
c === "b06-chart__spoil-fill",
),
);
return names;
@@ -124,6 +130,8 @@ for (const key of [
"embankment",
"spoil_fill",
"ground",
"planned",
"cut",
"ditch_soil",
"grading_cut",
"grading_fill",
+7 -3
View File
@@ -129,14 +129,17 @@ function chart() {
const plot = svgElement("g", { class: "b06-chart__plot-layer" });
const center = svgElement("g", { class: "b06-chart__center" });
center.append(svgElement("line", { x1: 319, y1: 124, x2: 319, y2: 160 }));
plot.append(center);
// 원지반 십자(413) — 계획 십자보다 먼저 · 중심 319,200
const ground = svgElement("g", { class: "b06-chart__ground-center" });
ground.append(svgElement("line", { x1: 319, y1: 182, x2: 319, y2: 218 }));
plot.append(ground, center);
clipGroup.append(plot);
svg.append(svgElement("rect"), axes, clipGroup, svgElement("line", { class: "b06-chart__axis" }));
return { svg, axes, plot, center };
return { svg, axes, plot, center, ground };
}
const ticks = (axes) =>
axes.childNodes.filter((node) => node.tag === "text").map((node) => node.textContent);
const { svg, axes, plot: layer, center } = chart();
const { svg, axes, plot: layer, center, ground } = chart();
let saved;
const handle = attachDetailZoom(svg, AXES, undefined, (next) => (saved = next));
const ticksBefore = ticks(axes);
@@ -194,6 +197,7 @@ out.reset = layer.getAttribute("transform");
// 확대 — 십자는 중심에서 배율을 되돌림(크기 그대로)
svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 });
out.centerTransform = center.getAttribute("transform");
out.groundTransform = ground.getAttribute("transform");
out.max = (() => {
for (let i = 0; i < 60; i++) svg.fire("wheel", { clientX: 600, clientY: 300, deltaY: -100 });
return saved.scale;
@@ -318,6 +318,88 @@ const out = {};
};
}
// ── 1b. 십자선 둘(413) — 원지반 먼저 · 계획 나중 · 배율 되돌림 둘 다 ─────────────
{
const { appendCardFrame } = makeLoader(
[
B06("B06_Section_UI_Cross_View_Frame"),
B06("B06_Section_UI_Section_Common"),
path.join(ROOT, "common_util", "common_util_svg.ts"),
],
{
"@config/config_frontend": { PIPE_TYPES: [] },
"../B05_Profile/B05_Profile_UI_IrregularStations": { PIPE_DISPLAY_NAME: {} },
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_t, key) => [String(key)] }),
},
},
)(B06("B06_Section_UI_Cross_View_Frame"));
const toY = (z) => (900 - z) * 10;
const frame = (designElevation, groundElevation) => {
const plot = new FakeNode("g");
const scale = appendCardFrame(new FakeNode("svg"), plot, {
valid: [{ offset_m: 0.4, elevation_m: 870, valid: true }],
centerX: 50,
designElevation,
groundElevation,
toY,
widthPx: 480,
heightPx: 300,
});
return { plot, scale };
};
const midY = (group) => Number(group.querySelector("line").attrs.y1) + 18;
const { plot, scale } = frame(878.5, 876.57);
scale(2);
const [groundG, designG] = plot.children;
const bare = frame(null, null).plot;
out.crosshair = {
order: plot.children.map((node) => node.className),
design_y: midY(designG),
want_design_y: toY(878.5),
ground_y: midY(groundG),
want_ground_y: toY(876.57),
design_only_ground: bare.querySelector(".b06-chart__ground-center"),
scaled: [groundG.attrs.transform, designG.attrs.transform],
want_scaled: [toY(876.57), toY(878.5)].map(
(y) => `translate(50 ${y}) scale(0.5) translate(-50 ${-y})`,
),
};
}
// ── 1c. 평면 초안 상세 = 계획선 표본도 초안 종단(413 · 십자선 대체 높이) ─────────────
{
const B05 = (name) => path.join(ROOT, "B05_Profile", "logic", `${name}.ts`);
const { draftSectionDetail } = makeLoader([
B05("B05_Profile_Pipeline_Detail"),
B05("B05_Profile_UI_Profile_Data"),
])(B05("B05_Profile_Pipeline_Detail"));
const draftSamples = [
{ chainage_m: 0, elevation_m: 110 },
{ chainage_m: 20, elevation_m: 112 },
];
const saved = {
longitudinal: {
design_profiles: [
{ id: "old", name: "계획선", samples: [{ chainage_m: 0, elevation_m: 100 }] },
],
},
cross_sections: [],
balloon_offsets: {},
};
const detail = draftSectionDetail(
saved,
{ stations: [], longitudinal: { chainageM: [0, 20], z: [109, 111] } },
{ samples: draftSamples, balance: { cut_area_m2: 0, fill_area_m2: 0, net_area_m2: 0 } },
null,
new Map(),
20,
);
const profile = detail.longitudinal.design_profiles[0];
out.draftProfile = { samples: profile.samples, id: profile.id };
}
// ── 2. 아래 띠(318-2 · 318-3) — 중심고 글 없음 · 암 경계 맨 왼쪽 · 토사도 띠 남음 ─────
{
const control = (cls) => () => {
@@ -116,7 +116,7 @@ m.useStationKeys(P, R, oldRoute);
out.hintKeyNow = m.keyAt(P, R, 52.5);
out.hintRead = m.crossDesignChoices(P, R).has(52.5);
/* 4. 노선이 바뀜 — 측점 열쇠로 넘김 · 넘김 = 카드 선택 넷 · 암 경계 · 노견 확장 안 넘김 · 사라진 측점 = 노선 밖 */
/* 4. 노선이 바뀜 — 측점 열쇠로 넘김 · 넘김 = 카드 선택 넷 · 암 경계 · 노견 확장(417) · 사라진 측점 = 노선 밖 */
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P); // 관이 47 m 로 옮김
m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 55 }]);
m.useStationKeys(
+14 -1
View File
@@ -38,7 +38,10 @@ const STUBS = {
},
"../B06_Section/B06_Section_Section_Store": {
invalidateSectionDetail: (_p, routeId) => calls.push(`invalidate:${routeId ?? "all"}`),
loadSectionDetail: async (_p, routeId) => (calls.push(`load:${routeId}`), { tag: routeId }),
loadSectionDetail: async (_p, routeId) => (
calls.push(`load:${routeId}`),
{ tag: routeId, cross_sections: [] }
),
saveCachedCrossPatches: async (_p, routeId) => (calls.push(`crossSave:${routeId}`), 1),
},
"../B06_Section/B06_Section_Station_Keys": {
@@ -47,7 +50,17 @@ const STUBS = {
calls.push(`carry:${from}->${to}${opts?.quiet ? ":quiet" : ""}`),
2
),
// 417 — 누가거리 열쇠 조절 옮김
carryChainageStates: (_p, from, to) => (calls.push(`carryAt:${from}->${to}`), 1),
},
"../B06_Section/B06_Section_Section_Adjusts": {
withDraftAdjusts: (_d, _p, routeId) => calls.push(`adjusts:${routeId}`),
withCarriedEdits: (_d, _p, routeId) => calls.push(`edits:${routeId}`),
},
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {
fetchDetailPipePoints: async () => (calls.push("pipes"), { pipe_points: [] }),
},
"../A00_Common/design/design_pipe_ids": { pipeChainageOf: () => null },
"./B05_Profile_Api_Fetch": { clearRouteLatestCache: () => calls.push("latest:clear") },
"./B05_Profile_Api_Replan": {
replanRoute: async (_p, body) => {
+23 -11
View File
@@ -241,7 +241,7 @@ out.stop = {
};
}
// ② 교차점 손잡이 — 쪽마다 원 · 늘 보임 · 키 toe:left|right · 고르면 ◀▶ 가 멈춤 자리로 보냄
// ② 교차점 손잡이 — 바뀜 자리(노견 끝 = 원지반) 쪽만 원 · 키 toe:left|right · 고르면 ◀▶ 로 절 ↔ 성(414-1)
const x = (o) => 300 - o * 20;
const y = (e) => 400 - (e - 100) * 20;
const attach = (sec, changes) => {
@@ -260,21 +260,28 @@ const attach = (sec, changes) => {
});
return { layer, handle, picks };
};
const toesOf = (layer) => layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
const near = section(crossing[0]); // 보통 단면(우 = 절토 · 바뀜 자리 아님)
const nearToes = toesOf(attach(near, []).layer);
const changes = [];
const near = section(crossing[0]);
const { layer, handle, picks } = attach(near, changes);
const toes = layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"));
const keys = pick.designLineMoves(near, () => undefined).toes;
const bareSec = section(out.stop.value); // 바뀜 자리(우 노견 끝 = 원지반)
const { layer, handle, picks } = attach(bareSec, changes);
const toes = toesOf(layer);
const moves = pick.designLineMoves(bareSec, () => undefined);
const keys = moves.toes;
const rightToeHit = toes.find((t) => Number(t.getAttribute("cx")) > x(0));
out.toe = {
nearCount: nearToes.length,
nearSides: ["left", "right"].filter((s) => pick.designLineMoves(near, () => undefined).toes[s]),
count: toes.length,
bareSides: ["left", "right"].filter((s) => keys[s] && moves.bare[s]),
centerClass: toes.every((t) => t.classList.contains("b06-chart__center-hit")),
sides: ["left", "right"].filter((s) => keys[s]),
rightOnGround:
keys.right && Math.abs(keys.right.elevation_m - groundAt(keys.right.offset_m)) < 1e-9,
rightCx: toes.length === 2 ? Number(toes[1].getAttribute("cx")) : null,
rightCx: rightToeHit ? Number(rightToeHit.getAttribute("cx")) : null,
rightX: keys.right ? x(keys.right.offset_m) : null,
};
toes[1].click();
rightToeHit.click();
handle.setActive(picks[0]);
// 아래 띠 공용 칸(370-3) — 제목 · 버튼 줄(끝 = 값 글)
const cellOf = (h) => ({
@@ -287,9 +294,12 @@ const cellOf = (h) => ({
});
const cell = cellOf(handle);
cell.button(x(-1) > x(0) ? "right" : "left").click(); // 화면 → 바깥(우 = −offset)
cell.button(x(-1) > x(0) ? "left" : "right").click(); // 화면 → 안쪽
out.toePick = {
picked: picks,
active: toes[1].classList.contains("is-active"),
active: rightToeHit.classList.contains("is-active"),
modeBefore: bareSec.design.section_mode,
modes: changes.map((c) => section(c.shoulder_extend.valueM).design.section_mode),
value: cell.value,
title: cell.title,
panelOpen: !handle.cell.classList.contains("is-empty"),
@@ -321,8 +331,10 @@ out.inherit.label = cellOf(after.handle).title;
// ⑤ 370-5 — 손잡이를 고른 뒤 넘어간 사면이 원지반과 못 만나면(손잡이 없음) 같은 쪽 첫 사면 선이 이어받음
pick.setPickedLine("S1", null);
{
const again = attach(near, []);
again.layer.children.filter((k) => k.classList.contains("b06-chart__toe-hit"))[1].click();
const again = attach(bareSec, []);
toesOf(again.layer)
.find((t) => Number(t.getAttribute("cx")) > x(0))
.click();
pick.setPickedLine("S1", again.picks[0]);
let extend = crossing[1];
let open = null;
@@ -128,7 +128,7 @@ function loadTs(full) {
const B06 = (f) => path.join(ROOT, "B06_Section", `${f}.ts`);
const store = loadTs(B06("B06_Section_Section_Store"));
const { writeState } = loadTs(path.join(ROOT, "A00_Common", "b_page_state.ts"));
const storeDetail = () => store.peekSectionDetail(P);
const storeDetail = () => store.peekSectionDetail(P, ROUTE);
/** 저장 정본 — 격자 0 · 20 · 40 · 60(지금 계획선으로 셈한 설계) · 계획선 평평 100. */
const canon = () => ({
+184
View File
@@ -0,0 +1,184 @@
/* 416 헬퍼 — 한 페이지 끊김 ② : 진짜 상세 가게(`B06_Section_Section_Store`) · 진짜 평면 횡단 판 자료
* (`B05_Profile_UI_RouteEdit_Cross_Card.routeCrossSource`) · 진짜 관 길이(`design_structure_pipe_calc`) ·
* 진짜 페이지 노선 칸. 서버 · 그림 · 묶음은 가짜. 짝 = `test_416_page_cache.py`.
* L2 — 판 = 페이지 노선 상세(더 큰 노선 번호 상세가 캐시에 있어도)
* M5 — 관 길이 = 공용 캐시(페이지 노선) · 저장 뒤 비우고 다시 받으면 새 값
* M1 — 비운 뒤 두 보기가 함께 받으면 같은 새 객체 하나
* 암선 기본 = config(기억해 둔 context 값)
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const CATALOG = require("./helper_322_catalog_module.cjs");
const ROOT = path.join(__dirname, "..", "..");
const P = "P416";
function memoryStorage() {
const map = new Map();
return {
getItem: (k) => (map.has(k) ? map.get(k) : null),
setItem: (k, v) => map.set(k, String(v)),
removeItem: (k) => map.delete(k),
key: (i) => [...map.keys()][i] ?? null,
get length() {
return map.size;
},
};
}
globalThis.window = { sessionStorage: memoryStorage(), localStorage: memoryStorage() };
const anyModule = (extra = {}) =>
new Proxy(extra, { get: (t, key) => (key in t ? t[key] : () => undefined) });
/** 가짜 서버 — 노선마다 관 길이 하나(40 m 측점 관) · 받을 때마다 새 객체 · 받은 수. */
const server = { pipeLength: { 3: 8, 9: 30 }, fetches: 0 };
const detailOf = (routeId) => ({
longitudinal: { stations: [], design_profiles: [] },
cross_sections: [0, 20, 40].map((chainage_m) => ({
station_id: `r${routeId}-${chainage_m}`,
chainage_m,
samples: [],
culvert: chainage_m === 40 ? { chainage_m: 40 } : undefined,
design: { pipe_length_m: chainage_m === 40 ? server.pipeLength[routeId] : undefined },
})),
});
let rockDefault = null; // 종단 보기가 기억한 context 값
let draftRoute = null; // 묶음 노선 초안
const STUBS = {
"design_pipeline_page.ts": {
reconcilePagePipeline: async () => 0,
pipelineOwnsCross: () => false,
pagePipeline: () => null,
pipelineCross: () => undefined,
pipelineDetail: () => null,
},
"design_pipeline_cross.ts": { forceCross: () => undefined },
"B06_Section_Section_Drafts.ts": {
forgetDraftStations: () => undefined,
withDraftShapes: async () => undefined,
withDraftStations: async () => undefined,
},
"B05_Profile_Api_PlusStations.ts": {
loadSavedPlusStations: async () => undefined,
plusStationsOf: () => [],
},
"B05_Profile_Api_Pipes_Draft.ts": { readPendingPipes: () => null },
"design_page_data.ts": { pagePipes: async () => ({ pipe_points: [] }) },
"B06_Section_Api_Fetch.ts": anyModule({
fetchSectionDetail: async (_projectId, routeId) => {
server.fetches += 1;
return detailOf(routeId);
},
}),
"B06_Section_Api_Culvert_Options.ts": anyModule({ STRUCTURE_ADJUST_KEYS: [] }),
"B06_Section_Swap_Bus.ts": anyModule(),
"B06_Section_Station_Keys.ts": anyModule({ KEYED_STATES: [] }),
"B06_Section_UI_Page_Persist.ts": anyModule(),
"B06_Section_UI_Standard_Panel.ts": anyModule({
readRockBoundaryDefault: () => rockDefault,
}),
"B06_Section_Cross_Design_Session.ts": anyModule(),
"common_util_cross_design.ts": anyModule(),
"B06_Section_Structure_Layouts.ts": anyModule({ computeStoredLayouts: () => null }),
"B06_Section_UI_Cross_Culvert_Wire.ts": anyModule(),
"B06_Section_UI_Cross_Ford_Geom.ts": anyModule(),
"B06_Section_UI_Cross_Box_Geom.ts": anyModule(),
"B06_Section_UI_Cross_View.ts": anyModule(),
"B06_Section_UI_Section_Common.ts": anyModule({ designElevationAt: () => undefined }),
"B06_Section_Display_Width.ts": anyModule({ displayHalfWidth: () => 10 }),
"B06_Section_UI_Style_Cross_Controls.css": {},
"B06_Section_UI_Cross_Culvert_Basin.ts": anyModule(),
"B06_Section_UI_Cross_Culvert_Const.ts": anyModule(),
"B06_Section_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }),
"B05_Profile_UI_IrregularStations.ts": anyModule({ PIPE_DISPLAY_NAME: {} }),
"config_frontend.ts": anyModule({ PIPE_TYPES: [] }),
};
const cache = new Map();
const resolve = (base) =>
[base, `${base}.ts`, path.join(base, "index.ts")].find(
(p) => fs.existsSync(p) && fs.statSync(p).isFile(),
) ?? `${base}.ts`;
function loadTs(full) {
const stub = STUBS[path.basename(full)];
if (stub) return stub;
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2020,
esModuleInterop: true,
},
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const near = (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG;
const alias = { "@util/": "common_util", "@ui/": "ui_template", "@config/": "config" };
for (const [prefix, dir] of Object.entries(alias))
if (request.startsWith(prefix))
return loadTs(resolve(path.join(ROOT, dir, request.slice(prefix.length))));
return loadTs(resolve(path.join(path.dirname(full), request)));
};
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
}
const store = loadTs(path.join(ROOT, "B06_Section", "B06_Section_Section_Store.ts"));
const pageRoute = loadTs(path.join(ROOT, "A00_Common", "design", "design_page_route.ts"));
const pipeCalc = loadTs(path.join(ROOT, "A00_Common", "design", "design_structure_pipe_calc.ts"));
const card = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Cross_Card.ts"));
const fakePipe = { input: (name) => (name === "route" ? draftRoute : null) };
async function run() {
const out = {};
// 열 때 — 페이지 노선 3 · 캐시에 더 큰 노선 9 도 있음(다른 탭 · 옛 판)
pageRoute.setPageRouteId(P, 3);
await store.loadSectionDetail(P, 9);
const shown = await store.loadSectionDetail(P, 3);
const source = card.routeCrossSource(P, fakePipe, 40, "cur");
out.l2 = {
station: source?.section.station_id ?? null,
noRoute: store.peekSectionDetail(P, null),
exact: store.peekSectionDetail(P, 3) === shown,
};
draftRoute = { v: 1 };
out.l2.draftNoSaved = card.routeCrossSource(P, fakePipe, 40, "cur"); // 노선 초안 중 = 저장 상세 안 씀
draftRoute = null;
// 암선 기본 = config(기억값) · 기억 전 = 서버 Schema 기본과 같은 값
out.rock = { pending: source.rockDefaultM };
rockDefault = -0.8;
out.rock.config = card.routeCrossSource(P, fakePipe, 40, "cur").rockDefaultM;
// M5 — 관 길이 = 공용 캐시(페이지 노선) 한 벌(design_page 가 거는 그 함수)
pipeCalc.setPipeSectionsSource(
() => store.peekSectionDetail(P, pageRoute.pageRouteId(P))?.cross_sections ?? null,
);
out.m5 = { before: pipeCalc.pipeLengthM(40) };
// [저장] — 서버가 관 길이를 다시 셈(8 → 11) · 한 벌이 캐시를 한 번 비움 → 두 보기가 함께 다시 받음
server.pipeLength[3] = 11;
store.invalidateSectionDetail(P);
out.m5.cleared = pipeCalc.pipeLengthM(40);
const fetchesBefore = server.fetches;
const [profileView, crossView] = await Promise.all([
store.loadSectionDetail(P, 3),
store.loadSectionDetail(P, 3),
]);
out.m1 = {
sameObject: profileView === crossView,
fetched: server.fetches - fetchesBefore,
fresh: profileView !== shown,
panelSeesFresh: card.routeCrossSource(P, fakePipe, 40, "cur")?.section.station_id ?? null,
};
out.m5.after = pipeCalc.pipeLengthM(40);
return out;
}
run()
.then((out) => console.log(JSON.stringify(out)))
.catch((error) => {
console.error(error);
process.exit(1);
});
+16
View File
@@ -0,0 +1,16 @@
/* 417 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼(helper_417_route_carry.mjs)가 부름.
* 노선 바뀐 [저장] 때 B06 조절 · 사면 쉬프트 · 소단 깊이 옮김 · 평면 초안 횡단 입력.
*/
export {
carryChainageStates,
carryStationDrafts,
useStationKeyList,
useStationKeys,
} from "../../B06_Section/B06_Section_Station_Keys";
export {
writeBermDepth,
writeShoulderExtend,
} from "../../B06_Section/B06_Section_Cross_Design_Session";
export { withCarriedEdits, withDraftAdjusts } from "../../B06_Section/B06_Section_Section_Adjusts";
export { pipelineCrossSource } from "../../A00_Common/design/design_pipeline_cross";
export { readState, writeState } from "../../A00_Common/b_page_state";
+155
View File
@@ -0,0 +1,155 @@
/* 417 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_417_entry.ts)을 부르고 결과를 JSON 한 벌로.
* node helper_417_route_carry.mjs <묶음.mjs> <입력.json>
* 입력 = { standard: 파이썬 config 표준단면 }.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
const [bundlePath, inputPath] = process.argv.slice(2);
const m = await import(pathToFileURL(bundlePath).href);
const input = JSON.parse(readFileSync(inputPath, "utf8"));
const P = "p417";
const R = 7;
const R2 = 8;
const raw = (name, route) => m.readState(name, P, route) ?? {};
const keysOf = (name, route) =>
Object.keys(raw(name, route))
.filter((k) => k !== "@at")
.sort();
const section = (chainage_m, kind, extra = {}) => ({
station_id: `s${chainage_m}`,
chainage_m,
kind,
label: String(chainage_m),
samples: [],
design: {},
...extra,
});
const detailOf = (sections) => ({
longitudinal: {
stations: sections,
profile_alignment: { policy: { station_interval_m: 20 } },
},
cross_sections: sections,
});
const out = {};
/* 1. 옛 노선(관 45 m) — B06 조절 · 사면 쉬프트 · 소단 깊이 · 측점 폭 · 상세 표 · 연동을 세션에 */
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 45 }] }, P);
const pipe = { structure: "파형강관 D800", culvert: {} };
m.useStationKeys(
P,
R,
detailOf([
section(0, "bp"),
section(20, "regular"),
section(40, "regular"),
section(45, "irregular", pipe),
section(60, "ep"),
]),
);
m.writeShoulderExtend(P, R, 40, "left", 0.5);
m.writeBermDepth(P, R, 40, "right", 1.2);
const wall = { x: 0.3, d: null, h: 1.5, m: null };
m.writeState("revetx", { "45.00:outlet": wall, "40.00:inlet": wall }, P, R);
m.writeState("boxadjust", { "45.00": { left: { x: 0.2 } } }, P, R);
m.writeState("crossw", { "40.00": 6 }, P, R);
m.writeState("detail-table", { "45.00": { rows: 2 } }, P, R);
m.writeState("revetlink", { detached: { "45.00": true }, grade: {} }, P, R);
/* 2. 노선 바뀐 [저장] — 새 노선(관 47 m 로 따라감 · 끝 58 m) 측점 열쇠로 옮김 */
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P);
const next = detailOf([
section(0, "bp"),
section(20, "regular", { design: { shoulder_extend_right_m: 0.7, berm_depth_left_m: 0.4 } }),
section(40, "regular"),
section(47, "irregular", pipe),
section(58, "ep"),
]);
m.useStationKeys(P, R2, next);
out.keyedOutside = m.carryStationDrafts(P, R, R2, { quiet: true });
out.chainageOutside = m.carryChainageStates(P, R, R2);
out.shoulderKeys = keysOf("shoulderext", R2);
out.bermKeys = keysOf("bermdepth", R2);
out.revet = raw("revetx", R2);
out.box = raw("boxadjust", R2);
out.width = raw("crossw", R2);
out.table = raw("detail-table", R2);
out.link = raw("revetlink", R2);
/* 3. 새 상세 설계 칸에 얹음 — 같은 [저장]의 횡단 패치가 실을 값 */
m.withDraftAdjusts(next, P, R2);
m.withCarriedEdits(next, P, R2);
const at = (c) => next.cross_sections.find((s) => s.chainage_m === c).design;
out.design40 = at(40);
out.design47 = at(47);
/* 4. 평면 초안 횡단 입력 — 세션 사면 쉬프트 · 소단 깊이 + 저장분(세션 없음)도 */
m.writeState("std-cross", input.standard, P);
const stations = next.cross_sections.map((s, i) => ({
key: ["end:BP", "grid:1", "grid:2", "pipe:pipe-s1", "end:EP"][i],
chainageM: s.chainage_m,
kind: i === 3 ? "pipe" : "grid",
}));
const source = m.pipelineCrossSource(P, next, stations, R2);
const pick = (entry) =>
Object.fromEntries(
Object.entries(entry ?? {}).filter(([k]) => /^(shoulderExtend|bermDepth)/.test(k)),
);
out.input40 = pick(source?.input.byKey["grid:2"]);
out.input20 = pick(source?.input.byKey["grid:1"]);
/* 5. 관 id 규칙이 갈림(옛 목록 세션 id · 새 목록 저장 관 id) — 시설 자리 풀이로 옮김 · 없으면 버림 */
const R3 = 9;
const R4 = 10;
const list = [
{ key: "end:BP", chainageM: 0 },
{ key: "grid:1", chainageM: 20 },
{ key: "grid:2", chainageM: 40 },
{ key: "pipe:p9", chainageM: 52 },
{ key: "end:EP", chainageM: 60 },
];
m.useStationKeyList(P, R3, list);
m.useStationKeyList(P, R4, list);
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 45 }] }, P);
m.useStationKeys(
P,
R,
detailOf([section(0, "bp"), section(45, "irregular", pipe), section(60, "ep")]),
);
m.writeState("revetx", { "45.00:outlet": wall }, P, R);
m.carryChainageStates(P, R, R3, { keyAt: (key) => (key === "pipe:pipe-s1" ? 52 : null) });
m.carryChainageStates(P, R, R4);
out.splitResolved = raw("revetx", R3);
out.splitDropped = raw("revetx", R4);
// 옛 저장 시설 측점(초안으로 안 따라간 고침) — 좌표 짝으로 새 시설 측점에
const R5 = 11;
m.useStationKeyList(P, R5, list);
m.carryChainageStates(P, R, R5, { movedAt: (c) => (c === 45 ? 52 : null) });
out.movedByPlace = raw("revetx", R5);
process.stdout.write(JSON.stringify(out));
@@ -0,0 +1,102 @@
/* 418 좌측 아래 [초기화] 헬퍼 — 진짜 `design_reset` · `b_page_state` 를 Node 로 돌려 남은 초안을
* 표준출력 JSON 한 벌로. 서버 · 확인창 · 다른 캐시 지우기는 가짜로 막음.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const calls = [];
const STUBS = {
"@ui/ui_template_elements": {
showConfirmDialog: async () => true,
showLoadingOverlay: () => {},
hideLoadingOverlay: () => {},
showToast: (message, kind) => calls.push(["toast", kind]),
},
"@ui/ui_template_locale": {
currentLanguageIndex: 0,
ui_locales: new Proxy({}, { get: (_, key) => [String(key)] }),
},
B05_Profile_Api_Fetch: {
resetRouteDesign: async () => calls.push(["resetRouteDesign"]),
clearRouteLatestCache: () => {},
},
B05_Profile_UI_Profile_Edit: { clearAlignmentDrafts: () => {} },
B06_Section_Section_Store: { invalidateSectionDetail: () => {} },
};
const cache = new Map();
const load = (file) => {
if (cache.has(file)) return cache.get(file);
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(file, box.exports);
const near = (m) => {
const stub = STUBS[m] ?? STUBS[path.basename(m)];
if (stub) return stub;
if (m.startsWith(".")) return load(path.join(path.dirname(file), `${m}.ts`));
throw new Error(`안 막은 import: ${m}`);
};
new Function("exports", "module", "require", js)(box.exports, box, near);
return box.exports;
};
class Store {
constructor() {
this.map = new Map();
}
get length() {
return this.map.size;
}
key(i) {
return [...this.map.keys()][i] ?? null;
}
getItem(k) {
return this.map.has(k) ? this.map.get(k) : null;
}
setItem(k, v) {
this.map.set(k, String(v));
}
removeItem(k) {
this.map.delete(k);
}
}
const session = new Store();
globalThis.window = { sessionStorage: session, localStorage: new Store() };
globalThis.location = { reload: () => calls.push(["reload"]) };
const state = load(path.join(ROOT, "A00_Common", "b_page_state.ts"));
const { resetDesign } = load(path.join(ROOT, "A00_Common", "design", "design_reset.ts"));
const P = "p418";
const OTHER = "q418";
const SEED = {
"b08-materials": { units: { a: 1 } },
"b08-hydro": { "0+020": { area: 1 } },
"b08-options": { soft_rock_pct: 10 },
"b08-milestone": { site: "x" },
"b09-estimation": { estimate: { b: 2 } },
"route-draft": { rev: 3 },
"std-cross": { width: 4 },
};
for (const [name, value] of Object.entries(SEED)) {
state.writeState(name, value, P);
state.writeState(name, value, OTHER);
}
state.writeState("crossdesign", { "0+020": "cut" }, P, 7); // 노선 범위 B06 초안
(async () => {
await resetDesign(P, 7);
const left = {};
for (const name of [...Object.keys(SEED), "crossdesign"]) {
left[name] = state.readState(name, P, name === "crossdesign" ? 7 : undefined);
}
const other = Object.fromEntries(Object.keys(SEED).map((n) => [n, state.readState(n, OTHER)]));
process.stdout.write(JSON.stringify({ left, other, seed: SEED, calls }));
})().catch((error) => {
process.stderr.write(String(error?.stack ?? error));
process.exit(1);
});
@@ -0,0 +1,107 @@
/* 421-2 추천 창 「못 지킨 구간」 · 원지반 기울기(참고) · 시종점 알림 헬퍼 — `_Initial_Model` 을
* 그 자리에서 트랜스파일해 Node 로 돌려 줄 글을 JSON 한 벌로.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
_map: map,
};
}
const session = memoryStorage();
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
globalThis.fetch = () => Promise.resolve({ ok: false });
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@ui/"))
return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
const nodes = [
{ x: 0, y: 0 },
{ x: 10, y: 5 },
];
const item = (kind, station, value, limit, reason) => ({ kind, station, value, limit, reason });
const now = model.normalizeRouteInitial(
{
nodes,
curves: [],
metrics: {
max_grade_pct: 35.8,
over_limit: { grade: 2, radius: 1, station: 1, stream: 0, total: 4 },
violations: [
item("grade", "No.1", 28.8, 14, "원지반 기울기 — 참고"),
item("radius", "No.3", 10, 12, "지형상 불가능 — 곡선"),
item("grade", "No.4", 18.9, 14, "원지반 기울기"),
item("station", "No.5+3.0", 3, 0, "측점 밖"),
],
},
ends_grade_pct: 16.24,
ends_over_limit: true,
},
"earthwork",
null,
);
const clean = model.normalizeRouteInitial(
{
nodes,
curves: [],
metrics: {
max_grade_pct: 12,
over_limit: { total: 1, grade: 1 },
violations: [item("grade", "No.2", 15, 14, "")],
},
ends_grade_pct: 3,
ends_over_limit: false,
},
"follow",
null,
);
const old = model.normalizeRouteInitial(
{ nodes, curves: [], metrics: { max_grade_pct: 9, over_limit: { total: 3 } } },
"gentle",
null,
);
const lines = (choice) => ({
violations: model.violationsLine(choice.metrics),
ground: model.groundGradeLine(choice.metrics),
groundTitle: model.groundGradeTitle(choice.metrics),
ends: model.endsLine(choice),
over: choice.metrics.overLimitCount,
route: model.metricsLines(choice.metrics).route,
});
process.stdout.write(JSON.stringify({ now: lines(now), clean: lines(clean), old: lines(old) }));
@@ -0,0 +1,91 @@
/* 421 추천 창 「종단 반영」 셈 헬퍼 — `_Initial_Model` 을 그 자리에서 트랜스파일해 Node 로 돌려
* 종단 반영 값이 서버 답 · 보낸 값 · 옛 캐시 · 서버 기록에서 어떻게 정해지는지 JSON 한 벌로.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
_map: map,
};
}
const session = memoryStorage();
globalThis.window = { localStorage: memoryStorage(), sessionStorage: session };
globalThis.fetch = () => Promise.resolve({ ok: false });
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@ui/"))
return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const model = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"));
const answer = {
nodes: [
{ x: 0, y: 0 },
{ x: 10, y: 5 },
],
curves: [],
};
const fromSent = model.normalizeRouteInitial(answer, "earthwork", null, null, false);
const fromServer = model.normalizeRouteInitial(
{ ...answer, with_profile: true, unsnapped: { planned: [[0, 0]] } },
"earthwork",
null,
null,
false,
);
const oldFollow = model.normalizeRouteInitial(answer, "follow", null);
const oldEarth = model.normalizeRouteInitial(answer, "earthwork", null);
const base = model.serverBase({ logic: "gentle", with_profile: false });
const oldBase = model.serverBase({ logic: "gentle" });
model.writeInitialChoice("p421", fromSent);
const readBack = model.readInitialChoice("p421");
process.stdout.write(
JSON.stringify({
sent: fromSent.withProfile,
server: fromServer.withProfile,
serverHasUnsnapped: "unsnapped" in fromServer,
oldFollow: model.choiceWithProfile(oldFollow),
oldEarth: model.choiceWithProfile(oldEarth),
base: base.withProfile,
oldBase: oldBase.withProfile ?? null,
baseProfile: model.choiceWithProfile(base),
oldBaseProfile: model.choiceWithProfile(oldBase),
sameOff: model.sameBase({ ...fromSent, logic: "gentle" }, base),
sameOn: model.sameBase({ ...fromServer, logic: "gentle" }, base),
sameOld: model.sameBase({ ...fromServer, logic: "gentle" }, oldBase),
readBack: readBack && readBack.withProfile,
}),
);
@@ -41,7 +41,8 @@ function loadTs(file) {
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.endsWith(".css")) return {};
if (request.startsWith("@ui/")) return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
if (request.startsWith("@ui/"))
return loadTs(path.join(ROOT, "ui_template", `${request.slice(4)}.ts`));
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
@@ -173,12 +174,13 @@ const labels = {
violationsOld: model.violationsLine({ ...plain.metrics, violations: null }),
violationsMany: model.violationsLine({
...plain.metrics,
// 못 지킨 곳(R) 일곱 — 원지반 기울기는 참고 줄이라 이 줄에 안 듦(421-2)
violations: Array.from({ length: 7 }, (_, i) => ({
kind: "grade",
kind: "radius",
station: `No.${i}+0.0`,
value: 15 + i,
limit: 14,
reason: "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음",
value: 5 + i,
limit: 12,
reason: "곡선 둘 자리 모자람 — 교점 사이가 짧음",
})),
}),
relaxed: model.relaxedLine(plain),
@@ -215,18 +217,7 @@ const sameAsServer = {
weightedOther: model.sameBase(planShape, bases.weighted),
};
// ⑥ 전처리 초기값 노선(`/route/plan` 의 `initial_route`)과 미리 보기 지도 선.
const initialRoute = {
ok: model.initialRouteSnapshot({
nodes: [
{ x: 0, y: 0 },
{ x: 10, y: 5, radius_m: 20 },
],
curves: [],
}),
none: model.initialRouteSnapshot(undefined),
tooShort: model.initialRouteSnapshot({ nodes: [{ x: 0, y: 0 }], curves: [] }),
};
// ⑥ 미리 보기 지도 선.
const mapLines = {
fit: model.mapPaths(
[
@@ -548,7 +539,6 @@ process.stdout.write(
cleared,
bases,
sameAsServer,
initialRoute,
mapLines,
historyRecs,
sweep,
+4 -1
View File
@@ -60,7 +60,10 @@ def test_칸마다_그림_강조(out):
assert active["cut_rock"] == ["b06-chart__area--cut_rock"]
assert active["embankment"] == ["b06-chart__area--fill"]
assert "spoil_fill" not in out["targets"] # 사토장 횡단 그림 없음(380-1)
assert active["ground"] == ["b06-chart__center"]
# 413 — 원지반고 = 원지반 십자 · 계획고 = 계획 십자 · 절성토고 = 둘 다
assert active["ground"] == ["b06-chart__ground-center"]
assert active["planned"] == ["b06-chart__center"]
assert active["cut"] == ["b06-chart__ground-center", "b06-chart__center"]
assert active["ditch_soil"] == ["ditch"]
assert active["grading_cut"] == ["cutSlope"]
assert active["grading_fill"] == ["fillSlope"]
+3 -1
View File
@@ -56,6 +56,8 @@ def test_축_고정_도형_층만_움직임_눈금_다시(out):
assert out["restoredTicksShifted"] is True # 다시 떠도 이어 간 범위로
# 확대 — 십자는 중심(319,142 · 칸 가운데)에서 배율 되돌림
assert out["centerTransform"].startswith("translate(319 142) scale(0.869")
# 413 — 원지반 십자도 자기 중심(319,200)에서 배율 되돌림
assert out["groundTransform"].startswith("translate(319 200) scale(0.869")
assert out["noAxesHandle"] is True # 축 옵션 없음 = 줌만
assert out["noLayer"] is None # 도형 층 없음 = 줌 없음
@@ -105,7 +107,7 @@ def test_강조_붉은_한_벌_고름_hover_다름_빈칸():
for selector in (
".b06-detail__cad .b06-chart__area.is-active > polygon {\n fill: color-mix(in srgb, var(--color-danger)",
".b06-detail__cad .b06-chart__design-hit.is-active {\n stroke: color-mix(in srgb, var(--color-danger)",
".b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);",
".b06-detail__cad .b06-chart__ground-center.is-active line,\n.b06-detail__cad .b06-chart__center.is-active line {\n stroke: var(--color-danger);",
".b06-detail__qtable .is-picked {\n background: color-mix(in srgb, var(--color-danger) 18%",
".b06-detail__qtable [data-key][data-empty] {\n cursor: default;",
):
+23 -4
View File
@@ -96,7 +96,16 @@ def test_no_overwrite(tmp_path: Path) -> None:
assert not write_stored(tmp_path, "k", "p", {}, {"n": 2}, False)
data = json.loads((store_dir(tmp_path) / "k.json").read_text(encoding="utf-8"))
assert data["result"] == {"n": 1} and data["default"] is True
assert set(data) == {"version", "key", "pool", "default", "created_at", "request", "result"}
assert set(data) == {
"version",
"source",
"key",
"pool",
"default",
"created_at",
"request",
"result",
}
def test_survives_reset_and_goes_with_data_swap(tmp_path: Path) -> None:
@@ -117,6 +126,8 @@ def test_survives_reset_and_goes_with_data_swap(tmp_path: Path) -> None:
class _Calls:
def __init__(self) -> None:
self.count = 0
self.inputs: list[dict] = []
self.standard: dict | None = None
@pytest.fixture
@@ -141,20 +152,26 @@ def wired(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> _Calls:
async def surface_params(_connection, _pid):
return {}
async def project_standard(_pid):
return calls.standard
class _Pool:
@asynccontextmanager
async def acquire(self):
yield None
def generate(expected, terrain, streams, *, pipes, **_inputs):
def generate(expected, terrain, streams, *, pipes, **inputs):
assert pipes is None # 관은 키 밖 — 덮개 없이 셈해 남김
calls.count += 1
calls.inputs.append(inputs)
return {
"planned": [[x, y] for x, y in expected],
"metrics": {
"length_m": 38.0,
"offset_avg_m": 0.0,
"profile": {
"cut_m3": 10.0,
"fill_m3": 4.0,
"stations_m": [0.0, 38.0],
"plan_m": [1.0, 1.0],
"ground_m": [0.0, 0.0],
@@ -167,6 +184,7 @@ def wired(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> _Calls:
monkeypatch.setattr(replan, "_road_settings", road_settings)
monkeypatch.setattr(replan, "_vertices_of", lambda _path: [list(p) for p in EXPECTED])
monkeypatch.setattr(router, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(router, "project_standard", project_standard)
monkeypatch.setattr(surface, "get_surface_confirmation_params", surface_params)
monkeypatch.setattr(surface, "analysis_terrain_params", lambda selection, _t: selection)
monkeypatch.setattr(grade, "legal_grade_limit_pct", lambda *_a: 14.0)
@@ -225,7 +243,8 @@ def test_precompute_defaults_then_hits(wired: _Calls) -> None:
]
assert sorted(item["request"]["mode"] for item in saved) == sorted(DEFAULT_MODES)
assert all(item["default"] for item in saved)
# 화면 첫 누름(비중은 화면이 50/25/25 를 채워 보냄 · 추적은 종단 끔) — 셈 없이 저장본
# 화면 첫 누름(비중은 화면이 50/25/25 를 채워 보냄 · 다섯 갈래 종단 켬 · 420) — 셈 없이 저장본
assert all(item["request"]["with_profile"] for item in saved)
hit = _ask(mode="weighted", weights={"expected": 50, "earthwork": 25, "grade": 25})
follow = _ask(mode="follow", with_profile=False)
follow = _ask(mode="follow")
assert hit["stored"] and follow["stored"] and wired.count == 5
@@ -40,9 +40,9 @@ def runs() -> dict:
caught: dict = {}
real = engine.build_pool
def spy(graph, follow, max_grade, min_radius_m, measure, snap):
def spy(graph, follow, max_grade, min_radius_m, measure, snap, **kw):
caught["measure"] = measure
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap)
caught["pool"] = real(graph, follow, max_grade, min_radius_m, measure, snap, **kw)
return caught["pool"]
engine._POOLS.clear()
+34 -6
View File
@@ -135,15 +135,43 @@ def test_center_wired_into_card_and_modal() -> None:
assert "centerZ: section.center_z ?? null" in _src(
"B06_Section", "B06_Section_UI_Section_View.ts"
)
# 379-3 계획 상자 = 십자선 계획고(카드 designElevation · 모달 cardDesignElevation)
assert "designZ: hasDesign ? (designElevation as number) : null" in _src(
"B06_Section", "B06_Section_UI_Cross_View.ts"
)
assert "designZ: cardDesignElevation(detail, section) ?? null" in _src(
"B06_Section", "B06_Section_UI_Section_View.ts"
# 379-3 계획 상자 = 십자선 계획고(카드 designZ · 모달 cardDesignElevation)
# 413 — 십자선 · 계획 상자 = 그 측점 설계 높이(형상과 한 값) · 설계 없을 때만 종단 표본
card = _src("B06_Section", "B06_Section_UI_Cross_View.ts")
assert "const designZ = section.design?.design_elevation_m ?? designElevation;" in card
assert "designZ: hasDesign ? (designZ as number) : null" in card
assert "designElevation: hasDesign ? (designZ as number) : null" in card
assert "groundElevation: section.center_z ?? null" in card
view = _src("B06_Section", "B06_Section_UI_Section_View.ts")
assert "designZ: cardDesignElevation(detail, section) ?? null" in view
assert (
"if (section.design?.design_elevation_m !== undefined) return section.design.design_elevation_m;"
in view
)
def test_two_crosshairs(result: dict) -> None:
"""413 — 원지반 십자(원지반 색 · 다른 무리) 먼저 · 계획 십자 나중(위) · 높이 = 설계 · 원지반."""
cross = result["crosshair"]
assert cross["order"] == ["b06-chart__ground-center", "b06-chart__center"]
assert abs(cross["design_y"] - cross["want_design_y"]) < 1e-6 # 설계 높이(종단 표본 아님)
assert abs(cross["ground_y"] - cross["want_ground_y"]) < 1e-6 # center_z
assert cross["design_only_ground"] is None # 설계 없음 = 십자 하나(지반 폴백)
assert cross["scaled"] == cross["want_scaled"] # 둘 다 자기 중심에서 배율 되돌림
areas = _src("B06_Section", "B06_Section_UI_Style_Cross_Areas.css")
assert "var(--color-chart-1)" in _rule(areas, ".b06-chart__ground-center-marker")
def test_draft_detail_profile_is_draft(result: dict) -> None:
"""413 — 평면 초안 상세의 계획선 표본 = 초안 종단(저장 종단 아님) · 이름은 저장분 그대로."""
draft = result["draftProfile"]
assert draft["samples"] == [
{"chainage_m": 0, "elevation_m": 110},
{"chainage_m": 20, "elevation_m": 112},
]
assert draft["id"] == "old"
# ── 318-2 · 318-3 양식 ──────────────────────────────────────────────────────
+2 -2
View File
@@ -6,7 +6,7 @@
- 쓰기 = 측점 열쇠(grid · pipe · structure) · 부르는 쪽은 누가거리로 읽음 · 암 경계 저장소도 같음.
- 구조물 id 를 아직 모를 때(첫 열기)도 쓴 때 자리로 그 측점 고침을 읽음.
- 노선이 바뀜 = 측점 열쇠로 넘김(관 · 구조물은 옮긴 자리로) · 넘김 = 카드 선택 넷 · 암 경계 ·
단면 유형 · 측구 방향 · 포장 · 노견 확장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」.
노견 확장(417) · 단면 유형 · 측구 방향 · 포장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」.
- cross 단계: 같은 입력 다시 셈 0 · 측점 하나 고침 = 1 · 범위 밖 재사용.
- masshaul 단계 = 계열 + 흙 배분 입력 · haul 단계 = 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로.
@@ -105,7 +105,7 @@ def test_노선_바뀜_측점_열쇠로_넘김(out):
"two_stage_slope": True,
}
assert out["carriedRock"] == {"20.00": -0.8}
assert out["carriedShoulder"] == []
assert out["carriedShoulder"] == ["grid:2"] # 사면 쉬프트도 넘김(417)
def test_cross_단계_국부_연산(out):
@@ -33,7 +33,9 @@ def test_칸_자리와_재료():
def test_편집기가_칸_요소를_밖에서_받음():
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
assert "overlay" not in initial
assert "const { recommendButton, resetButton, nowLine } = params.controls;" in initial
assert (
"const { recommendButton, nowLine } = params.controls;" in initial
) # 칸 [초기화] 지움(418)
assert "current: params.planned().map(([x, y]) => [x, y])," in initial # 지금 노선
main = _read("B05_Profile/B05_Profile_UI_RouteEdit.ts")
assert "controls: options.recommend," in main
@@ -79,10 +79,14 @@ def test_초안_넘김_차례(out):
"invalidate:9",
"load:9",
"keys:9:9",
"pipes", # 관 열쇠 → 새 자리(서버가 옮긴 관)
"carry:7->9:quiet",
"carryAt:7->9", # 417 — 조절 · 측점 폭 · 상세 표도 새 노선 측점으로
"adjusts:9",
"edits:9", # 옮긴 고침을 새 상세 설계 칸에 → 같은 [저장] 횡단 패치
"crossSave:9",
]
assert out["outside"] == 2
assert out["outside"] == 3 # 측점 열쇠 2 + 누가거리 열쇠 1
def test_추천_적용은_as_initial_저장_뒤_다시_세움(out):
@@ -90,7 +94,8 @@ 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 out["recommendCalls"] == ["overlay:on", "replan", "overlay:off"] # 잠금 막 하나
# 잠금 막 하나 · 저장 직전 저장 관 떠 둠(417 — 저장 뒤 같은 관 id 새 자리로 조절 옮김)
assert out["recommendCalls"] == ["overlay:on", "pipes", "replan", "overlay:off"]
assert out["recommendDraftsLeft"] == {"route": None, "pipes": None} # 새 초기값에서 다시
assert out["rebuilt"] == 1
@@ -129,7 +134,7 @@ def test_넘김_알림은_결과_한_줄에_합침():
def test_노선_반영_글자_없음():
recommend = _read("B05_Profile/B05_Profile_UI_Panel_Recommend.ts")
assert "노선 반영" not in recommend and "다시 계산은 [저장]에서" in recommend
assert "노선 반영" not in recommend # 칸 [초기화](「다시 계산은 [저장]에서」 글) 지움(418)
for rel in (
"B05_Profile/B05_Profile_UI_RouteEdit.ts",
"B05_Profile/B05_Profile_UI_RouteEdit_History.ts",
+9 -14
View File
@@ -3,8 +3,8 @@
./venv/Scripts/python.exe -m pytest resources/tester/test_351_2_curve_stations.py -q
모든 곡선 MC % 20 ≈ 0 · 이웃 MC 20 m 이상 · 곡선 수 ≤ 내부 일반측점 수 · 맞추기 전 선에서
`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과의
기울기 넘은 곳이 맞추기 전보다 늘지 않음 · 검증_sub1 예상노선 8벌 수치.
`ROUTE_STATION_SNAP_MAX_GAP_M`(6 m) 안 · 화면이 펴서 다시 짜도(저장 반올림) 그대로 · 추천 결과는
측점에 맞춘 선만(맞추기 전 선 안 냄 · 419) · 검증_sub1 예상노선 8벌 수치.
"""
import math
@@ -142,11 +142,8 @@ def test_recommend_result_on_stations(hill_runs: dict, mode: str) -> None:
if mode != "follow": # 갈래는 기울기 상한 안(언덕은 비켜 갈 수 있음)
assert result["metrics"]["over_limit"]["total"] == 0
assert result["metrics"]["max_grade_pct"] <= 14.0 + 1e-6
# 측점 맞춤 끔 = 맞추기 전 선(같으면 None) — 비교값 따로
raw = result["unsnapped"]
if raw is not None:
assert raw["planned"] != result["planned"] and raw["metrics"]["length_m"] > 0
assert set(raw) == {"planned", "nodes", "curves", "off_station_curves", "metrics"}
# 측점 맞춤 필수 — 맞추기 전 선은 안 냄(419)
assert "unsnapped" not in result
def _project_terrain():
@@ -171,7 +168,7 @@ def _project_terrain():
def test_project_modes_before_after() -> None:
"""검증_sub1 실지형(14 % · R12) — 갈래별 측점 맞춤 전후 넘은 곳 · 최대 기울기 · 측점 밖 곡선."""
"""검증_sub1 실지형(14 % · R12) — 갈래별 맞춘 선의 넘은 곳 · 최대 기울기 · 측점 밖 곡선(421)."""
if not (PROJECT / "B05_Profile/route/cost_surface_classification_dtm_smooth.npz").is_file():
pytest.skip("검증_sub1 storage 없음")
terrain, streams = _project_terrain()
@@ -189,17 +186,15 @@ def test_project_modes_before_after() -> None:
min_radius_m=12.0,
with_profile=False,
)
raw = result["unsnapped"] or result
after, before = result["metrics"], raw["metrics"]
after = result["metrics"]
curves = len(result["curves"])
on_total += curves - result["off_station_curves"]
curves_total += curves
print(
f"{mode}: 넘은 곳 {before['over_limit']['total']} → {after['over_limit']['total']}"
f" · 최대 기울기 {before['max_grade_pct']:.1f} → {after['max_grade_pct']:.1f} %"
f" · 곡선 {len(raw['curves'])} → {curves}(측점 밖 {result['off_station_curves']})"
f"{mode}: 넘은 곳 {after['over_limit']['total']}"
f" · 최대 기울기 {after['max_grade_pct']:.1f} %"
f" · 곡선 {curves}(측점 밖 {result['off_station_curves']})"
)
assert raw["off_station_curves"] >= result["off_station_curves"]
print(f"측점 위 곡선 {on_total}/{curves_total} = {100 * on_total / curves_total:.0f}%")
assert on_total / curves_total >= 0.75
@@ -90,6 +90,7 @@ def test_모달이_여는_갈래_먼저(monkeypatch) -> None:
async def compute(_pid, request):
asked.append(request.mode)
assert request.with_profile # 다섯 갈래 모두 종단 켬(420)
return {}
monkeypatch.setattr(router.replan, "_project_paths", paths)
+13 -8
View File
@@ -61,7 +61,7 @@ def test_반올림이_교차_앞이어도_다음_걸음은_넘어감(out) -> Non
def test_손잡이_없는_넘어감도_고름_유지(out) -> None:
"""370-5 — 넘어간 사면이 원지반과 못 만나 손잡이가 사라져도 같은 쪽 사면 선 · 암 칸으로 안 넘어감."""
"""370-5 — 바뀜 자리 손잡이를 고른 뒤 사면이 원지반과 못 만나도 같은 쪽 사면 선 · 암 칸으로 안 넘어감."""
got = out["noToe"]
assert got["toeGone"] is True
assert got["index"] is not None
@@ -70,23 +70,28 @@ def test_손잡이_없는_넘어감도_고름_유지(out) -> None:
assert got["buttons"] == ["left", "reset", "right"] # ◀↺▶ 남음
def test_교차점_손잡이_쪽마다_늘_보임(out) -> None:
def test_교차점_손잡이는_바뀜_자리만(out) -> None:
"""414-1 — 원 = 그 쪽 노견 끝 = 원지반(±5 mm · `bare`)일 때만 · 보통 단면은 원 0."""
toe = out["toe"]
assert toe["count"] == len(toe["sides"]) == 2
assert toe["nearSides"] == ["left", "right"] # 사면 ∩ 원지반 자리는 있어도
assert toe["nearCount"] == 0 # 보통 단면(절 · 성 정해짐) = 원 0
assert toe["count"] == len(toe["bareSides"]) == 1 and toe["bareSides"] == ["right"]
assert toe["centerClass"] is True # 중심점 원과 같은 고르기 차례
assert toe["rightOnGround"] is True
assert abs(toe["rightCx"] - toe["rightX"]) < 1e-9
def test_손잡이를_고르면_그_쪽_사면_좌우_이동(out) -> None:
def test_손잡이를_고르면_좌우로_절성토_넘김(out) -> None:
"""414-1 — 바뀜 자리 원을 고르면 ▶(바깥) = 성토 · ◀(안쪽) = 절토."""
got = out["toePick"]
assert got["active"] is True and got["panelOpen"] is True
assert got["title"] == "사면 이동 (우)" # 아래 띠 공용 칸(370-3)
assert got["value"].startswith("노선에서 ")
assert len(got["sent"]) == 1 and got["sent"][0]["side"] == "right"
assert got["value"].startswith("절·성토 없음 · 노선에서 ")
assert [c["side"] for c in got["sent"]] == ["right", "right"]
low, high = out["stop"]["crossing"]
assert abs(got["sent"][0]["valueM"] - out["stop"]["value"]) < 1e-9 # ◀▶ 도 멈춤 자리
assert low < got["sent"][0]["valueM"] < high
outward, inward = (c["valueM"] for c in got["sent"])
assert outward >= high and inward <= low # 멈춤 자리에서 다시 안 섬
assert got["modes"] == ["left_cut", "both_cut"] # 우 = 성토 → 절토
def test_절에서_성으로_넘어가도_고름_유지(out) -> None:
@@ -23,12 +23,23 @@ def test_노드는_라벨_뒤_맨_위_회전_안():
scene = render[render.index("export function drawRouteEditScene(") :]
scene = scene[: scene.index("context.restore(); // 회전 끝")]
labels = scene.index("drawContourLabels(context, scene, band, style);")
for later in ("scene.planned.forEach(", "scene.curveInfo.forEach(", "drawPickHover("):
for later in ("scene.planned.forEach(", "drawPickedCurve(", "drawPickHover("):
assert labels < scene.index(later), later
for earlier in ("drawStationMarks(", "scene.overStations?.(", "drawMeasureMarks("):
assert scene.index(earlier) < labels, earlier
def test_곡선_네모는_예상노선_뒤_노선_앞():
"""414-2 — 모든 곡선 BC · EC 네모 = 예상노선 뒤 · 노선 앞(노선이 네모 위를 지나감)."""
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
scene = render[render.index("export function drawRouteEditScene(") :]
expected = scene.index("scene.expected,")
squares = scene.index("scene.curveInfo.forEach(")
route = scene.index("strokePolyline(", squares)
assert expected < squares < route
assert scene[route:].split(",")[2].strip() == "line"
def test_집기_맴돔_한_함수():
pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
assert "pickAtScreen(" in pointer
@@ -100,7 +100,7 @@ def test_follow_nodes_equal_initial(tmp_path: Path) -> None:
assert all(abs(a[0] - b[0]) <= 1e-3 and abs(a[1] - b[1]) <= 1e-3 for a, b in zip(nodes, saved))
misses = [v for v in result["metrics"]["violations"] if v["kind"] == "station"]
assert len(misses) == result["metrics"]["over_limit"]["station"] == result["off_station_curves"]
assert result["unsnapped"] is not None # 「측점 맞춤」 끔 = 맞추기 전 선
assert "unsnapped" not in result # 측점 맞춤 필수 — 맞추기 전 선 안 냄(419)
def test_old_copy_flag_and_line_unchanged(tmp_path: Path) -> None:
+4 -4
View File
@@ -1,4 +1,4 @@
"""383-3 노선 추천 모달 [재계산] — 측점 맞춤 오른쪽 같은 줄 · 늘 보임 · 바꾼 것 있을 때만 켜짐.
"""383-3 노선 추천 모달 [재계산] — 종단 반영 오른쪽 같은 줄 · 늘 보임 · 늘 켜짐(421).
./venv/Scripts/python.exe -m pytest resources/tester/test_383_3_rerun_row.py -q
"""
@@ -10,11 +10,11 @@ ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
def test_재계산_한_줄_늘_보임_바꿨을_때만_켜짐() -> None:
def test_재계산_한_줄_늘_보임_늘_켜짐() -> None:
ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
assert re.search(r'<div class="b05-routerec__row"><button[^>]*data-act="rerun">재계산', ts)
assert 'data-act="rerun" hidden' not in ts and "rerun.hidden" not in ts
assert "rerun.before(snapToggle)" in ts # 토글이 같은 줄 왼쪽
assert "rerun.disabled = busy || !dirty || invalid.size > 0;" in ts
assert "rerun.before(profileToggle)" in ts # 종단 반영 토글이 같은 줄 왼쪽(421)
assert "rerun.disabled = busy || invalid.size > 0;" in ts
css = (B05 / "B05_Profile_UI_Style_RouteRecommend.css").read_text(encoding="utf-8")
assert re.search(r"\.b05-routerec__row \{\s*display: flex;", css)
+13 -12
View File
@@ -12,11 +12,7 @@ import numpy as np
from shapely.geometry import LineString, Point
from B05_Profile.B05_Profile_Engine_RouteInitial import generate_initial_route
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import (
GRADE_NOISE,
Guard,
merge_same_side,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import Guard, merge_same_side
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, widen
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import _Reference
@@ -73,16 +69,18 @@ def test_s_curve_keeps_straight() -> None:
assert _flat(merged)[0] == _flat(_outline(S_CURVE))[0]
def test_valley_grade_blocks() -> None:
"""옛 선 = 골짜기 바닥(평평) · 합친 곡선은 옆 비탈을 올라 기울기 상한을 넘음."""
def test_valley_earth_blocks() -> None:
"""옛 선 = 골짜기 바닥 · 합친 곡선은 옆 비탈을 올라 토공 가늠이 늘어 막힘(419 — 원지반 기울기는
참고만이라 기울기로는 안 막음 · 넘는 만큼은 잼에 남음)."""
distance = _distance(_outline(SAME))
guard = _guard(SAME, z=lambda xx, yy: 3.0 * distance(xx, yy))
assert len(_merge(SAME, guard).curves) == 2
# 막은 까닭 = 기울기 — 합친 꼴 조각이 옛 자리보다 상한을 더 넘음
cross = _line_intersection(*SAME)
piece = guard.piece(SAME[0], cross, SAME[-1], 40.0)
window = _Reference(_outline(SAME).vertices).window(piece)
assert guard._look(piece)[0] > guard._look(window)[0] + GRADE_NOISE
passed, (out, along, earth) = guard.judge(piece, window)
assert not passed and not out and along == 0.0 and earth > 0.0
assert guard._look(piece)[0] > guard._look(window)[0] # 기울기는 참고로 잼
def test_outside_corridor_blocks() -> None:
@@ -111,9 +109,12 @@ def test_stream_buffer_blocks() -> None:
def test_route_yardstick_blocks() -> None:
"""노선 전체 잣대(넘은 곳 · 최대 기울기 · 절성토)가 합치기 전보다 나빠지면 안 합침."""
worse = {**FLAT, "max_grade_pct": 5.0}
assert len(_merge(SAME, _guard(SAME), measure=lambda line: worse).curves) == 2
"""노선 전체 잣대(못 지킨 곳 · 절성토 +10 %)가 합치기 전보다 나빠지면 안 합침 · 최대 기울기는
참고만(419)."""
steeper = {**FLAT, "max_grade_pct": 5.0}
assert len(_merge(SAME, _guard(SAME), measure=lambda line: steeper).curves) == 1
over = {**FLAT, "over_limit": {"total": 1, "grade": 0}}
assert len(_merge(SAME, _guard(SAME), measure=lambda line: over).curves) == 2
def test_widen_guard_larger_than_gap_limit() -> None:
+97
View File
@@ -0,0 +1,97 @@
"""PLAN 416 sub5 — 한 페이지 끊김 ② : 저장 뒤 새 자료(M1) · 관 길이 출처(M5) · 오른쪽 판 노선 id(L2) · 암선 기본 config.
짝: `helper_416_page_cache.cjs`(진짜 상세 가게 · 평면 횡단 판 자료 · 관 길이 · 페이지 노선 칸 · 서버 · 그림 가짜).
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def r() -> dict:
proc = subprocess.run( # noqa: S603, S607 — 고정 실행 파일
["node", str(HERE / "helper_416_page_cache.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_L2_판은_페이지_노선_상세(r):
assert r["l2"]["station"] == "b05-cur-r3-40" # 캐시에 더 큰 노선 9 가 있어도
assert r["l2"]["noRoute"] is None and r["l2"]["exact"] is True
assert r["l2"]["draftNoSaved"] is None # 노선 초안 중 = 저장 상세 안 씀
def test_암선_기본은_config(r):
assert r["rock"] == {"pending": -0.5, "config": -0.8}
src = _read("B05_Profile/B05_Profile_UI_RouteEdit_Cross_Card.ts")
assert "readRockBoundaryDefault(projectId)" in src
assert "?? -0.5" not in src
def test_M5_관_길이는_공용_캐시(r):
assert r["m5"] == {"before": 8, "cleared": None, "after": 11}
page = _read("A00_Common/design/design_page.ts")
assert "peekSectionDetail(projectId, pageRouteId(projectId))?.cross_sections" in page
found = subprocess.run( # noqa: S603, S607 — 고정 실행 파일
["git", "grep", "-l", "setPipeSectionsSource(", "--", "*.ts"],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
).stdout.split()
# 거는 곳 = 페이지 하나(보기마다 걸면 마지막 연 보기 사본)
assert sorted(found) == [
"A00_Common/design/design_page.ts",
"A00_Common/design/design_structure_pipe_calc.ts",
]
def test_M5_횡단_다시_셈_뒤_좌측_폼_다시_그림():
shared = _read("A00_Common/design/design_shared.ts")
body = shared[shared.index("function crossDesignsUpdated(") :]
body = body[: body.index("\n}\n")]
assert "shared.structures?.facility.recount()" in body
# 종단도 알림 · 저장 노선 다시 셈 두 길 다 이 함수로
assert "onCrossDesignsUpdated: (updated) => crossDesignsUpdated(shared, updated)" in shared
assert "if (updated.length) crossDesignsUpdated(shared, updated);" in shared
assert 'toAll(shared, "onCrossDesignsUpdated")' in body
def test_M1_비운_뒤_두_보기는_새_객체_하나(r):
assert r["m1"] == {
"sameObject": True,
"fetched": 1,
"fresh": True,
"panelSeesFresh": "b05-cur-r3-40",
}
def test_M1_저장_끝_한_번_비우고_종단_다시_읽음():
shared = _read("A00_Common/design/design_shared.ts")
done = shared[shared.index("done: async (isConfirm)") :]
assert done.index("invalidateSectionDetail(shared.projectId)") < done.index("profileSave?.done")
actions = _read("B05_Profile/B05_Profile_UI_Page_Actions.ts")
tail = actions[actions.index("done: async") :]
assert tail.index("ctx.loadLatest(true)") < tail.index("ctx.restoreSections(routeId)")
assert "invalidateSectionDetail" not in actions
persist = _read("B06_Section/B06_Section_UI_Page_Persist.ts")
assert (
"invalidateSectionDetail" not in persist
) # 보기마다 비우면 앞 보기가 받은 새 상세를 또 버림
+179
View File
@@ -0,0 +1,179 @@
# -*- coding: utf-8 -*-
"""417 — 노선 바뀐 [저장] 때 B06 조절 · 사면 쉬프트 · 소단 깊이 옮김(M2) · 평면 초안 횡단 입력
(M3 · L4).
못박는 것
- 브라우저: 측점 열쇠 칸(사면 쉬프트 · 소단 깊이) · 누가거리 열쇠 칸(옹벽 · BOX · 측점 폭 ·
상세 표 · 연동)이 새 노선 측점으로 옮겨 감 · 구조물 조절은 관 · 구조물 열쇠만(일반 측점 옹벽
조절은 버림).
- 옮긴 값이 새 상세 설계 칸에 얹힘(같은 [저장] 횡단 패치가 실음).
- 평면 초안 횡단 입력 = 세션 사면 쉬프트 · 소단 깊이 + 저장분(세션 없음)도.
- 서버: `carry_cross_inputs` 가 `ROUTE_CARRY_KEYS` 를 측점 열쇠마다 넘김 · 구조물 조절은 관
열쇠만 · `compute_default_designs` 가 넘긴 사면 쉬프트로 설계선을 셈.
- 거울: 브라우저 `ROUTE_CARRY_STATES` 설계 칸 = 서버 `ROUTE_CARRY_KEYS` +
`STRUCTURE_ADJUST_KEYS` = `USER_TOUCHED_KEYS` − 소단 제원 · 암 경사(둘은 `CARRY_INPUT_KEYS`).
Node 헬퍼 `helper_417_route_carry.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_417_entry.ts`)을 부름.
"""
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B05_Profile.B05_Profile_Engine_Sections import find_cross_file # noqa: E402
from B05_Profile.B05_Profile_Route_Save_Parts import carry_cross_inputs # noqa: E402
from B06_Section.B06_Section_Engine_Design import ROUTE_CARRY_KEYS # noqa: E402
from B06_Section.B06_Section_Router_Design import ( # noqa: E402
CARRY_INPUT_KEYS,
STRUCTURE_ADJUST_KEYS,
USER_TOUCHED_KEYS,
compute_default_designs,
)
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
TS_KEYS = ROOT / "B06_Section" / "B06_Section_Station_Keys.ts"
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b417")
build = subprocess.run(
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_417_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_417_entry.js", bundle)
source = work / "input.json"
source.write_text(json.dumps({"standard": STANDARD_CROSS_SECTION}, default=str), "utf-8")
proc = subprocess.run(
["node", str(HERE / "helper_417_route_carry.mjs"), str(bundle), str(source)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_사면_쉬프트_소단_깊이_새_노선으로(out):
assert out["shoulderKeys"] == ["grid:2"]
assert out["bermKeys"] == ["grid:2"]
def test_조절은_관_따라_옮김_일반_측점_옹벽은_버림(out):
assert out["revet"] == {"47.00:outlet": {"x": 0.3, "d": None, "h": 1.5, "m": None}}
assert out["box"] == {"47.00": {"left": {"x": 0.2}}}
assert out["link"] == {"detached": {"47.00": True}, "grade": {}}
assert out["width"] == {"40.00": 6}
assert out["table"] == {"47.00": {"rows": 2}}
assert out["chainageOutside"] == 1 # 40 m 옹벽 조절(일반 측점)
def test_관_id_규칙이_갈려도_시설_자리로_옮김(out):
assert out["splitResolved"] == {"52.00:outlet": {"x": 0.3, "d": None, "h": 1.5, "m": None}}
assert out["splitDropped"] == {} # 풀이 없음 = 같은 자리 측점 없음 → 버림
# 초안으로 안 따라간 옛 저장 시설 측점 고침 = 좌표 짝 시설 측점으로
assert out["movedByPlace"] == {"52.00:outlet": {"x": 0.3, "d": None, "h": 1.5, "m": None}}
def test_새_상세_설계_칸에_얹힘(out):
d40, d47 = out["design40"], out["design47"]
assert d40["shoulder_extend_left_m"] == 0.5 and d40["berm_depth_right_m"] == 1.2
assert d40["display_half_width_m"] == 6 and "revet_adjust" not in d40
assert d47["revet_adjust"]["outlet"]["h"] == 1.5
assert d47["box_adjust"]["left"]["x"] == 0.2
assert d47["revet_link_detached"] is True and d47["detail_table"] == {"rows": 2}
def test_평면_초안_횡단_입력에_사면_쉬프트_소단_깊이(out):
assert out["input40"] == {"shoulderExtendLeftM": 0.5, "bermDepthRightM": 1.2}
# 세션 없음 = 저장분 그대로(저장 뒤 사라지지 않음 · L4)
assert out["input20"] == {"shoulderExtendRightM": 0.7, "bermDepthLeftM": 0.4}
def test_서버_이월_측점_손질_넘김_구조물_조절은_관만():
old = {"grid:2": 40.0, "pipe:p1": 45.0, "grid:3": 60.0}
new = {"grid:2": 40.0, "pipe:p1": 47.0}
designs = {
40.0: {"shoulder_extend_left_m": 0.5, "structure_note": "메모", "revet_adjust": {"h": 1}},
45.0: {"berm_depth_right_m": 1.2, "revet_adjust": {"h": 2}, "detail_table": {"r": 1}},
60.0: {"shoulder_extend_right_m": 0.3},
}
inputs, adjusts, dropped = carry_cross_inputs(
old, new, designs, (*CARRY_INPUT_KEYS, *ROUTE_CARRY_KEYS), STRUCTURE_ADJUST_KEYS
)
assert inputs == {
40.0: {"shoulder_extend_left_m": 0.5, "structure_note": "메모"},
47.0: {"berm_depth_right_m": 1.2, "detail_table": {"r": 1}},
}
assert adjusts == {47.0: {"revet_adjust": {"h": 2}}}
assert dropped == 1
def test_서버_기본_설계가_넘긴_사면_쉬프트로_셈(tmp_path):
route = tmp_path / "routes" / "r1"
(route / "longitudinal").mkdir(parents=True)
(route / "cross_sections").mkdir()
profile = [
{"chainage_m": 0.0, "elevation_m": 100.0},
{"chainage_m": 40.0, "elevation_m": 100.0},
]
relative = "routes/r1/longitudinal/longitudinal.json"
(tmp_path / relative).write_text(
json.dumps({"stations": [{"chainage_m": 20.0}], "design_profiles": [{"samples": profile}]}),
"utf-8",
)
samples = [
{"offset_m": float(o), "elevation_m": 100.0 + 0.3 * o, "valid": True}
for o in range(-15, 16)
]
cross = {"chainage_m": 20.0, "samples": samples, "uphill_side": "left"}
find_cross_file(route / "cross_sections", 20.0).write_text(json.dumps(cross), "utf-8")
plain = compute_default_designs(tmp_path, relative, [20.0])
carried = {20.0: {"shoulder_extend_right_m": 0.8, "structure_note": "메모"}}
shifted = compute_default_designs(tmp_path, relative, [20.0], carried=carried)
assert plain and shifted
design = shifted[0][1]
assert design["shoulder_extend_right_m"] == pytest.approx(0.8)
assert design["structure_note"] == "메모"
assert "shoulder_extend_right_m" not in plain[0][1]
assert design["design_line"] != plain[0][1]["design_line"] # 설계선이 쉬프트로 바뀜
def test_거울_브라우저_옮김_목록_서버_이월_열쇠():
text = TS_KEYS.read_text(encoding="utf-8")
block = re.search(r"ROUTE_CARRY_STATES = \{(.*?)\} as const", text, re.S).group(1)
rows = re.findall(r":\s*\[([^\]]*)\]", block) # 칸 이름 → [설계 칸 …]
ts_keys = {key for row in rows for key in re.findall(r'"([a-z_]+)"', row)}
assert ts_keys == set(ROUTE_CARRY_KEYS) | set(STRUCTURE_ADJUST_KEYS)
assert ts_keys == set(USER_TOUCHED_KEYS) - {"berm", "cut_slope_ratio_user"}
assert {"berm", "cut_slope_ratio_user"} <= set(CARRY_INPUT_KEYS)
+124
View File
@@ -0,0 +1,124 @@
"""418 초기화 정리(요구 254-3 · 253-6).
- 「노선 추천」 칸 [초기화] 없음 · 그 단추만 쓰던 길(편집기 handler · `initial_route`) 없음
- 좌측 아래 [초기화] — 저장 안 한 B08 · B09 초안 남김 · B05 · B06 초안만 지움(진짜 TS)
- 추천 [적용] → 고쳐 저장 → 아래 [초기화] = [적용]한 노선(진짜 `refresh_initial_snapshot`)
"""
import asyncio
import json
import subprocess
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
import pytest
from common_util.common_util_initial_snapshot import (
restore_snapshot_files,
save_initial_snapshot,
)
ROOT = Path(__file__).resolve().parents[2]
HELPER = Path(__file__).with_name("helper_418_reset_drafts.cjs")
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_노선_추천_칸에_초기화_단추_없음():
part = _read("B05_Profile/B05_Profile_UI_Panel_Recommend.ts")
assert 'data-act="reset"' not in part and "resetButton" not in part
assert part.count('data-act="recommend" disabled') == 1
initial = _read("B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts")
assert "resetButton" not in initial and "initialRouteSnapshot" not in initial
assert "initial_route" not in _read("B05_Profile/B05_Profile_Api_Replan.ts")
replan = _read("B05_Profile/B05_Profile_Router_Replan.py")
assert '"initial_route"' not in replan and "_initial_shape" not in replan
def test_아래_초기화는_B08_B09_초안_남김():
out = json.loads(
subprocess.run(
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True
).stdout
)
seed, left = out["seed"], out["left"]
for name in ("b08-materials", "b08-hydro", "b08-options", "b08-milestone", "b09-estimation"):
assert left[name] == seed[name], name
# B05 · B06 초안(프로젝트 범위 · 노선 범위)은 지움
assert left["route-draft"] is None and left["std-cross"] is None
assert left["crossdesign"] is None
# 다른 프로젝트 초안은 안 건드림
assert out["other"] == seed
assert [c[0] for c in out["calls"]] == ["resetRouteDesign", "toast", "reload"]
class _Cursor:
def __init__(self):
self.lastrowid = 0
self._rows = []
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, sql, params=None):
self._rows = [{"id": 7, "project_id": "p1"}] if "FROM routes" in sql else []
async def fetchone(self):
return self._rows[0] if self._rows else None
async def fetchall(self):
return self._rows
class _Connection:
def cursor(self, *_args, **_kwargs):
return _Cursor()
class _Pool:
@asynccontextmanager
async def acquire(self):
yield _Connection()
@pytest.fixture
def project(tmp_path: Path) -> Path:
(tmp_path / "B05_Profile/route").mkdir(parents=True)
return tmp_path
def _write_route(root: Path, tag: str) -> None:
(root / "B05_Profile/route/planned_route.csv").write_text(f"x,y\n{tag}\n", encoding="utf-8")
def _route(root: Path) -> str:
return (root / "B05_Profile/route/planned_route.csv").read_text(encoding="utf-8")
def test_추천_적용_뒤_고쳐도_아래_초기화는_적용_노선(project, monkeypatch):
from B05_Profile import B05_Profile_Router_RouteInitial as initial_router
monkeypatch.setattr(initial_router, "get_db_pool", lambda: _Pool())
# ① 자동설계 체인 직후 초기값
_write_route(project, "auto")
asyncio.run(save_initial_snapshot(_Connection(), project, 7))
# ② 추천 [적용](as_initial) — 그 노선으로 초기값 다시 뜸
_write_route(project, "applied")
asyncio.run(initial_router.refresh_initial_snapshot(uuid4(), project, 7))
# ③ 손으로 고쳐 [저장] — 초기값 그대로(as_initial 아님 · 스냅샷 안 부름)
_write_route(project, "edited")
# ④ 아래 [초기화] = 초기값 파일 복원
restore_snapshot_files(project)
assert _route(project) == "x,y\napplied\n"
# ⑤ 다른 추천으로 바꿔 [적용] → 그것이 새 초기화 자리
_write_route(project, "applied2")
asyncio.run(initial_router.refresh_initial_snapshot(uuid4(), project, 7))
_write_route(project, "edited2")
restore_snapshot_files(project)
assert _route(project) == "x,y\napplied2\n"
@@ -0,0 +1,214 @@
"""419 노선 추천 엔진 — 원지반 기울기 참고만 · 삼각 없음 · 측점 필수 · 합치기 멈춤 = 노선 토공.
./venv/Scripts/python.exe -m pytest resources/tester/test_419_1_recommend_engine.py -q
· 못 지킨 곳 수 = R · 계류 · 측점(원지반 기울기는 목록 · `grade` 수에 참고로만).
· 꺾임만(R 없는 교점) → 곡선(155° 이상도) 또는 교점 지움 · 측점 맞춤(직접 잼)도 꺾임만을 안 냄.
· 합치기 멈춤 = 노선 토공 +10 % — 바뀐 측점 횡단 차이(`cross_earthwork_delta`)를 주면 그 값으로.
· 종단 반영 켬 토공 고르기 = 줄 세운 위 3 개만 균형 종단 · 시종점 기울기 알림 · 맞추기 전 선 안 냄.
"""
import math
import numpy as np
import pytest
from B05_Profile.B05_Profile_Engine_RouteInitial import ends_grade, generate_initial_route
from B05_Profile.B05_Profile_Engine_RouteInitial_Merge import (
TOTAL_EARTH_TOL,
Guard,
RouteEarth,
merge_same_side,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import SHORTLIST, choose, hard_limits
from B05_Profile.B05_Profile_Engine_RouteInitial_Radius import _flat, curve_every_kink
from B05_Profile.B05_Profile_Engine_RouteInitial_Search import SearchGrid, Terrain
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import (
off_station_count,
snap_curves_to_stations,
)
from common_util.common_util_route_polyline import build_planned_polyline
RADIUS = 12.0
SAME = [(20.0, 20.0), (150.0, 20.0), (230.0, 70.0), (260.0, 180.0)]
def _kinks(outline) -> int:
return sum(1 for flag in _flat(outline)[1][1:-1] if not flag)
def _flat_terrain(width=802.0, height=402.0) -> Terrain:
x = np.arange(0.0, width, 2.0)
y = np.arange(0.0, height, 2.0)
xx, _ = np.meshgrid(x, y)
return Terrain(x, y, np.zeros(xx.shape), np.ones(xx.shape, dtype=bool))
def test_hard_limits_leave_grade_as_reference() -> None:
metrics = {
"over_limit": {"grade": 3, "radius": 1, "stream": 1, "total": 5},
"violations": [{"kind": "grade"}] * 3 + [{"kind": "radius"}, {"kind": "stream"}],
}
hard = hard_limits(metrics)
assert hard["over_limit"] == {"grade": 3, "radius": 1, "stream": 1, "total": 2}
assert hard["violations"] == metrics["violations"] # 목록엔 참고로 남음
def test_kink_becomes_curve_even_gentle() -> None:
"""내각 160° 꺾임만(곡선 끔) → 곡선(최소 R) · 155° 이상도 곡선 둠."""
points = [(0.0, 0.0), (100.0, 0.0), (200.0, 100.0 * math.tan(math.radians(20.0)))]
kinked = build_planned_polyline(
points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, False, True]
)
assert _kinks(kinked) == 1
fixed = curve_every_kink(kinked, RADIUS)
assert _kinks(fixed) == 0 and len(fixed.curves) == 1
assert fixed.curves[0].radius_m >= RADIUS - 1e-6 and not fixed.curves[0].violations
def test_kink_without_room_is_removed() -> None:
"""앞뒤 직선이 짧아 최소 R 이 안 드는 꺾임만 → 교점 지움(곧게)."""
points = [(0.0, 0.0), (100.0, 0.0), (103.0, 2.0), (200.0, 2.0)]
kinked = build_planned_polyline(
points, min_radius_m=RADIUS, simplify=False, curve_flags=[True, True, False, True]
)
fixed = curve_every_kink(kinked, RADIUS)
assert _kinks(fixed) == 0
assert len(_flat(fixed)[0]) == 3 # 짧은 쪽 교점 하나 빠짐
def _zigzag(seed: int) -> list[tuple[float, float]]:
rng = np.random.default_rng(seed)
points, heading = [(60.0, 200.0)], 0.0
for _ in range(10):
heading += float(rng.uniform(-0.6, 0.6))
step = float(rng.uniform(40.0, 80.0))
x, y = points[-1]
points.append((x + step * math.cos(heading), y + step * math.sin(heading)))
return points
@pytest.mark.parametrize("seed", range(6))
def test_guard_snap_no_kinks_all_on_station(seed: int) -> None:
"""직접 잼 측점 맞춤(추천 후보) — 평지 · 꺾임만 섞인 선 → 꺾임만 0 · 측점 밖 0."""
points = _zigzag(seed)
flags = [True] + [i % 3 != 1 for i in range(1, len(points) - 1)] + [True]
before = build_planned_polyline(points, min_radius_m=RADIUS, simplify=False, curve_flags=flags)
lo = np.min(points, axis=0) - 150.0
hi = np.max(points, axis=0) + 150.0
x = np.arange(lo[0], hi[0], 2.0)
y = np.arange(lo[1], hi[1], 2.0)
terrain = Terrain(x, y, np.zeros((len(y), len(x))), np.ones((len(y), len(x)), dtype=bool))
guard = Guard(SearchGrid(points, terrain, [], stream_offset_m=100.0), 0.14)
after = snap_curves_to_stations(before, RADIUS, guard=guard)
assert after is not None
assert _kinks(after) == 0
assert off_station_count(after) == 0
FLAT = {"over_limit": {"total": 0, "grade": 0}, "max_grade_pct": 0.0}
def _merge(earth_base: float, delta: float):
"""평지 같은 쪽 두 꺾임 — 노선 토공 `earth_base` · 합친 선 차이 `delta`(가짜 횡단 함수)."""
outline = build_planned_polyline(
SAME, min_radius_m=RADIUS, simplify=False, curve_flags=[True] * len(SAME)
)
base = {**FLAT, "cut_m3": 0.0, "fill_m3": 0.0} # 띠 가늠은 안 씀
calls = []
def cross(before, after, window):
calls.append(window)
return delta
guard = Guard(SearchGrid(SAME, _flat_terrain(402.0, 262.0), [], stream_offset_m=100.0), 0.14)
earth = RouteEarth(lambda line: earth_base, cross)
merged = merge_same_side((outline, base), guard, RADIUS, lambda line: base, earth)[0]
return merged, calls
def test_merge_stops_at_route_earth_tolerance() -> None:
"""합치기 판정 = 바뀐 측점 횡단 차이 — +10 % 안이면 합치고 넘으면 안 합침(엔진 상수)."""
assert TOTAL_EARTH_TOL == pytest.approx(0.10)
merged, calls = _merge(1000.0, 90.0)
assert len(merged.curves) == 1 and calls
s0, s1 = calls[0]
assert 0.0 <= s0 < s1 # 바뀐 자리(앞 선 누가거리)만 넘김
blocked, calls = _merge(1000.0, 110.0)
assert len(blocked.curves) == 2 and calls
def _route(name: str, earth: float, station: int = 0):
metrics = {
"name": name,
"cut_m3": earth,
"fill_m3": 0.0,
"cost_index": earth,
"over_limit": {"total": station, "station": station},
}
return (object(), metrics)
def test_choose_profiles_only_shortlist() -> None:
"""종단 반영 켬 토공 = 노선 횡단 토공(줄 세움) 위 `SHORTLIST` 개만 균형 종단 · 그 사이에서 고름 ·
줄 세움 = `earth`(켬 = 상한 누른 계획선 · 끔 = 지반고) · 없으면 띠 가늠."""
routes = [_route(f"r{i}", 100.0 + 10 * i) for i in range(12)] + [_route("off", 1.0, 1)]
last = f"r{SHORTLIST - 1}"
profiled = []
def profile(route):
profiled.append(route[1]["name"])
# 균형 종단 토공은 순서가 바뀜 — 줄 세운 마지막이 가장 작음 · 밖은 더 작아도 안 봄
earth = 300.0 if route[1]["name"] == last else 900.0
route[1]["profile"] = {"cut_m3": earth, "fill_m3": 0, "borrow_m3": 0, "spoil_m3": 0}
chosen = choose("earthwork", routes, True, profile)
assert profiled == [f"r{i}" for i in range(SHORTLIST)]
assert chosen[1]["name"] == last
# 종단 끔 = 줄 세우기 값 그대로 · 종단 안 붙임
profiled.clear()
assert choose("earthwork", routes, False, profile)[1]["name"] == "r0" and not profiled
# 횡단 줄 세움이 띠 가늠과 다르면 그것을 따름(끔 = 지반고 횡단 하나로 고름)
cross = {f"r{i}": 1000.0 - i for i in range(12)}
def earth(route, capped):
return cross.get(route[1]["name"], 0.0) + (0.0 if capped else 5.0)
assert choose("earthwork", routes, False, None, earth)[1]["name"] == "r11"
def test_ends_grade() -> None:
x = np.arange(0.0, 402.0, 2.0)
y = np.arange(0.0, 102.0, 2.0)
xx, _ = np.meshgrid(x, y)
slope = Terrain(x, y, 0.3 * xx, np.ones(xx.shape, dtype=bool))
assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 200.0) == pytest.approx(30.0)
assert ends_grade([(50.0, 50.0), (250.0, 50.0)], slope, 600.0) == pytest.approx(10.0)
@pytest.fixture(scope="module")
def hill_runs() -> dict:
x = np.arange(0.0, 802.0, 2.0)
y = np.arange(0.0, 402.0, 2.0)
xx, yy = np.meshgrid(x, y)
hill = Terrain(x, y, 40.0 * np.exp(-((xx - 400.0) ** 2 + (yy - 200.0) ** 2) / 90.0**2), xx > -1)
expected = [(float(v), 200.0 + 40.0 * math.sin(v / 60.0)) for v in np.arange(50.0, 752.0, 4.0)]
return {
mode: generate_initial_route(
expected, hill, [], mode=mode, weights=None, max_grade=0.14, min_radius_m=RADIUS
)
for mode in ("gradeline", "earthwork", "gentle", "weighted")
}
@pytest.mark.parametrize("mode", ["gradeline", "earthwork", "gentle", "weighted"])
def test_hill_branches_rules(hill_runs: dict, mode: str) -> None:
"""언덕 — 꺾임만 0 · 측점 밖 0 · 못 지킨 곳 수 = R · 계류 · 측점 · 맞추기 전 선 없음."""
result = hill_runs[mode]
interior = result["nodes"][1:-1]
assert interior and all(node["radius_m"] is not None for node in interior) # 삼각 0
assert result["off_station_curves"] == 0
over = result["metrics"]["over_limit"]
assert over["total"] == over["radius"] + over["stream"] + over.get("station", 0)
assert "unsnapped" not in result
assert result["ends_over_limit"] is False and result["ends_grade_pct"] < 14.0
+269
View File
@@ -0,0 +1,269 @@
"""420 추천 횡단 토공 · 저장본 판 · [재계산] 서버(요구 254).
./venv/Scripts/python.exe -m pytest resources/tester/test_420_1_cross_earth.py -q
횡단 토공 = 공식 횡단 단면적 평균단면법(저장 정본 ±6 %) · 차 = 바뀐 측점만 · 결과 표 절 · 성 =
횡단 · [재계산] = 잠금 안 지우고 새로 셈(셈 수 1 → 2) · 다른 지문은 남기고 30일 지난 것만 지움 ·
표준 횡단면 = 저장 패널값(키 · 엔진에 감) · `initial_choice` with_profile 되살림.
"""
import asyncio
import json
import os
import time
from pathlib import Path
from uuid import uuid4
import numpy as np
import pytest
from test_224_3_recommend_store import EXPECTED, STREAMS, _ask, _key, _terrain, wired # noqa: F401
from B05_Profile import B05_Profile_Router_RouteInitial as router
from B05_Profile.B05_Profile_Engine_RouteInitial import (
Terrain,
read_initial_choice,
write_initial_choice,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_CrossEarth import (
attach_cross_earth,
cross_earthwork,
cross_earthwork_delta,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import _station_areas, route_sections
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import (
ALGO_VERSION,
DEFAULT_MODES,
source_fingerprint,
store_dir,
)
ROOT = Path(__file__).resolve().parents[2]
SUB2 = ROOT / "storage/1/3/cb15892a-5788-4a26-9316-20a15421f68b"
def _slope() -> Terrain:
"""옆경사 30 % 사면(노선 = x 방향) — 절 · 성 둘 다 생김."""
x = np.arange(0.0, 200.0, 2.0)
y = np.arange(-80.0, 80.0, 2.0)
xx, yy = np.meshgrid(x, y)
return Terrain(x, y, 0.05 * xx + 0.3 * yy, np.ones(xx.shape, dtype=bool))
LINE = [(20.0, 0.0), (100.0, 0.0), (180.0, 0.0)]
def test_횡단_토공_평균단면법() -> None:
terrain = _slope()
earth = cross_earthwork(LINE, None, None, terrain)
rows = earth["per_station"]
assert [r["chainage_m"] for r in rows] == [float(s) for s in range(0, 161, 20)]
# 직접 셈한 단면적 · Σ (A_i + A_i+1) / 2 · L
at, ground, sections, modes, widen = route_sections(LINE, terrain)
areas = [_station_areas(sections[i], modes[i], ground[i], widen[i]) for i in range(len(at))]
cut = sum((areas[i][0] + areas[i + 1][0]) / 2 * 20.0 for i in range(len(at) - 1))
fill = sum((areas[i][2] + areas[i + 1][2]) / 2 * 20.0 for i in range(len(at) - 1))
assert earth["cut"] == pytest.approx(cut, abs=0.1) and earth["cut"] > 0
assert earth["fill"] == pytest.approx(fill, abs=0.1) and earth["fill"] > 0
# 계획고를 1 m 올리면 성토가 늚 · 측점 수와 다른 계획고는 거절
up = cross_earthwork(LINE, None, np.asarray(ground) + 1.0, terrain)
assert up["fill"] > earth["fill"] and up["cut"] < earth["cut"]
with pytest.raises(ValueError):
cross_earthwork(LINE, None, [1.0, 2.0], terrain)
def test_표준_횡단면이_단면적을_바꿈() -> None:
from config.config_system_design import STANDARD_CROSS_SECTION
terrain = _slope()
wide = json.loads(json.dumps(STANDARD_CROSS_SECTION))
for group in wide.values():
group["road_width_m"] += 2.0
base = cross_earthwork(LINE, None, None, terrain)
assert cross_earthwork(LINE, None, None, terrain, wide)["cut"] > base["cut"]
def test_토공_차_바뀐_측점만() -> None:
terrain = _slope()
moved = [(20.0, 0.0), (100.0, 6.0), (180.0, 0.0)]
changed = [60.0, 80.0, 100.0]
same = cross_earthwork_delta(LINE, LINE, changed, terrain)
assert same["cut"] == 0 and same["fill"] == 0 and same["stations"] == 3
delta = cross_earthwork_delta(LINE, moved, changed, terrain)
old = cross_earthwork(LINE, changed, None, terrain)["per_station"]
new = cross_earthwork(moved, changed, None, terrain)["per_station"]
expect = 20.0 * sum(n["fill_m2"] - o["fill_m2"] for n, o in zip(new, old))
assert delta["fill"] == pytest.approx(expect, abs=0.2)
# 옛 결과를 넘기면 옛 단면적을 다시 안 셈 · 전체 어림 = 옛 합 + 차
whole = cross_earthwork(LINE, None, None, terrain)
reuse = cross_earthwork_delta(whole, moved, changed, terrain)
assert reuse["fill"] == pytest.approx(delta["fill"], abs=0.2)
assert reuse["fill_total"] == pytest.approx(whole["fill"] + reuse["fill"], abs=0.2)
assert cross_earthwork_delta(LINE, moved, [], terrain)["earth"] == 0
@pytest.mark.skipif(not (SUB2 / "B05_Profile").is_dir(), reason="검증_sub2 storage 없음")
def test_검증_sub2_저장_정본_6퍼센트() -> None:
"""저장된 노선 측점 · 계획고 = 지반 — 공식 [저장] 단면적 합과 ±6 %(2 m 추천 지형 · 1 m 표본)."""
from B05_Profile.B05_Profile_Engine_RouteInitial import load_terrain_and_streams
from B05_Profile.B05_Profile_Engine_Solver import _MODELS_SUBDIR
from common_util.common_util_route_geometry import read_planned_route_csv
from common_util.common_util_surface_sampler import build_surface_sampler
route = read_planned_route_csv(SUB2 / "B05_Profile/route/planned_route_initial.csv")
curves = json.loads(
(SUB2 / "B05_Profile/route/planned_route_initial_curves.json").read_text("utf-8")
)
curves = curves.get("curves") if isinstance(curves, dict) else curves
line = [(v.x, v.y) for v in route.vertices]
files = sorted((SUB2 / "B06_Section/cross_sections").glob("cross_*.json"))
at = [json.loads(p.read_text("utf-8"))["chainage_m"] for p in files]
# 공식 [저장] 단면적(DB cross_sections.design · 2026-10-18 뜸 · 계획고 = 지반) 평균단면법 합
stored_cut, stored_fill = 5166.0, 23079.3
selection = {"source_filter": "classification", "method": "dtm", "smooth": True}
terrain, _ = load_terrain_and_streams(SUB2, selection)
surface = build_surface_sampler(SUB2 / _MODELS_SUBDIR, "classification", "dtm", True)
for ground in (terrain, surface):
earth = cross_earthwork(line, at, None, ground, design_curves=curves)
assert earth["cut"] == pytest.approx(stored_cut, rel=0.06)
assert earth["fill"] == pytest.approx(stored_fill, rel=0.06)
def test_결과_절성_횡단_값() -> None:
terrain = _slope()
plain = {"planned": [list(p) for p in LINE], "curves": [], "metrics": {"cut_m3": 1.0}}
profiled = {
"planned": [list(p) for p in LINE],
"metrics": {"cut_m3": 1.0, "fill_m3": 2.0, "profile": {"cut_m3": 30.0, "fill_m3": 20.0}},
}
result = {**plain, "unsnapped": profiled}
attach_cross_earth(result, terrain)
earth = cross_earthwork(LINE, None, None, terrain)
assert result["metrics"]["cut_m3"] == earth["cut"] and result["metrics"]["band_cut_m3"] == 1.0
assert result["metrics"]["surplus_m3"] == pytest.approx(earth["cut"] - earth["fill"], abs=0.2)
shown = result["unsnapped"]["metrics"]
assert (shown["cut_m3"], shown["fill_m3"], shown["band_fill_m3"]) == (30.0, 20.0, 2.0)
attach_cross_earth(result, terrain) # 두 번 붙여도 띠 가늠을 안 덮음
assert result["metrics"]["band_cut_m3"] == 1.0
def test_표준_횡단면_키_엔진에_감(wired) -> None: # noqa: F811
assert _key(standard={"a": 1}) != _key() and _key(standard={"a": 1}) == _key(standard={"a": 1})
wired.standard = {"road": {"width_m": 5.0}}
_ask(mode="earthwork")
assert wired.inputs[-1]["standard"] == wired.standard
def test_재계산_잠금_안_지우고_새로(wired) -> None: # noqa: F811
first = _ask(mode="earthwork")
_ask(mode="gentle")
_ask(mode="earthwork", criteria={"min_radius_m": 15.0}) # 다른 묶음
assert wired.count == 3 and first["stored"] is False
files = {p.name: p.read_text("utf-8") for p in store_dir(wired.root).glob("*.json")}
again = _ask(mode="earthwork", recompute=True)
assert again["stored"] is False and wired.count == 4 # 셈 1 → 2(earthwork)
now = {p.name: json.loads(p.read_text("utf-8")) for p in store_dir(wired.root).glob("*.json")}
# 같은 묶음 gentle 은 지워짐 · 다른 묶음은 그대로 · earthwork 파일은 새로 씀
assert len(now) == 2
other = [n for n, d in now.items() if d["request"]["criteria"]]
assert other and now[other[0]] == json.loads(files[other[0]])
fresh = next(d for n, d in now.items() if not d["request"]["criteria"])
assert "recompute" not in fresh["request"] and fresh["version"] == ALGO_VERSION
assert fresh["result"]["metrics"]["cut_m3"] == 10.0 # 횡단(균형 종단) 값
assert _ask(mode="earthwork")["stored"] and wired.count == 4
def test_재계산_메모리_묶음도_버림(wired) -> None: # noqa: F811
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
from B05_Profile.B05_Profile_Engine_RouteInitial_Store import request_fingerprint
_ask(mode="earthwork")
pool = request_fingerprint(
EXPECTED,
_terrain(),
STREAMS,
mode="earthwork",
weights=None,
max_grade=0.14,
min_radius_m=12.0,
stream_offset_m=None,
grade_smooth_m=None,
target_grade=None,
with_profile=True,
design_speed=20,
)[1]
_POOLS[bytes.fromhex(pool)] = "옛 묶음"
_ask(mode="earthwork")
assert bytes.fromhex(pool) in _POOLS
_ask(mode="earthwork", recompute=True)
assert bytes.fromhex(pool) not in _POOLS
def _foreign(folder: Path, name: str, days: float, **fields) -> None:
"""다른 판 · 지문 저장본(다른 PC · 워크트리 코드) — `days` 전 수정."""
data = {"version": ALGO_VERSION, "source": "0" * 16, "request": {}, **fields}
path = folder / f"{name}.json"
path.write_text(json.dumps(data), encoding="utf-8")
stamp = time.time() - days * 86400.0
os.utime(path, (stamp, stamp))
def test_다른_지문은_남기고_30일_지난_것만_지움(wired) -> None: # noqa: F811
folder = store_dir(wired.root)
folder.mkdir(parents=True)
_foreign(folder, "old_new", 1, version=ALGO_VERSION - 1, source=source_fingerprint())
_foreign(folder, "other_new", 29)
_foreign(folder, "old_stale", 31, version=ALGO_VERSION - 1)
_foreign(folder, "other_stale", 40)
(folder / "broken.json").write_text("{", encoding="utf-8")
asyncio.run(router.precompute_defaults(uuid4()))
names = {p.stem for p in folder.glob("*.json")}
assert {"old_new", "other_new", "broken"} <= names
assert not names & {"old_stale", "other_stale"}
assert len(names) == 3 + len(DEFAULT_MODES) and wired.count == len(DEFAULT_MODES)
def test_다른_지문은_재계산_정리에서도_안_건드림(wired) -> None: # noqa: F811
_ask(mode="earthwork")
saved = next(store_dir(wired.root).glob("*.json"))
pool = json.loads(saved.read_text("utf-8"))["pool"]
# 같은 묶음 · 같은 종류지만 다른 지문 — 최근 두 벌 정리 · [재계산] 둘 다 남김
folder = store_dir(wired.root)
for index in range(3):
_foreign(folder, f"other{index}", 0, pool=pool, request={"mode": "earthwork"})
_ask(mode="earthwork", criteria={"min_radius_m": 15.0})
_ask(mode="earthwork", recompute=True)
names = {p.stem for p in folder.glob("*.json")}
assert {"other0", "other1", "other2"} <= names
def test_소스_지문이_바뀌면_다른_키(monkeypatch: pytest.MonkeyPatch) -> None:
import B05_Profile.B05_Profile_Engine_RouteInitial_Store as store
before = _key()
monkeypatch.setattr(store, "source_fingerprint", lambda: "f" * 16)
assert _key() != before
def test_with_profile_되살림(tmp_path: Path) -> None:
assert read_initial_choice(tmp_path)["with_profile"] is True # 없으면 켬
write_initial_choice(tmp_path, "gentle", None, None, False)
assert read_initial_choice(tmp_path)["with_profile"] is False
write_initial_choice(tmp_path, "gentle", None, None)
assert read_initial_choice(tmp_path)["with_profile"] is True
# 옛 파일(칸 없음) = 켬
path = tmp_path / "B05_Profile/route/initial_choice.json"
path.write_text(json.dumps({"logic": "gentle"}), encoding="utf-8")
assert read_initial_choice(tmp_path)["with_profile"] is True
def test_적용이_종단_반영을_적음_소스() -> None:
source = (ROOT / "B05_Profile/B05_Profile_Router_Replan.py").read_text(encoding="utf-8")
call = source[source.index("write_initial_choice,\n") :][:600]
assert "request.balanced_profile" in call
initial = (ROOT / "B05_Profile/B05_Profile_Router_RouteInitial.py").read_text(encoding="utf-8")
# 지우기는 잠금 안
lock = initial.index("async with pool_lock(pool_key):")
assert lock < initial.index("drop_pool_results, project_root, pool_key") < lock + 400
assert 'model_dump(exclude={"recompute"})' in initial
# [적용] 균형 종단도 저장 패널값
assert "standard=standard" in initial[initial.index("async def balanced_profile_of") :]
@@ -0,0 +1,61 @@
"""421-2 추천 창 — 「못 지킨 구간」 = R · 계류 · 측점만 · 원지반 기울기(참고) 줄 · 시종점 알림.
./venv/Scripts/python.exe -m pytest resources/tester/test_421_2_violation_lines.py -q
"""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_421_2_violation_lines.cjs")
def _lines() -> dict:
run = subprocess.run(
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True
)
return json.loads(run.stdout)
def test_못_지킨_구간은_기울기_빼고_기준_초과와_같음() -> None:
now = _lines()["now"]
assert now["violations"].startswith("제약 못 지킨 구간 2곳: No.3 R 10.0m")
assert "28.8%" not in now["violations"] and "No.5+3.0 측점 밖 3.0m" in now["violations"]
assert now["over"] == 2 and "기준 초과 2구간" in now["route"] # 서버 total 4 아님
assert now["ground"] == "원지반 기울기(참고) 최대 35.8% · 상한 넘는 곳 2"
assert now["groundTitle"].split("\n") == [
"No.1 28.8%(원지반 기울기)",
"No.4 18.9%(원지반 기울기)",
]
assert now["ends"] == (
"시종점 높이 차가 상한을 넘음(평균 16.2%) — 종단으로 못 맞춤 · 노선을 늘려야 함"
)
def test_기울기만_넘으면_모두_지킴_시종점_알림_없음() -> None:
clean = _lines()["clean"]
assert clean["violations"] == "제약 못 지킨 구간 없음 — 모두 지킴"
assert clean["over"] == 0 and clean["ground"].endswith("상한 넘는 곳 1")
assert clean["ends"] == ""
def test_옛_답은_서버_수_그대로_줄_없음() -> None:
old = _lines()["old"]
assert (
old["over"] == 3 and old["violations"] == "" and old["ground"] == "" and old["ends"] == ""
)
def test_창에_줄_두_개와_도움말() -> None:
rec = (B05 / "B05_Profile_UI_RouteEdit_Recommend.ts").read_text(encoding="utf-8")
assert '<div data-line="ground" hidden></div>' in rec
assert '<div class="is-warn" data-line="ends" hidden></div>' in rec
assert 'line("violations").title = (brokenItems(view?.metrics ?? null) ?? [])' in rec
assert "const ends = endsLine(view);" in rec
help_ts = (B05 / "B05_Profile_UI_RouteEdit_Recommend_Help.ts").read_text(encoding="utf-8")
for name in ('name: "원지반 기울기(참고)",', 'name: "시종점 높이 차",'):
assert name in help_ts
assert "이보다 가파른 구간을 만들지 않음" not in help_ts
assert "「기준 초과」 수와 같음" in help_ts
@@ -0,0 +1,67 @@
"""421 추천 창 — 「측점 맞춤」 → 「종단 반영」 · [재계산] 늘 켜짐 + `recompute`.
./venv/Scripts/python.exe -m pytest resources/tester/test_421_recommend_profile.py -q
"""
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
B05 = ROOT / "B05_Profile"
HELPER = Path(__file__).with_name("helper_421_profile_toggle.cjs")
def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
def test_종단_반영_값_정하기() -> None:
out = json.loads(
subprocess.run(
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", check=True
).stdout
)
assert out["sent"] is False # 옛 서버 = 보낸 값
assert out["server"] is True # 서버 답 `with_profile` 이 먼저
assert out["serverHasUnsnapped"] is False # 맞추기 전 선은 화면이 안 씀
assert out["oldFollow"] is False and out["oldEarth"] is True # 옛 캐시 = 갈래로
assert out["base"] is False and out["baseProfile"] is False
assert out["oldBase"] is None and out["oldBaseProfile"] is True
assert out["sameOff"] is True and out["sameOn"] is False and out["sameOld"] is True
assert out["readBack"] is False # 캐시에 남아 다시 열 때 되살림
def test_창_단추와_재계산() -> None:
rec = _read("B05_Profile_UI_RouteEdit_Recommend.ts")
assert "측점 맞춤" not in rec and "snapOn" not in rec and "unsnapped" not in rec
assert 'toggleButton("종단 반영", profileOn,' in rec
assert "let profileOn = params.withProfile;" in rec
toggle = rec[rec.index('toggleButton("종단 반영"') :][:200]
assert "profileOn = active;" in toggle and "void run();" in toggle # 누르면 바로
assert "rerun.before(profileToggle);" in rec
assert "rerun.disabled = busy || invalid.size > 0;" in rec # 늘 켜짐
assert 'rerun.addEventListener("click", () => void run(true));' in rec
assert "const withProfile = profileOn;" in rec
assert "params.onApply(preview);" in rec
def test_요청과_적용에_종단_반영() -> None:
init = _read("B05_Profile_UI_RouteEdit_Initial.ts")
assert "with_profile: withProfile," in init
assert "...(recompute ? { recompute: true } : {})," in init
assert "withProfile: choice?.withProfile ?? base.withProfile ?? true," in init
on_apply = init[init.index("onApply: (picked) => {") :][:900]
assert "with_profile: choiceWithProfile(picked)," in on_apply
assert on_apply.count("choiceWithProfile(picked)") == 2 # 저장 칸 · 균형 종단
assert "withProfileOf(" not in init
assert "with_profile: choiceWithProfile(now)" in init # [저장]에도 실음
api = _read("B05_Profile_Api_RouteInitial.ts")
assert "recompute?: boolean;" in api
def test_도움말_글() -> None:
help_ts = _read("B05_Profile_UI_RouteEdit_Recommend_Help.ts")
assert "「측점 맞춤」을 끄면" not in help_ts and "끔 = 옮기기 전 선" not in help_ts
assert 'name: "재계산",' in help_ts and 'name: "측점 맞춤(늘)",' in help_ts
assert "단추로 켬 · 끔(기본 켬 · 다섯 갈래 모두)" in help_ts
+13 -13
View File
@@ -94,14 +94,15 @@ def test_비교값과_기준값_글(results):
def test_제약_못_지킨_구간과_상한_풂(results):
labels = results["labels"]
# 원지반 기울기(No.9 29.1%)는 참고 줄로 — 못 지킨 구간에 안 듦(421-2)
assert labels["violations"] == (
"제약 못 지킨 구간 3곳: No.9+0.0 29.1%(지형상 불가능)"
" · No.20+0.0 R 9.5m(곡선 둘 자리 모자람) · No.30+0.0 계류 32.0m(계류 버퍼 안)"
"제약 못 지킨 구간 2곳: No.20+0.0 R 9.5m(곡선 둘 자리 모자람)"
" · No.30+0.0 계류 32.0m(계류 버퍼 안)"
)
assert labels["violationsNone"] == "제약 못 지킨 구간 없음 — 모두 지킴"
assert labels["violationsOld"] == "" # 옛 답(목록 칸 없음)은 줄을 숨긴다
assert labels["violationsMany"].startswith(
"제약 못 지킨 구간 7곳: No.0+0.0 15.0%(곡선 맞춤 뒤 지반 차)"
"제약 못 지킨 구간 7곳: No.0+0.0 R 5.0m(곡선 둘 자리 모자람)"
)
assert labels["violationsMany"].endswith(" 외 2곳")
assert labels["relaxed"] == "지형상 14.0% 로 못 이어져 상한을 23.5% 로 풂"
@@ -146,13 +147,12 @@ def test_캐시는_결과_통_프로젝트_칸(results):
def test_전처리_초기값_노선을_편집값으로(results):
snap = results["initialRoute"]["ok"]
assert snap["planned"] == [[0, 0], [10, 5]]
assert snap["curveRadius"] == [None, 20]
assert snap["picked"] == -1
# 서버가 안 주거나 노드가 모자라면 null — 화면이 [초기화]를 안 한다.
assert results["initialRoute"]["none"] is None
assert results["initialRoute"]["tooShort"] is None
"""칸 [초기화]만 쓰던 전처리 초기 노선 펴기는 걷음(418) — 되돌림은 좌측 아래 [초기화] 하나."""
assert "initialRoute" not in results
model = os.path.join(
HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Initial_Model.ts"
)
assert "initialRouteSnapshot" not in open(model, encoding="utf-8").read()
def test_미리_보기_지도_선(results):
@@ -177,8 +177,7 @@ def test_도구_막대는_노선_추천_하나만():
assert "연 상태로" not in chrome and "history-reset" not in chrome
part = open(os.path.join(page, "B05_Profile_UI_Panel_Recommend.ts"), encoding="utf-8").read()
assert part.count('data-act="recommend" disabled') == 1 # 단추 하나
assert part.count('data-act="reset" disabled') == 1
assert part.index('data-act="recommend" disabled') < part.index('data-act="reset" disabled')
assert 'data-act="reset"' not in part # 칸 [초기화] 지움(418)
initial = open(
os.path.join(page, "B05_Profile_UI_RouteEdit_Initial.ts"), encoding="utf-8"
).read()
@@ -384,5 +383,6 @@ def test_같은_노선_알림_줄(results):
def test_비중_계산_넘은_구간_사유_그대로(results):
six = results["sixth"]
assert six["weightedViolation"] == "제약 못 지킨 구간 1곳: No.9+0.0 29.1%(비중 계산)"
# 원지반 기울기만 넘음 = 못 지킨 곳 없음(421-2) · 사유 글은 참고 줄 풍선에 그대로
assert six["weightedViolation"] == "제약 못 지킨 구간 없음 — 모두 지킴"
assert six["weightedViolationReason"] == "No.9+0.0 29.1%(비중 계산)"
@@ -5,7 +5,7 @@
① `POST /route/initial` → 비교값이 다 있음
② 화면 코드로 [확인] 몸통을 만들어 `POST /route/replan` → 서버가 initial 을 적음
③ `GET /route/plan` → 현재 초기값 = 보낸 것 · 다시 연 노드 = 보낸 노드
④ [초기화] — 서버 `initial_route`(전처리 초기값 노선)를 놓음 · 재계산 없음
④ 칸 [초기화]용 `initial_route` 는 없음(418) — 되돌림은 좌측 아래 [초기화](초기값 스냅샷)
화면 쪽 셈은 `helper_b05_routeedit_initial_apply.cjs` 가 화면 코드 그대로 돌린다.
화면 제약 칸(종단기울기 · 최소 반지름 · 계류 이격 · 지반 고름, 계획서 20 확정)에 겹친 값도
@@ -267,19 +267,11 @@ def test_다시_연_노선은_확인한_노선(rounds):
def test_초기화는_전처리_초기값_노선으로(rounds):
"""[초기화] 는 재계산 없이 `GET /route/plan` 의 `initial_route` 를 놓는다 — 무엇을 확인했든
같은 노선이고, 확인 전(수정본 없음) 노드와 같다."""
before = rounds["before"]
assert before["edited"] is False
first = [[n["x"], n["y"]] for n in before["nodes"]]
"""칸 [초기화] 지움(418) — `GET /route/plan` 이 전처리 초기 노선(`initial_route`)을 안 준다."""
assert rounds["before"]["edited"] is False
for item in rounds["rounds"]:
assert item["plan"]["edited"] is True
initial = item["plan"]["initial_route"]
assert len(initial["nodes"]) >= 2 and isinstance(initial["curves"], list)
assert _close([[n["x"], n["y"]] for n in initial["nodes"]], first), item["logic"]
# 확인한 마지막 노선은 초기값과 다르다 — 되돌림이 실제로 노선을 바꾼다.
last = rounds["rounds"][-1]
assert not _close(last["reopened"]["planned"], first)
assert "initial_route" not in item["plan"], item["logic"]
def test_제약_겹침도_확인_뒤_그대로(api, rounds):
+3 -1
View File
@@ -100,7 +100,9 @@ def test_진행_단계_두_칸이_보기를_따라감():
def test_초기화_계획노선_편집은_다시_세움():
"""[초기화] · [노선 반영](옛 계획노선 편집 [확인]) = 새 초기값 — 옛 캐시를 다 버리고 페이지를 통째로 다시 읽는다(PLAN 77)."""
reset = _read("A00_Common/design/design_reset.ts")
assert "clearDrafts(projectId);" in reset and "clearResults(projectId);" in reset
assert (
'namesInBucket("draft")' in reset and "clearResults(projectId);" in reset
) # B08 · B09 초안 남김(418)
assert "location.reload()" in reset
view = _read("A00_Common/design/design_view_profile.ts")
assert "location.reload()" not in view # [노선 반영]은 새로고침 대신 다시 세움(345-3)
+33 -8
View File
@@ -2,8 +2,9 @@
./venv/Scripts/python.exe -m pytest resources/tester/test_route_initial.py -q
공통 제약(종단기울기 상한 · 최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만
`metrics.violations` 에 측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다.
공통 제약(최소 곡선반지름 · 계류 이격)은 넘지 못함 — 지형상 불가능할 때만 `metrics.violations` 에
측점 · 값 · 사유로 뜨고 `over_limit.total` 은 그 수와 같다. 원지반 기울기(탐색 링크 상한은 막음)는
참고만 — 목록엔 뜨되 `total` 에 안 듦(419).
예상노선 추적(follow)은 전처리 초기값과 같은 선 — 제약 탐색 없이 넘은 곳만 목록(PLAN 23-1).
갈래는 제 목표 비교값이 가장 좋은 후보 — 실프로젝트(랩탑_보조 · 읽기만)에서 갈래가 갈림.
"""
@@ -32,8 +33,8 @@ from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import (
grade_line,
station_label,
)
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import profile_earth
from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS, SHORTLIST
from B05_Profile.B05_Profile_Engine_RouteInitial_Profile import plain_metrics, profile_earth
from common_util.common_util_route_polyline import build_planned_polyline
SEARCHED = [mode for mode in MODES if mode != "follow"]
@@ -44,6 +45,16 @@ GRADE = 0.14
RADIUS = 12.0
@pytest.fixture(autouse=True)
def _fresh_pools():
"""시험마다 묶음 캐시를 비움 — 앞 시험이 남긴 후보(다른 종단 반영으로 붙은 것)를 안 씀."""
from B05_Profile.B05_Profile_Engine_RouteInitial import _POOLS
_POOLS.clear()
yield
_POOLS.clear()
def _terrain(z_of) -> Terrain:
x = np.arange(0.0, 802.0, 2.0)
y = np.arange(0.0, 402.0, 2.0)
@@ -68,9 +79,10 @@ def _run(terrain: Terrain, mode: str, streams=(), weights=None) -> dict:
def _kept(result: dict) -> None:
"""제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음."""
"""제약을 지켰거나, 못 지킨 곳이 목록에 있고 수가 같음(원지반 기울기는 참고 · 수에 안 듦)."""
metrics = result["metrics"]
assert metrics["over_limit"]["total"] == len(metrics["violations"])
hard = [v for v in metrics["violations"] if v["kind"] != "grade"]
assert metrics["over_limit"]["total"] == len(hard)
if not metrics["violations"]:
assert metrics["max_grade_pct"] <= GRADE * 100.0 + 1e-6
assert result["relaxed"] is False
@@ -109,7 +121,7 @@ def test_follow_is_initial_polyline_and_lists_violations(hill: Terrain) -> None:
assert result["relaxed"] is False and result["terrain_bound_pct"] is None
grades = [v for v in result["metrics"]["violations"] if v["kind"] == "grade"]
assert grades and all("예상노선 추적" in v["reason"] for v in grades)
assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"])
_kept(result)
def _earth(result: dict) -> float:
@@ -123,13 +135,19 @@ def _earth(result: dict) -> float:
def _check_branch_goals(runs: dict) -> None:
"""갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 ·
계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5).
고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1)."""
고르기는 측점에 맞춘 선 · 측점 밖 곡선 없는 후보 먼저(383-1). 토공 · 공사비는 상한 누른 계획선
토공(종단 전 값) 위 `SHORTLIST` 안에서 균형 종단으로 고름(419) — 종단 전 값으로 위 3 안."""
branches = [runs[mode]["metrics"] for mode in BRANCHES]
def rank(goal, m: dict) -> tuple:
return (m["over_limit"].get("station", 0) > 0, *goal(m))
for mode, goal in GOALS.items():
if mode in ("earthwork", "cost"):
plain = sorted(rank(goal, plain_metrics(m)) for m in branches)
own = rank(goal, plain_metrics(runs[mode]["metrics"]))
assert own <= plain[min(SHORTLIST, len(plain)) - 1], mode
continue
assert all(rank(goal, runs[mode]["metrics"]) <= rank(goal, m) for m in branches), mode
for result in runs.values():
m = result["metrics"]
@@ -191,6 +209,10 @@ def _check_weight_monotone(run) -> None:
assert tops[0] <= min(tops) + 1e-6, tops # 끝 0/0/100 = 기울기 순한
@pytest.mark.xfail(
reason="엔진 pick 이 정밀 · 빠른 판 종단을 섞어 견줌 → 캐시 순서 의존 · PLAN 419 에서 풂",
strict=False,
)
def test_weight_ends_match_branches(hill: Terrain) -> None:
_check_weight_ends(lambda mode, weights=None: _run(hill, mode, weights=weights))
@@ -321,6 +343,7 @@ def test_initial_choice_roundtrip(tmp_path) -> None:
"logic": "follow",
"weights": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0},
"criteria": None,
"with_profile": True,
}
for logic in ("gradeline", "safe", "crossings", "cost"): # 새 갈래도 적고 다시 읽음(23-5)
write_initial_choice(tmp_path, logic, None, {"target_grade_pct": 10.0})
@@ -331,6 +354,7 @@ def test_initial_choice_roundtrip(tmp_path) -> None:
"logic": "weighted",
"weights": {"expected": 60.0, "earthwork": 20.0, "grade": 20.0},
"criteria": None,
"with_profile": True,
}
# 사용자가 화면 칸에 겹쳐 넣은 값만(빈 칸은 안 담김) — 남으면 다시 열 때 그 칸만 채운다.
write_initial_choice(
@@ -340,6 +364,7 @@ def test_initial_choice_roundtrip(tmp_path) -> None:
"logic": "earthwork",
"weights": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0},
"criteria": {"stream_offset_m": 30.0, "grade_smooth_m": 2.0},
"with_profile": True,
}
(tmp_path / "B05_Profile" / "route" / "initial_choice.json").write_text("{", encoding="utf-8")
assert read_initial_choice(tmp_path)["logic"] == "follow"