Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7
This commit is contained in:
@@ -209,6 +209,8 @@ export const STATE_REGISTRY = {
|
||||
/** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c).
|
||||
* 옛 키(프로젝트 없는 `b05-profile-alignment-draft:{노선}`)는 안 받음 — 다른 프로젝트 초안이 섞임(387-1). */
|
||||
"alignment-draft": { bucket: "draft", scope: "route" },
|
||||
/** B05 페이지 설정(448) — 설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점. [저장]의 `settings` 차례가 내보냄. */
|
||||
pagesettings: { bucket: "draft", scope: "project" },
|
||||
/** 노선 초안(344-4) — 노드 · 곡선 · 반경 · 스냅 · 지형 · rev(`RouteDraft` · `design_pipeline_types`).
|
||||
* 노선 번호는 [저장]마다 새로 나므로 project 범위 · 가게 키 `routeDraft`. */
|
||||
"route-draft": { bucket: "draft", scope: "project" },
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
* route · ground `B05_Profile/logic/B05_Profile_Pipeline_RouteGround.ts`(sub8)
|
||||
* follow · planline · profile `B05_Profile/logic/B05_Profile_Pipeline_Grade.ts`
|
||||
* stations `design_pipeline_stations.ts` · footprint `B05_Profile_Footprint_Stage.ts`(sub6)
|
||||
* cross · masshaul · haul `B06_Section_Cross_Stage` · `logic/B05_Profile_MassHaul_Stage` ·
|
||||
* `B05_Profile_Api_HaulPlan`(sub2) · drainage 배수유역 판 `pipelineStage`(sub3) · corridor 3D 「낡음」.
|
||||
* haul · corridor 는 노선 초안 동안만(저장 노선은 유토곡선 판 · 횡단 다시 셈이 맡음).
|
||||
* cross `B06_Section_Cross_Stage` · drainage 배수유역 판 `pipelineStage`(sub3) · corridor 3D 「낡음」.
|
||||
* corridor 는 노선 초안 동안만 · 유토곡선 · 흙 배분은 유토곡선 판이 셈(454).
|
||||
*
|
||||
* 입력 — 노선 = 편집기 다리(`RouteDraftBridge` · 캐시 칸 route-draft) · 계획고 = 종단도 편집
|
||||
* (`pipelineGrade` · 노선 초안 동안은 alignment-draft 노선 `DRAFT_ROUTE_ID`) · 횡단 = 저장 상세의 측점
|
||||
@@ -65,7 +64,6 @@ import {
|
||||
fetchRoutePlan,
|
||||
type RoutePlanResponse,
|
||||
} from "../../B05_Profile/B05_Profile_Api_Replan";
|
||||
import { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
|
||||
import { fetchStructureTypes } from "../../B05_Profile/B05_Profile_Api_Structures";
|
||||
import { forgetPageData, pagePipes, pageStructures, trackPageBusy } from "./design_page_data";
|
||||
import type { PagePipelineOptions, ProfileLink } from "./design_pipeline_page_types";
|
||||
@@ -98,10 +96,6 @@ import {
|
||||
SECTION_STATION_INTERVAL_M,
|
||||
} from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import { footprintStage } from "../../B05_Profile/logic/B05_Profile_Footprint_Stage";
|
||||
import {
|
||||
createMassHaulStage,
|
||||
type MassHaulStageContext,
|
||||
} from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage";
|
||||
import type {
|
||||
PlanPipe,
|
||||
PlanStructure,
|
||||
@@ -122,7 +116,7 @@ interface PagePipeline {
|
||||
bridge: RouteDraftBridge;
|
||||
grade(edits: AlignmentEdits): void;
|
||||
restart(): void;
|
||||
earthwork(context: MassHaulStageContext | null): void;
|
||||
earthwork(): void;
|
||||
cross(): void;
|
||||
reconcile(): Promise<number>;
|
||||
ownsCross: boolean;
|
||||
@@ -154,9 +148,8 @@ export const pipelineGrade = (projectId: string, edits: AlignmentEdits): void =>
|
||||
/** 저장 정본이 바뀜(새로 받은 상세 · 새 노선) — 처음부터 다시. 갈아끼운 상세 · 같은 상세 다시 그림은 안 셈. */
|
||||
export const restartPagePipeline = (projectId: string): void => pages.get(projectId)?.restart();
|
||||
|
||||
/** 유토곡선 설정(토량환산계수 · 자연방토 경사) — masshaul 단계 재료 · 표준단면이 막 왔으면 횡단 입력도. */
|
||||
export const pipelineEarthwork = (projectId: string, context: MassHaulStageContext | null): void =>
|
||||
pages.get(projectId)?.earthwork(context);
|
||||
/** 유토곡선 설정(표준단면 포함)이 막 옴 — 횡단 입력을 다시 먹임. */
|
||||
export const pipelineEarthwork = (projectId: string): void => pages.get(projectId)?.earthwork();
|
||||
|
||||
/** 횡단 고침(지반 유형 · 측구 · 암 · 소단 · 표준 — B06 · 365-2) — 세션 횡단 입력을 다시 모아 cross 부터. */
|
||||
export const pipelineCross = (projectId: string): void => pages.get(projectId)?.cross();
|
||||
@@ -195,7 +188,6 @@ function createPagePipeline(
|
||||
let enabled: PlanType[] = [];
|
||||
let savedStructures: PlanStructure[] = [];
|
||||
let savedPipes: PlanPipe[] = [];
|
||||
let earthwork: MassHaulStageContext | null = null;
|
||||
/** 저장 노선 측점(열쇠) · 마지막으로 모은 횡단 입력(시설 제원 — 새 노선 상세가 씀). */
|
||||
let savedStations: PipelineStation[] = [];
|
||||
let crossSource: PipelineCrossSource | null = null;
|
||||
@@ -280,7 +272,6 @@ function createPagePipeline(
|
||||
};
|
||||
const groundDeps = groundStageDeps(projectId);
|
||||
const crossStage = createCrossStage({ grid: groundDeps.grid }); // 미교차 넓힘 = 같은 지형 격자(349-0j)
|
||||
const haulStage = createHaulStage(projectId, () => savedSource().routeId ?? undefined);
|
||||
const followStage = createFollowStage({
|
||||
savedLine,
|
||||
saved: () => {
|
||||
@@ -379,8 +370,6 @@ function createPagePipeline(
|
||||
});
|
||||
},
|
||||
footprint: footprintStage,
|
||||
masshaul: createMassHaulStage(() => earthwork),
|
||||
haul: (ctx) => (ctx.inputs.route ? haulStage(ctx) : (ctx.prev ?? null)),
|
||||
drainage: link.side?.pipelineStage,
|
||||
corridor: (ctx) => {
|
||||
if (ctx.inputs.route) options.onCorridorStale?.();
|
||||
@@ -679,8 +668,7 @@ function createPagePipeline(
|
||||
// 갈아끼움 · 같은 상세 다시 그림은 안 셈 · 보기가 심은 측점은 묶음이 입력으로 셈(386-3)
|
||||
if (canonChanged()) void loadPlan().then(start);
|
||||
},
|
||||
earthwork(context) {
|
||||
earthwork = context;
|
||||
earthwork() {
|
||||
const cross = crossNow()?.input ?? null;
|
||||
if (started && cross) void feed("cross", cross);
|
||||
},
|
||||
|
||||
@@ -46,6 +46,11 @@ import {
|
||||
toAlignmentBase,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { createProfileEditStore } from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import {
|
||||
endOffsetsOf,
|
||||
withEndOffset,
|
||||
type ProfileEnd,
|
||||
} from "../../B05_Profile/logic/B05_Profile_EndOffsets";
|
||||
import {
|
||||
structureGroupMenuItems,
|
||||
stationIdAtStructure as stationIdAtStructureOf,
|
||||
@@ -280,6 +285,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
base: () => base,
|
||||
alignment: () => alignment,
|
||||
edits: () => store.edits(),
|
||||
savedRadii: () => store.savedRadii(),
|
||||
applyEdits: (next) => applyEdits(next),
|
||||
selectedStationId: () => selectedStationId,
|
||||
irregularStations: () => irregularStations,
|
||||
@@ -522,7 +528,7 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
*/
|
||||
setEarthworkContext(next: RouteMassHaulContext | null) {
|
||||
massHaul.setContext(next);
|
||||
pagePipeline.pipelineEarthwork(projectId, next);
|
||||
pagePipeline.pipelineEarthwork(projectId);
|
||||
drawSoon();
|
||||
},
|
||||
setSelectedStation(stationId: string | null) {
|
||||
@@ -607,6 +613,11 @@ export function createDesignProfile(ctx: DesignProfileContext) {
|
||||
if (key !== undefined) store.resetStation(Number(key), moveTo);
|
||||
},
|
||||
isDirty: () => store.dirty(),
|
||||
/** 시 · 종점 계획고 조정(448) — 첫 · 끝 측점 계획고 편집(m) · 기준이 없으면 null. */
|
||||
endOffsets: () => (base ? endOffsetsOf(base, store.edits()) : null),
|
||||
/** 시 · 종점 계획고 조정 칸 → 그 측점 편집(끝점 ▲▼ 와 같은 길 · 가드 거침). false = 막힘. */
|
||||
setEndOffset: (end: ProfileEnd, value: number): boolean =>
|
||||
!!base && applyEdits(withEndOffset(base, store.edits(), end, value)),
|
||||
/** 계산 묶음(346-1) — 저장 노선의 종단 기준 · 지금 계획고 편집. */
|
||||
pipelineSource: () => ({ base: listed(), edits: store.edits(), detail, routeId }),
|
||||
/** 노선 초안으로 셈한 첫 계획선 · 옮긴 계획고를 그림 — 초안 동안 편집은 노선 0 칸(저장 노선에
|
||||
|
||||
@@ -44,7 +44,7 @@ export function listedBase(
|
||||
);
|
||||
return {
|
||||
...storedBase,
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat),
|
||||
basePvi: seatBornNodes(storedBase, moved.nodes, moved.seat, moved.held),
|
||||
stations: list.map((station) => ({
|
||||
station_id: station.station_id,
|
||||
chainage_m: station.chainage_m,
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
* A00_Common/design/design_save.ts
|
||||
* B05 · B06 **저장 한 줄기**(계획서 24-1) — [저장] · [확정]이 어느 보기에서 눌려도 같은 차례.
|
||||
*
|
||||
* 노선 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
|
||||
* 노선 → 페이지 설정 → 관 → 구조물 → 추가 측점 → 계획고 → 암거 옵션 → 오르막(상단측) → 횡단
|
||||
*
|
||||
* 노선(346-3 · [노선 반영] 없앰) — 노선 초안이 마지막 저장과 다를 때만 맨 앞에서 `POST /route/replan`
|
||||
* (draft = 계약 `RouteDraft`). 서버가 노선 → 관 · 구조물 따라감 → 종횡단 → 계획고 · 횡단 이월까지 돌고
|
||||
* 새 노선 번호를 준다. 그 뒤 차례는 **새 노선**으로: 관 · 구조물 초안(좌표 정본) · 계획고 초안(옛 노선 칸 →
|
||||
* 새 노선) · 횡단 초안은 측점 열쇠로 넘겨(`carryStationDrafts`) 저장 · 결과 한 줄 · 페이지 다시 세움.
|
||||
* 노선 저장이 실패하면 서버가 되살리고 여기서 멈춤(다른 초안도 화면 · 세션에 그대로 · 오류 한 줄).
|
||||
* 노선 저장 뒤 다른 차례가 실패하면 초안을 새 노선 칸으로 옮기고 페이지를 새 노선으로(433-12).
|
||||
* 어느 차례든 실패하면 그 차례에서 멈추고 차례 이름째 알림(429 ⑨ · 뒤 차례는 안 보냄).
|
||||
* 노선을 저장했으면 횡단은 고침이 없어도 한 번 나감 — 뒤에 나간 계획고 초안을 서버가 횡단에 다시 셈(429 ④).
|
||||
*
|
||||
@@ -38,8 +39,10 @@ import {
|
||||
import {
|
||||
markAlignmentSaved,
|
||||
pendingAlignment,
|
||||
writeAlignmentDraft,
|
||||
} from "../../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { flushCulvertOptions } from "../../B06_Section/B06_Section_Api_Culvert_Options";
|
||||
import { flushPageSettings } from "../../B05_Profile/B05_Profile_Api_PageSettings";
|
||||
import { clearState, stateKey } from "../b_page_state";
|
||||
import {
|
||||
carryAfterRouteSave,
|
||||
@@ -52,6 +55,7 @@ import {
|
||||
/** 저장 차례 — 이 순서 그대로 돈다(`route` 는 노선 초안이 있을 때만). */
|
||||
export const DESIGN_SAVE_ORDER = [
|
||||
"route",
|
||||
"settings",
|
||||
"pipes",
|
||||
"structures",
|
||||
"plusStations",
|
||||
@@ -70,7 +74,7 @@ export interface DesignSaveContext {
|
||||
prepare?: () => Promise<void>;
|
||||
/**
|
||||
* 차례마다 **기본 창구 대신** 도는 일. 없으면 기본 창구(세션 초안 → 정본)가 돈다.
|
||||
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(화면 목록으로 한 번 더 맞춤).
|
||||
* `pipes` 만 예외 — 기본(세션 초안)이 먼저 나가고 그 뒤에 돈다(저장분으로 화면만 맞춤 · PUT 없음 · 433-7).
|
||||
*/
|
||||
steps?: Partial<Record<Exclude<DesignSaveStep, "cross" | "route">, () => Promise<void>>>;
|
||||
/** 횡단 — 보기마다 본문이 다르다. `confirm` 이면 단계를 닫는 길로. */
|
||||
@@ -92,6 +96,7 @@ export function savingDesign(projectId: string | null): boolean {
|
||||
* 초안은 세션에 남아 다음 [저장]에서 다시 나감). */
|
||||
const STEP_LABEL: Record<DesignSaveStep, string> = {
|
||||
route: "노선",
|
||||
settings: "페이지 설정",
|
||||
pipes: "배수관",
|
||||
structures: "구조물",
|
||||
plusStations: "추가 측점",
|
||||
@@ -140,16 +145,37 @@ async function flushAlignmentDraft(
|
||||
if (moved) markAlignmentSaved(DRAFT_ROUTE_ID);
|
||||
}
|
||||
|
||||
/** 노선은 저장했는데 뒤 차례가 실패 — 계획고 초안(노선 초안 칸 · 옛 노선 칸)을 새 노선 칸에 · 횡단 초안을 새
|
||||
* 측점 열쇠로(저장 없이 · 이미 넘겼으면 안 함) · 페이지를 새 노선으로 다시 세움(433-12). 다음 [저장]이 그대로 보냄. */
|
||||
async function keepDraftsOnNewRoute(
|
||||
projectId: string,
|
||||
fromRouteId: number,
|
||||
routeId: number,
|
||||
carried: boolean,
|
||||
): Promise<void> {
|
||||
const draft = pendingAlignment(DRAFT_ROUTE_ID) ?? pendingAlignment(fromRouteId);
|
||||
if (draft) {
|
||||
writeAlignmentDraft(routeId, draft);
|
||||
markAlignmentSaved(DRAFT_ROUTE_ID);
|
||||
markAlignmentSaved(fromRouteId);
|
||||
}
|
||||
if (!carried) await carryAfterRouteSave(projectId, fromRouteId, routeId, { save: false });
|
||||
await rebuildAfterRouteSave(projectId);
|
||||
}
|
||||
|
||||
/** 차례마다 기본 창구. */
|
||||
function defaultStep(
|
||||
step: Exclude<DesignSaveStep, "cross" | "route">,
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
fromRouteId: number = routeId,
|
||||
confirm = false,
|
||||
): () => Promise<void> {
|
||||
switch (step) {
|
||||
case "settings": // 노선 뒤 — 노선 저장이 새로 만든 종단에도 경고 기준이 얹힘(448)
|
||||
return () => flushPageSettings(projectId);
|
||||
case "pipes":
|
||||
return () => flushPendingPipes(projectId);
|
||||
return () => flushPendingPipes(projectId, confirm);
|
||||
case "structures":
|
||||
return () => flushPendingStructures(projectId);
|
||||
case "plusStations": // 계획고 앞 — 추가 측점 자리의 계획고 편집이 버려지지 않게(368-2)
|
||||
@@ -197,6 +223,8 @@ export async function saveDesign(
|
||||
let routeId = fromRouteId;
|
||||
/** 노선을 새로 저장했으면 그 결과 — 뒤 차례는 새 노선 · 화면 몫(옛 노선 판) 일은 건너뜀. */
|
||||
let routeSaved: Awaited<ReturnType<typeof saveRouteDraft>> | null = null;
|
||||
/** 횡단 초안을 새 노선으로 넘기기 시작함 — 실패 길에서 두 번 넘기지 않게(누가거리 칸은 제자리에서 옮김). */
|
||||
let carried = false;
|
||||
showLoadingOverlay();
|
||||
saving.add(ctx.projectId);
|
||||
try {
|
||||
@@ -215,6 +243,7 @@ export async function saveDesign(
|
||||
if (step === "cross") {
|
||||
if (!routeSaved) await ctx.cross(confirm);
|
||||
else {
|
||||
carried = true;
|
||||
const outside = await carryAfterRouteSave(ctx.projectId, fromRouteId, routeId);
|
||||
showToast(routeSaveSummary(routeSaved, outside), "info");
|
||||
}
|
||||
@@ -224,7 +253,7 @@ export async function saveDesign(
|
||||
if (step === "uphill" && routeSaved) continue;
|
||||
// 노선이 바뀌면 화면 몫 일(옛 노선 판 목록 · 계획고 판)은 건너뛰고 기본 창구만 새 노선으로.
|
||||
const own = routeSaved ? undefined : ctx.steps?.[step];
|
||||
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId);
|
||||
const base = defaultStep(step, ctx.projectId, routeId, fromRouteId, confirm);
|
||||
const run =
|
||||
step === "pipes"
|
||||
? async () => {
|
||||
@@ -250,6 +279,10 @@ export async function saveDesign(
|
||||
await ctx.done?.(confirm);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (routeSaved)
|
||||
await keepDraftsOnNewRoute(ctx.projectId, fromRouteId, routeId, carried).catch(
|
||||
() => undefined, // 못 옮김 — 초안은 옛 칸에 남음(알림은 아래 한 줄)
|
||||
);
|
||||
ctx.failed(error, confirm);
|
||||
return false;
|
||||
} finally {
|
||||
|
||||
@@ -394,14 +394,9 @@ export async function saveSharedDesign(shared: DesignPageShared, confirm: boolea
|
||||
routeId: () => routeIdOf(shared),
|
||||
prepare: crossSave?.prepare,
|
||||
steps: {
|
||||
// 세션 초안이 나간 뒤 배수유역도 목록으로 한 번 더 맞춘다(시설 되붙이기 · 응답으로 화면 갱신).
|
||||
...(side
|
||||
? {
|
||||
pipes: async () => {
|
||||
await side.savePipes(confirm);
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
// 관 정본 PUT 은 세션 초안 한 번(기본 창구) — 배수유역도는 저장분을 다시 받아 화면만 맞춤(433-7 · 판
|
||||
// 메모리를 다시 보내면 B06 고침 직후 · 판이 안 선 [저장]에서 관을 잃음).
|
||||
...(side ? { pipes: async () => side.reload(confirm) } : {}),
|
||||
// 계획고 초안 — 한 벌 종단도가 저장하고 다시 그린다.
|
||||
...(profile ? { alignment: () => profile.save() } : {}),
|
||||
...profileSave?.steps,
|
||||
|
||||
@@ -126,17 +126,15 @@ export function createProfileSections(deps: ProfileSectionsDeps) {
|
||||
try {
|
||||
renderSections(detail, routeId);
|
||||
// 구 확정분에 남은 비정규 측점(자유 텍스트)을 구조물 정본으로 1회 이관한다 — 멱등이지만
|
||||
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측).
|
||||
const legacy = detail.longitudinal.stations
|
||||
.filter((station) => station.kind === "irregular")
|
||||
.map((station) => ({
|
||||
chainage_m: station.chainage_m,
|
||||
structure: station.structure ?? "",
|
||||
}));
|
||||
// 탭 수명 동안 노선마다 한 번만 시도한다(2026-08-17 · 09-06 실측). 측점은 서버가 저장
|
||||
// 종단 파일에서 읽음 — 이 상세는 초안이 섞일 수 있어 보내지 않음(452).
|
||||
const hasLegacy = detail.longitudinal.stations.some(
|
||||
(station) => station.kind === "irregular",
|
||||
);
|
||||
const migrateKey = `${projectId}:${routeId}`;
|
||||
if (legacy.length && !migratedRoutes.has(migrateKey)) {
|
||||
if (hasLegacy && !migratedRoutes.has(migrateKey)) {
|
||||
migratedRoutes.add(migrateKey);
|
||||
void migrateLegacyStations(projectId, legacy)
|
||||
void migrateLegacyStations(projectId, routeId)
|
||||
.then((result) => {
|
||||
if (result.migrated > 0) {
|
||||
showToast(`구 구조물 측점 ${result.migrated}건을 구조물 목록으로 옮겼습니다.`);
|
||||
|
||||
@@ -39,10 +39,9 @@ export const BROWSER_STAGES: StageName[] = [
|
||||
"profile",
|
||||
"cross",
|
||||
"footprint",
|
||||
"masshaul",
|
||||
];
|
||||
/** 뒤 — 브라우저 단계가 끝나면 함께 띄우고 기다리지 않음. */
|
||||
export const AFTER_STAGES: StageName[] = ["haul", "drainage", "corridor"];
|
||||
export const AFTER_STAGES: StageName[] = ["drainage", "corridor"];
|
||||
|
||||
type Dep = StageName | `input.${InputKey}`;
|
||||
|
||||
@@ -56,15 +55,13 @@ export const STAGE_DEPS: Record<StageName, Dep[]> = {
|
||||
profile: ["planline", "input.alignment"],
|
||||
cross: ["profile", "ground", "input.cross", "input.pipes", "input.structures"],
|
||||
footprint: ["cross", "ground"],
|
||||
masshaul: ["cross"],
|
||||
haul: ["masshaul"],
|
||||
drainage: ["route", "input.pipes"],
|
||||
corridor: ["profile", "cross"],
|
||||
};
|
||||
|
||||
/** follow 가 고칠 수 있는 입력 — 노선 · 횡단은 안 됨(고리). */
|
||||
const FOLLOW_KEYS: ReadonlyArray<keyof FollowPatch> = ["pipes", "structures", "alignment"];
|
||||
/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단 · 유토곡선이 그다음. */
|
||||
/** 이 단계 뒤 한 번 쉼 — 종단이 먼저 그려지고 횡단이 그다음. */
|
||||
const YIELD_AFTER = new Set<StageName>(["profile"]);
|
||||
export const RUN_LOG_SIZE = 20;
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
* plusStations(추가 측점 · 429 ⑨)
|
||||
* ↓ update(출처, 값, 범위) — 조작마다 차례 번호(generation) +1 · 옛 계산 · 옛 요청 버림
|
||||
* 브라우저 즉시(차례대로 · 단계 사이마다 번호 확인 · 단계마다 결과 바로 내보냄)
|
||||
* route → follow → stations → ground → planline → profile → cross → footprint → masshaul
|
||||
* route → follow → stations → ground → planline → profile → cross → footprint
|
||||
* 뒤(기다리지 않음 · 새 조작이 오면 AbortSignal 로 끊김 · 옛 번호 결과 버림)
|
||||
* haul(흙 배분 · 서버 Node) · drainage(배수유역 「낡음」 표시) · corridor(3D 「낡음」 표시)
|
||||
* drainage(배수유역 「낡음」 표시) · corridor(3D 「낡음」 표시)
|
||||
*
|
||||
* 규칙:
|
||||
* · 단계는 자기 출력만 돌려줌 — 입력 · 다른 단계 출력을 고치지 않음(ctx 의 값은 읽기만).
|
||||
@@ -31,8 +31,6 @@
|
||||
* | profile | sub1 | planline · input.alignment | ProfileAlignment |
|
||||
* | cross | sub2 | profile · ground · input.cross · pipes · structures | CrossOutput |
|
||||
* | footprint | sub6 | cross · ground | PlanFootprint |
|
||||
* | masshaul | sub2 | cross | unknown(sub2 모양) |
|
||||
* | haul | sub2 | masshaul | unknown(서버 응답) |
|
||||
* | drainage | sub3 | route · input.pipes | 「낡음」 표시(출력 안 씀) |
|
||||
* | corridor | sub5 | profile · cross | 「낡음」 표시(출력 안 씀) |
|
||||
*
|
||||
@@ -238,8 +236,6 @@ export interface PipelineOutputs {
|
||||
profile: ProfileAlignment;
|
||||
cross: CrossOutput;
|
||||
footprint: PlanFootprint;
|
||||
masshaul: unknown;
|
||||
haul: unknown;
|
||||
drainage: null;
|
||||
corridor: null;
|
||||
}
|
||||
|
||||
@@ -416,6 +416,10 @@ def redesign_solve_request(
|
||||
**{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"),
|
||||
# 설계속도 · 최소고 강제 · 시작 측점도 옮김 — 빠지면 노선 저장마다 20 · 꺼짐으로(448 E1)
|
||||
design_speed_kph=options.get("design_speed_kph"),
|
||||
enforce_pipe_clearance=params.get("enforce_pipe_clearance"),
|
||||
station_display=params.get("station_display"),
|
||||
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"),
|
||||
|
||||
@@ -588,6 +588,8 @@ export interface DetailBasinResponse {
|
||||
upstream_lonlat: Array<Array<[number, number]>>;
|
||||
/** [저장] 때 못 만든 구조물 조합 사본 폴더(142-2 · `*` = 통째 실패). */
|
||||
snapshot_failed?: string[];
|
||||
/** 관 PUT 끝 측점 맞춤 실패(433-6 · 관 저장은 그대로). */
|
||||
stations_failed?: boolean;
|
||||
/** 지문(342-3) — 지금 노선 · 관 자리. */
|
||||
route_signature?: string;
|
||||
pipe_signature?: string;
|
||||
@@ -645,15 +647,21 @@ export async function resetDetailPipePoints(projectId: string): Promise<DetailBa
|
||||
/** 관 매설 지점을 정본으로 확정하고 세부유역 산출물까지 남긴다(모델 확정 시점). */
|
||||
export async function saveDetailPipePoints(
|
||||
projectId: string,
|
||||
points: DetailPipeInput[],
|
||||
points: DetailPipeInput[] | null,
|
||||
recompute = false,
|
||||
): Promise<DetailBasinResponse> {
|
||||
// [확정](recompute) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈(347-2) — 오래 걸림.
|
||||
// points null = 칸 없이 — 서버 저장분 그대로(433-7).
|
||||
const saved = await requestJson<DetailBasinResponse>(
|
||||
`/projects/${projectId}/drainage/pipe-points${recompute ? "?recompute=true" : ""}`,
|
||||
{ method: "PUT", body: JSON.stringify({ points }) },
|
||||
{ method: "PUT", body: JSON.stringify(points ? { points } : {}) },
|
||||
recompute ? API_ANALYSIS_TIMEOUT_MS : API_TIMEOUT_MS,
|
||||
);
|
||||
noteSnapshotFailed(saved.snapshot_failed);
|
||||
if (saved.stations_failed)
|
||||
showToast(
|
||||
"관은 저장했지만 관 자리 측점을 못 맞췄습니다 — 횡단 「측점 없는 관」 알림에서 [측점 만들기]를 눌러 주세요.",
|
||||
"error",
|
||||
);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -571,11 +571,12 @@ async def delete_pipe_points(project_id: UUID) -> dict[str, Any] | JSONResponse:
|
||||
return _payload(project_id, context, detail, points, False, stale)
|
||||
|
||||
|
||||
async def _stand_pipe_stations(project_id: UUID) -> None:
|
||||
async def _stand_pipe_stations(project_id: UUID) -> bool:
|
||||
"""관 PUT 끝 — 측점을 지금 관 자리로 다시 세움(`put_plus_stations` 처럼 · 부르는 길 모두 · 428).
|
||||
|
||||
노선 [저장] 뒤 관 차례가 화면 누가거리로 관을 덮으면 서버가 노선 저장 때 세운 측점과 어긋남 —
|
||||
여기서 맞추지 않으면 「측점이 없는 구조물」 · 옛 자리 카드가 남음. 실패해도 관 저장은 그대로.
|
||||
여기서 맞추지 않으면 「측점이 없는 구조물」 · 옛 자리 카드가 남음. 실패해도 관 저장은 그대로 ·
|
||||
실패 = 거짓(응답 `stations_failed` 로 알림 · 433-6).
|
||||
"""
|
||||
from B06_Section.B06_Section_Router_Stations import ensure_structure_stations
|
||||
|
||||
@@ -583,6 +584,8 @@ async def _stand_pipe_stations(project_id: UUID) -> None:
|
||||
await ensure_structure_stations(project_id)
|
||||
except Exception:
|
||||
logger.exception("관 저장: 측점 맞춤 실패(저장은 그대로): project_id=%s", project_id)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@router.put("/{project_id}/drainage/pipe-points", response_model=None)
|
||||
@@ -627,8 +630,9 @@ async def put_pipe_points(
|
||||
failed = await asyncio.to_thread(
|
||||
safe_sync, resolve_stored_project_path(context.stored_path), importer(session)
|
||||
)
|
||||
await _stand_pipe_stations(project_id)
|
||||
stood = await _stand_pipe_stations(project_id)
|
||||
result = _payload(project_id, context, detail, points, True, stale)
|
||||
result["saved_count"] = saved
|
||||
result["snapshot_failed"] = failed # 못 만든 구조물 조합 복사본(142-2) — [저장] 알림
|
||||
result["stations_failed"] = not stood # 측점 맞춤 실패(433-6) — [저장] 알림
|
||||
return result
|
||||
|
||||
@@ -147,6 +147,8 @@ export interface RouteLatestResponse {
|
||||
start_elevation_offset_m?: number | null;
|
||||
end_elevation_offset_m?: number | null;
|
||||
enforce_pipe_clearance?: boolean | null;
|
||||
/** 시작 측점 · 누가거리 표시(448) — B05 라벨만. */
|
||||
station_display?: { station: number; cumulative: number } | null;
|
||||
} | null;
|
||||
}
|
||||
|
||||
@@ -244,6 +246,28 @@ export async function saveProfileAlignment(
|
||||
);
|
||||
}
|
||||
|
||||
/** B05 페이지 설정(448) — 사용자가 넣은 값 그대로(빈 칸 = null = 법정값). */
|
||||
export interface PageSettingsBody {
|
||||
design_speed_kph: 20 | 30 | 40;
|
||||
terrain_type: "normal" | "special";
|
||||
max_grade_pct: number | null;
|
||||
min_vertical_radius_m: number | null;
|
||||
min_tangent_length_m: number | null;
|
||||
enforce_pipe_clearance: boolean;
|
||||
station_display: { station: number; cumulative: number };
|
||||
}
|
||||
|
||||
/** 페이지 설정을 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로). */
|
||||
export async function savePageSettings(
|
||||
projectId: string,
|
||||
body: PageSettingsBody,
|
||||
): Promise<{ warnings: string[]; profile_updated: boolean }> {
|
||||
return requestJson(`/projects/${projectId}/route/page-settings`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
/** 확정 시 비정규 측점 횡단을 생성하기 위한 입력(빈 값이면 확정만 한다). */
|
||||
export interface RouteConfirmRequest {
|
||||
filter_key?: string;
|
||||
|
||||
@@ -12,8 +12,6 @@
|
||||
|
||||
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/logic/design_pipeline_types";
|
||||
import type { MassHaulStageOutput } from "./logic/B05_Profile_MassHaul_Stage";
|
||||
|
||||
/** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다.
|
||||
* `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */
|
||||
@@ -26,7 +24,7 @@ const TIMEOUT_MS = 20000;
|
||||
/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */
|
||||
const KEPT_PLANS = 8;
|
||||
|
||||
/** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입 · 묶음 단계가 함께 씀. */
|
||||
/** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입이 씀. */
|
||||
const kept = new Map<string, HaulPlanPayload>();
|
||||
|
||||
/**
|
||||
@@ -86,24 +84,6 @@ async function postHaulPlan(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 계산 묶음 `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;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Api_PageSettings.ts
|
||||
* B05 「페이지 설정」 초안(448) — 세션 칸 `pagesettings` · [저장]의 `settings` 차례(노선 뒤 · 계획고 앞).
|
||||
*
|
||||
* 설계속도 · 지형 · 기준값 · 최소고 강제 · 시작 측점을 조작 즉시 세션에 쌓고 [저장] · [확정]에서만
|
||||
* 서버로(자동저장 없음). 서버가 stage 2 params 에 얹고 종단 경고 기준을 갈아 끼운다(기하 그대로).
|
||||
* 시작 측점은 B05 표시 전용 — 측점 열쇠 · 다른 페이지 라벨에 안 씀.
|
||||
* 시 · 종점 계획고 조정은 여기 없음 — 첫 · 끝 측점 계획고 편집(`alignment` 차례)이 정본.
|
||||
* ========================================================================== */
|
||||
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import {
|
||||
readRouteLatestCache,
|
||||
savePageSettings,
|
||||
writeRouteLatestCache,
|
||||
type PageSettingsBody,
|
||||
} from "./B05_Profile_Api_Fetch";
|
||||
import type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields";
|
||||
|
||||
export type PageSettingsDraft = PageSettingsBody;
|
||||
|
||||
/** 좌측 폼 값 → 초안(사용자가 넣은 값 그대로 · 빈 칸 = null). */
|
||||
export const pageSettingsOf = (values: RoutePanelValues): PageSettingsDraft => ({
|
||||
design_speed_kph: values.designSpeed,
|
||||
terrain_type: values.terrainType,
|
||||
max_grade_pct: values.maxGradePct,
|
||||
min_vertical_radius_m: values.minVerticalRadius,
|
||||
min_tangent_length_m: values.minTangentLength,
|
||||
enforce_pipe_clearance: values.enforcePipeClearance,
|
||||
station_display: values.stationDisplay,
|
||||
});
|
||||
|
||||
/** 초안 → 좌측 폼 되살리기 값(최신 응답 위에 얹음). */
|
||||
export const panelValuesOfDraft = (draft: PageSettingsDraft): Partial<RoutePanelValues> => ({
|
||||
designSpeed: draft.design_speed_kph,
|
||||
terrainType: draft.terrain_type,
|
||||
maxGradePct: draft.max_grade_pct,
|
||||
minVerticalRadius: draft.min_vertical_radius_m,
|
||||
minTangentLength: draft.min_tangent_length_m,
|
||||
enforcePipeClearance: draft.enforce_pipe_clearance,
|
||||
stationDisplay: draft.station_display,
|
||||
});
|
||||
|
||||
export const readPageSettings = (projectId: string): PageSettingsDraft | null =>
|
||||
readState<PageSettingsDraft>("pagesettings", projectId);
|
||||
|
||||
export const writePageSettings = (projectId: string, value: PageSettingsDraft): void =>
|
||||
writeState("pagesettings", value, projectId);
|
||||
|
||||
/** [저장] `settings` 차례 — 초안이 없으면 안 보냄 · 성공하면 최신 응답 캐시에 얹고 초안을 비움
|
||||
* (실패 = 던짐 · 초안 그대로 다음 [저장]에서 다시). */
|
||||
export async function flushPageSettings(projectId: string): Promise<void> {
|
||||
const draft = readPageSettings(projectId);
|
||||
if (!draft) return;
|
||||
await savePageSettings(projectId, draft);
|
||||
const latest = readRouteLatestCache(projectId);
|
||||
if (latest?.route_params) {
|
||||
const { design_speed_kph, terrain_type, ...top } = draft;
|
||||
latest.route_params = {
|
||||
...latest.route_params,
|
||||
...top,
|
||||
options: { ...latest.route_params.options, design_speed_kph, terrain_type },
|
||||
};
|
||||
writeRouteLatestCache(projectId, latest);
|
||||
}
|
||||
clearState("pagesettings", projectId);
|
||||
}
|
||||
@@ -27,10 +27,11 @@ export function writePendingPipes(projectId: string, next: PendingPipes | null):
|
||||
useDesignStore(projectId, null).set("pipes", next);
|
||||
}
|
||||
|
||||
/** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */
|
||||
export async function flushPendingPipes(projectId: string): Promise<void> {
|
||||
/** 초안이 있으면 정본에 쓰고 비운다 — 관 정본 PUT 은 이 한 번뿐(433-7 · 배수판 메모리는 안 보냄). 빈 목록 =
|
||||
* 사용자가 다 지움. 초안이 없으면 [저장]은 아무 일도 안 함 · [확정]은 `points` 없이(서버 저장분으로 다시 셈). */
|
||||
export async function flushPendingPipes(projectId: string, confirm = false): Promise<void> {
|
||||
const pending = readPendingPipes(projectId);
|
||||
if (!pending) return;
|
||||
await saveDetailPipePoints(projectId, pending);
|
||||
if (!pending && !confirm) return;
|
||||
await saveDetailPipePoints(projectId, pending, confirm);
|
||||
writePendingPipes(projectId, null);
|
||||
}
|
||||
|
||||
@@ -313,14 +313,15 @@ export async function saveStructures(
|
||||
}
|
||||
|
||||
/** 구 비정규 측점(자유 텍스트)을 구조물 정본으로 이관한다. 멱등 — 배관은 관 지점
|
||||
* 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다. */
|
||||
* 정본 소관이라 서버가 걸러내고, 이미 정본에 있는 (타입, 위치)는 건너뛴다.
|
||||
* 측점은 서버가 저장 종단 파일에서 읽음 — 화면 상세(초안 섞임)를 안 보냄(452). */
|
||||
export async function migrateLegacyStations(
|
||||
projectId: string,
|
||||
stations: Array<{ chainage_m: number; structure: string }>,
|
||||
routeId: number,
|
||||
): Promise<{ status: string; migrated: number; revision: number }> {
|
||||
return requestJson(`/projects/${projectId}/route/structures/migrate`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ stations }),
|
||||
body: JSON.stringify({ route_id: routeId }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,12 @@ class AlignmentPolicy:
|
||||
max_grade_pct=float(payload.get("max_grade_pct") or 9.0),
|
||||
curve_skip_delta_pct=float(payload.get("curve_skip_delta_pct") or 5.0),
|
||||
paved=bool(payload.get("paved", False)),
|
||||
# 평면 R 경고 기준도 물려받음 — 빠지면 계획고 저장마다 0(판정 안 함)으로(448)
|
||||
min_plan_radius_m=float(payload.get("min_plan_radius_m") or 0.0),
|
||||
hairpin_min_radius_m=float(
|
||||
payload.get("hairpin_min_radius_m")
|
||||
or FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"]
|
||||
),
|
||||
min_vertical_radius_m=float(payload.get("min_vertical_radius_m") or 0.0),
|
||||
min_curve_length_m=float(payload.get("min_curve_length_m") or 0.0),
|
||||
)
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
from collections import Counter
|
||||
from collections.abc import Callable
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
@@ -367,11 +368,13 @@ def design_pipe_anchored_profile(
|
||||
# 높이로, 정본 산식은 `common_util_drainage_pipes.facility_clearance_m`이다.
|
||||
# 시·종점만 오프셋을 얹는다.
|
||||
target = np.interp(base_s, chainage, ground)
|
||||
lifted = np.zeros(len(base_s), dtype=bool)
|
||||
if pipe_clearances:
|
||||
for index, value in enumerate(base_s):
|
||||
clearance = _clearance_at(pipe_clearances, float(value))
|
||||
if clearance > 0:
|
||||
target[index] += clearance
|
||||
lifted[index] = True
|
||||
target[0] = fixed[0]
|
||||
target[-1] = fixed[1]
|
||||
|
||||
@@ -380,12 +383,15 @@ def design_pipe_anchored_profile(
|
||||
edits = dict(edits or {})
|
||||
user_radii = dict(edits.get("curve_radii") or {})
|
||||
|
||||
def radii_for(nodes_z: np.ndarray) -> dict[str, float]:
|
||||
"""현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다)."""
|
||||
def radii_for(nodes_z: np.ndarray, held: np.ndarray) -> dict[str, float]:
|
||||
"""현 표고에서의 대수차로 R = L / A 를 역산한다(사용자 지정 R이 있으면 그쪽이 이긴다) ·
|
||||
직선 위로 옮긴(`held`) 꼭짓점은 R 없음."""
|
||||
spans = base_s[1:] - base_s[:-1]
|
||||
grades = (nodes_z[1:] - nodes_z[:-1]) / np.where(spans > 0, spans, 1.0)
|
||||
resolved: dict[str, float] = {}
|
||||
for index in range(1, len(base_s) - 1):
|
||||
if held[index]:
|
||||
continue
|
||||
delta = abs(float(grades[index] - grades[index - 1]))
|
||||
radius = target_length / delta if delta > 1e-9 else min_radius
|
||||
resolved[chainage_key(base_s[index])] = max(radius, min_radius)
|
||||
@@ -394,19 +400,28 @@ def design_pipe_anchored_profile(
|
||||
# 대칭 종단곡선은 꼭짓점(변화점)을 지나지 않는다 — 곡선이 배관 지반고를 통과하도록
|
||||
# 변화점 표고를 반복 보정한다(호가 안쪽으로 파고드는 중앙종거만큼 꼭짓점을 밀어낸다).
|
||||
# 표고가 움직이면 대수차도 변하므로 R도 매 회 다시 역산한다.
|
||||
base_z = target.copy()
|
||||
radii = radii_for(base_z)
|
||||
for _ in range(12):
|
||||
radii = radii_for(base_z)
|
||||
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii)
|
||||
plan_at = evaluate(base_s, base_z, curves, base_s)
|
||||
error = target - plan_at
|
||||
error[0] = 0.0
|
||||
error[-1] = 0.0
|
||||
if float(np.max(np.abs(error))) < 1e-4:
|
||||
# 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 관 꼭짓점 묶음은 앞뒤 꺾임을 잇는
|
||||
# 직선 위(높이 한도 없음 · 요구 269-2) · 경계가 새로 R > 500 m 가 되면 묶음을 늘려 다시 ·
|
||||
# 시설 여유로 들린 관 꼭짓점은 그대로(직선화 경계).
|
||||
held = np.zeros(len(base_s), dtype=bool)
|
||||
for _ in range(len(base_s)):
|
||||
base_z = straighten_held(base_s, target, held)
|
||||
for _ in range(12):
|
||||
radii = radii_for(base_z, held)
|
||||
curves, _curve_warnings = build_curves(base_s, base_z, policy, radii)
|
||||
plan_at = evaluate(base_s, base_z, curves, base_s)
|
||||
error = target - plan_at
|
||||
error[0] = 0.0
|
||||
error[-1] = 0.0
|
||||
error[held] = 0.0
|
||||
if float(np.max(np.abs(error))) < 1e-4:
|
||||
break
|
||||
base_z = straighten_held(base_s, base_z + error, held)
|
||||
flat = flat_bends(base_s, base_z, policy.default_curve_length_m) & ~held & ~lifted
|
||||
if not flat.any():
|
||||
break
|
||||
base_z = base_z + error
|
||||
edits["curve_radii"] = radii
|
||||
held = held | flat
|
||||
edits["curve_radii"] = radii_for(base_z, held)
|
||||
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
@@ -498,6 +513,14 @@ def design_alignment_profile(
|
||||
down_limit,
|
||||
balance_tolerance_percent=policy.balance_tolerance_percent,
|
||||
)
|
||||
# 초기값 직선화(438 · 433-13②) — 기본 L 기준 R > 500 m 꼭짓점 묶음은 앞뒤 꺾임을 잇는 직선 위
|
||||
held = np.zeros(len(base_s), dtype=bool)
|
||||
flat = flat_bends(base_s, base_z, policy.default_curve_length_m)
|
||||
while flat.any():
|
||||
held = held | flat
|
||||
straight = straighten_held(base_s, base_z, held)
|
||||
flat = flat_bends(base_s, straight, policy.default_curve_length_m) & ~held
|
||||
base_z = straighten_held(base_s, base_z, held)
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
@@ -531,6 +554,16 @@ def rebuild_alignment_profile(
|
||||
policy = AlignmentPolicy.from_dict(stored.get("policy") or {})
|
||||
|
||||
previous = (longitudinal.get("design_profiles") or [{}])[0]
|
||||
criteria = previous.get("criteria") or {}
|
||||
# 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로
|
||||
# 채움(433-13 · TS `readAlignment` 짝)
|
||||
policy = replace(
|
||||
policy,
|
||||
min_vertical_radius_m=policy.min_vertical_radius_m
|
||||
or float(criteria.get("min_vertical_radius_m") or 0.0),
|
||||
min_curve_length_m=policy.min_curve_length_m
|
||||
or float(criteria.get("min_curve_length_m") or 0.0),
|
||||
)
|
||||
alignment = build_alignment(
|
||||
base_s=base_s,
|
||||
base_z=base_z,
|
||||
@@ -540,7 +573,6 @@ def rebuild_alignment_profile(
|
||||
policy=policy,
|
||||
edits=edits,
|
||||
)
|
||||
criteria = previous.get("criteria") or {}
|
||||
options = GradeDesignOptions(
|
||||
max_grade_pct=float(criteria.get("max_grade_pct") or policy.max_grade_pct),
|
||||
max_reverse_grade_pct=float(criteria.get("max_reverse_grade_pct") or 5.0),
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
"""B05 페이지 설정 저장(448) — stage 2 params 에 얹을 값 · 종단 기준 갈아 끼우기 · 옛 스냅샷 걷기.
|
||||
|
||||
설계속도 · 지형 · 기준값은 **경고 기준만** 바꾼다 — 기하(base_pvi · 편집)는 그대로 · 종횡단 다시
|
||||
만들기 없음. params 에는 사용자가 넣은 원값만 둔다(해석값 저장 금지 — `resolve_grade_options`).
|
||||
시작 측점은 B05 표시 전용 — 저장만 하고 측점 열쇠 · 다른 페이지 라벨에 쓰지 않는다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import (
|
||||
GradeDesignOptions,
|
||||
legal_plan_radius_min_m,
|
||||
resolve_grade_options,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
|
||||
#: params["options"] 안의 페이지 설정 칸
|
||||
PAGE_OPTION_KEYS = ("design_speed_kph", "terrain_type")
|
||||
#: params 맨 위의 페이지 설정 칸(빈 값 = 법정값)
|
||||
PAGE_TOP_KEYS = (
|
||||
"max_grade_pct",
|
||||
"min_vertical_radius_m",
|
||||
"min_tangent_length_m",
|
||||
"enforce_pipe_clearance",
|
||||
"station_display",
|
||||
)
|
||||
_STORED_KEYS = ("max_grade_pct", "min_vertical_radius_m", "min_tangent_length_m")
|
||||
|
||||
|
||||
def params_patch(request: Any) -> dict[str, Any]:
|
||||
"""요청 → stage 2 params 에 얹을 값(원값 그대로)."""
|
||||
return {
|
||||
"options": {key: getattr(request, key) for key in PAGE_OPTION_KEYS},
|
||||
**{key: getattr(request, key) for key in PAGE_TOP_KEYS},
|
||||
}
|
||||
|
||||
|
||||
def strip_page_settings(params: dict[str, Any] | None) -> dict[str, Any] | None:
|
||||
"""페이지 설정 칸만 걷어 기본값으로 — stage 2 params 를 안 뜬 옛 스냅샷의 [초기화]."""
|
||||
if not isinstance(params, dict):
|
||||
return params
|
||||
stripped = {key: value for key, value in params.items() if key not in PAGE_TOP_KEYS}
|
||||
options = params.get("options")
|
||||
if isinstance(options, dict):
|
||||
stripped["options"] = {k: v for k, v in options.items() if k not in PAGE_OPTION_KEYS}
|
||||
return stripped
|
||||
|
||||
|
||||
def grade_class_of(road_type: str | None, params: dict[str, Any]) -> str:
|
||||
"""기준 등급 — 노선 풀기가 남긴 값 · 없으면 B02 임도 종류(main → trunk · 그 밖 = work)."""
|
||||
stored = (params.get("options") or {}).get("grade_class")
|
||||
if stored:
|
||||
return str(stored)
|
||||
if road_type in ("main", "trunk"):
|
||||
return "trunk"
|
||||
return "fire" if road_type == "fire" else "work"
|
||||
|
||||
|
||||
def grade_options_of(road_type: str | None, params: dict[str, Any]) -> GradeDesignOptions:
|
||||
"""params(사용자 원값) → 해석된 기준(작업임도 30 → 20 · 빈 칸 = 법정값)."""
|
||||
options = params.get("options") or {}
|
||||
speed = options.get("design_speed_kph")
|
||||
return resolve_grade_options(
|
||||
grade_class_of(road_type, params),
|
||||
terrain_type=str(options.get("terrain_type") or "special"),
|
||||
paved=bool(options.get("paved", False)),
|
||||
main_direction=str(options.get("main_direction") or "auto"),
|
||||
design_speed_kph=int(speed) if speed is not None else None,
|
||||
stored={key: params[key] for key in _STORED_KEYS if params.get(key) is not None}
|
||||
| {"enforce_pipe_clearance": params.get("enforce_pipe_clearance")},
|
||||
)
|
||||
|
||||
|
||||
def retarget_longitudinal(
|
||||
longitudinal: dict[str, Any], options: GradeDesignOptions
|
||||
) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
"""종단 정본의 정책 · 기준을 새 기준으로 — 저장 편집으로 다시 얹어 위반 · 요약만 새로.
|
||||
|
||||
정책 칸 = 최대 종단기울기 · 종단곡선 최소 R · 평면 최소 R(+ 있던 최소 L). 기하 칸은 그대로.
|
||||
반환 = (선형, 요약 `{id, ...}`) · `longitudinal` 은 제자리에서 바뀐다.
|
||||
"""
|
||||
stored = longitudinal.get("profile_alignment")
|
||||
if not isinstance(stored, dict) or not stored.get("base_pvi"):
|
||||
raise ValueError("저장된 계획선 선형이 없어 기준을 바꿀 수 없습니다.")
|
||||
policy = dict(stored.get("policy") or {})
|
||||
policy["max_grade_pct"] = options.max_grade_pct
|
||||
policy["min_vertical_radius_m"] = options.min_vertical_radius_m
|
||||
policy["min_plan_radius_m"] = legal_plan_radius_min_m(
|
||||
options.design_speed_kph, options.terrain_type
|
||||
)
|
||||
if policy.get("min_curve_length_m"):
|
||||
policy["min_curve_length_m"] = options.min_curve_length_m
|
||||
stored["policy"] = policy
|
||||
profiles = longitudinal.get("design_profiles")
|
||||
previous = profiles[0] if isinstance(profiles, list) and profiles else {}
|
||||
direction = (previous.get("criteria") or {}).get("resolved_main_direction", "none")
|
||||
previous["criteria"] = {**options.as_dict(), "resolved_main_direction": direction}
|
||||
longitudinal["design_profiles"] = [previous, *(profiles or [])[1:]]
|
||||
alignment, profile = rebuild_alignment_profile(longitudinal, stored.get("edits"))
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
longitudinal["design_profiles"][0] = profile
|
||||
return alignment, {"id": profile["id"], **profile["summary"]}
|
||||
|
||||
|
||||
def retarget_file(
|
||||
project_root: Path, longitudinal_file_path: str, options: GradeDesignOptions
|
||||
) -> dict[str, Any] | None:
|
||||
"""종단 정본 파일에 새 기준 — 요약을 돌려줌 · 계획선 선형이 없으면 None(파일 그대로)."""
|
||||
root = Path(project_root).resolve()
|
||||
path = (root / longitudinal_file_path).resolve()
|
||||
if root not in path.parents:
|
||||
raise ValueError("종단 데이터 경로가 프로젝트 밖을 가리킵니다.")
|
||||
if not path.is_file():
|
||||
return None
|
||||
longitudinal = json.loads(path.read_text(encoding="utf-8"))
|
||||
if not (longitudinal.get("profile_alignment") or {}).get("base_pvi"):
|
||||
return None
|
||||
_, summary = retarget_longitudinal(longitudinal, options)
|
||||
atomic_write_json(path, longitudinal)
|
||||
return summary
|
||||
@@ -93,19 +93,25 @@ def add_crossing_pipes(
|
||||
|
||||
def _facility_owners(
|
||||
pipes: Sequence[PipePoint], structures: Sequence[Any]
|
||||
) -> dict[float, tuple[str, str | None]]:
|
||||
"""측점 자리(0.01 m) → (관 · 구조물, id). 같은 자리면 관이 먼저(`plan_stations` 이름 규칙)."""
|
||||
owners: dict[float, tuple[str, str | None]] = {}
|
||||
) -> dict[float, tuple[str, str | None, str]]:
|
||||
"""측점 자리(0.01 m) → (관 · 구조물, id, 역할 anchor · start · end). 같은 자리면 관이 먼저
|
||||
(`plan_stations` 이름 규칙)."""
|
||||
owners: dict[float, tuple[str, str | None, str]] = {}
|
||||
for pipe in pipes:
|
||||
for mark in plan_stations([pipe], []):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2), ("pipe", getattr(pipe, "id", None))
|
||||
round(float(mark["chainage_m"]), 2),
|
||||
("pipe", getattr(pipe, "id", None), str(mark.get("role") or "anchor")),
|
||||
)
|
||||
for item in structures:
|
||||
for mark in plan_stations([], [item]):
|
||||
owners.setdefault(
|
||||
round(float(mark["chainage_m"]), 2),
|
||||
("structure", getattr(item, "structure_id", None)),
|
||||
(
|
||||
"structure",
|
||||
getattr(item, "structure_id", None),
|
||||
str(mark.get("role") or "anchor"),
|
||||
),
|
||||
)
|
||||
return owners
|
||||
|
||||
@@ -118,8 +124,9 @@ def station_keys(
|
||||
) -> dict[str, float]:
|
||||
"""측점 열쇠(`common_util_station_key.station_key` 한 벌) → 누가거리.
|
||||
|
||||
관 · 구조물 자리 > 곡선 측점(`curve:` · 252-2) > 시종점 > 일반 격자(간격 배수인 것만) ·
|
||||
id 없는 관 · 구조물은 `at:` ·
|
||||
시종점(맨 앞 · 맨 끝 = 관 · 구조물이 얹혀도 `end:BP/EP` · 화면 `buildStations` · B06
|
||||
`stationKeysOf` 와 같음 · 452) > 관 · 구조물 자리(구간형 시작 · 끝 `:start` · `:end`) >
|
||||
곡선 측점(`curve:` · 252-2) > 일반 격자(간격 배수인 것만) · id 없는 관 · 구조물은 `at:` ·
|
||||
같은 열쇠가 둘이면 뒤엣것은 `at:`(B06 `stationKeysOf` 와 같음).
|
||||
"""
|
||||
owners = _facility_owners(pipes, structures)
|
||||
@@ -128,14 +135,14 @@ def station_keys(
|
||||
out: dict[str, float] = {}
|
||||
for index, chainage in enumerate(values):
|
||||
owner = owners.get(round(chainage, 2))
|
||||
if owner is not None:
|
||||
key = station_key(owner[0], chainage, interval_m, owner[1])
|
||||
elif rows[index].get("kind") == "curve":
|
||||
key = station_key("curve", chainage, interval_m)
|
||||
elif index == 0:
|
||||
if index == 0:
|
||||
key = station_key("bp", chainage, interval_m)
|
||||
elif index == len(values) - 1:
|
||||
key = station_key("ep", chainage, interval_m)
|
||||
elif owner is not None:
|
||||
key = station_key(owner[0], chainage, interval_m, owner[1], role=owner[2])
|
||||
elif rows[index].get("kind") == "curve":
|
||||
key = station_key("curve", chainage, interval_m)
|
||||
elif interval_m > 0 and abs(chainage - round(chainage / interval_m) * interval_m) < _SAME_M:
|
||||
key = station_key("regular", chainage, interval_m)
|
||||
else:
|
||||
|
||||
@@ -157,6 +157,7 @@ async def solve_route_record(
|
||||
"cross_sample_interval_m": request.cross_sample_interval_m,
|
||||
"long_sample_interval_m": request.long_sample_interval_m,
|
||||
**request.grade_options(),
|
||||
"station_display": request.station_display, # B05 표시 전용(448)
|
||||
}
|
||||
log_b05_debug(
|
||||
logger,
|
||||
|
||||
@@ -213,14 +213,21 @@ async def sync_uphill_overrides_into_designs(
|
||||
_SAME_STATION_M = 0.006
|
||||
#: 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 새 꼭짓점을 지반에 세울지(438).
|
||||
_SEAT_GROUND_M = 0.01
|
||||
#: 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5).
|
||||
_SEAT_KEEP_M = 0.001
|
||||
|
||||
|
||||
def _seat_born_nodes(
|
||||
stored: dict[str, Any], base: list[dict[str, Any]], born: list[float]
|
||||
stored: dict[str, Any],
|
||||
base: list[dict[str, Any]],
|
||||
born: list[float],
|
||||
held: list[float] = (),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""새 측점(`born`)에 자동 계획선 규칙(원지반 지남 · `pass_through`)으로 꼭짓점을 세움(427).
|
||||
|
||||
다른 꼭짓점 높이는 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장
|
||||
이웃 꼭짓점은 곡선 모양이 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 ·
|
||||
바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점(`held` = 옮긴 뒤 `station_offsets` 측점)과 그
|
||||
밖 꼭짓점은 그대로 · 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. 지반 = 저장
|
||||
선형 표본(`samples`) · 곡선 = 편집 없이(`pass_through` 와 같음). 옮기기 전 계획선이 꼭짓점
|
||||
대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438). TS 짝 =
|
||||
`seatBornNodes`(B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 거울 시험 =
|
||||
@@ -258,20 +265,41 @@ def _seat_born_nodes(
|
||||
key=lambda row: row[0],
|
||||
)
|
||||
base_s = np.array([row[0] for row in rows], dtype=np.float64)
|
||||
target = np.array([row[1] for row in rows], dtype=np.float64)
|
||||
free = np.array([row[2] is None for row in rows])
|
||||
base_z = target.copy()
|
||||
for _ in range(20):
|
||||
born_rows = np.array([row[2] is None for row in rows])
|
||||
# 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것
|
||||
target = np.where(
|
||||
born_rows,
|
||||
np.array([row[1] for row in rows], dtype=np.float64),
|
||||
evaluate(pre_s, pre_z, pre_curves, base_s),
|
||||
)
|
||||
can = np.array(
|
||||
[
|
||||
row[2] is not None and all(abs(row[0] - value) >= _SAME_STATION_M for value in held)
|
||||
for row in rows
|
||||
]
|
||||
)
|
||||
free = born_rows.copy()
|
||||
base_z = np.array([row[1] for row in rows], dtype=np.float64)
|
||||
for _ in range(len(rows)):
|
||||
for _ in range(20):
|
||||
curves, _warnings = build_curves(base_s, base_z, policy)
|
||||
error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0)
|
||||
if float(np.max(np.abs(error))) < 1e-6:
|
||||
break
|
||||
base_z = base_z + error
|
||||
# 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼)
|
||||
curves, _warnings = build_curves(base_s, base_z, policy)
|
||||
error = np.where(free, target - evaluate(base_s, base_z, curves, base_s), 0.0)
|
||||
if float(np.max(np.abs(error))) < 1e-6:
|
||||
drift = (
|
||||
can & ~free & (np.abs(target - evaluate(base_s, base_z, curves, base_s)) > _SEAT_KEEP_M)
|
||||
)
|
||||
if not drift.any():
|
||||
break
|
||||
base_z = base_z + error
|
||||
free |= drift
|
||||
return [
|
||||
node
|
||||
if node is not None
|
||||
else {"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
|
||||
for (s, _target, node), z in zip(rows, base_z.tolist())
|
||||
if node is not None and not keep
|
||||
else {**(node or {}), "chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
|
||||
for (s, _z, node), z, keep in zip(rows, base_z.tolist(), free.tolist())
|
||||
]
|
||||
|
||||
|
||||
@@ -367,7 +395,8 @@ def _follow_moved_stations(
|
||||
changed = True
|
||||
if new_key is not None and new_key not in reseat:
|
||||
base.append({**node, "chainage_m": float(new_key)})
|
||||
seated = _seat_born_nodes(stored, base, seat)
|
||||
held = [float(key) for key in next_edits["station_offsets"]]
|
||||
seated = _seat_born_nodes(stored, base, seat, held)
|
||||
changed = changed or len(seated) != len(base)
|
||||
base = seated
|
||||
# 측점 목록만 바뀌어도 다시 셈 — 계획선 측점 줄(표 · ▲▼)이 옛 측점을 들고 남지 않게(272) ·
|
||||
|
||||
@@ -11,22 +11,34 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Debug import log_b05_debug
|
||||
from B05_Profile.B05_Profile_Repository import confirm_route, get_latest_route
|
||||
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m
|
||||
from B05_Profile.B05_Profile_PageSettings import grade_options_of, params_patch, retarget_file
|
||||
from B05_Profile.B05_Profile_Repository import (
|
||||
confirm_route,
|
||||
get_latest_route,
|
||||
update_longitudinal_grade_summary,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Router_Confirm import (
|
||||
_append_irregular_cross_sections,
|
||||
_merge_uphill_overrides_into_longitudinal,
|
||||
sync_uphill_overrides_into_designs,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Schema import RouteConfirmRequest, RouteConfirmResponse
|
||||
from B05_Profile.B05_Profile_Schema import (
|
||||
PageSettingsRequest,
|
||||
PageSettingsResponse,
|
||||
RouteConfirmRequest,
|
||||
RouteConfirmResponse,
|
||||
)
|
||||
from B06_Section.B06_Section_Repository import get_longitudinal_section
|
||||
from common_util.common_util_auth import get_optional_session
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_workflow_state import complete_stage, start_stage
|
||||
from common_util.common_util_workflow_state import complete_stage, merge_stage_params, start_stage
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -149,6 +161,76 @@ async def confirm_latest_route(
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}/route/page-settings", response_model=PageSettingsResponse)
|
||||
async def save_page_settings(
|
||||
project_id: UUID, request: PageSettingsRequest
|
||||
) -> PageSettingsResponse | JSONResponse:
|
||||
"""B05 페이지 설정 [저장](448) — stage 2 params 에 사용자 값만 얹고(상태 · STALE 그대로)
|
||||
최신 종단 정본의 경고 기준을 갈아 끼워 위반 · 요약을 다시 셈. 기하 · 종횡단은 그대로."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT road_type FROM projects WHERE id = %s", (str(project_id),)
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
await connection.begin()
|
||||
try:
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
params = await merge_stage_params(
|
||||
cursor, str(project_id), 2, params_patch(request)
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
options = grade_options_of(row[0] if row else None, params)
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
longitudinal = (
|
||||
await get_longitudinal_section(connection, project_id, latest["id"])
|
||||
if latest
|
||||
else None
|
||||
)
|
||||
summary = None
|
||||
if longitudinal:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
summary = await asyncio.to_thread(
|
||||
retarget_file,
|
||||
Path(resolve_stored_project_path(stored_path)),
|
||||
str(longitudinal["longitudinal_file_path"]),
|
||||
options,
|
||||
)
|
||||
if summary is not None:
|
||||
await connection.begin()
|
||||
try:
|
||||
await update_longitudinal_grade_summary(
|
||||
connection, route_id=latest["id"], grade_summary=summary
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
raise
|
||||
return PageSettingsResponse(
|
||||
project_id=str(project_id),
|
||||
design_speed_kph=options.design_speed_kph,
|
||||
terrain_type=options.terrain_type,
|
||||
max_grade_pct=options.max_grade_pct,
|
||||
min_vertical_radius_m=options.min_vertical_radius_m,
|
||||
min_plan_radius_m=legal_plan_radius_min_m(
|
||||
options.design_speed_kph, options.terrain_type
|
||||
),
|
||||
warnings=list(options.warnings),
|
||||
profile_updated=summary is not None,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B05 페이지 설정 저장 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "페이지 설정 저장 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/reset")
|
||||
async def reset_route_design(
|
||||
project_id: UUID,
|
||||
|
||||
@@ -187,6 +187,8 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]:
|
||||
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
|
||||
)
|
||||
params = (stage2 or {}).get("params") or {}
|
||||
# 노선 풀기 · 페이지 설정 저장(448)은 `options` 안에 둠 — 옛 맨 위 칸은 폴백
|
||||
params = {**params, **{k: v for k, v in (params.get("options") or {}).items() if v}}
|
||||
speed = params.get("design_speed_kph")
|
||||
if isinstance(speed, (int, float)):
|
||||
design_speed = int(speed)
|
||||
|
||||
@@ -117,6 +117,8 @@ class RouteSolveRequest(BaseModel):
|
||||
enforce_pipe_clearance: bool | None = Field(
|
||||
default=None, description="횡단배수 최소 계획고 강제 (기본 해제)"
|
||||
)
|
||||
# 시작 측점 · 누가거리 표시(448) — B05 라벨만 · 다시 풀어도 params 에 그대로 옮김.
|
||||
station_display: dict[str, float] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_choices(self) -> "RouteSolveRequest":
|
||||
@@ -217,6 +219,50 @@ class ProfileAlignmentSaveRequest(BaseModel):
|
||||
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
|
||||
|
||||
|
||||
class PageSettingsRequest(BaseModel):
|
||||
"""B05 페이지 설정 [저장](448) — 사용자가 넣은 값 그대로(빈 칸 = null = 법정값)."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
design_speed_kph: int = 20
|
||||
terrain_type: str = "special"
|
||||
max_grade_pct: float | None = Field(default=None, gt=0)
|
||||
min_vertical_radius_m: float | None = Field(default=None, gt=0)
|
||||
min_tangent_length_m: float | None = Field(default=None, gt=0)
|
||||
enforce_pipe_clearance: bool = False
|
||||
#: 시작 측점 · 누가거리 — B05 표시 전용(측점 열쇠 · 다른 페이지 라벨에 안 씀)
|
||||
station_display: dict[str, float] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_choices(self) -> "PageSettingsRequest":
|
||||
if self.design_speed_kph not in (20, 30, 40):
|
||||
raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.")
|
||||
if self.terrain_type not in GRADE_TERRAIN_TYPES:
|
||||
raise ValueError(f"지형 구분은 {GRADE_TERRAIN_TYPES} 중 하나여야 합니다.")
|
||||
if self.station_display is not None:
|
||||
station = self.station_display.get("station", 0)
|
||||
self.station_display = {
|
||||
"station": float(max(0, round(station))),
|
||||
"cumulative": float(self.station_display.get("cumulative", 0)),
|
||||
}
|
||||
return self
|
||||
|
||||
|
||||
class PageSettingsResponse(BaseModel):
|
||||
"""페이지 설정 저장 결과 — 해석된 기준(작업임도 30 → 20 등) · 경고."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
design_speed_kph: int
|
||||
terrain_type: str
|
||||
max_grade_pct: float
|
||||
min_vertical_radius_m: float
|
||||
min_plan_radius_m: float
|
||||
warnings: list[str] = Field(default_factory=list)
|
||||
#: 종단 정본의 기준을 갈아 끼우고 위반 · 요약을 다시 셈했나(종단이 없으면 false)
|
||||
profile_updated: bool = False
|
||||
|
||||
|
||||
class ProfileAlignmentSaveResponse(BaseModel):
|
||||
"""계획선 편집 저장 결과 (재계산된 정본 선형을 그대로 돌려준다)."""
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -16,7 +17,7 @@ from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Repository import get_latest_route
|
||||
@@ -239,9 +240,43 @@ async def read_structures(project_id: UUID) -> StructureListResponse | JSONRespo
|
||||
|
||||
|
||||
class StructureMigrateRequest(BaseModel):
|
||||
"""구 비정규 측점 이관 요청 — 화면이 복원한 `{chainage_m, structure}` 목록 그대로."""
|
||||
"""구 비정규 측점 이관 요청 — 노선 id 만(없으면 최신 노선).
|
||||
|
||||
stations: list[dict[str, Any]] = Field(default_factory=list)
|
||||
측점은 서버가 저장 종단 파일에서 읽음(452)."""
|
||||
|
||||
route_id: int | None = None
|
||||
|
||||
|
||||
async def _saved_irregular_stations(
|
||||
project_id: UUID, root: str, route_id: int | None
|
||||
) -> list[dict[str, Any]]:
|
||||
"""저장 종단 정본의 비정규 측점 `{chainage_m, structure}` — 이관 원천. 화면 상세는 초안
|
||||
(옮긴 관 자리)이 섞일 수 있어 안 받음(452 · [저장] 없이 초안을 정본에 쓰지 않음)."""
|
||||
from B06_Section.B06_Section_Repository import get_longitudinal_section
|
||||
from B06_Section.B06_Section_Router_Design import resolve_longitudinal_path
|
||||
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
if route_id is None:
|
||||
latest = await get_latest_route(connection, project_id)
|
||||
route_id = latest["id"] if latest else None
|
||||
row = (
|
||||
await get_longitudinal_section(connection, project_id, route_id)
|
||||
if route_id is not None
|
||||
else None
|
||||
)
|
||||
if not row or not row.get("longitudinal_file_path"):
|
||||
return []
|
||||
try:
|
||||
path = resolve_longitudinal_path(Path(root), str(row["longitudinal_file_path"]))
|
||||
stations = json.loads(path.read_text(encoding="utf-8")).get("stations") or []
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return []
|
||||
return [
|
||||
{"chainage_m": station["chainage_m"], "structure": station.get("structure") or ""}
|
||||
for station in stations
|
||||
if station.get("kind") == "irregular" and station.get("chainage_m") is not None
|
||||
]
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/structures/migrate", response_model=None)
|
||||
@@ -267,7 +302,8 @@ async def migrate_structures(project_id: UUID, payload: StructureMigrateRequest)
|
||||
plus_chainages(root),
|
||||
)
|
||||
]
|
||||
plan = migrate_irregular_stations(payload.stations, planted)
|
||||
legacy = await _saved_irregular_stations(project_id, root, payload.route_id)
|
||||
plan = migrate_irregular_stations(legacy, planted)
|
||||
# 표식 = "타입@위치". 원천(종단 정본의 비정규 측점)은 원복용으로 그대로 두고,
|
||||
# 옮긴 이력만 남긴다 — 그래야 사용자가 지운 구조물이 재진입 때 되살아나지 않는다
|
||||
# (2026-08-24 사용자: 초기 계산값은 원복용, 사용자 수정 1세트가 최종본).
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
computeDetailBasins,
|
||||
fetchDetailPipePoints,
|
||||
getVWorldMapUrl,
|
||||
saveDetailPipePoints,
|
||||
type DetailBasin,
|
||||
type DetailBasinResponse,
|
||||
type PipeSource,
|
||||
@@ -566,7 +565,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
projectId = nextProjectId;
|
||||
void loadLayers();
|
||||
},
|
||||
reload() {
|
||||
reload(fresh = false) {
|
||||
if (fresh) live.markFresh(); // [확정] 뒤 — 낡음 걷음(응답이 아직 낡았으면 다시 닮)
|
||||
if (projectId && meta) void run(loadSaved);
|
||||
},
|
||||
pipeChainages: () => pipeEditor.chainages(),
|
||||
@@ -667,15 +667,6 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
||||
syncPipeSelection(notify);
|
||||
scheduleDraw();
|
||||
},
|
||||
async savePipes(confirm = false) {
|
||||
if (!projectId) return 0;
|
||||
// 시설 종류·구간·옵션까지 정본에 남긴다(계산기 왕복에서 사라지므로 되붙임).
|
||||
const response = await saveDetailPipePoints(projectId, pipeRequest(), confirm);
|
||||
if (confirm && !response.stale) live.markFresh();
|
||||
apply(response);
|
||||
scheduleDraw();
|
||||
return response.pipe_count;
|
||||
},
|
||||
setRoute(points) {
|
||||
routePoints = points;
|
||||
routeLayer = meta && points.length > 1 ? prepareMetricPolyline(points, meta) : null;
|
||||
|
||||
@@ -19,8 +19,9 @@ export interface DrainagePanel {
|
||||
root: HTMLElement;
|
||||
/** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */
|
||||
load: (projectId: string) => void;
|
||||
/** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b). */
|
||||
reload: () => void;
|
||||
/** 세션 초안(관 목록)을 다시 읽어 세운다 — 다른 보기(횡단)가 관 목록을 고친 뒤(계획서 24-3b) · [저장] 뒤
|
||||
* 저장분으로(초안 비움 뒤 · 433-7). `fresh` = [확정] 뒤 낡음 걷음. */
|
||||
reload: (fresh?: boolean) => void;
|
||||
/** 확정된 노선 평면 선형(사업지 좌표계 m)을 지도 위에 겹친다. */
|
||||
setRoute: (points: ReadonlyArray<RoutePoint>) => void;
|
||||
/** 현재 관 매설 누가거리 목록(종단 테이블의 "배관" 구조물 라인과 맞추는 데 쓴다). */
|
||||
@@ -42,9 +43,6 @@ export interface DrainagePanel {
|
||||
attributes: FacilityAttributes,
|
||||
) => void;
|
||||
removePipe: (chainageM: number) => void;
|
||||
/** 경로 확정 시 관 매설 지점을 영구저장한다(B04 "모델 확정"과 같은 저장소). */
|
||||
/** [확정](confirm) = 낡았으면 서버가 지금 노선으로 배수유역부터 다시 셈 · [저장] = 낡음 달고 저장. */
|
||||
savePipes: (confirm?: boolean) => Promise<number>;
|
||||
/** 계산 묶음 drainage 단계(347-2) — 붙이기 = 묶음 쪽. */
|
||||
pipelineStage: StageFn<"drainage">;
|
||||
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
writeRouteLatestCache,
|
||||
type RouteLatestResponse,
|
||||
} from "./B05_Profile_Api_Fetch";
|
||||
import { panelValuesOfDraft, readPageSettings } from "./B05_Profile_Api_PageSettings";
|
||||
import type { RoutePanelValues } from "./B05_Profile_UI_Panel";
|
||||
|
||||
/**
|
||||
@@ -63,9 +64,11 @@ export function createLatestSource(projectId: string) {
|
||||
return { read, write, load, verify };
|
||||
}
|
||||
|
||||
/** 저장된 노선 설정값 → 좌측 폼 되살리기 값. */
|
||||
/** 저장된 노선 설정값 → 좌측 폼 되살리기 값 — 세션 페이지 설정 초안이 있으면 그 위에(448).
|
||||
* 시 · 종점 계획고 조정은 종단 편집이 비추므로 여기서 안 채움(옛 params 값은 자동 선형에 이미 듦). */
|
||||
export function panelValuesOf(next: RouteLatestResponse): Partial<RoutePanelValues> {
|
||||
const options = next.route_params?.options ?? {};
|
||||
const draft = readPageSettings(next.project_id);
|
||||
return {
|
||||
contourInterval: next.surface_params.contour_interval_m,
|
||||
gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined,
|
||||
@@ -77,9 +80,9 @@ export function panelValuesOf(next: RouteLatestResponse): Partial<RoutePanelValu
|
||||
maxGradePct: next.route_params?.max_grade_pct ?? undefined,
|
||||
minVerticalRadius: next.route_params?.min_vertical_radius_m ?? undefined,
|
||||
minTangentLength: next.route_params?.min_tangent_length_m ?? undefined,
|
||||
startElevationOffset: next.route_params?.start_elevation_offset_m ?? undefined,
|
||||
endElevationOffset: next.route_params?.end_elevation_offset_m ?? undefined,
|
||||
enforcePipeClearance:
|
||||
(next.route_params?.enforce_pipe_clearance as boolean | undefined) ?? undefined,
|
||||
stationDisplay: next.route_params?.station_display ?? undefined,
|
||||
...(draft ? panelValuesOfDraft(draft) : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import { createRecommendPart } from "./B05_Profile_UI_Panel_Recommend";
|
||||
import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings";
|
||||
import { createViewControls } from "./B05_Profile_UI_Panel_View";
|
||||
import { pageSettingsOf, writePageSettings } from "./B05_Profile_Api_PageSettings";
|
||||
|
||||
export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields";
|
||||
|
||||
@@ -71,7 +72,16 @@ interface PanelCallbacks {
|
||||
|
||||
export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) {
|
||||
const { viewControls, setCorridorPending } = createViewControls(callbacks);
|
||||
const settings = createSettingsPart(callbacks);
|
||||
const settings = createSettingsPart({
|
||||
...callbacks,
|
||||
onStationDisplayChange: (offset) => {
|
||||
callbacks.onStationDisplayChange(offset);
|
||||
rememberSettings();
|
||||
},
|
||||
// 시 · 종점 계획고 조정 = 종단도 끝 측점 편집(448) — 종단도는 한 벌(`shared.profile`)
|
||||
endOffsets: () => shared.profile?.endOffsets() ?? null,
|
||||
onEndOffsetChange: (end, value) => void shared.profile?.setEndOffset(end, value),
|
||||
});
|
||||
const recommend = createRecommendPart(); // 노선 추천 칸 — 동작은 노선 편집기가 건다(345-4)
|
||||
const {
|
||||
contourInterval,
|
||||
@@ -118,6 +128,37 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
|
||||
enforcePipeClearance,
|
||||
];
|
||||
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
|
||||
// 페이지 설정 초안(448) — 고친 즉시 세션에 · [저장]이 내보냄(시 · 종점 조정은 계획고 편집이라 뺌)
|
||||
[
|
||||
designSpeed,
|
||||
terrainType,
|
||||
maxGradePct,
|
||||
minVerticalRadius,
|
||||
minTangentLength,
|
||||
enforcePipeClearance,
|
||||
].forEach((input) => input.addEventListener("change", rememberSettings));
|
||||
function rememberSettings(): void {
|
||||
writePageSettings(shared.projectId, pageSettingsOf(values()));
|
||||
}
|
||||
|
||||
function values(): RoutePanelValues {
|
||||
return {
|
||||
contourInterval: Number(contourInterval.value) || 1,
|
||||
gradeClass: gradeClass.value as RoutePanelValues["gradeClass"],
|
||||
stationInterval: STATION_INTERVAL_M,
|
||||
crossSampleInterval: parseOptional(crossSampleInterval),
|
||||
longSampleInterval: parseOptional(longSampleInterval),
|
||||
designSpeed: settings.currentSpeed(),
|
||||
terrainType: terrainType.value as RoutePanelValues["terrainType"],
|
||||
maxGradePct: parseOptional(maxGradePct),
|
||||
minVerticalRadius: parseOptional(minVerticalRadius),
|
||||
minTangentLength: parseOptional(minTangentLength),
|
||||
startElevationOffset: parseOptional(startElevationOffset),
|
||||
endElevationOffset: parseOptional(endElevationOffset),
|
||||
enforcePipeClearance: enforcePipeClearance.checked,
|
||||
stationDisplay: settings.stationDisplayOffset(),
|
||||
};
|
||||
}
|
||||
|
||||
// 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 ·
|
||||
// [확정]은 한 벌이 두 보기 몫을 모아 낸다.
|
||||
@@ -136,23 +177,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
|
||||
gradeLimitPct: settings.gradeLimitPct,
|
||||
/** [3D 업데이트] 대기 표시 — 편집이 3D에 아직 안 들어갔음을 버튼에 알린다. */
|
||||
setCorridorPending,
|
||||
values(): RoutePanelValues {
|
||||
return {
|
||||
contourInterval: Number(contourInterval.value) || 1,
|
||||
gradeClass: gradeClass.value as RoutePanelValues["gradeClass"],
|
||||
stationInterval: STATION_INTERVAL_M,
|
||||
crossSampleInterval: parseOptional(crossSampleInterval),
|
||||
longSampleInterval: parseOptional(longSampleInterval),
|
||||
designSpeed: settings.currentSpeed(),
|
||||
terrainType: terrainType.value as RoutePanelValues["terrainType"],
|
||||
maxGradePct: parseOptional(maxGradePct),
|
||||
minVerticalRadius: parseOptional(minVerticalRadius),
|
||||
minTangentLength: parseOptional(minTangentLength),
|
||||
startElevationOffset: parseOptional(startElevationOffset),
|
||||
endElevationOffset: parseOptional(endElevationOffset),
|
||||
enforcePipeClearance: enforcePipeClearance.checked,
|
||||
};
|
||||
},
|
||||
values,
|
||||
restore(values: Partial<RoutePanelValues>) {
|
||||
if (values.contourInterval != null) contourInterval.value = String(values.contourInterval);
|
||||
if (values.gradeClass) gradeClass.value = values.gradeClass;
|
||||
@@ -167,17 +192,24 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
|
||||
}
|
||||
if (values.designSpeed) designSpeed.value = String(values.designSpeed);
|
||||
if (values.terrainType) terrainType.value = values.terrainType;
|
||||
if (values.maxGradePct != null) maxGradePct.value = String(values.maxGradePct);
|
||||
if (values.minVerticalRadius != null)
|
||||
minVerticalRadius.value = String(values.minVerticalRadius);
|
||||
if (values.minTangentLength != null) minTangentLength.value = String(values.minTangentLength);
|
||||
// 기준값 — null = 사용자가 비운 칸(초안 · 448) · undefined = 그대로
|
||||
const typed: Array<[HTMLInputElement, number | null | undefined]> = [
|
||||
[maxGradePct, values.maxGradePct],
|
||||
[minVerticalRadius, values.minVerticalRadius],
|
||||
[minTangentLength, values.minTangentLength],
|
||||
];
|
||||
typed.forEach(([input, value]) => {
|
||||
if (value !== undefined) input.value = value === null ? "" : String(value);
|
||||
});
|
||||
if (values.startElevationOffset != null)
|
||||
startElevationOffset.value = String(values.startElevationOffset);
|
||||
if (values.endElevationOffset != null)
|
||||
endElevationOffset.value = String(values.endElevationOffset);
|
||||
if (values.enforcePipeClearance != null)
|
||||
enforcePipeClearance.checked = values.enforcePipeClearance;
|
||||
if (values.stationDisplay) settings.setStationDisplay(values.stationDisplay);
|
||||
settings.syncCriteria();
|
||||
settings.showEndOffsets();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface RoutePanelValues {
|
||||
/** 횡단배수 최소 계획고를 계획선·편집에 강제할지 — 기본 해제(2026-09-01 사용자 지시).
|
||||
* 해제해도 부족분 경고는 그대로 뜬다. 강제만 풀고 판단은 사용자에게 맡긴다. */
|
||||
enforcePipeClearance: boolean;
|
||||
/** 시작 측점 · 누가거리 표시(448 저장) — B05 라벨만 · 측점 열쇠 · 다른 페이지에 안 씀. */
|
||||
stationDisplay: { station: number; cumulative: number };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,6 +25,10 @@ export interface SettingsPartCallbacks {
|
||||
onContourApply: (interval: number) => void | Promise<void>;
|
||||
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
|
||||
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
|
||||
/** 시 · 종점 계획고 조정(448) = 첫 · 끝 측점 계획고 편집 — 지금 값(기준 없으면 null). */
|
||||
endOffsets?: () => { start: number; end: number } | null;
|
||||
/** 칸을 바꿈 → 그 끝 측점 편집으로(빈 칸 = 0). */
|
||||
onEndOffsetChange?: (end: "start" | "end", value: number) => void;
|
||||
}
|
||||
|
||||
export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
@@ -73,6 +77,11 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
|
||||
const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample"));
|
||||
const longSampleInterval = numberField(L("B05_Route_Field_LongSample"));
|
||||
// 읽기 전용(448) — 바꾸면 종횡단을 다시 표본해야 해서 여기서 고치지 않음.
|
||||
[crossSampleInterval, longSampleInterval].forEach((input) => {
|
||||
input.disabled = true;
|
||||
input.title = "종횡단 표본 간격 — 바꾸려면 종횡단을 다시 만들어야 합니다(읽기 전용).";
|
||||
});
|
||||
// 샘플 간격 두 칸은 2열 한 행 — 종단이 먼저(2026-08-18 사용자 지시).
|
||||
const sampleRow = document.createElement("div");
|
||||
sampleRow.className = "b05-route__field-row";
|
||||
@@ -102,11 +111,38 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
const minTangentLength = numberField("최소 직선 길이 (m)");
|
||||
const startElevationOffset = numberField("시점 계획고 조정 (m)");
|
||||
const endElevationOffset = numberField("종점 계획고 조정 (m)");
|
||||
// 시 · 종점 계획고 조정 = 첫 · 끝 측점 계획고 편집(448) — 칸은 종단 편집을 비추고 바꾸면 그 편집으로.
|
||||
const endFields = { start: startElevationOffset, end: endElevationOffset };
|
||||
function showEndOffsets(): void {
|
||||
const offsets = callbacks.endOffsets?.();
|
||||
(["start", "end"] as const).forEach((end) => {
|
||||
endFields[end].disabled = !offsets;
|
||||
if (offsets && document.activeElement !== endFields[end])
|
||||
endFields[end].value = offsets[end] ? String(Number(offsets[end].toFixed(3))) : "";
|
||||
});
|
||||
}
|
||||
(["start", "end"] as const).forEach((end) => {
|
||||
endFields[end].placeholder = "0";
|
||||
endFields[end].title =
|
||||
"자동 계획선 대비 이 끝 측점의 계획고 편집(m) — 종단도 끝점 ▲▼ 와 같은 값.";
|
||||
endFields[end].addEventListener("change", () => {
|
||||
callbacks.onEndOffsetChange?.(end, parseOptional(endFields[end]) ?? 0);
|
||||
showEndOffsets(); // 막힌 편집(최소고 가드)이면 옛 값으로 돌아옴
|
||||
});
|
||||
});
|
||||
const criteriaNote = document.createElement("p");
|
||||
criteriaNote.className = "b05-route__note";
|
||||
const gradeAdvanced = document.createElement("details");
|
||||
const gradeSummary = document.createElement("summary");
|
||||
gradeSummary.textContent = "기준값 직접 지정";
|
||||
// 칸을 펼 때 · 펴 둔 동안 누름 · 키 뒤마다 종단 편집을 다시 비춤(끝점 ▲▼ · 되돌리기 · [저장] 뒤)
|
||||
// ponytail: 문서 듣기는 페이지마다 하나 · 떼지 않음(가벼움) — 새는 게 보이면 페이지 수명에 묶을 것
|
||||
gradeAdvanced.addEventListener("toggle", () => gradeAdvanced.open && showEndOffsets());
|
||||
const refreshSoon = (): void => {
|
||||
if (gradeAdvanced.isConnected && gradeAdvanced.open) requestAnimationFrame(showEndOffsets);
|
||||
};
|
||||
document.addEventListener("pointerup", refreshSoon);
|
||||
document.addEventListener("keyup", refreshSoon);
|
||||
gradeAdvanced.append(
|
||||
gradeSummary,
|
||||
maxGradePct.wrapper,
|
||||
@@ -224,6 +260,12 @@ export function createSettingsPart(callbacks: SettingsPartCallbacks) {
|
||||
enforcePipeClearance,
|
||||
},
|
||||
stationDisplayOffset,
|
||||
/** 저장된 시작 측점 · 누가거리를 칸에 되살림(알림 없음). */
|
||||
setStationDisplay(next: { station: number; cumulative: number }) {
|
||||
startStation.value = String(next.station);
|
||||
startCumulative.value = String(next.cumulative);
|
||||
},
|
||||
showEndOffsets,
|
||||
gradeLimitPct,
|
||||
currentSpeed,
|
||||
syncSpeedChoices,
|
||||
|
||||
@@ -70,6 +70,8 @@ function createHoldRepeater(node: Node): {
|
||||
|
||||
export interface ProfileEditStore {
|
||||
edits(): AlignmentEdits;
|
||||
/** 서버 저장분 곡선반경 — ▲▼ 가 자동 R 로 보고 지울 기준(433-13). */
|
||||
savedRadii(): Readonly<Record<string, number>>;
|
||||
replace(next: AlignmentEdits): void;
|
||||
/** 서버 저장이 끝났음을 표시한다. 편집 델타는 그대로 두고 초안만 지운다.
|
||||
* 이때의 값이 [초기화]의 기준점이 된다. */
|
||||
@@ -147,6 +149,13 @@ export function pendingAlignment(routeId: number): AlignmentEdits | null {
|
||||
return readAlignmentDraft(routeId);
|
||||
}
|
||||
|
||||
/** 초안을 다른 노선 칸에 둔다 — 노선 저장 뒤 다른 차례가 실패(433-12 · 다음 [저장]이 새 노선으로 보냄). */
|
||||
export function writeAlignmentDraft(routeId: number, edits: AlignmentEdits): void {
|
||||
draftOf(routeId).set("alignmentDraft", edits);
|
||||
const state = shared.get(routeId);
|
||||
if (state) Object.assign(state, { current: edits, unsaved: true });
|
||||
}
|
||||
|
||||
/** 계획고가 정본에 나갔다 — 그 노선 상태를 저장됨으로 두고 초안을 지운다. */
|
||||
export function markAlignmentSaved(routeId: number): void {
|
||||
const state = shared.get(routeId);
|
||||
@@ -202,6 +211,7 @@ function handleOf(
|
||||
|
||||
return {
|
||||
edits: () => state.current,
|
||||
savedRadii: () => state.saved.curve_radii ?? {},
|
||||
replace: commit,
|
||||
markSaved() {
|
||||
state.unsaved = false;
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface PanelToolsContext {
|
||||
base: () => AlignmentBase | null;
|
||||
alignment: () => ProfileAlignment | null;
|
||||
edits: () => AlignmentEdits;
|
||||
/** 서버 저장분 곡선반경 — ▲▼ 자동 R 기준(433-13). */
|
||||
savedRadii: () => Readonly<Record<string, number>>;
|
||||
/** false = 막힘(기울기 · 흙덮기). */
|
||||
applyEdits: (next: AlignmentEdits) => boolean;
|
||||
/** 고른 측점 id(없으면 null). */
|
||||
@@ -219,7 +221,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
|
||||
if (tools.mode() === "shift") return tools.nudge(delta);
|
||||
const chainage = selectedChainage();
|
||||
if (chainage === null) return false;
|
||||
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta));
|
||||
ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()));
|
||||
return true;
|
||||
}
|
||||
// 좌우 이동 = 마지막 누름이 종단도 안일 때만 — 횡단 카드 · 3D 를 누른 뒤 방향키가 관을 몰래 옮겼음(219-1)
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useDesignStore } from "../A00_Common/design/design_store";
|
||||
import type { DesignPipeline, PipelineEvent } from "../A00_Common/design/logic/design_pipeline";
|
||||
|
||||
/** cross 를 지났다고 볼 단계(cross 가 같은 입력이라 건너뛰어도 뒤 단계가 알림). */
|
||||
const AFTER_CROSS = new Set(["cross", "footprint", "masshaul"]);
|
||||
const AFTER_CROSS = new Set(["cross", "footprint"]);
|
||||
/** 묶음 차례가 cross 를 못 낸 채 끝나도(단계 실패) 「다시 계산 중」을 푸는 때(ms). */
|
||||
const DRAFT_WAIT_MS = 3000;
|
||||
|
||||
|
||||
@@ -79,6 +79,7 @@ export interface ProfileRenderContext {
|
||||
};
|
||||
store: {
|
||||
edits: () => AlignmentEdits;
|
||||
savedRadii: () => Readonly<Record<string, number>>;
|
||||
resetStation: (chainageM: number) => void;
|
||||
};
|
||||
detail: () => SectionDetailResponse | null;
|
||||
@@ -203,7 +204,8 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
ctx.applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
|
||||
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
|
||||
onAdjustStation: (chainage, delta) =>
|
||||
base && ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta)),
|
||||
base &&
|
||||
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii())),
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -411,7 +413,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 다른 편집 경로(직선화·쉬프트·틸팅·방향키)와 규칙이 갈린다(2026-09-02).
|
||||
onStation: (chainage, delta) => {
|
||||
if (!base) return;
|
||||
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta));
|
||||
ctx.applyEdits(adjustStation(base, store.edits(), chainage, delta, store.savedRadii()));
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -160,11 +160,13 @@ const savedPipes = (projectId: string): Promise<SavedPipe[]> =>
|
||||
/**
|
||||
* 노선 저장 뒤 세션 횡단 초안(옛 노선 측점 열쇠 · 누가거리 열쇠 조절 · 417) → 새 노선으로 넘기고 그 고침을 저장 —
|
||||
* 초안을 통째로 지우지 않는다. 사라진 측점 고침은 버림(수를 돌려줌 · 서버 `cross_dropped` 와 한 줄로).
|
||||
* `save` 거짓 = 넘기기만(뒤 차례 실패 · 433-12 — 다음 [저장]이 새 노선 초안을 보냄).
|
||||
*/
|
||||
export async function carryAfterRouteSave(
|
||||
projectId: string,
|
||||
fromRouteId: number,
|
||||
toRouteId: number,
|
||||
{ save = true }: { save?: boolean } = {},
|
||||
): Promise<number> {
|
||||
invalidateSectionDetail(projectId, toRouteId);
|
||||
const detail = await loadSectionDetail(projectId, toRouteId);
|
||||
@@ -192,6 +194,7 @@ export async function carryAfterRouteSave(
|
||||
const uphillDropped = uphillBefore - uphillCount();
|
||||
if (uphillDropped > 0)
|
||||
showToast(`상단측 ${uphillDropped}곳 — 새 노선에 같은 측점이 없어 뺐습니다.`, "info");
|
||||
if (!save) return outside;
|
||||
await flushUphillOverrides(projectId);
|
||||
// 옮긴 고침을 새 상세 설계 칸에 — 같은 [저장]의 횡단 패치가 실음(사면 쉬프트 · 조절 · 소단 깊이 · 417)
|
||||
withDraftAdjusts(detail, projectId, toRouteId);
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
/* =============================================================================
|
||||
* logic/B05_Profile_EndOffsets.ts
|
||||
* 「페이지 설정」 시 · 종점 계획고 조정(448) = 첫 · 끝 측점의 계획고 편집(`station_offsets`) 한 칸.
|
||||
* 칸 값 = 자동 선형 대비 그 측점 편집(m) · 바꾸면 끝점 ▲▼ 와 같은 길(`adjustStation`)로 얹음 —
|
||||
* 계산 줄 즉시 · [저장] `alignment` 차례가 서버 정본 · [초기화]는 종단 파일과 함께 0.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
adjustStation,
|
||||
chainageKey,
|
||||
type AlignmentBase,
|
||||
type AlignmentEdits,
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
|
||||
export type ProfileEnd = "start" | "end";
|
||||
|
||||
function endChainage(base: AlignmentBase, end: ProfileEnd): number | undefined {
|
||||
const nodes = base.basePvi;
|
||||
return (end === "start" ? nodes[0] : nodes[nodes.length - 1])?.chainage_m;
|
||||
}
|
||||
|
||||
/** 첫 · 끝 측점 계획고 편집(m) — 편집이 없으면 0. */
|
||||
export function endOffsetsOf(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
): Record<ProfileEnd, number> {
|
||||
const at = (end: ProfileEnd): number => {
|
||||
const chainage = endChainage(base, end);
|
||||
return chainage === undefined ? 0 : (edits.station_offsets[chainageKey(chainage)] ?? 0);
|
||||
};
|
||||
return { start: at("start"), end: at("end") };
|
||||
}
|
||||
|
||||
/** 그 끝 측점 계획고 편집을 `value`(m)로 — 기준이 없으면 편집 그대로. */
|
||||
export function withEndOffset(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
end: ProfileEnd,
|
||||
value: number,
|
||||
): AlignmentEdits {
|
||||
const chainage = endChainage(base, end);
|
||||
if (chainage === undefined || !Number.isFinite(value)) return edits;
|
||||
return adjustStation(base, edits, chainage, value - endOffsetsOf(base, edits)[end]);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile/logic/B05_Profile_Grade_Seat.ts
|
||||
* 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 다른 꼭짓점 그대로.
|
||||
* 옮긴 관 · 구조물의 새 측점에 계획선 꼭짓점 세움(427) — 원지반 지남(`passThrough` 규칙) · 이웃 꼭짓점은 곡선 모양이
|
||||
* 바뀌어 옮기기 전 계획고에서 1mm 넘게 뜨면 그 높이로 맞춤(433-5 · 바로 앞뒤부터 번지는 만큼) · 높이 고친 꼭짓점은 그대로.
|
||||
* 옮기기 전 계획선이 꼭짓점 대부분에서 지반을 안 지나면(균형 종단) 지반 대신 옮기기 전 계획선 높이(438).
|
||||
*
|
||||
* 짝 = 파이썬 `B05_Profile_Router_Confirm._seat_born_nodes` · 세울 측점은 공용 `followMovedNodes`
|
||||
@@ -16,6 +17,8 @@ import {
|
||||
|
||||
/** 지반 종단으로 볼 꼭짓점 자리 계획선 · 지반 어긋남 가운데값(m) — 파이썬 `_SEAT_GROUND_M`. */
|
||||
const SEAT_GROUND_M = 0.01;
|
||||
/** 새 꼭짓점 이웃이 옮기기 전 계획고에서 뜨면 맞출 한도(m · 433-5) — 파이썬 `_SEAT_KEEP_M`. */
|
||||
const SEAT_KEEP_M = 0.001;
|
||||
|
||||
/** `np.median` 짝. */
|
||||
function median(values: number[]): number {
|
||||
@@ -24,11 +27,13 @@ function median(values: number[]): number {
|
||||
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
||||
}
|
||||
|
||||
/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀. */
|
||||
/** 새 측점(`born`)에 원지반을 지나는 꼭짓점 — 양 끝 밖 · 이미 꼭짓점 있는(0.006m 안) 자리는 건너뜀 ·
|
||||
* `held` = 옮긴 뒤 높이 고친 꼭짓점 측점(`followMovedNodes` 의 `held`). */
|
||||
export function seatBornNodes(
|
||||
base: Pick<AlignmentBase, "chainage" | "ground" | "policy" | "basePvi">,
|
||||
nodes: AlignmentNode[],
|
||||
born: ReadonlyArray<number>,
|
||||
held: ReadonlyArray<number> = [],
|
||||
): AlignmentNode[] {
|
||||
if (!nodes.length || base.chainage.length < 2) return nodes;
|
||||
const first = nodes[0].chainage_m;
|
||||
@@ -59,22 +64,40 @@ export function seatBornNodes(
|
||||
...fresh.map((s) => ({ s, z: seatZ(s), node: null })),
|
||||
].sort((a, b) => a.s - b.s);
|
||||
const baseS = rows.map((row) => row.s);
|
||||
const target = rows.map((row) => row.z);
|
||||
let baseZ = [...target];
|
||||
for (let round = 0; round < 20; round += 1) {
|
||||
// 다른 꼭짓점 목표 = 옮기기 전 계획선 그 자리 높이(433-5) · 맞출 수 있는 = 높이 안 고친 것
|
||||
const target = rows.map((row) => (row.node ? evaluateAt(preS, preZ, preCurves, row.s) : row.z));
|
||||
const can = rows.map(
|
||||
(row) => row.node !== null && held.every((value) => Math.abs(row.s - value) >= 0.006),
|
||||
);
|
||||
const free = rows.map((row) => row.node === null);
|
||||
let baseZ = rows.map((row) => row.z);
|
||||
const errorOf = (): number[] => {
|
||||
const pvi = baseS.map((s, index) => ({ chainage_m: s, elevation_m: baseZ[index] }));
|
||||
const curves = buildCurves(pvi, base.policy, {}, []);
|
||||
const error = rows.map((row, index) =>
|
||||
row.node ? 0 : target[index] - evaluateAt(baseS, baseZ, curves, baseS[index]),
|
||||
return baseS.map((s, index) => target[index] - evaluateAt(baseS, baseZ, curves, s));
|
||||
};
|
||||
for (let grow = 0; grow < rows.length; grow += 1) {
|
||||
for (let round = 0; round < 20; round += 1) {
|
||||
const error = errorOf().map((value, index) => (free[index] ? value : 0));
|
||||
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
|
||||
baseZ = baseZ.map((z, index) => z + error[index]);
|
||||
}
|
||||
// 이웃 곡선 모양이 바뀌어 뜬 꼭짓점 — 그 꼭짓점도 맞춤(번지는 만큼)
|
||||
const drift = errorOf().map(
|
||||
(value, index) => can[index] && !free[index] && Math.abs(value) > SEAT_KEEP_M,
|
||||
);
|
||||
if (Math.max(...error.map(Math.abs)) < 1e-6) break;
|
||||
baseZ = baseZ.map((z, index) => z + error[index]);
|
||||
if (!drift.includes(true)) break;
|
||||
drift.forEach((hit, index) => {
|
||||
if (hit) free[index] = true;
|
||||
});
|
||||
}
|
||||
return rows.map(
|
||||
(row, index) =>
|
||||
row.node ?? {
|
||||
chainage_m: Number(row.s.toFixed(6)),
|
||||
elevation_m: Number(baseZ[index].toFixed(6)),
|
||||
},
|
||||
return rows.map((row, index) =>
|
||||
row.node && !free[index]
|
||||
? row.node
|
||||
: {
|
||||
...row.node,
|
||||
chainage_m: Number(row.s.toFixed(6)),
|
||||
elevation_m: Number(baseZ[index].toFixed(6)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/* =============================================================================
|
||||
* 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/logic/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 };
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* 추천 노선 맞춤(`B05_Profile_Engine_RouteInitial_Station.py` `_try` · `_solve`)을 줄인 꼴:
|
||||
* · `from`(`fitStart` = 고친 자리 앞)부터 끝까지 차례로 — 고정 아닌 교각점(IP)을 들어오는 접선 따라 ±간격 안에서
|
||||
* 밀어 MC(BC · EC 누가거리 가운데)를 가까운 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠.
|
||||
* 밀어 MC(BC · EC 누가거리 가운데)를 일반측점(앞 MC 측점 + 간격 이상 · No.1 이상)에 둠 — 내림 · 올림 후보 중 뒤 곡선도 서는 쪽(454).
|
||||
* · 곡선 하한을 새로 깨거나(`shortfallCrossed`) 앞 곡선 MC 를 흔들면 버림 → 그 곡선은 그대로.
|
||||
* · 반지름 · 켬끔은 안 바꿈 — IP 자리만. 합치기 · 곡선 끄기 · 되돌아가기 없음.
|
||||
* · 간격 = 스냅 간격(390-2 · 0.1 · 1 · 10 m · 측점 No.) · 허용 오차도 간격 따라(`mcTolM`).
|
||||
@@ -139,7 +139,9 @@ function solve(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로). */
|
||||
/** `from` 꺾임점부터 끝까지 곡선 MC 를 일반측점에 — 새 IP 목록(못 놓은 곡선 IP 는 그대로).
|
||||
* 곡선마다 후보 측점 둘(내림 · 올림 · 가까운 차례) · 뒤 곡선 하나를 미리 맞춰 보고 둘 다 서는 쪽(433-21 ②)
|
||||
* — 가까운 쪽만 고르면 뒤 곡선이 「앞 측점 + 1」에 몰려 R 이 눌리고 앞 MC 가 흔들려 버려짐. */
|
||||
export function fitCurveStations(
|
||||
cols: Columns & Pick<NodeColumns, "planned">,
|
||||
limits: FitLimits,
|
||||
@@ -150,28 +152,64 @@ export function fitCurveStations(
|
||||
if (!(interval > 0) || planned.length < 3) return planned;
|
||||
let built = build(cols, planned, limits);
|
||||
const floor = curveShortfalls(built.nodes, limits.limitRadiusM, limits.limitArcM);
|
||||
const tol = mcTolM(interval);
|
||||
type Fit = { planned: Vertex[]; built: EditedPolyline };
|
||||
/** 곡선을 놓을 측점 — 내림 · 올림 중 앞 측점 + 1 이상 · 가까운 차례. */
|
||||
const near = (mc: number, last: number): number[] => {
|
||||
const lo = Math.floor(mc / interval);
|
||||
return [lo, lo + 1]
|
||||
.filter((n) => n > last)
|
||||
.sort((a, b) => Math.abs(a * interval - mc) - Math.abs(b * interval - mc));
|
||||
};
|
||||
const onAt = (mc: number, last: number) =>
|
||||
near(mc, last).find((n) => Math.abs(mc - n * interval) <= tol);
|
||||
/** 곡선 `k` 를 측점 `n` 에 — 하한을 새로 깨거나 앞 곡선(`previous`) MC 를 흔들면 null. */
|
||||
const attempt = (at: Fit, k: number, mc: number, n: number, previous: number): Fit | null => {
|
||||
const fitted = solve(cols, limits, at.planned, k, n * interval, mc);
|
||||
if (
|
||||
!fitted ||
|
||||
shortfallCrossed(
|
||||
floor,
|
||||
curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
if (previous < 0) return fitted;
|
||||
const moved = Math.abs(
|
||||
(mcOf(fitted.built, previous) ?? Infinity) - (mcOf(at.built, previous) ?? 0),
|
||||
);
|
||||
return moved <= tol / 5 ? fitted : null;
|
||||
};
|
||||
/** `k` 뒤 첫 곡선이 측점 위이거나 맞춰지나(앞보기 한 걸음). */
|
||||
const nextSettles = (at: Fit, k: number, last: number): boolean => {
|
||||
for (let j = k + 1; j < at.planned.length - 1; j += 1) {
|
||||
const mc = mcOf(at.built, j);
|
||||
if (mc === null) continue;
|
||||
if (onAt(mc, last) !== undefined) return true;
|
||||
return !cols.apexLock[j] && near(mc, last).some((n) => attempt(at, j, mc, n, k) !== null);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
let lastStation = 0; // 앞 곡선이 놓인 측점 번호(없으면 0 = 시점)
|
||||
let previous = -1; // 앞 곡선 꺾임점
|
||||
for (let k = 1; k < planned.length - 1; k += 1) {
|
||||
const mc = mcOf(built, k);
|
||||
if (mc === null) continue;
|
||||
const near = Math.max(lastStation + 1, Math.round(mc / interval));
|
||||
const onStation = Math.abs(mc - near * interval) <= mcTolM(interval);
|
||||
const fitted =
|
||||
onStation || k < from || cols.apexLock[k] || Math.abs(near * interval - mc) > interval / 2
|
||||
? null
|
||||
: solve(cols, limits, planned, k, near * interval, mc);
|
||||
const keep =
|
||||
fitted &&
|
||||
!shortfallCrossed(
|
||||
floor,
|
||||
curveShortfalls(fitted.built.nodes, limits.limitRadiusM, limits.limitArcM),
|
||||
) &&
|
||||
(previous < 0 ||
|
||||
Math.abs((mcOf(fitted.built, previous) ?? Infinity) - (mcOf(built, previous) ?? 0)) <=
|
||||
mcTolM(interval) / 5);
|
||||
if (fitted && keep) ({ planned, built } = fitted);
|
||||
if (onStation || keep) lastStation = near;
|
||||
const on = onAt(mc, lastStation);
|
||||
if (on !== undefined) lastStation = on;
|
||||
else if (k >= from && !cols.apexLock[k]) {
|
||||
let pick: { fit: Fit; n: number } | null = null;
|
||||
for (const n of near(mc, lastStation)) {
|
||||
const fit = attempt({ planned, built }, k, mc, n, previous);
|
||||
if (!fit) continue;
|
||||
pick ??= { fit, n };
|
||||
if (nextSettles(fit, k, n)) {
|
||||
pick = { fit, n };
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (pick) (({ planned, built } = pick.fit), (lastStation = pick.n));
|
||||
}
|
||||
previous = k;
|
||||
}
|
||||
return planned;
|
||||
|
||||
@@ -561,12 +561,14 @@ const FEEDBACK_TOLERANCE_M = 1e-4;
|
||||
* 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼
|
||||
* 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다.
|
||||
* 누른 점 좌우 다음 꺾임(`pivotAxes`)을 축으로 사이 측점이 새 직선 위로 따라 돈다(355-1) · 직선 안 점도 그 점이 새 꺾임(368-1).
|
||||
* 누른 점 R 이 자동 R(`autoRadii` = 저장분 · 관 정착 등 엔진이 역산한 값)과 같으면 지워 기본 L(433-13) · 사용자 R 은 둠.
|
||||
*/
|
||||
export function adjustStation(
|
||||
base: AlignmentBase,
|
||||
edits: AlignmentEdits,
|
||||
chainageM: number,
|
||||
delta: number,
|
||||
autoRadii: Readonly<Record<string, number>> = {},
|
||||
): AlignmentEdits {
|
||||
const key = chainageKey(chainageM);
|
||||
const current = buildAlignment(base, edits);
|
||||
@@ -580,6 +582,7 @@ export function adjustStation(
|
||||
const withOffset = (value: number): AlignmentEdits => {
|
||||
const offsets = { ...edits.station_offsets, [key]: Number(value.toFixed(6)) };
|
||||
const radii = { ...edits.curve_radii };
|
||||
if (radii[key] !== undefined && radii[key] === autoRadii[key]) delete radii[key];
|
||||
axes?.inner.forEach((chainage) => {
|
||||
const [axisM, axisZ] = chainage < chainageM ? [axes.fromM, axes.fromZ] : [axes.toM, axes.toZ];
|
||||
const z =
|
||||
|
||||
@@ -17,6 +17,16 @@ export function readAlignment(data: LongitudinalSection): ProfileAlignment | nul
|
||||
station_offsets: candidate.edits?.station_offsets ?? {},
|
||||
curve_radii: candidate.edits?.curve_radii ?? {},
|
||||
};
|
||||
// 옛 정책엔 법정 최소 R · L 이 없음 — 첫 계획선 설계 기준(criteria = 설계속도 법정값)으로 채움
|
||||
// (433-13 · 서버 `rebuild_alignment_profile` 짝)
|
||||
const criteria = data.design_profiles?.[0]?.criteria;
|
||||
const policy = candidate.policy;
|
||||
if (criteria && (!policy.min_vertical_radius_m || !policy.min_curve_length_m))
|
||||
candidate.policy = {
|
||||
...policy,
|
||||
min_vertical_radius_m: policy.min_vertical_radius_m || criteria.min_vertical_radius_m || 0,
|
||||
min_curve_length_m: policy.min_curve_length_m || criteria.min_curve_length_m || 0,
|
||||
};
|
||||
return candidate;
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,8 @@ export interface DesignProfile {
|
||||
samples: DesignProfileSample[];
|
||||
balance_segments: DesignProfileSegment[];
|
||||
summary: DesignProfileSummary;
|
||||
/** 첫 계획선 설계 기준(서버 `GradeDesignOptions.as_dict`) — 옛 정책 빈 법정 최소 R · L 채움(433-13). */
|
||||
criteria?: { min_vertical_radius_m?: number | null; min_curve_length_m?: number | null };
|
||||
}
|
||||
|
||||
export interface LongitudinalSection {
|
||||
|
||||
@@ -39,11 +39,11 @@ def _regeneration_grade_options(stage_params: dict[str, Any] | None) -> GradeDes
|
||||
)
|
||||
if stage_params.get(key) is not None
|
||||
}
|
||||
# 설계속도는 B05가 저장해 둔 값을 그대로 따른다 — 없으면 종류별 기본(20).
|
||||
# 설계속도 · 지형은 B05가 저장해 둔 값 — 없으면 종류별 기본(20) · B05 기본 특수(448 E2).
|
||||
speed = options.get("design_speed_kph")
|
||||
return resolve_grade_options(
|
||||
str(grade_class),
|
||||
terrain_type=str(options.get("terrain_type") or "normal"),
|
||||
terrain_type=str(options.get("terrain_type") or "special"),
|
||||
paved=bool(options.get("paved", False)),
|
||||
design_speed_kph=int(speed) if speed is not None else None,
|
||||
stored=stored,
|
||||
|
||||
@@ -247,6 +247,7 @@ async def _regenerate_structure_stations(
|
||||
_section_options_from_stored,
|
||||
drop_rows_off_stations,
|
||||
)
|
||||
from B06_Section.B06_Section_Router_Confirm import _prune_cross_files
|
||||
|
||||
stored_options, crs_epsg, longitudinal = await _generation_inputs(project_id, route, snapshot)
|
||||
|
||||
@@ -269,6 +270,8 @@ async def _regenerate_structure_stations(
|
||||
summary = _merge_irregular_into_longitudinal(
|
||||
project_root, str(longitudinal["longitudinal_file_path"]), stations
|
||||
)
|
||||
# 측점을 고친 그 자리에서 옛 자리 cross_*.json 도 지움(관 PUT · 452)
|
||||
_prune_cross_files(project_root, str(longitudinal["longitudinal_file_path"]))
|
||||
return len(stations), summary
|
||||
|
||||
made, summary = await asyncio.to_thread(_regenerate)
|
||||
|
||||
@@ -148,7 +148,8 @@ export function useStructureIds(
|
||||
const looksLikePipe = (section: CrossSection): boolean =>
|
||||
Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? "");
|
||||
|
||||
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). */
|
||||
/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:` · `dedupeStationKeys`). 맨 앞 · 맨 끝 = 관 · 구조물이
|
||||
* 얹혀도(서버 병합이 종류를 irregular 로 갈음해도) `end:BP/EP` — 묶음 `buildStations` · 서버 `station_keys` 와 한 규칙(452). */
|
||||
export function stationKeysOf(
|
||||
projectId: string | null,
|
||||
sections: ReadonlyArray<CrossSection>,
|
||||
@@ -156,10 +157,12 @@ export function stationKeysOf(
|
||||
): Keyed[] {
|
||||
const known = projectId ? (owners.get(projectId) ?? []) : [];
|
||||
const planned = known.some((owner) => owner.kind === "pipe");
|
||||
const keys = sections.map((section) => {
|
||||
const keys = sections.map((section, index) => {
|
||||
const at = section.chainage_m;
|
||||
let key: string;
|
||||
if (section.kind === "irregular") {
|
||||
if (index === 0) key = stationKey("bp", at, intervalM);
|
||||
else if (index === sections.length - 1) key = stationKey("ep", at, intervalM);
|
||||
else 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, owner.role);
|
||||
else if (looksLikePipe(section)) {
|
||||
|
||||
@@ -73,7 +73,6 @@ export interface StructureSpanControl {
|
||||
/** 이 카드가 구간값을 만질 대상 측점 — 링크면 소유 측점, 아니면 자기 자신. */
|
||||
ownerOf: (section: CrossSection) => CrossSection | null;
|
||||
valuesFor: (section: CrossSection, role: SpanRole) => SpanValues | null;
|
||||
update: (section: CrossSection, role: SpanRole, patch: Partial<SpanValues>) => void;
|
||||
/** 다단 한 단의 구간값 — 기준벽과 달리 **단마다 따로** 잡는다(2026-08-29 사용자).
|
||||
* 값은 세션에 담기고 [저장]·[확정] 때 정본으로 간다. 없으면 기본 10m(5/5). */
|
||||
tierValuesFor: (section: CrossSection, key: RevetKey) => SpanValues;
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { createCadHost, type CadHostDrawing } from "@ui/cad_host/cad_host";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { PIPE_MATCH_M } from "../A00_Common/design/design_selection";
|
||||
import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
@@ -45,7 +46,19 @@ export function readStructureNoteCads(
|
||||
return new Map(Object.entries(raw ?? {}).map(([key, cad]) => [Number(key), cad]));
|
||||
}
|
||||
|
||||
/** 표 위 CAD 요소 한도 — 서버 `CrossNoteCad.entities max_length` 짝(넘으면 횡단 patch 통째 422 · 433-20). */
|
||||
export const NOTE_CAD_MAX_ENTITIES = 2000;
|
||||
|
||||
function writeNoteCad(section: CrossSection, cad: CrossNoteCad, at: NoteCadPlace): void {
|
||||
// 한도 넘음 = 초안에 안 씀(앞 초안 그대로) · 알림
|
||||
if (cad.entities.length > NOTE_CAD_MAX_ENTITIES) {
|
||||
showToast(
|
||||
`표 위 CAD 요소 ${cad.entities.length}개 — 한도 ${NOTE_CAD_MAX_ENTITIES}개를 넘어 저장하지 않습니다. 요소를 줄이세요.`,
|
||||
"warning",
|
||||
8000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 캐시 설계에도 얹음 — B05 [저장](`crossPatchesFromCache`)이 같은 값을 실음
|
||||
if (section.design) section.design.structure_note_cad = cad;
|
||||
const all = readState<Record<string, CrossNoteCad>>(
|
||||
|
||||
@@ -194,13 +194,9 @@ export function structurePanelDeps(ctx: StructurePanelContext): StructurePanelDe
|
||||
const role = spanRoleOf(key, ctx.inletIsBasin());
|
||||
return role && ctx.structureSpan ? ctx.structureSpan.valuesFor(ctx.section, role) : null;
|
||||
},
|
||||
// 구간 줄 = 다단 벽만(기준벽 · 집수정은 폼 칸 = 관 초안 · 433-19)
|
||||
setSpan: (key, patch) => {
|
||||
if (isTierKey(key)) {
|
||||
ctx.structureSpan?.updateTier(ctx.section, key, patch);
|
||||
return;
|
||||
}
|
||||
const role = spanRoleOf(key, ctx.inletIsBasin());
|
||||
if (role) ctx.structureSpan?.update(ctx.section, role, patch);
|
||||
if (isTierKey(key)) ctx.structureSpan?.updateTier(ctx.section, key, patch);
|
||||
},
|
||||
// 벽을 **그리지 않는 카드에는 [연동]을 내지 않는다**(2026-09-06). 링크 카드는 소유
|
||||
// 측점의 벽을 빌려 그리는데, 빌릴 벽이 없는 카드에도 버튼이 떠 눌러도 아무 일이
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 구조물 **구간값(길이·전/후)** 제어 — 다단 단별 구간값과 유입·유출·집수정 구간값.
|
||||
*
|
||||
* `B06_Section_UI_Page_Station_Controls` 에서 떼어냈다(700줄 제한, 2026-09-02).
|
||||
* 세션 맵(`extraSpans`)과 캐시(design)·정본 큐(`culvertOptions`)를 함께 만지는
|
||||
* 자리라 한 덩어리로 옮겼다. 판정 규칙은 종전대로 `_Cross_Culvert_Const` 몫이다.
|
||||
* 세션 맵(`extraSpans`)과 캐시(design)를 함께 만지는 자리라 한 덩어리로 옮겼다.
|
||||
* 기준벽 · 집수정 구간값은 폼 칸(관 초안)이 정본 — 여기는 읽기만(433-19 · 444).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossDesign, CrossSection, StoredWallSpan } from "./B06_Section_Api_Fetch";
|
||||
@@ -25,8 +25,6 @@ export interface SpanControlDeps {
|
||||
extraSpans: Map<string, SpanValues>;
|
||||
persistExtraSpans: () => void;
|
||||
patchCachedDesign: (chainageM: number, patch: Partial<CrossDesign>) => void;
|
||||
/** 정본 반영 큐(관 옵션). */
|
||||
culvertOptions: { queue: (chainageM: number, values: Record<string, number>) => void };
|
||||
}
|
||||
|
||||
/** 구간값 제어기와 단별 구간값 조회를 만든다. */
|
||||
@@ -36,7 +34,7 @@ export function createSpanControl(deps: SpanControlDeps): {
|
||||
tierSpansOf: (owner: CrossSection) => Record<string, StoredWallSpan>;
|
||||
spanKeyOf: (chainageM: number, wall: string) => string;
|
||||
} {
|
||||
const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf, culvertOptions } = deps;
|
||||
const { extraSpans, persistExtraSpans, patchCachedDesign, ownerOf } = deps;
|
||||
const spanKeyOf = (chainageM: number, wall: string): string => `${chainageM.toFixed(2)}:${wall}`;
|
||||
|
||||
const storedTierSpan = (owner: CrossSection, wall: string): SpanValues => {
|
||||
@@ -91,40 +89,6 @@ export function createSpanControl(deps: SpanControlDeps): {
|
||||
const owner = ownerOf(section);
|
||||
return owner ? CulvertConst.spanValuesOf(owner, role) : null;
|
||||
},
|
||||
update: (section, role, patch) => {
|
||||
const owner = ownerOf(section);
|
||||
if (!owner?.culvert) return;
|
||||
const current = CulvertConst.spanValuesOf(owner, role);
|
||||
if (!current) return;
|
||||
// 길이↔전/후 산식은 다단과 공용(`applySpanPatch`).
|
||||
const { lengthM, beforeM, afterM } = CulvertConst.applySpanPatch(current, patch);
|
||||
const spec = role === "outlet" ? owner.culvert.outlet : owner.culvert.inlet;
|
||||
const keys = CulvertConst.SPAN_OPTION_KEYS[role];
|
||||
if (role === "basin") {
|
||||
spec.basin_length_m = lengthM;
|
||||
spec.basin_before_m = beforeM;
|
||||
spec.basin_after_m = afterM;
|
||||
} else {
|
||||
spec.revet_length_m = lengthM;
|
||||
spec.revet_before_m = beforeM;
|
||||
spec.revet_after_m = afterM;
|
||||
}
|
||||
culvertOptions.queue(owner.chainage_m, {
|
||||
[keys.length]: lengthM,
|
||||
[keys.before]: beforeM,
|
||||
[keys.after]: afterM,
|
||||
});
|
||||
// 연장이 바뀌면 링크되는 옆 측점 목록이 달라진다. 다시 그릴 대상은 **옛 연장과
|
||||
// 새 연장을 합친 구간**뿐이다 — 측점 23개를 통째로 다시 그리면 조정창이 매번
|
||||
// 새로 만들어져 연타한 +/-가 중간에 삼켜진다(2026-08-24 화면 실측: 3번 눌러
|
||||
// 2번만 반영).
|
||||
const reach = Math.max(current.beforeM, current.afterM, beforeM, afterM);
|
||||
for (const other of deps.detail()?.cross_sections ?? []) {
|
||||
if (Math.abs(other.chainage_m - owner.chainage_m) <= reach + 1e-9) {
|
||||
deps.refreshCard(other.chainage_m);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return { control, tierSpansOf, spanKeyOf };
|
||||
|
||||
@@ -505,7 +505,6 @@ export function createStationControls(deps: StationControlDeps): StationControls
|
||||
extraSpans,
|
||||
persistExtraSpans,
|
||||
patchCachedDesign,
|
||||
culvertOptions,
|
||||
});
|
||||
const structureSpanControl = spanControl.control;
|
||||
const spanKeyOf = spanControl.spanKeyOf;
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* B06_Section_Station_Swap.ts
|
||||
* 측점 수가 바뀔 때 횡단 목록 갈아끼움(PLAN 346-2 4파) — 계산 묶음 측점(`PipelineStation[]`) → 새 상세 객체.
|
||||
*
|
||||
* · 같은 측점(열쇠 · 없으면 같은 자리 같은 종류)은 앞 횡단을 이어 받음 — 설계 · 구조물 제원 · 이름 그대로 ·
|
||||
* 자리(누가거리 · 좌표 · 방위 · 확폭)와 지반 표본(채워 왔으면)만 새것.
|
||||
* · 같은 측점(열쇠 · MC 가 움직여 바뀐 곡선 열쇠 `curveMoves` · 없으면 같은 자리 같은 종류)은 앞 횡단을 이어
|
||||
* 받음 — 설계 · 구조물 제원 · 이름 그대로 · 자리(누가거리 · 좌표 · 방위 · 확폭)만 새것(433-4).
|
||||
* · 지반 표본 · 상단측 · 중심고 = 자리 · 축이 같은 앞 횡단이면 앞 값(서버 샘플링 정본 · 덧 측점만 넣어도 나머지
|
||||
* 측점 면적이 브라우저 지형으로 안 바뀜 · 433-11) · 새 측점 · 옮긴 측점만 지반 표본(채워 왔으면).
|
||||
* · 새 측점 = 지반 표본 + 기본 설계(계획고 0 → 낡음 → 브라우저 다시 셈이 곧 채움).
|
||||
* · 상단측 = 새 지반 표본으로 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428).
|
||||
* · 상단측 = 새 지반 표본으로 다시 셈한 측점은 다시(단면 유형 기본값 재료 · 서버 횡단 파일 `uphill_side` 짝 · 428).
|
||||
* · 0.5 m 넘게 옮긴 관 · 구조물 측점 = 기본 설계 + 사용자 칸(만진 칸 · 지반 등 입력 `ROW_CARRY_KEYS`) — 서버가
|
||||
* 그 행을 지우고 기본 설계에 [저장] patch 를 얹음(`sync_cross_section_rows` · `compute_default_designs` · 428) ·
|
||||
* patch 가 그 칸을 모두 실음(`crossPatchesFromCache` · 445 H2).
|
||||
@@ -20,6 +22,7 @@
|
||||
import type { PipelineStation } from "../../A00_Common/design/logic/design_pipeline_types";
|
||||
import type { CrossSection, SectionDetailResponse, SectionStation } from "../B06_Section_Api_Fetch";
|
||||
import { uphillSide } from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import { curveMoves } from "../../common_util/common_util_station_key";
|
||||
|
||||
interface Sample {
|
||||
offset_m: number;
|
||||
@@ -141,9 +144,21 @@ export interface StationSwap {
|
||||
moved: boolean;
|
||||
}
|
||||
|
||||
/** 앞 횡단과 자리(누가거리 · 좌표 0.01 m) · 축(왼쪽 단위 벡터)이 같은가 — 같으면 앞 지반 표본을 그대로 씀. */
|
||||
function samePlace(prior: CrossSection, station: PipelineStation): boolean {
|
||||
const [lx, ly] = prior.frame?.left_xy ?? [Number.NaN, Number.NaN];
|
||||
return (
|
||||
Math.abs(prior.chainage_m - station.chainageM) < 0.01 &&
|
||||
Math.hypot(prior.center_x - station.x, prior.center_y - station.y) <= 0.01 &&
|
||||
Math.abs(lx - station.left[0]) < 1e-4 &&
|
||||
Math.abs(ly - station.left[1]) < 1e-4
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 새 측점 목록으로 상세를 새로 짬. `oldKeys` = 지금 상세 측점마다 열쇠(같은 차례 · `stationKeysOf`) ·
|
||||
* 열쇠가 안 맞으면 같은 자리(0.01 m) · 같은 종류의 앞 횡단을 씀.
|
||||
* 열쇠가 안 맞으면 옮긴 곡선 열쇠(`curveMoves` · 서버 `carry_cross_inputs` 같은 규칙) · 그다음 같은 자리(0.01 m) ·
|
||||
* 같은 종류의 앞 횡단을 씀.
|
||||
*/
|
||||
export function swapCrossSections(
|
||||
detail: SectionDetailResponse,
|
||||
@@ -157,8 +172,18 @@ export function swapCrossSections(
|
||||
const added: string[] = [];
|
||||
const moves = new Map<string, string>();
|
||||
const text = (chainageM: number) => chainageM.toFixed(2);
|
||||
// 새 곡선 열쇠 → 옛 곡선 열쇠(R 바꿔 MC 가 움직임 · 433-4)
|
||||
const curveFrom = new Map(
|
||||
[
|
||||
...curveMoves(
|
||||
detail.cross_sections.map((section, i) => [oldKeys[i], section.chainage_m] as const),
|
||||
stations.map((station) => [station.key, station.chainageM] as const),
|
||||
),
|
||||
].map(([from, to]) => [to, from]),
|
||||
);
|
||||
const priorOf = (station: PipelineStation): CrossSection | undefined => {
|
||||
const hit = byKey.get(station.key);
|
||||
const from = curveFrom.get(station.key);
|
||||
const hit = byKey.get(station.key) ?? (from === undefined ? undefined : byKey.get(from));
|
||||
if (hit && !used.has(hit)) return hit;
|
||||
return detail.cross_sections.find(
|
||||
(section) =>
|
||||
@@ -182,6 +207,8 @@ export function swapCrossSections(
|
||||
moved = true;
|
||||
if (prior && text(prior.chainage_m) !== text(station.chainageM))
|
||||
moves.set(text(prior.chainage_m), text(station.chainageM));
|
||||
// 자리 같은 앞 횡단 = 서버 표본 그대로(433-11) · 아니면 새 지반 표본(채워 왔으면)
|
||||
const fresh = !!station.ground && !(prior?.samples?.length && samePlace(prior, station));
|
||||
const kind = kindOf(station);
|
||||
const onGrid =
|
||||
kind === "irregular" &&
|
||||
@@ -193,7 +220,9 @@ export function swapCrossSections(
|
||||
label: formatStation(station.chainageM),
|
||||
kind,
|
||||
...(onGrid ? { grid: true } : {}),
|
||||
center_z: station.ground?.centerZ ?? prior?.center_z ?? null,
|
||||
center_z: fresh
|
||||
? (station.ground?.centerZ ?? prior?.center_z ?? null)
|
||||
: (prior?.center_z ?? null),
|
||||
azimuth_deg: station.azimuthDeg,
|
||||
plan_radius_m: station.planRadiusM,
|
||||
curve_outer_side: station.curveOuterSide,
|
||||
@@ -203,8 +232,8 @@ export function swapCrossSections(
|
||||
frame: { left_xy: [station.left[0], station.left[1]] as [number, number] },
|
||||
};
|
||||
longStations.push({ ...place, ...(prior?.structure ? { structure: prior.structure } : {}) });
|
||||
const samples = station.ground ? groundSamples(station) : (prior?.samples ?? []);
|
||||
const uphill = station.ground ? uphillSide(station) : (prior?.uphill_side ?? null);
|
||||
const samples = fresh ? groundSamples(station) : (prior?.samples ?? []);
|
||||
const uphill = fresh ? uphillSide(station) : (prior?.uphill_side ?? null);
|
||||
if (!prior) return { ...place, uphill_side: uphill, samples, design: freshDesign(uphill) };
|
||||
const reseed =
|
||||
(station.kind === "pipe" || station.kind === "structure") &&
|
||||
|
||||
@@ -218,6 +218,8 @@ async def save_initial_snapshot(
|
||||
dump: dict[str, Any] = {"routes": _json_safe([dict(route)])}
|
||||
for table in _CHILD_TABLES:
|
||||
dump[table] = _json_safe(await _dump_rows(connection, table, route_id))
|
||||
# stage 2 params(제어점 · 페이지 설정)도 — [초기화]가 함께 되돌림(448 E3)
|
||||
dump["stage2_params"] = await _stage2_params(connection, str(route["project_id"]))
|
||||
(target / _DB_DUMP_NAME).write_text(json.dumps(dump, ensure_ascii=False), encoding="utf-8")
|
||||
|
||||
|
||||
@@ -322,9 +324,40 @@ async def restore_initial_snapshot(
|
||||
if "project_id" in child:
|
||||
child["project_id"] = project_id
|
||||
await _insert_row(connection, table, child)
|
||||
await _restore_stage2_params(connection, project_id, dump)
|
||||
return new_id
|
||||
|
||||
|
||||
async def _stage2_params(connection: aiomysql.Connection, project_id: str) -> Any:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT params FROM project_workflow_stages WHERE project_id = %s AND stage_no = 2",
|
||||
(project_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
raw = row[0] if row else None
|
||||
return json.loads(raw) if isinstance(raw, str) else raw
|
||||
|
||||
|
||||
async def _restore_stage2_params(
|
||||
connection: aiomysql.Connection, project_id: str, dump: dict[str, Any]
|
||||
) -> None:
|
||||
"""뜬 stage 2 params 로 되씀 — 안 뜬 옛 스냅샷은 페이지 설정 칸만 걷어 기본값(448 E3)."""
|
||||
from B05_Profile.B05_Profile_PageSettings import strip_page_settings
|
||||
|
||||
if "stage2_params" in dump:
|
||||
params = dump["stage2_params"]
|
||||
else:
|
||||
params = strip_page_settings(await _stage2_params(connection, project_id))
|
||||
if params is None:
|
||||
return
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"UPDATE project_workflow_stages SET params = %s WHERE project_id = %s AND stage_no = 2",
|
||||
(json.dumps(params, ensure_ascii=False), project_id),
|
||||
)
|
||||
|
||||
|
||||
async def _insert_row(connection: aiomysql.Connection, table: str, row: dict[str, Any]) -> int:
|
||||
columns = list(row.keys())
|
||||
placeholders = ", ".join(["%s"] * len(columns))
|
||||
|
||||
@@ -344,14 +344,15 @@ export function oneStationList<S extends ListedStation, P extends { chainage_m:
|
||||
* 정수 m 맞춤 자리(272)면 높이 그대로 옮김. 옮긴 관 · 구조물(427) — 짝 못 찾은 옛 측점과 새 측점이 같은
|
||||
* 수면 차례대로 짝 · 높이 고친 꼭짓점(`edited` = 저장 `station_offsets` 키)은 높이째 · 나머지 새 측점은
|
||||
* `seat`(세움은 부르는 쪽이 `seatBornNodes` · B05_Profile/logic/B05_Profile_Grade_Seat.ts) · 짝 없는 옛
|
||||
* 꼭짓점은 지움. 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
|
||||
* 꼭짓점은 지움 · `held` = 옮긴 뒤 높이 고친 꼭짓점 측점(서버 옮긴 `station_offsets` 키 · 433-5).
|
||||
* 거울 시험 = `resources/tester/test_427_1_follow_seat.py`.
|
||||
*/
|
||||
export function followMovedNodes<N extends { chainage_m: number }>(
|
||||
nodes: ReadonlyArray<N>,
|
||||
gone: ReadonlyArray<number>,
|
||||
born: ReadonlyArray<number>,
|
||||
edited: ReadonlyArray<string> = [],
|
||||
): { nodes: N[]; seat: number[] } {
|
||||
): { nodes: N[]; seat: number[]; held: number[] } {
|
||||
const target = (old: number): number | null => {
|
||||
const near = born.find((value) => Math.abs(value - old) < 0.006);
|
||||
if (near !== undefined) return near;
|
||||
@@ -379,5 +380,10 @@ export function followMovedNodes<N extends { chainage_m: number }>(
|
||||
const to = moves.get(old) ?? null;
|
||||
return to === null || seat.includes(to) ? [] : [{ ...node, chainage_m: to }];
|
||||
});
|
||||
return { nodes: kept, seat };
|
||||
const held = edited.flatMap((key) => {
|
||||
const old = gone.find((value) => Math.abs(value - Number(key)) < 0.006);
|
||||
const to = old === undefined ? Number(key) : (moves.get(old) ?? null);
|
||||
return to === null ? [] : [to];
|
||||
});
|
||||
return { nodes: kept, seat, held };
|
||||
}
|
||||
|
||||
@@ -88,6 +88,37 @@ async def start_stage(
|
||||
)
|
||||
|
||||
|
||||
def merged_params(params: Dict[str, Any] | None, patch: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""params 에 patch 를 얹는다 — `options` 는 칸끼리 합침 · 나머지는 갈음(448)."""
|
||||
merged = dict(params or {})
|
||||
for key, value in patch.items():
|
||||
if key == "options" and isinstance(value, dict):
|
||||
merged["options"] = {**(merged.get("options") or {}), **value}
|
||||
else:
|
||||
merged[key] = value
|
||||
return merged
|
||||
|
||||
|
||||
async def merge_stage_params(
|
||||
cursor: aiomysql.DictCursor, project_id: str, stage_no: int, patch: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""단계 params 에 patch 만 얹는다 — 상태 · 이후 단계(STALE)는 그대로(448 페이지 설정)."""
|
||||
await cursor.execute(
|
||||
"SELECT params FROM project_workflow_stages "
|
||||
"WHERE project_id = %s AND stage_no = %s FOR UPDATE",
|
||||
(project_id, stage_no),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
raw = (row["params"] if isinstance(row, dict) else row[0]) if row else None
|
||||
params = json.loads(raw) if isinstance(raw, str) else raw
|
||||
merged = merged_params(params if isinstance(params, dict) else None, patch)
|
||||
await cursor.execute(
|
||||
"UPDATE project_workflow_stages SET params = %s WHERE project_id = %s AND stage_no = %s",
|
||||
(json.dumps(merged, ensure_ascii=False), project_id, stage_no),
|
||||
)
|
||||
return merged
|
||||
|
||||
|
||||
async def complete_stage(cursor: aiomysql.DictCursor, project_id: str, stage_no: int) -> None:
|
||||
"""단계를 완료 상태로 전환한다."""
|
||||
now = datetime.utcnow()
|
||||
|
||||
@@ -89,6 +89,7 @@ tools.createPanelTools({
|
||||
base: () => ({}),
|
||||
alignment: () => null,
|
||||
edits: () => ({}),
|
||||
savedRadii: () => ({}),
|
||||
applyEdits: () => undefined,
|
||||
selectedStationId: () => "irregular:p1",
|
||||
irregularStations: () => [pipe],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* 344-3 · 346-2 2파 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름.
|
||||
* 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross · masshaul · haul 단계를 화면 · 서버 없이 잰다.
|
||||
* 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross 단계를 화면 · 서버 없이 잰다.
|
||||
*/
|
||||
export {
|
||||
carryStationDrafts,
|
||||
@@ -20,8 +20,6 @@ export {
|
||||
readRockBoundarySession,
|
||||
} from "../../B06_Section/B06_Section_UI_Page_Persist";
|
||||
export { createCrossStage, groundSamples } from "../../B06_Section/logic/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,
|
||||
|
||||
@@ -202,39 +202,4 @@ const edited = m.buildAlignment(base, { station_offsets: { "40.000": 0.4 }, curv
|
||||
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));
|
||||
|
||||
@@ -133,7 +133,7 @@ async function run() {
|
||||
};
|
||||
}
|
||||
|
||||
// ② 늦은 응답 버림 — 브라우저 단계(ground 늦음) · 뒤 단계(haul 늦음 · 끊김)
|
||||
// ② 늦은 응답 버림 — 브라우저 단계(ground 늦음) · 뒤 단계(corridor 늦음 · 끊김)
|
||||
{
|
||||
const log = [];
|
||||
let releaseGround = null;
|
||||
@@ -161,25 +161,27 @@ async function run() {
|
||||
commits,
|
||||
};
|
||||
|
||||
// 뒤 단계 — haul 1 이 늦게 옴 · 그 사이 새 조작 → 끊김 · 버림
|
||||
let releaseHaul = [];
|
||||
const hold2 = { haul: () => new Promise((resolve) => releaseHaul.push(resolve)) };
|
||||
// 뒤 단계 — corridor 1 이 늦게 옴 · 그 사이 새 조작 → 끊김 · 버림
|
||||
let releaseAfter = [];
|
||||
const hold2 = { corridor: () => new Promise((resolve) => releaseAfter.push(resolve)) };
|
||||
const log2 = [];
|
||||
const pipe2 = pipeMod.createDesignPipeline({ stages: fakeStages(log2, hold2) });
|
||||
const commits2 = [];
|
||||
pipe2.subscribe((e) => e.kind === "stage" && e.stage === "haul" && commits2.push(e.generation));
|
||||
pipe2.subscribe(
|
||||
(e) => e.kind === "stage" && e.stage === "corridor" && commits2.push(e.generation),
|
||||
);
|
||||
await pipe2.start(BASE);
|
||||
const haul1 = log2.find((e) => e.name === "haul");
|
||||
const after1 = log2.find((e) => e.name === "corridor");
|
||||
await pipe2.update("cross", { x: 1 }, { fromM: 0, toM: 0 });
|
||||
const haul1Aborted = haul1.aborted();
|
||||
releaseHaul[0]();
|
||||
const after1Aborted = after1.aborted();
|
||||
releaseAfter[0]();
|
||||
await tick(1);
|
||||
releaseHaul[1]();
|
||||
releaseAfter[1]();
|
||||
await pipe2.settled();
|
||||
out.lateAfter = {
|
||||
haul1Aborted,
|
||||
after1Aborted,
|
||||
commits: commits2,
|
||||
haulGen: pipe2.output("haul").gen,
|
||||
afterGen: pipe2.output("corridor").gen,
|
||||
generation: pipe2.generation,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/* PLAN 346-1 · 346-2 붙임 단위검증 헬퍼 — 페이지 묶음에 cross · masshaul · haul · drainage · corridor 를
|
||||
/* PLAN 346-1 · 346-2 붙임 단위검증 헬퍼 — 페이지 묶음에 cross · drainage · corridor 를
|
||||
* 붙인 것. 진짜 TS(`design_pipeline_page` · `_cross` · 단계 파일 전부)를 그 자리에서 트랜스파일해 Node 로
|
||||
* 돌림. 서버 창구(노선 · 구조물 · 관 목록 · 지형 격자 · 표준단면 · 흙 배분 fetch)만 가짜.
|
||||
* 입력 argv[2] JSON · argv[3] 격자 이진. 결과 = 표준출력 JSON. 짝 = `test_346_2_pipeline_cross_page.py`. */
|
||||
@@ -6,6 +6,7 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
const finalCumulative = require("./helper_454_final_cumulative.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const job = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
@@ -29,7 +30,7 @@ globalThis.window = {
|
||||
clearTimeout,
|
||||
};
|
||||
|
||||
// 흙 배분 서버 — 늦게 답함 · 끊기면 버림
|
||||
// 흙 배분 서버 — 늦게 답함 · 끊기면 버림 · 묶음은 안 부름(454 masshaul · haul 단계 걷음)
|
||||
const haul = { calls: 0, aborted: 0, answered: 0 };
|
||||
globalThis.fetch = (url, init) =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -205,7 +206,7 @@ async function run() {
|
||||
stationIntervalM: job.interval,
|
||||
onCorridorStale: () => (corridor += 1),
|
||||
});
|
||||
pageMod.pipelineEarthwork(P, { conversion: job.conversion });
|
||||
pageMod.pipelineEarthwork(P);
|
||||
const pipe = pageMod.pagePipeline(P);
|
||||
const commits = [];
|
||||
pipe.subscribe((e) => e.kind === "stage" && commits.push(`${e.stage}@${e.generation}`));
|
||||
@@ -242,7 +243,6 @@ async function run() {
|
||||
const ground = pipe.output("ground");
|
||||
const profile = pipe.output("profile");
|
||||
const shown = pageMod.pipelineDetail(P);
|
||||
const mass = pipe.output("masshaul");
|
||||
const moved = {
|
||||
run: {
|
||||
outcome: runB.outcome,
|
||||
@@ -264,7 +264,7 @@ async function run() {
|
||||
cut: pipe.output("cross").byKey[s.key]?.cutAreaM2 ?? null,
|
||||
fill: pipe.output("cross").byKey[s.key]?.fillAreaM2 ?? null,
|
||||
})),
|
||||
finalCumulative: mass?.haulInput?.final_cumulative_m3 ?? null,
|
||||
finalCumulative: finalCumulative(load, pipe, job.conversion),
|
||||
footprint: pipe.output("footprint")?.records.length ?? 0,
|
||||
detail: shown && {
|
||||
sections: shown.cross_sections.length,
|
||||
|
||||
@@ -113,6 +113,7 @@ const NEW = (dy) => [
|
||||
kinds: swap.detail.cross_sections.map((s) => s.kind),
|
||||
labels: [swap.detail.cross_sections[2].label, swap.detail.cross_sections[7].label],
|
||||
samples: swap.detail.cross_sections[1].samples.map((s) => s.offset_m),
|
||||
movedSamples: swap.detail.cross_sections[2].samples.map((s) => s.offset_m),
|
||||
oldUntouched:
|
||||
before.cross_sections.length === 7 && before.cross_sections[3].chainage_m === 45.054,
|
||||
length: swap.detail.longitudinal.length_m,
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const finalCumulative = require("./helper_454_final_cumulative.cjs");
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
@@ -167,7 +168,7 @@ async function run() {
|
||||
drawn = next;
|
||||
void Promise.resolve().then(renderB06);
|
||||
});
|
||||
const final = () => pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null;
|
||||
const final = () => finalCumulative(load, pipe, job.conversion);
|
||||
const cut = () =>
|
||||
Object.values(pipe.output("cross")?.byKey ?? {}).reduce((s, c) => s + (c.cutAreaM2 ?? 0), 0);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const finalCumulative = require("./helper_454_final_cumulative.cjs");
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
@@ -249,7 +250,7 @@ async function draftRun(P, grid) {
|
||||
totalMs: run.totalMs,
|
||||
line: pipe.output("route").line,
|
||||
curves: pipe.output("route").curves,
|
||||
final: pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null,
|
||||
final: finalCumulative(load, pipe, job.conversion),
|
||||
stations,
|
||||
copied,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const finalCumulative = require("./helper_454_final_cumulative.cjs");
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
@@ -198,7 +199,7 @@ async function run() {
|
||||
stations: chainagesOf(stations),
|
||||
saved: job.detail.cross_sections.map((s) => s.chainage_m),
|
||||
pipeChainages: (pipe.input("pipes") ?? []).map((p) => p.chainage_m),
|
||||
final: pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null,
|
||||
final: finalCumulative(load, pipe, job.conversion),
|
||||
footprint: pipe.output("footprint")?.records.length ?? 0,
|
||||
};
|
||||
|
||||
@@ -239,7 +240,7 @@ async function run() {
|
||||
const again = await openOnce("proj-349b");
|
||||
const reopen = {
|
||||
runs: runs().slice(again.before).length,
|
||||
final: again.pipe.output("masshaul")?.haulInput?.final_cumulative_m3 ?? null,
|
||||
final: finalCumulative(load, again.pipe, job.conversion),
|
||||
stations: chainagesOf(again.pipe.output("ground")?.stations),
|
||||
};
|
||||
again.stop();
|
||||
|
||||
@@ -146,7 +146,15 @@ m.followEditorPipes(fresh2, m.createFacilityStore(), [
|
||||
]);
|
||||
out.fresh = [fresh.pipes(), fresh2.pipes()].map((list) => list.map((p) => [p.id, p.chainage_m]));
|
||||
|
||||
/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) */
|
||||
/* 6. B06 측점 열쇠 — 서버 관 id 도 관(계산 묶음 `pipe:` 와 같음) · 맨 앞 · 맨 끝 = 시종점(452)이라 BP · EP 사이에 둠 */
|
||||
const keysOf = (project, list) =>
|
||||
m
|
||||
.stationKeysOf(
|
||||
project,
|
||||
[{ chainage_m: 0, kind: "bp" }, ...list, { chainage_m: 400, kind: "ep" }],
|
||||
20,
|
||||
)
|
||||
.slice(1, -1);
|
||||
const planned = [
|
||||
{ chainage_m: 80.17, owners: ["p8017"], role: "anchor" },
|
||||
{ chainage_m: 52.5, owners: ["st-1"], role: "anchor" },
|
||||
@@ -156,9 +164,9 @@ const sections = [
|
||||
{ chainage_m: 80.17, kind: "irregular" },
|
||||
];
|
||||
m.useStationOwners(P, planned, [{ id: "p8017", chainage_m: 80.17 }]);
|
||||
out.keys = m.stationKeysOf(P, sections, 20).map((k) => k.key);
|
||||
out.keys = keysOf(P, sections).map((k) => k.key);
|
||||
m.useStationOwners(P, planned);
|
||||
out.keysNoPipes = m.stationKeysOf(P, sections, 20).map((k) => k.key);
|
||||
out.keysNoPipes = keysOf(P, sections).map((k) => k.key);
|
||||
out.pipelineKey = m.pipelineStationKey("pipe", "p8017");
|
||||
|
||||
/* 7. 411-E — 관 계획을 아는 동안 옛 자리 관 측점(옮겨 간 자리)은 세션 id 를 안 지음 · 헛 id 정리 */
|
||||
@@ -171,10 +179,10 @@ const staleSections = [
|
||||
{ chainage_m: 80, kind: "irregular", structure: "파형강관 D800", culvert: {} },
|
||||
{ chainage_m: 150, kind: "irregular", structure: "파형강관 D800", culvert: {} }, // 옛 자리
|
||||
];
|
||||
out.staleKeys = m.stationKeysOf(E, staleSections, 20).map((k) => k.key);
|
||||
out.staleKeys = keysOf(E, staleSections).map((k) => k.key);
|
||||
out.staleBook = bookSize();
|
||||
m.useStationOwners(E, []); // 관 계획 모름(초안 없음) = 옛 갈래 그대로 지음
|
||||
out.legacyKey = m.stationKeysOf(E, [staleSections[1]], 20)[0].key;
|
||||
out.legacyKey = keysOf(E, [staleSections[1]])[0].key;
|
||||
out.legacyBook = bookSize();
|
||||
m.prunePipeIds([80], E); // 지금 관 자리만 남김
|
||||
out.pruned = { size: bookSize(), at80: m.pipeIdAt(80, E), e1 };
|
||||
|
||||
@@ -28,6 +28,6 @@ const station = loadTs("common_util/common_util_structure_station.ts");
|
||||
const seat = loadTs("B05_Profile/logic/B05_Profile_Grade_Seat.ts");
|
||||
const out = cases.map((item) => {
|
||||
const moved = station.followMovedNodes(item.base.basePvi, item.gone, item.born, item.edited);
|
||||
return seat.seatBornNodes(item.base, moved.nodes, moved.seat);
|
||||
return seat.seatBornNodes(item.base, moved.nodes, moved.seat, moved.held);
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// 433-4 · 433-11 — 측점 갈아끼움(`swapCrossSections` · 진짜) · 횡단 엔진(`computeCrossDesign` · 진짜) ·
|
||||
// 유토(`computeMassHaul` · 진짜). 인자 = 표준단면 json · 토량환산계수 json.
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`;
|
||||
const STUBS = {
|
||||
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
|
||||
B05_Profile_UI_Profile_Data: { toDesignProfile: () => ({}) },
|
||||
};
|
||||
const cache = new Map();
|
||||
function load(full) {
|
||||
if (cache.has(full)) return cache.get(full).exports;
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box);
|
||||
const req = (r) => {
|
||||
const name = r.split("/").pop();
|
||||
if (STUBS[name]) return STUBS[name];
|
||||
if (r.startsWith("@util/")) return load(path.join(ROOT, "common_util", `${r.slice(6)}.ts`));
|
||||
if (r.startsWith(".")) return load(path.resolve(path.dirname(full), `${r}.ts`));
|
||||
throw new Error(`stub 없음: ${r}`);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
return box.exports;
|
||||
}
|
||||
const swap = load(path.join(ROOT, "B06_Section/logic/B06_Section_Station_Swap.ts"));
|
||||
const { computeCrossDesign } = load(path.join(ROOT, "common_util/common_util_cross_design.ts"));
|
||||
const { computeMassHaul } = load(path.join(ROOT, "common_util/common_util_mass_haul.ts"));
|
||||
const standard = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const conversion = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
|
||||
|
||||
const st = (key, m, kind, ground = null) => ({
|
||||
key,
|
||||
chainageM: m,
|
||||
kind,
|
||||
x: m,
|
||||
y: 0,
|
||||
left: [0, 1],
|
||||
azimuthDeg: 90,
|
||||
planRadiusM: null,
|
||||
curveOuterSide: null,
|
||||
curveWideningM: 0,
|
||||
ground,
|
||||
});
|
||||
|
||||
/* ── 433-4: R 바꿔 MC 측점이 움직임(curve:47.35 → curve:49.10) ── */
|
||||
const mcSec = (m, kind) => ({
|
||||
station_id: idOf(m),
|
||||
chainage_m: m,
|
||||
kind,
|
||||
center_x: m,
|
||||
center_y: 0,
|
||||
frame: { left_xy: [0, 1] },
|
||||
samples: [],
|
||||
uphill_side: "left",
|
||||
design: { memo: `MY-EDIT@${m}` },
|
||||
});
|
||||
const mcDetail = {
|
||||
longitudinal: { stations: [], length_m: 100 },
|
||||
cross_sections: [
|
||||
mcSec(0, "bp"),
|
||||
mcSec(20, "regular"),
|
||||
mcSec(47.35, "curve"),
|
||||
mcSec(60, "regular"),
|
||||
mcSec(100, "ep"),
|
||||
],
|
||||
};
|
||||
const mcOut = swap.swapCrossSections(
|
||||
mcDetail,
|
||||
[
|
||||
st("end:BP", 0, "end"),
|
||||
st("grid:1", 20, "grid"),
|
||||
st("curve:49.10", 49.1, "curve"),
|
||||
st("grid:3", 60, "grid"),
|
||||
st("end:EP", 101, "end"),
|
||||
],
|
||||
["end:BP", "grid:1", "curve:47.35", "grid:3", "end:EP"],
|
||||
20,
|
||||
);
|
||||
// 멀리(> CURVE_NEAR_M) 간 MC = 못 이음(새 측점 · 노선 밖)
|
||||
const farOut = swap.swapCrossSections(
|
||||
mcDetail,
|
||||
[
|
||||
st("end:BP", 0, "end"),
|
||||
st("grid:1", 20, "grid"),
|
||||
st("curve:62.00", 62, "curve"),
|
||||
st("grid:3", 60, "grid"),
|
||||
st("end:EP", 100, "end"),
|
||||
],
|
||||
["end:BP", "grid:1", "curve:47.35", "grid:3", "end:EP"],
|
||||
20,
|
||||
);
|
||||
|
||||
/* ── 433-11: 덧 측점 넣기 · 빼기 — 서버 표본(A) 대 브라우저 지형(B) ── */
|
||||
const OFFSETS = [-12, -9, -6, -4, -2, 0, 2, 4, 6, 9, 12];
|
||||
const groundA = (m, o) => 100 + 0.02 * m + 0.35 * o; // 서버 샘플링(저장 상세)
|
||||
const groundB = (m, o) => groundA(m, o) + 0.6 * Math.sin(m / 7 + o / 3); // 브라우저 설계 지형
|
||||
const planZ = (m) => 100.2 + 0.02 * m;
|
||||
const samplesOf = (fn, m) =>
|
||||
OFFSETS.map((o) => ({ offset_m: o, elevation_m: fn(m, o), valid: true }));
|
||||
const groundOf = (fn, m) => ({
|
||||
centerZ: fn(m, 0),
|
||||
left: OFFSETS.filter((o) => o > 0).map((o) => ({ offsetM: o, z: fn(m, o) })),
|
||||
right: OFFSETS.filter((o) => o < 0).map((o) => ({ offsetM: -o, z: fn(m, o) })),
|
||||
});
|
||||
const designOf = (samples, m) => ({
|
||||
...computeCrossDesign(samples, planZ(m), {
|
||||
standard,
|
||||
groundType: "soil",
|
||||
sectionMode: "left_cut",
|
||||
}),
|
||||
design_elevation_m: planZ(m),
|
||||
});
|
||||
const baseKeys = [
|
||||
["end:BP", 0, "end"],
|
||||
["grid:1", 20, "grid"],
|
||||
["grid:2", 40, "grid"],
|
||||
["grid:3", 60, "grid"],
|
||||
["end:EP", 80, "end"],
|
||||
];
|
||||
const kindOf = (kind, key) =>
|
||||
kind === "end" ? (key.endsWith("EP") ? "ep" : "bp") : kind === "grid" ? "regular" : "irregular";
|
||||
const savedSec = ([key, m, kind]) => {
|
||||
const samples = samplesOf(groundA, m);
|
||||
return {
|
||||
station_id: idOf(m),
|
||||
chainage_m: m,
|
||||
kind: kindOf(kind, key),
|
||||
center_x: m,
|
||||
center_y: 0,
|
||||
center_z: groundA(m, 0),
|
||||
frame: { left_xy: [0, 1] },
|
||||
uphill_side: "left",
|
||||
samples,
|
||||
design: designOf(samples, m),
|
||||
};
|
||||
};
|
||||
const saved = {
|
||||
longitudinal: { stations: [], length_m: 80 },
|
||||
cross_sections: baseKeys.map(savedSec),
|
||||
};
|
||||
const savedKeys = baseKeys.map(([key]) => key);
|
||||
// 브라우저 다시 셈 = 상세 표본으로 면적(Cross_Refresh 꼴)
|
||||
const recross = (detail) =>
|
||||
detail.cross_sections.map((s) => ({ ...s, design: designOf(s.samples, s.chainage_m) }));
|
||||
const cumulative = (sections) =>
|
||||
computeMassHaul(sections, conversion).points.map((p) => [
|
||||
p.chainage_m,
|
||||
Math.round(p.cumulative_volume_m3 * 1000) / 1000,
|
||||
]);
|
||||
|
||||
const plusList = [...baseKeys.slice(0, 2), ["pipe:p30", 30, "pipe"], ...baseKeys.slice(2)];
|
||||
const plusStations = plusList.map(([key, m, kind]) => st(key, m, kind, groundOf(groundB, m)));
|
||||
const added = swap.swapCrossSections(saved, plusStations, savedKeys, 20);
|
||||
const addedSections = recross(added.detail);
|
||||
// 서버 기준 = 저장 상세(표본 A) + 덧 측점(브라우저 표본 B · 새 측점은 표본이 그것뿐)
|
||||
const serverPlus = [...saved.cross_sections];
|
||||
serverPlus.splice(2, 0, addedSections[2]);
|
||||
const backStations = baseKeys.map(([key, m, kind]) => st(key, m, kind, groundOf(groundB, m)));
|
||||
const removed = swap.swapCrossSections(
|
||||
added.detail,
|
||||
backStations,
|
||||
plusList.map(([key]) => key),
|
||||
20,
|
||||
);
|
||||
// 노선이 옮겨 간 측점(좌표 다름)은 새 지형 표본
|
||||
const shifted = swap.swapCrossSections(
|
||||
saved,
|
||||
baseKeys.map(([key, m, kind]) => ({ ...st(key, m, kind, groundOf(groundB, m)), y: 1.5 })),
|
||||
savedKeys,
|
||||
20,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
mc: {
|
||||
added: mcOut.added,
|
||||
removed: mcOut.removed,
|
||||
moves: [...mcOut.moves],
|
||||
memo: mcOut.detail.cross_sections[2].design.memo ?? null,
|
||||
stationId: mcOut.detail.cross_sections[2].station_id,
|
||||
far: { added: farOut.added, removed: farOut.removed },
|
||||
},
|
||||
plus: {
|
||||
keptSamples: added.detail.cross_sections
|
||||
.filter((_, i) => i !== 2)
|
||||
.every(
|
||||
(s, i) => JSON.stringify(s.samples) === JSON.stringify(saved.cross_sections[i].samples),
|
||||
),
|
||||
keptCenterZ: added.detail.cross_sections.filter((_, i) => i !== 2).map((s) => s.center_z),
|
||||
savedCenterZ: saved.cross_sections.map((s) => s.center_z),
|
||||
newSamples:
|
||||
JSON.stringify(added.detail.cross_sections[2].samples) ===
|
||||
JSON.stringify(samplesOf(groundB, 30)),
|
||||
browser: cumulative(addedSections),
|
||||
server: cumulative(serverPlus),
|
||||
back: cumulative(recross(removed.detail)),
|
||||
saved: cumulative(saved.cross_sections),
|
||||
// 고치기 전 꼴(모든 측점 브라우저 표본) — 차가 큼을 보임
|
||||
allBrowser: cumulative(
|
||||
plusList.map(([, m], i) => ({
|
||||
...addedSections[i],
|
||||
design: designOf(samplesOf(groundB, m), m),
|
||||
})),
|
||||
),
|
||||
shiftedFresh: shifted.detail.cross_sections.every(
|
||||
(s, i) => JSON.stringify(s.samples) === JSON.stringify(samplesOf(groundB, baseKeys[i][1])),
|
||||
),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -5,7 +5,8 @@ const path = require("path");
|
||||
const ROOT = path.resolve(__dirname, "..", "..");
|
||||
const ts = require(path.join(ROOT, "config", "node_modules", "typescript"));
|
||||
const idOf = (m) => `station_${String(Math.round(m * 1000)).padStart(12, "0")}`;
|
||||
const REAL = /(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross)$/;
|
||||
const REAL =
|
||||
/(B05_Profile_Pipeline_Detail|B06_Section_Station_Swap|design_pipeline_cross|common_util_station_key)$/;
|
||||
const STUBS = {
|
||||
B05_Profile_Pipeline_Grade: { stationIdOf: idOf },
|
||||
common_util_station: { stationLabel: (m) => String(m) },
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/* PLAN 448(442-2) 단위검증 헬퍼 — 페이지 설정 저장 차례 · 초안 내보내기 · 기준표 거울 재료.
|
||||
* ① DESIGN_SAVE_ORDER 에 settings(route 뒤 · pipes 앞) · 노선 바꾼 [저장]에도 settings 기본 창구가 돎
|
||||
* ② flushPageSettings — 초안 없음 = 안 보냄 · 성공 = 최신 응답 캐시에 얹고 초안 비움 · 실패 = 초안 그대로
|
||||
* ③ 끝 측점 계획고 편집(시 · 종점 조정) — 칸 값 = 첫 · 끝 측점 station_offsets
|
||||
* ④ TS 기준표(PROFILE_CRITERIA · SPEED_CHOICES) — 거울 시험이 파이썬과 대조
|
||||
* TS 를 그 자리에서 트랜스파일해 Node 로 돌림 · 서버 창구는 가짜. 짝 = `test_442_2_page_settings.py`. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
globalThis.window = { setTimeout, clearTimeout };
|
||||
|
||||
const anything = () =>
|
||||
new Proxy({}, { get: (_target, key) => (key === "__esModule" ? false : () => undefined) });
|
||||
|
||||
function load(file, stubs) {
|
||||
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, (r) => stubs[r] ?? anything());
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
/** 상대 경로 import 를 실제 파일로 따라 읽는 로더(순수 logic 묶음용) · 그 밖은 가짜. */
|
||||
const cache = new Map();
|
||||
function loadReal(file) {
|
||||
if (cache.has(file)) return cache.get(file);
|
||||
const box = { exports: {} };
|
||||
cache.set(file, box.exports);
|
||||
const js = ts.transpileModule(fs.readFileSync(file, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const req = (r) => {
|
||||
if (!r.startsWith(".")) return anything();
|
||||
const target = path.resolve(path.dirname(file), r);
|
||||
return loadReal(fs.existsSync(`${target}.ts`) ? `${target}.ts` : target);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
cache.set(file, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const out = {};
|
||||
|
||||
// ── ① 저장 차례 ──
|
||||
const calls = [];
|
||||
const save = load(path.join(ROOT, "A00_Common", "design", "design_save.ts"), {
|
||||
"../../B05_Profile/B05_Profile_Api_PageSettings": {
|
||||
flushPageSettings: async (projectId) => calls.push(`settings:${projectId}`),
|
||||
},
|
||||
"../../B05_Profile/B05_Profile_UI_RouteEdit_Apply": {
|
||||
pendingRouteSave: async () => ({ draft: 1 }),
|
||||
saveRouteDraft: async () => (calls.push("route"), { route_id: 9 }),
|
||||
carryAfterRouteSave: async () => [],
|
||||
routeSaveSummary: () => "",
|
||||
rebuildAfterRouteSave: async () => undefined,
|
||||
},
|
||||
});
|
||||
out.order = [...save.DESIGN_SAVE_ORDER];
|
||||
const done = await save.saveDesign({
|
||||
projectId: "p1",
|
||||
routeId: () => 3,
|
||||
steps: { settings: async () => calls.push("own-settings") }, // 노선 바뀌면 화면 몫 건너뜀
|
||||
cross: async () => calls.push("cross"),
|
||||
failed: (error) => calls.push(`failed:${error.message}`),
|
||||
});
|
||||
out.routeSaveDone = done;
|
||||
out.routeSaveCalls = calls;
|
||||
|
||||
// ── ② 초안 내보내기 ──
|
||||
const session = new Map();
|
||||
const puts = [];
|
||||
let mode = "ok";
|
||||
let latest = {
|
||||
route_params: { options: { grade_class: "trunk", design_speed_kph: 20 }, max_grade_pct: 15 },
|
||||
};
|
||||
const api = load(path.join(ROOT, "B05_Profile", "B05_Profile_Api_PageSettings.ts"), {
|
||||
"../A00_Common/b_page_state": {
|
||||
readState: (name, p) => (session.has(`${name}:${p}`) ? session.get(`${name}:${p}`) : null),
|
||||
writeState: (name, value, p) => session.set(`${name}:${p}`, value),
|
||||
clearState: (name, p) => session.delete(`${name}:${p}`),
|
||||
},
|
||||
"./B05_Profile_Api_Fetch": {
|
||||
savePageSettings: async (projectId, body) => {
|
||||
puts.push(body);
|
||||
if (mode === "boom") throw new Error("서버 500");
|
||||
return { warnings: [], profile_updated: true };
|
||||
},
|
||||
readRouteLatestCache: () => latest,
|
||||
writeRouteLatestCache: (_p, value) => (latest = value),
|
||||
},
|
||||
});
|
||||
await api.flushPageSettings("p1");
|
||||
out.putsWithoutDraft = puts.length;
|
||||
const draft = api.pageSettingsOf({
|
||||
designSpeed: 30,
|
||||
terrainType: "normal",
|
||||
maxGradePct: null,
|
||||
minVerticalRadius: 300,
|
||||
minTangentLength: null,
|
||||
enforcePipeClearance: true,
|
||||
stationDisplay: { station: 12, cumulative: 240 },
|
||||
});
|
||||
out.draft = draft;
|
||||
api.writePageSettings("p1", draft);
|
||||
out.restoreFromDraft = api.panelValuesOfDraft(api.readPageSettings("p1"));
|
||||
mode = "boom";
|
||||
out.boom = await api.flushPageSettings("p1").then(
|
||||
() => "ok",
|
||||
(error) => error.message,
|
||||
);
|
||||
out.draftAfterBoom = api.readPageSettings("p1");
|
||||
mode = "ok";
|
||||
await api.flushPageSettings("p1");
|
||||
out.draftAfterOk = api.readPageSettings("p1");
|
||||
out.latestAfterOk = latest;
|
||||
out.puts = puts.length;
|
||||
|
||||
// ── ③ 끝 측점 계획고 편집 ──
|
||||
const logic = path.join(ROOT, "B05_Profile", "logic");
|
||||
const alignment = loadReal(path.join(logic, "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
const ends = loadReal(path.join(logic, "B05_Profile_EndOffsets.ts"));
|
||||
const base = {
|
||||
policy: {
|
||||
station_interval_m: 20,
|
||||
curve_radius_ratio: 5,
|
||||
default_curve_length_m: 20,
|
||||
curve_tangent_max_ratio: 0.5,
|
||||
curve_skip_legal_exception: true,
|
||||
balance_tolerance_percent: 5,
|
||||
edit_step_m: 0.1,
|
||||
grade_violation_policy: "warn",
|
||||
max_grade_pct: 14,
|
||||
curve_skip_delta_pct: 5,
|
||||
paved: false,
|
||||
},
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: [
|
||||
{ chainage_m: 0, elevation_m: 100 },
|
||||
{ chainage_m: 100, elevation_m: 105 },
|
||||
{ chainage_m: 200, elevation_m: 108 },
|
||||
],
|
||||
chainage: [0, 100, 200],
|
||||
ground: [100, 105, 108],
|
||||
stations: [0, 20, 40, 60, 80, 100, 120, 140, 160, 180, 200].map((s) => ({
|
||||
station_id: `s${s}`,
|
||||
chainage_m: s,
|
||||
})),
|
||||
};
|
||||
const empty = { station_offsets: {}, curve_radii: {} };
|
||||
out.endsEmpty = ends.endOffsetsOf(base, empty);
|
||||
const raised = ends.withEndOffset(base, empty, "start", 1.25);
|
||||
out.endsRaised = ends.endOffsetsOf(base, raised);
|
||||
const both = ends.withEndOffset(base, raised, "end", -0.5);
|
||||
out.endsBoth = ends.endOffsetsOf(base, both);
|
||||
const plan = alignment.buildAlignment(base, both);
|
||||
out.planStart = plan.samples[0].elevation_m;
|
||||
out.planEnd = plan.samples[plan.samples.length - 1].elevation_m;
|
||||
out.backToZero = ends.endOffsetsOf(base, ends.withEndOffset(base, both, "start", 0));
|
||||
|
||||
// ── ④ 기준표 ──
|
||||
const fields = load(path.join(ROOT, "B05_Profile", "B05_Profile_UI_Panel_Fields.ts"), {});
|
||||
out.criteria = fields.PROFILE_CRITERIA;
|
||||
out.speedChoices = fields.SPEED_CHOICES;
|
||||
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
}
|
||||
|
||||
run().catch((error) => {
|
||||
process.stderr.write(String(error.stack || error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/* PLAN 449 헬퍼 — [저장] 관 차례 · 노선 저장 뒤 실패(433-7 · 433-12). 실제 `design_save.ts` · 관 초안 창구 · 가게를
|
||||
* Node 로 · 서버 창구 · 계획고 초안 · 노선 편집 창구만 가짜. 짝 = `test_449_save_pipes.py`. */
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
const CATALOG = require("./helper_322_catalog_module.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
globalThis.window = {
|
||||
sessionStorage: memoryStorage(),
|
||||
localStorage: memoryStorage(),
|
||||
setTimeout,
|
||||
clearTimeout,
|
||||
};
|
||||
|
||||
const STUBS = {};
|
||||
const cache = new Map();
|
||||
function loadTs(full) {
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (CATALOG.isCatalogRequest(request)) return CATALOG;
|
||||
const key = Object.keys(STUBS).find((name) => request.endsWith(name));
|
||||
if (key) return STUBS[key];
|
||||
if (request.startsWith("@config/")) return { API_BASE_URL: "http://fake" };
|
||||
if (request.startsWith("@util/"))
|
||||
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
const load = (...parts) => loadTs(path.join(ROOT, ...parts));
|
||||
|
||||
let calls = [];
|
||||
let failAt = null; // 이 차례에서 throw
|
||||
let routeBody = null;
|
||||
const alignment = new Map(); // 노선 칸 → 계획고 초안
|
||||
STUBS["ui_template_elements"] = {
|
||||
showLoadingOverlay: () => undefined,
|
||||
hideLoadingOverlay: () => undefined,
|
||||
showToast: () => undefined,
|
||||
};
|
||||
const failing = (name) => {
|
||||
if (failAt === name) throw new Error(`${name} 409`);
|
||||
};
|
||||
STUBS["B04_PreProcess_Api_Fetch"] = {
|
||||
fetchDetailPipePoints: async () => (calls.push(["GET"]), { pipe_points: [] }),
|
||||
saveDetailPipePoints: async (_projectId, points, recompute) => {
|
||||
calls.push(["PUT", points ?? null, !!recompute]);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
STUBS["B05_Profile_Api_Structures"] = {
|
||||
flushPendingStructures: async () => failing("structures"),
|
||||
};
|
||||
STUBS["B05_Profile_Api_PlusStations"] = { flushPlusStations: async () => undefined };
|
||||
STUBS["B05_Profile_Api_Fetch"] = {
|
||||
flushUphillOverrides: async () => undefined,
|
||||
saveProfileAlignment: async (_projectId, routeId, draft) => {
|
||||
calls.push(["ALIGN", routeId, draft]);
|
||||
return {};
|
||||
},
|
||||
};
|
||||
STUBS["B05_Profile_UI_Profile_Edit"] = {
|
||||
pendingAlignment: (routeId) => alignment.get(routeId) ?? null,
|
||||
markAlignmentSaved: (routeId) => alignment.delete(routeId),
|
||||
writeAlignmentDraft: (routeId, edits) => alignment.set(routeId, edits),
|
||||
};
|
||||
STUBS["B06_Section_Api_Culvert_Options"] = { flushCulvertOptions: async () => undefined };
|
||||
STUBS["B05_Profile_UI_RouteEdit_Apply"] = {
|
||||
pendingRouteSave: async () => routeBody,
|
||||
saveRouteDraft: async () => (calls.push(["ROUTE"]), { route_id: 8 }),
|
||||
carryAfterRouteSave: async (_projectId, from, to, options) => {
|
||||
calls.push(["CARRY", from, to, options?.save ?? true]);
|
||||
failing("carry");
|
||||
return 0;
|
||||
},
|
||||
routeSaveSummary: () => "",
|
||||
rebuildAfterRouteSave: async () => void calls.push(["REBUILD"]),
|
||||
};
|
||||
const save = load("A00_Common", "design", "design_save.ts");
|
||||
const drafts = load("B05_Profile", "B05_Profile_Api_Pipes_Draft.ts");
|
||||
|
||||
const P = "proj-449";
|
||||
async function once({ route = false, draft = null, confirm = false, fail = null, routeId = 7 }) {
|
||||
calls = [];
|
||||
failAt = fail;
|
||||
routeBody = route ? { draft: {} } : null;
|
||||
drafts.writePendingPipes(P, draft);
|
||||
let failure = null;
|
||||
const ok = await save.saveDesign(
|
||||
{
|
||||
projectId: P,
|
||||
routeId: () => routeId,
|
||||
// 화면 몫 = 배수판 다시 받기(PUT 없음) — 433-7 뒤 design_shared 와 같음
|
||||
steps: { pipes: async () => void calls.push(["RELOAD"]) },
|
||||
cross: async () => void calls.push(["CROSS"]),
|
||||
failed: (error) => (failure = String(error && error.message ? error.message : error)),
|
||||
},
|
||||
{ confirm },
|
||||
);
|
||||
return { ok, failure, calls, left: drafts.readPendingPipes(P) };
|
||||
}
|
||||
|
||||
async function run() {
|
||||
window.localStorage.setItem("frd_current_project_id", P);
|
||||
const fifteen = [{ chainage_m: 15, source: "user", id: "p1" }];
|
||||
const out = {};
|
||||
out.draft = await once({ draft: fifteen }); // B06 고침 직후 [저장] = 초안 값
|
||||
out.noDraft = await once({}); // 판 안 섬 · 초안 없음 = PUT 없음
|
||||
out.confirmNoDraft = await once({ confirm: true }); // [확정] = 칸 없이(서버 저장분)
|
||||
out.emptyDraft = await once({ draft: [] }); // 사용자가 다 지움 = 빈 목록
|
||||
// 433-12 — 노선 저장 뒤 구조물 실패 → 0 칸 계획고가 새 노선 칸에 · 다음 [저장]이 보냄
|
||||
alignment.clear();
|
||||
alignment.set(0, { pvi: { 120: 1.5 } });
|
||||
out.routeFail = await once({ route: true, fail: "structures" });
|
||||
out.alignAfterFail = Object.fromEntries(alignment);
|
||||
out.nextSave = await once({ routeId: 8 });
|
||||
// 노선 저장 뒤 횡단 넘김 자체가 실패 → 두 번 안 넘김
|
||||
alignment.clear();
|
||||
out.carryFail = await once({ route: true, fail: "carry" });
|
||||
return out;
|
||||
}
|
||||
|
||||
run()
|
||||
.then((out) => {
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
process.stderr.write(String(error && error.stack ? error.stack : error));
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
/* 452 시험 묶음 진입점 — B06 측점 열쇠(시종점 규칙)를 vite ssr 로 묶어 Node 헬퍼가 부름. */
|
||||
export { stationKeysOf, useStructureIds } from "../../B06_Section/B06_Section_Station_Keys";
|
||||
@@ -0,0 +1,36 @@
|
||||
/* 452 헬퍼 — B06 `stationKeysOf` 열쇠를 JSON 으로.
|
||||
* node helper_452_keys.mjs <묶음.mjs> <입력.json>
|
||||
* 입력 = { sections: [{chainage_m, kind}], structures: [{structure_id, chainage_m, start_m, end_m}], interval }.
|
||||
*/
|
||||
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;
|
||||
|
||||
const [bundlePath, inputPath] = process.argv.slice(2);
|
||||
const m = await import(pathToFileURL(bundlePath).href);
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8"));
|
||||
m.useStructureIds("p452", input.structures);
|
||||
const sections = input.sections.map((s) => ({ station_id: `s${s.chainage_m}`, samples: [], ...s }));
|
||||
process.stdout.write(JSON.stringify(m.stationKeysOf("p452", sections, input.interval)));
|
||||
@@ -0,0 +1,96 @@
|
||||
/* 454(433-21 ②) 방향키 측점 No. 걸음 — 무작위 짧은 다리 노선(14 IP · 고정 R) 교점마다 네 방향 한 걸음
|
||||
* 누르고 측점 밖 막힘 수를 JSON 한 줄로(`test_454_arrow_station_step.py` 가 판정).
|
||||
* 누름 판정은 편집기 방향키 길과 같음 — 하한 깸 = 막힘 · 미리보기(`fitDrag`) 측점 밖이 늘면 막힘.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) =>
|
||||
request.startsWith(".")
|
||||
? loadTs(path.join(path.dirname(full), `${request}.ts`))
|
||||
: require(request);
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
|
||||
const fit = loadTs(path.join(LOGIC, "B05_Profile_RouteEdit_StationFit.ts"));
|
||||
const edits = loadTs(path.join(LOGIC, "B05_Profile_UI_RouteEdit_Edits.ts"));
|
||||
|
||||
const INTERVAL = 20;
|
||||
const LIMITS = { minRadiusM: 30, limitRadiusM: 12, limitArcM: 0, intervalM: INTERVAL };
|
||||
const columns = (p) => ({
|
||||
planned: p,
|
||||
curveOn: p.map(() => true),
|
||||
curveRadius: p.map(() => null),
|
||||
curveLock: p.map(() => null),
|
||||
curveArc: p.map(() => null),
|
||||
apexLock: p.map((_, i) => i === 0 || i === p.length - 1),
|
||||
});
|
||||
const copy = (c) => ({
|
||||
...c,
|
||||
planned: c.planned.map(([x, y]) => [x, y]),
|
||||
curveRadius: [...c.curveRadius],
|
||||
});
|
||||
const off = (b) => fit.offStationCount(b.vertices, b.curves, INTERVAL);
|
||||
const floor = (b) => edits.curveShortfalls(b.nodes, LIMITS.limitRadiusM, LIMITS.limitArcM);
|
||||
|
||||
function route(seed) {
|
||||
const rnd = () => (seed = (seed * 16807) % 2147483647) / 2147483647;
|
||||
const line = [[0, 0]];
|
||||
let a = 0;
|
||||
for (let i = 0; i < 14; i += 1) {
|
||||
a += (rnd() - 0.5) * 1.6;
|
||||
const length = 35 + rnd() * 60;
|
||||
const p = line[line.length - 1];
|
||||
line.push([p[0] + Math.cos(a) * length, p[1] + Math.sin(a) * length]);
|
||||
}
|
||||
return line;
|
||||
}
|
||||
|
||||
const out = { press: 0, floorBlock: 0, offBlock: 0, offStart: 0, offAfterRelease: 0 };
|
||||
for (let seed = 1; seed <= 10; seed += 1) {
|
||||
const hist = columns(fit.fitCurveStations(columns(route(seed)), LIMITS, 1));
|
||||
hist.curveRadius = edits
|
||||
.rebuildLine(copy(hist), LIMITS)
|
||||
.nodes.map((n) => (n.radius_m === null ? null : Math.round(n.radius_m * 100) / 100));
|
||||
out.offStart += off(edits.rebuildLine(copy(hist), LIMITS));
|
||||
for (let k = 1; k < hist.planned.length - 1; k += 1)
|
||||
for (const [dx, dy] of [
|
||||
[1, 0],
|
||||
[-1, 0],
|
||||
[0, 1],
|
||||
[0, -1],
|
||||
]) {
|
||||
const st = copy(hist);
|
||||
const b0 = edits.rebuildLine(st, LIMITS);
|
||||
const node = st.planned[k];
|
||||
st.planned[k] = [node[0] + dx * INTERVAL, node[1] + dy * INTERVAL];
|
||||
out.press += 1;
|
||||
if (edits.shortfallCrossed(floor(b0), floor(edits.rebuildLine(st, LIMITS)))) {
|
||||
out.floorBlock += 1;
|
||||
continue;
|
||||
}
|
||||
const pv = { ...st, planned: fit.fitDrag(st, hist.planned, k, LIMITS) };
|
||||
if (off(edits.rebuildLine(copy(pv), LIMITS)) > off(b0)) {
|
||||
out.offBlock += 1;
|
||||
continue;
|
||||
}
|
||||
const rel = { ...pv, planned: fit.fitCurveStations(pv, LIMITS, fit.fitStart(hist, pv)) };
|
||||
if (off(edits.rebuildLine(copy(rel), LIMITS))) out.offAfterRelease += 1;
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
@@ -0,0 +1,23 @@
|
||||
/* 454 — 옛 masshaul 단계 탐침 자리: 묶음 cross · profile 출력으로 누가토량 끝값(m3).
|
||||
* 유토곡선 판(`B05_Profile_UI_Profile_MassHaul`)과 같은 공용 계산(`computeMassHaulSeries`) · 횡단 기준.
|
||||
* `load` = 부르는 헬퍼의 TS 로더(`load("common_util", "…ts")`). */
|
||||
module.exports = function finalCumulative(load, pipe, conversion) {
|
||||
const cross = pipe.output("cross");
|
||||
const samples = pipe.output("profile")?.samples ?? [];
|
||||
if (!cross) return null;
|
||||
const { computeMassHaulSeries } = load("common_util", "common_util_mass_haul.ts");
|
||||
const sections = Object.values(cross.byKey)
|
||||
.sort((a, b) => a.chainageM - b.chainageM)
|
||||
.map((e) => ({ station_id: e.key, chainage_m: e.chainageM, design: e.detail }));
|
||||
const series = computeMassHaulSeries(
|
||||
{
|
||||
length_m:
|
||||
samples[samples.length - 1]?.chainage_m ?? sections[sections.length - 1]?.chainage_m ?? 0,
|
||||
design_profiles: samples.length ? [{ samples }] : [],
|
||||
},
|
||||
sections,
|
||||
conversion,
|
||||
);
|
||||
const picked = series.find((s) => s.basis === "cross") ?? series[0];
|
||||
return picked?.result?.final_cumulative_m3 ?? null;
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/* 455 (433-13 ①) 종단 ▲▼ 자동 R — `B05_Profile_UI_Profile_Alignment.ts` adjustStation 을 Node 로 돌림.
|
||||
* ① 누른 꼭짓점 R = 저장 자동 R → 지움 · 곡선 L = 기본 L ② 사용자 R(저장분과 다름) → 그대로
|
||||
* ③ 자동 R 기준을 안 넘기면(옛 부름) → 그대로. 표준출력 = JSON.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const align = loadTs(
|
||||
path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"),
|
||||
);
|
||||
|
||||
// 20m 측점 · 100m 에 꺾임 하나(대수차 0.5 %)
|
||||
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
|
||||
const ground = stations.map((s) => 100 + 0.02 * s + (s > 100 ? 0.005 * (s - 100) : 0));
|
||||
const base = {
|
||||
policy: {
|
||||
station_interval_m: 20,
|
||||
curve_radius_ratio: 1,
|
||||
curve_tangent_max_ratio: 0.5,
|
||||
curve_skip_legal_exception: false,
|
||||
default_curve_length_m: 15,
|
||||
default_curve_radius_m: 300,
|
||||
balance_tolerance_percent: 10,
|
||||
edit_step_m: 0.1,
|
||||
grade_violation_policy: "warn",
|
||||
max_grade_pct: 14,
|
||||
curve_skip_delta_pct: 0,
|
||||
paved: true,
|
||||
},
|
||||
onlyExplicitCurves: false,
|
||||
basePvi: stations.map((s, i) => ({ chainage_m: s, elevation_m: ground[i] })),
|
||||
chainage: stations,
|
||||
ground,
|
||||
stations: stations.map((s) => ({ station_id: `S${s}`, chainage_m: s })),
|
||||
};
|
||||
const KEY = "100.000";
|
||||
const AUTO = 15 / 0.005; // 관 정착이 역산한 꼴(R = L / A)
|
||||
|
||||
function press(radius, autoRadii) {
|
||||
const edits = { station_offsets: {}, curve_radii: { [KEY]: radius } };
|
||||
const next =
|
||||
autoRadii === undefined
|
||||
? align.adjustStation(base, edits, 100, 0.1)
|
||||
: align.adjustStation(base, edits, 100, 0.1, autoRadii);
|
||||
const curve = align
|
||||
.buildAlignment(base, next)
|
||||
.curves.find((c) => Math.abs(c.chainage_m - 100) < 1e-6);
|
||||
return { radius: next.curve_radii[KEY] ?? null, length: curve ? curve.l_m : null };
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
auto: press(AUTO, { [KEY]: AUTO }),
|
||||
user: press(777, { [KEY]: AUTO }),
|
||||
legacy: press(AUTO),
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,38 @@
|
||||
/* 455 (433-13 ③) 옛 정책 경고 — 진짜 `B05_Profile_UI_Profile_Data.ts` readAlignment +
|
||||
* `B05_Profile_UI_Profile_Alignment.ts` buildAlignment 를 Node 로 돌림.
|
||||
* 입력 argv[2] = 종단 정본 JSON · 표준출력 = { warnings: [법정 미달 글], policy }.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const LOGIC = path.join(ROOT, "B05_Profile", "logic");
|
||||
const data = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Data.ts"));
|
||||
const align = loadTs(path.join(LOGIC, "B05_Profile_UI_Profile_Alignment.ts"));
|
||||
const longitudinal = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
||||
const stored = data.readAlignment(longitudinal);
|
||||
const built = align.buildAlignment(align.toAlignmentBase(stored), align.emptyEdits());
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
warnings: built.warnings.filter((text) => text.includes("법정 최소")),
|
||||
policy: stored.policy,
|
||||
}),
|
||||
);
|
||||
@@ -74,6 +74,8 @@ const STUBS = {
|
||||
"../../B06_Section/B06_Section_Api_Culvert_Options": {
|
||||
flushCulvertOptions: async (_p, key) => calls.push(`culvert:${key}`),
|
||||
},
|
||||
// 448 페이지 설정 — 초안 없음(차례만 · 내보내기는 test_442_2_page_settings)
|
||||
"../../B05_Profile/B05_Profile_Api_PageSettings": { flushPageSettings: async () => undefined },
|
||||
};
|
||||
|
||||
const cache = new Map();
|
||||
|
||||
@@ -80,7 +80,9 @@ def test_종단도는_막힌_방향키를_건너뜀() -> None:
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "if (event.defaultPrevented) return;" in src
|
||||
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in src
|
||||
assert (
|
||||
"ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii()))" in src
|
||||
)
|
||||
|
||||
|
||||
def _ground(level: float) -> list[dict]:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross · masshaul · haul 단계.
|
||||
"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross 단계(masshaul · haul 단계는 454 에서 걷음).
|
||||
|
||||
못박는 것
|
||||
- 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 것은 지우지 않고 남김.
|
||||
@@ -8,7 +8,6 @@
|
||||
- 노선이 바뀜 = 측점 열쇠로 넘김(관 · 구조물은 옮긴 자리로) · 넘김 = 카드 선택 넷 · 암 경계 ·
|
||||
노견 확장(417) · 단면 유형 · 측구 방향 · 포장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」.
|
||||
- cross 단계: 같은 입력 다시 셈 0 · 측점 하나 고침 = 1 · 범위 밖 재사용.
|
||||
- masshaul 단계 = 계열 + 흙 배분 입력 · haul 단계 = 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로.
|
||||
|
||||
Node 헬퍼 `helper_344_3_station_keys.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_344_3_entry.ts`)을
|
||||
부름.
|
||||
@@ -116,9 +115,3 @@ def test_cross_단계_국부_연산(out):
|
||||
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}}
|
||||
|
||||
@@ -31,9 +31,8 @@ BROWSER = [
|
||||
"profile",
|
||||
"cross",
|
||||
"footprint",
|
||||
"masshaul",
|
||||
]
|
||||
AFTER = ["haul", "drainage", "corridor"]
|
||||
AFTER = ["drainage", "corridor"]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
@@ -65,7 +64,7 @@ def test_같은_입력_다시_안_셈(r):
|
||||
b = r["basic"]
|
||||
assert b["same"] == {"result": None, "generation": 1} # 같은 값 = 차례 번호도 안 오름
|
||||
# 횡단만 바뀌면 노선 ~ 종단 · 배수유역은 건너뜀
|
||||
assert b["crossRan"] == ["cross", "footprint", "masshaul", "haul", "corridor"]
|
||||
assert b["crossRan"] == ["cross", "footprint", "corridor"]
|
||||
assert b["crossSkipped"] == [
|
||||
"route",
|
||||
"follow",
|
||||
@@ -76,7 +75,7 @@ def test_같은_입력_다시_안_셈(r):
|
||||
"drainage",
|
||||
]
|
||||
# 계획고만 바뀌면 측점 · 지반 · 첫 계획선 · 배수유역 건너뜀
|
||||
assert b["gradeRan"] == ["profile", "cross", "footprint", "masshaul", "haul", "corridor"]
|
||||
assert b["gradeRan"] == ["profile", "cross", "footprint", "corridor"]
|
||||
# 노선이 바뀌면 전부
|
||||
assert b["routeRan"] == BROWSER + AFTER
|
||||
|
||||
@@ -101,8 +100,8 @@ def test_늦은_응답_버림_브라우저_단계(r):
|
||||
|
||||
def test_늦은_응답_버림_뒤_단계_끊김(r):
|
||||
late = r["lateAfter"]
|
||||
assert late["haul1Aborted"] is True # 새 조작 = 옛 요청 끊음
|
||||
assert late["commits"] == [2] and late["haulGen"] == 2 and late["generation"] == 2
|
||||
assert late["after1Aborted"] is True # 새 조작 = 옛 요청 끊음
|
||||
assert late["commits"] == [2] and late["afterGen"] == 2 and late["generation"] == 2
|
||||
|
||||
|
||||
def test_못_끝낸_단계는_범위를_합쳐_셈(r):
|
||||
@@ -133,8 +132,6 @@ def test_단계_시간_기록(r):
|
||||
"profile",
|
||||
"cross",
|
||||
"footprint",
|
||||
"masshaul",
|
||||
"haul",
|
||||
"corridor",
|
||||
]
|
||||
assert t["last"]["totalIsNumber"] is True
|
||||
|
||||
@@ -201,7 +201,7 @@ def r(request) -> dict:
|
||||
def test_열_때_한_번_발자국_저장_횡단_입력(r):
|
||||
o = r["open"]
|
||||
assert o["outcome"] == "done"
|
||||
assert {"cross", "footprint", "masshaul"} <= set(o["stages"])
|
||||
assert {"cross", "footprint"} <= set(o["stages"])
|
||||
assert o["crossCount"] == o["stations"] and o["footprint"] > 2
|
||||
assert o["carried"] == o["stations"] # 저장 측점마다 입력이 측점 열쇠로
|
||||
assert o["corridor"] == 0 and o["haulCalls"] == 0 and o["detail"] is None
|
||||
@@ -214,7 +214,7 @@ def test_노선_옮김_한_번에_유토곡선까지_시간(r):
|
||||
assert run["outcome"] == "done" and run["sources"] == ["route"]
|
||||
for stage in ("route", "stations", "ground", "planline", "profile", "cross", "footprint"):
|
||||
assert stage in run["stages"], stage
|
||||
assert "masshaul" in run["stages"]
|
||||
assert "masshaul" not in run["stages"] and "haul" not in run["stages"] # 454 걷음
|
||||
assert (
|
||||
sum(run["stages"][s] for s in run["stages"] if s not in ("haul", "drainage", "corridor"))
|
||||
< BUDGET_MS
|
||||
@@ -271,9 +271,8 @@ def test_누가토량_서버_엔진과_같은_자리수(r):
|
||||
def test_늦은_응답_버림(r):
|
||||
late = r["late"]
|
||||
assert late["genB"] > late["genA"]
|
||||
assert late["haul"]["aborted"] >= 1 # 앞 차례 흙 배분 요청 끊김
|
||||
assert f"haul@{late['genA']}" not in late["haulCommits"] # 옛 차례 배분 안 들어옴
|
||||
assert late["haulCommits"][-1] == f"haul@{late['genB']}"
|
||||
# 454 — 묶음은 흙 배분을 안 물음(유토곡선 판 선반입만) · 늦은 응답 버림 = 344-4 corridor
|
||||
assert late["haul"]["calls"] == 0 and late["haulCommits"] == []
|
||||
|
||||
|
||||
def test_저장_관_목록이_좌표로_따라감(r):
|
||||
|
||||
@@ -96,7 +96,9 @@ def test_셋_늘고_둘_빠진_목록으로_갈아끼움(out):
|
||||
"ep",
|
||||
]
|
||||
assert pure["labels"] == ["STA.0+047.000", "STA.0+090.000"]
|
||||
assert pure["samples"] == [-0.5, 0, 0.5]
|
||||
# 자리 같은 앞 단면 = 앞 표본(서버 정본 · 433-11) · 옮긴 측점 = 새 지반 표본
|
||||
assert pure["samples"] == [0]
|
||||
assert pure["movedSamples"] == [-0.5, 0, 0.5]
|
||||
assert pure["oldUntouched"] is True and pure["length"] == 90
|
||||
assert pure["same"] is False and pure["sameSelf"] is True
|
||||
|
||||
|
||||
@@ -123,9 +123,9 @@ def test_측점_열쇠_규칙():
|
||||
"at:33.30": 33.3, # id 없는 관
|
||||
"grid:2": 40.0,
|
||||
"pipe:p-7": 50.0,
|
||||
"structure:w1": 60.0, # 구간 시작 측점이 먼저 열쇠를 가짐
|
||||
"at:77.00": 77.0, # 같은 구조물 열쇠 둘째 — at:
|
||||
"at:80.00": 80.0,
|
||||
"structure:w1:start": 60.0, # 구간형 시작 · 끝 = :start · :end(452 · 화면과 한 규칙)
|
||||
"structure:w1": 77.0,
|
||||
"structure:w1:end": 80.0,
|
||||
"end:EP": 95.5,
|
||||
}
|
||||
assert "pipe:0" not in keys and "pipe:1" not in keys # 파일 차례 열쇠 없음
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
- 그리기 = 횡단 기준만(`["cross"]`) · 범례는 낸 계열마다라 종단 단추가 안 섬.
|
||||
- 저장된 범례가 종단(`longitudinal`)이면 횡단으로 맞춤 · 분배 켬은 그대로.
|
||||
- 계산 묶음 단계(MassHaul_Stage)는 두 기준 그대로 · 공용 계산 코드 남김.
|
||||
- 공용 계산 코드는 두 기준 그대로 남김(계산 묶음 masshaul 단계는 454 에서 걷음).
|
||||
|
||||
`helper_354_2_masshaul_cross_only.cjs` 가 진짜 TS 를 잘라 돌림.
|
||||
"""
|
||||
@@ -52,8 +52,7 @@ def test_그리기는_횡단만_부름():
|
||||
|
||||
|
||||
def test_묶음_단계와_공용_코드는_그대로():
|
||||
stage = (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").read_text(encoding="utf-8")
|
||||
assert '["cross"]' not in stage
|
||||
assert not (ROOT / "B05_Profile/logic/B05_Profile_MassHaul_Stage.ts").exists() # 454 걷음
|
||||
util = (ROOT / "common_util/common_util_mass_haul.ts").read_text(encoding="utf-8")
|
||||
assert 'const SERIES_BASES: MassHaulBasis[] = ["cross", "longitudinal"];' in util
|
||||
assert "longitudinalAreaSamples(longitudinal, sections)" in util
|
||||
|
||||
@@ -69,10 +69,11 @@ def test_직선_안_점은_새_꺾임_축은_좌우_다음_꺾임(out, case):
|
||||
|
||||
|
||||
def test_부르는_곳_셋이_같은_함수():
|
||||
assert "adjustStation(base, store.edits(), chainage, delta)" in _read(
|
||||
# 455 — 자동 R 기준(저장 곡선반경)을 함께 넘김
|
||||
assert "adjustStation(base, store.edits(), chainage, delta, store.savedRadii())" in _read(
|
||||
"B05_Profile/B05_Profile_UI_Profile_Render.ts"
|
||||
)
|
||||
assert "ctx.applyEdits(adjustStation(base, ctx.edits(), chainage, delta))" in _read(
|
||||
assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in _read(
|
||||
"B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts"
|
||||
)
|
||||
assert "pivotAxes(current, chainageM)" in _read(
|
||||
|
||||
@@ -195,3 +195,63 @@ def test_화면_짝과_같은_꼭짓점(tmp_path: Path, offsets, to) -> None:
|
||||
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
|
||||
for mine, theirs in zip(nodes, server["base_pvi"]):
|
||||
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
|
||||
|
||||
|
||||
def _balanced() -> dict:
|
||||
"""지반 종단을 1 m 내린 판 — 계획선이 지반을 안 지남(균형 종단 꼴 · 438 시험과 같은 꼴)."""
|
||||
data = _longitudinal()
|
||||
for node in data["profile_alignment"]["base_pvi"]:
|
||||
node["elevation_m"] -= 1.0
|
||||
alignment, profile = rebuild_alignment_profile(data, {})
|
||||
data["profile_alignment"], data["design_profiles"] = alignment, [profile]
|
||||
return data
|
||||
|
||||
|
||||
@pytest.mark.parametrize("make", [_longitudinal, _balanced])
|
||||
def test_433_5_관_옮김_뒤_이웃_측점_계획고_그대로(tmp_path: Path, make) -> None:
|
||||
"""관 351 → 356 — 이웃 340 · 360 곡선이 바뀌어 0.2 m 뜨던 것(433-5) → 옮기기 전 계획고."""
|
||||
first = make()
|
||||
before = _rows(first, 0, 400)
|
||||
_write(tmp_path, first)
|
||||
after = _rows(_move(tmp_path, 356.0), 0, 400)
|
||||
for at, value in before.items():
|
||||
if at != PIPE:
|
||||
assert abs(after[at] - value) < 0.01, at
|
||||
if make is _longitudinal:
|
||||
assert after[356.0] == pytest.approx(_ground(356.0), abs=1e-4)
|
||||
# 화면 짝 같은 꼭짓점
|
||||
stored = first["profile_alignment"]
|
||||
base = {
|
||||
"basePvi": stored["base_pvi"],
|
||||
"chainage": [s["chainage_m"] for s in stored["samples"]],
|
||||
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
|
||||
"policy": stored["policy"],
|
||||
}
|
||||
server = json.loads((tmp_path / REL).read_text(encoding="utf-8"))["profile_alignment"]
|
||||
[nodes] = _ts(tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": []}])
|
||||
assert [n["chainage_m"] for n in nodes] == [n["chainage_m"] for n in server["base_pvi"]]
|
||||
for mine, theirs in zip(nodes, server["base_pvi"], strict=True):
|
||||
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
|
||||
|
||||
|
||||
def test_433_5_높이_고친_이웃은_그대로(tmp_path: Path) -> None:
|
||||
"""340 높이 고침(+0.3) · 관 351 → 356 — 340 꼭짓점 그대로 · 360 은 맞춤 · 화면 짝."""
|
||||
first = _longitudinal({"340.000": 0.3})
|
||||
stored = copy.deepcopy(first["profile_alignment"])
|
||||
_write(tmp_path, first)
|
||||
server = _move(tmp_path, 356.0)["profile_alignment"]
|
||||
old = {n["chainage_m"]: n["elevation_m"] for n in stored["base_pvi"]}
|
||||
new = {n["chainage_m"]: n["elevation_m"] for n in server["base_pvi"]}
|
||||
assert new[340.0] == old[340.0]
|
||||
assert new[360.0] != old[360.0]
|
||||
base = {
|
||||
"basePvi": stored["base_pvi"],
|
||||
"chainage": [s["chainage_m"] for s in stored["samples"]],
|
||||
"ground": [s["ground_elevation_m"] for s in stored["samples"]],
|
||||
"policy": stored["policy"],
|
||||
}
|
||||
[nodes] = _ts(
|
||||
tmp_path, [{"base": base, "gone": [PIPE], "born": [356.0], "edited": ["340.000"]}]
|
||||
)
|
||||
for mine, theirs in zip(nodes, server["base_pvi"], strict=True):
|
||||
assert mine["elevation_m"] == pytest.approx(theirs["elevation_m"], abs=1e-6)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""433-11 — 덧 측점 넣기 · 빼기 뒤 브라우저 누가토량 = 서버(0.1 ㎥ 안).
|
||||
|
||||
뿌리: 갈아끼움이 안 바뀐 측점까지 지반 표본 · 상단측 · 중심고를 브라우저 지형 값으로 갈음.
|
||||
못박는 것
|
||||
- 자리 · 축이 같은 앞 단면 = 서버 표본 · 중심고 그대로 · 새 측점만 브라우저 표본.
|
||||
- 덧 측점 넣은 뒤 누가토량 = 서버 기준(저장 표본 + 새 측점) · 빼면 저장 값 그대로.
|
||||
- 노선이 옮겨 간 측점(좌표 다름)은 새 지형 표본.
|
||||
- 고치기 전 꼴(모든 측점 브라우저 표본)은 1 ㎥ 넘게 어긋남(시험이 차를 잼).
|
||||
|
||||
Node 헬퍼 `helper_433_swap.cjs` — 진짜 갈아끼움 · 횡단 엔진 · 유토.
|
||||
"""
|
||||
|
||||
import json
|
||||
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
|
||||
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402
|
||||
|
||||
TOL_M3 = 0.1
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def plus(tmp_path_factory):
|
||||
work = tmp_path_factory.mktemp("b433_11")
|
||||
standard = work / "standard.json"
|
||||
conversion = work / "conversion.json"
|
||||
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
|
||||
conversion.write_text(json.dumps(EARTHWORK_CONVERSION_FACTORS), encoding="utf-8")
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_433_swap.cjs"), str(standard), str(conversion)],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)["plus"]
|
||||
|
||||
|
||||
def _max_gap(a, b):
|
||||
assert [p[0] for p in a] == [p[0] for p in b]
|
||||
return max(abs(x[1] - y[1]) for x, y in zip(a, b))
|
||||
|
||||
|
||||
def test_안_바뀐_측점은_서버_표본_중심고(plus):
|
||||
assert plus["keptSamples"]
|
||||
assert plus["keptCenterZ"] == plus["savedCenterZ"]
|
||||
assert plus["newSamples"]
|
||||
|
||||
|
||||
def test_덧_측점_넣은_뒤_누가토량_서버와_같음(plus):
|
||||
assert _max_gap(plus["browser"], plus["server"]) < TOL_M3
|
||||
|
||||
|
||||
def test_덧_측점_뺀_뒤_저장_값_그대로(plus):
|
||||
assert _max_gap(plus["back"], plus["saved"]) < TOL_M3
|
||||
|
||||
|
||||
def test_고치기_전_꼴은_어긋남(plus):
|
||||
assert _max_gap(plus["allBrowser"], plus["server"]) > 1.0
|
||||
|
||||
|
||||
def test_노선이_옮긴_측점은_새_지형(plus):
|
||||
assert plus["shiftedFresh"]
|
||||
@@ -0,0 +1,45 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""433-19 · 433-20 — 구간값 죽은 길 걷음 · 표 위 CAD 요소 한도(서버 2000 짝).
|
||||
|
||||
못박는 것
|
||||
- 구간값 제어에 기준벽 `update`(culvertopt 큐 쓰기) 없음 · 구간 줄은 다단 벽만(444 관 초안 한 벌).
|
||||
- 표 위 CAD 한도 = 서버 `CrossNoteCad.entities max_length` · 넘으면 초안에 안 쓰고 알림.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
B06 = ROOT / "B06_Section"
|
||||
|
||||
|
||||
def _read(name: str) -> str:
|
||||
return (B06 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_구간값_제어에_culvertopt_큐_쓰기_없음():
|
||||
span = _read("B06_Section_UI_Page_Span_Control.ts")
|
||||
assert "culvertOptions" not in span
|
||||
assert "update: (section, role" not in span
|
||||
wire = _read("B06_Section_UI_Cross_Culvert_Wire.ts")
|
||||
assert not re.search(r"^\s*update: \(section: CrossSection, role", wire, re.M)
|
||||
view = _read("B06_Section_UI_Cross_View_Structure.ts")
|
||||
assert "structureSpan?.update(" not in view
|
||||
|
||||
|
||||
def test_CAD_한도_서버와_같음():
|
||||
schema = _read("B06_Section_Schema.py")
|
||||
server = re.search(
|
||||
r"class CrossNoteCad\(BaseModel\):.*?entities:.*?max_length=(\d+)", schema, re.S
|
||||
)
|
||||
cad = _read("B06_Section_UI_Cross_Detail_Cad.ts")
|
||||
screen = re.search(r"NOTE_CAD_MAX_ENTITIES = (\d+)", cad)
|
||||
assert server and screen and server.group(1) == screen.group(1)
|
||||
|
||||
|
||||
def test_CAD_한도_넘으면_초안에_안_씀():
|
||||
cad = _read("B06_Section_UI_Cross_Detail_Cad.ts")
|
||||
body = cad[cad.index("function writeNoteCad(") :]
|
||||
guard = body.index("NOTE_CAD_MAX_ENTITIES")
|
||||
assert guard < body.index("structure_note_cad = cad")
|
||||
assert "showToast(" in body[guard : body.index("structure_note_cad = cad")]
|
||||
@@ -0,0 +1,64 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""433-4 — 곡선 R 바꿔 MC 측점이 움직임(curve:47.35 → curve:49.10).
|
||||
|
||||
상세 갈아끼움이 옛 MC 횡단을 이어 받음.
|
||||
|
||||
못박는 것
|
||||
- MC 설계(사용자 칸) 남음 · 측점 id 그대로 · added · removed 빔.
|
||||
- moves 에 MC 짝(누가거리 열쇠 칸이 따라감).
|
||||
- CURVE_NEAR_M 밖으로 간 MC = 못 이음(새 측점 · 옛 열쇠 removed).
|
||||
|
||||
Node 헬퍼 `helper_433_swap.cjs` 가 진짜 모듈(`B06_Section_Station_Swap.ts`)을 부름.
|
||||
"""
|
||||
|
||||
import json
|
||||
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
|
||||
from config.config_system_design import EARTHWORK_CONVERSION_FACTORS # noqa: E402
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out(tmp_path_factory):
|
||||
work = tmp_path_factory.mktemp("b433")
|
||||
standard = work / "standard.json"
|
||||
conversion = work / "conversion.json"
|
||||
standard.write_text(json.dumps(STANDARD_CROSS_SECTION), encoding="utf-8")
|
||||
conversion.write_text(json.dumps(EARTHWORK_CONVERSION_FACTORS), encoding="utf-8")
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_433_swap.cjs"), str(standard), str(conversion)],
|
||||
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_MC_설계가_새_곡선_열쇠로_남음(out):
|
||||
mc = out["mc"]
|
||||
assert mc["memo"] == "MY-EDIT@47.35"
|
||||
assert mc["stationId"] == "station_000000047350"
|
||||
assert mc["added"] == []
|
||||
assert mc["removed"] == []
|
||||
|
||||
|
||||
def test_moves_에_MC_짝(out):
|
||||
assert ["47.35", "49.10"] in out["mc"]["moves"]
|
||||
|
||||
|
||||
def test_멀리_간_MC_는_못_이음(out):
|
||||
far = out["mc"]["far"]
|
||||
assert far["added"] == ["curve:62.00"]
|
||||
assert far["removed"] == ["curve:47.35"]
|
||||
@@ -0,0 +1,80 @@
|
||||
"""PLAN 448(442-2) 거울 — 「페이지 설정」 TS 기준표 ↔ 파이썬 법정 기준 전수.
|
||||
|
||||
TS `B05_Profile_UI_Panel_Fields.ts` PROFILE_CRITERIA · SPEED_CHOICES 와 `gradeLimitPct` 규칙
|
||||
(고를 수 없는 속도 = 첫 선택지 · 넣은 값 > 0 이면 그 값 · 아니면 표)을 파이썬
|
||||
`FOREST_ROAD_PROFILE_CRITERIA` · `resolve_grade_options(...).max_grade_pct` 와 임도 종류 × 속도 ×
|
||||
지형 × 입력 유무 전부 대조. TS 표는 `helper_442_2_page_settings.cjs` 가 꺼냄.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_grade_limit_mirror.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import itertools
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options
|
||||
from config.config_system_design import FOREST_ROAD_PROFILE_CRITERIA as PY
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def ts_tables():
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_442_2_page_settings.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
out = json.loads(proc.stdout)
|
||||
return out["criteria"], out["speedChoices"]
|
||||
|
||||
|
||||
def test_속도별_기울기_반경_표가_같음(ts_tables):
|
||||
criteria, _ = ts_tables
|
||||
assert sorted(int(speed) for speed in criteria) == sorted(PY["design_speed"])
|
||||
for speed, legal in PY["design_speed"].items():
|
||||
ts = criteria[str(speed)]
|
||||
assert ts["grade"] == legal["max_grade_pct"]
|
||||
assert ts["radius"] == legal["min_vertical_radius_m"]
|
||||
|
||||
|
||||
def test_종류별_속도_선택지가_같음(ts_tables):
|
||||
_, choices = ts_tables
|
||||
for road, allowed in choices.items():
|
||||
assert tuple(allowed) == tuple(PY["selectable_design_speeds"].get(road, (20,)))
|
||||
|
||||
|
||||
def _ts_limit(criteria, choices, road, speed, terrain, typed):
|
||||
allowed = choices.get(road, [20])
|
||||
speed = speed if speed in allowed else allowed[0] # syncSpeedChoices
|
||||
if typed is not None and typed > 0:
|
||||
return float(typed)
|
||||
return float(criteria[str(speed)]["grade"][terrain])
|
||||
|
||||
|
||||
def test_기울기_상한_전수(ts_tables):
|
||||
criteria, choices = ts_tables
|
||||
cases = itertools.product(
|
||||
["trunk", "fire", "work", "branch"], [20, 30, 40], ["normal", "special"], [None, 11.5]
|
||||
)
|
||||
for road, speed, terrain, typed in cases:
|
||||
py = resolve_grade_options(
|
||||
road,
|
||||
terrain_type=terrain,
|
||||
design_speed_kph=speed,
|
||||
stored={} if typed is None else {"max_grade_pct": typed},
|
||||
)
|
||||
assert _ts_limit(criteria, choices, road, speed, terrain, typed) == py.max_grade_pct, (
|
||||
road,
|
||||
speed,
|
||||
terrain,
|
||||
typed,
|
||||
)
|
||||
@@ -0,0 +1,404 @@
|
||||
"""PLAN 448(442-2 · 요구 269-1) — B05 페이지 설정 [저장] · [확정] 영구저장.
|
||||
|
||||
- 병합 — stage 2 params 에 사용자 값만 얹음(`options` 칸끼리 · 상태 · STALE 안 건드림)
|
||||
- 정책 갱신 — 종단 경고 기준(최대 기울기 · 종단곡선 R · 평면 R)만 · 기하(base_pvi · 편집) 그대로
|
||||
- 작업임도 30 → 20 해석 · params 엔 원값
|
||||
- E1 — 노선 바꾼 저장 · B04 재확정 요청이 설계속도 · 최소고 강제 · 시작 측점을 옮김
|
||||
- E2 — B06 재생성 지형 기본 = 특수(B05 기본)
|
||||
- E3 — 초기값 스냅샷이 stage 2 params 를 뜨고 [초기화]가 되돌림 · 옛 스냅샷은 설정 칸만 걷음
|
||||
- 저장 차례 · 초안 내보내기 · 끝 측점 편집 — Node 헬퍼 `helper_442_2_page_settings.cjs`
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_442_2_page_settings.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from B03_FileInput.B03_FileInput_Service_Chain import redesign_solve_request
|
||||
from B05_Profile import B05_Profile_PageSettings as page
|
||||
from B05_Profile.B05_Profile_Engine_Grade import resolve_grade_options
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import design_alignment_profile
|
||||
from B05_Profile.B05_Profile_Schema import PageSettingsRequest
|
||||
from B06_Section.B06_Section_Router_Regenerate_Options import _regeneration_grade_options
|
||||
from common_util import common_util_initial_snapshot as snapshot
|
||||
from common_util.common_util_workflow_state import merge_stage_params, merged_params
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out():
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_442_2_page_settings.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
PARAMS = {
|
||||
"points": {"bp": {"x": 0, "y": 0}, "ep": {"x": 1, "y": 1}},
|
||||
"options": {"grade_class": "trunk", "terrain_type": "special", "paved": False},
|
||||
"station_interval_m": 20,
|
||||
"max_grade_pct": 13.0,
|
||||
"start_elevation_offset_m": 0.4,
|
||||
}
|
||||
|
||||
|
||||
# ── 병합 ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_병합_options_칸끼리_나머지_갈음():
|
||||
request = PageSettingsRequest(
|
||||
design_speed_kph=30,
|
||||
terrain_type="normal",
|
||||
max_grade_pct=None,
|
||||
min_vertical_radius_m=300,
|
||||
station_display={"station": 12.4, "cumulative": 240},
|
||||
)
|
||||
merged = merged_params(PARAMS, page.params_patch(request))
|
||||
assert merged["options"] == {
|
||||
"grade_class": "trunk",
|
||||
"terrain_type": "normal",
|
||||
"paved": False,
|
||||
"design_speed_kph": 30,
|
||||
}
|
||||
assert merged["max_grade_pct"] is None # 비운 칸 = 법정값
|
||||
assert merged["min_vertical_radius_m"] == 300
|
||||
assert merged["station_display"] == {"station": 12.0, "cumulative": 240.0}
|
||||
assert merged["points"] == PARAMS["points"] and merged["start_elevation_offset_m"] == 0.4
|
||||
assert PARAMS["options"]["terrain_type"] == "special" # 원본 안 바뀜
|
||||
|
||||
|
||||
class _Cursor:
|
||||
def __init__(self, params):
|
||||
self.params = params
|
||||
self.sql: list[str] = []
|
||||
|
||||
async def execute(self, sql, args=None):
|
||||
self.sql.append(" ".join(sql.split()))
|
||||
if sql.strip().upper().startswith("UPDATE"):
|
||||
self.params = args[0]
|
||||
|
||||
async def fetchone(self):
|
||||
return {"params": self.params}
|
||||
|
||||
|
||||
def test_merge_stage_params_상태_안_건드림():
|
||||
cursor = _Cursor(json.dumps(PARAMS))
|
||||
merged = asyncio.run(merge_stage_params(cursor, "p1", 2, {"options": {"design_speed_kph": 40}}))
|
||||
assert merged["options"]["design_speed_kph"] == 40
|
||||
assert json.loads(cursor.params)["options"]["grade_class"] == "trunk"
|
||||
assert "FOR UPDATE" in cursor.sql[0]
|
||||
assert len(cursor.sql) == 2 and cursor.sql[1].startswith("UPDATE project_workflow_stages SET")
|
||||
assert all("state" not in sql.lower() and "projects" not in sql for sql in cursor.sql[1:])
|
||||
|
||||
|
||||
def test_요청_검증():
|
||||
with pytest.raises(ValueError):
|
||||
PageSettingsRequest(design_speed_kph=50)
|
||||
with pytest.raises(ValueError):
|
||||
PageSettingsRequest(terrain_type="flat")
|
||||
with pytest.raises(ValueError):
|
||||
PageSettingsRequest(max_grade_pct=0)
|
||||
with pytest.raises(ValueError):
|
||||
PageSettingsRequest(extra=1)
|
||||
|
||||
|
||||
def test_작업임도_30은_20으로_params_는_원값():
|
||||
request = PageSettingsRequest(design_speed_kph=30, terrain_type="normal")
|
||||
params = merged_params({"options": {}}, page.params_patch(request))
|
||||
assert params["options"]["design_speed_kph"] == 30 # 원값 저장(해석값 금지)
|
||||
work = page.grade_options_of("work", params)
|
||||
assert (work.design_speed_kph, work.max_grade_pct, work.min_vertical_radius_m) == (20, 9, 100)
|
||||
trunk = page.grade_options_of("main", params) # B02 main = 간선
|
||||
assert (trunk.design_speed_kph, trunk.max_grade_pct, trunk.min_vertical_radius_m) == (
|
||||
30,
|
||||
8,
|
||||
250,
|
||||
)
|
||||
typed = page.grade_options_of("main", {**params, "max_grade_pct": 11.0})
|
||||
assert typed.max_grade_pct == 11.0 and typed.warnings # 법정 8 초과 = 경고(막지 않음)
|
||||
|
||||
|
||||
# ── 정책 갱신 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _longitudinal() -> dict:
|
||||
"""앞 200 m 11 % 오르막 · 뒤 200 m 2 % — 특수 20(14 %)엔 위반 없음 · 일반 30(8 %)엔 위반."""
|
||||
chainage = np.arange(0.0, 400.5, 1.0)
|
||||
ground = np.where(chainage <= 200, 100 + 0.11 * chainage, 122 + 0.02 * (chainage - 200))
|
||||
longitudinal = {
|
||||
"samples": [
|
||||
{"chainage_m": float(s), "elevation_m": float(z)} for s, z in zip(chainage, ground)
|
||||
],
|
||||
"stations": [
|
||||
{"station_id": f"s{i}", "chainage_m": float(s)}
|
||||
for i, s in enumerate(np.arange(0, 401, 20))
|
||||
],
|
||||
}
|
||||
options = resolve_grade_options("trunk", terrain_type="special", design_speed_kph=20)
|
||||
alignment, profile = design_alignment_profile(longitudinal, options)
|
||||
edits = {"station_offsets": {"100.000": 0.8}, "curve_radii": {}}
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
longitudinal["design_profiles"] = [profile]
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import rebuild_alignment_profile
|
||||
|
||||
alignment, profile = rebuild_alignment_profile(longitudinal, edits)
|
||||
longitudinal["profile_alignment"] = alignment
|
||||
longitudinal["design_profiles"] = [profile]
|
||||
return longitudinal
|
||||
|
||||
|
||||
def test_정책_갱신_기하_그대로():
|
||||
before = _longitudinal()
|
||||
after = copy.deepcopy(before)
|
||||
strict = resolve_grade_options("trunk", terrain_type="normal", design_speed_kph=30)
|
||||
alignment, summary = page.retarget_longitudinal(after, strict)
|
||||
policy = alignment["policy"]
|
||||
assert (policy["max_grade_pct"], policy["min_vertical_radius_m"]) == (8, 250)
|
||||
assert policy["min_plan_radius_m"] == 30 # 30 km/h 일반지형
|
||||
criteria = after["design_profiles"][0]["criteria"]
|
||||
assert (criteria["design_speed_kph"], criteria["terrain_type"]) == (30, "normal")
|
||||
assert not before["profile_alignment"]["violations"]
|
||||
assert after["profile_alignment"]["violations"] # 같은 선 · 엄한 기준 = 위반 뜸
|
||||
assert summary["id"] == after["design_profiles"][0]["id"]
|
||||
# 기하 그대로 — 자동 선형 · 편집 · 계획고 표본
|
||||
assert alignment["base_pvi"] == before["profile_alignment"]["base_pvi"]
|
||||
assert alignment["edits"] == before["profile_alignment"]["edits"]
|
||||
z0 = [s["elevation_m"] for s in before["profile_alignment"]["samples"]]
|
||||
z1 = [s["elevation_m"] for s in alignment["samples"]]
|
||||
assert np.allclose(z0, z1)
|
||||
|
||||
|
||||
def test_retarget_file_선형_없으면_그대로(tmp_path):
|
||||
options = resolve_grade_options("trunk", terrain_type="normal")
|
||||
rel = "B06_Section/longitudinal/longitudinal.json"
|
||||
path = tmp_path / rel
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text(json.dumps({"samples": []}), encoding="utf-8")
|
||||
assert page.retarget_file(tmp_path, rel, options) is None
|
||||
assert json.loads(path.read_text(encoding="utf-8")) == {"samples": []}
|
||||
assert page.retarget_file(tmp_path, "nope.json", options) is None
|
||||
with pytest.raises(ValueError):
|
||||
page.retarget_file(tmp_path, "../outside.json", options)
|
||||
path.write_text(json.dumps(_longitudinal()), encoding="utf-8")
|
||||
summary = page.retarget_file(tmp_path, rel, options)
|
||||
stored = json.loads(path.read_text(encoding="utf-8"))
|
||||
assert summary is not None and stored["profile_alignment"]["policy"]["max_grade_pct"] == 9
|
||||
|
||||
|
||||
# ── E1 · E2 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_E1_재설계_요청이_설정을_옮김():
|
||||
params = {
|
||||
**PARAMS,
|
||||
"options": {**PARAMS["options"], "design_speed_kph": 30},
|
||||
"enforce_pipe_clearance": True,
|
||||
"station_display": {"station": 12, "cumulative": 240},
|
||||
}
|
||||
request = redesign_solve_request(params, {"source_filter": "csf"}, 5)
|
||||
assert request.design_speed_kph == 30
|
||||
assert request.enforce_pipe_clearance is True
|
||||
assert request.station_display == {"station": 12, "cumulative": 240}
|
||||
assert request.terrain_type == "special"
|
||||
|
||||
|
||||
def test_E2_B06_재생성_지형_기본_특수():
|
||||
no_terrain = _regeneration_grade_options({"options": {"grade_class": "trunk"}})
|
||||
assert no_terrain.terrain_type == "special" and no_terrain.max_grade_pct == 14
|
||||
saved = _regeneration_grade_options(
|
||||
{"options": {"grade_class": "trunk", "terrain_type": "normal", "design_speed_kph": 30}}
|
||||
)
|
||||
assert (saved.terrain_type, saved.max_grade_pct) == ("normal", 8)
|
||||
|
||||
|
||||
def test_노선_추천_설정은_options_에서_읽음(monkeypatch):
|
||||
from B05_Profile import B05_Profile_Router_Replan as replan
|
||||
|
||||
stage = {
|
||||
"stage_no": 2,
|
||||
"params": {"options": {"design_speed_kph": 30, "terrain_type": "normal"}},
|
||||
}
|
||||
|
||||
class _Cur:
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, *_):
|
||||
return None
|
||||
|
||||
async def fetchone(self):
|
||||
return ("main",)
|
||||
|
||||
class _Conn:
|
||||
def cursor(self, *_):
|
||||
return _Cur()
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self):
|
||||
return _Conn()
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(replan, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
|
||||
|
||||
async def fake_state(*_):
|
||||
return {"stages": [stage]}
|
||||
|
||||
import common_util.common_util_workflow_state as workflow
|
||||
|
||||
monkeypatch.setattr(workflow, "get_workflow_state", fake_state)
|
||||
assert asyncio.run(replan._road_settings("p1")) == ("main", 30, "normal")
|
||||
|
||||
|
||||
# ── E3 스냅샷 ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _SnapCursor:
|
||||
def __init__(self, state):
|
||||
self.state = state
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *_):
|
||||
return False
|
||||
|
||||
async def execute(self, sql, args=None):
|
||||
self.state["sql"].append(sql)
|
||||
if sql.strip().upper().startswith("UPDATE"):
|
||||
self.state["params"] = args[0]
|
||||
|
||||
async def fetchone(self):
|
||||
return (self.state["params"],)
|
||||
|
||||
|
||||
class _SnapConn:
|
||||
def __init__(self, state):
|
||||
self.state = state
|
||||
|
||||
def cursor(self, *_):
|
||||
return _SnapCursor(self.state)
|
||||
|
||||
|
||||
def test_E3_스냅샷_stage2_왕복():
|
||||
initial = {**PARAMS, "options": {**PARAMS["options"], "design_speed_kph": 20}}
|
||||
state = {"params": json.dumps(initial), "sql": []}
|
||||
dumped = asyncio.run(snapshot._stage2_params(_SnapConn(state), "p1"))
|
||||
assert dumped == initial
|
||||
state["params"] = json.dumps(
|
||||
{**initial, "max_grade_pct": 7, "options": {"design_speed_kph": 40}}
|
||||
)
|
||||
asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": dumped}))
|
||||
assert json.loads(state["params"]) == initial
|
||||
|
||||
|
||||
def test_E3_옛_스냅샷은_설정_칸만_걷음():
|
||||
edited = {
|
||||
**PARAMS,
|
||||
"options": {**PARAMS["options"], "design_speed_kph": 40, "terrain_type": "normal"},
|
||||
"min_vertical_radius_m": 500,
|
||||
"enforce_pipe_clearance": True,
|
||||
"station_display": {"station": 3, "cumulative": 60},
|
||||
}
|
||||
state = {"params": json.dumps(edited), "sql": []}
|
||||
asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"routes": []}))
|
||||
restored = json.loads(state["params"])
|
||||
assert restored["options"] == {"grade_class": "trunk", "paved": False}
|
||||
for key in page.PAGE_TOP_KEYS:
|
||||
assert key not in restored
|
||||
assert restored["points"] == PARAMS["points"] and restored["start_elevation_offset_m"] == 0.4
|
||||
|
||||
|
||||
def test_E3_뜰_params_없으면_안_씀():
|
||||
state = {"params": None, "sql": []}
|
||||
asyncio.run(snapshot._restore_stage2_params(_SnapConn(state), "p1", {"stage2_params": None}))
|
||||
assert not any(sql.strip().upper().startswith("UPDATE") for sql in state["sql"])
|
||||
|
||||
|
||||
# ── 화면(Node) ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_저장_차례_settings_는_노선_뒤_관_앞(out):
|
||||
order = out["order"]
|
||||
assert order.index("route") + 1 == order.index("settings") < order.index("alignment")
|
||||
|
||||
|
||||
def test_노선_바꾼_저장에도_settings_기본_창구(out):
|
||||
assert out["routeSaveDone"] is True
|
||||
assert out["routeSaveCalls"] == ["route", "settings:p1"] # 화면 몫(own) 아님 · 기본 창구
|
||||
|
||||
|
||||
def test_초안_내보내기(out):
|
||||
assert out["putsWithoutDraft"] == 0
|
||||
assert out["boom"] == "서버 500" and out["draftAfterBoom"] == out["draft"]
|
||||
assert out["draftAfterOk"] is None and out["puts"] == 2
|
||||
params = out["latestAfterOk"]["route_params"]
|
||||
assert params["options"] == {
|
||||
"grade_class": "trunk",
|
||||
"design_speed_kph": 30,
|
||||
"terrain_type": "normal",
|
||||
}
|
||||
assert params["max_grade_pct"] is None and params["min_vertical_radius_m"] == 300
|
||||
assert params["station_display"] == {"station": 12, "cumulative": 240}
|
||||
|
||||
|
||||
def test_초안_되살림_값(out):
|
||||
restore = out["restoreFromDraft"]
|
||||
assert restore["designSpeed"] == 30 and restore["maxGradePct"] is None
|
||||
assert restore["stationDisplay"] == {"station": 12, "cumulative": 240}
|
||||
|
||||
|
||||
def test_시종점_조정은_끝_측점_계획고_편집(out):
|
||||
assert out["endsEmpty"] == {"start": 0, "end": 0}
|
||||
assert out["endsRaised"] == {"start": 1.25, "end": 0}
|
||||
assert out["endsBoth"] == {"start": 1.25, "end": -0.5}
|
||||
assert out["planStart"] == pytest.approx(101.25) and out["planEnd"] == pytest.approx(107.5)
|
||||
assert out["backToZero"]["start"] == 0
|
||||
|
||||
|
||||
def test_시작_측점은_열쇠_다른_페이지에_안_섞임():
|
||||
root = HERE.parents[1]
|
||||
hits = subprocess.run(
|
||||
["git", "grep", "-l", "station_display", "--", "*.py", "*.ts"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
cwd=root,
|
||||
encoding="utf-8",
|
||||
).stdout.split()
|
||||
allowed = {
|
||||
"B03_FileInput/B03_FileInput_Service_Chain.py", # E1 옮김
|
||||
"B05_Profile/B05_Profile_Api_Fetch.ts",
|
||||
"B05_Profile/B05_Profile_Api_PageSettings.ts",
|
||||
"B05_Profile/B05_Profile_PageSettings.py",
|
||||
"B05_Profile/B05_Profile_Router.py",
|
||||
"B05_Profile/B05_Profile_Schema.py",
|
||||
"B05_Profile/B05_Profile_UI_Page_Latest.ts",
|
||||
}
|
||||
assert {hit for hit in hits if not hit.startswith("resources/")} <= allowed
|
||||
|
||||
|
||||
def test_저장_정책을_다시_읽어도_평면_R_유지():
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import AlignmentPolicy
|
||||
|
||||
policy = AlignmentPolicy.from_dict({"min_plan_radius_m": 30, "max_grade_pct": 8})
|
||||
assert policy.min_plan_radius_m == 30 and policy.hairpin_min_radius_m == 10
|
||||
assert AlignmentPolicy.from_dict({}).min_plan_radius_m == 0 # 옛 정책 = 판정 안 함 그대로
|
||||
@@ -0,0 +1,105 @@
|
||||
"""PLAN 449 — B05 [저장] 관 목록 · 노선 저장 뒤 실패(433-7 · 433-12 · 433-6).
|
||||
|
||||
· 관 정본 PUT = 세션 관 초안 한 번(B06 고침 직후 [저장] = 초안 값) · 배수판 메모리는 안 보냄
|
||||
· 초안 없음 = [저장] PUT 없음(판이 안 서도 관 수 그대로) · [확정] = 칸 없이(서버 저장분) · 빈 초안 = 다 지움
|
||||
· 노선 저장 뒤 구조물 차례 실패 → 0 칸 계획고가 새 노선 칸에 · 횡단 초안 넘김(저장 없이) · 다시 세움 →
|
||||
다음 [저장]이 새 노선으로 계획고를 보냄 · 넘김 자체가 실패하면 두 번 안 넘김
|
||||
· 관 PUT 끝 측점 맞춤 실패 = 응답 `stations_failed`
|
||||
짝: `helper_449_save_pipes.cjs`(실제 `design_save.ts` 를 Node 로 · 서버 창구만 가짜).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
if not (ROOT / "config" / "node_modules" / "typescript").is_dir():
|
||||
pytest.skip("프론트엔드 의존성 미설치")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_449_save_pipes.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def _puts(case: dict) -> list:
|
||||
return [call for call in case["calls"] if call[0] == "PUT"]
|
||||
|
||||
|
||||
def test_관_PUT_은_초안_한_번(out):
|
||||
case = out["draft"]
|
||||
assert case["ok"] and _puts(case) == [
|
||||
["PUT", [{"chainage_m": 15, "source": "user", "id": "p1"}], False]
|
||||
]
|
||||
assert case["calls"].index(["RELOAD"]) > case["calls"].index(
|
||||
_puts(case)[0]
|
||||
) # 화면은 뒤에 받기만
|
||||
assert case["left"] is None
|
||||
|
||||
|
||||
def test_초안_없으면_PUT_없음(out):
|
||||
assert out["noDraft"]["ok"] and _puts(out["noDraft"]) == []
|
||||
|
||||
|
||||
def test_확정은_칸_없이_다시_셈(out):
|
||||
assert _puts(out["confirmNoDraft"]) == [["PUT", None, True]]
|
||||
|
||||
|
||||
def test_빈_초안은_다_지움(out):
|
||||
assert _puts(out["emptyDraft"]) == [["PUT", [], False]]
|
||||
|
||||
|
||||
def test_노선_저장_뒤_실패_초안_새_노선(out):
|
||||
case = out["routeFail"]
|
||||
assert not case["ok"] and "구조물" in case["failure"]
|
||||
assert case["calls"] == [["ROUTE"], ["CARRY", 7, 8, False], ["REBUILD"]]
|
||||
assert out["alignAfterFail"] == {"8": {"pvi": {"120": 1.5}}} # 0 칸 비움 · 새 노선 칸
|
||||
align = [call for call in out["nextSave"]["calls"] if call[0] == "ALIGN"]
|
||||
assert align == [["ALIGN", 8, {"pvi": {"120": 1.5}}]]
|
||||
|
||||
|
||||
def test_넘김_실패면_두_번_안_넘김(out):
|
||||
case = out["carryFail"]
|
||||
assert not case["ok"]
|
||||
assert [call for call in case["calls"] if call[0] == "CARRY"] == [["CARRY", 7, 8, True]]
|
||||
assert case["calls"][-1] == ["REBUILD"]
|
||||
|
||||
|
||||
def test_공용_저장이_판_메모리를_안_보냄():
|
||||
shared = (ROOT / "A00_Common/design/design_shared.ts").read_text(encoding="utf-8")
|
||||
panel = (ROOT / "B05_Profile/B05_Profile_UI_Drainage_Panel.ts").read_text(encoding="utf-8")
|
||||
assert "savePipes" not in shared and "saveDetailPipePoints" not in panel
|
||||
|
||||
|
||||
def test_측점_맞춤_실패는_응답에(monkeypatch):
|
||||
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
|
||||
from B06_Section import B06_Section_Router_Stations as stations
|
||||
|
||||
async def boom(_project_id):
|
||||
raise RuntimeError("샘플링 없음")
|
||||
|
||||
async def fine(_project_id):
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(stations, "ensure_structure_stations", boom)
|
||||
assert asyncio.run(basins._stand_pipe_stations(uuid4())) is False
|
||||
monkeypatch.setattr(stations, "ensure_structure_stations", fine)
|
||||
assert asyncio.run(basins._stand_pipe_stations(uuid4())) is True
|
||||
@@ -0,0 +1,291 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""452 서버 — 구 구조물 이관 · 옛 횡단 파일 · 시종점 열쇠 (433-17① · 433-10 · 433-18).
|
||||
|
||||
못박는 것
|
||||
- 이관 원천 = 저장 종단 파일의 비정규 측점 — 화면 본문(초안 관 자리 섞임)은 안 믿음 ·
|
||||
저장 관 자리면 structures.json · pipe_points.json 그대로.
|
||||
- 관 PUT 끝 측점 다시 뜸(`_regenerate_structure_stations`) = 옛 자리 cross_*.json 도 지움.
|
||||
- 시종점 열쇠 한 규칙 — 맨 앞 · 맨 끝은 구조물이 얹혀도(병합이 종류를 irregular 로 갈음해도)
|
||||
`end:BP/EP` · 구간형 시작 · 끝 = `:start` · `:end` · 서버 `station_keys` = B06 `stationKeysOf`.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import B05_Profile.B05_Profile_Structures_Router as router_module # noqa: E402
|
||||
import B06_Section.B06_Section_Repository as b06_repo # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Sections import cross_filename # noqa: E402
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import station_keys # noqa: E402
|
||||
from common_util.common_util_drainage_pipes import pipe_points_path_in # noqa: E402
|
||||
|
||||
PROJECT_ID = "45245245-2452-4524-5245-245245245245"
|
||||
LONG_REL = "B06_Section/longitudinal/longitudinal.json"
|
||||
|
||||
|
||||
def _write_longitudinal(project: Path, stations):
|
||||
path = project / LONG_REL
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"stations": stations}), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
# ── 433-17① 이관 = 저장 종단 파일 ─────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def migrate_client(tmp_path, monkeypatch):
|
||||
project = tmp_path / "project"
|
||||
|
||||
async def fake_root(project_id):
|
||||
return str(project)
|
||||
|
||||
async def fake_length(project_id):
|
||||
return 400.0
|
||||
|
||||
async def fake_latest(connection, project_id):
|
||||
return {"id": 7}
|
||||
|
||||
async def fake_longitudinal(connection, project_id, route_id):
|
||||
assert route_id == 7
|
||||
return {"longitudinal_file_path": LONG_REL}
|
||||
|
||||
class _Acquire:
|
||||
async def __aenter__(self):
|
||||
return object()
|
||||
|
||||
async def __aexit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(router_module, "_project_root", fake_root)
|
||||
monkeypatch.setattr(router_module, "_route_length", fake_length)
|
||||
monkeypatch.setattr(router_module, "get_latest_route", fake_latest)
|
||||
monkeypatch.setattr(router_module, "get_db_pool", lambda: SimpleNamespace(acquire=_Acquire))
|
||||
monkeypatch.setattr(b06_repo, "get_longitudinal_section", fake_longitudinal)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
return TestClient(app), project
|
||||
|
||||
|
||||
def test_초안_측점_본문은_버리고_저장_관_자리는_이관_안_함(migrate_client):
|
||||
client, project = migrate_client
|
||||
pipes = pipe_points_path_in(project)
|
||||
pipes.parent.mkdir(parents=True, exist_ok=True)
|
||||
pipes.write_text(
|
||||
json.dumps({"route_signature": "s", "points": [{"chainage_m": 351.0, "id": "p1"}]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_longitudinal(
|
||||
project,
|
||||
[
|
||||
{"chainage_m": 0.0, "kind": "bp"},
|
||||
{"chainage_m": 351.0, "kind": "irregular", "structure": "파형강관 D800"},
|
||||
{"chainage_m": 400.0, "kind": "ep"},
|
||||
],
|
||||
)
|
||||
before = pipes.read_bytes()
|
||||
# 화면이 초안(관 351 → 364) 상세를 들고 와도 — 옛 본문 꼴을 보내 봄
|
||||
body = {"route_id": 7, "stations": [{"chainage_m": 364.0, "structure": "낙석방지책"}]}
|
||||
response = client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json=body)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["migrated"] == 0
|
||||
assert not (project / "B05_Profile" / "route" / "structures.json").exists()
|
||||
assert pipes.read_bytes() == before
|
||||
|
||||
|
||||
def test_저장_종단의_옛_자유글_측점은_이관(migrate_client):
|
||||
client, project = migrate_client
|
||||
_write_longitudinal(
|
||||
project,
|
||||
[
|
||||
{"chainage_m": 0.0, "kind": "bp"},
|
||||
{"chainage_m": 260.0, "kind": "irregular", "structure": "낙석방지책"},
|
||||
{"chainage_m": 400.0, "kind": "ep"},
|
||||
],
|
||||
)
|
||||
response = client.post(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"route_id": None}
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
assert response.json()["migrated"] == 1
|
||||
stored = client.get(f"/api/projects/{PROJECT_ID}/route/structures").json()["structures"]
|
||||
assert [(item["type_id"], item["chainage_m"]) for item in stored] == [("etc", 260.0)]
|
||||
|
||||
|
||||
def test_저장_종단이_없으면_아무것도_안_함(migrate_client):
|
||||
client, project = migrate_client
|
||||
response = client.post(f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={})
|
||||
assert response.status_code == 200 and response.json()["migrated"] == 0
|
||||
assert not (project / "B05_Profile" / "route" / "structures.json").exists()
|
||||
|
||||
|
||||
# ── 433-10 관 PUT 측점 다시 뜸 = 옛 cross 파일 지움 ─────────────────────────
|
||||
|
||||
|
||||
def test_관_옮김_측점_다시_뜸이_옛_자리_횡단_파일을_지움(tmp_path, monkeypatch):
|
||||
import B05_Profile.B05_Profile_Engine_Sections as engine
|
||||
import B06_Section.B06_Section_Router_Stations as stations_module
|
||||
|
||||
_write_longitudinal(
|
||||
tmp_path,
|
||||
[
|
||||
{"chainage_m": 0.0, "kind": "bp"},
|
||||
{"chainage_m": 351.0, "kind": "irregular", "structure": "파형강관 D800"},
|
||||
{"chainage_m": 400.0, "kind": "ep"},
|
||||
],
|
||||
)
|
||||
cross_dir = tmp_path / "B06_Section" / "cross_sections"
|
||||
cross_dir.mkdir(parents=True)
|
||||
for at in (0.0, 351.0, 400.0):
|
||||
(cross_dir / cross_filename(at)).write_text("{}", encoding="utf-8")
|
||||
|
||||
def fake_generate(*args, **kwargs):
|
||||
(cross_dir / cross_filename(361.0)).write_text("{}", encoding="utf-8")
|
||||
return [{"chainage_m": 361.0, "kind": "irregular", "structure": "파형강관 D800"}]
|
||||
|
||||
async def fake_inputs(project_id, route, snapshot):
|
||||
return None, None, {"longitudinal_file_path": LONG_REL}
|
||||
|
||||
async def fake_run(fn, *args):
|
||||
return None # DB 행 맞춤 · 등급 요약은 이 시험 밖
|
||||
|
||||
monkeypatch.setattr(engine, "_load_route_polyline", lambda *a: [[0.0, 0.0], [400.0, 0.0]])
|
||||
monkeypatch.setattr(engine, "_load_pipe_points", lambda *a: [])
|
||||
monkeypatch.setattr(engine, "resolve_extra_stations", lambda *a: [])
|
||||
monkeypatch.setattr(engine, "generate_irregular_sections", fake_generate)
|
||||
monkeypatch.setattr(stations_module, "_generation_inputs", fake_inputs)
|
||||
monkeypatch.setattr(stations_module, "run_with_connection", fake_run)
|
||||
|
||||
made = asyncio.run(
|
||||
stations_module._regenerate_structure_stations(
|
||||
PROJECT_ID,
|
||||
tmp_path,
|
||||
{"id": 7, "route_data_path": "route.json"},
|
||||
{"filter_key": "f", "method": "m"},
|
||||
)
|
||||
)
|
||||
assert made == 1
|
||||
assert sorted(p.name for p in cross_dir.glob("cross_*.json")) == sorted(
|
||||
cross_filename(at) for at in (0.0, 361.0, 400.0)
|
||||
)
|
||||
|
||||
|
||||
# ── 433-18 시종점 열쇠 한 규칙 ─────────────────────────────────────────────
|
||||
|
||||
|
||||
STRUCTURES = [
|
||||
{"structure_id": "w1", "type_id": "retaining_wall", "chainage_m": 80.0, "start_m": 60.0},
|
||||
]
|
||||
# 구간형 옹벽 끝 = 노선 끝(EP) — 병합이 EP 행 종류를 irregular 로 갈음한 저장분 꼴
|
||||
SECTIONS = [
|
||||
{"chainage_m": 0.0, "kind": "bp"},
|
||||
{"chainage_m": 20.0, "kind": "regular"},
|
||||
{"chainage_m": 40.0, "kind": "regular"},
|
||||
{"chainage_m": 60.0, "kind": "irregular", "structure": "옹벽"},
|
||||
{"chainage_m": 80.0, "kind": "irregular", "structure": "옹벽"},
|
||||
{"chainage_m": 100.0, "kind": "irregular", "structure": "옹벽"},
|
||||
]
|
||||
|
||||
|
||||
def _structures_ns():
|
||||
return [SimpleNamespace(**item, end_m=100.0, options={}) for item in STRUCTURES]
|
||||
|
||||
|
||||
def test_서버_시종점_열쇠가_구조물보다_먼저():
|
||||
keys = station_keys(SECTIONS, [], _structures_ns(), 20.0)
|
||||
assert keys == {
|
||||
"end:BP": 0.0,
|
||||
"grid:1": 20.0,
|
||||
"grid:2": 40.0,
|
||||
"structure:w1:start": 60.0,
|
||||
"structure:w1": 80.0,
|
||||
"end:EP": 100.0,
|
||||
}
|
||||
|
||||
|
||||
def test_B06_열쇠가_서버와_같음(tmp_path):
|
||||
work = tmp_path / "b452"
|
||||
build = subprocess.run(
|
||||
[
|
||||
"node",
|
||||
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
|
||||
"build",
|
||||
"--configLoader",
|
||||
"runner",
|
||||
"--ssr",
|
||||
"../resources/tester/helper_452_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_452_entry.js", bundle)
|
||||
source = work / "input.json"
|
||||
source.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"sections": SECTIONS,
|
||||
"structures": [{**item, "end_m": 100.0} for item in STRUCTURES],
|
||||
"interval": 20.0,
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
proc = subprocess.run(
|
||||
["node", str(HERE / "helper_452_keys.mjs"), str(bundle), str(source)],
|
||||
cwd=ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
ts = {entry["key"]: entry["chainageM"] for entry in json.loads(proc.stdout)}
|
||||
assert ts == station_keys(SECTIONS, [], _structures_ns(), 20.0)
|
||||
|
||||
|
||||
def test_노선_저장_이월_EP_고침이_새_EP_에_남음():
|
||||
from B05_Profile.B05_Profile_Route_Save_Parts import carry_cross_inputs
|
||||
|
||||
old_keys = station_keys(SECTIONS, [], _structures_ns(), 20.0)
|
||||
# 노선이 늘어 옹벽이 따라감 — 끝(EP) 110 · 시작 70 · 기준 90
|
||||
moved = [
|
||||
SimpleNamespace(
|
||||
structure_id="w1",
|
||||
type_id="retaining_wall",
|
||||
chainage_m=90.0,
|
||||
start_m=70.0,
|
||||
end_m=110.0,
|
||||
options={},
|
||||
)
|
||||
]
|
||||
new_sections = [
|
||||
{"chainage_m": at, "kind": "regular"} for at in (0.0, 20.0, 40.0, 60.0, 70.0, 80.0, 90.0)
|
||||
] + [{"chainage_m": 100.0, "kind": "regular"}, {"chainage_m": 110.0, "kind": "irregular"}]
|
||||
new_keys = station_keys(new_sections, [], moved, 20.0)
|
||||
designs = {100.0: {"ground_type": "rock"}, 60.0: {"ground_type": "weathered"}}
|
||||
inputs, _, dropped = carry_cross_inputs(old_keys, new_keys, designs, ("ground_type",), ())
|
||||
assert inputs[110.0] == {"ground_type": "rock"} # EP 고침 = 새 EP
|
||||
assert inputs[70.0] == {"ground_type": "weathered"} # 구간 시작 고침 = 따라간 시작
|
||||
assert dropped == 0
|
||||
@@ -0,0 +1,35 @@
|
||||
"""454(433-21 ②) 방향키 측점 No. 20 m 걸음 막힘 — 곡선 맞춤이 후보 측점 둘(내림 · 올림) + 뒤 곡선
|
||||
앞보기로 고름. 무작위 노선 열 개 · 교점마다 네 방향 = 520 누름(`helper_454_arrow_station_step.cjs`).
|
||||
고치기 전 측점 밖 막힘 48 · 처음 맞춤 측점 밖 2 · 놓은 뒤 측점 밖 43 → 6 · 0 · 0.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_454_arrow_station_step.py -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HELPER = Path(__file__).with_name("helper_454_arrow_station_step.cjs")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got() -> dict:
|
||||
run = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HELPER)], capture_output=True, text=True, encoding="utf-8", timeout=120
|
||||
)
|
||||
assert run.returncode == 0, run.stderr
|
||||
return json.loads(run.stdout)
|
||||
|
||||
|
||||
def test_누름_수(got) -> None:
|
||||
assert got["press"] == 520
|
||||
|
||||
|
||||
def test_측점_밖_막힘_줄어듦(got) -> None:
|
||||
assert got["offBlock"] <= 10
|
||||
|
||||
|
||||
def test_처음_맞춤_놓은_뒤_측점_밖_없음(got) -> None:
|
||||
assert got["offStart"] == 0 and got["offAfterRelease"] == 0
|
||||
@@ -0,0 +1,52 @@
|
||||
"""455 (433-13 ①) 종단 ▲▼ — 누른 꼭짓점의 자동 R 을 지워 기본 L.
|
||||
|
||||
까닭: ▲▼ 가 누른 꼭짓점 자신의 R 을 남겨 거의 직선이 된 꼭짓점에 옛 자동 R(관 정착 역산값)로
|
||||
짧은 곡선이 생김. 자동 R = 서버 저장분 곡선반경과 같은 값 · 사용자가 넣은 R 은 둠.
|
||||
계산은 `helper_455_arrow_auto_radius.cjs` 가 진짜 TS 를 Node 로 돌림 · 부르는 곳 셋은 소스로 봄.
|
||||
"""
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parent.parent
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def out() -> dict:
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_455_arrow_auto_radius.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_자동_R_은_지워_기본_L(out) -> None:
|
||||
assert out["auto"]["radius"] is None
|
||||
assert out["auto"]["length"] == pytest.approx(15.0, abs=1e-6)
|
||||
# 옛 판(지우지 않음)은 같은 R 로 짧은 곡선 — 까닭 근거
|
||||
assert out["legacy"]["radius"] == pytest.approx(3000.0)
|
||||
assert out["legacy"]["length"] < 14.0
|
||||
|
||||
|
||||
def test_사용자_R_은_그대로(out) -> None:
|
||||
assert out["user"]["radius"] == 777
|
||||
|
||||
|
||||
def test_부르는_곳_셋이_저장_R_을_넘김() -> None:
|
||||
def read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
render = read("B05_Profile/B05_Profile_UI_Profile_Render.ts")
|
||||
assert render.count("chainage, delta, store.savedRadii())") == 2
|
||||
tools = read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
|
||||
assert "adjustStation(base, ctx.edits(), chainage, delta, ctx.savedRadii())" in tools
|
||||
assert "savedRadii: () => store.savedRadii()," in read("A00_Common/design/design_profile.ts")
|
||||
@@ -0,0 +1,122 @@
|
||||
"""455 (433-13 ②) 폴백 첫 종단 직선화 — 관 정착 · 직선 분할도 R > 500 m 꼭짓점 없음.
|
||||
|
||||
까닭: 초기값 직선화(438)가 1순위 지반 추종 선형에만 걸려, 1순위가 실패해 2 · 3순위(관 정착 ·
|
||||
직선 분할)로 내려가면 R > 500 m 꼭짓점이 남음. 사용자 확정(269-2) — 직선화 높이 한도 없음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Alignment import ( # noqa: E402
|
||||
AlignmentPolicy,
|
||||
build_curves,
|
||||
is_straight_bend,
|
||||
)
|
||||
from B05_Profile.B05_Profile_Engine_Grade_CurveLaw import flat_bends # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_alignment_profile,
|
||||
design_pipe_anchored_profile,
|
||||
)
|
||||
|
||||
PIPES = [60.0, 125.0, 190.0, 260.0, 330.0]
|
||||
|
||||
|
||||
def _ground(s: float) -> float:
|
||||
return 100.0 + 0.06 * s + 2.5 * math.sin(s / 45.0) + 0.05 * math.sin(s / 6.0)
|
||||
|
||||
|
||||
def _longitudinal() -> dict:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
|
||||
for s in range(0, 401)
|
||||
]
|
||||
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
|
||||
return {"samples": samples, "stations": stations}
|
||||
|
||||
|
||||
OPTIONS = GradeDesignOptions(
|
||||
max_grade_pct=14.0,
|
||||
max_reverse_grade_pct=5.0,
|
||||
min_vertical_radius_m=100.0,
|
||||
min_curve_length_m=20.0,
|
||||
min_tangent_length_m=20.0,
|
||||
vertical_curve_skip_delta_pct=0.5,
|
||||
design_speed_kph=20,
|
||||
)
|
||||
|
||||
|
||||
def _flat(alignment: dict) -> tuple[list[float], int]:
|
||||
"""(R > 500 m 꺾임 측점 · 직선 위로 옮긴 꼭짓점 수) — 438 시험과 같은 판정."""
|
||||
s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64)
|
||||
z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64)
|
||||
policy = AlignmentPolicy.from_dict(alignment["policy"])
|
||||
grades = np.diff(z) / np.diff(s)
|
||||
straight = np.zeros(len(s), dtype=bool)
|
||||
for i in range(1, len(s) - 1):
|
||||
straight[i] = is_straight_bend(grades[i] - grades[i - 1], s[i] - s[i - 1], s[i + 1] - s[i])
|
||||
flat = flat_bends(s, z, policy.default_curve_length_m) & ~straight
|
||||
assert all(float(c["r_m"]) <= 500.0 for c in alignment["curves"])
|
||||
return s[flat].tolist(), int(straight.sum())
|
||||
|
||||
|
||||
def test_관_정착_선형_R500_넘는_꼭짓점_없음() -> None:
|
||||
alignment, _profile = design_pipe_anchored_profile(_longitudinal(), OPTIONS, PIPES)
|
||||
flat, straight = _flat(alignment)
|
||||
assert flat == [] and 0 < straight < len(PIPES)
|
||||
# 직선 위로 옮긴 관 꼭짓점은 자동 R 을 안 남김 · 남은 꺾임은 곡선이 관 자리를 지남
|
||||
radii = alignment["edits"]["curve_radii"]
|
||||
kinks = {n["chainage_m"] for n in alignment["base_pvi"][1:-1]}
|
||||
assert set(map(float, radii)) <= kinks
|
||||
s = np.array([n["chainage_m"] for n in alignment["base_pvi"]], dtype=np.float64)
|
||||
z = np.array([n["elevation_m"] for n in alignment["base_pvi"]], dtype=np.float64)
|
||||
curves, _ = build_curves(s, z, AlignmentPolicy.from_dict(alignment["policy"]), radii)
|
||||
assert all(float(c["chainage_m"]) in kinks for c in curves)
|
||||
|
||||
|
||||
def test_직선_분할_선형_R500_넘는_꼭짓점_없음(monkeypatch) -> None:
|
||||
alignment, _profile = design_alignment_profile(_longitudinal(), OPTIONS)
|
||||
assert _flat(alignment)[0] == []
|
||||
# 풀이가 R > 500 m 꺾임(100 m · 대수차 0.5 %)을 낸 꼴 — 직선 위로 · 200 m 꺾임은 그대로
|
||||
import B05_Profile.B05_Profile_Engine_Grade_Profile as engine
|
||||
|
||||
s = np.array([0.0, 100.0, 200.0, 400.0])
|
||||
z = np.array([100.0, 103.0, 106.5, 124.5])
|
||||
monkeypatch.setattr(engine, "station_breakpoints", lambda *_a, **_k: s)
|
||||
monkeypatch.setattr(engine, "solve_alignment_elevations", lambda *_a, **_k: (z.copy(), True))
|
||||
alignment, _profile = engine.design_alignment_profile(_longitudinal(), OPTIONS)
|
||||
flat, straight = _flat(alignment)
|
||||
assert flat == [] and straight == 1
|
||||
nodes = {n["chainage_m"]: n["elevation_m"] for n in alignment["base_pvi"]}
|
||||
assert nodes[100.0] == pytest.approx(103.25)
|
||||
assert nodes[200.0] == pytest.approx(106.5)
|
||||
|
||||
|
||||
def test_부르는_곳() -> None:
|
||||
src = (ROOT / "B05_Profile" / "B05_Profile_Engine_Grade_Profile.py").read_text(encoding="utf-8")
|
||||
assert src.count("flat_bends(base_s, ") >= 3 # 지반 추종(straight_pass_through 안) 밖 둘
|
||||
|
||||
|
||||
def test_여유로_들린_관_꼭짓점은_그대로() -> None:
|
||||
"""관 여유 강제(+1.5 m)로 들린 관 꼭짓점 = 직선화 경계 · 여유 없는 관 꼭짓점만 직선 위."""
|
||||
clearances = {125.0: 1.5}
|
||||
alignment, _profile = design_pipe_anchored_profile(
|
||||
_longitudinal(), OPTIONS, PIPES, pipe_clearances=clearances
|
||||
)
|
||||
flat, straight = _flat(alignment)
|
||||
assert straight > 0
|
||||
assert flat in ([], [125.0]) # 들린 꼭짓점만 R > 500 이 남을 수 있음
|
||||
rows = {r["chainage_m"]: r["plan_elevation_m"] for r in alignment["stations"]}
|
||||
plan = np.interp(125.0, list(rows), list(rows.values()))
|
||||
assert plan > _ground(125.0) + 1.0
|
||||
@@ -0,0 +1,105 @@
|
||||
"""455 (433-13 ③) 옛 정책 종단 — 법정 최소 R · L 미달 경고가 초안 · 저장 뒤 같음.
|
||||
|
||||
까닭: 옛 저장 정책엔 `min_vertical_radius_m` · `min_curve_length_m` 가 없어 화면 초안(저장
|
||||
정책 그대로) 경고 0 · 노선 [저장](새 정책)에서야 뜸. 고침: 빈 값은 첫 계획선 설계 기준(criteria =
|
||||
설계속도 법정값)
|
||||
— TS `readAlignment` ↔ 서버 `rebuild_alignment_profile` 같은 규칙(`helper_455_old_policy.cjs`).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from B05_Profile.B05_Profile_Engine_Grade import GradeDesignOptions # noqa: E402
|
||||
from B05_Profile.B05_Profile_Engine_Grade_Profile import ( # noqa: E402
|
||||
design_ground_following_profile,
|
||||
rebuild_alignment_profile,
|
||||
)
|
||||
|
||||
# 설계속도 40 — 법정 최소 R 450 · L 40 > 기본 L 20 → 곡선마다 미달
|
||||
OPTIONS = GradeDesignOptions(
|
||||
max_grade_pct=14.0,
|
||||
max_reverse_grade_pct=5.0,
|
||||
min_vertical_radius_m=450.0,
|
||||
min_curve_length_m=40.0,
|
||||
min_tangent_length_m=20.0,
|
||||
vertical_curve_skip_delta_pct=0.5,
|
||||
design_speed_kph=40,
|
||||
)
|
||||
|
||||
|
||||
def _ground(s: float) -> float:
|
||||
return 100.0 + 0.05 * s + 2.0 * math.sin(s / 30.0)
|
||||
|
||||
|
||||
def _fresh() -> dict:
|
||||
samples = [
|
||||
{"chainage_m": float(s), "elevation_m": round(_ground(s), 6), "valid": True}
|
||||
for s in range(0, 401)
|
||||
]
|
||||
stations = [{"chainage_m": float(s), "kind": "regular"} for s in range(0, 401, 20)]
|
||||
data = {"length_m": 400.0, "samples": samples, "stations": stations}
|
||||
alignment, profile = design_ground_following_profile(data, OPTIONS, station_interval_m=20.0)
|
||||
data["profile_alignment"], data["design_profiles"] = alignment, [profile]
|
||||
return data
|
||||
|
||||
|
||||
def _old() -> dict:
|
||||
data = _fresh()
|
||||
for key in ("min_vertical_radius_m", "min_curve_length_m"):
|
||||
data["profile_alignment"]["policy"].pop(key)
|
||||
return data
|
||||
|
||||
|
||||
def _law(warnings: list[str]) -> list[str]:
|
||||
return [text for text in warnings if "법정 최소" in text]
|
||||
|
||||
|
||||
def _ts(tmp_path: Path, data: dict) -> dict:
|
||||
source = tmp_path / "in455.json"
|
||||
source.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(HERE / "helper_455_old_policy.cjs"), str(source)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
cwd=str(ROOT),
|
||||
check=False,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_새_정책_경고_기준(tmp_path: Path) -> None:
|
||||
fresh = _fresh()
|
||||
law = _law(fresh["profile_alignment"]["warnings"])
|
||||
assert law
|
||||
assert _ts(tmp_path, fresh)["warnings"] == law
|
||||
|
||||
|
||||
def test_옛_정책_초안_경고_저장_뒤와_같음(tmp_path: Path) -> None:
|
||||
expected = _law(_fresh()["profile_alignment"]["warnings"])
|
||||
old = _old()
|
||||
ts = _ts(tmp_path, copy.deepcopy(old))
|
||||
assert ts["policy"]["min_vertical_radius_m"] == 450.0
|
||||
assert ts["policy"]["min_curve_length_m"] == 40.0
|
||||
assert ts["warnings"] == expected
|
||||
alignment, _profile = rebuild_alignment_profile(old, {})
|
||||
assert _law(alignment["warnings"]) == expected
|
||||
assert alignment["policy"]["min_curve_length_m"] == 40.0
|
||||
|
||||
|
||||
def test_criteria_없는_판은_그대로(tmp_path: Path) -> None:
|
||||
old = _old()
|
||||
old["design_profiles"][0].pop("criteria")
|
||||
assert _ts(tmp_path, copy.deepcopy(old))["warnings"] == []
|
||||
@@ -30,7 +30,13 @@ def _make_client(tmp_path, monkeypatch, invalidated_calls, *, stale_ok=True, rou
|
||||
|
||||
monkeypatch.setattr(router_module, "_project_root", fake_project_root)
|
||||
monkeypatch.setattr(router_module, "_invalidate_downstream", fake_invalidate)
|
||||
|
||||
async def fake_saved_irregular(project_id, root, route_id):
|
||||
# 이관 원천 = 저장 종단 파일(452) — 본문 대신 여기서 같은 측점을 줌
|
||||
return _migrate_payload()["stations"]
|
||||
|
||||
monkeypatch.setattr(router_module, "_route_length", fake_route_length)
|
||||
monkeypatch.setattr(router_module, "_saved_irregular_stations", fake_saved_irregular)
|
||||
app = FastAPI()
|
||||
app.include_router(router_module.router)
|
||||
return TestClient(app)
|
||||
@@ -298,7 +304,11 @@ def test_migrate_keeps_existing_structures(client, tmp_path):
|
||||
assert len(stored) == 3 # 기존 1 + 이관 2(기슭막이는 관 지점으로)
|
||||
|
||||
|
||||
def test_migrate_empty_list_is_noop(client):
|
||||
def test_migrate_empty_list_is_noop(client, monkeypatch):
|
||||
async def none_saved(project_id, root, route_id):
|
||||
return []
|
||||
|
||||
monkeypatch.setattr(router_module, "_saved_irregular_stations", none_saved)
|
||||
response = client.post(
|
||||
f"/api/projects/{PROJECT_ID}/route/structures/migrate", json={"stations": []}
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user