mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 14:14:13 +01:00
playground: most of the logic for both frontend and backend is implemented, some bugs remain
This commit is contained in:
@@ -13,6 +13,7 @@ import {Document} from "@gltf-transform/core";
|
||||
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
|
||||
import {mdiPlus} from '@mdi/js'
|
||||
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({
|
||||
@@ -82,14 +83,24 @@ networkMgr.addEventListener('update-early',
|
||||
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
|
||||
(async () => { // Start loading all configured models ASAP
|
||||
let sett = await settings();
|
||||
watch(viewer, (newViewer) => {
|
||||
if (newViewer) {
|
||||
newViewer.setPosterText('<tspan x="50%" dy="1.2em">Trying to load' +
|
||||
' models from:</tspan>' + sett.preload.map((url: string) => '<tspan x="50%" dy="1.2em">- ' + url + '</tspan>').join(""));
|
||||
if (sett.preload.length > 0) {
|
||||
watch(viewer, (newViewer) => {
|
||||
if (newViewer) {
|
||||
newViewer.setPosterText('<tspan x="50%" dy="1.2em">Trying to load' +
|
||||
' models from:</tspan>' + sett.preload.map((url: string) => '<tspan x="50%" dy="1.2em">- ' + url + '</tspan>').join(""));
|
||||
}
|
||||
});
|
||||
for (let model of sett.preload) {
|
||||
await networkMgr.load(model);
|
||||
}
|
||||
});
|
||||
for (let model of sett.preload) {
|
||||
await networkMgr.load(model);
|
||||
} 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);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -117,7 +128,7 @@ async function loadModelManual() {
|
||||
<svg-icon :path="mdiPlus" type="mdi"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
|
||||
<models ref="models" :viewer="viewer" @remove-model="onModelRemoveRequest"/>
|
||||
</sidebar>
|
||||
|
||||
<!-- The right collapsible sidebar has the list of tools -->
|
||||
@@ -125,7 +136,7 @@ async function loadModelManual() {
|
||||
<template #toolbar>
|
||||
<v-toolbar-title>Tools</v-toolbar-title>
|
||||
</template>
|
||||
<tools ref="tools" :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
|
||||
<tools ref="tools" :viewer="viewer" @find-model="models?.findModel" @update-model="onModelUpdateRequest"/>
|
||||
</sidebar>
|
||||
|
||||
</v-layout>
|
||||
@@ -135,6 +146,6 @@ async function loadModelManual() {
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
30
frontend/misc/IfNotSmallBuild.vue
Normal file
30
frontend/misc/IfNotSmallBuild.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup lang="ts">
|
||||
import {mdiLockQuestion} from "@mdi/js";
|
||||
import {VBtn, VTooltip} from "vuetify/lib/components/index.mjs";
|
||||
import SvgIcon from "@jamescoyle/vue-icon";
|
||||
|
||||
// @ts-expect-error
|
||||
let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== 'undefined' && __YACV_SMALL_BUILD__;
|
||||
|
||||
function clickedButton() { // Redirect to the main build
|
||||
window.open("https://yeicor-3d.github.io/yet-another-cad-viewer/" + window.location.search + window.location.hash, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- @ts-ignore-->
|
||||
<!-- Include the children as this is a full build -->
|
||||
<slot v-if="!isSmallBuild"/>
|
||||
<!-- A small info button saying that a feature is missing, and linking to the main build -->
|
||||
<v-btn v-else icon @click="clickedButton" base-color="#a00" style="margin: auto; display: block;">
|
||||
<v-tooltip activator="parent">
|
||||
This feature is not available in the small build.<br/>
|
||||
Click to go to the main build.
|
||||
</v-tooltip>
|
||||
<svg-icon :path="mdiLockQuestion" type="mdi"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -5,6 +5,9 @@ let io = new WebIO();
|
||||
export let extrasNameKey = "__yacv_name";
|
||||
export let extrasNameValueHelpers = "__helpers";
|
||||
|
||||
// @ts-expect-error
|
||||
let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== 'undefined' && __YACV_SMALL_BUILD__;
|
||||
|
||||
/**
|
||||
* Loads a GLB model from a URL and adds it to the document or replaces it if the names match.
|
||||
*
|
||||
@@ -27,7 +30,7 @@ export async function mergePartial(url: string, name: string, document: Document
|
||||
try { // Try to load fast if no extensions are used
|
||||
newDoc = await io.readBinary(new Uint8Array(buffer));
|
||||
} catch (e) { // Fallback to wait for download and register big extensions
|
||||
if (e instanceof Error && e.message.toLowerCase().includes("khr_draco_mesh_compression")) {
|
||||
if (!isSmallBuild && e instanceof Error && e.message.toLowerCase().includes("khr_draco_mesh_compression")) {
|
||||
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")
|
||||
@@ -38,7 +41,7 @@ export async function mergePartial(url: string, name: string, document: Document
|
||||
'draco3d.decoder': await dracoDecoderWeb.default({}),
|
||||
'draco3d.encoder': await dracoEncoderWeb.default({})
|
||||
});
|
||||
} else if (e instanceof Error && e.message.toLowerCase().includes("ext_texture_webp")) {
|
||||
} else if (!isSmallBuild && e instanceof Error && e.message.toLowerCase().includes("ext_texture_webp")) {
|
||||
if (alreadyTried["webp"]) throw e; else alreadyTried["webp"] = true;
|
||||
let {EXTTextureWebP} = await import("@gltf-transform/extensions")
|
||||
io.registerExtensions([EXTTextureWebP]);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
// These are the default values for the settings, which are overridden below
|
||||
import {ungzip} from "pako";
|
||||
import {b66Decode} from "../tools/b66.ts";
|
||||
|
||||
let settingsCache: any = null;
|
||||
|
||||
export async function settings() {
|
||||
@@ -49,10 +52,23 @@ export async function settings() {
|
||||
if (key in settings) (settings as any)[key] = parseSetting(key, value, settings);
|
||||
})
|
||||
|
||||
// Auto-decompress the code
|
||||
if (settings.code.length > 0) {
|
||||
try {
|
||||
settings.code = ungzip(b66Decode(settings.code), {to: 'string'});
|
||||
} catch (error) {
|
||||
console.warn("Failed to decompress code (assuming raw code):", error);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the default preload URL if not overridden (requires a fetch that is avoided if possible)
|
||||
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
|
||||
settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1));
|
||||
continue; // Skip this preload URL
|
||||
}
|
||||
const possibleBackend = new URL("./?api_updates=true", window.location.href)
|
||||
await fetch(possibleBackend, {method: "HEAD"}).then((response) => {
|
||||
if (response.ok && response.headers.get("Content-Type") === "text/event-stream") {
|
||||
|
||||
@@ -7,7 +7,7 @@ import Model from "./Model.vue";
|
||||
import {inject, ref, type Ref} from "vue";
|
||||
|
||||
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||
const emit = defineEmits<{ remove: [string] }>()
|
||||
const emit = defineEmits<{ removeModel: [string] }>()
|
||||
|
||||
let {sceneDocument} = inject<{ sceneDocument: Ref<Document> }>('sceneDocument')!!;
|
||||
|
||||
@@ -32,7 +32,7 @@ function meshName(mesh: Mesh) {
|
||||
}
|
||||
|
||||
function onRemove(mesh: Mesh) {
|
||||
emit('remove', meshName(mesh))
|
||||
emit('removeModel', meshName(mesh))
|
||||
}
|
||||
|
||||
function findModel(name: string) {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import {setupMonaco} from "./monaco.ts";
|
||||
import {VueMonacoEditor} from '@guolao/vue-monaco-editor'
|
||||
import {ref, shallowRef} from "vue";
|
||||
import {nextTick, ref, shallowRef} from "vue";
|
||||
import Loading from "../misc/Loading.vue";
|
||||
import {asyncRun, pyodideWorker, resetState} from "./pyodide-worker-api.ts";
|
||||
import {mdiCircleOpacity, mdiClose, mdiLockReset, mdiPlay, mdiReload, mdiRun} from "@mdi/js";
|
||||
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 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";
|
||||
|
||||
const props = defineProps<{ initialCode: string }>();
|
||||
const emit = defineEmits<{ (e: 'close'): void }>()
|
||||
const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>()
|
||||
|
||||
// ============ LOAD MONACO EDITOR ============
|
||||
setupMonaco() // Must be called before using the editor
|
||||
@@ -20,12 +25,12 @@ const outputText = ref(``);
|
||||
function output(text: string) {
|
||||
outputText.value += text; // Append to output
|
||||
console.log(text); // Also log to console
|
||||
setTimeout(() => { // Scroll to bottom? TODO: Test
|
||||
const consoleElement = document.querySelector('.playground-console pre');
|
||||
nextTick(() => { // Scroll to bottom
|
||||
const consoleElement = document.querySelector('.playground-console');
|
||||
if (consoleElement) {
|
||||
consoleElement.scrollTop = consoleElement.scrollHeight;
|
||||
}
|
||||
}, 0);
|
||||
})
|
||||
}
|
||||
|
||||
const MONACO_EDITOR_OPTIONS = {
|
||||
@@ -40,45 +45,120 @@ const handleMount = (editorInstance: typeof VueMonacoEditor) => (editor.value =
|
||||
const opacity = ref(0.9); // Opacity for the editor
|
||||
|
||||
// ============ LOAD PYODIDE (ASYNC) ============
|
||||
|
||||
let pyodideWorker: ReturnType<typeof newPyodideWorker> | null = (import.meta as any).hot?.data?.pyodideWorker || null;
|
||||
const running = ref(true);
|
||||
|
||||
async function setupPyodide() {
|
||||
running.value = true;
|
||||
if (opacity.value == 0.0) opacity.value = 0.9; // User doesn't know how to show code again, reset after reopening
|
||||
let firstTime = pyodideWorker === null;
|
||||
if (firstTime) {
|
||||
resetState();
|
||||
output("Loading packages...\n");
|
||||
await 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 *'''})
|
||||
await (micropip.install(["build123d"]))`, output);
|
||||
if (props.initialCode != "") {
|
||||
await runCode();
|
||||
opacity.value = 0.0; // Hide editor after running initial code
|
||||
} else {
|
||||
output("Ready for custom code.\n");
|
||||
}
|
||||
if (pyodideWorker === null) {
|
||||
output("Creating new Pyodide worker...\n");
|
||||
pyodideWorker = newPyodideWorker({
|
||||
indexURL: `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`, // FIXME: Local deployment?
|
||||
packages: ["micropip", "sqlite3"], // Faster load if done here
|
||||
});
|
||||
if ((import.meta as any).hot) (import.meta as any).hot.data.pyodideWorker = pyodideWorker
|
||||
} else {
|
||||
output("Reusing existing Pyodide instance...\n");
|
||||
}
|
||||
output("Preloading packages...\n");
|
||||
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 *'''})
|
||||
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
|
||||
running.value = false; // Indicate that Pyodide is ready
|
||||
output("Pyodide worker initialized.\n");
|
||||
}
|
||||
|
||||
setupPyodide()
|
||||
|
||||
async function runCode() {
|
||||
if (pyodideWorker === null) {
|
||||
output("Pyodide worker is not initialized. Please wait...\n");
|
||||
return;
|
||||
}
|
||||
if (running.value) {
|
||||
output("Pyodide is already running. Please wait...\n");
|
||||
return;
|
||||
}
|
||||
output("Running code...\n");
|
||||
try {
|
||||
running.value = true;
|
||||
await asyncRun(code.value, output);
|
||||
await pyodideWorker.asyncRun(code.value, 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);
|
||||
onModelData(modelData);
|
||||
} else {
|
||||
output(msg); // Print other messages directly
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
output(`Error running initial code: ${e}\n`);
|
||||
} finally {
|
||||
running.value = false; // Indicate that Pyodide is ready
|
||||
}
|
||||
}
|
||||
|
||||
const yacvServerModelPrefix = "yacv_server://model/";
|
||||
|
||||
function onModelData(modelData: string) {
|
||||
output(`Model data detected... ${modelData.length}B\n`);
|
||||
// Decode the model data and emit the event for the interface to handle
|
||||
// - Start by finding the end of the initial json object by looking for brackets.
|
||||
let i = 0;
|
||||
let openBrackets = 0;
|
||||
for (; i < modelData.length; i++) {
|
||||
if (modelData[i] === '{') openBrackets++;
|
||||
else if (modelData[i] === '}') openBrackets--;
|
||||
if (openBrackets === 0) break; // Found the end of the JSON object
|
||||
}
|
||||
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)
|
||||
// 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)
|
||||
if (!modelMetadata.isRemove) {
|
||||
const binaryData = Base64.toUint8Array(modelData.slice(i + 1)); // Extract the base64 part
|
||||
console.assert(binaryData.slice(0, 4).toString() == "103,108,84,70", // Ugly...
|
||||
"Invalid GLTF binary data received: " + binaryData.slice(0, 4).toString());
|
||||
// - Create a Blob from the binary data to be used as a URL
|
||||
const blob = new Blob([binaryData], {type: 'model/gltf-binary'});
|
||||
modelMetadata.url = URL.createObjectURL(blob); // Set the hacked URL in the model metadata
|
||||
}
|
||||
// - Emit the event with the model metadata and URL
|
||||
let networkUpdateEvent = new NetworkUpdateEvent([modelMetadata], () => {
|
||||
});
|
||||
emit('updateModel', networkUpdateEvent);
|
||||
}
|
||||
|
||||
function resetWorker() {
|
||||
code.value = props.initialCode; // Reset code to initial state
|
||||
if (pyodideWorker) {
|
||||
pyodideWorker.terminate(); // Terminate existing worker
|
||||
pyodideWorker = null; // Reset worker reference
|
||||
}
|
||||
outputText.value = ``; // Clear output text
|
||||
setupPyodide(); // Reinitialize Pyodide
|
||||
}
|
||||
|
||||
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()}`;
|
||||
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`));
|
||||
}
|
||||
|
||||
const reused = (import.meta as any).hot?.data?.pyodideWorker !== undefined;
|
||||
setupPyodide().then(() => {
|
||||
if (props.initialCode != "" && !reused) runCode();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -94,7 +174,7 @@ async function runCode() {
|
||||
|
||||
<!-- TODO: snapshots... -->
|
||||
|
||||
<v-btn icon @click="resetState()">
|
||||
<v-btn icon @click="resetWorker()">
|
||||
<svg-icon :path="mdiReload" type="mdi"/>
|
||||
</v-btn>
|
||||
|
||||
@@ -102,6 +182,10 @@ async function runCode() {
|
||||
<svg-icon :path="mdiPlay" type="mdi"/>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="shareLink()">
|
||||
<svg-icon :path="mdiShare" type="mdi"/>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="emit('close')">
|
||||
<svg-icon :path="mdiClose" type="mdi"/>
|
||||
</v-btn>
|
||||
@@ -110,13 +194,8 @@ async function runCode() {
|
||||
<!-- Only show content if opacity is greater than 0 -->
|
||||
<div class="playground-container">
|
||||
<div class="playground-editor">
|
||||
<VueMonacoEditor
|
||||
v-model:value="code"
|
||||
:theme="editorTheme"
|
||||
:options="MONACO_EDITOR_OPTIONS"
|
||||
language="python"
|
||||
@mount="handleMount"
|
||||
/>
|
||||
<VueMonacoEditor v-model:value="code" :theme="editorTheme" :options="MONACO_EDITOR_OPTIONS"
|
||||
language="python" @mount="handleMount"/>
|
||||
</div>
|
||||
<div class="playground-console">
|
||||
<h3>Console Output</h3>
|
||||
|
||||
@@ -21,6 +21,8 @@ import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||
import {defineAsyncComponent, ref, type Ref} from "vue";
|
||||
import type {SelectionInfo} from "./selection";
|
||||
import {settings} from "../misc/settings.ts";
|
||||
import type {NetworkUpdateEvent} from "../misc/network.ts";
|
||||
import IfNotSmallBuild from "../misc/IfNotSmallBuild.vue";
|
||||
|
||||
const SelectionComponent = defineAsyncComponent({
|
||||
loader: () => import("./Selection.vue"),
|
||||
@@ -43,7 +45,7 @@ const PlaygroundDialogContent = defineAsyncComponent({
|
||||
|
||||
|
||||
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||
const emit = defineEmits<{ findModel: [string] }>()
|
||||
const emit = defineEmits<{ findModel: [string], updateModel: [NetworkUpdateEvent] }>()
|
||||
|
||||
const sett = ref<any | null>(null);
|
||||
const showPlaygroundDialog = ref(false);
|
||||
@@ -162,14 +164,17 @@ window.addEventListener('keydown', (event) => {
|
||||
<h5>Extras</h5>
|
||||
<v-dialog v-model="showPlaygroundDialog" persistent :scrim="false" attach="body">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon v-bind="props" style="width: 100%">
|
||||
<v-btn v-bind="props" style="width: 100%">
|
||||
<v-tooltip activator="parent">Open a python editor and build models directly in the browser!</v-tooltip>
|
||||
<svg-icon :path="mdiScriptTextPlay" type="mdi"/>
|
||||
Sandbox
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<playground-dialog-content v-if="sett != null" :initial-code="sett.value.code" @close="isActive.value = false"/>
|
||||
<if-not-small-build>
|
||||
<playground-dialog-content v-if="sett != null" :initial-code="sett.code" @close="isActive.value = false"
|
||||
@update-model="(event: NetworkUpdateEvent) => emit('updateModel', event)"/>
|
||||
</if-not-small-build>
|
||||
</template>
|
||||
</v-dialog>
|
||||
<v-btn icon @click="downloadSceneGlb">
|
||||
|
||||
54
frontend/tools/b66.ts
Normal file
54
frontend/tools/b66.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,44 +1,31 @@
|
||||
// Each message needs a unique id to identify the response. In a real example,
|
||||
// we might use a real uuid package
|
||||
let lastId = 1;
|
||||
import type {loadPyodide} from "pyodide";
|
||||
|
||||
function getId() {
|
||||
return lastId++;
|
||||
/** Simple API for the Pyodide worker. */
|
||||
export function newPyodideWorker(initOpts: Parameters<typeof loadPyodide>[0]) {
|
||||
let worker = new Worker(new URL('./pyodide-worker.ts', import.meta.url), {type: "module"});
|
||||
worker.postMessage(initOpts);
|
||||
return {
|
||||
asyncRun: (code: String, stdout: (msg: string) => void, stderr: (msg: string) => void) => new Promise((resolve, reject) => {
|
||||
worker.addEventListener("message", function listener(event) {
|
||||
if (event.data?.stdout) {
|
||||
stdout(event.data.stdout);
|
||||
return;
|
||||
}
|
||||
if (event.data?.stderr) {
|
||||
stderr(event.data.stderr);
|
||||
return;
|
||||
}
|
||||
// Result or error.
|
||||
worker.removeEventListener("message", listener);
|
||||
if (event.data?.error) {
|
||||
reject(event.data.error);
|
||||
} else {
|
||||
resolve(event.data?.result);
|
||||
}
|
||||
});
|
||||
worker.postMessage(code);
|
||||
}),
|
||||
terminate: () => worker.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
// Add an id to msg, send it to worker, then wait for a response with the same id.
|
||||
// When we get such a response, use it to resolve the promise.
|
||||
function requestResponse(worker: Worker, code: String, output: (msg: string) => void) {
|
||||
return new Promise((resolve) => {
|
||||
const idWorker = getId();
|
||||
worker.addEventListener("message", function listener(event) {
|
||||
if (event.data?.stdout) {
|
||||
output(event.data.stdout + "\n");
|
||||
return;
|
||||
}
|
||||
if (event.data?.stderr) {
|
||||
output(event.data.stderr + "\n");
|
||||
return;
|
||||
}
|
||||
if (event.data?.id !== idWorker) return;
|
||||
// This listener is done so remove it.
|
||||
worker.removeEventListener("message", listener);
|
||||
// Filter the id out of the result
|
||||
const {id, ...rest} = event.data;
|
||||
resolve(rest);
|
||||
});
|
||||
worker.postMessage({id: idWorker, code});
|
||||
});
|
||||
}
|
||||
|
||||
export function asyncRun(code: String, output: (msg: string) => void) {
|
||||
return requestResponse(pyodideWorker as Worker, code, output);
|
||||
}
|
||||
|
||||
export function resetState() {
|
||||
// Reset the worker state by terminating it and creating a new one.
|
||||
if (pyodideWorker) pyodideWorker.terminate();
|
||||
pyodideWorker = new Worker(new URL('./pyodide-worker.ts', import.meta.url), {type: "module"});
|
||||
}
|
||||
|
||||
export let pyodideWorker: Worker | null = null;
|
||||
@@ -1,27 +1,33 @@
|
||||
import {loadPyodide, version} from "pyodide";
|
||||
import {loadPyodide, type PyodideInterface} from "pyodide";
|
||||
|
||||
let pyodideReadyPromise = loadPyodide({
|
||||
indexURL: `https://cdn.jsdelivr.net/pyodide/v${version}/full/`, // FIXME: Local deployment?
|
||||
packages: ["micropip", "sqlite3"], // Preloaded faster here...
|
||||
stdout: (msg) => self.postMessage({stdout: msg}),
|
||||
stderr: (msg) => self.postMessage({stderr: msg}),
|
||||
let myLoadPyodide = (initOpts: Parameters<typeof loadPyodide>[0]) => loadPyodide({
|
||||
...initOpts,
|
||||
stdout: (msg) => self.postMessage({stdout: msg + "\n"}), // Add newline for better readability
|
||||
stderr: (msg) => self.postMessage({stderr: msg + "\n"}), // Add newline for better readability
|
||||
stdin: () => {
|
||||
console.warn("Input requested by Python code, but stdin is not supported in this playground.");
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
self.onmessage = async (event) => {
|
||||
let pyodideReadyPromise: Promise<PyodideInterface> | null = null;
|
||||
|
||||
self.onmessage = async (event: MessageEvent<any>) => {
|
||||
if (!pyodideReadyPromise) { // First message is always the init message
|
||||
// If we haven't loaded Pyodide yet, do so now.
|
||||
// This is a singleton, so we only load it once.
|
||||
pyodideReadyPromise = myLoadPyodide(event.data as Parameters<typeof loadPyodide>[0]);
|
||||
return;
|
||||
}
|
||||
// All other messages are code to run.
|
||||
let code = event.data as string;
|
||||
// make sure loading is done
|
||||
const pyodide = await pyodideReadyPromise;
|
||||
const {id, code} = event.data;
|
||||
// Now load any packages we need, run the code, and send the result back.
|
||||
await pyodide.loadPackagesFromImports(code);
|
||||
try {
|
||||
// Execute the python code in this context
|
||||
const result = await pyodide.runPythonAsync(code);
|
||||
self.postMessage({result, id});
|
||||
self.postMessage({result: await pyodide.runPythonAsync(code)});
|
||||
} catch (error: any) {
|
||||
self.postMessage({error: error.message, id});
|
||||
self.postMessage({error: error.message});
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user