mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
513 lines
19 KiB
Vue
513 lines
19 KiB
Vue
<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, Color, 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";
|
|
import {highlight, highlightUndo, hitToSelectionInfo, type SelectionInfo} from "./selection";
|
|
|
|
export type MObject3D = Mesh & {
|
|
userData: { noHit?: boolean },
|
|
material: Material & {
|
|
color: Color,
|
|
wireframe?: boolean
|
|
}
|
|
};
|
|
|
|
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<SelectionInfo>>({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 mouseDownTime = 0;
|
|
let selectFilter = ref('Any (S)');
|
|
const raycaster = new Raycaster();
|
|
|
|
|
|
let mouseDownListener = (event: MouseEvent) => {
|
|
mouseDownAt = [event.clientX, event.clientY];
|
|
mouseDownTime = performance.now();
|
|
if (!selectionEnabled.value) return;
|
|
};
|
|
|
|
let mouseUpListener = (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 || performance.now() - mouseDownTime > 500) {
|
|
return;
|
|
}
|
|
}
|
|
|
|
// If disabled, avoid selection logic
|
|
if (!selectionEnabled.value) {
|
|
return;
|
|
}
|
|
|
|
// Set raycaster parameters
|
|
let paramScale = 1; // Make it easier to select vertices/edges based on camera distance
|
|
if (props.viewer?.scene) {
|
|
let scene = props.viewer.scene;
|
|
let lookAtCenter = scene.getTarget().clone().add(scene.target.position);
|
|
paramScale = scene.camera.position.distanceTo(lookAtCenter) / 150;
|
|
// console.log('paramScale', paramScale)
|
|
}
|
|
if (selectFilter.value === 'Any (S)') {
|
|
raycaster.params.Line.threshold = paramScale;
|
|
raycaster.params.Points.threshold = paramScale * 2; // Make vertices easier to select than edges
|
|
} else if (selectFilter.value === '(E)dges') {
|
|
raycaster.params.Line.threshold = paramScale;
|
|
raycaster.params.Points.threshold = 0.0;
|
|
} else if (selectFilter.value === '(V)ertices') {
|
|
raycaster.params.Line.threshold = 0.0;
|
|
raycaster.params.Points.threshold = paramScale;
|
|
} 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 for off-center clicks
|
|
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 raycast the wanted ones based on the filter
|
|
let objects: Array<any> = [];
|
|
scene.traverse((obj) => {
|
|
const kind = obj.type
|
|
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
|
|
let isEdge = kind === 'Line' || kind === 'LineSegments';
|
|
let isVertex = kind === 'Points';
|
|
if (obj.userData.noHit !== true &&
|
|
((selectFilter.value === 'Any (S)' && (isFace || isEdge || isVertex)) ||
|
|
(selectFilter.value === '(F)aces' && isFace) ||
|
|
(selectFilter.value === '(E)dges' && isEdge) ||
|
|
(selectFilter.value === '(V)ertices' && isVertex))) {
|
|
objects.push(obj);
|
|
}
|
|
});
|
|
//console.log("Raycasting objects", objects)
|
|
|
|
// Run the raycaster on the selected objects only searching for the first hit
|
|
// @ts-ignore
|
|
raycaster.firstHitOnly = true;
|
|
const hits = raycaster.intersectObjects(objects, false);
|
|
let hit = hits
|
|
// Check feasibility
|
|
.filter((hit: Intersection<Object3D>) => {
|
|
if (!hit.object) 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 (!isFace || hit.object.visible) && kindOk;
|
|
})
|
|
// Sort for highlighting partially hidden edges/vertices
|
|
.sort((a, b) => {
|
|
function lowerIsBetter(hit: Intersection<Object3D>) {
|
|
let score = hit.distance;
|
|
// Faces are easier to hit than 0-width edges/vertices, so we need to adjust scores
|
|
if (hit.object.type === 'Mesh' || hit.object.type === 'SkinnedMesh') score += paramScale;
|
|
// Edges are easier to hit than vertices, so we need to adjust scores
|
|
if (hit.object.type === 'Line' || hit.object.type === 'LineSegments') score += paramScale / 2;
|
|
return score;
|
|
}
|
|
|
|
return lowerIsBetter(a) - lowerIsBetter(b);
|
|
})
|
|
// Return the best hit
|
|
[0] as Intersection<MObject3D> | undefined;
|
|
|
|
if (!highlightNextSelection.value[0]) {
|
|
// If we are selecting, toggle the selection or deselect all if no hit
|
|
let selInfo: SelectionInfo | null = null;
|
|
if (hit) selInfo = hitToSelectionInfo(hit);
|
|
//console.log('Hit', hit, 'SelInfo', selInfo);
|
|
if (hit && selInfo !== null) {
|
|
// Toggle selection
|
|
const wasSelected = selected.value.find((m) => m.getKey() === selInfo.getKey()) !== undefined;
|
|
if (wasSelected) {
|
|
deselect(selInfo)
|
|
} else {
|
|
select(selInfo)
|
|
}
|
|
} 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(selInfo: SelectionInfo) {
|
|
// console.log('Selecting', selInfo.object.name)
|
|
if (selected.value.find((m) => m.getKey() === selInfo.getKey()) === undefined) {
|
|
selected.value.push(selInfo);
|
|
}
|
|
highlight(selInfo);
|
|
}
|
|
|
|
function deselect(selInfo: SelectionInfo, alsoRemove = true) {
|
|
// console.log('Deselecting', selInfo.object.name)
|
|
if (alsoRemove) {
|
|
// Remove the matching object from the selection
|
|
let toRemove = selected.value.findIndex((m) => m.getKey() === selInfo.getKey());
|
|
selected.value.splice(toRemove, 1);
|
|
}
|
|
highlightUndo(selInfo);
|
|
}
|
|
|
|
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('mousedown', mouseDownListener); // Avoid clicking when dragging
|
|
elem.addEventListener('mouseup', mouseUpListener);
|
|
elem.addEventListener('load', () => {
|
|
// 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;
|
|
if (!scene) continue;
|
|
let foundObject = null;
|
|
scene.traverse((obj: MObject3D) => {
|
|
if (sel.matches(obj)) {
|
|
foundObject = obj as MObject3D;
|
|
}
|
|
});
|
|
if (foundObject) {
|
|
sel.object = foundObject;
|
|
highlight(sel);
|
|
} else {
|
|
selected.value = selected.value.filter((m) => m.getKey() !== sel.getKey());
|
|
}
|
|
}
|
|
if (firstLoad) {
|
|
toggleShowBoundingBox();
|
|
firstLoad = false;
|
|
}
|
|
});
|
|
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.union(hit.getBox())
|
|
}
|
|
} else {
|
|
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
|
|
if (!boundingBox) return; // No models. Should not happen.
|
|
bb = boundingBox
|
|
}
|
|
// 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 length = to.clone().sub(from).length();
|
|
if (length < 0.05) continue; // Skip very small edges (e.g. a single point)
|
|
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,
|
|
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 {min, center, max} = distances(selected.value[0], selected.value[1], 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;
|
|
}
|
|
|
|
defineExpose({deselect, updateBoundingBox, updateDistances});
|
|
|
|
// 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: -20px;
|
|
}
|
|
|
|
.select-only {
|
|
display: inline-block;
|
|
width: calc(100% - 48px);
|
|
position: relative;
|
|
top: -12px;
|
|
}
|
|
</style> |