Files
Aislo/B06_Section/B06_Section_Cross_Refresh.ts
T
eomsangdonandClaude Opus 5.5 d3315da993 refactor(472-3 B06 벽 · 기슭막이 · 배치): 구조물 기하 logic 분리 · 그림 파일은 그리기만
- 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>
2026-10-23 19:22:27 +09:00

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;
}