From 3e3730a4a550f45ec6a757f254baeba1efc6ac35 Mon Sep 17 00:00:00 2001 From: Yeicor <4929005+Yeicor@users.noreply.github.com> Date: Thu, 7 Mar 2024 20:49:27 +0100 Subject: [PATCH] faster multi-object load, faster updates and better orthographic camera at different scales --- .github/workflows/build.yml | 2 +- frontend/misc/network.ts | 52 +++++++++++++++++++++++++++---------- frontend/misc/scene.ts | 11 ++++++-- frontend/tools/Tools.vue | 10 ++++--- package.json | 2 +- pyproject.toml | 2 +- yacv_server/gltf.py | 7 ++--- yacv_server/server.py | 43 +++++++++++++++--------------- yacv_server/tessellate.py | 2 +- 9 files changed, 83 insertions(+), 48 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c58f963..0d157c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -67,7 +67,7 @@ jobs: python-version: "3.11" cache: "poetry" - run: "SKIP_BUILD_FRONTEND=true poetry install" - - run: "PYTHONPATH=yacv_server YACV_STOP_EARLY=true poetry run python example/object.py" + - run: "PYTHONPATH=yacv_server YACV_DISABLE_SERVER=true poetry run python example/object.py" - run: "mv export/object.glb export/example.glb" - uses: "actions/upload-artifact@v4" with: diff --git a/frontend/misc/network.ts b/frontend/misc/network.ts index 39cd090..2d03a3d 100644 --- a/frontend/misc/network.ts +++ b/frontend/misc/network.ts @@ -27,7 +27,7 @@ export class NetworkManager extends EventTarget { if (url.startsWith("dev+")) { let baseUrl = new URL(url.slice(4)); baseUrl.searchParams.set("api_updates", "true"); - this.monitorDevServer(baseUrl); + await this.monitorDevServer(baseUrl); } else { // Get the last part of the URL as the "name" of the model let name = url.split("/").pop(); @@ -40,27 +40,51 @@ export class NetworkManager extends EventTarget { } } - private monitorDevServer(url: URL) { - // WARNING: This will spam the console logs with failed requests when the server is down - let eventSource = new EventSource(url); - eventSource.onmessage = (event) => { - let data = JSON.parse(event.data); - console.debug("WebSocket message", data); - let urlObj = new URL(url); - urlObj.searchParams.delete("api_updates"); - urlObj.searchParams.set("api_object", data.name); - this.foundModel(data.name, data.hash, urlObj.toString()); - }; - eventSource.onerror = () => { // Retry after a very short delay - setTimeout(() => this.monitorDevServer(url), settings.monitorEveryMs); + private async monitorDevServer(url: URL) { + try { + // WARNING: This will spam the console logs with failed requests when the server is down + let response = await fetch(url.toString()); + console.log("Monitoring", url.toString(), response); + if (response.status === 200) { + let lines = readLinesStreamings(response.body!.getReader()); + for await (let line of lines) { + if (!line || !line.startsWith("data:")) continue; + let data = JSON.parse(line.slice(5)); + console.debug("WebSocket message", data); + let urlObj = new URL(url); + urlObj.searchParams.delete("api_updates"); + urlObj.searchParams.set("api_object", data.name); + this.foundModel(data.name, data.hash, urlObj.toString()); + } + } + } catch (e) { // Ignore errors (retry very soon) } + setTimeout(() => this.monitorDevServer(url), settings.monitorEveryMs); + return; } private foundModel(name: string, hash: string | null, url: string) { let prevHash = this.knownObjectHashes[name]; + // TODO: Detect and manage instances of the same object (same hash, different name) if (!hash || hash !== prevHash) { this.knownObjectHashes[name] = hash; this.dispatchEvent(new NetworkUpdateEvent(name, url)); } } +} + +async function* readLinesStreamings(reader: ReadableStreamDefaultReader) { + let decoder = new TextDecoder(); + let buffer = new Uint8Array(); + while (true) { + let {value, done} = await reader.read(); + if (done || !value) break; + buffer = new Uint8Array([...buffer, ...value]); + let text = decoder.decode(buffer); + let lines = text.split("\n"); + for (let i = 0; i < lines.length - 1; i++) { + yield lines[i]; + } + buffer = new Uint8Array([...buffer.slice(-1)]); + } } \ No newline at end of file diff --git a/frontend/misc/scene.ts b/frontend/misc/scene.ts index 9f5159f..01284c3 100644 --- a/frontend/misc/scene.ts +++ b/frontend/misc/scene.ts @@ -6,11 +6,14 @@ import {Vector3} from "three/src/math/Vector3.js" import {Box3} from "three/src/math/Box3.js" import {Matrix4} from "three/src/math/Matrix4.js" +let latestModel: string | null = null; + /** This class helps manage SceneManagerData. All methods are static to support reactivity... */ export class SceneMgr { /** 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, document: Document, name: string, url: string): Promise { let loadStart = performance.now(); + latestModel = name; // To help load helpers only once per model load batch // Start merging into the current document, replacing or adding as needed document = await mergePartial(url, name, document); @@ -19,8 +22,12 @@ export class SceneMgr { if (name !== extrasNameValueHelpers) { // Reload the helpers to fit the new model - // TODO: Only reload the helpers after a few milliseconds of no more models being added/removed - document = await this.reloadHelpers(sceneUrl, document); + // Only reload the helpers after a few milliseconds of no more models being added/removed + setTimeout(async () => { + if (name === latestModel) { + document = await this.reloadHelpers(sceneUrl, document); + } + }, 10) } else { // Display the final fully loaded model let displayStart = performance.now(); diff --git a/frontend/tools/Tools.vue b/frontend/tools/Tools.vue index 1d32f27..3a15a4b 100644 --- a/frontend/tools/Tools.vue +++ b/frontend/tools/Tools.vue @@ -50,12 +50,14 @@ function syncOrthoCamera(force: boolean) { let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera; if (force || perspectiveCam && scene.camera != perspectiveCam) { // Get zoom level from perspective camera - let dist = scene.getTarget().distanceToSquared(perspectiveCam.position); - let w = scene.aspect * dist ** 1.1 / 4000; - let h = dist ** 1.1 / 4000; + let lookAtCenter = scene.getTarget().clone().add(scene.target.position); + let perspectiveWidthAtCenter = + 2 * Math.tan(perspectiveCam.fov * Math.PI / 180 / 2) * perspectiveCam.position.distanceTo(lookAtCenter); + let w = perspectiveWidthAtCenter; + let h = perspectiveWidthAtCenter / scene.aspect; (scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far); scene.camera.position.copy(perspectiveCam.position); - scene.camera.lookAt(scene.getTarget().clone().add(scene.target.position)); + scene.camera.lookAt(lookAtCenter); if (force) scene.queueRender() // Force rerender of model-viewer requestAnimationFrame(() => syncOrthoCamera(false)); } diff --git a/package.json b/package.json index d85597d..a53e67c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yet-another-cad-viewer", - "version": "0.3.0", + "version": "0.4.0", "description": "", "license": "MIT", "author": "Yeicor <4929005+Yeicor@users.noreply.github.com>", diff --git a/pyproject.toml b/pyproject.toml index e21a5fb..0e4d18a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "yacv-server" -version = "0.3.0" # TODO: Update automatically by CI on release (also for package.json!) +version = "0.4.0" # TODO: Update automatically by CI on release (also for package.json!) description = "Yet Another CAD Viewer (server)" authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"] license = "MIT" diff --git a/yacv_server/gltf.py b/yacv_server/gltf.py index 6248df7..7f62ebb 100644 --- a/yacv_server/gltf.py +++ b/yacv_server/gltf.py @@ -103,9 +103,10 @@ class GLTFMgr: indices_blob = indices.flatten().tobytes() # Check that all vertices are referenced by the indices - assert indices.max() == len(vertices) - 1, f"{indices.max()} != {len(vertices) - 1}" - assert indices.min() == 0 - assert np.unique(indices.flatten()).size == len(vertices) + # This can happen on broken faces like on some fonts + # assert indices.max() == len(vertices) - 1, f"{indices.max()} != {len(vertices) - 1}" + # assert indices.min() == 0, f"min({indices}) != 0" + # assert np.unique(indices.flatten()).size == len(vertices) assert len(tex_coord) == 0 or tex_coord.ndim == 2 assert len(tex_coord) == 0 or tex_coord.shape[1] == 2 diff --git a/yacv_server/server.py b/yacv_server/server.py index 30b308d..86d49bc 100644 --- a/yacv_server/server.py +++ b/yacv_server/server.py @@ -126,21 +126,23 @@ class Server: print('Cannot stop server because it is not running') return - if os.getenv('YACV_STOP_EARLY', '') == '': - # Make sure we can hold the lock for more than 100ms (to avoid exiting too early) - logger.info('Stopping server (waiting for at least one frontend request first, cancel with CTRL+C)...') - try: - while not self.at_least_one_client.is_set(): - time.sleep(0.01) - except KeyboardInterrupt: - pass - - logger.info('Stopping server (waiting for no more frontend requests)...') - acquired = time.time() - while time.time() - acquired < 1.0: - if self.frontend_lock.locked(): - acquired = time.time() + graceful_secs_connect = float(os.getenv('YACV_GRACEFUL_SECS_CONNECT', 12.0)) + graceful_secs_request = float(os.getenv('YACV_GRACEFUL_SECS_REQUEST', 1.0)) + # Make sure we can hold the lock for more than 100ms (to avoid exiting too early) + logger.info('Stopping server (waiting for at least one frontend request first, cancel with CTRL+C)...') + start = time.time() + try: + while not self.at_least_one_client.is_set() and time.time() - start < graceful_secs_connect: time.sleep(0.01) + except KeyboardInterrupt: + pass + + logger.info('Stopping server (waiting for no more frontend requests)...') + start = time.time() + while time.time() - start < graceful_secs_request: + if self.frontend_lock.locked(): + start = time.time() + time.sleep(0.01) # Stop the server in the background self.loop.call_soon_threadsafe(lambda *a: self.do_shutdown.set()) @@ -190,7 +192,6 @@ class Server: self.at_least_one_client.set() async with sse_response(request) as resp: logger.debug('Client connected: %s', request.remote) - resp.ping_interval = 0.1 # HACK: forces flushing of the buffer # Send buffered events first, while keeping a lock async with self.frontend_lock: @@ -338,12 +339,12 @@ class Server: logger.debug('Building object %s... %s', name, event.obj) _build_object() - # In either case return the elements of a subscription to the async generator - subscription = self.object_events[name].subscribe() - try: - return await anext(subscription) - finally: - await subscription.aclose() + # In either case return the elements of a subscription to the async generator + subscription = self.object_events[name].subscribe() + try: + return await anext(subscription) + finally: + await subscription.aclose() def export_all(self, folder: str) -> None: """Export all previously-shown objects to GLB files in the given folder""" diff --git a/yacv_server/tessellate.py b/yacv_server/tessellate.py index 5b8892b..237002c 100644 --- a/yacv_server/tessellate.py +++ b/yacv_server/tessellate.py @@ -13,9 +13,9 @@ from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex from build123d import Shape, Vertex, Face, Location from pygltflib import GLTF2 -from yacv_server.mylogger import logger from yacv_server.cad import CADLike from yacv_server.gltf import GLTFMgr +from yacv_server.mylogger import logger def tessellate(