diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index be365263..44905989 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -10,6 +10,7 @@ * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; const DB_NAME = "aislo-asset-cache"; const DB_VERSION = 1; @@ -205,27 +206,44 @@ export async function fetchCachedJson( } /* ── 준비 화면 연동 ──────────────────────────────────────────────────────── - * 어느 프로젝트를 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다. - * 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). */ -const PRELOADED_PROJECT_KEY = "frd_preloaded_project"; + * 무엇을 이미 준비했는지, 준비가 끝나면 어느 화면으로 보낼지를 탭 단위로 기억한다. + * 대시보드로 나갔다가 같은 프로젝트로 돌아오면 준비 화면을 건너뛴다(2026-08-01 사용자 지시). + * + * 표식은 프로젝트 번호가 아니라 **확정 구성(signature)** 이다. 관리자가 B04에서 다른 + * 필터·표현으로 다시 확정하면 표식이 달라져 준비 화면이 한 번 더 돌고 새 자료를 담는다. + * 프로젝트 번호만 봤다면 옛 자료를 계속 쓰게 된다(2026-08-01 사용자 지시). */ +const PRELOADED_SIGNATURE_KEY = "frd_preloaded_signature"; const PRELOAD_TARGET_KEY = "frd_preload_target"; -export function isProjectPreloaded(projectId: string): boolean { +const preloadStamp = (projectId: string, signature: string): string => `${projectId}|${signature}`; + +export function isProjectPreloaded(projectId: string, signature: string): boolean { try { - return window.sessionStorage.getItem(PRELOADED_PROJECT_KEY) === projectId; + return ( + window.sessionStorage.getItem(PRELOADED_SIGNATURE_KEY) === preloadStamp(projectId, signature) + ); } catch { return false; } } -export function markProjectPreloaded(projectId: string): void { +export function markProjectPreloaded(projectId: string, signature: string): void { try { - window.sessionStorage.setItem(PRELOADED_PROJECT_KEY, projectId); + window.sessionStorage.setItem(PRELOADED_SIGNATURE_KEY, preloadStamp(projectId, signature)); } catch { /* 세션 저장 실패는 준비 화면이 한 번 더 뜨는 정도의 영향뿐이다. */ } } +/** 담아 둔 표식을 지운다 — 확정이 바뀌어 자료를 다시 담아야 할 때 호출한다. */ +export function clearPreloadMark(): void { + try { + window.sessionStorage.removeItem(PRELOADED_SIGNATURE_KEY); + } catch { + /* 지우지 못해도 다음 표식 비교에서 불일치로 걸러진다. */ + } +} + export function setPreloadTarget(route: string): void { try { window.sessionStorage.setItem(PRELOAD_TARGET_KEY, route); @@ -245,49 +263,25 @@ export function readPreloadTarget(): string | null { /** 준비 화면이 표시할 단계 안내. ratio가 null이면 진행률을 모른다는 뜻이다. */ export type PreloadReporter = (label: string, ratio: number | null) => void; -/** 사용자가 고른 등고선 간격(B05에서 저장한 값). 없으면 1.0m. - * B04(관리자 확인용 화면)도 이 값을 시작값으로 쓴다 — 사용자가 정한 값이 우선이다. */ -export async function fetchUserContourInterval(projectId: string): Promise { - try { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/route/latest`, { - credentials: "include", - }); - if (!response.ok) return 1.0; - const data = (await response.json()) as { - surface_params?: { contour_interval_m?: number }; - }; - const interval = data.surface_params?.contour_interval_m; - return typeof interval === "number" && interval > 0 ? interval : 1.0; - } catch { - return 1.0; - } -} - /** 확정된 지표면의 3D 파일과 그 등고선을 보관함에 채운다(준비 화면에서 호출). * * 사용자가 실제로 보는 것은 이 둘이라 이것만 챙긴다 — 포인트클라우드·배수유역은 제외 * (2026-08-01 사용자 지시). 이미 보관돼 있으면 거의 즉시 끝난다. - * 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다. */ + * 평활 여부·등고선 간격은 짐작하지 않고 확정 저장값을 그대로 쓴다 — 짐작하면 B04·B05가 + * 서로 다른 파일을 받아 같은 지형을 두 번 내려받게 된다. + * 확정 지표면을 찾지 못하면 오류를 던져 준비 화면이 안내 문구를 띄우게 한다. + * 반환값은 담아 둔 구성의 signature — 호출측이 준비 표식으로 저장한다. */ export async function preloadSurfaceAssets( projectId: string, report: PreloadReporter = () => {}, -): Promise { +): Promise { report("확정된 지표면을 확인하는 중…", null); - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/surface/models`, { - credentials: "include", - }); - if (!response.ok) throw new Error("지표면 목록을 불러오지 못했습니다."); - const data = (await response.json()) as { - models?: Array<{ id: number; model_type?: string; status?: string }>; - }; - const confirmed = (data.models ?? []).find((model) => model.status === "CONFIRMED"); - if (!confirmed) throw new Error("확정된 지표면 모델이 없습니다."); + const confirmed = await fetchConfirmedSurface(projectId); + if (!confirmed.model_id) throw new Error("확정된 지표면 모델이 없습니다."); - // 스무딩을 지원하는 방식(dtm·tin)은 화면 기본값이 스무딩 적용본이다. - const method = (confirmed.model_type ?? "").toLowerCase(); - const smooth = method === "dtm" || method === "tin"; - const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.id}`; - const interval = await fetchUserContourInterval(projectId); + const smooth = confirmed.smooth ?? false; + const interval = confirmed.contour_interval_m ?? 1.0; + const base = `${API_BASE_URL}/projects/${projectId}/surface/models/${confirmed.model_id}`; report("3D 지표면을 준비하는 중…", 0); await fetchCachedBytes(projectId, `${base}/preview?smooth=${smooth}`, { @@ -301,4 +295,5 @@ export async function preloadSurfaceAssets( { onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) }, ); report("준비 완료", 1); + return confirmed.signature; } diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index f8c62270..11c843a5 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -5,6 +5,7 @@ import { type RoutePath, } from "@config/config_frontend"; import type { WorkflowStage } from "@ui/ui_template_workflow_layout"; +import { fetchConfirmedSurface } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { isProjectPreloaded, setPreloadTarget } from "./b_asset_cache"; import { navigateTo } from "./router"; @@ -37,13 +38,28 @@ export async function fetchWorkflowState(projectId: string): Promise { + let signature: string | null = null; + try { + signature = (await fetchConfirmedSurface(projectId)).signature; + } catch { + signature = null; + } + if (signature && isProjectPreloaded(projectId, signature)) { + navigateTo(route); return; } - navigateTo(route); + setPreloadTarget(route); + navigateTo(ROUTES.B11_LOADING); } diff --git a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts index bfbfb09e..4b23b8f4 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_Api_Fetch.ts @@ -111,6 +111,29 @@ export interface SurfaceModelListResponse { models: SurfaceModelSummary[]; } +/** 확정 지표면 요약 (SurfaceConfirmedResponse). + * 포인트 배열 없이 확정 구성과 지형 가장자리만 담는다 — 진입 판정·준비화면·B05 공용. */ +export interface SurfaceConfirmedResponse { + status: string; + project_id: string; + model_id: number | null; + source_filter: string | null; + method: string | null; + smooth: boolean | null; + contour_interval_m: number | null; + /** 확정 구성이 바뀌었는지 한 줄로 비교하기 위한 값. */ + signature: string; + point_count: number | null; + bounds: { + x_min: number; + x_max: number; + y_min: number; + y_max: number; + z_min: number; + z_max: number; + } | null; +} + /** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. * * `timeoutMs`를 주면 그 값으로 끊는다. 배수유역 격자 해석처럼 수십 초가 걸리는 요청은 @@ -199,6 +222,14 @@ export async function fetchSurfacePointCloud( ); } +/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB). + * 포인트클라우드 전체(수십 MB)를 받지 않고도 3D 좌표 환산에 필요한 값을 얻는다. */ +export async function fetchConfirmedSurface(projectId: string): Promise { + return requestJson(`/projects/${projectId}/surface/confirmed`, { + method: "GET", + }); +} + export async function fetchSurfaceGroundStats( projectId: string, ): Promise { diff --git a/B04_wf1_Surface/B04_wf1_Surface_Router.py b/B04_wf1_Surface/B04_wf1_Surface_Router.py index 47ad778b..93568f4f 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Router.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Router.py @@ -28,6 +28,7 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import ( from B04_wf1_Surface.B04_wf1_Surface_Schema import ( SurfaceAnalyzeRequest, SurfaceAnalyzeResponse, + SurfaceConfirmedResponse, SurfaceConfirmRequest, SurfaceConfirmResponse, SurfaceGroundStatsResponse, @@ -42,7 +43,10 @@ from common_util.common_util_auth import require_system_admin from common_util.common_util_http_cache import cached_file_response from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path -from common_util.common_util_surface_confirmation import surface_confirmation_defaults +from common_util.common_util_surface_confirmation import ( + get_surface_confirmation_params, + surface_confirmation_defaults, +) from common_util.common_util_workflow_state import ( fail_stage, start_stage, @@ -368,6 +372,80 @@ async def get_surface_point_cloud( ) +@router.get("/{project_id}/surface/confirmed", response_model=SurfaceConfirmedResponse) +async def get_confirmed_surface(project_id: UUID) -> SurfaceConfirmedResponse | JSONResponse: + """확정 지표면 구성과 지형 가장자리만 반환한다(포인트 배열 없음). + + B05 3D 배치·B11 준비화면·진입 판정이 모두 이 응답 하나를 기준으로 삼는다. + 구성이 바뀌면 signature가 달라지므로 프론트가 담아 둔 자료의 갱신 여부를 판단할 수 있다. + """ + pool = get_db_pool() + try: + async with pool.acquire() as connection: + stored_path = await get_project_storage_relative_path(connection, project_id) + models = await list_surface_models(connection, project_id) + params = await get_surface_confirmation_params(connection, str(project_id)) + + confirmed = next((model for model in models if model["status"] == "CONFIRMED"), None) + source_filter = params.get("source_filter") + + # 가장자리는 B05가 3D 마커 좌표를 환산할 때 쓰므로, 기존 포인트클라우드 응답과 + # 같은 파일(확정 필터의 지면 포인트)에서 읽어 값이 어긋나지 않게 한다. + processed_dir = Path(resolve_stored_project_path(stored_path)) / "B04_wf1_Surface" + processed_dir = processed_dir / "processed" + source_path = processed_dir / "structured.npz" + if source_filter: + filtered = processed_dir / f"ground_points_{source_filter}.npz" + if filtered.is_file(): + source_path = filtered + + bounds_payload: dict[str, float] | None = None + point_count: int | None = None + if source_path.is_file(): + with np.load(source_path) as stored: + bounds = np.asarray(stored["bounds"], dtype=np.float64) + if "point_count" in stored: + point_count = int(stored["point_count"]) + bounds_payload = { + "x_min": float(bounds[0, 0]), + "x_max": float(bounds[0, 1]), + "y_min": float(bounds[1, 0]), + "y_max": float(bounds[1, 1]), + "z_min": float(bounds[2, 0]), + "z_max": float(bounds[2, 1]), + } + + signature = "|".join( + str(value) + for value in ( + confirmed["id"] if confirmed else "none", + source_filter, + params.get("method"), + params.get("smooth"), + params.get("contour_interval_m"), + ) + ) + return SurfaceConfirmedResponse( + project_id=str(project_id), + model_id=int(confirmed["id"]) if confirmed else None, + source_filter=source_filter, + method=params.get("method"), + smooth=params.get("smooth"), + contour_interval_m=params.get("contour_interval_m"), + signature=signature, + point_count=point_count, + bounds=bounds_payload, + ) + except LookupError as exc: + return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)}) + except Exception: + logger.exception("B04 확정 지표면 요약 조회 실패: project_id=%s", project_id) + return JSONResponse( + status_code=500, + content={"status": "error", "message": "확정 지표면 정보를 불러오지 못했습니다."}, + ) + + @router.get("/{project_id}/surface/ground-stats", response_model=SurfaceGroundStatsResponse) async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse: """manifest에서 필터별 지면 포인트 통계를 반환한다.""" diff --git a/B04_wf1_Surface/B04_wf1_Surface_Schema.py b/B04_wf1_Surface/B04_wf1_Surface_Schema.py index 13605251..3e0ec724 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_Schema.py +++ b/B04_wf1_Surface/B04_wf1_Surface_Schema.py @@ -110,6 +110,25 @@ class SurfacePointCloudSampleResponse(BaseModel): rgb: list[list[int]] | None = None +class SurfaceConfirmedResponse(BaseModel): + """확정 지표면 요약 — 화면 진입 판정·준비화면·B05가 공통으로 쓰는 단일 출처. + + 포인트 배열 없이 확정값과 지형 가장자리만 담아 수 KB로 유지한다. + signature는 확정 구성이 바뀌었는지 프론트가 한 줄로 비교하기 위한 값이다. + """ + + status: str = "success" + project_id: str + model_id: int | None = None + source_filter: str | None = None + method: str | None = None + smooth: bool | None = None + contour_interval_m: float | None = None + signature: str + point_count: int | None = None + bounds: dict[str, float] | None = None + + class SurfaceGroundStatsResponse(BaseModel): """필터별 지면 포인트 통계 응답.""" diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts index 64519cb6..5ea15cd5 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts @@ -9,7 +9,8 @@ import { } from "@ui/ui_template_elements"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; -import { fetchUserContourInterval, purgeOtherProjects } from "../A00_Common/b_asset_cache"; +import { clearPreloadMark, purgeOtherProjects } from "../A00_Common/b_asset_cache"; +import { clearRouteLatestCache } from "../B05_wf2_Route/B05_wf2_Route_Api_Fetch"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; import { fetchWorkflowState, @@ -19,6 +20,7 @@ import { } from "../A00_Common/b_workflow_nav"; import { confirmSurfaceModel, + fetchConfirmedSurface, fetchSurfacePointCloud, fetchSurfaceStatus, listSurfaceInputFiles, @@ -320,16 +322,24 @@ export async function renderB04Surface(root: HTMLElement): Promise { } async function loadProjectData(projectId: string): Promise { - const [inputs, status, modelResponse, contourInterval] = await Promise.all([ + const [inputs, status, modelResponse, confirmed] = await Promise.all([ listSurfaceInputFiles(projectId), fetchSurfaceStatus(projectId), listSurfaceModels(projectId), - // 등고선 간격은 사용자가 B05에서 저장한 값을 시작값으로 쓴다. 여기서 바꿔도 DB에는 - // 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01). - fetchUserContourInterval(projectId), + // 확정본 구성(필터·표현·평활·등고선 간격)을 그대로 시작값으로 쓴다. 여기서 바꿔도 + // DB에는 저장하지 않는다 — 관리자 확인용이라 사용자 설정을 건드리지 않는다(2026-08-01). + fetchConfirmedSurface(projectId), ]); models = modelResponse.models; - terrainViewer.setContourInterval(contourInterval); + // 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다. + // 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다. + if (confirmed.model_id) { + if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter; + if (confirmed.method) methodGroup.select.value = confirmed.method; + terrainViewer.setSmoothing(confirmed.smooth ?? false); + } + if (confirmed.contour_interval_m) + terrainViewer.setContourInterval(confirmed.contour_interval_m); renderInputFiles(inputs.files); renderStatus(status); viewer.setLoading("포인트 데이터 로딩 중…"); @@ -360,6 +370,10 @@ export async function renderB04Surface(root: HTMLElement): Promise { smooth: terrainViewer.isSmoothingEnabled(), contour_interval_m: terrainViewer.getContourInterval(), }); + // 확정본이 바뀌었으므로 브라우저가 담아 둔 옛 자료를 더 이상 쓰지 않게 한다. + // 준비 표식을 지우면 아래 goToWorkflowStage가 준비 화면을 거쳐 새 자료를 담는다. + clearPreloadMark(); + clearRouteLatestCache(projectId); showToast( L("B04_Surface_Confirm_Success") .replace("{filter}", filterGroup.select.value) diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts index aca1d247..4c79e4f0 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_TerrainViewer.ts @@ -26,6 +26,8 @@ export interface SurfaceTerrainViewer { onCameraChange: (listener: (state: SurfaceCameraState) => void) => void; onAxesVisibilityChange: (listener: (visible: boolean) => void) => void; isSmoothingEnabled: () => boolean; + /** 스무딩 시작값을 정한다(확정본 저장값). 다시 그리지는 않는다. */ + setSmoothing: (enabled: boolean) => void; getContourInterval: () => number; /** 등고선 간격 시작값을 정한다(사용자가 B05에서 저장한 값). 다시 그리지는 않는다. */ setContourInterval: (interval: number) => void; @@ -703,6 +705,10 @@ export function createSurfaceTerrainViewer(): SurfaceTerrainViewer { isSmoothingEnabled() { return !smoothCheck.disabled && smoothCheck.checked; }, + setSmoothing(enabled) { + smoothPreferred = enabled; + syncSmoothingSupport(); + }, setContourInterval(interval) { if (Number.isFinite(interval) && interval > 0) intervalInput.value = String(interval); }, diff --git a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts index e06feb17..b6ef26e1 100644 --- a/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts +++ b/B05_wf2_Route/B05_wf2_Route_Api_Fetch.ts @@ -261,6 +261,19 @@ export async function fetchLatestRoute(projectId: string): Promise `b05:latest:${projectId}`; + +/** 담아 둔 최신 경로 값을 버린다. B04에서 지표면을 다시 확정하면 옛 확정값이 남아 + * B05가 이전 지형을 그리게 되므로, 확정 직후 이 값을 지운다. */ +export function clearRouteLatestCache(projectId: string): void { + try { + window.sessionStorage.removeItem(routeLatestCacheKey(projectId)); + } catch { + /* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */ + } +} + /* ── 배수유역도 (B05_wf2_Route_Router_Drainage.py) ───────────────────────── */ /** 관 매설 구조물 측점 후보 1개. reason: stream=세류 교차, spacing=300m 보충. */ diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 55ebb520..54d429a0 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -10,13 +10,14 @@ import { WORKFLOW_STEP_ROUTES, } from "../A00_Common/b_workflow_nav"; import { - fetchSurfacePointCloud, + fetchConfirmedSurface, listSurfaceModels, type SurfaceModelSummary, } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { confirmRoute, fetchLatestRoute, + routeLatestCacheKey, saveDrainageBoundary, solveRoute, updateContourInterval, @@ -246,7 +247,7 @@ export async function renderB05Route(root: HTMLElement): Promise { * 확정 이력이 있으면 매 진입마다 DB(latest) 조회 대신 브라우저 세션 캐시를 * 우선 사용해 응답속도를 높인다. 캐시 미스면 latest를 조회해 적재하고, * solve·확정 성공 시 신선한 값으로 갱신한다(세션 = 탭 단위, 탭 종료 시 소멸). */ - const latestCacheKey = `b05:latest:${activeProjectId}`; + const latestCacheKey = routeLatestCacheKey(activeProjectId); function readLatestCache(): RouteLatestResponse | null { try { @@ -672,17 +673,16 @@ export async function renderB05Route(root: HTMLElement): Promise { if (!confirmedSurface) { showToast("확정된 지표면 모델이 없습니다.", "error"); } else { - const cloud = await fetchSurfacePointCloud( - activeProjectId, - latestResponse.surface_params.source_filter, - ); + // 지형 가장자리만 필요하다 — 포인트클라우드 전체(수십 MB)는 받지 않는다. + const confirmed = await fetchConfirmedSurface(activeProjectId); + if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다."); await viewer.loadSurface( activeProjectId, confirmedSurface.id, latestResponse.surface_params.method, latestResponse.surface_params.smooth, latestResponse.surface_params.contour_interval_m, - toBounds(cloud.bounds), + toBounds(confirmed.bounds), ); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다. renderLatest(latestResponse); diff --git a/B11_Status/B11_Status_UI_Loading.ts b/B11_Status/B11_Status_UI_Loading.ts index 72f3a0a7..304faa1a 100644 --- a/B11_Status/B11_Status_UI_Loading.ts +++ b/B11_Status/B11_Status_UI_Loading.ts @@ -22,6 +22,9 @@ import "./B11_Status_UI_Style.css"; * 자료를 준비하지 못하면(분석 실패·저장 경로 문제) 넘어가지 않고 사유를 알린다. * ========================================================================== */ +/** 준비에 실패한 채 그냥 넘어갔을 때 남기는 표식 — 어떤 확정 구성과도 일치하지 않는다. */ +const PRELOAD_SKIPPED_SIGNATURE = "skipped"; + export async function renderB11Loading(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const target = (readPreloadTarget() ?? ROUTES.B03_FILE_INPUT) as RoutePath; @@ -54,10 +57,11 @@ export async function renderB11Loading(root: HTMLElement): Promise { try { // 다른 프로젝트 자료가 남아 있으면 지운다 — 프로젝트끼리 섞이면 안 된다. await purgeOtherProjects(projectId); - await preloadSurfaceAssets(projectId, (label, ratio) => { + const signature = await preloadSurfaceAssets(projectId, (label, ratio) => { progress.set(ratio, label); }); - markProjectPreloaded(projectId); + // 표식은 담아 둔 구성 그대로 남긴다 — 확정이 바뀌면 다음 진입에서 다시 준비한다. + markProjectPreloaded(projectId, signature); navigateTo(target); } catch (error) { const detail = error instanceof Error ? ` (${error.message})` : ""; @@ -73,8 +77,10 @@ export async function renderB11Loading(root: HTMLElement): Promise { label: t("B11_Loading_Btn_Continue"), variant: "ghost", // 준비를 못 했어도 같은 세션에서 다시 붙잡지 않도록 표시해 둔다. + // 확정 구성을 모르는 상태이므로 전용 표식을 남긴다 — 확정이 정상화되면 표식이 + // 어긋나 준비 화면이 다시 뜬다. onClick: () => { - if (projectId) markProjectPreloaded(projectId); + if (projectId) markProjectPreloaded(projectId, PRELOAD_SKIPPED_SIGNATURE); navigateTo(target); }, }),