Commit 43809eefdb32641325d4f6ddbe30cc7228d69c95
Merge branch 'fix/3d-model-load' into 'main_dev'
perf: 优化3d模型加载 See merge request yunteng/thingskit-view!309
Showing
10 changed files
with
231 additions
and
69 deletions
dist.zip
deleted
100644 → 0
No preview for this file type
editor/js/Menubar.Save.js
0 → 100644
| 1 | +import { UIPanel, UIButton } from './libs/ui.js'; | ||
| 2 | +import html2canvas from 'html2canvas' | ||
| 3 | +import useMessage from './MessageDialog.js' | ||
| 4 | +import { saveOrUpdateThreeJsModel } from './libs/http/api.js' | ||
| 5 | +import { fetchRouteParamsLocation } from '@/utils' | ||
| 6 | +import { uploadFile } from '@/api/external/contentSave/content' | ||
| 7 | +import { base64toFile } from '../utils/Base64ToFile.js' | ||
| 8 | +import { useSpin } from '../js/libs/spin/useSpin.js' | ||
| 9 | + | ||
| 10 | +function MenubarSave(editor) { | ||
| 11 | + const strings = editor.strings | ||
| 12 | + | ||
| 13 | + const container = new UIPanel(); | ||
| 14 | + container.setClass('menu right'); | ||
| 15 | + container.setStyle('height', ['100%']) | ||
| 16 | + container.setStyle('display', ['flex']) | ||
| 17 | + | ||
| 18 | + const saveButton = new UIButton(strings.getKey('menubar/file/save')) | ||
| 19 | + container.add(saveButton) | ||
| 20 | + | ||
| 21 | + saveButton.onClick(async function () { | ||
| 22 | + | ||
| 23 | + // 获取缩略图片 | ||
| 24 | + const range = document.querySelector('#viewport').children[3] | ||
| 25 | + const { spin, stop } = useSpin() | ||
| 26 | + const { success, error } = useMessage() | ||
| 27 | + try { | ||
| 28 | + spin() | ||
| 29 | + // 生成图片 | ||
| 30 | + const canvasImage = await html2canvas(range, { | ||
| 31 | + backgroundColor: null, | ||
| 32 | + allowTaint: true, | ||
| 33 | + useCORS: true, | ||
| 34 | + logging: false | ||
| 35 | + }) | ||
| 36 | + // 上传预览图 | ||
| 37 | + const uploadParams = new FormData() | ||
| 38 | + uploadParams.append( | ||
| 39 | + 'file', | ||
| 40 | + base64toFile(canvasImage.toDataURL(), `${fetchRouteParamsLocation()}_index_preview.png`) | ||
| 41 | + ) | ||
| 42 | + const uploadRes = await uploadFile(uploadParams) | ||
| 43 | + const file_json = editor.toJSON() | ||
| 44 | + const paramsStr = window.location.search | ||
| 45 | + const params = new URLSearchParams(paramsStr) | ||
| 46 | + const file_uuid = params.get('three_file_uuid') | ||
| 47 | + await saveOrUpdateThreeJsModel({ | ||
| 48 | + id: file_uuid, | ||
| 49 | + imageUrl: uploadRes?.fileDownloadUri, | ||
| 50 | + data: file_json | ||
| 51 | + }) | ||
| 52 | + success('保存成功') | ||
| 53 | + } catch (e) { | ||
| 54 | + error(e?.message || e?.msg || '网络错误') | ||
| 55 | + } finally { | ||
| 56 | + stop() | ||
| 57 | + } | ||
| 58 | + | ||
| 59 | + }) | ||
| 60 | + | ||
| 61 | + return container; | ||
| 62 | +} | ||
| 63 | + | ||
| 64 | +export { MenubarSave }; |
| @@ -5,20 +5,22 @@ import { MenubarEdit } from './Menubar.Edit.js'; | @@ -5,20 +5,22 @@ import { MenubarEdit } from './Menubar.Edit.js'; | ||
| 5 | import { MenubarFile } from './Menubar.File.js'; | 5 | import { MenubarFile } from './Menubar.File.js'; |
| 6 | import { MenubarView } from './Menubar.View.js'; | 6 | import { MenubarView } from './Menubar.View.js'; |
| 7 | import { MenubarHelp } from './Menubar.Help.js'; | 7 | import { MenubarHelp } from './Menubar.Help.js'; |
| 8 | -import { MenubarStatus } from './Menubar.Status.js'; | 8 | +// import { MenubarStatus } from './Menubar.Status.js'; |
| 9 | +import { MenubarSave } from './Menubar.Save.js' | ||
| 9 | 10 | ||
| 10 | -function Menubar( editor ) { | 11 | +function Menubar(editor) { |
| 11 | 12 | ||
| 12 | const container = new UIPanel(); | 13 | const container = new UIPanel(); |
| 13 | - container.setId( 'menubar' ); | 14 | + container.setId('menubar'); |
| 14 | 15 | ||
| 15 | - container.add( new MenubarFile( editor ) ); | ||
| 16 | - container.add( new MenubarEdit( editor ) ); | ||
| 17 | - container.add( new MenubarAdd( editor ) ); | ||
| 18 | - container.add( new MenubarView( editor ) ); | ||
| 19 | - container.add( new MenubarHelp( editor ) ); | 16 | + container.add(new MenubarFile(editor)); |
| 17 | + container.add(new MenubarEdit(editor)); | ||
| 18 | + container.add(new MenubarAdd(editor)); | ||
| 19 | + container.add(new MenubarView(editor)); | ||
| 20 | + container.add(new MenubarHelp(editor)); | ||
| 20 | 21 | ||
| 21 | // container.add( new MenubarStatus( editor ) ); | 22 | // container.add( new MenubarStatus( editor ) ); |
| 23 | + container.add(new MenubarSave(editor)) | ||
| 22 | 24 | ||
| 23 | return container; | 25 | return container; |
| 24 | 26 |
src/api/external/3dModel/index.ts
0 → 100644
| 1 | +import { defHttp } from '@/utils/external/http/axios'; | ||
| 2 | +import { ModelComponentItemType } from './model'; | ||
| 3 | + | ||
| 4 | + | ||
| 5 | + | ||
| 6 | + | ||
| 7 | +export const doGet3DComponentJson = (id: string, ) => { | ||
| 8 | + return defHttp.get<ModelComponentItemType | null>( | ||
| 9 | + { | ||
| 10 | + url: `/3d_component/json/${id}/root.json` | ||
| 11 | + } | ||
| 12 | + ) | ||
| 13 | +} |
src/api/external/3dModel/model/index.ts
0 → 100644
| @@ -284,6 +284,8 @@ export default { | @@ -284,6 +284,8 @@ export default { | ||
| 284 | after: 'After rendering', | 284 | after: 'After rendering', |
| 285 | doesDomComponent: 'At this time, the component DOM does not yet exist', | 285 | doesDomComponent: 'At this time, the component DOM does not yet exist', |
| 286 | alreadyDomComponent: 'At this point, the component DOM already exists', | 286 | alreadyDomComponent: 'At this point, the component DOM already exists', |
| 287 | - | 287 | + }, |
| 288 | + threeModel: { | ||
| 289 | + loadError: 'Load error occurred' | ||
| 288 | } | 290 | } |
| 289 | } | 291 | } |
| @@ -286,5 +286,8 @@ export default { | @@ -286,5 +286,8 @@ export default { | ||
| 286 | after: '渲染之后',//After rendering | 286 | after: '渲染之后',//After rendering |
| 287 | doesDomComponent: '此时组件 DOM 还未存在',//At this time, the component DOM does not yet exist | 287 | doesDomComponent: '此时组件 DOM 还未存在',//At this time, the component DOM does not yet exist |
| 288 | alreadyDomComponent: '此时组件 DOM 已经存在',//At this point, the component DOM already exists | 288 | alreadyDomComponent: '此时组件 DOM 已经存在',//At this point, the component DOM already exists |
| 289 | + }, | ||
| 290 | + threeModel: { | ||
| 291 | + loadError: '加载发生错误' | ||
| 289 | } | 292 | } |
| 290 | } | 293 | } |
| 1 | <template> | 1 | <template> |
| 2 | <div class="go-content-box" :style="{ border: !borderConfig.show ? 'none' : '' }"> | 2 | <div class="go-content-box" :style="{ border: !borderConfig.show ? 'none' : '' }"> |
| 3 | - <div> | ||
| 4 | - <vue3dLoader | ||
| 5 | - ref="vue3dLoaderRef" | ||
| 6 | - :requestHeader="headers" | ||
| 7 | - :backgroundColor="backgroundColor" | ||
| 8 | - :backgroundAlpha="backgroundAlpha" | ||
| 9 | - :webGLRendererOptions="webGLRendererOptions" | ||
| 10 | - :height="h" | ||
| 11 | - :width="w" | ||
| 12 | - :filePath="dataset" | ||
| 13 | - @process="onProcess" | ||
| 14 | - @load="onLoad" | ||
| 15 | - :intersectRecursive="true" | ||
| 16 | - /> | ||
| 17 | - <div v-show="show" class="process"> | ||
| 18 | - <n-spin :show="show"> | ||
| 19 | - <template #description> 拼命加载中... </template> | ||
| 20 | - </n-spin> | ||
| 21 | - </div> | ||
| 22 | - </div> | 3 | + <NSpin :show="show" style="width: 100%; height: 100%;"> |
| 4 | + <div v-show="!loadErrorMsg" ref="modelContainerElRef" :style="{ display: show ? 'hidden' : 'block' }"></div> | ||
| 5 | + <NResult v-show="loadErrorMsg" status="500" :title="t('external.threeModel.loadError')" | ||
| 6 | + :description="loadErrorMsg || ''"> | ||
| 7 | + </NResult> | ||
| 8 | + </NSpin> | ||
| 23 | </div> | 9 | </div> |
| 24 | </template> | 10 | </template> |
| 25 | <script setup lang="ts"> | 11 | <script setup lang="ts"> |
| 26 | -import { PropType, toRefs, ref, nextTick } from 'vue' | 12 | +import { PropType, toRefs, unref, shallowRef, onMounted, onBeforeMount, ref } from 'vue' |
| 27 | import { CreateComponentType } from '@/packages/index.d' | 13 | import { CreateComponentType } from '@/packages/index.d' |
| 28 | -import { vue3dLoader } from 'vue-3d-loader' | ||
| 29 | -import { getJwtToken } from '@/utils/external/auth' | 14 | +import { ModelLoader } from './modelLoader' |
| 15 | +import { doGet3DComponentJson } from '@/api/external/3dModel' | ||
| 16 | +import { NSpin, NResult } from 'naive-ui' | ||
| 17 | +const t = window['$t'] | ||
| 30 | 18 | ||
| 31 | const props = defineProps({ | 19 | const props = defineProps({ |
| 32 | chartConfig: { | 20 | chartConfig: { |
| @@ -35,39 +23,37 @@ const props = defineProps({ | @@ -35,39 +23,37 @@ const props = defineProps({ | ||
| 35 | } | 23 | } |
| 36 | }) | 24 | }) |
| 37 | 25 | ||
| 38 | -const vue3dLoaderRef = ref(null) | ||
| 39 | 26 | ||
| 40 | -const headers = { | ||
| 41 | - 'x-authorization': `Bearer ${getJwtToken()}` | ||
| 42 | -} | ||
| 43 | -const webGLRendererOptions = { | ||
| 44 | - alpha: true, // 透明 | ||
| 45 | - antialias: true, // 抗锯齿 | ||
| 46 | - // fix: 预览三维图像需加上preserveDrawingBuffer: true | ||
| 47 | - preserveDrawingBuffer: true //缩略图生效需开启 | ||
| 48 | -} | 27 | +const show = ref(false) |
| 28 | +const loadErrorMsg = ref<Nullable<string>>() | ||
| 49 | 29 | ||
| 50 | -//三维模型加载 | ||
| 51 | -const show = ref(true) | 30 | +const { borderConfig, dataset } = toRefs(props.chartConfig.option) as Recordable |
| 52 | 31 | ||
| 53 | -const { dataset, backgroundColor, backgroundAlpha, borderConfig } = toRefs(props.chartConfig.option) as Recordable | ||
| 54 | 32 | ||
| 55 | -const onLoad = async () => { | ||
| 56 | - //加载完成 | ||
| 57 | - await nextTick() | ||
| 58 | - if (dataset.value) { | 33 | +const modelContainerElRef = shallowRef<HTMLDivElement>() |
| 34 | + | ||
| 35 | +const { w, h } = toRefs(props.chartConfig.attr) | ||
| 36 | + | ||
| 37 | +const loader = new ModelLoader({ width: unref(w), height: unref(h) }) | ||
| 38 | + | ||
| 39 | +onBeforeMount(async () => { | ||
| 40 | + try { | ||
| 41 | + show.value = true | ||
| 42 | + loadErrorMsg.value = null | ||
| 43 | + const sceneJSON = await doGet3DComponentJson(unref(dataset)) | ||
| 44 | + await Promise.all([loader.parseScene(sceneJSON?.scene || {}), loader.parseCamera(sceneJSON?.camera || {})]) | ||
| 45 | + } catch (e) { | ||
| 46 | + loadErrorMsg.value = (e as any)?.toString() | ||
| 47 | + } finally { | ||
| 59 | show.value = false | 48 | show.value = false |
| 60 | } | 49 | } |
| 61 | -} | ||
| 62 | 50 | ||
| 63 | -//三维模型进度条 | ||
| 64 | -const process = ref(0) | ||
| 65 | - | ||
| 66 | -const onProcess = (event: Recordable) => { | ||
| 67 | - process.value = Math.floor((event.loaded / event.total) * 100) | ||
| 68 | -} | 51 | +}) |
| 69 | 52 | ||
| 70 | -const { w, h } = toRefs(props.chartConfig.attr) | 53 | +onMounted(() => { |
| 54 | + unref(modelContainerElRef)?.appendChild(loader.renderer.domElement) | ||
| 55 | + loader.addControls() | ||
| 56 | +}) | ||
| 71 | </script> | 57 | </script> |
| 72 | 58 | ||
| 73 | <style lang="scss" scoped> | 59 | <style lang="scss" scoped> |
| @@ -76,13 +62,14 @@ const { w, h } = toRefs(props.chartConfig.attr) | @@ -76,13 +62,14 @@ const { w, h } = toRefs(props.chartConfig.attr) | ||
| 76 | border-width: v-bind('borderConfig.size + "px"'); | 62 | border-width: v-bind('borderConfig.size + "px"'); |
| 77 | border-style: solid; | 63 | border-style: solid; |
| 78 | border-color: v-bind('borderConfig.color'); | 64 | border-color: v-bind('borderConfig.color'); |
| 65 | + | ||
| 79 | .process { | 66 | .process { |
| 80 | position: absolute; | 67 | position: absolute; |
| 81 | top: 50%; | 68 | top: 50%; |
| 82 | left: 50%; | 69 | left: 50%; |
| 83 | width: 50%; | 70 | width: 50%; |
| 84 | transform: translate(-50%, -50%); | 71 | transform: translate(-50%, -50%); |
| 85 | - display:flex; | 72 | + display: flex; |
| 86 | align-items: center; | 73 | align-items: center; |
| 87 | justify-content: center; | 74 | justify-content: center; |
| 88 | } | 75 | } |
| 1 | +import { Camera, Scene, PerspectiveCamera, WebGLRenderer, ObjectLoader, PMREMGenerator, } from "three"; | ||
| 2 | + | ||
| 3 | +import { OrbitControls } from "three/examples/jsm/controls/OrbitControls"; | ||
| 4 | + | ||
| 5 | +interface ModelLoaderConstructorOptions { | ||
| 6 | + width?: number | ||
| 7 | + height?: number | ||
| 8 | +} | ||
| 9 | + | ||
| 10 | +export class ModelLoader { | ||
| 11 | + | ||
| 12 | + renderer: WebGLRenderer | ||
| 13 | + scene: Scene | ||
| 14 | + camera: Camera | ||
| 15 | + controls?: OrbitControls | ||
| 16 | + | ||
| 17 | + private pmremGenerator?: PMREMGenerator | ||
| 18 | + private objectLoader: ObjectLoader | ||
| 19 | + private el?: HTMLElement | ||
| 20 | + | ||
| 21 | + constructor( | ||
| 22 | + { | ||
| 23 | + width = window.innerWidth, | ||
| 24 | + height = window.innerHeight | ||
| 25 | + }: ModelLoaderConstructorOptions = {} | ||
| 26 | + ) { | ||
| 27 | + this.renderer = new WebGLRenderer({ alpha: true, antialias: true }) | ||
| 28 | + this.scene = new Scene() | ||
| 29 | + this.camera = new PerspectiveCamera() | ||
| 30 | + this.objectLoader = new ObjectLoader() | ||
| 31 | + this.renderer.setPixelRatio(window.devicePixelRatio) | ||
| 32 | + this.renderer.setSize(width, height) | ||
| 33 | + this.renderer.setAnimationLoop(this.animate.bind(this)) | ||
| 34 | + } | ||
| 35 | + | ||
| 36 | + private animate() { | ||
| 37 | + this.renderer.render(this.scene, this.camera) | ||
| 38 | + } | ||
| 39 | + | ||
| 40 | + addControls() { | ||
| 41 | + this.controls = new OrbitControls(this.camera, this.renderer.domElement) | ||
| 42 | + // 启用阻尼效果 | ||
| 43 | + this.controls.enableDamping = true; | ||
| 44 | + // 阻尼系数 | ||
| 45 | + this.controls.dampingFactor = 0.25; | ||
| 46 | + // 禁止屏幕空间的平移 | ||
| 47 | + this.controls.screenSpacePanning = false; | ||
| 48 | + // 设置最小距离 | ||
| 49 | + this.controls.minDistance = 0.5; | ||
| 50 | + // 设置最大距离 | ||
| 51 | + this.controls.maxDistance = 500; | ||
| 52 | + // 限制垂直旋转角度 | ||
| 53 | + this.controls.maxPolarAngle = Math.PI / 2; | ||
| 54 | + } | ||
| 55 | + | ||
| 56 | + append(el: HTMLElement) { | ||
| 57 | + this.el = el | ||
| 58 | + this.el.appendChild(this.renderer.domElement) | ||
| 59 | + } | ||
| 60 | + | ||
| 61 | + setScene(scene: Scene) { | ||
| 62 | + this.scene = scene | ||
| 63 | + } | ||
| 64 | + | ||
| 65 | + async parseScene(object: Recordable) { | ||
| 66 | + this.scene = await this.objectLoader.parseAsync(object) | ||
| 67 | + } | ||
| 68 | + | ||
| 69 | + async parseCamera(object: Recordable) { | ||
| 70 | + this.camera = await this.objectLoader.parseAsync(object) | ||
| 71 | + if (this.controls) { | ||
| 72 | + this.controls.object = this.camera | ||
| 73 | + this.controls.update() | ||
| 74 | + } | ||
| 75 | + } | ||
| 76 | + | ||
| 77 | + // setModelviewer() { | ||
| 78 | + // this.pmremGenerator = new PMREMGenerator(this.renderer); | ||
| 79 | + // this.pmremGenerator.compileEquirectangularShader(); | ||
| 80 | + // this.scene.environment = this.pmremGenerator.fromScene(new RoomEnvironment(), 0.04).texture; | ||
| 81 | + // } | ||
| 82 | + | ||
| 83 | +} |
| 1 | <template> | 1 | <template> |
| 2 | <!-- 侧边栏和数据分发处理 --> | 2 | <!-- 侧边栏和数据分发处理 --> |
| 3 | <div class="go-chart-common"> | 3 | <div class="go-chart-common"> |
| 4 | - <n-menu | ||
| 5 | - v-show="hidePackageOneCategory" | ||
| 6 | - class="chart-menu-width" | ||
| 7 | - v-model:value="selectValue" | ||
| 8 | - :options="packages.menuOptions" | ||
| 9 | - :icon-size="16" | ||
| 10 | - :indent="18" | ||
| 11 | - @update:value="clickItemHandle" | ||
| 12 | - ></n-menu> | 4 | + <n-menu v-show="hidePackageOneCategory" class="chart-menu-width" v-model:value="selectValue" |
| 5 | + :options="packages.menuOptions" :icon-size="16" :indent="18" @update:value="clickItemHandle"></n-menu> | ||
| 13 | <div class="chart-content-list"> | 6 | <div class="chart-content-list"> |
| 14 | <n-scrollbar trigger="none"> | 7 | <n-scrollbar trigger="none"> |
| 15 | <charts-item-box :menuOptions="packages.selectOptions" @deletePhoto="deleteHandle"></charts-item-box> | 8 | <charts-item-box :menuOptions="packages.selectOptions" @deletePhoto="deleteHandle"></charts-item-box> |
| @@ -37,7 +30,7 @@ const props = defineProps({ | @@ -37,7 +30,7 @@ const props = defineProps({ | ||
| 37 | selectOptions: { | 30 | selectOptions: { |
| 38 | type: Object, | 31 | type: Object, |
| 39 | // eslint-disable-next-line @typescript-eslint/no-empty-function | 32 | // eslint-disable-next-line @typescript-eslint/no-empty-function |
| 40 | - default: () => {} | 33 | + default: () => { } |
| 41 | } | 34 | } |
| 42 | }) | 35 | }) |
| 43 | 36 | ||
| @@ -134,7 +127,7 @@ watch( | @@ -134,7 +127,7 @@ watch( | ||
| 134 | redirectComponent: 'Decorates/Three/Loader', | 127 | redirectComponent: 'Decorates/Three/Loader', |
| 135 | isThreeModel: true, | 128 | isThreeModel: true, |
| 136 | threeModelContent: modelItem.content, | 129 | threeModelContent: modelItem.content, |
| 137 | - threeModelFilePath: `${threeFilePath}${VITE_GLOB_API_URL}${VITE_GLOB_API_URL_PREFIX}/3d_component/json/${modelItem.id}/scene.json`, | 130 | + threeModelFilePath: modelItem.id, |
| 138 | threeModelImageUrl: modelItem.imageUrl | 131 | threeModelImageUrl: modelItem.imageUrl |
| 139 | } | 132 | } |
| 140 | }) | 133 | }) |
| @@ -189,14 +182,17 @@ const clickItemHandle = (key: string) => { | @@ -189,14 +182,17 @@ const clickItemHandle = (key: string) => { | ||
| 189 | /* 此高度与 ContentBox 组件关联*/ | 182 | /* 此高度与 ContentBox 组件关联*/ |
| 190 | $topHeight: 40px; | 183 | $topHeight: 40px; |
| 191 | $menuWidth: 65px; | 184 | $menuWidth: 65px; |
| 185 | + | ||
| 192 | @include go('chart-common') { | 186 | @include go('chart-common') { |
| 193 | display: flex; | 187 | display: flex; |
| 194 | height: calc(100vh - #{$--header-height} - #{$topHeight}); | 188 | height: calc(100vh - #{$--header-height} - #{$topHeight}); |
| 189 | + | ||
| 195 | .chart-menu-width { | 190 | .chart-menu-width { |
| 196 | width: $menuWidth; | 191 | width: $menuWidth; |
| 197 | flex-shrink: 0; | 192 | flex-shrink: 0; |
| 198 | @include fetch-bg-color('background-color2-shallow'); | 193 | @include fetch-bg-color('background-color2-shallow'); |
| 199 | } | 194 | } |
| 195 | + | ||
| 200 | .chart-content-list { | 196 | .chart-content-list { |
| 201 | width: 200px; | 197 | width: 200px; |
| 202 | flex: 1; | 198 | flex: 1; |
| @@ -204,14 +200,17 @@ $menuWidth: 65px; | @@ -204,14 +200,17 @@ $menuWidth: 65px; | ||
| 204 | flex-direction: column; | 200 | flex-direction: column; |
| 205 | align-items: center; | 201 | align-items: center; |
| 206 | } | 202 | } |
| 203 | + | ||
| 207 | @include deep() { | 204 | @include deep() { |
| 208 | .n-menu-item { | 205 | .n-menu-item { |
| 209 | height: 30px; | 206 | height: 30px; |
| 207 | + | ||
| 210 | &.n-menu-item--selected { | 208 | &.n-menu-item--selected { |
| 211 | &::before { | 209 | &::before { |
| 212 | background-color: rgba(0, 0, 0, 0); | 210 | background-color: rgba(0, 0, 0, 0); |
| 213 | } | 211 | } |
| 214 | } | 212 | } |
| 213 | + | ||
| 215 | .n-menu-item-content { | 214 | .n-menu-item-content { |
| 216 | text-align: center; | 215 | text-align: center; |
| 217 | padding: 0px 14px !important; | 216 | padding: 0px 14px !important; |