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

@@ -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)

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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

View File

@@ -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: '',

View File

@@ -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>

View File

@@ -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";

View File

@@ -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";

View File

@@ -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
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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;