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

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