feat(511-B B-4 cross 줄 엶 · 페이지 cross 서버 길): 계산 자리 cross = server(DTM 격자 있음)면 묶음 cross 를 서버 Node 가 같은 TS 로 — 저장 노선 다시 셈 = cross.refresh(Cross_Refresh 셈 몫을 logic/B06_Section_Cross_Refresh_Calc.ts 로 나눔 · 세션 읽기는 화면 · 구조물 폐회로 면적 층까지) · 엔진 길(노선 초안) = stage.cross(design_pipeline_cross_server.ts · 페이지 676 줄) · 못 받음 = 브라우저 · DTM 아닌 지표면 = 브라우저 · /calc 단계 · 화면 길 = 서버가 격자 경로 · 구조물 정의 채움(화면이 보낸 경로 버림) · 판 cross 줄 엶(기본값 both 그대로 · 까닭 글) · 화면 시험 test_511_b4_cross_server_browser(검증_sub2 · both 대 server 0 차 · 저장 노선 535 ms · 초안 462 ms 왕복 = 잠잠 400 포함) · 글 읽는 시험 10 = 셈 몫 파일도 읽게 — 요구 320-4
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/* =============================================================================
|
||||
* A00_Common/design/design_pipeline_cross_server.ts
|
||||
* 계산 자리 cross = server 의 묶음 cross 단계(PLAN 511-B B-4) — 엔진 길(노선 초안 · 저장 상세 없음)을 서버 Node
|
||||
* 단계(`stage.cross` · `design_calc_node_stages.ts`)가 같은 단계 함수 · 같은 붙임으로 셈. 저장 노선 다시 셈은
|
||||
* `B06_Section_Cross_Refresh` 의 `cross.refresh`. 판 both · DTM 격자 없음(DTM 아닌 지표면 · 받기 전) = null
|
||||
* (브라우저). 격자 경로 · 구조물 정의 = 서버 창구가 채움(`B06_Section_Router_Calc.py`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState } from "../b_page_state";
|
||||
import { designTerrainGridNow } from "../../B05_Profile/B05_Profile_Api_TerrainGrid";
|
||||
import { calcOnServerOnly } from "./design_calc_server";
|
||||
import { calcOnServerNow } from "./design_calc_sites";
|
||||
import type { StagesInputs } from "./logic/design_calc_node_stages";
|
||||
import type { CrossOutput, StageContext } from "./logic/design_pipeline_types";
|
||||
|
||||
/** 단계 입력 중 페이지 저장 맥락(묶음 입력 · 격자 · 관 세션 장부 뺌). */
|
||||
export type CrossStageContext = Omit<
|
||||
NonNullable<StagesInputs["stages"]>,
|
||||
"input" | "grid" | "pipe_ids"
|
||||
>;
|
||||
|
||||
/** 서버 단계 답 · 끊김 · 실패 = undefined(부르는 쪽이 브라우저) · server 아님 = null. */
|
||||
export function crossOnServer(
|
||||
projectId: string,
|
||||
ctx: StageContext<"cross">,
|
||||
context: () => CrossStageContext | null,
|
||||
): Promise<CrossOutput | undefined> | null {
|
||||
if (!calcOnServerNow("cross") || !designTerrainGridNow(projectId)) return null;
|
||||
const saved = context();
|
||||
if (!saved) return null;
|
||||
// ponytail: 저장 상세(1.4 MB)를 부름마다 올림 — 시험용 길 · 느리면 서버가 정본 맥락을 붙임
|
||||
return calcOnServerOnly<CrossOutput>(
|
||||
"cross",
|
||||
"C.stage",
|
||||
{
|
||||
run: ["stage.cross"],
|
||||
inputs: {
|
||||
stages: { ...saved, pipe_ids: readState("pipe-ids", projectId), input: { ...ctx.inputs } },
|
||||
},
|
||||
pick: (results) => results["stage.cross"],
|
||||
},
|
||||
ctx.signal,
|
||||
);
|
||||
}
|
||||
@@ -96,6 +96,7 @@ import {
|
||||
} from "../../B05_Profile/logic/B05_Profile_Pipeline_Detail";
|
||||
import { buildStations } from "../../B05_Profile/logic/B05_Profile_Sections_Stations";
|
||||
import { footprintStageSited } from "../../B05_Profile/B05_Profile_Footprint_Server";
|
||||
import { crossOnServer, type CrossStageContext } from "./design_pipeline_cross_server";
|
||||
import type {
|
||||
PlanPipe,
|
||||
PlanStructure,
|
||||
@@ -270,6 +271,19 @@ function createPagePipeline(
|
||||
{ intervalM: options.stationIntervalM, types: enabled, ...snapOptions(inputs.route, plan) },
|
||||
).map((station) => station.chainageM),
|
||||
});
|
||||
/** cross 서버 단계 맥락(511-B B-4) — 저장 노선 · 상세 · 시설 · 편집(`design_pipeline_cross_server`). */
|
||||
const serverContext = (): CrossStageContext | null =>
|
||||
plan && {
|
||||
plan,
|
||||
detail: savedSource().detail ?? null,
|
||||
types,
|
||||
saved_pipes: savedPipes,
|
||||
saved_structures: savedStructures,
|
||||
saved_edits: savedEdits(),
|
||||
saved_plus: savedPlusRows(projectId),
|
||||
before_draft: beforeDraft,
|
||||
interval_m: options.stationIntervalM,
|
||||
};
|
||||
/** 다시 셈 창구가 낸 cross 출력(365-4) — 엔진 재사용에서 뺌. */
|
||||
const savedOut = new WeakSet<object>();
|
||||
const pipe = createDesignPipeline({
|
||||
@@ -311,14 +325,17 @@ function createPagePipeline(
|
||||
...contextCross(ctx, status, options.bermAt?.(ctx.inputs.structures) ?? null),
|
||||
prev: ctx.prev && savedOut.has(ctx.prev) ? null : ctx.prev,
|
||||
});
|
||||
// 계산 자리 cross = server — 엔진 길도 서버 Node 단계(같은 TS · 511-B B-4) · 못 받음 = 브라우저
|
||||
const remote = () =>
|
||||
crossOnServer(projectId, ctx, serverContext)?.then((o) => o ?? engine());
|
||||
const { detail = null, routeId } = savedSource();
|
||||
const saved =
|
||||
!ctx.inputs.route && detail && typeof routeId === "number"
|
||||
? options.savedCross?.(detail, routeId, stations)
|
||||
: null;
|
||||
if (!saved) return engine();
|
||||
if (!saved) return remote() ?? engine();
|
||||
return saved.then((out) => {
|
||||
if (!out) return engine();
|
||||
if (!out) return remote() ?? engine();
|
||||
savedOut.add(out);
|
||||
return out;
|
||||
});
|
||||
|
||||
@@ -50,7 +50,7 @@ import {
|
||||
structureAreaRows,
|
||||
} from "../../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { carryRunners, type CarryInputs } from "./design_calc_node_carry";
|
||||
import { crossRunners, useDefinition, type CrossInputs } from "./design_calc_node_cross";
|
||||
import { crossRunners, PAGE_IDS, useDefinition, type CrossInputs } from "./design_calc_node_cross";
|
||||
import { followRunners, type FollowInputs } from "./design_calc_node_follow";
|
||||
import { STAGE_IDS, stageRunners, type StagesInputs } from "./design_calc_node_stages";
|
||||
import { TRIAL_IDS, trialRunners, type TrialInputs } from "./design_calc_node_trial";
|
||||
@@ -103,6 +103,7 @@ interface CalcInputs extends FollowInputs, CarryInputs, CrossInputs, TrialInputs
|
||||
/** 한 번 돌림 — 같은 id 는 한 번만 셈(memo) · 앞 id 는 `need` 로 · 단계 id 는 약속(기다림). */
|
||||
async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
const memo = new Map<string, unknown>();
|
||||
useDefinition(inputs.definition); // 구조물 표 = 서버 정의(M02 반영 · 511-B B-4)
|
||||
const sections = inputs.detail?.cross_sections ?? [];
|
||||
const context = inputs.context ?? {};
|
||||
let massHaul: MassHaulResult | null = null;
|
||||
@@ -170,7 +171,6 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
// 측점마다 TS 짝(`computeCrossDesign`) — 못 세우면 그 자리 { error }(파이썬은 ValueError 로 되던짐).
|
||||
"cross.design": () => {
|
||||
const batch = inputs.cross_design;
|
||||
if (batch?.facilities) useDefinition(inputs.definition);
|
||||
const ranges = batch?.facilities
|
||||
? facilityRanges(batch.facilities.pipes, batch.facilities.structures ?? [])
|
||||
: null;
|
||||
@@ -205,7 +205,7 @@ async function runDesignCalc(run: readonly string[], inputs: CalcInputs) {
|
||||
const unknown = run.filter((id) => !(id in RUNNERS));
|
||||
const results: Record<string, unknown> = {};
|
||||
if (!unknown.length) for (const id of run) results[id] = await need(id);
|
||||
const outside = [...TRIAL_IDS, ...STAGE_IDS];
|
||||
const outside = [...TRIAL_IDS, ...STAGE_IDS, ...PAGE_IDS];
|
||||
const available = Object.keys(RUNNERS).filter((id) => !outside.includes(id));
|
||||
return { results, unknown, available };
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
* cross.enforce [저장] 횡단 한 바퀴(`enforceSavedCross` · 설계선 → 미교차 넓힘 → 포장 → 세월교 하강 · G2 C3)
|
||||
* 입력 cross_enforce = `SaveCrossInput` + grid(DTM 격자 이진 경로 · 넓힘 때만 읽음)
|
||||
* 출력 측점마다 { design · samples?(넓힘) · extended? } | null
|
||||
* cross.refresh 계산 자리 cross = server 의 저장 노선 횡단 다시 셈(`refreshDesigns` + 구조물 폐회로 면적)
|
||||
* 입력 cross_refresh { detail · inputs: `RefreshInputs` · grid(서버 창구가 채움) }
|
||||
* 출력 { design_profiles · sections: [{ design · samples?(넓힘) }] · rows: 구조물 면적 줄 }
|
||||
* — 등록부 밖(`PAGE_IDS` · 파이썬 `calc_runner.PAGE_IDS`) · 화면 cross 단계 길
|
||||
* 구조물 표 = 서버 정의(`definition` · M02 반영) — 받으면 갈아 끼움(파이썬 `catalog_row` 와 같은 표).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -19,6 +23,12 @@ import {
|
||||
enforceSavedCross,
|
||||
type SaveCrossInput,
|
||||
} from "../../../B06_Section/logic/B06_Section_Cross_Save";
|
||||
import {
|
||||
refreshDesigns,
|
||||
type RefreshInputs,
|
||||
} from "../../../B06_Section/logic/B06_Section_Cross_Refresh_Calc";
|
||||
import type { SectionDetailResponse } from "../../../B06_Section/B06_Section_Api_Fetch";
|
||||
import { structureAreaRows } from "../../../B06_Section/B06_Section_Structure_Layouts";
|
||||
import { gridAt } from "./design_calc_node_stages";
|
||||
|
||||
export interface CrossInputs {
|
||||
@@ -30,11 +40,15 @@ export interface CrossInputs {
|
||||
types: AttachType[];
|
||||
};
|
||||
cross_enforce?: SaveCrossInput & { grid?: string | null };
|
||||
cross_refresh?: { detail: SectionDetailResponse; inputs: RefreshInputs; grid?: string | null };
|
||||
}
|
||||
|
||||
/** 등록부 밖 화면 길 id(`available` 에 안 넣음 · 파이썬 `PAGE_IDS` 와 같음). */
|
||||
export const PAGE_IDS = ["cross.refresh"];
|
||||
|
||||
let defined = "";
|
||||
|
||||
/** 서버 정의로 구조물 표를 갈아 끼움 — 같은 정의면 그대로(늘 떠 있는 Node). */
|
||||
/** 서버 정의로 구조물 표를 갈아 끼움 — 같은 정의면 그대로(늘 떠 있는 Node · 요청마다 입구가 부름). */
|
||||
export function useDefinition(definition: CatalogDefinition | null | undefined): void {
|
||||
if (!definition) return;
|
||||
const key = JSON.stringify(definition);
|
||||
@@ -48,7 +62,6 @@ const SHAPE_FIELDS = [...SHAPE_KEYS, "blank"];
|
||||
export function crossRunners(inputs: CrossInputs): Record<string, () => unknown> {
|
||||
return {
|
||||
"facility.attach": () => {
|
||||
useDefinition(inputs.definition);
|
||||
const attach = inputs.attach;
|
||||
if (!attach) throw new Error("형상 얹기 입력(attach) 없음");
|
||||
const sections = attach.sections.map((section) => ({ ...section })) as Array<
|
||||
@@ -63,10 +76,24 @@ export function crossRunners(inputs: CrossInputs): Record<string, () => unknown>
|
||||
return { shapes, misses };
|
||||
},
|
||||
"cross.enforce": () => {
|
||||
useDefinition(inputs.definition);
|
||||
const input = inputs.cross_enforce;
|
||||
if (!input) throw new Error("횡단 한 바퀴 입력(cross_enforce) 없음");
|
||||
return enforceSavedCross(input, () => (input.grid ? gridAt(input.grid) : null));
|
||||
},
|
||||
"cross.refresh": () => {
|
||||
const job = inputs.cross_refresh;
|
||||
if (!job) throw new Error("횡단 다시 셈 입력(cross_refresh) 없음");
|
||||
const { detail } = job;
|
||||
const before = detail.cross_sections.map((section) => section.samples);
|
||||
refreshDesigns(detail, job.inputs, job.grid ? gridAt(job.grid) : null);
|
||||
return {
|
||||
design_profiles: detail.longitudinal.design_profiles,
|
||||
sections: detail.cross_sections.map((section, index) => ({
|
||||
design: section.design,
|
||||
...(section.samples !== before[index] ? { samples: section.samples } : {}),
|
||||
})),
|
||||
rows: structureAreaRows(detail.cross_sections),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -24,22 +24,10 @@
|
||||
* 편집값은 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 같은 값을 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeCrossDesign } from "@util/common_util_cross_design";
|
||||
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
bermSpecAt,
|
||||
readCutSlopeSession,
|
||||
readRockBoundarySession,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSessionSpec, BermSpan } from "./B06_Section_UI_Page_Persist";
|
||||
import {
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
type BermSpec,
|
||||
} from "@util/common_util_cross_berm";
|
||||
import { readCutSlopeSession, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||
import type { BermSpan } from "./B06_Section_UI_Page_Persist";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { USER_TOUCHED_KEYS } from "./logic/B06_Section_Design_Keys";
|
||||
import { savedDesigns } from "./B06_Section_Section_Canon";
|
||||
@@ -51,7 +39,6 @@ import {
|
||||
import { effectiveStandardCross, readRockBoundaryDefault } from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
buildAlignment,
|
||||
planElevationAt,
|
||||
toAlignmentBase,
|
||||
} from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
@@ -66,8 +53,18 @@ import {
|
||||
calcOnServerOnly,
|
||||
} from "../A00_Common/design/design_calc_server";
|
||||
import { withPipeOptions } from "./B06_Section_Section_Adjusts";
|
||||
import { extendUnclosed } from "../B05_Profile/logic/B05_Profile_Sections_Ground";
|
||||
import { designTerrainGridNow } from "../B05_Profile/B05_Profile_Api_TerrainGrid";
|
||||
import {
|
||||
pavedBySpecs,
|
||||
refreshDesigns,
|
||||
rockKey,
|
||||
storedBermSpec,
|
||||
type RefreshInputs,
|
||||
type RefreshMemo,
|
||||
} from "./logic/B06_Section_Cross_Refresh_Calc";
|
||||
|
||||
/** 포장 판정 · 저장분 소단 읽기 — 셈 몫(logic)으로 옮김 · 부르는 곳 그대로(다시 내보냄). */
|
||||
export { pavedBySpecs, storedBermSpec };
|
||||
|
||||
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
|
||||
export interface CrossRefreshEdits {
|
||||
@@ -88,30 +85,6 @@ export interface CrossRefreshInput {
|
||||
/** 계산이 만들지 않는 **사용자 값** — 목록 한 벌 = `logic/B06_Section_Design_Keys`(서버 Node 입구도 · 472-6). */
|
||||
export { USER_TOUCHED_KEYS };
|
||||
|
||||
/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
||||
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
|
||||
|
||||
/**
|
||||
* 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다.
|
||||
* 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다.
|
||||
*/
|
||||
export function pavedBySpecs(section: Pick<CrossSection, "pavement" | "ford_pavement">): boolean {
|
||||
return Boolean(section.pavement?.length) || Boolean(section.ford_pavement);
|
||||
}
|
||||
|
||||
function preserveUserFields(
|
||||
next: NonNullable<CrossSection["design"]>,
|
||||
previous: CrossSection["design"],
|
||||
): NonNullable<CrossSection["design"]> {
|
||||
if (!previous) return next;
|
||||
const merged = { ...next } as Record<string, unknown>;
|
||||
const source = previous as unknown as Record<string, unknown>;
|
||||
for (const key of PRESERVED_KEYS) {
|
||||
if (source[key] !== undefined) merged[key] = source[key];
|
||||
}
|
||||
return merged as unknown as NonNullable<CrossSection["design"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리.
|
||||
* 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다
|
||||
@@ -164,6 +137,16 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<num
|
||||
useStationKeys(input.projectId, input.routeId, input.detail); // 세션 고침을 이 측점 목록으로 읽음(344-3)
|
||||
await withPipeOptions(input.detail, input.projectId, true); // 관 옵션을 고친 뒤도 면적이 따라옴(387-4b)
|
||||
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
|
||||
// 계산 자리 cross = server(DTM 격자 있음) — 서버 Node 가 같은 셈 · 구조물 층까지(511-B B-4) · 못 받음 = 브라우저
|
||||
const remote =
|
||||
calcOnServerNow("cross") && designTerrainGridNow(input.projectId) !== null
|
||||
? await refreshOnServer(input)
|
||||
: false;
|
||||
if (remote) {
|
||||
return sections
|
||||
.filter((section) => JSON.stringify(section.design) !== before.get(section))
|
||||
.map((section) => section.chainage_m);
|
||||
}
|
||||
refreshLocally(input); // 선형 저장분 · 표준값이 없으면 셈 없음(옛 서버 폴백 걷음 · 511-B B-4)
|
||||
// 구조물이 선 측점은 폐회로 면적이 정본이다 — **이 자리 한 곳**에서 늘 다시 얹는다(387-4 · 카드 그리기는
|
||||
// 안 얹음 · 설계가 그대로여도 조정창 4축 · 구조물 제원이 바뀌면 면적이 바뀜 · 6 ms/67 측점).
|
||||
@@ -191,10 +174,6 @@ export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<num
|
||||
* 통째로 놓쳤고, 그래서 B06 을 다녀오기 전과 후의 절·성토가 달랐다(2026-09-03 실측
|
||||
* 성토 16,715.5㎥ ↔ 16,690.7㎥). 키를 숫자로 되돌려 0.01m 단위로 맞춘다.
|
||||
*/
|
||||
function rockKey(chainageM: number): number {
|
||||
return Math.round(chainageM * 100) / 100;
|
||||
}
|
||||
|
||||
function readRockOffsets(projectId: string, routeId: number): Map<number, number> {
|
||||
const raw = readRockBoundarySession(projectId, routeId) ?? {};
|
||||
const offsets = new Map<number, number>();
|
||||
@@ -207,38 +186,6 @@ function readRockOffsets(projectId: string, routeId: number): Map<number, number
|
||||
return offsets;
|
||||
}
|
||||
|
||||
type Side = "left" | "right";
|
||||
const SIDES: Side[] = ["left", "right"];
|
||||
|
||||
/** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */
|
||||
export function storedBermSpec(berm: unknown): BermSessionSpec | null {
|
||||
if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null;
|
||||
const spec = berm as Record<string, unknown>;
|
||||
const value = (key: string, fallback: number): number => {
|
||||
const raw = spec[key];
|
||||
if (raw === undefined) return fallback;
|
||||
return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw);
|
||||
};
|
||||
const out = {
|
||||
width_m: value("width_m", BERM_DEFAULT_WIDTH_M),
|
||||
interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M),
|
||||
slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
||||
depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0),
|
||||
};
|
||||
return Object.values(out).every(Number.isFinite) ? out : null;
|
||||
}
|
||||
|
||||
/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */
|
||||
function bermDepthReader(projectId: string, routeId: number) {
|
||||
const session = readBermDepths(projectId, routeId);
|
||||
return (chainageM: number, design: Record<string, unknown>, side: Side): number | null => {
|
||||
const own = session.get(rockKey(chainageM))?.[side];
|
||||
const stored = design[`berm_depth_${side}_m`];
|
||||
const value = own ?? (typeof stored === "number" ? stored : null);
|
||||
return value === null ? null : Math.max(value, 0);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다.
|
||||
*
|
||||
@@ -263,13 +210,8 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map<number, num
|
||||
* 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면
|
||||
* `null` 을 돌려 서버 폴백으로 넘긴다.
|
||||
*/
|
||||
/** 측점별 앞 계산 — 측점 객체가 바뀌면(상세 다시 받음) 저절로 비워진다. */
|
||||
const memos = new WeakMap<
|
||||
CrossSection,
|
||||
{ samples: CrossSection["samples"]; fingerprint: string; design: CrossSection["design"] }
|
||||
>();
|
||||
|
||||
function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
/** 세션 · 가게에서 셈 입력을 모음(`RefreshInputs`) — 선형 저장분 · 표준값이 없으면 null(셈 없음). */
|
||||
function gatherRefresh(input: CrossRefreshInput): RefreshInputs | null {
|
||||
const { projectId, detail, edits } = input;
|
||||
const stored = readAlignment(detail.longitudinal);
|
||||
// 선 = 화면이 쥔 선 → 종단도가 맡긴 선 → 저장분 + 편집(종단도가 아직 없을 때) · 342-1
|
||||
@@ -282,188 +224,73 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
curve_radii: edits.curve_radii ?? {},
|
||||
})
|
||||
: null);
|
||||
if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
|
||||
if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 셈 없음(옛 서버 폴백 걷음 · 511-B B-4)
|
||||
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
|
||||
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
|
||||
const standardKey = JSON.stringify(standard);
|
||||
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
|
||||
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
|
||||
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
|
||||
// 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다**
|
||||
// — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다.
|
||||
detail.longitudinal.design_profiles = [
|
||||
toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]),
|
||||
];
|
||||
|
||||
const rockOffsets = readRockOffsets(projectId, input.routeId);
|
||||
const rockDefault = readRockBoundaryDefault(projectId);
|
||||
// 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로
|
||||
// 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07).
|
||||
const cutSlopeRatios = readCutSlopeRatios(projectId, input.routeId);
|
||||
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
||||
// 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`
|
||||
// (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467).
|
||||
const bermCopy = readState<BermSpan[]>("berm", projectId, input.routeId);
|
||||
const bermSpans = Array.isArray(bermCopy) ? bermCopy : null;
|
||||
const bermDepthAt = bermDepthReader(projectId, input.routeId);
|
||||
const bermAt = (chainageM: number, design: Record<string, unknown>): BermSpec | null => {
|
||||
const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm);
|
||||
return spec
|
||||
? {
|
||||
widthM: spec.width_m,
|
||||
intervalM: spec.interval_m,
|
||||
slopeDeg: spec.slope_deg,
|
||||
depthM: spec.depth_m,
|
||||
// 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분
|
||||
depthLeftM: bermDepthAt(chainageM, design, "left"),
|
||||
depthRightM: bermDepthAt(chainageM, design, "right"),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
|
||||
// (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]).
|
||||
/** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */
|
||||
const cutSlopeAt = (chainageM: number, design: Record<string, unknown>): number | null => {
|
||||
const session = cutSlopeRatios.get(rockKey(chainageM));
|
||||
if (session !== undefined) return session > 0 ? session : null;
|
||||
const stored = design.cut_slope_ratio_user;
|
||||
return typeof stored === "number" && stored > 0 ? stored : null;
|
||||
};
|
||||
const choices = crossDesignChoices(projectId, input.routeId);
|
||||
const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100);
|
||||
// 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로
|
||||
const shoulderExtends = readShoulderExtends(projectId, input.routeId);
|
||||
const extendAt = (chainageM: number, design: Record<string, unknown>, side: "left" | "right") => {
|
||||
const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side];
|
||||
const stored = design[`shoulder_extend_${side}_m`];
|
||||
return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0);
|
||||
};
|
||||
|
||||
// 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음)
|
||||
const grid = designTerrainGridNow(projectId)?.grid ?? null;
|
||||
// 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로
|
||||
// (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계
|
||||
const canon = new Map(
|
||||
(savedDesigns(projectId, input.routeId) ?? []).map((row) => [
|
||||
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 받은 뒤 다시 셈
|
||||
const { routeId } = input;
|
||||
// 소단 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`(파이썬 `load_berm_spans` 못 읽음 →
|
||||
// 저장분 · `stored_berm` 짝 · 467)
|
||||
const bermCopy = readState<BermSpan[]>("berm", projectId, routeId);
|
||||
return {
|
||||
alignment,
|
||||
standard,
|
||||
rockOffsets: [...readRockOffsets(projectId, routeId)],
|
||||
rockDefault: readRockBoundaryDefault(projectId),
|
||||
// 측점별 암 절토 경사 · 소단 · 카드 선택 · 노견 확장 = 세션에만 있는 값 — **계산 전에** 실음(2026-09-07 · 3-9)
|
||||
cutSlopes: [...readCutSlopeRatios(projectId, routeId)],
|
||||
bermSpans: Array.isArray(bermCopy) ? bermCopy : null,
|
||||
bermDepths: [...readBermDepths(projectId, routeId)],
|
||||
choices: [...crossDesignChoices(projectId, routeId)],
|
||||
shoulderExtends: [...readShoulderExtends(projectId, routeId)],
|
||||
// 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계(467 덧)
|
||||
canon: (savedDesigns(projectId, routeId) ?? []).map((row) => [
|
||||
rockKey(row.chainage_m),
|
||||
row.design,
|
||||
]),
|
||||
);
|
||||
const updated: number[] = [];
|
||||
for (const section of detail.cross_sections) {
|
||||
const previous = section.design;
|
||||
if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다.
|
||||
const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버
|
||||
// `recompute_designs_for_alignment` 의 우선순위와 같다.
|
||||
const sessionOffset = rockOffsets.get(rockKey(section.chainage_m));
|
||||
const storedOffset = design.rock_boundary_offset_m;
|
||||
const rockBoundaryOffsetM =
|
||||
typeof sessionOffset === "number"
|
||||
? sessionOffset
|
||||
: typeof storedOffset === "number"
|
||||
? storedOffset
|
||||
: rockDefault;
|
||||
const choice = choiceAt(section.chainage_m);
|
||||
const extendLeft = extendAt(section.chainage_m, design, "left");
|
||||
const extendRight = extendAt(section.chainage_m, design, "right");
|
||||
const elevation = planElevationAt(alignment, section.chainage_m);
|
||||
const options: CrossDesignOptions = {
|
||||
groundType:
|
||||
choice?.ground_type ??
|
||||
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
|
||||
sectionMode:
|
||||
choice?.section_mode ??
|
||||
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
|
||||
ditchSide:
|
||||
choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null),
|
||||
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
|
||||
ditchType:
|
||||
choice?.ditch_type ??
|
||||
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
|
||||
paved: pavedBySpecs(section),
|
||||
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
||||
standard,
|
||||
berm: bermAt(section.chainage_m, design),
|
||||
rockBoundaryOffsetM,
|
||||
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
|
||||
twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope),
|
||||
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
|
||||
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
|
||||
ditchChoice: choice
|
||||
? (choice.ditch_choice ?? null)
|
||||
: typeof design.ditch_choice === "boolean"
|
||||
? design.ditch_choice
|
||||
: null,
|
||||
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
|
||||
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
|
||||
surfaceDropM:
|
||||
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
|
||||
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
|
||||
planRadiusM: section.plan_radius_m ?? null,
|
||||
curveOuterSide:
|
||||
section.curve_outer_side === "left" || section.curve_outer_side === "right"
|
||||
? section.curve_outer_side
|
||||
: null,
|
||||
curveWideningM: section.curve_widening_m ?? null,
|
||||
shoulderExtendLeftM: extendLeft,
|
||||
shoulderExtendRightM: extendRight,
|
||||
};
|
||||
// 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고
|
||||
// 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐.
|
||||
const samples = section.samples ?? [];
|
||||
const fingerprint = `${standardKey}|${elevation}|${samples.length}|${grid ? "grid" : ""}|${JSON.stringify({ ...options, standard: null })}`;
|
||||
const memo = memos.get(section);
|
||||
if (
|
||||
memo?.samples === section.samples &&
|
||||
memo.fingerprint === fingerprint &&
|
||||
memo.design === previous
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let next;
|
||||
try {
|
||||
next = computeCrossDesign(samples, elevation, options);
|
||||
// 미교차 넓힘 — 서버 [저장] `extend_unclosed_sections` 짝 · 넓힌 표본을 측점에 남김(428 · 묶음 cross 와 같음)
|
||||
const wide =
|
||||
grid && next.slope_unclosed
|
||||
? extendUnclosed(
|
||||
samples,
|
||||
[section.center_x, section.center_y],
|
||||
section.frame.left_xy,
|
||||
(more) => computeCrossDesign(more as typeof samples, elevation, options),
|
||||
grid,
|
||||
)
|
||||
: null;
|
||||
if (wide && (wide.info.left_m || wide.info.right_m)) {
|
||||
section.samples = wide.samples as typeof samples;
|
||||
next = computeCrossDesign(section.samples, elevation, options);
|
||||
}
|
||||
} catch {
|
||||
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
||||
}
|
||||
section.design = preserveUserFields(
|
||||
next as unknown as NonNullable<CrossSection["design"]>,
|
||||
previous,
|
||||
);
|
||||
section.design.shoulder_extend_left_m = extendLeft;
|
||||
section.design.shoulder_extend_right_m = extendRight;
|
||||
// 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이
|
||||
// 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음)
|
||||
for (const side of SIDES) {
|
||||
const own = next[`berm_depth_${side}_m`];
|
||||
if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own;
|
||||
else delete section.design[`berm_depth_${side}_m`];
|
||||
}
|
||||
if (next.berm) section.design.berm = next.berm;
|
||||
else delete section.design.berm;
|
||||
memos.set(section, { samples: section.samples, fingerprint, design: section.design });
|
||||
updated.push(section.chainage_m);
|
||||
}
|
||||
return updated;
|
||||
};
|
||||
}
|
||||
|
||||
/** 측점별 앞 계산 — 측점 객체가 바뀌면(상세 다시 받음) 저절로 비워진다. */
|
||||
const memos: RefreshMemo = new WeakMap();
|
||||
|
||||
/** 브라우저 안에서 전 측점을 다시 셈(셈 = `logic/B06_Section_Cross_Refresh_Calc`) · 입력 못 모음 = null. */
|
||||
function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
const inputs = gatherRefresh(input);
|
||||
if (!inputs) return null;
|
||||
const grid = designTerrainGridNow(input.projectId)?.grid ?? null; // 미교차 넓힘 격자(받기 전 = null)
|
||||
return refreshDesigns(input.detail, inputs, grid, memos);
|
||||
}
|
||||
|
||||
/** 서버 답(`cross.refresh`) — 계획선 표본 · 측점마다 설계(넓혔으면 표본) · 구조물 폐회로 면적 줄. */
|
||||
interface RefreshAnswer {
|
||||
design_profiles: SectionDetailResponse["longitudinal"]["design_profiles"];
|
||||
sections: Array<{ design: CrossSection["design"]; samples?: CrossSection["samples"] }>;
|
||||
rows: ReturnType<typeof structureAreaRows>;
|
||||
}
|
||||
|
||||
/** 계산 자리 cross = server — 모은 입력 · 상세를 서버 Node(`cross.refresh` · 같은 셈)에 넘기고 답을 제자리에
|
||||
* 얹음 · 입력 못 모음 = 셈 없음(true) · 못 받음 = false(브라우저가 셈). */
|
||||
async function refreshOnServer(input: CrossRefreshInput): Promise<boolean> {
|
||||
const inputs = gatherRefresh(input);
|
||||
if (!inputs) return true;
|
||||
const { detail } = input;
|
||||
const answer = await calcOnServerOnly<RefreshAnswer>("cross", "C.saved", {
|
||||
run: ["cross.refresh"],
|
||||
inputs: {
|
||||
cross_refresh: {
|
||||
detail: { longitudinal: detail.longitudinal, cross_sections: detail.cross_sections },
|
||||
inputs,
|
||||
},
|
||||
},
|
||||
pick: (results) => results["cross.refresh"],
|
||||
});
|
||||
if (!answer || answer.sections.length !== detail.cross_sections.length) return false;
|
||||
detail.longitudinal.design_profiles = answer.design_profiles;
|
||||
answer.sections.forEach((row, index) => {
|
||||
const section = detail.cross_sections[index];
|
||||
section.design = row.design;
|
||||
if (row.samples) section.samples = row.samples;
|
||||
});
|
||||
applyStructureAreaRows(detail.cross_sections, answer.rows);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,19 @@
|
||||
권한 = 프로젝트 접근(`protected_with_company`) · 호출량 = `main.py` 미들웨어 · 본문 상한 `MAX_BODY`.
|
||||
실행 = 늘 떠 있는 Node 하나(`common_util_calc_runner.run_calc_served`) ·
|
||||
정본 저장 길은 이 창구를 안 씀.
|
||||
단계(`stage.*`) · 화면 길(`cross.refresh`) = 서버가 DTM 격자 경로 · 구조물 정의를 채움 —
|
||||
화면이 보낸 경로는 버림(파일 경로를 화면에 맡기지 않음 · DTM 아닌 지표면 = 격자 없음 · 511-B B-4).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from pathlib import Path
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from common_util.common_util_calc_runner import run_calc_served
|
||||
from common_util.common_util_calc_runner import PAGE_IDS, STAGE_IDS, run_calc_served
|
||||
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Calc Server"])
|
||||
|
||||
@@ -48,6 +51,8 @@ async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONRe
|
||||
return _error(400, "run = id 목록")
|
||||
if not isinstance(inputs, dict):
|
||||
return _error(400, "inputs = 객체")
|
||||
if set(run) & (STAGE_IDS | PAGE_IDS):
|
||||
await _server_context(project_id, inputs)
|
||||
try:
|
||||
answer = await asyncio.to_thread(run_calc_served, run, inputs)
|
||||
except ValueError as exc:
|
||||
@@ -57,3 +62,31 @@ async def run_calc_endpoint(project_id: UUID, request: Request) -> dict | JSONRe
|
||||
if "error" in answer:
|
||||
return _error(422, str(answer["error"]))
|
||||
return {"status": "success", "project_id": str(project_id), **answer}
|
||||
|
||||
|
||||
async def _server_context(project_id: UUID, inputs: dict) -> None:
|
||||
"""단계 · 화면 길 입력에 서버 맥락을 채움 — DTM 격자 이진 경로(없으면 None) ·
|
||||
구조물 정의(M02 반영)."""
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Structures_Schema import load_definition
|
||||
from B06_Section.B06_Section_Server_Calc_Prebuild import design_grid_file
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
design_terrain,
|
||||
get_surface_confirmation_params,
|
||||
)
|
||||
from config.config_db import run_with_connection
|
||||
|
||||
stored, params = await asyncio.gather(
|
||||
run_with_connection(get_project_storage_relative_path, project_id),
|
||||
run_with_connection(get_surface_confirmation_params, str(project_id)),
|
||||
)
|
||||
root = Path(resolve_stored_project_path(stored))
|
||||
grid = await asyncio.to_thread(design_grid_file, root, params)
|
||||
inputs["definition"] = load_definition()
|
||||
if isinstance(inputs.get("stages"), dict):
|
||||
inputs["stages"]["grid"] = (
|
||||
{"path": str(grid), "design": design_terrain(params)} if grid else None
|
||||
)
|
||||
if isinstance(inputs.get("cross_refresh"), dict):
|
||||
inputs["cross_refresh"]["grid"] = str(grid) if grid else None
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_Cross_Refresh_Calc.ts
|
||||
* 저장 노선 횡단 다시 셈의 셈 몫(PLAN 511-B B-4) — `B06_Section_Cross_Refresh.ts` 가 세션 · 가게에서 모은
|
||||
* 입력(`RefreshInputs` · JSON 꼴)으로 전 측점 설계를 제자리에서 다시 셈. 브라우저와 서버 Node
|
||||
* (`cross.refresh` · 계산 자리 cross = server)가 이 함수 하나를 씀 — 같은 입력 = 같은 값.
|
||||
* 계산 = `computeCrossDesign`(파이썬 `B06_Section_Engine_Design.py` 짝) · 미교차 넓힘 = `extendUnclosed`.
|
||||
* 순수 — 화면 · fetch · 세션 없음(세션 읽기는 부르는 쪽).
|
||||
* ========================================================================== */
|
||||
|
||||
import { bermSpecAt, type BermSessionSpec, type BermSpan } from "@util/common_util_berm_spans";
|
||||
import {
|
||||
BERM_DEFAULT_DEPTH_M,
|
||||
BERM_DEFAULT_INTERVAL_M,
|
||||
BERM_DEFAULT_SLOPE_DEG,
|
||||
BERM_DEFAULT_WIDTH_M,
|
||||
type BermSpec,
|
||||
} from "@util/common_util_cross_berm";
|
||||
import { computeCrossDesign } from "@util/common_util_cross_design";
|
||||
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||
import type { DtmGrid } from "@util/common_util_surface_sampler";
|
||||
import type { CrossSection, SectionDetailResponse } from "../B06_Section_Api_Fetch";
|
||||
import type { CrossDesignChoice } from "../B06_Section_Cross_Design_Session";
|
||||
import {
|
||||
planElevationAt,
|
||||
type ProfileAlignment,
|
||||
} from "../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
||||
import { toDesignProfile } from "../../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
||||
import { extendUnclosed } from "../../B05_Profile/logic/B05_Profile_Sections_Ground";
|
||||
import { USER_TOUCHED_KEYS } from "./B06_Section_Design_Keys";
|
||||
|
||||
type Side = "left" | "right";
|
||||
const SIDES: Side[] = ["left", "right"];
|
||||
type SideValues = Partial<Record<Side, number>>;
|
||||
|
||||
/** 모은 입력 — 열쇠 = 누가거리(0.01 m 숫자 · `rockKey`) · Map 은 목록(JSON 으로 서버에 감). */
|
||||
export interface RefreshInputs {
|
||||
/** 화면이 쥔 계획선(측점 한 벌 · 342-1). */
|
||||
alignment: ProfileAlignment;
|
||||
standard: StandardCrossSectionSpec;
|
||||
/** 암 경계 세션 오프셋 · 기본 오프셋(config). */
|
||||
rockOffsets: Array<[number, number]>;
|
||||
rockDefault: number | null;
|
||||
/** 측점별 암 절토 경사(0 = 표준값). */
|
||||
cutSlopes: Array<[number, number]>;
|
||||
/** 소단 사본(세션 `berm` · 없으면 null = 저장분 `design.berm`). */
|
||||
bermSpans: BermSpan[] | null;
|
||||
bermDepths: Array<[number, SideValues]>;
|
||||
/** 카드 선택(지반 · 측구 · 2단 경사 …). */
|
||||
choices: Array<[number, CrossDesignChoice]>;
|
||||
shoulderExtends: Array<[number, SideValues]>;
|
||||
/** 저장 정본 설계(세션 고름 ?? 정본). */
|
||||
canon: Array<[number, unknown]>;
|
||||
}
|
||||
|
||||
/** 측점별 앞 계산 — 브라우저만(측점 객체가 바뀌면 저절로 비워짐). */
|
||||
export type RefreshMemo = WeakMap<
|
||||
CrossSection,
|
||||
{ samples: CrossSection["samples"]; fingerprint: string; design: CrossSection["design"] }
|
||||
>;
|
||||
|
||||
/** 다시 계산해도 살려 두는 값 — 사용자 값에 **상태를 나르는 둘**을 더한 것. */
|
||||
const PRESERVED_KEYS = ["status", "pavement_suggested", ...USER_TOUCHED_KEYS] as const;
|
||||
|
||||
/** 누가거리 열쇠(0.01 m) — 자릿수에 안 휘둘리게 숫자로. */
|
||||
export function rockKey(chainageM: number): number {
|
||||
return Math.round(chainageM * 100) / 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 포장 = 노면공 · 물넘이 범위 안만(138-1) — 측점에 얹힌 제원(`pavement` · `ford_pavement`)이 곧 범위다.
|
||||
* 서버 짝 = `B06_Section_Router_Design.paved_at` · 저장값 · 카드 선택은 쓰지 않는다.
|
||||
*/
|
||||
export function pavedBySpecs(section: Pick<CrossSection, "pavement" | "ford_pavement">): boolean {
|
||||
return Boolean(section.pavement?.length) || Boolean(section.ford_pavement);
|
||||
}
|
||||
|
||||
function preserveUserFields(
|
||||
next: NonNullable<CrossSection["design"]>,
|
||||
previous: CrossSection["design"],
|
||||
): NonNullable<CrossSection["design"]> {
|
||||
if (!previous) return next;
|
||||
const merged = { ...next } as Record<string, unknown>;
|
||||
const source = previous as unknown as Record<string, unknown>;
|
||||
for (const key of PRESERVED_KEYS) {
|
||||
if (source[key] !== undefined) merged[key] = source[key];
|
||||
}
|
||||
return merged as unknown as NonNullable<CrossSection["design"]>;
|
||||
}
|
||||
|
||||
/** 저장분 소단 제원(`design.berm`) — 짝: 파이썬 `berm_from`(빈 칸 = 기본값 · 깊이 0 미만 = 0 · 못 읽으면 없음). */
|
||||
export function storedBermSpec(berm: unknown): BermSessionSpec | null {
|
||||
if (!berm || typeof berm !== "object" || Array.isArray(berm)) return null;
|
||||
const spec = berm as Record<string, unknown>;
|
||||
const value = (key: string, fallback: number): number => {
|
||||
const raw = spec[key];
|
||||
if (raw === undefined) return fallback;
|
||||
return raw === null || raw === "" || typeof raw === "object" ? Number.NaN : Number(raw);
|
||||
};
|
||||
const out = {
|
||||
width_m: value("width_m", BERM_DEFAULT_WIDTH_M),
|
||||
interval_m: value("interval_m", BERM_DEFAULT_INTERVAL_M),
|
||||
slope_deg: value("slope_deg", BERM_DEFAULT_SLOPE_DEG),
|
||||
depth_m: Math.max(value("depth_m", BERM_DEFAULT_DEPTH_M), 0),
|
||||
};
|
||||
return Object.values(out).every(Number.isFinite) ? out : null;
|
||||
}
|
||||
|
||||
/** 소단 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분 순 · 없으면 null(구간 깊이를 씀). */
|
||||
function bermDepthReader(session: Map<number, SideValues>) {
|
||||
return (chainageM: number, design: Record<string, unknown>, side: Side): number | null => {
|
||||
const own = session.get(rockKey(chainageM))?.[side];
|
||||
const stored = design[`berm_depth_${side}_m`];
|
||||
const value = own ?? (typeof stored === "number" ? stored : null);
|
||||
return value === null ? null : Math.max(value, 0);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 전 측점 설계를 모은 입력으로 다시 셈 · `detail` 제자리 고침 · 다시 센 측점 누가거리.
|
||||
* 계획고는 화면 선(`inputs.alignment`)에서 — `design_profiles` 도 그 선으로 얹음(낡음 판정 기준 한 벌).
|
||||
*/
|
||||
export function refreshDesigns(
|
||||
detail: SectionDetailResponse,
|
||||
inputs: RefreshInputs,
|
||||
grid: DtmGrid | null,
|
||||
memos?: RefreshMemo,
|
||||
): number[] {
|
||||
const { alignment, standard } = inputs;
|
||||
const standardKey = JSON.stringify(standard);
|
||||
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
|
||||
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
|
||||
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
|
||||
// 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다**
|
||||
// — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다.
|
||||
detail.longitudinal.design_profiles = [
|
||||
toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]),
|
||||
];
|
||||
|
||||
const rockOffsets = new Map(inputs.rockOffsets);
|
||||
const rockDefault = inputs.rockDefault;
|
||||
// 측점별 암 절토 경사도 세션에만 있는 값이다 — **계산 전에** 실어야 설계선이 새 경사로
|
||||
// 그려진다. 계산 뒤에 값만 베껴 붙이면 그림은 옛 경사, 숫자만 새것이 된다(2026-09-07).
|
||||
const cutSlopeRatios = new Map(inputs.cutSlopes);
|
||||
// 소단도 같은 성격 — 세션에만 있는 값이라 여기서 실어 주지 않으면 계획선을 고치는
|
||||
// 순간 계단이 사라진다(계획서 3-9). 사본 없음(시설 줄이 구조물을 받기 전) = 저장분 `design.berm`
|
||||
// (파이썬 `load_berm_spans` 못 읽음 → 저장분 · `stored_berm` 짝 · 467).
|
||||
const bermSpans = inputs.bermSpans;
|
||||
const bermDepthAt = bermDepthReader(new Map(inputs.bermDepths));
|
||||
const bermAt = (chainageM: number, design: Record<string, unknown>): BermSpec | null => {
|
||||
const spec = bermSpans ? bermSpecAt(bermSpans, chainageM) : storedBermSpec(design.berm);
|
||||
return spec
|
||||
? {
|
||||
widthM: spec.width_m,
|
||||
intervalM: spec.interval_m,
|
||||
slopeDeg: spec.slope_deg,
|
||||
depthM: spec.depth_m,
|
||||
// 쪽마다 옮긴 깊이(194-1) — 세션 → 저장분
|
||||
depthLeftM: bermDepthAt(chainageM, design, "left"),
|
||||
depthRightM: bermDepthAt(chainageM, design, "right"),
|
||||
}
|
||||
: null;
|
||||
};
|
||||
// 카드 버튼 선택은 세션 초안이 정본보다 새것이다 — 새로고침 뒤에도 고른 값이 남는다
|
||||
// (2026-09-06 사용자 확정: 조작은 캐시, 저장은 [저장]·[확정]).
|
||||
/** 세션 → 저장분 순. 없거나 0(되돌림)이면 null 을 줘 표준값을 쓰게 한다. */
|
||||
const cutSlopeAt = (chainageM: number, design: Record<string, unknown>): number | null => {
|
||||
const session = cutSlopeRatios.get(rockKey(chainageM));
|
||||
if (session !== undefined) return session > 0 ? session : null;
|
||||
const stored = design.cut_slope_ratio_user;
|
||||
return typeof stored === "number" && stored > 0 ? stored : null;
|
||||
};
|
||||
const choices = new Map(inputs.choices);
|
||||
const choiceAt = (chainageM: number) => choices.get(Math.round(chainageM * 100) / 100);
|
||||
// 노견 확장(139-6) — 세션 → 저장분 순 · 기하 입력이라 계산에 넣고 결과 칸도 그 값으로
|
||||
const shoulderExtends = new Map(inputs.shoulderExtends);
|
||||
const extendAt = (chainageM: number, design: Record<string, unknown>, side: "left" | "right") => {
|
||||
const session = shoulderExtends.get(Math.round(chainageM * 100) / 100)?.[side];
|
||||
const stored = design[`shoulder_extend_${side}_m`];
|
||||
return Math.max(session ?? (typeof stored === "number" ? stored : 0), 0);
|
||||
};
|
||||
|
||||
// 미교차 넓힘 격자 — 받기 전이면 null(받은 뒤 다시 셈이 넓힘 · 지문에 실음)
|
||||
// 입력 물러섬 = 저장 정본 설계(세션 고름 ?? 정본) — 다시 셈 결과가 메모리 설계에 앞 고름을 남겨도 ↶ 가 정본으로
|
||||
// (467 덧) · 정본에 없는 측점(새 측점 · 옮긴 시설)만 지금 설계
|
||||
const canon = new Map(inputs.canon);
|
||||
const updated: number[] = [];
|
||||
for (const section of detail.cross_sections) {
|
||||
const previous = section.design;
|
||||
if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다.
|
||||
const design = (canon.get(rockKey(section.chainage_m)) ?? previous) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
// 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버
|
||||
// `recompute_designs_for_alignment` 의 우선순위와 같다.
|
||||
const sessionOffset = rockOffsets.get(rockKey(section.chainage_m));
|
||||
const storedOffset = design.rock_boundary_offset_m;
|
||||
const rockBoundaryOffsetM =
|
||||
typeof sessionOffset === "number"
|
||||
? sessionOffset
|
||||
: typeof storedOffset === "number"
|
||||
? storedOffset
|
||||
: rockDefault;
|
||||
const choice = choiceAt(section.chainage_m);
|
||||
const extendLeft = extendAt(section.chainage_m, design, "left");
|
||||
const extendRight = extendAt(section.chainage_m, design, "right");
|
||||
const elevation = planElevationAt(alignment, section.chainage_m);
|
||||
const options: CrossDesignOptions = {
|
||||
groundType:
|
||||
choice?.ground_type ??
|
||||
(typeof design.ground_type === "string" ? design.ground_type : "ripping_rock"),
|
||||
sectionMode:
|
||||
choice?.section_mode ??
|
||||
(typeof design.section_mode === "string" ? design.section_mode : "left_cut"),
|
||||
ditchSide:
|
||||
choice?.ditch_side ?? (typeof design.ditch_side === "string" ? design.ditch_side : null),
|
||||
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
|
||||
ditchType:
|
||||
choice?.ditch_type ??
|
||||
(typeof design.ditch_type === "string" ? design.ditch_type : "standard"),
|
||||
paved: pavedBySpecs(section),
|
||||
// 물넘이 자리 = 측구 없음 · 지대 낮은 쪽 한 경사(286-2 · 287-4 · 서버 `tilt_ranges` 짝 = 얹힌 제원)
|
||||
tilted: Boolean(section.ford_pavement || section.underdrain),
|
||||
tiltPct: section.ford_pavement?.slope_pct ?? null, // 맹암거 = 표준 경사(371-3)
|
||||
standard,
|
||||
berm: bermAt(section.chainage_m, design),
|
||||
rockBoundaryOffsetM,
|
||||
cutSlopeRatio: cutSlopeAt(section.chainage_m, design),
|
||||
twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope),
|
||||
// ⚠ **선택과 결과를 갈라 넘긴다**(2026-09-09). 선택이 있으면 그것을 따르고,
|
||||
// 없으면 옛 저장분의 결과를 **자동값과 다를 때만** 선택으로 살린다.
|
||||
ditchChoice: choice
|
||||
? (choice.ditch_choice ?? null)
|
||||
: typeof design.ditch_choice === "boolean"
|
||||
? design.ditch_choice
|
||||
: null,
|
||||
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
|
||||
// 세월교 월류 하강 = 그 측점에 얹힌 세월교 제원(초안 · 저장분 같은 자리 · 서버 `ford_drop_at` 짝 · 314)
|
||||
surfaceDropM:
|
||||
Number(section.ford?.overflow_depth_m) > 0 ? Number(section.ford?.overflow_depth_m) : 0,
|
||||
// 곡선부 확폭 입력 — 측점 기록에 실려 온다(서버 엔진과 같은 값, 2026-09-06).
|
||||
planRadiusM: section.plan_radius_m ?? null,
|
||||
curveOuterSide:
|
||||
section.curve_outer_side === "left" || section.curve_outer_side === "right"
|
||||
? section.curve_outer_side
|
||||
: null,
|
||||
curveWideningM: section.curve_widening_m ?? null,
|
||||
shoulderExtendLeftM: extendLeft,
|
||||
shoulderExtendRightM: extendRight,
|
||||
};
|
||||
// 국부 연산(346-2) — 입력 지문(지반 표본 · 계획고 · 횡단 입력 · 구조물 맥락 · 표준값)이 같고
|
||||
// 설계가 그때 낸 그 객체면 앞 결과 그대로. 계획고 ▲ 한 번에 다시 세는 측점은 곡선 둘레 몇 곳뿐.
|
||||
const samples = section.samples ?? [];
|
||||
const fingerprint = `${standardKey}|${elevation}|${samples.length}|${grid ? "grid" : ""}|${JSON.stringify({ ...options, standard: null })}`;
|
||||
const memo = memos?.get(section);
|
||||
if (
|
||||
memo?.samples === section.samples &&
|
||||
memo.fingerprint === fingerprint &&
|
||||
memo.design === previous
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
let next;
|
||||
try {
|
||||
next = computeCrossDesign(samples, elevation, options);
|
||||
// 미교차 넓힘 — 서버 [저장] `extend_unclosed_sections` 짝 · 넓힌 표본을 측점에 남김(428 · 묶음 cross 와 같음)
|
||||
const wide =
|
||||
grid && next.slope_unclosed
|
||||
? extendUnclosed(
|
||||
samples,
|
||||
[section.center_x, section.center_y],
|
||||
section.frame.left_xy,
|
||||
(more) => computeCrossDesign(more as typeof samples, elevation, options),
|
||||
grid,
|
||||
)
|
||||
: null;
|
||||
if (wide && (wide.info.left_m || wide.info.right_m)) {
|
||||
section.samples = wide.samples as typeof samples;
|
||||
next = computeCrossDesign(section.samples, elevation, options);
|
||||
}
|
||||
} catch {
|
||||
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
||||
}
|
||||
section.design = preserveUserFields(
|
||||
next as unknown as NonNullable<CrossSection["design"]>,
|
||||
previous,
|
||||
);
|
||||
section.design.shoulder_extend_left_m = extendLeft;
|
||||
section.design.shoulder_extend_right_m = extendRight;
|
||||
// 옮긴 소단 깊이 = 새 계산이 되실은 값(옛 값 승계보다 세션이 새것 · 194-1) · 없으면 칸 없음(↶ 뒤 앞 값이
|
||||
// 메모리에 안 남음 · 467 덧) · 소단 제원도 이번 원천 그대로(지운 소단이 남지 않음)
|
||||
for (const side of SIDES) {
|
||||
const own = next[`berm_depth_${side}_m`];
|
||||
if (typeof own === "number") section.design[`berm_depth_${side}_m`] = own;
|
||||
else delete section.design[`berm_depth_${side}_m`];
|
||||
}
|
||||
if (next.berm) section.design.berm = next.berm;
|
||||
else delete section.design.berm;
|
||||
memos?.set(section, { samples: section.samples, fingerprint, design: section.design });
|
||||
updated.push(section.chainage_m);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
입력 꼴 = 입구 머리(`detail` · `route_points` · `context` · `excavation`).
|
||||
`run_calc_served` = 계산 자리 「서버」 창구(511-A · `B06_Section_Router_Calc`) —
|
||||
늘 떠 있는 Node 하나(`--serve` · 한 줄 요청 · 한 줄 답) · 죽음 · 묶음 다시 지음 = 다시 띄움 ·
|
||||
시험 길 `TRIAL_IDS` · 묶음 단계 `STAGE_IDS`(511-B) 도 받음.
|
||||
시험 길 `TRIAL_IDS` · 묶음 단계 `STAGE_IDS`(511-B) · 화면 길 `PAGE_IDS`(B-4) 도 받음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -65,6 +65,8 @@ TRIAL_IDS = frozenset(
|
||||
STAGE_IDS = frozenset(
|
||||
{"stage.route", "stage.follow", "stage.stations", "stage.profile", "stage.cross"}
|
||||
)
|
||||
# 화면 길(`design_calc_node_cross.ts` `PAGE_IDS` · 511-B B-4) — cross = server 저장 노선 다시 셈
|
||||
PAGE_IDS = frozenset({"cross.refresh"})
|
||||
SERVE_TIMEOUT_S = 60
|
||||
|
||||
|
||||
@@ -138,7 +140,7 @@ atexit.register(_SERVED.stop)
|
||||
|
||||
def run_calc_served(ids: Sequence[str], inputs: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""창구 길 — `{results, ms(Node 셈), wall_ms(파이썬 ↔ Node)}` · `{error}` · 못 돌림 None."""
|
||||
unknown = [item for item in ids if item not in NODE_IDS | TRIAL_IDS | STAGE_IDS]
|
||||
unknown = [item for item in ids if item not in NODE_IDS | TRIAL_IDS | STAGE_IDS | PAGE_IDS]
|
||||
if unknown:
|
||||
raise ValueError(f"서버 계산 id 아님: {', '.join(unknown)}")
|
||||
t0 = time.perf_counter()
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
판 = `resources/master_template/calc_sites.json` `{version, sites: {묶음: "both" | "server"}}`.
|
||||
both = 조작 중 브라우저 셈 + [저장] 서버 정본(지금 등록부 자리) · server = 조작 중에도 서버 길
|
||||
(G1 · D3 = 504-2 · ③ 토량 · ④ 이월 · ⑧ 고정 브라우저(시험용 · fixed 는 정본 칸 표시) = 511-A 창구).
|
||||
(G1 · D3 = 504-2 · ③ 토량 · ④ 이월 · ⑧ 고정 브라우저(시험용 · fixed 는 정본 칸 표시) = 511-A 창구 ·
|
||||
cross = 511-B B-4 묶음 cross 단계 · 저장 노선 다시 셈).
|
||||
판이 없거나 깨지면 기본값(전부 both = 등록부 그대로). [저장] · [확정] 서버 정본 길은 판을 안 읽음.
|
||||
묶음 12 줄 = 등록부(`design_calc_registry*.ts`) # 전부 · 같은지 시험 `test_309_2_calc_sites.py`.
|
||||
"""
|
||||
@@ -53,12 +54,13 @@ GROUPS: tuple[dict[str, Any], ...] = (
|
||||
"fixed": "both",
|
||||
"why": "pair_B3",
|
||||
},
|
||||
# cross = 짝 모음 끝(B-4) — server = 묶음 cross 를 서버 Node 가 같은 TS 로(DTM 아닌 지표면 = 브라우저)
|
||||
{
|
||||
"key": "cross",
|
||||
"nos": ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"],
|
||||
"switchable": False,
|
||||
"fixed": "both",
|
||||
"why": "pair_B4",
|
||||
"switchable": True,
|
||||
"fixed": None,
|
||||
"why": "cross",
|
||||
},
|
||||
# 폼 기본값 도구(B04 · B08 파이썬 · 고를 것 없음)
|
||||
{"key": "form_default", "nos": ["F9"], "switchable": False, "fixed": "both", "why": None},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"corridor_mesh": "both",
|
||||
"quantity": "both",
|
||||
"carry": "both",
|
||||
"cross": "both",
|
||||
"fixed_browser": "both"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,5 +205,10 @@ def test_좌측_패널_칸_구간_깊이_나름():
|
||||
# 소단 구간 셈은 472-4 C4 에서 `common_util_berm_spans.ts` 로 옮김
|
||||
source = (ROOT / "common_util" / "common_util_berm_spans.ts").read_text(encoding="utf-8")
|
||||
assert "options.depth_m" in source and "Math.max(rawDepth, 0)" in source
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(
|
||||
encoding="utf-8"
|
||||
) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔)
|
||||
refresh += (ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "depthM: spec.depth_m" in refresh
|
||||
|
||||
@@ -107,5 +107,10 @@ def test_단추가_메모리_설계를_안_덮음():
|
||||
sync.index("async function handleDesignChange") : sync.index("function recomputeIfRock")
|
||||
]
|
||||
assert "target.design" not in body
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
assert "savedDesigns(projectId, input.routeId)" in refresh
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(
|
||||
encoding="utf-8"
|
||||
) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔)
|
||||
refresh += (ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "savedDesigns(projectId, routeId)" in refresh
|
||||
|
||||
@@ -137,7 +137,10 @@ def test_노면은_표고_낮은_쪽으로():
|
||||
def test_저장_뒤_서버가_물넘이_측구를_맞춤():
|
||||
router = _read("B06_Section", "B06_Section_Router_Design.py")
|
||||
assert "stored_tilt(design) == tilt" in router
|
||||
refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts")
|
||||
refresh = _read(
|
||||
"B06_Section", "B06_Section_Cross_Refresh.ts"
|
||||
) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔)
|
||||
refresh += _read("B06_Section", "logic", "B06_Section_Cross_Refresh_Calc.ts")
|
||||
assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh
|
||||
|
||||
|
||||
|
||||
@@ -175,7 +175,12 @@ def test_횡단도_B07_이_맹암거를_그림():
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "appendUnderdrainOverlay(svg, section.underdrain, design, x, y);" in b07
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(encoding="utf-8")
|
||||
refresh = (ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts").read_text(
|
||||
encoding="utf-8"
|
||||
) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔)
|
||||
refresh += (ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "tilted: Boolean(section.ford_pavement || section.underdrain)," in refresh
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ def test_묶음_여덟_줄_등록부_전부():
|
||||
"corridor_mesh",
|
||||
"quantity",
|
||||
"carry",
|
||||
"cross", # 511-B B-4 끝에 엶
|
||||
"fixed_browser",
|
||||
} # 511-A 셋 더
|
||||
for group in calc_sites.GROUPS:
|
||||
@@ -155,7 +156,7 @@ def test_읽기_로그인_쓰기_시스템_관리자만(client):
|
||||
def test_잠금_모르는_줄_400_감사_한_줄(client):
|
||||
http, _role, board, log = client
|
||||
url = "/api/dashboard/admin/calc-sites"
|
||||
for bad in ({"cross": "server"}, {"fixed_server": "both"}, {"nope": "server"}):
|
||||
for bad in ({"profile": "server"}, {"fixed_server": "both"}, {"nope": "server"}):
|
||||
assert http.put(url, json={"sites": bad}).status_code == 400, bad
|
||||
assert http.put(url, json={"sites": {"ground_sample": "browser"}}).status_code == 400
|
||||
assert log == []
|
||||
|
||||
@@ -150,11 +150,13 @@ def test_시스템_관리자_카드_창_G1_서버_길(browser):
|
||||
calcs = [row for row in rows if not row["head"]]
|
||||
assert len(heads) == 12 and len(calcs) == 45 # 511-B 단계 다섯 줄 · F9 폼 기본값
|
||||
whys = {row["name"].split(" · ")[0]: row["why"] for row in calcs}
|
||||
for no, n in {"R1": 5, "F2": 5, "R6": 2, "P2": 3, "C1": 4, "F6": 4}.items():
|
||||
for no, n in {"R1": 5, "F2": 5, "R6": 2, "P2": 3}.items():
|
||||
assert whys[no] == f"짝 모으는 중 · B-{n}", no
|
||||
assert whys["C1"].startswith("끄면 횡단을 서버 Node") # 511-B B-4 엶
|
||||
assert heads[0]["boxes"] == [[True, False], [True, True]] # G1 묶음 머리 = 바꿀 수 있음
|
||||
editable = [row["name"] for row in calcs if not row["boxes"][0][1]]
|
||||
opened = ["G1", "D3", "E1", "F7", "F8", "C4", "R9", "F10"] # 511-A ③ ④ 더
|
||||
opened += ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"] # 511-B B-4
|
||||
opened += ["R2", "R4", "R8", "P7", "C7", "D4"] # ⑧ 시험용
|
||||
assert [name.split(" · ")[0] for name in editable] == opened
|
||||
assert all(row["name"].split(" · ")[1] for row in calcs) # 이름 = 등록부 label
|
||||
|
||||
@@ -72,7 +72,10 @@ def test_structure_change_reconciles_and_ford_reattach() -> None:
|
||||
view = _read("A00_Common", "design", "design_view_cross.ts")
|
||||
body = view.split("async function refreshDetailForStructures", 1)[1].split("\n }\n", 1)[0]
|
||||
assert "void reconcileStaleDesigns();" in body
|
||||
refresh = _read("B06_Section", "B06_Section_Cross_Refresh.ts")
|
||||
refresh = _read(
|
||||
"B06_Section", "B06_Section_Cross_Refresh.ts"
|
||||
) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔)
|
||||
refresh += _read("B06_Section", "logic", "B06_Section_Cross_Refresh_Calc.ts")
|
||||
assert "Number(section.ford?.overflow_depth_m) > 0" in refresh
|
||||
# 물넘이포장 옛 자리 걷고 새 자리(걸친 측점까지) 얹기 = 형상 얹기 한 벌(323-4 · 모든 열쇠 걷고 다시 ·
|
||||
# 동작 시험 = test_323_4_attach_mirror)
|
||||
|
||||
@@ -77,7 +77,7 @@ def test_ts_defaults_off_and_stored_kept():
|
||||
)
|
||||
# 브라우저 재계산 인자 — 세션 선택 → 저장값 → 꺼짐
|
||||
assert "twoStageSlope: choice?.two_stage_slope ?? Boolean(design.two_stage_slope)," in _read(
|
||||
"B06_Section/B06_Section_Cross_Refresh.ts"
|
||||
"B06_Section/logic/B06_Section_Cross_Refresh_Calc.ts" # 511-B B-4 셈 몫
|
||||
)
|
||||
# 지금 값을 「사용자 선택」으로 박는 길은 없앰(346-2 · 사용자 확정 10-16) — 선택은 세션 것만
|
||||
assert "changeFromDesign" not in _read("B06_Section/B06_Section_UI_Page_Design_Sync.ts")
|
||||
|
||||
@@ -80,12 +80,15 @@ def test_판_다섯_줄_잠금_기본값_그대로():
|
||||
"follow": (["P5", "P6", "F1", "F2"], "pair_B5"),
|
||||
"stations": (["R5", "R6", "R7", "F3"], "pair_B2"),
|
||||
"profile": (["P1", "P2", "P3", "P4", "P8"], "pair_B3"),
|
||||
"cross": (["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"], "pair_B4"),
|
||||
}
|
||||
for key, (nos, why) in expected.items():
|
||||
group = by_key[key]
|
||||
assert group["nos"] == nos and group["why"] == why, key
|
||||
assert group["switchable"] is False and group["fixed"] == "both", key
|
||||
# cross = B-4 끝에 엶(511-B B-4 · 기본값 both 그대로)
|
||||
cross = by_key["cross"]
|
||||
assert cross["nos"] == ["G2", "G4", "C1", "C2", "C3", "C5", "C6", "F4", "F5", "F6"]
|
||||
assert cross["switchable"] is True and cross["fixed"] is None and cross["why"] == "cross"
|
||||
assert by_key["form_default"]["nos"] == ["F9"] and by_key["form_default"]["fixed"] == "both"
|
||||
assert "stages" not in by_key and "structure_pair" not in by_key
|
||||
assert set(calc_sites.SWITCHABLE) == {
|
||||
@@ -93,6 +96,7 @@ def test_판_다섯_줄_잠금_기본값_그대로():
|
||||
"corridor_mesh",
|
||||
"quantity",
|
||||
"carry",
|
||||
"cross",
|
||||
"fixed_browser",
|
||||
}
|
||||
committed = subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""PLAN 511-B B-4 (요구 320-4) — 계산 자리 cross = server 화면 길(검증_sub2 · 이 폴더 서버 · Playwright).
|
||||
|
||||
못박는 것
|
||||
- 판 both 와 server(판 응답만 바꿈 · 판 파일 안 씀)에서 페이지 묶음 cross 출력 · 저장 상세 설계(구조물 폐회로
|
||||
면적 층까지)가 0 차 — 저장 노선(`cross.refresh` · 다시 셈 창구) · 노선 초안 둔 채 다시 열기(`stage.cross`).
|
||||
- server 일 때만 그 id 를 서버에 묻고 · both 면 /calc 0.
|
||||
- 왕복 시간(`__aisloCalcServer.calls`)을 남김(PLAN 시간 줄).
|
||||
저장 안 함 · 세션만(노선 초안은 창과 함께 버림).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_511_b4_cross_server_browser.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
ROOT = HERE.parents[1]
|
||||
for path in (ROOT, HERE):
|
||||
if str(path) not in sys.path:
|
||||
sys.path.insert(0, str(path))
|
||||
|
||||
from test_511_b1_calc_node_stages import BASE, JS_DRAFT, PROJECT, _diff, screen # noqa: E402
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
not (ROOT / "config" / "node_modules" / "vite").is_dir(), reason="프론트엔드 의존성 미설치"
|
||||
)
|
||||
OUT = ROOT / "tmp" / "511b4_capture.json"
|
||||
|
||||
# 묶음 cross 출력 · 저장 상세 설계(면적 · 구조물 층) · 서버 부름 기록
|
||||
JS_STATE = r"""async () => {
|
||||
const urls = performance.getEntriesByType('resource').map((e) => e.name);
|
||||
const mod = async (re) => {
|
||||
const u = urls.find((n) => re.test(n));
|
||||
if (!u) throw new Error('모듈 없음 ' + re);
|
||||
return import(u);
|
||||
};
|
||||
const page = await mod(/design_pipeline_page\.ts/);
|
||||
const pid = localStorage.getItem('frd_current_project_id');
|
||||
const pipe = page.pagePipeline(pid);
|
||||
await page.reconcilePagePipeline(pid);
|
||||
await pipe.settled();
|
||||
const data = await mod(/design_page_data\.ts/);
|
||||
const routeId = data.pageRouteId(pid);
|
||||
const detail = (await mod(/B06_Section_Section_Canon\.ts/)).savedSectionDetail(pid, routeId);
|
||||
const shown = page.pipelineDetail(pid) ?? detail;
|
||||
const calls = (window.__aisloCalcServer?.calls ?? []).map((c) => ({
|
||||
id: c.id, roundMs: c.roundMs, nodeMs: c.nodeMs, wallMs: c.wallMs, bytes: c.bytes,
|
||||
}));
|
||||
return JSON.parse(JSON.stringify({
|
||||
cross: pipe.output('cross'),
|
||||
designs: (shown?.cross_sections ?? []).map((s) => ({
|
||||
chainage_m: s.chainage_m, design: s.design, samples: (s.samples ?? []).length,
|
||||
})),
|
||||
calls,
|
||||
runs: (window.__aisloPipeline?.runs ?? []).slice(-1).map((r) => r.outcome),
|
||||
}));
|
||||
}"""
|
||||
|
||||
|
||||
def _open(play, cred: dict, cross: str) -> tuple:
|
||||
chrome = play.chromium.launch(channel="msedge", headless=True)
|
||||
context = chrome.new_context(viewport={"width": 1600, "height": 1000})
|
||||
login = {k: cred[k] for k in ("email", "password")}
|
||||
assert context.request.post(f"{BASE}/api/auth/login/request", data=login, timeout=60000).ok
|
||||
|
||||
def board(route): # 판 응답만 바꿈(판 파일 안 씀)
|
||||
response = route.fetch()
|
||||
body = response.json()
|
||||
body["sites"]["cross"] = cross
|
||||
route.fulfill(response=response, json=body)
|
||||
|
||||
context.route("**/api/dashboard/calc-sites", board)
|
||||
page = context.new_page()
|
||||
page.add_init_script(
|
||||
f"localStorage.setItem('frd_current_project_id', '{PROJECT}');"
|
||||
" performance.setResourceTimingBufferSize(20000);"
|
||||
)
|
||||
return chrome, page
|
||||
|
||||
|
||||
def _shot(page) -> dict:
|
||||
page.goto(f"{BASE}/#/b05-profile", wait_until="domcontentloaded")
|
||||
page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000)
|
||||
page.wait_for_timeout(9000)
|
||||
return page.evaluate(JS_STATE)
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def shots():
|
||||
sync = pytest.importorskip("playwright.sync_api")
|
||||
cred = json.loads((ROOT / "tmp" / "aislo_cred.json").read_text(encoding="utf-8"))
|
||||
out = {}
|
||||
with sync.sync_playwright() as play:
|
||||
for cross in ("both", "server"):
|
||||
chrome, page = _open(play, cred, cross)
|
||||
try:
|
||||
out[f"saved_{cross}"] = _shot(page)
|
||||
page.evaluate(JS_DRAFT) # 노선 초안(가운데 교각점 옮김 · 세션만)
|
||||
page.wait_for_timeout(3000)
|
||||
page.reload(wait_until="domcontentloaded")
|
||||
page.wait_for_function("() => !!window.__aisloPipeline", timeout=90000)
|
||||
page.wait_for_timeout(9000)
|
||||
out[f"draft_{cross}"] = page.evaluate(JS_STATE)
|
||||
finally:
|
||||
chrome.close() # 세션 초안은 창과 함께 버림
|
||||
OUT.write_text(json.dumps(out, ensure_ascii=False), encoding="utf-8")
|
||||
return out
|
||||
|
||||
|
||||
@screen
|
||||
@pytest.mark.parametrize("case", ["saved", "draft"])
|
||||
def test_cross_server_화면_값_브라우저와_0차(shots, case):
|
||||
both, server = shots[f"{case}_both"], shots[f"{case}_server"]
|
||||
assert both["runs"] == ["done"] and server["runs"] == ["done"], (both["runs"], server["runs"])
|
||||
assert both["cross"]["byKey"], "cross 출력 없음"
|
||||
hit = _diff(server["cross"], both["cross"], "cross")
|
||||
assert hit is None, hit
|
||||
hit = _diff(server["designs"], both["designs"], "designs")
|
||||
assert hit is None, hit
|
||||
# 부름 — both = /calc 없음 · server = 그 길 id
|
||||
assert not [c for c in both["calls"] if "cross" in c["id"]]
|
||||
want = "cross.refresh" if case == "saved" else "stage.cross"
|
||||
assert any(c["id"] == want for c in server["calls"]), server["calls"]
|
||||
@@ -103,7 +103,10 @@ def test_계산_전에_넘어간다():
|
||||
"""
|
||||
router = _ROUTER_DESIGN.read_text(encoding="utf-8")
|
||||
assert router.count("cut_slope_ratio=") >= 3, "재계산 경로 중 값을 안 넘기는 곳이 있음"
|
||||
refresh = _REFRESH.read_text(encoding="utf-8")
|
||||
refresh = _REFRESH.read_text(
|
||||
encoding="utf-8"
|
||||
) # 셈 몫 = logic/B06_Section_Cross_Refresh_Calc.ts(511-B B-4 나눔)
|
||||
refresh += (_B06 / "logic" / "B06_Section_Cross_Refresh_Calc.ts").read_text(encoding="utf-8")
|
||||
assert "cutSlopeRatio: cutSlopeAt(" in refresh, "브라우저 재계산이 값을 안 넘김"
|
||||
# 계산이 끝난 뒤 베껴 붙이는 꼴이면 안 된다.
|
||||
assert not re.search(r"design\.cut_slope_ratio\s*=", refresh), "계산 뒤에 값만 덮어쓰고 있음"
|
||||
|
||||
@@ -108,7 +108,7 @@ def test_선택이_저장_patch_까지_실린다() -> None:
|
||||
for name in (
|
||||
"B06_Section_Cross_Design_Session.ts", # 캐시 칸
|
||||
"B06_Section_UI_Page_Design_Sync.ts", # 캐시에 쓰는 자리
|
||||
"B06_Section_Cross_Refresh.ts", # 브라우저 재계산이 읽는 자리
|
||||
"logic/B06_Section_Cross_Refresh_Calc.ts", # 브라우저 재계산이 읽는 자리(511-B B-4 셈 몫)
|
||||
"B06_Section_UI_Page_Persist.ts", # [저장]·[확정] patch
|
||||
):
|
||||
assert "ditch_choice" in _reads(name), f"{name} 에서 측구 선택이 빠졌다"
|
||||
|
||||
@@ -12,7 +12,8 @@ from B06_Section.B06_Section_Router_Design import USER_TOUCHED_KEYS
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS_PATH = ROOT / "B06_Section" / "logic" / "B06_Section_Design_Keys.ts" # 목록 한 벌(472-6)
|
||||
REFRESH = ROOT / "B06_Section" / "B06_Section_Cross_Refresh.ts"
|
||||
# 셈 몫(PRESERVED_KEYS) = logic 으로 나눔(511-B B-4)
|
||||
REFRESH = ROOT / "B06_Section" / "logic" / "B06_Section_Cross_Refresh_Calc.ts"
|
||||
|
||||
# 브라우저 목록에만 있고 서버 목록에는 없는 둘 — 부르는 쪽이 따로 붙인다.
|
||||
_CALLER_ADDED = {"status", "pavement_suggested"}
|
||||
|
||||
@@ -118,6 +118,10 @@ export const ui_locales_m1 = {
|
||||
B01_CalcSites_Why_pair_B2: ["짝 모으는 중 · B-2", "Merging pairs · B-2"],
|
||||
B01_CalcSites_Why_pair_B3: ["짝 모으는 중 · B-3", "Merging pairs · B-3"],
|
||||
B01_CalcSites_Why_pair_B4: ["짝 모으는 중 · B-4", "Merging pairs · B-4"],
|
||||
B01_CalcSites_Why_cross: [
|
||||
"끄면 횡단을 서버 Node 가 셈(잠잠 뒤 · 왕복) · DTM 아닌 지표면 = 브라우저",
|
||||
"Off = server Node calcs cross sections (after a pause · round trip) · non-DTM surface = browser",
|
||||
],
|
||||
B01_CalcSites_Why_pair_B5: ["짝 모으는 중 · B-5", "Merging pairs · B-5"],
|
||||
B01_CalcSites_Why_form_default: [
|
||||
"폼 기본값 도구 — 입력 칸 기본값 · 서버 다시 안 셈(B04 · B08 은 파이썬)",
|
||||
|
||||
Reference in New Issue
Block a user