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

This commit is contained in:
2026-10-19 09:08:46 +09:00
53 changed files with 473 additions and 897 deletions
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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";
-64
View File
@@ -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,
-10
View File
@@ -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,
);
}
-11
View File
@@ -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을 삭제하고 유효 파일명 집합을 반환한다.
-29
View File
@@ -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;
+58
View File
@@ -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);
}
+5 -66
View File
@@ -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,
-29
View File
@@ -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,
+3 -125
View File
@@ -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": "횡단 설계 계산 중 오류가 발생했습니다."},
)
-8
View File
@@ -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)
-3
View File
@@ -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;
}
+2 -36
View File
@@ -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`).
+1 -1
View File
@@ -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` 수동 표고/반경 델타 영속화 호출 |
-1
View File
@@ -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)
+1 -1
View File
@@ -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": {
+6 -4
View File
@@ -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 };
}),
),
);
+10 -11
View File
@@ -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",
+130
View File
@@ -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, "라우터에 목록 사본이 남아 있음"
+3 -1
View File
@@ -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, "한쪽 갈래만 세움"