From 1c090c0f5e85ad4d2a8ed33c8ca3232d4fb679d6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:45:31 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(=ED=9A=A1=EB=8B=A8=EC=B4=88=EC=95=88):?= =?UTF-8?q?=20344-3=20=EC=B8=A1=EC=A0=90=20=EC=97=B4=EC=87=A0=20=E2=80=94?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=ED=9A=A1=EB=8B=A8=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=EC=9D=84=20=EC=B8=A1=EC=A0=90=20=EC=97=B4=EC=87=A0=EB=A1=9C=20?= =?UTF-8?q?=C2=B7=20=EC=98=9B=20=EC=B4=88=EC=95=88=20=EC=98=AE=EA=B9=80=20?= =?UTF-8?q?=C2=B7=20=EB=85=B8=EC=84=A0=20=EB=B0=94=EB=80=9C=20=EB=84=98?= =?UTF-8?q?=EA=B9=80=20=C2=B7=20=EB=85=B8=EC=84=A0=20=EB=B0=96=20=EA=B3=A0?= =?UTF-8?q?=EC=B9=A8=20=EC=95=8C=EB=A6=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 열쇠 규칙 한 벌 = common_util_station_key(ts · py 짝 · 거울 시험) — 일반 grid:k · 관 pipe: · 구조물 structure: · 시종점 end:BP/EP · id 모름 at:<0.01 m> - 세션 칸 다섯(카드 선택 · 노견 확장 · 소단 깊이 · 암 경계 · 암 경사) = 측점 열쇠로 담음 · 부르는 쪽은 그대로 누가거리로 읽고 씀 · 칸마다 쓴 때 자리를 함께 담아 구조물 id 를 아직 모를 때도 그 측점 고침을 읽음 - 옛 초안(누가거리 열쇠)은 읽을 때 측점 열쇠로 옮김 · 지금 측점에 없는 고침은 지우지 않고 남김 - 측점 목록 = 상세가 초안 측점을 얹을 때 · 다시 셈 때 세움(관 id = 관 장부 · 구조물 id = 측점 계획 · 구조물 목록) - 노선 바뀜 넘김 carryStationDrafts — 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사만 · 단면 유형 · 측구 방향은 새 지형이 정함 · 사라진 측점 = 「노선 밖 고침 N 건」 한 줄(부르는 자리 = 3파) - 옛 시험 셋 새 창구로 맞춤 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy --- .../B06_Section_Cross_Design_Session.ts | 38 +-- B06_Section/B06_Section_Cross_Refresh.ts | 16 +- B06_Section/B06_Section_Section_Drafts.ts | 3 + B06_Section/B06_Section_Station_Keys.ts | 273 ++++++++++++++++++ .../B06_Section_UI_Page_Design_Sync.ts | 2 + B06_Section/B06_Section_UI_Page_Persist.ts | 46 ++- common_util/common_util_station_key.py | 52 ++++ common_util/common_util_station_key.ts | 43 +++ .../tester/helper_273_2_preview_stations.cjs | 2 + .../tester/test_344_3_station_key_mirror.py | 88 ++++++ .../tester/test_session_keys_registered.py | 3 +- resources/tester/test_user_values_survive.py | 3 +- 12 files changed, 527 insertions(+), 42 deletions(-) create mode 100644 B06_Section/B06_Section_Station_Keys.ts create mode 100644 common_util/common_util_station_key.py create mode 100644 common_util/common_util_station_key.ts create mode 100644 resources/tester/test_344_3_station_key_mirror.py diff --git a/B06_Section/B06_Section_Cross_Design_Session.ts b/B06_Section/B06_Section_Cross_Design_Session.ts index d351a1f6b..451456bf7 100644 --- a/B06_Section/B06_Section_Cross_Design_Session.ts +++ b/B06_Section/B06_Section_Cross_Design_Session.ts @@ -8,9 +8,11 @@ * 계산은 브라우저가 하고(`common_util_cross_design.ts`), 선택값은 여기 담긴다. * * 담는 것은 **사용자가 고른 것만**이다 — 계산 결과(면적·설계선)는 담지 않는다. + * 세션 열쇠 = 측점 열쇠(344-3 · `B06_Section_Station_Keys`) · 부르는 쪽에는 누가거리로 내줌. * ========================================================================== */ -import { readState, writeState } from "../A00_Common/b_page_state"; +import { chainageKey } from "@util/common_util_station_key"; +import { readKeyed, writeKeyed } from "./B06_Section_Station_Keys"; /** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */ export interface CrossDesignChoice { @@ -25,14 +27,9 @@ export interface CrossDesignChoice { ditch_choice?: boolean | null; } -type ChoiceMap = Record; - -/** 측점 키 — 암 경계선 저장소와 같은 규칙(0.01m 단위). */ -const keyOf = (chainageM: number): string => chainageM.toFixed(2); - -function readAll(projectId: string, routeId: number): ChoiceMap { - return readState("crossdesign", projectId, routeId) ?? {}; -} +/** 누가거리(0.01 m 글) → 선택값 — 지금 측점에 있는 것만. */ +const readAll = (projectId: string, routeId: number) => + readKeyed("crossdesign", projectId, routeId); /** 이 측점의 선택값(없으면 null). */ export function readCrossDesignChoice( @@ -40,7 +37,7 @@ export function readCrossDesignChoice( routeId: number, chainageM: number, ): CrossDesignChoice | null { - return readAll(projectId, routeId)[keyOf(chainageM)] ?? null; + return readAll(projectId, routeId).get(chainageKey(chainageM)) ?? null; } /** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */ @@ -50,9 +47,8 @@ export function writeCrossDesignChoice( chainageM: number, choice: CrossDesignChoice, ): void { - const all = readAll(projectId, routeId); - all[keyOf(chainageM)] = choice; - writeState("crossdesign", all, projectId, routeId); + const at = chainageKey(chainageM); + writeKeyed("crossdesign", projectId, routeId, new Map([[at, choice]]), [at]); } /** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */ @@ -62,7 +58,7 @@ export function crossDesignChoices( ): Map { const choices = new Map(); if (!projectId || routeId === null) return choices; - for (const [key, value] of Object.entries(readAll(projectId, routeId))) { + for (const [key, value] of readAll(projectId, routeId)) { const chainage = Number(key); if (Number.isFinite(chainage) && value) choices.set(chainage, value); } @@ -80,12 +76,11 @@ type SideState = "shoulderext" | "bermdepth"; function readSides(name: SideState, projectId: string | null, routeId: number | null) { const out: ShoulderExtends = new Map(); if (!projectId || routeId === null) return out; - const raw = readState>>>( + for (const [key, value] of readKeyed>>( name, projectId, routeId, - ); - for (const [key, value] of Object.entries(raw ?? {})) { + )) { const chainage = Number(key); if (Number.isFinite(chainage) && value) out.set(chainage, value); } @@ -100,11 +95,10 @@ function writeSide( side: ShoulderSide, valueM: number, ): void { - const all = - readState>>>(name, projectId, routeId) ?? - {}; - all[keyOf(chainageM)] = { ...(all[keyOf(chainageM)] ?? {}), [side]: Math.max(valueM, 0) }; - writeState(name, all, projectId, routeId); + const at = chainageKey(chainageM); + const own = readKeyed>>(name, projectId, routeId).get(at); + const next = { ...(own ?? {}), [side]: Math.max(valueM, 0) }; + writeKeyed(name, projectId, routeId, new Map([[at, next]]), [at]); } /** 누가거리(숫자) → 쪽마다 노견 확장(세션에 있는 쪽만). */ diff --git a/B06_Section/B06_Section_Cross_Refresh.ts b/B06_Section/B06_Section_Cross_Refresh.ts index c4b1da7a5..07233dac9 100644 --- a/B06_Section/B06_Section_Cross_Refresh.ts +++ b/B06_Section/B06_Section_Cross_Refresh.ts @@ -28,7 +28,12 @@ import { computeCrossDesign } from "@util/common_util_cross_design"; import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design"; import { previewCrossDesigns } from "./B06_Section_Api_Fetch"; import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; -import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist"; +import { + bermSpecAt, + readBermSpans, + readCutSlopeSession, + readRockBoundarySession, +} from "./B06_Section_UI_Page_Persist"; import type { BermSessionSpec } from "./B06_Section_UI_Page_Persist"; import type { BermSpec } from "@util/common_util_cross_berm"; import { @@ -48,7 +53,7 @@ import { } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment"; import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data"; -import { readState } from "../A00_Common/b_page_state"; +import { useStationKeys } from "./B06_Section_Station_Keys"; import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit"; import { hasStaleDesigns } from "./B06_Section_UI_Section_Common"; import { applyStructureAreaRows, structureAreaRows } from "./B06_Section_Structure_Layouts"; @@ -211,6 +216,7 @@ export async function refreshStaleDesigns( * 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22). */ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise { + useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3) const sections = input.detail.cross_sections; const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)])); const updated = refreshLocally(input) ?? (await refreshFromServer(input)); @@ -301,8 +307,7 @@ function bermDepthReader(projectId: string, routeId: number) { */ function readCutSlopeRatios(projectId: string, routeId: number): Map { const out = new Map(); - const raw = readState>("cutslope", projectId, routeId); - if (!raw) return out; + const raw = readCutSlopeSession(projectId, routeId); for (const [chainage, ratio] of Object.entries(raw)) { const at = Number(chainage); if (Number.isFinite(at) && Number.isFinite(ratio) && ratio >= 0) out.set(rockKey(at), ratio); @@ -516,8 +521,7 @@ async function refreshFromServer(input: CrossRefreshInput): Promise { berms: bermPayload(projectId, routeId, detail), // 측점별 암 절토 경사도 세션값이라 함께 싣는다 — 안 실으면 서버 폴백에서만 // 사용자 경사가 조용히 표준값으로 되돌아간다(2026-09-07). - cutSlopeRatios: - readState>("cutslope", projectId, routeId) ?? undefined, + cutSlopeRatios: readCutSlopeSession(projectId, routeId), }, ); if (shouldApply && !shouldApply()) return []; diff --git a/B06_Section/B06_Section_Section_Drafts.ts b/B06_Section/B06_Section_Section_Drafts.ts index 633c9d7a9..64d6f1927 100644 --- a/B06_Section/B06_Section_Section_Drafts.ts +++ b/B06_Section/B06_Section_Section_Drafts.ts @@ -30,6 +30,7 @@ import { structureStationMarks, type PlannedStation, } from "../common_util/common_util_structure_station"; +import { useStationKeys, useStationOwners } from "./B06_Section_Station_Keys"; import { previewStructureSections, type CrossSection, @@ -188,6 +189,7 @@ export async function withDraftStations( if (inputs) { const [pipes, structures, types] = inputs; const planned = planStations(pipes, structures, types, registryDefaults(types)); + useStationOwners(projectId, planned); // 구조물 측점 열쇠 = 덮는 구조물 id(344-3) const planted = planned.map((station) => station.chainage_m); // 구조물 측점에서 종류를 바꿈 = 「구조물 없는 상태」부터 새 종류로(요구 213) — 저장분 횡단은 빼 두고 // 새 자리처럼 미리 뜬 횡단을 씀 · 저장분 종류로 되돌리면 빼 둔 횡단이 돌아옴 @@ -246,4 +248,5 @@ export async function withDraftStations( ...[...live, ...fresh].sort(byChainage), ); parked.set(detail, stale); + useStationKeys(projectId, routeId, detail); // 세션 횡단 초안 = 이 측점 목록의 열쇠(344-3) } diff --git a/B06_Section/B06_Section_Station_Keys.ts b/B06_Section/B06_Section_Station_Keys.ts new file mode 100644 index 000000000..3962048f3 --- /dev/null +++ b/B06_Section/B06_Section_Station_Keys.ts @@ -0,0 +1,273 @@ +/* ============================================================================= + * B06_Section_Station_Keys.ts + * 세션 횡단 초안의 **측점 열쇠**(PLAN 344-3) — 노선이 바뀌어 누가거리가 밀려도 같은 측점의 고침이 남게. + * + * 열쇠 규칙 = `common_util_station_key`(일반 grid:k · 관 pipe: · 구조물 structure: · 시종점 end:BP/EP · + * id 모름 at:<0.01 m>). 담는 칸 다섯(crossdesign · shoulderext · bermdepth · rockb · cutslope)은 측점 열쇠로 + * 쓰고, 부르는 쪽에는 지금처럼 **누가거리**로 내줌(읽기 · 쓰기 창구가 그대로). + * + * · 지금 측점 목록 = 상세가 초안 측점을 얹을 때마다(`useStationKeys` · Section_Drafts) — 관 id 는 관 세션 + * 장부 · 구조물 id 는 측점 계획(초안) · 구조물 목록(횡단 보기)에서. + * · 칸마다 열쇠 → 쓴 때 누가거리 `@at` 을 함께 담음 — 구조물 id 를 아직 모를 때(첫 열기)도 그 자리 측점이면 읽음. + * · 옛 초안(누가거리 열쇠 `"85.05"`) · `at:` 열쇠는 읽을 때 지금 측점 열쇠로 옮겨 다시 씀. + * · 노선이 바뀜 = `carryStationDrafts` — 넘기는 칸 = 서버 `CARRY_INPUT_KEYS` 일곱(지반 유형 · 측구 종류 · + * 측구 선택 · 2단 경사 · 암 경계 · 암 경사 · 소단 구간은 구간 그대로) · 단면 유형 · 측구 방향은 새 지형이 + * 정함 · 사라진 측점의 고침은 버리고 「노선 밖 고침 N 건」 한 줄. + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; +import { pipeIdAt } from "../A00_Common/design/design_pipe_ids"; +import { showToast } from "@ui/ui_template_elements"; +import { chainageKey, looseChainage, stationKey } from "@util/common_util_station_key"; +import { inferStationInterval } from "@util/common_util_svg"; +import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch"; + +/** 측점 열쇠로 담는 세션 칸. */ +export type KeyedState = "crossdesign" | "shoulderext" | "bermdepth" | "rockb" | "cutslope"; +export const KEYED_STATES: readonly KeyedState[] = [ + "crossdesign", + "shoulderext", + "bermdepth", + "rockb", + "cutslope", +]; + +/** 노선이 바뀔 때 넘기는 카드 선택 — 서버 `CARRY_INPUT_KEYS` 중 선택 칸(나머지 셋 = rockb · cutslope · 소단 구간). */ +const CARRY_CHOICE_KEYS = ["ground_type", "ditch_type", "ditch_choice", "two_stage_slope"] as const; +/** 노선이 바뀔 때 통째로 넘기는 칸 · 버리는 칸(노견 확장 · 소단 깊이 = 그 측점 기하 손질). */ +const CARRY_WHOLE: readonly KeyedState[] = ["rockb", "cutslope"]; + +/** 같은 측점으로 보는 거리 — 옛 초안 열쇠(0.01 m) · 서버 측점(0.001 m) 차이를 받음. */ +const NEAR_M = 0.01; +const HINT = "@at"; + +type Keyed = { key: string; chainageM: number }; +type Raw = Record & { [HINT]?: Record }; + +const lists = new Map(); +/** 프로젝트마다 구조물 측점 → id(측점 계획 · 구조물 목록). */ +const owners = new Map>(); +let current: { projectId: string; routeId: number } | null = null; + +const scopeOf = (projectId: string, routeId: number): string => `${projectId}:${routeId}`; + +/** 측점 계획(초안 · `planStations`)이 낸 덮는 구조물 id — 관 세션 id 는 `pipe-` 머리. */ +export function useStationOwners( + projectId: string, + planned: ReadonlyArray<{ chainage_m: number; owners: string[] }>, +): void { + owners.set( + projectId, + planned.flatMap((station) => + station.owners.slice(0, 1).map((id) => ({ + id, + kind: id.startsWith("pipe-") ? ("pipe" as const) : ("structure" as const), + at: station.chainage_m, + })), + ), + ); +} + +/** 구조물 목록(횡단 보기) — 한 자리 구조물의 id. 관 자리는 관 장부가 정함. */ +export function useStructureIds( + projectId: string, + structures: ReadonlyArray<{ structure_id?: string | null; chainage_m?: number | null }>, +): void { + const known = (owners.get(projectId) ?? []).filter((owner) => owner.kind === "pipe"); + for (const item of structures) { + if (item.structure_id && typeof item.chainage_m === "number") + known.push({ id: item.structure_id, kind: "structure", at: item.chainage_m }); + } + owners.set(projectId, known); +} + +const looksLikePipe = (section: CrossSection): boolean => + Boolean(section.culvert || section.box) || /^.+ D\d+/.test(section.structure ?? ""); + +/** 측점 목록 → 열쇠(같은 열쇠가 둘이면 뒤엣것은 `at:`). */ +export function stationKeysOf( + projectId: string | null, + sections: ReadonlyArray, + intervalM: number | null, +): Keyed[] { + const known = projectId ? (owners.get(projectId) ?? []) : []; + const seen = new Set(); + return sections.map((section) => { + const at = section.chainage_m; + let key: string; + if (section.kind === "irregular") { + const owner = known.find((entry) => Math.abs(entry.at - at) < NEAR_M); + if (owner) key = stationKey("irregular", at, intervalM, owner.id, owner.kind); + else if (looksLikePipe(section)) + key = stationKey("pipe", at, intervalM, pipeIdAt(at, projectId)); + else + key = section.grid + ? stationKey("regular", at, intervalM) + : stationKey("irregular", at, intervalM); + } else key = stationKey(section.kind, at, intervalM); + if (seen.has(key)) key = `at:${chainageKey(at)}`; + seen.add(key); + return { key, chainageM: at }; + }); +} + +/** 상세가 측점을 다 얹었다 — 이 노선의 지금 측점 열쇠를 세움(읽기 · 쓰기가 이 목록으로 옮김). */ +export function useStationKeys( + projectId: string, + routeId: number, + detail: Pick, +): void { + const interval = + ( + detail.longitudinal?.profile_alignment as + { policy?: { station_interval_m?: number } } | undefined + )?.policy?.station_interval_m ?? inferStationInterval(detail.cross_sections); + lists.set(scopeOf(projectId, routeId), stationKeysOf(projectId, detail.cross_sections, interval)); + current = { projectId, routeId }; +} + +/** 지금 노선(마지막으로 세운 목록) — 세션 열쇠만 아는 저장소(암 경계 · 암 경사)가 씀. */ +export const currentStationScope = () => current; + +function nearest(list: Keyed[], chainageM: number): Keyed | undefined { + let best: Keyed | undefined; + for (const entry of list) { + const gap = Math.abs(entry.chainageM - chainageM); + if (gap < NEAR_M && (!best || gap < Math.abs(best.chainageM - chainageM))) best = entry; + } + return best; +} + +/** 누가거리 → 열쇠(목록이 없거나 그 자리 측점이 없으면 `at:`). */ +export function keyAt(projectId: string, routeId: number, chainageM: number): string { + const list = lists.get(scopeOf(projectId, routeId)); + return (list && nearest(list, chainageM)?.key) ?? `at:${chainageKey(chainageM)}`; +} + +/** + * 열쇠 → 지금 누가거리(0.01 m). 목록에 있으면 그 측점 · 없으면 쓴 때 자리(`hint` · 옛 열쇠는 그 수)에 지금 + * 측점이 있으면 그 측점 · 목록이 아직 없으면 쓴 때 자리 그대로 · 그 밖 = null(노선 밖). + */ +export function chainageOfKey( + projectId: string, + routeId: number, + key: string, + hint?: number, +): number | null { + const list = lists.get(scopeOf(projectId, routeId)); + const hit = list?.find((entry) => entry.key === key); + if (hit) return Number(chainageKey(hit.chainageM)); + const at = looseChainage(key) ?? hint; + if (at === undefined) return null; + if (!list) return Number(chainageKey(at)); + const near = nearest(list, at); + return near ? Number(chainageKey(near.chainageM)) : null; +} + +function readRaw(name: KeyedState, projectId: string, routeId: number): Raw { + const raw = readState(name, projectId, routeId); + return raw && typeof raw === "object" ? raw : {}; +} + +function entries(raw: Raw): Array<[string, V, number | undefined]> { + const hints = raw[HINT] ?? {}; + return Object.entries(raw) + .filter(([key]) => key !== HINT) + .map(([key, value]) => [key, value as V, hints[key]]); +} + +/** + * 칸 하나를 누가거리(0.01 m 글) → 값으로 읽음 — 지금 측점에 없는 고침은 뺌(지우지 않음). + * 옛 열쇠(누가거리 · at:)는 지금 측점 열쇠로 옮겨 다시 씀(목록이 있을 때). + */ +export function readKeyed(name: KeyedState, projectId: string, routeId: number): Map { + const raw = readRaw(name, projectId, routeId); + const out = new Map(); + let moved = false; + for (const [key, value, hint] of entries(raw)) { + const at = chainageOfKey(projectId, routeId, key, hint); + if (at === null) continue; + out.set(chainageKey(at), value); + if (looseChainage(key) !== null && !keyAt(projectId, routeId, at).startsWith("at:")) + moved = true; + } + if (moved) writeKeyed(name, projectId, routeId, out); + return out; +} + +/** + * 누가거리(0.01 m 글) → 값 묶음을 측점 열쇠로 씀. 지금 측점이 아닌 옛 고침(노선 밖 · 아직 못 읽는 것)은 그대로 둠. + * `only` 를 주면 그 누가거리들만 바꾸고 나머지 지금 측점 고침도 그대로. + */ +export function writeKeyed( + name: KeyedState, + projectId: string, + routeId: number, + byChainage: ReadonlyMap, + only?: ReadonlyArray, +): void { + const raw = readRaw(name, projectId, routeId); + const next: Raw = { [HINT]: {} }; + const replaced = new Set((only ?? [...byChainage.keys()]).map((at) => chainageKey(Number(at)))); + for (const [key, value, hint] of entries(raw)) { + const at = chainageOfKey(projectId, routeId, key, hint); + if (at !== null && (only === undefined || replaced.has(chainageKey(at)))) continue; + next[key] = value; + if (hint !== undefined) next[HINT]![key] = hint; + } + for (const [at, value] of byChainage) { + if (only !== undefined && !replaced.has(chainageKey(Number(at)))) continue; + const key = keyAt(projectId, routeId, Number(at)); + next[key] = value; + next[HINT]![key] = Number(chainageKey(Number(at))); + } + writeState(name, next, projectId, routeId); +} + +/** + * 노선이 바뀜(새 측점 목록을 `useStationKeys` 로 세운 뒤) — 옛 노선 고침을 측점 열쇠로 새 노선에 넘김. + * 넘김 = 카드 선택 넷(지반 유형 · 측구 종류 · 측구 선택 · 2단 경사) · 암 경계 · 암 경사 · 단면 유형 · 측구 + * 방향은 새 지형이 정함 · 노견 확장 · 소단 깊이는 안 넘김. 돌려주는 값 = 노선 밖으로 나간 고침 수(측점 단위). + */ +export function carryStationDrafts( + projectId: string, + fromRouteId: number, + toRouteId: number, +): number { + const outside = new Set(); + for (const name of KEYED_STATES) { + const raw = readRaw(name, projectId, fromRouteId); + const next: Raw = { [HINT]: {} }; + for (const [key, value] of entries(raw)) { + // 옛 열쇠(누가거리 · at:)는 옛 목록으로 측점 열쇠부터 · 측점 열쇠(id)는 그대로 + const fromAt = + looseChainage(key) === null ? null : chainageOfKey(projectId, fromRouteId, key); + const stable = fromAt === null ? key : keyAt(projectId, fromRouteId, fromAt); + const toAt = chainageOfKey(projectId, toRouteId, stable, looseChainage(stable) ?? undefined); + const carried = carriedValue(name, value); + if (toAt === null || stable.startsWith("at:")) { + if (carried !== null) outside.add(stable); + continue; + } + if (carried === null) continue; + const toKey = keyAt(projectId, toRouteId, toAt); + next[toKey] = carried; + next[HINT]![toKey] = toAt; + } + writeState(name, next, projectId, toRouteId); + } + if (outside.size) + showToast(`노선 밖 고침 ${outside.size} 건 — 사라진 측점이라 버렸습니다.`, "info"); + return outside.size; +} + +/** 칸마다 넘기는 값 — 넘길 것이 없으면 null. */ +function carriedValue(name: KeyedState, value: unknown): unknown { + if (CARRY_WHOLE.includes(name)) return value; + if (name !== "crossdesign" || !value || typeof value !== "object") return null; + const source = value as Record; + const picked = Object.fromEntries( + CARRY_CHOICE_KEYS.filter((key) => source[key] !== undefined).map((key) => [key, source[key]]), + ); + return Object.keys(picked).length ? picked : null; +} diff --git a/B06_Section/B06_Section_UI_Page_Design_Sync.ts b/B06_Section/B06_Section_UI_Page_Design_Sync.ts index 804307026..fa04f3326 100644 --- a/B06_Section/B06_Section_UI_Page_Design_Sync.ts +++ b/B06_Section/B06_Section_UI_Page_Design_Sync.ts @@ -27,6 +27,7 @@ import type { SectionDetailResponse } from "./B06_Section_Api_Fetch"; import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View"; import type { StructureInstance } from "../B05_Profile/B05_Profile_Api_Structures"; import { setCrossDetailHost } from "./B06_Section_UI_Cross_Detail"; +import { useStructureIds } from "./B06_Section_Station_Keys"; export interface DesignSyncContext { projectId: string | null; @@ -194,6 +195,7 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController { function syncBermSpans(structures: ReadonlyArray): void { const currentRouteId = ctx.routeId(); if (!projectId || currentRouteId === null) return; + useStructureIds(projectId, structures); // 구조물 측점 열쇠 = 구조물 id(344-3) const next = bermSpansFromStructures(structures); if (JSON.stringify(next) === JSON.stringify(readBermSpans(projectId, currentRouteId))) return; writeBermSpans(projectId, currentRouteId, next); diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 3e870bbbc..8c0c1b2e1 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -49,6 +49,7 @@ import type { RockBoundaryControl } from "./B06_Section_UI_Section_View"; import type { CutSlopeControl } from "./B06_Section_UI_Cross_CutSlope"; import { balloonOffsetsPayload } from "@util/common_util_mass_haul_balance_view"; import { catalogRow } from "../common_util/common_util_structure_catalog"; +import { readKeyed, writeKeyed, type KeyedState } from "./B06_Section_Station_Keys"; /** * 세션에 쌓인 암 경계선 오프셋(측점키 → m). 없거나 손상되면 빈 객체. @@ -65,8 +66,35 @@ export function readRockBoundarySession( projectId: string, routeId: number, ): Record { - const stored = readState>("rockb", projectId, routeId); - return stored && typeof stored === "object" ? stored : {}; + // 세션 열쇠 = 측점 열쇠(344-3) — 부르는 쪽(다시 셈 · 서버 폴백)에는 누가거리(0.01 m 글)로 + return Object.fromEntries(readKeyed("rockb", projectId, routeId)); +} + +/** 측점별 암 절토 경사 세션값 — 누가거리(0.01 m 글) → 경사비(0 = 표준으로 되돌림 · 344-3). */ +export function readCutSlopeSession(projectId: string, routeId: number): Record { + return Object.fromEntries(readKeyed("cutslope", projectId, routeId)); +} + +/** 세션 열쇠 글(`aislo:draft:rockb:{p}:{r}`)의 프로젝트 · 노선 — 저장소가 측점 열쇠 창구로 읽고 씀. */ +function keyedScope(storageKey: string): { projectId: string; routeId: number } | null { + const [projectId, route] = storageKey.split(":").slice(-2); + const routeId = Number(route); + return projectId && Number.isInteger(routeId) ? { projectId, routeId } : null; +} + +/** 저장소 값 읽기 — 측점 열쇠 칸이면 누가거리로 옮겨 · 아니면 옛 꼴 그대로. */ +function loadKeyed(name: KeyedState, storageKey: string): Array<[string, number]> { + const scope = keyedScope(storageKey); + if (scope) return [...readKeyed(name, scope.projectId, scope.routeId)]; + const raw = readByKey(storageKey); + return raw ? Object.entries(JSON.parse(raw) as Record) : []; +} + +/** 저장소 값 쓰기 — 측점 열쇠로(노선 밖 옛 고침은 그대로 둠). */ +function persistKeyed(name: KeyedState, storageKey: string, values: Map): void { + const scope = keyedScope(storageKey); + if (scope) writeKeyed(name, scope.projectId, scope.routeId, values); + else writeByKey(storageKey, JSON.stringify(Object.fromEntries(values))); } /** 측점 하나의 소단 제원 — 서버 payload 와 같은 이름을 쓴다(그대로 실어 보낸다). */ @@ -215,7 +243,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - writeByKey(storageKey, JSON.stringify(Object.fromEntries(offsets))); + persistKeyed("rockb", storageKey, offsets); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -228,10 +256,7 @@ export function createRockBoundaryStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = readByKey(storageKey); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - Object.entries(parsed).forEach(([chainage, offset]) => { + loadKeyed("rockb", storageKey).forEach(([chainage, offset]) => { if (Number.isFinite(offset)) offsets.set(chainage, offset); }); } catch { @@ -318,7 +343,7 @@ export function createCutSlopeStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - writeByKey(storageKey, JSON.stringify(Object.fromEntries(ratios))); + persistKeyed("cutslope", storageKey, ratios); } catch { /* 세션 저장 실패는 무시(용량 초과 등) — 값은 메모리에 유지된다. */ } @@ -331,10 +356,7 @@ export function createCutSlopeStore(options: { const storageKey = sessionKey(); if (!storageKey) return; try { - const raw = readByKey(storageKey); - if (!raw) return; - const parsed = JSON.parse(raw) as Record; - Object.entries(parsed).forEach(([chainage, ratio]) => { + loadKeyed("cutslope", storageKey).forEach(([chainage, ratio]) => { // 0(되돌림 표시)도 그대로 싣는다 — 거르면 저장분 옛 값이 되살아난다. if (Number.isFinite(ratio) && ratio >= 0) ratios.set(chainage, ratio); }); diff --git a/common_util/common_util_station_key.py b/common_util/common_util_station_key.py new file mode 100644 index 000000000..de9c02d68 --- /dev/null +++ b/common_util/common_util_station_key.py @@ -0,0 +1,52 @@ +"""측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. + +일반 = ``grid:k``(누가거리 ÷ 측점 간격 반올림) · 관 = ``pipe:`` · 구조물 = ``structure:`` · +시종점 = ``end:BP`` · ``end:EP`` · id 를 모르는 구조물 측점 = ``at:<누가거리 0.01 m>``. + +⚠ 파이썬·TS 짝 — ``common_util_station_key.ts`` ``stationKey`` ↔ ``station_key``. +한쪽을 고치면 다른 쪽도 고치고 거울 시험 ``resources/tester/test_344_3_station_key_mirror.py``. +""" + +from __future__ import annotations + +import math +import re + +_LOOSE = re.compile(r"^-?\d+(\.\d+)?$") + + +def _half_up(value: float) -> int: + """JS ``Math.round`` 와 같은 반올림(0.5 는 위로).""" + return math.floor(value + 0.5) + + +def chainage_key(chainage_m: float) -> str: + """누가거리 0.01 m — 옛 초안 열쇠(``toFixed(2)``)와 같은 자릿수.""" + return f"{_half_up(chainage_m * 100) / 100:.2f}" + + +def station_key( + kind: str, + chainage_m: float, + interval_m: float | None, + station_id: str | None = None, + id_kind: str = "structure", +) -> str: + """측점 열쇠 — irregular 는 id 가 있으면 pipe: · structure:(``id_kind``) · 없으면 at:.""" + if kind == "bp": + return "end:BP" + if kind == "ep": + return "end:EP" + if kind in ("pipe", "structure"): + return f"{kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}" + if kind == "irregular": + return f"{id_kind}:{station_id}" if station_id else f"at:{chainage_key(chainage_m)}" + if interval_m and interval_m > 0: + return f"grid:{_half_up(chainage_m / interval_m)}" + return f"at:{chainage_key(chainage_m)}" + + +def loose_chainage(key: str) -> float | None: + """옛 초안 열쇠(누가거리 글) · ``at:`` 열쇠의 누가거리 — 측점 열쇠면 None.""" + text = key[3:] if key.startswith("at:") else key + return float(text) if _LOOSE.match(text) else None diff --git a/common_util/common_util_station_key.ts b/common_util/common_util_station_key.ts new file mode 100644 index 000000000..db398ac99 --- /dev/null +++ b/common_util/common_util_station_key.ts @@ -0,0 +1,43 @@ +/* ============================================================================= + * common_util_station_key.ts + * 측점 열쇠 한 규칙(PLAN 344-3) — 노선이 바뀌어도 같은 측점을 같은 이름으로 부름. + * 일반 = `grid:k`(누가거리 ÷ 측점 간격 반올림) · 관 = `pipe:` · 구조물 = `structure:` · + * 시종점 = `end:BP` · `end:EP` · id 를 모르는 구조물 측점 = `at:<누가거리 0.01 m>`(옛 초안 열쇠와 같은 몫). + * + * ⚠ 파이썬·TS 짝 — `common_util_station_key.py` `station_key` ↔ `stationKey`. 한쪽을 고치면 다른 쪽도 + * 고치고 거울 시험 `resources/tester/test_344_3_station_key_mirror.py`. + * ========================================================================== */ + +/** 측점 종류 — 서버 측점 `kind`(bp · ep · regular · irregular) 그대로 받음. */ +export type StationKeyKind = "bp" | "ep" | "regular" | "irregular" | "pipe" | "structure"; + +/** 누가거리 0.01 m — 옛 초안 열쇠(`toFixed(2)`)와 같은 자릿수. */ +export const chainageKey = (chainageM: number): string => + (Math.round(chainageM * 100) / 100).toFixed(2); + +/** + * 측점 열쇠. irregular 는 id 가 있으면 `pipe:` · `structure:`(종류는 `idKind`) · 없으면 `at:`. + * 간격이 없거나 0 이하면 일반 측점도 `at:`. + */ +export function stationKey( + kind: StationKeyKind, + chainageM: number, + intervalM: number | null, + id: string | null = null, + idKind: "pipe" | "structure" = "structure", +): string { + if (kind === "bp") return "end:BP"; + if (kind === "ep") return "end:EP"; + if (kind === "pipe" || kind === "structure") + return id ? `${kind}:${id}` : `at:${chainageKey(chainageM)}`; + if (kind === "irregular") return id ? `${idKind}:${id}` : `at:${chainageKey(chainageM)}`; + if (intervalM && intervalM > 0) return `grid:${Math.round(chainageM / intervalM)}`; + return `at:${chainageKey(chainageM)}`; +} + +/** 옛 초안 열쇠(누가거리 글 `"85.05"`) · `at:` 열쇠의 누가거리 — 측점 열쇠면 null. */ +export function looseChainage(key: string): number | null { + const text = key.startsWith("at:") ? key.slice(3) : key; + if (!/^-?\d+(\.\d+)?$/.test(text)) return null; + return Number(text); +} diff --git a/resources/tester/helper_273_2_preview_stations.cjs b/resources/tester/helper_273_2_preview_stations.cjs index 7f3b15bce..759e05c8f 100644 --- a/resources/tester/helper_273_2_preview_stations.cjs +++ b/resources/tester/helper_273_2_preview_stations.cjs @@ -65,6 +65,8 @@ const drafts = loadTs("B06_Section/B06_Section_Section_Drafts.ts", { } }, }, + // 측점 열쇠 세우기(344-3) — 이 시험 몫 아님 + "./B06_Section_Station_Keys": { useStationKeys: () => {}, useStationOwners: () => {} }, "./B06_Section_Api_Fetch": { previewStructureSections: async (_projectId, chainages) => { asked.push(chainages); diff --git a/resources/tester/test_344_3_station_key_mirror.py b/resources/tester/test_344_3_station_key_mirror.py new file mode 100644 index 000000000..66e3af00d --- /dev/null +++ b/resources/tester/test_344_3_station_key_mirror.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +"""344-3 측점 열쇠 거울 — `common_util_station_key.py` ↔ `common_util_station_key.ts` 같은 열쇠. + +일반 grid:k(누가거리 ÷ 간격 반올림 · 0.5 는 위로) · 관 pipe: · 구조물 structure: · +시종점 end:BP/EP · id 모름 at:<0.01 m> · 옛 열쇠(누가거리 글) · at: 의 누가거리. +""" + +import json +import subprocess +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from common_util.common_util_station_key import ( # noqa: E402 + chainage_key, + loose_chainage, + station_key, +) + +TS = PROJECT_ROOT / "common_util" / "common_util_station_key.ts" +TYPESCRIPT = PROJECT_ROOT / "config" / "node_modules" / "typescript" + +CASES = [ + ("bp", 0.0, 20.0, None, "structure"), + ("ep", 812.345, 20.0, None, "structure"), + ("regular", 40.0, 20.0, None, "structure"), + ("regular", 50.0, 20.0, None, "structure"), # 2.5 → 3(위로) + ("regular", 69.996, 20.0, None, "structure"), + ("regular", 40.0, None, None, "structure"), + ("irregular", 85.054, 20.0, "pipe-s3", "pipe"), + ("irregular", 85.054, 20.0, "st-7", "structure"), + ("irregular", 85.054, 20.0, None, "structure"), + ("irregular", 1.005, 20.0, None, "structure"), + ("pipe", 12.344, 20.0, "pipe-s1", "structure"), + ("structure", 12.345, 20.0, None, "structure"), +] +LOOSE = ["85.05", "at:85.05", "grid:4", "pipe:pipe-s3", "-1.5", "end:BP"] + +SCRIPT = r""" +const ts = require(process.argv[1]); +const fs = require("fs"); +const src = fs.readFileSync(process.argv[2], "utf8"); +const opts = { compilerOptions: { module: ts.ModuleKind.CommonJS } }; +const js = ts.transpileModule(src, opts).outputText; +const box = { exports: {} }; +new Function("exports", "module", js)(box.exports, box); +const m = box.exports; +const input = JSON.parse(process.argv[3]); +process.stdout.write(JSON.stringify({ + keys: input.cases.map((c) => m.stationKey(c[0], c[1], c[2], c[3], c[4])), + chainage: input.cases.map((c) => m.chainageKey(c[1])), + loose: input.loose.map((k) => m.looseChainage(k)), +})); +""" + + +def test_파이썬과_TS_열쇠_같음(): + proc = subprocess.run( + [ + "node", + "-e", + SCRIPT, + str(TYPESCRIPT), + str(TS), + json.dumps({"cases": CASES, "loose": LOOSE}), + ], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + out = json.loads(proc.stdout) + assert out["keys"] == [station_key(*case) for case in CASES] + assert out["chainage"] == [chainage_key(case[1]) for case in CASES] + assert out["loose"] == [loose_chainage(key) for key in LOOSE] + + +def test_열쇠_규칙(): + assert station_key("bp", 0.0, 20.0) == "end:BP" + assert station_key("ep", 812.3, 20.0) == "end:EP" + assert station_key("regular", 50.0, 20.0) == "grid:3" + assert station_key("irregular", 85.054, 20.0, "pipe-s3", "pipe") == "pipe:pipe-s3" + assert station_key("irregular", 85.054, 20.0) == "at:85.05" + assert loose_chainage("85.05") == 85.05 and loose_chainage("grid:4") is None diff --git a/resources/tester/test_session_keys_registered.py b/resources/tester/test_session_keys_registered.py index c49dbd168..b45cc4461 100644 --- a/resources/tester/test_session_keys_registered.py +++ b/resources/tester/test_session_keys_registered.py @@ -64,7 +64,8 @@ def test_등록표_밖에서_날문자열_세션_키를_안_쓴다(): def test_옛_암경계_키가_코드에_안_남아_있다(): """실사고 자리 못박기 — 읽는 쪽이 등록표를 거치는지.""" persist = (ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text(encoding="utf-8") - assert 'readState>("rockb"' in persist, "등록표를 안 거침" + # 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음 + assert 'readKeyed("rockb", projectId, routeId)' in persist, "등록표를 안 거침" # 옛 키가 **코드로** 되살아나는 것만 막는다 — 주석에 사연으로 적힌 것은 남겨 둔다. code = "\n".join( line for line in persist.splitlines() if not line.lstrip().startswith(("*", "//", "/*")) diff --git a/resources/tester/test_user_values_survive.py b/resources/tester/test_user_values_survive.py index 04ef0fefe..ff3053c52 100644 --- a/resources/tester/test_user_values_survive.py +++ b/resources/tester/test_user_values_survive.py @@ -202,7 +202,8 @@ def test_암경계선은_등록표_키로_읽는다(): persist = (PROJECT_ROOT / "B06_Section" / "B06_Section_UI_Page_Persist.ts").read_text( encoding="utf-8" ) - assert 'readState>("rockb"' in persist + # 344-3 — 측점 열쇠 창구(readKeyed → 등록표 readState)로 읽음 + assert 'readKeyed("rockb", projectId, routeId)' in persist # ── ⑦ 저장된 표준 횡단면이 새 탭에서 안 서던 것 (`72e50b3e`) ─────────────────── From 4262038cd4358ab7b011aeb198d9886b3627f52b Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:45:32 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(=EC=84=A4=EA=B3=84=EB=AC=B6=EC=9D=8C):?= =?UTF-8?q?=20346-2=20cross=20=C2=B7=20masshaul=20=C2=B7=20haul=20?= =?UTF-8?q?=EB=8B=A8=EA=B3=84=20=E2=80=94=20=EA=B3=84=EC=95=BD=20=EB=AA=A8?= =?UTF-8?q?=EC=96=91=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EC=A7=80=EB=AC=B8=20?= =?UTF-8?q?=EA=B5=AD=EB=B6=80=20=EC=97=B0=EC=82=B0=20=C2=B7=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=20=EB=B0=96=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20=C2=B7=20?= =?UTF-8?q?=ED=9D=99=20=EB=B0=B0=EB=B6=84=20=EC=97=B4=EC=87=A0=20=EA=B8=B0?= =?UTF-8?q?=EC=96=B5=20=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}}