feat(all): 离线包加载测试

This commit is contained in:
plum 2026-04-09 00:20:33 +08:00
parent 151bc7c8a2
commit d8f98ee295
7 changed files with 1667 additions and 139 deletions

BIN
Test/1121212-offline.astral Normal file

Binary file not shown.

BIN
Test/1121212.astral Normal file

Binary file not shown.

Binary file not shown.

364
Test/index-nosplit.html Normal file
View File

@ -0,0 +1,364 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TkAstral3D 非分包离线测试</title>
<style>
:root {
--bg: #0b1020;
--panel: rgba(15, 23, 42, 0.88);
--line: rgba(148, 163, 184, 0.24);
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #22c55e;
--accent-2: #38bdf8;
--danger: #f87171;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 32%),
radial-gradient(circle at top right, rgba(34, 197, 94, 0.14), transparent 28%),
linear-gradient(180deg, #0b1020, #050814 72%);
color: var(--text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.app {
display: grid;
grid-template-columns: 360px 1fr;
width: 100%;
height: 100%;
}
.sidebar {
padding: 20px;
border-right: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(10px);
overflow: auto;
}
.viewer-wrap {
position: relative;
min-width: 0;
min-height: 0;
}
#viewer {
width: 100%;
height: 100%;
}
h1 {
margin: 0 0 10px;
font-size: 24px;
line-height: 1.2;
}
.desc,
.hint,
.meta,
.log {
color: var(--muted);
line-height: 1.6;
}
.desc {
margin-bottom: 16px;
font-size: 14px;
}
.card {
margin-top: 16px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(2, 6, 23, 0.36);
}
.card h2 {
margin: 0 0 10px;
font-size: 15px;
color: #f8fafc;
}
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-top: 12px;
}
button,
input[type="file"] {
font: inherit;
}
button {
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.16), rgba(34, 197, 94, 0.16));
color: var(--text);
cursor: pointer;
}
button.secondary {
background: rgba(15, 23, 42, 0.9);
}
input[type="file"] {
width: 100%;
color: var(--muted);
}
.status {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(15, 23, 42, 0.72);
font-size: 13px;
white-space: pre-wrap;
}
.status.success {
border-color: rgba(34, 197, 94, 0.35);
color: #bbf7d0;
}
.status.error {
border-color: rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.file-list {
margin: 10px 0 0;
padding-left: 18px;
font-size: 13px;
color: var(--muted);
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.1);
border: 1px solid rgba(56, 189, 248, 0.18);
font-size: 12px;
color: #bae6fd;
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<h1>Tk 非分包离线测试</h1>
<div class="desc">
这个页面直接使用 Tk 构建产物来验证离线包加载。
仅支持非分包单文件模式:选择 1 个 .astral 文件加载。
</div>
<div class="row">
<span class="tag">SDK: TkAstral3D/Test/dist/astral3d.umd.js</span>
</div>
<div class="card">
<h2>选择离线包</h2>
<input id="fileInput" type="file" accept=".astral" />
<div class="row">
<button id="loadBtn">加载离线包</button>
<button id="loadLocalBtn" class="secondary">直接加载本目录</button>
<button id="resetBtn" class="secondary">重置场景</button>
</div>
<div id="status" class="status">等待选择 .astral 文件,或点击“直接加载本目录”</div>
<ul id="fileList" class="file-list"></ul>
</div>
<div class="card">
<h2>说明(非分包)</h2>
<div class="hint">
1. 仅选择一个非分包导出的 .astral 文件。\n
2. 页面使用 SDK 的 Package.unpack 直接加载,不依赖页面层 JSZip。\n
3. 也可点击“直接加载本目录”,默认读取 1121212.astral。\n
4. 建议通过本地静态服务器打开此页面,而不是直接 file:// 打开。
</div>
</div>
</aside>
<main class="viewer-wrap">
<div id="viewer"></div>
</main>
</div>
<script src="./dist/astral3d.umd.js"></script>
<script>
(function () {
const SDK = globalThis.Astral3D;
if (!SDK) {
document.getElementById("status").textContent = "Tk SDK 加载失败,请检查 astral3d.umd.js 路径";
document.getElementById("status").className = "status error";
return;
}
const viewerEl = document.getElementById("viewer");
const fileInput = document.getElementById("fileInput");
const loadBtn = document.getElementById("loadBtn");
const loadLocalBtn = document.getElementById("loadLocalBtn");
const resetBtn = document.getElementById("resetBtn");
const statusEl = document.getElementById("status");
const fileListEl = document.getElementById("fileList");
const LOCAL_SINGLE_FILE = "1121212.astral";
let viewer = null;
function setStatus(text, type) {
statusEl.textContent = text;
statusEl.className = "status" + (type ? ` ${type}` : "");
}
function ensureViewer() {
if (viewer) return viewer;
viewer = new SDK.Viewer({
container: viewerEl,
edit: { enabled: false },
grid: { enabled: true },
request: { baseUrl: "" },
});
globalThis.viewer = viewer;
return viewer;
}
function resetViewer() {
if (viewer && typeof viewer.dispose === "function") {
viewer.dispose();
}
viewer = null;
viewerEl.innerHTML = "";
setStatus("场景已重置", "success");
}
function renderFileList(files) {
fileListEl.innerHTML = "";
Array.from(files || []).forEach(file => {
const li = document.createElement("li");
li.textContent = `${file.name} (${Math.round(file.size / 1024)} KB)`;
fileListEl.appendChild(li);
});
}
async function loadByOfflineBundle(bundle, entryName) {
setStatus("正在准备离线包...", "");
const currentViewer = ensureViewer();
const packer = currentViewer.package || new SDK.Package(currentViewer);
currentViewer.package = packer;
setStatus(`开始加载离线包:${entryName}`, "");
await packer.unpack({
url: entryName,
offlineBundle: bundle,
offlineEntry: entryName,
onProgress: progress => {
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
},
onSceneLoad: () => {
setStatus("场景首包已解析,正在继续加载资源...", "");
},
onComplete: () => {
setStatus(`离线包加载完成:${entryName}`, "success");
},
});
}
async function fetchLocalAstralFile(name) {
const response = await fetch(`./${encodeURIComponent(name)}`);
if (!response.ok) {
throw new Error(`读取本地文件失败:${name}`);
}
const blob = await response.blob();
return new File([blob], name, { type: "application/octet-stream" });
}
function getSingleAstralFromFileList(fileList) {
const files = Array.from(fileList || []).filter(file => file.name.toLowerCase().endsWith(".astral"));
if (files.length === 0) {
throw new Error("请选择 .astral 文件");
}
if (files.length !== 1) {
throw new Error("非分包模式请只选择 1 个 .astral 文件");
}
return files[0];
}
async function loadOfflinePackage() {
try {
const file = getSingleAstralFromFileList(fileInput.files);
renderFileList([file]);
await loadByOfflineBundle(file, file.name);
} catch (error) {
console.error(error);
setStatus(`加载失败:${error && error.message ? error.message : error}`, "error");
}
}
async function loadLocalPackage() {
try {
renderFileList([{ name: LOCAL_SINGLE_FILE, size: 0 }]);
const file = await fetchLocalAstralFile(LOCAL_SINGLE_FILE);
await loadByOfflineBundle(file, LOCAL_SINGLE_FILE);
} catch (error) {
console.error(error);
setStatus(`本目录加载失败:${error && error.message ? error.message : error}`, "error");
}
}
fileInput.addEventListener("change", () => {
renderFileList(fileInput.files);
setStatus("文件已选择,点击“加载离线包”开始测试", "");
});
loadBtn.addEventListener("click", () => {
loadOfflinePackage();
});
loadLocalBtn.addEventListener("click", () => {
loadLocalPackage();
});
resetBtn.addEventListener("click", () => {
resetViewer();
});
ensureViewer();
setStatus("Tk SDK 已加载(非分包模式),等待选择离线包", "success");
})();
</script>
</body>
</html>

369
Test/index-split.html Normal file
View File

@ -0,0 +1,369 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TkAstral3D 分包离线测试</title>
<style>
:root {
--bg: #0b1020;
--panel: rgba(15, 23, 42, 0.88);
--line: rgba(148, 163, 184, 0.24);
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #22c55e;
--accent-2: #38bdf8;
--danger: #f87171;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 32%),
radial-gradient(circle at top right, rgba(34, 197, 94, 0.14), transparent 28%),
linear-gradient(180deg, #0b1020, #050814 72%);
color: var(--text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.app {
display: grid;
grid-template-columns: 360px 1fr;
width: 100%;
height: 100%;
}
.sidebar {
padding: 20px;
border-right: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(10px);
overflow: auto;
}
.viewer-wrap {
position: relative;
min-width: 0;
min-height: 0;
}
#viewer {
width: 100%;
height: 100%;
}
h1 {
margin: 0 0 10px;
font-size: 24px;
line-height: 1.2;
}
.desc,
.hint,
.meta,
.log {
color: var(--muted);
line-height: 1.6;
}
.desc {
margin-bottom: 16px;
font-size: 14px;
}
.card {
margin-top: 16px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(2, 6, 23, 0.36);
}
.card h2 {
margin: 0 0 10px;
font-size: 15px;
color: #f8fafc;
}
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-top: 12px;
}
button,
input[type="file"] {
font: inherit;
}
button {
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.16), rgba(34, 197, 94, 0.16));
color: var(--text);
cursor: pointer;
}
button.secondary {
background: rgba(15, 23, 42, 0.9);
}
input[type="file"] {
width: 100%;
color: var(--muted);
}
.status {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(15, 23, 42, 0.72);
font-size: 13px;
white-space: pre-wrap;
}
.status.success {
border-color: rgba(34, 197, 94, 0.35);
color: #bbf7d0;
}
.status.error {
border-color: rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.file-list {
margin: 10px 0 0;
padding-left: 18px;
font-size: 13px;
color: var(--muted);
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.1);
border: 1px solid rgba(56, 189, 248, 0.18);
font-size: 12px;
color: #bae6fd;
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<h1>Tk 分包离线测试</h1>
<div class="desc">
这个页面直接使用 Tk 构建产物来验证离线包加载。
仅支持分包模式:请一次选择多个 .astral 文件并加载。
</div>
<div class="row">
<span class="tag">SDK: TkAstral3D/Test/dist/astral3d.umd.js</span>
</div>
<div class="card">
<h2>选择离线包</h2>
<input id="fileInput" type="file" multiple accept=".astral" />
<div class="row">
<button id="loadBtn">加载离线包</button>
<button id="loadLocalBtn" class="secondary">直接加载本目录</button>
<button id="resetBtn" class="secondary">重置场景</button>
</div>
<div id="status" class="status">等待选择 .astral 文件,或点击“直接加载本目录”</div>
<ul id="fileList" class="file-list"></ul>
</div>
<div class="card">
<h2>说明(分包)</h2>
<div class="hint">
1. 请选择导出的全部分包 .astral 文件。\n
2. 至少包含入口包和依赖包后再点击加载。\n
3. 也可点击“直接加载本目录”,默认读取 1121212.astral + 7ff6dd32-25b3-4eea-abdc-6f316ecd668e.astral。\n
4. 建议通过本地静态服务器打开此页面,而不是直接 file:// 打开。
</div>
</div>
</aside>
<main class="viewer-wrap">
<div id="viewer"></div>
</main>
</div>
<script src="./dist/astral3d.umd.js"></script>
<script>
(function () {
const SDK = globalThis.Astral3D;
if (!SDK) {
document.getElementById("status").textContent = "Tk SDK 加载失败,请检查 astral3d.umd.js 路径";
document.getElementById("status").className = "status error";
return;
}
const viewerEl = document.getElementById("viewer");
const fileInput = document.getElementById("fileInput");
const loadBtn = document.getElementById("loadBtn");
const loadLocalBtn = document.getElementById("loadLocalBtn");
const resetBtn = document.getElementById("resetBtn");
const statusEl = document.getElementById("status");
const fileListEl = document.getElementById("fileList");
const LOCAL_SPLIT_FILES = [
"1121212.astral",
"7ff6dd32-25b3-4eea-abdc-6f316ecd668e.astral",
];
let viewer = null;
function setStatus(text, type) {
statusEl.textContent = text;
statusEl.className = "status" + (type ? ` ${type}` : "");
}
function guessEntryFile(files) {
const uuidReg = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
const findBaseName = name => name.replace(/\.[^.]+$/, "");
return files.find(file => !uuidReg.test(findBaseName(file.name))) || files[0];
}
function ensureViewer() {
if (viewer) return viewer;
viewer = new SDK.Viewer({
container: viewerEl,
edit: { enabled: false },
grid: { enabled: true },
request: { baseUrl: "" },
});
globalThis.viewer = viewer;
return viewer;
}
function resetViewer() {
if (viewer && typeof viewer.dispose === "function") {
viewer.dispose();
}
viewer = null;
viewerEl.innerHTML = "";
setStatus("场景已重置", "success");
}
function renderFileList(files) {
fileListEl.innerHTML = "";
Array.from(files || []).forEach(file => {
const li = document.createElement("li");
li.textContent = `${file.name} (${Math.round(file.size / 1024)} KB)`;
fileListEl.appendChild(li);
});
}
async function loadByOfflineFiles(files, entryName) {
setStatus(`开始加载离线包:${entryName}`, "");
const currentViewer = ensureViewer();
const packer = currentViewer.package || new SDK.Package(currentViewer);
currentViewer.package = packer;
await packer.unpack({
url: entryName,
offlineFiles: files,
offlineEntry: entryName,
onProgress: progress => {
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
},
onSceneLoad: () => {
setStatus("场景首包已解析,正在继续加载资源...", "");
},
onComplete: () => {
setStatus(`离线包加载完成:${entryName}`, "success");
},
});
}
async function fetchLocalAstralFile(name) {
const response = await fetch(`./${encodeURIComponent(name)}`);
if (!response.ok) {
throw new Error(`读取本地文件失败:${name}`);
}
const blob = await response.blob();
return new File([blob], name, { type: "application/octet-stream" });
}
async function loadOfflinePackage() {
const files = Array.from(fileInput.files || []).filter(f => f.name.toLowerCase().endsWith(".astral"));
if (files.length < 2) {
setStatus("分包模式请至少选择 2 个 .astral 文件", "error");
return;
}
renderFileList(files);
try {
const entry = guessEntryFile(files);
await loadByOfflineFiles(files, entry.name);
} catch (error) {
console.error(error);
setStatus(`加载失败:${error && error.message ? error.message : error}`, "error");
}
}
async function loadLocalPackages() {
try {
renderFileList(LOCAL_SPLIT_FILES.map(name => ({ name, size: 0 })));
const files = [];
for (const name of LOCAL_SPLIT_FILES) {
files.push(await fetchLocalAstralFile(name));
}
await loadByOfflineFiles(files, LOCAL_SPLIT_FILES[0]);
} catch (error) {
console.error(error);
setStatus(`本目录加载失败:${error && error.message ? error.message : error}`, "error");
}
}
fileInput.addEventListener("change", () => {
renderFileList(fileInput.files);
setStatus("文件已选择,点击“加载离线包”开始测试", "");
});
loadBtn.addEventListener("click", () => {
loadOfflinePackage();
});
loadLocalBtn.addEventListener("click", () => {
loadLocalPackages();
});
resetBtn.addEventListener("click", () => {
resetViewer();
});
ensureViewer();
setStatus("Tk SDK 已加载(分包模式),等待选择离线包", "success");
})();
</script>
</body>
</html>

425
Test/index.html Normal file
View File

@ -0,0 +1,425 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TkAstral3D Offline Package Test</title>
<style>
:root {
--bg: #0b1020;
--panel: rgba(15, 23, 42, 0.88);
--line: rgba(148, 163, 184, 0.24);
--text: #e2e8f0;
--muted: #94a3b8;
--accent: #22c55e;
--accent-2: #38bdf8;
--danger: #f87171;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 32%),
radial-gradient(circle at top right, rgba(34, 197, 94, 0.14), transparent 28%),
linear-gradient(180deg, #0b1020, #050814 72%);
color: var(--text);
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.app {
display: grid;
grid-template-columns: 360px 1fr;
width: 100%;
height: 100%;
}
.sidebar {
padding: 20px;
border-right: 1px solid var(--line);
background: var(--panel);
backdrop-filter: blur(10px);
overflow: auto;
}
.viewer-wrap {
position: relative;
min-width: 0;
min-height: 0;
}
#viewer {
width: 100%;
height: 100%;
}
h1 {
margin: 0 0 10px;
font-size: 24px;
line-height: 1.2;
}
.desc,
.hint,
.meta,
.log {
color: var(--muted);
line-height: 1.6;
}
.desc {
margin-bottom: 16px;
font-size: 14px;
}
.card {
margin-top: 16px;
padding: 14px;
border: 1px solid var(--line);
border-radius: 14px;
background: rgba(2, 6, 23, 0.36);
}
.card h2 {
margin: 0 0 10px;
font-size: 15px;
color: #f8fafc;
}
.row {
display: flex;
gap: 10px;
flex-wrap: wrap;
align-items: center;
margin-top: 12px;
}
button,
input[type="file"] {
font: inherit;
}
button {
padding: 10px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: linear-gradient(180deg, rgba(56, 189, 248, 0.16), rgba(34, 197, 94, 0.16));
color: var(--text);
cursor: pointer;
}
button.secondary {
background: rgba(15, 23, 42, 0.9);
}
input[type="file"] {
width: 100%;
color: var(--muted);
}
.status {
margin-top: 12px;
padding: 10px 12px;
border-radius: 10px;
border: 1px solid var(--line);
background: rgba(15, 23, 42, 0.72);
font-size: 13px;
white-space: pre-wrap;
}
.status.success {
border-color: rgba(34, 197, 94, 0.35);
color: #bbf7d0;
}
.status.error {
border-color: rgba(248, 113, 113, 0.35);
color: #fecaca;
}
.file-list {
margin: 10px 0 0;
padding-left: 18px;
font-size: 13px;
color: var(--muted);
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border-radius: 999px;
background: rgba(56, 189, 248, 0.1);
border: 1px solid rgba(56, 189, 248, 0.18);
font-size: 12px;
color: #bae6fd;
}
@media (max-width: 900px) {
.app {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
}
.sidebar {
border-right: 0;
border-bottom: 1px solid var(--line);
}
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<h1>Tk 离线包测试</h1>
<div class="desc">
这个页面直接使用 Tk 构建产物来验证离线包加载。
支持单包与多包 .astral 文件,直接通过 SDK 的离线参数加载,不依赖编辑器页面。
</div>
<div class="row">
<span class="tag">SDK: TkAstral3D/Test/dist/astral3d.umd.js</span>
</div>
<div class="card">
<h2>选择离线包</h2>
<input id="fileInput" type="file" multiple accept=".astral" />
<div class="row">
<button id="loadBtn">加载离线包</button>
<button id="loadLocalSplitBtn" class="secondary">直接加载本目录(分包)</button>
<button id="loadLocalSingleBtn" class="secondary">直接加载本目录(单包)</button>
<button id="resetBtn" class="secondary">重置场景</button>
</div>
<div id="status" class="status">等待选择 .astral 文件,或使用“直接加载本目录”</div>
<ul id="fileList" class="file-list"></ul>
</div>
<div class="card">
<h2>说明</h2>
<div class="hint">
1. 多包模式:直接选中全部 .astral 文件后加载。\n
2. 单包模式:只需要选择导出的单个 .astral 文件。\n
3. 本目录快捷加载:分包=1121212.astral + 7ff6dd32-25b3-4eea-abdc-6f316ecd668e.astral单包=1121212.astral。\n
4. 建议通过本地静态服务器打开此页面,而不是直接 file:// 打开。
</div>
</div>
</aside>
<main class="viewer-wrap">
<div id="viewer"></div>
</main>
</div>
<script src="./dist/astral3d.umd.js"></script>
<script>
(function () {
const SDK = globalThis.Astral3D;
if (!SDK) {
document.getElementById("status").textContent = "Tk SDK 加载失败,请检查 astral3d.umd.js 路径";
document.getElementById("status").className = "status error";
return;
}
const viewerEl = document.getElementById("viewer");
const fileInput = document.getElementById("fileInput");
const loadBtn = document.getElementById("loadBtn");
const loadLocalSplitBtn = document.getElementById("loadLocalSplitBtn");
const loadLocalSingleBtn = document.getElementById("loadLocalSingleBtn");
const resetBtn = document.getElementById("resetBtn");
const statusEl = document.getElementById("status");
const fileListEl = document.getElementById("fileList");
const LOCAL_SPLIT_FILES = [
"1121212.astral",
"7ff6dd32-25b3-4eea-abdc-6f316ecd668e.astral",
];
const LOCAL_SINGLE_FILE = "1121212-offline.astral";
let viewer = null;
function setStatus(text, type) {
statusEl.textContent = text;
statusEl.className = "status" + (type ? ` ${type}` : "");
}
function guessEntryFile(files) {
const uuidReg = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
return files.find(f => !uuidReg.test(f.name.replace(/\.[^.]+$/, ""))) || files[0];
}
function ensureViewer() {
if (viewer) return viewer;
viewer = new SDK.Viewer({
container: viewerEl,
edit: { enabled: false },
grid: { enabled: true },
request: { baseUrl: "" },
});
globalThis.viewer = viewer;
return viewer;
}
function resetViewer() {
if (viewer && typeof viewer.dispose === "function") {
viewer.dispose();
}
viewer = null;
viewerEl.innerHTML = "";
setStatus("场景已重置", "success");
}
function renderFileList(files) {
fileListEl.innerHTML = "";
Array.from(files || []).forEach(file => {
const li = document.createElement("li");
li.textContent = `${file.name} (${Math.round(file.size / 1024)} KB)`;
fileListEl.appendChild(li);
});
}
async function loadByOfflineFiles(files, entryName) {
setStatus(`开始加载离线包:${entryName}`, "");
const currentViewer = ensureViewer();
const packer = currentViewer.package || new SDK.Package(currentViewer);
currentViewer.package = packer;
await packer.unpack({
url: entryName,
offlineFiles: files,
offlineEntry: entryName,
onProgress: progress => {
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
},
onSceneLoad: () => {
setStatus("场景首包已解析,正在继续加载资源...", "");
},
onComplete: () => {
setStatus(`离线包加载完成:${entryName}`, "success");
},
});
}
async function loadByOfflineBundle(bundle, entryName) {
setStatus(`开始加载离线包:${entryName}`, "");
const currentViewer = ensureViewer();
const packer = currentViewer.package || new SDK.Package(currentViewer);
currentViewer.package = packer;
console.log({
url: "/",
offlineBundle: bundle,
offlineEntry: entryName,
onProgress: progress => {
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
},
onSceneLoad: () => {
setStatus("场景首包已解析,正在继续加载资源...", "");
},
onComplete: () => {
setStatus(`离线包加载完成:${entryName}`, "success");
},
})
await packer.unpack({
url: "/",
offlineBundle: bundle,
offlineEntry: entryName,
onProgress: progress => {
setStatus(`离线包加载中... ${Number(progress || 0).toFixed(2)}%`, "");
},
onSceneLoad: () => {
setStatus("场景首包已解析,正在继续加载资源...", "");
},
onComplete: () => {
setStatus(`离线包加载完成:${entryName}`, "success");
},
});
}
async function fetchLocalAstralFile(name) {
const response = await fetch(`./${encodeURIComponent(name)}`);
if (!response.ok) {
throw new Error(`读取本地文件失败:${name}`);
}
const blob = await response.blob();
return new File([blob], name, { type: "application/octet-stream" });
}
async function loadOfflinePackage() {
const files = Array.from(fileInput.files || []).filter(f => f.name.toLowerCase().endsWith(".astral"));
if (files.length === 0) {
setStatus("请选择 .astral 文件", "error");
return;
}
renderFileList(files);
try {
if (files.length === 1) {
await loadByOfflineBundle(files[0], files[0].name);
} else {
const entry = guessEntryFile(files);
await loadByOfflineFiles(files, entry.name);
}
} catch (error) {
console.error(error);
setStatus(`加载失败:${error && error.message ? error.message : error}`, "error");
}
}
async function loadLocalSplitPackage() {
try {
renderFileList(LOCAL_SPLIT_FILES.map(name => ({ name, size: 0 })));
const files = [];
for (const name of LOCAL_SPLIT_FILES) {
files.push(await fetchLocalAstralFile(name));
}
await loadByOfflineFiles(files, LOCAL_SPLIT_FILES[0]);
} catch (error) {
console.error(error);
setStatus(`本目录分包加载失败:${error && error.message ? error.message : error}`, "error");
}
}
async function loadLocalSinglePackage() {
try {
renderFileList([{ name: LOCAL_SINGLE_FILE, size: 0 }]);
const file = await fetchLocalAstralFile(LOCAL_SINGLE_FILE);
await loadByOfflineBundle(file, LOCAL_SINGLE_FILE);
} catch (error) {
console.error(error);
setStatus(`本目录单包加载失败:${error && error.message ? error.message : error}`, "error");
}
}
fileInput.addEventListener("change", () => {
renderFileList(fileInput.files);
setStatus("文件已选择,点击“加载离线包”开始测试", "");
});
loadBtn.addEventListener("click", () => {
loadOfflinePackage();
});
loadLocalSplitBtn.addEventListener("click", () => {
loadLocalSplitPackage();
});
loadLocalSingleBtn.addEventListener("click", () => {
loadLocalSinglePackage();
});
resetBtn.addEventListener("click", () => {
resetViewer();
});
ensureViewer();
setStatus("Tk SDK 已加载,等待选择离线包", "success");
})();
</script>
</body>
</html>

View File

@ -5,7 +5,7 @@
* @update 2025-02-14 * @update 2025-02-14
* @version 5.0.0 * @version 5.0.0
*/ */
import { Mesh, Group, Bone } from "three"; import { Mesh, Group, Bone, Object3D, Texture } from "three";
import JSZip from "jszip"; import JSZip from "jszip";
import { strToU8 } from 'three/examples/jsm/libs/fflate.module.js'; import { strToU8 } from 'three/examples/jsm/libs/fflate.module.js';
import { BASE64_TYPES, TYPED_ARRAYS } from "@/constant"; import { BASE64_TYPES, TYPED_ARRAYS } from "@/constant";
@ -123,6 +123,7 @@ export class Package {
private textureMap: Map<string, any>; private textureMap: Map<string, any>;
private callFunNum: { value: number; }; private callFunNum: { value: number; };
private skeletonClass: PackageSkeleton; private skeletonClass: PackageSkeleton;
private dataComponentMap: Record<string, any> | null = null;
// 离线包相关属性 // 离线包相关属性
// @ts-ignore // @ts-ignore
@ -202,6 +203,15 @@ export class Package {
* @returns {string} * @returns {string}
*/ */
handleImage(imageJson:ITHREEScene.ImageJSON, zipData:SourceData[]): string { handleImage(imageJson:ITHREEScene.ImageJSON, zipData:SourceData[]): string {
if (imageJson.url && typeof imageJson.url === "object" && !imageJson.url.type) {
const ktx2Data = (imageJson.url as any).ktx2OriginalData;
if (ktx2Data) {
const name = imageJson.uuid + `.ktx2`;
zipData.push({ name, texture: ktx2Data });
return name;
}
}
if (typeof imageJson.url === "string") { if (typeof imageJson.url === "string") {
const name = imageJson.uuid + `.${BASE64_TYPES[imageJson.url.split(",")[0]]}`; const name = imageJson.uuid + `.${BASE64_TYPES[imageJson.url.split(",")[0]]}`;
zipData.push({ name, texture: imageJson.url }); zipData.push({ name, texture: imageJson.url });
@ -221,6 +231,22 @@ export class Package {
return name; return name;
} }
private findTextureByImageUuid(mesh: Mesh, imageUuid: string): Texture | null {
if (!mesh.material) return null;
const materials = Array.isArray(mesh.material) ? mesh.material : [mesh.material];
for (const material of materials) {
for (const key in material) {
const value = (material as any)[key];
if (value && value.isTexture && value.source?.uuid === imageUuid) {
return value as Texture;
}
}
}
return null;
}
/** /**
* mesh json * mesh json
* @param mesh * @param mesh
@ -252,6 +278,12 @@ export class Package {
!json.images && (json.images = []); !json.images && (json.images = []);
const texture = this.findTextureByImageUuid(mesh, image.uuid);
const textureMeta = (texture as any)?.metadata;
if (textureMeta?.isKTX2 && textureMeta?.ktx2OriginalData && typeof image.url === "object") {
(image.url as any).ktx2OriginalData = textureMeta.ktx2OriginalData;
}
const name = this.handleImage(image, zipData) const name = this.handleImage(image, zipData)
if(name){ if(name){
json.images.push(name); json.images.push(name);
@ -314,6 +346,60 @@ export class Package {
} }
} }
private sanitizeDataComponentList(list: any) {
if (!Array.isArray(list) || list.length === 0) return null;
return list.map((entry) => {
if (!entry || typeof entry !== "object") return entry;
const config = entry.config && typeof entry.config === "object" ? { ...entry.config } : entry.config;
return { ...entry, config, data: null };
});
}
private collectDataComponentMap(root: Object3D) {
const map: Record<string, any> = {};
const condition = (obj: any) => !obj.ignore;
const visit = (obj: any) => {
const sanitized = this.sanitizeDataComponentList(obj?.dataComponent);
if (sanitized) {
map[obj.uuid] = sanitized;
}
};
if (typeof (root as any).traverseByCondition === "function") {
(root as any).traverseByCondition(visit, condition);
} else {
root.traverse((obj: any) => {
if (!condition(obj)) return;
visit(obj);
});
}
return map;
}
private applyDataComponentMap(root: Object3D) {
const map = this.dataComponentMap;
if (!map || Object.keys(map).length === 0) return;
const condition = (obj: any) => !obj.ignore;
const apply = (obj: any) => {
if (!Object.prototype.hasOwnProperty.call(map, obj.uuid)) return;
const sanitized = this.sanitizeDataComponentList(map[obj.uuid]);
if (sanitized) {
obj.dataComponent = sanitized;
}
};
if (typeof (root as any).traverseByCondition === "function") {
(root as any).traverseByCondition(apply, condition);
} else {
root.traverse((obj: any) => {
if (!condition(obj)) return;
apply(obj);
});
}
}
/** /**
* group 1zip文件 * group 1zip文件
* @param {IPackConfig} packConfig * @param {IPackConfig} packConfig
@ -422,6 +508,14 @@ export class Package {
console.log(sceneZipData,drawingInfo) console.log(sceneZipData,drawingInfo)
} }
const dataComponents = this.collectDataComponentMap(this.viewer.scene);
if (Object.keys(dataComponents).length > 0) {
sceneZipData.push({
name: "data_components.json",
json: JSON.stringify(dataComponents)
});
}
// 项目配置 // 项目配置
sceneZipData.push({ sceneZipData.push({
name: "config.json", name: "config.json",
@ -430,6 +524,8 @@ export class Package {
xr: App.project.getKey("xr"), xr: App.project.getKey("xr"),
// 项目渲染器配置 // 项目渲染器配置
renderer: App.project.getKey("renderer"), renderer: App.project.getKey("renderer"),
// BVH 配置
bvh: App.project.getKey("bvh"),
// 项目级联阴影映射 // 项目级联阴影映射
csm: App.project.getKey("csm"), csm: App.project.getKey("csm"),
// 项目后处理配置 // 项目后处理配置
@ -712,16 +808,170 @@ export class Package {
} }
} }
private decodeUint8Text(data: Uint8Array): string {
return new TextDecoder().decode(data);
}
private parseOfflineManifest(unzipped: Record<string, Uint8Array>): any | null {
const manifestData = unzipped["offline.json"];
if (!manifestData) return null;
try {
return JSON.parse(this.decodeUint8Text(manifestData));
} catch {
return null;
}
}
private buildOfflineFlatPackages(unzipped: Record<string, Uint8Array>, packagesPrefix: string): Map<string, Record<string, Uint8Array>> {
const prefix = packagesPrefix.endsWith("/") ? packagesPrefix : `${packagesPrefix}/`;
const map = new Map<string, Record<string, Uint8Array>>();
for (const [name, data] of Object.entries(unzipped)) {
if (name === "offline.json") continue;
if (!name.startsWith(prefix)) continue;
const rest = name.slice(prefix.length);
const splitIndex = rest.indexOf("/");
if (splitIndex <= 0) continue;
const baseName = rest.slice(0, splitIndex);
const innerPath = rest.slice(splitIndex + 1);
if (!innerPath) continue;
let pkg = map.get(baseName);
if (!pkg) {
pkg = {};
map.set(baseName, pkg);
}
pkg[innerPath] = data;
}
return map;
}
private async unzipJsZipToMap(zipInput: Blob | ArrayBuffer | Uint8Array): Promise<Record<string, Uint8Array>> {
const jszip = new JSZip();
const zipRes = await jszip.loadAsync(zipInput as any);
const out: Record<string, Uint8Array> = {};
for (const key in zipRes.files) {
if (zipRes.files[key].dir) continue;
const fileName = zipRes.files[key].name;
const content = await zipRes.file(fileName)?.async("uint8array");
if (content) out[fileName] = content;
}
return out;
}
private async buildOfflinePackageMapFromFiles(files: File[]): Promise<Map<string, Uint8Array>> {
const map = new Map<string, Uint8Array>();
for (const file of files) {
const buffer = new Uint8Array(await file.arrayBuffer());
const normalized = this.normalizeOfflinePackageName(file.name);
map.set(normalized, buffer);
const uuid = this.extractUuidFromName(file.name);
if (uuid) {
const canonical = this.normalizeOfflinePackageName(uuid.toLowerCase());
if (!map.has(canonical)) {
map.set(canonical, buffer);
}
}
}
return map;
}
private async initOfflineSource(unpackConfig: IUnpackConfig): Promise<void> {
this.isOfflineImportContext = Boolean(unpackConfig.offlineBundle || (unpackConfig.offlineFiles && unpackConfig.offlineFiles.length > 0));
this.offlineZipMap = null;
this.offlineEntryZip = null;
this.offlineFlatPackages = null;
this.offlineFlatEntryBase = null;
const setupFlat = (unzipped: Record<string, Uint8Array>, manifest?: any, fallbackEntry?: string) => {
const packagesPrefix = manifest?.packagesPrefix || this.offlinePackagesPrefix;
this.offlineFlatPackages = this.buildOfflineFlatPackages(unzipped, packagesPrefix);
const entryName = manifest?.entry
? this.normalizeOfflinePackageName(String(manifest.entry))
: (fallbackEntry ? this.normalizeOfflinePackageName(fallbackEntry) : undefined);
const entryBase = manifest?.entryBase || (entryName ? this.getOfflinePackageBaseName(entryName) : undefined);
if (entryBase) this.offlineFlatEntryBase = entryBase;
if (entryName) this.offlineEntryZip = entryName;
};
if (unpackConfig.offlineBundle) {
let u8: Uint8Array;
if (unpackConfig.offlineBundle instanceof Uint8Array) {
u8 = unpackConfig.offlineBundle;
} else if (unpackConfig.offlineBundle instanceof ArrayBuffer) {
u8 = new Uint8Array(unpackConfig.offlineBundle);
} else {
u8 = new Uint8Array(await unpackConfig.offlineBundle.arrayBuffer());
}
const unzipped = await this.unzipJsZipToMap(u8);
const manifest = this.parseOfflineManifest(unzipped);
if (manifest?.mode === "single") {
setupFlat(unzipped, manifest);
return;
}
const map = new Map<string, Uint8Array>();
for (const [name, data] of Object.entries(unzipped)) {
if (name === "offline.json") continue;
if (name.toLowerCase().endsWith(".astral")) {
map.set(this.normalizeOfflinePackageName(name), data);
}
}
this.offlineZipMap = map;
if (manifest?.entry) {
this.offlineEntryZip = this.normalizeOfflinePackageName(String(manifest.entry));
} else if (unpackConfig.offlineEntry) {
this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.offlineEntry);
}
} else if (unpackConfig.offlineFiles && unpackConfig.offlineFiles.length > 0) {
if (unpackConfig.offlineFiles.length === 1) {
const file = unpackConfig.offlineFiles[0];
try {
const unzipped = await this.unzipJsZipToMap(await file.arrayBuffer());
const manifest = this.parseOfflineManifest(unzipped);
if (manifest?.mode === "single") {
setupFlat(unzipped, manifest, file.name);
return;
}
} catch (err) {
// 兼容直接传入 .astral 分包文件的场景,忽略单包探测失败
}
}
this.offlineZipMap = await this.buildOfflinePackageMapFromFiles(unpackConfig.offlineFiles);
if (unpackConfig.offlineEntry) {
this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.offlineEntry);
}
}
if (this.offlineZipMap && !this.offlineEntryZip) {
this.offlineEntryZip = this.normalizeOfflinePackageName(unpackConfig.url);
}
}
/** /**
* *
* @param {IUnpackConfig} unpackConfig * @param {IUnpackConfig} unpackConfig
*/ */
public unpack(unpackConfig: IUnpackConfig) { public async unpack(unpackConfig: IUnpackConfig) {
unpackConfig.onProgress && unpackConfig.onProgress(0); unpackConfig.onProgress && unpackConfig.onProgress(0);
let totalZipNumber = 0, progress = 0; let totalZipNumber = 0, progress = 0;
await this.initOfflineSource(unpackConfig);
if (!this.offlineZipMap && !this.offlineFlatPackages) {
const match = unpackConfig.url.match( /(.*[\\/])?([a-zA-Z0-9]+-V\d+)(?=[\\/]|$)/); const match = unpackConfig.url.match( /(.*[\\/])?([a-zA-Z0-9]+-V\d+)(?=[\\/]|$)/);
this.prefix_url = this.viewer.options.request?.baseUrl + (match ? match[0] : unpackConfig.url.substring(0, unpackConfig.url.lastIndexOf("/"))); this.prefix_url = this.viewer.options.request?.baseUrl + (match ? match[0] : unpackConfig.url.substring(0, unpackConfig.url.lastIndexOf("/")));
} else {
this.prefix_url = "";
}
// indexDb存储 // indexDb存储
// const db = window.VIEWPORT.modules["db"]; // const db = window.VIEWPORT.modules["db"];
@ -743,6 +993,11 @@ export class Package {
that.imagesMap.clear(); that.imagesMap.clear();
that.materialsMap.clear(); that.materialsMap.clear();
that.textureMap.clear(); that.textureMap.clear();
that.offlineZipMap = null;
that.offlineEntryZip = null;
that.offlineFlatPackages = null;
that.offlineFlatEntryBase = null;
that.dataComponentMap = null;
// @ts-ignore 清除loader // @ts-ignore 清除loader
that.loader = undefined; that.loader = undefined;
} }
@ -806,6 +1061,11 @@ export class Package {
App.FPS = Number(configJson.renderer.fps); App.FPS = Number(configJson.renderer.fps);
} }
const bvhConfig = (configJson as any)?.bvh;
if (bvhConfig) {
App.project.setKey("bvh", bvhConfig);
}
if(configJson.csm){ if(configJson.csm){
const projectCSM = App.project.getKey("csm"); const projectCSM = App.project.getKey("csm");
let _csmNotChange = true; let _csmNotChange = true;
@ -859,6 +1119,9 @@ export class Package {
unpackConfig.onSceneLoad && unpackConfig.onSceneLoad(sceneJson, configJson); unpackConfig.onSceneLoad && unpackConfig.onSceneLoad(sceneJson, configJson);
this.applyDataComponentMap(App.scene as unknown as Object3D);
this.dataComponentMap = null;
// 防止项目只有一个包的情况造成不触发proxy set // 防止项目只有一个包的情况造成不触发proxy set
if (this.callFunNum.value === 0) { if (this.callFunNum.value === 0) {
this.callFunNum.value = 0; this.callFunNum.value = 0;
@ -872,15 +1135,12 @@ export class Package {
}) })
} }
const networkGet = () => { const parseSceneZip = async (file: Blob | Uint8Array | ArrayBuffer) => {
// 下载场景包
fetch(this.viewer.options.request?.baseUrl + unpackConfig.url)
.then(zipRes => zipRes.blob())
.then(async (file) => {
unpackConfig.onProgress && unpackConfig.onProgress(1); unpackConfig.onProgress && unpackConfig.onProgress(1);
// @ts-ignore // @ts-ignore
let sceneJson: ISceneJson = undefined, configJson: IAppProject.Config = undefined; let sceneJson: ISceneJson = undefined, configJson: IAppProject.Config = undefined;
let dataComponentMap: Record<string, any> | null = null;
// 开始解压首包 // 开始解压首包
const zip = new JSZip(); const zip = new JSZip();
@ -898,7 +1158,7 @@ export class Package {
} }
} }
const res = await zip.loadAsync(file); const res = await zip.loadAsync(file as any);
/** /**
* res.files里包含整个zip里的文件描述 * res.files里包含整个zip里的文件描述
@ -917,6 +1177,9 @@ export class Package {
} else if (fileName === "config.json") { // 项目配置json } else if (fileName === "config.json") { // 项目配置json
const content = await res.file(fileName)?.async('string') as string; const content = await res.file(fileName)?.async('string') as string;
configJson = JSON.parse(content); configJson = JSON.parse(content);
} else if (fileName === "data_components.json") {
const content = await res.file(fileName)?.async('string') as string;
dataComponentMap = JSON.parse(content);
} else if (fileName.substring(0, 9) === "Textures/") { } else if (fileName.substring(0, 9) === "Textures/") {
/** /**
* *
@ -928,6 +1191,11 @@ export class Package {
// 转换回贴图原始信息存入map // 转换回贴图原始信息存入map
const content = await res.file(fileName)?.async('arraybuffer'); const content = await res.file(fileName)?.async('arraybuffer');
this.unGzipImage(fileName.replace("Textures/", ""), content); this.unGzipImage(fileName.replace("Textures/", ""), content);
} else if (/\.ktx2$/i.test(fileName)) {
const content = await res.file(fileName)?.async('arraybuffer') as ArrayBuffer;
const blob = new Blob([content], { type: "image/ktx2" });
const blobUrl = URL.createObjectURL(blob) + `#${fileName.replace("Textures/", "")}`;
this.unGzipImage(fileName.replace("Textures/", ""), blobUrl);
} else { } else {
const content = await res.file(fileName)?.async('string') const content = await res.file(fileName)?.async('string')
this.unGzipImage(fileName.replace("Textures/", ""), content); this.unGzipImage(fileName.replace("Textures/", ""), content);
@ -964,6 +1232,7 @@ export class Package {
} }
totalZipNumber = sceneJson.totalZipNumber || 0; totalZipNumber = sceneJson.totalZipNumber || 0;
this.dataComponentMap = dataComponentMap;
// 贴图还原至sceneJson // 贴图还原至sceneJson
sceneJson.scene.images = sceneJson.scene.images.map((item) => { sceneJson.scene.images = sceneJson.scene.images.map((item) => {
@ -1003,12 +1272,42 @@ export class Package {
// 解压处理好的数据添加至 indexDB -> Msy3D -> scene // 解压处理好的数据添加至 indexDB -> Msy3D -> scene
// db.setItem(dbKey, sceneJson); // db.setItem(dbKey, sceneJson);
}) }
const networkGet = async () => {
if (this.offlineFlatPackages) {
const entryBase = this.offlineFlatEntryBase || this.getOfflinePackageBaseName(this.offlineEntryZip || unpackConfig.url);
const entry = this.offlineFlatPackages.get(entryBase);
if (!entry) {
console.error(`[Package] 离线包缺少首包内容: ${entryBase}`);
return;
}
const zipData = await this.zipRawFiles(entry);
await parseSceneZip(zipData);
return;
}
if (this.offlineZipMap) {
const entryName = this.offlineEntryZip || this.normalizeOfflinePackageName(unpackConfig.url);
const zipData = this.offlineZipMap.get(entryName);
if (!zipData) {
console.error(`[Package] 离线包缺少首包: ${entryName}`);
return;
}
await parseSceneZip(zipData);
return;
}
// 下载场景包
const file = await fetch(this.viewer.options.request?.baseUrl + unpackConfig.url).then(zipRes => zipRes.blob());
await parseSceneZip(file);
} }
// db.getItem(dbKey).then((dbData) => { // db.getItem(dbKey).then((dbData) => {
// if (dbData === undefined) { // if (dbData === undefined) {
networkGet(); await networkGet();
// }else{ // }else{
// this.recordUuid(dbData.scene); // this.recordUuid(dbData.scene);
// //
@ -1044,6 +1343,8 @@ export class Package {
const check = (object, group) => { const check = (object, group) => {
// 检查数据是否已完善 // 检查数据是否已完善
let isDone = true; let isDone = true;
const useMaterials = new Set<string>();
object.children.forEach((child) => { object.children.forEach((child) => {
// 检查几何数据是否都已拥有 // 检查几何数据是否都已拥有
if (child.geometry && group.geometries?.findIndex((geometry) => geometry.uuid === child.geometry) === -1) { if (child.geometry && group.geometries?.findIndex((geometry) => geometry.uuid === child.geometry) === -1) {
@ -1055,30 +1356,9 @@ export class Package {
} }
// material->texture->image // material->texture->image
if (child.material && group.materials?.findIndex((material) => material.uuid === child.material) === -1) { if (child.material) {
if (!this.materialsMap.has(child.material)) { const materialUUIDs = Array.isArray(child.material) ? child.material : [child.material];
isDone = false; materialUUIDs.forEach((materialUuid) => useMaterials.add(materialUuid));
} else {
group.materials.push(this.materialsMap.get(child.material));
const material = this.materialsMap.get(child.material);
if (material.map && group.textures?.findIndex((texture) => texture.uuid === material.map) === -1) {
if (!this.textureMap.has(material.map)) {
isDone = false;
} else {
group.textures.push(this.textureMap.get(material.map));
const texture = this.textureMap.get(material.map);
if (texture.image && group.images?.findIndex((image) => image.uuid === texture.image) === -1) {
if (!this.imagesMap.has(texture.image)) {
isDone = false;
} else {
group.images.push(this.imagesMap.get(texture.image));
}
}
}
}
}
} }
if (child.children?.length > 0 && isDone) { if (child.children?.length > 0 && isDone) {
@ -1086,6 +1366,68 @@ export class Package {
} }
}) })
if (!isDone) return isDone;
const useTextures = new Set<string>();
const textureFields = [
"map", "alphaMap", "aoMap", "anisotropyMap", "bumpMap", "clearcoatMap", "clearcoatNormalMap", "clearcoatRoughnessMap",
"displacementMap", "envMap", "emissiveMap", "iridescenceMap", "iridescenceThicknessMap", "lightMap", "sheenColorMap",
"sheenRoughnessMap", "specularColorMap", "specularIntensityMap", "specularMap", "thicknessMap", "transmissionMap",
"metalnessMap", "roughnessMap", "normalMap", "gradientMap"
];
useMaterials.forEach((materialUuid) => {
if (!isDone) return;
const material = this.materialsMap.get(materialUuid);
if (!material) {
isDone = false;
return;
}
if (group.materials?.findIndex((item) => item.uuid === materialUuid) === -1) {
group.materials.push(material);
}
textureFields.forEach((field) => {
const textureUuid = material[field];
if (textureUuid) useTextures.add(textureUuid);
});
});
if (!isDone) return isDone;
const useImages = new Set<string>();
useTextures.forEach((textureUuid) => {
if (!isDone) return;
const texture = this.textureMap.get(textureUuid);
if (!texture) {
isDone = false;
return;
}
if (group.textures?.findIndex((item) => item.uuid === textureUuid) === -1) {
group.textures.push(texture);
}
if (texture.image) useImages.add(texture.image);
});
if (!isDone) return isDone;
useImages.forEach((imageUuid) => {
if (!isDone) return;
if (!this.imagesMap.has(imageUuid)) {
isDone = false;
return;
}
if (group.images?.findIndex((item) => item.uuid === imageUuid) === -1) {
group.images.push(this.imagesMap.get(imageUuid));
}
});
return isDone; return isDone;
} }
@ -1132,11 +1474,7 @@ export class Package {
} }
} }
const getByNetwork = () => { const parseGroupZip = async (file: Blob | Uint8Array | ArrayBuffer) => {
Package._fetch(`${this.prefix_url}/${uuid}.zip`, {
onSuccess: (zipRes) => {
const file = zipRes.blob();
const zip = new JSZip(); const zip = new JSZip();
let json: GroupJson; let json: GroupJson;
@ -1162,27 +1500,22 @@ export class Package {
// 遍历children,拉取group zip还原 // 遍历children,拉取group zip还原
const children: any = []; const children: any = [];
json.object.children.forEach((uuid) => { json.object.children.forEach((childUuid) => {
if (typeof uuid === "string") { if (typeof childUuid === "string") {
// 保存uuid对应的function // 保存uuid对应的function
funcMap.set(uuid, this.unpackGroup); funcMap.set(childUuid, this.unpackGroup);
this.callFunNum.value++; this.callFunNum.value++;
} else { } else {
children.push(uuid) children.push(childUuid)
} }
}) })
json.object.children = children; json.object.children = children;
//json.object.groupChildren = [...funcMap.keys()];
// 解压处理好的数据添加至 indexDB -> Msy3D -> ${dbTable}
// db.setItem(`${uuid}.zip`, json,dbTable);
parse(json); parse(json);
} }
zip.loadAsync(file as Blob).then(res => { const res = await zip.loadAsync(file as any);
let num = new Proxy({ value: Object.keys(res.files).length }, { let num = new Proxy({ value: Object.keys(res.files).length }, {
set(target, p, value) { set(target, p, value) {
target[p] = value; target[p] = value;
@ -1192,6 +1525,7 @@ export class Package {
return true; return true;
} }
}) })
for (let key in res.files) { for (let key in res.files) {
//判断是否是目录 //判断是否是目录
if (!res.files[key].dir) { if (!res.files[key].dir) {
@ -1200,44 +1534,75 @@ export class Package {
//找到我们压缩包所需要的json文件 //找到我们压缩包所需要的json文件
if (fileName === `${uuid}.json`) { // 场景json if (fileName === `${uuid}.json`) { // 场景json
res.file(fileName)?.async('string').then(content => { res.file(fileName)?.async('string').then(content => {
//得到scene.json文件的内容
json = JSON.parse(content); json = JSON.parse(content);
num.value--; num.value--;
}) })
} else if (fileName.substring(0, 9) === "Textures/") { } else if (fileName.substring(0, 9) === "Textures/") {
/**
*
*
* 1.env格式type!width!height!uuid.envarraybuffer格式map
* 2.map
**/
if (/\.env$/.test(fileName)) { if (/\.env$/.test(fileName)) {
// 转换回贴图原始信息存入map // 转换回贴图原始信息存入map
res.file(fileName)?.async('arraybuffer').then(content => { res.file(fileName)?.async('arraybuffer').then(content => {
this.unGzipImage(fileName.replace("Textures/", ""), content); this.unGzipImage(fileName.replace("Textures/", ""), content);
num.value--;
})
} else if (/\.ktx2$/i.test(fileName)) {
res.file(fileName)?.async('arraybuffer').then(content => {
const blob = new Blob([content], { type: "image/ktx2" });
const blobUrl = URL.createObjectURL(blob) + `#${fileName.replace("Textures/", "")}`;
this.unGzipImage(fileName.replace("Textures/", ""), blobUrl);
num.value--; num.value--;
}) })
} else { } else {
res.file(fileName)?.async('string').then(content => { res.file(fileName)?.async('string').then(content => {
this.unGzipImage(fileName.replace("Textures/", ""), content); this.unGzipImage(fileName.replace("Textures/", ""), content);
num.value--; num.value--;
}) })
} }
} else if (/^Geometries\/geometries_\d*\.json$/.test(fileName)) { } else if (/^Geometries\/geometries_\d*\.json$/.test(fileName)) {
res.file(fileName)?.async('string').then(content => { res.file(fileName)?.async('string').then(content => {
geometries.push(...this.unGzipGeometryJson(JSON.parse(content))); geometries.push(...this.unGzipGeometryJson(JSON.parse(content)));
num.value--; num.value--;
}) })
} else {
num.value--;
} }
} else { } else {
num.value--; num.value--;
} }
} }
}) }
const getByNetwork = () => {
if (this.offlineFlatPackages) {
const entry = this.offlineFlatPackages.get(uuid);
if (!entry) {
console.error(`[Package] 离线包缺少分包内容: ${uuid}`);
this.callFunNum.value--;
return;
}
this.zipRawFiles(entry).then((zipData) => {
parseGroupZip(zipData);
});
return;
}
if (this.offlineZipMap) {
const fileName = this.normalizeOfflinePackageName(uuid);
const normalizedLowerName = this.normalizeOfflinePackageName(uuid.toLowerCase());
const zipData = this.offlineZipMap.get(fileName) || this.offlineZipMap.get(normalizedLowerName);
if (!zipData) {
console.error(`[Package] 离线包缺少分包: ${fileName}`);
this.callFunNum.value--;
return;
}
parseGroupZip(zipData);
return;
}
Package._fetch(`${this.prefix_url}/${uuid}.zip`, {
onSuccess: (zipRes) => {
parseGroupZip(zipRes.blob());
} }
}); });
} }
@ -1297,6 +1662,11 @@ export class Package {
this.prefix_url = ""; this.prefix_url = "";
this.callFunNum = { value: 0 }; // 重置为初始状态 this.callFunNum = { value: 0 }; // 重置为初始状态
this.totalSize = 0; this.totalSize = 0;
this.offlineZipMap = null;
this.offlineEntryZip = null;
this.offlineFlatPackages = null;
this.offlineFlatEntryBase = null;
this.dataComponentMap = null;
// 5. 释放 viewer 引用(注意:不销毁 viewer仅移除引用 // 5. 释放 viewer 引用(注意:不销毁 viewer仅移除引用
this.viewer = null as any; this.viewer = null as any;