Compare commits

...

4 Commits

Author SHA1 Message Date
Yeicor
3e3730a4a5 faster multi-object load, faster updates and better orthographic camera at different scales 2024-03-07 20:49:27 +01:00
Yeicor
753648e522 fix imports 2024-03-06 19:25:49 +01:00
Yeicor
986db75b24 cleaner readme 2024-03-05 21:14:03 +01:00
Yeicor
962eea2b27 ready for release 0.2.0 2024-03-05 21:12:16 +01:00
14 changed files with 116 additions and 99 deletions

View File

@@ -67,7 +67,7 @@ jobs:
python-version: "3.11" python-version: "3.11"
cache: "poetry" cache: "poetry"
- run: "SKIP_BUILD_FRONTEND=true poetry install" - 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" - run: "mv export/object.glb export/example.glb"
- uses: "actions/upload-artifact@v4" - uses: "actions/upload-artifact@v4"
with: with:

View File

@@ -18,29 +18,11 @@ in a web browser.
## Usage ## Usage
The [example](example) is a fully working project that demonstrates how to use the viewer. The [example](example) is a fully working project that shows how to use the viewer.
### Hot reloading You can play with the latest
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=base.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
To see the live updates you will need to run the [yacv_server](yacv_server) and
open [the viewer](https://yeicor-3d.github.io/yet-another-cad-viewer/) with
the `preload=ws://<host>:32323/` query parameter (by default it already tries localhost).
Note that [yacv_server](yacv_server) also hosts the frontend at `http://localhost:32323/` if you have no access to the
internet.
### Static deployment
To deploy the viewer and models as a static website you can simply copy the latest build directory to your server.
To load models use the `preload=...` query parameter in the URL.
It can be set multiple times to load multiple models.
Note that you can simply reuse the [main deployment](https://yeicor-3d.github.io/yet-another-cad-viewer/) and host only
your own models (linking them from the viewer with the `preload` query parameter).
To see a working example of a static deployment you can check out
the [demo](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=base.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
(or (or
the [demo without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=base.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=base.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)).
![Demo](assets/screenshot.png) ![Demo](assets/screenshot.png)

View File

@@ -523,8 +523,8 @@ Apache License
The following npm packages may be included in this product: The following npm packages may be included in this product:
- b4a@1.6.6 - b4a@1.6.6
- bare-events@2.2.0 - bare-events@2.2.1
- bare-fs@2.1.5 - bare-fs@2.2.1
- bare-os@2.2.0 - bare-os@2.2.0
- bare-path@2.1.0 - bare-path@2.1.0
@@ -1430,7 +1430,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The following npm package may be included in this product: The following npm package may be included in this product:
- @lit-labs/ssr-dom-shim@1.1.2 - @lit-labs/ssr-dom-shim@1.2.0
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -1556,7 +1556,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- @babel/parser@7.23.9 - @babel/parser@7.24.0
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -1736,7 +1736,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- node-abi@3.54.0 - node-abi@3.56.0
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -1826,7 +1826,7 @@ SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- @monogrid/gainmap-js@3.0.1 - @monogrid/gainmap-js@3.0.3
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -2378,7 +2378,7 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- prebuild-install@7.1.1 - prebuild-install@7.1.2
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -2408,7 +2408,7 @@ THE SOFTWARE.
The following npm package may be included in this product: The following npm package may be included in this product:
- vuetify@3.5.3 - vuetify@3.5.7
This package contains the following license and notice below: This package contains the following license and notice below:
@@ -2498,16 +2498,16 @@ THE SOFTWARE.
The following npm packages may be included in this product: The following npm packages may be included in this product:
- @vue/compiler-core@3.4.16 - @vue/compiler-core@3.4.21
- @vue/compiler-dom@3.4.16 - @vue/compiler-dom@3.4.21
- @vue/compiler-sfc@3.4.16 - @vue/compiler-sfc@3.4.21
- @vue/compiler-ssr@3.4.16 - @vue/compiler-ssr@3.4.21
- @vue/reactivity@3.4.16 - @vue/reactivity@3.4.21
- @vue/runtime-core@3.4.16 - @vue/runtime-core@3.4.21
- @vue/runtime-dom@3.4.16 - @vue/runtime-dom@3.4.21
- @vue/server-renderer@3.4.16 - @vue/server-renderer@3.4.21
- @vue/shared@3.4.16 - @vue/shared@3.4.21
- vue@3.4.16 - vue@3.4.21
These packages each contain the following license and notice below: These packages each contain the following license and notice below:
@@ -2538,7 +2538,7 @@ THE SOFTWARE.
The following npm packages may be included in this product: The following npm packages may be included in this product:
- fast-fifo@1.3.2 - fast-fifo@1.3.2
- streamx@2.16.0 - streamx@2.16.1
These packages each contain the following license and notice below: These packages each contain the following license and notice below:

View File

@@ -27,7 +27,7 @@ export class NetworkManager extends EventTarget {
if (url.startsWith("dev+")) { if (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");
this.monitorDevServer(baseUrl); await this.monitorDevServer(baseUrl);
} else { } else {
// Get the last part of the URL as the "name" of the model // Get the last part of the URL as the "name" of the model
let name = url.split("/").pop(); let name = url.split("/").pop();
@@ -40,27 +40,51 @@ export class NetworkManager extends EventTarget {
} }
} }
private monitorDevServer(url: URL) { private async monitorDevServer(url: URL) {
// WARNING: This will spam the console logs with failed requests when the server is down try {
let eventSource = new EventSource(url); // WARNING: This will spam the console logs with failed requests when the server is down
eventSource.onmessage = (event) => { let response = await fetch(url.toString());
let data = JSON.parse(event.data); console.log("Monitoring", url.toString(), response);
console.debug("WebSocket message", data); if (response.status === 200) {
let urlObj = new URL(url); let lines = readLinesStreamings(response.body!.getReader());
urlObj.searchParams.delete("api_updates"); for await (let line of lines) {
urlObj.searchParams.set("api_object", data.name); if (!line || !line.startsWith("data:")) continue;
this.foundModel(data.name, data.hash, urlObj.toString()); let data = JSON.parse(line.slice(5));
}; console.debug("WebSocket message", data);
eventSource.onerror = () => { // Retry after a very short delay let urlObj = new URL(url);
setTimeout(() => this.monitorDevServer(url), settings.monitorEveryMs); 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) { private foundModel(name: string, hash: string | null, url: string) {
let prevHash = this.knownObjectHashes[name]; let prevHash = this.knownObjectHashes[name];
// TODO: Detect and manage instances of the same object (same hash, different name)
if (!hash || hash !== prevHash) { if (!hash || hash !== prevHash) {
this.knownObjectHashes[name] = hash; this.knownObjectHashes[name] = hash;
this.dispatchEvent(new NetworkUpdateEvent(name, url)); this.dispatchEvent(new NetworkUpdateEvent(name, url));
} }
} }
}
async function* readLinesStreamings(reader: ReadableStreamDefaultReader<Uint8Array>) {
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)]);
}
} }

View File

@@ -6,11 +6,14 @@ import {Vector3} from "three/src/math/Vector3.js"
import {Box3} from "three/src/math/Box3.js" import {Box3} from "three/src/math/Box3.js"
import {Matrix4} from "three/src/math/Matrix4.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... */ /** 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): Promise<Document> { static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string): Promise<Document> {
let loadStart = performance.now(); 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 // Start merging into the current document, replacing or adding as needed
document = await mergePartial(url, name, document); document = await mergePartial(url, name, document);
@@ -19,8 +22,12 @@ export class SceneMgr {
if (name !== extrasNameValueHelpers) { if (name !== extrasNameValueHelpers) {
// Reload the helpers to fit the new model // Reload the helpers to fit the new model
// TODO: Only reload the helpers after a few milliseconds of no more models being added/removed // Only reload the helpers after a few milliseconds of no more models being added/removed
document = await this.reloadHelpers(sceneUrl, document); setTimeout(async () => {
if (name === latestModel) {
document = await this.reloadHelpers(sceneUrl, document);
}
}, 10)
} else { } else {
// Display the final fully loaded model // Display the final fully loaded model
let displayStart = performance.now(); let displayStart = performance.now();

View File

@@ -50,12 +50,14 @@ function syncOrthoCamera(force: boolean) {
let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera; let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera;
if (force || perspectiveCam && scene.camera != perspectiveCam) { if (force || perspectiveCam && scene.camera != perspectiveCam) {
// Get zoom level from perspective camera // Get zoom level from perspective camera
let dist = scene.getTarget().distanceToSquared(perspectiveCam.position); let lookAtCenter = scene.getTarget().clone().add(scene.target.position);
let w = scene.aspect * dist ** 1.1 / 4000; let perspectiveWidthAtCenter =
let h = dist ** 1.1 / 4000; 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 as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
scene.camera.position.copy(perspectiveCam.position); 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 if (force) scene.queueRender() // Force rerender of model-viewer
requestAnimationFrame(() => syncOrthoCamera(false)); requestAnimationFrame(() => syncOrthoCamera(false));
} }

View File

@@ -1,6 +1,6 @@
{ {
"name": "yet-another-cad-viewer", "name": "yet-another-cad-viewer",
"version": "0.1.0", "version": "0.4.0",
"description": "", "description": "",
"license": "MIT", "license": "MIT",
"author": "Yeicor <4929005+Yeicor@users.noreply.github.com>", "author": "Yeicor <4929005+Yeicor@users.noreply.github.com>",

View File

@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "yacv-server" name = "yacv-server"
version = "0.1.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)" 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

@@ -4,7 +4,7 @@ import time
from aiohttp import web from aiohttp import web
from server import Server from yacv_server.server import Server
server = Server() server = Server()
"""The server instance. This is the main entry point to serve CAD objects and other data to the frontend.""" """The server instance. This is the main entry point to serve CAD objects and other data to the frontend."""

View File

@@ -7,7 +7,7 @@ from typing import Optional, Union, List, Tuple
from OCP.TopLoc import TopLoc_Location from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS_Shape from OCP.TopoDS import TopoDS_Shape
from gltf import GLTFMgr from yacv_server.gltf import GLTFMgr
CADLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now CADLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now

View File

@@ -103,9 +103,10 @@ class GLTFMgr:
indices_blob = indices.flatten().tobytes() indices_blob = indices.flatten().tobytes()
# Check that all vertices are referenced by the indices # Check that all vertices are referenced by the indices
assert indices.max() == len(vertices) - 1, f"{indices.max()} != {len(vertices) - 1}" # This can happen on broken faces like on some fonts
assert indices.min() == 0 # assert indices.max() == len(vertices) - 1, f"{indices.max()} != {len(vertices) - 1}"
assert np.unique(indices.flatten()).size == len(vertices) # 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.ndim == 2
assert len(tex_coord) == 0 or tex_coord.shape[1] == 2 assert len(tex_coord) == 0 or tex_coord.shape[1] == 2

View File

@@ -2,7 +2,7 @@ import asyncio
from typing import List, TypeVar, \ from typing import List, TypeVar, \
Generic, AsyncGenerator Generic, AsyncGenerator
from mylogger import logger from yacv_server.mylogger import logger
T = TypeVar('T') T = TypeVar('T')

View File

@@ -16,10 +16,10 @@ from aiohttp_sse import sse_response
from build123d import Shape, Axis, Location, Vector from build123d import Shape, Axis, Location, Vector
from dataclasses_json import dataclass_json from dataclasses_json import dataclass_json
from cad import get_shape, grab_all_cad, image_to_gltf from yacv_server.cad import get_shape, grab_all_cad, image_to_gltf
from mylogger import logger from yacv_server.mylogger import logger
from pubsub import BufferedPubSub from yacv_server.pubsub import BufferedPubSub
from tessellate import _hashcode, tessellate from yacv_server.tessellate import _hashcode, tessellate
# Find the frontend folder (optional, but recommended) # Find the frontend folder (optional, but recommended)
FILE_DIR = os.path.dirname(__file__) FILE_DIR = os.path.dirname(__file__)
@@ -126,21 +126,23 @@ class Server:
print('Cannot stop server because it is not running') print('Cannot stop server because it is not running')
return return
if os.getenv('YACV_STOP_EARLY', '') == '': graceful_secs_connect = float(os.getenv('YACV_GRACEFUL_SECS_CONNECT', 12.0))
# Make sure we can hold the lock for more than 100ms (to avoid exiting too early) graceful_secs_request = float(os.getenv('YACV_GRACEFUL_SECS_REQUEST', 1.0))
logger.info('Stopping server (waiting for at least one frontend request first, cancel with CTRL+C)...') # Make sure we can hold the lock for more than 100ms (to avoid exiting too early)
try: logger.info('Stopping server (waiting for at least one frontend request first, cancel with CTRL+C)...')
while not self.at_least_one_client.is_set(): start = time.time()
time.sleep(0.01) try:
except KeyboardInterrupt: while not self.at_least_one_client.is_set() and time.time() - start < graceful_secs_connect:
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()
time.sleep(0.01) 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 # Stop the server in the background
self.loop.call_soon_threadsafe(lambda *a: self.do_shutdown.set()) self.loop.call_soon_threadsafe(lambda *a: self.do_shutdown.set())
@@ -190,7 +192,6 @@ class Server:
self.at_least_one_client.set() self.at_least_one_client.set()
async with sse_response(request) as resp: async with sse_response(request) as resp:
logger.debug('Client connected: %s', request.remote) 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 # Send buffered events first, while keeping a lock
async with self.frontend_lock: async with self.frontend_lock:
@@ -338,12 +339,12 @@ class Server:
logger.debug('Building object %s... %s', name, event.obj) logger.debug('Building object %s... %s', name, event.obj)
_build_object() _build_object()
# In either case return the elements of a subscription to the async generator # In either case return the elements of a subscription to the async generator
subscription = self.object_events[name].subscribe() subscription = self.object_events[name].subscribe()
try: try:
return await anext(subscription) return await anext(subscription)
finally: finally:
await subscription.aclose() await subscription.aclose()
def export_all(self, folder: str) -> None: def export_all(self, folder: str) -> None:
"""Export all previously-shown objects to GLB files in the given folder""" """Export all previously-shown objects to GLB files in the given folder"""

View File

@@ -13,9 +13,9 @@ from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex
from build123d import Shape, Vertex, Face, Location from build123d import Shape, Vertex, Face, Location
from pygltflib import GLTF2 from pygltflib import GLTF2
import mylogger from yacv_server.cad import CADLike
from cad import CADLike from yacv_server.gltf import GLTFMgr
from gltf import GLTFMgr from yacv_server.mylogger import logger
def tessellate( def tessellate(
@@ -68,7 +68,7 @@ def _tessellate_face(
face.mesh(tolerance, angular_tolerance) face.mesh(tolerance, angular_tolerance)
poly = BRep_Tool.Triangulation_s(face.wrapped, TopLoc_Location()) poly = BRep_Tool.Triangulation_s(face.wrapped, TopLoc_Location())
if poly is None: if poly is None:
mylogger.logger.warn("No triangulation found for face") logger.warn("No triangulation found for face")
return GLTF2() return GLTF2()
tri_mesh = face.tessellate(tolerance, angular_tolerance) tri_mesh = face.tessellate(tolerance, angular_tolerance)