mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
Compare commits
290 Commits
v0.1.0-alp
...
v0.8.11
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c5bd2aea0 | ||
|
|
b9c3b34416 | ||
|
|
b2f9880fd0 | ||
|
|
4b0fdd3459 | ||
|
|
503501df4b | ||
|
|
62fa310ec7 | ||
|
|
83cc12e59d | ||
|
|
016db36c50 | ||
|
|
583c244769 | ||
|
|
79919041f2 | ||
|
|
263671b4a9 | ||
|
|
e3fc8f1e74 | ||
|
|
7b63f574a1 | ||
|
|
56929ca136 | ||
|
|
dda5abfc94 | ||
|
|
3f9971a171 | ||
|
|
f4326c5779 | ||
|
|
96e73c0e7f | ||
|
|
2f1d1d51b3 | ||
|
|
fb3027fe66 | ||
|
|
d2b1e04e88 | ||
|
|
8c75f02c57 | ||
|
|
68317b32e4 | ||
|
|
49f91383a0 | ||
|
|
15b0636df9 | ||
|
|
e880a9f53a | ||
|
|
0ab8126e18 | ||
|
|
d9da038a06 | ||
|
|
186ca9d5bc | ||
|
|
739ce7f6c3 | ||
|
|
ba42fcfbd4 | ||
|
|
a0144a5432 | ||
|
|
66fd788398 | ||
|
|
9d2166dbe6 | ||
|
|
66396dfaee | ||
|
|
c539538d74 | ||
|
|
7d0ea24e23 | ||
|
|
c7ec469796 | ||
|
|
0c78964316 | ||
|
|
04c18e5198 | ||
|
|
25f0c04915 | ||
|
|
723efa232e | ||
|
|
49d154eeb0 | ||
|
|
5892c454d3 | ||
|
|
08d89836da | ||
|
|
e5e9493b35 | ||
|
|
775f5d3700 | ||
|
|
1ae0e508a8 | ||
|
|
2c5d73e957 | ||
|
|
6e0afef6bf | ||
|
|
4884030bd6 | ||
|
|
64ce65a568 | ||
|
|
3982706365 | ||
|
|
e71ebd75b3 | ||
|
|
c013916299 | ||
|
|
3787625230 | ||
|
|
ff712be60a | ||
|
|
ab0c1b1482 | ||
|
|
32246a890e | ||
|
|
72afb4b439 | ||
|
|
1b2312f0e5 | ||
|
|
87c6b12365 | ||
|
|
3b4982bb24 | ||
|
|
673814e1dd | ||
|
|
4b59ef2d1a | ||
|
|
cdec62f5b4 | ||
|
|
48ef6d2470 | ||
|
|
74fcc1b6b6 | ||
|
|
36da97e8c8 | ||
|
|
6a8ca13de3 | ||
|
|
60de0833b5 | ||
|
|
263a8e0c14 | ||
|
|
4d825ed763 | ||
|
|
5e3841186b | ||
|
|
18f22407c9 | ||
|
|
14817b6505 | ||
|
|
82b5e305d9 | ||
|
|
da94809cd2 | ||
|
|
933ec17293 | ||
|
|
dc91e4b497 | ||
|
|
9e740db791 | ||
|
|
c26cb78cc4 | ||
|
|
24c892c061 | ||
|
|
ec63c9efdc | ||
|
|
6f90e3c448 | ||
|
|
2205901752 | ||
|
|
ad5bb1b8a3 | ||
|
|
8ad0ffa147 | ||
|
|
96dbda0374 | ||
|
|
90c03ea99a | ||
|
|
50a7efa4ac | ||
|
|
dd6154955c | ||
|
|
d3847aae12 | ||
|
|
c74b695907 | ||
|
|
c720a8c03e | ||
|
|
a05c19c25e | ||
|
|
9437ccd987 | ||
|
|
20300ca2d9 | ||
|
|
42d0e0e158 | ||
|
|
4cd3e0ebfb | ||
|
|
83d34e5f21 | ||
|
|
94e7878c86 | ||
|
|
56b8dbadf7 | ||
|
|
a54873314e | ||
|
|
c7d672875e | ||
|
|
9d261f1718 | ||
|
|
db0f139cf8 | ||
|
|
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 | ||
|
|
8749c708e2 | ||
|
|
9954939aa0 | ||
|
|
8b59a5978e | ||
|
|
fef6a1349c | ||
|
|
817264289f | ||
|
|
b5c4e44eba | ||
|
|
ea4a3bdb06 | ||
|
|
6b771b4375 | ||
|
|
1cbd1987b3 | ||
|
|
37a1c5de1f | ||
|
|
d3fbe254cb | ||
|
|
2475a00622 | ||
|
|
4bd025e7d5 | ||
|
|
94e316472a | ||
|
|
258256912b | ||
|
|
075682ff18 | ||
|
|
cb1088fe71 | ||
|
|
682f41d3f8 | ||
|
|
dad2b4471a | ||
|
|
1c58c3d554 |
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
@@ -12,6 +12,12 @@ updates:
|
||||
interval: "weekly"
|
||||
day: "saturday"
|
||||
time: "09:00"
|
||||
- package-ecosystem: "pip"
|
||||
directory: "/example"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "saturday"
|
||||
time: "09:00"
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/.github/workflows/"
|
||||
schedule:
|
||||
|
||||
35
.github/workflows/autoupdate.yml
vendored
Normal file
35
.github/workflows/autoupdate.yml
vendored
Normal file
@@ -0,0 +1,35 @@
|
||||
on: "pull_request_target"
|
||||
|
||||
permissions:
|
||||
pull-requests: "write"
|
||||
contents: "write"
|
||||
|
||||
jobs:
|
||||
dependabot:
|
||||
runs-on: "ubuntu-latest"
|
||||
# Checking the actor will prevent your Action run failing on non-Dependabot
|
||||
# PRs but also ensures that it only does work for Dependabot PRs.
|
||||
if: "${{ github.actor == 'dependabot[bot]' }}"
|
||||
steps:
|
||||
# This first step will fail if there's no metadata and so the approval
|
||||
# will not occur.
|
||||
- name: "Dependabot metadata"
|
||||
id: "dependabot-metadata"
|
||||
uses: "dependabot/fetch-metadata@v2"
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
# Here the PR gets approved.
|
||||
- uses: "actions/checkout@v4"
|
||||
- name: "Approve a PR"
|
||||
run: "gh pr review --approve $PR_URL"
|
||||
env:
|
||||
PR_URL: "${{ github.event.pull_request.html_url }}"
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
# Finally, this sets the PR to allow auto-merging for patch and minor
|
||||
# updates if all checks pass
|
||||
- name: "Enable auto-merge for Dependabot PRs"
|
||||
#if: "${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}"
|
||||
run: "gh pr merge --auto --squash $PR_URL"
|
||||
env:
|
||||
PR_URL: "${{ github.event.pull_request.html_url }}"
|
||||
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
|
||||
45
.github/workflows/build.yml
vendored
45
.github/workflows/build.yml
vendored
@@ -5,6 +5,12 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "master"
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
type: "string"
|
||||
required: true
|
||||
description: "The ref (branch or tag) to build"
|
||||
|
||||
jobs:
|
||||
|
||||
@@ -13,6 +19,8 @@ jobs:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
with:
|
||||
ref: "${{ inputs.ref }}"
|
||||
- uses: "actions/setup-node@v4"
|
||||
with:
|
||||
cache: "yarn"
|
||||
@@ -21,7 +29,7 @@ jobs:
|
||||
- uses: "actions/upload-artifact@v4"
|
||||
with:
|
||||
name: "frontend"
|
||||
path: "./dist"
|
||||
path: "dist"
|
||||
retention-days: 5
|
||||
|
||||
build-backend:
|
||||
@@ -29,35 +37,52 @@ jobs:
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
- uses: "actions/setup-node@v4"
|
||||
with:
|
||||
cache: "yarn"
|
||||
ref: "${{ inputs.ref }}"
|
||||
- run: "pipx install poetry"
|
||||
- uses: "actions/setup-python@v5"
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: "poetry"
|
||||
- run: "poetry install"
|
||||
- run: "poetry build"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry build"
|
||||
|
||||
build-logo:
|
||||
name: "Build logo"
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
- uses: "actions/setup-node@v4"
|
||||
with:
|
||||
cache: "yarn"
|
||||
ref: "${{ inputs.ref }}"
|
||||
- run: "pipx install poetry"
|
||||
- uses: "actions/setup-python@v5"
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: "poetry"
|
||||
- run: "poetry install"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
||||
- run: "poetry run python yacv_server/logo.py"
|
||||
- run: "cp assets/fox.glb assets/logo_build/fox.glb"
|
||||
- uses: "actions/upload-artifact@v4"
|
||||
with:
|
||||
name: "logo"
|
||||
path: "./assets/logo_build"
|
||||
path: "assets/logo_build"
|
||||
retention-days: 5
|
||||
|
||||
build-example:
|
||||
name: "Build example"
|
||||
runs-on: "ubuntu-latest"
|
||||
steps:
|
||||
- uses: "actions/checkout@v4"
|
||||
with:
|
||||
ref: "${{ inputs.ref }}"
|
||||
- run: "pipx install poetry"
|
||||
- uses: "actions/setup-python@v5"
|
||||
with:
|
||||
python-version: "3.11"
|
||||
cache: "poetry"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
||||
- run: "YACV_DISABLE_SERVER=true poetry run python example/object.py"
|
||||
- uses: "actions/upload-artifact@v4"
|
||||
with:
|
||||
name: "example"
|
||||
path: "export"
|
||||
retention-days: 5
|
||||
52
.github/workflows/deploy.yml
vendored
52
.github/workflows/deploy.yml
vendored
@@ -1,52 +0,0 @@
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v**"
|
||||
workflow_dispatch:
|
||||
|
||||
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
|
||||
permissions:
|
||||
contents: read
|
||||
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:
|
||||
deploy:
|
||||
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: "svenstaro/upload-release-action@v2"
|
||||
with:
|
||||
repo_token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
file: "./public/*"
|
||||
asset_name: "frontend"
|
||||
tag: "${{ github.ref }}"
|
||||
overwrite: true
|
||||
file_glob: true
|
||||
- uses: "actions/configure-pages@v4"
|
||||
- uses: "actions/upload-pages-artifact@v3"
|
||||
with:
|
||||
path: 'public'
|
||||
- id: "deployment"
|
||||
uses: "actions/deploy-pages@v4"
|
||||
|
||||
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"
|
||||
|
||||
34
README.md
34
README.md
@@ -10,35 +10,19 @@ in a web browser.
|
||||
- All [GLTF 2.0](https://www.khronos.org/gltf/) features (textures, PBR materials, animations...).
|
||||
- All [model-viewer](https://modelviewer.dev/) features (smooth controls, augmented reality...).
|
||||
- Load multiple models at once, load external models and even images as quads.
|
||||
- View and interact with topological entities: faces, edges, vertices and locations.
|
||||
- Control clipping planes and transparency of each model.
|
||||
- View and interact with topological entities: faces, edges, vertices and locations.
|
||||
- Select any entity and measure bounding box size and distances.
|
||||
- Fully-featured [static deployment](#static-deployment): just upload the viewer and models to your server.
|
||||
- [Live lazy updates](#live-updates) 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 & demo
|
||||
## Usage
|
||||
|
||||
The [logo](yacv_server/logo.py) also works as an example of how to use the viewer.
|
||||
The [example](example) is a fully working project that shows how to use the viewer.
|
||||
|
||||
### Live updates
|
||||
|
||||
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 `preloadModels=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 `preloadModels=...` 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 `preloadModels` 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/?preloadModels=base.glb&preloadModels=fox.glb&preloadModels=img.jpg.glb&preloadModels=location.glb).
|
||||
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)
|
||||
(or
|
||||
[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/
|
||||
1093
assets/licenses.txt
1093
assets/licenses.txt
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
8
build.py
8
build.py
@@ -2,6 +2,8 @@ import os
|
||||
import subprocess
|
||||
|
||||
if __name__ == "__main__":
|
||||
# When building the backend, make sure the frontend is built first
|
||||
subprocess.run(['yarn', 'install'], check=True)
|
||||
subprocess.run(['yarn', 'build', '--outDir', 'yacv_server/frontend'], check=True)
|
||||
# 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
|
||||
subprocess.run(['yarn', 'install'], check=True)
|
||||
subprocess.run(['yarn', 'build', '--outDir', 'yacv_server/frontend'], check=True)
|
||||
|
||||
2
example/.gitignore
vendored
Normal file
2
example/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/venv/
|
||||
/export/
|
||||
40
example/README.md
Normal file
40
example/README.md
Normal file
@@ -0,0 +1,40 @@
|
||||
# Quickstart of Yet Another CAD Viewer
|
||||
|
||||
## Installation
|
||||
|
||||
1. Download the contents of this folder.
|
||||
2. Assuming you have a recent version of Python 3 installed, install the required packages:
|
||||
|
||||
```bash
|
||||
python -m venv venv
|
||||
. venv/bin/activate # Execute this line every time you change the terminal
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
### Development with hot-reloading
|
||||
|
||||
To start the viewer, open the [GitHub Pages link](https://yeicor-3d.github.io/yet-another-cad-viewer/) of the frontend.
|
||||
It will try to connect to the server at `127.0.0.1:32323` by default (this can be changed with the `preload` query
|
||||
parameter).
|
||||
|
||||
Running `python object.py` is enough to push the model to the viewer. However, the recommended way for developing with
|
||||
minimal latency is to run in cell mode (#%%). This way, the slow imports are only done once, and the server keeps
|
||||
running. After editing the file you can just re-run the cell with the `show_object` call to push the changes to
|
||||
the viewer.
|
||||
|
||||
### Static final deployment
|
||||
|
||||
Once your model is complete, you may want to share it with others using the same viewer.
|
||||
|
||||
You can do so by exporting the model as a .glb file as a last step of your script.
|
||||
This is already done in `object.py` if the environment variable `CI` is set.
|
||||
|
||||
Once you have the `object.glb` file, you can host it on any static file server and share the following link with others:
|
||||
`https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=<link-to-object.glb>`
|
||||
|
||||
For the example model, the build process is set up in [build.yml](../.github/workflows/build.yml), the upload process
|
||||
is set up in [deploy.yml](../.github/workflows/deploy.yml), and the final link is:
|
||||
https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=example.glb
|
||||
|
||||
26
example/object.py
Normal file
26
example/object.py
Normal file
@@ -0,0 +1,26 @@
|
||||
# Optional: enable logging to see what's happening
|
||||
import logging
|
||||
import os
|
||||
|
||||
from build123d import * # Also works with cadquery objects!
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
from yacv_server import show, export_all # Check out other exported methods for more features!
|
||||
|
||||
# %%
|
||||
|
||||
# Create a simple object
|
||||
with BuildPart() as example:
|
||||
Box(10, 10, 5)
|
||||
Cylinder(4, 5, mode=Mode.SUBTRACT)
|
||||
|
||||
# Show it in the frontend with hot-reloading
|
||||
show(example)
|
||||
|
||||
|
||||
# %%
|
||||
|
||||
# If running on CI, export the objects to .glb files for a static deployment
|
||||
if 'CI' in os.environ:
|
||||
export_all('export')
|
||||
2
example/requirements.txt
Normal file
2
example/requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
build123d
|
||||
yacv-server
|
||||
@@ -1,13 +1,13 @@
|
||||
<!--suppress SillyAssignmentJS -->
|
||||
<script setup lang="ts">
|
||||
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef} from "vue";
|
||||
<script lang="ts" setup>
|
||||
import {defineAsyncComponent, provide, type Ref, ref, shallowRef, triggerRef, watch} from "vue";
|
||||
import Sidebar from "./misc/Sidebar.vue";
|
||||
import Loading from "./misc/Loading.vue";
|
||||
import Tools from "./tools/Tools.vue";
|
||||
import Models from "./models/Models.vue";
|
||||
import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs";
|
||||
import {settings} from "./misc/settings";
|
||||
import {NetworkManager, NetworkUpdateEvent} from "./misc/network";
|
||||
import {NetworkManager, NetworkUpdateEvent, NetworkUpdateEventModel} from "./misc/network";
|
||||
import {SceneMgr} from "./misc/scene";
|
||||
import {Document} from "@gltf-transform/core";
|
||||
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());
|
||||
provide('sceneDocument', {sceneDocument});
|
||||
const models: Ref<InstanceType<typeof Models> | null> = ref(null)
|
||||
const tools: Ref<InstanceType<typeof Tools> | null> = ref(null)
|
||||
const disableTap = ref(false);
|
||||
const setDisableTap = (val: boolean) => disableTap.value = val;
|
||||
provide('disableTap', {disableTap, setDisableTap});
|
||||
|
||||
async function onModelLoadRequest(model: NetworkUpdateEvent) {
|
||||
sceneDocument.value = await SceneMgr.loadModel(sceneUrl, sceneDocument.value, model.name, model.url);
|
||||
async function onModelUpdateRequest(event: NetworkUpdateEvent) {
|
||||
// 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?
|
||||
}
|
||||
|
||||
async function onModelRemoveRequest(name: string) {
|
||||
sceneDocument.value = await SceneMgr.removeModel(sceneUrl, sceneDocument.value, name);
|
||||
triggerRef(sceneDocument); // Why not triggered automatically?
|
||||
await onModelUpdateRequest(new NetworkUpdateEvent([new NetworkUpdateEventModel(name, "", null, true)], () => {
|
||||
}));
|
||||
}
|
||||
|
||||
// Set up the load model event listener
|
||||
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
|
||||
for (let model of settings.preloadModels) {
|
||||
for (let model of settings.preload) {
|
||||
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() {
|
||||
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>
|
||||
|
||||
<!-- 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>
|
||||
<v-toolbar-title>Models</v-toolbar-title>
|
||||
</template>
|
||||
<template #toolbar-items>
|
||||
<v-btn icon="" @click="loadModelManual">
|
||||
<svg-icon type="mdi" :path="mdiPlus"/>
|
||||
<svg-icon :path="mdiPlus" type="mdi"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
|
||||
</sidebar>
|
||||
|
||||
<!-- 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>
|
||||
<v-toolbar-title>Tools</v-toolbar-title>
|
||||
</template>
|
||||
<tools :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
|
||||
<tools ref="tools" :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
|
||||
</sidebar>
|
||||
|
||||
</v-layout>
|
||||
|
||||
@@ -5,6 +5,12 @@ import {createVuetify} from 'vuetify';
|
||||
import * as directives from 'vuetify/lib/directives/index.mjs';
|
||||
import 'vuetify/dist/vuetify.css';
|
||||
|
||||
// @ts-ignore
|
||||
if (__APP_NAME__) {
|
||||
// @ts-ignore
|
||||
console.log(`Starting ${__APP_NAME__} v${__APP_VERSION__} (${__APP_GIT_SHA__}${__APP_GIT_DIRTY__ ? "+dirty" : ""})...`);
|
||||
}
|
||||
|
||||
const vuetify = createVuetify({
|
||||
directives,
|
||||
theme: {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import {VContainer, VRow, VCol, VProgressCircular} from "vuetify/lib/components/index.mjs";
|
||||
<script lang="ts" setup>
|
||||
import {VCol, VContainer, VProgressCircular, VRow} from "vuetify/lib/components/index.mjs";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-container>
|
||||
<v-row justify="center" style="height: 100%">
|
||||
<v-col align-self="center">
|
||||
<v-progress-circular indeterminate style="display: block; margin: 0 auto;"/>
|
||||
<v-progress-circular indeterminate style="display: block; margin: 0 auto;"></v-progress-circular>
|
||||
<slot/>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {ref} from "vue";
|
||||
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
|
||||
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
|
||||
@@ -16,22 +16,22 @@ const openIcon = props.side === 'left' ? mdiChevronRight : mdiChevronLeft;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-btn icon @click="opened = !opened" class="open-button" :class="side">
|
||||
<svg-icon type="mdi" :path="openIcon"/>
|
||||
<v-btn :class="side" class="open-button" icon @click="opened = !opened">
|
||||
<svg-icon :path="openIcon" type="mdi"/>
|
||||
</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-items v-if="side == 'right'">
|
||||
<slot name="toolbar-items"></slot>
|
||||
<v-btn icon @click="opened = !opened">
|
||||
<svg-icon type="mdi" :path="mdiClose"/>
|
||||
<svg-icon :path="mdiClose" type="mdi"/>
|
||||
</v-btn>
|
||||
</v-toolbar-items>
|
||||
<slot name="toolbar"></slot>
|
||||
<v-toolbar-items v-if="side == 'left'">
|
||||
<slot name="toolbar-items"></slot>
|
||||
<v-btn icon @click="opened = !opened">
|
||||
<svg-icon type="mdi" :path="mdiClose"/>
|
||||
<svg-icon :path="mdiClose" type="mdi"/>
|
||||
</v-btn>
|
||||
</v-toolbar-items>
|
||||
</v-toolbar>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three';
|
||||
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,
|
||||
vertices: Array<Vector3>
|
||||
} {
|
||||
obj.updateMatrixWorld();
|
||||
let pos: BufferAttribute | InterleavedBufferAttribute = obj.geometry.getAttribute('position');
|
||||
let ind: BufferAttribute | null = obj.geometry.index;
|
||||
if (!ind) {
|
||||
let pos: BufferAttribute | InterleavedBufferAttribute = selInfo.object.geometry.getAttribute('position');
|
||||
let ind: BufferAttribute | null = selInfo.object.geometry.index;
|
||||
if (ind === null) {
|
||||
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
|
||||
for (let i = 0; i < pos.count; i++) {
|
||||
ind.array[i] = i;
|
||||
@@ -18,15 +18,14 @@ function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
|
||||
}
|
||||
let center = new Vector3();
|
||||
let vertices = [];
|
||||
for (let i = 0; i < ind.count; i++) {
|
||||
let index = ind.array[i];
|
||||
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
|
||||
let index = ind.getX(i)
|
||||
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);
|
||||
vertices.push(vertex);
|
||||
}
|
||||
center = center.divideScalar(ind.count);
|
||||
console.log("center", center)
|
||||
center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]);
|
||||
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.
|
||||
* 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>,
|
||||
center: Array<Vector3>,
|
||||
max: Array<Vector3>
|
||||
@@ -46,7 +45,6 @@ export function distances(a: MObject3D, b: MObject3D, scene: ModelScene): {
|
||||
|
||||
// Find the closest and farthest vertices.
|
||||
// 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)
|
||||
let minDistance = Infinity;
|
||||
let minDistanceVertices = [new Vector3(), new Vector3()];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {Document, Scene, type Transform, WebIO, Buffer} from "@gltf-transform/core";
|
||||
import {unpartition} from "@gltf-transform/functions";
|
||||
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
|
||||
import {unpartition, mergeDocuments} from "@gltf-transform/functions";
|
||||
|
||||
let io = new WebIO();
|
||||
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).
|
||||
*/
|
||||
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
|
||||
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
|
||||
await document.transform(dropByName(name));
|
||||
@@ -24,7 +56,8 @@ export async function mergePartial(url: string, name: string, document: Document
|
||||
await newDoc.transform(setNames(name));
|
||||
|
||||
// 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> {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// noinspection JSVoidFunctionReturnValueUsed,JSUnresolvedReference
|
||||
|
||||
import {Document, type TypedArray} from '@gltf-transform/core'
|
||||
import {Vector2} from "three/src/math/Vector2.js"
|
||||
import {Vector3} from "three/src/math/Vector3.js"
|
||||
@@ -26,7 +28,7 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
|
||||
if (rawColors) {
|
||||
colors = doc.createAccessor(name + 'Color')
|
||||
.setArray(new Float32Array(rawColors) as TypedArray)
|
||||
.setType('VEC3')
|
||||
.setType('VEC4')
|
||||
.setBuffer(buffer);
|
||||
}
|
||||
const material = doc.createMaterial(name + 'Material')
|
||||
@@ -39,6 +41,11 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
|
||||
if (rawColors) {
|
||||
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 node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any)
|
||||
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.
|
||||
*/
|
||||
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
|
||||
let rawIndices = [0, 1, 2, 3, 4, 5];
|
||||
let rawPositions = [
|
||||
[0, 0, 0, size.x, 0, 0],
|
||||
[0, 0, 0, 0, size.y, 0],
|
||||
[0, 0, 0, 0, 0, -size.z],
|
||||
0, 0, 0, size.x, 0, 0,
|
||||
0, 0, 0, 0, size.y, 0,
|
||||
0, 0, 0, 0, 0, -size.z,
|
||||
];
|
||||
let rawIndices = [0, 1];
|
||||
let rawColors = [
|
||||
[...(AxesColors.x[0]), ...(AxesColors.x[1])],
|
||||
[...(AxesColors.y[0]), ...(AxesColors.y[1])],
|
||||
[...(AxesColors.z[0]), ...(AxesColors.z[1])],
|
||||
].map(g => g.map(x => x / 255.0));
|
||||
buildSimpleGltf(doc, rawPositions[0], rawIndices, rawColors[0], transform, '__helper_axes');
|
||||
buildSimpleGltf(doc, rawPositions[1], rawIndices, rawColors[1], transform, '__helper_axes');
|
||||
buildSimpleGltf(doc, rawPositions[2], rawIndices, rawColors[2], transform, '__helper_axes');
|
||||
buildSimpleGltf(doc, [0, 0, 0], [0], null, transform, '__helper_axes', WebGL2RenderingContext.POINTS);
|
||||
...(AxesColors.x[0]), 255, ...(AxesColors.x[1]), 255,
|
||||
...(AxesColors.y[0]), 255, ...(AxesColors.y[1]), 255,
|
||||
...(AxesColors.z[0]), 255, ...(AxesColors.z[1]), 255
|
||||
].map(x => x / 255.0);
|
||||
// Axes at (0, 0, 0)
|
||||
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), '__helper_axes');
|
||||
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], new Matrix4(), '__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.
|
||||
* 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
|
||||
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 positive of [1, -1]) {
|
||||
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.y) size2.set(size.x, size.z);
|
||||
if (axis.z) size2.set(size.x, size.y);
|
||||
let transform = baseTransform.clone().multiply(translation).multiply(rotation);
|
||||
newGridPlane(doc, size2, transform, divisions);
|
||||
let transform = new Matrix4().multiply(translation).multiply(rotation);
|
||||
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 rawIndices = [];
|
||||
// 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 + 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";
|
||||
|
||||
export class NetworkUpdateEvent extends Event {
|
||||
const batchTimeout = 250; // ms
|
||||
|
||||
export class NetworkUpdateEventModel {
|
||||
name: 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) {
|
||||
super("update");
|
||||
constructor(name: string, url: string, hash: string | null, isRemove: boolean | null) {
|
||||
this.name = name;
|
||||
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 */
|
||||
export class NetworkManager extends EventTarget {
|
||||
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.
|
||||
@@ -24,8 +43,10 @@ export class NetworkManager extends EventTarget {
|
||||
* Updates will be emitted as "update" events, including the download URL and the model name.
|
||||
*/
|
||||
async load(url: string) {
|
||||
if (url.startsWith("ws://") || url.startsWith("wss://")) {
|
||||
this.monitorWebSocket(url);
|
||||
if (url.startsWith("dev+")) {
|
||||
let baseUrl = new URL(url.slice(4));
|
||||
baseUrl.searchParams.set("api_updates", "true");
|
||||
await this.monitorDevServer(baseUrl);
|
||||
} else {
|
||||
// Get the last part of the URL as the "name" of the model
|
||||
let name = url.split("/").pop();
|
||||
@@ -34,32 +55,100 @@ export class NetworkManager extends EventTarget {
|
||||
let response = await fetch(url, {method: "HEAD"});
|
||||
let hash = response.headers.get("etag");
|
||||
// Only trigger an update if the hash has changed
|
||||
this.foundModel(name, hash, url);
|
||||
this.foundModel(name, hash, url, false);
|
||||
}
|
||||
}
|
||||
|
||||
private monitorWebSocket(url: string) {
|
||||
// WARNING: This will spam the console logs with failed requests when the server is down
|
||||
let ws = new WebSocket(url);
|
||||
ws.onmessage = (event) => {
|
||||
let data = JSON.parse(event.data);
|
||||
console.debug("WebSocket message", data);
|
||||
let urlObj = new URL(url);
|
||||
urlObj.protocol = urlObj.protocol === "ws:" ? "http:" : "https:";
|
||||
urlObj.searchParams.set("api_object", data.name);
|
||||
this.foundModel(data.name, data.hash, urlObj.toString());
|
||||
};
|
||||
ws.onerror = () => ws.close();
|
||||
ws.onclose = () => setTimeout(() => this.monitorWebSocket(url), settings.monitorEveryMs);
|
||||
let timeoutFaster = setTimeout(() => ws.close(), settings.monitorOpenTimeoutMs);
|
||||
ws.onopen = () => clearTimeout(timeoutFaster);
|
||||
private async monitorDevServer(url: URL, stop: () => boolean = () => false) {
|
||||
while (!stop()) {
|
||||
try {
|
||||
// WARNING: This will spam the console logs with failed requests when the server is down
|
||||
const controller = new AbortController();
|
||||
let response = await fetch(url.toString(), {signal: controller.signal});
|
||||
// console.log("Monitoring", url.toString(), response);
|
||||
if (response.status === 200) {
|
||||
let lines = readLinesStreamings(response.body!.getReader());
|
||||
for await (let line of lines) {
|
||||
if (stop()) break;
|
||||
if (!line || !line.startsWith("data:")) continue;
|
||||
let data: { name: string, hash: string, is_remove: boolean | null } = JSON.parse(line.slice(5));
|
||||
// console.debug("WebSocket message", data);
|
||||
let urlObj = new URL(url);
|
||||
urlObj.searchParams.delete("api_updates");
|
||||
urlObj.searchParams.set("api_object", data.name);
|
||||
this.foundModel(data.name, data.hash, urlObj.toString(), data.is_remove, async () => {
|
||||
controller.abort(); // Notify the server that we are done
|
||||
});
|
||||
}
|
||||
} 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) {
|
||||
let prevHash = this.knownObjectHashes[name];
|
||||
if (!hash || hash !== prevHash) {
|
||||
this.knownObjectHashes[name] = hash;
|
||||
this.dispatchEvent(new NetworkUpdateEvent(name, url));
|
||||
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean | null, disconnect: () => void = () => {
|
||||
}) {
|
||||
// console.debug("Found model", name, "with hash", hash, "at", url, "isRemove", isRemove);
|
||||
|
||||
// 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... */
|
||||
export class SceneMgr {
|
||||
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
|
||||
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string): 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 loadNetworkEnd: number;
|
||||
|
||||
// Start merging into the current document, replacing or adding as needed
|
||||
document = await mergePartial(url, name, document);
|
||||
try {
|
||||
// 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) {
|
||||
// Reload the helpers to fit the new model
|
||||
// TODO: Only reload the helpers after a few milliseconds of no more models being added/removed
|
||||
document = await this.reloadHelpers(sceneUrl, document);
|
||||
} else {
|
||||
// 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");
|
||||
if (reloadScene) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
||||
let bb = SceneMgr.getBoundingBox(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);
|
||||
}
|
||||
|
||||
static getBoundingBox(document: Document): Box3 {
|
||||
static getBoundingBox(document: Document): Box3 | null {
|
||||
if (document.getRoot().listNodes().length === 0) return null;
|
||||
// Get bounding box of the model and use it to set the size of the helpers
|
||||
let bbMin: number[] = [1e6, 1e6, 1e6];
|
||||
let bbMax: number[] = [-1e6, -1e6, -1e6];
|
||||
@@ -67,7 +62,7 @@ export class SceneMgr {
|
||||
}
|
||||
|
||||
/** 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();
|
||||
|
||||
// Remove the model from the document
|
||||
@@ -75,12 +70,27 @@ export class SceneMgr {
|
||||
|
||||
console.log("Model", name, "removed in", performance.now() - loadStart, "ms");
|
||||
|
||||
// Reload the helpers to fit the new model (will also show the document)
|
||||
document = await this.reloadHelpers(sceneUrl, document);
|
||||
if (updateHelpers) {
|
||||
// Reload the helpers to fit the new model (will also show the document)
|
||||
document = await this.reloadHelpers(sceneUrl, document, reloadScene);
|
||||
}
|
||||
|
||||
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 */
|
||||
private static async showCurrentDoc(sceneUrl: Ref<string>, document: Document): Promise<Document> {
|
||||
// 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
|
||||
let buffer = await toBuffer(document);
|
||||
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);
|
||||
|
||||
return document;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// These are the default values for the settings, which are overridden below
|
||||
export const settings = {
|
||||
preloadModels: [
|
||||
preload: [
|
||||
// @ts-ignore
|
||||
// new URL('../../assets/fox.glb', import.meta.url).href,
|
||||
// @ts-ignore
|
||||
@@ -10,14 +10,19 @@ export const settings = {
|
||||
// @ts-ignore
|
||||
// new URL('../../assets/logo_build/img.jpg.glb', import.meta.url).href,
|
||||
// Websocket URLs automatically listen for new models from the python backend
|
||||
"ws://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 */
|
||||
monitorEveryMs: 100,
|
||||
monitorOpenTimeoutMs: 100,
|
||||
monitorOpenTimeoutMs: 1000,
|
||||
// ModelViewer settings
|
||||
autoplay: true,
|
||||
autoplay: true, // Global animation toggle
|
||||
arModes: 'webxr scene-viewer quick-look',
|
||||
zoomSensitivity: 0.25,
|
||||
orbitSensitivity: 1,
|
||||
panSensitivity: 1,
|
||||
exposure: 1,
|
||||
shadowIntensity: 0,
|
||||
background: '',
|
||||
@@ -29,15 +34,19 @@ function parseSetting(name: string, value: string): any {
|
||||
if (arrayElem) name = name.slice(0, -2);
|
||||
let prevValue = (settings as any)[name];
|
||||
if (prevValue === undefined) throw new Error(`Unknown setting: ${name}`);
|
||||
if (Array.isArray(prevValue) && !arrayElem) {
|
||||
let toExtend = []
|
||||
if (!firstTimeNames.includes(name)) {
|
||||
firstTimeNames.push(name);
|
||||
if (Array.isArray(prevValue)) {
|
||||
if (!arrayElem) {
|
||||
let toExtend = []
|
||||
if (!firstTimeNames.includes(name)) {
|
||||
firstTimeNames.push(name);
|
||||
} else {
|
||||
toExtend = prevValue;
|
||||
}
|
||||
toExtend.push(parseSetting(name + ".0", value));
|
||||
return toExtend;
|
||||
} else {
|
||||
toExtend = prevValue;
|
||||
prevValue = prevValue[0];
|
||||
}
|
||||
toExtend.push(parseSetting(name + ".0", value));
|
||||
return toExtend;
|
||||
}
|
||||
switch (typeof prevValue) {
|
||||
case 'boolean':
|
||||
@@ -47,7 +56,7 @@ function parseSetting(name: string, value: string): any {
|
||||
case 'string':
|
||||
return value;
|
||||
default:
|
||||
throw new Error(`Unknown setting type: ${typeof prevValue}`);
|
||||
throw new Error(`Unknown setting type: ${typeof prevValue} -- ${prevValue}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
VBtn,
|
||||
VBtnToggle,
|
||||
@@ -12,8 +12,8 @@ import {
|
||||
VTooltip,
|
||||
} from "vuetify/lib/components/index.mjs";
|
||||
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
|
||||
import {Document, Mesh} from "@gltf-transform/core";
|
||||
import {inject, ref, type ShallowRef, watch} from "vue";
|
||||
import {Mesh} from "@gltf-transform/core";
|
||||
import {ref, watch} from "vue";
|
||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||
import {
|
||||
mdiCircleOpacity,
|
||||
@@ -22,16 +22,18 @@ import {
|
||||
mdiRectangle,
|
||||
mdiRectangleOutline,
|
||||
mdiSwapHorizontal,
|
||||
mdiVectorLine,
|
||||
mdiVectorRectangle
|
||||
} from '@mdi/js'
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
import {SceneMgr} from "../misc/scene";
|
||||
import {BackSide, FrontSide} from "three/src/constants.js";
|
||||
import {Box3} from "three/src/math/Box3.js";
|
||||
import {Color} from "three/src/math/Color.js";
|
||||
import {Plane} from "three/src/math/Plane.js";
|
||||
import {Vector3} from "three/src/math/Vector3.js";
|
||||
import type {MObject3D} from "../tools/Selection.vue";
|
||||
import {toLineSegments} from "../misc/lines.js";
|
||||
import {settings} from "../misc/settings.js"
|
||||
|
||||
const props = defineProps<{
|
||||
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"
|
||||
|
||||
// Reactive properties
|
||||
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
|
||||
const opacity = defineModel<number>("opacity", {default: 1});
|
||||
// Count the number of faces, edges and vertices
|
||||
let faceCount = ref(-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 clipPlaneSwappedX = ref(false);
|
||||
const clipPlaneY = ref(1);
|
||||
const clipPlaneSwappedY = ref(false);
|
||||
const clipPlaneZ = ref(1);
|
||||
const clipPlaneSwappedZ = ref(false);
|
||||
const edgeWidth = ref(settings.edgeWidth);
|
||||
|
||||
// Count the number of faces, edges and vertices
|
||||
let faceCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES).length).reduce((a, b) => a + b, 0)
|
||||
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)
|
||||
let vertexCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS).length).reduce((a, b) => a + b, 0)
|
||||
|
||||
// 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)
|
||||
// Misc properties
|
||||
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
|
||||
const opacity = defineModel<number>("opacity", {default: 1});
|
||||
const wireframe = ref(false);
|
||||
|
||||
// Listeners for changes in the properties (or viewer reloads)
|
||||
function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
||||
@@ -67,13 +68,10 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
||||
let scene = props.viewer?.scene;
|
||||
let sceneModel = (scene as any)?._model;
|
||||
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) => {
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
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'
|
||||
if (childIsFace || childIsEdge || childIsVertex) {
|
||||
let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1);
|
||||
@@ -87,16 +85,12 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
|
||||
scene.queueRender()
|
||||
}
|
||||
|
||||
watch(enabledFeatures, onEnabledFeaturesChange);
|
||||
watch(enabledFeatures, onEnabledFeaturesChange, {deep: true});
|
||||
|
||||
function onOpacityChange(newOpacity: number) {
|
||||
let scene = props.viewer?.scene;
|
||||
let sceneModel = (scene as any)?._model;
|
||||
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) => {
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
if (child.material && child.material.opacity !== newOpacity) {
|
||||
@@ -111,7 +105,22 @@ function onOpacityChange(newOpacity: number) {
|
||||
|
||||
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() {
|
||||
let scene = props.viewer?.scene;
|
||||
@@ -125,21 +134,25 @@ function onClipPlanesChange() {
|
||||
if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) {
|
||||
// Global value for all models, once set it cannot be unset (unknown for other models...)
|
||||
props.viewer.renderer.threeRenderer.localClippingEnabled = true;
|
||||
// Due to model-viewer's camera manipulation, the bounding box needs to be transformed
|
||||
bbox = SceneMgr.getBoundingBox(sceneDocument.value);
|
||||
bbox.translate(scene.getTarget());
|
||||
// Get the bounding box containing all features of this model
|
||||
bbox = new Box3();
|
||||
sceneModel.traverse((child: MObject3D) => {
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
bbox.expandByObject(child);
|
||||
}
|
||||
});
|
||||
}
|
||||
sceneModel.traverse((child: MObject3D) => {
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
if (child.material) {
|
||||
if (bbox) {
|
||||
if (bbox?.isEmpty() == false) {
|
||||
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 offsetZ = bbox.min.y + clipPlaneZ.value * (bbox.max.y - bbox.min.y);
|
||||
let offsetY = bbox.min.y + clipPlaneY.value * (bbox.max.y - bbox.min.y);
|
||||
let offsetZ = bbox.min.z + (1 - clipPlaneZ.value) * (bbox.max.z - bbox.min.z);
|
||||
let planes = [
|
||||
new Plane(new Vector3(-1, 0, 0), offsetX),
|
||||
new Plane(new Vector3(0, 0, 1), offsetY),
|
||||
new Plane(new Vector3(0, -1, 0), offsetZ),
|
||||
new Plane(new Vector3(0, -1, 0), offsetY),
|
||||
new Plane(new Vector3(0, 0, 1), -offsetZ),
|
||||
];
|
||||
if (clipPlaneSwappedX.value) planes[0].negate();
|
||||
if (clipPlaneSwappedY.value) planes[1].negate();
|
||||
@@ -165,20 +178,106 @@ watch(clipPlaneZ, onClipPlanesChange);
|
||||
watch(clipPlaneSwappedX, onClipPlanesChange);
|
||||
watch(clipPlaneSwappedY, 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() {
|
||||
let scene = props.viewer?.scene;
|
||||
let sceneModel = (scene as any)?._model;
|
||||
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
|
||||
|
||||
// Count the number of faces, edges and vertices
|
||||
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> = [];
|
||||
sceneModel.traverse((child: MObject3D) => {
|
||||
child.updateMatrixWorld(); // Objects are mostly static, so ensure updated matrices
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
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:
|
||||
// https://threejs.org/examples/?q=clipping#webgl_clipping_stencil
|
||||
// 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.side = BackSide;
|
||||
backChild.material.color = new Color(0.25, 0.25, 0.25)
|
||||
backChild.userData.noHit = true;
|
||||
child.userData.backChild = backChild;
|
||||
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));
|
||||
scene.queueRender()
|
||||
|
||||
// Furthermore...
|
||||
// Enabled features may have been reset after a reload
|
||||
onEnabledFeaturesChange(enabledFeatures.value)
|
||||
// Opacity may have been reset after a reload
|
||||
onOpacityChange(opacity.value)
|
||||
// Wireframe may have been reset after a reload
|
||||
onWireframeChange(wireframe.value)
|
||||
// Clip planes may have been reset after a reload
|
||||
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!!.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>
|
||||
|
||||
<template>
|
||||
<v-expansion-panel :value="modelName">
|
||||
<v-expansion-panel-title expand-icon="hide-this-icon" collapse-icon="hide-this-icon">
|
||||
<v-btn-toggle v-model="enabledFeatures" multiple @click.stop color="surface-light">
|
||||
<v-expansion-panel-title collapse-icon="hide-this-icon" expand-icon="hide-this-icon">
|
||||
<v-btn-toggle v-model="enabledFeatures" color="surface-light" multiple @click.stop>
|
||||
<v-btn icon>
|
||||
<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 icon>
|
||||
<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 icon>
|
||||
<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-toggle>
|
||||
<div class="model-name">{{ modelName }}</div>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon @click.stop="emit('remove')">
|
||||
<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-expansion-panel-title>
|
||||
<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>
|
||||
<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>
|
||||
</v-slider>
|
||||
<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>
|
||||
<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
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-tooltip activator="parent">Swap clip plane X</v-tooltip>
|
||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
||||
v-model="clipPlaneSwappedX">
|
||||
<v-checkbox-btn v-model="clipPlaneSwappedX" falseIcon="mdi-checkbox-blank-outline"
|
||||
trueIcon="mdi-checkbox-marked-outline">
|
||||
<template v-slot:label>
|
||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
||||
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
</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>
|
||||
<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
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
|
||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
||||
v-model="clipPlaneSwappedY">
|
||||
<v-checkbox-btn v-model="clipPlaneSwappedZ" falseIcon="mdi-checkbox-blank-outline"
|
||||
trueIcon="mdi-checkbox-marked-outline">
|
||||
<template v-slot:label>
|
||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
||||
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
</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>
|
||||
<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
|
||||
</template>
|
||||
<template v-slot:append>
|
||||
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
|
||||
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
|
||||
v-model="clipPlaneSwappedZ">
|
||||
<v-checkbox-btn v-model="clipPlaneSwappedY" falseIcon="mdi-checkbox-blank-outline"
|
||||
trueIcon="mdi-checkbox-marked-outline">
|
||||
<template v-slot:label>
|
||||
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
|
||||
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
|
||||
</template>
|
||||
</v-checkbox-btn>
|
||||
</template>
|
||||
@@ -331,11 +444,12 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
|
||||
}
|
||||
|
||||
.model-name {
|
||||
width: 130px;
|
||||
min-height: 1.15em; /* HACK: Avoid eating the bottom of the text when using 1 line */
|
||||
max-height: 2em;
|
||||
width: 179px;
|
||||
font-size: 110%;
|
||||
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;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2; /* https://caniuse.com/?search=line-clamp */
|
||||
-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... */
|
||||
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>
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {VExpansionPanels} from "vuetify/lib/components/index.mjs";
|
||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||
import {Document, Mesh} from "@gltf-transform/core";
|
||||
@@ -36,9 +36,7 @@ function onRemove(mesh: Mesh) {
|
||||
}
|
||||
|
||||
function findModel(name: string) {
|
||||
console.log('Find model', name);
|
||||
if (!expandedNames.value.includes(name)) expandedNames.value.push(name);
|
||||
console.log('Expanded', expandedNames.value);
|
||||
}
|
||||
|
||||
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
|
||||
declare module '@jamescoyle/vue-icon'
|
||||
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
|
||||
// @ts-ignore
|
||||
import licenseText from "../../assets/licenses.txt?raw";
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {onMounted, onUpdated, ref} from "vue";
|
||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
||||
import type {ModelViewerElement} from '@google/model-viewer';
|
||||
|
||||
// Optimized minimal dependencies from three
|
||||
import {Vector3} from "three/src/math/Vector3.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<{ elem: ModelViewerElement | null, scene: ModelScene }>();
|
||||
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> }>();
|
||||
|
||||
function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElement {
|
||||
// noinspection SpellCheckingInspection
|
||||
let gizmo = new OrientationGizmoRaw.default(scene.camera, {
|
||||
size: expectedParent.clientWidth,
|
||||
bubbleSizePrimary: expectedParent.clientWidth / 12,
|
||||
bubbleSizeSeconday: expectedParent.clientWidth / 14,
|
||||
fontSize: (expectedParent.clientWidth / 10) + "px"
|
||||
bubbleSizeSeconday: expectedParent.clientWidth / 12,
|
||||
fontSize: (expectedParent.clientWidth / 10) + "px",
|
||||
});
|
||||
// Make sure all bubbles are labeled
|
||||
for (let bubble of gizmo.bubbles) {
|
||||
bubble.label = bubble.axis.toUpperCase();
|
||||
}
|
||||
// HACK: Swap axes to fake the CAD orientation
|
||||
for (let swap of [["y", "-z"], ["z", "-y"], ["z", "-z"]]) {
|
||||
let indexA = gizmo.bubbles.findIndex((bubble: any) => bubble.axis == swap[0])
|
||||
@@ -33,21 +35,26 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
|
||||
}
|
||||
// Append and listen for events
|
||||
gizmo.onAxisSelected = (axis: { direction: { x: any; y: any; z: any; }; }) => {
|
||||
let lookFrom = scene.getCamera().position.clone();
|
||||
let lookAt = scene.getTarget().clone().add(scene.target.position);
|
||||
let magnitude = lookFrom.clone().sub(lookAt).length()
|
||||
let direction = new Vector3(axis.direction.x, axis.direction.y, axis.direction.z);
|
||||
let newLookFrom = lookAt.clone().add(direction.clone().multiplyScalar(magnitude));
|
||||
//console.log("New camera position", newLookFrom)
|
||||
scene.getCamera().position.copy(newLookFrom);
|
||||
scene.getCamera().lookAt(lookAt);
|
||||
if ((scene as any).__perspectiveCamera) { // HACK: Make the hacky ortho also work
|
||||
(scene as any).__perspectiveCamera.position.copy(newLookFrom);
|
||||
(scene as any).__perspectiveCamera.lookAt(lookAt);
|
||||
if (!props.viewer.elem || !props.viewer.controls) return;
|
||||
// Animate the controls to the new wanted angle
|
||||
const controls = props.viewer.controls;
|
||||
const {theta: curTheta/*, phi: curPhi*/} = (controls as any).goalSpherical;
|
||||
let wantedTheta = NaN;
|
||||
let wantedPhi = NaN;
|
||||
let attempt = 0
|
||||
while ((attempt == 0 || curTheta == wantedTheta) && attempt < 2) {
|
||||
if (attempt > 0) { // Flip the camera if the user clicks on the same axis
|
||||
axis.direction.x = -axis.direction.x;
|
||||
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();
|
||||
requestIdleCallback(() => props.elem?.dispatchEvent(
|
||||
new CustomEvent('camera-change', {detail: {source: 'none'}})))
|
||||
}
|
||||
return gizmo;
|
||||
}
|
||||
@@ -60,16 +67,16 @@ let gizmo: HTMLElement & { update: () => void }
|
||||
function updateGizmo() {
|
||||
if (gizmo.isConnected) {
|
||||
gizmo.update();
|
||||
requestIdleCallback(updateGizmo);
|
||||
requestIdleCallback(updateGizmo, {timeout: 250});
|
||||
}
|
||||
}
|
||||
|
||||
let reinstall = () => {
|
||||
if(!container.value) return;
|
||||
if (!container.value) return;
|
||||
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);
|
||||
requestIdleCallback(updateGizmo); // Low priority updates
|
||||
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
|
||||
}
|
||||
onMounted(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 {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
|
||||
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 {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
|
||||
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 {extrasNameKey} from "../misc/gltf";
|
||||
import {SceneMgr} from "../misc/scene";
|
||||
import {Document} from "@gltf-transform/core";
|
||||
import {AxesColors} from "../misc/helpers";
|
||||
import {distances} from "../misc/distances";
|
||||
import {highlight, highlightUndo, hitToSelectionInfo, type SelectionInfo} from "./selection";
|
||||
|
||||
export type MObject3D = Mesh & {
|
||||
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 emit = defineEmits<{ findModel: [string] }>();
|
||||
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
||||
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 showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
||||
let showDistances = ref<Boolean>(true);
|
||||
|
||||
let mouseDownAt: [number, number] | null = null;
|
||||
let mouseDownTime = 0;
|
||||
let selectFilter = ref('Any (S)');
|
||||
const raycaster = new Raycaster();
|
||||
|
||||
|
||||
let selectionMoveListener = (event: MouseEvent) => {
|
||||
let mouseDownListener = (event: MouseEvent) => {
|
||||
mouseDownAt = [event.clientX, event.clientY];
|
||||
mouseDownTime = performance.now();
|
||||
if (!selectionEnabled.value) return;
|
||||
};
|
||||
|
||||
let selectionListener = (event: MouseEvent) => {
|
||||
let mouseUpListener = (event: MouseEvent) => {
|
||||
// If the mouse moved while clicked (dragging), avoid selection logic
|
||||
if (mouseDownAt) {
|
||||
let [x, y] = mouseDownAt;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -54,15 +60,22 @@ let selectionListener = (event: MouseEvent) => {
|
||||
}
|
||||
|
||||
// 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)') {
|
||||
raycaster.params.Line.threshold = 0.2;
|
||||
raycaster.params.Points.threshold = 0.8;
|
||||
raycaster.params.Line.threshold = paramScale;
|
||||
raycaster.params.Points.threshold = paramScale * 2; // Make vertices easier to select than edges
|
||||
} else if (selectFilter.value === '(E)dges') {
|
||||
raycaster.params.Line.threshold = 0.8;
|
||||
raycaster.params.Line.threshold = paramScale;
|
||||
raycaster.params.Points.threshold = 0.0;
|
||||
} else if (selectFilter.value === '(V)ertices') {
|
||||
raycaster.params.Line.threshold = 0.0;
|
||||
raycaster.params.Points.threshold = 0.8;
|
||||
raycaster.params.Points.threshold = paramScale;
|
||||
} else if (selectFilter.value === '(F)aces') {
|
||||
raycaster.params.Line.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);
|
||||
raycaster.setFromCamera(ndcCoords, scene.camera);
|
||||
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()));
|
||||
}
|
||||
//console.log('Ray', raycaster.ray);
|
||||
@@ -85,31 +98,69 @@ let selectionListener = (event: MouseEvent) => {
|
||||
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
|
||||
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
|
||||
|
||||
// Find all hit objects and select the wanted one based on the filter
|
||||
const hits = raycaster.intersectObject(scene, true);
|
||||
let hit = hits.find((hit: Intersection<Object3D>) => {
|
||||
if (!hit.object || !(hit.object as any).isMesh) return false;
|
||||
const kind = hit.object.type
|
||||
// Find all hit objects and raycast the wanted ones based on the filter
|
||||
let objects: Array<any> = [];
|
||||
scene.traverse((obj) => {
|
||||
const kind = obj.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 hit.object.visible && !hit.object.userData.noHit && kindOk;
|
||||
}) as Intersection<MObject3D> | undefined;
|
||||
//console.log('Hit', hit)
|
||||
if (obj.userData.noHit !== true &&
|
||||
((selectFilter.value === 'Any (S)' && (isFace || isEdge || isVertex)) ||
|
||||
(selectFilter.value === '(F)aces' && isFace) ||
|
||||
(selectFilter.value === '(E)dges' && isEdge) ||
|
||||
(selectFilter.value === '(V)ertices' && isVertex))) {
|
||||
objects.push(obj);
|
||||
}
|
||||
});
|
||||
//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 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
|
||||
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) {
|
||||
deselect(hit)
|
||||
deselect(selInfo)
|
||||
} else {
|
||||
select(hit)
|
||||
select(selInfo)
|
||||
}
|
||||
} else {
|
||||
deselectAll();
|
||||
@@ -125,34 +176,22 @@ let selectionListener = (event: MouseEvent) => {
|
||||
scene.queueRender() // Force rerender of model-viewer
|
||||
}
|
||||
|
||||
function select(hit: Intersection<MObject3D>) {
|
||||
console.log('Selecting', hit.object.name)
|
||||
if (selected.value.find((m) => m.object.name === hit.object.name) === undefined) {
|
||||
selected.value.push(hit);
|
||||
function select(selInfo: SelectionInfo) {
|
||||
// console.log('Selecting', selInfo.object.name)
|
||||
if (selected.value.find((m) => m.getKey() === selInfo.getKey()) === undefined) {
|
||||
selected.value.push(selInfo);
|
||||
}
|
||||
hit.object.material.__prevBaseColorFactor = [
|
||||
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;
|
||||
highlight(selInfo);
|
||||
}
|
||||
|
||||
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) {
|
||||
console.log('Deselecting', hit.object.name)
|
||||
function deselect(selInfo: SelectionInfo, alsoRemove = true) {
|
||||
// console.log('Deselecting', selInfo.object.name)
|
||||
if (alsoRemove) {
|
||||
// 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);
|
||||
}
|
||||
if (hit.object.material.__prevBaseColorFactor) {
|
||||
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;
|
||||
}
|
||||
highlightUndo(selInfo);
|
||||
}
|
||||
|
||||
function deselectAll(alsoRemove = true) {
|
||||
@@ -217,14 +256,29 @@ let onViewerReady = (viewer: typeof ModelViewerWrapperT) => {
|
||||
viewer.onElemReady((elem: ModelViewerElement) => {
|
||||
if (hasListeners) return;
|
||||
hasListeners = true;
|
||||
elem.addEventListener('mouseup', selectionListener);
|
||||
elem.addEventListener('mousedown', selectionMoveListener); // Avoid clicking when dragging
|
||||
elem.addEventListener('load', () => {
|
||||
elem.addEventListener('mousedown', mouseDownListener); // Avoid clicking when dragging
|
||||
elem.addEventListener('mouseup', mouseUpListener);
|
||||
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) {
|
||||
toggleShowBoundingBox();
|
||||
firstLoad = false;
|
||||
} else {
|
||||
updateBoundingBox();
|
||||
}
|
||||
});
|
||||
elem.addEventListener('camera-change', onCameraChange);
|
||||
@@ -249,11 +303,12 @@ function updateBoundingBox() {
|
||||
if (selected.value.length > 0) {
|
||||
bb = new Box3();
|
||||
for (let hit of selected.value) {
|
||||
bb.expandByObject(hit.object);
|
||||
bb.union(hit.getBox())
|
||||
}
|
||||
bb.applyMatrix4(new Matrix4().makeTranslation(props.viewer?.scene.getTarget()));
|
||||
} else {
|
||||
bb = SceneMgr.getBoundingBox(sceneDocument.value);
|
||||
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
|
||||
if (!boundingBox) return; // No models. Should not happen.
|
||||
bb = boundingBox
|
||||
}
|
||||
// Define each edge of the bounding box, to draw a line for each axis
|
||||
let corners = [
|
||||
@@ -293,6 +348,8 @@ function updateBoundingBox() {
|
||||
}
|
||||
let from = new Vector3(...corners[edge[0]]);
|
||||
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 lineCacheKey = JSON.stringify([from, to]);
|
||||
let matchingLine = boundingBoxLines[lineCacheKey];
|
||||
@@ -300,7 +357,7 @@ function updateBoundingBox() {
|
||||
boundingBoxLinesToRemove = boundingBoxLinesToRemove.filter((l) => l !== lineCacheKey);
|
||||
} else {
|
||||
let newLineId = props.viewer?.addLine3D(from, to,
|
||||
to.clone().sub(from).length().toFixed(1) + "mm", {
|
||||
length.toFixed(1) + "mm", {
|
||||
"stroke": "rgb(" + color.join(',') + ")",
|
||||
"stroke-width": "2"
|
||||
});
|
||||
@@ -337,7 +394,7 @@ function updateDistances() {
|
||||
let distanceLinesToRemove = Object.keys(distanceLines);
|
||||
|
||||
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 matchingLine = distanceLines[lineCacheKey];
|
||||
if (matchingLine) {
|
||||
@@ -352,9 +409,7 @@ function updateDistances() {
|
||||
}
|
||||
|
||||
// Add lines (if not already added)
|
||||
let objA = selected.value[0].object;
|
||||
let objB = selected.value[1].object;
|
||||
let {min, center, max} = distances(objA, objB, props.viewer?.scene);
|
||||
let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer?.scene);
|
||||
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(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
|
||||
@@ -368,6 +423,8 @@ function updateDistances() {
|
||||
return;
|
||||
}
|
||||
|
||||
defineExpose({deselect, updateBoundingBox, updateDistances});
|
||||
|
||||
// Add keyboard shortcuts
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if (event.key === 's') {
|
||||
@@ -405,52 +462,41 @@ window.addEventListener('keydown', (event) => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="select-parent">
|
||||
<v-btn icon @click="toggleSelection" :color="selectionEnabled ? 'surface-light' : ''">
|
||||
<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-btn :color="selectionEnabled ? 'surface-light' : ''" icon @click="toggleSelection">
|
||||
<v-tooltip activator="parent">{{ selectionEnabled ? 'Disable (s)election mode' : 'Enable (s)election mode' }}
|
||||
</v-tooltip>
|
||||
</div>
|
||||
<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"/>
|
||||
<svg-icon :path="mdiCursorDefaultClick" type="mdi"/>
|
||||
</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>
|
||||
<svg-icon type="mdi" :path="mdiCubeOutline"/>
|
||||
<svg-icon :path="mdiCubeOutline" type="mdi"/>
|
||||
</v-btn>
|
||||
<v-btn icon @click="toggleShowDistances" :color="showDistances ? 'surface-light' : ''">
|
||||
<v-btn :color="showDistances ? 'surface-light' : ''" icon @click="toggleShowDistances">
|
||||
<v-tooltip activator="parent">
|
||||
{{ showDistances ? 'Hide selection (d)istances' : 'Show (d)istances (when a pair of features is selected)' }}
|
||||
</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiRuler"/>
|
||||
<svg-icon :path="mdiRuler" type="mdi"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* Very hacky styling... */
|
||||
.select-parent {
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.select-parent .v-btn {
|
||||
position: relative;
|
||||
top: -42px;
|
||||
}
|
||||
|
||||
.select-only {
|
||||
display: inline-block;
|
||||
width: calc(100% - 48px);
|
||||
float: right;
|
||||
height: 36px;
|
||||
position: relative;
|
||||
top: -12px;
|
||||
width: calc(100% - 48px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,4 +1,4 @@
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
VBtn,
|
||||
VCard,
|
||||
@@ -16,11 +16,10 @@ import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
|
||||
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
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 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({
|
||||
loader: () => import("./Selection.vue"),
|
||||
@@ -39,10 +38,10 @@ const LicensesDialogContent = defineAsyncComponent({
|
||||
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||
const emit = defineEmits<{ findModel: [string] }>()
|
||||
|
||||
let selection: Ref<Array<Intersection<MObject3D>>> = ref([]);
|
||||
let selectionFaceCount = () => selection.value.filter((s) => s.object.type == "Mesh" || s.object.type == "SkinnedMesh").length
|
||||
let selectionEdgeCount = () => selection.value.filter((s) => s.object.type == "Line").length
|
||||
let selectionVertexCount = () => selection.value.filter((s) => s.object.type == "Points").length
|
||||
let selection: Ref<Array<SelectionInfo>> = ref([]);
|
||||
let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length
|
||||
let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length
|
||||
let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length
|
||||
|
||||
function syncOrthoCamera(force: boolean) {
|
||||
let scene = props.viewer?.scene;
|
||||
@@ -50,19 +49,21 @@ function syncOrthoCamera(force: boolean) {
|
||||
let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera;
|
||||
if (force || perspectiveCam && scene.camera != perspectiveCam) {
|
||||
// Get zoom level from perspective camera
|
||||
let dist = scene.getTarget().distanceToSquared(perspectiveCam.position);
|
||||
let w = scene.aspect * dist ** 1.1 / 4000;
|
||||
let h = dist ** 1.1 / 4000;
|
||||
let lookAtCenter = scene.getTarget().clone().add(scene.target.position);
|
||||
let perspectiveWidthAtCenter =
|
||||
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.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
|
||||
requestAnimationFrame(() => syncOrthoCamera(false));
|
||||
}
|
||||
}
|
||||
|
||||
let toggleProjectionText = ref('PERSP'); // Default to perspective camera
|
||||
function toggleProjection() {
|
||||
async function toggleProjection() {
|
||||
let scene = props.viewer?.scene;
|
||||
if (!scene) return;
|
||||
let prevCam = scene.camera;
|
||||
@@ -77,16 +78,16 @@ function toggleProjection() {
|
||||
scene.queueRender() // Force rerender of model-viewer
|
||||
}
|
||||
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
|
||||
// The camera change may take a few frames to take effect, dispatch the event after a delay
|
||||
requestIdleCallback(() => props.viewer?.elem?.dispatchEvent(
|
||||
new CustomEvent('camera-change', {detail: {source: 'none'}})))
|
||||
// The camera change may take a frame to take effect, dispatch the event after a delay
|
||||
await new Promise((resolve) => requestAnimationFrame(resolve));
|
||||
props.viewer?.elem?.dispatchEvent(new CustomEvent('camera-change', {detail: {source: 'none'}}));
|
||||
}
|
||||
|
||||
async function centerCamera() {
|
||||
let viewerEl: ModelViewerElement | null | undefined = props.viewer?.elem;
|
||||
if (!viewerEl) return;
|
||||
await viewerEl.updateFraming();
|
||||
viewerEl.zoom(3);
|
||||
props.viewer?.scene?.setTarget(0, 0, 0); // Center the target
|
||||
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')
|
||||
}
|
||||
|
||||
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
|
||||
window.addEventListener('keydown', (event) => {
|
||||
@@ -116,35 +126,35 @@ window.addEventListener('keydown', (event) => {
|
||||
</script>
|
||||
|
||||
<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/>
|
||||
<h5>Camera</h5>
|
||||
<v-btn icon @click="toggleProjection"><span class="icon-detail">{{ toggleProjectionText }}</span>
|
||||
<v-tooltip activator="parent">Toggle (P)rojection<br/>(currently
|
||||
{{ toggleProjectionText === 'PERSP' ? 'perspective' : 'orthographic' }})
|
||||
</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiProjector"></svg-icon>
|
||||
<svg-icon :path="mdiProjector" type="mdi"></svg-icon>
|
||||
</v-btn>
|
||||
<v-btn icon @click="centerCamera">
|
||||
<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-divider/>
|
||||
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
|
||||
<selection-component :ref="selectionComp as any" :viewer="props.viewer as any" v-model="selection"
|
||||
@findModel="(name) => emit('findModel', name)"/>
|
||||
<selection-component ref="selectionComp" v-model="selection" :viewer="props.viewer as any"
|
||||
@findModel="(name: string) => emit('findModel', name)"/>
|
||||
<v-divider/>
|
||||
<v-spacer></v-spacer>
|
||||
<h5>Extras</h5>
|
||||
<v-btn icon @click="downloadSceneGlb">
|
||||
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiDownload"/>
|
||||
<svg-icon :path="mdiDownload" type="mdi"/>
|
||||
</v-btn>
|
||||
<v-dialog id="licenses-dialog" fullscreen>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon v-bind="props">
|
||||
<v-tooltip activator="parent">Show Licenses</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiLicense"/>
|
||||
<svg-icon :path="mdiLicense" type="mdi"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
@@ -154,7 +164,7 @@ window.addEventListener('keydown', (event) => {
|
||||
<v-spacer>
|
||||
</v-spacer>
|
||||
<v-btn icon @click="isActive.value = false">
|
||||
<svg-icon type="mdi" :path="mdiClose"/>
|
||||
<svg-icon :path="mdiClose" type="mdi"/>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-card-text>
|
||||
@@ -165,7 +175,7 @@ window.addEventListener('keydown', (event) => {
|
||||
</v-dialog>
|
||||
<v-btn icon @click="openGithub">
|
||||
<v-tooltip activator="parent">Open (G)itHub</v-tooltip>
|
||||
<svg-icon type="mdi" :path="mdiGithub"/>
|
||||
<svg-icon :path="mdiGithub" type="mdi"/>
|
||||
</v-btn>
|
||||
<div ref="statsHolder"></div>
|
||||
</template>
|
||||
@@ -185,4 +195,8 @@ window.addEventListener('keydown', (event) => {
|
||||
position: relative;
|
||||
top: 5px;
|
||||
}
|
||||
</style>
|
||||
|
||||
h5 {
|
||||
font-size: 14px;
|
||||
}
|
||||
</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,44 +1,115 @@
|
||||
<script lang="ts">
|
||||
</script>
|
||||
|
||||
<script setup lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {settings} from "../misc/settings";
|
||||
import {onMounted, inject, type Ref} from "vue";
|
||||
import {$scene, $renderer} from "@google/model-viewer/lib/model-viewer-base";
|
||||
import Loading from "../misc/Loading.vue";
|
||||
import {ref, watch} from "vue";
|
||||
import {inject, onMounted, type Ref, ref, watch} from "vue";
|
||||
import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
|
||||
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 type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
|
||||
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
|
||||
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
|
||||
|
||||
const emit = defineEmits<{ load: [] }>()
|
||||
//@ts-ignore
|
||||
BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
|
||||
//@ts-ignore
|
||||
BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
|
||||
//@ts-ignore
|
||||
Mesh.prototype.raycast = acceleratedRaycast;
|
||||
|
||||
const props = defineProps<{ src: string }>();
|
||||
|
||||
const elem = ref<ModelViewerElement | null>(null);
|
||||
const scene = ref<ModelScene | 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(() => {
|
||||
if (!elem.value) return;
|
||||
elem.value.addEventListener('load', async () => {
|
||||
elem.value.addEventListener('before-render', () => {
|
||||
if (!elem.value) return;
|
||||
// Delete the initial load banner
|
||||
let banner = elem.value.querySelector('.initial-load-banner');
|
||||
if (banner) banner.remove();
|
||||
// Set the scene and renderer
|
||||
// Extract internals of model-viewer in order to hack unsupported features
|
||||
scene.value = elem.value[$scene] as ModelScene;
|
||||
renderer.value = elem.value[$renderer] as Renderer;
|
||||
// Emit the load event
|
||||
emit('load')
|
||||
controls.value = (elem.value as any)[$controls] as SmoothControls;
|
||||
// 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('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 {
|
||||
startHotspot: HTMLElement = document.body
|
||||
endHotspot: HTMLElement = document.body
|
||||
@@ -76,7 +147,7 @@ function addLine3D(p1: Vector3, p2: Vector3, centerText?: string, lineAttrs: { [
|
||||
lineAttrs: lineAttrs
|
||||
};
|
||||
scene.value.queueRender() // Needed to update the hotspots
|
||||
requestIdleCallback(() => onCameraChangeLine(id));
|
||||
requestIdleCallback(() => onCameraChangeLine(id), {timeout: 100});
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -91,13 +162,6 @@ function removeLine3D(id: number): boolean {
|
||||
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);
|
||||
|
||||
function onCameraChangeLine(lineId: number) {
|
||||
@@ -133,42 +197,47 @@ function entries(lines: { [id: number]: Line3DData }): [string, Line3DData][] {
|
||||
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')!!;
|
||||
watch(disableTap, (value) => {
|
||||
// Rerender not auto triggered? This works anyway...
|
||||
if (value) elem.value?.setAttribute('disable-tap', '');
|
||||
else elem.value?.removeAttribute('disable-tap');
|
||||
watch(disableTap, (newDisableTap) => {
|
||||
if (elem.value) elem.value.disableTap = newDisableTap;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- The main 3D model viewer -->
|
||||
<model-viewer ref="elem" style="width: 100%; height: 100%" :src="props.src" alt="The 3D model(s)" camera-controls
|
||||
camera-orbit="30deg 75deg auto" max-camera-orbit="Infinity 180deg auto"
|
||||
min-camera-orbit="-Infinity 0deg 5%" :disable-tap="disableTap" :exposure="settings.exposure"
|
||||
:shadow-intensity="settings.shadowIntensity" interaction-prompt="none" :autoplay="settings.autoplay"
|
||||
:ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :skybox-image="settings.background"
|
||||
:environment-image="settings.background">
|
||||
<slot></slot> <!-- Controls, annotations, etc. -->
|
||||
<loading class="annotation initial-load-banner"></loading>
|
||||
<model-viewer ref="elem" :ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :autoplay="settings.autoplay"
|
||||
:environment-image="settings.background" :exposure="settings.exposure"
|
||||
:orbit-sensitivity="settings.orbitSensitivity" :pan-sensitivity="settings.panSensitivity"
|
||||
:poster="poster" :shadow-intensity="settings.shadowIntensity" :skybox-image="settings.background"
|
||||
:src="props.src" :zoom-sensitivity="settings.zoomSensitivity" alt="The 3D model(s)" camera-controls
|
||||
camera-orbit="30deg 75deg auto" interaction-prompt="none" max-camera-orbit="Infinity 180deg auto"
|
||||
min-camera-orbit="-Infinity 0deg 5%" style="width: 100%; height: 100%">
|
||||
<slot></slot>
|
||||
<!-- Add a progress bar to the top of the model viewer -->
|
||||
<div ref="progressBar" slot="progress-bar" class="progress-bar">
|
||||
<div ref="updateBar" class="update-bar"/>
|
||||
</div>
|
||||
</model-viewer>
|
||||
|
||||
<!-- The SVG overlay for fake 3D lines attached to the model -->
|
||||
<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">
|
||||
<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"/>
|
||||
<g v-if="line.centerText !== undefined">
|
||||
<rect :x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
||||
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2"
|
||||
:width="line.centerTextSize[0] + 8" :height="line.centerTextSize[1] + 4"
|
||||
fill="gray" fill-opacity="0.75" rx="4" ry="4" stroke="black" v-if="line.centerText"/>
|
||||
<text :x="(line.start2D[0] + line.end2D[0]) / 2" :y="(line.start2D[1] + line.end2D[1]) / 2"
|
||||
text-anchor="middle" dominant-baseline="middle" font-size="16" fill="black"
|
||||
:class="'line' + lineId + '_text'" v-if="line.centerText">
|
||||
<rect v-if="line.centerText"
|
||||
:height="line.centerTextSize[1] + 4"
|
||||
:width="line.centerTextSize[0] + 8"
|
||||
:x="(line.start2D[0] + line.end2D[0]) / 2 - line.centerTextSize[0]/2 - 4"
|
||||
:y="(line.start2D[1] + line.end2D[1]) / 2 - line.centerTextSize[1]/2 - 2" fill="gray"
|
||||
fill-opacity="0.75" rx="4" ry="4" stroke="black"/>
|
||||
<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 }}
|
||||
</text>
|
||||
</g>
|
||||
@@ -202,4 +271,30 @@ watch(disableTap, (value) => {
|
||||
height: 100dvh;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
width: 100%;
|
||||
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;
|
||||
}
|
||||
|
||||
.update-bar {
|
||||
background-color: rgba(255, 255, 255, 0.9);
|
||||
width: 0;
|
||||
height: 100%;
|
||||
border-radius: 25px;
|
||||
float: left;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
</style>
|
||||
39
package.json
39
package.json
@@ -1,8 +1,9 @@
|
||||
{
|
||||
"name": "yet-another-cad-viewer",
|
||||
"version": "0.1.0",
|
||||
"version": "0.8.11",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"author": "Yeicor <4929005+Yeicor@users.noreply.github.com>",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -14,31 +15,33 @@
|
||||
"update-licenses": "generate-license-file --input package.json --output assets/licenses.txt --overwrite"
|
||||
},
|
||||
"dependencies": {
|
||||
"@gltf-transform/core": "^3.10.0",
|
||||
"@gltf-transform/functions": "^3.10.0",
|
||||
"@google/model-viewer": "^3.4.0",
|
||||
"@gltf-transform/core": "^4.0.4",
|
||||
"@gltf-transform/extensions": "^4.0.8",
|
||||
"@gltf-transform/functions": "^4.0.8",
|
||||
"@google/model-viewer": "^3.5.0",
|
||||
"@jamescoyle/vue-icon": "^0.1.2",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/svg": "^7.4.47",
|
||||
"three": "^0.160.1",
|
||||
"three": "^0.163.0",
|
||||
"three-mesh-bvh": "^0.8.0",
|
||||
"three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo",
|
||||
"vue": "^3.4.21",
|
||||
"vuetify": "^3.5.7"
|
||||
"vue": "^3.5.7",
|
||||
"vuetify": "^3.7.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.2",
|
||||
"@types/node": "^20.11.24",
|
||||
"@types/three": "^0.160.0",
|
||||
"@vitejs/plugin-vue": "^5.0.3",
|
||||
"@vitejs/plugin-vue-jsx": "^3.1.0",
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/node": "^22.5.5",
|
||||
"@types/three": "^0.163.0",
|
||||
"@vitejs/plugin-vue": "^5.1.4",
|
||||
"@vitejs/plugin-vue-jsx": "^4.0.1",
|
||||
"@vue/tsconfig": "^0.5.1",
|
||||
"buffer": "^5.5.0||^6.0.0",
|
||||
"commander": "^12.0.0",
|
||||
"generate-license-file": "^3.0.1",
|
||||
"npm-run-all2": "^6.1.1",
|
||||
"terser": "^5.28.1",
|
||||
"typescript": "~5.3.0",
|
||||
"vite": "^5.0.11",
|
||||
"vue-tsc": "^2.0.3"
|
||||
"generate-license-file": "^3.5.1",
|
||||
"npm-run-all2": "^6.2.3",
|
||||
"terser": "^5.33.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^5.4.7",
|
||||
"vue-tsc": "^2.1.6"
|
||||
}
|
||||
}
|
||||
|
||||
1050
poetry.lock
generated
1050
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "yacv-server"
|
||||
version = "0.1.0" # TODO: Update automatically by CI on release (also for package.json!)
|
||||
version = "0.8.11"
|
||||
description = "Yet Another CAD Viewer (server)"
|
||||
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
|
||||
license = "MIT"
|
||||
@@ -14,13 +14,7 @@ include = [
|
||||
python = "^3.9"
|
||||
|
||||
# CAD
|
||||
build123d = "^0.4.0"
|
||||
ocp-tessellate = "^2.0.6"
|
||||
|
||||
# Web
|
||||
aiohttp = "^3.9.3"
|
||||
aiohttp-cors = "^0.7.0"
|
||||
aiohttp-devtools = "^1.1.2"
|
||||
build123d = ">=0.5,<0.8"
|
||||
|
||||
# Misc
|
||||
pygltflib = "^1.16.2"
|
||||
|
||||
@@ -8,12 +8,11 @@
|
||||
"frontend/**/__tests__/*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"declaration": false,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,12 @@ import {fileURLToPath, URL} from 'node:url'
|
||||
import {defineConfig} from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import {name, version} from './package.json'
|
||||
import {execSync} from 'child_process'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
base: "",
|
||||
plugins: [
|
||||
vue({
|
||||
template: {
|
||||
@@ -23,7 +26,15 @@ export default defineConfig({
|
||||
}
|
||||
},
|
||||
build: {
|
||||
assetsDir: '.',
|
||||
assetsDir: '.', // Support deploying to a subdirectory using relative URLs
|
||||
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 time
|
||||
|
||||
from aiohttp import web
|
||||
from build123d import Vector
|
||||
from yacv_server.cad import image_to_gltf
|
||||
from yacv_server.yacv import YACV
|
||||
|
||||
from server import Server
|
||||
|
||||
server = Server()
|
||||
yacv = YACV()
|
||||
"""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:
|
||||
# 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
|
||||
# the environment variable YACV_DISABLE_SERVER to a non-empty value
|
||||
server.start()
|
||||
yacv.start()
|
||||
|
||||
# Expose some nice aliases using the default server instance
|
||||
show = server.show
|
||||
show_object = show
|
||||
show_image = server.show_image
|
||||
show_all = server.show_cad_all
|
||||
|
||||
|
||||
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)
|
||||
show = yacv.show
|
||||
show_all = yacv.show_cad_all
|
||||
image_to_gltf = image_to_gltf
|
||||
export_all = yacv.export_all
|
||||
remove = yacv.remove
|
||||
clear = yacv.clear
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
Utilities to work with CAD objects
|
||||
"""
|
||||
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.TopTools import TopTools_IndexedMapOfShape
|
||||
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 """
|
||||
|
||||
# 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):
|
||||
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:
|
||||
raise ValueError(f'Cannot show object of type {type(obj)} (submit issue?)')
|
||||
else:
|
||||
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 """
|
||||
import inspect
|
||||
stack = inspect.stack()
|
||||
shapes = []
|
||||
shapes = set()
|
||||
for frame in stack:
|
||||
for key, value in frame.frame.f_locals.items():
|
||||
shape = get_shape(value, error=False)
|
||||
if shape:
|
||||
shapes.append((key, shape))
|
||||
if shape and shape not in shapes:
|
||||
shapes.add((key, shape))
|
||||
return shapes
|
||||
|
||||
|
||||
def image_to_gltf(source: str | bytes, center: any, ppmm: int, name: Optional[str] = None,
|
||||
save_mime: str = 'image/jpeg') -> Tuple[bytes, str]:
|
||||
"""Convert an image to a GLTF CAD object, indicating the center location and pixels per millimeter."""
|
||||
def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = None, height: Optional[float] = None,
|
||||
name: Optional[str] = None, save_mime: str = 'image/jpeg', power_of_two: bool = True) \
|
||||
-> Tuple[bytes, str]:
|
||||
"""Convert an image to a GLTF CAD object."""
|
||||
from PIL import Image
|
||||
import io
|
||||
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):
|
||||
raise ValueError('Center location not valid')
|
||||
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
|
||||
img = Image.open(source)
|
||||
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_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
|
||||
mgr = GLTFMgr(image=(img_buf, save_mime))
|
||||
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 * img.width / (2 * ppmm) - plane.y_dir * img.height / (2 * ppmm)),
|
||||
vert(plane.origin + plane.x_dir * img.width / (2 * ppmm) + plane.y_dir * img.height / (2 * ppmm)),
|
||||
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 * width / 2 + plane.y_dir * height / 2),
|
||||
vert(plane.origin + plane.x_dir * width / 2 - plane.y_dir * height / 2),
|
||||
vert(plane.origin - plane.x_dir * width / 2 - plane.y_dir * height / 2),
|
||||
], [
|
||||
(0, 2, 1),
|
||||
(0, 3, 2),
|
||||
@@ -118,7 +158,36 @@ def image_to_gltf(source: str | bytes, center: any, ppmm: int, name: Optional[st
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
(0, 1),
|
||||
])
|
||||
], (1, 1, 1, 1))
|
||||
|
||||
# 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:
|
||||
"""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(
|
||||
asset=Asset(generator=f"yacv_server@{importlib.metadata.version('yacv_server')}"),
|
||||
scene=0,
|
||||
scenes=[Scene(nodes=[0])],
|
||||
nodes=[Node(mesh=0)],
|
||||
meshes=[Mesh(primitives=[])],
|
||||
accessors=[],
|
||||
bufferViews=[BufferView(buffer=0, byteLength=len(image[0]), byteOffset=0)],
|
||||
buffers=[Buffer(byteLength=len(image[0]))],
|
||||
samplers=[Sampler(magFilter=NEAREST)],
|
||||
textures=[Texture(source=0, sampler=0)],
|
||||
images=[Image(bufferView=0, mimeType=image[1])],
|
||||
nodes=[Node(mesh=0)], # TODO: Server-side detection of shallow copies --> nodes
|
||||
meshes=[Mesh(primitives=[
|
||||
Primitive(indices=-1, attributes=Attributes(), mode=TRIANGLES, material=0,
|
||||
extras={"face_triangles_end": []}),
|
||||
Primitive(indices=-1, attributes=Attributes(), mode=LINES, material=0,
|
||||
extras={"edge_points_end": []}),
|
||||
Primitive(indices=-1, attributes=Attributes(), mode=POINTS, material=0),
|
||||
])],
|
||||
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]],
|
||||
tex_coord_raw: List[Tuple[float, float]]):
|
||||
"""Add a face to the GLTF as a new primitive of the unique mesh"""
|
||||
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")
|
||||
@property
|
||||
def _faces_primitive(self) -> Primitive:
|
||||
return [p for p in self.gltf.meshes[0].primitives if p.mode == TRIANGLES][0]
|
||||
|
||||
def add_edge(self, vertices_raw: List[Tuple[float, float, float]], mat: str = None):
|
||||
"""Add an edge to the GLTF as a new primitive of the unique mesh"""
|
||||
vertices = np.array([[v[0], v[1], v[2]] for v in vertices_raw], dtype=np.float32)
|
||||
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")
|
||||
@property
|
||||
def _edges_primitive(self) -> Primitive:
|
||||
return [p for p in self.gltf.meshes[0].primitives if p.mode == LINES][0]
|
||||
|
||||
def add_vertex(self, vertex: Tuple[float, float, float]):
|
||||
"""Add a vertex to the GLTF as a new primitive of the unique mesh"""
|
||||
vertices = np.array([[vertex[0], vertex[1], vertex[2]]])
|
||||
indices = np.array([[0]], dtype=np.uint32)
|
||||
tex_coord = np.array([], dtype=np.float32)
|
||||
self._add_any(vertices, indices, tex_coord, mode=POINTS, material="vertex")
|
||||
@property
|
||||
def _vertices_primitive(self) -> Primitive:
|
||||
return [p for p in self.gltf.meshes[0].primitives if p.mode == POINTS][0]
|
||||
|
||||
def add_face(self, vertices_raw: List[Vector], indices_raw: List[Tuple[int, int, int]],
|
||||
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):
|
||||
"""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
|
||||
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.y_dir)], mat="locY")
|
||||
self.add_edge([vert(pl.origin), vert(pl.origin + pl.z_dir)], mat="locZ")
|
||||
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))], color=(0.42, 0.8, 0.15, 1))
|
||||
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:
|
||||
"""It is important to use a different material for each primitive to be able to change them at runtime"""
|
||||
new_material: Material
|
||||
if kind == "face":
|
||||
new_material = Material(name="face", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
||||
baseColorTexture=TextureInfo(index=0), baseColorFactor=[1, 1, 0.5, 1]), doubleSided=True)
|
||||
elif kind == "edge":
|
||||
new_material = Material(name="edge", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
||||
baseColorFactor=[0, 0, 0.5, 1]))
|
||||
elif kind == "vertex":
|
||||
new_material = Material(name="vertex", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
|
||||
baseColorFactor=[0, 0.3, 0.3, 1]))
|
||||
elif kind == "locX":
|
||||
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]))
|
||||
def build(self) -> GLTF2:
|
||||
"""Merge the intermediate data into the GLTF object and return it"""
|
||||
buffers_list: List[Tuple[Accessor, BufferView, bytes]] = []
|
||||
|
||||
if len(self.face_indices) > 0:
|
||||
self._faces_primitive.indices = len(buffers_list)
|
||||
buffers_list.append(_gen_buffer_metadata(self.face_indices, 1))
|
||||
self._faces_primitive.attributes.POSITION = len(buffers_list)
|
||||
buffers_list.append(_gen_buffer_metadata(self.face_positions, 3))
|
||||
self._faces_primitive.attributes.TEXCOORD_0 = len(buffers_list)
|
||||
buffers_list.append(_gen_buffer_metadata(self.face_tex_coords, 2))
|
||||
self._faces_primitive.attributes.COLOR_0 = len(buffers_list)
|
||||
buffers_list.append(_gen_buffer_metadata(self.face_colors, 4))
|
||||
else:
|
||||
raise ValueError(f"Unknown material kind {kind}")
|
||||
self.gltf.materials.append(new_material)
|
||||
return len(self.gltf.materials) - 1
|
||||
self.image = None # Unused image
|
||||
self.gltf.meshes[0].primitives = list( # Remove unused faces primitive
|
||||
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,
|
||||
material: str = "face"):
|
||||
assert vertices.ndim == 2
|
||||
assert vertices.shape[1] == 3
|
||||
vertices = vertices.astype(np.float32)
|
||||
vertices_blob = vertices.tobytes()
|
||||
edges_and_vertices_mat = 0
|
||||
if self.image is not None and (len(self.edge_indices) > 0 or len(self.vertex_indices) > 0):
|
||||
# Create a material without texture for edges and vertices
|
||||
edges_and_vertices_mat = len(self.gltf.materials)
|
||||
new_mat = copy.deepcopy(self.gltf.materials[0])
|
||||
new_mat.pbrMetallicRoughness.baseColorTexture = None
|
||||
self.gltf.materials.append(new_mat)
|
||||
|
||||
assert indices.ndim == 2
|
||||
assert indices.shape[1] == 3 and mode == TRIANGLES or indices.shape[1] == 2 and mode == LINE_STRIP or \
|
||||
indices.shape[1] == 1 and mode == POINTS
|
||||
indices = indices.astype(np.uint32)
|
||||
indices_blob = indices.flatten().tobytes()
|
||||
# Treat edges and vertices the same way
|
||||
for (indices, positions, colors, primitive, kind) in [
|
||||
(self.edge_indices, self.edge_positions, self.edge_colors, self._edges_primitive, LINES),
|
||||
(self.vertex_indices, self.vertex_positions, self.vertex_colors, self._vertices_primitive, POINTS)
|
||||
]:
|
||||
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
|
||||
assert indices.max() == len(vertices) - 1, f"{indices.max()} != {len(vertices) - 1}"
|
||||
assert indices.min() == 0
|
||||
assert np.unique(indices.flatten()).size == len(vertices)
|
||||
if self.image is not None: # Add texture last as it creates a fake accessor that is not added!
|
||||
self.gltf.images = [Image(bufferView=len(buffers_list), mimeType=self.image[1])]
|
||||
self.gltf.textures = [Texture(source=0, sampler=0)]
|
||||
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
|
||||
assert len(tex_coord) == 0 or tex_coord.shape[1] == 2
|
||||
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()
|
||||
# Once all the data is ready, we can concatenate the buffers updating the accessors and views
|
||||
prev_binary_blob = self.gltf.binary_blob() or b''
|
||||
byte_offset_base = len(prev_binary_blob)
|
||||
self.gltf.bufferViews.extend([bv for bv in [
|
||||
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])
|
||||
for accessor, bufferView, blob in buffers_list:
|
||||
|
||||
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
|
||||
from typing import Tuple
|
||||
from typing import Union, Dict
|
||||
|
||||
from build123d import *
|
||||
|
||||
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"""
|
||||
with BuildPart(Plane.XY.offset(50)) as logo_obj:
|
||||
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')
|
||||
extrude(amount=1)
|
||||
|
||||
logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location # Avoid overlapping:
|
||||
logo_img_location.position = Vector(logo_img_location.position.X - 4e-2, logo_img_location.position.Y,
|
||||
logo_img_location.position.Z)
|
||||
logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location
|
||||
logo_img_location *= Location((0, 0, 4e-2), (0, 0, 90)) # Avoid overlapping and adjust placement
|
||||
|
||||
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__":
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
|
||||
# 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'] = '1'
|
||||
from yacv_server import show, show_image
|
||||
testing_server = os.getenv('TESTING_SERVER') is not None
|
||||
|
||||
if not testing_server:
|
||||
# Start an offline server to export the CAD part of the logo in a way compatible with the frontend
|
||||
# If this is not set, the server will auto-start on import and show_* calls will provide live updates
|
||||
os.environ['YACV_DISABLE_SERVER'] = 'True'
|
||||
|
||||
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
|
||||
logo, img_location, img_path = build_logo()
|
||||
show(logo, 'base')
|
||||
show(img_location, 'location')
|
||||
show_image(img_path, img_location, 20)
|
||||
show(*[obj for obj in logo.values()], names=[name for name in logo.keys()])
|
||||
|
||||
|
||||
async def exporter():
|
||||
# We need access to the actual server object for advanced features like exporting to file
|
||||
from yacv_server import server
|
||||
for name in server.shown_object_names():
|
||||
print(f'Exporting {name} to GLB...')
|
||||
with open(os.path.join(ASSETS_DIR, 'logo_build', f'{name}.glb'), 'wb') as f:
|
||||
f.write(await server.export(name))
|
||||
|
||||
|
||||
# Save the complete logo to multiple GLB files (async required)
|
||||
asyncio.run(exporter())
|
||||
|
||||
print('Logo saved!')
|
||||
if testing_server:
|
||||
# remove('location') # Test removing a part
|
||||
pass
|
||||
else:
|
||||
# Save the complete logo to multiple GLB files
|
||||
export_all(os.path.join(ASSETS_DIR, 'logo_build'))
|
||||
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,64 +1,89 @@
|
||||
import asyncio
|
||||
import queue
|
||||
import queue
|
||||
import threading
|
||||
from typing import List, TypeVar, \
|
||||
Generic, AsyncGenerator
|
||||
Generic, Generator
|
||||
|
||||
from mylogger import logger
|
||||
from yacv_server.mylogger import logger
|
||||
|
||||
T = TypeVar('T')
|
||||
|
||||
_end_of_queue = object()
|
||||
|
||||
|
||||
class BufferedPubSub(Generic[T]):
|
||||
"""A simple implementation of publish-subscribe pattern using asyncio and buffering all previous events"""
|
||||
"""A simple implementation of publish-subscribe pattern using threading and buffering all previous events"""
|
||||
|
||||
_buffer: List[T]
|
||||
_subscribers: List[asyncio.Queue[T]]
|
||||
_lock = asyncio.Lock()
|
||||
max_buffer_size = 1000
|
||||
_buffer_lock: threading.Lock
|
||||
_subscribers: List[queue.Queue[T]]
|
||||
_subscribers_lock: threading.Lock
|
||||
max_buffer_size: int
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, max_buffer_size: int = 100):
|
||||
self._buffer = []
|
||||
self._buffer_lock = threading.Lock()
|
||||
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)"""
|
||||
self._buffer.append(event)
|
||||
if len(self._buffer) > self.max_buffer_size:
|
||||
self._buffer.pop(0)
|
||||
for q in self._subscribers:
|
||||
q.put_nowait(event)
|
||||
with self._buffer_lock:
|
||||
self._buffer.append(event)
|
||||
if len(self._buffer) > self.max_buffer_size:
|
||||
self._buffer.pop(0)
|
||||
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"""
|
||||
q = asyncio.Queue()
|
||||
async with self._lock:
|
||||
q = queue.Queue()
|
||||
with self._subscribers_lock:
|
||||
self._subscribers.append(q)
|
||||
logger.debug(f"Subscribed to %s (%d subscribers)", self, len(self._subscribers))
|
||||
if include_buffered:
|
||||
for event in self._buffer:
|
||||
await q.put(event)
|
||||
with self._buffer_lock:
|
||||
for event in self._buffer:
|
||||
q.put(event)
|
||||
if not include_future:
|
||||
await q.put(None)
|
||||
q.put(_end_of_queue)
|
||||
return q
|
||||
|
||||
async def _unsubscribe(self, q: asyncio.Queue[T]):
|
||||
def _unsubscribe(self, q: queue.Queue[T]):
|
||||
"""Unsubscribes from events"""
|
||||
async with self._lock:
|
||||
with self._subscribers_lock:
|
||||
self._subscribers.remove(q)
|
||||
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]:
|
||||
"""Subscribes to events as an async generator that yields events and automatically unsubscribes"""
|
||||
q = await self._subscribe(include_buffered, include_future)
|
||||
def subscribe(self, include_buffered: bool = True, include_future: bool = True, yield_timeout: float = 0.0) -> \
|
||||
Generator[T, None, None]:
|
||||
"""Subscribes to events as an generator that yields events and automatically unsubscribes"""
|
||||
q = self._subscribe(include_buffered, include_future)
|
||||
try:
|
||||
while True:
|
||||
v = 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
|
||||
if v is None and not include_future:
|
||||
if v is _end_of_queue:
|
||||
break
|
||||
yield v
|
||||
finally: # When aclose() is called
|
||||
await self._unsubscribe(q)
|
||||
self._unsubscribe(q)
|
||||
|
||||
def buffer(self) -> List[T]:
|
||||
"""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):
|
||||
"""Deletes an event from the buffer"""
|
||||
with self._buffer_lock:
|
||||
self._buffer.remove(event)
|
||||
|
||||
def clear(self):
|
||||
"""Clears the buffer"""
|
||||
with self._buffer_lock:
|
||||
self._buffer.clear()
|
||||
|
||||
0
yacv_server/py.typed
Normal file
0
yacv_server/py.typed
Normal file
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,306 +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 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)
|
||||
|
||||
# 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:
|
||||
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
|
||||
do_shutdown = asyncio.Event()
|
||||
show_events = BufferedPubSub[UpdatesApiFullData]()
|
||||
object_events: Dict[str, BufferedPubSub[bytes]] = {}
|
||||
object_events_lock = asyncio.Lock()
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
# 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
|
||||
# FIXME: Wait for at least one client to confirm ready before stopping in case we are too fast?
|
||||
self.loop.call_soon_threadsafe(lambda *a: self.do_shutdown.set())
|
||||
self.thread.join(timeout=12)
|
||||
self.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"""
|
||||
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()
|
||||
# print(f'Server started at {site.name}')
|
||||
# Wait for a signal to stop the server while running
|
||||
await self.do_shutdown.wait()
|
||||
# print('Shutting down server...')
|
||||
await runner.cleanup()
|
||||
|
||||
async def _entrypoint(self, request: web.Request) -> web.StreamResponse:
|
||||
"""Main entrypoint to the server, which automatically serves the frontend/updates/objects"""
|
||||
if request.headers.get('Upgrade', '').lower() == 'websocket': # WebSocket -> 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.WebSocketResponse:
|
||||
"""Handles a publish-only websocket connection that send show_object events along with their hashes and URLs"""
|
||||
ws = web.WebSocketResponse()
|
||||
await ws.prepare(request)
|
||||
|
||||
async def _send_api_updates():
|
||||
subscription = self.show_events.subscribe()
|
||||
try:
|
||||
async for data in subscription:
|
||||
logger.debug('Sending info about %s to %s: %s', data.name, request.remote, data)
|
||||
# noinspection PyUnresolvedReferences
|
||||
await ws.send_str(data.to_json())
|
||||
finally:
|
||||
await subscription.aclose()
|
||||
|
||||
# Start sending updates to the client automatically
|
||||
send_task = asyncio.create_task(_send_api_updates())
|
||||
receive_task = asyncio.create_task(ws.receive())
|
||||
try:
|
||||
logger.debug('Client connected: %s', request.remote)
|
||||
# Wait for the client to close the connection (or send a message)
|
||||
done, pending = await asyncio.wait([send_task, receive_task], return_when=asyncio.FIRST_COMPLETED)
|
||||
# Make sure to stop sending updates to the client and close the connection
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
finally:
|
||||
await ws.close()
|
||||
logger.debug('Client disconnected: %s', request.remote)
|
||||
|
||||
return ws
|
||||
|
||||
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
|
||||
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."""
|
||||
# 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()])
|
||||
|
||||
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
|
||||
found = False
|
||||
obj: Optional[TopoDS_Shape] = None
|
||||
kwargs: Optional[Dict[str, any]] = None
|
||||
subscription = self.show_events.buffer()
|
||||
for data in subscription:
|
||||
if data.name == name:
|
||||
obj = data.obj
|
||||
kwargs = data.kwargs
|
||||
found = True # Required because obj could be None
|
||||
break
|
||||
if not found:
|
||||
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(obj, tolerance=kwargs.get('tolerance', 0.1),
|
||||
angular_tolerance=kwargs.get('angular_tolerance', 0.1),
|
||||
faces=kwargs.get('faces', True),
|
||||
edges=kwargs.get('edges', True),
|
||||
vertices=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))
|
||||
|
||||
# We should build it fully even if we are cancelled, so we use a separate task
|
||||
# Furthermore, building is CPU-bound, so we use the default executor
|
||||
await asyncio.get_running_loop().run_in_executor(None, _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()
|
||||
@@ -1,25 +1,20 @@
|
||||
import hashlib
|
||||
import io
|
||||
import re
|
||||
from typing import List, Dict, Tuple, Union
|
||||
from typing import List, Dict, Tuple
|
||||
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCP.GCPnts import GCPnts_TangentialDeflection
|
||||
from OCP.TopExp import TopExp
|
||||
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 build123d import Shape, Vertex, Face, Location
|
||||
from pygltflib import GLTF2
|
||||
|
||||
import mylogger
|
||||
from cad import CADLike
|
||||
from gltf import GLTFMgr
|
||||
from yacv_server.cad import CADCoreLike
|
||||
from yacv_server.gltf import GLTFMgr
|
||||
from yacv_server.mylogger import logger
|
||||
|
||||
|
||||
def tessellate(
|
||||
cad_like: CADLike,
|
||||
cad_like: CADCoreLike,
|
||||
tolerance: float = 0.1,
|
||||
angular_tolerance: float = 0.1,
|
||||
faces: bool = True,
|
||||
@@ -36,8 +31,8 @@ def tessellate(
|
||||
shape = Shape(cad_like)
|
||||
|
||||
# Perform tessellation tasks
|
||||
edge_to_faces: Dict[TopoDS_Edge, List[TopoDS_Face]] = {}
|
||||
vertex_to_faces: Dict[TopoDS_Vertex, List[TopoDS_Face]] = {}
|
||||
edge_to_faces: Dict[str, List[TopoDS_Face]] = {}
|
||||
vertex_to_faces: Dict[str, List[TopoDS_Face]] = {}
|
||||
if faces:
|
||||
for face in shape.faces():
|
||||
_tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance)
|
||||
@@ -55,7 +50,7 @@ def tessellate(
|
||||
for vertex in shape.vertices():
|
||||
_tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []))
|
||||
|
||||
return mgr.gltf
|
||||
return mgr.build()
|
||||
|
||||
|
||||
def _tessellate_face(
|
||||
@@ -65,12 +60,12 @@ def _tessellate_face(
|
||||
angular_tolerance: float = 0.1
|
||||
):
|
||||
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())
|
||||
if poly is None:
|
||||
mylogger.logger.warn("No triangulation found for face")
|
||||
logger.warn("No triangulation found for face")
|
||||
return GLTF2()
|
||||
tri_mesh = face.tessellate(tolerance, angular_tolerance)
|
||||
|
||||
# Get UV of each face from the parameters
|
||||
uv = [
|
||||
@@ -78,7 +73,7 @@ def _tessellate_face(
|
||||
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]
|
||||
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)
|
||||
if push_dir != (0, 0, 0):
|
||||
# Normalize the push direction by the number of faces and a constant factor
|
||||
# NOTE: Don't overdo it, or metrics will be wrong
|
||||
n = len(faces) / 1e-3
|
||||
push_dir = (push_dir[0] / n, push_dir[1] / n, push_dir[2] / n)
|
||||
# NOTE: Don't overdo it, or metrics will be (more) wrong
|
||||
n = 1e-3 / len(faces)
|
||||
push_dir = (push_dir[0] * n, push_dir[1] * n, push_dir[2] * n)
|
||||
# Push the vertex by the normal
|
||||
v = (v[0] + push_dir[0], v[1] + push_dir[1], v[2] + push_dir[2])
|
||||
return v
|
||||
@@ -119,6 +114,9 @@ def _tessellate_edge(
|
||||
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)
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
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