Compare commits

...

147 Commits

Author SHA1 Message Date
Yeicor
4eee767afb Automatically update version to 0.10.11 2025-09-28 11:39:46 +00:00
Yeicor
3f9ec31799 Merge pull request #259 from quasipedia/master
Fix for symlink failures
2025-09-28 13:34:27 +02:00
Yeicor
54a22b1499 Merge branch 'master' into master 2025-09-28 13:31:52 +02:00
Yeicor
438d315b53 Remove center axes helpers 2025-09-28 13:08:42 +02:00
renovate[bot]
0cc6d81e9e chore(deps): lock file maintenance 2025-09-28 04:57:54 +00:00
renovate[bot]
2468aeed08 chore(deps): lock file maintenance 2025-09-28 01:43:50 +00:00
renovate[bot]
a8ec19e2d4 chore(deps): update dependency vuetify to v3.10.3 2025-09-27 16:24:53 +00:00
renovate[bot]
741a658b01 chore(deps): update dependency vue-tsc to v3.0.8 2025-09-27 12:26:41 +00:00
renovate[bot]
19663354cc chore(deps): update dependency vite to v7.1.7 2025-09-27 08:33:35 +00:00
renovate[bot]
e5f2246ff1 chore(deps): update dependency vue to v3.5.22 2025-09-27 05:52:36 +00:00
renovate[bot]
4cb8a65582 chore(deps): update dependency pyodide to v0.28.3 2025-09-27 02:06:27 +00:00
Mac Ryan
fc737583fd Fix fro symlink failures
Issue outlined in https://github.com/yeicor-3d/yet-another-cad-viewer/issues/258
2025-09-26 14:44:24 +02:00
renovate[bot]
37ddf9ab63 fix(deps): update dependency poetry-core to v2.2.1 2025-09-21 17:34:18 +00:00
renovate[bot]
5977172096 chore(deps): lock file maintenance 2025-09-21 05:09:56 +00:00
renovate[bot]
888239d898 fix(deps): update dependency vuetify to v3.10.2 2025-09-20 09:42:09 +00:00
renovate[bot]
fdfc5cdcad chore(deps): update dependency vite to v7.1.6 2025-09-20 05:14:31 +00:00
renovate[bot]
e2707ac8db chore(deps): update dependency @types/node to v22.18.6 2025-09-20 00:38:42 +00:00
renovate[bot]
f0a55efff1 chore(deps): lock file maintenance 2025-09-15 09:08:51 +00:00
renovate[bot]
71e95ec393 fix(deps): update dependency vuetify to v3.10.0 2025-09-14 20:50:44 +00:00
renovate[bot]
454a7249cd fix(deps): update dependency monaco-editor to ^0.53.0 (#252)
* fix(deps): update dependency monaco-editor to ^0.53.0

* Fix monaco.ts

* Fix monaco.ts

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Yeicor <4929005+yeicor@users.noreply.github.com>
2025-09-14 20:43:04 +00:00
renovate[bot]
1501f8c3b5 fix(deps): update dependency poetry-core to v2.2.0 2025-09-14 13:36:36 +00:00
renovate[bot]
32d34d9ec4 chore(deps): update dependency vue-tsc to v3.0.7 2025-09-14 09:40:30 +00:00
renovate[bot]
9b3b679631 chore(deps): update dependency commander to v14.0.1 2025-09-14 03:08:14 +00:00
renovate[bot]
dd389f34a3 chore(deps): update dependency @types/node to v22.18.3 2025-09-13 23:34:05 +00:00
renovate[bot]
21cdaf2c33 chore(deps): update dependency vite to v7.1.5 [security] 2025-09-10 02:01:36 +00:00
renovate[bot]
ace4d5aa5e chore(deps): lock file maintenance 2025-09-07 13:50:19 +00:00
renovate[bot]
289677a4ac chore(deps): lock file maintenance 2025-09-06 21:02:36 +00:00
renovate[bot]
a97c2807d8 chore(deps): update actions/setup-python action to v6 2025-09-06 16:52:44 +00:00
renovate[bot]
629aa939ca chore(deps): update actions/setup-node action to v5 2025-09-06 12:54:34 +00:00
renovate[bot]
e21fa7d680 fix(deps): update dependency three to ^0.180.0 2025-09-06 08:42:02 +00:00
renovate[bot]
3933d4842b fix(deps): update dependency vuetify to v3.9.7 2025-09-06 04:32:19 +00:00
renovate[bot]
95ffb769e2 chore(deps): update dependency @types/node to v22.18.1 2025-09-06 02:29:40 +00:00
renovate[bot]
16627a3cd4 chore(deps): lock file maintenance 2025-09-03 04:48:07 +00:00
renovate[bot]
0e1f6b7aa0 chore(deps): update dependency generate-license-file to v4.1.0 2025-09-03 01:57:42 +00:00
renovate[bot]
0ff8776a12 chore(deps): update dependency @vitejs/plugin-vue-jsx to v5.1.1 2025-09-03 00:03:00 +00:00
renovate[bot]
64e9e26da1 chore(deps): update dependency @types/node to v22.18.0 2025-08-31 20:36:13 +00:00
renovate[bot]
7eaba92057 fix(deps): update dependency vuetify to v3.9.6 2025-08-31 16:54:16 +00:00
renovate[bot]
6a0aa265b6 chore(deps): update dependency @vue/tsconfig to ^0.8.0 (#251)
* chore(deps): update dependency @vue/tsconfig to ^0.8.0

* Fix new ts issues

* Add null checks for selection and model objects throughout frontend

This improves robustness by handling cases where selection or model objects may be missing or undefined, preventing
runtime errors.

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Yeicor <4929005+yeicor@users.noreply.github.com>
2025-08-31 15:15:10 +00:00
renovate[bot]
fbf2f3e9a1 fix(deps): update dependency vue to v3.5.20 2025-08-31 09:06:02 +00:00
renovate[bot]
74491fc867 chore(deps): lock file maintenance 2025-08-23 20:40:39 +00:00
renovate[bot]
cc0b087cf7 chore(deps): update actions/upload-pages-artifact action to v4 2025-08-23 16:37:26 +00:00
renovate[bot]
fcacf8172d fix(deps): update dependency vue to v3.5.19 2025-08-23 14:37:44 +00:00
renovate[bot]
0c93a7e05d fix(deps): update dependency pyodide to v0.28.2 2025-08-23 08:42:13 +00:00
renovate[bot]
e50e3c7f09 chore(deps): update dependency vue-tsc to v3.0.6 2025-08-23 05:22:43 +00:00
renovate[bot]
9f76df8668 chore(deps): update dependency vite to v7.1.3 2025-08-23 00:41:43 +00:00
renovate[bot]
6c7f7382f6 chore(deps): update dependency vite-plugin-static-copy to v3.1.2 [security] 2025-08-21 18:33:48 +00:00
renovate[bot]
c7aee20aac chore(deps): lock file maintenance 2025-08-17 21:08:59 +00:00
renovate[bot]
8598f7f75e chore(deps): lock file maintenance 2025-08-17 01:46:09 +00:00
renovate[bot]
7a8ff04cc7 chore(deps): update actions/checkout action to v5 2025-08-16 20:50:46 +00:00
renovate[bot]
ac8bf8cb06 fix(deps): update dependency vuetify to v3.9.5 2025-08-16 18:13:39 +00:00
renovate[bot]
7a425e3ccc fix(deps): update dependency js-base64 to v3.7.8 2025-08-16 12:48:15 +00:00
renovate[bot]
ff3c3d6f2f chore(deps): update dependency vite to v7.1.2 2025-08-16 09:27:29 +00:00
renovate[bot]
8792ab7139 chore(deps): update dependency @types/pako to v2.0.4 2025-08-16 04:34:05 +00:00
renovate[bot]
291e3c726d chore(deps): update dependency @types/node to v22.17.2 2025-08-16 01:13:43 +00:00
renovate[bot]
be400fde29 chore(deps): lock file maintenance 2025-08-10 22:21:38 +00:00
renovate[bot]
32fb3230bb chore(deps): lock file maintenance 2025-08-10 05:29:41 +00:00
renovate[bot]
a63175dc3a chore(deps): update actions/download-artifact action to v5 2025-08-09 20:46:08 +00:00
Yeicor
3c013de8ba Update README.md 2025-08-09 19:32:59 +02:00
renovate[bot]
a659b0561f chore(deps): lock file maintenance (#249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-09 17:22:03 +00:00
renovate[bot]
ccd48bcae3 fix(deps): update dependency pyodide to v0.28.1 (#250)
* fix(deps): update dependency pyodide to v0.28.1

* Fix after pyodide update and update licenses

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Yeicor <4929005+Yeicor@users.noreply.github.com>
2025-08-09 16:40:16 +00:00
renovate[bot]
1a9cac08cd chore(deps): update dependency @types/three to ^0.179.0 2025-08-09 09:31:56 +00:00
renovate[bot]
3fb07300b8 fix(deps): update dependency vuetify to v3.9.4 2025-08-09 04:49:40 +00:00
renovate[bot]
938c554919 chore(deps): update dependency @types/node to v22.17.1 2025-08-09 01:19:43 +00:00
Yeicor
5308ececae Automatically update version to 0.10.10 2025-08-05 20:55:32 +00:00
Yeicor
64e2622954 Fix uploading multiple models when sharing links 2025-08-05 22:51:58 +02:00
Yeicor
2e8b8bd65f Fix uploading multiple models when sharing links 2025-08-05 22:49:11 +02:00
Yeicor
854de26695 Automatically update version to 0.10.9 2025-08-04 15:52:38 +00:00
Yeicor
e0503983f1 Ability to explode models and minor improvements 2025-08-04 17:47:22 +02:00
Yeicor
021cfd89a1 Fix typescript 5.9 errors and minor improvements 2025-08-02 14:20:59 +02:00
renovate[bot]
9d96effb30 chore(deps): lock file maintenance 2025-08-02 12:10:18 +00:00
Yeicor
dff2d36b44 Automatically update version to 0.10.8 2025-08-02 12:07:49 +00:00
Yeicor
810647276b Fix demo 2 2025-08-02 14:06:50 +02:00
Yeicor
a54b91f704 Fix demo 2025-08-02 14:06:30 +02:00
Yeicor
099726035a Automatically update version to 0.10.7 2025-08-02 11:41:09 +00:00
Yeicor
c383b8573a Fix production builds 3 2025-08-02 13:37:58 +02:00
Yeicor
f1c0a21699 Automatically update version to 0.10.6 2025-08-02 11:35:48 +00:00
Yeicor
8df0401fef Fix production builds 2 2025-08-02 13:34:53 +02:00
Yeicor
2c7adf8188 Cleaner deployment 2 2025-08-02 13:18:10 +02:00
Yeicor
d2b1b3b952 Automatically update version to 0.10.5 2025-08-02 11:16:15 +00:00
Yeicor
db3bbf2d32 Cleaner deployment 2025-08-02 13:15:25 +02:00
Yeicor
1d6230ef1e Fix production builds 2025-08-02 13:12:15 +02:00
renovate[bot]
7d22ca465e fix(deps): update dependency three to ^0.179.0 2025-08-02 11:03:50 +00:00
Yeicor
7c38ccc522 Automatically update version to 0.10.4 2025-08-02 11:02:49 +00:00
Yeicor
06a95d4875 Add upload and share functionality and minor improvements 2025-08-02 12:50:20 +02:00
renovate[bot]
56fc556c0f chore(deps): update dependency @vitejs/plugin-vue to v6.0.1 2025-08-02 02:45:14 +00:00
Yeicor
fe54f48ad7 Automatically update version to 0.10.3 2025-07-31 18:25:41 +00:00
Yeicor
c47f961b0d Automatically update version to 0.10.2 2025-07-31 18:13:15 +00:00
Yeicor
f86b714c08 Automatically update version to 0.10.2-alpha.1 2025-07-31 17:56:25 +00:00
Yeicor
2ae7c86f8d Fix release CI 2025-07-31 19:54:03 +02:00
Yeicor
0908216bfc Preinstall font-fetcher package for automatically downloading any requested font. 2025-07-31 19:34:06 +02:00
renovate[bot]
7871a5070d chore(deps): lock file maintenance 2025-07-27 17:42:55 +00:00
renovate[bot]
5173f00d29 chore(deps): lock file maintenance 2025-07-27 10:42:21 +00:00
renovate[bot]
c2ef910783 chore(deps): lock file maintenance 2025-07-26 17:45:40 +00:00
Yeicor
5a5f948224 Update README.md 2025-07-26 19:30:00 +02:00
Yeicor
dbeae5632e Automatically update version to 0.10.1 2025-07-26 15:28:24 +00:00
Yeicor
59116e4a1a Merge remote-tracking branch 'origin/master' 2025-07-26 17:27:41 +02:00
Yeicor
0be0103c3c Fix loading from dev server 2025-07-26 17:27:32 +02:00
Yeicor
d6deef9e7f Automatically update version to 0.10.0 2025-07-26 15:21:57 +00:00
Yeicor
ad956762f4 Automatically update version to 0.10.0-rc.6 2025-07-26 15:05:40 +00:00
Yeicor
a4acd2f3d3 Revert "fix(deps): update python to >=3.13,<3.14 (#246)"
This reverts commit 0855a9c6c7.
2025-07-26 17:04:18 +02:00
Yeicor
c877fef490 Minor fixes and drag and drop models onto interface 2025-07-26 17:01:42 +02:00
renovate[bot]
0855a9c6c7 fix(deps): update python to >=3.13,<3.14 (#246)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-26 12:19:17 +00:00
Yeicor
657b34d098 Automatically update version to 0.10.0-rc.5 2025-07-26 12:17:21 +00:00
Yeicor
ad83f1c937 Merge remote-tracking branch 'origin/master' 2025-07-26 14:15:17 +02:00
Yeicor
38be4c638b playground: minor improvements 2025-07-26 14:15:09 +02:00
renovate[bot]
63f2b716d6 chore(deps): lock file maintenance 2025-07-26 01:37:40 +00:00
Yeicor
9e70a3998d Automatically update version to 0.10.0-rc.4 2025-07-25 23:14:16 +00:00
Yeicor
c7c4adc250 Merge remote-tracking branch 'origin/master' 2025-07-26 01:13:19 +02:00
Yeicor
393decd876 playground: fix async code usage in startup.py 2025-07-26 01:13:03 +02:00
Yeicor
111f417905 Automatically update version to 0.10.0-rc.3 2025-07-25 23:07:00 +00:00
Yeicor
7296b15a67 Merge remote-tracking branch 'origin/master' 2025-07-26 01:05:57 +02:00
Yeicor
88190b0d1e Automatically update version to 0.10.0-rc.2 2025-07-25 23:04:16 +00:00
Yeicor
f2a607bb00 playground: better readme and prerelease yacv-server 2025-07-26 01:03:14 +02:00
Yeicor
0b6ed2fc34 Automatically update version to 0.10.0-rc.1 2025-07-25 22:54:35 +00:00
Yeicor
d1e5658e07 playground: minor improvements and install default font 2025-07-26 00:51:53 +02:00
Yeicor
3545785cae Automatically update version to 0.10.0-alpha.6 2025-07-25 16:22:13 +00:00
Yeicor
1df938b067 Fix max python version 2025-07-25 18:19:07 +02:00
Yeicor
42ae6384f0 Automatically update version to 0.10.0-alpha.5 2025-07-25 16:15:39 +00:00
renovate[bot]
b10b228fcc fix(deps): update python to v3.13.5 (#240)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-07-25 16:14:36 +00:00
Yeicor
ffaf6f26ba Merge pull request #245 from yeicor-3d/feature/pyodide-build123d-playground
Add playground functionality: build 3d models by coding in the browser.
2025-07-25 18:11:23 +02:00
Yeicor
17357aef25 yarn upgrade and poetry update --lock 2025-07-25 18:09:08 +02:00
Yeicor
0e6118ed94 Merge branch 'master' into feature/pyodide-build123d-playground
# Conflicts:
#	poetry.lock
#	yarn.lock
2025-07-25 18:01:58 +02:00
Yeicor
8a435b5f1a playground: fully working (snapshots left as future work) and other quality of life improvements 2025-07-25 17:59:40 +02:00
Yeicor
a63d018850 playground: most of the logic for both frontend and backend is implemented, some bugs remain 2025-07-20 21:35:45 +02:00
Yeicor
0460e939e4 playground: minor build fixes 2 2025-07-19 22:10:15 +02:00
Yeicor
b54453ca06 playground: minor build fixes 2025-07-19 22:02:33 +02:00
renovate[bot]
cc4e3f427e Lock file maintenance 2025-07-19 19:50:39 +00:00
Yeicor
fc32393635 playground: basic editor functionality ready 2025-07-19 21:49:02 +02:00
renovate[bot]
ae2a5f9bb0 Update dependency vuetify to v3.9.2 2025-07-19 15:21:11 +00:00
Yeicor
46cf45e4ce Merge pull request #244 from yeicor-3d/feature/expansion-panel-buttons
Refactor Model.vue: clean up unused styles and add back expansion panel buttons
2025-07-19 17:20:10 +02:00
Yeicor
3423c99f8d Merge branch 'master' into feature/expansion-panel-buttons 2025-07-19 17:19:17 +02:00
Yeicor
667a08d2c6 Merge pull request #243 from yeicor-3d/feature/lighting-follow-camera
Main light can now be moved, intensity can be regulated
2025-07-19 17:17:09 +02:00
Yeicor
1733337331 Merge branch 'master' into feature/lighting-follow-camera 2025-07-19 17:16:06 +02:00
Yeicor
0c51b614e9 Refactor Model.vue: clean up unused styles and add back expansion panel buttons 2025-07-19 17:12:30 +02:00
Yeicor
9ef372f576 Cleaning up 2025-07-19 16:57:37 +02:00
Yeicor
a2275f2897 Main light can now be moved, intensity can be regulated and update environment image for model viewer 2025-07-19 16:52:15 +02:00
renovate[bot]
b597b1e3a5 Update dependency vue-tsc to v3.0.3 2025-07-19 10:46:01 +00:00
Yeicor
4af4315984 Merge pull request #242 from andyross/vis-defaults
Update visual defaults
2025-07-19 12:45:11 +02:00
Yeicor
83bc4c767d Merge branch 'master' into vis-defaults 2025-07-19 12:43:41 +02:00
Yeicor
10ed5e2e9e Minor backend improvements: better color and textured handling, smooth shading, better demo 2025-07-19 12:43:05 +02:00
Yeicor
41662944d6 Minor frontend improvements and license updates 2025-07-19 10:17:52 +02:00
Yeicor
7f00a3a4ee Merge branch 'master' into vis-defaults 2025-07-19 09:12:57 +02:00
renovate[bot]
7549c2543b Update dependency vite to v7.0.5 2025-07-19 06:00:16 +00:00
renovate[bot]
fb7a525a71 Update dependency @types/node to v22.16.4 2025-07-19 00:56:19 +00:00
Andy Ross
c14a823dc1 Update visual defaults
Somewhat more conventional visual presentation:

+ Use a neutral gray background a-la most other CAD/modelling software
  for better contrast with the geometry (which defaults to a bright
  yellow).  This is done with the "skybox-environment" image in
  model-viewer, so add a new setting value in settings.ts for this (it
  can be overriden in the URL just like other settings)

+ But using a skybox will cause that image to be used for lighting
  too, which is clearly not desired.  So fetch a nice professional
  HDRI image from Polyhaven for lighting.  This is much better (more
  directional, higher contrast) than the default light environment
  anyway.

+ The checkerboard texture isn't really a good default.  Use a 1x1
  white pixel instead, essentially presenting the model materials
  unchanged.

Also collect the default color in gltf.py out of the code and put it
next to the texture for clarity.  This should probably be wired
through to a setting at some point.

Signed-off-by: Andy Ross <andy@plausible.org>
2025-07-15 15:24:09 -07:00
renovate[bot]
3845720d53 Lock file maintenance 2025-07-13 10:04:14 +00:00
renovate[bot]
16c109f399 Update dependency vuetify to v3.9.0 2025-07-12 17:50:58 +00:00
48 changed files with 4384 additions and 2230 deletions

View File

@@ -1,6 +1,7 @@
{
"append": [
"assets/fox.glb.license",
"assets/qwantani_afternoon_1k_hdr.jpg.license",
"LICENSE"
],
"replace": {

View File

@@ -14,10 +14,10 @@ jobs:
name: "Build frontend"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "actions/checkout@v5"
with:
ref: "${{ inputs.ref }}"
- uses: "actions/setup-node@v4"
- uses: "actions/setup-node@v5"
with:
cache: "yarn"
- run: "yarn install"
@@ -32,32 +32,32 @@ jobs:
name: "Build backend"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "actions/checkout@v5"
with:
ref: "${{ inputs.ref }}"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
- uses: "actions/setup-python@v6"
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"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "actions/checkout@v5"
with:
ref: "${{ inputs.ref }}"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
- uses: "actions/setup-python@v6"
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:
@@ -69,16 +69,16 @@ jobs:
name: "Build example"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "actions/checkout@v5"
with:
ref: "${{ inputs.ref }}"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
- uses: "actions/setup-python@v6"
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:

View File

@@ -12,32 +12,37 @@ jobs:
update-versions:
runs-on: "ubuntu-latest"
outputs:
skip: "${{ steps.check_recursive.outputs.skip || 'false' }}" # Default to false if not set
steps:
- uses: "actions/checkout@v4"
- uses: "actions/checkout@v5"
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
- uses: "actions/setup-node@v4"
- uses: "actions/setup-node@v5"
- run: "yarn version --new-version $CLEAN_VERSION --no-git-tag-version"
# Write the new version to pyproject.toml
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
- uses: "actions/setup-python@v6"
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
@@ -53,6 +58,7 @@ jobs:
deploy: # Makes sure all artifacts are updated and use the new version for the next deployment steps
needs: "update-versions"
if: "needs.update-versions.outputs.skip != 'true'" # Only run if the update-versions job did not skip
uses: "./.github/workflows/deploy2.yml"
secrets: "inherit" # Inherit the secrets from the parent workflow
with:

View File

@@ -32,16 +32,12 @@ jobs:
name: "github-pages"
url: "${{ steps.deployment.outputs.page_url }}"
steps:
- uses: "actions/download-artifact@v4"
- uses: "actions/download-artifact@v5"
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"
- uses: "actions/upload-pages-artifact@v4"
with:
path: 'public'
- id: "deployment"
@@ -58,18 +54,19 @@ jobs:
needs: "rebuild"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "actions/checkout@v5"
with:
ref: "${{ inputs.ref }}"
- uses: "actions/setup-node@v4"
- uses: "actions/setup-node@v5"
with:
cache: "yarn"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
- uses: "actions/setup-python@v6"
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"

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@
*.iml
venv/
__pycache__/

View File

@@ -10,20 +10,22 @@ in a web browser.
- All [GLTF 2.0](https://www.khronos.org/gltf/) features (textures, PBR materials, animations...).
- All [model-viewer](https://modelviewer.dev/) features (smooth controls, augmented reality...).
- Load multiple models at once, load external models and even images as quads.
- Control clipping planes and transparency of each model.
- Control clipping planes, transparency, edge/vertex sizes and explode each model.
- View and interact with topological entities: faces, edges, vertices and locations.
- 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=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.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.glb&preload=location.glb)).
![Demo](assets/screenshot.png)
@@ -32,4 +34,8 @@ 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.
- [OCP.wasm](https://github.com/yeicor/OCP.wasm/) ports OCP (OpenCASCADE for Python) and supporting libraries to
WebAssembly, enabling full in-browser CAD model generation and manipulation. This powers the build123d playground
provided by this viewer.

View File

@@ -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
@@ -779,9 +809,9 @@ The above copyright notice and this permission notice shall be included in all c
The following npm packages may be included in this product:
- @img/sharp-linux-x64@0.33.5
- @img/sharp-linuxmusl-x64@0.33.5
- sharp@0.33.5
- @img/sharp-linux-x64@0.34.3
- @img/sharp-linuxmusl-x64@0.34.3
- sharp@0.34.3
These packages each contain the following license:
@@ -981,7 +1011,7 @@ third-party archives.
The following npm package may be included in this product:
- typescript@5.8.3
- typescript@5.9.2
This package contains the following license:
@@ -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,44 @@ 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:
- tanmayo7lock@1.0.18
This package contains the following license:
ISC
-----------
The following npm package may be included in this product:
- picocolors@1.1.1
@@ -1407,8 +1540,8 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
The following npm packages may be included in this product:
- @img/sharp-libvips-linux-x64@1.0.4
- @img/sharp-libvips-linuxmusl-x64@1.0.4
- @img/sharp-libvips-linux-x64@1.2.0
- @img/sharp-libvips-linuxmusl-x64@1.2.0
These packages each contain the following license:
@@ -1418,9 +1551,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 +1584,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 +1642,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 +1822,16 @@ SOFTWARE.
-----------
The following npm package may be included in this product:
- pyodide@0.28.1
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 +1864,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 +1886,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 +1918,7 @@ THE SOFTWARE.
The following npm package may be included in this product:
- three@0.175.0
- three@0.179.1
This package contains the following license:
@@ -1775,13 +2008,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.4
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 +2068,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 +2107,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:
@@ -1875,7 +2138,7 @@ SOFTWARE.
The following npm packages may be included in this product:
- ndarray-lanczos@0.3.0
- ndarray-pixels@4.1.0
- ndarray-pixels@5.0.1
- property-graph@3.0.0
These packages each contain the following license:
@@ -1906,9 +2169,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.1
- @gltf-transform/extensions@4.2.1
- @gltf-transform/functions@4.2.1
These packages each contain the following license:
@@ -1968,7 +2231,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 +2312,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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

View File

@@ -0,0 +1,2 @@
CC0: Qwantani Afternoon by Greg Zaal (Photography) and Jarod Guest (Processing)
https://polyhaven.com/a/qwantani_afternoon

View File

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

View File

@@ -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);
@@ -50,9 +51,10 @@ async function onModelUpdateRequest(event: NetworkUpdateEvent) {
for (let modelIndex in event.models) {
let isLast = parseInt(modelIndex) === event.models.length - 1;
let model = event.models[modelIndex];
if (!model) continue;
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 +82,55 @@ 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);
}
function loadDemoModels() {
for (let name of ['fox.glb', 'img.glb', 'location.glb', 'logo.glb', 'logo_hl.glb', 'logo_hl_tex.glb']) {
networkMgr.load(`https://yeicor-3d.github.io/yet-another-cad-viewer/${name}`)
}
}
// 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 +138,29 @@ 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"/>&nbsp; Open playground...
</v-btn>
<v-btn @click="loadDemoModels" class="mx-auto d-block my-4">
<svg-icon :path="mdiCube" type="mdi"/>&nbsp; Load demo models...
</v-btn>
<v-btn @click="loadModelManual" class="mx-auto d-block my-4">
<svg-icon :path="mdiPlus" type="mdi"/>&nbsp; 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">
<template v-if="model !== undefined">
{{ model }}<span v-if="index < preloadingModels.length - 1">, </span>
</template>
</span>
</span>
</div>
</v-main>
<!-- The left collapsible sidebar has the list of models -->
@@ -117,7 +173,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 +181,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 +191,6 @@ async function loadModelManual() {
<style>
html, body {
height: 100%;
overflow: hidden;
overflow: hidden !important;
}
</style>
</style>

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

View File

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

View File

@@ -1,75 +1,88 @@
import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three';
import type {MObject3D} from "../tools/Selection.vue";
import type {ModelScene} from '@google/model-viewer/lib/three-components/ModelScene';
import type {SelectionInfo} from "../tools/selection";
import { BufferAttribute, InterleavedBufferAttribute, Vector3 } from "three";
import type { MObject3D } from "../tools/Selection.vue";
import type { ModelScene } from "@google/model-viewer/lib/three-components/ModelScene";
import type { SelectionInfo } from "../tools/selection";
function getCenterAndVertexList(selInfo: SelectionInfo, scene: ModelScene): {
center: Vector3,
vertices: Array<Vector3>
function getCenterAndVertexList(
selInfo: SelectionInfo,
scene: ModelScene,
): {
center: Vector3;
vertices: Array<Vector3>;
} {
let pos: BufferAttribute | InterleavedBufferAttribute = selInfo.object.geometry.getAttribute('position');
let ind: BufferAttribute | null = selInfo.object.geometry.index;
if (ind === null) {
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
for (let i = 0; i < pos.count; i++) {
ind.array[i] = i;
}
if (!selInfo.object?.geometry) {
throw new Error("selInfo.object or geometry is undefined");
}
let pos = selInfo.object.geometry.getAttribute("position");
let ind = selInfo.object.geometry.index;
if (ind === null) {
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
for (let i = 0; i < pos.count; i++) {
ind.array[i] = i;
}
let center = new Vector3();
let vertices = [];
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
let index = ind.getX(i)
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index));
vertex = scene.target.worldToLocal(selInfo.object.localToWorld(vertex));
center.add(vertex);
vertices.push(vertex);
}
center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]);
return {center, vertices};
}
let center = new Vector3();
let vertices = [];
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
let index = ind.getX(i);
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index));
vertex = scene.target.worldToLocal(selInfo.object.localToWorld(vertex));
center.add(vertex);
vertices.push(vertex);
}
center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]);
return { center, vertices };
}
/**
* Given two THREE.Object3D objects, returns their closest and farthest vertices, and the geometric centers.
* All of them are approximated and should not be used for precise calculations.
*/
export function distances(a: SelectionInfo, b: SelectionInfo, scene: ModelScene): {
min: Array<Vector3>,
center: Array<Vector3>,
max: Array<Vector3>
export function distances(
a: SelectionInfo,
b: SelectionInfo,
scene: ModelScene,
): {
min: Array<Vector3>;
center: Array<Vector3>;
max: Array<Vector3>;
} {
// Simplify this problem (approximate) by using the distance between each of their vertices.
// Find the center of each object.
let {center: aCenter, vertices: aVertices} = getCenterAndVertexList(a, scene);
let {center: bCenter, vertices: bVertices} = getCenterAndVertexList(b, scene);
// Simplify this problem (approximate) by using the distance between each of their vertices.
// Find the center of each object.
let { center: aCenter, vertices: aVertices } = getCenterAndVertexList(a, scene);
let { center: bCenter, vertices: bVertices } = getCenterAndVertexList(b, scene);
// Find the closest and farthest vertices.
// TODO: Compute actual min and max distances between the two objects.
// FIXME: Really slow... (use a BVH or something)
let minDistance = Infinity;
let minDistanceVertices = [new Vector3(), new Vector3()];
let maxDistance = -Infinity;
let maxDistanceVertices = [new Vector3(), new Vector3()];
for (let i = 0; i < aVertices.length; i++) {
for (let j = 0; j < bVertices.length; j++) {
let distance = aVertices[i].distanceTo(bVertices[j]);
if (distance < minDistance) {
minDistance = distance;
minDistanceVertices[0] = aVertices[i];
minDistanceVertices[1] = bVertices[j];
}
if (distance > maxDistance) {
maxDistance = distance;
maxDistanceVertices[0] = aVertices[i];
maxDistanceVertices[1] = bVertices[j];
}
// Find the closest and farthest vertices.
// TODO: Compute actual min and max distances between the two objects.
// FIXME: Really slow... (use a BVH or something)
let minDistance = Infinity;
let minDistanceVertices = [new Vector3(), new Vector3()];
let maxDistance = -Infinity;
let maxDistanceVertices = [new Vector3(), new Vector3()];
for (let i = 0; i < aVertices.length; i++) {
for (let j = 0; j < bVertices.length; j++) {
const aVertex = aVertices[i];
const bVertex = bVertices[j];
if (aVertex && bVertex) {
let distance = aVertex.distanceTo(bVertex);
if (distance < minDistance) {
minDistance = distance;
minDistanceVertices[0] = aVertex;
minDistanceVertices[1] = bVertex;
}
if (distance > maxDistance) {
maxDistance = distance;
maxDistanceVertices[0] = aVertex;
maxDistanceVertices[1] = bVertex;
}
}
}
}
// Return the results.
return {
min: minDistanceVertices,
center: [aCenter, bCenter],
max: maxDistanceVertices
};
}
// Return the results.
return {
min: minDistanceVertices,
center: [aCenter, bCenter],
max: maxDistanceVertices,
};
}

View File

@@ -1,10 +1,14 @@
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
import {unpartition, mergeDocuments} from "@gltf-transform/functions";
import { Buffer, Document, Scene, type Transform, WebIO } from "@gltf-transform/core";
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,104 +16,148 @@ 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 = () => {
}): Promise<Document> {
// Fetch the complete document from the network
// This could be done at the same time as the document is being processed, but I wanted better metrics
let response = await fetch(url);
let buffer = await response.arrayBuffer();
networkFinished();
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 fetchOrRead(url);
let buffer = await response.arrayBuffer();
networkFinished();
// Load the new document
let newDoc = null;
let alreadyTried: { [name: string]: boolean } = {}
while (newDoc == null) { // Retry adding extensions as required until the document is loaded
try { // Try to load fast if no extensions are used
newDoc = await io.readBinary(new Uint8Array(buffer));
} catch (e) { // Fallback to wait for download and register big extensions
if (e instanceof Error && e.message.toLowerCase().includes("khr_draco_mesh_compression")) {
if (alreadyTried["draco"]) throw e; else alreadyTried["draco"] = true;
// WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?)
let {KHRDracoMeshCompression} = await import("@gltf-transform/extensions")
let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js");
let dracoEncoderWeb = await import("three/examples/jsm/libs/draco/draco_encoder.js");
io.registerExtensions([KHRDracoMeshCompression])
.registerDependencies({
'draco3d.decoder': await dracoDecoderWeb.default({}),
'draco3d.encoder': await dracoEncoderWeb.default({})
});
} else if (e instanceof Error && e.message.toLowerCase().includes("ext_texture_webp")) {
if (alreadyTried["webp"]) throw e; else alreadyTried["webp"] = true;
let {EXTTextureWebP} = await import("@gltf-transform/extensions")
io.registerExtensions([EXTTextureWebP]);
} else { // TODO: Add more extensions as required
throw e;
}
}
// Load the new document
let newDoc = null;
let alreadyTried: { [name: string]: boolean } = {};
while (newDoc == null) {
// Retry adding extensions as required until the document is loaded
try {
// Try to load fast if no extensions are used
newDoc = await io.readBinary(new Uint8Array(buffer));
} catch (e) {
// Fallback to wait for download and register big extensions
if (!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 (!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]);
} else {
// TODO: Add more extensions as required
throw e;
}
}
}
// Remove any previous model with the same name
await document.transform(dropByName(name));
// Remove any previous model with the same name
await document.transform(dropByName(name));
// Ensure consistent names
// noinspection TypeScriptValidateJSTypes
await newDoc.transform(setNames(name));
// Ensure consistent names
// noinspection TypeScriptValidateJSTypes
await newDoc.transform(setNames(name));
// Merge the new document into the current one
mergeDocuments(document, newDoc);
return document;
// Merge the new document into the current one
mergeDocuments(document, newDoc);
return document;
}
export async function mergeFinalize(document: Document): Promise<Document> {
// Single scene & buffer required before loading & rendering
return await document.transform(mergeScenes(), unpartition());
// Single scene & buffer required before loading & rendering
return await document.transform(mergeScenes(), unpartition());
}
export async function toBuffer(doc: Document): Promise<Uint8Array> {
return io.writeBinary(doc);
return io.writeBinary(doc);
}
export async function removeModel(name: string, document: Document): Promise<Document> {
return await document.transform(dropByName(name));
return await document.transform(dropByName(name));
}
/** Given a parsed GLTF document and a name, it forces the names of all elements to be identified by the name (or derivatives) */
function setNames(name: string): Transform {
return (doc: Document) => {
// Do this automatically for all elements changing any name
for (let elem of doc.getGraph().listEdges().map(e => e.getChild())) {
if (!elem.getExtras()) elem.setExtras({});
elem.getExtras()[extrasNameKey] = name;
}
return doc;
return (doc: Document) => {
// Do this automatically for all elements changing any name
for (let elem of doc
.getGraph()
.listEdges()
.map((e) => e.getChild())) {
if (!elem.getExtras()) elem.setExtras({});
elem.getExtras()[extrasNameKey] = name;
}
return doc;
};
}
/** Ensures that all elements with the given name are removed from the document */
function dropByName(name: string): Transform {
return (doc: Document) => {
for (let elem of doc.getGraph().listEdges().map(e => e.getChild())) {
if (elem.getExtras() == null || elem instanceof Scene || elem instanceof Buffer) continue;
if ((elem.getExtras()[extrasNameKey]?.toString() ?? "") == name) {
elem.dispose();
}
}
return doc;
};
return (doc: Document) => {
for (let elem of doc
.getGraph()
.listEdges()
.map((e) => e.getChild())) {
if (elem.getExtras() == null || elem instanceof Scene || elem instanceof Buffer) continue;
if ((elem.getExtras()[extrasNameKey]?.toString() ?? "") == name) {
elem.dispose();
}
}
return doc;
};
}
/** Merges all scenes in the document into a single default scene */
function mergeScenes(): Transform {
return (doc: Document) => {
let root = doc.getRoot();
let scene = root.getDefaultScene() ?? root.listScenes()[0];
for (let dropScene of root.listScenes()) {
if (dropScene === scene) continue;
for (let node of dropScene.listChildren()) {
scene.addChild(node);
}
dropScene.dispose();
}
return (doc: Document) => {
let root = doc.getRoot();
let scene = root.getDefaultScene() ?? root.listScenes()[0];
if (!scene) {
throw new Error("No scene found in GLTF document");
}
for (let dropScene of root.listScenes()) {
if (dropScene === scene) continue;
for (let node of dropScene.listChildren()) {
scene.addChild(node);
}
dropScene.dispose();
}
};
}
/** 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);
}
}

View File

@@ -1,79 +1,112 @@
// noinspection JSVoidFunctionReturnValueUsed,JSUnresolvedReference
import {Document, type TypedArray} from '@gltf-transform/core'
import {Vector2} from "three/src/math/Vector2.js"
import {Vector3} from "three/src/math/Vector3.js"
import {Matrix4} from "three/src/math/Matrix4.js"
import { Document, type TypedArray } from "@gltf-transform/core";
import { Vector2 } from "three/src/math/Vector2.js";
import { Vector3 } from "three/src/math/Vector3.js";
import { Matrix4 } from "three/src/math/Matrix4.js";
/** Exports the colors used for the axes, primary and secondary. They match the orientation gizmo. */
export const AxesColors = {
x: [[247, 60, 60], [148, 36, 36]],
z: [[108, 203, 38], [65, 122, 23]],
y: [[23, 140, 240], [14, 84, 144]]
}
x: [
[247, 60, 60],
[148, 36, 36],
],
z: [
[108, 203, 38],
[65, 122, 23],
],
y: [
[23, 140, 240],
[14, 84, 144],
],
};
function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: number[], rawColors: number[] | null, transform: Matrix4, name: string = '__helper', mode: number = WebGL2RenderingContext.LINES) {
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer(name + 'Buffer')
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0] ?? doc.createScene(name + 'Scene')
const positions = doc.createAccessor(name + 'Position')
.setArray(new Float32Array(rawPositions) as TypedArray)
.setType('VEC3')
.setBuffer(buffer)
const indices = doc.createAccessor(name + 'Indices')
.setArray(new Uint32Array(rawIndices) as TypedArray)
.setType('SCALAR')
.setBuffer(buffer)
let colors = null;
if (rawColors) {
colors = doc.createAccessor(name + 'Color')
.setArray(new Float32Array(rawColors) as TypedArray)
.setType('VEC4')
.setBuffer(buffer);
}
const material = doc.createMaterial(name + 'Material')
.setAlphaMode('OPAQUE')
const geometry = doc.createPrimitive()
.setIndices(indices)
.setAttribute('POSITION', positions)
.setMode(mode as any)
.setMaterial(material)
if (rawColors) {
geometry.setAttribute('COLOR_0', colors)
}
if (mode == WebGL2RenderingContext.TRIANGLES) {
geometry.setExtras({face_triangles_end: [rawIndices.length / 6, rawIndices.length * 2 / 6, rawIndices.length * 3 / 6, rawIndices.length * 4 / 6, rawIndices.length * 5 / 6, rawIndices.length]})
} else if (mode == WebGL2RenderingContext.LINES) {
geometry.setExtras({edge_points_end: [rawIndices.length / 3, rawIndices.length * 2 / 3, rawIndices.length]})
}
const mesh = doc.createMesh(name + 'Mesh').addPrimitive(geometry)
const node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any)
scene.addChild(node)
function buildSimpleGltf(
doc: Document,
rawPositions: number[],
rawIndices: number[],
rawColors: number[] | null,
transform: Matrix4,
name: string = "__helper",
mode: number = WebGL2RenderingContext.LINES,
) {
const buffer = doc.getRoot().listBuffers()[0] ?? doc.createBuffer(name + "Buffer");
const scene = doc.getRoot().getDefaultScene() ?? doc.getRoot().listScenes()[0] ?? doc.createScene(name + "Scene");
if (!scene) throw new Error("Scene is undefined");
if (!rawPositions) throw new Error("rawPositions is undefined");
const positions = doc
.createAccessor(name + "Position")
.setArray(new Float32Array(rawPositions) as TypedArray)
.setType("VEC3")
.setBuffer(buffer);
const indices = doc
.createAccessor(name + "Indices")
.setArray(new Uint32Array(rawIndices) as TypedArray)
.setType("SCALAR")
.setBuffer(buffer);
let colors = null;
if (rawColors) {
colors = doc
.createAccessor(name + "Color")
.setArray(new Float32Array(rawColors) as TypedArray)
.setType("VEC4")
.setBuffer(buffer);
}
const material = doc.createMaterial(name + "Material").setAlphaMode("OPAQUE");
const geometry = doc
.createPrimitive()
.setIndices(indices)
.setAttribute("POSITION", positions)
.setMode(mode as any)
.setMaterial(material);
if (rawColors) {
geometry.setAttribute("COLOR_0", colors);
}
if (mode == WebGL2RenderingContext.TRIANGLES) {
geometry.setExtras({
face_triangles_end: [
rawIndices.length / 6,
(rawIndices.length * 2) / 6,
(rawIndices.length * 3) / 6,
(rawIndices.length * 4) / 6,
(rawIndices.length * 5) / 6,
rawIndices.length,
],
});
} else if (mode == WebGL2RenderingContext.LINES) {
geometry.setExtras({ edge_points_end: [rawIndices.length / 3, (rawIndices.length * 2) / 3, rawIndices.length] });
}
const mesh = doc.createMesh(name + "Mesh").addPrimitive(geometry);
const node = doc
.createNode(name + "Node")
.setMesh(mesh)
.setMatrix(transform.elements as any);
scene.addChild(node);
}
/**
* Create a new Axes helper as a GLTF model, useful for debugging positions and orientations.
*/
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
let rawIndices = [0, 1, 2, 3, 4, 5];
let rawPositions = [
0, 0, 0, size.x, 0, 0,
0, 0, 0, 0, size.y, 0,
0, 0, 0, 0, 0, -size.z,
];
let rawColors = [
...(AxesColors.x[0]), 255, ...(AxesColors.x[1]), 255,
...(AxesColors.y[0]), 255, ...(AxesColors.y[1]), 255,
...(AxesColors.z[0]), 255, ...(AxesColors.z[1]), 255
].map(x => x / 255.0);
// Axes at (0, 0, 0)
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), '__helper_axes');
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], new Matrix4(), '__helper_axes', WebGL2RenderingContext.POINTS);
// Axes at center
if (new Matrix4() != transform) {
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, transform, '__helper_axes_center');
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], transform, '__helper_axes_center', WebGL2RenderingContext.POINTS);
}
let rawIndices = [0, 1, 2, 3, 4, 5];
let rawPositions = [0, 0, 0, size.x, 0, 0, 0, 0, 0, 0, size.y, 0, 0, 0, 0, 0, 0, -size.z];
let rawColors = [
...(AxesColors.x[0] ?? [255, 0, 0]),
255,
...(AxesColors.x[1] ?? [255, 0, 0]),
255,
...(AxesColors.y[0] ?? [0, 255, 0]),
255,
...(AxesColors.y[1] ?? [0, 255, 0]),
255,
...(AxesColors.z[0] ?? [0, 0, 255]),
255,
...(AxesColors.z[1] ?? [0, 0, 255]),
255,
].map((x) => x / 255.0);
// Axes at (0, 0, 0)
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), "__helper_axes");
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], new Matrix4(), "__helper_axes", WebGL2RenderingContext.POINTS);
}
/**
@@ -82,62 +115,62 @@ 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) {
// Create transformed positions for the inner faces of the box
let allPositions: number[] = [];
let allIndices: number[] = [];
for (let axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
for (let positive of [1, -1]) {
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive));
let translation = new Matrix4().makeTranslation(offset.x, offset.y, offset.z)
let rotation = new Matrix4().lookAt(new Vector3(), offset, new Vector3(0, 1, 0))
let size2 = new Vector2();
if (axis.x) size2.set(size.z, size.y);
if (axis.y) size2.set(size.x, size.z);
if (axis.z) size2.set(size.x, size.y);
let transform = new Matrix4().multiply(translation).multiply(rotation);
let [rawPositions, rawIndices] = newGridPlane(size2, divisions);
let baseIndex = allPositions.length / 3;
for (let i of rawIndices) {
allIndices.push(i + baseIndex);
}
// Apply transform to the positions before adding them to the list
for (let i = 0; i < rawPositions.length; i += 3) {
let pos = new Vector3(rawPositions[i], rawPositions[i + 1], rawPositions[i + 2]);
pos.applyMatrix4(transform);
allPositions.push(pos.x, pos.y, pos.z);
}
}
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[] = [];
for (let axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
for (let positive of [1, -1]) {
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive));
let translation = new Matrix4().makeTranslation(offset.x, offset.y, offset.z);
let rotation = new Matrix4().lookAt(new Vector3(), offset, new Vector3(0, 1, 0));
let size2 = new Vector2();
if (axis.x) size2.set(size.z, size.y);
if (axis.y) size2.set(size.x, size.z);
if (axis.z) size2.set(size.x, size.y);
let transform = new Matrix4().multiply(translation).multiply(rotation);
let [rawPositions, rawIndices] = newGridPlane(size2, divisions);
let baseIndex = allPositions.length / 3;
for (let i of rawIndices) {
allIndices.push(i + baseIndex);
}
// Apply transform to the positions before adding them to the list
for (let i = 0; i < rawPositions.length; i += 3) {
let pos = new Vector3(rawPositions[i], rawPositions[i + 1], rawPositions[i + 2]);
pos.applyMatrix4(transform);
allPositions.push(pos.x, pos.y, pos.z);
}
}
let colors = new Array(allPositions.length / 3 * 4).fill(1);
buildSimpleGltf(doc, allPositions, allIndices, colors, baseTransform, '__helper_grid', WebGL2RenderingContext.TRIANGLES);
}
let colors = new Array((allPositions.length / 3) * 4).fill(1);
buildSimpleGltf(doc, allPositions, allIndices, colors, baseTransform, "__helper_grid", WebGL2RenderingContext.TRIANGLES);
}
export function newGridPlane(size: Vector2, divisions = 10, divisionWidth = 0.002): [number[], number[]] {
const rawPositions = [];
const rawIndices = [];
// Build the grid as triangles
for (let i = 0; i <= divisions; i++) {
const x = -size.x / 2 + size.x * i / divisions;
const y = -size.y / 2 + size.y * i / divisions;
const rawPositions = [];
const rawIndices = [];
// Build the grid as triangles
for (let i = 0; i <= divisions; i++) {
const x = -size.x / 2 + (size.x * i) / divisions;
const y = -size.y / 2 + (size.y * i) / divisions;
// Vertical quad (two triangles)
rawPositions.push(x - divisionWidth * size.x / 2, -size.y / 2, 0);
rawPositions.push(x + divisionWidth * size.x / 2, -size.y / 2, 0);
rawPositions.push(x + divisionWidth * size.x / 2, size.y / 2, 0);
rawPositions.push(x - divisionWidth * size.x / 2, size.y / 2, 0);
const baseIndex = i * 4;
rawIndices.push(baseIndex, baseIndex + 1, baseIndex + 2);
rawIndices.push(baseIndex, baseIndex + 2, baseIndex + 3);
// Vertical quad (two triangles)
rawPositions.push(x - (divisionWidth * size.x) / 2, -size.y / 2, 0);
rawPositions.push(x + (divisionWidth * size.x) / 2, -size.y / 2, 0);
rawPositions.push(x + (divisionWidth * size.x) / 2, size.y / 2, 0);
rawPositions.push(x - (divisionWidth * size.x) / 2, size.y / 2, 0);
const baseIndex = i * 4;
rawIndices.push(baseIndex, baseIndex + 1, baseIndex + 2);
rawIndices.push(baseIndex, baseIndex + 2, baseIndex + 3);
// Horizontal quad (two triangles)
rawPositions.push(-size.x / 2, y - divisionWidth * size.y / 2, 0);
rawPositions.push(size.x / 2, y - divisionWidth * size.y / 2, 0);
rawPositions.push(size.x / 2, y + divisionWidth * size.y / 2, 0);
rawPositions.push(-size.x / 2, y + divisionWidth * size.y / 2, 0);
const baseIndex2 = (divisions + 1 + i) * 4;
rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2);
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3);
}
return [rawPositions, rawIndices];
}
// Horizontal quad (two triangles)
rawPositions.push(-size.x / 2, y - (divisionWidth * size.y) / 2, 0);
rawPositions.push(size.x / 2, y - (divisionWidth * size.y) / 2, 0);
rawPositions.push(size.x / 2, y + (divisionWidth * size.y) / 2, 0);
rawPositions.push(-size.x / 2, y + (divisionWidth * size.y) / 2, 0);
const baseIndex2 = (divisions + 1 + i) * 4;
rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2);
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3);
}
return [rawPositions, rawIndices];
}

View File

@@ -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,37 @@ 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 hashParams: URLSearchParams
try {
let urlObj = new URL(url, window.location.href);
hashParams = new URLSearchParams(urlObj.hash.slice(1));
} catch (e) {
hashParams = new URLSearchParams();
}
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 +80,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 +111,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);

View File

@@ -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,15 +80,29 @@ 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();
let transform = (new Matrix4()).makeTranslation(bb.getCenter(new Vector3()));
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)]));
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc) as ArrayBufferView<ArrayBuffer>]));
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 */
@@ -98,8 +112,9 @@ export class SceneMgr {
// Serialize the document into a GLB and update the viewerSrc
let buffer = await toBuffer(document);
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
let blob = new Blob([buffer as ArrayBufferView<ArrayBuffer>], {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;

View File

@@ -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
@@ -12,7 +14,7 @@ export async function settings() {
// @ts-ignore
// new URL('../../assets/logo_build/location.glb', import.meta.url).href,
// @ts-ignore
// new URL('../../assets/logo_build/img.jpg.glb', import.meta.url).href,
// new URL('../../assets/logo_build/img.glb', import.meta.url).href,
// Websocket URLs automatically listen for new models from the python backend
'<auto>', // Get the default preload URL if not overridden
],
@@ -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") {
@@ -53,13 +85,30 @@ export async function settings() {
url = "dev+http://localhost:32323";
}
}
settings.preload[i] = url;
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);

View File

@@ -13,9 +13,10 @@ import {
} from "vuetify/lib/components/index.mjs";
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
import {Mesh} from "@gltf-transform/core";
import {ref, watch} from "vue";
import {nextTick, ref, watch} from "vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import {
mdiArrowExpand,
mdiCircleOpacity,
mdiCube,
mdiDelete,
@@ -25,6 +26,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 +36,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>,
@@ -41,7 +45,7 @@ const props = defineProps<{
}>();
const emit = defineEmits<{ remove: [] }>()
let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah blah blag blah blah blah"
let modelName = props.meshes[0]?.getExtras()?.[extrasNameKey] // + " blah blah blah blah blag blah blah blah"
// Count the number of faces, edges and vertices
let faceCount = ref(-1);
@@ -55,9 +59,14 @@ const clipPlaneY = ref(1);
const clipPlaneSwappedY = ref(false);
const clipPlaneZ = ref(1);
const clipPlaneSwappedZ = ref(false);
const edgeWidth = ref(0);
const explodeStrength = ref(0);
const explodeSwapped = ref(false);
// Load the settings for the default edge width
(async () => {
let s = await settings();
let s = await settings;
edgeWidth.value = s.edgeWidth;
})();
@@ -115,7 +124,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,14 +163,15 @@ 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();
if (clipPlaneSwappedZ.value) planes[2].negate();
if (clipPlaneSwappedX.value) planes[0]?.negate();
if (clipPlaneSwappedY.value) planes[1]?.negate();
if (clipPlaneSwappedZ.value) planes[2]?.negate();
if (!enabledZ) planes.pop();
if (!enabledY) planes.splice(1, 1);
if (!enabledX) planes.shift();
@@ -227,9 +238,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));
});
@@ -240,6 +252,76 @@ function onEdgeWidthChange(newEdgeWidth: number) {
watch(edgeWidth, onEdgeWidthChange);
// Explode the model
function onExplodeChange(newExplodeStrength: number) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Get direction and size of the explosion in a first pass
const meBbox = new Box3();
const othersBbox = new Box3();
sceneModel.traverse((child: MObject3D) => {
if (child == sceneModel) return; // Skip the scene itself
const isMe = child.userData[extrasNameKey] === modelName;
if ((child.type === 'Mesh' || child.type === 'SkinnedMesh' ||
child.type === 'Line' || child.type === 'LineSegments' ||
child.type === 'Points') && !child.userData.noHit) {
if (isMe) {
meBbox.expandByObject(child);
} else if (!isMe && child.userData[extrasNameKey]) {
othersBbox.expandByObject(child);
}
}
});
const modelSize = new Vector3();
meBbox.getSize(modelSize);
const maxDimension = Math.max(modelSize.x, modelSize.y, modelSize.z);
const pushDirection = new Vector3().subVectors(meBbox.getCenter(new Vector3()), othersBbox.getCenter(new Vector3())).normalize();
// Use absolute value for strength calculation
let strength = Math.abs(newExplodeStrength);
if (explodeSwapped.value) strength = -strength;
// Apply explosion
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if ((child.type === 'Mesh' || child.type === 'SkinnedMesh' ||
child.type === 'Line' || child.type === 'LineSegments' ||
child.type === 'Points')) {
// Handle zero vector case (if object is at origin)
const direction = pushDirection.clone();
if (direction.lengthSq() < 0.0001) {
direction.set(0, 1, 0);
console.warn("Explode direction was zero, using (0, 1, 0) instead");
}
// Calculate new position based on model size
const factor = strength * maxDimension;
const newPosition = new Vector3().add(direction.multiplyScalar(factor));
// Apply new position
child.position.copy(newPosition);
// Update related objects (back is automatically updated)
if (child.userData.niceLine) {
child.userData.niceLine.position.copy(newPosition);
}
}
}
});
scene.queueRender();
onClipPlanesChange();
}
// Add watchers for explode variables
watch(explodeStrength, (newVal) => onExplodeChange(newVal));
watch(explodeSwapped, () => onExplodeChange(explodeStrength.value));
function onModelLoad() {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
@@ -306,15 +388,17 @@ function onModelLoad() {
// Furthermore...
// Enabled features may have been reset after a reload
onEnabledFeaturesChange(enabledFeatures.value)
onEnabledFeaturesChange(enabledFeatures.value);
// Opacity may have been reset after a reload
onOpacityChange(opacity.value)
onOpacityChange(opacity.value);
// Wireframe may have been reset after a reload
onWireframeChange(wireframe.value)
onWireframeChange(wireframe.value);
// Clip planes may have been reset after a reload
onClipPlanesChange()
onClipPlanesChange();
// Edge width may have been reset after a reload
onEdgeWidthChange(edgeWidth.value)
onEdgeWidthChange(edgeWidth.value);
// Explode may have been reset after a reload
if (explodeStrength.value > 0) nextTick(() => onExplodeChange(explodeStrength.value));
scene.queueRender()
}
@@ -331,7 +415,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>
@@ -364,6 +448,21 @@ if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) a
<v-checkbox-btn v-model="wireframe" falseIcon="mdi-triangle" trueIcon="mdi-triangle-outline"></v-checkbox-btn>
</template>
</v-slider>
<v-slider v-model="explodeStrength" hide-details max="1" min="0">
<template v-slot:prepend>
<v-tooltip activator="parent">Explode model</v-tooltip>
<svg-icon :path="mdiArrowExpand" type="mdi"></svg-icon>
</template>
<template v-slot:append>
<v-tooltip activator="parent">Swap explode direction (may go crazy)</v-tooltip>
<v-checkbox-btn v-model="explodeSwapped" falseIcon="mdi-checkbox-blank-outline"
trueIcon="mdi-checkbox-marked-outline">
<template v-slot:label>
<svg-icon :path="mdiSwapHorizontal" type="mdi"></svg-icon>
</template>
</v-checkbox-btn>
</template>
</v-slider>
<v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details max="1" min="0">
<template v-slot:prepend>
<v-tooltip activator="parent">Edge and vertex sizes</v-tooltip>
@@ -430,10 +529,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 +539,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 +560,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>');
}
@@ -484,4 +575,4 @@ if (props.viewer) onViewerReady(props.viewer); else watch((() => props.viewer) a
.mdi-triangle-outline { /* HACK: mdi is not fully imported, only required icons... */
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M12 2L1 21h22M12 6l7.53 13H4.47"/></svg>');
}
</style>
</style>

View File

@@ -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')!!;
@@ -17,7 +17,7 @@ function meshesList(sceneDocument: Document): Array<Array<Mesh>> {
// Grouped by shared name
return sceneDocument.getRoot().listMeshes().reduce((acc, mesh) => {
let name = mesh.getExtras()[extrasNameKey]?.toString() ?? 'Unnamed';
let group = acc.find((group) => meshName(group[0]) === name);
let group = acc.find((group) => group[0] && meshName(group[0]) === name);
if (group) {
group.push(mesh);
} else {
@@ -32,7 +32,7 @@ function meshName(mesh: Mesh) {
}
function onRemove(mesh: Mesh) {
emit('remove', meshName(mesh))
emit('removeModel', meshName(mesh))
}
function findModel(name: string) {
@@ -43,9 +43,9 @@ defineExpose({findModel})
</script>
<template>
<v-expansion-panels v-for="meshes in meshesList(sceneDocument)" :key="meshName(meshes[0])"
<v-expansion-panels v-for="meshes in meshesList(sceneDocument)" :key="meshes[0] ? meshName(meshes[0]) : 'unnamed'"
v-model="expandedNames as any" multiple>
<model :meshes="meshes" :viewer="props.viewer" @remove="onRemove(meshes[0])"/>
<model :meshes="meshes" :viewer="props.viewer" @remove="meshes[0] ? onRemove(meshes[0]) : undefined"/>
</v-expansion-panels>
</template>

5
frontend/shims.d.ts vendored
View File

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

View File

@@ -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});
}
}

View File

@@ -0,0 +1,414 @@
<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 as ArrayBufferView<ArrayBuffer>], {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, Array<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) {
if (Array.isArray(added[k])) {
for (const v of added[k]) {
hashParams.append(k, v); // Prefer hash to GET
}
} else if (typeof added[k] === 'string') {
hashParams.set(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 | Array<string>> = {
'pg_code': await uploadFile('code.py', new Uint8Array(await codeBlob.arrayBuffer()))
};
// Upload all models
newParams['preload'] = []
for (const name in builtModelsGlb) {
const glb: any = builtModelsGlb[name];
const url = await uploadFile(name + '.glb', glb)
newParams['preload'].push(url); // Add to preload list
}
// 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>

View 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()

View File

@@ -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] ?? false) : 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];
}
}
@@ -329,14 +330,20 @@ function updateBoundingBox() {
// Only draw one edge per axis, the 2nd closest one to the camera
for (let edgeI in edgesByAxis) {
let axisEdges = edgesByAxis[edgeI];
let edge: Array<number> = axisEdges[0];
if (!axisEdges || axisEdges.length === 0) continue;
let edge: Array<number> = axisEdges[0] ?? [];
for (let i = 0; i < 2; i++) { // Find the 2nd closest one by running twice dropping the first
edge = axisEdges[0];
if (!axisEdges || axisEdges.length === 0) break;
edge = axisEdges[0] ?? [];
let edgeDist = Infinity;
let cameraPos: Vector3 = props.viewer?.scene?.camera?.position ?? new Vector3();
for (let testEdge of axisEdges) {
let from = new Vector3(...corners[testEdge[0]]);
let to = new Vector3(...corners[testEdge[1]]);
if (!testEdge || testEdge.length < 2) continue;
let cornerA = corners[testEdge[0] ?? 0];
let cornerB = corners[testEdge[1] ?? 0];
if (!cornerA || !cornerB) continue;
let from = new Vector3(...cornerA);
let to = new Vector3(...cornerB);
let mid = from.clone().add(to).multiplyScalar(0.5);
let newDist = cameraPos.distanceTo(mid);
if (newDist < edgeDist) {
@@ -346,11 +353,16 @@ function updateBoundingBox() {
}
axisEdges = axisEdges.filter((e) => e !== edge);
}
let from = new Vector3(...corners[edge[0]]);
let to = new Vector3(...corners[edge[1]]);
if (!edge || edge.length < 2) continue;
let cornerA = corners[edge[0] ?? 0];
let cornerB = corners[edge[1] ?? 0];
if (!cornerA || !cornerB) continue;
let from = new Vector3(...cornerA);
let to = new Vector3(...cornerB);
let length = to.clone().sub(from).length();
if (length < 0.05) continue; // Skip very small edges (e.g. a single point)
let color = [AxesColors.x, AxesColors.y, AxesColors.z][edgeI][1]; // Secondary colors
let colorArray = [AxesColors.x, AxesColors.y, AxesColors.z][parseInt(edgeI)];
let color = colorArray ? colorArray[1] : [255, 255, 255]; // Secondary colors
let lineCacheKey = JSON.stringify([from, to]);
let matchingLine = boundingBoxLines[lineCacheKey];
if (matchingLine) {
@@ -358,7 +370,7 @@ function updateBoundingBox() {
} else {
let newLineId = props.viewer?.addLine3D(from, to,
length.toFixed(1) + "mm", {
"stroke": "rgb(" + color.join(',') + ")",
"stroke": "rgb(" + (color ?? [255, 255, 255]).join(',') + ")",
"stroke-width": "2"
});
if (newLineId) {
@@ -409,10 +421,17 @@ function updateDistances() {
}
// Add lines (if not already added)
let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer?.scene);
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
if (!selected.value[0] || !selected.value[1] || !props.viewer?.scene) return;
let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer.scene);
if (max[0] && max[1]) {
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
}
if (center[0] && center[1]) {
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
}
if (min[0] && min[1]) {
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
}
// Remove the lines that are no longer needed
for (let lineLocator of distanceLinesToRemove) {
@@ -427,6 +446,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 +478,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 +497,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">
@@ -499,4 +522,4 @@ window.addEventListener('keydown', (event) => {
top: -12px;
width: calc(100% - 48px);
}
</style>
</style>

View File

@@ -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"/>
&nbsp;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
View 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
View 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 as any).MonacoEnvironment = {
getWorker(_: any, label: string) {
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})
}

View 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()
}
}

View File

@@ -0,0 +1,83 @@
import {loadPyodide, type PyodideAPI} 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<PyodideAPI> | 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 {
await 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 {
await 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});
}
};

View File

@@ -1,163 +1,182 @@
// Model management from the graphics side
import type {MObject3D} from "./Selection.vue";
import type {Intersection} from "three";
import {Box3} from "three";
import {extrasNameKey} from "../misc/gltf";
import type { MObject3D } from "./Selection.vue";
import type { Intersection } from "three";
import { Box3 } from "three";
import { extrasNameKey } from "../misc/gltf";
/** Information about a single item in the selection */
export class SelectionInfo {
/** The object which was (partially) selected */
object: MObject3D
/** The type of the selection */
kind: 'face' | 'edge' | 'vertex'
/** Start and end indices of the primitives in the geometry */
indices: [number, number]
/** The object which was (partially) selected */
object: MObject3D;
/** The type of the selection */
kind: "face" | "edge" | "vertex";
/** Start and end indices of the primitives in the geometry */
indices: [number, number];
constructor(object: MObject3D, kind: 'face' | 'edge' | 'vertex', indices: [number, number]) {
this.object = object;
this.kind = kind;
this.indices = indices;
}
constructor(object: MObject3D, kind: "face" | "edge" | "vertex", indices: [number, number]) {
this.object = object;
this.kind = kind;
this.indices = indices;
}
public getObjectName() {
return this.object.userData[extrasNameKey];
}
public getObjectName() {
return this.object.userData[extrasNameKey];
}
public matches(object: MObject3D) {
return this.getObjectName() === object.userData[extrasNameKey] &&
(this.kind === 'face' && (object.type === 'Mesh' || object.type === 'SkinnedMesh') ||
this.kind === 'edge' && (object.type === 'Line' || object.type === 'LineSegments') ||
this.kind === 'vertex' && object.type === 'Points')
}
public matches(object: MObject3D) {
return (
this.getObjectName() === object.userData[extrasNameKey] &&
((this.kind === "face" && (object.type === "Mesh" || object.type === "SkinnedMesh")) ||
(this.kind === "edge" && (object.type === "Line" || object.type === "LineSegments")) ||
(this.kind === "vertex" && object.type === "Points"))
);
}
public getKey() {
return this.object.uuid + this.kind + this.indices[0].toFixed() + this.indices[1].toFixed();
}
public getKey() {
return this.object.uuid + this.kind + this.indices[0].toFixed() + this.indices[1].toFixed();
}
public getBox(): Box3 {
let index = this.object.geometry.index || {getX: (i: number) => i};
let pos = this.object.geometry.getAttribute('position');
let min = [Infinity, Infinity, Infinity];
let max = [-Infinity, -Infinity, -Infinity];
for (let i = this.indices[0]; i < this.indices[1]; i++) {
let vertIndex = index!.getX(i);
let x = pos.getX(vertIndex);
let y = pos.getY(vertIndex);
let z = pos.getZ(vertIndex);
min[0] = Math.min(min[0], x);
min[1] = Math.min(min[1], y);
min[2] = Math.min(min[2], z);
max[0] = Math.max(max[0], x);
max[1] = Math.max(max[1], y);
max[2] = Math.max(max[2], z);
}
return new Box3().setFromArray([...min, ...max]);
public getBox(): Box3 {
let index = this.object.geometry.index || { getX: (i: number) => i };
let pos = this.object.geometry.getAttribute("position");
let min = [Infinity, Infinity, Infinity];
let max = [-Infinity, -Infinity, -Infinity];
for (let i = this.indices[0]; i < this.indices[1]; i++) {
let vertIndex = index!.getX(i);
let x = pos.getX(vertIndex);
let y = pos.getY(vertIndex);
let z = pos.getZ(vertIndex);
min[0] = Math.min(min[0] ?? Infinity, x);
min[1] = Math.min(min[1] ?? Infinity, y);
min[2] = Math.min(min[2] ?? Infinity, z);
max[0] = Math.max(max[0] ?? -Infinity, x);
max[1] = Math.max(max[1] ?? -Infinity, y);
max[2] = Math.max(max[2] ?? -Infinity, z);
}
return new Box3().setFromArray([...min, ...max]);
}
}
export function hitToSelectionInfo(hit: Intersection<MObject3D>): SelectionInfo | null {
let kind = hit.object.type;
if (kind == 'Mesh' || kind == 'SkinnedMesh') {
let indices = hitFaceTriangleIndices(hit);
if (indices === null) return null;
return new SelectionInfo(hit.object, 'face', indices);
} else if (kind == 'Line' || kind == 'LineSegments') {
// Select raw lines, not the wide meshes representing them
// This is because the indices refer to the raw lines, not the wide meshes
// Furthermore, this allows better "fuzzy" raycasting logic
let indices = hitEdgePointIndices(hit);
if (indices === null) return null;
return new SelectionInfo(hit.object, 'edge', indices);
} else if (kind == 'Points') {
if (hit.index === undefined) return null;
return new SelectionInfo(hit.object, 'vertex', [hit.index, hit.index + 1]);
}
return null;
let kind = hit.object.type;
if (kind == "Mesh" || kind == "SkinnedMesh") {
let indices = hitFaceTriangleIndices(hit);
if (indices === null) return null;
return new SelectionInfo(hit.object, "face", indices);
} else if (kind == "Line" || kind == "LineSegments") {
// Select raw lines, not the wide meshes representing them
// This is because the indices refer to the raw lines, not the wide meshes
// Furthermore, this allows better "fuzzy" raycasting logic
let indices = hitEdgePointIndices(hit);
if (indices === null) return null;
return new SelectionInfo(hit.object, "edge", indices);
} else if (kind == "Points") {
if (hit.index === undefined) return null;
return new SelectionInfo(hit.object, "vertex", [hit.index, hit.index + 1]);
}
return null;
}
function hitFaceTriangleIndices(hit: Intersection<MObject3D>): [number, number] | null {
let faceTrianglesEnd = hit?.object?.geometry?.userData?.face_triangles_end;
if (!hit.faceIndex) return null;
if (!faceTrianglesEnd) { // Fallback to selecting the whole imported mesh
//console.log("No face_triangles_end found, selecting the whole mesh");
return [0, (hit.object.geometry.index ?? hit.object.geometry.attributes.position).count];
} else { // Normal CAD model
let rawIndex = hit.faceIndex * 3; // Faces are triangles with 3 indices
for (let i = 0; i < faceTrianglesEnd.length; i++) {
let faceSwapIndex = faceTrianglesEnd[i]
if (rawIndex < faceSwapIndex) {
let start = i === 0 ? 0 : faceTrianglesEnd[i - 1];
return [start, faceTrianglesEnd[i]];
}
}
let faceTrianglesEnd = hit?.object?.geometry?.userData?.face_triangles_end;
if (!hit.faceIndex) return null;
if (!faceTrianglesEnd) {
// Fallback to selecting the whole imported mesh
//console.log("No face_triangles_end found, selecting the whole mesh");
return [0, (hit.object.geometry.index ?? hit.object.geometry.attributes.position)?.count ?? 0];
} else {
// Normal CAD model
let rawIndex = hit.faceIndex * 3; // Faces are triangles with 3 indices
for (let i = 0; i < faceTrianglesEnd.length; i++) {
let faceSwapIndex = faceTrianglesEnd[i];
if (rawIndex < faceSwapIndex) {
let start = i === 0 ? 0 : faceTrianglesEnd[i - 1];
return [start, faceTrianglesEnd[i]];
}
}
return null;
}
return null;
}
function hitEdgePointIndices(hit: Intersection<MObject3D>): [number, number] | null {
let edgePointsEnd = hit?.object?.geometry?.userData?.edge_points_end;
if (!edgePointsEnd || hit.index === undefined) return null;
let rawIndex = hit.index; // Faces are triangles with 3 indices
for (let i = 0; i < edgePointsEnd.length; i++) {
let edgeSwapIndex = edgePointsEnd[i]
if (rawIndex < edgeSwapIndex) {
let start = i === 0 ? 0 : edgePointsEnd[i - 1];
return [start, edgePointsEnd[i]];
}
let edgePointsEnd = hit?.object?.geometry?.userData?.edge_points_end;
if (!edgePointsEnd || hit.index === undefined) return null;
let rawIndex = hit.index; // Faces are triangles with 3 indices
for (let i = 0; i < edgePointsEnd.length; i++) {
let edgeSwapIndex = edgePointsEnd[i];
if (rawIndex < edgeSwapIndex) {
let start = i === 0 ? 0 : edgePointsEnd[i - 1];
return [start, edgePointsEnd[i]];
}
return null;
}
return null;
}
function applyColor(selInfo: SelectionInfo, colorAttribute: any, color: [number, number, number, number]): [number, number, number, number] {
let index = selInfo.object.geometry.index
let prevColor: [number, number, number, number] | null = null;
if (colorAttribute !== undefined) {
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
let vertIndex = index!.getX(i);
if (prevColor === null) prevColor = [colorAttribute.getX(vertIndex), colorAttribute.getY(vertIndex), colorAttribute.getZ(vertIndex), colorAttribute.getW(vertIndex)];
colorAttribute.setXYZW(vertIndex, color[0], color[1], color[2], color[3]);
}
colorAttribute.needsUpdate = true;
if (selInfo.object.userData.niceLine !== undefined) {
// Need to update the color of the nice line as well
let indexAttribute = selInfo.object.geometry.index!!;
let allNewColors = [];
for (let i = 0; i < indexAttribute.count; i++) {
if (indexAttribute.getX(i) >= selInfo.indices[0] && indexAttribute.getX(i) < selInfo.indices[1]) {
allNewColors.push(color[0], color[1], color[2]);
} else {
allNewColors.push(colorAttribute.getX(indexAttribute.getX(i)), colorAttribute.getY(indexAttribute.getX(i)), colorAttribute.getZ(indexAttribute.getX(i)));
}
}
selInfo.object.userData.niceLine.geometry.setColors(allNewColors);
for (let attribute of Object.values(selInfo.object.userData.niceLine.geometry.attributes)) {
(attribute as any).needsUpdate = true;
}
}
} else { // Fallback to tinting the whole mesh for imported models
//console.log("No color attribute found, tinting the whole mesh")
let tmpPrevColor = selInfo.object.material.color;
prevColor = [tmpPrevColor.r, tmpPrevColor.g, tmpPrevColor.b, 1];
selInfo.object.material.color.setRGB(color[0], color[1], color[2]);
selInfo.object.material.needsUpdate = true;
function applyColor(
selInfo: SelectionInfo,
colorAttribute: any,
color: [number, number, number, number],
): [number, number, number, number] {
let index = selInfo.object.geometry.index;
let prevColor: [number, number, number, number] | null = null;
if (colorAttribute !== undefined) {
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
let vertIndex = index!.getX(i);
if (prevColor === null)
prevColor = [
colorAttribute.getX(vertIndex),
colorAttribute.getY(vertIndex),
colorAttribute.getZ(vertIndex),
colorAttribute.getW(vertIndex),
];
colorAttribute.setXYZW(vertIndex, color[0], color[1], color[2], color[3]);
}
return prevColor!;
colorAttribute.needsUpdate = true;
if (selInfo.object.userData.niceLine !== undefined) {
// Need to update the color of the nice line as well
let indexAttribute = selInfo.object.geometry.index!!;
let allNewColors = [];
for (let i = 0; i < indexAttribute.count; i++) {
if (indexAttribute.getX(i) >= selInfo.indices[0] && indexAttribute.getX(i) < selInfo.indices[1]) {
allNewColors.push(color[0], color[1], color[2]);
} else {
allNewColors.push(
colorAttribute.getX(indexAttribute.getX(i)),
colorAttribute.getY(indexAttribute.getX(i)),
colorAttribute.getZ(indexAttribute.getX(i)),
);
}
}
selInfo.object.userData.niceLine.geometry.setColors(allNewColors);
for (let attribute of Object.values(selInfo.object.userData.niceLine.geometry.attributes)) {
(attribute as any).needsUpdate = true;
}
}
} else {
// Fallback to tinting the whole mesh for imported models
//console.log("No color attribute found, tinting the whole mesh")
let tmpPrevColor = selInfo.object.material.color;
prevColor = [tmpPrevColor.r, tmpPrevColor.g, tmpPrevColor.b, 1];
selInfo.object.material.color.setRGB(color[0], color[1], color[2]);
selInfo.object.material.needsUpdate = true;
}
return prevColor!;
}
export function highlight(selInfo: SelectionInfo): void {
// Update the color of all the triangles in the face
let geometry = selInfo.object.geometry;
let colorAttr = selInfo.object.geometry.getAttribute('color');
geometry.userData.savedColor = geometry.userData.savedColor || {};
geometry.userData.savedColor[selInfo.getKey()] = applyColor(selInfo, colorAttr, [1.0, 0.0, 0.0, 1.0]);
// Update the color of all the triangles in the face
let geometry = selInfo.object.geometry;
let colorAttr = selInfo.object.geometry.getAttribute("color");
geometry.userData.savedColor = geometry.userData.savedColor || {};
geometry.userData.savedColor[selInfo.getKey()] = applyColor(selInfo, colorAttr, [1.0, 0.0, 0.0, 1.0]);
}
export function highlightUndo(selInfo: SelectionInfo): void {
// Update the color of all the triangles in the face
let geometry = selInfo.object.geometry;
let colorAttr = selInfo.object.geometry.getAttribute('color');
let savedColor = geometry.userData.savedColor[selInfo.getKey()];
applyColor(selInfo, colorAttr, savedColor);
delete geometry.userData.savedColor[selInfo.getKey()];
}
// Update the color of all the triangles in the face
let geometry = selInfo.object.geometry;
let colorAttr = selInfo.object.geometry.getAttribute("color");
let savedColor = geometry.userData.savedColor[selInfo.getKey()];
applyColor(selInfo, colorAttr, savedColor);
delete geometry.userData.savedColor[selInfo.getKey()];
}

View File

@@ -0,0 +1,91 @@
//@ts-expect-error
import encryptCode from "tanmayo7lock?raw";
function encrypt(msg: string, secret: string = "hudfhgd8fghdfgh3uhuifdgh"): string {
let exports: any = {};
eval(encryptCode.replace("exports.encrypt = encrypt;", "exports.LargeDataCrypto = LargeDataCrypto;\nexports.encrypt = encrypt;"));
return exports.LargeDataCrypto.encrypt(msg, secret);
}
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 as ArrayBufferView<ArrayBuffer>], {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);
}

View File

@@ -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() {
@@ -159,9 +161,9 @@ function addLine3D(p1: Vector3, p2: Vector3, centerText?: string, lineAttrs: { [
function removeLine3D(id: number): boolean {
if (!scene.value || !(id in lines.value)) return false;
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_start'}));
lines.value[id].startHotspot.parentElement?.remove()
lines.value[id]?.startHotspot.parentElement?.remove()
scene.value.removeHotspot(new Hotspot({name: 'line' + id + '_end'}));
lines.value[id].endHotspot.parentElement?.remove()
lines.value[id]?.endHotspot.parentElement?.remove()
delete lines.value[id];
scene.value.queueRender() // Needed to update the hotspots
return true;
@@ -173,17 +175,17 @@ function onCameraChangeLine(lineId: number) {
if (!(lineId in lines.value) || !(elem.value)) return // Silently ignore (not updated yet)
// Update start and end 2D positions
let {x: xB, y: yB} = elem.value.getBoundingClientRect();
let {x, y} = lines.value[lineId].startHotspot.getBoundingClientRect();
lines.value[lineId].start2D = [x - xB, y - yB];
let {x: x2, y: y2} = lines.value[lineId].endHotspot.getBoundingClientRect();
lines.value[lineId].end2D = [x2 - xB, y2 - yB];
let {x, y} = lines.value[lineId]?.startHotspot.getBoundingClientRect() ?? {x: 0, y: 0};
if (lines.value[lineId]) lines.value[lineId].start2D = [x - xB, y - yB];
let {x: x2, y: y2} = lines.value[lineId]?.endHotspot.getBoundingClientRect() ?? {x: 0, y: 0};
if (lines.value[lineId]) lines.value[lineId].end2D = [x2 - xB, y2 - yB];
// Update the center text size if needed
if (svg.value && lines.value[lineId].centerText && lines.value[lineId].centerTextSize[0] === 0) {
if (svg.value && lines.value[lineId]?.centerText && lines.value[lineId]?.centerTextSize[0] === 0) {
let text = svg.value.getElementsByClassName('line' + lineId + '_text')[0] as SVGTextElement | undefined;
if (text) {
let bbox = text.getBBox();
lines.value[lineId].centerTextSize = [bbox.width, bbox.height];
if (lines.value[lineId]) lines.value[lineId].centerTextSize = [bbox.width, bbox.height];
}
}
}
@@ -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>

View File

@@ -0,0 +1,96 @@
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 ?? 0) + (targetTouches[1]?.clientX ?? 0));
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 ?? 0) + (targetTouches[1]?.clientX ?? 0));
updatePan(thisX);
},
true,
);
document.addEventListener(
"mouseup",
(event) => {
panning = false;
},
true,
);
modelViewer.addEventListener(
"touchend",
(event) => {
panning = false;
},
true,
);
}

View File

@@ -1,6 +1,6 @@
{
"name": "yet-another-cad-viewer",
"version": "0.9.7",
"version": "0.10.11",
"description": "",
"license": "MIT",
"private": true,
@@ -19,29 +19,37 @@
"@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.178.0",
"js-base64": "^3.7.7",
"monaco-editor": "^0.53.0",
"pako": "^2.1.0",
"pyodide": "^0.28.0",
"tanmayo7lock": "^1.0.18",
"three": "^0.180.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.178.0",
"@types/pako": "^2.0.3",
"@types/three": "^0.180.0",
"@vitejs/plugin-vue": "^6.0.0",
"@vitejs/plugin-vue-jsx": "^5.0.0",
"@vue/tsconfig": "^0.7.0",
"@vue/tsconfig": "^0.8.0",
"buffer": "^5.5.0||^6.0.0",
"commander": "^14.0.0",
"generate-license-file": "^4.0.0",
"npm-run-all2": "^8.0.0",
"terser": "^5.36.0",
"typescript": "~5.8.0",
"typescript": "^5.9.2",
"vite": "^7.0.0",
"vite-plugin-static-copy": "^3.1.1",
"vue-tsc": "^3.0.0"
}
}

1183
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -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.7"
version = "0.10.11"
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.2.1"
[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"

View File

@@ -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...
},
],
});
}

View File

@@ -78,7 +78,8 @@ def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
# 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 (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)]
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
@@ -116,7 +117,7 @@ def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = Non
# Handle arguments
if name is None:
if isinstance(source, str):
name = os.path.basename(source)
name, _ = os.path.splitext(os.path.basename(source))
else:
hasher = hashlib.md5()
hasher.update(source)
@@ -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),
], [

View File

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

View File

@@ -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__":

View File

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

View File

@@ -42,8 +42,8 @@ class HTTPHandler(SimpleHTTPRequestHandler):
path = super().translate_path(path)
path = os.path.realpath(path) # Avoid symlink hacks
if self.directory: # Ensure proper subdirectory
base = os.path.abspath(self.directory)
if not os.path.abspath(path).startswith(base):
base = os.path.realpath(self.directory)
if not path.startswith(base):
self.send_error(HTTPStatus.FORBIDDEN, "Path is not in the frontend directory")
return ''
return path

View File

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

View File

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

View File

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

2250
yarn.lock

File diff suppressed because it is too large Load Diff