mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
refactor most of the frontend and add permissive cors to backend
This commit is contained in:
16
poetry.lock
generated
16
poetry.lock
generated
@@ -96,6 +96,20 @@ yarl = ">=1.0,<2.0"
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
speedups = ["Brotli", "aiodns", "brotlicffi"]
|
speedups = ["Brotli", "aiodns", "brotlicffi"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiohttp-cors"
|
||||||
|
version = "0.7.0"
|
||||||
|
description = "CORS support for aiohttp"
|
||||||
|
optional = false
|
||||||
|
python-versions = "*"
|
||||||
|
files = [
|
||||||
|
{file = "aiohttp-cors-0.7.0.tar.gz", hash = "sha256:4d39c6d7100fd9764ed1caf8cebf0eb01bf5e3f24e2e073fda6234bc48b19f5d"},
|
||||||
|
{file = "aiohttp_cors-0.7.0-py3-none-any.whl", hash = "sha256:0451ba59fdf6909d0e2cd21e4c0a43752bc0703d33fc78ae94d9d9321710193e"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
aiohttp = ">=1.1"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aiohttp-devtools"
|
name = "aiohttp-devtools"
|
||||||
version = "1.1.2"
|
version = "1.1.2"
|
||||||
@@ -1597,4 +1611,4 @@ multidict = ">=4.0"
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.0"
|
||||||
python-versions = "^3.9"
|
python-versions = "^3.9"
|
||||||
content-hash = "950e4ebfaa15d8cc389403d0f931a63b2c2685aa1afbf3624e5806152acd6c83"
|
content-hash = "92566c9fc24a286eea553f28b583fe6de53d7ecb595be508a0ce4ae9ca9f58c6"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ ocp-tessellate = "^2.0.6"
|
|||||||
|
|
||||||
# Web
|
# Web
|
||||||
aiohttp = "^3.9.3"
|
aiohttp = "^3.9.3"
|
||||||
|
aiohttp-cors = "^0.7.0"
|
||||||
aiohttp-devtools = "^1.1.2"
|
aiohttp-devtools = "^1.1.2"
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
|
|||||||
22
src/App.vue
22
src/App.vue
@@ -3,13 +3,14 @@
|
|||||||
import {defineAsyncComponent, ref, Ref} from "vue";
|
import {defineAsyncComponent, ref, Ref} from "vue";
|
||||||
import Sidebar from "./misc/Sidebar.vue";
|
import Sidebar from "./misc/Sidebar.vue";
|
||||||
import Loading from "./misc/Loading.vue";
|
import Loading from "./misc/Loading.vue";
|
||||||
import ModelViewerOverlay from "./viewer/ModelViewerOverlay.vue";
|
|
||||||
import Tools from "./tools/Tools.vue";
|
import Tools from "./tools/Tools.vue";
|
||||||
import Models from "./models/Models.vue";
|
import Models from "./models/Models.vue";
|
||||||
import {VLayout, VMain, VToolbarTitle, VTooltip, VBtn} 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 {SceneMgr} from "./misc/scene";
|
import {SceneMgr} from "./misc/scene";
|
||||||
|
import {Document} from "@gltf-transform/core";
|
||||||
|
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
|
||||||
|
|
||||||
// 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({
|
||||||
@@ -20,12 +21,14 @@ const ModelViewerWrapper = defineAsyncComponent({
|
|||||||
|
|
||||||
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > 1200);
|
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > 1200);
|
||||||
|
|
||||||
let [refSData, sData] = SceneMgr.newData();
|
let sceneUrl = ref("")
|
||||||
|
let viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
|
||||||
|
let document = new Document();
|
||||||
|
|
||||||
// Set up the load model event listener
|
// Set up the load model event listener
|
||||||
let networkMgr = new NetworkManager();
|
let networkMgr = new NetworkManager();
|
||||||
networkMgr.addEventListener('update', (model: NetworkUpdateEvent) => {
|
networkMgr.addEventListener('update', async (model: NetworkUpdateEvent) => {
|
||||||
SceneMgr.loadModel(refSData, sData, model.name, model.url);
|
document = await SceneMgr.loadModel(sceneUrl, document, model.name, model.url);
|
||||||
});
|
});
|
||||||
// Start loading all configured models ASAP
|
// Start loading all configured models ASAP
|
||||||
for (let model of settings.preloadModels) {
|
for (let model of settings.preloadModels) {
|
||||||
@@ -38,8 +41,7 @@ for (let model of settings.preloadModels) {
|
|||||||
|
|
||||||
<!-- The main content of the app is the model-viewer with the SVG "2D" overlay -->
|
<!-- The main content of the app is the model-viewer with the SVG "2D" overlay -->
|
||||||
<v-main id="main">
|
<v-main id="main">
|
||||||
<model-viewer-wrapper :src="refSData.viewerSrc" @load="(i) => SceneMgr.onload(refSData, i)"/>
|
<model-viewer-wrapper ref="viewer" :src="sceneUrl"/>
|
||||||
<model-viewer-overlay v-if="refSData.viewer !== null"/>
|
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
<!-- The left collapsible sidebar has the list of models -->
|
<!-- The left collapsible sidebar has the list of models -->
|
||||||
@@ -47,15 +49,15 @@ for (let model of settings.preloadModels) {
|
|||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<v-toolbar-title>Models</v-toolbar-title>
|
<v-toolbar-title>Models</v-toolbar-title>
|
||||||
</template>
|
</template>
|
||||||
<models/>
|
<models :viewer="viewer"/>
|
||||||
</sidebar>
|
</sidebar>
|
||||||
|
|
||||||
<!-- The right collapsible sidebar has the list of tools -->
|
<!-- The right collapsible sidebar has the list of tools -->
|
||||||
<sidebar :opened-init="openSidebarsByDefault" side="right" :width="48 * 3 /* buttons */ + 1">
|
<sidebar :opened-init="openSidebarsByDefault" side="right" :width="48 * 3 /* buttons */ + 1 /* border? */">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<v-toolbar-title>Tools</v-toolbar-title>
|
<v-toolbar-title>Tools</v-toolbar-title>
|
||||||
</template>
|
</template>
|
||||||
<tools :ref-s-data="refSData"/>
|
<tools :viewer="viewer"/>
|
||||||
</sidebar>
|
</sidebar>
|
||||||
|
|
||||||
</v-layout>
|
</v-layout>
|
||||||
|
|||||||
@@ -4,15 +4,15 @@ import {unpartition} from "@gltf-transform/functions";
|
|||||||
let io = new WebIO();
|
let io = new WebIO();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given the bytes of a GLB file and a parsed GLTF document, it parses and merges the GLB into the document.
|
* 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.
|
* 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).
|
* Remember to call mergeFinalize after all models have been merged (slower required operations).
|
||||||
*/
|
*/
|
||||||
export async function mergePartial(glb: Uint8Array, name: string, document: Document): Promise<Document> {
|
export async function mergePartial(url: string, name: string, document: Document): Promise<Document> {
|
||||||
// Load the new document
|
// Load the new document
|
||||||
let newDoc = await io.readBinary(glb);
|
let newDoc = await io.read(url);
|
||||||
|
|
||||||
// Remove any previous model with the same name and ensure consistent names
|
// Remove any previous model with the same name and ensure consistent names
|
||||||
// noinspection TypeScriptValidateJSTypes
|
// noinspection TypeScriptValidateJSTypes
|
||||||
@@ -16,7 +16,7 @@ export class NetworkManager extends EventTarget {
|
|||||||
private knownObjectHashes: { [name: string]: string } = {};
|
private knownObjectHashes: { [name: string]: string } = {};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries to load a new model (.glb or .glbs) from the given URL.
|
* 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.
|
* 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.
|
* In this case, it will only trigger updates if the name or hash of any model changes.
|
||||||
@@ -56,7 +56,7 @@ export class NetworkManager extends EventTarget {
|
|||||||
|
|
||||||
private foundModel(name: string, hash: string, url: string) {
|
private foundModel(name: string, hash: string, url: string) {
|
||||||
let prevHash = this.knownObjectHashes[name];
|
let prevHash = this.knownObjectHashes[name];
|
||||||
if (hash !== prevHash) {
|
if (!hash || hash !== prevHash) {
|
||||||
this.knownObjectHashes[name] = hash;
|
this.knownObjectHashes[name] = hash;
|
||||||
this.dispatchEvent(new NetworkUpdateEvent(name, url));
|
this.dispatchEvent(new NetworkUpdateEvent(name, url));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,76 +1,37 @@
|
|||||||
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 {Ref, ref} from 'vue';
|
import {Ref} from 'vue';
|
||||||
import {Document} from '@gltf-transform/core';
|
import {Document} from '@gltf-transform/core';
|
||||||
import {ModelViewerInfo} from "./viewer/ModelViewerWrapper.vue";
|
import {mergeFinalize, mergePartial, toBuffer} from "./gltf";
|
||||||
import {mergeFinalize, mergePartial, toBuffer} from "../models/glb/merge";
|
|
||||||
|
|
||||||
export type SceneMgrRefData = {
|
|
||||||
/** When updated, forces the viewer to load a new model replacing the current one */
|
|
||||||
viewerSrc: string | null
|
|
||||||
|
|
||||||
/** The model viewer HTML element with all APIs */
|
|
||||||
viewer: ModelViewerElement | null
|
|
||||||
/** The (hidden) scene of the model viewer */
|
|
||||||
viewerScene: ModelScene | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SceneMgrData = {
|
|
||||||
/** The currently shown document, which must match the viewerSrc. */
|
|
||||||
document: Document
|
|
||||||
}
|
|
||||||
|
|
||||||
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
|
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
|
||||||
export class SceneMgr {
|
export class SceneMgr {
|
||||||
private constructor() {
|
/** 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> {
|
||||||
|
|
||||||
/** Creates a new SceneManagerData object */
|
|
||||||
static newData(): [Ref<SceneMgrRefData>, SceneMgrData] {
|
|
||||||
let refData: any = ref({
|
|
||||||
viewerSrc: null,
|
|
||||||
viewer: null,
|
|
||||||
viewerScene: null,
|
|
||||||
});
|
|
||||||
let data = {
|
|
||||||
document: new Document()
|
|
||||||
};
|
|
||||||
// newVar.value.document.createScene("scene");
|
|
||||||
// this.showCurrentDoc(newVar.value).then(r => console.log("Initial empty model loaded"));
|
|
||||||
return [refData, data];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Loads a GLB/GLBS model from a URL and adds it to the viewer or replaces it if the names match */
|
|
||||||
static async loadModel(refData: Ref<SceneMgrRefData>, data: SceneMgrData, name: string, url: string) {
|
|
||||||
let loadStart = performance.now();
|
let loadStart = performance.now();
|
||||||
|
|
||||||
// Connect to the URL of the model
|
|
||||||
let response = await fetch(url);
|
|
||||||
if (!response.ok) throw new Error("Failed to fetch model: " + response.statusText);
|
|
||||||
|
|
||||||
// Start merging into the current document, replacing or adding as needed
|
// Start merging into the current document, replacing or adding as needed
|
||||||
let glb = new Uint8Array(await response.arrayBuffer());
|
document = await mergePartial(url, name, document);
|
||||||
data.document = await mergePartial(glb, name, data.document);
|
|
||||||
|
|
||||||
// Display the final fully loaded model
|
// Display the final fully loaded model
|
||||||
await this.showCurrentDoc(refData, data);
|
document = await this.showCurrentDoc(sceneUrl, document);
|
||||||
|
|
||||||
console.log("Model", name, "loaded in", performance.now() - loadStart, "ms");
|
console.log("Model", name, "loaded in", performance.now() - loadStart, "ms");
|
||||||
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serializes the current document into a GLB and updates the viewerSrc */
|
/** Serializes the current document into a GLB and updates the viewerSrc */
|
||||||
private static async showCurrentDoc(refData: Ref<SceneMgrRefData>, data: SceneMgrData) {
|
private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
||||||
data.document = await mergeFinalize(data.document);
|
// Make sure the document is fully loaded and ready to be shown
|
||||||
let buffer = await toBuffer(data.document);
|
document = await mergeFinalize(document);
|
||||||
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
|
|
||||||
console.log("Showing current doc", data.document, "as", Array.from(buffer));
|
|
||||||
refData.value.viewerSrc = URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Should be called any model finishes loading successfully (after a viewerSrc update) */
|
// Serialize the document into a GLB and update the viewerSrc
|
||||||
static onload(data: SceneMgrRefData, info: typeof ModelViewerInfo) {
|
let buffer = await toBuffer(document);
|
||||||
console.log("ModelViewer loaded", info);
|
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
|
||||||
data.viewer = info.viewer;
|
//console.log("Showing current doc", document, "as", Array.from(buffer));
|
||||||
data.viewerScene = info.scene;
|
sceneUrl.value = URL.createObjectURL(blob);
|
||||||
|
|
||||||
|
// Return the updated document
|
||||||
|
return document;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -6,7 +6,7 @@ export const settings = {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
new URL('../../assets/logo.glb', import.meta.url).href,
|
new URL('../../assets/logo.glb', import.meta.url).href,
|
||||||
// Websocket URLs automatically listen for new models from the python backend
|
// Websocket URLs automatically listen for new models from the python backend
|
||||||
//"ws://localhost:8080/"
|
// "ws://localhost:32323/"
|
||||||
],
|
],
|
||||||
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
|
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
|
||||||
checkServerEveryMs: 100, /* How often to check for a new server */
|
checkServerEveryMs: 100, /* How often to check for a new server */
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {VExpansionPanel, VExpansionPanels, VExpansionPanelText, VExpansionPanelTitle} from "vuetify/lib/components";
|
import {VExpansionPanel, VExpansionPanels, VExpansionPanelText, VExpansionPanelTitle} from "vuetify/lib/components";
|
||||||
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
|
import Loading from "../misc/Loading.vue";
|
||||||
|
|
||||||
|
let props = defineProps<{ viewer: typeof ModelViewerWrapper | null }>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-expansion-panels>
|
<Loading v-if="!props.viewer"/>
|
||||||
|
<v-expansion-panels v-else>
|
||||||
<v-expansion-panel key="model-id">
|
<v-expansion-panel key="model-id">
|
||||||
<v-expansion-panel-title>? F ? E ? V | Model Name</v-expansion-panel-title>
|
<v-expansion-panel-title>? F ? E ? V | Model Name</v-expansion-panel-title>
|
||||||
<v-expansion-panel-text>Content</v-expansion-panel-text>
|
<v-expansion-panel-text>Content</v-expansion-panel-text>
|
||||||
|
|||||||
@@ -7,19 +7,20 @@ import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelSc
|
|||||||
import {mdiCursorDefaultClick} from '@mdi/js';
|
import {mdiCursorDefaultClick} from '@mdi/js';
|
||||||
import type {Intersection, Material, Object3D} from "three";
|
import type {Intersection, Material, Object3D} from "three";
|
||||||
import {Raycaster} from "three";
|
import {Raycaster} from "three";
|
||||||
|
import type ModelViewerWrapperT from "./ModelViewerWrapper.vue";
|
||||||
|
|
||||||
export type MObject3D = Object3D & {
|
export type MObject3D = Object3D & {
|
||||||
userData: { noHit?: boolean },
|
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 props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
|
||||||
let selectionEnabled = ref(false);
|
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');
|
let selectFilter = ref('Faces');
|
||||||
const raycaster = new Raycaster();
|
const ray_caster = new Raycaster();
|
||||||
|
|
||||||
let selectionMoveListener = (event: MouseEvent) => {
|
let selectionMoveListener = (event: MouseEvent) => {
|
||||||
if (!selectionEnabled.value) return;
|
if (!selectionEnabled.value) return;
|
||||||
@@ -39,19 +40,19 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
mouseDownAt = undefined;
|
mouseDownAt = undefined;
|
||||||
}
|
}
|
||||||
let scene: ModelScene = props.scene;
|
let scene: ModelScene = props.viewer?.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;
|
||||||
raycaster.setFromCamera(ndcCoords, (scene as any).camera);
|
ray_caster.setFromCamera(ndcCoords, (scene as any).camera);
|
||||||
if ((scene as any).camera.isOrthographicCamera) {
|
if ((scene as any).camera.isOrthographicCamera) {
|
||||||
// Need to fix the ray direction for ortho camera
|
// Need to fix the ray direction for ortho camera
|
||||||
// FIXME: Still buggy (but less so :)
|
// FIXME: Still buggy (but less so :)
|
||||||
raycaster.ray.direction.copy(
|
ray_caster.ray.direction.copy(
|
||||||
scene.getTarget().clone().add(scene.target.position).sub((scene as any).camera.position).normalize());
|
scene.getTarget().clone().add(scene.target.position).sub((scene as any).camera.position).normalize());
|
||||||
}
|
}
|
||||||
console.log('NDC', ndcCoords, 'Camera', (scene as any).camera, 'Ray', raycaster.ray);
|
console.log('NDC', ndcCoords, 'Camera', (scene as any).camera, 'Ray', ray_caster.ray);
|
||||||
const hits = raycaster.intersectObject(scene, true);
|
const hits = ray_caster.intersectObject(scene, true);
|
||||||
console.log(hits)
|
console.log(hits)
|
||||||
let hit = hits.find((hit) => {
|
let hit = hits.find((hit) => {
|
||||||
let isFace = hit.faceIndex !== null;
|
let isFace = hit.faceIndex !== null;
|
||||||
@@ -68,6 +69,7 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
select(hit)
|
select(hit)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
scene.queueRender() // Force rerender of model-viewer
|
||||||
};
|
};
|
||||||
|
|
||||||
function select(hit: Intersection<MObject3D>) {
|
function select(hit: Intersection<MObject3D>) {
|
||||||
@@ -107,7 +109,7 @@ function deselectAll(alsoRemove = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toggleSelection() {
|
function toggleSelection() {
|
||||||
let viewer: ModelViewerElement = props.viewer;
|
let viewer: ModelViewerElement = props.viewer?.elem;
|
||||||
if (!viewer) return;
|
if (!viewer) return;
|
||||||
selectionEnabled.value = !selectionEnabled.value;
|
selectionEnabled.value = !selectionEnabled.value;
|
||||||
if (selectionEnabled.value) {
|
if (selectionEnabled.value) {
|
||||||
@@ -122,6 +124,7 @@ function toggleSelection() {
|
|||||||
} else {
|
} else {
|
||||||
deselectAll(false);
|
deselectAll(false);
|
||||||
}
|
}
|
||||||
|
props.viewer.scene.queueRender() // Force rerender of model-viewer
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera";
|
|||||||
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera";
|
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera";
|
||||||
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
||||||
import SvgIcon from '@jamescoyle/vue-icon/lib/svg-icon.vue';
|
import SvgIcon from '@jamescoyle/vue-icon/lib/svg-icon.vue';
|
||||||
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 {Intersection} from "three";
|
||||||
import type {MObject3D} from "./Selection.vue";
|
import type {MObject3D} from "./Selection.vue";
|
||||||
import Loading from "../misc/Loading.vue";
|
import Loading from "../misc/Loading.vue";
|
||||||
|
import type ModelViewerWrapper from "./viewer/ModelViewerWrapper.vue";
|
||||||
|
|
||||||
const SelectionComponent = defineAsyncComponent({
|
const SelectionComponent = defineAsyncComponent({
|
||||||
loader: () => import("./Selection.vue"),
|
loader: () => import("./Selection.vue"),
|
||||||
@@ -35,11 +35,11 @@ const LicensesDialogContent = defineAsyncComponent({
|
|||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
let props = defineProps<{ refSData: SceneMgrRefData }>();
|
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||||
let selection: Ref<Array<Intersection<typeof MObject3D>>> = ref([]);
|
let selection: Ref<Array<Intersection<typeof MObject3D>>> = ref([]);
|
||||||
|
|
||||||
function syncOrthoCamera(force: boolean) {
|
function syncOrthoCamera(force: boolean) {
|
||||||
let scene = props.refSData.viewerScene;
|
let scene = props.viewer?.scene;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera;
|
let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera;
|
||||||
if (force || perspectiveCam && scene.camera != perspectiveCam) {
|
if (force || perspectiveCam && scene.camera != perspectiveCam) {
|
||||||
@@ -50,13 +50,14 @@ function syncOrthoCamera(force: boolean) {
|
|||||||
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
|
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
|
||||||
scene.camera.position.copy(perspectiveCam.position);
|
scene.camera.position.copy(perspectiveCam.position);
|
||||||
scene.camera.lookAt(scene.getTarget().clone().add(scene.target.position));
|
scene.camera.lookAt(scene.getTarget().clone().add(scene.target.position));
|
||||||
|
if (force) props.viewer.scene.queueRender() // Force rerender of model-viewer
|
||||||
requestAnimationFrame(() => syncOrthoCamera(false));
|
requestAnimationFrame(() => syncOrthoCamera(false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
||||||
function toggleProjection() {
|
function toggleProjection() {
|
||||||
let scene = props.refSData.viewerScene;
|
let scene = props.viewer?.scene;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
let prevCam = scene.camera;
|
let prevCam = scene.camera;
|
||||||
let wasPerspectiveCamera = prevCam.isPerspectiveCamera;
|
let wasPerspectiveCamera = prevCam.isPerspectiveCamera;
|
||||||
@@ -67,21 +68,23 @@ function toggleProjection() {
|
|||||||
} else {
|
} else {
|
||||||
// Restore the default perspective camera
|
// Restore the default perspective camera
|
||||||
scene.camera = (scene as any).__perspectiveCamera;
|
scene.camera = (scene as any).__perspectiveCamera;
|
||||||
|
props.viewer.scene.queueRender() // Force rerender of model-viewer
|
||||||
}
|
}
|
||||||
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
||||||
}
|
}
|
||||||
|
|
||||||
function centerCamera() {
|
function centerCamera() {
|
||||||
let viewer: ModelViewerElement = props.refSData.viewer;
|
console.log('Centering camera', props.viewer);
|
||||||
if (!viewer) return;
|
let viewerEl: ModelViewerElement = props.viewer?.elem;
|
||||||
viewer.updateFraming();
|
if (!viewerEl) return;
|
||||||
|
viewerEl.updateFraming();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async function downloadSceneGlb() {
|
async function downloadSceneGlb() {
|
||||||
let viewer = props.refSData.viewer;
|
let viewerEl: ModelViewerElement = props.viewer?.elem;
|
||||||
if (!viewer) return;
|
if (!viewerEl) return;
|
||||||
const glTF = await viewer.exportScene();
|
const glTF = await viewerEl.exportScene();
|
||||||
const file = new File([glTF], "export.glb");
|
const file = new File([glTF], "export.glb");
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.download = file.name;
|
link.download = file.name;
|
||||||
@@ -96,7 +99,7 @@ async function openGithub() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<orientation-gizmo :scene="props.refSData.viewerScene" v-if="props.refSData.viewerScene !== null"/>
|
<orientation-gizmo :scene="props.viewer.scene" v-if="props.viewer?.scene"/>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<h5>Camera</h5>
|
<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>
|
||||||
@@ -111,7 +114,7 @@ async function openGithub() {
|
|||||||
</v-btn>
|
</v-btn>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<h5>Selection ({{ selection.filter((s) => s.face).length }}F {{ selection.filter((s) => !s.face).length }}E ?V)</h5>
|
<h5>Selection ({{ selection.filter((s) => s.face).length }}F {{ selection.filter((s) => !s.face).length }}E ?V)</h5>
|
||||||
<selection-component :viewer="props.refSData.viewer" :scene="props.refSData.viewerScene" v-model="selection"/>
|
<selection-component :viewer="props.viewer" v-model="selection"/>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<h5>Extras</h5>
|
<h5>Extras</h5>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
// TODO: transparent SVG overlay that redirects and grabs some events
|
|
||||||
// https://modelviewer.dev/examples/annotations/index.html
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="overlay-svg-wrapper">
|
|
||||||
<svg class="overlay-svg" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
|
||||||
<!-- <rect x="0" y="0" width="100%" height="100%" fill="transparent"/>-->
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.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>
|
|
||||||
@@ -1,35 +1,41 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
</script>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {settings} from "../misc/settings";
|
import {settings} from "../misc/settings";
|
||||||
import {ModelViewerElement} from '@google/model-viewer';
|
import {onMounted} from "vue";
|
||||||
import {onMounted, ref} from "vue";
|
|
||||||
import {$scene} from "@google/model-viewer/lib/model-viewer-base";
|
import {$scene} from "@google/model-viewer/lib/model-viewer-base";
|
||||||
|
import Loading from "../misc/Loading.vue";
|
||||||
|
import {ref} from "vue";
|
||||||
|
import {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";
|
||||||
|
|
||||||
export type ModelViewerInfo = { viewer: ModelViewerElement, scene: ModelScene };
|
|
||||||
|
|
||||||
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
load: [info: ModelViewerInfo]
|
// noinspection ThisExpressionReferencesGlobalObjectJS
|
||||||
|
load: []
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
src: String
|
src: String
|
||||||
});
|
});
|
||||||
|
|
||||||
let viewer = ref<ModelViewerElement | null>(null);
|
const elem = ref<ModelViewerElement | null>(null);
|
||||||
|
const scene = ref<ModelScene | null>(null);
|
||||||
|
defineExpose({elem, scene});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
viewer.value.addEventListener('load', () => {
|
elem.value.addEventListener('load', () => {
|
||||||
if (viewer.value) {
|
if (elem.value) {
|
||||||
// Delete the initial load banner
|
// Delete the initial load banner
|
||||||
// TODO: Replace with an actual poster?
|
// TODO: Replace with an actual poster?
|
||||||
let banner = viewer.value.querySelector('.initial-load-banner');
|
let banner = elem.value.querySelector('.initial-load-banner');
|
||||||
if (banner) banner.remove();
|
if (banner) banner.remove();
|
||||||
|
// Set the scene
|
||||||
|
scene.value = elem.value[$scene] as ModelScene;
|
||||||
// Emit the load event
|
// Emit the load event
|
||||||
emit('load', {
|
emit('load')
|
||||||
viewer: viewer.value,
|
|
||||||
scene: viewer.value[$scene] as ModelScene,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -37,15 +43,22 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<model-viewer ref="viewer"
|
<model-viewer ref="elem" style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls
|
||||||
style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls camera-orbit="30deg 75deg auto"
|
camera-orbit="30deg 75deg auto" max-camera-orbit="Infinity 180deg auto"
|
||||||
max-camera-orbit="Infinity 180deg auto" min-camera-orbit="-Infinity 0deg 5%" disable-tap
|
min-camera-orbit="-Infinity 0deg 5%" disable-tap :exposure="settings.exposure"
|
||||||
:exposure="settings.exposure" :shadow-intensity="settings.shadowIntensity" interaction-prompt="none"
|
:shadow-intensity="settings.shadowIntensity" interaction-prompt="none" :autoplay="settings.autoplay"
|
||||||
:autoplay="settings.autoplay" :ar="settings.arModes.length > 0" :ar-modes="settings.arModes"
|
:ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :skybox-image="settings.background"
|
||||||
:skybox-image="settings.background" :environment-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>
|
<loading class="annotation initial-load-banner"></loading>
|
||||||
</model-viewer>
|
</model-viewer>
|
||||||
|
<!-- TODO: Transparent SVG overlay that can draw 2D lines attached to the 3D model(s) -->
|
||||||
|
<!-- https://modelviewer.dev/examples/annotations/index.html -->
|
||||||
|
<div class="overlay-svg-wrapper">
|
||||||
|
<svg class="overlay-svg" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<!--<line x1="0" y1="0" x2="100%" y2="100%" stroke="black" stroke-width="2"/>-->
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -53,4 +66,24 @@ onMounted(() => {
|
|||||||
:not(:defined) > * {
|
:not(:defined) > * {
|
||||||
display: none;
|
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>
|
</style>
|
||||||
@@ -9,6 +9,7 @@ from dataclasses import dataclass, field
|
|||||||
from threading import Thread
|
from threading import Thread
|
||||||
from typing import Optional, Dict, Union
|
from typing import Optional, Dict, Union
|
||||||
|
|
||||||
|
import aiohttp_cors
|
||||||
from OCP.TopoDS import TopoDS_Shape
|
from OCP.TopoDS import TopoDS_Shape
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
from build123d import Shape, Axis
|
from build123d import Shape, Axis
|
||||||
@@ -59,6 +60,16 @@ class Server:
|
|||||||
# - Static files from the frontend
|
# - Static files from the frontend
|
||||||
self.app.router.add_get('/{path:(.*/|)}', _index_handler) # Any folder -> index.html
|
self.app.router.add_get('/{path:(.*/|)}', _index_handler) # Any folder -> index.html
|
||||||
self.app.router.add_static('/', path=FRONTEND_BASE_PATH, name='static_frontend')
|
self.app.router.add_static('/', path=FRONTEND_BASE_PATH, name='static_frontend')
|
||||||
|
# --- CORS ---
|
||||||
|
cors = aiohttp_cors.setup(self.app, defaults={
|
||||||
|
"*": aiohttp_cors.ResourceOptions(
|
||||||
|
allow_credentials=True,
|
||||||
|
expose_headers="*",
|
||||||
|
allow_headers="*",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
for route in list(self.app.router.routes()):
|
||||||
|
cors.add(route)
|
||||||
# --- Misc ---
|
# --- Misc ---
|
||||||
self.loop = asyncio.new_event_loop()
|
self.loop = asyncio.new_event_loop()
|
||||||
|
|
||||||
@@ -197,7 +208,7 @@ class Server:
|
|||||||
"""Returns the object file with the matching name, building it if necessary."""
|
"""Returns the object file with the matching name, building it if necessary."""
|
||||||
|
|
||||||
# Export the object (or fail if not found)
|
# Export the object (or fail if not found)
|
||||||
exported_glb = self.export(request.match_info['name'])
|
exported_glb = await self.export(request.match_info['name'])
|
||||||
response = web.Response()
|
response = web.Response()
|
||||||
try:
|
try:
|
||||||
# Create a new stream response with custom content type and headers
|
# Create a new stream response with custom content type and headers
|
||||||
|
|||||||
Reference in New Issue
Block a user