mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
playground: fully working (snapshots left as future work) and other quality of life improvements
This commit is contained in:
@@ -1360,6 +1360,42 @@ THE SOFTWARE.
|
|||||||
|
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
|
- js-base64@3.7.7
|
||||||
|
|
||||||
|
This package contains the following license:
|
||||||
|
|
||||||
|
Copyright (c) 2014, Dan Kogai
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of {{{project}}} nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
-----------
|
||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- estree-walker@2.0.2
|
- estree-walker@2.0.2
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import {NetworkManager, NetworkUpdateEvent, NetworkUpdateEventModel} from "./mis
|
|||||||
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";
|
||||||
import {mdiPlus} from '@mdi/js'
|
import {mdiCube, mdiPlus, mdiScriptTextPlay} from '@mdi/js'
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
import {toBuffer} from "./misc/gltf.ts";
|
|
||||||
|
|
||||||
// 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({
|
||||||
@@ -22,7 +22,7 @@ const ModelViewerWrapper = defineAsyncComponent({
|
|||||||
delay: 0,
|
delay: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > 1200);
|
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > window.innerHeight);
|
||||||
|
|
||||||
const sceneUrl = ref("")
|
const sceneUrl = ref("")
|
||||||
const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
|
const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
|
||||||
@@ -81,6 +81,7 @@ let networkMgr = new NetworkManager();
|
|||||||
networkMgr.addEventListener('update-early',
|
networkMgr.addEventListener('update-early',
|
||||||
(e) => viewer.value?.onProgress((e as CustomEvent<Array<any>>).detail.length * 0.01));
|
(e) => viewer.value?.onProgress((e as CustomEvent<Array<any>>).detail.length * 0.01));
|
||||||
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
|
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
|
||||||
|
let preloadingModels = ref<Array<string>>([]);
|
||||||
(async () => { // Start loading all configured models ASAP
|
(async () => { // Start loading all configured models ASAP
|
||||||
let sett = await settings();
|
let sett = await settings();
|
||||||
if (sett.preload.length > 0) {
|
if (sett.preload.length > 0) {
|
||||||
@@ -91,17 +92,16 @@ networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUp
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
for (let model of sett.preload) {
|
for (let model of sett.preload) {
|
||||||
await networkMgr.load(model);
|
preloadingModels.value.push(model);
|
||||||
|
let removeFromPreloadingModels = () => {
|
||||||
|
preloadingModels.value = preloadingModels.value.filter((m) => m !== model);
|
||||||
|
};
|
||||||
|
networkMgr.load(model).then(removeFromPreloadingModels).catch((e) => {
|
||||||
|
removeFromPreloadingModels()
|
||||||
|
console.error("Error preloading model", model, e);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} else { // Skip to interface without models (useful for playground mode)
|
} // else No preloaded models (useful for playground mode)
|
||||||
console.debug("Showing empty gltf document to load the interface without models.");
|
|
||||||
// FIXME: Empty document breaks the playground-loaded models (using preload seems to fix this, maybe __helpers issue?)
|
|
||||||
let emptyDoc = new Document();
|
|
||||||
emptyDoc.createScene();
|
|
||||||
let buffer = await toBuffer(emptyDoc);
|
|
||||||
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
|
|
||||||
sceneUrl.value = URL.createObjectURL(blob);
|
|
||||||
}
|
|
||||||
})();
|
})();
|
||||||
|
|
||||||
async function loadModelManual() {
|
async function loadModelManual() {
|
||||||
@@ -115,7 +115,28 @@ async function loadModelManual() {
|
|||||||
|
|
||||||
<!-- 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 ref="viewer" :src="sceneUrl"/>
|
<model-viewer-wrapper v-if="sceneDocument.getRoot().listMeshes().length > 0" ref="viewer" :src="sceneUrl"/>
|
||||||
|
<!-- A nice no model loaded alternative to avoid breaking model-viewer-wrapper -->
|
||||||
|
<div v-else style="height: 100%; overflow-y: auto">
|
||||||
|
<v-toolbar-title class="text-center ma-16 text-h5">No model loaded</v-toolbar-title>
|
||||||
|
<v-btn @click="() => tools?.openPlayground()" class="mx-auto d-block my-4">
|
||||||
|
<svg-icon :path="mdiScriptTextPlay" type="mdi"/> Open playground...
|
||||||
|
</v-btn>
|
||||||
|
<v-btn @click="networkMgr.load('https://yeicor-3d.github.io/yet-another-cad-viewer/logo.glb')"
|
||||||
|
class="mx-auto d-block my-4">
|
||||||
|
<svg-icon :path="mdiCube" type="mdi"/> Load demo model...
|
||||||
|
</v-btn>
|
||||||
|
<v-btn @click="loadModelManual" class="mx-auto d-block my-4">
|
||||||
|
<svg-icon :path="mdiPlus" type="mdi"/> Load model manually...
|
||||||
|
</v-btn>
|
||||||
|
<span v-if="preloadingModels.length > 0" class="d-block text-center my-16">
|
||||||
|
<span class="d-block text-center text-h6">Still trying to load the following:</span>
|
||||||
|
<span class="d-block text-center" v-for="(model, index) in preloadingModels" :key="index">
|
||||||
|
{{ model }}<span v-if="index < preloadingModels.length - 1">, </span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
</div>
|
||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
<!-- The left collapsible sidebar has the list of models -->
|
<!-- The left collapsible sidebar has the list of models -->
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {mdiLockQuestion} from "@mdi/js";
|
import {mdiLockQuestion} from "@mdi/js";
|
||||||
import {VBtn, VTooltip} from "vuetify/lib/components/index.mjs";
|
import {VBtn, VTooltip} from "vuetify/lib/components/index.mjs";
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from "@jamescoyle/vue-icon";
|
import SvgIcon from "@jamescoyle/vue-icon";
|
||||||
|
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
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'
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
|
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
|
||||||
import {unpartition, mergeDocuments} from "@gltf-transform/functions";
|
import {mergeDocuments, unpartition} from "@gltf-transform/functions";
|
||||||
|
|
||||||
let io = new WebIO();
|
let io = new WebIO();
|
||||||
export let extrasNameKey = "__yacv_name";
|
export let extrasNameKey = "__yacv_name";
|
||||||
@@ -34,7 +34,9 @@ export async function mergePartial(url: string, name: string, document: Document
|
|||||||
if (alreadyTried["draco"]) throw e; else alreadyTried["draco"] = true;
|
if (alreadyTried["draco"]) throw e; else alreadyTried["draco"] = true;
|
||||||
// WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?)
|
// WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?)
|
||||||
let {KHRDracoMeshCompression} = await import("@gltf-transform/extensions")
|
let {KHRDracoMeshCompression} = await import("@gltf-transform/extensions")
|
||||||
|
// @ts-expect-error
|
||||||
let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js");
|
let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js");
|
||||||
|
// @ts-expect-error
|
||||||
let dracoEncoderWeb = await import("three/examples/jsm/libs/draco/draco_encoder.js");
|
let dracoEncoderWeb = await import("three/examples/jsm/libs/draco/draco_encoder.js");
|
||||||
io.registerExtensions([KHRDracoMeshCompression])
|
io.registerExtensions([KHRDracoMeshCompression])
|
||||||
.registerDependencies({
|
.registerDependencies({
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
|
|||||||
* The grid is built as a box of triangles (representing lines) looking to the inside of the box.
|
* The grid is built as a box of triangles (representing lines) looking to the inside of the box.
|
||||||
* This ensures that only the back of the grid is always visible, regardless of the camera position.
|
* This ensures that only the back of the grid is always visible, regardless of the camera position.
|
||||||
*/
|
*/
|
||||||
export async function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4, divisions = 10) {
|
export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4, divisions = 10) {
|
||||||
// Create transformed positions for the inner faces of the box
|
// Create transformed positions for the inner faces of the box
|
||||||
let allPositions: number[] = [];
|
let allPositions: number[] = [];
|
||||||
let allIndices: number[] = [];
|
let allIndices: number[] = [];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {type Ref} from 'vue';
|
import {type Ref} from 'vue';
|
||||||
import {Document} from '@gltf-transform/core';
|
import {Buffer, Document, Scene} from '@gltf-transform/core';
|
||||||
import {extrasNameKey, extrasNameValueHelpers, mergeFinalize, mergePartial, removeModel, toBuffer} from "./gltf";
|
import {extrasNameKey, extrasNameValueHelpers, mergeFinalize, mergePartial, removeModel, toBuffer} from "./gltf";
|
||||||
import {newAxes, newGridBox} from "./helpers";
|
import {newAxes, newGridBox} from "./helpers";
|
||||||
import {Vector3} from "three/src/math/Vector3.js"
|
import {Vector3} from "three/src/math/Vector3.js"
|
||||||
@@ -80,7 +80,19 @@ export class SceneMgr {
|
|||||||
|
|
||||||
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
|
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
|
||||||
let bb = SceneMgr.getBoundingBox(document);
|
let bb = SceneMgr.getBoundingBox(document);
|
||||||
if (!bb) return document;
|
if (!bb) return document; // Empty document, no helpers to show
|
||||||
|
|
||||||
|
// If only the helpers remain, go back to the empty scene
|
||||||
|
let noOtherModels = true;
|
||||||
|
for (let elem of document.getGraph().listEdges().map(e => e.getChild())) {
|
||||||
|
if (elem.getExtras() && !(elem instanceof Scene) && !(elem instanceof Buffer) &&
|
||||||
|
elem.getExtras()[extrasNameKey] !== extrasNameValueHelpers) {
|
||||||
|
// There are other elements in the document, so we can show the helpers
|
||||||
|
noOtherModels = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (noOtherModels) return await removeModel(extrasNameValueHelpers, document);
|
||||||
|
|
||||||
// Create the helper axes and grid box
|
// Create the helper axes and grid box
|
||||||
let helpersDoc = new Document();
|
let helpersDoc = new Document();
|
||||||
|
|||||||
@@ -43,21 +43,37 @@ export async function settings() {
|
|||||||
"12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="),
|
"12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="),
|
||||||
|
|
||||||
// Playground settings
|
// Playground settings
|
||||||
code: "", // Automatically loaded and executed code for the playground
|
pg_code: "", // Automatically loaded and executed code for the playground
|
||||||
|
pg_code_url: "", // URL to load the code from (overrides pg_code)
|
||||||
|
pg_opacity_loading: -1, // Opacity of the code during first load and run (< 0 is 0.0 if preload and 0.9 if not)
|
||||||
|
pg_opacity_loaded: 0.9, // Opacity of the code after it has been run for the first time
|
||||||
};
|
};
|
||||||
|
|
||||||
// Auto-override any settings from the URL
|
// Auto-override any settings from the URL (either GET parameters or hash)
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.forEach((value, key) => {
|
url.searchParams.forEach((value, key) => {
|
||||||
if (key in settings) (settings as any)[key] = parseSetting(key, value, settings);
|
if (key in settings) (settings as any)[key] = parseSetting(key, value, settings);
|
||||||
})
|
})
|
||||||
|
if (url.hash.length > 0) { // Hash has bigger limits as it is not sent to the server
|
||||||
|
const hash = url.hash.slice(1);
|
||||||
|
const hashParams = new URLSearchParams(hash);
|
||||||
|
hashParams.forEach((value, key) => {
|
||||||
|
if (key in settings) (settings as any)[key] = parseSetting(key, value, settings);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-decompress the code
|
// Grab the code from the URL if it is set
|
||||||
if (settings.code.length > 0) {
|
if (settings.pg_code_url.length > 0) {
|
||||||
|
// If the code URL is set, override the code
|
||||||
try {
|
try {
|
||||||
settings.code = ungzip(b66Decode(settings.code), {to: 'string'});
|
const response = await fetch(settings.pg_code_url);
|
||||||
|
if (response.ok) {
|
||||||
|
settings.pg_code = await response.text();
|
||||||
|
} else {
|
||||||
|
console.warn("Failed to load code from URL:", settings.pg_code_url);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("Failed to decompress code (assuming raw code):", error);
|
console.error("Error fetching code from URL:", settings.pg_code_url, error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,7 +81,7 @@ export async function settings() {
|
|||||||
for (let i = 0; i < settings.preload.length; i++) {
|
for (let i = 0; i < settings.preload.length; i++) {
|
||||||
let url = settings.preload[i];
|
let url = settings.preload[i];
|
||||||
if (url === '<auto>') {
|
if (url === '<auto>') {
|
||||||
if (settings.code != "") { // <auto> means no preload URL if code is set
|
if (settings.pg_code != "") { // <auto> means no preload URL if code is set
|
||||||
settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1));
|
settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1));
|
||||||
continue; // Skip this preload URL
|
continue; // Skip this preload URL
|
||||||
}
|
}
|
||||||
@@ -82,6 +98,20 @@ export async function settings() {
|
|||||||
}
|
}
|
||||||
settings.preload[i] = url;
|
settings.preload[i] = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-decompress the code and other playground settings
|
||||||
|
if (settings.pg_code.length > 0) {
|
||||||
|
try {
|
||||||
|
settings.pg_code = ungzip(b66Decode(settings.pg_code), {to: 'string'});
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("Failed to decompress code (assuming raw code):", error);
|
||||||
|
}
|
||||||
|
if (settings.pg_opacity_loading < 0) {
|
||||||
|
// If the opacity is not set, use 0.0 if preload is set, otherwise 0.9
|
||||||
|
settings.pg_opacity_loading = settings.preload.length > 0 ? 0.0 : 0.9;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
settingsCache = settings;
|
settingsCache = settings;
|
||||||
return settings;
|
return settings;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
mdiVectorLine,
|
mdiVectorLine,
|
||||||
mdiVectorRectangle
|
mdiVectorRectangle
|
||||||
} from '@mdi/js'
|
} from '@mdi/js'
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
import {BackSide, FrontSide} from "three/src/constants.js";
|
import {BackSide, FrontSide} from "three/src/constants.js";
|
||||||
import {Box3} from "three/src/math/Box3.js";
|
import {Box3} from "three/src/math/Box3.js";
|
||||||
@@ -34,6 +35,8 @@ 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"
|
||||||
|
import {currentSceneRotation} from "../viewer/lighting.ts";
|
||||||
|
import {Matrix4} from "three/src/math/Matrix4.js";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
meshes: Array<Mesh>,
|
meshes: Array<Mesh>,
|
||||||
@@ -115,7 +118,8 @@ function onWireframeChange(newWireframe: boolean) {
|
|||||||
if (!scene || !sceneModel) return;
|
if (!scene || !sceneModel) return;
|
||||||
sceneModel.traverse((child: MObject3D) => {
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
if (child.userData[extrasNameKey] === modelName) {
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
if (child.material && child.material.wireframe !== newWireframe) {
|
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
|
||||||
|
if (child.material && child.material.wireframe !== newWireframe && childIsFace) {
|
||||||
child.material.wireframe = newWireframe;
|
child.material.wireframe = newWireframe;
|
||||||
child.material.needsUpdate = true;
|
child.material.needsUpdate = true;
|
||||||
}
|
}
|
||||||
@@ -153,10 +157,11 @@ function onClipPlanesChange() {
|
|||||||
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
|
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
|
||||||
let offsetY = bbox.min.y + clipPlaneY.value * (bbox.max.y - bbox.min.y);
|
let offsetY = bbox.min.y + clipPlaneY.value * (bbox.max.y - bbox.min.y);
|
||||||
let offsetZ = bbox.min.z + (1 - clipPlaneZ.value) * (bbox.max.z - bbox.min.z);
|
let offsetZ = bbox.min.z + (1 - clipPlaneZ.value) * (bbox.max.z - bbox.min.z);
|
||||||
|
let rotSceneMatrix = new Matrix4().makeRotationY(currentSceneRotation);
|
||||||
let planes = [
|
let planes = [
|
||||||
new Plane(new Vector3(-1, 0, 0), offsetX),
|
new Plane(new Vector3(-1, 0, 0), offsetX).applyMatrix4(rotSceneMatrix),
|
||||||
new Plane(new Vector3(0, -1, 0), offsetY),
|
new Plane(new Vector3(0, -1, 0), offsetY).applyMatrix4(rotSceneMatrix),
|
||||||
new Plane(new Vector3(0, 0, 1), -offsetZ),
|
new Plane(new Vector3(0, 0, 1), -offsetZ).applyMatrix4(rotSceneMatrix),
|
||||||
];
|
];
|
||||||
if (clipPlaneSwappedX.value) planes[0].negate();
|
if (clipPlaneSwappedX.value) planes[0].negate();
|
||||||
if (clipPlaneSwappedY.value) planes[1].negate();
|
if (clipPlaneSwappedY.value) planes[1].negate();
|
||||||
@@ -227,9 +232,10 @@ function onEdgeWidthChange(newEdgeWidth: number) {
|
|||||||
line.userData.niceLine = line2;
|
line.userData.niceLine = line2;
|
||||||
// line.parent!.remove(line); // Keep it for better raycast and selection!
|
// line.parent!.remove(line); // Keep it for better raycast and selection!
|
||||||
line2.userData.noHit = true;
|
line2.userData.noHit = true;
|
||||||
|
line2.visible = enabledFeatures.value.includes(1);
|
||||||
edgeWidthChangeCleanup.push(() => {
|
edgeWidthChangeCleanup.push(() => {
|
||||||
line2.parent!.remove(line2);
|
line2.parent!.remove(line2);
|
||||||
line.visible = true;
|
line.visible = enabledFeatures.value.includes(1);
|
||||||
props.viewer!!.onElemReady((elem) => {
|
props.viewer!!.onElemReady((elem) => {
|
||||||
elem.removeEventListener('resize', () => resizeListener(elem));
|
elem.removeEventListener('resize', () => resizeListener(elem));
|
||||||
});
|
});
|
||||||
|
|||||||
5
frontend/shims.d.ts
vendored
5
frontend/shims.d.ts
vendored
@@ -1,5 +0,0 @@
|
|||||||
// Avoids typescript error when importing some files
|
|
||||||
declare module '@jamescoyle/vue-icon'
|
|
||||||
declare module 'three-orientation-gizmo/src/OrientationGizmo'
|
|
||||||
declare module 'three/examples/jsm/libs/draco/draco_decoder.js'
|
|
||||||
declare module 'three/examples/jsm/libs/draco/draco_encoder.js'
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
<script lang="ts" setup>
|
<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";
|
||||||
|
// @ts-expect-error
|
||||||
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
||||||
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
|
import {currentSceneRotation} from "../viewer/lighting.ts";
|
||||||
|
|
||||||
// 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";
|
import {Euler} from "three/src/math/Euler.js";
|
||||||
|
|
||||||
(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
|
||||||
|
|
||||||
@@ -48,7 +51,7 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
|
|||||||
axis.direction.y = -axis.direction.y;
|
axis.direction.y = -axis.direction.y;
|
||||||
axis.direction.z = -axis.direction.z;
|
axis.direction.z = -axis.direction.z;
|
||||||
}
|
}
|
||||||
wantedTheta = Math.atan2(axis.direction.x, axis.direction.z);
|
wantedTheta = Math.atan2(axis.direction.x, axis.direction.z) + currentSceneRotation;
|
||||||
wantedPhi = Math.asin(-axis.direction.y) + Math.PI / 2;
|
wantedPhi = Math.asin(-axis.direction.y) + Math.PI / 2;
|
||||||
attempt++;
|
attempt++;
|
||||||
}
|
}
|
||||||
@@ -66,7 +69,12 @@ let gizmo: HTMLElement & { update: () => void }
|
|||||||
|
|
||||||
function updateGizmo() {
|
function updateGizmo() {
|
||||||
if (gizmo.isConnected) {
|
if (gizmo.isConnected) {
|
||||||
|
// HACK: Update camera temporarily to match skybox rotation before updating the gizmo and go back
|
||||||
|
let prevRot = ((gizmo as any).camera).rotation.clone() as Euler;
|
||||||
|
let thetaMat = new Matrix4().makeRotationY(-currentSceneRotation);
|
||||||
|
((gizmo as any).camera).rotation.setFromRotationMatrix(thetaMat.multiply(new Matrix4().makeRotationFromEuler(prevRot)));
|
||||||
gizmo.update();
|
gizmo.update();
|
||||||
|
((gizmo as any).camera).rotation.set(prevRot.x, prevRot.y, prevRot.z);
|
||||||
requestIdleCallback(updateGizmo, {timeout: 250});
|
requestIdleCallback(updateGizmo, {timeout: 250});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import {setupMonaco} from "./monaco.ts";
|
import {setupMonaco} from "./monaco.ts";
|
||||||
import {VueMonacoEditor} from '@guolao/vue-monaco-editor'
|
import {VueMonacoEditor} from '@guolao/vue-monaco-editor'
|
||||||
import {nextTick, ref, shallowRef} from "vue";
|
import {nextTick, onMounted, ref, shallowRef} from "vue";
|
||||||
import Loading from "../misc/Loading.vue";
|
import Loading from "../misc/Loading.vue";
|
||||||
import {newPyodideWorker} from "./pyodide-worker-api.ts";
|
import {newPyodideWorker} from "./pyodide-worker-api.ts";
|
||||||
import {mdiCircleOpacity, mdiClose, mdiPlay, mdiReload, mdiShare} from "@mdi/js";
|
import {mdiCircleOpacity, mdiClose, mdiContentSave, mdiFolderOpen, mdiPlay, mdiReload, mdiShare} from "@mdi/js";
|
||||||
import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle} from "vuetify/components";
|
import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle, VTooltip} from "vuetify/components";
|
||||||
|
// @ts-expect-error
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
import {version as pyodideVersion} from "pyodide";
|
import {version as pyodideVersion} from "pyodide";
|
||||||
import {gzip} from 'pako';
|
import {gzip} from 'pako';
|
||||||
import {b66Encode} from "./b66.ts";
|
import {b66Encode} from "./b66.ts";
|
||||||
import {Base64} from 'js-base64'; // More compatible with binary data from python...
|
import {Base64} from 'js-base64'; // More compatible with binary data from python...
|
||||||
import {NetworkUpdateEvent, NetworkUpdateEventModel} from "../misc/network.ts";
|
import {NetworkUpdateEvent, NetworkUpdateEventModel} from "../misc/network.ts";
|
||||||
|
import {settings} from "../misc/settings.ts";
|
||||||
|
|
||||||
const props = defineProps<{ initialCode: string }>();
|
const props = defineProps<{ initialCode: string }>();
|
||||||
const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>()
|
const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>()
|
||||||
@@ -24,6 +26,11 @@ const outputText = ref(``);
|
|||||||
|
|
||||||
function output(text: string) {
|
function output(text: string) {
|
||||||
outputText.value += text; // Append to output
|
outputText.value += text; // Append to output
|
||||||
|
// Avoid too much output, keep it reasonable
|
||||||
|
let max_output = 10000; // 10k characters
|
||||||
|
if (outputText.value.length > max_output) {
|
||||||
|
outputText.value = outputText.value.slice(-max_output); // Keep only the last 10k characters
|
||||||
|
}
|
||||||
console.log(text); // Also log to console
|
console.log(text); // Also log to console
|
||||||
nextTick(() => { // Scroll to bottom
|
nextTick(() => { // Scroll to bottom
|
||||||
const consoleElement = document.querySelector('.playground-console');
|
const consoleElement = document.querySelector('.playground-console');
|
||||||
@@ -65,9 +72,10 @@ async function setupPyodide() {
|
|||||||
await pyodideWorker.asyncRun(`import micropip, asyncio
|
await pyodideWorker.asyncRun(`import micropip, asyncio
|
||||||
micropip.set_index_urls(["https://yeicor.github.io/OCP.wasm", "https://pypi.org/simple"])
|
micropip.set_index_urls(["https://yeicor.github.io/OCP.wasm", "https://pypi.org/simple"])
|
||||||
await (micropip.install("lib3mf"))
|
await (micropip.install("lib3mf"))
|
||||||
micropip.add_mock_package("py-lib3mf", "2.4.1", modules={"py_lib3mf": '''from lib3mf import *'''})
|
micropip.add_mock_package("py-lib3mf", "2.4.1", modules={"py_lib3mf": 'from lib3mf import *'})
|
||||||
await (micropip.install(["https://files.pythonhosted.org/packages/67/25/80be117f39ff5652a4fdbd761b061123c5425e379f4b0a5ece4353215d86/yacv_server-0.10.0a4-py3-none-any.whl"]))
|
await (micropip.install(["https://files.pythonhosted.org/packages/67/25/80be117f39ff5652a4fdbd761b061123c5425e379f4b0a5ece4353215d86/yacv_server-0.10.0a4-py3-none-any.whl"]))
|
||||||
from yacv_server import *`, output, output); // Also import yacv_server here for faster custom code execution
|
from yacv_server import *
|
||||||
|
micropip.add_mock_package("ocp-vscode", "2.8.9", modules={"ocp_vscode": 'from yacv_server import *'})`, output, output); // Also import yacv_server and mock ocp_vscode here for faster custom code execution
|
||||||
running.value = false; // Indicate that Pyodide is ready
|
running.value = false; // Indicate that Pyodide is ready
|
||||||
output("Pyodide worker initialized.\n");
|
output("Pyodide worker initialized.\n");
|
||||||
}
|
}
|
||||||
@@ -116,7 +124,7 @@ function onModelData(modelData: string) {
|
|||||||
if (openBrackets !== 0) throw `Error: Invalid model data received: ${modelData}\n`
|
if (openBrackets !== 0) throw `Error: Invalid model data received: ${modelData}\n`
|
||||||
const jsonData = modelData.slice(0, i + 1); // Extract the JSON part and parse it into the proper class
|
const jsonData = modelData.slice(0, i + 1); // Extract the JSON part and parse it into the proper class
|
||||||
let modelMetadataRaw = JSON.parse(jsonData);
|
let modelMetadataRaw = JSON.parse(jsonData);
|
||||||
const modelMetadata: any = new NetworkUpdateEventModel(modelMetadataRaw.name, "Wait for it...", modelMetadataRaw.hash, modelMetadataRaw.is_remove)
|
const modelMetadata: any = new NetworkUpdateEventModel(modelMetadataRaw.name, "", modelMetadataRaw.hash, modelMetadataRaw.is_remove)
|
||||||
// console.debug(`Model metadata:`, modelMetadata);
|
// console.debug(`Model metadata:`, modelMetadata);
|
||||||
output(`Model metadata: ${JSON.stringify(modelMetadata)}\n`);
|
output(`Model metadata: ${JSON.stringify(modelMetadata)}\n`);
|
||||||
// - Now decode the rest of the model data which is a single base64 encoded glb file (or an empty string)
|
// - Now decode the rest of the model data which is a single base64 encoded glb file (or an empty string)
|
||||||
@@ -135,7 +143,6 @@ function onModelData(modelData: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resetWorker() {
|
function resetWorker() {
|
||||||
code.value = props.initialCode; // Reset code to initial state
|
|
||||||
if (pyodideWorker) {
|
if (pyodideWorker) {
|
||||||
pyodideWorker.terminate(); // Terminate existing worker
|
pyodideWorker.terminate(); // Terminate existing worker
|
||||||
pyodideWorker = null; // Reset worker reference
|
pyodideWorker = null; // Reset worker reference
|
||||||
@@ -146,18 +153,52 @@ function resetWorker() {
|
|||||||
|
|
||||||
function shareLink() {
|
function shareLink() {
|
||||||
const baseUrl = window.location
|
const baseUrl = window.location
|
||||||
const urlParams = new URLSearchParams(baseUrl.search); // Keep all previous URL parameters
|
const urlParams = new URLSearchParams(baseUrl.hash.slice(1)); // Keep all previous URL parameters
|
||||||
urlParams.set('code', b66Encode(gzip(code.value, {level: 9}))); // Compress and encode the code
|
urlParams.set('pg_code', b66Encode(gzip(code.value, {level: 9}))); // Compress and encode the code
|
||||||
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}?${urlParams.toString()}`;
|
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}${baseUrl.search}#${urlParams.toString()}`; // Prefer hash to GET (bigger limits)
|
||||||
output(`Share link ready: ${shareUrl}\n`)
|
output(`Share link ready: ${shareUrl}\n`)
|
||||||
navigator.clipboard.writeText(shareUrl)
|
if (!navigator.clipboard) {
|
||||||
.then(() => output("Link copied to clipboard!\n"))
|
output("Clipboard API not available. Please copy the link manually.\n");
|
||||||
.catch(err => output(`Failed to copy link: ${err}\n`));
|
return;
|
||||||
|
} else {
|
||||||
|
navigator.clipboard.writeText(shareUrl)
|
||||||
|
.then(() => output("Link copied to clipboard!\n"))
|
||||||
|
.catch(err => output(`Failed to copy link: ${err}\n`));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSnapshot() {
|
||||||
|
throw new Error("Not implemented yet!"); // TODO: Implement snapshot saving
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadSnapshot() {
|
||||||
|
throw new Error("Not implemented yet!"); // TODO: Implement snapshot loading
|
||||||
}
|
}
|
||||||
|
|
||||||
const reused = (import.meta as any).hot?.data?.pyodideWorker !== undefined;
|
const reused = (import.meta as any).hot?.data?.pyodideWorker !== undefined;
|
||||||
setupPyodide().then(() => {
|
(async () => {
|
||||||
if (props.initialCode != "" && !reused) runCode();
|
const sett = await settings()
|
||||||
|
if (!reused) opacity.value = sett.pg_opacity_loading
|
||||||
|
await setupPyodide()
|
||||||
|
if (props.initialCode != "" && !reused) await runCode();
|
||||||
|
if (!reused) opacity.value = sett.pg_opacity_loaded
|
||||||
|
})()
|
||||||
|
|
||||||
|
// Add keyboard shortcuts
|
||||||
|
const editorRef = ref<HTMLElement | null>(null);
|
||||||
|
onMounted(() => {
|
||||||
|
if (editorRef.value) {
|
||||||
|
console.log(editorRef.value)
|
||||||
|
editorRef.value.addEventListener('keydown', (event: Event) => {
|
||||||
|
if (!(event instanceof KeyboardEvent)) return; // Ensure event is a KeyboardEvent
|
||||||
|
if (event.key === 'Enter' && event.ctrlKey) {
|
||||||
|
event.preventDefault(); // Prevent default behavior of Enter key
|
||||||
|
runCode(); // Run code on Ctrl+Enter
|
||||||
|
} else if (event.key === 'Escape') {
|
||||||
|
emit('close'); // Close on Escape key
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -165,35 +206,59 @@ setupPyodide().then(() => {
|
|||||||
<v-card class="popup-card"
|
<v-card class="popup-card"
|
||||||
:style="opacity == 0 ? `position: absolute; top: calc(-50vh + 24px); width: calc(100vw - 64px);` : ``">
|
:style="opacity == 0 ? `position: absolute; top: calc(-50vh + 24px); width: calc(100vw - 64px);` : ``">
|
||||||
<v-toolbar class="popup">
|
<v-toolbar class="popup">
|
||||||
<v-toolbar-title>Playground</v-toolbar-title>
|
<v-toolbar-title style="flex: 0 1 auto">Playground</v-toolbar-title>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
|
|
||||||
<svg-icon :path="mdiCircleOpacity" type="mdi"></svg-icon>
|
<span style="display: inline-flex; margin-right: 16px;">
|
||||||
<v-slider v-model="opacity" :max="1" :min="0" :step="0.1"
|
<svg-icon :path="mdiCircleOpacity" type="mdi" style="height: 32px"></svg-icon>
|
||||||
style="max-width: 100px; height: 32px; margin-right: 16px;"></v-slider>
|
<v-slider v-model="opacity" :max="1" :min="0" :step="0.1"
|
||||||
|
style="width: 100px; height: 32px">
|
||||||
|
</v-slider>
|
||||||
|
<v-tooltip activator="parent"
|
||||||
|
location="bottom">Opacity of the editor (0 = hidden, 1 = fully visible)</v-tooltip>
|
||||||
|
</span>
|
||||||
|
|
||||||
<!-- TODO: snapshots... -->
|
<span style="margin-right: -8px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||||
|
<v-btn icon disabled @click="saveSnapshot()">
|
||||||
|
<svg-icon :path="mdiContentSave" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
<v-tooltip activator="parent"
|
||||||
|
location="bottom">Save current state to a snapshot for fast startup (WIP)</v-tooltip>
|
||||||
|
</span>
|
||||||
|
<span style="margin-left: -8px"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||||
|
<v-btn icon disabled @click="loadSnapshot()">
|
||||||
|
<svg-icon :path="mdiFolderOpen" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Load snapshot for fast startup (WIP)</v-tooltip>
|
||||||
|
</span>
|
||||||
|
|
||||||
<v-btn icon @click="resetWorker()">
|
<v-btn icon @click="resetWorker()">
|
||||||
<svg-icon :path="mdiReload" type="mdi"/>
|
<svg-icon :path="mdiReload" type="mdi"/>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Reset Pyodide worker (this forgets all previous state and will
|
||||||
|
take a little while)
|
||||||
|
</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn icon @click="runCode()">
|
<v-btn icon @click="runCode()" :disabled="running">
|
||||||
<svg-icon :path="mdiPlay" type="mdi"/>
|
<svg-icon :path="mdiPlay" type="mdi"/>
|
||||||
|
<Loading v-if="running" style="position: absolute; top: -16%; left: -16%"/><!-- Ugly positioning -->
|
||||||
|
<v-tooltip activator="parent" location="bottom">Run code</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn icon @click="shareLink()">
|
<v-btn icon @click="shareLink()">
|
||||||
<svg-icon :path="mdiShare" type="mdi"/>
|
<svg-icon :path="mdiShare" type="mdi"/>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Share link that auto-runs the code</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
|
||||||
<v-btn icon @click="emit('close')">
|
<v-btn icon @click="emit('close')">
|
||||||
<svg-icon :path="mdiClose" type="mdi"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
|
<v-tooltip activator="parent" location="bottom">Close (Pyodide remains loaded)</v-tooltip>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
<v-card-text class="popup-card-text" :style="opacity == 0 ? `display: none` : ``">
|
<v-card-text class="popup-card-text" :style="opacity == 0 ? `display: none` : ``">
|
||||||
<!-- Only show content if opacity is greater than 0 -->
|
<!-- Only show content if opacity is greater than 0 -->
|
||||||
<div class="playground-container">
|
<div class="playground-container">
|
||||||
<div class="playground-editor">
|
<div class="playground-editor" ref="editorRef">
|
||||||
<VueMonacoEditor v-model:value="code" :theme="editorTheme" :options="MONACO_EDITOR_OPTIONS"
|
<VueMonacoEditor v-model:value="code" :theme="editorTheme" :options="MONACO_EDITOR_OPTIONS"
|
||||||
language="python" @mount="handleMount"/>
|
language="python" @mount="handleMount"/>
|
||||||
</div>
|
</div>
|
||||||
@@ -212,6 +277,10 @@ setupPyodide().then(() => {
|
|||||||
background-color: #00000000; /* Transparent background */
|
background-color: #00000000; /* Transparent background */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.v-toolbar.popup > * {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.popup-card-text {
|
.popup-card-text {
|
||||||
background-color: #1e1e1e; /* Matches the Monaco editor background */
|
background-color: #1e1e1e; /* Matches the Monaco editor background */
|
||||||
opacity: v-bind(opacity);
|
opacity: v-bind(opacity);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {inject, ref, type ShallowRef, watch} from "vue";
|
import {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";
|
||||||
|
// @ts-expect-error
|
||||||
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 {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
@@ -28,7 +29,7 @@ let emit = defineEmits<{ findModel: [string] }>();
|
|||||||
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
||||||
let selectionEnabled = ref(false);
|
let selectionEnabled = ref(false);
|
||||||
let selected = defineModel<Array<SelectionInfo>>({default: []});
|
let selected = defineModel<Array<SelectionInfo>>({default: []});
|
||||||
let highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
let openNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
||||||
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
||||||
let showDistances = ref<Boolean>(true);
|
let showDistances = ref<Boolean>(true);
|
||||||
|
|
||||||
@@ -149,7 +150,7 @@ let mouseUpListener = (event: MouseEvent) => {
|
|||||||
// Return the best hit
|
// Return the best hit
|
||||||
[0] as Intersection<MObject3D> | undefined;
|
[0] as Intersection<MObject3D> | undefined;
|
||||||
|
|
||||||
if (!highlightNextSelection.value[0]) {
|
if (!openNextSelection.value[0]) {
|
||||||
// If we are selecting, toggle the selection or deselect all if no hit
|
// If we are selecting, toggle the selection or deselect all if no hit
|
||||||
let selInfo: SelectionInfo | null = null;
|
let selInfo: SelectionInfo | null = null;
|
||||||
if (hit) selInfo = hitToSelectionInfo(hit);
|
if (hit) selInfo = hitToSelectionInfo(hit);
|
||||||
@@ -171,7 +172,7 @@ let mouseUpListener = (event: MouseEvent) => {
|
|||||||
// Otherwise, highlight the model that owns the hit
|
// Otherwise, highlight the model that owns the hit
|
||||||
emit('findModel', hit.object.userData[extrasNameKey])
|
emit('findModel', hit.object.userData[extrasNameKey])
|
||||||
// And reset the selection mode
|
// And reset the selection mode
|
||||||
toggleHighlightNextSelection()
|
toggleOpenNextSelection()
|
||||||
}
|
}
|
||||||
scene.queueRender() // Force rerender of model-viewer
|
scene.queueRender() // Force rerender of model-viewer
|
||||||
}
|
}
|
||||||
@@ -209,17 +210,17 @@ function toggleSelection() {
|
|||||||
setDisableTap(selectionEnabled.value);
|
setDisableTap(selectionEnabled.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleHighlightNextSelection() {
|
function toggleOpenNextSelection() {
|
||||||
highlightNextSelection.value = [
|
openNextSelection.value = [
|
||||||
!highlightNextSelection.value[0],
|
!openNextSelection.value[0],
|
||||||
highlightNextSelection.value[0] ? highlightNextSelection.value[1] : selectionEnabled.value
|
openNextSelection.value[0] ? openNextSelection.value[1] : selectionEnabled.value
|
||||||
];
|
];
|
||||||
if (highlightNextSelection.value[0]) {
|
if (openNextSelection.value[0]) {
|
||||||
// Reuse selection code to identify the model
|
// Reuse selection code to identify the model
|
||||||
if (!selectionEnabled.value) toggleSelection()
|
if (!selectionEnabled.value) toggleSelection()
|
||||||
} else {
|
} else {
|
||||||
if (selectionEnabled.value !== highlightNextSelection.value[1]) toggleSelection()
|
if (selectionEnabled.value !== openNextSelection.value[1]) toggleSelection()
|
||||||
highlightNextSelection.value = [false, false];
|
openNextSelection.value = [false, false];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,6 +428,10 @@ defineExpose({deselect, updateBoundingBox, updateDistances});
|
|||||||
|
|
||||||
// Add keyboard shortcuts
|
// Add keyboard shortcuts
|
||||||
window.addEventListener('keydown', (event) => {
|
window.addEventListener('keydown', (event) => {
|
||||||
|
if ((event.target as any)?.tagName && ((event.target as any).tagName === 'INPUT' || (event.target as any).tagName === 'TEXTAREA')) {
|
||||||
|
// Ignore key events when an input is focused, except for text inputs
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === 's') {
|
if (event.key === 's') {
|
||||||
if (selectFilter.value == 'Any (S)') toggleSelection();
|
if (selectFilter.value == 'Any (S)') toggleSelection();
|
||||||
else {
|
else {
|
||||||
@@ -455,8 +460,8 @@ window.addEventListener('keydown', (event) => {
|
|||||||
toggleShowBoundingBox();
|
toggleShowBoundingBox();
|
||||||
} else if (event.key === 'd') {
|
} else if (event.key === 'd') {
|
||||||
toggleShowDistances();
|
toggleShowDistances();
|
||||||
} else if (event.key === 'h') {
|
} else if (event.key === 'o') {
|
||||||
toggleHighlightNextSelection();
|
toggleOpenNextSelection();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -474,8 +479,8 @@ window.addEventListener('keydown', (event) => {
|
|||||||
variant="underlined"/>
|
variant="underlined"/>
|
||||||
</template>
|
</template>
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<v-btn :color="highlightNextSelection[0] ? 'surface-light' : ''" icon @click="toggleHighlightNextSelection">
|
<v-btn :color="openNextSelection[0] ? 'surface-light' : ''" icon @click="toggleOpenNextSelection">
|
||||||
<v-tooltip activator="parent">(H)ighlight the next clicked element in the models list</v-tooltip>
|
<v-tooltip activator="parent">(O)pen the next clicked element in the models list</v-tooltip>
|
||||||
<svg-icon :path="mdiFeatureSearch" type="mdi"/>
|
<svg-icon :path="mdiFeatureSearch" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn :color="showBoundingBox ? 'surface-light' : ''" icon @click="toggleShowBoundingBox">
|
<v-btn :color="showBoundingBox ? 'surface-light' : ''" icon @click="toggleShowBoundingBox">
|
||||||
|
|||||||
@@ -13,7 +13,17 @@ import {
|
|||||||
import OrientationGizmo from "./OrientationGizmo.vue";
|
import OrientationGizmo from "./OrientationGizmo.vue";
|
||||||
import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera.js";
|
import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera.js";
|
||||||
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
|
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
|
||||||
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector, mdiScriptTextPlay} from '@mdi/js'
|
import {
|
||||||
|
mdiClose,
|
||||||
|
mdiCrosshairsGps,
|
||||||
|
mdiDownload,
|
||||||
|
mdiGithub,
|
||||||
|
mdiLicense,
|
||||||
|
mdiLightbulb,
|
||||||
|
mdiProjector,
|
||||||
|
mdiScriptTextPlay
|
||||||
|
} from '@mdi/js'
|
||||||
|
// @ts-expect-error
|
||||||
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 Loading from "../misc/Loading.vue";
|
import Loading from "../misc/Loading.vue";
|
||||||
@@ -51,7 +61,7 @@ const sett = ref<any | null>(null);
|
|||||||
const showPlaygroundDialog = ref(false);
|
const showPlaygroundDialog = ref(false);
|
||||||
(async () => {
|
(async () => {
|
||||||
sett.value = await settings();
|
sett.value = await settings();
|
||||||
showPlaygroundDialog.value = sett.value.code != "";
|
showPlaygroundDialog.value = sett.value.pg_code != "";
|
||||||
})();
|
})();
|
||||||
|
|
||||||
let selection: Ref<Array<SelectionInfo>> = ref([]);
|
let selection: Ref<Array<SelectionInfo>> = ref([]);
|
||||||
@@ -130,10 +140,14 @@ function removeObjectSelections(objName: string) {
|
|||||||
selectionComp.value?.updateDistances();
|
selectionComp.value?.updateDistances();
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({removeObjectSelections});
|
defineExpose({removeObjectSelections, openPlayground: () => showPlaygroundDialog.value = true});
|
||||||
|
|
||||||
// Add keyboard shortcuts
|
// Add keyboard shortcuts
|
||||||
window.addEventListener('keydown', (event) => {
|
document.addEventListener('keydown', (event) => {
|
||||||
|
if ((event.target as any)?.tagName && ((event.target as any).tagName === 'INPUT' || (event.target as any).tagName === 'TEXTAREA')) {
|
||||||
|
// Ignore key events when an input is focused, except for text inputs
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === 'p') toggleProjection();
|
if (event.key === 'p') toggleProjection();
|
||||||
else if (event.key === 'c') centerCamera();
|
else if (event.key === 'c') centerCamera();
|
||||||
else if (event.key === 'd') downloadSceneGlb();
|
else if (event.key === 'd') downloadSceneGlb();
|
||||||
@@ -155,6 +169,13 @@ window.addEventListener('keydown', (event) => {
|
|||||||
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
||||||
<svg-icon :path="mdiCrosshairsGps" type="mdi"/>
|
<svg-icon :path="mdiCrosshairsGps" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
|
<span>
|
||||||
|
<v-tooltip activator="parent">To rotate the light hold shift and drag the mouse or use two fingers<br/>
|
||||||
|
Note that this breaks slightly clipping planes for now... (restart to fix)</v-tooltip>
|
||||||
|
<v-btn icon disabled style="background: black;">
|
||||||
|
<svg-icon :path="mdiLightbulb" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
</span>
|
||||||
<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" v-model="selection" :viewer="props.viewer as any"
|
<selection-component ref="selectionComp" v-model="selection" :viewer="props.viewer as any"
|
||||||
@@ -172,7 +193,7 @@ window.addEventListener('keydown', (event) => {
|
|||||||
</template>
|
</template>
|
||||||
<template v-slot:default="{ isActive }">
|
<template v-slot:default="{ isActive }">
|
||||||
<if-not-small-build>
|
<if-not-small-build>
|
||||||
<playground-dialog-content v-if="sett != null" :initial-code="sett.code" @close="isActive.value = false"
|
<playground-dialog-content v-if="sett != null" :initial-code="sett.pg_code" @close="isActive.value = false"
|
||||||
@update-model="(event: NetworkUpdateEvent) => emit('updateModel', event)"/>
|
@update-model="(event: NetworkUpdateEvent) => emit('updateModel', event)"/>
|
||||||
</if-not-small-build>
|
</if-not-small-build>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import {ModelViewerElement} from '@google/model-viewer';
|
|||||||
import {$scene} from "@google/model-viewer/lib/model-viewer-base";
|
import {$scene} from "@google/model-viewer/lib/model-viewer-base";
|
||||||
import {settings} from "../misc/settings.ts";
|
import {settings} from "../misc/settings.ts";
|
||||||
|
|
||||||
|
export let currentSceneRotation = 0; // radians, 0 is the default rotation
|
||||||
|
|
||||||
export async function setupLighting(modelViewer: ModelViewerElement) {
|
export async function setupLighting(modelViewer: ModelViewerElement) {
|
||||||
modelViewer[$scene].environmentIntensity = (await settings()).environmentIntensity;
|
modelViewer[$scene].environmentIntensity = (await settings()).environmentIntensity;
|
||||||
// Code is mostly copied from the example at: https://modelviewer.dev/examples/stagingandcameras/#turnSkybox
|
// Code is mostly copied from the example at: https://modelviewer.dev/examples/stagingandcameras/#turnSkybox
|
||||||
let lastX: number;
|
let lastX: number;
|
||||||
let panning = false;
|
let panning = false;
|
||||||
let skyboxAngle = 0;
|
|
||||||
let radiansPerPixel: number;
|
let radiansPerPixel: number;
|
||||||
|
|
||||||
const startPan = () => {
|
const startPan = () => {
|
||||||
@@ -20,11 +21,11 @@ export async function setupLighting(modelViewer: ModelViewerElement) {
|
|||||||
const updatePan = (thisX: number) => {
|
const updatePan = (thisX: number) => {
|
||||||
const delta = (thisX - lastX) * radiansPerPixel;
|
const delta = (thisX - lastX) * radiansPerPixel;
|
||||||
lastX = thisX;
|
lastX = thisX;
|
||||||
skyboxAngle += delta;
|
currentSceneRotation += delta;
|
||||||
const orbit = modelViewer.getCameraOrbit();
|
const orbit = modelViewer.getCameraOrbit();
|
||||||
orbit.theta += delta;
|
orbit.theta += delta;
|
||||||
modelViewer.cameraOrbit = orbit.toString();
|
modelViewer.cameraOrbit = orbit.toString();
|
||||||
modelViewer.resetTurntableRotation(skyboxAngle);
|
modelViewer.resetTurntableRotation(currentSceneRotation);
|
||||||
modelViewer.jumpCameraToGoal();
|
modelViewer.jumpCameraToGoal();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user