Compare commits

...

12 Commits

Author SHA1 Message Date
Yeicor
d6deef9e7f Automatically update version to 0.10.0 2025-07-26 15:21:57 +00:00
Yeicor
ad956762f4 Automatically update version to 0.10.0-rc.6 2025-07-26 15:05:40 +00:00
Yeicor
a4acd2f3d3 Revert "fix(deps): update python to >=3.13,<3.14 (#246)"
This reverts commit 0855a9c6c7.
2025-07-26 17:04:18 +02:00
Yeicor
c877fef490 Minor fixes and drag and drop models onto interface 2025-07-26 17:01:42 +02:00
renovate[bot]
0855a9c6c7 fix(deps): update python to >=3.13,<3.14 (#246)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-26 12:19:17 +00:00
Yeicor
657b34d098 Automatically update version to 0.10.0-rc.5 2025-07-26 12:17:21 +00:00
Yeicor
ad83f1c937 Merge remote-tracking branch 'origin/master' 2025-07-26 14:15:17 +02:00
Yeicor
38be4c638b playground: minor improvements 2025-07-26 14:15:09 +02:00
renovate[bot]
63f2b716d6 chore(deps): lock file maintenance 2025-07-26 01:37:40 +00:00
Yeicor
9e70a3998d Automatically update version to 0.10.0-rc.4 2025-07-25 23:14:16 +00:00
Yeicor
c7c4adc250 Merge remote-tracking branch 'origin/master' 2025-07-26 01:13:19 +02:00
Yeicor
393decd876 playground: fix async code usage in startup.py 2025-07-26 01:13:03 +02:00
20 changed files with 197 additions and 138 deletions

View File

@@ -23,9 +23,9 @@ in a web browser.
The [example](example) is a fully working project that shows how to use the viewer. The [example](example) is a fully working project that shows how to use the viewer.
You can play with the latest You can play with the latest
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=logo_hl.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb) demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
(or (or
[without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=logo.glb&preload=logo_hl.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)). [without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)).
![Demo](assets/screenshot.png) ![Demo](assets/screenshot.png)

View File

@@ -53,7 +53,7 @@ async function onModelUpdateRequest(event: NetworkUpdateEvent) {
let model = event.models[modelIndex]; let model = event.models[modelIndex];
tools.value?.removeObjectSelections(model.name); tools.value?.removeObjectSelections(model.name);
try { try {
let loadHelpers = (await settings()).loadHelpers; let loadHelpers = (await settings).loadHelpers;
if (!model.isRemove) { if (!model.isRemove) {
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && loadHelpers, isLast); doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && loadHelpers, isLast);
} else { } else {
@@ -83,7 +83,7 @@ networkMgr.addEventListener('update-early',
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent)); networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
let preloadingModels = ref<Array<string>>([]); let preloadingModels = ref<Array<string>>([]);
(async () => { // Start loading all configured models ASAP (async () => { // Start loading all configured models ASAP
let sett = await settings(); let sett = await settings;
if (sett.preload.length > 0) { if (sett.preload.length > 0) {
watch(viewer, (newViewer) => { watch(viewer, (newViewer) => {
if (newViewer) { if (newViewer) {
@@ -108,6 +108,23 @@ async function loadModelManual() {
const modelUrl = prompt("For an improved experience in viewing CAD/GLTF models with automatic updates, it's recommended to use the official yacv_server Python package. This ensures seamless serving of models and automatic updates.\n\nOtherwise, enter the URL of the model to load:"); const modelUrl = prompt("For an improved experience in viewing CAD/GLTF models with automatic updates, it's recommended to use the official yacv_server Python package. This ensures seamless serving of models and automatic updates.\n\nOtherwise, enter the URL of the model to load:");
if (modelUrl) await networkMgr.load(modelUrl); if (modelUrl) await networkMgr.load(modelUrl);
} }
// Detect dropped .glb files and load them manually
document.body.addEventListener("dragover", e => {
e.preventDefault(); // Allow drop
});
document.body.addEventListener("drop", async e => {
e.preventDefault();
const file = e.dataTransfer?.files?.[0];
if (!file) return;
const ext = file.name.split('.').pop()?.toLowerCase();
if (ext === 'glb' || ext === 'gltf') {
await networkMgr.load(file);
}
});
</script> </script>
<template> <template>

View File

@@ -15,11 +15,11 @@ let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== 'undefined' && __YACV_SMALL_B
* *
* Remember to call mergeFinalize after all models have been merged (slower required operations). * Remember to call mergeFinalize after all models have been merged (slower required operations).
*/ */
export async function mergePartial(url: string, name: string, document: Document, networkFinished: () => void = () => { export async function mergePartial(url: string | Blob, name: string, document: Document, networkFinished: () => void = () => {
}): Promise<Document> { }): Promise<Document> {
// Fetch the complete document from the network // Fetch the complete document from the network
// This could be done at the same time as the document is being processed, but I wanted better metrics // This could be done at the same time as the document is being processed, but I wanted better metrics
let response = await fetch(url); let response = await fetchOrRead(url);
let buffer = await response.arrayBuffer(); let buffer = await response.arrayBuffer();
networkFinished(); networkFinished();
@@ -118,3 +118,30 @@ function mergeScenes(): Transform {
} }
} }
} }
/** Fetches a URL or reads it if it is a Blob URL */
async function fetchOrRead(url: string | Blob) {
if (url instanceof Blob) {
// Use the FileReader API as fetch does not support Blob URLs
return new Promise<Response>((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = (event: ProgressEvent<FileReader>) => {
if (event.target && event.target.result) {
resolve(new Response(event.target.result));
} else {
reject(new Error("Failed to read Blob URL: " + url));
}
};
reader.onerror = (error) => {
reject(new Error("Error reading Blob URL: " + url + " - " + error));
};
// Read the Blob URL as an ArrayBuffer
reader.readAsArrayBuffer(new Blob([url]));
});
} else {
// Fetch the URL
return fetch(url);
}
}

View File

@@ -4,12 +4,12 @@ const batchTimeout = 250; // ms
export class NetworkUpdateEventModel { export class NetworkUpdateEventModel {
name: string; name: string;
url: string; url: string | Blob;
// TODO: Detect and manage instances of the same object (same hash, different name) // TODO: Detect and manage instances of the same object (same hash, different name)
hash: string | null; hash: string | null;
isRemove: boolean | null; // This is null for a shutdown event isRemove: boolean | null; // This is null for a shutdown event
constructor(name: string, url: string, hash: string | null, isRemove: boolean | null) { constructor(name: string, url: string | Blob, hash: string | null, isRemove: boolean | null) {
this.name = name; this.name = name;
this.url = url; this.url = url;
this.hash = hash; this.hash = hash;
@@ -42,18 +42,26 @@ export class NetworkManager extends EventTarget {
* *
* Updates will be emitted as "update" events, including the download URL and the model name. * Updates will be emitted as "update" events, including the download URL and the model name.
*/ */
async load(url: string) { async load(url: string | Blob) {
if (url.startsWith("dev+") || url.startsWith("dev ")) { if (url instanceof String && (url.startsWith("dev+") || url.startsWith("dev "))) {
let baseUrl = new URL(url.slice(4)); let baseUrl = new URL(url.slice(4));
baseUrl.searchParams.set("api_updates", "true"); baseUrl.searchParams.set("api_updates", "true");
await this.monitorDevServer(baseUrl); await this.monitorDevServer(baseUrl);
} else { } else {
// Get the last part of the URL as the "name" of the model let name;
let name = url.split("/").pop(); let hash = null;
name = name?.split(".")[0] || `unknown-${Math.random()}`; if (url instanceof Blob) {
// Use a head request to get the hash of the file if (url instanceof File) name = (url as File).name
let response = await fetch(url, {method: "HEAD"}); else name = `blob-${Math.random()}`;
let hash = response.headers.get("etag"); name = name.replace('.glb', '').replace('.gltf', '');
} else {
// Get the last part of the URL as the "name" of the model
name = url.split("/").pop();
name = name?.split(".")[0] || `unknown-${Math.random()}`;
// Use a head request to get the hash of the file
let response = await fetch(url, {method: "HEAD"});
hash = response.headers.get("etag");
}
// Only trigger an update if the hash has changed // Only trigger an update if the hash has changed
this.foundModel(name, hash, url, false); this.foundModel(name, hash, url, false);
} }
@@ -61,7 +69,7 @@ export class NetworkManager extends EventTarget {
private async monitorDevServer(url: URL, stop: () => boolean = () => false) { private async monitorDevServer(url: URL, stop: () => boolean = () => false) {
while (!stop()) { while (!stop()) {
let monitorEveryMs = (await settings()).monitorEveryMs; let monitorEveryMs = (await settings).monitorEveryMs;
try { try {
// WARNING: This will spam the console logs with failed requests when the server is down // WARNING: This will spam the console logs with failed requests when the server is down
const controller = new AbortController(); const controller = new AbortController();
@@ -92,7 +100,7 @@ export class NetworkManager extends EventTarget {
} }
} }
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean | null, disconnect: () => void = () => { private foundModel(name: string, hash: string | null, url: string | Blob, isRemove: boolean | null, disconnect: () => void = () => {
}) { }) {
// console.debug("Found model", name, "with hash", hash, "at", url, "isRemove", isRemove); // console.debug("Found model", name, "with hash", hash, "at", url, "isRemove", isRemove);

View File

@@ -9,7 +9,7 @@ import {Matrix4} from "three/src/math/Matrix4.js"
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */ /** This class helps manage SceneManagerData. All methods are static to support reactivity... */
export class SceneMgr { export class SceneMgr {
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */ /** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> { static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string | Blob, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
let loadStart = performance.now(); let loadStart = performance.now();
let loadNetworkEnd: number; let loadNetworkEnd: number;
@@ -100,7 +100,9 @@ export class SceneMgr {
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform); newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform); newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)])); let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)]));
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene); let newDocument = await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
URL.revokeObjectURL(helpersUrl);
return newDocument;
} }
/** Serializes the current document into a GLB and updates the viewerSrc */ /** Serializes the current document into a GLB and updates the viewerSrc */
@@ -112,6 +114,7 @@ export class SceneMgr {
let buffer = await toBuffer(document); let buffer = await toBuffer(document);
let blob = new Blob([buffer], {type: 'model/gltf-binary'}); let blob = new Blob([buffer], {type: 'model/gltf-binary'});
console.debug("Showing current doc", document, "with", buffer.length, "total bytes"); console.debug("Showing current doc", document, "with", buffer.length, "total bytes");
if (sceneUrl.value.startsWith("blob:")) URL.revokeObjectURL(sceneUrl.value);
sceneUrl.value = URL.createObjectURL(blob); sceneUrl.value = URL.createObjectURL(blob);
return document; return document;

View File

@@ -1,11 +1,9 @@
// These are the default values for the settings, which are overridden below // These are the default values for the settings, which are overridden below
import {ungzip} from "pako"; import {ungzip} from "pako";
import {b66Decode} from "../tools/b66.ts"; import {b64UrlDecode} from "../tools/b64.ts";
let settingsCache: any = null; const firstTimeNames: Array<string> = []; // Needed for array values, which clear the array when overridden
export const settings = (async () => {
export async function settings() {
if (settingsCache !== null) return settingsCache;
let settings = { let settings = {
preload: [ preload: [
// @ts-ignore // @ts-ignore
@@ -80,6 +78,12 @@ export async function settings() {
// Get the default preload URL if not overridden (requires a fetch that is avoided if possible) // 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++) { for (let i = 0; i < settings.preload.length; i++) {
let url = settings.preload[i]; let url = settings.preload[i];
// Ignore empty preload URLs to allow overriding default auto behavior
if (url === '') {
settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1));
continue; // Skip this preload URL
}
// Handle special <auto> preload URL
if (url === '<auto>') { if (url === '<auto>') {
if (settings.pg_code != "") { // <auto> means no preload URL if code is set if (settings.pg_code != "") { // <auto> means no preload URL if code is set
settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1)); settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1));
@@ -102,9 +106,9 @@ export async function settings() {
// Auto-decompress the code and other playground settings // Auto-decompress the code and other playground settings
if (settings.pg_code.length > 0) { if (settings.pg_code.length > 0) {
try { try {
settings.pg_code = ungzip(b66Decode(settings.pg_code), {to: 'string'}); settings.pg_code = ungzip(b64UrlDecode(settings.pg_code), {to: 'string'});
} catch (error) { } 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 (settings.pg_opacity_loading < 0) {
// If the opacity is not set, use 0.0 if preload is set, otherwise 0.9 // If the opacity is not set, use 0.0 if preload is set, otherwise 0.9
@@ -112,11 +116,9 @@ export async function settings() {
} }
} }
settingsCache = settings;
return settings; return settings;
} })()
const firstTimeNames: Array<string> = []; // Needed for array values, which clear the array when overridden
function parseSetting(name: string, value: string, settings: any): any { function parseSetting(name: string, value: string, settings: any): any {
let arrayElem = name.endsWith(".0") let arrayElem = name.endsWith(".0")
if (arrayElem) name = name.slice(0, -2); if (arrayElem) name = name.slice(0, -2);

View File

@@ -60,7 +60,7 @@ const clipPlaneZ = ref(1);
const clipPlaneSwappedZ = ref(false); const clipPlaneSwappedZ = ref(false);
const edgeWidth = ref(0); const edgeWidth = ref(0);
(async () => { (async () => {
let s = await settings(); let s = await settings;
edgeWidth.value = s.edgeWidth; edgeWidth.value = s.edgeWidth;
})(); })();

View File

@@ -4,26 +4,34 @@ import {VueMonacoEditor} from '@guolao/vue-monaco-editor'
import {nextTick, onMounted, ref, shallowRef} from "vue"; import {nextTick, onMounted, ref, shallowRef} from "vue";
import Loading from "../misc/Loading.vue"; import Loading from "../misc/Loading.vue";
import {newPyodideWorker} from "./pyodide-worker-api.ts"; import {newPyodideWorker} from "./pyodide-worker-api.ts";
import {mdiCircleOpacity, mdiClose, 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"; import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle, VTooltip} from "vuetify/components";
// @ts-expect-error // @ts-expect-error
import SvgIcon from '@jamescoyle/vue-icon'; import SvgIcon from '@jamescoyle/vue-icon';
import {version as pyodideVersion} from "pyodide"; import {version as pyodideVersion} from "pyodide";
import {gzip} from 'pako'; import {gzip} from 'pako';
import {b66Encode} from "./b66.ts"; import {b64UrlEncode} from "./b64.ts";
import {Base64} from 'js-base64'; // More compatible with binary data from python... import {Base64} from 'js-base64'; // More compatible with binary data from python...
import {NetworkUpdateEvent, NetworkUpdateEventModel} from "../misc/network.ts"; import {NetworkUpdateEvent, NetworkUpdateEventModel} from "../misc/network.ts";
import {settings} from "../misc/settings.ts"; import {settings} from "../misc/settings.ts";
// @ts-expect-error // @ts-expect-error
import playgroundStartupCode from './PlaygroundStartup.py?raw'; 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] }>() const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>()
// ============ LOAD MONACO EDITOR ============ // ============ LOAD MONACO EDITOR ============
setupMonaco() // Must be called before using the editor setupMonaco() // Must be called before using the editor
const code = ref((import.meta as any)?.hot?.data?.code || props.initialCode);
const outputText = ref(``); const outputText = ref(``);
function output(text: string) { 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 editorTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? `vs-dark` : `vs`
const editor = shallowRef() const editor = shallowRef()
const handleMount = (editorInstance: typeof VueMonacoEditor) => (editor.value = editorInstance) 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) ============ // ============ LOAD PYODIDE (ASYNC) ============
let pyodideWorker: ReturnType<typeof newPyodideWorker> | null = (import.meta as any).hot?.data?.pyodideWorker || null; let pyodideWorker: ReturnType<typeof newPyodideWorker> | null = (import.meta as any).hot?.data?.pyodideWorker || null;
const running = ref(true); const running = ref(true);
async function setupPyodide() { async function setupPyodide(first: boolean, loadSnapshot: Uint8Array | undefined = undefined) {
running.value = true; 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) { if (pyodideWorker === null) {
output("Creating new Pyodide worker...\n"); output("Creating new Pyodide worker...\n");
pyodideWorker = newPyodideWorker({ pyodideWorker = newPyodideWorker(Object.assign({
indexURL: `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`, // FIXME: Local deployment? // 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 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 if ((import.meta as any).hot) (import.meta as any).hot.data.pyodideWorker = pyodideWorker
} else { } else {
output("Reusing existing Pyodide instance...\n"); output("Reusing existing Pyodide instance...\n");
@@ -73,7 +84,7 @@ async function setupPyodide() {
output("Preloading packages...\n"); output("Preloading packages...\n");
await pyodideWorker.asyncRun(playgroundStartupCode, output, output); // Also import yacv_server and mock ocp_vscode here for faster custom code execution 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 running.value = false; // Indicate that Pyodide is ready
output("Pyodide worker initialized.\n"); output("Pyodide worker ready.\n");
} }
async function runCode() { async function runCode() {
@@ -88,8 +99,7 @@ async function runCode() {
output("Running code...\n"); output("Running code...\n");
try { try {
running.value = true; 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(model.value.code, output, (msg: string) => {
await pyodideWorker.asyncRun(code.value, output, (msg: string) => {
// Detect models printed to console (since http server is not available in pyodide) // Detect models printed to console (since http server is not available in pyodide)
if (msg.startsWith(yacvServerModelPrefix)) { if (msg.startsWith(yacvServerModelPrefix)) {
const modelData = msg.slice(yacvServerModelPrefix.length); const modelData = msg.slice(yacvServerModelPrefix.length);
@@ -99,7 +109,7 @@ async function runCode() {
} }
}); });
} catch (e) { } catch (e) {
output(`Error running initial code: ${e}\n`); output(`Error running code: ${e}\n`);
} finally { } finally {
running.value = false; // Indicate that Pyodide is ready running.value = false; // Indicate that Pyodide is ready
} }
@@ -131,7 +141,7 @@ function onModelData(modelData: string) {
"Invalid GLTF binary data received: " + binaryData.slice(0, 4).toString()); "Invalid GLTF binary data received: " + binaryData.slice(0, 4).toString());
// - Create a Blob from the binary data to be used as a URL // - Create a Blob from the binary data to be used as a URL
const blob = new Blob([binaryData], {type: 'model/gltf-binary'}); const blob = new Blob([binaryData], {type: 'model/gltf-binary'});
modelMetadata.url = URL.createObjectURL(blob); // Set the hacked URL in the model metadata modelMetadata.url = URL.createObjectURL(blob); // Set the hacked URL in the model metadata XXX: revoked on App.vue
} }
// - Emit the event with the model metadata and URL // - Emit the event with the model metadata and URL
let networkUpdateEvent = new NetworkUpdateEvent([modelMetadata], () => { let networkUpdateEvent = new NetworkUpdateEvent([modelMetadata], () => {
@@ -139,22 +149,26 @@ function onModelData(modelData: string) {
emit('updateModel', networkUpdateEvent); emit('updateModel', networkUpdateEvent);
} }
function resetWorker() { function resetWorker(loadSnapshot: Uint8Array | undefined = undefined) {
if (pyodideWorker) { if (pyodideWorker) {
pyodideWorker.terminate(); // Terminate existing worker pyodideWorker.terminate(); // Terminate existing worker
pyodideWorker = null; // Reset worker reference pyodideWorker = null; // Reset worker reference
} }
outputText.value = ``; // Clear output text outputText.value = ``; // Clear output text
setupPyodide(); // Reinitialize Pyodide setupPyodide(false, loadSnapshot); // Reinitialize Pyodide
} }
function shareLink() { function shareLink() {
const baseUrl = window.location const baseUrl = window.location
const urlParams = new URLSearchParams(baseUrl.hash.slice(1)); // Keep all previous URL parameters const searchParams = new URLSearchParams(baseUrl.search);
urlParams.set('pg_code', b66Encode(gzip(code.value, {level: 9}))); // Compress and encode the code searchParams.delete('pg_code_url'); // Remove any existing pg_code parameter
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}${baseUrl.search}#${urlParams.toString()}`; // Prefer hash to GET (bigger limits) 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`) 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"); output("Clipboard API not available. Please copy the link manually.\n");
return; return;
} else { } else {
@@ -172,27 +186,28 @@ function loadSnapshot() {
throw new Error("Not implemented yet!"); // TODO: Implement snapshot loading throw new Error("Not implemented yet!"); // TODO: Implement snapshot loading
} }
const reused = (import.meta as any).hot?.data?.pyodideWorker !== undefined;
(async () => { (async () => {
const sett = await settings() const sett = await settings
if (!reused) opacity.value = sett.pg_opacity_loading if (model.value.firstTime) opacity.value = sett.pg_opacity_loading
await setupPyodide() await setupPyodide(true);
if (props.initialCode != "" && !reused) await runCode(); if (model.value.firstTime) {
if (!reused) opacity.value = sett.pg_opacity_loaded await runCode();
opacity.value = sett.pg_opacity_loaded
model.value.firstTime = false
}
})() })()
// Add keyboard shortcuts // Add keyboard shortcuts
const editorRef = ref<HTMLElement | null>(null); const editorRef = ref<HTMLElement | null>(null);
onMounted(() => { onMounted(() => {
if (editorRef.value) { if (editorRef.value) {
console.log(editorRef.value)
editorRef.value.addEventListener('keydown', (event: Event) => { editorRef.value.addEventListener('keydown', (event: Event) => {
if (!(event instanceof KeyboardEvent)) return; // Ensure event is a KeyboardEvent if (!(event instanceof KeyboardEvent)) return; // Ensure event is a KeyboardEvent
if (event.key === 'Enter' && event.ctrlKey) { if (event.key === 'F10') { // Run code on F10
event.preventDefault(); // Prevent default behavior of Enter key event.preventDefault(); // Prevent default behavior of the key
runCode(); // Run code on Ctrl+Enter runCode();
} else if (event.key === 'Escape') { } else if (event.key === 'Escape') { // Close on Escape key
emit('close'); // Close on Escape key emit('close');
} }
}); });
} }
@@ -256,11 +271,17 @@ onMounted(() => {
<!-- Only show content if opacity is greater than 0 --> <!-- Only show content if opacity is greater than 0 -->
<div class="playground-container"> <div class="playground-container">
<div class="playground-editor" ref="editorRef"> <div class="playground-editor" ref="editorRef">
<VueMonacoEditor v-model:value="code" :theme="editorTheme" :options="MONACO_EDITOR_OPTIONS" <VueMonacoEditor v-model:value="model.code" :theme="editorTheme" :options="MONACO_EDITOR_OPTIONS"
language="python" @mount="handleMount"/> language="python" @mount="handleMount"/>
</div> </div>
<div class="playground-console"> <div class="playground-console">
<h3>Console Output</h3> <h3 style="display:flex; align-items: center; justify-content: space-between; margin: 0;">
Console Output
<v-spacer></v-spacer>
<v-btn @click="outputText = ''">
<svg-icon :path="mdiBroom" type="mdi" class="h-"/>
</v-btn>
</h3>
<pre>{{ outputText }}</pre> <!-- Placeholder for console output --> <pre>{{ outputText }}</pre> <!-- Placeholder for console output -->
<Loading v-if="running"/> <Loading v-if="running"/>
</div> </div>

View File

@@ -16,23 +16,21 @@ micropip.add_mock_package("ocp-vscode", "2.8.9", modules={"ocp_vscode": 'from ya
show_object = show show_object = show
# Preinstall a font to avoid issues with no font being available. # Preinstall a font to avoid issues with no font being available.
def install_font_to_ocp(font_url, font_name=None): async def install_font_to_ocp(font_url, font_name=None):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
from pyodide.http import pyfetch from pyodide.http import pyfetch
from OCP.Font import Font_FontMgr, Font_SystemFont, Font_FA_Regular from OCP.Font import Font_FontMgr, Font_SystemFont, Font_FA_Regular
from OCP.TCollection import TCollection_AsciiString 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] 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) font_path = os.path.join("/tmp", font_name)
os.makedirs(os.path.dirname(font_path), exist_ok=True) os.makedirs(os.path.dirname(font_path), exist_ok=True)
# Download the font using pyfetch # Download the font using pyfetch
loop = asyncio.get_event_loop() response = await pyfetch(font_url)
response = loop.run_until_complete(pyfetch(font_url)) font_data = await response.bytes()
font_data = loop.run_until_complete(response.bytes())
# Save it to the system-like folder # Save it to the system-like folder
with open(font_path, "wb") as f: with open(font_path, "wb") as f:
@@ -47,4 +45,4 @@ def install_font_to_ocp(font_url, font_name=None):
# Make sure there is at least one font installed, so that the tests can run # Make sure there is at least one font installed, so that the tests can run
install_font_to_ocp("https://raw.githubusercontent.com/xbmc/xbmc/d3a7f95f3f017b8e861d5d95cc4b33eef4286ce2/media/Fonts/arial.ttf") await install_font_to_ocp("https://raw.githubusercontent.com/xbmc/xbmc/d3a7f95f3f017b8e861d5d95cc4b33eef4286ce2/media/Fonts/arial.ttf")

View File

@@ -28,7 +28,7 @@ import SvgIcon from '@jamescoyle/vue-icon';
import type {ModelViewerElement} from '@google/model-viewer'; import type {ModelViewerElement} from '@google/model-viewer';
import Loading from "../misc/Loading.vue"; import Loading from "../misc/Loading.vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.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 type {SelectionInfo} from "./selection";
import {settings} from "../misc/settings.ts"; import {settings} from "../misc/settings.ts";
import type {NetworkUpdateEvent} from "../misc/network.ts"; import type {NetworkUpdateEvent} from "../misc/network.ts";
@@ -59,12 +59,14 @@ const emit = defineEmits<{ findModel: [string], updateModel: [NetworkUpdateEvent
const sett = ref<any | null>(null); const sett = ref<any | null>(null);
const showPlaygroundDialog = ref(false); const showPlaygroundDialog = ref(false);
const pg_model = ref({code: '# Loading...', firstTime: false});
(async () => { (async () => {
sett.value = await settings(); sett.value = await settings;
showPlaygroundDialog.value = sett.value.pg_code != ""; pg_model.value = {code: sett.value.pg_code, firstTime: true};
showPlaygroundDialog.value = pg_model.value.code != "";
})(); })();
let selection: Ref<Array<SelectionInfo>> = ref([]); let selection = ref<Array<SelectionInfo>>([]);
let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length
let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length
let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length
@@ -126,6 +128,7 @@ async function downloadSceneGlb() {
link.download = file.name; link.download = file.name;
link.href = URL.createObjectURL(file); link.href = URL.createObjectURL(file);
link.click(); link.click();
URL.revokeObjectURL(link.href);
} }
async function openGithub() { async function openGithub() {
@@ -193,7 +196,7 @@ document.addEventListener('keydown', (event) => {
</template> </template>
<template v-slot:default="{ isActive }"> <template v-slot:default="{ isActive }">
<if-not-small-build> <if-not-small-build>
<playground-dialog-content v-if="sett != null" :initial-code="sett.pg_code" @close="isActive.value = false" <playground-dialog-content v-if="sett != null" v-model="pg_model" @close="isActive.value = false"
@update-model="(event: NetworkUpdateEvent) => emit('updateModel', event)"/> @update-model="(event: NetworkUpdateEvent) => emit('updateModel', event)"/>
</if-not-small-build> </if-not-small-build>
</template> </template>

21
frontend/tools/b64.ts Normal file
View File

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

View File

@@ -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);
}

View File

@@ -38,6 +38,7 @@ export function newPyodideWorker(initOpts: Parameters<typeof loadPyodide>[0]) {
mkdirTree: (path: string) => commonRequestResponse({type: "mkdirTree", id: requestId++, path}), mkdirTree: (path: string) => commonRequestResponse({type: "mkdirTree", id: requestId++, path}),
writeFile: (path: string, content: string) => writeFile: (path: string, content: string) =>
commonRequestResponse({type: "writeFile", id: requestId++, path, content}), commonRequestResponse({type: "writeFile", id: requestId++, path, content}),
makeSnapshot: () => commonRequestResponse({type: "makeSnapshot", id: requestId++}),
terminate: () => worker.terminate() terminate: () => worker.terminate()
} }
} }

View File

@@ -25,6 +25,9 @@ export type MessageEventDataIn = {
id: number; id: number;
path: string; path: string;
content: string; content: string;
} | {
type: 'makeSnapshot';
id: number;
} }
self.onmessage = async (event: MessageEvent<MessageEventDataIn>) => { self.onmessage = async (event: MessageEvent<MessageEventDataIn>) => {
@@ -64,6 +67,15 @@ self.onmessage = async (event: MessageEvent<MessageEventDataIn>) => {
} catch (error: any) { } catch (error: any) {
self.postMessage({id: event.data.id, error: error.message}); 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 { } else {
console.error("Unknown message type:", (event.data as any)?.type); 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}); self.postMessage({id: (event.data as any)?.id, error: "Unknown message type: " + (event.data as any)?.type});

View File

@@ -29,7 +29,7 @@ const renderer = ref<Renderer | null>(null);
const controls = ref<SmoothControls | null>(null); const controls = ref<SmoothControls | null>(null);
const sett = ref<any | null>(null); const sett = ref<any | null>(null);
(async () => sett.value = await settings())(); (async () => sett.value = await settings)();
let lastCameraTargetPosition: Vector3 | undefined = undefined; let lastCameraTargetPosition: Vector3 | undefined = undefined;
let lastCameraZoom: number | undefined = undefined; let lastCameraZoom: number | undefined = undefined;

View File

@@ -5,7 +5,7 @@ import {settings} from "../misc/settings.ts";
export let currentSceneRotation = 0; // radians, 0 is the default rotation export let currentSceneRotation = 0; // radians, 0 is the default rotation
export async function setupLighting(modelViewer: ModelViewerElement) { export async function setupLighting(modelViewer: ModelViewerElement) {
modelViewer[$scene].environmentIntensity = (await settings()).environmentIntensity; modelViewer[$scene].environmentIntensity = (await settings).environmentIntensity;
// Code is mostly copied from the example at: https://modelviewer.dev/examples/stagingandcameras/#turnSkybox // Code is mostly copied from the example at: https://modelviewer.dev/examples/stagingandcameras/#turnSkybox
let lastX: number; let lastX: number;
let panning = false; let panning = false;

View File

@@ -1,6 +1,6 @@
{ {
"name": "yet-another-cad-viewer", "name": "yet-another-cad-viewer",
"version": "0.10.0-rc.3", "version": "0.10.0",
"description": "", "description": "",
"license": "MIT", "license": "MIT",
"private": true, "private": true,

View File

@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
[tool.poetry] [tool.poetry]
name = "yacv-server" name = "yacv-server"
version = "0.10.0-rc.3" version = "0.10.0"
description = "Yet Another CAD Viewer (server)" description = "Yet Another CAD Viewer (server)"
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"] authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
license = "MIT" license = "MIT"

View File

@@ -141,7 +141,6 @@ class YACV:
"""Initializes the YACV server""" """Initializes the YACV server"""
raw_protocol = os.getenv('YACV_PROTOCOL', 'http' if sys.platform != 'emscripten' else 'stderr').upper() raw_protocol = os.getenv('YACV_PROTOCOL', 'http' if sys.platform != 'emscripten' else 'stderr').upper()
self.protocol = YACVProtocol[raw_protocol] if raw_protocol in YACVProtocol.__members__ else YACVProtocol.HTTP self.protocol = YACVProtocol[raw_protocol] if raw_protocol in YACVProtocol.__members__ else YACVProtocol.HTTP
self.protocol = YACVProtocol.STDERR
self.server_thread = None self.server_thread = None
self.server = None self.server = None
self.startup_complete = threading.Event() self.startup_complete = threading.Event()

View File

@@ -2940,6 +2940,7 @@ three-mesh-bvh@^0.9.0:
"three-orientation-gizmo@git+https://github.com/jrj2211/three-orientation-gizmo.git": "three-orientation-gizmo@git+https://github.com/jrj2211/three-orientation-gizmo.git":
version "1.1.0" version "1.1.0"
uid "000281f0559c316f72cdd23a1885d63ae6901095"
resolved "git+https://github.com/jrj2211/three-orientation-gizmo.git#000281f0559c316f72cdd23a1885d63ae6901095" resolved "git+https://github.com/jrj2211/three-orientation-gizmo.git#000281f0559c316f72cdd23a1885d63ae6901095"
dependencies: dependencies:
three "^0.125.0" three "^0.125.0"