Compare commits

...
25 Commits
Author SHA1 Message Date
plum ecd24acf19 feat(all): 修复html复制报错 2026-04-22 17:47:28 +08:00
plum 37e1e58883 feat(all): 修改分页 2026-04-17 12:44:21 +08:00
plum a8947696af chore(editor): commit pending workspace changes 2026-04-17 10:42:54 +08:00
plum 9662f41eec fix(data-center): clear and remount SQL datasource select on type switch 2026-04-16 11:42:35 +08:00
plum fbfaa59b14 fix(data-center): populate SQL data source options and normalize dataset source id mapping 2026-04-15 23:34:52 +08:00
plum 27b4b720ed fix(editor): correct object precision and dataset method binding 2026-04-14 11:08:49 +08:00
plum e62f2b54d5 chore(editor): warm up circular water pool after viewer init 2026-04-13 16:25:36 +08:00
plum d7c36e31f7 fix(sdk): keep dynamic path flowing when scene is static 2026-04-13 15:25:06 +08:00
plum 985c8d39fc fix(editor): keep html panel identity on upload refresh 2026-04-13 15:04:32 +08:00
plum 5368e52f0d chore(sdk): remove diagnostic console.error logs from unpackGroup 2026-04-10 11:24:40 +08:00
plum 83684b4c7b fix(sdk): fix loader lifecycle for consecutive offline pack imports 2026-04-10 11:22:28 +08:00
plum 6720a3e730 fix(sdk): capture loader ref in unpackGroup to prevent race condition on done() cleanup 2026-04-10 02:09:08 +08:00
plum e78afc73f3 fix(editor): copy sdk resource to assets/resource in build 2026-04-10 01:42:51 +08:00
plum 2c62987281 feat(all): 修改ws路径 2026-04-10 00:40:45 +08:00
plum ecddca00c9 fix editor data component refresh and transition update loop 2026-04-10 00:01:02 +08:00
plum 8b363e8eb4 chore(editor): 更新生产域名并调整 Devtool 配置 2026-04-09 19:49:03 +08:00
plum 43c2be9525 feat(editor): 对齐 Astral 完成数据中心接口接入与分组选择修复 2026-04-09 13:53:10 +08:00
plum 838939855e chore: 删除无用的 info 文件 2026-04-09 01:10:44 +08:00
plum e8615c6796 feat(sdk): 迁移脚本执行系统与编辑器热更新对齐 Astral
- Viewer.ts: enableScript setter 修复写回 options,脚本状态变量改为实例级
- Viewer.ts: 新增 InstallScriptsOptions、reinstallObjectScripts、invokeInstalledScriptEvent
- Viewer.ts: installScripts 支持 loadedScriptNames 精准触发 loaded,补充 bindDataChange 分发
- Viewer.ts: unInstallScripts 移除错误的 enableScript 提前返回守卫
- Signals.ts: 脚本信号统一改用 reinstallObjectScripts 热更新
- Signals.ts: sceneCleared 先卸载脚本再清场
- Signals.ts: registerSignal/dispose 模式防止信号泄漏
- CodeEditor.vue: Monaco 忽略 TS80002(构造函数转 class 建议)
2026-04-09 00:43:15 +08:00
plum d8f98ee295 feat(all): 离线包加载测试 2026-04-09 00:20:33 +08:00
plum 151bc7c8a2 feat(all): 离线包迁移 2026-04-08 21:54:54 +08:00
plum 404e804143 feat(all): 离线包迁移 2026-04-08 20:51:11 +08:00
plum 309bb0a5e6 feat(all): 数据面板 国际化语言迁移 2026-04-08 17:34:17 +08:00
plum 8385c08ec7 feat(all): 数据面板迁移 2026-04-08 17:25:44 +08:00
plum 4cc239d660 feat(all): 后处理迁移 2026-04-08 16:51:47 +08:00
73 changed files with 6269 additions and 1442 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
+364
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
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
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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

-21
View File
@@ -1,21 +0,0 @@
1. 脚本系统 支持编写代码脚本进行逻辑交互支持(升级后的)
2. GLTF轻量化工具 对GLTF格式模型进行轻量化处理,轻量化率达50%以上(升级后的)
3. 数据组件 支持注册数据源,并驱动模型进行可视化展示
4. 分包打包 支持ktx2等格式纹理打包,拥有更快的打包速度和更小的包体积(升级后的)
5. 离线包 导出和加载场景离线包,支持项目二次开发
6. 扩展3D对象 热力图、U面板、动态路径、水池
```text
把 Astral项目的 Astral/3d/editor/src/views/editor/components/extraPane/resource/builtin/Expansion.vue
迁移到 TkAstral3D/packages/editor/src/views/editor/components/extraPane/resource/builtin 下
要保证扩展中所有相关功能正常
```
+2 -2
View File
@@ -16,7 +16,7 @@ VITE_ENABLE_ANALYZE=true
VITE_ENABLE_CONFIG_GENERATE=true VITE_ENABLE_CONFIG_GENERATE=true
# 当前站点URL # 当前站点URL
VITE_GLOB_ORIGIN=https://editor.astraljs.com/ VITE_GLOB_ORIGIN=https://tk.astraltwin.cn/
#ws接口 #ws接口
VITE_GLOB_SOCKET_URL=wss://editor.astraljs.com/socket VITE_GLOB_SOCKET_URL=wss://tk.astraltwin.cn/api/sys/ws
Binary file not shown.
@@ -3,7 +3,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue'; import { ref, onMounted, onBeforeUnmount, nextTick, watch } from 'vue';
import * as monaco from 'monaco-editor'; import * as monaco from 'monaco-editor';
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker'; import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker';
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker'; import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker';
@@ -74,10 +74,22 @@ onBeforeUnmount(() => {
} }
}); });
watch(
() => props.source,
value => {
if (!editor) return;
const currentValue = editor.getValue();
if (value !== currentValue) {
editor.setValue(value);
}
}
);
async function initMonaco() { async function initMonaco() {
monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
noSemanticValidation: false, noSemanticValidation: false,
noSyntaxValidation: false, noSyntaxValidation: false,
diagnosticCodesToIgnore: [80002],
}); });
// 如果使用 Webpack 或其他打包工具,可以使用 importScripts // 如果使用 Webpack 或其他打包工具,可以使用 importScripts
monaco.languages.typescript.javascriptDefaults.setCompilerOptions({ monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
@@ -18,7 +18,7 @@ import {onMounted, ref} from "vue";
import {Copy} from "@vicons/carbon"; import {Copy} from "@vicons/carbon";
import {NIcon, NTooltip} from "naive-ui"; import {NIcon, NTooltip} from "naive-ui";
import {t} from "@/language"; import {t} from "@/language";
import {App,Hooks,AddObjectCommand} from "@astral3d/engine"; import {App,Hooks,Loader,Utils,AddObjectCommand} from "@astral3d/engine";
const disabled = ref(true); const disabled = ref(true);
@@ -36,6 +36,15 @@ function handleClone() {
//避免复制相机或场景 //避免复制相机或场景
if (object === null || object.parent === null) return; if (object === null || object.parent === null) return;
if (Utils.isHtmlPanelObject(object)) {
const _json = object.toJSON() as any;
Loader.objectLoader.parseAsync(_json).then(newObject3D => {
App.execute(new AddObjectCommand(newObject3D));
}).catch((e: Error) => window.$message?.error(e.message));
return;
}
object = object.clone(); object = object.clone();
App.execute(new AddObjectCommand(object)); App.execute(new AddObjectCommand(object));
@@ -3,7 +3,7 @@
<n-button class="mr-2"> <n-button class="mr-2">
<template #icon> <template #icon>
<n-icon size="22"> <n-icon size="22">
<DocumentExport/> <DocumentExport />
</n-icon> </n-icon>
</template> </template>
{{ t("layout.header.Export") }} {{ t("layout.header.Export") }}
@@ -13,7 +13,7 @@
<n-button type="primary" @click="handleImport"> <n-button type="primary" @click="handleImport">
<template #icon> <template #icon>
<n-icon> <n-icon>
<DocumentImport/> <DocumentImport />
</n-icon> </n-icon>
</template> </template>
{{ t("layout.header.Import") }} {{ t("layout.header.Import") }}
@@ -21,55 +21,57 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import {onMounted} from "vue"; import { onMounted } from "vue";
import {DocumentImport, DocumentExport} from "@vicons/carbon"; import { DocumentImport, DocumentExport } from "@vicons/carbon";
import {t} from "@/language"; import { t } from "@/language";
import {App, Export, Loader} from "@astral3d/engine"; import { App, Export, Loader } from "@astral3d/engine";
import { useGlobalConfigStore } from "@/store/modules/globalConfig";
const exportClass = new Export(); const exportClass = new Export();
const globalConfigStore = useGlobalConfigStore();
const exportOptions = [ const exportOptions = [
{ {
label: t("layout.header['Export Object']"), label: t("layout.header['Export Object']"),
key: "exportObject", key: "exportObject",
children: [ children: [
{ {
label: "JSON", label: "JSON",
key: "exportObjectToJSON" key: "exportObjectToJSON",
}, },
{ {
label: "GLB", label: "GLB",
key: "exportObjectToGlb" key: "exportObjectToGlb",
}, },
{ {
label: "GLTF", label: "GLTF",
key: "exportObjectToGltf" key: "exportObjectToGltf",
}, },
{ {
label: "OBJ", label: "OBJ",
key: "exportObjectToObj" key: "exportObjectToObj",
}, },
{ {
label: "PLY", label: "PLY",
key: "exportObjectToPly" key: "exportObjectToPly",
}, },
{ {
label: t("layout.header['PLY (Binary)']"), label: t("layout.header['PLY (Binary)']"),
key: "exportObjectToPlyBinary" key: "exportObjectToPlyBinary",
}, },
{ {
label: "STL", label: "STL",
key: "exportObjectToStl" key: "exportObjectToStl",
}, },
{ {
label: t("layout.header['STL (Binary)']"), label: t("layout.header['STL (Binary)']"),
key: "exportObjectToStlBinary" key: "exportObjectToStlBinary",
}, },
{ {
label: "USDZ", label: "USDZ",
key: "exportObjectToUSDZ" key: "exportObjectToUSDZ",
} },
] ],
}, },
{ {
label: t("layout.header['Export Scene']"), label: t("layout.header['Export Scene']"),
@@ -77,55 +79,82 @@ const exportOptions = [
children: [ children: [
{ {
label: "JSON", label: "JSON",
key: "exportSceneToJSON" key: "exportSceneToJSON",
}, },
{ {
label: "GLB", label: "GLB",
key: "exportSceneToGlb" key: "exportSceneToGlb",
}, },
{ {
label: "GLTF", label: "GLTF",
key: "exportSceneToGltf" key: "exportSceneToGltf",
}, },
{ {
label: "OBJ", label: "OBJ",
key: "exportSceneToObj" key: "exportSceneToObj",
}, },
{ {
label: "PLY", label: "PLY",
key: "exportSceneToPly" key: "exportSceneToPly",
}, },
{ {
label: t("layout.header['PLY (Binary)']"), label: t("layout.header['PLY (Binary)']"),
key: "exportSceneToPlyBinary" key: "exportSceneToPlyBinary",
}, },
{ {
label: "STL", label: "STL",
key: "exportSceneToStl" key: "exportSceneToStl",
}, },
{ {
label: t("layout.header['STL (Binary)']"), label: t("layout.header['STL (Binary)']"),
key: "exportSceneToStlBinary" key: "exportSceneToStlBinary",
}, },
{ {
label: "USDZ", label: "USDZ",
key: "exportSceneToUSDZ" key: "exportSceneToUSDZ",
} },
] ],
} },
] {
label: t("layout.header['Offline package']"),
key: "exportOffline",
children: [
{
label: t("layout.header['Single packet']"),
key: "exportOfflineSingle",
},
{
label: t("layout.header['Multiple packet']"),
key: "exportOfflineMultiple",
},
],
},
];
function handleImport() { function handleImport() {
const form = document.createElement('form'); const form = document.createElement("form");
form.style.display = 'none'; form.style.display = "none";
document.body.appendChild(form); document.body.appendChild(form);
const fileInput = document.createElement('input'); const fileInput = document.createElement("input");
fileInput.multiple = true; fileInput.multiple = true;
fileInput.type = 'file'; fileInput.type = "file";
fileInput.addEventListener('change', function () { fileInput.addEventListener("change", function () {
Loader.loadFiles(fileInput.files, undefined) const files = fileInput.files;
.catch((err) => { if (!files || files.length === 0) {
form.reset();
return;
}
const astralFiles = Array.from(files).filter(file => file.name.toLowerCase().endsWith(".astral"));
if (astralFiles.length > 0) {
handleOfflineFiles(files);
form.reset();
return;
}
Loader.loadFiles(files, undefined)
.catch(err => {
window.$message?.error(err); window.$message?.error(err);
}) })
.finally(() => { .finally(() => {
@@ -135,9 +164,18 @@ function handleImport() {
form.appendChild(fileInput); form.appendChild(fileInput);
fileInput.click(); fileInput.click();
} }
function handleExportSelect(key: string) {
if (key === "exportOfflineSingle") {
exportOffline("single");
return;
}
if (key === "exportOfflineMultiple") {
exportOffline("multiple");
return;
}
function handleExportSelect(key: string) {
if (key.startsWith("exportObject")) { if (key.startsWith("exportObject")) {
if (App.selected === null) { if (App.selected === null) {
window.$message?.error(window.$t("prompt['No object selected.']")); window.$message?.error(window.$t("prompt['No object selected.']"));
@@ -145,11 +183,121 @@ function handleExportSelect(key: string) {
} }
} }
exportClass[key](); exportClass[key] && exportClass[key]();
} }
onMounted(() => { function downloadFile(file: File) {
}) const url = URL.createObjectURL(file);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
function sleep(ms: number) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function downloadFilesInBatches(files: File[], batchSize = 9, delayMs = 1100) {
for (let i = 0; i < files.length; i++) {
downloadFile(files[i]);
if ((i + 1) % batchSize === 0 && i < files.length - 1) {
await sleep(delayMs);
}
}
}
function exportOffline(mode: "single" | "multiple") {
const packer = (window.viewer as any)?.package;
if (!packer || typeof packer.packOffline !== "function") {
window.$message?.error("当前SDK暂不支持离线包导出");
return;
}
const sceneInfo = App.project.getKey("sceneInfo");
const sceneName = sceneInfo?.sceneName || "scene";
globalConfigStore.loading = true;
globalConfigStore.loadingText = "正在导出离线包...";
packer
.packOffline({
name: sceneName,
layer: 2,
mode,
onProgress: (progress: number) => {
globalConfigStore.loadingText = `离线包导出 ${progress}%`;
},
})
.then(async (result: { files: File[] }) => {
await downloadFilesInBatches(result.files);
window.$message?.success("离线包导出完成");
})
.catch(err => {
console.error(err);
if (err?.code === "SDK_FEATURE_DISABLED") {
window.$message?.error("当前授权未开通离线包导出能力");
return;
}
if (typeof err?.message === "string" && err.message.trim()) {
window.$message?.error(err.message);
return;
}
window.$message?.error("离线包导出失败");
})
.finally(() => {
globalConfigStore.loading = false;
globalConfigStore.loadingText = "";
});
}
function handleOfflineFiles(files: FileList | null) {
if (!files || files.length === 0) return;
const astralFiles = Array.from(files).filter(file => file.name.toLowerCase().endsWith(".astral"));
if (astralFiles.length === 0) {
window.$message?.error("请选择离线包文件(.astral)");
return;
}
const packer = (window.viewer as any)?.package;
if (!packer || typeof packer.unpack !== "function") {
window.$message?.error("当前SDK暂不支持离线包导入");
return;
}
const entryFile = guessEntryFile(astralFiles);
globalConfigStore.loading = true;
globalConfigStore.loadingText = "离线包加载中...";
packer.unpack({
url: entryFile.name,
offlineFiles: astralFiles,
offlineEntry: entryFile.name,
onProgress: (progress: number) => {
globalConfigStore.loadingText = `离线包加载 ${progress.toFixed(2)}%`;
},
onComplete: () => {
globalConfigStore.loading = false;
globalConfigStore.loadingText = "";
window.$message?.success("离线包加载完成");
},
});
}
function guessEntryFile(files: File[]): File {
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: string) => name.replace(/\.[^.]+$/, "");
const candidate = files.find(file => !uuidReg.test(findBaseName(file.name)));
return candidate || files[0];
}
onMounted(() => {});
</script> </script>
<style scoped lang="less"></style> <style scoped lang="less"></style>
@@ -2,7 +2,7 @@
import {nextTick} from "vue"; import {nextTick} from "vue";
import {Save} from "@vicons/carbon"; import {Save} from "@vicons/carbon";
import {t} from "@/language"; import {t} from "@/language";
import {App,Package} from "@astral3d/engine"; import {App} from "@astral3d/engine";
import { useGlobalConfigStore } from '@/store/modules/globalConfig'; import { useGlobalConfigStore } from '@/store/modules/globalConfig';
import {fetchUpload} from "@/http/api/sys"; import {fetchUpload} from "@/http/api/sys";
import {filterSize} from "@/utils/common/file"; import {filterSize} from "@/utils/common/file";
@@ -59,7 +59,7 @@ function save(){
globalConfigStore.loadingText = window.$t("scene['Scene is being compressed...']"); globalConfigStore.loadingText = window.$t("scene['Scene is being compressed...']");
const p = new Package(window.viewer); const p = window.viewer.package;
p.pack({ p.pack({
// 首包名称 // 首包名称
name:`${sceneInfo.sceneName}`, name:`${sceneInfo.sceneName}`,
@@ -96,8 +96,6 @@ function save(){
setTimeout(() => { setTimeout(() => {
globalConfigStore.loading = false; globalConfigStore.loading = false;
p.dispose();
}, 500) }, 500)
}) })
} }
@@ -19,7 +19,7 @@ import {
ChoroplethMap ChoroplethMap
} from '@vicons/carbon'; } from '@vicons/carbon';
import {t} from "@/language"; import {t} from "@/language";
import {App,Hooks, MoveObjectCommand, RemoveObjectCommand, AddObjectCommand} from "@astral3d/engine"; import {App,Hooks,Loader,Utils, MoveObjectCommand, RemoveObjectCommand, AddObjectCommand} from "@astral3d/engine";
import {escapeHTML, findSiblingsAndIndex} from "@/utils/common/utils"; import {escapeHTML, findSiblingsAndIndex} from "@/utils/common/utils";
import {getMaterialName} from "@/utils/common/scenes"; import {getMaterialName} from "@/utils/common/scenes";
import EsContextmenu from "@/components/es/EsContextmenu.vue"; import EsContextmenu from "@/components/es/EsContextmenu.vue";
@@ -367,6 +367,15 @@ function handleContextmenuSelect(key: string) {
if (parent !== null) App.execute(new RemoveObjectCommand(object)); if (parent !== null) App.execute(new RemoveObjectCommand(object));
break; break;
case "clone": case "clone":
if (Utils.isHtmlPanelObject(object)) {
const _json = object.toJSON() as any;
Loader.objectLoader.parseAsync(_json).then(newObject3D => {
App.execute(new AddObjectCommand(newObject3D));
}).catch((e: Error) => window.$message?.error(e.message));
break;
}
const _object = object.clone(); const _object = object.clone();
App.execute(new AddObjectCommand(_object)); App.execute(new AddObjectCommand(_object));
+53
View File
@@ -0,0 +1,53 @@
import {request} from "@/http/request";
export interface DataSetQueryParams {
page?: number;
pageSize?: number;
limit?: number;
offset?: number;
name?: string;
type?: string;
groupId?: IDataSet.Item["groupId"];
}
export interface DataSetPayload {
id?: IDataSet.Item["id"];
name: string;
groupId: IDataSet.Item["groupId"];
type: "API" | "SQL" | "JSON" | string;
method?: IDataSet.Item["method"];
api?: string;
dataSourceId?: IDataSet.Item["id"];
sql?: string;
json?: string;
}
export interface DataSetExecutePayload {
params?: any[];
query?: Record<string, any>;
body?: Record<string, any>;
}
export function fetchDataSetPage(params: DataSetQueryParams) {
return request.get<Service.ListPageResult<IDataSet.Item>>(`/data-set/page`, {params});
}
export function fetchDataSetDetail(id: IDataSet.Item["id"]) {
return request.get<IDataSet.Item>(`/data-set/${id}`);
}
export function fetchCreateDataSet(data: DataSetPayload) {
return request.post(`/data-set`, data);
}
export function fetchUpdateDataSet(data: DataSetPayload) {
return request.put(`/data-set`, data);
}
export function fetchDeleteDataSet(id: IDataSet.Item["id"]) {
return request.delete(`/data-set/${id}`, {});
}
export function fetchExecuteDataSet(id: IDataSet.Item["id"], data?: DataSetExecutePayload) {
return request.post(`/data-set/${id}/execute`, data);
}
@@ -0,0 +1,23 @@
import {request} from "@/http/request";
export interface DataSetGroupPayload {
id?: IDataSet.IGroup["id"];
name: string;
pid: IDataSet.IGroup["pid"];
}
export function fetchDataSetGroupTree() {
return request.get<IDataSet.IGroup[]>(`/data-set-group/tree`);
}
export function fetchCreateDataSetGroup(data: DataSetGroupPayload) {
return request.post(`/data-set-group`, data);
}
export function fetchUpdateDataSetGroup(data: DataSetGroupPayload) {
return request.put(`/data-set-group`, data);
}
export function fetchDeleteDataSetGroup(id: IDataSet.IGroup["id"]) {
return request.delete(`/data-set-group/${id}`, {});
}
@@ -0,0 +1,30 @@
import {request} from "@/http/request";
export interface DataSourcePayload {
id?: IDataSource.Item["id"];
name: string;
type: string;
connectionString: string;
username?: string;
password?: string;
}
export function fetchDataSourceList() {
return request.get<IDataSource.Item[]>(`/data-source/list`);
}
export function fetchCreateDataSource(data: DataSourcePayload) {
return request.post<IDataSource.Item>(`/data-source`, data);
}
export function fetchUpdateDataSource(data: DataSourcePayload) {
return request.put<IDataSource.Item>(`/data-source`, data);
}
export function fetchDeleteDataSource(id: IDataSource.Item["id"]) {
return request.delete(`/data-source/${id}`, {});
}
export function fetchTestDataSource(data: DataSourcePayload) {
return request.post(`/data-source/test`, data);
}
@@ -77,6 +77,9 @@ export default {
Export: '导出', Export: '导出',
'Export Object': '导出物体', 'Export Object': '导出物体',
'Export Scene': '导出场景', 'Export Scene': '导出场景',
'Offline package': '离线包',
'Single packet': '单包',
'Multiple packet': '多包',
'PLY (Binary)': 'PLY(二进制)', 'PLY (Binary)': 'PLY(二进制)',
'STL (Binary)': 'STL(二进制)', 'STL (Binary)': 'STL(二进制)',
/* File 下的选项 End */ /* File 下的选项 End */
@@ -226,6 +229,27 @@ export default {
"Post processing": "后期处理", "Post processing": "后期处理",
postProcessing: { postProcessing: {
"Anti-aliasing": "抗锯齿", "Anti-aliasing": "抗锯齿",
"Quality Preset": "质量预设",
"Tone Mapping": "色调映射",
"Exposure": "曝光度",
"SSAO": "环境光遮蔽",
"Samples": "采样数",
"Rings": "环数",
"Bias": "偏置",
"Fade": "衰减",
"Luminance Influence": "亮度影响",
"Min Radius Scale": "最小半径缩放",
"Depth-aware Upsampling": "深度感知上采样",
"Distance Threshold": "距离阈值",
"Distance Falloff": "距离衰减",
"Range Threshold": "范围阈值",
"Range Falloff": "范围衰减",
"World Distance Threshold": "世界距离阈值",
"World Distance Falloff": "世界距离衰减",
"World Proximity Threshold": "世界邻近阈值",
"World Proximity Falloff": "世界邻近衰减",
"Colorize": "颜色化",
"AO Color": "遮蔽颜色",
"Outline": "描边线", "Outline": "描边线",
"Edge Strength": "边缘强度", "Edge Strength": "边缘强度",
"Edge Glow": "边缘发光", "Edge Glow": "边缘发光",
@@ -237,6 +261,57 @@ export default {
"Radius": "半径", "Radius": "半径",
"Threshold": "阈值", "Threshold": "阈值",
"Strength": "强度", "Strength": "强度",
"Brightness Contrast": "亮度对比度",
"Brightness": "亮度",
"Contrast": "对比度",
"Chromatic Aberration": "色差",
"Offset X": "偏移 X",
"Offset Y": "偏移 Y",
"Radial Modulation": "径向调制",
"Modulation Offset": "调制偏移",
"Color Depth": "色深",
"Bits": "位数",
"Hue Saturation": "色相饱和度",
"Hue": "色相",
"Saturation": "饱和度",
"Tilt Shift": "移轴模糊",
"Offset": "偏移",
"Rotation": "旋转",
"Focus Area": "焦点区域",
"Feather": "羽化",
"Scanline": "扫描线",
"Density": "密度",
"Scroll Speed": "滚动速度",
"Glitch": "故障",
"Glitch Mode": "故障模式",
"Sporadic": "偶发",
"Constant Mild": "持续轻微",
"Constant Wild": "持续剧烈",
"Delay Min": "延迟最小值",
"Delay Max": "延迟最大值",
"Duration Min": "持续最小值",
"Duration Max": "持续最大值",
"Strength Min": "强度最小值",
"Strength Max": "强度最大值",
"Ratio": "出现概率",
"Lens Distortion": "镜头畸变",
"Distortion X": "畸变 X",
"Distortion Y": "畸变 Y",
"Principal Point X": "主点 X",
"Principal Point Y": "主点 Y",
"Focal Length X": "焦距 X",
"Focal Length Y": "焦距 Y",
"Skew": "倾斜",
"Shock Wave": "冲击波",
"Click on scene to trigger shock wave effect": "点击场景触发冲击波效果",
"Amplitude": "振幅",
"Wave Size": "波浪大小",
"Speed": "传播速度",
"Max Radius": "最大半径",
"Click Trigger": "点击触发",
"Vignette": "暗角",
"Darkness": "暗度",
"Blend Function": "混合模式",
"LUT Color filter":"LUT 颜色滤镜", "LUT Color filter":"LUT 颜色滤镜",
"Intensity":"强度", "Intensity":"强度",
"Afterimage":"运动残影", "Afterimage":"运动残影",
@@ -245,6 +320,7 @@ export default {
"Focus": "焦距", "Focus": "焦距",
"Aperture": "孔径", "Aperture": "孔径",
"MaxBlur": "最大模糊", "MaxBlur": "最大模糊",
"Resolution Scale": "分辨率缩放",
"Pixelate": "像素风", "Pixelate": "像素风",
"PixelSize": "像素大小", "PixelSize": "像素大小",
"NormalEdgeStrength": "法向边缘强度", "NormalEdgeStrength": "法向边缘强度",
@@ -545,7 +621,26 @@ export default {
Heatmap: "热力图", Heatmap: "热力图",
Path: "路径", Path: "路径",
"UI panel": "UI面板", "UI panel": "UI面板",
"Data component": "数据组件",
"Water pool": "水池", "Water pool": "水池",
dataComponent: {
"Data set": "数据集",
"Select data set": "选择数据集",
"Data filter": "数据过滤器",
"Enable filter": "启用过滤",
"Disabled filter": "禁用过滤",
"Popup editor": "弹出编辑",
"Filter editor": "过滤器编辑",
"Auto refresh": "自动刷新",
"Seconds once": "秒一次",
"Apply to model": "应用于模型",
"Transition": "过渡时间",
"Milliseconds": "毫秒",
"Data result": "数据结果",
"Result editor": "结果编辑",
"Manual refresh": "手动刷新",
"Result error": "结果错误",
},
heatmap: { heatmap: {
"Heatmap Texture": "热力图纹理", "Heatmap Texture": "热力图纹理",
Blur: "模糊", Blur: "模糊",
@@ -747,6 +842,7 @@ export default {
Open: "开启", Open: "开启",
Close: "关闭", Close: "关闭",
Enable: "启用", Enable: "启用",
Enabled: "启用",
Minimum: "最小值", Minimum: "最小值",
Maximum: "最大值", Maximum: "最大值",
Width: "宽度", Width: "宽度",
+4 -3
View File
@@ -27,9 +27,10 @@ async function main() {
app.mount('#app'); app.mount('#app');
if (import.meta.env.PROD){ if (import.meta.env.PROD){
DisableDevtool({ // DisableDevtool({
clearLog:true // md5: "32981a13284db7a021131df49e6cd203",
}); // clearLog: true,
// });
} }
} }
@@ -0,0 +1,48 @@
import { t } from "@/language";
// Blend mode options for post-processing blendFunction config.
const BLEND_FUNCTION_KEYS = [
"NORMAL",
"ADD",
"ALPHA",
"AVERAGE",
"COLOR",
"COLOR_BURN",
"COLOR_DODGE",
"DARKEN",
"DIFFERENCE",
"DIVIDE",
"DST",
"EXCLUSION",
"HARD_LIGHT",
"HARD_MIX",
"HUE",
"INVERT",
"INVERT_RGB",
"LIGHTEN",
"LINEAR_BURN",
"LINEAR_DODGE",
"LINEAR_LIGHT",
"LUMINOSITY",
"MULTIPLY",
"NEGATION",
"OVERLAY",
"PIN_LIGHT",
"REFLECT",
"SATURATION",
"SCREEN",
"SET",
"SOFT_LIGHT",
"SRC",
"SUBTRACT",
"VIVID_LIGHT",
] as const;
export const blendFunctionOptions = BLEND_FUNCTION_KEYS.map(k => ({ label: k, value: k }));
// Glitch mode options.
export const glitchModeOptions = [
{ label: t("layout.sider.postProcessing.Sporadic"), value: "SPORADIC" },
{ label: t("layout.sider.postProcessing.Constant Mild"), value: "CONSTANT_MILD" },
{ label: t("layout.sider.postProcessing.Constant Wild"), value: "CONSTANT_WILD" },
];
@@ -1,8 +0,0 @@
import { injectWasm } from "@/utils/wasm/inject";
export function clearBuffer() {
injectWasm({ wasmUrl: "/wasm/Astral3DEditor.wasm" }).then(() => {
// 加载完wasm后自动注册了清除缓存的函数调用
window.clearBuffer();
});
}
+2 -5
View File
@@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
import {ref, onMounted, nextTick,provide} from 'vue'; import {ref, onMounted, nextTick,provide} from 'vue';
import {Hooks,App,Package,defaultProjectInfo} from "@astral3d/engine"; import {Hooks,App,defaultProjectInfo} from "@astral3d/engine";
import * as Layout from './layouts'; import * as Layout from './layouts';
import {connectWebSocket} from "@/hooks/useWebSocket"; import {connectWebSocket} from "@/hooks/useWebSocket";
import {useRoute} from "vue-router"; import {useRoute} from "vue-router";
@@ -82,8 +82,7 @@ function getScene(sceneInfo) {
closable: false, closable: false,
}) })
const p = new Package(window.viewer); window.viewer.package.unpack({
p.unpack({
url: sceneInfo.zip, url: sceneInfo.zip,
onSceneLoad: () => { onSceneLoad: () => {
drawingInfo.value = App.project.getKey("drawing"); drawingInfo.value = App.project.getKey("drawing");
@@ -94,8 +93,6 @@ function getScene(sceneInfo) {
Hooks.useDispatchSignal("sceneLoadComplete"); Hooks.useDispatchSignal("sceneLoadComplete");
notice.destroy(); notice.destroy();
p.dispose();
} }
}) })
return; return;
@@ -4,20 +4,16 @@
<SceneTree /> <SceneTree />
</div> </div>
<n-tabs default-value="bim" animated type="line" justify-content="space-around" class="!h-60%" <n-tabs default-value="cad" animated type="line" justify-content="space-around" class="!h-60%"
pane-wrapper-class="layout-assets-tab-pane-wrapper"> pane-wrapper-class="layout-assets-tab-pane-wrapper">
<n-tab-pane name="cad" tab="CAD" display-directive="show"> <n-tab-pane name="cad" tab="CAD" display-directive="show">
<CadLibrary /> <CadLibrary />
</n-tab-pane> </n-tab-pane>
<n-tab-pane name="bim" tab="BIM" display-directive="show">
<BIMLibrary />
</n-tab-pane>
</n-tabs> </n-tabs>
</div> </div>
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import BIMLibrary from "./assets/BIMLibrary.vue";
import SceneTree from "@/components/tree/SceneTree.vue"; import SceneTree from "@/components/tree/SceneTree.vue";
import CadLibrary from "./assets/CadLibrary.vue"; import CadLibrary from "./assets/CadLibrary.vue";
</script> </script>
@@ -14,6 +14,7 @@ import {
MagicWandFilled, MagicWandFilled,
CloudSnow, CloudSnow,
HeatMap, HeatMap,
DataBase,
Opacity, Opacity,
ImageReference, ImageReference,
LocationHeart, LocationHeart,
@@ -45,6 +46,7 @@ import SidebarHeatmap from "./sidebar/SidebarHeatmap.vue";
import SidebarPath from "./sidebar/SidebarPath.vue"; import SidebarPath from "./sidebar/SidebarPath.vue";
import SidebarUIPanel from "./sidebar/SidebarUIPanel.vue"; import SidebarUIPanel from "./sidebar/SidebarUIPanel.vue";
import SidebarWaterPool from "./sidebar/SidebarWaterPool.vue"; import SidebarWaterPool from "./sidebar/SidebarWaterPool.vue";
import SidebarDataComponent from "./sidebar/SidebarDataComponent.vue";
const tabsInstRef = ref<TabsInst | null>(null); const tabsInstRef = ref<TabsInst | null>(null);
const tabs = ref<Array<any>>([]); const tabs = ref<Array<any>>([]);
@@ -74,6 +76,7 @@ function setTabs(object){
{ name: "geometry", icon: { text: 'Geometry',color:"#6287D1", component: markRaw(GroupObjects) }, component: markRaw(SidebarGeometry) }, { name: "geometry", icon: { text: 'Geometry',color:"#6287D1", component: markRaw(GroupObjects) }, component: markRaw(SidebarGeometry) },
{ name: "material", icon: { text: 'Material',color:"#6287D1", component: markRaw(Opacity) }, component: markRaw(SidebarMaterial) }, { name: "material", icon: { text: 'Material',color:"#6287D1", component: markRaw(Opacity) }, component: markRaw(SidebarMaterial) },
{ name: "animations", icon: { text: 'Animations',color:"#06AF88", component: markRaw(Draw) }, component: markRaw(SidebarAnimations) }, { name: "animations", icon: { text: 'Animations',color:"#06AF88", component: markRaw(Draw) }, component: markRaw(SidebarAnimations) },
{ name: "dataComponent", icon: { text: 'Data component', color:"#0bbc14", component: markRaw(DataBase) }, component: markRaw(SidebarDataComponent) },
{ name: "script", icon: { text: 'Script',color:"#06AF88", component: markRaw(Script) }, component: markRaw(SidebarScript) }, { name: "script", icon: { text: 'Script',color:"#06AF88", component: markRaw(Script) }, component: markRaw(SidebarScript) },
] ]
@@ -1,339 +0,0 @@
<template>
<div id="bim-library" class="h-full flex flex-col">
<n-alert type="info" :show-icon="false" :bordered="false" class="mb-2 mx-2">
<n-button quaternary round type="primary" @click="showHistoryModal = true">
{{ t("layout.sider.History") }}
</n-button>
<n-button quaternary circle type="primary" @click="showBIMUpload = true">
<template #icon>
<n-icon>
<CloudUpload/>
</n-icon>
</template>
</n-button>
</n-alert>
<div class="flex-1 overflow-y-auto px-0.3rem">
<div class="grid grid-cols-[repeat(auto-fill,minmax(80px,1fr))] gap-2">
<n-card size="small" hoverable v-for="item in objectList" :key="item.id" @dblclick="addToScene(item)"
draggable="true" @dragstart="dragStart($event, item)" @dragend="dragEnd">
<template #cover>
<n-spin :show="item.conversionStatus === 0">
<template #description>
正在轻量化...
</template>
<img :src="item.thumbnail ? item.thumbnail : '/static/images/placeholder/占位图.png'"
:alt="item.fileName" draggable="false">
<n-tag :color="{ color: '#F1C3CC', textColor: '#D03050' }" :bordered="false" size="small"
class="absolute top-33px w-full" v-if="item.conversionStatus === 2">
轻量化失败
<template #icon>
<n-icon>
<CloseCircleSharp/>
</n-icon>
</template>
</n-tag>
</n-spin>
</template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger> {{ item.fileName }}</template>
<span> {{ item.fileName }} </span>
</n-tooltip>
</n-card>
</div>
</div>
<div class="flex justify-center mt-0.5rem">
<n-pagination v-bind="paginationReactive"></n-pagination>
</div>
<n-modal v-model:show="showHistoryModal" class="!w-60vw" preset="dialog" display-directive="show"
:title="t('bim[\'BIM lightweight\']') + t('layout.sider.History')">
<n-data-table class="mt-20px" size="small" :loading="tableLoading" :columns="columns"
:data="objectList"></n-data-table>
<div class="flex justify-end mt-0.5rem">
<n-pagination v-bind="paginationReactive"></n-pagination>
</div>
</n-modal>
<!-- BIM文件上传 -->
<BimUploadDialog v-model:show="showBIMUpload" ref="uploadDialogRef"/>
</div>
</template>
<script setup lang="ts">
import {ref, reactive, onMounted, h, onBeforeUnmount} from "vue";
import type {DataTableColumns} from 'naive-ui';
import {NButton, NTag, NIcon} from 'naive-ui'
import {CloudUpload, Reload, CheckmarkCircle, CloseOutline, CloseCircleSharp} from '@vicons/ionicons5';
import {t} from "@/language";
import {useDragStore} from "@/store/modules/drag";
import {fetchGetBim2GltfList} from "@/http/api/bim";
import {Bim2GltfWsData, WebSocketMessage} from "~/network";
import {Loader,Hooks} from "@astral3d/engine";
import {filterSize} from "@/utils/common/file";
import {onWebSocket, offWebSocket} from "@/hooks/useWebSocket";
import {useWebsocketStore} from "@/store/modules/websocket";
import {dateTimeFormat} from "@/utils/common/dateTime";
import BimUploadDialog from "./bimLibrary/BimUploadDialog.vue";
const websocketStore = useWebsocketStore();
let objectList = ref<IBIMData[]>([]);
const showHistoryModal = ref(false);
const tableLoading = ref(false);
const columns: DataTableColumns<IBIMData> = [
{
title: '文件名',
key: 'fileName'
},
{
title: 'bim文件体积',
key: 'bimFileSize',
render(row) {
return filterSize(row.bimFileSize);
}
},
{
title: 'gltf文件体积',
key: 'gltfFileSize',
render(row) {
return filterSize(row.gltfFileSize);
}
},
{
title: '转换时长',
key: 'conversionDuration',
render(row) {
return row.conversionDuration.toFixed(2) + "s";
}
},
{
title: '状态',
key: 'conversionStatus',
render(row) {
return h(
NTag,
{
bordered: false,
type: row.conversionStatus === 0 ? "warning" : row.conversionStatus === 1 ? "success" : "error",
},
{
default: () => row.conversionStatus === 0 ? "转换中" : row.conversionStatus === 1 ? "成功" : "失败",
icon: () => h(
NIcon, {
component: row.conversionStatus === 0 ? Reload : row.conversionStatus === 1 ? CheckmarkCircle : CloseOutline
}
)
}
)
}
},
{
title: '操作',
key: 'actions',
render(row) {
if (row.conversionStatus !== 1) return "";
return h(
NButton,
{
size: 'small',
onClick: () => addToScene(row)
},
{default: () => t("other.Load")}
)
}
}
];
let paginationReactive = reactive({
page: 1,
pageSize: 10,
pageCount: 1,
"on-update:page": (page: number) => {
paginationReactive.page = page;
getBim2GltfList();
}
})
const showBIMUpload = ref(false);
const uploadDialogRef = ref();
// 获取BIM转换列表
async function getBim2GltfList() {
const res = await fetchGetBim2GltfList({
offset: (paginationReactive.page - 1) * paginationReactive.pageSize,
limit: paginationReactive.pageSize
});
objectList.value = res.data?.items || [];
paginationReactive.pageCount = res.data?.pages || 1;
}
async function addToScene(item) {
showHistoryModal.value = false;
let notice = window.$notification.info({
title: window.$t("scene['Get the scene data']") + "...",
content: window.$t("other.Loading") + "...",
closable: false,
})
// 从gltfFilePath字段去服务器获取gltf文件
fetch(`file/static/${item.gltfFilePath}`)
.then(res => res.blob())
.then(data => {
const file = new File([data as Blob], `${item.fileName}.glb`, {type: 'model/gltf-binary'});
notice.content = window.$t("scene['Parsing to editor']");
Loader.loadFiles([file], undefined).finally(() => {
setTimeout(() => {
notice.destroy();
Hooks.useDispatchSignal("sceneGraphChanged");
}, 800)
Hooks.useDispatchSignal("sceneGraphChanged");
})
})
.catch(() => {
notice.content = window.$t("scene['Failed to get scene data']");
setTimeout(() => {
notice.destroy();
}, 500)
return null;
})
}
// 开始拖拽事件
const dragStore = useDragStore();
function dragStart(_, item) {
if (item.conversionStatus !== 1) return;
dragStore.setData(item)
}
function dragEnd() {
if (dragStore.getActionTarget !== "addToScene") return;
const data = dragStore.getData
if (data.conversionStatus !== 1) return;
addToScene(data);
dragStore.setActionTarget("");
}
// websocket监听 bim2gltf 消息
function Bim2GltfWsHandle(data: WebSocketMessage<Bim2GltfWsData>) {
if (data.type === "bim2gltf") {
// 判断订阅者,以确定是自己还是别人轻量化的结果
if (data.subscriber === websocketStore.uname) {
let wsNotice = uploadDialogRef.value?.getNotice();
// 如果是自己的轻量化结果,使用用户登录结果作为uname,则用户可能关闭页面再打开时接收到消息,应继续显示
if (!wsNotice) {
wsNotice = window.$notification.info({
title: t("bim['BIM lightweight']"),
content: "",
closable: false,
})
}
if (data.data.conversionStatus === "progress") {
wsNotice.content = t("bim['BIM lightweight is in progress']") + "...";
} else if (data.data.conversionStatus === "completed") {
wsNotice.content = `${t("bim['BIM lightweight completed']")},${t("bim.In")} ${data.data.item.conversionDuration} ${t("bim.seconds")}`;
setTimeout(() => {
wsNotice?.destroy();
window.$dialog.info({
title: t("bim['BIM lightweight completed']"),
content: t("bim['Whether to load the BIM model into the scene?']"),
positiveText: window.$t('other.Load'),
negativeText: window.$t('other.Cancel'),
onPositiveClick: () => {
addToScene(data.data.item);
}
})
}, 800)
getBim2GltfList();
} else if (data.data.conversionStatus === "failed") {
wsNotice.content = t("bim['BIM lightweight failed']");
setTimeout(() => {
wsNotice?.destroy();
}, 1500)
getBim2GltfList();
}
} else {
if (data.data.conversionStatus !== "completed") return;
// 别人的轻量化结果
const n = window.$notification.info({
title: t("bim['BIM lightweight']"),
content: t("bim['New lightweight BIM model received, do you want to view it?']"),
duration: 5000,
closable: true,
meta: dateTimeFormat("yyyy-MM-dd HH:mm:ss"),
action: () =>
h(NButton, {
text: true,
type: 'primary',
onClick: () => {
addToScene(data.data.item);
n.destroy();
}
},
{
default: () => t("other.Load")
}
),
})
}
}
}
onMounted(() => {
getBim2GltfList();
// 注册websocket消息监听
onWebSocket(Bim2GltfWsHandle);
})
onBeforeUnmount(() => {
offWebSocket(Bim2GltfWsHandle)
})
</script>
<style scoped lang="less">
#bim-library {
overflow-x: hidden;
.n-alert {
:deep(.n-alert-body) {
padding: 10px;
&__content {
display: flex;
justify-content: space-between;
align-items: center;
}
}
}
.n-card {
cursor: pointer;
:deep(.n-card-cover) {
img {
height: 89px;
object-fit: cover;
}
}
:deep(.n-card__content) {
padding: 3px 5px;
font-size: 13px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
</style>
@@ -1,277 +0,0 @@
<template>
<n-modal :show="show" @update:show="(b) => emits('update:show',b)" class="!w-500px" preset="card" :title="t('bim.BIM lightweight')">
<n-form label-placement="left" :model="BIMModel" :rules="BIMRules"
label-width="100px" label-align="right" ref="formRef"
require-mark-placement="right-hanging">
<!-- <n-form-item :label="t('bim[\'File name\']')" path="fileName">-->
<!-- <n-input v-model:value="BIMModel.fileName"-->
<!-- :placeholder="t('bim[\'Please enter the BIM file name\']')"/>-->
<!-- </n-form-item>-->
<n-form-item :label="t('bim.Thumbnail')">
<n-upload :default-upload="false" list-type="image-card" :max="1"
@change="thumbnailChange"/>
</n-form-item>
<n-form-item :label="t('bim[\'BIM file\']')" path="bimFile">
<n-upload ref="uploadBIMRef" :default-upload="false" directory-dnd :max="1"
:accept="'.' + NEED_CONVERT_BIM_MODEL.join(',.')" @change="bimChange">
<n-upload-dragger>
<div>
<n-icon size="48" :depth="3">
<ArchiveOutline/>
</n-icon>
</div>
<n-text style="font-size: 14px">
{{
t("bim['Click or drag the file to this area.Supported formats are:']") + ` ${NEED_CONVERT_BIM_MODEL.join("、")}`
}}
</n-text>
</n-upload-dragger>
</n-upload>
</n-form-item>
<n-collapse>
<template #arrow>
<n-icon>
<CaretForwardOutline />
</n-icon>
</template>
<n-collapse-item :title="t('bim[\'Conversion configuration\']')">
<!-- 极致轻量化 -->
<n-form-item :label="t('bim[\'Extreme lightweight\']')">
<n-switch v-model:value="BIMModel.options.Optimize" @change="handleOptimizeChange" />
</n-form-item>
<!-- 导出属性 -->
<n-form-item :label="t('bim[\'Export Property\']')">
<n-switch v-model:value="BIMModel.options.ExportProperty" :disabled="exportPropertyDisabled" />
</n-form-item>
<!-- 转换视图 -->
<n-form-item :label="t('bim[\'Conversion view\']')">
<n-radio-group v-model:value="BIMModel.options.View" name="View">
<n-radio-button value="Default">默认3D视图</n-radio-button>
<n-radio-button value="Name">按名称</n-radio-button>
</n-radio-group>
</n-form-item>
<!-- 视图名称 -->
<n-form-item :label="t('bim[\'View name\']')" v-if="BIMModel.options.View === 'Name'">
<n-input v-model:value="BIMModel.options.ViewName" :placeholder="t('bim[\'Please enter the conversion view name\']')"/>
</n-form-item>
<!-- 视觉样式 -->
<n-form-item :label="t('bim[\'Display style\']')">
<n-radio-group v-model:value="BIMModel.options.DisplayStyle" name="DisplayStyle">
<n-radio-button value="Colour">{{t("bim.Colour")}}</n-radio-button>
<n-radio-button value="Realistic">{{t("bim.Realistic")}}</n-radio-button>
<n-radio-button value="ViewDefault">{{t("bim['View default']")}}</n-radio-button>
</n-radio-group>
</n-form-item>
<!-- 坐标参考 -->
<n-form-item :label="t('bim[\'Coordinate reference\']')">
<n-radio-group v-model:value="BIMModel.options.CoordinateReference" name="CoordinateReference">
<n-radio-button value="Origin">{{t("bim.Origin")}}</n-radio-button>
<n-radio-button value="ProjectBasePoint">{{t("bim['Project base point']")}}</n-radio-button>
<n-radio-button value="MeasuringPoint">{{t("bim['Measuring point']")}}</n-radio-button>
</n-radio-group>
</n-form-item>
</n-collapse-item>
</n-collapse>
</n-form>
<div class="flex justify-end">
<n-button round type="primary" @click="submit">{{ t("bim['Upload and lightweight']") }}</n-button>
</div>
</n-modal>
</template>
<script setup lang="ts">
import {reactive, ref} from "vue";
import {NotificationReactive} from "naive-ui";
import {t} from "@/language";
import {ArchiveOutline, CaretForwardOutline} from "@vicons/ionicons5";
import {fetchUpload} from "@/http/api/sys";
import {fetchAddBim2Gltf, fetchUploadRvt} from "@/http/api/bim";
import {NEED_CONVERT_BIM_MODEL} from "@/utils/common/constant";
withDefaults(defineProps<{
show:boolean
}>(),{
show:false
})
const emits = defineEmits(["update:show"]);
const formRef = ref();
const uploadBIMRef = ref();
const exportPropertyDisabled = ref(false);
/* bim文件上传转换 */
const BIMModel = reactive<{
fileName: string,
thumbnail: File | null,
bimFile: File | null,
options:{
// 极致压缩率(会执行实例化网格,破坏模型结构)
Optimize:boolean,
// 是否导出属性
ExportProperty:boolean,
// 转换视图 - Default:默认3D视图 | Name:按传入名称查找视图
View:string,
// 转换视图名称 - View = "Name"时输入有效
ViewName:string,
// 转换视觉样式 - Colour:着色模式 | Realistic-真实模式 | ViewDefault-视图默认
DisplayStyle:string,
// 坐标参考 - Origin:默认原点 | ProjectBasePoint:项目基点 | MeasuringPoint:测量点
CoordinateReference:string
}
}>({
fileName: "",
thumbnail: null,
bimFile: null,
options:{
Optimize:false,
ExportProperty:true,
View:"Default",
ViewName:"",
DisplayStyle:"Colour",
CoordinateReference:"Origin"
}
})
const BIMRules = {
bimFile: {
required: true,
trigger: 'change',
validator: (_, value) => {
return new Promise<void>((resolve, reject) => {
// 判断value是否是File类型
if (value === '' || !(value instanceof File)) {
reject(Error(t("bim['Please upload the BIM file']")))
} else {
resolve()
}
})
}
}
}
// 预览图变化
function thumbnailChange({file}) {
if (file.status === "removed") {
BIMModel.thumbnail = null;
return;
}
BIMModel.thumbnail = file.file as File;
}
// bim文件选择变化
function bimChange({file}) {
if (file.status === "removed") {
BIMModel.bimFile = null;
formRef.value?.validate();
return;
}
if (!NEED_CONVERT_BIM_MODEL.includes(file.name.split(".").at(-1).toLowerCase())) {
window.$message?.error(t("prompt['This format is not supported, please upload again! Supported formats are:']") + ` ${NEED_CONVERT_BIM_MODEL.join("、")}`);
uploadBIMRef.value.clear();
return false
}
BIMModel.bimFile = file.file as File;
BIMModel.fileName = BIMModel.bimFile.name;
formRef.value?.validate();
}
// 极致轻量化选项变化(导出属性选项跟随变化)
function handleOptimizeChange() {
if(BIMModel.options.Optimize){
BIMModel.options.ExportProperty = false;
exportPropertyDisabled.value = true;
}else{
exportPropertyDisabled.value = false;
}
}
// ws 监听使用的notice
let wsNotice: null | NotificationReactive = null;
const getNotice = () => wsNotice;
// 提交
function submit(e) {
if (import.meta.env.PROD) {
window.$message?.error(window.$t("prompt['Disable this function in the demonstration environment!']"));
return;
}
e.preventDefault();
formRef.value?.validate(async (errors) => {
if (!errors) {
emits("update:show",false);
wsNotice = window.$notification.info({
title: t("bim['BIM lightweight']"),
content: t("prompt.Uploading") + "...",
closable: false,
})
// 1. 上传缩略图
let thumbnail = "";
if (BIMModel.thumbnail) {
const res = await fetchUpload({
file: BIMModel.thumbnail,
biz: "upload/bim/thumbnail"
})
if (res.data === null) {
window.$message?.error(t("bim['Failed to upload thumbnail']"));
} else {
thumbnail = res.data as string;
}
}
// 2. 上传bim文件
const bimRes = await fetchUploadRvt({
file: BIMModel.bimFile,
});
if (bimRes.data === null) {
window.$message?.error(t("bim['Failed to upload BIM file']"));
wsNotice.content = `${t("bim['Failed to upload BIM file']")},${t("prompt['Please try again later!']")}`;
setTimeout(() => {
wsNotice?.destroy();
}, 1000)
return;
}
//3 启动Revit轻量化服务
wsNotice.content = t("bim['BIM lightweight is in progress']") + "...";
await fetchAddBim2Gltf({
bimFilePath: bimRes.data,
bimFileSize: (BIMModel.bimFile as File).size,
fileName: BIMModel.fileName,
fileSourceIp: "",
thumbnail,
conversionStatus: 0,
// 转换配置
options:BIMModel.options
})
// reset
BIMModel.fileName = "";
BIMModel.thumbnail = null;
BIMModel.bimFile = null;
BIMModel.options = {
Optimize:false,
ExportProperty:true,
View:"Default",
ViewName:"",
DisplayStyle:"Colour",
CoordinateReference:"Origin"
}
}
})
}
defineExpose({getNotice})
</script>
<style scoped lang="less">
.n-form-item{
margin-bottom: 10px;
}
</style>
@@ -0,0 +1,598 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import type { SelectOption } from "naive-ui";
import { Popup } from "@vicons/carbon";
import { t } from "@/language";
import { App, Hooks, Utils, DataBindingManager } from "@astral3d/engine";
import { fetchDataSetPage, fetchExecuteDataSet } from "@/http/api/dataSet";
import CodeEditor from "@/components/code/CodeEditor.vue";
import EsInputNumber from "@/components/es/EsInputNumber.vue";
type DataSetId = IDataSet.Item["id"];
type DataComponentConfig = {
dataSetId?: DataSetId | null;
filterEnabled?: boolean;
filterBody?: string;
autoRefreshEnabled?: boolean;
autoRefreshInterval?: number;
applyToModel?: boolean;
transition?: number;
};
const defaultFilterBody = [" // Editable area", " // Example: return data.filter(item => item.value > 100);", " return data;"].join("\n");
const defaultConfig = {
dataSetId: null as DataSetId | null,
filterEnabled: false,
filterBody: defaultFilterBody,
autoRefreshEnabled: false,
autoRefreshInterval: 5,
applyToModel: false,
transition: 0,
};
const isSelectObject3D = ref(false);
const dataSetOptions = ref<SelectOption[]>([]);
const dataSetLoading = ref(false);
const resultSource = ref("");
const resultLoading = ref(false);
const fetchError = ref("");
const filterError = ref("");
const applyError = ref("");
const showFilterModal = ref(false);
const showResultModal = ref(false);
const resultError = computed(() => [fetchError.value, filterError.value, applyError.value].filter(Boolean).join(" | "));
const rawResult = ref<unknown>(undefined);
const processedResult = ref<unknown>(undefined);
let selectedObject: any = null;
const config = reactive({ ...defaultConfig });
const filterEditorKey = computed(() => (config.filterEnabled ? "filter-enabled" : "filter-disabled"));
const filterEditorConfig = computed(() => ({
readOnly: !config.filterEnabled,
minimap: { enabled: false },
renderValidationDecorations: "off",
automaticLayout: true,
lineNumbers: "off",
}));
const filterModalEditorConfig = computed(() => ({
readOnly: !config.filterEnabled,
minimap: { enabled: false },
renderValidationDecorations: "off",
automaticLayout: true,
lineNumbers: "on",
}));
const resultEditorConfig = {
readOnly: true,
minimap: { enabled: false },
renderValidationDecorations: "off",
automaticLayout: true,
lineNumbers: "off",
};
const resultModalEditorConfig = {
readOnly: true,
minimap: { enabled: false },
renderValidationDecorations: "off",
automaticLayout: true,
lineNumbers: "on",
};
let isHydrating = false;
let refreshTimer: ReturnType<typeof setInterval> | null = null;
let executeToken = 0;
async function loadDataSets() {
dataSetLoading.value = true;
const res = await fetchDataSetPage({ page: 1, pageSize: 1000 });
dataSetLoading.value = false;
if (res.error) return;
dataSetOptions.value =
res.data?.items?.map(item => ({
label: item.name,
value: item.id,
})) || [];
}
function clearResult() {
executeToken += 1;
rawResult.value = undefined;
processedResult.value = undefined;
resultSource.value = "";
resultLoading.value = false;
fetchError.value = "";
filterError.value = "";
applyError.value = "";
}
function normalizeInterval(value: number | null | undefined) {
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return defaultConfig.autoRefreshInterval;
}
return Math.min(600, Math.max(1, Math.round(numeric)));
}
function normalizeResult(value: unknown) {
if (typeof value !== "string") {
return value;
}
const trimmed = value.trim();
if (!trimmed) {
return value;
}
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
function formatResult(value: unknown) {
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return String(value ?? "");
}
}
function applyFilter(value: unknown) {
if (!config.filterEnabled) {
return { value, error: "" };
}
const body = (config.filterBody || "").trim();
if (!body) {
return { value, error: "" };
}
try {
const filterFn = new Function("data", body);
return { value: filterFn(value), error: "" };
} catch (error) {
const message = (error as Error).message || t("prompt.Parse failed");
window.$message?.error(message);
return { value, error: message };
}
}
function writeComponentData(value: unknown, options: { applyToModel?: boolean } = {}) {
const object = selectedObject;
if (!object) return;
const result = DataBindingManager.setData(object, value, {
index: 0,
applyToModel: options.applyToModel,
transition: config.transition,
onUpdate: () => Hooks.useDispatchSignal("objectChanged", object),
});
if (options.applyToModel && result.errors.length > 0) {
applyError.value = result.errors.join(" | ");
} else {
applyError.value = "";
}
}
function syncProcessedData(options: { applyToModel?: boolean } = {}) {
if (rawResult.value === undefined) {
processedResult.value = undefined;
resultSource.value = "";
filterError.value = "";
applyError.value = "";
return;
}
const { value, error } = applyFilter(rawResult.value);
processedResult.value = value;
filterError.value = error;
resultSource.value = formatResult(value);
writeComponentData(value, { applyToModel: options.applyToModel });
}
async function executeDataSet() {
if (!config.dataSetId) {
clearResult();
return;
}
const dataSetId = config.dataSetId;
const token = ++executeToken;
resultLoading.value = true;
fetchError.value = "";
filterError.value = "";
applyError.value = "";
const res = await fetchExecuteDataSet(dataSetId);
if (token !== executeToken) {
return;
}
resultLoading.value = false;
if (res.error) {
fetchError.value = res.error.msg || t("other['Query failed']");
return;
}
if (dataSetId !== config.dataSetId) {
return;
}
rawResult.value = normalizeResult(res.data);
syncProcessedData({ applyToModel: config.applyToModel });
}
function stopAutoRefresh() {
if (refreshTimer) {
clearInterval(refreshTimer);
refreshTimer = null;
}
}
function syncAutoRefresh() {
stopAutoRefresh();
if (!config.autoRefreshEnabled || !config.dataSetId) {
return;
}
const interval = normalizeInterval(config.autoRefreshInterval);
refreshTimer = setInterval(() => {
executeDataSet();
}, interval * 1000);
}
function buildConfig(): DataComponentConfig {
return {
dataSetId: config.dataSetId ?? null,
filterEnabled: Boolean(config.filterEnabled),
filterBody: config.filterBody ?? "",
autoRefreshEnabled: Boolean(config.autoRefreshEnabled),
autoRefreshInterval: normalizeInterval(config.autoRefreshInterval),
applyToModel: Boolean(config.applyToModel),
transition: Number.isFinite(Number(config.transition)) ? Number(config.transition) : defaultConfig.transition,
};
}
function isSameConfig(left: unknown, right: unknown) {
try {
return JSON.stringify(left) === JSON.stringify(right);
} catch {
return false;
}
}
function saveConfigToObject() {
const object = selectedObject;
if (!object) return;
const nextConfig = buildConfig();
const currentList = Array.isArray(object.dataComponent) ? object.dataComponent : [];
const currentConfig = currentList[0]?.config;
if (isSameConfig(currentConfig, nextConfig)) {
return;
}
DataBindingManager.setConfig(object, nextConfig, { index: 0 });
}
const scheduleSave = Utils.debounce(() => {
if (isHydrating) return;
saveConfigToObject();
}, 200);
const schedulePreview = Utils.debounce(() => {
syncProcessedData({ applyToModel: config.applyToModel });
}, 150);
function applyConfigFromObject(object: any) {
const saved = Array.isArray(object?.dataComponent) ? (object.dataComponent[0]?.config as DataComponentConfig | undefined) : undefined;
isHydrating = true;
config.dataSetId = saved?.dataSetId ?? defaultConfig.dataSetId;
config.filterEnabled = saved?.filterEnabled ?? defaultConfig.filterEnabled;
config.filterBody = typeof saved?.filterBody === "string" ? saved.filterBody : defaultConfig.filterBody;
config.autoRefreshEnabled = saved?.autoRefreshEnabled ?? defaultConfig.autoRefreshEnabled;
config.autoRefreshInterval = normalizeInterval(saved?.autoRefreshInterval ?? defaultConfig.autoRefreshInterval);
config.applyToModel = saved?.applyToModel ?? defaultConfig.applyToModel;
config.transition = Number.isFinite(Number(saved?.transition)) ? Number(saved?.transition) : defaultConfig.transition;
nextTick(() => {
isHydrating = false;
});
}
function handleSelectObject(object: any) {
stopAutoRefresh();
selectedObject = object || null;
if (object) {
isSelectObject3D.value = true;
applyConfigFromObject(object);
if (config.dataSetId) {
executeDataSet();
} else {
clearResult();
}
syncAutoRefresh();
} else {
isSelectObject3D.value = false;
clearResult();
}
}
watch(
() => config.dataSetId,
value => {
if (isHydrating) return;
scheduleSave();
if (value) {
executeDataSet();
} else {
clearResult();
stopAutoRefresh();
}
syncAutoRefresh();
}
);
watch(
() => config.filterEnabled,
() => {
if (isHydrating) return;
scheduleSave();
syncProcessedData({ applyToModel: config.applyToModel });
}
);
watch(
() => config.filterBody,
() => {
if (isHydrating) return;
scheduleSave();
schedulePreview();
}
);
watch(
() => config.autoRefreshEnabled,
value => {
if (isHydrating) return;
scheduleSave();
syncAutoRefresh();
if (value) {
executeDataSet();
}
}
);
watch(
() => config.autoRefreshInterval,
() => {
if (isHydrating) return;
scheduleSave();
syncAutoRefresh();
}
);
watch(
() => config.transition,
() => {
if (isHydrating) return;
scheduleSave();
}
);
watch(
() => config.applyToModel,
value => {
if (isHydrating) return;
scheduleSave();
if (!value) {
applyError.value = "";
return;
}
if (processedResult.value !== undefined) {
writeComponentData(processedResult.value, { applyToModel: true });
}
}
);
onMounted(() => {
Hooks.useAddSignal("objectSelected", handleSelectObject);
loadDataSets();
handleSelectObject(App.selected);
});
onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", handleSelectObject);
stopAutoRefresh();
});
</script>
<template>
<div v-if="isSelectObject3D" class="data-component-panel">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Data set") }}</span>
<n-select
v-model:value="config.dataSetId"
size="small"
:options="dataSetOptions"
:loading="dataSetLoading"
:placeholder="t('layout.sider.dataComponent.Select data set')"
filterable
clearable
/>
</div>
<n-divider class="!mt-2 !mb-4" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Data filter") }}</span>
<div class="flex justify-end gap-2">
<n-switch v-model:value="config.filterEnabled">
<template #checked>
{{ t("layout.sider.dataComponent.Enable filter") }}
</template>
<template #unchecked>
{{ t("layout.sider.dataComponent.Disabled filter") }}
</template>
</n-switch>
</div>
</div>
<div class="filter-editor relative" :class="{ 'is-disabled': !config.filterEnabled }">
<div class="filter-editor-line">function filter(data) {</div>
<CodeEditor :key="filterEditorKey" v-model:source="config.filterBody" mode="javascript" :config="filterEditorConfig" class="filter-editor-code" />
<div class="filter-editor-line">}</div>
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="small" tertiary @click="showFilterModal = true" class="absolute top-1 right-1">
<template #icon>
<n-icon><Popup /></n-icon>
</template>
</n-button>
</template>
{{ t("layout.sider.dataComponent.Popup editor") }}
</n-tooltip>
</div>
<n-divider class="!mt-2 !mb-4" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Apply to model") }}</span>
<div class="apply-controls">
<n-checkbox v-model:checked="config.applyToModel" size="small" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Auto refresh") }}</span>
<div class="flex justify-end gap-2 items-center">
<n-checkbox v-model:checked="config.autoRefreshEnabled" size="small" />
<EsInputNumber
v-model:value="config.autoRefreshInterval"
size="small"
:min="1"
:max="86400"
:decimal="0"
:step="1"
:show-button="false"
:disabled="!config.autoRefreshEnabled"
:unit="t('layout.sider.dataComponent.Seconds once')"
class="!w-60%"
/>
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Transition") }}</span>
<div class="flex justify-end items-center">
<EsInputNumber
v-model:value="config.transition"
size="small"
:min="0"
:max="600000"
:step="100"
:decimal="0"
:show-button="false"
:unit="t('layout.sider.dataComponent.Milliseconds')"
class="!w-60%"
/>
</div>
</div>
<n-divider class="!mt-2 !mb-4" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.dataComponent.Data result") }}</span>
<div class="flex items-center justify-end gap-2">
<n-text depth="3" v-if="resultLoading">{{ t("other.Loading") }}</n-text>
<n-button size="tiny" tertiary :disabled="!config.dataSetId" :loading="resultLoading" @click="executeDataSet">
{{ t("layout.sider.dataComponent.Manual refresh") }}
</n-button>
</div>
</div>
<div class="filter-editor relative">
<n-alert v-if="resultError" type="error" :title="t('layout.sider.dataComponent.Result error')" class="mb-2">
{{ resultError }}
</n-alert>
<CodeEditor v-model:source="resultSource" mode="json" :config="resultEditorConfig" class="flex-1 min-h-200px" />
<n-tooltip trigger="hover">
<template #trigger>
<n-button size="small" tertiary @click="showResultModal = true" class="absolute top-1 right-1">
<template #icon>
<n-icon><Popup /></n-icon>
</template>
</n-button>
</template>
{{ t("layout.sider.dataComponent.Popup editor") }}
</n-tooltip>
</div>
</div>
<n-result v-else status="418" title="Empty" :description="t('prompt[\'No object selected.\']')" />
<n-modal v-model:show="showFilterModal" preset="dialog" :showIcon="false" :title="t('layout.sider.dataComponent.Filter editor')" class="!w-90vw">
<div class="filter-editor filter-editor-modal" :class="{ 'is-disabled': !config.filterEnabled }">
<div class="filter-editor-line">function filter(data) {</div>
<CodeEditor
:key="`modal-${filterEditorKey}`"
v-model:source="config.filterBody"
mode="javascript"
:config="filterModalEditorConfig"
class="filter-editor-code filter-editor-code--modal"
/>
<div class="filter-editor-line">}</div>
</div>
<div class="flex justify-end mt-3">
<n-button size="small" @click="showFilterModal = false">{{ t("other.Close") }}</n-button>
</div>
</n-modal>
<n-modal v-model:show="showResultModal" preset="dialog" :showIcon="false" :title="t('layout.sider.dataComponent.Result editor')" class="!w-90vw">
<CodeEditor v-model:source="resultSource" mode="json" :config="resultModalEditorConfig" class="flex-1 min-h-200px !h-70vh" />
<div class="flex justify-end mt-3">
<n-button size="small" @click="showResultModal = false">{{ t("other.Close") }}</n-button>
</div>
</n-modal>
</template>
<style scoped lang="less">
.data-component-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.filter-editor {
margin-bottom: 0.5rem;
width: 100%;
border: 1px solid var(--n-border-color);
border-radius: 6px;
overflow: hidden;
background: #1e1e1e;
}
.filter-editor.is-disabled {
opacity: 0.6;
}
.filter-editor-line {
padding: 4px 8px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
color: #c5c5c5;
background: #1e1e1e;
}
.filter-editor-code {
height: 180px;
}
.filter-editor-code--modal {
height: 60vh;
}
.apply-controls {
display: flex;
justify-content: flex-end;
}
</style>
@@ -1,37 +1,122 @@
<script setup lang="ts"> <script setup lang="ts">
import {onMounted, ref} from "vue"; import { onMounted, ref } from "vue";
import {CaretForwardOutline} from "@vicons/ionicons5"; import { CaretForwardOutline } from "@vicons/ionicons5";
import {t} from "@/language"; import { t } from "@/language";
import {App} from "@astral3d/engine"; import { App } from "@astral3d/engine";
import OutLine from './effect/Sidebar.Effect.Outline.vue'; import OutLine from "./effect/Sidebar.Effect.Outline.vue";
import FXAA from './effect/Sidebar.Effect.FXAA.vue'; import SMAA from "./effect/Sidebar.Effect.SMAA.vue";
import UnrealBloom from './effect/Sidebar.Effect.UnrealBloom.vue'; import SSAO from "./effect/Sidebar.Effect.SSAO.vue";
import LUT from './effect/Sidebar.Effect.LUT.vue'; import UnrealBloom from "./effect/Sidebar.Effect.UnrealBloom.vue";
import Afterimage from './effect/Sidebar.Effect.Afterimage.vue'; import Bokeh from "./effect/Sidebar.Effect.Bokeh.vue";
import Bokeh from './effect/Sidebar.Effect.Bokeh.vue'; import Pixelate from "./effect/Sidebar.Effect.Pixelate.vue";
import Pixelate from './effect/Sidebar.Effect.Pixelate.vue'; import TiltShift from "./effect/Sidebar.Effect.TiltShift.vue";
import Halftone from './effect/Sidebar.Effect.Halftone.vue'; import Scanline from "./effect/Sidebar.Effect.Scanline.vue";
import EsTip from "@/components/es/EsTip.vue"; import BrightnessContrast from "./effect/Sidebar.Effect.BrightnessContrast.vue";
import ChromaticAberration from "./effect/Sidebar.Effect.ChromaticAberration.vue";
import ColorDepth from "./effect/Sidebar.Effect.ColorDepth.vue";
import Glitch from "./effect/Sidebar.Effect.Glitch.vue";
import HueSaturation from "./effect/Sidebar.Effect.HueSaturation.vue";
import LensDistortion from "./effect/Sidebar.Effect.LensDistortion.vue";
import ShockWave from "./effect/Sidebar.Effect.ShockWave.vue";
import ToneMapping from "./effect/Sidebar.Effect.ToneMapping.vue";
import Vignette from "./effect/Sidebar.Effect.Vignette.vue";
import EsTip from "@/components/es/EsTip.vue";
const effectEnabled = ref(App.project.getKey("effect.enabled")); const effectDefaults: Record<string, any> = {
ToneMapping: { mode: "ACES_FILMIC", exposure: 1.0, blendFunction: "NORMAL" },
SMAA: { enabled: true, preset: "ULTRA" },
SSAO: {
enabled: true,
blendFunction: "MULTIPLY",
samples: 9,
rings: 7,
radius: 0.1825,
intensity: 1.0,
bias: 0.025,
fade: 0.01,
luminanceInfluence: 0.7,
minRadiusScale: 0.1,
depthAwareUpsampling: true,
resolutionScale: 0.5,
distanceThreshold: 0.97,
distanceFalloff: 0.03,
rangeThreshold: 0.0005,
rangeFalloff: 0.001,
worldDistanceThreshold: null,
worldDistanceFalloff: null,
worldProximityThreshold: null,
worldProximityFalloff: null,
colorEnabled: false,
color: "#000000",
},
TiltShift: { enabled: false, offset: 0.0, rotation: 0.0, focusArea: 0.4, feather: 0.3, blendFunction: "NORMAL" },
Scanline: { enabled: false, density: 1.25, scrollSpeed: 0.0, blendFunction: "OVERLAY" },
BrightnessContrast: { enabled: false, brightness: 0.0, contrast: 0.0, blendFunction: "SRC" },
ChromaticAberration: {
enabled: false,
offset: { x: 0.002, y: 0.002 },
radialModulation: false,
modulationOffset: 0.15,
blendFunction: "NORMAL",
},
ColorDepth: { enabled: false, bits: 16, blendFunction: "NORMAL" },
Glitch: {
enabled: false,
chromaticAberrationOffset: null,
delay: { min: 1.5, max: 3.5 },
duration: { min: 0.6, max: 1.0 },
strength: { min: 0.3, max: 1.0 },
mode: "SPORADIC",
ratio: 0.85,
blendFunction: "NORMAL",
},
HueSaturation: { enabled: false, hue: 0.0, saturation: 0.0, blendFunction: "SRC" },
LensDistortion: {
enabled: false,
distortion: { x: 0.0, y: 0.0 },
principalPoint: { x: 0.0, y: 0.0 },
focalLength: { x: 1.0, y: 1.0 },
skew: 0.0,
},
ShockWave: { enabled: false, amplitude: 0.05, waveSize: 0.2, speed: 2.0, maxRadius: 1.0, clickTrigger: true },
Vignette: { enabled: false, offset: 0.5, darkness: 0.5, blendFunction: "NORMAL" },
};
onMounted(() => { // 兼容旧项目配置缺失 effect 子项,避免子组件 setup 阶段 JSON.parse(undefined)
function ensureEffectDefaults() {
if (App.project.getKey("effect") === undefined) {
App.project.setKey("effect", { enabled: false }, false);
}
Object.entries(effectDefaults).forEach(([name, config]) => {
const key = `effect.${name}`;
if (App.project.getKey(key) === undefined) {
App.project.setKey(key, JSON.parse(JSON.stringify(config)), false);
}
});
}
ensureEffectDefaults();
const effectEnabled = ref(App.project.getKey("effect.enabled"));
onMounted(() => {
const viewerLoaded = () => { const viewerLoaded = () => {
effectEnabled.value = App.project.getKey("effect.enabled"); effectEnabled.value = App.project.getKey("effect.enabled");
window.viewer.removeEventListener('loaded', viewerLoaded); window.viewer.removeEventListener("loaded", viewerLoaded);
} };
window.viewer.addEventListener('loaded', viewerLoaded); window.viewer.addEventListener("loaded", viewerLoaded);
}) });
function handleEffectEnabledChange(value:boolean){ function handleEffectEnabledChange(value: boolean) {
App.project.setKey("effect.enabled",value); App.project.setKey("effect.enabled", value);
} }
</script> </script>
<template> <template>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<h4>{{ t('layout.sider.Post processing') }}</h4> <h4>{{ t("layout.sider.Post processing") }}</h4>
<n-switch v-model:value="effectEnabled" @update:value="handleEffectEnabledChange"> <n-switch v-model:value="effectEnabled" @update:value="handleEffectEnabledChange">
<template #checked> <template #checked>
{{ t("other.Open") }} {{ t("other.Open") }}
@@ -44,7 +129,12 @@ function handleEffectEnabledChange(value:boolean){
<n-divider class="!my-3" /> <n-divider class="!my-3" />
<n-collapse display-directive="show" :default-expanded-names="['Anti-aliasing','Outline','UnrealBloom']"> <!-- ToneMapping:色调映射 -->
<div class="px-2 mb-3">
<ToneMapping :effect-enabled="effectEnabled" />
</div>
<n-collapse display-directive="show" :default-expanded-names="['Anti-aliasing', 'Outline']">
<template #arrow> <template #arrow>
<n-icon> <n-icon>
<CaretForwardOutline /> <CaretForwardOutline />
@@ -53,10 +143,14 @@ function handleEffectEnabledChange(value:boolean){
<!-- 抗锯齿 --> <!-- 抗锯齿 -->
<n-collapse-item :title="t('layout.sider.postProcessing[\'Anti-aliasing\']')" name="Anti-aliasing"> <n-collapse-item :title="t('layout.sider.postProcessing[\'Anti-aliasing\']')" name="Anti-aliasing">
<FXAA :effect-enabled="effectEnabled" /> <SMAA :effect-enabled="effectEnabled" />
</n-collapse-item> </n-collapse-item>
<!-- Outline:描边线 --> <!-- Outline:描边线 -->
<n-collapse-item :title="t('layout.sider.postProcessing.SSAO')" name="SSAO">
<SSAO :effect-enabled="effectEnabled" />
</n-collapse-item>
<n-collapse-item :title="t('layout.sider.postProcessing.Outline')" name="Outline"> <n-collapse-item :title="t('layout.sider.postProcessing.Outline')" name="Outline">
<OutLine :effect-enabled="effectEnabled" /> <OutLine :effect-enabled="effectEnabled" />
</n-collapse-item> </n-collapse-item>
@@ -66,40 +160,74 @@ function handleEffectEnabledChange(value:boolean){
<UnrealBloom :effect-enabled="effectEnabled" /> <UnrealBloom :effect-enabled="effectEnabled" />
</n-collapse-item> </n-collapse-item>
<!-- LUT:颜色滤镜 -->
<n-collapse-item :title="t('layout.sider.postProcessing.LUT Color filter')" name="LUT">
<LUT :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- 运动残影 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Afterimage')" name="Afterimage">
<Afterimage :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- Bokeh:变焦 --> <!-- Bokeh:变焦 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Bokeh')" name="Bokeh"> <n-collapse-item :title="t('layout.sider.postProcessing.Bokeh')" name="Bokeh">
<Bokeh :effect-enabled="effectEnabled" /> <Bokeh :effect-enabled="effectEnabled" />
</n-collapse-item> </n-collapse-item>
<!-- Pixelate:像素风 --> <!-- Pixelate:像素风 -->
<n-collapse-item name="Pixelate"> <n-collapse-item :title="t(`layout.sider.postProcessing.Pixelate`)" name="Pixelate">
<Pixelate :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- TiltShift:移轴模糊 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Tilt Shift')" name="TiltShift">
<TiltShift :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- Scanline:扫描线 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Scanline')" name="Scanline">
<Scanline :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- BrightnessContrast:亮度对比度 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Brightness Contrast')" name="BrightnessContrast">
<BrightnessContrast :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- ChromaticAberration:色差 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Chromatic Aberration')" name="ChromaticAberration">
<ChromaticAberration :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- ColorDepth:色深 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Color Depth')" name="ColorDepth">
<ColorDepth :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- Glitch:故障 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Glitch')" name="Glitch">
<Glitch :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- HueSaturation:色相饱和度 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Hue Saturation')" name="HueSaturation">
<HueSaturation :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- LensDistortion:镜头畸变 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Lens Distortion')" name="LensDistortion">
<LensDistortion :effect-enabled="effectEnabled" />
</n-collapse-item>
<!-- ShockWave:冲击波 -->
<n-collapse-item name="ShockWave">
<template #header> <template #header>
<n-text> <n-text>
<EsTip class="!justify-start" :content="t(`layout.sider.postProcessing.Pixelate`)"> <EsTip class="!justify-start" :content="t(`layout.sider.postProcessing.Shock Wave`)">
{{ t(`layout.sider.postProcessing.Use a solid color background to achieve the best rendering effect`) }} {{ t(`layout.sider.postProcessing.Click on scene to trigger shock wave effect`) }}
</EsTip> </EsTip>
</n-text> </n-text>
</template> </template>
<Pixelate :effect-enabled="effectEnabled" /> <ShockWave :effect-enabled="effectEnabled" />
</n-collapse-item> </n-collapse-item>
<!-- Halftone:半色调 --> <!-- Vignette:晕影 -->
<n-collapse-item :title="t('layout.sider.postProcessing.Halftoning')" name="Halftoning"> <n-collapse-item :title="t('layout.sider.postProcessing.Vignette')" name="Vignette">
<Halftone :effect-enabled="effectEnabled" /> <Vignette :effect-enabled="effectEnabled" />
</n-collapse-item> </n-collapse-item>
</n-collapse> </n-collapse>
</template> </template>
<style lang="less"> <style lang="less"></style>
</style>
@@ -29,6 +29,22 @@ onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", updateUI); Hooks.useRemoveSignal("objectSelected", updateUI);
}) })
function ensureHtmlPanelOptions(object: any) {
const options = object?.options || {};
const codes = Array.isArray(options.codes) ? options.codes : [];
const isSingleHtml = typeof options.isSingleHtml === 'boolean' ? options.isSingleHtml : true;
const isSprite = typeof options.isSprite === 'boolean' ? options.isSprite : !!object?.isHtmlSprite;
object.options = {
...options,
codes,
isSingleHtml,
isSprite
};
return object.options;
}
function updateUI() { function updateUI() {
const object = App.selected; const object = App.selected;
if(!object) return; if(!object) return;
@@ -39,7 +55,8 @@ function updateUI() {
} }
data.type = object.isHtmlPanel? 'panel' :'sprite'; data.type = object.isHtmlPanel? 'panel' :'sprite';
data.codes = object.options.codes; const options = ensureHtmlPanelOptions(object);
data.codes = options.codes;
} }
function update(method: string){ function update(method: string){
@@ -53,8 +70,9 @@ function update(method: string){
if(data.type === 'panel' && object.isHtmlPanel) return; if(data.type === 'panel' && object.isHtmlPanel) return;
if(data.type === 'sprite' && object.isHtmlSprite) return; if(data.type === 'sprite' && object.isHtmlSprite) return;
const _json = object.toJSON(); const _json = object.toJSON() as any;
_json.object.type = data.type === 'panel' ? 'HtmlSprite' : 'HtmlPanel'; _json.object.type = data.type === 'panel' ? 'HtmlSprite' : 'HtmlPanel';
_json.object.options = _json.object.options || {};
_json.object.options.isSprite = !_json.object.options.isSprite; _json.object.options.isSprite = !_json.object.options.isSprite;
Loader.objectLoader.parseAsync(_json).then(newObject3D => { Loader.objectLoader.parseAsync(_json).then(newObject3D => {
@@ -73,9 +91,11 @@ function update(method: string){
_code.content = editorCode.code; _code.content = editorCode.code;
const options = ensureHtmlPanelOptions(object);
const htmlPanelOption = { const htmlPanelOption = {
isSprite: object.options.isSprite, isSprite: options.isSprite,
isSingleHtml:object.options.isSingleHtml, isSingleHtml: options.isSingleHtml,
codes: data.codes codes: data.codes
} }
@@ -135,19 +155,26 @@ function updateFileList(fList: UploadFileInfo[]) {
isSprite: data.type ==='sprite', isSprite: data.type ==='sprite',
fileName: file.name fileName: file.name
}).then(htmlPlaneObj => { }).then(htmlPlaneObj => {
data.codes = htmlPlaneObj.options.codes; const options = ensureHtmlPanelOptions(object);
const _json = object.toJSON(); options.codes = htmlPlaneObj.options.codes;
Loader.objectLoader.copyAttrByData(htmlPlaneObj, _json.object) options.isSingleHtml = htmlPlaneObj.options.isSingleHtml;
object.parent?.add(htmlPlaneObj); data.codes = options.codes;
object.parent?.remove(object); // Keep object identity (id/uuid) so scene tree and context menu references stay valid.
while (object.element.firstChild) {
object.element.removeChild(object.element.firstChild);
}
App.selected = htmlPlaneObj; while (htmlPlaneObj.element.firstChild) {
object.element.appendChild(htmlPlaneObj.element.firstChild);
}
Hooks.useDispatchSignal("objectSelected", htmlPlaneObj); App.selected = object;
}).catch((e:Error) => window.$message?.error(e.message)); Hooks.useDispatchSignal("objectSelected", object);
}).catch((e:Error) => window.$message?.error(e.message))
.finally(() => URL.revokeObjectURL(tempURL));
} }
function getCanEdit(name: string) { function getCanEdit(name: string) {
@@ -121,15 +121,15 @@ const updateUI = Utils.throttle(function(object) {
objectData.type = object.type; objectData.type = object.type;
objectData.uuid = object.uuid; objectData.uuid = object.uuid;
objectData.name = object.name; objectData.name = object.name;
objectData.position.x = Number(object.position.x.toFixed(3)); objectData.position.x = Number(object.position.x.toFixed(6));
objectData.position.y = Number(object.position.y.toFixed(3)); objectData.position.y = Number(object.position.y.toFixed(6));
objectData.position.z = Number(object.position.z.toFixed(3)); objectData.position.z = Number(object.position.z.toFixed(6));
objectData.rotation.x = Number((object.rotation.x * THREE.MathUtils.RAD2DEG).toFixed(3)); objectData.rotation.x = Number((object.rotation.x * THREE.MathUtils.RAD2DEG).toFixed(6));
objectData.rotation.y = Number((object.rotation.y * THREE.MathUtils.RAD2DEG).toFixed(3)); objectData.rotation.y = Number((object.rotation.y * THREE.MathUtils.RAD2DEG).toFixed(6));
objectData.rotation.z = Number((object.rotation.z * THREE.MathUtils.RAD2DEG).toFixed(3)); objectData.rotation.z = Number((object.rotation.z * THREE.MathUtils.RAD2DEG).toFixed(6));
objectData.scale.x = Number(object.scale.x.toFixed(3)); objectData.scale.x = Number(object.scale.x.toFixed(6));
objectData.scale.y = Number(object.scale.y.toFixed(3)); objectData.scale.y = Number(object.scale.y.toFixed(6));
objectData.scale.z = Number(object.scale.z.toFixed(3)); objectData.scale.z = Number(object.scale.z.toFixed(6));
if (object.fov !== undefined) { if (object.fov !== undefined) {
objectData.fov = object.fov; objectData.fov = object.fov;
@@ -225,19 +225,19 @@ const update = (method: string) => {
}, },
position: () => { position: () => {
const newPosition = new THREE.Vector3(objectData.position.x, objectData.position.y, objectData.position.z); const newPosition = new THREE.Vector3(objectData.position.x, objectData.position.y, objectData.position.z);
if (object.position.distanceTo(newPosition) >= 0.01) { if (object.position.distanceTo(newPosition) >= 0.0001) {
App.execute(new SetPositionCommand(object, newPosition)); App.execute(new SetPositionCommand(object, newPosition));
} }
}, },
rotation: () => { rotation: () => {
const newRotation = new THREE.Euler(objectData.rotation.x * THREE.MathUtils.DEG2RAD, objectData.rotation.y * THREE.MathUtils.DEG2RAD, objectData.rotation.z * THREE.MathUtils.DEG2RAD); const newRotation = new THREE.Euler(objectData.rotation.x * THREE.MathUtils.DEG2RAD, objectData.rotation.y * THREE.MathUtils.DEG2RAD, objectData.rotation.z * THREE.MathUtils.DEG2RAD);
if (new THREE.Vector3().setFromEuler(object.rotation).distanceTo(new THREE.Vector3().setFromEuler(newRotation)) >= 0.01) { if (new THREE.Vector3().setFromEuler(object.rotation).distanceTo(new THREE.Vector3().setFromEuler(newRotation)) >= 0.0001) {
App.execute(new SetRotationCommand(object, newRotation, undefined)); App.execute(new SetRotationCommand(object, newRotation, undefined));
} }
}, },
scale: () => { scale: () => {
const newScale = new THREE.Vector3(objectData.scale.x, objectData.scale.y, objectData.scale.z); const newScale = new THREE.Vector3(objectData.scale.x, objectData.scale.y, objectData.scale.z);
if (object.scale.distanceTo(newScale) >= 0.01) { if (object.scale.distanceTo(newScale) >= 0.0001) {
App.execute(new SetScaleCommand(object, newScale, undefined)); App.execute(new SetScaleCommand(object, newScale, undefined));
} }
}, },
@@ -433,11 +433,11 @@ const handleUserDataClick = () => {
<div class="sider-scene-attr-item"> <div class="sider-scene-attr-item">
<EsKeyFrame :label="t('layout.sider.object.position')" attr="position" /> <EsKeyFrame :label="t('layout.sider.object.position')" attr="position" />
<div class="flex"> <div class="flex">
<EsInputNumber v-model:value="objectData.position.x" size="tiny" :show-button="false" :decimal="3" :step="1" <EsInputNumber v-model:value="objectData.position.x" size="tiny" :show-button="false" :decimal="6" :step="1"
@change="update('position')" /> @change="update('position')" />
<EsInputNumber v-model:value="objectData.position.y" size="tiny" :show-button="false" :decimal="3" :step="1" <EsInputNumber v-model:value="objectData.position.y" size="tiny" :show-button="false" :decimal="6" :step="1"
@change="update('position')" /> @change="update('position')" />
<EsInputNumber v-model:value="objectData.position.z" size="tiny" :show-button="false" :decimal="3" :step="1" <EsInputNumber v-model:value="objectData.position.z" size="tiny" :show-button="false" :decimal="6" :step="1"
@change="update('position')" /> @change="update('position')" />
</div> </div>
</div> </div>
@@ -445,11 +445,11 @@ const handleUserDataClick = () => {
<div class="sider-scene-attr-item" v-if="transformRowsVisible.rotation"> <div class="sider-scene-attr-item" v-if="transformRowsVisible.rotation">
<EsKeyFrame :label="t('layout.sider.object.rotation')" attr="quaternion" /> <EsKeyFrame :label="t('layout.sider.object.rotation')" attr="quaternion" />
<div class="flex"> <div class="flex">
<EsInputNumber v-model:value="objectData.rotation.x" size="tiny" :decimal="2" :step="1" :show-button="false" <EsInputNumber v-model:value="objectData.rotation.x" size="tiny" :decimal="6" :step="1" :show-button="false"
@change="update('rotation')" unit="°" /> @change="update('rotation')" unit="°" />
<EsInputNumber v-model:value="objectData.rotation.y" size="tiny" :decimal="2" :step="1" :show-button="false" <EsInputNumber v-model:value="objectData.rotation.y" size="tiny" :decimal="6" :step="1" :show-button="false"
@change="update('rotation')" unit="°" /> @change="update('rotation')" unit="°" />
<EsInputNumber v-model:value="objectData.rotation.z" size="tiny" :decimal="2" :step="1" :show-button="false" <EsInputNumber v-model:value="objectData.rotation.z" size="tiny" :decimal="6" :step="1" :show-button="false"
@change="update('rotation')" unit="°" /> @change="update('rotation')" unit="°" />
</div> </div>
</div> </div>
@@ -28,7 +28,7 @@ function handleAfterimageConfigChange(){
<template> <template>
<div class="sidebar-config-item"> <div class="sidebar-config-item">
<span>{{ t(`other.Enable`) }}</span> <span>{{ t(`other.Enabled`) }}</span>
<div> <div>
<n-checkbox size="small" v-model:checked="afterimage.enabled" :disabled="!effectEnabled" @update:checked="handleAfterimageConfigChange"/> <n-checkbox size="small" v-model:checked="afterimage.enabled" :disabled="!effectEnabled" @update:checked="handleAfterimageConfigChange"/>
</div> </div>
@@ -46,3 +46,4 @@ function handleAfterimageConfigChange(){
<style scoped lang="less"> <style scoped lang="less">
</style> </style>
@@ -28,7 +28,7 @@ function handleBokehConfigChange(){
<template> <template>
<div class="sidebar-config-item"> <div class="sidebar-config-item">
<span>{{ t(`other.Enable`) }}</span> <span>{{ t(`other.Enabled`) }}</span>
<div> <div>
<n-checkbox size="small" v-model:checked="bokeh.enabled" :disabled="!effectEnabled" @update:checked="handleBokehConfigChange"/> <n-checkbox size="small" v-model:checked="bokeh.enabled" :disabled="!effectEnabled" @update:checked="handleBokehConfigChange"/>
</div> </div>
@@ -63,3 +63,4 @@ function handleBokehConfigChange(){
<style scoped lang="less"> <style scoped lang="less">
</style> </style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions } from "../../../../../utils/common/postprocessing";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const bc = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.BrightnessContrast"))));
const disabled = computed(() => !props.effectEnabled || !bc.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(bc, App.project.getKey("effect.BrightnessContrast"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.BrightnessContrast`, toRaw(bc));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="bc.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 亮度 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Brightness`) }}</span>
<div>
<n-slider v-model:value="bc.brightness" :step="0.01" :min="-1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 对比度 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Contrast`) }}</span>
<div>
<n-slider v-model:value="bc.contrast" :step="0.01" :min="-1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 混合模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select size="small" v-model:value="bc.blendFunction" :options="blendFunctionOptions" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions } from "../../../../../utils/common/postprocessing";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const ca = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.ChromaticAberration"))));
const disabled = computed(() => !props.effectEnabled || !ca.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(ca, App.project.getKey("effect.ChromaticAberration"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.ChromaticAberration`, toRaw(ca));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="ca.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 偏移 X -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Offset X`) }}</span>
<div>
<n-slider v-model:value="ca.offset.x" :step="0.001" :min="0" :max="0.05" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 偏移 Y -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Offset Y`) }}</span>
<div>
<n-slider v-model:value="ca.offset.y" :step="0.001" :min="0" :max="0.05" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 径向调制 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Radial Modulation`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="ca.radialModulation" :disabled="disabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 调制偏移 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Modulation Offset`) }}</span>
<div>
<n-slider v-model:value="ca.modulationOffset" :step="0.01" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 混合模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select size="small" v-model:value="ca.blendFunction" :options="blendFunctionOptions" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -0,0 +1,59 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions } from "../../../../../utils/common/postprocessing";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const colorDepth = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.ColorDepth"))));
const disabled = computed(() => !props.effectEnabled || !colorDepth.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(colorDepth, App.project.getKey("effect.ColorDepth"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.ColorDepth`, toRaw(colorDepth));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="colorDepth.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 色深位数 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Bits`) }}</span>
<div>
<n-slider v-model:value="colorDepth.bits" :step="1" :min="1" :max="32" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 混合模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select v-model:value="colorDepth.blendFunction" :options="blendFunctionOptions" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -27,7 +27,7 @@ function handleFXAAConfigChange(){
<template> <template>
<div class="sidebar-config-item"> <div class="sidebar-config-item">
<span>{{ t(`other.Enable`) }}</span> <span>{{ t(`other.Enabled`) }}</span>
<div> <div>
<n-checkbox size="small" v-model:checked="faxx.enabled" :disabled="!effectEnabled" @update:checked="handleFXAAConfigChange"/> <n-checkbox size="small" v-model:checked="faxx.enabled" :disabled="!effectEnabled" @update:checked="handleFXAAConfigChange"/>
</div> </div>
@@ -37,3 +37,4 @@ function handleFXAAConfigChange(){
<style scoped lang="less"> <style scoped lang="less">
</style> </style>
@@ -0,0 +1,121 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions, glitchModeOptions } from "../../../../../utils/common/postprocessing";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const glitch = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.Glitch"))));
const disabled = computed(() => !props.effectEnabled || !glitch.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(glitch, App.project.getKey("effect.Glitch"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.Glitch`, toRaw(glitch));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="glitch.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 故障模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Glitch Mode`) }}</span>
<div>
<n-select v-model:value="glitch.mode" :options="glitchModeOptions" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 延迟最小值 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Delay Min`) }}</span>
<div>
<n-slider v-model:value="glitch.delay.min" :step="0.1" :min="0" :max="5" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 延迟最大值 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Delay Max`) }}</span>
<div>
<n-slider v-model:value="glitch.delay.max" :step="0.1" :min="0" :max="10" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 持续时间最小值 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Duration Min`) }}</span>
<div>
<n-slider v-model:value="glitch.duration.min" :step="0.1" :min="0" :max="2" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 持续时间最大值 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Duration Max`) }}</span>
<div>
<n-slider v-model:value="glitch.duration.max" :step="0.1" :min="0" :max="5" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 强度最小值 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Strength Min`) }}</span>
<div>
<n-slider v-model:value="glitch.strength.min" :step="0.1" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 强度最大值 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Strength Max`) }}</span>
<div>
<n-slider v-model:value="glitch.strength.max" :step="0.1" :min="0" :max="2" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 故障比率 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Ratio`) }}</span>
<div>
<n-slider v-model:value="glitch.ratio" :step="0.05" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 混合模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select
size="small"
v-model:value="glitch.blendFunction"
:options="blendFunctionOptions"
:disabled="disabled"
@update:value="handleConfigChange"
/>
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -28,7 +28,7 @@ function handleHalftoneConfigChange(){
<template> <template>
<div class="sidebar-config-item"> <div class="sidebar-config-item">
<span>{{ t(`other.Enable`) }}</span> <span>{{ t(`other.Enabled`) }}</span>
<div> <div>
<n-checkbox size="small" v-model:checked="halftone.enabled" :disabled="!effectEnabled" @update:checked="handleHalftoneConfigChange"/> <n-checkbox size="small" v-model:checked="halftone.enabled" :disabled="!effectEnabled" @update:checked="handleHalftoneConfigChange"/>
</div> </div>
@@ -121,3 +121,4 @@ function handleHalftoneConfigChange(){
<style scoped lang="less"> <style scoped lang="less">
</style> </style>
@@ -0,0 +1,69 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions } from "../../../../../utils/common/postprocessing";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const hueSat = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.HueSaturation"))));
const disabled = computed(() => !props.effectEnabled || !hueSat.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(hueSat, App.project.getKey("effect.HueSaturation"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.HueSaturation`, toRaw(hueSat));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="hueSat.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 色相 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Hue`) }}</span>
<div>
<n-slider v-model:value="hueSat.hue" :step="0.01" :min="-3.14" :max="3.14" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 饱和度 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Saturation`) }}</span>
<div>
<n-slider v-model:value="hueSat.saturation" :step="0.01" :min="-1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 混合模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select v-model:value="hueSat.blendFunction" :options="blendFunctionOptions" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -30,7 +30,7 @@ function handleLUTConfigChange(){
<template> <template>
<div class="sidebar-config-item"> <div class="sidebar-config-item">
<span>{{ t(`other.Enable`) }}</span> <span>{{ t(`other.Enabled`) }}</span>
<div> <div>
<n-checkbox size="small" v-model:checked="lut.enabled" :disabled="!effectEnabled" @update:checked="handleLUTConfigChange"/> <n-checkbox size="small" v-model:checked="lut.enabled" :disabled="!effectEnabled" @update:checked="handleLUTConfigChange"/>
</div> </div>
@@ -56,3 +56,4 @@ function handleLUTConfigChange(){
<style scoped lang="less"> <style scoped lang="less">
</style> </style>
@@ -0,0 +1,98 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const lens = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.LensDistortion"))));
const disabled = computed(() => !props.effectEnabled || !lens.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(lens, App.project.getKey("effect.LensDistortion"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.LensDistortion`, toRaw(lens));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="lens.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 畸变 X -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Distortion X`) }}</span>
<div>
<n-slider v-model:value="lens.distortion.x" :step="0.01" :min="-1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 畸变 Y -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Distortion Y`) }}</span>
<div>
<n-slider v-model:value="lens.distortion.y" :step="0.01" :min="-1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 主点 X -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Principal Point X`) }}</span>
<div>
<n-slider v-model:value="lens.principalPoint.x" :step="0.01" :min="-1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 主点 Y -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Principal Point Y`) }}</span>
<div>
<n-slider v-model:value="lens.principalPoint.y" :step="0.01" :min="-1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 焦距 X -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Focal Length X`) }}</span>
<div>
<n-slider v-model:value="lens.focalLength.x" :step="0.1" :min="0.1" :max="5" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 焦距 Y -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Focal Length Y`) }}</span>
<div>
<n-slider v-model:value="lens.focalLength.y" :step="0.1" :min="0.1" :max="5" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 倾斜 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Skew`) }}</span>
<div>
<n-slider v-model:value="lens.skew" :step="0.01" :min="-1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -28,7 +28,7 @@ function handleOutlineConfigChange(){
<template> <template>
<div class="sidebar-config-item"> <div class="sidebar-config-item">
<span>{{ t(`other.Enable`) }}</span> <span>{{ t(`other.Enabled`) }}</span>
<div> <div>
<n-checkbox size="small" v-model:checked="outline.enabled" :disabled="!effectEnabled" @update:checked="handleOutlineConfigChange"/> <n-checkbox size="small" v-model:checked="outline.enabled" :disabled="!effectEnabled" @update:checked="handleOutlineConfigChange"/>
</div> </div>
@@ -86,3 +86,4 @@ function handleOutlineConfigChange(){
<style scoped lang="less"> <style scoped lang="less">
</style> </style>
@@ -28,7 +28,7 @@ function handlePixelateConfigChange(){
<template> <template>
<div class="sidebar-config-item"> <div class="sidebar-config-item">
<span>{{ t(`other.Enable`) }}</span> <span>{{ t(`other.Enabled`) }}</span>
<div> <div>
<n-checkbox size="small" v-model:checked="pixelate.enabled" :disabled="!effectEnabled" @update:checked="handlePixelateConfigChange"/> <n-checkbox size="small" v-model:checked="pixelate.enabled" :disabled="!effectEnabled" @update:checked="handlePixelateConfigChange"/>
</div> </div>
@@ -62,3 +62,4 @@ function handlePixelateConfigChange(){
<style scoped lang="less"> <style scoped lang="less">
</style> </style>
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
// SMAA (Subpixel Morphological Anti-Aliasing) 替代 FXAA
const smaa = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.SMAA"))));
const disabled = computed(() => !props.effectEnabled || !smaa.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(smaa, App.project.getKey("effect.SMAA"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleSMAAConfigChange() {
App.project.setKey(`effect.SMAA`, toRaw(smaa));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="smaa.enabled" :disabled="!effectEnabled" @update:checked="handleSMAAConfigChange" />
</div>
</div>
<!-- 质量预设 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Quality Preset`) }}</span>
<div>
<n-select
v-model:value="smaa.preset"
:disabled="disabled"
@update:value="handleSMAAConfigChange"
:options="[
{ label: 'Low', value: 'LOW' },
{ label: 'Medium', value: 'MEDIUM' },
{ label: 'High', value: 'HIGH' },
{ label: 'Ultra', value: 'ULTRA' },
]"
/>
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -0,0 +1,208 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions } from "../../../../../utils/common/postprocessing";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const ssao = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.SSAO"))));
const disabled = computed(() => !props.effectEnabled || !ssao.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(ssao, App.project.getKey("effect.SSAO"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
// 世界空间阈值允许为空,这里在提交前归一化,避免 NaN 污染配置
function normalizeOptionalNumber(value: unknown): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function handleConfigChange() {
ssao.worldDistanceThreshold = normalizeOptionalNumber(ssao.worldDistanceThreshold);
ssao.worldDistanceFalloff = normalizeOptionalNumber(ssao.worldDistanceFalloff);
ssao.worldProximityThreshold = normalizeOptionalNumber(ssao.worldProximityThreshold);
ssao.worldProximityFalloff = normalizeOptionalNumber(ssao.worldProximityFalloff);
App.project.setKey("effect.SSAO", toRaw(ssao));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="ssao.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select size="small" v-model:value="ssao.blendFunction" :options="blendFunctionOptions" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Samples`) }}</span>
<div>
<n-slider v-model:value="ssao.samples" :step="1" :min="1" :max="64" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Rings`) }}</span>
<div>
<n-slider v-model:value="ssao.rings" :step="1" :min="1" :max="32" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Radius`) }}</span>
<div>
<n-slider v-model:value="ssao.radius" :step="0.001" :min="0.01" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Strength`) }}</span>
<div>
<n-slider v-model:value="ssao.intensity" :step="0.01" :min="0" :max="5" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Bias`) }}</span>
<div>
<n-slider v-model:value="ssao.bias" :step="0.001" :min="0" :max="0.1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Fade`) }}</span>
<div>
<n-slider v-model:value="ssao.fade" :step="0.001" :min="0" :max="0.1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Luminance Influence`) }}</span>
<div>
<n-slider v-model:value="ssao.luminanceInfluence" :step="0.01" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Min Radius Scale`) }}</span>
<div>
<n-slider v-model:value="ssao.minRadiusScale" :step="0.01" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Depth-aware Upsampling`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="ssao.depthAwareUpsampling" :disabled="disabled" @update:checked="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Resolution Scale`) }}</span>
<div>
<n-slider v-model:value="ssao.resolutionScale" :step="0.05" :min="0.1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Distance Threshold`) }}</span>
<div>
<n-slider v-model:value="ssao.distanceThreshold" :step="0.001" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Distance Falloff`) }}</span>
<div>
<n-slider v-model:value="ssao.distanceFalloff" :step="0.001" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Range Threshold`) }}</span>
<div>
<n-slider v-model:value="ssao.rangeThreshold" :step="0.0001" :min="0" :max="0.02" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Range Falloff`) }}</span>
<div>
<n-slider v-model:value="ssao.rangeFalloff" :step="0.0001" :min="0" :max="0.02" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.World Distance Threshold`) }}</span>
<div>
<n-input-number v-model:value="ssao.worldDistanceThreshold" size="small" :min="0" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.World Distance Falloff`) }}</span>
<div>
<n-input-number v-model:value="ssao.worldDistanceFalloff" size="small" :min="0" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.World Proximity Threshold`) }}</span>
<div>
<n-input-number v-model:value="ssao.worldProximityThreshold" size="small" :min="0" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.World Proximity Falloff`) }}</span>
<div>
<n-input-number v-model:value="ssao.worldProximityFalloff" size="small" :min="0" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Colorize`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="ssao.colorEnabled" :disabled="disabled" @update:checked="handleConfigChange" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.AO Color`) }}</span>
<div>
<n-color-picker
v-model:value="ssao.color"
:show-alpha="false"
:modes="['hex']"
size="small"
:disabled="disabled || !ssao.colorEnabled"
@update:value="handleConfigChange"
/>
</div>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions } from "../../../../../utils/common/postprocessing";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const scanline = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.Scanline"))));
const disabled = computed(() => !props.effectEnabled || !scanline.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(scanline, App.project.getKey("effect.Scanline"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.Scanline`, toRaw(scanline));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="scanline.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 密度 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Density`) }}</span>
<div>
<n-slider v-model:value="scanline.density" :step="0.05" :min="0.1" :max="5" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 滚动速度 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Scroll Speed`) }}</span>
<div>
<n-slider v-model:value="scanline.scrollSpeed" :step="0.01" :min="0" :max="2" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 混合模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select
size="small"
v-model:value="scanline.blendFunction"
:options="blendFunctionOptions"
:disabled="disabled"
@update:value="handleConfigChange"
/>
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const shockWave = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.ShockWave"))));
const disabled = computed(() => !props.effectEnabled || !shockWave.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(shockWave, App.project.getKey("effect.ShockWave"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.ShockWave`, toRaw(shockWave));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="shockWave.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 振幅 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Amplitude`) }}</span>
<div>
<n-slider v-model:value="shockWave.amplitude" :step="0.01" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 波浪大小 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Wave Size`) }}</span>
<div>
<n-slider v-model:value="shockWave.waveSize" :step="0.01" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 速度 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Speed`) }}</span>
<div>
<n-slider v-model:value="shockWave.speed" :step="0.1" :min="0.1" :max="10" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 最大半径 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Max Radius`) }}</span>
<div>
<EsInputNumber
v-model:value="shockWave.maxRadius"
:show-button="false"
:decimal="1"
:min="0"
:max="100"
size="small"
:disabled="disabled"
@change="handleConfigChange"
/>
</div>
</div>
<!-- 点击触发 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Click Trigger`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="shockWave.clickTrigger" :disabled="disabled" @update:checked="handleConfigChange" />
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions } from "../../../../../utils/common/postprocessing";
const props = withDefaults(
defineProps<{
effectEnabled: boolean;
}>(),
{
effectEnabled: false,
}
);
const tiltShift = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.TiltShift"))));
const disabled = computed(() => !props.effectEnabled || !tiltShift.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(tiltShift, App.project.getKey("effect.TiltShift"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.TiltShift`, toRaw(tiltShift));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="tiltShift.enabled" :disabled="!effectEnabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 焦点偏移 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Offset`) }}</span>
<div>
<n-slider v-model:value="tiltShift.offset" :step="0.01" :min="-1" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 旋转角度 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Rotation`) }}</span>
<div>
<n-slider v-model:value="tiltShift.rotation" :step="0.01" :min="0" :max="6.28" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 焦点区域 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Focus Area`) }}</span>
<div>
<n-slider v-model:value="tiltShift.focusArea" :step="0.01" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 羽化 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Feather`) }}</span>
<div>
<n-slider v-model:value="tiltShift.feather" :step="0.01" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 混合模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select
size="small"
v-model:value="tiltShift.blendFunction"
:options="blendFunctionOptions"
:disabled="disabled"
@update:value="handleConfigChange"
/>
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -0,0 +1,81 @@
<script setup lang="ts">
import { h, onMounted, reactive, toRaw, VNode } from "vue";
import { SelectOption, NTooltip } from "naive-ui";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions } from "../../../../../utils/common/postprocessing";
const defaultToneMapping = {
mode: "ACES_FILMIC",
exposure: 1,
blendFunction: "NORMAL",
};
const toneMapping = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.ToneMapping") || defaultToneMapping)));
// 色调映射模式选项
const toneMappingModeOptions = [
{ label: "Linear", value: "LINEAR", tooltip: "线性映射,保持原始颜色" },
{ label: "Reinhard", value: "REINHARD", tooltip: "Reinhard 映射,柔和的高光压缩" },
{ label: "Reinhard2", value: "REINHARD2", tooltip: "改进的 Reinhard 映射" },
{ label: "Optimized Cineon", value: "OPTIMIZED_CINEON", tooltip: "优化的电影风格映射" },
{ label: "ACES Filmic", value: "ACES_FILMIC", tooltip: "电影工业标准 ACES 映射" },
{ label: "AgX", value: "AGX", tooltip: "AgX 映射,适合户外场景" },
{ label: "Neutral", value: "NEUTRAL", tooltip: "中性映射,平衡的色调" },
];
const renderToneMappingOption = ({ node, option }: { node: VNode; option: SelectOption }) => {
return h(NTooltip, null, {
trigger: () => node,
default: () => (option as any).tooltip || option.label,
});
};
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(toneMapping, App.project.getKey("effect.ToneMapping") || defaultToneMapping);
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.ToneMapping`, toRaw(toneMapping));
}
</script>
<template>
<!-- 色调映射模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.rendererConfig.Tone mapping`) || "色调映射" }}</span>
<div>
<n-select
v-model:value="toneMapping.mode"
:options="toneMappingModeOptions"
:render-option="renderToneMappingOption"
size="small"
@update:value="handleConfigChange"
/>
</div>
</div>
<!-- 曝光度 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Exposure`) || "曝光度" }}</span>
<div>
<n-slider v-model:value="toneMapping.exposure" :step="0.01" :min="0.1" :max="3" @update:value="handleConfigChange" />
</div>
</div>
<!-- 混合模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select size="small" v-model:value="toneMapping.blendFunction" :options="blendFunctionOptions" @update:value="handleConfigChange" />
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -28,7 +28,7 @@ function handleUnrealBloomConfigChange(){
<template> <template>
<div class="sidebar-config-item"> <div class="sidebar-config-item">
<span>{{ t(`other.Enable`) }}</span> <span>{{ t(`other.Enabled`) }}</span>
<div> <div>
<n-checkbox size="small" v-model:checked="unrealBloom.enabled" :disabled="!effectEnabled" @update:checked="handleUnrealBloomConfigChange"/> <n-checkbox size="small" v-model:checked="unrealBloom.enabled" :disabled="!effectEnabled" @update:checked="handleUnrealBloomConfigChange"/>
</div> </div>
@@ -62,3 +62,4 @@ function handleUnrealBloomConfigChange(){
<style scoped lang="less"> <style scoped lang="less">
</style> </style>
@@ -0,0 +1,70 @@
<script setup lang="ts">
import { computed, onMounted, reactive, toRaw } from "vue";
import { t } from "@/language";
import { App, Utils } from "@astral3d/engine";
import { blendFunctionOptions } from "../../../../../utils/common/postprocessing";
defineProps<{
effectEnabled: boolean;
}>();
const vignette = reactive(JSON.parse(JSON.stringify(App.project.getKey("effect.Vignette"))));
const disabled = computed(() => !vignette.enabled);
onMounted(() => {
const viewerLoaded = () => {
Utils.deepAssign(vignette, App.project.getKey("effect.Vignette"));
window.viewer.removeEventListener("loaded", viewerLoaded);
};
window.viewer.addEventListener("loaded", viewerLoaded);
});
function handleConfigChange() {
App.project.setKey(`effect.Vignette`, toRaw(vignette));
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t(`other.Enabled`) }}</span>
<div>
<n-checkbox size="small" v-model:checked="vignette.enabled" @update:checked="handleConfigChange" />
</div>
</div>
<!-- 偏移 -->
<div class="sidebar-config-item">
<span>{{ t("layout.sider.postProcessing.Offset") }}</span>
<div>
<n-slider v-model:value="vignette.offset" :step="0.01" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 暗度 -->
<div class="sidebar-config-item">
<span>{{ t("layout.sider.postProcessing.Darkness") }}</span>
<div>
<n-slider v-model:value="vignette.darkness" :step="0.01" :min="0" :max="1" :disabled="disabled" @update:value="handleConfigChange" />
</div>
</div>
<!-- 混合模式 -->
<div class="sidebar-config-item">
<span>{{ t(`layout.sider.postProcessing.Blend Function`) }}</span>
<div>
<n-select
size="small"
v-model:value="vignette.blendFunction"
:options="blendFunctionOptions"
:disabled="disabled"
@update:value="handleConfigChange"
/>
</div>
</div>
</template>
<style scoped lang="less"></style>
@@ -1,65 +0,0 @@
<template>
<n-card v-if="visible" class="absolute top-40px right-1 max-w-300px" content-style="padding: 5px 10px;">
<n-collapse accordion default-expanded-names="bim">
<template #arrow="{ collapsed }">
<n-icon v-if="collapsed">
<PlanetOutline/>
</n-icon>
<n-icon v-else>
<SunnyOutline/>
</n-icon>
</template>
<n-collapse-item title="&nbsp;BIM 构件信息" name="bim">
<div class="max-h-360px overflow-y-auto">
<n-descriptions v-for="(item, index) in info.parameters" :key="index" label-placement="left"
bordered size="small" :column="1">
<template #header>
<p class="py-2">{{ item.GroupName }}</p>
</template>
<n-descriptions-item v-for="(it, i) in item.Parameters" :key="i + it.name" :label="it.name">
{{ it.value }}
</n-descriptions-item>
</n-descriptions>
</div>
</n-collapse-item>
</n-collapse>
</n-card>
</template>
<script setup lang="ts">
import {PlanetOutline, SunnyOutline} from "@vicons/ionicons5";
import {ref, onBeforeUnmount, onMounted} from "vue";
import {Hooks} from "@astral3d/engine";
const visible = ref(false);
const info = ref({
parameters: [{GroupName: "", Parameters: [{name: "", value: ""}]}]
})
function objectSelected(object) {
if (!object) {
visible.value = false;
return;
}
if (!object.userData.BIM) {
if (!object.parent || !object.parent.userData.BIM) {
visible.value = false;
} else {
visible.value = true;
info.value = object.parent.userData.BIM;
}
} else {
visible.value = true;
info.value = object.userData.BIM;
}
}
onMounted(() => {
Hooks.useAddSignal("objectSelected", objectSelected);
})
onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", objectSelected);
})
</script>
@@ -6,9 +6,6 @@
<ViewportInfo/> <ViewportInfo/>
</div> </div>
<!-- RVT BIM 构件信息悬浮框 -->
<BIMProperties/>
<!-- IFC BIM 构件信息悬浮框 --> <!-- IFC BIM 构件信息悬浮框 -->
<IFCProperties/> <IFCProperties/>
@@ -18,15 +15,13 @@
<script setup lang="ts"> <script setup lang="ts">
import {onMounted, ref, nextTick, onBeforeUnmount} from 'vue'; import {onMounted, ref, nextTick, onBeforeUnmount} from 'vue';
import {App,Viewer,Hooks} from "@astral3d/engine"; import {App,Viewer,Hooks,WaterPool} from "@astral3d/engine";
import Toolbar from "./Toolbar.vue"; import Toolbar from "./Toolbar.vue";
import PathDrawingOverlay from "./PathDrawingOverlay.vue"; import PathDrawingOverlay from "./PathDrawingOverlay.vue";
import {useGlobalConfigStore} from "@/store/modules/globalConfig"; import {useGlobalConfigStore} from "@/store/modules/globalConfig";
import {usePluginStore} from "@/store/modules/plugin"; import {usePluginStore} from "@/store/modules/plugin";
import {installBuiltinPlugin} from "@/plugin"; import {installBuiltinPlugin} from "@/plugin";
import { clearBuffer } from "@/utils/wasm/optimize";
import ViewportInfo from "./ViewportInfo.vue"; import ViewportInfo from "./ViewportInfo.vue";
import BIMProperties from "./BIMProperties.vue";
import IFCProperties from "./IFCProperties.vue"; import IFCProperties from "./IFCProperties.vue";
const globalStore = useGlobalConfigStore(); const globalStore = useGlobalConfigStore();
@@ -34,6 +29,43 @@ const pluginStore = usePluginStore();
const viewportRef = ref(); const viewportRef = ref();
function bootstrapWaterPoolWarmup(viewer: Viewer) {
const warmupPool = new WaterPool({
sky: viewer.scene.environment,
name: '__water_pool_warmup__',
type: 'cylinder',
light: [0.7, 1, -0.3],
diameter: 5,
height: 5,
wallMode: 'none',
wallOpacity: 0,
useSceneRefraction: 1,
surfaceTransmittance: 0.6,
normalStrength: 0.5,
refractionStrength: 0.035,
});
warmupPool.visible = false;
// Use scene.add/remove directly to avoid objectAdded/objectRemoved signals and scene tree refresh.
viewer.scene.add(warmupPool);
const removeWarmupPool = () => {
if (warmupPool.parent) {
warmupPool.parent.remove(warmupPool);
}
warmupPool.dispose?.();
};
const loaded = (warmupPool as any).loaded;
if (loaded && typeof loaded.then === 'function') {
loaded.finally(removeWarmupPool);
return;
}
removeWarmupPool();
}
onMounted(async () => { onMounted(async () => {
App.setConfig({ App.setConfig({
theme: globalStore.theme.replace("Theme", ""), theme: globalStore.theme.replace("Theme", ""),
@@ -52,6 +84,8 @@ onMounted(async () => {
await nextTick(); await nextTick();
bootstrapWaterPoolWarmup(window.viewer);
// 添加astral engine内置插件并监听插件注册 // 添加astral engine内置插件并监听插件注册
pluginStore.setPlugins(Array.from(window.viewer.modules.plugin.plugins.values())); pluginStore.setPlugins(Array.from(window.viewer.modules.plugin.plugins.values()));
Hooks.useAddSignal("pluginInstall",pluginStore.addPlugin); Hooks.useAddSignal("pluginInstall",pluginStore.addPlugin);
@@ -59,9 +93,6 @@ onMounted(async () => {
// 注册astral editor内置插件 // 注册astral editor内置插件
installBuiltinPlugin(window.viewer); installBuiltinPlugin(window.viewer);
// 清理wasm缓存
clearBuffer();
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -21,7 +21,7 @@
</n-split> --> </n-split> -->
<n-flex class="h-full w-full"> <n-flex class="h-full w-full">
<DataSetGroup class="w-50 min-w-300px max-w-500px" /> <DataSetGroup class="w-50 min-w-300px max-w-500px" @select="handleGroupSelect" />
<n-divider vertical class="!h-full" /> <n-divider vertical class="!h-full" />
@@ -34,9 +34,9 @@
</template> </template>
</n-input> </n-input>
<n-button type="primary" @click="showDataSetModal = true">{{ t('home.Add data set') }}</n-button> <n-button type="primary" @click="handleAddDataSet">{{ t('home.Add data set') }}</n-button>
</div> </div>
<n-data-table ref="tableRef" :columns="dataSetColumns" :data="dataSets" :pagination="pagination" /> <n-data-table :columns="dataSetColumns" :data="dataSets" :pagination="pagination" :loading="tableLoading" />
</div> </div>
</n-flex> </n-flex>
@@ -45,29 +45,32 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, reactive, h, onMounted, useTemplateRef } from 'vue'; import { ref, reactive, h, onMounted } from 'vue';
import { NButton, NPopconfirm } from 'naive-ui'; import { NButton, NPopconfirm } from 'naive-ui';
import type { DataTableInst } from 'naive-ui';
import { Search } from '@vicons/carbon'; import { Search } from '@vicons/carbon';
import { t } from "@/language"; import { t } from "@/language";
import { fetchDataSetDetail, fetchDataSetPage, fetchDeleteDataSet } from "@/http/api/dataSet";
import DataSetModal from "./DataSetModal.vue"; import DataSetModal from "./DataSetModal.vue";
import DataSetGroup from "./DataSetGroup.vue"; import DataSetGroup from "./DataSetGroup.vue";
const tableRef = useTemplateRef<DataTableInst>("tableRef"); const dataSets = ref<IDataSet.Item[]>([])
const dataSets = ref<IDataSet.Item[]>([ const tableLoading = ref(false);
{
id: '',
groupId:"1",
name: '323324234',
type: 'SQL'
}
])
const showDataSetModal = ref(false) const showDataSetModal = ref(false)
const currentDataSet = reactive<IDataSet.Item>({ const searchName = ref("");
const selectedGroupId = ref<IDataSet.IGroup["id"] | null>(null);
const defaultDataSet: IDataSet.Item = {
id: '', id: '',
groupId:"1", groupId: '',
name: '', name: '',
type: 'SQL' type: 'API',
method: 'GET',
api: '',
dataSourceId: '',
sql: '',
json: ''
};
const currentDataSet = reactive<IDataSet.Item>({
...defaultDataSet
}) })
const dataSetColumns = [ const dataSetColumns = [
{ {
@@ -119,23 +122,75 @@ const dataSetColumns = [
} }
] ]
// 分页配置 // 分页配置
const pagination = { pageSize: 10 } const pagination = reactive({
page: 1,
pageSize: 10,
pageCount: 1,
"on-update:page": (page: number) => {
pagination.page = page;
getList();
}
})
function getList() { } function resetCurrentDataSet(data?: Partial<IDataSet.Item>) {
Object.assign(currentDataSet, defaultDataSet, data || {});
function handleSearch(searchText: string) {
tableRef.value?.filter({
name: [searchText]
})
} }
function editDataSet(item) { async function getList() {
Object.assign(currentDataSet, item) tableLoading.value = true;
const res = await fetchDataSetPage({
page: pagination.page,
pageSize: pagination.pageSize,
name: searchName.value || undefined,
groupId: selectedGroupId.value ?? undefined
});
tableLoading.value = false;
dataSets.value = res.data?.items || [];
pagination.pageCount = res.data?.pages || 1;
if (res.data?.current) {
pagination.page = res.data.current;
}
}
function handleSearch(searchText: string) {
searchName.value = searchText;
pagination.page = 1;
getList();
}
function handleGroupSelect(group: IDataSet.IGroup | null) {
selectedGroupId.value = group?.id ?? null;
pagination.page = 1;
getList();
}
function handleAddDataSet() {
resetCurrentDataSet({
groupId: selectedGroupId.value ?? ""
});
showDataSetModal.value = true;
}
async function editDataSet(item) {
const res = await fetchDataSetDetail(item.id);
if (res.error) {
return;
}
const detail = (res.data || item) as any;
resetCurrentDataSet({
...detail,
dataSourceId: detail.dataSourceId || detail.dataSource ? String(detail.dataSourceId || detail.dataSource) : ""
});
showDataSetModal.value = true showDataSetModal.value = true
} }
function deleteDataSet(item) { async function deleteDataSet(item) {
dataSets.value = dataSets.value.filter(ds => ds.id !== item.id) const res = await fetchDeleteDataSet(item.id);
if (res.error) {
return;
}
window.$message?.success(t("prompt.Success to delete"));
getList();
} }
onMounted(() => { onMounted(() => {
@@ -34,49 +34,20 @@ import { ref, reactive,onMounted } from "vue";
import type { DropdownOption, TreeOption, TreeOverrideNodeClickBehaviorReturn } from 'naive-ui'; import type { DropdownOption, TreeOption, TreeOverrideNodeClickBehaviorReturn } from 'naive-ui';
import { Search, Add } from '@vicons/carbon'; import { Search, Add } from '@vicons/carbon';
import { t } from "@/language"; import { t } from "@/language";
import { fetchDataSetGroupTree, fetchDeleteDataSetGroup } from "@/http/api/dataSetGroup";
import DataSetGroupModal from "./DataSetGroupModal.vue"; import DataSetGroupModal from "./DataSetGroupModal.vue";
const pattern = ref(""); const emits = defineEmits(["select"]);
const data: IDataSet.IGroup[] = [
{
name: '0',
id: '0',
children: [
{
name: '0-0',
id: '0-0',
children: [
{ name: '0-0-0', id: '0-0-0' },
{ name: '0-0-1', id: '0-0-1' }
]
},
{
name: '0-1',
id: '0-1',
children: [
{ name: '0-1-0', id: '0-1-0' },
{ name: '0-1-1', id: '0-1-1' }
]
}
]
},
{
name: '1',
id: '1',
children: [
{
name: '1-0',
id: '1-0'
},
{
name: '1-1',
id: '1-1'
}
]
}
]
function getTreeData(){} const pattern = ref("");
const data = ref<IDataSet.IGroup[]>([]);
const selectedGroup = ref<IDataSet.IGroup | null>(null);
const activeNode = ref<IDataSet.IGroup | null>(null);
async function getTreeData(){
const res = await fetchDataSetGroupTree();
data.value = res.data || [];
}
// 子节点选中,含子节点则展开 // 子节点选中,含子节点则展开
function handleOverride({ option }): TreeOverrideNodeClickBehaviorReturn { function handleOverride({ option }): TreeOverrideNodeClickBehaviorReturn {
@@ -96,7 +67,7 @@ const currentGroup = reactive<IDataSet.IGroup>({
function handleAddGroup(){ function handleAddGroup(){
currentGroup.id = ""; currentGroup.id = "";
currentGroup.pid = ""; currentGroup.pid = selectedGroup.value?.id ?? "";
currentGroup.name = ""; currentGroup.name = "";
showGroupModal.value = true; showGroupModal.value = true;
@@ -107,8 +78,7 @@ const showDropdown = ref(false);
const options = ref<DropdownOption[]>([ const options = ref<DropdownOption[]>([
{ {
label: t("home.Delete"), label: t("home.Delete"),
key: 'delete', key: 'delete'
disabled: true
}, },
{ {
label: t("layout.sider.script.Edit"), label: t("layout.sider.script.Edit"),
@@ -120,9 +90,15 @@ const y = ref(0)
// 绑定节点属性以实现右键菜单 // 绑定节点属性以实现右键菜单
function handleNodeProps({ option }: { option: TreeOption }) { function handleNodeProps({ option }: { option: TreeOption }) {
const group = option as IDataSet.IGroup;
return { return {
onClick() { }, onClick() {
selectedGroup.value = group;
emits("select", group);
},
onContextmenu(e: MouseEvent): void { onContextmenu(e: MouseEvent): void {
activeNode.value = group;
selectedGroup.value = group;
showDropdown.value = true; showDropdown.value = true;
x.value = e.clientX; x.value = e.clientX;
y.value = e.clientY; y.value = e.clientY;
@@ -132,8 +108,34 @@ function handleNodeProps({ option }: { option: TreeOption }) {
} }
} }
function handleDropdownSelect() { async function handleDropdownSelect(key: string) {
showDropdown.value = false; showDropdown.value = false;
if (!activeNode.value) {
return;
}
if (key === "edit") {
Object.assign(currentGroup, {
id: activeNode.value.id,
pid: activeNode.value.pid ?? "",
name: activeNode.value.name
});
showGroupModal.value = true;
return;
}
if (key === "delete") {
const res = await fetchDeleteDataSetGroup(activeNode.value.id);
if (res.error) {
return;
}
window.$message?.success(t("prompt.Success to delete"));
if (selectedGroup.value?.id === activeNode.value.id) {
selectedGroup.value = null;
emits("select", null);
}
getTreeData();
}
} }
function handleDropdownClickoutside() { function handleDropdownClickoutside() {
@@ -1,18 +1,18 @@
<template> <template>
<n-modal :show="show" @mask-click="handleClose"> <n-modal :show="show" @mask-click="handleClose">
<n-card class="w-120 max-w-600px" :title="t('home.Data set group config')"> <n-card class="w-120 max-w-600px" :title="t('home.Data set group config')">
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto"> <n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="submitLoading">
<n-form-item :label="t('home.Parent group')"> <n-form-item :label="t('home.Parent group')">
<n-cascader v-model:value="model.pid" expand-trigger="hover" :options="treeData" <n-cascader v-model:value="model.pid" expand-trigger="hover" :options="treeData"
check-strategy="child" show-path filterable clearable label-field="name" value-field="id" /> check-strategy="all" show-path filterable clearable label-field="name" value-field="id" />
</n-form-item> </n-form-item>
<n-form-item :label="t('home.Group name')" path="name"> <n-form-item :label="t('home.Group name')" path="name">
<n-input v-model:value="model.name" /> <n-input v-model:value="model.name" />
</n-form-item> </n-form-item>
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<n-button @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button> <n-button :disabled="submitLoading" @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
<n-button type="primary" @click="saveDataSet">{{ t("other.Ok") }}</n-button> <n-button type="primary" :loading="submitLoading" @click="saveDataSet">{{ t("other.Ok") }}</n-button>
</div> </div>
</n-form> </n-form>
</n-card> </n-card>
@@ -20,9 +20,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useTemplateRef,computed } from "vue"; import { useTemplateRef, ref } from "vue";
import type { FormInst } from 'naive-ui' import type { FormInst } from 'naive-ui'
import { t } from "@/language"; import { t } from "@/language";
import { DataSetGroupPayload, fetchCreateDataSetGroup, fetchUpdateDataSetGroup } from "@/http/api/dataSetGroup";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
show: boolean, show: boolean,
@@ -43,6 +44,7 @@ const formRef = useTemplateRef<FormInst>("formRef");
const rules = { const rules = {
name: { required: true, message: t("prompt.Please enter a grouping name for the dataset"), trigger: 'blur' } name: { required: true, message: t("prompt.Please enter a grouping name for the dataset"), trigger: 'blur' }
}; };
const submitLoading = ref(false);
function handleClose() { function handleClose() {
emits("update:show", false); emits("update:show", false);
@@ -51,15 +53,21 @@ function handleClose() {
function saveDataSet(e: MouseEvent) { function saveDataSet(e: MouseEvent) {
e.preventDefault() e.preventDefault()
formRef.value?.validate((errors) => { formRef.value?.validate(async (errors) => {
if (!errors) { if (!errors) {
if (!props.model.id) { const payload: DataSetGroupPayload = {
// 新增 id: props.model.id || undefined,
pid: props.model.pid || "",
} else { name: props.model.name?.trim() || ""
// 更新 };
submitLoading.value = true;
const res = props.model.id ? await fetchUpdateDataSetGroup(payload) : await fetchCreateDataSetGroup(payload);
submitLoading.value = false;
if (res.error) {
return;
} }
window.$message?.success(props.model.id ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
// 刷新父页面表格 // 刷新父页面表格
emits("refresh"); emits("refresh");
@@ -1,36 +1,49 @@
<template> <template>
<n-modal :show="show" @mask-click="handleClose"> <n-modal :show="show" @mask-click="handleClose">
<n-card class="w-200 max-w-1200px" :title="t('home.Data set config')"> <n-card class="w-200 max-w-1200px" :title="t('home.Data set config')">
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto"> <n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="submitLoading">
<n-grid :cols="24" :x-gap="24"> <n-grid :cols="24" :x-gap="24">
<n-form-item-gi :span="12" :label="t('home.Data set name')" path="name"> <n-form-item-gi :span="12" :label="t('home.Data set name')" path="name">
<n-input v-model:value="model.name" /> <n-input v-model:value="model.name" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="12" :label="t('home.Data set group')"> <n-form-item-gi :span="12" :label="t('home.Data set group')" path="groupId">
<n-cascader v-model:value="model.groupId" expand-trigger="hover" :options="groupOptions" <n-cascader
check-strategy="child" show-path filterable clearable label-field="name" value-field="id" /> v-model:value="model.groupId"
expand-trigger="hover"
:options="groupOptions"
check-strategy="all"
show-path
filterable
clearable
label-field="name"
value-field="id"
/>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="12" :label="t('home.Data set type')"> <n-form-item-gi :span="12" :label="t('home.Data set type')" path="type">
<n-select v-model:value="model.type" :options="setTypes" /> <n-select v-model:value="model.type" :options="setTypes" />
</n-form-item-gi> </n-form-item-gi>
<template v-if="model.type === 'API'"> <template v-if="model.type === 'API'">
<n-form-item-gi :span="12" :label="t('home.Method')"> <n-form-item-gi :span="12" :label="t('home.Method')" path="method">
<n-select v-model:value="model.type" :options="[ <n-select
:key="'dataset-method-select'"
v-model:value="model.method"
:options="[
{ label: 'GET', value: 'GET' }, { label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' }, { label: 'POST', value: 'POST' },
]" /> ]"
/>
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="24" :label="t('home.API interface')"> <n-form-item-gi :span="24" :label="t('home.API interface')" path="api">
<n-input v-model:value="model.api" type="textarea" /> <n-input v-model:value="model.api" type="textarea" />
</n-form-item-gi> </n-form-item-gi>
</template> </template>
<template v-else-if="model.type === 'SQL'"> <template v-else-if="model.type === 'SQL'">
<n-form-item-gi :span="12" :label="t('home.Data sources')"> <n-form-item-gi :span="12" :label="t('home.Data sources')" path="dataSourceId">
<n-select v-model:value="model.dataSource" :options="dataSourceOptions" /> <n-select :key="'dataset-datasource-select'" v-model:value="model.dataSourceId" :options="dataSourceOptions" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="24" :label="`SQL(${t('home.Query only')})`"> <n-form-item-gi :span="24" :label="`SQL(${t('home.Query only')})`" path="sql">
<SQLEditor v-model:value="model.sql as string" /> <SQLEditor v-model:value="model.sql as string" />
</n-form-item-gi> </n-form-item-gi>
<n-form-item-gi :span="24" label=" "> <n-form-item-gi :span="24" label=" ">
@@ -44,15 +57,15 @@
</template> </template>
<template v-else-if="model.type === 'JSON'"> <template v-else-if="model.type === 'JSON'">
<n-form-item-gi :span="24" label="JSON"> <n-form-item-gi :span="24" label="JSON" path="json">
<JSONEditor v-model:value="model.json as string" /> <JSONEditor v-model:value="model.json as string" />
</n-form-item-gi> </n-form-item-gi>
</template> </template>
<n-gi :span="24"> <n-gi :span="24">
<div class="flex justify-end mt-4"> <div class="flex justify-end mt-4">
<n-button @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button> <n-button :disabled="submitLoading" @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
<n-button type="primary" @click="saveDataSet">{{ t("other.Ok") }}</n-button> <n-button type="primary" :loading="submitLoading" @click="saveDataSet">{{ t("other.Ok") }}</n-button>
</div> </div>
</n-gi> </n-gi>
</n-grid> </n-grid>
@@ -62,60 +75,256 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { useTemplateRef } from "vue"; import { ref, watch, computed, useTemplateRef } from "vue";
import type { FormInst } from 'naive-ui' import type { FormInst } from "naive-ui";
import { t } from "@/language"; import { t } from "@/language";
import SQLEditor from "@/components/code/SQLEditor.vue"; import { DataSetPayload, fetchCreateDataSet, fetchUpdateDataSet } from "@/http/api/dataSet";
import JSONEditor from "@/components/code/JSONEditor.vue"; import { fetchDataSetGroupTree } from "@/http/api/dataSetGroup";
import { fetchDataSourceList } from "@/http/api/dataSource";
const props = withDefaults(defineProps<{ import SQLEditor from "@/components/code/SQLEditor.vue";
show: boolean, import JSONEditor from "@/components/code/JSONEditor.vue";
model: IDataSet.Item const props = withDefaults(
}>(), { defineProps<{
show: boolean;
model?: IDataSet.Item;
}>(),
{
show: false, show: false,
model: () => ({ model: () => ({
id: '', id: "",
groupId: "", groupId: "",
name: '', name: "",
type: 'SQL', type: "API",
}) method: "GET",
}) api: "",
const emits = defineEmits(["update:show", "refresh"]); dataSourceId: "",
sql: "",
json: "",
createTime: "",
}),
}
);
const emits = defineEmits(["update:show", "refresh"]);
const formRef = useTemplateRef<FormInst>("formRef"); const formRef = useTemplateRef<FormInst>("formRef");
const rules = { function createRequiredRule(message: string, trigger: Array<"input" | "blur" | "change">) {
name: { required: true, message: t("prompt.Please enter a name for the dataset"), trigger: 'blur' } return {
}; trigger,
const groupOptions = []; validator(_: unknown, value: unknown) {
const setTypes = [ if (value === undefined || value === null || value === "") {
{ label: 'API', value: 'API' }, return new Error(message);
{ label: 'SQL', value: 'SQL' }, }
{ label: 'JSON', value: 'JSON' }, if (Array.isArray(value) && value.length === 0) {
]; return new Error(message);
const dataSourceOptions = []; }
return true;
},
};
}
function handleClose() { const rules = computed(() => {
emits("update:show", false); const baseRules: Record<string, any> = {
} name: { required: true, message: t("prompt.Please enter a name for the dataset"), trigger: ["input", "blur"] },
groupId: createRequiredRule("请选择数据集分组", ["change", "blur"]),
type: { required: true, message: "请选择数据集类型", trigger: ["change", "blur"] },
};
const type = props.model?.type;
if (type === "API") {
baseRules.method = {
required: true,
message: "请选择请求方式",
trigger: ["change", "blur"],
};
baseRules.api = {
required: true,
message: "请输入接口地址",
trigger: ["input", "blur"],
};
} else if (type === "SQL") {
baseRules.dataSourceId = createRequiredRule("请选择数据源", ["change", "blur"]);
baseRules.sql = { required: true, message: "请输入 SQL 语句", trigger: ["blur"] };
} else if (type === "JSON") {
baseRules.json = { required: true, message: "请输入 JSON 数据", trigger: ["blur"] };
}
return baseRules;
});
const groupOptions = ref<IDataSet.IGroup[]>([]);
const setTypes = [
{ label: "API", value: "API" },
{ label: "SQL", value: "SQL" },
{ label: "JSON", value: "JSON" },
];
const dataSourceOptions = ref<{ label: string; value: IDataSource.Item["id"] }[]>([]);
const submitLoading = ref(false);
const isEdit = computed(() => Boolean(props.model?.id));
function saveDataSet(e: MouseEvent) { function normalizeDataSourceId(value: unknown): IDataSource.Item["id"] | undefined {
e.preventDefault() if (value === undefined || value === null || value === "") {
return undefined;
}
return String(value);
}
formRef.value?.validate((errors) => { function extractList<T>(value: unknown): T[] {
if (!errors) { if (Array.isArray(value)) {
if (!props.model.id) { return value as T[];
// 新增 }
if (value && typeof value === "object") {
const nested = (value as Record<string, unknown>).result;
if (Array.isArray(nested)) {
return nested as T[];
}
}
return [];
}
async function loadOptions() {
const [groupRes, dataSourceRes] = await Promise.allSettled([fetchDataSetGroupTree(), fetchDataSourceList()]);
if (groupRes.status === "fulfilled") {
groupOptions.value = extractList<IDataSet.IGroup>(groupRes.value.data);
} else { } else {
// 更新 groupOptions.value = [];
} }
// 刷新父页面表格 if (dataSourceRes.status === "fulfilled") {
const list = extractList<IDataSource.Item>(dataSourceRes.value.data);
dataSourceOptions.value = list.map(item => ({
label: item.name,
value: String(item.id),
}));
} else {
dataSourceOptions.value = [];
}
}
function normalizeJsonValue(value?: IDataSet.Item["json"]) {
if (!value) {
return "";
}
if (typeof value === "string") {
return value;
}
try {
return JSON.stringify(value, null, 2);
} catch {
return "";
}
}
function resetFieldsForType(type: string, prevType?: string) {
if (!props.model) {
return;
}
if (type !== "API") {
props.model.method = undefined;
props.model.api = "";
}
if (type === "API" && !props.model.method) {
props.model.method = "GET";
}
if (type !== "SQL") {
props.model.dataSourceId = undefined;
props.model.sql = "";
} else if (prevType && prevType !== "SQL") {
props.model.dataSourceId = undefined;
props.model.sql = "";
}
if (type !== "JSON") {
props.model.json = "";
}
}
function validateSql(sql: string) {
const trimmed = sql.trim();
if (!/^(SELECT|WITH)\b/i.test(trimmed)) {
window.$message?.error("SQL 必须以 SELECT 或 WITH 开头");
return false;
}
if (/[;]|--|\/\*/.test(trimmed)) {
window.$message?.error("SQL 不允许包含危险语句标记");
return false;
}
return true;
}
function buildPayload(): DataSetPayload {
const payload: DataSetPayload = {
name: props.model?.name?.trim() || "",
groupId: props.model?.groupId ?? "",
type: props.model?.type || "API",
};
if (props.model?.id) {
payload.id = props.model.id;
}
if (payload.type === "API") {
payload.method = props.model?.method;
payload.api = props.model?.api?.trim();
} else if (payload.type === "SQL") {
payload.dataSourceId = normalizeDataSourceId(props.model?.dataSourceId);
payload.sql = props.model?.sql?.trim();
} else if (payload.type === "JSON") {
payload.json = normalizeJsonValue(props.model?.json);
}
return payload;
}
watch(
() => props.show,
show => {
if (!show) {
return;
}
loadOptions();
if (props.model) {
props.model.dataSourceId = normalizeDataSourceId(props.model.dataSourceId);
}
if (props.model?.type === "API" && !props.model.method) {
props.model.method = "GET";
}
if (props.model?.type === "JSON") {
props.model.json = normalizeJsonValue(props.model.json);
}
}
);
watch(
() => props.model?.type,
(next, prev) => {
if (!next || next === prev) {
return;
}
resetFieldsForType(next, prev);
formRef.value?.restoreValidation();
}
);
function handleClose() {
emits("update:show", false);
}
function saveDataSet(e: MouseEvent) {
e.preventDefault();
formRef.value?.validate(async errors => {
if (errors || !props.model) {
return;
}
if (props.model.type === "SQL" && !validateSql(props.model.sql || "")) {
return;
}
submitLoading.value = true;
const payload = buildPayload();
const res = isEdit.value ? await fetchUpdateDataSet(payload) : await fetchCreateDataSet(payload);
submitLoading.value = false;
if (res.error) {
return;
}
window.$message?.success(isEdit.value ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
emits("refresh"); emits("refresh");
handleClose(); handleClose();
});
} }
})
}
</script> </script>
@@ -7,9 +7,9 @@
</template> </template>
</n-input> </n-input>
<n-button type="primary" @click="showDataSourceModal = true">{{ t('home.Add data source') }}</n-button> <n-button type="primary" @click="handleAddDataSource">{{ t('home.Add data source') }}</n-button>
</div> </div>
<n-data-table ref="tableRef" :columns="dataSourceColumns" :data="dataSources" :pagination="pagination" striped /> <n-data-table ref="tableRef" :columns="dataSourceColumns" :data="dataSources" :pagination="pagination" :loading="tableLoading" striped />
<!-- 数据源模态框 --> <!-- 数据源模态框 -->
<DataSourceModal v-model:show="showDataSourceModal" :model="currentDataSource" @refresh="getList" /> <DataSourceModal v-model:show="showDataSourceModal" :model="currentDataSource" @refresh="getList" />
@@ -21,18 +21,23 @@ import { NButton, NPopconfirm } from 'naive-ui';
import type { DataTableInst } from 'naive-ui'; import type { DataTableInst } from 'naive-ui';
import { Search } from '@vicons/carbon'; import { Search } from '@vicons/carbon';
import { t } from "@/language"; import { t } from "@/language";
import { fetchDataSourceList, fetchDeleteDataSource } from "@/http/api/dataSource";
import DataSourceModal from "./DataSourceModal.vue"; import DataSourceModal from "./DataSourceModal.vue";
const tableRef = useTemplateRef<DataTableInst>("tableRef"); const tableRef = useTemplateRef<DataTableInst>("tableRef");
const dataSources = ref<IDataSource.Item[]>([]) const dataSources = ref<IDataSource.Item[]>([])
const showDataSourceModal = ref(false) const showDataSourceModal = ref(false)
const currentDataSource = reactive({ const tableLoading = ref(false);
const defaultDataSource = {
id: '', id: '',
name: '', name: '',
type: 'MySQL', type: 'MySQL',
connectionString: '', connectionString: '',
username: "", username: "",
password: "" password: ""
}
const currentDataSource = reactive({
...defaultDataSource
}) })
const dataSourceColumns = [ const dataSourceColumns = [
{ {
@@ -106,7 +111,21 @@ const dataSourceColumns = [
// 分页配置 // 分页配置
const pagination = { pageSize: 10 } const pagination = { pageSize: 10 }
function getList() { } async function getList() {
tableLoading.value = true;
const res = await fetchDataSourceList();
tableLoading.value = false;
dataSources.value = res.data || [];
}
function resetCurrentDataSource(data?: Partial<IDataSource.Item>) {
Object.assign(currentDataSource, defaultDataSource, data || {});
}
function handleAddDataSource() {
resetCurrentDataSource();
showDataSourceModal.value = true;
}
function handleSearch(searchText: string) { function handleSearch(searchText: string) {
tableRef.value?.filter({ tableRef.value?.filter({
@@ -115,12 +134,17 @@ function handleSearch(searchText: string) {
} }
function editDataSource(item) { function editDataSource(item) {
Object.assign(currentDataSource, item) resetCurrentDataSource(item)
showDataSourceModal.value = true showDataSourceModal.value = true
} }
function deleteDataSource(item) { async function deleteDataSource(item) {
dataSources.value = dataSources.value.filter(ds => ds.id !== item.id) const res = await fetchDeleteDataSource(item.id);
if (res.error) {
return;
}
window.$message?.success(t("prompt.Success to delete"));
getList();
} }
onMounted(() => { onMounted(() => {
@@ -1,7 +1,7 @@
<template> <template>
<n-modal :show="show" @mask-click="handleClose"> <n-modal :show="show" @mask-click="handleClose">
<n-card class="w-100 max-w-600px" :title="t('home.Data source config')"> <n-card class="w-100 max-w-600px" :title="t('home.Data source config')">
<n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto"> <n-form :model="model" :rules="rules" ref="formRef" label-placement="left" label-width="auto" :disabled="testLinkLoading || submitLoading">
<n-form-item :label="t('home.Data source name')" path="name"> <n-form-item :label="t('home.Data source name')" path="name">
<n-input v-model:value="model.name" :disabled="testLinkLoading" /> <n-input v-model:value="model.name" :disabled="testLinkLoading" />
</n-form-item> </n-form-item>
@@ -22,8 +22,8 @@
<n-button type="primary" :loading="testLinkLoading" @click="handleTestLink">{{ t("home.Test the connection") }}</n-button> <n-button type="primary" :loading="testLinkLoading" @click="handleTestLink">{{ t("home.Test the connection") }}</n-button>
<div> <div>
<n-button @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button> <n-button :disabled="testLinkLoading || submitLoading" @click="handleClose" class="mr-2">{{ t("other.Cancel") }}</n-button>
<n-button type="primary" @click="saveDataSource">{{ t("other.Ok") }}</n-button> <n-button type="primary" :loading="submitLoading" @click="saveDataSource">{{ t("other.Ok") }}</n-button>
</div> </div>
</div> </div>
@@ -33,9 +33,10 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, useTemplateRef } from "vue"; import { ref, computed, useTemplateRef } from "vue";
import type { FormInst } from 'naive-ui' import type { FormInst } from 'naive-ui'
import { t } from "@/language"; import { t } from "@/language";
import { DataSourcePayload, fetchCreateDataSource, fetchTestDataSource, fetchUpdateDataSource } from "@/http/api/dataSource";
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
show: boolean, show: boolean,
@@ -65,6 +66,8 @@ const dbTypes = [
{ label: 'Oracle', value: 'Oracle' } { label: 'Oracle', value: 'Oracle' }
] ]
const testLinkLoading = ref(false); const testLinkLoading = ref(false);
const submitLoading = ref(false);
const isEdit = computed(() => Boolean(props.model.id));
function handleClose() { function handleClose() {
emits("update:show", false); emits("update:show", false);
@@ -72,25 +75,52 @@ function handleClose() {
function handleTestLink(e: MouseEvent) { function handleTestLink(e: MouseEvent) {
e.preventDefault() e.preventDefault()
formRef.value?.validate((errors) => { formRef.value?.validate(async (errors) => {
if (!errors) { if (!errors) {
testLinkLoading.value = true; testLinkLoading.value = true;
const res = await fetchTestDataSource(buildPayload(false));
testLinkLoading.value = false;
if (res.error) {
return;
}
window.$message?.success(t("prompt.Operation successful"));
} }
}) })
} }
function buildPayload(includeId: boolean): DataSourcePayload {
const payload: DataSourcePayload = {
name: props.model.name?.trim() || "",
type: props.model.type,
connectionString: props.model.connectionString?.trim() || ""
};
if (props.model.username) {
payload.username = props.model.username;
}
if (props.model.password) {
payload.password = props.model.password;
}
if (includeId && props.model.id) {
payload.id = props.model.id;
}
return payload;
}
function saveDataSource(e: MouseEvent) { function saveDataSource(e: MouseEvent) {
e.preventDefault() e.preventDefault()
formRef.value?.validate((errors) => { formRef.value?.validate(async (errors) => {
if (!errors) { if (!errors) {
if (!props.model.id) { submitLoading.value = true;
// 新增 const payload = buildPayload(true);
const res = isEdit.value ? await fetchUpdateDataSource(payload) : await fetchCreateDataSource(payload);
} else { submitLoading.value = false;
// 更新 if (res.error) {
return;
} }
window.$message?.success(isEdit.value ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
// 刷新父页面表格 // 刷新父页面表格
emits("refresh"); emits("refresh");
@@ -19,26 +19,12 @@ const options = [
key: 'details', key: 'details',
icon: renderIcon(Information) icon: renderIcon(Information)
}, },
{
label: () => cpt("home.Rename").value,
key: 'rename',
icon: renderIcon(Edit)
},
{ {
label: () => cpt("home.Delete").value, label: () => cpt("home.Delete").value,
key: 'delete', key: 'delete',
icon: renderIcon(Delete) icon: renderIcon(Delete)
}, },
{
label: () => cpt("home.Release").value,
key: 'release',
icon: renderIcon(SendAlt)
},
{
label: () => cpt("layout.header.Export").value,
key: 'export',
icon: renderIcon(Export)
}
] ]
const detail = ref(); const detail = ref();
const detailVisible = ref(false); const detailVisible = ref(false);
+2 -3
View File
@@ -3,7 +3,7 @@ import {nextTick, onMounted, provide, ref} from "vue";
import {useRoute} from 'vue-router'; import {useRoute} from 'vue-router';
import {t} from "@/language"; import {t} from "@/language";
import {fetchGetOneScene} from "@/http/api/scenes"; import {fetchGetOneScene} from "@/http/api/scenes";
import {App,Viewer,Hooks,Package,defaultProjectInfo} from "@astral3d/engine"; import {App,Viewer,Hooks,defaultProjectInfo} from "@astral3d/engine";
import {usePreviewOperationStore} from "@/store/modules/previewOperation"; import {usePreviewOperationStore} from "@/store/modules/previewOperation";
import EsCubeLoading from "@/components/es/EsCubeLoading.vue"; import EsCubeLoading from "@/components/es/EsCubeLoading.vue";
import PreviewSceneTree from "@/views/preview/components/PreviewSceneTree.vue"; import PreviewSceneTree from "@/views/preview/components/PreviewSceneTree.vue";
@@ -97,8 +97,7 @@ function getScene(sceneInfo) {
closable: false, closable: false,
}) })
const p = new Package(window.viewer); window.viewer.package.unpack({
p.unpack({
url: sceneInfo.zip, url: sceneInfo.zip,
onSceneLoad: (sceneJson: ISceneJson) => { onSceneLoad: (sceneJson: ISceneJson) => {
if (sceneJson.controls?.state) { if (sceneJson.controls?.state) {
+10 -6
View File
@@ -1,6 +1,7 @@
declare namespace IDataSource { declare namespace IDataSource {
type Id = string | number;
interface Item { interface Item {
id: string; id: Id;
name: string; name: string;
type: string; type: string;
connectionString: string; connectionString: string;
@@ -10,21 +11,24 @@ declare namespace IDataSource {
} }
declare namespace IDataSet { declare namespace IDataSet {
type Id = string | number;
interface Item { interface Item {
id: string; id: Id;
groupId: string; groupId: Id;
name: string; name: string;
type: string; type: string;
method?: "GET" | "POST"; method?: "GET" | "POST";
api?: string; api?: string;
dataSource?: string; dataSourceId?: Id;
dataSource?: Id;
sql?: string; sql?: string;
json?: string; json?: string;
createTime?: string;
} }
interface IGroup { interface IGroup {
id: string; id: Id;
pid?: string; pid?: Id;
name: string; name: string;
children?: IGroup[]; children?: IGroup[];
} }
+2 -1
View File
@@ -158,7 +158,8 @@ export default defineConfig(async ({mode, command}) => {
// }), // }),
viteStaticCopy({ viteStaticCopy({
targets: [ targets: [
{ src: 'node_modules/@astral3d/engine/dist/libs/*', dest: 'assets/libs' } { src: 'node_modules/@astral3d/engine/dist/libs/*', dest: 'assets/libs' },
{ src: 'node_modules/@astral3d/engine/dist/resource/**/*', dest: 'assets/resource' }
], ],
}), }),
...plugins ...plugins
+149 -4
View File
@@ -41,6 +41,46 @@ export const defaultProjectInfo = (): IAppProject.Info => ({
// 后处理 // 后处理
effect: { effect: {
enabled: false, enabled: false,
// 色调映射 (ToneMappingEffect)
ToneMapping: {
// 色调映射模式:LINEAR, REINHARD, REINHARD2, OPTIMIZED_CINEON, ACES_FILMIC, AGX, NEUTRAL
mode: "ACES_FILMIC",
// 曝光度
exposure: 1.0,
// 混合模式
blendFunction: "NORMAL"
},
// 抗锯齿 (SMAAEffect)
SMAA: {
enabled: true,
// 质量预设: LOW, MEDIUM, HIGH, ULTRA
preset: "ULTRA"
},
// 环境光遮蔽 (SSAOEffect)
SSAO: {
enabled: true,
blendFunction: "MULTIPLY",
samples: 9,
rings: 7,
radius: 0.1825,
intensity: 1.0,
bias: 0.025,
fade: 0.01,
luminanceInfluence: 0.7,
minRadiusScale: 0.1,
depthAwareUpsampling: true,
resolutionScale: 0.5,
distanceThreshold: 0.97,
distanceFalloff: 0.03,
rangeThreshold: 0.0005,
rangeFalloff: 0.001,
worldDistanceThreshold: null,
worldDistanceFalloff: null,
worldProximityThreshold: null,
worldProximityFalloff: null,
colorEnabled: false,
color: "#000000",
},
// 描边线 // 描边线
Outline: { Outline: {
enabled: true, enabled: true,
@@ -57,7 +97,14 @@ export const defaultProjectInfo = (): IAppProject.Info => ({
// 可见边缘的颜色 // 可见边缘的颜色
visibleEdgeColor: "#ffee00", visibleEdgeColor: "#ffee00",
// 不可见边缘的颜色 // 不可见边缘的颜色
hiddenEdgeColor: "#ff6a00" hiddenEdgeColor: "#ff6a00",
// Astral postprocessing 兼容字段
pulseSpeed: 0.0,
xRay: true,
blur: true,
kernelSize: 1,
multisampling: 4,
blendFunction: "SCREEN"
}, },
// 抗锯齿 // 抗锯齿
FXAA: { FXAA: {
@@ -71,7 +118,14 @@ export const defaultProjectInfo = (): IAppProject.Info => ({
// 光晕强度 // 光晕强度
strength: 1, strength: 1,
// 光晕半径 // 光晕半径
radius: 0 radius: 0,
// Astral postprocessing 兼容字段
intensity: 1.0,
luminanceThreshold: 0.9,
luminanceSmoothing: 0.025,
levels: 8,
mipmapBlur: true,
blendFunction: "SCREEN"
}, },
// 背景虚化 // 背景虚化
Bokeh: { Bokeh: {
@@ -81,7 +135,12 @@ export const defaultProjectInfo = (): IAppProject.Info => ({
// 孔径,类似相机孔径调节 // 孔径,类似相机孔径调节
aperture: 0.00005, aperture: 0.00005,
// 最大模糊程度 // 最大模糊程度
maxblur: 0.01 maxblur: 0.01,
// Astral postprocessing 兼容字段
focusDistance: 10.0,
focusRange: 5.0,
bokehScale: 2.0,
resolutionScale: 0.5
}, },
// 像素风 // 像素风
Pixelate: { Pixelate: {
@@ -92,6 +151,87 @@ export const defaultProjectInfo = (): IAppProject.Info => ({
normalEdgeStrength: 0.3, normalEdgeStrength: 0.3,
// 深度边缘强度 // 深度边缘强度
depthEdgeStrength: 0.4, depthEdgeStrength: 0.4,
// Astral postprocessing 兼容字段
granularity: 6,
},
// 移轴模糊 (TiltShiftEffect)
TiltShift: {
enabled: false,
offset: 0.0,
rotation: 0.0,
focusArea: 0.4,
feather: 0.3,
blendFunction: "NORMAL"
},
// 扫描线 (ScanlineEffect)
Scanline: {
enabled: false,
density: 1.25,
scrollSpeed: 0.0,
blendFunction: "OVERLAY"
},
// 亮度对比度 (BrightnessContrastEffect)
BrightnessContrast: {
enabled: false,
brightness: 0.0,
contrast: 0.0,
blendFunction: "SRC"
},
// 色差 (ChromaticAberrationEffect)
ChromaticAberration: {
enabled: false,
offset: { x: 0.002, y: 0.002 },
radialModulation: false,
modulationOffset: 0.15,
blendFunction: "NORMAL"
},
// 色深 (ColorDepthEffect)
ColorDepth: {
enabled: false,
bits: 16,
blendFunction: "NORMAL"
},
// 故障 (GlitchEffect)
Glitch: {
enabled: false,
chromaticAberrationOffset: null,
delay: { min: 1.5, max: 3.5 },
duration: { min: 0.6, max: 1.0 },
strength: { min: 0.3, max: 1.0 },
mode: "SPORADIC",
ratio: 0.85,
blendFunction: "NORMAL"
},
// 色相饱和度 (HueSaturationEffect)
HueSaturation: {
enabled: false,
hue: 0.0,
saturation: 0.0,
blendFunction: "SRC"
},
// 镜头畸变 (LensDistortionEffect)
LensDistortion: {
enabled: false,
distortion: { x: 0.0, y: 0.0 },
principalPoint: { x: 0.0, y: 0.0 },
focalLength: { x: 1.0, y: 1.0 },
skew: 0.0
},
// 冲击波 (ShockWaveEffect)
ShockWave: {
enabled: false,
amplitude: 0.05,
waveSize: 0.2,
speed: 2.0,
maxRadius: 1.0,
clickTrigger: true
},
// 暗角 (VignetteEffect)
Vignette: {
enabled: false,
offset: 0.5,
darkness: 0.5,
blendFunction: "NORMAL"
}, },
// 半色调 // 半色调
Halftone: { Halftone: {
@@ -223,7 +363,12 @@ class Project {
* @param {string} key 可以多层级,需用.分割,如a.b.c * @param {string} key 可以多层级,需用.分割,如a.b.c
*/ */
getKey(key: string): any { getKey(key: string): any {
return getNestedProperty(this.info, key); const value = getNestedProperty(this.info, key);
if (value !== undefined) {
return value;
}
return getNestedProperty(defaultProjectInfo(), key);
} }
/** /**
File diff suppressed because it is too large Load Diff
+2
View File
@@ -65,6 +65,8 @@ export default class Path extends THREE.Mesh {
if (Path.flowSignalBound) return; if (Path.flowSignalBound) return;
Path.flowSignalBound = true; Path.flowSignalBound = true;
useAddSignal("sceneRendered", Path.handleFlowTick); useAddSignal("sceneRendered", Path.handleFlowTick);
// 立即请求一帧渲染,防止场景静止时 sceneRendered 永远不触发导致流动停止
(App.viewer as any)?.pluginRequestRender?.(true);
} }
private static unbindFlowSignal() { private static unbindFlowSignal() {
@@ -0,0 +1,645 @@
import * as THREE from "three";
import { Easing, Tween } from "three/examples/jsm/libs/tween.module.js";
import App from "@/core/app/App";
export interface AstralMaterialFormat {
color?: string;
opacity?: number;
transparent?: boolean;
metalness?: number;
roughness?: number;
emissive?: string;
emissiveIntensity?: number;
}
export type AstralAnimationBehavior = "play" | "pause" | "stop";
export interface AstralAnimationCommand {
name: string;
behavior: AstralAnimationBehavior;
duration?: number;
}
export interface AstralDataFormat {
position?: [number, number, number];
rotation?: [number, number, number];
scale?: [number, number, number];
material?: AstralMaterialFormat;
visible?: boolean;
animations?: AstralAnimationCommand[];
}
export interface DataBindingResult {
changed: boolean;
errors: string[];
}
export interface DataBindingOptions {
transition?: number;
onUpdate?: (object: THREE.Object3D) => void;
}
export interface DataComponentConfig {
dataSetId?: unknown;
filterEnabled?: boolean;
filterBody?: string;
autoRefreshEnabled?: boolean;
autoRefreshInterval?: number;
applyToModel?: boolean;
transition?: number;
}
export interface DataComponentEntry {
config?: DataComponentConfig;
data?: unknown;
}
export interface SetConfigOptions {
index?: number;
}
export interface SetDataOptions {
index?: number;
applyToModel?: boolean;
transition?: number;
onUpdate?: (object: THREE.Object3D) => void;
}
type Vector3Tuple = [number, number, number];
const animationBehaviors = new Set<AstralAnimationBehavior>(["play", "pause", "stop"]);
const activeTweens = new WeakMap<THREE.Object3D, Map<string, Set<Tween<any>>>>();
const pendingUpdates = new WeakMap<THREE.Object3D, boolean>();
function isPlainObject(value: unknown): value is Record<string, any> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseVector3(value: unknown, field: string, errors: string[]): Vector3Tuple | null {
if (!Array.isArray(value) || value.length !== 3) {
errors.push(`${field} must be an array of 3 numbers`);
return null;
}
const parsed = value.map(item => Number(item)) as number[];
if (parsed.some(item => !Number.isFinite(item))) {
errors.push(`${field} must contain finite numbers`);
return null;
}
return [parsed[0], parsed[1], parsed[2]];
}
function clamp(value: number, min: number, max: number) {
if (!Number.isFinite(value)) return null;
return Math.min(max, Math.max(min, value));
}
function normalizeTransition(value: number | undefined) {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) return 0;
return numeric;
}
function normalizeIndex(value: number | undefined) {
const index = Number(value);
if (!Number.isFinite(index) || index < 0) return 0;
return Math.floor(index);
}
function ensureDataComponentEntry(object: THREE.Object3D, index: number): DataComponentEntry {
const list = Array.isArray((object as any).dataComponent) ? ((object as any).dataComponent as DataComponentEntry[]) : [];
if (!Array.isArray((object as any).dataComponent)) {
(object as any).dataComponent = list;
}
let entry = list[index];
if (!entry || typeof entry !== "object") {
entry = { config: {}, data: undefined };
list[index] = entry;
} else if (!entry.config || typeof entry.config !== "object") {
entry.config = {};
}
return entry;
}
function scheduleObjectUpdate(object: THREE.Object3D, onUpdate?: (object: THREE.Object3D) => void) {
if (!onUpdate) return;
if (pendingUpdates.get(object)) return;
pendingUpdates.set(object, true);
if (typeof requestAnimationFrame === "function") {
requestAnimationFrame(() => {
pendingUpdates.delete(object);
onUpdate(object);
});
} else {
pendingUpdates.delete(object);
onUpdate(object);
}
}
function getTweenBucket(object: THREE.Object3D, key: string) {
let map = activeTweens.get(object);
if (!map) {
map = new Map();
activeTweens.set(object, map);
}
let bucket = map.get(key);
if (!bucket) {
bucket = new Set();
map.set(key, bucket);
}
return bucket;
}
function trackTween(object: THREE.Object3D, key: string, tween: Tween<any>) {
const bucket = getTweenBucket(object, key);
bucket.add(tween);
const cleanup = () => {
const map = activeTweens.get(object);
const currentBucket = map?.get(key);
if (!currentBucket) return;
currentBucket.delete(tween);
if (currentBucket.size === 0) {
map?.delete(key);
}
if (map && map.size === 0) {
activeTweens.delete(object);
}
};
tween.onStop(cleanup);
tween.onComplete(cleanup);
}
function stopTweensForKey(object: THREE.Object3D, key: string) {
const map = activeTweens.get(object);
const bucket = map?.get(key);
if (!bucket) return;
bucket.forEach(tween => tween.stop());
map?.delete(key);
if (map && map.size === 0) {
activeTweens.delete(object);
}
}
function tweenVector3(
object: THREE.Object3D,
key: string,
target: THREE.Vector3,
value: Vector3Tuple,
duration: number,
onUpdate?: (object: THREE.Object3D) => void
) {
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
return false;
}
const state = { x: target.x, y: target.y, z: target.z };
const tween = new Tween(state)
.to({ x: value[0], y: value[1], z: value[2] }, duration)
.easing(Easing.Linear.None)
.onUpdate(() => {
target.set(state.x, state.y, state.z);
scheduleObjectUpdate(object, onUpdate);
});
trackTween(object, key, tween);
tween.start();
return true;
}
function tweenEuler(
object: THREE.Object3D,
key: string,
target: THREE.Euler,
value: Vector3Tuple,
duration: number,
onUpdate?: (object: THREE.Object3D) => void
) {
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
return false;
}
const state = { x: target.x, y: target.y, z: target.z };
const tween = new Tween(state)
.to({ x: value[0], y: value[1], z: value[2] }, duration)
.easing(Easing.Linear.None)
.onUpdate(() => {
target.set(state.x, state.y, state.z);
scheduleObjectUpdate(object, onUpdate);
});
trackTween(object, key, tween);
tween.start();
return true;
}
function tweenNumber(
object: THREE.Object3D,
key: string,
target: Record<string, any>,
prop: string,
value: number,
duration: number,
onUpdate?: (object: THREE.Object3D) => void
) {
if (Number(target[prop]) === value) {
return false;
}
const state = { value: Number(target[prop]) };
const tween = new Tween(state)
.to({ value }, duration)
.easing(Easing.Linear.None)
.onUpdate(() => {
target[prop] = state.value;
scheduleObjectUpdate(object, onUpdate);
});
trackTween(object, key, tween);
tween.start();
return true;
}
function tweenColor(
object: THREE.Object3D,
key: string,
target: THREE.Color,
value: THREE.Color,
duration: number,
onUpdate?: (object: THREE.Object3D) => void
) {
if (target.equals(value)) {
return false;
}
const state = { r: target.r, g: target.g, b: target.b };
const tween = new Tween(state)
.to({ r: value.r, g: value.g, b: value.b }, duration)
.easing(Easing.Linear.None)
.onUpdate(() => {
target.setRGB(state.r, state.g, state.b);
scheduleObjectUpdate(object, onUpdate);
});
trackTween(object, key, tween);
tween.start();
return true;
}
function updateVector3(target: THREE.Vector3, value: Vector3Tuple) {
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
return false;
}
target.set(value[0], value[1], value[2]);
return true;
}
function updateEuler(target: THREE.Euler, value: Vector3Tuple) {
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
return false;
}
target.set(value[0], value[1], value[2]);
return true;
}
function updateMaterial(
object: THREE.Object3D,
material: THREE.Material,
data: AstralMaterialFormat,
errors: string[],
transition: number,
onUpdate?: (object: THREE.Object3D) => void
) {
let changed = false;
const duration = normalizeTransition(transition);
const mat = material as any;
const materialKey = material.uuid || "material";
if (data.color !== undefined && mat.color && typeof mat.color.set === "function") {
let nextColor: THREE.Color | null = null;
try {
nextColor = new THREE.Color();
nextColor.set(data.color);
} catch (error) {
errors.push(`material.color invalid: ${String((error as Error).message || error)}`);
}
if (nextColor) {
const key = `material:${materialKey}:color`;
stopTweensForKey(object, key);
if (duration > 0) {
changed = tweenColor(object, key, mat.color, nextColor, duration, onUpdate) || changed;
} else if (typeof mat.color.equals !== "function" || !mat.color.equals(nextColor)) {
mat.color.copy(nextColor);
changed = true;
}
}
}
if (data.emissive !== undefined && mat.emissive && typeof mat.emissive.set === "function") {
let nextEmissive: THREE.Color | null = null;
try {
nextEmissive = new THREE.Color();
nextEmissive.set(data.emissive);
} catch (error) {
errors.push(`material.emissive invalid: ${String((error as Error).message || error)}`);
}
if (nextEmissive) {
const key = `material:${materialKey}:emissive`;
stopTweensForKey(object, key);
if (duration > 0) {
changed = tweenColor(object, key, mat.emissive, nextEmissive, duration, onUpdate) || changed;
} else if (typeof mat.emissive.equals !== "function" || !mat.emissive.equals(nextEmissive)) {
mat.emissive.copy(nextEmissive);
changed = true;
}
}
}
if (data.opacity !== undefined && "opacity" in mat) {
const next = clamp(Number(data.opacity), 0, 1);
if (next === null) {
errors.push("material.opacity must be a number between 0 and 1");
} else {
const key = `material:${materialKey}:opacity`;
stopTweensForKey(object, key);
if (duration > 0) {
changed = tweenNumber(object, key, mat, "opacity", next, duration, onUpdate) || changed;
} else if (mat.opacity !== next) {
mat.opacity = next;
changed = true;
}
}
}
if (data.transparent !== undefined && "transparent" in mat) {
const next = Boolean(data.transparent);
const key = `material:${materialKey}:transparent`;
stopTweensForKey(object, key);
if (mat.transparent !== next) {
mat.transparent = next;
changed = true;
scheduleObjectUpdate(object, onUpdate);
}
}
if (data.metalness !== undefined && "metalness" in mat) {
const next = clamp(Number(data.metalness), 0, 1);
if (next === null) {
errors.push("material.metalness must be a number between 0 and 1");
} else {
const key = `material:${materialKey}:metalness`;
stopTweensForKey(object, key);
if (duration > 0) {
changed = tweenNumber(object, key, mat, "metalness", next, duration, onUpdate) || changed;
} else if (mat.metalness !== next) {
mat.metalness = next;
changed = true;
}
}
}
if (data.roughness !== undefined && "roughness" in mat) {
const next = clamp(Number(data.roughness), 0, 1);
if (next === null) {
errors.push("material.roughness must be a number between 0 and 1");
} else {
const key = `material:${materialKey}:roughness`;
stopTweensForKey(object, key);
if (duration > 0) {
changed = tweenNumber(object, key, mat, "roughness", next, duration, onUpdate) || changed;
} else if (mat.roughness !== next) {
mat.roughness = next;
changed = true;
}
}
}
if (data.emissiveIntensity !== undefined && "emissiveIntensity" in mat) {
const next = clamp(Number(data.emissiveIntensity), 0, 1);
if (next === null) {
errors.push("material.emissiveIntensity must be a number between 0 and 1");
} else {
const key = `material:${materialKey}:emissiveIntensity`;
stopTweensForKey(object, key);
if (duration > 0) {
changed = tweenNumber(object, key, mat, "emissiveIntensity", next, duration, onUpdate) || changed;
} else if (mat.emissiveIntensity !== next) {
mat.emissiveIntensity = next;
changed = true;
}
}
}
if (changed && "needsUpdate" in mat) {
mat.needsUpdate = true;
}
if (duration === 0 && changed) {
scheduleObjectUpdate(object, onUpdate);
}
return changed;
}
function getAnimationActionByName(object: THREE.Object3D, name: string) {
const animations = (object as any)?.animations;
if (!Array.isArray(animations)) return null;
for (const animation of animations) {
if (!animation || typeof (animation as any).getClip !== "function") continue;
const clip = (animation as THREE.AnimationAction).getClip?.();
if (!clip || clip.name !== name) continue;
if (typeof (animation as any).play === "function") {
return animation as THREE.AnimationAction;
}
}
return null;
}
function applyAnimations(object: THREE.Object3D, commands: AstralAnimationCommand[], errors: string[]) {
let changed = false;
commands.forEach((command, index) => {
if (!isPlainObject(command)) {
errors.push(`animations[${index}] must be an object`);
return;
}
const name = typeof command.name === "string" ? command.name.trim() : "";
if (!name) {
errors.push(`animations[${index}].name is required`);
return;
}
const behaviorValue = typeof command.behavior === "string" ? command.behavior.trim().toLowerCase() : "";
if (!animationBehaviors.has(behaviorValue as AstralAnimationBehavior)) {
errors.push(`animations[${index}].behavior must be play | pause | stop`);
return;
}
const action = getAnimationActionByName(object, name);
if (!action) return;
if (command.duration !== undefined) {
const duration = Number(command.duration);
if (!Number.isFinite(duration) || duration <= 0) {
errors.push(`animations[${index}].duration must be a positive number`);
return;
}
if (typeof action.setDuration === "function") {
action.setDuration(duration);
}
}
switch (behaviorValue) {
case "play":
action.play();
action.paused = false;
break;
case "pause":
action.paused = true;
break;
case "stop":
action.stop();
action.paused = false;
break;
}
changed = true;
});
return changed;
}
export class DataBindingManager {
static setConfig(object: THREE.Object3D, config: DataComponentConfig, options: SetConfigOptions = {}) {
const index = normalizeIndex(options.index);
const entry = ensureDataComponentEntry(object, index);
entry.config = config && typeof config === "object" ? { ...config } : {};
return entry;
}
static setData(object: THREE.Object3D, data: unknown, options: SetDataOptions = {}): DataBindingResult {
const index = normalizeIndex(options.index);
const entry = ensureDataComponentEntry(object, index);
entry.data = data;
const config = entry.config || {};
if (App.viewer) {
App.viewer.dispatchEvent({
type: "bindDataChange",
object,
data,
config,
index,
});
}
const shouldApply = options.applyToModel ?? Boolean(config.applyToModel);
if (!shouldApply) {
return { changed: false, errors: [] };
}
const transition =
options.transition !== undefined ? options.transition : Number.isFinite(Number(config.transition)) ? Number(config.transition) : undefined;
return DataBindingManager.applyDataToObject(object, data, {
transition,
onUpdate: options.onUpdate,
});
}
static applyDataToObject(object: THREE.Object3D, data: unknown, options: DataBindingOptions = {}): DataBindingResult {
const errors: string[] = [];
let changed = false;
if (!isPlainObject(data)) {
return { changed: false, errors: ["data must be an object"] };
}
const payload = data as AstralDataFormat;
const transition = normalizeTransition(options.transition);
const onUpdate = options.onUpdate;
if (payload.position !== undefined) {
const next = parseVector3(payload.position, "position", errors);
if (next) {
const key = "position";
stopTweensForKey(object, key);
if (transition > 0) {
changed = tweenVector3(object, key, object.position, next, transition, onUpdate) || changed;
} else {
changed = updateVector3(object.position, next) || changed;
if (changed) {
scheduleObjectUpdate(object, onUpdate);
}
}
}
}
if (payload.rotation !== undefined) {
const next = parseVector3(payload.rotation, "rotation", errors);
if (next) {
const key = "rotation";
stopTweensForKey(object, key);
if (transition > 0) {
changed = tweenEuler(object, key, object.rotation, next, transition, onUpdate) || changed;
} else {
changed = updateEuler(object.rotation, next) || changed;
if (changed) {
scheduleObjectUpdate(object, onUpdate);
}
}
}
}
if (payload.scale !== undefined) {
const next = parseVector3(payload.scale, "scale", errors);
if (next) {
const key = "scale";
stopTweensForKey(object, key);
if (transition > 0) {
changed = tweenVector3(object, key, object.scale, next, transition, onUpdate) || changed;
} else {
changed = updateVector3(object.scale, next) || changed;
if (changed) {
scheduleObjectUpdate(object, onUpdate);
}
}
}
}
if (payload.visible !== undefined) {
const next = Boolean(payload.visible);
const key = "visible";
stopTweensForKey(object, key);
if (object.visible !== next) {
object.visible = next;
changed = true;
scheduleObjectUpdate(object, onUpdate);
}
}
if (payload.material && isPlainObject(payload.material)) {
const targetMaterial = (object as any).material;
if (Array.isArray(targetMaterial)) {
targetMaterial.forEach((mat: THREE.Material) => {
changed =
updateMaterial(object, mat, payload.material as AstralMaterialFormat, errors, transition, onUpdate) || changed;
});
} else if (targetMaterial) {
changed =
updateMaterial(
object,
targetMaterial as THREE.Material,
payload.material as AstralMaterialFormat,
errors,
transition,
onUpdate
) || changed;
}
}
if (payload.animations !== undefined) {
if (!Array.isArray(payload.animations)) {
errors.push("animations must be an array");
} else {
changed = applyAnimations(object, payload.animations, errors) || changed;
}
}
return { changed, errors };
}
}
+1
View File
@@ -5,3 +5,4 @@ export {ClippedEdgesBox} from "./ClippedEdgesBox";
export {Measure,MeasureMode} from "./Measure"; export {Measure,MeasureMode} from "./Measure";
export {Export} from "./Export"; export {Export} from "./Export";
export {ModelExplode} from "./ModelExplode"; export {ModelExplode} from "./ModelExplode";
export {DataBindingManager} from "./DataBindingManager";
+163 -69
View File
@@ -4,6 +4,7 @@ import {Timer} from 'three/examples/jsm/misc/Timer.js';
import {CSS2DRenderer} from "three/examples/jsm/renderers/CSS2DRenderer"; import {CSS2DRenderer} from "three/examples/jsm/renderers/CSS2DRenderer";
import {CSS3DRenderer} from "three/examples/jsm/renderers/CSS3DRenderer.js"; import {CSS3DRenderer} from "three/examples/jsm/renderers/CSS3DRenderer.js";
import {TransformControls} from "three/examples/jsm/controls/TransformControls.js"; import {TransformControls} from "three/examples/jsm/controls/TransformControls.js";
import TWEEN from "three/examples/jsm/libs/tween.module.js";
import App from "../app/App"; import App from "../app/App";
import {ViewerOptions} from "./ViewerOptions"; import {ViewerOptions} from "./ViewerOptions";
import {PluginManager} from "@/core/plugin/plugin"; import {PluginManager} from "@/core/plugin/plugin";
@@ -32,6 +33,7 @@ import ParticleEmitter from "@/core/objects/ParticleEmitter.ts";
import {ViewerPathTracer} from "@/core/viewer/ViewerPathTracer.ts"; import {ViewerPathTracer} from "@/core/viewer/ViewerPathTracer.ts";
import {Helper as ScriptHelper} from "../script"; import {Helper as ScriptHelper} from "../script";
import Tiles from "../objects/Tile.ts"; import Tiles from "../objects/Tile.ts";
import {Package} from "@/core/loader/Package";
export interface ViewerEventMap { export interface ViewerEventMap {
// 场景加载完成时执行,仅执行一次 // 场景加载完成时执行,仅执行一次
@@ -61,6 +63,14 @@ export interface ViewerEventMap {
// 模型双击事件 // 模型双击事件
onDoubleClick: { intersect: THREE.Intersection, object: THREE.Object3D }; onDoubleClick: { intersect: THREE.Intersection, object: THREE.Object3D };
// 数据组件绑定数据更新
bindDataChange: {
object: THREE.Object3D;
data: unknown;
config: unknown;
index: number;
};
// 键盘按下事件(全局) // 键盘按下事件(全局)
onKeyDown: { event: KeyboardEvent }; onKeyDown: { event: KeyboardEvent };
@@ -110,6 +120,11 @@ export interface ViewerModules {
tilesManage:TilesManage, tilesManage:TilesManage,
} }
export interface InstallScriptsOptions {
invokeLoaded?: boolean;
loadedScriptNames?: string[];
}
CameraControls.install({ CameraControls.install({
THREE: { THREE: {
Vector2: THREE.Vector2, Vector2: THREE.Vector2,
@@ -131,25 +146,14 @@ const onDoubleClickPosition = new THREE.Vector2();
// 表示animate()函数被多次调用累积时间,用于限制FPS // 表示animate()函数被多次调用累积时间,用于限制FPS
let timeStamp = 0; let timeStamp = 0;
// 事件绑定
const Fn: any = {
pointerdown: null,
pointerup: null,
pointermove: null,
keydown: null,
keyup: null,
touchstart: null,
dblclick: null,
}
// 脚本管理数据结构 // 脚本管理数据结构
type EventHandlers = { type EventHandlers = {
[eventName: string]: { [eventName: string]: {
[uuid: string]: Function[]; [uuid: string]: Function[];
}; };
}; };
// 脚本中可写的所有事件
let events: EventHandlers = { const createScriptEvents = (): EventHandlers => ({
loaded: {}, loaded: {},
beforeAnimation: {}, beforeAnimation: {},
afterAnimation: {}, afterAnimation: {},
@@ -159,6 +163,7 @@ let events: EventHandlers = {
afterDestroy: {}, afterDestroy: {},
onPick: {}, onPick: {},
onDoubleClick: {}, onDoubleClick: {},
bindDataChange: {},
onKeyDown: {}, onKeyDown: {},
onKeyUp: {}, onKeyUp: {},
onPointerDown: {}, onPointerDown: {},
@@ -166,9 +171,7 @@ let events: EventHandlers = {
onPointerMove: {}, onPointerMove: {},
onTouchStart: {}, onTouchStart: {},
onTouchEnd: {}, onTouchEnd: {},
}; });
// UUID 到事件的映射
const uuidEventMap: Map<string, Map<string, { name: string, fn: Function }[]>> = new Map();
export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> { export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
public container: HTMLElement; public container: HTMLElement;
@@ -188,8 +191,27 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
public css2DRenderer: CSS2DRenderer = new CSS2DRenderer(); public css2DRenderer: CSS2DRenderer = new CSS2DRenderer();
public css3DRenderer: CSS3DRenderer = new CSS3DRenderer(); public css3DRenderer: CSS3DRenderer = new CSS3DRenderer();
public timer = new Timer(); public timer = new Timer();
public events: EventHandlers = createScriptEvents();
public uuidEventMap: Map<string, Map<string, { name: string, fn: Function }[]>> = new Map();
public fns: any = {
mousedown: null,
pointerdown: null,
pointerup: null,
pointermove: null,
keydown: null,
keyup: null,
touchstart: null,
dblclick: null,
};
//整个主场景的box3 //整个主场景的box3
public sceneBox3 = new THREE.Box3(); public sceneBox3 = new THREE.Box3();
public package: Package;
private _pluginNeedsRender = false;
/** 供插件/内部模块请求下一帧强制渲染,传 true 标记,animate() 检测后自动清除 */
pluginRequestRender(value: boolean) {
this._pluginNeedsRender = value;
}
constructor(options: IViewerSetting) { constructor(options: IViewerSetting) {
super(); super();
@@ -208,6 +230,7 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
this.renderer = this.createEngine(); this.renderer = this.createEngine();
this.modules = this.initModules(); this.modules = this.initModules();
this.package = new Package(this);
/** helpers **/ /** helpers **/
if (this.options.grid.enabled) { if (this.options.grid.enabled) {
@@ -351,6 +374,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
set enableScript(enable: boolean) { set enableScript(enable: boolean) {
if (enable === this.enableScript) return; if (enable === this.enableScript) return;
this.options.enableScript = enable;
if (enable) { if (enable) {
this.installScripts(); this.installScripts();
} else { } else {
@@ -580,14 +605,14 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
* 初始化事件监听 * 初始化事件监听
*/ */
initEvent() { initEvent() {
Fn.pointerdown = this.onPointerDown.bind(this); this.fns.pointerdown = this.onPointerDown.bind(this);
this.container.addEventListener('pointerdown', Fn.pointerdown); this.container.addEventListener('pointerdown', this.fns.pointerdown);
Fn.pointermove = this.onPointerMove.bind(this); this.fns.pointermove = this.onPointerMove.bind(this);
this.container.addEventListener('pointermove', Fn.pointermove); this.container.addEventListener('pointermove', this.fns.pointermove);
Fn.touchstart = this.onTouchStart.bind(this); this.fns.touchstart = this.onTouchStart.bind(this);
this.container.addEventListener('touchstart', Fn.touchstart); this.container.addEventListener('touchstart', this.fns.touchstart);
Fn.dblclick = this.onDoubleClick.bind(this) this.fns.dblclick = this.onDoubleClick.bind(this)
this.container.addEventListener('dblclick', Fn.dblclick); this.container.addEventListener('dblclick', this.fns.dblclick);
} }
/** /**
@@ -595,9 +620,13 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
* @param uuids 传入此参数则仅组装此数组下Object.uuid的脚本 * @param uuids 传入此参数则仅组装此数组下Object.uuid的脚本
* @param filterName 传入此参数则仅组装此数组下Object.uuid的脚本中name匹配的脚本 * @param filterName 传入此参数则仅组装此数组下Object.uuid的脚本中name匹配的脚本
*/ */
installScripts(uuids?: string | string[], filterName: string = "") { installScripts(uuids?: string | string[], filterName: string = "", options: InstallScriptsOptions = {}) {
if (!this.enableScript) return; if (!this.enableScript) return;
const { invokeLoaded = false, loadedScriptNames = [] } = options;
const loadedScriptNameSet = new Set(loadedScriptNames.filter(Boolean));
const shouldInvokeLoadedAfterInstall = invokeLoaded || loadedScriptNameSet.size > 0;
// 注册 Helper // 注册 Helper
const helper = new ScriptHelper(this.scene); const helper = new ScriptHelper(this.scene);
@@ -611,7 +640,7 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
} }
// 拼接下方闭包函数返回的结构,即返回脚本中写的支持的事件函数 // 拼接下方闭包函数返回的结构,即返回脚本中写的支持的事件函数
const validEvents = Object.keys(events); const validEvents = Object.keys(this.events);
// 准备返回结构 // 准备返回结构
validEvents.forEach(eventName => { validEvents.forEach(eventName => {
@@ -635,7 +664,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
// 一个模型允许存在多个脚本 // 一个模型允许存在多个脚本
const scripts = App.scripts[uuid] || []; const scripts = App.scripts[uuid] || [];
const uuidEvents = uuidEventMap.get(uuid) || new Map<string, { name: string, fn: Function }[]>(); const uuidEvents = this.uuidEventMap.get(uuid) || new Map<string, { name: string, fn: Function }[]>();
const scriptsNeedLoaded = new Set<string>();
scripts.forEach(script => { scripts.forEach(script => {
// 如果存在需要按照name过滤 // 如果存在需要按照name过滤
@@ -649,6 +679,11 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
this.camera, this.modules.controls, this.timer, fns.render this.camera, this.modules.controls, this.timer, fns.render
); );
const shouldInvokeLoaded = loadedScriptNameSet.size > 0 ? loadedScriptNameSet.has(script.name) : invokeLoaded;
if (shouldInvokeLoaded && typeof functions.loaded === "function") {
scriptsNeedLoaded.add(script.name);
}
Object.entries(functions).forEach(([name, fn]) => { Object.entries(functions).forEach(([name, fn]) => {
if (!fn || !validEvents.includes(name)) { if (!fn || !validEvents.includes(name)) {
if (fn && !validEvents.includes(name)) { if (fn && !validEvents.includes(name)) {
@@ -661,12 +696,16 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
const {type, target, ...params} = e; const {type, target, ...params} = e;
// 点击事件只分发给对应模型 // 点击事件只分发给对应模型
if (["onPick", "onDoubleClick"].includes(name)) { if (["onPick", "onDoubleClick", "bindDataChange"].includes(name)) {
const {intersect, object: _object} = params; const {intersect, object: _object, data, config, index} = params;
if (_object.uuid !== object.uuid) return; if (_object.uuid !== object.uuid) return;
if (name === "bindDataChange") {
(fn as Function).bind(object)(data, config, index);
} else {
(fn as Function).bind(object)(intersect as THREE.Intersection); (fn as Function).bind(object)(intersect as THREE.Intersection);
}
} else { } else {
if (isEmptyObject(params)) { if (isEmptyObject(params)) {
(fn as Function).bind(object)(); (fn as Function).bind(object)();
@@ -677,8 +716,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
} }
// 添加到全局事件集合 // 添加到全局事件集合
if (!events[name][uuid]) events[name][uuid] = []; if (!this.events[name][uuid]) this.events[name][uuid] = [];
events[name][uuid].push(boundFn); this.events[name][uuid].push(boundFn);
// 添加到 UUID 事件映射 // 添加到 UUID 事件映射
if (!uuidEvents.has(name)) uuidEvents.set(name, []); if (!uuidEvents.has(name)) uuidEvents.set(name, []);
@@ -693,7 +732,13 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
}); });
// 更新 UUID 映射 // 更新 UUID 映射
uuidEventMap.set(uuid, uuidEvents); this.uuidEventMap.set(uuid, uuidEvents);
if (shouldInvokeLoadedAfterInstall) {
scriptsNeedLoaded.forEach(scriptName => {
this.invokeInstalledScriptEvent(uuid, scriptName, "loaded");
});
}
}; };
// 处理指定 UUID 或全部 // 处理指定 UUID 或全部
@@ -703,15 +748,15 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
Object.keys(App.scripts).forEach(processUuid); Object.keys(App.scripts).forEach(processUuid);
} }
if (!Fn.keydown) { if (!this.fns.keydown) {
Fn.keydown = (event: KeyboardEvent) => { this.fns.keydown = (event: KeyboardEvent) => {
this.dispatchEvent({type: "onKeyDown", event}) this.dispatchEvent({type: "onKeyDown", event})
} }
window.addEventListener('keydown', Fn.keydown); window.addEventListener('keydown', this.fns.keydown);
Fn.keyup = (event: KeyboardEvent) => { this.fns.keyup = (event: KeyboardEvent) => {
this.dispatchEvent({type: "onKeyUp", event}) this.dispatchEvent({type: "onKeyUp", event})
} }
window.addEventListener('keyup', Fn.keyup); window.addEventListener('keyup', this.fns.keyup);
} }
} }
@@ -721,9 +766,9 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
* @param filterName 传入此参数则仅卸载此Object.uuid的脚本中name匹配的脚本 * @param filterName 传入此参数则仅卸载此Object.uuid的脚本中name匹配的脚本
*/ */
uninstallScriptsByUuid(uuid: string, filterName: string = "") { uninstallScriptsByUuid(uuid: string, filterName: string = "") {
if (!uuidEventMap.has(uuid)) return; if (!this.uuidEventMap.has(uuid)) return;
const uuidEvents = uuidEventMap.get(uuid)!; const uuidEvents = this.uuidEventMap.get(uuid)!;
const uuidEventsArray = Array.from(uuidEvents); const uuidEventsArray = Array.from(uuidEvents);
for (let i = uuidEventsArray.length - 1; i >= 0; i--) { for (let i = uuidEventsArray.length - 1; i >= 0; i--) {
@@ -745,7 +790,7 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
} }
// 全局事件集合 // 全局事件集合
const es = events[eventName][uuid]; const es = this.events[eventName][uuid];
// 移除相应函数 // 移除相应函数
const ei = es.findIndex(f => f === sc.fn); const ei = es.findIndex(f => f === sc.fn);
if (ei !== -1) { if (ei !== -1) {
@@ -753,40 +798,79 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
} }
if (es.length === 0) { if (es.length === 0) {
delete events[eventName][uuid]; delete this.events[eventName][uuid];
} }
} }
} }
// 清理 UUID 映射 // 清理 UUID 映射
if (Array.from(uuidEvents.keys()).length === 0) { if (Array.from(uuidEvents.keys()).length === 0) {
uuidEventMap.delete(uuid); this.uuidEventMap.delete(uuid);
} }
} }
invokeInstalledScriptEvent<T extends keyof ViewerEventMap>(
uuid: string,
scriptName: string,
eventName: T,
payload: Partial<ViewerEventMap[T]> = {}
): boolean {
const uuidEvents = this.uuidEventMap.get(uuid);
if (!uuidEvents) return false;
const handlers = uuidEvents.get(String(eventName));
if (!handlers || handlers.length === 0) return false;
let executed = false;
handlers.forEach(handler => {
if (handler.name !== scriptName) return;
try {
handler.fn({
...payload,
type: eventName,
target: this,
});
executed = true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
App.log.error(`[Script] 执行 ${scriptName}.${String(eventName)} 失败: ${message}`);
}
});
return executed;
}
reinstallObjectScripts(uuid: string, loadedScriptNames: string[] = []): void {
if (!uuid) return;
const uniqueLoadedScriptNames = Array.from(new Set(loadedScriptNames.filter(Boolean)));
this.uninstallScriptsByUuid(uuid);
this.installScripts([uuid], "", uniqueLoadedScriptNames.length > 0 ? { loadedScriptNames: uniqueLoadedScriptNames } : undefined);
}
/** /**
* 卸载所有脚本 * 卸载所有脚本
*/ */
unInstallScripts() { unInstallScripts() {
if (this.enableScript) return;
// 直接遍历 UUID 映射,复杂度 O(n) // 直接遍历 UUID 映射,复杂度 O(n)
uuidEventMap.forEach((_, uuid) => { this.uuidEventMap.forEach((_, uuid) => {
this.uninstallScriptsByUuid(uuid); this.uninstallScriptsByUuid(uuid);
}); });
// 重置数据结构 // 重置数据结构
uuidEventMap.clear(); this.uuidEventMap.clear();
Object.keys(events).forEach(event => { Object.keys(this.events).forEach(event => {
events[event] = {}; this.events[event] = {};
}); });
if (Fn.keydown) { if (this.fns.keydown) {
window.removeEventListener('keydown', Fn.keydown); window.removeEventListener('keydown', this.fns.keydown);
Fn.keydown = null; this.fns.keydown = null;
window.removeEventListener('keyup', Fn.keyup); window.removeEventListener('keyup', this.fns.keyup);
Fn.keyup = null; this.fns.keyup = null;
} }
} }
@@ -893,8 +977,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
event.preventDefault(); event.preventDefault();
const array = getMousePosition(this.container, event.clientX, event.clientY); const array = getMousePosition(this.container, event.clientX, event.clientY);
onDownPosition.fromArray(array); onDownPosition.fromArray(array);
Fn.pointerup = this.onPointerUp.bind(this); this.fns.pointerup = this.onPointerUp.bind(this);
document.addEventListener('pointerup', Fn.pointerup); document.addEventListener('pointerup', this.fns.pointerup);
} }
/** /**
@@ -907,8 +991,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
const array = getMousePosition(this.container, event.clientX, event.clientY); const array = getMousePosition(this.container, event.clientX, event.clientY);
onUpPosition.fromArray(array); onUpPosition.fromArray(array);
this.handleClick(); this.handleClick();
document.removeEventListener('pointerup', Fn.pointerup); document.removeEventListener('pointerup', this.fns.pointerup);
Fn.pointerup = null; this.fns.pointerup = null;
} }
/** /**
@@ -929,8 +1013,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
const touch = event.changedTouches[0]; const touch = event.changedTouches[0];
const array = getMousePosition(this.container, touch.clientX, touch.clientY); const array = getMousePosition(this.container, touch.clientX, touch.clientY);
onDownPosition.fromArray(array); onDownPosition.fromArray(array);
Fn.pointerup = this.onTouchEnd.bind(this); this.fns.pointerup = this.onTouchEnd.bind(this);
document.addEventListener('touchend', Fn.pointerup); document.addEventListener('touchend', this.fns.pointerup);
} }
/** /**
@@ -944,8 +1028,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
const array = getMousePosition(this.container, touch.clientX, touch.clientY); const array = getMousePosition(this.container, touch.clientX, touch.clientY);
onUpPosition.fromArray(array); onUpPosition.fromArray(array);
this.handleClick(); this.handleClick();
document.removeEventListener('touchend', Fn.pointerup); document.removeEventListener('touchend', this.fns.pointerup);
Fn.pointerup = null; this.fns.pointerup = null;
} }
/** /**
@@ -983,6 +1067,9 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
this.dispatchEvent({type: 'beforeAnimation', delta: timeStamp}); this.dispatchEvent({type: 'beforeAnimation', delta: timeStamp});
let needRender = App.animationManager.update(timeStamp); let needRender = App.animationManager.update(timeStamp);
if (TWEEN.update()) {
needRender = true;
}
if (needRender) { if (needRender) {
if (App.selected !== null && App.selected.animations.length > 0) { if (App.selected !== null && App.selected.animations.length > 0) {
// 避免某些蒙皮网格的帧延迟效应(e.g. Michelle.glb) // 避免某些蒙皮网格的帧延迟效应(e.g. Michelle.glb)
@@ -1026,6 +1113,11 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
needRender = true; needRender = true;
} }
if (this._pluginNeedsRender) {
needRender = true;
this._pluginNeedsRender = false;
}
this.dispatchEvent({type: 'afterAnimation', delta: timeStamp, toBeRender: (_needRender:boolean = false) => { this.dispatchEvent({type: 'afterAnimation', delta: timeStamp, toBeRender: (_needRender:boolean = false) => {
needRender = _needRender; needRender = _needRender;
} }
@@ -1122,14 +1214,14 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
dispose() { dispose() {
this.dispatchEvent({type: "beforeDestroy"}); this.dispatchEvent({type: "beforeDestroy"});
this.container.removeEventListener('mousedown', Fn.mousedown); this.container.removeEventListener('mousedown', this.fns.mousedown);
Fn.mousedown = null; this.fns.mousedown = null;
this.container.removeEventListener('pointermove', Fn.pointermove); this.container.removeEventListener('pointermove', this.fns.pointermove);
Fn.pointermove = null; this.fns.pointermove = null;
this.container.removeEventListener('touchstart', Fn.touchstart); this.container.removeEventListener('touchstart', this.fns.touchstart);
Fn.touchstart = null; this.fns.touchstart = null;
this.container.removeEventListener('dblclick', Fn.dblclick); this.container.removeEventListener('dblclick', this.fns.dblclick);
Fn.dblclick = null; this.fns.dblclick = null;
Object.keys(this.modules).forEach(key => { Object.keys(this.modules).forEach(key => {
if (this.modules[key].dispose) { if (this.modules[key].dispose) {
@@ -1140,6 +1232,8 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
this.dispatchEvent({type: "afterDestroy"}); this.dispatchEvent({type: "afterDestroy"});
this.unInstallScripts(); this.unInstallScripts();
this.package?.dispose();
this.package = null as any;
} }
/* -----------------暂时放在Viewer下的工具方法-------------------- */ /* -----------------暂时放在Viewer下的工具方法-------------------- */
+73 -33
View File
@@ -2,15 +2,21 @@ import * as THREE from "three";
import {RoomEnvironment} from "three/examples/jsm/environments/RoomEnvironment.js"; import {RoomEnvironment} from "three/examples/jsm/environments/RoomEnvironment.js";
import {ShaderPass} from "three/examples/jsm/postprocessing/ShaderPass.js"; import {ShaderPass} from "three/examples/jsm/postprocessing/ShaderPass.js";
import {Effect} from "./Effect"; import {Effect} from "./Effect";
import {useAddSignal} from "@/hooks"; import {useAddSignal, useRemoveSignal} from "@/hooks";
import Viewer from "../Viewer"; import Viewer from "../Viewer";
import App from "@/core/app/App"; import App from "@/core/app/App";
import {focusObject} from "@/utils/scene/controls.ts"; import {focusObject} from "@/utils/scene/controls.ts";
type SignalListenerRecord = {
name: string;
listener: (...params: any[]) => void;
};
export class Signals { export class Signals {
private readonly viewer: Viewer; private readonly viewer: Viewer;
private useBackgroundAsEnvironment = false; private useBackgroundAsEnvironment = false;
private readonly signalListeners: SignalListenerRecord[] = [];
constructor(viewer:Viewer) { constructor(viewer:Viewer) {
this.viewer = viewer; this.viewer = viewer;
@@ -18,41 +24,46 @@ export class Signals {
this.init(); this.init();
} }
private registerSignal(name: string, listener: (...params: any[]) => void) {
this.signalListeners.push({ name, listener });
useAddSignal(name, listener);
}
init() { init() {
useAddSignal("sceneCleared", this.sceneCleared.bind(this)); this.registerSignal("sceneCleared", this.sceneCleared.bind(this));
useAddSignal("transformModeChanged", this.transformModeChanged.bind(this)); this.registerSignal("transformModeChanged", this.transformModeChanged.bind(this));
useAddSignal("snapChanged", this.snapChanged.bind(this)); this.registerSignal("snapChanged", this.snapChanged.bind(this));
useAddSignal("spaceChanged", this.spaceChanged.bind(this)); this.registerSignal("spaceChanged", this.spaceChanged.bind(this));
useAddSignal("effectEnabledChange", this.effectEnabledChange.bind(this)); this.registerSignal("effectEnabledChange", this.effectEnabledChange.bind(this));
useAddSignal("rendererUpdated", this.rendererUpdated.bind(this)); this.registerSignal("rendererUpdated", this.rendererUpdated.bind(this));
useAddSignal("rendererCreated", this.rendererCreated.bind(this)); this.registerSignal("rendererCreated", this.rendererCreated.bind(this));
useAddSignal("rendererConfigUpdate", this.rendererConfigUpdate.bind(this)); this.registerSignal("rendererConfigUpdate", this.rendererConfigUpdate.bind(this));
useAddSignal("rendererDetectKTX2Support", this.rendererDetectKTX2Support.bind(this)); this.registerSignal("rendererDetectKTX2Support", this.rendererDetectKTX2Support.bind(this));
useAddSignal("sceneBackgroundChanged", this.sceneBackgroundChanged.bind(this)); this.registerSignal("sceneBackgroundChanged", this.sceneBackgroundChanged.bind(this));
useAddSignal("sceneEnvironmentChanged", this.sceneEnvironmentChanged.bind(this)); this.registerSignal("sceneEnvironmentChanged", this.sceneEnvironmentChanged.bind(this));
useAddSignal("sceneGraphChanged", this.sceneGraphChanged.bind(this)); this.registerSignal("sceneGraphChanged", this.sceneGraphChanged.bind(this));
useAddSignal("cameraChanged", this.cameraChanged.bind(this)); this.registerSignal("cameraChanged", this.cameraChanged.bind(this));
useAddSignal("cameraReset", this.viewer.updateAspectRatio.bind(this.viewer)); this.registerSignal("cameraReset", this.viewer.updateAspectRatio.bind(this.viewer));
useAddSignal("viewportCameraChanged", this.viewportCameraChanged.bind(this)); this.registerSignal("viewportCameraChanged", this.viewportCameraChanged.bind(this));
useAddSignal("viewportShadingChanged", this.viewportShadingChanged.bind(this)); this.registerSignal("viewportShadingChanged", this.viewportShadingChanged.bind(this));
useAddSignal("objectSelected", this.objectSelected.bind(this)); this.registerSignal("objectSelected", this.objectSelected.bind(this));
useAddSignal("objectFocused", this.objectFocused.bind(this)); this.registerSignal("objectFocused", this.objectFocused.bind(this));
useAddSignal("objectAdded", this.objectAdded.bind(this)); this.registerSignal("objectAdded", this.objectAdded.bind(this));
useAddSignal("objectChanged", this.objectChanged.bind(this)); this.registerSignal("objectChanged", this.objectChanged.bind(this));
useAddSignal("objectRemoved", this.objectRemoved.bind(this)); this.registerSignal("objectRemoved", this.objectRemoved.bind(this));
useAddSignal("geometryChanged", this.geometryChanged.bind(this)); this.registerSignal("geometryChanged", this.geometryChanged.bind(this));
useAddSignal("materialChanged", this.materialChanged.bind(this)); this.registerSignal("materialChanged", this.materialChanged.bind(this));
useAddSignal("sceneResize", this.sceneResize.bind(this)); this.registerSignal("sceneResize", this.sceneResize.bind(this));
useAddSignal("showGridChanged", this.showGridChanged.bind(this)); this.registerSignal("showGridChanged", this.showGridChanged.bind(this));
useAddSignal("scriptAdded",this.scriptAdded.bind(this)); this.registerSignal("scriptAdded",this.scriptAdded.bind(this));
useAddSignal("scriptRemoved",this.scriptRemoved.bind(this)); this.registerSignal("scriptRemoved",this.scriptRemoved.bind(this));
useAddSignal("scriptChanged",this.scriptChanged.bind(this)); this.registerSignal("scriptChanged",this.scriptChanged.bind(this));
} }
/** /**
@@ -66,6 +77,8 @@ export class Signals {
* 清空 * 清空
*/ */
sceneCleared() { sceneCleared() {
this.viewer.unInstallScripts();
this.viewer.modules.controls.setTarget(0, 0, 0,true); this.viewer.modules.controls.setTarget(0, 0, 0,true);
this.viewer.pathtracer?.reset(); this.viewer.pathtracer?.reset();
@@ -453,15 +466,29 @@ export class Signals {
/** /**
* 添加脚本 * 添加脚本
*/ */
scriptAdded(object:THREE.Object3D, _:ISceneScript){ scriptAdded(object:THREE.Object3D, sc:ISceneScript){
this.viewer.installScripts([object.uuid]); if (!object?.uuid || !sc?.name) return;
try {
this.viewer.reinstallObjectScripts(object.uuid, [sc.name]);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
App.log.error(`[Script] 热安装脚本 ${sc.name} 失败: ${message}`);
}
} }
/** /**
* 移除脚本 * 移除脚本
*/ */
scriptRemoved(object:THREE.Object3D, sc:ISceneScript){ scriptRemoved(object:THREE.Object3D, sc:ISceneScript){
this.viewer.uninstallScriptsByUuid(object.uuid,sc.name); if (!object?.uuid) return;
try {
this.viewer.reinstallObjectScripts(object.uuid);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
App.log.error(`[Script] 卸载脚本 ${sc?.name || "unknown"} 失败: ${message}`);
}
} }
/** /**
@@ -469,8 +496,14 @@ export class Signals {
*/ */
scriptChanged(attributeName:string,object:THREE.Object3D, sc:ISceneScript){ scriptChanged(attributeName:string,object:THREE.Object3D, sc:ISceneScript){
if(attributeName !== "source") return; if(attributeName !== "source") return;
if (!object?.uuid || !sc?.name) return;
this.viewer.installScripts([object.uuid],sc.name); try {
this.viewer.reinstallObjectScripts(object.uuid, [sc.name]);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
App.log.error(`[Script] 热更新脚本 ${sc.name} 失败: ${message}`);
}
} }
/** /**
@@ -479,4 +512,11 @@ export class Signals {
render(){ render(){
this.viewer.render(); this.viewer.render();
} }
dispose() {
this.signalListeners.forEach(({ name, listener }) => {
useRemoveSignal(name, listener);
});
this.signalListeners.length = 0;
}
} }
+119
View File
@@ -25,6 +25,39 @@ declare namespace IAppProject {
interface Effect { interface Effect {
enabled: boolean; enabled: boolean;
ToneMapping: {
mode: "LINEAR" | "REINHARD" | "REINHARD2" | "OPTIMIZED_CINEON" | "ACES_FILMIC" | "AGX" | "NEUTRAL";
exposure: number;
blendFunction: string;
};
SMAA: {
enabled: boolean;
preset: string;
};
SSAO: {
enabled: boolean;
blendFunction: string;
samples: number;
rings: number;
radius: number;
intensity: number;
bias: number;
fade: number;
luminanceInfluence: number;
minRadiusScale: number;
depthAwareUpsampling: boolean;
resolutionScale: number;
distanceThreshold: number;
distanceFalloff: number;
rangeThreshold: number;
rangeFalloff: number;
worldDistanceThreshold: number | null;
worldDistanceFalloff: number | null;
worldProximityThreshold: number | null;
worldProximityFalloff: number | null;
colorEnabled: boolean;
color: string;
};
Outline: { Outline: {
enabled: boolean; enabled: boolean;
edgeStrength: number; edgeStrength: number;
@@ -34,6 +67,12 @@ declare namespace IAppProject {
usePatternTexture: boolean; usePatternTexture: boolean;
visibleEdgeColor: string; visibleEdgeColor: string;
hiddenEdgeColor: string; hiddenEdgeColor: string;
pulseSpeed?: number;
xRay?: boolean;
blur?: boolean;
kernelSize?: number;
multisampling?: number;
blendFunction?: string;
}; };
FXAA: { FXAA: {
enabled: boolean; enabled: boolean;
@@ -43,18 +82,98 @@ declare namespace IAppProject {
threshold: number; threshold: number;
strength: number; strength: number;
radius: number; radius: number;
intensity?: number;
luminanceThreshold?: number;
luminanceSmoothing?: number;
levels?: number;
mipmapBlur?: boolean;
blendFunction?: string;
}; };
Bokeh: { Bokeh: {
enabled: boolean; enabled: boolean;
focus: number; focus: number;
aperture: number; aperture: number;
maxblur: number; maxblur: number;
focusDistance?: number;
focusRange?: number;
bokehScale?: number;
resolutionScale?: number;
}; };
Pixelate: { Pixelate: {
enabled: boolean; enabled: boolean;
pixelSize: number; pixelSize: number;
normalEdgeStrength: number; normalEdgeStrength: number;
depthEdgeStrength: number; depthEdgeStrength: number;
granularity?: number;
};
TiltShift?: {
enabled: boolean;
offset: number;
rotation: number;
focusArea: number;
feather: number;
blendFunction: string;
};
Scanline?: {
enabled: boolean;
density: number;
scrollSpeed: number;
blendFunction: string;
};
BrightnessContrast?: {
enabled: boolean;
brightness: number;
contrast: number;
blendFunction: string;
};
ChromaticAberration?: {
enabled: boolean;
offset: { x: number; y: number };
radialModulation: boolean;
modulationOffset: number;
blendFunction: string;
};
ColorDepth?: {
enabled: boolean;
bits: number;
blendFunction: string;
};
Glitch?: {
enabled: boolean;
chromaticAberrationOffset: { x: number; y: number } | null;
delay: { min: number; max: number };
duration: { min: number; max: number };
strength: { min: number; max: number };
mode: "SPORADIC" | "CONSTANT_MILD" | "CONSTANT_WILD";
ratio: number;
blendFunction: string;
};
HueSaturation?: {
enabled: boolean;
hue: number;
saturation: number;
blendFunction: string;
};
LensDistortion?: {
enabled: boolean;
distortion: { x: number; y: number };
principalPoint: { x: number; y: number };
focalLength: { x: number; y: number };
skew: number;
};
ShockWave?: {
enabled: boolean;
amplitude: number;
waveSize: number;
speed: number;
maxRadius: number;
clickTrigger: boolean;
};
Vignette?: {
enabled: boolean;
offset: number;
darkness: number;
blendFunction: string;
}; };
Halftone: { Halftone: {
enabled: boolean; enabled: boolean;
+2 -1
View File
@@ -28,7 +28,8 @@ export default defineConfig(async ({mode, command}) => {
root, root,
compress: { compress: {
compress: VITE_BUILD_COMPRESS, compress: VITE_BUILD_COMPRESS,
deleteOriginFile: VITE_BUILD_COMPRESS_DELETE_ORIGIN_FILE, // SDK 作为 workspace 依赖需要保留原始产物,package.json 的 main/module/exports 依赖这些文件
deleteOriginFile: false,
}, },
enableAnalyze: VITE_ENABLE_ANALYZE, enableAnalyze: VITE_ENABLE_ANALYZE,
enableConfig:VITE_ENABLE_CONFIG_GENERATE enableConfig:VITE_ENABLE_CONFIG_GENERATE