Compare commits

...

137 Commits

Author SHA1 Message Date
Yeicor
2ba0e18479 Automatically update version to 0.8.6 2024-03-30 16:31:40 +00:00
Yeicor
eca2bbfa7c Fix python import bug 2024-03-30 17:28:49 +01:00
Yeicor
86180c424e Keep selected enabled features on model updates instead of resetting them, better list of objects support and recover/disable previous selection on scene reloads. 2024-03-30 17:26:06 +01:00
dependabot[bot]
e42d6be074 Bump @tsconfig/node20 from 20.1.3 to 20.1.4 (#30)
Bumps [@tsconfig/node20](https://github.com/tsconfig/bases/tree/HEAD/bases) from 20.1.3 to 20.1.4.
- [Commits](https://github.com/tsconfig/bases/commits/HEAD/bases)

---
updated-dependencies:
- dependency-name: "@tsconfig/node20"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 10:57:52 +00:00
dependabot[bot]
e2d6a3cb00 Bump actions/configure-pages from 4 to 5 in /.github/workflows (#31)
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:15:41 +00:00
dependabot[bot]
9e453b7890 Bump vuetify from 3.5.11 to 3.5.13 (#29)
Bumps [vuetify](https://github.com/vuetifyjs/vuetify/tree/HEAD/packages/vuetify) from 3.5.11 to 3.5.13.
- [Release notes](https://github.com/vuetifyjs/vuetify/releases)
- [Commits](https://github.com/vuetifyjs/vuetify/commits/v3.5.13/packages/vuetify)

---
updated-dependencies:
- dependency-name: vuetify
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:14:06 +00:00
dependabot[bot]
0b8faa9e8b Bump vite from 5.2.6 to 5.2.7 (#28)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.2.6 to 5.2.7.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.2.7/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:13:56 +00:00
dependabot[bot]
00bc2a15e0 Bump @types/node from 20.11.30 to 20.12.2 (#27)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.30 to 20.12.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:13:29 +00:00
dependabot[bot]
432abcf85c Bump terser from 5.29.2 to 5.30.0 (#26)
Bumps [terser](https://github.com/terser/terser) from 5.29.2 to 5.30.0.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.29.2...v5.30.0)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-30 09:13:17 +00:00
Yeicor
4b6d3f6266 Automatically update version to 0.8.5 2024-03-29 11:26:06 +00:00
Yeicor
255ae72ed2 Count features again after changes to model and support for sending arbitrary lists of shapes as a single model. 2024-03-29 12:23:16 +01:00
Yeicor
77dd9fb43e Merge remote-tracking branch 'origin/master' 2024-03-28 23:32:28 +01:00
Yeicor
5dc2ae2f8d Remove debug check 2024-03-28 23:32:20 +01:00
Yeicor
58440723bd Automatically update version to 0.8.4 2024-03-28 22:29:24 +00:00
Yeicor
bfdd656316 Merge remote-tracking branch 'origin/master' 2024-03-28 23:28:43 +01:00
Yeicor
7408823c02 Debug CI 2024-03-28 23:28:36 +01:00
Yeicor
856ffbc4c5 Reduce logging 2024-03-28 23:26:00 +01:00
Yeicor
d0f8463bbf Automatically update version to 0.8.3 2024-03-28 22:01:12 +00:00
Yeicor
162d3e22a2 Fix typescript 2024-03-28 22:57:26 +01:00
Yeicor
4b06559ab8 Add a progress bar to the frontend and improve slightly batched updates logic 2024-03-28 22:52:34 +01:00
Yeicor
9afa2e5786 Add support for some gltf extensions and better multi-object updates 2024-03-28 19:12:21 +01:00
Yeicor
7196fb2f32 Automatically update version to 0.8.2 2024-03-28 11:52:24 +00:00
Yeicor
8ec60faa04 Merge remote-tracking branch 'origin/master' 2024-03-28 12:51:34 +01:00
Yeicor
13bbdd5956 Fix automatic _find_var_name 2024-03-28 12:51:26 +01:00
Yeicor
3675d2f447 Automatically update version to 0.8.1 2024-03-28 11:23:32 +00:00
Yeicor
efc7a1d3b6 Merge remote-tracking branch 'origin/master' 2024-03-28 12:21:10 +01:00
Yeicor
7166f9fe3d Improved image location after build123d update 2024-03-28 12:20:56 +01:00
Yeicor
3405de38e7 Automatically update version to 0.8.0 2024-03-27 19:18:02 +00:00
Yeicor
2bd927f2a8 Updates 2 2024-03-27 20:17:19 +01:00
Yeicor
9718172fdd Updates 2024-03-27 20:16:49 +01:00
Yeicor
472a7a8309 Merge remote-tracking branch 'origin/master' 2024-03-26 22:02:40 +01:00
Yeicor
7a7627f57e clean code 2024-03-26 22:02:32 +01:00
Yeicor
064d9aeb35 Automatically update version to 0.7.1 2024-03-26 20:30:21 +00:00
Yeicor
eed0baccac fix automatic naming of objects 2024-03-26 21:25:28 +01:00
Yeicor
72480d82c8 strong performance optimizations for the backend 2024-03-26 21:22:48 +01:00
Yeicor
3de710c8b5 Merge remote-tracking branch 'origin/master' 2024-03-26 20:43:31 +01:00
Yeicor
8ebf48cb36 configurable edge and vertex widths 2024-03-26 20:43:21 +01:00
Yeicor
5083037b73 Automatically update version to 0.7.0 2024-03-25 20:39:40 +00:00
Yeicor
632e7e93c6 lots of performance improvements, bug fixes and some new features 2024-03-25 21:37:28 +01:00
dependabot[bot]
ec7139c809 Bump vite from 5.1.6 to 5.2.3 (#23)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.6 to 5.2.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/create-vite@5.2.3/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-23 09:58:13 +00:00
dependabot[bot]
752d6dc778 Bump vuetify from 3.5.9 to 3.5.11 (#25)
Bumps [vuetify](https://github.com/vuetifyjs/vuetify/tree/HEAD/packages/vuetify) from 3.5.9 to 3.5.11.
- [Release notes](https://github.com/vuetifyjs/vuetify/releases)
- [Commits](https://github.com/vuetifyjs/vuetify/commits/v3.5.11/packages/vuetify)

---
updated-dependencies:
- dependency-name: vuetify
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-23 09:57:06 +00:00
dependabot[bot]
6d60b7b003 Bump typescript from 5.4.2 to 5.4.3 (#24)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.4.2 to 5.4.3.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.4.2...v5.4.3)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-23 09:56:54 +00:00
dependabot[bot]
675e2e970a Bump @types/node from 20.11.28 to 20.11.30 (#22)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.28 to 20.11.30.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-23 09:56:21 +00:00
dependabot[bot]
70bef528c5 Bump vue-tsc from 2.0.6 to 2.0.7 (#21)
Bumps [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) from 2.0.6 to 2.0.7.
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v2.0.7/packages/tsc)

---
updated-dependencies:
- dependency-name: vue-tsc
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-23 09:56:08 +00:00
dependabot[bot]
78319fead5 Bump dependabot/fetch-metadata from 1 to 2 in /.github/workflows (#20)
Bumps [dependabot/fetch-metadata](https://github.com/dependabot/fetch-metadata) from 1 to 2.
- [Release notes](https://github.com/dependabot/fetch-metadata/releases)
- [Commits](https://github.com/dependabot/fetch-metadata/compare/v1...v2)

---
updated-dependencies:
- dependency-name: dependabot/fetch-metadata
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-23 09:23:39 +00:00
Yeicor
6944f69110 Automatically update version to 0.6.18 2024-03-16 15:23:23 +00:00
Yeicor
1d01c75448 more fixes 2024-03-16 16:22:08 +01:00
Yeicor
cb0a7bdf0c Automatically update version to 0.6.17 2024-03-16 09:57:27 +00:00
Yeicor
a7dba6fd1b Merge remote-tracking branch 'origin/master' 2024-03-16 10:56:54 +01:00
Yeicor
981d923e5e fix 2024-03-16 10:56:46 +01:00
Yeicor
6f95a2f3ad Automatically update version to 0.6.16 2024-03-16 09:55:30 +00:00
Yeicor
63c74461b2 Merge remote-tracking branch 'origin/master' 2024-03-16 10:54:38 +01:00
Yeicor
e85dc36fea clean frontend disconnection protocol 2024-03-16 10:54:26 +01:00
dependabot[bot]
43d30b0fdd Bump vite from 5.1.5 to 5.1.6 (#19)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.5 to 5.1.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.1.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:18:16 +00:00
dependabot[bot]
acba91322c Bump terser from 5.29.1 to 5.29.2 (#18)
Bumps [terser](https://github.com/terser/terser) from 5.29.1 to 5.29.2.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.29.1...v5.29.2)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:18:03 +00:00
dependabot[bot]
ba7ce3727d Bump @types/node from 20.11.25 to 20.11.28 (#16)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.25 to 20.11.28.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:17:45 +00:00
dependabot[bot]
d168806744 Bump vuetify from 3.5.8 to 3.5.9 (#15)
Bumps [vuetify](https://github.com/vuetifyjs/vuetify/tree/HEAD/packages/vuetify) from 3.5.8 to 3.5.9.
- [Release notes](https://github.com/vuetifyjs/vuetify/releases)
- [Commits](https://github.com/vuetifyjs/vuetify/commits/v3.5.9/packages/vuetify)

---
updated-dependencies:
- dependency-name: vuetify
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:17:27 +00:00
dependabot[bot]
919c05eb9d Bump @gltf-transform/functions from 3.10.0 to 3.10.1 (#14)
Bumps [@gltf-transform/functions](https://github.com/donmccurdy/glTF-Transform) from 3.10.0 to 3.10.1.
- [Changelog](https://github.com/donmccurdy/glTF-Transform/blob/main/CHANGELOG.md)
- [Commits](https://github.com/donmccurdy/glTF-Transform/compare/v3.10.0...v3.10.1)

---
updated-dependencies:
- dependency-name: "@gltf-transform/functions"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-16 09:17:17 +00:00
Yeicor
2370fd72ed Automatically update version to 0.6.15 2024-03-14 16:27:55 +00:00
Yeicor
aef047a658 Merge remote-tracking branch 'origin/master' 2024-03-14 17:27:02 +01:00
Yeicor
d5cdd094e8 reduce idle cpu usage and add todo 2024-03-14 17:26:54 +01:00
Yeicor
9c71573934 Automatically update version to 0.6.14 2024-03-10 18:47:07 +00:00
Yeicor
8fc5ed7544 fix for export_all 2024-03-10 19:46:20 +01:00
Yeicor
1fd932dbc6 Merge remote-tracking branch 'origin/master' 2024-03-10 19:06:59 +01:00
Yeicor
539ac40e3d update readme links 2024-03-10 19:06:51 +01:00
Yeicor
9c2656d7db Automatically update version to 0.6.13 2024-03-10 17:57:32 +00:00
Yeicor
161d76ee69 Merge remote-tracking branch 'origin/master' 2024-03-10 18:56:52 +01:00
Yeicor
431c41a615 fix CI deployment 12 2024-03-10 18:56:45 +01:00
Yeicor
7144eb39da Automatically update version to 0.6.12 2024-03-10 17:41:13 +00:00
Yeicor
8e1c89ad6d fix CI deployment 11 2024-03-10 18:40:13 +01:00
Yeicor
7f692c0b52 Automatically update version to 0.6.11 2024-03-10 17:32:32 +00:00
Yeicor
86043132a8 Merge remote-tracking branch 'origin/master' 2024-03-10 18:32:00 +01:00
Yeicor
23b4d25464 fix CI deployment 10 2024-03-10 18:31:52 +01:00
Yeicor
22514d8603 Automatically update version to 0.6.10 2024-03-10 17:27:00 +00:00
Yeicor
b440a89b13 Merge remote-tracking branch 'origin/master' 2024-03-10 18:26:21 +01:00
Yeicor
cbdb5aff5e fix CI deployment 10 2024-03-10 18:26:13 +01:00
Yeicor
a3a9258a78 Automatically update version to 0.6.9 2024-03-10 17:21:51 +00:00
Yeicor
9f30ac8eb7 Merge remote-tracking branch 'origin/master' 2024-03-10 18:21:08 +01:00
Yeicor
e11c9dd5c6 fix CI deployment 9 2024-03-10 18:21:00 +01:00
Yeicor
520b89af4a Automatically update version to 0.6.8 2024-03-10 17:17:37 +00:00
Yeicor
ba9aef2454 Merge remote-tracking branch 'origin/master' 2024-03-10 18:16:43 +01:00
Yeicor
509b12cd97 fix CI deployment 8 2024-03-10 18:16:34 +01:00
Yeicor
40b4d51895 Automatically update version to 0.6.7 2024-03-10 17:13:50 +00:00
Yeicor
af68f8b1ff fix CI deployment 7 2024-03-10 18:13:05 +01:00
Yeicor
9cb6b29c93 fix CI deployment 6 2024-03-10 18:11:24 +01:00
Yeicor
3174a39ef9 Merge remote-tracking branch 'origin/master' 2024-03-10 18:09:37 +01:00
Yeicor
78231aff31 fix CI deployment 5 2024-03-10 18:09:26 +01:00
Yeicor
39f1231f90 Automatically update version to 0.6.5 2024-03-10 17:08:58 +00:00
Yeicor
ed9251faac fix CI deployment 4 2024-03-10 18:08:13 +01:00
Yeicor
49d0afa616 fix CI deployment 3 2024-03-10 18:05:21 +01:00
Yeicor
844860ee1a fix CI deployment 2 2024-03-10 17:58:06 +01:00
Yeicor
c1ae621e6f fix CI deployment 2024-03-10 17:56:48 +01:00
Yeicor
b339955e37 fix CI 2 2024-03-10 17:42:09 +01:00
Yeicor
f3672202ea fix CI 2024-03-10 17:36:48 +01:00
Yeicor
49df7af970 add support for programmatically and efficiently removing objects, better API and more CI automation 2024-03-10 17:30:34 +01:00
Yeicor
88e1167b57 v0.5.1 2024-03-10 15:48:10 +01:00
Yeicor
b2b7faf626 v0.5.2 2024-03-10 15:48:09 +01:00
Yeicor
77ceeb2eba fix logo export 2024-03-10 15:38:56 +01:00
Yeicor
719395863d big rewrite focusing on faster performance and selection improvements 2024-03-10 15:34:39 +01:00
Yeicor
a9ce189c45 faster updates 2024-03-09 12:59:07 +01:00
Yeicor
9ca53bca61 fix issue with helpers 2024-03-09 12:48:22 +01:00
Yeicor
f1b04db24a small improvement for export 2024-03-09 12:05:40 +01:00
Yeicor
a304536536 Merge remote-tracking branch 'origin/master' 2024-03-09 11:47:31 +01:00
Yeicor
8b3d08826d small fix for pip 2024-03-09 11:47:23 +01:00
dependabot[bot]
40f424dd20 Bump typescript from 5.3.3 to 5.4.2 (#12)
Bumps [typescript](https://github.com/Microsoft/TypeScript) from 5.3.3 to 5.4.2.
- [Release notes](https://github.com/Microsoft/TypeScript/releases)
- [Changelog](https://github.com/microsoft/TypeScript/blob/main/azure-pipelines.release.yml)
- [Commits](https://github.com/Microsoft/TypeScript/compare/v5.3.3...v5.4.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-09 09:21:30 +00:00
dependabot[bot]
cb6c0acb63 Bump @types/node from 20.11.24 to 20.11.25 (#13)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.24 to 20.11.25.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-09 09:20:51 +00:00
dependabot[bot]
4ddc38f5c5 Bump vue-tsc from 2.0.5 to 2.0.6 (#11)
Bumps [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) from 2.0.5 to 2.0.6.
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v2.0.6/packages/tsc)

---
updated-dependencies:
- dependency-name: vue-tsc
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-09 09:20:21 +00:00
dependabot[bot]
f26d3a65a0 Bump vuetify from 3.5.7 to 3.5.8 (#10)
Bumps [vuetify](https://github.com/vuetifyjs/vuetify/tree/HEAD/packages/vuetify) from 3.5.7 to 3.5.8.
- [Release notes](https://github.com/vuetifyjs/vuetify/releases)
- [Commits](https://github.com/vuetifyjs/vuetify/commits/v3.5.8/packages/vuetify)

---
updated-dependencies:
- dependency-name: vuetify
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-09 09:20:10 +00:00
dependabot[bot]
b027819815 Bump terser from 5.28.1 to 5.29.1 (#9)
Bumps [terser](https://github.com/terser/terser) from 5.28.1 to 5.29.1.
- [Changelog](https://github.com/terser/terser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/terser/terser/compare/v5.28.1...v5.29.1)

---
updated-dependencies:
- dependency-name: terser
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-09 09:20:02 +00:00
Yeicor
768603b4d3 increase default request timeout 2024-03-07 20:56:51 +01:00
Yeicor
3e3730a4a5 faster multi-object load, faster updates and better orthographic camera at different scales 2024-03-07 20:49:27 +01:00
Yeicor
753648e522 fix imports 2024-03-06 19:25:49 +01:00
Yeicor
986db75b24 cleaner readme 2024-03-05 21:14:03 +01:00
Yeicor
962eea2b27 ready for release 0.2.0 2024-03-05 21:12:16 +01:00
Yeicor
8749c708e2 quick fixes 4 2024-03-05 21:08:24 +01:00
Yeicor
9954939aa0 quick fixes 3 2024-03-05 21:04:08 +01:00
Yeicor
8b59a5978e Merge remote-tracking branch 'origin/master' 2024-03-05 21:02:35 +01:00
Yeicor
fef6a1349c quick fixes 2 2024-03-05 21:02:28 +01:00
dependabot[bot]
817264289f Bump vite from 5.1.4 to 5.1.5 (#8)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.1.4 to 5.1.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.1.5/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-05 20:02:05 +00:00
Yeicor
b5c4e44eba Merge remote-tracking branch 'origin/master' 2024-03-05 21:01:28 +01:00
Yeicor
ea4a3bdb06 quick fixes 2024-03-05 21:01:19 +01:00
dependabot[bot]
6b771b4375 Bump vue-tsc from 2.0.3 to 2.0.5 (#7)
Bumps [vue-tsc](https://github.com/vuejs/language-tools/tree/HEAD/packages/tsc) from 2.0.3 to 2.0.5.
- [Release notes](https://github.com/vuejs/language-tools/releases)
- [Changelog](https://github.com/vuejs/language-tools/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vuejs/language-tools/commits/v2.0.5/packages/tsc)

---
updated-dependencies:
- dependency-name: vue-tsc
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-05 19:59:32 +00:00
Yeicor
1cbd1987b3 fully working example and many fixes 2024-03-05 20:58:14 +01:00
Yeicor
37a1c5de1f improve README.md 2024-03-03 21:12:45 +01:00
Yeicor
d3fbe254cb improve release asset 2 2024-03-03 20:47:29 +01:00
Yeicor
2475a00622 improve release asset 2024-03-03 20:45:12 +01:00
Yeicor
4bd025e7d5 fix launching backend without a built frontend 2024-03-03 20:41:44 +01:00
Yeicor
94e316472a fix selection 2024-03-03 20:38:56 +01:00
Yeicor
258256912b optimize CI times 2024-03-03 20:34:52 +01:00
Yeicor
075682ff18 fix for frontend parsing settings 2024-03-03 20:30:03 +01:00
Yeicor
cb1088fe71 add autoupdate.yml 2024-03-03 20:23:45 +01:00
Yeicor
682f41d3f8 fix frontend to use relative urls 2 2024-03-03 20:22:08 +01:00
Yeicor
dad2b4471a fix frontend to use relative urls 2024-03-03 20:18:34 +01:00
Yeicor
1c58c3d554 improve deploy workflow 7 2024-03-03 20:14:16 +01:00
Yeicor
5ff6e58071 improve deploy workflow 6 2024-03-03 20:08:32 +01:00
Yeicor
032f37a64b improve deploy workflow 5 2024-03-03 20:03:56 +01:00
Yeicor
dc12f83780 improve deploy workflow 4 2024-03-03 20:01:19 +01:00
51 changed files with 2577 additions and 2027 deletions

View File

@@ -12,6 +12,12 @@ updates:
interval: "weekly"
day: "saturday"
time: "09:00"
- package-ecosystem: "pip"
directory: "/example"
schedule:
interval: "weekly"
day: "saturday"
time: "09:00"
- package-ecosystem: "github-actions"
directory: "/.github/workflows/"
schedule:

35
.github/workflows/autoupdate.yml vendored Normal file
View File

@@ -0,0 +1,35 @@
on: "pull_request_target"
permissions:
pull-requests: "write"
contents: "write"
jobs:
dependabot:
runs-on: "ubuntu-latest"
# Checking the actor will prevent your Action run failing on non-Dependabot
# PRs but also ensures that it only does work for Dependabot PRs.
if: "${{ github.actor == 'dependabot[bot]' }}"
steps:
# This first step will fail if there's no metadata and so the approval
# will not occur.
- name: "Dependabot metadata"
id: "dependabot-metadata"
uses: "dependabot/fetch-metadata@v2"
with:
github-token: "${{ secrets.GITHUB_TOKEN }}"
# Here the PR gets approved.
- uses: "actions/checkout@v4"
- name: "Approve a PR"
run: "gh pr review --approve $PR_URL"
env:
PR_URL: "${{ github.event.pull_request.html_url }}"
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
# Finally, this sets the PR to allow auto-merging for patch and minor
# updates if all checks pass
- name: "Enable auto-merge for Dependabot PRs"
#if: "${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}"
run: "gh pr merge --auto --squash $PR_URL"
env:
PR_URL: "${{ github.event.pull_request.html_url }}"
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"

View File

@@ -1,5 +1,3 @@
name: "build"
on:
push:
branches:
@@ -7,6 +5,12 @@ on:
pull_request:
branches:
- "master"
workflow_call:
inputs:
ref:
type: "string"
required: true
description: "The ref (branch or tag) to build"
jobs:
@@ -15,6 +19,8 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with:
ref: "${{ inputs.ref }}"
- uses: "actions/setup-node@v4"
with:
cache: "yarn"
@@ -23,7 +29,7 @@ jobs:
- uses: "actions/upload-artifact@v4"
with:
name: "frontend"
path: "./dist"
path: "dist"
retention-days: 5
build-backend:
@@ -31,35 +37,52 @@ jobs:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "actions/setup-node@v4"
with:
cache: "yarn"
ref: "${{ inputs.ref }}"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
with:
python-version: "3.11"
cache: "poetry"
- run: "poetry install"
- run: "poetry build"
- run: "SKIP_BUILD_FRONTEND=true poetry install"
- run: "SKIP_BUILD_FRONTEND=true poetry build"
build-logo:
name: "Build logo"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
- uses: "actions/setup-node@v4"
with:
cache: "yarn"
ref: "${{ inputs.ref }}"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
with:
python-version: "3.11"
cache: "poetry"
- run: "poetry install"
- run: "SKIP_BUILD_FRONTEND=true poetry install"
- run: "poetry run python yacv_server/logo.py"
- run: "cp assets/fox.glb assets/logo_build/fox.glb"
- uses: "actions/upload-artifact@v4"
with:
name: "logo"
path: "./assets/logo_build"
path: "assets/logo_build"
retention-days: 5
build-example:
name: "Build example"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with:
ref: "${{ inputs.ref }}"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
with:
python-version: "3.11"
cache: "poetry"
- run: "SKIP_BUILD_FRONTEND=true poetry install"
- run: "YACV_DISABLE_SERVER=true poetry run python example/object.py"
- uses: "actions/upload-artifact@v4"
with:
name: "example"
path: "export"
retention-days: 5

View File

@@ -1,41 +0,0 @@
name: "maybe deploy"
on:
push:
tags:
- "v[0-9]+.[0-9]+.[0-9]+.*"
permissions:
contents: "write"
jobs:
deploy:
concurrency: "ci-${{ github.ref }}" # Recommended if you intend to make multiple deployments in quick succession.
runs-on: "ubuntu-latest"
needs: "check-early-exit"
if: "needs.check-early-exit.outputs.should-deploy == 'true'"
steps:
- uses: "dawidd6/action-download-artifact@v3"
with:
workflow: "build"
name: "frontend"
path: "./public"
- uses: "dawidd6/action-download-artifact@v3"
with:
workflow: "build"
name: "logo"
path: "./public"
- run: "ls -l -R ./public"
- uses: "JamesIves/github-pages-deploy-action@v4"
with:
folder: "public"
- uses: "svenstaro/upload-release-action@v2"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
file: "./public/*"
asset_name: "frontend"
tag: "${{ github.ref }}"
overwrite: true
file_glob: true

59
.github/workflows/deploy1.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
on:
push:
tags:
- "v**"
permissions: # Same as deploy2.yml
contents: "write"
pages: "write"
id-token: "write"
jobs:
update-versions:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with: # Ensure we are not in a detached HEAD state
ref: "master"
# Check that the tag commit is the latest master commit
- run: |
git fetch --tags
tag_commit=$(git rev-parse ${{ github.ref }})
master_commit=$(git rev-parse master)
if [ "$tag_commit" != "$master_commit" ]; then
echo "The tag commit ($tag_commit) is not the latest master commit ($master_commit)"
exit 1
fi
- run: "echo 'CLEAN_VERSION=${{ github.ref }}' | sed 's,refs/tags/v,,g' >> $GITHUB_ENV"
# Write the new version to package.json
- uses: "actions/setup-node@v4"
- run: "yarn version --new-version $CLEAN_VERSION --no-git-tag-version"
# Write the new version to pyproject.toml
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
with:
python-version: "3.11"
cache: "poetry"
- run: "poetry version $CLEAN_VERSION"
# Commit the changes and move the tag!
- run: |
git config --global user.email "yeicor@users.noreply.github.com"
git config --global user.name "Yeicor"
if git commit -am "Automatically update version to $CLEAN_VERSION"; then
git push
# Move the tag to the new commit
git tag -f -a "v$CLEAN_VERSION" -m "v$CLEAN_VERSION"
git push -f --tags # Force push the tag to GitHub
# The tag move will NOT trigger a new workflow
else
echo "No source change detected on version update (did you repeat a release tag??)"
exit 1
fi
deploy: # Makes sure all artifacts are updated and use the new version for the next deployment steps
needs: "update-versions"
uses: "./.github/workflows/deploy2.yml"
secrets: "inherit" # Inherit the secrets from the parent workflow
with:
ref: "master" # Ensure we are cloning the latest version of the repository

75
.github/workflows/deploy2.yml vendored Normal file
View File

@@ -0,0 +1,75 @@
on:
workflow_call:
inputs:
ref:
type: "string"
required: true
description: "The ref (branch or tag) to build"
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: "write"
pages: "write"
id-token: "write"
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: "pages"
cancel-in-progress: false
jobs:
rebuild: # Makes sure all artifacts are updated and use the new version
uses: "./.github/workflows/build.yml"
with:
ref: "${{ inputs.ref }}"
deploy-frontend:
needs: "rebuild"
runs-on: "ubuntu-latest"
environment:
name: "github-pages"
url: "${{ steps.deployment.outputs.page_url }}"
steps:
- uses: "actions/download-artifact@v4"
with: # Downloads all artifacts from the build job
path: "./public"
- run: | # Merge the subdirectories of public into a single directory
for dir in public/*; do
mv "$dir/"* public/
rmdir "$dir"
done
- uses: "actions/configure-pages@v5"
- uses: "actions/upload-pages-artifact@v3"
with:
path: 'public'
- id: "deployment"
uses: "actions/deploy-pages@v4"
- run: 'zip -r frontend.zip public'
- uses: "svenstaro/upload-release-action@v2"
with:
repo_token: "${{ secrets.GITHUB_TOKEN }}"
file: "frontend.zip"
tag: "${{ github.ref }}"
overwrite: true
deploy-backend:
needs: "rebuild"
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v4"
with:
ref: "${{ inputs.ref }}"
- uses: "actions/setup-node@v4"
with:
cache: "yarn"
- run: "pipx install poetry"
- uses: "actions/setup-python@v5"
with:
python-version: "3.11"
cache: "poetry"
- run: "poetry install"
- run: "poetry config pypi-token.pypi ${{ secrets.PYPI_TOKEN }}"
- run: "poetry publish --build"

View File

@@ -10,35 +10,19 @@ in a web browser.
- All [GLTF 2.0](https://www.khronos.org/gltf/) features (textures, PBR materials, animations...).
- All [model-viewer](https://modelviewer.dev/) features (smooth controls, augmented reality...).
- Load multiple models at once, load external models and even images as quads.
- View and interact with topological entities: faces, edges, vertices and locations.
- Control clipping planes and transparency of each model.
- View and interact with topological entities: faces, edges, vertices and locations.
- Select any entity and measure bounding box size and distances.
- Fully-featured [static deployment](#static-deployment): just upload the viewer and models to your server.
- [Live lazy updates](#live-updates) while editing the CAD model (using the `yacv-server` package).
- Hot reloading while editing the CAD model (using the `yacv-server` package).
- Fully-featured static deployment: just upload the viewer and models to your server.
## Usage & demo
## Usage
The [logo](yacv_server/logo.py) also works as an example of how to use the viewer.
The [example](example) is a fully working project that shows how to use the viewer.
### Live updates
To see the live updates you will need to run the [yacv_server](yacv_server) and
open [the viewer](https://yeicor-3d.github.io/yet-another-cad-viewer/) with
the `preloadModels=ws://<host>:32323/` query parameter (by default it already tries localhost).
Note that [yacv_server](yacv_server) also hosts the frontend at `http://localhost:32323/` if you have no access to the
internet.
### Static deployment
To deploy the viewer and models as a static website you can simply copy the latest build directory to your server.
To load models use the `preloadModels=...` query parameter in the URL.
It can be set multiple times to load multiple models.
Note that you can simply reuse the [main deployment](https://yeicor-3d.github.io/yet-another-cad-viewer/) and host only
your own models (linking them from the viewer with the `preloadModels` query parameter).
To see a working example of a static deployment you can check out
the [demo](https://yeicor-3d.github.io/yet-another-cad-viewer/?preloadModels=base.glb&preloadModels=fox.glb&preloadModels=img.jpg.glb&preloadModels=location.glb).
You can play with the latest
demo [here](https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=logo.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)
(or
[without animation](https://yeicor-3d.github.io/yet-another-cad-viewer/?autoplay=false&preload=logo.glb&preload=fox.glb&preload=img.jpg.glb&preload=location.glb)).
![Demo](assets/screenshot.png)

1
assets/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
/logo_build/

View File

@@ -211,11 +211,12 @@ Apache License
-----------
The following npm package may be included in this product:
The following npm packages may be included in this product:
- source-map-js@1.0.2
- source-map-js@1.2.0
This package contains the following license and notice below:
These packages each contain the following license and notice below:
Copyright (c) 2009-2011, Mozilla Foundation and contributors
All rights reserved.
@@ -523,8 +524,8 @@ Apache License
The following npm packages may be included in this product:
- b4a@1.6.6
- bare-events@2.2.0
- bare-fs@2.1.5
- bare-events@2.2.1
- bare-fs@2.2.1
- bare-os@2.2.0
- bare-path@2.1.0
@@ -1290,7 +1291,7 @@ third-party archives.
The following npm package may be included in this product:
- typescript@5.3.3
- typescript@5.4.3
This package contains the following license and notice below:
@@ -1430,7 +1431,7 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
The following npm package may be included in this product:
- @lit-labs/ssr-dom-shim@1.1.2
- @lit-labs/ssr-dom-shim@1.2.0
This package contains the following license and notice below:
@@ -1556,7 +1557,7 @@ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
The following npm package may be included in this product:
- @babel/parser@7.23.9
- @babel/parser@7.24.0
This package contains the following license and notice below:
@@ -1736,7 +1737,7 @@ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
The following npm package may be included in this product:
- node-abi@3.54.0
- node-abi@3.56.0
This package contains the following license and notice below:
@@ -1764,6 +1765,36 @@ SOFTWARE.
-----------
The following npm package may be included in this product:
- three-mesh-bvh@0.7.3
This package contains the following license and notice below:
MIT License
Copyright (c) 2018 Garrett Johnson
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-----------
The following npm package may be included in this product:
- napi-build-utils@1.0.2
@@ -1826,7 +1857,7 @@ SOFTWARE.
The following npm package may be included in this product:
- @monogrid/gainmap-js@3.0.1
- @monogrid/gainmap-js@3.0.3
This package contains the following license and notice below:
@@ -2378,7 +2409,7 @@ THE SOFTWARE.
The following npm package may be included in this product:
- prebuild-install@7.1.1
- prebuild-install@7.1.2
This package contains the following license and notice below:
@@ -2408,7 +2439,7 @@ THE SOFTWARE.
The following npm package may be included in this product:
- vuetify@3.5.3
- vuetify@3.5.13
This package contains the following license and notice below:
@@ -2498,16 +2529,16 @@ THE SOFTWARE.
The following npm packages may be included in this product:
- @vue/compiler-core@3.4.16
- @vue/compiler-dom@3.4.16
- @vue/compiler-sfc@3.4.16
- @vue/compiler-ssr@3.4.16
- @vue/reactivity@3.4.16
- @vue/runtime-core@3.4.16
- @vue/runtime-dom@3.4.16
- @vue/server-renderer@3.4.16
- @vue/shared@3.4.16
- vue@3.4.16
- @vue/compiler-core@3.4.21
- @vue/compiler-dom@3.4.21
- @vue/compiler-sfc@3.4.21
- @vue/compiler-ssr@3.4.21
- @vue/reactivity@3.4.21
- @vue/runtime-core@3.4.21
- @vue/runtime-dom@3.4.21
- @vue/server-renderer@3.4.21
- @vue/shared@3.4.21
- vue@3.4.21
These packages each contain the following license and notice below:
@@ -2538,7 +2569,7 @@ THE SOFTWARE.
The following npm packages may be included in this product:
- fast-fifo@1.3.2
- streamx@2.16.0
- streamx@2.16.1
These packages each contain the following license and notice below:
@@ -2690,9 +2721,9 @@ THE SOFTWARE.
The following npm packages may be included in this product:
- @gltf-transform/core@3.10.0
- @gltf-transform/extensions@3.10.0
- @gltf-transform/functions@3.10.0
- @gltf-transform/core@3.10.1
- @gltf-transform/extensions@3.10.1
- @gltf-transform/functions@3.10.1
These packages each contain the following license and notice below:
@@ -2843,7 +2874,7 @@ THE SOFTWARE.
The following npm package may be included in this product:
- postcss@8.4.35
- postcss@8.4.38
This package contains the following license and notice below:

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -2,6 +2,8 @@ import os
import subprocess
if __name__ == "__main__":
# When building the backend, make sure the frontend is built first
subprocess.run(['yarn', 'install'], check=True)
subprocess.run(['yarn', 'build', '--outDir', 'yacv_server/frontend'], check=True)
# Building the frontend is optional
if os.getenv('SKIP_BUILD_FRONTEND') is None and os.path.exists('package.json'):
# When building the backend, make sure the frontend is built first
subprocess.run(['yarn', 'install'], check=True)
subprocess.run(['yarn', 'build', '--outDir', 'yacv_server/frontend'], check=True)

2
example/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/venv/
/export/

41
example/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Quickstart of Yet Another CAD Viewer
## Installation
1. Download the contents of this folder.
2. Assuming you have a recent version of Python installed, install the required packages:
```bash
python -m venv venv
pip install -r requirements.txt
# Do this every time you change the terminal:
. venv/bin/activate
```
## Usage
### Development with hot-reloading
To start the viewer, open the [GitHub Pages link](https://yeicor-3d.github.io/yet-another-cad-viewer/) of the frontend.
It will try to connect to the server at `127.0.0.1:32323` by default (this can be changed with the `preload` query
parameter).
Running `python object.py` is enough to push the model to the viewer. However, the recommended way for developing with
minimal latency is to run in cell mode (#%%). This way, the slow imports are only done once, and the server keeps
running. After editing the file you can just re-run the cell with the `show_object` call to push the changes to
the viewer.
### Static final deployment
Once your model is complete, you may want to share it with others using the same viewer.
You can do so by exporting the model as a .glb file as a last step of your script.
This is already done in `object.py` if the environment variable `CI` is set.
Once you have the `object.glb` file, you can host it on any static file server and share the following link with others:
`https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=<link-to-object.glb>`
For the example model, the build process is set up in [build.yml](../.github/workflows/build.yml), the upload process
is set up in [deploy.yml](../.github/workflows/deploy.yml), and the final link is:
https://yeicor-3d.github.io/yet-another-cad-viewer/?preload=example.glb

26
example/object.py Normal file
View File

@@ -0,0 +1,26 @@
# Optional: enable logging to see what's happening
import logging
import os
from build123d import * # Also works with cadquery objects!
logging.basicConfig(level=logging.DEBUG)
from yacv_server import show, export_all # Check out other exported methods for more features!
# %%
# Create a simple object
with BuildPart() as example:
Box(10, 10, 5)
Cylinder(3, 5, mode=Mode.SUBTRACT)
# Show it in the frontend with hot-reloading
show(example)
# %%
# If running on CI, export the objects to .glb files for a static deployment
if 'CI' in os.environ:
export_all('export')

2
example/requirements.txt Normal file
View File

@@ -0,0 +1,2 @@
build123d
yacv-server

View File

@@ -7,7 +7,7 @@ import Tools from "./tools/Tools.vue";
import Models from "./models/Models.vue";
import {VBtn, VLayout, VMain, VToolbarTitle} from "vuetify/lib/components/index.mjs";
import {settings} from "./misc/settings";
import {NetworkManager, NetworkUpdateEvent} from "./misc/network";
import {NetworkManager, NetworkUpdateEvent, NetworkUpdateEventModel} from "./misc/network";
import {SceneMgr} from "./misc/scene";
import {Document} from "@gltf-transform/core";
import type ModelViewerWrapperT from "./viewer/ModelViewerWrapper.vue";
@@ -28,25 +28,59 @@ const viewer: Ref<InstanceType<typeof ModelViewerWrapperT> | null> = ref(null);
const sceneDocument = shallowRef(new Document());
provide('sceneDocument', {sceneDocument});
const models: Ref<InstanceType<typeof Models> | null> = ref(null)
const tools: Ref<InstanceType<typeof Tools> | null> = ref(null)
const disableTap = ref(false);
const setDisableTap = (val: boolean) => disableTap.value = val;
provide('disableTap', {disableTap, setDisableTap});
async function onModelLoadRequest(model: NetworkUpdateEvent) {
sceneDocument.value = await SceneMgr.loadModel(sceneUrl, sceneDocument.value, model.name, model.url);
async function onModelUpdateRequest(event: NetworkUpdateEvent) {
// Trigger progress bar as soon as possible (also triggered earlier for each raw notification)
if (viewer.value && event.models.length > 0) {
viewer.value.onProgress(0.10);
}
// Load/unload a new batch of models to optimize rendering time
console.log("Received model update request", event.models);
let shutdownRequestIndex = event.models.findIndex((model) => model.isRemove == null);
let shutdownRequest = null;
if (shutdownRequestIndex !== -1) {
console.log("Will shut down the connection after this load, as requested by the server");
shutdownRequest = event.models.splice(shutdownRequestIndex, 1)[0];
}
let doc = sceneDocument.value;
for (let modelIndex in event.models) {
let isLast = parseInt(modelIndex) === event.models.length - 1;
let model = event.models[modelIndex];
tools.value?.removeObjectSelections(model.name);
try {
if (!model.isRemove) {
doc = await SceneMgr.loadModel(sceneUrl, doc, model.name, model.url, isLast && settings.loadHelpers, isLast);
} else {
doc = await SceneMgr.removeModel(sceneUrl, doc, model.name, isLast && settings.loadHelpers, isLast);
}
} catch (e) {
console.error("Error loading model", model, e);
}
}
if (shutdownRequest !== null) {
console.log("Shutting down the connection as requested by the server");
event.disconnect();
}
sceneDocument.value = doc
triggerRef(sceneDocument); // Why not triggered automatically?
}
async function onModelRemoveRequest(name: string) {
sceneDocument.value = await SceneMgr.removeModel(sceneUrl, sceneDocument.value, name);
triggerRef(sceneDocument); // Why not triggered automatically?
await onModelUpdateRequest(new NetworkUpdateEvent([new NetworkUpdateEventModel(name, "", null, true)], () => {
}));
}
// Set up the load model event listener
let networkMgr = new NetworkManager();
networkMgr.addEventListener('update', (e) => onModelLoadRequest(e as NetworkUpdateEvent));
networkMgr.addEventListener('update-early',
(e) => viewer.value?.onProgress((e as CustomEvent<Array<any>>).detail.length * 0.01));
networkMgr.addEventListener('update', (e) => onModelUpdateRequest(e as NetworkUpdateEvent));
// Start loading all configured models ASAP
for (let model of settings.preloadModels) {
for (let model of settings.preload) {
networkMgr.load(model);
}
@@ -82,7 +116,7 @@ async function loadModelManual() {
<template #toolbar>
<v-toolbar-title>Tools</v-toolbar-title>
</template>
<tools :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
<tools ref="tools" :viewer="viewer" @findModel="(name) => models?.findModel(name)"/>
</sidebar>
</v-layout>

View File

@@ -5,6 +5,12 @@ import {createVuetify} from 'vuetify';
import * as directives from 'vuetify/lib/directives/index.mjs';
import 'vuetify/dist/vuetify.css';
// @ts-ignore
if (__APP_NAME__) {
// @ts-ignore
console.log(`Starting ${__APP_NAME__} v${__APP_VERSION__} (${__APP_GIT_SHA__}${__APP_GIT_DIRTY__ ? "+dirty" : ""})...`);
}
const vuetify = createVuetify({
directives,
theme: {

View File

@@ -6,7 +6,8 @@ import {VContainer, VRow, VCol, VProgressCircular} from "vuetify/lib/components/
<v-container>
<v-row justify="center" style="height: 100%">
<v-col align-self="center">
<v-progress-circular indeterminate style="display: block; margin: 0 auto;"/>
<v-progress-circular indeterminate style="display: block; margin: 0 auto;"></v-progress-circular>
<slot/>
</v-col>
</v-row>
</v-container>

View File

@@ -1,16 +1,16 @@
import {BufferAttribute, InterleavedBufferAttribute, Vector3} from 'three';
import type {MObject3D} from "../tools/Selection.vue";
import type { ModelScene } from '@google/model-viewer/lib/three-components/ModelScene';
import type {ModelScene} from '@google/model-viewer/lib/three-components/ModelScene';
import type {SelectionInfo} from "../tools/selection";
function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
function getCenterAndVertexList(selInfo: SelectionInfo, scene: ModelScene): {
center: Vector3,
vertices: Array<Vector3>
} {
obj.updateMatrixWorld();
let pos: BufferAttribute | InterleavedBufferAttribute = obj.geometry.getAttribute('position');
let ind: BufferAttribute | null = obj.geometry.index;
if (!ind) {
let pos: BufferAttribute | InterleavedBufferAttribute = selInfo.object.geometry.getAttribute('position');
let ind: BufferAttribute | null = selInfo.object.geometry.index;
if (ind === null) {
ind = new BufferAttribute(new Uint16Array(pos.count), 1);
for (let i = 0; i < pos.count; i++) {
ind.array[i] = i;
@@ -18,15 +18,14 @@ function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
}
let center = new Vector3();
let vertices = [];
for (let i = 0; i < ind.count; i++) {
let index = ind.array[i];
for (let i = selInfo.indices[0]; i < selInfo.indices[1]; i++) {
let index = ind.getX(i)
let vertex = new Vector3(pos.getX(index), pos.getY(index), pos.getZ(index));
vertex = scene.target.worldToLocal(obj.localToWorld(vertex));
vertex = scene.target.worldToLocal(selInfo.object.localToWorld(vertex));
center.add(vertex);
vertices.push(vertex);
}
center = center.divideScalar(ind.count);
console.log("center", center)
center = center.divideScalar(selInfo.indices[1] - selInfo.indices[0]);
return {center, vertices};
}
@@ -34,7 +33,7 @@ function getCenterAndVertexList(obj: MObject3D, scene: ModelScene): {
* Given two THREE.Object3D objects, returns their closest and farthest vertices, and the geometric centers.
* All of them are approximated and should not be used for precise calculations.
*/
export function distances(a: MObject3D, b: MObject3D, scene: ModelScene): {
export function distances(a: SelectionInfo, b: SelectionInfo, scene: ModelScene): {
min: Array<Vector3>,
center: Array<Vector3>,
max: Array<Vector3>
@@ -46,7 +45,6 @@ export function distances(a: MObject3D, b: MObject3D, scene: ModelScene): {
// Find the closest and farthest vertices.
// TODO: Compute actual min and max distances between the two objects.
// FIXME: Working for points and lines, but not triangles...
// FIXME: Really slow... (use a BVH or something)
let minDistance = Infinity;
let minDistanceVertices = [new Vector3(), new Vector3()];

View File

@@ -1,4 +1,4 @@
import {Document, Scene, type Transform, WebIO, Buffer} from "@gltf-transform/core";
import {Buffer, Document, Scene, type Transform, WebIO} from "@gltf-transform/core";
import {unpartition} from "@gltf-transform/functions";
let io = new WebIO();
@@ -12,9 +12,41 @@ export let extrasNameValueHelpers = "__helpers";
*
* Remember to call mergeFinalize after all models have been merged (slower required operations).
*/
export async function mergePartial(url: string, name: string, document: Document): Promise<Document> {
export async function mergePartial(url: string, name: string, document: Document, networkFinished: () => void = () => {
}): Promise<Document> {
// Fetch the complete document from the network
// This could be done at the same time as the document is being processed, but I wanted better metrics
let response = await fetch(url);
let buffer = await response.arrayBuffer();
networkFinished();
// Load the new document
let newDoc = await io.read(url);
let newDoc = null;
let alreadyTried: { [name: string]: boolean } = {}
while (newDoc == null) { // Retry adding extensions as required until the document is loaded
try { // Try to load fast if no extensions are used
newDoc = await io.readBinary(new Uint8Array(buffer));
} catch (e) { // Fallback to wait for download and register big extensions
if (e instanceof Error && e.message.toLowerCase().includes("khr_draco_mesh_compression")) {
if (alreadyTried["draco"]) throw e; else alreadyTried["draco"] = true;
// WARNING: Draco decompression on web is really slow for non-trivial models! (it should work?)
let {KHRDracoMeshCompression} = await import("@gltf-transform/extensions")
let dracoDecoderWeb = await import("three/examples/jsm/libs/draco/draco_decoder.js");
let dracoEncoderWeb = await import("three/examples/jsm/libs/draco/draco_encoder.js");
io.registerExtensions([KHRDracoMeshCompression])
.registerDependencies({
'draco3d.decoder': await dracoDecoderWeb.default({}),
'draco3d.encoder': await dracoEncoderWeb.default({})
});
} else if (e instanceof Error && e.message.toLowerCase().includes("ext_texture_webp")) {
if (alreadyTried["webp"]) throw e; else alreadyTried["webp"] = true;
let {EXTTextureWebP} = await import("@gltf-transform/extensions")
io.registerExtensions([EXTTextureWebP]);
} else { // TODO: Add more extensions as required
throw e;
}
}
}
// Remove any previous model with the same name
await document.transform(dropByName(name));

View File

@@ -1,3 +1,5 @@
// noinspection JSVoidFunctionReturnValueUsed,JSUnresolvedReference
import {Document, type TypedArray} from '@gltf-transform/core'
import {Vector2} from "three/src/math/Vector2.js"
import {Vector3} from "three/src/math/Vector3.js"
@@ -26,7 +28,7 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
if (rawColors) {
colors = doc.createAccessor(name + 'Color')
.setArray(new Float32Array(rawColors) as TypedArray)
.setType('VEC3')
.setType('VEC4')
.setBuffer(buffer);
}
const material = doc.createMaterial(name + 'Material')
@@ -39,6 +41,11 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
if (rawColors) {
geometry.setAttribute('COLOR_0', colors)
}
if (mode == WebGL2RenderingContext.TRIANGLES) {
geometry.setExtras({face_triangles_end: [rawIndices.length / 6, rawIndices.length * 2 / 6, rawIndices.length * 3 / 6, rawIndices.length * 4 / 6, rawIndices.length * 5 / 6, rawIndices.length]})
} else if (mode == WebGL2RenderingContext.LINES) {
geometry.setExtras({edge_points_end: [rawIndices.length / 3, rawIndices.length * 2 / 3, rawIndices.length]})
}
const mesh = doc.createMesh(name + 'Mesh').addPrimitive(geometry)
const node = doc.createNode(name + 'Node').setMesh(mesh).setMatrix(transform.elements as any)
scene.addChild(node)
@@ -48,21 +55,25 @@ function buildSimpleGltf(doc: Document, rawPositions: number[], rawIndices: numb
* Create a new Axes helper as a GLTF model, useful for debugging positions and orientations.
*/
export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
let rawIndices = [0, 1, 2, 3, 4, 5];
let rawPositions = [
[0, 0, 0, size.x, 0, 0],
[0, 0, 0, 0, size.y, 0],
[0, 0, 0, 0, 0, -size.z],
0, 0, 0, size.x, 0, 0,
0, 0, 0, 0, size.y, 0,
0, 0, 0, 0, 0, -size.z,
];
let rawIndices = [0, 1];
let rawColors = [
[...(AxesColors.x[0]), ...(AxesColors.x[1])],
[...(AxesColors.y[0]), ...(AxesColors.y[1])],
[...(AxesColors.z[0]), ...(AxesColors.z[1])],
].map(g => g.map(x => x / 255.0));
buildSimpleGltf(doc, rawPositions[0], rawIndices, rawColors[0], transform, '__helper_axes');
buildSimpleGltf(doc, rawPositions[1], rawIndices, rawColors[1], transform, '__helper_axes');
buildSimpleGltf(doc, rawPositions[2], rawIndices, rawColors[2], transform, '__helper_axes');
buildSimpleGltf(doc, [0, 0, 0], [0], null, transform, '__helper_axes', WebGL2RenderingContext.POINTS);
...(AxesColors.x[0]), 255, ...(AxesColors.x[1]), 255,
...(AxesColors.y[0]), 255, ...(AxesColors.y[1]), 255,
...(AxesColors.z[0]), 255, ...(AxesColors.z[1]), 255
].map(x => x / 255.0);
// Axes at (0, 0, 0)
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, new Matrix4(), '__helper_axes');
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], new Matrix4(), '__helper_axes', WebGL2RenderingContext.POINTS);
// Axes at center
if (new Matrix4() != transform) {
buildSimpleGltf(doc, rawPositions, rawIndices, rawColors, transform, '__helper_axes_center');
buildSimpleGltf(doc, [0, 0, 0], [0], [1, 1, 1, 1], transform, '__helper_axes_center', WebGL2RenderingContext.POINTS);
}
}
/**
@@ -71,8 +82,10 @@ export function newAxes(doc: Document, size: Vector3, transform: Matrix4) {
* The grid is built as a box of triangles (representing lines) looking to the inside of the box.
* This ensures that only the back of the grid is always visible, regardless of the camera position.
*/
export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4 = new Matrix4(), divisions = 10) {
export async function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4, divisions = 10) {
// Create transformed positions for the inner faces of the box
let allPositions: number[] = [];
let allIndices: number[] = [];
for (let axis of [new Vector3(1, 0, 0), new Vector3(0, 1, 0), new Vector3(0, 0, -1)]) {
for (let positive of [1, -1]) {
let offset = axis.clone().multiply(size.clone().multiplyScalar(0.5 * positive));
@@ -82,13 +95,25 @@ export function newGridBox(doc: Document, size: Vector3, baseTransform: Matrix4
if (axis.x) size2.set(size.z, size.y);
if (axis.y) size2.set(size.x, size.z);
if (axis.z) size2.set(size.x, size.y);
let transform = baseTransform.clone().multiply(translation).multiply(rotation);
newGridPlane(doc, size2, transform, divisions);
let transform = new Matrix4().multiply(translation).multiply(rotation);
let [rawPositions, rawIndices] = newGridPlane(size2, divisions);
let baseIndex = allPositions.length / 3;
for (let i of rawIndices) {
allIndices.push(i + baseIndex);
}
// Apply transform to the positions before adding them to the list
for (let i = 0; i < rawPositions.length; i += 3) {
let pos = new Vector3(rawPositions[i], rawPositions[i + 1], rawPositions[i + 2]);
pos.applyMatrix4(transform);
allPositions.push(pos.x, pos.y, pos.z);
}
}
}
let colors = new Array(allPositions.length / 3 * 4).fill(1);
buildSimpleGltf(doc, allPositions, allIndices, colors, baseTransform, '__helper_grid', WebGL2RenderingContext.TRIANGLES);
}
export function newGridPlane(doc: Document, size: Vector2, transform: Matrix4 = new Matrix4(), divisions = 10, divisionWidth = 0.002) {
export function newGridPlane(size: Vector2, divisions = 10, divisionWidth = 0.002): [number[], number[]] {
const rawPositions = [];
const rawIndices = [];
// Build the grid as triangles
@@ -114,5 +139,5 @@ export function newGridPlane(doc: Document, size: Vector2, transform: Matrix4 =
rawIndices.push(baseIndex2, baseIndex2 + 1, baseIndex2 + 2);
rawIndices.push(baseIndex2, baseIndex2 + 2, baseIndex2 + 3);
}
buildSimpleGltf(doc, rawPositions, rawIndices, null, transform, '__helper_grid', WebGL2RenderingContext.TRIANGLES);
return [rawPositions, rawIndices];
}

55
frontend/misc/lines.ts Normal file
View File

@@ -0,0 +1,55 @@
import {BufferGeometry} from 'three/src/core/BufferGeometry.js';
import {Vector2} from 'three/src/math/Vector2.js';
// The following imports must be done dynamically to be able to import three.js separately (smaller bundle sizee)
// import {LineSegments2} from "three/examples/jsm/lines/LineSegments2.js";
// import {LineMaterial} from "three/examples/jsm/lines/LineMaterial.js";
// import {LineSegmentsGeometry} from 'three/examples/jsm/lines/LineSegmentsGeometry.js';
const LineSegments2Import = import('three/examples/jsm/lines/LineSegments2.js');
const LineMaterialImport = import('three/examples/jsm/lines/LineMaterial.js');
const LineSegmentsGeometryImport = import('three/examples/jsm/lines/LineSegmentsGeometry.js');
export async function toLineSegments(bufferGeometry: BufferGeometry, lineWidth: number = 0.1) {
const LineSegments2 = (await LineSegments2Import).LineSegments2;
const LineMaterial = (await LineMaterialImport).LineMaterial;
return new LineSegments2(await toLineSegmentsGeometry(bufferGeometry), new LineMaterial({
color: 0xffffffff,
vertexColors: true,
linewidth: lineWidth, // mm
worldUnits: true,
resolution: new Vector2(1, 1), // Update resolution on resize!!!
}));
}
async function toLineSegmentsGeometry(bufferGeometry: BufferGeometry) {
const LineSegmentsGeometry = (await LineSegmentsGeometryImport).LineSegmentsGeometry;
const lg = new LineSegmentsGeometry();
const position = bufferGeometry.getAttribute('position');
const indexAttribute = bufferGeometry.index!!;
const positions = [];
for (let index = 0; index != indexAttribute.count; ++index) {
const i = indexAttribute.getX(index);
const x = position.getX(i);
const y = position.getY(i);
const z = position.getZ(i);
positions.push(x, y, z);
}
lg.setPositions(positions);
const colors = [];
const color = bufferGeometry.getAttribute('color');
if (color) {
for (let index = 0; index != indexAttribute.count; ++index) {
const i = indexAttribute.getX(index);
const r = color.getX(i);
const g = color.getY(i);
const b = color.getZ(i);
colors.push(r, g, b);
}
lg.setColors(colors);
}
lg.userData = bufferGeometry.userData;
return lg;
}

View File

@@ -1,19 +1,38 @@
import {settings} from "./settings";
export class NetworkUpdateEvent extends Event {
const batchTimeout = 250; // ms
export class NetworkUpdateEventModel {
name: string;
url: string;
// TODO: Detect and manage instances of the same object (same hash, different name)
hash: string | null;
isRemove: boolean | null; // This is null for a shutdown event
constructor(name: string, url: string) {
super("update");
constructor(name: string, url: string, hash: string | null, isRemove: boolean | null) {
this.name = name;
this.url = url;
this.hash = hash;
this.isRemove = isRemove;
}
}
export class NetworkUpdateEvent extends Event {
models: NetworkUpdateEventModel[];
disconnect: () => void;
constructor(models: NetworkUpdateEventModel[], disconnect: () => void) {
super("update");
this.models = models;
this.disconnect = disconnect;
}
}
/** Listens for updates and emits events when a model changes */
export class NetworkManager extends EventTarget {
private knownObjectHashes: { [name: string]: string | null } = {};
private bufferedUpdates: NetworkUpdateEventModel[] = [];
private batchTimeout: number | null = null;
/**
* Tries to load a new model (.glb) from the given URL.
@@ -24,8 +43,10 @@ export class NetworkManager extends EventTarget {
* Updates will be emitted as "update" events, including the download URL and the model name.
*/
async load(url: string) {
if (url.startsWith("ws://") || url.startsWith("wss://")) {
this.monitorWebSocket(url);
if (url.startsWith("dev+")) {
let baseUrl = new URL(url.slice(4));
baseUrl.searchParams.set("api_updates", "true");
await this.monitorDevServer(baseUrl);
} else {
// Get the last part of the URL as the "name" of the model
let name = url.split("/").pop();
@@ -34,32 +55,100 @@ export class NetworkManager extends EventTarget {
let response = await fetch(url, {method: "HEAD"});
let hash = response.headers.get("etag");
// Only trigger an update if the hash has changed
this.foundModel(name, hash, url);
this.foundModel(name, hash, url, false);
}
}
private monitorWebSocket(url: string) {
// WARNING: This will spam the console logs with failed requests when the server is down
let ws = new WebSocket(url);
ws.onmessage = (event) => {
let data = JSON.parse(event.data);
console.debug("WebSocket message", data);
let urlObj = new URL(url);
urlObj.protocol = urlObj.protocol === "ws:" ? "http:" : "https:";
urlObj.searchParams.set("api_object", data.name);
this.foundModel(data.name, data.hash, urlObj.toString());
};
ws.onerror = () => ws.close();
ws.onclose = () => setTimeout(() => this.monitorWebSocket(url), settings.monitorEveryMs);
let timeoutFaster = setTimeout(() => ws.close(), settings.monitorOpenTimeoutMs);
ws.onopen = () => clearTimeout(timeoutFaster);
private async monitorDevServer(url: URL, stop: () => boolean = () => false) {
while (!stop()) {
try {
// WARNING: This will spam the console logs with failed requests when the server is down
const controller = new AbortController();
let response = await fetch(url.toString(), {signal: controller.signal});
// console.log("Monitoring", url.toString(), response);
if (response.status === 200) {
let lines = readLinesStreamings(response.body!.getReader());
for await (let line of lines) {
if (stop()) break;
if (!line || !line.startsWith("data:")) continue;
let data: { name: string, hash: string, is_remove: boolean | null } = JSON.parse(line.slice(5));
// console.debug("WebSocket message", data);
let urlObj = new URL(url);
urlObj.searchParams.delete("api_updates");
urlObj.searchParams.set("api_object", data.name);
this.foundModel(data.name, data.hash, urlObj.toString(), data.is_remove, async () => {
controller.abort(); // Notify the server that we are done
});
}
} else {
// Server is down, wait a little longer before retrying
await new Promise(resolve => setTimeout(resolve, 10 * settings.monitorEveryMs));
}
controller.abort();
} catch (e) { // Ignore errors (retry very soon)
}
await new Promise(resolve => setTimeout(resolve, settings.monitorEveryMs));
}
}
private foundModel(name: string, hash: string | null, url: string) {
let prevHash = this.knownObjectHashes[name];
if (!hash || hash !== prevHash) {
this.knownObjectHashes[name] = hash;
this.dispatchEvent(new NetworkUpdateEvent(name, url));
private foundModel(name: string, hash: string | null, url: string, isRemove: boolean | null, disconnect: () => void = () => {
}) {
// console.debug("Found model", name, "with hash", hash, "at", url, "isRemove", isRemove);
// We only care about the latest update per model name
this.bufferedUpdates = this.bufferedUpdates.filter(m => m.name !== name);
// Add the new model to the list of updates and dispatch the early update
let upd = new NetworkUpdateEventModel(name, url, hash, isRemove);
this.bufferedUpdates.push(upd);
this.dispatchEvent(new CustomEvent("update-early", {detail: this.bufferedUpdates}));
// Optimization: try to batch updates automatically for faster rendering
if (this.batchTimeout !== null) clearTimeout(this.batchTimeout);
this.batchTimeout = setTimeout(() => {
// Update known hashes for minimal updates
for (let model of this.bufferedUpdates) {
if (model.isRemove == false && model.hash && model.hash === this.knownObjectHashes[model.name]) {
// Delete this useless update
let foundFirst = false;
this.bufferedUpdates = this.bufferedUpdates.filter(m => {
if (m === model) {
if (!foundFirst) { // Remove only first full match
foundFirst = true;
return false;
}
}
return true;
})
} else {
// Keep this update and update the last known hash
if (model.isRemove == true) {
if (model.name in this.knownObjectHashes) delete this.knownObjectHashes[model.name];
} else if (model.isRemove == false) {
this.knownObjectHashes[model.name] = model.hash;
}
}
}
// Dispatch the event to actually update the models
this.dispatchEvent(new NetworkUpdateEvent(this.bufferedUpdates, disconnect));
this.bufferedUpdates = [];
}, batchTimeout);
}
}
async function* readLinesStreamings(reader: ReadableStreamDefaultReader<Uint8Array>) {
let decoder = new TextDecoder();
let buffer = new Uint8Array();
while (true) {
let {value, done} = await reader.read();
if (done || !value) break;
buffer = new Uint8Array([...buffer, ...value]);
let text = decoder.decode(buffer);
let lines = text.split("\n");
for (let i = 0; i < lines.length - 1; i++) {
yield lines[i];
}
buffer = new Uint8Array([...buffer.slice(-1)]);
}
}

View File

@@ -9,30 +9,37 @@ import {Matrix4} from "three/src/math/Matrix4.js"
/** This class helps manage SceneManagerData. All methods are static to support reactivity... */
export class SceneMgr {
/** Loads a GLB model from a URL and adds it to the viewer or replaces it if the names match */
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string): Promise<Document> {
static async loadModel(sceneUrl: Ref<string>, document: Document, name: string, url: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
let loadStart = performance.now();
let loadNetworkEnd: number;
// Start merging into the current document, replacing or adding as needed
document = await mergePartial(url, name, document);
try {
// Start merging into the current document, replacing or adding as needed
document = await mergePartial(url, name, document, () => loadNetworkEnd = performance.now());
console.log("Model", name, "loaded in", performance.now() - loadStart, "ms");
console.log("Model", name, "loaded in", performance.now() - loadNetworkEnd!, "ms after",
loadNetworkEnd! - loadStart, "ms of transferring data (maybe building the object on the server)");
} finally {
if (updateHelpers) {
// Reload the helpers to fit the new model
await this.reloadHelpers(sceneUrl, document, reloadScene);
reloadScene = false;
}
if (name !== extrasNameValueHelpers) {
// Reload the helpers to fit the new model
// TODO: Only reload the helpers after a few milliseconds of no more models being added/removed
document = await this.reloadHelpers(sceneUrl, document);
} else {
// Display the final fully loaded model
let displayStart = performance.now();
document = await this.showCurrentDoc(sceneUrl, document);
console.log("Scene displayed in", performance.now() - displayStart, "ms");
if (reloadScene) {
// Display the final fully loaded model
let displayStart = performance.now();
document = await this.showCurrentDoc(sceneUrl, document);
console.log("Scene displayed in", performance.now() - displayStart, "ms");
}
}
return document;
}
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document): Promise<Document> {
private static async reloadHelpers(sceneUrl: Ref<string>, document: Document, reloadScene: boolean): Promise<Document> {
let bb = SceneMgr.getBoundingBox(document);
if (!bb) return document;
// Create the helper axes and grid box
let helpersDoc = new Document();
@@ -40,10 +47,11 @@ export class SceneMgr {
newAxes(helpersDoc, bb.getSize(new Vector3()).multiplyScalar(0.5), transform);
newGridBox(helpersDoc, bb.getSize(new Vector3()), transform);
let helpersUrl = URL.createObjectURL(new Blob([await toBuffer(helpersDoc)]));
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl);
return await SceneMgr.loadModel(sceneUrl, document, extrasNameValueHelpers, helpersUrl, false, reloadScene);
}
static getBoundingBox(document: Document): Box3 {
static getBoundingBox(document: Document): Box3 | null {
if (document.getRoot().listNodes().length === 0) return null;
// Get bounding box of the model and use it to set the size of the helpers
let bbMin: number[] = [1e6, 1e6, 1e6];
let bbMax: number[] = [-1e6, -1e6, -1e6];
@@ -67,7 +75,7 @@ export class SceneMgr {
}
/** Removes a model from the viewer */
static async removeModel(sceneUrl: Ref<string>, document: Document, name: string): Promise<Document> {
static async removeModel(sceneUrl: Ref<string>, document: Document, name: string, updateHelpers: boolean = true, reloadScene: boolean = true): Promise<Document> {
let loadStart = performance.now();
// Remove the model from the document
@@ -75,8 +83,10 @@ export class SceneMgr {
console.log("Model", name, "removed in", performance.now() - loadStart, "ms");
// Reload the helpers to fit the new model (will also show the document)
document = await this.reloadHelpers(sceneUrl, document);
if (updateHelpers) {
// Reload the helpers to fit the new model (will also show the document)
document = await this.reloadHelpers(sceneUrl, document, reloadScene);
}
return document;
}
@@ -89,7 +99,7 @@ export class SceneMgr {
// Serialize the document into a GLB and update the viewerSrc
let buffer = await toBuffer(document);
let blob = new Blob([buffer], {type: 'model/gltf-binary'});
console.debug("Showing current doc", document, "as", Array.from(buffer));
console.debug("Showing current doc", document, "with", buffer.length, "total bytes");
sceneUrl.value = URL.createObjectURL(blob);
return document;

View File

@@ -1,6 +1,6 @@
// These are the default values for the settings, which are overridden below
export const settings = {
preloadModels: [
preload: [
// @ts-ignore
// new URL('../../assets/fox.glb', import.meta.url).href,
// @ts-ignore
@@ -10,11 +10,13 @@ export const settings = {
// @ts-ignore
// new URL('../../assets/logo_build/img.jpg.glb', import.meta.url).href,
// Websocket URLs automatically listen for new models from the python backend
"ws://127.0.0.1:32323/"
"dev+http://127.0.0.1:32323/"
],
loadHelpers: true,
edgeWidth: 0, /* The default line size for edges, set to 0 to use basic gl.LINEs */
displayLoadingEveryMs: 1000, /* How often to display partially loaded models */
monitorEveryMs: 100,
monitorOpenTimeoutMs: 100,
monitorOpenTimeoutMs: 1000,
// ModelViewer settings
autoplay: true,
arModes: 'webxr scene-viewer quick-look',
@@ -29,15 +31,19 @@ function parseSetting(name: string, value: string): any {
if (arrayElem) name = name.slice(0, -2);
let prevValue = (settings as any)[name];
if (prevValue === undefined) throw new Error(`Unknown setting: ${name}`);
if (Array.isArray(prevValue) && !arrayElem) {
let toExtend = []
if (!firstTimeNames.includes(name)) {
firstTimeNames.push(name);
if (Array.isArray(prevValue)) {
if (!arrayElem) {
let toExtend = []
if (!firstTimeNames.includes(name)) {
firstTimeNames.push(name);
} else {
toExtend = prevValue;
}
toExtend.push(parseSetting(name + ".0", value));
return toExtend;
} else {
toExtend = prevValue;
prevValue = prevValue[0];
}
toExtend.push(parseSetting(name + ".0", value));
return toExtend;
}
switch (typeof prevValue) {
case 'boolean':
@@ -47,7 +53,7 @@ function parseSetting(name: string, value: string): any {
case 'string':
return value;
default:
throw new Error(`Unknown setting type: ${typeof prevValue}`);
throw new Error(`Unknown setting type: ${typeof prevValue} -- ${prevValue}`);
}
}

View File

@@ -12,8 +12,8 @@ import {
VTooltip,
} from "vuetify/lib/components/index.mjs";
import {extrasNameKey, extrasNameValueHelpers} from "../misc/gltf";
import {Document, Mesh} from "@gltf-transform/core";
import {inject, ref, type ShallowRef, watch} from "vue";
import {Mesh} from "@gltf-transform/core";
import {ref, watch} from "vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import {
mdiCircleOpacity,
@@ -22,16 +22,18 @@ import {
mdiRectangle,
mdiRectangleOutline,
mdiSwapHorizontal,
mdiVectorLine,
mdiVectorRectangle
} from '@mdi/js'
import SvgIcon from '@jamescoyle/vue-icon';
import {SceneMgr} from "../misc/scene";
import {BackSide, FrontSide} from "three/src/constants.js";
import {Box3} from "three/src/math/Box3.js";
import {Color} from "three/src/math/Color.js";
import {Plane} from "three/src/math/Plane.js";
import {Vector3} from "three/src/math/Vector3.js";
import type {MObject3D} from "../tools/Selection.vue";
import {toLineSegments} from "../misc/lines.js";
import {settings} from "../misc/settings.js";
const props = defineProps<{
meshes: Array<Mesh>,
@@ -41,25 +43,24 @@ const emit = defineEmits<{ remove: [] }>()
let modelName = props.meshes[0].getExtras()[extrasNameKey] // + " blah blah blah blah blag blah blah blah"
// Reactive properties
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
const opacity = defineModel<number>("opacity", {default: 1});
// Count the number of faces, edges and vertices
let faceCount = ref(-1);
let edgeCount = ref(-1);
let vertexCount = ref(-1);
// Clipping planes are handled in y-up space (swapped on interface, Z inverted later)
const clipPlaneX = ref(1);
const clipPlaneSwappedX = ref(false);
const clipPlaneY = ref(1);
const clipPlaneSwappedY = ref(false);
const clipPlaneZ = ref(1);
const clipPlaneSwappedZ = ref(false);
const edgeWidth = ref(settings.edgeWidth);
// Count the number of faces, edges and vertices
let faceCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES).length).reduce((a, b) => a + b, 0)
let edgeCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]).length).reduce((a, b) => a + b, 0)
let vertexCount = props.meshes.map((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS).length).reduce((a, b) => a + b, 0)
// Set initial defaults for the enabled features
if (faceCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
if (edgeCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
if (vertexCount === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
// Misc properties
const enabledFeatures = defineModel<Array<number>>("enabledFeatures", {default: [0, 1, 2]});
const opacity = defineModel<number>("opacity", {default: 1});
const wireframe = ref(false);
// Listeners for changes in the properties (or viewer reloads)
function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
@@ -67,13 +68,10 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Iterate all primitives of the mesh and set their visibility based on the enabled features
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
// of not actually removing the primitives from the scene graph
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
let childIsFace = child.type == 'Mesh' || child.type == 'SkinnedMesh'
let childIsEdge = child.type == 'Line' || child.type == 'LineSegments'
let childIsEdge = child.type == 'Line' || child.type == 'LineSegments' || child.type == 'LineSegments2'
let childIsVertex = child.type == 'Points'
if (childIsFace || childIsEdge || childIsVertex) {
let visible = newEnabledFeatures.includes(childIsFace ? 0 : childIsEdge ? 1 : childIsVertex ? 2 : -1);
@@ -87,16 +85,12 @@ function onEnabledFeaturesChange(newEnabledFeatures: Array<number>) {
scene.queueRender()
}
watch(enabledFeatures, onEnabledFeaturesChange);
watch(enabledFeatures, onEnabledFeaturesChange, {deep: true});
function onOpacityChange(newOpacity: number) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Iterate all primitives of the mesh and set their opacity based on the enabled features
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
// of not actually removing the primitives from the scene graph
// console.log('Opacity may have changed', newOpacity)
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if (child.material && child.material.opacity !== newOpacity) {
@@ -111,7 +105,22 @@ function onOpacityChange(newOpacity: number) {
watch(opacity, onOpacityChange);
let {sceneDocument} = inject<{ sceneDocument: ShallowRef<Document> }>('sceneDocument')!!;
function onWireframeChange(newWireframe: boolean) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if (child.material && child.material.wireframe !== newWireframe) {
child.material.wireframe = newWireframe;
child.material.needsUpdate = true;
}
}
});
scene.queueRender()
}
watch(wireframe, onWireframeChange);
function onClipPlanesChange() {
let scene = props.viewer?.scene;
@@ -125,21 +134,25 @@ function onClipPlanesChange() {
if (props.viewer?.renderer && (enabledX || enabledY || enabledZ)) {
// Global value for all models, once set it cannot be unset (unknown for other models...)
props.viewer.renderer.threeRenderer.localClippingEnabled = true;
// Due to model-viewer's camera manipulation, the bounding box needs to be transformed
bbox = SceneMgr.getBoundingBox(sceneDocument.value);
bbox.translate(scene.getTarget());
// Get the bounding box containing all features of this model
bbox = new Box3();
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
bbox.expandByObject(child);
}
});
}
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if (child.material) {
if (bbox) {
if (bbox?.isEmpty() == false) {
let offsetX = bbox.min.x + clipPlaneX.value * (bbox.max.x - bbox.min.x);
let offsetY = bbox.min.z + clipPlaneY.value * (bbox.max.z - bbox.min.z);
let offsetZ = bbox.min.y + clipPlaneZ.value * (bbox.max.y - bbox.min.y);
let offsetY = bbox.min.y + clipPlaneY.value * (bbox.max.y - bbox.min.y);
let offsetZ = bbox.min.z + (1 - clipPlaneZ.value) * (bbox.max.z - bbox.min.z);
let planes = [
new Plane(new Vector3(-1, 0, 0), offsetX),
new Plane(new Vector3(0, 0, 1), offsetY),
new Plane(new Vector3(0, -1, 0), offsetZ),
new Plane(new Vector3(0, -1, 0), offsetY),
new Plane(new Vector3(0, 0, 1), -offsetZ),
];
if (clipPlaneSwappedX.value) planes[0].negate();
if (clipPlaneSwappedY.value) planes[1].negate();
@@ -168,17 +181,105 @@ watch(clipPlaneSwappedZ, onClipPlanesChange);
// Clip planes are also affected by the camera position, so we need to listen to camera changes
props.viewer!!.onElemReady((elem) => elem.addEventListener('camera-change', onClipPlanesChange))
let edgeWidthChangeCleanup = [] as Array<() => void>;
function onEdgeWidthChange(newEdgeWidth: number) {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
edgeWidthChangeCleanup.forEach((f) => f());
edgeWidthChangeCleanup = [];
let linesToImprove: Array<MObject3D> = [];
sceneModel.traverse((child: MObject3D) => {
if (child.userData[extrasNameKey] === modelName) {
if (child.type == 'Line' || child.type == 'LineSegments') {
// child.material.linewidth = 3; // Not supported in WebGL2
// Swap geometry with LineGeometry to support widths
// https://threejs.org/examples/?q=line#webgl_lines_fat
if (newEdgeWidth > 0) linesToImprove.push(child);
}
if (child.type == 'Points') {
(child.material as any).size = newEdgeWidth > 0 ? newEdgeWidth * 50 : 5;
child.material.needsUpdate = true;
}
}
});
linesToImprove.forEach(async (line: MObject3D) => {
let line2 = await toLineSegments(line.geometry, newEdgeWidth);
// Update resolution on resize
let resizeListener = (elem: HTMLElement) => {
line2.material.resolution.set(elem.clientWidth, elem.clientHeight);
line2.material.needsUpdate = true;
};
props.viewer!!.onElemReady((elem) => {
elem.addEventListener('resize', () => resizeListener(elem));
resizeListener(elem);
});
// Copy the transform of the original line
line2.position.copy(line.position);
line2.computeLineDistances();
line2.userData = Object.assign({}, line.userData);
line.parent!.add(line2);
line.children.forEach((o) => line2.add(o));
line.visible = false;
line.userData.niceLine = line2;
// line.parent!.remove(line); // Keep it for better raycast and selection!
line2.userData.noHit = true;
edgeWidthChangeCleanup.push(() => {
line2.parent!.remove(line2);
line.visible = true;
props.viewer!!.onElemReady((elem) => {
elem.removeEventListener('resize', () => resizeListener(elem));
});
});
});
scene.queueRender()
}
watch(edgeWidth, onEdgeWidthChange);
function onModelLoad() {
let scene = props.viewer?.scene;
let sceneModel = (scene as any)?._model;
if (!scene || !sceneModel) return;
// Iterate all primitives of the mesh and set their visibility based on the enabled features
// Use the scene graph instead of the document to avoid reloading the same model, at the cost
// of not actually removing the primitives from the scene graph
// Count the number of faces, edges and vertices
const isFirstLoad = faceCount.value === -1;
faceCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.TRIANGLES))
.map(p => (p.getExtras()?.face_triangles_end as any)?.length ?? 1)
.reduce((a, b) => a + b, 0)
edgeCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() in [WebGL2RenderingContext.LINE_STRIP, WebGL2RenderingContext.LINES]))
.map(p => (p.getExtras()?.edge_points_end as any)?.length ?? 0)
.reduce((a, b) => a + b, 0)
vertexCount.value = props.meshes
.flatMap((m) => m.listPrimitives().filter(p => p.getMode() === WebGL2RenderingContext.POINTS))
.map(p => (p.getAttribute("POSITION")?.getCount() ?? 0))
.reduce((a, b) => a + b, 0)
// First time: set the enabled features to all provided features
if (isFirstLoad) {
if (faceCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 0)
else if (!enabledFeatures.value.includes(0)) enabledFeatures.value.push(0)
if (edgeCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 1)
else if (!enabledFeatures.value.includes(1)) enabledFeatures.value.push(1)
if (vertexCount.value === 0) enabledFeatures.value = enabledFeatures.value.filter((f) => f !== 2)
else if (!enabledFeatures.value.includes(2)) enabledFeatures.value.push(2)
}
// Add darkened back faces for all face objects to improve cutting planes
let childrenToAdd: Array<MObject3D> = [];
sceneModel.traverse((child: MObject3D) => {
child.updateMatrixWorld(); // Objects are mostly static, so ensure updated matrices
if (child.userData[extrasNameKey] === modelName) {
if (child.type == 'Mesh' || child.type == 'SkinnedMesh') {
// Compute a BVH for faster raycasting (MUCH faster selection)
// @ts-ignore
child.geometry?.computeBoundsTree({indirect: true}); // indirect to avoid changing index order
// TODO: Accelerated raycast for lines and points (https://github.com/gkjohnson/three-mesh-bvh/issues/243)
// TODO: ParallelMeshBVHWorker
// We could implement cutting planes using the stencil buffer:
// https://threejs.org/examples/?q=clipping#webgl_clipping_stencil
// But this is buggy for lots of models, so instead we just draw
@@ -192,30 +293,28 @@ function onModelLoad() {
backChild.material = child.material.clone();
backChild.material.side = BackSide;
backChild.material.color = new Color(0.25, 0.25, 0.25)
backChild.userData.noHit = true;
child.userData.backChild = backChild;
childrenToAdd.push(backChild as MObject3D);
}
}
// if (child.type == 'Line' || child.type == 'LineSegments') {
// child.material.linewidth = 3; // Not supported in WebGL2
// If wide lines are really needed, we need https://threejs.org/examples/?q=line#webgl_lines_fat
// }
if (child.type == 'Points') {
(child.material as any).size = 5;
child.material.needsUpdate = true;
}
}
});
childrenToAdd.forEach((child: MObject3D) => sceneModel.add(child));
scene.queueRender()
// Furthermore...
// Enabled features may have been reset after a reload
onEnabledFeaturesChange(enabledFeatures.value)
// Opacity may have been reset after a reload
onOpacityChange(opacity.value)
// Wireframe may have been reset after a reload
onWireframeChange(wireframe.value)
// Clip planes may have been reset after a reload
onClipPlanesChange()
// Edge width may have been reset after a reload
onEdgeWidthChange(edgeWidth.value)
scene.queueRender()
}
// props.viewer.elem may not yet be available, so we need to wait for it
@@ -252,6 +351,16 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
<v-tooltip activator="parent">Change opacity</v-tooltip>
<svg-icon type="mdi" :path="mdiCircleOpacity"></svg-icon>
</template>
<template v-slot:append>
<v-tooltip activator="parent">Wireframe</v-tooltip>
<v-checkbox-btn trueIcon="mdi-triangle-outline" falseIcon="mdi-triangle" v-model="wireframe"></v-checkbox-btn>
</template>
</v-slider>
<v-slider v-if="edgeCount > 0 || vertexCount > 0" v-model="edgeWidth" hide-details min="0" max="1">
<template v-slot:prepend>
<v-tooltip activator="parent">Edge and vertex sizes</v-tooltip>
<svg-icon type="mdi" :path="mdiVectorLine"></svg-icon>
</template>
</v-slider>
<v-divider></v-divider>
<v-slider v-model="clipPlaneX" hide-details min="0" max="1">
@@ -270,7 +379,7 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
</v-checkbox-btn>
</template>
</v-slider>
<v-slider v-model="clipPlaneY" hide-details min="0" max="1">
<v-slider v-model="clipPlaneZ" hide-details min="0" max="1">
<template v-slot:prepend>
<v-tooltip activator="parent">Clip plane Y</v-tooltip>
<svg-icon type="mdi" :path="mdiCube" :rotate="-120"></svg-icon>
@@ -279,14 +388,14 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
<template v-slot:append>
<v-tooltip activator="parent">Swap clip plane Y</v-tooltip>
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
v-model="clipPlaneSwappedY">
v-model="clipPlaneSwappedZ">
<template v-slot:label>
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
</template>
</v-checkbox-btn>
</template>
</v-slider>
<v-slider v-model="clipPlaneZ" hide-details min="0" max="1">
<v-slider v-model="clipPlaneY" hide-details min="0" max="1">
<template v-slot:prepend>
<v-tooltip activator="parent">Clip plane Z</v-tooltip>
<svg-icon type="mdi" :path="mdiCube"></svg-icon>
@@ -295,7 +404,7 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
<template v-slot:append>
<v-tooltip activator="parent">Swap clip plane Z</v-tooltip>
<v-checkbox-btn trueIcon="mdi-checkbox-marked-outline" falseIcon="mdi-checkbox-blank-outline"
v-model="clipPlaneSwappedZ">
v-model="clipPlaneSwappedY">
<template v-slot:label>
<svg-icon type="mdi" :path="mdiSwapHorizontal"></svg-icon>
</template>
@@ -358,4 +467,12 @@ props.viewer!!.onElemReady((elem) => elem.addEventListener('load', onModelLoad))
.mdi-checkbox-marked-outline { /* HACK: mdi is not fully imported, only required icons... */
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M19,19H5V5H15V3H5C3.89,3 3,3.89 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V11H19M7.91,10.08L6.5,11.5L11,16L21,6L19.59,4.58L11,13.17L7.91,10.08Z"/></svg>');
}
.mdi-triangle { /* HACK: mdi is not fully imported, only required icons... */
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M1 21h22L12 2"/></svg>');
}
.mdi-triangle-outline { /* HACK: mdi is not fully imported, only required icons... */
background-image: url('data:image/svg+xml;charset=UTF-8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="white"><path d="M12 2L1 21h22M12 6l7.53 13H4.47"/></svg>');
}
</style>

View File

@@ -36,9 +36,7 @@ function onRemove(mesh: Mesh) {
}
function findModel(name: string) {
console.log('Find model', name);
if (!expandedNames.value.includes(name)) expandedNames.value.push(name);
console.log('Expanded', expandedNames.value);
}
defineExpose({findModel})

2
frontend/shims.d.ts vendored
View File

@@ -1,3 +1,5 @@
// Avoids typescript error when importing some files
declare module '@jamescoyle/vue-icon'
declare module 'three-orientation-gizmo/src/OrientationGizmo'
declare module 'three/examples/jsm/libs/draco/draco_decoder.js'
declare module 'three/examples/jsm/libs/draco/draco_encoder.js'

View File

@@ -8,7 +8,7 @@ import type {ModelViewerElement} from '@google/model-viewer';
import {Vector3} from "three/src/math/Vector3.js";
import {Matrix4} from "three/src/math/Matrix4.js";
globalThis.THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
(globalThis as any).THREE = {Vector3, Matrix4} as any // HACK: Required for the gizmo to work
const OrientationGizmo = OrientationGizmoRaw.default;
@@ -47,7 +47,7 @@ function createGizmo(expectedParent: HTMLElement, scene: ModelScene): HTMLElemen
}
scene.queueRender();
requestIdleCallback(() => props.elem?.dispatchEvent(
new CustomEvent('camera-change', {detail: {source: 'none'}})))
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
}
return gizmo;
}
@@ -60,7 +60,7 @@ let gizmo: HTMLElement & { update: () => void }
function updateGizmo() {
if (gizmo.isConnected) {
gizmo.update();
requestIdleCallback(updateGizmo);
requestIdleCallback(updateGizmo, {timeout: 250});
}
}
@@ -69,7 +69,7 @@ let reinstall = () => {
if (gizmo) container.value.removeChild(gizmo);
gizmo = createGizmo(container.value, props.scene as ModelScene) as typeof gizmo;
container.value.appendChild(gizmo);
requestIdleCallback(updateGizmo); // Low priority updates
requestIdleCallback(updateGizmo, {timeout: 250}); // Low priority updates
}
onMounted(reinstall)
onUpdated(reinstall);

View File

@@ -6,44 +6,50 @@ import type {ModelViewerElement} from '@google/model-viewer';
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
import {mdiCubeOutline, mdiCursorDefaultClick, mdiFeatureSearch, mdiRuler} from '@mdi/js';
import type {Intersection, Material, Mesh, Object3D} from "three";
import {Box3, Matrix4, Raycaster, Vector3} from "three";
import {Box3, Color, Raycaster, Vector3} from "three";
import type ModelViewerWrapperT from "../viewer/ModelViewerWrapper.vue";
import {extrasNameKey} from "../misc/gltf";
import {SceneMgr} from "../misc/scene";
import {Document} from "@gltf-transform/core";
import {AxesColors} from "../misc/helpers";
import {distances} from "../misc/distances";
import {highlight, highlightUndo, hitToSelectionInfo, type SelectionInfo} from "./selection";
export type MObject3D = Mesh & {
userData: { noHit?: boolean },
material: Material & { color: { r: number, g: number, b: number }, __prevBaseColorFactor?: [number, number, number] }
material: Material & {
color: Color,
wireframe?: boolean
}
};
let props = defineProps<{ viewer: typeof ModelViewerWrapperT | null }>();
let emit = defineEmits<{ findModel: [string] }>();
let {setDisableTap} = inject<{ setDisableTap: (arg0: boolean) => void }>('disableTap')!!;
let selectionEnabled = ref(false);
let selected = defineModel<Array<Intersection<MObject3D>>>({default: []});
let selected = defineModel<Array<SelectionInfo>>({default: []});
let highlightNextSelection = ref([false, false]); // Second is whether selection was enabled before
let showBoundingBox = ref<Boolean>(false); // Enabled automatically on start
let showDistances = ref<Boolean>(true);
let mouseDownAt: [number, number] | null = null;
let mouseDownTime = 0;
let selectFilter = ref('Any (S)');
const raycaster = new Raycaster();
let selectionMoveListener = (event: MouseEvent) => {
let mouseDownListener = (event: MouseEvent) => {
mouseDownAt = [event.clientX, event.clientY];
mouseDownTime = performance.now();
if (!selectionEnabled.value) return;
};
let selectionListener = (event: MouseEvent) => {
let mouseUpListener = (event: MouseEvent) => {
// If the mouse moved while clicked (dragging), avoid selection logic
if (mouseDownAt) {
let [x, y] = mouseDownAt;
mouseDownAt = null;
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5) {
if (Math.abs(event.clientX - x) > 5 || Math.abs(event.clientY - y) > 5 || performance.now() - mouseDownTime > 500) {
return;
}
}
@@ -54,15 +60,22 @@ let selectionListener = (event: MouseEvent) => {
}
// Set raycaster parameters
let paramScale = 1; // Make it easier to select vertices/edges based on camera distance
if (props.viewer?.scene) {
let scene = props.viewer.scene;
let lookAtCenter = scene.getTarget().clone().add(scene.target.position);
paramScale = scene.camera.position.distanceTo(lookAtCenter) / 150;
// console.log('paramScale', paramScale)
}
if (selectFilter.value === 'Any (S)') {
raycaster.params.Line.threshold = 0.2;
raycaster.params.Points.threshold = 0.8;
raycaster.params.Line.threshold = paramScale;
raycaster.params.Points.threshold = paramScale * 2; // Make vertices easier to select than edges
} else if (selectFilter.value === '(E)dges') {
raycaster.params.Line.threshold = 0.8;
raycaster.params.Line.threshold = paramScale;
raycaster.params.Points.threshold = 0.0;
} else if (selectFilter.value === '(V)ertices') {
raycaster.params.Line.threshold = 0.0;
raycaster.params.Points.threshold = 0.8;
raycaster.params.Points.threshold = paramScale;
} else if (selectFilter.value === '(F)aces') {
raycaster.params.Line.threshold = 0.0;
raycaster.params.Points.threshold = 0.0;
@@ -74,7 +87,7 @@ let selectionListener = (event: MouseEvent) => {
const ndcCoords = scene.getNDC(event.clientX, event.clientY);
raycaster.setFromCamera(ndcCoords, scene.camera);
if (!scene.camera.isPerspectiveCamera) {
// Need to fix the ray direction for ortho camera FIXME: Still buggy...
// Need to fix the ray direction for ortho camera FIXME: Still buggy for off-center clicks
raycaster.ray.direction.copy(scene.camera.getWorldDirection(new Vector3()));
}
//console.log('Ray', raycaster.ray);
@@ -85,31 +98,69 @@ let selectionListener = (event: MouseEvent) => {
// let lineHandle = props.viewer?.addLine3D(actualFrom, actualTo, "Ray")
// setTimeout(() => props.viewer?.removeLine3D(lineHandle), 30000)
// Find all hit objects and select the wanted one based on the filter
const hits = raycaster.intersectObject(scene, true);
let hit = hits.find((hit: Intersection<Object3D>) => {
if (!hit.object || !(hit.object as any).isMesh) return false;
const kind = hit.object.type
// Find all hit objects and raycast the wanted ones based on the filter
let objects: Array<any> = [];
scene.traverse((obj) => {
const kind = obj.type
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
let isEdge = kind === 'Line' || kind === 'LineSegments';
let isVertex = kind === 'Points';
const kindOk = (selectFilter.value === 'Any (S)') ||
(isFace && selectFilter.value === '(F)aces') ||
(isEdge && selectFilter.value === '(E)dges') ||
(isVertex && selectFilter.value === '(V)ertices');
return hit.object.visible && !hit.object.userData.noHit && kindOk;
}) as Intersection<MObject3D> | undefined;
//console.log('Hit', hit)
if (obj.userData.noHit !== true &&
((selectFilter.value === 'Any (S)' && (isFace || isEdge || isVertex)) ||
(selectFilter.value === '(F)aces' && isFace) ||
(selectFilter.value === '(E)dges' && isEdge) ||
(selectFilter.value === '(V)ertices' && isVertex))) {
objects.push(obj);
}
});
//console.log("Raycasting objects", objects)
// Run the raycaster on the selected objects only searching for the first hit
// @ts-ignore
raycaster.firstHitOnly = true;
const hits = raycaster.intersectObjects(objects, false);
let hit = hits
// Check feasibility
.filter((hit: Intersection<Object3D>) => {
if (!hit.object) return false;
const kind = hit.object.type
let isFace = kind === 'Mesh' || kind === 'SkinnedMesh';
let isEdge = kind === 'Line' || kind === 'LineSegments';
let isVertex = kind === 'Points';
const kindOk = (selectFilter.value === 'Any (S)') ||
(isFace && selectFilter.value === '(F)aces') ||
(isEdge && selectFilter.value === '(E)dges') ||
(isVertex && selectFilter.value === '(V)ertices');
return (!isFace || hit.object.visible) && kindOk;
})
// Sort for highlighting partially hidden edges/vertices
.sort((a, b) => {
function lowerIsBetter(hit: Intersection<Object3D>) {
let score = hit.distance;
// Faces are easier to hit than 0-width edges/vertices, so we need to adjust scores
if (hit.object.type === 'Mesh' || hit.object.type === 'SkinnedMesh') score += paramScale;
// Edges are easier to hit than vertices, so we need to adjust scores
if (hit.object.type === 'Line' || hit.object.type === 'LineSegments') score += paramScale / 2;
return score;
}
return lowerIsBetter(a) - lowerIsBetter(b);
})
// Return the best hit
[0] as Intersection<MObject3D> | undefined;
if (!highlightNextSelection.value[0]) {
// If we are selecting, toggle the selection or deselect all if no hit
if (hit) {
let selInfo: SelectionInfo | null = null;
if (hit) selInfo = hitToSelectionInfo(hit);
//console.log('Hit', hit, 'SelInfo', selInfo);
if (hit && selInfo !== null) {
// Toggle selection
const wasSelected = selected.value.find((m) => m.object.name === hit?.object?.name) !== undefined;
const wasSelected = selected.value.find((m) => m.getKey() === selInfo.getKey()) !== undefined;
if (wasSelected) {
deselect(hit)
deselect(selInfo)
} else {
select(hit)
select(selInfo)
}
} else {
deselectAll();
@@ -125,34 +176,22 @@ let selectionListener = (event: MouseEvent) => {
scene.queueRender() // Force rerender of model-viewer
}
function select(hit: Intersection<MObject3D>) {
console.log('Selecting', hit.object.name)
if (selected.value.find((m) => m.object.name === hit.object.name) === undefined) {
selected.value.push(hit);
function select(selInfo: SelectionInfo) {
// console.log('Selecting', selInfo.object.name)
if (selected.value.find((m) => m.getKey() === selInfo.getKey()) === undefined) {
selected.value.push(selInfo);
}
hit.object.material.__prevBaseColorFactor = [
hit.object.material.color.r,
hit.object.material.color.g,
hit.object.material.color.b,
];
hit.object.material.color.r = 1;
hit.object.material.color.g = 0;
hit.object.material.color.b = 0;
highlight(selInfo);
}
function deselect(hit: Intersection<MObject3D>, alsoRemove = true) {
console.log('Deselecting', hit.object.name)
function deselect(selInfo: SelectionInfo, alsoRemove = true) {
// console.log('Deselecting', selInfo.object.name)
if (alsoRemove) {
// Remove the matching object from the selection
let toRemove = selected.value.findIndex((m) => m.object.name === hit.object.name);
let toRemove = selected.value.findIndex((m) => m.getKey() === selInfo.getKey());
selected.value.splice(toRemove, 1);
}
if (hit.object.material.__prevBaseColorFactor) {
hit.object.material.color.r = hit.object.material.__prevBaseColorFactor[0]
hit.object.material.color.g = hit.object.material.__prevBaseColorFactor[1]
hit.object.material.color.b = hit.object.material.__prevBaseColorFactor[2]
delete hit.object.material.__prevBaseColorFactor;
}
highlightUndo(selInfo);
}
function deselectAll(alsoRemove = true) {
@@ -217,14 +256,29 @@ let onViewerReady = (viewer: typeof ModelViewerWrapperT) => {
viewer.onElemReady((elem: ModelViewerElement) => {
if (hasListeners) return;
hasListeners = true;
elem.addEventListener('mouseup', selectionListener);
elem.addEventListener('mousedown', selectionMoveListener); // Avoid clicking when dragging
elem.addEventListener('mousedown', mouseDownListener); // Avoid clicking when dragging
elem.addEventListener('mouseup', mouseUpListener);
elem.addEventListener('load', () => {
// After a reload of the scene, we need to recover object references and highlight them again
for (let sel of selected.value) {
let scene = props.viewer?.scene;
if (!scene) continue;
let foundObject = null;
scene.traverse((obj: MObject3D) => {
if (sel.matches(obj)) {
foundObject = obj as MObject3D;
}
});
if (foundObject) {
sel.object = foundObject;
highlight(sel);
} else {
selected.value = selected.value.filter((m) => m.getKey() !== sel.getKey());
}
}
if (firstLoad) {
toggleShowBoundingBox();
firstLoad = false;
} else {
updateBoundingBox();
}
});
elem.addEventListener('camera-change', onCameraChange);
@@ -249,11 +303,12 @@ function updateBoundingBox() {
if (selected.value.length > 0) {
bb = new Box3();
for (let hit of selected.value) {
bb.expandByObject(hit.object);
bb.union(hit.getBox())
}
bb.applyMatrix4(new Matrix4().makeTranslation(props.viewer?.scene.getTarget()));
} else {
bb = SceneMgr.getBoundingBox(sceneDocument.value);
let boundingBox = SceneMgr.getBoundingBox(sceneDocument.value);
if (!boundingBox) return; // No models. Should not happen.
bb = boundingBox
}
// Define each edge of the bounding box, to draw a line for each axis
let corners = [
@@ -293,6 +348,8 @@ function updateBoundingBox() {
}
let from = new Vector3(...corners[edge[0]]);
let to = new Vector3(...corners[edge[1]]);
let length = to.clone().sub(from).length();
if (length < 0.05) continue; // Skip very small edges (e.g. a single point)
let color = [AxesColors.x, AxesColors.y, AxesColors.z][edgeI][1]; // Secondary colors
let lineCacheKey = JSON.stringify([from, to]);
let matchingLine = boundingBoxLines[lineCacheKey];
@@ -300,7 +357,7 @@ function updateBoundingBox() {
boundingBoxLinesToRemove = boundingBoxLinesToRemove.filter((l) => l !== lineCacheKey);
} else {
let newLineId = props.viewer?.addLine3D(from, to,
to.clone().sub(from).length().toFixed(1) + "mm", {
length.toFixed(1) + "mm", {
"stroke": "rgb(" + color.join(',') + ")",
"stroke-width": "2"
});
@@ -337,7 +394,7 @@ function updateDistances() {
let distanceLinesToRemove = Object.keys(distanceLines);
function ensureLine(from: Vector3, to: Vector3, text: string, color: string) {
console.log('ensureLine', from, to, text, color)
// console.log('ensureLine', from, to, text, color)
let lineCacheKey = JSON.stringify([from, to]);
let matchingLine = distanceLines[lineCacheKey];
if (matchingLine) {
@@ -352,9 +409,7 @@ function updateDistances() {
}
// Add lines (if not already added)
let objA = selected.value[0].object;
let objB = selected.value[1].object;
let {min, center, max} = distances(objA, objB, props.viewer?.scene);
let {min, center, max} = distances(selected.value[0], selected.value[1], props.viewer?.scene);
ensureLine(max[0], max[1], max[1].distanceTo(max[0]).toFixed(1) + "mm", "orange");
ensureLine(center[0], center[1], center[1].distanceTo(center[0]).toFixed(1) + "mm", "green");
ensureLine(min[0], min[1], min[1].distanceTo(min[0]).toFixed(1) + "mm", "cyan");
@@ -368,6 +423,8 @@ function updateDistances() {
return;
}
defineExpose({deselect, updateBoundingBox, updateDistances});
// Add keyboard shortcuts
window.addEventListener('keydown', (event) => {
if (event.key === 's') {
@@ -444,7 +501,7 @@ window.addEventListener('keydown', (event) => {
.select-parent .v-btn {
position: relative;
top: -42px;
top: -20px;
}
.select-only {

View File

@@ -16,11 +16,11 @@ import {OrthographicCamera} from "three/src/cameras/OrthographicCamera.js";
import {mdiClose, mdiCrosshairsGps, mdiDownload, mdiGithub, mdiLicense, mdiProjector} from '@mdi/js'
import SvgIcon from '@jamescoyle/vue-icon';
import type {ModelViewerElement} from '@google/model-viewer';
import type {Intersection} from "three";
import type {MObject3D} from "./Selection.vue";
import Loading from "../misc/Loading.vue";
import type ModelViewerWrapper from "../viewer/ModelViewerWrapper.vue";
import {defineAsyncComponent, type Ref, ref} from "vue";
import {defineAsyncComponent, ref, type Ref} from "vue";
import type {SelectionInfo} from "./selection";
const SelectionComponent = defineAsyncComponent({
loader: () => import("./Selection.vue"),
@@ -39,10 +39,10 @@ const LicensesDialogContent = defineAsyncComponent({
let props = defineProps<{ viewer: InstanceType<typeof ModelViewerWrapper> | null }>();
const emit = defineEmits<{ findModel: [string] }>()
let selection: Ref<Array<Intersection<MObject3D>>> = ref([]);
let selectionFaceCount = () => selection.value.filter((s) => s.object.type == "Mesh" || s.object.type == "SkinnedMesh").length
let selectionEdgeCount = () => selection.value.filter((s) => s.object.type == "Line").length
let selectionVertexCount = () => selection.value.filter((s) => s.object.type == "Points").length
let selection: Ref<Array<SelectionInfo>> = ref([]);
let selectionFaceCount = () => selection.value.filter((s) => s.kind == 'face').length
let selectionEdgeCount = () => selection.value.filter((s) => s.kind == 'edge').length
let selectionVertexCount = () => selection.value.filter((s) => s.kind == "vertex").length
function syncOrthoCamera(force: boolean) {
let scene = props.viewer?.scene;
@@ -50,12 +50,14 @@ function syncOrthoCamera(force: boolean) {
let perspectiveCam: PerspectiveCamera = (scene as any).__perspectiveCamera;
if (force || perspectiveCam && scene.camera != perspectiveCam) {
// Get zoom level from perspective camera
let dist = scene.getTarget().distanceToSquared(perspectiveCam.position);
let w = scene.aspect * dist ** 1.1 / 4000;
let h = dist ** 1.1 / 4000;
let lookAtCenter = scene.getTarget().clone().add(scene.target.position);
let perspectiveWidthAtCenter =
2 * Math.tan(perspectiveCam.fov * Math.PI / 180 / 2) * perspectiveCam.position.distanceTo(lookAtCenter);
let w = perspectiveWidthAtCenter;
let h = perspectiveWidthAtCenter / scene.aspect;
(scene as any).camera = new OrthographicCamera(-w, w, h, -h, perspectiveCam.near, perspectiveCam.far);
scene.camera.position.copy(perspectiveCam.position);
scene.camera.lookAt(scene.getTarget().clone().add(scene.target.position));
scene.camera.lookAt(lookAtCenter);
if (force) scene.queueRender() // Force rerender of model-viewer
requestAnimationFrame(() => syncOrthoCamera(false));
}
@@ -79,7 +81,7 @@ function toggleProjection() {
toggleProjectionText.value = wasPerspectiveCamera ? 'ORTHO' : 'PERSP';
// The camera change may take a few frames to take effect, dispatch the event after a delay
requestIdleCallback(() => props.viewer?.elem?.dispatchEvent(
new CustomEvent('camera-change', {detail: {source: 'none'}})))
new CustomEvent('camera-change', {detail: {source: 'none'}})), {timeout: 100})
}
async function centerCamera() {
@@ -105,6 +107,15 @@ async function openGithub() {
window.open('https://github.com/yeicor-3d/yet-another-cad-viewer', '_blank')
}
function removeObjectSelections(objName: string) {
for (let selInfo of selection.value.filter((s) => s.getObjectName() === objName)) {
selectionComp.value?.deselect(selInfo);
}
selectionComp.value?.updateBoundingBox();
selectionComp.value?.updateDistances();
}
defineExpose({removeObjectSelections});
// Add keyboard shortcuts
window.addEventListener('keydown', (event) => {
@@ -131,7 +142,7 @@ window.addEventListener('keydown', (event) => {
</v-btn>
<v-divider/>
<h5>Selection ({{ selectionFaceCount() }}F {{ selectionEdgeCount() }}E {{ selectionVertexCount() }}V)</h5>
<selection-component :ref="selectionComp as any" :viewer="props.viewer as any" v-model="selection"
<selection-component ref="selectionComp" :viewer="props.viewer as any" v-model="selection"
@findModel="(name) => emit('findModel', name)"/>
<v-divider/>
<v-spacer></v-spacer>
@@ -185,4 +196,8 @@ window.addEventListener('keydown', (event) => {
position: relative;
top: 5px;
}
h5 {
font-size: 14px;
}
</style>

163
frontend/tools/selection.ts Normal file
View File

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

View File

@@ -1,19 +1,23 @@
<script lang="ts">
</script>
<script setup lang="ts">
import {settings} from "../misc/settings";
import {onMounted, inject, type Ref} from "vue";
import {$scene, $renderer} from "@google/model-viewer/lib/model-viewer-base";
import Loading from "../misc/Loading.vue";
import {ref, watch} from "vue";
import {inject, onMounted, type Ref, ref, watch} from "vue";
import {VList, VListItem} from "vuetify/lib/components/index.mjs";
import {$renderer, $scene} from "@google/model-viewer/lib/model-viewer-base";
import {ModelViewerElement} from '@google/model-viewer';
import type {ModelScene} from "@google/model-viewer/lib/three-components/ModelScene";
import {Hotspot} from "@google/model-viewer/lib/three-components/Hotspot";
import type {Renderer} from "@google/model-viewer/lib/three-components/Renderer";
import type {Vector3} from "three";
import {BufferGeometry, Mesh} from "three";
import {acceleratedRaycast, computeBoundsTree, disposeBoundsTree} from 'three-mesh-bvh';
ModelViewerElement.modelCacheSize = 0; // Also needed to avoid tree shaking
//@ts-ignore
BufferGeometry.prototype.computeBoundsTree = computeBoundsTree;
//@ts-ignore
BufferGeometry.prototype.disposeBoundsTree = disposeBoundsTree;
//@ts-ignore
Mesh.prototype.raycast = acceleratedRaycast;
const emit = defineEmits<{ load: [] }>()
@@ -37,8 +41,31 @@ onMounted(() => {
emit('load')
});
elem.value.addEventListener('camera-change', onCameraChange);
elem.value.addEventListener('progress', (ev) => onProgress((ev as any).detail.totalProgress));
});
// Handles loading the events for <model-viewer>'s slotted progress bar
const progressBar = ref<HTMLElement | null>(null);
const updateBar = ref<HTMLElement | null>(null);
let onProgressHideTimeout: number | null = null;
const onProgress = (totalProgress: number) => {
if (!progressBar.value || !updateBar.value) return;
// Update the progress bar and ensure it's visible
progressBar.value.style.display = 'block';
progressBar.value.style.opacity = '1'; // Fade in
updateBar.value.style.width = `${totalProgress * 100}%`;
// Auto-hide smoothly when no progress is made for a while
if (onProgressHideTimeout) clearTimeout(onProgressHideTimeout);
onProgressHideTimeout = setTimeout(() => {
if (!progressBar.value) return;
progressBar.value.style.opacity = '0'; // Fade out
setTimeout(() => {
if (!progressBar.value) return;
progressBar.value.style.display = 'none'; // Actually hide
}, 300); // 0.3s fade out
}, 1000);
};
class Line3DData {
startHotspot: HTMLElement = document.body
endHotspot: HTMLElement = document.body
@@ -76,7 +103,7 @@ function addLine3D(p1: Vector3, p2: Vector3, centerText?: string, lineAttrs: { [
lineAttrs: lineAttrs
};
scene.value.queueRender() // Needed to update the hotspots
requestIdleCallback(() => onCameraChangeLine(id));
requestIdleCallback(() => onCameraChangeLine(id), {timeout: 100});
return id;
}
@@ -133,7 +160,7 @@ function entries(lines: { [id: number]: Line3DData }): [string, Line3DData][] {
return Object.entries(lines);
}
defineExpose({elem, onElemReady, scene, renderer, addLine3D, removeLine3D});
defineExpose({elem, onElemReady, scene, renderer, addLine3D, removeLine3D, onProgress});
let {disableTap} = inject<{ disableTap: Ref<boolean> }>('disableTap')!!;
watch(disableTap, (value) => {
@@ -151,8 +178,20 @@ watch(disableTap, (value) => {
:shadow-intensity="settings.shadowIntensity" interaction-prompt="none" :autoplay="settings.autoplay"
:ar="settings.arModes.length > 0" :ar-modes="settings.arModes" :skybox-image="settings.background"
:environment-image="settings.background">
<slot></slot> <!-- Controls, annotations, etc. -->
<loading class="annotation initial-load-banner"></loading>
<slot></slot>
<!-- Display some information during initial load -->
<div class="annotation initial-load-banner">
Trying to load models from...
<v-list v-for="src in settings.preload" :key="src">
<v-list-item>{{ src }}</v-list-item>
</v-list>
<!-- Too much idle CPU usage: <loading></loading> -->
</div>
<!-- Customize the progress bar -->
<div class="progress-bar" slot="progress-bar" ref="progressBar">
<div class="update-bar" ref="updateBar"/>
</div>
</model-viewer>
<!-- The SVG overlay for fake 3D lines attached to the model -->
@@ -202,4 +241,41 @@ watch(disableTap, (value) => {
height: 100dvh;
pointer-events: none;
}
.initial-load-banner {
width: 300px;
margin: auto;
margin-top: 3em;
overflow: hidden;
}
.initial-load-banner .v-list-item {
overflow: hidden;
}
.progress-bar {
display: block;
pointer-events: none;
width: 100%;
height: 10%;
max-height: 2%;
position: absolute;
left: 50%;
top: 0;
transform: translate3d(-50%, 0%, 0);
border-radius: 25px;
box-shadow: 0 3px 10px 3px rgba(0, 0, 0, 0.5), 0 0 5px 1px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.9);
background-color: rgba(0, 0, 0, 0.5);
transition: opacity 0.3s;
}
.update-bar {
background-color: rgba(255, 255, 255, 0.9);
width: 0;
height: 100%;
border-radius: 25px;
float: left;
transition: width 0.3s;
}
</style>

View File

@@ -1,8 +1,9 @@
{
"name": "yet-another-cad-viewer",
"version": "0.1.0",
"version": "0.8.6",
"description": "",
"license": "MIT",
"private": true,
"author": "Yeicor <4929005+Yeicor@users.noreply.github.com>",
"type": "module",
"scripts": {
@@ -14,20 +15,22 @@
"update-licenses": "generate-license-file --input package.json --output assets/licenses.txt --overwrite"
},
"dependencies": {
"@gltf-transform/core": "^3.10.0",
"@gltf-transform/functions": "^3.10.0",
"@gltf-transform/core": "^3.10.1",
"@gltf-transform/extensions": "^3.10.1",
"@gltf-transform/functions": "^3.10.1",
"@google/model-viewer": "^3.4.0",
"@jamescoyle/vue-icon": "^0.1.2",
"@mdi/js": "^7.4.47",
"@mdi/svg": "^7.4.47",
"three": "^0.160.1",
"three-mesh-bvh": "^0.7.3",
"three-orientation-gizmo": "https://github.com/jrj2211/three-orientation-gizmo",
"vue": "^3.4.21",
"vuetify": "^3.5.7"
"vuetify": "^3.5.13"
},
"devDependencies": {
"@tsconfig/node20": "^20.1.2",
"@types/node": "^20.11.24",
"@tsconfig/node20": "^20.1.4",
"@types/node": "^20.12.2",
"@types/three": "^0.160.0",
"@vitejs/plugin-vue": "^5.0.3",
"@vitejs/plugin-vue-jsx": "^3.1.0",
@@ -36,9 +39,9 @@
"commander": "^12.0.0",
"generate-license-file": "^3.0.1",
"npm-run-all2": "^6.1.1",
"terser": "^5.28.1",
"typescript": "~5.3.0",
"vite": "^5.0.11",
"vue-tsc": "^2.0.3"
"terser": "^5.30.0",
"typescript": "~5.4.3",
"vite": "^5.2.7",
"vue-tsc": "^2.0.7"
}
}

860
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "yacv-server"
version = "0.1.0" # TODO: Update automatically by CI on release (also for package.json!)
version = "0.8.6"
description = "Yet Another CAD Viewer (server)"
authors = ["Yeicor <4929005+Yeicor@users.noreply.github.com>"]
license = "MIT"
@@ -14,13 +14,7 @@ include = [
python = "^3.9"
# CAD
build123d = "^0.4.0"
ocp-tessellate = "^2.0.6"
# Web
aiohttp = "^3.9.3"
aiohttp-cors = "^0.7.0"
aiohttp-devtools = "^1.1.2"
build123d = "^0.5.0"
# Misc
pygltflib = "^1.16.2"

View File

@@ -3,9 +3,12 @@ import {fileURLToPath, URL} from 'node:url'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import {name, version} from './package.json'
import {execSync} from 'child_process'
// https://vitejs.dev/config/
export default defineConfig({
base: "",
plugins: [
vue({
template: {
@@ -23,7 +26,15 @@ export default defineConfig({
}
},
build: {
assetsDir: '.',
assetsDir: '.', // Support deploying to a subdirectory using relative URLs
cssCodeSplit: false, // Small enough to inline
chunkSizeWarningLimit: 550, // Three.js is big. Draco is even bigger but not likely to be used.
sourcemap: true, // For debugging production
},
define: {
__APP_NAME__: JSON.stringify(name),
__APP_VERSION__: JSON.stringify(version),
__APP_GIT_SHA__: JSON.stringify(execSync('git rev-parse HEAD').toString().trim()),
__APP_GIT_DIRTY__: JSON.stringify(execSync('git diff --quiet || echo dirty').toString().trim()),
}
})

View File

@@ -1,42 +1,21 @@
import logging
import os
import time
from aiohttp import web
from build123d import Vector
from yacv_server.cad import image_to_gltf
from yacv_server.yacv import YACV
from server import Server
server = Server()
yacv = YACV()
"""The server instance. This is the main entry point to serve CAD objects and other data to the frontend."""
if 'YACV_DISABLE_SERVER' not in os.environ:
# Start a new server ASAP to let the polling client connect while still building CAD objects
# This is a bit of a hack, but it is seamless to the user. This behavior can be disabled by setting
# the environment variable YACV_DISABLE_SERVER to a non-empty value
server.start()
yacv.start()
# Expose some nice aliases using the default server instance
show = server.show
show_object = show
show_image = server.show_image
show_all = server.show_cad_all
def _get_app() -> web.Application:
"""Required by aiohttp-devtools"""
logging.basicConfig(level=logging.DEBUG)
from logo import build_logo, ASSETS_DIR
logo, img_location, img_path = build_logo()
server.show_cad(logo, 'logo')
server.show_cad(img_location, 'location')
server.show_image(img_path, img_location, 20)
server.show_gltf(open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read(), 'fox')
return server.app
if __name__ == '__main__':
# Publish the logo to the server (reusing code from the aiohttp-devtools)
_get_app()
# Keep the server running for testing
time.sleep(60)
show = yacv.show
show_all = yacv.show_cad_all
image_to_gltf = image_to_gltf
export_all = yacv.export_all
remove = yacv.remove
clear = yacv.clear

View File

@@ -2,17 +2,23 @@
Utilities to work with CAD objects
"""
import hashlib
from typing import Optional, Union, List, Tuple
import io
import re
from typing import Optional, Union, Tuple
from OCP.TopExp import TopExp
from OCP.TopLoc import TopLoc_Location
from OCP.TopTools import TopTools_IndexedMapOfShape
from OCP.TopoDS import TopoDS_Shape
from build123d import Compound, Shape
from gltf import GLTFMgr
from yacv_server.gltf import GLTFMgr
CADLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now
CADCoreLike = Union[TopoDS_Shape, TopLoc_Location] # Faces, Edges, Vertices and Locations for now
CADLike = Union[CADCoreLike, any] # build123d and cadquery types
def get_shape(obj: any, error: bool = True) -> Optional[CADLike]:
def get_shape(obj: CADLike, error: bool = True) -> Optional[CADCoreLike]:
""" Get the shape of a CAD-like object """
# Try to grab a shape if a different type of object was passed
@@ -39,28 +45,49 @@ def get_shape(obj: any, error: bool = True) -> Optional[CADLike]:
if isinstance(obj, TopoDS_Shape):
return obj
# Handle iterables like Build123d ShapeList by extracting all sub-shapes and making a compound
if isinstance(obj, list) or isinstance(obj, tuple) or isinstance(obj, set) or isinstance(obj, dict):
try:
if isinstance(obj, dict):
obj_iter = iter(obj.values())
else:
obj_iter = iter(obj)
# print(obj, ' -> ', obj_iter)
shapes_raw = [get_shape(sub_obj, error=False) for sub_obj in obj_iter]
# Silently drop non-shapes
shapes_raw_filtered = [shape for shape in shapes_raw if shape is not None]
if len(shapes_raw_filtered) > 0: # Continue if we found at least one shape
# Sorting is required to improve hashcode consistency
shapes_raw_filtered_sorted = sorted(shapes_raw_filtered, key=lambda x: _hashcode(x))
# Build a single compound shape
shapes_bd = [Shape(shape) for shape in shapes_raw_filtered_sorted if shape is not None]
return get_shape(Compound(shapes_bd), error)
except TypeError:
pass
if error:
raise ValueError(f'Cannot show object of type {type(obj)} (submit issue?)')
else:
return None
def grab_all_cad() -> List[Tuple[str, CADLike]]:
def grab_all_cad() -> set[Tuple[str, CADCoreLike]]:
""" Grab all shapes by inspecting the stack """
import inspect
stack = inspect.stack()
shapes = []
shapes = set()
for frame in stack:
for key, value in frame.frame.f_locals.items():
shape = get_shape(value, error=False)
if shape:
shapes.append((key, shape))
if shape and shape not in shapes:
shapes.add((key, shape))
return shapes
def image_to_gltf(source: str | bytes, center: any, ppmm: int, name: Optional[str] = None,
save_mime: str = 'image/jpeg') -> Tuple[bytes, str]:
"""Convert an image to a GLTF CAD object, indicating the center location and pixels per millimeter."""
def image_to_gltf(source: str | bytes, center: any, width: Optional[float] = None, height: Optional[float] = None,
name: Optional[str] = None, save_mime: str = 'image/jpeg', power_of_two: bool = True) \
-> Tuple[bytes, str]:
"""Convert an image to a GLTF CAD object."""
from PIL import Image
import io
import os
@@ -89,27 +116,40 @@ def image_to_gltf(source: str | bytes, center: any, ppmm: int, name: Optional[st
if not isinstance(center_loc, TopLoc_Location):
raise ValueError('Center location not valid')
plane = Plane(Location(center_loc))
# Convert coordinates system
plane.origin = Vector(plane.origin.X, plane.origin.Z, -plane.origin.Y)
plane.z_dir = -plane.y_dir
plane.y_dir = plane.z_dir
def vert(v: Vector) -> Tuple[float, float, float]:
return v.X, v.Y, v.Z
# Load the image to a byte buffer
img = Image.open(source)
img_buf = io.BytesIO()
# Use the original dimensions for scaling the model
if width is None and height is None:
raise ValueError('At least one of width or height must be specified') # Fallback to pixels == mm?
elif width is None:
width = img.width / img.height * height
elif height is None:
height = height or img.height / img.width * width # Apply default aspect ratio if unspecified
# Resize the image to a power of two if requested (recommended for GLTF)
if power_of_two:
new_width = 2 ** (img.width - 1).bit_length()
new_height = 2 ** (img.height - 1).bit_length()
img = img.resize((new_width, new_height))
# Save the image to a buffer
img.save(img_buf, format=format)
img_buf = img_buf.getvalue()
# Convert coordinates system as a last step (gltf is Y-up instead of Z-up)
def vert(v: Vector) -> Vector:
return Vector(v.X, v.Z, -v.Y)
# Build the gltf
mgr = GLTFMgr(image=(img_buf, save_mime))
mgr.add_face([
vert(plane.origin - plane.x_dir * img.width / (2 * ppmm) - plane.y_dir * img.height / (2 * ppmm)),
vert(plane.origin + plane.x_dir * img.width / (2 * ppmm) - plane.y_dir * img.height / (2 * ppmm)),
vert(plane.origin + plane.x_dir * img.width / (2 * ppmm) + plane.y_dir * img.height / (2 * ppmm)),
vert(plane.origin - plane.x_dir * img.width / (2 * ppmm) + plane.y_dir * img.height / (2 * ppmm)),
vert(plane.origin - plane.x_dir * width / 2 + plane.y_dir * height / 2),
vert(plane.origin + plane.x_dir * width / 2 + plane.y_dir * height / 2),
vert(plane.origin + plane.x_dir * width / 2 - plane.y_dir * height / 2),
vert(plane.origin - plane.x_dir * width / 2 - plane.y_dir * height / 2),
], [
(0, 2, 1),
(0, 3, 2),
@@ -118,7 +158,36 @@ def image_to_gltf(source: str | bytes, center: any, ppmm: int, name: Optional[st
(1, 0),
(1, 1),
(0, 1),
])
], (1, 1, 1, 1))
# Return the GLTF binary blob and the suggested name of the image
return b''.join(mgr.gltf.save_to_bytes()), name
return b''.join(mgr.build().save_to_bytes()), name
def _hashcode(obj: Union[bytes, CADCoreLike], **extras) -> str:
"""Utility to compute the STABLE hash code of a shape"""
# NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program
# This is best-effort and not guaranteed to be unique
hasher = hashlib.md5(usedforsecurity=False)
for k, v in extras.items():
hasher.update(str(k).encode())
hasher.update(str(v).encode())
if isinstance(obj, bytes):
hasher.update(obj)
elif isinstance(obj, TopLoc_Location):
sub_data = io.BytesIO()
obj.DumpJson(sub_data)
hasher.update(sub_data.getvalue())
elif isinstance(obj, TopoDS_Shape):
map_of_shapes = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(obj, map_of_shapes)
for i in range(1, map_of_shapes.Extent() + 1):
sub_shape = map_of_shapes.FindKey(i)
sub_data = io.BytesIO()
TopoDS_Shape.DumpJson(sub_shape, sub_data)
val = sub_data.getvalue()
val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address
hasher.update(val)
else:
raise ValueError(f'Cannot hash object of type {type(obj)}')
return hasher.hexdigest()

View File

@@ -12,43 +12,95 @@ _checkerboard_image_bytes = base64.decodebytes(
class GLTFMgr:
"""A utility class to build our GLTF2 objects easily and incrementally"""
def __init__(self, image: Tuple[bytes, str] = (_checkerboard_image_bytes, 'image/png')):
gltf: GLTF2
# Intermediate data to be filled by the add_* methods and merged into the GLTF object
# - Face data
face_indices: List[int] # 3 indices per triangle
face_positions: List[float] # x, y, z
face_tex_coords: List[float] # u, v
face_colors: List[float] # r, g, b, a
image: Optional[Tuple[bytes, str]] # image/png
# - Edge data
edge_indices: List[int] # 2 indices per edge
edge_positions: List[float] # x, y, z
edge_colors: List[float] # r, g, b, a
# - Vertex data
vertex_indices: List[int] # 1 index per vertex
vertex_positions: List[float] # x, y, z
vertex_colors: List[float] # r, g, b, a
def __init__(self, image: Optional[Tuple[bytes, str]] = (_checkerboard_image_bytes, 'image/png')):
self.gltf = GLTF2(
asset=Asset(generator=f"yacv_server@{importlib.metadata.version('yacv_server')}"),
scene=0,
scenes=[Scene(nodes=[0])],
nodes=[Node(mesh=0)],
meshes=[Mesh(primitives=[])],
accessors=[],
bufferViews=[BufferView(buffer=0, byteLength=len(image[0]), byteOffset=0)],
buffers=[Buffer(byteLength=len(image[0]))],
samplers=[Sampler(magFilter=NEAREST)],
textures=[Texture(source=0, sampler=0)],
images=[Image(bufferView=0, mimeType=image[1])],
nodes=[Node(mesh=0)], # TODO: Server-side detection of shallow copies --> nodes
meshes=[Mesh(primitives=[
Primitive(indices=-1, attributes=Attributes(), mode=TRIANGLES, material=0,
extras={"face_triangles_end": []}),
Primitive(indices=-1, attributes=Attributes(), mode=LINES, material=0,
extras={"edge_points_end": []}),
Primitive(indices=-1, attributes=Attributes(), mode=POINTS, material=0),
])],
materials=[Material(pbrMetallicRoughness=PbrMetallicRoughness(metallicFactor=0.1, roughnessFactor=1.0),
alphaCutoff=None)],
)
self.gltf.set_binary_blob(image[0])
self.face_indices = []
self.face_positions = []
self.face_tex_coords = []
self.face_colors = []
self.image = image
self.edge_indices = []
self.edge_positions = []
self.edge_colors = []
self.vertex_indices = []
self.vertex_positions = []
self.vertex_colors = []
def add_face(self, vertices_raw: List[Tuple[float, float, float]], indices_raw: List[Tuple[int, int, int]],
tex_coord_raw: List[Tuple[float, float]]):
"""Add a face to the GLTF as a new primitive of the unique mesh"""
vertices = np.array([[v[0], v[1], v[2]] for v in vertices_raw], dtype=np.float32)
indices = np.array([[i[0], i[1], i[2]] for i in indices_raw], dtype=np.uint32)
tex_coord = np.array([[t[0], t[1]] for t in tex_coord_raw], dtype=np.float32)
self._add_any(vertices, indices, tex_coord, mode=TRIANGLES, material="face")
@property
def _faces_primitive(self) -> Primitive:
return [p for p in self.gltf.meshes[0].primitives if p.mode == TRIANGLES][0]
def add_edge(self, vertices_raw: List[Tuple[float, float, float]], mat: str = None):
"""Add an edge to the GLTF as a new primitive of the unique mesh"""
vertices = np.array([[v[0], v[1], v[2]] for v in vertices_raw], dtype=np.float32)
indices = np.array(list(map(lambda i: [i, i + 1], range(len(vertices) - 1))), dtype=np.uint32)
tex_coord = np.array([])
self._add_any(vertices, indices, tex_coord, mode=LINE_STRIP, material=mat or "edge")
@property
def _edges_primitive(self) -> Primitive:
return [p for p in self.gltf.meshes[0].primitives if p.mode == LINES][0]
def add_vertex(self, vertex: Tuple[float, float, float]):
"""Add a vertex to the GLTF as a new primitive of the unique mesh"""
vertices = np.array([[vertex[0], vertex[1], vertex[2]]])
indices = np.array([[0]], dtype=np.uint32)
tex_coord = np.array([], dtype=np.float32)
self._add_any(vertices, indices, tex_coord, mode=POINTS, material="vertex")
@property
def _vertices_primitive(self) -> Primitive:
return [p for p in self.gltf.meshes[0].primitives if p.mode == POINTS][0]
def add_face(self, vertices_raw: List[Vector], indices_raw: List[Tuple[int, int, int]],
tex_coord_raw: List[Tuple[float, float]],
color: Tuple[float, float, float, float] = (1.0, 0.75, 0.0, 1.0)):
"""Add a face to the GLTF mesh"""
# assert len(vertices_raw) == len(tex_coord_raw), f"Vertices and texture coordinates have different lengths"
# assert min([i for t in indices_raw for i in t]) == 0, f"Face indices start at {min(indices_raw)}"
# assert max([e for t in indices_raw for e in t]) < len(vertices_raw), f"Indices have non-existing vertices"
base_index = len(self.face_positions) // 3 # All the new indices reference the new vertices
self.face_indices.extend([base_index + i for t in indices_raw for i in t])
self.face_positions.extend([v for t in vertices_raw for v in t])
self.face_tex_coords.extend([c for t in tex_coord_raw for c in t])
self.face_colors.extend([col for _ in range(len(vertices_raw)) for col in color])
self._faces_primitive.extras["face_triangles_end"].append(len(self.face_indices))
def add_edge(self, vertices_raw: List[Tuple[Tuple[float, float, float], Tuple[float, float, float]]],
color: Tuple[float, float, float, float] = (0.1, 0.1, 1.0, 1.0)):
"""Add an edge to the GLTF mesh"""
vertices_flat = [v for t in vertices_raw for v in t] # Line from 0 to 1, 2 to 3, 4 to 5, etc.
base_index = len(self.edge_positions) // 3
self.edge_indices.extend([base_index + i for i in range(len(vertices_flat))])
self.edge_positions.extend([v for t in vertices_flat for v in t])
self.edge_colors.extend([col for _ in range(len(vertices_flat)) for col in color])
self._edges_primitive.extras["edge_points_end"].append(len(self.edge_indices))
def add_vertex(self, vertex: Tuple[float, float, float],
color: Tuple[float, float, float, float] = (0.1, 0.1, 0.1, 1.0)):
"""Add a vertex to the GLTF mesh"""
base_index = len(self.vertex_positions) // 3
self.vertex_indices.append(base_index)
self.vertex_positions.extend(vertex)
self.vertex_colors.extend(color)
def add_location(self, loc: Location):
"""Add a location to the GLTF as a new primitive of the unique mesh"""
@@ -59,119 +111,91 @@ class GLTFMgr:
# Add 1 origin vertex and 3 edges with custom colors to identify the X, Y and Z axis
self.add_vertex(vert(pl.origin))
self.add_edge([vert(pl.origin), vert(pl.origin + pl.x_dir)], mat="locX")
self.add_edge([vert(pl.origin), vert(pl.origin + pl.y_dir)], mat="locY")
self.add_edge([vert(pl.origin), vert(pl.origin + pl.z_dir)], mat="locZ")
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.x_dir))], color=(0.97, 0.24, 0.24, 1))
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.y_dir))], color=(0.42, 0.8, 0.15, 1))
self.add_edge([(vert(pl.origin), vert(pl.origin + pl.z_dir))], color=(0.09, 0.55, 0.94, 1))
def add_material(self, kind: str) -> int:
"""It is important to use a different material for each primitive to be able to change them at runtime"""
new_material: Material
if kind == "face":
new_material = Material(name="face", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
baseColorTexture=TextureInfo(index=0), baseColorFactor=[1, 1, 0.5, 1]), doubleSided=True)
elif kind == "edge":
new_material = Material(name="edge", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
baseColorFactor=[0, 0, 0.5, 1]))
elif kind == "vertex":
new_material = Material(name="vertex", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
baseColorFactor=[0, 0.3, 0.3, 1]))
elif kind == "locX":
new_material = Material(name="locX", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
baseColorFactor=[0.97, 0.24, 0.24, 1]))
elif kind == "locY":
new_material = Material(name="locY", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
baseColorFactor=[0.42, 0.8, 0.15, 1]))
elif kind == "locZ":
new_material = Material(name="locZ", alphaCutoff=None, pbrMetallicRoughness=PbrMetallicRoughness(
baseColorFactor=[0.09, 0.55, 0.94, 1]))
def build(self) -> GLTF2:
"""Merge the intermediate data into the GLTF object and return it"""
buffers_list: List[Tuple[Accessor, BufferView, bytes]] = []
if len(self.face_indices) > 0:
self._faces_primitive.indices = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(self.face_indices, 1))
self._faces_primitive.attributes.POSITION = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(self.face_positions, 3))
self._faces_primitive.attributes.TEXCOORD_0 = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(self.face_tex_coords, 2))
self._faces_primitive.attributes.COLOR_0 = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(self.face_colors, 4))
else:
raise ValueError(f"Unknown material kind {kind}")
self.gltf.materials.append(new_material)
return len(self.gltf.materials) - 1
self.image = None # Unused image
self.gltf.meshes[0].primitives = list( # Remove unused faces primitive
filter(lambda p: p.mode != TRIANGLES, self.gltf.meshes[0].primitives))
def _add_any(self, vertices: np.ndarray, indices: np.ndarray, tex_coord: np.ndarray, mode: int = TRIANGLES,
material: str = "face"):
assert vertices.ndim == 2
assert vertices.shape[1] == 3
vertices = vertices.astype(np.float32)
vertices_blob = vertices.tobytes()
edges_and_vertices_mat = 0
if self.image is not None and (len(self.edge_indices) > 0 or len(self.vertex_indices) > 0):
# Create a material without texture for edges and vertices
edges_and_vertices_mat = len(self.gltf.materials)
new_mat = copy.deepcopy(self.gltf.materials[0])
new_mat.pbrMetallicRoughness.baseColorTexture = None
self.gltf.materials.append(new_mat)
assert indices.ndim == 2
assert indices.shape[1] == 3 and mode == TRIANGLES or indices.shape[1] == 2 and mode == LINE_STRIP or \
indices.shape[1] == 1 and mode == POINTS
indices = indices.astype(np.uint32)
indices_blob = indices.flatten().tobytes()
# Treat edges and vertices the same way
for (indices, positions, colors, primitive, kind) in [
(self.edge_indices, self.edge_positions, self.edge_colors, self._edges_primitive, LINES),
(self.vertex_indices, self.vertex_positions, self.vertex_colors, self._vertices_primitive, POINTS)
]:
if len(indices) > 0:
primitive.material = edges_and_vertices_mat
primitive.indices = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(indices, 1))
primitive.attributes.POSITION = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(positions, 3))
primitive.attributes.COLOR_0 = len(buffers_list)
buffers_list.append(_gen_buffer_metadata(colors, 4))
else:
self.gltf.meshes[0].primitives = list( # Remove unused edges primitive
filter(lambda p: p.mode != kind, self.gltf.meshes[0].primitives))
# Check that all vertices are referenced by the indices
assert indices.max() == len(vertices) - 1, f"{indices.max()} != {len(vertices) - 1}"
assert indices.min() == 0
assert np.unique(indices.flatten()).size == len(vertices)
if self.image is not None: # Add texture last as it creates a fake accessor that is not added!
self.gltf.images = [Image(bufferView=len(buffers_list), mimeType=self.image[1])]
self.gltf.textures = [Texture(source=0, sampler=0)]
self.gltf.samplers = [Sampler(magFilter=NEAREST)]
self.gltf.materials[0].pbrMetallicRoughness.baseColorTexture = TextureInfo(index=0)
buffers_list.append((Accessor(), BufferView(), self.image[0]))
assert len(tex_coord) == 0 or tex_coord.ndim == 2
assert len(tex_coord) == 0 or tex_coord.shape[1] == 2
tex_coord = tex_coord.astype(np.float32)
tex_coord_blob = tex_coord.tobytes()
accessor_base = len(self.gltf.accessors)
self.gltf.meshes[0].primitives.append(
Primitive(
attributes=Attributes(POSITION=accessor_base + 1, TEXCOORD_0=accessor_base + 2)
if len(tex_coord) > 0 else Attributes(POSITION=accessor_base + 1),
indices=accessor_base,
mode=mode,
material=self.add_material(material),
)
)
buffer_view_base = len(self.gltf.bufferViews)
self.gltf.accessors.extend([it for it in [
Accessor(
bufferView=buffer_view_base,
componentType=UNSIGNED_INT,
count=indices.size,
type=SCALAR,
max=[int(indices.max())],
min=[int(indices.min())],
),
Accessor(
bufferView=buffer_view_base + 1,
componentType=FLOAT,
count=len(vertices),
type=VEC3,
max=vertices.max(axis=0).tolist(),
min=vertices.min(axis=0).tolist(),
),
Accessor(
bufferView=buffer_view_base + 2,
componentType=FLOAT,
count=len(tex_coord),
type=VEC2,
max=tex_coord.max(axis=0).tolist(),
min=tex_coord.min(axis=0).tolist(),
) if len(tex_coord) > 0 else None
] if it is not None])
prev_binary_blob = self.gltf.binary_blob()
# Once all the data is ready, we can concatenate the buffers updating the accessors and views
prev_binary_blob = self.gltf.binary_blob() or b''
byte_offset_base = len(prev_binary_blob)
self.gltf.bufferViews.extend([bv for bv in [
BufferView(
buffer=0,
byteOffset=byte_offset_base,
byteLength=len(indices_blob),
target=ELEMENT_ARRAY_BUFFER,
),
BufferView(
buffer=0,
byteOffset=byte_offset_base + len(indices_blob),
byteLength=len(vertices_blob),
target=ARRAY_BUFFER,
),
BufferView(
buffer=0,
byteOffset=byte_offset_base + len(indices_blob) + len(vertices_blob),
byteLength=len(tex_coord_blob),
target=ARRAY_BUFFER,
)
] if bv.byteLength > 0])
for accessor, bufferView, blob in buffers_list:
self.gltf.set_binary_blob(prev_binary_blob + indices_blob + vertices_blob + tex_coord_blob)
if accessor.componentType is not None: # Remove accessor of texture
buffer_view_base = len(self.gltf.bufferViews)
accessor.bufferView = buffer_view_base
self.gltf.accessors.append(accessor)
bufferView.buffer = 0
bufferView.byteOffset = byte_offset_base
bufferView.byteLength = len(blob)
self.gltf.bufferViews.append(bufferView)
byte_offset_base += len(blob)
prev_binary_blob += blob
self.gltf.buffers.append(Buffer(byteLength=byte_offset_base))
self.gltf.set_binary_blob(prev_binary_blob)
return self.gltf
def _gen_buffer_metadata(data: List[any], chunk: int) -> Tuple[Accessor, BufferView, bytes]:
return Accessor(
componentType={1: UNSIGNED_INT, 2: FLOAT, 3: FLOAT, 4: FLOAT}[chunk],
count=len(data) // chunk,
type={1: SCALAR, 2: VEC2, 3: VEC3, 4: VEC4}[chunk],
max=[max(data[i::chunk]) for i in range(chunk)],
min=[min(data[i::chunk]) for i in range(chunk)],
), BufferView(
target={1: ELEMENT_ARRAY_BUFFER, 2: ARRAY_BUFFER, 3: ARRAY_BUFFER, 4: ARRAY_BUFFER}[chunk],
), np.array(data, dtype={1: np.uint32, 2: np.float32, 3: np.float32, 4: np.float32}[chunk]).tobytes()

View File

@@ -1,14 +1,12 @@
import asyncio
import logging
import os
from typing import Tuple
from typing import Union, Dict
from build123d import *
ASSETS_DIR = os.getenv('ASSETS_DIR', os.path.join(os.path.dirname(__file__), '..', 'assets'))
def build_logo(text: bool = True) -> Tuple[Part, Location, str]:
def build_logo(text: bool = True) -> Dict[str, Union[Part, Location, str]]:
"""Builds the CAD part of the logo"""
with BuildPart(Plane.XY.offset(50)) as logo_obj:
Box(22, 40, 30)
@@ -21,38 +19,41 @@ def build_logo(text: bool = True) -> Tuple[Part, Location, str]:
Text('Yet Another\nCAD Viewer', 7, font_path='/usr/share/fonts/TTF/OpenSans-Regular.ttf')
extrude(amount=1)
logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location # Avoid overlapping:
logo_img_location.position = Vector(logo_img_location.position.X - 4e-2, logo_img_location.position.Y,
logo_img_location.position.Z)
logo_img_location = logo_obj.faces().group_by(Axis.X)[0].face().center_location
logo_img_location *= Location((0, 0, 4e-2), (0, 0, 90)) # Avoid overlapping and adjust placement
logo_img_path = os.path.join(ASSETS_DIR, 'img.jpg')
return logo_obj.part, logo_img_location, logo_img_path
img_glb_bytes, img_name = image_to_gltf(logo_img_path, logo_img_location, height=18)
fox_glb_bytes = open(os.path.join(ASSETS_DIR, 'fox.glb'), 'rb').read()
return {'fox': fox_glb_bytes, 'logo': logo_obj, 'location': logo_img_location, img_name: img_glb_bytes}
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.DEBUG)
# Start an offline server to export the CAD part of the logo in a way compatible with the frontend
# If this is not set, the server will auto-start on import and show_* calls will provide live updates
os.environ['YACV_DISABLE_SERVER'] = '1'
from yacv_server import show, show_image
testing_server = os.getenv('TESTING_SERVER') is not None
if not testing_server:
# Start an offline server to export the CAD part of the logo in a way compatible with the frontend
# If this is not set, the server will auto-start on import and show_* calls will provide live updates
os.environ['YACV_DISABLE_SERVER'] = 'True'
from yacv_server import export_all, show, image_to_gltf
# Build the CAD part of the logo
logo = build_logo()
# Add the CAD part of the logo to the server
logo, img_location, img_path = build_logo()
show(logo, 'base')
show(img_location, 'location')
show_image(img_path, img_location, 20)
show(*[obj for obj in logo.values()], names=[name for name in logo.keys()])
async def exporter():
# We need access to the actual server object for advanced features like exporting to file
from yacv_server import server
for name in server.shown_object_names():
print(f'Exporting {name} to GLB...')
with open(os.path.join(ASSETS_DIR, 'logo_build', f'{name}.glb'), 'wb') as f:
f.write(await server.export(name))
# Save the complete logo to multiple GLB files (async required)
asyncio.run(exporter())
print('Logo saved!')
if testing_server:
# remove('location') # Test removing a part
pass
else:
# Save the complete logo to multiple GLB files
export_all(os.path.join(ASSETS_DIR, 'logo_build'))
print('Logo saved!')

134
yacv_server/myhttp.py Normal file
View File

@@ -0,0 +1,134 @@
import io
import os
import urllib.parse
from http import HTTPStatus
from http.server import SimpleHTTPRequestHandler
from yacv_server.mylogger import logger
# Find the frontend folder (optional, but recommended)
FILE_DIR = os.path.dirname(__file__)
FRONTEND_BASE_PATH = os.getenv('FRONTEND_BASE_PATH', os.path.join(FILE_DIR, 'frontend'))
if not os.path.exists(FRONTEND_BASE_PATH):
if os.path.exists(os.path.join(FILE_DIR, '..', 'dist')): # Fallback to dev build
FRONTEND_BASE_PATH = os.path.join(FILE_DIR, '..', 'dist')
else:
logger.warning('Frontend not found at %s', FRONTEND_BASE_PATH)
FRONTEND_BASE_PATH = None
# Define the API paths (also available at the root path for simplicity)
UPDATES_API_PATH = '/api/updates'
OBJECTS_API_PATH = '/api/object' # /{name}
class HTTPHandler(SimpleHTTPRequestHandler):
yacv: 'yacv.YACV'
def __init__(self, *args, yacv: 'yacv.YACV', **kwargs):
self.yacv = yacv
super().__init__(*args, **kwargs, directory=FRONTEND_BASE_PATH)
def log_message(self, fmt, *args):
logger.debug(fmt, *args)
def end_headers(self):
# Add CORS headers to the response
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
super().end_headers()
def translate_path(self, path: str) -> str:
"""Translate a path to the local filesystem, adds some basic security checks"""
path = super().translate_path(path)
path = os.path.realpath(path) # Avoid symlink hacks
if self.directory: # Ensure proper subdirectory
base = os.path.abspath(self.directory)
if not os.path.abspath(path).startswith(base):
self.send_error(HTTPStatus.FORBIDDEN, "Path is not in the frontend directory")
return ''
return path
def send_head(self):
path_parts = self.path.split('?', 1)
if len(path_parts) == 1:
path_parts.append('')
[path, query_str] = path_parts
query = urllib.parse.parse_qs(query_str)
if path == UPDATES_API_PATH or path == '/' and query.get('api_updates') is not None:
return self._api_updates()
elif path.startswith(OBJECTS_API_PATH) or path == '/' and query.get('api_object') is not None:
if path.startswith(OBJECTS_API_PATH):
obj_name = self.path[len(OBJECTS_API_PATH) + 1:]
else:
obj_name = query.get('api_object').pop()
return self._api_object(obj_name)
elif path.endswith('/'): # Frontend index.html
self.path += 'index.html'
return super().send_head()
else: # Normal frontend file
return super().send_head()
def _api_updates(self):
"""Handles a publish-only websocket connection that send show_object events along with their hashes and URLs"""
# Keep a shared read lock to know if any frontend is still working before shutting down
with self.yacv.frontend_lock.r_locked():
# Avoid accepting new connections while shutting down
if self.yacv.shutting_down.is_set() and self.yacv.at_least_one_client.is_set():
self.send_error(HTTPStatus.SERVICE_UNAVAILABLE, 'Server is shutting down')
return
self.yacv.at_least_one_client.set()
logger.debug('Updates client connected')
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", "text/event-stream")
self.send_header("Cache-Control", "no-cache")
# Chunked transfer encoding!
self.send_header("Transfer-Encoding", "chunked")
self.end_headers()
def write_chunk(_chunk_data: str):
self.wfile.write(hex(len(_chunk_data))[2:].encode('utf-8'))
self.wfile.write(b'\r\n')
self.wfile.write(_chunk_data.encode('utf-8'))
self.wfile.write(b'\r\n')
self.wfile.flush()
write_chunk('retry: 100\n\n')
subscription = self.yacv.show_events.subscribe(yield_timeout=1.0) # Keep-alive interval
try:
for data in subscription:
if data is None:
write_chunk(':keep-alive\n\n')
else:
logger.debug('Sending info about %s: %s', data.name, data)
# noinspection PyUnresolvedReferences
to_send = data.to_json()
write_chunk(f'data: {to_send}\n\n')
except BrokenPipeError: # Client disconnected normally
pass
finally:
subscription.close()
logger.debug('Updates client disconnected')
def _api_object(self, obj_name: str):
"""Returns the object file with the matching name, building it if necessary."""
# Export the object (or fail if not found)
_export = self.yacv.export(obj_name)
if _export is None:
self.send_error(HTTPStatus.NOT_FOUND, f'Object {obj_name} not found')
return io.BytesIO()
exported_glb, _hash = _export
# Wrap the GLB in a response and return it
self.send_response(HTTPStatus.OK)
self.send_header('Content-Type', 'model/gltf-binary')
self.send_header('Content-Length', str(len(exported_glb)))
self.send_header('Content-Disposition', f'attachment; filename="{obj_name}.glb"')
self.send_header('E-Tag', f'"{_hash}"')
self.end_headers()
self.wfile.write(exported_glb)

View File

@@ -1 +1 @@
# TODO: Plugins that can freely modify the GLTF file as it is being built
# TODO(if there is interest): Plugins that can freely modify the GLTF file as it is being built

View File

@@ -1,64 +1,89 @@
import asyncio
import queue
import queue
import threading
from typing import List, TypeVar, \
Generic, AsyncGenerator
Generic, Generator
from mylogger import logger
from yacv_server.mylogger import logger
T = TypeVar('T')
_end_of_queue = object()
class BufferedPubSub(Generic[T]):
"""A simple implementation of publish-subscribe pattern using asyncio and buffering all previous events"""
"""A simple implementation of publish-subscribe pattern using threading and buffering all previous events"""
_buffer: List[T]
_subscribers: List[asyncio.Queue[T]]
_lock = asyncio.Lock()
max_buffer_size = 1000
_buffer_lock: threading.Lock
_subscribers: List[queue.Queue[T]]
_subscribers_lock: threading.Lock
max_buffer_size: int
def __init__(self):
def __init__(self, max_buffer_size: int = 100):
self._buffer = []
self._buffer_lock = threading.Lock()
self._subscribers = []
self._subscribers_lock = threading.Lock()
self.max_buffer_size = max_buffer_size
def publish_nowait(self, event: T):
def publish(self, event: T):
"""Publishes an event without blocking (synchronous API does not require locking)"""
self._buffer.append(event)
if len(self._buffer) > self.max_buffer_size:
self._buffer.pop(0)
for q in self._subscribers:
q.put_nowait(event)
with self._buffer_lock:
self._buffer.append(event)
if len(self._buffer) > self.max_buffer_size:
self._buffer.pop(0)
for q in self._subscribers:
q.put(event)
async def _subscribe(self, include_buffered: bool = True, include_future: bool = True) -> asyncio.Queue[T]:
def _subscribe(self, include_buffered: bool = True, include_future: bool = True) -> queue.Queue[T]:
"""Subscribes to events"""
q = asyncio.Queue()
async with self._lock:
q = queue.Queue()
with self._subscribers_lock:
self._subscribers.append(q)
logger.debug(f"Subscribed to %s (%d subscribers)", self, len(self._subscribers))
if include_buffered:
for event in self._buffer:
await q.put(event)
with self._buffer_lock:
for event in self._buffer:
q.put(event)
if not include_future:
await q.put(None)
q.put(_end_of_queue)
return q
async def _unsubscribe(self, q: asyncio.Queue[T]):
def _unsubscribe(self, q: queue.Queue[T]):
"""Unsubscribes from events"""
async with self._lock:
with self._subscribers_lock:
self._subscribers.remove(q)
logger.debug(f"Unsubscribed from %s (%d subscribers)", self, len(self._subscribers))
async def subscribe(self, include_buffered: bool = True, include_future: bool = True) -> AsyncGenerator[T, None]:
"""Subscribes to events as an async generator that yields events and automatically unsubscribes"""
q = await self._subscribe(include_buffered, include_future)
def subscribe(self, include_buffered: bool = True, include_future: bool = True, yield_timeout: float = 0.0) -> \
Generator[T, None, None]:
"""Subscribes to events as an generator that yields events and automatically unsubscribes"""
q = self._subscribe(include_buffered, include_future)
try:
while True:
v = await q.get()
try:
v = q.get(timeout=yield_timeout)
except queue.Empty:
v = None
# include_future is incompatible with None values as they are used to signal the end of the stream
if v is None and not include_future:
if v is _end_of_queue:
break
yield v
finally: # When aclose() is called
await self._unsubscribe(q)
self._unsubscribe(q)
def buffer(self) -> List[T]:
"""Returns a shallow copy of the list of buffered events"""
return self._buffer[:]
with self._buffer_lock:
return self._buffer[:]
def delete(self, event: T):
"""Deletes an event from the buffer"""
with self._buffer_lock:
self._buffer.remove(event)
def clear(self):
"""Clears the buffer"""
with self._buffer_lock:
self._buffer.clear()

96
yacv_server/rwlock.py Normal file
View File

@@ -0,0 +1,96 @@
# -*- coding: utf-8 -*-
""" rwlock.py
A class to implement read-write locks on top of the standard threading
library.
This is implemented with two mutexes (threading.Lock instances) as per this
wikipedia pseudocode:
https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock#Using_two_mutexes
Code written by Tyler Neylon at Unbox Research.
This file is public domain.
"""
# _______________________________________________________________________
# Imports
from contextlib import contextmanager
from threading import Lock
# _______________________________________________________________________
# Class
class RWLock(object):
""" RWLock class; this is meant to allow an object to be read from by
multiple threads, but only written to by a single thread at a time. See:
https://en.wikipedia.org/wiki/Readers%E2%80%93writer_lock
Usage:
from rwlock import RWLock
my_obj_rwlock = RWLock()
# When reading from my_obj:
with my_obj_rwlock.r_locked():
do_read_only_things_with(my_obj)
# When writing to my_obj:
with my_obj_rwlock.w_locked():
mutate(my_obj)
"""
def __init__(self):
self.w_lock = Lock()
self.num_r_lock = Lock()
self.num_r = 0
# ___________________________________________________________________
# Reading methods.
def r_acquire(self, *args, **kwargs):
self.num_r_lock.acquire(*args, **kwargs)
self.num_r += 1
if self.num_r == 1:
self.w_lock.acquire(*args, **kwargs)
self.num_r_lock.release()
def r_release(self, *args, **kwargs):
assert self.num_r > 0
self.num_r_lock.acquire(*args, **kwargs)
self.num_r -= 1
if self.num_r == 0:
self.w_lock.release()
self.num_r_lock.release()
@contextmanager
def r_locked(self, *args, **kwargs):
""" This method is designed to be used via the `with` statement. """
try:
self.r_acquire(*args, **kwargs)
yield
finally:
self.r_release()
# ___________________________________________________________________
# Writing methods.
def w_acquire(self, *args, **kwargs):
self.w_lock.acquire(*args, **kwargs)
def w_release(self):
self.w_lock.release()
@contextmanager
def w_locked(self, *args, **kwargs):
""" This method is designed to be used via the `with` statement. """
try:
self.w_acquire(*args, **kwargs)
yield
finally:
self.w_release()

View File

@@ -1,306 +0,0 @@
import asyncio
import atexit
import os
import signal
import sys
import time
from dataclasses import dataclass
from threading import Thread
from typing import Optional, Dict, Union
import aiohttp_cors
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS_Shape
from aiohttp import web
from build123d import Shape, Axis, Location, Vector
from dataclasses_json import dataclass_json
from cad import get_shape, grab_all_cad, image_to_gltf
from mylogger import logger
from pubsub import BufferedPubSub
from tessellate import _hashcode, tessellate
# Find the frontend folder (optional, but recommended)
FILE_DIR = os.path.dirname(__file__)
FRONTEND_BASE_PATH = os.getenv('FRONTEND_BASE_PATH', os.path.join(FILE_DIR, 'frontend'))
if not os.path.exists(FRONTEND_BASE_PATH):
if os.path.exists(os.path.join(FILE_DIR, '..', 'dist')): # Fallback to dev build
FRONTEND_BASE_PATH = os.path.join(FILE_DIR, '..', 'dist')
else:
logger.warning('Frontend not found at %s', FRONTEND_BASE_PATH)
# Define the API paths (also available at the root path for simplicity)
UPDATES_API_PATH = '/api/updates'
OBJECTS_API_PATH = '/api/object' # /{name}
@dataclass_json
@dataclass
class UpdatesApiData:
"""Data sent to the client through the updates API"""
name: str
"""Name of the object. Should be unique unless you want to overwrite the previous object"""
hash: str
"""Hash of the object, to detect changes without rebuilding the object"""
class UpdatesApiFullData(UpdatesApiData):
obj: Optional[TopoDS_Shape]
"""The OCCT object, if any (not serialized)"""
kwargs: Optional[Dict[str, any]]
"""The show_object options, if any (not serialized)"""
def __init__(self, name: str, hash: str, obj: Optional[TopoDS_Shape] = None,
kwargs: Optional[Dict[str, any]] = None):
self.name = name
self.hash = hash
self.obj = obj
self.kwargs = kwargs
def to_json(self) -> str:
return super().to_json()
# noinspection PyUnusedLocal
async def _index_handler(request: web.Request) -> web.Response:
return web.HTTPTemporaryRedirect(location='index.html')
class Server:
app = web.Application()
runner: web.AppRunner
thread: Optional[Thread] = None
do_shutdown = asyncio.Event()
show_events = BufferedPubSub[UpdatesApiFullData]()
object_events: Dict[str, BufferedPubSub[bytes]] = {}
object_events_lock = asyncio.Lock()
def __init__(self, *args, **kwargs):
# --- Routes ---
# - APIs
self.app.router.add_route('GET', f'{UPDATES_API_PATH}', self._api_updates)
self.app.router.add_route('GET', f'{OBJECTS_API_PATH}/{{name}}', self._api_object)
# - Single websocket/objects/frontend entrypoint to ease client configuration
self.app.router.add_get('/', self._entrypoint)
# - Static files from the frontend
self.app.router.add_get('/{path:(.*/|)}', _index_handler) # Any folder -> index.html
self.app.router.add_static('/', path=FRONTEND_BASE_PATH, name='static_frontend')
# --- CORS ---
cors = aiohttp_cors.setup(self.app, defaults={
"*": aiohttp_cors.ResourceOptions(
allow_credentials=True,
expose_headers="*",
allow_headers="*",
)
})
for route in list(self.app.router.routes()):
cors.add(route)
# --- Misc ---
self.loop = asyncio.new_event_loop()
def start(self):
"""Starts the web server in the background"""
assert self.thread is None, "Server currently running, cannot start another one"
# Start the server in a separate daemon thread
self.thread = Thread(target=self._run_server, name='yacv_server', daemon=True)
signal.signal(signal.SIGINT | signal.SIGTERM, self.stop)
atexit.register(self.stop)
self.thread.start()
# noinspection PyUnusedLocal
def stop(self, *args):
"""Stops the web server"""
if self.thread is None:
print('Cannot stop server because it is not running')
return
# FIXME: Wait for at least one client to confirm ready before stopping in case we are too fast?
self.loop.call_soon_threadsafe(lambda *a: self.do_shutdown.set())
self.thread.join(timeout=12)
self.thread = None
if len(args) >= 1 and args[0] in (signal.SIGINT, signal.SIGTERM):
sys.exit(0) # Exit with success
def _run_server(self):
"""Runs the web server"""
asyncio.set_event_loop(self.loop)
self.loop.run_until_complete(self._run_server_async())
self.loop.stop()
self.loop.close()
async def _run_server_async(self):
"""Runs the web server (async)"""
runner = web.AppRunner(self.app)
await runner.setup()
site = web.TCPSite(runner, os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323)))
await site.start()
# print(f'Server started at {site.name}')
# Wait for a signal to stop the server while running
await self.do_shutdown.wait()
# print('Shutting down server...')
await runner.cleanup()
async def _entrypoint(self, request: web.Request) -> web.StreamResponse:
"""Main entrypoint to the server, which automatically serves the frontend/updates/objects"""
if request.headers.get('Upgrade', '').lower() == 'websocket': # WebSocket -> updates API
return await self._api_updates(request)
elif request.query.get('api_object', '') != '': # ?api_object={name} -> object API
request.match_info['name'] = request.query['api_object']
return await self._api_object(request)
else: # Anything else -> frontend index.html
return await _index_handler(request)
async def _api_updates(self, request: web.Request) -> web.WebSocketResponse:
"""Handles a publish-only websocket connection that send show_object events along with their hashes and URLs"""
ws = web.WebSocketResponse()
await ws.prepare(request)
async def _send_api_updates():
subscription = self.show_events.subscribe()
try:
async for data in subscription:
logger.debug('Sending info about %s to %s: %s', data.name, request.remote, data)
# noinspection PyUnresolvedReferences
await ws.send_str(data.to_json())
finally:
await subscription.aclose()
# Start sending updates to the client automatically
send_task = asyncio.create_task(_send_api_updates())
receive_task = asyncio.create_task(ws.receive())
try:
logger.debug('Client connected: %s', request.remote)
# Wait for the client to close the connection (or send a message)
done, pending = await asyncio.wait([send_task, receive_task], return_when=asyncio.FIRST_COMPLETED)
# Make sure to stop sending updates to the client and close the connection
for task in pending:
task.cancel()
finally:
await ws.close()
logger.debug('Client disconnected: %s', request.remote)
return ws
obj_counter = 0
def _show_common(self, name: Optional[str], hash: str, start: float, obj: Optional[TopoDS_Shape] = None,
kwargs=None):
name = name or f'object_{self.obj_counter}'
self.obj_counter += 1
precomputed_info = UpdatesApiFullData(name=name, hash=hash, obj=obj, kwargs=kwargs or {})
self.show_events.publish_nowait(precomputed_info)
logger.info('show_object(%s, %s) took %.3f seconds', name, hash, time.time() - start)
return precomputed_info
def show(self, any_object: Union[bytes, TopoDS_Shape, any], name: Optional[str] = None, **kwargs):
"""Publishes "any" object to the server"""
if isinstance(any_object, bytes):
self.show_gltf(any_object, name, **kwargs)
else:
self.show_cad(any_object, name, **kwargs)
def show_gltf(self, gltf: bytes, name: Optional[str] = None, **kwargs):
"""Publishes any single-file GLTF object to the server."""
start = time.time()
# Precompute the info and send it to the client as if it was a CAD object
precomputed_info = self._show_common(name, _hashcode(gltf, **kwargs), start, kwargs=kwargs)
# Also pre-populate the GLTF data for the object API
publish_to = BufferedPubSub[bytes]()
publish_to.publish_nowait(gltf)
publish_to.publish_nowait(b'') # Signal the end of the stream
self.object_events[precomputed_info.name] = publish_to
def show_image(self, source: str | bytes, center: any, ppmm: int, name: Optional[str] = None,
save_mime: str = 'image/jpeg', **kwargs):
"""Publishes an image as a quad GLTF object, indicating the center location and pixels per millimeter."""
# Convert the image to a GLTF CAD object
gltf, name = image_to_gltf(source, center, ppmm, name, save_mime)
# Publish it like any other GLTF object
self.show_gltf(gltf, name, **kwargs)
def show_cad(self, obj: Union[TopoDS_Shape, any], name: Optional[str] = None, **kwargs):
"""Publishes a CAD object to the server"""
start = time.time()
# Get the shape of a CAD-like object
obj = get_shape(obj)
# Convert Z-up (OCCT convention) to Y-up (GLTF convention)
if isinstance(obj, TopoDS_Shape):
obj = Shape(obj).rotate(Axis.X, -90).wrapped
elif isinstance(obj, TopLoc_Location):
tmp_location = Location(obj)
tmp_location.position = Vector(tmp_location.position.X, tmp_location.position.Z, -tmp_location.position.Y)
tmp_location.orientation = Vector(tmp_location.orientation.X - 90, tmp_location.orientation.Y,
tmp_location.orientation.Z)
obj = tmp_location.wrapped
self._show_common(name, _hashcode(obj, **kwargs), start, obj, kwargs)
def show_cad_all(self, **kwargs):
"""Publishes all CAD objects to the server"""
for name, obj in grab_all_cad():
self.show_cad(obj, name, **kwargs)
async def _api_object(self, request: web.Request) -> web.Response:
"""Returns the object file with the matching name, building it if necessary."""
# Export the object (or fail if not found)
exported_glb = await self.export(request.match_info['name'])
# Wrap the GLB in a response and return it
response = web.Response(body=exported_glb)
response.content_type = 'model/gltf-binary'
response.headers['Content-Disposition'] = f'attachment; filename="{request.match_info["name"]}.glb"'
return response
def shown_object_names(self) -> list[str]:
"""Returns the names of all objects that have been shown"""
return list([obj.name for obj in self.show_events.buffer()])
async def export(self, name: str) -> bytes:
"""Export the given previously-shown object to a single GLB file, building it if necessary."""
start = time.time()
# Check that the object to build exists and grab it if it does
found = False
obj: Optional[TopoDS_Shape] = None
kwargs: Optional[Dict[str, any]] = None
subscription = self.show_events.buffer()
for data in subscription:
if data.name == name:
obj = data.obj
kwargs = data.kwargs
found = True # Required because obj could be None
break
if not found:
raise web.HTTPNotFound(text=f'No object named {name} was previously shown')
# Use the lock to ensure that we don't build the object twice
async with self.object_events_lock:
# If there are no object events for this name, we need to build the object
if name not in self.object_events:
# Prepare the pubsub for the object
publish_to = BufferedPubSub[bytes]()
self.object_events[name] = publish_to
def _build_object():
# Build and publish the object (once)
gltf = tessellate(obj, tolerance=kwargs.get('tolerance', 0.1),
angular_tolerance=kwargs.get('angular_tolerance', 0.1),
faces=kwargs.get('faces', True),
edges=kwargs.get('edges', True),
vertices=kwargs.get('vertices', True))
glb_list_of_bytes = gltf.save_to_bytes()
publish_to.publish_nowait(b''.join(glb_list_of_bytes))
logger.info('export(%s) took %.3f seconds, %d parts', name, time.time() - start,
len(gltf.meshes[0].primitives))
# We should build it fully even if we are cancelled, so we use a separate task
# Furthermore, building is CPU-bound, so we use the default executor
await asyncio.get_running_loop().run_in_executor(None, _build_object)
# In either case return the elements of a subscription to the async generator
subscription = self.object_events[name].subscribe()
try:
return await anext(subscription)
finally:
await subscription.aclose()

View File

@@ -1,25 +1,20 @@
import hashlib
import io
import re
from typing import List, Dict, Tuple, Union
from typing import List, Dict, Tuple
from OCP.BRep import BRep_Tool
from OCP.BRepAdaptor import BRepAdaptor_Curve
from OCP.GCPnts import GCPnts_TangentialDeflection
from OCP.TopExp import TopExp
from OCP.TopLoc import TopLoc_Location
from OCP.TopTools import TopTools_IndexedMapOfShape
from OCP.TopoDS import TopoDS_Face, TopoDS_Edge, TopoDS_Shape, TopoDS_Vertex
from build123d import Shape, Vertex, Face, Location
from pygltflib import GLTF2
import mylogger
from cad import CADLike
from gltf import GLTFMgr
from yacv_server.cad import CADCoreLike
from yacv_server.gltf import GLTFMgr
from yacv_server.mylogger import logger
def tessellate(
cad_like: CADLike,
cad_like: CADCoreLike,
tolerance: float = 0.1,
angular_tolerance: float = 0.1,
faces: bool = True,
@@ -36,8 +31,8 @@ def tessellate(
shape = Shape(cad_like)
# Perform tessellation tasks
edge_to_faces: Dict[TopoDS_Edge, List[TopoDS_Face]] = {}
vertex_to_faces: Dict[TopoDS_Vertex, List[TopoDS_Face]] = {}
edge_to_faces: Dict[str, List[TopoDS_Face]] = {}
vertex_to_faces: Dict[str, List[TopoDS_Face]] = {}
if faces:
for face in shape.faces():
_tessellate_face(mgr, face.wrapped, tolerance, angular_tolerance)
@@ -55,7 +50,7 @@ def tessellate(
for vertex in shape.vertices():
_tessellate_vertex(mgr, vertex.wrapped, vertex_to_faces.get(vertex.wrapped, []))
return mgr.gltf
return mgr.build()
def _tessellate_face(
@@ -65,12 +60,12 @@ def _tessellate_face(
angular_tolerance: float = 0.1
):
face = Shape(ocp_face)
face.mesh(tolerance, angular_tolerance)
# face.mesh(tolerance, angular_tolerance)
tri_mesh = face.tessellate(tolerance, angular_tolerance)
poly = BRep_Tool.Triangulation_s(face.wrapped, TopLoc_Location())
if poly is None:
mylogger.logger.warn("No triangulation found for face")
logger.warn("No triangulation found for face")
return GLTF2()
tri_mesh = face.tessellate(tolerance, angular_tolerance)
# Get UV of each face from the parameters
uv = [
@@ -78,7 +73,7 @@ def _tessellate_face(
for v in (poly.UVNode(i) for i in range(1, poly.NbNodes() + 1))
]
vertices = [(v.X, v.Y, v.Z) for v in tri_mesh[0]]
vertices = tri_mesh[0]
indices = tri_mesh[1]
mgr.add_face(vertices, indices, uv)
@@ -91,9 +86,9 @@ def _push_point(v: Tuple[float, float, float], faces: List[TopoDS_Face]) -> Tupl
push_dir = (push_dir[0] + normal.X, push_dir[1] + normal.Y, push_dir[2] + normal.Z)
if push_dir != (0, 0, 0):
# Normalize the push direction by the number of faces and a constant factor
# NOTE: Don't overdo it, or metrics will be wrong
n = len(faces) / 1e-3
push_dir = (push_dir[0] / n, push_dir[1] / n, push_dir[2] / n)
# NOTE: Don't overdo it, or metrics will be (more) wrong
n = 1e-3 / len(faces)
push_dir = (push_dir[0] * n, push_dir[1] * n, push_dir[2] * n)
# Push the vertex by the normal
v = (v[0] + push_dir[0], v[1] + push_dir[1], v[2] + push_dir[2])
return v
@@ -119,6 +114,9 @@ def _tessellate_edge(
for i in range(1, discretizer.NbPoints() + 1)
)
]
# Convert strip of vertices to a list of pairs of vertices
vertices = [(vertices[i], vertices[i + 1]) for i in range(len(vertices) - 1)]
mgr.add_edge(vertices)
@@ -127,30 +125,3 @@ def _tessellate_vertex(mgr: GLTFMgr, ocp_vertex: TopoDS_Vertex, faces: List[Topo
mgr.add_vertex(_push_point((c.X, c.Y, c.Z), faces))
def _hashcode(obj: Union[bytes, TopoDS_Shape], **extras) -> str:
"""Utility to compute the hash code of a shape recursively without the need to tessellate it"""
# NOTE: obj.HashCode(MAX_HASH_CODE) is not stable across different runs of the same program
# This is best-effort and not guaranteed to be unique
hasher = hashlib.md5(usedforsecurity=False)
for k, v in extras.items():
hasher.update(str(k).encode())
hasher.update(str(v).encode())
if isinstance(obj, bytes):
hasher.update(obj)
elif isinstance(obj, TopLoc_Location):
sub_data = io.BytesIO()
obj.DumpJson(sub_data)
hasher.update(sub_data.getvalue())
elif isinstance(obj, TopoDS_Shape):
map_of_shapes = TopTools_IndexedMapOfShape()
TopExp.MapShapes_s(obj, map_of_shapes)
for i in range(1, map_of_shapes.Extent() + 1):
sub_shape = map_of_shapes.FindKey(i)
sub_data = io.BytesIO()
TopoDS_Shape.DumpJson(sub_shape, sub_data)
val = sub_data.getvalue()
val = re.sub(b'"this": "[^"]*"', b'', val) # Remove memory address
hasher.update(val)
else:
raise ValueError(f'Cannot hash object of type {type(obj)}')
return hasher.hexdigest()

343
yacv_server/yacv.py Normal file
View File

@@ -0,0 +1,343 @@
import atexit
import copy
import inspect
import os
import signal
import sys
import threading
import time
from dataclasses import dataclass
from http.server import ThreadingHTTPServer
from importlib.metadata import version
from threading import Thread
from typing import Optional, Dict, Union, Callable, List, Tuple
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import TopoDS_Shape
# noinspection PyProtectedMember
from build123d import Shape, Axis, Location, Vector
from dataclasses_json import dataclass_json
from yacv_server.cad import get_shape, grab_all_cad, CADCoreLike, CADLike
from yacv_server.myhttp import HTTPHandler
from yacv_server.mylogger import logger
from yacv_server.pubsub import BufferedPubSub
from yacv_server.rwlock import RWLock
from yacv_server.tessellate import tessellate
from yacv_server.cad import _hashcode
@dataclass_json
@dataclass
class UpdatesApiData:
"""Data sent to the client through the updates API"""
name: str
"""Name of the object. Should be unique unless you want to overwrite the previous object"""
hash: str
"""Hash of the object, to detect changes without rebuilding the object"""
is_remove: Optional[bool]
"""Whether to remove the object from the scene. If None, this is a shutdown request"""
YACVSupported = Union[bytes, CADCoreLike]
class UpdatesApiFullData(UpdatesApiData):
obj: YACVSupported
"""The OCCT object, if any (not serialized)"""
kwargs: Optional[Dict[str, any]]
"""The show_object options, if any (not serialized)"""
def __init__(self, obj: YACVSupported, name: str, _hash: str, is_remove: Optional[bool] = False,
kwargs: Optional[Dict[str, any]] = None):
self.name = name
self.hash = _hash
self.is_remove = is_remove
self.obj = obj
self.kwargs = kwargs
def to_json(self) -> str:
# noinspection PyUnresolvedReferences
return super().to_json()
class YACV:
"""The main yacv_server class, which manages the web server and the CAD objects."""
# Startup
server_thread: Optional[Thread]
"""The main thread running the server (will spawn other threads for each request)"""
server: Optional[ThreadingHTTPServer]
"""The server object"""
startup_complete: threading.Event
"""Event to signal when the server has started"""
# Running
show_events: BufferedPubSub[UpdatesApiFullData]
"""PubSub for show events (objects to be shown in/removed from the scene)"""
build_events: Dict[str, BufferedPubSub[bytes]]
"""PubSub for build events (objects that were built)"""
build_events_lock: threading.Lock
"""Lock to ensure that objects are only built once"""
# Shutdown
at_least_one_client: threading.Event
"""Event to signal when at least one client has connected"""
shutting_down: threading.Event
"""Event to signal when the server is shutting down"""
frontend_lock: RWLock
"""Lock to ensure that the frontend has finished working before we shut down"""
def __init__(self):
self.server_thread = None
self.server = None
self.startup_complete = threading.Event()
self.show_events = BufferedPubSub()
self.build_events = {}
self.build_events_lock = threading.Lock()
self.at_least_one_client = threading.Event()
self.shutting_down = threading.Event()
self.frontend_lock = RWLock()
logger.info('Using yacv-server v%s', version('yacv-server'))
def start(self):
"""Starts the web server in the background"""
assert self.server_thread is None, "Server currently running, cannot start another one"
assert self.startup_complete.is_set() is False, "Server already started"
# Start the server in a separate daemon thread
self.server_thread = Thread(target=self._run_server, name='yacv_server', daemon=True)
signal.signal(signal.SIGINT | signal.SIGTERM, self.stop)
atexit.register(self.stop)
self.server_thread.start()
logger.info('Server started (requested)...')
# Wait for the server to be ready before returning
while not self.startup_complete.wait():
time.sleep(0.01)
logger.info('Server started (received)...')
# noinspection PyUnusedLocal
def stop(self, *args):
"""Stops the web server"""
if self.server_thread is None:
logger.error('Cannot stop server because it is not running')
return
# Inform the server that we are shutting down
self.shutting_down.set()
# noinspection PyTypeChecker
self.show_events.publish(UpdatesApiFullData(name='__shutdown', _hash='', is_remove=None, obj=None))
# If we were too fast, ensure that at least one client has connected
graceful_secs_connect = float(os.getenv('YACV_GRACEFUL_SECS_CONNECT', 12.0))
if graceful_secs_connect > 0:
start = time.time()
try:
if not self.at_least_one_client.is_set():
logger.warning(
'Waiting for at least one frontend request before stopping server, cancel with CTRL+C...')
while (not self.at_least_one_client.wait(graceful_secs_connect / 10) and
time.time() - start < graceful_secs_connect):
time.sleep(0.01)
except KeyboardInterrupt:
pass
# Wait for the server to stop gracefully (all frontends to stop working)
graceful_secs_request = float(os.getenv('YACV_GRACEFUL_SECS_WORK', 1000000))
with self.frontend_lock.w_locked(timeout=graceful_secs_request):
# Stop the server
self.server.shutdown()
# Wait for the server thread to stop
self.server_thread.join(timeout=30)
self.server_thread = None
if len(args) >= 1 and args[0] in (signal.SIGINT, signal.SIGTERM):
sys.exit(0) # Exit with success
def _run_server(self):
"""Runs the web server"""
logger.info('Starting server...')
self.server = ThreadingHTTPServer(
(os.getenv('YACV_HOST', 'localhost'), int(os.getenv('YACV_PORT', 32323))),
lambda a, b, c: HTTPHandler(a, b, c, yacv=self))
# noinspection HttpUrlsUsage
logger.info(f'Serving at http://{self.server.server_name}:{self.server.server_port}')
self.startup_complete.set()
self.server.serve_forever()
def show(self, *objs: List[YACVSupported], names: Optional[Union[str, List[str]]] = None, **kwargs):
# Prepare the arguments
start = time.time()
names = names or [_find_var_name(obj) for obj in objs]
if isinstance(names, str):
names = [names]
assert len(names) == len(objs), 'Number of names must match the number of objects'
# Handle auto clearing of previous objects
if kwargs.get('auto_clear', True):
self.clear(except_names=names)
# Remove a previous object event with the same name
for old_event in self.show_events.buffer():
if old_event.name in names:
self.show_events.delete(old_event)
if old_event.name in self.build_events:
del self.build_events[old_event.name]
# Publish the show event
for obj, name in zip(objs, names):
if not isinstance(obj, bytes):
obj = _preprocess_cad(obj, **kwargs)
_hash = _hashcode(obj, **kwargs)
event = UpdatesApiFullData(name=name, _hash=_hash, obj=obj, kwargs=kwargs or {})
self.show_events.publish(event)
logger.info('show %s took %.3f seconds', names, time.time() - start)
def show_cad_all(self, **kwargs):
"""Publishes all CAD objects in the current scope to the server"""
all_cad = list(grab_all_cad()) # List for reproducible iteration order
self.show(*[cad for _, cad in all_cad], names=[name for name, _ in all_cad], **kwargs)
def remove(self, name: str):
"""Removes a previously-shown object from the scene"""
show_events = self._show_events(name)
if len(show_events) > 0:
# Ensure only the new remove event remains for this name
for old_show_event in show_events:
self.show_events.delete(old_show_event)
# Delete any cached object builds
with self.build_events_lock:
if name in self.build_events:
del self.build_events[name]
# Publish the remove event
show_event = copy.copy(show_events[-1])
show_event.is_remove = True
self.show_events.publish(show_event)
def clear(self, except_names: List[str] = None):
"""Clears all previously-shown objects from the scene"""
if except_names is None:
except_names = []
for event in self.show_events.buffer():
if event.name not in except_names:
self.remove(event.name)
def shown_object_names(self, apply_removes: bool = True) -> List[str]:
"""Returns the names of all objects that have been shown"""
res = set()
for obj in self.show_events.buffer():
if not obj.is_remove or not apply_removes:
res.add(obj.name)
else:
res.discard(obj.name)
return list(res)
def _show_events(self, name: str, apply_removes: bool = True) -> List[UpdatesApiFullData]:
"""Returns the show events with the given name"""
res = []
for event in self.show_events.buffer():
if event.name == name:
if not event.is_remove or not apply_removes:
res.append(event)
else:
# Also remove the previous events
for old_event in res:
if old_event.name == event.name:
res.remove(old_event)
return res
def export(self, name: str) -> Optional[Tuple[bytes, str]]:
"""Export the given previously-shown object to a single GLB blob, building it if necessary."""
start = time.time()
# Check that the object to build exists and grab it if it does
events = self._show_events(name)
if len(events) == 0:
logger.warning('Object %s not found', name)
return None
event = events[-1]
# Use the lock to ensure that we don't build the object twice
with self.build_events_lock:
# If there are no object events for this name, we need to build the object
if name not in self.build_events:
logger.debug('Building object %s with hash %s', name, event.hash)
# Prepare the pubsub for the object
publish_to = BufferedPubSub[bytes]()
self.build_events[name] = publish_to
# Build and publish the object (once)
if isinstance(event.obj, bytes): # Already a GLTF
publish_to.publish(event.obj)
else: # CAD object to tessellate and convert to GLTF
gltf = tessellate(event.obj, tolerance=event.kwargs.get('tolerance', 0.1),
angular_tolerance=event.kwargs.get('angular_tolerance', 0.1),
faces=event.kwargs.get('faces', True),
edges=event.kwargs.get('edges', True),
vertices=event.kwargs.get('vertices', True))
glb_list_of_bytes = gltf.save_to_bytes()
glb_bytes = b''.join(glb_list_of_bytes)
publish_to.publish(glb_bytes)
logger.info('export(%s) took %.3f seconds, %s', name, time.time() - start,
sizeof_fmt(len(glb_bytes)))
# In either case return the elements of a subscription to the async generator
subscription = self.build_events[name].subscribe()
try:
return next(subscription), event.hash
finally:
subscription.close()
def export_all(self, folder: str,
export_filter: Callable[[str, Optional[CADCoreLike]], bool] = lambda name, obj: True):
"""Export all previously-shown objects to GLB files in the given folder"""
os.makedirs(folder, exist_ok=True)
for name in self.shown_object_names():
if export_filter(name, self._show_events(name)[-1].obj):
with open(os.path.join(folder, f'{name}.glb'), 'wb') as f:
f.write(self.export(name)[0])
# noinspection PyUnusedLocal
def _preprocess_cad(obj: CADLike, **kwargs) -> CADCoreLike:
# Get the shape of a CAD-like object
obj = get_shape(obj)
# Convert Z-up (OCCT convention) to Y-up (GLTF convention)
if isinstance(obj, TopoDS_Shape):
obj = Shape(obj).rotate(Axis.X, -90).wrapped
elif isinstance(obj, TopLoc_Location):
tmp_location = Location(obj)
tmp_location.position = Vector(tmp_location.position.X, tmp_location.position.Z,
-tmp_location.position.Y)
tmp_location.orientation = Vector(tmp_location.orientation.X - 90, tmp_location.orientation.Y,
tmp_location.orientation.Z)
obj = tmp_location.wrapped
return obj
_find_var_name_count = 0
def _find_var_name(obj: any, avoid_levels: int = 2) -> str:
"""A hacky way to get a stable name for an object that may change over time"""
global _find_var_name_count
obj_shape = get_shape(obj, error=False) or obj
for frame in inspect.stack()[avoid_levels:]:
for key, value in frame.frame.f_locals.items():
if get_shape(value, error=False) is obj_shape:
return key
_find_var_name_count += 1
return 'unknown_var_' + str(_find_var_name_count)
def sizeof_fmt(num, suffix="B"):
for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"):
if abs(num) < 1024.0:
return f"{num:3.1f}{unit}{suffix}"
num /= 1024.0
return f"{num:.1f}Yi{suffix}"

530
yarn.lock
View File

@@ -275,143 +275,143 @@
resolved "https://registry.yarnpkg.com/@commander-js/extra-typings/-/extra-typings-11.1.0.tgz#dd19fcb8cc6e33ede237fc1b7af96c70833d8f93"
integrity sha512-GuvZ38d23H+7Tz2C9DhzCepivsOsky03s5NI+KCy7ke1FNUvsJ2oO47scQ9YaGGhgjgNW5OYYNSADmbjcSoIhw==
"@esbuild/aix-ppc64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.19.12.tgz#d1bc06aedb6936b3b6d313bf809a5a40387d2b7f"
integrity sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==
"@esbuild/aix-ppc64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz#a70f4ac11c6a1dfc18b8bbb13284155d933b9537"
integrity sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==
"@esbuild/android-arm64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.19.12.tgz#7ad65a36cfdb7e0d429c353e00f680d737c2aed4"
integrity sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==
"@esbuild/android-arm64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz#db1c9202a5bc92ea04c7b6840f1bbe09ebf9e6b9"
integrity sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==
"@esbuild/android-arm@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.19.12.tgz#b0c26536f37776162ca8bde25e42040c203f2824"
integrity sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==
"@esbuild/android-arm@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz#3b488c49aee9d491c2c8f98a909b785870d6e995"
integrity sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==
"@esbuild/android-x64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.19.12.tgz#cb13e2211282012194d89bf3bfe7721273473b3d"
integrity sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==
"@esbuild/android-x64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz#3b1628029e5576249d2b2d766696e50768449f98"
integrity sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==
"@esbuild/darwin-arm64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz#cbee41e988020d4b516e9d9e44dd29200996275e"
integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==
"@esbuild/darwin-arm64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz#6e8517a045ddd86ae30c6608c8475ebc0c4000bb"
integrity sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==
"@esbuild/darwin-x64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.19.12.tgz#e37d9633246d52aecf491ee916ece709f9d5f4cd"
integrity sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==
"@esbuild/darwin-x64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz#90ed098e1f9dd8a9381695b207e1cff45540a0d0"
integrity sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==
"@esbuild/freebsd-arm64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.19.12.tgz#1ee4d8b682ed363b08af74d1ea2b2b4dbba76487"
integrity sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==
"@esbuild/freebsd-arm64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz#d71502d1ee89a1130327e890364666c760a2a911"
integrity sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==
"@esbuild/freebsd-x64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.19.12.tgz#37a693553d42ff77cd7126764b535fb6cc28a11c"
integrity sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==
"@esbuild/freebsd-x64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz#aa5ea58d9c1dd9af688b8b6f63ef0d3d60cea53c"
integrity sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==
"@esbuild/linux-arm64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.19.12.tgz#be9b145985ec6c57470e0e051d887b09dddb2d4b"
integrity sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==
"@esbuild/linux-arm64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz#055b63725df678379b0f6db9d0fa85463755b2e5"
integrity sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==
"@esbuild/linux-arm@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.19.12.tgz#207ecd982a8db95f7b5279207d0ff2331acf5eef"
integrity sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==
"@esbuild/linux-arm@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz#76b3b98cb1f87936fbc37f073efabad49dcd889c"
integrity sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==
"@esbuild/linux-ia32@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.19.12.tgz#d0d86b5ca1562523dc284a6723293a52d5860601"
integrity sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==
"@esbuild/linux-ia32@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz#c0e5e787c285264e5dfc7a79f04b8b4eefdad7fa"
integrity sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==
"@esbuild/linux-loong64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.19.12.tgz#9a37f87fec4b8408e682b528391fa22afd952299"
integrity sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==
"@esbuild/linux-loong64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz#a6184e62bd7cdc63e0c0448b83801001653219c5"
integrity sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==
"@esbuild/linux-mips64el@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.19.12.tgz#4ddebd4e6eeba20b509d8e74c8e30d8ace0b89ec"
integrity sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==
"@esbuild/linux-mips64el@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz#d08e39ce86f45ef8fc88549d29c62b8acf5649aa"
integrity sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==
"@esbuild/linux-ppc64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.19.12.tgz#adb67dadb73656849f63cd522f5ecb351dd8dee8"
integrity sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==
"@esbuild/linux-ppc64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz#8d252f0b7756ffd6d1cbde5ea67ff8fd20437f20"
integrity sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==
"@esbuild/linux-riscv64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.19.12.tgz#11bc0698bf0a2abf8727f1c7ace2112612c15adf"
integrity sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==
"@esbuild/linux-riscv64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz#19f6dcdb14409dae607f66ca1181dd4e9db81300"
integrity sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==
"@esbuild/linux-s390x@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.19.12.tgz#e86fb8ffba7c5c92ba91fc3b27ed5a70196c3cc8"
integrity sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==
"@esbuild/linux-s390x@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz#3c830c90f1a5d7dd1473d5595ea4ebb920988685"
integrity sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==
"@esbuild/linux-x64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz#5f37cfdc705aea687dfe5dfbec086a05acfe9c78"
integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==
"@esbuild/linux-x64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz#86eca35203afc0d9de0694c64ec0ab0a378f6fff"
integrity sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==
"@esbuild/netbsd-x64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.19.12.tgz#29da566a75324e0d0dd7e47519ba2f7ef168657b"
integrity sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==
"@esbuild/netbsd-x64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz#e771c8eb0e0f6e1877ffd4220036b98aed5915e6"
integrity sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==
"@esbuild/openbsd-x64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.19.12.tgz#306c0acbdb5a99c95be98bdd1d47c916e7dc3ff0"
integrity sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==
"@esbuild/openbsd-x64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz#9a795ae4b4e37e674f0f4d716f3e226dd7c39baf"
integrity sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==
"@esbuild/sunos-x64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.19.12.tgz#0933eaab9af8b9b2c930236f62aae3fc593faf30"
integrity sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==
"@esbuild/sunos-x64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz#7df23b61a497b8ac189def6e25a95673caedb03f"
integrity sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==
"@esbuild/win32-arm64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.19.12.tgz#773bdbaa1971b36db2f6560088639ccd1e6773ae"
integrity sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==
"@esbuild/win32-arm64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz#f1ae5abf9ca052ae11c1bc806fb4c0f519bacf90"
integrity sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==
"@esbuild/win32-ia32@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.19.12.tgz#000516cad06354cc84a73f0943a4aa690ef6fd67"
integrity sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==
"@esbuild/win32-ia32@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz#241fe62c34d8e8461cd708277813e1d0ba55ce23"
integrity sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==
"@esbuild/win32-x64@0.19.12":
version "0.19.12"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.19.12.tgz#c57c8afbb4054a3ab8317591a0b7320360b444ae"
integrity sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==
"@esbuild/win32-x64@0.20.2":
version "0.20.2"
resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz#9c907b21e30a52db959ba4f80bb01a0cc403d5cc"
integrity sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==
"@gltf-transform/core@^3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@gltf-transform/core/-/core-3.10.0.tgz#854e7345f23971e4e7367a29183a2d1b62d45e46"
integrity sha512-NxVKhSWvH0j1tjZE8Yl461HUMyZLmYmqcbqHw0TOcQd5Q1SV7Y5w6W68XMt9/amRfMAiJLLNREE7kbr+Z0Ydbw==
"@gltf-transform/core@^3.10.1":
version "3.10.1"
resolved "https://registry.yarnpkg.com/@gltf-transform/core/-/core-3.10.1.tgz#d99c060b499482ed2c3304466405bf4c10939831"
integrity sha512-50OYemknGNxjBmiOM6iJp04JAu0bl9jvXJfN/gFt9QdJO02cPDcoXlTfSPJG6TVWDcfl0xPlsx1vybcbPVGFcQ==
dependencies:
property-graph "^1.3.1"
"@gltf-transform/extensions@^3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@gltf-transform/extensions/-/extensions-3.10.0.tgz#4ae11c3fe8e2a77e6e9dd04ebf0931c7b0cd3690"
integrity sha512-dz/cf2toBzP+w3ES2VgMiINCN6q86MVGu1lHkT0El4No77Bje9fnHVEPrKwaDCsXi5YXUiG/u6686vK6jePwDA==
"@gltf-transform/extensions@^3.10.1":
version "3.10.1"
resolved "https://registry.yarnpkg.com/@gltf-transform/extensions/-/extensions-3.10.1.tgz#71664389cae46fb12eb97dc71eb96d86a0d7801f"
integrity sha512-xUS9K5fMvW2dkYN4VzxHg2aBPG54M2WqgIjQ7RoSyybMoD7DsPUyMyVgRja+aiTVt/Bxza2ve7zJBD3+tN+aTA==
dependencies:
"@gltf-transform/core" "^3.10.0"
"@gltf-transform/core" "^3.10.1"
ktx-parse "^0.6.0"
"@gltf-transform/functions@^3.10.0":
version "3.10.0"
resolved "https://registry.yarnpkg.com/@gltf-transform/functions/-/functions-3.10.0.tgz#bf0331c109ac948d19be7394d3afcfae84215cfd"
integrity sha512-FStbDaH7t2z74RyEeUQn3aBcybULbDkt72ZasC0s7DwQ2DFKKKOth4Zksi4g9+8URNM6vNa2JSfuO851dkJHEg==
"@gltf-transform/functions@^3.10.1":
version "3.10.1"
resolved "https://registry.yarnpkg.com/@gltf-transform/functions/-/functions-3.10.1.tgz#c40817740241c0ee770f4d1210ccc766e46d8ab2"
integrity sha512-Zs6+1qvTD9w40R5qv70E4wJXXacNQ46ZxjKKW6dmfGIyjT8bsSJmV3Tdj+WJ8R6lWXXZ8e2p3ZvAUfPDEG73bQ==
dependencies:
"@gltf-transform/core" "^3.10.0"
"@gltf-transform/extensions" "^3.10.0"
"@gltf-transform/core" "^3.10.1"
"@gltf-transform/extensions" "^3.10.1"
ktx-parse "^0.6.0"
ndarray "^1.0.19"
ndarray-lanczos "^0.3.0"
@@ -668,70 +668,70 @@
resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33"
integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==
"@rollup/rollup-android-arm-eabi@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.12.0.tgz#38c3abd1955a3c21d492af6b1a1dca4bb1d894d6"
integrity sha512-+ac02NL/2TCKRrJu2wffk1kZ+RyqxVUlbjSagNgPm94frxtr+XDL12E5Ll1enWskLrtrZ2r8L3wED1orIibV/w==
"@rollup/rollup-android-arm-eabi@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.13.0.tgz#b98786c1304b4ff8db3a873180b778649b5dff2b"
integrity sha512-5ZYPOuaAqEH/W3gYsRkxQATBW3Ii1MfaT4EQstTnLKViLi2gLSQmlmtTpGucNP3sXEpOiI5tdGhjdE111ekyEg==
"@rollup/rollup-android-arm64@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.12.0.tgz#3822e929f415627609e53b11cec9a4be806de0e2"
integrity sha512-OBqcX2BMe6nvjQ0Nyp7cC90cnumt8PXmO7Dp3gfAju/6YwG0Tj74z1vKrfRz7qAv23nBcYM8BCbhrsWqO7PzQQ==
"@rollup/rollup-android-arm64@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.13.0.tgz#8833679af11172b1bf1ab7cb3bad84df4caf0c9e"
integrity sha512-BSbaCmn8ZadK3UAQdlauSvtaJjhlDEjS5hEVVIN3A4bbl3X+otyf/kOJV08bYiRxfejP3DXFzO2jz3G20107+Q==
"@rollup/rollup-darwin-arm64@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz#6c082de71f481f57df6cfa3701ab2a7afde96f69"
integrity sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==
"@rollup/rollup-darwin-arm64@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.13.0.tgz#ef02d73e0a95d406e0eb4fd61a53d5d17775659b"
integrity sha512-Ovf2evVaP6sW5Ut0GHyUSOqA6tVKfrTHddtmxGQc1CTQa1Cw3/KMCDEEICZBbyppcwnhMwcDce9ZRxdWRpVd6g==
"@rollup/rollup-darwin-x64@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.12.0.tgz#c34ca0d31f3c46a22c9afa0e944403eea0edcfd8"
integrity sha512-cc71KUZoVbUJmGP2cOuiZ9HSOP14AzBAThn3OU+9LcA1+IUqswJyR1cAJj3Mg55HbjZP6OLAIscbQsQLrpgTOg==
"@rollup/rollup-darwin-x64@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.13.0.tgz#3ce5b9bcf92b3341a5c1c58a3e6bcce0ea9e7455"
integrity sha512-U+Jcxm89UTK592vZ2J9st9ajRv/hrwHdnvyuJpa5A2ngGSVHypigidkQJP+YiGL6JODiUeMzkqQzbCG3At81Gg==
"@rollup/rollup-linux-arm-gnueabihf@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.12.0.tgz#48e899c1e438629c072889b824a98787a7c2362d"
integrity sha512-a6w/Y3hyyO6GlpKL2xJ4IOh/7d+APaqLYdMf86xnczU3nurFTaVN9s9jOXQg97BE4nYm/7Ga51rjec5nfRdrvA==
"@rollup/rollup-linux-arm-gnueabihf@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.13.0.tgz#3d3d2c018bdd8e037c6bfedd52acfff1c97e4be4"
integrity sha512-8wZidaUJUTIR5T4vRS22VkSMOVooG0F4N+JSwQXWSRiC6yfEsFMLTYRFHvby5mFFuExHa/yAp9juSphQQJAijQ==
"@rollup/rollup-linux-arm64-gnu@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.12.0.tgz#788c2698a119dc229062d40da6ada8a090a73a68"
integrity sha512-0fZBq27b+D7Ar5CQMofVN8sggOVhEtzFUwOwPppQt0k+VR+7UHMZZY4y+64WJ06XOhBTKXtQB/Sv0NwQMXyNAA==
"@rollup/rollup-linux-arm64-gnu@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.13.0.tgz#5fc8cc978ff396eaa136d7bfe05b5b9138064143"
integrity sha512-Iu0Kno1vrD7zHQDxOmvweqLkAzjxEVqNhUIXBsZ8hu8Oak7/5VTPrxOEZXYC1nmrBVJp0ZcL2E7lSuuOVaE3+w==
"@rollup/rollup-linux-arm64-musl@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.12.0.tgz#3882a4e3a564af9e55804beeb67076857b035ab7"
integrity sha512-eTvzUS3hhhlgeAv6bfigekzWZjaEX9xP9HhxB0Dvrdbkk5w/b+1Sxct2ZuDxNJKzsRStSq1EaEkVSEe7A7ipgQ==
"@rollup/rollup-linux-arm64-musl@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.13.0.tgz#f2ae7d7bed416ffa26d6b948ac5772b520700eef"
integrity sha512-C31QrW47llgVyrRjIwiOwsHFcaIwmkKi3PCroQY5aVq4H0A5v/vVVAtFsI1nfBngtoRpeREvZOkIhmRwUKkAdw==
"@rollup/rollup-linux-riscv64-gnu@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.12.0.tgz#0c6ad792e1195c12bfae634425a3d2aa0fe93ab7"
integrity sha512-ix+qAB9qmrCRiaO71VFfY8rkiAZJL8zQRXveS27HS+pKdjwUfEhqo2+YF2oI+H/22Xsiski+qqwIBxVewLK7sw==
"@rollup/rollup-linux-riscv64-gnu@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.13.0.tgz#303d57a328ee9a50c85385936f31cf62306d30b6"
integrity sha512-Oq90dtMHvthFOPMl7pt7KmxzX7E71AfyIhh+cPhLY9oko97Zf2C9tt/XJD4RgxhaGeAraAXDtqxvKE1y/j35lA==
"@rollup/rollup-linux-x64-gnu@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz#9d62485ea0f18d8674033b57aa14fb758f6ec6e3"
integrity sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==
"@rollup/rollup-linux-x64-gnu@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.13.0.tgz#f672f6508f090fc73f08ba40ff76c20b57424778"
integrity sha512-yUD/8wMffnTKuiIsl6xU+4IA8UNhQ/f1sAnQebmE/lyQ8abjsVyDkyRkWop0kdMhKMprpNIhPmYlCxgHrPoXoA==
"@rollup/rollup-linux-x64-musl@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz#50e8167e28b33c977c1f813def2b2074d1435e05"
integrity sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==
"@rollup/rollup-linux-x64-musl@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.13.0.tgz#d2f34b1b157f3e7f13925bca3288192a66755a89"
integrity sha512-9RyNqoFNdF0vu/qqX63fKotBh43fJQeYC98hCaf89DYQpv+xu0D8QFSOS0biA7cGuqJFOc1bJ+m2rhhsKcw1hw==
"@rollup/rollup-win32-arm64-msvc@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.12.0.tgz#68d233272a2004429124494121a42c4aebdc5b8e"
integrity sha512-JPDxovheWNp6d7AHCgsUlkuCKvtu3RB55iNEkaQcf0ttsDU/JZF+iQnYcQJSk/7PtT4mjjVG8N1kpwnI9SLYaw==
"@rollup/rollup-win32-arm64-msvc@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.13.0.tgz#8ffecc980ae4d9899eb2f9c4ae471a8d58d2da6b"
integrity sha512-46ue8ymtm/5PUU6pCvjlic0z82qWkxv54GTJZgHrQUuZnVH+tvvSP0LsozIDsCBFO4VjJ13N68wqrKSeScUKdA==
"@rollup/rollup-win32-ia32-msvc@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.12.0.tgz#366ca62221d1689e3b55a03f4ae12ae9ba595d40"
integrity sha512-fjtuvMWRGJn1oZacG8IPnzIV6GF2/XG+h71FKn76OYFqySXInJtseAqdprVTDTyqPxQOG9Exak5/E9Z3+EJ8ZA==
"@rollup/rollup-win32-ia32-msvc@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.13.0.tgz#a7505884f415662e088365b9218b2b03a88fc6f2"
integrity sha512-P5/MqLdLSlqxbeuJ3YDeX37srC8mCflSyTrUsgbU1c/U9j6l2g2GiIdYaGD9QjdMQPMSgYm7hgg0551wHyIluw==
"@rollup/rollup-win32-x64-msvc@4.12.0":
version "4.12.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.12.0.tgz#9ffdf9ed133a7464f4ae187eb9e1294413fab235"
integrity sha512-ZYmr5mS2wd4Dew/JjT0Fqi2NPB/ZhZ2VvPp7SmvPZb4Y1CG/LRcS6tcRo2cYU7zLK5A7cdbhWnnWmUjoI4qapg==
"@rollup/rollup-win32-x64-msvc@4.13.0":
version "4.13.0"
resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.13.0.tgz#6abd79db7ff8d01a58865ba20a63cfd23d9e2a10"
integrity sha512-UKXUQNbO3DOhzLRwHSpa0HnhhCgNODvfoPWv2FCXme8N/ANFfhIPMGuOT+QuKd16+B5yxZ0HdpNlqPvTMS1qfw==
"@sigstore/bundle@^2.2.0":
version "2.2.0"
@@ -777,10 +777,10 @@
"@sigstore/core" "^1.0.0"
"@sigstore/protobuf-specs" "^0.3.0"
"@tsconfig/node20@^20.1.2":
version "20.1.2"
resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.2.tgz#b93128c411d38e9507035255195bc8a6718541e3"
integrity sha512-madaWq2k+LYMEhmcp0fs+OGaLFk0OenpHa4gmI4VEmCKX4PJntQ6fnnGADVFrVkBj0wIdAlQnK/MrlYTHsa1gQ==
"@tsconfig/node20@^20.1.4":
version "20.1.4"
resolved "https://registry.yarnpkg.com/@tsconfig/node20/-/node20-20.1.4.tgz#3457d42eddf12d3bde3976186ab0cd22b85df928"
integrity sha512-sqgsT69YFeLWf5NtJ4Xq/xAF8p4ZQHlmGW74Nu2tD4+g5fAsposc4ZfaaPixVu4y01BEiDCWLRDCvDM5JOsRxg==
"@tufjs/canonical-json@2.0.0":
version "2.0.0"
@@ -805,10 +805,10 @@
resolved "https://registry.yarnpkg.com/@types/ndarray/-/ndarray-1.0.14.tgz#96b28c09a3587a76de380243f87bb7a2d63b4b23"
integrity sha512-oANmFZMnFQvb219SSBIhI1Ih/r4CvHDOzkWyJS/XRqkMrGH5/kaPSA1hQhdIBzouaE+5KpE/f5ylI9cujmckQg==
"@types/node@^20.11.24":
version "20.11.24"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.24.tgz#cc207511104694e84e9fb17f9a0c4c42d4517792"
integrity sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==
"@types/node@^20.12.2":
version "20.12.2"
resolved "https://registry.yarnpkg.com/@types/node/-/node-20.12.2.tgz#9facdd11102f38b21b4ebedd9d7999663343d72e"
integrity sha512-zQ0NYO87hyN6Xrclcqp7f8ZbXNbRfoGWNcMvHTPQp9UUrwI0mI7XBz+cu7/W6/VClYo2g63B0cjull/srU7LgQ==
dependencies:
undici-types "~5.26.4"
@@ -851,26 +851,26 @@
resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz#508d6a0f2440f86945835d903fcc0d95d1bb8a37"
integrity sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ==
"@volar/language-core@2.1.0", "@volar/language-core@~2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.1.0.tgz#26953a62f5d956a4ba4003faf59ae09b2a8aabb6"
integrity sha512-BrYEgYHx92ocpt1OUxJs2x3TAXEjpPLxsQoARb96g2GdF62xnfRQUqCNBwiU7Z3MQ/0tOAdqdHNYNmrFtx6q4A==
"@volar/language-core@2.1.5", "@volar/language-core@~2.1.3":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@volar/language-core/-/language-core-2.1.5.tgz#78803531562ca423a3b4fa1b3a90d33537aab928"
integrity sha512-u1OHmVkCFsJqNdaM2GKuMhE67TxcEnOqJNF+VtYv2Ji8DnrUaF4FAFSNxY+MRGICl+873CsSJVKas9TQtW14LA==
dependencies:
"@volar/source-map" "2.1.0"
"@volar/source-map" "2.1.5"
"@volar/source-map@2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.1.0.tgz#f8c70b5043ae4a3d2cbd66a84036ef030b655a8e"
integrity sha512-VPyi+DTv67cvUOkUewzsOQJY3VUhjOjQxigT487z/H7tEI8ZFd5RksC5afk3JelOK+a/3Y8LRDbKmYKu1dz87g==
"@volar/source-map@2.1.5":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@volar/source-map/-/source-map-2.1.5.tgz#c073549368d399365f0024ed89aea93de0807328"
integrity sha512-GIkAM6fHgDcTXcdH4i10fAiAZzO0HLIer8/pt3oZ9A0n7n4R5d1b2F8Xxzh/pgmgNoL+SrHX3MFxs35CKgfmtA==
dependencies:
muggle-string "^0.4.0"
"@volar/typescript@~2.1.0":
version "2.1.0"
resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.1.0.tgz#640abcdcb6b822f9860006d090e1d5252c655e37"
integrity sha512-2cicVoW4q6eU/omqfOBv+6r9JdrF5bBelujbJhayPNKiOj/xwotSJ/DM8IeMvTZvtkOZkm6suyOCLEokLY0w2w==
"@volar/typescript@~2.1.3":
version "2.1.5"
resolved "https://registry.yarnpkg.com/@volar/typescript/-/typescript-2.1.5.tgz#756a8626af1c5ce3c9d8df3e79d49bfdd0adf39e"
integrity sha512-zo9a3NrNMSkufIvHuExDGTfYv+zO7C5p2wg8fyP7vcqF/Qo0ztjb0ZfOgq/A85EO/MBc1Kj2Iu7PaOBtP++NMw==
dependencies:
"@volar/language-core" "2.1.0"
"@volar/language-core" "2.1.5"
path-browserify "^1.0.1"
"@vue/babel-helper-vue-transform-on@1.2.1":
@@ -948,12 +948,12 @@
"@vue/compiler-dom" "3.4.21"
"@vue/shared" "3.4.21"
"@vue/language-core@2.0.3":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.0.3.tgz#49e290c928b216a5b0f07012ff6e1065a6e15258"
integrity sha512-hnVF/Q3cD2v+EFD4pD1YdITGBcdM38P18SYqilVQDezKw5RobWny4BwIckWGS1fJmUstsO9mTX30ZOyzyR2Q+Q==
"@vue/language-core@2.0.7":
version "2.0.7"
resolved "https://registry.yarnpkg.com/@vue/language-core/-/language-core-2.0.7.tgz#af12f752a93c4d2498626fca33f5d1ddc8c5ceb9"
integrity sha512-Vh1yZX3XmYjn9yYLkjU8DN6L0ceBtEcapqiyclHne8guG84IaTzqtvizZB1Yfxm3h6m7EIvjerLO5fvOZO6IIQ==
dependencies:
"@volar/language-core" "~2.1.0"
"@volar/language-core" "~2.1.3"
"@vue/compiler-dom" "^3.4.0"
"@vue/shared" "^3.4.0"
computeds "^0.0.1"
@@ -1481,34 +1481,34 @@ error-ex@^1.3.1:
dependencies:
is-arrayish "^0.2.1"
esbuild@^0.19.3:
version "0.19.12"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.19.12.tgz#dc82ee5dc79e82f5a5c3b4323a2a641827db3e04"
integrity sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==
esbuild@^0.20.1:
version "0.20.2"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.20.2.tgz#9d6b2386561766ee6b5a55196c6d766d28c87ea1"
integrity sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==
optionalDependencies:
"@esbuild/aix-ppc64" "0.19.12"
"@esbuild/android-arm" "0.19.12"
"@esbuild/android-arm64" "0.19.12"
"@esbuild/android-x64" "0.19.12"
"@esbuild/darwin-arm64" "0.19.12"
"@esbuild/darwin-x64" "0.19.12"
"@esbuild/freebsd-arm64" "0.19.12"
"@esbuild/freebsd-x64" "0.19.12"
"@esbuild/linux-arm" "0.19.12"
"@esbuild/linux-arm64" "0.19.12"
"@esbuild/linux-ia32" "0.19.12"
"@esbuild/linux-loong64" "0.19.12"
"@esbuild/linux-mips64el" "0.19.12"
"@esbuild/linux-ppc64" "0.19.12"
"@esbuild/linux-riscv64" "0.19.12"
"@esbuild/linux-s390x" "0.19.12"
"@esbuild/linux-x64" "0.19.12"
"@esbuild/netbsd-x64" "0.19.12"
"@esbuild/openbsd-x64" "0.19.12"
"@esbuild/sunos-x64" "0.19.12"
"@esbuild/win32-arm64" "0.19.12"
"@esbuild/win32-ia32" "0.19.12"
"@esbuild/win32-x64" "0.19.12"
"@esbuild/aix-ppc64" "0.20.2"
"@esbuild/android-arm" "0.20.2"
"@esbuild/android-arm64" "0.20.2"
"@esbuild/android-x64" "0.20.2"
"@esbuild/darwin-arm64" "0.20.2"
"@esbuild/darwin-x64" "0.20.2"
"@esbuild/freebsd-arm64" "0.20.2"
"@esbuild/freebsd-x64" "0.20.2"
"@esbuild/linux-arm" "0.20.2"
"@esbuild/linux-arm64" "0.20.2"
"@esbuild/linux-ia32" "0.20.2"
"@esbuild/linux-loong64" "0.20.2"
"@esbuild/linux-mips64el" "0.20.2"
"@esbuild/linux-ppc64" "0.20.2"
"@esbuild/linux-riscv64" "0.20.2"
"@esbuild/linux-s390x" "0.20.2"
"@esbuild/linux-x64" "0.20.2"
"@esbuild/netbsd-x64" "0.20.2"
"@esbuild/openbsd-x64" "0.20.2"
"@esbuild/sunos-x64" "0.20.2"
"@esbuild/win32-arm64" "0.20.2"
"@esbuild/win32-ia32" "0.20.2"
"@esbuild/win32-x64" "0.20.2"
escalade@^3.1.1:
version "3.1.2"
@@ -2410,14 +2410,14 @@ postcss-selector-parser@^6.0.10:
cssesc "^3.0.0"
util-deprecate "^1.0.2"
postcss@^8.4.35:
version "8.4.35"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.35.tgz#60997775689ce09011edf083a549cea44aabe2f7"
integrity sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==
postcss@^8.4.35, postcss@^8.4.38:
version "8.4.38"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.38.tgz#b387d533baf2054288e337066d81c6bee9db9e0e"
integrity sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==
dependencies:
nanoid "^3.3.7"
picocolors "^1.0.0"
source-map-js "^1.0.2"
source-map-js "^1.2.0"
prebuild-install@^7.1.1:
version "7.1.2"
@@ -2551,26 +2551,26 @@ retry@^0.12.0:
resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b"
integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==
rollup@^4.2.0:
version "4.12.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.12.0.tgz#0b6d1e5f3d46bbcf244deec41a7421dc54cc45b5"
integrity sha512-wz66wn4t1OHIJw3+XU7mJJQV/2NAfw5OAk6G6Hoo3zcvz/XOfQ52Vgi+AN4Uxoxi0KBBwk2g8zPrTDA4btSB/Q==
rollup@^4.13.0:
version "4.13.0"
resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.13.0.tgz#dd2ae144b4cdc2ea25420477f68d4937a721237a"
integrity sha512-3YegKemjoQnYKmsBlOHfMLVPPA5xLkQ8MHLLSw/fBrFaVkEayL51DilPpNNLq1exr98F2B1TzrV0FUlN3gWRPg==
dependencies:
"@types/estree" "1.0.5"
optionalDependencies:
"@rollup/rollup-android-arm-eabi" "4.12.0"
"@rollup/rollup-android-arm64" "4.12.0"
"@rollup/rollup-darwin-arm64" "4.12.0"
"@rollup/rollup-darwin-x64" "4.12.0"
"@rollup/rollup-linux-arm-gnueabihf" "4.12.0"
"@rollup/rollup-linux-arm64-gnu" "4.12.0"
"@rollup/rollup-linux-arm64-musl" "4.12.0"
"@rollup/rollup-linux-riscv64-gnu" "4.12.0"
"@rollup/rollup-linux-x64-gnu" "4.12.0"
"@rollup/rollup-linux-x64-musl" "4.12.0"
"@rollup/rollup-win32-arm64-msvc" "4.12.0"
"@rollup/rollup-win32-ia32-msvc" "4.12.0"
"@rollup/rollup-win32-x64-msvc" "4.12.0"
"@rollup/rollup-android-arm-eabi" "4.13.0"
"@rollup/rollup-android-arm64" "4.13.0"
"@rollup/rollup-darwin-arm64" "4.13.0"
"@rollup/rollup-darwin-x64" "4.13.0"
"@rollup/rollup-linux-arm-gnueabihf" "4.13.0"
"@rollup/rollup-linux-arm64-gnu" "4.13.0"
"@rollup/rollup-linux-arm64-musl" "4.13.0"
"@rollup/rollup-linux-riscv64-gnu" "4.13.0"
"@rollup/rollup-linux-x64-gnu" "4.13.0"
"@rollup/rollup-linux-x64-musl" "4.13.0"
"@rollup/rollup-win32-arm64-msvc" "4.13.0"
"@rollup/rollup-win32-ia32-msvc" "4.13.0"
"@rollup/rollup-win32-x64-msvc" "4.13.0"
fsevents "~2.3.2"
safe-buffer@^5.0.1, safe-buffer@~5.2.0:
@@ -2701,6 +2701,11 @@ source-map-js@^1.0.2:
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
source-map-js@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.0.tgz#16b809c162517b5b8c3e7dcd315a2a5c2612b2af"
integrity sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==
source-map-support@~0.5.20:
version "0.5.21"
resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
@@ -2878,16 +2883,21 @@ tar@^6.1.11, tar@^6.1.2:
mkdirp "^1.0.3"
yallist "^4.0.0"
terser@^5.28.1:
version "5.28.1"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.28.1.tgz#bf00f7537fd3a798c352c2d67d67d65c915d1b28"
integrity sha512-wM+bZp54v/E9eRRGXb5ZFDvinrJIOaTapx3WUokyVGZu5ucVCK55zEgGd5Dl2fSr3jUo5sDiERErUWLY6QPFyA==
terser@^5.30.0:
version "5.30.0"
resolved "https://registry.yarnpkg.com/terser/-/terser-5.30.0.tgz#64cb2af71e16ea3d32153f84d990f9be0cdc22bf"
integrity sha512-Y/SblUl5kEyEFzhMAQdsxVHh+utAxd4IuRNJzKywY/4uzSogh3G219jqbDDxYu4MXO9CzY3tSEqmZvW6AoEDJw==
dependencies:
"@jridgewell/source-map" "^0.3.3"
acorn "^8.8.2"
commander "^2.20.0"
source-map-support "~0.5.20"
three-mesh-bvh@^0.7.3:
version "0.7.3"
resolved "https://registry.yarnpkg.com/three-mesh-bvh/-/three-mesh-bvh-0.7.3.tgz#91f2d7e26f230288b5b0a6bdf41bdd9620348945"
integrity sha512-3W6KjzmupjfE89GuHPT31kxKWZ4YGZPEZJNysJpiOZfQRsBQQgmK7v/VJPpjG6syhAvTnY+5Fr77EvIkTLpGSw==
"three-orientation-gizmo@https://github.com/jrj2211/three-orientation-gizmo":
version "1.1.0"
resolved "https://github.com/jrj2211/three-orientation-gizmo#000281f0559c316f72cdd23a1885d63ae6901095"
@@ -2935,10 +2945,10 @@ tunnel-agent@^0.6.0:
dependencies:
safe-buffer "^5.0.1"
typescript@~5.3.0:
version "5.3.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37"
integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==
typescript@~5.4.3:
version "5.4.3"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.4.3.tgz#5c6fedd4c87bee01cd7a528a30145521f8e0feff"
integrity sha512-KrPd3PKaCLr78MalgiwJnA25Nm8HAmdwN3mYUYZgG/wizIo9EainNVQI9/yDavtVFRN2h3k8uf3GLHuhDMgEHg==
undici-types@~5.26.4:
version "5.26.5"
@@ -2992,14 +3002,14 @@ validate-npm-package-name@^5.0.0:
dependencies:
builtins "^5.0.0"
vite@^5.0.11:
version "5.1.4"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.1.4.tgz#14e9d3e7a6e488f36284ef13cebe149f060bcfb6"
integrity sha512-n+MPqzq+d9nMVTKyewqw6kSt+R3CkvF9QAKY8obiQn8g1fwTscKxyfaYnC632HtBXAQGc1Yjomphwn1dtwGAHg==
vite@^5.2.7:
version "5.2.7"
resolved "https://registry.yarnpkg.com/vite/-/vite-5.2.7.tgz#e1b8a985eb54fcb9467d7f7f009d87485016df6e"
integrity sha512-k14PWOKLI6pMaSzAuGtT+Cf0YmIx12z9YGon39onaJNy8DLBfBJrzg9FQEmkAM5lpHBZs9wksWAsyF/HkpEwJA==
dependencies:
esbuild "^0.19.3"
postcss "^8.4.35"
rollup "^4.2.0"
esbuild "^0.20.1"
postcss "^8.4.38"
rollup "^4.13.0"
optionalDependencies:
fsevents "~2.3.3"
@@ -3011,13 +3021,13 @@ vue-template-compiler@^2.7.14:
de-indent "^1.0.2"
he "^1.2.0"
vue-tsc@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-2.0.3.tgz#9b736f6ad478a5c98a23aeef509eb0b73d115b26"
integrity sha512-aMJqbgLiKDAwAglWqMoGf1Ez6Wwqhlk2MDxEjFGziiLW0A+tHOWE1+YQJZQ1Vm6zaENPA2KJAubFhaR988UvGg==
vue-tsc@^2.0.7:
version "2.0.7"
resolved "https://registry.yarnpkg.com/vue-tsc/-/vue-tsc-2.0.7.tgz#3177a2fe720bfa7355d3717929ee8c8d132bc5d0"
integrity sha512-LYa0nInkfcDBB7y8jQ9FQ4riJTRNTdh98zK/hzt4gEpBZQmf30dPhP+odzCa+cedGz6B/guvJEd0BavZaRptjg==
dependencies:
"@volar/typescript" "~2.1.0"
"@vue/language-core" "2.0.3"
"@volar/typescript" "~2.1.3"
"@vue/language-core" "2.0.7"
semver "^7.5.4"
vue@^3.4.21:
@@ -3031,10 +3041,10 @@ vue@^3.4.21:
"@vue/server-renderer" "3.4.21"
"@vue/shared" "3.4.21"
vuetify@^3.5.7:
version "3.5.7"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.5.7.tgz#9dfa027a582aa7d2211c8019c33ef7cadd66c5c0"
integrity sha512-BFj/puY8odRwY50pRfE1gpawnxreY8PtPb/tDw3oumxSLXhoXw8q6YLA6QUvqZrYEzcYpojxZIYhNWUky2KN1w==
vuetify@^3.5.13:
version "3.5.13"
resolved "https://registry.yarnpkg.com/vuetify/-/vuetify-3.5.13.tgz#24a45d19ce5dcf71b2653f0bcf3ea91edf1f406c"
integrity sha512-3ZyIoHgB2GR87ojIpqNwkkRXlUNTEKh83fjUuQ1hOKdTXzEuZXBgtfUt9kp4WOVnYILGdZKWTJ6gv8nXOa/tZA==
walk-up-path@^3.0.1:
version "3.0.1"