feat(B05,B06): 계획선 편집 중 횡단 재계산을 브라우저에서 수행 — 서버 왕복 제거
사용자 조작 중의 계산은 브라우저 안에서 끝나야 함(2026-09-03 사용자 확정). 계획고가 바뀔 때마다 전 측점 횡단 계산이 `POST …/cross-design/preview` 로 나가 왕복이 조작 속도를 지배했음. - `refreshCrossDesigns` 를 로컬 계산으로 교체. 계획고는 저장 선형(`base_pvi`)에 편집 델타를 얹어 TS `buildAlignment` 로 여기서 풂 — 호출부(B05·B06) 시그니처는 그대로라 단일 창구가 유지됨. 서버 프리뷰는 선형 저장분이 없는 옛 데이터 폴백으로만 남음. - 표준단면 config 기본값·암 경계 기본 오프셋을 `sections/context` 응답에서 세션에 기억 (`rememberStandardDefaults`·`rememberRockBoundaryDefault`). 프론트에 config 수치를 복제하지 않으면서 패널 없는 B05 도 같은 값으로 계산함. - 디바운스 250 → 60ms — 왕복이 사라져 손을 떼는 즉시 유토곡선이 따라옴. - 낡음 판정을 `hasStaleDesigns` 로 바꿔 첫 건에서 중단하고, 계획선 유효 샘플을 샘플 배열당 한 번만 걸러 이진탐색(그리기 프레임당 2.33ms → 0.07ms 실측). - 파이썬 엔진 2파일·프리뷰 라우터에 「TS 짝 파일과 한 벌」 경고 블록 추가. 검증: `tmp/tests/test_b06_cross_design_mirror.py` 10건 — 토사/암 2단계/L형 측구/포장/ 세월교 하강/사면 미닫힘 등에서 파이썬과 TS 결과가 면적·설계선까지 일치. 전체 183건 통과. `tsc --noEmit` 통과. 실측: route 127(측점 128) 전 측점 로컬 재계산 7.5ms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,10 @@ import { createSelectionSync } from "./B05_Profile_UI_Selection";
|
||||
import { createStructuresBridge } from "./B05_Profile_UI_Page_Structures";
|
||||
import { createRouteViewer } from "./B05_Profile_UI_Viewer";
|
||||
import { irregularStationId, isPipeStation } from "./B05_Profile_UI_IrregularStations";
|
||||
import {
|
||||
rememberRockBoundaryDefault,
|
||||
rememberStandardDefaults,
|
||||
} from "../B06_Section/B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
fetchSectionContext,
|
||||
type SectionDetailResponse,
|
||||
@@ -632,6 +636,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
haulLimits: sectionContext.haul_equipment_limits,
|
||||
naturalSpoilMinSlope: sectionContext.natural_spoil_min_ground_slope ?? undefined,
|
||||
});
|
||||
// 표준횡단 config 기본값도 같은 방식으로 기억한다 — B05에는 「표준 횡단면 설정」
|
||||
// 패널이 없지만, 계획선 편집 중 횡단 재계산이 브라우저에서 돌아 이 값이 필요하다
|
||||
// (2026-09-03 로컬 계산 전환). 프론트에 config 사본을 두지 않으려는 같은 원칙이다.
|
||||
rememberStandardDefaults(activeProjectId, sectionContext.standard_cross_section);
|
||||
rememberRockBoundaryDefault(activeProjectId, sectionContext.rock_boundary_default_offset_m);
|
||||
restorePanel(latestResponse);
|
||||
renderLatest(latestResponse);
|
||||
latest = latestResponse;
|
||||
|
||||
@@ -127,7 +127,7 @@ export interface RouteMassHaulDrawParams {
|
||||
/**
|
||||
* 저장된 횡단이 지금 계획선과 어긋나 재계산이 예약된 상태. 그 사이의 면적은 **옛
|
||||
* 계획고로 만든 값**이라 그리면 사용자가 옛 그림을 보게 된다(2026-09-03 사용자 확정:
|
||||
* 「새 값만 보여주기」). Panel이 `staleDesignChainages`로 판정해 넘긴다.
|
||||
* 「새 값만 보여주기」). Panel이 `hasStaleDesigns`로 판정해 넘긴다.
|
||||
*/
|
||||
pendingRecalc?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ import { hasLegacyAlignment, readAlignment } from "./B05_Profile_UI_Profile_Data
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { saveProfileAlignment } from "./B05_Profile_Api_Fetch";
|
||||
import { staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { hasStaleDesigns } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import {
|
||||
blocksMinCover,
|
||||
findMinCoverViolations,
|
||||
@@ -80,9 +80,11 @@ const MASSHAUL_HANDLE_GUTTER_PX = 13;
|
||||
const MIN_PANEL_HEIGHT = 180;
|
||||
/** 상한은 3D 뷰포트가 완전히 가려지지 않도록 부모 높이의 90%까지만 허용한다. */
|
||||
const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
/** 계획고 편집 후 횡단 재계산을 서버에 묻기까지 기다리는 시간(ms).
|
||||
* ▲/▼ 길게 누르기(초당 10회)로 요청이 쏟아지지 않게 마지막 값만 보낸다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 250;
|
||||
/** 계획고 편집 후 횡단을 다시 계산하기까지 기다리는 시간(ms).
|
||||
* ▲/▼ 길게 누르기(초당 10회)·끌기에서 매 프레임 다시 돌지 않게 마지막 값만 계산한다.
|
||||
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
|
||||
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 60;
|
||||
|
||||
/**
|
||||
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
|
||||
@@ -582,7 +584,7 @@ export function createRouteProfilePanel(
|
||||
// 저장된 횡단 설계가 옛 계획고로 굳어 있으면 진입 즉시 한 번 맞춘다 —
|
||||
// 지금까지는 B06에 들어가야만 고쳐져, B05의 횡단 기준 유토곡선과 3D
|
||||
// 예상형상이 옛 설계선을 그대로 썼다(2026-08-23 사용자 보고 · 실측 확인).
|
||||
if (staleDesignChainages(nextDetail).length) scheduleCrossPreview();
|
||||
if (hasStaleDesigns(nextDetail)) scheduleCrossPreview();
|
||||
},
|
||||
/** 라이브 계획선 샘플(편집 반영분) — 3D 측점 바·코리도가 이걸 본다(2026-08-23).
|
||||
* 편집 전·base 없음이면 null — 호출부는 정본 design_profiles로 폴백한다. */
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
} from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import { LONG_PAD, staleDesignChainages } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import { hasStaleDesigns, LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import { normalizedLongitudinal, toDesignProfile } from "./B05_Profile_UI_Profile_Data";
|
||||
import {
|
||||
@@ -328,7 +328,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
crossSections: detail.cross_sections,
|
||||
// 저장된 횡단이 지금 계획선과 어긋나면 그 면적은 옛 계획고로 만든 값이다 —
|
||||
// Panel이 재계산을 예약해 두므로 유토곡선은 그 결과만 그린다(2026-09-03 사용자 확정).
|
||||
pendingRecalc: staleDesignChainages(detail).length > 0,
|
||||
pendingRecalc: hasStaleDesigns(detail),
|
||||
axis: {
|
||||
maxChainageM: maxChainageOf(longitudinal),
|
||||
// 종단 그래프의 `chainageMapper`와 정확히 같은 매핑이 되도록 반 칸 들여쓰기를
|
||||
|
||||
@@ -2,24 +2,44 @@
|
||||
* B06_Section_Cross_Refresh.ts
|
||||
* 「현재 계획선에 맞춘 횡단 재계산」 **단일 창구** — B05·B06이 같은 입력으로 같은 결과를 본다.
|
||||
*
|
||||
* ── 왜 필요한가 (2026-09-03 사용자 지시) ─────────────────────────────
|
||||
* 유토곡선은 두 화면이 같은 데이터를 보여 주는 기능인데, 재계산 호출이 두 벌이라 값이
|
||||
* 갈렸다. 실측 — 같은 프로젝트·같은 시점에 B06 `절토(자연) 3,704.6㎥` ↔ B05 `4,526.8㎥`.
|
||||
* 원인은 인자였다:
|
||||
* · B06: `previewCrossDesigns(..., standardPanel.getValues(), { fullDesigns, rockBoundaryOffsets })`
|
||||
* · B05: `previewCrossDesigns(..., undefined, { fullDesigns })`
|
||||
* 표준 단면값과 암 경계 오프셋이 빠지면 서버가 다른 설계를 그려 단면적이 달라진다.
|
||||
* 보존하는 사용자 부속값 목록도 서로 달라(B05는 2개, B06은 7개) B05를 거치면 기슭막이
|
||||
* 조정 같은 값이 사라졌다.
|
||||
* ── 계산은 브라우저 안에서 끝난다 (2026-09-03 사용자 확정) ───────────
|
||||
* 사용자 조작 중의 계산은 서버로 나가지 않는다. 조작은 세션 캐시에 쌓이고 화면은 즉시
|
||||
* 따라오며, 영구저장소는 [저장]·[확정]에서만 건드린다. 예전에는 계획고가 바뀔 때마다
|
||||
* `POST …/cross-design/preview` 로 전 측점 횡단을 서버에 물어, 왕복이 조작 속도를
|
||||
* 지배했다(2026-09-03 사용자 보고: 「종단을 바꾸면 업데이트가 느리다」).
|
||||
*
|
||||
* 그래서 **입력 수집·서버 호출·제자리 반영**을 여기 한 곳으로 모은다. 세션 편집값은
|
||||
* 패널이 아니라 세션 저장소에서 직접 읽으므로, 패널이 없는 B05도 B06과 같은 값을 보낸다.
|
||||
* 그래서 설계 계산은 `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 { readStandardCrossSession } from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
effectiveStandardCross,
|
||||
readRockBoundaryDefault,
|
||||
readStandardCrossSession,
|
||||
} from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
buildAlignment,
|
||||
planElevationAt,
|
||||
toAlignmentBase,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import { readAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Data";
|
||||
|
||||
/** 계획선 편집 델타 — B05 `AlignmentEdits`와 저장분 `profile_alignment.edits`가 같은 모양이다. */
|
||||
export interface CrossRefreshEdits {
|
||||
@@ -34,28 +54,39 @@ export interface CrossRefreshInput {
|
||||
detail: SectionDetailResponse;
|
||||
edits: CrossRefreshEdits;
|
||||
/**
|
||||
* 응답이 도착한 시점에 **아직 이 결과를 써도 되는지** 묻는다(false면 반영하지 않는다).
|
||||
* 끌기 중에는 요청이 겹치므로, 늦게 온 옛 응답이 새 설계를 덮지 않게 하는 문지기다.
|
||||
* 결과를 **아직 써도 되는지** 묻는다(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;
|
||||
return {
|
||||
...next,
|
||||
inlet_structure: previous.inlet_structure,
|
||||
basin_adjust: previous.basin_adjust,
|
||||
revet_adjust: previous.revet_adjust,
|
||||
extra_wall_counts: previous.extra_wall_counts,
|
||||
extra_spans: previous.extra_spans,
|
||||
revet_link_detached: previous.revet_link_detached,
|
||||
revet_follow_grade: previous.revet_follow_grade,
|
||||
};
|
||||
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"]>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,8 +94,84 @@ function preserveUserFields(
|
||||
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
|
||||
*/
|
||||
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 ?? {},
|
||||
});
|
||||
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;
|
||||
// full_designs — 설계선 좌표까지 받아야 3D 코리도·횡단도가 편집 즉시 같은 형상이 된다.
|
||||
const response = await previewCrossDesigns(
|
||||
projectId,
|
||||
routeId,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""B06 횡단 단면적 적분 — 절·성토 면적과 절토의 토사/암반 분리.
|
||||
|
||||
⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠
|
||||
짝: `common_util/common_util_cross_design_areas.ts`
|
||||
회귀 테스트: `tmp/tests/test_b06_cross_design_mirror.py` (고칠 때 같이 돌릴 것).
|
||||
두 벌인 이유는 `B06_Section_Engine_Design.py` 머리 참조.
|
||||
|
||||
`_Engine_Design.py`가 700줄을 넘겨, 「설계선을 어떻게 세우나」(그쪽)와 「그 선과 지반 사이
|
||||
넓이를 어떻게 재나」(여기)로 갈랐다. 두 함수 모두 지반선과 설계선의 **차이 배열**만 받으므로
|
||||
설계 로직을 전혀 모른다 — 그래서 따로 떼어 검산하기도 쉽다.
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
"""B06 측점 표준횡단 설계 계산 엔진.
|
||||
|
||||
⚠⚠ TS 짝 파일과 **한 벌**이다 — 한쪽만 고치면 화면과 저장본이 갈린다 ⚠⚠
|
||||
짝: `common_util/common_util_cross_design.ts` (`computeCrossDesign`)
|
||||
면적 적분은 `B06_Section_Engine_Areas.py` ↔ `common_util_cross_design_areas.ts`.
|
||||
회귀 테스트가 두 구현을 같은 입력으로 실제 비교한다:
|
||||
`tmp/tests/test_b06_cross_design_mirror.py` — 어느 쪽을 고치든 반드시 같이 돌릴 것.
|
||||
|
||||
왜 두 벌인가: 사용자가 계획선을 만지는 동안의 계산은 **브라우저 안에서 끝나야
|
||||
한다**(2026-09-03 사용자 확정). 계획고가 바뀔 때마다 서버에 전 측점 횡단을 물으면
|
||||
왕복이 조작 속도를 지배했다. 이 파이썬 엔진은 자동설계 체인·[저장]·[확정]·도면
|
||||
산출의 **정본**이고, TS 미러는 조작 중 화면용이다. 값이 갈리면 화면과 납품물이
|
||||
달라지므로 새 필드·상수·판정을 더할 때 양쪽을 함께 고칠 것.
|
||||
|
||||
지반유형(토사/리핑암/발파암)과 단면유형(좌절/우절/양절/양성)에 따라 표준횡단
|
||||
설계선을 구성하고, 지반선과의 차이로 절·성토 단면적을 산출한다. B06에서 사용자가
|
||||
버튼을 누를 때 즉시 호출되며, 여기서 나온 값은 잠정치로 저장되고 B07 상세설계에서
|
||||
|
||||
@@ -516,7 +516,12 @@ async def preview_cross_designs(
|
||||
) -> CrossDesignPreviewResponse | JSONResponse:
|
||||
"""계획선 편집 델타로 계획선과 전 측점 횡단 설계를 다시 계산해 돌려준다(저장 없음).
|
||||
|
||||
B05에서 계획고를 끄는 동안 횡단 단면적·유토곡선이 함께 움직이게 하는 프리뷰 경로다.
|
||||
⚠ **평상시 조작 경로가 아니다**(2026-09-03 이후). 계획선을 만지는 동안의 재계산은
|
||||
브라우저가 직접 한다(`common_util/common_util_cross_design.ts`) — 조작 중 계산이
|
||||
서버로 나가면 왕복이 조작 속도를 지배하기 때문이다(사용자 확정). 이 엔드포인트는
|
||||
선형 저장분이 없어 브라우저가 계획고를 풀 수 없는 **옛 데이터 폴백**으로만 남는다
|
||||
(`B06_Section_Cross_Refresh.refreshCrossDesigns`).
|
||||
|
||||
영속화는 각 페이지의 임시저장·확정이 맡는다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
|
||||
@@ -31,13 +31,17 @@ import {
|
||||
import { maxToeFitHalfWidth } from "./B06_Section_UI_Cross_Fit";
|
||||
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { type CrossDesignChange, createSectionView } from "./B06_Section_UI_Section_View";
|
||||
import { staleDesignChainages } from "./B06_Section_UI_Section_Common";
|
||||
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
|
||||
import { revetWallSpec } from "./B06_Section_UI_Cross_Culvert_Const";
|
||||
import {
|
||||
applyPipeOptionsToCache,
|
||||
type PipeOptionsContext,
|
||||
} from "./B06_Section_UI_Page_Pipe_Options";
|
||||
import { createStandardPanel, type StandardPanelController } from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
createStandardPanel,
|
||||
rememberRockBoundaryDefault,
|
||||
type StandardPanelController,
|
||||
} from "./B06_Section_UI_Standard_Panel";
|
||||
import {
|
||||
createB06StructuresPanel,
|
||||
isWallSelecting,
|
||||
@@ -285,9 +289,9 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
*/
|
||||
async function reconcileStaleDesigns(): Promise<void> {
|
||||
if (!sectionDetail || !projectId || currentRouteId === null) return;
|
||||
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 staleDesignChainages — 계획고 어긋남 +
|
||||
// 낡음 판정은 B05와 **같은 규칙** 하나뿐이다(공용 판정 — 계획고 어긋남 +
|
||||
// 옛 암 2단계 필드 누락). 조건이 갈리면 같은 데이터가 두 화면에서 다른 값이 된다.
|
||||
if (!staleDesignChainages(sectionDetail).length) return;
|
||||
if (!hasStaleDesigns(sectionDetail)) return;
|
||||
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
|
||||
// (CLAUDE.md 5장).
|
||||
try {
|
||||
@@ -588,6 +592,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
// 표준 횡단면 설정 패널 장착(세션값 우선, 없으면 config 기본값).
|
||||
// 횡단 반폭 입력은 [전체 측점 반영] 버튼 위로 들어간다(2026-08-06 사용자 지시).
|
||||
standardPanel = createStandardPanel(projectId, context.standard_cross_section, applyPanelToAll);
|
||||
// 브라우저 횡단 계산이 옛 암 측점을 서버와 같은 기본값으로 다시 계산하게 기억해 둔다.
|
||||
rememberRockBoundaryDefault(projectId, context.rock_boundary_default_offset_m);
|
||||
standardPanelSlot.append(standardPanel.root);
|
||||
|
||||
if (context.route_id === null) {
|
||||
|
||||
@@ -78,11 +78,7 @@ export function validElevation(
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
|
||||
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
|
||||
*/
|
||||
/**
|
||||
* 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**을 찾는다(B05·B06 공용 규칙).
|
||||
* 저장된 횡단 설계가 **현재 계획선과 어긋난 측점**이 하나라도 있는가(B05·B06 공용 규칙).
|
||||
*
|
||||
* 횡단 설계는 계산 당시 계획고(`design.design_elevation_m`)를 기준으로 설계선 좌표를
|
||||
* 굳혀 둔다. 계획선이 그 뒤에 바뀌면(사용자 편집·확정, 배수 최소고 같은 백엔드 규칙
|
||||
@@ -90,58 +86,97 @@ export function validElevation(
|
||||
* — 두 기준이 어긋나 횡단도·3D가 종단을 안 따라오는 것처럼 보인다(2026-08-23 실측:
|
||||
* 한 노선에서 최대 2.21m 어긋남). 재계산 대상을 한 규칙으로 판정해 두 화면이 같은
|
||||
* 시점에 같은 조치를 하게 한다.
|
||||
*
|
||||
* 그리기마다 불리므로 **첫 건에서 멈춘다** — 전 측점을 훑어 목록을 만들던 옛 판은
|
||||
* 프레임당 2.33ms 를 먹어 계획선 편집을 굼뜨게 했다(2026-09-03 실측).
|
||||
*/
|
||||
export function staleDesignChainages(
|
||||
export function hasStaleDesigns(
|
||||
detail: {
|
||||
longitudinal: { design_profiles?: DesignProfile[] };
|
||||
cross_sections: Array<{
|
||||
chainage_m: number;
|
||||
design?: {
|
||||
design_elevation_m: number;
|
||||
geometry_preset?: string;
|
||||
two_stage_slope?: boolean;
|
||||
} | null;
|
||||
}>;
|
||||
cross_sections: StaleCandidate[];
|
||||
},
|
||||
toleranceM = 1e-3,
|
||||
): number[] {
|
||||
): boolean {
|
||||
const profiles = detail.longitudinal.design_profiles;
|
||||
return detail.cross_sections
|
||||
.filter((section) => {
|
||||
const design = section.design;
|
||||
if (!design) return false;
|
||||
// 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다.
|
||||
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
|
||||
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
|
||||
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
|
||||
if (!profiles?.length) return false;
|
||||
const planned = designElevationAt(profiles, section.chainage_m);
|
||||
return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM;
|
||||
})
|
||||
.map((section) => section.chainage_m);
|
||||
return detail.cross_sections.some((section) => isStaleSection(section, profiles, toleranceM));
|
||||
}
|
||||
|
||||
interface StaleCandidate {
|
||||
chainage_m: number;
|
||||
design?: {
|
||||
design_elevation_m: number;
|
||||
geometry_preset?: string;
|
||||
two_stage_slope?: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
function isStaleSection(
|
||||
section: StaleCandidate,
|
||||
profiles: DesignProfile[] | undefined,
|
||||
toleranceM: number,
|
||||
): boolean {
|
||||
const design = section.design;
|
||||
if (!design) return false;
|
||||
// 옛 암 측점: 2단계 절토 경사 필드가 없으면 절토 면적이 최신 엔진과 다르다.
|
||||
// 이 조건이 B06 페이지에만 있어 B05는 재계산을 건너뛰었고, 같은 데이터인데 두 화면의
|
||||
// 절토량이 갈렸다(2026-09-03 실측 228.52㎡ ↔ 270.12㎡). 판정을 여기 한 곳으로 모은다.
|
||||
if (design.geometry_preset === "rock" && design.two_stage_slope === undefined) return true;
|
||||
if (!profiles?.length) return false;
|
||||
const planned = designElevationAt(profiles, section.chainage_m);
|
||||
return planned !== undefined && Math.abs(planned - design.design_elevation_m) > toleranceM;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선 샘플에서 유효분만 걸러 둔 사본 — **샘플 배열 하나당 한 번만** 만든다.
|
||||
*
|
||||
* 이 함수는 측점마다 불린다(측점 128 × 샘플 2,159 규모). 호출마다 `filter()` 로 새
|
||||
* 배열을 만들면 그리기 한 프레임이 통째로 그 비용에 먹힌다(2026-09-03 실측: 프레임당
|
||||
* 2.33ms, 계획선 편집이 굼떠지는 원인). 원본 배열이 바뀌면 새 객체가 오므로 키로 쓴다.
|
||||
*/
|
||||
const validSamplesCache = new WeakMap<object, Array<{ chainage_m: number; elevation_m: number }>>();
|
||||
|
||||
function validPlanSamples(
|
||||
designProfiles: DesignProfile[] | undefined,
|
||||
): Array<{ chainage_m: number; elevation_m: number }> | undefined {
|
||||
const samples = designProfiles?.[0]?.samples;
|
||||
if (!samples?.length) return undefined;
|
||||
const cached = validSamplesCache.get(samples);
|
||||
if (cached) return cached.length ? cached : undefined;
|
||||
const filtered = samples.filter((sample) => Number.isFinite(sample.elevation_m)) as Array<{
|
||||
chainage_m: number;
|
||||
elevation_m: number;
|
||||
}>;
|
||||
validSamplesCache.set(samples, filtered);
|
||||
return filtered.length ? filtered : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
|
||||
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
|
||||
*/
|
||||
export function designElevationAt(
|
||||
designProfiles: DesignProfile[] | undefined,
|
||||
chainageM: number,
|
||||
): number | undefined {
|
||||
const samples = designProfiles?.[0]?.samples?.filter((sample) =>
|
||||
Number.isFinite(sample.elevation_m),
|
||||
);
|
||||
if (!samples?.length) return undefined;
|
||||
const samples = validPlanSamples(designProfiles);
|
||||
if (!samples) return undefined;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m;
|
||||
const last = samples[samples.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last.elevation_m;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1];
|
||||
const current = samples[index];
|
||||
if (chainageM > current.chainage_m) continue;
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 0) return current.elevation_m;
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
||||
// 누가거리 순 배열이라 이진탐색으로 구간을 고른다(선형 훑기와 결과는 같다).
|
||||
let low = 1;
|
||||
let high = samples.length - 1;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (samples[mid].chainage_m < chainageM) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return last.elevation_m;
|
||||
const previous = samples[low - 1];
|
||||
const current = samples[low];
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 0) return current.elevation_m;
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 범례 → 요약줄까지의 한 덩어리는 여기서 만든다. 계산·판정은 전부 공용 모듈
|
||||
* (`common_util_mass_haul*`)이 하고 여기서는 **어디에 무엇을 붙일지**만 정한다.
|
||||
*
|
||||
* 낡음 판정(`staleDesignChainages`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금
|
||||
* 낡음 판정(`hasStaleDesigns`)은 B05와 같은 규칙 하나를 쓴다 — 저장된 횡단이 지금
|
||||
* 계획선과 어긋나면 곡선을 그리지 않고 안내만 띄운다(2026-09-03 사용자 확정:
|
||||
* 「새 값만 보여주기」). 옛 계획고로 만든 면적을 잠깐 보여 주고 정본으로 갈아 끼우면
|
||||
* 사용자가 옛 그림을 본다.
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
L,
|
||||
longitudinalMaxChainage,
|
||||
LONG_PAD,
|
||||
staleDesignChainages,
|
||||
hasStaleDesigns,
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
|
||||
/** 유토곡선 Y축 눈금 — 가로 스크롤 고정 오버레이가 그대로 받는다. */
|
||||
@@ -76,7 +76,7 @@ export interface MassHaulPanelResult {
|
||||
*/
|
||||
export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult {
|
||||
const { detail, visibleSeries } = input;
|
||||
const pendingRecalc = staleDesignChainages(detail).length > 0;
|
||||
const pendingRecalc = hasStaleDesigns(detail);
|
||||
const series: MassHaulSeries[] =
|
||||
input.conversion && !pendingRecalc
|
||||
? computeMassHaulSeries(
|
||||
|
||||
@@ -33,11 +33,76 @@ const GROUP_ORDER: Array<[StandardCrossKey, keyof typeof ui_locales]> = [
|
||||
];
|
||||
|
||||
const SESSION_PREFIX = "b06:std-cross:";
|
||||
/** config 기본값 사본 — 편집값(`SESSION_PREFIX`)과 수명은 같되 용도가 다르다. */
|
||||
const DEFAULTS_PREFIX = "b06:std-cross-default:";
|
||||
|
||||
function sessionKey(projectId: string): string {
|
||||
return `${SESSION_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
function defaultsKey(projectId: string): string {
|
||||
return `${DEFAULTS_PREFIX}${projectId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버가 내려 준 표준횡단 **config 기본값**을 세션에 둔다(`sections/context` 응답).
|
||||
*
|
||||
* 횡단 계산이 브라우저에서 돌므로 config 수치가 프론트에도 있어야 하는데, 상수를
|
||||
* 복제하면 정본이 둘이 된다. 그래서 복제 대신 **서버가 준 값을 그대로 기억**한다.
|
||||
* 패널을 열지 않는 B05도 이 값으로 계산해야 두 화면 결과가 같다(2026-09-03 로컬 전환).
|
||||
*/
|
||||
export function rememberStandardDefaults(projectId: string, defaults: StandardCrossSection): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(defaultsKey(projectId), JSON.stringify(defaults));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시 — 계산은 편집값·저장분으로 이어 간다. */
|
||||
}
|
||||
}
|
||||
|
||||
/** 기억해 둔 config 기본값. 아직 컨텍스트를 못 받았으면 null. */
|
||||
export function readStandardDefaults(projectId: string): StandardCrossSection | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(defaultsKey(projectId));
|
||||
return raw ? (JSON.parse(raw) as StandardCrossSection) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const ROCK_DEFAULT_PREFIX = "b06:rock-boundary-default:";
|
||||
|
||||
/**
|
||||
* 암반 경계선 기본 오프셋(config `STANDARD_ROCK_BOUNDARY_DEFAULT_OFFSET_M`)을 기억한다.
|
||||
* 저장분에 경계 오프셋이 없는 옛 암 측점을 서버와 **같은 기본값**으로 다시 계산하려면
|
||||
* 브라우저에도 이 값이 있어야 한다 — 상수 복제 대신 컨텍스트 응답을 기억하는 방식이다.
|
||||
*/
|
||||
export function rememberRockBoundaryDefault(projectId: string, offsetM: number): void {
|
||||
try {
|
||||
window.sessionStorage.setItem(`${ROCK_DEFAULT_PREFIX}${projectId}`, String(offsetM));
|
||||
} catch {
|
||||
/* 세션 저장 실패는 무시. */
|
||||
}
|
||||
}
|
||||
|
||||
/** 기억해 둔 암반 경계 기본 오프셋(m). 없으면 null. */
|
||||
export function readRockBoundaryDefault(projectId: string): number | null {
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(`${ROCK_DEFAULT_PREFIX}${projectId}`);
|
||||
const parsed = raw === null ? Number.NaN : Number(raw);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단 계산에 넣을 표준단면 한 벌 — **세션 편집값이 있으면 그쪽, 없으면 config 기본값**.
|
||||
* 서버 `_resolve_group(preset, standard)`의 "요청값 → config" 우선순위와 같은 뜻이다.
|
||||
*/
|
||||
export function effectiveStandardCross(projectId: string): StandardCrossSection | null {
|
||||
return readStandardCrossSession(projectId) ?? readStandardDefaults(projectId);
|
||||
}
|
||||
|
||||
/** 표준횡단 세션 편집값을 버린다 — B05 [초기화]가 부른다(정의처를 여기 하나로 둔다). */
|
||||
export function clearStandardCrossSession(projectId: string): void {
|
||||
try {
|
||||
@@ -174,6 +239,8 @@ export function createStandardPanel(
|
||||
defaults: StandardCrossSection,
|
||||
onApplyAll?: () => void | Promise<void>,
|
||||
): StandardPanelController {
|
||||
// 브라우저 횡단 계산이 패널 없이도 config 기본값을 쓸 수 있게 먼저 기억해 둔다.
|
||||
rememberStandardDefaults(projectId, defaults);
|
||||
// 세션값 우선, 없으면 config 기본값. defaults는 복원 기준으로 보존한다.
|
||||
const sessionValue = readStandardCrossSession(projectId);
|
||||
const hadSession = sessionValue !== null;
|
||||
|
||||
@@ -193,20 +193,30 @@ function resolveDitchSide(sectionMode: string, ditchSide: string | null | undefi
|
||||
return "left";
|
||||
}
|
||||
|
||||
/** 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프). */
|
||||
/**
|
||||
* 짝: `_ground_interpolator`. 정렬된 (offset, 지반고) 선형 보간(범위 밖 끝값 클램프).
|
||||
*
|
||||
* 짝 파일은 앞에서부터 훑지만 여기서는 이진탐색으로 같은 구간을 고른다 — 사면·지반
|
||||
* 교차 행진이 이 함수를 수만 번 부르는데 브라우저에서는 그 비용이 조작 속도에 바로
|
||||
* 드러나기 때문이다. **결과는 완전히 같다**(같은 구간, 같은 선형보간).
|
||||
*/
|
||||
function groundInterpolator(valid: Array<[number, number]>): (offsetM: number) => number {
|
||||
const last = valid.length - 1;
|
||||
return (offsetM: number): number => {
|
||||
if (offsetM <= valid[0][0]) return valid[0][1];
|
||||
if (offsetM >= valid[valid.length - 1][0]) return valid[valid.length - 1][1];
|
||||
for (let index = 1; index < valid.length; index += 1) {
|
||||
const [x1, z1] = valid[index];
|
||||
if (offsetM > x1) continue;
|
||||
const [x0, z0] = valid[index - 1];
|
||||
const span = x1 - x0;
|
||||
if (span <= 0) return z1;
|
||||
return z0 + (z1 - z0) * ((offsetM - x0) / span);
|
||||
if (offsetM >= valid[last][0]) return valid[last][1];
|
||||
let low = 1;
|
||||
let high = last;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >> 1;
|
||||
if (valid[mid][0] < offsetM) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return valid[valid.length - 1][1];
|
||||
const [x1, z1] = valid[low];
|
||||
const [x0, z0] = valid[low - 1];
|
||||
const span = x1 - x0;
|
||||
if (span <= 0) return z1;
|
||||
return z0 + (z1 - z0) * ((offsetM - x0) / span);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -652,7 +662,8 @@ export function computeCrossDesign(
|
||||
// 자동 판정된 절/성토 역할에서 실제 단면 유형을 도출해 echo 한다(D-2).
|
||||
let resolvedMode: string;
|
||||
if (geometry.leftRole === "cut" && geometry.rightRole === "cut") resolvedMode = "both_cut";
|
||||
else if (geometry.leftRole === "fill" && geometry.rightRole === "fill") resolvedMode = "both_fill";
|
||||
else if (geometry.leftRole === "fill" && geometry.rightRole === "fill")
|
||||
resolvedMode = "both_fill";
|
||||
else if (geometry.leftRole === "cut") resolvedMode = "left_cut";
|
||||
else resolvedMode = "right_cut";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user