mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-20 22:47:04 +01:00
Compare commits
163 Commits
v0.2.0-alp
...
v0.8.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b394d5325 | ||
|
|
1d4e9efc5c | ||
|
|
097b17386b | ||
|
|
c65b871404 | ||
|
|
64a2c9e630 | ||
|
|
62aa53be27 | ||
|
|
6c9cba552e | ||
|
|
624fcb6fcf | ||
|
|
835d72d6dc | ||
|
|
4c0a752b3b | ||
|
|
f6b6039c5f | ||
|
|
ad7caae417 | ||
|
|
c96b910f5e | ||
|
|
8c5dc4fefd | ||
|
|
fd824f611f | ||
|
|
66fd1d88c7 | ||
|
|
3539918b12 | ||
|
|
a385ff9cb5 | ||
|
|
a80cfffcda | ||
|
|
4f0da2bee1 | ||
|
|
62808e3e52 | ||
|
|
2f901f78c1 | ||
|
|
9fa15f1a49 | ||
|
|
01431bc46c | ||
|
|
073f9086e0 | ||
|
|
36f9b09bd7 | ||
|
|
135023c950 | ||
|
|
dcc206ec78 | ||
|
|
e8a23f2ef1 | ||
|
|
35f8c047df | ||
|
|
0b670896f4 | ||
|
|
d5a9f6a1f5 | ||
|
|
258bd831b3 | ||
|
|
507f5141df | ||
|
|
31bd324d0f | ||
|
|
a1bd159a84 | ||
|
|
b472f1adfb | ||
|
|
9fd6a81d98 | ||
|
|
14d51a4cb0 | ||
|
|
456b593ad0 | ||
|
|
9af50dccd6 | ||
|
|
21e85b1ce6 | ||
|
|
1f30c2fd0a | ||
|
|
ba05a8072b | ||
|
|
27f540be23 | ||
|
|
e8c0f683c5 | ||
|
|
345636e478 | ||
|
|
9a0fb03526 | ||
|
|
2037621afc | ||
|
|
2ba0e18479 | ||
|
|
eca2bbfa7c | ||
|
|
86180c424e | ||
|
|
e42d6be074 | ||
|
|
e2d6a3cb00 | ||
|
|
9e453b7890 | ||
|
|
0b8faa9e8b | ||
|
|
00bc2a15e0 | ||
|
|
432abcf85c | ||
|
|
4b6d3f6266 | ||
|
|
255ae72ed2 | ||
|
|
77dd9fb43e | ||
|
|
5dc2ae2f8d | ||
|
|
58440723bd | ||
|
|
bfdd656316 | ||
|
|
7408823c02 | ||
|
|
856ffbc4c5 | ||
|
|
d0f8463bbf | ||
|
|
162d3e22a2 | ||
|
|
4b06559ab8 | ||
|
|
9afa2e5786 | ||
|
|
7196fb2f32 | ||
|
|
8ec60faa04 | ||
|
|
13bbdd5956 | ||
|
|
3675d2f447 | ||
|
|
efc7a1d3b6 | ||
|
|
7166f9fe3d | ||
|
|
3405de38e7 | ||
|
|
2bd927f2a8 | ||
|
|
9718172fdd | ||
|
|
472a7a8309 | ||
|
|
7a7627f57e | ||
|
|
064d9aeb35 | ||
|
|
eed0baccac | ||
|
|
72480d82c8 | ||
|
|
3de710c8b5 | ||
|
|
8ebf48cb36 | ||
|
|
5083037b73 | ||
|
|
632e7e93c6 | ||
|
|
ec7139c809 | ||
|
|
752d6dc778 | ||
|
|
6d60b7b003 | ||
|
|
675e2e970a | ||
|
|
70bef528c5 | ||
|
|
78319fead5 | ||
|
|
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 | ||
|
|
719395863d | ||
|
|
a9ce189c45 | ||
|
|
9ca53bca61 | ||
|
|
f1b04db24a | ||
|
|
a304536536 | ||
|
|
8b3d08826d | ||
|
|
40f424dd20 | ||
|
|
cb6c0acb63 | ||
|
|
4ddc38f5c5 | ||
|
|
f26d3a65a0 | ||
|
|
b027819815 | ||
|
|
768603b4d3 | ||
|
|
3e3730a4a5 | ||
|
|
753648e522 | ||
|
|
986db75b24 | ||
|
|
962eea2b27 |
2
.github/workflows/autoupdate.yml
vendored
2
.github/workflows/autoupdate.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
|||||||
# will not occur.
|
# will not occur.
|
||||||
- name: "Dependabot metadata"
|
- name: "Dependabot metadata"
|
||||||
id: "dependabot-metadata"
|
id: "dependabot-metadata"
|
||||||
uses: "dependabot/fetch-metadata@v1"
|
uses: "dependabot/fetch-metadata@v2"
|
||||||
with:
|
with:
|
||||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
# Here the PR gets approved.
|
# Here the PR gets approved.
|
||||||
|
|||||||
18
.github/workflows/build.yml
vendored
18
.github/workflows/build.yml
vendored
@@ -5,6 +5,12 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
branches:
|
branches:
|
||||||
- "master"
|
- "master"
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
ref:
|
||||||
|
type: "string"
|
||||||
|
required: true
|
||||||
|
description: "The ref (branch or tag) to build"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
||||||
@@ -13,6 +19,8 @@ jobs:
|
|||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v4"
|
||||||
|
with:
|
||||||
|
ref: "${{ inputs.ref }}"
|
||||||
- uses: "actions/setup-node@v4"
|
- uses: "actions/setup-node@v4"
|
||||||
with:
|
with:
|
||||||
cache: "yarn"
|
cache: "yarn"
|
||||||
@@ -29,6 +37,8 @@ jobs:
|
|||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v4"
|
||||||
|
with:
|
||||||
|
ref: "${{ inputs.ref }}"
|
||||||
- run: "pipx install poetry"
|
- run: "pipx install poetry"
|
||||||
- uses: "actions/setup-python@v5"
|
- uses: "actions/setup-python@v5"
|
||||||
with:
|
with:
|
||||||
@@ -42,6 +52,8 @@ jobs:
|
|||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v4"
|
||||||
|
with:
|
||||||
|
ref: "${{ inputs.ref }}"
|
||||||
- run: "pipx install poetry"
|
- run: "pipx install poetry"
|
||||||
- uses: "actions/setup-python@v5"
|
- uses: "actions/setup-python@v5"
|
||||||
with:
|
with:
|
||||||
@@ -49,7 +61,6 @@ jobs:
|
|||||||
cache: "poetry"
|
cache: "poetry"
|
||||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
||||||
- run: "poetry run python yacv_server/logo.py"
|
- run: "poetry run python yacv_server/logo.py"
|
||||||
- run: "cp assets/fox.glb assets/logo_build/fox.glb"
|
|
||||||
- uses: "actions/upload-artifact@v4"
|
- uses: "actions/upload-artifact@v4"
|
||||||
with:
|
with:
|
||||||
name: "logo"
|
name: "logo"
|
||||||
@@ -61,14 +72,15 @@ jobs:
|
|||||||
runs-on: "ubuntu-latest"
|
runs-on: "ubuntu-latest"
|
||||||
steps:
|
steps:
|
||||||
- uses: "actions/checkout@v4"
|
- uses: "actions/checkout@v4"
|
||||||
|
with:
|
||||||
|
ref: "${{ inputs.ref }}"
|
||||||
- run: "pipx install poetry"
|
- run: "pipx install poetry"
|
||||||
- uses: "actions/setup-python@v5"
|
- uses: "actions/setup-python@v5"
|
||||||
with:
|
with:
|
||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
cache: "poetry"
|
cache: "poetry"
|
||||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
||||||
- run: "PYTHONPATH=yacv_server YACV_STOP_EARLY=true poetry run python example/object.py"
|
- run: "YACV_DISABLE_SERVER=true poetry run python example/object.py"
|
||||||
- run: "mv export/object.glb export/example.glb"
|
|
||||||
- uses: "actions/upload-artifact@v4"
|
- uses: "actions/upload-artifact@v4"
|
||||||
with:
|
with:
|
||||||
name: "example"
|
name: "example"
|
||||||
|
|||||||
60
.github/workflows/deploy.yml
vendored
60
.github/workflows/deploy.yml
vendored
@@ -1,60 +0,0 @@
|
|||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v**"
|
|
||||||
workflow_dispatch:
|
|
||||||
|
|
||||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
|
||||||
permissions:
|
|
||||||
contents: "write"
|
|
||||||
pages: "write"
|
|
||||||
id-token: "write"
|
|
||||||
|
|
||||||
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
|
||||||
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
|
||||||
concurrency:
|
|
||||||
group: "pages"
|
|
||||||
cancel-in-progress: false
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
# TODO: Update versions automatically
|
|
||||||
|
|
||||||
deploy-frontend:
|
|
||||||
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"
|
|
||||||
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
|
|
||||||
- uses: "actions/configure-pages@v4"
|
|
||||||
- uses: "actions/upload-pages-artifact@v3"
|
|
||||||
with:
|
|
||||||
path: 'public'
|
|
||||||
- id: "deployment"
|
|
||||||
uses: "actions/deploy-pages@v4"
|
|
||||||
- run: 'zip -r frontend.zip public'
|
|
||||||
- uses: "svenstaro/upload-release-action@v2"
|
|
||||||
with:
|
|
||||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
|
||||||
file: "frontend.zip"
|
|
||||||
tag: "${{ github.ref }}"
|
|
||||||
overwrite: true
|
|
||||||
|
|
||||||
# TODO: deploy-backend
|
|
||||||
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
|
||||||
75
.github/workflows/deploy2.yml
vendored
Normal file
75
.github/workflows/deploy2.yml
vendored
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
on:
|
||||||
|
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:
|
||||||
|
contents: "write"
|
||||||
|
pages: "write"
|
||||||
|
id-token: "write"
|
||||||
|
|
||||||
|
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
|
||||||
|
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
|
||||||
|
concurrency:
|
||||||
|
group: "pages"
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
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: "actions/download-artifact@v4"
|
||||||
|
with: # Downloads all artifacts from the build job
|
||||||
|
path: "./public"
|
||||||
|
- 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@v5"
|
||||||
|
- uses: "actions/upload-pages-artifact@v3"
|
||||||
|
with:
|
||||||
|
path: 'public'
|
||||||
|
- id: "deployment"
|
||||||
|
uses: "actions/deploy-pages@v4"
|
||||||
|
- run: 'zip -r frontend.zip public'
|
||||||
|
- uses: "svenstaro/upload-release-action@v2"
|
||||||
|
with:
|
||||||
|
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||||
|
file: "frontend.zip"
|
||||||
|
tag: "${{ github.ref }}"
|
||||||
|
overwrite: true
|
||||||
|
|
||||||
|
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"
|
||||||
|
|
||||||
30
README.md
30
README.md
@@ -10,37 +10,19 @@ in a web browser.
|
|||||||
- All [GLTF 2.0](https://www.khronos.org/gltf/) features (textures, PBR materials, animations...).
|
- 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...).
|
- All [model-viewer](https://modelviewer.dev/) features (smooth controls, augmented reality...).
|
||||||
- Load multiple models at once, load external models and even images as quads.
|
- 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.
|
- 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.
|
- 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).
|
- 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
|
## Usage
|
||||||
|
|
||||||
The [example](example) is a fully working project that demonstrates how to use the viewer.
|
The [example](example) is a fully working project that shows how to use the viewer.
|
||||||
|
|
||||||
### Hot reloading
|
You can play with the latest
|
||||||
|
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
|
||||||
To see the live updates you will need to run the [yacv_server](yacv_server) and
|
|
||||||
open [the viewer](https://yeicor-3d.github.io/yet-another-cad-viewer/) with
|
|
||||||
the `preload=ws://<host>:32323/` query parameter (by default it already tries localhost).
|
|
||||||
|
|
||||||
Note that [yacv_server](yacv_server) also hosts the frontend at `http://localhost:32323/` if you have no access to the
|
|
||||||
internet.
|
|
||||||
|
|
||||||
### Static deployment
|
|
||||||
|
|
||||||
To deploy the viewer and models as a static website you can simply copy the latest build directory to your server.
|
|
||||||
To load models use the `preload=...` query parameter in the URL.
|
|
||||||
It can be set multiple times to load multiple models.
|
|
||||||
|
|
||||||
Note that you can simply reuse the [main deployment](https://yeicor-3d.github.io/yet-another-cad-viewer/) and host only
|
|
||||||
your own models (linking them from the viewer with the `preload` query parameter).
|
|
||||||
|
|
||||||
To see a working example of a static deployment you can check out
|
|
||||||
the [demo](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=base.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
|
|
||||||
(or
|
(or
|
||||||
the [demo 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
assets/.gitignore
vendored
Normal file
1
assets/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/logo_build/
|
||||||
@@ -211,11 +211,12 @@ Apache License
|
|||||||
|
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm packages may be included in this product:
|
||||||
|
|
||||||
- source-map-js@1.0.2
|
- source-map-js@1.0.2
|
||||||
|
- source-map-js@1.2.0
|
||||||
|
|
||||||
This package contains the following license and notice below:
|
These packages each contain the following license and notice below:
|
||||||
|
|
||||||
Copyright (c) 2009-2011, Mozilla Foundation and contributors
|
Copyright (c) 2009-2011, Mozilla Foundation and contributors
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
@@ -523,8 +524,8 @@ Apache License
|
|||||||
The following npm packages may be included in this product:
|
The following npm packages may be included in this product:
|
||||||
|
|
||||||
- b4a@1.6.6
|
- b4a@1.6.6
|
||||||
- bare-events@2.2.0
|
- bare-events@2.2.1
|
||||||
- bare-fs@2.1.5
|
- bare-fs@2.2.1
|
||||||
- bare-os@2.2.0
|
- bare-os@2.2.0
|
||||||
- bare-path@2.1.0
|
- bare-path@2.1.0
|
||||||
|
|
||||||
@@ -1290,7 +1291,7 @@ third-party archives.
|
|||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- typescript@5.3.3
|
- typescript@5.4.3
|
||||||
|
|
||||||
This package contains the following license and notice below:
|
This package contains the following license and notice below:
|
||||||
|
|
||||||
@@ -1430,7 +1431,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- @lit-labs/ssr-dom-shim@1.1.2
|
- @lit-labs/ssr-dom-shim@1.2.0
|
||||||
|
|
||||||
This package contains the following license and notice below:
|
This package contains the following license and notice below:
|
||||||
|
|
||||||
@@ -1556,7 +1557,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- @babel/parser@7.23.9
|
- @babel/parser@7.24.0
|
||||||
|
|
||||||
This package contains the following license and notice below:
|
This package contains the following license and notice below:
|
||||||
|
|
||||||
@@ -1736,7 +1737,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
|||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- node-abi@3.54.0
|
- node-abi@3.56.0
|
||||||
|
|
||||||
This package contains the following license and notice below:
|
This package contains the following license and notice below:
|
||||||
|
|
||||||
@@ -1764,6 +1765,36 @@ SOFTWARE.
|
|||||||
|
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
|
- three-mesh-bvh@0.7.3
|
||||||
|
|
||||||
|
This package contains the following license and notice below:
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2018 Garrett Johnson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
||||||
|
-----------
|
||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- napi-build-utils@1.0.2
|
- napi-build-utils@1.0.2
|
||||||
@@ -1826,7 +1857,7 @@ SOFTWARE.
|
|||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- @monogrid/gainmap-js@3.0.1
|
- @monogrid/gainmap-js@3.0.3
|
||||||
|
|
||||||
This package contains the following license and notice below:
|
This package contains the following license and notice below:
|
||||||
|
|
||||||
@@ -2378,7 +2409,7 @@ THE SOFTWARE.
|
|||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- prebuild-install@7.1.1
|
- prebuild-install@7.1.2
|
||||||
|
|
||||||
This package contains the following license and notice below:
|
This package contains the following license and notice below:
|
||||||
|
|
||||||
@@ -2408,7 +2439,7 @@ THE SOFTWARE.
|
|||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- vuetify@3.5.3
|
- vuetify@3.5.13
|
||||||
|
|
||||||
This package contains the following license and notice below:
|
This package contains the following license and notice below:
|
||||||
|
|
||||||
@@ -2498,16 +2529,16 @@ THE SOFTWARE.
|
|||||||
|
|
||||||
The following npm packages may be included in this product:
|
The following npm packages may be included in this product:
|
||||||
|
|
||||||
- @vue/compiler-core@3.4.16
|
- @vue/compiler-core@3.4.21
|
||||||
- @vue/compiler-dom@3.4.16
|
- @vue/compiler-dom@3.4.21
|
||||||
- @vue/compiler-sfc@3.4.16
|
- @vue/compiler-sfc@3.4.21
|
||||||
- @vue/compiler-ssr@3.4.16
|
- @vue/compiler-ssr@3.4.21
|
||||||
- @vue/reactivity@3.4.16
|
- @vue/reactivity@3.4.21
|
||||||
- @vue/runtime-core@3.4.16
|
- @vue/runtime-core@3.4.21
|
||||||
- @vue/runtime-dom@3.4.16
|
- @vue/runtime-dom@3.4.21
|
||||||
- @vue/server-renderer@3.4.16
|
- @vue/server-renderer@3.4.21
|
||||||
- @vue/shared@3.4.16
|
- @vue/shared@3.4.21
|
||||||
- vue@3.4.16
|
- vue@3.4.21
|
||||||
|
|
||||||
These packages each contain the following license and notice below:
|
These packages each contain the following license and notice below:
|
||||||
|
|
||||||
@@ -2538,7 +2569,7 @@ THE SOFTWARE.
|
|||||||
The following npm packages may be included in this product:
|
The following npm packages may be included in this product:
|
||||||
|
|
||||||
- fast-fifo@1.3.2
|
- fast-fifo@1.3.2
|
||||||
- streamx@2.16.0
|
- streamx@2.16.1
|
||||||
|
|
||||||
These packages each contain the following license and notice below:
|
These packages each contain the following license and notice below:
|
||||||
|
|
||||||
@@ -2690,9 +2721,9 @@ THE SOFTWARE.
|
|||||||
|
|
||||||
The following npm packages may be included in this product:
|
The following npm packages may be included in this product:
|
||||||
|
|
||||||
- @gltf-transform/core@3.10.0
|
- @gltf-transform/core@3.10.1
|
||||||
- @gltf-transform/extensions@3.10.0
|
- @gltf-transform/extensions@3.10.1
|
||||||
- @gltf-transform/functions@3.10.0
|
- @gltf-transform/functions@3.10.1
|
||||||
|
|
||||||
These packages each contain the following license and notice below:
|
These packages each contain the following license and notice below:
|
||||||
|
|
||||||
@@ -2843,7 +2874,7 @@ THE SOFTWARE.
|
|||||||
|
|
||||||
The following npm package may be included in this product:
|
The following npm package may be included in this product:
|
||||||
|
|
||||||
- postcss@8.4.35
|
- postcss@8.4.38
|
||||||
|
|
||||||
This package contains the following license and notice below:
|
This package contains the following license and notice below:
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
3
build.py
3
build.py
@@ -2,7 +2,8 @@ import os
|
|||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
if os.getenv('SKIP_BUILD_FRONTEND') is None:
|
# Building the frontend is optional
|
||||||
|
if os.getenv('SKIP_BUILD_FRONTEND') is None and os.path.exists('package.json'):
|
||||||
# When building the backend, make sure the frontend is built first
|
# When building the backend, make sure the frontend is built first
|
||||||
subprocess.run(['yarn', 'install'], check=True)
|
subprocess.run(['yarn', 'install'], check=True)
|
||||||
subprocess.run(['yarn', 'build', '--outDir', 'yacv_server/frontend'], check=True)
|
subprocess.run(['yarn', 'build', '--outDir', 'yacv_server/frontend'], check=True)
|
||||||
|
|||||||
@@ -3,13 +3,12 @@
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
1. Download the contents of this folder.
|
1. Download the contents of this folder.
|
||||||
2. Assuming you have a recent version of Python installed, install the required packages:
|
2. Assuming you have a recent version of Python 3 installed, install the required packages:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python -m venv venv
|
python -m venv venv
|
||||||
|
. venv/bin/activate # Execute this line every time you change the terminal
|
||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
# Do this every time you change the terminal:
|
|
||||||
. venv/bin/activate
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|||||||
@@ -1,25 +1,26 @@
|
|||||||
|
# Optional: enable logging to see what's happening
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
|
|
||||||
from build123d import * # Also works with cadquery objects!
|
from build123d import * # Also works with cadquery objects!
|
||||||
|
|
||||||
# Optional: enable logging to see what's happening
|
|
||||||
import logging
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
from yacv_server import show_object, export_all # Check out all show_* methods for more features!
|
from yacv_server import show, export_all # Check out other exported methods for more features!
|
||||||
|
|
||||||
# %%
|
# %%
|
||||||
|
|
||||||
# Create a simple object
|
# Create a simple object
|
||||||
with BuildPart() as obj:
|
with BuildPart() as example:
|
||||||
Box(10, 10, 5)
|
Box(10, 10, 5)
|
||||||
Cylinder(4, 5, mode=Mode.SUBTRACT)
|
Cylinder(4, 5, mode=Mode.SUBTRACT)
|
||||||
|
|
||||||
# Show it in the frontend
|
# Show it in the frontend with hot-reloading
|
||||||
show_object(obj, 'object')
|
show(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:
|
if 'CI' in os.environ:
|
||||||
export_all('export')
|
export_all('export')
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<!--suppress SillyAssignmentJS -->
|
<!--suppress SillyAssignmentJS -->
|
||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef} from "vue";
|
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef, watch} from "vue";
|
||||||
import Sidebar from "./misc/Sidebar.vue";
|
import Sidebar from "./misc/Sidebar.vue";
|
||||||
import Loading from "./misc/Loading.vue";
|
import Loading from "./misc/Loading.vue";
|
||||||
import Tools from "./tools/Tools.vue";
|
import Tools from "./tools/Tools.vue";
|
||||||
import Models from "./models/Models.vue";
|
import Models from "./models/Models.vue";
|
||||||
import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs";
|
import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs";
|
||||||
import {settings} from "./misc/settings";
|
import {settings} from "./misc/settings";
|
||||||
import {NetworkManager, NetworkUpdateEvent} from "./misc/network";
|
import {NetworkManager, NetworkUpdateEvent, NetworkUpdateEventModel} from "./misc/network";
|
||||||
import {SceneMgr} from "./misc/scene";
|
import {SceneMgr} from "./misc/scene";
|
||||||
import {Document} from "@gltf-transform/core";
|
import {Document} from "@gltf-transform/core";
|
||||||
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
|
||||||
@@ -28,27 +28,67 @@ const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
|
|||||||
const sceneDocument = shallowRef(new Document());
|
const sceneDocument = shallowRef(new Document());
|
||||||
provide('sceneDocument', {sceneDocument});
|
provide('sceneDocument', {sceneDocument});
|
||||||
const models: Ref<InstanceType<typeof Models> | null> = ref(null)
|
const models: Ref<InstanceType<typeof Models> | null> = ref(null)
|
||||||
|
const tools: Ref<InstanceType<typeof Tools> | null> = ref(null)
|
||||||
const disableTap = ref(false);
|
const disableTap = ref(false);
|
||||||
const setDisableTap = (val: boolean) => disableTap.value = val;
|
const setDisableTap = (val: boolean) => disableTap.value = val;
|
||||||
provide('disableTap', {disableTap, setDisableTap});
|
provide('disableTap', {disableTap, setDisableTap});
|
||||||
|
|
||||||
async function onModelLoadRequest(model: NetworkUpdateEvent) {
|
async function onModelUpdateRequest(event: NetworkUpdateEvent) {
|
||||||
sceneDocument.value = await SceneMgr.loadModel(sceneUrl, sceneDocument.value, model.name, model.url);
|
// Trigger progress bar as soon as possible (also triggered earlier for each raw notification)
|
||||||
|
if (viewer.value && event.models.length > 0) {
|
||||||
|
viewer.value.onProgress(0.10);
|
||||||
|
}
|
||||||
|
// 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 modelIndex in event.models) {
|
||||||
|
let isLast = parseInt(modelIndex) === event.models.length - 1;
|
||||||
|
let model = event.models[modelIndex];
|
||||||
|
tools.value?.removeObjectSelections(model.name);
|
||||||
|
try {
|
||||||
|
if (!model.isRemove) {
|
||||||
|
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && settings.loadHelpers, isLast);
|
||||||
|
} else {
|
||||||
|
doc = await SceneMgr.removeModel(sceneUrl, doc, model.name, isLast && settings.loadHelpers, 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?
|
triggerRef(sceneDocument); // Why not triggered automatically?
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onModelRemoveRequest(name: string) {
|
async function onModelRemoveRequest(name: string) {
|
||||||
sceneDocument.value = await SceneMgr.removeModel(sceneUrl, sceneDocument.value, name);
|
await onModelUpdateRequest(new NetworkUpdateEvent([new NetworkUpdateEventModel(name, "", null, true)], () => {
|
||||||
triggerRef(sceneDocument); // Why not triggered automatically?
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up the load model event listener
|
// Set up the load model event listener
|
||||||
let networkMgr = new NetworkManager();
|
let networkMgr = new NetworkManager();
|
||||||
networkMgr.addEventListener('update', (e) => onModelLoadRequest(e as NetworkUpdateEvent));
|
networkMgr.addEventListener('update-early',
|
||||||
|
(e) => viewer.value?.onProgress((e as CustomEvent<Array<any>>).detail.length * 0.01));
|
||||||
|
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
|
||||||
// Start loading all configured models ASAP
|
// Start loading all configured models ASAP
|
||||||
for (let model of settings.preload) {
|
for (let model of settings.preload) {
|
||||||
networkMgr.load(model);
|
networkMgr.load(model);
|
||||||
}
|
}
|
||||||
|
watch(viewer, (newViewer) => {
|
||||||
|
if (newViewer) {
|
||||||
|
newViewer.setPosterText('<tspan x="50%" dy="1.2em">Trying to load' +
|
||||||
|
' models from:</tspan>' + settings.preload.map((url) => '<tspan x="50%" dy="1.2em">- ' + url + '</tspan>').join(""));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
async function loadModelManual() {
|
async function loadModelManual() {
|
||||||
const modelUrl = prompt("For an improved experience in viewing CAD/GLTF models with automatic updates, it's recommended to use the official yacv_server Python package. This ensures seamless serving of models and automatic updates.\n\nOtherwise, enter the URL of the model to load:");
|
const modelUrl = prompt("For an improved experience in viewing CAD/GLTF models with automatic updates, it's recommended to use the official yacv_server Python package. This ensures seamless serving of models and automatic updates.\n\nOtherwise, enter the URL of the model to load:");
|
||||||
@@ -65,24 +105,24 @@ async function loadModelManual() {
|
|||||||
</v-main>
|
</v-main>
|
||||||
|
|
||||||
<!-- The left collapsible sidebar has the list of models -->
|
<!-- The left collapsible sidebar has the list of models -->
|
||||||
<sidebar :opened-init="openSidebarsByDefault" side="left" :width="300">
|
<sidebar :opened-init="openSidebarsByDefault" :width="300" side="left">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<v-toolbar-title>Models</v-toolbar-title>
|
<v-toolbar-title>Models</v-toolbar-title>
|
||||||
</template>
|
</template>
|
||||||
<template #toolbar-items>
|
<template #toolbar-items>
|
||||||
<v-btn icon="" @click="loadModelManual">
|
<v-btn icon="" @click="loadModelManual">
|
||||||
<svg-icon type="mdi" :path="mdiPlus"/>
|
<svg-icon :path="mdiPlus" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
|
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
|
||||||
</sidebar>
|
</sidebar>
|
||||||
|
|
||||||
<!-- The right collapsible sidebar has the list of tools -->
|
<!-- The right collapsible sidebar has the list of tools -->
|
||||||
<sidebar :opened-init="openSidebarsByDefault" side="right" :width="48 * 3 /* buttons */ + 1 /* border? */">
|
<sidebar :opened-init="openSidebarsByDefault" :width="48 * 3 /* buttons */ + 1 /* border? */" side="right">
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<v-toolbar-title>Tools</v-toolbar-title>
|
<v-toolbar-title>Tools</v-toolbar-title>
|
||||||
</template>
|
</template>
|
||||||
<tools :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
|
<tools ref="tools" :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
|
||||||
</sidebar>
|
</sidebar>
|
||||||
|
|
||||||
</v-layout>
|
</v-layout>
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ import {createVuetify} from 'vuetify';
|
|||||||
import * as directives from 'vuetify/lib/directives/index.mjs';
|
import * as directives from 'vuetify/lib/directives/index.mjs';
|
||||||
import 'vuetify/dist/vuetify.css';
|
import 'vuetify/dist/vuetify.css';
|
||||||
|
|
||||||
|
// @ts-ignore
|
||||||
|
if (__APP_NAME__) {
|
||||||
|
// @ts-ignore
|
||||||
|
console.log(`Starting ${__APP_NAME__} v${__APP_VERSION__} (${__APP_GIT_SHA__}${__APP_GIT_DIRTY__ ? "+dirty" : ""})...`);
|
||||||
|
}
|
||||||
|
|
||||||
const vuetify = createVuetify({
|
const vuetify = createVuetify({
|
||||||
directives,
|
directives,
|
||||||
theme: {
|
theme: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {VContainer, VRow, VCol, VProgressCircular} from "vuetify/lib/components/index.mjs";
|
import {VCol, VContainer, VProgressCircular, VRow} from "vuetify/lib/components/index.mjs";
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {ref} from "vue";
|
import {ref} from "vue";
|
||||||
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
|
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
|
||||||
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
|
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
|
||||||
@@ -16,22 +16,22 @@ const openIcon = props.side === 'left' ? mdiChevronRight : mdiChevronLeft;
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-btn icon @click="opened = !opened" class="open-button" :class="side">
|
<v-btn :class="side" class="open-button" icon @click="opened = !opened">
|
||||||
<svg-icon type="mdi" :path="openIcon"/>
|
<svg-icon :path="openIcon" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-navigation-drawer v-model="opened" permanent :location="side" :width="props.width">
|
<v-navigation-drawer v-model="opened" :location="side" :width="props.width" permanent>
|
||||||
<v-toolbar density="compact">
|
<v-toolbar density="compact">
|
||||||
<v-toolbar-items v-if="side == 'right'">
|
<v-toolbar-items v-if="side == 'right'">
|
||||||
<slot name="toolbar-items"></slot>
|
<slot name="toolbar-items"></slot>
|
||||||
<v-btn icon @click="opened = !opened">
|
<v-btn icon @click="opened = !opened">
|
||||||
<svg-icon type="mdi" :path="mdiClose"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar-items>
|
</v-toolbar-items>
|
||||||
<slot name="toolbar"></slot>
|
<slot name="toolbar"></slot>
|
||||||
<v-toolbar-items v-if="side == 'left'">
|
<v-toolbar-items v-if="side == 'left'">
|
||||||
<slot name="toolbar-items"></slot>
|
<slot name="toolbar-items"></slot>
|
||||||
<v-btn icon @click="opened = !opened">
|
<v-btn icon @click="opened = !opened">
|
||||||
<svg-icon type="mdi" :path="mdiClose"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar-items>
|
</v-toolbar-items>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three';
|
import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three';
|
||||||
import type {MObject3D} from "../tools/Selection.vue";
|
import type {MObject3D} from "../tools/Selection.vue";
|
||||||
import type { ModelScene } from '@google/model-viewer/lib/three-components/ModelScene';
|
import type {ModelScene} from '@google/model-viewer/lib/three-components/ModelScene';
|
||||||
|
import type {SelectionInfo} from "../tools/selection";
|
||||||
|
|
||||||
|
|
||||||
function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
|
function getCenterAndVertexList(selInfo: SelectionInfo, scene: ModelScene): {
|
||||||
center: Vector3,
|
center: Vector3,
|
||||||
vertices: Array<Vector3>
|
vertices: Array<Vector3>
|
||||||
} {
|
} {
|
||||||
obj.updateMatrixWorld();
|
let pos: BufferAttribute | InterleavedBufferAttribute = selInfo.object.geometry.getAttribute('position');
|
||||||
let pos: BufferAttribute | InterleavedBufferAttribute = obj.geometry.getAttribute('position');
|
let ind: BufferAttribute | null = selInfo.object.geometry.index;
|
||||||
let ind: BufferAttribute | null = obj.geometry.index;
|
if (ind === null) {
|
||||||
if (!ind) {
|
|
||||||
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
|
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
|
||||||
for (let i = 0; i < pos.count; i++) {
|
for (let i = 0; i < pos.count; i++) {
|
||||||
ind.array[i] = i;
|
ind.array[i] = i;
|
||||||
@@ -18,15 +18,14 @@ function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
|
|||||||
}
|
}
|
||||||
let center = new Vector3();
|
let center = new Vector3();
|
||||||
let vertices = [];
|
let vertices = [];
|
||||||
for (let i = 0; i < ind.count; i++) {
|
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
|
||||||
let index = ind.array[i];
|
let index = ind.getX(i)
|
||||||
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index));
|
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index));
|
||||||
vertex = scene.target.worldToLocal(obj.localToWorld(vertex));
|
vertex = scene.target.worldToLocal(selInfo.object.localToWorld(vertex));
|
||||||
center.add(vertex);
|
center.add(vertex);
|
||||||
vertices.push(vertex);
|
vertices.push(vertex);
|
||||||
}
|
}
|
||||||
center = center.divideScalar(ind.count);
|
center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]);
|
||||||
console.log("center", center)
|
|
||||||
return {center, vertices};
|
return {center, vertices};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
|
|||||||
* Given two THREE.Object3D objects, returns their closest and farthest vertices, and the geometric centers.
|
* Given two THREE.Object3D objects, returns their closest and farthest vertices, and the geometric centers.
|
||||||
* All of them are approximated and should not be used for precise calculations.
|
* All of them are approximated and should not be used for precise calculations.
|
||||||
*/
|
*/
|
||||||
export function distances(a: MObject3D, b: MObject3D, scene: ModelScene): {
|
export function distances(a: SelectionInfo, b: SelectionInfo, scene: ModelScene): {
|
||||||
min: Array<Vector3>,
|
min: Array<Vector3>,
|
||||||
center: Array<Vector3>,
|
center: Array<Vector3>,
|
||||||
max: Array<Vector3>
|
max: Array<Vector3>
|
||||||
@@ -46,7 +45,6 @@ export function distances(a: MObject3D, b: MObject3D, scene: ModelScene): {
|
|||||||
|
|
||||||
// Find the closest and farthest vertices.
|
// Find the closest and farthest vertices.
|
||||||
// TODO: Compute actual min and max distances between the two objects.
|
// TODO: Compute actual min and max distances between the two objects.
|
||||||
// FIXME: Working for points and lines, but not triangles...
|
|
||||||
// FIXME: Really slow... (use a BVH or something)
|
// FIXME: Really slow... (use a BVH or something)
|
||||||
let minDistance = Infinity;
|
let minDistance = Infinity;
|
||||||
let minDistanceVertices = [new Vector3(), new Vector3()];
|
let minDistanceVertices = [new Vector3(), new Vector3()];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import {Document, Scene, type Transform, WebIO, Buffer} from "@gltf-transform/core";
|
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
|
||||||
import {unpartition} from "@gltf-transform/functions";
|
import {unpartition, mergeDocuments} from "@gltf-transform/functions";
|
||||||
|
|
||||||
let io = new WebIO();
|
let io = new WebIO();
|
||||||
export let extrasNameKey = "__yacv_name";
|
export let extrasNameKey = "__yacv_name";
|
||||||
@@ -12,9 +12,41 @@ export let extrasNameValueHelpers = "__helpers";
|
|||||||
*
|
*
|
||||||
* Remember to call mergeFinalize after all models have been merged (slower required operations).
|
* 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> {
|
||||||
|
// Fetch the complete document from the network
|
||||||
|
// This could be done at the same time as the document is being processed, but I wanted better metrics
|
||||||
|
let response = await fetch(url);
|
||||||
|
let buffer = await response.arrayBuffer();
|
||||||
|
networkFinished();
|
||||||
|
|
||||||
// Load the new document
|
// Load the new document
|
||||||
let newDoc = await io.read(url);
|
let newDoc = null;
|
||||||
|
let alreadyTried: { [name: string]: boolean } = {}
|
||||||
|
while (newDoc == null) { // Retry adding extensions as required until the document is loaded
|
||||||
|
try { // Try to load fast if no extensions are used
|
||||||
|
newDoc = await io.readBinary(new Uint8Array(buffer));
|
||||||
|
} catch (e) { // Fallback to wait for download and register big extensions
|
||||||
|
if (e instanceof Error && e.message.toLowerCase().includes("khr_draco_mesh_compression")) {
|
||||||
|
if (alreadyTried["draco"]) throw e; else alreadyTried["draco"] = true;
|
||||||
|
// WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?)
|
||||||
|
let {KHRDracoMeshCompression} = await import("@gltf-transform/extensions")
|
||||||
|
let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js");
|
||||||
|
let dracoEncoderWeb = await import("three/examples/jsm/libs/draco/draco_encoder.js");
|
||||||
|
io.registerExtensions([KHRDracoMeshCompression])
|
||||||
|
.registerDependencies({
|
||||||
|
'draco3d.decoder': await dracoDecoderWeb.default({}),
|
||||||
|
'draco3d.encoder': await dracoEncoderWeb.default({})
|
||||||
|
});
|
||||||
|
} else if (e instanceof Error && e.message.toLowerCase().includes("ext_texture_webp")) {
|
||||||
|
if (alreadyTried["webp"]) throw e; else alreadyTried["webp"] = true;
|
||||||
|
let {EXTTextureWebP} = await import("@gltf-transform/extensions")
|
||||||
|
io.registerExtensions([EXTTextureWebP]);
|
||||||
|
} else { // TODO: Add more extensions as required
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove any previous model with the same name
|
// Remove any previous model with the same name
|
||||||
await document.transform(dropByName(name));
|
await document.transform(dropByName(name));
|
||||||
@@ -24,7 +56,8 @@ export async function mergePartial(url: string, name: string, document: Document
|
|||||||
await newDoc.transform(setNames(name));
|
await newDoc.transform(setNames(name));
|
||||||
|
|
||||||
// Merge the new document into the current one
|
// Merge the new document into the current one
|
||||||
return document.merge(newDoc);
|
mergeDocuments(document, newDoc);
|
||||||
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function mergeFinalize(document: Document): Promise<Document> {
|
export async function mergeFinalize(document: Document): Promise<Document> {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
// noinspection JSVoidFunctionReturnValueUsed,JSUnresolvedReference
|
||||||
|
|
||||||
import {Document, type TypedArray} from '@gltf-transform/core'
|
import {Document, type TypedArray} from '@gltf-transform/core'
|
||||||
import {Vector2} from "three/src/math/Vector2.js"
|
import {Vector2} from "three/src/math/Vector2.js"
|
||||||
import {Vector3} from "three/src/math/Vector3.js"
|
import {Vector3} from "three/src/math/Vector3.js"
|
||||||
@@ -26,7 +28,7 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
|
|||||||
if (rawColors) {
|
if (rawColors) {
|
||||||
colors = doc.createAccessor(name + 'Color')
|
colors = doc.createAccessor(name + 'Color')
|
||||||
.setArray(new Float32Array(rawColors) as TypedArray)
|
.setArray(new Float32Array(rawColors) as TypedArray)
|
||||||
.setType('VEC3')
|
.setType('VEC4')
|
||||||
.setBuffer(buffer);
|
.setBuffer(buffer);
|
||||||
}
|
}
|
||||||
const material = doc.createMaterial(name + 'Material')
|
const material = doc.createMaterial(name + 'Material')
|
||||||
@@ -39,6 +41,11 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
|
|||||||
if (rawColors) {
|
if (rawColors) {
|
||||||
geometry.setAttribute('COLOR_0', colors)
|
geometry.setAttribute('COLOR_0', colors)
|
||||||
}
|
}
|
||||||
|
if (mode == WebGL2RenderingContext.TRIANGLES) {
|
||||||
|
geometry.setExtras({face_triangles_end: [rawIndices.length / 6, rawIndices.length * 2 / 6, rawIndices.length * 3 / 6, rawIndices.length * 4 / 6, rawIndices.length * 5 / 6, rawIndices.length]})
|
||||||
|
} else if (mode == WebGL2RenderingContext.LINES) {
|
||||||
|
geometry.setExtras({edge_points_end: [rawIndices.length / 3, rawIndices.length * 2 / 3, rawIndices.length]})
|
||||||
|
}
|
||||||
const mesh = doc.createMesh(name + 'Mesh').addPrimitive(geometry)
|
const mesh = doc.createMesh(name + 'Mesh').addPrimitive(geometry)
|
||||||
const node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any)
|
const node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any)
|
||||||
scene.addChild(node)
|
scene.addChild(node)
|
||||||
@@ -48,21 +55,25 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
|
|||||||
* Create a new Axes helper as a GLTF model, useful for debugging positions and orientations.
|
* Create a new Axes helper as a GLTF model, useful for debugging positions and orientations.
|
||||||
*/
|
*/
|
||||||
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
|
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
|
||||||
|
let rawIndices = [0, 1, 2, 3, 4, 5];
|
||||||
let rawPositions = [
|
let rawPositions = [
|
||||||
[0, 0, 0, size.x, 0, 0],
|
0, 0, 0, size.x, 0, 0,
|
||||||
[0, 0, 0, 0, size.y, 0],
|
0, 0, 0, 0, size.y, 0,
|
||||||
[0, 0, 0, 0, 0, -size.z],
|
0, 0, 0, 0, 0, -size.z,
|
||||||
];
|
];
|
||||||
let rawIndices = [0, 1];
|
|
||||||
let rawColors = [
|
let rawColors = [
|
||||||
[...(AxesColors.x[0]), ...(AxesColors.x[1])],
|
...(AxesColors.x[0]), 255, ...(AxesColors.x[1]), 255,
|
||||||
[...(AxesColors.y[0]), ...(AxesColors.y[1])],
|
...(AxesColors.y[0]), 255, ...(AxesColors.y[1]), 255,
|
||||||
[...(AxesColors.z[0]), ...(AxesColors.z[1])],
|
...(AxesColors.z[0]), 255, ...(AxesColors.z[1]), 255
|
||||||
].map(g => g.map(x => x / 255.0));
|
].map(x => x / 255.0);
|
||||||
buildSimpleGltf(doc, rawPositions[0], rawIndices, rawColors[0], transform, '__helper_axes');
|
// Axes at (0, 0, 0)
|
||||||
buildSimpleGltf(doc, rawPositions[1], rawIndices, rawColors[1], transform, '__helper_axes');
|
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), '__helper_axes');
|
||||||
buildSimpleGltf(doc, rawPositions[2], rawIndices, rawColors[2], transform, '__helper_axes');
|
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], new Matrix4(), '__helper_axes', WebGL2RenderingContext.POINTS);
|
||||||
buildSimpleGltf(doc, [0, 0, 0], [0], null, transform, '__helper_axes', WebGL2RenderingContext.POINTS);
|
// Axes at center
|
||||||
|
if (new Matrix4() != transform) {
|
||||||
|
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, transform, '__helper_axes_center');
|
||||||
|
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], transform, '__helper_axes_center', WebGL2RenderingContext.POINTS);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -71,8 +82,10 @@ export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
|
|||||||
* The grid is built as a box of triangles (representing lines) looking to the inside of the box.
|
* The grid is built as a box of triangles (representing lines) looking to the inside of the box.
|
||||||
* This ensures that only the back of the grid is always visible, regardless of the camera position.
|
* This ensures that only the back of the grid is always visible, regardless of the camera position.
|
||||||
*/
|
*/
|
||||||
export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4 = new Matrix4(), divisions = 10) {
|
export async function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4, divisions = 10) {
|
||||||
// Create transformed positions for the inner faces of the box
|
// Create transformed positions for the inner faces of the box
|
||||||
|
let allPositions: number[] = [];
|
||||||
|
let allIndices: number[] = [];
|
||||||
for (let axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
|
for (let axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
|
||||||
for (let positive of [1, -1]) {
|
for (let positive of [1, -1]) {
|
||||||
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive));
|
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive));
|
||||||
@@ -82,13 +95,25 @@ export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4
|
|||||||
if (axis.x) size2.set(size.z, size.y);
|
if (axis.x) size2.set(size.z, size.y);
|
||||||
if (axis.y) size2.set(size.x, size.z);
|
if (axis.y) size2.set(size.x, size.z);
|
||||||
if (axis.z) size2.set(size.x, size.y);
|
if (axis.z) size2.set(size.x, size.y);
|
||||||
let transform = baseTransform.clone().multiply(translation).multiply(rotation);
|
let transform = new Matrix4().multiply(translation).multiply(rotation);
|
||||||
newGridPlane(doc, size2, transform, divisions);
|
let [rawPositions, rawIndices] = newGridPlane(size2, divisions);
|
||||||
|
let baseIndex = allPositions.length / 3;
|
||||||
|
for (let i of rawIndices) {
|
||||||
|
allIndices.push(i + baseIndex);
|
||||||
|
}
|
||||||
|
// Apply transform to the positions before adding them to the list
|
||||||
|
for (let i = 0; i < rawPositions.length; i += 3) {
|
||||||
|
let pos = new Vector3(rawPositions[i], rawPositions[i + 1], rawPositions[i + 2]);
|
||||||
|
pos.applyMatrix4(transform);
|
||||||
|
allPositions.push(pos.x, pos.y, pos.z);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let colors = new Array(allPositions.length / 3 * 4).fill(1);
|
||||||
|
buildSimpleGltf(doc, allPositions, allIndices, colors, baseTransform, '__helper_grid', WebGL2RenderingContext.TRIANGLES);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newGridPlane(doc: Document, size: Vector2, transform: Matrix4 = new Matrix4(), divisions = 10, divisionWidth = 0.002) {
|
export function newGridPlane(size: Vector2, divisions = 10, divisionWidth = 0.002): [number[], number[]] {
|
||||||
const rawPositions = [];
|
const rawPositions = [];
|
||||||
const rawIndices = [];
|
const rawIndices = [];
|
||||||
// Build the grid as triangles
|
// Build the grid as triangles
|
||||||
@@ -114,5 +139,5 @@ export function newGridPlane(doc: Document, size: Vector2, transform: Matrix4 =
|
|||||||
rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2);
|
rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2);
|
||||||
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3);
|
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3);
|
||||||
}
|
}
|
||||||
buildSimpleGltf(doc, rawPositions, rawIndices, null, transform, '__helper_grid', WebGL2RenderingContext.TRIANGLES);
|
return [rawPositions, rawIndices];
|
||||||
}
|
}
|
||||||
55
frontend/misc/lines.ts
Normal file
55
frontend/misc/lines.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import {BufferGeometry} from 'three/src/core/BufferGeometry.js';
|
||||||
|
import {Vector2} from 'three/src/math/Vector2.js';
|
||||||
|
|
||||||
|
// The following imports must be done dynamically to be able to import three.js separately (smaller bundle sizee)
|
||||||
|
// import {LineSegments2} from "three/examples/jsm/lines/LineSegments2.js";
|
||||||
|
// import {LineMaterial} from "three/examples/jsm/lines/LineMaterial.js";
|
||||||
|
// import {LineSegmentsGeometry} from 'three/examples/jsm/lines/LineSegmentsGeometry.js';
|
||||||
|
const LineSegments2Import = import('three/examples/jsm/lines/LineSegments2.js');
|
||||||
|
const LineMaterialImport = import('three/examples/jsm/lines/LineMaterial.js');
|
||||||
|
const LineSegmentsGeometryImport = import('three/examples/jsm/lines/LineSegmentsGeometry.js');
|
||||||
|
|
||||||
|
export async function toLineSegments(bufferGeometry: BufferGeometry, lineWidth: number = 0.1) {
|
||||||
|
const LineSegments2 = (await LineSegments2Import).LineSegments2;
|
||||||
|
const LineMaterial = (await LineMaterialImport).LineMaterial;
|
||||||
|
return new LineSegments2(await toLineSegmentsGeometry(bufferGeometry), new LineMaterial({
|
||||||
|
color: 0xffffffff,
|
||||||
|
vertexColors: true,
|
||||||
|
linewidth: lineWidth, // mm
|
||||||
|
worldUnits: true,
|
||||||
|
resolution: new Vector2(1, 1), // Update resolution on resize!!!
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toLineSegmentsGeometry(bufferGeometry: BufferGeometry) {
|
||||||
|
const LineSegmentsGeometry = (await LineSegmentsGeometryImport).LineSegmentsGeometry;
|
||||||
|
const lg = new LineSegmentsGeometry();
|
||||||
|
|
||||||
|
const position = bufferGeometry.getAttribute('position');
|
||||||
|
const indexAttribute = bufferGeometry.index!!;
|
||||||
|
const positions = [];
|
||||||
|
for (let index = 0; index != indexAttribute.count; ++index) {
|
||||||
|
const i = indexAttribute.getX(index);
|
||||||
|
const x = position.getX(i);
|
||||||
|
const y = position.getY(i);
|
||||||
|
const z = position.getZ(i);
|
||||||
|
positions.push(x, y, z);
|
||||||
|
}
|
||||||
|
lg.setPositions(positions);
|
||||||
|
|
||||||
|
const colors = [];
|
||||||
|
const color = bufferGeometry.getAttribute('color');
|
||||||
|
if (color) {
|
||||||
|
for (let index = 0; index != indexAttribute.count; ++index) {
|
||||||
|
const i = indexAttribute.getX(index);
|
||||||
|
const r = color.getX(i);
|
||||||
|
const g = color.getY(i);
|
||||||
|
const b = color.getZ(i);
|
||||||
|
colors.push(r, g, b);
|
||||||
|
}
|
||||||
|
lg.setColors(colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
lg.userData = bufferGeometry.userData;
|
||||||
|
return lg;
|
||||||
|
}
|
||||||
@@ -1,19 +1,38 @@
|
|||||||
import {settings} from "./settings";
|
import {settings} from "./settings";
|
||||||
|
|
||||||
export class NetworkUpdateEvent extends Event {
|
const batchTimeout = 250; // ms
|
||||||
|
|
||||||
|
export class NetworkUpdateEventModel {
|
||||||
name: string;
|
name: string;
|
||||||
url: string;
|
url: string;
|
||||||
|
// TODO: Detect and manage instances of the same object (same hash, different name)
|
||||||
|
hash: string | null;
|
||||||
|
isRemove: boolean | null; // This is null for a shutdown event
|
||||||
|
|
||||||
constructor(name: string, url: string) {
|
constructor(name: string, url: string, hash: string | null, isRemove: boolean | null) {
|
||||||
super("update");
|
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.url = url;
|
this.url = url;
|
||||||
|
this.hash = hash;
|
||||||
|
this.isRemove = isRemove;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class NetworkUpdateEvent extends Event {
|
||||||
|
models: NetworkUpdateEventModel[];
|
||||||
|
disconnect: () => void;
|
||||||
|
|
||||||
|
constructor(models: NetworkUpdateEventModel[], disconnect: () => void) {
|
||||||
|
super("update");
|
||||||
|
this.models = models;
|
||||||
|
this.disconnect = disconnect;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Listens for updates and emits events when a model changes */
|
/** Listens for updates and emits events when a model changes */
|
||||||
export class NetworkManager extends EventTarget {
|
export class NetworkManager extends EventTarget {
|
||||||
private knownObjectHashes: { [name: string]: string | null } = {};
|
private knownObjectHashes: { [name: string]: string | null } = {};
|
||||||
|
private bufferedUpdates: NetworkUpdateEventModel[] = [];
|
||||||
|
private batchTimeout: number | null = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries to load a new model (.glb) from the given URL.
|
* Tries to load a new model (.glb) from the given URL.
|
||||||
@@ -27,7 +46,7 @@ export class NetworkManager extends EventTarget {
|
|||||||
if (url.startsWith("dev+")) {
|
if (url.startsWith("dev+")) {
|
||||||
let baseUrl = new URL(url.slice(4));
|
let baseUrl = new URL(url.slice(4));
|
||||||
baseUrl.searchParams.set("api_updates", "true");
|
baseUrl.searchParams.set("api_updates", "true");
|
||||||
this.monitorDevServer(baseUrl);
|
await this.monitorDevServer(baseUrl);
|
||||||
} else {
|
} else {
|
||||||
// Get the last part of the URL as the "name" of the model
|
// Get the last part of the URL as the "name" of the model
|
||||||
let name = url.split("/").pop();
|
let name = url.split("/").pop();
|
||||||
@@ -36,31 +55,100 @@ export class NetworkManager extends EventTarget {
|
|||||||
let response = await fetch(url, {method: "HEAD"});
|
let response = await fetch(url, {method: "HEAD"});
|
||||||
let hash = response.headers.get("etag");
|
let hash = response.headers.get("etag");
|
||||||
// Only trigger an update if the hash has changed
|
// Only trigger an update if the hash has changed
|
||||||
this.foundModel(name, hash, url);
|
this.foundModel(name, hash, url, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private monitorDevServer(url: URL) {
|
private async monitorDevServer(url: URL, stop: () => boolean = () => false) {
|
||||||
// WARNING: This will spam the console logs with failed requests when the server is down
|
while (!stop()) {
|
||||||
let eventSource = new EventSource(url);
|
try {
|
||||||
eventSource.onmessage = (event) => {
|
// WARNING: This will spam the console logs with failed requests when the server is down
|
||||||
let data = JSON.parse(event.data);
|
const controller = new AbortController();
|
||||||
console.debug("WebSocket message", data);
|
let response = await fetch(url.toString(), {signal: controller.signal});
|
||||||
let urlObj = new URL(url);
|
// console.log("Monitoring", url.toString(), response);
|
||||||
urlObj.searchParams.delete("api_updates");
|
if (response.status === 200) {
|
||||||
urlObj.searchParams.set("api_object", data.name);
|
let lines = readLinesStreamings(response.body!.getReader());
|
||||||
this.foundModel(data.name, data.hash, urlObj.toString());
|
for await (let line of lines) {
|
||||||
};
|
if (stop()) break;
|
||||||
eventSource.onerror = () => { // Retry after a very short delay
|
if (!line || !line.startsWith("data:")) continue;
|
||||||
setTimeout(() => this.monitorDevServer(url), settings.monitorEveryMs);
|
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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Server is down, wait a little longer before retrying
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 10 * settings.monitorEveryMs));
|
||||||
|
}
|
||||||
|
controller.abort();
|
||||||
|
} catch (e) { // Ignore errors (retry very soon)
|
||||||
|
}
|
||||||
|
await new Promise(resolve => setTimeout(resolve, settings.monitorEveryMs));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private foundModel(name: string, hash: string | null, url: string) {
|
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean | null, disconnect: () => void = () => {
|
||||||
let prevHash = this.knownObjectHashes[name];
|
}) {
|
||||||
if (!hash || hash !== prevHash) {
|
// console.debug("Found model", name, "with hash", hash, "at", url, "isRemove", isRemove);
|
||||||
this.knownObjectHashes[name] = hash;
|
|
||||||
this.dispatchEvent(new NetworkUpdateEvent(name, url));
|
// We only care about the latest update per model name
|
||||||
|
this.bufferedUpdates = this.bufferedUpdates.filter(m => m.name !== name);
|
||||||
|
|
||||||
|
// Add the new model to the list of updates and dispatch the early update
|
||||||
|
let upd = new NetworkUpdateEventModel(name, url, hash, isRemove);
|
||||||
|
this.bufferedUpdates.push(upd);
|
||||||
|
this.dispatchEvent(new CustomEvent("update-early", {detail: this.bufferedUpdates}));
|
||||||
|
|
||||||
|
// Optimization: try to batch updates automatically for faster rendering
|
||||||
|
if (this.batchTimeout !== null) clearTimeout(this.batchTimeout);
|
||||||
|
this.batchTimeout = setTimeout(() => {
|
||||||
|
// Update known hashes for minimal updates
|
||||||
|
for (let model of this.bufferedUpdates) {
|
||||||
|
if (model.isRemove == false && model.hash && model.hash === this.knownObjectHashes[model.name]) {
|
||||||
|
// Delete this useless update
|
||||||
|
let foundFirst = false;
|
||||||
|
this.bufferedUpdates = this.bufferedUpdates.filter(m => {
|
||||||
|
if (m === model) {
|
||||||
|
if (!foundFirst) { // Remove only first full match
|
||||||
|
foundFirst = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Keep this update and update the last known hash
|
||||||
|
if (model.isRemove == true) {
|
||||||
|
if (model.name in this.knownObjectHashes) delete this.knownObjectHashes[model.name];
|
||||||
|
} else if (model.isRemove == false) {
|
||||||
|
this.knownObjectHashes[model.name] = model.hash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dispatch the event to actually update the models
|
||||||
|
this.dispatchEvent(new NetworkUpdateEvent(this.bufferedUpdates, disconnect));
|
||||||
|
this.bufferedUpdates = [];
|
||||||
|
}, batchTimeout);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function* readLinesStreamings(reader: ReadableStreamDefaultReader<Uint8Array>) {
|
||||||
|
let decoder = new TextDecoder();
|
||||||
|
let buffer = new Uint8Array();
|
||||||
|
while (true) {
|
||||||
|
let {value, done} = await reader.read();
|
||||||
|
if (done || !value) break;
|
||||||
|
buffer = new Uint8Array([...buffer, ...value]);
|
||||||
|
let text = decoder.decode(buffer);
|
||||||
|
let lines = text.split("\n");
|
||||||
|
for (let i = 0; i < lines.length - 1; i++) {
|
||||||
|
yield lines[i];
|
||||||
}
|
}
|
||||||
|
buffer = new Uint8Array([...buffer.slice(-1)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -9,41 +9,36 @@ import {Matrix4} from "three/src/math/Matrix4.js"
|
|||||||
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
|
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
|
||||||
export class SceneMgr {
|
export class SceneMgr {
|
||||||
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
|
/** 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): Promise<Document> {
|
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
|
||||||
let loadStart = performance.now();
|
let loadStart = performance.now();
|
||||||
|
let loadNetworkEnd: number;
|
||||||
|
|
||||||
// Start merging into the current document, replacing or adding as needed
|
try {
|
||||||
document = await mergePartial(url, name, document);
|
// Start merging into the current document, replacing or adding as needed
|
||||||
|
document = await mergePartial(url, name, document, () => loadNetworkEnd = performance.now());
|
||||||
|
|
||||||
console.log("Model", name, "loaded in", performance.now() - loadStart, "ms");
|
console.log("Model", name, "loaded in", performance.now() - loadNetworkEnd!, "ms after",
|
||||||
|
loadNetworkEnd! - loadStart, "ms of transferring data (maybe building the object on the server)");
|
||||||
|
} finally {
|
||||||
|
if (updateHelpers) {
|
||||||
|
// Reload the helpers to fit the new model
|
||||||
|
await this.reloadHelpers(sceneUrl, document, reloadScene);
|
||||||
|
reloadScene = false;
|
||||||
|
}
|
||||||
|
|
||||||
if (name !== extrasNameValueHelpers) {
|
if (reloadScene) {
|
||||||
// Reload the helpers to fit the new model
|
// Display the final fully loaded model
|
||||||
// TODO: Only reload the helpers after a few milliseconds of no more models being added/removed
|
let displayStart = performance.now();
|
||||||
document = await this.reloadHelpers(sceneUrl, document);
|
document = await this.showCurrentDoc(sceneUrl, document);
|
||||||
} else {
|
console.log("Scene displayed in", performance.now() - displayStart, "ms");
|
||||||
// Display the final fully loaded model
|
}
|
||||||
let displayStart = performance.now();
|
|
||||||
document = await this.showCurrentDoc(sceneUrl, document);
|
|
||||||
console.log("Scene displayed in", performance.now() - displayStart, "ms");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
static getBoundingBox(document: Document): Box3 | null {
|
||||||
let bb = SceneMgr.getBoundingBox(document);
|
if (document.getRoot().listNodes().length === 0) return null;
|
||||||
|
|
||||||
// Create the helper axes and grid box
|
|
||||||
let helpersDoc = new Document();
|
|
||||||
let transform = (new Matrix4()).makeTranslation(bb.getCenter(new Vector3()));
|
|
||||||
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
|
|
||||||
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
|
|
||||||
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)]));
|
|
||||||
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
static getBoundingBox(document: Document): Box3 {
|
|
||||||
// Get bounding box of the model and use it to set the size of the helpers
|
// Get bounding box of the model and use it to set the size of the helpers
|
||||||
let bbMin: number[] = [1e6, 1e6, 1e6];
|
let bbMin: number[] = [1e6, 1e6, 1e6];
|
||||||
let bbMax: number[] = [-1e6, -1e6, -1e6];
|
let bbMax: number[] = [-1e6, -1e6, -1e6];
|
||||||
@@ -67,7 +62,7 @@ export class SceneMgr {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Removes a model from the viewer */
|
/** Removes a model from the viewer */
|
||||||
static async removeModel(sceneUrl: Ref<string>, document: Document, name: string): Promise<Document> {
|
static async removeModel(sceneUrl: Ref<string>, document: Document, name: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
|
||||||
let loadStart = performance.now();
|
let loadStart = performance.now();
|
||||||
|
|
||||||
// Remove the model from the document
|
// Remove the model from the document
|
||||||
@@ -75,12 +70,27 @@ export class SceneMgr {
|
|||||||
|
|
||||||
console.log("Model", name, "removed in", performance.now() - loadStart, "ms");
|
console.log("Model", name, "removed in", performance.now() - loadStart, "ms");
|
||||||
|
|
||||||
// Reload the helpers to fit the new model (will also show the document)
|
if (updateHelpers) {
|
||||||
document = await this.reloadHelpers(sceneUrl, document);
|
// Reload the helpers to fit the new model (will also show the document)
|
||||||
|
document = await this.reloadHelpers(sceneUrl, document, reloadScene);
|
||||||
|
}
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
let transform = (new Matrix4()).makeTranslation(bb.getCenter(new Vector3()));
|
||||||
|
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
|
||||||
|
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
|
||||||
|
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)]));
|
||||||
|
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
|
||||||
|
}
|
||||||
|
|
||||||
/** Serializes the current document into a GLB and updates the viewerSrc */
|
/** Serializes the current document into a GLB and updates the viewerSrc */
|
||||||
private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
||||||
// Make sure the document is fully loaded and ready to be shown
|
// Make sure the document is fully loaded and ready to be shown
|
||||||
@@ -89,7 +99,7 @@ export class SceneMgr {
|
|||||||
// Serialize the document into a GLB and update the viewerSrc
|
// Serialize the document into a GLB and update the viewerSrc
|
||||||
let buffer = await toBuffer(document);
|
let buffer = await toBuffer(document);
|
||||||
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
|
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
|
||||||
console.debug("Showing current doc", document, "as", Array.from(buffer));
|
console.debug("Showing current doc", document, "with", buffer.length, "total bytes");
|
||||||
sceneUrl.value = URL.createObjectURL(blob);
|
sceneUrl.value = URL.createObjectURL(blob);
|
||||||
|
|
||||||
return document;
|
return document;
|
||||||
|
|||||||
@@ -12,12 +12,17 @@ export const settings = {
|
|||||||
// Websocket URLs automatically listen for new models from the python backend
|
// Websocket URLs automatically listen for new models from the python backend
|
||||||
"dev+http://127.0.0.1:32323/"
|
"dev+http://127.0.0.1:32323/"
|
||||||
],
|
],
|
||||||
|
loadHelpers: true,
|
||||||
|
edgeWidth: 0, /* The default line size for edges, set to 0 to use basic gl.LINEs */
|
||||||
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
|
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
|
||||||
monitorEveryMs: 100,
|
monitorEveryMs: 100,
|
||||||
monitorOpenTimeoutMs: 1000,
|
monitorOpenTimeoutMs: 1000,
|
||||||
// ModelViewer settings
|
// ModelViewer settings
|
||||||
autoplay: true,
|
autoplay: true, // Global animation toggle
|
||||||
arModes: 'webxr scene-viewer quick-look',
|
arModes: 'webxr scene-viewer quick-look',
|
||||||
|
zoomSensitivity: 0.25,
|
||||||
|
orbitSensitivity: 1,
|
||||||
|
panSensitivity: 1,
|
||||||
exposure: 1,
|
exposure: 1,
|
||||||
shadowIntensity: 0,
|
shadowIntensity: 0,
|
||||||
background: '',
|
background: '',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {
|
import {
|
||||||
VBtn,
|
VBtn,
|
||||||
VBtnToggle,
|
VBtnToggle,
|
||||||
@@ -12,8 +12,8 @@ import {
|
|||||||
VTooltip,
|
VTooltip,
|
||||||
} from "vuetify/lib/components/index.mjs";
|
} from "vuetify/lib/components/index.mjs";
|
||||||
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
|
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
|
||||||
import {Document, Mesh} from "@gltf-transform/core";
|
import {Mesh} from "@gltf-transform/core";
|
||||||
import {inject, ref, type ShallowRef, watch} from "vue";
|
import {ref, watch} from "vue";
|
||||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {
|
import {
|
||||||
mdiCircleOpacity,
|
mdiCircleOpacity,
|
||||||
@@ -22,16 +22,18 @@ import {
|
|||||||
mdiRectangle,
|
mdiRectangle,
|
||||||
mdiRectangleOutline,
|
mdiRectangleOutline,
|
||||||
mdiSwapHorizontal,
|
mdiSwapHorizontal,
|
||||||
|
mdiVectorLine,
|
||||||
mdiVectorRectangle
|
mdiVectorRectangle
|
||||||
} from '@mdi/js'
|
} from '@mdi/js'
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
import {SceneMgr} from "../misc/scene";
|
|
||||||
import {BackSide, FrontSide} from "three/src/constants.js";
|
import {BackSide, FrontSide} from "three/src/constants.js";
|
||||||
import {Box3} from "three/src/math/Box3.js";
|
import {Box3} from "three/src/math/Box3.js";
|
||||||
import {Color} from "three/src/math/Color.js";
|
import {Color} from "three/src/math/Color.js";
|
||||||
import {Plane} from "three/src/math/Plane.js";
|
import {Plane} from "three/src/math/Plane.js";
|
||||||
import {Vector3} from "three/src/math/Vector3.js";
|
import {Vector3} from "three/src/math/Vector3.js";
|
||||||
import type {MObject3D} from "../tools/Selection.vue";
|
import type {MObject3D} from "../tools/Selection.vue";
|
||||||
|
import {toLineSegments} from "../misc/lines.js";
|
||||||
|
import {settings} from "../misc/settings.js"
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
meshes: Array<Mesh>,
|
meshes: Array<Mesh>,
|
||||||
@@ -41,25 +43,24 @@ const emit = defineEmits<{ remove: [] }>()
|
|||||||
|
|
||||||
let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah blah blag blah blah blah"
|
let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah blah blag blah blah blah"
|
||||||
|
|
||||||
// Reactive properties
|
// Count the number of faces, edges and vertices
|
||||||
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
|
let faceCount = ref(-1);
|
||||||
const opacity = defineModel<number>("opacity", {default: 1});
|
let edgeCount = ref(-1);
|
||||||
|
let vertexCount = ref(-1);
|
||||||
|
|
||||||
|
// Clipping planes are handled in y-up space (swapped on interface, Z inverted later)
|
||||||
const clipPlaneX = ref(1);
|
const clipPlaneX = ref(1);
|
||||||
const clipPlaneSwappedX = ref(false);
|
const clipPlaneSwappedX = ref(false);
|
||||||
const clipPlaneY = ref(1);
|
const clipPlaneY = ref(1);
|
||||||
const clipPlaneSwappedY = ref(false);
|
const clipPlaneSwappedY = ref(false);
|
||||||
const clipPlaneZ = ref(1);
|
const clipPlaneZ = ref(1);
|
||||||
const clipPlaneSwappedZ = ref(false);
|
const clipPlaneSwappedZ = ref(false);
|
||||||
|
const edgeWidth = ref(settings.edgeWidth);
|
||||||
|
|
||||||
// Count the number of faces, edges and vertices
|
// Misc properties
|
||||||
let faceCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES).length).reduce((a, b) => a + b, 0)
|
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
|
||||||
let edgeCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]).length).reduce((a, b) => a + b, 0)
|
const opacity = defineModel<number>("opacity", {default: 1});
|
||||||
let vertexCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS).length).reduce((a, b) => a + b, 0)
|
const wireframe = ref(false);
|
||||||
|
|
||||||
// Set initial defaults for the enabled features
|
|
||||||
if (faceCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
|
|
||||||
if (edgeCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
|
|
||||||
if (vertexCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
|
|
||||||
|
|
||||||
// Listeners for changes in the properties (or viewer reloads)
|
// Listeners for changes in the properties (or viewer reloads)
|
||||||
function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
||||||
@@ -67,13 +68,10 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
|||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
let sceneModel = (scene as any)?._model;
|
let sceneModel = (scene as any)?._model;
|
||||||
if (!scene || !sceneModel) return;
|
if (!scene || !sceneModel) return;
|
||||||
// Iterate all primitives of the mesh and set their visibility based on the enabled features
|
|
||||||
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
|
|
||||||
// of not actually removing the primitives from the scene graph
|
|
||||||
sceneModel.traverse((child: MObject3D) => {
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
if (child.userData[extrasNameKey] === modelName) {
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
|
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
|
||||||
let childIsEdge = child.type == 'Line' || child.type == 'LineSegments'
|
let childIsEdge = child.type == 'Line' || child.type == 'LineSegments' || child.type == 'LineSegments2'
|
||||||
let childIsVertex = child.type == 'Points'
|
let childIsVertex = child.type == 'Points'
|
||||||
if (childIsFace || childIsEdge || childIsVertex) {
|
if (childIsFace || childIsEdge || childIsVertex) {
|
||||||
let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1);
|
let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1);
|
||||||
@@ -87,16 +85,12 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
|||||||
scene.queueRender()
|
scene.queueRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(enabledFeatures, onEnabledFeaturesChange);
|
watch(enabledFeatures, onEnabledFeaturesChange, {deep: true});
|
||||||
|
|
||||||
function onOpacityChange(newOpacity: number) {
|
function onOpacityChange(newOpacity: number) {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
let sceneModel = (scene as any)?._model;
|
let sceneModel = (scene as any)?._model;
|
||||||
if (!scene || !sceneModel) return;
|
if (!scene || !sceneModel) return;
|
||||||
// Iterate all primitives of the mesh and set their opacity based on the enabled features
|
|
||||||
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
|
|
||||||
// of not actually removing the primitives from the scene graph
|
|
||||||
// console.log('Opacity may have changed', newOpacity)
|
|
||||||
sceneModel.traverse((child: MObject3D) => {
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
if (child.userData[extrasNameKey] === modelName) {
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
if (child.material && child.material.opacity !== newOpacity) {
|
if (child.material && child.material.opacity !== newOpacity) {
|
||||||
@@ -111,7 +105,22 @@ function onOpacityChange(newOpacity: number) {
|
|||||||
|
|
||||||
watch(opacity, onOpacityChange);
|
watch(opacity, onOpacityChange);
|
||||||
|
|
||||||
let {sceneDocument} = inject<{ sceneDocument: ShallowRef<Document> }>('sceneDocument')!!;
|
function onWireframeChange(newWireframe: boolean) {
|
||||||
|
let scene = props.viewer?.scene;
|
||||||
|
let sceneModel = (scene as any)?._model;
|
||||||
|
if (!scene || !sceneModel) return;
|
||||||
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
|
if (child.material && child.material.wireframe !== newWireframe) {
|
||||||
|
child.material.wireframe = newWireframe;
|
||||||
|
child.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scene.queueRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(wireframe, onWireframeChange);
|
||||||
|
|
||||||
function onClipPlanesChange() {
|
function onClipPlanesChange() {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
@@ -125,21 +134,25 @@ function onClipPlanesChange() {
|
|||||||
if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) {
|
if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) {
|
||||||
// Global value for all models, once set it cannot be unset (unknown for other models...)
|
// Global value for all models, once set it cannot be unset (unknown for other models...)
|
||||||
props.viewer.renderer.threeRenderer.localClippingEnabled = true;
|
props.viewer.renderer.threeRenderer.localClippingEnabled = true;
|
||||||
// Due to model-viewer's camera manipulation, the bounding box needs to be transformed
|
// Get the bounding box containing all features of this model
|
||||||
bbox = SceneMgr.getBoundingBox(sceneDocument.value);
|
bbox = new Box3();
|
||||||
bbox.translate(scene.getTarget());
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
|
bbox.expandByObject(child);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
sceneModel.traverse((child: MObject3D) => {
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
if (child.userData[extrasNameKey] === modelName) {
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
if (child.material) {
|
if (child.material) {
|
||||||
if (bbox) {
|
if (bbox?.isEmpty() == false) {
|
||||||
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
|
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
|
||||||
let offsetY = bbox.min.z + clipPlaneY.value * (bbox.max.z - bbox.min.z);
|
let offsetY = bbox.min.y + clipPlaneY.value * (bbox.max.y - bbox.min.y);
|
||||||
let offsetZ = bbox.min.y + clipPlaneZ.value * (bbox.max.y - bbox.min.y);
|
let offsetZ = bbox.min.z + (1 - clipPlaneZ.value) * (bbox.max.z - bbox.min.z);
|
||||||
let planes = [
|
let planes = [
|
||||||
new Plane(new Vector3(-1, 0, 0), offsetX),
|
new Plane(new Vector3(-1, 0, 0), offsetX),
|
||||||
new Plane(new Vector3(0, 0, 1), offsetY),
|
new Plane(new Vector3(0, -1, 0), offsetY),
|
||||||
new Plane(new Vector3(0, -1, 0), offsetZ),
|
new Plane(new Vector3(0, 0, 1), -offsetZ),
|
||||||
];
|
];
|
||||||
if (clipPlaneSwappedX.value) planes[0].negate();
|
if (clipPlaneSwappedX.value) planes[0].negate();
|
||||||
if (clipPlaneSwappedY.value) planes[1].negate();
|
if (clipPlaneSwappedY.value) planes[1].negate();
|
||||||
@@ -165,20 +178,106 @@ watch(clipPlaneZ, onClipPlanesChange);
|
|||||||
watch(clipPlaneSwappedX, onClipPlanesChange);
|
watch(clipPlaneSwappedX, onClipPlanesChange);
|
||||||
watch(clipPlaneSwappedY, onClipPlanesChange);
|
watch(clipPlaneSwappedY, onClipPlanesChange);
|
||||||
watch(clipPlaneSwappedZ, onClipPlanesChange);
|
watch(clipPlaneSwappedZ, onClipPlanesChange);
|
||||||
// Clip planes are also affected by the camera position, so we need to listen to camera changes
|
|
||||||
props.viewer!!.onElemReady((elem) => elem.addEventListener('camera-change', onClipPlanesChange))
|
let edgeWidthChangeCleanup = [] as Array<() => void>;
|
||||||
|
|
||||||
|
function onEdgeWidthChange(newEdgeWidth: number) {
|
||||||
|
let scene = props.viewer?.scene;
|
||||||
|
let sceneModel = (scene as any)?._model;
|
||||||
|
if (!scene || !sceneModel) return;
|
||||||
|
edgeWidthChangeCleanup.forEach((f) => f());
|
||||||
|
edgeWidthChangeCleanup = [];
|
||||||
|
let linesToImprove: Array<MObject3D> = [];
|
||||||
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
|
if (child.type == 'Line' || child.type == 'LineSegments') {
|
||||||
|
// child.material.linewidth = 3; // Not supported in WebGL2
|
||||||
|
// Swap geometry with LineGeometry to support widths
|
||||||
|
// https://threejs.org/examples/?q=line#webgl_lines_fat
|
||||||
|
if (newEdgeWidth > 0) linesToImprove.push(child);
|
||||||
|
}
|
||||||
|
if (child.type == 'Points') {
|
||||||
|
(child.material as any).size = newEdgeWidth > 0 ? newEdgeWidth * 50 : 5;
|
||||||
|
child.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
linesToImprove.forEach(async (line: MObject3D) => {
|
||||||
|
let line2 = await toLineSegments(line.geometry, newEdgeWidth);
|
||||||
|
// Update resolution on resize
|
||||||
|
let resizeListener = (elem: HTMLElement) => {
|
||||||
|
line2.material.resolution.set(elem.clientWidth, elem.clientHeight);
|
||||||
|
line2.material.needsUpdate = true;
|
||||||
|
};
|
||||||
|
props.viewer!!.onElemReady((elem) => {
|
||||||
|
elem.addEventListener('resize', () => resizeListener(elem));
|
||||||
|
resizeListener(elem);
|
||||||
|
});
|
||||||
|
// Copy the transform of the original line
|
||||||
|
line2.position.copy(line.position);
|
||||||
|
line2.computeLineDistances();
|
||||||
|
line2.userData = Object.assign({}, line.userData);
|
||||||
|
line.parent!.add(line2);
|
||||||
|
line.children.forEach((o) => line2.add(o));
|
||||||
|
line.visible = false;
|
||||||
|
line.userData.niceLine = line2;
|
||||||
|
// line.parent!.remove(line); // Keep it for better raycast and selection!
|
||||||
|
line2.userData.noHit = true;
|
||||||
|
edgeWidthChangeCleanup.push(() => {
|
||||||
|
line2.parent!.remove(line2);
|
||||||
|
line.visible = true;
|
||||||
|
props.viewer!!.onElemReady((elem) => {
|
||||||
|
elem.removeEventListener('resize', () => resizeListener(elem));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
scene.queueRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(edgeWidth, onEdgeWidthChange);
|
||||||
|
|
||||||
function onModelLoad() {
|
function onModelLoad() {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
let sceneModel = (scene as any)?._model;
|
let sceneModel = (scene as any)?._model;
|
||||||
if (!scene || !sceneModel) return;
|
if (!scene || !sceneModel) return;
|
||||||
// Iterate all primitives of the mesh and set their visibility based on the enabled features
|
|
||||||
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
|
// Count the number of faces, edges and vertices
|
||||||
// of not actually removing the primitives from the scene graph
|
const isFirstLoad = faceCount.value === -1;
|
||||||
|
faceCount.value = props.meshes
|
||||||
|
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES))
|
||||||
|
.map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1)
|
||||||
|
.reduce((a, b) => a + b, 0)
|
||||||
|
edgeCount.value = props.meshes
|
||||||
|
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]))
|
||||||
|
.map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0)
|
||||||
|
.reduce((a, b) => a + b, 0)
|
||||||
|
vertexCount.value = props.meshes
|
||||||
|
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS))
|
||||||
|
.map(p => (p.getAttribute("POSITION")?.getCount() ?? 0))
|
||||||
|
.reduce((a, b) => a + b, 0)
|
||||||
|
|
||||||
|
// First time: set the enabled features to all provided features
|
||||||
|
if (isFirstLoad) {
|
||||||
|
if (faceCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
|
||||||
|
else if (!enabledFeatures.value.includes(0)) enabledFeatures.value.push(0)
|
||||||
|
if (edgeCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
|
||||||
|
else if (!enabledFeatures.value.includes(1)) enabledFeatures.value.push(1)
|
||||||
|
if (vertexCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
|
||||||
|
else if (!enabledFeatures.value.includes(2)) enabledFeatures.value.push(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add darkened back faces for all face objects to improve cutting planes
|
||||||
let childrenToAdd: Array<MObject3D> = [];
|
let childrenToAdd: Array<MObject3D> = [];
|
||||||
sceneModel.traverse((child: MObject3D) => {
|
sceneModel.traverse((child: MObject3D) => {
|
||||||
|
child.updateMatrixWorld(); // Objects are mostly static, so ensure updated matrices
|
||||||
if (child.userData[extrasNameKey] === modelName) {
|
if (child.userData[extrasNameKey] === modelName) {
|
||||||
if (child.type == 'Mesh' || child.type == 'SkinnedMesh') {
|
if (child.type == 'Mesh' || child.type == 'SkinnedMesh') {
|
||||||
|
// Compute a BVH for faster raycasting (MUCH faster selection)
|
||||||
|
// @ts-ignore
|
||||||
|
child.geometry?.computeBoundsTree({indirect: true}); // indirect to avoid changing index order
|
||||||
|
// TODO: Accelerated raycast for lines and points (https://github.com/gkjohnson/three-mesh-bvh/issues/243)
|
||||||
|
// TODO: ParallelMeshBVHWorker
|
||||||
|
|
||||||
// We could implement cutting planes using the stencil buffer:
|
// We could implement cutting planes using the stencil buffer:
|
||||||
// https://threejs.org/examples/?q=clipping#webgl_clipping_stencil
|
// https://threejs.org/examples/?q=clipping#webgl_clipping_stencil
|
||||||
// But this is buggy for lots of models, so instead we just draw
|
// But this is buggy for lots of models, so instead we just draw
|
||||||
@@ -192,112 +291,126 @@ function onModelLoad() {
|
|||||||
backChild.material = child.material.clone();
|
backChild.material = child.material.clone();
|
||||||
backChild.material.side = BackSide;
|
backChild.material.side = BackSide;
|
||||||
backChild.material.color = new Color(0.25, 0.25, 0.25)
|
backChild.material.color = new Color(0.25, 0.25, 0.25)
|
||||||
|
backChild.userData.noHit = true;
|
||||||
child.userData.backChild = backChild;
|
child.userData.backChild = backChild;
|
||||||
childrenToAdd.push(backChild as MObject3D);
|
childrenToAdd.push(backChild as MObject3D);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// if (child.type == 'Line' || child.type == 'LineSegments') {
|
|
||||||
// child.material.linewidth = 3; // Not supported in WebGL2
|
|
||||||
// If wide lines are really needed, we need https://threejs.org/examples/?q=line#webgl_lines_fat
|
|
||||||
// }
|
|
||||||
if (child.type == 'Points') {
|
|
||||||
(child.material as any).size = 5;
|
|
||||||
child.material.needsUpdate = true;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child));
|
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child));
|
||||||
scene.queueRender()
|
|
||||||
|
|
||||||
// Furthermore...
|
// Furthermore...
|
||||||
// Enabled features may have been reset after a reload
|
// Enabled features may have been reset after a reload
|
||||||
onEnabledFeaturesChange(enabledFeatures.value)
|
onEnabledFeaturesChange(enabledFeatures.value)
|
||||||
// Opacity may have been reset after a reload
|
// Opacity may have been reset after a reload
|
||||||
onOpacityChange(opacity.value)
|
onOpacityChange(opacity.value)
|
||||||
|
// Wireframe may have been reset after a reload
|
||||||
|
onWireframeChange(wireframe.value)
|
||||||
// Clip planes may have been reset after a reload
|
// Clip planes may have been reset after a reload
|
||||||
onClipPlanesChange()
|
onClipPlanesChange()
|
||||||
|
// Edge width may have been reset after a reload
|
||||||
|
onEdgeWidthChange(edgeWidth.value)
|
||||||
|
|
||||||
|
scene.queueRender()
|
||||||
}
|
}
|
||||||
|
|
||||||
// props.viewer.elem may not yet be available, so we need to wait for it
|
// props.viewer.elem may not yet be available, so we need to wait for it
|
||||||
props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
const onViewerReady = (viewer: InstanceType<typeof ModelViewerWrapper>) => {
|
||||||
|
viewer?.onElemReady((elem: HTMLElement) => {
|
||||||
|
elem.addEventListener('before-render', onModelLoad);
|
||||||
|
elem.addEventListener('camera-change', onClipPlanesChange);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) as any, onViewerReady);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<v-expansion-panel :value="modelName">
|
<v-expansion-panel :value="modelName">
|
||||||
<v-expansion-panel-title expand-icon="hide-this-icon" collapse-icon="hide-this-icon">
|
<v-expansion-panel-title collapse-icon="hide-this-icon" expand-icon="hide-this-icon">
|
||||||
<v-btn-toggle v-model="enabledFeatures" multiple @click.stop color="surface-light">
|
<v-btn-toggle v-model="enabledFeatures" color="surface-light" multiple @click.stop>
|
||||||
<v-btn icon>
|
<v-btn icon>
|
||||||
<v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip>
|
<v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiRectangle" :rotate="90"></svg-icon>
|
<svg-icon :path="mdiRectangle" :rotate="90" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon>
|
<v-btn icon>
|
||||||
<v-tooltip activator="parent">Toggle Edges ({{ edgeCount }})</v-tooltip>
|
<v-tooltip activator="parent">Toggle Edges ({{ edgeCount }})</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiRectangleOutline" :rotate="90"></svg-icon>
|
<svg-icon :path="mdiRectangleOutline" :rotate="90" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon>
|
<v-btn icon>
|
||||||
<v-tooltip activator="parent">Toggle Vertices ({{ vertexCount }})</v-tooltip>
|
<v-tooltip activator="parent">Toggle Vertices ({{ vertexCount }})</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiVectorRectangle" :rotate="90"></svg-icon>
|
<svg-icon :path="mdiVectorRectangle" :rotate="90" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-btn-toggle>
|
</v-btn-toggle>
|
||||||
<div class="model-name">{{ modelName }}</div>
|
<div class="model-name">{{ modelName }}</div>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<v-btn icon @click.stop="emit('remove')">
|
<v-btn icon @click.stop="emit('remove')">
|
||||||
<v-tooltip activator="parent">Remove</v-tooltip>
|
<v-tooltip activator="parent">Remove</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiDelete"></svg-icon>
|
<svg-icon :path="mdiDelete" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-expansion-panel-title>
|
</v-expansion-panel-title>
|
||||||
<v-expansion-panel-text>
|
<v-expansion-panel-text>
|
||||||
<v-slider v-model="opacity" hide-details min="0" max="1" :step="0.1">
|
<v-slider v-model="opacity" :step="0.1" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Change opacity</v-tooltip>
|
<v-tooltip activator="parent">Change opacity</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCircleOpacity"></svg-icon>
|
<svg-icon :path="mdiCircleOpacity" type="mdi"></svg-icon>
|
||||||
|
</template>
|
||||||
|
<template v-slot:append>
|
||||||
|
<v-tooltip activator="parent">Wireframe</v-tooltip>
|
||||||
|
<v-checkbox-btn v-model="wireframe" falseIcon="mdi-triangle" trueIcon="mdi-triangle-outline"></v-checkbox-btn>
|
||||||
|
</template>
|
||||||
|
</v-slider>
|
||||||
|
<v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details max="1" min="0">
|
||||||
|
<template v-slot:prepend>
|
||||||
|
<v-tooltip activator="parent">Edge and vertex sizes</v-tooltip>
|
||||||
|
<svg-icon :path="mdiVectorLine" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-divider></v-divider>
|
<v-divider></v-divider>
|
||||||
<v-slider v-model="clipPlaneX" hide-details min="0" max="1">
|
<v-slider v-model="clipPlaneX" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Clip plane X</v-tooltip>
|
<v-tooltip activator="parent">Clip plane X</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCube" :rotate="120"></svg-icon>
|
<svg-icon :path="mdiCube" :rotate="120" type="mdi"></svg-icon>
|
||||||
X
|
X
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Swap clip plane X</v-tooltip>
|
<v-tooltip activator="parent">Swap clip plane X</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
<v-checkbox-btn v-model="clipPlaneSwappedX" falseIcon="mdi-checkbox-blank-outline"
|
||||||
v-model="clipPlaneSwappedX">
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-slider v-model="clipPlaneY" hide-details min="0" max="1">
|
<v-slider v-model="clipPlaneZ" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Clip plane Y</v-tooltip>
|
<v-tooltip activator="parent">Clip plane Y</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCube" :rotate="-120"></svg-icon>
|
<svg-icon :path="mdiCube" :rotate="-120" type="mdi"></svg-icon>
|
||||||
Y
|
Y
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
|
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
<v-checkbox-btn v-model="clipPlaneSwappedZ" falseIcon="mdi-checkbox-blank-outline"
|
||||||
v-model="clipPlaneSwappedY">
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
</v-slider>
|
</v-slider>
|
||||||
<v-slider v-model="clipPlaneZ" hide-details min="0" max="1">
|
<v-slider v-model="clipPlaneY" hide-details max="1" min="0">
|
||||||
<template v-slot:prepend>
|
<template v-slot:prepend>
|
||||||
<v-tooltip activator="parent">Clip plane Z</v-tooltip>
|
<v-tooltip activator="parent">Clip plane Z</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCube"></svg-icon>
|
<svg-icon :path="mdiCube" type="mdi"></svg-icon>
|
||||||
Z
|
Z
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:append>
|
<template v-slot:append>
|
||||||
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
|
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
|
||||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
<v-checkbox-btn v-model="clipPlaneSwappedY" falseIcon="mdi-checkbox-blank-outline"
|
||||||
v-model="clipPlaneSwappedZ">
|
trueIcon="mdi-checkbox-marked-outline">
|
||||||
<template v-slot:label>
|
<template v-slot:label>
|
||||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||||
</template>
|
</template>
|
||||||
</v-checkbox-btn>
|
</v-checkbox-btn>
|
||||||
</template>
|
</template>
|
||||||
@@ -331,11 +444,12 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
|||||||
}
|
}
|
||||||
|
|
||||||
.model-name {
|
.model-name {
|
||||||
width: 130px;
|
width: 179px;
|
||||||
min-height: 1.15em; /* HACK: Avoid eating the bottom of the text when using 1 line */
|
font-size: 110%;
|
||||||
max-height: 2em;
|
overflow-x: clip;
|
||||||
|
overflow-y: visible; /* HACK: bottom of text is lost otherwise (due to buggy -webkit-box bounds?) */
|
||||||
|
word-wrap: break-word;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
overflow: hidden;
|
|
||||||
display: -webkit-box;
|
display: -webkit-box;
|
||||||
-webkit-line-clamp: 2; /* https://caniuse.com/?search=line-clamp */
|
-webkit-line-clamp: 2; /* https://caniuse.com/?search=line-clamp */
|
||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
@@ -358,4 +472,12 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
|||||||
.mdi-checkbox-marked-outline { /* HACK: mdi is not fully imported, only required icons... */
|
.mdi-checkbox-marked-outline { /* HACK: mdi is not fully imported, only required icons... */
|
||||||
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,19H5V5H15V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V11H19M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"/></svg>');
|
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,19H5V5H15V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V11H19M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"/></svg>');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mdi-triangle { /* HACK: mdi is not fully imported, only required icons... */
|
||||||
|
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M1 21h22L12 2"/></svg>');
|
||||||
|
}
|
||||||
|
|
||||||
|
.mdi-triangle-outline { /* HACK: mdi is not fully imported, only required icons... */
|
||||||
|
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M12 2L1 21h22M12 6l7.53 13H4.47"/></svg>');
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {VExpansionPanels} from "vuetify/lib/components/index.mjs";
|
import {VExpansionPanels} from "vuetify/lib/components/index.mjs";
|
||||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {Document, Mesh} from "@gltf-transform/core";
|
import {Document, Mesh} from "@gltf-transform/core";
|
||||||
@@ -36,9 +36,7 @@ function onRemove(mesh: Mesh) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function findModel(name: string) {
|
function findModel(name: string) {
|
||||||
console.log('Find model', name);
|
|
||||||
if (!expandedNames.value.includes(name)) expandedNames.value.push(name);
|
if (!expandedNames.value.includes(name)) expandedNames.value.push(name);
|
||||||
console.log('Expanded', expandedNames.value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({findModel})
|
defineExpose({findModel})
|
||||||
|
|||||||
2
frontend/shims.d.ts
vendored
2
frontend/shims.d.ts
vendored
@@ -1,3 +1,5 @@
|
|||||||
// Avoids typescript error when importing some files
|
// Avoids typescript error when importing some files
|
||||||
declare module '@jamescoyle/vue-icon'
|
declare module '@jamescoyle/vue-icon'
|
||||||
declare module 'three-orientation-gizmo/src/OrientationGizmo'
|
declare module 'three-orientation-gizmo/src/OrientationGizmo'
|
||||||
|
declare module 'three/examples/jsm/libs/draco/draco_decoder.js'
|
||||||
|
declare module 'three/examples/jsm/libs/draco/draco_encoder.js'
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
// License text for all dependencies, only downloaded when/if needed
|
// License text for all dependencies, only downloaded when/if needed
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import licenseText from "../../assets/licenses.txt?raw";
|
import licenseText from "../../assets/licenses.txt?raw";
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {onMounted, onUpdated, ref} from "vue";
|
import {onMounted, onUpdated, ref} from "vue";
|
||||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
||||||
import type {ModelViewerElement} from '@google/model-viewer';
|
|
||||||
|
|
||||||
// Optimized minimal dependencies from three
|
// Optimized minimal dependencies from three
|
||||||
import {Vector3} from "three/src/math/Vector3.js";
|
import {Vector3} from "three/src/math/Vector3.js";
|
||||||
import {Matrix4} from "three/src/math/Matrix4.js";
|
import {Matrix4} from "three/src/math/Matrix4.js";
|
||||||
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
|
|
||||||
globalThis.THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
(globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
||||||
|
|
||||||
const OrientationGizmo = OrientationGizmoRaw.default;
|
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> }>();
|
||||||
|
|
||||||
const props = defineProps<{ elem: ModelViewerElement | null, scene: ModelScene }>();
|
|
||||||
|
|
||||||
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
|
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
|
||||||
// noinspection SpellCheckingInspection
|
// noinspection SpellCheckingInspection
|
||||||
@@ -33,21 +31,26 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
|
|||||||
}
|
}
|
||||||
// Append and listen for events
|
// Append and listen for events
|
||||||
gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => {
|
gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => {
|
||||||
let lookFrom = scene.getCamera().position.clone();
|
if (!props.viewer.elem || !props.viewer.controls) return;
|
||||||
let lookAt = scene.getTarget().clone().add(scene.target.position);
|
// Animate the controls to the new wanted angle
|
||||||
let magnitude = lookFrom.clone().sub(lookAt).length()
|
const controls = props.viewer.controls;
|
||||||
let direction = new Vector3(axis.direction.x, axis.direction.y, axis.direction.z);
|
const {theta: curTheta/*, phi: curPhi*/} = (controls as any).goalSpherical;
|
||||||
let newLookFrom = lookAt.clone().add(direction.clone().multiplyScalar(magnitude));
|
let wantedTheta = NaN;
|
||||||
//console.log("New camera position", newLookFrom)
|
let wantedPhi = NaN;
|
||||||
scene.getCamera().position.copy(newLookFrom);
|
let attempt = 0
|
||||||
scene.getCamera().lookAt(lookAt);
|
while ((attempt == 0 || curTheta == wantedTheta) && attempt < 2) {
|
||||||
if ((scene as any).__perspectiveCamera) { // HACK: Make the hacky ortho also work
|
if (attempt > 0) { // Flip the camera if the user clicks on the same axis
|
||||||
(scene as any).__perspectiveCamera.position.copy(newLookFrom);
|
axis.direction.x = -axis.direction.x;
|
||||||
(scene as any).__perspectiveCamera.lookAt(lookAt);
|
axis.direction.y = -axis.direction.y;
|
||||||
|
axis.direction.z = -axis.direction.z;
|
||||||
|
}
|
||||||
|
wantedTheta = Math.atan2(axis.direction.x, axis.direction.z);
|
||||||
|
wantedPhi = Math.asin(-axis.direction.y) + Math.PI / 2;
|
||||||
|
attempt++;
|
||||||
}
|
}
|
||||||
|
controls.setOrbit(wantedTheta, wantedPhi);
|
||||||
|
props.viewer.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}))
|
||||||
scene.queueRender();
|
scene.queueRender();
|
||||||
requestIdleCallback(() => props.elem?.dispatchEvent(
|
|
||||||
new CustomEvent('camera-change', {detail: {source: 'none'}})))
|
|
||||||
}
|
}
|
||||||
return gizmo;
|
return gizmo;
|
||||||
}
|
}
|
||||||
@@ -60,16 +63,16 @@ let gizmo: HTMLElement & { update: () => void }
|
|||||||
function updateGizmo() {
|
function updateGizmo() {
|
||||||
if (gizmo.isConnected) {
|
if (gizmo.isConnected) {
|
||||||
gizmo.update();
|
gizmo.update();
|
||||||
requestIdleCallback(updateGizmo);
|
requestIdleCallback(updateGizmo, {timeout: 250});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let reinstall = () => {
|
let reinstall = () => {
|
||||||
if(!container.value) return;
|
if (!container.value) return;
|
||||||
if (gizmo) container.value.removeChild(gizmo);
|
if (gizmo) container.value.removeChild(gizmo);
|
||||||
gizmo = createGizmo(container.value, props.scene as ModelScene) as typeof gizmo;
|
gizmo = createGizmo(container.value, props.viewer.scene!! as any) as typeof gizmo;
|
||||||
container.value.appendChild(gizmo);
|
container.value.appendChild(gizmo);
|
||||||
requestIdleCallback(updateGizmo); // Low priority updates
|
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
|
||||||
}
|
}
|
||||||
onMounted(reinstall)
|
onMounted(reinstall)
|
||||||
onUpdated(reinstall);
|
onUpdated(reinstall);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {defineModel, inject, ref, type ShallowRef, watch} from "vue";
|
import {defineModel, inject, ref, type ShallowRef, watch} from "vue";
|
||||||
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
|
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
@@ -6,44 +6,50 @@ import type {ModelViewerElement} from '@google/model-viewer';
|
|||||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
|
import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
|
||||||
import type {Intersection, Material, Mesh, Object3D} from "three";
|
import type {Intersection, Material, Mesh, Object3D} from "three";
|
||||||
import {Box3, Matrix4, Raycaster, Vector3} from "three";
|
import {Box3, Color, Raycaster, Vector3} from "three";
|
||||||
import type ModelViewerWrapperT from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapperT from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {extrasNameKey} from "../misc/gltf";
|
import {extrasNameKey} from "../misc/gltf";
|
||||||
import {SceneMgr} from "../misc/scene";
|
import {SceneMgr} from "../misc/scene";
|
||||||
import {Document} from "@gltf-transform/core";
|
import {Document} from "@gltf-transform/core";
|
||||||
import {AxesColors} from "../misc/helpers";
|
import {AxesColors} from "../misc/helpers";
|
||||||
import {distances} from "../misc/distances";
|
import {distances} from "../misc/distances";
|
||||||
|
import {highlight, highlightUndo, hitToSelectionInfo, type SelectionInfo} from "./selection";
|
||||||
|
|
||||||
export type MObject3D = Mesh & {
|
export type MObject3D = Mesh & {
|
||||||
userData: { noHit?: boolean },
|
userData: { noHit?: boolean },
|
||||||
material: Material & { color: { r: number, g: number, b: number }, __prevBaseColorFactor?: [number, number, number] }
|
material: Material & {
|
||||||
|
color: Color,
|
||||||
|
wireframe?: boolean
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
|
let props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
|
||||||
let emit = defineEmits<{ findModel: [string] }>();
|
let emit = defineEmits<{ findModel: [string] }>();
|
||||||
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
||||||
let selectionEnabled = ref(false);
|
let selectionEnabled = ref(false);
|
||||||
let selected = defineModel<Array<Intersection<MObject3D>>>({default: []});
|
let selected = defineModel<Array<SelectionInfo>>({default: []});
|
||||||
let highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
let highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
||||||
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
||||||
let showDistances = ref<Boolean>(true);
|
let showDistances = ref<Boolean>(true);
|
||||||
|
|
||||||
let mouseDownAt: [number, number] | null = null;
|
let mouseDownAt: [number, number] | null = null;
|
||||||
|
let mouseDownTime = 0;
|
||||||
let selectFilter = ref('Any (S)');
|
let selectFilter = ref('Any (S)');
|
||||||
const raycaster = new Raycaster();
|
const raycaster = new Raycaster();
|
||||||
|
|
||||||
|
|
||||||
let selectionMoveListener = (event: MouseEvent) => {
|
let mouseDownListener = (event: MouseEvent) => {
|
||||||
mouseDownAt = [event.clientX, event.clientY];
|
mouseDownAt = [event.clientX, event.clientY];
|
||||||
|
mouseDownTime = performance.now();
|
||||||
if (!selectionEnabled.value) return;
|
if (!selectionEnabled.value) return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let selectionListener = (event: MouseEvent) => {
|
let mouseUpListener = (event: MouseEvent) => {
|
||||||
// If the mouse moved while clicked (dragging), avoid selection logic
|
// If the mouse moved while clicked (dragging), avoid selection logic
|
||||||
if (mouseDownAt) {
|
if (mouseDownAt) {
|
||||||
let [x, y] = mouseDownAt;
|
let [x, y] = mouseDownAt;
|
||||||
mouseDownAt = null;
|
mouseDownAt = null;
|
||||||
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5) {
|
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5 || performance.now() - mouseDownTime > 500) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,15 +60,22 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Set raycaster parameters
|
// Set raycaster parameters
|
||||||
|
let paramScale = 1; // Make it easier to select vertices/edges based on camera distance
|
||||||
|
if (props.viewer?.scene) {
|
||||||
|
let scene = props.viewer.scene;
|
||||||
|
let lookAtCenter = scene.getTarget().clone().add(scene.target.position);
|
||||||
|
paramScale = scene.camera.position.distanceTo(lookAtCenter) / 150;
|
||||||
|
// console.log('paramScale', paramScale)
|
||||||
|
}
|
||||||
if (selectFilter.value === 'Any (S)') {
|
if (selectFilter.value === 'Any (S)') {
|
||||||
raycaster.params.Line.threshold = 0.2;
|
raycaster.params.Line.threshold = paramScale;
|
||||||
raycaster.params.Points.threshold = 0.8;
|
raycaster.params.Points.threshold = paramScale * 2; // Make vertices easier to select than edges
|
||||||
} else if (selectFilter.value === '(E)dges') {
|
} else if (selectFilter.value === '(E)dges') {
|
||||||
raycaster.params.Line.threshold = 0.8;
|
raycaster.params.Line.threshold = paramScale;
|
||||||
raycaster.params.Points.threshold = 0.0;
|
raycaster.params.Points.threshold = 0.0;
|
||||||
} else if (selectFilter.value === '(V)ertices') {
|
} else if (selectFilter.value === '(V)ertices') {
|
||||||
raycaster.params.Line.threshold = 0.0;
|
raycaster.params.Line.threshold = 0.0;
|
||||||
raycaster.params.Points.threshold = 0.8;
|
raycaster.params.Points.threshold = paramScale;
|
||||||
} else if (selectFilter.value === '(F)aces') {
|
} else if (selectFilter.value === '(F)aces') {
|
||||||
raycaster.params.Line.threshold = 0.0;
|
raycaster.params.Line.threshold = 0.0;
|
||||||
raycaster.params.Points.threshold = 0.0;
|
raycaster.params.Points.threshold = 0.0;
|
||||||
@@ -74,7 +87,7 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
const ndcCoords = scene.getNDC(event.clientX, event.clientY);
|
const ndcCoords = scene.getNDC(event.clientX, event.clientY);
|
||||||
raycaster.setFromCamera(ndcCoords, scene.camera);
|
raycaster.setFromCamera(ndcCoords, scene.camera);
|
||||||
if (!scene.camera.isPerspectiveCamera) {
|
if (!scene.camera.isPerspectiveCamera) {
|
||||||
// Need to fix the ray direction for ortho camera FIXME: Still buggy...
|
// Need to fix the ray direction for ortho camera FIXME: Still buggy for off-center clicks
|
||||||
raycaster.ray.direction.copy(scene.camera.getWorldDirection(new Vector3()));
|
raycaster.ray.direction.copy(scene.camera.getWorldDirection(new Vector3()));
|
||||||
}
|
}
|
||||||
//console.log('Ray', raycaster.ray);
|
//console.log('Ray', raycaster.ray);
|
||||||
@@ -85,31 +98,69 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
|
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
|
||||||
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
|
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
|
||||||
|
|
||||||
// Find all hit objects and select the wanted one based on the filter
|
// Find all hit objects and raycast the wanted ones based on the filter
|
||||||
const hits = raycaster.intersectObject(scene, true);
|
let objects: Array<any> = [];
|
||||||
let hit = hits.find((hit: Intersection<Object3D>) => {
|
scene.traverse((obj) => {
|
||||||
if (!hit.object) return false;
|
const kind = obj.type
|
||||||
const kind = hit.object.type
|
|
||||||
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
|
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
|
||||||
let isEdge = kind === 'Line' || kind === 'LineSegments';
|
let isEdge = kind === 'Line' || kind === 'LineSegments';
|
||||||
let isVertex = kind === 'Points';
|
let isVertex = kind === 'Points';
|
||||||
const kindOk = (selectFilter.value === 'Any (S)') ||
|
if (obj.userData.noHit !== true &&
|
||||||
(isFace && selectFilter.value === '(F)aces') ||
|
((selectFilter.value === 'Any (S)' && (isFace || isEdge || isVertex)) ||
|
||||||
(isEdge && selectFilter.value === '(E)dges') ||
|
(selectFilter.value === '(F)aces' && isFace) ||
|
||||||
(isVertex && selectFilter.value === '(V)ertices');
|
(selectFilter.value === '(E)dges' && isEdge) ||
|
||||||
return hit.object.visible && !hit.object.userData.noHit && kindOk;
|
(selectFilter.value === '(V)ertices' && isVertex))) {
|
||||||
}) as Intersection<MObject3D> | undefined;
|
objects.push(obj);
|
||||||
//console.log('Hit', hit)
|
}
|
||||||
|
});
|
||||||
|
//console.log("Raycasting objects", objects)
|
||||||
|
|
||||||
|
// Run the raycaster on the selected objects only searching for the first hit
|
||||||
|
// @ts-ignore
|
||||||
|
raycaster.firstHitOnly = true;
|
||||||
|
const hits = raycaster.intersectObjects(objects, false);
|
||||||
|
let hit = hits
|
||||||
|
// Check feasibility
|
||||||
|
.filter((hit: Intersection<Object3D>) => {
|
||||||
|
if (!hit.object) return false;
|
||||||
|
const kind = hit.object.type
|
||||||
|
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
|
||||||
|
let isEdge = kind === 'Line' || kind === 'LineSegments';
|
||||||
|
let isVertex = kind === 'Points';
|
||||||
|
const kindOk = (selectFilter.value === 'Any (S)') ||
|
||||||
|
(isFace && selectFilter.value === '(F)aces') ||
|
||||||
|
(isEdge && selectFilter.value === '(E)dges') ||
|
||||||
|
(isVertex && selectFilter.value === '(V)ertices');
|
||||||
|
return (!isFace || hit.object.visible) && kindOk;
|
||||||
|
})
|
||||||
|
// Sort for highlighting partially hidden edges/vertices
|
||||||
|
.sort((a, b) => {
|
||||||
|
function lowerIsBetter(hit: Intersection<Object3D>) {
|
||||||
|
let score = hit.distance;
|
||||||
|
// Faces are easier to hit than 0-width edges/vertices, so we need to adjust scores
|
||||||
|
if (hit.object.type === 'Mesh' || hit.object.type === 'SkinnedMesh') score += paramScale;
|
||||||
|
// Edges are easier to hit than vertices, so we need to adjust scores
|
||||||
|
if (hit.object.type === 'Line' || hit.object.type === 'LineSegments') score += paramScale / 2;
|
||||||
|
return score;
|
||||||
|
}
|
||||||
|
|
||||||
|
return lowerIsBetter(a) - lowerIsBetter(b);
|
||||||
|
})
|
||||||
|
// Return the best hit
|
||||||
|
[0] as Intersection<MObject3D> | undefined;
|
||||||
|
|
||||||
if (!highlightNextSelection.value[0]) {
|
if (!highlightNextSelection.value[0]) {
|
||||||
// If we are selecting, toggle the selection or deselect all if no hit
|
// If we are selecting, toggle the selection or deselect all if no hit
|
||||||
if (hit) {
|
let selInfo: SelectionInfo | null = null;
|
||||||
|
if (hit) selInfo = hitToSelectionInfo(hit);
|
||||||
|
//console.log('Hit', hit, 'SelInfo', selInfo);
|
||||||
|
if (hit && selInfo !== null) {
|
||||||
// Toggle selection
|
// Toggle selection
|
||||||
const wasSelected = selected.value.find((m) => m.object.name === hit?.object?.name) !== undefined;
|
const wasSelected = selected.value.find((m) => m.getKey() === selInfo.getKey()) !== undefined;
|
||||||
if (wasSelected) {
|
if (wasSelected) {
|
||||||
deselect(hit)
|
deselect(selInfo)
|
||||||
} else {
|
} else {
|
||||||
select(hit)
|
select(selInfo)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
deselectAll();
|
deselectAll();
|
||||||
@@ -125,34 +176,22 @@ let selectionListener = (event: MouseEvent) => {
|
|||||||
scene.queueRender() // Force rerender of model-viewer
|
scene.queueRender() // Force rerender of model-viewer
|
||||||
}
|
}
|
||||||
|
|
||||||
function select(hit: Intersection<MObject3D>) {
|
function select(selInfo: SelectionInfo) {
|
||||||
console.log('Selecting', hit.object.name)
|
// console.log('Selecting', selInfo.object.name)
|
||||||
if (selected.value.find((m) => m.object.name === hit.object.name) === undefined) {
|
if (selected.value.find((m) => m.getKey() === selInfo.getKey()) === undefined) {
|
||||||
selected.value.push(hit);
|
selected.value.push(selInfo);
|
||||||
}
|
}
|
||||||
hit.object.material.__prevBaseColorFactor = [
|
highlight(selInfo);
|
||||||
hit.object.material.color.r,
|
|
||||||
hit.object.material.color.g,
|
|
||||||
hit.object.material.color.b,
|
|
||||||
];
|
|
||||||
hit.object.material.color.r = 1;
|
|
||||||
hit.object.material.color.g = 0;
|
|
||||||
hit.object.material.color.b = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) {
|
function deselect(selInfo: SelectionInfo, alsoRemove = true) {
|
||||||
console.log('Deselecting', hit.object.name)
|
// console.log('Deselecting', selInfo.object.name)
|
||||||
if (alsoRemove) {
|
if (alsoRemove) {
|
||||||
// Remove the matching object from the selection
|
// Remove the matching object from the selection
|
||||||
let toRemove = selected.value.findIndex((m) => m.object.name === hit.object.name);
|
let toRemove = selected.value.findIndex((m) => m.getKey() === selInfo.getKey());
|
||||||
selected.value.splice(toRemove, 1);
|
selected.value.splice(toRemove, 1);
|
||||||
}
|
}
|
||||||
if (hit.object.material.__prevBaseColorFactor) {
|
highlightUndo(selInfo);
|
||||||
hit.object.material.color.r = hit.object.material.__prevBaseColorFactor[0]
|
|
||||||
hit.object.material.color.g = hit.object.material.__prevBaseColorFactor[1]
|
|
||||||
hit.object.material.color.b = hit.object.material.__prevBaseColorFactor[2]
|
|
||||||
delete hit.object.material.__prevBaseColorFactor;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function deselectAll(alsoRemove = true) {
|
function deselectAll(alsoRemove = true) {
|
||||||
@@ -217,14 +256,29 @@ let onViewerReady = (viewer: typeof ModelViewerWrapperT) => {
|
|||||||
viewer.onElemReady((elem: ModelViewerElement) => {
|
viewer.onElemReady((elem: ModelViewerElement) => {
|
||||||
if (hasListeners) return;
|
if (hasListeners) return;
|
||||||
hasListeners = true;
|
hasListeners = true;
|
||||||
elem.addEventListener('mouseup', selectionListener);
|
elem.addEventListener('mousedown', mouseDownListener); // Avoid clicking when dragging
|
||||||
elem.addEventListener('mousedown', selectionMoveListener); // Avoid clicking when dragging
|
elem.addEventListener('mouseup', mouseUpListener);
|
||||||
elem.addEventListener('load', () => {
|
elem.addEventListener('before-render', () => {
|
||||||
|
// After a reload of the scene, we need to recover object references and highlight them again
|
||||||
|
for (let sel of selected.value) {
|
||||||
|
let scene = props.viewer?.scene;
|
||||||
|
if (!scene) continue;
|
||||||
|
let foundObject = null;
|
||||||
|
scene.traverse((obj: MObject3D) => {
|
||||||
|
if (sel.matches(obj)) {
|
||||||
|
foundObject = obj as MObject3D;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (foundObject) {
|
||||||
|
sel.object = foundObject;
|
||||||
|
highlight(sel);
|
||||||
|
} else {
|
||||||
|
selected.value = selected.value.filter((m) => m.getKey() !== sel.getKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
if (firstLoad) {
|
if (firstLoad) {
|
||||||
toggleShowBoundingBox();
|
toggleShowBoundingBox();
|
||||||
firstLoad = false;
|
firstLoad = false;
|
||||||
} else {
|
|
||||||
updateBoundingBox();
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
elem.addEventListener('camera-change', onCameraChange);
|
elem.addEventListener('camera-change', onCameraChange);
|
||||||
@@ -249,11 +303,12 @@ function updateBoundingBox() {
|
|||||||
if (selected.value.length > 0) {
|
if (selected.value.length > 0) {
|
||||||
bb = new Box3();
|
bb = new Box3();
|
||||||
for (let hit of selected.value) {
|
for (let hit of selected.value) {
|
||||||
bb.expandByObject(hit.object);
|
bb.union(hit.getBox())
|
||||||
}
|
}
|
||||||
bb.applyMatrix4(new Matrix4().makeTranslation(props.viewer?.scene.getTarget()));
|
|
||||||
} else {
|
} 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
|
// Define each edge of the bounding box, to draw a line for each axis
|
||||||
let corners = [
|
let corners = [
|
||||||
@@ -293,6 +348,8 @@ function updateBoundingBox() {
|
|||||||
}
|
}
|
||||||
let from = new Vector3(...corners[edge[0]]);
|
let from = new Vector3(...corners[edge[0]]);
|
||||||
let to = new Vector3(...corners[edge[1]]);
|
let to = new Vector3(...corners[edge[1]]);
|
||||||
|
let length = to.clone().sub(from).length();
|
||||||
|
if (length < 0.05) continue; // Skip very small edges (e.g. a single point)
|
||||||
let color = [AxesColors.x, AxesColors.y, AxesColors.z][edgeI][1]; // Secondary colors
|
let color = [AxesColors.x, AxesColors.y, AxesColors.z][edgeI][1]; // Secondary colors
|
||||||
let lineCacheKey = JSON.stringify([from, to]);
|
let lineCacheKey = JSON.stringify([from, to]);
|
||||||
let matchingLine = boundingBoxLines[lineCacheKey];
|
let matchingLine = boundingBoxLines[lineCacheKey];
|
||||||
@@ -300,7 +357,7 @@ function updateBoundingBox() {
|
|||||||
boundingBoxLinesToRemove = boundingBoxLinesToRemove.filter((l) => l !== lineCacheKey);
|
boundingBoxLinesToRemove = boundingBoxLinesToRemove.filter((l) => l !== lineCacheKey);
|
||||||
} else {
|
} else {
|
||||||
let newLineId = props.viewer?.addLine3D(from, to,
|
let newLineId = props.viewer?.addLine3D(from, to,
|
||||||
to.clone().sub(from).length().toFixed(1) + "mm", {
|
length.toFixed(1) + "mm", {
|
||||||
"stroke": "rgb(" + color.join(',') + ")",
|
"stroke": "rgb(" + color.join(',') + ")",
|
||||||
"stroke-width": "2"
|
"stroke-width": "2"
|
||||||
});
|
});
|
||||||
@@ -337,7 +394,7 @@ function updateDistances() {
|
|||||||
let distanceLinesToRemove = Object.keys(distanceLines);
|
let distanceLinesToRemove = Object.keys(distanceLines);
|
||||||
|
|
||||||
function ensureLine(from: Vector3, to: Vector3, text: string, color: string) {
|
function ensureLine(from: Vector3, to: Vector3, text: string, color: string) {
|
||||||
console.log('ensureLine', from, to, text, color)
|
// console.log('ensureLine', from, to, text, color)
|
||||||
let lineCacheKey = JSON.stringify([from, to]);
|
let lineCacheKey = JSON.stringify([from, to]);
|
||||||
let matchingLine = distanceLines[lineCacheKey];
|
let matchingLine = distanceLines[lineCacheKey];
|
||||||
if (matchingLine) {
|
if (matchingLine) {
|
||||||
@@ -352,9 +409,7 @@ function updateDistances() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Add lines (if not already added)
|
// Add lines (if not already added)
|
||||||
let objA = selected.value[0].object;
|
let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer?.scene);
|
||||||
let objB = selected.value[1].object;
|
|
||||||
let {min, center, max} = distances(objA, objB, props.viewer?.scene);
|
|
||||||
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
|
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
|
||||||
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
|
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
|
||||||
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
|
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
|
||||||
@@ -368,6 +423,8 @@ function updateDistances() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({deselect, updateBoundingBox, updateDistances});
|
||||||
|
|
||||||
// Add keyboard shortcuts
|
// Add keyboard shortcuts
|
||||||
window.addEventListener('keydown', (event) => {
|
window.addEventListener('keydown', (event) => {
|
||||||
if (event.key === 's') {
|
if (event.key === 's') {
|
||||||
@@ -405,52 +462,41 @@ window.addEventListener('keydown', (event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="select-parent">
|
<v-btn :color="selectionEnabled ? 'surface-light' : ''" icon @click="toggleSelection">
|
||||||
<v-btn icon @click="toggleSelection" :color="selectionEnabled ? 'surface-light' : ''">
|
<v-tooltip activator="parent">{{ selectionEnabled ? 'Disable (s)election mode' : 'Enable (s)election mode' }}
|
||||||
<v-tooltip activator="parent">{{ selectionEnabled ? 'Disable (s)election mode' : 'Enable (s)election mode' }}
|
|
||||||
</v-tooltip>
|
|
||||||
<svg-icon type="mdi" :path="mdiCursorDefaultClick"/>
|
|
||||||
</v-btn>
|
|
||||||
<v-tooltip :text="'Select only ' + selectFilter.toString().toLocaleLowerCase()" :open-on-click="false">
|
|
||||||
<template v-slot:activator="{ props }">
|
|
||||||
<v-select v-bind="props" class="select-only" variant="underlined"
|
|
||||||
:items="['Any (S)', '(F)aces', '(E)dges', '(V)ertices']"
|
|
||||||
v-model="selectFilter"/>
|
|
||||||
</template>
|
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
</div>
|
<svg-icon :path="mdiCursorDefaultClick" type="mdi"/>
|
||||||
<v-btn icon @click="toggleHighlightNextSelection" :color="highlightNextSelection[0] ? 'surface-light' : ''">
|
|
||||||
<v-tooltip activator="parent">(H)ighlight the next clicked element in the models list</v-tooltip>
|
|
||||||
<svg-icon type="mdi" :path="mdiFeatureSearch"/>
|
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="toggleShowBoundingBox" :color="showBoundingBox ? 'surface-light' : ''">
|
<v-tooltip :open-on-click="false" :text="'Select only ' + selectFilter.toString().toLocaleLowerCase()">
|
||||||
|
<template v-slot:activator="{ props }">
|
||||||
|
<v-select v-model="selectFilter" :items="['Any (S)', '(F)aces', '(E)dges', '(V)ertices']" class="select-only"
|
||||||
|
v-bind="props"
|
||||||
|
variant="underlined"/>
|
||||||
|
</template>
|
||||||
|
</v-tooltip>
|
||||||
|
<v-btn :color="highlightNextSelection[0] ? 'surface-light' : ''" icon @click="toggleHighlightNextSelection">
|
||||||
|
<v-tooltip activator="parent">(H)ighlight the next clicked element in the models list</v-tooltip>
|
||||||
|
<svg-icon :path="mdiFeatureSearch" type="mdi"/>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn :color="showBoundingBox ? 'surface-light' : ''" icon @click="toggleShowBoundingBox">
|
||||||
<v-tooltip activator="parent">{{ showBoundingBox ? 'Hide selection (b)ounds' : 'Show selection (b)ounds' }}
|
<v-tooltip activator="parent">{{ showBoundingBox ? 'Hide selection (b)ounds' : 'Show selection (b)ounds' }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCubeOutline"/>
|
<svg-icon :path="mdiCubeOutline" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="toggleShowDistances" :color="showDistances ? 'surface-light' : ''">
|
<v-btn :color="showDistances ? 'surface-light' : ''" icon @click="toggleShowDistances">
|
||||||
<v-tooltip activator="parent">
|
<v-tooltip activator="parent">
|
||||||
{{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }}
|
{{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }}
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiRuler"/>
|
<svg-icon :path="mdiRuler" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
/* Very hacky styling... */
|
|
||||||
.select-parent {
|
|
||||||
height: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-parent .v-btn {
|
|
||||||
position: relative;
|
|
||||||
top: -42px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.select-only {
|
.select-only {
|
||||||
display: inline-block;
|
float: right;
|
||||||
width: calc(100% - 48px);
|
height: 36px;
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -12px;
|
top: -12px;
|
||||||
|
width: calc(100% - 48px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {
|
import {
|
||||||
VBtn,
|
VBtn,
|
||||||
VCard,
|
VCard,
|
||||||
@@ -16,11 +16,10 @@ import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
|
|||||||
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
||||||
import SvgIcon from '@jamescoyle/vue-icon';
|
import SvgIcon from '@jamescoyle/vue-icon';
|
||||||
import type {ModelViewerElement} from '@google/model-viewer';
|
import type {ModelViewerElement} from '@google/model-viewer';
|
||||||
import type {Intersection} from "three";
|
|
||||||
import type {MObject3D} from "./Selection.vue";
|
|
||||||
import Loading from "../misc/Loading.vue";
|
import Loading from "../misc/Loading.vue";
|
||||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||||
import {defineAsyncComponent, type Ref, ref} from "vue";
|
import {defineAsyncComponent, ref, type Ref} from "vue";
|
||||||
|
import type {SelectionInfo} from "./selection";
|
||||||
|
|
||||||
const SelectionComponent = defineAsyncComponent({
|
const SelectionComponent = defineAsyncComponent({
|
||||||
loader: () => import("./Selection.vue"),
|
loader: () => import("./Selection.vue"),
|
||||||
@@ -39,10 +38,10 @@ const LicensesDialogContent = defineAsyncComponent({
|
|||||||
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||||
const emit = defineEmits<{ findModel: [string] }>()
|
const emit = defineEmits<{ findModel: [string] }>()
|
||||||
|
|
||||||
let selection: Ref<Array<Intersection<MObject3D>>> = ref([]);
|
let selection: Ref<Array<SelectionInfo>> = ref([]);
|
||||||
let selectionFaceCount = () => selection.value.filter((s) => s.object.type == "Mesh" || s.object.type == "SkinnedMesh").length
|
let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length
|
||||||
let selectionEdgeCount = () => selection.value.filter((s) => s.object.type == "Line").length
|
let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length
|
||||||
let selectionVertexCount = () => selection.value.filter((s) => s.object.type == "Points").length
|
let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length
|
||||||
|
|
||||||
function syncOrthoCamera(force: boolean) {
|
function syncOrthoCamera(force: boolean) {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
@@ -50,19 +49,21 @@ function syncOrthoCamera(force: boolean) {
|
|||||||
let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera;
|
let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera;
|
||||||
if (force || perspectiveCam && scene.camera != perspectiveCam) {
|
if (force || perspectiveCam && scene.camera != perspectiveCam) {
|
||||||
// Get zoom level from perspective camera
|
// Get zoom level from perspective camera
|
||||||
let dist = scene.getTarget().distanceToSquared(perspectiveCam.position);
|
let lookAtCenter = scene.getTarget().clone().add(scene.target.position);
|
||||||
let w = scene.aspect * dist ** 1.1 / 4000;
|
let perspectiveWidthAtCenter =
|
||||||
let h = dist ** 1.1 / 4000;
|
2 * Math.tan(perspectiveCam.fov * Math.PI / 180 / 2) * perspectiveCam.position.distanceTo(lookAtCenter);
|
||||||
|
let w = perspectiveWidthAtCenter;
|
||||||
|
let h = perspectiveWidthAtCenter / scene.aspect;
|
||||||
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
|
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
|
||||||
scene.camera.position.copy(perspectiveCam.position);
|
scene.camera.position.copy(perspectiveCam.position);
|
||||||
scene.camera.lookAt(scene.getTarget().clone().add(scene.target.position));
|
scene.camera.rotation.copy(perspectiveCam.rotation);
|
||||||
if (force) scene.queueRender() // Force rerender of model-viewer
|
if (force) scene.queueRender() // Force rerender of model-viewer
|
||||||
requestAnimationFrame(() => syncOrthoCamera(false));
|
requestAnimationFrame(() => syncOrthoCamera(false));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
||||||
function toggleProjection() {
|
async function toggleProjection() {
|
||||||
let scene = props.viewer?.scene;
|
let scene = props.viewer?.scene;
|
||||||
if (!scene) return;
|
if (!scene) return;
|
||||||
let prevCam = scene.camera;
|
let prevCam = scene.camera;
|
||||||
@@ -77,16 +78,16 @@ function toggleProjection() {
|
|||||||
scene.queueRender() // Force rerender of model-viewer
|
scene.queueRender() // Force rerender of model-viewer
|
||||||
}
|
}
|
||||||
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
||||||
// The camera change may take a few frames to take effect, dispatch the event after a delay
|
// The camera change may take a frame to take effect, dispatch the event after a delay
|
||||||
requestIdleCallback(() => props.viewer?.elem?.dispatchEvent(
|
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||||
new CustomEvent('camera-change', {detail: {source: 'none'}})))
|
props.viewer?.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function centerCamera() {
|
async function centerCamera() {
|
||||||
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
|
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
|
||||||
if (!viewerEl) return;
|
if (!viewerEl) return;
|
||||||
await viewerEl.updateFraming();
|
props.viewer?.scene?.setTarget(0, 0, 0); // Center the target
|
||||||
viewerEl.zoom(3);
|
viewerEl.zoom(-1000000); // Max zoom out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -105,6 +106,15 @@ async function openGithub() {
|
|||||||
window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank')
|
window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeObjectSelections(objName: string) {
|
||||||
|
for (let selInfo of selection.value.filter((s) => s.getObjectName() === objName)) {
|
||||||
|
selectionComp.value?.deselect(selInfo);
|
||||||
|
}
|
||||||
|
selectionComp.value?.updateBoundingBox();
|
||||||
|
selectionComp.value?.updateDistances();
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({removeObjectSelections});
|
||||||
|
|
||||||
// Add keyboard shortcuts
|
// Add keyboard shortcuts
|
||||||
window.addEventListener('keydown', (event) => {
|
window.addEventListener('keydown', (event) => {
|
||||||
@@ -116,35 +126,35 @@ window.addEventListener('keydown', (event) => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<orientation-gizmo :scene="props.viewer.scene as any" :elem="props.viewer.elem" v-if="props.viewer?.scene"/>
|
<orientation-gizmo v-if="props.viewer?.scene" :viewer="props.viewer"/>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<h5>Camera</h5>
|
<h5>Camera</h5>
|
||||||
<v-btn icon @click="toggleProjection"><span class="icon-detail">{{ toggleProjectionText }}</span>
|
<v-btn icon @click="toggleProjection"><span class="icon-detail">{{ toggleProjectionText }}</span>
|
||||||
<v-tooltip activator="parent">Toggle (P)rojection<br/>(currently
|
<v-tooltip activator="parent">Toggle (P)rojection<br/>(currently
|
||||||
{{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }})
|
{{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }})
|
||||||
</v-tooltip>
|
</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiProjector"></svg-icon>
|
<svg-icon :path="mdiProjector" type="mdi"></svg-icon>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-btn icon @click="centerCamera">
|
<v-btn icon @click="centerCamera">
|
||||||
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiCrosshairsGps"/>
|
<svg-icon :path="mdiCrosshairsGps" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
|
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
|
||||||
<selection-component :ref="selectionComp as any" :viewer="props.viewer as any" v-model="selection"
|
<selection-component ref="selectionComp" v-model="selection" :viewer="props.viewer as any"
|
||||||
@findModel="(name) => emit('findModel', name)"/>
|
@findModel="(name) => emit('findModel', name)"/>
|
||||||
<v-divider/>
|
<v-divider/>
|
||||||
<v-spacer></v-spacer>
|
<v-spacer></v-spacer>
|
||||||
<h5>Extras</h5>
|
<h5>Extras</h5>
|
||||||
<v-btn icon @click="downloadSceneGlb">
|
<v-btn icon @click="downloadSceneGlb">
|
||||||
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
|
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiDownload"/>
|
<svg-icon :path="mdiDownload" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-dialog id="licenses-dialog" fullscreen>
|
<v-dialog id="licenses-dialog" fullscreen>
|
||||||
<template v-slot:activator="{ props }">
|
<template v-slot:activator="{ props }">
|
||||||
<v-btn icon v-bind="props">
|
<v-btn icon v-bind="props">
|
||||||
<v-tooltip activator="parent">Show Licenses</v-tooltip>
|
<v-tooltip activator="parent">Show Licenses</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiLicense"/>
|
<svg-icon :path="mdiLicense" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</template>
|
</template>
|
||||||
<template v-slot:default="{ isActive }">
|
<template v-slot:default="{ isActive }">
|
||||||
@@ -154,7 +164,7 @@ window.addEventListener('keydown', (event) => {
|
|||||||
<v-spacer>
|
<v-spacer>
|
||||||
</v-spacer>
|
</v-spacer>
|
||||||
<v-btn icon @click="isActive.value = false">
|
<v-btn icon @click="isActive.value = false">
|
||||||
<svg-icon type="mdi" :path="mdiClose"/>
|
<svg-icon :path="mdiClose" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-toolbar>
|
</v-toolbar>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
@@ -165,7 +175,7 @@ window.addEventListener('keydown', (event) => {
|
|||||||
</v-dialog>
|
</v-dialog>
|
||||||
<v-btn icon @click="openGithub">
|
<v-btn icon @click="openGithub">
|
||||||
<v-tooltip activator="parent">Open (G)itHub</v-tooltip>
|
<v-tooltip activator="parent">Open (G)itHub</v-tooltip>
|
||||||
<svg-icon type="mdi" :path="mdiGithub"/>
|
<svg-icon :path="mdiGithub" type="mdi"/>
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<div ref="statsHolder"></div>
|
<div ref="statsHolder"></div>
|
||||||
</template>
|
</template>
|
||||||
@@ -185,4 +195,8 @@ window.addEventListener('keydown', (event) => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
top: 5px;
|
top: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h5 {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
163
frontend/tools/selection.ts
Normal file
163
frontend/tools/selection.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
// Model management from the graphics side
|
||||||
|
|
||||||
|
import type {MObject3D} from "./Selection.vue";
|
||||||
|
import type {Intersection} from "three";
|
||||||
|
import {Box3} from "three";
|
||||||
|
import {extrasNameKey} from "../misc/gltf";
|
||||||
|
|
||||||
|
/** Information about a single item in the selection */
|
||||||
|
export class SelectionInfo {
|
||||||
|
/** The object which was (partially) selected */
|
||||||
|
object: MObject3D
|
||||||
|
/** The type of the selection */
|
||||||
|
kind: 'face' | 'edge' | 'vertex'
|
||||||
|
/** Start and end indices of the primitives in the geometry */
|
||||||
|
indices: [number, number]
|
||||||
|
|
||||||
|
constructor(object: MObject3D, kind: 'face' | 'edge' | 'vertex', indices: [number, number]) {
|
||||||
|
this.object = object;
|
||||||
|
this.kind = kind;
|
||||||
|
this.indices = indices;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getObjectName() {
|
||||||
|
return this.object.userData[extrasNameKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
public matches(object: MObject3D) {
|
||||||
|
return this.getObjectName() === object.userData[extrasNameKey] &&
|
||||||
|
(this.kind === 'face' && (object.type === 'Mesh' || object.type === 'SkinnedMesh') ||
|
||||||
|
this.kind === 'edge' && (object.type === 'Line' || object.type === 'LineSegments') ||
|
||||||
|
this.kind === 'vertex' && object.type === 'Points')
|
||||||
|
}
|
||||||
|
|
||||||
|
public getKey() {
|
||||||
|
return this.object.uuid + this.kind + this.indices[0].toFixed() + this.indices[1].toFixed();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getBox(): Box3 {
|
||||||
|
let index = this.object.geometry.index || {getX: (i: number) => i};
|
||||||
|
let pos = this.object.geometry.getAttribute('position');
|
||||||
|
let min = [Infinity, Infinity, Infinity];
|
||||||
|
let max = [-Infinity, -Infinity, -Infinity];
|
||||||
|
for (let i = this.indices[0]; i < this.indices[1]; i++) {
|
||||||
|
let vertIndex = index!.getX(i);
|
||||||
|
let x = pos.getX(vertIndex);
|
||||||
|
let y = pos.getY(vertIndex);
|
||||||
|
let z = pos.getZ(vertIndex);
|
||||||
|
min[0] = Math.min(min[0], x);
|
||||||
|
min[1] = Math.min(min[1], y);
|
||||||
|
min[2] = Math.min(min[2], z);
|
||||||
|
max[0] = Math.max(max[0], x);
|
||||||
|
max[1] = Math.max(max[1], y);
|
||||||
|
max[2] = Math.max(max[2], z);
|
||||||
|
}
|
||||||
|
return new Box3().setFromArray([...min, ...max]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hitToSelectionInfo(hit: Intersection<MObject3D>): SelectionInfo | null {
|
||||||
|
let kind = hit.object.type;
|
||||||
|
if (kind == 'Mesh' || kind == 'SkinnedMesh') {
|
||||||
|
let indices = hitFaceTriangleIndices(hit);
|
||||||
|
if (indices === null) return null;
|
||||||
|
return new SelectionInfo(hit.object, 'face', indices);
|
||||||
|
} else if (kind == 'Line' || kind == 'LineSegments') {
|
||||||
|
// Select raw lines, not the wide meshes representing them
|
||||||
|
// This is because the indices refer to the raw lines, not the wide meshes
|
||||||
|
// Furthermore, this allows better "fuzzy" raycasting logic
|
||||||
|
let indices = hitEdgePointIndices(hit);
|
||||||
|
if (indices === null) return null;
|
||||||
|
return new SelectionInfo(hit.object, 'edge', indices);
|
||||||
|
} else if (kind == 'Points') {
|
||||||
|
if (hit.index === undefined) return null;
|
||||||
|
return new SelectionInfo(hit.object, 'vertex', [hit.index, hit.index + 1]);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitFaceTriangleIndices(hit: Intersection<MObject3D>): [number, number] | null {
|
||||||
|
let faceTrianglesEnd = hit?.object?.geometry?.userData?.face_triangles_end;
|
||||||
|
if (hit.faceIndex === undefined) return null;
|
||||||
|
if (!faceTrianglesEnd) { // Fallback to selecting the whole imported mesh
|
||||||
|
//console.log("No face_triangles_end found, selecting the whole mesh");
|
||||||
|
return [0, (hit.object.geometry.index ?? hit.object.geometry.attributes.position).count];
|
||||||
|
} else { // Normal CAD model
|
||||||
|
let rawIndex = hit.faceIndex * 3; // Faces are triangles with 3 indices
|
||||||
|
for (let i = 0; i < faceTrianglesEnd.length; i++) {
|
||||||
|
let faceSwapIndex = faceTrianglesEnd[i]
|
||||||
|
if (rawIndex < faceSwapIndex) {
|
||||||
|
let start = i === 0 ? 0 : faceTrianglesEnd[i - 1];
|
||||||
|
return [start, faceTrianglesEnd[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hitEdgePointIndices(hit: Intersection<MObject3D>): [number, number] | null {
|
||||||
|
let edgePointsEnd = hit?.object?.geometry?.userData?.edge_points_end;
|
||||||
|
if (!edgePointsEnd || hit.index === undefined) return null;
|
||||||
|
let rawIndex = hit.index; // Faces are triangles with 3 indices
|
||||||
|
for (let i = 0; i < edgePointsEnd.length; i++) {
|
||||||
|
let edgeSwapIndex = edgePointsEnd[i]
|
||||||
|
if (rawIndex < edgeSwapIndex) {
|
||||||
|
let start = i === 0 ? 0 : edgePointsEnd[i - 1];
|
||||||
|
return [start, edgePointsEnd[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyColor(selInfo: SelectionInfo, colorAttribute: any, color: [number, number, number, number]): [number, number, number, number] {
|
||||||
|
let index = selInfo.object.geometry.index
|
||||||
|
let prevColor: [number, number, number, number] | null = null;
|
||||||
|
if (colorAttribute !== undefined) {
|
||||||
|
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
|
||||||
|
let vertIndex = index!.getX(i);
|
||||||
|
if (prevColor === null) prevColor = [colorAttribute.getX(vertIndex), colorAttribute.getY(vertIndex), colorAttribute.getZ(vertIndex), colorAttribute.getW(vertIndex)];
|
||||||
|
colorAttribute.setXYZW(vertIndex, color[0], color[1], color[2], color[3]);
|
||||||
|
}
|
||||||
|
colorAttribute.needsUpdate = true;
|
||||||
|
if (selInfo.object.userData.niceLine !== undefined) {
|
||||||
|
// Need to update the color of the nice line as well
|
||||||
|
let indexAttribute = selInfo.object.geometry.index!!;
|
||||||
|
let allNewColors = [];
|
||||||
|
for (let i = 0; i < indexAttribute.count; i++) {
|
||||||
|
if (indexAttribute.getX(i) >= selInfo.indices[0] && indexAttribute.getX(i) < selInfo.indices[1]) {
|
||||||
|
allNewColors.push(color[0], color[1], color[2]);
|
||||||
|
} else {
|
||||||
|
allNewColors.push(colorAttribute.getX(indexAttribute.getX(i)), colorAttribute.getY(indexAttribute.getX(i)), colorAttribute.getZ(indexAttribute.getX(i)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
selInfo.object.userData.niceLine.geometry.setColors(allNewColors);
|
||||||
|
for (let attribute of Object.values(selInfo.object.userData.niceLine.geometry.attributes)) {
|
||||||
|
(attribute as any).needsUpdate = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else { // Fallback to tinting the whole mesh for imported models
|
||||||
|
//console.log("No color attribute found, tinting the whole mesh")
|
||||||
|
let tmpPrevColor = selInfo.object.material.color;
|
||||||
|
prevColor = [tmpPrevColor.r, tmpPrevColor.g, tmpPrevColor.b, 1];
|
||||||
|
selInfo.object.material.color.setRGB(color[0], color[1], color[2]);
|
||||||
|
selInfo.object.material.needsUpdate = true;
|
||||||
|
}
|
||||||
|
return prevColor!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlight(selInfo: SelectionInfo): void {
|
||||||
|
// Update the color of all the triangles in the face
|
||||||
|
let geometry = selInfo.object.geometry;
|
||||||
|
let colorAttr = selInfo.object.geometry.getAttribute('color');
|
||||||
|
geometry.userData.savedColor = geometry.userData.savedColor || {};
|
||||||
|
geometry.userData.savedColor[selInfo.getKey()] = applyColor(selInfo, colorAttr, [1.0, 0.0, 0.0, 1.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function highlightUndo(selInfo: SelectionInfo): void {
|
||||||
|
// Update the color of all the triangles in the face
|
||||||
|
let geometry = selInfo.object.geometry;
|
||||||
|
let colorAttr = selInfo.object.geometry.getAttribute('color');
|
||||||
|
let savedColor = geometry.userData.savedColor[selInfo.getKey()];
|
||||||
|
applyColor(selInfo, colorAttr, savedColor);
|
||||||
|
delete geometry.userData.savedColor[selInfo.getKey()];
|
||||||
|
}
|
||||||
@@ -1,41 +1,115 @@
|
|||||||
<script setup lang="ts">
|
<script lang="ts" setup>
|
||||||
import {settings} from "../misc/settings";
|
import {settings} from "../misc/settings";
|
||||||
import {inject, onMounted, type Ref, ref, watch} from "vue";
|
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 {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
|
||||||
import Loading from "../misc/Loading.vue";
|
import {$controls} from '@google/model-viewer/lib/features/controls.js';
|
||||||
|
import {type SmoothControls} from '@google/model-viewer/lib/three-components/SmoothControls';
|
||||||
import {ModelViewerElement} from '@google/model-viewer';
|
import {ModelViewerElement} from '@google/model-viewer';
|
||||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||||
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
|
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
|
||||||
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
|
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
|
||||||
import type {Vector3} from "three";
|
import type {Vector3} from "three";
|
||||||
|
import {BufferGeometry, Mesh} from "three";
|
||||||
|
import {acceleratedRaycast, computeBoundsTree, disposeBoundsTree} from 'three-mesh-bvh';
|
||||||
|
|
||||||
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
||||||
|
//@ts-ignore
|
||||||
const emit = defineEmits<{ load: [] }>()
|
BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
|
||||||
|
//@ts-ignore
|
||||||
|
BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
|
||||||
|
//@ts-ignore
|
||||||
|
Mesh.prototype.raycast = acceleratedRaycast;
|
||||||
|
|
||||||
const props = defineProps<{ src: string }>();
|
const props = defineProps<{ src: string }>();
|
||||||
|
|
||||||
const elem = ref<ModelViewerElement | null>(null);
|
const elem = ref<ModelViewerElement | null>(null);
|
||||||
const scene = ref<ModelScene | null>(null);
|
const scene = ref<ModelScene | null>(null);
|
||||||
const renderer = ref<Renderer | null>(null);
|
const renderer = ref<Renderer | null>(null);
|
||||||
|
const controls = ref<SmoothControls | null>(null);
|
||||||
|
|
||||||
|
|
||||||
|
let lastCameraTargetPosition: Vector3 | undefined = undefined;
|
||||||
|
let lastCameraZoom: number | undefined = undefined;
|
||||||
|
let lastCameraUrl = props.src.toString();
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (!elem.value) return;
|
if (!elem.value) return;
|
||||||
elem.value.addEventListener('load', async () => {
|
elem.value.addEventListener('before-render', () => {
|
||||||
if (!elem.value) return;
|
if (!elem.value) return;
|
||||||
// Delete the initial load banner
|
// Extract internals of model-viewer in order to hack unsupported features
|
||||||
let banner = elem.value.querySelector('.initial-load-banner');
|
|
||||||
if (banner) banner.remove();
|
|
||||||
// Set the scene and renderer
|
|
||||||
scene.value = elem.value[$scene] as ModelScene;
|
scene.value = elem.value[$scene] as ModelScene;
|
||||||
renderer.value = elem.value[$renderer] as Renderer;
|
renderer.value = elem.value[$renderer] as Renderer;
|
||||||
// Emit the load event
|
controls.value = (elem.value as any)[$controls] as SmoothControls;
|
||||||
emit('load')
|
// Recover the camera position if it was set before
|
||||||
|
if (lastCameraTargetPosition) {
|
||||||
|
// console.log("RESTORING camera position?", lastCameraTargetPosition);
|
||||||
|
scene.value.setTarget(-lastCameraTargetPosition.x, -lastCameraTargetPosition.y, -lastCameraTargetPosition.z);
|
||||||
|
scene.value.jumpToGoal(); // Avoid move animation
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
let tries = 0
|
||||||
|
while (tries++ < 25) {
|
||||||
|
if (!lastCameraZoom || !elem.value?.getCameraOrbit()?.radius) break;
|
||||||
|
let change = lastCameraZoom - elem.value.getCameraOrbit().radius;
|
||||||
|
//console.log("Zooming to", lastCameraZoom, "from", elem.value.getCameraOrbit().radius, "change", change);
|
||||||
|
if (Math.abs(change) < 0.001) break;
|
||||||
|
elem.value.zoom(-Math.sign(change) * (Math.pow(Math.abs(change) + 1, 0.9) - 1)); // Arbitrary, experimental
|
||||||
|
elem.value.jumpCameraToGoal();
|
||||||
|
await elem.value.updateComplete;
|
||||||
|
}
|
||||||
|
//console.log("Ready to save!")
|
||||||
|
lastCameraUrl = props.src.toString();
|
||||||
|
})();
|
||||||
});
|
});
|
||||||
elem.value.addEventListener('camera-change', onCameraChange);
|
elem.value.addEventListener('camera-change', onCameraChange);
|
||||||
|
elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function onCameraChange() {
|
||||||
|
// Remember the camera position to keep it in case of scene changes
|
||||||
|
if (scene.value && props.src.toString() == lastCameraUrl) { // Don't overwrite with initial unwanted positions
|
||||||
|
lastCameraTargetPosition = scene.value.target.position.clone();
|
||||||
|
lastCameraZoom = elem.value?.getCameraOrbit()?.radius;
|
||||||
|
//console.log("Saving camera?", lastCameraTargetPosition, lastCameraZoom);
|
||||||
|
}
|
||||||
|
// Also need to update the SVG overlay
|
||||||
|
for (let lineId in lines.value) {
|
||||||
|
onCameraChangeLine(lineId as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handles loading the events for <model-viewer>'s slotted progress bar
|
||||||
|
const progressBar = ref<HTMLElement | null>(null);
|
||||||
|
const updateBar = ref<HTMLElement | null>(null);
|
||||||
|
let onProgressHideTimeout: number | null = null;
|
||||||
|
const onProgress = (totalProgress: number) => {
|
||||||
|
if (!progressBar.value || !updateBar.value) return;
|
||||||
|
// Update the progress bar and ensure it's visible
|
||||||
|
progressBar.value.style.display = 'block';
|
||||||
|
progressBar.value.style.opacity = '1'; // Fade in
|
||||||
|
updateBar.value.style.width = `${totalProgress * 100}%`;
|
||||||
|
// Auto-hide smoothly when no progress is made for a while
|
||||||
|
if (onProgressHideTimeout) clearTimeout(onProgressHideTimeout);
|
||||||
|
onProgressHideTimeout = setTimeout(() => {
|
||||||
|
if (!progressBar.value) return;
|
||||||
|
progressBar.value.style.opacity = '0'; // Fade out
|
||||||
|
setTimeout(() => {
|
||||||
|
if (!progressBar.value) return;
|
||||||
|
progressBar.value.style.display = 'none'; // Actually hide
|
||||||
|
}, 300); // 0.3s fade out
|
||||||
|
}, 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const poster = ref<string>("")
|
||||||
|
const setPosterText = (newText: string) => {
|
||||||
|
poster.value = "data:image/svg+xml;charset=utf-8;base64," + btoa(
|
||||||
|
'<svg width="800" height="600" xmlns="http://www.w3.org/2000/svg" fill="gray">' +
|
||||||
|
'<text x="50%" y="0%" dominant-baseline="middle" text-anchor="middle" font-size="48px">' +
|
||||||
|
newText +
|
||||||
|
'</text>' +
|
||||||
|
'</svg>')
|
||||||
|
}
|
||||||
|
setPosterText("Loading...")
|
||||||
|
|
||||||
class Line3DData {
|
class Line3DData {
|
||||||
startHotspot: HTMLElement = document.body
|
startHotspot: HTMLElement = document.body
|
||||||
endHotspot: HTMLElement = document.body
|
endHotspot: HTMLElement = document.body
|
||||||
@@ -73,7 +147,7 @@ function addLine3D(p1: Vector3, p2: Vector3, centerText?: string, lineAttrs: { [
|
|||||||
lineAttrs: lineAttrs
|
lineAttrs: lineAttrs
|
||||||
};
|
};
|
||||||
scene.value.queueRender() // Needed to update the hotspots
|
scene.value.queueRender() // Needed to update the hotspots
|
||||||
requestIdleCallback(() => onCameraChangeLine(id));
|
requestIdleCallback(() => onCameraChangeLine(id), {timeout: 100});
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,13 +162,6 @@ function removeLine3D(id: number): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCameraChange() {
|
|
||||||
// Need to update the SVG overlay
|
|
||||||
for (let lineId in lines.value) {
|
|
||||||
onCameraChangeLine(lineId as any);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let svg = ref<SVGElement | null>(null);
|
let svg = ref<SVGElement | null>(null);
|
||||||
|
|
||||||
function onCameraChangeLine(lineId: number) {
|
function onCameraChangeLine(lineId: number) {
|
||||||
@@ -130,48 +197,47 @@ function entries(lines: { [id: number]: Line3DData }): [string, Line3DData][] {
|
|||||||
return Object.entries(lines);
|
return Object.entries(lines);
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({elem, onElemReady, scene, renderer, addLine3D, removeLine3D});
|
defineExpose({elem, onElemReady, scene, renderer, controls, addLine3D, removeLine3D, onProgress, setPosterText});
|
||||||
|
|
||||||
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
|
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
|
||||||
watch(disableTap, (value) => {
|
watch(disableTap, (newDisableTap) => {
|
||||||
// Rerender not auto triggered? This works anyway...
|
if (elem.value) elem.value.disableTap = newDisableTap;
|
||||||
if (value) elem.value?.setAttribute('disable-tap', '');
|
|
||||||
else elem.value?.removeAttribute('disable-tap');
|
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- The main 3D model viewer -->
|
<!-- The main 3D model viewer -->
|
||||||
<model-viewer ref="elem" style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls
|
<model-viewer ref="elem" :ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :autoplay="settings.autoplay"
|
||||||
camera-orbit="30deg 75deg auto" max-camera-orbit="Infinity 180deg auto"
|
:environment-image="settings.background" :exposure="settings.exposure"
|
||||||
min-camera-orbit="-Infinity 0deg 5%" :disable-tap="disableTap" :exposure="settings.exposure"
|
:orbit-sensitivity="settings.orbitSensitivity" :pan-sensitivity="settings.panSensitivity"
|
||||||
:shadow-intensity="settings.shadowIntensity" interaction-prompt="none" :autoplay="settings.autoplay"
|
:poster="poster" :shadow-intensity="settings.shadowIntensity" :skybox-image="settings.background"
|
||||||
:ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :skybox-image="settings.background"
|
:src="props.src" :zoom-sensitivity="settings.zoomSensitivity" alt="The 3D model(s)" camera-controls
|
||||||
:environment-image="settings.background">
|
camera-orbit="30deg 75deg auto" interaction-prompt="none" max-camera-orbit="Infinity 180deg auto"
|
||||||
<slot></slot> <!-- Controls, annotations, etc. -->
|
min-camera-orbit="-Infinity 0deg 5%" style="width: 100%; height: 100%">
|
||||||
<div class="annotation initial-load-banner">
|
<slot></slot>
|
||||||
Trying to load models from...
|
<!-- Add a progress bar to the top of the model viewer -->
|
||||||
<v-list v-for="src in settings.preload" :key="src">
|
<div ref="progressBar" slot="progress-bar" class="progress-bar">
|
||||||
<v-list-item>{{ src }}</v-list-item>
|
<div ref="updateBar" class="update-bar"/>
|
||||||
</v-list>
|
|
||||||
<loading></loading>
|
|
||||||
</div>
|
</div>
|
||||||
</model-viewer>
|
</model-viewer>
|
||||||
|
|
||||||
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
||||||
<div class="overlay-svg-wrapper">
|
<div class="overlay-svg-wrapper">
|
||||||
<svg ref="svg" class="overlay-svg" width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">
|
<svg ref="svg" class="overlay-svg" height="100%" width="100%" xmlns="http://www.w3.org/2000/svg">
|
||||||
<g v-for="[lineId, line] in entries(lines)" :key="lineId">
|
<g v-for="[lineId, line] in entries(lines)" :key="lineId">
|
||||||
<line :x1="line.start2D[0]" :y1="line.start2D[1]" :x2="line.end2D[0]"
|
<line :x1="line.start2D[0]" :x2="line.end2D[0]" :y1="line.start2D[1]"
|
||||||
:y2="line.end2D[1]" v-bind="line.lineAttrs"/>
|
:y2="line.end2D[1]" v-bind="line.lineAttrs"/>
|
||||||
<g v-if="line.centerText !== undefined">
|
<g v-if="line.centerText !== undefined">
|
||||||
<rect :x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
<rect v-if="line.centerText"
|
||||||
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2"
|
:height="line.centerTextSize[1] + 4"
|
||||||
:width="line.centerTextSize[0] + 8" :height="line.centerTextSize[1] + 4"
|
:width="line.centerTextSize[0] + 8"
|
||||||
fill="gray" fill-opacity="0.75" rx="4" ry="4" stroke="black" v-if="line.centerText"/>
|
:x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
||||||
<text :x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2" fill="gray"
|
||||||
text-anchor="middle" dominant-baseline="middle" font-size="16" fill="black"
|
fill-opacity="0.75" rx="4" ry="4" stroke="black"/>
|
||||||
:class="'line' + lineId + '_text'" v-if="line.centerText">
|
<text v-if="line.centerText" :class="'line' + lineId + '_text'"
|
||||||
|
:x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
||||||
|
dominant-baseline="middle" fill="black"
|
||||||
|
font-size="16" text-anchor="middle">
|
||||||
{{ line.centerText }}
|
{{ line.centerText }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
@@ -206,14 +272,29 @@ watch(disableTap, (value) => {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.initial-load-banner {
|
.progress-bar {
|
||||||
width: 300px;
|
display: block;
|
||||||
margin: auto;
|
pointer-events: none;
|
||||||
margin-top: 3em;
|
width: 100%;
|
||||||
overflow: hidden;
|
height: 10%;
|
||||||
|
max-height: 2%;
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 0;
|
||||||
|
transform: translate3d(-50%, 0%, 0);
|
||||||
|
border-radius: 25px;
|
||||||
|
box-shadow: 0 3px 10px 3px rgba(0, 0, 0, 0.5), 0 0 5px 1px rgba(0, 0, 0, 0.6);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.9);
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
transition: opacity 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.initial-load-banner .v-list-item {
|
.update-bar {
|
||||||
overflow: hidden;
|
background-color: rgba(255, 255, 255, 0.9);
|
||||||
|
width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 25px;
|
||||||
|
float: left;
|
||||||
|
transition: width 0.3s;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
33
package.json
33
package.json
@@ -1,8 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "yet-another-cad-viewer",
|
"name": "yet-another-cad-viewer",
|
||||||
"version": "0.1.0",
|
"version": "0.8.10",
|
||||||
"description": "",
|
"description": "",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"private": true,
|
||||||
"author": "Yeicor <4929005+Yeicor@users.noreply.github.com>",
|
"author": "Yeicor <4929005+Yeicor@users.noreply.github.com>",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,31 +15,33 @@
|
|||||||
"update-licenses": "generate-license-file --input package.json --output assets/licenses.txt --overwrite"
|
"update-licenses": "generate-license-file --input package.json --output assets/licenses.txt --overwrite"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@gltf-transform/core": "^3.10.0",
|
"@gltf-transform/core": "^4.0.0",
|
||||||
"@gltf-transform/functions": "^3.10.0",
|
"@gltf-transform/extensions": "^4.0.0",
|
||||||
"@google/model-viewer": "^3.4.0",
|
"@gltf-transform/functions": "^4.0.0",
|
||||||
|
"@google/model-viewer": "^3.5.0",
|
||||||
"@jamescoyle/vue-icon": "^0.1.2",
|
"@jamescoyle/vue-icon": "^0.1.2",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@mdi/svg": "^7.4.47",
|
"@mdi/svg": "^7.4.47",
|
||||||
"three": "^0.160.1",
|
"three": "^0.163.0",
|
||||||
|
"three-mesh-bvh": "^0.7.4",
|
||||||
"three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo",
|
"three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.27",
|
||||||
"vuetify": "^3.5.7"
|
"vuetify": "^3.6.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node20": "^20.1.2",
|
"@tsconfig/node20": "^20.1.4",
|
||||||
"@types/node": "^20.11.24",
|
"@types/node": "^20.12.11",
|
||||||
"@types/three": "^0.160.0",
|
"@types/three": "^0.163.0",
|
||||||
"@vitejs/plugin-vue": "^5.0.3",
|
"@vitejs/plugin-vue": "^5.0.3",
|
||||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||||
"@vue/tsconfig": "^0.5.1",
|
"@vue/tsconfig": "^0.5.1",
|
||||||
"buffer": "^5.5.0||^6.0.0",
|
"buffer": "^5.5.0||^6.0.0",
|
||||||
"commander": "^12.0.0",
|
"commander": "^12.0.0",
|
||||||
"generate-license-file": "^3.0.1",
|
"generate-license-file": "^3.4.0",
|
||||||
"npm-run-all2": "^6.1.1",
|
"npm-run-all2": "^6.1.1",
|
||||||
"terser": "^5.28.1",
|
"terser": "^5.31.0",
|
||||||
"typescript": "~5.3.0",
|
"typescript": "~5.4.5",
|
||||||
"vite": "^5.1.5",
|
"vite": "^5.2.11",
|
||||||
"vue-tsc": "^2.0.5"
|
"vue-tsc": "^2.0.17"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
1013
poetry.lock
generated
1013
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "yacv-server"
|
name = "yacv-server"
|
||||||
version = "0.1.0" # TODO: Update automatically by CI on release (also for package.json!)
|
version = "0.8.10"
|
||||||
description = "Yet Another CAD Viewer (server)"
|
description = "Yet Another CAD Viewer (server)"
|
||||||
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
|
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
@@ -14,14 +14,7 @@ include = [
|
|||||||
python = "^3.9"
|
python = "^3.9"
|
||||||
|
|
||||||
# CAD
|
# CAD
|
||||||
build123d = "^0.4.0"
|
build123d = "^0.5.0"
|
||||||
ocp-tessellate = "^2.0.6"
|
|
||||||
|
|
||||||
# Web
|
|
||||||
aiohttp = "^3.9.3"
|
|
||||||
aiohttp-sse = "^2.2.0"
|
|
||||||
aiohttp-cors = "^0.7.0"
|
|
||||||
aiohttp-devtools = "^1.1.2"
|
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
pygltflib = "^1.16.2"
|
pygltflib = "^1.16.2"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import {fileURLToPath, URL} from 'node:url'
|
|||||||
import {defineConfig} from 'vite'
|
import {defineConfig} from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||||
|
import {name, version} from './package.json'
|
||||||
|
import {execSync} from 'child_process'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -24,7 +26,15 @@ export default defineConfig({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
assetsDir: '.',
|
assetsDir: '.', // Support deploying to a subdirectory using relative URLs
|
||||||
cssCodeSplit: false, // Small enough to inline
|
cssCodeSplit: false, // Small enough to inline
|
||||||
|
chunkSizeWarningLimit: 550, // Three.js is big. Draco is even bigger but not likely to be used.
|
||||||
|
sourcemap: true, // For debugging production
|
||||||
|
},
|
||||||
|
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,42 +1,21 @@
|
|||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
import time
|
|
||||||
|
|
||||||
from aiohttp import web
|
from yacv_server.cad import image_to_gltf
|
||||||
|
from yacv_server.yacv import YACV
|
||||||
|
|
||||||
from server import Server
|
yacv = YACV()
|
||||||
|
|
||||||
server = Server()
|
|
||||||
"""The server instance. This is the main entry point to serve CAD objects and other data to the frontend."""
|
"""The server instance. This is the main entry point to serve CAD objects and other data to the frontend."""
|
||||||
|
|
||||||
if 'YACV_DISABLE_SERVER' not in os.environ:
|
if 'YACV_DISABLE_SERVER' not in os.environ:
|
||||||
# Start a new server ASAP to let the polling client connect while still building CAD objects
|
# Start a new server ASAP to let the polling client connect while still building CAD objects
|
||||||
# This is a bit of a hack, but it is seamless to the user. This behavior can be disabled by setting
|
# This is a bit of a hack, but it is seamless to the user. This behavior can be disabled by setting
|
||||||
# the environment variable YACV_DISABLE_SERVER to a non-empty value
|
# the environment variable YACV_DISABLE_SERVER to a non-empty value
|
||||||
server.start()
|
yacv.start()
|
||||||
|
|
||||||
# Expose some nice aliases using the default server instance
|
# Expose some nice aliases using the default server instance
|
||||||
show = server.show
|
show = yacv.show
|
||||||
show_object = show
|
show_all = yacv.show_cad_all
|
||||||
show_image = server.show_image
|
image_to_gltf = image_to_gltf
|
||||||
show_all = server.show_cad_all
|
export_all = yacv.export_all
|
||||||
export_all = server.export_all
|
remove = yacv.remove
|
||||||
|
clear = yacv.clear
|
||||||
|
|
||||||
def _get_app() -> web.Application:
|
|
||||||
"""Required by aiohttp-devtools"""
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
|
||||||
from logo import build_logo, ASSETS_DIR
|
|
||||||
logo, img_location, img_path = build_logo()
|
|
||||||
server.show_cad(logo, 'logo')
|
|
||||||
server.show_cad(img_location, 'location')
|
|
||||||
server.show_image(img_path, img_location, 20)
|
|
||||||
server.show_gltf(open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read(), 'fox')
|
|
||||||
return server.app
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
# Publish the logo to the server (reusing code from the aiohttp-devtools)
|
|
||||||
_get_app()
|
|
||||||
# Keep the server running for testing
|
|
||||||
time.sleep(60)
|
|
||||||
|
|||||||
@@ -2,17 +2,23 @@
|
|||||||
Utilities to work with CAD objects
|
Utilities to work with CAD objects
|
||||||
"""
|
"""
|
||||||
import hashlib
|
import hashlib
|
||||||
from typing import Optional, Union, List, Tuple
|
import io
|
||||||
|
import re
|
||||||
|
from typing import Optional, Union, Tuple
|
||||||
|
|
||||||
|
from OCP.TopExp import TopExp
|
||||||
from OCP.TopLoc import TopLoc_Location
|
from OCP.TopLoc import TopLoc_Location
|
||||||
|
from OCP.TopTools import TopTools_IndexedMapOfShape
|
||||||
from OCP.TopoDS import TopoDS_Shape
|
from OCP.TopoDS import TopoDS_Shape
|
||||||
|
from build123d import Compound, Shape
|
||||||
|
|
||||||
from gltf import GLTFMgr
|
from yacv_server.gltf import GLTFMgr
|
||||||
|
|
||||||
CADLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now
|
CADCoreLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now
|
||||||
|
CADLike = Union[CADCoreLike, any] # build123d and cadquery types
|
||||||
|
|
||||||
|
|
||||||
def get_shape(obj: any, error: bool = True) -> Optional[CADLike]:
|
def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
|
||||||
""" Get the shape of a CAD-like object """
|
""" Get the shape of a CAD-like object """
|
||||||
|
|
||||||
# Try to grab a shape if a different type of object was passed
|
# Try to grab a shape if a different type of object was passed
|
||||||
@@ -39,28 +45,49 @@ def get_shape(obj: any, error: bool = True) -> Optional[CADLike]:
|
|||||||
if isinstance(obj, TopoDS_Shape):
|
if isinstance(obj, TopoDS_Shape):
|
||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
# Handle iterables like Build123d ShapeList by extracting all sub-shapes and making a compound
|
||||||
|
if isinstance(obj, list) or isinstance(obj, tuple) or isinstance(obj, set) or isinstance(obj, dict):
|
||||||
|
try:
|
||||||
|
if isinstance(obj, dict):
|
||||||
|
obj_iter = iter(obj.values())
|
||||||
|
else:
|
||||||
|
obj_iter = iter(obj)
|
||||||
|
# print(obj, ' -> ', obj_iter)
|
||||||
|
shapes_raw = [get_shape(sub_obj, error=False) for sub_obj in obj_iter]
|
||||||
|
# Silently drop non-shapes
|
||||||
|
shapes_raw_filtered = [shape for shape in shapes_raw if shape is not None]
|
||||||
|
if len(shapes_raw_filtered) > 0: # Continue if we found at least one shape
|
||||||
|
# Sorting is required to improve hashcode consistency
|
||||||
|
shapes_raw_filtered_sorted = sorted(shapes_raw_filtered, key=lambda x: _hashcode(x))
|
||||||
|
# Build a single compound shape
|
||||||
|
shapes_bd = [Shape(shape) for shape in shapes_raw_filtered_sorted if shape is not None]
|
||||||
|
return get_shape(Compound(shapes_bd), error)
|
||||||
|
except TypeError:
|
||||||
|
pass
|
||||||
|
|
||||||
if error:
|
if error:
|
||||||
raise ValueError(f'Cannot show object of type {type(obj)} (submit issue?)')
|
raise ValueError(f'Cannot show object of type {type(obj)} (submit issue?)')
|
||||||
else:
|
else:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def grab_all_cad() -> List[Tuple[str, CADLike]]:
|
def grab_all_cad() -> set[Tuple[str, CADCoreLike]]:
|
||||||
""" Grab all shapes by inspecting the stack """
|
""" Grab all shapes by inspecting the stack """
|
||||||
import inspect
|
import inspect
|
||||||
stack = inspect.stack()
|
stack = inspect.stack()
|
||||||
shapes = []
|
shapes = set()
|
||||||
for frame in stack:
|
for frame in stack:
|
||||||
for key, value in frame.frame.f_locals.items():
|
for key, value in frame.frame.f_locals.items():
|
||||||
shape = get_shape(value, error=False)
|
shape = get_shape(value, error=False)
|
||||||
if shape:
|
if shape and shape not in shapes:
|
||||||
shapes.append((key, shape))
|
shapes.add((key, shape))
|
||||||
return shapes
|
return shapes
|
||||||
|
|
||||||
|
|
||||||
def image_to_gltf(source: str | bytes, center: any, ppmm: int, name: Optional[str] = None,
|
def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = None, height: Optional[float] = None,
|
||||||
save_mime: str = 'image/jpeg') -> Tuple[bytes, str]:
|
name: Optional[str] = None, save_mime: str = 'image/jpeg', power_of_two: bool = True) \
|
||||||
"""Convert an image to a GLTF CAD object, indicating the center location and pixels per millimeter."""
|
-> Tuple[bytes, str]:
|
||||||
|
"""Convert an image to a GLTF CAD object."""
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
@@ -89,27 +116,40 @@ def image_to_gltf(source: str | bytes, center: any, ppmm: int, name: Optional[st
|
|||||||
if not isinstance(center_loc, TopLoc_Location):
|
if not isinstance(center_loc, TopLoc_Location):
|
||||||
raise ValueError('Center location not valid')
|
raise ValueError('Center location not valid')
|
||||||
plane = Plane(Location(center_loc))
|
plane = Plane(Location(center_loc))
|
||||||
# Convert coordinates system
|
|
||||||
plane.origin = Vector(plane.origin.X, plane.origin.Z, -plane.origin.Y)
|
|
||||||
plane.z_dir = -plane.y_dir
|
|
||||||
plane.y_dir = plane.z_dir
|
|
||||||
|
|
||||||
def vert(v: Vector) -> Tuple[float, float, float]:
|
|
||||||
return v.X, v.Y, v.Z
|
|
||||||
|
|
||||||
# Load the image to a byte buffer
|
# Load the image to a byte buffer
|
||||||
img = Image.open(source)
|
img = Image.open(source)
|
||||||
img_buf = io.BytesIO()
|
img_buf = io.BytesIO()
|
||||||
|
|
||||||
|
# Use the original dimensions for scaling the model
|
||||||
|
if width is None and height is None:
|
||||||
|
raise ValueError('At least one of width or height must be specified') # Fallback to pixels == mm?
|
||||||
|
elif width is None:
|
||||||
|
width = img.width / img.height * height
|
||||||
|
elif height is None:
|
||||||
|
height = height or img.height / img.width * width # Apply default aspect ratio if unspecified
|
||||||
|
|
||||||
|
# Resize the image to a power of two if requested (recommended for GLTF)
|
||||||
|
if power_of_two:
|
||||||
|
new_width = 2 ** (img.width - 1).bit_length()
|
||||||
|
new_height = 2 ** (img.height - 1).bit_length()
|
||||||
|
img = img.resize((new_width, new_height))
|
||||||
|
|
||||||
|
# Save the image to a buffer
|
||||||
img.save(img_buf, format=format)
|
img.save(img_buf, format=format)
|
||||||
img_buf = img_buf.getvalue()
|
img_buf = img_buf.getvalue()
|
||||||
|
|
||||||
|
# Convert coordinates system as a last step (gltf is Y-up instead of Z-up)
|
||||||
|
def vert(v: Vector) -> Vector:
|
||||||
|
return Vector(v.X, v.Z, -v.Y)
|
||||||
|
|
||||||
# Build the gltf
|
# Build the gltf
|
||||||
mgr = GLTFMgr(image=(img_buf, save_mime))
|
mgr = GLTFMgr(image=(img_buf, save_mime))
|
||||||
mgr.add_face([
|
mgr.add_face([
|
||||||
vert(plane.origin - plane.x_dir * img.width / (2 * ppmm) - plane.y_dir * img.height / (2 * ppmm)),
|
vert(plane.origin - plane.x_dir * width / 2 + plane.y_dir * height / 2),
|
||||||
vert(plane.origin + plane.x_dir * img.width / (2 * ppmm) - plane.y_dir * img.height / (2 * ppmm)),
|
vert(plane.origin + plane.x_dir * width / 2 + plane.y_dir * height / 2),
|
||||||
vert(plane.origin + plane.x_dir * img.width / (2 * ppmm) + plane.y_dir * img.height / (2 * ppmm)),
|
vert(plane.origin + plane.x_dir * width / 2 - plane.y_dir * height / 2),
|
||||||
vert(plane.origin - plane.x_dir * img.width / (2 * ppmm) + plane.y_dir * img.height / (2 * ppmm)),
|
vert(plane.origin - plane.x_dir * width / 2 - plane.y_dir * height / 2),
|
||||||
], [
|
], [
|
||||||
(0, 2, 1),
|
(0, 2, 1),
|
||||||
(0, 3, 2),
|
(0, 3, 2),
|
||||||
@@ -118,7 +158,36 @@ def image_to_gltf(source: str | bytes, center: any, ppmm: int, name: Optional[st
|
|||||||
(1, 0),
|
(1, 0),
|
||||||
(1, 1),
|
(1, 1),
|
||||||
(0, 1),
|
(0, 1),
|
||||||
])
|
], (1, 1, 1, 1))
|
||||||
|
|
||||||
# Return the GLTF binary blob and the suggested name of the image
|
# Return the GLTF binary blob and the suggested name of the image
|
||||||
return b''.join(mgr.gltf.save_to_bytes()), name
|
return b''.join(mgr.build().save_to_bytes()), name
|
||||||
|
|
||||||
|
|
||||||
|
def _hashcode(obj: Union[bytes, CADCoreLike], **extras) -> str:
|
||||||
|
"""Utility to compute the STABLE hash code of a shape"""
|
||||||
|
# NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program
|
||||||
|
# This is best-effort and not guaranteed to be unique
|
||||||
|
hasher = hashlib.md5(usedforsecurity=False)
|
||||||
|
for k, v in extras.items():
|
||||||
|
hasher.update(str(k).encode())
|
||||||
|
hasher.update(str(v).encode())
|
||||||
|
if isinstance(obj, bytes):
|
||||||
|
hasher.update(obj)
|
||||||
|
elif isinstance(obj, TopLoc_Location):
|
||||||
|
sub_data = io.BytesIO()
|
||||||
|
obj.DumpJson(sub_data)
|
||||||
|
hasher.update(sub_data.getvalue())
|
||||||
|
elif isinstance(obj, TopoDS_Shape):
|
||||||
|
map_of_shapes = TopTools_IndexedMapOfShape()
|
||||||
|
TopExp.MapShapes_s(obj, map_of_shapes)
|
||||||
|
for i in range(1, map_of_shapes.Extent() + 1):
|
||||||
|
sub_shape = map_of_shapes.FindKey(i)
|
||||||
|
sub_data = io.BytesIO()
|
||||||
|
TopoDS_Shape.DumpJson(sub_shape, sub_data)
|
||||||
|
val = sub_data.getvalue()
|
||||||
|
val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address
|
||||||
|
hasher.update(val)
|
||||||
|
else:
|
||||||
|
raise ValueError(f'Cannot hash object of type {type(obj)}')
|
||||||
|
return hasher.hexdigest()
|
||||||
|
|||||||
@@ -12,43 +12,95 @@ _checkerboard_image_bytes = base64.decodebytes(
|
|||||||
class GLTFMgr:
|
class GLTFMgr:
|
||||||
"""A utility class to build our GLTF2 objects easily and incrementally"""
|
"""A utility class to build our GLTF2 objects easily and incrementally"""
|
||||||
|
|
||||||
def __init__(self, image: Tuple[bytes, str] = (_checkerboard_image_bytes, 'image/png')):
|
gltf: GLTF2
|
||||||
|
|
||||||
|
# Intermediate data to be filled by the add_* methods and merged into the GLTF object
|
||||||
|
# - Face data
|
||||||
|
face_indices: List[int] # 3 indices per triangle
|
||||||
|
face_positions: List[float] # x, y, z
|
||||||
|
face_tex_coords: List[float] # u, v
|
||||||
|
face_colors: List[float] # r, g, b, a
|
||||||
|
image: Optional[Tuple[bytes, str]] # image/png
|
||||||
|
# - Edge data
|
||||||
|
edge_indices: List[int] # 2 indices per edge
|
||||||
|
edge_positions: List[float] # x, y, z
|
||||||
|
edge_colors: List[float] # r, g, b, a
|
||||||
|
# - Vertex data
|
||||||
|
vertex_indices: List[int] # 1 index per vertex
|
||||||
|
vertex_positions: List[float] # x, y, z
|
||||||
|
vertex_colors: List[float] # r, g, b, a
|
||||||
|
|
||||||
|
def __init__(self, image: Optional[Tuple[bytes, str]] = (_checkerboard_image_bytes, 'image/png')):
|
||||||
self.gltf = GLTF2(
|
self.gltf = GLTF2(
|
||||||
asset=Asset(generator=f"yacv_server@{importlib.metadata.version('yacv_server')}"),
|
asset=Asset(generator=f"yacv_server@{importlib.metadata.version('yacv_server')}"),
|
||||||
scene=0,
|
scene=0,
|
||||||
scenes=[Scene(nodes=[0])],
|
scenes=[Scene(nodes=[0])],
|
||||||
nodes=[Node(mesh=0)],
|
nodes=[Node(mesh=0)], # TODO: Server-side detection of shallow copies --> nodes
|
||||||
meshes=[Mesh(primitives=[])],
|
meshes=[Mesh(primitives=[
|
||||||
accessors=[],
|
Primitive(indices=-1, attributes=Attributes(), mode=TRIANGLES, material=0,
|
||||||
bufferViews=[BufferView(buffer=0, byteLength=len(image[0]), byteOffset=0)],
|
extras={"face_triangles_end": []}),
|
||||||
buffers=[Buffer(byteLength=len(image[0]))],
|
Primitive(indices=-1, attributes=Attributes(), mode=LINES, material=0,
|
||||||
samplers=[Sampler(magFilter=NEAREST)],
|
extras={"edge_points_end": []}),
|
||||||
textures=[Texture(source=0, sampler=0)],
|
Primitive(indices=-1, attributes=Attributes(), mode=POINTS, material=0),
|
||||||
images=[Image(bufferView=0, mimeType=image[1])],
|
])],
|
||||||
|
materials=[Material(pbrMetallicRoughness=PbrMetallicRoughness(metallicFactor=0.1, roughnessFactor=1.0),
|
||||||
|
alphaCutoff=None)],
|
||||||
)
|
)
|
||||||
self.gltf.set_binary_blob(image[0])
|
self.face_indices = []
|
||||||
|
self.face_positions = []
|
||||||
|
self.face_tex_coords = []
|
||||||
|
self.face_colors = []
|
||||||
|
self.image = image
|
||||||
|
self.edge_indices = []
|
||||||
|
self.edge_positions = []
|
||||||
|
self.edge_colors = []
|
||||||
|
self.vertex_indices = []
|
||||||
|
self.vertex_positions = []
|
||||||
|
self.vertex_colors = []
|
||||||
|
|
||||||
def add_face(self, vertices_raw: List[Tuple[float, float, float]], indices_raw: List[Tuple[int, int, int]],
|
@property
|
||||||
tex_coord_raw: List[Tuple[float, float]]):
|
def _faces_primitive(self) -> Primitive:
|
||||||
"""Add a face to the GLTF as a new primitive of the unique mesh"""
|
return [p for p in self.gltf.meshes[0].primitives if p.mode == TRIANGLES][0]
|
||||||
vertices = np.array([[v[0], v[1], v[2]] for v in vertices_raw], dtype=np.float32)
|
|
||||||
indices = np.array([[i[0], i[1], i[2]] for i in indices_raw], dtype=np.uint32)
|
|
||||||
tex_coord = np.array([[t[0], t[1]] for t in tex_coord_raw], dtype=np.float32)
|
|
||||||
self._add_any(vertices, indices, tex_coord, mode=TRIANGLES, material="face")
|
|
||||||
|
|
||||||
def add_edge(self, vertices_raw: List[Tuple[float, float, float]], mat: str = None):
|
@property
|
||||||
"""Add an edge to the GLTF as a new primitive of the unique mesh"""
|
def _edges_primitive(self) -> Primitive:
|
||||||
vertices = np.array([[v[0], v[1], v[2]] for v in vertices_raw], dtype=np.float32)
|
return [p for p in self.gltf.meshes[0].primitives if p.mode == LINES][0]
|
||||||
indices = np.array(list(map(lambda i: [i, i + 1], range(len(vertices) - 1))), dtype=np.uint32)
|
|
||||||
tex_coord = np.array([])
|
|
||||||
self._add_any(vertices, indices, tex_coord, mode=LINE_STRIP, material=mat or "edge")
|
|
||||||
|
|
||||||
def add_vertex(self, vertex: Tuple[float, float, float]):
|
@property
|
||||||
"""Add a vertex to the GLTF as a new primitive of the unique mesh"""
|
def _vertices_primitive(self) -> Primitive:
|
||||||
vertices = np.array([[vertex[0], vertex[1], vertex[2]]])
|
return [p for p in self.gltf.meshes[0].primitives if p.mode == POINTS][0]
|
||||||
indices = np.array([[0]], dtype=np.uint32)
|
|
||||||
tex_coord = np.array([], dtype=np.float32)
|
def add_face(self, vertices_raw: List[Vector], indices_raw: List[Tuple[int, int, int]],
|
||||||
self._add_any(vertices, indices, tex_coord, mode=POINTS, material="vertex")
|
tex_coord_raw: List[Tuple[float, float]],
|
||||||
|
color: Tuple[float, float, float, float] = (1.0, 0.75, 0.0, 1.0)):
|
||||||
|
"""Add a face to the GLTF mesh"""
|
||||||
|
# assert len(vertices_raw) == len(tex_coord_raw), f"Vertices and texture coordinates have different lengths"
|
||||||
|
# assert min([i for t in indices_raw for i in t]) == 0, f"Face indices start at {min(indices_raw)}"
|
||||||
|
# assert max([e for t in indices_raw for e in t]) < len(vertices_raw), f"Indices have non-existing vertices"
|
||||||
|
base_index = len(self.face_positions) // 3 # All the new indices reference the new vertices
|
||||||
|
self.face_indices.extend([base_index + i for t in indices_raw for i in t])
|
||||||
|
self.face_positions.extend([v for t in vertices_raw for v in t])
|
||||||
|
self.face_tex_coords.extend([c for t in tex_coord_raw for c in t])
|
||||||
|
self.face_colors.extend([col for _ in range(len(vertices_raw)) for col in color])
|
||||||
|
self._faces_primitive.extras["face_triangles_end"].append(len(self.face_indices))
|
||||||
|
|
||||||
|
def add_edge(self, vertices_raw: List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]],
|
||||||
|
color: Tuple[float, float, float, float] = (0.1, 0.1, 1.0, 1.0)):
|
||||||
|
"""Add an edge to the GLTF mesh"""
|
||||||
|
vertices_flat = [v for t in vertices_raw for v in t] # Line from 0 to 1, 2 to 3, 4 to 5, etc.
|
||||||
|
base_index = len(self.edge_positions) // 3
|
||||||
|
self.edge_indices.extend([base_index + i for i in range(len(vertices_flat))])
|
||||||
|
self.edge_positions.extend([v for t in vertices_flat for v in t])
|
||||||
|
self.edge_colors.extend([col for _ in range(len(vertices_flat)) for col in color])
|
||||||
|
self._edges_primitive.extras["edge_points_end"].append(len(self.edge_indices))
|
||||||
|
|
||||||
|
def add_vertex(self, vertex: Tuple[float, float, float],
|
||||||
|
color: Tuple[float, float, float, float] = (0.1, 0.1, 0.1, 1.0)):
|
||||||
|
"""Add a vertex to the GLTF mesh"""
|
||||||
|
base_index = len(self.vertex_positions) // 3
|
||||||
|
self.vertex_indices.append(base_index)
|
||||||
|
self.vertex_positions.extend(vertex)
|
||||||
|
self.vertex_colors.extend(color)
|
||||||
|
|
||||||
def add_location(self, loc: Location):
|
def add_location(self, loc: Location):
|
||||||
"""Add a location to the GLTF as a new primitive of the unique mesh"""
|
"""Add a location to the GLTF as a new primitive of the unique mesh"""
|
||||||
@@ -59,119 +111,91 @@ class GLTFMgr:
|
|||||||
|
|
||||||
# Add 1 origin vertex and 3 edges with custom colors to identify the X, Y and Z axis
|
# Add 1 origin vertex and 3 edges with custom colors to identify the X, Y and Z axis
|
||||||
self.add_vertex(vert(pl.origin))
|
self.add_vertex(vert(pl.origin))
|
||||||
self.add_edge([vert(pl.origin), vert(pl.origin + pl.x_dir)], mat="locX")
|
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.x_dir))], color=(0.97, 0.24, 0.24, 1))
|
||||||
self.add_edge([vert(pl.origin), vert(pl.origin + pl.y_dir)], mat="locY")
|
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.y_dir))], color=(0.42, 0.8, 0.15, 1))
|
||||||
self.add_edge([vert(pl.origin), vert(pl.origin + pl.z_dir)], mat="locZ")
|
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.z_dir))], color=(0.09, 0.55, 0.94, 1))
|
||||||
|
|
||||||
def add_material(self, kind: str) -> int:
|
def build(self) -> GLTF2:
|
||||||
"""It is important to use a different material for each primitive to be able to change them at runtime"""
|
"""Merge the intermediate data into the GLTF object and return it"""
|
||||||
new_material: Material
|
buffers_list: List[Tuple[Accessor, BufferView, bytes]] = []
|
||||||
if kind == "face":
|
|
||||||
new_material = Material(name="face", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
if len(self.face_indices) > 0:
|
||||||
baseColorTexture=TextureInfo(index=0), baseColorFactor=[1, 1, 0.5, 1]), doubleSided=True)
|
self._faces_primitive.indices = len(buffers_list)
|
||||||
elif kind == "edge":
|
buffers_list.append(_gen_buffer_metadata(self.face_indices, 1))
|
||||||
new_material = Material(name="edge", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
self._faces_primitive.attributes.POSITION = len(buffers_list)
|
||||||
baseColorFactor=[0, 0, 0.5, 1]))
|
buffers_list.append(_gen_buffer_metadata(self.face_positions, 3))
|
||||||
elif kind == "vertex":
|
self._faces_primitive.attributes.TEXCOORD_0 = len(buffers_list)
|
||||||
new_material = Material(name="vertex", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
buffers_list.append(_gen_buffer_metadata(self.face_tex_coords, 2))
|
||||||
baseColorFactor=[0, 0.3, 0.3, 1]))
|
self._faces_primitive.attributes.COLOR_0 = len(buffers_list)
|
||||||
elif kind == "locX":
|
buffers_list.append(_gen_buffer_metadata(self.face_colors, 4))
|
||||||
new_material = Material(name="locX", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
|
||||||
baseColorFactor=[0.97, 0.24, 0.24, 1]))
|
|
||||||
elif kind == "locY":
|
|
||||||
new_material = Material(name="locY", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
|
||||||
baseColorFactor=[0.42, 0.8, 0.15, 1]))
|
|
||||||
elif kind == "locZ":
|
|
||||||
new_material = Material(name="locZ", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
|
||||||
baseColorFactor=[0.09, 0.55, 0.94, 1]))
|
|
||||||
else:
|
else:
|
||||||
raise ValueError(f"Unknown material kind {kind}")
|
self.image = None # Unused image
|
||||||
self.gltf.materials.append(new_material)
|
self.gltf.meshes[0].primitives = list( # Remove unused faces primitive
|
||||||
return len(self.gltf.materials) - 1
|
filter(lambda p: p.mode != TRIANGLES, self.gltf.meshes[0].primitives))
|
||||||
|
|
||||||
def _add_any(self, vertices: np.ndarray, indices: np.ndarray, tex_coord: np.ndarray, mode: int = TRIANGLES,
|
edges_and_vertices_mat = 0
|
||||||
material: str = "face"):
|
if self.image is not None and (len(self.edge_indices) > 0 or len(self.vertex_indices) > 0):
|
||||||
assert vertices.ndim == 2
|
# Create a material without texture for edges and vertices
|
||||||
assert vertices.shape[1] == 3
|
edges_and_vertices_mat = len(self.gltf.materials)
|
||||||
vertices = vertices.astype(np.float32)
|
new_mat = copy.deepcopy(self.gltf.materials[0])
|
||||||
vertices_blob = vertices.tobytes()
|
new_mat.pbrMetallicRoughness.baseColorTexture = None
|
||||||
|
self.gltf.materials.append(new_mat)
|
||||||
|
|
||||||
assert indices.ndim == 2
|
# Treat edges and vertices the same way
|
||||||
assert indices.shape[1] == 3 and mode == TRIANGLES or indices.shape[1] == 2 and mode == LINE_STRIP or \
|
for (indices, positions, colors, primitive, kind) in [
|
||||||
indices.shape[1] == 1 and mode == POINTS
|
(self.edge_indices, self.edge_positions, self.edge_colors, self._edges_primitive, LINES),
|
||||||
indices = indices.astype(np.uint32)
|
(self.vertex_indices, self.vertex_positions, self.vertex_colors, self._vertices_primitive, POINTS)
|
||||||
indices_blob = indices.flatten().tobytes()
|
]:
|
||||||
|
if len(indices) > 0:
|
||||||
|
primitive.material = edges_and_vertices_mat
|
||||||
|
primitive.indices = len(buffers_list)
|
||||||
|
buffers_list.append(_gen_buffer_metadata(indices, 1))
|
||||||
|
primitive.attributes.POSITION = len(buffers_list)
|
||||||
|
buffers_list.append(_gen_buffer_metadata(positions, 3))
|
||||||
|
primitive.attributes.COLOR_0 = len(buffers_list)
|
||||||
|
buffers_list.append(_gen_buffer_metadata(colors, 4))
|
||||||
|
else:
|
||||||
|
self.gltf.meshes[0].primitives = list( # Remove unused edges primitive
|
||||||
|
filter(lambda p: p.mode != kind, self.gltf.meshes[0].primitives))
|
||||||
|
|
||||||
# Check that all vertices are referenced by the indices
|
if self.image is not None: # Add texture last as it creates a fake accessor that is not added!
|
||||||
assert indices.max() == len(vertices) - 1, f"{indices.max()} != {len(vertices) - 1}"
|
self.gltf.images = [Image(bufferView=len(buffers_list), mimeType=self.image[1])]
|
||||||
assert indices.min() == 0
|
self.gltf.textures = [Texture(source=0, sampler=0)]
|
||||||
assert np.unique(indices.flatten()).size == len(vertices)
|
self.gltf.samplers = [Sampler(magFilter=NEAREST)]
|
||||||
|
self.gltf.materials[0].pbrMetallicRoughness.baseColorTexture = TextureInfo(index=0)
|
||||||
|
buffers_list.append((Accessor(), BufferView(), self.image[0]))
|
||||||
|
|
||||||
assert len(tex_coord) == 0 or tex_coord.ndim == 2
|
# Once all the data is ready, we can concatenate the buffers updating the accessors and views
|
||||||
assert len(tex_coord) == 0 or tex_coord.shape[1] == 2
|
prev_binary_blob = self.gltf.binary_blob() or b''
|
||||||
tex_coord = tex_coord.astype(np.float32)
|
|
||||||
tex_coord_blob = tex_coord.tobytes()
|
|
||||||
|
|
||||||
accessor_base = len(self.gltf.accessors)
|
|
||||||
self.gltf.meshes[0].primitives.append(
|
|
||||||
Primitive(
|
|
||||||
attributes=Attributes(POSITION=accessor_base + 1, TEXCOORD_0=accessor_base + 2)
|
|
||||||
if len(tex_coord) > 0 else Attributes(POSITION=accessor_base + 1),
|
|
||||||
indices=accessor_base,
|
|
||||||
mode=mode,
|
|
||||||
material=self.add_material(material),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
buffer_view_base = len(self.gltf.bufferViews)
|
|
||||||
self.gltf.accessors.extend([it for it in [
|
|
||||||
Accessor(
|
|
||||||
bufferView=buffer_view_base,
|
|
||||||
componentType=UNSIGNED_INT,
|
|
||||||
count=indices.size,
|
|
||||||
type=SCALAR,
|
|
||||||
max=[int(indices.max())],
|
|
||||||
min=[int(indices.min())],
|
|
||||||
),
|
|
||||||
Accessor(
|
|
||||||
bufferView=buffer_view_base + 1,
|
|
||||||
componentType=FLOAT,
|
|
||||||
count=len(vertices),
|
|
||||||
type=VEC3,
|
|
||||||
max=vertices.max(axis=0).tolist(),
|
|
||||||
min=vertices.min(axis=0).tolist(),
|
|
||||||
),
|
|
||||||
Accessor(
|
|
||||||
bufferView=buffer_view_base + 2,
|
|
||||||
componentType=FLOAT,
|
|
||||||
count=len(tex_coord),
|
|
||||||
type=VEC2,
|
|
||||||
max=tex_coord.max(axis=0).tolist(),
|
|
||||||
min=tex_coord.min(axis=0).tolist(),
|
|
||||||
) if len(tex_coord) > 0 else None
|
|
||||||
] if it is not None])
|
|
||||||
|
|
||||||
prev_binary_blob = self.gltf.binary_blob()
|
|
||||||
byte_offset_base = len(prev_binary_blob)
|
byte_offset_base = len(prev_binary_blob)
|
||||||
self.gltf.bufferViews.extend([bv for bv in [
|
for accessor, bufferView, blob in buffers_list:
|
||||||
BufferView(
|
|
||||||
buffer=0,
|
|
||||||
byteOffset=byte_offset_base,
|
|
||||||
byteLength=len(indices_blob),
|
|
||||||
target=ELEMENT_ARRAY_BUFFER,
|
|
||||||
),
|
|
||||||
BufferView(
|
|
||||||
buffer=0,
|
|
||||||
byteOffset=byte_offset_base + len(indices_blob),
|
|
||||||
byteLength=len(vertices_blob),
|
|
||||||
target=ARRAY_BUFFER,
|
|
||||||
),
|
|
||||||
BufferView(
|
|
||||||
buffer=0,
|
|
||||||
byteOffset=byte_offset_base + len(indices_blob) + len(vertices_blob),
|
|
||||||
byteLength=len(tex_coord_blob),
|
|
||||||
target=ARRAY_BUFFER,
|
|
||||||
)
|
|
||||||
] if bv.byteLength > 0])
|
|
||||||
|
|
||||||
self.gltf.set_binary_blob(prev_binary_blob + indices_blob + vertices_blob + tex_coord_blob)
|
if accessor.componentType is not None: # Remove accessor of texture
|
||||||
|
buffer_view_base = len(self.gltf.bufferViews)
|
||||||
|
accessor.bufferView = buffer_view_base
|
||||||
|
self.gltf.accessors.append(accessor)
|
||||||
|
|
||||||
|
bufferView.buffer = 0
|
||||||
|
bufferView.byteOffset = byte_offset_base
|
||||||
|
bufferView.byteLength = len(blob)
|
||||||
|
self.gltf.bufferViews.append(bufferView)
|
||||||
|
|
||||||
|
byte_offset_base += len(blob)
|
||||||
|
prev_binary_blob += blob
|
||||||
|
|
||||||
|
self.gltf.buffers.append(Buffer(byteLength=byte_offset_base))
|
||||||
|
self.gltf.set_binary_blob(prev_binary_blob)
|
||||||
|
|
||||||
|
return self.gltf
|
||||||
|
|
||||||
|
|
||||||
|
def _gen_buffer_metadata(data: List[any], chunk: int) -> Tuple[Accessor, BufferView, bytes]:
|
||||||
|
return Accessor(
|
||||||
|
componentType={1: UNSIGNED_INT, 2: FLOAT, 3: FLOAT, 4: FLOAT}[chunk],
|
||||||
|
count=len(data) // chunk,
|
||||||
|
type={1: SCALAR, 2: VEC2, 3: VEC3, 4: VEC4}[chunk],
|
||||||
|
max=[max(data[i::chunk]) for i in range(chunk)],
|
||||||
|
min=[min(data[i::chunk]) for i in range(chunk)],
|
||||||
|
), BufferView(
|
||||||
|
target={1: ELEMENT_ARRAY_BUFFER, 2: ARRAY_BUFFER, 3: ARRAY_BUFFER, 4: ARRAY_BUFFER}[chunk],
|
||||||
|
), np.array(data, dtype={1: np.uint32, 2: np.float32, 3: np.float32, 4: np.float32}[chunk]).tobytes()
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import asyncio
|
|
||||||
import logging
|
|
||||||
import os
|
import os
|
||||||
from typing import Tuple
|
from typing import Union, Dict
|
||||||
|
|
||||||
from build123d import *
|
from build123d import *
|
||||||
|
|
||||||
ASSETS_DIR = os.getenv('ASSETS_DIR', os.path.join(os.path.dirname(__file__), '..', 'assets'))
|
ASSETS_DIR = os.getenv('ASSETS_DIR', os.path.join(os.path.dirname(__file__), '..', 'assets'))
|
||||||
|
|
||||||
|
|
||||||
def build_logo(text: bool = True) -> Tuple[Part, Location, str]:
|
def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]:
|
||||||
"""Builds the CAD part of the logo"""
|
"""Builds the CAD part of the logo"""
|
||||||
with BuildPart(Plane.XY.offset(50)) as logo_obj:
|
with BuildPart(Plane.XY.offset(50)) as logo_obj:
|
||||||
Box(22, 40, 30)
|
Box(22, 40, 30)
|
||||||
@@ -21,38 +19,41 @@ def build_logo(text: bool = True) -> Tuple[Part, Location, str]:
|
|||||||
Text('Yet Another\nCAD Viewer', 7, font_path='/usr/share/fonts/TTF/OpenSans-Regular.ttf')
|
Text('Yet Another\nCAD Viewer', 7, font_path='/usr/share/fonts/TTF/OpenSans-Regular.ttf')
|
||||||
extrude(amount=1)
|
extrude(amount=1)
|
||||||
|
|
||||||
logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location # Avoid overlapping:
|
logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location
|
||||||
logo_img_location.position = Vector(logo_img_location.position.X - 4e-2, logo_img_location.position.Y,
|
logo_img_location *= Location((0, 0, 4e-2), (0, 0, 90)) # Avoid overlapping and adjust placement
|
||||||
logo_img_location.position.Z)
|
|
||||||
logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg')
|
logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg')
|
||||||
return logo_obj.part, logo_img_location, logo_img_path
|
img_glb_bytes, img_name = image_to_gltf(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_name: img_glb_bytes}
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
import logging
|
||||||
|
|
||||||
logging.basicConfig(level=logging.DEBUG)
|
logging.basicConfig(level=logging.DEBUG)
|
||||||
|
|
||||||
# Start an offline server to export the CAD part of the logo in a way compatible with the frontend
|
testing_server = os.getenv('TESTING_SERVER') is not None
|
||||||
# If this is not set, the server will auto-start on import and show_* calls will provide live updates
|
|
||||||
os.environ['YACV_DISABLE_SERVER'] = '1'
|
if not testing_server:
|
||||||
from yacv_server import show, show_image
|
# 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'
|
||||||
|
|
||||||
|
from yacv_server import export_all, show, image_to_gltf
|
||||||
|
|
||||||
|
# Build the CAD part of the logo
|
||||||
|
logo = build_logo()
|
||||||
|
|
||||||
# Add the CAD part of the logo to the server
|
# Add the CAD part of the logo to the server
|
||||||
logo, img_location, img_path = build_logo()
|
show(*[obj for obj in logo.values()], names=[name for name in logo.keys()])
|
||||||
show(logo, 'base')
|
|
||||||
show(img_location, 'location')
|
|
||||||
show_image(img_path, img_location, 20)
|
|
||||||
|
|
||||||
|
if testing_server:
|
||||||
async def exporter():
|
# remove('location') # Test removing a part
|
||||||
# We need access to the actual server object for advanced features like exporting to file
|
pass
|
||||||
from yacv_server import server
|
else:
|
||||||
for name in server.shown_object_names():
|
# Save the complete logo to multiple GLB files
|
||||||
print(f'Exporting {name} to GLB...')
|
export_all(os.path.join(ASSETS_DIR, 'logo_build'))
|
||||||
with open(os.path.join(ASSETS_DIR, 'logo_build', f'{name}.glb'), 'wb') as f:
|
print('Logo saved!')
|
||||||
f.write(await server.export(name))
|
|
||||||
|
|
||||||
|
|
||||||
# Save the complete logo to multiple GLB files (async required)
|
|
||||||
asyncio.run(exporter())
|
|
||||||
|
|
||||||
print('Logo saved!')
|
|
||||||
|
|||||||
134
yacv_server/myhttp.py
Normal file
134
yacv_server/myhttp.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
import io
|
||||||
|
import os
|
||||||
|
import urllib.parse
|
||||||
|
from http import HTTPStatus
|
||||||
|
from http.server import SimpleHTTPRequestHandler
|
||||||
|
|
||||||
|
from yacv_server.mylogger import logger
|
||||||
|
|
||||||
|
# Find the frontend folder (optional, but recommended)
|
||||||
|
FILE_DIR = os.path.dirname(__file__)
|
||||||
|
FRONTEND_BASE_PATH = os.getenv('FRONTEND_BASE_PATH', os.path.join(FILE_DIR, 'frontend'))
|
||||||
|
if not os.path.exists(FRONTEND_BASE_PATH):
|
||||||
|
if os.path.exists(os.path.join(FILE_DIR, '..', 'dist')): # Fallback to dev build
|
||||||
|
FRONTEND_BASE_PATH = os.path.join(FILE_DIR, '..', 'dist')
|
||||||
|
else:
|
||||||
|
logger.warning('Frontend not found at %s', FRONTEND_BASE_PATH)
|
||||||
|
FRONTEND_BASE_PATH = None
|
||||||
|
|
||||||
|
# Define the API paths (also available at the root path for simplicity)
|
||||||
|
UPDATES_API_PATH = '/api/updates'
|
||||||
|
OBJECTS_API_PATH = '/api/object' # /{name}
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPHandler(SimpleHTTPRequestHandler):
|
||||||
|
yacv: 'yacv.YACV'
|
||||||
|
|
||||||
|
def __init__(self, *args, yacv: 'yacv.YACV', **kwargs):
|
||||||
|
self.yacv = yacv
|
||||||
|
super().__init__(*args, **kwargs, directory=FRONTEND_BASE_PATH)
|
||||||
|
|
||||||
|
def log_message(self, fmt, *args):
|
||||||
|
logger.debug(fmt, *args)
|
||||||
|
|
||||||
|
def end_headers(self):
|
||||||
|
# Add CORS headers to the response
|
||||||
|
self.send_header('Access-Control-Allow-Origin', '*')
|
||||||
|
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||||
|
super().end_headers()
|
||||||
|
|
||||||
|
def translate_path(self, path: str) -> str:
|
||||||
|
"""Translate a path to the local filesystem, adds some basic security checks"""
|
||||||
|
path = super().translate_path(path)
|
||||||
|
path = os.path.realpath(path) # Avoid symlink hacks
|
||||||
|
if self.directory: # Ensure proper subdirectory
|
||||||
|
base = os.path.abspath(self.directory)
|
||||||
|
if not os.path.abspath(path).startswith(base):
|
||||||
|
self.send_error(HTTPStatus.FORBIDDEN, "Path is not in the frontend directory")
|
||||||
|
return ''
|
||||||
|
return path
|
||||||
|
|
||||||
|
def send_head(self):
|
||||||
|
path_parts = self.path.split('?', 1)
|
||||||
|
if len(path_parts) == 1:
|
||||||
|
path_parts.append('')
|
||||||
|
[path, query_str] = path_parts
|
||||||
|
query = urllib.parse.parse_qs(query_str)
|
||||||
|
if path == UPDATES_API_PATH or path == '/' and query.get('api_updates') is not None:
|
||||||
|
return self._api_updates()
|
||||||
|
elif path.startswith(OBJECTS_API_PATH) or path == '/' and query.get('api_object') is not None:
|
||||||
|
if path.startswith(OBJECTS_API_PATH):
|
||||||
|
obj_name = self.path[len(OBJECTS_API_PATH) + 1:]
|
||||||
|
else:
|
||||||
|
obj_name = query.get('api_object').pop()
|
||||||
|
return self._api_object(obj_name)
|
||||||
|
elif path.endswith('/'): # Frontend index.html
|
||||||
|
self.path += 'index.html'
|
||||||
|
return super().send_head()
|
||||||
|
else: # Normal frontend file
|
||||||
|
return super().send_head()
|
||||||
|
|
||||||
|
def _api_updates(self):
|
||||||
|
"""Handles a publish-only websocket connection that send show_object events along with their hashes and URLs"""
|
||||||
|
|
||||||
|
# Keep a shared read lock to know if any frontend is still working before shutting down
|
||||||
|
with self.yacv.frontend_lock.r_locked():
|
||||||
|
|
||||||
|
# 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')
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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."""
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
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 +1 @@
|
|||||||
# TODO: Plugins that can freely modify the GLTF file as it is being built
|
# TODO(if there is interest): Plugins that can freely modify the GLTF file as it is being built
|
||||||
|
|||||||
@@ -1,68 +1,89 @@
|
|||||||
import asyncio
|
import queue
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
from typing import List, TypeVar, \
|
from typing import List, TypeVar, \
|
||||||
Generic, AsyncGenerator
|
Generic, Generator
|
||||||
|
|
||||||
from mylogger import logger
|
from yacv_server.mylogger import logger
|
||||||
|
|
||||||
T = TypeVar('T')
|
T = TypeVar('T')
|
||||||
|
|
||||||
|
_end_of_queue = object()
|
||||||
|
|
||||||
|
|
||||||
class BufferedPubSub(Generic[T]):
|
class BufferedPubSub(Generic[T]):
|
||||||
"""A simple implementation of publish-subscribe pattern using asyncio and buffering all previous events"""
|
"""A simple implementation of publish-subscribe pattern using threading and buffering all previous events"""
|
||||||
|
|
||||||
_buffer: List[T]
|
_buffer: List[T]
|
||||||
_subscribers: List[asyncio.Queue[T]]
|
_buffer_lock: threading.Lock
|
||||||
_lock = asyncio.Lock()
|
_subscribers: List[queue.Queue[T]]
|
||||||
max_buffer_size = 1000
|
_subscribers_lock: threading.Lock
|
||||||
|
max_buffer_size: int
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, max_buffer_size: int = 100):
|
||||||
self._buffer = []
|
self._buffer = []
|
||||||
|
self._buffer_lock = threading.Lock()
|
||||||
self._subscribers = []
|
self._subscribers = []
|
||||||
|
self._subscribers_lock = threading.Lock()
|
||||||
|
self.max_buffer_size = max_buffer_size
|
||||||
|
|
||||||
def publish_nowait(self, event: T):
|
def publish(self, event: T):
|
||||||
"""Publishes an event without blocking (synchronous API does not require locking)"""
|
"""Publishes an event without blocking (synchronous API does not require locking)"""
|
||||||
self._buffer.append(event)
|
with self._buffer_lock:
|
||||||
if len(self._buffer) > self.max_buffer_size:
|
self._buffer.append(event)
|
||||||
self._buffer.pop(0)
|
if len(self._buffer) > self.max_buffer_size:
|
||||||
for q in self._subscribers:
|
self._buffer.pop(0)
|
||||||
q.put_nowait(event)
|
for q in self._subscribers:
|
||||||
|
q.put(event)
|
||||||
|
|
||||||
async def _subscribe(self, include_buffered: bool = True, include_future: bool = True) -> asyncio.Queue[T]:
|
def _subscribe(self, include_buffered: bool = True, include_future: bool = True) -> queue.Queue[T]:
|
||||||
"""Subscribes to events"""
|
"""Subscribes to events"""
|
||||||
q = asyncio.Queue()
|
q = queue.Queue()
|
||||||
async with self._lock:
|
with self._subscribers_lock:
|
||||||
self._subscribers.append(q)
|
self._subscribers.append(q)
|
||||||
logger.debug(f"Subscribed to %s (%d subscribers)", self, len(self._subscribers))
|
logger.debug(f"Subscribed to %s (%d subscribers)", self, len(self._subscribers))
|
||||||
if include_buffered:
|
if include_buffered:
|
||||||
for event in self._buffer:
|
with self._buffer_lock:
|
||||||
await q.put(event)
|
for event in self._buffer:
|
||||||
|
q.put(event)
|
||||||
if not include_future:
|
if not include_future:
|
||||||
await q.put(None)
|
q.put(_end_of_queue)
|
||||||
return q
|
return q
|
||||||
|
|
||||||
async def _unsubscribe(self, q: asyncio.Queue[T]):
|
def _unsubscribe(self, q: queue.Queue[T]):
|
||||||
"""Unsubscribes from events"""
|
"""Unsubscribes from events"""
|
||||||
async with self._lock:
|
with self._subscribers_lock:
|
||||||
self._subscribers.remove(q)
|
self._subscribers.remove(q)
|
||||||
logger.debug(f"Unsubscribed from %s (%d subscribers)", self, len(self._subscribers))
|
logger.debug(f"Unsubscribed from %s (%d subscribers)", self, len(self._subscribers))
|
||||||
|
|
||||||
async def subscribe(self, include_buffered: bool = True, include_future: bool = True) -> AsyncGenerator[T, None]:
|
def subscribe(self, include_buffered: bool = True, include_future: bool = True, yield_timeout: float = 0.0) -> \
|
||||||
"""Subscribes to events as an async generator that yields events and automatically unsubscribes"""
|
Generator[T, None, None]:
|
||||||
q = await self._subscribe(include_buffered, include_future)
|
"""Subscribes to events as an generator that yields events and automatically unsubscribes"""
|
||||||
|
q = self._subscribe(include_buffered, include_future)
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
v = await 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
|
# 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
|
break
|
||||||
yield v
|
yield v
|
||||||
finally: # When aclose() is called
|
finally: # When aclose() is called
|
||||||
await self._unsubscribe(q)
|
self._unsubscribe(q)
|
||||||
|
|
||||||
def buffer(self) -> List[T]:
|
def buffer(self) -> List[T]:
|
||||||
"""Returns a shallow copy of the list of buffered events"""
|
"""Returns a shallow copy of the list of buffered events"""
|
||||||
return self._buffer[:]
|
with self._buffer_lock:
|
||||||
|
return self._buffer[:]
|
||||||
|
|
||||||
def delete(self, event: T):
|
def delete(self, event: T):
|
||||||
"""Deletes an event from the buffer"""
|
"""Deletes an event from the buffer"""
|
||||||
self._buffer.remove(event)
|
with self._buffer_lock:
|
||||||
|
self._buffer.remove(event)
|
||||||
|
|
||||||
|
def clear(self):
|
||||||
|
"""Clears the buffer"""
|
||||||
|
with self._buffer_lock:
|
||||||
|
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()
|
||||||
@@ -1,358 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
import atexit
|
|
||||||
import os
|
|
||||||
import signal
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from threading import Thread
|
|
||||||
from typing import Optional, Dict, Union
|
|
||||||
|
|
||||||
import aiohttp_cors
|
|
||||||
from OCP.TopLoc import TopLoc_Location
|
|
||||||
from OCP.TopoDS import TopoDS_Shape
|
|
||||||
from aiohttp import web
|
|
||||||
from aiohttp_sse import sse_response
|
|
||||||
from build123d import Shape, Axis, Location, Vector
|
|
||||||
from dataclasses_json import dataclass_json
|
|
||||||
|
|
||||||
from cad import get_shape, grab_all_cad, image_to_gltf
|
|
||||||
from mylogger import logger
|
|
||||||
from pubsub import BufferedPubSub
|
|
||||||
from tessellate import _hashcode, tessellate
|
|
||||||
|
|
||||||
# Find the frontend folder (optional, but recommended)
|
|
||||||
FILE_DIR = os.path.dirname(__file__)
|
|
||||||
FRONTEND_BASE_PATH = os.getenv('FRONTEND_BASE_PATH', os.path.join(FILE_DIR, 'frontend'))
|
|
||||||
if not os.path.exists(FRONTEND_BASE_PATH):
|
|
||||||
if os.path.exists(os.path.join(FILE_DIR, '..', 'dist')): # Fallback to dev build
|
|
||||||
FRONTEND_BASE_PATH = os.path.join(FILE_DIR, '..', 'dist')
|
|
||||||
else:
|
|
||||||
logger.warning('Frontend not found at %s', FRONTEND_BASE_PATH)
|
|
||||||
FRONTEND_BASE_PATH = None
|
|
||||||
|
|
||||||
# Define the API paths (also available at the root path for simplicity)
|
|
||||||
UPDATES_API_PATH = '/api/updates'
|
|
||||||
OBJECTS_API_PATH = '/api/object' # /{name}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass_json
|
|
||||||
@dataclass
|
|
||||||
class UpdatesApiData:
|
|
||||||
"""Data sent to the client through the updates API"""
|
|
||||||
name: str
|
|
||||||
"""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"""
|
|
||||||
|
|
||||||
|
|
||||||
class UpdatesApiFullData(UpdatesApiData):
|
|
||||||
obj: Optional[TopoDS_Shape]
|
|
||||||
"""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, obj: Optional[TopoDS_Shape] = None,
|
|
||||||
kwargs: Optional[Dict[str, any]] = None):
|
|
||||||
self.name = name
|
|
||||||
self.hash = hash
|
|
||||||
self.obj = obj
|
|
||||||
self.kwargs = kwargs
|
|
||||||
|
|
||||||
def to_json(self) -> str:
|
|
||||||
# noinspection PyUnresolvedReferences
|
|
||||||
return super().to_json()
|
|
||||||
|
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
|
||||||
async def _index_handler(request: web.Request) -> web.Response:
|
|
||||||
return web.HTTPTemporaryRedirect(location='index.html')
|
|
||||||
|
|
||||||
|
|
||||||
class Server:
|
|
||||||
app = web.Application()
|
|
||||||
runner: web.AppRunner
|
|
||||||
thread: Optional[Thread] = None
|
|
||||||
startup_complete = asyncio.Event()
|
|
||||||
do_shutdown = asyncio.Event()
|
|
||||||
at_least_one_client = asyncio.Event()
|
|
||||||
show_events = BufferedPubSub[UpdatesApiFullData]()
|
|
||||||
object_events: Dict[str, BufferedPubSub[bytes]] = {}
|
|
||||||
object_events_lock = asyncio.Lock()
|
|
||||||
frontend_lock = asyncio.Lock() # To avoid exiting too early while frontend makes requests
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
# --- Routes ---
|
|
||||||
# - APIs
|
|
||||||
self.app.router.add_route('GET', f'{UPDATES_API_PATH}', self._api_updates)
|
|
||||||
self.app.router.add_route('GET', f'{OBJECTS_API_PATH}/{{name}}', self._api_object)
|
|
||||||
# - Single websocket/objects/frontend entrypoint to ease client configuration
|
|
||||||
self.app.router.add_get('/', self._entrypoint)
|
|
||||||
# - Static files from the frontend
|
|
||||||
self.app.router.add_get('/{path:(.*/|)}', _index_handler) # Any folder -> index.html
|
|
||||||
if FRONTEND_BASE_PATH is not None:
|
|
||||||
self.app.router.add_static('/', path=FRONTEND_BASE_PATH, name='static_frontend')
|
|
||||||
# --- CORS ---
|
|
||||||
cors = aiohttp_cors.setup(self.app, defaults={
|
|
||||||
"*": aiohttp_cors.ResourceOptions(
|
|
||||||
allow_credentials=True,
|
|
||||||
expose_headers="*",
|
|
||||||
allow_headers="*",
|
|
||||||
)
|
|
||||||
})
|
|
||||||
for route in list(self.app.router.routes()):
|
|
||||||
cors.add(route)
|
|
||||||
# --- Misc ---
|
|
||||||
self.loop = asyncio.new_event_loop()
|
|
||||||
|
|
||||||
def start(self):
|
|
||||||
"""Starts the web server in the background"""
|
|
||||||
assert self.thread is None, "Server currently running, cannot start another one"
|
|
||||||
# Start the server in a separate daemon thread
|
|
||||||
self.thread = Thread(target=self._run_server, name='yacv_server', daemon=True)
|
|
||||||
signal.signal(signal.SIGINT | signal.SIGTERM, self.stop)
|
|
||||||
atexit.register(self.stop)
|
|
||||||
self.thread.start()
|
|
||||||
logger.info('Server started (requested)...')
|
|
||||||
# Wait for the server to be ready before returning
|
|
||||||
while not self.startup_complete.is_set():
|
|
||||||
time.sleep(0.01)
|
|
||||||
logger.info('Server started (received)...')
|
|
||||||
|
|
||||||
# noinspection PyUnusedLocal
|
|
||||||
def stop(self, *args):
|
|
||||||
"""Stops the web server"""
|
|
||||||
if self.thread is None:
|
|
||||||
print('Cannot stop server because it is not running')
|
|
||||||
return
|
|
||||||
|
|
||||||
if os.getenv('YACV_STOP_EARLY', '') == '':
|
|
||||||
# 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)...')
|
|
||||||
try:
|
|
||||||
while not self.at_least_one_client.is_set():
|
|
||||||
time.sleep(0.01)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
pass
|
|
||||||
|
|
||||||
logger.info('Stopping server (waiting for no more frontend requests)...')
|
|
||||||
acquired = time.time()
|
|
||||||
while time.time() - acquired < 1.0:
|
|
||||||
if self.frontend_lock.locked():
|
|
||||||
acquired = time.time()
|
|
||||||
time.sleep(0.01)
|
|
||||||
|
|
||||||
# Stop the server in the background
|
|
||||||
self.loop.call_soon_threadsafe(lambda *a: self.do_shutdown.set())
|
|
||||||
logger.info('Stopping server (sent)...')
|
|
||||||
|
|
||||||
# Wait for the server to stop gracefully
|
|
||||||
self.thread.join(timeout=30)
|
|
||||||
self.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
|
|
||||||
|
|
||||||
def _run_server(self):
|
|
||||||
"""Runs the web server"""
|
|
||||||
asyncio.set_event_loop(self.loop)
|
|
||||||
self.loop.run_until_complete(self._run_server_async())
|
|
||||||
self.loop.stop()
|
|
||||||
self.loop.close()
|
|
||||||
|
|
||||||
async def _run_server_async(self):
|
|
||||||
"""Runs the web server (async)"""
|
|
||||||
runner = web.AppRunner(self.app)
|
|
||||||
await runner.setup()
|
|
||||||
site = web.TCPSite(runner, os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323)))
|
|
||||||
await site.start()
|
|
||||||
logger.info('Server started (sent)...')
|
|
||||||
self.startup_complete.set()
|
|
||||||
# Wait for a signal to stop the server while running
|
|
||||||
await self.do_shutdown.wait()
|
|
||||||
logger.info('Stopping server (received)...')
|
|
||||||
await runner.shutdown()
|
|
||||||
# await runner.cleanup() # Gets stuck?
|
|
||||||
logger.info('Stopping server (done)...')
|
|
||||||
|
|
||||||
async def _entrypoint(self, request: web.Request) -> web.StreamResponse:
|
|
||||||
"""Main entrypoint to the server, which automatically serves the frontend/updates/objects"""
|
|
||||||
if request.query.get('api_updates', '') != '': # ?api_updates -> updates API
|
|
||||||
return await self._api_updates(request)
|
|
||||||
elif request.query.get('api_object', '') != '': # ?api_object={name} -> object API
|
|
||||||
request.match_info['name'] = request.query['api_object']
|
|
||||||
return await self._api_object(request)
|
|
||||||
else: # Anything else -> frontend index.html
|
|
||||||
return await _index_handler(request)
|
|
||||||
|
|
||||||
async def _api_updates(self, request: web.Request) -> web.StreamResponse:
|
|
||||||
"""Handles a publish-only websocket connection that send show_object events along with their hashes and URLs"""
|
|
||||||
self.at_least_one_client.set()
|
|
||||||
async with sse_response(request) as resp:
|
|
||||||
logger.debug('Client connected: %s', request.remote)
|
|
||||||
resp.ping_interval = 0.1 # HACK: forces flushing of the buffer
|
|
||||||
|
|
||||||
# Send buffered events first, while keeping a lock
|
|
||||||
async with self.frontend_lock:
|
|
||||||
for data in self.show_events.buffer():
|
|
||||||
logger.debug('Sending info about %s to %s: %s', data.name, request.remote, data)
|
|
||||||
# noinspection PyUnresolvedReferences
|
|
||||||
await resp.send(data.to_json())
|
|
||||||
|
|
||||||
# Send future events over the same connection
|
|
||||||
subscription = self.show_events.subscribe(include_buffered=False)
|
|
||||||
try:
|
|
||||||
async for data in subscription:
|
|
||||||
logger.debug('Sending info about %s to %s: %s', data.name, request.remote, data)
|
|
||||||
# noinspection PyUnresolvedReferences
|
|
||||||
await resp.send(data.to_json())
|
|
||||||
finally:
|
|
||||||
await subscription.aclose()
|
|
||||||
logger.debug('Client disconnected: %s', request.remote)
|
|
||||||
|
|
||||||
return resp
|
|
||||||
|
|
||||||
obj_counter = 0
|
|
||||||
|
|
||||||
def _show_common(self, name: Optional[str], hash: str, start: float, obj: Optional[TopoDS_Shape] = None,
|
|
||||||
kwargs=None):
|
|
||||||
name = name or f'object_{self.obj_counter}'
|
|
||||||
self.obj_counter += 1
|
|
||||||
# Remove a previous object with the same name
|
|
||||||
for old_event in self.show_events.buffer():
|
|
||||||
if old_event.name == name:
|
|
||||||
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_nowait(precomputed_info)
|
|
||||||
logger.info('show_object(%s, %s) took %.3f seconds', name, hash, time.time() - start)
|
|
||||||
return precomputed_info
|
|
||||||
|
|
||||||
def show(self, any_object: Union[bytes, TopoDS_Shape, 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)
|
|
||||||
|
|
||||||
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_nowait(gltf)
|
|
||||||
publish_to.publish_nowait(b'') # Signal the end of the stream
|
|
||||||
self.object_events[precomputed_info.name] = publish_to
|
|
||||||
|
|
||||||
def show_image(self, source: str | bytes, center: any, ppmm: int, 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, ppmm, name, save_mime)
|
|
||||||
# Publish it like any other GLTF object
|
|
||||||
self.show_gltf(gltf, name, **kwargs)
|
|
||||||
|
|
||||||
def show_cad(self, obj: Union[TopoDS_Shape, 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)
|
|
||||||
|
|
||||||
def show_cad_all(self, **kwargs):
|
|
||||||
"""Publishes all CAD objects to the server"""
|
|
||||||
for name, obj in grab_all_cad():
|
|
||||||
self.show_cad(obj, name, **kwargs)
|
|
||||||
|
|
||||||
async def _api_object(self, request: web.Request) -> web.Response:
|
|
||||||
"""Returns the object file with the matching name, building it if necessary."""
|
|
||||||
async with self.frontend_lock:
|
|
||||||
# Export the object (or fail if not found)
|
|
||||||
exported_glb = await self.export(request.match_info['name'])
|
|
||||||
|
|
||||||
# Wrap the GLB in a response and return it
|
|
||||||
response = web.Response(body=exported_glb)
|
|
||||||
response.content_type = 'model/gltf-binary'
|
|
||||||
response.headers['Content-Disposition'] = f'attachment; filename="{request.match_info["name"]}.glb"'
|
|
||||||
return response
|
|
||||||
|
|
||||||
def shown_object_names(self) -> list[str]:
|
|
||||||
"""Returns the names of all objects that have been shown"""
|
|
||||||
return list([obj.name for obj in self.show_events.buffer()])
|
|
||||||
|
|
||||||
def _shown_object(self, name: str) -> Optional[UpdatesApiFullData]:
|
|
||||||
"""Returns the object with the given name, if it exists"""
|
|
||||||
for obj in self.show_events.buffer():
|
|
||||||
if obj.name == name:
|
|
||||||
return obj
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def export(self, name: str) -> bytes:
|
|
||||||
"""Export the given previously-shown object to a single GLB file, building it if necessary."""
|
|
||||||
start = time.time()
|
|
||||||
|
|
||||||
# Check that the object to build exists and grab it if it does
|
|
||||||
event = self._shown_object(name)
|
|
||||||
if not event:
|
|
||||||
raise web.HTTPNotFound(text=f'No object named {name} was previously shown')
|
|
||||||
|
|
||||||
# Use the lock to ensure that we don't build the object twice
|
|
||||||
async with self.object_events_lock:
|
|
||||||
# If there are no object events for this name, we need to build the object
|
|
||||||
if name not in self.object_events:
|
|
||||||
# Prepare the pubsub for the object
|
|
||||||
publish_to = BufferedPubSub[bytes]()
|
|
||||||
self.object_events[name] = publish_to
|
|
||||||
|
|
||||||
def _build_object():
|
|
||||||
# Build and publish the object (once)
|
|
||||||
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),
|
|
||||||
edges=event.kwargs.get('edges', True),
|
|
||||||
vertices=event.kwargs.get('vertices', True))
|
|
||||||
glb_list_of_bytes = gltf.save_to_bytes()
|
|
||||||
publish_to.publish_nowait(b''.join(glb_list_of_bytes))
|
|
||||||
logger.info('export(%s) took %.3f seconds, %d parts', name, time.time() - start,
|
|
||||||
len(gltf.meshes[0].primitives))
|
|
||||||
|
|
||||||
# await asyncio.get_running_loop().run_in_executor(None, _build_object)
|
|
||||||
# The previous line has problems with auto-closed loop on script exit
|
|
||||||
# and is cancellable, so instead run blocking code in async context :(
|
|
||||||
logger.debug('Building object %s... %s', name, event.obj)
|
|
||||||
_build_object()
|
|
||||||
|
|
||||||
# In either case return the elements of a subscription to the async generator
|
|
||||||
subscription = self.object_events[name].subscribe()
|
|
||||||
try:
|
|
||||||
return await anext(subscription)
|
|
||||||
finally:
|
|
||||||
await subscription.aclose()
|
|
||||||
|
|
||||||
def export_all(self, folder: str) -> None:
|
|
||||||
"""Export all previously-shown objects to GLB files in the given folder"""
|
|
||||||
import asyncio
|
|
||||||
|
|
||||||
async def _export_all():
|
|
||||||
os.makedirs(folder, exist_ok=True)
|
|
||||||
for name in self.shown_object_names():
|
|
||||||
with open(os.path.join(folder, f'{name}.glb'), 'wb') as f:
|
|
||||||
f.write(await self.export(name))
|
|
||||||
|
|
||||||
asyncio.run(_export_all())
|
|
||||||
@@ -1,25 +1,20 @@
|
|||||||
import hashlib
|
from typing import List, Dict, Tuple
|
||||||
import io
|
|
||||||
import re
|
|
||||||
from typing import List, Dict, Tuple, Union
|
|
||||||
|
|
||||||
from OCP.BRep import BRep_Tool
|
from OCP.BRep import BRep_Tool
|
||||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||||
from OCP.GCPnts import GCPnts_TangentialDeflection
|
from OCP.GCPnts import GCPnts_TangentialDeflection
|
||||||
from OCP.TopExp import TopExp
|
|
||||||
from OCP.TopLoc import TopLoc_Location
|
from OCP.TopLoc import TopLoc_Location
|
||||||
from OCP.TopTools import TopTools_IndexedMapOfShape
|
|
||||||
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex
|
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex
|
||||||
from build123d import Shape, Vertex, Face, Location
|
from build123d import Shape, Vertex, Face, Location
|
||||||
from pygltflib import GLTF2
|
from pygltflib import GLTF2
|
||||||
|
|
||||||
import mylogger
|
from yacv_server.cad import CADCoreLike
|
||||||
from cad import CADLike
|
from yacv_server.gltf import GLTFMgr
|
||||||
from gltf import GLTFMgr
|
from yacv_server.mylogger import logger
|
||||||
|
|
||||||
|
|
||||||
def tessellate(
|
def tessellate(
|
||||||
cad_like: CADLike,
|
cad_like: CADCoreLike,
|
||||||
tolerance: float = 0.1,
|
tolerance: float = 0.1,
|
||||||
angular_tolerance: float = 0.1,
|
angular_tolerance: float = 0.1,
|
||||||
faces: bool = True,
|
faces: bool = True,
|
||||||
@@ -36,8 +31,8 @@ def tessellate(
|
|||||||
shape = Shape(cad_like)
|
shape = Shape(cad_like)
|
||||||
|
|
||||||
# Perform tessellation tasks
|
# Perform tessellation tasks
|
||||||
edge_to_faces: Dict[TopoDS_Edge, List[TopoDS_Face]] = {}
|
edge_to_faces: Dict[str, List[TopoDS_Face]] = {}
|
||||||
vertex_to_faces: Dict[TopoDS_Vertex, List[TopoDS_Face]] = {}
|
vertex_to_faces: Dict[str, List[TopoDS_Face]] = {}
|
||||||
if faces:
|
if faces:
|
||||||
for face in shape.faces():
|
for face in shape.faces():
|
||||||
_tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance)
|
_tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance)
|
||||||
@@ -55,7 +50,7 @@ def tessellate(
|
|||||||
for vertex in shape.vertices():
|
for vertex in shape.vertices():
|
||||||
_tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []))
|
_tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []))
|
||||||
|
|
||||||
return mgr.gltf
|
return mgr.build()
|
||||||
|
|
||||||
|
|
||||||
def _tessellate_face(
|
def _tessellate_face(
|
||||||
@@ -65,12 +60,12 @@ def _tessellate_face(
|
|||||||
angular_tolerance: float = 0.1
|
angular_tolerance: float = 0.1
|
||||||
):
|
):
|
||||||
face = Shape(ocp_face)
|
face = Shape(ocp_face)
|
||||||
face.mesh(tolerance, angular_tolerance)
|
# face.mesh(tolerance, angular_tolerance)
|
||||||
|
tri_mesh = face.tessellate(tolerance, angular_tolerance)
|
||||||
poly = BRep_Tool.Triangulation_s(face.wrapped, TopLoc_Location())
|
poly = BRep_Tool.Triangulation_s(face.wrapped, TopLoc_Location())
|
||||||
if poly is None:
|
if poly is None:
|
||||||
mylogger.logger.warn("No triangulation found for face")
|
logger.warn("No triangulation found for face")
|
||||||
return GLTF2()
|
return GLTF2()
|
||||||
tri_mesh = face.tessellate(tolerance, angular_tolerance)
|
|
||||||
|
|
||||||
# Get UV of each face from the parameters
|
# Get UV of each face from the parameters
|
||||||
uv = [
|
uv = [
|
||||||
@@ -78,7 +73,7 @@ def _tessellate_face(
|
|||||||
for v in (poly.UVNode(i) for i in range(1, poly.NbNodes() + 1))
|
for v in (poly.UVNode(i) for i in range(1, poly.NbNodes() + 1))
|
||||||
]
|
]
|
||||||
|
|
||||||
vertices = [(v.X, v.Y, v.Z) for v in tri_mesh[0]]
|
vertices = tri_mesh[0]
|
||||||
indices = tri_mesh[1]
|
indices = tri_mesh[1]
|
||||||
mgr.add_face(vertices, indices, uv)
|
mgr.add_face(vertices, indices, uv)
|
||||||
|
|
||||||
@@ -91,9 +86,9 @@ def _push_point(v: Tuple[float, float, float], faces: List[TopoDS_Face]) -> Tupl
|
|||||||
push_dir = (push_dir[0] + normal.X, push_dir[1] + normal.Y, push_dir[2] + normal.Z)
|
push_dir = (push_dir[0] + normal.X, push_dir[1] + normal.Y, push_dir[2] + normal.Z)
|
||||||
if push_dir != (0, 0, 0):
|
if push_dir != (0, 0, 0):
|
||||||
# Normalize the push direction by the number of faces and a constant factor
|
# Normalize the push direction by the number of faces and a constant factor
|
||||||
# NOTE: Don't overdo it, or metrics will be wrong
|
# NOTE: Don't overdo it, or metrics will be (more) wrong
|
||||||
n = len(faces) / 1e-3
|
n = 1e-3 / len(faces)
|
||||||
push_dir = (push_dir[0] / n, push_dir[1] / n, push_dir[2] / n)
|
push_dir = (push_dir[0] * n, push_dir[1] * n, push_dir[2] * n)
|
||||||
# Push the vertex by the normal
|
# Push the vertex by the normal
|
||||||
v = (v[0] + push_dir[0], v[1] + push_dir[1], v[2] + push_dir[2])
|
v = (v[0] + push_dir[0], v[1] + push_dir[1], v[2] + push_dir[2])
|
||||||
return v
|
return v
|
||||||
@@ -119,6 +114,9 @@ def _tessellate_edge(
|
|||||||
for i in range(1, discretizer.NbPoints() + 1)
|
for i in range(1, discretizer.NbPoints() + 1)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Convert strip of vertices to a list of pairs of vertices
|
||||||
|
vertices = [(vertices[i], vertices[i + 1]) for i in range(len(vertices) - 1)]
|
||||||
mgr.add_edge(vertices)
|
mgr.add_edge(vertices)
|
||||||
|
|
||||||
|
|
||||||
@@ -127,30 +125,3 @@ def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[Topo
|
|||||||
mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces))
|
mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces))
|
||||||
|
|
||||||
|
|
||||||
def _hashcode(obj: Union[bytes, TopoDS_Shape], **extras) -> str:
|
|
||||||
"""Utility to compute the hash code of a shape recursively without the need to tessellate it"""
|
|
||||||
# NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program
|
|
||||||
# This is best-effort and not guaranteed to be unique
|
|
||||||
hasher = hashlib.md5(usedforsecurity=False)
|
|
||||||
for k, v in extras.items():
|
|
||||||
hasher.update(str(k).encode())
|
|
||||||
hasher.update(str(v).encode())
|
|
||||||
if isinstance(obj, bytes):
|
|
||||||
hasher.update(obj)
|
|
||||||
elif isinstance(obj, TopLoc_Location):
|
|
||||||
sub_data = io.BytesIO()
|
|
||||||
obj.DumpJson(sub_data)
|
|
||||||
hasher.update(sub_data.getvalue())
|
|
||||||
elif isinstance(obj, TopoDS_Shape):
|
|
||||||
map_of_shapes = TopTools_IndexedMapOfShape()
|
|
||||||
TopExp.MapShapes_s(obj, map_of_shapes)
|
|
||||||
for i in range(1, map_of_shapes.Extent() + 1):
|
|
||||||
sub_shape = map_of_shapes.FindKey(i)
|
|
||||||
sub_data = io.BytesIO()
|
|
||||||
TopoDS_Shape.DumpJson(sub_shape, sub_data)
|
|
||||||
val = sub_data.getvalue()
|
|
||||||
val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address
|
|
||||||
hasher.update(val)
|
|
||||||
else:
|
|
||||||
raise ValueError(f'Cannot hash object of type {type(obj)}')
|
|
||||||
return hasher.hexdigest()
|
|
||||||
|
|||||||
343
yacv_server/yacv.py
Normal file
343
yacv_server/yacv.py
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import atexit
|
||||||
|
import copy
|
||||||
|
import inspect
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
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, List, Tuple
|
||||||
|
|
||||||
|
from OCP.TopLoc import TopLoc_Location
|
||||||
|
from OCP.TopoDS import TopoDS_Shape
|
||||||
|
# noinspection PyProtectedMember
|
||||||
|
from build123d import Shape, Axis, Location, Vector
|
||||||
|
from dataclasses_json import dataclass_json
|
||||||
|
|
||||||
|
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.rwlock import RWLock
|
||||||
|
from yacv_server.tessellate import tessellate
|
||||||
|
from yacv_server.cad import _hashcode
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass_json
|
||||||
|
@dataclass
|
||||||
|
class UpdatesApiData:
|
||||||
|
"""Data sent to the client through the updates API"""
|
||||||
|
name: str
|
||||||
|
"""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: 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: YACVSupported
|
||||||
|
"""The OCCT object, if any (not serialized)"""
|
||||||
|
kwargs: Optional[Dict[str, any]]
|
||||||
|
"""The show_object options, if any (not serialized)"""
|
||||||
|
|
||||||
|
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
|
||||||
|
self.is_remove = is_remove
|
||||||
|
self.obj = obj
|
||||||
|
self.kwargs = kwargs
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
# noinspection PyUnresolvedReferences
|
||||||
|
return super().to_json()
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
|
"""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.show_events = BufferedPubSub()
|
||||||
|
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"""
|
||||||
|
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
|
||||||
|
self.server_thread = Thread(target=self._run_server, name='yacv_server', daemon=True)
|
||||||
|
signal.signal(signal.SIGINT | signal.SIGTERM, self.stop)
|
||||||
|
atexit.register(self.stop)
|
||||||
|
self.server_thread.start()
|
||||||
|
logger.info('Server started (requested)...')
|
||||||
|
# Wait for the server to be ready before returning
|
||||||
|
while not self.startup_complete.wait():
|
||||||
|
time.sleep(0.01)
|
||||||
|
logger.info('Server started (received)...')
|
||||||
|
|
||||||
|
# noinspection PyUnusedLocal
|
||||||
|
def stop(self, *args):
|
||||||
|
"""Stops the web server"""
|
||||||
|
if self.server_thread is None:
|
||||||
|
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))
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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()
|
||||||
|
|
||||||
|
# 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"""
|
||||||
|
logger.info('Starting server...')
|
||||||
|
self.server = ThreadingHTTPServer(
|
||||||
|
(os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323))),
|
||||||
|
lambda a, b, c: HTTPHandler(a, b, c, yacv=self))
|
||||||
|
# noinspection HttpUrlsUsage
|
||||||
|
logger.info(f'Serving at http://{self.server.server_name}:{self.server.server_port}')
|
||||||
|
self.startup_complete.set()
|
||||||
|
self.server.serve_forever()
|
||||||
|
|
||||||
|
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(except_names=names)
|
||||||
|
|
||||||
|
# Remove a previous object event with the same name
|
||||||
|
for old_event in self.show_events.buffer():
|
||||||
|
if old_event.name in names:
|
||||||
|
self.show_events.delete(old_event)
|
||||||
|
if old_event.name in self.build_events:
|
||||||
|
del self.build_events[old_event.name]
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
|
||||||
|
logger.info('show %s took %.3f seconds', names, time.time() - start)
|
||||||
|
|
||||||
|
def show_cad_all(self, **kwargs):
|
||||||
|
"""Publishes all CAD objects in the current scope to the server"""
|
||||||
|
all_cad = list(grab_all_cad()) # List for reproducible iteration order
|
||||||
|
self.show(*[cad for _, cad in all_cad], names=[name for name, _ in all_cad], **kwargs)
|
||||||
|
|
||||||
|
def remove(self, name: str):
|
||||||
|
"""Removes a previously-shown object from the scene"""
|
||||||
|
show_events = self._show_events(name)
|
||||||
|
if len(show_events) > 0:
|
||||||
|
# Ensure only the new remove event remains for this name
|
||||||
|
for old_show_event in show_events:
|
||||||
|
self.show_events.delete(old_show_event)
|
||||||
|
|
||||||
|
# Delete any cached object builds
|
||||||
|
with self.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():
|
||||||
|
if event.name not in except_names:
|
||||||
|
self.remove(event.name)
|
||||||
|
|
||||||
|
def shown_object_names(self, apply_removes: bool = True) -> List[str]:
|
||||||
|
"""Returns the names of all objects that have been shown"""
|
||||||
|
res = set()
|
||||||
|
for obj in self.show_events.buffer():
|
||||||
|
if not obj.is_remove or not apply_removes:
|
||||||
|
res.add(obj.name)
|
||||||
|
else:
|
||||||
|
res.discard(obj.name)
|
||||||
|
return list(res)
|
||||||
|
|
||||||
|
def _show_events(self, name: str, apply_removes: bool = True) -> List[UpdatesApiFullData]:
|
||||||
|
"""Returns the show events with the given name"""
|
||||||
|
res = []
|
||||||
|
for event in self.show_events.buffer():
|
||||||
|
if event.name == name:
|
||||||
|
if not event.is_remove or not apply_removes:
|
||||||
|
res.append(event)
|
||||||
|
else:
|
||||||
|
# Also remove the previous events
|
||||||
|
for old_event in res:
|
||||||
|
if old_event.name == event.name:
|
||||||
|
res.remove(old_event)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def export(self, name: str) -> Optional[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
|
||||||
|
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.build_events_lock:
|
||||||
|
# If there are no object events for this name, we need to build the object
|
||||||
|
if name not in self.build_events:
|
||||||
|
logger.debug('Building object %s with hash %s', name, event.hash)
|
||||||
|
|
||||||
|
# Prepare the pubsub for the object
|
||||||
|
publish_to = BufferedPubSub[bytes]()
|
||||||
|
self.build_events[name] = publish_to
|
||||||
|
|
||||||
|
# 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),
|
||||||
|
edges=event.kwargs.get('edges', True),
|
||||||
|
vertices=event.kwargs.get('vertices', True))
|
||||||
|
glb_list_of_bytes = gltf.save_to_bytes()
|
||||||
|
glb_bytes = b''.join(glb_list_of_bytes)
|
||||||
|
publish_to.publish(glb_bytes)
|
||||||
|
logger.info('export(%s) took %.3f seconds, %s', name, time.time() - start,
|
||||||
|
sizeof_fmt(len(glb_bytes)))
|
||||||
|
|
||||||
|
# In either case return the elements of a subscription to the async generator
|
||||||
|
subscription = self.build_events[name].subscribe()
|
||||||
|
try:
|
||||||
|
return next(subscription), event.hash
|
||||||
|
finally:
|
||||||
|
subscription.close()
|
||||||
|
|
||||||
|
def export_all(self, folder: str,
|
||||||
|
export_filter: Callable[[str, Optional[CADCoreLike]], bool] = lambda name, obj: True):
|
||||||
|
"""Export all previously-shown objects to GLB files in the given folder"""
|
||||||
|
os.makedirs(folder, exist_ok=True)
|
||||||
|
for name in self.shown_object_names():
|
||||||
|
if export_filter(name, self._show_events(name)[-1].obj):
|
||||||
|
with open(os.path.join(folder, f'{name}.glb'), 'wb') as f:
|
||||||
|
f.write(self.export(name)[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
|
||||||
|
obj_shape = get_shape(obj, error=False) or obj
|
||||||
|
for frame in inspect.stack()[avoid_levels:]:
|
||||||
|
for key, value in frame.frame.f_locals.items():
|
||||||
|
if get_shape(value, error=False) is obj_shape:
|
||||||
|
return key
|
||||||
|
_find_var_name_count += 1
|
||||||
|
return 'unknown_var_' + str(_find_var_name_count)
|
||||||
|
|
||||||
|
|
||||||
|
def sizeof_fmt(num, suffix="B"):
|
||||||
|
for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"):
|
||||||
|
if abs(num) < 1024.0:
|
||||||
|
return f"{num:3.1f}{unit}{suffix}"
|
||||||
|
num /= 1024.0
|
||||||
|
return f"{num:.1f}Yi{suffix}"
|
||||||
Reference in New Issue
Block a user