Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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?.();
|
||||
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 기본값으로 채운다.
|
||||
|
||||
@@ -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↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
|
||||
|
||||
@@ -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) — 화면은 이 글로 알린다. */
|
||||
|
||||
@@ -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: 잠금은 지우지 않음 — 입력 묶음 수만큼(작음) · 다중 프로세스면 파일 잠금으로
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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` 도(구조물 조정은 구조물 따라) · 열쇠가 사라진 측점 수 =
|
||||
「노선 밖 고침」.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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] };
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: "상한을 풂",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 폼 되보내기가 옵션 값을 제원에 그대로
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(배율 반영). */
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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 이 올리기 전엔 지금 띠 가늠으로 둠.
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = () => ({
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;",
|
||||
):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 양식 ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
) # 보기마다 비우면 앞 보기가 받은 새 상세를 또 버림
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user