Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_1', 'origin/sub_laptop_2', 'origin/sub_laptop_3', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_8

This commit is contained in:
63 changed files with 4221 additions and 261 deletions
+2
View File
@@ -107,6 +107,8 @@ export const STATE_REGISTRY = {
"input-prefs": { bucket: "pref", scope: "global" },
/** B05 3D 영역 투영(316-2) `perspective · ortho` · 없으면 원근. */
"b05-3d-projection": { bucket: "pref", scope: "global" },
/** 배수유역 계산 `manual · auto`(347-2) · 없으면 수동. */
"drainage-mode": { bucket: "pref", scope: "global" },
"section-panel-height": {
bucket: "pref",
scope: "global",
+123
View File
@@ -0,0 +1,123 @@
/* =============================================================================
* A00_Common/design/design_leave.ts
* B05 · B06 떠남 확인(346-4) — 저장 안 한 조작(초안 통)이 있으면 탭 닫기 · 다른 페이지로 갈 때
* 공용 물음(`ui_template_leave_dialog` · [취소] [버리고 넘어감] [저장 후 넘어감]).
*
* 고침 = 초안 통 칸 중 고침이 아닌 칸(고름 · 관 id 장부 · 종류 캐시 · 고른 구조물 넘김) ·
* 다른 페이지 칸(b08 · b09) · 저장 뒤에도 남기는 표준 횡단면(std-cross)을 뺀 것.
* 횡단 초안은 [저장] 뒤에도 세션에 남음 — 그래서 「있음」이 아니라 **마지막 깨끗한 때와 다름**으로 봄:
* [저장] · [확정]이 끝나면 `markDesignClean` 이 그때 지문을 적음 · [초기화]는 초안을 다 지우고 새로 읽음.
* B05 ↔ B06 는 한 페이지 — 그 사이 이동은 묻지 않음.
* 프로젝트 바꿈은 이 페이지를 떠난 뒤라 이 물음이 먼저 · 들어갈 때 물음은 `b_project_switch`.
* ========================================================================== */
import { ROUTES } from "@config/config_frontend";
import { leaveOrStay } from "@ui/ui_template_leave_dialog";
import { t as L } from "@ui/ui_template_locale";
import {
clearState,
namesInBucket,
readByKey,
routeStateKeys,
stateKey,
type StateName,
} from "../b_page_state";
import { currentRoute, setLeaveGuard } from "../router";
/** 초안 통이지만 고침으로 안 보는 칸 — 묻지 않고 [버리고 넘어감]에도 안 지움. */
const NOT_EDITS = new Set<StateName>([
"selection",
"pipe-ids",
"structure-pick",
"structure-type-cache",
"std-cross",
]);
const OTHER_PAGE = /^b0[89]-/;
export function editDraftNames(): StateName[] {
return namesInBucket("draft").filter((name) => !NOT_EDITS.has(name) && !OTHER_PAGE.test(name));
}
const filled = (raw: string | null): raw is string =>
raw !== null && raw !== "" && raw !== "{}" && raw !== "[]";
/** 고침 칸 지문 — 모든 노선 것 · 빈 칸 뺌 · 고침이 없으면 "". */
export function editSignature(projectId: string): string {
const parts: string[] = [];
for (const name of editDraftNames()) {
const own = stateKey(name, projectId);
const keys = own ? [own] : routeStateKeys(name, projectId);
for (const key of keys.sort()) {
const raw = readByKey(key);
if (filled(raw)) parts.push(`${key}=${raw}`);
}
}
return parts.join("\n");
}
/** 긴 지문을 들고 있지 않게 — FNV-1a 32비트 + 길이. */
function digest(text: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index += 1) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return `${text.length}:${hash >>> 0}`;
}
// ponytail: 깨끗한 표는 메모리만 — [저장] 뒤 새로고침하면 남은 횡단 초안 때문에 한 번 더 물음.
const cleanMarks = new Map<string, string>();
/** [저장] · [확정]이 끝난 때 — 지금 초안을 깨끗한 것으로 적음. */
export function markDesignClean(projectId: string): void {
if (projectId) cleanMarks.set(projectId, digest(editSignature(projectId)));
}
/** 저장 안 한 조작이 있나. */
export function designDirty(projectId: string): boolean {
if (!projectId) return false;
const signature = editSignature(projectId);
if (!signature) return false;
return cleanMarks.get(projectId) !== digest(signature);
}
/** [버리고 넘어감] — 고침 칸만 지움(모든 노선). */
export function discardDesignEdits(projectId: string): void {
editDraftNames().forEach((name) => clearState(name, projectId));
}
const SAME_PAGE = new Set<string>([ROUTES.B05_PROFILE, ROUTES.B06_SECTION]);
/**
* 페이지를 세울 때 한 번 — `root` 가 화면에서 빠지면 스스로 풂.
* `save` = [저장] 한 줄기(`saveSharedDesign(shared, false)`).
*/
export function guardDesignLeave(
projectId: string,
root: HTMLElement,
save: () => Promise<unknown>,
): void {
const unload = (event: BeforeUnloadEvent): void => {
if (!root.isConnected) return window.removeEventListener("beforeunload", unload);
if (!designDirty(projectId)) return;
event.preventDefault();
event.returnValue = "";
};
window.addEventListener("beforeunload", unload);
const guard = async (): Promise<boolean> => {
if (!root.isConnected) return true;
if (SAME_PAGE.has(currentRoute())) {
// 라우터가 「넘어감」 뒤 물음을 내림 — 같은 페이지 안 이동이면 한 틱 뒤 다시 걺.
window.setTimeout(() => root.isConnected && setLeaveGuard(guard), 0);
return true;
}
if (!designDirty(projectId)) return true;
const got = await leaveOrStay(L("B05_Design_Leave"), {
save,
stillDirty: () => designDirty(projectId),
discard: () => discardDesignEdits(projectId),
});
return got !== "stay";
};
setLeaveGuard(guard);
}
+3 -1
View File
@@ -28,7 +28,8 @@ import {
} from "../b_workflow_nav";
import { currentRoute, navigateTo, type MountedPage } from "../router";
import type { DesignView } from "./design_dock";
import type { DesignPageShared } from "./design_shared";
import { saveSharedDesign, type DesignPageShared } from "./design_shared";
import { guardDesignLeave } from "./design_leave";
import { usePipeIds } from "./design_pipe_ids";
import "./design_page.css";
@@ -138,6 +139,7 @@ export function renderDesignPage(root: HTMLElement): MountedPage {
revealLeft: () => layout?.setOptionsOpen(true),
ensureView: (view) => (slots.get(view) ?? mountView(view, false)).ready,
};
guardDesignLeave(projectId, page, () => saveSharedDesign(shared, false)); // 떠남 확인(346-4)
/** 한 벌이 보기를 따라간다 — 좌측 칸 · 단추 글자 · 종단도 판 · 진행 단계 · 제목. */
const applyView = (view: DesignView): void => {
+2
View File
@@ -26,6 +26,7 @@ import { createDesignPanel, type DesignPanel } from "./design_panel";
import { createDesignProfile, type DesignProfile } from "./design_profile";
import { attachProfilePopout, attachSubPanelPopouts } from "./design_profile_popout";
import { saveDesign, type DesignSaveStep } from "./design_save";
import { markDesignClean } from "./design_leave";
import { resetDesign } from "./design_reset";
import type { DesignView } from "./design_dock";
@@ -313,6 +314,7 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
else await profileSave?.cross?.(isConfirm);
},
done: async (isConfirm) => {
markDesignClean(shared.projectId); // 떠남 확인 — 지금 초안이 저장분(346-4)
if (isConfirm) {
showToast(L("B06_Profile_Confirm_Success"), "success");
goToWorkflowStage(shared.projectId, WORKFLOW_STEP_ROUTES[4]);
+2 -6
View File
@@ -53,7 +53,6 @@ import {
structureAnchorM,
} from "../../B05_Profile/B05_Profile_Api_Structures";
import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types";
import { saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor";
import {
profileSaveParts,
type PageActionContext,
@@ -135,7 +134,7 @@ export async function mountProfileView(
{
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
// (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다.
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
onCrossDesignsUpdated: () => sections.setCorridorPending(true),
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의
// 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫).
onPipesChanged: (pipes) => {
@@ -188,6 +187,7 @@ export async function mountProfileView(
roadWidths: DEFAULT_ROAD_WIDTHS,
detail: null,
corridorVisible: true,
corridorPending: false,
routeReady: false,
restoring: true,
};
@@ -404,10 +404,6 @@ export async function mountProfileView(
}
profilePick.paint();
},
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
onHide: () => {
if (state.latest?.route?.id) void saveCorridorIfDirty(activeProjectId, state.latest.route.id);
},
});
/** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를
+33 -20
View File
@@ -7,7 +7,7 @@
import { showToast } from "@ui/ui_template_elements";
import { updateContourInterval } from "../../B05_Profile/B05_Profile_Api_Fetch";
import { refreshCorridor, saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor";
import { refreshCorridor } from "../../B05_Profile/B05_Profile_UI_Corridor";
import type { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest";
import { toBounds } from "../../B05_Profile/B05_Profile_UI_Page_Helpers";
import type { ViewControlCallbacks } from "../../B05_Profile/B05_Profile_UI_Panel_View";
@@ -48,27 +48,36 @@ export function createProfile3d(deps: Profile3dDeps) {
onResetView: () => viewer.setView("top"),
onProjection: viewer.setProjection,
// [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다.
onCorridorRefresh: async () => {
const detail = state.detail;
const latest = state.latest;
if (!detail || !latest?.route?.id) return;
const routePoints = latest.route_points ?? [];
if (routePoints.length <= 1) return;
await refreshCorridor(
viewer,
projectId,
latest.route.id,
detail,
routePoints,
deps.profilePanel().alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
deps.sections().renderStationLines(detail);
// 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다.
void saveCorridorIfDirty(projectId, latest.route.id);
},
onCorridorRefresh: () => refreshOnce(),
};
/** 짓는 중이면 그 결과를 같이 기다린다 — 단추 · 모달 열기가 겹쳐도 한 번만 짓는다. */
let refreshing: Promise<void> | null = null;
function refreshOnce(): Promise<void> {
refreshing ??= refreshCorridorNow().finally(() => (refreshing = null));
return refreshing;
}
/** 낡은 3D 길 모형을 지금 노선 · 계획선으로 — 저장본이 맞으면 받기만 · 아니면 워커 미리보기. */
async function refreshCorridorNow(): Promise<void> {
const detail = state.detail;
const latest = state.latest;
if (!detail || !latest?.route?.id) return;
const routePoints = latest.route_points ?? [];
if (routePoints.length <= 1) return;
await refreshCorridor(
viewer,
projectId,
latest.route.id,
detail,
routePoints,
deps.profilePanel().alignmentSamples() ?? undefined,
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
deps.sections().renderStationLines(detail);
deps.sections().setCorridorPending(false);
}
async function applyContours(interval: number): Promise<void> {
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장).
try {
@@ -122,6 +131,10 @@ export function createProfile3d(deps: Profile3dDeps) {
viewer,
loadTerrain,
visible: () => deps.host.visible(),
// 낡은 채 열면 그때 한 번 다시 짓는다(워커 · 345-4) — 닫혀 있는 동안은 안 짓는다.
onOpen: () => {
if (state.corridorPending) void refreshOnce();
},
});
return { viewCallbacks, applyContours, modal };
@@ -18,6 +18,8 @@ export interface Viewer3dModalDeps {
loadTerrain: () => Promise<void>;
/** 종단 보기가 보이는가 — 숨은 보기에서는 안 연다. */
visible: () => boolean;
/** 열 때마다(지형이 선 뒤) — 낡은 3D 길 모형 다시 짓기(345-4). */
onOpen?: () => void;
}
export function createViewer3dModal(deps: Viewer3dModalDeps) {
@@ -54,11 +56,13 @@ export function createViewer3dModal(deps: Viewer3dModalDeps) {
},
});
viewer.setActive(true);
if (terrain) return;
const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true });
progress.set(null);
viewer.root.append(progress.root);
terrain = deps.loadTerrain().finally(() => progress.remove());
if (!terrain) {
const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true });
progress.set(null);
viewer.root.append(progress.root);
terrain = deps.loadTerrain().finally(() => progress.remove());
}
void terrain.then(() => modal && deps.onOpen?.()); // 그 사이 닫았으면 안 지음
}
/** 열려 있으면 닫는다 — 보기를 떠날 때 · 페이지를 떠날 때. */
@@ -29,6 +29,8 @@ export interface ProfileViewState {
detail: SectionDetailResponse | null;
/** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */
corridorVisible: boolean;
/** 3D 길 모형이 지금 정본보다 낡았나 — [3D 업데이트] 표시와 한 값 · 모달을 열 때 다시 짓는 기준(345-4). */
corridorPending: boolean;
routeReady: boolean;
restoring: boolean;
}
@@ -92,7 +94,7 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
detail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
).then((fresh) => panel.setCorridorPending(!fresh));
).then((fresh) => setCorridorPending(!fresh));
}
}
@@ -152,6 +154,12 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
}
}
/** 「3D 늦음」 — [3D 업데이트] 표시(setCorridorPending 그대로)와 상태 한 칸을 같이 맞춘다. */
function setCorridorPending(pending: boolean): void {
state.corridorPending = pending;
deps.panel().setCorridorPending(pending);
}
function renderLatest(next: RouteLatestResponse): void {
state.latest = next;
state.routeReady = Boolean(next.route && next.route_points.length > 1);
@@ -175,5 +183,6 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
renderSections,
restoreSections,
renderLatest,
setCorridorPending,
};
}
+48 -39
View File
@@ -392,6 +392,53 @@ async def _ensure_initial_polyline(
logger.exception("초기 계획노선 폴리라인 생성 실패(계속 진행): %s", project_id)
def redesign_solve_request(
params: dict[str, Any], selection: dict[str, Any], surface_model_id: int
) -> Any:
"""stage 2 params(사용자 저장분) + 새 확정 지표면 → as_planned 풀기 요청(노선 저장도 씀)."""
from B05_Profile.B05_Profile_Schema import RouteSolveRequest
points = params.get("points") or {}
options = params.get("options") or {}
return RouteSolveRequest(
filter_key=str(selection.get("source_filter") or params.get("filter_key")),
method=str(selection.get("method") or params.get("method") or "dtm"),
smooth=bool(selection.get("smooth", params.get("smooth", False))),
surface_model_id=surface_model_id,
# 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사·
# 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측:
# 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음).
# PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다.
algorithm="as_planned",
bp=points["bp"],
ep=points["ep"],
cp=points.get("cp") or [],
ap=points.get("ap") or [],
fp=points.get("fp") or [],
station_interval_m=params.get("station_interval_m"),
cross_half_width_m=params.get("cross_half_width_m"),
cross_sample_interval_m=params.get("cross_sample_interval_m"),
long_sample_interval_m=params.get("long_sample_interval_m"),
**{key: options.get(key) for key in ("grade_class",) if options.get(key)},
paved=bool(options.get("paved", False)),
terrain_type=str(options.get("terrain_type") or "special"),
main_direction=str(options.get("main_direction") or "auto"),
min_curve_radius_m=options.get("min_curve_radius_m"),
max_uphill_grade=options.get("max_uphill_grade"),
max_downhill_grade=options.get("max_downhill_grade"),
min_uphill_grade=options.get("min_uphill_grade"),
min_downhill_grade=options.get("min_downhill_grade"),
weights=options.get("weights"),
allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)),
max_grade_pct=params.get("max_grade_pct"),
min_vertical_radius_m=params.get("min_vertical_radius_m"),
min_tangent_length_m=params.get("min_tangent_length_m"),
balance_segment_length_m=params.get("balance_segment_length_m"),
start_elevation_offset_m=params.get("start_elevation_offset_m"),
end_elevation_offset_m=params.get("end_elevation_offset_m"),
)
async def run_redesign_chain(
project_id: UUID,
surface_model_id: int,
@@ -421,7 +468,6 @@ async def run_redesign_chain(
confirm_latest_route,
solve_route_record,
)
from B05_Profile.B05_Profile_Schema import RouteSolveRequest
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_latest_section_options,
@@ -478,7 +524,6 @@ async def run_redesign_chain(
)
params = (stage2 or {}).get("params") or {}
points = params.get("points") or {}
options = params.get("options") or {}
if not points.get("bp") or not points.get("ep"):
logger.warning(
"재확정 체인 중단(stage 2 params에 제어점 없음): project_id=%s", project_id
@@ -488,43 +533,7 @@ async def run_redesign_chain(
marks.append(("준비·사용자입력 회수", time.perf_counter()))
# 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분.
request = RouteSolveRequest(
filter_key=str(selection.get("source_filter") or params.get("filter_key")),
method=str(selection.get("method") or params.get("method") or "dtm"),
smooth=bool(selection.get("smooth", params.get("smooth", False))),
surface_model_id=surface_model_id,
# 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사·
# 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측:
# 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음).
# PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다.
algorithm="as_planned",
bp=points["bp"],
ep=points["ep"],
cp=points.get("cp") or [],
ap=points.get("ap") or [],
fp=points.get("fp") or [],
station_interval_m=params.get("station_interval_m"),
cross_half_width_m=params.get("cross_half_width_m"),
cross_sample_interval_m=params.get("cross_sample_interval_m"),
long_sample_interval_m=params.get("long_sample_interval_m"),
**{key: options.get(key) for key in ("grade_class",) if options.get(key)},
paved=bool(options.get("paved", False)),
terrain_type=str(options.get("terrain_type") or "special"),
main_direction=str(options.get("main_direction") or "auto"),
min_curve_radius_m=options.get("min_curve_radius_m"),
max_uphill_grade=options.get("max_uphill_grade"),
max_downhill_grade=options.get("max_downhill_grade"),
min_uphill_grade=options.get("min_uphill_grade"),
min_downhill_grade=options.get("min_downhill_grade"),
weights=options.get("weights"),
allow_avoid_pass_through=bool(options.get("allow_avoid_pass_through", False)),
max_grade_pct=params.get("max_grade_pct"),
min_vertical_radius_m=params.get("min_vertical_radius_m"),
min_tangent_length_m=params.get("min_tangent_length_m"),
balance_segment_length_m=params.get("balance_segment_length_m"),
start_elevation_offset_m=params.get("start_elevation_offset_m"),
end_elevation_offset_m=params.get("end_elevation_offset_m"),
)
request = redesign_solve_request(params, selection, surface_model_id)
# 종횡단은 관을 정한 뒤 한 번만 만든다(342-2) — 여기서 만들면 아래 4c 가 통째로 덮는 헛일.
solve_result: Any = await solve_route_record(project_id, request, generate_sections=False)
if isinstance(solve_result, JSONResponse):
+6 -1
View File
@@ -591,6 +591,8 @@ export interface DetailBasinResponse {
pipe_signature?: string;
/** 분석이 낡음(노선이 분석 때와 다름) — 까닭 글 · 세부 유역 없음 · 관 자리만. */
stale?: string | null;
/** 관 자리 후보(347-3) — 제안만 · [적용] 때 관 초안에. */
pipe_candidates?: Array<{ chainage_m: number; lonlat: [number, number] }>;
}
let lastNote = { text: "", at: 0 };
@@ -640,10 +642,13 @@ export async function resetDetailPipePoints(projectId: string): Promise<DetailBa
export async function saveDetailPipePoints(
projectId: string,
points: DetailPipeInput[],
recompute = false,
): Promise<DetailBasinResponse> {
// [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림.
const saved = await requestJson<DetailBasinResponse>(
`/projects/${projectId}/drainage/pipe-points`,
`/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`,
{ method: "PUT", body: JSON.stringify({ points }) },
recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
);
noteSnapshotFailed(saved.snapshot_failed);
return saved;
+93 -47
View File
@@ -12,6 +12,8 @@
import { API_BASE_URL } from "@config/config_frontend";
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
import type { StageContext } from "../A00_Common/design/design_pipeline_types";
import type { MassHaulStageOutput } from "./logic/B05_Profile_MassHaul_Stage";
/** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다.
* `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */
@@ -24,6 +26,84 @@ const TIMEOUT_MS = 20000;
/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */
const KEPT_PLANS = 8;
/** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입 · 묶음 단계가 함께 씀. */
const kept = new Map<string, HaulPlanPayload>();
/**
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
*/
function keyOf(result: unknown): string {
const points = (result as { points?: unknown[] })?.points;
if (!Array.isArray(points) || !points.length) return "empty";
return JSON.stringify(points);
}
const keptKey = (projectId: string, route: number, key: string): string =>
`${projectId}:${route}|${key}`;
function keep(at: string, plan: HaulPlanPayload): void {
kept.delete(at);
kept.set(at, plan);
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
}
/** 서버에 배분을 물음 — 받으면 기억 · 못 받으면(실패 · 끊김) undefined. `signal` 이 끊으면 함께 끊음. */
async function postHaulPlan(
projectId: string,
route: number,
result: unknown,
key: string,
signal?: AbortSignal,
): Promise<HaulPlanPayload | undefined> {
if (signal?.aborted) return undefined;
const controller = new AbortController();
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
const relay = () => controller.abort();
signal?.addEventListener("abort", relay);
try {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result }),
signal: controller.signal,
},
);
if (!response.ok) return undefined;
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
const plan = payload.haul_plan ?? null;
keep(keptKey(projectId, route, key), plan);
return plan;
} catch {
return undefined; // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
} finally {
window.clearTimeout(abort);
signal?.removeEventListener("abort", relay);
}
}
/**
* 계산 묶음 `haul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — masshaul 의 횡단 기준 결과를 서버
* Node 로 배분 · 같은 열쇠면 안 물음 · 새 조작이 오면 `ctx.signal` 로 끊김(옛 번호 결과는 묶음이 버림).
* 못 받으면 앞 배분(`ctx.prev`) 그대로.
*/
export function createHaulStage(projectId: string, routeId: () => number | undefined) {
return async (ctx: StageContext<"haul">): Promise<HaulPlanPayload> => {
const result = (ctx.out.masshaul as MassHaulStageOutput | null)?.haulInput;
const route = routeId();
if (!result || !route) return (ctx.prev as HaulPlanPayload) ?? null;
const key = keyOf(result);
const at = keptKey(projectId, route, key);
if (kept.has(at)) return kept.get(at) ?? null;
const plan = await postHaulPlan(projectId, route, result, key, ctx.signal);
return plan === undefined ? ((ctx.prev as HaulPlanPayload) ?? null) : plan;
};
}
export interface HaulPlanPrefetch {
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
schedule: (result: unknown) => void;
@@ -50,19 +130,6 @@ export function createHaulPlanPrefetch(
let sentKey: string | null = null;
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
let applying = false;
/** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */
const kept = new Map<string, HaulPlanPayload>();
/**
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
*/
function keyOf(result: unknown): string {
const points = (result as { points?: unknown[] })?.points;
if (!Array.isArray(points) || !points.length) return "empty";
return JSON.stringify(points);
}
/** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */
function forget(seq: number): void {
@@ -72,41 +139,19 @@ export function createHaulPlanPrefetch(
async function send(result: unknown, seq: number, key: string): Promise<void> {
const route = routeId();
if (!route) return forget(seq);
const controller = new AbortController();
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
const got = await postHaulPlan(projectId, route, result, key);
if (got === undefined) return forget(seq);
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
if (seq !== sequence) return;
plan = got;
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
applying = true;
try {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result }),
signal: controller.signal,
},
);
if (!response.ok) return forget(seq);
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
if (seq !== sequence) return;
plan = payload.haul_plan ?? null;
kept.delete(key);
kept.set(key, plan);
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
applying = true;
try {
onReady();
} finally {
applying = false;
}
} catch {
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
forget(seq);
onReady();
} finally {
window.clearTimeout(abort);
applying = false;
}
}
@@ -121,7 +166,8 @@ export function createHaulPlanPrefetch(
const seq = sequence;
window.clearTimeout(timer);
// 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다.
const known = kept.get(key);
const route = routeId();
const known = route ? kept.get(keptKey(projectId, route, key)) : undefined;
if (known !== undefined) return void (plan = known);
timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS);
},
+496
View File
@@ -0,0 +1,496 @@
"""노선 저장 서버 길(346-3) — 노선 초안을 정본으로: 노선 → 풀기 → 관 · 구조물 따라감 →
종 · 횡단 한 번 → 계획고 옮김 → 횡단 이월 → B06 확정(서버 재계산 · 3D). 배수유역은 체인에서 뗌.
API — `POST /api/projects/{id}/route/replan`(`B05_Profile_Router_Replan.replan_route`):
요청 `RouteSaveRequest` = 옛 칸(`vertices` · `initial` · `balanced_profile` · `terrain` ·
`station_snap`) +
`draft` — 계약 `RouteDraft`(`A00_Common/design/design_pipeline_types.ts`) 그대로
`{rev, nodes: [[x, y]…], curveOn: [bool…], curveRadius: [m | null…], curveLock, curveArc,
stationSnap, terrain}` · 오면 `vertices` · `station_snap` · `terrain` 대신 이것.
`as_initial` — 추천 노선 [적용](초기값 다시 뜸) · 손으로 고친 노선 저장은 false(초기값 그대로).
응답 200 `{status: "success", project_id, route_id, total_length_m, vertex_count, current_initial,
initial_refreshed, pipes: {kept, crossing, concave}, structures: {moved, far}, cross_dropped,
drainage_stale: true, seconds}`.
오류 400(노드 둘 미만 · 초안 칸 길이 어긋남) · 404(프로젝트 없음) ·
409 `{status: "error", message}`(체인 실패 — 노선 · 관 · 구조물 · 종횡단 파일 · 새 노선 행을
옛것으로 되살린 뒤).
규칙:
- 관 정본을 지우지 않음 — 좌표를 따라감 · 새 세류선 교차점 기본 관 · 오목 지점 보충만 더함
(`B05_Profile_Route_Save_Parts`) · 배수유역 저장분은 노선 지문이 달라 「낡음」(B04 `_resolve`).
- 계획고 = 바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 지킴
(`B05_Profile_Engine_Grade_Carry`) · 추천 「종단 반영」 켬이면 균형 종단
(`apply_profile_target`).
- 초기값은 여기서 안 뜸 — 부르는 쪽이 `as_initial` 일 때만(자동설계 체인 직후 + 추천 [적용]).
"""
from __future__ import annotations
import asyncio
import json
import logging
import shutil
import tempfile
import time
from dataclasses import asdict
from pathlib import Path
from typing import Any, Literal
from uuid import UUID
import numpy as np
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B05_Profile.B05_Profile_Route_Save_Parts import (
add_crossing_pipes,
carry_cross_inputs,
concave_chainages,
fill_concave_pipes,
follow_pipes,
station_keys,
vertices_of,
)
from B05_Profile.B05_Profile_Schema import RouteReplanRequest, RouteVertexInput
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
class RouteDraftInput(BaseModel):
"""노선 초안 — 계약 `RouteDraft` 모양 그대로(칸 이름 camelCase)."""
rev: int = 0
nodes: list[tuple[float, float]] = Field(default_factory=list)
curveOn: list[bool] = Field(default_factory=list) # noqa: N815 — 계약 칸 이름
curveRadius: list[float | None] = Field(default_factory=list) # noqa: N815
curveLock: list[str | None] = Field(default_factory=list) # noqa: N815
curveArc: list[float | None] = Field(default_factory=list) # noqa: N815
stationSnap: bool = False # noqa: N815
terrain: Literal["las", "sheet"] | None = None
class RouteSaveRequest(RouteReplanRequest):
"""노선 저장 요청 — 옛 [노선 반영] 칸 + 초안(`draft`) · 추천 [적용](`as_initial`)."""
draft: RouteDraftInput | None = None
as_initial: bool = False
def resolved(self) -> RouteReplanRequest:
"""초안이 오면 노드 · 곡선 · 스냅 · 지형을 옛 칸으로 편다. 칸 길이가 어긋나면 ValueError."""
if self.draft is None:
return self
draft = self.draft
count = len(draft.nodes)
for name in ("curveOn", "curveRadius"):
values = getattr(draft, name)
if values and len(values) != count:
raise ValueError(
f"노선 초안 {name} 길이({len(values)})가 노드 수({count})와 다릅니다."
)
vertices = [
RouteVertexInput(
x=x,
y=y,
curve=draft.curveOn[index] if draft.curveOn else True,
radius_m=draft.curveRadius[index] if draft.curveRadius else None,
)
for index, (x, y) in enumerate(draft.nodes)
]
return self.model_copy(
update={
"vertices": vertices,
"station_snap": draft.stationSnap,
"terrain": draft.terrain,
}
)
class _Backup:
"""체인이 고치는 파일을 손에 쥠 — 실패하면 그대로 되돌림(노선 · 관 · 구조물 · 종횡단)."""
def __init__(self, project_root: Path, paths: list[Path], folders: list[Path]) -> None:
self.held = tempfile.mkdtemp(prefix="route_save_", dir=str(project_root))
self.files = {path: path.read_bytes() if path.is_file() else None for path in paths}
self.folders: dict[Path, Path | None] = {}
for index, folder in enumerate(folders):
copy = Path(self.held) / f"dir{index}"
self.folders[folder] = shutil.copytree(folder, copy) if folder.is_dir() else None
def restore(self) -> None:
for path, saved in self.files.items():
if saved is None:
path.unlink(missing_ok=True)
else:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(saved)
for folder, copy in self.folders.items():
shutil.rmtree(folder, ignore_errors=True)
if copy is not None:
shutil.copytree(copy, folder)
def drop(self) -> None:
shutil.rmtree(self.held, ignore_errors=True)
class _Fail(RuntimeError):
"""체인 한 단계 실패 — 사유를 그대로 화면까지(까닭을 덮지 않음)."""
def _reason(response: JSONResponse) -> str:
try:
return bytes(response.body).decode("utf-8", "replace")[:300]
except Exception: # noqa: BLE001 — 사유는 로그 · 응답용
return f"status={response.status_code}"
def _read_json(path: Path) -> dict[str, Any]:
try:
return json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def _xy(project_root: Path, route_data_path: str | None) -> list[list[float]]:
from B05_Profile.B05_Profile_Engine_Sections import _load_route_polyline
if not route_data_path:
return []
try:
return [[x, y] for x, y, _ in _load_route_polyline(project_root, route_data_path)]
except (OSError, ValueError):
return []
def _ground_along(project_root: Path, selection: dict[str, Any], vertices: list) -> tuple:
"""새 노선 지반 종단(2 m) — 오목 지점 찾기. 지표면을 못 열면 빈 배열(보충 건너뜀)."""
from shapely.geometry import LineString
from B05_Profile.B05_Profile_Route_Save_Parts import CONCAVE_STEP_M
from common_util.common_util_surface_sampler import build_surface_sampler
try:
sampler = build_surface_sampler(
project_root / "B04_PreProcess" / "models",
str(selection.get("source_filter")),
str(selection.get("method") or "dtm"),
bool(selection.get("smooth")),
)
except (FileNotFoundError, KeyError, OSError, ValueError):
logger.warning("노선 저장: 지표면을 못 열어 오목 지점 보충을 건너뜀")
return np.zeros(0), np.zeros(0)
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
chainage = np.arange(0.0, line.length + 1e-9, CONCAVE_STEP_M)
xy = np.array([line.interpolate(float(s)).coords[0] for s in chainage], dtype=np.float64)
z, valid = sampler.sample_xy(xy)
return chainage[valid], np.asarray(z, dtype=np.float64)[valid]
async def _place_pipes(
project_id: UUID,
project_root: Path,
stored_path: str,
selection: dict[str, Any],
old_pipes: list,
old_xy: list[list[float]],
points: list[dict[str, float]],
) -> tuple[list, dict[str, int]]:
"""관 따라감 → 새 교차점 기본 관 → 오목 지점 보충 → 정본 저장(새 노선 지문)."""
from B04_PreProcess.B04_PreProcess_Engine_Watershed_Analyze import _base_pipes
from B04_PreProcess.B04_PreProcess_Router_Watershed import _prepare
from common_util.common_util_drainage_pipes import route_signature, save_pipe_points
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse): # 도엽 · 노선 파일 없음 — 따라감만
vertices, streams = vertices_of([(p["x"], p["y"]) for p in points]), []
else:
vertices, streams = prepared["vertices"], prepared["streams"]
pairs = follow_pipes(old_pipes, vertices)
total = float(vertices[-1].chainage_m)
crossing = add_crossing_pipes(pairs, _base_pipes(vertices, streams), old_xy, total)
chainage, ground = await asyncio.to_thread(_ground_along, project_root, selection, vertices)
concave = fill_concave_pipes(pairs, concave_chainages(chainage, ground), vertices, old_xy)
await asyncio.to_thread(
save_pipe_points,
stored_path,
route_signature(vertices),
[pipe for _, pipe in pairs],
vertices,
)
return pairs, {"kept": len(old_pipes), "crossing": crossing, "concave": concave}
async def _carry_profile(
project_root: Path,
relative: str,
route_id: int,
old_alignment: dict[str, Any],
old_xy: list[list[float]],
new_xy: list[list[float]],
) -> None:
"""바뀐 구간만 새 지반 첫 계획선 · 그 밖 사용자 계획고 · 종단곡선 R 은 길이 차만큼 밀어 지킴."""
from B05_Profile.B05_Profile_Engine_Grade_Carry import carry_profile_edits, changed_span
from B05_Profile.B05_Profile_Repository import update_longitudinal_grade_summary
from B05_Profile.B05_Profile_Router import _apply_alignment_edits
from common_util.common_util_json import atomic_write_json
if not old_alignment.get("base_pvi"):
return # 옛 선형 없음 — 새 지반 첫 계획선 그대로
path = project_root / relative
longitudinal = _read_json(path)
alignment = longitudinal.get("profile_alignment") or {}
span = changed_span([tuple(p) for p in old_xy], [tuple(p) for p in new_xy])
if span is None: # 같은 노선 — 옛 계획선 통째
base, edits = old_alignment["base_pvi"], old_alignment.get("edits") or {}
else:
base, edits = carry_profile_edits(
old_alignment["base_pvi"], old_alignment.get("edits"), span, alignment["base_pvi"]
)
alignment["base_pvi"] = base
await asyncio.to_thread(atomic_write_json, path, longitudinal)
_alignment, summary, _dropped = await asyncio.to_thread(
_apply_alignment_edits, project_root, relative, edits
)
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
await update_longitudinal_grade_summary(
connection, route_id=route_id, grade_summary=summary
)
await connection.commit()
async def _carry_cross(
project_id: UUID,
project_root: Path,
route_id: int,
relative: str,
old_long: dict[str, Any],
old_designs: list[dict[str, Any]],
old_pipes: list,
old_structures: list,
pairs: list,
interval_m: float,
standard: dict[str, Any] | None,
) -> int:
"""측점 열쇠로 옛 횡단 입력을 새 측점에 얹고 전 측점을 새 계획고로 셈 · 버린 수 돌려줌."""
from B05_Profile.B05_Profile_Structures_Repository import load_structures
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Confirm import _rowless_station_chainages
from B06_Section.B06_Section_Router_Design import (
CARRY_INPUT_KEYS,
STRUCTURE_ADJUST_KEYS,
compute_default_designs,
)
new_long = _read_json(project_root / relative)
_, new_structures = load_structures(str(project_root))
old_keys = station_keys(
old_long.get("stations") or [], list(enumerate(old_pipes)), old_structures, interval_m
)
new_keys = station_keys(new_long.get("stations") or [], pairs, new_structures, interval_m)
designs = {
round(float(row["chainage_m"]), 3): row["design"]
for row in old_designs
if isinstance(row.get("design"), dict)
}
inputs, adjusts, dropped = carry_cross_inputs(
old_keys, new_keys, designs, CARRY_INPUT_KEYS, STRUCTURE_ADJUST_KEYS
)
chainages = await asyncio.to_thread(_rowless_station_chainages, project_root, relative, [])
entries = await asyncio.to_thread(
compute_default_designs, project_root, relative, chainages, standard, inputs
)
entries = [(at, {**design, **adjusts.get(round(at, 3), {})}) for at, design in entries]
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
await merge_cross_section_designs(
connection, route_id=route_id, entries=entries, replace=True, project_id=project_id
)
await connection.commit()
except Exception:
await connection.rollback()
raise
return dropped
async def _rollback(project_id: UUID, old_route_id: int, params: dict[str, Any]) -> None:
"""새 노선 행(종횡단 함께 지워짐)을 지우고 stage 2 params 를 옛것으로 — 최신 노선 = 옛 노선."""
from common_util.common_util_workflow_state import start_stage
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin()
try:
async with connection.cursor() as cursor:
await cursor.execute(
"DELETE FROM routes WHERE project_id = %s AND id > %s",
(str(project_id), old_route_id),
)
await start_stage(cursor, str(project_id), 2, params)
await connection.commit()
except Exception:
await connection.rollback()
logger.exception("노선 저장 되살림(DB) 실패: project_id=%s", project_id)
async def run_route_save_chain(
project_id: UUID,
project_root: Path,
stored_path: str,
surface_model_id: int,
selection: dict[str, Any],
old_route: dict[str, Any],
points: list[dict[str, float]],
previous_params: dict[str, Any],
profile_target: tuple[list[float], list[float]] | None = None,
) -> dict[str, Any]:
"""노선 저장 체인 — 성공이면 요약 dict · 실패면 `{"error": 사유}`(파일 · 행은 되살린 뒤)."""
import aiomysql
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps, redesign_solve_request
from B05_Profile.B05_Profile_Engine_Sections import _STAGE_SUBDIR
from B05_Profile.B05_Profile_Router import (
_section_options,
confirm_latest_route,
solve_route_record,
)
from B05_Profile.B05_Profile_Structures_Repository import load_structures, structures_file_path
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_latest_section_options,
get_longitudinal_section,
)
from B06_Section.B06_Section_Router import regenerate_route_sections
from B06_Section.B06_Section_Router_Confirm import confirm_sections
from B06_Section.B06_Section_Router_Design import stored_standard_cross_section
from B06_Section.B06_Section_Schema import SectionConfirmRequest
from common_util.common_util_drainage_pipes import pipe_points_path, read_pipe_points_file
from common_util.common_util_initial_snapshot import clear_designing, mark_designing
from common_util.common_util_workflow_state import get_workflow_state
marks = [("시작", time.perf_counter())]
old_route_id = int(old_route["id"])
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor(aiomysql.DictCursor) as cursor:
state = await get_workflow_state(cursor, str(project_id))
old_long_row = await get_longitudinal_section(connection, project_id, old_route_id)
old_designs = await get_cross_section_designs(connection, old_route_id)
old_section_options = await get_latest_section_options(connection, project_id)
stage2 = next((s for s in state.get("stages", []) if int(s.get("stage_no", -1)) == 2), None)
params = (stage2 or {}).get("params") or {}
relative_old = str((old_long_row or {}).get("longitudinal_file_path") or "")
old_long = _read_json(project_root / relative_old) if relative_old else {}
old_xy = _xy(project_root, old_route.get("route_data_path"))
old_pipes = read_pipe_points_file(pipe_points_path(stored_path))
_, old_structures = load_structures(str(project_root))
stage_dir = project_root / _STAGE_SUBDIR
backup = _Backup(
project_root,
[
project_root / str(old_route.get("route_data_path") or "B05_Profile/route/x"),
pipe_points_path(stored_path),
Path(structures_file_path(str(project_root))),
],
[stage_dir / "longitudinal", stage_dir / "cross_sections"],
)
mark_designing(project_root)
try:
# 구조물 = 땅 위 자리 따라감(옛 노선 파일이 아직 옛 것일 때 — 좌표 없는 옛 자료 채움)
try:
structures = await asyncio.to_thread(
follow_structures_to_route, project_root, old_route.get("route_data_path"), points
)
except Exception: # noqa: BLE001 — 따라감 실패 = 옛 누가거리 그대로(비치명)
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
structures = {"moved": 0, "far": 0}
request = redesign_solve_request(params, selection, surface_model_id)
solved: Any = await solve_route_record(project_id, request, generate_sections=False)
if isinstance(solved, JSONResponse):
raise _Fail(f"노선 풀기 실패({solved.status_code}): {_reason(solved)}")
new_route_id = int(solved.route_id)
confirmed = await confirm_latest_route(project_id, None, mark_stage_complete=False)
if isinstance(confirmed, JSONResponse):
raise _Fail(f"노선 확정 실패({confirmed.status_code})")
new_xy = _xy(project_root, solved.route_data_path)
marks.append(("노선 풀기 · 구조물 따라감", time.perf_counter()))
pairs, pipes = await _place_pipes(
project_id, project_root, stored_path, selection, old_pipes, old_xy, points
)
marks.append(("관 따라감 · 교차점 · 오목 보충", time.perf_counter()))
seed = asdict(_section_options(request, old_section_options))
seed.pop("extra_stations", None)
made = await regenerate_route_sections(
project_id, new_route_id, float(seed["cross_half_width_m"]), stored_options=seed
)
if isinstance(made, JSONResponse):
raise _Fail(f"종횡단 생성 실패({made.status_code}): {_reason(made)}")
async with pool.acquire() as connection:
new_long_row = await get_longitudinal_section(connection, project_id, new_route_id)
relative = str((new_long_row or {}).get("longitudinal_file_path") or "")
marks.append(("종횡단 한 번", time.perf_counter()))
if profile_target is not None:
from B05_Profile.B05_Profile_Router_RouteInitial import apply_profile_target
await apply_profile_target(project_id, new_route_id, profile_target)
else:
await _carry_profile(
project_root,
relative,
new_route_id,
old_long.get("profile_alignment") or {},
old_xy,
new_xy,
)
marks.append(("계획고 옮김", time.perf_counter()))
standard = stored_standard_cross_section(old_long_row)
dropped = await _carry_cross(
project_id,
project_root,
new_route_id,
relative,
old_long,
old_designs,
old_pipes,
old_structures,
pairs,
float(seed["station_interval_m"]),
standard,
)
marks.append(("횡단 이월(측점 열쇠)", time.perf_counter()))
done = await confirm_sections(
project_id,
new_route_id,
SectionConfirmRequest(standard_cross_section=standard) if standard else None,
mark_stage_complete=False,
)
if isinstance(done, JSONResponse):
raise _Fail(f"종횡단 확정 실패({done.status_code})")
marks.append(("B06 확정 · 서버 재계산 · 3D", time.perf_counter()))
except Exception as exc: # noqa: BLE001 — 어느 단계든 옛 노선으로 되살림
if not isinstance(exc, _Fail):
logger.exception("노선 저장 체인 실패: project_id=%s", project_id)
await asyncio.to_thread(backup.restore)
await _rollback(project_id, old_route_id, previous_params)
return {"error": str(exc) if isinstance(exc, _Fail) else f"노선 저장 중 오류: {exc}"}
finally:
backup.drop()
clear_designing(project_root)
_log_steps("재확정 체인(노선 저장)", marks)
return {
"route_id": new_route_id,
"total_length_m": float(solved.total_length_m or 0.0),
"pipes": pipes,
"structures": structures,
"cross_dropped": dropped,
"drainage_stale": True,
"seconds": round(marks[-1][1] - marks[0][1], 2),
}
+224
View File
@@ -0,0 +1,224 @@
"""노선 저장 길(346-3) 순수 부품 — 관 따라감 · 새 교차점 기본 관 · 오목 지점 보충 · 측점 열쇠 ·
횡단 이월.
DB · 파일 없이 값만 받아 값을 돌려준다(시험이 그대로 부름). 차례 · 저장 · 되살림은
`B05_Profile_Route_Save.py`.
- 관 = 좌표가 정본 — 새 노선에 투영해 누가거리만 다시(`project_pipe_points` · 정수 m). 지우지 않음
(사용자 관 편집 지킴).
- 새 세류선 교차점(옛 노선 위에 없던 자리)에 관이 없으면 기본 관 하나(`_base_pipes` 결과를 받음).
- 관 최대 간격 보충 = 옛 노선에서 벗어난 자리의 **오목 지점**(지반 종단 낮은 곳)에만 — 격자 없음.
- 측점 열쇠(계약 `design_pipeline_types.ts` 머리) = 일반 `grid:k`(누가거리 ÷ 측점 간격 반올림) ·
관 `pipe:<id>` · 구조물 `structure:<id>`(구간 시작 · 끝은 `:start` · `:end`) · 시종점
`end:BP` · `end:EP`. 관 id = 저장 파일 차례(노선 저장 한 번 안에서 옛 · 새가 같음).
- 횡단 이월 = 같은 열쇠 측점에 `CARRY_INPUT_KEYS` 일곱 · 관 · 구조물 열쇠면
`STRUCTURE_ADJUST_KEYS` 도(구조물 조정은 구조물 따라) · 열쇠가 사라진 측점 수 =
「노선 밖 고침」.
"""
from __future__ import annotations
import copy
import math
from typing import Any, Sequence
import numpy as np
from shapely.geometry import LineString, Point
from common_util.common_util_drainage_pipes import (
PIPE_SOURCE_SPACING,
PIPE_SOURCE_STREAM,
PipePoint,
project_pipe_points,
)
from common_util.common_util_route_geometry import RouteVertex
from common_util.common_util_structure_station import plan_stations, snap_structure_station
from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M
# 옛 노선에서 이만큼(m) 넘게 떨어진 자리 = 노선이 바뀐 자리(교차점 · 보충 대상).
MOVED_AWAY_M = 0.5
# 오목 지점을 찾을 지반 표본 간격(m) · 고름 창(표본 수) — DTM 잔물결을 오목으로 안 읽게.
# ponytail: 이동 평균 + 국소 최저 · 계곡이 얕아 놓치면 창을 줄이거나 흐름 강도와 섞을 것.
CONCAVE_STEP_M = 2.0
CONCAVE_WINDOW = 5
_SAME_M = 0.01
def vertices_of(xy: Sequence[Sequence[float]]) -> list[RouteVertex]:
"""평면 점열 → 누가거리 붙은 정점(z 0)."""
out: list[RouteVertex] = []
walked = 0.0
for index, point in enumerate(xy):
x, y = float(point[0]), float(point[1])
if index:
walked += math.hypot(x - out[-1].x, y - out[-1].y)
out.append(RouteVertex(x=x, y=y, z=0.0, chainage_m=walked))
return out
def follow_pipes(
old_pipes: list[PipePoint], vertices: list[RouteVertex]
) -> list[tuple[int, PipePoint]]:
"""관이 새 노선 좌표를 따라감 → [(관 id = 옛 차례, 새 관)] 누가거리 순 · 옛 목록 그대로."""
pairs = [(index, copy.deepcopy(pipe)) for index, pipe in enumerate(old_pipes)]
for _, pipe in pairs:
project_pipe_points([pipe], vertices)
pairs.sort(key=lambda pair: pair[1].chainage_m)
return pairs
def _moved_away(old_line: LineString | None, x: float, y: float) -> bool:
return old_line is None or old_line.distance(Point(x, y)) > MOVED_AWAY_M
def add_crossing_pipes(
pairs: list[tuple[int, PipePoint]],
crossings: Sequence[Any],
old_xy: Sequence[Sequence[float]] | None,
total_m: float,
) -> int:
"""옛 노선에 없던 세류선 교차점에 관이 없으면 기본 관을 더함 · 더한 수."""
old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None
added = 0
for crossing in crossings:
if not _moved_away(old_line, crossing.x, crossing.y):
continue # 옛 교차점 — 사용자가 지운 관을 되살리지 않음
at = snap_structure_station(float(crossing.chainage_m), [total_m])
if any(abs(pipe.chainage_m - at) < DRAINAGE_PIPE_MIN_SPACING_M for _, pipe in pairs):
continue
pairs.append(
(-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_STREAM, x=crossing.x, y=crossing.y))
)
added += 1
pairs.sort(key=lambda pair: pair[1].chainage_m)
return added
def concave_chainages(chainage: np.ndarray, ground: np.ndarray) -> list[tuple[float, float]]:
"""지반 종단의 오목 지점(이동 평균 국소 최저) → [(누가거리, 고른 지반고)]."""
if ground.size < 3:
return []
kernel = np.ones(CONCAVE_WINDOW) / CONCAVE_WINDOW
pad = CONCAVE_WINDOW // 2
smooth = np.convolve(np.pad(ground, pad, mode="edge"), kernel, mode="valid")
return [
(float(chainage[i]), float(smooth[i]))
for i in range(1, smooth.size - 1)
if smooth[i] < smooth[i - 1] and smooth[i] <= smooth[i + 1]
]
def fill_concave_pipes(
pairs: list[tuple[int, PipePoint]],
concave: list[tuple[float, float]],
vertices: list[RouteVertex],
old_xy: Sequence[Sequence[float]] | None,
) -> int:
"""관 사이(시종점 포함)가 최대 간격을 넘으면 옛 노선 밖 오목 지점 중 가장 낮은 곳에 관."""
line = LineString([(vertex.x, vertex.y) for vertex in vertices])
old_line = LineString(old_xy) if old_xy and len(old_xy) >= 2 else None
total = float(line.length)
added = 0
while True:
edges = [0.0, *(pipe.chainage_m for _, pipe in pairs), total]
best: tuple[float, float] | None = None
for start, end in zip(edges, edges[1:]):
if end - start <= DRAINAGE_PIPE_MAX_SPACING_M:
continue
for at, z in concave:
if not (
start + DRAINAGE_PIPE_MIN_SPACING_M < at < end - DRAINAGE_PIPE_MIN_SPACING_M
):
continue
point = line.interpolate(at)
if _moved_away(old_line, point.x, point.y) and (best is None or z < best[1]):
best = (at, z)
if best is None:
return added
at = snap_structure_station(best[0], [total])
point = line.interpolate(at)
pairs.append(
(-1, PipePoint(chainage_m=at, source=PIPE_SOURCE_SPACING, x=point.x, y=point.y))
)
pairs.sort(key=lambda pair: pair[1].chainage_m)
concave = [item for item in concave if abs(item[0] - best[0]) > _SAME_M]
added += 1
def _facility_keys(
pipes: Sequence[tuple[int, PipePoint]], structures: Sequence[Any]
) -> dict[float, str]:
"""측점 자리(0.01 m) → 관 · 구조물 열쇠. 같은 자리면 관이 먼저(`plan_stations` 이름 규칙)."""
keys: dict[float, str] = {}
def put(marks: list[dict[str, object]], head: str) -> None:
for mark in marks:
role = str(mark.get("role") or "anchor")
keys.setdefault(
round(float(mark["chainage_m"]), 2), head if role == "anchor" else f"{head}:{role}"
)
for pipe_id, pipe in pipes:
if pipe_id >= 0:
put(plan_stations([pipe], []), f"pipe:{pipe_id}")
for item in structures:
if getattr(item, "structure_id", None):
put(plan_stations([], [item]), f"structure:{item.structure_id}")
return keys
def station_keys(
stations: Sequence[dict[str, Any]],
pipes: Sequence[tuple[int, PipePoint]],
structures: Sequence[Any],
interval_m: float,
) -> dict[str, float]:
"""측점 열쇠 → 누가거리. 관 · 구조물 자리 > 시종점 > 일반 격자(간격 배수인 것만)."""
facility = _facility_keys(pipes, structures)
values = [float(row["chainage_m"]) for row in stations if row.get("chainage_m") is not None]
out: dict[str, float] = {}
for index, chainage in enumerate(values):
key = facility.get(round(chainage, 2))
if key is None and index == 0:
key = "end:BP"
elif key is None and index == len(values) - 1:
key = "end:EP"
elif key is None and interval_m > 0:
k = round(chainage / interval_m)
key = f"grid:{k}" if abs(chainage - k * interval_m) < _SAME_M else None
if key is not None:
out.setdefault(key, chainage)
return out
def carry_cross_inputs(
old_keys: dict[str, float],
new_keys: dict[str, float],
old_designs: dict[float, dict[str, Any]],
input_keys: Sequence[str],
adjust_keys: Sequence[str],
) -> tuple[dict[float, dict[str, Any]], dict[float, dict[str, Any]], int]:
"""같은 열쇠 측점으로 횡단 입력을 넘김 → (입력{새 누가거리 0.001: 값}, 구조물 조정{…}, 버린 수).
`old_designs` = 옛 노선 행 {누가거리 0.001: design}. 버린 수 = 설계가 있던 옛 측점 중 새 노선에
같은 열쇠가 없는 것(「노선 밖 고침」).
"""
inputs: dict[float, dict[str, Any]] = {}
adjusts: dict[float, dict[str, Any]] = {}
dropped = 0
for key, old_at in old_keys.items():
design = old_designs.get(round(old_at, 3))
if not isinstance(design, dict):
continue
new_at = new_keys.get(key)
if new_at is None:
dropped += 1
continue
carried = {name: design[name] for name in input_keys if design.get(name) is not None}
if carried:
inputs[round(new_at, 3)] = carried
if key.startswith(("pipe:", "structure:")):
adjust = {name: design[name] for name in adjust_keys if design.get(name) is not None}
if adjust:
adjusts[round(new_at, 3)] = adjust
return inputs, adjusts, dropped
+43 -41
View File
@@ -14,17 +14,13 @@
노선 초기화는 수정본 파일을 지우는 것이다 — 그러면 초기 폴리라인을 읽으므로 「초기본을
수정본으로 복사」와 결과가 같다.
[확인]을 눌렀을 때만 계산이 돈다. 재계산은 **초기 업로드 체인의 로직을 그대로 재사용**한다
(2026-09-06 사용자 제안) — `run_redesign_chain`이 배수유역 다시 분석 → 기본 관 저장 → 관
정착 계획선 재산출 → 종횡단 재생성 → 옛 측점 설계를 누가거리로 이월 → B05·B06 확정까지 한
줄로 돈다. 여기서 하는 일은 그 앞에 **노선을 갈아 끼우는 것**뿐이다.
시설 처리(2026-09-06 사용자 확정) — 횡단배수 지점은 노선 자리에 따라 생기고 없어지므로
저장분을 버리고 **새로 계산한 값**을 쓴다. 그 밖의 구조물은 **옛 측점값을 그대로** 이어받는다
(`structures.json`은 프로젝트 정본이라 손대지 않으면 그대로 남는다).
노선 저장(346-3) — 여기서 노선을 갈아 끼운 뒤 `B05_Profile_Route_Save.run_route_save_chain` 이
풀기 → 관 · 구조물 좌표 따라감 → 종횡단 한 번 → 계획고 옮김 → 횡단 이월(측점 열쇠) → B06 확정까지
돈다. 배수유역은 다시 안 셈(낡음) · 관 정본은 안 지움 · 초기값은 추천 [적용](`as_initial`)일 때만.
요청 · 응답 · 오류 모양 = 그 모듈 머리.
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
POST /api/projects/{id}/route/replan → 고친 계획노선으로 갈아 끼우고 재계산
POST /api/projects/{id}/route/replan → 노선 초안 저장(노선 저장 체인)
POST /api/projects/{id}/route/replan/reset → 계획노선을 예상노선으로 되돌리고 재계산
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀)
"""
@@ -45,6 +41,7 @@ from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
RouteInitialCriteria,
RouteInitialRequest,
@@ -340,22 +337,19 @@ async def _recompute(
from B03_FileInput.B03_FileInput_Service_Chain import (
_log_steps,
_planned_route_points_in_project_crs,
run_redesign_chain,
)
from B04_PreProcess.B04_PreProcess_Service import (
find_surface_model_for_selection,
switch_design_terrain,
)
from B05_Profile.B05_Profile_Repository import get_latest_route
from common_util.common_util_drainage_pipes import clear_pipe_points
from B05_Profile.B05_Profile_Route_Save import run_route_save_chain
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state, start_stage
marks = [("시작", time.perf_counter())]
# 횡단배수 지점은 새 노선에서 새로 계산한다 — 저장분을 남기면 옛 자리가 살아난다.
await asyncio.to_thread(clear_pipe_points, stored_path)
# 관 정본은 지우지 않음(346-3) — 사용자 관 편집을 지키고 좌표로 새 노선을 따라감(체인 안).
pool = get_db_pool()
async with pool.acquire() as connection:
await connection.begin() # 고른 지형 = 설계 지형 — 뒤 단계가 모두 이 확정값을 읽음
@@ -408,7 +402,8 @@ async def _recompute(
stage2 = next(
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
)
params = dict((stage2 or {}).get("params") or {})
previous_params = dict((stage2 or {}).get("params") or {}) # 실패 때 되살림
params = dict(previous_params)
params["points"] = {
"bp": points[0],
"ep": points[-1],
@@ -418,23 +413,28 @@ async def _recompute(
await start_stage(cursor, str(project_id), 2, params)
await connection.commit()
marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter()))
# 구조물은 땅 위 자리(좌표)를 따라감(344-2) — 옛 노선 경로 파일은 체인 전이라 아직 옛 것
from B05_Profile.B05_Profile_Structures_Router import follow_structures_to_route
try:
old_path = (latest or {}).get("route_data_path")
await asyncio.to_thread(follow_structures_to_route, project_root, old_path, points)
except Exception: # 따라감 실패 = 옛 누가거리 그대로(지금까지와 같음)
logger.exception("구조물 따라감 실패(옛 누가거리 유지): project_id=%s", project_id)
marks.append(("재계산 준비(노선 읽기·stage 기록)", time.perf_counter()))
target = None
if balanced is not None:
from B05_Profile.B05_Profile_Router_RouteInitial import balanced_target
target = await balanced_target(project_id, project_root, points, balanced.criteria)
failure = await run_redesign_chain(project_id, int(surface_model_id), selection, target)
if not latest:
return {"error": "저장된 노선이 없어 노선을 저장할 수 없습니다(자동설계 먼저)."}
# 노선 저장 체인(346-3) — 배수유역 뗌 · 관 · 구조물 따라감 · 종횡단 한 번 · 계획고 옮김
saved = await run_route_save_chain(
project_id,
project_root,
stored_path,
int(surface_model_id),
selection,
latest,
points,
previous_params,
target,
)
failure = saved.get("error")
marks.append(("재확정 체인", time.perf_counter()))
_log_steps("노선 [확인] 재계산", marks)
if failure and switched: # 노선과 함께 설계 지형도 되돌림
@@ -448,14 +448,7 @@ async def _recompute(
# 부르는 쪽이 계획노선을 되돌리도록 사유를 올려 보낸다.
return {"error": failure}
pool = get_db_pool()
async with pool.acquire() as connection:
made = await get_latest_route(connection, project_id)
return {
"route_id": int(made["id"]) if made else None,
"total_length_m": float(made.get("total_length_m") or 0.0) if made else None,
"vertex_count": len(points),
}
return {**saved, "vertex_count": len(points)}
@router.get("/{project_id}/route/plan", response_model=None)
@@ -530,10 +523,16 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
@router.post("/{project_id}/route/replan", response_model=None)
async def replan_route(
project_id: UUID, request: RouteReplanRequest
) -> dict[str, Any] | JSONResponse:
"""고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다(모달 [확인])."""
async def replan_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any] | JSONResponse:
"""노선 초안을 정본으로 저장한다(346-3 · 요청 · 응답 모양 = `B05_Profile_Route_Save` 머리).
배수유역은 다시 안 셈(낡음 표시) · 초기값은 추천 [적용](`as_initial`)일 때만 다시 뜬다.
"""
as_initial = bool(getattr(body, "as_initial", False)) # 옛 요청 꼴(파이썬 호출)도 받음
try:
request = body.resolved() if isinstance(body, RouteSaveRequest) else body
except ValueError as error:
return JSONResponse(status_code=400, content={"status": "error", "message": str(error)})
if len(request.vertices) < 2:
return JSONResponse(
status_code=400,
@@ -616,15 +615,18 @@ async def replan_route(
if choice
else await asyncio.to_thread(read_initial_choice, project_root)
)
# 확정 노선 = 새 초기값(PLAN 74-4) — [저장] 없이 이 결과를 [초기화]가 돌아갈 자리로 뜬다.
from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot
# 초기값 = 자동설계 체인 직후 + 추천 노선 [적용] 때만(346-3 · 사용자 확정 10-16) — 손으로 고친
# 노선 저장은 [초기화]가 돌아갈 자리를 안 바꿈.
if as_initial:
from B05_Profile.B05_Profile_Router_RouteInitial import refresh_initial_snapshot
await refresh_initial_snapshot(project_id, project_root, result.get("route_id"))
await refresh_initial_snapshot(project_id, project_root, result.get("route_id"))
return {
"status": "success",
"project_id": str(project_id),
**result,
"current_initial": current,
"initial_refreshed": as_initial,
}
@@ -337,7 +337,7 @@ async def balanced_profile_of(
async def refresh_initial_snapshot(
project_id: UUID, project_root: Path, route_id: int | None
) -> None:
"""노선 [확인] 결과로 초기값(initial_snapshot)을 다시 뜬다(PLAN 74-4) — 실패는 비치명적."""
"""추천 노선 [적용](as_initial) 결과로 초기값을 다시 뜬다(346-3) — 실패는 비치명적."""
from common_util.common_util_initial_snapshot import (
discard_initial_snapshot,
save_initial_snapshot,
+6 -7
View File
@@ -6,8 +6,9 @@
* 1) 짓기 = 서버 Node(`B05_Profile_Corridor_Prebuild`) — 자동 체인 · 노선 [확인] 체인 ·
* B06 [저장] · [확정] 끝. 브라우저는 올리지 않는다(PUT 없음).
* 2) 받기: 저장본 GET → 버전 해시가 현 정본과 맞으면 그대로 표시.
* 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] 때만 브라우저가 미리보기로
* 지음 — 메모리에만 · 올리지 않음.
* 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] · 낡은 채 3D 모달을 열 때만 브라우저가
* 미리보기로 지음 — **웹 워커**(`_Corridor_Worker_Client` · 345-4 · 주 스레드 멈춤 0) · 메모리에만 ·
* 올리지 않음.
* 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이
* 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외).
* ========================================================================== */
@@ -21,7 +22,8 @@ import {
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh";
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client";
import {
corridorHash,
ENVELOPE_VERSION,
@@ -169,7 +171,7 @@ export async function ensureCorridor(
// 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다.
// 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는
// 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③).
const build = buildCorridor(
const build = await buildCorridorOffThread(
detail.cross_sections,
routePoints,
designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples,
@@ -184,9 +186,6 @@ export async function ensureCorridor(
return build;
}
/** 옛 올림 자리 — 3D 길 모형은 서버가 [저장]·[확정] 때 지음(342-2). 부르는 곳이 지워지면 함께 지움. */
export async function saveCorridorIfDirty(_projectId: string, _routeId: number): Promise<void> {}
/** ③ 사면이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */
function withSections(
build: CorridorBuildResult | null,
@@ -0,0 +1,58 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Worker.ts
* 3D 길 모형 **웹 워커**(345-4) — 저장 안 한 초안의 미리보기를 주 스레드 밖에서 짓는다.
*
* 짓는 셈은 `buildCorridor` 그대로(서버 Node 묶음 `B05_Profile_Corridor_Node` 와 같은 순수 계산) —
* 새로 짜지 않는다. 결과의 리본 좌표(Float64Array)는 복사 없이 넘긴다(Transferable).
* 부르는 쪽은 `_Corridor_Worker_Client`. 이 파일 맨 위에 DOM · window 를 두지 않는다.
* ========================================================================== */
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
export interface CorridorWorkerRequest {
id: number;
crossSections: CrossSection[];
routePoints: RoutePoint[];
designSamples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>;
}
export interface CorridorWorkerResponse {
id: number;
build: CorridorBuildResult | null;
error?: string;
}
/** 넘길 버퍼 — 리본 좌표(가장 큼). 나머지는 구조화 복제. */
export function corridorTransfers(build: CorridorBuildResult | null): ArrayBuffer[] {
return build ? build.ribbons.map((ribbon) => ribbon.positions.buffer as ArrayBuffer) : [];
}
/** 요청 하나 — 워커 안에서도 시험에서도 같은 함수. */
export function handleCorridorRequest(request: CorridorWorkerRequest): CorridorWorkerResponse {
try {
const build = buildCorridor(request.crossSections, request.routePoints, request.designSamples);
return { id: request.id, build };
} catch (error) {
return {
id: request.id,
build: null,
error: error instanceof Error ? error.message : "짓기 실패",
};
}
}
interface WorkerScope {
onmessage: ((event: { data: CorridorWorkerRequest }) => void) | null;
postMessage: (message: CorridorWorkerResponse, transfer: ArrayBuffer[]) => void;
}
// 워커로 돌 때만 듣는다(모듈을 시험 · 주 스레드가 들여와도 아무 일 없음).
if (typeof (globalThis as { WorkerGlobalScope?: unknown }).WorkerGlobalScope !== "undefined") {
const scope = globalThis as unknown as WorkerScope;
scope.onmessage = (event) => {
const response = handleCorridorRequest(event.data);
scope.postMessage(response, corridorTransfers(response.build));
};
}
@@ -0,0 +1,73 @@
/* =============================================================================
* B05_Profile_UI_Corridor_Worker_Client.ts
* 3D 길 모형 워커 부르기(345-4) — `buildCorridorOffThread` 가 워커(`_Corridor_Worker`)에 맡겨 주 스레드
* 멈춤 없이 짓는다. 워커를 못 쓰는 곳(시험 · 옛 브라우저) · 워커가 깨지면 주 스레드에서 같은 함수로 짓는다.
* 워커는 처음 부를 때 하나 세워 페이지 수명 동안 다시 쓴다.
* ========================================================================== */
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch";
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
import type {
CorridorWorkerRequest,
CorridorWorkerResponse,
} from "./B05_Profile_UI_Corridor_Worker";
type Samples = CorridorWorkerRequest["designSamples"];
let worker: Worker | null = null;
let seq = 0;
const waits = new Map<number, (response: CorridorWorkerResponse | null) => void>();
function settleAll(): void {
waits.forEach((resolve) => resolve(null));
waits.clear();
}
function getWorker(): Worker | null {
if (worker) return worker;
if (typeof Worker === "undefined") return null;
try {
worker = new Worker(new URL("./B05_Profile_UI_Corridor_Worker.ts", import.meta.url), {
type: "module",
});
} catch {
return null;
}
worker.onmessage = (event: MessageEvent<CorridorWorkerResponse>) => {
const resolve = waits.get(event.data.id);
waits.delete(event.data.id);
resolve?.(event.data);
};
// 워커가 깨지면 버리고 기다리던 것은 주 스레드로 — 다음 부름은 새 워커.
worker.onerror = () => {
worker?.terminate();
worker = null;
settleAll();
};
return worker;
}
/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). */
export async function buildCorridorOffThread(
crossSections: CrossSection[],
routePoints: RoutePoint[],
designSamples?: Samples,
): Promise<CorridorBuildResult | null> {
const onMain = (): CorridorBuildResult | null =>
buildCorridor(crossSections, routePoints, designSamples);
const target = getWorker();
if (!target) return onMain();
const id = ++seq;
const response = await new Promise<CorridorWorkerResponse | null>((resolve) => {
waits.set(id, resolve);
try {
target.postMessage({ id, crossSections, routePoints, designSamples });
} catch {
waits.delete(id); // 복제 못 하는 값 — 주 스레드로
resolve(null);
}
});
if (!response || response.error) return onMain();
return response.build;
}
+225
View File
@@ -0,0 +1,225 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Live.ts
* 배수유역 자동/수동 다시 셈(347-2) — 묶음 drainage 단계 · ⚙ 설정 창 · 흐림 + [업데이트] · 구석
* 「계산 중」 · 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`.
*
* - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 —
* 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음).
* - 노선 초안이 있으면 「낡음」 — 자동 = 3 s 조용한 뒤 셈 · 수동 = 칸만 흐림 + 가운데 [업데이트].
* - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s.
* - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림.
* - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { createProgressCircle } from "@ui/ui_template_progress";
import { openModal } from "@ui/ui_template_modal";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import type { StageFn } from "../A00_Common/design/design_pipeline_types";
import {
computeDetailBasins,
type DetailBasinResponse,
type DetailPipeInput,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
acceptLive,
draftMakesStale,
liveRequestBody,
parseDrainageMode,
QUIET_MS,
type DrainageMode,
} from "./logic/B05_Profile_Drainage_Live";
import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run";
import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest";
const MODE_KEY = "drainage-mode";
export interface DrainageLiveDeps {
/** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */
root: HTMLElement;
/** 제안 목록을 이 앞에 둠. */
basinList: HTMLElement;
pipeRequest: () => DetailPipeInput[];
runner: DetailRunner;
/** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담음. */
rememberPipes: () => void;
}
export interface DrainageLive {
/** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */
stage: StageFn<"drainage">;
/** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */
refresh(): void;
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */
markFresh(): void;
readonly stale: boolean;
suggest: DrainageSuggest;
}
/** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */
async function fetchLive(
projectId: string,
body: ReturnType<typeof liveRequestBody>,
signal: AbortSignal,
): Promise<DetailBasinResponse & { status?: string; generation?: number }> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
signal,
});
const payload = await response.json();
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
}
export function readDrainageMode(): DrainageMode {
return parseDrainageMode(readStateRaw(MODE_KEY));
}
export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
const viewport = deps.root.querySelector<HTMLElement>(".b05-drainage__viewport") as HTMLElement;
const header = deps.root.querySelector<HTMLElement>(".b05-drainage__header") as HTMLElement;
const suggest = createDrainageSuggest(deps.pipeRequest, (points) => {
const projectId = deps.runner.projectId();
if (!projectId) return;
void deps.runner
.run(() => computeDetailBasins(projectId, points))
.then((ok) => ok && deps.rememberPipes());
});
deps.basinList.before(suggest.root);
let mode = readDrainageMode();
let route: unknown = null;
let stale = false;
let generation = 0;
let timer = 0;
let inflight: AbortController | null = null;
const veil = document.createElement("div");
veil.className = "b05-drainage__veil";
veil.hidden = true;
const update = document.createElement("button");
update.type = "button";
update.className = "b05-drainage__analyze b05-drainage__update";
update.textContent = "업데이트";
update.title = "노선 · 관 자리가 바뀌어 배수유역이 낡았습니다 — 지금 노선으로 다시 셉니다.";
veil.append(update);
const busy = createProgressCircle({ size: 22, label: "계산 중" });
busy.root.classList.add("b05-drainage__busy");
busy.root.hidden = true;
viewport.append(veil, busy.root);
const gear = document.createElement("button");
gear.type = "button";
gear.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__gear";
gear.textContent = "⚙";
gear.title = "배수유역 계산 설정 — 수동(기본) / 자동";
header.append(gear);
const paint = (): void => {
deps.root.classList.toggle("is-stale", stale);
veil.hidden = !stale || mode === "auto" || inflight !== null;
busy.root.hidden = inflight === null;
};
function cancel(): void {
window.clearTimeout(timer);
timer = 0;
inflight?.abort();
inflight = null;
}
function refresh(): void {
const projectId = deps.runner.projectId();
if (!projectId) return;
cancel();
const sent = generation;
const controller = new AbortController();
inflight = controller;
paint();
const body = liveRequestBody(sent, route, deps.pipeRequest());
void deps.runner
.run(async () => {
const response = await fetchLive(projectId, body, controller.signal);
// 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌.
if (!acceptLive(response, sent, generation))
throw new DOMException("superseded", "AbortError");
return response;
}, true)
.then((applied) => {
if (inflight !== controller) return;
inflight = null;
if (applied) stale = false;
paint();
});
}
/** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */
function invalidate(): void {
generation += 1;
cancel();
stale = true;
if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
paint();
}
function openSettings(): void {
openModal({
title: "배수유역 계산",
closeLabel: "닫기",
mount(body, close) {
const choices: Array<[DrainageMode, string, string]> = [
["manual", "수동(기본)", "노선 · 관 자리가 바뀌면 칸만 흐려지고 [업데이트]를 누를 때 셈"],
["auto", "자동", "조작이 3초 멈추면 뒤에서 조용히 셈(화면을 막지 않음)"],
];
choices.forEach(([value, label, hint]) => {
const row = document.createElement("label");
row.className = "b05-drainage__mode";
const input = document.createElement("input");
input.type = "radio";
input.name = "b05-drainage-mode";
input.checked = mode === value;
input.addEventListener("change", () => {
setMode(value);
close();
});
const text = document.createElement("span");
text.textContent = `${label} — ${hint}`;
row.append(input, text);
body.append(row);
});
},
});
}
function setMode(next: DrainageMode): void {
mode = next;
writeStateRaw(MODE_KEY, next);
window.clearTimeout(timer);
timer = 0;
if (stale && next === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS);
paint();
}
update.addEventListener("click", refresh);
gear.addEventListener("click", openSettings);
return {
stage(ctx) {
route = ctx.inputs.route;
if (draftMakesStale(route) || stale) invalidate();
return null;
},
refresh,
suggest,
markFresh() {
cancel();
stale = false;
paint();
},
get stale() {
return stale;
},
};
}
+24 -32
View File
@@ -38,6 +38,8 @@ import { readPendingPipes, writePendingPipes } from "./B05_Profile_Api_Pipes_Dra
import { buildPipeReports } from "./B05_Profile_UI_Drainage_Report";
import { fillFordHeights, fordWaitsOf, waitsFordHeight } from "./B05_Profile_UI_Drainage_Ford_Fill";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { createDetailRunner } from "./B05_Profile_UI_Drainage_Run";
import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
@@ -47,7 +49,6 @@ import {
DRAINAGE_LAYERS,
fetchDrainageLayers,
createProgressReporter,
analyzeFailed,
fitCanvasToViewport,
fitViewToRoute,
observeViewportSize,
@@ -180,7 +181,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 화면 틀은 판이 놓인 창 것 — 새 창 최대화로 가려진 메인 창 틀은 멈춤(230-2). */
let frameView: Window = window;
let loadSequence = 0;
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
/** 바로 놓은 세월교 · 물넘이포장 — 재계산 유량이 오면 빈 월류 높이를 채울 측점(126-1 · B06 [추가]도 298-1). */
const fordWaits = () => fordWaitsOf(projectId);
let canvasWidth = 0;
@@ -276,6 +276,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
markedChainage,
stationIntervalM: MAP_STATION_INTERVAL_M,
intervalSpans,
candidates: live.suggest.candidates(),
});
updateImageTransform();
}
@@ -338,6 +339,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
* — 두 화면이 다른 결과를 보이면 안 되기 때문이다(2026-08-01 일원화). */
function apply(response: DetailBasinResponse): void {
basins = response.basins;
live.suggest.update(response);
mainBoundary = response.main_polygon_lonlat ?? [];
upstreamLines = (response.upstream_lonlat ?? []) as Array<Array<[number, number]>>;
flowArrows = (response.flow_arrows ?? []) as FlowArrow[];
@@ -374,32 +376,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
syncPipeSelection();
}
/** 세부유역 요청 한 번을 감싼다 — 버튼 잠금·진행 문구·오류 표기를 한 자리에 모은다. */
async function run(request: () => Promise<DetailBasinResponse>): Promise<void> {
if (!projectId) return;
analyzeButton.disabled = true;
status.hidden = false;
status.textContent = L("B05_Drainage_Status_Analyzing");
showProgress(null, L("B05_Drainage_Status_Analyzing"));
const seq = ++requestSeq;
try {
const response = await request();
if (seq !== requestSeq) return; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
apply(response);
status.hidden = basins.length > 0;
if (!basins.length) status.textContent = response.stale ?? L("B05_Drainage_Status_NoBasin");
scheduleDraw();
} catch (error) {
if (seq !== requestSeq) return;
status.hidden = false;
status.textContent = analyzeFailed(error, L("B05_Drainage_Status_AnalyzeFailed"));
} finally {
if (seq === requestSeq) {
analyzeButton.disabled = false;
showProgress(null, null);
}
}
}
/** 세부유역 요청 한 번 — 버튼 잠금 · 진행 문구 · 오류 · 최신 응답만(347-2 에 나눔). */
const runner = createDetailRunner({
projectId: () => projectId,
analyzeButton,
status,
showProgress,
apply,
basinCount: () => basins.length,
scheduleDraw,
label: L,
});
const { run } = runner;
/** 지금 관 목록 + 시설 정보 — 저장 · 재계산 · 세션 초안 요청 몸통이 모두 이것이다. */
const pipeRequest = () =>
@@ -418,7 +406,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. 저장 전 조작분(세션 초안)이
* 있으면 그 관 목록으로 세운다 — B06 을 다녀와도 지정 · 옵션이 옛값으로 안 돌아감(21-10 흠 3). */
const loadSaved = (): Promise<void> =>
const loadSaved = (): Promise<boolean> =>
run(() => {
const pending = readPendingPipes(projectId as string);
return pending !== null // 빈 목록 = 다 지움(관 0개 · 312) — 저장분으로 안 넘어감
@@ -428,12 +416,14 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
/** 저장분까지 버리고 자동 배치로 되돌린다("초기화"). 화면만 되돌리면 다시 들어왔을 때
* 옛 관이 살아난다(2026-08-02 사용자 보고). */
const resetPipes = (): Promise<void> => run(() => resetDetailPipePoints(projectId as string));
const resetPipes = (): Promise<boolean> => run(() => resetDetailPipePoints(projectId as string));
/** 세부유역을 다시 나눈다(격자 해석 없음) — 같은 틱의 여러 부름은 한 번(342-3 · 옛 소수 관 옮김).
* 시설 종류·구간·옵션은 계산기에서 사라지므로 요청에 되붙인다. */
const analyze = batched(() => run(() => computeDetailBasins(projectId as string, pipeRequest())));
// 자동/수동 다시 셈(347-2) · 제안 목록(347-3 · [적용] = 관 초안으로 세부 유역 다시 나눔).
const live = createDrainageLive({ root, basinList, pipeRequest, runner, rememberPipes });
analyzeButton.addEventListener("click", () => void analyze());
deleteButton.addEventListener("click", () => void pipeEditor.deleteSelected());
autoButton.addEventListener("click", () => void resetPipes());
@@ -668,10 +658,11 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
syncPipeSelection(notify);
scheduleDraw();
},
async savePipes() {
async savePipes(confirm = false) {
if (!projectId) return 0;
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
const response = await saveDetailPipePoints(projectId, pipeRequest());
const response = await saveDetailPipePoints(projectId, pipeRequest(), confirm);
if (confirm && !response.stale) live.markFresh();
apply(response);
scheduleDraw();
return response.pipe_count;
@@ -695,5 +686,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
widthResizer.dispose();
},
rehome: () => resizeObserver.rehome(),
pipelineStage: live.stage,
};
}
@@ -13,6 +13,7 @@ import type { FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility";
import type { PipeFacility, PipeSource } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { MapContextMenuItem } from "@ui/ui_template_context_menu";
import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans";
import type { StageFn } from "../A00_Common/design/design_pipeline_types";
export interface DrainagePanel {
root: HTMLElement;
@@ -40,7 +41,10 @@ export interface DrainagePanel {
) => void;
removePipe: (chainageM: number) => void;
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
savePipes: () => Promise<number>;
/** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */
savePipes: (confirm?: boolean) => Promise<number>;
/** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */
pipelineStage: StageFn<"drainage">;
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
selectBasinByChainage: (chainageM: number | null) => void;
/** 밖(리스트·그래프·3D)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
+2 -2
View File
@@ -61,8 +61,8 @@ export interface PipeEditor {
* 세부유역을 다시 나눠야 하는 시점이 정확히 `onCommit`이다(2026-08-01 사용자 지시).
* `onMove`는 끌기를 마친 관의 옛 · 새 측점 — `onCommit` 앞에 불러 시설 속성이 관을 따라감. */
/** 같은 틱에 여러 번 불러도 한 번만 돈다 — 옛 소수 관을 한꺼번에 옮겨도 세부유역 요청은 한 번(342-3). */
export function batched(work: () => Promise<void>): () => Promise<void> {
let queued: Promise<void> | null = null;
export function batched<T>(work: () => Promise<T>): () => Promise<T> {
let queued: Promise<T> | null = null;
return () =>
(queued ??= Promise.resolve().then(() => {
queued = null;
@@ -70,6 +70,8 @@ export interface DrainageScene {
stationIntervalM: number;
/** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */
intervalSpans: ReadonlyArray<RouteSpanBand>;
/** 관 자리 후보 누가거리(347-3 · 제안만) — 계획선 위 점선 원. */
candidates: ReadonlyArray<number>;
}
export function drawDrainageScene(
@@ -168,6 +170,20 @@ export function drawDrainageScene(
scene.maxHotspotArea,
);
}
// 관 자리 후보 — 점선 원(배관 마커 아래 · 제안만이라 채우지 않음).
scene.candidates.forEach((chainage) => {
const sample = scene.strengthSamples[Math.round(chainage)];
if (!sample) return;
const [x, y] = projector.toScreen(sample.x, sample.y);
context.save();
context.setLineDash([3, 3]);
context.lineWidth = 2;
context.strokeStyle = "#1e88e5";
context.beginPath();
context.arc(x, y, 9, 0, Math.PI * 2);
context.stroke();
context.restore();
});
// 배관(관 매설) 마커 — 계획선 위.
scene.pipeEditor.draw(context, view, scene.pipeColor);
// 측점 선택 마킹 — 계획선 위 다이아몬드 표식(2026-08-05 사용자 지시. 3자 선택 동기화의
@@ -0,0 +1,72 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Run.ts
* 세부유역 요청 한 번 감싸기 — 버튼 잠금 · 진행 문구 · 오류 표기 · 최신 응답만 채택(차례 번호).
*
* 패널 본체(B05_Profile_UI_Drainage_Panel)가 700줄이라 나눔(347-2). 「지금 노선으로 셈」 답도
* 같은 차례 번호를 타서, 늦게 온 세부유역 답이 새 답을 덮지 않음 — `quiet` 이면 버튼 · 가운데 진행
* 원 · 상태 글을 안 건드림(칸 구석 「계산 중」은 부르는 쪽 · 조작을 막지 않음).
* ========================================================================== */
import type { DetailBasinResponse } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { analyzeFailed } from "./B05_Profile_UI_Drainage_Parts";
type StatusKey =
| "B05_Drainage_Status_Analyzing"
| "B05_Drainage_Status_NoBasin"
| "B05_Drainage_Status_AnalyzeFailed";
export interface DetailRunnerDeps {
projectId: () => string | null;
analyzeButton: HTMLButtonElement;
status: HTMLElement;
showProgress: (ratio: number | null, label: string | null) => void;
apply: (response: DetailBasinResponse) => void;
basinCount: () => number;
scheduleDraw: () => void;
label: (key: StatusKey) => string;
}
export interface DetailRunner {
projectId: () => string | null;
/** 요청 하나 — 답을 화면에 올렸으면 true(뒤에 보낸 요청이 있으면 false). */
run(request: () => Promise<DetailBasinResponse>, quiet?: boolean): Promise<boolean>;
}
export function createDetailRunner(deps: DetailRunnerDeps): DetailRunner {
const { analyzeButton, status, showProgress, label } = deps;
let requestSeq = 0; // 세부유역 요청 — 최신 응답만 채택
return {
projectId: deps.projectId,
async run(request, quiet = false) {
if (!deps.projectId()) return false;
if (!quiet) {
analyzeButton.disabled = true;
status.hidden = false;
status.textContent = label("B05_Drainage_Status_Analyzing");
showProgress(null, label("B05_Drainage_Status_Analyzing"));
}
const seq = ++requestSeq;
try {
const response = await request();
if (seq !== requestSeq) return false; // 뒤에 보낸 요청이 있음 — 그 응답을 기다림
deps.apply(response);
status.hidden = deps.basinCount() > 0;
if (!deps.basinCount())
status.textContent = response.stale ?? label("B05_Drainage_Status_NoBasin");
deps.scheduleDraw();
return true;
} catch (error) {
if (seq !== requestSeq) return false;
if (error instanceof DOMException && error.name === "AbortError") return false;
status.hidden = false;
status.textContent = analyzeFailed(error, label("B05_Drainage_Status_AnalyzeFailed"));
return false;
} finally {
if (seq === requestSeq) {
analyzeButton.disabled = false;
showProgress(null, null);
}
}
},
};
}
@@ -0,0 +1,87 @@
/* =============================================================================
* B05_Profile_UI_Drainage_Suggest.ts
* 배수유역 칸 제안 목록(347-3) — 관경 · 시설 종류 · 관 자리 후보를 보여 주고 [적용] 한 번에 관 초안으로.
* 모으기 · 얹기 규칙은 `logic/B05_Profile_Drainage_Suggest.ts` · 자동 바꿈 없음.
* 관 자리 후보는 배수유역도에도 점선 원으로 그림(`candidates()` → 그리기 판).
* ========================================================================== */
import type {
DetailBasinResponse,
DetailPipeInput,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
applySuggestions,
collectSuggestions,
type Suggestion,
} from "./logic/B05_Profile_Drainage_Suggest";
const FACILITY_NAME: Record<string, string> = {
pipe: "배관",
box_culvert: "BOX암거",
ford_bridge: "세월교",
};
const facilityName = (id: string): string => FACILITY_NAME[id] ?? id;
/** 제안 한 줄 글. */
export function suggestionText(item: Suggestion): string {
const at = `측점 ${Math.round(item.chainageM)} m`;
if (item.kind === "position") return `${at} — 관 자리 후보(흐름이 모이는 오목 자리)`;
if (item.kind === "facility")
return `${at} — ${facilityName(item.from)} → ${facilityName(item.to)} 검토`;
return `${at} — 관경 ${item.from === null ? "미정" : `Ø${item.from}`} → Ø${item.to}`;
}
export interface DrainageSuggest {
root: HTMLElement;
update(response: DetailBasinResponse): void;
/** 관 자리 후보 누가거리 — 배수유역도 점선 원. */
candidates(): number[];
}
export function createDrainageSuggest(
pipeRequest: () => DetailPipeInput[],
applyPoints: (points: DetailPipeInput[]) => void,
): DrainageSuggest {
const root = document.createElement("div");
root.className = "b05-drainage__suggest";
root.hidden = true;
const head = document.createElement("div");
head.className = "b05-drainage__suggest-head";
const title = document.createElement("span");
const apply = document.createElement("button");
apply.type = "button";
apply.className = "b05-drainage__analyze";
apply.textContent = "적용";
apply.title =
"제안을 관 초안에 한 번에 얹습니다(관 더함 · 관경 · 시설 바꿈) — [저장] 때 남습니다.";
head.append(title, apply);
const list = document.createElement("ul");
list.className = "b05-drainage__suggest-list";
root.append(head, list);
let suggestions: Suggestion[] = [];
apply.addEventListener("click", () => {
if (suggestions.length) applyPoints(applySuggestions(pipeRequest(), suggestions));
});
return {
root,
update(response) {
suggestions = response.stale
? []
: collectSuggestions(response.pipe_points, response.basins, response.pipe_candidates ?? []);
root.hidden = suggestions.length === 0;
title.textContent = `제안 ${suggestions.length}건`;
list.replaceChildren(
...suggestions.map((item) => {
const row = document.createElement("li");
row.textContent = suggestionText(item);
return row;
}),
);
},
candidates: () =>
suggestions.filter((item) => item.kind === "position").map((item) => item.chainageM),
};
}
@@ -13,7 +13,6 @@ import {
invalidateSectionDetail,
saveCachedCrossPatches,
} from "../B06_Section/B06_Section_Section_Store";
import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
import type { createRoutePanel } from "./B05_Profile_UI_Panel";
import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
@@ -87,9 +86,6 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave {
// 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다.
invalidateSectionDetail(projectId);
ctx.renderLatest(await ctx.loadLatest(true));
// 저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 저장은 성공 처리.
const routeId = ctx.latest()?.route?.id;
if (routeId) void saveCorridorIfDirty(projectId, routeId);
},
};
}
@@ -11,8 +11,7 @@
* 그리기·라벨·집기가 출처를 안 가리게 한다.
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache";
import {
createNormalizer,
type GeoJsonCollection,
@@ -71,8 +70,7 @@ export async function loadRouteEditContours(
// 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다.
const data = await fetchCachedJson<ContourResponse>(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
`/contour?interval=${interval}&smooth=${options.smooth}`,
contourAssetUrl(projectId, options.surfaceModelId, interval, options.smooth),
);
const all = data.contours ?? [];
const onUnit = (level: number): boolean => Math.abs(level % LAS_CONTOUR_UNIT_M) < 1e-6;
@@ -266,3 +266,70 @@
.b05-drainage[hidden] {
display: none;
}
/* 배수유역 낡음(347-2) — 칸만 흐림 · 가운데 [업데이트] · 구석 「계산 중」(조작은 막지 않음). */
.b05-drainage.is-stale .b05-drainage__canvas,
.b05-drainage.is-stale .b05-drainage__image,
.b05-drainage.is-stale .b05-drainage__summary,
.b05-drainage.is-stale .b05-drainage__basins,
.b05-drainage.is-stale .b05-drainage__suggest {
filter: blur(2px);
opacity: 0.6;
}
.b05-drainage__veil {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.b05-drainage__veil[hidden] {
display: none;
}
.b05-drainage__veil .b05-drainage__update {
pointer-events: auto;
}
.b05-drainage__busy {
position: absolute;
right: var(--spacing-8);
bottom: var(--spacing-8);
}
.b05-drainage__busy[hidden] {
display: none;
}
.b05-drainage__mode {
display: flex;
gap: var(--spacing-8);
align-items: center;
padding: var(--spacing-8) 0;
color: var(--color-text-body);
}
/* 제안 목록(347-3) — 유역 목록 위 · [적용] 한 번. */
.b05-drainage__suggest {
flex: 0 0 auto;
border-top: 1px solid var(--color-border);
padding: var(--spacing-8);
font-size: var(--text-caption);
color: var(--color-text-body);
}
.b05-drainage__suggest-head {
display: flex;
align-items: center;
justify-content: space-between;
}
.b05-drainage__suggest-list {
max-height: 6em;
margin: var(--spacing-8) 0 0;
padding-left: 1.2em;
overflow-y: auto;
}
+2 -2
View File
@@ -4,7 +4,7 @@ import { createTerrainCompass } from "@ui/ui_template_compass";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { contourAssetUrl, fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache";
import { bindCursorPivotControls, TOP_VIEW_TILT } from "../B04_PreProcess/B04_PreProcess_UI_Camera";
import {
createRouteMarkers,
@@ -325,7 +325,7 @@ export function createRouteViewer(): RouteViewer {
contours: Array<{ level: number; coordinates: [number, number, number][] }>;
}>(
current.projectId,
`${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`,
contourAssetUrl(current.projectId, current.modelId, interval, current.smooth),
);
clearContours();
// 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다.
@@ -0,0 +1,43 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Drainage_Live.ts
* 배수유역 자동/수동 다시 셈(347-2) — 순수 규칙만. 화면 · fetch 는 `B05_Profile_UI_Drainage_Live.ts`.
*
* - 대상 = 평면(노선 초안) · 관 자리만 — 종단 · 횡단 조작은 배수유역 결과를 안 바꿈(묶음 deps 와 같음).
* - 노선 초안이 없으면(저장 노선 그대로) 낡음 아님 — 관 조작은 세부 유역 나눔(0.2 s)이 바로 맞춤.
* - 자동 = 조용한 뒤 `QUIET_MS` · 수동 = 흐림 + [업데이트] · 기본 수동.
* - 답 채택 = 보낸 차례가 지금 차례와 같고 서버가 그만두지 않았을 때만(늦은 답 버림).
* ========================================================================== */
export type DrainageMode = "manual" | "auto";
/** 자동 모드 — 마지막 조작 뒤 이만큼 조용하면 셈(ms · 341 결정 거리 6 · 3 초). */
export const QUIET_MS = 3000;
/** 계정 화면 설정 글자 → 모드(없거나 모르는 값 = 수동). */
export const parseDrainageMode = (raw: string | null | undefined): DrainageMode =>
raw === "auto" ? "auto" : "manual";
/** 묶음 drainage 단계 입력 → 낡았는가 — 노선 초안이 있을 때만. */
export const draftMakesStale = (route: unknown): boolean => route !== null && route !== undefined;
/** 「지금 노선으로 셈」 몸통 — 서버 `post_drainage_live`. */
export function liveRequestBody<P>(
generation: number,
route: unknown,
points: P[],
): { generation: number; route: unknown; points: P[] } {
return { generation, route: route ?? null, points };
}
/** 답을 화면에 올리는가 — 서버가 그만둔 답 · 옛 차례 답은 버림. */
export function acceptLive(
response: { status?: string; generation?: number },
sentGeneration: number,
currentGeneration: number,
): boolean {
return (
response.status !== "superseded" &&
sentGeneration === currentGeneration &&
(response.generation === undefined || response.generation === sentGeneration)
);
}
@@ -0,0 +1,83 @@
/* =============================================================================
* B05_Profile/logic/B05_Profile_Drainage_Suggest.ts
* 배수유역 제안(347-3) — 관경 · 시설 종류 · 관 자리 후보를 모으고 [적용] 때 관 초안에 한 번에 얹음.
* 순수 함수만(화면 = `B05_Profile_UI_Drainage_Suggest.ts`).
*
* - 관경 = 유역 추천이 배관이고 추천 관경이 지금 관경과 다를 때(「Ø→Ø 배관 제안」).
* - 시설 = 유역 추천이 BOX암거 · 세월교이고 지금 관이 그 종류가 아닐 때.
* - 관 자리 = 서버 `suggest_pipe_positions`(간격 보충 규칙 · 기존 관에서 먼 곳).
* - 자동 바꿈 없음 — 모으기만 하고 바꾸는 것은 `applySuggestions`([적용])뿐.
* ========================================================================== */
/** 관 자리가 같은 유역으로 볼 거리(m) — 화면 `PIPE_MATCH_M` 과 같은 값. */
const SAME_PIPE_M = 0.5;
interface PipeLike {
chainage_m: number;
source?: string;
facility?: string;
options?: Record<string, string | number>;
}
interface BasinLike {
chainage_m: number;
recommended_facility?: string;
recommended_diameter_mm?: number | null;
}
export type Suggestion =
| { kind: "diameter"; chainageM: number; from: number | null; to: number }
| { kind: "facility"; chainageM: number; from: string; to: string }
| { kind: "position"; chainageM: number };
const near = (a: number, b: number): boolean => Math.abs(a - b) < SAME_PIPE_M;
/** 응답(유역 · 관 자리 후보) + 지금 관 목록 → 제안 목록(관 자리 차례). */
export function collectSuggestions(
pipes: ReadonlyArray<PipeLike>,
basins: ReadonlyArray<BasinLike>,
candidates: ReadonlyArray<{ chainage_m: number }>,
): Suggestion[] {
const found: Suggestion[] = [];
basins.forEach((basin) => {
const pipe = pipes.find((item) => near(item.chainage_m, basin.chainage_m));
if (!pipe) return;
const facility = pipe.facility ?? "pipe";
const want = basin.recommended_facility ?? "pipe";
if (want !== "pipe" && facility !== want) {
found.push({ kind: "facility", chainageM: pipe.chainage_m, from: facility, to: want });
return;
}
const to = basin.recommended_diameter_mm;
const raw = pipe.options?.pipe_diameter_mm;
const from = raw === undefined || raw === "" ? null : Number(raw);
if (want === "pipe" && facility === "pipe" && to != null && from !== to)
found.push({ kind: "diameter", chainageM: pipe.chainage_m, from, to });
});
candidates
.filter((item) => !pipes.some((pipe) => near(pipe.chainage_m, item.chainage_m)))
.forEach((item) => found.push({ kind: "position", chainageM: item.chainage_m }));
return found.sort((a, b) => a.chainageM - b.chainageM);
}
/** [적용] — 제안을 관 초안에 한 번에(관 더함 · 관경 · 시설 바꿈). 입력 목록은 안 고침. */
export function applySuggestions<P extends PipeLike>(
pipes: ReadonlyArray<P>,
suggestions: ReadonlyArray<Suggestion>,
): P[] {
const next = pipes.map(
(pipe) => ({ ...pipe, options: pipe.options && { ...pipe.options } }) as P,
);
suggestions.forEach((item) => {
if (item.kind === "position") {
if (!next.some((pipe) => near(pipe.chainage_m, item.chainageM)))
next.push({ chainage_m: item.chainageM, source: "spacing" } as P);
return;
}
const pipe = next.find((candidate) => near(candidate.chainage_m, item.chainageM));
if (!pipe) return;
if (item.kind === "facility") pipe.facility = item.to;
else pipe.options = { ...(pipe.options ?? {}), pipe_diameter_mm: item.to };
});
return next.sort((a, b) => a.chainage_m - b.chainage_m);
}
@@ -0,0 +1,53 @@
/* =============================================================================
* B05_Profile_MassHaul_Stage.ts
* 계산 묶음 `masshaul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — cross(측점 열쇠마다 면적) ·
* profile → 유토곡선 계열(`computeMassHaulSeries` · 화면 유토곡선 · B06 과 같은 계산).
*
* 출력 = `MassHaulStageOutput`(계열 + 흙 배분에 보낼 횡단 기준 결과) · 순수 계산(DOM · fetch · 세션 없음).
* 토량환산계수 · 자연 사토 기울기는 부르는 쪽이 넘김(`context`) — 없으면 null.
* ========================================================================== */
import { computeMassHaulSeries, type MassHaulSeries } from "@util/common_util_mass_haul";
import type { EarthworkConversion, MassHaulSection } from "@util/common_util_mass_haul_types";
import type { StageContext } from "../../A00_Common/design/design_pipeline_types";
export interface MassHaulStageContext {
conversion: EarthworkConversion;
naturalSpoilMinSlope?: number;
}
export interface MassHaulStageOutput {
series: MassHaulSeries[];
/** 흙 배분(haul 단계)에 보낼 결과 — 횡단 기준(없으면 첫 계열) · 계열이 없으면 null. */
haulInput: MassHaulSeries["result"] | null;
}
/** `masshaul` 단계 만들기 — `context()` 는 부를 때마다 읽음(설정이 바뀌면 다음 계산부터). */
export function createMassHaulStage(context: () => MassHaulStageContext | null) {
return (ctx: StageContext<"masshaul">): MassHaulStageOutput | null => {
const cross = ctx.out.cross;
const settings = context();
if (!cross || !settings) return null;
const sections: MassHaulSection[] = Object.values(cross.byKey)
.sort((a, b) => a.chainageM - b.chainageM)
.map((entry) => ({
station_id: entry.key,
chainage_m: entry.chainageM,
design: entry.detail as MassHaulSection["design"],
}));
const samples = ctx.out.profile?.samples ?? [];
const longitudinal = {
length_m:
samples[samples.length - 1]?.chainage_m ?? sections[sections.length - 1]?.chainage_m ?? 0,
design_profiles: samples.length ? [{ samples }] : [],
};
const series = computeMassHaulSeries(
longitudinal,
sections,
settings.conversion,
settings.naturalSpoilMinSlope,
);
const picked = series.find((entry) => entry.basis === "cross") ?? series[0];
return { series, haulInput: picked?.result ?? null };
};
}
+16 -22
View File
@@ -8,9 +8,11 @@
* 계산은 브라우저가 하고(`common_util_cross_design.ts`), 선택값은 여기 담긴다.
*
* 담는 것은 **사용자가 고른 것만**이다 — 계산 결과(면적·설계선)는 담지 않는다.
* 세션 열쇠 = 측점 열쇠(344-3 · `B06_Section_Station_Keys`) · 부르는 쪽에는 누가거리로 내줌.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { chainageKey } from "@util/common_util_station_key";
import { readKeyed, writeKeyed } from "./B06_Section_Station_Keys";
/** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */
export interface CrossDesignChoice {
@@ -25,14 +27,9 @@ export interface CrossDesignChoice {
ditch_choice?: boolean | null;
}
type ChoiceMap = Record<string, CrossDesignChoice>;
/** 측점 키 — 암 경계선 저장소와 같은 규칙(0.01m 단위). */
const keyOf = (chainageM: number): string => chainageM.toFixed(2);
function readAll(projectId: string, routeId: number): ChoiceMap {
return readState<ChoiceMap>("crossdesign", projectId, routeId) ?? {};
}
/** 누가거리(0.01 m 글) → 선택값 — 지금 측점에 있는 것만. */
const readAll = (projectId: string, routeId: number) =>
readKeyed<CrossDesignChoice>("crossdesign", projectId, routeId);
/** 이 측점의 선택값(없으면 null). */
export function readCrossDesignChoice(
@@ -40,7 +37,7 @@ export function readCrossDesignChoice(
routeId: number,
chainageM: number,
): CrossDesignChoice | null {
return readAll(projectId, routeId)[keyOf(chainageM)] ?? null;
return readAll(projectId, routeId).get(chainageKey(chainageM)) ?? null;
}
/** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */
@@ -50,9 +47,8 @@ export function writeCrossDesignChoice(
chainageM: number,
choice: CrossDesignChoice,
): void {
const all = readAll(projectId, routeId);
all[keyOf(chainageM)] = choice;
writeState("crossdesign", all, projectId, routeId);
const at = chainageKey(chainageM);
writeKeyed("crossdesign", projectId, routeId, new Map([[at, choice]]), [at]);
}
/** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */
@@ -62,7 +58,7 @@ export function crossDesignChoices(
): Map<number, CrossDesignChoice> {
const choices = new Map<number, CrossDesignChoice>();
if (!projectId || routeId === null) return choices;
for (const [key, value] of Object.entries(readAll(projectId, routeId))) {
for (const [key, value] of readAll(projectId, routeId)) {
const chainage = Number(key);
if (Number.isFinite(chainage) && value) choices.set(chainage, value);
}
@@ -80,12 +76,11 @@ type SideState = "shoulderext" | "bermdepth";
function readSides(name: SideState, projectId: string | null, routeId: number | null) {
const out: ShoulderExtends = new Map();
if (!projectId || routeId === null) return out;
const raw = readState<Record<string, Partial<Record<ShoulderSide, number>>>>(
for (const [key, value] of readKeyed<Partial<Record<ShoulderSide, number>>>(
name,
projectId,
routeId,
);
for (const [key, value] of Object.entries(raw ?? {})) {
)) {
const chainage = Number(key);
if (Number.isFinite(chainage) && value) out.set(chainage, value);
}
@@ -100,11 +95,10 @@ function writeSide(
side: ShoulderSide,
valueM: number,
): void {
const all =
readState<Record<string, Partial<Record<ShoulderSide, number>>>>(name, projectId, routeId) ??
{};
all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) };
writeState(name, all, projectId, routeId);
const at = chainageKey(chainageM);
const own = readKeyed<Partial<Record<ShoulderSide, number>>>(name, projectId, routeId).get(at);
const next = { ...(own ?? {}), [side]: Math.max(valueM, 0) };
writeKeyed(name, projectId, routeId, new Map([[at, next]]), [at]);
}
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
+10 -6
View File
@@ -28,7 +28,12 @@ import { computeCrossDesign } from "@util/common_util_cross_design";
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import {
bermSpecAt,
readBermSpans,
readCutSlopeSession,
readRockBoundarySession,
} from "./B06_Section_UI_Page_Persist";
import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist";
import type { BermSpec } from "@util/common_util_cross_berm";
import {
@@ -48,7 +53,7 @@ import {
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
import { readState } from "../A00_Common/b_page_state";
import { useStationKeys } from "./B06_Section_Station_Keys";
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts";
@@ -211,6 +216,7 @@ export async function refreshStaleDesigns(
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3)
const sections = input.detail.cross_sections;
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
const updated = refreshLocally(input) ?? (await refreshFromServer(input));
@@ -301,8 +307,7 @@ function bermDepthReader(projectId: string, routeId: number) {
*/
function readCutSlopeRatios(projectId: string, routeId: number): Map<number, number> {
const out = new Map<number, number>();
const raw = readState<Record<string, number>>("cutslope", projectId, routeId);
if (!raw) return out;
const raw = readCutSlopeSession(projectId, routeId);
for (const [chainage, ratio] of Object.entries(raw)) {
const at = Number(chainage);
if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio);
@@ -516,8 +521,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
berms: bermPayload(projectId, routeId, detail),
// 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만
// 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07).
cutSlopeRatios:
readState<Record<string, number>>("cutslope", projectId, routeId) ?? undefined,
cutSlopeRatios: readCutSlopeSession(projectId, routeId),
},
);
if (shouldApply && !shouldApply()) return [];
+136
View File
@@ -0,0 +1,136 @@
/* =============================================================================
* B06_Section_Cross_Stage.ts
* 계산 묶음 `cross` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — profile · ground · input.cross →
* 측점 열쇠마다 횡단 설계(`CrossOutput.byKey`).
*
* · 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝 · Cross_Refresh 와 같은 엔진).
* · 국부 연산 = 측점 열쇠마다 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 확폭 · 표준값) — 같으면 앞 결과 그대로.
* · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있으면 셈 없이 재사용.
* · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음.
* input.cross 모양 = `CrossStageInput`(부르는 쪽이 세션 선택 · 저장분 · 구조물 맥락을 측점 열쇠로 모아 넘김).
* ========================================================================== */
import { computeCrossDesign } from "@util/common_util_cross_design";
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
import type {
CrossOutput,
CrossStationResult,
PipelineStation,
StageContext,
} from "../A00_Common/design/design_pipeline_types";
import { planElevationAt } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
/** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 좌절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */
export type CrossStationInput = Partial<
Omit<CrossDesignOptions, "standard" | "planRadiusM" | "curveOuterSide" | "curveWideningM">
>;
/** `input.cross` — 표준단면 + 측점 열쇠 → 그 측점 입력. */
export interface CrossStageInput {
standard: StandardCrossSectionSpec;
byKey: Record<string, CrossStationInput>;
}
interface Sample {
offset_m: number;
elevation_m: number | null;
valid: boolean;
}
/** 지반 표본(중심 → 바깥 · 좌 +) → 엔진 표본(오프셋 오름차순 · 우 −). */
export function groundSamples(station: PipelineStation): Sample[] {
const ground = station.ground;
if (!ground) return [];
const point = (offset: number, z: number | null): Sample => ({
offset_m: offset,
elevation_m: z,
valid: z !== null && Number.isFinite(z),
});
const right = ground.right.map((s) => point(-s.offsetM, s.z));
const left = ground.left.map((s) => point(s.offsetM, s.z));
const out = [...right, ...left];
if (ground.centerZ !== null && !out.some((s) => Math.abs(s.offset_m) < 1e-9))
out.push(point(0, ground.centerZ));
return out.sort((a, b) => a.offset_m - b.offset_m);
}
/** 측점 하나의 엔진 입력. */
function optionsFor(
station: PipelineStation,
input: CrossStationInput | undefined,
standard: StandardCrossSectionSpec,
): CrossDesignOptions {
return {
groundType: "ripping_rock",
sectionMode: "left_cut",
ditchType: "standard",
...input,
standard,
planRadiusM: station.planRadiusM,
curveOuterSide: station.curveOuterSide,
curveWideningM: station.curveWideningM,
};
}
const inRange = (station: PipelineStation, ctx: StageContext<"cross">): boolean => {
const range = ctx.range;
if (!range) return true;
if (range.keys.includes(station.key)) return true;
return station.chainageM >= range.fromM - 1e-6 && station.chainageM <= range.toM + 1e-6;
};
/**
* `cross` 단계 만들기 — 측점 지문 기억은 이 단계 함수가 사는 동안. `counts()` = 마지막 계산의 다시 셈 · 재사용 수.
*/
export function createCrossStage() {
const memo = new Map<string, { fingerprint: string; result: CrossStationResult }>();
let last = { computed: 0, reused: 0 };
function stage(ctx: StageContext<"cross">): CrossOutput {
const profile = ctx.out.profile;
const ground = ctx.out.ground;
const input = ctx.inputs.cross as CrossStageInput | null;
if (!profile || !ground || !input?.standard) return ctx.prev ?? { byKey: {} };
const standardKey = JSON.stringify(input.standard);
const byKey: Record<string, CrossStationResult> = {};
last = { computed: 0, reused: 0 };
for (const station of ground.stations) {
const kept = ctx.prev?.byKey[station.key];
if (kept && !inRange(station, ctx)) {
byKey[station.key] = { ...kept, chainageM: station.chainageM };
last.reused += 1;
continue;
}
const samples = groundSamples(station);
const elevation = planElevationAt(profile, station.chainageM);
const options = optionsFor(station, input.byKey[station.key], input.standard);
const fingerprint = `${standardKey}|${elevation}|${JSON.stringify(samples)}|${JSON.stringify({ ...options, standard: null })}`;
const hit = memo.get(station.key);
if (hit?.fingerprint === fingerprint) {
byKey[station.key] = { ...hit.result, chainageM: station.chainageM };
last.reused += 1;
continue;
}
let design;
try {
design = computeCrossDesign(samples, elevation, options);
} catch {
continue; // 표본 부족 측점은 건너뜀(Cross_Refresh · 서버 엔진과 같은 태도)
}
const result: CrossStationResult = {
key: station.key,
chainageM: station.chainageM,
cutAreaM2: design.cut_area_m2,
fillAreaM2: design.fill_area_m2,
detail: design,
};
memo.set(station.key, { fingerprint, result });
byKey[station.key] = result;
last.computed += 1;
}
for (const key of [...memo.keys()]) if (!byKey[key]) memo.delete(key); // 사라진 측점
return { byKey };
}
return { stage, counts: () => last };
}
+20
View File
@@ -186,6 +186,18 @@ async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
)
def _prune_cross_files(project_root: Path, longitudinal_file_path: str) -> None:
"""측점을 고친 뒤 옛 cross_*.json 을 지움 — GET 은 안 지우므로 쓰는 쪽(저장 · 확정)이 맡음."""
from B05_Profile.B05_Profile_Engine_Sections import prune_stale_cross_files
path = project_root / longitudinal_file_path
try:
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
except (OSError, ValueError, AttributeError):
return # 측점을 못 읽으면 안 지움(오삭제 막음)
prune_stale_cross_files(path.parent.parent / "cross_sections", stations)
async def _prebuild_corridor(project_id: UUID, route_id: int, project_root: Path) -> None:
"""3D 길 모형을 서버 Node 가 지어 남긴다(342-2) — 정본이 다시 선 [저장] · [확정] 끝.
@@ -261,6 +273,10 @@ async def save_sections(
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
project_root = Path(resolve_stored_project_path(stored_path))
# 측점 맞춤이 지운 자리의 옛 횡단 파일 정리(쓰는 쪽 몫)
await asyncio.to_thread(
_prune_cross_files, project_root, str(existing["longitudinal_file_path"])
)
rowless = missing + await asyncio.to_thread(
_rowless_station_chainages,
project_root,
@@ -341,6 +357,10 @@ async def confirm_sections(
known = await get_cross_section_chainages(connection, route_id)
marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter()))
project_root = Path(resolve_stored_project_path(stored_path))
# 측점 맞춤이 지운 자리의 옛 횡단 파일 정리(쓰는 쪽 몫)
await asyncio.to_thread(
_prune_cross_files, project_root, str(existing["longitudinal_file_path"])
)
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
# 조회 때마다 프리뷰가 다시 계산돼 3D·수량이 확정 결과가 아니게 된다(2026-08-24).
missing = missing + await asyncio.to_thread(
@@ -30,6 +30,7 @@ import {
structureStationMarks,
type PlannedStation,
} from "../common_util/common_util_structure_station";
import { useStationKeys, useStationOwners } from "./B06_Section_Station_Keys";
import {
previewStructureSections,
type CrossSection,
@@ -188,6 +189,7 @@ export async function withDraftStations(
if (inputs) {
const [pipes, structures, types] = inputs;
const planned = planStations(pipes, structures, types, registryDefaults(types));
useStationOwners(projectId, planned); // 구조물 측점 열쇠 = 덮는 구조물 id(344-3)
const planted = planned.map((station) => station.chainage_m);
// 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고
// 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴
@@ -246,4 +248,5 @@ export async function withDraftStations(
...[...live, ...fresh].sort(byChainage),
);
parked.set(detail, stale);
useStationKeys(projectId, routeId, detail); // 세션 횡단 초안 = 이 측점 목록의 열쇠(344-3)
}
+273
View File
@@ -0,0 +1,273 @@
/* =============================================================================
* B06_Section_Station_Keys.ts
* 세션 횡단 초안의 **측점 열쇠**(PLAN 344-3) — 노선이 바뀌어 누가거리가 밀려도 같은 측점의 고침이 남게.
*
* 열쇠 규칙 = `common_util_station_key`(일반 grid:k · 관 pipe:<id> · 구조물 structure:<id> · 시종점 end:BP/EP ·
* id 모름 at:<0.01 m>). 담는 칸 다섯(crossdesign · shoulderext · bermdepth · rockb · cutslope)은 측점 열쇠로
* 쓰고, 부르는 쪽에는 지금처럼 **누가거리**로 내줌(읽기 · 쓰기 창구가 그대로).
*
* · 지금 측점 목록 = 상세가 초안 측점을 얹을 때마다(`useStationKeys` · Section_Drafts) — 관 id 는 관 세션
* 장부 · 구조물 id 는 측점 계획(초안) · 구조물 목록(횡단 보기)에서.
* · 칸마다 열쇠 → 쓴 때 누가거리 `@at` 을 함께 담음 — 구조물 id 를 아직 모를 때(첫 열기)도 그 자리 측점이면 읽음.
* · 옛 초안(누가거리 열쇠 `"85.05"`) · `at:` 열쇠는 읽을 때 지금 측점 열쇠로 옮겨 다시 씀.
* · 노선이 바뀜 = `carryStationDrafts` — 넘기는 칸 = 서버 `CARRY_INPUT_KEYS` 일곱(지반 유형 · 측구 종류 ·
* 측구 선택 · 2단 경사 · 암 경계 · 암 경사 · 소단 구간은 구간 그대로) · 단면 유형 · 측구 방향은 새 지형이
* 정함 · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」 한 줄.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { pipeIdAt } from "../A00_Common/design/design_pipe_ids";
import { showToast } from "@ui/ui_template_elements";
import { chainageKey, looseChainage, stationKey } from "@util/common_util_station_key";
import { inferStationInterval } from "@util/common_util_svg";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
/** 측점 열쇠로 담는 세션 칸. */
export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope";
export const KEYED_STATES: readonly KeyedState[] = [
"crossdesign",
"shoulderext",
"bermdepth",
"rockb",
"cutslope",
];
/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */
const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const;
/** 노선이 바뀔 때 통째로 넘기는 칸 · 버리는 칸(노견 확장 · 소단 깊이 = 그 측점 기하 손질). */
const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"];
/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */
const NEAR_M = 0.01;
const HINT = "@at";
type Keyed = { key: string; chainageM: number };
type Raw = Record<string, unknown> & { [HINT]?: Record<string, number> };
const lists = new Map<string, Keyed[]>();
/** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */
const owners = new Map<string, Array<{ id: string; kind: "pipe" | "structure"; at: number }>>();
let current: { projectId: string; routeId: number } | null = null;
const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`;
/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */
export function useStationOwners(
projectId: string,
planned: ReadonlyArray<{ chainage_m: number; owners: string[] }>,
): void {
owners.set(
projectId,
planned.flatMap((station) =>
station.owners.slice(0, 1).map((id) => ({
id,
kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const),
at: station.chainage_m,
})),
),
);
}
/** 구조물 목록(횡단 보기) — 한 자리 구조물의 id. 관 자리는 관 장부가 정함. */
export function useStructureIds(
projectId: string,
structures: ReadonlyArray<{ structure_id?: string | null; chainage_m?: number | null }>,
): void {
const known = (owners.get(projectId) ?? []).filter((owner) => owner.kind === "pipe");
for (const item of structures) {
if (item.structure_id && typeof item.chainage_m === "number")
known.push({ id: item.structure_id, kind: "structure", at: item.chainage_m });
}
owners.set(projectId, known);
}
const looksLikePipe = (section: CrossSection): boolean =>
Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? "");
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:`). */
export function stationKeysOf(
projectId: string | null,
sections: ReadonlyArray<CrossSection>,
intervalM: number | null,
): Keyed[] {
const known = projectId ? (owners.get(projectId) ?? []) : [];
const seen = new Set<string>();
return sections.map((section) => {
const at = section.chainage_m;
let key: string;
if (section.kind === "irregular") {
const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M);
if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind);
else if (looksLikePipe(section))
key = stationKey("pipe", at, intervalM, pipeIdAt(at, projectId));
else
key = section.grid
? stationKey("regular", at, intervalM)
: stationKey("irregular", at, intervalM);
} else key = stationKey(section.kind, at, intervalM);
if (seen.has(key)) key = `at:${chainageKey(at)}`;
seen.add(key);
return { key, chainageM: at };
});
}
/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */
export function useStationKeys(
projectId: string,
routeId: number,
detail: Pick<SectionDetailResponse, "cross_sections" | "longitudinal">,
): void {
const interval =
(
detail.longitudinal?.profile_alignment as
{ policy?: { station_interval_m?: number } } | undefined
)?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections);
lists.set(scopeOf(projectId, routeId), stationKeysOf(projectId, detail.cross_sections, interval));
current = { projectId, routeId };
}
/** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */
export const currentStationScope = () => current;
function nearest(list: Keyed[], chainageM: number): Keyed | undefined {
let best: Keyed | undefined;
for (const entry of list) {
const gap = Math.abs(entry.chainageM - chainageM);
if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry;
}
return best;
}
/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */
export function keyAt(projectId: string, routeId: number, chainageM: number): string {
const list = lists.get(scopeOf(projectId, routeId));
return (list && nearest(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`;
}
/**
* 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금
* 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖).
*/
export function chainageOfKey(
projectId: string,
routeId: number,
key: string,
hint?: number,
): number | null {
const list = lists.get(scopeOf(projectId, routeId));
const hit = list?.find((entry) => entry.key === key);
if (hit) return Number(chainageKey(hit.chainageM));
const at = looseChainage(key) ?? hint;
if (at === undefined) return null;
if (!list) return Number(chainageKey(at));
const near = nearest(list, at);
return near ? Number(chainageKey(near.chainageM)) : null;
}
function readRaw(name: KeyedState, projectId: string, routeId: number): Raw {
const raw = readState<Raw>(name, projectId, routeId);
return raw && typeof raw === "object" ? raw : {};
}
function entries<V>(raw: Raw): Array<[string, V, number | undefined]> {
const hints = raw[HINT] ?? {};
return Object.entries(raw)
.filter(([key]) => key !== HINT)
.map(([key, value]) => [key, value as V, hints[key]]);
}
/**
* 칸 하나를 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌(지우지 않음).
* 옛 열쇠(누가거리 · at:)는 지금 측점 열쇠로 옮겨 다시 씀(목록이 있을 때).
*/
export function readKeyed<V>(name: KeyedState, projectId: string, routeId: number): Map<string, V> {
const raw = readRaw(name, projectId, routeId);
const out = new Map<string, V>();
let moved = false;
for (const [key, value, hint] of entries<V>(raw)) {
const at = chainageOfKey(projectId, routeId, key, hint);
if (at === null) continue;
out.set(chainageKey(at), value);
if (looseChainage(key) !== null && !keyAt(projectId, routeId, at).startsWith("at:"))
moved = true;
}
if (moved) writeKeyed(name, projectId, routeId, out);
return out;
}
/**
* 누가거리(0.01 m 글) → 값 묶음을 측점 열쇠로 씀. 지금 측점이 아닌 옛 고침(노선 밖 · 아직 못 읽는 것)은 그대로 둠.
* `only` 를 주면 그 누가거리들만 바꾸고 나머지 지금 측점 고침도 그대로.
*/
export function writeKeyed<V>(
name: KeyedState,
projectId: string,
routeId: number,
byChainage: ReadonlyMap<string, V>,
only?: ReadonlyArray<string>,
): void {
const raw = readRaw(name, projectId, routeId);
const next: Raw = { [HINT]: {} };
const replaced = new Set((only ?? [...byChainage.keys()]).map((at) => chainageKey(Number(at))));
for (const [key, value, hint] of entries<unknown>(raw)) {
const at = chainageOfKey(projectId, routeId, key, hint);
if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue;
next[key] = value;
if (hint !== undefined) next[HINT]![key] = hint;
}
for (const [at, value] of byChainage) {
if (only !== undefined && !replaced.has(chainageKey(Number(at)))) continue;
const key = keyAt(projectId, routeId, Number(at));
next[key] = value;
next[HINT]![key] = Number(chainageKey(Number(at)));
}
writeState(name, next, projectId, routeId);
}
/**
* 노선이 바뀜(새 측점 목록을 `useStationKeys` 로 세운 뒤) — 옛 노선 고침을 측점 열쇠로 새 노선에 넘김.
* 넘김 = 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사 · 단면 유형 · 측구
* 방향은 새 지형이 정함 · 노견 확장 · 소단 깊이는 안 넘김. 돌려주는 값 = 노선 밖으로 나간 고침 수(측점 단위).
*/
export function carryStationDrafts(
projectId: string,
fromRouteId: number,
toRouteId: number,
): number {
const outside = new Set<string>();
for (const name of KEYED_STATES) {
const raw = readRaw(name, projectId, fromRouteId);
const next: Raw = { [HINT]: {} };
for (const [key, value] of entries<unknown>(raw)) {
// 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로
const fromAt =
looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key);
const stable = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt);
const toAt = chainageOfKey(projectId, toRouteId, stable, looseChainage(stable) ?? undefined);
const carried = carriedValue(name, value);
if (toAt === null || stable.startsWith("at:")) {
if (carried !== null) outside.add(stable);
continue;
}
if (carried === null) continue;
const toKey = keyAt(projectId, toRouteId, toAt);
next[toKey] = carried;
next[HINT]![toKey] = toAt;
}
writeState(name, next, projectId, toRouteId);
}
if (outside.size)
showToast(`노선 밖 고침 ${outside.size} 건 — 사라진 측점이라 버렸습니다.`, "info");
return outside.size;
}
/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */
function carriedValue(name: KeyedState, value: unknown): unknown {
if (CARRY_WHOLE.includes(name)) return value;
if (name !== "crossdesign" || !value || typeof value !== "object") return null;
const source = value as Record<string, unknown>;
const picked = Object.fromEntries(
CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]),
);
return Object.keys(picked).length ? picked : null;
}
@@ -27,6 +27,7 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures";
import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail";
import { useStructureIds } from "./B06_Section_Station_Keys";
export interface DesignSyncContext {
projectId: string | null;
@@ -194,6 +195,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
function syncBermSpans(structures: ReadonlyArray<StructureInstance>): void {
const currentRouteId = ctx.routeId();
if (!projectId || currentRouteId === null) return;
useStructureIds(projectId, structures); // 구조물 측점 열쇠 = 구조물 id(344-3)
const next = bermSpansFromStructures(structures);
if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return;
writeBermSpans(projectId, currentRouteId, next);
+34 -12
View File
@@ -49,6 +49,7 @@ import type { RockBoundaryControl } from "./B06_Section_UI_Section_View";
import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope";
import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view";
import { catalogRow } from "../common_util/common_util_structure_catalog";
import { readKeyed, writeKeyed, type KeyedState } from "./B06_Section_Station_Keys";
/**
* 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체.
@@ -65,8 +66,35 @@ export function readRockBoundarySession(
projectId: string,
routeId: number,
): Record<string, number> {
const stored = readState<Record<string, number>>("rockb", projectId, routeId);
return stored && typeof stored === "object" ? stored : {};
// 세션 열쇠 = 측점 열쇠(344-3) — 부르는 쪽(다시 셈 · 서버 폴백)에는 누가거리(0.01 m 글)로
return Object.fromEntries(readKeyed<number>("rockb", projectId, routeId));
}
/** 측점별 암 절토 경사 세션값 — 누가거리(0.01 m 글) → 경사비(0 = 표준으로 되돌림 · 344-3). */
export function readCutSlopeSession(projectId: string, routeId: number): Record<string, number> {
return Object.fromEntries(readKeyed<number>("cutslope", projectId, routeId));
}
/** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */
function keyedScope(storageKey: string): { projectId: string; routeId: number } | null {
const [projectId, route] = storageKey.split(":").slice(-2);
const routeId = Number(route);
return projectId && Number.isInteger(routeId) ? { projectId, routeId } : null;
}
/** 저장소 값 읽기 — 측점 열쇠 칸이면 누가거리로 옮겨 · 아니면 옛 꼴 그대로. */
function loadKeyed(name: KeyedState, storageKey: string): Array<[string, number]> {
const scope = keyedScope(storageKey);
if (scope) return [...readKeyed<number>(name, scope.projectId, scope.routeId)];
const raw = readByKey(storageKey);
return raw ? Object.entries(JSON.parse(raw) as Record<string, number>) : [];
}
/** 저장소 값 쓰기 — 측점 열쇠로(노선 밖 옛 고침은 그대로 둠). */
function persistKeyed(name: KeyedState, storageKey: string, values: Map<string, number>): void {
const scope = keyedScope(storageKey);
if (scope) writeKeyed(name, scope.projectId, scope.routeId, values);
else writeByKey(storageKey, JSON.stringify(Object.fromEntries(values)));
}
/** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */
@@ -215,7 +243,7 @@ export function createRockBoundaryStore(options: {
const storageKey = sessionKey();
if (!storageKey) return;
try {
writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets)));
persistKeyed("rockb", storageKey, offsets);
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
@@ -228,10 +256,7 @@ export function createRockBoundaryStore(options: {
const storageKey = sessionKey();
if (!storageKey) return;
try {
const raw = readByKey(storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([chainage, offset]) => {
loadKeyed("rockb", storageKey).forEach(([chainage, offset]) => {
if (Number.isFinite(offset)) offsets.set(chainage, offset);
});
} catch {
@@ -318,7 +343,7 @@ export function createCutSlopeStore(options: {
const storageKey = sessionKey();
if (!storageKey) return;
try {
writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios)));
persistKeyed("cutslope", storageKey, ratios);
} catch {
/* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */
}
@@ -331,10 +356,7 @@ export function createCutSlopeStore(options: {
const storageKey = sessionKey();
if (!storageKey) return;
try {
const raw = readByKey(storageKey);
if (!raw) return;
const parsed = JSON.parse(raw) as Record<string, number>;
Object.entries(parsed).forEach(([chainage, ratio]) => {
loadKeyed("cutslope", storageKey).forEach(([chainage, ratio]) => {
// 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다.
if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio);
});
+52
View File
@@ -0,0 +1,52 @@
"""측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름.
일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:<id>`` · 구조물 = ``structure:<id>`` ·
시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``.
⚠ 파이썬·TS 짝 — ``common_util_station_key.ts`` ``stationKey`` ↔ ``station_key``.
한쪽을 고치면 다른 쪽도 고치고 거울 시험 ``resources/tester/test_344_3_station_key_mirror.py``.
"""
from __future__ import annotations
import math
import re
_LOOSE = re.compile(r"^-?\d+(\.\d+)?$")
def _half_up(value: float) -> int:
"""JS ``Math.round`` 와 같은 반올림(0.5 는 위로)."""
return math.floor(value + 0.5)
def chainage_key(chainage_m: float) -> str:
"""누가거리 0.01 m — 옛 초안 열쇠(``toFixed(2)``)와 같은 자릿수."""
return f"{_half_up(chainage_m * 100) / 100:.2f}"
def station_key(
kind: str,
chainage_m: float,
interval_m: float | None,
station_id: str | None = None,
id_kind: str = "structure",
) -> str:
"""측점 열쇠 — irregular 는 id 가 있으면 pipe: · structure:(``id_kind``) · 없으면 at:."""
if kind == "bp":
return "end:BP"
if kind == "ep":
return "end:EP"
if kind in ("pipe", "structure"):
return f"{kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}"
if kind == "irregular":
return f"{id_kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}"
if interval_m and interval_m > 0:
return f"grid:{_half_up(chainage_m / interval_m)}"
return f"at:{chainage_key(chainage_m)}"
def loose_chainage(key: str) -> float | None:
"""옛 초안 열쇠(누가거리 글) · ``at:`` 열쇠의 누가거리 — 측점 열쇠면 None."""
text = key[3:] if key.startswith("at:") else key
return float(text) if _LOOSE.match(text) else None
+43
View File
@@ -0,0 +1,43 @@
/* =============================================================================
* common_util_station_key.ts
* 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름.
* 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:<id>` · 구조물 = `structure:<id>` ·
* 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫).
*
* ⚠ 파이썬·TS 짝 — `common_util_station_key.py` `station_key` ↔ `stationKey`. 한쪽을 고치면 다른 쪽도
* 고치고 거울 시험 `resources/tester/test_344_3_station_key_mirror.py`.
* ========================================================================== */
/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular) 그대로 받음. */
export type StationKeyKind = "bp" | "ep" | "regular" | "irregular" | "pipe" | "structure";
/** 누가거리 0.01 m — 옛 초안 열쇠(`toFixed(2)`)와 같은 자릿수. */
export const chainageKey = (chainageM: number): string =>
(Math.round(chainageM * 100) / 100).toFixed(2);
/**
* 측점 열쇠. irregular 는 id 가 있으면 `pipe:` · `structure:`(종류는 `idKind`) · 없으면 `at:`.
* 간격이 없거나 0 이하면 일반 측점도 `at:`.
*/
export function stationKey(
kind: StationKeyKind,
chainageM: number,
intervalM: number | null,
id: string | null = null,
idKind: "pipe" | "structure" = "structure",
): string {
if (kind === "bp") return "end:BP";
if (kind === "ep") return "end:EP";
if (kind === "pipe" || kind === "structure")
return id ? `${kind}:${id}` : `at:${chainageKey(chainageM)}`;
if (kind === "irregular") return id ? `${idKind}:${id}` : `at:${chainageKey(chainageM)}`;
if (intervalM && intervalM > 0) return `grid:${Math.round(chainageM / intervalM)}`;
return `at:${chainageKey(chainageM)}`;
}
/** 옛 초안 열쇠(누가거리 글 `"85.05"`) · `at:` 열쇠의 누가거리 — 측점 열쇠면 null. */
export function looseChainage(key: string): number | null {
const text = key.startsWith("at:") ? key.slice(3) : key;
if (!/^-?\d+(\.\d+)?$/.test(text)) return null;
return Number(text);
}
@@ -309,3 +309,4 @@
232. (원문 · 10-16) 1. 결국 초기화 데이터는 전처리 과정에서 나온 결과물일테고 만약 추천 노선으로 변경된다면 그게 초기값이 될것같음. 거기에는 예정노선을 따라가는 기능도 있으니.
2. 배관과 구조물을 제안하면 좋지만 이건 배수유역 계산해야 되어 몰래 업데이트 하거나 수동업데이트 하려고 했음. 사용자 조작 편의성을 위해 화면 프리즈 안되고 0.3초 안에 종단 횡단 평면(노선) 이 사로 연동되어 바뀐다면 배관제안이나 구조물 제안도 좋음. 이때 조심해야할 내용은 양방향 연동이리는 점임. 필요하면 변경지점을 지정하는 로직을 넣어 국부적으로 연산하는 기능을 구현해도 좋음.
3. 상세 신규계획서를 작성하고 너가 브레인으로써 잘 수행해줄것. 나는 한동안 신경 못쓰니 너가 제어해서 최종 브라우져 검증까지해서 아티팩트 보고 할것.
233. (원문 · 10-16) 캡쳐한 그림을 보고 제안 b05의 메인은 지금처럼 계획노선 편집 화면을 보여주는 건 맞음. 단 3d 처럼 상세 전체영역을 사용하는게 맞으며 하단 종단 패널은 오버레이 방식이 맞음. 이때 하단에 종단 그래프가 있으니 기존에 대략적으로 보여주던 원반선과 계획 종단선 그래프는 필요없음(종단그래프 영역 아님 조심할것.)
@@ -65,6 +65,8 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", {
}
},
},
// 측점 열쇠 세우기(344-3) — 이 시험 몫 아님
"./B06_Section_Station_Keys": { useStationKeys: () => {}, useStationOwners: () => {} },
"./B06_Section_Api_Fetch": {
previewStructureSections: async (_projectId, chainages) => {
asked.push(chainages);
+26
View File
@@ -0,0 +1,26 @@
/* 344-3 · 346-2 2파 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름.
* 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross · masshaul · haul 단계를 화면 · 서버 없이 잰다.
*/
export {
carryStationDrafts,
chainageOfKey,
keyAt,
readKeyed,
useStationKeys,
useStructureIds,
} from "../../B06_Section/B06_Section_Station_Keys";
export {
crossDesignChoices,
readShoulderExtends,
writeCrossDesignChoice,
writeShoulderExtend,
} from "../../B06_Section/B06_Section_Cross_Design_Session";
export {
createRockBoundaryStore,
readRockBoundarySession,
} from "../../B06_Section/B06_Section_UI_Page_Persist";
export { createCrossStage, groundSamples } from "../../B06_Section/B06_Section_Cross_Stage";
export { createMassHaulStage } from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage";
export { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
export { buildAlignment, emptyEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
export { readState, stateKey, writeState } from "../../A00_Common/b_page_state";
@@ -0,0 +1,240 @@
/* 344-3 · 346-2 2파 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_344_3_entry.ts)을 부르고 결과를 JSON 한 벌로.
* node helper_344_3_station_keys.mjs <묶음.mjs> <입력.json>
* 입력 = { standard: 파이썬 config 표준단면, policy: 종단 정책 }.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
const [bundlePath, inputPath] = process.argv.slice(2);
const m = await import(pathToFileURL(bundlePath).href);
const input = JSON.parse(readFileSync(inputPath, "utf8"));
const P = "p344";
const R = 7;
const R2 = 8;
const raw = (name, route) => m.readState(name, P, route) ?? {};
const keysOf = (name, route) =>
Object.keys(raw(name, route))
.filter((k) => k !== "@at")
.sort();
const section = (chainage_m, kind, extra = {}) => ({
station_id: `s${chainage_m}`,
chainage_m,
kind,
label: String(chainage_m),
samples: [],
...extra,
});
const detailOf = (sections) => ({
longitudinal: {
stations: sections,
profile_alignment: { policy: { station_interval_m: 20 } },
},
cross_sections: sections,
});
const choice = (ground) => ({
ground_type: ground,
section_mode: "right_cut",
ditch_side: "right",
ditch_type: "l_type",
paved: true,
two_stage_slope: true,
ditch_choice: false,
});
const out = {};
/* 1. 옛 초안(누가거리 열쇠) → 측점 열쇠로 옮김 · 지금 측점에 없는 것은 남겨 둠 */
const oldRoute = detailOf([
section(0, "bp"),
section(20, "regular"),
section(40, "regular"),
section(45.054, "irregular", { structure: "파형강관 D800", culvert: {} }),
section(52.5, "irregular", { structure: "대피로" }),
section(60, "regular"),
section(70, "ep"),
]);
m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 52.5 }]);
m.useStationKeys(P, R, oldRoute);
m.writeState(
"crossdesign",
{
"20.00": choice("soil"),
45.05: choice("blasting_rock"),
"52.50": choice("soil"),
"60.00": choice("soil"),
"99.00": choice("soil"),
},
P,
R,
);
out.legacyRead = [...m.crossDesignChoices(P, R).keys()].sort((a, b) => a - b);
out.legacyKeys = keysOf("crossdesign", R);
/* 2. 쓰기 = 측점 열쇠 · 누가거리로 다시 읽음 · 암 경계 저장소도 같은 열쇠 */
m.writeCrossDesignChoice(P, R, 40, choice("ripping_rock"));
m.writeShoulderExtend(P, R, 40, "left", 0.5);
out.writeKey = keysOf("crossdesign", R).includes("grid:2");
out.shoulderKeys = keysOf("shoulderext", R);
const rock = m.createRockBoundaryStore({
sessionKey: () => m.stateKey("rockb", P, R),
detail: () => oldRoute,
refreshCard: () => undefined,
recompute: () => undefined,
});
rock.control.adjust(20, -0.3);
out.rockKeys = keysOf("rockb", R);
rock.load();
out.rockLoaded = Object.fromEntries(rock.offsets);
out.rockSession = m.readRockBoundarySession(P, R);
/* 3. 구조물 id 를 아직 모를 때(첫 열기) — 쓴 때 자리(@at)로 그 측점 고침을 읽음 */
m.useStructureIds(P, []);
m.useStationKeys(P, R, oldRoute);
out.hintKeyNow = m.keyAt(P, R, 52.5);
out.hintRead = m.crossDesignChoices(P, R).has(52.5);
/* 4. 노선이 바뀜 — 측점 열쇠로 넘김 · 넘김 = 카드 선택 넷 · 암 경계 · 노견 확장 안 넘김 · 사라진 측점 = 노선 밖 */
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P); // 관이 47 m 로 옮김
m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 55 }]);
m.useStationKeys(
P,
R2,
detailOf([
section(0, "bp"),
section(20, "regular"),
section(40, "regular"),
section(47, "irregular", { structure: "파형강관 D800", culvert: {} }),
section(55, "irregular", { structure: "대피로" }),
section(58, "ep"),
]),
);
// 옛 노선 목록(st-1 은 52.5 · 관 장부는 옛 자리 기준으로 이미 열쇠가 섬)
out.outside = m.carryStationDrafts(P, R, R2);
out.carriedKeys = keysOf("crossdesign", R2);
out.carriedChoice = raw("crossdesign", R2)["grid:1"];
out.carriedRead = [...m.crossDesignChoices(P, R2).keys()].sort((a, b) => a - b);
out.carriedRock = m.readRockBoundarySession(P, R2);
out.carriedShoulder = keysOf("shoulderext", R2);
/* 5. cross 단계 — 가짜 입력 · 같은 입력 0 · 측점 하나 고침 = 1 · 범위 밖 재사용 */
const grid = [0, 20, 40, 60, 80, 100];
const groundAt = (c) => 100 + 0.05 * c - 3 * Math.sin(c / 17);
const base = {
policy: input.policy,
onlyExplicitCurves: false,
basePvi: grid.map((c, i) => ({ chainage_m: c, elevation_m: groundAt(c) + (i % 2 ? 0.8 : -0.6) })),
chainage: Array.from({ length: 101 }, (_, i) => i),
ground: Array.from({ length: 101 }, (_, i) => groundAt(i)),
stations: grid.map((c) => ({ station_id: `s${c}`, chainage_m: c })),
};
const profile = m.buildAlignment(base, m.emptyEdits());
const side = (c, sign) =>
Array.from({ length: 24 }, (_, i) => ({
offsetM: (i + 1) * 0.5,
z: groundAt(c) + sign * 0.3 * (i + 1) * 0.5,
}));
const ground = {
stations: grid.map((c, i) => ({
key: i === 0 ? "end:BP" : i === grid.length - 1 ? "end:EP" : `grid:${i}`,
chainageM: c,
x: c,
y: 0,
azimuthDeg: 90,
left: [0, 1],
kind: i === 0 || i === grid.length - 1 ? "end" : "grid",
id: null,
ground: { centerZ: groundAt(c), left: side(c, 1), right: side(c, -1) },
planRadiusM: null,
curveOuterSide: null,
curveWideningM: null,
})),
longitudinal: { chainageM: [], z: [] },
};
const samples = m.groundSamples(ground.stations[1]);
out.samplesSorted = samples.every((s, i) => i === 0 || s.offset_m > samples[i - 1].offset_m);
out.samplesCount = samples.length;
const { stage, counts } = m.createCrossStage();
const ctxOf = (cross, range, prev, prof = profile) => ({
generation: 1,
range,
signal: new AbortController().signal,
inputs: { route: null, alignment: null, cross, pipes: null, structures: null },
out: { profile: prof, ground },
before: {},
prev,
});
const crossInput = { standard: input.standard, byKey: { "grid:2": { groundType: "soil" } } };
const first = stage(ctxOf(crossInput, null, null));
out.crossFirst = { ...counts(), keys: Object.keys(first.byKey).length };
out.crossHasArea = Object.values(first.byKey).every((r) => Number.isFinite(r.cutAreaM2));
stage(ctxOf(crossInput, null, first));
out.crossSame = counts();
const touched = { ...crossInput, byKey: { ...crossInput.byKey, "grid:3": { groundType: "soil" } } };
const third = stage(
ctxOf(touched, { sources: ["cross"], fromM: 60, toM: 60, keys: ["grid:3"] }, first),
);
out.crossOne = counts();
out.crossOneChanged = third.byKey["grid:3"].detail.ground_type;
const edited = m.buildAlignment(base, { station_offsets: { "40.000": 0.4 }, curve_radii: {} });
stage(ctxOf(touched, { sources: ["grade"], fromM: 20, toM: 60, keys: [] }, third, edited));
out.crossGrade = counts();
/* 6. masshaul 단계 — 계열 · 흙 배분 입력 */
const conversion = {
soil: { loose: 1.25, compacted: 0.9 },
ripping_rock: { loose: 1.5, compacted: 1.2 },
blasting_rock: { loose: 1.65, compacted: 1.35 },
};
const massStage = m.createMassHaulStage(() => ({ conversion }));
const mass = massStage({ ...ctxOf(crossInput, null, null), out: { profile, cross: first } });
out.massSeries = mass.series.map((s) => s.basis);
out.massHaulInput = Array.isArray(mass.haulInput?.points);
/* 7. haul 단계 — 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로 */
const posts = [];
globalThis.fetch = async (_url, init) => {
if (init.signal?.aborted) throw new Error("aborted");
posts.push(1);
return { ok: true, json: async () => ({ haul_plan: { n: posts.length } }) };
};
const haul = m.createHaulStage(P, () => R);
const haulCtx = (masshaul, prev, signal = new AbortController().signal) => ({
...ctxOf(crossInput, null, prev),
signal,
out: { masshaul },
});
const plan1 = await haul(haulCtx(mass, null));
const plan2 = await haul(haulCtx(mass, plan1));
const other = massStage({
...ctxOf(crossInput, null, null),
out: { profile: edited, cross: third },
});
const aborted = new AbortController();
aborted.abort();
const plan3 = await haul(haulCtx(other, plan1, aborted.signal));
out.haul = { posts: posts.length, plan1, plan2, plan3 };
process.stdout.write(JSON.stringify(out));
@@ -111,6 +111,7 @@ const frames = (n) => {
let terrainLoads = 0;
let resolveTerrain;
let opens = 0; // 345-4 — 열 때(지형 뒤) 낡은 3D 길 모형 다시 짓기 부름
const view3d = createViewer3dModal({
viewer,
loadTerrain: () => {
@@ -118,6 +119,7 @@ const view3d = createViewer3dModal({
return new Promise((resolve) => (resolveTerrain = resolve));
},
visible: () => true,
onOpen: () => (opens += 1),
});
(async () => {
@@ -131,12 +133,15 @@ const view3d = createViewer3dModal({
const first = modals[0];
out.wide = first.opts.wide === true;
out.inModal = viewer.root.parent === first.body;
await new Promise((r) => setImmediate(r));
out.opensBeforeTerrain = opens;
out.spinnerWhileLoading = viewer.root.children.length === 1;
frames(3);
out.openDraws = viewer.draws;
resolveTerrain();
await new Promise((r) => setImmediate(r));
out.spinnerGone = viewer.root.children.length === 0;
out.opensAfterTerrain = opens;
// 닫기 단추 · Esc · 바깥 누름 = beforeClose 뒤 닫힘(공용 모달 규칙)
out.beforeCloseOk = (await first.opts.beforeClose()) === true;
@@ -145,6 +150,8 @@ const view3d = createViewer3dModal({
out.afterCloseDraws = viewer.draws;
out.backInPark = viewer.root.parent === view3d.park;
out.disposedOnClose = viewer.disposed;
await new Promise((r) => setImmediate(r));
out.opensWhileClosed = opens;
view3d.open();
view3d.open(); // 열린 동안 또 눌러도 하나
@@ -152,10 +159,27 @@ const view3d = createViewer3dModal({
out.terrainLoads = terrainLoads;
frames(2);
out.reopenDraws = viewer.draws;
await new Promise((r) => setImmediate(r));
out.opensAfterReopen = opens;
view3d.dispose(); // 페이지 떠남 — 열려 있으면 닫고 버림
out.closedOnDispose = modals[1].open === false;
out.disposed = viewer.disposed;
out.runningAfterDispose = viewer.running;
// 지형 받는 사이 닫으면 — 다시 짓기 안 부름(닫힌 동안 짓기 0)
let lateOpens = 0;
let lateResolve;
const late = createViewer3dModal({
viewer: { root: new FakeNode("div"), setActive() {}, dispose() {} },
loadTerrain: () => new Promise((resolve) => (lateResolve = resolve)),
visible: () => true,
onOpen: () => (lateOpens += 1),
});
late.open();
late.close();
lateResolve();
await new Promise((r) => setImmediate(r));
out.closedBeforeTerrainOpens = lateOpens;
console.log(JSON.stringify(out));
})();
@@ -0,0 +1,132 @@
/* 345-4 3D 길 모형 웹 워커 헬퍼 — 워커 몸(`handleCorridorRequest`) · 부르기(`buildCorridorOffThread`)를
* 트랜스파일해 같은 입력으로 주 스레드 `buildCorridor` 와 견줌. 워커 경계는 structuredClone(+transfer)로 흉내 —
* 브라우저 postMessage 와 같은 복제 규칙. 측점 꼴은 helper_115_8_corridor_loft.cjs 와 같다. 결과는 JSON 한 벌.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const ts = require(path.join(ROOT, "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.replace(/import\.meta\.url/g, '"file:///x.js"');
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (request.startsWith("@ui/") || request.startsWith("@config/"))
return new Proxy({}, { get: () => () => undefined });
const target = request.startsWith("@util/")
? path.join(ROOT, "common_util", request.slice(6))
: path.join(path.dirname(full), request);
if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8"));
return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts"));
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`);
const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build"));
const { handleCorridorRequest, corridorTransfers } = loadTs(B05("B05_Profile_UI_Corridor_Worker"));
const { buildCorridorOffThread } = loadTs(B05("B05_Profile_UI_Corridor_Worker_Client"));
const HALF = 3;
const ROAD_Z = 100;
function station(chainage, cx, cy, lx, ly, groundZ, ratio) {
const w = Math.abs(groundZ - ROAD_Z) * ratio;
const mode = groundZ < ROAD_Z ? "both_fill" : "both_cut";
const pts = [
[-40, groundZ],
[-(HALF + w), groundZ],
[-HALF, ROAD_Z],
[-2.5, ROAD_Z],
[2.5, ROAD_Z],
[HALF, ROAD_Z],
[HALF + w, groundZ],
[40, groundZ],
];
const samples = [];
for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: groundZ });
return {
station_id: `S${chainage}`,
chainage_m: chainage,
label: "",
kind: "regular",
center_x: cx,
center_y: cy,
center_z: groundZ,
frame: { left_xy: [lx, ly] },
samples,
design: {
section_mode: mode,
road_edges: {
left: { offset_m: HALF, elevation_m: ROAD_Z },
right: { offset_m: -HALF, elevation_m: ROAD_Z },
},
carriageway_edges: {
left: { offset_m: 2.5, elevation_m: ROAD_Z },
right: { offset_m: -2.5, elevation_m: ROAD_Z },
},
design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })),
ditch: { type: "none" },
ditch_enabled: false,
ditch_side: "left",
},
};
}
const sections = [
station(0, 0, 0, 0, 1, 96, 1.5),
station(20, 20, 0, 0, 1, 104, 1.0),
station(40, 40, 0, 0, 1, 96, 1.5),
];
const route = [
{ x: 0, y: 0 },
{ x: 40, y: 0 },
];
const designSamples = [
{ chainage_m: 0, elevation_m: 100, ground_elevation_m: 96 },
{ chainage_m: 20, elevation_m: 100, ground_elevation_m: 104 },
{ chainage_m: 40, elevation_m: 100, ground_elevation_m: 96 },
];
/** 비교용 평탄화 — 타입 배열은 숫자 배열로. */
const plain = (value) =>
JSON.parse(JSON.stringify(value, (_k, v) => (ArrayBuffer.isView(v) ? Array.from(v) : v)));
(async () => {
const out = {};
const direct = buildCorridor(sections, route, designSamples);
// 워커 경계 흉내 — 요청 · 응답 둘 다 구조화 복제 · 응답은 리본 버퍼를 넘김(transfer)
const request = structuredClone({
id: 7,
crossSections: sections,
routePoints: route,
designSamples,
});
const response = handleCorridorRequest(request);
const transfer = corridorTransfers(response.build);
const received = structuredClone(response, { transfer });
out.ribbonCount = direct.ribbons.length;
out.idKept = received.id === 7;
out.sameAsMain = JSON.stringify(plain(received.build)) === JSON.stringify(plain(direct));
out.float64 = received.build.ribbons.every((r) => r.positions instanceof Float64Array);
out.transferCount = transfer.length;
out.detached = response.build.ribbons.every((r) => r.positions.byteLength === 0); // 복사 없이 넘김
// 측점 모자람 → null(워커도 같음)
out.tooFew = handleCorridorRequest({
id: 1,
crossSections: [sections[0]],
routePoints: route,
}).build;
// 워커 없는 곳(시험 · 옛 브라우저) — 부르기가 주 스레드 같은 함수로
const fallback = await buildCorridorOffThread(sections, route, designSamples);
out.fallbackSame = JSON.stringify(plain(fallback)) === JSON.stringify(plain(direct));
console.log(JSON.stringify(out));
})();
@@ -0,0 +1,167 @@
/* 346-4 떠남 확인 단위검증 헬퍼 — `A00_Common/design/design_leave.ts` 를 그 자리에서 트랜스파일 ·
* `b_page_state` 는 진짜 · 라우터 · 물음 창 · 문구는 흉내. 결과 JSON 을 stdout 으로.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function memoryStorage() {
const map = new Map();
return {
getItem: (key) => (map.has(key) ? map.get(key) : null),
setItem: (key, value) => map.set(key, String(value)),
removeItem: (key) => map.delete(key),
clear: () => map.clear(),
key: (index) => [...map.keys()][index] ?? null,
get length() {
return map.size;
},
};
}
const session = memoryStorage();
const win = new EventTarget();
win.sessionStorage = session;
win.localStorage = memoryStorage();
win.setTimeout = setTimeout;
globalThis.window = win;
globalThis.sessionStorage = session;
let route = "/b05-profile";
let guard = null;
let guardSets = 0;
let choice = "cancel";
const asked = [];
const STUBS = {
"@config/config_frontend": {
ROUTES: { B05_PROFILE: "/b05-profile", B06_SECTION: "/b06-section" },
},
"@ui/ui_template_locale": { t: (key) => key },
"@ui/ui_template_leave_dialog": {
leaveOrStay: async (message, hooks) => {
asked.push(message);
if (choice === "cancel") return "stay";
if (choice === "discard") {
await hooks.discard();
return "discarded";
}
await hooks.save();
return hooks.stillDirty() ? "stay" : "saved";
},
},
"../router": {
currentRoute: () => route,
setLeaveGuard: (fn) => {
guard = fn;
guardSets += 1;
},
},
};
function loadTs(file) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (request in STUBS) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(file), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const leave = loadTs(path.join(ROOT, "A00_Common", "design", "design_leave.ts"));
const P = "p1";
const tick = () => new Promise((resolve) => setTimeout(resolve, 5));
/** 브라우저 BeforeUnloadEvent 처럼 returnValue 를 쓸 수 있게. */
class BeforeUnload extends Event {
returnValue = true;
}
const unloadBlocked = () => {
const event = new BeforeUnload("beforeunload", { cancelable: true });
win.dispatchEvent(event);
return event.defaultPrevented;
};
(async () => {
const out = {};
const root = { isConnected: true };
let saveMarks = true;
let saves = 0;
leave.guardDesignLeave(P, root, async () => {
saves += 1;
if (saveMarks) leave.markDesignClean(P);
});
out.emptyDirty = leave.designDirty(P);
// 고침이 아닌 칸 · 다른 페이지 칸 · 표준 횡단면만 — 안 물음
session.setItem(`aislo:draft:selection:${P}`, '{"chainageM":20}');
session.setItem(`aislo:draft:std-cross:${P}`, '{"soil":{}}');
session.setItem(`aislo:draft:pipe-ids:${P}`, '{"next":3}');
session.setItem(`aislo:draft:b08-materials:${P}`, '{"units":1}');
session.setItem(`aislo:draft:crossdesign:${P}:7`, "{}"); // 빈 칸
out.nonEditDirty = leave.designDirty(P);
out.nonEditUnload = unloadBlocked();
route = "/b08-quantity";
out.nonEditLeave = await guard();
out.askedNonEdit = asked.length;
// 횡단 초안(노선 7) — 물음
session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"20.000":{"ground_type":"soil"}}');
out.editDirty = leave.designDirty(P);
out.editUnload = unloadBlocked();
// 같은 페이지(B05 → B06) — 안 묻고 한 틱 뒤 다시 걺
route = "/b06-section";
const setsBefore = guardSets;
out.samePage = await guard();
await tick();
out.reArmed = guardSets === setsBefore + 1;
out.askedSamePage = asked.length;
// 다른 페이지 — 취소 = 머묾
route = "/b08-quantity";
choice = "cancel";
out.cancelLeave = await guard();
// 저장 실패(깨끗한 표 없음) = 머묾 · 저장 성공 = 넘어감 · 뒤로 안 물음
choice = "save";
saveMarks = false;
out.saveFailLeave = await guard();
saveMarks = true;
out.saveLeave = await guard();
out.saves = saves;
out.afterSaveDirty = leave.designDirty(P);
out.afterSaveUnload = unloadBlocked();
const askedAfterSave = asked.length;
out.afterSaveLeave = await guard();
out.askedAfterSave = asked.length - askedAfterSave;
// 저장 뒤 다시 고침 — 다시 물음 · 버리고 넘어감 = 고침 칸만 지움
session.setItem(`aislo:draft:alignment-draft:${P}:7`, '{"station_offsets":{"20.000":0.3}}');
session.setItem(`aislo:draft:route-draft:${P}`, '{"rev":2}');
out.reEditDirty = leave.designDirty(P);
choice = "discard";
out.discardLeave = await guard();
out.left = {
crossdesign: session.getItem(`aislo:draft:crossdesign:${P}:7`),
alignment: session.getItem(`aislo:draft:alignment-draft:${P}:7`),
routeDraft: session.getItem(`aislo:draft:route-draft:${P}`),
selection: session.getItem(`aislo:draft:selection:${P}`),
stdCross: session.getItem(`aislo:draft:std-cross:${P}`),
b08: session.getItem(`aislo:draft:b08-materials:${P}`),
};
out.afterDiscardDirty = leave.designDirty(P);
// 페이지가 화면에서 빠짐 — 안 물음
session.setItem(`aislo:draft:crossdesign:${P}:7`, '{"40.000":{}}');
root.isConnected = false;
const askedGone = asked.length;
out.goneLeave = await guard();
out.goneUnload = unloadBlocked();
out.askedGone = asked.length - askedGone;
process.stdout.write(JSON.stringify(out));
})();
@@ -0,0 +1,57 @@
/* 347-2 · 347-3 배수유역 자동/수동 · 제안 헬퍼 — logic 두 파일을 그 자리에서 트랜스파일해
* Node 로 돌리고 값을 JSON 으로 낸다(틀 = helper_281_1).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
function loadTs(file) {
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
new Function("exports", "module", "require", js)(box.exports, box, require);
return box.exports;
}
const live = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Live.ts"));
const sug = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_Drainage_Suggest.ts"));
const pipes = [
{ chainage_m: 100, source: "stream", options: { pipe_diameter_mm: 1000 } },
{ chainage_m: 300, source: "user", facility: "pipe" },
{ chainage_m: 500, source: "stream", facility: "box_culvert" },
];
const basins = [
{ chainage_m: 100, recommended_facility: "pipe", recommended_diameter_mm: 1200 },
{ chainage_m: 300, recommended_facility: "ford_bridge", recommended_diameter_mm: null },
{ chainage_m: 500, recommended_facility: "box_culvert", recommended_diameter_mm: null },
];
const candidates = [{ chainage_m: 200 }, { chainage_m: 500.2 }];
const before = JSON.stringify(pipes);
const suggestions = sug.collectSuggestions(pipes, basins, candidates);
const applied = sug.applySuggestions(pipes, suggestions);
process.stdout.write(
JSON.stringify({
quietMs: live.QUIET_MS,
modes: [
live.parseDrainageMode(null),
live.parseDrainageMode("auto"),
live.parseDrainageMode("x"),
],
stale: [live.draftMakesStale(null), live.draftMakesStale({ rev: 1 })],
accept: [
live.acceptLive({ status: "success", generation: 3 }, 3, 3),
live.acceptLive({ status: "success", generation: 3 }, 3, 4),
live.acceptLive({ status: "superseded" }, 3, 3),
live.acceptLive({ status: "success", generation: 2 }, 3, 3),
],
body: live.liveRequestBody(5, undefined, [{ chainage_m: 1 }]),
suggestions,
applied,
untouched: JSON.stringify(pipes) === before,
}),
);
@@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
"""346-3 노선 저장 실경로 시간 — 검증 프로젝트의 지금 노드 · 곡선 그대로 저장 한 번(수동 실행).
같은 노선이라 값은 그대로(새 노선 행 번호만 바뀜) · 초기값은 안 건드림(`as_initial` 끔).
쓰는 법: `./venv/Scripts/python.exe resources/tester/run_346_3_route_save_timing.py <project_id>`
"""
import asyncio
import json
import logging
import sys
import time
from pathlib import Path
from uuid import UUID
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from config.config_db import close_db_pool, init_db_pool # noqa: E402
async def main(project_id: UUID) -> None:
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
logging.basicConfig(level=logging.INFO, format="%(message)s")
await init_db_pool()
try:
plan = await replan.read_route_plan(project_id)
nodes = plan["nodes"]
radii: dict[int, float] = {}
for curve in plan["curves"]:
if curve.get("node_first") == curve.get("node_last"):
radii[int(curve["node_first"])] = float(curve["radius_m"])
body = RouteSaveRequest(
vertices=[
{
"x": node["x"],
"y": node["y"],
"curve": index in radii,
"radius_m": radii.get(index),
}
for index, node in enumerate(nodes)
],
station_snap=bool(plan.get("station_snap")),
)
started = time.perf_counter()
result = await replan.replan_route(project_id, body)
seconds = time.perf_counter() - started
body_text = getattr(result, "body", None)
print(json.dumps(json.loads(body_text) if body_text else result, ensure_ascii=False))
print(f"[시간] 노선 저장 전체 {seconds:.1f}s")
finally:
await close_db_pool()
asyncio.run(main(UUID(sys.argv[1])))
@@ -18,7 +18,7 @@ import numpy as np
import B03_FileInput.B03_FileInput_Service_Chain as chain
import B04_PreProcess.B04_PreProcess_Service as service
import B05_Profile.B05_Profile_Repository as route_repo
import common_util.common_util_drainage_pipes as drainage_pipes
import B05_Profile.B05_Profile_Route_Save as route_save
import common_util.common_util_workflow_state as workflow_state
from B05_Profile import B05_Profile_Router_Replan as replan
from B06_Section import B06_Section_Router_Stations as stations
@@ -212,16 +212,15 @@ def _patch_recompute(monkeypatch, tmp_path: Path, db: _Db, failure: str | None):
async def start(*_args):
return None
async def run_chain(_project_id, model_id, selection, _target=None):
async def run_chain(_project_id, _root, _stored, model_id, selection, *_rest):
seen["model_id"], seen["selection"] = model_id, dict(selection)
return failure
return {"error": failure} if failure else {"route_id": 238}
points = [{"x": 0.0, "y": 0.0}, {"x": 5.0, "y": 5.0}]
monkeypatch.setattr(replan, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(drainage_pipes, "clear_pipe_points", lambda _s: None)
monkeypatch.setattr(route_repo, "get_latest_route", latest)
monkeypatch.setattr(chain, "_planned_route_points_in_project_crs", lambda *_a: points)
monkeypatch.setattr(chain, "run_redesign_chain", run_chain)
monkeypatch.setattr(route_save, "run_route_save_chain", run_chain)
monkeypatch.setattr(workflow_state, "get_workflow_state", state)
monkeypatch.setattr(workflow_state, "start_stage", start)
return seen
@@ -0,0 +1,88 @@
# -*- coding: utf-8 -*-
"""344-3 측점 열쇠 거울 — `common_util_station_key.py` ↔ `common_util_station_key.ts` 같은 열쇠.
일반 grid:k(누가거리 ÷ 간격 반올림 · 0.5 는 위로) · 관 pipe:<id> · 구조물 structure:<id> ·
시종점 end:BP/EP · id 모름 at:<0.01 m> · 옛 열쇠(누가거리 글) · at: 의 누가거리.
"""
import json
import subprocess
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
from common_util.common_util_station_key import ( # noqa: E402
chainage_key,
loose_chainage,
station_key,
)
TS = PROJECT_ROOT / "common_util" / "common_util_station_key.ts"
TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript"
CASES = [
("bp", 0.0, 20.0, None, "structure"),
("ep", 812.345, 20.0, None, "structure"),
("regular", 40.0, 20.0, None, "structure"),
("regular", 50.0, 20.0, None, "structure"), # 2.5 → 3(위로)
("regular", 69.996, 20.0, None, "structure"),
("regular", 40.0, None, None, "structure"),
("irregular", 85.054, 20.0, "pipe-s3", "pipe"),
("irregular", 85.054, 20.0, "st-7", "structure"),
("irregular", 85.054, 20.0, None, "structure"),
("irregular", 1.005, 20.0, None, "structure"),
("pipe", 12.344, 20.0, "pipe-s1", "structure"),
("structure", 12.345, 20.0, None, "structure"),
]
LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"]
SCRIPT = r"""
const ts = require(process.argv[1]);
const fs = require("fs");
const src = fs.readFileSync(process.argv[2], "utf8");
const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } };
const js = ts.transpileModule(src, opts).outputText;
const box = { exports: {} };
new Function("exports", "module", js)(box.exports, box);
const m = box.exports;
const input = JSON.parse(process.argv[3]);
process.stdout.write(JSON.stringify({
keys: input.cases.map((c) => m.stationKey(c[0], c[1], c[2], c[3], c[4])),
chainage: input.cases.map((c) => m.chainageKey(c[1])),
loose: input.loose.map((k) => m.looseChainage(k)),
}));
"""
def test_파이썬과_TS_열쇠_같음():
proc = subprocess.run(
[
"node",
"-e",
SCRIPT,
str(TYPESCRIPT),
str(TS),
json.dumps({"cases": CASES, "loose": LOOSE}),
],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
out = json.loads(proc.stdout)
assert out["keys"] == [station_key(*case) for case in CASES]
assert out["chainage"] == [chainage_key(case[1]) for case in CASES]
assert out["loose"] == [loose_chainage(key) for key in LOOSE]
def test_열쇠_규칙():
assert station_key("bp", 0.0, 20.0) == "end:BP"
assert station_key("ep", 812.3, 20.0) == "end:EP"
assert station_key("regular", 50.0, 20.0) == "grid:3"
assert station_key("irregular", 85.054, 20.0, "pipe-s3", "pipe") == "pipe:pipe-s3"
assert station_key("irregular", 85.054, 20.0) == "at:85.05"
assert loose_chainage("85.05") == 85.05 and loose_chainage("grid:4") is None
+124
View File
@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross · masshaul · haul 단계.
못박는 것
- 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 것은 지우지 않고 남김.
- 쓰기 = 측점 열쇠(grid · pipe · structure) · 부르는 쪽은 누가거리로 읽음 · 암 경계 저장소도 같음.
- 구조물 id 를 아직 모를 때(첫 열기)도 쓴 때 자리로 그 측점 고침을 읽음.
- 노선이 바뀜 = 측점 열쇠로 넘김(관 · 구조물은 옮긴 자리로) · 넘김 = 카드 선택 넷 · 암 경계 ·
단면 유형 · 측구 방향 · 포장 · 노견 확장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」.
- cross 단계: 같은 입력 다시 셈 0 · 측점 하나 고침 = 1 · 범위 밖 재사용.
- masshaul 단계 = 계열 + 흙 배분 입력 · haul 단계 = 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로.
Node 헬퍼 `helper_344_3_station_keys.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_344_3_entry.ts`)을
부름.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
FIXTURE = HERE / "fixtures" / "b05_192_1_moved_pipe_longitudinal.json"
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b344")
build = subprocess.run(
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_344_3_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_344_3_entry.js", bundle)
policy = json.loads(FIXTURE.read_text(encoding="utf-8"))["profile_alignment"]["policy"]
source = work / "input.json"
source.write_text(
json.dumps({"standard": STANDARD_CROSS_SECTION, "policy": policy}, default=str),
encoding="utf-8",
)
proc = subprocess.run(
["node", str(HERE / "helper_344_3_station_keys.mjs"), str(bundle), str(source)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_옛_초안은_읽을_때_측점_열쇠로_옮김(out):
assert out["legacyRead"] == [20, 45.05, 52.5, 60]
assert out["legacyKeys"] == ["99.00", "grid:1", "grid:3", "pipe:pipe-s1", "structure:st-1"]
def test_쓰기는_측점_열쇠_읽기는_누가거리(out):
assert out["writeKey"] is True
assert out["shoulderKeys"] == ["grid:2"]
assert out["rockKeys"] == ["grid:1"]
assert out["rockLoaded"] == {"20.00": -0.8}
assert out["rockSession"] == {"20.00": -0.8}
def test_구조물_id_모를_때도_쓴_자리로_읽음(out):
assert out["hintKeyNow"] == "at:52.50"
assert out["hintRead"] is True
def test_노선_바뀜_측점_열쇠로_넘김(out):
assert out["outside"] == 2 # 60 m(노선이 짧아짐) · 99 m(옛 노선에도 없던 옛 초안)
assert out["carriedKeys"] == ["grid:1", "grid:2", "pipe:pipe-s1", "structure:st-1"]
assert out["carriedRead"] == [20, 40, 47, 55] # 관 · 구조물은 옮긴 자리로
assert out["carriedChoice"] == {
"ground_type": "soil",
"ditch_type": "l_type",
"ditch_choice": False,
"two_stage_slope": True,
}
assert out["carriedRock"] == {"20.00": -0.8}
assert out["carriedShoulder"] == []
def test_cross_단계_국부_연산(out):
assert out["samplesSorted"] is True and out["samplesCount"] == 49
assert out["crossFirst"] == {"computed": 6, "reused": 0, "keys": 6}
assert out["crossHasArea"] is True
assert out["crossSame"] == {"computed": 0, "reused": 6}
assert out["crossOne"] == {"computed": 1, "reused": 5}
assert out["crossOneChanged"] == "soil"
assert out["crossGrade"]["computed"] <= 3 and out["crossGrade"]["reused"] >= 3
def test_masshaul_haul_단계(out):
assert out["massSeries"] == ["cross", "longitudinal"]
assert out["massHaulInput"] is True
assert out["haul"] == {"posts": 1, "plan1": {"n": 1}, "plan2": {"n": 1}, "plan3": {"n": 1}}
+9
View File
@@ -48,6 +48,15 @@ def test_처음_열_때_지형_한_번_로딩_원(out):
assert out["reopenDraws"] == 5
def test_열_때만_다시_짓기_닫힌_동안_0(out):
"""345-4 — 낡은 3D 길 모형은 모달을 열 때(지형이 선 뒤)만 다시 짓기를 부름 · 닫혀 있으면 안 부름."""
assert out["opensBeforeTerrain"] == 0
assert out["opensAfterTerrain"] == 1
assert out["opensWhileClosed"] == 1
assert out["opensAfterReopen"] == 2 # 다시 열 때 한 번(열린 동안 또 눌러도 하나)
assert out["closedBeforeTerrainOpens"] == 0
def test_떠날_때만_버림(out):
assert out["beforeCloseOk"] is True
assert out["closedOnDispose"] is True
@@ -0,0 +1,35 @@
"""345-4 정리 — B05 등고선 주소 한 벌(`contourAssetUrl` · 미리 받기와 같은 파일) · 빈 saveCorridorIfDirty 지움."""
from __future__ import annotations
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def test_B05_등고선_주소는_한_벌():
for rel in (
"B05_Profile/B05_Profile_UI_Viewer.ts",
"B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts",
):
source = _read(rel)
assert "contourAssetUrl(" in source, rel
assert "/contour?interval" not in source, rel
# 미리 받기(b_asset_cache)도 같은 함수 — 주소가 같아야 보관함 한 칸을 같이 씀
assert "contourAssetUrl(projectId, confirmed.model_id, interval, smooth)" in _read(
"A00_Common/b_asset_cache.ts"
)
def test_빈_코리도_올림_함수_지움():
for rel in (
"B05_Profile/B05_Profile_UI_Corridor.ts",
"B05_Profile/B05_Profile_UI_Page_Actions.ts",
"A00_Common/design/design_view_profile.ts",
"A00_Common/design/design_view_profile_3d.ts",
):
assert "saveCorridorIfDirty" not in _read(rel), rel
@@ -0,0 +1,72 @@
"""345-4 3D 길 모형 웹 워커 — 워커 결과 = 주 스레드 결과(같은 입력) · 리본 좌표는 복사 없이(Transferable) ·
서버 저장본이 먼저(해시 맞으면 받기만) · 저장 안 한 초안일 때만 워커 미리보기 · 모달을 열 때만 다시 짓기.
셈은 Node 헬퍼 `helper_345_4_corridor_worker.cjs`(워커 경계 = structuredClone + transfer) · 배선은 글로 봄.
모달 닫힌 동안 짓기 0 은 `test_345_2_3d_modal.py`.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run(
["node", str(HERE / "helper_345_4_corridor_worker.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(run.stdout)
def test_워커_결과는_주_스레드와_같음(out):
assert out["ribbonCount"] > 0
assert out["sameAsMain"] is True and out["idKept"] is True
assert out["float64"] is True # 모델 좌표 해상도 그대로
assert out["tooFew"] is None
assert out["fallbackSame"] is True # 워커 없는 곳은 주 스레드 같은 함수
def test_리본_좌표는_복사_없이_넘김(out):
assert out["transferCount"] == out["ribbonCount"]
assert out["detached"] is True
def test_저장본_먼저_초안만_워커():
corridor = _read("B05_Profile/B05_Profile_UI_Corridor.ts")
body = corridor[corridor.index("export async function ensureCorridor(") :]
body = body[: body.index("\n}\n")]
assert body.index("fetchStored(") < body.index("buildCorridorOffThread(")
assert "if (!rebuild) return null;" in body # 진입 = 저장본만(짓지 않음)
assert "buildCorridor(" not in body.replace("buildCorridorOffThread(", "")
client = _read("B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts")
assert 'new Worker(new URL("./B05_Profile_UI_Corridor_Worker.ts", import.meta.url)' in client
worker = _read("B05_Profile/B05_Profile_UI_Corridor_Worker.ts")
for banned in ("document.", "window.", "sessionStorage", 'from "three"'):
assert banned not in worker
def test_모달_열_때_낡았으면_다시_짓기():
view3d = _read("A00_Common/design/design_view_profile_3d.ts")
assert "if (state.corridorPending) void refreshOnce();" in view3d
assert "onCorridorRefresh: () => refreshOnce()," in view3d
assert "deps.sections().setCorridorPending(false);" in view3d
sections = _read("A00_Common/design/design_view_profile_sections.ts")
assert ".then((fresh) => setCorridorPending(!fresh));" in sections
assert "deps.panel().setCorridorPending(pending);" in sections # 「3D 늦음」 표시 그대로
view = _read("A00_Common/design/design_view_profile.ts")
assert "onCrossDesignsUpdated: () => sections.setCorridorPending(true)," in view
+561
View File
@@ -0,0 +1,561 @@
# -*- coding: utf-8 -*-
"""346-3 노선 저장 서버 길 — 차례 · 관 지킴 · 새 교차점 기본 관 · 오목 보충 · 이월 열쇠 ·
초기값 규칙 ·
배수유역 낡음 · 실패 때 옛 노선 되살림.
순수 부품(`B05_Profile_Route_Save_Parts`)은 값으로 · 체인(`run_route_save_chain`)과 API
(`replan_route`)는 DB · 엔진을 가짜로 바꿔 차례 · 되살림 · 초기값만 잰다.
"""
from __future__ import annotations
import asyncio
import json
import sys
from pathlib import Path
from types import SimpleNamespace
from uuid import uuid4
import numpy as np
import pytest
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from fastapi.responses import JSONResponse # noqa: E402
import B03_FileInput.B03_FileInput_Service_Chain as chain # noqa: E402
import B04_PreProcess.B04_PreProcess_Router_Watershed as watershed # noqa: E402
import B05_Profile.B05_Profile_Route_Save as save # noqa: E402
import B05_Profile.B05_Profile_Router as b05_router # noqa: E402
import B05_Profile.B05_Profile_Router_Replan as replan # noqa: E402
import B05_Profile.B05_Profile_Router_RouteInitial as route_initial # noqa: E402
import B05_Profile.B05_Profile_Structures_Router as structures_router # noqa: E402
import B06_Section.B06_Section_Repository as b06_repo # noqa: E402
import B06_Section.B06_Section_Router as b06_router # noqa: E402
import B06_Section.B06_Section_Router_Confirm as b06_confirm # noqa: E402
import common_util.common_util_drainage_pipes as drainage_pipes # noqa: E402
import common_util.common_util_initial_snapshot as snapshot # noqa: E402
import common_util.common_util_workflow_state as workflow # noqa: E402
from B05_Profile.B05_Profile_Route_Save_Parts import ( # noqa: E402
add_crossing_pipes,
carry_cross_inputs,
concave_chainages,
fill_concave_pipes,
follow_pipes,
station_keys,
vertices_of,
)
from common_util.common_util_drainage_pipes import ( # noqa: E402
PIPE_SOURCE_SPACING,
PIPE_SOURCE_STREAM,
PipePoint,
analysis_route_signature,
route_signature,
)
OLD = [(0.0, 0.0), (100.0, 0.0), (200.0, 0.0), (300.0, 0.0), (400.0, 0.0)]
# 새 노선 = 100 ~ 300 m 를 북으로 휘게(앞 · 뒤는 옛 노선 위)
NEW = [(0.0, 0.0), (100.0, 0.0), (200.0, 60.0), (300.0, 0.0), (400.0, 0.0)]
# ── 관 따라감 · 교차점 · 오목 보충 ─────────────────────────────────────────────
def test_관은_지우지_않고_좌표를_따라감():
old = [
PipePoint(chainage_m=50.0, x=50.0, y=0.0, options={"pipe_kind": "흄관"}),
PipePoint(chainage_m=350.0, x=350.0, y=0.0, start_m=345.0, end_m=355.0),
]
pairs = follow_pipes(old, vertices_of(NEW))
assert [pipe_id for pipe_id, _ in pairs] == [0, 1]
assert pairs[0][1].options == {"pipe_kind": "흄관"} # 사용자 관 속성 그대로
shift = pairs[1][1].chainage_m - 350.0 # 새 노선이 길어진 만큼 뒤 관이 밀림
assert shift > 30 and pairs[1][1].start_m == pytest.approx(345.0 + shift)
assert old[1].chainage_m == 350.0 # 옛 목록은 안 고침
def test_새_교차점에만_기본_관():
pairs = follow_pipes([PipePoint(chainage_m=50.0, x=50.0, y=0.0)], vertices_of(NEW))
crossings = [
SimpleNamespace(chainage_m=20.0, x=20.0, y=0.0), # 옛 노선 위 — 사용자가 지운 관 안 되살림
SimpleNamespace(chainage_m=150.4, x=150.0, y=30.0), # 새 자리 · 관 없음 → 더함
SimpleNamespace(chainage_m=52.0, x=52.0, y=0.0), # 옛 자리
]
added = add_crossing_pipes(pairs, crossings, OLD, 456.0)
assert added == 1
new = [pipe for pipe_id, pipe in pairs if pipe_id < 0]
assert new[0].chainage_m == 150.0 and new[0].source == PIPE_SOURCE_STREAM
# 같은 자리 다시 = 이미 관이 있음
assert add_crossing_pipes(pairs, crossings, OLD, 456.0) == 0
def test_오목_지점_보충은_새_자리의_오목만():
vertices = vertices_of(NEW)
total = vertices[-1].chainage_m
chainage = np.arange(0.0, total, 2.0)
ground = 100.0 + np.abs(chainage - 220.0) * 0.05 # 220 m 에 골(새 자리)
ground[chainage < 60] = (
100.0 + np.abs(chainage[chainage < 60] - 30.0) * 0.05
) # 30 m 골(옛 자리)
concave = concave_chainages(chainage, ground)
assert any(abs(at - 220.0) < 3 for at, _ in concave)
pairs: list = []
added = fill_concave_pipes(pairs, concave, vertices, OLD)
places = [pipe.chainage_m for _, pipe in pairs]
assert added >= 1 and all(pipe.source == PIPE_SOURCE_SPACING for _, pipe in pairs)
assert any(abs(at - 220.0) <= 3 for at in places)
assert not any(at < 60 for at in places) # 옛 노선 위 오목엔 안 둠
def test_간격이_좁으면_보충_없음():
vertices = vertices_of(NEW)
pairs = [(0, PipePoint(chainage_m=at)) for at in (0.0, 90.0, 180.0, 270.0, 360.0, 450.0)]
concave = [(220.0, 99.0)]
assert fill_concave_pipes(pairs, concave, vertices, OLD) == 0
# ── 측점 열쇠 · 횡단 이월 ──────────────────────────────────────────────────────
def _structure(sid, type_id, anchor, start=None, end=None):
return SimpleNamespace(
structure_id=sid,
type_id=type_id,
chainage_m=anchor,
start_m=start,
end_m=end,
options={},
)
def test_측점_열쇠_규칙():
stations = [{"chainage_m": s} for s in (0.0, 20.0, 40.0, 50.0, 60.0, 77.0, 80.0, 95.5)]
pipes = [(3, PipePoint(chainage_m=50.0))]
structures = [_structure("w1", "retaining_wall", 77.0, 60.0, 80.0)]
keys = station_keys(stations, pipes, structures, 20.0)
assert keys == {
"end:BP": 0.0,
"grid:1": 20.0,
"grid:2": 40.0,
"pipe:3": 50.0,
"structure:w1:start": 60.0,
"structure:w1": 77.0,
"structure:w1:end": 80.0,
"end:EP": 95.5,
}
def test_이월은_열쇠로_누가거리가_밀려도():
old_keys = {
"end:BP": 0.0,
"grid:1": 20.0,
"pipe:0": 50.0,
"structure:w1": 77.0,
"grid:5": 100.0,
}
new_keys = {"end:BP": 0.0, "grid:1": 20.0, "pipe:0": 57.0, "structure:w1": 84.0}
designs = {
0.0: {"ground_type": "soil"},
20.0: {"ground_type": "rock", "berm": {"on": True}, "cut_area_m2": 9.0},
50.0: {"ditch_type": "u", "inlet_structure": "basin", "basin_adjust": {"d": 1}},
77.0: {"rock_boundary_offset_m": 0.8, "revet_adjust": {"h": 2}},
100.0: {"ground_type": "soil"},
}
inputs, adjusts, dropped = carry_cross_inputs(
old_keys,
new_keys,
designs,
("ground_type", "ditch_type", "berm", "rock_boundary_offset_m"),
("inlet_structure", "basin_adjust", "revet_adjust"),
)
assert inputs == {
0.0: {"ground_type": "soil"},
20.0: {"ground_type": "rock", "berm": {"on": True}}, # 결과 칸(면적)은 안 넘김
57.0: {"ditch_type": "u"},
84.0: {"rock_boundary_offset_m": 0.8},
}
# 구조물 조정은 관 · 구조물 열쇠만 따라감
assert adjusts == {
57.0: {"inlet_structure": "basin", "basin_adjust": {"d": 1}},
84.0: {"revet_adjust": {"h": 2}},
}
assert dropped == 1 # grid:5 — 새 노선에 없음(노선 밖 고침)
# ── 노선 초안 요청 ────────────────────────────────────────────────────────────
def test_초안_모양을_옛_칸으로():
body = save.RouteSaveRequest(
draft={
"rev": 3,
"nodes": [[0, 0], [10, 5], [20, 0]],
"curveOn": [True, False, True],
"curveRadius": [None, 30.0, None],
"curveLock": [None, "radius", None],
"curveArc": [None, None, None],
"stationSnap": True,
"terrain": "sheet",
}
)
request = body.resolved()
assert [(v.x, v.y, v.curve, v.radius_m) for v in request.vertices] == [
(0, 0, True, None),
(10, 5, False, 30.0),
(20, 0, True, None),
]
assert request.station_snap is True and request.terrain == "sheet"
with pytest.raises(ValueError):
save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]}).resolved()
# ── 체인 차례 · 되살림 · 배수유역 낡음 ─────────────────────────────────────────
class _Cursor:
def __init__(self, log):
self.log = log
async def __aenter__(self):
return self
async def __aexit__(self, *exc):
return False
async def execute(self, sql, params=None):
self.log.append(" ".join(sql.split()))
class _Connection:
def __init__(self, log):
self.log = log
def cursor(self, *args):
return _Cursor(self.log)
async def begin(self):
return None
async def commit(self):
return None
async def rollback(self):
return None
class _Pool:
def __init__(self, log):
self.log = log
def acquire(self):
connection = _Connection(self.log)
class _Acquire:
async def __aenter__(self):
return connection
async def __aexit__(self, *exc):
return False
return _Acquire()
def _async(value, order=None, name=None):
async def fake(*args, **kwargs):
if order is not None:
order.append(name)
return value
return fake
@pytest.fixture
def chain_world(monkeypatch, tmp_path):
order: list[str] = []
sql: list[str] = []
root = tmp_path
route_file = root / "B05_Profile" / "route" / "route_main.geojson"
route_file.parent.mkdir(parents=True)
route_file.write_text(
json.dumps({"geometry": {"coordinates": [[*p, 0] for p in OLD]}}), "utf-8"
)
long_dir = root / "B06_Section" / "longitudinal"
long_dir.mkdir(parents=True)
(long_dir / "longitudinal.json").write_text(json.dumps({"stations": [], "old": True}), "utf-8")
drain = root / "drain"
pipes_file = drain / "edits" / "pipe_points.json"
pipes_file.parent.mkdir(parents=True)
pipes_file.write_text(
json.dumps(
{"route_signature": "old", "points": [{"chainage_m": 50.0, "x": 50.0, "y": 0.0}]}
),
"utf-8",
)
(drain / "00_watershed_response.json").write_text(
json.dumps({"route_signature": "old"}), "utf-8"
)
state = {"stages": [{"stage_no": 2, "params": {"points": {}, "options": {}}}]}
def solve(project_id, request, *, generate_sections=True):
order.append("solve")
assert generate_sections is False
route_file.write_text(
json.dumps({"geometry": {"coordinates": [[*p, 0] for p in NEW]}}), "utf-8"
)
return _async(
SimpleNamespace(
route_id=8,
total_length_m=456.0,
route_data_path="B05_Profile/route/route_main.geojson",
)
)()
async def regenerate(*args, **kwargs):
order.append("sections")
(long_dir / "longitudinal.json").write_text(
json.dumps({"stations": [], "new": True}), "utf-8"
)
return SimpleNamespace()
async def watershed_must_not_run(*args, **kwargs):
raise AssertionError("배수유역은 체인에서 뗌")
async def prepare(project_id):
order.append("pipes")
return {"vertices": vertices_of(NEW), "streams": []}
monkeypatch.setattr(save, "get_db_pool", lambda: _Pool(sql))
monkeypatch.setattr(workflow, "get_workflow_state", _async(state))
monkeypatch.setattr(workflow, "start_stage", _async(None, order, "restore_params"))
monkeypatch.setattr(
b06_repo,
"get_longitudinal_section",
_async(
{"data": {}, "longitudinal_file_path": "B06_Section/longitudinal/longitudinal.json"}
),
)
monkeypatch.setattr(b06_repo, "get_cross_section_designs", _async([]))
monkeypatch.setattr(b06_repo, "get_latest_section_options", _async({}))
monkeypatch.setattr(
structures_router,
"follow_structures_to_route",
lambda *a: order.append("structures") or {"moved": 0, "far": 0},
)
monkeypatch.setattr(
chain,
"redesign_solve_request",
lambda *a: b05_router.RouteSolveRequest(
filter_key="csf",
method="dtm",
smooth=False,
algorithm="as_planned",
bp={"x": 0, "y": 0},
ep={"x": 400, "y": 0},
),
)
monkeypatch.setattr(b05_router, "solve_route_record", solve)
monkeypatch.setattr(b05_router, "confirm_latest_route", _async({}))
monkeypatch.setattr(watershed, "get_primary_region", watershed_must_not_run)
monkeypatch.setattr(watershed, "_prepare", prepare)
monkeypatch.setattr(drainage_pipes, "clear_pipe_points", watershed_must_not_run)
monkeypatch.setattr(drainage_pipes, "drainage_dir", lambda stored: drain)
monkeypatch.setattr(save, "_ground_along", lambda *a: (np.zeros(0), np.zeros(0)))
monkeypatch.setattr(b06_router, "regenerate_route_sections", regenerate)
monkeypatch.setattr(save, "_carry_profile", _async(None, order, "profile"))
monkeypatch.setattr(save, "_carry_cross", _async(2, order, "cross"))
monkeypatch.setattr(
b06_confirm, "confirm_sections", _async(SimpleNamespace(), order, "confirm")
)
monkeypatch.setattr(snapshot, "mark_designing", lambda root: None)
monkeypatch.setattr(snapshot, "clear_designing", lambda root: None)
return SimpleNamespace(
order=order,
sql=sql,
root=root,
route_file=route_file,
pipes_file=pipes_file,
long_dir=long_dir,
drain=drain,
)
def _run(world, **kwargs):
old_route = {"id": 7, "route_data_path": "B05_Profile/route/route_main.geojson"}
return asyncio.run(
save.run_route_save_chain(
uuid4(),
world.root,
"p",
1,
{},
old_route,
[{"x": x, "y": y} for x, y in NEW],
{"old": 1},
**kwargs,
)
)
def test_차례와_배수유역_뗌(chain_world):
result = _run(chain_world)
assert "error" not in result, result
assert chain_world.order == [
"structures",
"solve",
"pipes",
"sections",
"profile",
"cross",
"confirm",
]
assert result["cross_dropped"] == 2 and result["drainage_stale"] is True
assert result["pipes"] == {"kept": 1, "crossing": 0, "concave": 0}
# 관 정본 = 새 노선 지문 · 배수유역 분석 지문(옛) 과 달라 B04 가 「낡음」
saved = json.loads(chain_world.pipes_file.read_text("utf-8"))
assert saved["route_signature"] == route_signature(vertices_of(NEW))
assert analysis_route_signature("p") != saved["route_signature"]
assert len(saved["points"]) == 1 # 사용자 관 그대로(지우지 않음)
def test_실패하면_옛_노선을_되살림(chain_world, monkeypatch):
monkeypatch.setattr(
b06_confirm, "confirm_sections", _async(JSONResponse(status_code=500, content={}))
)
before_pipes = chain_world.pipes_file.read_text("utf-8")
result = _run(chain_world)
assert result["error"].startswith("종횡단 확정 실패")
assert json.loads(chain_world.route_file.read_text("utf-8"))["geometry"]["coordinates"] == [
[*p, 0] for p in OLD
]
assert chain_world.pipes_file.read_text("utf-8") == before_pipes
assert json.loads((chain_world.long_dir / "longitudinal.json").read_text("utf-8")) == {
"stations": [],
"old": True,
}
assert any(
sql.startswith("DELETE FROM routes WHERE project_id = %s AND id > %s")
for sql in chain_world.sql
)
assert chain_world.order[-1] == "restore_params"
assert not list(chain_world.root.glob("route_save_*")) # 손에 쥔 사본 치움
def test_추천_종단_반영이면_균형_종단(chain_world, monkeypatch):
monkeypatch.setattr(
route_initial, "apply_profile_target", _async(None, chain_world.order, "balanced")
)
_run(chain_world, profile_target=([0.0, 456.0], [100.0, 110.0]))
assert "balanced" in chain_world.order and "profile" not in chain_world.order
# ── 초기값 규칙 ───────────────────────────────────────────────────────────────
@pytest.fixture
def api_world(monkeypatch, tmp_path):
calls: list = []
monkeypatch.setattr(replan, "_project_paths", _async((tmp_path, "p")))
monkeypatch.setattr(replan, "_ensure_expected_route", lambda root: None)
monkeypatch.setattr(replan, "_plan_criteria", _async((20.0, 0.0, 0.0)))
monkeypatch.setattr(replan, "_ensure_planned_initial", lambda *a: None)
monkeypatch.setattr(
replan,
"_write_planned_polyline",
lambda *a, **k: {"vertices": 3, "curves": 0, "violations": 0},
)
monkeypatch.setattr(replan, "_recompute", _async({"route_id": 9, "total_length_m": 10.0}))
monkeypatch.setattr(replan, "read_initial_choice", lambda root: None)
monkeypatch.setattr(
route_initial, "refresh_initial_snapshot", lambda *a: calls.append(a) or _async(None)()
)
return calls
def _body(**kwargs):
return save.RouteSaveRequest(vertices=[{"x": 0, "y": 0}, {"x": 5, "y": 5}], **kwargs)
def test_손으로_고친_노선_저장은_초기값_그대로(api_world):
result = asyncio.run(replan.replan_route(uuid4(), _body()))
assert result["initial_refreshed"] is False and api_world == []
def test_추천_적용은_초기값_다시(api_world):
result = asyncio.run(replan.replan_route(uuid4(), _body(as_initial=True)))
assert result["initial_refreshed"] is True and len(api_world) == 1
def test_초안_칸이_어긋나면_400(api_world):
body = save.RouteSaveRequest(draft={"nodes": [[0, 0], [1, 1]], "curveOn": [True]})
response = asyncio.run(replan.replan_route(uuid4(), body))
assert response.status_code == 400
def _longitudinal(total: float) -> dict:
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options
from B05_Profile.B05_Profile_Engine_Grade_Profile import design_ground_following_profile
samples = [
{"chainage_m": float(s), "elevation_m": 100.0 + 0.05 * s + 2.0 * np.sin(s / 17.0)}
for s in range(int(total) + 1)
]
stations = [{"chainage_m": float(s)} for s in range(0, int(total) + 1, 20)]
longitudinal = {"samples": samples, "stations": stations}
alignment, profile = design_ground_following_profile(
longitudinal, resolve_grade_options("trunk")
)
return {**longitudinal, "profile_alignment": alignment, "design_profiles": [profile]}
def test_계획고_옮김은_앞_그대로_뒤_밂(tmp_path, monkeypatch):
new_xy = [list(p) for p in NEW]
total = float(vertices_of(NEW)[-1].chainage_m)
old = _longitudinal(400.0)
old["profile_alignment"]["edits"] = {
"station_offsets": {"40.000": 0.7, "200.000": -0.4, "380.000": 0.3},
"curve_radii": {},
}
path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json"
path.parent.mkdir(parents=True)
path.write_text(json.dumps(_longitudinal(total)), "utf-8")
import B05_Profile.B05_Profile_Repository as b05_repo
monkeypatch.setattr(save, "get_db_pool", lambda: _Pool([]))
monkeypatch.setattr(b05_repo, "update_longitudinal_grade_summary", _async(None))
asyncio.run(
save._carry_profile(
tmp_path,
"B06_Section/longitudinal/longitudinal.json",
8,
old["profile_alignment"],
[list(p) for p in OLD],
new_xy,
)
)
edits = json.loads(path.read_text("utf-8"))["profile_alignment"]["edits"]["station_offsets"]
delta = total - 400.0
assert edits["40.000"] == 0.7 # 바뀐 구간 앞 — 그대로
assert "200.000" not in edits # 바뀐 구간 안 — 새 지반
assert edits[f"{380.0 + delta:.3f}"] == 0.3 # 뒤 — 길이 차만큼 밂
def test_저장_확정이_옛_횡단_파일을_지움(tmp_path):
from B05_Profile.B05_Profile_Engine_Sections import cross_filename
long_path = tmp_path / "B06_Section" / "longitudinal" / "longitudinal.json"
cross_dir = tmp_path / "B06_Section" / "cross_sections"
long_path.parent.mkdir(parents=True)
cross_dir.mkdir(parents=True)
long_path.write_text(json.dumps({"stations": [{"chainage_m": 0.0}, {"chainage_m": 20.0}]}))
for at in (0.0, 20.0, 37.0):
(cross_dir / cross_filename(at)).write_text("{}")
b06_confirm._prune_cross_files(tmp_path, "B06_Section/longitudinal/longitudinal.json")
assert sorted(p.name for p in cross_dir.glob("cross_*.json")) == sorted(
cross_filename(at) for at in (0.0, 20.0)
)
source = (ROOT / "B06_Section" / "B06_Section_Router_Confirm.py").read_text("utf-8")
for name in ("async def save_sections(", "async def confirm_sections("):
body = source[source.index(name) :]
assert body.index("_stand_patch_stations(") < body.index("_prune_cross_files,")
@@ -0,0 +1,57 @@
"""346-4 떠남 확인 — B05 · B06 저장 안 한 조작이 있으면 탭 닫기 · 다른 페이지 때 물음.
./venv/Scripts/python.exe -m pytest resources/tester/test_346_4_design_leave.py -q
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HELPER = Path(__file__).resolve().parent / "helper_346_4_design_leave.cjs"
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8"
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_고침_아닌_칸은_안_물음(out) -> None:
assert out["emptyDirty"] is False
assert out["nonEditDirty"] is False and out["nonEditUnload"] is False
assert out["nonEditLeave"] is True and out["askedNonEdit"] == 0
def test_횡단_초안이면_물음(out) -> None:
assert out["editDirty"] is True and out["editUnload"] is True
assert out["cancelLeave"] is False
def test_B05_B06_사이는_안_묻고_다시_걺(out) -> None:
assert out["samePage"] is True and out["reArmed"] is True and out["askedSamePage"] == 0
def test_저장_실패는_머묾_성공_뒤엔_안_물음(out) -> None:
assert out["saveFailLeave"] is False
assert out["saveLeave"] is True and out["saves"] == 2
assert out["afterSaveDirty"] is False and out["afterSaveUnload"] is False
assert out["afterSaveLeave"] is True and out["askedAfterSave"] == 0
def test_버리고_넘어감은_고침_칸만_지움(out) -> None:
assert out["reEditDirty"] is True and out["discardLeave"] is True
left = out["left"]
assert left["crossdesign"] is None and left["alignment"] is None and left["routeDraft"] is None
assert left["selection"] and left["stdCross"] and left["b08"]
assert out["afterDiscardDirty"] is False
def test_페이지가_빠지면_안_물음(out) -> None:
assert out["goneLeave"] is True and out["goneUnload"] is False and out["askedGone"] == 0
@@ -0,0 +1,58 @@
"""347-2 · 347-3 배수유역 자동/수동 · 제안 — 화면 쪽 순수 규칙(logic/ 두 파일).
- 기본 수동 · 자동 = 3 초 조용한 뒤 · 노선 초안이 있을 때만 낡음.
- 늦은 답 버림: 옛 차례 · 서버가 그만둔 답 · 답 차례가 다른 것은 안 올림.
- 제안: 관경(배관 · 추천 ≠ 지금) · 시설(BOX암거 · 세월교) · 관 자리(기존 관 근처 뺌) ·
[적용] = 한 번에 얹음 · 입력 목록은 안 고침(자동 바꿈 없음).
Node 헬퍼(`helper_347_2_drainage_live.cjs`)가 TS 를 그 자리에서 돌린다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def r():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_347_2_drainage_live.cjs")],
capture_output=True,
text=True,
timeout=60,
encoding="utf-8",
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_mode_and_stale(r):
assert r["quietMs"] == 3000
assert r["modes"] == ["manual", "auto", "manual"]
assert r["stale"] == [False, True]
def test_late_answers_dropped(r):
assert r["accept"] == [True, False, False, False]
assert r["body"] == {"generation": 5, "route": None, "points": [{"chainage_m": 1}]}
def test_suggestions_collected(r):
assert r["suggestions"] == [
{"kind": "diameter", "chainageM": 100, "from": 1000, "to": 1200},
{"kind": "position", "chainageM": 200},
{"kind": "facility", "chainageM": 300, "from": "pipe", "to": "ford_bridge"},
]
def test_apply_once_without_touching_input(r):
assert r["untouched"]
applied = {pipe["chainage_m"]: pipe for pipe in r["applied"]}
assert sorted(applied) == [100, 200, 300, 500]
assert applied[100]["options"]["pipe_diameter_mm"] == 1200
assert applied[200]["source"] == "spacing"
assert applied[300]["facility"] == "ford_bridge"
assert applied[500]["facility"] == "box_culvert" # 이미 그 종류 — 그대로
@@ -64,7 +64,8 @@ def test_등록표_밖에서_날문자열_세션_키를_안_쓴다():
def test_옛_암경계_키가_코드에_안_남아_있다():
"""실사고 자리 못박기 — 읽는 쪽이 등록표를 거치는지."""
persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8")
assert 'readState<Record<string, number>>("rockb"' in persist, "등록표를 안 거침"
# 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음
assert 'readKeyed<number>("rockb", projectId, routeId)' in persist, "등록표를 안 거침"
# 옛 키가 **코드로** 되살아나는 것만 막는다 — 주석에 사연으로 적힌 것은 남겨 둔다.
code = "\n".join(
line for line in persist.splitlines() if not line.lstrip().startswith(("*", "//", "/*"))
+2 -1
View File
@@ -202,7 +202,8 @@ def test_암경계선은_등록표_키로_읽는다():
persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(
encoding="utf-8"
)
assert 'readState<Record<string, number>>("rockb"' in persist
# 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음
assert 'readKeyed<number>("rockb", projectId, routeId)' in persist
# ── ⑦ 저장된 표준 횡단면이 새 탭에서 안 서던 것 (`72e50b3e`) ───────────────────
+1
View File
@@ -113,4 +113,5 @@ export const ui_locales_b4 = {
"B05 · B06 에 저장 안 한 조작이 있습니다 — B05 · B06 [저장] 먼저 누른 뒤 연결하세요.",
"Unsaved edits in B05 · B06 — save there first, then link.",
],
B05_Design_Leave: ["B05 · B06 에 저장 안 한 조작이 있음", "Unsaved edits in B05 · B06"],
} as const;