mirror of
https://github.com/yeicor-3d/yet-another-cad-viewer.git
synced 2025-12-19 22:24:17 +01:00
Compare commits
84 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2b1b3b952 | ||
|
|
db3bbf2d32 | ||
|
|
1d6230ef1e | ||
|
|
7d22ca465e | ||
|
|
7c38ccc522 | ||
|
|
06a95d4875 | ||
|
|
56fc556c0f | ||
|
|
fe54f48ad7 | ||
|
|
c47f961b0d | ||
|
|
f86b714c08 | ||
|
|
2ae7c86f8d | ||
|
|
0908216bfc | ||
|
|
7871a5070d | ||
|
|
5173f00d29 | ||
|
|
c2ef910783 | ||
|
|
5a5f948224 | ||
|
|
dbeae5632e | ||
|
|
59116e4a1a | ||
|
|
0be0103c3c | ||
|
|
d6deef9e7f | ||
|
|
ad956762f4 | ||
|
|
a4acd2f3d3 | ||
|
|
c877fef490 | ||
|
|
0855a9c6c7 | ||
|
|
657b34d098 | ||
|
|
ad83f1c937 | ||
|
|
38be4c638b | ||
|
|
63f2b716d6 | ||
|
|
9e70a3998d | ||
|
|
c7c4adc250 | ||
|
|
393decd876 | ||
|
|
111f417905 | ||
|
|
7296b15a67 | ||
|
|
88190b0d1e | ||
|
|
f2a607bb00 | ||
|
|
0b6ed2fc34 | ||
|
|
d1e5658e07 | ||
|
|
3545785cae | ||
|
|
1df938b067 | ||
|
|
42ae6384f0 | ||
|
|
b10b228fcc | ||
|
|
ffaf6f26ba | ||
|
|
17357aef25 | ||
|
|
0e6118ed94 | ||
|
|
8a435b5f1a | ||
|
|
a63d018850 | ||
|
|
0460e939e4 | ||
|
|
b54453ca06 | ||
|
|
cc4e3f427e | ||
|
|
fc32393635 | ||
|
|
ae2a5f9bb0 | ||
|
|
46cf45e4ce | ||
|
|
3423c99f8d | ||
|
|
667a08d2c6 | ||
|
|
1733337331 | ||
|
|
0c51b614e9 | ||
|
|
9ef372f576 | ||
|
|
a2275f2897 | ||
|
|
b597b1e3a5 | ||
|
|
4af4315984 | ||
|
|
83bc4c767d | ||
|
|
10ed5e2e9e | ||
|
|
41662944d6 | ||
|
|
7f00a3a4ee | ||
|
|
7549c2543b | ||
|
|
fb7a525a71 | ||
|
|
c14a823dc1 | ||
|
|
3845720d53 | ||
|
|
16c109f399 | ||
|
|
7b58b00231 | ||
|
|
05a159ac6c | ||
|
|
458f81f51d | ||
|
|
f3545bebb8 | ||
|
|
90d05f08c4 | ||
|
|
5b4865978f | ||
|
|
9ec2de8e4a | ||
|
|
0c98c273b8 | ||
|
|
014a68c33d | ||
|
|
16bf550f75 | ||
|
|
f4a74c2f1c | ||
|
|
1fddd984aa | ||
|
|
5809b88cee | ||
|
|
5cd7fb5d9d | ||
|
|
dbfc72c03b |
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"append": [
|
||||
"assets/fox.glb.license",
|
||||
"assets/qwantani_afternoon_1k_hdr.jpg.license",
|
||||
"LICENSE"
|
||||
],
|
||||
"replace": {
|
||||
|
||||
20
.github/workflows/build.yml
vendored
20
.github/workflows/build.yml
vendored
@@ -38,11 +38,11 @@ jobs:
|
||||
- run: "pipx install poetry"
|
||||
- uses: "actions/setup-python@v5"
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: "poetry"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry lock"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry build"
|
||||
- run: "poetry lock"
|
||||
- run: "poetry install"
|
||||
- run: "poetry build" # Skips building frontend (not using task)
|
||||
|
||||
build-logo:
|
||||
name: "Build logo"
|
||||
@@ -54,10 +54,10 @@ jobs:
|
||||
- run: "pipx install poetry"
|
||||
- uses: "actions/setup-python@v5"
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: "poetry"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry lock"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
||||
- run: "poetry lock"
|
||||
- run: "poetry install"
|
||||
- run: "poetry run python yacv_server/logo.py"
|
||||
- uses: "actions/upload-artifact@v4"
|
||||
with:
|
||||
@@ -75,10 +75,10 @@ jobs:
|
||||
- run: "pipx install poetry"
|
||||
- uses: "actions/setup-python@v5"
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: "poetry"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry lock"
|
||||
- run: "SKIP_BUILD_FRONTEND=true poetry install"
|
||||
- run: "poetry lock"
|
||||
- run: "poetry install"
|
||||
- run: "YACV_DISABLE_SERVER=true poetry run python example/object.py"
|
||||
- uses: "actions/upload-artifact@v4"
|
||||
with:
|
||||
|
||||
13
.github/workflows/deploy1.yml
vendored
13
.github/workflows/deploy1.yml
vendored
@@ -16,14 +16,16 @@ jobs:
|
||||
- uses: "actions/checkout@v4"
|
||||
with: # Ensure we are not in a detached HEAD state
|
||||
ref: "master"
|
||||
token: "${{ secrets.GH_PAT }}"
|
||||
# Check that the tag commit is the latest master commit
|
||||
- run: |
|
||||
- id: check_recursive
|
||||
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
|
||||
echo "::warning ::The tag commit $tag_commit does not match the latest master commit $master_commit. This is probably a recursive tag push that will be ignored."
|
||||
echo "skip=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
- run: "echo 'CLEAN_VERSION=${{ github.ref }}' | sed 's,refs/tags/v,,g' >> $GITHUB_ENV"
|
||||
# Write the new version to package.json
|
||||
@@ -33,11 +35,12 @@ jobs:
|
||||
- run: "pipx install poetry"
|
||||
- uses: "actions/setup-python@v5"
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: "poetry"
|
||||
- run: "poetry version $CLEAN_VERSION"
|
||||
# Commit the changes and move the tag!
|
||||
- run: |
|
||||
- if: "steps.check_recursive.outputs.skip != 'true'"
|
||||
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
|
||||
|
||||
11
.github/workflows/deploy2.yml
vendored
11
.github/workflows/deploy2.yml
vendored
@@ -35,11 +35,7 @@ jobs:
|
||||
- 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
|
||||
merge-multiple: true
|
||||
- uses: "actions/configure-pages@v5"
|
||||
- uses: "actions/upload-pages-artifact@v3"
|
||||
with:
|
||||
@@ -67,9 +63,10 @@ jobs:
|
||||
- run: "pipx install poetry"
|
||||
- uses: "actions/setup-python@v5"
|
||||
with:
|
||||
python-version: "3.12"
|
||||
python-version: "3.13"
|
||||
cache: "poetry"
|
||||
- run: "poetry install"
|
||||
- run: "poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}"
|
||||
- run: "poetry publish --build"
|
||||
- run: "poetry run task build" # This task also builds the frontend (with reduced features for less size)
|
||||
- run: "poetry publish"
|
||||
|
||||
|
||||
@@ -15,15 +15,17 @@ in a web browser.
|
||||
- Select any entity and measure bounding box size and distances.
|
||||
- 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.
|
||||
- Build123d playground! Code and build your model fully inside the
|
||||
browser: [demo](https://yeicor-3d.github.io/yet-another-cad-viewer/#pg_code_url=https://raw.githubusercontent.com/gumyr/build123d/refs/heads/dev/examples/toy_truck.py).
|
||||
|
||||
## Usage
|
||||
|
||||
The [example](example) is a fully working project that shows how to use the viewer.
|
||||
|
||||
You can play with the latest
|
||||
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=logo_hl.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
|
||||
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.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=logo_hl.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)).
|
||||
[without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=logo.glb&preload=logo_hl.glb&preload=logo_hl_tex.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)).
|
||||
|
||||

|
||||
|
||||
@@ -32,4 +34,5 @@ demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb
|
||||
- [cq-studio](https://github.com/ccazabon/cq-studio) provides an alternative workflow that detects file changes instead
|
||||
of relying on an interactive environment like Jupyter for hot-reloading.
|
||||
Uses the same backend and frontend behind the scenes.
|
||||
- [build123d-docker](https://github.com/derhuerst/build123d-docker/pkgs/container/build123d) provides docker images for Yet Another CAD Viewer and other projects, with automatic updates.
|
||||
- [build123d-docker](https://github.com/derhuerst/build123d-docker/pkgs/container/build123d) provides docker images for
|
||||
Yet Another CAD Viewer and other projects, with automatic updates.
|
||||
|
||||
@@ -3,7 +3,7 @@ https://www.npmjs.com/package/generate-license-file
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- @google/model-viewer@4.0.0
|
||||
- @google/model-viewer@4.1.0
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -522,7 +522,7 @@ Apache License
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- detect-libc@2.0.3
|
||||
- detect-libc@2.0.4
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -761,6 +761,36 @@ MIT License
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- pako@2.1.0
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
(The MIT License)
|
||||
|
||||
Copyright (C) 2014-2017 by Vitaly Puzrin and Andrei Tuputcyn
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- lie@3.3.0
|
||||
@@ -1045,9 +1075,9 @@ END OF TERMS AND CONDITIONS
|
||||
|
||||
The following npm packages may be included in this product:
|
||||
|
||||
- @lit/reactive-element@1.6.3
|
||||
- lit-element@3.3.3
|
||||
- lit@2.8.0
|
||||
- @lit/reactive-element@2.1.1
|
||||
- lit-element@4.2.1
|
||||
- lit@3.3.1
|
||||
|
||||
These packages each contain the following license:
|
||||
|
||||
@@ -1084,7 +1114,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- lit-html@2.8.0
|
||||
- lit-html@3.3.1
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -1121,7 +1151,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- @lit-labs/ssr-dom-shim@1.3.0
|
||||
- @lit-labs/ssr-dom-shim@1.4.0
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -1129,6 +1159,35 @@ BSD-3-Clause
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- ws@8.18.3
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
Copyright (c) 2011 Einar Otto Stangvik <einaros@gmail.com>
|
||||
Copyright (c) 2013 Arnout Kazemier and contributors
|
||||
Copyright (c) 2016 Luigi Pinca and contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
this software and associated documentation files (the "Software"), to deal in
|
||||
the Software without restriction, including without limitation the rights to
|
||||
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- color-string@1.9.1
|
||||
@@ -1247,7 +1306,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- @babel/parser@7.27.0
|
||||
- @babel/parser@7.28.0
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -1301,6 +1360,42 @@ THE SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- js-base64@3.7.7
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
Copyright (c) 2014, Dan Kogai
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
* Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
* Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
* Neither the name of {{{project}}} nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- estree-walker@2.0.2
|
||||
@@ -1381,6 +1476,34 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- @jridgewell/sourcemap-codec@1.5.4
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
Copyright 2024 Justin Ridgewell <justin@ridgewell.name>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- picocolors@1.1.1
|
||||
@@ -1418,9 +1541,9 @@ LGPL-3.0-or-later
|
||||
|
||||
The following npm packages may be included in this product:
|
||||
|
||||
- @babel/helper-string-parser@7.25.9
|
||||
- @babel/helper-validator-identifier@7.25.9
|
||||
- @babel/types@7.27.0
|
||||
- @babel/helper-string-parser@7.27.1
|
||||
- @babel/helper-validator-identifier@7.27.1
|
||||
- @babel/types@7.28.2
|
||||
|
||||
These packages each contain the following license:
|
||||
|
||||
@@ -1451,7 +1574,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- three-mesh-bvh@0.9.0
|
||||
- three-mesh-bvh@0.9.1
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -1509,6 +1632,126 @@ SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- state-local@1.0.7
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020 Suren Atoyan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- vue-demi@0.14.10
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2020-present, Anthony Fu
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- @monaco-editor/loader@1.5.0
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Suren Atoyan
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- @guolao/vue-monaco-editor@1.5.5
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 guolao
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- @monogrid/gainmap-js@3.1.0
|
||||
@@ -1569,6 +1812,16 @@ SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- pyodide@0.28.0
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
MPL-2.0
|
||||
|
||||
-----------
|
||||
|
||||
The following npm packages may be included in this product:
|
||||
|
||||
- @mdi/js@7.4.47
|
||||
@@ -1601,7 +1854,7 @@ The MIT license applies to all non-font and non-icon files.
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- semver@7.7.1
|
||||
- semver@7.7.2
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -1623,36 +1876,6 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- @jridgewell/sourcemap-codec@1.5.0
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
The MIT License
|
||||
|
||||
Copyright (c) 2015 Rich Harris
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in
|
||||
all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
THE SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- three@0.125.2
|
||||
@@ -1685,7 +1908,7 @@ THE SOFTWARE.
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- three@0.175.0
|
||||
- three@0.178.0
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -1775,13 +1998,43 @@ THE SOFTWARE.
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- vuetify@3.8.0
|
||||
- monaco-editor@0.52.2
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016-2023 John Jeremy Leider
|
||||
Copyright (c) 2016 - present Microsoft Corporation
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
-----------
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- vuetify@3.9.2
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2016-now Vuetify, LLC
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
@@ -1805,16 +2058,16 @@ THE SOFTWARE.
|
||||
|
||||
The following npm packages may be included in this product:
|
||||
|
||||
- @vue/compiler-core@3.5.13
|
||||
- @vue/compiler-dom@3.5.13
|
||||
- @vue/compiler-sfc@3.5.13
|
||||
- @vue/compiler-ssr@3.5.13
|
||||
- @vue/reactivity@3.5.13
|
||||
- @vue/runtime-core@3.5.13
|
||||
- @vue/runtime-dom@3.5.13
|
||||
- @vue/server-renderer@3.5.13
|
||||
- @vue/shared@3.5.13
|
||||
- vue@3.5.13
|
||||
- @vue/compiler-core@3.5.18
|
||||
- @vue/compiler-dom@3.5.18
|
||||
- @vue/compiler-sfc@3.5.18
|
||||
- @vue/compiler-ssr@3.5.18
|
||||
- @vue/reactivity@3.5.18
|
||||
- @vue/runtime-core@3.5.18
|
||||
- @vue/runtime-dom@3.5.18
|
||||
- @vue/server-renderer@3.5.18
|
||||
- @vue/shared@3.5.18
|
||||
- vue@3.5.18
|
||||
|
||||
These packages each contain the following license:
|
||||
|
||||
@@ -1844,7 +2097,7 @@ THE SOFTWARE.
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- ktx-parse@1.0.0
|
||||
- ktx-parse@1.0.1
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -1906,9 +2159,9 @@ SOFTWARE.
|
||||
|
||||
The following npm packages may be included in this product:
|
||||
|
||||
- @gltf-transform/core@4.1.3
|
||||
- @gltf-transform/extensions@4.1.3
|
||||
- @gltf-transform/functions@4.1.3
|
||||
- @gltf-transform/core@4.2.0
|
||||
- @gltf-transform/extensions@4.2.0
|
||||
- @gltf-transform/functions@4.2.0
|
||||
|
||||
These packages each contain the following license:
|
||||
|
||||
@@ -1968,7 +2221,7 @@ THE SOFTWARE.
|
||||
|
||||
The following npm package may be included in this product:
|
||||
|
||||
- postcss@8.5.3
|
||||
- postcss@8.5.6
|
||||
|
||||
This package contains the following license:
|
||||
|
||||
@@ -2049,6 +2302,11 @@ glTF conversion by @AsoboStudio and @scurest
|
||||
|
||||
-----------
|
||||
|
||||
CC0: Qwantani Afternoon by Greg Zaal (Photography) and Jarod Guest (Processing)
|
||||
https://polyhaven.com/a/qwantani_afternoon
|
||||
|
||||
-----------
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 Yeicor
|
||||
|
||||
BIN
assets/qwantani_afternoon_1k_hdr.jpg
Normal file
BIN
assets/qwantani_afternoon_1k_hdr.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
2
assets/qwantani_afternoon_1k_hdr.jpg.license
Normal file
2
assets/qwantani_afternoon_1k_hdr.jpg.license
Normal file
@@ -0,0 +1,2 @@
|
||||
CC0: Qwantani Afternoon by Greg Zaal (Photography) and Jarod Guest (Processing)
|
||||
https://polyhaven.com/a/qwantani_afternoon
|
||||
9
build.py
9
build.py
@@ -1,9 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 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)
|
||||
@@ -11,7 +11,8 @@ import {NetworkManager, NetworkUpdateEvent, NetworkUpdateEventModel} from "./mis
|
||||
import {SceneMgr} from "./misc/scene";
|
||||
import {Document} from "@gltf-transform/core";
|
||||
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
|
||||
import {mdiPlus} from '@mdi/js'
|
||||
import {mdiCube, mdiPlus, mdiScriptTextPlay} from '@mdi/js'
|
||||
// @ts-expect-error
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
|
||||
// NOTE: The ModelViewer library is big (THREE.js), so we split it and import it asynchronously
|
||||
@@ -21,7 +22,7 @@ const ModelViewerWrapper = defineAsyncComponent({
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > 1200);
|
||||
let openSidebarsByDefault: Ref<boolean> = ref(window.innerWidth > window.innerHeight);
|
||||
|
||||
const sceneUrl = ref("")
|
||||
const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
|
||||
@@ -52,7 +53,7 @@ async function onModelUpdateRequest(event: NetworkUpdateEvent) {
|
||||
let model = event.models[modelIndex];
|
||||
tools.value?.removeObjectSelections(model.name);
|
||||
try {
|
||||
let loadHelpers = (await settings()).loadHelpers;
|
||||
let loadHelpers = (await settings).loadHelpers;
|
||||
if (!model.isRemove) {
|
||||
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && loadHelpers, isLast);
|
||||
} else {
|
||||
@@ -80,23 +81,50 @@ let networkMgr = new NetworkManager();
|
||||
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));
|
||||
let preloadingModels = ref<Array<string>>([]);
|
||||
(async () => { // Start loading all configured models ASAP
|
||||
let sett = await settings();
|
||||
watch(viewer, (newViewer) => {
|
||||
if (newViewer) {
|
||||
newViewer.setPosterText('<tspan x="50%" dy="1.2em">Trying to load' +
|
||||
' models from:</tspan>' + sett.preload.map((url: string) => '<tspan x="50%" dy="1.2em">- ' + url + '</tspan>').join(""));
|
||||
let sett = await settings;
|
||||
if (sett.preload.length > 0) {
|
||||
watch(viewer, (newViewer) => {
|
||||
if (newViewer) {
|
||||
newViewer.setPosterText('<tspan x="50%" dy="1.2em">Trying to load' +
|
||||
' models from:</tspan>' + sett.preload.map((url: string) => '<tspan x="50%" dy="1.2em">- ' + url + '</tspan>').join(""));
|
||||
}
|
||||
});
|
||||
for (let model of sett.preload) {
|
||||
preloadingModels.value.push(model);
|
||||
let removeFromPreloadingModels = () => {
|
||||
preloadingModels.value = preloadingModels.value.filter((m) => m !== model);
|
||||
};
|
||||
networkMgr.load(model).then(removeFromPreloadingModels).catch((e) => {
|
||||
removeFromPreloadingModels()
|
||||
console.error("Error preloading model", model, e);
|
||||
});
|
||||
}
|
||||
});
|
||||
for (let model of sett.preload) {
|
||||
await networkMgr.load(model);
|
||||
}
|
||||
} // else No preloaded models (useful for playground mode)
|
||||
})();
|
||||
|
||||
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:");
|
||||
if (modelUrl) await networkMgr.load(modelUrl);
|
||||
}
|
||||
|
||||
// Detect dropped .glb files and load them manually
|
||||
document.body.addEventListener("dragover", e => {
|
||||
e.preventDefault(); // Allow drop
|
||||
});
|
||||
|
||||
document.body.addEventListener("drop", async e => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer?.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'glb' || ext === 'gltf') {
|
||||
await networkMgr.load(file);
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -104,7 +132,28 @@ async function loadModelManual() {
|
||||
|
||||
<!-- The main content of the app is the model-viewer with the SVG "2D" overlay -->
|
||||
<v-main id="main">
|
||||
<model-viewer-wrapper ref="viewer" :src="sceneUrl"/>
|
||||
<model-viewer-wrapper v-if="sceneDocument.getRoot().listMeshes().length > 0" ref="viewer" :src="sceneUrl"/>
|
||||
<!-- A nice no model loaded alternative to avoid breaking model-viewer-wrapper -->
|
||||
<div v-else style="height: 100%; overflow-y: auto">
|
||||
<v-toolbar-title class="text-center ma-16 text-h5">No model loaded</v-toolbar-title>
|
||||
<v-btn @click="() => tools?.openPlayground()" class="mx-auto d-block my-4">
|
||||
<svg-icon :path="mdiScriptTextPlay" type="mdi"/> Open playground...
|
||||
</v-btn>
|
||||
<v-btn @click="networkMgr.load('https://yeicor-3d.github.io/yet-another-cad-viewer/logo.glb')"
|
||||
class="mx-auto d-block my-4">
|
||||
<svg-icon :path="mdiCube" type="mdi"/> Load demo model...
|
||||
</v-btn>
|
||||
<v-btn @click="loadModelManual" class="mx-auto d-block my-4">
|
||||
<svg-icon :path="mdiPlus" type="mdi"/> Load model manually...
|
||||
</v-btn>
|
||||
<span v-if="preloadingModels.length > 0" class="d-block text-center my-16">
|
||||
<span class="d-block text-center text-h6">Still trying to load the following:</span>
|
||||
<span class="d-block text-center" v-for="(model, index) in preloadingModels" :key="index">
|
||||
{{ model }}<span v-if="index < preloadingModels.length - 1">, </span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
</div>
|
||||
</v-main>
|
||||
|
||||
<!-- The left collapsible sidebar has the list of models -->
|
||||
@@ -117,7 +166,7 @@ async function loadModelManual() {
|
||||
<svg-icon :path="mdiPlus" type="mdi"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
<models ref="models" :viewer="viewer" @remove="onModelRemoveRequest"/>
|
||||
<models ref="models" :viewer="viewer" @remove-model="onModelRemoveRequest"/>
|
||||
</sidebar>
|
||||
|
||||
<!-- The right collapsible sidebar has the list of tools -->
|
||||
@@ -125,7 +174,7 @@ async function loadModelManual() {
|
||||
<template #toolbar>
|
||||
<v-toolbar-title>Tools</v-toolbar-title>
|
||||
</template>
|
||||
<tools ref="tools" :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
|
||||
<tools ref="tools" :viewer="viewer" @find-model="models?.findModel" @update-model="onModelUpdateRequest"/>
|
||||
</sidebar>
|
||||
|
||||
</v-layout>
|
||||
@@ -135,6 +184,6 @@ async function loadModelManual() {
|
||||
<style>
|
||||
html, body {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
</style>
|
||||
31
frontend/misc/IfNotSmallBuild.vue
Normal file
31
frontend/misc/IfNotSmallBuild.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import {mdiLockQuestion} from "@mdi/js";
|
||||
import {VBtn, VTooltip} from "vuetify/lib/components/index.mjs";
|
||||
// @ts-expect-error
|
||||
import SvgIcon from "@jamescoyle/vue-icon";
|
||||
|
||||
// @ts-expect-error
|
||||
let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== 'undefined' && __YACV_SMALL_BUILD__;
|
||||
|
||||
function clickedButton() { // Redirect to the main build
|
||||
window.open("https://yeicor-3d.github.io/yet-another-cad-viewer/" + window.location.search + window.location.hash, '_blank');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- @ts-ignore-->
|
||||
<!-- Include the children as this is a full build -->
|
||||
<slot v-if="!isSmallBuild"/>
|
||||
<!-- A small info button saying that a feature is missing, and linking to the main build -->
|
||||
<v-btn v-else icon @click="clickedButton" base-color="#a00" style="margin: auto; display: block;">
|
||||
<v-tooltip activator="parent">
|
||||
This feature is not available in the small build.<br/>
|
||||
Click to go to the main build.
|
||||
</v-tooltip>
|
||||
<svg-icon :path="mdiLockQuestion" type="mdi"/>
|
||||
</v-btn>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
||||
@@ -2,6 +2,7 @@
|
||||
import {ref} from "vue";
|
||||
import {VBtn, VNavigationDrawer, VToolbar, VToolbarItems} from "vuetify/lib/components/index.mjs";
|
||||
import {mdiChevronLeft, mdiChevronRight, mdiClose} from '@mdi/js'
|
||||
// @ts-expect-error
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
|
||||
import {unpartition, mergeDocuments} from "@gltf-transform/functions";
|
||||
import {mergeDocuments, unpartition} from "@gltf-transform/functions";
|
||||
import {retrieveFile} from "../tools/upload-file.ts";
|
||||
|
||||
let io = new WebIO();
|
||||
export let extrasNameKey = "__yacv_name";
|
||||
export let extrasNameValueHelpers = "__helpers";
|
||||
|
||||
// @ts-expect-error
|
||||
let isSmallBuild = typeof __YACV_SMALL_BUILD__ !== 'undefined' && __YACV_SMALL_BUILD__;
|
||||
|
||||
/**
|
||||
* Loads a GLB model from a URL and adds it to the document or replaces it if the names match.
|
||||
*
|
||||
@@ -12,11 +16,11 @@ 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, networkFinished: () => void = () => {
|
||||
export async function mergePartial(url: string | Blob, 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 response = await fetchOrRead(url);
|
||||
let buffer = await response.arrayBuffer();
|
||||
networkFinished();
|
||||
|
||||
@@ -27,18 +31,20 @@ export async function mergePartial(url: string, name: string, document: Document
|
||||
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 (!isSmallBuild && 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")
|
||||
// @ts-expect-error
|
||||
let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js");
|
||||
// @ts-expect-error
|
||||
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")) {
|
||||
} else if (!isSmallBuild && 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]);
|
||||
@@ -113,3 +119,30 @@ function mergeScenes(): Transform {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Fetches a URL or reads it if it is a Blob URL */
|
||||
async function fetchOrRead(url: string | Blob) {
|
||||
if (url instanceof Blob) {
|
||||
// Use the FileReader API as fetch does not support Blob URLs
|
||||
return new Promise<Response>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = (event: ProgressEvent<FileReader>) => {
|
||||
if (event.target && event.target.result) {
|
||||
resolve(new Response(event.target.result));
|
||||
} else {
|
||||
reject(new Error("Failed to read Blob URL: " + url));
|
||||
}
|
||||
};
|
||||
reader.onerror = (error) => {
|
||||
reject(new Error("Error reading Blob URL: " + url + " - " + error));
|
||||
};
|
||||
// Read the Blob URL as an ArrayBuffer
|
||||
reader.readAsArrayBuffer(new Blob([url]));
|
||||
});
|
||||
} else {
|
||||
// Fetch the URL
|
||||
return retrieveFile(url);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ 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 async function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4, divisions = 10) {
|
||||
export 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[] = [];
|
||||
|
||||
@@ -4,12 +4,12 @@ const batchTimeout = 250; // ms
|
||||
|
||||
export class NetworkUpdateEventModel {
|
||||
name: string;
|
||||
url: string;
|
||||
url: string | Blob;
|
||||
// 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, hash: string | null, isRemove: boolean | null) {
|
||||
constructor(name: string, url: string | Blob, hash: string | null, isRemove: boolean | null) {
|
||||
this.name = name;
|
||||
this.url = url;
|
||||
this.hash = hash;
|
||||
@@ -42,18 +42,32 @@ 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("dev+") || url.startsWith("dev ")) {
|
||||
async load(url: string | Blob) {
|
||||
if (!(url instanceof Blob) && (url.startsWith("dev+") || 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();
|
||||
name = name?.split(".")[0] || `unknown-${Math.random()}`;
|
||||
// Use a head request to get the hash of the file
|
||||
let response = await fetch(url, {method: "HEAD"});
|
||||
let hash = response.headers.get("etag");
|
||||
let name;
|
||||
let hash = null;
|
||||
if (url instanceof Blob) {
|
||||
if (url instanceof File) name = (url as File).name
|
||||
else name = `blob-${Math.random()}`;
|
||||
name = name.replace('.glb', '').replace('.gltf', '');
|
||||
} else {
|
||||
// If there is a #name parameter in the URL, use it as the name
|
||||
let urlObj = new URL(url);
|
||||
let hashParams = new URLSearchParams(urlObj.hash.slice(1));
|
||||
if (hashParams.has("name")) {
|
||||
name = hashParams.get("name") || `unknown-${Math.random()}`;
|
||||
} else { // Default to the last part of the URL as the "name" of the model
|
||||
name = url.split("/").pop();
|
||||
}
|
||||
name = name?.split(".")[0] || `unknown-${Math.random()}`;
|
||||
// Use a head request to get the hash of the file
|
||||
let response = await fetch(url, {method: "HEAD"});
|
||||
hash = response.headers.get("etag");
|
||||
}
|
||||
// Only trigger an update if the hash has changed
|
||||
this.foundModel(name, hash, url, false);
|
||||
}
|
||||
@@ -61,7 +75,7 @@ export class NetworkManager extends EventTarget {
|
||||
|
||||
private async monitorDevServer(url: URL, stop: () => boolean = () => false) {
|
||||
while (!stop()) {
|
||||
let monitorEveryMs = (await settings()).monitorEveryMs;
|
||||
let monitorEveryMs = (await settings).monitorEveryMs;
|
||||
try {
|
||||
// WARNING: This will spam the console logs with failed requests when the server is down
|
||||
const controller = new AbortController();
|
||||
@@ -92,7 +106,7 @@ export class NetworkManager extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean | null, disconnect: () => void = () => {
|
||||
private foundModel(name: string, hash: string | null, url: string | Blob, isRemove: boolean | null, disconnect: () => void = () => {
|
||||
}) {
|
||||
// console.debug("Found model", name, "with hash", hash, "at", url, "isRemove", isRemove);
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {type Ref} from 'vue';
|
||||
import {Document} from '@gltf-transform/core';
|
||||
import {Buffer, Document, Scene} from '@gltf-transform/core';
|
||||
import {extrasNameKey, extrasNameValueHelpers, mergeFinalize, mergePartial, removeModel, toBuffer} from "./gltf";
|
||||
import {newAxes, newGridBox} from "./helpers";
|
||||
import {Vector3} from "three/src/math/Vector3.js"
|
||||
@@ -9,7 +9,7 @@ import {Matrix4} from "three/src/math/Matrix4.js"
|
||||
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
|
||||
export class SceneMgr {
|
||||
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
|
||||
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
|
||||
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string | Blob, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
|
||||
let loadStart = performance.now();
|
||||
let loadNetworkEnd: number;
|
||||
|
||||
@@ -80,7 +80,19 @@ export class SceneMgr {
|
||||
|
||||
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
|
||||
let bb = SceneMgr.getBoundingBox(document);
|
||||
if (!bb) return document;
|
||||
if (!bb) return document; // Empty document, no helpers to show
|
||||
|
||||
// If only the helpers remain, go back to the empty scene
|
||||
let noOtherModels = true;
|
||||
for (let elem of document.getGraph().listEdges().map(e => e.getChild())) {
|
||||
if (elem.getExtras() && !(elem instanceof Scene) && !(elem instanceof Buffer) &&
|
||||
elem.getExtras()[extrasNameKey] !== extrasNameValueHelpers) {
|
||||
// There are other elements in the document, so we can show the helpers
|
||||
noOtherModels = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (noOtherModels) return await removeModel(extrasNameValueHelpers, document);
|
||||
|
||||
// Create the helper axes and grid box
|
||||
let helpersDoc = new Document();
|
||||
@@ -88,7 +100,9 @@ export class SceneMgr {
|
||||
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);
|
||||
let newDocument = await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
|
||||
URL.revokeObjectURL(helpersUrl);
|
||||
return newDocument;
|
||||
}
|
||||
|
||||
/** Serializes the current document into a GLB and updates the viewerSrc */
|
||||
@@ -100,6 +114,7 @@ export class SceneMgr {
|
||||
let buffer = await toBuffer(document);
|
||||
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
|
||||
console.debug("Showing current doc", document, "with", buffer.length, "total bytes");
|
||||
if (sceneUrl.value.startsWith("blob:")) URL.revokeObjectURL(sceneUrl.value);
|
||||
sceneUrl.value = URL.createObjectURL(blob);
|
||||
|
||||
return document;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
// These are the default values for the settings, which are overridden below
|
||||
let settingsCache: any = null;
|
||||
import {ungzip} from "pako";
|
||||
import {b64UrlDecode} from "../tools/b64.ts";
|
||||
import {retrieveFile} from "../tools/upload-file.ts";
|
||||
|
||||
export async function settings() {
|
||||
if (settingsCache !== null) return settingsCache;
|
||||
const firstTimeNames: Array<string> = []; // Needed for array values, which clear the array when overridden
|
||||
export const settings = (async () => {
|
||||
let settings = {
|
||||
preload: [
|
||||
// @ts-ignore
|
||||
@@ -29,19 +31,49 @@ export async function settings() {
|
||||
panSensitivity: 1,
|
||||
exposure: 1,
|
||||
shadowIntensity: 0,
|
||||
background: '',
|
||||
// Nice low-res outdoor/high-contrast HDRI image (CC0 licensed) for lighting
|
||||
environment: new URL('../../assets/qwantani_afternoon_1k_hdr.jpg', import.meta.url).href,
|
||||
environmentIntensity: 1.0,
|
||||
// Uniform (1x1 pixel) medium gray background for visibility (following dark/light mode)
|
||||
skybox: (window.matchMedia("(prefers-color-scheme: dark)").matches ?
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEU4ODiyn42XAAAACklEQVQI" +
|
||||
"12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg==" :
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEW6urpaLVq8AAAACklEQVQI" +
|
||||
"12NgAAAAAgAB4iG8MwAAAABJRU5ErkJggg=="),
|
||||
|
||||
// Playground settings
|
||||
pg_code: "", // Automatically loaded and executed code for the playground
|
||||
pg_opacity_loading: -1, // Opacity of the code during first load and run (< 0 is 0.0 if preload and 0.9 if not)
|
||||
pg_opacity_loaded: 0.9, // Opacity of the code after it has been run for the first time
|
||||
};
|
||||
|
||||
// Auto-override any settings from the URL
|
||||
// Auto-override any settings from the URL (either GET parameters or hash)
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.forEach((value, key) => {
|
||||
if (key in settings) (settings as any)[key] = parseSetting(key, value, settings);
|
||||
})
|
||||
if (url.hash.length > 0) { // Hash has bigger limits as it is not sent to the server
|
||||
const hash = url.hash.slice(1);
|
||||
const hashParams = new URLSearchParams(hash);
|
||||
hashParams.forEach((value, key) => {
|
||||
if (key in settings) (settings as any)[key] = parseSetting(key, value, settings);
|
||||
});
|
||||
}
|
||||
|
||||
// Get the default preload URL if not overridden (requires a fetch that is avoided if possible)
|
||||
for (let i = 0; i < settings.preload.length; i++) {
|
||||
let url = settings.preload[i];
|
||||
// Ignore empty preload URLs to allow overriding default auto behavior
|
||||
if (url === '') {
|
||||
settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1));
|
||||
continue; // Skip this preload URL
|
||||
}
|
||||
// Handle special <auto> preload URL
|
||||
if (url === '<auto>') {
|
||||
if (settings.pg_code != "") { // <auto> means no preload URL if code is set
|
||||
settings.preload = settings.preload.slice(0, i).concat(settings.preload.slice(i + 1));
|
||||
continue; // Skip this preload URL
|
||||
}
|
||||
const possibleBackend = new URL("./?api_updates=true", window.location.href)
|
||||
await fetch(possibleBackend, {method: "HEAD"}).then((response) => {
|
||||
if (response.ok && response.headers.get("Content-Type") === "text/event-stream") {
|
||||
@@ -55,11 +87,28 @@ export async function settings() {
|
||||
}
|
||||
settings.preload[i] = url;
|
||||
}
|
||||
settingsCache = settings;
|
||||
return settings;
|
||||
}
|
||||
|
||||
const firstTimeNames: Array<string> = []; // Needed for array values, which clear the array when overridden
|
||||
// Auto-decompress the code and other playground settings
|
||||
if (settings.pg_code.length > 0) {
|
||||
// pg_code has a few possible formats: URL, base64url+gzipped, or raw code (try them in that order)
|
||||
try {
|
||||
new URL(settings.pg_code); // Check if it's a valid absolute URL
|
||||
settings.pg_code = await (await retrieveFile(settings.pg_code)).text();
|
||||
} catch (error1) { // Not a valid URL, try base64url+gzipped
|
||||
try {
|
||||
settings.pg_code = ungzip(b64UrlDecode(settings.pg_code), {to: 'string'});
|
||||
} catch (error2) { // Not base64url+gzipped, assume it's raw code
|
||||
console.log("pg_code is not a URL (", error1, ") or base64url+gzipped (", error2, "), using it as raw code:", settings.pg_code);
|
||||
}
|
||||
}
|
||||
if (settings.pg_opacity_loading < 0) {
|
||||
// If the opacity is not set, use 0.0 if preload is set, otherwise 0.9
|
||||
settings.pg_opacity_loading = settings.preload.length > 0 ? 0.0 : 0.9;
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
})()
|
||||
|
||||
function parseSetting(name: string, value: string, settings: any): any {
|
||||
let arrayElem = name.endsWith(".0")
|
||||
if (arrayElem) name = name.slice(0, -2);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
mdiVectorLine,
|
||||
mdiVectorRectangle
|
||||
} from '@mdi/js'
|
||||
// @ts-expect-error
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
import {BackSide, FrontSide} from "three/src/constants.js";
|
||||
import {Box3} from "three/src/math/Box3.js";
|
||||
@@ -34,6 +35,8 @@ 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"
|
||||
import {currentSceneRotation} from "../viewer/lighting.ts";
|
||||
import {Matrix4} from "three/src/math/Matrix4.js";
|
||||
|
||||
const props = defineProps<{
|
||||
meshes: Array<Mesh>,
|
||||
@@ -57,7 +60,7 @@ const clipPlaneZ = ref(1);
|
||||
const clipPlaneSwappedZ = ref(false);
|
||||
const edgeWidth = ref(0);
|
||||
(async () => {
|
||||
let s = await settings();
|
||||
let s = await settings;
|
||||
edgeWidth.value = s.edgeWidth;
|
||||
})();
|
||||
|
||||
@@ -115,7 +118,8 @@ function onWireframeChange(newWireframe: boolean) {
|
||||
if (!scene || !sceneModel) return;
|
||||
sceneModel.traverse((child: MObject3D) => {
|
||||
if (child.userData[extrasNameKey] === modelName) {
|
||||
if (child.material && child.material.wireframe !== newWireframe) {
|
||||
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
|
||||
if (child.material && child.material.wireframe !== newWireframe && childIsFace) {
|
||||
child.material.wireframe = newWireframe;
|
||||
child.material.needsUpdate = true;
|
||||
}
|
||||
@@ -153,10 +157,11 @@ function onClipPlanesChange() {
|
||||
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
|
||||
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 rotSceneMatrix = new Matrix4().makeRotationY(currentSceneRotation);
|
||||
let planes = [
|
||||
new Plane(new Vector3(-1, 0, 0), offsetX),
|
||||
new Plane(new Vector3(0, -1, 0), offsetY),
|
||||
new Plane(new Vector3(0, 0, 1), -offsetZ),
|
||||
new Plane(new Vector3(-1, 0, 0), offsetX).applyMatrix4(rotSceneMatrix),
|
||||
new Plane(new Vector3(0, -1, 0), offsetY).applyMatrix4(rotSceneMatrix),
|
||||
new Plane(new Vector3(0, 0, 1), -offsetZ).applyMatrix4(rotSceneMatrix),
|
||||
];
|
||||
if (clipPlaneSwappedX.value) planes[0].negate();
|
||||
if (clipPlaneSwappedY.value) planes[1].negate();
|
||||
@@ -227,9 +232,10 @@ function onEdgeWidthChange(newEdgeWidth: number) {
|
||||
line.userData.niceLine = line2;
|
||||
// line.parent!.remove(line); // Keep it for better raycast and selection!
|
||||
line2.userData.noHit = true;
|
||||
line2.visible = enabledFeatures.value.includes(1);
|
||||
edgeWidthChangeCleanup.push(() => {
|
||||
line2.parent!.remove(line2);
|
||||
line.visible = true;
|
||||
line.visible = enabledFeatures.value.includes(1);
|
||||
props.viewer!!.onElemReady((elem) => {
|
||||
elem.removeEventListener('resize', () => resizeListener(elem));
|
||||
});
|
||||
@@ -331,7 +337,7 @@ if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) a
|
||||
|
||||
<template>
|
||||
<v-expansion-panel :value="modelName">
|
||||
<v-expansion-panel-title collapse-icon="hide-this-icon" expand-icon="hide-this-icon">
|
||||
<v-expansion-panel-title>
|
||||
<v-btn-toggle v-model="enabledFeatures" color="surface-light" multiple @click.stop>
|
||||
<v-btn icon>
|
||||
<v-tooltip activator="parent">Toggle Faces ({{ faceCount }})</v-tooltip>
|
||||
@@ -430,10 +436,6 @@ if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) a
|
||||
}
|
||||
|
||||
/* More compact accordions */
|
||||
.v-expansion-panel {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
.v-expansion-panel-title {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -444,11 +446,11 @@ if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) a
|
||||
}
|
||||
|
||||
.v-btn {
|
||||
--v-btn-height: 16px;
|
||||
--v-btn-height: 12px;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
width: 179px;
|
||||
width: 172px;
|
||||
font-size: 110%;
|
||||
overflow-x: clip;
|
||||
overflow-y: visible; /* HACK: bottom of text is lost otherwise (due to buggy -webkit-box bounds?) */
|
||||
@@ -465,10 +467,6 @@ if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) a
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.hide-this-icon {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.mdi-checkbox-blank-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,3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5C21,3.89 20.1,3 19,3M19,5V19H5V5H19Z"/></svg>');
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import Model from "./Model.vue";
|
||||
import {inject, ref, type Ref} from "vue";
|
||||
|
||||
const props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||
const emit = defineEmits<{ remove: [string] }>()
|
||||
const emit = defineEmits<{ removeModel: [string] }>()
|
||||
|
||||
let {sceneDocument} = inject<{ sceneDocument: Ref<Document> }>('sceneDocument')!!;
|
||||
|
||||
@@ -32,7 +32,7 @@ function meshName(mesh: Mesh) {
|
||||
}
|
||||
|
||||
function onRemove(mesh: Mesh) {
|
||||
emit('remove', meshName(mesh))
|
||||
emit('removeModel', meshName(mesh))
|
||||
}
|
||||
|
||||
function findModel(name: string) {
|
||||
|
||||
5
frontend/shims.d.ts
vendored
5
frontend/shims.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
// 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,12 +1,15 @@
|
||||
<script lang="ts" setup>
|
||||
import {onMounted, onUpdated, ref} from "vue";
|
||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||
// @ts-expect-error
|
||||
import * as OrientationGizmoRaw from "three-orientation-gizmo/src/OrientationGizmo";
|
||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||
import {currentSceneRotation} from "../viewer/lighting.ts";
|
||||
|
||||
// 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";
|
||||
import {Euler} from "three/src/math/Euler.js";
|
||||
|
||||
(globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
|
||||
|
||||
@@ -48,7 +51,7 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
|
||||
axis.direction.y = -axis.direction.y;
|
||||
axis.direction.z = -axis.direction.z;
|
||||
}
|
||||
wantedTheta = Math.atan2(axis.direction.x, axis.direction.z);
|
||||
wantedTheta = Math.atan2(axis.direction.x, axis.direction.z) + currentSceneRotation;
|
||||
wantedPhi = Math.asin(-axis.direction.y) + Math.PI / 2;
|
||||
attempt++;
|
||||
}
|
||||
@@ -66,7 +69,12 @@ let gizmo: HTMLElement & { update: () => void }
|
||||
|
||||
function updateGizmo() {
|
||||
if (gizmo.isConnected) {
|
||||
// HACK: Update camera temporarily to match skybox rotation before updating the gizmo and go back
|
||||
let prevRot = ((gizmo as any).camera).rotation.clone() as Euler;
|
||||
let thetaMat = new Matrix4().makeRotationY(-currentSceneRotation);
|
||||
((gizmo as any).camera).rotation.setFromRotationMatrix(thetaMat.multiply(new Matrix4().makeRotationFromEuler(prevRot)));
|
||||
gizmo.update();
|
||||
((gizmo as any).camera).rotation.set(prevRot.x, prevRot.y, prevRot.z);
|
||||
requestIdleCallback(updateGizmo, {timeout: 250});
|
||||
}
|
||||
}
|
||||
|
||||
404
frontend/tools/PlaygroundDialogContent.vue
Normal file
404
frontend/tools/PlaygroundDialogContent.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<script setup lang="ts">
|
||||
import {setupMonaco} from "./monaco.ts";
|
||||
import {VueMonacoEditor} from '@guolao/vue-monaco-editor'
|
||||
import {nextTick, onMounted, ref, shallowRef} from "vue";
|
||||
import Loading from "../misc/Loading.vue";
|
||||
import {newPyodideWorker} from "./pyodide-worker-api.ts";
|
||||
import {
|
||||
mdiBroom,
|
||||
mdiCircleOpacity,
|
||||
mdiClose,
|
||||
mdiContentSave,
|
||||
mdiFolderOpen,
|
||||
mdiPlay,
|
||||
mdiReload,
|
||||
mdiShare,
|
||||
mdiUpload
|
||||
} from "@mdi/js";
|
||||
import {VBtn, VCard, VCardText, VSlider, VSpacer, VToolbar, VToolbarTitle, VTooltip} from "vuetify/components";
|
||||
// @ts-expect-error
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
import {version as pyodideVersion} from "pyodide";
|
||||
import {gzip} from 'pako';
|
||||
import {b64UrlEncode} from "./b64.ts";
|
||||
import {Base64} from 'js-base64'; // More compatible with binary data from python...
|
||||
import {NetworkUpdateEvent, NetworkUpdateEventModel} from "../misc/network.ts";
|
||||
import {settings} from "../misc/settings.ts";
|
||||
// @ts-expect-error
|
||||
import playgroundStartupCode from './PlaygroundStartup.py?raw';
|
||||
import {uploadFile} from "./upload-file.ts";
|
||||
|
||||
const model = defineModel<{ code: string, firstTime: boolean }>({required: true}); // Initial code should only be set on first load!
|
||||
const emit = defineEmits<{ close: [], updateModel: [NetworkUpdateEvent] }>()
|
||||
|
||||
// ============ LOAD MONACO EDITOR ============
|
||||
setupMonaco() // Must be called before using the editor
|
||||
|
||||
const outputText = ref(``);
|
||||
|
||||
function output(text: string) {
|
||||
outputText.value += text; // Append to output
|
||||
// Avoid too much output, keep it reasonable
|
||||
let max_output = 10000; // 10k characters
|
||||
if (outputText.value.length > max_output) {
|
||||
outputText.value = outputText.value.slice(-max_output); // Keep only the last 10k characters
|
||||
}
|
||||
console.log(text); // Also log to console
|
||||
nextTick(() => { // Scroll to bottom
|
||||
const consoleElement = document.querySelector('.playground-console');
|
||||
if (consoleElement) {
|
||||
consoleElement.scrollTop = consoleElement.scrollHeight;
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const MONACO_EDITOR_OPTIONS = {
|
||||
automaticLayout: true,
|
||||
formatOnType: true,
|
||||
formatOnPaste: true,
|
||||
}
|
||||
|
||||
const editorTheme = window.matchMedia("(prefers-color-scheme: dark)").matches ? `vs-dark` : `vs`
|
||||
const editor = shallowRef()
|
||||
const handleMount = (editorInstance: typeof VueMonacoEditor) => (editor.value = editorInstance)
|
||||
const opacity = ref(0.9); // Opacity for the editor (overriden when settings are loaded)
|
||||
|
||||
// ============ LOAD PYODIDE (ASYNC) ============
|
||||
let pyodideWorker: ReturnType<typeof newPyodideWorker> | null = (import.meta as any).hot?.data?.pyodideWorker || null;
|
||||
const running = ref(true);
|
||||
|
||||
async function setupPyodide(first: boolean, loadSnapshot: Uint8Array | undefined = undefined) {
|
||||
running.value = true;
|
||||
if (opacity.value == 0.0 && !first) opacity.value = 0.9; // User doesn't know how to show code again, reset after reopening
|
||||
if (pyodideWorker === null) {
|
||||
output("Creating new Pyodide worker...\n");
|
||||
pyodideWorker = newPyodideWorker(Object.assign({
|
||||
// Note: python wheels are downloaded from the CDN, as we can't know which ones are needed in advance to bundle them
|
||||
// Furthermore, this lets us use the latest version of all wheels including ocp-specific ones without app updates
|
||||
indexURL: `https://cdn.jsdelivr.net/pyodide/v${pyodideVersion}/full/`,
|
||||
packages: ["micropip", "sqlite3"], // Faster load if done here
|
||||
// _makeSnapshot: true, // Enable snapshotting for faster startup (still experimental: breaks loading any packages)
|
||||
}, (loadSnapshot ? {_loadSnapshot: loadSnapshot} : {}))); // Load snapshot if provided
|
||||
if ((import.meta as any).hot) (import.meta as any).hot.data.pyodideWorker = pyodideWorker
|
||||
} else {
|
||||
output("Reusing existing Pyodide instance...\n");
|
||||
}
|
||||
output("Preloading packages...\n");
|
||||
await pyodideWorker.asyncRun(playgroundStartupCode, output, output); // Also import yacv_server and mock ocp_vscode here for faster custom code execution
|
||||
running.value = false; // Indicate that Pyodide is ready
|
||||
output("Pyodide worker ready.\n");
|
||||
}
|
||||
|
||||
async function runCode() {
|
||||
if (pyodideWorker === null) {
|
||||
output("Pyodide worker is not initialized. Please wait...\n");
|
||||
return;
|
||||
}
|
||||
if (running.value) {
|
||||
output("Pyodide is already running. Please wait...\n");
|
||||
return;
|
||||
}
|
||||
output("Running code...\n");
|
||||
try {
|
||||
running.value = true;
|
||||
await pyodideWorker.asyncRun(model.value.code, output, (msg: string) => {
|
||||
// Detect models printed to console (since http server is not available in pyodide)
|
||||
if (msg.startsWith(yacvServerModelPrefix)) {
|
||||
const modelData = msg.slice(yacvServerModelPrefix.length);
|
||||
onModelData(modelData);
|
||||
} else {
|
||||
output(msg); // Print other messages directly
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
output(`Error running code: ${e}\n`);
|
||||
} finally {
|
||||
output("Code execution finished.\n");
|
||||
running.value = false; // Indicate that Pyodide is ready
|
||||
}
|
||||
}
|
||||
|
||||
const yacvServerModelPrefix = "yacv_server://model/";
|
||||
|
||||
function onModelData(modelData: string) {
|
||||
output(`Model data detected... ${modelData.length}B\n`);
|
||||
// Decode the model data and emit the event for the interface to handle
|
||||
// - Start by finding the end of the initial json object by looking for brackets.
|
||||
let i = 0;
|
||||
let openBrackets = 0;
|
||||
for (; i < modelData.length; i++) {
|
||||
if (modelData[i] === '{') openBrackets++;
|
||||
else if (modelData[i] === '}') openBrackets--;
|
||||
if (openBrackets === 0) break; // Found the end of the JSON object
|
||||
}
|
||||
if (openBrackets !== 0) throw `Error: Invalid model data received: ${modelData}\n`
|
||||
const jsonData = modelData.slice(0, i + 1); // Extract the JSON part and parse it into the proper class
|
||||
let modelMetadataRaw = JSON.parse(jsonData);
|
||||
const modelMetadata: any = new NetworkUpdateEventModel(modelMetadataRaw.name, "", modelMetadataRaw.hash, modelMetadataRaw.is_remove)
|
||||
// console.debug(`Model metadata:`, modelMetadata);
|
||||
output(`Model metadata: ${JSON.stringify(modelMetadata)}\n`);
|
||||
// - Now decode the rest of the model data which is a single base64 encoded glb file (or an empty string)
|
||||
if (!modelMetadata.isRemove) {
|
||||
const binaryData = Base64.toUint8Array(modelData.slice(i + 1)); // Extract the base64 part
|
||||
console.assert(binaryData.slice(0, 4).toString() == "103,108,84,70", // Ugly...
|
||||
"Invalid GLTF binary data received: " + binaryData.slice(0, 4).toString());
|
||||
// - Save for upload and share link feature
|
||||
builtModelsGlb[modelMetadata.name] = binaryData;
|
||||
// - Create a Blob from the binary data to be used as a URL
|
||||
const blob = new Blob([binaryData], {type: 'model/gltf-binary'});
|
||||
modelMetadata.url = URL.createObjectURL(blob); // Set the hacked URL in the model metadata XXX: revoked on App.vue
|
||||
} else {
|
||||
delete builtModelsGlb[modelMetadata.name]; // Remove from built models if it's a remove request
|
||||
}
|
||||
// - Emit the event with the model metadata and URL
|
||||
let networkUpdateEvent = new NetworkUpdateEvent([modelMetadata], () => {
|
||||
});
|
||||
emit('updateModel', networkUpdateEvent);
|
||||
}
|
||||
|
||||
function resetWorker(loadSnapshot: Uint8Array | undefined = undefined) {
|
||||
if (pyodideWorker) {
|
||||
pyodideWorker.terminate(); // Terminate existing worker
|
||||
pyodideWorker = null; // Reset worker reference
|
||||
}
|
||||
outputText.value = ``; // Clear output text
|
||||
setupPyodide(false, loadSnapshot); // Reinitialize Pyodide
|
||||
}
|
||||
|
||||
function shareLinkCommon(added: Record<string, string>, forgotten: Array<string>) {
|
||||
const baseUrl = window.location
|
||||
const searchParams = new URLSearchParams(baseUrl.search);
|
||||
for (const k of forgotten) searchParams.delete(k);
|
||||
const hashParams = new URLSearchParams(baseUrl.hash.slice(1)); // Keep all previous URL parameters
|
||||
for (const k of forgotten) hashParams.delete(k);
|
||||
for (const k in added) hashParams.append(k, added[k]); // Prefer hash to GET
|
||||
const shareUrl = `${baseUrl.origin}${baseUrl.pathname}?${searchParams}#${hashParams}`;
|
||||
output(`Share link ready: ${shareUrl}\n`)
|
||||
if (navigator.clipboard?.writeText === undefined) {
|
||||
output("Clipboard API not available. Please copy the link manually.\n");
|
||||
return;
|
||||
} else {
|
||||
navigator.clipboard.writeText(shareUrl)
|
||||
.then(() => output("Link copied to clipboard!\n"))
|
||||
.catch(err => output(`Failed to copy link: ${err}\n`));
|
||||
}
|
||||
}
|
||||
|
||||
function shareLink() {
|
||||
shareLinkCommon({'pg_code': b64UrlEncode(gzip(model.value.code, {level: 9}))}, ['pg_code']);
|
||||
}
|
||||
|
||||
const builtModelsGlb: Record<string, Uint8Array> = {}; // Store built models to support uploading
|
||||
async function uploadAndShareLink() {
|
||||
try {
|
||||
output("Uploading files...\n");
|
||||
|
||||
// Upload code.py
|
||||
const codeBlob = new Blob([model.value.code], {type: 'text/x-python'});
|
||||
const newParams: Record<string, string> = {
|
||||
'pg_code': await uploadFile('code.py', new Uint8Array(await codeBlob.arrayBuffer()))
|
||||
};
|
||||
|
||||
// Upload all models
|
||||
for (const name in builtModelsGlb) {
|
||||
const glb: any = builtModelsGlb[name];
|
||||
newParams['preload'] = await uploadFile(name + '.glb', glb);
|
||||
}
|
||||
|
||||
// Build share URL
|
||||
return shareLinkCommon(newParams, ['pg_code'])
|
||||
} catch (e) {
|
||||
output(`Error uploading/sharing files: ${e}. Falling back to private share link.\n`);
|
||||
return shareLink(); // Fallback to private share link if upload fails
|
||||
}
|
||||
}
|
||||
|
||||
function saveSnapshot() {
|
||||
throw new Error("Not implemented yet!"); // TODO: Implement snapshot saving
|
||||
}
|
||||
|
||||
function loadSnapshot() {
|
||||
throw new Error("Not implemented yet!"); // TODO: Implement snapshot loading
|
||||
}
|
||||
|
||||
(async () => {
|
||||
const sett = await settings
|
||||
if (model.value.firstTime) opacity.value = sett.pg_opacity_loading
|
||||
await setupPyodide(true);
|
||||
if (model.value.firstTime) {
|
||||
await runCode();
|
||||
opacity.value = sett.pg_opacity_loaded
|
||||
model.value.firstTime = false
|
||||
}
|
||||
})()
|
||||
|
||||
// Add keyboard shortcuts
|
||||
const editorRef = ref<HTMLElement | null>(null);
|
||||
onMounted(() => {
|
||||
if (editorRef.value) {
|
||||
editorRef.value.addEventListener('keydown', (event: Event) => {
|
||||
if (!(event instanceof KeyboardEvent)) return; // Ensure event is a KeyboardEvent
|
||||
if (event.key === 'F10') { // Run code on F10
|
||||
event.preventDefault(); // Prevent default behavior of the key
|
||||
runCode();
|
||||
} else if (event.key === 'Escape') { // Close on Escape key
|
||||
emit('close');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-card class="popup-card"
|
||||
:style="opacity == 0 ? `position: absolute; top: calc(-50vh + 24px); width: calc(100vw - 64px);` : ``">
|
||||
<v-toolbar class="popup">
|
||||
<v-toolbar-title style="flex: 0 1 auto">Playground</v-toolbar-title>
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<span style="display: inline-flex; margin-right: 16px;">
|
||||
<svg-icon :path="mdiCircleOpacity" type="mdi" style="height: 32px"></svg-icon>
|
||||
<v-slider v-model="opacity" :max="1" :min="0" :step="0.1"
|
||||
style="width: 100px; height: 32px">
|
||||
</v-slider>
|
||||
<v-tooltip activator="parent"
|
||||
location="bottom">Opacity of the editor (0 = hidden, 1 = fully visible)</v-tooltip>
|
||||
</span>
|
||||
|
||||
<span style="padding-left: 12px; width: 48px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||
<v-btn icon disabled @click="saveSnapshot()">
|
||||
<svg-icon :path="mdiContentSave" type="mdi"/>
|
||||
</v-btn>
|
||||
<v-tooltip activator="parent"
|
||||
location="bottom">Save current state to a snapshot for fast startup (WIP)</v-tooltip>
|
||||
</span>
|
||||
<span style="padding-right: 12px; width: 48px;"><!-- This span is only needed to force tooltip to work while button is disabled -->
|
||||
<v-btn icon disabled @click="loadSnapshot()">
|
||||
<svg-icon :path="mdiFolderOpen" type="mdi"/>
|
||||
</v-btn>
|
||||
<v-tooltip activator="parent" location="bottom">Load snapshot for fast startup (WIP)</v-tooltip>
|
||||
</span>
|
||||
|
||||
<v-btn icon @click="shareLink()" style="padding-left: 12px;">
|
||||
<svg-icon :path="mdiShare" type="mdi"/>
|
||||
<v-tooltip activator="parent" location="bottom">Share link that automatically runs the code.<br/>Only people
|
||||
with the link can see the code.
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="uploadAndShareLink()" style="padding-right: 12px">
|
||||
<svg-icon :path="mdiShare" type="mdi" style="position: absolute; scale: 75%; top: 6px;"/>
|
||||
<svg-icon :path="mdiUpload" type="mdi" style="position: absolute; scale: 75%; bottom: 6px;"/>
|
||||
<v-tooltip activator="parent" location="bottom">Uploads all models and code and then shares a link to them.<br/>Useful
|
||||
to view the models while the playground loads, but uses third-party storage.
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="resetWorker()" style="padding-left: 12px;">
|
||||
<svg-icon :path="mdiReload" type="mdi"/>
|
||||
<v-tooltip activator="parent" location="bottom">Reset Pyodide worker (this forgets all previous state and will
|
||||
take a little while)
|
||||
</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="runCode()" :disabled="running" style="padding-right: 12px">
|
||||
<svg-icon :path="mdiPlay" type="mdi"/>
|
||||
<Loading v-if="running" style="position: absolute; top: -16%; left: -28%"/><!-- Ugly positioning -->
|
||||
<v-tooltip activator="parent" location="bottom">Run code</v-tooltip>
|
||||
</v-btn>
|
||||
|
||||
<v-btn icon @click="emit('close')">
|
||||
<svg-icon :path="mdiClose" type="mdi"/>
|
||||
<v-tooltip activator="parent" location="bottom">Close (Pyodide remains loaded)</v-tooltip>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
<v-card-text class="popup-card-text" :style="opacity == 0 ? `display: none` : ``">
|
||||
<!-- Only show content if opacity is greater than 0 -->
|
||||
<div class="playground-container">
|
||||
<div class="playground-editor" ref="editorRef">
|
||||
<VueMonacoEditor v-model:value="model.code" :theme="editorTheme" :options="MONACO_EDITOR_OPTIONS"
|
||||
language="python" @mount="handleMount"/>
|
||||
</div>
|
||||
<div class="playground-console">
|
||||
<h3 style="display:flex; align-items: center; justify-content: space-between; margin: 0;">
|
||||
Console Output
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn @click="outputText = ''">
|
||||
<svg-icon :path="mdiBroom" type="mdi" class="h-"/>
|
||||
</v-btn>
|
||||
</h3>
|
||||
<pre>{{ outputText }}</pre> <!-- Placeholder for console output -->
|
||||
<Loading v-if="running"/>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.popup-card {
|
||||
background-color: #00000000; /* Transparent background */
|
||||
}
|
||||
|
||||
.v-toolbar.popup > * {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.popup-card-text {
|
||||
background-color: #1e1e1e; /* Matches the Monaco editor background */
|
||||
opacity: v-bind(opacity);
|
||||
}
|
||||
|
||||
.playground-container {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.playground-editor {
|
||||
flex: 1;
|
||||
height: calc(100vh - 150px);
|
||||
}
|
||||
|
||||
.playground-console {
|
||||
flex: 0.5;
|
||||
padding: 10px;
|
||||
overflow-y: auto;
|
||||
min-width: 100px;
|
||||
height: calc(100vh - 150px);
|
||||
}
|
||||
|
||||
.playground-console pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (min-height: 100vw) {
|
||||
/* Adjust layout for vertical space */
|
||||
.playground-container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.playground-editor {
|
||||
flex: 1;
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.playground-editor > * {
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.playground-console {
|
||||
max-height: calc(40vh - 150px);
|
||||
}
|
||||
}
|
||||
|
||||
/* TODO: Adjust more colors on bright mode */
|
||||
</style>
|
||||
|
||||
<style>
|
||||
/* https://stackoverflow.com/questions/47017753/monaco-editor-dynamically-resizable/71876526#71876526 */
|
||||
.monaco-editor {
|
||||
position: absolute !important;
|
||||
}
|
||||
</style>
|
||||
24
frontend/tools/PlaygroundStartup.py
Normal file
24
frontend/tools/PlaygroundStartup.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import micropip
|
||||
|
||||
# Prioritize the OCP.wasm package repository for finding the ported dependencies.
|
||||
micropip.set_index_urls(["https://yeicor.github.io/OCP.wasm", "https://pypi.org/simple"])
|
||||
|
||||
# For build123d < 0.10.0, we need to install the mock the py-lib3mf package (before the main install).
|
||||
await micropip.install("lib3mf")
|
||||
micropip.add_mock_package("py-lib3mf", "2.4.1", modules={"py_lib3mf": 'from lib3mf import *'})
|
||||
|
||||
# Install the yacv_server package, which is the main server for the OCP.wasm playground; and also preinstalls build123d.
|
||||
await micropip.install("yacv_server", pre=True)
|
||||
|
||||
# Preimport the yacv_server package to ensure it is available in the global scope, and mock the ocp_vscode package.
|
||||
from yacv_server import *
|
||||
|
||||
micropip.add_mock_package("ocp-vscode", "2.8.9", modules={"ocp_vscode": 'from yacv_server import *'})
|
||||
show_object = show
|
||||
|
||||
# Preinstall the font-fetcher package and install its hook to automatically download any requested font.
|
||||
await micropip.install("font-fetcher", pre=True)
|
||||
|
||||
from font_fetcher.ocp import install_ocp_font_hook
|
||||
|
||||
install_ocp_font_hook()
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import {inject, ref, type ShallowRef, watch} from "vue";
|
||||
import {VBtn, VSelect, VTooltip} from "vuetify/lib/components/index.mjs";
|
||||
// @ts-expect-error
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
import type {ModelViewerElement} from '@google/model-viewer';
|
||||
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
|
||||
@@ -28,7 +29,7 @@ let emit = defineEmits<{ findModel: [string] }>();
|
||||
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
|
||||
let selectionEnabled = ref(false);
|
||||
let selected = defineModel<Array<SelectionInfo>>({default: []});
|
||||
let highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
||||
let openNextSelection = ref([false, false]); // Second is whether selection was enabled before
|
||||
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
|
||||
let showDistances = ref<Boolean>(true);
|
||||
|
||||
@@ -149,7 +150,7 @@ let mouseUpListener = (event: MouseEvent) => {
|
||||
// Return the best hit
|
||||
[0] as Intersection<MObject3D> | undefined;
|
||||
|
||||
if (!highlightNextSelection.value[0]) {
|
||||
if (!openNextSelection.value[0]) {
|
||||
// If we are selecting, toggle the selection or deselect all if no hit
|
||||
let selInfo: SelectionInfo | null = null;
|
||||
if (hit) selInfo = hitToSelectionInfo(hit);
|
||||
@@ -171,7 +172,7 @@ let mouseUpListener = (event: MouseEvent) => {
|
||||
// Otherwise, highlight the model that owns the hit
|
||||
emit('findModel', hit.object.userData[extrasNameKey])
|
||||
// And reset the selection mode
|
||||
toggleHighlightNextSelection()
|
||||
toggleOpenNextSelection()
|
||||
}
|
||||
scene.queueRender() // Force rerender of model-viewer
|
||||
}
|
||||
@@ -209,17 +210,17 @@ function toggleSelection() {
|
||||
setDisableTap(selectionEnabled.value);
|
||||
}
|
||||
|
||||
function toggleHighlightNextSelection() {
|
||||
highlightNextSelection.value = [
|
||||
!highlightNextSelection.value[0],
|
||||
highlightNextSelection.value[0] ? highlightNextSelection.value[1] : selectionEnabled.value
|
||||
function toggleOpenNextSelection() {
|
||||
openNextSelection.value = [
|
||||
!openNextSelection.value[0],
|
||||
openNextSelection.value[0] ? openNextSelection.value[1] : selectionEnabled.value
|
||||
];
|
||||
if (highlightNextSelection.value[0]) {
|
||||
if (openNextSelection.value[0]) {
|
||||
// Reuse selection code to identify the model
|
||||
if (!selectionEnabled.value) toggleSelection()
|
||||
} else {
|
||||
if (selectionEnabled.value !== highlightNextSelection.value[1]) toggleSelection()
|
||||
highlightNextSelection.value = [false, false];
|
||||
if (selectionEnabled.value !== openNextSelection.value[1]) toggleSelection()
|
||||
openNextSelection.value = [false, false];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +428,10 @@ defineExpose({deselect, updateBoundingBox, updateDistances});
|
||||
|
||||
// Add keyboard shortcuts
|
||||
window.addEventListener('keydown', (event) => {
|
||||
if ((event.target as any)?.tagName && ((event.target as any).tagName === 'INPUT' || (event.target as any).tagName === 'TEXTAREA')) {
|
||||
// Ignore key events when an input is focused, except for text inputs
|
||||
return;
|
||||
}
|
||||
if (event.key === 's') {
|
||||
if (selectFilter.value == 'Any (S)') toggleSelection();
|
||||
else {
|
||||
@@ -455,8 +460,8 @@ window.addEventListener('keydown', (event) => {
|
||||
toggleShowBoundingBox();
|
||||
} else if (event.key === 'd') {
|
||||
toggleShowDistances();
|
||||
} else if (event.key === 'h') {
|
||||
toggleHighlightNextSelection();
|
||||
} else if (event.key === 'o') {
|
||||
toggleOpenNextSelection();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -474,8 +479,8 @@ window.addEventListener('keydown', (event) => {
|
||||
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>
|
||||
<v-btn :color="openNextSelection[0] ? 'surface-light' : ''" icon @click="toggleOpenNextSelection">
|
||||
<v-tooltip activator="parent">(O)pen 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">
|
||||
|
||||
@@ -13,13 +13,26 @@ import {
|
||||
import OrientationGizmo from "./OrientationGizmo.vue";
|
||||
import type {PerspectiveCamera} from "three/src/cameras/PerspectiveCamera.js";
|
||||
import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
|
||||
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
|
||||
import {
|
||||
mdiClose,
|
||||
mdiCrosshairsGps,
|
||||
mdiDownload,
|
||||
mdiGithub,
|
||||
mdiLicense,
|
||||
mdiLightbulb,
|
||||
mdiProjector,
|
||||
mdiScriptTextPlay
|
||||
} from '@mdi/js'
|
||||
// @ts-expect-error
|
||||
import SvgIcon from '@jamescoyle/vue-icon';
|
||||
import type {ModelViewerElement} from '@google/model-viewer';
|
||||
import Loading from "../misc/Loading.vue";
|
||||
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
|
||||
import {defineAsyncComponent, ref, type Ref} from "vue";
|
||||
import {defineAsyncComponent, ref} from "vue";
|
||||
import type {SelectionInfo} from "./selection";
|
||||
import {settings} from "../misc/settings.ts";
|
||||
import type {NetworkUpdateEvent} from "../misc/network.ts";
|
||||
import IfNotSmallBuild from "../misc/IfNotSmallBuild.vue";
|
||||
|
||||
const SelectionComponent = defineAsyncComponent({
|
||||
loader: () => import("./Selection.vue"),
|
||||
@@ -34,11 +47,26 @@ const LicensesDialogContent = defineAsyncComponent({
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
const PlaygroundDialogContent = defineAsyncComponent({
|
||||
loader: () => import("./PlaygroundDialogContent.vue"),
|
||||
loadingComponent: Loading,
|
||||
delay: 0,
|
||||
});
|
||||
|
||||
|
||||
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
|
||||
const emit = defineEmits<{ findModel: [string] }>()
|
||||
const emit = defineEmits<{ findModel: [string], updateModel: [NetworkUpdateEvent] }>()
|
||||
|
||||
let selection: Ref<Array<SelectionInfo>> = ref([]);
|
||||
const sett = ref<any | null>(null);
|
||||
const showPlaygroundDialog = ref(false);
|
||||
const pg_model = ref({code: '# Loading...', firstTime: false});
|
||||
(async () => {
|
||||
sett.value = await settings;
|
||||
pg_model.value = {code: sett.value.pg_code, firstTime: true};
|
||||
showPlaygroundDialog.value = pg_model.value.code != "";
|
||||
})();
|
||||
|
||||
let selection = ref<Array<SelectionInfo>>([]);
|
||||
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
|
||||
@@ -100,6 +128,7 @@ async function downloadSceneGlb() {
|
||||
link.download = file.name;
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
}
|
||||
|
||||
async function openGithub() {
|
||||
@@ -114,10 +143,14 @@ function removeObjectSelections(objName: string) {
|
||||
selectionComp.value?.updateDistances();
|
||||
}
|
||||
|
||||
defineExpose({removeObjectSelections});
|
||||
defineExpose({removeObjectSelections, openPlayground: () => showPlaygroundDialog.value = true});
|
||||
|
||||
// Add keyboard shortcuts
|
||||
window.addEventListener('keydown', (event) => {
|
||||
document.addEventListener('keydown', (event) => {
|
||||
if ((event.target as any)?.tagName && ((event.target as any).tagName === 'INPUT' || (event.target as any).tagName === 'TEXTAREA')) {
|
||||
// Ignore key events when an input is focused, except for text inputs
|
||||
return;
|
||||
}
|
||||
if (event.key === 'p') toggleProjection();
|
||||
else if (event.key === 'c') centerCamera();
|
||||
else if (event.key === 'd') downloadSceneGlb();
|
||||
@@ -139,6 +172,13 @@ window.addEventListener('keydown', (event) => {
|
||||
<v-tooltip activator="parent">Re(c)enter Camera</v-tooltip>
|
||||
<svg-icon :path="mdiCrosshairsGps" type="mdi"/>
|
||||
</v-btn>
|
||||
<span>
|
||||
<v-tooltip activator="parent">To rotate the light hold shift and drag the mouse or use two fingers<br/>
|
||||
Note that this breaks slightly clipping planes for now... (restart to fix)</v-tooltip>
|
||||
<v-btn icon disabled style="background: black;">
|
||||
<svg-icon :path="mdiLightbulb" type="mdi"/>
|
||||
</v-btn>
|
||||
</span>
|
||||
<v-divider/>
|
||||
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
|
||||
<selection-component ref="selectionComp" v-model="selection" :viewer="props.viewer as any"
|
||||
@@ -146,11 +186,26 @@ window.addEventListener('keydown', (event) => {
|
||||
<v-divider/>
|
||||
<v-spacer></v-spacer>
|
||||
<h5>Extras</h5>
|
||||
<v-dialog v-model="showPlaygroundDialog" persistent :scrim="false" attach="body">
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn v-bind="props" style="width: 100%">
|
||||
<v-tooltip activator="parent">Open a python editor and build models directly in the browser!</v-tooltip>
|
||||
<svg-icon :path="mdiScriptTextPlay" type="mdi"/>
|
||||
Sandbox
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<if-not-small-build>
|
||||
<playground-dialog-content v-if="sett != null" v-model="pg_model" @close="isActive.value = false"
|
||||
@update-model="(event: NetworkUpdateEvent) => emit('updateModel', event)"/>
|
||||
</if-not-small-build>
|
||||
</template>
|
||||
</v-dialog>
|
||||
<v-btn icon @click="downloadSceneGlb">
|
||||
<v-tooltip activator="parent">(D)ownload Scene</v-tooltip>
|
||||
<svg-icon :path="mdiDownload" type="mdi"/>
|
||||
</v-btn>
|
||||
<v-dialog id="licenses-dialog" fullscreen>
|
||||
<v-dialog>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn icon v-bind="props">
|
||||
<v-tooltip activator="parent">Show Licenses</v-tooltip>
|
||||
@@ -158,11 +213,10 @@ window.addEventListener('keydown', (event) => {
|
||||
</v-btn>
|
||||
</template>
|
||||
<template v-slot:default="{ isActive }">
|
||||
<v-card>
|
||||
<v-toolbar>
|
||||
<v-card style="height: 90vh">
|
||||
<v-toolbar class="popup">
|
||||
<v-toolbar-title>Licenses</v-toolbar-title>
|
||||
<v-spacer>
|
||||
</v-spacer>
|
||||
<v-spacer></v-spacer>
|
||||
<v-btn icon @click="isActive.value = false">
|
||||
<svg-icon :path="mdiClose" type="mdi"/>
|
||||
</v-btn>
|
||||
@@ -199,4 +253,17 @@ window.addEventListener('keydown', (event) => {
|
||||
h5 {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.v-toolbar {
|
||||
position: sticky !important;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.v-toolbar.popup {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.v-toolbar.popup > div {
|
||||
height: 32px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
21
frontend/tools/b64.ts
Normal file
21
frontend/tools/b64.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export function b64UrlEncode(data: Uint8Array): string {
|
||||
const base64 = btoa(String.fromCharCode(...data));
|
||||
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
export function b64UrlDecode(encoded: string): Uint8Array {
|
||||
// Replace URL-safe characters with standard base64 characters
|
||||
let base64 = encoded.replace(/-/g, '+').replace(/_/g, '/');
|
||||
// Add padding if necessary
|
||||
const padding = base64.length % 4;
|
||||
if (padding) {
|
||||
base64 += '='.repeat(4 - padding);
|
||||
}
|
||||
// Decode the base64 string to a byte array
|
||||
const binaryString = atob(base64);
|
||||
const byteArray = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
byteArray[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return byteArray;
|
||||
}
|
||||
35
frontend/tools/monaco.ts
Normal file
35
frontend/tools/monaco.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {loader} from "@guolao/vue-monaco-editor"
|
||||
|
||||
import * as monaco from "monaco-editor"
|
||||
//@ts-ignore
|
||||
import editorWorker from "monaco-editor/esm/vs/editor/editor.worker?worker"
|
||||
//@ts-ignore
|
||||
import jsonWorker from "monaco-editor/esm/vs/language/json/json.worker?worker"
|
||||
//@ts-ignore
|
||||
import cssWorker from "monaco-editor/esm/vs/language/css/css.worker?worker"
|
||||
//@ts-ignore
|
||||
import htmlWorker from "monaco-editor/esm/vs/language/html/html.worker?worker"
|
||||
//@ts-ignore
|
||||
import tsWorker from "monaco-editor/esm/vs/language/typescript/ts.worker?worker"
|
||||
|
||||
self.MonacoEnvironment = {
|
||||
getWorker(_, label) {
|
||||
if (label === "json") {
|
||||
return new jsonWorker()
|
||||
}
|
||||
if (label === "css" || label === "scss" || label === "less") {
|
||||
return new cssWorker()
|
||||
}
|
||||
if (label === "html" || label === "handlebars" || label === "razor") {
|
||||
return new htmlWorker()
|
||||
}
|
||||
if (label === "typescript" || label === "javascript") {
|
||||
return new tsWorker()
|
||||
}
|
||||
return new editorWorker()
|
||||
}
|
||||
}
|
||||
|
||||
export function setupMonaco() {
|
||||
loader.config({monaco})
|
||||
}
|
||||
45
frontend/tools/pyodide-worker-api.ts
Normal file
45
frontend/tools/pyodide-worker-api.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import type {loadPyodide} from "pyodide";
|
||||
import type {MessageEventDataIn} from "./pyodide-worker.ts";
|
||||
|
||||
let requestId = 0;
|
||||
|
||||
/** Simple API for the Pyodide worker. */
|
||||
export function newPyodideWorker(initOpts: Parameters<typeof loadPyodide>[0]) {
|
||||
let worker = new Worker(new URL('./pyodide-worker.ts', import.meta.url), {type: "module"});
|
||||
worker.postMessage(initOpts);
|
||||
const commonRequestResponse = (event: MessageEventDataIn, stdout?: (msg: string) => void, stderr?: (msg: string) => void) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
worker.addEventListener("message", function listener(event: MessageEvent) {
|
||||
if (stdout && event.data?.stdout) {
|
||||
stdout(event.data.stdout); // No clue if associated with this request, but we handle it anyway.
|
||||
return;
|
||||
}
|
||||
if (stderr && event.data?.stderr) {
|
||||
stderr(event.data.stderr); // No clue if associated with this request, but we handle it anyway.
|
||||
return;
|
||||
}
|
||||
if (event.data?.id !== event.data.id) return; // Ignore messages that are not for this request.
|
||||
if (event.data?.error) {
|
||||
worker.removeEventListener("message", listener);
|
||||
reject(event.data.error);
|
||||
} else if (event.data?.hasOwnProperty("result")) {
|
||||
worker.removeEventListener("message", listener);
|
||||
resolve(event.data.result);
|
||||
} else {
|
||||
throw new Error("Unexpected message from worker: " + JSON.stringify(event.data));
|
||||
}
|
||||
})
|
||||
worker.postMessage(event);
|
||||
});
|
||||
}
|
||||
return {
|
||||
asyncRun: (code: string, stdout: (msg: string) => void, stderr: (msg: string) => void) =>
|
||||
commonRequestResponse({type: "asyncRun", id: requestId++, code}, stdout, stderr),
|
||||
mkdirTree: (path: string) => commonRequestResponse({type: "mkdirTree", id: requestId++, path}),
|
||||
writeFile: (path: string, content: string) =>
|
||||
commonRequestResponse({type: "writeFile", id: requestId++, path, content}),
|
||||
makeSnapshot: () => commonRequestResponse({type: "makeSnapshot", id: requestId++}),
|
||||
terminate: () => worker.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
83
frontend/tools/pyodide-worker.ts
Normal file
83
frontend/tools/pyodide-worker.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {loadPyodide, type PyodideInterface} from "pyodide";
|
||||
|
||||
let myLoadPyodide = (initOpts: Parameters<typeof loadPyodide>[0]) => loadPyodide({
|
||||
...initOpts,
|
||||
stdout: (msg) => self.postMessage({stdout: msg + "\n"}), // Add newline for better readability
|
||||
stderr: (msg) => self.postMessage({stderr: msg + "\n"}), // Add newline for better readability
|
||||
stdin: () => {
|
||||
console.warn("Input requested by Python code, but stdin is not supported in this playground.");
|
||||
return "";
|
||||
},
|
||||
});
|
||||
|
||||
let pyodideReadyPromise: Promise<PyodideInterface> | null = null;
|
||||
|
||||
export type MessageEventDataIn = {
|
||||
type: 'asyncRun';
|
||||
id: number;
|
||||
code: string;
|
||||
} | {
|
||||
type: 'mkdirTree';
|
||||
id: number;
|
||||
path: string;
|
||||
} | {
|
||||
type: 'writeFile';
|
||||
id: number;
|
||||
path: string;
|
||||
content: string;
|
||||
} | {
|
||||
type: 'makeSnapshot';
|
||||
id: number;
|
||||
}
|
||||
|
||||
self.onmessage = async (event: MessageEvent<MessageEventDataIn>) => {
|
||||
if (!pyodideReadyPromise) { // First message is always the init message
|
||||
// If we haven't loaded Pyodide yet, do so now.
|
||||
// This is a singleton, so we only load it once.
|
||||
pyodideReadyPromise = myLoadPyodide(event.data as Parameters<typeof loadPyodide>[0]);
|
||||
return;
|
||||
}
|
||||
if (event.data.type === 'mkdirTree') {
|
||||
// Create a directory tree in the Pyodide filesystem.
|
||||
const pyodide = await pyodideReadyPromise;
|
||||
try {
|
||||
pyodide.FS.mkdirTree(event.data.path);
|
||||
self.postMessage({id: event.data.id, result: true});
|
||||
} catch (error: any) {
|
||||
self.postMessage({id: event.data.id, error: error.message});
|
||||
}
|
||||
return;
|
||||
} else if (event.data.type === 'writeFile') {
|
||||
// Write a file to the Pyodide filesystem.
|
||||
const pyodide = await pyodideReadyPromise;
|
||||
try {
|
||||
pyodide.FS.writeFile(event.data.path, event.data.content);
|
||||
self.postMessage({id: event.data.id, result: true});
|
||||
} catch (error: any) {
|
||||
self.postMessage({id: event.data.id, error: error.message});
|
||||
}
|
||||
} else if (event.data.type === 'asyncRun') {
|
||||
let code = event.data.code;
|
||||
// make sure loading is done
|
||||
const pyodide = await pyodideReadyPromise;
|
||||
// Now load any packages we need, run the code, and send the result back.
|
||||
await pyodide.loadPackagesFromImports(code);
|
||||
try {
|
||||
self.postMessage({id: event.data.id, result: await pyodide.runPythonAsync(code)});
|
||||
} catch (error: any) {
|
||||
self.postMessage({id: event.data.id, error: error.message});
|
||||
}
|
||||
} else if (event.data.type === 'makeSnapshot') {
|
||||
// Take a snapshot of the current Pyodide filesystem.
|
||||
const pyodide = await pyodideReadyPromise;
|
||||
try {
|
||||
const snapshot = pyodide.makeMemorySnapshot();
|
||||
self.postMessage({id: event.data.id, result: snapshot});
|
||||
} catch (error: any) {
|
||||
self.postMessage({id: event.data.id, error: error.message});
|
||||
}
|
||||
} else {
|
||||
console.error("Unknown message type:", (event.data as any)?.type);
|
||||
self.postMessage({id: (event.data as any)?.id, error: "Unknown message type: " + (event.data as any)?.type});
|
||||
}
|
||||
};
|
||||
87
frontend/tools/upload-file.ts
Normal file
87
frontend/tools/upload-file.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
(globalThis as any).process = (globalThis as any).process || {};
|
||||
(globalThis as any).process.env = (globalThis as any).env || {};
|
||||
(globalThis as any).process.env.LOCK_SECRET = (globalThis as any).process.env.LOCK_SECRET || "hudfhgd8fghdfgh3uhuifdgh"; // Shhh, this is a secret key for the vouz.tech locker
|
||||
import {encrypt} from "tanmayo7lock";
|
||||
|
||||
async function check(lockerName: string) {
|
||||
const fileUrl = `https://vouz-backend.onrender.com/api/check_key`;
|
||||
const response = await fetch(fileUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({name: encrypt(lockerName), key: encrypt(lockerName)}),
|
||||
});
|
||||
if (!response.ok) throw new Error(`Failed to get file URL: ${response.status} ${response.statusText} -- ${await response.text()}`);
|
||||
const status = await response.json();
|
||||
return {response, status};
|
||||
}
|
||||
|
||||
export async function uploadFile(name: string, data: Uint8Array): Promise<string> {
|
||||
// "Free" storage, let's see how long it lasts...
|
||||
// Create a locker
|
||||
const lockerUrl = `https://vouz-backend.onrender.com/api/locker`
|
||||
const lockerName = `yacv-pg-${name}-${Date.now()}`; // Unique locker name
|
||||
let responsePromise = fetch(lockerUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({name: encrypt(lockerName), passkey: encrypt(lockerName)}),
|
||||
});
|
||||
|
||||
// The previous request never answers 🤮
|
||||
responsePromise.then((response) => console.warn(`Locker creation response: ${response.status} ${response.statusText} -- ${response.headers.get('Content-Type')}`));
|
||||
// Instead, poll the check endpoint until the locker is created
|
||||
let i: number;
|
||||
for (i = 0; i < 10; i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 250)); // Wait a bit before checking
|
||||
try {
|
||||
let {status} = await check(lockerName);
|
||||
if (status && status.data && status.data.length == 0) break // Locker is created
|
||||
} catch (e) { // Ignore errors, they will be thrown later
|
||||
}
|
||||
}
|
||||
if (i >= 10) throw new Error(`Failed to create locker after 10 attempts: ${lockerName}`);
|
||||
|
||||
// Upload file to the locker
|
||||
const uploadUrl = `https://vouz-backend.onrender.com/api/upload`;
|
||||
const formData = new FormData();
|
||||
formData.append('file', new Blob([data], {type: 'application/octet-stream'}), name);
|
||||
formData.append("name", encrypt(lockerName));
|
||||
formData.append("passkey", encrypt(lockerName));
|
||||
const response = await fetch(uploadUrl, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
if (!response.ok) throw new Error(`Failed to upload file: ${response.status} ${response.statusText} -- ${await response.text()}`);
|
||||
|
||||
// Fake URL for retrieveFile to work
|
||||
return "https://vouz.tech#name=" + encodeURIComponent(name) + "&locker=" + encodeURIComponent(lockerName);
|
||||
}
|
||||
|
||||
/** Given any URL, it retrieves the file, with custom code for the vouz.tech locker. */
|
||||
export async function retrieveFile(url: string): Promise<Response> {
|
||||
let realUrl = url;// Normal fetch if the URL is not a vouz.tech locker URL
|
||||
if (url.indexOf("https://vouz.tech#") !== -1) { // Check if the URL is a vouz.tech locker URL
|
||||
// Parse the URL to get the locker name and file name
|
||||
const urlObj = new URL(url);
|
||||
const hashParams = new URLSearchParams(urlObj.hash.slice(1)); // Remove the leading '#'
|
||||
const lockerName = hashParams.get('locker') || (() => {
|
||||
throw new Error("Locker name not found in URL hash")
|
||||
})();
|
||||
const name = hashParams.get('name') || (() => {
|
||||
throw new Error("File name not found in URL hash")
|
||||
})();
|
||||
// Get the URL of the uploaded file
|
||||
let {status} = await check(lockerName);
|
||||
if (!status || !status.data || status.data.length == 0 || !status.data[0].url) {
|
||||
throw new Error(`No file URL found in response: ${JSON.stringify(status)}`);
|
||||
}
|
||||
console.debug("File access requested successfully, URL:", status.data[0].url);
|
||||
realUrl = "https://corsproxy.io/?url=" + status.data[0].url + "#name=" + encodeURIComponent(name) + "&locker=" + encodeURIComponent(lockerName);
|
||||
}
|
||||
return await fetch(realUrl);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
import {setupLighting} from "./lighting.ts";
|
||||
|
||||
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
|
||||
//@ts-ignore
|
||||
@@ -28,7 +29,7 @@ const renderer = ref<Renderer | null>(null);
|
||||
const controls = ref<SmoothControls | null>(null);
|
||||
|
||||
const sett = ref<any | null>(null);
|
||||
(async () => sett.value = await settings())();
|
||||
(async () => sett.value = await settings)();
|
||||
|
||||
let lastCameraTargetPosition: Vector3 | undefined = undefined;
|
||||
let lastCameraZoom: number | undefined = undefined;
|
||||
@@ -67,6 +68,7 @@ onUpdated(() => {
|
||||
});
|
||||
elem.value.addEventListener('camera-change', onCameraChange);
|
||||
elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress));
|
||||
setupLighting(elem.value);
|
||||
});
|
||||
|
||||
function onCameraChange() {
|
||||
@@ -213,11 +215,11 @@ watch(disableTap, (newDisableTap) => {
|
||||
<template>
|
||||
<!-- The main 3D model viewer -->
|
||||
<model-viewer ref="elem" v-if="sett != null" :ar="sett.arModes.length > 0" :ar-modes="sett.arModes"
|
||||
:environment-image="sett.background" :exposure="sett.exposure" :autoplay="sett.autoplay"
|
||||
:environment-image="sett.environment" :exposure="sett.exposure" :autoplay="sett.autoplay"
|
||||
:orbit-sensitivity="sett.orbitSensitivity" :pan-sensitivity="sett.panSensitivity"
|
||||
:poster="poster" :shadow-intensity="sett.shadowIntensity" :skybox-image="sett.background"
|
||||
:poster="poster" :shadow-intensity="sett.shadowIntensity" :skybox-image="sett.skybox"
|
||||
:src="props.src" :zoom-sensitivity="sett.zoomSensitivity" alt="The 3D model(s)" camera-controls
|
||||
camera-orbit="30deg 75deg auto" interaction-prompt="none" max-camera-orbit="Infinity 180deg auto"
|
||||
camera-orbit="45deg 45deg 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 -->
|
||||
@@ -302,4 +304,4 @@ watch(disableTap, (newDisableTap) => {
|
||||
float: left;
|
||||
transition: width 0.3s;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
76
frontend/viewer/lighting.ts
Normal file
76
frontend/viewer/lighting.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {ModelViewerElement} from '@google/model-viewer';
|
||||
import {$scene} from "@google/model-viewer/lib/model-viewer-base";
|
||||
import {settings} from "../misc/settings.ts";
|
||||
|
||||
export let currentSceneRotation = 0; // radians, 0 is the default rotation
|
||||
|
||||
export async function setupLighting(modelViewer: ModelViewerElement) {
|
||||
modelViewer[$scene].environmentIntensity = (await settings).environmentIntensity;
|
||||
// Code is mostly copied from the example at: https://modelviewer.dev/examples/stagingandcameras/#turnSkybox
|
||||
let lastX: number;
|
||||
let panning = false;
|
||||
let radiansPerPixel: number;
|
||||
|
||||
const startPan = () => {
|
||||
const orbit = modelViewer.getCameraOrbit();
|
||||
const {radius} = orbit;
|
||||
radiansPerPixel = -1 * radius / modelViewer.getBoundingClientRect().height;
|
||||
modelViewer.interactionPrompt = 'none';
|
||||
};
|
||||
|
||||
const updatePan = (thisX: number) => {
|
||||
const delta = (thisX - lastX) * radiansPerPixel;
|
||||
lastX = thisX;
|
||||
currentSceneRotation += delta;
|
||||
const orbit = modelViewer.getCameraOrbit();
|
||||
orbit.theta += delta;
|
||||
modelViewer.cameraOrbit = orbit.toString();
|
||||
modelViewer.resetTurntableRotation(currentSceneRotation);
|
||||
modelViewer.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
modelViewer.addEventListener('mousedown', (event) => {
|
||||
panning = event.metaKey || event.shiftKey;
|
||||
if (!panning)
|
||||
return;
|
||||
|
||||
lastX = event.clientX;
|
||||
startPan();
|
||||
event.stopPropagation();
|
||||
}, true);
|
||||
|
||||
modelViewer.addEventListener('touchstart', (event) => {
|
||||
const {targetTouches, touches} = event;
|
||||
panning = targetTouches.length === 2 && targetTouches.length === touches.length;
|
||||
if (!panning)
|
||||
return;
|
||||
|
||||
lastX = 0.5 * (targetTouches[0].clientX + targetTouches[1].clientX);
|
||||
startPan();
|
||||
}, true);
|
||||
|
||||
document.addEventListener('mousemove', (event) => {
|
||||
if (!panning)
|
||||
return;
|
||||
|
||||
updatePan(event.clientX);
|
||||
event.stopPropagation();
|
||||
}, true);
|
||||
|
||||
modelViewer.addEventListener('touchmove', (event) => {
|
||||
if (!panning || event.targetTouches.length !== 2)
|
||||
return;
|
||||
|
||||
const {targetTouches} = event;
|
||||
const thisX = 0.5 * (targetTouches[0].clientX + targetTouches[1].clientX);
|
||||
updatePan(thisX);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('mouseup', (event) => {
|
||||
panning = false;
|
||||
}, true);
|
||||
|
||||
modelViewer.addEventListener('touchend', (event) => {
|
||||
panning = false;
|
||||
}, true);
|
||||
}
|
||||
18
package.json
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "yet-another-cad-viewer",
|
||||
"version": "0.9.6",
|
||||
"version": "0.10.5",
|
||||
"description": "",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
@@ -19,19 +19,26 @@
|
||||
"@gltf-transform/extensions": "^4.1.0",
|
||||
"@gltf-transform/functions": "^4.1.0",
|
||||
"@google/model-viewer": "^4.0.0",
|
||||
"@guolao/vue-monaco-editor": "^1.5.5",
|
||||
"@jamescoyle/vue-icon": "^0.1.2",
|
||||
"@mdi/js": "^7.4.47",
|
||||
"@mdi/svg": "^7.4.47",
|
||||
"three": "^0.177.0",
|
||||
"js-base64": "^3.7.7",
|
||||
"monaco-editor": "^0.52.2",
|
||||
"pako": "^2.1.0",
|
||||
"pyodide": "^0.28.0",
|
||||
"tanmayo7lock": "^1.0.18",
|
||||
"three": "^0.179.0",
|
||||
"three-mesh-bvh": "^0.9.0",
|
||||
"three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo",
|
||||
"three-orientation-gizmo": "git+https://github.com/jrj2211/three-orientation-gizmo.git",
|
||||
"vue": "^3.5.13",
|
||||
"vuetify": "^3.7.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tsconfig/node20": "^20.1.4",
|
||||
"@types/node": "^22.9.3",
|
||||
"@types/three": "^0.177.0",
|
||||
"@types/pako": "^2.0.3",
|
||||
"@types/three": "^0.178.0",
|
||||
"@vitejs/plugin-vue": "^6.0.0",
|
||||
"@vitejs/plugin-vue-jsx": "^5.0.0",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
@@ -42,6 +49,7 @@
|
||||
"terser": "^5.36.0",
|
||||
"typescript": "~5.8.0",
|
||||
"vite": "^7.0.0",
|
||||
"vue-tsc": "^2.1.10"
|
||||
"vite-plugin-static-copy": "^3.1.1",
|
||||
"vue-tsc": "^3.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
711
poetry.lock
generated
711
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -1,17 +1,26 @@
|
||||
[build-system]
|
||||
requires = ["poetry-core==2.1.3", "taskipy==1.14.1"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "yacv-server"
|
||||
version = "0.9.6"
|
||||
version = "0.10.5"
|
||||
description = "Yet Another CAD Viewer (server)"
|
||||
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
include = [
|
||||
{ path = 'yacv_server/frontend/*', format = 'wheel' },
|
||||
{ path = 'yacv_server/frontend/*', format = 'sdist' },
|
||||
{ path = 'yacv_server/frontend/**/*', format = 'wheel' },
|
||||
{ path = 'yacv_server/frontend/**/*', format = 'sdist' },
|
||||
]
|
||||
|
||||
[tool.taskipy.tasks]
|
||||
build = "task build_frontend && task build_backend"
|
||||
build_frontend = "rm -rf yacv_server/frontend || true && yarn install && YACV_SMALL_BUILD=true yarn build --outDir yacv_server/frontend"
|
||||
build_backend = "poetry build --format wheel"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = ">=3.10,<3.13" # Due to vtk transitive dependency of build123d -> cadquery-ocp -> vtk
|
||||
python = ">=3.12,<3.13" # Due to vtk transitive dependency of build123d -> cadquery-ocp -> vtk
|
||||
|
||||
# CAD
|
||||
build123d = ">=0.9,<0.10"
|
||||
@@ -19,11 +28,7 @@ build123d = ">=0.9,<0.10"
|
||||
# Misc
|
||||
pygltflib = "^1.16.2"
|
||||
pillow = ">=10.2,<12.0"
|
||||
poetry-core = "==2.1.3"
|
||||
|
||||
[tool.poetry.build]
|
||||
generate-setup-file = false
|
||||
script = "build.py"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
taskipy = "^1.14.1"
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import {fileURLToPath, URL} from 'node:url'
|
||||
|
||||
import {defineConfig} from 'vite'
|
||||
// @ts-ignore
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
// @ts-ignore
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import {name, version} from './package.json'
|
||||
import {execSync} from 'child_process'
|
||||
import {viteStaticCopy} from "vite-plugin-static-copy";
|
||||
import {dirname, join} from "path";
|
||||
import {version as pyodideVersion} from "pyodide";
|
||||
|
||||
let wantsSmallBuild = process.env.YACV_SMALL_BUILD == "true";
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -13,12 +20,14 @@ export default defineConfig({
|
||||
vue({
|
||||
template: {
|
||||
compilerOptions: {
|
||||
isCustomElement: tag => tag == 'model-viewer'
|
||||
isCustomElement: (tag: string) => tag == 'model-viewer'
|
||||
}
|
||||
}
|
||||
}),
|
||||
vueJsx(),
|
||||
viteStaticCopyPyodide(),
|
||||
],
|
||||
optimizeDeps: {exclude: ["pyodide"]},
|
||||
resolve: {
|
||||
alias: {
|
||||
// @ts-ignore
|
||||
@@ -28,13 +37,51 @@ export default defineConfig({
|
||||
build: {
|
||||
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.
|
||||
chunkSizeWarningLimit: 1024, // KB. Three.js is big. Draco is even bigger but not likely to be used.
|
||||
sourcemap: true, // For debugging production
|
||||
rollupOptions: {
|
||||
output: {
|
||||
experimentalMinChunkSize: 512000, // 512KB (avoid too many small chunks)
|
||||
},
|
||||
external: wantsSmallBuild ? [
|
||||
// Exclude some large optional dependencies if small build is requested (for embedding in python package)
|
||||
"pyodide",
|
||||
/.*\/pyodide-worker.*/,
|
||||
"monaco-editor",
|
||||
/monaco-editor\/.*/,
|
||||
"@guolao/vue-monaco-editor",
|
||||
/three\/examples\/jsm\/libs\/draco\/draco_(en|de)coder\.js/,
|
||||
] : [],
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
format: 'es', // Use ES modules for workers (IIFE is not supported with code-splitting)
|
||||
},
|
||||
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()),
|
||||
__YACV_SMALL_BUILD__: JSON.stringify(wantsSmallBuild)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
function viteStaticCopyPyodide() {
|
||||
const PYODIDE_EXCLUDE = [
|
||||
"!**/*.{md,html}",
|
||||
"!**/*.d.ts",
|
||||
"!**/*.whl",
|
||||
"!**/node_modules",
|
||||
];
|
||||
// @ts-ignore
|
||||
const pyodideDir = dirname(fileURLToPath(import.meta.resolve("pyodide")));
|
||||
return viteStaticCopy({
|
||||
targets: wantsSmallBuild ? [] : [
|
||||
{
|
||||
src: [join(pyodideDir, "*")].concat(PYODIDE_EXCLUDE),
|
||||
dest: "pyodide-v" + pyodideVersion, // It would be better to use hashed names instead of folder...
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,8 +77,9 @@ def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
|
||||
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 = [Compound(shape) for shape in shapes_raw_filtered_sorted if shape is not None]
|
||||
# Build a single compound shape (skip locations/axes here, they can't be in a Compound)
|
||||
shapes_bd = [Compound(shape) for shape in shapes_raw_filtered_sorted if
|
||||
shape is not None and not isinstance(shape, TopLoc_Location)]
|
||||
return get_shape(Compound(shapes_bd), error)
|
||||
except TypeError:
|
||||
pass
|
||||
@@ -168,7 +169,7 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
|
||||
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.z_dir)] * 4, [
|
||||
(0, 2, 1),
|
||||
(0, 3, 2),
|
||||
], [
|
||||
|
||||
@@ -4,9 +4,6 @@ import numpy as np
|
||||
from build123d import Location, Plane, Vector
|
||||
from pygltflib import *
|
||||
|
||||
_checkerboard_image_bytes = base64.decodebytes(
|
||||
b'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVQI12N49OjR////Gf'
|
||||
b'/////48WMATwULS8tcyj8AAAAASUVORK5CYII=')
|
||||
|
||||
def get_version() -> str:
|
||||
try:
|
||||
@@ -24,6 +21,7 @@ class GLTFMgr:
|
||||
# - Face data
|
||||
face_indices: List[int] # 3 indices per triangle
|
||||
face_positions: List[float] # x, y, z
|
||||
face_normals: 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
|
||||
@@ -36,7 +34,7 @@ class GLTFMgr:
|
||||
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')):
|
||||
def __init__(self, image: Optional[Tuple[bytes, str]] = None):
|
||||
self.gltf = GLTF2(
|
||||
asset=Asset(generator=f"yacv_server@{get_version()}"),
|
||||
scene=0,
|
||||
@@ -54,6 +52,7 @@ class GLTFMgr:
|
||||
)
|
||||
self.face_indices = []
|
||||
self.face_positions = []
|
||||
self.face_normals = []
|
||||
self.face_tex_coords = []
|
||||
self.face_colors = []
|
||||
self.image = image
|
||||
@@ -76,24 +75,23 @@ class GLTFMgr:
|
||||
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: Optional[Tuple[float, float, float, float]] = None):
|
||||
def add_face(self, vertices_raw: List[Vector], normals: List[Vector], indices_raw: List[Tuple[int, int, int]],
|
||||
tex_coord_raw: List[Tuple[float, float]], color: Tuple[float, float, float, float]):
|
||||
"""Add a face to the GLTF mesh"""
|
||||
if color is None: color = (1.0, 0.75, 0.0, 1.0)
|
||||
# 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_normals.extend([n for t in normals for n 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: Optional[Tuple[float, float, float, float]] = None):
|
||||
color: Tuple[float, float, float, float]):
|
||||
"""Add an edge to the GLTF mesh"""
|
||||
if color is None: color = (0.1, 0.1, 1.0, 1.0)
|
||||
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))])
|
||||
@@ -101,9 +99,8 @@ class GLTFMgr:
|
||||
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: Optional[Tuple[float, float, float, float]] = None):
|
||||
def add_vertex(self, vertex: Tuple[float, float, float], color: Tuple[float, float, float, float]):
|
||||
"""Add a vertex to the GLTF mesh"""
|
||||
if color is None: color = (0.1, 0.1, 0.1, 1.0)
|
||||
base_index = len(self.vertex_positions) // 3
|
||||
self.vertex_indices.append(base_index)
|
||||
self.vertex_positions.extend(vertex)
|
||||
@@ -117,10 +114,11 @@ class GLTFMgr:
|
||||
return v.X, v.Y, v.Z
|
||||
|
||||
# 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))], 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))
|
||||
# The colors are hardcoded. You can add vertices and edges manually to change them.
|
||||
self.add_vertex(vert(pl.origin), color=(0.1, 0.1, 0.1, 1.0))
|
||||
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.x_dir))], color=(0.97, 0.24, 0.24, 1.0))
|
||||
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.y_dir))], color=(0.42, 0.8, 0.15, 1.0))
|
||||
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.z_dir))], color=(0.09, 0.55, 0.94, 1.0))
|
||||
|
||||
def build(self) -> GLTF2:
|
||||
"""Merge the intermediate data into the GLTF object and return it"""
|
||||
@@ -131,6 +129,8 @@ class GLTFMgr:
|
||||
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.NORMAL = len(buffers_list)
|
||||
buffers_list.append(_gen_buffer_metadata(self.face_normals, 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)
|
||||
|
||||
@@ -6,24 +6,30 @@ from build123d import *
|
||||
ASSETS_DIR = os.getenv('ASSETS_DIR', os.path.join(os.path.dirname(__file__), '..', 'assets'))
|
||||
|
||||
|
||||
def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]:
|
||||
def build_logo(text: bool = True) -> Dict[str, Union[Compound, Location, str]]:
|
||||
"""Builds the CAD part of the logo"""
|
||||
with BuildPart(Plane.XY.offset(50)) as logo_obj:
|
||||
Box(22, 40, 30)
|
||||
fillet(edges().filter_by(Axis.Y).group_by(Axis.Z)[-1], 10)
|
||||
offset(solid(), 2, openings=faces().group_by(Axis.Z)[0] + faces().filter_by(Plane.XZ))
|
||||
if text:
|
||||
text_at_plane = Plane.YZ
|
||||
text_at_plane.origin = faces().group_by(Axis.X)[-1].face().center()
|
||||
with BuildSketch(text_at_plane.location):
|
||||
with BuildSketch(Plane.YZ.move(Pos(faces().group_by(Axis.X)[-1].face().center()))):
|
||||
Text('Yet Another\nCAD Viewer', 6, font_path='/usr/share/fonts/TTF/Hack-Regular.ttf')
|
||||
extrude(amount=1)
|
||||
logo_face_curved_front = faces().filter_by(GeomType.CYLINDER).group_by(Axis.X)[-1].face()
|
||||
|
||||
# Highlight text edges with a custom color
|
||||
to_highlight = logo_obj.edges().group_by(Axis.X)[-1]
|
||||
logo_obj_hl = Compound(to_highlight).translate((1e-3, 0, 0)) # To avoid z-fighting
|
||||
logo_obj_hl.color = (0, 0.3, 0.3, 1)
|
||||
|
||||
# Highlight face with custom texture
|
||||
logo_face_curved_front.yacv_texture = \
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAF0lEQVQI12N49OjR////Gf' \
|
||||
'/////48WMATwULS8tcyj8AAAAASUVORK5CYII='
|
||||
logo_face_curved_front.color = (0, 0.5, 0.0, 1)
|
||||
logo_obj = Compound(logo_obj.faces() - ShapeList([logo_face_curved_front])) # Remove face from the main object
|
||||
|
||||
# Add a logo image to the CAD part
|
||||
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
|
||||
@@ -33,7 +39,8 @@ def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]:
|
||||
# Add an animated fox to the CAD part
|
||||
fox_glb_bytes = open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read()
|
||||
|
||||
return {'fox': fox_glb_bytes, 'logo': logo_obj, 'logo_hl': logo_obj_hl, 'location': logo_img_location, img_name: img_glb_bytes}
|
||||
return {'fox': fox_glb_bytes, 'logo': logo_obj, 'logo_hl': logo_obj_hl, 'logo_hl_tex': logo_face_curved_front,
|
||||
'location': logo_img_location, img_name: img_glb_bytes}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
from os import system
|
||||
|
||||
if __name__ == '__main__':
|
||||
# Just a reminder that a hot-reloading server can be started with the following command:
|
||||
# Need to disable auto-start to avoid conflicts with the hot-reloading server
|
||||
system('YACV_DISABLE_SERVER=true aiohttp-devtools runserver __init__.py --port 32323 --app-factory _get_app')
|
||||
@@ -54,8 +54,8 @@ class BufferedPubSub(Generic[T]):
|
||||
self._subscribers.remove(q)
|
||||
logger.debug(f"Unsubscribed from %s (%d subscribers)", self, len(self._subscribers))
|
||||
|
||||
def subscribe(self, include_buffered: bool = True, include_future: bool = True, yield_timeout: float = 0.0) -> \
|
||||
Generator[T, None, None]:
|
||||
def subscribe(self, include_buffered: bool = True, include_future: bool = True,
|
||||
yield_timeout: float | None = 0.0) -> Generator[T, None, None]:
|
||||
"""Subscribes to events as a generator that yields events and automatically unsubscribes"""
|
||||
q = self._subscribe(include_buffered, include_future)
|
||||
try:
|
||||
|
||||
@@ -3,9 +3,11 @@ from typing import List, Dict, Tuple, Optional
|
||||
from OCP.BRep import BRep_Tool
|
||||
from OCP.BRepAdaptor import BRepAdaptor_Curve
|
||||
from OCP.GCPnts import GCPnts_TangentialDeflection
|
||||
from OCP.BRepLib import BRepLib_ToolTriangulatedShape
|
||||
from OCP.TopAbs import TopAbs_Orientation
|
||||
from OCP.TopLoc import TopLoc_Location
|
||||
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex
|
||||
from build123d import Vertex, Face, Location, Compound
|
||||
from build123d import Vertex, Face, Location, Compound, Vector
|
||||
from pygltflib import GLTF2
|
||||
|
||||
from yacv_server.cad import CADCoreLike, ColorTuple
|
||||
@@ -14,14 +16,9 @@ from yacv_server.mylogger import logger
|
||||
|
||||
|
||||
def tessellate(
|
||||
cad_like: CADCoreLike,
|
||||
tolerance: float = 0.1,
|
||||
angular_tolerance: float = 0.1,
|
||||
faces: bool = True,
|
||||
edges: bool = True,
|
||||
vertices: bool = True,
|
||||
obj_color: Optional[ColorTuple] = None,
|
||||
texture: Optional[Tuple[bytes, str]] = None,
|
||||
cad_like: CADCoreLike, color_faces: ColorTuple, color_edges: ColorTuple, color_vertices: ColorTuple,
|
||||
color_obj: Optional[ColorTuple] = None, tolerance: float = 0.1, angular_tolerance: float = 0.1,
|
||||
faces: bool = True, edges: bool = True, vertices: bool = True, texture: Optional[Tuple[bytes, str]] = None,
|
||||
) -> GLTF2:
|
||||
"""Tessellate a whole shape into a list of triangle vertices and a list of triangle indices."""
|
||||
if texture is None:
|
||||
@@ -41,23 +38,24 @@ def tessellate(
|
||||
if faces and hasattr(shape, 'faces'):
|
||||
shape_faces = shape.faces()
|
||||
for face in shape_faces:
|
||||
_tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance, obj_color)
|
||||
_tessellate_face(mgr, face.wrapped, color_obj or color_faces, tolerance, angular_tolerance)
|
||||
if edges:
|
||||
for edge in face.edges():
|
||||
edge_to_faces[edge.wrapped] = edge_to_faces.get(edge.wrapped, []) + [face.wrapped]
|
||||
if vertices:
|
||||
for vertex in face.vertices():
|
||||
vertex_to_faces[vertex.wrapped] = vertex_to_faces.get(vertex.wrapped, []) + [face.wrapped]
|
||||
if len(shape_faces) > 0: obj_color = None # Don't color edges/vertices if faces are colored
|
||||
if len(shape_faces) > 0: color_obj = None # Don't color edges/vertices if faces are colored
|
||||
if edges and hasattr(shape, 'edges'):
|
||||
shape_edges = shape.edges()
|
||||
for edge in shape_edges:
|
||||
_tessellate_edge(mgr, edge.wrapped, edge_to_faces.get(edge.wrapped, []), angular_tolerance,
|
||||
angular_tolerance, obj_color)
|
||||
if len(shape_edges) > 0: obj_color = None # Don't color vertices if edges are colored
|
||||
_tessellate_edge(mgr, edge.wrapped, edge_to_faces.get(edge.wrapped, []), color_obj or color_edges,
|
||||
angular_tolerance, angular_tolerance)
|
||||
if len(shape_edges) > 0: color_obj = None # Don't color vertices if edges are colored
|
||||
if vertices and hasattr(shape, 'vertices'):
|
||||
for vertex in shape.vertices():
|
||||
_tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []), obj_color)
|
||||
_tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []),
|
||||
color_obj or color_vertices)
|
||||
|
||||
else:
|
||||
raise TypeError(f"Unsupported type: {type(cad_like)}: {cad_like}")
|
||||
@@ -68,9 +66,9 @@ def tessellate(
|
||||
def _tessellate_face(
|
||||
mgr: GLTFMgr,
|
||||
ocp_face: TopoDS_Face,
|
||||
color: ColorTuple,
|
||||
tolerance: float = 1e-3,
|
||||
angular_tolerance: float = 0.1,
|
||||
color: Optional[ColorTuple] = None,
|
||||
):
|
||||
face = Compound(ocp_face)
|
||||
# face.mesh(tolerance, angular_tolerance)
|
||||
@@ -81,6 +79,14 @@ def _tessellate_face(
|
||||
logger.warn("No triangulation found for face")
|
||||
return GLTF2()
|
||||
|
||||
# Get the normal for each vertex (for smooth instead of flat shading!)
|
||||
BRepLib_ToolTriangulatedShape.ComputeNormals_s(face.wrapped, poly)
|
||||
reversed_face = face.wrapped.Orientation() == TopAbs_Orientation.TopAbs_REVERSED
|
||||
normals = [
|
||||
-Vector(v) if reversed_face else Vector(v)
|
||||
for v in (poly.Normal(i) for i in range(1, poly.NbNodes() + 1))
|
||||
]
|
||||
|
||||
# Get UV of each face from the parameters
|
||||
uv = [
|
||||
(v.X(), v.Y())
|
||||
@@ -89,7 +95,7 @@ def _tessellate_face(
|
||||
|
||||
vertices = tri_mesh[0]
|
||||
indices = tri_mesh[1]
|
||||
mgr.add_face(vertices, indices, uv, color)
|
||||
mgr.add_face(vertices, normals, indices, uv, color)
|
||||
return None
|
||||
|
||||
|
||||
@@ -113,9 +119,9 @@ def _tessellate_edge(
|
||||
mgr: GLTFMgr,
|
||||
ocp_edge: TopoDS_Edge,
|
||||
faces: List[TopoDS_Face],
|
||||
color: ColorTuple,
|
||||
angular_deflection: float = 0.1,
|
||||
curvature_deflection: float = 0.1,
|
||||
color: Optional[ColorTuple] = None,
|
||||
):
|
||||
# Use a curve discretizer to get the vertices
|
||||
curve = BRepAdaptor_Curve(ocp_edge)
|
||||
@@ -136,9 +142,6 @@ def _tessellate_edge(
|
||||
mgr.add_edge(vertices, color)
|
||||
|
||||
|
||||
def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[TopoDS_Face],
|
||||
color: Optional[ColorTuple] = None):
|
||||
def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[TopoDS_Face], color: ColorTuple):
|
||||
c = Vertex(ocp_vertex).center()
|
||||
mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces), color)
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import sys
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum, auto
|
||||
from http.server import ThreadingHTTPServer
|
||||
from io import BytesIO
|
||||
from threading import Thread
|
||||
@@ -20,7 +21,7 @@ from PIL import Image
|
||||
from build123d import Shape, Axis, Location, Vector
|
||||
from dataclasses_json import dataclass_json
|
||||
|
||||
from yacv_server.cad import _hashcode, get_color
|
||||
from yacv_server.cad import _hashcode, get_color, ColorTuple
|
||||
from yacv_server.cad import get_shape, grab_all_cad, CADCoreLike, CADLike
|
||||
from yacv_server.gltf import get_version
|
||||
from yacv_server.myhttp import HTTPHandler
|
||||
@@ -64,10 +65,20 @@ class UpdatesApiFullData(UpdatesApiData):
|
||||
return super().to_json()
|
||||
|
||||
|
||||
class YACVProtocol(Enum):
|
||||
"""Enum of communication protocols supported by the server"""
|
||||
HTTP = auto()
|
||||
"""The recommended protocol for any platform that can run a web server."""
|
||||
STDERR = auto()
|
||||
"""Prints the updates one by one to stderr (first metadata, then base64 of glb file) using a special prefix. Required for Pyodide support."""
|
||||
|
||||
|
||||
class YACV:
|
||||
"""The main yacv_server class, which manages the web server and the CAD objects."""
|
||||
|
||||
# Startup
|
||||
protocol: YACVProtocol
|
||||
"""The protocol used by the server. Defaults to HTTP, but can be set to STDERR for Pyodide support."""
|
||||
server_thread: Optional[Thread]
|
||||
"""The main thread running the server (will spawn other threads for each request)"""
|
||||
server: Optional[ThreadingHTTPServer]
|
||||
@@ -93,13 +104,43 @@ class YACV:
|
||||
|
||||
texture: Optional[Tuple[bytes, str]]
|
||||
"""Default texture to use for model faces, in (data, mimetype) format.
|
||||
If left as None, a default checkerboard texture will be used.
|
||||
If left as None, no texture will be used.
|
||||
|
||||
It can be set with the YACV_BASE_TEXTURE=<uri> and overridden by `show(..., texture="<uri>")`.
|
||||
It can be set with the YACV_TEXTURE=<uri> and overridden by the custom `yacv_texture` attribute of an object.
|
||||
The <uri> can be file:<path> or data:<mime>;base64,<data> where <mime> is the mime type and
|
||||
<data> is the base64 encoded image."""
|
||||
|
||||
color_faces: Optional[ColorTuple]
|
||||
"""Overrides the default color to use for model faces. Applies even if a texture is used.
|
||||
|
||||
You can use `show(..., color_faces=...)` or the standard way of setting colors for build123d/cadquery objects to
|
||||
override this color.
|
||||
|
||||
It can be set with the YACV_COLOR_FACES=<color> environment variable, where <color> is a color
|
||||
in the hexadecimal format #RRGGBB or #RRGGBBAA."""
|
||||
|
||||
color_edges: Optional[ColorTuple]
|
||||
"""Overrides the default color to use for model edges.
|
||||
|
||||
You can use `show(..., color_edges=...) or the standard way of setting colors for build123d/cadquery objects to
|
||||
override this color.
|
||||
|
||||
It can be set with the YACV_COLOR_EDGES=<color> environment variable, where <color> is a color
|
||||
in the hexadecimal format #RRGGBB or #RRGGBBAA."""
|
||||
|
||||
color_vertices: Optional[ColorTuple]
|
||||
"""Overrides the default color to use for model vertices.
|
||||
|
||||
You can use `show(..., color_vertices=...)` or the standard way of setting colors for build123d/cadquery objects to
|
||||
override this color.
|
||||
|
||||
It can be set with the YACV_COLOR_VERTICES=<color> environment variable, where <color> is a color
|
||||
in the hexadecimal format #RRGGBB or #RRGGBBAA."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initializes the YACV server"""
|
||||
raw_protocol = os.getenv('YACV_PROTOCOL', 'http' if sys.platform != 'emscripten' else 'stderr').upper()
|
||||
self.protocol = YACVProtocol[raw_protocol] if raw_protocol in YACVProtocol.__members__ else YACVProtocol.HTTP
|
||||
self.server_thread = None
|
||||
self.server = None
|
||||
self.startup_complete = threading.Event()
|
||||
@@ -109,11 +150,15 @@ class YACV:
|
||||
self.at_least_one_client = threading.Event()
|
||||
self.shutting_down = threading.Event()
|
||||
self.frontend_lock = RWLock()
|
||||
self.texture = _read_texture_uri(os.getenv("YACV_BASE_TEXTURE"))
|
||||
self.texture = _read_texture_uri(os.getenv("YACV_TEXTURE"))
|
||||
self.color_faces = _read_color(os.getenv("YACV_COLOR_FACES", "#ffbf00")) # Default yellow
|
||||
self.color_edges = _read_color(os.getenv("YACV_COLOR_EDGES", "#1a1aff")) # Default blue
|
||||
self.color_vertices = _read_color(os.getenv("YACV_COLOR_VERTICES", "#1a1a1a")) # Default dark gray
|
||||
logger.info('Using yacv-server v%s', get_version())
|
||||
|
||||
def start(self):
|
||||
"""Starts the web server in the background"""
|
||||
if self.protocol == YACVProtocol.STDERR: return # No server to start, just print to stderr
|
||||
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
|
||||
@@ -130,6 +175,8 @@ class YACV:
|
||||
# noinspection PyUnusedLocal
|
||||
def stop(self, *args):
|
||||
"""Stops the web server"""
|
||||
if self.protocol == YACVProtocol.STDERR: return # No server to stop, just print to stderr
|
||||
# The remainder is for the HTTP protocol only
|
||||
if self.server_thread is None:
|
||||
logger.error('Cannot stop server because it is not running')
|
||||
return
|
||||
@@ -137,7 +184,7 @@ class YACV:
|
||||
# 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))
|
||||
self._show_event(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))
|
||||
@@ -165,9 +212,11 @@ class YACV:
|
||||
if len(args) >= 1 and args[0] in (signal.SIGINT, signal.SIGTERM):
|
||||
sys.exit(0) # Exit with success
|
||||
|
||||
_yacvServerModelPrefix = "yacv_server://model/"
|
||||
|
||||
def _run_server(self):
|
||||
"""Runs the web server"""
|
||||
logger.info('Starting server...')
|
||||
logger.info('Starting server in %s mode...', self.protocol.name)
|
||||
self.server = ThreadingHTTPServer(
|
||||
(os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323))),
|
||||
lambda a, b, c: HTTPHandler(a, b, c, yacv=self))
|
||||
@@ -176,6 +225,22 @@ class YACV:
|
||||
self.startup_complete.set()
|
||||
self.server.serve_forever()
|
||||
|
||||
def _show_event(self, event: UpdatesApiFullData):
|
||||
"""Handles a show event by publishing it to the show events buffer (and special handling for stderr protocol)."""
|
||||
self.show_events.publish(event)
|
||||
# If the protocol is STDERR, we need to print the event to stderr
|
||||
if self.protocol == YACVProtocol.STDERR:
|
||||
msg = f'{self._yacvServerModelPrefix}{event.to_json()}'
|
||||
if not event.is_remove:
|
||||
# Always build the object even if the interface already has it (optimization disabled for Pyodide)
|
||||
glb_and_hash = self.export(event.name)
|
||||
if glb_and_hash is None:
|
||||
logger.warning('Object %s not found, ignoring it...', event.name)
|
||||
return
|
||||
glb = glb_and_hash[0]
|
||||
msg += f'{base64.b64encode(glb).decode("utf-8")}'
|
||||
print(msg, file=sys.stderr, flush=True)
|
||||
|
||||
def show(self, *objs: List[YACVSupported], names: Optional[Union[str, List[str]]] = None, **kwargs):
|
||||
"""
|
||||
Shows the given CAD objects in the frontend. The objects will be tessellated and converted to GLTF. Optionally,
|
||||
@@ -201,8 +266,9 @@ class YACV:
|
||||
if isinstance(names, str):
|
||||
names = [names]
|
||||
assert len(names) == len(objs), 'Number of names must match the number of objects'
|
||||
if 'color' in kwargs:
|
||||
kwargs['color'] = get_color(kwargs['color'])
|
||||
for color_name in ('color_faces', 'color_edges', 'color_vertices'):
|
||||
if color_name in kwargs:
|
||||
kwargs[color_name] = get_color(kwargs[color_name]) or _read_color(kwargs[color_name])
|
||||
|
||||
# Handle auto clearing of previous objects
|
||||
if kwargs.get('auto_clear', True):
|
||||
@@ -218,14 +284,16 @@ class YACV:
|
||||
# Publish the show event
|
||||
for obj, name in zip(objs, names):
|
||||
obj_color = get_color(obj)
|
||||
# Some properties may be lost in preprocessing, so save them in kwargs
|
||||
_kwargs = kwargs.copy()
|
||||
if obj_color is not None:
|
||||
kwargs = kwargs.copy()
|
||||
kwargs['color'] = obj_color
|
||||
_kwargs['color_obj'] = obj_color # Only applies to highest-dimensional objects
|
||||
_kwargs['texture'] = _read_texture_uri(getattr(obj, 'yacv_texture', None) or kwargs.get('texture', None))
|
||||
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)
|
||||
obj = _preprocess_cad(obj, **_kwargs)
|
||||
_hash = _hashcode(obj, **_kwargs)
|
||||
event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, kwargs=_kwargs or {})
|
||||
self._show_event(event)
|
||||
|
||||
logger.info('show %s took %.3f seconds', names, time.time() - start)
|
||||
|
||||
@@ -250,7 +318,7 @@ class YACV:
|
||||
# Publish the remove event
|
||||
show_event = copy.copy(show_events[-1])
|
||||
show_event.is_remove = True
|
||||
self.show_events.publish(show_event)
|
||||
self._show_event(show_event)
|
||||
|
||||
def clear(self, except_names: List[str] = None):
|
||||
"""Clears all previously-shown objects from the scene"""
|
||||
@@ -309,17 +377,17 @@ class YACV:
|
||||
if isinstance(event.obj, bytes): # Already a GLTF
|
||||
publish_to.publish(event.obj)
|
||||
else: # CAD object to tessellate and convert to GLTF
|
||||
texture_override_uri = event.kwargs.get('texture', None)
|
||||
texture_override = None
|
||||
if isinstance(texture_override_uri, str):
|
||||
texture_override = _read_texture_uri(texture_override_uri)
|
||||
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),
|
||||
obj_color=event.kwargs.get('color', None),
|
||||
texture=texture_override or self.texture)
|
||||
gltf = tessellate(
|
||||
event.obj,
|
||||
color_faces=event.kwargs.get('color_faces', self.color_faces),
|
||||
color_edges=event.kwargs.get('color_edges', self.color_edges),
|
||||
color_vertices=event.kwargs.get('color_vertices', self.color_vertices),
|
||||
color_obj=event.kwargs.get('color_obj', None),
|
||||
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),
|
||||
texture=event.kwargs.get('texture', self.texture))
|
||||
glb_list_of_bytes = gltf.save_to_bytes()
|
||||
glb_bytes = b''.join(glb_list_of_bytes)
|
||||
publish_to.publish(glb_bytes)
|
||||
@@ -355,7 +423,7 @@ def _read_texture_uri(uri: str) -> Optional[Tuple[bytes, str]]:
|
||||
img = Image.open(buf)
|
||||
mtype = img.get_format_mimetype()
|
||||
return data, mtype
|
||||
if uri.startswith("data:"): # https://en.wikipedia.org/wiki/Data_URI_scheme#Syntax (limited)
|
||||
if uri.startswith("data:"): # https://en.wikipedia.org/wiki/Data_URI_scheme#Syntax (limited)
|
||||
mtype_and_data = uri[len("data:"):]
|
||||
mtype = mtype_and_data.split(";", 1)[0]
|
||||
data_str = mtype_and_data.split(",", 1)[1]
|
||||
@@ -363,6 +431,20 @@ def _read_texture_uri(uri: str) -> Optional[Tuple[bytes, str]]:
|
||||
return data, mtype
|
||||
return None
|
||||
|
||||
|
||||
def _read_color(color: str) -> Optional[ColorTuple]:
|
||||
"""Reads a color from a string in the format #RRGGBB or #RRGGBBAA"""
|
||||
if color is None:
|
||||
return None
|
||||
if not color.startswith('#') or len(color) not in (7, 9):
|
||||
raise ValueError(f'Invalid color format: {color}')
|
||||
r = float(int(color[1:3], 16)) / 255.0
|
||||
g = float(int(color[3:5], 16)) / 255.0
|
||||
b = float(int(color[5:7], 16)) / 255.0
|
||||
a = float(int(color[7:9], 16)) / 255.0 if len(color) == 9 else 1.0
|
||||
return r, g, b, a
|
||||
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
def _preprocess_cad(obj: CADLike, **kwargs) -> CADCoreLike:
|
||||
# Get the shape of a CAD-like object
|
||||
@@ -384,6 +466,7 @@ def _preprocess_cad(obj: CADLike, **kwargs) -> CADCoreLike:
|
||||
|
||||
_obj_name_counts = {}
|
||||
|
||||
|
||||
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"""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user