mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
frontend complete migration from parcel to vite for much better production builds
This commit is contained in:
15
frontend/tools/LicensesDialogContent.vue
Normal file
15
frontend/tools/LicensesDialogContent.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
// License text for all dependencies, only downloaded when/if needed
|
||||
// @ts-ignore
|
||||
import licenseText from "../../assets/licenses.txt?raw";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<pre class="license-text" v-html="licenseText"/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.license-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
</style>
|
||||
81
frontend/tools/OrientationGizmo.vue
Normal file
81
frontend/tools/OrientationGizmo.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<script setup lang="ts">
|
||||
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";
|
||||
|
||||
globalThis.THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
||||
|
||||
const OrientationGizmo = OrientationGizmoRaw.default;
|
||||
|
||||
const props = defineProps<{ elem: ModelViewerElement | null, scene: ModelScene }>();
|
||||
|
||||
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
|
||||
// noinspection SpellCheckingInspection
|
||||
let gizmo = new OrientationGizmoRaw.default(scene.camera, {
|
||||
size: expectedParent.clientWidth,
|
||||
bubbleSizePrimary: expectedParent.clientWidth / 12,
|
||||
bubbleSizeSeconday: expectedParent.clientWidth / 14,
|
||||
fontSize: (expectedParent.clientWidth / 10) + "px"
|
||||
});
|
||||
// HACK: Swap axes to fake the CAD orientation
|
||||
for (let swap of [["y", "-z"], ["z", "-y"], ["z", "-z"]]) {
|
||||
let indexA = gizmo.bubbles.findIndex((bubble: any) => bubble.axis == swap[0])
|
||||
let indexB = gizmo.bubbles.findIndex((bubble: any) => bubble.axis == swap[1])
|
||||
let dirA = gizmo.bubbles[indexA].direction.clone();
|
||||
let dirB = gizmo.bubbles[indexB].direction.clone();
|
||||
gizmo.bubbles[indexA].direction.copy(dirB);
|
||||
gizmo.bubbles[indexB].direction.copy(dirA);
|
||||
}
|
||||
// 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);
|
||||
}
|
||||
scene.queueRender();
|
||||
requestIdleCallback(() => props.elem?.dispatchEvent(
|
||||
new CustomEvent('camera-change', {detail: {source: 'none'}})))
|
||||
}
|
||||
return gizmo;
|
||||
}
|
||||
|
||||
// Mount, unmount and listen for scene changes
|
||||
let container = ref<HTMLElement | null>(null);
|
||||
|
||||
let gizmo: HTMLElement & { update: () => void }
|
||||
|
||||
function updateGizmo() {
|
||||
if (gizmo.isConnected) {
|
||||
gizmo.update();
|
||||
requestIdleCallback(updateGizmo);
|
||||
}
|
||||
}
|
||||
|
||||
let reinstall = () => {
|
||||
if(!container.value) return;
|
||||
if (gizmo) container.value.removeChild(gizmo);
|
||||
gizmo = createGizmo(container.value, props.scene as ModelScene) as typeof gizmo;
|
||||
container.value.appendChild(gizmo);
|
||||
requestIdleCallback(updateGizmo); // Low priority updates
|
||||
}
|
||||
onMounted(reinstall)
|
||||
onUpdated(reinstall);
|
||||
// onUnmounted is not needed because the gizmo is removed when the container is removed
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="orientation-gizmo"/>
|
||||
</template>
|
||||
456
frontend/tools/Selection.vue
Normal file
456
frontend/tools/Selection.vue
Normal file
@@ -0,0 +1,456 @@
|
||||
<script setup lang="ts">
|
||||
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';
|
||||
import type {ModelViewerElement} from '@google/model-viewer';
|
||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||
import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
|
||||
import type {Intersection, Material, Mesh, Object3D} from "three";
|
||||
import {Box3, Matrix4, Raycaster, Vector3} from "three";
|
||||
import type ModelViewerWrapperT from "../viewer/ModelViewerWrapper.vue";
|
||||
import {extrasNameKey} from "../misc/gltf";
|
||||
import {SceneMgr} from "../misc/scene";
|
||||
import {Document} from "@gltf-transform/core";
|
||||
import {AxesColors} from "../misc/helpers";
|
||||
import {distances} from "../misc/distances";
|
||||
|
||||
export type MObject3D = Mesh & {
|
||||
userData: { noHit?: boolean },
|
||||
material: Material & { color: { r: number, g: number, b: number }, __prevBaseColorFactor?: [number, number, number] }
|
||||
};
|
||||
|
||||
let props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
|
||||
let emit = defineEmits<{ findModel: [string] }>();
|
||||
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
||||
let selectionEnabled = ref(false);
|
||||
let selected = defineModel<Array<Intersection<MObject3D>>>({default: []});
|
||||
let highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
||||
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
||||
let showDistances = ref<Boolean>(true);
|
||||
|
||||
let mouseDownAt: [number, number] | null = null;
|
||||
let selectFilter = ref('Any (S)');
|
||||
const raycaster = new Raycaster();
|
||||
|
||||
|
||||
let selectionMoveListener = (event: MouseEvent) => {
|
||||
mouseDownAt = [event.clientX, event.clientY];
|
||||
if (!selectionEnabled.value) return;
|
||||
};
|
||||
|
||||
let selectionListener = (event: MouseEvent) => {
|
||||
// If the mouse moved while clicked (dragging), avoid selection logic
|
||||
if (mouseDownAt) {
|
||||
let [x, y] = mouseDownAt;
|
||||
mouseDownAt = null;
|
||||
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If disabled, avoid selection logic
|
||||
if (!selectionEnabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Set raycaster parameters
|
||||
if (selectFilter.value === 'Any (S)') {
|
||||
raycaster.params.Line.threshold = 0.2;
|
||||
raycaster.params.Points.threshold = 0.8;
|
||||
} else if (selectFilter.value === '(E)dges') {
|
||||
raycaster.params.Line.threshold = 0.8;
|
||||
raycaster.params.Points.threshold = 0.0;
|
||||
} else if (selectFilter.value === '(V)ertices') {
|
||||
raycaster.params.Line.threshold = 0.0;
|
||||
raycaster.params.Points.threshold = 0.8;
|
||||
} else if (selectFilter.value === '(F)aces') {
|
||||
raycaster.params.Line.threshold = 0.0;
|
||||
raycaster.params.Points.threshold = 0.0;
|
||||
}
|
||||
|
||||
// Define the 3D ray from the camera to the mouse
|
||||
// NOTE: Need to access internal as the API has issues with small faces surrounded by edges
|
||||
let scene: ModelScene = props.viewer?.scene;
|
||||
const ndcCoords = scene.getNDC(event.clientX, event.clientY);
|
||||
raycaster.setFromCamera(ndcCoords, scene.camera);
|
||||
if (!scene.camera.isPerspectiveCamera) {
|
||||
// Need to fix the ray direction for ortho camera FIXME: Still buggy...
|
||||
raycaster.ray.direction.copy(scene.camera.getWorldDirection(new Vector3()));
|
||||
}
|
||||
//console.log('Ray', raycaster.ray);
|
||||
|
||||
// DEBUG: Draw the ray
|
||||
// let actualFrom = scene.getTarget().clone().add(raycaster.ray.origin);
|
||||
// let actualTo = actualFrom.clone().add(raycaster.ray.direction.clone().multiplyScalar(50));
|
||||
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
|
||||
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
|
||||
|
||||
// Find all hit objects and select the wanted one based on the filter
|
||||
const hits = raycaster.intersectObject(scene, true);
|
||||
let hit = hits.find((hit: Intersection<Object3D>) => {
|
||||
if (!hit.object || !(hit.object as any).isMesh) return false;
|
||||
const kind = hit.object.type
|
||||
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
|
||||
let isEdge = kind === 'Line' || kind === 'LineSegments';
|
||||
let isVertex = kind === 'Points';
|
||||
const kindOk = (selectFilter.value === 'Any (S)') ||
|
||||
(isFace && selectFilter.value === '(F)aces') ||
|
||||
(isEdge && selectFilter.value === '(E)dges') ||
|
||||
(isVertex && selectFilter.value === '(V)ertices');
|
||||
return hit.object.visible && !hit.object.userData.noHit && kindOk;
|
||||
}) as Intersection<MObject3D> | undefined;
|
||||
//console.log('Hit', hit)
|
||||
|
||||
if (!highlightNextSelection.value[0]) {
|
||||
// If we are selecting, toggle the selection or deselect all if no hit
|
||||
if (hit) {
|
||||
// Toggle selection
|
||||
const wasSelected = selected.value.find((m) => m.object.name === hit?.object?.name) !== undefined;
|
||||
if (wasSelected) {
|
||||
deselect(hit)
|
||||
} else {
|
||||
select(hit)
|
||||
}
|
||||
} else {
|
||||
deselectAll();
|
||||
}
|
||||
updateBoundingBox();
|
||||
updateDistances();
|
||||
} else if (hit) {
|
||||
// Otherwise, highlight the model that owns the hit
|
||||
emit('findModel', hit.object.userData[extrasNameKey])
|
||||
// And reset the selection mode
|
||||
toggleHighlightNextSelection()
|
||||
}
|
||||
scene.queueRender() // Force rerender of model-viewer
|
||||
}
|
||||
|
||||
function select(hit: Intersection<MObject3D>) {
|
||||
console.log('Selecting', hit.object.name)
|
||||
if (selected.value.find((m) => m.object.name === hit.object.name) === undefined) {
|
||||
selected.value.push(hit);
|
||||
}
|
||||
hit.object.material.__prevBaseColorFactor = [
|
||||
hit.object.material.color.r,
|
||||
hit.object.material.color.g,
|
||||
hit.object.material.color.b,
|
||||
];
|
||||
hit.object.material.color.r = 1;
|
||||
hit.object.material.color.g = 0;
|
||||
hit.object.material.color.b = 0;
|
||||
}
|
||||
|
||||
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) {
|
||||
console.log('Deselecting', hit.object.name)
|
||||
if (alsoRemove) {
|
||||
// Remove the matching object from the selection
|
||||
let toRemove = selected.value.findIndex((m) => m.object.name === hit.object.name);
|
||||
selected.value.splice(toRemove, 1);
|
||||
}
|
||||
if (hit.object.material.__prevBaseColorFactor) {
|
||||
hit.object.material.color.r = hit.object.material.__prevBaseColorFactor[0]
|
||||
hit.object.material.color.g = hit.object.material.__prevBaseColorFactor[1]
|
||||
hit.object.material.color.b = hit.object.material.__prevBaseColorFactor[2]
|
||||
delete hit.object.material.__prevBaseColorFactor;
|
||||
}
|
||||
}
|
||||
|
||||
function deselectAll(alsoRemove = true) {
|
||||
// Clear selection (shallow copy to avoid modifying the array while iterating)
|
||||
let toClear = selected.value.slice();
|
||||
for (let material of toClear) {
|
||||
deselect(material, alsoRemove);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelection() {
|
||||
let viewer: ModelViewerElement = props.viewer?.elem;
|
||||
if (!viewer) return;
|
||||
selectionEnabled.value = !selectionEnabled.value;
|
||||
setDisableTap(selectionEnabled.value);
|
||||
}
|
||||
|
||||
function toggleHighlightNextSelection() {
|
||||
highlightNextSelection.value = [
|
||||
!highlightNextSelection.value[0],
|
||||
highlightNextSelection.value[0] ? highlightNextSelection.value[1] : selectionEnabled.value
|
||||
];
|
||||
if (highlightNextSelection.value[0]) {
|
||||
// Reuse selection code to identify the model
|
||||
if (!selectionEnabled.value) toggleSelection()
|
||||
} else {
|
||||
if (selectionEnabled.value !== highlightNextSelection.value[1]) toggleSelection()
|
||||
highlightNextSelection.value = [false, false];
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowBoundingBox() {
|
||||
showBoundingBox.value = !showBoundingBox.value;
|
||||
updateBoundingBox();
|
||||
}
|
||||
|
||||
let firstLoad = true;
|
||||
let hasListeners = false;
|
||||
let cameraChangeWaiting = false;
|
||||
let cameraChangeLast = 0
|
||||
let onCameraChange = () => {
|
||||
// Avoid updates while dragging (slow operation)
|
||||
cameraChangeLast = performance.now();
|
||||
if (cameraChangeWaiting) return;
|
||||
cameraChangeWaiting = true;
|
||||
let waitingHandler: () => void;
|
||||
waitingHandler = () => {
|
||||
// Ignore also inertia
|
||||
if (performance.now() - cameraChangeLast > 250) {
|
||||
updateBoundingBox();
|
||||
cameraChangeWaiting = false;
|
||||
} else {
|
||||
// If the camera is still moving, wait a bit more
|
||||
setTimeout(waitingHandler, 100);
|
||||
}
|
||||
};
|
||||
setTimeout(waitingHandler, 100); // Wait for the camera to stop moving
|
||||
};
|
||||
let onViewerReady = (viewer: typeof ModelViewerWrapperT) => {
|
||||
if (!viewer) return;
|
||||
// props.viewer.elem may not yet be available, so we need to wait for it
|
||||
viewer.onElemReady((elem: ModelViewerElement) => {
|
||||
if (hasListeners) return;
|
||||
hasListeners = true;
|
||||
elem.addEventListener('mouseup', selectionListener);
|
||||
elem.addEventListener('mousedown', selectionMoveListener); // Avoid clicking when dragging
|
||||
elem.addEventListener('load', () => {
|
||||
if (firstLoad) {
|
||||
toggleShowBoundingBox();
|
||||
firstLoad = false;
|
||||
} else {
|
||||
updateBoundingBox();
|
||||
}
|
||||
});
|
||||
elem.addEventListener('camera-change', onCameraChange);
|
||||
});
|
||||
};
|
||||
if (props.viewer) onViewerReady(props.viewer);
|
||||
else watch(() => props.viewer, () => onViewerReady(props.viewer as any));
|
||||
|
||||
let {sceneDocument} = inject<{ sceneDocument: ShallowRef<Document> }>('sceneDocument')!!;
|
||||
let boundingBoxLines: { [points: string]: number } = {}
|
||||
|
||||
function updateBoundingBox() {
|
||||
if (!showBoundingBox.value) {
|
||||
for (let lineId of Object.values(boundingBoxLines)) {
|
||||
props.viewer?.removeLine3D(lineId);
|
||||
}
|
||||
boundingBoxLines = {};
|
||||
return;
|
||||
}
|
||||
let bb: Box3
|
||||
let boundingBoxLinesToRemove = Object.keys(boundingBoxLines);
|
||||
if (selected.value.length > 0) {
|
||||
bb = new Box3();
|
||||
for (let hit of selected.value) {
|
||||
bb.expandByObject(hit.object);
|
||||
}
|
||||
bb.applyMatrix4(new Matrix4().makeTranslation(props.viewer?.scene.getTarget()));
|
||||
} else {
|
||||
bb = SceneMgr.getBoundingBox(sceneDocument.value);
|
||||
}
|
||||
// Define each edge of the bounding box, to draw a line for each axis
|
||||
let corners = [
|
||||
[bb.min.x, bb.min.y, bb.min.z],
|
||||
[bb.min.x, bb.min.y, bb.max.z],
|
||||
[bb.min.x, bb.max.y, bb.min.z],
|
||||
[bb.min.x, bb.max.y, bb.max.z],
|
||||
[bb.max.x, bb.min.y, bb.min.z],
|
||||
[bb.max.x, bb.min.y, bb.max.z],
|
||||
[bb.max.x, bb.max.y, bb.min.z],
|
||||
[bb.max.x, bb.max.y, bb.max.z],
|
||||
];
|
||||
let edgesByAxis = [
|
||||
[[0, 4], [1, 5], [2, 6], [3, 7]], // X (CAD)
|
||||
[[0, 2], [1, 3], [4, 6], [5, 7]], // Z (CAD)
|
||||
[[0, 1], [2, 3], [4, 5], [6, 7]], // Y (CAD)
|
||||
];
|
||||
// Only draw one edge per axis, the 2nd closest one to the camera
|
||||
for (let edgeI in edgesByAxis) {
|
||||
let axisEdges = edgesByAxis[edgeI];
|
||||
let edge: Array<number> = axisEdges[0];
|
||||
for (let i = 0; i < 2; i++) { // Find the 2nd closest one by running twice dropping the first
|
||||
edge = axisEdges[0];
|
||||
let edgeDist = Infinity;
|
||||
let cameraPos: Vector3 = props.viewer?.scene.camera.position;
|
||||
for (let testEdge of axisEdges) {
|
||||
let from = new Vector3(...corners[testEdge[0]]);
|
||||
let to = new Vector3(...corners[testEdge[1]]);
|
||||
let mid = from.clone().add(to).multiplyScalar(0.5);
|
||||
let newDist = cameraPos.distanceTo(mid);
|
||||
if (newDist < edgeDist) {
|
||||
edge = testEdge;
|
||||
edgeDist = newDist;
|
||||
}
|
||||
}
|
||||
axisEdges = axisEdges.filter((e) => e !== edge);
|
||||
}
|
||||
let from = new Vector3(...corners[edge[0]]);
|
||||
let to = new Vector3(...corners[edge[1]]);
|
||||
let color = [AxesColors.x, AxesColors.y, AxesColors.z][edgeI][1]; // Secondary colors
|
||||
let lineCacheKey = JSON.stringify([from, to]);
|
||||
let matchingLine = boundingBoxLines[lineCacheKey];
|
||||
if (matchingLine) {
|
||||
boundingBoxLinesToRemove = boundingBoxLinesToRemove.filter((l) => l !== lineCacheKey);
|
||||
} else {
|
||||
let newLineId = props.viewer?.addLine3D(from, to,
|
||||
to.clone().sub(from).length().toFixed(1) + "mm", {
|
||||
"stroke": "rgb(" + color.join(',') + ")",
|
||||
"stroke-width": "2"
|
||||
});
|
||||
if (newLineId) {
|
||||
boundingBoxLines[lineCacheKey] = newLineId;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Remove the lines that are no longer needed
|
||||
for (let lineLocator of boundingBoxLinesToRemove) {
|
||||
if (props.viewer?.removeLine3D(boundingBoxLines[lineLocator])) {
|
||||
delete boundingBoxLines[lineLocator];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleShowDistances() {
|
||||
showDistances.value = !showDistances.value;
|
||||
updateDistances();
|
||||
}
|
||||
|
||||
let distanceLines: { [points: string]: number } = {}
|
||||
|
||||
function updateDistances() {
|
||||
if (!showDistances.value || selected.value.length != 2) {
|
||||
for (let lineId of Object.values(distanceLines)) {
|
||||
props.viewer?.removeLine3D(lineId);
|
||||
}
|
||||
distanceLines = {};
|
||||
return;
|
||||
}
|
||||
|
||||
// Set up the line cache (for delta updates)
|
||||
let distanceLinesToRemove = Object.keys(distanceLines);
|
||||
|
||||
function ensureLine(from: Vector3, to: Vector3, text: string, color: string) {
|
||||
console.log('ensureLine', from, to, text, color)
|
||||
let lineCacheKey = JSON.stringify([from, to]);
|
||||
let matchingLine = distanceLines[lineCacheKey];
|
||||
if (matchingLine) {
|
||||
distanceLinesToRemove = distanceLinesToRemove.filter((l) => l !== lineCacheKey);
|
||||
} else {
|
||||
distanceLines[lineCacheKey] = props.viewer?.addLine3D(from, to, text, {
|
||||
"stroke": color,
|
||||
"stroke-width": "2",
|
||||
"stroke-dasharray": "5"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add lines (if not already added)
|
||||
let objA = selected.value[0].object;
|
||||
let objB = selected.value[1].object;
|
||||
let {min, center, max} = distances(objA, objB, props.viewer?.scene);
|
||||
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
|
||||
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
|
||||
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
|
||||
|
||||
// Remove the lines that are no longer needed
|
||||
for (let lineLocator of distanceLinesToRemove) {
|
||||
props.viewer?.removeLine3D(distanceLines[lineLocator]);
|
||||
delete distanceLines[lineLocator];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Add keyboard shortcuts
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 's') {
|
||||
if (selectFilter.value == 'Any (S)') toggleSelection();
|
||||
else {
|
||||
selectFilter.value = 'Any (S)';
|
||||
if (!selectionEnabled.value) toggleSelection();
|
||||
}
|
||||
} else if (event.key === 'f') {
|
||||
if (selectFilter.value == '(F)aces') toggleSelection();
|
||||
else {
|
||||
selectFilter.value = '(F)aces';
|
||||
if (!selectionEnabled.value) toggleSelection();
|
||||
}
|
||||
} else if (event.key === 'e') {
|
||||
if (selectFilter.value == '(E)dges') toggleSelection();
|
||||
else {
|
||||
selectFilter.value = '(E)dges';
|
||||
if (!selectionEnabled.value) toggleSelection();
|
||||
}
|
||||
} else if (event.key === 'v') {
|
||||
if (selectFilter.value == '(V)ertices') toggleSelection();
|
||||
else {
|
||||
selectFilter.value = '(V)ertices';
|
||||
if (!selectionEnabled.value) toggleSelection();
|
||||
}
|
||||
} else if (event.key === 'b') {
|
||||
toggleShowBoundingBox();
|
||||
} else if (event.key === 'd') {
|
||||
toggleShowDistances();
|
||||
} else if (event.key === 'h') {
|
||||
toggleHighlightNextSelection();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="select-parent">
|
||||
<v-btn icon @click="toggleSelection" :color="selectionEnabled ? 'surface-light' : ''">
|
||||
<v-tooltip activator="parent">{{ selectionEnabled ? 'Disable (s)election mode' : 'Enable (s)election mode' }}
|
||||
</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiCursorDefaultClick"/>
|
||||
</v-btn>
|
||||
<v-tooltip :text="'Select only ' + selectFilter.toString().toLocaleLowerCase()" :open-on-click="false">
|
||||
<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"/>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<v-btn icon @click="toggleHighlightNextSelection" :color="highlightNextSelection[0] ? 'surface-light' : ''">
|
||||
<v-tooltip activator="parent">(H)ighlight the next clicked element in the models list</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiFeatureSearch"/>
|
||||
</v-btn>
|
||||
<v-btn icon @click="toggleShowBoundingBox" :color="showBoundingBox ? 'surface-light' : ''">
|
||||
<v-tooltip activator="parent">{{ showBoundingBox ? 'Hide selection (b)ounds' : 'Show selection (b)ounds' }}
|
||||
</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiCubeOutline"/>
|
||||
</v-btn>
|
||||
<v-btn icon @click="toggleShowDistances" :color="showDistances ? 'surface-light' : ''">
|
||||
<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"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Very hacky styling... */
|
||||
.select-parent {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.select-parent .v-btn {
|
||||
position: relative;
|
||||
top: -42px;
|
||||
}
|
||||
|
||||
.select-only {
|
||||
display: inline-block;
|
||||
width: calc(100% - 48px);
|
||||
position: relative;
|
||||
top: -12px;
|
||||
}
|
||||
</style>
|
||||
188
frontend/tools/Tools.vue
Normal file
188
frontend/tools/Tools.vue
Normal file
@@ -0,0 +1,188 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
VBtn,
|
||||
VCard,
|
||||
VCardText,
|
||||
VDialog,
|
||||
VDivider,
|
||||
VSpacer,
|
||||
VToolbar,
|
||||
VToolbarTitle,
|
||||
VTooltip,
|
||||
} from "vuetify/lib/components/index.mjs";
|
||||
import OrientationGizmo from "./OrientationGizmo.vue";
|
||||
import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera.js";
|
||||
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 {Intersection} from "three";
|
||||
import type {MObject3D} from "./Selection.vue";
|
||||
import Loading from "../misc/Loading.vue";
|
||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||
import {defineAsyncComponent, type Ref, ref} from "vue";
|
||||
|
||||
const SelectionComponent = defineAsyncComponent({
|
||||
loader: () => import("./Selection.vue"),
|
||||
loadingComponent: () => "Loading...",
|
||||
delay: 0,
|
||||
});
|
||||
let selectionComp = ref<InstanceType<typeof SelectionComponent> | null>(null);
|
||||
|
||||
const LicensesDialogContent = defineAsyncComponent({
|
||||
loader: () => import("./LicensesDialogContent.vue"),
|
||||
loadingComponent: Loading,
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
|
||||
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||
const emit = defineEmits<{ findModel: [string] }>()
|
||||
|
||||
let selection: Ref<Array<Intersection<MObject3D>>> = ref([]);
|
||||
let selectionFaceCount = () => selection.value.filter((s) => s.object.type == "Mesh" || s.object.type == "SkinnedMesh").length
|
||||
let selectionEdgeCount = () => selection.value.filter((s) => s.object.type == "Line").length
|
||||
let selectionVertexCount = () => selection.value.filter((s) => s.object.type == "Points").length
|
||||
|
||||
function syncOrthoCamera(force: boolean) {
|
||||
let scene = props.viewer?.scene;
|
||||
if (!scene) return;
|
||||
let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera;
|
||||
if (force || perspectiveCam && scene.camera != perspectiveCam) {
|
||||
// Get zoom level from perspective camera
|
||||
let dist = scene.getTarget().distanceToSquared(perspectiveCam.position);
|
||||
let w = scene.aspect * dist ** 1.1 / 4000;
|
||||
let h = dist ** 1.1 / 4000;
|
||||
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
|
||||
scene.camera.position.copy(perspectiveCam.position);
|
||||
scene.camera.lookAt(scene.getTarget().clone().add(scene.target.position));
|
||||
if (force) scene.queueRender() // Force rerender of model-viewer
|
||||
requestAnimationFrame(() => syncOrthoCamera(false));
|
||||
}
|
||||
}
|
||||
|
||||
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
||||
function toggleProjection() {
|
||||
let scene = props.viewer?.scene;
|
||||
if (!scene) return;
|
||||
let prevCam = scene.camera;
|
||||
let wasPerspectiveCamera = prevCam.isPerspectiveCamera;
|
||||
if (wasPerspectiveCamera) {
|
||||
(scene as any).__perspectiveCamera = prevCam; // Save the default perspective camera
|
||||
// This hack also needs to sync the camera position and target
|
||||
syncOrthoCamera(true);
|
||||
} else {
|
||||
// Restore the default perspective camera
|
||||
scene.camera = (scene as any).__perspectiveCamera;
|
||||
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'}})))
|
||||
}
|
||||
|
||||
async function centerCamera() {
|
||||
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
|
||||
if (!viewerEl) return;
|
||||
await viewerEl.updateFraming();
|
||||
viewerEl.zoom(3);
|
||||
}
|
||||
|
||||
|
||||
async function downloadSceneGlb() {
|
||||
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
|
||||
if (!viewerEl) return;
|
||||
const glTF = await viewerEl.exportScene({onlyVisible: true, binary: true});
|
||||
const file = new File([glTF], "export.glb");
|
||||
const link = document.createElement("a");
|
||||
link.download = file.name;
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.click();
|
||||
}
|
||||
|
||||
async function openGithub() {
|
||||
window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank')
|
||||
}
|
||||
|
||||
|
||||
// Add keyboard shortcuts
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'p') toggleProjection();
|
||||
else if (event.key === 'c') centerCamera();
|
||||
else if (event.key === 'd') downloadSceneGlb();
|
||||
else if (event.key === 'g') openGithub();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<orientation-gizmo :scene="props.viewer.scene as any" :elem="props.viewer.elem" v-if="props.viewer?.scene"/>
|
||||
<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>
|
||||
</v-btn>
|
||||
<v-btn icon @click="centerCamera">
|
||||
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiCrosshairsGps"/>
|
||||
</v-btn>
|
||||
<v-divider/>
|
||||
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
|
||||
<selection-component :ref="selectionComp as any" :viewer="props.viewer as any" v-model="selection"
|
||||
@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"/>
|
||||
</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"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card>
|
||||
<v-toolbar>
|
||||
<v-toolbar-title>Licenses</v-toolbar-title>
|
||||
<v-spacer>
|
||||
</v-spacer>
|
||||
<v-btn icon @click="isActive.value = false">
|
||||
<svg-icon type="mdi" :path="mdiClose"/>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
<licenses-dialog-content/>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
</v-dialog>
|
||||
<v-btn icon @click="openGithub">
|
||||
<v-tooltip activator="parent">Open (G)itHub</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiGithub"/>
|
||||
</v-btn>
|
||||
<div ref="statsHolder"></div>
|
||||
</template>
|
||||
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style>
|
||||
.icon-detail {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 0;
|
||||
font-size: xx-small;
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.icon-detail + svg {
|
||||
position: relative;
|
||||
top: 5px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user