From 4262038cd4358ab7b011aeb198d9886b3627f52b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:45:32 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=84=A4=EA=B3=84=EB=AC=B6=EC=9D=8C):=203?= =?UTF-8?q?46-2=20cross=20=C2=B7=20masshaul=20=C2=B7=20haul=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=E2=80=94=20=EA=B3=84=EC=95=BD=20=EB=AA=A8=EC=96=91?= =?UTF-8?q?=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EC=A7=80=EB=AC=B8=20=EA=B5=AD?= =?UTF-8?q?=EB=B6=80=20=EC=97=B0=EC=82=B0=20=C2=B7=20=EB=B2=94=EC=9C=84=20?= =?UTF-8?q?=EB=B0=96=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=C2=B7=20=ED=9D=99=20?= =?UTF-8?q?=EB=B0=B0=EB=B6=84=20=EC=97=B4=EC=87=A0=20=EA=B8=B0=EC=96=B5=20?= =?UTF-8?q?=C2=B7=20AbortSignal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- B05_Profile/B05_Profile_Api_HaulPlan.ts | 140 ++++++---- .../logic/B05_Profile_MassHaul_Stage.ts | 53 ++++ B06_Section/B06_Section_Cross_Stage.ts | 136 ++++++++++ resources/tester/helper_344_3_entry.ts | 26 ++ .../tester/helper_344_3_station_keys.mjs | 240 ++++++++++++++++++ resources/tester/test_344_3_station_keys.py | 124 +++++++++ 6 files changed, 672 insertions(+), 47 deletions(-) create mode 100644 B05_Profile/logic/B05_Profile_MassHaul_Stage.ts create mode 100644 B06_Section/B06_Section_Cross_Stage.ts create mode 100644 resources/tester/helper_344_3_entry.ts create mode 100644 resources/tester/helper_344_3_station_keys.mjs create mode 100644 resources/tester/test_344_3_station_keys.py diff --git a/B05_Profile/B05_Profile_Api_HaulPlan.ts b/B05_Profile/B05_Profile_Api_HaulPlan.ts index 16595b035..b4813838a 100644 --- a/B05_Profile/B05_Profile_Api_HaulPlan.ts +++ b/B05_Profile/B05_Profile_Api_HaulPlan.ts @@ -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(); + +/** + * 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 + * `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 { + 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 => { + 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(); - - /** - * 입력이 달라졌는지 가리는 표식. 응답이 오면 곡선을 다시 그리고, 그리기가 다시 - * `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 { 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); }, diff --git a/B05_Profile/logic/B05_Profile_MassHaul_Stage.ts b/B05_Profile/logic/B05_Profile_MassHaul_Stage.ts new file mode 100644 index 000000000..883b083f0 --- /dev/null +++ b/B05_Profile/logic/B05_Profile_MassHaul_Stage.ts @@ -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 }; + }; +} diff --git a/B06_Section/B06_Section_Cross_Stage.ts b/B06_Section/B06_Section_Cross_Stage.ts new file mode 100644 index 000000000..cde2f0969 --- /dev/null +++ b/B06_Section/B06_Section_Cross_Stage.ts @@ -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 +>; + +/** `input.cross` — 표준단면 + 측점 열쇠 → 그 측점 입력. */ +export interface CrossStageInput { + standard: StandardCrossSectionSpec; + byKey: Record; +} + +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(); + 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 = {}; + 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 }; +} diff --git a/resources/tester/helper_344_3_entry.ts b/resources/tester/helper_344_3_entry.ts new file mode 100644 index 000000000..b45c2a570 --- /dev/null +++ b/resources/tester/helper_344_3_entry.ts @@ -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"; diff --git a/resources/tester/helper_344_3_station_keys.mjs b/resources/tester/helper_344_3_station_keys.mjs new file mode 100644 index 000000000..542aabc22 --- /dev/null +++ b/resources/tester/helper_344_3_station_keys.mjs @@ -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)); diff --git a/resources/tester/test_344_3_station_keys.py b/resources/tester/test_344_3_station_keys.py new file mode 100644 index 000000000..a00ddd633 --- /dev/null +++ b/resources/tester/test_344_3_station_keys.py @@ -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}}