feat(종단보기): 345-4 3D 모달 열 때 낡았으면 자동 다시 짓기 — 닫혀 있는 동안 안 지음 · 「3D 늦음」 = setCorridorPending 그대로 · 단추 · 열기 겹쳐도 한 번

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
This commit is contained in:
2026-10-16 11:47:31 +09:00
co-authored by Claude Opus 5.5
parent 871693650b
commit 519b54eb73
7 changed files with 158 additions and 24 deletions
+2 -1
View File
@@ -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,
};
+32 -17
View File
@@ -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<void> | null = null;
function refreshOnce(): Promise<void> {
refreshing ??= refreshCorridorNow().finally(() => (refreshing = null));
return refreshing;
}
/** 낡은 3D 길 모형을 지금 노선 · 계획선으로 — 저장본이 맞으면 받기만 · 아니면 워커 미리보기. */
async function refreshCorridorNow(): Promise<void> {
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<void> {
// 화면 전체를 잠그지 않는다 — 진행 표시는 [재적용] 버튼이 맡는다(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 };
@@ -18,6 +18,8 @@ export interface Viewer3dModalDeps {
loadTerrain: () => Promise<void>;
/** 종단 보기가 보이는가 — 숨은 보기에서는 안 연다. */
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?.()); // 그 사이 닫았으면 안 지음
}
/** 열려 있으면 닫는다 — 보기를 떠날 때 · 페이지를 떠날 때. */
@@ -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,
};
}
@@ -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));
})();
+9
View File
@@ -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
@@ -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