perf(3D): 345-4 3D 길 모형 웹 워커 — 저장 안 한 초안 미리보기를 워커에서(buildCorridor 그대로) · 리본 좌표 Transferable · 주 스레드 멈춤 0 · 저장본 먼저(해시 맞으면 받기만)

- 새 _Corridor_Worker(워커 몸 · handleCorridorRequest) · _Corridor_Worker_Client(buildCorridorOffThread · 워커 없거나 깨지면 주 스레드 같은 함수)
- ensureCorridor 의 짓기만 워커로 · vite 설정 그대로(new Worker(new URL(…)) 기본 지원)
- 서버 Node 묶음 입구 · Build 안 고침(다시 짓기 불필요)

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 593ee198aa
commit 871693650b
4 changed files with 269 additions and 4 deletions
+6 -4
View File
@@ -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,
@@ -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));
};
}
@@ -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<number, (response: CorridorWorkerResponse | null) => 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<CorridorWorkerResponse>) => {
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<CorridorBuildResult | null> {
const onMain = (): CorridorBuildResult | null =>
buildCorridor(crossSections, routePoints, designSamples);
const target = getWorker();
if (!target) return onMain();
const id = ++seq;
const response = await new Promise<CorridorWorkerResponse | null>((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;
}
@@ -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));
})();