diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5b96d79..e78a604 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,6 +5,7 @@ on: pull_request: branches: - "master" + workflow_call: jobs: diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3b545c6..2764b8f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,6 @@ on: push: tags: - "v**" - workflow_dispatch: # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages permissions: @@ -17,9 +16,28 @@ concurrency: cancel-in-progress: false jobs: - # TODO: Update versions automatically + + update-versions: + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v4" + # Write the new version to package.json + - uses: "actions/setup-node@v4" + - run: "yarn version --new-version ${{ github.ref }}" + # Write the new version to pyproject.toml + - run: "pipx install poetry" + - uses: "actions/setup-python@v5" + with: + python-version: "3.11" + cache: "poetry" + - run: "poetry version ${{ github.ref }}" + + rebuild: # Makes sure all artifacts are updated and use the new version + needs: "update-versions" + uses: "./.github/workflows/build.yml" deploy-frontend: + needs: "rebuild" runs-on: "ubuntu-latest" environment: name: "github-pages" @@ -57,4 +75,12 @@ jobs: tag: "${{ github.ref }}" overwrite: true - # TODO: deploy-backend + deploy-backend: + needs: "rebuild" + runs-on: "ubuntu-latest" + steps: + - uses: "actions/checkout@v4" + - uses: "JRubics/poetry-publish@v2" + with: + python-version: "3.11" + pypi_token: "${{ secrets.PYPI_TOKEN }}" diff --git a/example/object.py b/example/object.py index 361c7ef..16a8e51 100644 --- a/example/object.py +++ b/example/object.py @@ -1,12 +1,12 @@ +# Optional: enable logging to see what's happening +import logging import os from build123d import * # Also works with cadquery objects! -# Optional: enable logging to see what's happening -import logging logging.basicConfig(level=logging.DEBUG) -from yacv_server import show_object, export_all # Check out all show_* methods for more features! +from yacv_server import show, export_all # Check out other exported methods for more features! # %% @@ -15,11 +15,11 @@ with BuildPart() as obj: Box(10, 10, 5) Cylinder(4, 5, mode=Mode.SUBTRACT) -# Show it in the frontend -show_object(obj, 'object') +# Show it in the frontend with hot-reloading +show(obj) # %% -# If running on CI, export the object to a .glb file compatible with the frontend +# If running on CI, export the objects to .glb files for a static deployment if 'CI' in os.environ: export_all('export') diff --git a/frontend/App.vue b/frontend/App.vue index 38eb0b9..d7e348a 100644 --- a/frontend/App.vue +++ b/frontend/App.vue @@ -32,11 +32,13 @@ const disableTap = ref(false); const setDisableTap = (val: boolean) => disableTap.value = val; provide('disableTap', {disableTap, setDisableTap}); -async function onModelLoadRequest(event: NetworkUpdateEvent) { - // Load a new batch of models to optimize rendering time +async function onModelUpdateRequest(event: NetworkUpdateEvent) { + // Load/unload a new batch of models to optimize rendering time + console.log("Received model update request", event.models); let doc = sceneDocument.value; - for (let model of event.models) { - let isLast = event.models[event.models.length - 1].url == model.url; + for (let modelIndex in event.models) { + let isLast = parseInt(modelIndex) === event.models.length - 1; + let model = event.models[modelIndex]; if (!model.isRemove) { doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast, isLast); } else { @@ -54,7 +56,7 @@ async function onModelRemoveRequest(name: string) { // Set up the load model event listener let networkMgr = new NetworkManager(); -networkMgr.addEventListener('update', (e) => onModelLoadRequest(e as NetworkUpdateEvent)); +networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent)); // Start loading all configured models ASAP for (let model of settings.preload) { networkMgr.load(model); diff --git a/frontend/index.ts b/frontend/index.ts index 9bc0ae8..16634a5 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -5,6 +5,12 @@ import {createVuetify} from 'vuetify'; import * as directives from 'vuetify/lib/directives/index.mjs'; import 'vuetify/dist/vuetify.css'; +// @ts-ignore +if (__APP_NAME__) { + // @ts-ignore + console.log(`Starting ${__APP_NAME__} v${__APP_VERSION__} (${__APP_GIT_SHA__}${__APP_GIT_DIRTY__ ? "+dirty" : ""})...`); +} + const vuetify = createVuetify({ directives, theme: { diff --git a/frontend/misc/network.ts b/frontend/misc/network.ts index b6c6afe..f599f38 100644 --- a/frontend/misc/network.ts +++ b/frontend/misc/network.ts @@ -82,10 +82,13 @@ export class NetworkManager extends EventTarget { private foundModel(name: string, hash: string | null, url: string, isRemove: boolean) { let prevHash = this.knownObjectHashes[name]; - let hashToCheck = hash + (isRemove ? "-remove" : ""); // console.debug("Found model", name, "with hash", hash, "and previous hash", prevHash); - if (!hash || hashToCheck !== prevHash) { - this.knownObjectHashes[name] = hash; + if (!hash || hash !== prevHash || isRemove) { + if (!isRemove) { + this.knownObjectHashes[name] = hash; + } else { + delete this.knownObjectHashes[name]; + } let newModel = new NetworkUpdateEventModel(name, url, hash, isRemove); this.bufferedUpdates.push(newModel); diff --git a/frontend/misc/scene.ts b/frontend/misc/scene.ts index cde87aa..99feffe 100644 --- a/frontend/misc/scene.ts +++ b/frontend/misc/scene.ts @@ -35,6 +35,7 @@ export class SceneMgr { private static async reloadHelpers(sceneUrl: Ref, document: Document, reloadScene: boolean): Promise { let bb = SceneMgr.getBoundingBox(document); + if (!bb) return document; // Create the helper axes and grid box let helpersDoc = new Document(); @@ -45,7 +46,8 @@ export class SceneMgr { return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene); } - static getBoundingBox(document: Document): Box3 { + static getBoundingBox(document: Document): Box3 | null { + if (document.getRoot().listNodes().length === 0) return null; // Get bounding box of the model and use it to set the size of the helpers let bbMin: number[] = [1e6, 1e6, 1e6]; let bbMax: number[] = [-1e6, -1e6, -1e6]; diff --git a/package.json b/package.json index b458f69..0e83cc3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "yet-another-cad-viewer", - "version": "0.5.1", + "version": "0.6.0", "description": "", "license": "MIT", "private": true, diff --git a/pyproject.toml b/pyproject.toml index b7431b2..7b15b99 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "yacv-server" -version = "0.5.1" # TODO: Update automatically by CI on release (also for package.json!) +version = "0.6.0" description = "Yet Another CAD Viewer (server)" authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"] license = "MIT" diff --git a/vite.config.ts b/vite.config.ts index 8e3b50f..02f826f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,6 +3,8 @@ import {fileURLToPath, URL} from 'node:url' import {defineConfig} from 'vite' import vue from '@vitejs/plugin-vue' import vueJsx from '@vitejs/plugin-vue-jsx' +import {name, version} from './package.json' +import {execSync} from 'child_process' // https://vitejs.dev/config/ export default defineConfig({ @@ -26,5 +28,11 @@ export default defineConfig({ build: { assetsDir: '.', cssCodeSplit: false, // Small enough to inline + }, + define: { + __APP_NAME__: JSON.stringify(name), + __APP_VERSION__: JSON.stringify(version), + __APP_GIT_SHA__: JSON.stringify(execSync('git rev-parse HEAD').toString().trim()), + __APP_GIT_DIRTY__: JSON.stringify(execSync('git diff --quiet || echo dirty').toString().trim()), } }) diff --git a/yacv_server/__init__.py b/yacv_server/__init__.py index 4e3b64b..182a33f 100644 --- a/yacv_server/__init__.py +++ b/yacv_server/__init__.py @@ -1,5 +1,6 @@ import os +from cad import image_to_gltf from yacv_server.yacv import YACV yacv = YACV() @@ -13,9 +14,8 @@ if 'YACV_DISABLE_SERVER' not in os.environ: # Expose some nice aliases using the default server instance show = yacv.show -show_object = show -show_image = yacv.show_image show_all = yacv.show_cad_all +prepare_image = image_to_gltf export_all = yacv.export_all remove = yacv.remove clear = yacv.clear diff --git a/yacv_server/cad.py b/yacv_server/cad.py index e590659..63d829f 100644 --- a/yacv_server/cad.py +++ b/yacv_server/cad.py @@ -9,10 +9,11 @@ from OCP.TopoDS import TopoDS_Shape from yacv_server.gltf import GLTFMgr -CADLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now +CADCoreLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now +CADLike = Union[CADCoreLike, any] # build123d and cadquery types -def get_shape(obj: any, error: bool = True) -> Optional[CADLike]: +def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]: """ Get the shape of a CAD-like object """ # Try to grab a shape if a different type of object was passed @@ -45,7 +46,7 @@ def get_shape(obj: any, error: bool = True) -> Optional[CADLike]: return None -def grab_all_cad() -> List[Tuple[str, CADLike]]: +def grab_all_cad() -> List[Tuple[str, CADCoreLike]]: """ Grab all shapes by inspecting the stack """ import inspect stack = inspect.stack() @@ -60,7 +61,7 @@ def grab_all_cad() -> List[Tuple[str, CADLike]]: def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = None, height: Optional[float] = None, name: Optional[str] = None, save_mime: str = 'image/jpeg') -> Tuple[bytes, str]: - """Convert an image to a GLTF CAD object, indicating the center location and pixels per millimeter.""" + """Convert an image to a GLTF CAD object.""" from PIL import Image import io import os diff --git a/yacv_server/logo.py b/yacv_server/logo.py index 465189b..3c105c1 100644 --- a/yacv_server/logo.py +++ b/yacv_server/logo.py @@ -22,41 +22,34 @@ def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]: logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location # Avoid overlapping: logo_img_location.position = Vector(logo_img_location.position.X - 4e-2, logo_img_location.position.Y, logo_img_location.position.Z) + logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg') + img_bytes, img_name = prepare_image(logo_img_path, logo_img_location, height=18) fox_glb_bytes = open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read() - return {'fox': fox_glb_bytes, 'logo': logo_obj, 'location': logo_img_location, 'img_path': logo_img_path} - - -def show_logo(parts: Dict[str, Union[Part, Location, str]]) -> None: - """Shows the prebuilt logo parts""" - from yacv_server import show_image, show_object - for name, part in parts.items(): - if isinstance(part, str): - show_image(source=part, center=parts['location'], height=18, auto_clear=False) - else: - show_object(part, name, auto_clear=False) + return {'fox': fox_glb_bytes, 'logo': logo_obj, 'location': logo_img_location, img_name: img_bytes} if __name__ == "__main__": - from yacv_server import export_all, remove import logging logging.basicConfig(level=logging.DEBUG) - testing_server = os.getenv('TESTING_SERVER', 'False') == 'True' + testing_server = os.getenv('TESTING_SERVER') is not None if not testing_server: # Start an offline server to export the CAD part of the logo in a way compatible with the frontend # If this is not set, the server will auto-start on import and show_* calls will provide live updates os.environ['YACV_DISABLE_SERVER'] = 'True' - # Build the CAD part of the logo + from yacv_server import export_all, remove, prepare_image, show + + # Build the CAD part of the logo logo = build_logo() # Add the CAD part of the logo to the server - show_logo(logo) + show(*[obj for obj in logo.values()], names=[name for name in logo.keys()]) if testing_server: remove('location') # Test removing a part diff --git a/yacv_server/myhttp.py b/yacv_server/myhttp.py index b887d88..b1a0f27 100644 --- a/yacv_server/myhttp.py +++ b/yacv_server/myhttp.py @@ -116,14 +116,16 @@ class HTTPHandler(SimpleHTTPRequestHandler): # noinspection PyUnresolvedReferences to_send = data.to_json() write_chunk(f'data: {to_send}\n\n') - for i in range(200): # Need to fill browser buffers for instant updates! - write_chunk(':flush\n\n') except BrokenPipeError: # Client disconnected normally pass finally: - it.interrupt() - subscription.close() logger.debug('Updates client disconnected') + try: + it.interrupt() + next(it) # Make sure the iterator is interrupted before trying to close the subscription + subscription.close() + except BaseException as e: + logger.debug('Ignoring error while closing subscription: %s', e) def _api_object(self, obj_name: str): """Returns the object file with the matching name, building it if necessary.""" diff --git a/yacv_server/tessellate.py b/yacv_server/tessellate.py index 237002c..ae00c42 100644 --- a/yacv_server/tessellate.py +++ b/yacv_server/tessellate.py @@ -13,13 +13,13 @@ 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.cad import CADLike +from yacv_server.cad import CADCoreLike from yacv_server.gltf import GLTFMgr from yacv_server.mylogger import logger def tessellate( - cad_like: CADLike, + cad_like: CADCoreLike, tolerance: float = 0.1, angular_tolerance: float = 0.1, faces: bool = True, diff --git a/yacv_server/yacv.py b/yacv_server/yacv.py index 285367a..d7b4ad2 100644 --- a/yacv_server/yacv.py +++ b/yacv_server/yacv.py @@ -1,4 +1,5 @@ import atexit +import copy import inspect import os import signal @@ -7,8 +8,9 @@ import threading import time from dataclasses import dataclass from http.server import ThreadingHTTPServer +from importlib.metadata import version from threading import Thread -from typing import Optional, Dict, Union, Callable +from typing import Optional, Dict, Union, Callable, List from OCP.TopLoc import TopLoc_Location from OCP.TopoDS import TopoDS_Shape @@ -17,7 +19,7 @@ from build123d import Shape, Axis, Location, Vector from dataclasses_json import dataclass_json from myhttp import HTTPHandler -from yacv_server.cad import get_shape, grab_all_cad, image_to_gltf, CADLike +from yacv_server.cad import get_shape, grab_all_cad, CADCoreLike, CADLike from yacv_server.mylogger import logger from yacv_server.pubsub import BufferedPubSub from yacv_server.tessellate import _hashcode, tessellate @@ -35,13 +37,16 @@ class UpdatesApiData: """Whether to remove the object from the scene""" +YACVSupported = Union[bytes, CADCoreLike] + + class UpdatesApiFullData(UpdatesApiData): - obj: Optional[CADLike] + obj: YACVSupported """The OCCT object, if any (not serialized)""" kwargs: Optional[Dict[str, any]] """The show_object options, if any (not serialized)""" - def __init__(self, name: str, _hash: str, is_remove: bool = False, obj: Optional[CADLike] = None, + def __init__(self, obj: YACVSupported, name: str, _hash: str, is_remove: bool = False, kwargs: Optional[Dict[str, any]] = None): self.name = name self.hash = _hash @@ -59,7 +64,7 @@ class YACV: server: Optional[ThreadingHTTPServer] startup_complete: threading.Event show_events: BufferedPubSub[UpdatesApiFullData] - object_events: Dict[str, BufferedPubSub[bytes]] + build_events: Dict[str, BufferedPubSub[bytes]] object_events_lock: threading.Lock def __init__(self): @@ -68,13 +73,13 @@ class YACV: self.startup_complete = threading.Event() self.at_least_one_client = threading.Event() self.show_events = BufferedPubSub() - self.object_events = {} + self.build_events = {} self.object_events_lock = threading.Lock() self.frontend_lock = threading.Lock() + logger.info('Using yacv-server v%s', version('yacv-server')) def start(self): """Starts the web server in the background""" - print('yacv>start') assert self.server_thread is None, "Server currently running, cannot start another one" assert self.startup_complete.is_set() is False, "Server already started" # Start the server in a separate daemon thread @@ -92,7 +97,7 @@ class YACV: def stop(self, *args): """Stops the web server""" if self.server_thread is None: - print('Cannot stop server because it is not running') + logger.error('Cannot stop server because it is not running') return graceful_secs_connect = float(os.getenv('YACV_GRACEFUL_SECS_CONNECT', 12.0)) @@ -130,7 +135,6 @@ class YACV: def _run_server(self): """Runs the web server""" - print('yacv>run_server', inspect.stack()) logger.info('Starting server...') self.server = ThreadingHTTPServer( (os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323))), @@ -140,119 +144,115 @@ class YACV: self.startup_complete.set() self.server.serve_forever() - def _show_common(self, name: Optional[str], _hash: str, start: float, obj: Optional[CADLike] = None, - kwargs=None): + def show(self, *objs: List[YACVSupported], names: Optional[Union[str, List[str]]] = None, **kwargs): + # Prepare the arguments + start = time.time() + names = names or [_find_var_name(obj) for obj in objs] + if isinstance(names, str): + names = [names] + assert len(names) == len(objs), 'Number of names must match the number of objects' + + # Handle auto clearing of previous objects if kwargs.get('auto_clear', True): - self.clear() - name = name or f'object_{len(self.show_events.buffer())}' - # Remove a previous object with the same name + self.clear(except_names=names) + + # Remove a previous object event with the same name for old_event in self.show_events.buffer(): - if old_event.name == name: + if old_event.name in names: self.show_events.delete(old_event) - if name in self.object_events: - del self.object_events[name] - break - precomputed_info = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, kwargs=kwargs or {}) - self.show_events.publish(precomputed_info) - logger.info('show_object(%s, %s) took %.3f seconds', name, _hash, time.time() - start) - return precomputed_info + if old_event.name in self.build_events: + del self.build_events[old_event.name] - def show(self, any_object: Union[bytes, CADLike, any], name: Optional[str] = None, **kwargs): - """Publishes "any" object to the server""" - if isinstance(any_object, bytes): - self.show_gltf(any_object, name, **kwargs) - else: - self.show_cad(any_object, name, **kwargs) + # Publish the show event + for obj, name in zip(objs, names): + if not isinstance(obj, bytes): + obj = _preprocess_cad(obj, **kwargs) + _hash = _hashcode(obj, **kwargs) + event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, kwargs=kwargs or {}) + self.show_events.publish(event) - def show_gltf(self, gltf: bytes, name: Optional[str] = None, **kwargs): - """Publishes any single-file GLTF object to the server.""" - start = time.time() - # Precompute the info and send it to the client as if it was a CAD object - precomputed_info = self._show_common(name, _hashcode(gltf, **kwargs), start, kwargs=kwargs) - # Also pre-populate the GLTF data for the object API - publish_to = BufferedPubSub[bytes]() - publish_to.publish(gltf) - publish_to.publish(b'') # Signal the end of the stream - self.object_events[precomputed_info.name] = publish_to - - def show_image(self, source: str | bytes, center: any, width: Optional[float] = None, - height: Optional[float] = None, name: Optional[str] = None, save_mime: str = 'image/jpeg', **kwargs): - """Publishes an image as a quad GLTF object, indicating the center location and pixels per millimeter.""" - # Convert the image to a GLTF CAD object - gltf, name = image_to_gltf(source, center, width, height, name, save_mime) - # Publish it like any other GLTF object - self.show_gltf(gltf, name, **kwargs) - - def show_cad(self, obj: Union[CADLike, any], name: Optional[str] = None, **kwargs): - """Publishes a CAD object to the server""" - start = time.time() - - # Get the shape of a CAD-like object - obj = get_shape(obj) - - # Convert Z-up (OCCT convention) to Y-up (GLTF convention) - if isinstance(obj, TopoDS_Shape): - obj = Shape(obj).rotate(Axis.X, -90).wrapped - elif isinstance(obj, TopLoc_Location): - tmp_location = Location(obj) - tmp_location.position = Vector(tmp_location.position.X, tmp_location.position.Z, - -tmp_location.position.Y) - tmp_location.orientation = Vector(tmp_location.orientation.X - 90, tmp_location.orientation.Y, - tmp_location.orientation.Z) - obj = tmp_location.wrapped - - self._show_common(name, _hashcode(obj, **kwargs), start, obj, kwargs) + logger.info('show %s took %.3f seconds', names, time.time() - start) def show_cad_all(self, **kwargs): """Publishes all CAD objects in the current scope to the server""" - for name, obj in grab_all_cad(): - self.show_cad(obj, name, **kwargs) + all_cad = grab_all_cad() + self.show(*[cad for _, cad in all_cad], names=[name for name, _ in all_cad], **kwargs) def remove(self, name: str): """Removes a previously-shown object from the scene""" - shown_object = self._shown_object(name) - if shown_object: - shown_object.is_remove = True + show_events = self._show_events(name) + if len(show_events) > 0: + # Ensure only the new remove event remains for this name + for old_show_event in show_events: + self.show_events.delete(old_show_event) + + # Delete any cached object builds with self.object_events_lock: - if name in self.object_events: - del self.object_events[name] - self.show_events.publish(shown_object) + if name in self.build_events: + del self.build_events[name] - def clear(self): + # Publish the remove event + show_event = copy.copy(show_events[-1]) + show_event.is_remove = True + self.show_events.publish(show_event) + + def clear(self, except_names: List[str] = None): """Clears all previously-shown objects from the scene""" + if except_names is None: + except_names = [] for event in self.show_events.buffer(): - self.remove(event.name) + if event.name not in except_names: + self.remove(event.name) - def shown_object_names(self) -> list[str]: + def shown_object_names(self, apply_removes: bool = True) -> List[str]: """Returns the names of all objects that have been shown""" - return list([obj.name for obj in self.show_events.buffer()]) - - def _shown_object(self, name: str) -> Optional[UpdatesApiFullData]: - """Returns the object with the given name, if it exists""" + res = [] for obj in self.show_events.buffer(): - if obj.name == name: - return obj - return None + if not obj.is_remove or not apply_removes: + res.append(obj.name) + else: + res.remove(obj.name) + return res + + def _show_events(self, name: str, apply_removes: bool = True) -> List[UpdatesApiFullData]: + """Returns the show events with the given name""" + res = [] + for event in self.show_events.buffer(): + if event.name == name: + if not event.is_remove or not apply_removes: + res.append(event) + else: + # Also remove the previous events + for old_event in res: + if old_event.name == event.name: + res.remove(old_event) + return res def export(self, name: str) -> Optional[bytes]: """Export the given previously-shown object to a single GLB file, building it if necessary.""" start = time.time() # Check that the object to build exists and grab it if it does - event = self._shown_object(name) - if event is None: + events = self._show_events(name) + if len(events) == 0: + logger.warning('Object %s not found', name) return None + event = events[-1] # Use the lock to ensure that we don't build the object twice with self.object_events_lock: # If there are no object events for this name, we need to build the object - if name not in self.object_events: + if name not in self.build_events: + logger.debug('Building object %s with hash %s', name, event.hash) + # Prepare the pubsub for the object publish_to = BufferedPubSub[bytes]() - self.object_events[name] = publish_to + self.build_events[name] = publish_to - def _build_object(): - # Build and publish the object (once) + # Build and publish the object (once) + if isinstance(event.obj, bytes): # Already a GLTF + publish_to.publish(event.obj) + else: # CAD object to tessellate and convert to GLTF gltf = tessellate(event.obj, tolerance=event.kwargs.get('tolerance', 0.1), angular_tolerance=event.kwargs.get('angular_tolerance', 0.1), faces=event.kwargs.get('faces', True), @@ -263,24 +263,51 @@ class YACV: logger.info('export(%s) took %.3f seconds, %d parts', name, time.time() - start, len(gltf.meshes[0].primitives)) - # await asyncio.get_running_loop().run_in_executor(None, _build_object) - # The previous line has problems with auto-closed loop on script exit - # and is cancellable, so instead run blocking code in async context :( - 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() + subscription = self.build_events[name].subscribe() try: return next(subscription) finally: subscription.close() def export_all(self, folder: str, - export_filter: Callable[[str, Optional[CADLike]], bool] = lambda name, obj: True): + export_filter: Callable[[str, Optional[CADCoreLike]], bool] = lambda name, obj: True): """Export all previously-shown objects to GLB files in the given folder""" os.makedirs(folder, exist_ok=True) for name in self.shown_object_names(): - if export_filter(name, self._shown_object(name).obj): + if export_filter(name, self._show_events(name)[-1].obj): with open(os.path.join(folder, f'{name}.glb'), 'wb') as f: f.write(self.export(name)) + + +# noinspection PyUnusedLocal +def _preprocess_cad(obj: CADLike, **kwargs) -> CADCoreLike: + # Get the shape of a CAD-like object + obj = get_shape(obj) + + # Convert Z-up (OCCT convention) to Y-up (GLTF convention) + if isinstance(obj, TopoDS_Shape): + obj = Shape(obj).rotate(Axis.X, -90).wrapped + elif isinstance(obj, TopLoc_Location): + tmp_location = Location(obj) + tmp_location.position = Vector(tmp_location.position.X, tmp_location.position.Z, + -tmp_location.position.Y) + tmp_location.orientation = Vector(tmp_location.orientation.X - 90, tmp_location.orientation.Y, + tmp_location.orientation.Z) + obj = tmp_location.wrapped + + return obj + + +_find_var_name_count = 0 + + +def _find_var_name(obj: any) -> str: + """A hacky way to get a stable name for an object that may change over time""" + global _find_var_name_count + for frame in inspect.stack(): + for key, value in frame.frame.f_locals.items(): + if value is obj: + return key + _find_var_name_count += 1 + return 'unknown_var_' + str(_find_var_name_count)