mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-21 23:14:27 +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:
@@ -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";
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user