diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 0ed5e9664..b03e06de9 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -134,7 +134,7 @@ export async function mountProfileView( { // 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다** // (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다. - onCrossDesignsUpdated: () => panel.setCorridorPending(true), + onCrossDesignsUpdated: () => sections.setCorridorPending(true), // 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의 // 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫). onPipesChanged: (pipes) => { @@ -187,6 +187,7 @@ export async function mountProfileView( roadWidths: DEFAULT_ROAD_WIDTHS, detail: null, corridorVisible: true, + corridorPending: false, routeReady: false, restoring: true, }; diff --git a/A00_Common/design/design_view_profile_3d.ts b/A00_Common/design/design_view_profile_3d.ts index 833af8f2e..435f3b941 100644 --- a/A00_Common/design/design_view_profile_3d.ts +++ b/A00_Common/design/design_view_profile_3d.ts @@ -48,25 +48,36 @@ export function createProfile3d(deps: Profile3dDeps) { onResetView: () => viewer.setView("top"), onProjection: viewer.setProjection, // [3D 업데이트](2026-09-01) — 밀린 계획선 편집을 예상형상·측점선에 한 번에 반영한다. - onCorridorRefresh: async () => { - const detail = state.detail; - const latest = state.latest; - if (!detail || !latest?.route?.id) return; - const routePoints = latest.route_points ?? []; - if (routePoints.length <= 1) return; - await refreshCorridor( - viewer, - projectId, - latest.route.id, - detail, - routePoints, - deps.profilePanel().alignmentSamples() ?? undefined, - ); - // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). - deps.sections().renderStationLines(detail); - }, + onCorridorRefresh: () => refreshOnce(), }; + /** 짓는 중이면 그 결과를 같이 기다린다 — 단추 · 모달 열기가 겹쳐도 한 번만 짓는다. */ + let refreshing: Promise | null = null; + function refreshOnce(): Promise { + refreshing ??= refreshCorridorNow().finally(() => (refreshing = null)); + return refreshing; + } + + /** 낡은 3D 길 모형을 지금 노선 · 계획선으로 — 저장본이 맞으면 받기만 · 아니면 워커 미리보기. */ + async function refreshCorridorNow(): Promise { + const detail = state.detail; + const latest = state.latest; + if (!detail || !latest?.route?.id) return; + const routePoints = latest.route_points ?? []; + if (routePoints.length <= 1) return; + await refreshCorridor( + viewer, + projectId, + latest.route.id, + detail, + routePoints, + deps.profilePanel().alignmentSamples() ?? undefined, + ); + // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). + deps.sections().renderStationLines(detail); + deps.sections().setCorridorPending(false); + } + async function applyContours(interval: number): Promise { // 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(CLAUDE.md 5장). try { @@ -120,6 +131,10 @@ export function createProfile3d(deps: Profile3dDeps) { viewer, loadTerrain, visible: () => deps.host.visible(), + // 낡은 채 열면 그때 한 번 다시 짓는다(워커 · 345-4) — 닫혀 있는 동안은 안 짓는다. + onOpen: () => { + if (state.corridorPending) void refreshOnce(); + }, }); return { viewCallbacks, applyContours, modal }; diff --git a/A00_Common/design/design_view_profile_3d_modal.ts b/A00_Common/design/design_view_profile_3d_modal.ts index 69af1a74d..7791d9dd0 100644 --- a/A00_Common/design/design_view_profile_3d_modal.ts +++ b/A00_Common/design/design_view_profile_3d_modal.ts @@ -18,6 +18,8 @@ export interface Viewer3dModalDeps { loadTerrain: () => Promise; /** 종단 보기가 보이는가 — 숨은 보기에서는 안 연다. */ visible: () => boolean; + /** 열 때마다(지형이 선 뒤) — 낡은 3D 길 모형 다시 짓기(345-4). */ + onOpen?: () => void; } export function createViewer3dModal(deps: Viewer3dModalDeps) { @@ -54,11 +56,13 @@ export function createViewer3dModal(deps: Viewer3dModalDeps) { }, }); viewer.setActive(true); - if (terrain) return; - const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); - progress.set(null); - viewer.root.append(progress.root); - terrain = deps.loadTerrain().finally(() => progress.remove()); + if (!terrain) { + const progress = createProgressCircle({ label: "3D 지형을 불러오는 중…", overlay: true }); + progress.set(null); + viewer.root.append(progress.root); + terrain = deps.loadTerrain().finally(() => progress.remove()); + } + void terrain.then(() => modal && deps.onOpen?.()); // 그 사이 닫았으면 안 지음 } /** 열려 있으면 닫는다 — 보기를 떠날 때 · 페이지를 떠날 때. */ diff --git a/A00_Common/design/design_view_profile_sections.ts b/A00_Common/design/design_view_profile_sections.ts index bd0e8118b..dc9702ea6 100644 --- a/A00_Common/design/design_view_profile_sections.ts +++ b/A00_Common/design/design_view_profile_sections.ts @@ -29,6 +29,8 @@ export interface ProfileViewState { detail: SectionDetailResponse | null; /** [예상형상] 표시 상태 — 측점 바를 계획고 위로 올릴지 판단한다(패널 기본 ON). */ corridorVisible: boolean; + /** 3D 길 모형이 지금 정본보다 낡았나 — [3D 업데이트] 표시와 한 값 · 모달을 열 때 다시 짓는 기준(345-4). */ + corridorPending: boolean; routeReady: boolean; restoring: boolean; } @@ -92,7 +94,7 @@ export function createProfileSections(deps: ProfileSectionsDeps) { detail, routePoints, profilePanel.alignmentSamples() ?? undefined, - ).then((fresh) => panel.setCorridorPending(!fresh)); + ).then((fresh) => setCorridorPending(!fresh)); } } @@ -152,6 +154,12 @@ export function createProfileSections(deps: ProfileSectionsDeps) { } } + /** 「3D 늦음」 — [3D 업데이트] 표시(setCorridorPending 그대로)와 상태 한 칸을 같이 맞춘다. */ + function setCorridorPending(pending: boolean): void { + state.corridorPending = pending; + deps.panel().setCorridorPending(pending); + } + function renderLatest(next: RouteLatestResponse): void { state.latest = next; state.routeReady = Boolean(next.route && next.route_points.length > 1); @@ -175,5 +183,6 @@ export function createProfileSections(deps: ProfileSectionsDeps) { renderSections, restoreSections, renderLatest, + setCorridorPending, }; } diff --git a/resources/tester/helper_345_2_3d_modal.cjs b/resources/tester/helper_345_2_3d_modal.cjs index 83466b52a..2bda4f7c1 100644 --- a/resources/tester/helper_345_2_3d_modal.cjs +++ b/resources/tester/helper_345_2_3d_modal.cjs @@ -111,6 +111,7 @@ const frames = (n) => { let terrainLoads = 0; let resolveTerrain; +let opens = 0; // 345-4 — 열 때(지형 뒤) 낡은 3D 길 모형 다시 짓기 부름 const view3d = createViewer3dModal({ viewer, loadTerrain: () => { @@ -118,6 +119,7 @@ const view3d = createViewer3dModal({ return new Promise((resolve) => (resolveTerrain = resolve)); }, visible: () => true, + onOpen: () => (opens += 1), }); (async () => { @@ -131,12 +133,15 @@ const view3d = createViewer3dModal({ const first = modals[0]; out.wide = first.opts.wide === true; out.inModal = viewer.root.parent === first.body; + await new Promise((r) => setImmediate(r)); + out.opensBeforeTerrain = opens; out.spinnerWhileLoading = viewer.root.children.length === 1; frames(3); out.openDraws = viewer.draws; resolveTerrain(); await new Promise((r) => setImmediate(r)); out.spinnerGone = viewer.root.children.length === 0; + out.opensAfterTerrain = opens; // 닫기 단추 · Esc · 바깥 누름 = beforeClose 뒤 닫힘(공용 모달 규칙) out.beforeCloseOk = (await first.opts.beforeClose()) === true; @@ -145,6 +150,8 @@ const view3d = createViewer3dModal({ out.afterCloseDraws = viewer.draws; out.backInPark = viewer.root.parent === view3d.park; out.disposedOnClose = viewer.disposed; + await new Promise((r) => setImmediate(r)); + out.opensWhileClosed = opens; view3d.open(); view3d.open(); // 열린 동안 또 눌러도 하나 @@ -152,10 +159,27 @@ const view3d = createViewer3dModal({ out.terrainLoads = terrainLoads; frames(2); out.reopenDraws = viewer.draws; + await new Promise((r) => setImmediate(r)); + out.opensAfterReopen = opens; view3d.dispose(); // 페이지 떠남 — 열려 있으면 닫고 버림 out.closedOnDispose = modals[1].open === false; out.disposed = viewer.disposed; out.runningAfterDispose = viewer.running; + + // 지형 받는 사이 닫으면 — 다시 짓기 안 부름(닫힌 동안 짓기 0) + let lateOpens = 0; + let lateResolve; + const late = createViewer3dModal({ + viewer: { root: new FakeNode("div"), setActive() {}, dispose() {} }, + loadTerrain: () => new Promise((resolve) => (lateResolve = resolve)), + visible: () => true, + onOpen: () => (lateOpens += 1), + }); + late.open(); + late.close(); + lateResolve(); + await new Promise((r) => setImmediate(r)); + out.closedBeforeTerrainOpens = lateOpens; console.log(JSON.stringify(out)); })(); diff --git a/resources/tester/test_345_2_3d_modal.py b/resources/tester/test_345_2_3d_modal.py index 751a94981..457e5fe0d 100644 --- a/resources/tester/test_345_2_3d_modal.py +++ b/resources/tester/test_345_2_3d_modal.py @@ -48,6 +48,15 @@ def test_처음_열_때_지형_한_번_로딩_원(out): assert out["reopenDraws"] == 5 +def test_열_때만_다시_짓기_닫힌_동안_0(out): + """345-4 — 낡은 3D 길 모형은 모달을 열 때(지형이 선 뒤)만 다시 짓기를 부름 · 닫혀 있으면 안 부름.""" + assert out["opensBeforeTerrain"] == 0 + assert out["opensAfterTerrain"] == 1 + assert out["opensWhileClosed"] == 1 + assert out["opensAfterReopen"] == 2 # 다시 열 때 한 번(열린 동안 또 눌러도 하나) + assert out["closedBeforeTerrainOpens"] == 0 + + def test_떠날_때만_버림(out): assert out["beforeCloseOk"] is True assert out["closedOnDispose"] is True diff --git a/resources/tester/test_345_4_corridor_worker.py b/resources/tester/test_345_4_corridor_worker.py new file mode 100644 index 000000000..4a378e427 --- /dev/null +++ b/resources/tester/test_345_4_corridor_worker.py @@ -0,0 +1,72 @@ +"""345-4 3D 길 모형 웹 워커 — 워커 결과 = 주 스레드 결과(같은 입력) · 리본 좌표는 복사 없이(Transferable) · +서버 저장본이 먼저(해시 맞으면 받기만) · 저장 안 한 초안일 때만 워커 미리보기 · 모달을 열 때만 다시 짓기. + +셈은 Node 헬퍼 `helper_345_4_corridor_worker.cjs`(워커 경계 = structuredClone + transfer) · 배선은 글로 봄. +모달 닫힌 동안 짓기 0 은 `test_345_2_3d_modal.py`. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +ROOT = HERE.parents[1] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +@pytest.fixture(scope="module") +def out() -> dict: + run = subprocess.run( + ["node", str(HERE / "helper_345_4_corridor_worker.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + check=True, + ) + return json.loads(run.stdout) + + +def test_워커_결과는_주_스레드와_같음(out): + assert out["ribbonCount"] > 0 + assert out["sameAsMain"] is True and out["idKept"] is True + assert out["float64"] is True # 모델 좌표 해상도 그대로 + assert out["tooFew"] is None + assert out["fallbackSame"] is True # 워커 없는 곳은 주 스레드 같은 함수 + + +def test_리본_좌표는_복사_없이_넘김(out): + assert out["transferCount"] == out["ribbonCount"] + assert out["detached"] is True + + +def test_저장본_먼저_초안만_워커(): + corridor = _read("B05_Profile/B05_Profile_UI_Corridor.ts") + body = corridor[corridor.index("export async function ensureCorridor(") :] + body = body[: body.index("\n}\n")] + assert body.index("fetchStored(") < body.index("buildCorridorOffThread(") + assert "if (!rebuild) return null;" in body # 진입 = 저장본만(짓지 않음) + assert "buildCorridor(" not in body.replace("buildCorridorOffThread(", "") + client = _read("B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts") + assert 'new Worker(new URL("./B05_Profile_UI_Corridor_Worker.ts", import.meta.url)' in client + worker = _read("B05_Profile/B05_Profile_UI_Corridor_Worker.ts") + for banned in ("document.", "window.", "sessionStorage", 'from "three"'): + assert banned not in worker + + +def test_모달_열_때_낡았으면_다시_짓기(): + view3d = _read("A00_Common/design/design_view_profile_3d.ts") + assert "if (state.corridorPending) void refreshOnce();" in view3d + assert "onCorridorRefresh: () => refreshOnce()," in view3d + assert "deps.sections().setCorridorPending(false);" in view3d + sections = _read("A00_Common/design/design_view_profile_sections.ts") + assert ".then((fresh) => setCorridorPending(!fresh));" in sections + assert "deps.panel().setCorridorPending(pending);" in sections # 「3D 늦음」 표시 그대로 + view = _read("A00_Common/design/design_view_profile.ts") + assert "onCrossDesignsUpdated: () => sections.setCorridorPending(true)," in view