feat(설계묶음): 346-2 cross · masshaul · haul 단계 — 계약 모양 · 측점 지문 국부 연산 · 범위 밖 재사용 · 흙 배분 열쇠 기억 · AbortSignal

- cross 단계(B06_Section_Cross_Stage) = profile · ground · input.cross → 측점 열쇠마다 횡단 설계(CrossOutput.byKey) · 같은 엔진 · 측점 지문이 같으면 앞 결과 · 바뀐 범위 밖은 ctx.prev 재사용
- masshaul 단계(logic/B05_Profile_MassHaul_Stage) = cross · profile → 유토곡선 계열 + 흙 배분 입력
- haul 단계(Api_HaulPlan createHaulStage) = 서버 Node 배분 · 선반입과 같은 열쇠 기억 · 새 조작이면 끊김 · 못 받으면 앞 배분
- 시험: 옛 초안 옮김 · 쓴 자리 읽기 · 노선 바뀜 넘김(노선 밖 2 건) · cross 6 → 같은 입력 0 → 측점 하나 1 · masshaul 계열 · haul 같은 열쇠 POST 1 번

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
2026-10-16 11:45:32 +09:00
co-authored by Claude Opus 5.5
parent 1c090c0f5e
commit 4262038cd4
6 changed files with 672 additions and 47 deletions
+93 -47
View File
@@ -12,6 +12,8 @@
import { API_BASE_URL } from "@config/config_frontend";
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
import type { StageContext } from "../A00_Common/design/design_pipeline_types";
import type { MassHaulStageOutput } from "./logic/B05_Profile_MassHaul_Stage";
/** 서버가 돌려주는 배분 한 벌 — **화면이 쓰는 꼴 그대로**라 그리기 코드가 손대지 않는다.
* `import type` 이라 배분 모듈이 번들에 실리지 않는다(빌드에서 지워진다). */
@@ -24,6 +26,84 @@ const TIMEOUT_MS = 20000;
/** 열쇠별로 받아 둔 배분 — 되돌리기 · 보기 오감처럼 같은 누가토량이 다시 오면 묻지 않음(342-1). */
const KEPT_PLANS = 8;
/** 받은 배분 — `프로젝트:노선|열쇠` → 배분 · 오래된 것부터 버림 · 선반입 · 묶음 단계가 함께 씀. */
const kept = new Map<string, HaulPlanPayload>();
/**
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
*/
function keyOf(result: unknown): string {
const points = (result as { points?: unknown[] })?.points;
if (!Array.isArray(points) || !points.length) return "empty";
return JSON.stringify(points);
}
const keptKey = (projectId: string, route: number, key: string): string =>
`${projectId}:${route}|${key}`;
function keep(at: string, plan: HaulPlanPayload): void {
kept.delete(at);
kept.set(at, plan);
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
}
/** 서버에 배분을 물음 — 받으면 기억 · 못 받으면(실패 · 끊김) undefined. `signal` 이 끊으면 함께 끊음. */
async function postHaulPlan(
projectId: string,
route: number,
result: unknown,
key: string,
signal?: AbortSignal,
): Promise<HaulPlanPayload | undefined> {
if (signal?.aborted) return undefined;
const controller = new AbortController();
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
const relay = () => controller.abort();
signal?.addEventListener("abort", relay);
try {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result }),
signal: controller.signal,
},
);
if (!response.ok) return undefined;
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
const plan = payload.haul_plan ?? null;
keep(keptKey(projectId, route, key), plan);
return plan;
} catch {
return undefined; // 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
} finally {
window.clearTimeout(abort);
signal?.removeEventListener("abort", relay);
}
}
/**
* 계산 묶음 `haul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — masshaul 의 횡단 기준 결과를 서버
* Node 로 배분 · 같은 열쇠면 안 물음 · 새 조작이 오면 `ctx.signal` 로 끊김(옛 번호 결과는 묶음이 버림).
* 못 받으면 앞 배분(`ctx.prev`) 그대로.
*/
export function createHaulStage(projectId: string, routeId: () => number | undefined) {
return async (ctx: StageContext<"haul">): Promise<HaulPlanPayload> => {
const result = (ctx.out.masshaul as MassHaulStageOutput | null)?.haulInput;
const route = routeId();
if (!result || !route) return (ctx.prev as HaulPlanPayload) ?? null;
const key = keyOf(result);
const at = keptKey(projectId, route, key);
if (kept.has(at)) return kept.get(at) ?? null;
const plan = await postHaulPlan(projectId, route, result, key, ctx.signal);
return plan === undefined ? ((ctx.prev as HaulPlanPayload) ?? null) : plan;
};
}
export interface HaulPlanPrefetch {
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
schedule: (result: unknown) => void;
@@ -50,19 +130,6 @@ export function createHaulPlanPrefetch(
let sentKey: string | null = null;
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
let applying = false;
/** 받은 배분 — 열쇠(누가토량 결과 전체) → 배분 · 오래된 것부터 버림. */
const kept = new Map<string, HaulPlanPayload>();
/**
* 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시
* `schedule` 을 부르므로 **표식이 없으면 요청이 끝없이 되돌아온다**(2026-09-06 실측).
* 누가토량 결과 전체 — 끝 · 가운데 누계만 보던 옛 표식은 사이 측점이 바뀌어도 같았다(342-1).
*/
function keyOf(result: unknown): string {
const points = (result as { points?: unknown[] })?.points;
if (!Array.isArray(points) || !points.length) return "empty";
return JSON.stringify(points);
}
/** 못 받은 열쇠는 잊는다 — 다음 그리기가 같은 값으로 다시 보냄(342-1). */
function forget(seq: number): void {
@@ -72,41 +139,19 @@ export function createHaulPlanPrefetch(
async function send(result: unknown, seq: number, key: string): Promise<void> {
const route = routeId();
if (!route) return forget(seq);
const controller = new AbortController();
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
const got = await postHaulPlan(projectId, route, result, key);
if (got === undefined) return forget(seq);
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
if (seq !== sequence) return;
plan = got;
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
applying = true;
try {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result }),
signal: controller.signal,
},
);
if (!response.ok) return forget(seq);
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
if (seq !== sequence) return;
plan = payload.haul_plan ?? null;
kept.delete(key);
kept.set(key, plan);
if (kept.size > KEPT_PLANS) kept.delete(kept.keys().next().value as string);
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
applying = true;
try {
onReady();
} finally {
applying = false;
}
} catch {
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
forget(seq);
onReady();
} finally {
window.clearTimeout(abort);
applying = false;
}
}
@@ -121,7 +166,8 @@ export function createHaulPlanPrefetch(
const seq = sequence;
window.clearTimeout(timer);
// 받아 둔 열쇠면 묻지 않고 그대로 — 그리는 쪽은 이 뒤에 `current()` 를 읽는다.
const known = kept.get(key);
const route = routeId();
const known = route ? kept.get(keptKey(projectId, route, key)) : undefined;
if (known !== undefined) return void (plan = known);
timer = window.setTimeout(() => void send(pending, seq, key), SETTLE_MS);
},
@@ -0,0 +1,53 @@
/* =============================================================================
* B05_Profile_MassHaul_Stage.ts
* 계산 묶음 `masshaul` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — cross(측점 열쇠마다 면적) ·
* profile → 유토곡선 계열(`computeMassHaulSeries` · 화면 유토곡선 · B06 과 같은 계산).
*
* 출력 = `MassHaulStageOutput`(계열 + 흙 배분에 보낼 횡단 기준 결과) · 순수 계산(DOM · fetch · 세션 없음).
* 토량환산계수 · 자연 사토 기울기는 부르는 쪽이 넘김(`context`) — 없으면 null.
* ========================================================================== */
import { computeMassHaulSeries, type MassHaulSeries } from "@util/common_util_mass_haul";
import type { EarthworkConversion, MassHaulSection } from "@util/common_util_mass_haul_types";
import type { StageContext } from "../../A00_Common/design/design_pipeline_types";
export interface MassHaulStageContext {
conversion: EarthworkConversion;
naturalSpoilMinSlope?: number;
}
export interface MassHaulStageOutput {
series: MassHaulSeries[];
/** 흙 배분(haul 단계)에 보낼 결과 — 횡단 기준(없으면 첫 계열) · 계열이 없으면 null. */
haulInput: MassHaulSeries["result"] | null;
}
/** `masshaul` 단계 만들기 — `context()` 는 부를 때마다 읽음(설정이 바뀌면 다음 계산부터). */
export function createMassHaulStage(context: () => MassHaulStageContext | null) {
return (ctx: StageContext<"masshaul">): MassHaulStageOutput | null => {
const cross = ctx.out.cross;
const settings = context();
if (!cross || !settings) return null;
const sections: MassHaulSection[] = Object.values(cross.byKey)
.sort((a, b) => a.chainageM - b.chainageM)
.map((entry) => ({
station_id: entry.key,
chainage_m: entry.chainageM,
design: entry.detail as MassHaulSection["design"],
}));
const samples = ctx.out.profile?.samples ?? [];
const longitudinal = {
length_m:
samples[samples.length - 1]?.chainage_m ?? sections[sections.length - 1]?.chainage_m ?? 0,
design_profiles: samples.length ? [{ samples }] : [],
};
const series = computeMassHaulSeries(
longitudinal,
sections,
settings.conversion,
settings.naturalSpoilMinSlope,
);
const picked = series.find((entry) => entry.basis === "cross") ?? series[0];
return { series, haulInput: picked?.result ?? null };
};
}
+136
View File
@@ -0,0 +1,136 @@
/* =============================================================================
* B06_Section_Cross_Stage.ts
* 계산 묶음 `cross` 단계(PLAN 346-2 · 계약 `design_pipeline_types.ts`) — profile · ground · input.cross →
* 측점 열쇠마다 횡단 설계(`CrossOutput.byKey`).
*
* · 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝 · Cross_Refresh 와 같은 엔진).
* · 국부 연산 = 측점 열쇠마다 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 확폭 · 표준값) — 같으면 앞 결과 그대로.
* · 바뀐 범위(`ctx.range`) 밖이고 앞 출력(`ctx.prev`)에 있으면 셈 없이 재사용.
* · 단계는 자기 출력만 돌려줌(입력 · 앞 단계 출력은 읽기만). DOM · fetch · 세션 없음.
* input.cross 모양 = `CrossStageInput`(부르는 쪽이 세션 선택 · 저장분 · 구조물 맥락을 측점 열쇠로 모아 넘김).
* ========================================================================== */
import { computeCrossDesign } from "@util/common_util_cross_design";
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
import type {
CrossOutput,
CrossStationResult,
PipelineStation,
StageContext,
} from "../A00_Common/design/design_pipeline_types";
import { planElevationAt } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
/** 측점 하나의 횡단 입력 — 비면 기본(지반 리핑암 · 좌절토 · 표준 측구 · 표준 경사). 확폭은 측점(stations)이 줌. */
export type CrossStationInput = Partial<
Omit<CrossDesignOptions, "standard" | "planRadiusM" | "curveOuterSide" | "curveWideningM">
>;
/** `input.cross` — 표준단면 + 측점 열쇠 → 그 측점 입력. */
export interface CrossStageInput {
standard: StandardCrossSectionSpec;
byKey: Record<string, CrossStationInput>;
}
interface Sample {
offset_m: number;
elevation_m: number | null;
valid: boolean;
}
/** 지반 표본(중심 → 바깥 · 좌 +) → 엔진 표본(오프셋 오름차순 · 우 −). */
export function groundSamples(station: PipelineStation): Sample[] {
const ground = station.ground;
if (!ground) return [];
const point = (offset: number, z: number | null): Sample => ({
offset_m: offset,
elevation_m: z,
valid: z !== null && Number.isFinite(z),
});
const right = ground.right.map((s) => point(-s.offsetM, s.z));
const left = ground.left.map((s) => point(s.offsetM, s.z));
const out = [...right, ...left];
if (ground.centerZ !== null && !out.some((s) => Math.abs(s.offset_m) < 1e-9))
out.push(point(0, ground.centerZ));
return out.sort((a, b) => a.offset_m - b.offset_m);
}
/** 측점 하나의 엔진 입력. */
function optionsFor(
station: PipelineStation,
input: CrossStationInput | undefined,
standard: StandardCrossSectionSpec,
): CrossDesignOptions {
return {
groundType: "ripping_rock",
sectionMode: "left_cut",
ditchType: "standard",
...input,
standard,
planRadiusM: station.planRadiusM,
curveOuterSide: station.curveOuterSide,
curveWideningM: station.curveWideningM,
};
}
const inRange = (station: PipelineStation, ctx: StageContext<"cross">): boolean => {
const range = ctx.range;
if (!range) return true;
if (range.keys.includes(station.key)) return true;
return station.chainageM >= range.fromM - 1e-6 && station.chainageM <= range.toM + 1e-6;
};
/**
* `cross` 단계 만들기 — 측점 지문 기억은 이 단계 함수가 사는 동안. `counts()` = 마지막 계산의 다시 셈 · 재사용 수.
*/
export function createCrossStage() {
const memo = new Map<string, { fingerprint: string; result: CrossStationResult }>();
let last = { computed: 0, reused: 0 };
function stage(ctx: StageContext<"cross">): CrossOutput {
const profile = ctx.out.profile;
const ground = ctx.out.ground;
const input = ctx.inputs.cross as CrossStageInput | null;
if (!profile || !ground || !input?.standard) return ctx.prev ?? { byKey: {} };
const standardKey = JSON.stringify(input.standard);
const byKey: Record<string, CrossStationResult> = {};
last = { computed: 0, reused: 0 };
for (const station of ground.stations) {
const kept = ctx.prev?.byKey[station.key];
if (kept && !inRange(station, ctx)) {
byKey[station.key] = { ...kept, chainageM: station.chainageM };
last.reused += 1;
continue;
}
const samples = groundSamples(station);
const elevation = planElevationAt(profile, station.chainageM);
const options = optionsFor(station, input.byKey[station.key], input.standard);
const fingerprint = `${standardKey}|${elevation}|${JSON.stringify(samples)}|${JSON.stringify({ ...options, standard: null })}`;
const hit = memo.get(station.key);
if (hit?.fingerprint === fingerprint) {
byKey[station.key] = { ...hit.result, chainageM: station.chainageM };
last.reused += 1;
continue;
}
let design;
try {
design = computeCrossDesign(samples, elevation, options);
} catch {
continue; // 표본 부족 측점은 건너뜀(Cross_Refresh · 서버 엔진과 같은 태도)
}
const result: CrossStationResult = {
key: station.key,
chainageM: station.chainageM,
cutAreaM2: design.cut_area_m2,
fillAreaM2: design.fill_area_m2,
detail: design,
};
memo.set(station.key, { fingerprint, result });
byKey[station.key] = result;
last.computed += 1;
}
for (const key of [...memo.keys()]) if (!byKey[key]) memo.delete(key); // 사라진 측점
return { byKey };
}
return { stage, counts: () => last };
}
+26
View File
@@ -0,0 +1,26 @@
/* 344-3 · 346-2 2파 시험 묶음 진입점 — 진짜 모듈을 vite ssr 로 한 벌 묶어 Node 헬퍼가 부름.
* 측점 열쇠(옛 초안 옮김 · 노선 밖 고침) · cross · masshaul · haul 단계를 화면 · 서버 없이 잰다.
*/
export {
carryStationDrafts,
chainageOfKey,
keyAt,
readKeyed,
useStationKeys,
useStructureIds,
} from "../../B06_Section/B06_Section_Station_Keys";
export {
crossDesignChoices,
readShoulderExtends,
writeCrossDesignChoice,
writeShoulderExtend,
} from "../../B06_Section/B06_Section_Cross_Design_Session";
export {
createRockBoundaryStore,
readRockBoundarySession,
} from "../../B06_Section/B06_Section_UI_Page_Persist";
export { createCrossStage, groundSamples } from "../../B06_Section/B06_Section_Cross_Stage";
export { createMassHaulStage } from "../../B05_Profile/logic/B05_Profile_MassHaul_Stage";
export { createHaulStage } from "../../B05_Profile/B05_Profile_Api_HaulPlan";
export { buildAlignment, emptyEdits } from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
export { readState, stateKey, writeState } from "../../A00_Common/b_page_state";
@@ -0,0 +1,240 @@
/* 344-3 · 346-2 2파 헬퍼 — vite ssr 로 묶은 진짜 모듈(helper_344_3_entry.ts)을 부르고 결과를 JSON 한 벌로.
* node helper_344_3_station_keys.mjs <묶음.mjs> <입력.json>
* 입력 = { standard: 파이썬 config 표준단면, policy: 종단 정책 }.
*/
import { readFileSync } from "node:fs";
import { pathToFileURL } from "node:url";
const store = () => {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => m.set(k, String(v)),
removeItem: (k) => m.delete(k),
key: (i) => [...m.keys()][i] ?? null,
get length() {
return m.size;
},
clear: () => m.clear(),
};
};
globalThis.window = globalThis;
globalThis.sessionStorage = store();
globalThis.localStorage = store();
const any = new Proxy(function () {}, {
get: (_t, k) => (k === Symbol.toPrimitive ? () => 0 : any),
apply: () => any,
construct: () => any,
});
globalThis.document = any;
globalThis.requestAnimationFrame = (run) => setTimeout(run, 0);
const [bundlePath, inputPath] = process.argv.slice(2);
const m = await import(pathToFileURL(bundlePath).href);
const input = JSON.parse(readFileSync(inputPath, "utf8"));
const P = "p344";
const R = 7;
const R2 = 8;
const raw = (name, route) => m.readState(name, P, route) ?? {};
const keysOf = (name, route) =>
Object.keys(raw(name, route))
.filter((k) => k !== "@at")
.sort();
const section = (chainage_m, kind, extra = {}) => ({
station_id: `s${chainage_m}`,
chainage_m,
kind,
label: String(chainage_m),
samples: [],
...extra,
});
const detailOf = (sections) => ({
longitudinal: {
stations: sections,
profile_alignment: { policy: { station_interval_m: 20 } },
},
cross_sections: sections,
});
const choice = (ground) => ({
ground_type: ground,
section_mode: "right_cut",
ditch_side: "right",
ditch_type: "l_type",
paved: true,
two_stage_slope: true,
ditch_choice: false,
});
const out = {};
/* 1. 옛 초안(누가거리 열쇠) → 측점 열쇠로 옮김 · 지금 측점에 없는 것은 남겨 둠 */
const oldRoute = detailOf([
section(0, "bp"),
section(20, "regular"),
section(40, "regular"),
section(45.054, "irregular", { structure: "파형강관 D800", culvert: {} }),
section(52.5, "irregular", { structure: "대피로" }),
section(60, "regular"),
section(70, "ep"),
]);
m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 52.5 }]);
m.useStationKeys(P, R, oldRoute);
m.writeState(
"crossdesign",
{
"20.00": choice("soil"),
45.05: choice("blasting_rock"),
"52.50": choice("soil"),
"60.00": choice("soil"),
"99.00": choice("soil"),
},
P,
R,
);
out.legacyRead = [...m.crossDesignChoices(P, R).keys()].sort((a, b) => a - b);
out.legacyKeys = keysOf("crossdesign", R);
/* 2. 쓰기 = 측점 열쇠 · 누가거리로 다시 읽음 · 암 경계 저장소도 같은 열쇠 */
m.writeCrossDesignChoice(P, R, 40, choice("ripping_rock"));
m.writeShoulderExtend(P, R, 40, "left", 0.5);
out.writeKey = keysOf("crossdesign", R).includes("grid:2");
out.shoulderKeys = keysOf("shoulderext", R);
const rock = m.createRockBoundaryStore({
sessionKey: () => m.stateKey("rockb", P, R),
detail: () => oldRoute,
refreshCard: () => undefined,
recompute: () => undefined,
});
rock.control.adjust(20, -0.3);
out.rockKeys = keysOf("rockb", R);
rock.load();
out.rockLoaded = Object.fromEntries(rock.offsets);
out.rockSession = m.readRockBoundarySession(P, R);
/* 3. 구조물 id 를 아직 모를 때(첫 열기) — 쓴 때 자리(@at)로 그 측점 고침을 읽음 */
m.useStructureIds(P, []);
m.useStationKeys(P, R, oldRoute);
out.hintKeyNow = m.keyAt(P, R, 52.5);
out.hintRead = m.crossDesignChoices(P, R).has(52.5);
/* 4. 노선이 바뀜 — 측점 열쇠로 넘김 · 넘김 = 카드 선택 넷 · 암 경계 · 노견 확장 안 넘김 · 사라진 측점 = 노선 밖 */
m.writeState("pipe-ids", { next: 2, ids: [{ id: "pipe-s1", at: 47 }] }, P); // 관이 47 m 로 옮김
m.useStructureIds(P, [{ structure_id: "st-1", chainage_m: 55 }]);
m.useStationKeys(
P,
R2,
detailOf([
section(0, "bp"),
section(20, "regular"),
section(40, "regular"),
section(47, "irregular", { structure: "파형강관 D800", culvert: {} }),
section(55, "irregular", { structure: "대피로" }),
section(58, "ep"),
]),
);
// 옛 노선 목록(st-1 은 52.5 · 관 장부는 옛 자리 기준으로 이미 열쇠가 섬)
out.outside = m.carryStationDrafts(P, R, R2);
out.carriedKeys = keysOf("crossdesign", R2);
out.carriedChoice = raw("crossdesign", R2)["grid:1"];
out.carriedRead = [...m.crossDesignChoices(P, R2).keys()].sort((a, b) => a - b);
out.carriedRock = m.readRockBoundarySession(P, R2);
out.carriedShoulder = keysOf("shoulderext", R2);
/* 5. cross 단계 — 가짜 입력 · 같은 입력 0 · 측점 하나 고침 = 1 · 범위 밖 재사용 */
const grid = [0, 20, 40, 60, 80, 100];
const groundAt = (c) => 100 + 0.05 * c - 3 * Math.sin(c / 17);
const base = {
policy: input.policy,
onlyExplicitCurves: false,
basePvi: grid.map((c, i) => ({ chainage_m: c, elevation_m: groundAt(c) + (i % 2 ? 0.8 : -0.6) })),
chainage: Array.from({ length: 101 }, (_, i) => i),
ground: Array.from({ length: 101 }, (_, i) => groundAt(i)),
stations: grid.map((c) => ({ station_id: `s${c}`, chainage_m: c })),
};
const profile = m.buildAlignment(base, m.emptyEdits());
const side = (c, sign) =>
Array.from({ length: 24 }, (_, i) => ({
offsetM: (i + 1) * 0.5,
z: groundAt(c) + sign * 0.3 * (i + 1) * 0.5,
}));
const ground = {
stations: grid.map((c, i) => ({
key: i === 0 ? "end:BP" : i === grid.length - 1 ? "end:EP" : `grid:${i}`,
chainageM: c,
x: c,
y: 0,
azimuthDeg: 90,
left: [0, 1],
kind: i === 0 || i === grid.length - 1 ? "end" : "grid",
id: null,
ground: { centerZ: groundAt(c), left: side(c, 1), right: side(c, -1) },
planRadiusM: null,
curveOuterSide: null,
curveWideningM: null,
})),
longitudinal: { chainageM: [], z: [] },
};
const samples = m.groundSamples(ground.stations[1]);
out.samplesSorted = samples.every((s, i) => i === 0 || s.offset_m > samples[i - 1].offset_m);
out.samplesCount = samples.length;
const { stage, counts } = m.createCrossStage();
const ctxOf = (cross, range, prev, prof = profile) => ({
generation: 1,
range,
signal: new AbortController().signal,
inputs: { route: null, alignment: null, cross, pipes: null, structures: null },
out: { profile: prof, ground },
before: {},
prev,
});
const crossInput = { standard: input.standard, byKey: { "grid:2": { groundType: "soil" } } };
const first = stage(ctxOf(crossInput, null, null));
out.crossFirst = { ...counts(), keys: Object.keys(first.byKey).length };
out.crossHasArea = Object.values(first.byKey).every((r) => Number.isFinite(r.cutAreaM2));
stage(ctxOf(crossInput, null, first));
out.crossSame = counts();
const touched = { ...crossInput, byKey: { ...crossInput.byKey, "grid:3": { groundType: "soil" } } };
const third = stage(
ctxOf(touched, { sources: ["cross"], fromM: 60, toM: 60, keys: ["grid:3"] }, first),
);
out.crossOne = counts();
out.crossOneChanged = third.byKey["grid:3"].detail.ground_type;
const edited = m.buildAlignment(base, { station_offsets: { "40.000": 0.4 }, curve_radii: {} });
stage(ctxOf(touched, { sources: ["grade"], fromM: 20, toM: 60, keys: [] }, third, edited));
out.crossGrade = counts();
/* 6. masshaul 단계 — 계열 · 흙 배분 입력 */
const conversion = {
soil: { loose: 1.25, compacted: 0.9 },
ripping_rock: { loose: 1.5, compacted: 1.2 },
blasting_rock: { loose: 1.65, compacted: 1.35 },
};
const massStage = m.createMassHaulStage(() => ({ conversion }));
const mass = massStage({ ...ctxOf(crossInput, null, null), out: { profile, cross: first } });
out.massSeries = mass.series.map((s) => s.basis);
out.massHaulInput = Array.isArray(mass.haulInput?.points);
/* 7. haul 단계 — 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로 */
const posts = [];
globalThis.fetch = async (_url, init) => {
if (init.signal?.aborted) throw new Error("aborted");
posts.push(1);
return { ok: true, json: async () => ({ haul_plan: { n: posts.length } }) };
};
const haul = m.createHaulStage(P, () => R);
const haulCtx = (masshaul, prev, signal = new AbortController().signal) => ({
...ctxOf(crossInput, null, prev),
signal,
out: { masshaul },
});
const plan1 = await haul(haulCtx(mass, null));
const plan2 = await haul(haulCtx(mass, plan1));
const other = massStage({
...ctxOf(crossInput, null, null),
out: { profile: edited, cross: third },
});
const aborted = new AbortController();
aborted.abort();
const plan3 = await haul(haulCtx(other, plan1, aborted.signal));
out.haul = { posts: posts.length, plan1, plan2, plan3 };
process.stdout.write(JSON.stringify(out));
+124
View File
@@ -0,0 +1,124 @@
# -*- coding: utf-8 -*-
"""344-3 · 346-2 2파 — 세션 횡단 초안 측점 열쇠 · cross · masshaul · haul 단계.
못박는 것
- 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 것은 지우지 않고 남김.
- 쓰기 = 측점 열쇠(grid · pipe · structure) · 부르는 쪽은 누가거리로 읽음 · 암 경계 저장소도 같음.
- 구조물 id 를 아직 모를 때(첫 열기)도 쓴 때 자리로 그 측점 고침을 읽음.
- 노선이 바뀜 = 측점 열쇠로 넘김(관 · 구조물은 옮긴 자리로) · 넘김 = 카드 선택 넷 · 암 경계 ·
단면 유형 · 측구 방향 · 포장 · 노견 확장은 안 넘김 · 사라진 측점 = 「노선 밖 고침 N 건」.
- cross 단계: 같은 입력 다시 셈 0 · 측점 하나 고침 = 1 · 범위 밖 재사용.
- masshaul 단계 = 계열 + 흙 배분 입력 · haul 단계 = 같은 열쇠 안 물음 · 끊김 = 앞 배분 그대로.
Node 헬퍼 `helper_344_3_station_keys.mjs` 가 vite ssr 로 묶은 진짜 모듈(`helper_344_3_entry.ts`)을
부름.
"""
import json
import shutil
import subprocess
import sys
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from config.config_system import STANDARD_CROSS_SECTION # noqa: E402
FIXTURE = HERE / "fixtures" / "b05_192_1_moved_pipe_longitudinal.json"
@pytest.fixture(scope="module")
def out(tmp_path_factory):
work = tmp_path_factory.mktemp("b344")
build = subprocess.run(
[
"node",
str(ROOT / "config" / "node_modules" / "vite" / "bin" / "vite.js"),
"build",
"--configLoader",
"runner",
"--ssr",
"../resources/tester/helper_344_3_entry.ts",
"--outDir",
str(work),
"--logLevel",
"error",
],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=300,
)
assert build.returncode == 0, build.stderr
bundle = work / "bundle.mjs"
shutil.copy(work / "helper_344_3_entry.js", bundle)
policy = json.loads(FIXTURE.read_text(encoding="utf-8"))["profile_alignment"]["policy"]
source = work / "input.json"
source.write_text(
json.dumps({"standard": STANDARD_CROSS_SECTION, "policy": policy}, default=str),
encoding="utf-8",
)
proc = subprocess.run(
["node", str(HERE / "helper_344_3_station_keys.mjs"), str(bundle), str(source)],
cwd=ROOT,
capture_output=True,
text=True,
encoding="utf-8",
timeout=120,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_옛_초안은_읽을_때_측점_열쇠로_옮김(out):
assert out["legacyRead"] == [20, 45.05, 52.5, 60]
assert out["legacyKeys"] == ["99.00", "grid:1", "grid:3", "pipe:pipe-s1", "structure:st-1"]
def test_쓰기는_측점_열쇠_읽기는_누가거리(out):
assert out["writeKey"] is True
assert out["shoulderKeys"] == ["grid:2"]
assert out["rockKeys"] == ["grid:1"]
assert out["rockLoaded"] == {"20.00": -0.8}
assert out["rockSession"] == {"20.00": -0.8}
def test_구조물_id_모를_때도_쓴_자리로_읽음(out):
assert out["hintKeyNow"] == "at:52.50"
assert out["hintRead"] is True
def test_노선_바뀜_측점_열쇠로_넘김(out):
assert out["outside"] == 2 # 60 m(노선이 짧아짐) · 99 m(옛 노선에도 없던 옛 초안)
assert out["carriedKeys"] == ["grid:1", "grid:2", "pipe:pipe-s1", "structure:st-1"]
assert out["carriedRead"] == [20, 40, 47, 55] # 관 · 구조물은 옮긴 자리로
assert out["carriedChoice"] == {
"ground_type": "soil",
"ditch_type": "l_type",
"ditch_choice": False,
"two_stage_slope": True,
}
assert out["carriedRock"] == {"20.00": -0.8}
assert out["carriedShoulder"] == []
def test_cross_단계_국부_연산(out):
assert out["samplesSorted"] is True and out["samplesCount"] == 49
assert out["crossFirst"] == {"computed": 6, "reused": 0, "keys": 6}
assert out["crossHasArea"] is True
assert out["crossSame"] == {"computed": 0, "reused": 6}
assert out["crossOne"] == {"computed": 1, "reused": 5}
assert out["crossOneChanged"] == "soil"
assert out["crossGrade"]["computed"] <= 3 and out["crossGrade"]["reused"] >= 3
def test_masshaul_haul_단계(out):
assert out["massSeries"] == ["cross", "longitudinal"]
assert out["massHaulInput"] is True
assert out["haul"] == {"posts": 1, "plan1": {"n": 1}, "plan2": {"n": 1}, "plan3": {"n": 1}}