playground: fully working (snapshots left as future work) and other quality of life improvements

This commit is contained in:
Yeicor
2025-07-25 17:59:40 +02:00
parent a63d018850
commit 8a435b5f1a
15 changed files with 289 additions and 81 deletions

View File

@@ -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:
- estree-walker@2.0.2

View File

@@ -11,9 +11,9 @@ import {NetworkManager, NetworkUpdateEvent, NetworkUpdateEventModel} from "./mis
import {SceneMgr} from "./misc/scene";
import {Document} from "@gltf-transform/core";
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 {toBuffer} from "./misc/gltf.ts";
// NOTE: The ModelViewer library is big (THREE.js), so we split it and import it asynchronously
const ModelViewerWrapper = defineAsyncComponent({
@@ -22,7 +22,7 @@ const ModelViewerWrapper = defineAsyncComponent({
delay: 0,
});
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > 1200);
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > window.innerHeight);
const sceneUrl = ref("")
const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
@@ -81,6 +81,7 @@ let networkMgr = new NetworkManager();
networkMgr.addEventListener('update-early',
(e) => viewer.value?.onProgress((e as CustomEvent<Array<any>>).detail.length * 0.01));
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
let preloadingModels = ref<Array<string>>([]);
(async () => { // Start loading all configured models ASAP
let sett = await settings();
if (sett.preload.length > 0) {
@@ -91,17 +92,16 @@ networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUp
}
});
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)
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);
}
} // else No preloaded models (useful for playground mode)
})();
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 -->
<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"/>&nbsp; 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"/>&nbsp; Load demo model...
</v-btn>
<v-btn @click="loadModelManual" class="mx-auto d-block my-4">
<svg-icon :path="mdiPlus" type="mdi"/>&nbsp; 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>
<!-- The left collapsible sidebar has the list of models -->

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import {mdiLockQuestion} from "@mdi/js";
import {VBtn, VTooltip} from "vuetify/lib/components/index.mjs";
// @ts-expect-error
import SvgIcon from "@jamescoyle/vue-icon";
// @ts-expect-error

View File

@@ -2,6 +2,7 @@
import {ref} from "vue";
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
// @ts-expect-error
import SvgIcon from '@jamescoyle/vue-icon';
const props = defineProps<{

View File

@@ -1,5 +1,5 @@
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();
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;
// WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?)
let {KHRDracoMeshCompression} = await import("@gltf-transform/extensions")
// @ts-expect-error
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");
io.registerExtensions([KHRDracoMeshCompression])
.registerDependencies({

View File

@@ -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.
* 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
let allPositions: number[] = [];
let allIndices: number[] = [];

View File

@@ -1,5 +1,5 @@
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 {newAxes, newGridBox} from "./helpers";
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> {
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
let helpersDoc = new Document();

View File

@@ -43,21 +43,37 @@ export async function settings() {
"12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="),
// 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);
url.searchParams.forEach((value, key) => {
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
if (settings.code.length > 0) {
// Grab the code from the URL if it is set
if (settings.pg_code_url.length > 0) {
// If the code URL is set, override the code
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) {
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++) {
let url = settings.preload[i];
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));
continue; // Skip this preload URL
}
@@ -82,6 +98,20 @@ export async function settings() {
}
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;
return settings;
}

View File

@@ -25,6 +25,7 @@ import {
mdiVectorLine,
mdiVectorRectangle
} from '@mdi/js'
// @ts-expect-error
import SvgIcon from '@jamescoyle/vue-icon';
import {BackSide, FrontSide} from "three/src/constants.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 {toLineSegments} from "../misc/lines.js";
import {settings} from "../misc/settings.js"
import {currentSceneRotation} from "../viewer/lighting.ts";
import {Matrix4} from "three/src/math/Matrix4.js";
const props = defineProps<{
meshes: Array<Mesh>,
@@ -115,7 +118,8 @@ function onWireframeChange(newWireframe: boolean) {
if (!scene || !sceneModel) return;
sceneModel.traverse((child: MObject3D) => {
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.needsUpdate = true;
}
@@ -153,10 +157,11 @@ function onClipPlanesChange() {
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 offsetZ = bbox.min.z + (1 - clipPlaneZ.value) * (bbox.max.z - bbox.min.z);
let rotSceneMatrix = new Matrix4().makeRotationY(currentSceneRotation);
let planes = [
new Plane(new Vector3(-1, 0, 0), offsetX),
new Plane(new Vector3(0, -1, 0), offsetY),
new Plane(new Vector3(0, 0, 1), -offsetZ),
new Plane(new Vector3(-1, 0, 0), offsetX).applyMatrix4(rotSceneMatrix),
new Plane(new Vector3(0, -1, 0), offsetY).applyMatrix4(rotSceneMatrix),
new Plane(new Vector3(0, 0, 1), -offsetZ).applyMatrix4(rotSceneMatrix),
];
if (clipPlaneSwappedX.value) planes[0].negate();
if (clipPlaneSwappedY.value) planes[1].negate();
@@ -227,9 +232,10 @@ function onEdgeWidthChange(newEdgeWidth: number) {
line.userData.niceLine = line2;
// line.parent!.remove(line); // Keep it for better raycast and selection!
line2.userData.noHit = true;
line2.visible = enabledFeatures.value.includes(1);
edgeWidthChangeCleanup.push(() => {
line2.parent!.remove(line2);
line.visible = true;
line.visible = enabledFeatures.value.includes(1);
props.viewer!!.onElemReady((elem) => {
elem.removeEventListener('resize', () => resizeListener(elem));
});

5
frontend/shims.d.ts vendored
View File

@@ -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'

View File

@@ -1,12 +1,15 @@
<script lang="ts" setup>
import {onMounted, onUpdated, ref} from "vue";
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
// @ts-expect-error
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
import {Vector3} from "three/src/math/Vector3.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
@@ -48,7 +51,7 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
axis.direction.y = -axis.direction.y;
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;
attempt++;
}
@@ -66,7 +69,12 @@ let gizmo: HTMLElement & { update: () => void }
function updateGizmo() {
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 as any).camera).rotation.set(prevRot.x, prevRot.y, prevRot.z);
requestIdleCallback(updateGizmo, {timeout: 250});
}
}

View File

@@ -1,17 +1,19 @@
<script setup lang="ts">
import {setupMonaco} from "./monaco.ts";
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 {newPyodideWorker} from "./pyodide-worker-api.ts";
import {mdiCircleOpacity, mdiClose, mdiPlay, mdiReload, mdiShare} from "@mdi/js";
import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle} from "vuetify/components";
import {mdiCircleOpacity, mdiClose, mdiContentSave, mdiFolderOpen, mdiPlay, mdiReload, mdiShare} from "@mdi/js";
import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle, VTooltip} from "vuetify/components";
// @ts-expect-error
import SvgIcon from '@jamescoyle/vue-icon';
import {version as pyodideVersion} from "pyodide";
import {gzip} from 'pako';
import {b66Encode} from "./b66.ts";
import {Base64} from 'js-base64'; // More compatible with binary data from python...
import {NetworkUpdateEvent, NetworkUpdateEventModel} from "../misc/network.ts";
import {settings} from "../misc/settings.ts";
const props = defineProps<{ initialCode: string }>();
const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>()
@@ -24,6 +26,11 @@ const outputText = ref(``);
function output(text: string) {
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
nextTick(() => { // Scroll to bottom
const consoleElement = document.querySelector('.playground-console');
@@ -65,9 +72,10 @@ async function setupPyodide() {
await pyodideWorker.asyncRun(`import micropip, asyncio
micropip.set_index_urls(["https://yeicor.github.io/OCP.wasm", "https://pypi.org/simple"])
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"]))
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
output("Pyodide worker initialized.\n");
}
@@ -116,7 +124,7 @@ function onModelData(modelData: string) {
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
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);
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)
@@ -135,7 +143,6 @@ function onModelData(modelData: string) {
}
function resetWorker() {
code.value = props.initialCode; // Reset code to initial state
if (pyodideWorker) {
pyodideWorker.terminate(); // Terminate existing worker
pyodideWorker = null; // Reset worker reference
@@ -146,18 +153,52 @@ function resetWorker() {
function shareLink() {
const baseUrl = window.location
const urlParams = new URLSearchParams(baseUrl.search); // Keep all previous URL parameters
urlParams.set('code', b66Encode(gzip(code.value, {level: 9}))); // Compress and encode the code
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}?${urlParams.toString()}`;
const urlParams = new URLSearchParams(baseUrl.hash.slice(1)); // Keep all previous URL parameters
urlParams.set('pg_code', b66Encode(gzip(code.value, {level: 9}))); // Compress and encode the code
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}${baseUrl.search}#${urlParams.toString()}`; // Prefer hash to GET (bigger limits)
output(`Share link ready: ${shareUrl}\n`)
navigator.clipboard.writeText(shareUrl)
.then(() => output("Link copied to clipboard!\n"))
.catch(err => output(`Failed to copy link: ${err}\n`));
if (!navigator.clipboard) {
output("Clipboard API not available. Please copy the link manually.\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;
setupPyodide().then(() => {
if (props.initialCode != "" && !reused) runCode();
(async () => {
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>
@@ -165,35 +206,59 @@ setupPyodide().then(() => {
<v-card class="popup-card"
:style="opacity == 0 ? `position: absolute; top: calc(-50vh + 24px); width: calc(100vw - 64px);` : ``">
<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>
<svg-icon :path="mdiCircleOpacity" type="mdi"></svg-icon>
<v-slider v-model="opacity" :max="1" :min="0" :step="0.1"
style="max-width: 100px; height: 32px; margin-right: 16px;"></v-slider>
<span style="display: inline-flex; margin-right: 16px;">
<svg-icon :path="mdiCircleOpacity" type="mdi" style="height: 32px"></svg-icon>
<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()">
<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 icon @click="runCode()">
<v-btn icon @click="runCode()" :disabled="running">
<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 icon @click="shareLink()">
<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 icon @click="emit('close')">
<svg-icon :path="mdiClose" type="mdi"/>
<v-tooltip activator="parent" location="bottom">Close (Pyodide remains loaded)</v-tooltip>
</v-btn>
</v-toolbar>
<v-card-text class="popup-card-text" :style="opacity == 0 ? `display: none` : ``">
<!-- Only show content if opacity is greater than 0 -->
<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"
language="python" @mount="handleMount"/>
</div>
@@ -212,6 +277,10 @@ setupPyodide().then(() => {
background-color: #00000000; /* Transparent background */
}
.v-toolbar.popup > * {
overflow-x: auto;
}
.popup-card-text {
background-color: #1e1e1e; /* Matches the Monaco editor background */
opacity: v-bind(opacity);

View File

@@ -1,6 +1,7 @@
<script lang="ts" setup>
import {inject, ref, type ShallowRef, watch} from "vue";
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
// @ts-expect-error
import SvgIcon from '@jamescoyle/vue-icon';
import type {ModelViewerElement} from '@google/model-viewer';
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 selectionEnabled = ref(false);
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 showDistances = ref<Boolean>(true);
@@ -149,7 +150,7 @@ let mouseUpListener = (event: MouseEvent) => {
// Return the best hit
[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
let selInfo: SelectionInfo | null = null;
if (hit) selInfo = hitToSelectionInfo(hit);
@@ -171,7 +172,7 @@ let mouseUpListener = (event: MouseEvent) => {
// Otherwise, highlight the model that owns the hit
emit('findModel', hit.object.userData[extrasNameKey])
// And reset the selection mode
toggleHighlightNextSelection()
toggleOpenNextSelection()
}
scene.queueRender() // Force rerender of model-viewer
}
@@ -209,17 +210,17 @@ function toggleSelection() {
setDisableTap(selectionEnabled.value);
}
function toggleHighlightNextSelection() {
highlightNextSelection.value = [
!highlightNextSelection.value[0],
highlightNextSelection.value[0] ? highlightNextSelection.value[1] : selectionEnabled.value
function toggleOpenNextSelection() {
openNextSelection.value = [
!openNextSelection.value[0],
openNextSelection.value[0] ? openNextSelection.value[1] : selectionEnabled.value
];
if (highlightNextSelection.value[0]) {
if (openNextSelection.value[0]) {
// Reuse selection code to identify the model
if (!selectionEnabled.value) toggleSelection()
} else {
if (selectionEnabled.value !== highlightNextSelection.value[1]) toggleSelection()
highlightNextSelection.value = [false, false];
if (selectionEnabled.value !== openNextSelection.value[1]) toggleSelection()
openNextSelection.value = [false, false];
}
}
@@ -427,6 +428,10 @@ defineExpose({deselect, updateBoundingBox, updateDistances});
// Add keyboard shortcuts
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 (selectFilter.value == 'Any (S)') toggleSelection();
else {
@@ -455,8 +460,8 @@ window.addEventListener('keydown', (event) => {
toggleShowBoundingBox();
} else if (event.key === 'd') {
toggleShowDistances();
} else if (event.key === 'h') {
toggleHighlightNextSelection();
} else if (event.key === 'o') {
toggleOpenNextSelection();
}
});
</script>
@@ -474,8 +479,8 @@ window.addEventListener('keydown', (event) => {
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>
<v-btn :color="openNextSelection[0] ? 'surface-light' : ''" icon @click="toggleOpenNextSelection">
<v-tooltip activator="parent">(O)pen 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">

View File

@@ -13,7 +13,17 @@ import {
import OrientationGizmo from "./OrientationGizmo.vue";
import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera.js";
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector, 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 type {ModelViewerElement} from '@google/model-viewer';
import Loading from "../misc/Loading.vue";
@@ -51,7 +61,7 @@ const sett = ref<any | null>(null);
const showPlaygroundDialog = ref(false);
(async () => {
sett.value = await settings();
showPlaygroundDialog.value = sett.value.code != "";
showPlaygroundDialog.value = sett.value.pg_code != "";
})();
let selection: Ref<Array<SelectionInfo>> = ref([]);
@@ -130,10 +140,14 @@ function removeObjectSelections(objName: string) {
selectionComp.value?.updateDistances();
}
defineExpose({removeObjectSelections});
defineExpose({removeObjectSelections, openPlayground: () => showPlaygroundDialog.value = true});
// 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();
else if (event.key === 'c') centerCamera();
else if (event.key === 'd') downloadSceneGlb();
@@ -155,6 +169,13 @@ window.addEventListener('keydown', (event) => {
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
<svg-icon :path="mdiCrosshairsGps" type="mdi"/>
</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/>
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
<selection-component ref="selectionComp" v-model="selection" :viewer="props.viewer as any"
@@ -172,7 +193,7 @@ window.addEventListener('keydown', (event) => {
</template>
<template v-slot:default="{ isActive }">
<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)"/>
</if-not-small-build>
</template>

View File

@@ -2,12 +2,13 @@ import {ModelViewerElement} from '@google/model-viewer';
import {$scene} from "@google/model-viewer/lib/model-viewer-base";
import {settings} from "../misc/settings.ts";
export let currentSceneRotation = 0; // radians, 0 is the default rotation
export async function setupLighting(modelViewer: ModelViewerElement) {
modelViewer[$scene].environmentIntensity = (await settings()).environmentIntensity;
// Code is mostly copied from the example at: https://modelviewer.dev/examples/stagingandcameras/#turnSkybox
let lastX: number;
let panning = false;
let skyboxAngle = 0;
let radiansPerPixel: number;
const startPan = () => {
@@ -20,11 +21,11 @@ export async function setupLighting(modelViewer: ModelViewerElement) {
const updatePan = (thisX: number) => {
const delta = (thisX - lastX) * radiansPerPixel;
lastX = thisX;
skyboxAngle += delta;
currentSceneRotation += delta;
const orbit = modelViewer.getCameraOrbit();
orbit.theta += delta;
modelViewer.cameraOrbit = orbit.toString();
modelViewer.resetTurntableRotation(skyboxAngle);
modelViewer.resetTurntableRotation(currentSceneRotation);
modelViewer.jumpCameraToGoal();
}