lots of selection fixes and nicer tools interface

This commit is contained in:
Yeicor
2024-02-21 21:08:46 +01:00
parent cc5b96877a
commit dc600c3f6c
6 changed files with 115 additions and 33 deletions

3
.gitignore vendored
View File

@@ -10,9 +10,8 @@
# TODO: Figure out which assets to keep in the repo # TODO: Figure out which assets to keep in the repo
/assets/fox.glb /assets/fox.glb
/assets/logo.glbs /assets/fox.glb.license
/assets/logo.glb /assets/logo.glb
/assets/logo.stl
*.iml *.iml
venv/ venv/

View File

@@ -9,7 +9,7 @@ import Models from "./models/Models.vue";
import {VLayout, VMain, VToolbarTitle} from "vuetify/lib/components"; import {VLayout, VMain, VToolbarTitle} from "vuetify/lib/components";
import {settings} from "./misc/settings"; import {settings} from "./misc/settings";
import {NetworkManager, NetworkUpdateEvent} from "./misc/network"; import {NetworkManager, NetworkUpdateEvent} from "./misc/network";
import {SceneManagerData, SceneMgr} from "./misc/scene"; import {SceneMgr} from "./misc/scene";
// NOTE: The ModelViewer library is big (THREE.js), so we split it and import it asynchronously // NOTE: The ModelViewer library is big (THREE.js), so we split it and import it asynchronously
const ModelViewerWrapper = defineAsyncComponent({ const ModelViewerWrapper = defineAsyncComponent({

View File

@@ -3,7 +3,7 @@ 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";
// Optimized minimal dependencies from three to avoid more async imports // 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";

View File

@@ -1,20 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import {defineModel, ref} from "vue"; import {defineModel, ref} from "vue";
import {VBtn} from "vuetify/lib/components"; import {VBtn, VSelect} from "vuetify/lib/components";
import SvgIcon from '@jamescoyle/vue-icon'; import SvgIcon from '@jamescoyle/vue-icon/lib/svg-icon.vue';
import type {ModelViewerElement} from '@google/model-viewer'; import type {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 {mdiCursorDefaultClick} from '@mdi/js'; import {mdiCursorDefaultClick} from '@mdi/js';
import type {Intersection, Material, Object3D} from "three"; import type {Intersection, Material, Object3D} from "three";
let props = defineProps<{ viewer: ModelViewerElement, scene: ModelScene }>(); const {Raycaster} = await import("three");
let selectionEnabled = ref(false);
type MObject3D = Object3D & { export type MObject3D = Object3D & {
userData: { noHit?: boolean },
material: Material & { color: { r: number, g: number, b: number }, __prevBaseColorFactor?: [number, number, number] } material: Material & { color: { r: number, g: number, b: number }, __prevBaseColorFactor?: [number, number, number] }
}; };
let props = defineProps<{ viewer: ModelViewerElement, scene: ModelScene }>();
let selectionEnabled = ref(false);
let selectedMaterials = defineModel<Array<Intersection<MObject3D>>>({default: []}); let selectedMaterials = defineModel<Array<Intersection<MObject3D>>>({default: []});
let hasListener = false; let hasListener = false;
let mouseDownAt: [number, number] | null = null; let mouseDownAt: [number, number] | null = null;
let selectFilter = ref('Faces');
const raycaster = new Raycaster();
let selectionMoveListener = (event: MouseEvent) => { let selectionMoveListener = (event: MouseEvent) => {
if (!selectionEnabled.value) return; if (!selectionEnabled.value) return;
@@ -37,21 +43,36 @@ let selectionListener = (event: MouseEvent) => {
let scene: ModelScene = props.scene; let scene: ModelScene = props.scene;
// NOTE: Need to access internal as the API has issues with small faces surrounded by edges // NOTE: Need to access internal as the API has issues with small faces surrounded by edges
const ndcCoords = scene.getNDC(event.clientX, event.clientY); const ndcCoords = scene.getNDC(event.clientX, event.clientY);
const hit = scene.hitFromPoint(ndcCoords) as Intersection<MObject3D> | undefined; //const hit = scene.hitFromPoint(ndcCoords) as Intersection<MObject3D> | undefined;
console.log(hit) raycaster.setFromCamera(ndcCoords, (scene as any).camera);
// TODO: Multiple hits to differentiate edges and faces if ((scene as any).camera.isOrthographicCamera) {
// TODO: Edge collisions too big? // Need to fix the ray direction for ortho camera
// FIXME: Clicking with ORTHO camera does not work... // FIXME: Still buggy (but less so :)
if (!hit) return; raycaster.ray.direction.copy(
const wasSelected = selectedMaterials.value.find((m) => m.object.name === hit.object.name) !== undefined; scene.getTarget().clone().add(scene.target.position).sub((scene as any).camera.position).normalize());
if (wasSelected) { }
deselect(hit) console.log('NDC', ndcCoords, 'Camera', (scene as any).camera, 'Ray', raycaster.ray);
const hits = raycaster.intersectObject(scene, true);
console.log(hits)
let hit = hits.find((hit) => {
let isFace = hit.faceIndex !== null;
return hit.object.visible && !hit.object.userData.noHit && isFace == (selectFilter.value === 'Faces');
}) as Intersection<MObject3D> | undefined;
if (!hit) {
deselectAll();
} else { } else {
select(hit) // Toggle selection
const wasSelected = selectedMaterials.value.find((m) => m.object.name === hit.object.name) !== undefined;
if (wasSelected) {
deselect(hit)
} else {
select(hit)
}
} }
}; };
function select(hit: Intersection<MObject3D>) { function select(hit: Intersection<MObject3D>) {
console.log('Selecting', hit.object.name)
if (selectedMaterials.value.find((m) => m.object.name === hit.object.name) === undefined) { if (selectedMaterials.value.find((m) => m.object.name === hit.object.name) === undefined) {
selectedMaterials.value.push(hit); selectedMaterials.value.push(hit);
} }
@@ -66,13 +87,26 @@ function select(hit: Intersection<MObject3D>) {
} }
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) { function deselect(hit: Intersection<MObject3D>, alsoRemove = true) {
if (alsoRemove) selectedMaterials.value = selectedMaterials.value.filter((m) => m.object.name !== hit.object.name); console.log('Deselecting', hit.object.name)
if (alsoRemove) {
// Remove the matching object from the selection
let toRemove = selectedMaterials.value.findIndex((m) => m.object.name === hit.object.name);
selectedMaterials.value.splice(toRemove, 1);
}
hit.object.material.color.r = hit.object.material.__prevBaseColorFactor[0] hit.object.material.color.r = hit.object.material.__prevBaseColorFactor[0]
hit.object.material.color.g = hit.object.material.__prevBaseColorFactor[1] hit.object.material.color.g = hit.object.material.__prevBaseColorFactor[1]
hit.object.material.color.b = hit.object.material.__prevBaseColorFactor[2] hit.object.material.color.b = hit.object.material.__prevBaseColorFactor[2]
delete hit.object.material.__prevBaseColorFactor; delete hit.object.material.__prevBaseColorFactor;
} }
function deselectAll(alsoRemove = true) {
// Clear selection (shallow copy to avoid modifying the array while iterating)
let toClear = selectedMaterials.value.slice();
for (let material of toClear) {
deselect(material, alsoRemove);
}
}
function toggleSelection() { function toggleSelection() {
let viewer: ModelViewerElement = props.viewer; let viewer: ModelViewerElement = props.viewer;
if (!viewer) return; if (!viewer) return;
@@ -87,15 +121,35 @@ function toggleSelection() {
select(material); select(material);
} }
} else { } else {
for (let material of selectedMaterials.value) { deselectAll(false);
deselect(material, false);
}
} }
} }
</script> </script>
<template> <template>
<v-btn icon="" @click="toggleSelection" :variant="selectionEnabled ? 'tonal' : 'elevated'"> <div class="select-parent">
<svg-icon type="mdi" :path="mdiCursorDefaultClick"/> <v-btn icon="" @click="toggleSelection" :variant="selectionEnabled ? 'tonal' : 'elevated'">
</v-btn> <svg-icon type="mdi" :path="mdiCursorDefaultClick"/>
</template> </v-btn>
<v-select class="select-only" variant="underlined" :items="['Faces', 'Edges']" v-model="selectFilter"/>
</div>
</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>

View File

@@ -1,20 +1,25 @@
<script setup lang="ts"> <script setup lang="ts">
import {VBtn} from "vuetify/lib/components"; import {VBtn, VDivider} from "vuetify/lib/components";
import {ref} from "vue"; import {Ref, ref, Suspense} from "vue";
import OrientationGizmo from "./OrientationGizmo.vue"; import OrientationGizmo from "./OrientationGizmo.vue";
import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera";
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera"; import {OrthographicCamera} from "three/src/cameras/OrthographicCamera";
import {mdiCrosshairsGps, mdiDownload, mdiProjector} from '@mdi/js' import {mdiCrosshairsGps, mdiDownload, mdiGithub, mdiProjector} from '@mdi/js'
import SvgIcon from '@jamescoyle/vue-icon'; import SvgIcon from '@jamescoyle/vue-icon/lib/svg-icon.vue';
import {SceneMgrRefData} from "../misc/scene"; import {SceneMgrRefData} from "../misc/scene";
import type {ModelViewerElement} from '@google/model-viewer'; import type {ModelViewerElement} from '@google/model-viewer';
import type {Intersection} from "three";
import type {MObject3D} from "./Selection.vue";
import Selection from "./Selection.vue"; import Selection from "./Selection.vue";
let props = defineProps<{ refSData: SceneMgrRefData }>(); let props = defineProps<{ refSData: SceneMgrRefData }>();
let selection: Ref<Array<Intersection<typeof MObject3D>>> = ref([]);
function syncOrthoCamera(force: boolean) { function syncOrthoCamera(force: boolean) {
let scene = props.refSData.viewerScene; let scene = props.refSData.viewerScene;
if (!scene) return; if (!scene) return;
let perspectiveCam = (scene as any).__perspectiveCamera; let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera;
if (force || perspectiveCam && scene.camera != perspectiveCam) { if (force || perspectiveCam && scene.camera != perspectiveCam) {
// Get zoom level from perspective camera // Get zoom level from perspective camera
let dist = scene.getTarget().distanceToSquared(perspectiveCam.position); let dist = scene.getTarget().distanceToSquared(perspectiveCam.position);
@@ -62,20 +67,38 @@ async function downloadSceneGlb() {
link.click(); link.click();
} }
async function openGithub() {
window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank')
}
</script> </script>
<template> <template>
<orientation-gizmo :scene="props.refSData.viewerScene" v-if="props.refSData.viewerScene !== null"/> <orientation-gizmo :scene="props.refSData.viewerScene" v-if="props.refSData.viewerScene !== null"/>
<v-divider/>
<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>
<svg-icon type="mdi" :path="mdiProjector"></svg-icon> <svg-icon type="mdi" :path="mdiProjector"></svg-icon>
</v-btn> </v-btn>
<v-btn icon="" @click="centerCamera"> <v-btn icon="" @click="centerCamera">
<svg-icon type="mdi" :path="mdiCrosshairsGps"/> <svg-icon type="mdi" :path="mdiCrosshairsGps"/>
</v-btn> </v-btn>
<selection :viewer="props.refSData.viewer" :scene="props.refSData.viewerScene"/> <v-divider/>
<h5>Selection ({{ selection.filter((s) => s.face).length }}F {{ selection.filter((s) => !s.face).length }}E)</h5>
<Suspense>
<selection :viewer="props.refSData.viewer" :scene="props.refSData.viewerScene" v-model="selection"/>
<template #fallback>Loading...</template>
</Suspense>
<v-divider/>
<h5>Extras</h5>
<v-btn icon="" @click="downloadSceneGlb"> <v-btn icon="" @click="downloadSceneGlb">
<svg-icon type="mdi" :path="mdiDownload"/> <svg-icon type="mdi" :path="mdiDownload"/>
</v-btn> </v-btn>
<v-btn icon="" @click="openGithub">
<svg-icon type="mdi" :path="mdiGithub"/>
</v-btn>
<!-- TODO: Licenses button -->
<!-- TODO: Tooltips for ALL tools -->
</template> </template>
<!--suppress CssUnusedSymbol --> <!--suppress CssUnusedSymbol -->

View File

@@ -21,6 +21,11 @@ let viewer = ref<ModelViewerElement | null>(null);
onMounted(() => { onMounted(() => {
viewer.value.addEventListener('load', () => { viewer.value.addEventListener('load', () => {
if (viewer.value) { if (viewer.value) {
// Delete the initial load banner
// TODO: Replace with an actual poster?
let banner = viewer.value.querySelector('.initial-load-banner');
if (banner) banner.remove();
// Emit the load event
emit('load', { emit('load', {
viewer: viewer.value, viewer: viewer.value,
scene: viewer.value[$scene] as ModelScene, scene: viewer.value[$scene] as ModelScene,
@@ -39,6 +44,7 @@ onMounted(() => {
:autoplay="settings.autoplay" :ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :autoplay="settings.autoplay" :ar="settings.arModes.length > 0" :ar-modes="settings.arModes"
:skybox-image="settings.background" :environment-image="settings.background"> :skybox-image="settings.background" :environment-image="settings.background">
<slot></slot> <!-- Controls, annotations, etc. --> <slot></slot> <!-- Controls, annotations, etc. -->
<div class="annotation initial-load-banner">Loading models...</div>
</model-viewer> </model-viewer>
</template> </template>