From 593ee198aa70667df93317647fd557f33e2382e3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:39:56 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor(=EC=A2=85=EB=8B=A8=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0):=20345-4=20=EC=A0=95=EB=A6=AC=20=E2=80=94=20?= =?UTF-8?q?=EB=B9=88=20saveCorridorIfDirty=20=EC=99=80=20=EB=B6=80?= =?UTF-8?q?=EB=A5=B4=EB=8A=94=20=EA=B3=B3=20=EC=A7=80=EC=9B=80=20=C2=B7=20?= =?UTF-8?q?B05=20=EB=93=B1=EA=B3=A0=EC=84=A0=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=EB=91=90=20=EA=B3=B3=20=3D=20contourAssetUrl(=EB=AF=B8?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=9B=EA=B8=B0=EC=99=80=20=EA=B0=99=EC=9D=80=20?= =?UTF-8?q?=ED=8C=8C=EC=9D=BC)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 3D 뷰어 · 노선 편집 바탕 등고선이 같은 주소 한 벌(recalculate=false 붙음 · 보관함 한 칸) - 시험: test_345_4_contour_one_url 2 · 관련 250 통과 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- A00_Common/design/design_view_profile.ts | 5 --- A00_Common/design/design_view_profile_3d.ts | 4 +-- B05_Profile/B05_Profile_UI_Corridor.ts | 3 -- B05_Profile/B05_Profile_UI_Page_Actions.ts | 4 --- .../B05_Profile_UI_RouteEdit_Contour.ts | 6 ++-- B05_Profile/B05_Profile_UI_Viewer.ts | 4 +-- .../tester/test_345_4_contour_one_url.py | 35 +++++++++++++++++++ 7 files changed, 40 insertions(+), 21 deletions(-) create mode 100644 resources/tester/test_345_4_contour_one_url.py diff --git a/A00_Common/design/design_view_profile.ts b/A00_Common/design/design_view_profile.ts index 75eb2d58b..0ed5e9664 100644 --- a/A00_Common/design/design_view_profile.ts +++ b/A00_Common/design/design_view_profile.ts @@ -53,7 +53,6 @@ import { structureAnchorM, } from "../../B05_Profile/B05_Profile_Api_Structures"; import { pipeToFacilityItem } from "../../B05_Profile/B05_Profile_UI_Structures_Panel_Types"; -import { saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor"; import { profileSaveParts, type PageActionContext, @@ -404,10 +403,6 @@ export async function mountProfileView( } profilePick.paint(); }, - // 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. - onHide: () => { - if (state.latest?.route?.id) void saveCorridorIfDirty(activeProjectId, state.latest.route.id); - }, }); /** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를 diff --git a/A00_Common/design/design_view_profile_3d.ts b/A00_Common/design/design_view_profile_3d.ts index 96f999fe7..833af8f2e 100644 --- a/A00_Common/design/design_view_profile_3d.ts +++ b/A00_Common/design/design_view_profile_3d.ts @@ -7,7 +7,7 @@ import { showToast } from "@ui/ui_template_elements"; import { updateContourInterval } from "../../B05_Profile/B05_Profile_Api_Fetch"; -import { refreshCorridor, saveCorridorIfDirty } from "../../B05_Profile/B05_Profile_UI_Corridor"; +import { refreshCorridor } from "../../B05_Profile/B05_Profile_UI_Corridor"; import type { createLatestSource } from "../../B05_Profile/B05_Profile_UI_Page_Latest"; import { toBounds } from "../../B05_Profile/B05_Profile_UI_Page_Helpers"; import type { ViewControlCallbacks } from "../../B05_Profile/B05_Profile_UI_Panel_View"; @@ -64,8 +64,6 @@ export function createProfile3d(deps: Profile3dDeps) { ); // 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③). deps.sections().renderStationLines(detail); - // 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다. - void saveCorridorIfDirty(projectId, latest.route.id); }, }; diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 7bf2963c5..321cd5de3 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -184,9 +184,6 @@ export async function ensureCorridor( return build; } -/** 옛 올림 자리 — 3D 길 모형은 서버가 [저장]·[확정] 때 지음(342-2). 부르는 곳이 지워지면 함께 지움. */ -export async function saveCorridorIfDirty(_projectId: string, _routeId: number): Promise {} - /** ③ 사면이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */ function withSections( build: CorridorBuildResult | null, diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index 5d6fc7ef8..d4b27c095 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -13,7 +13,6 @@ import { invalidateSectionDetail, saveCachedCrossPatches, } from "../B06_Section/B06_Section_Section_Store"; -import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor"; import type { createRoutePanel } from "./B05_Profile_UI_Panel"; import type { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel"; import type { createStructuresBridge } from "./B05_Profile_UI_Page_Structures"; @@ -87,9 +86,6 @@ export function profileSaveParts(ctx: PageActionContext): DesignViewSave { // 서버가 종단 정본의 계획선을 다시 썼다 — 공유 캐시를 비워 횡단 보기가 옛 계획선을 못 보게 한다. invalidateSectionDetail(projectId); ctx.renderLatest(await ctx.loadLatest(true)); - // 저장 = 코리도 영구저장 시점(2026-08-23) — 실패해도 저장은 성공 처리. - const routeId = ctx.latest()?.route?.id; - if (routeId) void saveCorridorIfDirty(projectId, routeId); }, }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index a1b0f9cea..709e616e8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -11,8 +11,7 @@ * 그리기·라벨·집기가 출처를 안 가리게 한다. * ========================================================================== */ -import { API_BASE_URL } from "@config/config_frontend"; -import { fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { createNormalizer, type GeoJsonCollection, @@ -71,8 +70,7 @@ export async function loadRouteEditContours( // 3D 뷰어가 쓰는 것과 **같은 파일**이다 — 보관함에 있으면 다시 내려받지 않는다. const data = await fetchCachedJson( projectId, - `${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` + - `/contour?interval=${interval}&smooth=${options.smooth}`, + contourAssetUrl(projectId, options.surfaceModelId, interval, options.smooth), ); const all = data.contours ?? []; const onUnit = (level: number): boolean => Math.abs(level % LAS_CONTOUR_UNIT_M) < 1e-6; diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 78f276413..619242cc5 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -4,7 +4,7 @@ import { createTerrainCompass } from "@ui/ui_template_compass"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js"; import { API_BASE_URL } from "@config/config_frontend"; -import { fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; +import { contourAssetUrl, fetchCachedBytes, fetchCachedJson } from "../A00_Common/b_asset_cache"; import { bindCursorPivotControls, TOP_VIEW_TILT } from "../B04_PreProcess/B04_PreProcess_UI_Camera"; import { createRouteMarkers, @@ -325,7 +325,7 @@ export function createRouteViewer(): RouteViewer { contours: Array<{ level: number; coordinates: [number, number, number][] }>; }>( current.projectId, - `${API_BASE_URL}/projects/${current.projectId}/surface/models/${current.modelId}/contour?interval=${interval}&smooth=${current.smooth}`, + contourAssetUrl(current.projectId, current.modelId, interval, current.smooth), ); clearContours(); // 등고선 한 가닥마다 3D 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다. diff --git a/resources/tester/test_345_4_contour_one_url.py b/resources/tester/test_345_4_contour_one_url.py new file mode 100644 index 000000000..b7db895b6 --- /dev/null +++ b/resources/tester/test_345_4_contour_one_url.py @@ -0,0 +1,35 @@ +"""345-4 정리 — B05 등고선 주소 한 벌(`contourAssetUrl` · 미리 받기와 같은 파일) · 빈 saveCorridorIfDirty 지움.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] + + +def _read(rel: str) -> str: + return (ROOT / rel).read_text(encoding="utf-8") + + +def test_B05_등고선_주소는_한_벌(): + for rel in ( + "B05_Profile/B05_Profile_UI_Viewer.ts", + "B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts", + ): + source = _read(rel) + assert "contourAssetUrl(" in source, rel + assert "/contour?interval" not in source, rel + # 미리 받기(b_asset_cache)도 같은 함수 — 주소가 같아야 보관함 한 칸을 같이 씀 + assert "contourAssetUrl(projectId, confirmed.model_id, interval, smooth)" in _read( + "A00_Common/b_asset_cache.ts" + ) + + +def test_빈_코리도_올림_함수_지움(): + for rel in ( + "B05_Profile/B05_Profile_UI_Corridor.ts", + "B05_Profile/B05_Profile_UI_Page_Actions.ts", + "A00_Common/design/design_view_profile.ts", + "A00_Common/design/design_view_profile_3d.ts", + ): + assert "saveCorridorIfDirty" not in _read(rel), rel From 871693650b00999980d62242b63e39a055191083 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:47:31 +0900 Subject: [PATCH 2/3] =?UTF-8?q?perf(3D):=20345-4=203D=20=EA=B8=B8=20?= =?UTF-8?q?=EB=AA=A8=ED=98=95=20=EC=9B=B9=20=EC=9B=8C=EC=BB=A4=20=E2=80=94?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=95=88=20=ED=95=9C=20=EC=B4=88?= =?UTF-8?q?=EC=95=88=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=9B=8C=EC=BB=A4=EC=97=90=EC=84=9C(buildCorridor=20=EA=B7=B8?= =?UTF-8?q?=EB=8C=80=EB=A1=9C)=20=C2=B7=20=EB=A6=AC=EB=B3=B8=20=EC=A2=8C?= =?UTF-8?q?=ED=91=9C=20Transferable=20=C2=B7=20=EC=A3=BC=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=EB=A9=88=EC=B6=A4=200=20=C2=B7=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EB=B3=B8=20=EB=A8=BC=EC=A0=80(=ED=95=B4?= =?UTF-8?q?=EC=8B=9C=20=EB=A7=9E=EC=9C=BC=EB=A9=B4=20=EB=B0=9B=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 _Corridor_Worker(워커 몸 · handleCorridorRequest) · _Corridor_Worker_Client(buildCorridorOffThread · 워커 없거나 깨지면 주 스레드 같은 함수) - ensureCorridor 의 짓기만 워커로 · vite 설정 그대로(new Worker(new URL(…)) 기본 지원) - 서버 Node 묶음 입구 · Build 안 고침(다시 짓기 불필요) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn --- B05_Profile/B05_Profile_UI_Corridor.ts | 10 +- B05_Profile/B05_Profile_UI_Corridor_Worker.ts | 58 ++++++++ .../B05_Profile_UI_Corridor_Worker_Client.ts | 73 ++++++++++ .../tester/helper_345_4_corridor_worker.cjs | 132 ++++++++++++++++++ 4 files changed, 269 insertions(+), 4 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Worker.ts create mode 100644 B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts create mode 100644 resources/tester/helper_345_4_corridor_worker.cjs diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 321cd5de3..46a4d4c73 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -6,8 +6,9 @@ * 1) 짓기 = 서버 Node(`B05_Profile_Corridor_Prebuild`) — 자동 체인 · 노선 [확인] 체인 · * B06 [저장] · [확정] 끝. 브라우저는 올리지 않는다(PUT 없음). * 2) 받기: 저장본 GET → 버전 해시가 현 정본과 맞으면 그대로 표시. - * 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] 때만 브라우저가 미리보기로 - * 지음 — 메모리에만 · 올리지 않음. + * 3) 어긋나면(저장 안 한 초안 — 벽 · 구조물) [3D 업데이트] · 낡은 채 3D 모달을 열 때만 브라우저가 + * 미리보기로 지음 — **웹 워커**(`_Corridor_Worker_Client` · 345-4 · 주 스레드 멈춤 0) · 메모리에만 · + * 올리지 않음. * 버전 해시 = 종횡단 정본의 설계 입력 요약(FNV-1a) — 측점·계획고·설계선이 * 바뀌면 달라진다. 구조물 서피스 훅(CorridorStructureHook)은 예약만(범위 제외). * ========================================================================== */ @@ -21,7 +22,8 @@ import { import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import { refreshStaleDesigns } from "../B06_Section/B06_Section_Cross_Refresh"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; -import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import type { CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import { buildCorridorOffThread } from "./B05_Profile_UI_Corridor_Worker_Client"; import { corridorHash, ENVELOPE_VERSION, @@ -169,7 +171,7 @@ export async function ensureCorridor( // 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다. // 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는 // 편집 전 값이라 전환점·종단 보정이 낡은 계획고를 따라간다(2026-08-23 지적 ③). - const build = buildCorridor( + const build = await buildCorridorOffThread( detail.cross_sections, routePoints, designSamples ?? detail.longitudinal.design_profiles?.[0]?.samples, diff --git a/B05_Profile/B05_Profile_UI_Corridor_Worker.ts b/B05_Profile/B05_Profile_UI_Corridor_Worker.ts new file mode 100644 index 000000000..6c605be99 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Worker.ts @@ -0,0 +1,58 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Worker.ts + * 3D 길 모형 **웹 워커**(345-4) — 저장 안 한 초안의 미리보기를 주 스레드 밖에서 짓는다. + * + * 짓는 셈은 `buildCorridor` 그대로(서버 Node 묶음 `B05_Profile_Corridor_Node` 와 같은 순수 계산) — + * 새로 짜지 않는다. 결과의 리본 좌표(Float64Array)는 복사 없이 넘긴다(Transferable). + * 부르는 쪽은 `_Corridor_Worker_Client`. 이 파일 맨 위에 DOM · window 를 두지 않는다. + * ========================================================================== */ + +import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; + +export interface CorridorWorkerRequest { + id: number; + crossSections: CrossSection[]; + routePoints: RoutePoint[]; + designSamples?: Array<{ chainage_m: number; elevation_m: number; ground_elevation_m?: number }>; +} + +export interface CorridorWorkerResponse { + id: number; + build: CorridorBuildResult | null; + error?: string; +} + +/** 넘길 버퍼 — 리본 좌표(가장 큼). 나머지는 구조화 복제. */ +export function corridorTransfers(build: CorridorBuildResult | null): ArrayBuffer[] { + return build ? build.ribbons.map((ribbon) => ribbon.positions.buffer as ArrayBuffer) : []; +} + +/** 요청 하나 — 워커 안에서도 시험에서도 같은 함수. */ +export function handleCorridorRequest(request: CorridorWorkerRequest): CorridorWorkerResponse { + try { + const build = buildCorridor(request.crossSections, request.routePoints, request.designSamples); + return { id: request.id, build }; + } catch (error) { + return { + id: request.id, + build: null, + error: error instanceof Error ? error.message : "짓기 실패", + }; + } +} + +interface WorkerScope { + onmessage: ((event: { data: CorridorWorkerRequest }) => void) | null; + postMessage: (message: CorridorWorkerResponse, transfer: ArrayBuffer[]) => void; +} + +// 워커로 돌 때만 듣는다(모듈을 시험 · 주 스레드가 들여와도 아무 일 없음). +if (typeof (globalThis as { WorkerGlobalScope?: unknown }).WorkerGlobalScope !== "undefined") { + const scope = globalThis as unknown as WorkerScope; + scope.onmessage = (event) => { + const response = handleCorridorRequest(event.data); + scope.postMessage(response, corridorTransfers(response.build)); + }; +} diff --git a/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts new file mode 100644 index 000000000..8d2f43114 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Corridor_Worker_Client.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * B05_Profile_UI_Corridor_Worker_Client.ts + * 3D 길 모형 워커 부르기(345-4) — `buildCorridorOffThread` 가 워커(`_Corridor_Worker`)에 맡겨 주 스레드 + * 멈춤 없이 짓는다. 워커를 못 쓰는 곳(시험 · 옛 브라우저) · 워커가 깨지면 주 스레드에서 같은 함수로 짓는다. + * 워커는 처음 부를 때 하나 세워 페이지 수명 동안 다시 쓴다. + * ========================================================================== */ + +import type { RoutePoint } from "./B05_Profile_Api_Fetch"; +import type { CrossSection } from "../B06_Section/B06_Section_Api_Fetch"; +import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; +import type { + CorridorWorkerRequest, + CorridorWorkerResponse, +} from "./B05_Profile_UI_Corridor_Worker"; + +type Samples = CorridorWorkerRequest["designSamples"]; + +let worker: Worker | null = null; +let seq = 0; +const waits = new Map void>(); + +function settleAll(): void { + waits.forEach((resolve) => resolve(null)); + waits.clear(); +} + +function getWorker(): Worker | null { + if (worker) return worker; + if (typeof Worker === "undefined") return null; + try { + worker = new Worker(new URL("./B05_Profile_UI_Corridor_Worker.ts", import.meta.url), { + type: "module", + }); + } catch { + return null; + } + worker.onmessage = (event: MessageEvent) => { + const resolve = waits.get(event.data.id); + waits.delete(event.data.id); + resolve?.(event.data); + }; + // 워커가 깨지면 버리고 기다리던 것은 주 스레드로 — 다음 부름은 새 워커. + worker.onerror = () => { + worker?.terminate(); + worker = null; + settleAll(); + }; + return worker; +} + +/** 초안 미리보기 짓기 — 결과는 `buildCorridor` 와 같음(같은 입력 · 같은 함수). */ +export async function buildCorridorOffThread( + crossSections: CrossSection[], + routePoints: RoutePoint[], + designSamples?: Samples, +): Promise { + const onMain = (): CorridorBuildResult | null => + buildCorridor(crossSections, routePoints, designSamples); + const target = getWorker(); + if (!target) return onMain(); + const id = ++seq; + const response = await new Promise((resolve) => { + waits.set(id, resolve); + try { + target.postMessage({ id, crossSections, routePoints, designSamples }); + } catch { + waits.delete(id); // 복제 못 하는 값 — 주 스레드로 + resolve(null); + } + }); + if (!response || response.error) return onMain(); + return response.build; +} diff --git a/resources/tester/helper_345_4_corridor_worker.cjs b/resources/tester/helper_345_4_corridor_worker.cjs new file mode 100644 index 000000000..fa6edd906 --- /dev/null +++ b/resources/tester/helper_345_4_corridor_worker.cjs @@ -0,0 +1,132 @@ +/* 345-4 3D 길 모형 웹 워커 헬퍼 — 워커 몸(`handleCorridorRequest`) · 부르기(`buildCorridorOffThread`)를 + * 트랜스파일해 같은 입력으로 주 스레드 `buildCorridor` 와 견줌. 워커 경계는 structuredClone(+transfer)로 흉내 — + * 브라우저 postMessage 와 같은 복제 규칙. 측점 꼴은 helper_115_8_corridor_loft.cjs 와 같다. 결과는 JSON 한 벌. + */ +const fs = require("fs"); +const path = require("path"); +const ROOT = path.join(__dirname, "..", ".."); +const ts = require(path.join(ROOT, "config", "node_modules", "typescript")); + +const cache = new Map(); +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts + .transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }) + .outputText.replace(/import\.meta\.url/g, '"file:///x.js"'); + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (request.startsWith("@ui/") || request.startsWith("@config/")) + return new Proxy({}, { get: () => () => undefined }); + const target = request.startsWith("@util/") + ? path.join(ROOT, "common_util", request.slice(6)) + : path.join(path.dirname(full), request); + if (request.endsWith(".json")) return JSON.parse(fs.readFileSync(target, "utf8")); + return loadTs(fs.existsSync(`${target}.ts`) ? `${target}.ts` : path.join(target, "index.ts")); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + return box.exports; +} + +const B05 = (name) => path.join(ROOT, "B05_Profile", `${name}.ts`); +const { buildCorridor } = loadTs(B05("B05_Profile_UI_Corridor_Build")); +const { handleCorridorRequest, corridorTransfers } = loadTs(B05("B05_Profile_UI_Corridor_Worker")); +const { buildCorridorOffThread } = loadTs(B05("B05_Profile_UI_Corridor_Worker_Client")); + +const HALF = 3; +const ROAD_Z = 100; +function station(chainage, cx, cy, lx, ly, groundZ, ratio) { + const w = Math.abs(groundZ - ROAD_Z) * ratio; + const mode = groundZ < ROAD_Z ? "both_fill" : "both_cut"; + const pts = [ + [-40, groundZ], + [-(HALF + w), groundZ], + [-HALF, ROAD_Z], + [-2.5, ROAD_Z], + [2.5, ROAD_Z], + [HALF, ROAD_Z], + [HALF + w, groundZ], + [40, groundZ], + ]; + const samples = []; + for (let o = -40; o <= 40; o += 1) samples.push({ offset_m: o, elevation_m: groundZ }); + return { + station_id: `S${chainage}`, + chainage_m: chainage, + label: "", + kind: "regular", + center_x: cx, + center_y: cy, + center_z: groundZ, + frame: { left_xy: [lx, ly] }, + samples, + design: { + section_mode: mode, + road_edges: { + left: { offset_m: HALF, elevation_m: ROAD_Z }, + right: { offset_m: -HALF, elevation_m: ROAD_Z }, + }, + carriageway_edges: { + left: { offset_m: 2.5, elevation_m: ROAD_Z }, + right: { offset_m: -2.5, elevation_m: ROAD_Z }, + }, + design_line: pts.map(([offset_m, elevation_m]) => ({ offset_m, elevation_m })), + ditch: { type: "none" }, + ditch_enabled: false, + ditch_side: "left", + }, + }; +} + +const sections = [ + station(0, 0, 0, 0, 1, 96, 1.5), + station(20, 20, 0, 0, 1, 104, 1.0), + station(40, 40, 0, 0, 1, 96, 1.5), +]; +const route = [ + { x: 0, y: 0 }, + { x: 40, y: 0 }, +]; +const designSamples = [ + { chainage_m: 0, elevation_m: 100, ground_elevation_m: 96 }, + { chainage_m: 20, elevation_m: 100, ground_elevation_m: 104 }, + { chainage_m: 40, elevation_m: 100, ground_elevation_m: 96 }, +]; + +/** 비교용 평탄화 — 타입 배열은 숫자 배열로. */ +const plain = (value) => + JSON.parse(JSON.stringify(value, (_k, v) => (ArrayBuffer.isView(v) ? Array.from(v) : v))); + +(async () => { + const out = {}; + const direct = buildCorridor(sections, route, designSamples); + // 워커 경계 흉내 — 요청 · 응답 둘 다 구조화 복제 · 응답은 리본 버퍼를 넘김(transfer) + const request = structuredClone({ + id: 7, + crossSections: sections, + routePoints: route, + designSamples, + }); + const response = handleCorridorRequest(request); + const transfer = corridorTransfers(response.build); + const received = structuredClone(response, { transfer }); + out.ribbonCount = direct.ribbons.length; + out.idKept = received.id === 7; + out.sameAsMain = JSON.stringify(plain(received.build)) === JSON.stringify(plain(direct)); + out.float64 = received.build.ribbons.every((r) => r.positions instanceof Float64Array); + out.transferCount = transfer.length; + out.detached = response.build.ribbons.every((r) => r.positions.byteLength === 0); // 복사 없이 넘김 + // 측점 모자람 → null(워커도 같음) + out.tooFew = handleCorridorRequest({ + id: 1, + crossSections: [sections[0]], + routePoints: route, + }).build; + // 워커 없는 곳(시험 · 옛 브라우저) — 부르기가 주 스레드 같은 함수로 + const fallback = await buildCorridorOffThread(sections, route, designSamples); + out.fallbackSame = JSON.stringify(plain(fallback)) === JSON.stringify(plain(direct)); + console.log(JSON.stringify(out)); +})(); From 519b54eb7378752e27f74f4575c3e1323aa7a8d4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:47:31 +0900 Subject: [PATCH 3/3] =?UTF-8?q?feat(=EC=A2=85=EB=8B=A8=EB=B3=B4=EA=B8=B0):?= =?UTF-8?q?=20345-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