mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
lots of selection fixes and nicer tools interface
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -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/
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user