fix(종횡단): 342-1 계획선 한 벌 · 남은 낡음 표시 · 흙 배분 열쇠
- 횡단 다시 셈이 화면이 쥔 선(측점 한 벌 · 관 지점 · 옮긴 꼭짓점)을 받음 — 종단도가 선을 맡기고 B06 진입 정합 · 3D [업데이트] 앞 다시 셈도 같은 선 · 낡음 판정도 그 선 기준(staleAgainstScreen) - 다시 셈 뒤에도 낡은 측점이 남으면 유토곡선은 그리고 그 측점만 표시 · 콘솔 경고 한 줄 · 흙 배분도 건너뛰지 않음(09-03 「새 값만 보여주기」 바꿈 · 사용자 확정 10-16) - 흙 배분: 실패한 열쇠 다시 보냄 · 받은 열쇠(누가토량 결과 전체)는 다시 안 물음 · 서버도 같은 열쇠 기억 · 묶음 실패는 500 - 옛 시험 셋 새 동작으로 맞춤 · 서버 열쇠 시험 더함 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QfHKQs7J3hQELKMWQMUJEy
This commit is contained in:
@@ -25,7 +25,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { computeCrossDesign } from "@util/common_util_cross_design";
|
||||
import type { StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||
import type { CrossDesignOptions, StandardCrossSectionSpec } from "@util/common_util_cross_design";
|
||||
import { previewCrossDesigns } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { bermSpecAt, readBermSpans, readRockBoundarySession } from "./B06_Section_UI_Page_Persist";
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
planElevationAt,
|
||||
toAlignmentBase,
|
||||
} from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import type { ProfileAlignment } from "../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
||||
import { readAlignment, toDesignProfile } from "../B05_Profile/B05_Profile_UI_Profile_Data";
|
||||
import { readState } from "../A00_Common/b_page_state";
|
||||
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
@@ -64,6 +65,8 @@ export interface CrossRefreshInput {
|
||||
/** 제자리 갱신 대상 — 공유 캐시가 들고 있는 그 객체여야 두 화면이 같이 따라온다. */
|
||||
detail: SectionDetailResponse;
|
||||
edits: CrossRefreshEdits;
|
||||
/** 화면이 쥔 계획선(측점 한 벌 · 342-1) — 없으면 종단도가 맡긴 선 → 저장분 + `edits`. */
|
||||
alignment?: ProfileAlignment | null;
|
||||
/**
|
||||
* 결과를 **아직 써도 되는지** 묻는다(false면 반영하지 않는다). 로컬 계산은 즉시 끝나
|
||||
* 늦은 응답이 없지만, 옛 데이터 폴백(서버 프리뷰)에서는 여전히 문지기가 필요하다.
|
||||
@@ -149,10 +152,33 @@ function preserveUserFields(
|
||||
}
|
||||
|
||||
/**
|
||||
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
|
||||
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
|
||||
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
|
||||
* 계획선 한 벌(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;
|
||||
};
|
||||
}
|
||||
|
||||
/** 낡음 판정을 화면 선 기준으로 — 저장분 선끼리 견주면 화면과 반대 판정이 났다(341 부록 2 4장). */
|
||||
export function staleAgainstScreen(detail: SectionDetailResponse, routeId: number): boolean {
|
||||
const screen = screenLine?.(routeId);
|
||||
if (!screen) return hasStaleDesigns(detail);
|
||||
return hasStaleDesigns({
|
||||
longitudinal: {
|
||||
design_profiles: [toDesignProfile(screen, detail.longitudinal.design_profiles?.[0])],
|
||||
},
|
||||
cross_sections: detail.cross_sections,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 낡은 설계만 다시 셈 — B05 [3D 업데이트]가 예상형상을 짓기 전에 부름(317-1).
|
||||
* 편집 = 세션 계획선 초안 → 없으면 저장분 · 초안이 있으면 늘 맞춤(B06 `reconcileStaleDesigns` 와 같은 규칙).
|
||||
@@ -161,7 +187,7 @@ export async function refreshStaleDesigns(
|
||||
input: Omit<CrossRefreshInput, "edits" | "shouldApply">,
|
||||
): Promise<number[]> {
|
||||
const draft = readAlignmentDraft(input.routeId);
|
||||
if (!draft && !hasStaleDesigns(input.detail)) return [];
|
||||
if (!draft && !staleAgainstScreen(input.detail, input.routeId)) return [];
|
||||
const saved = (
|
||||
input.detail.longitudinal.profile_alignment as
|
||||
| {
|
||||
@@ -179,6 +205,11 @@ export async function refreshStaleDesigns(
|
||||
return refreshCrossDesigns({ ...input, edits });
|
||||
}
|
||||
|
||||
/**
|
||||
* 전 측점 횡단을 현재 계획선으로 다시 계산해 `detail.cross_sections[].design`을 제자리 교체한다.
|
||||
* 돌려주는 값은 실제로 바뀐 측점의 누가거리 목록 — 호출한 쪽이 그 카드만 다시 그리면 된다.
|
||||
* 계획선 ▲ 한 번에 값이 바뀌는 측점은 몇 개뿐 — 전부 돌려주면 카드를 다 갈아 끼워 멈춤(54-22).
|
||||
*/
|
||||
export async function refreshCrossDesigns(input: CrossRefreshInput): Promise<number[]> {
|
||||
const sections = input.detail.cross_sections;
|
||||
const before = new Map(sections.map((section) => [section, JSON.stringify(section.design)]));
|
||||
@@ -290,14 +321,19 @@ function readCutSlopeRatios(projectId: string, routeId: number): Map<number, num
|
||||
function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
const { projectId, detail, edits } = input;
|
||||
const stored = readAlignment(detail.longitudinal);
|
||||
if (!stored) return null; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
|
||||
// 선 = 화면이 쥔 선 → 종단도가 맡긴 선 → 저장분 + 편집(종단도가 아직 없을 때) · 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; // 선형 저장분이 없는 옛 데이터 — 서버가 풀어 준다.
|
||||
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 편집 중
|
||||
@@ -367,69 +403,66 @@ function refreshLocally(input: CrossRefreshInput): number[] | null {
|
||||
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 ?? section.underdrain?.slope_pct ?? null,
|
||||
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,
|
||||
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
|
||||
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
|
||||
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
|
||||
spoilFill:
|
||||
typeof design.spoil_fill_width_m === "number" &&
|
||||
design.spoil_fill_width_m > 0 &&
|
||||
(design.spoil_fill_side === "left" || design.spoil_fill_side === "right")
|
||||
? {
|
||||
side: design.spoil_fill_side,
|
||||
widthM: design.spoil_fill_width_m,
|
||||
slopeRatioN: null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
let next;
|
||||
try {
|
||||
next = computeCrossDesign(
|
||||
section.samples ?? [],
|
||||
planElevationAt(alignment, section.chainage_m),
|
||||
{
|
||||
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 ?? section.underdrain?.slope_pct ?? null,
|
||||
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,
|
||||
// 사토장 — **저장분 폭을 그대로 잇는다**. 폭은 구간 용량에서 서버가 정한 값이라
|
||||
// 브라우저가 다시 풀지 않는다(다시 풀면 그 측점만 폭이 달라져 작업장 모양이 깨진다).
|
||||
// 이어 붙이지 않으면 계획선을 만질 때마다 사토장이 그림에서 사라진다.
|
||||
spoilFill:
|
||||
typeof design.spoil_fill_width_m === "number" &&
|
||||
design.spoil_fill_width_m > 0 &&
|
||||
(design.spoil_fill_side === "left" || design.spoil_fill_side === "right")
|
||||
? {
|
||||
side: design.spoil_fill_side,
|
||||
widthM: design.spoil_fill_width_m,
|
||||
slopeRatioN: null,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
);
|
||||
next = computeCrossDesign(section.samples ?? [], elevation, options);
|
||||
} catch {
|
||||
continue; // 샘플 부족·값 손상 측점은 건너뛴다(서버 엔진과 같은 태도).
|
||||
}
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -37,6 +40,16 @@ router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
_NPM_SCRIPT = "build:server-calc"
|
||||
# 측점 수 상한 — 정상 노선은 수백 곳이다. 그보다 크면 비정상 요청으로 본다.
|
||||
_MAX_POINTS = 5000
|
||||
# 열쇠(누가토량 결과 + 계수 · 장비 경계) → 배분 — 같은 열쇠면 Node 를 다시 안 띄움
|
||||
# (342-1 · 부록 4 R25). ponytail: 프로세스 메모리 · 최근 몇 벌만 — 재시작 뒤 첫 열기는
|
||||
# 다시 셈(_derived 저장본은 343 틀 뒤).
|
||||
_KEPT: OrderedDict[str, Any] = OrderedDict()
|
||||
_KEPT_MAX = 32
|
||||
|
||||
|
||||
def _plan_key(result: dict[str, Any], context: Any) -> str:
|
||||
body = json.dumps({"result": result, "context": context}, sort_keys=True, default=str)
|
||||
return hashlib.sha1(body.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@router.post("/{project_id}/sections/{route_id}/haul-plan", response_model=None)
|
||||
@@ -69,15 +82,17 @@ async def compute_haul_plan(
|
||||
factors = await conversion_factors_for(project_id)
|
||||
# 장비 거리 경계도 프로젝트 값으로 — 도쟈 한계거리를 고쳤으면 배분이 그 값으로 선다.
|
||||
limits = await haul_limits_for(project_id)
|
||||
context = _mass_haul_context(haul_inputs, factors, limits)
|
||||
key = _plan_key(result, context)
|
||||
if key in _KEPT:
|
||||
_KEPT.move_to_end(key)
|
||||
return JSONResponse(content={"status": "success", "haul_plan": _KEPT[key]})
|
||||
try:
|
||||
output = await asyncio.to_thread(
|
||||
run_bundle_json,
|
||||
BUNDLE,
|
||||
_NPM_SCRIPT,
|
||||
{
|
||||
"haul_plan_for": result,
|
||||
"context": _mass_haul_context(haul_inputs, factors, limits),
|
||||
},
|
||||
{"haul_plan_for": result, "context": context},
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("유토 배분 계산 실패: project_id=%s route_id=%s", project_id, route_id)
|
||||
@@ -85,5 +100,14 @@ async def compute_haul_plan(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
|
||||
)
|
||||
plan = output.get("haul_plan") if isinstance(output, dict) else None
|
||||
# 묶음 실패(None)는 실패로 — 성공 + 빈 배분으로 주면 화면이 그 열쇠를 다시 안 물었다(342-1).
|
||||
if not isinstance(output, dict):
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "유토 배분 계산에 실패했습니다."},
|
||||
)
|
||||
plan = output.get("haul_plan")
|
||||
_KEPT[key] = plan
|
||||
if len(_KEPT) > _KEPT_MAX:
|
||||
_KEPT.popitem(last=False)
|
||||
return JSONResponse(content={"status": "success", "haul_plan": plan})
|
||||
|
||||
@@ -15,14 +15,13 @@ import {
|
||||
writeShoulderExtend,
|
||||
} from "./B06_Section_Cross_Design_Session";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { refreshCrossDesigns } from "./B06_Section_Cross_Refresh";
|
||||
import { refreshCrossDesigns, staleAgainstScreen } from "./B06_Section_Cross_Refresh";
|
||||
import {
|
||||
bermSpansFromStructures,
|
||||
readBermSpans,
|
||||
writeBermSpans,
|
||||
} from "./B06_Section_UI_Page_Persist";
|
||||
import { readAlignmentDraft } from "../B05_Profile/B05_Profile_UI_Profile_Edit";
|
||||
import { hasStaleDesigns } from "./B06_Section_UI_Section_Common";
|
||||
import { L } from "./B06_Section_UI_Page_Common";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import type { CrossDesignChange, SectionViewController } from "./B06_Section_UI_Section_View";
|
||||
@@ -167,7 +166,8 @@ export function createDesignSync(ctx: DesignSyncContext): DesignSyncController {
|
||||
// 저장분끼리는 늘 일치해 「낡지 않음」으로 나오는데, B05가 세션에 남긴 편집은 그 안에
|
||||
// 없어 B06이 재계산을 통째로 건너뛰었다 — 같은 시점에 B05 절토 4,774.1㎥ ↔ B06
|
||||
// 4,515.0㎥ 로 갈렸다(2026-09-03 실측). 재계산은 브라우저 안에서 끝나 값싸다.
|
||||
if (!options?.force && !draft && !hasStaleDesigns(sectionDetail)) return;
|
||||
// 판정 · 다시 셈 모두 종단도가 쥔 선(측점 한 벌) 기준 — 저장분 선끼리 견주면 화면과 갈림(342-1).
|
||||
if (!options?.force && !draft && !staleAgainstScreen(sectionDetail, currentRouteId)) return;
|
||||
// 진입 정합은 화면을 잠그지 않는다 — 카드가 도착하는 대로 조용히 갱신된다
|
||||
// (CLAUDE.md 5장).
|
||||
try {
|
||||
|
||||
@@ -133,6 +133,11 @@ export function staleKey(
|
||||
.join("|");
|
||||
}
|
||||
|
||||
/** 낡음 지문(`staleKey`)에 든 측점 누가거리 — 다시 셈 뒤에도 남은 측점을 표시할 때(342-1). */
|
||||
export function staleChainages(key: string): number[] {
|
||||
return key ? key.split("|").map((entry) => Number(entry.split(":")[0])) : [];
|
||||
}
|
||||
|
||||
interface StaleCandidate {
|
||||
chainage_m: number;
|
||||
// 측점에 얹힌 구조물 제원 — 저장분 설계값(포장 · 기울기 · 노면 하강)과 맞대 본다(314)
|
||||
|
||||
Reference in New Issue
Block a user