diff --git a/B05_Profile/B05_Profile_Router_Corridor.py b/B05_Profile/B05_Profile_Router_Corridor.py index 4da39233..d0ba5c41 100644 --- a/B05_Profile/B05_Profile_Router_Corridor.py +++ b/B05_Profile/B05_Profile_Router_Corridor.py @@ -9,10 +9,11 @@ import asyncio import json import logging +import re from pathlib import Path from uuid import UUID -from fastapi import APIRouter, Body +from fastapi import APIRouter, Body, Query from fastapi.responses import JSONResponse, Response from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path @@ -64,9 +65,39 @@ async def _resolve_project_root(project_id: UUID) -> Path | None: return Path(resolve_stored_project_path(stored_path)) +# 저장본 머리에서 열쇠(해시)만 떼어 볼 만큼. 봉투 첫 두 칸이 `version`·`hash` 라 +# (`B05_Profile_UI_Corridor_Envelope.serialize`) 이 안에 반드시 들어온다. +_HASH_HEAD_BYTES = 4096 +_HASH_PATTERN = re.compile(rb'"hash"\s*:\s*"([0-9a-fA-F]+)"') + + +async def _stored_hash(path: Path) -> str | None: + """저장본을 통째로 읽지 않고 머리 4KB 에서 열쇠만 뽑는다. 못 찾으면 None.""" + + def read_head() -> bytes: + with path.open("rb") as handle: + return handle.read(_HASH_HEAD_BYTES) + + found = _HASH_PATTERN.search(await asyncio.to_thread(read_head)) + return found.group(1).decode() if found else None + + @router.get("/{project_id}/routes/{route_id}/corridor", response_model=None) -async def get_corridor(project_id: UUID, route_id: int) -> Response: - """저장된 코리도 파일 반환 — 없으면 404(프론트가 빌드로 폴백).""" +async def get_corridor( + project_id: UUID, + route_id: int, + hash: str | None = Query( + None, + description="지금 정본의 열쇠. 저장본이 다르면 파일 대신 stale 만 돌려준다.", + max_length=64, + ), +) -> Response: + """저장된 코리도 파일 반환 — 없으면 404(프론트가 빌드로 폴백). + + `hash` 를 주면 **먼저 열쇠부터 맞춰 본다**(2026-09-06 실측). 저장본이 낡았으면 + 18.6MB 를 다 내려보낸 뒤 브라우저가 버리는 일이 벌어졌다 — 어긋날 때는 수십 바이트만 + 돌려주고 끝낸다. 맞으면 종전처럼 파일을 그대로 흘려보내므로 왕복은 여전히 한 번이다. + """ try: project_root = await _resolve_project_root(project_id) if project_root is None: @@ -79,6 +110,10 @@ async def get_corridor(project_id: UUID, route_id: int) -> Response: status_code=404, content={"status": "error", "message": "저장된 코리도가 없습니다."}, ) + if hash: + stored = await _stored_hash(path) + if stored is not None and stored != hash: + return JSONResponse(content={"status": "stale", "hash": stored}) payload = await asyncio.to_thread(path.read_bytes) return Response(content=payload, media_type="application/json") except OSError as exc: diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 8e96f937..11740b21 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -58,18 +58,35 @@ async function requestCorridor(path: string, init: RequestInit): Promise { +/** 저장본 조회 결과 — 파일을 받았거나, 열쇠가 어긋나 안 받았거나, 아예 없거나. */ +type StoredLookup = + | { kind: "envelope"; envelope: CorridorEnvelope } + | { kind: "stale" } + | { kind: "missing" }; + +/** + * 저장본을 가져온다. **열쇠(hash)를 함께 보내면 서버가 먼저 맞춰 본다** — 어긋나면 + * 파일 대신 수십 바이트짜리 `stale` 만 온다(2026-09-06). 그 전에는 18.6MB 를 다 받은 뒤 + * 해시가 다르다고 버렸다. + */ +async function fetchStored( + projectId: string, + routeId: number, + hash: string, +): Promise { try { - const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, { - method: "GET", - }); - if (!response.ok) return null; - const payload = (await response.json()) as CorridorEnvelope; + const response = await requestCorridor( + `/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`, + { method: "GET" }, + ); + if (!response.ok) return { kind: "missing" }; + const payload = (await response.json()) as CorridorEnvelope & { status?: string }; + if (payload?.status === "stale") return { kind: "stale" }; return payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons) - ? payload - : null; + ? { kind: "envelope", envelope: payload } + : { kind: "missing" }; } catch { - return null; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다. + return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다. } } @@ -120,16 +137,19 @@ export async function ensureCorridor( detail: SectionDetailResponse, routePoints: RoutePoint[], designSamples?: ProfileSamples, + /** 저장본이 낡았을 때 **브라우저가 다시 만들지** 여부. 진입 경로는 false 로 부른다 — + * 3D 는 [3D 업데이트]로만 도는 수동 조작인데 진입만 자동으로 남아 있었다(2026-09-06). */ + rebuild = true, ): Promise { const key = keyOf(projectId, routeId); const hash = corridorHash(detail, routePoints); const cached = cache.get(key); if (cached && cached.hash === hash) return cached.build; - const stored = await fetchStored(projectId, routeId); - if (stored && stored.hash === hash) { + const lookup = await fetchStored(projectId, routeId, hash); + if (lookup.kind === "envelope" && lookup.envelope.hash === hash) { try { - const build = deserialize(stored); + const build = deserialize(lookup.envelope); cache.set(key, { hash, build, dirty: false }); markSource("stored", hash); return build; @@ -137,6 +157,12 @@ export async function ensureCorridor( // 손상 저장본 — 빌드로 폴백. } } + // 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아 + // 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫). + if (!rebuild) return null; + // 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이 + // 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다. + const hasStored = lookup.kind !== "missing"; // 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다. // 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는 @@ -151,7 +177,7 @@ export async function ensureCorridor( return null; } markSource("built", hash); - if (stored === null) { + if (!hasStored) { // 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행). cache.set(key, { hash, build, dirty: false }); void putStored(projectId, routeId, serialize(build, hash)).then((ok) => { @@ -186,9 +212,32 @@ export function refreshCorridor( detail: SectionDetailResponse, routePoints: RoutePoint[], designSamples?: ProfileSamples, + rebuild = true, ): Promise { if (!routeId) return Promise.resolve(); - return ensureCorridor(projectId, routeId, detail, routePoints, designSamples) + return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild) .then((build) => viewer.setCorridor(build)) .catch(() => viewer.setCorridor(null)); } + +/** + * 진입 전용 — 저장본이 **그대로 맞을 때만** 3D 에 올린다. 낡았으면 받지도 만들지도 않고 + * `false` 를 돌려주므로, 부르는 쪽이 [3D 업데이트] 대기 표시를 켜면 된다(2026-09-06). + * 돌아온 값이 곧 「지금 화면의 3D 가 최신인가」다. + */ +export function loadCorridorIfFresh( + viewer: { setCorridor: (build: CorridorBuildResult | null) => void }, + projectId: string, + routeId: number | undefined, + detail: SectionDetailResponse, + routePoints: RoutePoint[], + designSamples?: ProfileSamples, +): Promise { + if (!routeId) return Promise.resolve(false); + return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false) + .then((build) => { + if (build) viewer.setCorridor(build); + return build !== null; + }) + .catch(() => false); +} diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index 57f200e4..2420d947 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -46,7 +46,11 @@ import { } from "../B06_Section/B06_Section_Api_Fetch"; import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store"; import { migrateLegacyStations } from "./B05_Profile_Api_Structures"; -import { refreshCorridor, saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; +import { + loadCorridorIfFresh, + refreshCorridor, + saveCorridorIfDirty, +} from "./B05_Profile_UI_Corridor"; import { resetDesignAction, solveRouteAction, @@ -509,16 +513,17 @@ export async function renderB05Route(root: HTMLElement): Promise { // renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지). const routePoints = latest?.route_points ?? []; if (routePoints.length > 1) { - void refreshCorridor( + // 저장본이 지금 정본과 맞을 때만 올린다 — 낡았으면 18.6MB 를 받지도, 다시 만들지도 + // 않고 [3D 업데이트] 대기 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시 + // 만드느라 화면 전환이 16.7초까지 갔다). 3D 는 원래 수동인데 진입만 자동이었다. + void loadCorridorIfFresh( viewer, activeProjectId, routeId ?? latest?.route?.id, detail, routePoints, profilePanel.alignmentSamples() ?? undefined, - ); - // 방금 정본 그대로 그렸으므로 [3D 업데이트] 대기 표시를 지운다(2026-09-01). - panel.setCorridorPending(false); + ).then((fresh) => panel.setCorridorPending(!fresh)); } }