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
@@ -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