From 871693650b00999980d62242b63e39a055191083 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 16 Oct 2026 11:47:31 +0900 Subject: [PATCH] =?UTF-8?q?perf(3D):=20345-4=203D=20=EA=B8=B8=20=EB=AA=A8?= =?UTF-8?q?=ED=98=95=20=EC=9B=B9=20=EC=9B=8C=EC=BB=A4=20=E2=80=94=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=95=88=20=ED=95=9C=20=EC=B4=88=EC=95=88?= =?UTF-8?q?=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=EB=A5=BC=20=EC=9B=8C?= =?UTF-8?q?=EC=BB=A4=EC=97=90=EC=84=9C(buildCorridor=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C)=20=C2=B7=20=EB=A6=AC=EB=B3=B8=20=EC=A2=8C=ED=91=9C?= =?UTF-8?q?=20Transferable=20=C2=B7=20=EC=A3=BC=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C=20=EB=A9=88=EC=B6=A4=200=20=C2=B7=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EB=B3=B8=20=EB=A8=BC=EC=A0=80(=ED=95=B4=EC=8B=9C=20?= =?UTF-8?q?=EB=A7=9E=EC=9C=BC=EB=A9=B4=20=EB=B0=9B=EA=B8=B0=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)); +})();