Compare commits

..

6 Commits

9 changed files with 408 additions and 197 deletions

View File

@ -17,7 +17,7 @@ export interface DataSetPayload {
type: "API" | "SQL" | "JSON" | string;
method?: IDataSet.Item["method"];
api?: string;
dataSourceId?: string;
dataSourceId?: IDataSet.Item["id"];
sql?: string;
json?: string;
}

View File

@ -53,7 +53,7 @@ function update(method: string){
if(data.type === 'panel' && object.isHtmlPanel) 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.options.isSprite = !_json.object.options.isSprite;
@ -135,19 +135,24 @@ function updateFileList(fList: UploadFileInfo[]) {
isSprite: data.type ==='sprite',
fileName: file.name
}).then(htmlPlaneObj => {
data.codes = htmlPlaneObj.options.codes;
object.options.codes = htmlPlaneObj.options.codes;
object.options.isSingleHtml = htmlPlaneObj.options.isSingleHtml;
const _json = object.toJSON();
Loader.objectLoader.copyAttrByData(htmlPlaneObj, _json.object)
data.codes = object.options.codes;
object.parent?.add(htmlPlaneObj);
// Keep object identity (id/uuid) so scene tree and context menu references stay valid.
while (object.element.firstChild) {
object.element.removeChild(object.element.firstChild);
}
object.parent?.remove(object);
while (htmlPlaneObj.element.firstChild) {
object.element.appendChild(htmlPlaneObj.element.firstChild);
}
App.selected = htmlPlaneObj;
Hooks.useDispatchSignal("objectSelected", htmlPlaneObj);
}).catch((e:Error) => window.$message?.error(e.message));
App.selected = object;
Hooks.useDispatchSignal("objectSelected", object);
}).catch((e:Error) => window.$message?.error(e.message))
.finally(() => URL.revokeObjectURL(tempURL));
}
function getCanEdit(name: string) {

View File

@ -121,15 +121,15 @@ const updateUI = Utils.throttle(function(object) {
objectData.type = object.type;
objectData.uuid = object.uuid;
objectData.name = object.name;
objectData.position.x = Number(object.position.x.toFixed(3));
objectData.position.y = Number(object.position.y.toFixed(3));
objectData.position.z = Number(object.position.z.toFixed(3));
objectData.rotation.x = Number((object.rotation.x * THREE.MathUtils.RAD2DEG).toFixed(3));
objectData.rotation.y = Number((object.rotation.y * THREE.MathUtils.RAD2DEG).toFixed(3));
objectData.rotation.z = Number((object.rotation.z * THREE.MathUtils.RAD2DEG).toFixed(3));
objectData.scale.x = Number(object.scale.x.toFixed(3));
objectData.scale.y = Number(object.scale.y.toFixed(3));
objectData.scale.z = Number(object.scale.z.toFixed(3));
objectData.position.x = Number(object.position.x.toFixed(6));
objectData.position.y = Number(object.position.y.toFixed(6));
objectData.position.z = Number(object.position.z.toFixed(6));
objectData.rotation.x = Number((object.rotation.x * THREE.MathUtils.RAD2DEG).toFixed(6));
objectData.rotation.y = Number((object.rotation.y * THREE.MathUtils.RAD2DEG).toFixed(6));
objectData.rotation.z = Number((object.rotation.z * THREE.MathUtils.RAD2DEG).toFixed(6));
objectData.scale.x = Number(object.scale.x.toFixed(6));
objectData.scale.y = Number(object.scale.y.toFixed(6));
objectData.scale.z = Number(object.scale.z.toFixed(6));
if (object.fov !== undefined) {
objectData.fov = object.fov;
@ -225,19 +225,19 @@ const update = (method: string) => {
},
position: () => {
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));
}
},
rotation: () => {
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));
}
},
scale: () => {
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));
}
},
@ -433,11 +433,11 @@ const handleUserDataClick = () => {
<div class="sider-scene-attr-item">
<EsKeyFrame :label="t('layout.sider.object.position')" attr="position" />
<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')" />
<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')" />
<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')" />
</div>
</div>
@ -445,11 +445,11 @@ const handleUserDataClick = () => {
<div class="sider-scene-attr-item" v-if="transformRowsVisible.rotation">
<EsKeyFrame :label="t('layout.sider.object.rotation')" attr="quaternion" />
<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="°" />
<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="°" />
<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="°" />
</div>
</div>

View File

@ -15,7 +15,7 @@
<script setup lang="ts">
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 PathDrawingOverlay from "./PathDrawingOverlay.vue";
import {useGlobalConfigStore} from "@/store/modules/globalConfig";
@ -30,6 +30,43 @@ const pluginStore = usePluginStore();
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 () => {
App.setConfig({
theme: globalStore.theme.replace("Theme", ""),
@ -48,6 +85,8 @@ onMounted(async () => {
await nextTick();
bootstrapWaterPoolWarmup(window.viewer);
// astral engine
pluginStore.setPlugins(Array.from(window.viewer.modules.plugin.plugins.values()));
Hooks.useAddSignal("pluginInstall",pluginStore.addPlugin);

View File

@ -65,7 +65,7 @@ const defaultDataSet: IDataSet.Item = {
type: 'API',
method: 'GET',
api: '',
dataSource: '',
dataSourceId: '',
sql: '',
json: ''
};
@ -179,7 +179,7 @@ async function editDataSet(item) {
const detail = (res.data || item) as any;
resetCurrentDataSet({
...detail,
dataSource: detail.dataSourceId || detail.dataSource || ""
dataSourceId: detail.dataSourceId || detail.dataSource ? String(detail.dataSourceId || detail.dataSource) : ""
});
showDataSetModal.value = true
}

View File

@ -6,31 +6,44 @@
<n-form-item-gi :span="12" :label="t('home.Data set name')" path="name">
<n-input v-model:value="model.name" />
</n-form-item-gi>
<n-form-item-gi :span="12" :label="t('home.Data set group')">
<n-cascader 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 :span="12" :label="t('home.Data set group')" path="groupId">
<n-cascader
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 :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-form-item-gi>
<template v-if="model.type === 'API'">
<n-form-item-gi :span="12" :label="t('home.Method')">
<n-select v-model:value="model.type" :options="[
<n-form-item-gi :span="12" :label="t('home.Method')" path="method">
<n-select
:key="'dataset-method-select'"
v-model:value="model.method"
:options="[
{ label: 'GET', value: 'GET' },
{ label: 'POST', value: 'POST' },
]" />
]"
/>
</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-form-item-gi>
</template>
<template v-else-if="model.type === 'SQL'">
<n-form-item-gi :span="12" :label="t('home.Data sources')">
<n-select v-model:value="model.dataSource" :options="dataSourceOptions" />
<n-form-item-gi :span="12" :label="t('home.Data sources')" path="dataSourceId">
<n-select :key="'dataset-datasource-select'" v-model:value="model.dataSourceId" :options="dataSourceOptions" />
</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" />
</n-form-item-gi>
<n-form-item-gi :span="24" label=" ">
@ -44,7 +57,7 @@
</template>
<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" />
</n-form-item-gi>
</template>
@ -62,119 +75,256 @@
</template>
<script lang="ts" setup>
import { ref, watch, useTemplateRef } from "vue";
import type { FormInst } from 'naive-ui'
import { ref, watch, computed, useTemplateRef } from "vue";
import type { FormInst } from "naive-ui";
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 JSONEditor from "@/components/code/JSONEditor.vue";
const props = withDefaults(defineProps<{
show: boolean,
model: IDataSet.Item
}>(), {
const props = withDefaults(
defineProps<{
show: boolean;
model?: IDataSet.Item;
}>(),
{
show: false,
model: () => ({
id: '',
id: "",
groupId: "",
name: '',
type: 'SQL',
})
})
name: "",
type: "API",
method: "GET",
api: "",
dataSourceId: "",
sql: "",
json: "",
createTime: "",
}),
}
);
const emits = defineEmits(["update:show", "refresh"]);
const formRef = useTemplateRef<FormInst>("formRef");
const rules = {
name: { required: true, message: t("prompt.Please enter a name for the dataset"), trigger: 'blur' }
function createRequiredRule(message: string, trigger: Array<"input" | "blur" | "change">) {
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 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 = [
{ label: 'API', value: 'API' },
{ label: 'SQL', value: 'SQL' },
{ label: 'JSON', value: 'JSON' },
{ label: "API", value: "API" },
{ label: "SQL", value: "SQL" },
{ label: "JSON", value: "JSON" },
];
const dataSourceOptions = ref<{ label: string; value: IDataSource.Item["id"] }[]>([]);
const submitLoading = ref(false);
const isEdit = computed(() => Boolean(props.model?.id));
watch(() => props.show, (show) => {
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();
});
watch(() => props.model.type, (nextType, oldType) => {
if (!nextType || nextType === oldType) {
return;
if (props.model) {
props.model.dataSourceId = normalizeDataSourceId(props.model.dataSourceId);
}
if (nextType !== "API") {
props.model.method = undefined;
props.model.api = "";
} else if (!props.model.method) {
if (props.model?.type === "API" && !props.model.method) {
props.model.method = "GET";
}
if (nextType !== "SQL") {
props.model.dataSource = "";
props.model.sql = "";
if (props.model?.type === "JSON") {
props.model.json = normalizeJsonValue(props.model.json);
}
if (nextType !== "JSON") {
props.model.json = "";
}
});
);
async function loadOptions() {
const [groupRes, dataSourceRes] = await Promise.all([
fetchDataSetGroupTree(),
fetchDataSourceList()
]);
groupOptions.value = groupRes.data || [];
dataSourceOptions.value = (dataSourceRes.data || []).map(item => ({
label: item.name,
value: item.id
}));
watch(
() => props.model?.type,
(next, prev) => {
if (!next || next === prev) {
return;
}
resetFieldsForType(next, prev);
formRef.value?.restoreValidation();
}
);
function handleClose() {
emits("update:show", false);
}
function saveDataSet(e: MouseEvent) {
e.preventDefault()
e.preventDefault();
formRef.value?.validate(async (errors) => {
if (!errors) {
submitLoading.value = true;
const payload: DataSetPayload = {
id: props.model.id || undefined,
name: props.model.name?.trim() || "",
groupId: props.model.groupId,
type: props.model.type
};
if (props.model.type === "API") {
payload.method = props.model.method;
payload.api = props.model.api?.trim();
} else if (props.model.type === "SQL") {
payload.dataSourceId = props.model.dataSource;
payload.sql = props.model.sql?.trim();
} else if (props.model.type === "JSON") {
payload.json = props.model.json;
formRef.value?.validate(async errors => {
if (errors || !props.model) {
return;
}
const res = props.model.id ? await fetchUpdateDataSet(payload) : await fetchCreateDataSet(payload);
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(props.model.id ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
//
window.$message?.success(isEdit.value ? t("prompt.Success to update") : t("prompt.Saved successfully!"));
emits("refresh");
handleClose();
}
})
});
}
</script>

View File

@ -1,6 +1,7 @@
declare namespace IDataSource {
type Id = string | number;
interface Item {
id: string;
id: Id;
name: string;
type: string;
connectionString: string;
@ -10,21 +11,24 @@ declare namespace IDataSource {
}
declare namespace IDataSet {
type Id = string | number;
interface Item {
id: string;
groupId: string;
id: Id;
groupId: Id;
name: string;
type: string;
method?: "GET" | "POST";
api?: string;
dataSource?: string;
dataSourceId?: Id;
dataSource?: Id;
sql?: string;
json?: string;
createTime?: string;
}
interface IGroup {
id: string;
pid?: string;
id: Id;
pid?: Id;
name: string;
children?: IGroup[];
}

View File

@ -65,6 +65,8 @@ export default class Path extends THREE.Mesh {
if (Path.flowSignalBound) return;
Path.flowSignalBound = true;
useAddSignal("sceneRendered", Path.handleFlowTick);
// 立即请求一帧渲染,防止场景静止时 sceneRendered 永远不触发导致流动停止
(App.viewer as any)?.pluginRequestRender?.(true);
}
private static unbindFlowSignal() {

View File

@ -206,6 +206,12 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
//整个主场景的box3
public sceneBox3 = new THREE.Box3();
public package: Package;
private _pluginNeedsRender = false;
/** 供插件/内部模块请求下一帧强制渲染,传 true 标记animate() 检测后自动清除 */
pluginRequestRender(value: boolean) {
this._pluginNeedsRender = value;
}
constructor(options: IViewerSetting) {
super();
@ -1107,6 +1113,11 @@ export default class Viewer extends THREE.EventDispatcher<ViewerEventMap> {
needRender = true;
}
if (this._pluginNeedsRender) {
needRender = true;
this._pluginNeedsRender = false;
}
this.dispatchEvent({type: 'afterAnimation', delta: timeStamp, toBeRender: (_needRender:boolean = false) => {
needRender = _needRender;
}