add support for programmatically and efficiently removing objects, better API and more CI automation

This commit is contained in:
Yeicor
2024-03-10 17:30:34 +01:00
parent 88e1167b57
commit 49df7af970
16 changed files with 217 additions and 146 deletions

View File

@@ -5,6 +5,7 @@ on:
pull_request: pull_request:
branches: branches:
- "master" - "master"
workflow_call:
jobs: jobs:

View File

@@ -2,7 +2,6 @@ on:
push: push:
tags: tags:
- "v**" - "v**"
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages # Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions: permissions:
@@ -17,9 +16,28 @@ concurrency:
cancel-in-progress: false cancel-in-progress: false
jobs: 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: deploy-frontend:
needs: "rebuild"
runs-on: "ubuntu-latest" runs-on: "ubuntu-latest"
environment: environment:
name: "github-pages" name: "github-pages"
@@ -57,4 +75,12 @@ jobs:
tag: "${{ github.ref }}" tag: "${{ github.ref }}"
overwrite: true 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 }}"

View File

@@ -1,12 +1,12 @@
# Optional: enable logging to see what's happening
import logging
import os import os
from build123d import * # Also works with cadquery objects! from build123d import * # Also works with cadquery objects!
# Optional: enable logging to see what's happening
import logging
logging.basicConfig(level=logging.DEBUG) 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) Box(10, 10, 5)
Cylinder(4, 5, mode=Mode.SUBTRACT) Cylinder(4, 5, mode=Mode.SUBTRACT)
# Show it in the frontend # Show it in the frontend with hot-reloading
show_object(obj, 'object') 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: if 'CI' in os.environ:
export_all('export') export_all('export')

View File

@@ -32,11 +32,13 @@ const disableTap = ref(false);
const setDisableTap = (val: boolean) => disableTap.value = val; const setDisableTap = (val: boolean) => disableTap.value = val;
provide('disableTap', {disableTap, setDisableTap}); provide('disableTap', {disableTap, setDisableTap});
async function onModelLoadRequest(event: NetworkUpdateEvent) { async function onModelUpdateRequest(event: NetworkUpdateEvent) {
// Load a new batch of models to optimize rendering time // Load/unload a new batch of models to optimize rendering time
console.log("Received model update request", event.models);
let doc = sceneDocument.value; let doc = sceneDocument.value;
for (let model of event.models) { for (let modelIndex in event.models) {
let isLast = event.models[event.models.length - 1].url == model.url; let isLast = parseInt(modelIndex) === event.models.length - 1;
let model = event.models[modelIndex];
if (!model.isRemove) { if (!model.isRemove) {
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast, isLast); doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast, isLast);
} else { } else {
@@ -54,7 +56,7 @@ async function onModelRemoveRequest(name: string) {
// Set up the load model event listener // Set up the load model event listener
let networkMgr = new NetworkManager(); 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 // Start loading all configured models ASAP
for (let model of settings.preload) { for (let model of settings.preload) {
networkMgr.load(model); networkMgr.load(model);

View File

@@ -5,6 +5,12 @@ import {createVuetify} from 'vuetify';
import * as directives from 'vuetify/lib/directives/index.mjs'; import * as directives from 'vuetify/lib/directives/index.mjs';
import 'vuetify/dist/vuetify.css'; 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({ const vuetify = createVuetify({
directives, directives,
theme: { theme: {

View File

@@ -82,10 +82,13 @@ export class NetworkManager extends EventTarget {
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean) { private foundModel(name: string, hash: string | null, url: string, isRemove: boolean) {
let prevHash = this.knownObjectHashes[name]; let prevHash = this.knownObjectHashes[name];
let hashToCheck = hash + (isRemove ? "-remove" : "");
// console.debug("Found model", name, "with hash", hash, "and previous hash", prevHash); // console.debug("Found model", name, "with hash", hash, "and previous hash", prevHash);
if (!hash || hashToCheck !== prevHash) { if (!hash || hash !== prevHash || isRemove) {
if (!isRemove) {
this.knownObjectHashes[name] = hash; this.knownObjectHashes[name] = hash;
} else {
delete this.knownObjectHashes[name];
}
let newModel = new NetworkUpdateEventModel(name, url, hash, isRemove); let newModel = new NetworkUpdateEventModel(name, url, hash, isRemove);
this.bufferedUpdates.push(newModel); this.bufferedUpdates.push(newModel);

View File

@@ -35,6 +35,7 @@ export class SceneMgr {
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> { private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
let bb = SceneMgr.getBoundingBox(document); let bb = SceneMgr.getBoundingBox(document);
if (!bb) return document;
// Create the helper axes and grid box // Create the helper axes and grid box
let helpersDoc = new Document(); let helpersDoc = new Document();
@@ -45,7 +46,8 @@ export class SceneMgr {
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene); 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 // Get bounding box of the model and use it to set the size of the helpers
let bbMin: number[] = [1e6, 1e6, 1e6]; let bbMin: number[] = [1e6, 1e6, 1e6];
let bbMax: number[] = [-1e6, -1e6, -1e6]; let bbMax: number[] = [-1e6, -1e6, -1e6];

View File

@@ -1,6 +1,6 @@
{ {
"name": "yet-another-cad-viewer", "name": "yet-another-cad-viewer",
"version": "0.5.1", "version": "0.6.0",
"description": "", "description": "",
"license": "MIT", "license": "MIT",
"private": true, "private": true,

View File

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

@@ -3,6 +3,8 @@ import {fileURLToPath, URL} from 'node:url'
import {defineConfig} from 'vite' import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx' import vueJsx from '@vitejs/plugin-vue-jsx'
import {name, version} from './package.json'
import {execSync} from 'child_process'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -26,5 +28,11 @@ export default defineConfig({
build: { build: {
assetsDir: '.', assetsDir: '.',
cssCodeSplit: false, // Small enough to inline 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()),
} }
}) })

View File

@@ -1,5 +1,6 @@
import os import os
from cad import image_to_gltf
from yacv_server.yacv import YACV from yacv_server.yacv import YACV
yacv = YACV() yacv = YACV()
@@ -13,9 +14,8 @@ if 'YACV_DISABLE_SERVER' not in os.environ:
# Expose some nice aliases using the default server instance # Expose some nice aliases using the default server instance
show = yacv.show show = yacv.show
show_object = show
show_image = yacv.show_image
show_all = yacv.show_cad_all show_all = yacv.show_cad_all
prepare_image = image_to_gltf
export_all = yacv.export_all export_all = yacv.export_all
remove = yacv.remove remove = yacv.remove
clear = yacv.clear clear = yacv.clear

View File

@@ -9,10 +9,11 @@ from OCP.TopoDS import TopoDS_Shape
from yacv_server.gltf import GLTFMgr 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 """ """ Get the shape of a CAD-like object """
# Try to grab a shape if a different type of object was passed # 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 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 """ """ Grab all shapes by inspecting the stack """
import inspect import inspect
stack = inspect.stack() 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, 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]: 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 from PIL import Image
import io import io
import os import os

View File

@@ -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 = 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 = Vector(logo_img_location.position.X - 4e-2, logo_img_location.position.Y,
logo_img_location.position.Z) logo_img_location.position.Z)
logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg') 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() 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} return {'fox': fox_glb_bytes, 'logo': logo_obj, 'location': logo_img_location, img_name: img_bytes}
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)
if __name__ == "__main__": if __name__ == "__main__":
from yacv_server import export_all, remove
import logging import logging
logging.basicConfig(level=logging.DEBUG) 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: if not testing_server:
# Start an offline server to export the CAD part of the logo in a way compatible with the frontend # 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 # 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' os.environ['YACV_DISABLE_SERVER'] = 'True'
from yacv_server import export_all, remove, prepare_image, show
# Build the CAD part of the logo # Build the CAD part of the logo
logo = build_logo() logo = build_logo()
# Add the CAD part of the logo to the server # 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: if testing_server:
remove('location') # Test removing a part remove('location') # Test removing a part

View File

@@ -116,14 +116,16 @@ class HTTPHandler(SimpleHTTPRequestHandler):
# noinspection PyUnresolvedReferences # noinspection PyUnresolvedReferences
to_send = data.to_json() to_send = data.to_json()
write_chunk(f'data: {to_send}\n\n') 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 except BrokenPipeError: # Client disconnected normally
pass pass
finally: finally:
it.interrupt()
subscription.close()
logger.debug('Updates client disconnected') 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): def _api_object(self, obj_name: str):
"""Returns the object file with the matching name, building it if necessary.""" """Returns the object file with the matching name, building it if necessary."""

View File

@@ -13,13 +13,13 @@ 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
from yacv_server.cad import CADLike from yacv_server.cad import CADCoreLike
from yacv_server.gltf import GLTFMgr from yacv_server.gltf import GLTFMgr
from yacv_server.mylogger import logger from yacv_server.mylogger import logger
def tessellate( def tessellate(
cad_like: CADLike, cad_like: CADCoreLike,
tolerance: float = 0.1, tolerance: float = 0.1,
angular_tolerance: float = 0.1, angular_tolerance: float = 0.1,
faces: bool = True, faces: bool = True,

View File

@@ -1,4 +1,5 @@
import atexit import atexit
import copy
import inspect import inspect
import os import os
import signal import signal
@@ -7,8 +8,9 @@ import threading
import time import time
from dataclasses import dataclass from dataclasses import dataclass
from http.server import ThreadingHTTPServer from http.server import ThreadingHTTPServer
from importlib.metadata import version
from threading import Thread 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.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS_Shape from OCP.TopoDS import TopoDS_Shape
@@ -17,7 +19,7 @@ from build123d import Shape, Axis, Location, Vector
from dataclasses_json import dataclass_json from dataclasses_json import dataclass_json
from myhttp import HTTPHandler 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.mylogger import logger
from yacv_server.pubsub import BufferedPubSub from yacv_server.pubsub import BufferedPubSub
from yacv_server.tessellate import _hashcode, tessellate from yacv_server.tessellate import _hashcode, tessellate
@@ -35,13 +37,16 @@ class UpdatesApiData:
"""Whether to remove the object from the scene""" """Whether to remove the object from the scene"""
YACVSupported = Union[bytes, CADCoreLike]
class UpdatesApiFullData(UpdatesApiData): class UpdatesApiFullData(UpdatesApiData):
obj: Optional[CADLike] obj: YACVSupported
"""The OCCT object, if any (not serialized)""" """The OCCT object, if any (not serialized)"""
kwargs: Optional[Dict[str, any]] kwargs: Optional[Dict[str, any]]
"""The show_object options, if any (not serialized)""" """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): kwargs: Optional[Dict[str, any]] = None):
self.name = name self.name = name
self.hash = _hash self.hash = _hash
@@ -59,7 +64,7 @@ class YACV:
server: Optional[ThreadingHTTPServer] server: Optional[ThreadingHTTPServer]
startup_complete: threading.Event startup_complete: threading.Event
show_events: BufferedPubSub[UpdatesApiFullData] show_events: BufferedPubSub[UpdatesApiFullData]
object_events: Dict[str, BufferedPubSub[bytes]] build_events: Dict[str, BufferedPubSub[bytes]]
object_events_lock: threading.Lock object_events_lock: threading.Lock
def __init__(self): def __init__(self):
@@ -68,13 +73,13 @@ class YACV:
self.startup_complete = threading.Event() self.startup_complete = threading.Event()
self.at_least_one_client = threading.Event() self.at_least_one_client = threading.Event()
self.show_events = BufferedPubSub() self.show_events = BufferedPubSub()
self.object_events = {} self.build_events = {}
self.object_events_lock = threading.Lock() self.object_events_lock = threading.Lock()
self.frontend_lock = threading.Lock() self.frontend_lock = threading.Lock()
logger.info('Using yacv-server v%s', version('yacv-server'))
def start(self): def start(self):
"""Starts the web server in the background""" """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.server_thread is None, "Server currently running, cannot start another one"
assert self.startup_complete.is_set() is False, "Server already started" assert self.startup_complete.is_set() is False, "Server already started"
# Start the server in a separate daemon thread # Start the server in a separate daemon thread
@@ -92,7 +97,7 @@ class YACV:
def stop(self, *args): def stop(self, *args):
"""Stops the web server""" """Stops the web server"""
if self.server_thread is None: 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 return
graceful_secs_connect = float(os.getenv('YACV_GRACEFUL_SECS_CONNECT', 12.0)) graceful_secs_connect = float(os.getenv('YACV_GRACEFUL_SECS_CONNECT', 12.0))
@@ -130,7 +135,6 @@ class YACV:
def _run_server(self): def _run_server(self):
"""Runs the web server""" """Runs the web server"""
print('yacv>run_server', inspect.stack())
logger.info('Starting server...') logger.info('Starting server...')
self.server = ThreadingHTTPServer( self.server = ThreadingHTTPServer(
(os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323))), (os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323))),
@@ -140,53 +144,144 @@ class YACV:
self.startup_complete.set() self.startup_complete.set()
self.server.serve_forever() self.server.serve_forever()
def _show_common(self, name: Optional[str], _hash: str, start: float, obj: Optional[CADLike] = None, def show(self, *objs: List[YACVSupported], names: Optional[Union[str, List[str]]] = None, **kwargs):
kwargs=None): # 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): if kwargs.get('auto_clear', True):
self.clear() self.clear(except_names=names)
name = name or f'object_{len(self.show_events.buffer())}'
# Remove a previous object with the same name # Remove a previous object event with the same name
for old_event in self.show_events.buffer(): 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) self.show_events.delete(old_event)
if name in self.object_events: if old_event.name in self.build_events:
del self.object_events[name] del self.build_events[old_event.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
def show(self, any_object: Union[bytes, CADLike, any], name: Optional[str] = None, **kwargs): # Publish the show event
"""Publishes "any" object to the server""" for obj, name in zip(objs, names):
if isinstance(any_object, bytes): if not isinstance(obj, bytes):
self.show_gltf(any_object, name, **kwargs) 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)
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"""
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"""
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.build_events:
del self.build_events[name]
# 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():
if event.name not in except_names:
self.remove(event.name)
def shown_object_names(self, apply_removes: bool = True) -> List[str]:
"""Returns the names of all objects that have been shown"""
res = []
for obj in self.show_events.buffer():
if not obj.is_remove or not apply_removes:
res.append(obj.name)
else: else:
self.show_cad(any_object, name, **kwargs) res.remove(obj.name)
return res
def show_gltf(self, gltf: bytes, name: Optional[str] = None, **kwargs): def _show_events(self, name: str, apply_removes: bool = True) -> List[UpdatesApiFullData]:
"""Publishes any single-file GLTF object to the server.""" """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() 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) # Check that the object to build exists and grab it if it does
# Also pre-populate the GLTF data for the object API 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.build_events:
logger.debug('Building object %s with hash %s', name, event.hash)
# Prepare the pubsub for the object
publish_to = BufferedPubSub[bytes]() publish_to = BufferedPubSub[bytes]()
publish_to.publish(gltf) self.build_events[name] = publish_to
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, # Build and publish the object (once)
height: Optional[float] = None, name: Optional[str] = None, save_mime: str = 'image/jpeg', **kwargs): if isinstance(event.obj, bytes): # Already a GLTF
"""Publishes an image as a quad GLTF object, indicating the center location and pixels per millimeter.""" publish_to.publish(event.obj)
# Convert the image to a GLTF CAD object else: # CAD object to tessellate and convert to GLTF
gltf, name = image_to_gltf(source, center, width, height, name, save_mime) gltf = tessellate(event.obj, tolerance=event.kwargs.get('tolerance', 0.1),
# Publish it like any other GLTF object angular_tolerance=event.kwargs.get('angular_tolerance', 0.1),
self.show_gltf(gltf, name, **kwargs) faces=event.kwargs.get('faces', True),
edges=event.kwargs.get('edges', True),
vertices=event.kwargs.get('vertices', True))
glb_list_of_bytes = gltf.save_to_bytes()
publish_to.publish(b''.join(glb_list_of_bytes))
logger.info('export(%s) took %.3f seconds, %d parts', name, time.time() - start,
len(gltf.meshes[0].primitives))
def show_cad(self, obj: Union[CADLike, any], name: Optional[str] = None, **kwargs): # In either case return the elements of a subscription to the async generator
"""Publishes a CAD object to the server""" subscription = self.build_events[name].subscribe()
start = time.time() try:
return next(subscription)
finally:
subscription.close()
def export_all(self, folder: str,
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._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 # Get the shape of a CAD-like object
obj = get_shape(obj) obj = get_shape(obj)
@@ -201,86 +296,18 @@ class YACV:
tmp_location.orientation.Z) tmp_location.orientation.Z)
obj = tmp_location.wrapped obj = tmp_location.wrapped
self._show_common(name, _hashcode(obj, **kwargs), start, obj, kwargs)
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)
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
with self.object_events_lock:
if name in self.object_events:
del self.object_events[name]
self.show_events.publish(shown_object)
def clear(self):
"""Clears all previously-shown objects from the scene"""
for event in self.show_events.buffer():
self.remove(event.name)
def shown_object_names(self) -> 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"""
for obj in self.show_events.buffer():
if obj.name == name:
return obj return obj
return None
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 _find_var_name_count = 0
event = self._shown_object(name)
if event is None:
return None
# 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:
# Prepare the pubsub for the object
publish_to = BufferedPubSub[bytes]()
self.object_events[name] = publish_to
def _build_object(): def _find_var_name(obj: any) -> str:
# Build and publish the object (once) """A hacky way to get a stable name for an object that may change over time"""
gltf = tessellate(event.obj, tolerance=event.kwargs.get('tolerance', 0.1), global _find_var_name_count
angular_tolerance=event.kwargs.get('angular_tolerance', 0.1), for frame in inspect.stack():
faces=event.kwargs.get('faces', True), for key, value in frame.frame.f_locals.items():
edges=event.kwargs.get('edges', True), if value is obj:
vertices=event.kwargs.get('vertices', True)) return key
glb_list_of_bytes = gltf.save_to_bytes() _find_var_name_count += 1
publish_to.publish(b''.join(glb_list_of_bytes)) return 'unknown_var_' + str(_find_var_name_count)
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()
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 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):
with open(os.path.join(folder, f'{name}.glb'), 'wb') as f:
f.write(self.export(name))