Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1

This commit is contained in:
2026-10-16 11:53:52 +09:00
15 changed files with 467 additions and 49 deletions
+2 -6
View File
@@ -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,
@@ -135,7 +134,7 @@ export async function mountProfileView(
{
// 계획선 편집 프리뷰가 횡단 설계선을 갱신해도 3D는 **자동으로 따라오지 않는다**
// (2026-09-01 사용자 지시). 갱신은 상단 [3D 업데이트] 버튼으로 모았다.
onCrossDesignsUpdated: () => panel.setCorridorPending(true),
onCrossDesignsUpdated: () => sections.setCorridorPending(true),
// 관 매설 목록 → 그래프 배관 측점선·통합 목록을 한 방향으로 맞춘다. 정본은 배수유역도의
// 관 지점이다. 숨어 있을 때는 담아만 두고 다시 보일 때 싣는다(폼은 한 벌이라 횡단 보기 몫).
onPipesChanged: (pipes) => {
@@ -188,6 +187,7 @@ export async function mountProfileView(
roadWidths: DEFAULT_ROAD_WIDTHS,
detail: null,
corridorVisible: true,
corridorPending: false,
routeReady: false,
restoring: true,
};
@@ -404,10 +404,6 @@ export async function mountProfileView(
}
profilePick.paint();
},
// 보기를 떠나는 자리 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
onHide: () => {
if (state.latest?.route?.id) void saveCorridorIfDirty(activeProjectId, state.latest.route.id);
},
});
/** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를
+33 -20
View File
@@ -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";
@@ -48,27 +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);
// 만든 것을 **바로 올려 둔다**(2026-09-06) — 다음 진입이 네트워크 없이 선다.
void saveCorridorIfDirty(projectId, latest.route.id);
},
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 {
@@ -122,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,
};
}
+6 -7
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,
@@ -184,9 +186,6 @@ export async function ensureCorridor(
return build;
}
/** 옛 올림 자리 — 3D 길 모형은 서버가 [저장]·[확정] 때 지음(342-2). 부르는 곳이 지워지면 함께 지움. */
export async function saveCorridorIfDirty(_projectId: string, _routeId: number): Promise<void> {}
/** ③ 사면이 읽을 측점 단면을 얹는다(115-1 · 저장본에는 안 담긴다). */
function withSections(
build: CorridorBuildResult | null,
@@ -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;
}
@@ -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);
},
};
}
@@ -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<ContourResponse>(
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;
+2 -2
View File
@@ -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 객체를 만들면 수백 개가 된다. 주곡선·보조곡선 두 덩어리로 합친다.
@@ -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));
})();
@@ -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));
})();
+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,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
@@ -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