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:
97
frontend/App.vue
Normal file
97
frontend/App.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<!--suppress SillyAssignmentJS -->
|
||||
<script setup lang="ts">
|
||||
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef} from "vue";
|
||||
import Sidebar from "./misc/Sidebar.vue";
|
||||
import Loading from "./misc/Loading.vue";
|
||||
import Tools from "./tools/Tools.vue";
|
||||
import Models from "./models/Models.vue";
|
||||
import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs";
|
||||
import {settings} from "./misc/settings";
|
||||
import {NetworkManager, NetworkUpdateEvent} from "./misc/network";
|
||||
import {SceneMgr} from "./misc/scene";
|
||||
import {Document} from "@gltf-transform/core";
|
||||
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
|
||||
import {mdiPlus} from '@mdi/js'
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
|
||||
// NOTE: The ModelViewer library is big (THREE.js), so we split it and import it asynchronously
|
||||
const ModelViewerWrapper = defineAsyncComponent({
|
||||
loader: () => import('./viewer/ModelViewerWrapper.vue'),
|
||||
loadingComponent: Loading,
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > 1200);
|
||||
|
||||
const sceneUrl = ref("")
|
||||
const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
|
||||
const sceneDocument = shallowRef(new Document());
|
||||
provide('sceneDocument', {sceneDocument});
|
||||
const models: Ref<InstanceType<typeof Models> | null> = ref(null)
|
||||
const disableTap = ref(false);
|
||||
const setDisableTap = (val: boolean) => disableTap.value = val;
|
||||
provide('disableTap', {disableTap, setDisableTap});
|
||||
|
||||
async function onModelLoadRequest(model: NetworkUpdateEvent) {
|
||||
sceneDocument.value = await SceneMgr.loadModel(sceneUrl, sceneDocument.value, model.name, model.url);
|
||||
triggerRef(sceneDocument); // Why not triggered automatically?
|
||||
}
|
||||
|
||||
async function onModelRemoveRequest(name: string) {
|
||||
sceneDocument.value = await SceneMgr.removeModel(sceneUrl, sceneDocument.value, name);
|
||||
triggerRef(sceneDocument); // Why not triggered automatically?
|
||||
}
|
||||
|
||||
// Set up the load model event listener
|
||||
let networkMgr = new NetworkManager();
|
||||
networkMgr.addEventListener('update', (e) => onModelLoadRequest(e as NetworkUpdateEvent));
|
||||
// Start loading all configured models ASAP
|
||||
for (let model of settings.preloadModels) {
|
||||
networkMgr.load(model);
|
||||
}
|
||||
|
||||
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:");
|
||||
if (modelUrl) await networkMgr.load(modelUrl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-layout full-height>
|
||||
|
||||
<!-- The main content of the app is the model-viewer with the SVG "2D" overlay -->
|
||||
<v-main id="main">
|
||||
<model-viewer-wrapper ref="viewer" :src="sceneUrl"/>
|
||||
</v-main>
|
||||
|
||||
<!-- The left collapsible sidebar has the list of models -->
|
||||
<sidebar :opened-init="openSidebarsByDefault" side="left" :width="300">
|
||||
<template #toolbar>
|
||||
<v-toolbar-title>Models</v-toolbar-title>
|
||||
</template>
|
||||
<template #toolbar-items>
|
||||
<v-btn icon="" @click="loadModelManual">
|
||||
<svg-icon type="mdi" :path="mdiPlus"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
|
||||
</sidebar>
|
||||
|
||||
<!-- The right collapsible sidebar has the list of tools -->
|
||||
<sidebar :opened-init="openSidebarsByDefault" side="right" :width="48 * 3 /* buttons */ + 1 /* border? */">
|
||||
<template #toolbar>
|
||||
<v-toolbar-title>Tools</v-toolbar-title>
|
||||
</template>
|
||||
<tools :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
|
||||
</sidebar>
|
||||
|
||||
</v-layout>
|
||||
</template>
|
||||
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
17
frontend/index.ts
Normal file
17
frontend/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import {createApp} from 'vue'
|
||||
import App from './App.vue'
|
||||
|
||||
import {createVuetify} from 'vuetify';
|
||||
import * as directives from 'vuetify/lib/directives/index.mjs';
|
||||
import 'vuetify/dist/vuetify.css';
|
||||
|
||||
const vuetify = createVuetify({
|
||||
directives,
|
||||
theme: {
|
||||
defaultTheme: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light',
|
||||
}
|
||||
});
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(vuetify)
|
||||
app.mount('body')
|
||||
13
frontend/misc/Loading.vue
Normal file
13
frontend/misc/Loading.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import {VContainer, VRow, VCol, VProgressCircular} from "vuetify/lib/components/index.mjs";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container>
|
||||
<v-row justify="center" style="height: 100%">
|
||||
<v-col align-self="center">
|
||||
<v-progress-circular indeterminate style="display: block; margin: 0 auto;"/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</template>
|
||||
54
frontend/misc/Sidebar.vue
Normal file
54
frontend/misc/Sidebar.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import {ref} from "vue";
|
||||
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
|
||||
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
|
||||
const props = defineProps<{
|
||||
openedInit: Boolean,
|
||||
side: "left" | "right",
|
||||
width: number
|
||||
}>();
|
||||
|
||||
let opened = ref(props.openedInit.valueOf());
|
||||
const openIcon = props.side === 'left' ? mdiChevronRight : mdiChevronLeft;
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn icon @click="opened = !opened" class="open-button" :class="side">
|
||||
<svg-icon type="mdi" :path="openIcon"/>
|
||||
</v-btn>
|
||||
<v-navigation-drawer v-model="opened" permanent :location="side" :width="props.width">
|
||||
<v-toolbar density="compact">
|
||||
<v-toolbar-items v-if="side == 'right'">
|
||||
<slot name="toolbar-items"></slot>
|
||||
<v-btn icon @click="opened = !opened">
|
||||
<svg-icon type="mdi" :path="mdiClose"/>
|
||||
</v-btn>
|
||||
</v-toolbar-items>
|
||||
<slot name="toolbar"></slot>
|
||||
<v-toolbar-items v-if="side == 'left'">
|
||||
<slot name="toolbar-items"></slot>
|
||||
<v-btn icon @click="opened = !opened">
|
||||
<svg-icon type="mdi" :path="mdiClose"/>
|
||||
</v-btn>
|
||||
</v-toolbar-items>
|
||||
</v-toolbar>
|
||||
<slot/>
|
||||
</v-navigation-drawer>
|
||||
</template>
|
||||
|
||||
<!--suppress CssUnusedSymbol -->
|
||||
<style scoped>
|
||||
.open-button {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
/*z-index: 1;*/
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.open-button.right {
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
77
frontend/misc/distances.ts
Normal file
77
frontend/misc/distances.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three';
|
||||
import type {MObject3D} from "../tools/Selection.vue";
|
||||
import type { ModelScene } from '@google/model-viewer/lib/three-components/ModelScene';
|
||||
|
||||
|
||||
function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
|
||||
center: Vector3,
|
||||
vertices: Array<Vector3>
|
||||
} {
|
||||
obj.updateMatrixWorld();
|
||||
let pos: BufferAttribute | InterleavedBufferAttribute = obj.geometry.getAttribute('position');
|
||||
let ind: BufferAttribute | null = obj.geometry.index;
|
||||
if (!ind) {
|
||||
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
ind.array[i] = i;
|
||||
}
|
||||
}
|
||||
let center = new Vector3();
|
||||
let vertices = [];
|
||||
for (let i = 0; i < ind.count; i++) {
|
||||
let index = ind.array[i];
|
||||
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index));
|
||||
vertex = scene.target.worldToLocal(obj.localToWorld(vertex));
|
||||
center.add(vertex);
|
||||
vertices.push(vertex);
|
||||
}
|
||||
center = center.divideScalar(ind.count);
|
||||
console.log("center", center)
|
||||
return {center, vertices};
|
||||
}
|
||||
|
||||
/**
|
||||
* Given two THREE.Object3D objects, returns their closest and farthest vertices, and the geometric centers.
|
||||
* All of them are approximated and should not be used for precise calculations.
|
||||
*/
|
||||
export function distances(a: MObject3D, b: MObject3D, scene: ModelScene): {
|
||||
min: Array<Vector3>,
|
||||
center: Array<Vector3>,
|
||||
max: Array<Vector3>
|
||||
} {
|
||||
// Simplify this problem (approximate) by using the distance between each of their vertices.
|
||||
// Find the center of each object.
|
||||
let {center: aCenter, vertices: aVertices} = getCenterAndVertexList(a, scene);
|
||||
let {center: bCenter, vertices: bVertices} = getCenterAndVertexList(b, scene);
|
||||
|
||||
// Find the closest and farthest vertices.
|
||||
// TODO: Compute actual min and max distances between the two objects.
|
||||
// FIXME: Working for points and lines, but not triangles...
|
||||
// FIXME: Really slow... (use a BVH or something)
|
||||
let minDistance = Infinity;
|
||||
let minDistanceVertices = [new Vector3(), new Vector3()];
|
||||
let maxDistance = -Infinity;
|
||||
let maxDistanceVertices = [new Vector3(), new Vector3()];
|
||||
for (let i = 0; i < aVertices.length; i++) {
|
||||
for (let j = 0; j < bVertices.length; j++) {
|
||||
let distance = aVertices[i].distanceTo(bVertices[j]);
|
||||
if (distance < minDistance) {
|
||||
minDistance = distance;
|
||||
minDistanceVertices[0] = aVertices[i];
|
||||
minDistanceVertices[1] = bVertices[j];
|
||||
}
|
||||
if (distance > maxDistance) {
|
||||
maxDistance = distance;
|
||||
maxDistanceVertices[0] = aVertices[i];
|
||||
maxDistanceVertices[1] = bVertices[j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return the results.
|
||||
return {
|
||||
min: minDistanceVertices,
|
||||
center: [aCenter, bCenter],
|
||||
max: maxDistanceVertices
|
||||
};
|
||||
}
|
||||
82
frontend/misc/gltf.ts
Normal file
82
frontend/misc/gltf.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import {Document, Scene, type Transform, WebIO, Buffer} from "@gltf-transform/core";
|
||||
import {unpartition} from "@gltf-transform/functions";
|
||||
|
||||
let io = new WebIO();
|
||||
export let extrasNameKey = "__yacv_name";
|
||||
export let extrasNameValueHelpers = "__helpers";
|
||||
|
||||
/**
|
||||
* Loads a GLB model from a URL and adds it to the document or replaces it if the names match.
|
||||
*
|
||||
* It can replace previous models in the document if the provided name matches the name of a previous model.
|
||||
*
|
||||
* Remember to call mergeFinalize after all models have been merged (slower required operations).
|
||||
*/
|
||||
export async function mergePartial(url: string, name: string, document: Document): Promise<Document> {
|
||||
// Load the new document
|
||||
let newDoc = await io.read(url);
|
||||
|
||||
// Remove any previous model with the same name
|
||||
await document.transform(dropByName(name));
|
||||
|
||||
// Ensure consistent names
|
||||
// noinspection TypeScriptValidateJSTypes
|
||||
await newDoc.transform(setNames(name));
|
||||
|
||||
// Merge the new document into the current one
|
||||
return document.merge(newDoc);
|
||||
}
|
||||
|
||||
export async function mergeFinalize(document: Document): Promise<Document> {
|
||||
// Single scene & buffer required before loading & rendering
|
||||
return await document.transform(mergeScenes(), unpartition());
|
||||
}
|
||||
|
||||
export async function toBuffer(doc: Document): Promise<Uint8Array> {
|
||||
return io.writeBinary(doc);
|
||||
}
|
||||
|
||||
export async function removeModel(name: string, document: Document): Promise<Document> {
|
||||
return await document.transform(dropByName(name));
|
||||
}
|
||||
|
||||
/** Given a parsed GLTF document and a name, it forces the names of all elements to be identified by the name (or derivatives) */
|
||||
function setNames(name: string): Transform {
|
||||
return (doc: Document) => {
|
||||
// Do this automatically for all elements changing any name
|
||||
for (let elem of doc.getGraph().listEdges().map(e => e.getChild())) {
|
||||
if (!elem.getExtras()) elem.setExtras({});
|
||||
elem.getExtras()[extrasNameKey] = name;
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensures that all elements with the given name are removed from the document */
|
||||
function dropByName(name: string): Transform {
|
||||
return (doc: Document) => {
|
||||
for (let elem of doc.getGraph().listEdges().map(e => e.getChild())) {
|
||||
if (elem.getExtras() == null || elem instanceof Scene || elem instanceof Buffer) continue;
|
||||
if ((elem.getExtras()[extrasNameKey]?.toString() ?? "") == name) {
|
||||
elem.dispose();
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
/** Merges all scenes in the document into a single default scene */
|
||||
function mergeScenes(): Transform {
|
||||
return (doc: Document) => {
|
||||
let root = doc.getRoot();
|
||||
let scene = root.getDefaultScene() ?? root.listScenes()[0];
|
||||
for (let dropScene of root.listScenes()) {
|
||||
if (dropScene === scene) continue;
|
||||
for (let node of dropScene.listChildren()) {
|
||||
scene.addChild(node);
|
||||
}
|
||||
dropScene.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
118
frontend/misc/helpers.ts
Normal file
118
frontend/misc/helpers.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import {Document, type TypedArray} from '@gltf-transform/core'
|
||||
import {Vector2} from "three/src/math/Vector2.js"
|
||||
import {Vector3} from "three/src/math/Vector3.js"
|
||||
import {Matrix4} from "three/src/math/Matrix4.js"
|
||||
|
||||
|
||||
/** Exports the colors used for the axes, primary and secondary. They match the orientation gizmo. */
|
||||
export const AxesColors = {
|
||||
x: [[247, 60, 60], [148, 36, 36]],
|
||||
z: [[108, 203, 38], [65, 122, 23]],
|
||||
y: [[23, 140, 240], [14, 84, 144]]
|
||||
}
|
||||
|
||||
function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: number[], rawColors: number[] | null, transform: Matrix4, name: string = '__helper', mode: number = WebGL2RenderingContext.LINES) {
|
||||
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer(name + 'Buffer')
|
||||
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0] ?? doc.createScene(name + 'Scene')
|
||||
const positions = doc.createAccessor(name + 'Position')
|
||||
.setArray(new Float32Array(rawPositions) as TypedArray)
|
||||
.setType('VEC3')
|
||||
.setBuffer(buffer)
|
||||
const indices = doc.createAccessor(name + 'Indices')
|
||||
.setArray(new Uint32Array(rawIndices) as TypedArray)
|
||||
.setType('SCALAR')
|
||||
.setBuffer(buffer)
|
||||
let colors = null;
|
||||
if (rawColors) {
|
||||
colors = doc.createAccessor(name + 'Color')
|
||||
.setArray(new Float32Array(rawColors) as TypedArray)
|
||||
.setType('VEC3')
|
||||
.setBuffer(buffer);
|
||||
}
|
||||
const material = doc.createMaterial(name + 'Material')
|
||||
.setAlphaMode('OPAQUE')
|
||||
const geometry = doc.createPrimitive()
|
||||
.setIndices(indices)
|
||||
.setAttribute('POSITION', positions)
|
||||
.setMode(mode as any)
|
||||
.setMaterial(material)
|
||||
if (rawColors) {
|
||||
geometry.setAttribute('COLOR_0', colors)
|
||||
}
|
||||
const mesh = doc.createMesh(name + 'Mesh').addPrimitive(geometry)
|
||||
const node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any)
|
||||
scene.addChild(node)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Axes helper as a GLTF model, useful for debugging positions and orientations.
|
||||
*/
|
||||
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
|
||||
let rawPositions = [
|
||||
[0, 0, 0, size.x, 0, 0],
|
||||
[0, 0, 0, 0, size.y, 0],
|
||||
[0, 0, 0, 0, 0, -size.z],
|
||||
];
|
||||
let rawIndices = [0, 1];
|
||||
let rawColors = [
|
||||
[...(AxesColors.x[0]), ...(AxesColors.x[1])],
|
||||
[...(AxesColors.y[0]), ...(AxesColors.y[1])],
|
||||
[...(AxesColors.z[0]), ...(AxesColors.z[1])],
|
||||
].map(g => g.map(x => x / 255.0));
|
||||
buildSimpleGltf(doc, rawPositions[0], rawIndices, rawColors[0], transform, '__helper_axes');
|
||||
buildSimpleGltf(doc, rawPositions[1], rawIndices, rawColors[1], transform, '__helper_axes');
|
||||
buildSimpleGltf(doc, rawPositions[2], rawIndices, rawColors[2], transform, '__helper_axes');
|
||||
buildSimpleGltf(doc, [0, 0, 0], [0], null, transform, '__helper_axes', WebGL2RenderingContext.POINTS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new Grid helper as a GLTF model, useful for debugging sizes with an OrthographicCamera.
|
||||
*
|
||||
* The grid is built as a box of triangles (representing lines) looking to the inside of the box.
|
||||
* This ensures that only the back of the grid is always visible, regardless of the camera position.
|
||||
*/
|
||||
export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4 = new Matrix4(), divisions = 10) {
|
||||
// Create transformed positions for the inner faces of the box
|
||||
for (let axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
|
||||
for (let positive of [1, -1]) {
|
||||
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive));
|
||||
let translation = new Matrix4().makeTranslation(offset.x, offset.y, offset.z)
|
||||
let rotation = new Matrix4().lookAt(new Vector3(), offset, new Vector3(0, 1, 0))
|
||||
let size2 = new Vector2();
|
||||
if (axis.x) size2.set(size.z, size.y);
|
||||
if (axis.y) size2.set(size.x, size.z);
|
||||
if (axis.z) size2.set(size.x, size.y);
|
||||
let transform = baseTransform.clone().multiply(translation).multiply(rotation);
|
||||
newGridPlane(doc, size2, transform, divisions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function newGridPlane(doc: Document, size: Vector2, transform: Matrix4 = new Matrix4(), divisions = 10, divisionWidth = 0.002) {
|
||||
const rawPositions = [];
|
||||
const rawIndices = [];
|
||||
// Build the grid as triangles
|
||||
for (let i = 0; i <= divisions; i++) {
|
||||
const x = -size.x / 2 + size.x * i / divisions;
|
||||
const y = -size.y / 2 + size.y * i / divisions;
|
||||
|
||||
// Vertical quad (two triangles)
|
||||
rawPositions.push(x - divisionWidth * size.x / 2, -size.y / 2, 0);
|
||||
rawPositions.push(x + divisionWidth * size.x / 2, -size.y / 2, 0);
|
||||
rawPositions.push(x + divisionWidth * size.x / 2, size.y / 2, 0);
|
||||
rawPositions.push(x - divisionWidth * size.x / 2, size.y / 2, 0);
|
||||
const baseIndex = i * 4;
|
||||
rawIndices.push(baseIndex, baseIndex + 1, baseIndex + 2);
|
||||
rawIndices.push(baseIndex, baseIndex + 2, baseIndex + 3);
|
||||
|
||||
// Horizontal quad (two triangles)
|
||||
rawPositions.push(-size.x / 2, y - divisionWidth * size.y / 2, 0);
|
||||
rawPositions.push(size.x / 2, y - divisionWidth * size.y / 2, 0);
|
||||
rawPositions.push(size.x / 2, y + divisionWidth * size.y / 2, 0);
|
||||
rawPositions.push(-size.x / 2, y + divisionWidth * size.y / 2, 0);
|
||||
const baseIndex2 = (divisions + 1 + i) * 4;
|
||||
rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2);
|
||||
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3);
|
||||
}
|
||||
buildSimpleGltf(doc, rawPositions, rawIndices, null, transform, '__helper_grid', WebGL2RenderingContext.TRIANGLES);
|
||||
}
|
||||
65
frontend/misc/network.ts
Normal file
65
frontend/misc/network.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {settings} from "./settings";
|
||||
|
||||
export class NetworkUpdateEvent extends Event {
|
||||
name: string;
|
||||
url: string;
|
||||
|
||||
constructor(name: string, url: string) {
|
||||
super("update");
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Listens for updates and emits events when a model changes */
|
||||
export class NetworkManager extends EventTarget {
|
||||
private knownObjectHashes: { [name: string]: string | null } = {};
|
||||
|
||||
/**
|
||||
* Tries to load a new model (.glb) from the given URL.
|
||||
*
|
||||
* If the URL uses the websocket protocol (ws:// or wss://), the server will be continuously monitored for changes.
|
||||
* In this case, it will only trigger updates if the name or hash of any model changes.
|
||||
*
|
||||
* Updates will be emitted as "update" events, including the download URL and the model name.
|
||||
*/
|
||||
async load(url: string) {
|
||||
if (url.startsWith("ws://") || url.startsWith("wss://")) {
|
||||
this.monitorWebSocket(url);
|
||||
} else {
|
||||
// Get the last part of the URL as the "name" of the model
|
||||
let name = url.split("/").pop();
|
||||
name = name?.split(".")[0] || `unknown-${Math.random()}`;
|
||||
// Use a head request to get the hash of the file
|
||||
let response = await fetch(url, {method: "HEAD"});
|
||||
let hash = response.headers.get("etag");
|
||||
// Only trigger an update if the hash has changed
|
||||
this.foundModel(name, hash, url);
|
||||
}
|
||||
}
|
||||
|
||||
private monitorWebSocket(url: string) {
|
||||
// WARNING: This will spam the console logs with failed requests when the server is down
|
||||
let ws = new WebSocket(url);
|
||||
ws.onmessage = (event) => {
|
||||
let data = JSON.parse(event.data);
|
||||
console.debug("WebSocket message", data);
|
||||
let urlObj = new URL(url);
|
||||
urlObj.protocol = urlObj.protocol === "ws:" ? "http:" : "https:";
|
||||
urlObj.searchParams.set("api_object", data.name);
|
||||
this.foundModel(data.name, data.hash, urlObj.toString());
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onclose = () => setTimeout(() => this.monitorWebSocket(url), settings.monitorEveryMs);
|
||||
let timeoutFaster = setTimeout(() => ws.close(), settings.monitorOpenTimeoutMs);
|
||||
ws.onopen = () => clearTimeout(timeoutFaster);
|
||||
}
|
||||
|
||||
private foundModel(name: string, hash: string | null, url: string) {
|
||||
let prevHash = this.knownObjectHashes[name];
|
||||
if (!hash || hash !== prevHash) {
|
||||
this.knownObjectHashes[name] = hash;
|
||||
this.dispatchEvent(new NetworkUpdateEvent(name, url));
|
||||
}
|
||||
}
|
||||
}
|
||||
97
frontend/misc/scene.ts
Normal file
97
frontend/misc/scene.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {type Ref} from 'vue';
|
||||
import {Document} from '@gltf-transform/core';
|
||||
import {extrasNameKey, extrasNameValueHelpers, mergeFinalize, mergePartial, removeModel, toBuffer} from "./gltf";
|
||||
import {newAxes, newGridBox} from "./helpers";
|
||||
import {Vector3} from "three/src/math/Vector3.js"
|
||||
import {Box3} from "three/src/math/Box3.js"
|
||||
import {Matrix4} from "three/src/math/Matrix4.js"
|
||||
|
||||
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
|
||||
export class SceneMgr {
|
||||
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
|
||||
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string): Promise<Document> {
|
||||
let loadStart = performance.now();
|
||||
|
||||
// Start merging into the current document, replacing or adding as needed
|
||||
document = await mergePartial(url, name, document);
|
||||
|
||||
console.log("Model", name, "loaded in", performance.now() - loadStart, "ms");
|
||||
|
||||
if (name !== extrasNameValueHelpers) {
|
||||
// Reload the helpers to fit the new model
|
||||
// TODO: Only reload the helpers after a few milliseconds of no more models being added/removed
|
||||
document = await this.reloadHelpers(sceneUrl, document);
|
||||
} else {
|
||||
// Display the final fully loaded model
|
||||
let displayStart = performance.now();
|
||||
document = await this.showCurrentDoc(sceneUrl, document);
|
||||
console.log("Scene displayed in", performance.now() - displayStart, "ms");
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
||||
let bb = SceneMgr.getBoundingBox(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);
|
||||
}
|
||||
|
||||
static getBoundingBox(document: Document): Box3 {
|
||||
// Get bounding box of the model and use it to set the size of the helpers
|
||||
let bbMin: number[] = [1e6, 1e6, 1e6];
|
||||
let bbMax: number[] = [-1e6, -1e6, -1e6];
|
||||
document.getRoot().listNodes().forEach(node => {
|
||||
if ((node.getExtras()[extrasNameKey] ?? extrasNameValueHelpers) === extrasNameValueHelpers) return;
|
||||
let transform = new Matrix4(...node.getWorldMatrix());
|
||||
for (let prim of node.getMesh()?.listPrimitives() ?? []) {
|
||||
let accessor = prim.getAttribute('POSITION');
|
||||
if (!accessor) continue;
|
||||
let objMin = new Vector3(...accessor.getMin([0, 0, 0]))
|
||||
.applyMatrix4(transform);
|
||||
let objMax = new Vector3(...accessor.getMax([0, 0, 0]))
|
||||
.applyMatrix4(transform);
|
||||
bbMin = bbMin.map((v, i) => Math.min(v, objMin.getComponent(i)));
|
||||
bbMax = bbMax.map((v, i) => Math.max(v, objMax.getComponent(i)));
|
||||
}
|
||||
});
|
||||
let bbSize = new Vector3().fromArray(bbMax).sub(new Vector3().fromArray(bbMin));
|
||||
let bbCenter = new Vector3().fromArray(bbMin).add(bbSize.clone().multiplyScalar(0.5));
|
||||
return new Box3().setFromCenterAndSize(bbCenter, bbSize);
|
||||
}
|
||||
|
||||
/** Removes a model from the viewer */
|
||||
static async removeModel(sceneUrl: Ref<string>, document: Document, name: string): Promise<Document> {
|
||||
let loadStart = performance.now();
|
||||
|
||||
// Remove the model from the document
|
||||
document = await removeModel(name, document)
|
||||
|
||||
console.log("Model", name, "removed in", performance.now() - loadStart, "ms");
|
||||
|
||||
// Reload the helpers to fit the new model (will also show the document)
|
||||
document = await this.reloadHelpers(sceneUrl, document);
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/** Serializes the current document into a GLB and updates the viewerSrc */
|
||||
private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
||||
// Make sure the document is fully loaded and ready to be shown
|
||||
document = await mergeFinalize(document);
|
||||
|
||||
// Serialize the document into a GLB and update the viewerSrc
|
||||
let buffer = await toBuffer(document);
|
||||
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
|
||||
console.debug("Showing current doc", document, "as", Array.from(buffer));
|
||||
sceneUrl.value = URL.createObjectURL(blob);
|
||||
|
||||
return document;
|
||||
}
|
||||
}
|
||||
58
frontend/misc/settings.ts
Normal file
58
frontend/misc/settings.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
// These are the default values for the settings, which are overridden below
|
||||
export const settings = {
|
||||
preloadModels: [
|
||||
// @ts-ignore
|
||||
// new URL('../../assets/fox.glb', import.meta.url).href,
|
||||
// @ts-ignore
|
||||
// new URL('../../assets/logo_build/base.glb', import.meta.url).href,
|
||||
// @ts-ignore
|
||||
// new URL('../../assets/logo_build/location.glb', import.meta.url).href,
|
||||
// @ts-ignore
|
||||
// new URL('../../assets/logo_build/img.jpg.glb', import.meta.url).href,
|
||||
// Websocket URLs automatically listen for new models from the python backend
|
||||
"ws://127.0.0.1:32323/"
|
||||
],
|
||||
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
|
||||
monitorEveryMs: 100,
|
||||
monitorOpenTimeoutMs: 10000,
|
||||
// ModelViewer settings
|
||||
autoplay: true,
|
||||
arModes: 'webxr scene-viewer quick-look',
|
||||
exposure: 1,
|
||||
shadowIntensity: 0,
|
||||
background: '',
|
||||
}
|
||||
|
||||
const firstTimeNames: Array<string> = []; // Needed for array values, which clear the array when overridden
|
||||
function parseSetting(name: string, value: string): any {
|
||||
let arrayElem = name.endsWith(".0")
|
||||
if (arrayElem) name = name.slice(0, -2);
|
||||
let prevValue = (settings as any)[name];
|
||||
if (prevValue === undefined) throw new Error(`Unknown setting: ${name}`);
|
||||
if (Array.isArray(prevValue) && !arrayElem) {
|
||||
let toExtend = []
|
||||
if (!firstTimeNames.includes(name)) {
|
||||
firstTimeNames.push(name);
|
||||
} else {
|
||||
toExtend = prevValue;
|
||||
}
|
||||
toExtend.push(parseSetting(name + ".0", value));
|
||||
return toExtend;
|
||||
}
|
||||
switch (typeof prevValue) {
|
||||
case 'boolean':
|
||||
return value === 'true';
|
||||
case 'number':
|
||||
return Number(value);
|
||||
case 'string':
|
||||
return value;
|
||||
default:
|
||||
throw new Error(`Unknown setting type: ${typeof prevValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-override any settings from the URL
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (key in settings) (settings as any)[key] = parseSetting(key, value);
|
||||
})
|
||||
361
frontend/models/Model.vue
Normal file
361
frontend/models/Model.vue
Normal file
@@ -0,0 +1,361 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
VBtn,
|
||||
VBtnToggle,
|
||||
VCheckboxBtn,
|
||||
VDivider,
|
||||
VExpansionPanel,
|
||||
VExpansionPanelText,
|
||||
VExpansionPanelTitle,
|
||||
VSlider,
|
||||
VSpacer,
|
||||
VTooltip,
|
||||
} from "vuetify/lib/components/index.mjs";
|
||||
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
|
||||
import {Document, Mesh} from "@gltf-transform/core";
|
||||
import {inject, ref, type ShallowRef, watch} from "vue";
|
||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||
import {
|
||||
mdiCircleOpacity,
|
||||
mdiCube,
|
||||
mdiDelete,
|
||||
mdiRectangle,
|
||||
mdiRectangleOutline,
|
||||
mdiSwapHorizontal,
|
||||
mdiVectorRectangle
|
||||
} from '@mdi/js'
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
import {SceneMgr} from "../misc/scene";
|
||||
import {BackSide, FrontSide} from "three/src/constants.js";
|
||||
import {Box3} from "three/src/math/Box3.js";
|
||||
import {Color} from "three/src/math/Color.js";
|
||||
import {Plane} from "three/src/math/Plane.js";
|
||||
import {Vector3} from "three/src/math/Vector3.js";
|
||||
import type {MObject3D} from "../tools/Selection.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
meshes: Array<Mesh>,
|
||||
viewer: InstanceType<typeof ModelViewerWrapper> | null
|
||||
}>();
|
||||
const emit = defineEmits<{ remove: [] }>()
|
||||
|
||||
let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah blah blag blah blah blah"
|
||||
|
||||
// Reactive properties
|
||||
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
|
||||
const opacity = defineModel<number>("opacity", {default: 1});
|
||||
const clipPlaneX = ref(1);
|
||||
const clipPlaneSwappedX = ref(false);
|
||||
const clipPlaneY = ref(1);
|
||||
const clipPlaneSwappedY = ref(false);
|
||||
const clipPlaneZ = ref(1);
|
||||
const clipPlaneSwappedZ = ref(false);
|
||||
|
||||
// Count the number of faces, edges and vertices
|
||||
let faceCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES).length).reduce((a, b) => a + b, 0)
|
||||
let edgeCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]).length).reduce((a, b) => a + b, 0)
|
||||
let vertexCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS).length).reduce((a, b) => a + b, 0)
|
||||
|
||||
// Set initial defaults for the enabled features
|
||||
if (faceCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
|
||||
if (edgeCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
|
||||
if (vertexCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
|
||||
|
||||
// Listeners for changes in the properties (or viewer reloads)
|
||||
function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
||||
//console.log('Enabled features may have changed', newEnabledFeatures)
|
||||
let scene = props.viewer?.scene;
|
||||
let sceneModel = (scene as any)?._model;
|
||||
if (!scene || !sceneModel) return;
|
||||
// Iterate all primitives of the mesh and set their visibility based on the enabled features
|
||||
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
|
||||
// of not actually removing the primitives from the scene graph
|
||||
sceneModel.traverse((child: MObject3D) => {
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
|
||||
let childIsEdge = child.type == 'Line' || child.type == 'LineSegments'
|
||||
let childIsVertex = child.type == 'Points'
|
||||
if (childIsFace || childIsEdge || childIsVertex) {
|
||||
let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1);
|
||||
if (child.visible !== visible) {
|
||||
child.visible = visible;
|
||||
if (child.userData.backChild) child.userData.backChild.visible = visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
scene.queueRender()
|
||||
}
|
||||
|
||||
watch(enabledFeatures, onEnabledFeaturesChange);
|
||||
|
||||
function onOpacityChange(newOpacity: number) {
|
||||
let scene = props.viewer?.scene;
|
||||
let sceneModel = (scene as any)?._model;
|
||||
if (!scene || !sceneModel) return;
|
||||
// Iterate all primitives of the mesh and set their opacity based on the enabled features
|
||||
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
|
||||
// of not actually removing the primitives from the scene graph
|
||||
// console.log('Opacity may have changed', newOpacity)
|
||||
sceneModel.traverse((child: MObject3D) => {
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
if (child.material && child.material.opacity !== newOpacity) {
|
||||
child.material.transparent = newOpacity < 1;
|
||||
child.material.opacity = newOpacity;
|
||||
child.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
scene.queueRender()
|
||||
}
|
||||
|
||||
watch(opacity, onOpacityChange);
|
||||
|
||||
let {sceneDocument} = inject<{ sceneDocument: ShallowRef<Document> }>('sceneDocument')!!;
|
||||
|
||||
function onClipPlanesChange() {
|
||||
let scene = props.viewer?.scene;
|
||||
let sceneModel = (scene as any)?._model;
|
||||
if (!scene || !sceneModel) return;
|
||||
let enabledX = clipPlaneX.value < 1 && !clipPlaneSwappedX.value || clipPlaneX.value > 0 && clipPlaneSwappedX.value;
|
||||
let enabledY = clipPlaneY.value < 1 && !clipPlaneSwappedY.value || clipPlaneY.value > 0 && clipPlaneSwappedY.value;
|
||||
let enabledZ = clipPlaneZ.value < 1 && !clipPlaneSwappedZ.value || clipPlaneZ.value > 0 && clipPlaneSwappedZ.value;
|
||||
// let enabled = [enabledX, enabledY, enabledZ];
|
||||
let bbox: Box3;
|
||||
if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) {
|
||||
// Global value for all models, once set it cannot be unset (unknown for other models...)
|
||||
props.viewer.renderer.threeRenderer.localClippingEnabled = true;
|
||||
// Due to model-viewer's camera manipulation, the bounding box needs to be transformed
|
||||
bbox = SceneMgr.getBoundingBox(sceneDocument.value);
|
||||
bbox.translate(scene.getTarget());
|
||||
}
|
||||
sceneModel.traverse((child: MObject3D) => {
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
if (child.material) {
|
||||
if (bbox) {
|
||||
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
|
||||
let offsetY = bbox.min.z + clipPlaneY.value * (bbox.max.z - bbox.min.z);
|
||||
let offsetZ = bbox.min.y + clipPlaneZ.value * (bbox.max.y - bbox.min.y);
|
||||
let planes = [
|
||||
new Plane(new Vector3(-1, 0, 0), offsetX),
|
||||
new Plane(new Vector3(0, 0, 1), offsetY),
|
||||
new Plane(new Vector3(0, -1, 0), offsetZ),
|
||||
];
|
||||
if (clipPlaneSwappedX.value) planes[0].negate();
|
||||
if (clipPlaneSwappedY.value) planes[1].negate();
|
||||
if (clipPlaneSwappedZ.value) planes[2].negate();
|
||||
if (!enabledZ) planes.pop();
|
||||
if (!enabledY) planes.splice(1, 1);
|
||||
if (!enabledX) planes.shift();
|
||||
child.material.clippingPlanes = planes;
|
||||
if (child.userData.backChild && child.userData.backChild.material) child.userData.backChild.material.clippingPlanes = planes;
|
||||
} else {
|
||||
child.material.clippingPlanes = [];
|
||||
if (child.userData.backChild && child.userData.backChild.material) child.userData.backChild.material.clippingPlanes = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
scene.queueRender()
|
||||
}
|
||||
|
||||
watch(clipPlaneX, onClipPlanesChange);
|
||||
watch(clipPlaneY, onClipPlanesChange);
|
||||
watch(clipPlaneZ, onClipPlanesChange);
|
||||
watch(clipPlaneSwappedX, onClipPlanesChange);
|
||||
watch(clipPlaneSwappedY, 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))
|
||||
|
||||
function onModelLoad() {
|
||||
let scene = props.viewer?.scene;
|
||||
let sceneModel = (scene as any)?._model;
|
||||
if (!scene || !sceneModel) return;
|
||||
// Iterate all primitives of the mesh and set their visibility based on the enabled features
|
||||
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
|
||||
// of not actually removing the primitives from the scene graph
|
||||
let childrenToAdd: Array<MObject3D> = [];
|
||||
sceneModel.traverse((child: MObject3D) => {
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
if (child.type == 'Mesh' || child.type == 'SkinnedMesh') {
|
||||
// We could implement cutting planes using the stencil buffer:
|
||||
// https://threejs.org/examples/?q=clipping#webgl_clipping_stencil
|
||||
// But this is buggy for lots of models, so instead we just draw
|
||||
// back faces with a different material.
|
||||
child.material.side = FrontSide;
|
||||
|
||||
if (modelName !== extrasNameValueHelpers) {
|
||||
// The back of the material only writes to the stencil buffer the areas
|
||||
// that should be covered by the plane, but does not render anything
|
||||
let backChild = child.clone();
|
||||
backChild.material = child.material.clone();
|
||||
backChild.material.side = BackSide;
|
||||
backChild.material.color = new Color(0.25, 0.25, 0.25)
|
||||
child.userData.backChild = backChild;
|
||||
childrenToAdd.push(backChild as MObject3D);
|
||||
}
|
||||
}
|
||||
// if (child.type == 'Line' || child.type == 'LineSegments') {
|
||||
// child.material.linewidth = 3; // Not supported in WebGL2
|
||||
// If wide lines are really needed, we need https://threejs.org/examples/?q=line#webgl_lines_fat
|
||||
// }
|
||||
if (child.type == 'Points') {
|
||||
(child.material as any).size = 5;
|
||||
child.material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child));
|
||||
scene.queueRender()
|
||||
|
||||
// Furthermore...
|
||||
// Enabled features may have been reset after a reload
|
||||
onEnabledFeaturesChange(enabledFeatures.value)
|
||||
// Opacity may have been reset after a reload
|
||||
onOpacityChange(opacity.value)
|
||||
// Clip planes may have been reset after a reload
|
||||
onClipPlanesChange()
|
||||
}
|
||||
|
||||
// props.viewer.elem may not yet be available, so we need to wait for it
|
||||
props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-expansion-panel :value="modelName">
|
||||
<v-expansion-panel-title expand-icon="hide-this-icon" collapse-icon="hide-this-icon">
|
||||
<v-btn-toggle v-model="enabledFeatures" multiple @click.stop color="surface-light">
|
||||
<v-btn icon>
|
||||
<v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiRectangle" :rotate="90"></svg-icon>
|
||||
</v-btn>
|
||||
<v-btn icon>
|
||||
<v-tooltip activator="parent">Toggle Edges ({{ edgeCount }})</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiRectangleOutline" :rotate="90"></svg-icon>
|
||||
</v-btn>
|
||||
<v-btn icon>
|
||||
<v-tooltip activator="parent">Toggle Vertices ({{ vertexCount }})</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiVectorRectangle" :rotate="90"></svg-icon>
|
||||
</v-btn>
|
||||
</v-btn-toggle>
|
||||
<div class="model-name">{{ modelName }}</div>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon @click.stop="emit('remove')">
|
||||
<v-tooltip activator="parent">Remove</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiDelete"></svg-icon>
|
||||
</v-btn>
|
||||
</v-expansion-panel-title>
|
||||
<v-expansion-panel-text>
|
||||
<v-slider v-model="opacity" hide-details min="0" max="1" :step="0.1">
|
||||
<template v-slot:prepend>
|
||||
<v-tooltip activator="parent">Change opacity</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiCircleOpacity"></svg-icon>
|
||||
</template>
|
||||
</v-slider>
|
||||
<v-divider></v-divider>
|
||||
<v-slider v-model="clipPlaneX" hide-details min="0" max="1">
|
||||
<template v-slot:prepend>
|
||||
<v-tooltip activator="parent">Clip plane X</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiCube" :rotate="120"></svg-icon>
|
||||
X
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-tooltip activator="parent">Swap clip plane X</v-tooltip>
|
||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
||||
v-model="clipPlaneSwappedX">
|
||||
<template v-slot:label>
|
||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
</v-slider>
|
||||
<v-slider v-model="clipPlaneY" hide-details min="0" max="1">
|
||||
<template v-slot:prepend>
|
||||
<v-tooltip activator="parent">Clip plane Y</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiCube" :rotate="-120"></svg-icon>
|
||||
Y
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
|
||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
||||
v-model="clipPlaneSwappedY">
|
||||
<template v-slot:label>
|
||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
</v-slider>
|
||||
<v-slider v-model="clipPlaneZ" hide-details min="0" max="1">
|
||||
<template v-slot:prepend>
|
||||
<v-tooltip activator="parent">Clip plane Z</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiCube"></svg-icon>
|
||||
Z
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
|
||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
||||
v-model="clipPlaneSwappedZ">
|
||||
<template v-slot:label>
|
||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
</v-slider>
|
||||
</v-expansion-panel-text>
|
||||
</v-expansion-panel>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Fix bug in hidden expansion panel text next to active expansion panel */
|
||||
.v-expansion-panel-title--active + .v-expansion-panel-text {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
/* More compact accordions */
|
||||
.v-expansion-panel {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.v-expansion-panel-title {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.v-expansion-panel-title > .v-btn-toggle {
|
||||
margin: 0;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
--v-btn-height: 16px;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
width: 130px;
|
||||
min-height: 1.15em; /* HACK: Avoid eating the bottom of the text when using 1 line */
|
||||
max-height: 2em;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2; /* https://caniuse.com/?search=line-clamp */
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.v-expansion-panel-text__wrapper {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.hide-this-icon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mdi-checkbox-blank-outline { /* HACK: mdi is not fully imported, only required icons... */
|
||||
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z"/></svg>');
|
||||
}
|
||||
|
||||
.mdi-checkbox-marked-outline { /* HACK: mdi is not fully imported, only required icons... */
|
||||
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,19H5V5H15V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V11H19M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"/></svg>');
|
||||
}
|
||||
</style>
|
||||
66
frontend/models/Models.vue
Normal file
66
frontend/models/Models.vue
Normal file
@@ -0,0 +1,66 @@
|
||||
<script setup lang="ts">
|
||||
import {VExpansionPanels} from "vuetify/lib/components/index.mjs";
|
||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||
import {Document, Mesh} from "@gltf-transform/core";
|
||||
import {extrasNameKey} from "../misc/gltf";
|
||||
import Model from "./Model.vue";
|
||||
import {inject, ref, type Ref} from "vue";
|
||||
|
||||
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||
const emit = defineEmits<{ remove: [string] }>()
|
||||
|
||||
let {sceneDocument} = inject<{ sceneDocument: Ref<Document> }>('sceneDocument')!!;
|
||||
|
||||
let expandedNames = ref<Array<string>>([]);
|
||||
|
||||
function meshesList(sceneDocument: Document): Array<Array<Mesh>> {
|
||||
// Grouped by shared name
|
||||
return sceneDocument.getRoot().listMeshes().reduce((acc, mesh) => {
|
||||
let name = mesh.getExtras()[extrasNameKey]?.toString() ?? 'Unnamed';
|
||||
let group = acc.find((group) => meshName(group[0]) === name);
|
||||
if (group) {
|
||||
group.push(mesh);
|
||||
} else {
|
||||
acc.push([mesh]);
|
||||
}
|
||||
return acc;
|
||||
}, [] as Array<Array<Mesh>>);
|
||||
}
|
||||
|
||||
function meshName(mesh: Mesh) {
|
||||
return mesh.getExtras()[extrasNameKey]?.toString() ?? 'Unnamed';
|
||||
}
|
||||
|
||||
function onRemove(mesh: Mesh) {
|
||||
emit('remove', meshName(mesh))
|
||||
}
|
||||
|
||||
function findModel(name: string) {
|
||||
console.log('Find model', name);
|
||||
if (!expandedNames.value.includes(name)) expandedNames.value.push(name);
|
||||
console.log('Expanded', expandedNames.value);
|
||||
}
|
||||
|
||||
defineExpose({findModel})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-expansion-panels v-for="meshes in meshesList(sceneDocument)" :key="meshName(meshes[0])"
|
||||
v-model="expandedNames" multiple>
|
||||
<model :meshes="meshes" :viewer="props.viewer" @remove="onRemove(meshes[0])"/>
|
||||
</v-expansion-panels>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
.mdi-chevron-down, .mdi-menu-down { /* HACK: mdi is not fully imported, only required icons... */
|
||||
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M7 10l5 5 5-5H7z"/></svg>');
|
||||
}
|
||||
|
||||
.mdi-chevron-up, .mdi-menu-up { /* HACK: mdi is not fully imported, only required icons... */
|
||||
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M7 14l5-5 5 5H7z"/></svg>');
|
||||
}
|
||||
|
||||
.v-overlay--active > .v-overlay__content {
|
||||
display: block !important; /* HACK: Fix buggy tooltips not showing? */
|
||||
}
|
||||
</style>
|
||||
3
frontend/shims.d.ts
vendored
Normal file
3
frontend/shims.d.ts
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
// Avoids typescript error when importing some files
|
||||
declare module '@jamescoyle/vue-icon'
|
||||
declare module 'three-orientation-gizmo/src/OrientationGizmo'
|
||||
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>
|
||||
205
frontend/viewer/ModelViewerWrapper.vue
Normal file
205
frontend/viewer/ModelViewerWrapper.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {settings} from "../misc/settings";
|
||||
import {onMounted, inject, type Ref} from "vue";
|
||||
import {$scene, $renderer} from "@google/model-viewer/lib/model-viewer-base";
|
||||
import Loading from "../misc/Loading.vue";
|
||||
import {ref, watch} from "vue";
|
||||
import {ModelViewerElement} from '@google/model-viewer';
|
||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
|
||||
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
|
||||
import type {Vector3} from "three";
|
||||
|
||||
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
||||
|
||||
const emit = defineEmits<{ load: [] }>()
|
||||
|
||||
const props = defineProps<{ src: string }>();
|
||||
|
||||
const elem = ref<ModelViewerElement | null>(null);
|
||||
const scene = ref<ModelScene | null>(null);
|
||||
const renderer = ref<Renderer | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
if (!elem.value) return;
|
||||
elem.value.addEventListener('load', async () => {
|
||||
if (!elem.value) return;
|
||||
// Delete the initial load banner
|
||||
let banner = elem.value.querySelector('.initial-load-banner');
|
||||
if (banner) banner.remove();
|
||||
// Set the scene and renderer
|
||||
scene.value = elem.value[$scene] as ModelScene;
|
||||
renderer.value = elem.value[$renderer] as Renderer;
|
||||
// Emit the load event
|
||||
emit('load')
|
||||
});
|
||||
elem.value.addEventListener('camera-change', onCameraChange);
|
||||
});
|
||||
|
||||
class Line3DData {
|
||||
startHotspot: HTMLElement = document.body
|
||||
endHotspot: HTMLElement = document.body
|
||||
start2D: [number, number] = [-1000, -1000];
|
||||
end2D: [number, number] = [-1000, -1000];
|
||||
lineAttrs: { [key: string]: string } = {"stroke-width": "2", "stroke": "red"}
|
||||
centerText?: string = undefined;
|
||||
centerTextSize: [number, number] = [0, 0];
|
||||
}
|
||||
|
||||
let nextLineId = 1; // Avoid 0 (falsy!)
|
||||
let lines = ref<{ [id: number]: Line3DData }>({});
|
||||
|
||||
function positionToHotspot(position: Vector3): string {
|
||||
return position.x + ' ' + position.y + ' ' + position.z;
|
||||
}
|
||||
|
||||
function addLine3D(p1: Vector3, p2: Vector3, centerText?: string = undefined, lineAttrs: { [key: string]: string } = {
|
||||
"stroke-width": "2",
|
||||
"stroke": "red",
|
||||
}): number | null {
|
||||
if (!scene.value || !elem.value?.shadowRoot) return null
|
||||
let id = nextLineId++;
|
||||
let hotspotName1 = 'line' + id + '_start';
|
||||
let hotspotName2 = 'line' + id + '_end';
|
||||
scene.value.addHotspot(new Hotspot({name: hotspotName1, position: positionToHotspot(p1)}));
|
||||
scene.value.addHotspot(new Hotspot({name: hotspotName2, position: positionToHotspot(p2)}));
|
||||
lines.value[id] = {
|
||||
startHotspot: elem.value.shadowRoot.querySelector('slot[name="' + hotspotName1 + '"]')!!.parentElement!!,
|
||||
endHotspot: elem.value.shadowRoot.querySelector('slot[name="' + hotspotName2 + '"]')!!.parentElement!!,
|
||||
start2D: [-1000, -1000],
|
||||
end2D: [-1000, -1000],
|
||||
centerText: centerText,
|
||||
centerTextSize: [0, 0],
|
||||
lineAttrs: lineAttrs
|
||||
};
|
||||
scene.value.queueRender() // Needed to update the hotspots
|
||||
requestIdleCallback(() => onCameraChangeLine(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
function removeLine3D(id: number): boolean {
|
||||
if (!scene.value || !(id in lines.value)) return false;
|
||||
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_start'}));
|
||||
lines.value[id].startHotspot.parentElement?.remove()
|
||||
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_end'}));
|
||||
lines.value[id].endHotspot.parentElement?.remove()
|
||||
delete lines.value[id];
|
||||
scene.value.queueRender() // Needed to update the hotspots
|
||||
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);
|
||||
|
||||
function onCameraChangeLine(lineId: number) {
|
||||
if (!(lineId in lines.value) || !(elem.value)) return // Silently ignore (not updated yet)
|
||||
// Update start and end 2D positions
|
||||
let {x: xB, y: yB} = elem.value.getBoundingClientRect();
|
||||
let {x, y} = lines.value[lineId].startHotspot.getBoundingClientRect();
|
||||
lines.value[lineId].start2D = [x - xB, y - yB];
|
||||
let {x: x2, y: y2} = lines.value[lineId].endHotspot.getBoundingClientRect();
|
||||
lines.value[lineId].end2D = [x2 - xB, y2 - yB];
|
||||
|
||||
// Update the center text size if needed
|
||||
if (svg.value && lines.value[lineId].centerText && lines.value[lineId].centerTextSize[0] === 0) {
|
||||
let text = svg.value.getElementsByClassName('line' + lineId + '_text')[0] as SVGTextElement | undefined;
|
||||
if (text) {
|
||||
let bbox = text.getBBox();
|
||||
lines.value[lineId].centerTextSize = [bbox.width, bbox.height];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onElemReady(callback: (elem: ModelViewerElement) => void) {
|
||||
if (elem.value) {
|
||||
callback(elem.value);
|
||||
} else {
|
||||
watch(() => elem.value, (elem) => {
|
||||
if (elem) callback(elem);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function entries(lines: { [id: number]: Line3DData }): [string, Line3DData][] {
|
||||
return Object.entries(lines);
|
||||
}
|
||||
|
||||
defineExpose({elem, onElemReady, scene, renderer, addLine3D, removeLine3D});
|
||||
|
||||
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
|
||||
watch(disableTap, (value) => {
|
||||
// Rerender not auto triggered? This works anyway...
|
||||
if (value) elem.value?.setAttribute('disable-tap', '');
|
||||
else elem.value?.removeAttribute('disable-tap');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The main 3D model viewer -->
|
||||
<model-viewer ref="elem" style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls
|
||||
camera-orbit="30deg 75deg auto" max-camera-orbit="Infinity 180deg auto"
|
||||
min-camera-orbit="-Infinity 0deg 5%" :disable-tap="disableTap" :exposure="settings.exposure"
|
||||
:shadow-intensity="settings.shadowIntensity" interaction-prompt="none" :autoplay="settings.autoplay"
|
||||
:ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :skybox-image="settings.background"
|
||||
:environment-image="settings.background">
|
||||
<slot></slot> <!-- Controls, annotations, etc. -->
|
||||
<loading class="annotation initial-load-banner"></loading>
|
||||
</model-viewer>
|
||||
|
||||
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
||||
<div class="overlay-svg-wrapper">
|
||||
<svg ref="svg" class="overlay-svg" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||
<g v-for="[lineId, line] in entries(lines)" :key="lineId">
|
||||
<line :x1="line.start2D[0]" :y1="line.start2D[1]" :x2="line.end2D[0]"
|
||||
:y2="line.end2D[1]" v-bind="line.lineAttrs"/>
|
||||
<g v-if="line.centerText !== undefined">
|
||||
<rect :x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
||||
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2"
|
||||
:width="line.centerTextSize[0] + 8" :height="line.centerTextSize[1] + 4"
|
||||
fill="gray" fill-opacity="0.75" rx="4" ry="4" stroke="black" v-if="line.centerText"/>
|
||||
<text :x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
||||
text-anchor="middle" dominant-baseline="middle" font-size="16" fill="black"
|
||||
:class="'line' + lineId + '_text'" v-if="line.centerText">
|
||||
{{ line.centerText }}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* This keeps child nodes hidden while the element loads */
|
||||
:not(:defined) > * {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* This is the SVG overlay that will be used for line annotations */
|
||||
.overlay-svg-wrapper {
|
||||
position: relative;
|
||||
top: -100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.overlay-svg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100dvh;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user