mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
add support for programmatically and efficiently removing objects, better API and more CI automation
This commit is contained in:
1
.github/workflows/build.yml
vendored
1
.github/workflows/build.yml
vendored
@@ -5,6 +5,7 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- "master"
|
- "master"
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
|
|||||||
32
.github/workflows/deploy.yml
vendored
32
.github/workflows/deploy.yml
vendored
@@ -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 }}"
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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))
|
|
||||||
|
|||||||
Reference in New Issue
Block a user