mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6944f69110 | ||
|
|
1d01c75448 | ||
|
|
cb0a7bdf0c | ||
|
|
a7dba6fd1b | ||
|
|
981d923e5e | ||
|
|
6f95a2f3ad | ||
|
|
63c74461b2 | ||
|
|
e85dc36fea | ||
|
|
43d30b0fdd | ||
|
|
acba91322c | ||
|
|
ba7ce3727d | ||
|
|
d168806744 | ||
|
|
919c05eb9d | ||
|
|
2370fd72ed | ||
|
|
aef047a658 | ||
|
|
d5cdd094e8 | ||
|
|
9c71573934 | ||
|
|
8fc5ed7544 | ||
|
|
1fd932dbc6 | ||
|
|
539ac40e3d | ||
|
|
9c2656d7db | ||
|
|
161d76ee69 | ||
|
|
431c41a615 | ||
|
|
7144eb39da | ||
|
|
8e1c89ad6d | ||
|
|
7f692c0b52 | ||
|
|
86043132a8 | ||
|
|
23b4d25464 | ||
|
|
22514d8603 | ||
|
|
b440a89b13 | ||
|
|
cbdb5aff5e | ||
|
|
a3a9258a78 | ||
|
|
9f30ac8eb7 | ||
|
|
e11c9dd5c6 | ||
|
|
520b89af4a | ||
|
|
ba9aef2454 | ||
|
|
509b12cd97 | ||
|
|
40b4d51895 | ||
|
|
af68f8b1ff | ||
|
|
9cb6b29c93 | ||
|
|
3174a39ef9 | ||
|
|
78231aff31 | ||
|
|
39f1231f90 | ||
|
|
ed9251faac | ||
|
|
49d0afa616 | ||
|
|
844860ee1a | ||
|
|
c1ae621e6f | ||
|
|
b339955e37 | ||
|
|
f3672202ea | ||
|
|
49df7af970 | ||
|
|
88e1167b57 | ||
|
|
b2b7faf626 | ||
|
|
77ceeb2eba |
17
.github/workflows/build.yml
vendored
17
.github/workflows/build.yml
vendored
@@ -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
59
.github/workflows/deploy1.yml
vendored
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)).
|
||||
|
||||

|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
13
package.json
13
package.json
@@ -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
13
poetry.lock
generated
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
96
yacv_server/rwlock.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
62
yarn.lock
62
yarn.lock
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user