feat(all): 数据面板迁移
This commit is contained in:
parent
4cc239d660
commit
8385c08ec7
53
packages/editor/src/http/api/dataSet.ts
Normal file
53
packages/editor/src/http/api/dataSet.ts
Normal 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?: string;
|
||||||
|
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);
|
||||||
|
}
|
||||||
@ -32,6 +32,25 @@ export default {
|
|||||||
"Data source name":"数据源名称",
|
"Data source name":"数据源名称",
|
||||||
"Data source type":"数据源类型",
|
"Data source type":"数据源类型",
|
||||||
"Connection string":"连接字符串",
|
"Connection string":"连接字符串",
|
||||||
|
"Data component": "数据组件",
|
||||||
|
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": "结果错误",
|
||||||
|
},
|
||||||
"Username":"用户名",
|
"Username":"用户名",
|
||||||
"Password":"密码",
|
"Password":"密码",
|
||||||
"Test the connection":"测试连接",
|
"Test the connection":"测试连接",
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import {
|
|||||||
MagicWandFilled,
|
MagicWandFilled,
|
||||||
CloudSnow,
|
CloudSnow,
|
||||||
HeatMap,
|
HeatMap,
|
||||||
|
DataBase,
|
||||||
Opacity,
|
Opacity,
|
||||||
ImageReference,
|
ImageReference,
|
||||||
LocationHeart,
|
LocationHeart,
|
||||||
@ -45,6 +46,7 @@ import SidebarHeatmap from "./sidebar/SidebarHeatmap.vue";
|
|||||||
import SidebarPath from "./sidebar/SidebarPath.vue";
|
import SidebarPath from "./sidebar/SidebarPath.vue";
|
||||||
import SidebarUIPanel from "./sidebar/SidebarUIPanel.vue";
|
import SidebarUIPanel from "./sidebar/SidebarUIPanel.vue";
|
||||||
import SidebarWaterPool from "./sidebar/SidebarWaterPool.vue";
|
import SidebarWaterPool from "./sidebar/SidebarWaterPool.vue";
|
||||||
|
import SidebarDataComponent from "./sidebar/SidebarDataComponent.vue";
|
||||||
|
|
||||||
const tabsInstRef = ref<TabsInst | null>(null);
|
const tabsInstRef = ref<TabsInst | null>(null);
|
||||||
const tabs = ref<Array<any>>([]);
|
const tabs = ref<Array<any>>([]);
|
||||||
@ -74,6 +76,7 @@ function setTabs(object){
|
|||||||
{ name: "geometry", icon: { text: 'Geometry',color:"#6287D1", component: markRaw(GroupObjects) }, component: markRaw(SidebarGeometry) },
|
{ name: "geometry", icon: { text: 'Geometry',color:"#6287D1", component: markRaw(GroupObjects) }, component: markRaw(SidebarGeometry) },
|
||||||
{ name: "material", icon: { text: 'Material',color:"#6287D1", component: markRaw(Opacity) }, component: markRaw(SidebarMaterial) },
|
{ name: "material", icon: { text: 'Material',color:"#6287D1", component: markRaw(Opacity) }, component: markRaw(SidebarMaterial) },
|
||||||
{ name: "animations", icon: { text: 'Animations',color:"#06AF88", component: markRaw(Draw) }, component: markRaw(SidebarAnimations) },
|
{ name: "animations", icon: { text: 'Animations',color:"#06AF88", component: markRaw(Draw) }, component: markRaw(SidebarAnimations) },
|
||||||
|
{ name: "dataComponent", icon: { text: 'Data component', color:"#0bbc14", component: markRaw(DataBase) }, component: markRaw(SidebarDataComponent) },
|
||||||
{ name: "script", icon: { text: 'Script',color:"#06AF88", component: markRaw(Script) }, component: markRaw(SidebarScript) },
|
{ name: "script", icon: { text: 'Script',color:"#06AF88", component: markRaw(Script) }, component: markRaw(SidebarScript) },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
645
packages/sdk/lib/core/tools/DataBindingManager.ts
Normal file
645
packages/sdk/lib/core/tools/DataBindingManager.ts
Normal file
@ -0,0 +1,645 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
import { Easing, Tween } from "three/examples/jsm/libs/tween.module.js";
|
||||||
|
import App from "@/core/app/App";
|
||||||
|
|
||||||
|
export interface AstralMaterialFormat {
|
||||||
|
color?: string;
|
||||||
|
opacity?: number;
|
||||||
|
transparent?: boolean;
|
||||||
|
metalness?: number;
|
||||||
|
roughness?: number;
|
||||||
|
emissive?: string;
|
||||||
|
emissiveIntensity?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AstralAnimationBehavior = "play" | "pause" | "stop";
|
||||||
|
|
||||||
|
export interface AstralAnimationCommand {
|
||||||
|
name: string;
|
||||||
|
behavior: AstralAnimationBehavior;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AstralDataFormat {
|
||||||
|
position?: [number, number, number];
|
||||||
|
rotation?: [number, number, number];
|
||||||
|
scale?: [number, number, number];
|
||||||
|
material?: AstralMaterialFormat;
|
||||||
|
visible?: boolean;
|
||||||
|
animations?: AstralAnimationCommand[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataBindingResult {
|
||||||
|
changed: boolean;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataBindingOptions {
|
||||||
|
transition?: number;
|
||||||
|
onUpdate?: (object: THREE.Object3D) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataComponentConfig {
|
||||||
|
dataSetId?: unknown;
|
||||||
|
filterEnabled?: boolean;
|
||||||
|
filterBody?: string;
|
||||||
|
autoRefreshEnabled?: boolean;
|
||||||
|
autoRefreshInterval?: number;
|
||||||
|
applyToModel?: boolean;
|
||||||
|
transition?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DataComponentEntry {
|
||||||
|
config?: DataComponentConfig;
|
||||||
|
data?: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetConfigOptions {
|
||||||
|
index?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SetDataOptions {
|
||||||
|
index?: number;
|
||||||
|
applyToModel?: boolean;
|
||||||
|
transition?: number;
|
||||||
|
onUpdate?: (object: THREE.Object3D) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Vector3Tuple = [number, number, number];
|
||||||
|
|
||||||
|
const animationBehaviors = new Set<AstralAnimationBehavior>(["play", "pause", "stop"]);
|
||||||
|
const activeTweens = new WeakMap<THREE.Object3D, Map<string, Set<Tween<any>>>>();
|
||||||
|
const pendingUpdates = new WeakMap<THREE.Object3D, boolean>();
|
||||||
|
function isPlainObject(value: unknown): value is Record<string, any> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseVector3(value: unknown, field: string, errors: string[]): Vector3Tuple | null {
|
||||||
|
if (!Array.isArray(value) || value.length !== 3) {
|
||||||
|
errors.push(`${field} must be an array of 3 numbers`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const parsed = value.map(item => Number(item)) as number[];
|
||||||
|
if (parsed.some(item => !Number.isFinite(item))) {
|
||||||
|
errors.push(`${field} must contain finite numbers`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return [parsed[0], parsed[1], parsed[2]];
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
if (!Number.isFinite(value)) return null;
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTransition(value: number | undefined) {
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (!Number.isFinite(numeric) || numeric <= 0) return 0;
|
||||||
|
return numeric;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeIndex(value: number | undefined) {
|
||||||
|
const index = Number(value);
|
||||||
|
if (!Number.isFinite(index) || index < 0) return 0;
|
||||||
|
return Math.floor(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureDataComponentEntry(object: THREE.Object3D, index: number): DataComponentEntry {
|
||||||
|
const list = Array.isArray((object as any).dataComponent) ? ((object as any).dataComponent as DataComponentEntry[]) : [];
|
||||||
|
if (!Array.isArray((object as any).dataComponent)) {
|
||||||
|
(object as any).dataComponent = list;
|
||||||
|
}
|
||||||
|
let entry = list[index];
|
||||||
|
if (!entry || typeof entry !== "object") {
|
||||||
|
entry = { config: {}, data: undefined };
|
||||||
|
list[index] = entry;
|
||||||
|
} else if (!entry.config || typeof entry.config !== "object") {
|
||||||
|
entry.config = {};
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleObjectUpdate(object: THREE.Object3D, onUpdate?: (object: THREE.Object3D) => void) {
|
||||||
|
if (!onUpdate) return;
|
||||||
|
if (pendingUpdates.get(object)) return;
|
||||||
|
pendingUpdates.set(object, true);
|
||||||
|
if (typeof requestAnimationFrame === "function") {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
pendingUpdates.delete(object);
|
||||||
|
onUpdate(object);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
pendingUpdates.delete(object);
|
||||||
|
onUpdate(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTweenBucket(object: THREE.Object3D, key: string) {
|
||||||
|
let map = activeTweens.get(object);
|
||||||
|
if (!map) {
|
||||||
|
map = new Map();
|
||||||
|
activeTweens.set(object, map);
|
||||||
|
}
|
||||||
|
let bucket = map.get(key);
|
||||||
|
if (!bucket) {
|
||||||
|
bucket = new Set();
|
||||||
|
map.set(key, bucket);
|
||||||
|
}
|
||||||
|
return bucket;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trackTween(object: THREE.Object3D, key: string, tween: Tween<any>) {
|
||||||
|
const bucket = getTweenBucket(object, key);
|
||||||
|
bucket.add(tween);
|
||||||
|
const cleanup = () => {
|
||||||
|
const map = activeTweens.get(object);
|
||||||
|
const currentBucket = map?.get(key);
|
||||||
|
if (!currentBucket) return;
|
||||||
|
currentBucket.delete(tween);
|
||||||
|
if (currentBucket.size === 0) {
|
||||||
|
map?.delete(key);
|
||||||
|
}
|
||||||
|
if (map && map.size === 0) {
|
||||||
|
activeTweens.delete(object);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tween.onStop(cleanup);
|
||||||
|
tween.onComplete(cleanup);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopTweensForKey(object: THREE.Object3D, key: string) {
|
||||||
|
const map = activeTweens.get(object);
|
||||||
|
const bucket = map?.get(key);
|
||||||
|
if (!bucket) return;
|
||||||
|
bucket.forEach(tween => tween.stop());
|
||||||
|
map?.delete(key);
|
||||||
|
if (map && map.size === 0) {
|
||||||
|
activeTweens.delete(object);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tweenVector3(
|
||||||
|
object: THREE.Object3D,
|
||||||
|
key: string,
|
||||||
|
target: THREE.Vector3,
|
||||||
|
value: Vector3Tuple,
|
||||||
|
duration: number,
|
||||||
|
onUpdate?: (object: THREE.Object3D) => void
|
||||||
|
) {
|
||||||
|
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const state = { x: target.x, y: target.y, z: target.z };
|
||||||
|
const tween = new Tween(state)
|
||||||
|
.to({ x: value[0], y: value[1], z: value[2] }, duration)
|
||||||
|
.easing(Easing.Linear.None)
|
||||||
|
.onUpdate(() => {
|
||||||
|
target.set(state.x, state.y, state.z);
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
});
|
||||||
|
trackTween(object, key, tween);
|
||||||
|
tween.start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tweenEuler(
|
||||||
|
object: THREE.Object3D,
|
||||||
|
key: string,
|
||||||
|
target: THREE.Euler,
|
||||||
|
value: Vector3Tuple,
|
||||||
|
duration: number,
|
||||||
|
onUpdate?: (object: THREE.Object3D) => void
|
||||||
|
) {
|
||||||
|
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const state = { x: target.x, y: target.y, z: target.z };
|
||||||
|
const tween = new Tween(state)
|
||||||
|
.to({ x: value[0], y: value[1], z: value[2] }, duration)
|
||||||
|
.easing(Easing.Linear.None)
|
||||||
|
.onUpdate(() => {
|
||||||
|
target.set(state.x, state.y, state.z);
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
});
|
||||||
|
trackTween(object, key, tween);
|
||||||
|
tween.start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tweenNumber(
|
||||||
|
object: THREE.Object3D,
|
||||||
|
key: string,
|
||||||
|
target: Record<string, any>,
|
||||||
|
prop: string,
|
||||||
|
value: number,
|
||||||
|
duration: number,
|
||||||
|
onUpdate?: (object: THREE.Object3D) => void
|
||||||
|
) {
|
||||||
|
if (Number(target[prop]) === value) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const state = { value: Number(target[prop]) };
|
||||||
|
const tween = new Tween(state)
|
||||||
|
.to({ value }, duration)
|
||||||
|
.easing(Easing.Linear.None)
|
||||||
|
.onUpdate(() => {
|
||||||
|
target[prop] = state.value;
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
});
|
||||||
|
trackTween(object, key, tween);
|
||||||
|
tween.start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tweenColor(
|
||||||
|
object: THREE.Object3D,
|
||||||
|
key: string,
|
||||||
|
target: THREE.Color,
|
||||||
|
value: THREE.Color,
|
||||||
|
duration: number,
|
||||||
|
onUpdate?: (object: THREE.Object3D) => void
|
||||||
|
) {
|
||||||
|
if (target.equals(value)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const state = { r: target.r, g: target.g, b: target.b };
|
||||||
|
const tween = new Tween(state)
|
||||||
|
.to({ r: value.r, g: value.g, b: value.b }, duration)
|
||||||
|
.easing(Easing.Linear.None)
|
||||||
|
.onUpdate(() => {
|
||||||
|
target.setRGB(state.r, state.g, state.b);
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
});
|
||||||
|
trackTween(object, key, tween);
|
||||||
|
tween.start();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVector3(target: THREE.Vector3, value: Vector3Tuple) {
|
||||||
|
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
target.set(value[0], value[1], value[2]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateEuler(target: THREE.Euler, value: Vector3Tuple) {
|
||||||
|
if (target.x === value[0] && target.y === value[1] && target.z === value[2]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
target.set(value[0], value[1], value[2]);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMaterial(
|
||||||
|
object: THREE.Object3D,
|
||||||
|
material: THREE.Material,
|
||||||
|
data: AstralMaterialFormat,
|
||||||
|
errors: string[],
|
||||||
|
transition: number,
|
||||||
|
onUpdate?: (object: THREE.Object3D) => void
|
||||||
|
) {
|
||||||
|
let changed = false;
|
||||||
|
const duration = normalizeTransition(transition);
|
||||||
|
const mat = material as any;
|
||||||
|
const materialKey = material.uuid || "material";
|
||||||
|
|
||||||
|
if (data.color !== undefined && mat.color && typeof mat.color.set === "function") {
|
||||||
|
let nextColor: THREE.Color | null = null;
|
||||||
|
try {
|
||||||
|
nextColor = new THREE.Color();
|
||||||
|
nextColor.set(data.color);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`material.color invalid: ${String((error as Error).message || error)}`);
|
||||||
|
}
|
||||||
|
if (nextColor) {
|
||||||
|
const key = `material:${materialKey}:color`;
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (duration > 0) {
|
||||||
|
changed = tweenColor(object, key, mat.color, nextColor, duration, onUpdate) || changed;
|
||||||
|
} else if (typeof mat.color.equals !== "function" || !mat.color.equals(nextColor)) {
|
||||||
|
mat.color.copy(nextColor);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.emissive !== undefined && mat.emissive && typeof mat.emissive.set === "function") {
|
||||||
|
let nextEmissive: THREE.Color | null = null;
|
||||||
|
try {
|
||||||
|
nextEmissive = new THREE.Color();
|
||||||
|
nextEmissive.set(data.emissive);
|
||||||
|
} catch (error) {
|
||||||
|
errors.push(`material.emissive invalid: ${String((error as Error).message || error)}`);
|
||||||
|
}
|
||||||
|
if (nextEmissive) {
|
||||||
|
const key = `material:${materialKey}:emissive`;
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (duration > 0) {
|
||||||
|
changed = tweenColor(object, key, mat.emissive, nextEmissive, duration, onUpdate) || changed;
|
||||||
|
} else if (typeof mat.emissive.equals !== "function" || !mat.emissive.equals(nextEmissive)) {
|
||||||
|
mat.emissive.copy(nextEmissive);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.opacity !== undefined && "opacity" in mat) {
|
||||||
|
const next = clamp(Number(data.opacity), 0, 1);
|
||||||
|
if (next === null) {
|
||||||
|
errors.push("material.opacity must be a number between 0 and 1");
|
||||||
|
} else {
|
||||||
|
const key = `material:${materialKey}:opacity`;
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (duration > 0) {
|
||||||
|
changed = tweenNumber(object, key, mat, "opacity", next, duration, onUpdate) || changed;
|
||||||
|
} else if (mat.opacity !== next) {
|
||||||
|
mat.opacity = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.transparent !== undefined && "transparent" in mat) {
|
||||||
|
const next = Boolean(data.transparent);
|
||||||
|
const key = `material:${materialKey}:transparent`;
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (mat.transparent !== next) {
|
||||||
|
mat.transparent = next;
|
||||||
|
changed = true;
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.metalness !== undefined && "metalness" in mat) {
|
||||||
|
const next = clamp(Number(data.metalness), 0, 1);
|
||||||
|
if (next === null) {
|
||||||
|
errors.push("material.metalness must be a number between 0 and 1");
|
||||||
|
} else {
|
||||||
|
const key = `material:${materialKey}:metalness`;
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (duration > 0) {
|
||||||
|
changed = tweenNumber(object, key, mat, "metalness", next, duration, onUpdate) || changed;
|
||||||
|
} else if (mat.metalness !== next) {
|
||||||
|
mat.metalness = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.roughness !== undefined && "roughness" in mat) {
|
||||||
|
const next = clamp(Number(data.roughness), 0, 1);
|
||||||
|
if (next === null) {
|
||||||
|
errors.push("material.roughness must be a number between 0 and 1");
|
||||||
|
} else {
|
||||||
|
const key = `material:${materialKey}:roughness`;
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (duration > 0) {
|
||||||
|
changed = tweenNumber(object, key, mat, "roughness", next, duration, onUpdate) || changed;
|
||||||
|
} else if (mat.roughness !== next) {
|
||||||
|
mat.roughness = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.emissiveIntensity !== undefined && "emissiveIntensity" in mat) {
|
||||||
|
const next = clamp(Number(data.emissiveIntensity), 0, 1);
|
||||||
|
if (next === null) {
|
||||||
|
errors.push("material.emissiveIntensity must be a number between 0 and 1");
|
||||||
|
} else {
|
||||||
|
const key = `material:${materialKey}:emissiveIntensity`;
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (duration > 0) {
|
||||||
|
changed = tweenNumber(object, key, mat, "emissiveIntensity", next, duration, onUpdate) || changed;
|
||||||
|
} else if (mat.emissiveIntensity !== next) {
|
||||||
|
mat.emissiveIntensity = next;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed && "needsUpdate" in mat) {
|
||||||
|
mat.needsUpdate = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (duration === 0 && changed) {
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
}
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnimationActionByName(object: THREE.Object3D, name: string) {
|
||||||
|
const animations = (object as any)?.animations;
|
||||||
|
if (!Array.isArray(animations)) return null;
|
||||||
|
|
||||||
|
for (const animation of animations) {
|
||||||
|
if (!animation || typeof (animation as any).getClip !== "function") continue;
|
||||||
|
|
||||||
|
const clip = (animation as THREE.AnimationAction).getClip?.();
|
||||||
|
if (!clip || clip.name !== name) continue;
|
||||||
|
|
||||||
|
if (typeof (animation as any).play === "function") {
|
||||||
|
return animation as THREE.AnimationAction;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAnimations(object: THREE.Object3D, commands: AstralAnimationCommand[], errors: string[]) {
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
commands.forEach((command, index) => {
|
||||||
|
if (!isPlainObject(command)) {
|
||||||
|
errors.push(`animations[${index}] must be an object`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = typeof command.name === "string" ? command.name.trim() : "";
|
||||||
|
if (!name) {
|
||||||
|
errors.push(`animations[${index}].name is required`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const behaviorValue = typeof command.behavior === "string" ? command.behavior.trim().toLowerCase() : "";
|
||||||
|
if (!animationBehaviors.has(behaviorValue as AstralAnimationBehavior)) {
|
||||||
|
errors.push(`animations[${index}].behavior must be play | pause | stop`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const action = getAnimationActionByName(object, name);
|
||||||
|
if (!action) return;
|
||||||
|
|
||||||
|
if (command.duration !== undefined) {
|
||||||
|
const duration = Number(command.duration);
|
||||||
|
if (!Number.isFinite(duration) || duration <= 0) {
|
||||||
|
errors.push(`animations[${index}].duration must be a positive number`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof action.setDuration === "function") {
|
||||||
|
action.setDuration(duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (behaviorValue) {
|
||||||
|
case "play":
|
||||||
|
action.play();
|
||||||
|
action.paused = false;
|
||||||
|
break;
|
||||||
|
case "pause":
|
||||||
|
action.paused = true;
|
||||||
|
break;
|
||||||
|
case "stop":
|
||||||
|
action.stop();
|
||||||
|
action.paused = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
changed = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DataBindingManager {
|
||||||
|
static setConfig(object: THREE.Object3D, config: DataComponentConfig, options: SetConfigOptions = {}) {
|
||||||
|
const index = normalizeIndex(options.index);
|
||||||
|
const entry = ensureDataComponentEntry(object, index);
|
||||||
|
entry.config = config && typeof config === "object" ? { ...config } : {};
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
static setData(object: THREE.Object3D, data: unknown, options: SetDataOptions = {}): DataBindingResult {
|
||||||
|
const index = normalizeIndex(options.index);
|
||||||
|
const entry = ensureDataComponentEntry(object, index);
|
||||||
|
entry.data = data;
|
||||||
|
|
||||||
|
const config = entry.config || {};
|
||||||
|
if (App.viewer) {
|
||||||
|
App.viewer.dispatchEvent({
|
||||||
|
type: "bindDataChange",
|
||||||
|
object,
|
||||||
|
data,
|
||||||
|
config,
|
||||||
|
index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldApply = options.applyToModel ?? Boolean(config.applyToModel);
|
||||||
|
if (!shouldApply) {
|
||||||
|
return { changed: false, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const transition =
|
||||||
|
options.transition !== undefined ? options.transition : Number.isFinite(Number(config.transition)) ? Number(config.transition) : undefined;
|
||||||
|
|
||||||
|
return DataBindingManager.applyDataToObject(object, data, {
|
||||||
|
transition,
|
||||||
|
onUpdate: options.onUpdate,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static applyDataToObject(object: THREE.Object3D, data: unknown, options: DataBindingOptions = {}): DataBindingResult {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (!isPlainObject(data)) {
|
||||||
|
return { changed: false, errors: ["data must be an object"] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = data as AstralDataFormat;
|
||||||
|
const transition = normalizeTransition(options.transition);
|
||||||
|
const onUpdate = options.onUpdate;
|
||||||
|
|
||||||
|
if (payload.position !== undefined) {
|
||||||
|
const next = parseVector3(payload.position, "position", errors);
|
||||||
|
if (next) {
|
||||||
|
const key = "position";
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (transition > 0) {
|
||||||
|
changed = tweenVector3(object, key, object.position, next, transition, onUpdate) || changed;
|
||||||
|
} else {
|
||||||
|
changed = updateVector3(object.position, next) || changed;
|
||||||
|
if (changed) {
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.rotation !== undefined) {
|
||||||
|
const next = parseVector3(payload.rotation, "rotation", errors);
|
||||||
|
if (next) {
|
||||||
|
const key = "rotation";
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (transition > 0) {
|
||||||
|
changed = tweenEuler(object, key, object.rotation, next, transition, onUpdate) || changed;
|
||||||
|
} else {
|
||||||
|
changed = updateEuler(object.rotation, next) || changed;
|
||||||
|
if (changed) {
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.scale !== undefined) {
|
||||||
|
const next = parseVector3(payload.scale, "scale", errors);
|
||||||
|
if (next) {
|
||||||
|
const key = "scale";
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (transition > 0) {
|
||||||
|
changed = tweenVector3(object, key, object.scale, next, transition, onUpdate) || changed;
|
||||||
|
} else {
|
||||||
|
changed = updateVector3(object.scale, next) || changed;
|
||||||
|
if (changed) {
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.visible !== undefined) {
|
||||||
|
const next = Boolean(payload.visible);
|
||||||
|
const key = "visible";
|
||||||
|
stopTweensForKey(object, key);
|
||||||
|
if (object.visible !== next) {
|
||||||
|
object.visible = next;
|
||||||
|
changed = true;
|
||||||
|
scheduleObjectUpdate(object, onUpdate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.material && isPlainObject(payload.material)) {
|
||||||
|
const targetMaterial = (object as any).material;
|
||||||
|
if (Array.isArray(targetMaterial)) {
|
||||||
|
targetMaterial.forEach((mat: THREE.Material) => {
|
||||||
|
changed =
|
||||||
|
updateMaterial(object, mat, payload.material as AstralMaterialFormat, errors, transition, onUpdate) || changed;
|
||||||
|
});
|
||||||
|
} else if (targetMaterial) {
|
||||||
|
changed =
|
||||||
|
updateMaterial(
|
||||||
|
object,
|
||||||
|
targetMaterial as THREE.Material,
|
||||||
|
payload.material as AstralMaterialFormat,
|
||||||
|
errors,
|
||||||
|
transition,
|
||||||
|
onUpdate
|
||||||
|
) || changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.animations !== undefined) {
|
||||||
|
if (!Array.isArray(payload.animations)) {
|
||||||
|
errors.push("animations must be an array");
|
||||||
|
} else {
|
||||||
|
changed = applyAnimations(object, payload.animations, errors) || changed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { changed, errors };
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,3 +5,4 @@ export {ClippedEdgesBox} from "./ClippedEdgesBox";
|
|||||||
export {Measure,MeasureMode} from "./Measure";
|
export {Measure,MeasureMode} from "./Measure";
|
||||||
export {Export} from "./Export";
|
export {Export} from "./Export";
|
||||||
export {ModelExplode} from "./ModelExplode";
|
export {ModelExplode} from "./ModelExplode";
|
||||||
|
export {DataBindingManager} from "./DataBindingManager";
|
||||||
|
|||||||
@ -61,6 +61,14 @@ export interface ViewerEventMap {
|
|||||||
// 模型双击事件
|
// 模型双击事件
|
||||||
onDoubleClick: { intersect: THREE.Intersection, object: THREE.Object3D };
|
onDoubleClick: { intersect: THREE.Intersection, object: THREE.Object3D };
|
||||||
|
|
||||||
|
// 数据组件绑定数据更新
|
||||||
|
bindDataChange: {
|
||||||
|
object: THREE.Object3D;
|
||||||
|
data: unknown;
|
||||||
|
config: unknown;
|
||||||
|
index: number;
|
||||||
|
};
|
||||||
|
|
||||||
// 键盘按下事件(全局)
|
// 键盘按下事件(全局)
|
||||||
onKeyDown: { event: KeyboardEvent };
|
onKeyDown: { event: KeyboardEvent };
|
||||||
|
|
||||||
|
|||||||
119
packages/sdk/types/app/Project.d.ts
vendored
119
packages/sdk/types/app/Project.d.ts
vendored
@ -25,6 +25,39 @@ declare namespace IAppProject {
|
|||||||
|
|
||||||
interface Effect {
|
interface Effect {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
ToneMapping: {
|
||||||
|
mode: "LINEAR" | "REINHARD" | "REINHARD2" | "OPTIMIZED_CINEON" | "ACES_FILMIC" | "AGX" | "NEUTRAL";
|
||||||
|
exposure: number;
|
||||||
|
blendFunction: string;
|
||||||
|
};
|
||||||
|
SMAA: {
|
||||||
|
enabled: boolean;
|
||||||
|
preset: string;
|
||||||
|
};
|
||||||
|
SSAO: {
|
||||||
|
enabled: boolean;
|
||||||
|
blendFunction: string;
|
||||||
|
samples: number;
|
||||||
|
rings: number;
|
||||||
|
radius: number;
|
||||||
|
intensity: number;
|
||||||
|
bias: number;
|
||||||
|
fade: number;
|
||||||
|
luminanceInfluence: number;
|
||||||
|
minRadiusScale: number;
|
||||||
|
depthAwareUpsampling: boolean;
|
||||||
|
resolutionScale: number;
|
||||||
|
distanceThreshold: number;
|
||||||
|
distanceFalloff: number;
|
||||||
|
rangeThreshold: number;
|
||||||
|
rangeFalloff: number;
|
||||||
|
worldDistanceThreshold: number | null;
|
||||||
|
worldDistanceFalloff: number | null;
|
||||||
|
worldProximityThreshold: number | null;
|
||||||
|
worldProximityFalloff: number | null;
|
||||||
|
colorEnabled: boolean;
|
||||||
|
color: string;
|
||||||
|
};
|
||||||
Outline: {
|
Outline: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
edgeStrength: number;
|
edgeStrength: number;
|
||||||
@ -34,6 +67,12 @@ declare namespace IAppProject {
|
|||||||
usePatternTexture: boolean;
|
usePatternTexture: boolean;
|
||||||
visibleEdgeColor: string;
|
visibleEdgeColor: string;
|
||||||
hiddenEdgeColor: string;
|
hiddenEdgeColor: string;
|
||||||
|
pulseSpeed?: number;
|
||||||
|
xRay?: boolean;
|
||||||
|
blur?: boolean;
|
||||||
|
kernelSize?: number;
|
||||||
|
multisampling?: number;
|
||||||
|
blendFunction?: string;
|
||||||
};
|
};
|
||||||
FXAA: {
|
FXAA: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@ -43,18 +82,98 @@ declare namespace IAppProject {
|
|||||||
threshold: number;
|
threshold: number;
|
||||||
strength: number;
|
strength: number;
|
||||||
radius: number;
|
radius: number;
|
||||||
|
intensity?: number;
|
||||||
|
luminanceThreshold?: number;
|
||||||
|
luminanceSmoothing?: number;
|
||||||
|
levels?: number;
|
||||||
|
mipmapBlur?: boolean;
|
||||||
|
blendFunction?: string;
|
||||||
};
|
};
|
||||||
Bokeh: {
|
Bokeh: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
focus: number;
|
focus: number;
|
||||||
aperture: number;
|
aperture: number;
|
||||||
maxblur: number;
|
maxblur: number;
|
||||||
|
focusDistance?: number;
|
||||||
|
focusRange?: number;
|
||||||
|
bokehScale?: number;
|
||||||
|
resolutionScale?: number;
|
||||||
};
|
};
|
||||||
Pixelate: {
|
Pixelate: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
pixelSize: number;
|
pixelSize: number;
|
||||||
normalEdgeStrength: number;
|
normalEdgeStrength: number;
|
||||||
depthEdgeStrength: number;
|
depthEdgeStrength: number;
|
||||||
|
granularity?: number;
|
||||||
|
};
|
||||||
|
TiltShift?: {
|
||||||
|
enabled: boolean;
|
||||||
|
offset: number;
|
||||||
|
rotation: number;
|
||||||
|
focusArea: number;
|
||||||
|
feather: number;
|
||||||
|
blendFunction: string;
|
||||||
|
};
|
||||||
|
Scanline?: {
|
||||||
|
enabled: boolean;
|
||||||
|
density: number;
|
||||||
|
scrollSpeed: number;
|
||||||
|
blendFunction: string;
|
||||||
|
};
|
||||||
|
BrightnessContrast?: {
|
||||||
|
enabled: boolean;
|
||||||
|
brightness: number;
|
||||||
|
contrast: number;
|
||||||
|
blendFunction: string;
|
||||||
|
};
|
||||||
|
ChromaticAberration?: {
|
||||||
|
enabled: boolean;
|
||||||
|
offset: { x: number; y: number };
|
||||||
|
radialModulation: boolean;
|
||||||
|
modulationOffset: number;
|
||||||
|
blendFunction: string;
|
||||||
|
};
|
||||||
|
ColorDepth?: {
|
||||||
|
enabled: boolean;
|
||||||
|
bits: number;
|
||||||
|
blendFunction: string;
|
||||||
|
};
|
||||||
|
Glitch?: {
|
||||||
|
enabled: boolean;
|
||||||
|
chromaticAberrationOffset: { x: number; y: number } | null;
|
||||||
|
delay: { min: number; max: number };
|
||||||
|
duration: { min: number; max: number };
|
||||||
|
strength: { min: number; max: number };
|
||||||
|
mode: "SPORADIC" | "CONSTANT_MILD" | "CONSTANT_WILD";
|
||||||
|
ratio: number;
|
||||||
|
blendFunction: string;
|
||||||
|
};
|
||||||
|
HueSaturation?: {
|
||||||
|
enabled: boolean;
|
||||||
|
hue: number;
|
||||||
|
saturation: number;
|
||||||
|
blendFunction: string;
|
||||||
|
};
|
||||||
|
LensDistortion?: {
|
||||||
|
enabled: boolean;
|
||||||
|
distortion: { x: number; y: number };
|
||||||
|
principalPoint: { x: number; y: number };
|
||||||
|
focalLength: { x: number; y: number };
|
||||||
|
skew: number;
|
||||||
|
};
|
||||||
|
ShockWave?: {
|
||||||
|
enabled: boolean;
|
||||||
|
amplitude: number;
|
||||||
|
waveSize: number;
|
||||||
|
speed: number;
|
||||||
|
maxRadius: number;
|
||||||
|
clickTrigger: boolean;
|
||||||
|
};
|
||||||
|
Vignette?: {
|
||||||
|
enabled: boolean;
|
||||||
|
offset: number;
|
||||||
|
darkness: number;
|
||||||
|
blendFunction: string;
|
||||||
};
|
};
|
||||||
Halftone: {
|
Halftone: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user