Files
Aislo/B06_Section/B06_Section_Cross_Refresh.ts
T
eomsangdonandClaude Opus 5 bc7b9e3a48 fix(B06): 유토곡선 영역 누락 — 재계산 결과와 계획선 정본이 어긋나던 것
두 곳이 같은 뿌리였음. 브라우저 재계산은 **편집이 반영된 계획고**로 횡단 설계를 만드는데
`design_profiles` 는 저장분 그대로라 두 값의 기준이 갈렸음.

1. 재계산 창구가 방금 푼 계획선을 **공유 캐시에도 얹도록** 함
   (`detail.longitudinal.design_profiles`). 이게 없으면 낡음 판정이 영원히 참이 되어
   B06 유토곡선이 통째로 빈 채로 남았음(사용자 보고). `profile_alignment`(base_pvi)는
   건드리지 않음 — 편집 델타의 기준선이라 편집분을 구워 넣으면 이중 적용됨.

2. B06 진입 정합이 **미저장 세션 편집을 못 보고 건너뛰던** 것. 서버에서 갓 받은 저장분
   끼리는 늘 일치해 「낡지 않음」이 되는데, B05가 세션에 남긴 편집은 그 안에 없음.
   세션 초안이 있으면 판정을 건너뛰고 무조건 맞추도록 함 — 재계산은 브라우저 안에서
   끝나 값이 쌈.

화면 검증(5174, 같은 시점 비교):
  수정 전 — B05 절토 4,774.1㎥ / 성토 16,715.5㎥, B06 절토 4,515.0㎥ / 성토 14,857.5㎥
  수정 후 — 양쪽 모두 절토 4,774.1㎥ / 성토 16,690.7㎥ (일치)
B06 유토곡선 영역도 곡선이 다시 그려짐(문구 없음).

전체 테스트 183건 통과, `tsc --noEmit` 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-03 20:49:25 +09:00

210 lines
10 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`
* 의 미러)로 옮겼고, 여기서는 **입력을 모아 전 측점을 돌리고 제자리 반영**만 한다.
* 서버 프리뷰는 선형 저장분이 없어 계획고를 못 푸는 **옛 데이터 폴백**으로만 남는다.
*
* ⚠ 두 벌 계산 주의 — 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 { computeCrossDesign } from "@util/common_util_cross_design";
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
import { readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
import {
effectiveStandardCross,
readRockBoundaryDefault,
readStandardCrossSession,
} from "./B06_Section_UI_Standard_Panel";
import {
buildAlignment,
planElevationAt,
toAlignmentBase,
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
/** 계획선 편집 델타 — 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;
/**
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
*/
shouldApply?: () => boolean;
}
/** 다시 계산해도 **살려 두는 값** — 화면 조작으로만 생기거나 상태를 나르는 필드다. */
const PRESERVED_KEYS = [
"status",
"pavement_suggested",
"display_half_width_m",
"inlet_structure",
"basin_adjust",
"revet_adjust",
"ford_adjust",
"box_adjust",
"extra_wall_counts",
"extra_spans",
"revet_link_detached",
"revet_follow_grade",
] as const;
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"]>;
}
/**
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
*/
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
const local = refreshLocally(input);
if (local !== null) return local;
return refreshFromServer(input);
}
/**
* 브라우저 안에서 전 측점을 다시 계산한다(정상 경로).
*
* 계획고는 저장된 자동 선형(`profile_alignment.base_pvi`)에 편집 델타를 얹어 **여기서**
* 푼다 — B05 편집 중에는 `detail.longitudinal.design_profiles` 가 아직 옛 계획선이라
* 그걸 쓰면 한 박자 늦은 값이 된다. 계산 재료(선형 저장분·표준단면)를 갖추지 못하면
* `null` 을 돌려 서버 폴백으로 넘긴다.
*/
function refreshLocally(input: CrossRefreshInput): number[] | null {
const { projectId, detail, edits } = input;
const stored = readAlignment(detail.longitudinal);
if (!stored) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
const standard = effectiveStandardCross(projectId) as StandardCrossSectionSpec | null;
if (!standard) return null; // 컨텍스트를 아직 못 받음 — 이번만 서버로.
const alignment = buildAlignment(toAlignmentBase(stored), {
station_offsets: edits.station_offsets ?? {},
curve_radii: edits.curve_radii ?? {},
});
// 방금 푼 계획선을 **공유 캐시에도 얹는다**. 여기서 만드는 횡단 설계는 편집이 반영된
// 계획고 기준인데 `design_profiles` 만 저장분으로 남으면 두 값의 기준이 어긋나, 낡음
// 판정이 영원히 참이 되어 유토곡선이 빈 채로 남는다(2026-09-03 사용자 보고: B05 편집 중
// 문구만 뜸 → B06 유토곡선 영역 누락). `profile_alignment`(base_pvi)는 **건드리지 않는다**
// — 편집 델타의 기준선이라 편집분을 구워 넣으면 다음 편집에서 이중 적용된다.
detail.longitudinal.design_profiles = [
toDesignProfile(alignment, detail.longitudinal.design_profiles?.[0]),
];
const rockOffsets = readRockBoundarySession(projectId, input.routeId) ?? {};
const rockDefault = readRockBoundaryDefault(projectId);
const updated: number[] = [];
for (const section of detail.cross_sections) {
const previous = section.design;
if (!previous) continue; // 설계가 없는 측점은 서버 기본 설계가 붙을 때까지 둔다.
const design = previous as unknown as Record<string, unknown>;
// 암 경계는 세션 조정값 → 저장분 → config 기본값 순 — 서버
// `recompute_designs_for_alignment` 의 우선순위와 같다.
const sessionOffset = rockOffsets[section.chainage_m.toFixed(3)];
const storedOffset = design.rock_boundary_offset_m;
const rockBoundaryOffsetM =
typeof sessionOffset === "number"
? sessionOffset
: typeof storedOffset === "number"
? storedOffset
: rockDefault;
let next;
try {
next = computeCrossDesign(
section.samples ?? [],
planElevationAt(alignment, section.chainage_m),
{
groundType: typeof design.ground_type === "string" ? design.ground_type : "ripping_rock",
sectionMode: typeof design.section_mode === "string" ? design.section_mode : "left_cut",
ditchSide: typeof design.ditch_side === "string" ? design.ditch_side : null,
// 저장분은 측구가 없으면 `ditch_type: null` 이다 — 서버와 같이 기본형으로 되돌린다.
ditchType: typeof design.ditch_type === "string" ? design.ditch_type : "standard",
paved: Boolean(design.paved),
standard,
rockBoundaryOffsetM,
twoStageSlope:
design.two_stage_slope === undefined ? true : Boolean(design.two_stage_slope),
ditchEnabled: typeof design.ditch_enabled === "boolean" ? design.ditch_enabled : null,
// 세월교 월류 하강은 계획선 편집으로 바뀌지 않는다 — 저장분 값을 그대로 잇는다.
surfaceDropM: typeof design.surface_drop_m === "number" ? design.surface_drop_m : 0,
},
);
} catch {
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
}
section.design = preserveUserFields(
next as unknown as NonNullable<CrossSection["design"]>,
previous,
);
updated.push(section.chainage_m);
}
return updated;
}
/** 옛 데이터 폴백 — 선형 저장분이 없어 브라우저가 계획고를 풀 수 없을 때만 쓴다. */
async function refreshFromServer(input: CrossRefreshInput): Promise<number[]> {
const { projectId, routeId, detail, edits, shouldApply } = input;
const response = await previewCrossDesigns(
projectId,
routeId,
edits,
readStandardCrossSession(projectId) ?? undefined,
{
fullDesigns: true,
rockBoundaryOffsets: readRockBoundarySession(projectId, routeId),
},
);
if (shouldApply && !shouldApply()) return [];
const designByChainage = new Map(
response.designs.map((entry) => [entry.chainage_m.toFixed(3), entry.design]),
);
const updated: number[] = [];
for (const section of detail.cross_sections) {
const next = designByChainage.get(section.chainage_m.toFixed(3));
if (!next) continue;
section.design = preserveUserFields(
next as NonNullable<CrossSection["design"]>,
section.design,
);
updated.push(section.chainage_m);
}
return updated;
}