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:
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
/** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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)에서 관을 고른다 — 마커 선택·유역 강조를 맞춘다.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
/** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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}}
|
||||
@@ -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
|
||||
@@ -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(("*", "//", "/*"))
|
||||
|
||||
@@ -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`) ───────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user