Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -55,7 +55,7 @@ export function forgetPageData(projectId: string, leaving = false): void {
|
||||
/** 횡단 context(세션 캐시 · 새 요청 없음) — 노선 id 칸을 채움 · 두 보기가 이것으로만 받음. 서버 창구는 부를 때
|
||||
* 불러옴(묶음 · 초안 측점이 끄는 이 파일이 화면 모듈을 안 끌게). */
|
||||
export async function pageSectionContext(projectId: string): Promise<SectionContextResponse> {
|
||||
const { fetchSectionContext } = await import("../../B06_Section/B06_Section_Api_Fetch");
|
||||
const { fetchSectionContext } = await import("../../B06_Section/B06_Section_Api_Context");
|
||||
const context = await fetchSectionContext(projectId);
|
||||
setPageRouteId(projectId, context.route_id); // 노선 id 한 칸(design_page_route)
|
||||
return context;
|
||||
|
||||
@@ -19,7 +19,7 @@ import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../b_workflow_nav";
|
||||
import { readState, writeState } from "../b_page_state";
|
||||
import { currentRoute, navigateTo, remountRoute } from "../router";
|
||||
import { clearRouteLatestCache } from "../../B05_Profile/B05_Profile_Api_Fetch";
|
||||
import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { fetchCurrentRouteId } from "../../B06_Section/B06_Section_Api_Context";
|
||||
import { createStructuresSection, type StructuresSection } from "../b_structures_section";
|
||||
import type { StructuresCallbacks } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||
import type { RouteProfilePanelCallbacks } from "../../B05_Profile/B05_Profile_UI_Profile_Panel_Types";
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
* 2차 워크플로우(경로 설계) API 클라이언트
|
||||
*
|
||||
* 백엔드 계약 (B05_Profile_Router.py):
|
||||
* POST /api/projects/{project_id}/route/solve → 경로 탐색 + DB 기록
|
||||
* POST /api/projects/{project_id}/route/confirm → 최신 경로 확정
|
||||
*
|
||||
* 규칙:
|
||||
@@ -28,44 +27,6 @@ export interface CirclePoint extends RoutePoint {
|
||||
radius_m: number;
|
||||
}
|
||||
|
||||
/** 경로 탐색 실행 요청 (RouteSolveRequest) */
|
||||
export interface RouteSolveRequest {
|
||||
filter_key: string;
|
||||
method?: string;
|
||||
smooth?: boolean;
|
||||
surface_model_id?: number | null;
|
||||
algorithm?: string;
|
||||
bp: RoutePoint;
|
||||
ep: RoutePoint;
|
||||
cp?: RoutePoint[];
|
||||
ap?: CirclePoint[];
|
||||
fp?: CirclePoint[];
|
||||
grade_class?: string;
|
||||
paved?: boolean;
|
||||
min_curve_radius_m?: number | null;
|
||||
max_uphill_grade?: number | null;
|
||||
max_downhill_grade?: number | null;
|
||||
min_uphill_grade?: number | null;
|
||||
min_downhill_grade?: number | null;
|
||||
allow_avoid_pass_through?: boolean;
|
||||
station_interval_m?: number | null;
|
||||
cross_half_width_m?: number | null;
|
||||
cross_sample_interval_m?: number | null;
|
||||
long_sample_interval_m?: number | null;
|
||||
terrain_type?: string;
|
||||
/** 설계속도(km/h) — 종단 법정 기준 축. 임도 기본 20(2026-08-19). */
|
||||
design_speed_kph?: number | null;
|
||||
main_direction?: string;
|
||||
max_grade_pct?: number | null;
|
||||
min_vertical_radius_m?: number | null;
|
||||
min_tangent_length_m?: number | null;
|
||||
balance_segment_length_m?: number | null;
|
||||
start_elevation_offset_m?: number | null;
|
||||
end_elevation_offset_m?: number | null;
|
||||
/** 횡단배수 최소 계획고 강제 — 기본 해제(2026-09-01 사용자 지시). */
|
||||
enforce_pipe_clearance?: boolean | null;
|
||||
}
|
||||
|
||||
/** 계획선 산출 요약 (실패 시 null) */
|
||||
export interface RouteGradeSummary {
|
||||
id: string;
|
||||
@@ -82,20 +43,6 @@ export interface RouteGradeSummary {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** 경로 탐색 실행 결과 (RouteSolveResponse) */
|
||||
export interface RouteSolveResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
route_id: number;
|
||||
total_length_m: number;
|
||||
metrics: Record<string, unknown>;
|
||||
required_points_ok: boolean;
|
||||
route_data_path: string;
|
||||
longitudinal_length_m: number | null;
|
||||
cross_section_count: number | null;
|
||||
grade_summary: RouteGradeSummary | null;
|
||||
}
|
||||
|
||||
/** 경로 확정 결과 (RouteConfirmResponse) */
|
||||
export interface RouteConfirmResponse {
|
||||
status: string;
|
||||
@@ -190,17 +137,6 @@ async function requestJson<T>(
|
||||
}
|
||||
}
|
||||
|
||||
/** 경로 탐색을 실행한다 (비용면 생성 → Dijkstra/ridge-valley → GeoJSON 저장). */
|
||||
export async function solveRoute(
|
||||
projectId: string,
|
||||
request: RouteSolveRequest,
|
||||
): Promise<RouteSolveResponse> {
|
||||
return requestJson<RouteSolveResponse>(`/projects/${projectId}/route/solve`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
});
|
||||
}
|
||||
|
||||
/** 등고선 간격 재적용 값을 서버(stage 1 params)에 영속화한다. */
|
||||
export async function updateContourInterval(
|
||||
projectId: string,
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
*
|
||||
* GET /projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계 m)
|
||||
* POST /projects/{id}/route/replan → 노선 초안 저장(346-3 · draft = 계약 RouteDraft · as_initial)
|
||||
* POST /projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
||||
* GET /projects/{id}/route/terrain-sources → 고를 수 있는 분석 지형(라이다 · 수치지형도)
|
||||
*
|
||||
* 편집 창 분석(지반고 · 횡단 미리보기 · 추천)은 **고른 분석 지형**(`terrain`)을 실어
|
||||
@@ -255,12 +254,3 @@ export interface CrossPreviewResponse {
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
|
||||
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
|
||||
return requestJson<RouteReplanResponse>(
|
||||
`/projects/${projectId}/route/replan/reset`,
|
||||
{ method: "POST" },
|
||||
REPLAN_TIMEOUT_MS,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -367,17 +367,6 @@ export function structureAnchorM(structure: StructureInstance): number {
|
||||
return structure.chainage_m ?? structure.start_m ?? 0;
|
||||
}
|
||||
|
||||
/** 타입 정의의 기본값으로 옵션을 채운다(신규 추가·타입 변경 시). */
|
||||
export function defaultOptions(type: StructureType): Record<string, string | number> {
|
||||
const options: Record<string, string | number> = {};
|
||||
type.options.forEach((field) => {
|
||||
// 기본값이 있는 항목만 채운다. 필수 선택지의 첫 항목을 대신 넣어 주면 사용자가
|
||||
// 고르지도 않은 재료·형식이 확정값으로 저장된다(2026-08-16 크로스체크 지적 2).
|
||||
if (field.default !== null && field.default !== undefined) options[field.key] = field.default;
|
||||
});
|
||||
return options;
|
||||
}
|
||||
|
||||
/* ── 미저장 구조물 조작분(세션) ────────────────────────────────────────────
|
||||
* 조작은 세션에만 쌓고 영구저장은 [저장]·[확정]에서만 한다(CLAUDE.md 5장,
|
||||
* 2026-08-29 사용자 확정). B05에서 만지고 B06으로 넘어가 확정하는 경로가 있어
|
||||
|
||||
@@ -16,6 +16,8 @@ scipy에 의존하지 않는 순수 기하 계산만 두어 프론트엔드 구
|
||||
|
||||
직선 판정 짝: `is_straight_bend` · `STRAIGHT_HEIGHT_M` ↔ TS `logic/B05_Profile_Profile_Straight.ts`
|
||||
(거울 시험 `test_426_profile_straight.py`).
|
||||
경고 글 짝: `build_alignment` warnings ↔ TS `buildAlignment` warnings — 같은 목록
|
||||
(거울 시험 `test_472_save_warnings.py`).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -369,7 +371,7 @@ def build_curves(
|
||||
continue
|
||||
if length / 2.0 - half > 1e-6:
|
||||
warnings.append(
|
||||
f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 "
|
||||
f"{chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 "
|
||||
f"{half * 2.0:.1f}m(반경 {half * 2.0 / abs(delta):.0f}m)로 줄였습니다."
|
||||
)
|
||||
length = half * 2.0
|
||||
@@ -592,18 +594,9 @@ def build_alignment(
|
||||
for segment in segments
|
||||
if abs(segment["grade_percent"]) > policy.max_grade_pct + 1e-6
|
||||
]
|
||||
if violations:
|
||||
worst = max(abs(item["value"]) for item in violations)
|
||||
warnings.append(
|
||||
f"종단기울기 {worst:.2f}%가 기준 {policy.max_grade_pct:.2f}%를 초과하는 구간이 "
|
||||
f"{len(violations)}개 있습니다."
|
||||
)
|
||||
# 경고 글 = TS `buildAlignment` 와 한 벌(472 덧 · 거울 `test_472_save_warnings`) — 기울기 초과 ·
|
||||
# 불균형은 글로 안 남김(`violations` · `balance` 칸이 정본 · 화면 칩이 그 칸으로 셈)
|
||||
within_tolerance = imbalance <= policy.balance_tolerance_percent + 1e-9
|
||||
if not within_tolerance:
|
||||
warnings.append(
|
||||
f"절·성토 불균형 {imbalance:.1f}%가 허용치 "
|
||||
f"{policy.balance_tolerance_percent:.1f}%를 초과합니다."
|
||||
)
|
||||
|
||||
return {
|
||||
"schema_version": ALIGNMENT_SCHEMA_VERSION,
|
||||
|
||||
@@ -163,6 +163,18 @@ def valid_cross_names(cross_dir: Path, stations: list[Any]) -> set[str]:
|
||||
}
|
||||
|
||||
|
||||
def cross_files_in_order(cross_dir: Path, stations: list[Any]) -> list[Path]:
|
||||
"""잔재를 걸러 읽을 cross_*.json — **측점 차례**(이름 차례면 `01080.16` 이 `01080` 앞)."""
|
||||
valid = valid_cross_names(cross_dir, stations)
|
||||
|
||||
def chainage(path: Path) -> tuple[float, str]:
|
||||
match = re.fullmatch(rf"cross_{CROSS_KEY_PATTERN}m", path.stem)
|
||||
return (float(match.group(1)) if match else float("inf"), path.name)
|
||||
|
||||
files = [path for path in cross_dir.glob("cross_*.json") if not valid or path.name in valid]
|
||||
return sorted(files, key=chainage)
|
||||
|
||||
|
||||
def prune_stale_cross_files(cross_dir: Path, stations: list[Any]) -> set[str]:
|
||||
"""stations에 없는 잔재 cross_*.json을 삭제하고 유효 파일명 집합을 반환한다.
|
||||
|
||||
|
||||
@@ -11,9 +11,6 @@
|
||||
- **계획노선**(수정본) — 초기본에서 시작해 사용자가 **노드를 잡아** 고친 것.
|
||||
`planned_route.csv`. 설계 계통은 이것이 있으면 이것을 읽는다(`load_design_route`).
|
||||
|
||||
노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 초기 폴리라인을 읽으므로 「초기본을
|
||||
수정본으로 복사」와 결과가 같다.
|
||||
|
||||
노선 저장(346-3) — 여기서 노선을 갈아 끼운 뒤 `B05_Profile_Route_Save.run_route_save_chain` 이
|
||||
풀기 → 관 · 구조물 좌표 따라감 → 종횡단 한 번 → 계획고 옮김 → 횡단 이월(측점 열쇠) → B06 확정까지
|
||||
돈다. 배수유역은 다시 안 셈(낡음) · 관 정본은 안 지움 · 초기값은 추천 [적용](`as_initial`)일 때만.
|
||||
@@ -21,7 +18,6 @@
|
||||
|
||||
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
|
||||
POST /api/projects/{id}/route/replan → 노선 초안 저장(노선 저장 체인)
|
||||
POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
|
||||
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀)
|
||||
"""
|
||||
|
||||
@@ -669,30 +665,5 @@ async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, An
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/replan/reset", response_model=None)
|
||||
async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
"""계획노선을 예상노선으로 되돌리고 다시 계산한다(노선 초기화).
|
||||
|
||||
수정본 파일을 지우면 설계 계통이 원본을 읽으므로 「원본을 수정본으로 복사」와 같다.
|
||||
"""
|
||||
paths = await _project_paths(project_id)
|
||||
if paths is None:
|
||||
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
|
||||
project_root, stored_path = paths
|
||||
await asyncio.to_thread(_ensure_expected_route, project_root)
|
||||
radius_m, radius_limit_m, arc_limit_m = await _plan_criteria(project_id)
|
||||
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
|
||||
working_path = planned_route_working_path(project_root)
|
||||
if working_path.is_file():
|
||||
working_path.unlink()
|
||||
logger.info("계획노선 초기화(초기 폴리라인으로): project_id=%s", project_id)
|
||||
result = await _recompute(project_id, project_root, stored_path)
|
||||
if "error" in result:
|
||||
return JSONResponse(
|
||||
status_code=409, content={"status": "error", "message": result["error"]}
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **result}
|
||||
|
||||
|
||||
# `POST /route/initial` 은 나눈 파일에서 같은 `router` 에 붙는다 — 끝에서 불러 등록.
|
||||
from B05_Profile import B05_Profile_Router_RouteInitial # noqa: E402, F401
|
||||
|
||||
@@ -360,40 +360,6 @@ export function classifyStation(section: CrossSection): StationPieces | null {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 단면(도로측 → 바깥)에서 **경계 편거리 안쪽을 잘라낸다**(2026-08-25 구조물 절단).
|
||||
* 경계 바깥 조각만 남기고, 경계 지점을 보간해 첫 점으로 세운다 — 이 점이 행마다
|
||||
* 이어져 절단선(메시 모서리)이 된다. 전부 안쪽이면 null(조각 드롭).
|
||||
*/
|
||||
export function cutInnerTo(
|
||||
inner2outer: OffsetPoint[],
|
||||
boundaryOffset: number,
|
||||
side: "left" | "right",
|
||||
): OffsetPoint[] | null {
|
||||
const sign = side === "left" ? 1 : -1;
|
||||
const beyond = (point: OffsetPoint): number => (point.offset_m - boundaryOffset) * sign;
|
||||
const kept: OffsetPoint[] = [];
|
||||
for (let i = 0; i < inner2outer.length; i += 1) {
|
||||
const point = inner2outer[i];
|
||||
if (beyond(point) >= -1e-9) {
|
||||
if (!kept.length && i > 0) {
|
||||
// 경계 통과 지점을 보간해 첫 점으로.
|
||||
const prev = inner2outer[i - 1];
|
||||
const span = point.offset_m - prev.offset_m;
|
||||
const t = Math.abs(span) < 1e-12 ? 0 : (boundaryOffset - prev.offset_m) / span;
|
||||
kept.push({
|
||||
offset_m: boundaryOffset,
|
||||
elevation_m: prev.elevation_m + (point.elevation_m - prev.elevation_m) * t,
|
||||
});
|
||||
}
|
||||
kept.push({ ...point });
|
||||
}
|
||||
}
|
||||
if (kept.length < 2) return null;
|
||||
const width = Math.abs(kept[kept.length - 1].offset_m - kept[0].offset_m);
|
||||
return width < SLOPE_MIN_WIDTH_M ? null : kept;
|
||||
}
|
||||
|
||||
/** 비탈 폭 하한(m) — 트림 후 이보다 좁으면 그 행에서 비탈이 없는 것으로 본다. */
|
||||
export const SLOPE_MIN_WIDTH_M = 0.02;
|
||||
/** 폴리라인 안에서 지반을 못 만날 때 마지막 구배로 연장하는 한계(m)·걸음(m). */
|
||||
|
||||
@@ -16,26 +16,6 @@ import { FORD_MANNING_N, FORD_SLOPE } from "@config/config_frontend";
|
||||
* 문자열 리터럴로 두어 B05가 B06 모듈에 기대지 않게 한다. */
|
||||
export type InletStructureKind = "auto" | "revet" | "I" | "L" | "U";
|
||||
|
||||
/** 유입구 "구조" 목록 — 조정창의 구조물 형식(자동/기슭막이/집수정 I·ㄴ·ㄷ)을 이 한
|
||||
* 드롭다운으로 합쳤다(2026-08-29 사용자 지시 5: 일단 리스트를 합치고 중복은 뒤에 뺀다).
|
||||
* 고른 값 하나가 정본 둘을 정한다 — 관 옵션 `inlet_type`(기슭막이/집수정)과 B06
|
||||
* 유입측 형식(`inlet_structure`). */
|
||||
export const INLET_KINDS: ReadonlyArray<{
|
||||
label: string;
|
||||
type: "기슭막이" | "집수정";
|
||||
structure: InletStructureKind;
|
||||
}> = [
|
||||
{ label: "흙막이", type: "기슭막이", structure: "revet" },
|
||||
{ label: "집수정", type: "집수정", structure: "auto" },
|
||||
{ label: "자동(규칙)", type: "기슭막이", structure: "auto" },
|
||||
{ label: "집수정 I형", type: "집수정", structure: "I" },
|
||||
{ label: "집수정 ㄴ형", type: "집수정", structure: "L" },
|
||||
{ label: "집수정 ㄷ형", type: "집수정", structure: "U" },
|
||||
];
|
||||
|
||||
export const inletKindOf = (label: string): (typeof INLET_KINDS)[number] =>
|
||||
INLET_KINDS.find((kind) => kind.label === label) ?? INLET_KINDS[0];
|
||||
|
||||
/* ── 물넘이·세월교 개략 단면 ────────────────────────────────────────────── */
|
||||
|
||||
/** 월류 단면 한 벌 — 설계유량을 흘리는 데 **필요한** 수심·단면적과 그때의 유속. */
|
||||
@@ -363,61 +343,6 @@ export function createRevetmentFields(keys: RevetmentKeys): RevetmentFields {
|
||||
};
|
||||
}
|
||||
|
||||
/** 집수정 기준측점 전 · 후 한 줄 — 옛 조정창에만 있던 칸(계획서 24-4a). 길이 칸과 짝:
|
||||
* 전 + 후 = 길이 · 나중에 고친 쪽이 살고 반대쪽이 나머지(기슭막이 칸과 같은 규칙). */
|
||||
export function createBasinSplitFields(length: HTMLInputElement): {
|
||||
row: HTMLDivElement;
|
||||
write: (options: Record<string, string | number>) => void;
|
||||
read: (target: Record<string, string | number>) => void;
|
||||
onChange: (handler: () => void) => void;
|
||||
} {
|
||||
const before = numberInput("0.1", "0");
|
||||
const after = numberInput("0.1", "0");
|
||||
let last: HTMLInputElement = before;
|
||||
const sync = (): void => {
|
||||
const total = Number.parseFloat(length.value);
|
||||
if (!Number.isFinite(total)) return;
|
||||
const other = last === before ? after : before;
|
||||
const kept = Math.min(Math.max(Number.parseFloat(last.value) || 0, 0), total);
|
||||
last.value = kept.toFixed(1);
|
||||
other.value = (total - kept).toFixed(1);
|
||||
};
|
||||
return {
|
||||
row: grid(
|
||||
labeled("기준측점 전 (m)", stepper(before, 0.5), "inlet_basin_before_m"),
|
||||
labeled("기준측점 후 (m)", stepper(after, 0.5), "inlet_basin_after_m"),
|
||||
),
|
||||
write(options) {
|
||||
before.value =
|
||||
options.inlet_basin_before_m !== undefined ? String(options.inlet_basin_before_m) : "";
|
||||
after.value =
|
||||
options.inlet_basin_after_m !== undefined ? String(options.inlet_basin_after_m) : "";
|
||||
},
|
||||
read(target) {
|
||||
// 전/후는 0도 값(기준이 곧 끝) — 빈칸만 뺀다.
|
||||
for (const [key, input] of [
|
||||
["inlet_basin_before_m", before],
|
||||
["inlet_basin_after_m", after],
|
||||
] as const) {
|
||||
const value = Number.parseFloat(input.value);
|
||||
if (Number.isFinite(value) && value >= 0) target[key] = Number(value.toFixed(1));
|
||||
}
|
||||
},
|
||||
onChange(handler) {
|
||||
for (const input of [before, after]) {
|
||||
input.addEventListener("change", () => {
|
||||
last = input;
|
||||
sync();
|
||||
handler();
|
||||
});
|
||||
}
|
||||
length.addEventListener("change", () => {
|
||||
if (before.value || after.value) sync();
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 날개벽 옵션 키 묶음 — 유입·유출이 같은 조각을 쓰되 키만 다르다. */
|
||||
interface WingKeys {
|
||||
install: string;
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Drainage_Facility_Ford.ts
|
||||
* 물넘이포장·세월교 칸 묶음 — 월류 폭·높이·바닥 경사·포장 두께·길이·련 수 + 개략 단면 줄.
|
||||
*
|
||||
* 서브폼 본체(`_Drainage_Facility.ts`)가 700줄에 닿아 떼어 냄(2026-09-14).
|
||||
* 월류 폭 기본값(세월교 10m · 물넘이 5m · 등록부)은 시설이 생길 때 칸에 실제 값으로 들어옴
|
||||
* (PLAN 46-7 · 제안값 단추 없음). 빈 칸 안내 = 엔진이 비운 칸을 읽는 값(폭 기본 · 필요 수심).
|
||||
* 적은 높이가 필요 수심보다 낮으면 되돌림(계산값 미만은 안 받음).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
FORD_BRIDGE_DEFAULT_WIDTH_M,
|
||||
FORD_PAVEMENT_DEFAULT_WIDTH_M,
|
||||
} from "@config/config_frontend";
|
||||
import {
|
||||
fordSection,
|
||||
grid,
|
||||
labeled,
|
||||
numberInput,
|
||||
putNumber,
|
||||
stepper,
|
||||
} from "./B05_Profile_UI_Drainage_Facility_Fields";
|
||||
|
||||
type FordKind = "ford_pavement" | "ford_bridge";
|
||||
|
||||
export interface FordFields {
|
||||
widthRow: HTMLElement;
|
||||
slopeRow: HTMLElement;
|
||||
countRow: HTMLElement;
|
||||
summary: HTMLElement;
|
||||
/** 바뀌면 저장 흐름을 울릴 칸(높이는 자기 검사 뒤 스스로 울림). */
|
||||
inputs: HTMLInputElement[];
|
||||
setDesignFlow: (designFlow: number | null) => void;
|
||||
sync: () => void;
|
||||
write: (kind: FordKind, options: Record<string, string | number>) => void;
|
||||
read: (kind: FordKind, target: Record<string, string | number>) => void;
|
||||
}
|
||||
|
||||
export function createFordFields(emit: () => void): FordFields {
|
||||
// 세월교 — 구체 내 배관 수량(련). 련은 정수라 소수 자릿수를 두지 않는다.
|
||||
const count = numberInput("1", "1", "련");
|
||||
const countRow = grid(labeled("수량 (련)", stepper(count, 1, 0), "pipe_count"));
|
||||
const width = numberInput("0.1");
|
||||
const height = numberInput("0.01");
|
||||
const widthRow = grid(
|
||||
labeled("월류 폭 (m)", stepper(width, 0.1), "ford_width_m"),
|
||||
labeled("월류 높이 (m)", stepper(height, 0.1), "ford_height_m"),
|
||||
);
|
||||
// 물넘이 바닥은 유입(상류)이 높고 유출이 낮게 기운다(2026-08-28 사용자 확정).
|
||||
// 비우면 그 측점의 **노면 횡단경사**를 그대로 쓴다 — 횡단도가 판단한다.
|
||||
const slope = numberInput("0.1");
|
||||
slope.placeholder = "노면 기울기";
|
||||
// 포장 두께·노폭 방향 길이 — 수량(㎡ = 월류 폭 × 길이 · 두께로 원단위 고름)이 읽는 칸(A3).
|
||||
const thickness = numberInput("1");
|
||||
const length = numberInput("0.1");
|
||||
const slopeRow = grid(
|
||||
labeled("바닥 경사 유입→유출 (%)", stepper(slope, 0.1), "ford_slope_pct"),
|
||||
labeled("포장 두께 (㎝)", stepper(thickness, 1, 0), "thickness_cm"),
|
||||
labeled("포장 길이 노폭 방향 (m)", stepper(length, 0.1), "length_m"),
|
||||
);
|
||||
const summary = document.createElement("p");
|
||||
summary.className = "b05-drainage__facility-note";
|
||||
let designFlowM3s: number | null = null;
|
||||
/** 현재 조건(설계유량·월류 폭)의 필요 최소 수심(m). 계산 불가면 null. */
|
||||
let minDepthM: number | null = null;
|
||||
|
||||
/** 폭을 비우면 엔진이 쓰는 폭(`B06_Section_Engine_Culvert.FORD_*_DEFAULT_WIDTH_M` 짝). */
|
||||
const emptyWidth = (kind: FordKind): number =>
|
||||
kind === "ford_bridge" ? FORD_BRIDGE_DEFAULT_WIDTH_M : FORD_PAVEMENT_DEFAULT_WIDTH_M;
|
||||
|
||||
function sync(): void {
|
||||
const widthM = Number.parseFloat(width.value || width.dataset.empty || "");
|
||||
const section = designFlowM3s !== null ? fordSection(designFlowM3s, widthM) : null;
|
||||
minDepthM = section ? Number(section.depthM.toFixed(2)) : null;
|
||||
height.placeholder = minDepthM !== null ? `필요 수심 ${minDepthM.toFixed(2)} 이상` : "";
|
||||
if (designFlowM3s === null) {
|
||||
summary.textContent =
|
||||
"담당 유역의 설계유량이 아직 없습니다 — [유역 분석] 후 개략 단면이 나옵니다.";
|
||||
return;
|
||||
}
|
||||
const head = `설계유량 ${designFlowM3s.toFixed(3)} ㎥/s`;
|
||||
if (!section) {
|
||||
summary.textContent = `${head} · 월류 폭을 넣으면 필요 수심·단면을 계산합니다.`;
|
||||
return;
|
||||
}
|
||||
// 필요 최소 수심이다 — 월류 높이는 이 값 아래로 내릴 수 없다.
|
||||
summary.textContent =
|
||||
`${head} · 필요 수심 ${section.depthM.toFixed(2)} m · ` +
|
||||
`필요 단면 ${section.areaM2.toFixed(2)} ㎡ · 유속 ${section.velocityMs.toFixed(2)} m/s — ` +
|
||||
`월류 높이는 필요 수심 이상만 입력됩니다.`;
|
||||
}
|
||||
|
||||
// 적은 값이 계산값 미만이면 받지 않는다 — 계산값으로 되돌리고 잠깐 붉힌다(빈 칸은 그대로 빈 칸).
|
||||
height.addEventListener("change", () => {
|
||||
const value = Number.parseFloat(height.value);
|
||||
if (
|
||||
minDepthM !== null &&
|
||||
height.value !== "" &&
|
||||
(!Number.isFinite(value) || value < minDepthM)
|
||||
) {
|
||||
height.value = minDepthM.toFixed(2);
|
||||
height.classList.add("is-invalid");
|
||||
window.setTimeout(() => height.classList.remove("is-invalid"), 900);
|
||||
}
|
||||
emit();
|
||||
});
|
||||
|
||||
return {
|
||||
widthRow,
|
||||
slopeRow,
|
||||
countRow,
|
||||
summary,
|
||||
inputs: [count, width],
|
||||
setDesignFlow(designFlow) {
|
||||
designFlowM3s = designFlow ?? null;
|
||||
},
|
||||
sync,
|
||||
write(kind, options) {
|
||||
const text = (key: string): string =>
|
||||
options[key] !== undefined ? String(options[key]) : "";
|
||||
count.value = kind === "ford_bridge" ? text("pipe_count") : "";
|
||||
width.value = text("ford_width_m");
|
||||
width.dataset.empty = String(emptyWidth(kind));
|
||||
width.placeholder = `비우면 ${emptyWidth(kind)}`;
|
||||
height.value = text("ford_height_m");
|
||||
slope.value = text("ford_slope_pct");
|
||||
thickness.value = text("thickness_cm");
|
||||
length.value = text("length_m");
|
||||
},
|
||||
read(kind, target) {
|
||||
putNumber(target, "ford_width_m", width.value);
|
||||
// 월류 높이는 cm 단위 수심이라 0.01m 정밀도로 싣는다(putNumber는 0.1m 반올림).
|
||||
const value = Number.parseFloat(height.value);
|
||||
if (Number.isFinite(value) && value > 0) target.ford_height_m = Number(value.toFixed(2));
|
||||
if (kind === "ford_pavement") {
|
||||
putNumber(target, "ford_slope_pct", slope.value);
|
||||
putNumber(target, "thickness_cm", thickness.value);
|
||||
putNumber(target, "length_m", length.value);
|
||||
return;
|
||||
}
|
||||
const pipes = Number.parseInt(count.value, 10);
|
||||
if (Number.isFinite(pipes) && pipes > 0) target.pipe_count = pipes;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -58,7 +58,6 @@ export const hotspotToggleColor = (): string => themeColor("--map-flow-ramp-4",
|
||||
export const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b");
|
||||
|
||||
/** 접힘·폭은 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
|
||||
export const COLLAPSED_KEY = stateKey("drainage-collapsed") ?? "";
|
||||
/** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
|
||||
export const WIDTH_KEY = stateKey("drainage-width") ?? "";
|
||||
/** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */
|
||||
@@ -257,17 +256,6 @@ export function drawHotspots(
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/** 화면 좌표 폴리곤 안에 점이 있는지(홀짝 규칙). 유역을 눌러 고를 때 쓴다. */
|
||||
export function pointInRing(ring: ReadonlyArray<[number, number]>, x: number, y: number): boolean {
|
||||
let inside = false;
|
||||
for (let index = 0, previous = ring.length - 1; index < ring.length; previous = index++) {
|
||||
const [xi, yi] = ring[index];
|
||||
const [xj, yj] = ring[previous];
|
||||
if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/** 표시 토글 한 줄을 통째로 만든다 — 위성/도엽/화살표/강도/집중점/상류세류 6종.
|
||||
* 패널이 700줄 한계에 닿아 옮겼다. 상태는 갖지 않고 켜고 끌 때 넘겨받은 함수만 부른다. */
|
||||
export function mountDrainageToggles(
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { stationPartsLabel } from "../common_util/common_util_station";
|
||||
import { PIPE_DISPLAY_NAME } from "../common_util/common_util_structure_catalog";
|
||||
import {
|
||||
ESCAPE_ROUTE_DEFAULT_WIDTH_M,
|
||||
ESCAPE_ROUTE_WIDTHS_M,
|
||||
@@ -54,8 +55,6 @@ export interface IrregularStation {
|
||||
/** 관 매설 지점이 구조물 목록에 실체화될 때 쓰는 종류 값(구 데이터 판정 키라 유지 —
|
||||
* 화면 표시 이름은 "배수관"이다, 2026-08-17 사용자 지시). */
|
||||
export const PIPE_STRUCTURE_NAME = "배관";
|
||||
/** 화면에 보이는 이름. */
|
||||
export const PIPE_DISPLAY_NAME = "배수관";
|
||||
|
||||
/** 배수관으로 볼 항목인가. 드롭다운 종류가 "배관"이거나 손으로 이름을 "배관"·"배수관"이라
|
||||
* 적은 것도 관 지점 정본을 따르게 한다 — 아니면 [초기화]로 지워지지 않고 배수유역도와
|
||||
|
||||
@@ -124,14 +124,6 @@ export function findMinCoverViolations(
|
||||
return violations;
|
||||
}
|
||||
|
||||
/** 경고 문구 — 가장 크게 모자란 곳부터 알려 준다. */
|
||||
export function minCoverWarningText(violations: MinCoverViolation[]): string | null {
|
||||
if (!violations.length) return null;
|
||||
const worst = [...violations].sort((a, b) => b.shortfall_m - a.shortfall_m)[0];
|
||||
const more = violations.length > 1 ? ` 외 ${violations.length - 1}곳` : "";
|
||||
return `${worst.label} 최소고 ${worst.shortfall_m.toFixed(2)}m 부족${more}`;
|
||||
}
|
||||
|
||||
/** 종단 격자에서 그 자리 원지반고 — 가드 판정용 선형 보간. */
|
||||
function groundAt(base: AlignmentBase, chainageM: number): number | null {
|
||||
const { chainage: xs, ground: ys } = base;
|
||||
|
||||
@@ -53,18 +53,6 @@ export function summarizeCross(preview: CrossPreviewResponse): string {
|
||||
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`;
|
||||
}
|
||||
|
||||
/** 좌·우 설계 반폭(m) — 사면이 원지반과 만나는 자리까지. 못 만난 측은 기본 반폭(샘플 끝). */
|
||||
export function designHalfWidths(preview: CrossPreviewResponse): { leftM: number; rightM: number } {
|
||||
const offsets = preview.samples
|
||||
.map((sample) => Number(sample.offset_m))
|
||||
.filter((offset) => Number.isFinite(offset));
|
||||
const toes = preview.design ? toeOffsets(asSection(preview)) : { left: null, right: null };
|
||||
return {
|
||||
leftM: toes.left ?? Math.max(0, ...offsets), // +offset = 좌
|
||||
rightM: Math.abs(toes.right ?? Math.min(0, ...offsets)), // −offset = 우
|
||||
};
|
||||
}
|
||||
|
||||
/** B06 셈 함수가 보는 꼴로 맞춘다 — 그 둘은 `samples` 와 `design` 만 읽는다. */
|
||||
function asSection(preview: CrossPreviewResponse): CrossSection {
|
||||
return { samples: preview.samples, design: preview.design } as unknown as CrossSection;
|
||||
|
||||
@@ -29,14 +29,6 @@ export function pointInTri2(p: P2, a: P2, b: P2, c: P2): boolean {
|
||||
return !(hasNeg && hasPos);
|
||||
}
|
||||
|
||||
export function segmentsIntersect(a: P2, b: P2, c: P2, d: P2): boolean {
|
||||
const d1 = cross2(b.x - a.x, b.z - a.z, c.x - a.x, c.z - a.z);
|
||||
const d2 = cross2(b.x - a.x, b.z - a.z, d.x - a.x, d.z - a.z);
|
||||
const d3 = cross2(d.x - c.x, d.z - c.z, a.x - c.x, a.z - c.z);
|
||||
const d4 = cross2(d.x - c.x, d.z - c.z, b.x - c.x, b.z - c.z);
|
||||
return d1 * d2 < -EPS && d3 * d4 < -EPS;
|
||||
}
|
||||
|
||||
/** 경계 선분 하나 — AABB를 함께 들고 있어 격자 후보 판정이 싸다. */
|
||||
export interface RegionSegment {
|
||||
p: P2;
|
||||
|
||||
@@ -34,20 +34,6 @@ export function chainageToStation(
|
||||
return { station, remainder };
|
||||
}
|
||||
|
||||
/** "3+18.0" · "3 + 18" · "76.5"(누가거리 직접) 입력을 누가거리로 해석한다.
|
||||
* 해석 불가하면 null — 호출부가 칸을 붉히고 진행을 멈춘다. */
|
||||
export function parseStationText(text: string, intervalM: number): number | null {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return null;
|
||||
const match = /^(\d+)\s*\+\s*(\d+(?:\.\d+)?)$/.exec(trimmed);
|
||||
if (match) {
|
||||
const chainage = stationToChainage(Number(match[1]), Number(match[2]), intervalM);
|
||||
return Number.isFinite(chainage) ? chainage : null;
|
||||
}
|
||||
const direct = Number(trimmed);
|
||||
return Number.isFinite(direct) && direct >= 0 ? direct : null;
|
||||
}
|
||||
|
||||
/** 구조물 측점과 겹쳤다고 볼 거리(m) — 이보다 가까우면 규칙 측점을 지운다. */
|
||||
export const STATION_MERGE_TOLERANCE_M = 0.1;
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_Api_Context.ts
|
||||
* 종횡단 설정값(`GET …/sections/context`)의 **세션 캐시 창구** — 화면 쪽.
|
||||
*
|
||||
* `B06_Section_Api_Fetch.ts` 에서 갈라냄(472-2) — 서버 Node 묶음이 Api_Fetch 를 끌어도
|
||||
* 세션(`b_page_state`) · 되돌리기 사진첩(`Profile_History`)을 함께 끌지 않게.
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearState, readState, stateKey, writeState } from "../A00_Common/b_page_state";
|
||||
import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History";
|
||||
import { requestSectionContext } from "./B06_Section_Api_Fetch";
|
||||
import type { SectionContextResponse } from "./B06_Section_Api_Types";
|
||||
|
||||
/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */
|
||||
/** 종횡단 설정값 — ④ 계산 결과라 세션에 담아 두고 화면을 오갈 때마다 다시 묻지 않는다
|
||||
* (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. */
|
||||
export async function fetchSectionContext(projectId: string): Promise<SectionContextResponse> {
|
||||
const cached = readState<SectionContextResponse>("section-context", projectId);
|
||||
if (cached) return seedStandardCross(projectId, cached);
|
||||
const fresh = await requestSectionContext(projectId);
|
||||
writeState("section-context", fresh, projectId);
|
||||
return seedStandardCross(projectId, fresh);
|
||||
}
|
||||
|
||||
/** 서버가 지금 보는 노선 id — 세션 캐시를 건너뛰고 묻는다. [저장] · [확정] 앞에서 화면이 쥔
|
||||
* 노선과 대조한다(PLAN 76-1). 받은 설정값으로 캐시도 새로 둔다. */
|
||||
export async function fetchCurrentRouteId(projectId: string): Promise<number | null> {
|
||||
const fresh = await requestSectionContext(projectId);
|
||||
writeState("section-context", fresh, projectId);
|
||||
return fresh.route_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 표준 횡단면을 **세션이 비어 있을 때만** 채운다(2026-09-07).
|
||||
*
|
||||
* 브라우저 횡단 계산은 `세션 ?? config 기본값` 으로 서는데, 표준단면 세션값은 그 탭에서만
|
||||
* 산다. 그래서 **탭을 새로 열면** 화면은 config 기본값으로, 서버는 저장분으로 계산해
|
||||
* 같은 측점이 갈렸다(실측 — 용화 route 169 저장분은 암반 횡단경사 **5%**·측구 상단폭
|
||||
* **0.9m**, config 기본값은 **3%**·**0.69m**).
|
||||
*
|
||||
* 여기가 두 화면(B05·B06)이 함께 지나는 유일한 자리라 이 한 곳에서 채운다. 사용자가
|
||||
* 그 탭에서 고친 값이 있으면 **건드리지 않는다** — 초안이 언제나 우선이다.
|
||||
*/
|
||||
function seedStandardCross(
|
||||
projectId: string,
|
||||
context: SectionContextResponse,
|
||||
): SectionContextResponse {
|
||||
const stored = context.stored_standard_cross_section;
|
||||
if (stored && readState<unknown>("std-cross", projectId) === null) {
|
||||
writeState("std-cross", stored, projectId);
|
||||
seedPageHistory(stateKey("std-cross", projectId)); // 씨앗 — 되돌리기 걸음 아님(364-1)
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function clearSectionContextCache(projectId: string): void {
|
||||
clearState("section-context", projectId);
|
||||
}
|
||||
@@ -16,15 +16,11 @@
|
||||
* 기존 호출 코드가 깨지지 않도록 여기서 그대로 다시 내보낸다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearState, readState, stateKey, writeState } from "../A00_Common/b_page_state";
|
||||
import { seedPageHistory } from "../B05_Profile/B05_Profile_UI_Profile_History";
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { CrossDesignChoice } from "./B06_Section_Cross_Design_Session";
|
||||
import type {
|
||||
CrossDesign,
|
||||
CrossDesignRequest,
|
||||
CrossDesignResponse,
|
||||
CrossSection,
|
||||
CrossSectionPatch,
|
||||
SectionConfirmResponse,
|
||||
@@ -62,56 +58,11 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */
|
||||
/** 종횡단 설정값 — ④ 계산 결과라 세션에 담아 두고 화면을 오갈 때마다 다시 묻지 않는다
|
||||
* (2026-09-06 호출 정리). 노선이 바뀌면 `clearSectionContextCache` 로 버린다. */
|
||||
export async function fetchSectionContext(projectId: string): Promise<SectionContextResponse> {
|
||||
const cached = readState<SectionContextResponse>("section-context", projectId);
|
||||
if (cached) return seedStandardCross(projectId, cached);
|
||||
const fresh = await requestJson<SectionContextResponse>(
|
||||
`/projects/${projectId}/sections/context`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
writeState("section-context", fresh, projectId);
|
||||
return seedStandardCross(projectId, fresh);
|
||||
}
|
||||
|
||||
/** 서버가 지금 보는 노선 id — 세션 캐시를 건너뛰고 묻는다. [저장] · [확정] 앞에서 화면이 쥔
|
||||
* 노선과 대조한다(PLAN 76-1). 받은 설정값으로 캐시도 새로 둔다. */
|
||||
export async function fetchCurrentRouteId(projectId: string): Promise<number | null> {
|
||||
const fresh = await requestJson<SectionContextResponse>(
|
||||
`/projects/${projectId}/sections/context`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
writeState("section-context", fresh, projectId);
|
||||
return fresh.route_id;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 표준 횡단면을 **세션이 비어 있을 때만** 채운다(2026-09-07).
|
||||
*
|
||||
* 브라우저 횡단 계산은 `세션 ?? config 기본값` 으로 서는데, 표준단면 세션값은 그 탭에서만
|
||||
* 산다. 그래서 **탭을 새로 열면** 화면은 config 기본값으로, 서버는 저장분으로 계산해
|
||||
* 같은 측점이 갈렸다(실측 — 용화 route 169 저장분은 암반 횡단경사 **5%**·측구 상단폭
|
||||
* **0.9m**, config 기본값은 **3%**·**0.69m**).
|
||||
*
|
||||
* 여기가 두 화면(B05·B06)이 함께 지나는 유일한 자리라 이 한 곳에서 채운다. 사용자가
|
||||
* 그 탭에서 고친 값이 있으면 **건드리지 않는다** — 초안이 언제나 우선이다.
|
||||
*/
|
||||
function seedStandardCross(
|
||||
projectId: string,
|
||||
context: SectionContextResponse,
|
||||
): SectionContextResponse {
|
||||
const stored = context.stored_standard_cross_section;
|
||||
if (stored && readState<unknown>("std-cross", projectId) === null) {
|
||||
writeState("std-cross", stored, projectId);
|
||||
seedPageHistory(stateKey("std-cross", projectId)); // 씨앗 — 되돌리기 걸음 아님(364-1)
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function clearSectionContextCache(projectId: string): void {
|
||||
clearState("section-context", projectId);
|
||||
/** 종횡단 설정값 한 번 묻기(캐시 없음) — 세션 캐시 창구는 `B06_Section_Api_Context.ts`(472-2). */
|
||||
export function requestSectionContext(projectId: string): Promise<SectionContextResponse> {
|
||||
return requestJson<SectionContextResponse>(`/projects/${projectId}/sections/context`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
/** 경로의 종단면 요약을 조회한다. */
|
||||
@@ -161,18 +112,6 @@ export async function regenerateSections(
|
||||
);
|
||||
}
|
||||
|
||||
/** 측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산·저장하고 잠정 결과를 반환한다. */
|
||||
export async function computeCrossDesign(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
request: CrossDesignRequest,
|
||||
): Promise<CrossDesignResponse> {
|
||||
return requestJson<CrossDesignResponse>(
|
||||
`/projects/${projectId}/sections/${routeId}/cross-design`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
);
|
||||
}
|
||||
|
||||
/** 경로의 종·횡단면을 확정한다. 표준 횡단면 설정값·측점별 세션 보관값을 함께 저장할 수 있다. */
|
||||
export async function confirmSections(
|
||||
projectId: string,
|
||||
|
||||
@@ -550,35 +550,6 @@ export interface CrossDesign {
|
||||
pipe_length_m?: number;
|
||||
}
|
||||
|
||||
export interface CrossDesignResponse {
|
||||
status: string;
|
||||
chainage_m: number;
|
||||
design: CrossDesign;
|
||||
}
|
||||
|
||||
export interface CrossDesignRequest {
|
||||
chainage_m: number;
|
||||
ground_type: GroundType;
|
||||
section_mode: SectionMode;
|
||||
ditch_side?: DitchSide | null;
|
||||
/** 측구 형식(일반/L형). L형은 암 지반에서만 허용된다. */
|
||||
ditch_type?: DitchType;
|
||||
/** 포장 중첩 여부 — 횡단경사·포장층만 포장 그룹 값으로 계산된다. */
|
||||
paved?: boolean;
|
||||
/** 암 경계선 오프셋(m, 지면선 기준 하향 음수). 암 지반 2단계 절토 무릎 계산용. */
|
||||
rock_boundary_offset_m?: number | null;
|
||||
/** 암 지반 2단계 경사 적용 여부(기본 true, 토글로 해제). */
|
||||
two_stage_slope?: boolean;
|
||||
/** 이 측점만 쓰는 암 절토 경사비(1:n 의 n). 없으면 표준값(2026-09-07). */
|
||||
cut_slope_ratio?: number | null;
|
||||
/** 옛 이름 — 결과값이 그대로 실려 오던 자리(호환). 새 요청은 `ditch_choice` 를 쓴다. */
|
||||
ditch_enabled?: boolean | null;
|
||||
/** 측구를 둘지 **사용자가 정한 선택**. `null`/미지정 = 자동 판정(2026-09-09 정리). */
|
||||
ditch_choice?: boolean | null;
|
||||
/** 설정 패널 편집값. 요청값 → config 기본값 순으로 우선한다. */
|
||||
standard_cross_section?: StandardCrossSection;
|
||||
}
|
||||
|
||||
/** 확정 시 측점별 data.design에 병합할 프론트 세션 보관값. */
|
||||
export interface CrossSectionPatch {
|
||||
chainage_m: number;
|
||||
|
||||
@@ -31,15 +31,6 @@ export interface CrossDesignChoice {
|
||||
const readAll = (projectId: string, routeId: number) =>
|
||||
readKeyed<CrossDesignChoice>("crossdesign", projectId, routeId);
|
||||
|
||||
/** 이 측점의 선택값(없으면 null). */
|
||||
export function readCrossDesignChoice(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
chainageM: number,
|
||||
): CrossDesignChoice | null {
|
||||
return readAll(projectId, routeId).get(chainageKey(chainageM)) ?? null;
|
||||
}
|
||||
|
||||
/** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */
|
||||
export function writeCrossDesignChoice(
|
||||
projectId: string,
|
||||
|
||||
@@ -16,18 +16,12 @@ from fastapi.responses import JSONResponse, Response
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from B05_Profile.B05_Profile_Engine_Sections import (
|
||||
cross_files_in_order,
|
||||
relabel_pipe_stations,
|
||||
run_section_generation,
|
||||
valid_cross_names,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import SectionGenerationOptions
|
||||
from B06_Section.B06_Section_Engine_Attach import attach_saved_shapes
|
||||
from B06_Section.B06_Section_Engine_Design import (
|
||||
compute_cross_design,
|
||||
curve_widening_args,
|
||||
stored_shoulder_extend,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Tilt import tilt_args, tilt_ranges
|
||||
from B06_Section.B06_Section_Repository import (
|
||||
count_cross_sections,
|
||||
create_longitudinal_section,
|
||||
@@ -40,26 +34,13 @@ from B06_Section.B06_Section_Repository import (
|
||||
get_workflow_route_context,
|
||||
insert_cross_sections,
|
||||
list_recent_company_projects,
|
||||
update_cross_section_design,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
PREVIEW_DESIGN_FIELDS as _PREVIEW_DESIGN_FIELDS,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
USER_TOUCHED_KEYS,
|
||||
ford_drop_at,
|
||||
ford_surface_drops,
|
||||
paved_at,
|
||||
pavement_ranges,
|
||||
stored_berm,
|
||||
stored_cut_slope,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
attach_default_designs as _attach_default_designs,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
read_cross_design_inputs as _read_cross_design_inputs,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Design import (
|
||||
recompute_designs_for_alignment as _recompute_designs_for_alignment,
|
||||
)
|
||||
@@ -76,8 +57,6 @@ from B06_Section.B06_Section_Schema import (
|
||||
CompanyStandardResponse,
|
||||
CrossDesignPreviewRequest,
|
||||
CrossDesignPreviewResponse,
|
||||
CrossDesignRequest,
|
||||
CrossDesignResponse,
|
||||
HaulEquipmentLimit,
|
||||
SectionContextResponse,
|
||||
SectionDetailResponse,
|
||||
@@ -269,12 +248,10 @@ def _read_section_detail(project_root: Path, longitudinal_file_path: str) -> dic
|
||||
|
||||
longitudinal = json.loads(longitudinal_path.read_text(encoding="utf-8"))
|
||||
stations = longitudinal.get("stations") if isinstance(longitudinal, dict) else None
|
||||
# 조회는 지우지 않음 — 잔재는 걸러 읽기만(지우기는 측점을 쓰는 쪽 몫)
|
||||
valid_names = valid_cross_names(cross_dir, stations if isinstance(stations, list) else [])
|
||||
# 조회는 지우지 않음 — 잔재는 걸러 읽기만(지우기는 측점을 쓰는 쪽 몫) · 측점 차례(472-2)
|
||||
cross_sections = [
|
||||
json.loads(path.read_text(encoding="utf-8"))
|
||||
for path in sorted(cross_dir.glob("cross_*.json"))
|
||||
if not valid_names or path.name in valid_names
|
||||
for path in cross_files_in_order(cross_dir, stations if isinstance(stations, list) else [])
|
||||
]
|
||||
if not isinstance(longitudinal, dict) or not all(
|
||||
isinstance(section, dict) for section in cross_sections
|
||||
@@ -597,102 +574,3 @@ async def preview_cross_designs(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "횡단 설계 프리뷰 계산 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/cross-design", response_model=CrossDesignResponse)
|
||||
async def compute_cross_section_design(
|
||||
project_id: UUID, route_id: int, request: CrossDesignRequest
|
||||
) -> CrossDesignResponse | JSONResponse:
|
||||
"""측점 표준횡단 설계(지반유형·단면유형)를 즉시 계산해 잠정치로 저장한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
longitudinal = await get_longitudinal_section(connection, project_id, route_id)
|
||||
if not longitudinal:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "종횡단 상세 결과가 없습니다."},
|
||||
)
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
# 저장분을 **계산 전에** 읽는다 — 소단은 사용자 조작값이면서 **기하 입력**이라,
|
||||
# 나중에 키만 베껴 붙이면 설계선·면적은 계단 없이 나오고 `berm` 값만 남아
|
||||
# 서로 어긋난다(2026-09-07). 값을 나르는 다른 사용자 키와 다른 점이다.
|
||||
stored_designs = await get_cross_section_designs(connection, route_id)
|
||||
stored_design: dict[str, Any] | None = None
|
||||
for record in stored_designs:
|
||||
if abs(float(record["chainage_m"]) - request.chainage_m) < 0.01:
|
||||
candidate = record.get("design")
|
||||
stored_design = candidate if isinstance(candidate, dict) else None
|
||||
break
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
samples, design_elevation, pavement_suggested, cross_record = await asyncio.to_thread(
|
||||
_read_cross_design_inputs,
|
||||
project_root,
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
request.chainage_m,
|
||||
)
|
||||
design = compute_cross_design(
|
||||
samples,
|
||||
design_elevation,
|
||||
ground_type=request.ground_type,
|
||||
section_mode=request.section_mode,
|
||||
ditch_side=request.ditch_side,
|
||||
ditch_type=request.ditch_type,
|
||||
paved=paved_at(request.chainage_m, pavement_ranges(project_root)),
|
||||
standard=request.standard_cross_section,
|
||||
rock_boundary_offset_m=request.rock_boundary_offset_m,
|
||||
two_stage_slope=request.two_stage_slope,
|
||||
# 측점별 암 절토 경사 — 요청값이 없으면 저장분에서 잇는다(2026-09-07).
|
||||
cut_slope_ratio=request.cut_slope_ratio or stored_cut_slope(stored_design or {}),
|
||||
ditch_enabled=request.ditch_enabled,
|
||||
ditch_choice=request.ditch_choice,
|
||||
surface_drop_m=ford_drop_at(request.chainage_m, ford_surface_drops(project_root)),
|
||||
berm=stored_berm(stored_design or {}),
|
||||
**curve_widening_args(cross_record),
|
||||
**stored_shoulder_extend(stored_design or {}), # 노견 확장(139-6)
|
||||
**tilt_args(request.chainage_m, tilt_ranges(project_root)),
|
||||
)
|
||||
# B06 지정 시점은 잠정치. B07 도면 확정 시 동일 엔진으로 재계산해 confirmed로 승격한다.
|
||||
design["status"] = "provisional"
|
||||
# 법정 근거 문구 표기용 — 사용자가 포장을 바꿔도 제안 여부는 그대로 남긴다.
|
||||
design["pavement_suggested"] = pavement_suggested
|
||||
# 표시 설정(측점 개별 반폭)은 계산 입력이 아니다 — 저장분에서 이월해 재계산이
|
||||
# 지우지 않게 한다(2026-08-06). 목록을 여기 다시 적지 않는다 — 서버의 한 벌은
|
||||
# `B06_Section_Router_Design.USER_TOUCHED_KEYS` 다(2026-09-07). 예전에는 여기에
|
||||
# 따로 적어 두어 `extra_spans` 가 빠져 있었다.
|
||||
if stored_design is not None:
|
||||
for key in USER_TOUCHED_KEYS:
|
||||
if stored_design.get(key) is not None:
|
||||
design[key] = stored_design[key]
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
updated = await update_cross_section_design(
|
||||
connection,
|
||||
route_id=route_id,
|
||||
chainage_m=request.chainage_m,
|
||||
design=design,
|
||||
project_id=project_id,
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
if not updated:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "해당 측점 횡단 레코드가 없습니다."},
|
||||
)
|
||||
return CrossDesignResponse(chainage_m=request.chainage_m, design=design)
|
||||
except FileNotFoundError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except ValueError as exc:
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"B06 측점 횡단 설계 계산 실패: project_id=%s route_id=%s", project_id, route_id
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "횡단 설계 계산 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
@@ -63,14 +63,6 @@ class CrossDesignRequest(BaseModel):
|
||||
standard_cross_section: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class CrossDesignResponse(BaseModel):
|
||||
"""측점 표준횡단 설계 계산 결과(잠정치)."""
|
||||
|
||||
status: str = "success"
|
||||
chainage_m: float
|
||||
design: dict[str, Any]
|
||||
|
||||
|
||||
class BasinAdjustPatch(BaseModel):
|
||||
"""집수정 내부 치수와 기본 위치 대비 이동량."""
|
||||
|
||||
|
||||
@@ -229,6 +229,42 @@ def _enforce_stored_designs(
|
||||
enforce_ford_surface_drops(longitudinal, sections, project_root, standard)
|
||||
|
||||
|
||||
def refresh_alignment(longitudinal: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""종단 경고를 저장 입력(base_pvi · 편집 · 정책 · 기준)으로 지금 코드가 다시 셈 — 옛 코드가
|
||||
쓴 경고가 남지 않게(472 덧 · 요구 272). 기하(계획고 · 곡선 · 표본 · 위반)는 저장값 그대로 —
|
||||
저장값이 6 자리 반올림이라 통째로 다시 쓰면 끝자리가 흔들림(계획고 1e-6 m → 면적 1e-4 ㎡).
|
||||
반환 = 요약(`grade_summary`) · 선형이 없는 옛 종단이면 None(그대로)."""
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
|
||||
stored = longitudinal.get("profile_alignment")
|
||||
if not isinstance(stored, dict) or not stored.get("base_pvi"):
|
||||
return None
|
||||
fresh, profile = rebuild_alignment_profile(longitudinal, stored.get("edits"))
|
||||
stored["warnings"] = fresh["warnings"]
|
||||
profiles = longitudinal.get("design_profiles")
|
||||
if isinstance(profiles, list) and profiles and isinstance(profiles[0], dict):
|
||||
profile = profiles[0]
|
||||
profile.setdefault("summary", {})["warnings"] = fresh["warnings"]
|
||||
else:
|
||||
longitudinal["design_profiles"] = [profile]
|
||||
return {"id": profile.get("id"), **profile["summary"]}
|
||||
|
||||
|
||||
def _refresh_alignment_file(project_root: Path, relative: str) -> dict[str, Any] | None:
|
||||
"""종단 정본 파일의 선형을 다시 셈 · 바뀐 때만 씀. 파일 · 선형이 없으면 None."""
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
path = project_root / relative
|
||||
if not path.is_file():
|
||||
return None
|
||||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||||
before = json.dumps(longitudinal, sort_keys=True, default=str)
|
||||
summary = refresh_alignment(longitudinal)
|
||||
if summary is not None and json.dumps(longitudinal, sort_keys=True, default=str) != before:
|
||||
atomic_write_json(path, longitudinal)
|
||||
return summary
|
||||
|
||||
|
||||
async def recompute_server_side(project_id: UUID | str, route_id: int) -> int:
|
||||
"""포장 구간·세월교 노면 하강 보정에 더해 구조물 면적·유토곡선까지 Node 로 만들어 저장한다.
|
||||
|
||||
@@ -240,19 +276,40 @@ async def recompute_server_side(project_id: UUID | str, route_id: int) -> int:
|
||||
|
||||
|
||||
async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
|
||||
from B06_Section.B06_Section_Router import get_section_detail
|
||||
|
||||
project_uuid = UUID(str(project_id))
|
||||
marks = [("시작", time.perf_counter())]
|
||||
# 상세 만들기(파일 읽기 위주)와 DB 두 건은 서로 기다릴 이유가 없다 — 같이 보낸다.
|
||||
# 원격 DB 라 순차로 내면 왕복이 그대로 더해진다(질의 하나 약 12ms, 2026-09-06 실측).
|
||||
# 원격 DB 라 순차로 내면 왕복이 그대로 더해진다(질의 하나 약 12ms, 2026-09-06 실측) — 같이 보냄.
|
||||
pool = get_db_pool()
|
||||
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
|
||||
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다).
|
||||
response, stored_path, longitudinal_row, haul_inputs, surface = await asyncio.gather(
|
||||
get_section_detail(project_uuid, route_id),
|
||||
stored_path, longitudinal_row = await asyncio.gather(
|
||||
run_with_connection(get_project_storage_relative_path, project_uuid),
|
||||
run_with_connection(get_longitudinal_section, project_uuid, route_id),
|
||||
)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
# 종단 경고 먼저 — 저장 입력으로 다시 셈(472 덧) · 아래 상세가 새 경고를 읽음
|
||||
summary = await asyncio.to_thread(
|
||||
_refresh_alignment_file,
|
||||
project_root,
|
||||
str((longitudinal_row or {}).get("longitudinal_file_path") or ""),
|
||||
)
|
||||
if summary is not None:
|
||||
async with pool.acquire() as connection:
|
||||
await connection.begin()
|
||||
try:
|
||||
await update_longitudinal_grade_summary(
|
||||
connection, route_id=route_id, grade_summary=summary
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
marks.append(("종단 선형 다시 셈", time.perf_counter()))
|
||||
# 구조물 몫(채집석 공제·구조물 잔토)도 함께 받아 온다 — **B08 이 낸 값**이고, 안 넘기면
|
||||
# 통로만 있고 값이 안 흐른다(2026-09-09 실측: 공제가 늘 `None` 이라 사토가 안 줄었다).
|
||||
response, haul_inputs, surface = await asyncio.gather(
|
||||
get_section_detail(project_uuid, route_id),
|
||||
haul_inputs_for(project_uuid),
|
||||
run_with_connection(get_surface_confirmation_params, str(project_uuid)),
|
||||
)
|
||||
@@ -262,7 +319,6 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
|
||||
raise RuntimeError(f"서버 재계산: 종횡단 상세를 못 받음 (route_id={route_id})")
|
||||
detail = payload(mode="json")
|
||||
sections = detail.get("cross_sections") or []
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
|
||||
|
||||
standard = stored_standard_cross_section(longitudinal_row)
|
||||
|
||||
@@ -232,9 +232,6 @@ export function registeredKeys(
|
||||
return sections.map((s, i) => (list && nearest(list, s.chainage_m)?.key) ?? computed[i].key);
|
||||
}
|
||||
|
||||
/** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */
|
||||
export const currentStationScope = () => current;
|
||||
|
||||
function nearest(list: Keyed[], chainageM: number): Keyed | undefined {
|
||||
let best: Keyed | undefined;
|
||||
for (const entry of list) {
|
||||
|
||||
@@ -22,11 +22,6 @@ import {
|
||||
import { EXTRA_SPAN_DEFAULT } from "./logic/B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { StructureSpan } from "./logic/B06_Section_UI_Cross_Culvert_Const";
|
||||
import type { WallAdjust } from "./B06_Section_UI_Cross_Culvert_Types";
|
||||
import {
|
||||
designInterpolator,
|
||||
groundInterpolator,
|
||||
slopeToeOffset,
|
||||
} from "./logic/B06_Section_UI_Cross_Culvert_Solve";
|
||||
/**
|
||||
* 기슭막이 4축 조작 제어(2026-08-22 확정) — 벽을 눌러 고르고 조정창에서
|
||||
* 좌우(x)·상하(d, 성토선 대각)·높이(h)·재질(m)을 만진다. 고른 벽은 여기 담아 두어
|
||||
@@ -648,26 +643,3 @@ function adjustsInput(
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* **구조물 없는 상태**의 설계 절·성토선이 원지반과 만나는 지점이 들어오는 데
|
||||
* 필요한 표시 반폭(m, 여유 0.5 포함) — 2026-08-22 사용자 확정 줌 기준.
|
||||
* 구조물(기슭막이·집수정)이 어디로 가든 화면 기준은 지형·설계선이 정한다.
|
||||
*/
|
||||
export function culvertRequiredHalfWidth(section: CrossSection): number | null {
|
||||
const design = section.design;
|
||||
if (!design) return null;
|
||||
const groundAt = groundInterpolator(section.samples);
|
||||
const designAt = designInterpolator(design.design_line);
|
||||
const edges = design.road_edges;
|
||||
if (!groundAt || !designAt || !edges) return null;
|
||||
const offsets = section.samples.map((sample) => sample.offset_m ?? 0);
|
||||
const limits = { left: Math.max(...offsets), right: Math.min(...offsets) };
|
||||
let extent = 0;
|
||||
for (const side of ["left", "right"] as const) {
|
||||
const edge = side === "left" ? edges.left : edges.right;
|
||||
const toe = slopeToeOffset(designAt, groundAt, edge.offset_m, limits[side]);
|
||||
extent = Math.max(extent, Math.abs(toe));
|
||||
}
|
||||
return extent > 0 ? extent + 0.5 : null;
|
||||
}
|
||||
|
||||
@@ -8,15 +8,9 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { PIPE_TYPES } from "@config/config_frontend";
|
||||
import { PIPE_DISPLAY_NAME } from "../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import { pipeTitleKinds } from "../common_util/common_util_structure_catalog";
|
||||
import { PIPE_DISPLAY_NAME, pipeTitleKinds } from "../common_util/common_util_structure_catalog";
|
||||
import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design";
|
||||
import type {
|
||||
DesignProfile,
|
||||
LongitudinalSection,
|
||||
SectionDetailResponse,
|
||||
SectionSample,
|
||||
} from "./B06_Section_Api_Fetch";
|
||||
import type { DesignProfile, LongitudinalSection, SectionSample } from "./B06_Section_Api_Fetch";
|
||||
|
||||
export type { CrossDesignChange };
|
||||
export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void;
|
||||
@@ -274,34 +268,6 @@ export function longitudinalMaxChainage(data: LongitudinalSection): number {
|
||||
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 종·횡단 공통 Y스케일. `longHeightPx`는 종단면도의 실제 렌더 높이로, 패널 리사이즈로
|
||||
* 종단도가 줄면 그 높이를 넘겨야 표고 범위가 잘리지 않는다(기본은 고정 높이).
|
||||
*/
|
||||
export function calculateYScale(
|
||||
detail: SectionDetailResponse,
|
||||
longHeightPx: number = LONG_HEIGHT,
|
||||
): YScaleOptions | undefined {
|
||||
const elevations = [
|
||||
...detail.longitudinal.samples.map((sample) => sample.elevation_m),
|
||||
...detail.cross_sections.flatMap((section) =>
|
||||
section.samples.map((sample) => sample.elevation_m),
|
||||
),
|
||||
...(detail.longitudinal.design_profiles ?? []).flatMap((profile) =>
|
||||
profile.samples.map((sample) => sample.elevation_m),
|
||||
),
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
if (!elevations.length) return undefined;
|
||||
const globalMinElevation = Math.min(...elevations);
|
||||
const globalMaxElevation = Math.max(...elevations);
|
||||
const plotHeight = Math.max(longHeightPx - LONG_PAD.top - LONG_PAD.bottom, 1);
|
||||
return {
|
||||
pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1),
|
||||
globalMinElevation,
|
||||
globalMaxElevation,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* **보이는 구간의 표고 최저·최고**(종단 그래프 세로 자동 맞춤, 2026-09-04 사용자 확정).
|
||||
*
|
||||
|
||||
@@ -1,21 +1,14 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Section_View_Menu.ts
|
||||
* B06 종단 그래프 위 **우클릭 메뉴** — B05 와 같은 부품(`mountStructureMenu`)을 그대로 쓴다.
|
||||
*
|
||||
* 왜 여기서도 여나(2026-09-12 사용자) — B05·B06 은 한 페이지다. 같은 그래프를 보면서
|
||||
* 한쪽에서만 넣고 뺄 수 있으면 사용자가 화면을 오가야 하고 직관도 깨진다.
|
||||
* B06 종단 그래프 구조물 조작 길(넣기 · 빼기 · 옮기기) — 페이지가 물려 준다.
|
||||
*
|
||||
* 정본에 바로 쓰지 않는다 — 추가·삭제·이동은 **세션에 예약**하고 [저장]·[확정]에서
|
||||
* 관 정본(`pipe_points.json`)으로 나간다(CLAUDE.md 5장). 세부 배수유역 재분할은 그때
|
||||
* 서버가 관 목록을 다시 받아 처리한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { StructureInstance, StructureType } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { structureAnchorM } from "../B05_Profile/B05_Profile_Api_Structures";
|
||||
import type { IrregularStation } from "../B05_Profile/B05_Profile_UI_IrregularStations";
|
||||
import { mountStructureMenu } from "../B05_Profile/B05_Profile_UI_Profile_Structures";
|
||||
import { pickGroups } from "../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
|
||||
import { listPick } from "../common_util/common_util_structure_catalog";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
|
||||
/** 종단 그래프에서 구조물을 넣고 빼는 길 — 페이지가 물려 준다. 없으면 메뉴를 안 붙인다. */
|
||||
@@ -87,49 +80,3 @@ function moveStructureMark(
|
||||
export function isPipeMark(structure: StructureInstance): boolean {
|
||||
return String(structure.structure_id ?? "").startsWith("pipe-");
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 그래프 칸에 우클릭 메뉴를 붙인다. 그래프는 그릴 때마다 새로 만들어지므로 이
|
||||
* 함수도 그때마다 다시 부른다 — 상태를 밖에 남기지 않는다(B05 와 같은 규칙).
|
||||
*/
|
||||
export function mountSectionStructureMenu(
|
||||
host: HTMLElement,
|
||||
input: {
|
||||
structures: ReadonlyArray<StructureInstance>;
|
||||
types: ReadonlyArray<StructureType>;
|
||||
x: (chainageM: number) => number;
|
||||
chainageAt: (px: number) => number;
|
||||
maxChainageM: number;
|
||||
edit: SectionStructureEdit;
|
||||
},
|
||||
): void {
|
||||
// 메뉴가 쓰는 최소 정보만 옮겨 담는다 — B06 에는 B05 의 비정규 측점 목록이 없다.
|
||||
const stations: IrregularStation[] = input.structures.map((structure) => {
|
||||
const chainage = structureAnchorM(structure);
|
||||
const pipe = isPipeMark(structure);
|
||||
return {
|
||||
id: String(structure.structure_id),
|
||||
station: 0,
|
||||
remainder: 0,
|
||||
chainage_m: chainage,
|
||||
structure:
|
||||
input.types.find((type) => type.type_id === structure.type_id)?.name ??
|
||||
String(structure.type_id),
|
||||
origin: pipe ? "pipe" : "user",
|
||||
} as IrregularStation;
|
||||
});
|
||||
|
||||
mountStructureMenu(host, {
|
||||
stations,
|
||||
x: input.x,
|
||||
chainageAt: input.chainageAt,
|
||||
maxChainageM: input.maxChainageM,
|
||||
onRemove: (station) => {
|
||||
if (station.origin === "pipe") input.edit.removePipe(station.chainage_m);
|
||||
else input.edit.removeStructure(station.id);
|
||||
},
|
||||
onAddPipe: (chainageM) => input.edit.addPipe(chainageM),
|
||||
structureGroups: () => pickGroups(input.types, listPick("b06")), // B06 패널 거르기와 같음(129-1 · 322-1)
|
||||
onAddStructureType: (chainageM, typeId) => input.edit.addStructureType(chainageM, typeId),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -28,9 +28,6 @@ export const REVET_THICKNESS_M = 0.45;
|
||||
* 못 가른 자리만 이 값. 돌쌓기 벽은 `B06_Section_UI_Cross_Lean.leanOf`(품셈 13-4-4 [주]⑪ 표준경사). */
|
||||
export const REVET_LEAN_RATIO = 0.3;
|
||||
|
||||
/** 사다리꼴(배면부) 상단 폭(m) — 띠 두께의 절반(2026-08-20 사용자 확정). */
|
||||
export const REVET_TRAP_TOP_M = REVET_THICKNESS_M / 2;
|
||||
|
||||
/** 성토 위 이음 = 벽 윗면 사면 쪽 끝에서 이만큼 안(m · 371-1). */
|
||||
export const WALL_FILL_TOP_INSET_M = 0.1;
|
||||
|
||||
@@ -122,8 +119,6 @@ export function revetFormOf(m: string | null | undefined, specForm?: string | nu
|
||||
return m || specForm || REVET_FORM_DEFAULT;
|
||||
}
|
||||
|
||||
export const REVET_MATERIALS: RevetMaterial[] = ["dry", "wet", "concrete"];
|
||||
|
||||
export function materialLabel(material: RevetMaterial): string {
|
||||
return material === "dry" ? "메쌓기" : material === "wet" ? "찰쌓기" : "콘크리트";
|
||||
}
|
||||
@@ -195,7 +190,6 @@ export const EXTRA_TIER_SLOPE_M = 1.0;
|
||||
* (2026-08-21 사용자 지시).
|
||||
*/
|
||||
export const FILL_SLOPE_RATIO_MIN = 1.2;
|
||||
export const FILL_SLOPE_RATIO_MAX = 2.0;
|
||||
|
||||
/** 성토사면 길이 한계(m) — `성토_비탈면.md` §2 "5m 이내". 초과 시 옹벽·석축 등 의무. */
|
||||
export const FILL_SLOPE_MAX_LENGTH_M = 5.0;
|
||||
@@ -221,13 +215,6 @@ export const PIPE_WALL_DEFAULT_RUN_M =
|
||||
export const pipeWallDefaultD = (adjust: { x: number }): number =>
|
||||
Math.abs(adjust.x) > 1e-9 ? PIPE_WALL_DEFAULT_RUN_M : 0;
|
||||
|
||||
/**
|
||||
* 성토 전체고(노견 − 원지반) 기준(m) — 이 높이를 넘으면 1:1.2로도 사면이 5m를 넘어
|
||||
* 구조물이 필요해진다(3m × √(1+1.2²) = 4.69m, 3.2m에서 5.0m). 기슭막이를 자연 사면
|
||||
* 끝이 아니라 **사면길이 5m 지점**으로 끌어올리는 판정에 쓴다(2026-08-21 사용자 지시).
|
||||
*/
|
||||
export const FILL_STRUCTURE_HEIGHT_M = 3.0;
|
||||
|
||||
/**
|
||||
* 성토 사면이 성립하는 **최소 성토고**(m) — 노견 − 벽 이음선 상단.
|
||||
*
|
||||
@@ -237,13 +224,6 @@ export const FILL_STRUCTURE_HEIGHT_M = 3.0;
|
||||
*/
|
||||
export const FILL_MIN_RISE_M = 0.3;
|
||||
|
||||
/**
|
||||
* 집수정 바닥 여유(m) — **관 하단 아래**로 두는 깊이. 상부 여유는 기슭막이와 같은
|
||||
* `REVET_FREEBOARD_M`(0.5)을 쓴다. 집수정 깊이 = 바닥 여유 + 관경 + 상부 여유
|
||||
* (2026-08-21 사용자 확정 — 종전에는 성토 경사선과의 교점으로 높이를 잡았다).
|
||||
*/
|
||||
export const BASIN_BOTTOM_CLEARANCE_M = 0.5;
|
||||
|
||||
/**
|
||||
* 집수정 내부 높이(m) — 바닥판이 있는 ㄴ(L)·ㄷ형의 **바닥 윗면~벽 상단** 깊이.
|
||||
* 프로젝트 기본값 1.2m(2026-08-22 사용자 확정). 외부 기준은 확인되지 않았다.
|
||||
|
||||
@@ -196,26 +196,6 @@ export function cutLength(corners: { inlet: PipeEnd; outlet: PipeEnd }): number
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 유출 벽을 그 자리에 세웠을 때의 성토 사면(물매 1:n·사면길이)을 재는 함수를 만든다.
|
||||
* 관 길이 정수 맞춤이 벽을 옮겨도 되는 자리인지 판정하는 데 쓴다.
|
||||
*/
|
||||
export function outletSlopeFactory(
|
||||
info: { edge: { offset_m: number; elevation_m: number }; outward: number },
|
||||
invertAt: (offset: number) => number,
|
||||
wallHeight: number,
|
||||
): (offset: number, thickness: number) => { ratio: number; lengthM: number } {
|
||||
return (offset, thickness) => {
|
||||
const joint = offset + info.outward * (thickness / 2);
|
||||
const rise = info.edge.elevation_m - (invertAt(offset) + wallHeight);
|
||||
const run = Math.abs(joint - info.edge.offset_m);
|
||||
return {
|
||||
ratio: rise > 1e-6 ? run / rise : Number.POSITIVE_INFINITY,
|
||||
lengthM: Math.hypot(run, Math.max(rise, 0)),
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 기슭막이 **자동 자리 = 노견(노폭 연장)이 최소가 되는 지점**(2026-08-22 사용자 확정).
|
||||
* 노견 끝에서 바깥으로 훑어, 표준 높이 벽으로 ① 성토고 하한(FILL_MIN_RISE_M)과
|
||||
|
||||
@@ -286,7 +286,7 @@ def _cross_sheet_plan(project_root: Path, longitudinal_path: Path) -> list[dict[
|
||||
"""
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
chainages: list[int | float] = []
|
||||
for path in sorted(_cross_files(longitudinal_path, longitudinal)):
|
||||
for path in _cross_files(longitudinal_path, longitudinal):
|
||||
match = _CROSS_ID.fullmatch(path.stem)
|
||||
if not match:
|
||||
continue
|
||||
|
||||
@@ -9,7 +9,7 @@ import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Sections import cross_station, valid_cross_names
|
||||
from B05_Profile.B05_Profile_Engine_Sections import cross_files_in_order, cross_station
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -39,12 +39,8 @@ def _cross_files(longitudinal_path: Path, longitudinal: dict[str, Any]) -> list[
|
||||
if not cross_dir.is_dir():
|
||||
raise FileNotFoundError("B06 횡단면 파일을 찾을 수 없습니다.")
|
||||
stations = longitudinal.get("stations")
|
||||
# 읽기는 지우지 않음 — 잔재는 걸러 읽기만(지우기는 측점을 쓰는 쪽 몫 · R26)
|
||||
valid_names = valid_cross_names(cross_dir, stations if isinstance(stations, list) else [])
|
||||
files = sorted(cross_dir.glob("cross_*.json"))
|
||||
if valid_names:
|
||||
files = [path for path in files if path.name in valid_names]
|
||||
return files
|
||||
# 읽기는 지우지 않음 — 잔재는 걸러 읽기만(지우기는 측점을 쓰는 쪽 몫 · R26) · 측점 차례(472-2)
|
||||
return cross_files_in_order(cross_dir, stations if isinstance(stations, list) else [])
|
||||
|
||||
|
||||
def _station_map(longitudinal: dict[str, Any]) -> dict[int | float, dict[str, Any]]:
|
||||
|
||||
@@ -204,3 +204,6 @@ export function shapeNeeds(typeId: string): readonly string[] {
|
||||
|
||||
/** 관 공식 이름의 관종들(정의 선택지) — 표시 이름이 「배수관」으로 줄일 이름을 알아봄. */
|
||||
export const pipeTitleKinds = (): readonly string[] => pipeKinds;
|
||||
|
||||
/** 배수관의 화면 표시 이름(2026-08-17 사용자 지시) — 목록 · 종 · 횡단 표기가 같이 씀. */
|
||||
export const PIPE_DISPLAY_NAME = "배수관";
|
||||
|
||||
@@ -33,7 +33,7 @@ source: ["docs/raw/조사/2026-10-19_472_구조다시세움_목록.md", "PLAN.md
|
||||
| P5 | 노선 바뀜 계획고 옮김 | `logic/B05_Profile_Grade_Carry.ts:188` | `B05_Profile_Engine_Grade_Carry.py:149` | — | 짝 · `test_400_profile_carry` · `test_349_0v_route_save_span` |
|
||||
| P6 | 옮긴 시설 꼭짓점 세움 | `logic/B05_Profile_Grade_Seat.ts:32` · `common_util_structure_station.ts:356` | `B05_Profile_Router_Confirm.py:220 · 306` | — | 짝 · `test_427_1_follow_seat` · `test_459_route_seat_mirror` |
|
||||
| P7 | 직선화 · 자유지정 · 쉬프트 · 시종점 | `logic/B05_Profile_UI_Profile_Straighten.ts` · `logic/design_profile_free.ts` · `logic/B05_Profile_UI_Profile_Pivot.ts` · `logic/B05_Profile_EndOffsets.ts` | — | — | 브라우저(편집값만 저장 → P2) |
|
||||
| P8 | 종단 경고(기울기 · 법정 L · 최소 R · 피복) | `B05_Profile_UI_Panel_Fields.ts` · `logic/B05_Profile_UI_Profile_Guard.ts` · `B05_Profile_UI_Profile_MinCover.ts` | `B05_Profile_Engine_Grade.py` · `B05_Profile_PageSettings.py` | — | 짝 · `test_442_2_grade_limit_mirror` |
|
||||
| P8 | 종단 경고(기울기 · 법정 L · 최소 R · 피복) | `B05_Profile_UI_Panel_Fields.ts` · `logic/B05_Profile_UI_Profile_Guard.ts` · `B05_Profile_UI_Profile_MinCover.ts` · 경고 글 `logic/B05_Profile_UI_Profile_Alignment.ts` buildAlignment | `B05_Profile_Engine_Grade.py` · `B05_Profile_PageSettings.py` · 경고 글 `B05_Profile_Engine_Grade_Alignment.py` build_alignment | — | 짝 · [저장] · [확정] = `B06_Section_Server_Calc_Prebuild.py` refresh_alignment 가 경고만 다시 씀(기하 그대로) · `test_442_2_grade_limit_mirror` · `test_472_save_warnings` |
|
||||
| C1 | 횡단 설계선 | `common_util_cross_design.ts:267` · `logic/B06_Section_Cross_Stage.ts:93` · `B06_Section_Cross_Refresh.ts:330` | `B06_Section_Engine_Design.py:195` | ex | 짝 · `test_b06_cross_design_mirror` |
|
||||
| C2 | 단면적(절 · 성 · 측구 · 층따기) | `common_util_cross_design_areas.ts` | `B06_Section_Engine_Areas.py` | sc · ex | 짝 · 정본 = 표준 측점 파이썬 · 구조물 측점 Node |
|
||||
| C3 | 확정 보정(포장 · 세월교 · 기울임) | `logic/B06_Section_Cross_Enforce.ts:40` | `B06_Section_Router_Design.py` · `B06_Section_Engine_Tilt.py` | — | 짝 · `test_349_0n_cross_enforce_mirror` |
|
||||
|
||||
@@ -32,5 +32,5 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
|
||||
| B06 폼 관 측점 · 옵션 고침 | `pipeFacilities` → 세션 `pipes` · 옮긴 자리(정수 m)로 옵션 · 캐시 얹기 · 집계 찾음 | B05 관 옮김과 같음 | 배수유역 다시 나눔 | 관 차례 `PUT /drainage/pipe-points` | 14 | `B06_Section_UI_Page_Structures_Panel.ts:300-345 · 536-560` |
|
||||
| B06 구조물 패널 | 보기 `structures` → 시설 줄 `set` → 세션 `structures` | B05 구조물과 같음 | — | `PUT /route/structures` | 11 | `B06_Section_UI_Page_Structures_Panel.ts:211-227 · 582-598` |
|
||||
| B06 평면 패널 끌기 | B05 교점 끌기와 같음(같은 편집기를 옮겨 붙임) | 같음 · B06 쪽 갈아끼움이 먼저 | 같음 | `/route/replan` | 11 | `design_plan_peek.ts:61-77` · `B05_Profile_UI_RouteEdit.ts:290-293 · 599-601` · `B06_Section_UI_Page_Station_Swap.ts:35` |
|
||||
| 넓힘(표본 폭 넘음) | 없음 — 확인창 뒤 바로 서버 | 응답 뒤 상세 강제 다시 받음 | `/sections/{노선}/regenerate` | **저장 밖** — DB 바로 씀(이미 정본) | 3 | `B06_Section_UI_Page_Common.ts:32-63` · `B06_Section_Router.py:401-411` · `B06_Section_Router_Widen.py:80` |
|
||||
| 넓힘(표본 폭 넘음) | 없음 — 확인창 뒤 바로 서버 | 응답 뒤 상세 강제 다시 받음 | `/sections/{노선}/regenerate` | **저장 밖** — DB 바로 씀(이미 정본) | 3 | `B06_Section_UI_Page_Common.ts:32-63` · `B06_Section_Router.py:378-388` · `B06_Section_Router_Widen.py:80` |
|
||||
| [측점 만들기] | 없음 — 바로 서버 | 응답 뒤 상세 비움 · 강제 다시 받음 · 알림 | `/section/missing-stations` | **저장 밖** — 종단 · 횡단 파일 바로 씀 | 3 | `B06_Section_UI_Missing_Stations.ts:86-138` · `B06_Section_Router_Stations.py:236-296 · 365-407` |
|
||||
|
||||
@@ -35,7 +35,7 @@ source: ["docs/raw/조사/2026-10-19_470_B05B06_데이터흐름.md", "docs/raw/
|
||||
| alignment | `flushAlignmentDraft` | `PUT /route/profile-alignment` | 초안 노선 0 편집을 새 노선에 | `design_save.ts:185-186` |
|
||||
| culvertOptions | `flushCulvertOptions`(`culvertopt` · 옛 노선 열쇠) | `pipe_points.json` 한 번 더 PUT | 옛 노선 열쇠로 읽음 | `design_save.ts:187-188` · `B06_Section_Api_Culvert_Options.ts:67-95` |
|
||||
| uphill | `flushRouteParts` | 상단측 | 건너뜀 — 횡단 차례가 옮긴 뒤 보냄 | `design_save.ts:189-190 · 285` · `B05_Profile_Api_Fetch.ts:321-342` |
|
||||
| cross | `ctx.cross(confirm)` = 횡단 패치 한 요청 | `/sections/{노선}/save` · `confirm` | `carryAfterRouteSave`(새 측점으로 옮김) | `design_save.ts:275-283` |
|
||||
| cross | `ctx.cross(confirm)` = 횡단 패치 한 요청 | `/sections/{노선}/save` · `confirm` · 재계산 맨 앞 종단 경고 다시 씀(`B06_Section_Server_Calc_Prebuild.py` refresh_alignment) | `carryAfterRouteSave`(새 측점으로 옮김) | `design_save.ts:275-283` |
|
||||
|
||||
- 차례마다 따로 커밋 — 실패 = 그 차례에서 멈춤 · 이름째 알림 · 앞 차례는 저장된 채(`design_save.ts:297-299`).
|
||||
- 저장 중 = `saving` 표시 · 묶음 맞춤은 저장 뒤로 미룸(`design_pipeline_page.ts:567`).
|
||||
|
||||
@@ -45,7 +45,7 @@ source: ["CLAUDE.md §5", "docs/raw/조사/2026-10-19_465_요구272_값안변함
|
||||
|
||||
| 내용 | 자리(파일:줄) | 상태 |
|
||||
|---|---|---|
|
||||
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:401-411` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 |
|
||||
| 저장 밖 정본 쓰기 셋 — 넓힘 `/regenerate` · [측점 만들기] · 추천 [적용] | `B06_Section_Router.py:378-388` · `B06_Section_Router_Stations.py:236-296` · `B05_Profile_UI_RouteEdit_Apply.ts:220-245` | 사용자 확정 · 확인창 · 472-6 협의 |
|
||||
| [저장] 9 차례 · 차례마다 따로 커밋 — 실패 = 앞 차례만 저장 | `design_save.ts:253-299` | 472-6 |
|
||||
| `pipe_points.json` 한 [저장]에 두 번 PUT(관 · 관 옵션) | `B06_Section_Api_Culvert_Options.ts:67-95` | 코드 확인 |
|
||||
| 노선 바뀐 [저장]의 관 옵션 차례 = 옛 노선 열쇠 | `design_save.ts:187-188` | 추정 |
|
||||
|
||||
@@ -9,13 +9,12 @@ last_updated: 2026-07-24
|
||||
|
||||
# B05_Profile_Api_Fetch.ts
|
||||
|
||||
FastAPI 백엔드 B05 라우터 API 통신(solve, confirm, latest, profile-alignment, contour-interval)을 담당하는 클라이언트 HTTP 클라이언트 모듈입니다.
|
||||
FastAPI 백엔드 B05 라우터 API 통신(confirm, latest, profile-alignment, contour-interval)을 담당하는 클라이언트 HTTP 클라이언트 모듈입니다.
|
||||
|
||||
## 🛠️ 주요 API 함수 목록
|
||||
|
||||
| 항목 | 위치(상대 경로) | 역할 |
|
||||
|---|---|---|
|
||||
| `solveRoute()` | `B05_Profile/B05_Profile_Api_Fetch.ts` | `POST /api/projects/{id}/route/solve` 최적 탐색 요청 호출 |
|
||||
| `confirmRoute()` | `B05_Profile/B05_Profile_Api_Fetch.ts` | `POST /api/projects/{id}/route/confirm` 경로 확정 및 비정규 측점 생성 요청 호출 |
|
||||
| `fetchLatestRoute()` | `B05_Profile/B05_Profile_Api_Fetch.ts` | `GET /api/projects/{id}/route/latest` 최신 경로 및 제어점 데이터 복원 호출 |
|
||||
| `saveProfileAlignment()` | `B05_Profile/B05_Profile_Api_Fetch.ts` | `PUT /api/projects/{id}/route/profile-alignment` 수동 표고/반경 델타 영속화 호출 |
|
||||
|
||||
@@ -20,7 +20,6 @@ source: ["docs/raw/verification/2026-07-19_verify_B05_B06_UI_Station_Improvement
|
||||
| `GET /{project_id}/sections/detail` | `B06_Section_Router.py` | 종단 결과와 다수 횡단면을 일괄 조회하고 배수관 측점에는 `culvert` 세트 제원을 결합 |
|
||||
| `GET /{project_id}/sections/road-widths` | `B06_Section_Router.py` | 임도 등급별 법정 최소 도로폭 정보(`FOREST_ROAD_MIN_WIDTH_M`) 조회 (B05 프론트엔드가 측점 가로선 반폭 계산 시 호출) |
|
||||
| `POST /{project_id}/sections/{route_id}/regenerate` | `B06_Section_Router.py:85` | 횡단 반폭 재생성 요청 (`SectionRegenerateRequest` 매핑) |
|
||||
| `POST /{project_id}/sections/{route_id}/cross-design` | `B06_Section_Router.py` | 측점별 지반·단면·측구와 집수정 `basin_adjust`(폭·높이·좌우·상하)를 `data.design`에 저장 (`CrossDesignRequest` → `CrossDesignResponse`) |
|
||||
| `GET /{project_id}/recent-company-projects` | `B06_Section_Router.py` | 동일 회사 최근 5개 프로젝트 목록 반환 API |
|
||||
| `GET /{project_id}/standard-cross-section-from/{target_id}` | `B06_Section_Router.py` | 지정 타 프로젝트의 표준단면 설계값 로드 API |
|
||||
|
||||
|
||||
@@ -15,8 +15,6 @@ last_updated: 2026-08-02
|
||||
## 2. 주요 연동 API 함수
|
||||
* `getSectionsDetail(projectId)`:
|
||||
* `GET /sections/detail` 호출. 종단면도 지반선/계획선 및 다수 횡단면 데이터를 일괄 응답받습니다.
|
||||
* `crossDesignOne(projectId, routeId, data)`:
|
||||
* `POST /sections/{routeId}/cross-design` 호출. 측점별 지반유형/단면유형/측구/암반경계선 높이 갱신 시 실시간 단면적 계산 및 저장을 수행합니다.
|
||||
* `regenerateSections(projectId, routeId, width)`:
|
||||
* `POST /sections/{routeId}/regenerate` 호출. 횡단 반폭 재생성을 비동기로 요청합니다.
|
||||
* `confirmSections(projectId, routeId, massHaul)`:
|
||||
|
||||
@@ -1850,11 +1850,56 @@
|
||||
"pipe:p35100": 7.9
|
||||
},
|
||||
"warnings": [
|
||||
"chainage 1080.0m: 인접 직선이 짧아 종단곡선 길이를 0.1m(반경 1m)로 줄였습니다.",
|
||||
"chainage 340.0m: 인접 직선이 짧아 종단곡선 길이를 9.9m(반경 26m)로 줄였습니다.",
|
||||
"chainage 351.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 25m)로 줄였습니다.",
|
||||
"chainage 360.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 231m)로 줄였습니다.",
|
||||
"종단기울기 56.60%가 기준 14.00%를 초과하는 구간이 34개 있습니다."
|
||||
"100.0m: 종단곡선 R 57m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"1000.0m: 종단곡선 R 73m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"1020.0m: 종단곡선 R 31m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"1060.0m: 종단곡선 R 25m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"1080.0m: 인접 직선이 짧아 종단곡선 길이를 0.1m(반경 1m)로 줄였습니다.",
|
||||
"1080.0m: 종단곡선 R 1m · L 0.1m — 법정 최소 R 100m · L 20m 미달",
|
||||
"140.0m: 종단곡선 R 53m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"20.0m: 종단곡선 R 205m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"200.0m: 종단곡선 R 154m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"220.0m: 종단곡선 R 285m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"240.0m: 종단곡선 R 61m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"260.0m: 종단곡선 R 24m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"280.0m: 종단곡선 R 21m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"300.0m: 종단곡선 R 196m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"320.0m: 종단곡선 R 118m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"340.0m: 인접 직선이 짧아 종단곡선 길이를 9.9m(반경 26m)로 줄였습니다.",
|
||||
"340.0m: 종단곡선 R 26m · L 9.9m — 법정 최소 R 100m · L 20m 미달",
|
||||
"351.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 25m)로 줄였습니다.",
|
||||
"351.0m: 종단곡선 R 25m · L 8.1m — 법정 최소 R 100m · L 20m 미달",
|
||||
"360.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 231m)로 줄였습니다.",
|
||||
"40.0m: 종단곡선 R 55m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"400.0m: 종단곡선 R 105m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"420.0m: 종단곡선 R 100m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"440.0m: 종단곡선 R 117m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"460.0m: 종단곡선 R 48m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"480.0m: 종단곡선 R 46m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"520.0m: 종단곡선 R 24m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"540.0m: 종단곡선 R 41m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"560.0m: 종단곡선 R 53m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"580.0m: 종단곡선 R 123m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"600.0m: 종단곡선 R 40m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"620.0m: 종단곡선 R 88m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"640.0m: 종단곡선 R 147m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"660.0m: 종단곡선 R 28m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"680.0m: 종단곡선 R 14m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"700.0m: 종단곡선 R 49m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"720.0m: 종단곡선 R 38m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"740.0m: 종단곡선 R 55m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"760.0m: 종단곡선 R 36m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"780.0m: 종단곡선 R 54m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"80.0m: 종단곡선 R 58m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"800.0m: 종단곡선 R 13m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"820.0m: 종단곡선 R 45m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"840.0m: 종단곡선 R 29m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"880.0m: 종단곡선 R 29m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"900.0m: 종단곡선 R 115m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"920.0m: 종단곡선 R 44m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"940.0m: 종단곡선 R 66m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"960.0m: 종단곡선 R 23m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"980.0m: 종단곡선 R 42m · L 15.0m — 법정 최소 R 100m · L 20m 미달"
|
||||
],
|
||||
"violations": 34
|
||||
},
|
||||
@@ -2783,6 +2828,7 @@
|
||||
"1000.0m: 종단곡선 R 73m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"1020.0m: 종단곡선 R 31m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"1060.0m: 종단곡선 R 25m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"1080.0m: 인접 직선이 짧아 종단곡선 길이를 0.1m(반경 1m)로 줄였습니다.",
|
||||
"1080.0m: 종단곡선 R 1m · L 0.1m — 법정 최소 R 100m · L 20m 미달",
|
||||
"140.0m: 종단곡선 R 53m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"20.0m: 종단곡선 R 205m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
@@ -2793,8 +2839,11 @@
|
||||
"280.0m: 종단곡선 R 21m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"300.0m: 종단곡선 R 196m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"320.0m: 종단곡선 R 118m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"340.0m: 인접 직선이 짧아 종단곡선 길이를 9.9m(반경 26m)로 줄였습니다.",
|
||||
"340.0m: 종단곡선 R 26m · L 9.9m — 법정 최소 R 100m · L 20m 미달",
|
||||
"351.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 25m)로 줄였습니다.",
|
||||
"351.0m: 종단곡선 R 25m · L 8.1m — 법정 최소 R 100m · L 20m 미달",
|
||||
"360.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 231m)로 줄였습니다.",
|
||||
"40.0m: 종단곡선 R 55m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"400.0m: 종단곡선 R 105m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"420.0m: 종단곡선 R 100m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
@@ -2824,12 +2873,7 @@
|
||||
"920.0m: 종단곡선 R 44m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"940.0m: 종단곡선 R 66m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"960.0m: 종단곡선 R 23m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"980.0m: 종단곡선 R 42m · L 15.0m — 법정 최소 R 100m · L 20m 미달",
|
||||
"chainage 1080.0m: 인접 직선이 짧아 종단곡선 길이를 0.1m(반경 1m)로 줄였습니다.",
|
||||
"chainage 340.0m: 인접 직선이 짧아 종단곡선 길이를 9.9m(반경 26m)로 줄였습니다.",
|
||||
"chainage 351.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 25m)로 줄였습니다.",
|
||||
"chainage 360.0m: 인접 직선이 짧아 종단곡선 길이를 8.1m(반경 231m)로 줄였습니다.",
|
||||
"종단기울기 56.60%가 기준 14.00%를 초과하는 구간이 34개 있습니다."
|
||||
"980.0m: 종단곡선 R 42m · L 15.0m — 법정 최소 R 100m · L 20m 미달"
|
||||
],
|
||||
"violations": 34
|
||||
},
|
||||
|
||||
@@ -51,7 +51,7 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
|
||||
"../A00_Common/design/design_page_data": loadTs("A00_Common/design/design_page_data.ts", {
|
||||
"../../B04_PreProcess/B04_PreProcess_Api_Fetch": serverFetch,
|
||||
"../../B05_Profile/B05_Profile_Api_Structures": serverStructures,
|
||||
"../../B06_Section/B06_Section_Api_Fetch": {
|
||||
"../../B06_Section/B06_Section_Api_Context": {
|
||||
fetchSectionContext: async () => ({ route_id: 1 }),
|
||||
},
|
||||
"./design_page_route": loadTs("A00_Common/design/design_page_route.ts"), // 노선 id 한 칸(386-2b)
|
||||
|
||||
@@ -23,7 +23,7 @@ const STUBS = {
|
||||
},
|
||||
},
|
||||
// 횡단 context(세션 캐시) — 노선 id 한 칸(386-2b)
|
||||
"../../B06_Section/B06_Section_Api_Fetch": {
|
||||
"../../B06_Section/B06_Section_Api_Context": {
|
||||
fetchSectionContext: async (projectId) => ({ route_id: projectId === "p-none" ? null : 304 }),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_Api_Structures": {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""472-1 기준값 — 고정 자료(`fixtures/golden_472`) 하나로 길 넷의 값을 뜸(시험 · 값 뜨기 함께 씀).
|
||||
|
||||
stored GET 상세(정본 그대로) → 누가토량(서버 Node 묶음 `computeMassHaul`)
|
||||
server_save [저장] 서버 재계산 — 정본 상세 → `enforce_design_lines` →
|
||||
`_enforce_stored_designs` → server-calc
|
||||
server_save [저장] 서버 재계산 — 정본 상세 → 종단 경고 다시 셈(`refresh_alignment` · 472 덧) →
|
||||
`enforce_design_lines` → `_enforce_stored_designs` → server-calc
|
||||
server_route 노선 저장 체인(같은 노선 · DB 없이) — `run_section_generation`(고정 geojson · npz)
|
||||
→ `_carry_profile` 같은 노선 갈래(옛 base_pvi · 편집) → `_apply_alignment_edits` →
|
||||
측점 열쇠 이월(`carry_cross_inputs`) → `compute_default_designs` → 상세 읽기 →
|
||||
@@ -125,19 +125,21 @@ def _context() -> dict[str, Any]:
|
||||
|
||||
|
||||
def _save_tail(detail: dict[str, Any], root: Path) -> tuple[list, dict | None]:
|
||||
"""[저장] 재계산 본문(`_recompute`) — 설계선 · 넓힘 · 보정 → Node 면적 · 누가토량 얹음."""
|
||||
"""[저장] 재계산(`_recompute`) — 종단 경고 · 설계선 · 넓힘 · 보정 → Node 면적 · 누가토량."""
|
||||
from B06_Section.B06_Section_Router_Design import enforce_design_lines
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import (
|
||||
_AREA_KEYS,
|
||||
_NPM_SCRIPT,
|
||||
BUNDLE,
|
||||
_enforce_stored_designs,
|
||||
refresh_alignment,
|
||||
)
|
||||
from common_util.common_util_node_bundle import run_bundle_json
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
|
||||
longitudinal = detail["longitudinal"]
|
||||
sections = detail["cross_sections"]
|
||||
refresh_alignment(longitudinal)
|
||||
enforce_design_lines(longitudinal, sections, root, None)
|
||||
unclosed = any((s.get("design") or {}).get("slope_unclosed") for s in sections)
|
||||
sampler = (
|
||||
@@ -163,9 +165,9 @@ def _save_tail(detail: dict[str, Any], root: Path) -> tuple[list, dict | None]:
|
||||
|
||||
def server_save(root: Path) -> dict[str, Any]:
|
||||
detail = copy.deepcopy(read("detail.json"))
|
||||
alignment = detail["longitudinal"].get("profile_alignment") or {}
|
||||
keys = _keys(detail["longitudinal"], detail["cross_sections"], root)
|
||||
sections, haul = _save_tail(detail, root)
|
||||
alignment = detail["longitudinal"].get("profile_alignment") or {}
|
||||
return values_of(
|
||||
sections, keys, haul, alignment.get("warnings") or [], alignment.get("violations") or []
|
||||
)
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
/* 472 덧 종단 경고 거울 헬퍼 — `logic/B05_Profile_UI_Profile_Alignment.ts` `buildAlignment` 를 그 자리에서
|
||||
* 트랜스파일해 돌림. 입력 argv[2] JSON { base, cases: [edits] } · 표준출력 JSON [{ warnings, violations }].
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const cache = new Map();
|
||||
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) =>
|
||||
request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const align = loadTs(
|
||||
path.join(__dirname, "..", "..", "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"),
|
||||
);
|
||||
const input = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
input.cases.map((edits) => {
|
||||
const built = align.buildAlignment(input.base, edits);
|
||||
return { warnings: built.warnings, violations: built.violations.length };
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -44,10 +44,15 @@ class Known:
|
||||
|
||||
|
||||
KNOWN: dict[tuple[str, str], list[Known]] = {
|
||||
(
|
||||
"server_save",
|
||||
"stored",
|
||||
): [], # [저장] 재계산 = 정본 그대로(사용자 원칙 — 입력 안 바뀌면 값 안 바뀜)
|
||||
# [저장] 재계산 = 정본 그대로(사용자 원칙 — 입력 안 바뀌면 값 안 바뀜) · 경고만 지금 코드
|
||||
("server_save", "stored"): [
|
||||
Known(
|
||||
"W0",
|
||||
{"warnings": None},
|
||||
"정본 종단 경고 = 10-17 저장 때 코드(법정 R · L 미달 46 줄 전) · "
|
||||
"[저장] 재계산이 지금 코드로 다시 씀(472 덧 · 기하 그대로)",
|
||||
),
|
||||
],
|
||||
("server_route", "stored"): [
|
||||
Known(
|
||||
"R7",
|
||||
@@ -58,7 +63,7 @@ KNOWN: dict[tuple[str, str], list[Known]] = {
|
||||
"W0",
|
||||
{"warnings": None},
|
||||
"정본 종단 경고 = 10-17 저장 때 코드(법정 R · L 미달 46 줄 전) · "
|
||||
"[저장] 재계산은 종단 경고를 안 씀",
|
||||
"노선 저장 체인은 지금 코드",
|
||||
),
|
||||
],
|
||||
("browser", "server_route"): [
|
||||
@@ -76,12 +81,6 @@ KNOWN: dict[tuple[str, str], list[Known]] = {
|
||||
"측점 자리 ≤ 1 mm → 표본 · 면적 ≤ 0.07 ㎡ · 층따기 밑수 ≤ 0.52 m · "
|
||||
"누가토량 끝값 0.69 ㎥(−17,570.01 ↔ −17,570.70)",
|
||||
),
|
||||
Known(
|
||||
"W1",
|
||||
{"warnings": None},
|
||||
"종단 경고 글 두 벌(X5 · P8) — TS 「340.0m: …」 · 파이썬 「chainage 340.0m: …」 · "
|
||||
"최대 기울기 총괄 줄은 파이썬만",
|
||||
),
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""472-2 덧 — 횡단 파일을 측점(누가거리) 차례로 읽는지.
|
||||
|
||||
이름 차례면 `cross_01080.16m` 이 `cross_01080m` 앞(「.」 < 「m」) → GET 상세의 측점 차례가
|
||||
어긋나 화면 차례 짝(detailCross)이 갈리고, B07 측점 도면 장 나눔도 차례가 뒤집힘.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B06_Section.B06_Section_Router import _read_section_detail # noqa: E402
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import _cross_files # noqa: E402
|
||||
|
||||
CHAINAGES = [1060.0, 1080.0, 1080.16, 1100.0, 999.5]
|
||||
NAMES = {
|
||||
1060.0: "cross_01060m.json",
|
||||
1080.0: "cross_01080m.json",
|
||||
1080.16: "cross_01080.16m.json",
|
||||
1100.0: "cross_01100m.json",
|
||||
999.5: "cross_00999.50m.json",
|
||||
}
|
||||
|
||||
|
||||
def _stage(tmp_path: Path) -> Path:
|
||||
stage = tmp_path / "B05_Profile"
|
||||
(stage / "cross_sections").mkdir(parents=True)
|
||||
(stage / "longitudinal").mkdir()
|
||||
longitudinal = stage / "longitudinal" / "longitudinal.json"
|
||||
longitudinal.write_text(
|
||||
json.dumps({"stations": [{"chainage_m": m} for m in CHAINAGES]}), encoding="utf-8"
|
||||
)
|
||||
for chainage, name in NAMES.items():
|
||||
(stage / "cross_sections" / name).write_text(
|
||||
json.dumps({"chainage_m": chainage, "samples": []}), encoding="utf-8"
|
||||
)
|
||||
return longitudinal
|
||||
|
||||
|
||||
def test_상세_GET_은_측점_차례(tmp_path: Path) -> None:
|
||||
longitudinal = _stage(tmp_path)
|
||||
detail = _read_section_detail(tmp_path, str(longitudinal.relative_to(tmp_path)))
|
||||
got = [section["chainage_m"] for section in detail["cross_sections"]]
|
||||
assert got == sorted(CHAINAGES)
|
||||
|
||||
|
||||
def test_B07_측점_파일도_측점_차례(tmp_path: Path) -> None:
|
||||
longitudinal = _stage(tmp_path)
|
||||
files = _cross_files(longitudinal, json.loads(longitudinal.read_text(encoding="utf-8")))
|
||||
assert [path.name for path in files] == [NAMES[m] for m in sorted(CHAINAGES)]
|
||||
|
||||
|
||||
def test_B07_장_나눔도_측점_차례() -> None:
|
||||
"""장 나눔 쪽이 이름으로 다시 줄 세우지 않음(`sorted(_cross_files(…))` = 이름 차례)."""
|
||||
source = (ROOT / "B07_DesignDetail" / "B07_DesignDetail_Router_Support.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "sorted(_cross_files(" not in source
|
||||
@@ -54,9 +54,6 @@ SESSION_PULLS = (
|
||||
"A00_Common/design/design_pick_match.ts",
|
||||
"A00_Common/design/design_pipe_ids.ts",
|
||||
"A00_Common/design/design_selection.ts",
|
||||
"A00_Common/design/design_store.ts",
|
||||
"B05_Profile/B05_Profile_UI_IrregularStations.ts",
|
||||
"B05_Profile/B05_Profile_UI_Profile_History.ts",
|
||||
"B06_Section/B06_Section_Api_Fetch.ts",
|
||||
"B06_Section/B06_Section_UI_Section_Common.ts",
|
||||
"config/config_frontend.ts",
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""PLAN 472 덧 (요구 272) — [저장] · [확정] 재계산이 종단 경고를 저장 입력으로 다시 씀.
|
||||
|
||||
증상 — 검증_랩탑메인 정본 종단 경고 5 줄(10-17 옛 코드) · 지금 코드로 셈하면 50 줄 ·
|
||||
[저장] · [확정] 재계산(`B06_Section_Server_Calc_Prebuild._recompute`)이 경고를 다시 안 셈.
|
||||
고침 — `_recompute` 맨 앞 `_refresh_alignment_file` → `refresh_alignment`(저장 base_pvi · 편집 ·
|
||||
정책 · 기준 → `rebuild_alignment_profile` 의 경고만) · 기하는 저장값 그대로 · 같으면 안 씀.
|
||||
경고 글 = TS `buildAlignment` 와 한 벌(거울). 고정 자료 = `fixtures/golden_472/detail.json`.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_472_save_warnings.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import inspect
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import ground_profile # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import chainage_key # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile # noqa: E402
|
||||
from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild # noqa: E402
|
||||
|
||||
FIXTURE = HERE / "fixtures" / "golden_472"
|
||||
DETAIL = json.loads((FIXTURE / "detail.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _longitudinal() -> dict:
|
||||
return copy.deepcopy(DETAIL["longitudinal"])
|
||||
|
||||
|
||||
def test_정본_경고는_옛_코드_5줄() -> None:
|
||||
assert len(DETAIL["longitudinal"]["profile_alignment"]["warnings"]) == 5
|
||||
|
||||
|
||||
def test_다시_셈_경고만_새로_기하는_그대로() -> None:
|
||||
longitudinal = _longitudinal()
|
||||
before = copy.deepcopy(longitudinal)
|
||||
summary = prebuild.refresh_alignment(longitudinal)
|
||||
warnings = longitudinal["profile_alignment"]["warnings"]
|
||||
assert len(warnings) == 50
|
||||
assert sum("법정 최소 R" in w for w in warnings) == 46 # 432 법정 R · L 미달
|
||||
assert not [w for w in warnings if w.startswith("chainage ")]
|
||||
assert summary is not None and summary["warnings"] == warnings
|
||||
assert summary["id"] == before["design_profiles"][0]["id"]
|
||||
assert longitudinal["design_profiles"][0]["summary"]["warnings"] == warnings
|
||||
# 경고 밖 = 저장값 그대로(계획고 · 곡선 · 표본 · 위반 · 정책 — 6 자리 반올림 끝자리도)
|
||||
longitudinal["profile_alignment"]["warnings"] = before["profile_alignment"]["warnings"]
|
||||
longitudinal["design_profiles"][0]["summary"]["warnings"] = before["design_profiles"][0][
|
||||
"summary"
|
||||
]["warnings"]
|
||||
assert longitudinal == before
|
||||
|
||||
|
||||
def test_선형_없는_옛_종단은_그대로() -> None:
|
||||
longitudinal = _longitudinal()
|
||||
longitudinal["profile_alignment"].pop("base_pvi")
|
||||
keep = copy.deepcopy(longitudinal)
|
||||
assert prebuild.refresh_alignment(longitudinal) is None
|
||||
assert longitudinal == keep
|
||||
|
||||
|
||||
def test_파일_다시_씀_같으면_안_씀(tmp_path) -> None:
|
||||
path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps(_longitudinal()), encoding="utf-8")
|
||||
relative = "B06_Section/longitudinal/longitudinal.json"
|
||||
first = prebuild._refresh_alignment_file(tmp_path, relative)
|
||||
stored = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert first is not None and len(stored["profile_alignment"]["warnings"]) == 50
|
||||
written = path.stat().st_mtime_ns
|
||||
assert prebuild._refresh_alignment_file(tmp_path, relative) == first
|
||||
assert path.stat().st_mtime_ns == written # 같은 값 — 다시 안 씀
|
||||
assert prebuild._refresh_alignment_file(tmp_path, "없음/longitudinal.json") is None
|
||||
|
||||
|
||||
def test_재계산_맨_앞에서_선형_다시_셈() -> None:
|
||||
source = inspect.getsource(prebuild._recompute)
|
||||
assert "_refresh_alignment_file" in source and "update_longitudinal_grade_summary" in source
|
||||
assert source.index("_refresh_alignment_file") < source.index("get_section_detail(")
|
||||
|
||||
|
||||
def test_TS_거울_같은_경고_목록(tmp_path) -> None:
|
||||
longitudinal = _longitudinal()
|
||||
alignment = longitudinal["profile_alignment"]
|
||||
# 옛 정책엔 법정 R · L 이 없음 — 두 쪽 다 기준으로 채운 정책(433-13 · TS `readAlignment`)
|
||||
policy = rebuild_alignment_profile(longitudinal, alignment["edits"])[0]["policy"]
|
||||
assert (
|
||||
policy["min_vertical_radius_m"] == 100.0
|
||||
and "min_vertical_radius_m" not in alignment["policy"]
|
||||
)
|
||||
chainage, ground = ground_profile(longitudinal)
|
||||
curve = alignment["curves"][1]
|
||||
station = longitudinal["stations"][5]
|
||||
cases = [
|
||||
alignment["edits"],
|
||||
{"station_offsets": {}, "curve_radii": {chainage_key(curve["chainage_m"]): 500.0}},
|
||||
{"station_offsets": {chainage_key(station["chainage_m"]): 3.0}, "curve_radii": {}},
|
||||
]
|
||||
base = {
|
||||
"policy": policy,
|
||||
"onlyExplicitCurves": bool(alignment.get("only_explicit_curves")),
|
||||
"basePvi": alignment["base_pvi"],
|
||||
"chainage": chainage.tolist(),
|
||||
"ground": ground.tolist(),
|
||||
"stations": [
|
||||
{"station_id": s.get("station_id"), "chainage_m": s["chainage_m"]}
|
||||
for s in longitudinal["stations"]
|
||||
],
|
||||
}
|
||||
source = tmp_path / "in.json"
|
||||
source.write_text(json.dumps({"base": base, "cases": cases}), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_472_save_warnings.cjs"), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
for edits, got in zip(cases, json.loads(proc.stdout), strict=True):
|
||||
py, _profile = rebuild_alignment_profile(copy.deepcopy(longitudinal), edits)
|
||||
assert got["warnings"] == py["warnings"]
|
||||
assert got["violations"] == len(py["violations"])
|
||||
@@ -80,7 +80,6 @@ def test_화면에_제안값_단추_제안_글이_없음():
|
||||
files = [
|
||||
"B05_Profile/B05_Profile_UI_Drainage_Facility.ts",
|
||||
"B05_Profile/B05_Profile_UI_Drainage_Facility_Fields.ts",
|
||||
"B05_Profile/B05_Profile_UI_Drainage_Facility_Ford.ts",
|
||||
"B05_Profile/B05_Profile_UI_Structures_Fields.ts",
|
||||
"B05_Profile/B05_Profile_UI_Structures_Panel_Options.ts",
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
||||
|
||||
from B06_Section.B06_Section_Schema import SectionContextResponse # noqa: E402
|
||||
|
||||
_FETCH = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts"
|
||||
_FETCH = PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Context.ts" # 472-2 세션 창구 갈라냄
|
||||
_ROUTER = PROJECT_ROOT / "B06_Section" / "B06_Section_Router.py"
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ def test_확정_payload_도_손나열이_없다():
|
||||
|
||||
|
||||
def test_서버_목록이_한_벌이다():
|
||||
"""라우터가 목록을 다시 적지 않고 그 한 벌을 쓴다."""
|
||||
"""라우터가 목록을 다시 적지 않는다(목록을 쓰던 `cross-design` 엔드포인트는 472-2 에서 지움)."""
|
||||
router = (ROOT / "B06_Section" / "B06_Section_Router.py").read_text(encoding="utf-8")
|
||||
assert "USER_TOUCHED_KEYS" in router
|
||||
assert router.count('"revet_follow_grade"') == 0, "라우터에 목록 사본이 남아 있음"
|
||||
|
||||
@@ -211,7 +211,9 @@ def test_저장된_표준단면이_브라우저까지_간다():
|
||||
from B06_Section.B06_Section_Schema import SectionContextResponse
|
||||
|
||||
assert "stored_standard_cross_section" in SectionContextResponse.model_fields
|
||||
fetch = (PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Fetch.ts").read_text(encoding="utf-8")
|
||||
fetch = (PROJECT_ROOT / "B06_Section" / "B06_Section_Api_Context.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "function seedStandardCross(" in fetch
|
||||
assert fetch.count("seedStandardCross(projectId,") >= 2, "한쪽 갈래만 세움"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user