Compare commits

...

53 Commits

Author SHA1 Message Date
Yeicor
6944f69110 Automatically update version to 0.6.18 2024-03-16 15:23:23 +00:00
Yeicor
1d01c75448 more fixes 2024-03-16 16:22:08 +01:00
Yeicor
cb0a7bdf0c Automatically update version to 0.6.17 2024-03-16 09:57:27 +00:00
Yeicor
a7dba6fd1b Merge remote-tracking branch 'origin/master' 2024-03-16 10:56:54 +01:00
Yeicor
981d923e5e fix 2024-03-16 10:56:46 +01:00
Yeicor
6f95a2f3ad Automatically update version to 0.6.16 2024-03-16 09:55:30 +00:00
Yeicor
63c74461b2 Merge remote-tracking branch 'origin/master' 2024-03-16 10:54:38 +01:00
Yeicor
e85dc36fea clean frontend disconnection protocol 2024-03-16 10:54:26 +01:00
dependabot[bot]
43d30b0fdd Bump vite from 5.1.5 to 5.1.6 (#19)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.5 to 5.1.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.1.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:18:16 +00:00
dependabot[bot]
acba91322c Bump terser from 5.29.1 to 5.29.2 (#18)
Bumps [terser](https://github.com/terser/terser) from 5.29.1 to 5.29.2.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.29.1...v5.29.2)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:18:03 +00:00
dependabot[bot]
ba7ce3727d Bump @types/node from 20.11.25 to 20.11.28 (#16)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.25 to 20.11.28.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:17:45 +00:00
dependabot[bot]
d168806744 Bump vuetify from 3.5.8 to 3.5.9 (#15)
Bumps [vuetify](https://github.com/vuetifyjs/vuetify/tree/HEAD/packages/vuetify) from 3.5.8 to 3.5.9.
- [Release notes](https://github.com/vuetifyjs/vuetify/releases)
- [Commits](https://github.com/vuetifyjs/vuetify/commits/v3.5.9/packages/vuetify)

---
updated-dependencies:
- dependency-name: vuetify
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:17:27 +00:00
dependabot[bot]
919c05eb9d Bump @gltf-transform/functions from 3.10.0 to 3.10.1 (#14)
Bumps [@gltf-transform/functions](https://github.com/donmccurdy/glTF-Transform) from 3.10.0 to 3.10.1.
- [Changelog](https://github.com/donmccurdy/glTF-Transform/blob/main/CHANGELOG.md)
- [Commits](https://github.com/donmccurdy/glTF-Transform/compare/v3.10.0...v3.10.1)

---
updated-dependencies:
- dependency-name: "@gltf-transform/functions"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:17:17 +00:00
Yeicor
2370fd72ed Automatically update version to 0.6.15 2024-03-14 16:27:55 +00:00
Yeicor
aef047a658 Merge remote-tracking branch 'origin/master' 2024-03-14 17:27:02 +01:00
Yeicor
d5cdd094e8 reduce idle cpu usage and add todo 2024-03-14 17:26:54 +01:00
Yeicor
9c71573934 Automatically update version to 0.6.14 2024-03-10 18:47:07 +00:00
Yeicor
8fc5ed7544 fix for export_all 2024-03-10 19:46:20 +01:00
Yeicor
1fd932dbc6 Merge remote-tracking branch 'origin/master' 2024-03-10 19:06:59 +01:00
Yeicor
539ac40e3d update readme links 2024-03-10 19:06:51 +01:00
Yeicor
9c2656d7db Automatically update version to 0.6.13 2024-03-10 17:57:32 +00:00
Yeicor
161d76ee69 Merge remote-tracking branch 'origin/master' 2024-03-10 18:56:52 +01:00
Yeicor
431c41a615 fix CI deployment 12 2024-03-10 18:56:45 +01:00
Yeicor
7144eb39da Automatically update version to 0.6.12 2024-03-10 17:41:13 +00:00
Yeicor
8e1c89ad6d fix CI deployment 11 2024-03-10 18:40:13 +01:00
Yeicor
7f692c0b52 Automatically update version to 0.6.11 2024-03-10 17:32:32 +00:00
Yeicor
86043132a8 Merge remote-tracking branch 'origin/master' 2024-03-10 18:32:00 +01:00
Yeicor
23b4d25464 fix CI deployment 10 2024-03-10 18:31:52 +01:00
Yeicor
22514d8603 Automatically update version to 0.6.10 2024-03-10 17:27:00 +00:00
Yeicor
b440a89b13 Merge remote-tracking branch 'origin/master' 2024-03-10 18:26:21 +01:00
Yeicor
cbdb5aff5e fix CI deployment 10 2024-03-10 18:26:13 +01:00
Yeicor
a3a9258a78 Automatically update version to 0.6.9 2024-03-10 17:21:51 +00:00
Yeicor
9f30ac8eb7 Merge remote-tracking branch 'origin/master' 2024-03-10 18:21:08 +01:00
Yeicor
e11c9dd5c6 fix CI deployment 9 2024-03-10 18:21:00 +01:00
Yeicor
520b89af4a Automatically update version to 0.6.8 2024-03-10 17:17:37 +00:00
Yeicor
ba9aef2454 Merge remote-tracking branch 'origin/master' 2024-03-10 18:16:43 +01:00
Yeicor
509b12cd97 fix CI deployment 8 2024-03-10 18:16:34 +01:00
Yeicor
40b4d51895 Automatically update version to 0.6.7 2024-03-10 17:13:50 +00:00
Yeicor
af68f8b1ff fix CI deployment 7 2024-03-10 18:13:05 +01:00
Yeicor
9cb6b29c93 fix CI deployment 6 2024-03-10 18:11:24 +01:00
Yeicor
3174a39ef9 Merge remote-tracking branch 'origin/master' 2024-03-10 18:09:37 +01:00
Yeicor
78231aff31 fix CI deployment 5 2024-03-10 18:09:26 +01:00
Yeicor
39f1231f90 Automatically update version to 0.6.5 2024-03-10 17:08:58 +00:00
Yeicor
ed9251faac fix CI deployment 4 2024-03-10 18:08:13 +01:00
Yeicor
49d0afa616 fix CI deployment 3 2024-03-10 18:05:21 +01:00
Yeicor
844860ee1a fix CI deployment 2 2024-03-10 17:58:06 +01:00
Yeicor
c1ae621e6f fix CI deployment 2024-03-10 17:56:48 +01:00
Yeicor
b339955e37 fix CI 2 2024-03-10 17:42:09 +01:00
Yeicor
f3672202ea fix CI 2024-03-10 17:36:48 +01:00
Yeicor
49df7af970 add support for programmatically and efficiently removing objects, better API and more CI automation 2024-03-10 17:30:34 +01:00
Yeicor
88e1167b57 v0.5.1 2024-03-10 15:48:10 +01:00
Yeicor
b2b7faf626 v0.5.2 2024-03-10 15:48:09 +01:00
Yeicor
77ceeb2eba fix logo export 2024-03-10 15:38:56 +01:00
27 changed files with 628 additions and 366 deletions

View File

@@ -5,6 +5,12 @@ on:
pull_request:
branches:
- "master"
workflow_call:
inputs:
ref:
type: "string"
required: true
description: "The ref (branch or tag) to build"
jobs:
@@ -13,6 +19,8 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with:
ref: "${{ inputs.ref }}"
- uses: "actions/setup-node@v4"
with:
cache: "yarn"
@@ -29,6 +37,8 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with:
ref: "${{ inputs.ref }}"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
with:
@@ -42,6 +52,8 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with:
ref: "${{ inputs.ref }}"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
with:
@@ -60,14 +72,15 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with:
ref: "${{ inputs.ref }}"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
with:
python-version: "3.11"
cache: "poetry"
- run: "SKIP_BUILD_FRONTEND=true poetry install"
- run: "PYTHONPATH=yacv_server YACV_DISABLE_SERVER=true poetry run python example/object.py"
- run: "mv export/object.glb export/example.glb"
- run: "YACV_DISABLE_SERVER=true poetry run python example/object.py"
- uses: "actions/upload-artifact@v4"
with:
name: "example"

59
.github/workflows/deploy1.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
on:
push:
tags:
- "v**"
permissions: # Same as deploy2.yml
contents: "write"
pages: "write"
id-token: "write"
jobs:
update-versions:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with: # Ensure we are not in a detached HEAD state
ref: "master"
# Check that the tag commit is the latest master commit
- run: |
git fetch --tags
tag_commit=$(git rev-parse ${{ github.ref }})
master_commit=$(git rev-parse master)
if [ "$tag_commit" != "$master_commit" ]; then
echo "The tag commit ($tag_commit) is not the latest master commit ($master_commit)"
exit 1
fi
- run: "echo 'CLEAN_VERSION=${{ github.ref }}' | sed 's,refs/tags/v,,g' >> $GITHUB_ENV"
# Write the new version to package.json
- uses: "actions/setup-node@v4"
- run: "yarn version --new-version $CLEAN_VERSION --no-git-tag-version"
# 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 $CLEAN_VERSION"
# Commit the changes and move the tag!
- run: |
git config --global user.email "yeicor@users.noreply.github.com"
git config --global user.name "Yeicor"
if git commit -am "Automatically update version to $CLEAN_VERSION"; then
git push
# Move the tag to the new commit
git tag -f -a "v$CLEAN_VERSION" -m "v$CLEAN_VERSION"
git push -f --tags # Force push the tag to GitHub
# The tag move will NOT trigger a new workflow
else
echo "No source change detected on version update (did you repeat a release tag??)"
exit 1
fi
deploy: # Makes sure all artifacts are updated and use the new version for the next deployment steps
needs: "update-versions"
uses: "./.github/workflows/deploy2.yml"
secrets: "inherit" # Inherit the secrets from the parent workflow
with:
ref: "master" # Ensure we are cloning the latest version of the repository

View File

@@ -1,8 +1,10 @@
on:
push:
tags:
- "v**"
workflow_dispatch:
workflow_call:
inputs:
ref:
type: "string"
required: true
description: "The ref (branch or tag) to build"
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
@@ -17,32 +19,27 @@ concurrency:
cancel-in-progress: false
jobs:
# TODO: Update versions automatically
rebuild: # Makes sure all artifacts are updated and use the new version
uses: "./.github/workflows/build.yml"
with:
ref: "${{ inputs.ref }}"
deploy-frontend:
needs: "rebuild"
runs-on: "ubuntu-latest"
environment:
name: "github-pages"
url: "${{ steps.deployment.outputs.page_url }}"
steps:
- uses: "dawidd6/action-download-artifact@v3"
with:
workflow: "build.yml"
name: "frontend"
- uses: "actions/download-artifact@v4"
with: # Downloads all artifacts from the build job
path: "./public"
allow_forks: false
- uses: "dawidd6/action-download-artifact@v3"
with:
workflow: "build.yml"
name: "logo"
path: "./public"
allow_forks: false
- uses: "dawidd6/action-download-artifact@v3"
with:
workflow: "build.yml"
name: "example"
path: "./public"
allow_forks: false
- run: | # Merge the subdirectories of public into a single directory
for dir in public/*; do
mv "$dir/"* public/
rmdir "$dir"
done
- uses: "actions/configure-pages@v4"
- uses: "actions/upload-pages-artifact@v3"
with:
@@ -57,4 +54,22 @@ jobs:
tag: "${{ github.ref }}"
overwrite: true
# TODO: deploy-backend
deploy-backend:
needs: "rebuild"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with:
ref: "${{ inputs.ref }}"
- uses: "actions/setup-node@v4"
with:
cache: "yarn"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
with:
python-version: "3.11"
cache: "poetry"
- run: "poetry install"
- run: "poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}"
- run: "poetry publish --build"

View File

@@ -10,19 +10,19 @@ in a web browser.
- All [GLTF 2.0](https://www.khronos.org/gltf/) features (textures, PBR materials, animations...).
- All [model-viewer](https://modelviewer.dev/) features (smooth controls, augmented reality...).
- Load multiple models at once, load external models and even images as quads.
- View and interact with topological entities: faces, edges, vertices and locations.
- Control clipping planes and transparency of each model.
- View and interact with topological entities: faces, edges, vertices and locations.
- Select any entity and measure bounding box size and distances.
- Fully-featured static deployment: just upload the viewer and models to your server.
- Hot reloading while editing the CAD model (using the `yacv-server` package).
- Fully-featured static deployment: just upload the viewer and models to your server.
## Usage
The [example](example) is a fully working project that shows how to use the viewer.
You can play with the latest
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=base.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
(or
[without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=base.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)).
[without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=logo.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)).
![Demo](assets/screenshot.png)

View File

@@ -1,25 +1,25 @@
# 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!
# %%
# Create a simple object
with BuildPart() as obj:
with BuildPart() as example:
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(example)
# %%
# 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')

View File

@@ -32,17 +32,33 @@ 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 shutdownRequestIndex = event.models.findIndex((model) => model.isRemove == null);
let shutdownRequest = null;
if (shutdownRequestIndex !== -1) {
console.log("Will shut down the connection after this load, as requested by the server");
shutdownRequest = event.models.splice(shutdownRequestIndex, 1)[0];
}
let doc = sceneDocument.value;
for (let model of event.models) {
let isLast = event.models[event.models.length - 1].url == model.url;
if (!model.isRemove) {
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast, isLast);
} else {
doc = await SceneMgr.removeModel(sceneUrl, doc, model.name, isLast);
for (let modelIndex in event.models) {
let isLast = parseInt(modelIndex) === event.models.length - 1;
let model = event.models[modelIndex];
try {
if (!model.isRemove) {
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast, isLast);
} else {
doc = await SceneMgr.removeModel(sceneUrl, doc, model.name, isLast);
}
} catch (e) {
console.error("Error loading model", model, e);
}
}
if (shutdownRequest !== null) {
console.log("Shutting down the connection as requested by the server");
event.disconnect();
}
sceneDocument.value = doc
triggerRef(sceneDocument); // Why not triggered automatically?
}
@@ -54,7 +70,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);

View File

@@ -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: {

View File

@@ -12,9 +12,10 @@ export let extrasNameValueHelpers = "__helpers";
*
* Remember to call mergeFinalize after all models have been merged (slower required operations).
*/
export async function mergePartial(url: string, name: string, document: Document): Promise<Document> {
export async function mergePartial(url: string, name: string, document: Document, networkFinished: () => void = () => {}): Promise<Document> {
// Load the new document
let newDoc = await io.read(url);
networkFinished()
// Remove any previous model with the same name
await document.transform(dropByName(name));

View File

@@ -7,22 +7,24 @@ class NetworkUpdateEventModel {
url: string;
// TODO: Detect and manage instances of the same object (same hash, different name)
hash: string | null;
isRemove: boolean;
isRemove: boolean | null; // This is null for a shutdown event
constructor(name: string, url: string, hash: string | null, isDelete: boolean) {
constructor(name: string, url: string, hash: string | null, isRemove: boolean | null) {
this.name = name;
this.url = url;
this.hash = hash;
this.isRemove = isDelete;
this.isRemove = isRemove;
}
}
export class NetworkUpdateEvent extends Event {
models: NetworkUpdateEventModel[];
disconnect: () => void;
constructor(models: NetworkUpdateEventModel[]) {
constructor(models: NetworkUpdateEventModel[], disconnect: () => void) {
super("update");
this.models = models;
this.disconnect = disconnect;
}
}
@@ -57,42 +59,56 @@ export class NetworkManager extends EventTarget {
}
}
private async monitorDevServer(url: URL) {
try {
// WARNING: This will spam the console logs with failed requests when the server is down
let response = await fetch(url.toString());
// console.log("Monitoring", url.toString(), response);
if (response.status === 200) {
let lines = readLinesStreamings(response.body!.getReader());
for await (let line of lines) {
if (!line || !line.startsWith("data:")) continue;
let data = JSON.parse(line.slice(5));
// console.debug("WebSocket message", data);
let urlObj = new URL(url);
urlObj.searchParams.delete("api_updates");
urlObj.searchParams.set("api_object", data.name);
this.foundModel(data.name, data.hash, urlObj.toString(), data.is_remove);
private async monitorDevServer(url: URL, stop: () => boolean = () => false) {
while (!stop()) {
try {
// WARNING: This will spam the console logs with failed requests when the server is down
const controller = new AbortController();
let response = await fetch(url.toString(), {signal: controller.signal});
// console.log("Monitoring", url.toString(), response);
if (response.status === 200) {
let lines = readLinesStreamings(response.body!.getReader());
for await (let line of lines) {
if (stop()) break;
if (!line || !line.startsWith("data:")) continue;
let data: { name: string, hash: string, is_remove: boolean | null } = JSON.parse(line.slice(5));
// console.debug("WebSocket message", data);
let urlObj = new URL(url);
urlObj.searchParams.delete("api_updates");
urlObj.searchParams.set("api_object", data.name);
this.foundModel(data.name, data.hash, urlObj.toString(), data.is_remove, async () => {
controller.abort(); // Notify the server that we are done
});
}
}
controller.abort();
} catch (e) { // Ignore errors (retry very soon)
}
} catch (e) { // Ignore errors (retry very soon)
await new Promise(resolve => setTimeout(resolve, settings.monitorEveryMs));
}
setTimeout(() => this.monitorDevServer(url), settings.monitorEveryMs);
return;
}
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean) {
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean | null, disconnect: () => void = () => {
}) {
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) {
// Update known hashes
if (isRemove == false) {
this.knownObjectHashes[name] = hash;
} else if (isRemove == true) {
if (!(name in this.knownObjectHashes)) return; // Nothing to remove...
delete this.knownObjectHashes[name];
// Also update buffered updates if the model is removed
this.bufferedUpdates = this.bufferedUpdates.filter(m => m.name !== name);
}
let newModel = new NetworkUpdateEventModel(name, url, hash, isRemove);
this.bufferedUpdates.push(newModel);
// Optimization: try to batch updates automatically for faster rendering
if (this.batchTimeout !== null) clearTimeout(this.batchTimeout);
this.batchTimeout = setTimeout(() => {
this.dispatchEvent(new NetworkUpdateEvent(this.bufferedUpdates));
this.dispatchEvent(new NetworkUpdateEvent(this.bufferedUpdates, disconnect));
this.bufferedUpdates = [];
}, batchTimeout);
}

View File

@@ -9,11 +9,11 @@ import {Matrix4} from "three/src/math/Matrix4.js"
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
export class SceneMgr {
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string, updateHelpers: boolean = true, reloadScene: boolean = true, networkFinished: () => void = () => {}): Promise<Document> {
let loadStart = performance.now();
// Start merging into the current document, replacing or adding as needed
document = await mergePartial(url, name, document);
document = await mergePartial(url, name, document, networkFinished);
console.log("Model", name, "loaded in", performance.now() - loadStart, "ms");
@@ -35,6 +35,7 @@ export class SceneMgr {
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
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];

View File

@@ -126,8 +126,9 @@ function onClipPlanesChange() {
// Global value for all models, once set it cannot be unset (unknown for other models...)
props.viewer.renderer.threeRenderer.localClippingEnabled = true;
// Due to model-viewer's camera manipulation, the bounding box needs to be transformed
bbox = SceneMgr.getBoundingBox(sceneDocument.value);
bbox.translate(scene.getTarget());
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
if (!boundingBox) return; // No models. Should not happen.
bbox = boundingBox.translate(scene.getTarget());
}
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {

View File

@@ -277,7 +277,9 @@ function updateBoundingBox() {
}
bb.applyMatrix4(new Matrix4().makeTranslation(props.viewer?.scene.getTarget()));
} else {
bb = SceneMgr.getBoundingBox(sceneDocument.value);
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
if (!boundingBox) return; // No models. Should not happen.
bb = boundingBox
}
// Define each edge of the bounding box, to draw a line for each axis
let corners = [

View File

@@ -3,7 +3,6 @@ import {settings} from "../misc/settings";
import {inject, onMounted, type Ref, ref, watch} from "vue";
import {VList, VListItem} from "vuetify/lib/components/index.mjs";
import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
import Loading from "../misc/Loading.vue";
import {ModelViewerElement} from '@google/model-viewer';
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
@@ -154,7 +153,7 @@ watch(disableTap, (value) => {
<v-list v-for="src in settings.preload" :key="src">
<v-list-item>{{ src }}</v-list-item>
</v-list>
<loading></loading>
<!-- Too much idle CPU usage: <loading></loading> -->
</div>
</model-viewer>

View File

@@ -1,8 +1,9 @@
{
"name": "yet-another-cad-viewer",
"version": "0.5.0",
"version": "0.6.18",
"description": "",
"license": "MIT",
"private": true,
"author": "Yeicor <4929005+Yeicor@users.noreply.github.com>",
"type": "module",
"scripts": {
@@ -15,7 +16,7 @@
},
"dependencies": {
"@gltf-transform/core": "^3.10.0",
"@gltf-transform/functions": "^3.10.0",
"@gltf-transform/functions": "^3.10.1",
"@google/model-viewer": "^3.4.0",
"@jamescoyle/vue-icon": "^0.1.2",
"@mdi/js": "^7.4.47",
@@ -23,11 +24,11 @@
"three": "^0.160.1",
"three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo",
"vue": "^3.4.21",
"vuetify": "^3.5.8"
"vuetify": "^3.5.9"
},
"devDependencies": {
"@tsconfig/node20": "^20.1.2",
"@types/node": "^20.11.25",
"@types/node": "^20.11.28",
"@types/three": "^0.160.0",
"@vitejs/plugin-vue": "^5.0.3",
"@vitejs/plugin-vue-jsx": "^3.1.0",
@@ -36,9 +37,9 @@
"commander": "^12.0.0",
"generate-license-file": "^3.0.1",
"npm-run-all2": "^6.1.1",
"terser": "^5.29.1",
"terser": "^5.29.2",
"typescript": "~5.4.2",
"vite": "^5.1.5",
"vite": "^5.1.6",
"vue-tsc": "^2.0.6"
}
}

13
poetry.lock generated
View File

@@ -318,17 +318,6 @@ qtconsole = ["qtconsole"]
test = ["pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath"]
test-extra = ["curio", "matplotlib (!=3.2.0)", "nbformat", "numpy (>=1.22)", "pandas", "pickleshare", "pytest (<7.1)", "pytest-asyncio (<0.22)", "testpath", "trio"]
[[package]]
name = "iterators"
version = "0.2.0"
description = "Iterator utility classes and functions"
optional = false
python-versions = ">=3.6"
files = [
{file = "iterators-0.2.0-py3-none-any.whl", hash = "sha256:1d7ff03f576c9de0e01bac66209556c066d6b1fc45583a99cfc9f4645be7900e"},
{file = "iterators-0.2.0.tar.gz", hash = "sha256:e9927a1ea1ef081830fd1512f3916857c36bd4b37272819a6cd29d0f44431b97"},
]
[[package]]
name = "jedi"
version = "0.19.1"
@@ -962,4 +951,4 @@ files = [
[metadata]
lock-version = "2.0"
python-versions = "^3.9"
content-hash = "d9746e99dd8861758730e68d12dc72d9ec5fb0101b3c070a7d7a373439c658a0"
content-hash = "567ef9c980c250ace7e380098b810250a36b92dd2e824b5b4f4851898a675e09"

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "yacv-server"
version = "0.5.0" # TODO: Update automatically by CI on release (also for package.json!)
version = "0.6.18"
description = "Yet Another CAD Viewer (server)"
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
license = "MIT"
@@ -19,7 +19,6 @@ build123d = "^0.4.0"
# Misc
pygltflib = "^1.16.2"
pillow = "^10.2.0"
iterators = "^0.2.0"
[tool.poetry.build]
generate-setup-file = false

View File

@@ -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()),
}
})

View File

@@ -1,5 +1,6 @@
import os
from yacv_server.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

View File

@@ -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

View File

@@ -26,6 +26,8 @@ class GLTFMgr:
textures=[Texture(source=0, sampler=0)],
images=[Image(bufferView=0, mimeType=image[1])],
)
# TODO: Reduce the number of draw calls by merging all faces into a single primitive, and using
# color attributes + extension? to differentiate them (same for edges and vertices)
self.gltf.set_binary_blob(image[0])
def add_face(self, vertices_raw: List[Tuple[float, float, float]], indices_raw: List[Tuple[int, int, int]],

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.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 = bool(os.getenv('TESTING_SERVER', 'False'))
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

View File

@@ -1,13 +1,10 @@
import io
import os
import threading
import urllib.parse
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
from iterators import TimeoutIterator
from mylogger import logger
from yacv_server.mylogger import logger
# Find the frontend folder (optional, but recommended)
FILE_DIR = os.path.dirname(__file__)
@@ -26,13 +23,9 @@ OBJECTS_API_PATH = '/api/object' # /{name}
class HTTPHandler(SimpleHTTPRequestHandler):
yacv: 'yacv.YACV'
frontend_lock: threading.Lock # To avoid exiting too early while frontend makes requests
at_least_one_client: threading.Event
def __init__(self, *args, yacv: 'yacv.YACV', **kwargs):
self.yacv = yacv
self.frontend_lock = threading.Lock()
self.at_least_one_client = threading.Event()
super().__init__(*args, **kwargs, directory=FRONTEND_BASE_PATH)
def log_message(self, fmt, *args):
@@ -77,67 +70,65 @@ class HTTPHandler(SimpleHTTPRequestHandler):
def _api_updates(self):
"""Handles a publish-only websocket connection that send show_object events along with their hashes and URLs"""
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
# Chunked transfer encoding!
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
self.at_least_one_client.set()
logger.debug('Updates client connected')
def write_chunk(_chunk_data: str):
self.wfile.write(hex(len(_chunk_data))[2:].encode('utf-8'))
self.wfile.write(b'\r\n')
self.wfile.write(_chunk_data.encode('utf-8'))
self.wfile.write(b'\r\n')
self.wfile.flush()
# Keep a shared read lock to know if any frontend is still working before shutting down
with self.yacv.frontend_lock.r_locked():
write_chunk('retry: 100\n\n')
# Avoid accepting new connections while shutting down
if self.yacv.shutting_down.is_set() and self.yacv.at_least_one_client.is_set():
self.send_error(HTTPStatus.SERVICE_UNAVAILABLE, 'Server is shutting down')
return
self.yacv.at_least_one_client.set()
logger.debug('Updates client connected')
# Send buffered events first, while keeping a lock
with self.frontend_lock:
for data in self.yacv.show_events.buffer():
logger.debug('Sending info about %s: %s', data.name, data)
# noinspection PyUnresolvedReferences
to_send = data.to_json()
write_chunk(f'data: {to_send}\n\n')
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
# Chunked transfer encoding!
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
# Send future events over the same connection
# Also send keep-alive to know if the client is still connected
subscription = self.yacv.show_events.subscribe(include_buffered=False)
it = TimeoutIterator(subscription, sentinel=None, reset_on_next=True, timeout=5.0) # Keep-alive interval
try:
for data in it:
if data is None:
write_chunk(':keep-alive\n\n')
else:
logger.debug('Sending info about %s: %s', data.name, data)
# 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')
def write_chunk(_chunk_data: str):
self.wfile.write(hex(len(_chunk_data))[2:].encode('utf-8'))
self.wfile.write(b'\r\n')
self.wfile.write(_chunk_data.encode('utf-8'))
self.wfile.write(b'\r\n')
self.wfile.flush()
write_chunk('retry: 100\n\n')
subscription = self.yacv.show_events.subscribe(yield_timeout=1.0) # Keep-alive interval
try:
for data in subscription:
if data is None:
write_chunk(':keep-alive\n\n')
else:
logger.debug('Sending info about %s: %s', data.name, data)
# noinspection PyUnresolvedReferences
to_send = data.to_json()
write_chunk(f'data: {to_send}\n\n')
except BrokenPipeError: # Client disconnected normally
pass
finally:
subscription.close()
logger.debug('Updates client disconnected')
def _api_object(self, obj_name: str):
"""Returns the object file with the matching name, building it if necessary."""
with self.frontend_lock:
# Export the object (or fail if not found)
exported_glb = self.yacv.export(obj_name)
if exported_glb is None:
self.send_error(HTTPStatus.NOT_FOUND, f'Object {obj_name} not found')
return io.BytesIO()
# Export the object (or fail if not found)
_export = self.yacv.export(obj_name)
if _export is None:
self.send_error(HTTPStatus.NOT_FOUND, f'Object {obj_name} not found')
return io.BytesIO()
# Wrap the GLB in a response and return it
self.send_response(HTTPStatus.OK)
self.send_header('Content-Type', 'model/gltf-binary')
self.send_header('Content-Length', str(len(exported_glb)))
self.send_header('Content-Disposition', f'attachment; filename="{obj_name}.glb"')
self.end_headers()
self.wfile.write(exported_glb)
exported_glb, _hash = _export
# Wrap the GLB in a response and return it
self.send_response(HTTPStatus.OK)
self.send_header('Content-Type', 'model/gltf-binary')
self.send_header('Content-Length', str(len(exported_glb)))
self.send_header('Content-Disposition', f'attachment; filename="{obj_name}.glb"')
self.send_header('E-Tag', f'"{_hash}"')
self.end_headers()
self.wfile.write(exported_glb)

View File

@@ -1,4 +1,4 @@
import threading
import queue
import queue
import threading
from typing import List, TypeVar, \
@@ -8,6 +8,8 @@ from yacv_server.mylogger import logger
T = TypeVar('T')
_end_of_queue = object()
class BufferedPubSub(Generic[T]):
"""A simple implementation of publish-subscribe pattern using threading and buffering all previous events"""
@@ -45,7 +47,7 @@ class BufferedPubSub(Generic[T]):
for event in self._buffer:
q.put(event)
if not include_future:
q.put(None)
q.put(_end_of_queue)
return q
def _unsubscribe(self, q: queue.Queue[T]):
@@ -54,14 +56,18 @@ class BufferedPubSub(Generic[T]):
self._subscribers.remove(q)
logger.debug(f"Unsubscribed from %s (%d subscribers)", self, len(self._subscribers))
def subscribe(self, include_buffered: bool = True, include_future: bool = True) -> Generator[T, None, None]:
def subscribe(self, include_buffered: bool = True, include_future: bool = True, yield_timeout: float = 0.0) -> \
Generator[T, None, None]:
"""Subscribes to events as an generator that yields events and automatically unsubscribes"""
q = self._subscribe(include_buffered, include_future)
try:
while True:
v = q.get()
try:
v = q.get(timeout=yield_timeout)
except queue.Empty:
v = None
# include_future is incompatible with None values as they are used to signal the end of the stream
if v is None and not include_future:
if v is _end_of_queue:
break
yield v
finally: # When aclose() is called
@@ -80,4 +86,4 @@ class BufferedPubSub(Generic[T]):
def clear(self):
"""Clears the buffer"""
with self._buffer_lock:
self._buffer.clear()
self._buffer.clear()

96
yacv_server/rwlock.py Normal file
View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
""" rwlock.py
A class to implement read-write locks on top of the standard threading
library.
This is implemented with two mutexes (threading.Lock instances) as per this
wikipedia pseudocode:
https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock#Using_two_mutexes
Code written by Tyler Neylon at Unbox Research.
This file is public domain.
"""
# _______________________________________________________________________
# Imports
from contextlib import contextmanager
from threading import Lock
# _______________________________________________________________________
# Class
class RWLock(object):
""" RWLock class; this is meant to allow an object to be read from by
multiple threads, but only written to by a single thread at a time. See:
https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock
Usage:
from rwlock import RWLock
my_obj_rwlock = RWLock()
# When reading from my_obj:
with my_obj_rwlock.r_locked():
do_read_only_things_with(my_obj)
# When writing to my_obj:
with my_obj_rwlock.w_locked():
mutate(my_obj)
"""
def __init__(self):
self.w_lock = Lock()
self.num_r_lock = Lock()
self.num_r = 0
# ___________________________________________________________________
# Reading methods.
def r_acquire(self, *args, **kwargs):
self.num_r_lock.acquire(*args, **kwargs)
self.num_r += 1
if self.num_r == 1:
self.w_lock.acquire(*args, **kwargs)
self.num_r_lock.release()
def r_release(self, *args, **kwargs):
assert self.num_r > 0
self.num_r_lock.acquire(*args, **kwargs)
self.num_r -= 1
if self.num_r == 0:
self.w_lock.release()
self.num_r_lock.release()
@contextmanager
def r_locked(self, *args, **kwargs):
""" This method is designed to be used via the `with` statement. """
try:
self.r_acquire(*args, **kwargs)
yield
finally:
self.r_release()
# ___________________________________________________________________
# Writing methods.
def w_acquire(self, *args, **kwargs):
self.w_lock.acquire(*args, **kwargs)
def w_release(self):
self.w_lock.release()
@contextmanager
def w_locked(self, *args, **kwargs):
""" This method is designed to be used via the `with` statement. """
try:
self.w_acquire(*args, **kwargs)
yield
finally:
self.w_release()

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 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,

View File

@@ -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, Tuple
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS_Shape
@@ -16,8 +18,9 @@ from OCP.TopoDS import TopoDS_Shape
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.rwlock import RWLock
from yacv_server.cad import get_shape, grab_all_cad, CADCoreLike, CADLike
from yacv_server.myhttp import HTTPHandler
from yacv_server.mylogger import logger
from yacv_server.pubsub import BufferedPubSub
from yacv_server.tessellate import _hashcode, tessellate
@@ -31,17 +34,20 @@ class UpdatesApiData:
"""Name of the object. Should be unique unless you want to overwrite the previous object"""
hash: str
"""Hash of the object, to detect changes without rebuilding the object"""
is_remove: bool
"""Whether to remove the object from the scene"""
is_remove: Optional[bool]
"""Whether to remove the object from the scene. If None, this is a shutdown request"""
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: Optional[bool] = False,
kwargs: Optional[Dict[str, any]] = None):
self.name = name
self.hash = _hash
@@ -55,26 +61,46 @@ class UpdatesApiFullData(UpdatesApiData):
class YACV:
"""The main yacv_server class, which manages the web server and the CAD objects."""
# Startup
server_thread: Optional[Thread]
"""The main thread running the server (will spawn other threads for each request)"""
server: Optional[ThreadingHTTPServer]
"""The server object"""
startup_complete: threading.Event
"""Event to signal when the server has started"""
# Running
show_events: BufferedPubSub[UpdatesApiFullData]
object_events: Dict[str, BufferedPubSub[bytes]]
object_events_lock: threading.Lock
"""PubSub for show events (objects to be shown in/removed from the scene)"""
build_events: Dict[str, BufferedPubSub[bytes]]
"""PubSub for build events (objects that were built)"""
build_events_lock: threading.Lock
"""Lock to ensure that objects are only built once"""
# Shutdown
at_least_one_client: threading.Event
"""Event to signal when at least one client has connected"""
shutting_down: threading.Event
"""Event to signal when the server is shutting down"""
frontend_lock: RWLock
"""Lock to ensure that the frontend has finished working before we shut down"""
def __init__(self):
self.server_thread = None
self.server = None
self.startup_complete = threading.Event()
self.at_least_one_client = threading.Event()
self.show_events = BufferedPubSub()
self.object_events = {}
self.object_events_lock = threading.Lock()
self.frontend_lock = threading.Lock()
self.build_events = {}
self.build_events_lock = threading.Lock()
self.at_least_one_client = threading.Event()
self.shutting_down = threading.Event()
self.frontend_lock = RWLock()
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,45 +118,42 @@ 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
# Inform the server that we are shutting down
self.shutting_down.set()
# noinspection PyTypeChecker
self.show_events.publish(UpdatesApiFullData(name='__shutdown', _hash='', is_remove=None, obj=None))
# If we were too fast, ensure that at least one client has connected
graceful_secs_connect = float(os.getenv('YACV_GRACEFUL_SECS_CONNECT', 12.0))
graceful_secs_request = float(os.getenv('YACV_GRACEFUL_SECS_REQUEST', 5.0))
# Make sure we can hold the lock for more than 100ms (to avoid exiting too early)
logger.info('Stopping server (waiting for at least one frontend request first, cancel with CTRL+C)...')
start = time.time()
try:
while not self.at_least_one_client.wait(
graceful_secs_connect / 10) and time.time() - start < graceful_secs_connect:
time.sleep(0.01)
except KeyboardInterrupt:
pass
if graceful_secs_connect > 0:
start = time.time()
try:
if not self.at_least_one_client.is_set():
logger.warning(
'Waiting for at least one frontend request before stopping server, cancel with CTRL+C...')
while (not self.at_least_one_client.wait(graceful_secs_connect / 10) and
time.time() - start < graceful_secs_connect):
time.sleep(0.01)
except KeyboardInterrupt:
pass
logger.info('Stopping server (waiting for no more frontend requests)...')
start = time.time()
try:
while time.time() - start < graceful_secs_request:
if self.frontend_lock.locked():
start = time.time()
time.sleep(0.01)
except KeyboardInterrupt:
pass
# Wait for the server to stop gracefully (all frontends to stop working)
graceful_secs_request = float(os.getenv('YACV_GRACEFUL_SECS_WORK', 1000000))
with self.frontend_lock.w_locked(timeout=graceful_secs_request):
# Stop the server
self.server.shutdown()
# Stop the server in the background
self.server.shutdown()
logger.info('Stopping server (sent)...')
# Wait for the server to stop gracefully
self.server_thread.join(timeout=30)
self.server_thread = None
logger.info('Stopping server (confirmed)...')
if len(args) >= 1 and args[0] in (signal.SIGINT, signal.SIGTERM):
sys.exit(0) # Exit with success
# Wait for the server thread to stop
self.server_thread.join(timeout=30)
self.server_thread = None
if len(args) >= 1 and args[0] in (signal.SIGINT, signal.SIGTERM):
sys.exit(0) # Exit with success
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 +163,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
with self.object_events_lock:
if name in self.object_events:
del self.object_events[name]
self.show_events.publish(shown_object)
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)
def clear(self):
# Delete any cached object builds
with self.build_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():
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 = set()
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.add(obj.name)
else:
res.discard(obj.name)
return list(res)
def export(self, name: str) -> Optional[bytes]:
"""Export the given previously-shown object to a single GLB file, building it if necessary."""
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[Tuple[bytes, str]]:
"""Export the given previously-shown object to a single GLB blob, 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:
with self.build_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 +282,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)
return next(subscription), event.hash
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))
f.write(self.export(name)[0])
# 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, avoid_levels: int = 2) -> 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()[avoid_levels:]:
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)

View File

@@ -390,28 +390,28 @@
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae"
integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==
"@gltf-transform/core@^3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@gltf-transform/core/-/core-3.10.0.tgz#854e7345f23971e4e7367a29183a2d1b62d45e46"
integrity sha512-NxVKhSWvH0j1tjZE8Yl461HUMyZLmYmqcbqHw0TOcQd5Q1SV7Y5w6W68XMt9/amRfMAiJLLNREE7kbr+Z0Ydbw==
"@gltf-transform/core@^3.10.0", "@gltf-transform/core@^3.10.1":
version "3.10.1"
resolved "https://registry.yarnpkg.com/@gltf-transform/core/-/core-3.10.1.tgz#d99c060b499482ed2c3304466405bf4c10939831"
integrity sha512-50OYemknGNxjBmiOM6iJp04JAu0bl9jvXJfN/gFt9QdJO02cPDcoXlTfSPJG6TVWDcfl0xPlsx1vybcbPVGFcQ==
dependencies:
property-graph "^1.3.1"
"@gltf-transform/extensions@^3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@gltf-transform/extensions/-/extensions-3.10.0.tgz#4ae11c3fe8e2a77e6e9dd04ebf0931c7b0cd3690"
integrity sha512-dz/cf2toBzP+w3ES2VgMiINCN6q86MVGu1lHkT0El4No77Bje9fnHVEPrKwaDCsXi5YXUiG/u6686vK6jePwDA==
"@gltf-transform/extensions@^3.10.1":
version "3.10.1"
resolved "https://registry.yarnpkg.com/@gltf-transform/extensions/-/extensions-3.10.1.tgz#71664389cae46fb12eb97dc71eb96d86a0d7801f"
integrity sha512-xUS9K5fMvW2dkYN4VzxHg2aBPG54M2WqgIjQ7RoSyybMoD7DsPUyMyVgRja+aiTVt/Bxza2ve7zJBD3+tN+aTA==
dependencies:
"@gltf-transform/core" "^3.10.0"
"@gltf-transform/core" "^3.10.1"
ktx-parse "^0.6.0"
"@gltf-transform/functions@^3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@gltf-transform/functions/-/functions-3.10.0.tgz#bf0331c109ac948d19be7394d3afcfae84215cfd"
integrity sha512-FStbDaH7t2z74RyEeUQn3aBcybULbDkt72ZasC0s7DwQ2DFKKKOth4Zksi4g9+8URNM6vNa2JSfuO851dkJHEg==
"@gltf-transform/functions@^3.10.1":
version "3.10.1"
resolved "https://registry.yarnpkg.com/@gltf-transform/functions/-/functions-3.10.1.tgz#c40817740241c0ee770f4d1210ccc766e46d8ab2"
integrity sha512-Zs6+1qvTD9w40R5qv70E4wJXXacNQ46ZxjKKW6dmfGIyjT8bsSJmV3Tdj+WJ8R6lWXXZ8e2p3ZvAUfPDEG73bQ==
dependencies:
"@gltf-transform/core" "^3.10.0"
"@gltf-transform/extensions" "^3.10.0"
"@gltf-transform/core" "^3.10.1"
"@gltf-transform/extensions" "^3.10.1"
ktx-parse "^0.6.0"
ndarray "^1.0.19"
ndarray-lanczos "^0.3.0"
@@ -805,10 +805,10 @@
resolved "https://registry.yarnpkg.com/@types/ndarray/-/ndarray-1.0.14.tgz#96b28c09a3587a76de380243f87bb7a2d63b4b23"
integrity sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==
"@types/node@^20.11.25":
version "20.11.25"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.25.tgz#0f50d62f274e54dd7a49f7704cc16bfbcccaf49f"
integrity sha512-TBHyJxk2b7HceLVGFcpAUjsa5zIdsPWlR6XHfyGzd0SFu+/NFgQgMAl96MSDZgQDvJAvV6BKsFOrt6zIL09JDw==
"@types/node@^20.11.28":
version "20.11.28"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.28.tgz#4fd5b2daff2e580c12316e457473d68f15ee6f66"
integrity sha512-M/GPWVS2wLkSkNHVeLkrF2fD5Lx5UC4PxA0uZcKc6QqbIQUJyW1jVjueJYi1z8n0I5PxYrtpnPnWglE+y9A0KA==
dependencies:
undici-types "~5.26.4"
@@ -2878,10 +2878,10 @@ tar@^6.1.11, tar@^6.1.2:
mkdirp "^1.0.3"
yallist "^4.0.0"
terser@^5.29.1:
version "5.29.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.29.1.tgz#44e58045b70c09792ba14bfb7b4e14ca8755b9fa"
integrity sha512-lZQ/fyaIGxsbGxApKmoPTODIzELy3++mXhS5hOqaAWZjQtpq/hFHAc+rm29NND1rYRxRWKcjuARNwULNXa5RtQ==
terser@^5.29.2:
version "5.29.2"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.29.2.tgz#c17d573ce1da1b30f21a877bffd5655dd86fdb35"
integrity sha512-ZiGkhUBIM+7LwkNjXYJq8svgkd+QK3UUr0wJqY4MieaezBSAIPgbSPZyIx0idM6XWK5CMzSWa8MJIzmRcB8Caw==
dependencies:
"@jridgewell/source-map" "^0.3.3"
acorn "^8.8.2"
@@ -2992,10 +2992,10 @@ validate-npm-package-name@^5.0.0:
dependencies:
builtins "^5.0.0"
vite@^5.1.5:
version "5.1.5"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.5.tgz#bdbc2b15e8000d9cc5172f059201178f9c9de5fb"
integrity sha512-BdN1xh0Of/oQafhU+FvopafUp6WaYenLU/NFoL5WyJL++GxkNfieKzBhM24H3HVsPQrlAqB7iJYTHabzaRed5Q==
vite@^5.1.6:
version "5.1.6"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.6.tgz#706dae5fab9e97f57578469eef1405fc483943e4"
integrity sha512-yYIAZs9nVfRJ/AiOLCA91zzhjsHUgMjB+EigzFb6W2XTLO8JixBCKCjvhKZaye+NKYHCrkv3Oh50dH9EdLU2RA==
dependencies:
esbuild "^0.19.3"
postcss "^8.4.35"
@@ -3031,10 +3031,10 @@ vue@^3.4.21:
"@vue/server-renderer" "3.4.21"
"@vue/shared" "3.4.21"
vuetify@^3.5.8:
version "3.5.8"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.5.8.tgz#bc8f08dfd3314640e7b5d43b50138a26d650cbbf"
integrity sha512-8nGS+lKejZkev55HFwIfsRt+9fOqbeDQNmXxfmLKAlnUT8FtynVwbjAwHMtX/OQAQ3ZwRaR1ptqQQmx3OgxzbQ==
vuetify@^3.5.9:
version "3.5.9"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.5.9.tgz#9cb3554f4b9bb7f3c277a10b99ab5944e6d04b03"
integrity sha512-tA3N2uWZFNSZRFNnXN841x4rWozYXKC0fGW/mJIwcKkQiI0+gmVCETtjF8bnOS7L1s0buWzw94uYTlXQa5AQ4w==
walk-up-path@^3.0.1:
version "3.0.1"