From 38be4c638b7b33e3b4880260bbafa9c4bcf51dbb Mon Sep 17 00:00:00 2001 From: Yeicor <4929005+Yeicor@users.noreply.github.com> Date: Sat, 26 Jul 2025 14:15:09 +0200 Subject: [PATCH] playground: minor improvements --- frontend/App.vue | 4 +- frontend/misc/network.ts | 2 +- frontend/misc/settings.ts | 14 ++-- frontend/models/Model.vue | 2 +- frontend/tools/PlaygroundDialogContent.vue | 89 +++++++++++++--------- frontend/tools/PlaygroundStartup.py | 5 +- frontend/tools/Tools.vue | 12 +-- frontend/tools/b64.ts | 21 +++++ frontend/tools/b66.ts | 54 ------------- frontend/tools/pyodide-worker-api.ts | 1 + frontend/tools/pyodide-worker.ts | 12 +++ frontend/viewer/ModelViewerWrapper.vue | 2 +- frontend/viewer/lighting.ts | 2 +- 13 files changed, 109 insertions(+), 111 deletions(-) create mode 100644 frontend/tools/b64.ts delete mode 100644 frontend/tools/b66.ts diff --git a/frontend/App.vue b/frontend/App.vue index d7bab44..130522e 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -53,7 +53,7 @@ async function onModelUpdateRequest(event: NetworkUpdateEvent) { let model = event.models[modelIndex]; tools.value?.removeObjectSelections(model.name); try { - let loadHelpers = (await settings()).loadHelpers; + let loadHelpers = (await settings).loadHelpers; if (!model.isRemove) { doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && loadHelpers, isLast); } else { @@ -83,7 +83,7 @@ networkMgr.addEventListener('update-early', networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent)); let preloadingModels = ref>([]); (async () => { // Start loading all configured models ASAP - let sett = await settings(); + let sett = await settings; if (sett.preload.length > 0) { watch(viewer, (newViewer) => { if (newViewer) { diff --git a/frontend/misc/network.ts b/frontend/misc/network.ts index b31fa84..4a0412e 100644 --- a/frontend/misc/network.ts +++ b/frontend/misc/network.ts @@ -61,7 +61,7 @@ export class NetworkManager extends EventTarget { private async monitorDevServer(url: URL, stop: () => boolean = () => false) { while (!stop()) { - let monitorEveryMs = (await settings()).monitorEveryMs; + let monitorEveryMs = (await settings).monitorEveryMs; try { // WARNING: This will spam the console logs with failed requests when the server is down const controller = new AbortController(); diff --git a/frontend/misc/settings.ts b/frontend/misc/settings.ts index 0bf0327..c399447 100644 --- a/frontend/misc/settings.ts +++ b/frontend/misc/settings.ts @@ -1,11 +1,8 @@ // These are the default values for the settings, which are overridden below import {ungzip} from "pako"; -import {b66Decode} from "../tools/b66.ts"; +import {b64UrlDecode} from "../tools/b64.ts"; -let settingsCache: any = null; - -export async function settings() { - if (settingsCache !== null) return settingsCache; +export const settings = (async () => { let settings = { preload: [ // @ts-ignore @@ -102,9 +99,9 @@ export async function settings() { // 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'}); + settings.pg_code = ungzip(b64UrlDecode(settings.pg_code), {to: 'string'}); } catch (error) { - console.warn("Failed to decompress code (assuming raw code):", error); + console.log("pg_code is not base64url+gzipped, assuming raw code. Decoding error:", error); } if (settings.pg_opacity_loading < 0) { // If the opacity is not set, use 0.0 if preload is set, otherwise 0.9 @@ -112,9 +109,8 @@ export async function settings() { } } - settingsCache = settings; return settings; -} +})() const firstTimeNames: Array = []; // Needed for array values, which clear the array when overridden function parseSetting(name: string, value: string, settings: any): any { diff --git a/frontend/models/Model.vue b/frontend/models/Model.vue index 47c2dd8..0bba3f2 100644 --- a/frontend/models/Model.vue +++ b/frontend/models/Model.vue @@ -60,7 +60,7 @@ const clipPlaneZ = ref(1); const clipPlaneSwappedZ = ref(false); const edgeWidth = ref(0); (async () => { - let s = await settings(); + let s = await settings; edgeWidth.value = s.edgeWidth; })(); diff --git a/frontend/tools/PlaygroundDialogContent.vue b/frontend/tools/PlaygroundDialogContent.vue index ec4cfa2..978878e 100644 --- a/frontend/tools/PlaygroundDialogContent.vue +++ b/frontend/tools/PlaygroundDialogContent.vue @@ -4,26 +4,34 @@ import {VueMonacoEditor} from '@guolao/vue-monaco-editor' import {nextTick, onMounted, ref, shallowRef} from "vue"; import Loading from "../misc/Loading.vue"; import {newPyodideWorker} from "./pyodide-worker-api.ts"; -import {mdiCircleOpacity, mdiClose, mdiContentSave, mdiFolderOpen, mdiPlay, mdiReload, mdiShare} from "@mdi/js"; +import { + mdiBroom, + 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 {b64UrlEncode} from "./b64.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"; // @ts-expect-error import playgroundStartupCode from './PlaygroundStartup.py?raw'; -const props = defineProps<{ initialCode: string }>(); +const model = defineModel<{ code: string, firstTime: boolean }>({required: true}); // Initial code should only be set on first load! const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>() // ============ LOAD MONACO EDITOR ============ setupMonaco() // Must be called before using the editor -const code = ref((import.meta as any)?.hot?.data?.code || props.initialCode); const outputText = ref(``); function output(text: string) { @@ -51,21 +59,24 @@ const MONACO_EDITOR_OPTIONS = { const editorTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? `vs-dark` : `vs` const editor = shallowRef() const handleMount = (editorInstance: typeof VueMonacoEditor) => (editor.value = editorInstance) -const opacity = ref(0.9); // Opacity for the editor +const opacity = ref(0.9); // Opacity for the editor (overriden when settings are loaded) // ============ LOAD PYODIDE (ASYNC) ============ let pyodideWorker: ReturnType | null = (import.meta as any).hot?.data?.pyodideWorker || null; const running = ref(true); -async function setupPyodide() { +async function setupPyodide(first: boolean, loadSnapshot: Uint8Array | undefined = undefined) { running.value = true; - if (opacity.value == 0.0) opacity.value = 0.9; // User doesn't know how to show code again, reset after reopening + if (opacity.value == 0.0 && !first) opacity.value = 0.9; // User doesn't know how to show code again, reset after reopening if (pyodideWorker === null) { output("Creating new Pyodide worker...\n"); - pyodideWorker = newPyodideWorker({ - indexURL: `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`, // FIXME: Local deployment? + pyodideWorker = newPyodideWorker(Object.assign({ + // Note: python wheels are downloaded from the CDN, as we can't know which ones are needed in advance to bundle them + // Furthermore, this lets us use the latest version of all wheels including ocp-specific ones without app updates + indexURL: `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`, packages: ["micropip", "sqlite3"], // Faster load if done here - }); + // _makeSnapshot: true, // Enable snapshotting for faster startup (still experimental: breaks loading any packages) + }, (loadSnapshot ? {_loadSnapshot: loadSnapshot} : {}))); // Load snapshot if provided if ((import.meta as any).hot) (import.meta as any).hot.data.pyodideWorker = pyodideWorker } else { output("Reusing existing Pyodide instance...\n"); @@ -73,7 +84,7 @@ async function setupPyodide() { output("Preloading packages...\n"); await pyodideWorker.asyncRun(playgroundStartupCode, 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"); + output("Pyodide worker ready.\n"); } async function runCode() { @@ -88,8 +99,7 @@ async function runCode() { output("Running code...\n"); try { running.value = true; - if ((import.meta as any).hot) (import.meta as any).hot.data.code = code.value; // Save code for hot reload - await pyodideWorker.asyncRun(code.value, output, (msg: string) => { + await pyodideWorker.asyncRun(model.value.code, output, (msg: string) => { // Detect models printed to console (since http server is not available in pyodide) if (msg.startsWith(yacvServerModelPrefix)) { const modelData = msg.slice(yacvServerModelPrefix.length); @@ -99,7 +109,7 @@ async function runCode() { } }); } catch (e) { - output(`Error running initial code: ${e}\n`); + output(`Error running code: ${e}\n`); } finally { running.value = false; // Indicate that Pyodide is ready } @@ -139,22 +149,26 @@ function onModelData(modelData: string) { emit('updateModel', networkUpdateEvent); } -function resetWorker() { +function resetWorker(loadSnapshot: Uint8Array | undefined = undefined) { if (pyodideWorker) { pyodideWorker.terminate(); // Terminate existing worker pyodideWorker = null; // Reset worker reference } outputText.value = ``; // Clear output text - setupPyodide(); // Reinitialize Pyodide + setupPyodide(false, loadSnapshot); // Reinitialize Pyodide } function shareLink() { const baseUrl = window.location - 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) + const searchParams = new URLSearchParams(baseUrl.search); + searchParams.delete('pg_code_url'); // Remove any existing pg_code parameter + searchParams.delete('pg_code'); // Remove any existing pg_code parameter + const hashParams = new URLSearchParams(baseUrl.hash.slice(1)); // Keep all previous URL parameters + hashParams.delete('pg_code_url') // Would overwrite the pg_code parameter + hashParams.set('pg_code', b64UrlEncode(gzip(model.value.code, {level: 9}))); // Compress and encode the code + const shareUrl = `${baseUrl.origin}${baseUrl.pathname}?${searchParams}#${hashParams}`; // Prefer hash to GET output(`Share link ready: ${shareUrl}\n`) - if (!navigator.clipboard) { + if (navigator.clipboard?.writeText === undefined) { output("Clipboard API not available. Please copy the link manually.\n"); return; } else { @@ -172,27 +186,28 @@ function loadSnapshot() { throw new Error("Not implemented yet!"); // TODO: Implement snapshot loading } -const reused = (import.meta as any).hot?.data?.pyodideWorker !== undefined; (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 + const sett = await settings + if (model.value.firstTime) opacity.value = sett.pg_opacity_loading + await setupPyodide(true); + if (model.value.firstTime) { + await runCode(); + opacity.value = sett.pg_opacity_loaded + model.value.firstTime = false + } })() // Add keyboard shortcuts const editorRef = ref(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 + if (event.key === 'F10') { // Run code on F10 + event.preventDefault(); // Prevent default behavior of the key + runCode(); + } else if (event.key === 'Escape') { // Close on Escape key + emit('close'); } }); } @@ -256,11 +271,17 @@ onMounted(() => {
-
-

Console Output

+

+ Console Output + + + + +

{{ outputText }}
diff --git a/frontend/tools/PlaygroundStartup.py b/frontend/tools/PlaygroundStartup.py index af573b4..436619a 100644 --- a/frontend/tools/PlaygroundStartup.py +++ b/frontend/tools/PlaygroundStartup.py @@ -21,11 +21,10 @@ async def install_font_to_ocp(font_url, font_name=None): from pyodide.http import pyfetch from OCP.Font import Font_FontMgr, Font_SystemFont, Font_FA_Regular from OCP.TCollection import TCollection_AsciiString - import os, asyncio + import os + # Prepare the font path and name font_name = font_name if font_name is not None else font_url.split("/")[-1] - - # Choose a "system-like" font directory font_path = os.path.join("/tmp", font_name) os.makedirs(os.path.dirname(font_path), exist_ok=True) diff --git a/frontend/tools/Tools.vue b/frontend/tools/Tools.vue index d56798e..f23f20c 100644 --- a/frontend/tools/Tools.vue +++ b/frontend/tools/Tools.vue @@ -28,7 +28,7 @@ import SvgIcon from '@jamescoyle/vue-icon'; import type {ModelViewerElement} from '@google/model-viewer'; import Loading from "../misc/Loading.vue"; import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue"; -import {defineAsyncComponent, ref, type Ref} from "vue"; +import {defineAsyncComponent, ref} from "vue"; import type {SelectionInfo} from "./selection"; import {settings} from "../misc/settings.ts"; import type {NetworkUpdateEvent} from "../misc/network.ts"; @@ -59,12 +59,14 @@ const emit = defineEmits<{ findModel: [string], updateModel: [NetworkUpdateEvent const sett = ref(null); const showPlaygroundDialog = ref(false); +const pg_model = ref({code: '# Loading...', firstTime: false}); (async () => { - sett.value = await settings(); - showPlaygroundDialog.value = sett.value.pg_code != ""; + sett.value = await settings; + pg_model.value = {code: sett.value.pg_code, firstTime: true}; + showPlaygroundDialog.value = pg_model.value.code != ""; })(); -let selection: Ref> = ref([]); +let selection = ref>([]); let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length @@ -193,7 +195,7 @@ document.addEventListener('keydown', (event) => { diff --git a/frontend/tools/b64.ts b/frontend/tools/b64.ts new file mode 100644 index 0000000..9f2e14c --- /dev/null +++ b/frontend/tools/b64.ts @@ -0,0 +1,21 @@ +export function b64UrlEncode(data: Uint8Array): string { + const base64 = btoa(String.fromCharCode(...data)); + return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +export function b64UrlDecode(encoded: string): Uint8Array { + // Replace URL-safe characters with standard base64 characters + let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/'); + // Add padding if necessary + const padding = base64.length % 4; + if (padding) { + base64 += '='.repeat(4 - padding); + } + // Decode the base64 string to a byte array + const binaryString = atob(base64); + const byteArray = new Uint8Array(binaryString.length); + for (let i = 0; i < binaryString.length; i++) { + byteArray[i] = binaryString.charCodeAt(i); + } + return byteArray; +} \ No newline at end of file diff --git a/frontend/tools/b66.ts b/frontend/tools/b66.ts deleted file mode 100644 index 9026a04..0000000 --- a/frontend/tools/b66.ts +++ /dev/null @@ -1,54 +0,0 @@ -// B66 encoding and decoding functions for compact url query parameter values. https://gist.github.com/danneu/6755394 -const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789.-_~"; - -export function b66Encode(data: Uint8Array): string { - let result = ""; - let bits = 0; - let value = 0; - - for (let byte of data) { - value = (value << 8) | byte; - bits += 8; - - while (bits >= 6) { - bits -= 6; - result += alphabet[(value >> bits) & 0x3F]; - } - } - - if (bits > 0) { - result += alphabet[(value << (6 - bits)) & 0x3F]; - } - - return result; -} - -export function b66Decode(encoded: string): Uint8Array { - let result = []; - let bits = 0; - let value = 0; - - for (let char of encoded) { - const index = alphabet.indexOf(char); - if (index === -1) { - throw new Error(`Invalid character '${char}' in B66 encoded string.`); - } - - value = (value << 6) | index; - bits += 6; - - while (bits >= 8) { - bits -= 8; - result.push((value >> bits) & 0xFF); - } - } - - if (bits > 0) { - // If there are leftover bits, they should not be present in a valid B66 encoding. - if (value << (8 - bits)) { - throw new Error("Invalid B66 encoding: leftover bits."); - } - } - - return new Uint8Array(result); -} \ No newline at end of file diff --git a/frontend/tools/pyodide-worker-api.ts b/frontend/tools/pyodide-worker-api.ts index e7367fe..7535b21 100644 --- a/frontend/tools/pyodide-worker-api.ts +++ b/frontend/tools/pyodide-worker-api.ts @@ -38,6 +38,7 @@ export function newPyodideWorker(initOpts: Parameters[0]) { mkdirTree: (path: string) => commonRequestResponse({type: "mkdirTree", id: requestId++, path}), writeFile: (path: string, content: string) => commonRequestResponse({type: "writeFile", id: requestId++, path, content}), + makeSnapshot: () => commonRequestResponse({type: "makeSnapshot", id: requestId++}), terminate: () => worker.terminate() } } diff --git a/frontend/tools/pyodide-worker.ts b/frontend/tools/pyodide-worker.ts index db4d0d0..aa85a73 100644 --- a/frontend/tools/pyodide-worker.ts +++ b/frontend/tools/pyodide-worker.ts @@ -25,6 +25,9 @@ export type MessageEventDataIn = { id: number; path: string; content: string; +} | { + type: 'makeSnapshot'; + id: number; } self.onmessage = async (event: MessageEvent) => { @@ -64,6 +67,15 @@ self.onmessage = async (event: MessageEvent) => { } catch (error: any) { self.postMessage({id: event.data.id, error: error.message}); } + } else if (event.data.type === 'makeSnapshot') { + // Take a snapshot of the current Pyodide filesystem. + const pyodide = await pyodideReadyPromise; + try { + const snapshot = pyodide.makeMemorySnapshot(); + self.postMessage({id: event.data.id, result: snapshot}); + } catch (error: any) { + self.postMessage({id: event.data.id, error: error.message}); + } } else { console.error("Unknown message type:", (event.data as any)?.type); self.postMessage({id: (event.data as any)?.id, error: "Unknown message type: " + (event.data as any)?.type}); diff --git a/frontend/viewer/ModelViewerWrapper.vue b/frontend/viewer/ModelViewerWrapper.vue index e93477b..008cc6b 100644 --- a/frontend/viewer/ModelViewerWrapper.vue +++ b/frontend/viewer/ModelViewerWrapper.vue @@ -29,7 +29,7 @@ const renderer = ref(null); const controls = ref(null); const sett = ref(null); -(async () => sett.value = await settings())(); +(async () => sett.value = await settings)(); let lastCameraTargetPosition: Vector3 | undefined = undefined; let lastCameraZoom: number | undefined = undefined; diff --git a/frontend/viewer/lighting.ts b/frontend/viewer/lighting.ts index f82a903..34c48f4 100644 --- a/frontend/viewer/lighting.ts +++ b/frontend/viewer/lighting.ts @@ -5,7 +5,7 @@ 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; + 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;