Compare commits

...

14 Commits

Author SHA1 Message Date
Yeicor
27f540be23 Automatically update version to 0.8.8 2024-03-31 18:24:32 +00:00
Yeicor
e8c0f683c5 Lots of frontend improvements like keeping camera position on changes, avoid flickering on live updates, smooth gizmo animations, proper ortho camera movement, and enabling move by tap when not selecting. 2024-03-31 20:20:53 +02:00
Yeicor
345636e478 Automatically update version to 0.8.7 2024-03-30 17:02:20 +00:00
Yeicor
9a0fb03526 Merge remote-tracking branch 'origin/master' 2024-03-30 18:00:50 +01:00
Yeicor
2037621afc Fix frontend style issues 2024-03-30 18:00:37 +01:00
Yeicor
2ba0e18479 Automatically update version to 0.8.6 2024-03-30 16:31:40 +00:00
Yeicor
eca2bbfa7c Fix python import bug 2024-03-30 17:28:49 +01:00
Yeicor
86180c424e Keep selected enabled features on model updates instead of resetting them, better list of objects support and recover/disable previous selection on scene reloads. 2024-03-30 17:26:06 +01:00
dependabot[bot]
e42d6be074 Bump @tsconfig/node20 from 20.1.3 to 20.1.4 (#30)
Bumps [@tsconfig/node20](https://github.com/tsconfig/bases/tree/HEAD/bases) from 20.1.3 to 20.1.4.
- [Commits](https://github.com/tsconfig/bases/commits/HEAD/bases)

---
updated-dependencies:
- dependency-name: "@tsconfig/node20"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 10:57:52 +00:00
dependabot[bot]
e2d6a3cb00 Bump actions/configure-pages from 4 to 5 in /.github/workflows (#31)
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:15:41 +00:00
dependabot[bot]
9e453b7890 Bump vuetify from 3.5.11 to 3.5.13 (#29)
Bumps [vuetify](https://github.com/vuetifyjs/vuetify/tree/HEAD/packages/vuetify) from 3.5.11 to 3.5.13.
- [Release notes](https://github.com/vuetifyjs/vuetify/releases)
- [Commits](https://github.com/vuetifyjs/vuetify/commits/v3.5.13/packages/vuetify)

---
updated-dependencies:
- dependency-name: vuetify
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:14:06 +00:00
dependabot[bot]
0b8faa9e8b Bump vite from 5.2.6 to 5.2.7 (#28)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.6 to 5.2.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:13:56 +00:00
dependabot[bot]
00bc2a15e0 Bump @types/node from 20.11.30 to 20.12.2 (#27)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.30 to 20.12.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:13:29 +00:00
dependabot[bot]
432abcf85c Bump terser from 5.29.2 to 5.30.0 (#26)
Bumps [terser](https://github.com/terser/terser) from 5.29.2 to 5.30.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.29.2...v5.30.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:13:17 +00:00
24 changed files with 387 additions and 305 deletions

View File

@@ -40,7 +40,7 @@ jobs:
mv "$dir/"* public/ mv "$dir/"* public/
rmdir "$dir" rmdir "$dir"
done done
- uses: "actions/configure-pages@v4" - uses: "actions/configure-pages@v5"
- uses: "actions/upload-pages-artifact@v3" - uses: "actions/upload-pages-artifact@v3"
with: with:
path: 'public' path: 'public'

View File

@@ -2439,7 +2439,7 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- vuetify@3.5.11 - vuetify@3.5.13
This package contains the following license and notice below: This package contains the following license and notice below:

View File

@@ -1,13 +1,13 @@
<!--suppress SillyAssignmentJS --> <!--suppress SillyAssignmentJS -->
<script setup lang="ts"> <script lang="ts" setup>
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef} from "vue"; import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef, watch} 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 Tools from "./tools/Tools.vue"; import Tools from "./tools/Tools.vue";
import Models from "./models/Models.vue"; import Models from "./models/Models.vue";
import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs"; import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs";
import {settings} from "./misc/settings"; import {settings} from "./misc/settings";
import {NetworkManager, NetworkUpdateEvent} from "./misc/network"; import {NetworkManager, NetworkUpdateEvent, NetworkUpdateEventModel} from "./misc/network";
import {SceneMgr} from "./misc/scene"; import {SceneMgr} from "./misc/scene";
import {Document} from "@gltf-transform/core"; import {Document} from "@gltf-transform/core";
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue"; import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
@@ -28,6 +28,7 @@ const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
const sceneDocument = shallowRef(new Document()); const sceneDocument = shallowRef(new Document());
provide('sceneDocument', {sceneDocument}); provide('sceneDocument', {sceneDocument});
const models: Ref<InstanceType<typeof Models> | null> = ref(null) const models: Ref<InstanceType<typeof Models> | null> = ref(null)
const tools: Ref<InstanceType<typeof Tools> | null> = ref(null)
const disableTap = ref(false); const disableTap = ref(false);
const setDisableTap = (val: boolean) => disableTap.value = val; const setDisableTap = (val: boolean) => disableTap.value = val;
provide('disableTap', {disableTap, setDisableTap}); provide('disableTap', {disableTap, setDisableTap});
@@ -49,6 +50,7 @@ async function onModelUpdateRequest(event: NetworkUpdateEvent) {
for (let modelIndex in event.models) { for (let modelIndex in event.models) {
let isLast = parseInt(modelIndex) === event.models.length - 1; let isLast = parseInt(modelIndex) === event.models.length - 1;
let model = event.models[modelIndex]; let model = event.models[modelIndex];
tools.value?.removeObjectSelections(model.name);
try { try {
if (!model.isRemove) { if (!model.isRemove) {
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && settings.loadHelpers, isLast); doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && settings.loadHelpers, isLast);
@@ -68,8 +70,8 @@ async function onModelUpdateRequest(event: NetworkUpdateEvent) {
} }
async function onModelRemoveRequest(name: string) { async function onModelRemoveRequest(name: string) {
sceneDocument.value = await SceneMgr.removeModel(sceneUrl, sceneDocument.value, name); await onModelUpdateRequest(new NetworkUpdateEvent([new NetworkUpdateEventModel(name, "", null, true)], () => {
triggerRef(sceneDocument); // Why not triggered automatically? }));
} }
// Set up the load model event listener // Set up the load model event listener
@@ -81,6 +83,12 @@ networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUp
for (let model of settings.preload) { for (let model of settings.preload) {
networkMgr.load(model); networkMgr.load(model);
} }
watch(viewer, (newViewer) => {
if (newViewer) {
newViewer.setPosterText('<tspan x="50%" dy="1.2em">Trying to load' +
' models from:</tspan>' + settings.preload.map((url) => '<tspan x="50%" dy="1.2em">- ' + url + '</tspan>').join(""));
}
});
async function loadModelManual() { 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:"); 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:");
@@ -97,24 +105,24 @@ async function loadModelManual() {
</v-main> </v-main>
<!-- The left collapsible sidebar has the list of models --> <!-- The left collapsible sidebar has the list of models -->
<sidebar :opened-init="openSidebarsByDefault" side="left" :width="300"> <sidebar :opened-init="openSidebarsByDefault" :width="300" side="left">
<template #toolbar> <template #toolbar>
<v-toolbar-title>Models</v-toolbar-title> <v-toolbar-title>Models</v-toolbar-title>
</template> </template>
<template #toolbar-items> <template #toolbar-items>
<v-btn icon="" @click="loadModelManual"> <v-btn icon="" @click="loadModelManual">
<svg-icon type="mdi" :path="mdiPlus"/> <svg-icon :path="mdiPlus" type="mdi"/>
</v-btn> </v-btn>
</template> </template>
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/> <models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
</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 /* border? */"> <sidebar :opened-init="openSidebarsByDefault" :width="48 * 3 /* buttons */ + 1 /* border? */" side="right">
<template #toolbar> <template #toolbar>
<v-toolbar-title>Tools</v-toolbar-title> <v-toolbar-title>Tools</v-toolbar-title>
</template> </template>
<tools :viewer="viewer" @findModel="(name) => models?.findModel(name)"/> <tools ref="tools" :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
</sidebar> </sidebar>
</v-layout> </v-layout>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script lang="ts" setup>
import {VContainer, VRow, VCol, VProgressCircular} from "vuetify/lib/components/index.mjs"; import {VCol, VContainer, VProgressCircular, VRow} from "vuetify/lib/components/index.mjs";
</script> </script>
<template> <template>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts" setup>
import {ref} from "vue"; import {ref} from "vue";
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs"; import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js' import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
@@ -16,22 +16,22 @@ const openIcon = props.side === 'left' ? mdiChevronRight : mdiChevronLeft;
</script> </script>
<template> <template>
<v-btn icon @click="opened = !opened" class="open-button" :class="side"> <v-btn :class="side" class="open-button" icon @click="opened = !opened">
<svg-icon type="mdi" :path="openIcon"/> <svg-icon :path="openIcon" type="mdi"/>
</v-btn> </v-btn>
<v-navigation-drawer v-model="opened" permanent :location="side" :width="props.width"> <v-navigation-drawer v-model="opened" :location="side" :width="props.width" permanent>
<v-toolbar density="compact"> <v-toolbar density="compact">
<v-toolbar-items v-if="side == 'right'"> <v-toolbar-items v-if="side == 'right'">
<slot name="toolbar-items"></slot> <slot name="toolbar-items"></slot>
<v-btn icon @click="opened = !opened"> <v-btn icon @click="opened = !opened">
<svg-icon type="mdi" :path="mdiClose"/> <svg-icon :path="mdiClose" type="mdi"/>
</v-btn> </v-btn>
</v-toolbar-items> </v-toolbar-items>
<slot name="toolbar"></slot> <slot name="toolbar"></slot>
<v-toolbar-items v-if="side == 'left'"> <v-toolbar-items v-if="side == 'left'">
<slot name="toolbar-items"></slot> <slot name="toolbar-items"></slot>
<v-btn icon @click="opened = !opened"> <v-btn icon @click="opened = !opened">
<svg-icon type="mdi" :path="mdiClose"/> <svg-icon :path="mdiClose" type="mdi"/>
</v-btn> </v-btn>
</v-toolbar-items> </v-toolbar-items>
</v-toolbar> </v-toolbar>

View File

@@ -8,7 +8,6 @@ function getCenterAndVertexList(selInfo: SelectionInfo, scene: ModelScene): {
center: Vector3, center: Vector3,
vertices: Array<Vector3> vertices: Array<Vector3>
} { } {
selInfo.object.updateMatrixWorld();
let pos: BufferAttribute | InterleavedBufferAttribute = selInfo.object.geometry.getAttribute('position'); let pos: BufferAttribute | InterleavedBufferAttribute = selInfo.object.geometry.getAttribute('position');
let ind: BufferAttribute | null = selInfo.object.geometry.index; let ind: BufferAttribute | null = selInfo.object.geometry.index;
if (ind === null) { if (ind === null) {

View File

@@ -2,7 +2,7 @@ import {settings} from "./settings";
const batchTimeout = 250; // ms const batchTimeout = 250; // ms
class NetworkUpdateEventModel { export class NetworkUpdateEventModel {
name: string; name: string;
url: string; url: string;
// TODO: Detect and manage instances of the same object (same hash, different name) // TODO: Detect and manage instances of the same object (same hash, different name)

View File

@@ -37,19 +37,6 @@ export class SceneMgr {
return document; return document;
} }
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
let bb = SceneMgr.getBoundingBox(document);
if (!bb) return 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, false, reloadScene);
}
static getBoundingBox(document: Document): Box3 | null { static getBoundingBox(document: Document): Box3 | null {
if (document.getRoot().listNodes().length === 0) return null; if (document.getRoot().listNodes().length === 0) return null;
// Get bounding box of the model and use it to set the size of the helpers // Get bounding box of the model and use it to set the size of the helpers
@@ -91,6 +78,19 @@ export class SceneMgr {
return document; return document;
} }
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
let bb = SceneMgr.getBoundingBox(document);
if (!bb) return 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, false, reloadScene);
}
/** 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(sceneUrl: Ref<string>, document: Document): Promise<Document> { private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
// Make sure the document is fully loaded and ready to be shown // Make sure the document is fully loaded and ready to be shown

View File

@@ -18,8 +18,11 @@ export const settings = {
monitorEveryMs: 100, monitorEveryMs: 100,
monitorOpenTimeoutMs: 1000, monitorOpenTimeoutMs: 1000,
// ModelViewer settings // ModelViewer settings
autoplay: true, autoplay: true, // Global animation toggle
arModes: 'webxr scene-viewer quick-look', arModes: 'webxr scene-viewer quick-look',
zoomSensitivity: 0.25,
orbitSensitivity: 1,
panSensitivity: 1,
exposure: 1, exposure: 1,
shadowIntensity: 0, shadowIntensity: 0,
background: '', background: '',

View File

@@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts" setup>
import { import {
VBtn, VBtn,
VBtnToggle, VBtnToggle,
@@ -12,8 +12,8 @@ import {
VTooltip, VTooltip,
} from "vuetify/lib/components/index.mjs"; } from "vuetify/lib/components/index.mjs";
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf"; import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
import {Document, Mesh} from "@gltf-transform/core"; import {Mesh} from "@gltf-transform/core";
import {inject, ref, type ShallowRef, watch} from "vue"; import {ref, watch} from "vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue"; import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import { import {
mdiCircleOpacity, mdiCircleOpacity,
@@ -33,7 +33,7 @@ import {Plane} from "three/src/math/Plane.js";
import {Vector3} from "three/src/math/Vector3.js"; import {Vector3} from "three/src/math/Vector3.js";
import type {MObject3D} from "../tools/Selection.vue"; import type {MObject3D} from "../tools/Selection.vue";
import {toLineSegments} from "../misc/lines.js"; import {toLineSegments} from "../misc/lines.js";
import {settings} from "../misc/settings.js"; import {settings} from "../misc/settings.js"
const props = defineProps<{ const props = defineProps<{
meshes: Array<Mesh>, meshes: Array<Mesh>,
@@ -85,7 +85,7 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
scene.queueRender() scene.queueRender()
} }
watch(enabledFeatures, onEnabledFeaturesChange); watch(enabledFeatures, onEnabledFeaturesChange, {deep: true});
function onOpacityChange(newOpacity: number) { function onOpacityChange(newOpacity: number) {
let scene = props.viewer?.scene; let scene = props.viewer?.scene;
@@ -122,8 +122,6 @@ function onWireframeChange(newWireframe: boolean) {
watch(wireframe, onWireframeChange); watch(wireframe, onWireframeChange);
let {sceneDocument} = inject<{ sceneDocument: ShallowRef<Document> }>('sceneDocument')!!;
function onClipPlanesChange() { function onClipPlanesChange() {
let scene = props.viewer?.scene; let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model; let sceneModel = (scene as any)?._model;
@@ -180,8 +178,6 @@ watch(clipPlaneZ, onClipPlanesChange);
watch(clipPlaneSwappedX, onClipPlanesChange); watch(clipPlaneSwappedX, onClipPlanesChange);
watch(clipPlaneSwappedY, onClipPlanesChange); watch(clipPlaneSwappedY, onClipPlanesChange);
watch(clipPlaneSwappedZ, 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))
let edgeWidthChangeCleanup = [] as Array<() => void>; let edgeWidthChangeCleanup = [] as Array<() => void>;
@@ -245,9 +241,35 @@ function onModelLoad() {
let sceneModel = (scene as any)?._model; let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return; if (!scene || !sceneModel) return;
// Count the number of faces, edges and vertices
const isFirstLoad = faceCount.value === -1;
faceCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES))
.map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1)
.reduce((a, b) => a + b, 0)
edgeCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]))
.map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0)
.reduce((a, b) => a + b, 0)
vertexCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS))
.map(p => (p.getAttribute("POSITION")?.getCount() ?? 0))
.reduce((a, b) => a + b, 0)
// First time: set the enabled features to all provided features
if (isFirstLoad) {
if (faceCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
else if (!enabledFeatures.value.includes(0)) enabledFeatures.value.push(0)
if (edgeCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
else if (!enabledFeatures.value.includes(1)) enabledFeatures.value.push(1)
if (vertexCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
else if (!enabledFeatures.value.includes(2)) enabledFeatures.value.push(2)
}
// Add darkened back faces for all face objects to improve cutting planes // Add darkened back faces for all face objects to improve cutting planes
let childrenToAdd: Array<MObject3D> = []; let childrenToAdd: Array<MObject3D> = [];
sceneModel.traverse((child: MObject3D) => { sceneModel.traverse((child: MObject3D) => {
child.updateMatrixWorld(); // Objects are mostly static, so ensure updated matrices
if (child.userData[extrasNameKey] === modelName) { if (child.userData[extrasNameKey] === modelName) {
if (child.type == 'Mesh' || child.type == 'SkinnedMesh') { if (child.type == 'Mesh' || child.type == 'SkinnedMesh') {
// Compute a BVH for faster raycasting (MUCH faster selection) // Compute a BVH for faster raycasting (MUCH faster selection)
@@ -278,28 +300,6 @@ function onModelLoad() {
}); });
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child)); childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child));
// Count the number of faces, edges and vertices
faceCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES))
.map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1)
.reduce((a, b) => a + b, 0)
edgeCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]))
.map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0)
.reduce((a, b) => a + b, 0)
vertexCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS))
.map(p => (p.getAttribute("POSITION")?.getCount() ?? 0))
.reduce((a, b) => a + b, 0)
// Set the enabled features to all provided features
if (faceCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
else if (!enabledFeatures.value.includes(0)) enabledFeatures.value.push(0)
if (edgeCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
else if (!enabledFeatures.value.includes(1)) enabledFeatures.value.push(1)
if (vertexCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
else if (!enabledFeatures.value.includes(2)) enabledFeatures.value.push(2)
// Furthermore... // Furthermore...
// Enabled features may have been reset after a reload // Enabled features may have been reset after a reload
onEnabledFeaturesChange(enabledFeatures.value) onEnabledFeaturesChange(enabledFeatures.value)
@@ -316,95 +316,101 @@ function onModelLoad() {
} }
// props.viewer.elem may not yet be available, so we need to wait for it // props.viewer.elem may not yet be available, so we need to wait for it
props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad)) const onViewerReady = (viewer: InstanceType<typeof ModelViewerWrapper>) => {
viewer?.onElemReady((elem: HTMLElement) => {
elem.addEventListener('before-render', onModelLoad);
elem.addEventListener('camera-change', onClipPlanesChange);
});
};
if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) as any, onViewerReady);
</script> </script>
<template> <template>
<v-expansion-panel :value="modelName"> <v-expansion-panel :value="modelName">
<v-expansion-panel-title expand-icon="hide-this-icon" collapse-icon="hide-this-icon"> <v-expansion-panel-title collapse-icon="hide-this-icon" expand-icon="hide-this-icon">
<v-btn-toggle v-model="enabledFeatures" multiple @click.stop color="surface-light"> <v-btn-toggle v-model="enabledFeatures" color="surface-light" multiple @click.stop>
<v-btn icon> <v-btn icon>
<v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip> <v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip>
<svg-icon type="mdi" :path="mdiRectangle" :rotate="90"></svg-icon> <svg-icon :path="mdiRectangle" :rotate="90" type="mdi"></svg-icon>
</v-btn> </v-btn>
<v-btn icon> <v-btn icon>
<v-tooltip activator="parent">Toggle Edges ({{ edgeCount }})</v-tooltip> <v-tooltip activator="parent">Toggle Edges ({{ edgeCount }})</v-tooltip>
<svg-icon type="mdi" :path="mdiRectangleOutline" :rotate="90"></svg-icon> <svg-icon :path="mdiRectangleOutline" :rotate="90" type="mdi"></svg-icon>
</v-btn> </v-btn>
<v-btn icon> <v-btn icon>
<v-tooltip activator="parent">Toggle Vertices ({{ vertexCount }})</v-tooltip> <v-tooltip activator="parent">Toggle Vertices ({{ vertexCount }})</v-tooltip>
<svg-icon type="mdi" :path="mdiVectorRectangle" :rotate="90"></svg-icon> <svg-icon :path="mdiVectorRectangle" :rotate="90" type="mdi"></svg-icon>
</v-btn> </v-btn>
</v-btn-toggle> </v-btn-toggle>
<div class="model-name">{{ modelName }}</div> <div class="model-name">{{ modelName }}</div>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<v-btn icon @click.stop="emit('remove')"> <v-btn icon @click.stop="emit('remove')">
<v-tooltip activator="parent">Remove</v-tooltip> <v-tooltip activator="parent">Remove</v-tooltip>
<svg-icon type="mdi" :path="mdiDelete"></svg-icon> <svg-icon :path="mdiDelete" type="mdi"></svg-icon>
</v-btn> </v-btn>
</v-expansion-panel-title> </v-expansion-panel-title>
<v-expansion-panel-text> <v-expansion-panel-text>
<v-slider v-model="opacity" hide-details min="0" max="1" :step="0.1"> <v-slider v-model="opacity" :step="0.1" hide-details max="1" min="0">
<template v-slot:prepend> <template v-slot:prepend>
<v-tooltip activator="parent">Change opacity</v-tooltip> <v-tooltip activator="parent">Change opacity</v-tooltip>
<svg-icon type="mdi" :path="mdiCircleOpacity"></svg-icon> <svg-icon :path="mdiCircleOpacity" type="mdi"></svg-icon>
</template> </template>
<template v-slot:append> <template v-slot:append>
<v-tooltip activator="parent">Wireframe</v-tooltip> <v-tooltip activator="parent">Wireframe</v-tooltip>
<v-checkbox-btn trueIcon="mdi-triangle-outline" falseIcon="mdi-triangle" v-model="wireframe"></v-checkbox-btn> <v-checkbox-btn v-model="wireframe" falseIcon="mdi-triangle" trueIcon="mdi-triangle-outline"></v-checkbox-btn>
</template> </template>
</v-slider> </v-slider>
<v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details min="0" max="1"> <v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details max="1" min="0">
<template v-slot:prepend> <template v-slot:prepend>
<v-tooltip activator="parent">Edge and vertex sizes</v-tooltip> <v-tooltip activator="parent">Edge and vertex sizes</v-tooltip>
<svg-icon type="mdi" :path="mdiVectorLine"></svg-icon> <svg-icon :path="mdiVectorLine" type="mdi"></svg-icon>
</template> </template>
</v-slider> </v-slider>
<v-divider></v-divider> <v-divider></v-divider>
<v-slider v-model="clipPlaneX" hide-details min="0" max="1"> <v-slider v-model="clipPlaneX" hide-details max="1" min="0">
<template v-slot:prepend> <template v-slot:prepend>
<v-tooltip activator="parent">Clip plane X</v-tooltip> <v-tooltip activator="parent">Clip plane X</v-tooltip>
<svg-icon type="mdi" :path="mdiCube" :rotate="120"></svg-icon> <svg-icon :path="mdiCube" :rotate="120" type="mdi"></svg-icon>
X X
</template> </template>
<template v-slot:append> <template v-slot:append>
<v-tooltip activator="parent">Swap clip plane X</v-tooltip> <v-tooltip activator="parent">Swap clip plane X</v-tooltip>
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline" <v-checkbox-btn v-model="clipPlaneSwappedX" falseIcon="mdi-checkbox-blank-outline"
v-model="clipPlaneSwappedX"> trueIcon="mdi-checkbox-marked-outline">
<template v-slot:label> <template v-slot:label>
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon> <svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
</template> </template>
</v-checkbox-btn> </v-checkbox-btn>
</template> </template>
</v-slider> </v-slider>
<v-slider v-model="clipPlaneZ" hide-details min="0" max="1"> <v-slider v-model="clipPlaneZ" hide-details max="1" min="0">
<template v-slot:prepend> <template v-slot:prepend>
<v-tooltip activator="parent">Clip plane Y</v-tooltip> <v-tooltip activator="parent">Clip plane Y</v-tooltip>
<svg-icon type="mdi" :path="mdiCube" :rotate="-120"></svg-icon> <svg-icon :path="mdiCube" :rotate="-120" type="mdi"></svg-icon>
Y Y
</template> </template>
<template v-slot:append> <template v-slot:append>
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip> <v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline" <v-checkbox-btn v-model="clipPlaneSwappedZ" falseIcon="mdi-checkbox-blank-outline"
v-model="clipPlaneSwappedZ"> trueIcon="mdi-checkbox-marked-outline">
<template v-slot:label> <template v-slot:label>
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon> <svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
</template> </template>
</v-checkbox-btn> </v-checkbox-btn>
</template> </template>
</v-slider> </v-slider>
<v-slider v-model="clipPlaneY" hide-details min="0" max="1"> <v-slider v-model="clipPlaneY" hide-details max="1" min="0">
<template v-slot:prepend> <template v-slot:prepend>
<v-tooltip activator="parent">Clip plane Z</v-tooltip> <v-tooltip activator="parent">Clip plane Z</v-tooltip>
<svg-icon type="mdi" :path="mdiCube"></svg-icon> <svg-icon :path="mdiCube" type="mdi"></svg-icon>
Z Z
</template> </template>
<template v-slot:append> <template v-slot:append>
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip> <v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline" <v-checkbox-btn v-model="clipPlaneSwappedY" falseIcon="mdi-checkbox-blank-outline"
v-model="clipPlaneSwappedY"> trueIcon="mdi-checkbox-marked-outline">
<template v-slot:label> <template v-slot:label>
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon> <svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
</template> </template>
</v-checkbox-btn> </v-checkbox-btn>
</template> </template>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts" setup>
import {VExpansionPanels} from "vuetify/lib/components/index.mjs"; import {VExpansionPanels} from "vuetify/lib/components/index.mjs";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue"; import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import {Document, Mesh} from "@gltf-transform/core"; import {Document, Mesh} from "@gltf-transform/core";

View File

@@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts" setup>
// License text for all dependencies, only downloaded when/if needed // License text for all dependencies, only downloaded when/if needed
// @ts-ignore // @ts-ignore
import licenseText from "../../assets/licenses.txt?raw"; import licenseText from "../../assets/licenses.txt?raw";

View File

@@ -1,18 +1,16 @@
<script setup lang="ts"> <script lang="ts" setup>
import {onMounted, onUpdated, ref} from "vue"; import {onMounted, onUpdated, ref} from "vue";
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene"; import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo"; import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
import type {ModelViewerElement} from '@google/model-viewer';
// Optimized minimal dependencies from three // Optimized minimal dependencies from three
import {Vector3} from "three/src/math/Vector3.js"; import {Vector3} from "three/src/math/Vector3.js";
import {Matrix4} from "three/src/math/Matrix4.js"; import {Matrix4} from "three/src/math/Matrix4.js";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
(globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work (globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
const OrientationGizmo = OrientationGizmoRaw.default; const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> }>();
const props = defineProps<{ elem: ModelViewerElement | null, scene: ModelScene }>();
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement { function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
// noinspection SpellCheckingInspection // noinspection SpellCheckingInspection
@@ -33,21 +31,26 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
} }
// Append and listen for events // Append and listen for events
gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => { gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => {
let lookFrom = scene.getCamera().position.clone(); if (!props.viewer.elem || !props.viewer.controls) return;
let lookAt = scene.getTarget().clone().add(scene.target.position); // Animate the controls to the new wanted angle
let magnitude = lookFrom.clone().sub(lookAt).length() const controls = props.viewer.controls;
let direction = new Vector3(axis.direction.x, axis.direction.y, axis.direction.z); const {theta: curTheta/*, phi: curPhi*/} = (controls as any).goalSpherical;
let newLookFrom = lookAt.clone().add(direction.clone().multiplyScalar(magnitude)); let wantedTheta = NaN;
//console.log("New camera position", newLookFrom) let wantedPhi = NaN;
scene.getCamera().position.copy(newLookFrom); let attempt = 0
scene.getCamera().lookAt(lookAt); while ((attempt == 0 || curTheta == wantedTheta) && attempt < 2) {
if ((scene as any).__perspectiveCamera) { // HACK: Make the hacky ortho also work if (attempt > 0) { // Flip the camera if the user clicks on the same axis
(scene as any).__perspectiveCamera.position.copy(newLookFrom); axis.direction.x = -axis.direction.x;
(scene as any).__perspectiveCamera.lookAt(lookAt); axis.direction.y = -axis.direction.y;
axis.direction.z = -axis.direction.z;
}
wantedTheta = Math.atan2(axis.direction.x, axis.direction.z);
wantedPhi = Math.asin(-axis.direction.y) + Math.PI / 2;
attempt++;
} }
controls.setOrbit(wantedTheta, wantedPhi);
props.viewer.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}))
scene.queueRender(); scene.queueRender();
requestIdleCallback(() => props.elem?.dispatchEvent(
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
} }
return gizmo; return gizmo;
} }
@@ -65,9 +68,9 @@ function updateGizmo() {
} }
let reinstall = () => { let reinstall = () => {
if(!container.value) return; if (!container.value) return;
if (gizmo) container.value.removeChild(gizmo); if (gizmo) container.value.removeChild(gizmo);
gizmo = createGizmo(container.value, props.scene as ModelScene) as typeof gizmo; gizmo = createGizmo(container.value, props.viewer.scene!! as any) as typeof gizmo;
container.value.appendChild(gizmo); container.value.appendChild(gizmo);
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
} }

View File

@@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts" setup>
import {defineModel, inject, ref, type ShallowRef, watch} from "vue"; import {defineModel, inject, ref, type ShallowRef, watch} from "vue";
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs"; import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
import SvgIcon from '@jamescoyle/vue-icon'; import SvgIcon from '@jamescoyle/vue-icon';
@@ -33,21 +33,23 @@ let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
let showDistances = ref<Boolean>(true); let showDistances = ref<Boolean>(true);
let mouseDownAt: [number, number] | null = null; let mouseDownAt: [number, number] | null = null;
let mouseDownTime = 0;
let selectFilter = ref('Any (S)'); let selectFilter = ref('Any (S)');
const raycaster = new Raycaster(); const raycaster = new Raycaster();
let selectionMoveListener = (event: MouseEvent) => { let mouseDownListener = (event: MouseEvent) => {
mouseDownAt = [event.clientX, event.clientY]; mouseDownAt = [event.clientX, event.clientY];
mouseDownTime = performance.now();
if (!selectionEnabled.value) return; if (!selectionEnabled.value) return;
}; };
let selectionListener = (event: MouseEvent) => { let mouseUpListener = (event: MouseEvent) => {
// If the mouse moved while clicked (dragging), avoid selection logic // If the mouse moved while clicked (dragging), avoid selection logic
if (mouseDownAt) { if (mouseDownAt) {
let [x, y] = mouseDownAt; let [x, y] = mouseDownAt;
mouseDownAt = null; mouseDownAt = null;
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5) { if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5 || performance.now() - mouseDownTime > 500) {
return; return;
} }
} }
@@ -254,14 +256,29 @@ let onViewerReady = (viewer: typeof ModelViewerWrapperT) => {
viewer.onElemReady((elem: ModelViewerElement) => { viewer.onElemReady((elem: ModelViewerElement) => {
if (hasListeners) return; if (hasListeners) return;
hasListeners = true; hasListeners = true;
elem.addEventListener('mouseup', selectionListener); elem.addEventListener('mousedown', mouseDownListener); // Avoid clicking when dragging
elem.addEventListener('mousedown', selectionMoveListener); // Avoid clicking when dragging elem.addEventListener('mouseup', mouseUpListener);
elem.addEventListener('load', () => { elem.addEventListener('before-render', () => {
// After a reload of the scene, we need to recover object references and highlight them again
for (let sel of selected.value) {
let scene = props.viewer?.scene;
if (!scene) continue;
let foundObject = null;
scene.traverse((obj: MObject3D) => {
if (sel.matches(obj)) {
foundObject = obj as MObject3D;
}
});
if (foundObject) {
sel.object = foundObject;
highlight(sel);
} else {
selected.value = selected.value.filter((m) => m.getKey() !== sel.getKey());
}
}
if (firstLoad) { if (firstLoad) {
toggleShowBoundingBox(); toggleShowBoundingBox();
firstLoad = false; firstLoad = false;
} else {
updateBoundingBox();
} }
}); });
elem.addEventListener('camera-change', onCameraChange); elem.addEventListener('camera-change', onCameraChange);
@@ -406,6 +423,8 @@ function updateDistances() {
return; return;
} }
defineExpose({deselect, updateBoundingBox, updateDistances});
// Add keyboard shortcuts // Add keyboard shortcuts
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
if (event.key === 's') { if (event.key === 's') {
@@ -443,52 +462,41 @@ window.addEventListener('keydown', (event) => {
</script> </script>
<template> <template>
<div class="select-parent"> <v-btn :color="selectionEnabled ? 'surface-light' : ''" icon @click="toggleSelection">
<v-btn icon @click="toggleSelection" :color="selectionEnabled ? 'surface-light' : ''"> <v-tooltip activator="parent">{{ selectionEnabled ? 'Disable (s)election mode' : 'Enable (s)election mode' }}
<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> </v-tooltip>
</div> <svg-icon :path="mdiCursorDefaultClick" type="mdi"/>
<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>
<v-btn icon @click="toggleShowBoundingBox" :color="showBoundingBox ? 'surface-light' : ''"> <v-tooltip :open-on-click="false" :text="'Select only ' + selectFilter.toString().toLocaleLowerCase()">
<template v-slot:activator="{ props }">
<v-select v-model="selectFilter" :items="['Any (S)', '(F)aces', '(E)dges', '(V)ertices']" class="select-only"
v-bind="props"
variant="underlined"/>
</template>
</v-tooltip>
<v-btn :color="highlightNextSelection[0] ? 'surface-light' : ''" icon @click="toggleHighlightNextSelection">
<v-tooltip activator="parent">(H)ighlight the next clicked element in the models list</v-tooltip>
<svg-icon :path="mdiFeatureSearch" type="mdi"/>
</v-btn>
<v-btn :color="showBoundingBox ? 'surface-light' : ''" icon @click="toggleShowBoundingBox">
<v-tooltip activator="parent">{{ showBoundingBox ? 'Hide selection (b)ounds' : 'Show selection (b)ounds' }} <v-tooltip activator="parent">{{ showBoundingBox ? 'Hide selection (b)ounds' : 'Show selection (b)ounds' }}
</v-tooltip> </v-tooltip>
<svg-icon type="mdi" :path="mdiCubeOutline"/> <svg-icon :path="mdiCubeOutline" type="mdi"/>
</v-btn> </v-btn>
<v-btn icon @click="toggleShowDistances" :color="showDistances ? 'surface-light' : ''"> <v-btn :color="showDistances ? 'surface-light' : ''" icon @click="toggleShowDistances">
<v-tooltip activator="parent"> <v-tooltip activator="parent">
{{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }} {{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }}
</v-tooltip> </v-tooltip>
<svg-icon type="mdi" :path="mdiRuler"/> <svg-icon :path="mdiRuler" type="mdi"/>
</v-btn> </v-btn>
</template> </template>
<style scoped> <style scoped>
/* Very hacky styling... */
.select-parent {
height: 48px;
}
.select-parent .v-btn {
position: relative;
top: -20px;
}
.select-only { .select-only {
display: inline-block; float: right;
width: calc(100% - 48px); height: 36px;
position: relative; position: relative;
top: -12px; top: -12px;
width: calc(100% - 48px);
} }
</style> </style>

View File

@@ -1,4 +1,4 @@
<script setup lang="ts"> <script lang="ts" setup>
import { import {
VBtn, VBtn,
VCard, VCard,
@@ -16,10 +16,9 @@ import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
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'; import SvgIcon from '@jamescoyle/vue-icon';
import type {ModelViewerElement} from '@google/model-viewer'; import type {ModelViewerElement} from '@google/model-viewer';
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"; import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import {defineAsyncComponent, type Ref, ref} from "vue"; import {defineAsyncComponent, ref, type Ref} from "vue";
import type {SelectionInfo} from "./selection"; import type {SelectionInfo} from "./selection";
const SelectionComponent = defineAsyncComponent({ const SelectionComponent = defineAsyncComponent({
@@ -57,14 +56,14 @@ function syncOrthoCamera(force: boolean) {
let h = perspectiveWidthAtCenter / scene.aspect; let h = perspectiveWidthAtCenter / scene.aspect;
(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(lookAtCenter); scene.camera.rotation.copy(perspectiveCam.rotation);
if (force) scene.queueRender() // Force rerender of model-viewer if (force) 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() { async function toggleProjection() {
let scene = props.viewer?.scene; let scene = props.viewer?.scene;
if (!scene) return; if (!scene) return;
let prevCam = scene.camera; let prevCam = scene.camera;
@@ -79,16 +78,16 @@ function toggleProjection() {
scene.queueRender() // Force rerender of model-viewer scene.queueRender() // Force rerender of model-viewer
} }
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP'; toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
// The camera change may take a few frames to take effect, dispatch the event after a delay // The camera change may take a frame to take effect, dispatch the event after a delay
requestIdleCallback(() => props.viewer?.elem?.dispatchEvent( await new Promise((resolve) => requestAnimationFrame(resolve));
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100}) props.viewer?.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}));
} }
async function centerCamera() { async function centerCamera() {
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem; let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
if (!viewerEl) return; if (!viewerEl) return;
await viewerEl.updateFraming(); props.viewer?.scene?.setTarget(0, 0, 0); // Center the target
viewerEl.zoom(3); viewerEl.zoom(-1000000); // Max zoom out
} }
@@ -107,6 +106,15 @@ async function openGithub() {
window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank') window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank')
} }
function removeObjectSelections(objName: string) {
for (let selInfo of selection.value.filter((s) => s.getObjectName() === objName)) {
selectionComp.value?.deselect(selInfo);
}
selectionComp.value?.updateBoundingBox();
selectionComp.value?.updateDistances();
}
defineExpose({removeObjectSelections});
// Add keyboard shortcuts // Add keyboard shortcuts
window.addEventListener('keydown', (event) => { window.addEventListener('keydown', (event) => {
@@ -118,35 +126,35 @@ window.addEventListener('keydown', (event) => {
</script> </script>
<template> <template>
<orientation-gizmo :scene="props.viewer.scene as any" :elem="props.viewer.elem" v-if="props.viewer?.scene"/> <orientation-gizmo v-if="props.viewer?.scene" :viewer="props.viewer"/>
<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>
<v-tooltip activator="parent">Toggle (P)rojection<br/>(currently <v-tooltip activator="parent">Toggle (P)rojection<br/>(currently
{{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }}) {{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }})
</v-tooltip> </v-tooltip>
<svg-icon type="mdi" :path="mdiProjector"></svg-icon> <svg-icon :path="mdiProjector" type="mdi"></svg-icon>
</v-btn> </v-btn>
<v-btn icon @click="centerCamera"> <v-btn icon @click="centerCamera">
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip> <v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
<svg-icon type="mdi" :path="mdiCrosshairsGps"/> <svg-icon :path="mdiCrosshairsGps" type="mdi"/>
</v-btn> </v-btn>
<v-divider/> <v-divider/>
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5> <h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
<selection-component :ref="selectionComp as any" :viewer="props.viewer as any" v-model="selection" <selection-component ref="selectionComp" v-model="selection" :viewer="props.viewer as any"
@findModel="(name) => emit('findModel', name)"/> @findModel="(name) => emit('findModel', name)"/>
<v-divider/> <v-divider/>
<v-spacer></v-spacer> <v-spacer></v-spacer>
<h5>Extras</h5> <h5>Extras</h5>
<v-btn icon @click="downloadSceneGlb"> <v-btn icon @click="downloadSceneGlb">
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip> <v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
<svg-icon type="mdi" :path="mdiDownload"/> <svg-icon :path="mdiDownload" type="mdi"/>
</v-btn> </v-btn>
<v-dialog id="licenses-dialog" fullscreen> <v-dialog id="licenses-dialog" fullscreen>
<template v-slot:activator="{ props }"> <template v-slot:activator="{ props }">
<v-btn icon v-bind="props"> <v-btn icon v-bind="props">
<v-tooltip activator="parent">Show Licenses</v-tooltip> <v-tooltip activator="parent">Show Licenses</v-tooltip>
<svg-icon type="mdi" :path="mdiLicense"/> <svg-icon :path="mdiLicense" type="mdi"/>
</v-btn> </v-btn>
</template> </template>
<template v-slot:default="{ isActive }"> <template v-slot:default="{ isActive }">
@@ -156,7 +164,7 @@ window.addEventListener('keydown', (event) => {
<v-spacer> <v-spacer>
</v-spacer> </v-spacer>
<v-btn icon @click="isActive.value = false"> <v-btn icon @click="isActive.value = false">
<svg-icon type="mdi" :path="mdiClose"/> <svg-icon :path="mdiClose" type="mdi"/>
</v-btn> </v-btn>
</v-toolbar> </v-toolbar>
<v-card-text> <v-card-text>
@@ -167,7 +175,7 @@ window.addEventListener('keydown', (event) => {
</v-dialog> </v-dialog>
<v-btn icon @click="openGithub"> <v-btn icon @click="openGithub">
<v-tooltip activator="parent">Open (G)itHub</v-tooltip> <v-tooltip activator="parent">Open (G)itHub</v-tooltip>
<svg-icon type="mdi" :path="mdiGithub"/> <svg-icon :path="mdiGithub" type="mdi"/>
</v-btn> </v-btn>
<div ref="statsHolder"></div> <div ref="statsHolder"></div>
</template> </template>
@@ -187,4 +195,8 @@ window.addEventListener('keydown', (event) => {
position: relative; position: relative;
top: 5px; top: 5px;
} }
h5 {
font-size: 14px;
}
</style> </style>

View File

@@ -3,6 +3,7 @@
import type {MObject3D} from "./Selection.vue"; import type {MObject3D} from "./Selection.vue";
import type {Intersection} from "three"; import type {Intersection} from "three";
import {Box3} from "three"; import {Box3} from "three";
import {extrasNameKey} from "../misc/gltf";
/** Information about a single item in the selection */ /** Information about a single item in the selection */
export class SelectionInfo { export class SelectionInfo {
@@ -19,6 +20,17 @@ export class SelectionInfo {
this.indices = indices; this.indices = indices;
} }
public getObjectName() {
return this.object.userData[extrasNameKey];
}
public matches(object: MObject3D) {
return this.getObjectName() === object.userData[extrasNameKey] &&
(this.kind === 'face' && (object.type === 'Mesh' || object.type === 'SkinnedMesh') ||
this.kind === 'edge' && (object.type === 'Line' || object.type === 'LineSegments') ||
this.kind === 'vertex' && object.type === 'Points')
}
public getKey() { public getKey() {
return this.object.uuid + this.kind + this.indices[0].toFixed() + this.indices[1].toFixed(); return this.object.uuid + this.kind + this.indices[0].toFixed() + this.indices[1].toFixed();
} }

View File

@@ -1,8 +1,9 @@
<script setup lang="ts"> <script lang="ts" setup>
import {settings} from "../misc/settings"; import {settings} from "../misc/settings";
import {inject, onMounted, type Ref, ref, watch} from "vue"; import {inject, onMounted, type Ref, ref, watch} from "vue";
import {VList, VListItem} from "vuetify/lib/components/index.mjs";
import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base"; import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
import {$controls} from '@google/model-viewer/lib/features/controls.js';
import {type SmoothControls} from '@google/model-viewer/lib/three-components/SmoothControls';
import {ModelViewerElement} from '@google/model-viewer'; 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";
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot"; import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
@@ -19,31 +20,63 @@ BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
//@ts-ignore //@ts-ignore
Mesh.prototype.raycast = acceleratedRaycast; Mesh.prototype.raycast = acceleratedRaycast;
const emit = defineEmits<{ load: [] }>()
const props = defineProps<{ src: string }>(); const props = defineProps<{ src: string }>();
const elem = ref<ModelViewerElement | null>(null); const elem = ref<ModelViewerElement | null>(null);
const scene = ref<ModelScene | null>(null); const scene = ref<ModelScene | null>(null);
const renderer = ref<Renderer | null>(null); const renderer = ref<Renderer | null>(null);
const controls = ref<SmoothControls | null>(null);
let lastCameraTargetPosition: Vector3 | undefined = undefined;
let lastCameraZoom: number | undefined = undefined;
let lastCameraUrl = props.src.toString();
onMounted(() => { onMounted(() => {
if (!elem.value) return; if (!elem.value) return;
elem.value.addEventListener('load', async () => { elem.value.addEventListener('before-render', () => {
if (!elem.value) return; if (!elem.value) return;
// Delete the initial load banner // Extract internals of model-viewer in order to hack unsupported features
let banner = elem.value.querySelector('.initial-load-banner');
if (banner) banner.remove();
// Set the scene and renderer
scene.value = elem.value[$scene] as ModelScene; scene.value = elem.value[$scene] as ModelScene;
renderer.value = elem.value[$renderer] as Renderer; renderer.value = elem.value[$renderer] as Renderer;
// Emit the load event controls.value = (elem.value as any)[$controls] as SmoothControls;
emit('load') // Recover the camera position if it was set before
if (lastCameraTargetPosition) {
// console.log("RESTORING camera position?", lastCameraTargetPosition);
scene.value.setTarget(-lastCameraTargetPosition.x, -lastCameraTargetPosition.y, -lastCameraTargetPosition.z);
scene.value.jumpToGoal(); // Avoid move animation
}
(async () => {
let tries = 0
while (tries++ < 25) {
if (!lastCameraZoom || !elem.value?.getCameraOrbit()?.radius) break;
let change = lastCameraZoom - elem.value.getCameraOrbit().radius;
//console.log("Zooming to", lastCameraZoom, "from", elem.value.getCameraOrbit().radius, "change", change);
if (Math.abs(change) < 0.001) break;
elem.value.zoom(-Math.sign(change) * (Math.pow(Math.abs(change) + 1, 0.9) - 1)); // Arbitrary, experimental
elem.value.jumpCameraToGoal();
await elem.value.updateComplete;
}
//console.log("Ready to save!")
lastCameraUrl = props.src.toString();
})();
}); });
elem.value.addEventListener('camera-change', onCameraChange); elem.value.addEventListener('camera-change', onCameraChange);
elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress)); elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress));
}); });
function onCameraChange() {
// Remember the camera position to keep it in case of scene changes
if (scene.value && props.src.toString() == lastCameraUrl) { // Don't overwrite with initial unwanted positions
lastCameraTargetPosition = scene.value.target.position.clone();
lastCameraZoom = elem.value?.getCameraOrbit()?.radius;
//console.log("Saving camera?", lastCameraTargetPosition, lastCameraZoom);
}
// Also need to update the SVG overlay
for (let lineId in lines.value) {
onCameraChangeLine(lineId as any);
}
}
// Handles loading the events for <model-viewer>'s slotted progress bar // Handles loading the events for <model-viewer>'s slotted progress bar
const progressBar = ref<HTMLElement | null>(null); const progressBar = ref<HTMLElement | null>(null);
const updateBar = ref<HTMLElement | null>(null); const updateBar = ref<HTMLElement | null>(null);
@@ -66,6 +99,17 @@ const onProgress = (totalProgress: number) => {
}, 1000); }, 1000);
}; };
const poster = ref<string>("")
const setPosterText = (newText: string) => {
poster.value = "data:image/svg+xml;charset=utf-8;base64," + btoa(
'<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg" fill="gray">' +
'<text x="50%" y="0%" dominant-baseline="middle" text-anchor="middle" font-size="48px">' +
newText +
'</text>' +
'</svg>')
}
setPosterText("Loading...")
class Line3DData { class Line3DData {
startHotspot: HTMLElement = document.body startHotspot: HTMLElement = document.body
endHotspot: HTMLElement = document.body endHotspot: HTMLElement = document.body
@@ -118,13 +162,6 @@ function removeLine3D(id: number): boolean {
return true; 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); let svg = ref<SVGElement | null>(null);
function onCameraChangeLine(lineId: number) { function onCameraChangeLine(lineId: number) {
@@ -160,54 +197,47 @@ function entries(lines: { [id: number]: Line3DData }): [string, Line3DData][] {
return Object.entries(lines); return Object.entries(lines);
} }
defineExpose({elem, onElemReady, scene, renderer, addLine3D, removeLine3D, onProgress}); defineExpose({elem, onElemReady, scene, renderer, controls, addLine3D, removeLine3D, onProgress, setPosterText});
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!; let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
watch(disableTap, (value) => { watch(disableTap, (newDisableTap) => {
// Rerender not auto triggered? This works anyway... if (elem.value) elem.value.disableTap = newDisableTap;
if (value) elem.value?.setAttribute('disable-tap', '');
else elem.value?.removeAttribute('disable-tap');
}); });
</script> </script>
<template> <template>
<!-- The main 3D model viewer --> <!-- The main 3D model viewer -->
<model-viewer ref="elem" style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls <model-viewer ref="elem" :ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :autoplay="settings.autoplay"
camera-orbit="30deg 75deg auto" max-camera-orbit="Infinity 180deg auto" :environment-image="settings.background" :exposure="settings.exposure"
min-camera-orbit="-Infinity 0deg 5%" :disable-tap="disableTap" :exposure="settings.exposure" :orbit-sensitivity="settings.orbitSensitivity" :pan-sensitivity="settings.panSensitivity"
:shadow-intensity="settings.shadowIntensity" interaction-prompt="none" :autoplay="settings.autoplay" :poster="poster" :shadow-intensity="settings.shadowIntensity" :skybox-image="settings.background"
:ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :skybox-image="settings.background" :src="props.src" :zoom-sensitivity="settings.zoomSensitivity" alt="The 3D model(s)" camera-controls
:environment-image="settings.background"> camera-orbit="30deg 75deg auto" interaction-prompt="none" max-camera-orbit="Infinity 180deg auto"
min-camera-orbit="-Infinity 0deg 5%" style="width: 100%; height: 100%">
<slot></slot> <slot></slot>
<!-- Display some information during initial load --> <!-- Add a progress bar to the top of the model viewer -->
<div class="annotation initial-load-banner"> <div ref="progressBar" slot="progress-bar" class="progress-bar">
Trying to load models from... <div ref="updateBar" class="update-bar"/>
<v-list v-for="src in settings.preload" :key="src">
<v-list-item>{{ src }}</v-list-item>
</v-list>
<!-- Too much idle CPU usage: <loading></loading> -->
</div>
<!-- Customize the progress bar -->
<div class="progress-bar" slot="progress-bar" ref="progressBar">
<div class="update-bar" ref="updateBar"/>
</div> </div>
</model-viewer> </model-viewer>
<!-- The SVG overlay for fake 3D lines attached to the model --> <!-- The SVG overlay for fake 3D lines attached to the model -->
<div class="overlay-svg-wrapper"> <div class="overlay-svg-wrapper">
<svg ref="svg" class="overlay-svg" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg"> <svg ref="svg" class="overlay-svg" height="100%" width="100%" xmlns="http://www.w3.org/2000/svg">
<g v-for="[lineId, line] in entries(lines)" :key="lineId"> <g v-for="[lineId, line] in entries(lines)" :key="lineId">
<line :x1="line.start2D[0]" :y1="line.start2D[1]" :x2="line.end2D[0]" <line :x1="line.start2D[0]" :x2="line.end2D[0]" :y1="line.start2D[1]"
:y2="line.end2D[1]" v-bind="line.lineAttrs"/> :y2="line.end2D[1]" v-bind="line.lineAttrs"/>
<g v-if="line.centerText !== undefined"> <g v-if="line.centerText !== undefined">
<rect :x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4" <rect v-if="line.centerText"
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2" :height="line.centerTextSize[1] + 4"
:width="line.centerTextSize[0] + 8" :height="line.centerTextSize[1] + 4" :width="line.centerTextSize[0] + 8"
fill="gray" fill-opacity="0.75" rx="4" ry="4" stroke="black" v-if="line.centerText"/> :x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
<text :x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2" fill="gray"
text-anchor="middle" dominant-baseline="middle" font-size="16" fill="black" fill-opacity="0.75" rx="4" ry="4" stroke="black"/>
:class="'line' + lineId + '_text'" v-if="line.centerText"> <text v-if="line.centerText" :class="'line' + lineId + '_text'"
:x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
dominant-baseline="middle" fill="black"
font-size="16" text-anchor="middle">
{{ line.centerText }} {{ line.centerText }}
</text> </text>
</g> </g>
@@ -242,17 +272,6 @@ watch(disableTap, (value) => {
pointer-events: none; pointer-events: none;
} }
.initial-load-banner {
width: 300px;
margin: auto;
margin-top: 3em;
overflow: hidden;
}
.initial-load-banner .v-list-item {
overflow: hidden;
}
.progress-bar { .progress-bar {
display: block; display: block;
pointer-events: none; pointer-events: none;

View File

@@ -1,6 +1,6 @@
{ {
"name": "yet-another-cad-viewer", "name": "yet-another-cad-viewer",
"version": "0.8.5", "version": "0.8.8",
"description": "", "description": "",
"license": "MIT", "license": "MIT",
"private": true, "private": true,
@@ -26,11 +26,11 @@
"three-mesh-bvh": "^0.7.3", "three-mesh-bvh": "^0.7.3",
"three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo", "three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo",
"vue": "^3.4.21", "vue": "^3.4.21",
"vuetify": "^3.5.11" "vuetify": "^3.5.13"
}, },
"devDependencies": { "devDependencies": {
"@tsconfig/node20": "^20.1.3", "@tsconfig/node20": "^20.1.4",
"@types/node": "^20.11.30", "@types/node": "^20.12.2",
"@types/three": "^0.160.0", "@types/three": "^0.160.0",
"@vitejs/plugin-vue": "^5.0.3", "@vitejs/plugin-vue": "^5.0.3",
"@vitejs/plugin-vue-jsx": "^3.1.0", "@vitejs/plugin-vue-jsx": "^3.1.0",
@@ -39,9 +39,9 @@
"commander": "^12.0.0", "commander": "^12.0.0",
"generate-license-file": "^3.0.1", "generate-license-file": "^3.0.1",
"npm-run-all2": "^6.1.1", "npm-run-all2": "^6.1.1",
"terser": "^5.29.2", "terser": "^5.30.0",
"typescript": "~5.4.3", "typescript": "~5.4.3",
"vite": "^5.2.6", "vite": "^5.2.7",
"vue-tsc": "^2.0.7" "vue-tsc": "^2.0.7"
} }
} }

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "yacv-server" name = "yacv-server"
version = "0.8.5" version = "0.8.8"
description = "Yet Another CAD Viewer (server)" description = "Yet Another CAD Viewer (server)"
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"] authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
license = "MIT" license = "MIT"

View File

@@ -26,9 +26,10 @@ export default defineConfig({
} }
}, },
build: { build: {
assetsDir: '.', assetsDir: '.', // Support deploying to a subdirectory using relative URLs
cssCodeSplit: false, // Small enough to inline cssCodeSplit: false, // Small enough to inline
chunkSizeWarningLimit: 550, // Three.js is big. Draco is even bigger but not likely to be used. chunkSizeWarningLimit: 550, // Three.js is big. Draco is even bigger but not likely to be used.
sourcemap: true, // For debugging production
}, },
define: { define: {
__APP_NAME__: JSON.stringify(name), __APP_NAME__: JSON.stringify(name),

View File

@@ -2,9 +2,13 @@
Utilities to work with CAD objects Utilities to work with CAD objects
""" """
import hashlib import hashlib
import io
import re
from typing import Optional, Union, Tuple from typing import Optional, Union, Tuple
from OCP.TopExp import TopExp
from OCP.TopLoc import TopLoc_Location from OCP.TopLoc import TopLoc_Location
from OCP.TopTools import TopTools_IndexedMapOfShape
from OCP.TopoDS import TopoDS_Shape from OCP.TopoDS import TopoDS_Shape
from build123d import Compound, Shape from build123d import Compound, Shape
@@ -14,7 +18,7 @@ CADCoreLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and
CADLike = Union[CADCoreLike, any] # build123d and cadquery types CADLike = Union[CADCoreLike, any] # build123d and cadquery types
def get_shape(obj: CADLike, error: bool = True, in_iter: bool = False) -> Optional[CADCoreLike]: def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
""" Get the shape of a CAD-like object """ """ Get the shape of a CAD-like object """
# Try to grab a shape if a different type of object was passed # Try to grab a shape if a different type of object was passed
@@ -42,13 +46,22 @@ def get_shape(obj: CADLike, error: bool = True, in_iter: bool = False) -> Option
return obj return obj
# Handle iterables like Build123d ShapeList by extracting all sub-shapes and making a compound # Handle iterables like Build123d ShapeList by extracting all sub-shapes and making a compound
if not in_iter: if isinstance(obj, list) or isinstance(obj, tuple) or isinstance(obj, set) or isinstance(obj, dict):
try: try:
obj_iter = iter(obj) if isinstance(obj, dict):
obj_iter = iter(obj.values())
else:
obj_iter = iter(obj)
# print(obj, ' -> ', obj_iter) # print(obj, ' -> ', obj_iter)
shapes_raw = [get_shape(sub_obj, error=False, in_iter=True) for sub_obj in obj_iter] shapes_raw = [get_shape(sub_obj, error=False) for sub_obj in obj_iter]
shapes_bd = [Shape(shape) for shape in shapes_raw if shape is not None] # Silently drop non-shapes
return get_shape(Compound(shapes_bd), error) shapes_raw_filtered = [shape for shape in shapes_raw if shape is not None]
if len(shapes_raw_filtered) > 0: # Continue if we found at least one shape
# Sorting is required to improve hashcode consistency
shapes_raw_filtered_sorted = sorted(shapes_raw_filtered, key=lambda x: _hashcode(x))
# Build a single compound shape
shapes_bd = [Shape(shape) for shape in shapes_raw_filtered_sorted if shape is not None]
return get_shape(Compound(shapes_bd), error)
except TypeError: except TypeError:
pass pass
@@ -149,3 +162,32 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
# Return the GLTF binary blob and the suggested name of the image # Return the GLTF binary blob and the suggested name of the image
return b''.join(mgr.build().save_to_bytes()), name return b''.join(mgr.build().save_to_bytes()), name
def _hashcode(obj: Union[bytes, CADCoreLike], **extras) -> str:
"""Utility to compute the STABLE hash code of a shape"""
# NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program
# This is best-effort and not guaranteed to be unique
hasher = hashlib.md5(usedforsecurity=False)
for k, v in extras.items():
hasher.update(str(k).encode())
hasher.update(str(v).encode())
if isinstance(obj, bytes):
hasher.update(obj)
elif isinstance(obj, TopLoc_Location):
sub_data = io.BytesIO()
obj.DumpJson(sub_data)
hasher.update(sub_data.getvalue())
elif isinstance(obj, TopoDS_Shape):
map_of_shapes = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(obj, map_of_shapes)
for i in range(1, map_of_shapes.Extent() + 1):
sub_shape = map_of_shapes.FindKey(i)
sub_data = io.BytesIO()
TopoDS_Shape.DumpJson(sub_shape, sub_data)
val = sub_data.getvalue()
val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address
hasher.update(val)
else:
raise ValueError(f'Cannot hash object of type {type(obj)}')
return hasher.hexdigest()

View File

@@ -1,14 +1,9 @@
import hashlib from typing import List, Dict, Tuple
import io
import re
from typing import List, Dict, Tuple, Union
from OCP.BRep import BRep_Tool from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GCPnts import GCPnts_TangentialDeflection from OCP.GCPnts import GCPnts_TangentialDeflection
from OCP.TopExp import TopExp
from OCP.TopLoc import TopLoc_Location from OCP.TopLoc import TopLoc_Location
from OCP.TopTools import TopTools_IndexedMapOfShape
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex
from build123d import Shape, Vertex, Face, Location from build123d import Shape, Vertex, Face, Location
from pygltflib import GLTF2 from pygltflib import GLTF2
@@ -130,30 +125,3 @@ def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[Topo
mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces)) mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces))
def _hashcode(obj: Union[bytes, TopoDS_Shape], **extras) -> str:
"""Utility to compute the hash code of a shape recursively without the need to tessellate it"""
# NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program
# This is best-effort and not guaranteed to be unique
hasher = hashlib.md5(usedforsecurity=False)
for k, v in extras.items():
hasher.update(str(k).encode())
hasher.update(str(v).encode())
if isinstance(obj, bytes):
hasher.update(obj)
elif isinstance(obj, TopLoc_Location):
sub_data = io.BytesIO()
obj.DumpJson(sub_data)
hasher.update(sub_data.getvalue())
elif isinstance(obj, TopoDS_Shape):
map_of_shapes = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(obj, map_of_shapes)
for i in range(1, map_of_shapes.Extent() + 1):
sub_shape = map_of_shapes.FindKey(i)
sub_data = io.BytesIO()
TopoDS_Shape.DumpJson(sub_shape, sub_data)
val = sub_data.getvalue()
val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address
hasher.update(val)
else:
raise ValueError(f'Cannot hash object of type {type(obj)}')
return hasher.hexdigest()

View File

@@ -23,7 +23,8 @@ from yacv_server.myhttp import HTTPHandler
from yacv_server.mylogger import logger from yacv_server.mylogger import logger
from yacv_server.pubsub import BufferedPubSub from yacv_server.pubsub import BufferedPubSub
from yacv_server.rwlock import RWLock from yacv_server.rwlock import RWLock
from yacv_server.tessellate import _hashcode, tessellate from yacv_server.tessellate import tessellate
from yacv_server.cad import _hashcode
@dataclass_json @dataclass_json

View File

@@ -777,10 +777,10 @@
"@sigstore/core" "^1.0.0" "@sigstore/core" "^1.0.0"
"@sigstore/protobuf-specs" "^0.3.0" "@sigstore/protobuf-specs" "^0.3.0"
"@tsconfig/node20@^20.1.3": "@tsconfig/node20@^20.1.4":
version "20.1.3" version "20.1.4"
resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.3.tgz#b3b4cf785e1b390a6ab48a68aa594a25960d2fe8" resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.4.tgz#3457d42eddf12d3bde3976186ab0cd22b85df928"
integrity sha512-XeWn6Gms5MaQWdj+C4fuxuo/Icy8ckh+BwAIijhX2LKRHHt1OuctLLLlB0F4EPi55m2IUJNTnv8FH9kSBI7Ogw== integrity sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==
"@tufjs/canonical-json@2.0.0": "@tufjs/canonical-json@2.0.0":
version "2.0.0" version "2.0.0"
@@ -805,10 +805,10 @@
resolved "https://registry.yarnpkg.com/@types/ndarray/-/ndarray-1.0.14.tgz#96b28c09a3587a76de380243f87bb7a2d63b4b23" resolved "https://registry.yarnpkg.com/@types/ndarray/-/ndarray-1.0.14.tgz#96b28c09a3587a76de380243f87bb7a2d63b4b23"
integrity sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg== integrity sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==
"@types/node@^20.11.30": "@types/node@^20.12.2":
version "20.11.30" version "20.12.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.30.tgz#9c33467fc23167a347e73834f788f4b9f399d66f" resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.2.tgz#9facdd11102f38b21b4ebedd9d7999663343d72e"
integrity sha512-dHM6ZxwlmuZaRmUPfv1p+KrdD1Dci04FbdEm/9wEMouFqxYoFl5aMkt0VMAUtYRQDyYvD41WJLukhq/ha3YuTw== integrity sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==
dependencies: dependencies:
undici-types "~5.26.4" undici-types "~5.26.4"
@@ -2410,7 +2410,7 @@ postcss-selector-parser@^6.0.10:
cssesc "^3.0.0" cssesc "^3.0.0"
util-deprecate "^1.0.2" util-deprecate "^1.0.2"
postcss@^8.4.35, postcss@^8.4.36: postcss@^8.4.35, postcss@^8.4.38:
version "8.4.38" version "8.4.38"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e"
integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A== integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==
@@ -2883,10 +2883,10 @@ tar@^6.1.11, tar@^6.1.2:
mkdirp "^1.0.3" mkdirp "^1.0.3"
yallist "^4.0.0" yallist "^4.0.0"
terser@^5.29.2: terser@^5.30.0:
version "5.29.2" version "5.30.0"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.29.2.tgz#c17d573ce1da1b30f21a877bffd5655dd86fdb35" resolved "https://registry.yarnpkg.com/terser/-/terser-5.30.0.tgz#64cb2af71e16ea3d32153f84d990f9be0cdc22bf"
integrity sha512-ZiGkhUBIM+7LwkNjXYJq8svgkd+QK3UUr0wJqY4MieaezBSAIPgbSPZyIx0idM6XWK5CMzSWa8MJIzmRcB8Caw== integrity sha512-Y/SblUl5kEyEFzhMAQdsxVHh+utAxd4IuRNJzKywY/4uzSogh3G219jqbDDxYu4MXO9CzY3tSEqmZvW6AoEDJw==
dependencies: dependencies:
"@jridgewell/source-map" "^0.3.3" "@jridgewell/source-map" "^0.3.3"
acorn "^8.8.2" acorn "^8.8.2"
@@ -3002,13 +3002,13 @@ validate-npm-package-name@^5.0.0:
dependencies: dependencies:
builtins "^5.0.0" builtins "^5.0.0"
vite@^5.2.6: vite@^5.2.7:
version "5.2.6" version "5.2.7"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.6.tgz#fc2ce309e0b4871e938cb0aca3b96c422c01f222" resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.7.tgz#e1b8a985eb54fcb9467d7f7f009d87485016df6e"
integrity sha512-FPtnxFlSIKYjZ2eosBQamz4CbyrTizbZ3hnGJlh/wMtCrlp1Hah6AzBLjGI5I2urTfNnpovpHdrL6YRuBOPnCA== integrity sha512-k14PWOKLI6pMaSzAuGtT+Cf0YmIx12z9YGon39onaJNy8DLBfBJrzg9FQEmkAM5lpHBZs9wksWAsyF/HkpEwJA==
dependencies: dependencies:
esbuild "^0.20.1" esbuild "^0.20.1"
postcss "^8.4.36" postcss "^8.4.38"
rollup "^4.13.0" rollup "^4.13.0"
optionalDependencies: optionalDependencies:
fsevents "~2.3.3" fsevents "~2.3.3"
@@ -3041,10 +3041,10 @@ vue@^3.4.21:
"@vue/server-renderer" "3.4.21" "@vue/server-renderer" "3.4.21"
"@vue/shared" "3.4.21" "@vue/shared" "3.4.21"
vuetify@^3.5.11: vuetify@^3.5.13:
version "3.5.11" version "3.5.13"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.5.11.tgz#9e5b628544e736de0b7f236b704539d544588152" resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.5.13.tgz#24a45d19ce5dcf71b2653f0bcf3ea91edf1f406c"
integrity sha512-us5I0jyFwIQYG4v41PFmVMkoc/oJddVT4C2RFjJTI99ttigbQ92gsTeG5SB8BPfmfnUS4paR5BedZwk6W3KlJw== integrity sha512-3ZyIoHgB2GR87ojIpqNwkkRXlUNTEKh83fjUuQ1hOKdTXzEuZXBgtfUt9kp4WOVnYILGdZKWTJ6gv8nXOa/tZA==
walk-up-path@^3.0.1: walk-up-path@^3.0.1:
version "3.0.1" version "3.0.1"