From 519b54eb7378752e27f74f4575c3e1323aa7a8d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:47:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8=EB=B3=B4=EA=B8=B0):=203?= =?UTF-8?q?45-4=203D=20=EB=AA=A8=EB=8B=AC=20=EC=97=B4=20=EB=95=8C=20?= =?UTF-8?q?=EB=82=A1=EC=95=98=EC=9C=BC=EB=A9=B4=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EC=A7=93=EA=B8=B0=20=E2=80=94=20=EB=8B=AB?= =?UTF-8?q?=ED=98=80=20=EC=9E=88=EB=8A=94=20=EB=8F=99=EC=95=88=20=EC=95=88?= =?UTF-8?q?=20=EC=A7=80=EC=9D=8C=20=C2=B7=20=E3=80=8C3D=20=EB=8A=A6?= =?UTF-8?q?=EC=9D=8C=E3=80=8D=20=3D=20setCorridorPending=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C=20=C2=B7=20=EB=8B=A8=EC=B6=94=20=C2=B7=20?= =?UTF-8?q?=EC=97=B4=EA=B8=B0=20=EA=B2=B9=EC=B3=90=EB=8F=84=20=ED=95=9C=20?= =?UTF-8?q?=EB=B2=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProfileViewState.corridorPending(표시와 한 칸) · sections.setCorridorPending · 모달 onOpen(지형 선 뒤 · 그 사이 닫으면 안 부름) - 시험: test_345_4_corridor_worker 4(워커 = 주 스레드 · transfer 9/9 · 저장본 먼저) · test_345_2_3d_modal 5(닫힌 동안 짓기 0) · 관련 310 통과 · typecheck 내 파일 0 오류 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_view_profile.ts | 3 +- A00_Common/design/design_view_profile_3d.ts | 49 ++++++++----- .../design/design_view_profile_3d_modal.ts | 14 ++-- .../design/design_view_profile_sections.ts | 11 ++- resources/tester/helper_345_2_3d_modal.cjs | 24 +++++++ resources/tester/test_345_2_3d_modal.py | 9 +++ .../tester/test_345_4_corridor_worker.py | 72 +++++++++++++++++++ 7 files changed, 158 insertions(+), 24 deletions(-) create mode 100644 resources/tester/test_345_4_corridor_worker.py 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