Compare commits

...
28 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
plum 986da8ce42 feat(all): 拖入花线迁移 2026-04-08 16:00:14 +08:00
plum 2605656dc4 feat(all): 迁移扩展相关功能,对应的面板迁移 2026-04-08 15:51:10 +08:00
plum d7c0dba569 feat(all): 迁移扩展相关功能 2026-04-08 15:34:43 +08:00
353 changed files with 34377 additions and 1484 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>
+3 -3
View File
@@ -7,10 +7,10 @@ export const navbar = defineNavbarConfig([
icon: 'icon-park-outline:guide-board', icon: 'icon-park-outline:guide-board',
link: '/notes/guide/quick-start/介绍.md', link: '/notes/guide/quick-start/介绍.md',
}, },
{ text: 'SDK', link: '/notes/sdk/README.md', icon: 'carbon:api' }, { text: 'SDK', link: '/notes/sdk/迁移目标.md', icon: 'carbon:api' },
{ text: '示例', link: 'https://examples.astraljs.com/', icon: 'carbon:carbon-for-ibm-dotcom' }, { text: '示例', link: 'https://examples.astraljs.com/', icon: 'carbon:carbon-for-ibm-dotcom' },
{ text: '推广中心', link: '/notes/promotion/README.md', icon: 'mdi:star-shooting-outline' }, { text: '推广中心', link: '/notes/promotion/迁移目标.md', icon: 'mdi:star-shooting-outline' },
{ text: '商务合作', link: '/notes/cooperation/README.md', icon: 'carbon:partnership' }, { text: '商务合作', link: '/notes/cooperation/迁移目标.md', icon: 'carbon:partnership' },
{ {
text: '更多', text: '更多',
icon: 'icon-park-outline:more-three', icon: 'icon-park-outline:more-three',
+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.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
<rect width="256" height="256" fill="#121212"/>
<rect x="24" y="24" width="208" height="208" rx="18" fill="#1f1f1f" stroke="#2d2d2d" stroke-width="4"/>
<rect x="40" y="40" width="176" height="56" rx="12" fill="#00b6a4"/>
<rect x="40" y="108" width="176" height="84" rx="12" fill="#262626"/>
<text x="128" y="78" text-anchor="middle" font-size="28" font-family="Arial, sans-serif" fill="#ffffff">UIPanel</text>
<text x="128" y="156" text-anchor="middle" font-size="18" font-family="Arial, sans-serif" fill="#cfd8dc">WYSIWYG</text>
</svg>

After

Width:  |  Height:  |  Size: 637 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

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));
@@ -25,8 +25,10 @@ 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 = [
{ {
@@ -35,41 +37,41 @@ const exportOptions = [
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(() => {
@@ -138,6 +167,15 @@ function handleImport() {
} }
function handleExportSelect(key: string) { function handleExportSelect(key: string) {
if (key === "exportOfflineSingle") {
exportOffline("single");
return;
}
if (key === "exportOfflineMultiple") {
exportOffline("multiple");
return;
}
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);
}
+266
View File
@@ -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 */
@@ -122,6 +125,9 @@ export default {
world: '世界坐标', world: '世界坐标',
"Status monitoring":"状态监控", "Status monitoring":"状态监控",
}, },
path: {
"Path drawing: Left-click to add a point, and double-click to end the drawing": "路径绘制:左键单击添加点,双击结束绘制",
},
viewportInfo: { viewportInfo: {
Objects: '物体', Objects: '物体',
Vertices: '顶点', Vertices: '顶点',
@@ -223,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": "边缘发光",
@@ -234,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":"运动残影",
@@ -242,6 +320,7 @@ export default {
"Focus": "焦距", "Focus": "焦距",
"Aperture": "孔径", "Aperture": "孔径",
"MaxBlur": "最大模糊", "MaxBlur": "最大模糊",
"Resolution Scale": "分辨率缩放",
"Pixelate": "像素风", "Pixelate": "像素风",
"PixelSize": "像素大小", "PixelSize": "像素大小",
"NormalEdgeStrength": "法向边缘强度", "NormalEdgeStrength": "法向边缘强度",
@@ -539,6 +618,173 @@ export default {
htmlPanel: { htmlPanel: {
Content: "内容" Content: "内容"
}, },
Heatmap: "热力图",
Path: "路径",
"UI panel": "UI面板",
"Data component": "数据组件",
"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 Texture": "热力图纹理",
Blur: "模糊",
Gradient: "色阶",
Add: "添加",
Remove: "移除",
Data: "数据",
Points: "点",
"Controls the opacity of the darkest and brightest parts of a thermal texture": "控制热力纹理最暗处和最亮处的不透明度",
"Height Scale": "高度缩放",
"Heatmap Points": "热力图点",
Error: "错误",
"Points must be an array.": "点数据必须是数组。",
"Point[{index}] must be an object.": "点[{index}]必须是对象。",
"Point[{index}].x must be a number.": "点[{index}].x必须是数字。",
"Point[{index}].y must be a number.": "点[{index}].y必须是数字。",
"Point[{index}].value must be a number.": "点[{index}].value必须是数字。",
"Point[{index}].radius must be a number.": "点[{index}].radius必须是数字。",
},
path: {
"Path Params": "路径参数",
"Ribbon Params": "平面路径",
"Tube Params": "管状路径",
"Flow Params": "流动效果",
Type: "类型",
Ribbon: "平面",
Tube: "管状",
"Corner Radius": "拐角半径",
"Corner Split": "拐角分段",
Progress: "进度",
Arrow: "箭头",
Side: "绘制侧",
Both: "双侧",
Left: "左侧",
Right: "右侧",
"Start Rad": "起始弧度",
Flow: "流动",
"Flow Speed": "流动速度",
"Flow Direction": "流动方向",
},
uipanel: {
Node: "节点",
"Select a node to edit": "选择节点以编辑",
Type: "类型",
Text: "文本",
Color: "颜色",
Padding: "内边距",
Margin: "外边距",
Offset: "偏移",
"Font opacity": "字体透明度",
"Font supersampling": "字体超采样",
"Letter spacing": "字间距",
"Line height": "行高",
"White space": "空白处理",
"Break on": "换行字符",
"Background color": "背景色",
"Background opacity": "背景透明度",
"Background image URL": "背景图链接",
"Select local image": "选择本地图片",
"Image size must be <= {size}MB": "图片大小不能超过{size}MB",
"Background size": "背景图大小",
Cover: "覆盖",
Contain: "包含",
Stretch: "拉伸",
"Border radius": "圆角",
"Border width": "边框宽度",
"Border color": "边框颜色",
"Hidden overflow": "隐藏溢出",
"Flex direction": "布局方向",
Row: "横向",
"Row reverse": "横向反转",
Column: "纵向",
"Column reverse": "纵向反转",
"Justify content": "主轴对齐",
"Align items": "交叉轴对齐",
States: "状态样式",
Hover: "悬停",
Active: "点击",
"Hover background": "悬停背景色",
"Hover opacity": "悬停透明度",
"Active background": "点击背景色",
"Active opacity": "点击透明度",
"Hover color": "悬停颜色",
"Active color": "点击颜色",
Start: "开始",
Center: "居中",
End: "结束",
"Space between": "两端对齐",
"Space around": "环绕",
"Space evenly": "均分",
"Font size": "字号",
"Text align": "文字对齐",
Left: "左对齐",
Right: "右对齐",
"Add Block": "添加容器",
"Add Text": "添加文本",
},
waterPool: {
Basic: "基础",
Type: "水池类型",
Cylinder: "圆柱",
Square: "方形",
WallMode: "墙体模式",
WallNone: "无墙体",
Wall: "墙体",
Volume: "体积水",
WallOpacity: "墙体透明度",
ScreenRefraction: "屏幕空间折射",
Size: "尺寸",
Diameter: "直径",
Width: "宽度",
Depth: "深度",
Height: "高度",
Light: "光照",
Direction: "方向",
Color: "颜色",
VolumeColor: "体积水颜色",
SurfaceColor: "水面颜色",
Disturbance: "水波",
Enabled: "启用",
Mode: "模式",
ModeDrag: "拖动",
ModeUniform: "均匀",
DropsPerStep: "每步水滴数",
RadiusMin: "半径最小",
RadiusMax: "半径最大",
StrengthMin: "强度最小",
StrengthMax: "强度最大",
TravelRadius: "活动半径",
DriftSpeed: "漂移速度",
Jitter: "抖动强度",
Spread: "扩散范围",
Refraction: "折射与波纹",
SurfaceTransmittance: "透视强度",
NormalStrength: "法线强度",
RefractionStrength: "折射强度",
Texture: "贴图",
WallTexture: "墙体纹理",
Quality: "模拟精度",
SimulationSize: "模拟尺寸",
CausticsSize: "焦散尺寸",
WaterSegments: "水面分段",
WallSegments: "墙体分段",
},
"3D Tiles": "3D Tiles", "3D Tiles": "3D Tiles",
tiles: { tiles: {
"Color mode": "颜色模式", "Color mode": "颜色模式",
@@ -596,6 +842,7 @@ export default {
Open: "开启", Open: "开启",
Close: "关闭", Close: "关闭",
Enable: "启用", Enable: "启用",
Enabled: "启用",
Minimum: "最小值", Minimum: "最小值",
Maximum: "最大值", Maximum: "最大值",
Width: "宽度", Width: "宽度",
@@ -606,6 +853,7 @@ export default {
'Query failed': "查询失败", 'Query failed': "查询失败",
'Related document': "相关文档", 'Related document': "相关文档",
Copy: "复制", Copy: "复制",
Finish: "完成",
Focus: '聚焦', Focus: '聚焦',
Support: '支持', Support: '支持',
Upload: '上传', Upload: '上传',
@@ -1081,6 +1329,24 @@ export default {
// 其他 // 其他
'Dragon':"龙", 'Dragon':"龙",
}, },
"Expansion": "扩展",
expansion: {
"Heat map": "热力图",
"Path": "路径",
"Flat heatmap": "平面热力图",
"Elevation heatmap": "高程热力图",
"Flow path": "流动路径",
"Flowing light path": "流光路径",
"Tube path": "管道路径",
"UI Panel": "UI面板",
"Water": "水体",
"Circular Water Surface": "圆形水面",
"Cylinder Water Pool": "圆柱水池",
"Cylinder Water Pool Volume": "圆柱水池体",
"Square Water Surface": "方形水面",
"Square Water Pool": "方形水池",
"Square Water Pool Volume": "方形水池体",
},
"Light": '灯光', "Light": '灯光',
"Camera": '相机', "Camera": '相机',
} }
+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,60 @@
import { defineStore } from "pinia";
import { store } from "@/store";
interface IPathDrawingState {
active: boolean;
template: Record<string, any> | null;
submit: ((payload: IPathDrawingResult) => void) | null;
}
export interface IPathDrawingResult {
worldPoints: Array<{ x: number; y: number; z: number }>;
origin: { x: number; y: number; z: number };
options: Record<string, any>;
}
export interface IPathDrawingRequest {
template?: Record<string, any> | null;
submit?: (payload: IPathDrawingResult) => void;
}
function isPathDrawingRequest(payload: Record<string, any> | IPathDrawingRequest) {
return Object.prototype.hasOwnProperty.call(payload, "template") || Object.prototype.hasOwnProperty.call(payload, "submit");
}
export const usePathDrawingStore = defineStore({
id: "pathDrawing",
state: (): IPathDrawingState => ({
active: false,
template: null,
submit: null,
}),
getters: {
isActive: state => state.active,
getTemplate: state => state.template,
getSubmit: state => state.submit,
},
actions: {
start(payload: Record<string, any> | IPathDrawingRequest) {
const request = isPathDrawingRequest(payload) ? payload : { template: payload };
this.template = request.template ? JSON.parse(JSON.stringify(request.template)) : null;
this.submit = typeof request.submit === "function" ? request.submit : null;
this.active = true;
},
cancel() {
this.active = false;
this.template = null;
this.submit = null;
},
finish() {
this.active = false;
this.template = null;
this.submit = null;
},
},
});
export function usePathDrawingStoreWithOut() {
return usePathDrawingStore(store);
}
@@ -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();
});
}
@@ -0,0 +1,659 @@
<template>
<div id="extra-resource-expansion" class="flex flex-col">
<div class="mb-3 flex flex-wrap items-center gap-2">
<n-button
v-for="subCategory in subCategories"
:key="subCategory.key"
strong
secondary
round
:type="activeSubCategory === subCategory.key ? 'primary' : 'default'"
@click="selectSubCategory(subCategory.key)"
>
{{ subCategory.name }}
</n-button>
</div>
<div class="flex-1 overflow-y-auto">
<div class="grid grid-cols-[repeat(auto-fill,minmax(100px,1fr))] gap-3">
<n-card
size="small"
hoverable
v-for="item in filteredList"
:key="item.key"
@contextmenu.prevent="handlePreview(item)"
@dblclick="addToScene(item)"
draggable="true"
@dragstart="dragStart(item)"
@dragend="dragEnd"
>
<template #cover>
<img :src="item.image" :alt="item.key" draggable="false" class="w-full object-fill" />
</template>
<n-tooltip placement="bottom" trigger="hover">
<template #trigger>{{ getItemName(item) }}</template>
<span>{{ getItemName(item) }}</span>
</n-tooltip>
</n-card>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, inject, computed, nextTick } from "vue";
import type { Ref } from "vue";
import { Box3, Vector3, type Object3D } from "three";
import { cpt } from "@/language";
import { useDragStore } from "@/store/modules/drag";
import { usePathDrawingStore } from "@/store/modules/pathDrawing";
import { screenToWorld } from "@/utils/common/scenes";
import { App, AddObjectCommand, Heatmap, Path, UIPanel, Water, type Preview } from "@astral3d/engine";
interface ExpansionItem {
key: string;
image: string;
name: any;
options: Record<string, any>;
}
const searchText = inject("searchText") as Ref<string>;
const previewInfo = inject("previewInfo") as any;
const previewRef = inject("previewRef") as any;
const pathDrawingStore = usePathDrawingStore();
const activeSubCategory = ref("heatmap");
const subCategories = ref([
{key: "heatmap", name: cpt("extra.resource.expansion.Heat map")},
{key: "path", name: cpt("extra.resource.expansion.Path")},
{key: "uipanel", name: cpt("extra.resource.expansion.UI Panel")},
{key: "water", name: cpt("extra.resource.expansion.Water")},
]);
const pathPreviewPoints = [
{x: -3.5, y: 0, z: -1.8},
{x: -1.8, y: 0, z: 1.2},
{x: 0.4, y: 0, z: -0.8},
{x: 2.4, y: 0, z: 1.6},
{x: 3.6, y: 0, z: -0.4},
];
const pathPreviewPoints3D = [
{x: -3.2, y: 0.2, z: -1.4},
{x: -1.6, y: 1.1, z: 1.0},
{x: 0.3, y: 0.4, z: -0.6},
{x: 2.1, y: 1.3, z: 1.4},
{x: 3.1, y: 0.5, z: -0.3},
];
const allList: Record<string, ExpansionItem[]> = {
heatmap: [
{
key: "flatHeatmap",
image: "/static/images/resource/expansion/heatmap/flatHeatmap.jpg",
name: cpt("extra.resource.expansion.Flat heatmap"),
options: {
mode: "flat",
heatmap: {
maxOpacity: 0.8,
minOpacity: 0,
},
data: {
max: 5,
min: 0,
points: [
{x: 0, y: 0, value: 5, radius: 1.6},
{x: 0.9, y: -0.5, value: 4.4, radius: 1.2},
{x: -0.8, y: 0.9, value: 4.0, radius: 1.1},
{x: 1.6, y: 0.8, value: 3.6, radius: 0.9},
{x: -1.7, y: -0.9, value: 3.2, radius: 0.9},
{x: 0.2, y: 1.8, value: 2.8, radius: 0.8},
{x: -0.4, y: -2.1, value: 4.6, radius: 0.8},
{x: 2.6, y: -1.9, value: 2.9, radius: 0.9},
{x: -2.4, y: 2.1, value: 3.7, radius: 0.9},
{x: 3.2, y: 2.9, value: 3.8, radius: 1.1},
{x: 4.1, y: 2.1, value: 3.0, radius: 0.9},
{x: 2.4, y: 3.9, value: 2.4, radius: 0.8},
{x: 4.5, y: 3.4, value: 3.8, radius: 0.7},
{x: -3.4, y: -2.7, value: 3.1, radius: 0.9},
{x: -4.2, y: -2.1, value: 2.3, radius: 0.8},
{x: -2.6, y: -3.9, value: 2.0, radius: 0.7},
{x: -4.6, y: -3.6, value: 1.4, radius: 0.6},
{x: -1.3, y: 3.1, value: 2.2, radius: 0.8},
{x: 1.5, y: 2.6, value: 2.7, radius: 0.8},
{x: 3.0, y: -3.7, value: 1.9, radius: 0.7},
{x: 4.2, y: -3.4, value: 4.4, radius: 0.6},
{x: -4.4, y: 1.4, value: 4.6, radius: 0.6},
{x: -3.7, y: 2.5, value: 2.1, radius: 0.7},
{x: -2.0, y: 4.4, value: 3.3, radius: 0.6},
{x: 0.0, y: 4.6, value: 1.2, radius: 0.6},
{x: 4.6, y: 0.2, value: 3.5, radius: 0.6},
{x: -4.8, y: 0.1, value: 4.0, radius: 0.5},
{x: 0.3, y: -4.5, value: 3.1, radius: 0.6},
{x: 1.8, y: -4.1, value: 1.4, radius: 0.6},
{x: -1.7, y: -4.2, value: 4.6, radius: 0.6},
{x: 3.6, y: -1.2, value: 2.5, radius: 0.8},
{x: -3.2, y: 0.6, value: 2.7, radius: 0.8},
{x: 0.0, y: -2.6, value: 4.3, radius: 0.8},
{x: -0.6, y: 2.2, value: 2.9, radius: 0.8},
{x: 2.4, y: 0.4, value: 3.1, radius: 0.9},
{x: -2.8, y: -0.6, value: 2.4, radius: 0.8},
{x: 1.0, y: 4.2, value: 4.6, radius: 0.6},
{x: -4.1, y: 4.1, value: 5.0, radius: 0.5},
],
},
},
},
{
key: "heightHeatmap",
image: "/static/images/resource/expansion/heatmap/heightHeatmap.jpg",
name: cpt("extra.resource.expansion.Elevation heatmap"),
options: {
mode: "height",
data: {
max: 5,
min: 0,
points: [
{x: 0, y: 0, value: 5, radius: 1.6},
{x: 0.9, y: -0.5, value: 4.4, radius: 1.2},
{x: -0.8, y: 0.9, value: 4.0, radius: 1.1},
{x: 1.6, y: 0.8, value: 3.6, radius: 0.9},
{x: -1.7, y: -0.9, value: 3.2, radius: 0.9},
{x: 0.2, y: 1.8, value: 2.8, radius: 0.8},
{x: -0.4, y: -2.1, value: 4.6, radius: 0.8},
{x: 2.6, y: -1.9, value: 2.9, radius: 0.9},
{x: -2.4, y: 2.1, value: 3.7, radius: 0.9},
{x: 3.2, y: 2.9, value: 3.8, radius: 1.1},
{x: 4.1, y: 2.1, value: 3.0, radius: 0.9},
{x: 2.4, y: 3.9, value: 2.4, radius: 0.8},
{x: 4.5, y: 3.4, value: 3.8, radius: 0.7},
{x: -3.4, y: -2.7, value: 3.1, radius: 0.9},
{x: -4.2, y: -2.1, value: 2.3, radius: 0.8},
{x: -2.6, y: -3.9, value: 2.0, radius: 0.7},
{x: -4.6, y: -3.6, value: 1.4, radius: 0.6},
{x: -1.3, y: 3.1, value: 2.2, radius: 0.8},
{x: 1.5, y: 2.6, value: 2.7, radius: 0.8},
{x: 3.0, y: -3.7, value: 1.9, radius: 0.7},
{x: 4.2, y: -3.4, value: 4.4, radius: 0.6},
{x: -4.4, y: 1.4, value: 4.6, radius: 0.6},
{x: -3.7, y: 2.5, value: 2.1, radius: 0.7},
{x: -2.0, y: 4.4, value: 3.3, radius: 0.6},
{x: 0.0, y: 4.6, value: 1.2, radius: 0.6},
{x: 4.6, y: 0.2, value: 3.5, radius: 0.6},
{x: -4.8, y: 0.1, value: 4.0, radius: 0.5},
{x: 0.3, y: -4.5, value: 3.1, radius: 0.6},
{x: 1.8, y: -4.1, value: 1.4, radius: 0.6},
{x: -1.7, y: -4.2, value: 4.6, radius: 0.6},
{x: 3.6, y: -1.2, value: 2.5, radius: 0.8},
{x: -3.2, y: 0.6, value: 2.7, radius: 0.8},
{x: 0.0, y: -2.6, value: 4.3, radius: 0.8},
{x: -0.6, y: 2.2, value: 2.9, radius: 0.8},
{x: 2.4, y: 0.4, value: 3.1, radius: 0.9},
{x: -2.8, y: -0.6, value: 2.4, radius: 0.8},
{x: 1.0, y: 4.2, value: 4.6, radius: 0.6},
{x: -4.1, y: 4.1, value: 5.0, radius: 0.5},
],
},
},
},
],
path: [
{
key: "flowPath",
image: "/static/images/resource/expansion/path/flow.png",
name: cpt("extra.resource.expansion.Flow path"),
options: {
mode: "path",
cornerRadius: 1,
cornerSplit: 30,
path: {
width: 1,
arrow: false,
progress: 1,
side: "both",
},
flow: {
enabled: true,
speed: 0.35,
direction: [1, 0],
},
material: {
map: "/static/images/resource/expansion/path/flow.png",
},
points: pathPreviewPoints,
},
},
{
key: "flowingLightPath",
image: "/static/images/resource/expansion/path/light.png",
name: cpt("extra.resource.expansion.Flowing light path"),
options: {
mode: "path",
cornerRadius: 1,
cornerSplit: 30,
path: {
width: 1,
arrow: false,
progress: 1,
side: "both",
},
flow: {
enabled: true,
speed: 0.5,
direction: [1, 0],
},
material: {
map: "/static/images/resource/expansion/path/light.png",
},
points: pathPreviewPoints,
},
},
{
key: "tubePath",
image: "/static/images/resource/expansion/path/diffuse.jpg",
name: cpt("extra.resource.expansion.Tube path"),
options: {
mode: "tube",
cornerRadius: 0.15,
cornerSplit: 6,
tube: {
radius: 0.18,
radialSegments: 12,
progress: 1,
startRad: 0,
},
material: {
color: "#4c90f5",
map: "/static/images/resource/expansion/path/diffuse.jpg",
transparent: true,
repeat: [1, 1],
},
points: pathPreviewPoints3D,
},
},
],
uipanel: [
{
key: "uiPanel",
image: "/static/images/resource/expansion/uipanel/uipanel.svg",
name: cpt("extra.resource.expansion.UI Panel"),
options: {
name: "UIPanel",
type: "block",
props: {
width: 1.6,
height: 0.9,
padding: 0.04,
backgroundColor: "#151515",
backgroundOpacity: 0.9,
borderRadius: 0.05,
flexDirection: "column",
justifyContent: "space-between",
alignItems: "center",
},
children: [
{
type: "block",
name: "Header",
props: {
width: 1.5,
height: 0.18,
backgroundColor: "#00b6a4",
backgroundOpacity: 0.95,
borderRadius: 0.04,
justifyContent: "center",
alignItems: "center",
},
children: [
{
type: "text",
name: "HeaderTitle",
props: {
textContent: "UIPanel",
fontSize: 0.07,
color: "#ffffff",
textAlign: "center",
},
},
],
},
{
type: "block",
name: "Body",
props: {
width: 1.5,
height: 0.5,
padding: 0.03,
backgroundColor: "#007e82",
backgroundOpacity: 0.9,
borderRadius: 0.04,
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
},
children: [
{
type: "text",
name: "BodyTitle",
props: {
textContent: "WYSIWYG",
fontSize: 0.06,
color: "#cfd8dc",
textAlign: "center",
},
},
{
type: "text",
name: "BodySubTitle",
props: {
textContent: "Drag nodes to layout",
fontSize: 0.045,
color: "#9aa4ad",
textAlign: "center",
},
},
],
},
{
type: "block",
name: "Footer",
props: {
width: 1.5,
height: 0.12,
backgroundColor: "#101010",
backgroundOpacity: 0.9,
borderRadius: 0.04,
justifyContent: "center",
alignItems: "center",
},
children: [
{
type: "text",
name: "FooterText",
props: {
textContent: "Astral3D UI",
fontSize: 0.04,
color: "#7bd5cd",
textAlign: "center",
},
},
],
},
],
},
},
],
water: [
{
key: "circularWaterSurface",
image: "/static/images/resource/expansion/water/circularWaterSurface.jpg",
name: cpt("extra.resource.expansion.Circular Water Surface"),
options: {
sky: window.viewer?.scene.environment,
name: "CircularWaterSurface",
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,
},
},
{
key: "cylinderWaterPool",
image: "/static/images/resource/expansion/water/cylinderWaterPool.jpg",
name: cpt("extra.resource.expansion.Cylinder Water Pool"),
options: {
sky: window.viewer?.scene.environment,
name: "CylinderWaterPool",
type: "cylinder",
light: [0.7, 1, -0.3],
diameter: 5,
height: 5,
wallMode: "wall",
wallOpacity: 1,
useSceneRefraction: 1,
surfaceTransmittance: 0.6,
normalStrength: 0.5,
refractionStrength: 0.035,
},
},
{
key: "cylinderWaterPoolVolume",
image: "/static/images/resource/expansion/water/cylinderWaterPoolVolume.jpg",
name: cpt("extra.resource.expansion.Cylinder Water Pool Volume"),
options: {
sky: window.viewer?.scene.environment,
name: "CylinderWaterPoolVolume",
type: "cylinder",
light: [0.7, 1, -0.3],
diameter: 5,
height: 5,
wallMode: "volume",
wallOpacity: 1,
useSceneRefraction: 1,
surfaceTransmittance: 0.6,
normalStrength: 0.5,
refractionStrength: 0.035,
},
},
{
key: "squareWaterSurface",
image: "/static/images/resource/expansion/water/squareWaterSurface.jpg",
name: cpt("extra.resource.expansion.Square Water Surface"),
options: {
sky: window.viewer?.scene.environment,
name: "SquareWaterSurface",
type: "square",
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,
},
},
{
key: "squareWaterPool",
image: "/static/images/resource/expansion/water/squareWaterPool.jpg",
name: cpt("extra.resource.expansion.Square Water Pool"),
options: {
sky: window.viewer?.scene.environment,
name: "SquareWaterPool",
type: "square",
light: [0.7, 1, -0.3],
diameter: 5,
height: 5,
wallMode: "wall",
wallOpacity: 1,
useSceneRefraction: 1,
surfaceTransmittance: 0.6,
normalStrength: 0.5,
refractionStrength: 0.035,
},
},
{
key: "squareWaterPoolVolume",
image: "/static/images/resource/expansion/water/squareWaterPoolVolume.jpg",
name: cpt("extra.resource.expansion.Square Water Pool Volume"),
options: {
sky: window.viewer?.scene.environment,
name: "SquareWaterPoolVolume",
type: "square",
light: [0.7, 1, -0.3],
diameter: 5,
height: 5,
wallMode: "volume",
wallOpacity: 1,
useSceneRefraction: 1,
surfaceTransmittance: 0.6,
normalStrength: 0.5,
refractionStrength: 0.035,
},
},
],
};
const dragStore = useDragStore();
function getItemName(item: ExpansionItem): string {
return item.name?.value || item.name || "";
}
const filteredList = computed(() => {
const key = activeSubCategory.value;
const list = allList[key] || [];
return list.filter(item => getItemName(item).toLowerCase().includes(searchText.value.toLowerCase()));
});
function selectSubCategory(key: string) {
activeSubCategory.value = key;
}
function startPathDrawing(item: ExpansionItem) {
const options = JSON.parse(JSON.stringify(item.options || {}));
if (!options.name) {
options.name = getItemName(item);
}
if (pathDrawingStore.isActive) {
pathDrawingStore.cancel();
}
pathDrawingStore.start(options);
}
function getDefaultAddPosition(): number[] | undefined {
const container = window.viewer?.container;
if (!container) return undefined;
const centerX = container.offsetWidth / 2;
const centerY = container.offsetHeight / 2;
const centerPosition = screenToWorld(centerX, centerY);
return centerPosition.toArray();
}
async function handlePreview(item: ExpansionItem) {
previewInfo.name = getItemName(item);
previewInfo.visible = true;
await nextTick();
previewRef.value?.getPreviewer().then((previewer: Preview) => {
if (!previewer) return;
let object3d: Object3D | null = null;
switch (activeSubCategory.value) {
case "heatmap":
object3d = new Heatmap(item.options);
break;
case "path":
object3d = new Path(item.options);
break;
case "uipanel":
object3d = new UIPanel(item.options);
break;
}
if (!object3d) return;
previewer.scene.add(object3d);
const box3 = new Box3();
if (activeSubCategory.value === "heatmap") {
box3.set(new Vector3(-5, 0, -5), new Vector3(5, 5, 5));
} else {
box3.setFromObject(object3d);
}
previewer.modules.controls.fitToBox(box3, true);
});
}
function addToScene(item: ExpansionItem, position?: number[]) {
const options = JSON.parse(JSON.stringify(item.options || {}));
if (!options.name) {
options.name = getItemName(item);
}
const initPosition = position || getDefaultAddPosition();
if (initPosition) {
options.position = initPosition;
}
switch (activeSubCategory.value) {
case "heatmap": {
const heatmap = new Heatmap(options);
App.execute(new AddObjectCommand(heatmap), `Add Heatmap: ${options.name}`);
break;
}
case "path": {
startPathDrawing(item);
break;
}
case "uipanel": {
const uipanel = new UIPanel(options);
App.execute(new AddObjectCommand(uipanel), `Add UIPanel: ${options.name}`);
break;
}
case "water": {
Water.waterPool(options, options.position);
break;
}
}
}
function dragStart(item: ExpansionItem) {
dragStore.setData(item);
}
function dragEnd() {
if (dragStore.getActionTarget !== "addToScene" || dragStore.endArea !== "Scene") return;
if (activeSubCategory.value === "path") {
startPathDrawing(dragStore.getData);
dragStore.setActionTarget("");
return;
}
const position = screenToWorld(dragStore.endPosition.x, dragStore.endPosition.y).toArray();
addToScene(dragStore.getData, position);
dragStore.setActionTarget("");
}
</script>
<style scoped lang="less">
#extra-resource-expansion {
overflow-x: hidden;
.n-card {
cursor: pointer;
:deep(.n-card-cover) {
display: flex;
justify-content: center;
align-items: center;
img {
aspect-ratio: 1/1;
}
}
:deep(.n-card__content) {
padding: 0.3rem 0;
font-size: 0.65rem;
text-align: center;
.n-button {
font-size: 0.65rem !important;
}
}
}
}
</style>
@@ -29,7 +29,7 @@
<script setup lang="ts"> <script setup lang="ts">
import {computed, markRaw, reactive, ref, provide, useTemplateRef} from "vue"; import {computed, markRaw, reactive, ref, provide, useTemplateRef} from "vue";
import {cpt} from "@/language"; import {cpt} from "@/language";
import {Buoy, CameraAction, HeatMap, LocationCompany, LocationHeart,ChoroplethMap, ModelAlt, Opacity} from "@vicons/carbon"; import {Buoy, CameraAction, HeatMap, LocationCompany, LocationHeart,ChoroplethMap, EdtLoop, ModelAlt, Opacity} from "@vicons/carbon";
import CommonPreview from "@/components/preview/CommonPreview.vue"; import CommonPreview from "@/components/preview/CommonPreview.vue";
import Models from "@/views/editor/components/extraPane/resource/builtin/Models.vue"; import Models from "@/views/editor/components/extraPane/resource/builtin/Models.vue";
import Materials from "@/views/editor/components/extraPane/resource/builtin/Materials.vue"; import Materials from "@/views/editor/components/extraPane/resource/builtin/Materials.vue";
@@ -37,6 +37,7 @@ import Particles from "@/views/editor/components/extraPane/resource/builtin/Part
import Billboard from "@/views/editor/components/extraPane/resource/builtin/Billboard.vue"; import Billboard from "@/views/editor/components/extraPane/resource/builtin/Billboard.vue";
import HtmlPanel from "@/views/editor/components/extraPane/resource/builtin/HtmlPanel.vue"; import HtmlPanel from "@/views/editor/components/extraPane/resource/builtin/HtmlPanel.vue";
import Tiles from "@/views/editor/components/extraPane/resource/builtin/Tiles.vue"; import Tiles from "@/views/editor/components/extraPane/resource/builtin/Tiles.vue";
import Expansion from "@/views/editor/components/extraPane/resource/builtin/Expansion.vue";
import Lights from "@/views/editor/components/extraPane/resource/builtin/Lights.vue"; import Lights from "@/views/editor/components/extraPane/resource/builtin/Lights.vue";
import Cameras from "@/views/editor/components/extraPane/resource/builtin/Cameras.vue"; import Cameras from "@/views/editor/components/extraPane/resource/builtin/Cameras.vue";
@@ -48,6 +49,7 @@ const categories = ref([
{ key: 'billboard', name: cpt("home.assets.Billboard"), icon: markRaw(LocationHeart), component: markRaw(Billboard) }, { key: 'billboard', name: cpt("home.assets.Billboard"), icon: markRaw(LocationHeart), component: markRaw(Billboard) },
{ key: 'htmlPanel', name: cpt("extra.resource.Html panel"), icon: markRaw(LocationCompany), component: markRaw(HtmlPanel) }, { key: 'htmlPanel', name: cpt("extra.resource.Html panel"), icon: markRaw(LocationCompany), component: markRaw(HtmlPanel) },
{ key: 'tiles', name: "3D Tiles", icon: markRaw(ChoroplethMap), component: markRaw(Tiles) }, { key: 'tiles', name: "3D Tiles", icon: markRaw(ChoroplethMap), component: markRaw(Tiles) },
{ key: 'expansion', name: cpt("extra.resource.Expansion"), icon: markRaw(EdtLoop), component: markRaw(Expansion) },
{ key: 'lights', name: cpt("extra.resource.Light"), icon: markRaw(Buoy), component: markRaw(Lights) }, { key: 'lights', name: cpt("extra.resource.Light"), icon: markRaw(Buoy), component: markRaw(Lights) },
{ key: 'cameras', name: cpt("extra.resource.Camera"), icon: markRaw(CameraAction), component: markRaw(Cameras) }, { key: 'cameras', name: cpt("extra.resource.Camera"), icon: markRaw(CameraAction), component: markRaw(Cameras) },
]); ]);
+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,11 +14,15 @@ import {
MagicWandFilled, MagicWandFilled,
CloudSnow, CloudSnow,
HeatMap, HeatMap,
DataBase,
Opacity, Opacity,
ImageReference, ImageReference,
LocationHeart, LocationHeart,
LocationCompany, LocationCompany,
ChoroplethMap ChoroplethMap,
DirectionLoopLeft,
Gui,
ChartWaterfall
} from "@vicons/carbon"; } from "@vicons/carbon";
import { t } from "@/language"; import { t } from "@/language";
@@ -38,6 +42,11 @@ import SidebarParticle from "./sidebar/SidebarParticle.vue";
import SidebarBillboard from "./sidebar/SidebarBillboard.vue"; import SidebarBillboard from "./sidebar/SidebarBillboard.vue";
import SidebarHtmlPanel from "./sidebar/SidebarHtmlPanel.vue"; import SidebarHtmlPanel from "./sidebar/SidebarHtmlPanel.vue";
import Sidebar3DTiles from "./sidebar/Sidebar3DTiles.vue"; import Sidebar3DTiles from "./sidebar/Sidebar3DTiles.vue";
import SidebarHeatmap from "./sidebar/SidebarHeatmap.vue";
import SidebarPath from "./sidebar/SidebarPath.vue";
import SidebarUIPanel from "./sidebar/SidebarUIPanel.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>>([]);
@@ -67,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) },
] ]
@@ -96,6 +106,30 @@ function setTabs(object){
current.value = '3DTiles'; current.value = '3DTiles';
break; break;
case "Heatmap":
object3DTabs.push({ name: 'heatmap', icon: { text: 'Heatmap',color:"#A9575F", component: markRaw(HeatMap) }, component: markRaw(SidebarHeatmap) })
current.value = 'heatmap';
break;
case "Path":
object3DTabs.push({ name: 'path', icon: { text: 'Path',color:"#A9575F", component: markRaw(DirectionLoopLeft) }, component: markRaw(SidebarPath) })
current.value = 'path';
break;
case "WaterPool":
object3DTabs.push({ name: 'waterPool', icon: { text: 'Water pool',color:"#4aa3b5", component: markRaw(ChartWaterfall) }, component: markRaw(SidebarWaterPool) })
current.value = 'waterPool';
break;
case "UIPanel":
case "UIPanelBlock":
case "UIPanelText":
case "UIPanelInline":
case "UIPanelInlineBlock":
object3DTabs.push({ name: 'uipanel', icon: { text: 'UI panel',color:"#A9575F", component: markRaw(Gui) }, component: markRaw(SidebarUIPanel) })
current.value = 'uipanel';
break;
} }
} }
@@ -130,6 +164,30 @@ function setTabs(object){
current.value = 'object'; current.value = 'object';
} }
} }
if (current.value === 'heatmap') {
if (!(object && object.type === 'Heatmap')){
current.value = 'object';
}
}
if (current.value === 'path') {
if (!(object && object.type === 'Path')){
current.value = 'object';
}
}
if (current.value === 'waterPool') {
if (!(object && object.type === 'WaterPool')){
current.value = 'object';
}
}
if (current.value === 'uipanel') {
if (!(object && ["UIPanel", "UIPanelBlock", "UIPanelText", "UIPanelInline", "UIPanelInlineBlock"].includes(object.type))){
current.value = 'object';
}
}
} }
onMounted(()=>{ onMounted(()=>{
@@ -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>
@@ -3,26 +3,111 @@ 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 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"; import EsTip from "@/components/es/EsTip.vue";
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" },
};
// 兼容旧项目配置缺失 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")); const effectEnabled = ref(App.project.getKey("effect.enabled"));
onMounted(() => { 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);
@@ -31,7 +116,7 @@ function handleEffectEnabledChange(value:boolean){
<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>
@@ -0,0 +1,534 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { CaretForwardOutline } from "@vicons/ionicons5";
import { t } from "@/language";
import { App, Hooks, Utils, getDefaultHeatmapOptions } from "@astral3d/engine";
import CodeEditor from "@/components/code/CodeEditor.vue";
import EsTip from "@/components/es/EsTip.vue";
type GradientStep = {
id: string;
step: number;
color: string;
};
const defaultOptions = getDefaultHeatmapOptions();
const heatmapData = reactive(getDefaultHeatmapOptions() as any);
const opacityRange = ref<[number, number]>([heatmapData.heatmap.minOpacity, heatmapData.heatmap.maxOpacity]);
const isHeightMode = computed(() => heatmapData.mode === "height");
const pointsPreview = computed(() => JSON.stringify(heatmapData.data?.points || [], null, 2));
const pointsEditorShow = ref(false);
const pointsEditorRef = ref();
const pointsSource = ref("[]");
const pointsErrors = ref<string[]>([]);
const gradientSteps = ref<GradientStep[]>([]);
function getHeatmapObject() {
const object = App.selected;
if (!object) return null;
if (object.type !== "Heatmap" || !object.options) return null;
return object as any;
}
function updateUI() {
const object = getHeatmapObject();
if (!object) return;
Utils.deepAssign(heatmapData, object.options);
if (object.options.heatmap?.gradient) {
heatmapData.heatmap.gradient = { ...object.options.heatmap.gradient };
}
opacityRange.value = [heatmapData.heatmap.minOpacity, heatmapData.heatmap.maxOpacity];
ensureDataShape();
syncGradientSteps();
}
function ensureDataShape() {
if (!heatmapData.data) {
heatmapData.data = JSON.parse(JSON.stringify(defaultOptions.data));
}
if (!Array.isArray(heatmapData.data.points)) {
heatmapData.data.points = [];
}
if (!heatmapData.height) {
heatmapData.height = JSON.parse(JSON.stringify(defaultOptions.height));
}
if (!heatmapData.heatmap) {
heatmapData.heatmap = JSON.parse(JSON.stringify(defaultOptions.heatmap));
}
if (!heatmapData.heatmap.gradient || typeof heatmapData.heatmap.gradient !== "object" || Array.isArray(heatmapData.heatmap.gradient)) {
heatmapData.heatmap.gradient = JSON.parse(JSON.stringify(defaultOptions.heatmap.gradient));
}
}
function clampStep(value: number) {
if (!Number.isFinite(value)) return 0;
return Math.min(1, Math.max(0, value));
}
function normalizeGradientSteps(gradient: Record<string, string>) {
const steps: GradientStep[] = [];
let index = 0;
Object.entries(gradient).forEach(([step, color]) => {
const numericStep = Number(step);
if (!Number.isFinite(numericStep)) return;
steps.push({
id: `step-${index}-${numericStep}`,
step: clampStep(numericStep),
color: typeof color === "string" && color.trim() !== "" ? color : "#ffffff",
});
index += 1;
});
if (steps.length === 0) {
steps.push({ id: `step-${Date.now()}`, step: 0, color: "#ffffff" });
}
steps.sort((a, b) => a.step - b.step);
return steps;
}
function syncGradientSteps() {
ensureDataShape();
gradientSteps.value = normalizeGradientSteps(heatmapData.heatmap.gradient || {});
}
function buildGradientFromSteps() {
const gradient: Record<string, string> = {};
const sortedSteps = [...gradientSteps.value].sort((a, b) => a.step - b.step);
sortedSteps.forEach(step => {
const clamped = clampStep(Number(step.step));
gradient[clamped.toFixed(2)] = step.color || "#ffffff";
});
return gradient;
}
function updateGradient() {
ensureDataShape();
heatmapData.heatmap.gradient = buildGradientFromSteps();
update("heatmap");
}
function getNextGradientStep() {
const steps = gradientSteps.value
.map(step => step.step)
.filter(Number.isFinite)
.sort((a, b) => a - b);
if (steps.length === 0) return 0;
for (let i = 0; i < steps.length - 1; i += 1) {
const gap = steps[i + 1] - steps[i];
if (gap > 0.1) {
return Number(((steps[i] + steps[i + 1]) / 2).toFixed(2));
}
}
const last = steps[steps.length - 1];
if (last < 1) {
return Number(Math.min(1, last + 0.1).toFixed(2));
}
const first = steps[0];
return Number(Math.max(0, first - 0.1).toFixed(2));
}
function addGradientStep() {
const step = getNextGradientStep();
const fallbackColor = gradientSteps.value.length ? gradientSteps.value[gradientSteps.value.length - 1].color : "#ffffff";
gradientSteps.value.push({ id: `step-${Date.now()}-${Math.random().toString(16).slice(2)}`, step, color: fallbackColor });
updateGradient();
}
function removeGradientStep(index: number) {
if (gradientSteps.value.length <= 1) return;
gradientSteps.value.splice(index, 1);
updateGradient();
}
function updateGradientStep(index: number) {
const item = gradientSteps.value[index];
if (!item) return;
const step = clampStep(Number(item.step ?? 0));
item.step = step;
updateGradient();
}
function updateGradientColor(index: number, color: string | null) {
const item = gradientSteps.value[index];
if (!item) return;
item.color = color || "#ffffff";
updateGradient();
}
function requestRender(object) {
Hooks.useDispatchSignal("objectChanged", object);
}
function update(key: string) {
const object = getHeatmapObject();
if (!object) return;
const call = {
mode: () => {
object.setMode(heatmapData.mode);
},
size: () => {
object.setSize({ width: heatmapData.size.width, height: heatmapData.size.height });
},
heatmap: () => {
heatmapData.heatmap.minOpacity = opacityRange.value[0];
heatmapData.heatmap.maxOpacity = opacityRange.value[1];
object.updateHeatmapConfig(heatmapData.heatmap);
},
height: () => {
object.options.height = {
scale: heatmapData.height.scale,
segments: {
width: heatmapData.height.segments.width,
height: heatmapData.height.segments.height,
},
};
object.setSize({ width: heatmapData.size.width, height: heatmapData.size.height });
},
};
call[key]?.();
requestRender(object);
}
function updateData() {
const object = getHeatmapObject();
if (!object) return;
ensureDataShape();
object.setData({
max: heatmapData.data.max,
min: heatmapData.data.min,
points: heatmapData.data.points || [],
});
requestRender(object);
}
function openPointsEditor() {
pointsEditorShow.value = true;
}
function validatePoints(value: unknown) {
const errors: string[] = [];
const points: Array<{ x: number; y: number; value: number; radius?: number }> = [];
if (!Array.isArray(value)) {
return { errors: [t("layout.sider.heatmap.Points must be an array.")], points };
}
value.forEach((item, index) => {
if (!item || typeof item !== "object") {
errors.push(t("layout.sider.heatmap.Point[{index}] must be an object.", { index }));
return;
}
const point = item as Record<string, unknown>;
const x = Number(point.x);
const y = Number(point.y);
const v = Number(point.value);
const radius = point.radius === undefined ? undefined : Number(point.radius);
if (!Number.isFinite(x)) errors.push(t("layout.sider.heatmap.Point[{index}].x must be a number.", { index }));
if (!Number.isFinite(y)) errors.push(t("layout.sider.heatmap.Point[{index}].y must be a number.", { index }));
if (!Number.isFinite(v)) errors.push(t("layout.sider.heatmap.Point[{index}].value must be a number.", { index }));
if (radius !== undefined && !Number.isFinite(radius)) {
errors.push(t("layout.sider.heatmap.Point[{index}].radius must be a number.", { index }));
}
if (Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(v)) {
const normalized: { x: number; y: number; value: number; radius?: number } = { x, y, value: v };
if (radius !== undefined && Number.isFinite(radius)) {
normalized.radius = radius;
}
points.push(normalized);
}
});
return { errors, points };
}
function submitPoints(e: Event) {
e.stopPropagation();
pointsErrors.value = pointsEditorRef.value?.getErrors?.() || [];
if (pointsErrors.value.length > 0) return;
let parsed;
try {
parsed = JSON.parse(pointsSource.value);
} catch (error) {
pointsErrors.value = [String((error as Error).message || error)];
return;
}
const { errors, points } = validatePoints(parsed);
if (errors.length > 0) {
pointsErrors.value = errors;
return;
}
ensureDataShape();
heatmapData.data.points = points;
updateData();
pointsEditorShow.value = false;
}
watch(
() => pointsEditorShow.value,
show => {
if (!show) return;
ensureDataShape();
pointsSource.value = JSON.stringify(heatmapData.data.points || [], null, 2);
pointsErrors.value = [];
}
);
onMounted(() => {
Hooks.useAddSignal("objectSelected", updateUI);
updateUI();
});
onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", updateUI);
});
</script>
<template>
<n-collapse display-directive="show" :default-expanded-names="['basic', 'heatmap', 'data', 'height']">
<template #arrow>
<n-icon>
<CaretForwardOutline />
</n-icon>
</template>
<!-- 基础 -->
<n-collapse-item :title="t('extra.resource.expansion.Heat map')" name="basic">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.particle.Type") }}</span>
<n-select
v-model:value="heatmapData.mode"
size="small"
:options="[
{ label: t('extra.resource.expansion.Flat heatmap'), value: 'flat' },
{ label: t('extra.resource.expansion.Elevation heatmap'), value: 'height' },
]"
@update:value="update('mode')"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("other.Width") }}</span>
<EsInputNumber
v-model:value="heatmapData.size.width"
size="small"
:min="0.1"
:max="Infinity"
:decimal="2"
:show-button="false"
@change="update('size')"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("other.Height") }}</span>
<EsInputNumber
v-model:value="heatmapData.size.height"
size="small"
:min="0.1"
:max="Infinity"
:decimal="2"
:show-button="false"
@change="update('size')"
/>
</div>
</n-collapse-item>
<!-- 高程 -->
<n-collapse-item v-if="isHeightMode" :title="t('extra.resource.expansion.Elevation heatmap')" name="height">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.heatmap.Height Scale") }}</span>
<EsInputNumber v-model:value="heatmapData.height.scale" size="small" :min="0" :max="100" :decimal="2" :show-button="false" @change="update('height')" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.scene.Width segments") }}</span>
<EsInputNumber
v-model:value="heatmapData.height.segments.width"
size="small"
:min="1"
:max="512"
:decimal="0"
:show-button="false"
@change="update('height')"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.scene.Height segments") }}</span>
<EsInputNumber
v-model:value="heatmapData.height.segments.height"
size="small"
:min="1"
:max="512"
:decimal="0"
:show-button="false"
@change="update('height')"
/>
</div>
</n-collapse-item>
<!-- 热力图纹理参数 -->
<n-collapse-item :title="t('layout.sider.heatmap.Heatmap Texture')" name="heatmap">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.postProcessing.Radius") }}</span>
<EsInputNumber
v-model:value="heatmapData.heatmap.radius"
size="small"
:min="1"
:max="Infinity"
:decimal="0"
:show-button="false"
@change="update('heatmap')"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.heatmap.Blur") }}</span>
<n-slider v-model:value="heatmapData.heatmap.blur" :min="0" :max="1" :step="0.01" @update:value="update('heatmap')" />
</div>
<div class="sidebar-config-item">
<n-text>
<EsTip class="!justify-start" content="Alpha">
{{ t("layout.sider.heatmap.Controls the opacity of the darkest and brightest parts of a thermal texture") }}
</EsTip>
</n-text>
<n-slider v-model:value="opacityRange" range :min="0" :max="1" :step="0.01" @update:value="update('heatmap')" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.heatmap.Gradient") }}</span>
<div class="heatmap-gradient-actions">
<n-button size="tiny" tertiary @click="addGradientStep">{{ t("layout.sider.heatmap.Add") }}</n-button>
</div>
</div>
<div class="heatmap-gradient-item" v-for="(step, index) in gradientSteps" :key="step.id">
<EsInputNumber
v-model:value="step.step"
size="small"
:min="0"
:max="1"
:decimal="2"
:show-button="false"
class="heatmap-gradient-step"
@change="updateGradientStep(index)"
/>
<n-color-picker
v-model:value="step.color"
:show-alpha="false"
:modes="['hex']"
size="small"
class="heatmap-gradient-color"
:render-label="() => ''"
@update:value="updateGradientColor(index, $event)"
/>
<n-button size="tiny" quaternary :disabled="gradientSteps.length <= 1" @click="removeGradientStep(index)">
{{ t("layout.sider.heatmap.Remove") }}
</n-button>
</div>
</n-collapse-item>
<!-- 数据 -->
<n-collapse-item :title="t('layout.sider.heatmap.Data')" name="data">
<div class="sidebar-config-item">
<span>{{ t("other.Maximum") }}</span>
<EsInputNumber
v-model:value="heatmapData.data.max"
size="small"
:min="-Infinity"
:max="Infinity"
:decimal="2"
:show-button="false"
@change="updateData"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("other.Minimum") }}</span>
<EsInputNumber
v-model:value="heatmapData.data.min"
size="small"
:min="-Infinity"
:max="Infinity"
:decimal="2"
:show-button="false"
@change="updateData"
/>
</div>
<div class="sidebar-config-item">
<n-input class="!w-full" type="textarea" :value="pointsPreview" :rows="6" readonly round @click.stop="openPointsEditor" />
</div>
</n-collapse-item>
</n-collapse>
<n-modal
:show="pointsEditorShow"
@update:show="show => (pointsEditorShow = show)"
class="!w-60vw"
preset="dialog"
:title="t('layout.sider.heatmap.Heatmap Points')"
:showIcon="false"
>
<CodeEditor ref="pointsEditorRef" v-model:source="pointsSource" mode="json" class="!h-400px" />
<n-alert
:title="t('layout.sider.heatmap.Error')"
type="error"
v-if="pointsErrors.length"
closable
@close="pointsErrors = []"
class="absolute bottom-0 w-full z-9999"
>
<n-text depth="1" v-for="(error, index) in pointsErrors" :key="index" class="block">{{ error }}</n-text>
</n-alert>
<div class="float-right mt-10px">
<n-button size="small" @click="pointsEditorShow = false">{{ t("other.Cancel") }}</n-button>
<n-button class="ml-5px" type="primary" size="small" @click="submitPoints">{{ t("other.Ok") }}</n-button>
</div>
</n-modal>
</template>
<style scoped lang="less">
.heatmap-gradient-actions {
display: flex;
justify-content: flex-end;
}
.heatmap-gradient-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0 1rem 0.5rem 1rem;
width: calc(100% - 2rem);
}
.heatmap-gradient-step {
width: 70px;
}
.heatmap-gradient-color {
flex: 1;
}
:deep(.n-input__textarea) {
font-size: 12px;
}
</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>
@@ -0,0 +1,223 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive } from "vue";
import { CaretForwardOutline } from "@vicons/ionicons5";
import { t } from "@/language";
import { App, Hooks, Utils, getDefaultPathOptions } from "@astral3d/engine";
import EsInputNumber from "@/components/es/EsInputNumber.vue";
const defaultOptions = getDefaultPathOptions();
const pathData = reactive(getDefaultPathOptions() as any);
const isTubeMode = computed(() => pathData.mode === "tube");
function getPathObject() {
const object = App.selected;
if (!object) return null;
if (object.type !== "Path") return null;
return object as any;
}
function ensureDataShape() {
if (!pathData.path) {
pathData.path = JSON.parse(JSON.stringify(defaultOptions.path));
}
if (!pathData.tube) {
pathData.tube = JSON.parse(JSON.stringify(defaultOptions.tube));
}
if (!pathData.flow) {
pathData.flow = JSON.parse(JSON.stringify(defaultOptions.flow));
}
if (!Array.isArray(pathData.flow.direction)) {
pathData.flow.direction = defaultOptions.flow.direction.slice();
}
if (typeof pathData.cornerRadius !== "number") {
pathData.cornerRadius = defaultOptions.cornerRadius;
}
if (typeof pathData.cornerSplit !== "number") {
pathData.cornerSplit = defaultOptions.cornerSplit;
}
if (typeof pathData.closed !== "boolean") {
pathData.closed = defaultOptions.closed;
}
if (!pathData.mode) {
pathData.mode = defaultOptions.mode;
}
}
function updateUI() {
const object = getPathObject();
if (!object) return;
Utils.deepAssign(pathData, object.options);
ensureDataShape();
}
function updateOptions() {
const object = getPathObject();
if (!object) return;
object.updateOptions({
mode: pathData.mode,
closed: pathData.closed,
cornerRadius: pathData.cornerRadius,
cornerSplit: pathData.cornerSplit,
path: { ...pathData.path },
tube: { ...pathData.tube },
flow: {
...pathData.flow,
direction: Array.isArray(pathData.flow.direction) ? pathData.flow.direction.slice() : defaultOptions.flow.direction.slice(),
},
});
Hooks.useDispatchSignal("objectChanged", object);
}
onMounted(() => {
Hooks.useAddSignal("objectSelected", updateUI);
updateUI();
});
onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", updateUI);
});
</script>
<template>
<n-collapse display-directive="show" :default-expanded-names="['base', 'shape', 'flow']">
<template #arrow>
<n-icon>
<CaretForwardOutline />
</n-icon>
</template>
<n-collapse-item :title="t('layout.sider.path.Path Params')" name="base">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Type") }}</span>
<n-select
v-model:value="pathData.mode"
size="small"
:options="[
{ label: t('layout.sider.path.Ribbon'), value: 'path' },
{ label: t('layout.sider.path.Tube'), value: 'tube' },
]"
@update:value="updateOptions"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Corner Radius") }}</span>
<EsInputNumber v-model:value="pathData.cornerRadius" size="small" :min="0" :max="100" :decimal="2" :show-button="false" @change="updateOptions" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Corner Split") }}</span>
<EsInputNumber v-model:value="pathData.cornerSplit" size="small" :min="0" :max="50" :decimal="0" :show-button="false" @change="updateOptions" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.scene.Closed") }}</span>
<n-checkbox size="small" v-model:checked="pathData.closed" @update:checked="updateOptions" />
</div>
</n-collapse-item>
<n-collapse-item v-if="!isTubeMode" :title="t('layout.sider.path.Ribbon Params')" name="shape">
<div class="sidebar-config-item">
<span>{{ t("other.Width") }}</span>
<EsInputNumber v-model:value="pathData.path.width" size="small" :min="0.01" :max="100" :decimal="2" :show-button="false" @change="updateOptions" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Progress") }}</span>
<n-slider v-model:value="pathData.path.progress" :min="0" :max="1" :step="0.01" @update:value="updateOptions" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Arrow") }}</span>
<n-switch size="small" v-model:value="pathData.path.arrow" @update:value="updateOptions" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Side") }}</span>
<n-select
v-model:value="pathData.path.side"
size="small"
:options="[
{ label: t('layout.sider.path.Both'), value: 'both' },
{ label: t('layout.sider.path.Left'), value: 'left' },
{ label: t('layout.sider.path.Right'), value: 'right' },
]"
@update:value="updateOptions"
/>
</div>
</n-collapse-item>
<n-collapse-item v-else :title="t('layout.sider.path.Tube Params')" name="shape">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.scene.Radius") }}</span>
<EsInputNumber v-model:value="pathData.tube.radius" size="small" :min="0.01" :max="100" :decimal="2" :show-button="false" @change="updateOptions" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.scene['Radial segments']") }}</span>
<EsInputNumber v-model:value="pathData.tube.radialSegments" size="small" :min="2" :max="64" :decimal="0" :show-button="false" @change="updateOptions" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Progress") }}</span>
<n-slider v-model:value="pathData.tube.progress" :min="0" :max="1" :step="0.01" @update:value="updateOptions" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Start Rad") }}</span>
<EsInputNumber v-model:value="pathData.tube.startRad" size="small" :min="-6.28" :max="6.28" :decimal="2" :show-button="false" @change="updateOptions" />
</div>
</n-collapse-item>
<n-collapse-item :title="t('layout.sider.path.Flow Params')" name="flow">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Flow") }}</span>
<n-switch size="small" v-model:value="pathData.flow.enabled" @update:value="updateOptions" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Flow Speed") }}</span>
<EsInputNumber
v-model:value="pathData.flow.speed"
size="small"
:min="-10"
:max="10"
:decimal="2"
:show-button="false"
:disabled="!pathData.flow.enabled"
@change="updateOptions"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.path.Flow Direction") }}</span>
<div class="flex">
<EsInputNumber
v-model:value="pathData.flow.direction[0]"
size="small"
:min="-1"
:max="1"
:decimal="2"
:show-button="false"
:disabled="!pathData.flow.enabled"
@change="updateOptions"
/>
<EsInputNumber
v-model:value="pathData.flow.direction[1]"
size="small"
:min="-1"
:max="1"
:decimal="2"
:show-button="false"
:disabled="!pathData.flow.enabled"
@change="updateOptions"
/>
</div>
</div>
</n-collapse-item>
</n-collapse>
</template>
<style scoped lang="less"></style>
@@ -0,0 +1,256 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { CaretForwardOutline } from "@vicons/ionicons5";
import { t } from "@/language";
import { App, Hooks, UIPanelBlock, UIPanelText, Utils } from "@astral3d/engine";
import SidebarUIPanelBlock from "./uipanel/Sidebar.UIPanel.Block.vue";
import SidebarUIPanelText from "./uipanel/Sidebar.UIPanel.Text.vue";
type UIPanelFormType = "Block" | "Text";
type UIPanelStateMap = Record<string, Record<string, any>>;
const UIPANEL_TYPES = new Set(["UIPanel", "UIPanelBlock", "UIPanelText", "UIPanelInline", "UIPanelInlineBlock"]);
// 只存储是否有选中对象的状态,3D 对象完全脱离 Vue 响应式系统
const hasSelection = ref(false);
const formData = reactive({
type: "Block" as UIPanelFormType,
props: {} as Record<string, any>,
states: {} as UIPanelStateMap,
});
function getUIPanelContext() {
const object = App.selected as any;
if (!object || !UIPANEL_TYPES.has(object.type)) return null;
const panel = Utils.findUIPanelRoot(object);
if (!panel) return null;
return { panel, object };
}
function normalizeFormData() {
if (!formData.props || typeof formData.props !== "object") formData.props = {};
if (!formData.states || typeof formData.states !== "object") formData.states = {};
if (formData.type === "Block") {
if (!Number.isFinite(formData.props.width)) formData.props.width = 1;
if (!Number.isFinite(formData.props.height)) formData.props.height = 0.4;
if (!Number.isFinite(formData.props.padding)) formData.props.padding = 0.04;
if (!Number.isFinite(formData.props.margin)) formData.props.margin = 0;
if (!Number.isFinite(formData.props.offset)) formData.props.offset = 0.005;
if (!Number.isFinite(formData.props.backgroundOpacity)) formData.props.backgroundOpacity = 0.8;
if (!Number.isFinite(formData.props.borderRadius)) formData.props.borderRadius = 0.04;
if (!Number.isFinite(formData.props.borderWidth)) formData.props.borderWidth = 0;
if (!formData.props.backgroundColor) formData.props.backgroundColor = "#2a2a2a";
if (!formData.props.borderColor) formData.props.borderColor = "#ffffff";
if (!formData.props.flexDirection) formData.props.flexDirection = "column";
if (!formData.props.justifyContent) formData.props.justifyContent = "center";
if (!formData.props.alignItems) formData.props.alignItems = "center";
if (!formData.props.backgroundSize) formData.props.backgroundSize = "cover";
if (!formData.props.overflow) formData.props.overflow = "visible";
if (!formData.props.bestFit) formData.props.bestFit = "none";
} else {
if (!formData.props.textContent) formData.props.textContent = "";
// Text 类型的 width/height 使用 "auto" 表示自动尺寸,
// 若 props 中未定义或为 "auto",则保持 "auto";仅当为有限数值时显示具体值
if (formData.props.width !== "auto" && !Number.isFinite(formData.props.width)) formData.props.width = "auto";
if (formData.props.height !== "auto" && !Number.isFinite(formData.props.height)) formData.props.height = "auto";
if (!Number.isFinite(formData.props.fontSize)) formData.props.fontSize = 0.05;
if (!Number.isFinite(formData.props.fontOpacity)) formData.props.fontOpacity = 1;
if (!Number.isFinite(formData.props.letterSpacing)) formData.props.letterSpacing = 0;
if (!Number.isFinite(formData.props.lineHeight)) formData.props.lineHeight = 1.2;
if (!Number.isFinite(formData.props.padding)) formData.props.padding = 0;
if (!Number.isFinite(formData.props.margin)) formData.props.margin = 0;
if (!Number.isFinite(formData.props.offset)) formData.props.offset = 0.005;
if (!formData.props.color) formData.props.color = "#ffffff";
if (!formData.props.textAlign) formData.props.textAlign = "center";
if (!formData.props.fontKerning) formData.props.fontKerning = "normal";
if (!formData.props.whiteSpace) formData.props.whiteSpace = "pre-line";
if (!formData.props.fontSmooth) formData.props.fontSmooth = "antialiased";
if (!formData.props.breakOn) formData.props.breakOn = "- ,.:?!\n";
}
}
function updateUI() {
const context = getUIPanelContext();
if (!context) {
hasSelection.value = false;
return;
}
hasSelection.value = true;
const options = context.object.options || {};
formData.type = context.object.type === "UIPanelText" || context.object.type === "UIPanelInline" ? "Text" : "Block";
formData.props = JSON.parse(JSON.stringify(options.props || {}));
formData.states = JSON.parse(JSON.stringify(options.states || {}));
normalizeFormData();
}
const canAddNodes = computed(() => {
if (!hasSelection.value) return false;
const object = App.selected as any;
return object && (object.type === "UIPanel" || object.type === "UIPanelBlock");
});
function getAddParent() {
const object = App.selected as any;
if (object && (object.type === "UIPanel" || object.type === "UIPanelBlock")) {
return object;
}
return Utils.findUIPanelRoot(object);
}
function createBlock() {
return new UIPanelBlock({
name: "Block",
props: {
width: 1,
height: 0.4,
padding: 0.04,
backgroundColor: "#2a2a2a",
backgroundOpacity: 0.8,
borderRadius: 0.04,
justifyContent: "center",
alignItems: "center",
},
});
}
function createText() {
return new UIPanelText({
name: "Text",
props: {
textContent: "",
fontSize: 0.05,
color: "#ffffff",
textAlign: "center",
},
});
}
function addNode(type: "Block" | "Text") {
const parent = getAddParent();
if (!parent) return;
const node = type === "Block" ? createBlock() : createText();
parent.add?.(node);
Hooks.useDispatchSignal("objectChanged", parent);
App.select(node);
}
function updateProps(patch: Record<string, any>) {
const object = App.selected as any;
if (!object) return;
object.setProps?.(patch);
Hooks.useDispatchSignal("objectChanged", object);
}
function updateStates() {
const object = App.selected as any;
if (!object) return;
object.setStates?.(formData.states);
Hooks.useDispatchSignal("objectChanged", object);
}
function isStateEnabled(key: string) {
return Boolean(formData.states && formData.states[key]);
}
function toggleState(key: string, enabled: boolean) {
if (enabled) {
if (!formData.states) formData.states = {};
if (!formData.states[key]) {
// 首次启用状态时,根据类型设置不同的默认值
if (formData.type === "Block") {
// Block: 使用当前背景色和透明度 1
formData.states[key] = {
backgroundColor: formData.props.backgroundColor || "#2a2a2a",
backgroundOpacity: 1,
};
} else {
// Text: 使用当前文字颜色
formData.states[key] = {
color: formData.props.color || "#ffffff",
};
}
}
} else if (formData.states) {
delete formData.states[key];
if (Object.keys(formData.states).length === 0) {
formData.states = {};
}
}
updateStates();
}
function updateStateProps(key: string, patch: Record<string, any>) {
if (!formData.states) formData.states = {};
if (!formData.states[key]) formData.states[key] = {};
formData.states[key] = { ...formData.states[key], ...patch };
updateStates();
}
onMounted(() => {
Hooks.useAddSignal("objectSelected", updateUI);
Hooks.useAddSignal("objectChanged", updateUI);
updateUI();
});
onBeforeUnmount(() => {
Hooks.useRemoveSignal("objectSelected", updateUI);
Hooks.useRemoveSignal("objectChanged", updateUI);
});
</script>
<template>
<div class="uipanel-actions">
<n-space size="small" align="center" justify="center">
<n-button v-if="canAddNodes" @click="addNode('Block')">{{ t("layout.sider.uipanel['Add Block']") }}</n-button>
<n-button v-if="canAddNodes" @click="addNode('Text')">{{ t("layout.sider.uipanel['Add Text']") }}</n-button>
</n-space>
</div>
<n-collapse display-directive="show" :default-expanded-names="['node']">
<template #arrow>
<n-icon>
<CaretForwardOutline />
</n-icon>
</template>
<n-collapse-item :title="t('layout.sider.uipanel.Node')" name="node">
<div v-if="!hasSelection" class="text-12px opacity-70">
{{ t("layout.sider.uipanel['Select a node to edit']") }}
</div>
<template v-else>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Type") }}</span>
<div class="text-center">{{ formData.type }}</div>
</div>
<SidebarUIPanelBlock
v-if="formData.type === 'Block'"
:form-data="formData"
:is-state-enabled="isStateEnabled"
:toggle-state="toggleState"
:update-props="updateProps"
:update-state-props="updateStateProps"
/>
<SidebarUIPanelText
v-else
:form-data="formData"
:is-state-enabled="isStateEnabled"
:toggle-state="toggleState"
:update-props="updateProps"
:update-state-props="updateStateProps"
/>
</template>
</n-collapse-item>
</n-collapse>
</template>
<style scoped lang="less">
.uipanel-actions {
margin-bottom: 0.5rem;
}
</style>
@@ -0,0 +1,687 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref, toRaw } from "vue";
import { CaretForwardOutline } from "@vicons/ionicons5";
import { Color, CubeTexture, Texture } from "three";
import { App, Hooks, Water, WaterPool } from "@astral3d/engine";
import EsInputNumber from "@/components/es/EsInputNumber.vue";
import EsTexture from "@/components/es/EsTexture.vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const poolData = reactive({
type: "cylinder",
light: { x: 0.7, y: 1, z: -0.3 },
wallMode: "volume",
wallOpacity: 0.3,
disturbanceEnabled: true,
disturbanceMode: "drag",
disturbanceDropsPerStep: 1,
disturbanceRadiusMin: 0.01,
disturbanceRadiusMax: 0.05,
disturbanceStrengthMin: 0.005,
disturbanceStrengthMax: 0.02,
disturbanceTravelRadius: 0.85,
disturbanceDriftSpeed: 0.015,
disturbanceJitter: 0.01,
disturbanceSpread: 0.08,
useSceneRefraction: true,
surfaceTransmittance: 0.65,
normalStrength: 0.6,
refractionStrength: 0.035,
simulationSize: 256,
causticsSize: 1024,
waterSegments: 200,
wallSegments: 64,
diameter: 2,
width: 2,
depth: 2,
height: 2,
});
const tilesTexture = ref<Color | Texture | CubeTexture | null>(null);
const volumeColor = ref("#2c5965");
const surfaceColor = ref("#4a8aa0");
const rebuilding = ref(false);
let qualityTimer: number | null = null;
const isCylinder = computed(() => poolData.type === "cylinder");
function getWaterPoolObject() {
const object = App.selected;
if (!object) return null;
if (object.type !== "WaterPool") return null;
return object as any;
}
function syncColors(object) {
const volume = object.volumeColor instanceof Color ? object.volumeColor : new Color(object.volumeColor ?? 0x2c5965);
const surface = object.surfaceColor instanceof Color ? object.surfaceColor : new Color(object.surfaceColor ?? volume.getHex());
volumeColor.value = `#${volume.getHexString()}`;
surfaceColor.value = `#${surface.getHexString()}`;
}
function updateUI() {
const object = getWaterPoolObject();
if (!object) return;
poolData.type = object.poolType === "cylinder" ? "cylinder" : "square";
poolData.light.x = object.light?.[0] ?? 0.7;
poolData.light.y = object.light?.[1] ?? 1;
poolData.light.z = object.light?.[2] ?? -0.3;
poolData.wallMode = object.wallMode ?? (object.hasWall ? "wall" : "none");
poolData.wallOpacity = Number.isFinite(object.wallOpacity) ? object.wallOpacity : 1;
const disturbance = object._disturbance || null;
if (disturbance) {
poolData.disturbanceEnabled = true;
poolData.disturbanceMode = disturbance.mode === "uniform" ? "uniform" : "drag";
poolData.disturbanceDropsPerStep = disturbance.dropsPerStep ?? 1;
poolData.disturbanceRadiusMin = disturbance.radiusMin ?? 0.01;
poolData.disturbanceRadiusMax = disturbance.radiusMax ?? 0.05;
poolData.disturbanceStrengthMin = disturbance.strengthMin ?? 0.005;
poolData.disturbanceStrengthMax = disturbance.strengthMax ?? 0.02;
poolData.disturbanceTravelRadius = disturbance.travelRadius ?? 0.85;
poolData.disturbanceDriftSpeed = disturbance.driftSpeed ?? 0.015;
poolData.disturbanceJitter = disturbance.jitter ?? 0.01;
poolData.disturbanceSpread = disturbance.spread ?? 0.08;
} else {
poolData.disturbanceEnabled = true;
poolData.disturbanceMode = "drag";
poolData.disturbanceDropsPerStep = 1;
poolData.disturbanceRadiusMin = 0.01;
poolData.disturbanceRadiusMax = 0.05;
poolData.disturbanceStrengthMin = 0.005;
poolData.disturbanceStrengthMax = 0.02;
poolData.disturbanceTravelRadius = 0.85;
poolData.disturbanceDriftSpeed = 0.015;
poolData.disturbanceJitter = 0.01;
poolData.disturbanceSpread = 0.08;
}
poolData.useSceneRefraction = !!object.useSceneRefraction;
poolData.surfaceTransmittance = Number.isFinite(object.surfaceTransmittance) ? object.surfaceTransmittance : 0.65;
poolData.normalStrength = Number.isFinite(object.normalStrength) ? object.normalStrength : 0.6;
poolData.refractionStrength = Number.isFinite(object.refractionStrength) ? object.refractionStrength : 0.035;
poolData.simulationSize = object.simulationSize ?? 256;
poolData.causticsSize = object.causticsSize ?? 1024;
poolData.waterSegments = object.waterSegments ?? 200;
poolData.wallSegments = object.wallSegments ?? 64;
poolData.height = Number.isFinite(object.height) ? object.height : (object._poolHeight ?? 2);
if (poolData.type === "cylinder") {
poolData.diameter = Number.isFinite(object.diameter) ? object.diameter : object._radius ? object._radius * 2 : 2;
} else {
poolData.width = Number.isFinite(object.width) ? object.width : 2;
poolData.depth = Number.isFinite(object.depth) ? object.depth : 2;
}
tilesTexture.value = object.tiles || null;
syncColors(object);
applyDisturbance();
}
function requestRender(object) {
Hooks.useDispatchSignal("objectChanged", object);
}
function applyLight() {
const object = getWaterPoolObject();
if (!object) return;
const light = [poolData.light.x, poolData.light.y, poolData.light.z];
object.light = light;
const materials = typeof object._getWaterMaterials === "function" ? object._getWaterMaterials() : [];
materials.forEach(material => {
if (material?.uniforms?.light) material.uniforms.light.value = light;
});
const wallMaterial = object.wall?._material;
if (wallMaterial?.uniforms?.light) wallMaterial.uniforms.light.value = light;
const causticsMaterial = typeof object._getCausticsMaterial === "function" ? object._getCausticsMaterial() : null;
if (causticsMaterial?.uniforms?.light) causticsMaterial.uniforms.light.value = light;
requestRender(object);
}
function applySize() {
const object = getWaterPoolObject();
if (!object) return;
if (poolData.type === "cylinder") {
object.setSize({ diameter: poolData.diameter, height: poolData.height });
} else {
object.setSize({ width: poolData.width, depth: poolData.depth, height: poolData.height });
}
requestRender(object);
}
function applyTiles() {
const object = getWaterPoolObject();
if (!object) return;
if (poolData.wallMode !== "wall") return;
const texture = (toRaw(tilesTexture.value) as any) || null;
object.tiles = texture;
const materials = typeof object._getWaterMaterials === "function" ? object._getWaterMaterials() : [];
materials.forEach(material => {
if (material?.uniforms?.tiles) material.uniforms.tiles.value = texture;
if (material?.uniforms?.useTiles) material.uniforms.useTiles.value = texture ? 1 : 0;
if (material?.uniforms?.tilesColor) material.uniforms.tilesColor.value = object.volumeColor;
});
const wallMaterial = object.wall?._material;
if (wallMaterial?.uniforms?.tiles) wallMaterial.uniforms.tiles.value = texture;
if (wallMaterial?.uniforms?.useTiles) wallMaterial.uniforms.useTiles.value = texture ? 1 : 0;
if (wallMaterial?.uniforms?.tilesColor) wallMaterial.uniforms.tilesColor.value = object.volumeColor;
requestRender(object);
}
function applyColors() {
const object = getWaterPoolObject();
if (!object) return;
object.volumeColor = new Color(volumeColor.value || "#2c5965");
object.surfaceColor = new Color(surfaceColor.value || volumeColor.value || "#2c5965");
if (typeof object._applyUniforms === "function") {
object._applyUniforms();
}
requestRender(object);
}
function applyRefraction() {
const object = getWaterPoolObject();
if (!object) return;
object.useSceneRefraction = poolData.useSceneRefraction ? 1 : 0;
object.surfaceTransmittance = poolData.surfaceTransmittance;
object.normalStrength = poolData.normalStrength;
object.refractionStrength = poolData.refractionStrength;
if (typeof object._applyUniforms === "function") {
object._applyUniforms();
}
requestRender(object);
}
function applyDisturbance() {
const object = getWaterPoolObject();
if (!object) return;
if (!poolData.disturbanceEnabled) {
object.stopDisturbance?.();
requestRender(object);
return;
}
object.startDisturbance?.({
mode: poolData.disturbanceMode === "uniform" ? "uniform" : "drag",
dropsPerStep: Math.max(1, Math.round(poolData.disturbanceDropsPerStep)),
radiusMin: Math.max(0, poolData.disturbanceRadiusMin),
radiusMax: Math.max(poolData.disturbanceRadiusMin, poolData.disturbanceRadiusMax),
strengthMin: poolData.disturbanceStrengthMin,
strengthMax: poolData.disturbanceStrengthMax,
travelRadius: Math.max(0, poolData.disturbanceTravelRadius),
driftSpeed: Math.max(0, poolData.disturbanceDriftSpeed),
jitter: Math.max(0, poolData.disturbanceJitter),
spread: Math.max(0, poolData.disturbanceSpread),
});
requestRender(object);
}
function applyWallOpacity() {
const object = getWaterPoolObject();
if (!object) return;
object.wallOpacity = poolData.wallOpacity;
if (typeof object._applyUniforms === "function") {
object._applyUniforms();
}
const wallMaterial = object.wall?._material;
if (wallMaterial) {
const transparent = object.wallOpacity < 1;
wallMaterial.transparent = transparent;
wallMaterial.depthWrite = !transparent;
wallMaterial.needsUpdate = true;
}
requestRender(object);
}
function buildOptionsFromUI(object) {
const options: any = {
name: object?.name || "WaterPool",
type: poolData.type,
light: [poolData.light.x, poolData.light.y, poolData.light.z],
tiles: poolData.wallMode === "wall" ? (toRaw(tilesTexture.value) as any) || null : object?.tiles || null,
sky: object?.sky || null,
wallMode: poolData.wallMode,
wall: poolData.wallMode !== "none",
wallOpacity: poolData.wallOpacity,
useSceneRefraction: poolData.useSceneRefraction ? 1 : 0,
surfaceTransmittance: poolData.surfaceTransmittance,
normalStrength: poolData.normalStrength,
refractionStrength: poolData.refractionStrength,
volumeColor: new Color(volumeColor.value || "#2c5965"),
surfaceColor: new Color(surfaceColor.value || volumeColor.value || "#2c5965"),
simulationSize: Math.max(16, Math.round(poolData.simulationSize)),
causticsSize: Math.max(64, Math.round(poolData.causticsSize)),
waterSegments: Math.max(1, Math.round(poolData.waterSegments)),
wallSegments: Math.max(3, Math.round(poolData.wallSegments)),
};
if (poolData.type === "cylinder") {
options.diameter = poolData.diameter;
} else {
options.width = poolData.width;
options.depth = poolData.depth;
}
options.height = poolData.height;
if (object && Number.isFinite(object._renderOrderBase)) {
options.renderOrder = object._renderOrderBase;
}
return options;
}
async function rebuildPool() {
const object = getWaterPoolObject();
if (!object || rebuilding.value) return;
rebuilding.value = true;
try {
const options = buildOptionsFromUI(object);
const newPool = new WaterPool(options);
await newPool.loaded;
newPool.name = object.name;
newPool.position.copy(object.position);
newPool.quaternion.copy(object.quaternion);
newPool.scale.copy(object.scale);
newPool.visible = object.visible;
newPool.layers.mask = object.layers.mask;
newPool.userData = { ...object.userData };
const parent = object.parent ?? App.scene;
if (parent) {
const oldIndex = parent.children.indexOf(object);
parent.add(newPool);
const newIndex = parent.children.indexOf(newPool);
if (oldIndex >= 0 && newIndex >= 0 && newIndex !== oldIndex) {
parent.children.splice(newIndex, 1);
parent.children.splice(oldIndex, 0, newPool);
}
}
Water.registerRendering();
object.parent?.remove(object);
App.select(newPool);
requestRender(newPool);
} catch (error) {
// 避免阻断侧边栏操作
console.warn("WaterPool 重建失败:", error);
} finally {
rebuilding.value = false;
}
}
function applyWallMode() {
rebuildPool();
}
function applyType() {
rebuildPool();
}
function applyQuality() {
rebuildPool();
}
function scheduleQualityRebuild() {
if (qualityTimer !== null) {
clearTimeout(qualityTimer);
}
qualityTimer = window.setTimeout(() => {
qualityTimer = null;
applyQuality();
}, 600);
}
onMounted(() => {
Hooks.useAddSignal("objectSelected", updateUI);
updateUI();
});
onBeforeUnmount(() => {
if (qualityTimer !== null) {
clearTimeout(qualityTimer);
qualityTimer = null;
}
Hooks.useRemoveSignal("objectSelected", updateUI);
});
</script>
<template>
<n-collapse display-directive="show" :default-expanded-names="['basic', 'size', 'light', 'color', 'disturbance', 'refraction', 'texture', 'quality']">
<template #arrow>
<n-icon>
<CaretForwardOutline />
</n-icon>
</template>
<n-collapse-item :title="t('layout.sider.waterPool.Basic')" name="basic">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.Type") }}</span>
<n-select
v-model:value="poolData.type"
size="small"
:options="[
{ label: t('layout.sider.waterPool.Cylinder'), value: 'cylinder' },
{ label: t('layout.sider.waterPool.Square'), value: 'square' },
]"
:disabled="rebuilding"
@update:value="applyType"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.WallMode") }}</span>
<n-select
v-model:value="poolData.wallMode"
size="small"
:options="[
{ label: t('layout.sider.waterPool.WallNone'), value: 'none' },
{ label: t('layout.sider.waterPool.Wall'), value: 'wall' },
{ label: t('layout.sider.waterPool.Volume'), value: 'volume' },
]"
:disabled="rebuilding"
@update:value="applyWallMode"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.WallOpacity") }}</span>
<n-slider
v-model:value="poolData.wallOpacity"
:min="0"
:max="1"
:step="0.01"
:disabled="poolData.wallMode === 'none'"
@update:value="applyWallOpacity"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.ScreenRefraction") }}</span>
<n-switch size="small" v-model:value="poolData.useSceneRefraction" @update:value="applyRefraction" />
</div>
</n-collapse-item>
<n-collapse-item :title="t('layout.sider.waterPool.Size')" name="size">
<div class="sidebar-config-item" v-if="isCylinder">
<span>{{ t("layout.sider.waterPool.Diameter") }}</span>
<EsInputNumber
v-model:value="poolData.diameter"
size="small"
:min="0.1"
:max="Infinity"
:decimal="2"
:show-button="false"
@change="applySize"
/>
</div>
<div class="sidebar-config-item" v-else>
<span>{{ t("layout.sider.waterPool.Width") }}</span>
<EsInputNumber v-model:value="poolData.width" size="small" :min="0.1" :max="Infinity" :decimal="2" :show-button="false" @change="applySize" />
</div>
<div class="sidebar-config-item" v-if="!isCylinder">
<span>{{ t("layout.sider.waterPool.Depth") }}</span>
<EsInputNumber v-model:value="poolData.depth" size="small" :min="0.1" :max="Infinity" :decimal="2" :show-button="false" @change="applySize" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.Height") }}</span>
<EsInputNumber v-model:value="poolData.height" size="small" :min="0.1" :max="Infinity" :decimal="2" :show-button="false" @change="applySize" />
</div>
</n-collapse-item>
<n-collapse-item :title="t('layout.sider.waterPool.Light')" name="light">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.Direction") }}</span>
<div class="flex gap-6px">
<EsInputNumber v-model:value="poolData.light.x" size="small" :min="-1" :max="1" :decimal="2" :show-button="false" @change="applyLight" />
<EsInputNumber v-model:value="poolData.light.y" size="small" :min="-1" :max="1" :decimal="2" :show-button="false" @change="applyLight" />
<EsInputNumber v-model:value="poolData.light.z" size="small" :min="-1" :max="1" :decimal="2" :show-button="false" @change="applyLight" />
</div>
</div>
</n-collapse-item>
<n-collapse-item :title="t('layout.sider.waterPool.Color')" name="color">
<div class="sidebar-config-item" v-if="poolData.wallMode !== 'none'">
<span>{{ t("layout.sider.waterPool.VolumeColor") }}</span>
<n-color-picker v-model:value="volumeColor" :show-alpha="false" :modes="['hex']" size="small" @update:value="applyColors" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.SurfaceColor") }}</span>
<n-color-picker v-model:value="surfaceColor" :show-alpha="false" :modes="['hex']" size="small" @update:value="applyColors" />
</div>
</n-collapse-item>
<n-collapse-item :title="t('layout.sider.waterPool.Disturbance')" name="disturbance">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.Enabled") }}</span>
<n-switch size="small" v-model:value="poolData.disturbanceEnabled" @update:value="applyDisturbance" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.Mode") }}</span>
<n-select
v-model:value="poolData.disturbanceMode"
size="small"
:options="[
{ label: t('layout.sider.waterPool.ModeDrag'), value: 'drag' },
{ label: t('layout.sider.waterPool.ModeUniform'), value: 'uniform' },
]"
@update:value="applyDisturbance"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.DropsPerStep") }}</span>
<EsInputNumber
v-model:value="poolData.disturbanceDropsPerStep"
size="small"
:min="1"
:max="20"
:decimal="0"
:show-button="false"
@change="applyDisturbance"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.RadiusMin") }}</span>
<EsInputNumber
v-model:value="poolData.disturbanceRadiusMin"
size="small"
:min="0"
:max="1"
:decimal="3"
:show-button="false"
@change="applyDisturbance"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.RadiusMax") }}</span>
<EsInputNumber
v-model:value="poolData.disturbanceRadiusMax"
size="small"
:min="0"
:max="1"
:decimal="3"
:show-button="false"
@change="applyDisturbance"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.StrengthMin") }}</span>
<EsInputNumber
v-model:value="poolData.disturbanceStrengthMin"
size="small"
:min="-1"
:max="1"
:decimal="3"
:show-button="false"
@change="applyDisturbance"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.StrengthMax") }}</span>
<EsInputNumber
v-model:value="poolData.disturbanceStrengthMax"
size="small"
:min="-1"
:max="1"
:decimal="3"
:show-button="false"
@change="applyDisturbance"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.TravelRadius") }}</span>
<EsInputNumber
v-model:value="poolData.disturbanceTravelRadius"
size="small"
:min="0"
:max="2"
:decimal="3"
:show-button="false"
@change="applyDisturbance"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.DriftSpeed") }}</span>
<EsInputNumber
v-model:value="poolData.disturbanceDriftSpeed"
size="small"
:min="0"
:max="1"
:decimal="3"
:show-button="false"
@change="applyDisturbance"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.Jitter") }}</span>
<EsInputNumber
v-model:value="poolData.disturbanceJitter"
size="small"
:min="0"
:max="1"
:decimal="3"
:show-button="false"
@change="applyDisturbance"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.Spread") }}</span>
<EsInputNumber
v-model:value="poolData.disturbanceSpread"
size="small"
:min="0"
:max="1"
:decimal="3"
:show-button="false"
@change="applyDisturbance"
/>
</div>
</n-collapse-item>
<n-collapse-item :title="t('layout.sider.waterPool.Refraction')" name="refraction">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.SurfaceTransmittance") }}</span>
<n-slider v-model:value="poolData.surfaceTransmittance" :min="0" :max="1" :step="0.01" @update:value="applyRefraction" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.NormalStrength") }}</span>
<n-slider v-model:value="poolData.normalStrength" :min="0" :max="1" :step="0.01" @update:value="applyRefraction" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.RefractionStrength") }}</span>
<n-slider v-model:value="poolData.refractionStrength" :min="0" :max="1" :step="0.001" @update:value="applyRefraction" />
</div>
</n-collapse-item>
<n-collapse-item v-if="poolData.wallMode === 'wall'" :title="t('layout.sider.waterPool.Texture')" name="texture">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.WallTexture") }}</span>
<EsTexture v-model:texture="tilesTexture" width="26px" height="26px" class="ml-5px" @change="applyTiles" />
</div>
</n-collapse-item>
<n-collapse-item :title="t('layout.sider.waterPool.Quality')" name="quality">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.SimulationSize") }}</span>
<EsInputNumber
v-model:value="poolData.simulationSize"
size="small"
:min="16"
:max="2048"
:decimal="0"
:show-button="false"
:disabled="rebuilding"
@update:value="scheduleQualityRebuild"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.CausticsSize") }}</span>
<EsInputNumber
v-model:value="poolData.causticsSize"
size="small"
:min="64"
:max="4096"
:decimal="0"
:show-button="false"
:disabled="rebuilding"
@update:value="scheduleQualityRebuild"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.WaterSegments") }}</span>
<EsInputNumber
v-model:value="poolData.waterSegments"
size="small"
:min="1"
:max="1024"
:decimal="0"
:show-button="false"
:disabled="rebuilding"
@update:value="scheduleQualityRebuild"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.waterPool.WallSegments") }}</span>
<EsInputNumber
v-model:value="poolData.wallSegments"
size="small"
:min="3"
:max="512"
:decimal="0"
:show-button="false"
:disabled="rebuilding"
@update:value="scheduleQualityRebuild"
/>
</div>
</n-collapse-item>
</n-collapse>
</template>
<style scoped lang="less"></style>
@@ -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>
@@ -0,0 +1,342 @@
<script setup lang="ts">
import { computed, ref, toRefs } from "vue";
import { t } from "@/language";
import EsInputNumber from "@/components/es/EsInputNumber.vue";
type UIPanelStateMap = Record<string, Record<string, any>>;
const props = defineProps<{
formData: { props: Record<string, any>; states: UIPanelStateMap };
updateProps: (patch: Record<string, any>) => void;
updateStateProps: (key: string, patch: Record<string, any>) => void;
isStateEnabled: (key: string) => boolean;
toggleState: (key: string, enabled: boolean) => void;
}>();
const { formData } = toRefs(props);
const backgroundImageInput = ref<HTMLInputElement | null>(null);
const overflowHidden = computed({
get: () => formData.value.props.overflow === "hidden",
set: (value: boolean) => {
formData.value.props.overflow = value ? "hidden" : "visible";
props.updateProps({ overflow: formData.value.props.overflow });
},
});
const flexOptions = computed(() => [
{ label: t("layout.sider.uipanel.Row"), value: "row" },
{ label: t("layout.sider.uipanel.Row reverse"), value: "row-reverse" },
{ label: t("layout.sider.uipanel.Column"), value: "column" },
{ label: t("layout.sider.uipanel.Column reverse"), value: "column-reverse" },
]);
const justifyOptions = computed(() => [
{ label: t("layout.sider.uipanel.Start"), value: "start" },
{ label: t("layout.sider.uipanel.Center"), value: "center" },
{ label: t("layout.sider.uipanel.End"), value: "end" },
{ label: t("layout.sider.uipanel['Space between']"), value: "space-between" },
{ label: t("layout.sider.uipanel['Space around']"), value: "space-around" },
{ label: t("layout.sider.uipanel['Space evenly']"), value: "space-evenly" },
]);
const alignOptions = computed(() => [
{ label: t("layout.sider.uipanel.Start"), value: "start" },
{ label: t("layout.sider.uipanel.Center"), value: "center" },
{ label: t("layout.sider.uipanel.End"), value: "end" },
{ label: t("layout.sider.uipanel.Stretch"), value: "stretch" },
]);
const backgroundSizeOptions = computed(() => [
{ label: t("layout.sider.uipanel.Cover"), value: "cover" },
{ label: t("layout.sider.uipanel.Contain"), value: "contain" },
{ label: t("layout.sider.uipanel.Stretch"), value: "stretch" },
]);
function handleBackgroundImageUrl(value: string) {
formData.value.props.backgroundImage = value;
props.updateProps({ backgroundImage: value });
}
function openBackgroundImagePicker() {
backgroundImageInput.value?.click();
}
function handleBackgroundImageFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
const maxSizeMb = 3;
if (file.size / (1024 * 1024) > maxSizeMb) {
console.log(file.size / (1024 * 1024));
const message = t("layout.sider.uipanel['Image size must be <= {size}MB']").replace("{size}", String(maxSizeMb));
window.$message?.warning(message);
input.value = "";
return;
}
const reader = new FileReader();
reader.onload = () => {
if (typeof reader.result !== "string") return;
formData.value.props.backgroundImage = reader.result;
props.updateProps({ backgroundImage: reader.result });
};
reader.readAsDataURL(file);
input.value = "";
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.scene.Width") }}</span>
<EsInputNumber
v-model:value="formData.props.width"
size="small"
:decimal="2"
:show-button="false"
@change="props.updateProps({ width: formData.props.width })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.scene.Height") }}</span>
<EsInputNumber
v-model:value="formData.props.height"
size="small"
:decimal="2"
:show-button="false"
@change="props.updateProps({ height: formData.props.height })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Padding") }}</span>
<EsInputNumber
v-model:value="formData.props.padding"
size="small"
:decimal="2"
:show-button="false"
@change="props.updateProps({ padding: formData.props.padding })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Margin") }}</span>
<EsInputNumber
v-model:value="formData.props.margin"
size="small"
:decimal="2"
:show-button="false"
@change="props.updateProps({ margin: formData.props.margin })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Offset") }}</span>
<EsInputNumber
v-model:value="formData.props.offset"
size="small"
:decimal="3"
:show-button="false"
@change="props.updateProps({ offset: formData.props.offset })"
/>
</div>
<n-divider class="!my-2" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Background color']") }}</span>
<n-color-picker
v-model:value="formData.props.backgroundColor"
:show-alpha="false"
size="small"
@update:value="props.updateProps({ backgroundColor: formData.props.backgroundColor })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Background opacity']") }}</span>
<EsInputNumber
v-model:value="formData.props.backgroundOpacity"
size="small"
:min="0"
:max="1"
:decimal="2"
:show-button="false"
@change="props.updateProps({ backgroundOpacity: formData.props.backgroundOpacity })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Background image URL']") }}</span>
<n-input v-model:value="formData.props.backgroundImage" size="small" @update:value="handleBackgroundImageUrl" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Select local image']") }}</span>
<div class="uipanel-bg-image">
<n-button size="small" @click="openBackgroundImagePicker">
{{ t("layout.sider.uipanel['Select local image']") }}
</n-button>
<input ref="backgroundImageInput" class="uipanel-bg-image-input" type="file" accept="image/*" @change="handleBackgroundImageFile" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Background size']") }}</span>
<n-select
v-model:value="formData.props.backgroundSize"
size="small"
:options="backgroundSizeOptions"
@update:value="props.updateProps({ backgroundSize: formData.props.backgroundSize })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Border radius']") }}</span>
<EsInputNumber
v-model:value="formData.props.borderRadius"
size="small"
:min="0"
:decimal="3"
:show-button="false"
@change="props.updateProps({ borderRadius: formData.props.borderRadius })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Border width']") }}</span>
<EsInputNumber
v-model:value="formData.props.borderWidth"
size="small"
:min="0"
:decimal="3"
:show-button="false"
@change="props.updateProps({ borderWidth: formData.props.borderWidth })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Border color']") }}</span>
<n-color-picker
v-model:value="formData.props.borderColor"
:show-alpha="false"
size="small"
@update:value="props.updateProps({ borderColor: formData.props.borderColor })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Hidden overflow']") }}</span>
<n-switch v-model:value="overflowHidden" size="small" />
</div>
<n-divider class="!my-2" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Flex direction']") }}</span>
<n-select
v-model:value="formData.props.flexDirection"
size="small"
:options="flexOptions"
@update:value="props.updateProps({ flexDirection: formData.props.flexDirection })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Justify content']") }}</span>
<n-select
v-model:value="formData.props.justifyContent"
size="small"
:options="justifyOptions"
@update:value="props.updateProps({ justifyContent: formData.props.justifyContent })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Align items']") }}</span>
<n-select
v-model:value="formData.props.alignItems"
size="small"
:options="alignOptions"
@update:value="props.updateProps({ alignItems: formData.props.alignItems })"
/>
</div>
<n-divider class="!my-2" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.States") }}</span>
<div />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Hover") }}</span>
<n-switch size="small" :value="props.isStateEnabled('hover')" @update:value="(value: boolean) => props.toggleState('hover', value)" />
</div>
<template v-if="props.isStateEnabled('hover')">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Hover background']") }}</span>
<n-color-picker
v-model:value="formData.states.hover.backgroundColor"
:show-alpha="false"
size="small"
@update:value="props.updateStateProps('hover', { backgroundColor: formData.states.hover.backgroundColor })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Hover opacity']") }}</span>
<EsInputNumber
v-model:value="formData.states.hover.backgroundOpacity"
size="small"
:min="0"
:max="1"
:decimal="2"
:show-button="false"
@change="props.updateStateProps('hover', { backgroundOpacity: formData.states.hover.backgroundOpacity })"
/>
</div>
</template>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Active") }}</span>
<n-switch size="small" :value="props.isStateEnabled('active')" @update:value="(value: boolean) => props.toggleState('active', value)" />
</div>
<template v-if="props.isStateEnabled('active')">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Active background']") }}</span>
<n-color-picker
v-model:value="formData.states.active.backgroundColor"
:show-alpha="false"
size="small"
@update:value="props.updateStateProps('active', { backgroundColor: formData.states.active.backgroundColor })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Active opacity']") }}</span>
<EsInputNumber
v-model:value="formData.states.active.backgroundOpacity"
size="small"
:min="0"
:max="1"
:decimal="2"
:show-button="false"
@change="props.updateStateProps('active', { backgroundOpacity: formData.states.active.backgroundOpacity })"
/>
</div>
</template>
</template>
<style scoped lang="less">
.uipanel-bg-image {
display: flex;
justify-content: flex-end;
}
.uipanel-bg-image-input {
display: none;
}
</style>
@@ -0,0 +1,284 @@
<script setup lang="ts">
import { computed, toRefs } from "vue";
import { t } from "@/language";
import EsInputNumber from "@/components/es/EsInputNumber.vue";
type UIPanelStateMap = Record<string, Record<string, any>>;
const props = defineProps<{
formData: { props: Record<string, any>; states: UIPanelStateMap };
updateProps: (patch: Record<string, any>) => void;
updateStateProps: (key: string, patch: Record<string, any>) => void;
isStateEnabled: (key: string) => boolean;
toggleState: (key: string, enabled: boolean) => void;
}>();
const { formData } = toRefs(props);
const fontSupersampling = computed({
get: () => formData.value.props.fontSmooth !== "none",
set: (value: boolean) => {
formData.value.props.fontSmooth = value ? "antialiased" : "none";
props.updateProps({ fontSmooth: formData.value.props.fontSmooth });
},
});
// 宽度是否使用 auto
const widthIsAuto = computed({
get: () => formData.value.props.width === "auto",
set: (value: boolean) => {
formData.value.props.width = value ? "auto" : 0.5;
props.updateProps({ width: formData.value.props.width });
},
});
// 高度是否使用 auto
const heightIsAuto = computed({
get: () => formData.value.props.height === "auto",
set: (value: boolean) => {
formData.value.props.height = value ? "auto" : 0.1;
props.updateProps({ height: formData.value.props.height });
},
});
// 数值化的宽度(用于绑定数字输入)
const numericWidth = computed({
get: () => (typeof formData.value.props.width === "number" ? formData.value.props.width : 0),
set: (value: number) => {
formData.value.props.width = value;
props.updateProps({ width: value });
},
});
// 数值化的高度(用于绑定数字输入)
const numericHeight = computed({
get: () => (typeof formData.value.props.height === "number" ? formData.value.props.height : 0),
set: (value: number) => {
formData.value.props.height = value;
props.updateProps({ height: value });
},
});
const textAlignOptions = computed(() => [
{ label: t("layout.sider.uipanel.Left"), value: "left" },
{ label: t("layout.sider.uipanel.Center"), value: "center" },
{ label: t("layout.sider.uipanel.Right"), value: "right" }
]);
const whiteSpaceOptions = [
{ label: "normal", value: "normal" },
{ label: "pre-line", value: "pre-line" },
{ label: "pre-wrap", value: "pre-wrap" },
{ label: "pre", value: "pre" },
{ label: "nowrap", value: "nowrap" },
];
function handleTextContent(value: string) {
formData.value.props.textContent = value;
props.updateProps({ textContent: value });
}
</script>
<template>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Text") }}</span>
<n-input v-model:value="formData.props.textContent" type="textarea" size="small" :autosize="{ minRows: 1, maxRows: 3 }" @update:value="handleTextContent" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.scene.Width") }}</span>
<div class="uipanel-auto-size">
<n-switch v-model:value="widthIsAuto" size="small" />
<span class="uipanel-auto-label">auto</span>
<EsInputNumber v-if="!widthIsAuto" v-model:value="numericWidth" size="small" :min="0" :decimal="3" :show-button="false" @change="numericWidth = $event" />
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.scene.Height") }}</span>
<div class="uipanel-auto-size">
<n-switch v-model:value="heightIsAuto" size="small" />
<span class="uipanel-auto-label">auto</span>
<EsInputNumber
v-if="!heightIsAuto"
v-model:value="numericHeight"
size="small"
:min="0"
:decimal="3"
:show-button="false"
@change="numericHeight = $event"
/>
</div>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Font size']") }}</span>
<EsInputNumber
v-model:value="formData.props.fontSize"
size="small"
:decimal="3"
:show-button="false"
@change="props.updateProps({ fontSize: formData.props.fontSize })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Color") }}</span>
<n-color-picker v-model:value="formData.props.color" :show-alpha="false" size="small" @update:value="props.updateProps({ color: formData.props.color })" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Font opacity']") }}</span>
<EsInputNumber
v-model:value="formData.props.fontOpacity"
size="small"
:min="0"
:max="1"
:decimal="2"
:show-button="false"
@change="props.updateProps({ fontOpacity: formData.props.fontOpacity })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Font supersampling']") }}</span>
<n-switch v-model:value="fontSupersampling" size="small" />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Letter spacing']") }}</span>
<EsInputNumber
v-model:value="formData.props.letterSpacing"
size="small"
:decimal="3"
:show-button="false"
@change="props.updateProps({ letterSpacing: formData.props.letterSpacing })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Line height']") }}</span>
<EsInputNumber
v-model:value="formData.props.lineHeight"
size="small"
:decimal="2"
:show-button="false"
@change="props.updateProps({ lineHeight: formData.props.lineHeight })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Padding") }}</span>
<EsInputNumber
v-model:value="formData.props.padding"
size="small"
:decimal="3"
:show-button="false"
@change="props.updateProps({ padding: formData.props.padding })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Margin") }}</span>
<EsInputNumber
v-model:value="formData.props.margin"
size="small"
:decimal="3"
:show-button="false"
@change="props.updateProps({ margin: formData.props.margin })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Offset") }}</span>
<EsInputNumber
v-model:value="formData.props.offset"
size="small"
:decimal="3"
:show-button="false"
@change="props.updateProps({ offset: formData.props.offset })"
/>
</div>
<n-divider class="!my-2" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Text align']") }}</span>
<n-select
v-model:value="formData.props.textAlign"
size="small"
:options="textAlignOptions"
@update:value="props.updateProps({ textAlign: formData.props.textAlign })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['White space']") }}</span>
<n-select
v-model:value="formData.props.whiteSpace"
size="small"
:options="whiteSpaceOptions"
@update:value="props.updateProps({ whiteSpace: formData.props.whiteSpace })"
/>
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Break on']") }}</span>
<n-input v-model:value="formData.props.breakOn" size="small" @update:value="(value: string) => props.updateProps({ breakOn: value })" />
</div>
<n-divider class="!my-2" />
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.States") }}</span>
<div />
</div>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Hover") }}</span>
<n-switch size="small" :value="props.isStateEnabled('hover')" @update:value="(value: boolean) => props.toggleState('hover', value)" />
</div>
<template v-if="props.isStateEnabled('hover')">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Hover color']") }}</span>
<n-color-picker
v-model:value="formData.states.hover.color"
:show-alpha="false"
size="small"
@update:value="props.updateStateProps('hover', { color: formData.states.hover.color })"
/>
</div>
</template>
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel.Active") }}</span>
<n-switch size="small" :value="props.isStateEnabled('active')" @update:value="(value: boolean) => props.toggleState('active', value)" />
</div>
<template v-if="props.isStateEnabled('active')">
<div class="sidebar-config-item">
<span>{{ t("layout.sider.uipanel['Active color']") }}</span>
<n-color-picker
v-model:value="formData.states.active.color"
:show-alpha="false"
size="small"
@update:value="props.updateStateProps('active', { color: formData.states.active.color })"
/>
</div>
</template>
</template>
<style scoped lang="less">
.uipanel-auto-size {
display: flex;
align-items: center;
gap: 6px;
}
.uipanel-auto-label {
font-size: 12px;
opacity: 0.7;
min-width: 30px;
}
</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>
@@ -0,0 +1,307 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, watch } from "vue";
import * as THREE from "three";
import { AddObjectCommand, App, Hooks, Path, Utils, getDefaultPathOptions } from "@astral3d/engine";
import { t } from "@/language";
import type { IPathDrawingResult } from "@/store/modules/pathDrawing";
import { usePathDrawingStore } from "@/store/modules/pathDrawing";
const pathDrawingStore = usePathDrawingStore();
const isDrawing = computed(() => pathDrawingStore.isActive);
const submit = computed(() => pathDrawingStore.getSubmit);
let templateOptions: Record<string, any> | null = null;
let points: THREE.Vector3[] = [];
let origin: THREE.Vector3 | null = null;
let previewPath: Path | null = null;
let viewerRef: any = null;
let prevCursor = "";
const previewFlag = "__pathPreview";
const groundPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0);
const tempNdc = new THREE.Vector2();
const onDownPosition = new THREE.Vector2();
const onUpPosition = new THREE.Vector2();
let isPointerDown = false;
let lastClickTime = 0;
const signal = Hooks.useSignal();
function haltSelectionOnIntersect() {
if (!isDrawing.value) return;
signal.halt("intersectionsDetected");
}
function setViewer(viewer: any) {
viewerRef = viewer;
}
function cloneTemplate() {
const current = pathDrawingStore.getTemplate;
templateOptions = current ? JSON.parse(JSON.stringify(current)) : null;
}
function cleanupPreview() {
let removed = false;
if (previewPath) {
previewPath.parent?.remove(previewPath);
previewPath.dispose?.();
previewPath = null;
removed = true;
}
const orphans: THREE.Object3D[] = [];
App.scene.traverse(child => {
if (child.userData?.[previewFlag]) {
orphans.push(child);
}
});
if (orphans.length > 0) {
orphans.forEach(child => {
child.parent?.remove(child);
(child as any).dispose?.();
});
removed = true;
}
if (removed) {
Hooks.useDispatchSignal("sceneGraphChanged");
}
}
function resetState() {
points = [];
origin = null;
isPointerDown = false;
lastClickTime = 0;
cleanupPreview();
}
function buildOptions(worldPoints: THREE.Vector3[], baseOrigin: THREE.Vector3) {
const options = getDefaultPathOptions();
if (templateOptions) {
Utils.deepAssign(options, templateOptions);
}
options.position = baseOrigin.toArray();
options.points = worldPoints.map(point => ({
x: point.x - baseOrigin.x,
y: point.y - baseOrigin.y,
z: point.z - baseOrigin.z,
}));
if (!options.name) options.name = "Path";
return options;
}
function buildResult(worldPoints: THREE.Vector3[], baseOrigin: THREE.Vector3): IPathDrawingResult {
return {
worldPoints: worldPoints.map(point => ({ x: point.x, y: point.y, z: point.z })),
origin: {
x: baseOrigin.x,
y: baseOrigin.y,
z: baseOrigin.z,
},
options: buildOptions(worldPoints, baseOrigin),
};
}
function updatePreview() {
if (!templateOptions) return;
if (points.length === 0) return;
if (!origin) {
origin = points[0].clone();
}
const options = buildOptions(points, origin);
if (!previewPath) {
const path = new Path(options);
path.ignore = true;
path.userData[previewFlag] = true;
previewPath = path;
App.scene.add(path);
} else {
previewPath.updateOptions({
mode: options.mode,
closed: options.closed,
cornerRadius: options.cornerRadius,
cornerSplit: options.cornerSplit,
path: options.path,
tube: options.tube,
position: options.position,
points: options.points,
});
}
Hooks.useDispatchSignal("sceneGraphChanged");
}
function getPickPoint(event: PointerEvent) {
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return null;
const mouse = Utils.getMousePosition(viewer.container, event.clientX, event.clientY);
const intersects = viewer.getIntersects(new THREE.Vector2(mouse[0], mouse[1]));
if (intersects.length > 0) {
return intersects[0].point.clone();
}
tempNdc.set(mouse[0] * 2 - 1, -(mouse[1] * 2) + 1);
viewer.raycaster.setFromCamera(tempNdc, viewer.camera);
const hit = new THREE.Vector3();
return viewer.raycaster.ray.intersectPlane(groundPlane, hit) ? hit.clone() : null;
}
function addPoint(point: THREE.Vector3) {
points.push(point);
updatePreview();
}
function finishDrawing() {
if (!isDrawing.value) return;
if (points.length <= 2) {
cleanupPreview();
pathDrawingStore.cancel();
return;
}
const baseOrigin = origin || points[0];
const result = buildResult(points, baseOrigin);
if (submit.value) {
try {
submit.value(result);
} catch (error) {
console.error("[PathDrawingOverlay] path drawing submit failed", error);
}
} else {
const path = new Path(result.options);
App.execute(new AddObjectCommand(path), `Add Path: ${result.options.name}`);
}
cleanupPreview();
pathDrawingStore.finish();
}
function cancelDrawing() {
if (!isDrawing.value) return;
cleanupPreview();
pathDrawingStore.cancel();
}
function stopDrawing() {
unbindEvents();
resetState();
const viewer = viewerRef || (window as any).viewer;
if (viewer) {
viewer.container.style.cursor = prevCursor || "";
}
}
function bindEvents() {
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return;
prevCursor = viewer.container.style.cursor || "";
viewer.container.style.cursor = "crosshair";
viewer.container.addEventListener("pointerdown", onPointerDown, true);
viewer.container.addEventListener("pointerup", onPointerUp, true);
viewer.container.addEventListener("dblclick", onDoubleClick, true);
}
function unbindEvents() {
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return;
viewer.container.removeEventListener("pointerdown", onPointerDown, true);
viewer.container.removeEventListener("pointerup", onPointerUp, true);
viewer.container.removeEventListener("dblclick", onDoubleClick, true);
}
function onPointerDown(event: PointerEvent) {
if (!isDrawing.value) return;
if (event.button !== 0) return;
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return;
const array = Utils.getMousePosition(viewer.container, event.clientX, event.clientY);
onDownPosition.fromArray(array);
isPointerDown = true;
}
function onPointerUp(event: PointerEvent) {
if (!isDrawing.value) return;
if (event.button !== 0) return;
if (!isPointerDown) return;
const viewer = viewerRef || (window as any).viewer;
if (!viewer) return;
const array = Utils.getMousePosition(viewer.container, event.clientX, event.clientY);
onUpPosition.fromArray(array);
isPointerDown = false;
if (onDownPosition.distanceTo(onUpPosition) !== 0) return;
const now = Date.now();
if (now - lastClickTime < 200) return;
lastClickTime = now;
const point = getPickPoint(event);
if (!point) return;
addPoint(point);
}
function onDoubleClick(event: MouseEvent) {
if (!isDrawing.value) return;
event.preventDefault();
event.stopPropagation();
finishDrawing();
}
watch(
() => isDrawing.value,
active => {
if (active) {
resetState();
cloneTemplate();
bindEvents();
} else {
stopDrawing();
}
}
);
onMounted(() => {
Hooks.useAddSignal("intersectionsDetected", haltSelectionOnIntersect, undefined, 999);
Hooks.useAddSignal("viewerInitCompleted", setViewer);
if ((window as any).viewer) {
setViewer((window as any).viewer);
}
});
onBeforeUnmount(() => {
Hooks.useRemoveSignal("intersectionsDetected", haltSelectionOnIntersect);
Hooks.useRemoveSignal("viewerInitCompleted", setViewer);
stopDrawing();
});
</script>
<template>
<n-card v-if="isDrawing" size="small" class="path-drawing-overlay">
<n-space size="small" align="center" @pointerdown.stop>
<n-text strong>
{{ t("layout.scene.path['Path drawing: Left-click to add a point, and double-click to end the drawing']") }}
</n-text>
<n-button size="small" type="primary" @click="finishDrawing">{{ t("other.Finish") }}</n-button>
<n-button size="small" @click="cancelDrawing">{{ t("other.Cancel") }}</n-button>
</n-space>
</n-card>
</template>
<style scoped lang="less">
.path-drawing-overlay {
position: absolute;
bottom: 10px;
left: 50%;
width: max-content;
transform: translateX(-50%);
z-index: 20;
}
</style>
@@ -6,24 +6,22 @@
<ViewportInfo/> <ViewportInfo/>
</div> </div>
<!-- RVT BIM 构件信息悬浮框 -->
<BIMProperties/>
<!-- IFC BIM 构件信息悬浮框 --> <!-- IFC BIM 构件信息悬浮框 -->
<IFCProperties/> <IFCProperties/>
<PathDrawingOverlay/>
</div> </div>
</template> </template>
<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 {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();
@@ -31,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", ""),
@@ -49,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);
@@ -56,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,
function getList() { } pageSize: 10,
pageCount: 1,
function handleSearch(searchText: string) { "on-update:page": (page: number) => {
tableRef.value?.filter({ pagination.page = page;
name: [searchText] getList();
}
}) })
function resetCurrentDataSet(data?: Partial<IDataSet.Item>) {
Object.assign(currentDataSet, defaultDataSet, data || {});
} }
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 { DataSetPayload, fetchCreateDataSet, fetchUpdateDataSet } from "@/http/api/dataSet";
import { fetchDataSetGroupTree } from "@/http/api/dataSetGroup";
import { fetchDataSourceList } from "@/http/api/dataSource";
import SQLEditor from "@/components/code/SQLEditor.vue"; import SQLEditor from "@/components/code/SQLEditor.vue";
import JSONEditor from "@/components/code/JSONEditor.vue"; import JSONEditor from "@/components/code/JSONEditor.vue";
const props = withDefaults(
const props = withDefaults(defineProps<{ defineProps<{
show: boolean, show: boolean;
model: IDataSet.Item model?: IDataSet.Item;
}>(), { }>(),
{
show: false, show: false,
model: () => ({ model: () => ({
id: '', id: "",
groupId: "", groupId: "",
name: '', name: "",
type: 'SQL', type: "API",
}) method: "GET",
}) api: "",
dataSourceId: "",
sql: "",
json: "",
createTime: "",
}),
}
);
const emits = defineEmits(["update:show", "refresh"]); 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,
validator(_: unknown, value: unknown) {
if (value === undefined || value === null || value === "") {
return new Error(message);
}
if (Array.isArray(value) && value.length === 0) {
return new Error(message);
}
return true;
},
}; };
const groupOptions = []; }
const rules = computed(() => {
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 = [ const setTypes = [
{ label: 'API', value: 'API' }, { label: "API", value: "API" },
{ label: 'SQL', value: 'SQL' }, { label: "SQL", value: "SQL" },
{ label: 'JSON', value: 'JSON' }, { label: "JSON", value: "JSON" },
]; ];
const dataSourceOptions = []; const dataSourceOptions = ref<{ label: string; value: IDataSource.Item["id"] }[]>([]);
const submitLoading = ref(false);
const isEdit = computed(() => Boolean(props.model?.id));
function normalizeDataSourceId(value: unknown): IDataSource.Item["id"] | undefined {
if (value === undefined || value === null || value === "") {
return undefined;
}
return String(value);
}
function extractList<T>(value: unknown): T[] {
if (Array.isArray(value)) {
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 {
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() { function handleClose() {
emits("update:show", false); emits("update:show", false);
} }
function saveDataSet(e: MouseEvent) { function saveDataSet(e: MouseEvent) {
e.preventDefault() e.preventDefault();
formRef.value?.validate((errors) => {
if (!errors) {
if (!props.model.id) {
// 新增
} else {
// 更新
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) {
+66
View File
@@ -0,0 +1,66 @@
import MapLayer from "@/cesium/modules/mapLayer";
import CameraUtils from "@/cesium/modules/cameraUtils";
import * as Cesium from 'cesium';
import * as THREE from 'three';
import { VNode } from "vue";
export default class CesiumApp {
dom: HTMLElement;
cesiumParentElement: HTMLElement;
viewer: Cesium.Viewer;
helper: Cesium.EventHelper;
module: {
mapLayer: MapLayer;
cameraUtils: CameraUtils;
};
_three: {
camera: THREE.PerspectiveCamera;
scene: THREE.Scene;
sceneHelpers: THREE.Scene;
showSceneHelpers: boolean;
renderer: THREE.WebGLRenderer;
};
constructor(dom: any);
getViewer(): Cesium.Viewer;
/**
* 场景事件监听
*/
eventListener(): void;
/**
* 鼠标按下处理
* @param event
*/
onMouseDown(event: any): void;
/**
* 鼠标抬起处理
* @param event
*/
onMouseUp(event: any): void;
/**
* three 场景点击事件
*/
handleClick(): void;
/**
* 初始化cesium 场景后的处理操作
*/
handleInitCesiumAfter(): void;
/**
* 添加中国地图遮罩
*/
addChinaMask(): void;
/**
* 获取与鼠标点击位置射线相交的对象数组
*/
getIntersects(point: any): THREE.Intersection<THREE.Object3D<THREE.Object3DEventMap>>[];
/**
* 添加VNode至 viewer._toolbar
*/
addVNodeToViewer(vNode: VNode): void;
/**
* 重置cesium场景
*/
reset(): void;
/**
* 销毁
*/
destroy(): void;
}
+23
View File
@@ -0,0 +1,23 @@
import * as Cesium from 'cesium';
/**
* @Date 2022-06-09
* @Author 二三
* @param {*} viewer Cesium.Viewer
* @Description: cesium相机类
*/
export default class CameraUtils {
viewer: Cesium.Viewer | null;
entity: Cesium.Entity | null;
constructor();
init(): void;
setViewer(viewer: any): void;
/**
* 修改鼠标操作方式
*/
changeMouseOperate(): void;
flyTo(lng: any, lat: any, distance: any, pitch?: number, heading?: number): void;
/**
* 销毁
*/
destroy(): void;
}
+27
View File
@@ -0,0 +1,27 @@
import * as Cesium from 'cesium';
/**
* @Date 2023-03-07
* @Author 二三
* @Description: cesium地图底图图层管理
*/
export default class MapLayer {
viewer: Cesium.Viewer | null;
layers: {
[s: string]: {
satellite?: Cesium.UrlTemplateImageryProvider | Cesium.WebMapTileServiceImageryProvider;
mark?: Cesium.UrlTemplateImageryProvider;
vector?: Cesium.UrlTemplateImageryProvider;
};
};
constructor();
setViewer(viewer: any): void;
/**
* 获取默认底图
* @param layer 底图类型,默认卫星影像图 enum: satellite | vector
*/
getDefaultLayer(layer?: 'satellite' | 'vector'): Cesium.UrlTemplateImageryProvider | Cesium.WebMapTileServiceImageryProvider | undefined;
/**
* 获取默认底图对应得标记图
*/
getMarkMapByDefaultLayer(): Cesium.UrlTemplateImageryProvider | undefined;
}
+10
View File
@@ -0,0 +1,10 @@
/**
* 笛卡尔坐标转换经纬度坐标
* @param {*} car3_ps
* @returns
*/
export declare function getLngLatByCartesian3(car3_ps: any): {
longitude: number;
latitude: number;
elevation: number;
};
+69
View File
@@ -0,0 +1,69 @@
import * as THREE from "three";
import { TransformControls } from 'three/examples/jsm/controls/TransformControls.js';
import CesiumApp from "@/cesium/cesiumApp";
/**
* @Date 2023-02-06
* @Author 二三
* @Description: cesium视图出口
*/
export default class ViewPort {
app: CesiumApp;
cesiumParentElement: HTMLElement;
_three: {
camera: THREE.PerspectiveCamera;
scene: THREE.Scene;
sceneHelpers: THREE.Scene;
showSceneHelpers: boolean;
renderer: THREE.WebGLRenderer;
transformControls: TransformControls | null;
box: THREE.Box3;
};
threeSelectionBox: THREE.Box3Helper;
minWGS84: Array<number>;
maxWGS84: Array<number>;
animationFrameID: number | null;
constructor(dom: any);
init(): void;
/**
* 相关signals注册
*/
signalsRegister(isAdd?: boolean): void;
/**
*
*/
addButtonToViewer(): void;
/**
* cesium 开始加载后的相关操作
*/
handleCesiumEvent(): void;
/**
* 初始化three变换控制器
*/
initThreeTransformControls(): void;
/**
* 计算场景中心位置等信息
*/
calcCenter(): void;
/**
* 处理three canvas 的 transformControls,以便于场景融合;
*/
handleThreeMouseMove(event: any): void;
/**
* 飞行至three场景
*/
flyToThree(): void;
/**
* cesium 渲染
*/
renderCesium(): void;
/**
* 两个画布相机融合
*/
fusionCanvas(): void;
/**
* three 渲染器的渲染
*/
renderThree(): void;
loop(): void;
destroy(): void;
}
+9
View File
@@ -0,0 +1,9 @@
export declare const CESIUM_DEFAULT_MAP: {
label: string;
value: string;
coordinateSystem: string;
}[];
export declare const CESIUM_DEFAULT_MAP_TYPE: {
label: string;
value: string;
}[];
+34
View File
@@ -0,0 +1,34 @@
/** 错误信息的显示时间 */
export declare const ERROR_MSG_DURATION: number;
/** 默认的请求错误code */
export declare const DEFAULT_REQUEST_ERROR_CODE = "DEFAULT";
/** 默认的请求错误文本 */
export declare const DEFAULT_REQUEST_ERROR_MSG = "\u8BF7\u6C42\u9519\u8BEF~";
/** 请求超时的错误code(为固定值:ECONNABORTED) */
export declare const REQUEST_TIMEOUT_CODE = "ECONNABORTED";
/** 请求超时的错误文本 */
export declare const REQUEST_TIMEOUT_MSG = "\u8BF7\u6C42\u8D85\u65F6~";
/** 网络不可用的code */
export declare const NETWORK_ERROR_CODE = "NETWORK_ERROR";
/** 网络不可用的错误文本 */
export declare const NETWORK_ERROR_MSG = "\u7F51\u7EDC\u4E0D\u53EF\u7528~";
/** 请求不成功各种状态的错误 */
export declare const ERROR_STATUS: {
400: string;
401: string;
403: string;
404: string;
405: string;
408: string;
500: string;
501: string;
502: string;
503: string;
504: string;
505: string;
DEFAULT: string;
};
/** 不弹出错误信息的code */
export declare const NO_ERROR_MSG_CODE: (string | number)[];
/** token失效需要刷新token的code */
export declare const REFRESH_TOKEN_CODE: (string | number)[];
+15
View File
@@ -0,0 +1,15 @@
import { Preview } from "@astral3d/engine";
export declare class AssetPreview {
#private;
previewer: Preview;
private container;
static getInstance(container?: HTMLElement): AssetPreview;
constructor(container?: HTMLElement);
updateContainer(container: HTMLElement): void;
/**
* 加载预览项
*/
load(fileOrUrl: string | File, type?: string): Promise<unknown>;
clear(): void;
dispose(): void;
}
+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[];
} }
+3
View File
@@ -0,0 +1,3 @@
import useBoolean from './useBoolean';
import useLoading from './useLoading';
export { useBoolean, useLoading };
+11
View File
@@ -0,0 +1,11 @@
/**
* boolean组合式函数
* @param initValue 初始值
*/
export default function useBoolean(initValue?: boolean): {
bool: import("vue").Ref<boolean, boolean>;
setBool: (value: boolean) => void;
setTrue: () => void;
setFalse: () => void;
toggle: () => void;
};
+5
View File
@@ -0,0 +1,5 @@
export default function useLoading(initValue?: boolean): {
loading: import("vue").Ref<boolean, boolean>;
startLoading: () => void;
endLoading: () => void;
};
+25
View File
@@ -0,0 +1,25 @@
/**
* 开启 WebSocket 链接,全局只需执行一次
* @param url
*/
export declare function connectWebSocket(url: any): void;
/**
* 添加 WebSocket 消息监听
* @param callback
*/
export declare function onWebSocket(callback: (data: object) => any): void;
/**
* 解除 WebSocket 消息监听
*
* @param callback
*/
export declare function offWebSocket(callback: (data: object) => any): void;
export declare function useWebSocket(): {
data: string | null;
status: import("@vueuse/core").WebSocketStatus;
close: WebSocket["close"];
open: import("@vueuse/core").Fn;
send: (data: string | ArrayBuffer | Blob, useBuffer?: boolean) => boolean;
ws: WebSocket | undefined;
} | null;
export declare function send(message: string | ArrayBuffer | Blob): void;
+6
View File
@@ -0,0 +1,6 @@
/**
* 获取资产分类树
*/
export declare function fetchAssetsCategoryTreeList(params?: {
type: string;
}): Promise<Service.RequestResult<T>>;
+20
View File
@@ -0,0 +1,20 @@
/**
* 获取资产列表
*/
export declare function fetchGetAssetsList(params: Service.ListPageQueryParams): Promise<Service.RequestResult<T>>;
/**
* 新增资产
*/
export declare function fetchAddAsset(data: IAssets.Item): Promise<Service.RequestResult<T>>;
/**
* 更新资产
*/
export declare function fetchUpdateAsset(data: IAssets.Item): Promise<Service.RequestResult<T>>;
/**
* 移除资产
*/
export declare function fetchRemoveAsset(id: IAssets.Item['id']): Promise<Service.RequestResult<T>>;
/**
* 获取分类下的资产tags
*/
export declare function fetchGetAssetCategoryTags(type: IAssets.SupportType, category: string): Promise<Service.RequestResult<T>>;
+12
View File
@@ -0,0 +1,12 @@
/**
* 获取bim转换列表
*/
export declare function fetchGetBim2GltfList(params: any): Promise<Service.RequestResult<T>>;
/**
* 上传bim文件
*/
export declare function fetchUploadRvt(data: any): Promise<Service.RequestResult<T>>;
/**
* 添加数据并启动revit转换( 需传入接收结果的websocket uname
*/
export declare function fetchAddBim2Gltf(data: any): Promise<Service.RequestResult<T>>;
+8
View File
@@ -0,0 +1,8 @@
/**
* 获取cad列表
*/
export declare function fetchGetCadList(params: any): Promise<Service.RequestResult<T>>;
/**
* 添加数据并启动cad解析( 需传入接收结果的websocket uname
*/
export declare function fetchAddDwg2dxf(data: any): Promise<Service.RequestResult<T>>;

Some files were not shown because too many files have changed in this diff Show More