- logic 새 파일 다섯 — Cross_Wall_Geom(벽 1매) · Cross_Wall_Hatch_Geom(무늬 띠 · 물빼기 · 도수로 띠 · 원지반선) · Cross_Excavation_Geom(벽 터파기) · Cross_Design_Surface_Geom(암선 · 포장층) · Cross_Revetment_Geom(독립 기슭막이 배치) - logic 으로 옮김 — Body_Guard → Cross_Body_Guard · Structure_Layouts · Route_Carry_Patch(내용 그대로 · import 경로만) - inferStationInterval → common_util_station(글자표 없는 자리 · common_util_svg 는 다시 내보냄) — Node 묶음 ui_template_locale 끌림 끊음 - 부르는 쪽 import 줄 · 등록부 ts 경로 · 시험 · 도우미 경로 - test_472_3 GEOMETRY_PULLS 다섯 줄 걷음 · Wall_Hatch · Excavation · Design_Surface 세 줄은 Culvert · Box · Ford 그림 파일(518-2)이 그리기 함수로 부르는 동안 남김 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
297 lines
16 KiB
TypeScript
297 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_Cross_Refresh.ts
|
|
* 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다.
|
|
*
|
|
* ── 계산은 브라우저 안에서 끝난다 (2026-09-03 사용자 확정) ───────────
|
|
* 사용자 조작 중의 계산은 서버로 나가지 않는다. 조작은 세션 캐시에 쌓이고 화면은 즉시
|
|
* 따라오며, 영구저장소는 [저장]·[확정]에서만 건드린다. 예전에는 계획고가 바뀔 때마다
|
|
* `POST …/cross-design/preview` 로 전 측점 횡단을 서버에 물어, 왕복이 조작 속도를
|
|
* 지배했다(2026-09-03 사용자 보고: 「종단을 바꾸면 업데이트가 느리다」).
|
|
*
|
|
* 그래서 설계 계산은 `common_util_cross_design.ts`(파이썬 `B06_Section_Engine_Design.py`
|
|
* 의 미러)로 옮겼고, 여기서는 **입력을 모아 전 측점을 돌리고 제자리 반영**만 한다.
|
|
* 옛 데이터 서버 프리뷰(`/cross-design/preview`)는 걷음(511-B B-4) — 선형 저장분이 없으면 셈 없음.
|
|
*
|
|
* ⚠ 두 벌 계산 주의 — TS 미러(`common_util_cross_design*.ts`)와 파이썬 엔진
|
|
* (`B06_Section_Engine_Design.py`·`B06_Section_Engine_Areas.py`)은 한 벌이다.
|
|
* 한쪽만 고치면 화면과 저장본이 갈린다. 회귀 테스트:
|
|
* `tmp/tests/test_b06_cross_design_mirror.py`
|
|
*
|
|
* ── 왜 창구가 하나여야 하는가 (2026-09-03 실측) ──────────────────────
|
|
* 재계산 호출이 두 벌이던 시절, 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥`
|
|
* ↔ B05 `4,526.8㎥` 로 갈렸다. 원인은 인자였다 — 표준 단면값과 암 경계 오프셋이 빠지면
|
|
* 서버가 다른 설계를 그린다. 입력 수집·계산·제자리 반영을 여기 한 곳에 모아 둔다. 세션
|
|
* 편집값은 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 같은 값을 쓴다.
|
|
* ========================================================================== */
|
|
|
|
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
|
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
|
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";
|
|
import {
|
|
crossDesignChoices,
|
|
readBermDepths,
|
|
readShoulderExtends,
|
|
} from "./B06_Section_Cross_Design_Session";
|
|
import { effectiveStandardCross, readRockBoundaryDefault } from "./B06_Section_UI_Standard_Panel";
|
|
import {
|
|
buildAlignment,
|
|
toAlignmentBase,
|
|
} from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
|
import type { ProfileAlignment } from "../B05_Profile/logic/B05_Profile_UI_Profile_Alignment";
|
|
import { readAlignment, toDesignProfile } from "../B05_Profile/logic/B05_Profile_UI_Profile_Data";
|
|
import { useStationKeys } from "./B06_Section_Station_Keys";
|
|
import { hasStaleDesigns, staleKey } from "./B06_Section_UI_Section_Common";
|
|
import { applyStructureAreaRows, structureAreaRows } from "./logic/B06_Section_Structure_Layouts";
|
|
import { calcOnServerNow } from "../A00_Common/design/design_calc_sites";
|
|
import {
|
|
areasRequest,
|
|
calcChecked,
|
|
calcOnServerOnly,
|
|
} from "../A00_Common/design/design_calc_server";
|
|
import { withPipeOptions } from "./B06_Section_Section_Adjusts";
|
|
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 {
|
|
station_offsets: Record<string, number>;
|
|
curve_radii: Record<string, number>;
|
|
}
|
|
|
|
export interface CrossRefreshInput {
|
|
projectId: string;
|
|
routeId: number;
|
|
/** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */
|
|
detail: SectionDetailResponse;
|
|
edits: CrossRefreshEdits;
|
|
/** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */
|
|
alignment?: ProfileAlignment | null;
|
|
}
|
|
|
|
/** 계산이 만들지 않는 **사용자 값** — 목록 한 벌 = `logic/B06_Section_Design_Keys`(서버 Node 입구도 · 472-6). */
|
|
export { USER_TOUCHED_KEYS };
|
|
|
|
/**
|
|
* 계획선 한 벌(342-1) — 종단도가 화면에 그린 선(서버 측점 대신 관 지점 · 옮긴 꼭짓점)을 맡겨 두는 자리.
|
|
* 다시 셈이 저장분 선으로 계획고를 적으면 그리기 판정(화면 선)과 mm 단위로 갈려 늘 낡음이 됐다
|
|
* (유토곡선 「다시 계산하는 중」 머묾 · 341 부록 2 4장). B05 · B06 · 3D 가 모두 이 선을 받는다.
|
|
*/
|
|
type ScreenLine = (routeId: number) => ProfileAlignment | null;
|
|
let screenLine: ScreenLine | null = null;
|
|
|
|
/** 종단도가 자기 선을 맡김 — 돌려준 함수로 거둠(자기 것일 때만). */
|
|
export function holdScreenAlignment(line: ScreenLine): () => void {
|
|
screenLine = line;
|
|
return () => {
|
|
if (screenLine === line) screenLine = null;
|
|
};
|
|
}
|
|
|
|
/** 화면 선을 얹은 견줌 꼴 — 화면 선이 없으면 상세 그대로. */
|
|
function againstScreen(detail: SectionDetailResponse, routeId: number) {
|
|
const screen = screenLine?.(routeId);
|
|
if (!screen) return detail;
|
|
return {
|
|
longitudinal: {
|
|
design_profiles: [toDesignProfile(screen, detail.longitudinal.design_profiles?.[0])],
|
|
},
|
|
cross_sections: detail.cross_sections,
|
|
};
|
|
}
|
|
|
|
/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */
|
|
export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean {
|
|
return hasStaleDesigns(againstScreen(detail, routeId));
|
|
}
|
|
|
|
/** 화면 선 기준 낡은 측점 지문(모두 맞으면 "") — 상세 가게가 같은 낡음에 cross 를 되풀이하지 않게(387-8). */
|
|
export function staleKeyAgainstScreen(detail: SectionDetailResponse, routeId: number): string {
|
|
return staleKey(againstScreen(detail, routeId));
|
|
}
|
|
|
|
/**
|
|
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
|
|
* 돌려주는 값은 설계 · 면적이 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
|
|
* 부르는 곳은 계산 묶음 cross 단계 하나(`design_shared` refreshSavedCross · 387-4) — 화면은 `pipelineCross` 로 부탁.
|
|
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
|
|
*/
|
|
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
|
|
// 노선 초안 동안 묶음 새 노선 상세(측점 모두 `preview`) = 묶음 cross 결과 그대로 — 브라우저 다시 셈이 다른
|
|
// 값을 안 냄(넓힌 표본 · 화면 선이 다름 · 349-0o). 관 · 구조물 초안이 미리 뜬 측점 몇 개(`preview`)는 다시 셈
|
|
const sections = input.detail.cross_sections;
|
|
if (sections.length && sections.every((section) => section.preview)) return [];
|
|
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 측점).
|
|
// 계산 자리 quantity = server 면 잠잠 뒤 서버 값만 · 밀린 부름은 안 얹음(새 부름이 얹음 · 511-A)
|
|
const rows = calcOnServerNow("quantity")
|
|
? await calcOnServerOnly<ReturnType<typeof structureAreaRows>>(
|
|
"quantity",
|
|
"F7.saved",
|
|
areasRequest(input.detail.cross_sections),
|
|
)
|
|
: calcChecked("quantity", "structure.layouts", structureAreaRows, [
|
|
input.detail.cross_sections,
|
|
]);
|
|
if (rows) applyStructureAreaRows(input.detail.cross_sections, rows);
|
|
return sections
|
|
.filter((section) => JSON.stringify(section.design) !== before.get(section))
|
|
.map((section) => section.chainage_m);
|
|
}
|
|
|
|
/**
|
|
* 암 경계 세션 오프셋을 **자릿수에 안 휘둘리게** 읽는다.
|
|
*
|
|
* 저장하는 쪽(`createRockBoundaryStore`)은 키를 `toFixed(2)` 로 쓰고, 서버는 받은 키를
|
|
* 숫자로 바꿔 비교했다. 로컬 계산이 문자열 키를 그대로 맞추려다 자릿수가 달라 세션값을
|
|
* 통째로 놓쳤고, 그래서 B06 을 다녀오기 전과 후의 절·성토가 달랐다(2026-09-03 실측
|
|
* 성토 16,715.5㎥ ↔ 16,690.7㎥). 키를 숫자로 되돌려 0.01m 단위로 맞춘다.
|
|
*/
|
|
function readRockOffsets(projectId: string, routeId: number): Map<number, number> {
|
|
const raw = readRockBoundarySession(projectId, routeId) ?? {};
|
|
const offsets = new Map<number, number>();
|
|
for (const [key, value] of Object.entries(raw)) {
|
|
const chainage = Number(key);
|
|
if (Number.isFinite(chainage) && typeof value === "number" && Number.isFinite(value)) {
|
|
offsets.set(rockKey(chainage), value);
|
|
}
|
|
}
|
|
return offsets;
|
|
}
|
|
|
|
/**
|
|
* 측점별 암 절토 경사비 세션값 — 암 경계선(`readRockOffsets`)과 같은 방식으로 읽는다.
|
|
*
|
|
* **0 은 「표준값을 씀」**이다(되돌리기). 그래서 0 을 거르지 않고 그대로 싣는다 — 거르면
|
|
* 저장분에 남은 옛 사용자 값이 되살아난다.
|
|
*/
|
|
function readCutSlopeRatios(projectId: string, routeId: number): Map<number, number> {
|
|
const out = new Map<number, number>();
|
|
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);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* 브라우저 안에서 전 측점을 다시 계산한다(정상 경로).
|
|
*
|
|
* 계획고는 저장된 자동 선형(`profile_alignment.base_pvi`)에 편집 델타를 얹어 **여기서**
|
|
* 푼다 — B05 편집 중에는 `detail.longitudinal.design_profiles` 가 아직 옛 계획선이라
|
|
* 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면
|
|
* `null` 을 돌려 서버 폴백으로 넘긴다.
|
|
*/
|
|
/** 세션 · 가게에서 셈 입력을 모음(`RefreshInputs`) — 선형 저장분 · 표준값이 없으면 null(셈 없음). */
|
|
function gatherRefresh(input: CrossRefreshInput): RefreshInputs | null {
|
|
const { projectId, detail, edits } = input;
|
|
const stored = readAlignment(detail.longitudinal);
|
|
// 선 = 화면이 쥔 선 → 종단도가 맡긴 선 → 저장분 + 편집(종단도가 아직 없을 때) · 342-1
|
|
const alignment =
|
|
input.alignment ??
|
|
screenLine?.(input.routeId) ??
|
|
(stored
|
|
? buildAlignment(toAlignmentBase(stored), {
|
|
station_offsets: edits.station_offsets ?? {},
|
|
curve_radii: edits.curve_radii ?? {},
|
|
})
|
|
: null);
|
|
if (!alignment) return null; // 선형 저장분이 없는 옛 데이터 — 셈 없음(옛 서버 폴백 걷음 · 511-B B-4)
|
|
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
|
|
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 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;
|
|
}
|