mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
Lots of frontend improvements like keeping camera position on changes, avoid flickering on live updates, smooth gizmo animations, proper ortho camera movement, and enabling move by tap when not selecting.
This commit is contained in:
@@ -13,7 +13,7 @@ from yacv_server import show, export_all # Check out other exported methods for
|
|||||||
# Create a simple object
|
# Create a simple object
|
||||||
with BuildPart() as example:
|
with BuildPart() as example:
|
||||||
Box(10, 10, 5)
|
Box(10, 10, 5)
|
||||||
Cylinder(3, 5, mode=Mode.SUBTRACT)
|
Cylinder(4, 5, mode=Mode.SUBTRACT)
|
||||||
|
|
||||||
# Show it in the frontend with hot-reloading
|
# Show it in the frontend with hot-reloading
|
||||||
show(example)
|
show(example)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<!--suppress SillyAssignmentJS -->
|
<!--suppress SillyAssignmentJS -->
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef} from "vue";
|
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef, watch} from "vue";
|
||||||
import Sidebar from "./misc/Sidebar.vue";
|
import Sidebar from "./misc/Sidebar.vue";
|
||||||
import Loading from "./misc/Loading.vue";
|
import Loading from "./misc/Loading.vue";
|
||||||
import Tools from "./tools/Tools.vue";
|
import Tools from "./tools/Tools.vue";
|
||||||
@@ -83,6 +83,12 @@ networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUp
|
|||||||
for (let model of settings.preload) {
|
for (let model of settings.preload) {
|
||||||
networkMgr.load(model);
|
networkMgr.load(model);
|
||||||
}
|
}
|
||||||
|
watch(viewer, (newViewer) => {
|
||||||
|
if (newViewer) {
|
||||||
|
newViewer.setPosterText('<tspan x="50%" dy="1.2em">Trying to load' +
|
||||||
|
' models from:</tspan>' + settings.preload.map((url) => '<tspan x="50%" dy="1.2em">- ' + url + '</tspan>').join(""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadModelManual() {
|
async function loadModelManual() {
|
||||||
const modelUrl = prompt("For an improved experience in viewing CAD/GLTF models with automatic updates, it's recommended to use the official yacv_server Python package. This ensures seamless serving of models and automatic updates.\n\nOtherwise, enter the URL of the model to load:");
|
const modelUrl = prompt("For an improved experience in viewing CAD/GLTF models with automatic updates, it's recommended to use the official yacv_server Python package. This ensures seamless serving of models and automatic updates.\n\nOtherwise, enter the URL of the model to load:");
|
||||||
@@ -99,20 +105,20 @@ async function loadModelManual() {
|
|||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
<!-- The left collapsible sidebar has the list of models -->
|
<!-- The left collapsible sidebar has the list of models -->
|
||||||
<sidebar :opened-init="openSidebarsByDefault" side="left" :width="300">
|
<sidebar :opened-init="openSidebarsByDefault" :width="300" side="left">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<v-toolbar-title>Models</v-toolbar-title>
|
<v-toolbar-title>Models</v-toolbar-title>
|
||||||
</template>
|
</template>
|
||||||
<template #toolbar-items>
|
<template #toolbar-items>
|
||||||
<v-btn icon="" @click="loadModelManual">
|
<v-btn icon="" @click="loadModelManual">
|
||||||
<svg-icon type="mdi" :path="mdiPlus"/>
|
<svg-icon :path="mdiPlus" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
|
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
|
||||||
</sidebar>
|
</sidebar>
|
||||||
|
|
||||||
<!-- The right collapsible sidebar has the list of tools -->
|
<!-- The right collapsible sidebar has the list of tools -->
|
||||||
<sidebar :opened-init="openSidebarsByDefault" side="right" :width="48 * 3 /* buttons */ + 1 /* border? */">
|
<sidebar :opened-init="openSidebarsByDefault" :width="48 * 3 /* buttons */ + 1 /* border? */" side="right">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<v-toolbar-title>Tools</v-toolbar-title>
|
<v-toolbar-title>Tools</v-toolbar-title>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {VContainer, VRow, VCol, VProgressCircular} from "vuetify/lib/components/index.mjs";
|
import {VCol, VContainer, VProgressCircular, VRow} from "vuetify/lib/components/index.mjs";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
|
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
|
||||||
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
|
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
|
||||||
@@ -16,22 +16,22 @@ const openIcon = props.side === 'left' ? mdiChevronRight : mdiChevronLeft;
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-btn icon @click="opened = !opened" class="open-button" :class="side">
|
<v-btn :class="side" class="open-button" icon @click="opened = !opened">
|
||||||
<svg-icon type="mdi" :path="openIcon"/>
|
<svg-icon :path="openIcon" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-navigation-drawer v-model="opened" permanent :location="side" :width="props.width">
|
<v-navigation-drawer v-model="opened" :location="side" :width="props.width" permanent>
|
||||||
<v-toolbar density="compact">
|
<v-toolbar density="compact">
|
||||||
<v-toolbar-items v-if="side == 'right'">
|
<v-toolbar-items v-if="side == 'right'">
|
||||||
<slot name="toolbar-items"></slot>
|
<slot name="toolbar-items"></slot>
|
||||||
<v-btn icon @click="opened = !opened">
|
<v-btn icon @click="opened = !opened">
|
||||||
<svg-icon type="mdi" :path="mdiClose"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar-items>
|
</v-toolbar-items>
|
||||||
<slot name="toolbar"></slot>
|
<slot name="toolbar"></slot>
|
||||||
<v-toolbar-items v-if="side == 'left'">
|
<v-toolbar-items v-if="side == 'left'">
|
||||||
<slot name="toolbar-items"></slot>
|
<slot name="toolbar-items"></slot>
|
||||||
<v-btn icon @click="opened = !opened">
|
<v-btn icon @click="opened = !opened">
|
||||||
<svg-icon type="mdi" :path="mdiClose"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar-items>
|
</v-toolbar-items>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
|
|||||||
@@ -37,19 +37,6 @@ export class SceneMgr {
|
|||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
|
|
||||||
let bb = SceneMgr.getBoundingBox(document);
|
|
||||||
if (!bb) return document;
|
|
||||||
|
|
||||||
// Create the helper axes and grid box
|
|
||||||
let helpersDoc = new Document();
|
|
||||||
let transform = (new Matrix4()).makeTranslation(bb.getCenter(new Vector3()));
|
|
||||||
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
|
|
||||||
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
|
|
||||||
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)]));
|
|
||||||
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getBoundingBox(document: Document): Box3 | null {
|
static getBoundingBox(document: Document): Box3 | null {
|
||||||
if (document.getRoot().listNodes().length === 0) return null;
|
if (document.getRoot().listNodes().length === 0) return null;
|
||||||
// Get bounding box of the model and use it to set the size of the helpers
|
// Get bounding box of the model and use it to set the size of the helpers
|
||||||
@@ -91,6 +78,19 @@ export class SceneMgr {
|
|||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
|
||||||
|
let bb = SceneMgr.getBoundingBox(document);
|
||||||
|
if (!bb) return document;
|
||||||
|
|
||||||
|
// Create the helper axes and grid box
|
||||||
|
let helpersDoc = new Document();
|
||||||
|
let transform = (new Matrix4()).makeTranslation(bb.getCenter(new Vector3()));
|
||||||
|
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
|
||||||
|
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
|
||||||
|
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)]));
|
||||||
|
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
|
||||||
|
}
|
||||||
|
|
||||||
/** Serializes the current document into a GLB and updates the viewerSrc */
|
/** Serializes the current document into a GLB and updates the viewerSrc */
|
||||||
private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
||||||
// Make sure the document is fully loaded and ready to be shown
|
// Make sure the document is fully loaded and ready to be shown
|
||||||
|
|||||||
@@ -18,8 +18,11 @@ export const settings = {
|
|||||||
monitorEveryMs: 100,
|
monitorEveryMs: 100,
|
||||||
monitorOpenTimeoutMs: 1000,
|
monitorOpenTimeoutMs: 1000,
|
||||||
// ModelViewer settings
|
// ModelViewer settings
|
||||||
autoplay: true,
|
autoplay: true, // Global animation toggle
|
||||||
arModes: 'webxr scene-viewer quick-look',
|
arModes: 'webxr scene-viewer quick-look',
|
||||||
|
zoomSensitivity: 0.25,
|
||||||
|
orbitSensitivity: 1,
|
||||||
|
panSensitivity: 1,
|
||||||
exposure: 1,
|
exposure: 1,
|
||||||
shadowIntensity: 0,
|
shadowIntensity: 0,
|
||||||
background: '',
|
background: '',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {
|
import {
|
||||||
VBtn,
|
VBtn,
|
||||||
VBtnToggle,
|
VBtnToggle,
|
||||||
@@ -33,7 +33,7 @@ import {Plane} from "three/src/math/Plane.js";
|
|||||||
import {Vector3} from "three/src/math/Vector3.js";
|
import {Vector3} from "three/src/math/Vector3.js";
|
||||||
import type {MObject3D} from "../tools/Selection.vue";
|
import type {MObject3D} from "../tools/Selection.vue";
|
||||||
import {toLineSegments} from "../misc/lines.js";
|
import {toLineSegments} from "../misc/lines.js";
|
||||||
import {settings} from "../misc/settings.js";
|
import {settings} from "../misc/settings.js"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
meshes: Array<Mesh>,
|
meshes: Array<Mesh>,
|
||||||
@@ -178,8 +178,6 @@ watch(clipPlaneZ, onClipPlanesChange);
|
|||||||
watch(clipPlaneSwappedX, onClipPlanesChange);
|
watch(clipPlaneSwappedX, onClipPlanesChange);
|
||||||
watch(clipPlaneSwappedY, onClipPlanesChange);
|
watch(clipPlaneSwappedY, onClipPlanesChange);
|
||||||
watch(clipPlaneSwappedZ, onClipPlanesChange);
|
watch(clipPlaneSwappedZ, onClipPlanesChange);
|
||||||
// Clip planes are also affected by the camera position, so we need to listen to camera changes
|
|
||||||
props.viewer!!.onElemReady((elem) => elem.addEventListener('camera-change', onClipPlanesChange))
|
|
||||||
|
|
||||||
let edgeWidthChangeCleanup = [] as Array<() => void>;
|
let edgeWidthChangeCleanup = [] as Array<() => void>;
|
||||||
|
|
||||||
@@ -318,95 +316,101 @@ function onModelLoad() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// props.viewer.elem may not yet be available, so we need to wait for it
|
// props.viewer.elem may not yet be available, so we need to wait for it
|
||||||
props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
const onViewerReady = (viewer: InstanceType<typeof ModelViewerWrapper>) => {
|
||||||
|
viewer?.onElemReady((elem: HTMLElement) => {
|
||||||
|
elem.addEventListener('before-render', onModelLoad);
|
||||||
|
elem.addEventListener('camera-change', onClipPlanesChange);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) as any, onViewerReady);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-expansion-panel :value="modelName">
|
<v-expansion-panel :value="modelName">
|
||||||
<v-expansion-panel-title expand-icon="hide-this-icon" collapse-icon="hide-this-icon">
|
<v-expansion-panel-title collapse-icon="hide-this-icon" expand-icon="hide-this-icon">
|
||||||
<v-btn-toggle v-model="enabledFeatures" multiple @click.stop color="surface-light">
|
<v-btn-toggle v-model="enabledFeatures" color="surface-light" multiple @click.stop>
|
||||||
<v-btn icon>
|
<v-btn icon>
|
||||||
<v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip>
|
<v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiRectangle" :rotate="90"></svg-icon>
|
<svg-icon :path="mdiRectangle" :rotate="90" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon>
|
<v-btn icon>
|
||||||
<v-tooltip activator="parent">Toggle Edges ({{ edgeCount }})</v-tooltip>
|
<v-tooltip activator="parent">Toggle Edges ({{ edgeCount }})</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiRectangleOutline" :rotate="90"></svg-icon>
|
<svg-icon :path="mdiRectangleOutline" :rotate="90" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon>
|
<v-btn icon>
|
||||||
<v-tooltip activator="parent">Toggle Vertices ({{ vertexCount }})</v-tooltip>
|
<v-tooltip activator="parent">Toggle Vertices ({{ vertexCount }})</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiVectorRectangle" :rotate="90"></svg-icon>
|
<svg-icon :path="mdiVectorRectangle" :rotate="90" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
<div class="model-name">{{ modelName }}</div>
|
<div class="model-name">{{ modelName }}</div>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn icon @click.stop="emit('remove')">
|
<v-btn icon @click.stop="emit('remove')">
|
||||||
<v-tooltip activator="parent">Remove</v-tooltip>
|
<v-tooltip activator="parent">Remove</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiDelete"></svg-icon>
|
<svg-icon :path="mdiDelete" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-expansion-panel-title>
|
</v-expansion-panel-title>
|
||||||
<v-expansion-panel-text>
|
<v-expansion-panel-text>
|
||||||
<v-slider v-model="opacity" hide-details min="0" max="1" :step="0.1">
|
<v-slider v-model="opacity" :step="0.1" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Change opacity</v-tooltip>
|
<v-tooltip activator="parent">Change opacity</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCircleOpacity"></svg-icon>
|
<svg-icon :path="mdiCircleOpacity" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Wireframe</v-tooltip>
|
<v-tooltip activator="parent">Wireframe</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-triangle-outline" falseIcon="mdi-triangle" v-model="wireframe"></v-checkbox-btn>
|
<v-checkbox-btn v-model="wireframe" falseIcon="mdi-triangle" trueIcon="mdi-triangle-outline"></v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details min="0" max="1">
|
<v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Edge and vertex sizes</v-tooltip>
|
<v-tooltip activator="parent">Edge and vertex sizes</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiVectorLine"></svg-icon>
|
<svg-icon :path="mdiVectorLine" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<v-slider v-model="clipPlaneX" hide-details min="0" max="1">
|
<v-slider v-model="clipPlaneX" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Clip plane X</v-tooltip>
|
<v-tooltip activator="parent">Clip plane X</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCube" :rotate="120"></svg-icon>
|
<svg-icon :path="mdiCube" :rotate="120" type="mdi"></svg-icon>
|
||||||
X
|
X
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Swap clip plane X</v-tooltip>
|
<v-tooltip activator="parent">Swap clip plane X</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
<v-checkbox-btn v-model="clipPlaneSwappedX" falseIcon="mdi-checkbox-blank-outline"
|
||||||
v-model="clipPlaneSwappedX">
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-slider v-model="clipPlaneZ" hide-details min="0" max="1">
|
<v-slider v-model="clipPlaneZ" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Clip plane Y</v-tooltip>
|
<v-tooltip activator="parent">Clip plane Y</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCube" :rotate="-120"></svg-icon>
|
<svg-icon :path="mdiCube" :rotate="-120" type="mdi"></svg-icon>
|
||||||
Y
|
Y
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
|
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
<v-checkbox-btn v-model="clipPlaneSwappedZ" falseIcon="mdi-checkbox-blank-outline"
|
||||||
v-model="clipPlaneSwappedZ">
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-slider v-model="clipPlaneY" hide-details min="0" max="1">
|
<v-slider v-model="clipPlaneY" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Clip plane Z</v-tooltip>
|
<v-tooltip activator="parent">Clip plane Z</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCube"></svg-icon>
|
<svg-icon :path="mdiCube" type="mdi"></svg-icon>
|
||||||
Z
|
Z
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
|
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
<v-checkbox-btn v-model="clipPlaneSwappedY" falseIcon="mdi-checkbox-blank-outline"
|
||||||
v-model="clipPlaneSwappedY">
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {VExpansionPanels} from "vuetify/lib/components/index.mjs";
|
import {VExpansionPanels} from "vuetify/lib/components/index.mjs";
|
||||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {Document, Mesh} from "@gltf-transform/core";
|
import {Document, Mesh} from "@gltf-transform/core";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
// License text for all dependencies, only downloaded when/if needed
|
// License text for all dependencies, only downloaded when/if needed
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import licenseText from "../../assets/licenses.txt?raw";
|
import licenseText from "../../assets/licenses.txt?raw";
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {onMounted, onUpdated, ref} from "vue";
|
import {onMounted, onUpdated, ref} from "vue";
|
||||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
||||||
import type {ModelViewerElement} from '@google/model-viewer';
|
|
||||||
|
|
||||||
// Optimized minimal dependencies from three
|
// Optimized minimal dependencies from three
|
||||||
import {Vector3} from "three/src/math/Vector3.js";
|
import {Vector3} from "three/src/math/Vector3.js";
|
||||||
import {Matrix4} from "three/src/math/Matrix4.js";
|
import {Matrix4} from "three/src/math/Matrix4.js";
|
||||||
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
|
|
||||||
(globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
(globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
||||||
|
|
||||||
const OrientationGizmo = OrientationGizmoRaw.default;
|
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> }>();
|
||||||
|
|
||||||
const props = defineProps<{ elem: ModelViewerElement | null, scene: ModelScene }>();
|
|
||||||
|
|
||||||
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
|
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
|
||||||
// noinspection SpellCheckingInspection
|
// noinspection SpellCheckingInspection
|
||||||
@@ -33,21 +31,26 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
|
|||||||
}
|
}
|
||||||
// Append and listen for events
|
// Append and listen for events
|
||||||
gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => {
|
gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => {
|
||||||
let lookFrom = scene.getCamera().position.clone();
|
if (!props.viewer.elem || !props.viewer.controls) return;
|
||||||
let lookAt = scene.getTarget().clone().add(scene.target.position);
|
// Animate the controls to the new wanted angle
|
||||||
let magnitude = lookFrom.clone().sub(lookAt).length()
|
const controls = props.viewer.controls;
|
||||||
let direction = new Vector3(axis.direction.x, axis.direction.y, axis.direction.z);
|
const {theta: curTheta/*, phi: curPhi*/} = (controls as any).goalSpherical;
|
||||||
let newLookFrom = lookAt.clone().add(direction.clone().multiplyScalar(magnitude));
|
let wantedTheta = NaN;
|
||||||
//console.log("New camera position", newLookFrom)
|
let wantedPhi = NaN;
|
||||||
scene.getCamera().position.copy(newLookFrom);
|
let attempt = 0
|
||||||
scene.getCamera().lookAt(lookAt);
|
while ((attempt == 0 || curTheta == wantedTheta) && attempt < 2) {
|
||||||
if ((scene as any).__perspectiveCamera) { // HACK: Make the hacky ortho also work
|
if (attempt > 0) { // Flip the camera if the user clicks on the same axis
|
||||||
(scene as any).__perspectiveCamera.position.copy(newLookFrom);
|
axis.direction.x = -axis.direction.x;
|
||||||
(scene as any).__perspectiveCamera.lookAt(lookAt);
|
axis.direction.y = -axis.direction.y;
|
||||||
|
axis.direction.z = -axis.direction.z;
|
||||||
|
}
|
||||||
|
wantedTheta = Math.atan2(axis.direction.x, axis.direction.z);
|
||||||
|
wantedPhi = Math.asin(-axis.direction.y) + Math.PI / 2;
|
||||||
|
attempt++;
|
||||||
}
|
}
|
||||||
|
controls.setOrbit(wantedTheta, wantedPhi);
|
||||||
|
props.viewer.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}))
|
||||||
scene.queueRender();
|
scene.queueRender();
|
||||||
requestIdleCallback(() => props.elem?.dispatchEvent(
|
|
||||||
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
|
|
||||||
}
|
}
|
||||||
return gizmo;
|
return gizmo;
|
||||||
}
|
}
|
||||||
@@ -65,9 +68,9 @@ function updateGizmo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let reinstall = () => {
|
let reinstall = () => {
|
||||||
if(!container.value) return;
|
if (!container.value) return;
|
||||||
if (gizmo) container.value.removeChild(gizmo);
|
if (gizmo) container.value.removeChild(gizmo);
|
||||||
gizmo = createGizmo(container.value, props.scene as ModelScene) as typeof gizmo;
|
gizmo = createGizmo(container.value, props.viewer.scene!! as any) as typeof gizmo;
|
||||||
container.value.appendChild(gizmo);
|
container.value.appendChild(gizmo);
|
||||||
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
|
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {defineModel, inject, ref, type ShallowRef, watch} from "vue";
|
import {defineModel, inject, ref, type ShallowRef, watch} from "vue";
|
||||||
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
|
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
@@ -258,7 +258,7 @@ let onViewerReady = (viewer: typeof ModelViewerWrapperT) => {
|
|||||||
hasListeners = true;
|
hasListeners = true;
|
||||||
elem.addEventListener('mousedown', mouseDownListener); // Avoid clicking when dragging
|
elem.addEventListener('mousedown', mouseDownListener); // Avoid clicking when dragging
|
||||||
elem.addEventListener('mouseup', mouseUpListener);
|
elem.addEventListener('mouseup', mouseUpListener);
|
||||||
elem.addEventListener('load', () => {
|
elem.addEventListener('before-render', () => {
|
||||||
// After a reload of the scene, we need to recover object references and highlight them again
|
// After a reload of the scene, we need to recover object references and highlight them again
|
||||||
for (let sel of selected.value) {
|
for (let sel of selected.value) {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
@@ -462,32 +462,32 @@ window.addEventListener('keydown', (event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-btn icon @click="toggleSelection" :color="selectionEnabled ? 'surface-light' : ''">
|
<v-btn :color="selectionEnabled ? 'surface-light' : ''" icon @click="toggleSelection">
|
||||||
<v-tooltip activator="parent">{{ selectionEnabled ? 'Disable (s)election mode' : 'Enable (s)election mode' }}
|
<v-tooltip activator="parent">{{ selectionEnabled ? 'Disable (s)election mode' : 'Enable (s)election mode' }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCursorDefaultClick"/>
|
<svg-icon :path="mdiCursorDefaultClick" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-tooltip :text="'Select only ' + selectFilter.toString().toLocaleLowerCase()" :open-on-click="false">
|
<v-tooltip :open-on-click="false" :text="'Select only ' + selectFilter.toString().toLocaleLowerCase()">
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-select v-bind="props" class="select-only" variant="underlined"
|
<v-select v-model="selectFilter" :items="['Any (S)', '(F)aces', '(E)dges', '(V)ertices']" class="select-only"
|
||||||
:items="['Any (S)', '(F)aces', '(E)dges', '(V)ertices']"
|
v-bind="props"
|
||||||
v-model="selectFilter"/>
|
variant="underlined"/>
|
||||||
</template>
|
</template>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<v-btn icon @click="toggleHighlightNextSelection" :color="highlightNextSelection[0] ? 'surface-light' : ''">
|
<v-btn :color="highlightNextSelection[0] ? 'surface-light' : ''" icon @click="toggleHighlightNextSelection">
|
||||||
<v-tooltip activator="parent">(H)ighlight the next clicked element in the models list</v-tooltip>
|
<v-tooltip activator="parent">(H)ighlight the next clicked element in the models list</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiFeatureSearch"/>
|
<svg-icon :path="mdiFeatureSearch" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="toggleShowBoundingBox" :color="showBoundingBox ? 'surface-light' : ''">
|
<v-btn :color="showBoundingBox ? 'surface-light' : ''" icon @click="toggleShowBoundingBox">
|
||||||
<v-tooltip activator="parent">{{ showBoundingBox ? 'Hide selection (b)ounds' : 'Show selection (b)ounds' }}
|
<v-tooltip activator="parent">{{ showBoundingBox ? 'Hide selection (b)ounds' : 'Show selection (b)ounds' }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCubeOutline"/>
|
<svg-icon :path="mdiCubeOutline" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="toggleShowDistances" :color="showDistances ? 'surface-light' : ''">
|
<v-btn :color="showDistances ? 'surface-light' : ''" icon @click="toggleShowDistances">
|
||||||
<v-tooltip activator="parent">
|
<v-tooltip activator="parent">
|
||||||
{{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }}
|
{{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiRuler"/>
|
<svg-icon :path="mdiRuler" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {
|
import {
|
||||||
VBtn,
|
VBtn,
|
||||||
VCard,
|
VCard,
|
||||||
@@ -16,7 +16,6 @@ import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
|
|||||||
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
import type {ModelViewerElement} from '@google/model-viewer';
|
import type {ModelViewerElement} from '@google/model-viewer';
|
||||||
import type {MObject3D} from "./Selection.vue";
|
|
||||||
import Loading from "../misc/Loading.vue";
|
import Loading from "../misc/Loading.vue";
|
||||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {defineAsyncComponent, ref, type Ref} from "vue";
|
import {defineAsyncComponent, ref, type Ref} from "vue";
|
||||||
@@ -57,14 +56,14 @@ function syncOrthoCamera(force: boolean) {
|
|||||||
let h = perspectiveWidthAtCenter / scene.aspect;
|
let h = perspectiveWidthAtCenter / scene.aspect;
|
||||||
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
|
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
|
||||||
scene.camera.position.copy(perspectiveCam.position);
|
scene.camera.position.copy(perspectiveCam.position);
|
||||||
scene.camera.lookAt(lookAtCenter);
|
scene.camera.rotation.copy(perspectiveCam.rotation);
|
||||||
if (force) scene.queueRender() // Force rerender of model-viewer
|
if (force) scene.queueRender() // Force rerender of model-viewer
|
||||||
requestAnimationFrame(() => syncOrthoCamera(false));
|
requestAnimationFrame(() => syncOrthoCamera(false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
||||||
function toggleProjection() {
|
async function toggleProjection() {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
let prevCam = scene.camera;
|
let prevCam = scene.camera;
|
||||||
@@ -79,16 +78,16 @@ function toggleProjection() {
|
|||||||
scene.queueRender() // Force rerender of model-viewer
|
scene.queueRender() // Force rerender of model-viewer
|
||||||
}
|
}
|
||||||
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
||||||
// The camera change may take a few frames to take effect, dispatch the event after a delay
|
// The camera change may take a frame to take effect, dispatch the event after a delay
|
||||||
requestIdleCallback(() => props.viewer?.elem?.dispatchEvent(
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
|
props.viewer?.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function centerCamera() {
|
async function centerCamera() {
|
||||||
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
|
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
|
||||||
if (!viewerEl) return;
|
if (!viewerEl) return;
|
||||||
await viewerEl.updateFraming();
|
props.viewer?.scene?.setTarget(0, 0, 0); // Center the target
|
||||||
viewerEl.zoom(3);
|
viewerEl.zoom(-1000000); // Max zoom out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -127,35 +126,35 @@ window.addEventListener('keydown', (event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<orientation-gizmo :scene="props.viewer.scene as any" :elem="props.viewer.elem" v-if="props.viewer?.scene"/>
|
<orientation-gizmo v-if="props.viewer?.scene" :viewer="props.viewer"/>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<h5>Camera</h5>
|
<h5>Camera</h5>
|
||||||
<v-btn icon @click="toggleProjection"><span class="icon-detail">{{ toggleProjectionText }}</span>
|
<v-btn icon @click="toggleProjection"><span class="icon-detail">{{ toggleProjectionText }}</span>
|
||||||
<v-tooltip activator="parent">Toggle (P)rojection<br/>(currently
|
<v-tooltip activator="parent">Toggle (P)rojection<br/>(currently
|
||||||
{{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }})
|
{{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }})
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiProjector"></svg-icon>
|
<svg-icon :path="mdiProjector" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="centerCamera">
|
<v-btn icon @click="centerCamera">
|
||||||
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCrosshairsGps"/>
|
<svg-icon :path="mdiCrosshairsGps" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
|
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
|
||||||
<selection-component ref="selectionComp" :viewer="props.viewer as any" v-model="selection"
|
<selection-component ref="selectionComp" v-model="selection" :viewer="props.viewer as any"
|
||||||
@findModel="(name) => emit('findModel', name)"/>
|
@findModel="(name) => emit('findModel', name)"/>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<h5>Extras</h5>
|
<h5>Extras</h5>
|
||||||
<v-btn icon @click="downloadSceneGlb">
|
<v-btn icon @click="downloadSceneGlb">
|
||||||
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
|
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiDownload"/>
|
<svg-icon :path="mdiDownload" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-dialog id="licenses-dialog" fullscreen>
|
<v-dialog id="licenses-dialog" fullscreen>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn icon v-bind="props">
|
<v-btn icon v-bind="props">
|
||||||
<v-tooltip activator="parent">Show Licenses</v-tooltip>
|
<v-tooltip activator="parent">Show Licenses</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiLicense"/>
|
<svg-icon :path="mdiLicense" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:default="{ isActive }">
|
<template v-slot:default="{ isActive }">
|
||||||
@@ -165,7 +164,7 @@ window.addEventListener('keydown', (event) => {
|
|||||||
<v-spacer>
|
<v-spacer>
|
||||||
</v-spacer>
|
</v-spacer>
|
||||||
<v-btn icon @click="isActive.value = false">
|
<v-btn icon @click="isActive.value = false">
|
||||||
<svg-icon type="mdi" :path="mdiClose"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -176,7 +175,7 @@ window.addEventListener('keydown', (event) => {
|
|||||||
</v-dialog>
|
</v-dialog>
|
||||||
<v-btn icon @click="openGithub">
|
<v-btn icon @click="openGithub">
|
||||||
<v-tooltip activator="parent">Open (G)itHub</v-tooltip>
|
<v-tooltip activator="parent">Open (G)itHub</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiGithub"/>
|
<svg-icon :path="mdiGithub" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<div ref="statsHolder"></div>
|
<div ref="statsHolder"></div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {settings} from "../misc/settings";
|
import {settings} from "../misc/settings";
|
||||||
import {inject, onMounted, type Ref, ref, watch} from "vue";
|
import {inject, onMounted, type Ref, ref, watch} from "vue";
|
||||||
import {VList, VListItem} from "vuetify/lib/components/index.mjs";
|
|
||||||
import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
|
import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
|
||||||
|
import {$controls} from '@google/model-viewer/lib/features/controls.js';
|
||||||
|
import {type SmoothControls} from '@google/model-viewer/lib/three-components/SmoothControls';
|
||||||
import {ModelViewerElement} from '@google/model-viewer';
|
import {ModelViewerElement} from '@google/model-viewer';
|
||||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
|
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
|
||||||
@@ -19,31 +20,63 @@ BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
|
|||||||
//@ts-ignore
|
//@ts-ignore
|
||||||
Mesh.prototype.raycast = acceleratedRaycast;
|
Mesh.prototype.raycast = acceleratedRaycast;
|
||||||
|
|
||||||
const emit = defineEmits<{ load: [] }>()
|
|
||||||
|
|
||||||
const props = defineProps<{ src: string }>();
|
const props = defineProps<{ src: string }>();
|
||||||
|
|
||||||
const elem = ref<ModelViewerElement | null>(null);
|
const elem = ref<ModelViewerElement | null>(null);
|
||||||
const scene = ref<ModelScene | null>(null);
|
const scene = ref<ModelScene | null>(null);
|
||||||
const renderer = ref<Renderer | null>(null);
|
const renderer = ref<Renderer | null>(null);
|
||||||
|
const controls = ref<SmoothControls | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
let lastCameraTargetPosition: Vector3 | undefined = undefined;
|
||||||
|
let lastCameraZoom: number | undefined = undefined;
|
||||||
|
let lastCameraUrl = props.src.toString();
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!elem.value) return;
|
if (!elem.value) return;
|
||||||
elem.value.addEventListener('load', async () => {
|
elem.value.addEventListener('before-render', () => {
|
||||||
if (!elem.value) return;
|
if (!elem.value) return;
|
||||||
// Delete the initial load banner
|
// Extract internals of model-viewer in order to hack unsupported features
|
||||||
let banner = elem.value.querySelector('.initial-load-banner');
|
|
||||||
if (banner) banner.remove();
|
|
||||||
// Set the scene and renderer
|
|
||||||
scene.value = elem.value[$scene] as ModelScene;
|
scene.value = elem.value[$scene] as ModelScene;
|
||||||
renderer.value = elem.value[$renderer] as Renderer;
|
renderer.value = elem.value[$renderer] as Renderer;
|
||||||
// Emit the load event
|
controls.value = (elem.value as any)[$controls] as SmoothControls;
|
||||||
emit('load')
|
// Recover the camera position if it was set before
|
||||||
|
if (lastCameraTargetPosition) {
|
||||||
|
// console.log("RESTORING camera position?", lastCameraTargetPosition);
|
||||||
|
scene.value.setTarget(-lastCameraTargetPosition.x, -lastCameraTargetPosition.y, -lastCameraTargetPosition.z);
|
||||||
|
scene.value.jumpToGoal(); // Avoid move animation
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
let tries = 0
|
||||||
|
while (tries++ < 25) {
|
||||||
|
if (!lastCameraZoom || !elem.value?.getCameraOrbit()?.radius) break;
|
||||||
|
let change = lastCameraZoom - elem.value.getCameraOrbit().radius;
|
||||||
|
//console.log("Zooming to", lastCameraZoom, "from", elem.value.getCameraOrbit().radius, "change", change);
|
||||||
|
if (Math.abs(change) < 0.001) break;
|
||||||
|
elem.value.zoom(-Math.sign(change) * (Math.pow(Math.abs(change) + 1, 0.9) - 1)); // Arbitrary, experimental
|
||||||
|
elem.value.jumpCameraToGoal();
|
||||||
|
await elem.value.updateComplete;
|
||||||
|
}
|
||||||
|
//console.log("Ready to save!")
|
||||||
|
lastCameraUrl = props.src.toString();
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
elem.value.addEventListener('camera-change', onCameraChange);
|
elem.value.addEventListener('camera-change', onCameraChange);
|
||||||
elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress));
|
elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onCameraChange() {
|
||||||
|
// Remember the camera position to keep it in case of scene changes
|
||||||
|
if (scene.value && props.src.toString() == lastCameraUrl) { // Don't overwrite with initial unwanted positions
|
||||||
|
lastCameraTargetPosition = scene.value.target.position.clone();
|
||||||
|
lastCameraZoom = elem.value?.getCameraOrbit()?.radius;
|
||||||
|
//console.log("Saving camera?", lastCameraTargetPosition, lastCameraZoom);
|
||||||
|
}
|
||||||
|
// Also need to update the SVG overlay
|
||||||
|
for (let lineId in lines.value) {
|
||||||
|
onCameraChangeLine(lineId as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Handles loading the events for <model-viewer>'s slotted progress bar
|
// Handles loading the events for <model-viewer>'s slotted progress bar
|
||||||
const progressBar = ref<HTMLElement | null>(null);
|
const progressBar = ref<HTMLElement | null>(null);
|
||||||
const updateBar = ref<HTMLElement | null>(null);
|
const updateBar = ref<HTMLElement | null>(null);
|
||||||
@@ -66,6 +99,17 @@ const onProgress = (totalProgress: number) => {
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const poster = ref<string>("")
|
||||||
|
const setPosterText = (newText: string) => {
|
||||||
|
poster.value = "data:image/svg+xml;charset=utf-8;base64," + btoa(
|
||||||
|
'<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg" fill="gray">' +
|
||||||
|
'<text x="50%" y="0%" dominant-baseline="middle" text-anchor="middle" font-size="48px">' +
|
||||||
|
newText +
|
||||||
|
'</text>' +
|
||||||
|
'</svg>')
|
||||||
|
}
|
||||||
|
setPosterText("Loading...")
|
||||||
|
|
||||||
class Line3DData {
|
class Line3DData {
|
||||||
startHotspot: HTMLElement = document.body
|
startHotspot: HTMLElement = document.body
|
||||||
endHotspot: HTMLElement = document.body
|
endHotspot: HTMLElement = document.body
|
||||||
@@ -118,13 +162,6 @@ function removeLine3D(id: number): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCameraChange() {
|
|
||||||
// Need to update the SVG overlay
|
|
||||||
for (let lineId in lines.value) {
|
|
||||||
onCameraChangeLine(lineId as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let svg = ref<SVGElement | null>(null);
|
let svg = ref<SVGElement | null>(null);
|
||||||
|
|
||||||
function onCameraChangeLine(lineId: number) {
|
function onCameraChangeLine(lineId: number) {
|
||||||
@@ -160,54 +197,47 @@ function entries(lines: { [id: number]: Line3DData }): [string, Line3DData][] {
|
|||||||
return Object.entries(lines);
|
return Object.entries(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({elem, onElemReady, scene, renderer, addLine3D, removeLine3D, onProgress});
|
defineExpose({elem, onElemReady, scene, renderer, controls, addLine3D, removeLine3D, onProgress, setPosterText});
|
||||||
|
|
||||||
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
|
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
|
||||||
watch(disableTap, (value) => {
|
watch(disableTap, (newDisableTap) => {
|
||||||
// Rerender not auto triggered? This works anyway...
|
if (elem.value) elem.value.disableTap = newDisableTap;
|
||||||
if (value) elem.value?.setAttribute('disable-tap', '');
|
|
||||||
else elem.value?.removeAttribute('disable-tap');
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- The main 3D model viewer -->
|
<!-- The main 3D model viewer -->
|
||||||
<model-viewer ref="elem" style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls
|
<model-viewer ref="elem" :ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :autoplay="settings.autoplay"
|
||||||
camera-orbit="30deg 75deg auto" max-camera-orbit="Infinity 180deg auto"
|
:environment-image="settings.background" :exposure="settings.exposure"
|
||||||
min-camera-orbit="-Infinity 0deg 5%" :disable-tap="disableTap" :exposure="settings.exposure"
|
:orbit-sensitivity="settings.orbitSensitivity" :pan-sensitivity="settings.panSensitivity"
|
||||||
:shadow-intensity="settings.shadowIntensity" interaction-prompt="none" :autoplay="settings.autoplay"
|
:poster="poster" :shadow-intensity="settings.shadowIntensity" :skybox-image="settings.background"
|
||||||
:ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :skybox-image="settings.background"
|
:src="props.src" :zoom-sensitivity="settings.zoomSensitivity" alt="The 3D model(s)" camera-controls
|
||||||
:environment-image="settings.background">
|
camera-orbit="30deg 75deg auto" interaction-prompt="none" max-camera-orbit="Infinity 180deg auto"
|
||||||
|
min-camera-orbit="-Infinity 0deg 5%" style="width: 100%; height: 100%">
|
||||||
<slot></slot>
|
<slot></slot>
|
||||||
<!-- Display some information during initial load -->
|
<!-- Add a progress bar to the top of the model viewer -->
|
||||||
<div class="annotation initial-load-banner">
|
<div ref="progressBar" slot="progress-bar" class="progress-bar">
|
||||||
Trying to load models from...
|
<div ref="updateBar" class="update-bar"/>
|
||||||
<v-list v-for="src in settings.preload" :key="src">
|
|
||||||
<v-list-item>{{ src }}</v-list-item>
|
|
||||||
</v-list>
|
|
||||||
<!-- Too much idle CPU usage: <loading></loading> -->
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Customize the progress bar -->
|
|
||||||
<div class="progress-bar" slot="progress-bar" ref="progressBar">
|
|
||||||
<div class="update-bar" ref="updateBar"/>
|
|
||||||
</div>
|
</div>
|
||||||
</model-viewer>
|
</model-viewer>
|
||||||
|
|
||||||
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
||||||
<div class="overlay-svg-wrapper">
|
<div class="overlay-svg-wrapper">
|
||||||
<svg ref="svg" class="overlay-svg" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
<svg ref="svg" class="overlay-svg" height="100%" width="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g v-for="[lineId, line] in entries(lines)" :key="lineId">
|
<g v-for="[lineId, line] in entries(lines)" :key="lineId">
|
||||||
<line :x1="line.start2D[0]" :y1="line.start2D[1]" :x2="line.end2D[0]"
|
<line :x1="line.start2D[0]" :x2="line.end2D[0]" :y1="line.start2D[1]"
|
||||||
:y2="line.end2D[1]" v-bind="line.lineAttrs"/>
|
:y2="line.end2D[1]" v-bind="line.lineAttrs"/>
|
||||||
<g v-if="line.centerText !== undefined">
|
<g v-if="line.centerText !== undefined">
|
||||||
<rect :x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
<rect v-if="line.centerText"
|
||||||
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2"
|
:height="line.centerTextSize[1] + 4"
|
||||||
:width="line.centerTextSize[0] + 8" :height="line.centerTextSize[1] + 4"
|
:width="line.centerTextSize[0] + 8"
|
||||||
fill="gray" fill-opacity="0.75" rx="4" ry="4" stroke="black" v-if="line.centerText"/>
|
:x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
||||||
<text :x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2" fill="gray"
|
||||||
text-anchor="middle" dominant-baseline="middle" font-size="16" fill="black"
|
fill-opacity="0.75" rx="4" ry="4" stroke="black"/>
|
||||||
:class="'line' + lineId + '_text'" v-if="line.centerText">
|
<text v-if="line.centerText" :class="'line' + lineId + '_text'"
|
||||||
|
:x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
||||||
|
dominant-baseline="middle" fill="black"
|
||||||
|
font-size="16" text-anchor="middle">
|
||||||
{{ line.centerText }}
|
{{ line.centerText }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -242,17 +272,6 @@ watch(disableTap, (value) => {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.initial-load-banner {
|
|
||||||
width: 300px;
|
|
||||||
margin: auto;
|
|
||||||
margin-top: 3em;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.initial-load-banner .v-list-item {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress-bar {
|
.progress-bar {
|
||||||
display: block;
|
display: block;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|||||||
Reference in New Issue
Block a user