Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1
This commit is contained in:
@@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
/** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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));
|
||||
})();
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user