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:
Yeicor
2024-03-31 20:20:53 +02:00
parent 345636e478
commit e8c0f683c5
13 changed files with 207 additions and 173 deletions

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
// License text for all dependencies, only downloaded when/if needed
// @ts-ignore
import licenseText from "../../assets/licenses.txt?raw";

View File

@@ -1,18 +1,16 @@
<script setup lang="ts">
<script lang="ts" setup>
import {onMounted, onUpdated, ref} from "vue";
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
import type {ModelViewerElement} from '@google/model-viewer';
// Optimized minimal dependencies from three
import {Vector3} from "three/src/math/Vector3.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
const OrientationGizmo = OrientationGizmoRaw.default;
const props = defineProps<{ elem: ModelViewerElement | null, scene: ModelScene }>();
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> }>();
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
// noinspection SpellCheckingInspection
@@ -33,21 +31,26 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
}
// Append and listen for events
gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => {
let lookFrom = scene.getCamera().position.clone();
let lookAt = scene.getTarget().clone().add(scene.target.position);
let magnitude = lookFrom.clone().sub(lookAt).length()
let direction = new Vector3(axis.direction.x, axis.direction.y, axis.direction.z);
let newLookFrom = lookAt.clone().add(direction.clone().multiplyScalar(magnitude));
//console.log("New camera position", newLookFrom)
scene.getCamera().position.copy(newLookFrom);
scene.getCamera().lookAt(lookAt);
if ((scene as any).__perspectiveCamera) { // HACK: Make the hacky ortho also work
(scene as any).__perspectiveCamera.position.copy(newLookFrom);
(scene as any).__perspectiveCamera.lookAt(lookAt);
if (!props.viewer.elem || !props.viewer.controls) return;
// Animate the controls to the new wanted angle
const controls = props.viewer.controls;
const {theta: curTheta/*, phi: curPhi*/} = (controls as any).goalSpherical;
let wantedTheta = NaN;
let wantedPhi = NaN;
let attempt = 0
while ((attempt == 0 || curTheta == wantedTheta) && attempt < 2) {
if (attempt > 0) { // Flip the camera if the user clicks on the same axis
axis.direction.x = -axis.direction.x;
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();
requestIdleCallback(() => props.elem?.dispatchEvent(
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
}
return gizmo;
}
@@ -65,9 +68,9 @@ function updateGizmo() {
}
let reinstall = () => {
if(!container.value) return;
if (!container.value) return;
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);
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
}

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import {defineModel, inject, ref, type ShallowRef, watch} from "vue";
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
import SvgIcon from '@jamescoyle/vue-icon';
@@ -258,7 +258,7 @@ let onViewerReady = (viewer: typeof ModelViewerWrapperT) => {
hasListeners = true;
elem.addEventListener('mousedown', mouseDownListener); // Avoid clicking when dragging
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
for (let sel of selected.value) {
let scene = props.viewer?.scene;
@@ -462,32 +462,32 @@ window.addEventListener('keydown', (event) => {
</script>
<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>
<svg-icon type="mdi" :path="mdiCursorDefaultClick"/>
<svg-icon :path="mdiCursorDefaultClick" type="mdi"/>
</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 }">
<v-select v-bind="props" class="select-only" variant="underlined"
:items="['Any (S)', '(F)aces', '(E)dges', '(V)ertices']"
v-model="selectFilter"/>
<v-select v-model="selectFilter" :items="['Any (S)', '(F)aces', '(E)dges', '(V)ertices']" class="select-only"
v-bind="props"
variant="underlined"/>
</template>
</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>
<svg-icon type="mdi" :path="mdiFeatureSearch"/>
<svg-icon :path="mdiFeatureSearch" type="mdi"/>
</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>
<svg-icon type="mdi" :path="mdiCubeOutline"/>
<svg-icon :path="mdiCubeOutline" type="mdi"/>
</v-btn>
<v-btn icon @click="toggleShowDistances" :color="showDistances ? 'surface-light' : ''">
<v-btn :color="showDistances ? 'surface-light' : ''" icon @click="toggleShowDistances">
<v-tooltip activator="parent">
{{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }}
</v-tooltip>
<svg-icon type="mdi" :path="mdiRuler"/>
<svg-icon :path="mdiRuler" type="mdi"/>
</v-btn>
</template>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts">
<script lang="ts" setup>
import {
VBtn,
VCard,
@@ -16,7 +16,6 @@ import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
import SvgIcon from '@jamescoyle/vue-icon';
import type {ModelViewerElement} from '@google/model-viewer';
import type {MObject3D} from "./Selection.vue";
import Loading from "../misc/Loading.vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import {defineAsyncComponent, ref, type Ref} from "vue";
@@ -57,14 +56,14 @@ function syncOrthoCamera(force: boolean) {
let h = perspectiveWidthAtCenter / scene.aspect;
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
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
requestAnimationFrame(() => syncOrthoCamera(false));
}
}
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
function toggleProjection() {
async function toggleProjection() {
let scene = props.viewer?.scene;
if (!scene) return;
let prevCam = scene.camera;
@@ -79,16 +78,16 @@ function toggleProjection() {
scene.queueRender() // Force rerender of model-viewer
}
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
// The camera change may take a few frames to take effect, dispatch the event after a delay
requestIdleCallback(() => props.viewer?.elem?.dispatchEvent(
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
// The camera change may take a frame to take effect, dispatch the event after a delay
await new Promise((resolve) => requestAnimationFrame(resolve));
props.viewer?.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}));
}
async function centerCamera() {
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
if (!viewerEl) return;
await viewerEl.updateFraming();
viewerEl.zoom(3);
props.viewer?.scene?.setTarget(0, 0, 0); // Center the target
viewerEl.zoom(-1000000); // Max zoom out
}
@@ -127,35 +126,35 @@ window.addEventListener('keydown', (event) => {
</script>
<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/>
<h5>Camera</h5>
<v-btn icon @click="toggleProjection"><span class="icon-detail">{{ toggleProjectionText }}</span>
<v-tooltip activator="parent">Toggle (P)rojection<br/>(currently
{{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }})
</v-tooltip>
<svg-icon type="mdi" :path="mdiProjector"></svg-icon>
<svg-icon :path="mdiProjector" type="mdi"></svg-icon>
</v-btn>
<v-btn icon @click="centerCamera">
<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-divider/>
<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)"/>
<v-divider/>
<v-spacer></v-spacer>
<h5>Extras</h5>
<v-btn icon @click="downloadSceneGlb">
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
<svg-icon type="mdi" :path="mdiDownload"/>
<svg-icon :path="mdiDownload" type="mdi"/>
</v-btn>
<v-dialog id="licenses-dialog" fullscreen>
<template v-slot:activator="{ props }">
<v-btn icon v-bind="props">
<v-tooltip activator="parent">Show Licenses</v-tooltip>
<svg-icon type="mdi" :path="mdiLicense"/>
<svg-icon :path="mdiLicense" type="mdi"/>
</v-btn>
</template>
<template v-slot:default="{ isActive }">
@@ -165,7 +164,7 @@ window.addEventListener('keydown', (event) => {
<v-spacer>
</v-spacer>
<v-btn icon @click="isActive.value = false">
<svg-icon type="mdi" :path="mdiClose"/>
<svg-icon :path="mdiClose" type="mdi"/>
</v-btn>
</v-toolbar>
<v-card-text>
@@ -176,7 +175,7 @@ window.addEventListener('keydown', (event) => {
</v-dialog>
<v-btn icon @click="openGithub">
<v-tooltip activator="parent">Open (G)itHub</v-tooltip>
<svg-icon type="mdi" :path="mdiGithub"/>
<svg-icon :path="mdiGithub" type="mdi"/>
</v-btn>
<div ref="statsHolder"></div>
</template>