Merge remote-tracking branch 'origin/main_laptop_1' into sub_laptop_7

This commit is contained in:
2026-10-18 22:04:53 +09:00
101 changed files with 3364 additions and 424 deletions
+2
View File
@@ -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 -18
View File
@@ -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);
},
+12 -1
View File
@@ -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 칸(저장 노선에
+1 -1
View File
@@ -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,
+37 -4
View File
@@ -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 {
+3 -8
View File
@@ -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}건을 구조물 목록으로 옮겼습니다.`);
+2 -5
View File
@@ -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"),
+10 -2
View File
@@ -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
+24
View File
@@ -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;
+1 -21
View File
@@ -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);
}
+5 -4
View File
@@ -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);
}
+4 -3
View File
@@ -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),
)
+47 -15
View File
@@ -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),
+125
View File
@@ -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
+19 -12
View File
@@ -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:
+1
View File
@@ -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,
+42 -13
View File
@@ -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) ·
+85 -3
View File
@@ -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,
+2
View File
@@ -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)
+46
View File
@@ -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):
"""계획선 편집 저장 결과 (재계산된 정본 선형을 그대로 돌려준다)."""
+40 -4
View File
@@ -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세트가 최종본).
+2 -11
View File
@@ -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">;
/** 밖에서 유역을 고른다(그래프 측점선·사이드 패널 선택과 맞추기 위함). 이미 같으면 무시. */
+6 -3
View File
@@ -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) : {}),
};
}
+54 -22
View File
@@ -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;
+4 -2
View File
@@ -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]);
}
+38 -15
View File
@@ -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;
}
+2
View File
@@ -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)
+6 -3
View File
@@ -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;
+37 -8
View File
@@ -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))
+9 -3
View File
@@ -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 };
}
+31
View File
@@ -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 -3
View File
@@ -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));
+215
View File
@@ -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);
});
+152
View File
@@ -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);
});
+2
View File
@@ -0,0 +1,2 @@
/* 452 시험 묶음 진입점 — B06 측점 열쇠(시종점 규칙)를 vite ssr 로 묶어 Node 헬퍼가 부름. */
export { stationKeysOf, useStructureIds } from "../../B06_Section/B06_Section_Station_Keys";
+36
View File
@@ -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();
+3 -1
View File
@@ -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 -8
View File
@@ -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):
+3 -1
View File
@@ -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
+3 -3
View File
@@ -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
+3 -2
View File
@@ -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")]
+64
View File
@@ -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 # 옛 정책 = 판정 안 함 그대로
+105
View File
@@ -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"] == []
+11 -1
View File
@@ -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