squash commits
This commit is contained in:
commit
0f545c5e4d
611 files changed
+272565
No files matched your search
@@ -0,0 +1,8 @@
|
||||
node_modules/*
|
||||
shared-assets
|
||||
dist/*
|
||||
lib/*
|
||||
**/*.sw*
|
||||
.DS_Store
|
||||
.idea
|
||||
*.iml
|
||||
@@ -0,0 +1,93 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of
|
||||
experience, education, socio-economic status, nationality, personal appearance,
|
||||
race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, or to ban temporarily or permanently any
|
||||
contributor for other behaviors that they deem inappropriate, threatening,
|
||||
offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
This Code of Conduct also applies outside the project spaces when the Project
|
||||
Steward has a reasonable belief that an individual's behavior may have a
|
||||
negative impact on the project or its community.
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
We do not believe that all conflict is bad; healthy debate and disagreement
|
||||
often yield positive results. However, it is never okay to be disrespectful or
|
||||
to engage in behavior that violates the project’s code of conduct.
|
||||
|
||||
If you see someone violating the code of conduct, you are encouraged to address
|
||||
the behavior directly with those involved. Many issues can be resolved quickly
|
||||
and easily, and this gives people more control over the outcome of their
|
||||
dispute. If you are unable to resolve the matter for any reason, or if the
|
||||
behavior is threatening or harassing, report it. We are dedicated to providing
|
||||
an environment where participants feel welcome and safe.
|
||||
|
||||
Reports should be directed to Matt Small (mbsmall@google.com), the
|
||||
Project Steward(s) for model-viewer. It is the Project Steward’s duty to
|
||||
receive and address reported violations of the code of conduct. They will then
|
||||
work with a committee consisting of representatives from the Open Source
|
||||
Programs Office and the Google Open Source Strategy team. If for any reason you
|
||||
are uncomfortable reaching out the Project Steward, please email
|
||||
opensource@google.com.
|
||||
|
||||
We will investigate every complaint, but you may not receive a direct response.
|
||||
We will use our discretion in determining when and how to follow up on reported
|
||||
incidents, which may range from not taking action to permanent expulsion from
|
||||
the project and project-sponsored spaces. We will notify the accused of the
|
||||
report and provide them an opportunity to discuss it before any action is taken.
|
||||
The identity of the reporter will be omitted from the details of the report
|
||||
supplied to the accused. In potentially harmful situations, such as ongoing
|
||||
harassment or threats to anyone's safety, we may take action without notice.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 1.4,
|
||||
available at
|
||||
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,99 @@
|
||||
# `<model-viewer-effects>`
|
||||
|
||||

|
||||

|
||||
|
||||
`<model-viewer-effects>` is a web component library addon for `<model-viewer>` that makes adding post-processing
|
||||
effects to your models easy to do, on as many browsers and devices as possible.
|
||||
|
||||
`<model-viewer-effects>` strives to give you great defaults for rendering quality and
|
||||
performance.
|
||||
|
||||

|
||||
|
||||
[Examples](https://modelviewer.dev/examples/postprocessing/) • [Documentation](https://modelviewer.dev/docs/mve)
|
||||
## Usage
|
||||
Using effects is as simple as adding the `<effect-composer>` inside your `<model-viewer>`, and placing any effects inside the composer component.
|
||||
|
||||
```html
|
||||
<model-viewer src="...">
|
||||
<effect-composer>
|
||||
<bloom-effect></bloom-effect>
|
||||
</effect-composer>
|
||||
</model-viewer>
|
||||
```
|
||||
|
||||
### PostProcessing
|
||||
`<model-viewer-effects>` uses the [postprocessing](https://github.com/pmndrs/postprocessing) library under the hood, for its superior [performance](https://github.com/pmndrs/postprocessing#performance) and support.
|
||||
|
||||
In addition to the built-in effects wrapped by this library, you can add any custom effects/passes that follow the [postprocessing spec](https://github.com/pmndrs/postprocessing/wiki/Custom-Passes).
|
||||
|
||||
### *XR Support*
|
||||
The effects are not supported in the `<model-viewer>` XR modes, which will render as usual.
|
||||
|
||||
## Installing
|
||||
### NPM
|
||||
|
||||
The `<model-viewer-effects>` library can be installed from [NPM](https://npmjs.org):
|
||||
|
||||
```sh
|
||||
npm install three @google/model-viewer @google/model-viewer-effects
|
||||
```
|
||||
|
||||
### HTML
|
||||
|
||||
`<model-viewer-effects>` and `<model-viewer>` share a [Three.js](https://threejs.org/) dependency. In order to avoid version conflicts, you should bring Three through an `import-map`:
|
||||
|
||||
```html
|
||||
<!-- ES-Shims for older browser compatibility -->
|
||||
<script async src="https://ga.jspm.io/npm:[email protected]/dist/es-module-shims.js"></script>
|
||||
|
||||
<!-- Import Three.js using an import-map -->
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"three": "https://cdn.jsdelivr.net/npm/three@^0.172.0/build/three.module.min.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
You should then bring the `module` version of `<model-viewer>`, along with `<model-viewer-effects>` from your favourite CDN, such as [jsDelivr](https://www.jsdelivr.com/package/npm/@google/model-viewer):
|
||||
|
||||
|
||||
```html
|
||||
<script type="module" src=" https://cdn.jsdelivr.net/npm/@google/model-viewer/dist/model-viewer-module.min.js "></script>
|
||||
|
||||
<script type="module" src=" https://cdn.jsdelivr.net/npm/@google/model-viewer-effects/dist/model-viewer-effects.min.js "></script>
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
`<model-viewer-effects>` is supported on the last 2 major versions of all evergreen
|
||||
desktop and mobile browsers, and on all platforms (Android, IOS, MacOS, Windows, Linux).
|
||||
|
||||
| | <img src="https://github.com/alrra/browser-logos/raw/master/src/chrome/chrome_32x32.png" width="16"> Chrome | <img src="https://github.com/alrra/browser-logos/raw/master/src/firefox/firefox_32x32.png" width="16"> Firefox | <img src="https://github.com/alrra/browser-logos/raw/master/src/safari/safari_32x32.png" width="16"> Safari | <img src="https://github.com/alrra/browser-logos/raw/master/src/edge/edge_32x32.png" width="16"> Edge |
|
||||
| -------- | --- | --- | --- | --- |
|
||||
| Desktop | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mobile | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
`<model-viewer-effects>` builds upon standard web platform APIs so that the performance,
|
||||
capabilities and compatibility of the library get better as the web evolves.
|
||||
|
||||
## Development
|
||||
|
||||
To get started, follow the instructions in [the main README.md file](../../README.md).
|
||||
|
||||
The following commands are available when developing `<model-viewer-effects>`:
|
||||
|
||||
Command | Description
|
||||
------------------------------- | -----------
|
||||
`npm run build` | Builds all `<model-viewer-effects>` distributable files
|
||||
`npm run build:dev` | Builds a subset of distributable files (faster than `npm run build`)
|
||||
`npm run test` | Run `<model-viewer-effects>` unit tests
|
||||
`npm run clean` | Deletes all build artifacts
|
||||
`npm run dev` | Starts `tsc` and `rollup` in "watch" mode, causing artifacts to automatically rebuild upon incremental changes
|
||||
|
||||
|
||||
----
|
||||
*Rocket Ship by Daniel Melchior [CC-BY](https://creativecommons.org/licenses/by/3.0/) via [Poly Pizza](https://poly.pizza/m/9dyJn4gp7U8)*
|
||||
@@ -0,0 +1,110 @@
|
||||
{
|
||||
"name": "@google/model-viewer-effects",
|
||||
"type": "module",
|
||||
"version": "1.4.0",
|
||||
"description": "Easily add and combine post-processing effects with <model-viewer>!",
|
||||
"repository": "https://github.com/google/model-viewer/tree/master/packages/model-viewer-effects",
|
||||
"bugs": {
|
||||
"url": "https://github.com/google/model-viewer/issues"
|
||||
},
|
||||
"homepage": "https://github.com/google/model-viewer/tree/master/packages/model-viewer-effects#readme",
|
||||
"contributors": [
|
||||
"Adam Beili <[email protected]>"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
},
|
||||
"main": "dist/model-viewer-effects.min.js",
|
||||
"module": "lib/model-viewer-effects.js",
|
||||
"files": [
|
||||
"src",
|
||||
"lib",
|
||||
"dist/model-viewer-effects.js",
|
||||
"dist/model-viewer-effects.js.map",
|
||||
"dist/model-viewer-effects.min.js",
|
||||
"dist/model-viewer-effects.min.js.map",
|
||||
"dist/model-viewer-effects-umd.js",
|
||||
"dist/model-viewer-effects-umd.js.map",
|
||||
"dist/model-viewer-effects-umd.min.js",
|
||||
"dist/model-viewer-effects-umd.min.js.map",
|
||||
"dist/model-viewer-effects.d.ts"
|
||||
],
|
||||
"typings": "lib/model-viewer-effects.d.ts",
|
||||
"types": "lib/model-viewer-effects.d.ts",
|
||||
"scripts": {
|
||||
"clean": "rm -rf ./lib ./dist",
|
||||
"prepare": "if [ ! -L './shared-assets' ]; then ln -s ../shared-assets ./shared-assets; fi && ../shared-assets/scripts/fetch-khronos-gltf-samples.sh",
|
||||
"build": "npm run build:tsc && npm run build:rollup",
|
||||
"build:dev": "npm run build:tsc && npm run build:rollup:dev",
|
||||
"build:tsc": "tsc --incremental",
|
||||
"build:rollup": "rollup -c --environment NODE_ENV:production",
|
||||
"build:rollup:dev": "rollup -c --environment NODE_ENV:development",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "web-test-runner --playwright --browsers chromium firefox webkit",
|
||||
"test:ci": "web-test-runner --static-logging --playwright --browsers chromium webkit",
|
||||
"serve": "node_modules/.bin/http-server -c-1",
|
||||
"dev": "npm run build:dev && npm-run-all --parallel 'watch:tsc -- --preserveWatchOutput' 'watch:test' 'serve -- -s'",
|
||||
"watch:tsc": "tsc -w --incremental",
|
||||
"watch:rollup": "rollup -c -w --environment NODE_ENV:production",
|
||||
"watch:rollup:dev": "rollup -c -w --environment NODE_ENV:development",
|
||||
"watch:test": "web-test-runner --node-resolve --playwright --browsers chromium --watch",
|
||||
"build:dev:serve": "npm run build:dev && npm run serve"
|
||||
},
|
||||
"keywords": [
|
||||
"ar",
|
||||
"gltf",
|
||||
"glb",
|
||||
"webar",
|
||||
"webvr",
|
||||
"webxr",
|
||||
"arcore",
|
||||
"arkit",
|
||||
"webaronarcore",
|
||||
"webaronarkit",
|
||||
"augmented reality",
|
||||
"model-viewer",
|
||||
"model-viewer-effects",
|
||||
"3d",
|
||||
"post",
|
||||
"processing",
|
||||
"effect",
|
||||
"filter"
|
||||
],
|
||||
"dependencies": {
|
||||
"lit": "^3.2.1",
|
||||
"postprocessing": "^6.37.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@google/model-viewer": "^4.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@google/model-viewer": "^4.1.0",
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@rollup/plugin-replace": "^6.0.2",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/three": "^0.174.0",
|
||||
"@ungap/event-target": "^0.2.4",
|
||||
"@web/test-runner": "^0.20.0",
|
||||
"@web/test-runner-playwright": "^0.11.0",
|
||||
"chai": "^5.2.0",
|
||||
"@rollup/plugin-swc": "^0.4.0",
|
||||
"@swc/core": "^1.11.8",
|
||||
"focus-visible": "^5.2.1",
|
||||
"http-server": "^14.1.1",
|
||||
"mocha": "^11.1.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rollup": "^4.35.0",
|
||||
"rollup-plugin-cleanup": "^3.2.1",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-polyfill": "^4.2.0",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"three": "^0.174.0",
|
||||
"typescript": "5.8.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import {nodeResolve as resolve} from '@rollup/plugin-node-resolve';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import swc from '@rollup/plugin-swc';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import cleanup from 'rollup-plugin-cleanup';
|
||||
import dts from 'rollup-plugin-dts';
|
||||
|
||||
const {NODE_ENV} = process.env;
|
||||
|
||||
const onwarn = (warning, warn) => {
|
||||
// Suppress non-actionable warning caused by TypeScript boilerplate:
|
||||
if (warning.code !== 'THIS_IS_UNDEFINED') {
|
||||
warn(warning);
|
||||
}
|
||||
};
|
||||
|
||||
let plugins = [
|
||||
resolve(),
|
||||
replace({'Reflect.decorate': 'undefined', preventAssignment: true})
|
||||
];
|
||||
|
||||
const watchFiles = ['lib/**'];
|
||||
|
||||
const outputOptions = [
|
||||
{
|
||||
input: './lib/model-viewer-effects.js',
|
||||
output: {
|
||||
file: './dist/model-viewer-effects.js',
|
||||
sourcemap: true,
|
||||
format: 'esm',
|
||||
name: 'ModelViewerEffects',
|
||||
globals: {
|
||||
three: 'three',
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
include: watchFiles,
|
||||
},
|
||||
plugins,
|
||||
external: ['three'],
|
||||
onwarn,
|
||||
},
|
||||
];
|
||||
|
||||
if (NODE_ENV !== 'development') {
|
||||
const pluginsIE11 = [
|
||||
...plugins,
|
||||
commonjs(),
|
||||
swc(),
|
||||
cleanup({
|
||||
// Ideally we'd also clean third_party/three, which saves
|
||||
// ~45kb in filesize alone... but takes 2 minutes to build
|
||||
include: ['lib/**'],
|
||||
comments: 'none',
|
||||
}),
|
||||
];
|
||||
|
||||
// IE11 does not support modules, so they are removed here, as well as in a
|
||||
// dedicated unit test build which is needed for the same reason.
|
||||
outputOptions.push({
|
||||
input: './lib/model-viewer-effects.js',
|
||||
output: {
|
||||
file: './dist/model-viewer-effects-umd.js',
|
||||
sourcemap: true,
|
||||
format: 'umd',
|
||||
name: 'ModelViewerEffects',
|
||||
globals: {
|
||||
three: 'three',
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
include: watchFiles,
|
||||
},
|
||||
external: ['three'],
|
||||
plugins: pluginsIE11,
|
||||
onwarn,
|
||||
});
|
||||
|
||||
plugins = [...plugins, terser()];
|
||||
|
||||
outputOptions.push({
|
||||
input: './dist/model-viewer-effects.js',
|
||||
output: {
|
||||
file: './dist/model-viewer-effects.min.js',
|
||||
sourcemap: true,
|
||||
format: 'esm',
|
||||
name: 'ModelViewerEffects',
|
||||
globals: {
|
||||
three: 'three',
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
include: watchFiles,
|
||||
},
|
||||
external: ['three'],
|
||||
plugins,
|
||||
onwarn,
|
||||
});
|
||||
|
||||
outputOptions.push({
|
||||
input: './dist/model-viewer-effects-umd.js',
|
||||
output: {
|
||||
file: './dist/model-viewer-effects-umd.min.js',
|
||||
sourcemap: true,
|
||||
format: 'umd',
|
||||
name: 'ModelViewerEffects',
|
||||
globals: {
|
||||
three: 'three',
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
include: watchFiles,
|
||||
},
|
||||
external: ['three'],
|
||||
plugins,
|
||||
onwarn,
|
||||
});
|
||||
|
||||
outputOptions.push({
|
||||
input: './lib/model-viewer-effects.d.ts',
|
||||
output: {
|
||||
file: './dist/model-viewer-effects.d.ts',
|
||||
format: 'esm',
|
||||
name: 'ModelViewerEffects',
|
||||
},
|
||||
plugins: [dts()],
|
||||
});
|
||||
}
|
||||
|
||||
export default outputOptions;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
@@ -0,0 +1,419 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ModelViewerElement} from '@google/model-viewer';
|
||||
import {ModelScene} from '@google/model-viewer/lib/three-components/ModelScene.js';
|
||||
import {ReactiveElement} from 'lit';
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {EffectComposer as PPEffectComposer, EffectPass, NormalPass, Pass, RenderPass, Selection} from 'postprocessing';
|
||||
import {Camera, HalfFloatType, NeutralToneMapping, ToneMapping, UnsignedByteType, WebGLRenderer} from 'three';
|
||||
|
||||
import {IMVEffect, IntegrationOptions, MVEffectBase} from './effects/mixins/effect-base.js';
|
||||
import {disposeEffectPass, isConvolution, validateLiteralType} from './utilities.js';
|
||||
|
||||
export const $scene = Symbol('scene');
|
||||
export const $composer = Symbol('composer');
|
||||
export const $modelViewerElement = Symbol('modelViewerElement');
|
||||
export const $effectComposer = Symbol('effectComposer');
|
||||
export const $renderPass = Symbol('renderPass');
|
||||
export const $normalPass = Symbol('normalPass');
|
||||
export const $effectPasses = Symbol('effectsPass');
|
||||
export const $requires = Symbol('requires');
|
||||
export const $effects = Symbol('effects');
|
||||
export const $selection = Symbol('selection');
|
||||
export const $onSceneLoad = Symbol('onSceneLoad');
|
||||
export const $resetEffectPasses = Symbol('resetEffectPasses');
|
||||
export const $userEffectCount = Symbol('userEffectCount');
|
||||
export const $tonemapping = Symbol('tonemapping');
|
||||
const $updateProperties = Symbol('updateProperties');
|
||||
|
||||
/**
|
||||
* Light wrapper around {@link EffectComposer} for storing the `scene` and
|
||||
* `camera at a top level, and setting them for every {@link Pass} added.
|
||||
*/
|
||||
export class EffectComposer extends PPEffectComposer {
|
||||
public camera?: Camera;
|
||||
public scene?: ModelScene;
|
||||
public dirtyRender?: boolean;
|
||||
|
||||
[$tonemapping]: ToneMapping = NeutralToneMapping;
|
||||
|
||||
constructor(renderer?: WebGLRenderer, options?: {
|
||||
depthBuffer?: boolean;
|
||||
stencilBuffer?: boolean;
|
||||
alpha?: boolean;
|
||||
multisampling?: number;
|
||||
frameBufferType?: number;
|
||||
}) {
|
||||
super(renderer, options);
|
||||
}
|
||||
|
||||
private preRender() {
|
||||
// the EffectComposer expects autoClear to be false so that buffers aren't
|
||||
// cleared between renders while the threeRenderer should be true so that
|
||||
// the frames are cleared each render.
|
||||
const renderer = this.getRenderer();
|
||||
renderer.autoClear = false;
|
||||
renderer.toneMapping = this[$tonemapping];
|
||||
}
|
||||
|
||||
private postRender() {
|
||||
const renderer = this.getRenderer();
|
||||
renderer.toneMapping = NeutralToneMapping;
|
||||
renderer.autoClear = true;
|
||||
}
|
||||
|
||||
override render(deltaTime?: number|undefined): void {
|
||||
this.preRender();
|
||||
super.render(deltaTime);
|
||||
this.postRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a pass, optionally at a specific index.
|
||||
* Additionally sets `scene` and `camera`.
|
||||
* @param pass A new pass.
|
||||
* @param index An index at which the pass should be inserted.
|
||||
*/
|
||||
override addPass(pass: Pass, index?: number): void {
|
||||
super.addPass(pass, index);
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
override setMainCamera(camera: Camera): void {
|
||||
this.camera = camera;
|
||||
super.setMainCamera(camera);
|
||||
}
|
||||
|
||||
override setMainScene(scene: ModelScene): void {
|
||||
this.scene = scene;
|
||||
super.setMainScene(scene);
|
||||
}
|
||||
|
||||
/**
|
||||
* Effect Materials that use the camera need to be manually updated whenever
|
||||
* the camera settings update.
|
||||
*/
|
||||
refresh(): void {
|
||||
if (this.camera && this.scene) {
|
||||
super.setMainCamera(this.camera);
|
||||
super.setMainScene(this.scene);
|
||||
}
|
||||
}
|
||||
|
||||
beforeRender(_time: DOMHighResTimeStamp, _delta: DOMHighResTimeStamp): void {
|
||||
if (this.dirtyRender) {
|
||||
this.scene?.queueRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type MVPass = Pass&IntegrationOptions;
|
||||
|
||||
export const RENDER_MODES = ['performance', 'quality'] as const;
|
||||
export type RenderMode = typeof RENDER_MODES[number];
|
||||
|
||||
const N_DEFAULT_PASSES = 2; // RenderPass, NormalPass
|
||||
|
||||
export class MVEffectComposer extends ReactiveElement {
|
||||
static get is() {
|
||||
return 'effect-composer';
|
||||
}
|
||||
|
||||
/**
|
||||
* `quality` | `performance`. Changing this after the element was constructed
|
||||
* has no effect.
|
||||
*
|
||||
* Using `quality` improves banding on certain effects, at a memory cost. Use
|
||||
* in HDR scenarios.
|
||||
*
|
||||
* `performance` should be sufficient for most use-cases.
|
||||
* @default 'performance'
|
||||
*/
|
||||
@property({type: String, attribute: 'render-mode'})
|
||||
renderMode: RenderMode = 'performance';
|
||||
|
||||
/**
|
||||
* Anti-Aliasing using the MSAA algorithm. Doesn't work well with depth-based
|
||||
* effects.
|
||||
*
|
||||
* Recommended to use with a factor of 2.
|
||||
* @default 0
|
||||
*/
|
||||
@property({type: Number, attribute: 'msaa'}) msaa: number = 0;
|
||||
|
||||
protected[$composer]?: EffectComposer;
|
||||
protected[$modelViewerElement]?: ModelViewerElement;
|
||||
protected[$renderPass]: RenderPass;
|
||||
protected[$normalPass]: NormalPass;
|
||||
protected[$selection]: Selection;
|
||||
protected[$userEffectCount]: number = 0;
|
||||
|
||||
get[$effectComposer]() {
|
||||
if (!this[$composer])
|
||||
throw new Error(
|
||||
'The EffectComposer has not been instantiated yet. Please make sure the component is properly mounted on the Document within a <model-viewer> element.');
|
||||
return this[$composer];
|
||||
}
|
||||
|
||||
/**
|
||||
* Array of custom {@link MVPass}'s added with {@link addPass}.
|
||||
*/
|
||||
get userPasses(): MVPass[] {
|
||||
return this[$effectComposer].passes.slice(
|
||||
N_DEFAULT_PASSES, N_DEFAULT_PASSES + this[$userEffectCount]);
|
||||
}
|
||||
|
||||
get modelViewerElement() {
|
||||
if (!this[$modelViewerElement])
|
||||
throw new Error(
|
||||
'<effect-composer> must be a child of a <model-viewer> component.');
|
||||
return this[$modelViewerElement];
|
||||
}
|
||||
|
||||
/**
|
||||
* The Texture buffer of the inbuilt {@link NormalPass}.
|
||||
*/
|
||||
get normalBuffer() {
|
||||
return this[$normalPass].texture;
|
||||
}
|
||||
|
||||
/**
|
||||
* A selection of all {@link Mesh}'s in the ModelScene.
|
||||
*/
|
||||
get selection() {
|
||||
return this[$selection];
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new MVEffectComposer element.
|
||||
*
|
||||
* @warning The EffectComposer instance is created only on connection with the
|
||||
* DOM, so that the renderMode is properly taken into account. Do not interact
|
||||
* with this class if it is not mounted to the DOM.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
this[$renderPass] = new RenderPass();
|
||||
this[$normalPass] = new NormalPass();
|
||||
this[$selection] = new Selection();
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
if (this.parentNode?.nodeName.toLowerCase() === 'model-viewer') {
|
||||
this[$modelViewerElement] = this.parentNode as ModelViewerElement;
|
||||
}
|
||||
|
||||
try {
|
||||
validateLiteralType(RENDER_MODES, this.renderMode);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
(e as Error).message + '\nrenderMode defaulting to: performance');
|
||||
}
|
||||
this[$composer] = new EffectComposer(undefined, {
|
||||
stencilBuffer: true,
|
||||
multisampling: this.msaa,
|
||||
frameBufferType: this.renderMode === 'quality' ? HalfFloatType :
|
||||
UnsignedByteType,
|
||||
});
|
||||
this.modelViewerElement.registerEffectComposer(this[$effectComposer]);
|
||||
this[$effectComposer].addPass(this[$renderPass], 0);
|
||||
this[$effectComposer].addPass(this[$normalPass], 1);
|
||||
|
||||
this[$onSceneLoad]();
|
||||
this.modelViewerElement.addEventListener(
|
||||
'before-render', this[$onSceneLoad]);
|
||||
this.updateEffects();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback && super.disconnectedCallback();
|
||||
this.modelViewerElement.unregisterEffectComposer();
|
||||
this.modelViewerElement.removeEventListener(
|
||||
'before-render', this[$onSceneLoad]);
|
||||
this[$effectComposer].dispose();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string|number|symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('msaa')) {
|
||||
this[$effectComposer].multisampling = this.msaa;
|
||||
}
|
||||
if (changedProperties.has('renderMode') &&
|
||||
changedProperties.get('renderMode') !== undefined) {
|
||||
throw new Error('renderMode cannot be changed after startup.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom Pass that extends the {@link Pass} class.
|
||||
* All passes added through this method will be prepended before all other
|
||||
* web-component effects.
|
||||
*
|
||||
* This method automatically sets the `mainScene` and `mainCamera` of the
|
||||
* pass.
|
||||
* @param {Pass} pass Custom Pass to add. The camera and scene are set
|
||||
* automatically.
|
||||
* @param {boolean} requireNormals Whether any effect in this pass uses
|
||||
* the {@link normalBuffer}
|
||||
* @param {boolean} requireDirtyRender Enable this if the effect requires a
|
||||
* render frame every frame. Significant performance impact from enabling
|
||||
* this.
|
||||
*/
|
||||
addPass(pass: Pass, requireNormals?: boolean, requireDirtyRender?: boolean):
|
||||
void {
|
||||
(pass as MVPass).requireNormals = requireNormals;
|
||||
(pass as MVPass).requireDirtyRender = requireDirtyRender;
|
||||
this[$effectComposer].addPass(
|
||||
pass,
|
||||
this[$userEffectCount] +
|
||||
N_DEFAULT_PASSES); // push after current userPasses, before any
|
||||
// web-component effects.
|
||||
this[$userEffectCount]++;
|
||||
// Enable the normalPass and dirtyRendering if required by any effect.
|
||||
this[$updateProperties]();
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes and optionally disposes of a previously added Pass.
|
||||
* @param pass Custom Pass to remove
|
||||
* @param {Boolean} dispose Disposes of the Pass properties and effects.
|
||||
* Default is `true`.
|
||||
*/
|
||||
removePass(pass: Pass, dispose: boolean = true): void {
|
||||
if (!this[$effectComposer].passes.includes(pass))
|
||||
throw new Error(`Pass ${pass.name} not found.`);
|
||||
this[$effectComposer].removePass(pass);
|
||||
if (dispose)
|
||||
pass.dispose();
|
||||
// Enable the normalPass and dirtyRendering if required by any effect.
|
||||
this[$updateProperties]();
|
||||
this[$userEffectCount]--;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all existing EffectPasses, adding any new `<model-viewer-effects>`
|
||||
* Effects in the order they were added, after any custom Passes added
|
||||
* with {@link addPass}.
|
||||
*
|
||||
* Runs automatically whenever a new Effect is added.
|
||||
*/
|
||||
updateEffects(): void {
|
||||
this[$resetEffectPasses]();
|
||||
|
||||
// Iterate over all effects (web-component), and combines as many as
|
||||
// possible. Convolution effects must sit on their own EffectPass. In order
|
||||
// to preserve the correct effect order, the convolution effects separate
|
||||
// all effects before and after into separate EffectPasses.
|
||||
const effects = this[$effects];
|
||||
let i = 0;
|
||||
while (i < effects.length) {
|
||||
const separateIndex = effects.slice(i).findIndex(
|
||||
(effect) => effect.requireSeparatePass || isConvolution(effect));
|
||||
if (separateIndex != 0) {
|
||||
const effectPass = new EffectPass(
|
||||
undefined,
|
||||
...effects.slice(
|
||||
i, separateIndex == -1 ? effects.length : separateIndex));
|
||||
this[$effectComposer].addPass(effectPass);
|
||||
}
|
||||
|
||||
if (separateIndex != -1) {
|
||||
const convolutionPass =
|
||||
new EffectPass(undefined, effects[i + separateIndex]);
|
||||
this[$effectComposer].addPass(convolutionPass);
|
||||
i += separateIndex + 1;
|
||||
} else {
|
||||
break; // A convolution was not found, the first Effect pass contains
|
||||
// all effects from i to effects.length
|
||||
}
|
||||
}
|
||||
|
||||
// Enable the normalPass and dirtyRendering if required by any effect.
|
||||
this[$updateProperties]();
|
||||
|
||||
this.queueRender();
|
||||
}
|
||||
|
||||
/**
|
||||
* Request a render-frame manually.
|
||||
*/
|
||||
queueRender(): void {
|
||||
this[$scene]?.queueRender();
|
||||
}
|
||||
|
||||
get[$scene]() {
|
||||
return this[$effectComposer].scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets child effects
|
||||
*/
|
||||
get[$effects](): IMVEffect[] {
|
||||
// iterate over all web-component children effects
|
||||
const effects: IMVEffect[] = [];
|
||||
for (let i = 0; i < this.children.length; i++) {
|
||||
const childEffect = this.children.item(i) as MVEffectBase;
|
||||
if (!childEffect.effects)
|
||||
continue;
|
||||
const childEffects = childEffect.effects;
|
||||
if (childEffects) {
|
||||
effects.push(...childEffects.filter((effect) => !effect.disabled));
|
||||
}
|
||||
}
|
||||
return effects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets effectPasses of child effects
|
||||
*/
|
||||
get[$effectPasses]() {
|
||||
return this[$effectComposer].passes.slice(
|
||||
N_DEFAULT_PASSES + this[$userEffectCount]) as EffectPass[];
|
||||
}
|
||||
|
||||
[$onSceneLoad] = (): void => {
|
||||
this[$effectComposer].refresh();
|
||||
// Place all Geometries in the selection
|
||||
this[$selection].clear();
|
||||
this[$scene]?.traverse(
|
||||
(obj) => obj.hasOwnProperty('geometry') && this[$selection].add(obj));
|
||||
this.dispatchEvent(new CustomEvent('updated-selection'));
|
||||
};
|
||||
|
||||
[$updateProperties]() {
|
||||
this[$normalPass].enabled = this[$requires]('requireNormals');
|
||||
this[$normalPass].renderToScreen = false;
|
||||
this[$effectComposer].dirtyRender = this[$requires]('requireDirtyRender');
|
||||
this[$renderPass].renderToScreen =
|
||||
this[$effectComposer].passes.length === N_DEFAULT_PASSES;
|
||||
}
|
||||
|
||||
[$requires](property: 'requireNormals'|'requireSeparatePass'|
|
||||
'requireDirtyRender'): boolean {
|
||||
return this[$effectComposer].passes.some(
|
||||
(pass: any) => pass[property] ||
|
||||
(pass.effects &&
|
||||
pass.effects.some((effect: IMVEffect) => effect[property])));
|
||||
}
|
||||
|
||||
[$resetEffectPasses](): void {
|
||||
this[$effectPasses].forEach((pass) => {
|
||||
this[$effectComposer].removePass(pass);
|
||||
disposeEffectPass(pass);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { BlendFunction, BloomEffect } from 'postprocessing';
|
||||
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
|
||||
|
||||
export class MVBloomEffect extends MVEffectBase {
|
||||
static get is() {
|
||||
return 'bloom-effect';
|
||||
}
|
||||
|
||||
/**
|
||||
* The strength of the bloom effect.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'strength', reflect: true })
|
||||
strength = 1;
|
||||
|
||||
/**
|
||||
* Value in the range of (0, 1). Pixels with a brightness above this will bloom.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'threshold', reflect: true })
|
||||
threshold = 0.85;
|
||||
|
||||
/**
|
||||
* Value in the range of (0, 1).
|
||||
*/
|
||||
@property({ type: Number, attribute: 'radius', reflect: true })
|
||||
radius = 0.85;
|
||||
|
||||
/**
|
||||
* Value in the range of (0, 1).
|
||||
*/
|
||||
@property({ type: Number, attribute: 'smoothing', reflect: true })
|
||||
smoothing = 0.025;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.effects = [new BloomEffect(this[$effectOptions])];
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$updateProperties]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (
|
||||
changedProperties.has('strength') ||
|
||||
changedProperties.has('threshold') ||
|
||||
changedProperties.has('smoothing') ||
|
||||
changedProperties.has('radius')
|
||||
) {
|
||||
this[$updateProperties]();
|
||||
}
|
||||
}
|
||||
|
||||
[$updateProperties](): void {
|
||||
(this.effects[0] as BloomEffect).luminanceMaterial.threshold = this.threshold;
|
||||
(this.effects[0] as BloomEffect).luminanceMaterial.smoothing = this.smoothing;
|
||||
(this.effects[0] as BloomEffect).intensity = this.strength;
|
||||
(this.effects[0] as any).mipmapBlurPass.radius = this.radius;
|
||||
this.effectComposer.queueRender();
|
||||
}
|
||||
|
||||
get [$effectOptions]() {
|
||||
return {
|
||||
blendFunction: BlendFunction.ADD,
|
||||
mipmapBlur: true,
|
||||
radius: this.radius,
|
||||
luminanceThreshold: this.threshold,
|
||||
luminanceSmoothing: this.smoothing,
|
||||
intensity: this.strength,
|
||||
} as ConstructorParameters<typeof BloomEffect>[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {BlendFunction, BrightnessContrastEffect, HueSaturationEffect, ToneMappingEffect, ToneMappingMode as PPToneMappingMode} from 'postprocessing';
|
||||
import {NeutralToneMapping, NoToneMapping} from 'three';
|
||||
|
||||
import {$effectComposer, $tonemapping} from '../effect-composer.js';
|
||||
import {clamp, validateLiteralType, wrapClamp} from '../utilities.js';
|
||||
|
||||
import {$updateProperties, MVEffectBase} from './mixins/effect-base.js';
|
||||
|
||||
const TWO_PI = Math.PI * 2;
|
||||
|
||||
export type ToneMappingMode = keyof typeof PPToneMappingMode;
|
||||
;
|
||||
export const TONEMAPPING_MODES =
|
||||
Object.keys(PPToneMappingMode) as ToneMappingMode[];
|
||||
|
||||
export class MVColorGradeEffect extends MVEffectBase {
|
||||
static get is() {
|
||||
return 'color-grade-effect';
|
||||
}
|
||||
|
||||
/**
|
||||
* `reinhard | reinhard2 | reinhard_adaptive | optimized_cineon | aces_filmic
|
||||
* | linear`
|
||||
* @default 'aces_filmic'
|
||||
*/
|
||||
@property({type: String, attribute: 'tonemapping', reflect: true})
|
||||
tonemapping: ToneMappingMode = 'ACES_FILMIC'
|
||||
|
||||
/**
|
||||
* Value in the range of (-1, 1).
|
||||
*/
|
||||
@property({type: Number, attribute: 'brightness', reflect: true})
|
||||
brightness = 0;
|
||||
|
||||
/**
|
||||
* Value in the range of (-1, 1).
|
||||
*/
|
||||
@property({type: Number, attribute: 'contrast', reflect: true}) contrast = 0;
|
||||
|
||||
/**
|
||||
* Value in the range of (-1, 1).
|
||||
*/
|
||||
@property({type: Number, attribute: 'saturation', reflect: true})
|
||||
saturation = 0;
|
||||
|
||||
/**
|
||||
* Value in the range of (0, 2 * PI).
|
||||
*
|
||||
* This property is wrapping, meaning that if you set it above the max it
|
||||
* resets to the minimum and vice-versa.
|
||||
*/
|
||||
@property({type: Number, attribute: 'hue', reflect: true}) hue = 0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.effects = [
|
||||
new ToneMappingEffect({
|
||||
mode: PPToneMappingMode.ACES_FILMIC,
|
||||
}),
|
||||
new HueSaturationEffect({
|
||||
hue: wrapClamp(this.hue, 0, TWO_PI),
|
||||
saturation: clamp(this.saturation, -1, 1),
|
||||
blendFunction: BlendFunction.SRC,
|
||||
}),
|
||||
new BrightnessContrastEffect({
|
||||
brightness: clamp(this.brightness, -1, 1),
|
||||
contrast: clamp(this.contrast, -1, 1),
|
||||
blendFunction: BlendFunction.SRC,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$updateProperties]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string|number|symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('tonemapping') ||
|
||||
changedProperties.has('brightness') ||
|
||||
changedProperties.has('contrast') || changedProperties.has('hue') ||
|
||||
changedProperties.has('saturation') ||
|
||||
changedProperties.has('blendMode')) {
|
||||
this[$updateProperties]();
|
||||
}
|
||||
}
|
||||
|
||||
[$updateProperties]() {
|
||||
if (this.blendMode === 'SKIP') {
|
||||
this.effectComposer[$effectComposer][$tonemapping] = NeutralToneMapping;
|
||||
} else {
|
||||
this.effectComposer[$effectComposer][$tonemapping] = NoToneMapping;
|
||||
}
|
||||
this.saturation = clamp(this.saturation, -1, 1);
|
||||
this.hue = wrapClamp(this.hue, 0, TWO_PI);
|
||||
this.brightness = clamp(this.brightness, -1, 1);
|
||||
this.contrast = clamp(this.contrast, -1, 1);
|
||||
(this.effects[1] as HueSaturationEffect).saturation = this.saturation;
|
||||
(this.effects[1] as HueSaturationEffect).hue = this.hue;
|
||||
(this.effects[2] as BrightnessContrastEffect).brightness = this.brightness;
|
||||
(this.effects[2] as BrightnessContrastEffect).contrast = this.contrast;
|
||||
try {
|
||||
this.tonemapping = this.tonemapping.toUpperCase() as ToneMappingMode;
|
||||
validateLiteralType(TONEMAPPING_MODES, this.tonemapping);
|
||||
(this.effects[0] as ToneMappingEffect).mode =
|
||||
PPToneMappingMode[this.tonemapping];
|
||||
} finally {
|
||||
this.effectComposer.queueRender();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { ChromaticAberrationEffect, GlitchEffect, GlitchMode as Mode } from 'postprocessing';
|
||||
import { Vector2 } from 'three';
|
||||
import { clamp, validateLiteralType } from '../utilities.js';
|
||||
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
|
||||
|
||||
|
||||
export const GLITCH_MODES = ['sporadic', 'constant'] as const;
|
||||
export type GlitchMode = typeof GLITCH_MODES[number];
|
||||
|
||||
export class MVGlitchEffect extends MVEffectBase {
|
||||
static get is() {
|
||||
return 'glitch-effect';
|
||||
}
|
||||
|
||||
/**
|
||||
* Value in the range of (0, 1).
|
||||
*/
|
||||
@property({ type: Number, attribute: 'strength', reflect: true })
|
||||
strength: number = 0.5;
|
||||
|
||||
/**
|
||||
* `sporadic` | `constant`
|
||||
* @default 'sporadic'
|
||||
*/
|
||||
@property({ type: String, attribute: 'mode', reflect: true })
|
||||
mode: GlitchMode = 'sporadic';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const chromaticAberrationEffect = new ChromaticAberrationEffect();
|
||||
const glitchEffect = new GlitchEffect(this[$effectOptions](chromaticAberrationEffect));
|
||||
this.effects = [glitchEffect, chromaticAberrationEffect];
|
||||
this.effects[1].requireDirtyRender = true;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$updateProperties]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('mode') || changedProperties.has('strength')) {
|
||||
this[$updateProperties]();
|
||||
}
|
||||
}
|
||||
|
||||
[$updateProperties](): void {
|
||||
this.strength = clamp(this.strength, 0, 1);
|
||||
this.mode = this.mode.toLowerCase() as GlitchMode;
|
||||
try {
|
||||
validateLiteralType(GLITCH_MODES, this.mode);
|
||||
} catch(e) {
|
||||
console.error((e as Error).message + + "\nmode defaulting to 'sporadic'")
|
||||
}
|
||||
if (this.strength == 0) {
|
||||
(this.effects[0] as GlitchEffect).columns = 0;
|
||||
(this.effects[0] as GlitchEffect).mode = this.mode === 'constant' ? Mode.CONSTANT_MILD : Mode.SPORADIC;
|
||||
} else {
|
||||
(this.effects[0] as GlitchEffect).columns = 0.06;
|
||||
(this.effects[0] as GlitchEffect).mode = this.mode === 'constant' ? Mode.CONSTANT_WILD : Mode.SPORADIC;
|
||||
}
|
||||
(this.effects[0] as GlitchEffect).maxStrength = this.strength;
|
||||
(this.effects[0] as GlitchEffect).ratio = 1 - this.strength;
|
||||
}
|
||||
|
||||
[$effectOptions](chromaticAberrationEffect: ChromaticAberrationEffect) {
|
||||
this.strength = clamp(this.strength, 0, 1);
|
||||
return {
|
||||
chromaticAberrationOffset: chromaticAberrationEffect.offset,
|
||||
delay: new Vector2(1 * 1000, 3.5 * 1000),
|
||||
duration: new Vector2(0.5 * 1000, 1 * 1000),
|
||||
strength: new Vector2(0.075, this.strength),
|
||||
ratio: 1 - this.strength,
|
||||
} as ConstructorParameters<typeof GlitchEffect>[0];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactiveElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { BlendFunction } from 'postprocessing';
|
||||
import { Constructor, clampNormal, validateLiteralType } from '../../utilities.js';
|
||||
import { IEffectBaseMixin } from './effect-base.js';
|
||||
|
||||
export const $setDefaultProperties = Symbol('setDefaultProperties');
|
||||
|
||||
export type BlendMode = keyof typeof BlendFunction;
|
||||
export const BLEND_MODES = Object.keys(BlendFunction) as BlendMode[];
|
||||
|
||||
export interface IBlendModeMixin {
|
||||
opacity: number;
|
||||
blendMode: BlendMode;
|
||||
[$setDefaultProperties](): void;
|
||||
}
|
||||
|
||||
export const BlendModeMixin = <T extends Constructor<IEffectBaseMixin & ReactiveElement>>(
|
||||
EffectClass: T
|
||||
): Constructor<IBlendModeMixin> & T => {
|
||||
class BlendEffectElement extends EffectClass {
|
||||
/**
|
||||
* The function to use to blend the effect with the base render.
|
||||
*/
|
||||
@property({ type: String, attribute: 'blend-mode', reflect: true })
|
||||
blendMode: 'DEFAULT' | BlendMode = 'DEFAULT';
|
||||
|
||||
/**
|
||||
* The opacity of the effect that will be blended with the base render.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'opacity', reflect: true })
|
||||
opacity: number = 1;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$setDefaultProperties]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('blendMode') || changedProperties.has('opacity')) {
|
||||
this.opacity = clampNormal(this.opacity);
|
||||
this.blendMode = this.blendMode.toUpperCase() as BlendMode;
|
||||
this.effects.forEach((effect) => {
|
||||
if (this.blendMode === 'DEFAULT') {
|
||||
if (effect.blendMode.defaultBlendFunction === undefined) throw new Error(`${effect.name} has no default blend function`);
|
||||
effect.blendMode.blendFunction = effect.blendMode.defaultBlendFunction;
|
||||
} else {
|
||||
validateLiteralType(BLEND_MODES, this.blendMode);
|
||||
effect.blendMode.blendFunction = BlendFunction[this.blendMode];
|
||||
}
|
||||
effect.disabled = this.blendMode === 'SKIP';
|
||||
effect.blendMode.setOpacity(this.opacity);
|
||||
});
|
||||
// Recreate EffectPasses if the new or old value was 'skip'
|
||||
if (this.blendMode === 'SKIP' || changedProperties.get('blendMode') === 'SKIP') {
|
||||
this.effectComposer.updateEffects();
|
||||
}
|
||||
this.effectComposer.queueRender();
|
||||
}
|
||||
}
|
||||
|
||||
protected [$setDefaultProperties]() {
|
||||
this.effects.forEach((effect) => {
|
||||
effect.blendMode.defaultBlendFunction = effect.blendMode.blendFunction;
|
||||
});
|
||||
}
|
||||
}
|
||||
return BlendEffectElement as Constructor<IBlendModeMixin> & T;
|
||||
};
|
||||
@@ -0,0 +1,87 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { LitElement, ReactiveElement } from 'lit';
|
||||
import { BlendFunction, BlendMode, Effect } from 'postprocessing';
|
||||
import { $effectComposer, MVEffectComposer } from '../../effect-composer.js';
|
||||
import { Constructor } from '../../utilities.js';
|
||||
import { BlendModeMixin } from './blend-mode.js';
|
||||
import { getComponentName } from '../utilities.js';
|
||||
|
||||
export const $updateProperties = Symbol('updateProperties');
|
||||
export const $effectOptions = Symbol('effectOptions');
|
||||
|
||||
export interface IMVBlendMode extends BlendMode {
|
||||
defaultBlendFunction?: BlendFunction;
|
||||
}
|
||||
|
||||
export interface IntegrationOptions {
|
||||
/**
|
||||
* Enable this if effect uses the built-in {@link NormalPass}
|
||||
*/
|
||||
requireNormals?: boolean;
|
||||
/**
|
||||
* Enable this if the effect requires a render frame every frame.
|
||||
* @warning Significant performance impact from enabling this
|
||||
*/
|
||||
requireDirtyRender?: boolean;
|
||||
}
|
||||
|
||||
export interface IMVEffect extends Effect, IntegrationOptions {
|
||||
readonly blendMode: IMVBlendMode;
|
||||
/**
|
||||
* Enable this if the effect doesn't play well when used with other effects.
|
||||
*/
|
||||
requireSeparatePass?: boolean;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface IEffectBaseMixin {
|
||||
effects: IMVEffect[];
|
||||
effectComposer: MVEffectComposer;
|
||||
}
|
||||
|
||||
export const EffectBaseMixin = <T extends Constructor<ReactiveElement>>(EffectClass: T): Constructor<IEffectBaseMixin> & T => {
|
||||
class EffectBaseElement extends EffectClass {
|
||||
[$effectComposer]?: MVEffectComposer;
|
||||
protected effects!: IMVEffect[];
|
||||
|
||||
/**
|
||||
* The parent {@link MVEffectComposer} element.
|
||||
*/
|
||||
protected get effectComposer() {
|
||||
if (!this[$effectComposer]) throw new Error(`${getComponentName(this as any)} must be a child of a <model-viewer> component.`);
|
||||
return this[$effectComposer];
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
if (this.parentNode?.nodeName.toLowerCase() === 'effect-composer') {
|
||||
this[$effectComposer] = this.parentNode as MVEffectComposer;
|
||||
}
|
||||
this.effectComposer.updateEffects();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback && super.disconnectedCallback();
|
||||
this.effects.forEach((effect) => effect.dispose());
|
||||
this.effectComposer.updateEffects();
|
||||
}
|
||||
}
|
||||
return EffectBaseElement as Constructor<IEffectBaseMixin> & T;
|
||||
};
|
||||
|
||||
export const MVEffectBase = BlendModeMixin(EffectBaseMixin(LitElement));
|
||||
export type MVEffectBase = InstanceType<typeof MVEffectBase>;
|
||||
@@ -0,0 +1,80 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ReactiveElement } from 'lit';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { Selection } from 'postprocessing';
|
||||
import { $selection, $scene } from '../../effect-composer.js';
|
||||
import { Constructor } from '../../utilities.js';
|
||||
import { IEffectBaseMixin, IMVEffect } from './effect-base.js';
|
||||
import { Object3D } from 'three';
|
||||
|
||||
export const $setSelection = Symbol('setSelection');
|
||||
|
||||
export interface ISelectionEffect extends IMVEffect {
|
||||
selection?: Selection;
|
||||
}
|
||||
|
||||
export interface ISelectiveMixin {
|
||||
selection: Array<string | Object3D>;
|
||||
}
|
||||
|
||||
export const SelectiveMixin = <T extends Constructor<IEffectBaseMixin & ReactiveElement>>(
|
||||
EffectClass: T
|
||||
): Constructor<ISelectiveMixin> & T => {
|
||||
class SelectiveEffectElement extends EffectClass {
|
||||
/**
|
||||
* The objects to attemp to place into the effect selection. Can be either the 'name' or the actual objects themselves.
|
||||
*
|
||||
* Note that since this is an array property, it must be set using the '=' operator in order to properly update.
|
||||
*/
|
||||
@property({ type: Array })
|
||||
selection: Array<string | Object3D> = [];
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$setSelection]();
|
||||
this.effectComposer.addEventListener('updated-selection', this[$setSelection]);
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
super.disconnectedCallback && super.disconnectedCallback();
|
||||
this.effectComposer.removeEventListener('updated-selection', this[$setSelection]);
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('selection')) {
|
||||
this[$setSelection]();
|
||||
this.effectComposer.queueRender();
|
||||
}
|
||||
}
|
||||
|
||||
[$setSelection] = () => {
|
||||
const { effectComposer } = this;
|
||||
if (!effectComposer) return;
|
||||
|
||||
if (this.selection?.length > 0) {
|
||||
const selection: Object3D[] = [];
|
||||
const scene = effectComposer[$scene];
|
||||
scene?.traverse((obj) => (this.selection.includes(obj.name) || this.selection.includes(obj)) && selection.push(obj));
|
||||
this.effects.forEach((effect: ISelectionEffect) => effect.selection?.set(selection));
|
||||
} else {
|
||||
this.effects.forEach((effect: ISelectionEffect) => effect.selection?.set(effectComposer[$selection].values()));
|
||||
}
|
||||
};
|
||||
}
|
||||
return SelectiveEffectElement as Constructor<ISelectiveMixin> & T;
|
||||
};
|
||||
@@ -0,0 +1,88 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { BlendFunction, OutlineEffect } from 'postprocessing';
|
||||
import { Color, ColorRepresentation } from 'three';
|
||||
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
|
||||
import { SelectiveMixin } from './mixins/selective.js';
|
||||
import { getKernelSize, TEMP_CAMERA } from './utilities.js';
|
||||
|
||||
export class MVOutlineEffect extends SelectiveMixin(MVEffectBase) {
|
||||
static get is() {
|
||||
return 'outline-effect';
|
||||
}
|
||||
|
||||
/**
|
||||
* String or RGB #-hexadecimal Color.
|
||||
* @default 'white'
|
||||
*/
|
||||
@property({ type: String || Number, attribute: 'color', reflect: true })
|
||||
color: ColorRepresentation = 'white';
|
||||
|
||||
/**
|
||||
* A larger value denotes a thicker edge.
|
||||
* @default 1
|
||||
*/
|
||||
@property({ type: Number, attribute: 'strength', reflect: true })
|
||||
strength = 1;
|
||||
|
||||
/**
|
||||
* Value in the range of (0, 6). Controls the edge blur strength.
|
||||
* @default 1
|
||||
*/
|
||||
@property({ type: Number, attribute: 'smoothing', reflect: true })
|
||||
smoothing = 1;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.effects = [new OutlineEffect(undefined, TEMP_CAMERA, this[$effectOptions])];
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$updateProperties]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('color') || changedProperties.has('smoothing') || changedProperties.has('strength')) {
|
||||
this[$updateProperties]();
|
||||
}
|
||||
}
|
||||
|
||||
[$updateProperties]() {
|
||||
(this.effects[0] as OutlineEffect).edgeStrength = this.strength;
|
||||
(this.effects[0] as OutlineEffect).visibleEdgeColor = new Color(this.color);
|
||||
(this.effects[0] as OutlineEffect).hiddenEdgeColor = new Color(this.color);
|
||||
(this.effects[0] as OutlineEffect).blurPass.enabled = Math.round(this.smoothing) > 0;
|
||||
(this.effects[0] as OutlineEffect).blurPass.kernelSize = getKernelSize(this.smoothing);
|
||||
this.effectComposer.queueRender();
|
||||
}
|
||||
|
||||
get [$effectOptions]() {
|
||||
return {
|
||||
blendFunction: BlendFunction.SCREEN,
|
||||
edgeStrength: this.strength,
|
||||
pulseSpeed: 0.0,
|
||||
visibleEdgeColor: new Color(this.color).getHex(),
|
||||
hiddenEdgeColor: new Color(this.color).getHex(),
|
||||
blur: Math.round(this.smoothing) > 0,
|
||||
kernelSize: getKernelSize(this.smoothing),
|
||||
xRay: true,
|
||||
resolutionScale: 1,
|
||||
} as ConstructorParameters<typeof OutlineEffect>[2];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { PixelationEffect } from 'postprocessing';
|
||||
import { $updateProperties, MVEffectBase } from './mixins/effect-base.js';
|
||||
|
||||
export class MVPixelateEffect extends MVEffectBase {
|
||||
static get is() {
|
||||
return 'pixelate-effect';
|
||||
}
|
||||
|
||||
/**
|
||||
* The pixel granularity. Higher value = lower resolution.
|
||||
* @default 10
|
||||
*/
|
||||
@property({ type: Number, attribute: 'granularity', reflect: true })
|
||||
granularity = 10.0;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.effects = [new PixelationEffect(this.granularity)];
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$updateProperties]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('granularity')) {
|
||||
this[$updateProperties]();
|
||||
}
|
||||
}
|
||||
|
||||
[$updateProperties]() {
|
||||
(this.effects[0] as PixelationEffect).granularity = this.granularity;
|
||||
this.effectComposer.queueRender();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { BlendFunction, SelectiveBloomEffect } from 'postprocessing';
|
||||
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
|
||||
import { SelectiveMixin } from './mixins/selective.js';
|
||||
import { TEMP_CAMERA } from './utilities.js';
|
||||
|
||||
export class MVSelectiveBloomEffect extends SelectiveMixin(MVEffectBase) {
|
||||
static get is() {
|
||||
return 'selective-bloom-effect';
|
||||
}
|
||||
|
||||
/**
|
||||
* The strength of the bloom effect.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'strength', reflect: true })
|
||||
strength = 1;
|
||||
|
||||
/**
|
||||
* Value in the range of (0, 1). Pixels with a brightness above this will bloom.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'threshold', reflect: true })
|
||||
threshold = 0.85;
|
||||
|
||||
/**
|
||||
* Value in the range of (0, 1).
|
||||
*/
|
||||
@property({ type: Number, attribute: 'smoothing', reflect: true })
|
||||
smoothing = 0.025;
|
||||
|
||||
/**
|
||||
* Value in the range of (0, 1).
|
||||
*/
|
||||
@property({ type: Number, attribute: 'radius', reflect: true })
|
||||
radius = 0.85;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.effects = [new SelectiveBloomEffect(undefined, TEMP_CAMERA, this[$effectOptions])];
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$updateProperties]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (
|
||||
changedProperties.has('strength') ||
|
||||
changedProperties.has('threshold') ||
|
||||
changedProperties.has('smoothing') ||
|
||||
changedProperties.has('radius')
|
||||
) {
|
||||
this[$updateProperties]();
|
||||
}
|
||||
}
|
||||
|
||||
[$updateProperties](): void {
|
||||
(this.effects[0] as SelectiveBloomEffect).luminanceMaterial.threshold = this.threshold;
|
||||
(this.effects[0] as SelectiveBloomEffect).luminanceMaterial.smoothing = this.smoothing;
|
||||
(this.effects[0] as SelectiveBloomEffect).intensity = this.strength;
|
||||
(this.effects[0] as any).mipmapBlurPass.radius = this.radius;
|
||||
this.effectComposer.queueRender();
|
||||
}
|
||||
|
||||
get [$effectOptions]() {
|
||||
return {
|
||||
blendFunction: BlendFunction.ADD,
|
||||
mipmapBlur: true,
|
||||
radius: this.radius,
|
||||
luminanceThreshold: this.threshold,
|
||||
luminanceSmoothing: this.smoothing,
|
||||
intensity: this.strength,
|
||||
} as ConstructorParameters<typeof SelectiveBloomEffect>[2];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { SMAAEffect, SMAAPreset } from 'postprocessing';
|
||||
import { $updateProperties, MVEffectBase } from './mixins/effect-base.js';
|
||||
import { validateLiteralType } from '../utilities.js';
|
||||
|
||||
export type SMAAQuality = keyof typeof SMAAPreset;
|
||||
export const SMAA_QUALITIES = Object.keys(SMAAPreset) as SMAAQuality[];
|
||||
|
||||
export class MVSMAAEffect extends MVEffectBase {
|
||||
static get is() {
|
||||
return 'smaa-effect';
|
||||
}
|
||||
|
||||
/**
|
||||
* `low | medium | high | ultra`
|
||||
* @default 'medium'
|
||||
*/
|
||||
@property({ type: String, attribute: 'quality', reflect: true })
|
||||
quality: SMAAQuality = 'MEDIUM';
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.effects = [new SMAAEffect({ preset: SMAAPreset[this.quality] })];
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$updateProperties]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string | number | symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('quality')) {
|
||||
this[$updateProperties]();
|
||||
}
|
||||
}
|
||||
|
||||
[$updateProperties]() {
|
||||
this.quality = this.quality.toUpperCase() as SMAAQuality;
|
||||
validateLiteralType(SMAA_QUALITIES, this.quality);
|
||||
(this.effects[0] as SMAAEffect).applyPreset(SMAAPreset[this.quality]);
|
||||
this.effectComposer.queueRender();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { SSAOEffect } from 'postprocessing';
|
||||
import { $updateProperties, $effectOptions, MVEffectBase } from './mixins/effect-base.js';
|
||||
import { property } from 'lit/decorators.js';
|
||||
import { TEMP_CAMERA } from './utilities.js';
|
||||
import { $setDefaultProperties } from './mixins/blend-mode.js';
|
||||
|
||||
export class MVSSAOEffect extends MVEffectBase {
|
||||
static get is() {
|
||||
return 'ssao-effect';
|
||||
}
|
||||
|
||||
/**
|
||||
* The strength of the shadow occlusions. Higher value means darker shadows.
|
||||
*/
|
||||
@property({ type: Number, attribute: 'strength', reflect: true })
|
||||
strength: number = 2;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.effects = [new SSAOEffect(TEMP_CAMERA, undefined, this[$effectOptions])];
|
||||
this.effects[0].requireNormals = true;
|
||||
}
|
||||
|
||||
connectedCallback(): void {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
this[$setDefaultProperties]();
|
||||
this[$updateProperties]();
|
||||
}
|
||||
|
||||
update(changedProperties: Map<string | number | symbol, any>): void {
|
||||
super.update && super.update(changedProperties);
|
||||
if (changedProperties.has('strength')) {
|
||||
this[$updateProperties]();
|
||||
}
|
||||
}
|
||||
|
||||
[$updateProperties](): void {
|
||||
(this.effects[0] as SSAOEffect).intensity = this.strength;
|
||||
this.effectComposer.queueRender();
|
||||
}
|
||||
|
||||
[$setDefaultProperties]() {
|
||||
super[$setDefaultProperties]();
|
||||
(this.effects[0] as SSAOEffect).normalBuffer = this.effectComposer.normalBuffer;
|
||||
}
|
||||
|
||||
get [$effectOptions]() {
|
||||
return {
|
||||
worldDistanceThreshold: 1000,
|
||||
worldDistanceFalloff: 1000,
|
||||
worldProximityThreshold: 1000,
|
||||
worldProximityFalloff: 1000,
|
||||
luminanceInfluence: 0.7,
|
||||
samples: 16,
|
||||
fade: 0.05,
|
||||
radius: 0.05,
|
||||
intensity: this.strength,
|
||||
} as ConstructorParameters<typeof SSAOEffect>[2];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { KernelSize } from 'postprocessing';
|
||||
import { PerspectiveCamera } from 'three';
|
||||
import { clamp } from '../utilities.js';
|
||||
import { MVEffectBase } from './mixins/effect-base.js';
|
||||
|
||||
/**
|
||||
* Helper function for calculating the Kernel Size
|
||||
* @param n Range(0, 6)
|
||||
* @returns The relative Kernel Size
|
||||
*/
|
||||
export function getKernelSize(n: number): number {
|
||||
return Math.round(clamp(n + 1, KernelSize.VERY_SMALL, KernelSize.HUGE + 1)) - 1;
|
||||
}
|
||||
|
||||
export function getComponentName(obj: MVEffectBase): string {
|
||||
return '<' + obj.constructor.name.replace('MV', '').split(/(?=[A-Z])/).join('-').toLowerCase() + '>';
|
||||
}
|
||||
|
||||
// Used for effects which require a valid Camera for shader instance
|
||||
export const TEMP_CAMERA = new PerspectiveCamera();
|
||||
@@ -0,0 +1,64 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { MVBloomEffect } from './effects/bloom.js';
|
||||
import { MVColorGradeEffect } from './effects/color-grade.js';
|
||||
import { MVGlitchEffect } from './effects/glitch.js';
|
||||
import { MVOutlineEffect } from './effects/outline.js';
|
||||
import { MVPixelateEffect } from './effects/pixelate.js';
|
||||
import { MVSMAAEffect } from './effects/smaa.js';
|
||||
import { MVSSAOEffect } from './effects/ssao.js';
|
||||
import { MVEffectComposer } from './effect-composer.js';
|
||||
import { MVEffectBase } from './effects/mixins/effect-base.js';
|
||||
import { SelectiveMixin } from './effects/mixins/selective.js';
|
||||
import { MVSelectiveBloomEffect } from './effects/selective-bloom.js';
|
||||
|
||||
customElements.define('effect-composer', MVEffectComposer);
|
||||
customElements.define('pixelate-effect', MVPixelateEffect);
|
||||
customElements.define('bloom-effect', MVBloomEffect);
|
||||
customElements.define('selective-bloom-effect', MVSelectiveBloomEffect);
|
||||
customElements.define('color-grade-effect', MVColorGradeEffect);
|
||||
customElements.define('outline-effect', MVOutlineEffect);
|
||||
customElements.define('smaa-effect', MVSMAAEffect);
|
||||
customElements.define('ssao-effect', MVSSAOEffect);
|
||||
customElements.define('glitch-effect', MVGlitchEffect);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'effect-composer': MVEffectComposer;
|
||||
'pixelate-effect': MVPixelateEffect;
|
||||
'bloom-effect': MVBloomEffect;
|
||||
'selective-bloom-effect': MVSelectiveBloomEffect;
|
||||
'color-grade-effect': MVColorGradeEffect;
|
||||
'outline-effect': MVOutlineEffect;
|
||||
'ssao-effect': MVSSAOEffect;
|
||||
'smaa-effect': MVSMAAEffect;
|
||||
'glitch-effect': MVGlitchEffect;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
MVEffectComposer as EffectComposer,
|
||||
MVPixelateEffect as PixelateEffect,
|
||||
MVBloomEffect as BloomEffect,
|
||||
MVSelectiveBloomEffect as SelectiveBloomEffect,
|
||||
MVColorGradeEffect as ColorGradeEffect,
|
||||
MVOutlineEffect as OutlineEffect,
|
||||
MVSSAOEffect as SSAOEffect,
|
||||
MVSMAAEffect as SMAAEffect,
|
||||
MVGlitchEffect as GlitchEffect,
|
||||
MVEffectBase as EffectBase,
|
||||
SelectiveMixin,
|
||||
};
|
||||
@@ -0,0 +1,102 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ModelViewerElement} from '@google/model-viewer';
|
||||
import {expect} from 'chai';
|
||||
import {DotScreenEffect, Effect, EffectPass, GridEffect} from 'postprocessing';
|
||||
import {Camera} from 'three';
|
||||
|
||||
import {$effectComposer, $normalPass, $renderPass, $scene} from '../effect-composer.js';
|
||||
import {EffectComposer} from '../model-viewer-effects.js';
|
||||
|
||||
import {assetPath, createModelViewerElement, waitForEvent} from './utilities.js';
|
||||
|
||||
suite('MVEffectComposer', () => {
|
||||
let element: ModelViewerElement;
|
||||
let composer: EffectComposer;
|
||||
|
||||
setup(async () => {
|
||||
element = createModelViewerElement(assetPath('models/Astronaut.glb'));
|
||||
composer = new EffectComposer();
|
||||
element.insertBefore(composer, element.firstChild);
|
||||
await waitForEvent(element, 'before-render');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
suite('registered successfully', () => {
|
||||
suite('scene+camera', () => {
|
||||
test('has scene', () => {
|
||||
expect(composer[$scene]).to.be.ok;
|
||||
});
|
||||
|
||||
test('has camera', () => {
|
||||
expect(composer[$scene]).to.be.ok;
|
||||
});
|
||||
});
|
||||
suite('passes, selection', () => {
|
||||
test('renderPass + normalPass added successfuly', () => {
|
||||
expect(composer[$renderPass]).to.be.ok;
|
||||
expect(composer[$normalPass]).to.be.ok;
|
||||
expect(composer[$effectComposer].passes.length).to.eq(2);
|
||||
expect(composer[$effectComposer].passes[0])
|
||||
.to.eq(composer[$renderPass]);
|
||||
expect(composer[$effectComposer].passes[1])
|
||||
.to.eq(composer[$normalPass]);
|
||||
expect(composer[$normalPass].enabled).to.be.false;
|
||||
expect(composer[$normalPass].renderToScreen).to.be.false;
|
||||
expect((composer[$renderPass] as any).scene).to.eq(composer[$scene]);
|
||||
});
|
||||
|
||||
test('selection finds Meshes', () => {
|
||||
expect(composer.selection.size).to.be.greaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('userEffects', () => {
|
||||
let pass: EffectPass;
|
||||
let effects: Effect[] = [];
|
||||
test('adds grid effect', () => {
|
||||
const effect = new GridEffect();
|
||||
effects.push(effect);
|
||||
pass = new EffectPass(composer[$scene]?.camera as Camera, ...effects);
|
||||
composer.addPass(pass);
|
||||
expect(composer[$effectComposer].passes.length).to.eq(3);
|
||||
expect(composer[$effectComposer].passes[2]).to.eq(pass);
|
||||
expect((composer[$effectComposer].passes[2] as any).effects)
|
||||
.to.contain(effect);
|
||||
|
||||
composer.removePass(pass, false);
|
||||
});
|
||||
|
||||
test('multiple effects all on one layer', async () => {
|
||||
const effect = new DotScreenEffect();
|
||||
effects.push(effect);
|
||||
pass = new EffectPass(composer[$scene]?.camera as Camera, ...effects);
|
||||
composer.addPass(pass);
|
||||
expect(composer[$effectComposer].passes.length).to.eq(3);
|
||||
expect(composer[$effectComposer].passes[2]).to.eq(pass);
|
||||
expect((composer[$effectComposer].passes[2] as any).effects.length)
|
||||
.to.eq(2);
|
||||
expect((composer[$effectComposer].passes[2] as any).effects)
|
||||
.to.contain(effect);
|
||||
|
||||
composer.removePass(pass, false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ModelViewerElement} from '@google/model-viewer';
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {ColorGradeEffect, EffectComposer} from '../../model-viewer-effects.js';
|
||||
import {ArraysAreEqual, assetPath, AverageHSL, CompareArrays, createModelViewerElement, rafPasses, screenshot, waitForEvent} from '../utilities.js';
|
||||
|
||||
suite('Color Grade Effect', () => {
|
||||
let element: ModelViewerElement;
|
||||
let composer: EffectComposer;
|
||||
let baseScreenshot: Uint8Array;
|
||||
let colorGrade: ColorGradeEffect;
|
||||
|
||||
setup(async () => {
|
||||
element = createModelViewerElement(assetPath('models/Astronaut.glb'));
|
||||
composer = new EffectComposer();
|
||||
element.insertBefore(composer, element.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
baseScreenshot = screenshot(element);
|
||||
colorGrade = new ColorGradeEffect();
|
||||
composer.insertBefore(colorGrade, composer.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('Color Grade Affects Pixels', async () => {
|
||||
colorGrade.contrast = 1.0;
|
||||
await composer.updateComplete;
|
||||
await rafPasses();
|
||||
const colorGradeScreenshot = screenshot(element);
|
||||
|
||||
expect(ArraysAreEqual(baseScreenshot, colorGradeScreenshot)).to.be.false;
|
||||
expect(CompareArrays(baseScreenshot, colorGradeScreenshot))
|
||||
.to.be.lessThan(0.98);
|
||||
});
|
||||
|
||||
test('Saturation = 0', async () => {
|
||||
colorGrade.saturation = -1;
|
||||
await composer.updateComplete;
|
||||
await rafPasses();
|
||||
const colorGradeScreenshot = screenshot(element);
|
||||
const hslBefore = AverageHSL(baseScreenshot);
|
||||
const hslAfter = AverageHSL(colorGradeScreenshot);
|
||||
expect(hslBefore.s).to.be.greaterThan(hslAfter.s);
|
||||
expect(hslAfter.s).to.be.closeTo(0, 0.01);
|
||||
});
|
||||
|
||||
test('Brightness = 0', async () => {
|
||||
colorGrade.brightness = -1;
|
||||
await composer.updateComplete;
|
||||
await rafPasses();
|
||||
const colorGradeScreenshot = screenshot(element);
|
||||
const hslBefore = AverageHSL(baseScreenshot);
|
||||
const hslAfter = AverageHSL(colorGradeScreenshot);
|
||||
expect(hslBefore.l).to.be.greaterThan(hslAfter.l);
|
||||
expect(hslAfter.l).to.be.eq(0);
|
||||
});
|
||||
|
||||
test('Hue difference', async () => {
|
||||
colorGrade.brightness = colorGrade.contrast = colorGrade.saturation = 0;
|
||||
colorGrade.hue = 2;
|
||||
await composer.updateComplete;
|
||||
await rafPasses();
|
||||
const colorGradeScreenshot = screenshot(element);
|
||||
const hslBefore = AverageHSL(baseScreenshot);
|
||||
const hslAfter = AverageHSL(colorGradeScreenshot);
|
||||
expect(hslBefore.h).to.not.eq(hslAfter.h);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import './color-grade-spec.js';
|
||||
@@ -0,0 +1,18 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import './utilities-spec.js';
|
||||
import './effect-composer-spec.js';
|
||||
import './effects.js';
|
||||
@@ -0,0 +1,79 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ModelViewerElement} from '@google/model-viewer';
|
||||
import {Renderer} from '@google/model-viewer/lib/three-components/Renderer.js';
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {$effectComposer} from '../effect-composer.js';
|
||||
import {EffectComposer} from '../model-viewer-effects.js';
|
||||
import {getOwnPropertySymbolValue} from '../utilities.js';
|
||||
|
||||
import {ArraysAreEqual, assetPath, createModelViewerElement, screenshot, timePasses, waitForEvent} from './utilities.js';
|
||||
|
||||
suite('Screenshot Baseline Test', () => {
|
||||
let element: ModelViewerElement;
|
||||
let baseScreenshot: Uint8Array;
|
||||
|
||||
setup(async () => {
|
||||
element = createModelViewerElement(assetPath('models/Astronaut.glb'));
|
||||
await waitForEvent(element, 'load');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('Compare ModelViewer to Self', async () => {
|
||||
const renderer =
|
||||
getOwnPropertySymbolValue<Renderer>(element, 'renderer') as Renderer;
|
||||
expect(renderer).to.not.be.undefined;
|
||||
expect(renderer.threeRenderer).to.not.be.undefined;
|
||||
await timePasses(5);
|
||||
baseScreenshot = screenshot(element);
|
||||
await timePasses(5);
|
||||
const screenshot2 = screenshot(element);
|
||||
|
||||
expect(ArraysAreEqual(baseScreenshot, screenshot2)).to.be.true;
|
||||
});
|
||||
|
||||
suite('<effect-composer>', () => {
|
||||
let composer: EffectComposer;
|
||||
let composerScreenshot: Uint8Array;
|
||||
|
||||
setup(async () => {
|
||||
composer = new EffectComposer();
|
||||
composer.renderMode = 'quality';
|
||||
composer.msaa = 8;
|
||||
element.insertBefore(composer, element.firstChild);
|
||||
await timePasses(5);
|
||||
});
|
||||
|
||||
test('Compare Self', async () => {
|
||||
const renderer = composer[$effectComposer].getRenderer();
|
||||
expect(renderer).to.not.be.undefined;
|
||||
await timePasses(10);
|
||||
composerScreenshot = screenshot(element);
|
||||
await timePasses(10);
|
||||
const screenshot2 = screenshot(element);
|
||||
|
||||
expect(ArraysAreEqual(composerScreenshot, screenshot2)).to.be.true;
|
||||
});
|
||||
|
||||
test('Empty EffectComposer and base Renderer are identical', () => {
|
||||
expect(ArraysAreEqual(baseScreenshot, composerScreenshot)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,334 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ModelViewerElement} from '@google/model-viewer';
|
||||
import {Renderer} from '@google/model-viewer/lib/three-components/Renderer.js';
|
||||
import {HSL} from 'three';
|
||||
|
||||
import {getOwnPropertySymbolValue} from '../utilities.js';
|
||||
|
||||
export const timePasses = (ms: number = 0): Promise<void> =>
|
||||
new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
/**
|
||||
* Converts a partial URL string to a fully qualified URL string.
|
||||
*
|
||||
* @param {String} url
|
||||
* @return {String}
|
||||
*/
|
||||
export const toFullUrl = (partialUrl: string): string => {
|
||||
const url = new URL(partialUrl, window.location.toString());
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
export const deserializeUrl = (url: string|null): string|null =>
|
||||
(!!url && url !== 'null' ? toFullUrl(url) : null);
|
||||
|
||||
export const elementFromLocalPoint =
|
||||
(document: Document|ShadowRoot, x: number, y: number): Element|null => {
|
||||
const host: HTMLElement = document === window.document ?
|
||||
window.document.body :
|
||||
((document as ShadowRoot).host as HTMLElement);
|
||||
const actualDocument =
|
||||
(window as any).ShadyCSS ? window.document : document;
|
||||
const boundingRect = host.getBoundingClientRect();
|
||||
|
||||
return actualDocument.elementFromPoint(
|
||||
boundingRect.left + x, boundingRect.top + y);
|
||||
};
|
||||
|
||||
export const pickShadowDescendant =
|
||||
(element: Element, x: number = 0, y: number = 0): Element|null => {
|
||||
return element.shadowRoot != null ?
|
||||
elementFromLocalPoint(element.shadowRoot, x, y) :
|
||||
null;
|
||||
};
|
||||
|
||||
export const rafPasses = (): Promise<void> =>
|
||||
new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
|
||||
export interface SyntheticEventProperties {
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
deltaY?: number;
|
||||
key?: string;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a synthetic event on a given element with a given type, and
|
||||
* optionally with custom event properties. Returns the dispatched event.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {type} string
|
||||
* @param {*} properties
|
||||
*/
|
||||
export const dispatchSyntheticEvent =
|
||||
(target: EventTarget, type: string, properties: SyntheticEventProperties = {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
deltaY: 1.0,
|
||||
}): CustomEvent => {
|
||||
const event = new CustomEvent(type, {cancelable: true, bubbles: true});
|
||||
Object.assign(event, properties);
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
export const ASSETS_DIRECTORY = 'packages/shared-assets/';
|
||||
|
||||
/**
|
||||
* Returns the full path for an asset by name. This is a convenience helper so
|
||||
* that we don't need to change paths throughout all test suites if we ever
|
||||
* decide to move files around.
|
||||
*
|
||||
* @param {string} name
|
||||
* @return {string}
|
||||
*/
|
||||
export const assetPath = (name: string): string =>
|
||||
deserializeUrl(`${ASSETS_DIRECTORY}${name}`)!;
|
||||
|
||||
/**
|
||||
* Returns true if the given element is in the tree of the document of the
|
||||
* current frame.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const isInDocumentTree = (node: Node): boolean => {
|
||||
let root: Node = node.getRootNode();
|
||||
|
||||
while (root !== node && root != null) {
|
||||
if (root.nodeType === Node.DOCUMENT_NODE) {
|
||||
return root === document;
|
||||
}
|
||||
|
||||
root = (root as ShadowRoot).host && (root as ShadowRoot).host.getRootNode();
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* "Spies" on a particular object by replacing a specified part of its
|
||||
* implementation with a custom version. Returns a function that restores the
|
||||
* original implementation to the object when invoked.
|
||||
*/
|
||||
export const spy =
|
||||
(object: Object, property: string, descriptor: PropertyDescriptor): (
|
||||
() => void) => {
|
||||
let sourcePrototype = object;
|
||||
|
||||
while (sourcePrototype != null &&
|
||||
!sourcePrototype.hasOwnProperty(property)) {
|
||||
sourcePrototype = (sourcePrototype as any).__proto__;
|
||||
}
|
||||
|
||||
if (sourcePrototype == null) {
|
||||
throw new Error(`Cannot spy property "${property}" on ${object}`);
|
||||
}
|
||||
|
||||
const originalDescriptor =
|
||||
Object.getOwnPropertyDescriptor(sourcePrototype, property);
|
||||
|
||||
if (originalDescriptor == null) {
|
||||
throw new Error(`Cannot read descriptor of "${property}" on ${object}`);
|
||||
}
|
||||
|
||||
Object.defineProperty(sourcePrototype, property, descriptor);
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(sourcePrototype, property, originalDescriptor);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a ModelViewerElement with a given src, attaches to document as first
|
||||
* child and returns
|
||||
* @param src Model to load
|
||||
* @returns element
|
||||
*/
|
||||
export const createModelViewerElement =
|
||||
(src: string|null): ModelViewerElement => {
|
||||
const element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
element.src = src;
|
||||
return element;
|
||||
};
|
||||
|
||||
export type PredicateFunction<T = void> = (value: T) => boolean;
|
||||
|
||||
/**
|
||||
* @param {EventTarget|EventDispatcher} target
|
||||
* @param {string} eventName
|
||||
* @param {?Function} predicate
|
||||
*/
|
||||
export const waitForEvent =
|
||||
<T>(target: any,
|
||||
eventName: string,
|
||||
predicate: PredicateFunction<T>|null = null): Promise<T> =>
|
||||
new Promise((resolve) => {
|
||||
function handler(event: T) {
|
||||
if (!predicate || predicate(event)) {
|
||||
resolve(event);
|
||||
target.removeEventListener(eventName, handler);
|
||||
}
|
||||
}
|
||||
target.addEventListener(eventName, handler);
|
||||
});
|
||||
|
||||
export interface TypedArray<T = unknown> {
|
||||
readonly BYTES_PER_ELEMENT: number;
|
||||
length: number;
|
||||
[n: number]: T;
|
||||
reduce(
|
||||
callbackfn:
|
||||
(previousValue: number, currentValue: number, currentIndex: number,
|
||||
array: TypedArray<number>) => number,
|
||||
initialValue?: number): number;
|
||||
}
|
||||
|
||||
const COMPONENTS_PER_PIXEL = 4;
|
||||
|
||||
export function screenshot(element: ModelViewerElement): Uint8Array {
|
||||
const renderer = getOwnPropertySymbolValue<Renderer>(element, 'renderer');
|
||||
if (!renderer)
|
||||
throw new Error('Invalid element provided');
|
||||
|
||||
const screenshotContext = renderer.threeRenderer.getContext();
|
||||
const width = screenshotContext.drawingBufferWidth;
|
||||
const height = screenshotContext.drawingBufferHeight;
|
||||
|
||||
const pixels = new Uint8Array(width * height * COMPONENTS_PER_PIXEL);
|
||||
// this function reads in the bottom-up direction from the coordinate
|
||||
// specified ((0,0) is the bottom-left corner).
|
||||
screenshotContext.readPixels(
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
screenshotContext.RGBA,
|
||||
screenshotContext.UNSIGNED_BYTE,
|
||||
pixels);
|
||||
|
||||
return pixels;
|
||||
}
|
||||
|
||||
export function ArraysAreEqual(arr1: TypedArray, arr2: TypedArray): boolean {
|
||||
if (arr1.length !== arr2.length)
|
||||
return false;
|
||||
|
||||
for (let i = 0; i < arr1.length; i++) {
|
||||
if (arr1[i] !== arr2[i])
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/*
|
||||
* Compares two
|
||||
* @param arr1
|
||||
* @param arr2
|
||||
* @returns Percentage of similarity (0-1), higher is better
|
||||
*/
|
||||
export function CompareArrays(
|
||||
arr1: TypedArray<number>, arr2: TypedArray<number>): number {
|
||||
if (arr1.length !== arr2.length ||
|
||||
arr1.BYTES_PER_ELEMENT !== arr2.BYTES_PER_ELEMENT)
|
||||
return 0;
|
||||
|
||||
const similarity: number[] = [];
|
||||
const max = maxValue(arr1.BYTES_PER_ELEMENT);
|
||||
for (let i = 0; i < arr1.length; i += COMPONENTS_PER_PIXEL) {
|
||||
if (arr1[i + 3] != 0 && arr2[i + 3] != 0) { // a
|
||||
similarity.push(1 - percentage(arr1[i], arr2[i], max)); // r
|
||||
similarity.push(1 - percentage(arr1[i + 1], arr2[i + 1], max)); // g
|
||||
similarity.push(1 - percentage(arr1[i + 2], arr2[i + 2], max)); // b
|
||||
}
|
||||
}
|
||||
return average(similarity);
|
||||
}
|
||||
|
||||
export function AverageHSL(arr: TypedArray<number>): HSL {
|
||||
const H: number[] = [];
|
||||
const S: number[] = [];
|
||||
const L: number[] = [];
|
||||
for (let i = 0; i < arr.length; i += COMPONENTS_PER_PIXEL) {
|
||||
if (arr[i + 3] != 0) {
|
||||
// a
|
||||
const hsl = rgbToHsl(arr[i], arr[i + 1], arr[i + 2]);
|
||||
H.push(hsl.h);
|
||||
S.push(hsl.s);
|
||||
L.push(hsl.l);
|
||||
}
|
||||
}
|
||||
return {h: average(H), s: average(S), l: average(L)};
|
||||
}
|
||||
|
||||
function maxValue(bytes: number): number {
|
||||
return Math.pow(2, 8 * bytes) - 1;
|
||||
}
|
||||
|
||||
function percentage(n1: number, n2: number, maxN: number): number {
|
||||
return Math.abs(n1 - n2) / maxN;
|
||||
}
|
||||
|
||||
function average(arr: number[]): number {
|
||||
return arr.reduce((a, b) => a + b, 0) / arr.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an RGB color value to HSL. Conversion formula
|
||||
* adapted from http://en.wikipedia.org/wiki/HSL_color_space.
|
||||
* Assumes r, g, and b are contained in the set [0, 255] and
|
||||
* returns h, s, and l in the set [0, 1].
|
||||
*
|
||||
* @param Number r The red color value
|
||||
* @param Number g The green color value
|
||||
* @param Number b The blue color value
|
||||
* @return Array The HSL representation
|
||||
*/
|
||||
function rgbToHsl(r: number, g: number, b: number): HSL {
|
||||
(r /= 255), (g /= 255), (b /= 255);
|
||||
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
|
||||
if (max == min) {
|
||||
h = s = 0; // achromatic
|
||||
} else {
|
||||
var d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
default:
|
||||
throw new Error('invalid rgb');
|
||||
}
|
||||
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return {h, s, l};
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/* @license
|
||||
* Copyright 2023 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Effect, EffectAttribute, EffectPass, Pass } from 'postprocessing';
|
||||
import { ColorRepresentation } from 'three';
|
||||
|
||||
export type Constructor<T = object, U = object> = {
|
||||
new (...args: any[]): T;
|
||||
prototype: T;
|
||||
} & U;
|
||||
|
||||
/**
|
||||
* Get symbol of given key if exists on object.
|
||||
* @param object Object to retrieve symbol from
|
||||
* @param key Key to search for (case sensitive)
|
||||
* @returns `Symbol(key)`
|
||||
*/
|
||||
export function getOwnPropertySymbol(object: any, key: string): symbol | undefined {
|
||||
while (object) {
|
||||
const symbol = Object.getOwnPropertySymbols(object).find((symbol) => symbol.description === key);
|
||||
if (symbol) return symbol;
|
||||
// Search further up in prototype chain
|
||||
object = Object.getPrototypeOf(object);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an object has a Symbol property with the specified key.
|
||||
* @param object Object to retrieve symbol from
|
||||
* @param key Key to search for (case sensitive)
|
||||
*/
|
||||
export function hasOwnPropertySymbol(object: any, key: string): boolean {
|
||||
return getOwnPropertySymbol(object, key) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get value of symbol of given key if exists on object.
|
||||
* @param object Object to retrieve key value from
|
||||
* @param key Key to search for (case sensitive)
|
||||
* @returns `object[Symbol(key)]`
|
||||
*/
|
||||
export function getOwnPropertySymbolValue<T = unknown>(object: any, key: string): T | undefined {
|
||||
const symbol = getOwnPropertySymbol(object, key);
|
||||
return symbol && object[symbol];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} value
|
||||
* @param {Number} lowerLimit
|
||||
* @param {Number} upperLimit
|
||||
* @return {Number} value clamped within `lowerLimit - upperLimit`
|
||||
*/
|
||||
export function clamp(value: number, lowerLimit: number, upperLimit: number): number {
|
||||
return Math.max(lowerLimit, Math.min(upperLimit, value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} value
|
||||
* @returns value clamped between `0 - 1`
|
||||
*/
|
||||
export function clampNormal(value: number): number {
|
||||
return clamp(value, 0, 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Number} value
|
||||
* @param {Number} lowerLimit
|
||||
* @param {Number} upperLimit
|
||||
* @return {Number} wraps value between `lowerLimit - upperLimit`
|
||||
*/
|
||||
export function wrapClamp(value: number, lowerLimit: number, upperLimit: number): number {
|
||||
if (value > upperLimit) return lowerLimit;
|
||||
if (value < lowerLimit) return upperLimit;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches through hierarchy of HTMLElement until an element with a non-transparent background is found
|
||||
* @param elem The element background to get
|
||||
* @returns The backgroundColor
|
||||
*/
|
||||
export function getBackgroundColor(elem: HTMLElement): ColorRepresentation | undefined {
|
||||
let currElem: HTMLElement | null = elem;
|
||||
while (currElem && isTransparent(getComputedStyle(currElem))) {
|
||||
currElem = currElem.parentElement;
|
||||
}
|
||||
if (!currElem) return;
|
||||
return getComputedStyle(currElem).backgroundColor as ColorRepresentation;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether an Element's backgroundColor is transparent
|
||||
* @param style The CSS properties of an Element
|
||||
*/
|
||||
function isTransparent(style: CSSStyleDeclaration): boolean {
|
||||
return style.backgroundColor === 'transparent' || style.backgroundColor === 'rgba(0, 0, 0, 0)' || !style.backgroundColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the given Effect uses Convolution.
|
||||
* @param effect The effect to check.
|
||||
*/
|
||||
export function isConvolution(effect: Effect): boolean {
|
||||
return (effect.getAttributes() & EffectAttribute.CONVOLUTION) != 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of Pass properties without disposing of the Effects.
|
||||
* @param pass Pass to dispose of
|
||||
*/
|
||||
export function disposeEffectPass(pass: EffectPass): void {
|
||||
Pass.prototype.dispose.call(pass);
|
||||
|
||||
if (!(pass as any).listener) return;
|
||||
for (const effect of (pass as any).effects) {
|
||||
effect.removeEventListener('change', (pass as any).listener);
|
||||
}
|
||||
}
|
||||
|
||||
export function getValueOfEnum<T extends Object>(Enum: T, key: string): T {
|
||||
const index = Object.keys(Enum)
|
||||
.filter((v) => !isNaN(Number(v)))
|
||||
.indexOf(key);
|
||||
return (Enum as any)[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to validate whether a value is in-fact a valid option of a literal type.
|
||||
*
|
||||
* Requires the type to be defined as follows:
|
||||
* @code
|
||||
* `const TOptions = [...] as const;`
|
||||
*
|
||||
* `type T = typeof TOptions[number];`
|
||||
* @param options `TOptions`
|
||||
* @param value `value: T`
|
||||
* @throws TypeError
|
||||
*/
|
||||
export function validateLiteralType<TOptions extends readonly unknown[]>(options: TOptions, value: typeof options[number]): void {
|
||||
if (!options.includes(value)) throw new TypeError(`Validation Error: ${value} is not a valid value. Expected ${options.join(' | ')}`);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"module": "es2015",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": [
|
||||
"es2017",
|
||||
"dom"
|
||||
],
|
||||
"sourceMap": true,
|
||||
"inlineSources": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"outDir": "./lib",
|
||||
"experimentalDecorators": true,
|
||||
"noErrorTruncation": true,
|
||||
"declaration": true,
|
||||
"esModuleInterop": true,
|
||||
},
|
||||
"include": [
|
||||
"./src/**/*.ts"
|
||||
],
|
||||
"exclude": []
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
// import {esbuildPlugin} from '@web/dev-server-esbuild';
|
||||
import {devices, playwrightLauncher} from '@web/test-runner-playwright';
|
||||
|
||||
export default {
|
||||
concurrency: 10,
|
||||
nodeResolve: true,
|
||||
files: 'lib/test/**/*-spec.js',
|
||||
// in a monorepo you need to set set the root dir to resolve modules
|
||||
rootDir: '../../',
|
||||
browserLogs: false,
|
||||
filterBrowserLogs:
|
||||
(log) => {
|
||||
return log.type === 'error';
|
||||
},
|
||||
testRunnerHtml: testFramework => `
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="${testFramework}"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
testsFinishTimeout: 300000,
|
||||
testFramework: {
|
||||
config: {
|
||||
ui: 'tdd',
|
||||
timeout: '120000',
|
||||
},
|
||||
},
|
||||
// plugins: [esbuildPlugin({ts: true})],
|
||||
// browsers:
|
||||
// [
|
||||
// playwrightLauncher({
|
||||
// product: 'webkit',
|
||||
// createBrowserContext({browser}) {
|
||||
// return browser.newContext({...devices['iPhone X']});
|
||||
// },
|
||||
// }),
|
||||
// ],
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
renderers/*
|
||||
node_modules/*
|
||||
shared-assets
|
||||
dist/*
|
||||
lib/*
|
||||
**/*.sw*
|
||||
.DS_Store
|
||||
.idea
|
||||
*.iml
|
||||
@@ -0,0 +1,93 @@
|
||||
# Code of Conduct
|
||||
|
||||
## Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
our community a harassment-free experience for everyone, regardless of age, body
|
||||
size, disability, ethnicity, gender identity and expression, level of
|
||||
experience, education, socio-economic status, nationality, personal appearance,
|
||||
race, religion, or sexual identity and orientation.
|
||||
|
||||
## Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual attention or
|
||||
advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit, or reject
|
||||
comments, commits, code, wiki edits, issues, and other contributions that are
|
||||
not aligned to this Code of Conduct, or to ban temporarily or permanently any
|
||||
contributor for other behaviors that they deem inappropriate, threatening,
|
||||
offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
representing a project or community include using an official project e-mail
|
||||
address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
This Code of Conduct also applies outside the project spaces when the Project
|
||||
Steward has a reasonable belief that an individual's behavior may have a
|
||||
negative impact on the project or its community.
|
||||
|
||||
## Conflict Resolution
|
||||
|
||||
We do not believe that all conflict is bad; healthy debate and disagreement
|
||||
often yield positive results. However, it is never okay to be disrespectful or
|
||||
to engage in behavior that violates the project’s code of conduct.
|
||||
|
||||
If you see someone violating the code of conduct, you are encouraged to address
|
||||
the behavior directly with those involved. Many issues can be resolved quickly
|
||||
and easily, and this gives people more control over the outcome of their
|
||||
dispute. If you are unable to resolve the matter for any reason, or if the
|
||||
behavior is threatening or harassing, report it. We are dedicated to providing
|
||||
an environment where participants feel welcome and safe.
|
||||
|
||||
Reports should be directed to Matt Small (mbsmall@google.com), the
|
||||
Project Steward(s) for model-viewer. It is the Project Steward’s duty to
|
||||
receive and address reported violations of the code of conduct. They will then
|
||||
work with a committee consisting of representatives from the Open Source
|
||||
Programs Office and the Google Open Source Strategy team. If for any reason you
|
||||
are uncomfortable reaching out the Project Steward, please email
|
||||
opensource@google.com.
|
||||
|
||||
We will investigate every complaint, but you may not receive a direct response.
|
||||
We will use our discretion in determining when and how to follow up on reported
|
||||
incidents, which may range from not taking action to permanent expulsion from
|
||||
the project and project-sponsored spaces. We will notify the accused of the
|
||||
report and provide them an opportunity to discuss it before any action is taken.
|
||||
The identity of the reporter will be omitted from the details of the report
|
||||
supplied to the accused. In potentially harmful situations, such as ongoing
|
||||
harassment or threats to anyone's safety, we may take action without notice.
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is adapted from the Contributor Covenant, version 1.4,
|
||||
available at
|
||||
https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,100 @@
|
||||
# `<model-viewer>`
|
||||
|
||||
[](https://bundlephobia.com/result?p=@google/model-viewer)
|
||||
[](https://github.com/google/model-viewer/releases)
|
||||
[](https://www.npmjs.com/package/@google/model-viewer)
|
||||
|
||||
[](https://twitter.com/intent/follow?screen_name=modelviewer)
|
||||
[](https://github.com/google/model-viewer/discussions)
|
||||
|
||||
`<model-viewer>` is a web component that makes rendering interactive 3D
|
||||
models - optionally in AR - easy to do, on as many browsers and devices as possible.
|
||||
`<model-viewer>` strives to give you great defaults for rendering quality and
|
||||
performance.
|
||||
|
||||
As new standards and APIs become available `<model-viewer>` will be improved
|
||||
to take advantage of them. If possible, fallbacks and polyfills will be
|
||||
supported to provide a seamless development experience.
|
||||
|
||||
[Demo](https://model-viewer.glitch.me) • [Documentation](https://modelviewer.dev/) • [Quality Comparisons](https://github.khronos.org/glTF-Render-Fidelity/comparison/) (courtesy of Khronos)
|
||||
|
||||
|
||||
## Installing
|
||||
|
||||
### NPM
|
||||
|
||||
The `<model-viewer>` web component can be installed from [NPM](https://npmjs.org):
|
||||
|
||||
```sh
|
||||
# install peer dependency ThreeJS
|
||||
npm install three
|
||||
# install package
|
||||
npm install @google/model-viewer
|
||||
```
|
||||
|
||||
Finally, include the `<model-viewer>` script in your project.
|
||||
|
||||
```js
|
||||
import '@google/model-viewer';
|
||||
```
|
||||
|
||||
### CDN
|
||||
|
||||
It can also be used directly from various free CDNs such as [jsDelivr](https://www.jsdelivr.com/package/npm/@google/model-viewer) and Google's own [hosted libraries](https://developers.google.com/speed/libraries#model-viewer):
|
||||
|
||||
```html
|
||||
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/4.0.0/model-viewer.min.js"></script>
|
||||
```
|
||||
|
||||
For more detailed usage documentation and live examples, please visit our docs
|
||||
at [modelviewer.dev](https://modelviewer.dev)!
|
||||
|
||||
### Important note about versions
|
||||
Our goal for `<model-viewer>` is to be a consistent, stable part of your web
|
||||
platform while continuing to deliver cutting-edge features. We’ll always try
|
||||
to minimize breaking changes, and to keep the component backwards compatible.
|
||||
See our [guide to contributing](../../CONTRIBUTING.md#Stability) for more
|
||||
information on backwards compatibility.
|
||||
|
||||
For your production site you may want the extra stability that comes by
|
||||
pinning to a specific version, and upgrading on your own schedule (after
|
||||
testing).
|
||||
|
||||
If you’ve installed via [NPM](https://www.npmjs.com/package/@google/model-viewer), you’re all set - you’ll only
|
||||
upgrade when you run [`npm update`](https://docs.npmjs.com/cli/update.html).
|
||||
Note that three.js is a peer dependency, so that must also be installed, but can
|
||||
be shared with other bundled code. Note that `<model-viewer>` requires the
|
||||
version of three.js we test on to maintain quality, due to frequent upstream
|
||||
breaking changes. We strongly recommend you keep your three.js version locked to
|
||||
`<model-viewer>`'s. If you must use a different version, npm will give you an
|
||||
error which you can work around using their `--legacy-peer-deps` option, which
|
||||
will allow you to go outside of our version range. Please do not file issues if
|
||||
you use this option.
|
||||
|
||||
## Browser Support
|
||||
|
||||
`<model-viewer>` is supported on the last 2 major versions of all evergreen
|
||||
desktop and mobile browsers.
|
||||
|
||||
| | <img src="https://github.com/alrra/browser-logos/raw/master/src/chrome/chrome_32x32.png" width="16"> Chrome | <img src="https://github.com/alrra/browser-logos/raw/master/src/firefox/firefox_32x32.png" width="16"> Firefox | <img src="https://github.com/alrra/browser-logos/raw/master/src/safari/safari_32x32.png" width="16"> Safari | <img src="https://github.com/alrra/browser-logos/raw/master/src/edge/edge_32x32.png" width="16"> Edge |
|
||||
| -------- | --- | --- | --- | --- |
|
||||
| Desktop | ✅ | ✅ | ✅ | ✅ |
|
||||
| Mobile | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
`<model-viewer>` builds upon standard web platform APIs so that the performance,
|
||||
capabilities and compatibility of the library get better as the web evolves.
|
||||
|
||||
## Development
|
||||
|
||||
To get started, follow the instructions in [the main README.md file](../../README.md).
|
||||
|
||||
The following commands are available when developing `<model-viewer>`:
|
||||
|
||||
Command | Description
|
||||
------------------------------- | -----------
|
||||
`npm run build` | Builds all `<model-viewer>` distributable files
|
||||
`npm run build:dev` | Builds a subset of distributable files (faster than `npm run build`)
|
||||
`npm run test` | Run `<model-viewer>` unit tests
|
||||
`npm run clean` | Deletes all build artifacts
|
||||
`npm run dev` | Starts `tsc` and `rollup` in "watch" mode, causing artifacts to automatically rebuild upon incremental changes
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIDCTCCAfGgAwIBAgIUaB3LM9xSQHJJAoK1tG5rups7VgMwDQYJKoZIhvcNAQEL
|
||||
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDgxODE2MDM1N1oXDTI2MDgx
|
||||
ODE2MDM1N1owFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
|
||||
AAOCAQ8AMIIBCgKCAQEAvhSO3A1WMujzINjK11rYr3jYA2upG5Xdk6GIwFy6gX4T
|
||||
v63tbjT2cwbs2EACDzXsNfCeGvlHVqGHfJd5VDkjkfkPubx820F9RSA8cyV3suPr
|
||||
jjDDBi4UbP4oCuaW4lDuV6YLQrBgpQHGHvA0I7TWJ5wyXtTbCwFcbUJP6B24cZJB
|
||||
lCkdDom1z89UlIZieDrVgqZN51jYqUzW7L0sBspHEqL9DrcsTXP+r3YCAQnKTct0
|
||||
J1fscIaauGdNTqCYSc83bgIOEA6BeiMZsvGHwJMPL6a+hV5kTxe6Y/k0GKfyEcI0
|
||||
/QfFbXf1rdgEuk1SQE5Vtyk8s2OLm7S0R72mXUknYwIDAQABo1MwUTAdBgNVHQ4E
|
||||
FgQUiCiN58HZm/JnB00aCWZcwXa59fgwHwYDVR0jBBgwFoAUiCiN58HZm/JnB00a
|
||||
CWZcwXa59fgwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEABa24
|
||||
P/Ckxj4VvCpKyU15x2GSsULCY48OgxBCU2gTqcrZr56JuoaHNdyulSqP404kfcyb
|
||||
dLoTsg+DSDF1OOgUkv7Fv4+gch+gtTx2yXs0K+9AkoFsXJvjufoM14+vGB/OmsgM
|
||||
BLd+9XUUONQazfl3ttca9xMyyCdUb+pRtVfnjRm40vpOXUlIMBKXrQUU/CVlLM+h
|
||||
Ym5dJOq2cUvrqSAo9Sqyn0/wIW6wml6ZihPdu5LpXYUB5pBqmjD6+dYh8/bJKBg/
|
||||
uFQK8BsvMAQdMjPv3yIZKhmESSDFIlb4nm7T63D1yzFvsViXX6GnY5tJZfnPWi1k
|
||||
HHRktvgW/t2fXPAEpA==
|
||||
-----END CERTIFICATE-----
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC+FI7cDVYy6PMg
|
||||
2MrXWtiveNgDa6kbld2ToYjAXLqBfhO/re1uNPZzBuzYQAIPNew18J4a+UdWoYd8
|
||||
l3lUOSOR+Q+5vHzbQX1FIDxzJXey4+uOMMMGLhRs/igK5pbiUO5XpgtCsGClAcYe
|
||||
8DQjtNYnnDJe1NsLAVxtQk/oHbhxkkGUKR0OibXPz1SUhmJ4OtWCpk3nWNipTNbs
|
||||
vSwGykcSov0OtyxNc/6vdgIBCcpNy3QnV+xwhpq4Z01OoJhJzzduAg4QDoF6Ixmy
|
||||
8YfAkw8vpr6FXmRPF7pj+TQYp/IRwjT9B8Vtd/Wt2AS6TVJATlW3KTyzY4ubtLRH
|
||||
vaZdSSdjAgMBAAECggEAAheVKvQ1SqzTCugtSLQwm7MneCzV2P6L8yAIB/X9UjI3
|
||||
uBhgpfc3RIoto9kF13JZ0zjUGf/nD8ZfXg5cwNc606nQySk7RpPbSR4oYYFmu9/+
|
||||
VmZQVIpqoc3PqwRhYhmkm0UHQrNQ8lVT/5XX57y0eWMiussk+Li0cmS/HxLqAMtS
|
||||
bmJX23R2HAtwnI1l57c2loJApxOBl7nbRGtB3bKD2Bk0PPi8RqY7K+UmHnlp/8T9
|
||||
A1FOarqoEUjmej/bgyxDgdVRIcMBu+yrWhjBIKOdKZyIkG0deFz+i7vNUq2LRLA7
|
||||
bsnkqThcL43596K8zvXpw5N6cHr8yBhveIv5OWUVIQKBgQD0jtJrbJsTmY2KHU7P
|
||||
6LHk4WHNqU75ioZF1WeuCGLzcAQ9d6p8DY1hl0Sb7Yp6N27+QMT+COpmlQUpns24
|
||||
9+KKsPP8Wf8wDeB5uibqFK+z2uOuAPRiTvlRq9TWOFCgLDuAJHRD9Hrb+Daa2Vi+
|
||||
XydL//iBR4FI8AslpHCmSCPoFQKBgQDG+TvfXibctXhy7UbKALSaXri7ZMjOW4pa
|
||||
z+54cyXzmE+a2tgxpqQxZa8HGN5B8c+ko4DOsBoHNIxWnugDoI4Hy25+36brl2oo
|
||||
N7BkZKUznuzhVH+TOIBGZAanScsJLaEpmaS8drvF+9fDr/dCXCoX2y+LK37qCOxu
|
||||
lcZ08zP3lwKBgAx3n3iEf99e651H4zWsKi194+uFHxaPnkq/F1sC6HB6nGy5xgIu
|
||||
+q8n9AJy+KVEYC8sBB7jO3fhTvMROnGciXsCjF2oBN9hRblO6R7z0QU9OnArckn0
|
||||
trcYKHCHTGzt9FaTBS5Vr1G5dKcuP1ztIua39OY6S/f47MiNeoSvls0FAoGBAL3X
|
||||
VIHE8i1I05hLvWvEioxy7ayV92W0P2hv1aaEruQhIWqtfPK6fRRIYVvTJVQj5CrA
|
||||
eYg6y0qun2uSB+pWCM11EoLo3vkPKZEfuSPAR0LeUkKXfXU4xmLi0tpP9PFX4Nmx
|
||||
J1VNr9CxfebOgIqHJv5F+bG+GUQwqWzFaGlzFdUXAoGAQFiPZtxM+FPmSWz8PzeK
|
||||
2FwVURXDW6iU+RtEQPfP+sbd/qwTv5Y0bJHde/JBlzjmc8K3KuAoij3ezPqvh1SL
|
||||
oC+pA0DbpTPgb1x3KRmp0asMq+xt5UtrHpkfk6hKREu2D/G9Xrlr6ClUSW3ztVnH
|
||||
h9x0xHJ+Tj/zjK67JIGBLxA=
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"name": "@google/model-viewer",
|
||||
"version": "4.1.0",
|
||||
"description": "Easily display interactive 3D models on the web and in AR!",
|
||||
"repository": "https://github.com/google/model-viewer",
|
||||
"bugs": {
|
||||
"url": "https://github.com/google/model-viewer/issues"
|
||||
},
|
||||
"homepage": "https://github.com/google/model-viewer#readme",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
},
|
||||
"contributors": [
|
||||
"Jordan Santell <[email protected]>",
|
||||
"Chris Joel <[email protected]>",
|
||||
"Emmett Lalish <[email protected]>",
|
||||
"Ricardo Cabello <[email protected]>",
|
||||
"Matt Small <[email protected]>",
|
||||
"Yuin Chien <[email protected]>",
|
||||
"Diego Teran <[email protected]>"
|
||||
],
|
||||
"main": "dist/model-viewer.min.js",
|
||||
"module": "lib/model-viewer.js",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"src",
|
||||
"lib",
|
||||
"dist/model-viewer.js",
|
||||
"dist/model-viewer.js.map",
|
||||
"dist/model-viewer.min.js",
|
||||
"dist/model-viewer.min.js.map",
|
||||
"dist/model-viewer-umd.js",
|
||||
"dist/model-viewer-umd.js.map",
|
||||
"dist/model-viewer-umd.min.js",
|
||||
"dist/model-viewer-umd.min.js.map",
|
||||
"dist/model-viewer-module.js",
|
||||
"dist/model-viewer-module.js.map",
|
||||
"dist/model-viewer-module.min.js",
|
||||
"dist/model-viewer-module.min.js.map",
|
||||
"dist/model-viewer-module-umd.js",
|
||||
"dist/model-viewer-module-umd.js.map",
|
||||
"dist/model-viewer-module-umd.min.js",
|
||||
"dist/model-viewer-module-umd.min.js.map",
|
||||
"dist/model-viewer.d.ts"
|
||||
],
|
||||
"typings": "lib/model-viewer.d.ts",
|
||||
"types": "lib/model-viewer.d.ts",
|
||||
"scripts": {
|
||||
"clean": "rm -rf ./lib ./dist",
|
||||
"prepare": "if [ ! -L './shared-assets' ]; then ln -s ../shared-assets ./shared-assets; fi && ../shared-assets/scripts/fetch-khronos-gltf-samples.sh",
|
||||
"build": "npm run build:tsc && npm run build:rollup",
|
||||
"build:dev": "npm run build:tsc && npm run build:rollup:dev",
|
||||
"build:tsc": "tsc --incremental",
|
||||
"build:rollup": "rollup -c --environment NODE_ENV:production",
|
||||
"build:rollup:dev": "rollup -c --environment NODE_ENV:development",
|
||||
"prepublishOnly": "npm run build",
|
||||
"test": "web-test-runner --playwright --browsers chromium firefox webkit",
|
||||
"test:ci": "web-test-runner --static-logging --config web-test-ci.config.mjs",
|
||||
"check-fidelity": "node ./test/fidelity/index.js ./test/fidelity/config.json",
|
||||
"compare-fidelity": "./scripts/compare-fidelity-to-ref.sh",
|
||||
"serve": "../../node_modules/.bin/http-server -c-1 -S -p 3000",
|
||||
"dev": "npm run build:dev && npm-run-all --parallel 'watch:tsc -- --preserveWatchOutput' 'watch:test' 'serve -- -s'",
|
||||
"watch:tsc": "tsc -w --incremental",
|
||||
"watch:rollup": "rollup -c -w --environment NODE_ENV:production",
|
||||
"watch:rollup:dev": "rollup -c -w --environment NODE_ENV:development",
|
||||
"watch:test": "web-test-runner --playwright --browsers chromium --watch",
|
||||
"build:dev:serve": "npm run build:dev && npm run serve"
|
||||
},
|
||||
"keywords": [
|
||||
"ar",
|
||||
"gltf",
|
||||
"glb",
|
||||
"webar",
|
||||
"webvr",
|
||||
"webxr",
|
||||
"arcore",
|
||||
"arkit",
|
||||
"webaronarcore",
|
||||
"webaronarkit",
|
||||
"augmented reality",
|
||||
"model-viewer",
|
||||
"3d"
|
||||
],
|
||||
"dependencies": {
|
||||
"@monogrid/gainmap-js": "^3.1.0",
|
||||
"lit": "^3.2.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"three": "^0.174.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@rollup/plugin-commonjs": "^28.0.3",
|
||||
"@rollup/plugin-node-resolve": "^16.0.0",
|
||||
"@rollup/plugin-replace": "^6.0.2",
|
||||
"@rollup/plugin-swc": "^0.4.0",
|
||||
"@rollup/plugin-terser": "^0.4.4",
|
||||
"@swc/core": "^1.11.8",
|
||||
"@types/mocha": "^10.0.10",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/three": "^0.174.0",
|
||||
"@ungap/event-target": "^0.2.4",
|
||||
"@web/test-runner": "^0.20.0",
|
||||
"@web/test-runner-playwright": "^0.11.0",
|
||||
"chai": "^5.2.0",
|
||||
"http-server": "^14.1.1",
|
||||
"mocha": "^11.1.0",
|
||||
"npm-run-all": "^4.1.5",
|
||||
"rollup": "^4.35.0",
|
||||
"rollup-plugin-cleanup": "^3.2.1",
|
||||
"rollup-plugin-dts": "^6.1.1",
|
||||
"rollup-plugin-polyfill": "^4.2.0",
|
||||
"three": "^0.174.0",
|
||||
"typescript": "5.8.2"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import commonjs from '@rollup/plugin-commonjs';
|
||||
import {nodeResolve as resolve} from '@rollup/plugin-node-resolve';
|
||||
import replace from '@rollup/plugin-replace';
|
||||
import swc from '@rollup/plugin-swc';
|
||||
import terser from '@rollup/plugin-terser';
|
||||
import cleanup from 'rollup-plugin-cleanup';
|
||||
import dts from 'rollup-plugin-dts';
|
||||
|
||||
const {NODE_ENV} = process.env;
|
||||
|
||||
const onwarn = (warning, warn) => {
|
||||
// Suppress non-actionable warning caused by TypeScript boilerplate:
|
||||
if (warning.code !== 'THIS_IS_UNDEFINED') {
|
||||
warn(warning);
|
||||
}
|
||||
};
|
||||
|
||||
let commonPlugins = [
|
||||
resolve({dedupe: 'three'}),
|
||||
replace({'Reflect.decorate': 'undefined', preventAssignment: true})
|
||||
];
|
||||
|
||||
const watchFiles = ['lib/**'];
|
||||
|
||||
const createModelViewerOutput =
|
||||
(file, format, plugins = commonPlugins, external = []) => {
|
||||
const globals = external.reduce((acc, mod) => {
|
||||
acc[mod] =
|
||||
mod; // Assuming global variable names are the same as module names
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
input: './lib/model-viewer.js',
|
||||
output: {
|
||||
file,
|
||||
format,
|
||||
sourcemap: true,
|
||||
name: 'ModelViewerElement',
|
||||
globals
|
||||
},
|
||||
external,
|
||||
watch: {include: watchFiles},
|
||||
plugins,
|
||||
onwarn
|
||||
};
|
||||
};
|
||||
|
||||
const outputOptions = [
|
||||
createModelViewerOutput('./dist/model-viewer.js', 'esm'),
|
||||
createModelViewerOutput(
|
||||
'./dist/model-viewer-module.js', 'esm', commonPlugins, ['three'])
|
||||
];
|
||||
|
||||
if (NODE_ENV !== 'development') {
|
||||
const pluginsIE11 = [
|
||||
...commonPlugins,
|
||||
commonjs(),
|
||||
swc(),
|
||||
cleanup({
|
||||
// Ideally we'd also clean third_party/three, which saves
|
||||
// ~45kb in filesize alone... but takes 2 minutes to build
|
||||
include: ['lib/**'],
|
||||
comments: 'none',
|
||||
}),
|
||||
];
|
||||
|
||||
// IE11 does not support modules, so they are removed here, as well as in a
|
||||
// dedicated unit test build which is needed for the same reason.
|
||||
outputOptions.push(
|
||||
createModelViewerOutput('./dist/model-viewer-umd.js', 'umd', pluginsIE11),
|
||||
/** Bundled w/o three */
|
||||
createModelViewerOutput(
|
||||
'./dist/model-viewer-module-umd.js', 'umd', pluginsIE11, ['three']));
|
||||
|
||||
// Minified Versions
|
||||
const minifiedPlugins = [...commonPlugins, terser()];
|
||||
|
||||
outputOptions.push(
|
||||
createModelViewerOutput(
|
||||
'./dist/model-viewer.min.js', 'esm', minifiedPlugins),
|
||||
createModelViewerOutput(
|
||||
'./dist/model-viewer-umd.min.js', 'umd', minifiedPlugins),
|
||||
createModelViewerOutput(
|
||||
'./dist/model-viewer-module.min.js',
|
||||
'esm',
|
||||
minifiedPlugins,
|
||||
['three']),
|
||||
createModelViewerOutput(
|
||||
'./dist/model-viewer-module-umd.min.js', 'umd', minifiedPlugins, [
|
||||
'three'
|
||||
]));
|
||||
|
||||
outputOptions.push({
|
||||
input: './lib/model-viewer.d.ts',
|
||||
output: {
|
||||
file: './dist/model-viewer.d.ts',
|
||||
format: 'esm',
|
||||
name: 'ModelViewerElement',
|
||||
},
|
||||
plugins: [dts()],
|
||||
});
|
||||
}
|
||||
|
||||
export default outputOptions;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 698 KiB |
@@ -0,0 +1,36 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {html} from 'lit';
|
||||
|
||||
export default html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#000000">
|
||||
<!-- NOTE(cdata): This SVG filter is a stop-gap until we can implement
|
||||
support for dynamic re-coloring of UI components -->
|
||||
<defs>
|
||||
<filter id="drop-shadow" x="-100%" y="-100%" width="300%" height="300%">
|
||||
<feGaussianBlur in="SourceAlpha" stdDeviation="1"/>
|
||||
<feOffset dx="0" dy="0" result="offsetblur"/>
|
||||
<feFlood flood-color="#000000"/>
|
||||
<feComposite in2="offsetblur" operator="in"/>
|
||||
<feMerge>
|
||||
<feMergeNode/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<path filter="url(#drop-shadow)" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
|
||||
<path d="M0 0h24v24H0z" fill="none"/>
|
||||
</svg>`;
|
||||
@@ -0,0 +1,37 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {html} from 'lit';
|
||||
|
||||
export default html`
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="36">
|
||||
<defs>
|
||||
<path id="A" d="M.001.232h24.997V36H.001z" />
|
||||
</defs>
|
||||
<g transform="translate(-11 -4)" fill="none" fill-rule="evenodd">
|
||||
<path fill-opacity="0" fill="#fff" d="M0 0h44v44H0z" />
|
||||
<g transform="translate(11 3)">
|
||||
<path d="M8.733 11.165c.04-1.108.766-2.027 1.743-2.307a2.54 2.54 0 0 1 .628-.089c.16 0 .314.017.463.044 1.088.2 1.9 1.092 1.9 2.16v8.88h1.26c2.943-1.39 5-4.45 5-8.025a9.01 9.01 0 0 0-1.9-5.56l-.43-.5c-.765-.838-1.683-1.522-2.712-2-1.057-.49-2.226-.77-3.46-.77s-2.4.278-3.46.77c-1.03.478-1.947 1.162-2.71 2l-.43.5a9.01 9.01 0 0 0-1.9 5.56 9.04 9.04 0 0 0 .094 1.305c.03.21.088.41.13.617l.136.624c.083.286.196.56.305.832l.124.333a8.78 8.78 0 0 0 .509.953l.065.122a8.69 8.69 0 0 0 3.521 3.191l1.11.537v-9.178z" fill-opacity=".5" fill="#e4e4e4" />
|
||||
<path d="M22.94 26.218l-2.76 7.74c-.172.485-.676.8-1.253.8H12.24c-1.606 0-3.092-.68-3.98-1.82-1.592-2.048-3.647-3.822-6.11-5.27-.095-.055-.15-.137-.152-.23-.004-.1.046-.196.193-.297.56-.393 1.234-.6 1.926-.6a3.43 3.43 0 0 1 .691.069l4.922.994V10.972c0-.663.615-1.203 1.37-1.203s1.373.54 1.373 1.203v9.882h2.953c.273 0 .533.073.757.21l6.257 3.874c.027.017.045.042.07.06.41.296.586.77.426 1.22M4.1 16.614c-.024-.04-.042-.083-.065-.122a8.69 8.69 0 0 1-.509-.953c-.048-.107-.08-.223-.124-.333l-.305-.832c-.058-.202-.09-.416-.136-.624l-.13-.617a9.03 9.03 0 0 1-.094-1.305c0-2.107.714-4.04 1.9-5.56l.43-.5c.764-.84 1.682-1.523 2.71-2 1.058-.49 2.226-.77 3.46-.77s2.402.28 3.46.77c1.03.477 1.947 1.16 2.712 2l.428.5a9 9 0 0 1 1.901 5.559c0 3.577-2.056 6.636-5 8.026h-1.26v-8.882c0-1.067-.822-1.96-1.9-2.16-.15-.028-.304-.044-.463-.044-.22 0-.427.037-.628.09-.977.28-1.703 1.198-1.743 2.306v9.178l-1.11-.537C6.18 19.098 4.96 18 4.1 16.614M22.97 24.09l-6.256-3.874c-.102-.063-.218-.098-.33-.144 2.683-1.8 4.354-4.855 4.354-8.243 0-.486-.037-.964-.104-1.43a9.97 9.97 0 0 0-1.57-4.128l-.295-.408-.066-.092a10.05 10.05 0 0 0-.949-1.078c-.342-.334-.708-.643-1.094-.922-1.155-.834-2.492-1.412-3.94-1.65l-.732-.088-.748-.03a9.29 9.29 0 0 0-1.482.119c-1.447.238-2.786.816-3.94 1.65a9.33 9.33 0 0 0-.813.686 9.59 9.59 0 0 0-.845.877l-.385.437-.36.5-.288.468-.418.778-.04.09c-.593 1.28-.93 2.71-.93 4.222 0 3.832 2.182 7.342 5.56 8.938l1.437.68v4.946L5 25.64a4.44 4.44 0 0 0-.888-.086c-.017 0-.034.003-.05.003-.252.004-.503.033-.75.08a5.08 5.08 0 0 0-.237.056c-.193.046-.382.107-.568.18-.075.03-.15.057-.225.1-.25.114-.494.244-.723.405a1.31 1.31 0 0 0-.566 1.122 1.28 1.28 0 0 0 .645 1.051C4 29.925 5.96 31.614 7.473 33.563a5.06 5.06 0 0 0 .434.491c1.086 1.082 2.656 1.713 4.326 1.715h6.697c.748-.001 1.43-.333 1.858-.872.142-.18.256-.38.336-.602l2.757-7.74c.094-.26.13-.53.112-.794s-.088-.52-.203-.76a2.19 2.19 0 0 0-.821-.91" fill-opacity=".6" fill="#000" />
|
||||
<path d="M22.444 24.94l-6.257-3.874a1.45 1.45 0 0 0-.757-.211h-2.953v-9.88c0-.663-.616-1.203-1.373-1.203s-1.37.54-1.37 1.203v16.643l-4.922-.994a3.44 3.44 0 0 0-.692-.069 3.35 3.35 0 0 0-1.925.598c-.147.102-.198.198-.194.298.004.094.058.176.153.23 2.462 1.448 4.517 3.22 6.11 5.27.887 1.14 2.373 1.82 3.98 1.82h6.686c.577 0 1.08-.326 1.253-.8l2.76-7.74c.16-.448-.017-.923-.426-1.22-.025-.02-.043-.043-.07-.06z" fill="#fff" />
|
||||
<g transform="translate(0 .769)">
|
||||
<mask id="B" fill="#fff">
|
||||
<use xlink:href="#A" />
|
||||
</mask>
|
||||
<path d="M23.993 24.992a1.96 1.96 0 0 1-.111.794l-2.758 7.74c-.08.22-.194.423-.336.602-.427.54-1.11.87-1.857.872h-6.698c-1.67-.002-3.24-.633-4.326-1.715-.154-.154-.3-.318-.434-.49C5.96 30.846 4 29.157 1.646 27.773c-.385-.225-.626-.618-.645-1.05a1.31 1.31 0 0 1 .566-1.122 4.56 4.56 0 0 1 .723-.405l.225-.1a4.3 4.3 0 0 1 .568-.18l.237-.056c.248-.046.5-.075.75-.08.018 0 .034-.003.05-.003.303-.001.597.027.89.086l3.722.752V20.68l-1.436-.68c-3.377-1.596-5.56-5.106-5.56-8.938 0-1.51.336-2.94.93-4.222.015-.03.025-.06.04-.09.127-.267.268-.525.418-.778.093-.16.186-.316.288-.468.063-.095.133-.186.2-.277L3.773 5c.118-.155.26-.29.385-.437.266-.3.544-.604.845-.877a9.33 9.33 0 0 1 .813-.686C6.97 2.167 8.31 1.59 9.757 1.35a9.27 9.27 0 0 1 1.481-.119 8.82 8.82 0 0 1 .748.031c.247.02.49.05.733.088 1.448.238 2.786.816 3.94 1.65.387.28.752.588 1.094.922a9.94 9.94 0 0 1 .949 1.078l.066.092c.102.133.203.268.295.408a9.97 9.97 0 0 1 1.571 4.128c.066.467.103.945.103 1.43 0 3.388-1.67 6.453-4.353 8.243.11.046.227.08.33.144l6.256 3.874c.37.23.645.55.82.9.115.24.185.498.203.76m.697-1.195c-.265-.55-.677-1.007-1.194-1.326l-5.323-3.297c2.255-2.037 3.564-4.97 3.564-8.114 0-2.19-.637-4.304-1.84-6.114-.126-.188-.26-.37-.4-.552-.645-.848-1.402-1.6-2.252-2.204C15.472.91 13.393.232 11.238.232A10.21 10.21 0 0 0 5.23 2.19c-.848.614-1.606 1.356-2.253 2.205-.136.18-.272.363-.398.55C1.374 6.756.737 8.87.737 11.06c0 4.218 2.407 8.08 6.133 9.842l.863.41v3.092l-2.525-.51c-.356-.07-.717-.106-1.076-.106a5.45 5.45 0 0 0-3.14.996c-.653.46-1.022 1.202-.99 1.983a2.28 2.28 0 0 0 1.138 1.872c2.24 1.318 4.106 2.923 5.543 4.772 1.26 1.62 3.333 2.59 5.55 2.592h6.698c1.42-.001 2.68-.86 3.134-2.138l2.76-7.74c.272-.757.224-1.584-.134-2.325" fill-opacity=".05" fill="#000" mask="url(#B)" />
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
@@ -0,0 +1,34 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {html} from 'lit';
|
||||
|
||||
export default html`
|
||||
<svg version="1.1" id="view_x5F_in_x5F_AR_x5F_icon"
|
||||
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="24px" height="24px"
|
||||
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
|
||||
<rect id="Bounding_Box" x="0" y="0" fill="none" width="24" height="24"/>
|
||||
<g id="Art_layer">
|
||||
<path d="M3,4c0-0.55,0.45-1,1-1h2V1H4C2.35,1,1,2.35,1,4v2h2V4z"/>
|
||||
<path d="M20,3c0.55,0,1,0.45,1,1v2h2V4c0-1.65-1.35-3-3-3h-2v2H20z"/>
|
||||
<path d="M4,21c-0.55,0-1-0.45-1-1v-2H1v2c0,1.65,1.35,3,3,3h2v-2H4z"/>
|
||||
<path d="M20,21c0.55,0,1-0.45,1-1v-2h2v2c0,1.65-1.35,3-3,3h-2v-2H20z"/>
|
||||
<g>
|
||||
<path d="M18.25,7.6l-5.5-3.18c-0.46-0.27-1.04-0.27-1.5,0L5.75,7.6C5.29,7.87,5,8.36,5,8.9v6.35c0,0.54,0.29,1.03,0.75,1.3
|
||||
l5.5,3.18c0.46,0.27,1.04,0.27,1.5,0l5.5-3.18c0.46-0.27,0.75-0.76,0.75-1.3V8.9C19,8.36,18.71,7.87,18.25,7.6z M7,14.96v-4.62
|
||||
l4,2.32v4.61L7,14.96z M12,10.93L8,8.61l4-2.31l4,2.31L12,10.93z M13,17.27v-4.61l4-2.32v4.62L13,17.27z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>`;
|
||||
@@ -0,0 +1,103 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// NOTE(cdata): The HAS_WEBXR_* constants can be enabled in Chrome by turning on
|
||||
// the appropriate flags. However, just because we have the API does not
|
||||
// guarantee that AR will work.
|
||||
export const HAS_WEBXR_DEVICE_API = navigator.xr != null &&
|
||||
(self as any).XRSession != null && navigator.xr.isSessionSupported != null;
|
||||
|
||||
export const HAS_WEBXR_HIT_TEST_API = HAS_WEBXR_DEVICE_API &&
|
||||
(self as any).XRSession.prototype.requestHitTestSource != null;
|
||||
|
||||
export const HAS_RESIZE_OBSERVER = self.ResizeObserver != null;
|
||||
|
||||
export const HAS_INTERSECTION_OBSERVER = self.IntersectionObserver != null;
|
||||
|
||||
export const IS_WEBXR_AR_CANDIDATE = HAS_WEBXR_HIT_TEST_API;
|
||||
|
||||
export const IS_MOBILE = (() => {
|
||||
const userAgent =
|
||||
navigator.userAgent || navigator.vendor || (self as any).opera;
|
||||
let check = false;
|
||||
// eslint-disable-next-line
|
||||
if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i
|
||||
.test(userAgent) ||
|
||||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i
|
||||
.test(userAgent.substr(0, 4))) {
|
||||
check = true;
|
||||
}
|
||||
return check;
|
||||
})();
|
||||
|
||||
export const IS_CHROMEOS = /\bCrOS\b/.test(navigator.userAgent);
|
||||
|
||||
export const IS_ANDROID = /android/i.test(navigator.userAgent);
|
||||
|
||||
// Prior to iOS 13, detecting iOS Safari was relatively straight-forward.
|
||||
// As of iOS 13, Safari on iPad (in its default configuration) reports the same
|
||||
// user-agent string as Safari on desktop MacOS. Strictly speaking, we only care
|
||||
// about iOS for the purposes if selecting for cases where Quick Look is known
|
||||
// to be supported. However, for API correctness purposes, we must rely on
|
||||
// known, detectable signals to distinguish iOS Safari from MacOS Safari. At the
|
||||
// time of this writing, there are no non-iOS/iPadOS Apple devices with
|
||||
// multi-touch displays.
|
||||
// @see https://stackoverflow.com/questions/57765958/how-to-detect-ipad-and-ipad-os-version-in-ios-13-and-up
|
||||
// @see https://forums.developer.apple.com/thread/119186
|
||||
// @see https://github.com/google/model-viewer/issues/758
|
||||
export const IS_IOS =
|
||||
(/iPad|iPhone|iPod/.test(navigator.userAgent) && !(self as any).MSStream) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
// @see https://developer.chrome.com/multidevice/user-agent
|
||||
export const IS_SAFARI = /Safari\//.test(navigator.userAgent);
|
||||
export const IS_FIREFOX = /firefox/i.test(navigator.userAgent);
|
||||
export const IS_OCULUS = /OculusBrowser/.test(navigator.userAgent);
|
||||
export const IS_IOS_CHROME = IS_IOS && /CriOS\//.test(navigator.userAgent);
|
||||
export const IS_IOS_SAFARI = IS_IOS && IS_SAFARI;
|
||||
|
||||
export const IS_SCENEVIEWER_CANDIDATE = IS_ANDROID && !IS_FIREFOX && !IS_OCULUS;
|
||||
|
||||
// Extend Window type with webkit property,
|
||||
// required to check if iOS is running within a WKWebView browser instance.
|
||||
declare global {
|
||||
interface Window {
|
||||
webkit?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export const IS_WKWEBVIEW =
|
||||
Boolean(window.webkit && window.webkit.messageHandlers);
|
||||
|
||||
// If running in iOS Safari proper, and not within a WKWebView component
|
||||
// instance, check for ARQL feature support. Otherwise, if running in a
|
||||
// WKWebView instance, check for known ARQL compatible iOS browsers, including:
|
||||
// Chrome (CriOS), Edge (EdgiOS), Firefox (FxiOS), Google App (GSA), DuckDuckGo
|
||||
// (DuckDuckGo). All other iOS browsers / apps will fail by default.
|
||||
export const IS_AR_QUICKLOOK_CANDIDATE = (() => {
|
||||
if (IS_IOS) {
|
||||
if (!IS_WKWEBVIEW) {
|
||||
const tempAnchor = document.createElement('a');
|
||||
return Boolean(
|
||||
tempAnchor.relList && tempAnchor.relList.supports &&
|
||||
tempAnchor.relList.supports('ar'));
|
||||
} else {
|
||||
return Boolean(/CriOS\/|EdgiOS\/|FxiOS\/|GSA\/|DuckDuckGo\//.test(
|
||||
navigator.userAgent));
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,162 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ReactiveElement} from 'lit';
|
||||
|
||||
import {EvaluatedStyle, Intrinsics, StyleEvaluator} from './styles/evaluators.js';
|
||||
import {parseExpressions, Unit} from './styles/parsers.js';
|
||||
import {StyleEffector} from './styles/style-effector.js';
|
||||
|
||||
// An IntrinsicsFactory generates up-to-date intrinsics for a given ModelViewer
|
||||
// element instance when it is invoked.
|
||||
export type IntrinsicsFactory<T extends Intrinsics<Array<Unit>>,
|
||||
U extends ReactiveElement> =
|
||||
(element: U) => T;
|
||||
|
||||
// When applying the @style decorator, it needs to be configured with
|
||||
// corresponding Intrinsics and the property key of a method to receive updated
|
||||
// values. Optionally, it can also be configured to observe environment effects,
|
||||
// which causes a StyleEffector to be created for the property.
|
||||
export interface StyleDecoratorConfig<T extends Intrinsics<Array<Unit>>,
|
||||
U extends ReactiveElement> {
|
||||
intrinsics: T|IntrinsicsFactory<T, U>;
|
||||
updateHandler: symbol;
|
||||
observeEffects?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The @style decorator is responsible for coordinating the conversion of a
|
||||
* CSS-like string property value into numbers that can be applied to
|
||||
* lower-level constructs. It also can optionally manage the lifecycle of a
|
||||
* StyleEffector which allows automatic updates for styles that use env() or
|
||||
* var() functions.
|
||||
*
|
||||
* The decorator is configured with Intrinsics and the property key for a
|
||||
* method that handles updates. The named update handler is invoked with the
|
||||
* result of parsing and evaluating the raw property string value. The format of
|
||||
* the evaluated result is derived from the basis of the configured Intrinsics,
|
||||
* and is always an array of numbers of fixed length.
|
||||
*
|
||||
* NOTE: This decorator depends on the property updating mechanism defined by
|
||||
* UpdatingElement as exported by the lit-element module. That means it *must*
|
||||
* be used in conjunction with the @property decorator, or equivalent
|
||||
* JavaScript.
|
||||
*
|
||||
* Supported configurations are:
|
||||
*
|
||||
* - `intrinsics`: An Intrinsics struct that describes how to interpret a
|
||||
* serialized style attribute. For more detail on intrinsics see
|
||||
* ./styles/evaluators.ts
|
||||
* - `updateHandler`: A string or Symbol that is the key of a method to be
|
||||
* invoked with the result of parsing and evaluating a serialized style string.
|
||||
* - `observeEffects`: Optional, if set to true then styles that use env() will
|
||||
* cause their update handlers to be invoked every time the corresponding
|
||||
* environment variable changes (even if the style attribute itself remains
|
||||
* static).
|
||||
*/
|
||||
export const style =
|
||||
<T extends Intrinsics<Array<Unit>>, U extends ReactiveElement>(
|
||||
config: StyleDecoratorConfig<T, U>) => {
|
||||
const observeEffects: boolean = config.observeEffects || false;
|
||||
const getIntrinsics = config.intrinsics instanceof Function ?
|
||||
config.intrinsics :
|
||||
(() => config.intrinsics) as IntrinsicsFactory<T, U>;
|
||||
|
||||
return <U extends typeof ReactiveElement['prototype']>(
|
||||
proto: U, propertyName: string) => {
|
||||
const originalUpdated = (proto as any).updated;
|
||||
const originalConnectedCallback = proto.connectedCallback;
|
||||
const originalDisconnectedCallback = proto.disconnectedCallback;
|
||||
|
||||
const $styleEffector = Symbol(`${propertyName}StyleEffector`);
|
||||
const $styleEvaluator = Symbol(`${propertyName}StyleEvaluator`);
|
||||
const $updateEvaluator = Symbol(`${propertyName}UpdateEvaluator`);
|
||||
const $evaluateAndSync = Symbol(`${propertyName}EvaluateAndSync`);
|
||||
|
||||
Object.defineProperties(proto, {
|
||||
[$styleEffector]:
|
||||
{value: null as StyleEffector | null, writable: true},
|
||||
[$styleEvaluator]:
|
||||
{value: null as StyleEvaluator<T>| null, writable: true},
|
||||
|
||||
[$updateEvaluator]: {
|
||||
value: function() {
|
||||
const ast = parseExpressions(
|
||||
this[propertyName as keyof ReactiveElement] as string);
|
||||
this[$styleEvaluator] =
|
||||
new StyleEvaluator(ast, getIntrinsics(this));
|
||||
|
||||
if (this[$styleEffector] == null && observeEffects) {
|
||||
this[$styleEffector] =
|
||||
new StyleEffector(() => this[$evaluateAndSync]());
|
||||
}
|
||||
|
||||
if (this[$styleEffector] != null) {
|
||||
this[$styleEffector].observeEffectsFor(ast);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
[$evaluateAndSync]: {
|
||||
value: function() {
|
||||
if (this[$styleEvaluator] == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this[$styleEvaluator].evaluate();
|
||||
|
||||
// @see https://github.com/microsoft/TypeScript/pull/30769
|
||||
// @see https://github.com/Microsoft/TypeScript/issues/1863
|
||||
(this as unknown as Record<
|
||||
string,
|
||||
(style: EvaluatedStyle<T>) =>
|
||||
void>)[config.updateHandler as unknown as string](
|
||||
result);
|
||||
}
|
||||
},
|
||||
|
||||
updated: {
|
||||
value: function(changedProperties: Map<string, any>) {
|
||||
// Always invoke updates to styles first. This gives a class that
|
||||
// uses this decorator the opportunity to override the effect, or
|
||||
// respond to it, in its own implementation of `updated`.
|
||||
if (changedProperties.has(propertyName)) {
|
||||
this[$updateEvaluator]();
|
||||
this[$evaluateAndSync]();
|
||||
}
|
||||
|
||||
originalUpdated.call(this, changedProperties);
|
||||
}
|
||||
},
|
||||
|
||||
connectedCallback: {
|
||||
value: function() {
|
||||
originalConnectedCallback.call(this);
|
||||
this.requestUpdate(propertyName, this[propertyName]);
|
||||
}
|
||||
},
|
||||
|
||||
disconnectedCallback: {
|
||||
value: function() {
|
||||
originalDisconnectedCallback.call(this);
|
||||
if (this[$styleEffector] != null) {
|
||||
this[$styleEffector].dispose();
|
||||
this[$styleEffector] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {LoopOnce, LoopPingPong, LoopRepeat} from 'three';
|
||||
|
||||
import ModelViewerElementBase, {$getModelIsVisible, $needsRender, $onModelLoad, $renderer, $scene, $tick} from '../model-viewer-base.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
|
||||
const MILLISECONDS_PER_SECOND = 1000.0
|
||||
|
||||
const $changeAnimation = Symbol('changeAnimation');
|
||||
const $appendAnimation = Symbol('appendAnimation');
|
||||
const $detachAnimation = Symbol('detachAnimation');
|
||||
const $paused = Symbol('paused');
|
||||
|
||||
interface PlayAnimationOptions {
|
||||
repetitions: number, pingpong: boolean,
|
||||
}
|
||||
|
||||
interface AppendAnimationOptions {
|
||||
pingpong: boolean, repetitions: number|null, weight: number,
|
||||
timeScale: number, fade: boolean|number, warp: boolean|number,
|
||||
relativeWarp: boolean, time: number|null
|
||||
}
|
||||
|
||||
interface DetachAnimationOptions {
|
||||
fade: boolean|number
|
||||
}
|
||||
|
||||
const DEFAULT_PLAY_OPTIONS: PlayAnimationOptions = {
|
||||
repetitions: Infinity,
|
||||
pingpong: false
|
||||
};
|
||||
|
||||
const DEFAULT_APPEND_OPTIONS: AppendAnimationOptions = {
|
||||
pingpong: false,
|
||||
repetitions: null,
|
||||
weight: 1,
|
||||
timeScale: 1,
|
||||
fade: false,
|
||||
warp: false,
|
||||
relativeWarp: true,
|
||||
time: null
|
||||
};
|
||||
|
||||
const DEFAULT_DETACH_OPTIONS: DetachAnimationOptions = {
|
||||
fade: true
|
||||
};
|
||||
|
||||
export declare interface AnimationInterface {
|
||||
autoplay: boolean;
|
||||
animationName: string|void;
|
||||
animationCrossfadeDuration: number;
|
||||
readonly availableAnimations: Array<string>;
|
||||
readonly paused: boolean;
|
||||
readonly duration: number;
|
||||
currentTime: number;
|
||||
timeScale: number;
|
||||
pause(): void;
|
||||
play(options?: PlayAnimationOptions): void;
|
||||
appendAnimation(animationName: string, options?: AppendAnimationOptions):
|
||||
void;
|
||||
detachAnimation(animationName: string, options?: DetachAnimationOptions):
|
||||
void;
|
||||
}
|
||||
|
||||
export const AnimationMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<AnimationInterface>&T => {
|
||||
class AnimationModelViewerElement extends ModelViewerElement {
|
||||
@property({type: Boolean}) autoplay: boolean = false;
|
||||
@property({type: String, attribute: 'animation-name'})
|
||||
animationName: string|undefined = undefined;
|
||||
@property({type: Number, attribute: 'animation-crossfade-duration'})
|
||||
animationCrossfadeDuration: number = 300;
|
||||
|
||||
protected[$paused]: boolean = true;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(args);
|
||||
|
||||
this[$scene].subscribeMixerEvent('loop', (e) => {
|
||||
const count = e.action._loopCount;
|
||||
const name = e.action._clip.name;
|
||||
const uuid = e.action._clip.uuid;
|
||||
const targetAnimation =
|
||||
this[$scene].markedAnimations.find(e => e.name === name);
|
||||
|
||||
if (targetAnimation) {
|
||||
this[$scene].updateAnimationLoop(
|
||||
targetAnimation.name,
|
||||
targetAnimation.loopMode,
|
||||
targetAnimation.repetitionCount);
|
||||
const filtredArr =
|
||||
this[$scene].markedAnimations.filter(e => e.name !== name);
|
||||
this[$scene].markedAnimations = filtredArr;
|
||||
}
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('loop', {detail: {count, name, uuid}}));
|
||||
});
|
||||
this[$scene].subscribeMixerEvent('finished', (e) => {
|
||||
if (!this[$scene].appendedAnimations.includes(e.action._clip.name)) {
|
||||
this[$paused] = true;
|
||||
} else {
|
||||
const filteredList = this[$scene].appendedAnimations.filter(
|
||||
i => i !== e.action._clip.name);
|
||||
this[$scene].appendedAnimations = filteredList;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('finished'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array
|
||||
*/
|
||||
get availableAnimations(): Array<string> {
|
||||
if (this.loaded) {
|
||||
return this[$scene].animationNames;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this[$scene].duration;
|
||||
}
|
||||
|
||||
get paused(): boolean {
|
||||
return this[$paused];
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
return this[$scene].animationTime;
|
||||
}
|
||||
|
||||
get appendedAnimations(): string[] {
|
||||
return this[$scene].appendedAnimations;
|
||||
}
|
||||
|
||||
set currentTime(value: number) {
|
||||
this[$scene].animationTime = value;
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
get timeScale(): number {
|
||||
return this[$scene].animationTimeScale;
|
||||
}
|
||||
|
||||
set timeScale(value: number) {
|
||||
this[$scene].animationTimeScale = value;
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this[$paused]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[$paused] = true;
|
||||
this.dispatchEvent(new CustomEvent('pause'));
|
||||
}
|
||||
|
||||
play(options?: PlayAnimationOptions) {
|
||||
if (this.availableAnimations.length > 0) {
|
||||
this[$paused] = false;
|
||||
|
||||
this[$changeAnimation](options);
|
||||
|
||||
this.dispatchEvent(new CustomEvent('play'));
|
||||
}
|
||||
}
|
||||
|
||||
appendAnimation(animationName: string, options?: AppendAnimationOptions) {
|
||||
if (this.availableAnimations.length > 0) {
|
||||
this[$paused] = false;
|
||||
|
||||
this[$appendAnimation](animationName, options);
|
||||
|
||||
this.dispatchEvent(new CustomEvent('append-animation'));
|
||||
}
|
||||
}
|
||||
|
||||
detachAnimation(animationName: string, options?: DetachAnimationOptions) {
|
||||
if (this.availableAnimations.length > 0) {
|
||||
this[$paused] = false;
|
||||
|
||||
this[$detachAnimation](animationName, options);
|
||||
|
||||
this.dispatchEvent(new CustomEvent('detach-animation'));
|
||||
}
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
super[$onModelLoad]();
|
||||
|
||||
this[$paused] = true;
|
||||
|
||||
if (this.animationName != null) {
|
||||
this[$changeAnimation]();
|
||||
}
|
||||
|
||||
if (this.autoplay) {
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
|
||||
[$tick](_time: number, delta: number) {
|
||||
super[$tick](_time, delta);
|
||||
|
||||
if (this[$paused] ||
|
||||
(!this[$getModelIsVisible]() && !this[$renderer].isPresenting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[$scene].updateAnimation(delta / MILLISECONDS_PER_SECOND);
|
||||
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('autoplay') && this.autoplay) {
|
||||
this.play();
|
||||
}
|
||||
|
||||
if (changedProperties.has('animationName')) {
|
||||
this[$changeAnimation]();
|
||||
}
|
||||
}
|
||||
|
||||
[$changeAnimation](options: PlayAnimationOptions = DEFAULT_PLAY_OPTIONS) {
|
||||
const repetitions = options.repetitions ?? Infinity;
|
||||
const mode = options.pingpong ?
|
||||
LoopPingPong :
|
||||
(repetitions === 1 ? LoopOnce : LoopRepeat);
|
||||
this[$scene].playAnimation(
|
||||
this.animationName,
|
||||
this.animationCrossfadeDuration / MILLISECONDS_PER_SECOND,
|
||||
mode,
|
||||
repetitions);
|
||||
|
||||
// If we are currently paused, we need to force a render so that
|
||||
// the scene updates to the first frame of the new animation
|
||||
if (this[$paused]) {
|
||||
this[$scene].updateAnimation(0);
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
|
||||
[$appendAnimation](
|
||||
animationName: string = '',
|
||||
options: AppendAnimationOptions = DEFAULT_APPEND_OPTIONS) {
|
||||
const repetitions = options.repetitions ?? Infinity;
|
||||
const mode = options.pingpong ?
|
||||
LoopPingPong :
|
||||
(repetitions === 1 ? LoopOnce : LoopRepeat);
|
||||
|
||||
const needsToStop = !!options.repetitions || 'pingpong' in options;
|
||||
|
||||
this[$scene].appendAnimation(
|
||||
animationName ? animationName : this.animationName,
|
||||
mode,
|
||||
repetitions,
|
||||
options.weight,
|
||||
options.timeScale,
|
||||
options.fade,
|
||||
options.warp,
|
||||
options.relativeWarp,
|
||||
options.time,
|
||||
needsToStop);
|
||||
|
||||
// If we are currently paused, we need to force a render so that
|
||||
// the scene updates to the first frame of the new animation
|
||||
if (this[$paused]) {
|
||||
this[$scene].updateAnimation(0);
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
|
||||
[$detachAnimation](
|
||||
animationName: string = '',
|
||||
options: DetachAnimationOptions = DEFAULT_DETACH_OPTIONS) {
|
||||
this[$scene].detachAnimation(
|
||||
animationName ? animationName : this.animationName, options.fade);
|
||||
|
||||
// If we are currently paused, we need to force a render so that
|
||||
// the scene updates to the first frame of the new animation
|
||||
if (this[$paused]) {
|
||||
this[$scene].updateAnimation(0);
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AnimationModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Matrix4, Vector3} from 'three';
|
||||
|
||||
import ModelViewerElementBase, {$needsRender, $onModelLoad, $scene, $tick, toVector2D, toVector3D, Vector2D, Vector3D} from '../model-viewer-base.js';
|
||||
import {Hotspot, HotspotConfiguration} from '../three-components/Hotspot.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
|
||||
const $hotspotMap = Symbol('hotspotMap');
|
||||
const $mutationCallback = Symbol('mutationCallback');
|
||||
const $observer = Symbol('observer');
|
||||
const $addHotspot = Symbol('addHotspot');
|
||||
const $removeHotspot = Symbol('removeHotspot');
|
||||
|
||||
const worldToModel = new Matrix4();
|
||||
|
||||
export declare type HotspotData = {
|
||||
position: Vector3D,
|
||||
normal: Vector3D,
|
||||
canvasPosition: Vector3D,
|
||||
facingCamera: boolean,
|
||||
}
|
||||
|
||||
export declare interface AnnotationInterface {
|
||||
updateHotspot(config: HotspotConfiguration): void;
|
||||
queryHotspot(name: string): HotspotData|null;
|
||||
positionAndNormalFromPoint(pixelX: number, pixelY: number):
|
||||
{position: Vector3D, normal: Vector3D, uv: Vector2D|null}|null;
|
||||
surfaceFromPoint(pixelX: number, pixelY: number): string|null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnnotationMixin implements a declarative API to add hotspots and annotations.
|
||||
* Child elements of the <model-viewer> element that have a slot name that
|
||||
* begins with "hotspot" and data-position and data-normal attributes in
|
||||
* the format of the camera-target attribute will be added to the scene and
|
||||
* track the specified model coordinates.
|
||||
*/
|
||||
export const AnnotationMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<AnnotationInterface>&T => {
|
||||
class AnnotationModelViewerElement extends ModelViewerElement {
|
||||
private[$hotspotMap] = new Map<string, Hotspot>();
|
||||
private[$mutationCallback] = (mutations: Array<unknown>) => {
|
||||
mutations.forEach((mutation) => {
|
||||
// NOTE: Be wary that in ShadyDOM cases, the MutationRecord
|
||||
// only has addedNodes and removedNodes (and no other details).
|
||||
if (!(mutation instanceof MutationRecord) ||
|
||||
mutation.type === 'childList') {
|
||||
(mutation as MutationRecord).addedNodes.forEach((node) => {
|
||||
this[$addHotspot](node);
|
||||
});
|
||||
(mutation as MutationRecord).removedNodes.forEach((node) => {
|
||||
this[$removeHotspot](node);
|
||||
});
|
||||
this[$needsRender]();
|
||||
}
|
||||
});
|
||||
};
|
||||
private[$observer] = new MutationObserver(this[$mutationCallback]);
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
for (let i = 0; i < this.children.length; ++i) {
|
||||
this[$addHotspot](this.children[i]);
|
||||
}
|
||||
|
||||
const {ShadyDOM} = self as any;
|
||||
|
||||
if (ShadyDOM == null) {
|
||||
this[$observer].observe(this, {childList: true});
|
||||
} else {
|
||||
this[$observer] =
|
||||
ShadyDOM.observeChildren(this, this[$mutationCallback]);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
const {ShadyDOM} = self as any;
|
||||
|
||||
if (ShadyDOM == null) {
|
||||
this[$observer].disconnect();
|
||||
} else {
|
||||
ShadyDOM.unobserveChildren(this[$observer]);
|
||||
}
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
super[$onModelLoad]();
|
||||
|
||||
const scene = this[$scene];
|
||||
scene.forHotspots((hotspot) => {
|
||||
scene.updateSurfaceHotspot(hotspot);
|
||||
});
|
||||
}
|
||||
|
||||
[$tick](time: number, delta: number) {
|
||||
super[$tick](time, delta);
|
||||
const scene = this[$scene];
|
||||
const {annotationRenderer} = scene;
|
||||
const camera = scene.getCamera();
|
||||
|
||||
if (scene.shouldRender()) {
|
||||
scene.animateSurfaceHotspots();
|
||||
scene.updateHotspotsVisibility(camera.position);
|
||||
annotationRenderer.domElement.style.display = '';
|
||||
annotationRenderer.render(scene, camera);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since the data-position and data-normal attributes are not observed, use
|
||||
* this method to move a hotspot. Keep in mind that all hotspots with the
|
||||
* same slot name use a single location and the first definition takes
|
||||
* precedence, until updated with this method.
|
||||
*/
|
||||
updateHotspot(config: HotspotConfiguration) {
|
||||
const hotspot = this[$hotspotMap].get(config.name);
|
||||
|
||||
if (hotspot == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
hotspot.updatePosition(config.position);
|
||||
hotspot.updateNormal(config.normal);
|
||||
hotspot.surface = config.surface;
|
||||
this[$scene].updateSurfaceHotspot(hotspot);
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns in-scene data about a requested hotspot including
|
||||
* its position in screen (canvas) space and its current visibility.
|
||||
*/
|
||||
queryHotspot(name: string): HotspotData|null {
|
||||
const hotspot = this[$hotspotMap].get(name);
|
||||
if (hotspot == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = toVector3D(hotspot.position);
|
||||
const normal = toVector3D(hotspot.normal);
|
||||
const facingCamera = hotspot.facingCamera;
|
||||
|
||||
const scene = this[$scene];
|
||||
const camera = scene.getCamera();
|
||||
const vector = new Vector3();
|
||||
|
||||
vector.setFromMatrixPosition(hotspot.matrixWorld);
|
||||
vector.project(camera);
|
||||
|
||||
const widthHalf = scene.width / 2;
|
||||
const heightHalf = scene.height / 2;
|
||||
|
||||
vector.x = (vector.x * widthHalf) + widthHalf;
|
||||
vector.y = -(vector.y * heightHalf) + heightHalf;
|
||||
|
||||
const canvasPosition =
|
||||
toVector3D(new Vector3(vector.x, vector.y, vector.z));
|
||||
|
||||
if (!Number.isFinite(canvasPosition.x) ||
|
||||
!Number.isFinite(canvasPosition.y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {position, normal, canvasPosition, facingCamera};
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns the model position, normal and texture coordinate
|
||||
* of the point on the mesh corresponding to the input pixel coordinates
|
||||
* given relative to the model-viewer element. The position and normal
|
||||
* are returned as strings in the format suitable for putting in a
|
||||
* hotspot's data-position and data-normal attributes. If the mesh is
|
||||
* not hit, the result is null.
|
||||
*/
|
||||
positionAndNormalFromPoint(pixelX: number, pixelY: number):
|
||||
{position: Vector3D, normal: Vector3D, uv: Vector2D|null}|null {
|
||||
const scene = this[$scene];
|
||||
const ndcPosition = scene.getNDC(pixelX, pixelY);
|
||||
|
||||
const hit = scene.positionAndNormalFromPoint(ndcPosition);
|
||||
if (hit == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
worldToModel.copy(scene.target.matrixWorld).invert();
|
||||
const position = toVector3D(hit.position.applyMatrix4(worldToModel));
|
||||
const normal = toVector3D(hit.normal.transformDirection(worldToModel));
|
||||
|
||||
let uv = null;
|
||||
if (hit.uv != null) {
|
||||
uv = toVector2D(hit.uv);
|
||||
}
|
||||
|
||||
return {position: position, normal: normal, uv: uv};
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a dynamic hotspot ID string of the point on the mesh
|
||||
* corresponding to the input pixel coordinates given relative to the
|
||||
* model-viewer element. The ID string can be used in the data-surface
|
||||
* attribute of the hotspot to make it follow this point on the surface even
|
||||
* as the model animates. If the mesh is not hit, the result is null.
|
||||
*/
|
||||
surfaceFromPoint(pixelX: number, pixelY: number): string|null {
|
||||
const scene = this[$scene];
|
||||
const ndcPosition = scene.getNDC(pixelX, pixelY);
|
||||
|
||||
return scene.surfaceFromPoint(ndcPosition);
|
||||
}
|
||||
|
||||
private[$addHotspot](node: Node) {
|
||||
if (!(node instanceof HTMLElement &&
|
||||
node.slot.indexOf('hotspot') === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hotspot = this[$hotspotMap].get(node.slot);
|
||||
|
||||
if (hotspot != null) {
|
||||
hotspot.increment();
|
||||
} else {
|
||||
hotspot = new Hotspot({
|
||||
name: node.slot,
|
||||
position: node.dataset.position,
|
||||
normal: node.dataset.normal,
|
||||
surface: node.dataset.surface,
|
||||
});
|
||||
this[$hotspotMap].set(node.slot, hotspot);
|
||||
this[$scene].addHotspot(hotspot);
|
||||
}
|
||||
this[$scene].queueRender();
|
||||
}
|
||||
|
||||
private[$removeHotspot](node: Node) {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hotspot = this[$hotspotMap].get(node.slot);
|
||||
|
||||
if (!hotspot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hotspot.decrement()) {
|
||||
this[$scene].removeHotspot(hotspot);
|
||||
this[$hotspotMap].delete(node.slot);
|
||||
}
|
||||
this[$scene].queueRender();
|
||||
}
|
||||
}
|
||||
|
||||
return AnnotationModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,486 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {USDZExporter} from 'three/examples/jsm/exporters/USDZExporter.js';
|
||||
|
||||
import {IS_AR_QUICKLOOK_CANDIDATE, IS_SCENEVIEWER_CANDIDATE, IS_WEBXR_AR_CANDIDATE} from '../constants.js';
|
||||
import ModelViewerElementBase, {$needsRender, $progressTracker, $renderer, $scene, $shouldAttemptPreload, $updateSource} from '../model-viewer-base.js';
|
||||
import {enumerationDeserializer} from '../styles/deserializers.js';
|
||||
import {ARStatus, ARTracking} from '../three-components/ARRenderer.js';
|
||||
import {Constructor, waitForEvent} from '../utilities.js';
|
||||
|
||||
let isWebXRBlocked = false;
|
||||
let isSceneViewerBlocked = false;
|
||||
const noArViewerSigil = '#model-viewer-no-ar-fallback';
|
||||
|
||||
export type ARMode = 'quick-look'|'scene-viewer'|'webxr'|'none';
|
||||
|
||||
const deserializeARModes = enumerationDeserializer<ARMode>(
|
||||
['quick-look', 'scene-viewer', 'webxr', 'none']);
|
||||
|
||||
const DEFAULT_AR_MODES = 'webxr scene-viewer quick-look';
|
||||
|
||||
const ARMode: {[index: string]: ARMode} = {
|
||||
QUICK_LOOK: 'quick-look',
|
||||
SCENE_VIEWER: 'scene-viewer',
|
||||
WEBXR: 'webxr',
|
||||
NONE: 'none'
|
||||
};
|
||||
|
||||
export interface ARStatusDetails {
|
||||
status: ARStatus;
|
||||
}
|
||||
|
||||
export interface ARTrackingDetails {
|
||||
status: ARTracking;
|
||||
}
|
||||
|
||||
const $arButtonContainer = Symbol('arButtonContainer');
|
||||
const $enterARWithWebXR = Symbol('enterARWithWebXR');
|
||||
export const $openSceneViewer = Symbol('openSceneViewer');
|
||||
export const $openIOSARQuickLook = Symbol('openIOSARQuickLook');
|
||||
const $canActivateAR = Symbol('canActivateAR');
|
||||
const $arMode = Symbol('arMode');
|
||||
const $arModes = Symbol('arModes');
|
||||
const $arAnchor = Symbol('arAnchor');
|
||||
const $preload = Symbol('preload');
|
||||
|
||||
const $onARButtonContainerClick = Symbol('onARButtonContainerClick');
|
||||
const $onARStatus = Symbol('onARStatus');
|
||||
const $onARTracking = Symbol('onARTracking');
|
||||
const $onARTap = Symbol('onARTap');
|
||||
const $selectARMode = Symbol('selectARMode');
|
||||
const $triggerLoad = Symbol('triggerLoad');
|
||||
|
||||
export declare interface ARInterface {
|
||||
ar: boolean;
|
||||
arModes: string;
|
||||
arScale: string;
|
||||
arPlacement: 'floor'|'wall'|'ceiling';
|
||||
iosSrc: string|null;
|
||||
xrEnvironment: boolean;
|
||||
arUsdzMaxTextureSize: string;
|
||||
readonly canActivateAR: boolean;
|
||||
activateAR(): Promise<void>;
|
||||
}
|
||||
|
||||
export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<ARInterface>&T => {
|
||||
class ARModelViewerElement extends ModelViewerElement {
|
||||
@property({type: Boolean, attribute: 'ar'}) ar: boolean = false;
|
||||
|
||||
@property({type: String, attribute: 'ar-scale'}) arScale: string = 'auto';
|
||||
|
||||
@property({type: String, attribute: 'ar-usdz-max-texture-size'})
|
||||
arUsdzMaxTextureSize: string = 'auto';
|
||||
|
||||
@property({type: String, attribute: 'ar-placement'})
|
||||
arPlacement: 'floor'|'wall'|'ceiling' = 'floor';
|
||||
|
||||
@property({type: String, attribute: 'ar-modes'})
|
||||
arModes: string = DEFAULT_AR_MODES;
|
||||
|
||||
@property({type: String, attribute: 'ios-src'}) iosSrc: string|null = null;
|
||||
|
||||
@property({type: Boolean, attribute: 'xr-environment'})
|
||||
xrEnvironment: boolean = false;
|
||||
|
||||
get canActivateAR(): boolean {
|
||||
return this[$arMode] !== ARMode.NONE;
|
||||
}
|
||||
|
||||
protected[$canActivateAR]: boolean = false;
|
||||
|
||||
// TODO: Add this to the shadow root as part of this mixin's
|
||||
// implementation:
|
||||
protected[$arButtonContainer]: HTMLElement =
|
||||
this.shadowRoot!.querySelector('.ar-button') as HTMLElement;
|
||||
|
||||
protected[$arAnchor] = document.createElement('a');
|
||||
|
||||
protected[$arModes]: Set<ARMode> = new Set();
|
||||
protected[$arMode]: ARMode = ARMode.NONE;
|
||||
protected[$preload] = false;
|
||||
|
||||
private[$onARButtonContainerClick] = (event: Event) => {
|
||||
event.preventDefault();
|
||||
this.activateAR();
|
||||
};
|
||||
|
||||
private[$onARStatus] = ({status}: {status: ARStatus}) => {
|
||||
if (status === ARStatus.NOT_PRESENTING ||
|
||||
this[$renderer].arRenderer.presentedScene === this[$scene]) {
|
||||
this.setAttribute('ar-status', status);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<ARStatusDetails>('ar-status', {detail: {status}}));
|
||||
if (status === ARStatus.NOT_PRESENTING) {
|
||||
this.removeAttribute('ar-tracking');
|
||||
} else if (status === ARStatus.SESSION_STARTED) {
|
||||
this.setAttribute('ar-tracking', ARTracking.TRACKING);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private[$onARTracking] = ({status}: {status: ARTracking}) => {
|
||||
this.setAttribute('ar-tracking', status);
|
||||
this.dispatchEvent(new CustomEvent<ARTrackingDetails>(
|
||||
'ar-tracking', {detail: {status}}));
|
||||
};
|
||||
|
||||
private[$onARTap] = (event: Event) => {
|
||||
if ((event as any).data == '_apple_ar_quicklook_button_tapped') {
|
||||
this.dispatchEvent(new CustomEvent('quick-look-button-tapped'));
|
||||
}
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this[$renderer].arRenderer.addEventListener('status', this[$onARStatus]);
|
||||
this.setAttribute('ar-status', ARStatus.NOT_PRESENTING);
|
||||
|
||||
this[$renderer].arRenderer.addEventListener(
|
||||
'tracking', this[$onARTracking]);
|
||||
|
||||
this[$arAnchor].addEventListener('message', this[$onARTap]);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this[$renderer].arRenderer.removeEventListener(
|
||||
'status', this[$onARStatus]);
|
||||
this[$renderer].arRenderer.removeEventListener(
|
||||
'tracking', this[$onARTracking]);
|
||||
|
||||
this[$arAnchor].removeEventListener('message', this[$onARTap]);
|
||||
}
|
||||
|
||||
update(changedProperties: Map<string, any>) {
|
||||
super.update(changedProperties);
|
||||
|
||||
if (changedProperties.has('arScale')) {
|
||||
this[$scene].canScale = this.arScale !== 'fixed';
|
||||
}
|
||||
|
||||
if (changedProperties.has('arPlacement')) {
|
||||
this[$scene].updateShadow();
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('arModes')) {
|
||||
this[$arModes] = deserializeARModes(this.arModes);
|
||||
}
|
||||
|
||||
if (changedProperties.has('ar') || changedProperties.has('arModes') ||
|
||||
changedProperties.has('src') || changedProperties.has('iosSrc') ||
|
||||
changedProperties.has('arUsdzMaxTextureSize')) {
|
||||
this[$selectARMode]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates AR. Note that for any mode that is not WebXR-based, this
|
||||
* method most likely has to be called synchronous from a user
|
||||
* interaction handler. Otherwise, attempts to activate modes that
|
||||
* require user interaction will most likely be ignored.
|
||||
*/
|
||||
async activateAR() {
|
||||
switch (this[$arMode]) {
|
||||
case ARMode.QUICK_LOOK:
|
||||
await this[$openIOSARQuickLook]();
|
||||
break;
|
||||
case ARMode.WEBXR:
|
||||
await this[$enterARWithWebXR]();
|
||||
break;
|
||||
case ARMode.SCENE_VIEWER:
|
||||
this[$openSceneViewer]();
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
'No AR Mode can be activated. This is probably due to missing \
|
||||
configuration or device capabilities');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async[$selectARMode]() {
|
||||
let arMode = ARMode.NONE;
|
||||
if (this.ar) {
|
||||
if (this.src != null) {
|
||||
for (const value of this[$arModes]) {
|
||||
if (value === 'webxr' && IS_WEBXR_AR_CANDIDATE && !isWebXRBlocked &&
|
||||
await this[$renderer].arRenderer.supportsPresentation()) {
|
||||
arMode = ARMode.WEBXR;
|
||||
break;
|
||||
}
|
||||
if (value === 'scene-viewer' && !isSceneViewerBlocked &&
|
||||
(IS_SCENEVIEWER_CANDIDATE ||
|
||||
((navigator as any).userAgentData &&
|
||||
(navigator as any).userAgentData.getHighEntropyValues &&
|
||||
(await (navigator as any).userAgentData.getHighEntropyValues([
|
||||
'formFactor'
|
||||
])).formFactor?.includes('XR')))) {
|
||||
arMode = ARMode.SCENE_VIEWER;
|
||||
break;
|
||||
}
|
||||
if (value === 'quick-look' && IS_AR_QUICKLOOK_CANDIDATE) {
|
||||
arMode = ARMode.QUICK_LOOK;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The presence of ios-src overrides the absence of quick-look
|
||||
// ar-mode.
|
||||
if (arMode === ARMode.NONE && this.iosSrc != null &&
|
||||
IS_AR_QUICKLOOK_CANDIDATE) {
|
||||
arMode = ARMode.QUICK_LOOK;
|
||||
}
|
||||
}
|
||||
|
||||
if (arMode !== ARMode.NONE) {
|
||||
this[$arButtonContainer].classList.add('enabled');
|
||||
this[$arButtonContainer].addEventListener(
|
||||
'click', this[$onARButtonContainerClick]);
|
||||
} else if (this[$arButtonContainer].classList.contains('enabled')) {
|
||||
this[$arButtonContainer].removeEventListener(
|
||||
'click', this[$onARButtonContainerClick]);
|
||||
this[$arButtonContainer].classList.remove('enabled');
|
||||
|
||||
// If AR went from working to not, notify the element.
|
||||
const status = ARStatus.FAILED;
|
||||
this.setAttribute('ar-status', status);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<ARStatusDetails>('ar-status', {detail: {status}}));
|
||||
}
|
||||
this[$arMode] = arMode;
|
||||
}
|
||||
|
||||
protected async[$enterARWithWebXR]() {
|
||||
console.log('Attempting to present in AR with WebXR...');
|
||||
|
||||
await this[$triggerLoad]();
|
||||
|
||||
try {
|
||||
this[$arButtonContainer].removeEventListener(
|
||||
'click', this[$onARButtonContainerClick]);
|
||||
const {arRenderer} = this[$renderer];
|
||||
if (this.arPlacement === 'wall') {
|
||||
arRenderer.placementMode = 'wall';
|
||||
} else if (this.arPlacement === 'ceiling') {
|
||||
arRenderer.placementMode = 'ceiling';
|
||||
} else {
|
||||
arRenderer.placementMode = 'floor';
|
||||
}
|
||||
await arRenderer.present(this[$scene], this.xrEnvironment);
|
||||
} catch (error) {
|
||||
console.warn('Error while trying to present in AR with WebXR');
|
||||
console.error(error);
|
||||
await this[$renderer].arRenderer.stopPresenting();
|
||||
isWebXRBlocked = true;
|
||||
console.warn('Falling back to next ar-mode');
|
||||
await this[$selectARMode]();
|
||||
this.activateAR();
|
||||
} finally {
|
||||
this[$selectARMode]();
|
||||
}
|
||||
}
|
||||
|
||||
async[$triggerLoad]() {
|
||||
if (!this.loaded) {
|
||||
this[$preload] = true;
|
||||
this[$updateSource]();
|
||||
await waitForEvent(this, 'load');
|
||||
this[$preload] = false;
|
||||
}
|
||||
}
|
||||
|
||||
[$shouldAttemptPreload](): boolean {
|
||||
return super[$shouldAttemptPreload]() || this[$preload];
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a URL and a title string, and attempts to launch Scene Viewer on
|
||||
* the current device.
|
||||
*/
|
||||
[$openSceneViewer]() {
|
||||
const location = self.location.toString();
|
||||
const locationUrl = new URL(location);
|
||||
const modelUrl = new URL(this.src!, location);
|
||||
if (modelUrl.hash)
|
||||
modelUrl.hash = '';
|
||||
const params = new URLSearchParams(modelUrl.search);
|
||||
|
||||
locationUrl.hash = noArViewerSigil;
|
||||
|
||||
// modelUrl can contain title/link/sound etc.
|
||||
params.set('mode', 'ar_preferred');
|
||||
if (!params.has('disable_occlusion')) {
|
||||
params.set('disable_occlusion', 'true');
|
||||
}
|
||||
if (this.arScale === 'fixed') {
|
||||
params.set('resizable', 'false');
|
||||
}
|
||||
if (this.arPlacement === 'wall') {
|
||||
params.set('enable_vertical_placement', 'true');
|
||||
}
|
||||
if (params.has('sound')) {
|
||||
const soundUrl = new URL(params.get('sound')!, location);
|
||||
params.set('sound', soundUrl.toString());
|
||||
}
|
||||
if (params.has('link')) {
|
||||
const linkUrl = new URL(params.get('link')!, location);
|
||||
params.set('link', linkUrl.toString());
|
||||
}
|
||||
|
||||
const intent = `intent://arvr.google.com/scene-viewer/1.2?${
|
||||
params.toString() + '&file=' +
|
||||
encodeURIComponent(
|
||||
modelUrl
|
||||
.toString())}#Intent;scheme=https;package=com.google.android.googlequicksearchbox;action=android.intent.action.VIEW;S.browser_fallback_url=${
|
||||
encodeURIComponent(locationUrl.toString())};end;`;
|
||||
|
||||
const undoHashChange = () => {
|
||||
if (self.location.hash === noArViewerSigil) {
|
||||
isSceneViewerBlocked = true;
|
||||
// The new history will be the current URL with a new hash.
|
||||
// Go back one step so that we reset to the expected URL.
|
||||
// NOTE(cdata): this should not invoke any browser-level navigation
|
||||
// because hash-only changes modify the URL in-place without
|
||||
// navigating:
|
||||
self.history.back();
|
||||
console.warn('Error while trying to present in AR with Scene Viewer');
|
||||
console.warn('Falling back to next ar-mode');
|
||||
this[$selectARMode]();
|
||||
// Would be nice to activateAR() here, but webXR fails due to not
|
||||
// seeing a user activation.
|
||||
}
|
||||
};
|
||||
|
||||
self.addEventListener('hashchange', undoHashChange, {once: true});
|
||||
|
||||
this[$arAnchor].setAttribute('href', intent);
|
||||
console.log('Attempting to present in AR with Scene Viewer...');
|
||||
this[$arAnchor].click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a URL to a USDZ file and sets the appropriate fields so that
|
||||
* Safari iOS can intent to their AR Quick Look.
|
||||
*/
|
||||
async[$openIOSARQuickLook]() {
|
||||
const generateUsdz = !this.iosSrc;
|
||||
|
||||
this[$arButtonContainer].classList.remove('enabled');
|
||||
|
||||
const objectURL = generateUsdz ? await this.prepareUSDZ() : this.iosSrc!;
|
||||
const modelUrl = new URL(objectURL, self.location.toString());
|
||||
|
||||
if (generateUsdz) {
|
||||
const location = self.location.toString();
|
||||
const locationUrl = new URL(location);
|
||||
const srcUrl = new URL(this.src!, locationUrl);
|
||||
if (srcUrl.hash) {
|
||||
modelUrl.hash = srcUrl.hash;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.arScale === 'fixed') {
|
||||
if (modelUrl.hash) {
|
||||
modelUrl.hash += '&';
|
||||
}
|
||||
modelUrl.hash += 'allowsContentScaling=0';
|
||||
}
|
||||
|
||||
const anchor = this[$arAnchor];
|
||||
anchor.setAttribute('rel', 'ar');
|
||||
const img = document.createElement('img');
|
||||
anchor.appendChild(img);
|
||||
anchor.setAttribute('href', modelUrl.toString());
|
||||
if (generateUsdz) {
|
||||
anchor.setAttribute('download', 'model.usdz');
|
||||
}
|
||||
|
||||
// attach anchor to shadow DOM to ensure iOS16 ARQL banner click message
|
||||
// event propagation
|
||||
anchor.style.display = 'none';
|
||||
if (!anchor.isConnected)
|
||||
this.shadowRoot!.appendChild(anchor);
|
||||
|
||||
console.log('Attempting to present in AR with Quick Look...');
|
||||
anchor.click();
|
||||
anchor.removeChild(img);
|
||||
if (generateUsdz) {
|
||||
URL.revokeObjectURL(objectURL);
|
||||
}
|
||||
this[$arButtonContainer].classList.add('enabled');
|
||||
}
|
||||
|
||||
async prepareUSDZ(): Promise<string> {
|
||||
const updateSourceProgress =
|
||||
this[$progressTracker].beginActivity('usdz-conversion');
|
||||
|
||||
await this[$triggerLoad]();
|
||||
|
||||
const {model, shadow, target} = this[$scene];
|
||||
if (model == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let visible = false;
|
||||
|
||||
// Remove shadow from export
|
||||
if (shadow != null) {
|
||||
visible = shadow.visible;
|
||||
shadow.visible = false;
|
||||
}
|
||||
|
||||
updateSourceProgress(0.2);
|
||||
|
||||
const exporter = new USDZExporter();
|
||||
|
||||
target.remove(model);
|
||||
model.position.copy(target.position);
|
||||
model.updateWorldMatrix(false, true);
|
||||
|
||||
const arraybuffer = await exporter.parseAsync(model, {
|
||||
maxTextureSize: isNaN(this.arUsdzMaxTextureSize as any) ?
|
||||
Infinity :
|
||||
Math.max(parseInt(this.arUsdzMaxTextureSize), 16),
|
||||
});
|
||||
|
||||
model.position.set(0, 0, 0);
|
||||
target.add(model);
|
||||
|
||||
const blob = new Blob([arraybuffer], {
|
||||
type: 'model/vnd.usdz+zip',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
updateSourceProgress(1);
|
||||
|
||||
if (shadow != null) {
|
||||
shadow.visible = visible;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
return ARModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,956 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {Event, PerspectiveCamera, Spherical, Vector3} from 'three';
|
||||
|
||||
import {style} from '../decorators.js';
|
||||
import ModelViewerElementBase, {$ariaLabel, $container, $getModelIsVisible, $loadedTime, $needsRender, $onModelLoad, $onResize, $renderer, $scene, $tick, $updateStatus, $userInputElement, toVector3D, Vector3D} from '../model-viewer-base.js';
|
||||
import {degreesToRadians, normalizeUnit} from '../styles/conversions.js';
|
||||
import {EvaluatedStyle, Intrinsics, SphericalIntrinsics, StyleEvaluator, Vector3Intrinsics} from '../styles/evaluators.js';
|
||||
import {IdentNode, NumberNode, numberNode, parseExpressions} from '../styles/parsers.js';
|
||||
import {DECAY_MILLISECONDS} from '../three-components/Damper.js';
|
||||
import {ChangeSource, PointerChangeEvent, SmoothControls} from '../three-components/SmoothControls.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
import {Path, timeline, TimingFunction} from '../utilities/animation.js';
|
||||
|
||||
|
||||
|
||||
// NOTE(cdata): The following "animation" timing functions are deliberately
|
||||
// being used in favor of CSS animations. In Safari 12.1 and 13, CSS animations
|
||||
// would cause the interaction prompt to glitch unexpectedly
|
||||
// @see https://github.com/google/model-viewer/issues/839
|
||||
const PROMPT_ANIMATION_TIME = 5000;
|
||||
|
||||
// For timing purposes, a "frame" is a timing agnostic relative unit of time
|
||||
// and a "value" is a target value for the Frame.
|
||||
const wiggle = timeline({
|
||||
initialValue: 0,
|
||||
keyframes: [
|
||||
{frames: 5, value: -1},
|
||||
{frames: 1, value: -1},
|
||||
{frames: 8, value: 1},
|
||||
{frames: 1, value: 1},
|
||||
{frames: 5, value: 0},
|
||||
{frames: 18, value: 0}
|
||||
]
|
||||
});
|
||||
|
||||
const fade = timeline({
|
||||
initialValue: 0,
|
||||
keyframes: [
|
||||
{frames: 1, value: 1},
|
||||
{frames: 5, value: 1},
|
||||
{frames: 1, value: 0},
|
||||
{frames: 6, value: 0}
|
||||
]
|
||||
});
|
||||
|
||||
export const DEFAULT_FOV_DEG = 30;
|
||||
export const DEFAULT_MIN_FOV_DEG = 12;
|
||||
|
||||
export const DEFAULT_CAMERA_ORBIT = '0deg 75deg 105%';
|
||||
const DEFAULT_CAMERA_TARGET = 'auto auto auto';
|
||||
const DEFAULT_FIELD_OF_VIEW = 'auto';
|
||||
|
||||
const MINIMUM_RADIUS_RATIO = 2.2;
|
||||
|
||||
const AZIMUTHAL_QUADRANT_LABELS = ['front', 'right', 'back', 'left'];
|
||||
const POLAR_TRIENT_LABELS = ['upper-', '', 'lower-'];
|
||||
|
||||
export const DEFAULT_INTERACTION_PROMPT_THRESHOLD = 3000;
|
||||
export const INTERACTION_PROMPT = '. Use mouse, touch or arrow keys to move.';
|
||||
|
||||
export interface CameraChangeDetails {
|
||||
source: ChangeSource;
|
||||
}
|
||||
|
||||
export interface SphericalPosition {
|
||||
theta: number; // equator angle around the y (up) axis.
|
||||
phi: number; // polar angle from the y (up) axis.
|
||||
radius: number;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export interface Finger {
|
||||
x: Path;
|
||||
y: Path;
|
||||
}
|
||||
|
||||
export interface A11yTranslationsInterface {
|
||||
left: string;
|
||||
right: string;
|
||||
front: string;
|
||||
back: string;
|
||||
'upper-left': string;
|
||||
'upper-right': string;
|
||||
'upper-front': string;
|
||||
'upper-back': string;
|
||||
'lower-left': string;
|
||||
'lower-right': string;
|
||||
'lower-front': string;
|
||||
'lower-back': string;
|
||||
'interaction-prompt': string;
|
||||
}
|
||||
|
||||
export type InteractionPromptStrategy = 'auto'|'none';
|
||||
export type InteractionPromptStyle = 'basic'|'wiggle';
|
||||
export type TouchAction = 'pan-y'|'pan-x'|'none';
|
||||
|
||||
export const InteractionPromptStrategy:
|
||||
{[index: string]: InteractionPromptStrategy} = {
|
||||
AUTO: 'auto',
|
||||
NONE: 'none'
|
||||
};
|
||||
|
||||
export const InteractionPromptStyle:
|
||||
{[index: string]: InteractionPromptStyle} = {
|
||||
BASIC: 'basic',
|
||||
WIGGLE: 'wiggle'
|
||||
};
|
||||
|
||||
export const TouchAction: {[index: string]: TouchAction} = {
|
||||
PAN_Y: 'pan-y',
|
||||
PAN_X: 'pan-x',
|
||||
NONE: 'none'
|
||||
};
|
||||
|
||||
export const fieldOfViewIntrinsics = () => {
|
||||
return {
|
||||
basis:
|
||||
[degreesToRadians(numberNode(DEFAULT_FOV_DEG, 'deg')) as
|
||||
NumberNode<'rad'>],
|
||||
keywords: {auto: [null]}
|
||||
};
|
||||
};
|
||||
|
||||
const minFieldOfViewIntrinsics = () => {
|
||||
return {
|
||||
basis:
|
||||
[degreesToRadians(numberNode(DEFAULT_MIN_FOV_DEG, 'deg')) as
|
||||
NumberNode<'rad'>],
|
||||
keywords: {auto: [null]}
|
||||
};
|
||||
};
|
||||
|
||||
export const cameraOrbitIntrinsics = (() => {
|
||||
const defaultTerms =
|
||||
parseExpressions(DEFAULT_CAMERA_ORBIT)[0]
|
||||
.terms as [NumberNode<'rad'>, NumberNode<'rad'>, IdentNode];
|
||||
|
||||
const theta = normalizeUnit(defaultTerms[0]) as NumberNode<'rad'>;
|
||||
const phi = normalizeUnit(defaultTerms[1]) as NumberNode<'rad'>;
|
||||
|
||||
return (element: ModelViewerElementBase) => {
|
||||
const radius = element[$scene].idealCameraDistance();
|
||||
|
||||
return {
|
||||
basis: [theta, phi, numberNode(radius, 'm')],
|
||||
keywords: {auto: [null, null, numberNode(105, '%')]}
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
const minCameraOrbitIntrinsics = (element: ModelViewerElementBase&
|
||||
ControlsInterface) => {
|
||||
const radius = MINIMUM_RADIUS_RATIO * element[$scene].boundingSphere.radius;
|
||||
|
||||
return {
|
||||
basis: [
|
||||
numberNode(-Infinity, 'rad'),
|
||||
numberNode(0, 'rad'),
|
||||
numberNode(radius, 'm')
|
||||
],
|
||||
keywords: {auto: [null, null, null]}
|
||||
};
|
||||
};
|
||||
|
||||
const maxCameraOrbitIntrinsics = (element: ModelViewerElementBase) => {
|
||||
const orbitIntrinsics = cameraOrbitIntrinsics(element);
|
||||
const evaluator = new StyleEvaluator([], orbitIntrinsics);
|
||||
const defaultRadius = evaluator.evaluate()[2];
|
||||
|
||||
return {
|
||||
basis: [
|
||||
numberNode(Infinity, 'rad'),
|
||||
numberNode(Math.PI, 'rad'),
|
||||
numberNode(defaultRadius, 'm')
|
||||
],
|
||||
keywords: {auto: [null, null, null]}
|
||||
};
|
||||
};
|
||||
|
||||
export const cameraTargetIntrinsics = (element: ModelViewerElementBase) => {
|
||||
const center = element[$scene].boundingBox.getCenter(new Vector3());
|
||||
|
||||
return {
|
||||
basis: [
|
||||
numberNode(center.x, 'm'),
|
||||
numberNode(center.y, 'm'),
|
||||
numberNode(center.z, 'm')
|
||||
],
|
||||
keywords: {auto: [null, null, null]}
|
||||
};
|
||||
};
|
||||
|
||||
const HALF_PI = Math.PI / 2.0;
|
||||
const THIRD_PI = Math.PI / 3.0;
|
||||
const QUARTER_PI = HALF_PI / 2.0;
|
||||
const TAU = 2.0 * Math.PI;
|
||||
|
||||
export const $controls = Symbol('controls');
|
||||
export const $panElement = Symbol('panElement');
|
||||
export const $promptElement = Symbol('promptElement');
|
||||
export const $promptAnimatedContainer = Symbol('promptAnimatedContainer');
|
||||
export const $fingerAnimatedContainers = Symbol('fingerAnimatedContainers');
|
||||
|
||||
const $deferInteractionPrompt = Symbol('deferInteractionPrompt');
|
||||
const $updateAria = Symbol('updateAria');
|
||||
const $a11y = Symbol('a11y');
|
||||
const $updateA11y = Symbol('updateA11y');
|
||||
const $updateCameraForRadius = Symbol('updateCameraForRadius');
|
||||
|
||||
const $cancelPrompts = Symbol('cancelPrompts');
|
||||
const $onChange = Symbol('onChange');
|
||||
const $onPointerChange = Symbol('onPointerChange');
|
||||
|
||||
const $waitingToPromptUser = Symbol('waitingToPromptUser');
|
||||
const $userHasInteracted = Symbol('userHasInteracted');
|
||||
const $promptElementVisibleTime = Symbol('promptElementVisibleTime');
|
||||
const $lastPromptOffset = Symbol('lastPromptOffset');
|
||||
const $cancellationSource = Symbol('cancellationSource');
|
||||
|
||||
const $lastSpherical = Symbol('lastSpherical');
|
||||
const $jumpCamera = Symbol('jumpCamera');
|
||||
const $initialized = Symbol('initialized');
|
||||
const $maintainThetaPhi = Symbol('maintainThetaPhi');
|
||||
|
||||
const $syncCameraOrbit = Symbol('syncCameraOrbit');
|
||||
const $syncFieldOfView = Symbol('syncFieldOfView');
|
||||
const $syncCameraTarget = Symbol('syncCameraTarget');
|
||||
|
||||
const $syncMinCameraOrbit = Symbol('syncMinCameraOrbit');
|
||||
const $syncMaxCameraOrbit = Symbol('syncMaxCameraOrbit');
|
||||
const $syncMinFieldOfView = Symbol('syncMinFieldOfView');
|
||||
const $syncMaxFieldOfView = Symbol('syncMaxFieldOfView');
|
||||
|
||||
export declare interface ControlsInterface {
|
||||
cameraControls: boolean;
|
||||
cameraOrbit: string;
|
||||
cameraTarget: string;
|
||||
fieldOfView: string;
|
||||
minCameraOrbit: string;
|
||||
maxCameraOrbit: string;
|
||||
minFieldOfView: string;
|
||||
maxFieldOfView: string;
|
||||
interactionPrompt: InteractionPromptStrategy;
|
||||
interactionPromptStyle: InteractionPromptStyle;
|
||||
interactionPromptThreshold: number;
|
||||
orbitSensitivity: number;
|
||||
zoomSensitivity: number;
|
||||
panSensitivity: number;
|
||||
touchAction: TouchAction;
|
||||
interpolationDecay: number;
|
||||
disableZoom: boolean;
|
||||
disablePan: boolean;
|
||||
disableTap: boolean;
|
||||
a11y: A11yTranslationsInterface|string|null;
|
||||
getCameraOrbit(): SphericalPosition;
|
||||
getCameraTarget(): Vector3D;
|
||||
getFieldOfView(): number;
|
||||
getMinimumFieldOfView(): number;
|
||||
getMaximumFieldOfView(): number;
|
||||
getIdealAspect(): number;
|
||||
jumpCameraToGoal(): void;
|
||||
updateFraming(): Promise<void>;
|
||||
resetInteractionPrompt(): void;
|
||||
zoom(keyPresses: number): void;
|
||||
interact(duration: number, finger0: Finger, finger1?: Finger): void;
|
||||
inputSensitivity: number;
|
||||
}
|
||||
|
||||
export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<ControlsInterface>&T => {
|
||||
class ControlsModelViewerElement extends ModelViewerElement {
|
||||
@property({type: Boolean, attribute: 'camera-controls'})
|
||||
cameraControls: boolean = false;
|
||||
|
||||
@style({
|
||||
intrinsics: cameraOrbitIntrinsics,
|
||||
observeEffects: true,
|
||||
updateHandler: $syncCameraOrbit
|
||||
})
|
||||
@property({type: String, attribute: 'camera-orbit', hasChanged: () => true})
|
||||
cameraOrbit: string = DEFAULT_CAMERA_ORBIT;
|
||||
|
||||
@style({
|
||||
intrinsics: cameraTargetIntrinsics,
|
||||
observeEffects: true,
|
||||
updateHandler: $syncCameraTarget
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'camera-target', hasChanged: () => true})
|
||||
cameraTarget: string = DEFAULT_CAMERA_TARGET;
|
||||
|
||||
@style({
|
||||
intrinsics: fieldOfViewIntrinsics,
|
||||
observeEffects: true,
|
||||
updateHandler: $syncFieldOfView
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'field-of-view', hasChanged: () => true})
|
||||
fieldOfView: string = DEFAULT_FIELD_OF_VIEW;
|
||||
|
||||
@style({
|
||||
intrinsics: minCameraOrbitIntrinsics,
|
||||
updateHandler: $syncMinCameraOrbit
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'min-camera-orbit', hasChanged: () => true})
|
||||
minCameraOrbit: string = 'auto';
|
||||
|
||||
@style({
|
||||
intrinsics: maxCameraOrbitIntrinsics,
|
||||
updateHandler: $syncMaxCameraOrbit
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'max-camera-orbit', hasChanged: () => true})
|
||||
maxCameraOrbit: string = 'auto';
|
||||
|
||||
@style({
|
||||
intrinsics: minFieldOfViewIntrinsics,
|
||||
updateHandler: $syncMinFieldOfView
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'min-field-of-view', hasChanged: () => true})
|
||||
minFieldOfView: string = 'auto';
|
||||
|
||||
@style(
|
||||
{intrinsics: fieldOfViewIntrinsics, updateHandler: $syncMaxFieldOfView})
|
||||
@property(
|
||||
{type: String, attribute: 'max-field-of-view', hasChanged: () => true})
|
||||
maxFieldOfView: string = 'auto';
|
||||
|
||||
@property({type: Number, attribute: 'interaction-prompt-threshold'})
|
||||
interactionPromptThreshold: number = DEFAULT_INTERACTION_PROMPT_THRESHOLD;
|
||||
|
||||
@property({type: String, attribute: 'interaction-prompt'})
|
||||
interactionPrompt: InteractionPromptStrategy =
|
||||
InteractionPromptStrategy.AUTO;
|
||||
|
||||
@property({type: String, attribute: 'interaction-prompt-style'})
|
||||
interactionPromptStyle: InteractionPromptStyle =
|
||||
InteractionPromptStyle.WIGGLE;
|
||||
|
||||
@property({type: Number, attribute: 'orbit-sensitivity'})
|
||||
orbitSensitivity: number = 1;
|
||||
|
||||
@property({type: Number, attribute: 'zoom-sensitivity'})
|
||||
zoomSensitivity: number = 1;
|
||||
|
||||
@property({type: Number, attribute: 'pan-sensitivity'})
|
||||
panSensitivity: number = 1;
|
||||
|
||||
@property({type: String, attribute: 'touch-action'})
|
||||
touchAction: TouchAction = TouchAction.NONE;
|
||||
|
||||
@property({type: Boolean, attribute: 'disable-zoom'})
|
||||
disableZoom: boolean = false;
|
||||
|
||||
@property({type: Boolean, attribute: 'disable-pan'})
|
||||
disablePan: boolean = false;
|
||||
|
||||
@property({type: Boolean, attribute: 'disable-tap'})
|
||||
disableTap: boolean = false;
|
||||
|
||||
@property({type: Number, attribute: 'interpolation-decay'})
|
||||
interpolationDecay: number = DECAY_MILLISECONDS;
|
||||
|
||||
@property() a11y: A11yTranslationsInterface|string|null = null;
|
||||
|
||||
protected[$promptElement] =
|
||||
this.shadowRoot!.querySelector('.interaction-prompt') as HTMLElement;
|
||||
protected[$promptAnimatedContainer] =
|
||||
this.shadowRoot!.querySelector('#prompt') as HTMLElement;
|
||||
protected[$fingerAnimatedContainers]: HTMLElement[] = [
|
||||
this.shadowRoot!.querySelector('#finger0')!,
|
||||
this.shadowRoot!.querySelector('#finger1')!
|
||||
];
|
||||
protected[$panElement] =
|
||||
this.shadowRoot!.querySelector('.pan-target') as HTMLElement;
|
||||
|
||||
protected[$lastPromptOffset] = 0;
|
||||
protected[$promptElementVisibleTime] = Infinity;
|
||||
protected[$userHasInteracted] = false;
|
||||
protected[$waitingToPromptUser] = false;
|
||||
protected[$cancellationSource] = ChangeSource.AUTOMATIC;
|
||||
|
||||
protected[$controls] = new SmoothControls(
|
||||
this[$scene].camera as PerspectiveCamera, this[$userInputElement],
|
||||
this[$scene]);
|
||||
|
||||
protected[$lastSpherical] = new Spherical();
|
||||
protected[$jumpCamera] = false;
|
||||
protected[$initialized] = false;
|
||||
protected[$maintainThetaPhi] = false;
|
||||
protected[$a11y] = {} as A11yTranslationsInterface;
|
||||
|
||||
get inputSensitivity(): number {
|
||||
return this[$controls].inputSensitivity;
|
||||
}
|
||||
|
||||
set inputSensitivity(value: number) {
|
||||
this[$controls].inputSensitivity = value;
|
||||
}
|
||||
|
||||
getCameraOrbit(): SphericalPosition {
|
||||
const {theta, phi, radius} = this[$lastSpherical];
|
||||
return {
|
||||
theta,
|
||||
phi,
|
||||
radius,
|
||||
toString() {
|
||||
return `${this.theta}rad ${this.phi}rad ${this.radius}m`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getCameraTarget(): Vector3D {
|
||||
return toVector3D(
|
||||
this[$renderer].isPresenting ? this[$renderer].arRenderer.target :
|
||||
this[$scene].getDynamicTarget());
|
||||
}
|
||||
|
||||
getFieldOfView(): number {
|
||||
return this[$controls].getFieldOfView();
|
||||
}
|
||||
|
||||
// Provided so user code does not have to parse these from attributes.
|
||||
getMinimumFieldOfView(): number {
|
||||
return this[$controls].options.minimumFieldOfView!;
|
||||
}
|
||||
|
||||
getMaximumFieldOfView(): number {
|
||||
return this[$controls].options.maximumFieldOfView!;
|
||||
}
|
||||
|
||||
getIdealAspect(): number {
|
||||
return this[$scene].idealAspect;
|
||||
}
|
||||
|
||||
jumpCameraToGoal() {
|
||||
this[$jumpCamera] = true;
|
||||
this.requestUpdate($jumpCamera, false);
|
||||
}
|
||||
|
||||
resetInteractionPrompt() {
|
||||
this[$lastPromptOffset] = 0;
|
||||
this[$promptElementVisibleTime] = Infinity;
|
||||
this[$userHasInteracted] = false;
|
||||
this[$waitingToPromptUser] =
|
||||
this.interactionPrompt === InteractionPromptStrategy.AUTO &&
|
||||
this.cameraControls;
|
||||
}
|
||||
|
||||
zoom(keyPresses: number) {
|
||||
const event = new WheelEvent('wheel', {deltaY: -30 * keyPresses});
|
||||
this[$userInputElement].dispatchEvent(event);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this[$controls].addEventListener(
|
||||
'user-interaction', this[$cancelPrompts]);
|
||||
this[$controls].addEventListener(
|
||||
'pointer-change-start',
|
||||
this[$onPointerChange] as (event: Event) => void);
|
||||
this[$controls].addEventListener(
|
||||
'pointer-change-end',
|
||||
this[$onPointerChange] as (event: Event) => void);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this[$controls].removeEventListener(
|
||||
'user-interaction', this[$cancelPrompts]);
|
||||
this[$controls].removeEventListener(
|
||||
'pointer-change-start',
|
||||
this[$onPointerChange] as (event: Event) => void);
|
||||
this[$controls].removeEventListener(
|
||||
'pointer-change-end',
|
||||
this[$onPointerChange] as (event: Event) => void);
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string|number|symbol, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
const controls = this[$controls];
|
||||
const scene = this[$scene];
|
||||
|
||||
if (changedProperties.has('cameraControls')) {
|
||||
if (this.cameraControls) {
|
||||
controls.enableInteraction();
|
||||
if (this.interactionPrompt === InteractionPromptStrategy.AUTO) {
|
||||
this[$waitingToPromptUser] = true;
|
||||
}
|
||||
} else {
|
||||
controls.disableInteraction();
|
||||
this[$deferInteractionPrompt]();
|
||||
}
|
||||
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
|
||||
}
|
||||
|
||||
if (changedProperties.has('disableZoom')) {
|
||||
controls.disableZoom = this.disableZoom;
|
||||
}
|
||||
|
||||
if (changedProperties.has('disablePan')) {
|
||||
controls.enablePan = !this.disablePan;
|
||||
}
|
||||
|
||||
if (changedProperties.has('disableTap')) {
|
||||
controls.enableTap = !this.disableTap;
|
||||
}
|
||||
|
||||
if (changedProperties.has('interactionPrompt') ||
|
||||
changedProperties.has('cameraControls') ||
|
||||
changedProperties.has('src')) {
|
||||
if (this.interactionPrompt === InteractionPromptStrategy.AUTO &&
|
||||
this.cameraControls && !this[$userHasInteracted]) {
|
||||
this[$waitingToPromptUser] = true;
|
||||
} else {
|
||||
this[$deferInteractionPrompt]();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has('interactionPromptStyle')) {
|
||||
this[$promptAnimatedContainer].style.opacity =
|
||||
this.interactionPromptStyle == InteractionPromptStyle.BASIC ? '1' :
|
||||
'0';
|
||||
}
|
||||
|
||||
if (changedProperties.has('touchAction')) {
|
||||
const touchAction = this.touchAction;
|
||||
controls.applyOptions({touchAction});
|
||||
controls.updateTouchActionStyle();
|
||||
}
|
||||
|
||||
if (changedProperties.has('orbitSensitivity')) {
|
||||
controls.orbitSensitivity = this.orbitSensitivity;
|
||||
}
|
||||
|
||||
if (changedProperties.has('zoomSensitivity')) {
|
||||
controls.zoomSensitivity = this.zoomSensitivity;
|
||||
}
|
||||
|
||||
if (changedProperties.has('panSensitivity')) {
|
||||
controls.panSensitivity = this.panSensitivity;
|
||||
}
|
||||
|
||||
if (changedProperties.has('interpolationDecay')) {
|
||||
controls.setDamperDecayTime(this.interpolationDecay);
|
||||
scene.setTargetDamperDecayTime(this.interpolationDecay);
|
||||
}
|
||||
|
||||
if (changedProperties.has('a11y')) {
|
||||
this[$updateA11y]();
|
||||
}
|
||||
|
||||
if (this[$jumpCamera] === true) {
|
||||
Promise.resolve().then(() => {
|
||||
controls.jumpToGoal();
|
||||
scene.jumpToGoal();
|
||||
this[$onChange]();
|
||||
this[$jumpCamera] = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateFraming() {
|
||||
const scene = this[$scene];
|
||||
const oldFramedFoV = scene.adjustedFoV(scene.framedFoVDeg);
|
||||
|
||||
await scene.updateFraming();
|
||||
|
||||
const newFramedFoV = scene.adjustedFoV(scene.framedFoVDeg);
|
||||
const zoom = this[$controls].getFieldOfView() / oldFramedFoV;
|
||||
this[$controls].setFieldOfView(newFramedFoV * zoom);
|
||||
this[$maintainThetaPhi] = true;
|
||||
|
||||
this.requestUpdate('maxFieldOfView');
|
||||
this.requestUpdate('fieldOfView');
|
||||
this.requestUpdate('minCameraOrbit');
|
||||
this.requestUpdate('maxCameraOrbit');
|
||||
this.requestUpdate('cameraOrbit');
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
interact(duration: number, finger0: Finger, finger1?: Finger) {
|
||||
const inputElement = this[$userInputElement];
|
||||
const fingerElements = this[$fingerAnimatedContainers];
|
||||
|
||||
if (fingerElements[0].style.opacity === '1') {
|
||||
console.warn(
|
||||
'interact() failed because an existing interaction is running.')
|
||||
return;
|
||||
}
|
||||
|
||||
const xy = new Array<{x: TimingFunction, y: TimingFunction}>();
|
||||
xy.push({x: timeline(finger0.x), y: timeline(finger0.y)});
|
||||
const positions = [{x: xy[0].x(0), y: xy[0].y(0)}];
|
||||
|
||||
if (finger1 != null) {
|
||||
xy.push({x: timeline(finger1.x), y: timeline(finger1.y)});
|
||||
positions.push({x: xy[1].x(0), y: xy[1].y(0)});
|
||||
}
|
||||
|
||||
let startTime = performance.now();
|
||||
const {width, height} = this[$scene];
|
||||
const rect = this.getBoundingClientRect();
|
||||
|
||||
const dispatchTouches = (type: string) => {
|
||||
for (const [i, position] of positions.entries()) {
|
||||
const {style} = fingerElements[i];
|
||||
style.transform = `translateX(${width * position.x}px) translateY(${
|
||||
height * position.y}px)`;
|
||||
if (type === 'pointerdown') {
|
||||
style.opacity = '1';
|
||||
} else if (type === 'pointerup') {
|
||||
style.opacity = '0';
|
||||
}
|
||||
|
||||
const init = {
|
||||
pointerId: i - 5678, // help ensure uniqueness
|
||||
pointerType: 'touch',
|
||||
target: inputElement,
|
||||
clientX: width * position.x + rect.x,
|
||||
clientY: height * position.y + rect.y,
|
||||
altKey: true // flag that this is not a user interaction
|
||||
} as PointerEventInit;
|
||||
|
||||
inputElement.dispatchEvent(new PointerEvent(type, init));
|
||||
}
|
||||
};
|
||||
|
||||
const moveTouches = () => {
|
||||
// Cancel interaction if something else moves the camera or input is
|
||||
// removed from the DOM.
|
||||
const changeSource = this[$cancellationSource];
|
||||
if (changeSource !== ChangeSource.AUTOMATIC ||
|
||||
!inputElement.isConnected) {
|
||||
for (const fingerElement of this[$fingerAnimatedContainers]) {
|
||||
fingerElement.style.opacity = '0';
|
||||
}
|
||||
dispatchTouches('pointercancel');
|
||||
this.dispatchEvent(new CustomEvent<CameraChangeDetails>(
|
||||
'interact-stopped', {detail: {source: changeSource}}));
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const time = Math.min(1, (performance.now() - startTime) / duration);
|
||||
for (const [i, position] of positions.entries()) {
|
||||
position.x = xy[i].x(time);
|
||||
position.y = xy[i].y(time);
|
||||
}
|
||||
dispatchTouches('pointermove');
|
||||
|
||||
if (time < 1) {
|
||||
requestAnimationFrame(moveTouches);
|
||||
} else {
|
||||
dispatchTouches('pointerup');
|
||||
this.dispatchEvent(new CustomEvent<CameraChangeDetails>(
|
||||
'interact-stopped', {detail: {source: ChangeSource.AUTOMATIC}}));
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
let elapsed = 0;
|
||||
if (document.visibilityState === 'hidden') {
|
||||
elapsed = performance.now() - startTime;
|
||||
} else {
|
||||
startTime = performance.now() - elapsed;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
dispatchTouches('pointerdown');
|
||||
|
||||
this[$cancellationSource] = ChangeSource.AUTOMATIC;
|
||||
|
||||
requestAnimationFrame(moveTouches);
|
||||
}
|
||||
|
||||
[$syncFieldOfView](style: EvaluatedStyle<Intrinsics<['rad']>>) {
|
||||
const controls = this[$controls];
|
||||
const scene = this[$scene];
|
||||
scene.framedFoVDeg = style[0] * 180 / Math.PI;
|
||||
controls.changeSource = ChangeSource.NONE;
|
||||
controls.setFieldOfView(scene.adjustedFoV(scene.framedFoVDeg));
|
||||
this[$cancelPrompts]();
|
||||
}
|
||||
|
||||
[$syncCameraOrbit](style: EvaluatedStyle<SphericalIntrinsics>) {
|
||||
const controls = this[$controls];
|
||||
if (this[$maintainThetaPhi]) {
|
||||
const {theta, phi} = this.getCameraOrbit();
|
||||
style[0] = theta;
|
||||
style[1] = phi;
|
||||
this[$maintainThetaPhi] = false;
|
||||
}
|
||||
controls.changeSource = ChangeSource.NONE;
|
||||
controls.setOrbit(style[0], style[1], style[2]);
|
||||
this[$cancelPrompts]();
|
||||
}
|
||||
|
||||
[$syncMinCameraOrbit](style: EvaluatedStyle<SphericalIntrinsics>) {
|
||||
this[$controls].applyOptions({
|
||||
minimumAzimuthalAngle: style[0],
|
||||
minimumPolarAngle: style[1],
|
||||
minimumRadius: style[2]
|
||||
});
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$syncMaxCameraOrbit](style: EvaluatedStyle<SphericalIntrinsics>) {
|
||||
this[$controls].applyOptions({
|
||||
maximumAzimuthalAngle: style[0],
|
||||
maximumPolarAngle: style[1],
|
||||
maximumRadius: style[2]
|
||||
});
|
||||
this[$updateCameraForRadius](style[2]);
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$syncMinFieldOfView](style: EvaluatedStyle<Intrinsics<['rad']>>) {
|
||||
this[$controls].applyOptions(
|
||||
{minimumFieldOfView: style[0] * 180 / Math.PI});
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$syncMaxFieldOfView](style: EvaluatedStyle<Intrinsics<['rad']>>) {
|
||||
const fov = this[$scene].adjustedFoV(style[0] * 180 / Math.PI);
|
||||
this[$controls].applyOptions({maximumFieldOfView: fov});
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$syncCameraTarget](style: EvaluatedStyle<Vector3Intrinsics>) {
|
||||
const [x, y, z] = style;
|
||||
if (!this[$renderer].arRenderer.isPresenting) {
|
||||
this[$scene].setTarget(x, y, z);
|
||||
}
|
||||
this[$controls].changeSource = ChangeSource.NONE;
|
||||
this[$renderer].arRenderer.updateTarget();
|
||||
this[$cancelPrompts]();
|
||||
}
|
||||
|
||||
[$tick](time: number, delta: number) {
|
||||
super[$tick](time, delta);
|
||||
|
||||
if (this[$renderer].isPresenting || !this[$getModelIsVisible]()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = this[$controls];
|
||||
const scene = this[$scene];
|
||||
|
||||
const now = performance.now();
|
||||
if (this[$waitingToPromptUser]) {
|
||||
if (this.loaded &&
|
||||
now > this[$loadedTime] + this.interactionPromptThreshold) {
|
||||
this[$waitingToPromptUser] = false;
|
||||
this[$promptElementVisibleTime] = now;
|
||||
|
||||
this[$promptElement].classList.add('visible');
|
||||
}
|
||||
}
|
||||
|
||||
if (isFinite(this[$promptElementVisibleTime]) &&
|
||||
this.interactionPromptStyle === InteractionPromptStyle.WIGGLE) {
|
||||
const animationTime =
|
||||
((now - this[$promptElementVisibleTime]) / PROMPT_ANIMATION_TIME) %
|
||||
1;
|
||||
const offset = wiggle(animationTime);
|
||||
const opacity = fade(animationTime);
|
||||
|
||||
this[$promptAnimatedContainer].style.opacity = `${opacity}`;
|
||||
|
||||
if (offset !== this[$lastPromptOffset]) {
|
||||
const xOffset = offset * scene.width * 0.05;
|
||||
const deltaTheta = (offset - this[$lastPromptOffset]) * Math.PI / 16;
|
||||
|
||||
this[$promptAnimatedContainer].style.transform =
|
||||
`translateX(${xOffset}px)`;
|
||||
|
||||
controls.changeSource = ChangeSource.AUTOMATIC;
|
||||
controls.adjustOrbit(deltaTheta, 0, 0);
|
||||
|
||||
this[$lastPromptOffset] = offset;
|
||||
}
|
||||
}
|
||||
|
||||
const cameraMoved = controls.update(time, delta);
|
||||
const targetMoved = scene.updateTarget(delta);
|
||||
|
||||
if (cameraMoved || targetMoved) {
|
||||
this[$onChange]();
|
||||
}
|
||||
}
|
||||
|
||||
[$deferInteractionPrompt]() {
|
||||
// Effectively cancel the timer waiting for user interaction:
|
||||
this[$waitingToPromptUser] = false;
|
||||
this[$promptElement].classList.remove('visible');
|
||||
this[$promptElementVisibleTime] = Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the camera's near and far planes to enclose the scene when
|
||||
* orbiting at the supplied radius.
|
||||
*/
|
||||
[$updateCameraForRadius](radius: number) {
|
||||
const maximumRadius = Math.max(this[$scene].farRadius(), radius);
|
||||
|
||||
const near = 0;
|
||||
const far = Math.abs(2 * maximumRadius);
|
||||
this[$controls].updateNearFar(near, far);
|
||||
}
|
||||
|
||||
[$updateAria]() {
|
||||
const {theta, phi} =
|
||||
this[$controls]!.getCameraSpherical(this[$lastSpherical]);
|
||||
|
||||
const azimuthalQuadrant =
|
||||
(4 + Math.floor(((theta % TAU) + QUARTER_PI) / HALF_PI)) % 4;
|
||||
|
||||
const polarTrient = Math.floor(phi / THIRD_PI);
|
||||
|
||||
const azimuthalQuadrantLabel =
|
||||
AZIMUTHAL_QUADRANT_LABELS[azimuthalQuadrant];
|
||||
const polarTrientLabel = POLAR_TRIENT_LABELS[polarTrient];
|
||||
const position = `${polarTrientLabel}${azimuthalQuadrantLabel}`;
|
||||
|
||||
const key = position as keyof A11yTranslationsInterface;
|
||||
if (key in this[$a11y]) {
|
||||
this[$updateStatus](this[$a11y][key]);
|
||||
} else {
|
||||
this[$updateStatus](`View from stage ${position}`);
|
||||
}
|
||||
}
|
||||
|
||||
get[$ariaLabel]() {
|
||||
let interactionPrompt = INTERACTION_PROMPT;
|
||||
if ('interaction-prompt' in this[$a11y]) {
|
||||
interactionPrompt = `. ${this[$a11y]['interaction-prompt']}`;
|
||||
}
|
||||
|
||||
return super[$ariaLabel].replace(/\.$/, '') +
|
||||
(this.cameraControls ? interactionPrompt : '');
|
||||
}
|
||||
|
||||
async[$onResize](event: any) {
|
||||
const controls = this[$controls];
|
||||
const scene = this[$scene];
|
||||
const oldFramedFoV = scene.adjustedFoV(scene.framedFoVDeg);
|
||||
|
||||
// The super of $onResize may update the scene's adjustedFoV, so we
|
||||
// compare the before and after to calculate the proper zoom.
|
||||
super[$onResize](event);
|
||||
|
||||
const fovRatio = scene.adjustedFoV(scene.framedFoVDeg) / oldFramedFoV;
|
||||
const fov =
|
||||
controls.getFieldOfView() * (isFinite(fovRatio) ? fovRatio : 1);
|
||||
|
||||
controls.updateAspect(this[$scene].aspect);
|
||||
|
||||
this.requestUpdate('maxFieldOfView', this.maxFieldOfView);
|
||||
await this.updateComplete;
|
||||
this[$controls].setFieldOfView(fov);
|
||||
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
super[$onModelLoad]();
|
||||
|
||||
if (this[$initialized]) {
|
||||
this[$maintainThetaPhi] = true;
|
||||
} else {
|
||||
this[$initialized] = true;
|
||||
}
|
||||
this.requestUpdate('maxFieldOfView', this.maxFieldOfView);
|
||||
this.requestUpdate('fieldOfView', this.fieldOfView);
|
||||
this.requestUpdate('minCameraOrbit', this.minCameraOrbit);
|
||||
this.requestUpdate('maxCameraOrbit', this.maxCameraOrbit);
|
||||
this.requestUpdate('cameraOrbit', this.cameraOrbit);
|
||||
this.requestUpdate('cameraTarget', this.cameraTarget);
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$cancelPrompts] = () => {
|
||||
const source = this[$controls].changeSource;
|
||||
this[$cancellationSource] = source;
|
||||
|
||||
if (source === ChangeSource.USER_INTERACTION) {
|
||||
this[$userHasInteracted] = true;
|
||||
this[$deferInteractionPrompt]();
|
||||
}
|
||||
};
|
||||
|
||||
[$onChange] = () => {
|
||||
this[$updateAria]();
|
||||
this[$needsRender]();
|
||||
const source = this[$controls].changeSource;
|
||||
|
||||
this.dispatchEvent(new CustomEvent<CameraChangeDetails>(
|
||||
'camera-change', {detail: {source}}));
|
||||
};
|
||||
|
||||
[$onPointerChange] = (event: PointerChangeEvent) => {
|
||||
this[$container].classList.toggle(
|
||||
'pointer-tumbling', event.type === 'pointer-change-start');
|
||||
};
|
||||
|
||||
[$updateA11y]() {
|
||||
if (typeof this.a11y === 'string') {
|
||||
if (this.a11y.startsWith('{')) {
|
||||
try {
|
||||
this[$a11y] = JSON.parse(this.a11y);
|
||||
} catch (error) {
|
||||
console.warn('Error parsing a11y JSON:', error);
|
||||
}
|
||||
} else if (this.a11y.length > 0) {
|
||||
console.warn(
|
||||
'Error not supported format, should be a JSON string:',
|
||||
this.a11y);
|
||||
} else {
|
||||
this[$a11y] = <A11yTranslationsInterface>{};
|
||||
}
|
||||
} else if (typeof this.a11y === 'object' && this.a11y != null) {
|
||||
this[$a11y] = Object.assign({}, this.a11y);
|
||||
} else {
|
||||
this[$a11y] = <A11yTranslationsInterface>{};
|
||||
}
|
||||
|
||||
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
|
||||
}
|
||||
}
|
||||
|
||||
return ControlsModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {ACESFilmicToneMapping, AgXToneMapping, CineonToneMapping, LinearToneMapping, NeutralToneMapping, NoToneMapping, ReinhardToneMapping, Texture} from 'three';
|
||||
|
||||
import ModelViewerElementBase, {$needsRender, $progressTracker, $renderer, $scene, $shouldAttemptPreload} from '../model-viewer-base.js';
|
||||
import {clamp, Constructor, deserializeUrl} from '../utilities.js';
|
||||
|
||||
export const BASE_OPACITY = 0.5;
|
||||
const DEFAULT_SHADOW_INTENSITY = 0.0;
|
||||
const DEFAULT_SHADOW_SOFTNESS = 1.0;
|
||||
const DEFAULT_EXPOSURE = 1.0;
|
||||
|
||||
export type ToneMappingValue = 'auto'|'aces'|'agx'|'commerce'|'neutral'|
|
||||
'reinhard'|'cineon'|'linear'|'none';
|
||||
|
||||
export const $currentEnvironmentMap = Symbol('currentEnvironmentMap');
|
||||
export const $currentBackground = Symbol('currentBackground');
|
||||
export const $updateEnvironment = Symbol('updateEnvironment');
|
||||
const $cancelEnvironmentUpdate = Symbol('cancelEnvironmentUpdate');
|
||||
|
||||
export declare interface EnvironmentInterface {
|
||||
environmentImage: string|null;
|
||||
skyboxImage: string|null;
|
||||
skyboxHeight: string;
|
||||
shadowIntensity: number;
|
||||
shadowSoftness: number;
|
||||
exposure: number;
|
||||
hasBakedShadow(): boolean;
|
||||
}
|
||||
|
||||
export const EnvironmentMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<EnvironmentInterface>&T => {
|
||||
class EnvironmentModelViewerElement extends ModelViewerElement {
|
||||
@property({type: String, attribute: 'environment-image'})
|
||||
environmentImage: string|null = null;
|
||||
|
||||
@property({type: String, attribute: 'skybox-image'})
|
||||
skyboxImage: string|null = null;
|
||||
|
||||
@property({type: Number, attribute: 'shadow-intensity'})
|
||||
shadowIntensity: number = DEFAULT_SHADOW_INTENSITY;
|
||||
|
||||
@property({type: Number, attribute: 'shadow-softness'})
|
||||
shadowSoftness: number = DEFAULT_SHADOW_SOFTNESS;
|
||||
|
||||
@property({type: Number}) exposure: number = DEFAULT_EXPOSURE;
|
||||
|
||||
@property({type: String, attribute: 'tone-mapping'})
|
||||
toneMapping: ToneMappingValue = 'auto';
|
||||
|
||||
@property({type: String, attribute: 'skybox-height'})
|
||||
skyboxHeight: string = '0';
|
||||
|
||||
protected[$currentEnvironmentMap]: Texture|null = null;
|
||||
protected[$currentBackground]: Texture|null = null;
|
||||
|
||||
private[$cancelEnvironmentUpdate]: ((...args: any[]) => any)|null = null;
|
||||
|
||||
updated(changedProperties: Map<string|number|symbol, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('shadowIntensity')) {
|
||||
this[$scene].setShadowIntensity(this.shadowIntensity * BASE_OPACITY);
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('shadowSoftness')) {
|
||||
this[$scene].setShadowSoftness(this.shadowSoftness);
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('exposure')) {
|
||||
this[$scene].exposure = this.exposure;
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('toneMapping')) {
|
||||
const TONE_MAPPING = new Map([
|
||||
['aces', ACESFilmicToneMapping],
|
||||
['agx', AgXToneMapping],
|
||||
['reinhard', ReinhardToneMapping],
|
||||
['cineon', CineonToneMapping],
|
||||
['linear', LinearToneMapping],
|
||||
['none', NoToneMapping]
|
||||
]);
|
||||
|
||||
this[$scene].toneMapping = TONE_MAPPING.get(this.toneMapping) ?? NeutralToneMapping;
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if ((changedProperties.has('environmentImage') ||
|
||||
changedProperties.has('skyboxImage')) &&
|
||||
this[$shouldAttemptPreload]()) {
|
||||
this[$updateEnvironment]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('skyboxHeight')) {
|
||||
this[$scene].setGroundedSkybox();
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
|
||||
hasBakedShadow(): boolean {
|
||||
return this[$scene].bakedShadows.size > 0;
|
||||
}
|
||||
|
||||
async[$updateEnvironment]() {
|
||||
const {skyboxImage, environmentImage} = this;
|
||||
|
||||
if (this[$cancelEnvironmentUpdate] != null) {
|
||||
this[$cancelEnvironmentUpdate]!();
|
||||
this[$cancelEnvironmentUpdate] = null;
|
||||
}
|
||||
|
||||
const {textureUtils} = this[$renderer];
|
||||
|
||||
if (textureUtils == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateEnvProgress =
|
||||
this[$progressTracker].beginActivity('environment-update');
|
||||
|
||||
try {
|
||||
const {environmentMap, skybox} =
|
||||
await textureUtils.generateEnvironmentMapAndSkybox(
|
||||
deserializeUrl(skyboxImage),
|
||||
environmentImage,
|
||||
(progress: number) => updateEnvProgress(clamp(progress, 0, 1)),
|
||||
this.withCredentials);
|
||||
|
||||
if (this[$currentEnvironmentMap] !== environmentMap) {
|
||||
this[$currentEnvironmentMap] = environmentMap;
|
||||
this.dispatchEvent(new CustomEvent('environment-change'));
|
||||
}
|
||||
if (skybox != null) {
|
||||
// When using the same environment and skybox, use the environment as
|
||||
// it gives HDR filtering.
|
||||
this[$currentBackground] =
|
||||
skybox.name === environmentMap.name ? environmentMap : skybox;
|
||||
} else {
|
||||
this[$currentBackground] = null;
|
||||
}
|
||||
|
||||
this[$scene].setEnvironmentAndSkybox(
|
||||
this[$currentEnvironmentMap], this[$currentBackground]);
|
||||
} catch (errorOrPromise) {
|
||||
if (errorOrPromise instanceof Error) {
|
||||
this[$scene].setEnvironmentAndSkybox(null, null);
|
||||
throw errorOrPromise;
|
||||
}
|
||||
} finally {
|
||||
updateEnvProgress(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EnvironmentModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,422 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {Vector3} from 'three';
|
||||
|
||||
import ModelViewerElementBase, {$altDefaulted, $announceModelVisibility, $getModelIsVisible, $isElementInViewport, $progressTracker, $scene, $shouldAttemptPreload, $updateSource, $userInputElement, toVector3D, Vector3D} from '../model-viewer-base.js';
|
||||
import {$loader, CachingGLTFLoader} from '../three-components/CachingGLTFLoader.js';
|
||||
import {Renderer} from '../three-components/Renderer.js';
|
||||
import {Constructor, throttle} from '../utilities.js';
|
||||
|
||||
export type RevealAttributeValue = 'auto'|'manual';
|
||||
export type LoadingAttributeValue = 'auto'|'lazy'|'eager';
|
||||
|
||||
export const PROGRESS_BAR_UPDATE_THRESHOLD = 100;
|
||||
|
||||
const DEFAULT_DRACO_DECODER_LOCATION =
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
|
||||
|
||||
const DEFAULT_KTX2_TRANSCODER_LOCATION =
|
||||
'https://www.gstatic.com/basis-universal/versioned/2021-04-15-ba1c3e4/';
|
||||
|
||||
const DEFAULT_LOTTIE_LOADER_LOCATION =
|
||||
'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/loaders/LottieLoader.js';
|
||||
|
||||
const RevealStrategy: {[index: string]: RevealAttributeValue} = {
|
||||
AUTO: 'auto',
|
||||
MANUAL: 'manual'
|
||||
};
|
||||
|
||||
const LoadingStrategy: {[index: string]: LoadingAttributeValue} = {
|
||||
AUTO: 'auto',
|
||||
LAZY: 'lazy',
|
||||
EAGER: 'eager'
|
||||
};
|
||||
|
||||
export const $defaultProgressBarElement = Symbol('defaultProgressBarElement');
|
||||
|
||||
export const $posterContainerElement = Symbol('posterContainerElement');
|
||||
export const $defaultPosterElement = Symbol('defaultPosterElement');
|
||||
|
||||
const $shouldDismissPoster = Symbol('shouldDismissPoster');
|
||||
const $hidePoster = Symbol('hidePoster');
|
||||
const $modelIsRevealed = Symbol('modelIsRevealed');
|
||||
const $updateProgressBar = Symbol('updateProgressBar');
|
||||
|
||||
const $ariaLabelCallToAction = Symbol('ariaLabelCallToAction');
|
||||
|
||||
const $onProgress = Symbol('onProgress');
|
||||
|
||||
export declare interface LoadingInterface {
|
||||
poster: string|null;
|
||||
reveal: RevealAttributeValue;
|
||||
loading: LoadingAttributeValue;
|
||||
readonly loaded: boolean;
|
||||
readonly modelIsVisible: boolean;
|
||||
dismissPoster(): void;
|
||||
showPoster(): void;
|
||||
getDimensions(): Vector3D;
|
||||
getBoundingBoxCenter(): Vector3D;
|
||||
}
|
||||
|
||||
export declare interface LoadingStaticInterface {
|
||||
dracoDecoderLocation: string;
|
||||
ktx2TranscoderLocation: string;
|
||||
meshoptDecoderLocation: string;
|
||||
lottieLoaderLocation: string;
|
||||
mapURLs(callback: (url: string) => string): void;
|
||||
}
|
||||
|
||||
export interface ModelViewerGlobalConfig {
|
||||
dracoDecoderLocation?: string;
|
||||
ktx2TranscoderLocation?: string;
|
||||
meshoptDecoderLocation?: string;
|
||||
lottieLoaderLocation?: string;
|
||||
powerPreference?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LoadingMixin implements features related to lazy loading, as well as
|
||||
* presentation details related to the pre-load / pre-render presentation of a
|
||||
* <model-viewer>
|
||||
*
|
||||
* This mixin implements support for models with DRACO-compressed meshes.
|
||||
* The DRACO decoder will be loaded on-demand if a glTF that uses the DRACO mesh
|
||||
* compression extension is encountered.
|
||||
*
|
||||
* By default, the DRACO decoder will be loaded from a Google CDN. It is
|
||||
* possible to customize where the decoder is loaded from by defining a global
|
||||
* configuration option for `<model-viewer>` like so:
|
||||
*
|
||||
* ```html
|
||||
* <script>
|
||||
* self.ModelViewerElement = self.ModelViewerElement || {};
|
||||
* self.ModelViewerElement.dracoDecoderLocation =
|
||||
* 'http://example.com/location/of/draco/decoder/files/';
|
||||
* </script>
|
||||
* ```
|
||||
*
|
||||
* Note that the above configuration strategy must be performed *before* the
|
||||
* first `<model-viewer>` element is created in the browser. The configuration
|
||||
* can be done anywhere, but the easiest way to ensure it is done at the right
|
||||
* time is to do it in the `<head>` of the HTML document. This is the
|
||||
* recommended way to set the location because it is most compatible with
|
||||
* scenarios where the `<model-viewer>` library is lazily loaded.
|
||||
*
|
||||
* If you absolutely have to set the DRACO decoder location *after* the first
|
||||
* `<model-viewer>` element is created, you can do it this way:
|
||||
*
|
||||
* ```html
|
||||
* <script>
|
||||
* const ModelViewerElement = customElements.get('model-viewer');
|
||||
* ModelViewerElement.dracoDecoderLocation =
|
||||
* 'http://example.com/location/of/draco/decoder/files/';
|
||||
* </script>
|
||||
* ```
|
||||
*
|
||||
* Note that the above configuration approach will not work until *after*
|
||||
* `<model-viewer>` is defined in the browser. Also note that this configuration
|
||||
* *must* be set *before* the first DRACO model is fully loaded.
|
||||
*
|
||||
* It is recommended that users who intend to take advantage of DRACO mesh
|
||||
* compression consider whether or not it is acceptable for their use case to
|
||||
* have code side-loaded from a Google CDN. If it is not acceptable, then the
|
||||
* location must be customized before loading any DRACO models in order to cause
|
||||
* the decoder to be loaded from an alternative, acceptable location.
|
||||
*/
|
||||
export const LoadingMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement:
|
||||
T): Constructor<LoadingInterface, LoadingStaticInterface>&T => {
|
||||
class LoadingModelViewerElement extends ModelViewerElement {
|
||||
static set dracoDecoderLocation(value: string) {
|
||||
CachingGLTFLoader.setDRACODecoderLocation(value);
|
||||
}
|
||||
|
||||
static get dracoDecoderLocation() {
|
||||
return CachingGLTFLoader.getDRACODecoderLocation();
|
||||
}
|
||||
|
||||
static set ktx2TranscoderLocation(value: string) {
|
||||
CachingGLTFLoader.setKTX2TranscoderLocation(value);
|
||||
}
|
||||
|
||||
static get ktx2TranscoderLocation() {
|
||||
return CachingGLTFLoader.getKTX2TranscoderLocation();
|
||||
}
|
||||
|
||||
static set meshoptDecoderLocation(value: string) {
|
||||
CachingGLTFLoader.setMeshoptDecoderLocation(value);
|
||||
}
|
||||
|
||||
static get meshoptDecoderLocation() {
|
||||
return CachingGLTFLoader.getMeshoptDecoderLocation();
|
||||
}
|
||||
|
||||
static set lottieLoaderLocation(value: string) {
|
||||
Renderer.singleton.textureUtils!.lottieLoaderUrl = value;
|
||||
}
|
||||
|
||||
static get lottieLoaderLocation() {
|
||||
return Renderer.singleton.textureUtils!.lottieLoaderUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* If provided, the callback will be passed each resource URL before a
|
||||
* request is sent. The callback may return the original URL, or a new URL
|
||||
* to override loading behavior. This behavior can be used to load assets
|
||||
* from .ZIP files, drag-and-drop APIs, and Data URIs.
|
||||
*/
|
||||
static mapURLs(callback: (url: string) => string) {
|
||||
Renderer.singleton.loader[$loader].manager.setURLModifier(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* A URL pointing to the image to use as a poster in scenarios where the
|
||||
* <model-viewer> is not ready to reveal a rendered model to the viewer.
|
||||
*/
|
||||
@property({type: String}) poster: string|null = null;
|
||||
|
||||
/**
|
||||
* An enumerable attribute describing under what conditions the
|
||||
* <model-viewer> should reveal a model to the viewer.
|
||||
*
|
||||
* The default value is "auto". The only supported alternative values is
|
||||
* "manual".
|
||||
*/
|
||||
@property({type: String})
|
||||
reveal: RevealAttributeValue = RevealStrategy.AUTO;
|
||||
|
||||
/**
|
||||
* An enumerable attribute describing under what conditions the
|
||||
* <model-viewer> should preload a model.
|
||||
*
|
||||
* The default value is "auto". The only supported alternative values are
|
||||
* "lazy" and "eager". Auto is equivalent to lazy, which loads the model
|
||||
* when it is near the viewport for reveal = "auto", and when interacted
|
||||
* with for reveal = "interaction". Eager loads the model immediately.
|
||||
*/
|
||||
@property({type: String})
|
||||
loading: LoadingAttributeValue = LoadingStrategy.AUTO;
|
||||
|
||||
/**
|
||||
* Dismisses the poster, causing the model to load and render if
|
||||
* necessary. This is currently effectively the same as interacting with
|
||||
* the poster via user input.
|
||||
*/
|
||||
dismissPoster() {
|
||||
if (this.loaded) {
|
||||
this[$hidePoster]();
|
||||
} else {
|
||||
this[$shouldDismissPoster] = true;
|
||||
this[$updateSource]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the poster, hiding the 3D model. If this is called after the 3D
|
||||
* model has been revealed, then it must be dismissed by a call to
|
||||
* dismissPoster().
|
||||
*/
|
||||
showPoster() {
|
||||
const posterContainerElement = this[$posterContainerElement];
|
||||
if (posterContainerElement.classList.contains('show')) {
|
||||
return;
|
||||
}
|
||||
posterContainerElement.classList.add('show');
|
||||
this[$userInputElement].classList.remove('show');
|
||||
|
||||
const defaultPosterElement = this[$defaultPosterElement];
|
||||
defaultPosterElement.removeAttribute('tabindex');
|
||||
defaultPosterElement.removeAttribute('aria-hidden');
|
||||
|
||||
const oldVisibility = this.modelIsVisible;
|
||||
this[$modelIsRevealed] = false;
|
||||
this[$announceModelVisibility](oldVisibility);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model's bounding box dimensions in meters, independent of
|
||||
* turntable rotation.
|
||||
*/
|
||||
getDimensions(): Vector3D {
|
||||
return toVector3D(this[$scene].size);
|
||||
}
|
||||
|
||||
getBoundingBoxCenter(): Vector3D {
|
||||
return toVector3D(this[$scene].boundingBox.getCenter(new Vector3()));
|
||||
}
|
||||
|
||||
protected[$modelIsRevealed] = false;
|
||||
|
||||
protected[$shouldDismissPoster] = false;
|
||||
|
||||
// TODO: Add this to the shadow root as part of this mixin's
|
||||
// implementation:
|
||||
protected[$posterContainerElement]: HTMLElement =
|
||||
this.shadowRoot!.querySelector('.slot.poster') as HTMLElement;
|
||||
|
||||
protected[$defaultPosterElement]: HTMLElement =
|
||||
this.shadowRoot!.querySelector('#default-poster') as HTMLElement;
|
||||
|
||||
protected[$defaultProgressBarElement]: HTMLElement =
|
||||
this.shadowRoot!.querySelector('#default-progress-bar > .bar') as
|
||||
HTMLElement;
|
||||
|
||||
protected[$ariaLabelCallToAction] =
|
||||
this[$defaultPosterElement].getAttribute('aria-label');
|
||||
|
||||
protected[$updateProgressBar] = throttle((progress: number) => {
|
||||
const parentNode = this[$defaultProgressBarElement].parentNode as Element;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this[$defaultProgressBarElement].style.transform =
|
||||
`scaleX(${progress})`;
|
||||
|
||||
if (progress === 0) {
|
||||
// NOTE(cdata): We remove and re-append the progress bar in this
|
||||
// condition so that the progress bar does not appear to
|
||||
// transition backwards from the right when we reset to 0 (or
|
||||
// otherwise <1) progress after having already reached 1 progress
|
||||
// previously.
|
||||
parentNode.removeChild(this[$defaultProgressBarElement]);
|
||||
parentNode.appendChild(this[$defaultProgressBarElement]);
|
||||
}
|
||||
|
||||
this[$defaultProgressBarElement].classList.toggle('hide', progress === 1.0);
|
||||
});
|
||||
}, PROGRESS_BAR_UPDATE_THRESHOLD);
|
||||
|
||||
constructor(...args: Array<any>) {
|
||||
super(...args);
|
||||
|
||||
const ModelViewerElement: ModelViewerGlobalConfig =
|
||||
(self as any).ModelViewerElement || {};
|
||||
|
||||
const dracoDecoderLocation = ModelViewerElement.dracoDecoderLocation ||
|
||||
DEFAULT_DRACO_DECODER_LOCATION;
|
||||
CachingGLTFLoader.setDRACODecoderLocation(dracoDecoderLocation);
|
||||
|
||||
const ktx2TranscoderLocation =
|
||||
ModelViewerElement.ktx2TranscoderLocation ||
|
||||
DEFAULT_KTX2_TRANSCODER_LOCATION;
|
||||
CachingGLTFLoader.setKTX2TranscoderLocation(ktx2TranscoderLocation);
|
||||
|
||||
if (ModelViewerElement.meshoptDecoderLocation) {
|
||||
CachingGLTFLoader.setMeshoptDecoderLocation(
|
||||
ModelViewerElement.meshoptDecoderLocation);
|
||||
}
|
||||
|
||||
const lottieLoaderLocation = ModelViewerElement.lottieLoaderLocation ||
|
||||
DEFAULT_LOTTIE_LOADER_LOCATION;
|
||||
Renderer.singleton.textureUtils!.lottieLoaderUrl = lottieLoaderLocation;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.loaded) {
|
||||
this.showPoster();
|
||||
}
|
||||
|
||||
this[$progressTracker].addEventListener('progress', this[$onProgress]);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this[$progressTracker].removeEventListener('progress', this[$onProgress]);
|
||||
}
|
||||
|
||||
async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('poster') && this.poster != null) {
|
||||
this[$defaultPosterElement].style.backgroundImage =
|
||||
`url(${this.poster})`;
|
||||
}
|
||||
|
||||
if (changedProperties.has('alt')) {
|
||||
this[$defaultPosterElement].setAttribute(
|
||||
'aria-label', this[$altDefaulted]);
|
||||
}
|
||||
|
||||
if (changedProperties.has('reveal') || changedProperties.has('loading')) {
|
||||
this[$updateSource]();
|
||||
}
|
||||
}
|
||||
|
||||
[$onProgress] = (event: Event) => {
|
||||
const progress = (event as any).detail.totalProgress;
|
||||
const reason = (event as any).detail.reason;
|
||||
|
||||
if (progress === 1.0) {
|
||||
this[$updateProgressBar].flush();
|
||||
if (this.loaded &&
|
||||
(this[$shouldDismissPoster] ||
|
||||
this.reveal === RevealStrategy.AUTO)) {
|
||||
this[$hidePoster]();
|
||||
}
|
||||
}
|
||||
|
||||
this[$updateProgressBar](progress);
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('progress', {detail: {totalProgress: progress, reason}}));
|
||||
};
|
||||
|
||||
[$shouldAttemptPreload](): boolean {
|
||||
return !!this.src &&
|
||||
(this[$shouldDismissPoster] ||
|
||||
this.loading === LoadingStrategy.EAGER ||
|
||||
(this.reveal === RevealStrategy.AUTO && this[$isElementInViewport]));
|
||||
}
|
||||
|
||||
[$hidePoster]() {
|
||||
this[$shouldDismissPoster] = false;
|
||||
const posterContainerElement = this[$posterContainerElement];
|
||||
if (!posterContainerElement.classList.contains('show')) {
|
||||
return;
|
||||
}
|
||||
posterContainerElement.classList.remove('show');
|
||||
this[$userInputElement].classList.add('show');
|
||||
|
||||
const oldVisibility = this.modelIsVisible;
|
||||
this[$modelIsRevealed] = true;
|
||||
this[$announceModelVisibility](oldVisibility);
|
||||
|
||||
const root = this.getRootNode();
|
||||
|
||||
// If the <model-viewer> is still focused, forward the focus to
|
||||
// the canvas that has just been revealed
|
||||
if (root && (root as Document | ShadowRoot).activeElement === this) {
|
||||
this[$userInputElement].focus();
|
||||
}
|
||||
|
||||
// Ensure that the poster is no longer focusable or visible to
|
||||
// screen readers
|
||||
const defaultPosterElement = this[$defaultPosterElement];
|
||||
defaultPosterElement.setAttribute('aria-hidden', 'true');
|
||||
defaultPosterElement.tabIndex = -1;
|
||||
this.dispatchEvent(new CustomEvent('poster-dismissed'));
|
||||
}
|
||||
|
||||
[$getModelIsVisible]() {
|
||||
return super[$getModelIsVisible]() && this[$modelIsRevealed];
|
||||
}
|
||||
}
|
||||
|
||||
return LoadingModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,300 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {CanvasTexture, RepeatWrapping, SRGBColorSpace, Texture, VideoTexture} from 'three';
|
||||
import {GLTFExporter, GLTFExporterOptions} from 'three/examples/jsm/exporters/GLTFExporter.js';
|
||||
|
||||
import ModelViewerElementBase, {$needsRender, $onModelLoad, $progressTracker, $renderer, $scene} from '../model-viewer-base.js';
|
||||
import {GLTF} from '../three-components/gltf-instance/gltf-defaulted.js';
|
||||
import {ModelViewerGLTFInstance} from '../three-components/gltf-instance/ModelViewerGLTFInstance.js';
|
||||
import GLTFExporterMaterialsVariantsExtension from '../three-components/gltf-instance/VariantMaterialExporterPlugin.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
|
||||
import {Image, PBRMetallicRoughness, Sampler, TextureInfo} from './scene-graph/api.js';
|
||||
import {Material} from './scene-graph/material.js';
|
||||
import {$availableVariants, $materialFromPoint, $prepareVariantsForExport, $switchVariant, Model} from './scene-graph/model.js';
|
||||
import {Texture as ModelViewerTexture} from './scene-graph/texture.js';
|
||||
|
||||
|
||||
|
||||
export const $currentGLTF = Symbol('currentGLTF');
|
||||
export const $originalGltfJson = Symbol('originalGltfJson');
|
||||
export const $model = Symbol('model');
|
||||
const $getOnUpdateMethod = Symbol('getOnUpdateMethod');
|
||||
const $buildTexture = Symbol('buildTexture');
|
||||
|
||||
interface SceneExportOptions {
|
||||
binary?: boolean, trs?: boolean, onlyVisible?: boolean,
|
||||
maxTextureSize?: number, includeCustomExtensions?: boolean,
|
||||
forceIndices?: boolean
|
||||
}
|
||||
|
||||
export interface SceneGraphInterface {
|
||||
readonly model?: Model;
|
||||
variantName: string|null;
|
||||
readonly availableVariants: string[];
|
||||
orientation: string;
|
||||
scale: string;
|
||||
readonly originalGltfJson: GLTF|null;
|
||||
exportScene(options?: SceneExportOptions): Promise<Blob>;
|
||||
createTexture(uri: string, type?: string): Promise<ModelViewerTexture|null>;
|
||||
createLottieTexture(uri: string, quality?: number):
|
||||
Promise<ModelViewerTexture|null>;
|
||||
createVideoTexture(uri: string): ModelViewerTexture;
|
||||
createCanvasTexture(): ModelViewerTexture;
|
||||
/**
|
||||
* Intersects a ray with the scene and returns a list of materials who's
|
||||
* objects were intersected.
|
||||
* @param pixelX X coordinate of the mouse.
|
||||
* @param pixelY Y coordinate of the mouse.
|
||||
* @returns a material, if no intersection is made then null is returned.
|
||||
*/
|
||||
materialFromPoint(pixelX: number, pixelY: number): Material|null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SceneGraphMixin manages exposes a model API in order to support operations on
|
||||
* the <model-viewer> scene graph.
|
||||
*/
|
||||
export const SceneGraphMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<SceneGraphInterface>&T => {
|
||||
class SceneGraphModelViewerElement extends ModelViewerElement {
|
||||
protected[$model]: Model|undefined = undefined;
|
||||
protected[$currentGLTF]: ModelViewerGLTFInstance|null = null;
|
||||
private[$originalGltfJson]: GLTF|null = null;
|
||||
|
||||
@property({type: String, attribute: 'variant-name'})
|
||||
variantName: string|null = null;
|
||||
|
||||
@property({type: String, attribute: 'orientation'})
|
||||
orientation: string = '0 0 0';
|
||||
|
||||
@property({type: String, attribute: 'scale'}) scale: string = '1 1 1';
|
||||
|
||||
// Scene-graph API:
|
||||
/** @export */
|
||||
get model() {
|
||||
return this[$model];
|
||||
}
|
||||
|
||||
get availableVariants() {
|
||||
return this.model ? this.model[$availableVariants]() : [] as string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep copy of the gltf JSON as loaded. It will not reflect
|
||||
* changes to the scene-graph, nor will editing it have any effect.
|
||||
*/
|
||||
get originalGltfJson() {
|
||||
return this[$originalGltfJson];
|
||||
}
|
||||
|
||||
/**
|
||||
* References to each element constructor. Supports instanceof checks; these
|
||||
* classes are not directly constructable.
|
||||
*/
|
||||
static Model: Constructor<Model>;
|
||||
static Material: Constructor<Material>;
|
||||
static PBRMetallicRoughness: Constructor<PBRMetallicRoughness>;
|
||||
static Sampler: Constructor<Sampler>;
|
||||
static TextureInfo: Constructor<TextureInfo>;
|
||||
static Texture: Constructor<Texture>;
|
||||
static Image: Constructor<Image>;
|
||||
|
||||
private[$getOnUpdateMethod]() {
|
||||
return () => {
|
||||
this[$needsRender]();
|
||||
};
|
||||
}
|
||||
|
||||
private[$buildTexture](texture: Texture): ModelViewerTexture {
|
||||
// Applies glTF default settings.
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.wrapS = RepeatWrapping;
|
||||
texture.wrapT = RepeatWrapping;
|
||||
return new ModelViewerTexture(this[$getOnUpdateMethod](), texture);
|
||||
}
|
||||
|
||||
async createTexture(uri: string, type: string = 'image/png'):
|
||||
Promise<ModelViewerTexture> {
|
||||
const {textureUtils} = this[$renderer];
|
||||
const texture = await textureUtils!.loadImage(uri, this.withCredentials);
|
||||
|
||||
texture.userData.mimeType = type;
|
||||
|
||||
return this[$buildTexture](texture);
|
||||
}
|
||||
|
||||
async createLottieTexture(uri: string, quality = 1):
|
||||
Promise<ModelViewerTexture> {
|
||||
const {textureUtils} = this[$renderer];
|
||||
const texture =
|
||||
await textureUtils!.loadLottie(uri, quality, this.withCredentials);
|
||||
|
||||
return this[$buildTexture](texture);
|
||||
}
|
||||
|
||||
createVideoTexture(uri: string): ModelViewerTexture {
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin =
|
||||
this.withCredentials ? 'use-credentials' : 'anonymous';
|
||||
video.src = uri;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.loop = true;
|
||||
video.play();
|
||||
const texture = new VideoTexture(video);
|
||||
|
||||
return this[$buildTexture](texture);
|
||||
}
|
||||
|
||||
createCanvasTexture(): ModelViewerTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
const texture = new CanvasTexture(canvas);
|
||||
|
||||
return this[$buildTexture](texture);
|
||||
}
|
||||
|
||||
async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('variantName')) {
|
||||
const updateVariantProgress =
|
||||
this[$progressTracker].beginActivity('variant-update');
|
||||
updateVariantProgress(0.1);
|
||||
const model = this[$model];
|
||||
const {variantName} = this;
|
||||
|
||||
if (model != null) {
|
||||
await model[$switchVariant](variantName!);
|
||||
this[$needsRender]();
|
||||
this.dispatchEvent(new CustomEvent('variant-applied'));
|
||||
}
|
||||
updateVariantProgress(1.0);
|
||||
}
|
||||
|
||||
if (changedProperties.has('orientation') ||
|
||||
changedProperties.has('scale')) {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
const scene = this[$scene];
|
||||
scene.applyTransform();
|
||||
scene.updateBoundingBox();
|
||||
scene.updateShadow();
|
||||
this[$renderer].arRenderer.onUpdateScene();
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
super[$onModelLoad]();
|
||||
|
||||
const {currentGLTF} = this[$scene];
|
||||
|
||||
if (currentGLTF != null) {
|
||||
const {correlatedSceneGraph} = currentGLTF;
|
||||
|
||||
if (correlatedSceneGraph != null &&
|
||||
currentGLTF !== this[$currentGLTF]) {
|
||||
this[$model] =
|
||||
new Model(correlatedSceneGraph, this[$getOnUpdateMethod]());
|
||||
this[$originalGltfJson] =
|
||||
JSON.parse(JSON.stringify(correlatedSceneGraph.gltf));
|
||||
}
|
||||
|
||||
// KHR_materials_variants extension spec:
|
||||
// https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_materials_variants
|
||||
|
||||
if ('variants' in currentGLTF.userData) {
|
||||
this.requestUpdate('variantName');
|
||||
}
|
||||
}
|
||||
|
||||
this[$currentGLTF] = currentGLTF;
|
||||
}
|
||||
|
||||
/** @export */
|
||||
async exportScene(options?: SceneExportOptions): Promise<Blob> {
|
||||
const scene = this[$scene];
|
||||
return new Promise<Blob>(async (resolve, reject) => {
|
||||
// Defaults
|
||||
const opts = {
|
||||
binary: true,
|
||||
onlyVisible: true,
|
||||
maxTextureSize: Infinity,
|
||||
includeCustomExtensions: false,
|
||||
forceIndices: false
|
||||
} as GLTFExporterOptions;
|
||||
|
||||
Object.assign(opts, options);
|
||||
// Not configurable
|
||||
opts.animations = scene.animations;
|
||||
opts.truncateDrawRange = true;
|
||||
|
||||
const shadow = scene.shadow;
|
||||
let visible = false;
|
||||
// Remove shadow from export
|
||||
if (shadow != null) {
|
||||
visible = shadow.visible;
|
||||
shadow.visible = false;
|
||||
}
|
||||
|
||||
await this[$model]![$prepareVariantsForExport]();
|
||||
|
||||
const exporter =
|
||||
(new GLTFExporter() as any)
|
||||
.register(
|
||||
(writer: any) =>
|
||||
new GLTFExporterMaterialsVariantsExtension(writer));
|
||||
exporter.parse(
|
||||
scene.model,
|
||||
(gltf: object) => {
|
||||
return resolve(new Blob(
|
||||
[opts.binary ? gltf as Blob : JSON.stringify(gltf)], {
|
||||
type: opts.binary ? 'application/octet-stream' :
|
||||
'application/json'
|
||||
}));
|
||||
},
|
||||
() => {
|
||||
return reject('glTF export failed');
|
||||
},
|
||||
opts);
|
||||
|
||||
if (shadow != null) {
|
||||
shadow.visible = visible;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
materialFromPoint(pixelX: number, pixelY: number): Material|null {
|
||||
const model = this[$model];
|
||||
if (model == null) {
|
||||
return null;
|
||||
}
|
||||
const scene = this[$scene];
|
||||
const ndcCoords = scene.getNDC(pixelX, pixelY);
|
||||
const hit = scene.hitFromPoint(ndcCoords);
|
||||
if (hit == null || hit.face == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return model[$materialFromPoint](hit);
|
||||
}
|
||||
}
|
||||
|
||||
return SceneGraphModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,443 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {AlphaMode, MagFilter, MinFilter, WrapMode} from '../../three-components/gltf-instance/gltf-2.0.js';
|
||||
|
||||
|
||||
/**
|
||||
* All constructs in a 3DOM scene graph have a corresponding string name.
|
||||
* This is similar in spirit to the concept of a "tag name" in HTML, and exists
|
||||
* in support of looking up 3DOM elements by type.
|
||||
*/
|
||||
export declare interface ThreeDOMElementMap {
|
||||
'model': Model;
|
||||
'material': Material;
|
||||
'pbr-metallic-roughness': PBRMetallicRoughness;
|
||||
'sampler': Sampler;
|
||||
'image': Image;
|
||||
'texture': Texture;
|
||||
'texture-info': TextureInfo;
|
||||
}
|
||||
|
||||
/** A 2D Cartesian coordinate */
|
||||
export interface Vector2DInterface {
|
||||
u: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Model is the root element of a 3DOM scene graph. It gives scripts access
|
||||
* to the sub-elements found without the graph.
|
||||
*/
|
||||
export declare interface Model {
|
||||
/**
|
||||
* An ordered set of unique Materials found in this model. The Materials
|
||||
* correspond to the listing of materials in the glTF, with the possible
|
||||
* addition of a default material at the end.
|
||||
*/
|
||||
readonly materials: Readonly<Material[]>;
|
||||
|
||||
/**
|
||||
* Gets a material(s) by name.
|
||||
* @param name the name of the material to return.
|
||||
* @returns the first material to whose name matches `name`
|
||||
*/
|
||||
getMaterialByName(name: string): Material|null;
|
||||
|
||||
/**
|
||||
* Creates a new material variant from an existing material.
|
||||
* @param originalMaterialIndex index of the material to clone the variant
|
||||
* from.
|
||||
* @param materialName the name of the new material
|
||||
* @param variantName the name of the variant
|
||||
* @param activateVariant activates this material variant, i.e. the variant
|
||||
* material is rendered, not the existing material.
|
||||
* @returns returns a clone of the original material, returns `null` if the
|
||||
* material instance for this variant already exists.
|
||||
*/
|
||||
createMaterialInstanceForVariant(
|
||||
originalMaterialIndex: number, newMaterialName: string,
|
||||
variantName: string, activateVariant: boolean): Material|null;
|
||||
|
||||
/**
|
||||
* Adds a variant name to the model.
|
||||
* @param variantName
|
||||
*/
|
||||
createVariant(variantName: string): void;
|
||||
|
||||
/**
|
||||
* Adds an existing material to a variant name.
|
||||
* @param materialIndex
|
||||
* @param targetVariantName
|
||||
*/
|
||||
setMaterialToVariant(materialIndex: number, targetVariantName: string): void;
|
||||
|
||||
/**
|
||||
* Removes the variant name from the model.
|
||||
* @param variantName the variant to remove.
|
||||
*/
|
||||
deleteVariant(variantName: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Material gives the script access to modify a single, unique material found
|
||||
* in a model's scene graph.
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-material
|
||||
*/
|
||||
export declare interface Material {
|
||||
/**
|
||||
* The name of the material, if any.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
readonly normalTexture: TextureInfo|null;
|
||||
readonly occlusionTexture: TextureInfo|null;
|
||||
readonly emissiveTexture: TextureInfo|null;
|
||||
|
||||
readonly emissiveFactor: Readonly<RGB>;
|
||||
setEmissiveFactor(rgb: RGB|string): void;
|
||||
setAlphaCutoff(cutoff: number): void;
|
||||
getAlphaCutoff(): number;
|
||||
setDoubleSided(doubleSided: boolean): void;
|
||||
getDoubleSided(): boolean;
|
||||
setAlphaMode(alphaMode: AlphaMode): void;
|
||||
getAlphaMode(): AlphaMode;
|
||||
|
||||
/**
|
||||
* PBR Next properties.
|
||||
*/
|
||||
readonly emissiveStrength: number;
|
||||
readonly clearcoatFactor: number;
|
||||
readonly clearcoatRoughnessFactor: number;
|
||||
readonly clearcoatTexture: TextureInfo;
|
||||
readonly clearcoatRoughnessTexture: TextureInfo;
|
||||
readonly clearcoatNormalTexture: TextureInfo;
|
||||
readonly clearcoatNormalScale: number;
|
||||
readonly ior: number;
|
||||
readonly sheenColorFactor: Readonly<RGB>;
|
||||
readonly sheenColorTexture: TextureInfo;
|
||||
readonly sheenRoughnessFactor: number;
|
||||
readonly sheenRoughnessTexture: TextureInfo;
|
||||
readonly transmissionFactor: number;
|
||||
readonly transmissionTexture: TextureInfo;
|
||||
readonly thicknessFactor: number;
|
||||
readonly thicknessTexture: TextureInfo;
|
||||
readonly attenuationDistance: number;
|
||||
readonly attenuationColor: Readonly<RGB>;
|
||||
readonly specularFactor: number;
|
||||
readonly specularTexture: TextureInfo;
|
||||
readonly specularColorFactor: Readonly<RGB>;
|
||||
readonly specularColorTexture: TextureInfo;
|
||||
readonly iridescenceFactor: number;
|
||||
readonly iridescenceTexture: TextureInfo;
|
||||
readonly iridescenceIor: number;
|
||||
readonly iridescenceThicknessMinimum: number;
|
||||
readonly iridescenceThicknessMaximum: number;
|
||||
readonly iridescenceThicknessTexture: TextureInfo;
|
||||
readonly anisotropyStrength: number;
|
||||
readonly anisotropyRotation: number;
|
||||
readonly anisotropyTexture: TextureInfo;
|
||||
|
||||
setEmissiveStrength(emissiveStrength: number): void;
|
||||
setClearcoatFactor(clearcoatFactor: number): void;
|
||||
setClearcoatRoughnessFactor(clearcoatRoughnessFactor: number): void;
|
||||
setClearcoatNormalScale(clearcoatNormalScale: number): void;
|
||||
setIor(ior: number): void;
|
||||
setSheenColorFactor(rgb: RGB|string): void;
|
||||
setSheenRoughnessFactor(roughness: number): void;
|
||||
setTransmissionFactor(transmission: number): void;
|
||||
setThicknessFactor(thickness: number): void;
|
||||
setAttenuationDistance(attenuationDistance: number): void;
|
||||
setAttenuationColor(rgb: RGB|string): void;
|
||||
setSpecularFactor(specularFactor: number): void;
|
||||
setSpecularColorFactor(rgb: RGB|string): void;
|
||||
setIridescenceFactor(iridescence: number): void;
|
||||
setIridescenceIor(ior: number): void;
|
||||
setIridescenceThicknessMinimum(thicknessMin: number): void;
|
||||
setIridescenceThicknessMaximum(thicknessMax: number): void;
|
||||
setAnisotropyStrength(strength: number): void;
|
||||
setAnisotropyRotation(rotation: number): void;
|
||||
|
||||
/**
|
||||
* The PBRMetallicRoughness configuration of the material.
|
||||
*/
|
||||
readonly pbrMetallicRoughness: PBRMetallicRoughness;
|
||||
|
||||
/**
|
||||
* Asynchronously loads the underlying material resource if it's currently
|
||||
* unloaded, otherwise the method is a noop.
|
||||
*/
|
||||
ensureLoaded(): void;
|
||||
|
||||
/**
|
||||
* Returns true if the material participates in the variant.
|
||||
* @param name the variant name.
|
||||
*/
|
||||
hasVariant(name: string): boolean;
|
||||
|
||||
/**
|
||||
* Returns true if the material is loaded.
|
||||
*/
|
||||
readonly isLoaded: boolean;
|
||||
|
||||
/**
|
||||
* Returns true if the material is participating in scene renders.
|
||||
*/
|
||||
readonly isActive: boolean;
|
||||
|
||||
/**
|
||||
* Returns the glTF index of this material.
|
||||
*/
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PBRMetallicRoughness encodes the PBR properties of a material
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-pbrmetallicroughness
|
||||
*/
|
||||
export declare interface PBRMetallicRoughness {
|
||||
/**
|
||||
* The base color factor of the material, represented as RGBA values
|
||||
*/
|
||||
readonly baseColorFactor: Readonly<RGBA>;
|
||||
|
||||
/**
|
||||
* Metalness factor of the material, represented as number between 0 and 1
|
||||
*/
|
||||
readonly metallicFactor: number;
|
||||
|
||||
/**
|
||||
* Roughness factor of the material, represented as number between 0 and 1
|
||||
*/
|
||||
readonly roughnessFactor: number;
|
||||
|
||||
/**
|
||||
* A texture reference, associating an image with color information and
|
||||
* a sampler for describing base color factor for a UV coordinate space.
|
||||
*/
|
||||
readonly baseColorTexture: TextureInfo|null;
|
||||
|
||||
/**
|
||||
* A texture reference, associating an image with color information and
|
||||
* a sampler for describing metalness (B channel) and roughness (G channel)
|
||||
* for a UV coordinate space.
|
||||
*/
|
||||
readonly metallicRoughnessTexture: TextureInfo|null;
|
||||
|
||||
/**
|
||||
* Changes the base color factor of the material to the given value.
|
||||
*/
|
||||
setBaseColorFactor(rgba: RGBA|string): void;
|
||||
|
||||
/**
|
||||
* Changes the metalness factor of the material to the given value.
|
||||
*/
|
||||
setMetallicFactor(value: number): void;
|
||||
|
||||
/**
|
||||
* Changes the roughness factor of the material to the given value.
|
||||
*/
|
||||
setRoughnessFactor(value: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A TextureInfo is a pointer to a specific Texture in use on a Material
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-textureinfo
|
||||
*/
|
||||
export declare interface TextureInfo {
|
||||
/**
|
||||
* The Texture being referenced by this TextureInfo.
|
||||
*/
|
||||
readonly texture: Texture|null;
|
||||
|
||||
/**
|
||||
* Sets the texture, or removes it if argument is null. Note you cannot build
|
||||
* your own Texture object, but must either use one from another TextureInfo,
|
||||
* or create one with the createTexture method.
|
||||
*/
|
||||
setTexture(texture: Texture|null): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Texture pairs an Image and a Sampler for use in a Material
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-texture
|
||||
*/
|
||||
export declare interface Texture {
|
||||
/**
|
||||
* The name of the texture, if any.
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* The Sampler for this Texture
|
||||
*/
|
||||
readonly sampler: Sampler;
|
||||
|
||||
/**
|
||||
* The source Image for this Texture
|
||||
*/
|
||||
readonly source: Image;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sampler describes how to filter and wrap textures
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-sampler
|
||||
*/
|
||||
export declare interface Sampler {
|
||||
/**
|
||||
* The name of the sampler, if any.
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplerminfilter
|
||||
*/
|
||||
readonly minFilter: MinFilter;
|
||||
|
||||
/**
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplermagfilter
|
||||
*/
|
||||
readonly magFilter: MagFilter;
|
||||
|
||||
/**
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplerwraps
|
||||
*/
|
||||
readonly wrapS: WrapMode;
|
||||
|
||||
/**
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplerwrapt
|
||||
*/
|
||||
readonly wrapT: WrapMode;
|
||||
|
||||
/**
|
||||
* The texture rotation in radians.
|
||||
*/
|
||||
readonly rotation: number|null;
|
||||
|
||||
/**
|
||||
* The texture scale.
|
||||
*/
|
||||
readonly scale: Vector2DInterface|null;
|
||||
|
||||
/**
|
||||
* The texture offset.
|
||||
*/
|
||||
readonly offset: Vector2DInterface|null;
|
||||
|
||||
/**
|
||||
* Configure the minFilter value of the Sampler.
|
||||
*/
|
||||
setMinFilter(filter: MinFilter): void;
|
||||
|
||||
/**
|
||||
* Configure the magFilter value of the Sampler.
|
||||
*/
|
||||
setMagFilter(filter: MagFilter): void;
|
||||
|
||||
/**
|
||||
* Configure the S (U) wrap mode of the Sampler.
|
||||
*/
|
||||
setWrapS(mode: WrapMode): void;
|
||||
|
||||
/**
|
||||
* Configure the T (V) wrap mode of the Sampler.
|
||||
*/
|
||||
setWrapT(mode: WrapMode): void;
|
||||
|
||||
/**
|
||||
* Sets the texture rotation, or resets it to zero if argument is null.
|
||||
* Rotation is in radians, positive for counter-clockwise.
|
||||
*/
|
||||
setRotation(rotation: number|null): void;
|
||||
|
||||
/**
|
||||
* Sets the texture scale, or resets it to (1, 1) if argument is null.
|
||||
* As the scale value increases, the repetition of the texture will increase.
|
||||
*/
|
||||
setScale(scale: Vector2DInterface|null): void;
|
||||
|
||||
/**
|
||||
* Sets the texture offset, or resets it to (0, 0) if argument is null.
|
||||
*/
|
||||
setOffset(offset: Vector2DInterface|null): void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* An Image represents an embedded or external image used to provide texture
|
||||
* color data.
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-image
|
||||
*/
|
||||
export declare interface Image {
|
||||
/**
|
||||
* The name of the image, if any.
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* The type is 'external' if the image has a configured URI. Otherwise, it is
|
||||
* considered to be 'embedded'. Note: this distinction is only implied by the
|
||||
* glTF spec, and is made explicit here for convenience.
|
||||
*/
|
||||
readonly type: 'embedded'|'external';
|
||||
|
||||
/**
|
||||
* The URI of the image, if it is external.
|
||||
*/
|
||||
readonly uri?: string;
|
||||
|
||||
/**
|
||||
* The bufferView of the image, if it is embedded.
|
||||
*/
|
||||
readonly bufferView?: number;
|
||||
|
||||
/**
|
||||
* The backing HTML element, if this is a video or canvas texture.
|
||||
*/
|
||||
readonly element?: HTMLVideoElement|HTMLCanvasElement;
|
||||
|
||||
/**
|
||||
* The Lottie animation object, if this is a Lottie texture. You may wish to
|
||||
* do image.animation as import('lottie-web').AnimationItem; to get its type
|
||||
* info.
|
||||
*/
|
||||
readonly animation?: any;
|
||||
|
||||
/**
|
||||
* A method to create an object URL of this image at the desired
|
||||
* resolution. Especially useful for KTX2 textures which are GPU compressed,
|
||||
* and so are unreadable on the CPU without a method like this.
|
||||
*/
|
||||
createThumbnail(width: number, height: number): Promise<string>;
|
||||
|
||||
/**
|
||||
* Only applies to canvas textures. Call when the content of the canvas has
|
||||
* been updated and should be reflected in the model.
|
||||
*/
|
||||
update(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An RGBA-encoded color, with channels represented as floating point values
|
||||
* from [0,1].
|
||||
*/
|
||||
export declare type RGBA = [number, number, number, number];
|
||||
export declare type RGB = [number, number, number];
|
||||
@@ -0,0 +1,140 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, Texture as ThreeTexture, WebGLRenderTarget} from 'three';
|
||||
|
||||
import {blobCanvas} from '../../model-viewer-base.js';
|
||||
import {Renderer} from '../../three-components/Renderer.js';
|
||||
|
||||
import {Image as ImageInterface} from './api.js';
|
||||
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
|
||||
const quadMaterial = new MeshBasicMaterial();
|
||||
const quad = new PlaneGeometry(2, 2);
|
||||
let adhocNum = 0;
|
||||
|
||||
export const $threeTexture = Symbol('threeTexture');
|
||||
export const $threeTextures = Symbol('threeTextures');
|
||||
export const $applyTexture = Symbol('applyTexture');
|
||||
|
||||
/**
|
||||
* Image facade implementation for Three.js textures
|
||||
*/
|
||||
export class Image extends ThreeDOMElement implements ImageInterface {
|
||||
get[$threeTexture]() {
|
||||
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
|
||||
}
|
||||
|
||||
get[$threeTextures](): Set<ThreeTexture> {
|
||||
return this[$correlatedObjects] as Set<ThreeTexture>;
|
||||
}
|
||||
|
||||
constructor(onUpdate: () => void, texture: ThreeTexture) {
|
||||
super(onUpdate, new Set<ThreeTexture>(texture ? [texture] : []));
|
||||
|
||||
if (!this[$threeTexture].image.src) {
|
||||
this[$threeTexture].image.src =
|
||||
texture.name ? texture.name : 'adhoc_image' + adhocNum++;
|
||||
}
|
||||
if (!this[$threeTexture].image.name) {
|
||||
this[$threeTexture].image.name =
|
||||
(texture && texture.image && texture.image.src) ?
|
||||
texture.image.src.split('/').pop() :
|
||||
'adhoc_image';
|
||||
}
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this[$threeTexture].image.name || '';
|
||||
}
|
||||
|
||||
get uri(): string|undefined {
|
||||
return this[$threeTexture].image.src;
|
||||
}
|
||||
|
||||
get bufferView(): number|undefined {
|
||||
return this[$threeTexture].image.bufferView;
|
||||
}
|
||||
|
||||
get element(): HTMLVideoElement|HTMLCanvasElement|undefined {
|
||||
const texture = this[$threeTexture] as any;
|
||||
if (texture && (texture.isCanvasTexture || texture.isVideoTexture)) {
|
||||
return texture.image;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
get animation(): any|undefined {
|
||||
const texture = this[$threeTexture] as any;
|
||||
if (texture && texture.isCanvasTexture && texture.animation) {
|
||||
return texture.animation;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
get type(): 'embedded'|'external' {
|
||||
return this.uri != null ? 'external' : 'embedded';
|
||||
}
|
||||
|
||||
set name(name: string) {
|
||||
for (const texture of this[$threeTextures]) {
|
||||
texture.image.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
const texture = this[$threeTexture] as any;
|
||||
// Applies to non-Lottie canvas textures only
|
||||
if (texture && texture.isCanvasTexture && !texture.animation) {
|
||||
this[$threeTexture].needsUpdate = true;
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
|
||||
async createThumbnail(width: number, height: number): Promise<string> {
|
||||
const scene = new Scene();
|
||||
quadMaterial.map = this[$threeTexture];
|
||||
const mesh = new Mesh(quad, quadMaterial);
|
||||
scene.add(mesh);
|
||||
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const {threeRenderer} = Renderer.singleton;
|
||||
const renderTarget = new WebGLRenderTarget(width, height);
|
||||
threeRenderer.setRenderTarget(renderTarget);
|
||||
threeRenderer.render(scene, camera);
|
||||
threeRenderer.setRenderTarget(null);
|
||||
|
||||
const buffer = new Uint8Array(width * height * 4);
|
||||
threeRenderer.readRenderTargetPixels(
|
||||
renderTarget, 0, 0, width, height, buffer);
|
||||
|
||||
blobCanvas.width = width;
|
||||
blobCanvas.height = height;
|
||||
const blobContext = blobCanvas.getContext('2d')!;
|
||||
const imageData = blobContext.createImageData(width, height);
|
||||
imageData.data.set(buffer);
|
||||
blobContext.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
blobCanvas.toBlob(blob => {
|
||||
if (!blob) {
|
||||
return reject('Failed to capture thumbnail.');
|
||||
}
|
||||
resolve(URL.createObjectURL(blob));
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Color, ColorRepresentation, DoubleSide, FrontSide, MeshPhysicalMaterial, Vector2} from 'three';
|
||||
|
||||
import {AlphaMode, RGB} from '../../three-components/gltf-instance/gltf-2.0.js';
|
||||
|
||||
import {Material as MaterialInterface} from './api.js';
|
||||
import {LazyLoader, VariantData} from './model.js';
|
||||
import {PBRMetallicRoughness} from './pbr-metallic-roughness.js';
|
||||
import {TextureInfo, TextureUsage} from './texture-info.js';
|
||||
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
|
||||
|
||||
const $pbrMetallicRoughness = Symbol('pbrMetallicRoughness');
|
||||
const $normalTexture = Symbol('normalTexture');
|
||||
const $occlusionTexture = Symbol('occlusionTexture');
|
||||
const $emissiveTexture = Symbol('emissiveTexture');
|
||||
const $backingThreeMaterial = Symbol('backingThreeMaterial');
|
||||
const $applyAlphaCutoff = Symbol('applyAlphaCutoff');
|
||||
const $getAlphaMode = Symbol('getAlphaMode');
|
||||
export const $lazyLoadGLTFInfo = Symbol('lazyLoadGLTFInfo');
|
||||
const $initialize = Symbol('initialize');
|
||||
export const $getLoadedMaterial = Symbol('getLoadedMaterial');
|
||||
export const $ensureMaterialIsLoaded = Symbol('ensureMaterialIsLoaded');
|
||||
export const $gltfIndex = Symbol('gltfIndex');
|
||||
export const $setActive = Symbol('setActive');
|
||||
export const $variantIndices = Symbol('variantIndices');
|
||||
const $isActive = Symbol('isActive');
|
||||
const $modelVariants = Symbol('modelVariants');
|
||||
const $name = Symbol('name');
|
||||
const $pbrTextures = Symbol('pbrTextures');
|
||||
|
||||
/**
|
||||
* Material facade implementation for Three.js materials
|
||||
*/
|
||||
export class Material extends ThreeDOMElement implements MaterialInterface {
|
||||
private[$pbrMetallicRoughness]!: PBRMetallicRoughness;
|
||||
private[$normalTexture]!: TextureInfo;
|
||||
private[$occlusionTexture]!: TextureInfo;
|
||||
private[$emissiveTexture]!: TextureInfo;
|
||||
private[$lazyLoadGLTFInfo]?: LazyLoader;
|
||||
private[$gltfIndex]: number;
|
||||
private[$isActive]: boolean;
|
||||
public[$variantIndices] = new Set<number>();
|
||||
private[$name]?: string;
|
||||
readonly[$modelVariants]: Map<string, VariantData>;
|
||||
private[$pbrTextures] = new Map<TextureUsage, TextureInfo>();
|
||||
|
||||
get[$backingThreeMaterial](): MeshPhysicalMaterial {
|
||||
return (this[$correlatedObjects] as Set<MeshPhysicalMaterial>)
|
||||
.values()
|
||||
.next()
|
||||
.value!;
|
||||
}
|
||||
|
||||
constructor(
|
||||
onUpdate: () => void,
|
||||
gltfIndex: number,
|
||||
isActive: boolean,
|
||||
modelVariants: Map<string, VariantData>,
|
||||
correlatedMaterials: Set<MeshPhysicalMaterial>,
|
||||
name: string|undefined,
|
||||
lazyLoadInfo: LazyLoader|undefined = undefined,
|
||||
) {
|
||||
super(onUpdate, correlatedMaterials);
|
||||
this[$gltfIndex] = gltfIndex;
|
||||
this[$isActive] = isActive;
|
||||
this[$modelVariants] = modelVariants;
|
||||
this[$name] = name;
|
||||
|
||||
if (lazyLoadInfo == null) {
|
||||
this[$initialize]();
|
||||
} else {
|
||||
this[$lazyLoadGLTFInfo] = lazyLoadInfo;
|
||||
}
|
||||
}
|
||||
|
||||
private[$initialize](): void {
|
||||
const onUpdate = this[$onUpdate] as () => void;
|
||||
const correlatedMaterials =
|
||||
this[$correlatedObjects] as Set<MeshPhysicalMaterial>;
|
||||
|
||||
this[$pbrMetallicRoughness] =
|
||||
new PBRMetallicRoughness(onUpdate, correlatedMaterials);
|
||||
|
||||
const {normalMap, aoMap, emissiveMap} =
|
||||
correlatedMaterials.values().next().value!;
|
||||
|
||||
this[$normalTexture] = new TextureInfo(
|
||||
onUpdate,
|
||||
TextureUsage.Normal,
|
||||
normalMap,
|
||||
correlatedMaterials,
|
||||
);
|
||||
|
||||
this[$occlusionTexture] = new TextureInfo(
|
||||
onUpdate,
|
||||
TextureUsage.Occlusion,
|
||||
aoMap,
|
||||
correlatedMaterials,
|
||||
);
|
||||
|
||||
this[$emissiveTexture] = new TextureInfo(
|
||||
onUpdate,
|
||||
TextureUsage.Emissive,
|
||||
emissiveMap,
|
||||
correlatedMaterials,
|
||||
);
|
||||
|
||||
const createTextureInfo = (usage: TextureUsage) => {
|
||||
this[$pbrTextures].set(
|
||||
usage,
|
||||
new TextureInfo(
|
||||
onUpdate,
|
||||
usage,
|
||||
null,
|
||||
correlatedMaterials,
|
||||
));
|
||||
};
|
||||
|
||||
createTextureInfo(TextureUsage.Clearcoat);
|
||||
createTextureInfo(TextureUsage.ClearcoatRoughness);
|
||||
createTextureInfo(TextureUsage.ClearcoatNormal);
|
||||
createTextureInfo(TextureUsage.SheenColor);
|
||||
createTextureInfo(TextureUsage.SheenRoughness);
|
||||
createTextureInfo(TextureUsage.Transmission);
|
||||
createTextureInfo(TextureUsage.Thickness);
|
||||
createTextureInfo(TextureUsage.Specular);
|
||||
createTextureInfo(TextureUsage.SpecularColor);
|
||||
createTextureInfo(TextureUsage.Iridescence);
|
||||
createTextureInfo(TextureUsage.IridescenceThickness);
|
||||
createTextureInfo(TextureUsage.Anisotropy);
|
||||
}
|
||||
|
||||
async[$getLoadedMaterial](): Promise<MeshPhysicalMaterial|null> {
|
||||
if (this[$lazyLoadGLTFInfo] != null) {
|
||||
const material = await this[$lazyLoadGLTFInfo]!.doLazyLoad();
|
||||
|
||||
this[$initialize]();
|
||||
// Releases lazy load info.
|
||||
this[$lazyLoadGLTFInfo] = undefined;
|
||||
// Redefines the method as a noop method.
|
||||
this.ensureLoaded = async () => {};
|
||||
return material as MeshPhysicalMaterial;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private colorFromRgb(rgb: RGB|string): Color {
|
||||
const color = new Color();
|
||||
if (rgb instanceof Array) {
|
||||
color.fromArray(rgb);
|
||||
} else {
|
||||
color.set(rgb as ColorRepresentation);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
[$ensureMaterialIsLoaded]() {
|
||||
if (this[$lazyLoadGLTFInfo] == null) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Material "${this.name}" has not been loaded, call 'await
|
||||
myMaterial.ensureLoaded()' before using an unloaded material.`);
|
||||
}
|
||||
|
||||
async ensureLoaded() {
|
||||
await this[$getLoadedMaterial]();
|
||||
}
|
||||
|
||||
get isLoaded() {
|
||||
return this[$lazyLoadGLTFInfo] == null;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this[$isActive];
|
||||
}
|
||||
|
||||
[$setActive](isActive: boolean) {
|
||||
this[$isActive] = isActive;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this[$name] || '';
|
||||
}
|
||||
|
||||
set name(name: string) {
|
||||
this[$name] = name;
|
||||
if (this[$correlatedObjects] != null) {
|
||||
for (const threeMaterial of this[$correlatedObjects]!) {
|
||||
threeMaterial.name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get pbrMetallicRoughness(): PBRMetallicRoughness {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrMetallicRoughness];
|
||||
}
|
||||
|
||||
get normalTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$normalTexture];
|
||||
}
|
||||
|
||||
get occlusionTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$occlusionTexture];
|
||||
}
|
||||
|
||||
get emissiveTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$emissiveTexture];
|
||||
}
|
||||
|
||||
get emissiveFactor(): RGB {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial].emissive.toArray() as RGB);
|
||||
}
|
||||
|
||||
get index(): number {
|
||||
return this[$gltfIndex];
|
||||
}
|
||||
|
||||
hasVariant(name: string): boolean {
|
||||
const variantData = this[$modelVariants].get(name);
|
||||
return variantData != null && this[$variantIndices].has(variantData.index);
|
||||
}
|
||||
|
||||
setEmissiveFactor(rgb: RGB|string) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const color = this.colorFromRgb(rgb);
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.emissive.set(color);
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
[$getAlphaMode](): string {
|
||||
// Follows implementation of GLTFExporter from three.js
|
||||
if (this[$backingThreeMaterial].transparent) {
|
||||
return 'BLEND';
|
||||
} else {
|
||||
if (this[$backingThreeMaterial].alphaTest > 0.0) {
|
||||
return 'MASK';
|
||||
}
|
||||
}
|
||||
return 'OPAQUE';
|
||||
}
|
||||
|
||||
[$applyAlphaCutoff]() {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
if (this[$getAlphaMode]() === 'MASK') {
|
||||
if (material.alphaTest == undefined) {
|
||||
material.alphaTest = 0.5;
|
||||
}
|
||||
} else {
|
||||
(material.alphaTest as number | undefined) = undefined;
|
||||
}
|
||||
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
setAlphaCutoff(cutoff: number): void {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.alphaTest = cutoff;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
// Set AlphaCutoff to undefined if AlphaMode is not MASK.
|
||||
this[$applyAlphaCutoff]();
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
getAlphaCutoff(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].alphaTest;
|
||||
}
|
||||
|
||||
setDoubleSided(doubleSided: boolean): void {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
// When double-sided is disabled gltf spec dictates that Back-Face culling
|
||||
// must be disabled, in three.js parlance that would mean FrontSide
|
||||
// rendering only.
|
||||
// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#double-sided
|
||||
material.side = doubleSided ? DoubleSide : FrontSide;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
getDoubleSided(): boolean {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].side == DoubleSide;
|
||||
}
|
||||
|
||||
setAlphaMode(alphaMode: AlphaMode): void {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const enableTransparency =
|
||||
(material: MeshPhysicalMaterial, enabled: boolean): void => {
|
||||
material.transparent = enabled;
|
||||
material.depthWrite = !enabled;
|
||||
};
|
||||
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
enableTransparency(material, alphaMode === 'BLEND');
|
||||
if (alphaMode === 'MASK') {
|
||||
material.alphaTest = 0.5;
|
||||
} else {
|
||||
(material.alphaTest as number | undefined) = undefined;
|
||||
}
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
getAlphaMode(): AlphaMode {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$getAlphaMode]() as AlphaMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* PBR Next properties.
|
||||
*/
|
||||
|
||||
// KHR_materials_emissive_strength
|
||||
get emissiveStrength(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].emissiveIntensity;
|
||||
}
|
||||
|
||||
setEmissiveStrength(emissiveStrength: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.emissiveIntensity = emissiveStrength;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_clearcoat
|
||||
get clearcoatFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].clearcoat;
|
||||
}
|
||||
|
||||
get clearcoatRoughnessFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].clearcoatRoughness;
|
||||
}
|
||||
|
||||
get clearcoatTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Clearcoat)!;
|
||||
}
|
||||
|
||||
get clearcoatRoughnessTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.ClearcoatRoughness)!;
|
||||
}
|
||||
|
||||
get clearcoatNormalTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.ClearcoatNormal)!;
|
||||
}
|
||||
|
||||
get clearcoatNormalScale(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].clearcoatNormalScale.x;
|
||||
}
|
||||
|
||||
setClearcoatFactor(clearcoatFactor: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.clearcoat = clearcoatFactor;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setClearcoatRoughnessFactor(clearcoatRoughnessFactor: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.clearcoatRoughness = clearcoatRoughnessFactor;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setClearcoatNormalScale(clearcoatNormalScale: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.clearcoatNormalScale =
|
||||
new Vector2(clearcoatNormalScale, clearcoatNormalScale);
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_ior
|
||||
get ior(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].ior;
|
||||
}
|
||||
|
||||
setIor(ior: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.ior = ior;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_sheen
|
||||
get sheenColorFactor(): RGB {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial].sheenColor.toArray() as RGB);
|
||||
}
|
||||
get sheenColorTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.SheenColor)!;
|
||||
}
|
||||
|
||||
get sheenRoughnessFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].sheenRoughness;
|
||||
}
|
||||
|
||||
get sheenRoughnessTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.SheenRoughness)!;
|
||||
}
|
||||
|
||||
setSheenColorFactor(rgb: RGB|string) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const color = this.colorFromRgb(rgb);
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.sheenColor.set(color);
|
||||
// Three.js GLTFExporter checks for internal sheen value.
|
||||
material.sheen = 1;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setSheenRoughnessFactor(roughness: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.sheenRoughness = roughness;
|
||||
// Three.js GLTFExporter checks for internal sheen value.
|
||||
material.sheen = 1;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_transmission
|
||||
get transmissionFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].transmission;
|
||||
}
|
||||
|
||||
get transmissionTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Transmission)!;
|
||||
}
|
||||
|
||||
setTransmissionFactor(transmission: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.transmission = transmission;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_volume
|
||||
get thicknessFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].thickness;
|
||||
}
|
||||
|
||||
get thicknessTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Thickness)!;
|
||||
}
|
||||
|
||||
get attenuationDistance(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].attenuationDistance;
|
||||
}
|
||||
|
||||
get attenuationColor(): RGB {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial].attenuationColor.toArray() as RGB);
|
||||
}
|
||||
|
||||
setThicknessFactor(thickness: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.thickness = thickness;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setAttenuationDistance(attenuationDistance: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.attenuationDistance = attenuationDistance;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setAttenuationColor(rgb: RGB|string) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const color = this.colorFromRgb(rgb);
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.attenuationColor.set(color);
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_specular
|
||||
get specularFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].specularIntensity;
|
||||
}
|
||||
|
||||
get specularTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Specular)!;
|
||||
}
|
||||
|
||||
get specularColorFactor(): RGB {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial].specularColor.toArray() as RGB);
|
||||
}
|
||||
|
||||
get specularColorTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.SheenColor)!;
|
||||
}
|
||||
|
||||
setSpecularFactor(specularFactor: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.specularIntensity = specularFactor;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setSpecularColorFactor(rgb: RGB|string) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const color = this.colorFromRgb(rgb);
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.specularColor.set(color);
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_iridescence
|
||||
get iridescenceFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].iridescence;
|
||||
}
|
||||
|
||||
get iridescenceTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Iridescence)!;
|
||||
}
|
||||
|
||||
get iridescenceIor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].iridescenceIOR;
|
||||
}
|
||||
|
||||
get iridescenceThicknessMinimum(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].iridescenceThicknessRange[0];
|
||||
}
|
||||
|
||||
get iridescenceThicknessMaximum(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].iridescenceThicknessRange[1];
|
||||
}
|
||||
|
||||
get iridescenceThicknessTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.IridescenceThickness)!;
|
||||
}
|
||||
|
||||
setIridescenceFactor(iridescence: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.iridescence = iridescence;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setIridescenceIor(ior: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.iridescenceIOR = ior;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setIridescenceThicknessMinimum(thicknessMin: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.iridescenceThicknessRange[0] = thicknessMin;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setIridescenceThicknessMaximum(thicknessMax: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.iridescenceThicknessRange[1] = thicknessMax;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_anisotropy
|
||||
get anisotropyStrength(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial] as any).anisotropy;
|
||||
}
|
||||
|
||||
get anisotropyRotation(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial] as any).anisotropyRotation;
|
||||
}
|
||||
|
||||
get anisotropyTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Anisotropy)!;
|
||||
}
|
||||
|
||||
setAnisotropyStrength(strength: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
(material as any).anisotropy = strength;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setAnisotropyRotation(rotation: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
(material as any).anisotropyRotation = rotation;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Intersection, Material as ThreeMaterial, Mesh, MeshPhysicalMaterial, Object3D} from 'three';
|
||||
|
||||
import {CorrelatedSceneGraph, GLTFElementToThreeObjectMap} from '../../three-components/gltf-instance/correlated-scene-graph.js';
|
||||
import {GLTF, GLTFElement} from '../../three-components/gltf-instance/gltf-2.0.js';
|
||||
|
||||
import {Model as ModelInterface} from './api.js';
|
||||
import {$setActive, $variantIndices, Material} from './material.js';
|
||||
import {Node, PrimitiveNode} from './nodes/primitive-node.js';
|
||||
import {$correlatedObjects} from './three-dom-element.js';
|
||||
|
||||
|
||||
|
||||
export const $materials = Symbol('materials');
|
||||
const $hierarchy = Symbol('hierarchy');
|
||||
const $roots = Symbol('roots');
|
||||
export const $primitivesList = Symbol('primitives');
|
||||
export const $loadVariant = Symbol('loadVariant');
|
||||
export const $prepareVariantsForExport = Symbol('prepareVariantsForExport');
|
||||
export const $switchVariant = Symbol('switchVariant');
|
||||
export const $materialFromPoint = Symbol('materialFromPoint');
|
||||
export const $nodeFromPoint = Symbol('nodeFromPoint');
|
||||
export const $nodeFromIndex = Symbol('nodeFromIndex');
|
||||
export const $variantData = Symbol('variantData');
|
||||
export const $availableVariants = Symbol('availableVariants');
|
||||
const $modelOnUpdate = Symbol('modelOnUpdate');
|
||||
const $cloneMaterial = Symbol('cloneMaterial');
|
||||
|
||||
// Holds onto temporary scene context information needed to perform lazy loading
|
||||
// of a resource.
|
||||
export class LazyLoader {
|
||||
gltf: GLTF;
|
||||
gltfElementMap: GLTFElementToThreeObjectMap;
|
||||
mapKey: GLTFElement;
|
||||
doLazyLoad: () => Promise<ThreeMaterial>;
|
||||
constructor(
|
||||
gltf: GLTF, gltfElementMap: GLTFElementToThreeObjectMap,
|
||||
mapKey: GLTFElement, doLazyLoad: () => Promise<ThreeMaterial>) {
|
||||
this.gltf = gltf;
|
||||
this.gltfElementMap = gltfElementMap;
|
||||
this.mapKey = mapKey;
|
||||
this.doLazyLoad = doLazyLoad;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Facades variant mapping data.
|
||||
*/
|
||||
export interface VariantData {
|
||||
name: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Model facades the top-level GLTF object returned by Three.js' GLTFLoader.
|
||||
* Currently, the model only bothers itself with the materials in the Three.js
|
||||
* scene graph.
|
||||
*/
|
||||
export class Model implements ModelInterface {
|
||||
private[$materials] = new Array<Material>();
|
||||
private[$hierarchy] = new Array<Node>();
|
||||
private[$roots] = new Array<Node>();
|
||||
private[$primitivesList] = new Array<PrimitiveNode>();
|
||||
private[$modelOnUpdate]: () => void = () => {};
|
||||
private[$variantData] = new Map<string, VariantData>();
|
||||
|
||||
constructor(
|
||||
correlatedSceneGraph: CorrelatedSceneGraph,
|
||||
onUpdate: () => void = () => {}) {
|
||||
this[$modelOnUpdate] = onUpdate;
|
||||
const {gltf, threeGLTF, gltfElementMap} = correlatedSceneGraph;
|
||||
|
||||
for (const [i, material] of gltf.materials!.entries()) {
|
||||
const correlatedMaterial =
|
||||
gltfElementMap.get(material) as Set<MeshPhysicalMaterial>| null;
|
||||
|
||||
if (correlatedMaterial != null) {
|
||||
this[$materials].push(new Material(
|
||||
onUpdate,
|
||||
i,
|
||||
true,
|
||||
this[$variantData],
|
||||
correlatedMaterial,
|
||||
material.name));
|
||||
} else {
|
||||
const elementArray = gltf['materials'] || [];
|
||||
const gltfMaterialDef = elementArray[i];
|
||||
|
||||
const threeMaterialSet = new Set<MeshPhysicalMaterial>();
|
||||
gltfElementMap.set(gltfMaterialDef, threeMaterialSet);
|
||||
const materialLoadCallback = async () => {
|
||||
const threeMaterial = await threeGLTF.parser.getDependency(
|
||||
'material', i) as MeshPhysicalMaterial;
|
||||
threeMaterialSet.add(threeMaterial);
|
||||
|
||||
return threeMaterial;
|
||||
};
|
||||
|
||||
// Configures the material for lazy loading.
|
||||
this[$materials].push(new Material(
|
||||
onUpdate,
|
||||
i,
|
||||
false,
|
||||
this[$variantData],
|
||||
threeMaterialSet,
|
||||
material.name,
|
||||
new LazyLoader(
|
||||
gltf, gltfElementMap, gltfMaterialDef, materialLoadCallback)));
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a hierarchy of Nodes. Allows not just for switching which
|
||||
// material is applied to a mesh but also exposes a way to provide API
|
||||
// for switching materials and general assignment/modification.
|
||||
|
||||
// Prepares for scene iteration.
|
||||
const parentMap = new Map<object, Node>();
|
||||
const nodeStack = new Array<Object3D>();
|
||||
for (const object of threeGLTF.scene.children) {
|
||||
nodeStack.push(object);
|
||||
}
|
||||
|
||||
// Walks the hierarchy and creates a node tree.
|
||||
while (nodeStack.length > 0) {
|
||||
const object = nodeStack.pop()!;
|
||||
|
||||
let node: Node|null = null;
|
||||
|
||||
if (object instanceof Mesh) {
|
||||
node = new PrimitiveNode(
|
||||
object as Mesh,
|
||||
this.materials,
|
||||
this[$variantData],
|
||||
correlatedSceneGraph);
|
||||
this[$primitivesList].push(node as PrimitiveNode);
|
||||
} else {
|
||||
node = new Node(object.name);
|
||||
}
|
||||
|
||||
const parent: Node|undefined = parentMap.get(object);
|
||||
if (parent != null) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
this[$roots].push(node);
|
||||
}
|
||||
this[$hierarchy].push(node);
|
||||
|
||||
for (const child of object.children) {
|
||||
nodeStack.push(child);
|
||||
parentMap.set(object, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materials are listed in the order of the GLTF materials array, plus a
|
||||
* default material at the end if one is used.
|
||||
*
|
||||
* TODO(#1003): How do we handle non-active scenes?
|
||||
*/
|
||||
get materials(): Material[] {
|
||||
return this[$materials];
|
||||
}
|
||||
|
||||
[$availableVariants]() {
|
||||
const variants = Array.from(this[$variantData].values());
|
||||
variants.sort((a, b) => {
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
return variants.map((data) => {
|
||||
return data.name;
|
||||
});
|
||||
}
|
||||
|
||||
getMaterialByName(name: string): Material|null {
|
||||
const matches = this[$materials].filter(material => {
|
||||
return material.name === name;
|
||||
});
|
||||
|
||||
if (matches.length > 0) {
|
||||
return matches[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[$nodeFromIndex](mesh: number, primitive: number): PrimitiveNode|null {
|
||||
const found = this[$hierarchy].find((node: Node) => {
|
||||
if (node instanceof PrimitiveNode) {
|
||||
const {meshes, primitives} = node.mesh.userData.associations;
|
||||
if (meshes == mesh && primitives == primitive) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return found == null ? null : found as PrimitiveNode;
|
||||
}
|
||||
|
||||
[$nodeFromPoint](hit: Intersection<Object3D>): PrimitiveNode {
|
||||
return this[$hierarchy].find((node: Node) => {
|
||||
if (node instanceof PrimitiveNode) {
|
||||
const primitive = node as PrimitiveNode;
|
||||
if (primitive.mesh === hit.object) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}) as PrimitiveNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersects a ray with the Model and returns the first material whose
|
||||
* object was intersected.
|
||||
*/
|
||||
[$materialFromPoint](hit: Intersection<Object3D>): Material {
|
||||
return this[$nodeFromPoint](hit).getActiveMaterial();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches model variant to the variant name provided, or switches to
|
||||
* default/initial materials if 'null' is provided.
|
||||
*/
|
||||
async[$switchVariant](variantName: string|null) {
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
await primitive.enableVariant(variantName);
|
||||
}
|
||||
|
||||
for (const material of this.materials) {
|
||||
material[$setActive](false);
|
||||
}
|
||||
// Marks the materials that are now in use after the variant switch.
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
this.materials[primitive.getActiveMaterial().index][$setActive](true);
|
||||
}
|
||||
}
|
||||
|
||||
async[$prepareVariantsForExport]() {
|
||||
const promises = new Array<Promise<void>>();
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
promises.push(primitive.instantiateVariants());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
[$cloneMaterial](index: number, newMaterialName: string): Material {
|
||||
const material = this.materials[index];
|
||||
|
||||
if (!material.isLoaded) {
|
||||
console.error(`Cloning an unloaded material,
|
||||
call 'material.ensureLoaded() before cloning the material.`);
|
||||
}
|
||||
|
||||
const threeMaterialSet =
|
||||
material[$correlatedObjects] as Set<MeshPhysicalMaterial>;
|
||||
|
||||
const clonedSet = new Set<MeshPhysicalMaterial>();
|
||||
for (const [i, threeMaterial] of threeMaterialSet.entries()) {
|
||||
const clone = threeMaterial.clone() as MeshPhysicalMaterial;
|
||||
clone.name =
|
||||
newMaterialName + (threeMaterialSet.size > 1 ? '_inst' + i : '');
|
||||
clonedSet.add(clone);
|
||||
}
|
||||
|
||||
const clonedMaterial = new Material(
|
||||
this[$modelOnUpdate],
|
||||
this[$materials].length,
|
||||
false, // Cloned as inactive.
|
||||
this[$variantData],
|
||||
clonedSet,
|
||||
newMaterialName);
|
||||
|
||||
this[$materials].push(clonedMaterial);
|
||||
|
||||
return clonedMaterial;
|
||||
}
|
||||
|
||||
createMaterialInstanceForVariant(
|
||||
originalMaterialIndex: number, newMaterialName: string,
|
||||
variantName: string, activateVariant: boolean = true): Material|null {
|
||||
let variantMaterialInstance: Material|null = null;
|
||||
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
const variantData = this[$variantData].get(variantName);
|
||||
// Skips the primitive if the variant already exists.
|
||||
if (variantData != null && primitive.variantInfo.has(variantData.index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skips the primitive if the source/original material does not exist.
|
||||
if (primitive.getMaterial(originalMaterialIndex) == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.hasVariant(variantName)) {
|
||||
this.createVariant(variantName);
|
||||
}
|
||||
|
||||
if (variantMaterialInstance == null) {
|
||||
variantMaterialInstance =
|
||||
this[$cloneMaterial](originalMaterialIndex, newMaterialName);
|
||||
}
|
||||
primitive.addVariant(variantMaterialInstance, variantName)
|
||||
}
|
||||
|
||||
if (activateVariant && variantMaterialInstance != null) {
|
||||
(variantMaterialInstance as Material)[$setActive](true);
|
||||
this.materials[originalMaterialIndex][$setActive](false);
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
primitive.enableVariant(variantName);
|
||||
}
|
||||
}
|
||||
|
||||
return variantMaterialInstance;
|
||||
}
|
||||
|
||||
createVariant(variantName: string) {
|
||||
if (!this[$variantData].has(variantName)) {
|
||||
// Adds the name if it's not already in the list.
|
||||
this[$variantData].set(
|
||||
variantName,
|
||||
{name: variantName, index: this[$variantData].size} as VariantData);
|
||||
} else {
|
||||
console.warn(`Variant '${variantName}'' already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
hasVariant(variantName: string) {
|
||||
return this[$variantData].has(variantName);
|
||||
}
|
||||
|
||||
setMaterialToVariant(materialIndex: number, targetVariantName: string) {
|
||||
if (this[$availableVariants]().find(name => name === targetVariantName) ==
|
||||
null) {
|
||||
console.warn(`Can't add material to '${
|
||||
targetVariantName}', the variant does not exist.'`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (materialIndex < 0 || materialIndex >= this.materials.length) {
|
||||
console.error(`setMaterialToVariant(): materialIndex is out of bounds.`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
const material = primitive.getMaterial(materialIndex);
|
||||
// Ensures the material exists on the primitive before setting it to a
|
||||
// variant.
|
||||
if (material != null) {
|
||||
primitive.addVariant(material, targetVariantName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateVariantName(currentName: string, newName: string) {
|
||||
const variantData = this[$variantData].get(currentName);
|
||||
if (variantData == null) {
|
||||
return;
|
||||
}
|
||||
variantData.name = newName;
|
||||
this[$variantData].set(newName, variantData!);
|
||||
this[$variantData].delete(currentName);
|
||||
}
|
||||
|
||||
deleteVariant(variantName: string) {
|
||||
const variant = this[$variantData].get(variantName);
|
||||
if (variant == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const material of this.materials) {
|
||||
if (material.hasVariant(variantName)) {
|
||||
material[$variantIndices].delete(variant.index);
|
||||
}
|
||||
}
|
||||
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
primitive.deleteVariant(variant.index);
|
||||
}
|
||||
|
||||
this[$variantData].delete(variantName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {Material as ThreeMaterial, Mesh, MeshPhysicalMaterial} from 'three';
|
||||
import {GLTFParser, GLTFReference} from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
import {CorrelatedSceneGraph} from '../../../three-components/gltf-instance/correlated-scene-graph.js';
|
||||
import {KHRMaterialsVariants, Primitive} from '../../../three-components/gltf-instance/gltf-2.0.js';
|
||||
import {UserDataVariantMapping} from '../../../three-components/gltf-instance/VariantMaterialLoaderPlugin.js';
|
||||
import {$getLoadedMaterial, $variantIndices, Material} from '../material.js';
|
||||
import {VariantData} from '../model.js';
|
||||
import {$correlatedObjects} from '../three-dom-element.js';
|
||||
|
||||
// Defines the base level node methods and data.
|
||||
export class Node {
|
||||
name: string = '';
|
||||
children = new Array<Node>();
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Represents a primitive in a glTF mesh.
|
||||
export class PrimitiveNode extends Node {
|
||||
public mesh: Mesh;
|
||||
// Maps glTF material index number to a material that this primitive supports.
|
||||
public materials = new Map<number, Material>();
|
||||
// Maps variant index to material.
|
||||
private variantToMaterialMap = new Map<number, Material>();
|
||||
public initialMaterialIdx = 0;
|
||||
private activeMaterialIdx = 0;
|
||||
private modelVariants: Map<string, VariantData>;
|
||||
private parser: GLTFParser;
|
||||
|
||||
constructor(
|
||||
mesh: Mesh, mvMaterials: Material[],
|
||||
modelVariants: Map<string, VariantData>,
|
||||
correlatedSceneGraph: CorrelatedSceneGraph) {
|
||||
super(mesh.name);
|
||||
this.mesh = mesh;
|
||||
const {gltf, threeGLTF, threeObjectMap} = correlatedSceneGraph;
|
||||
this.parser = threeGLTF.parser;
|
||||
this.modelVariants = modelVariants;
|
||||
this.mesh.userData.variantData = modelVariants;
|
||||
// Captures the primitive's initial material.
|
||||
const materialMappings =
|
||||
threeObjectMap.get(mesh.material as ThreeMaterial)!;
|
||||
if (materialMappings.materials != null) {
|
||||
this.initialMaterialIdx = this.activeMaterialIdx =
|
||||
materialMappings.materials;
|
||||
} else {
|
||||
console.error(
|
||||
`Primitive (${mesh.name}) missing initial material reference.`);
|
||||
}
|
||||
|
||||
// Gets the mesh index from the node.
|
||||
const associations =
|
||||
(mesh.userData.associations as GLTFReference & {primitives: number}) ||
|
||||
{};
|
||||
|
||||
if (associations.meshes == null) {
|
||||
console.error('Mesh is missing primitive index association');
|
||||
return;
|
||||
}
|
||||
// The gltf mesh array to sample from.
|
||||
const meshElementArray = gltf['meshes'] || [];
|
||||
// List of primitives under the mesh.
|
||||
const gltfPrimitives =
|
||||
(meshElementArray[associations.meshes].primitives || []) as Primitive[];
|
||||
|
||||
const gltfPrimitive = gltfPrimitives[associations.primitives];
|
||||
if (gltfPrimitive == null) {
|
||||
console.error('Mesh primitive definition is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Maps the gltfPrimitive default to a material.
|
||||
if (gltfPrimitive.material != null) {
|
||||
this.materials.set(
|
||||
gltfPrimitive.material, mvMaterials[gltfPrimitive.material]);
|
||||
} else {
|
||||
const defaultIdx = mvMaterials.findIndex((mat: Material) => {
|
||||
return mat.name === 'Default';
|
||||
});
|
||||
if (defaultIdx >= 0) {
|
||||
this.materials.set(defaultIdx, mvMaterials[defaultIdx]);
|
||||
} else {
|
||||
console.warn('gltfPrimitive has no material!');
|
||||
}
|
||||
}
|
||||
|
||||
if (gltfPrimitive.extensions &&
|
||||
gltfPrimitive.extensions['KHR_materials_variants']) {
|
||||
const variantsExtension =
|
||||
gltfPrimitive.extensions['KHR_materials_variants'] as
|
||||
KHRMaterialsVariants;
|
||||
const extensions = threeGLTF.parser.json.extensions;
|
||||
const variantNames = extensions['KHR_materials_variants'].variants;
|
||||
// Provides definition now that we know there are variants to
|
||||
// support.
|
||||
for (const mapping of variantsExtension.mappings) {
|
||||
const mvMaterial = mvMaterials[mapping.material];
|
||||
// Maps variant indices to Materials.
|
||||
this.materials.set(mapping.material, mvMaterial);
|
||||
for (const variant of mapping.variants) {
|
||||
const {name} = variantNames[variant];
|
||||
this.variantToMaterialMap.set(variant, mvMaterial);
|
||||
// Provides variant info for material self lookup.
|
||||
mvMaterial[$variantIndices].add(variant);
|
||||
// Updates the models variant data.
|
||||
if (!modelVariants.has(name)) {
|
||||
modelVariants.set(name, {name, index: variant} as VariantData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setActiveMaterial(material: number): Promise<ThreeMaterial|null> {
|
||||
const mvMaterial = this.materials.get(material)!;
|
||||
if (material !== this.activeMaterialIdx) {
|
||||
const backingMaterials =
|
||||
mvMaterial[$correlatedObjects] as Set<MeshPhysicalMaterial>;
|
||||
|
||||
const baseMaterial = await mvMaterial[$getLoadedMaterial]();
|
||||
if (baseMaterial != null) {
|
||||
this.mesh.material = baseMaterial;
|
||||
} else {
|
||||
this.mesh.material = backingMaterials.values().next().value!;
|
||||
}
|
||||
|
||||
this.parser.assignFinalMaterial(this.mesh);
|
||||
backingMaterials.add(this.mesh.material as MeshPhysicalMaterial);
|
||||
this.activeMaterialIdx = material;
|
||||
}
|
||||
return this.mesh.material as ThreeMaterial;
|
||||
}
|
||||
|
||||
getActiveMaterial(): Material {
|
||||
return this.materials.get(this.activeMaterialIdx)!;
|
||||
}
|
||||
|
||||
getMaterial(index: number): Material|undefined {
|
||||
return this.materials.get(index);
|
||||
}
|
||||
|
||||
async enableVariant(name: string|null): Promise<ThreeMaterial|null> {
|
||||
if (name == null) {
|
||||
return this.setActiveMaterial(this.initialMaterialIdx);
|
||||
}
|
||||
if (this.variantToMaterialMap != null && this.modelVariants.has(name)) {
|
||||
const modelVariants = this.modelVariants.get(name)!;
|
||||
return this.enableVariantHelper(modelVariants.index);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async enableVariantHelper(index: number|
|
||||
null): Promise<ThreeMaterial|null> {
|
||||
if (this.variantToMaterialMap != null && index != null) {
|
||||
const material = this.variantToMaterialMap.get(index);
|
||||
if (material != null) {
|
||||
return this.setActiveMaterial(material.index);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async instantiateVariants() {
|
||||
if (this.variantToMaterialMap == null) {
|
||||
return;
|
||||
}
|
||||
for (const index of this.variantToMaterialMap.keys()) {
|
||||
const variantMaterial = this.mesh.userData.variantMaterials.get(index) as
|
||||
UserDataVariantMapping;
|
||||
if (variantMaterial.material != null) {
|
||||
continue;
|
||||
}
|
||||
const threeMaterial = await this.enableVariantHelper(index);
|
||||
if (threeMaterial != null) {
|
||||
variantMaterial.material = threeMaterial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get variantInfo() {
|
||||
return this.variantToMaterialMap;
|
||||
}
|
||||
|
||||
addVariant(materialVariant: Material, variantName: string) {
|
||||
if (!this.ensureVariantIsUnused(variantName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Adds the variant to the model variants if needed.
|
||||
if (!this.modelVariants.has(variantName)) {
|
||||
this.modelVariants.set(
|
||||
variantName, {name: variantName, index: this.modelVariants.size});
|
||||
}
|
||||
const modelVariantData = this.modelVariants.get(variantName)!;
|
||||
const variantIndex = modelVariantData.index;
|
||||
|
||||
// Updates materials mapped to the variant.
|
||||
materialVariant[$variantIndices].add(variantIndex);
|
||||
|
||||
// Updates internal mappings.
|
||||
this.variantToMaterialMap.set(variantIndex, materialVariant);
|
||||
this.materials.set(materialVariant.index, materialVariant);
|
||||
|
||||
this.updateVariantUserData(variantIndex, materialVariant);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteVariant(variantIndex: number) {
|
||||
if (this.variantInfo.has(variantIndex)) {
|
||||
this.variantInfo.delete(variantIndex);
|
||||
|
||||
const userDataMap = this.mesh.userData.variantMaterials! as
|
||||
Map<number, UserDataVariantMapping>;
|
||||
if (userDataMap != null) {
|
||||
userDataMap.delete(variantIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateVariantUserData(
|
||||
variantIndex: number, materialVariant: Material) {
|
||||
// Adds variants name to material variants set.
|
||||
materialVariant[$variantIndices].add(variantIndex);
|
||||
|
||||
this.mesh.userData.variantData = this.modelVariants;
|
||||
// Updates import data (see VariantMaterialLoaderPlugin.ts).
|
||||
this.mesh.userData.variantMaterials = this.mesh.userData.variantMaterials ||
|
||||
new Map<number, UserDataVariantMapping>();
|
||||
const map = this.mesh.userData.variantMaterials! as
|
||||
Map<number, UserDataVariantMapping>;
|
||||
map.set(variantIndex, {
|
||||
material: materialVariant[$correlatedObjects]!.values().next().value as
|
||||
ThreeMaterial,
|
||||
gltfMaterialIndex: materialVariant.index,
|
||||
});
|
||||
}
|
||||
|
||||
private ensureVariantIsUnused(variantName: string) {
|
||||
const modelVariants = this.modelVariants.get(variantName);
|
||||
|
||||
if (modelVariants != null && this.variantInfo.has(modelVariants!.index)) {
|
||||
console.warn(`Primitive cannot add variant '${
|
||||
variantName}' for this material, it already exists.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Color, ColorRepresentation, MeshPhysicalMaterial} from 'three';
|
||||
|
||||
import {PBRMetallicRoughness as PBRMetallicRoughnessInterface, RGBA} from './api.js';
|
||||
import {TextureInfo, TextureUsage} from './texture-info.js';
|
||||
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
|
||||
const $threeMaterial = Symbol('threeMaterial');
|
||||
const $threeMaterials = Symbol('threeMaterials');
|
||||
const $baseColorTexture = Symbol('baseColorTexture');
|
||||
const $metallicRoughnessTexture = Symbol('metallicRoughnessTexture');
|
||||
|
||||
/**
|
||||
* PBR material properties facade implementation for Three.js materials
|
||||
*/
|
||||
export class PBRMetallicRoughness extends ThreeDOMElement implements
|
||||
PBRMetallicRoughnessInterface {
|
||||
private[$baseColorTexture]: TextureInfo;
|
||||
private[$metallicRoughnessTexture]: TextureInfo;
|
||||
|
||||
private get[$threeMaterials](): Set<MeshPhysicalMaterial> {
|
||||
return this[$correlatedObjects] as Set<MeshPhysicalMaterial>;
|
||||
}
|
||||
|
||||
private get[$threeMaterial]() {
|
||||
return this[$correlatedObjects]?.values().next().value as
|
||||
MeshPhysicalMaterial;
|
||||
}
|
||||
|
||||
constructor(
|
||||
onUpdate: () => void, correlatedMaterials: Set<MeshPhysicalMaterial>) {
|
||||
super(onUpdate, correlatedMaterials);
|
||||
|
||||
const {map, metalnessMap} = correlatedMaterials.values().next().value!;
|
||||
|
||||
this[$baseColorTexture] =
|
||||
new TextureInfo(onUpdate, TextureUsage.Base, map, correlatedMaterials);
|
||||
|
||||
this[$metallicRoughnessTexture] = new TextureInfo(
|
||||
onUpdate,
|
||||
TextureUsage.MetallicRoughness,
|
||||
metalnessMap,
|
||||
correlatedMaterials);
|
||||
}
|
||||
|
||||
|
||||
get baseColorFactor(): RGBA {
|
||||
const rgba = [0, 0, 0, this[$threeMaterial].opacity];
|
||||
this[$threeMaterial].color.toArray(rgba);
|
||||
return rgba as RGBA;
|
||||
}
|
||||
|
||||
get metallicFactor(): number {
|
||||
return this[$threeMaterial].metalness;
|
||||
}
|
||||
|
||||
get roughnessFactor(): number {
|
||||
return this[$threeMaterial].roughness;
|
||||
}
|
||||
|
||||
get baseColorTexture(): TextureInfo {
|
||||
return this[$baseColorTexture];
|
||||
}
|
||||
|
||||
get metallicRoughnessTexture(): TextureInfo {
|
||||
return this[$metallicRoughnessTexture];
|
||||
}
|
||||
|
||||
setBaseColorFactor(rgba: RGBA|string) {
|
||||
const color = new Color();
|
||||
if (rgba instanceof Array) {
|
||||
color.fromArray(rgba);
|
||||
} else {
|
||||
color.set(rgba as ColorRepresentation);
|
||||
}
|
||||
for (const material of this[$threeMaterials]) {
|
||||
material.color.set(color);
|
||||
if (rgba instanceof Array && rgba.length > 3) {
|
||||
material.opacity = rgba[3];
|
||||
} else {
|
||||
rgba = [0, 0, 0, material.opacity];
|
||||
color.toArray(rgba);
|
||||
}
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setMetallicFactor(value: number) {
|
||||
for (const material of this[$threeMaterials]) {
|
||||
material.metalness = value;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setRoughnessFactor(value: number) {
|
||||
for (const material of this[$threeMaterials]) {
|
||||
material.roughness = value;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ClampToEdgeWrapping, LinearFilter, LinearMipmapLinearFilter, LinearMipmapNearestFilter, MagnificationTextureFilter, MinificationTextureFilter, MirroredRepeatWrapping, NearestFilter, NearestMipmapLinearFilter, NearestMipmapNearestFilter, RepeatWrapping, Texture as ThreeTexture, Vector2, Wrapping} from 'three';
|
||||
|
||||
import {toVector2D, Vector2D} from '../../model-viewer-base.js';
|
||||
import {Filter, MagFilter, MinFilter, Wrap, WrapMode} from '../../three-components/gltf-instance/gltf-2.0.js';
|
||||
import {Sampler as DefaultedSampler} from '../../three-components/gltf-instance/gltf-defaulted.js';
|
||||
|
||||
import {Sampler as SamplerInterface, Vector2DInterface} from './api.js';
|
||||
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
// Convertion between gltf standards and threejs standards.
|
||||
const wrapModeToWrapping = new Map<WrapMode, Wrapping>([
|
||||
[Wrap.Repeat, RepeatWrapping],
|
||||
[Wrap.ClampToEdge, ClampToEdgeWrapping],
|
||||
[Wrap.MirroredRepeat, MirroredRepeatWrapping]
|
||||
]);
|
||||
const wrappingToWrapMode = new Map<Wrapping, WrapMode>([
|
||||
[RepeatWrapping, Wrap.Repeat],
|
||||
[ClampToEdgeWrapping, Wrap.ClampToEdge],
|
||||
[MirroredRepeatWrapping, Wrap.MirroredRepeat]
|
||||
]);
|
||||
const minFilterToMinification = new Map<MinFilter, MinificationTextureFilter>([
|
||||
[Filter.Nearest, NearestFilter],
|
||||
[Filter.Linear, LinearFilter],
|
||||
[Filter.NearestMipmapNearest, NearestMipmapNearestFilter],
|
||||
[Filter.LinearMipmapNearest, LinearMipmapNearestFilter],
|
||||
[Filter.NearestMipmapLinear, NearestMipmapLinearFilter],
|
||||
[Filter.LinearMipmapLinear, LinearMipmapLinearFilter]
|
||||
]);
|
||||
const minificationToMinFilter = new Map<MinificationTextureFilter, MinFilter>([
|
||||
[NearestFilter, Filter.Nearest],
|
||||
[LinearFilter, Filter.Linear],
|
||||
[NearestMipmapNearestFilter, Filter.NearestMipmapNearest],
|
||||
[LinearMipmapNearestFilter, Filter.LinearMipmapNearest],
|
||||
[NearestMipmapLinearFilter, Filter.NearestMipmapLinear],
|
||||
[LinearMipmapLinearFilter, Filter.LinearMipmapLinear]
|
||||
]);
|
||||
const magFilterToMagnification = new Map<MagFilter, MagnificationTextureFilter>(
|
||||
[[Filter.Nearest, NearestFilter], [Filter.Linear, LinearFilter]]);
|
||||
const magnificationToMagFilter = new Map<MagnificationTextureFilter, MagFilter>(
|
||||
[[NearestFilter, Filter.Nearest], [LinearFilter, Filter.Linear]]);
|
||||
|
||||
// Checks for threejs standards.
|
||||
const isMinFilter = (() => {
|
||||
return (value: unknown): value is MinificationTextureFilter =>
|
||||
minificationToMinFilter.has(value as MinificationTextureFilter);
|
||||
})();
|
||||
|
||||
const isMagFilter = (() => {
|
||||
return (value: unknown): value is MagnificationTextureFilter =>
|
||||
magnificationToMagFilter.has(value as MagnificationTextureFilter);
|
||||
})();
|
||||
|
||||
const isWrapping = (() => {
|
||||
return (value: unknown): value is Wrapping =>
|
||||
wrappingToWrapMode.has(value as Wrapping);
|
||||
})();
|
||||
|
||||
const isValidSamplerValue =
|
||||
<P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'|'rotation'|'repeat'|
|
||||
'offset'>(property: P, value: unknown): value is DefaultedSampler[P] => {
|
||||
switch (property) {
|
||||
case 'minFilter':
|
||||
return isMinFilter(value);
|
||||
case 'magFilter':
|
||||
return isMagFilter(value);
|
||||
case 'wrapS':
|
||||
case 'wrapT':
|
||||
return isWrapping(value);
|
||||
case 'rotation':
|
||||
case 'repeat':
|
||||
case 'offset':
|
||||
return true;
|
||||
default:
|
||||
throw new Error(`Cannot configure property "${property}" on Sampler`);
|
||||
}
|
||||
};
|
||||
|
||||
const $threeTexture = Symbol('threeTexture');
|
||||
const $threeTextures = Symbol('threeTextures');
|
||||
const $setProperty = Symbol('setProperty');
|
||||
|
||||
/**
|
||||
* Sampler facade implementation for Three.js textures
|
||||
*/
|
||||
export class Sampler extends ThreeDOMElement implements SamplerInterface {
|
||||
private get[$threeTexture]() {
|
||||
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
|
||||
}
|
||||
|
||||
private get[$threeTextures]() {
|
||||
return this[$correlatedObjects] as Set<ThreeTexture>;
|
||||
}
|
||||
|
||||
constructor(onUpdate: () => void, texture: ThreeTexture) {
|
||||
super(onUpdate, new Set<ThreeTexture>(texture ? [texture] : []));
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this[$threeTexture].name || '';
|
||||
}
|
||||
|
||||
get minFilter(): MinFilter {
|
||||
return minificationToMinFilter.get(this[$threeTexture].minFilter)!;
|
||||
}
|
||||
|
||||
get magFilter(): MagFilter {
|
||||
return magnificationToMagFilter.get(this[$threeTexture].magFilter)!;
|
||||
}
|
||||
|
||||
get wrapS(): WrapMode {
|
||||
return wrappingToWrapMode.get(this[$threeTexture].wrapS)!;
|
||||
}
|
||||
|
||||
get wrapT(): WrapMode {
|
||||
return wrappingToWrapMode.get(this[$threeTexture].wrapT)!;
|
||||
}
|
||||
|
||||
get rotation(): number {
|
||||
return this[$threeTexture].rotation;
|
||||
}
|
||||
|
||||
get scale(): Vector2D {
|
||||
return toVector2D(this[$threeTexture].repeat);
|
||||
}
|
||||
|
||||
get offset(): Vector2D|null {
|
||||
return toVector2D(this[$threeTexture].offset);
|
||||
}
|
||||
|
||||
setMinFilter(filter: MinFilter) {
|
||||
this[$setProperty]('minFilter', minFilterToMinification.get(filter)!);
|
||||
}
|
||||
|
||||
setMagFilter(filter: MagFilter) {
|
||||
this[$setProperty]('magFilter', magFilterToMagnification.get(filter)!);
|
||||
}
|
||||
|
||||
setWrapS(mode: WrapMode) {
|
||||
this[$setProperty]('wrapS', wrapModeToWrapping.get(mode)!);
|
||||
}
|
||||
|
||||
setWrapT(mode: WrapMode) {
|
||||
this[$setProperty]('wrapT', wrapModeToWrapping.get(mode)!);
|
||||
}
|
||||
|
||||
setRotation(rotation: number|null): void {
|
||||
if (rotation == null) {
|
||||
// Reset rotation.
|
||||
rotation = 0;
|
||||
}
|
||||
this[$setProperty]('rotation', rotation);
|
||||
}
|
||||
|
||||
setScale(scale: Vector2DInterface|null): void {
|
||||
if (scale == null) {
|
||||
// Reset scale.
|
||||
scale = {u: 1, v: 1};
|
||||
}
|
||||
this[$setProperty]('repeat', new Vector2(scale.u, scale.v));
|
||||
}
|
||||
|
||||
setOffset(offset: Vector2DInterface|null): void {
|
||||
if (offset == null) {
|
||||
// Reset offset.
|
||||
offset = {u: 0, v: 0};
|
||||
}
|
||||
this[$setProperty]('offset', new Vector2(offset.u, offset.v));
|
||||
}
|
||||
|
||||
private[$setProperty]<P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'|
|
||||
'rotation'|'repeat'|'offset'>(
|
||||
property: P, value: MinFilter|MagFilter|Wrapping|number|Vector2) {
|
||||
if (isValidSamplerValue(property, value)) {
|
||||
for (const texture of this[$threeTextures]) {
|
||||
(texture[property] as MinFilter | MagFilter | Wrapping | number |
|
||||
Vector2) = value;
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ColorSpace, LinearSRGBColorSpace, MeshPhysicalMaterial, SRGBColorSpace, Texture as ThreeTexture, Vector2, VideoTexture} from 'three';
|
||||
|
||||
import {TextureInfo as TextureInfoInterface} from './api.js';
|
||||
import {$threeTexture} from './image.js';
|
||||
import {Texture} from './texture.js';
|
||||
|
||||
const $texture = Symbol('texture');
|
||||
const $transform = Symbol('transform');
|
||||
export const $materials = Symbol('materials');
|
||||
export const $usage = Symbol('usage');
|
||||
const $onUpdate = Symbol('onUpdate');
|
||||
const $activeVideo = Symbol('activeVideo');
|
||||
|
||||
// Defines what a texture will be used for.
|
||||
export enum TextureUsage {
|
||||
Base,
|
||||
MetallicRoughness,
|
||||
Normal,
|
||||
Occlusion,
|
||||
Emissive,
|
||||
Clearcoat,
|
||||
ClearcoatRoughness,
|
||||
ClearcoatNormal,
|
||||
SheenColor,
|
||||
SheenRoughness,
|
||||
Transmission,
|
||||
Thickness,
|
||||
Specular,
|
||||
SpecularColor,
|
||||
Iridescence,
|
||||
IridescenceThickness,
|
||||
Anisotropy,
|
||||
}
|
||||
|
||||
interface TextureTransform {
|
||||
rotation: number;
|
||||
scale: Vector2;
|
||||
offset: Vector2;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextureInfo facade implementation for Three.js materials
|
||||
*/
|
||||
export class TextureInfo implements TextureInfoInterface {
|
||||
private[$texture]: Texture|null = null;
|
||||
private[$transform]: TextureTransform = {
|
||||
rotation: 0,
|
||||
scale: new Vector2(1, 1),
|
||||
offset: new Vector2(0, 0)
|
||||
};
|
||||
|
||||
// Holds a reference to the Three data that backs the material object.
|
||||
private[$materials]: Set<MeshPhysicalMaterial>|null;
|
||||
|
||||
// Texture usage defines the how the texture is used (ie Normal, Emissive...
|
||||
// etc)
|
||||
private[$usage]: TextureUsage;
|
||||
private[$onUpdate]: () => void;
|
||||
private[$activeVideo] = false;
|
||||
|
||||
constructor(
|
||||
onUpdate: () => void, usage: TextureUsage,
|
||||
threeTexture: ThreeTexture|null, material: Set<MeshPhysicalMaterial>) {
|
||||
// Creates image, sampler, and texture if valid texture info is provided.
|
||||
if (threeTexture) {
|
||||
this[$transform].rotation = threeTexture.rotation;
|
||||
this[$transform].scale.copy(threeTexture.repeat);
|
||||
this[$transform].offset.copy(threeTexture.offset);
|
||||
|
||||
this[$texture] = new Texture(onUpdate, threeTexture);
|
||||
}
|
||||
|
||||
this[$onUpdate] = onUpdate;
|
||||
this[$materials] = material;
|
||||
this[$usage] = usage;
|
||||
}
|
||||
|
||||
get texture(): Texture|null {
|
||||
return this[$texture];
|
||||
}
|
||||
|
||||
setTexture(texture: Texture|null): void {
|
||||
const threeTexture: ThreeTexture|null =
|
||||
texture != null ? texture.source[$threeTexture] : null;
|
||||
|
||||
const oldTexture = this[$texture]?.source[$threeTexture] as VideoTexture;
|
||||
if (oldTexture != null && oldTexture.isVideoTexture) {
|
||||
this[$activeVideo] = false;
|
||||
} else if (this[$texture]?.source.animation) {
|
||||
this[$texture].source.animation.removeEventListener(
|
||||
'enterFrame', this[$onUpdate]);
|
||||
}
|
||||
|
||||
this[$texture] = texture;
|
||||
|
||||
if (threeTexture != null && (threeTexture as VideoTexture).isVideoTexture) {
|
||||
const element = threeTexture.image;
|
||||
this[$activeVideo] = true;
|
||||
if (element.requestVideoFrameCallback != null) {
|
||||
const update = () => {
|
||||
if (!this[$activeVideo]) {
|
||||
return;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
element.requestVideoFrameCallback(update);
|
||||
};
|
||||
element.requestVideoFrameCallback(update);
|
||||
} else {
|
||||
const update = () => {
|
||||
if (!this[$activeVideo]) {
|
||||
return;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
requestAnimationFrame(update);
|
||||
};
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
} else if (texture?.source.animation != null) {
|
||||
texture.source.animation.addEventListener('enterFrame', this[$onUpdate]);
|
||||
}
|
||||
|
||||
let colorSpace: ColorSpace = SRGBColorSpace;
|
||||
if (this[$materials]) {
|
||||
for (const material of this[$materials]!) {
|
||||
switch (this[$usage]) {
|
||||
case TextureUsage.Base:
|
||||
material.map = threeTexture;
|
||||
break;
|
||||
case TextureUsage.MetallicRoughness:
|
||||
colorSpace = LinearSRGBColorSpace;
|
||||
material.metalnessMap = threeTexture;
|
||||
material.roughnessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Normal:
|
||||
colorSpace = LinearSRGBColorSpace;
|
||||
material.normalMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Occlusion:
|
||||
colorSpace = LinearSRGBColorSpace;
|
||||
material.aoMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Emissive:
|
||||
material.emissiveMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Clearcoat:
|
||||
material.clearcoatMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.ClearcoatRoughness:
|
||||
material.clearcoatRoughnessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.ClearcoatNormal:
|
||||
material.clearcoatNormalMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.SheenColor:
|
||||
material.sheenColorMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.SheenRoughness:
|
||||
material.sheenRoughnessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Transmission:
|
||||
material.transmissionMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Thickness:
|
||||
material.thicknessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Specular:
|
||||
material.specularIntensityMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.SpecularColor:
|
||||
material.specularColorMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Iridescence:
|
||||
material.iridescenceMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.IridescenceThickness:
|
||||
material.iridescenceThicknessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Anisotropy:
|
||||
(material as any).anisotropyMap = threeTexture;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (threeTexture) {
|
||||
// Updates the colorSpace for the texture, affects all references.
|
||||
threeTexture.colorSpace = colorSpace;
|
||||
threeTexture.rotation = this[$transform].rotation;
|
||||
threeTexture.repeat = this[$transform].scale;
|
||||
threeTexture.offset = this[$transform].offset;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Texture as ThreeTexture} from 'three';
|
||||
|
||||
import {Texture as TextureInterface} from './api.js';
|
||||
import {Image} from './image.js';
|
||||
import {Sampler} from './sampler.js';
|
||||
import {$correlatedObjects, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
|
||||
|
||||
const $image = Symbol('image');
|
||||
const $sampler = Symbol('sampler');
|
||||
const $threeTexture = Symbol('threeTexture');
|
||||
|
||||
/**
|
||||
* Material facade implementation for Three.js materials
|
||||
*/
|
||||
export class Texture extends ThreeDOMElement implements TextureInterface {
|
||||
private[$image]: Image;
|
||||
private[$sampler]: Sampler;
|
||||
|
||||
private get[$threeTexture]() {
|
||||
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
|
||||
}
|
||||
|
||||
constructor(onUpdate: () => void, threeTexture: ThreeTexture) {
|
||||
super(onUpdate, new Set<ThreeTexture>(threeTexture ? [threeTexture] : []));
|
||||
|
||||
this[$sampler] = new Sampler(onUpdate, threeTexture);
|
||||
this[$image] = new Image(onUpdate, threeTexture);
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this[$threeTexture].name || '';
|
||||
}
|
||||
|
||||
set name(name: string) {
|
||||
for (const texture of this[$correlatedObjects] as Set<ThreeTexture>) {
|
||||
texture.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
get sampler(): Sampler {
|
||||
return this[$sampler];
|
||||
}
|
||||
|
||||
get source(): Image {
|
||||
return this[$image];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Material, Object3D, Texture} from 'three';
|
||||
|
||||
export const $correlatedObjects = Symbol('correlatedObjects');
|
||||
export const $onUpdate = Symbol('onUpdate');
|
||||
|
||||
type CorrelatedObjects = Set<Object3D>|Set<Material>|Set<Texture>;
|
||||
|
||||
/**
|
||||
* A SerializableThreeDOMElement is the common primitive of all scene graph
|
||||
* elements that have been facaded in the host execution context. It adds
|
||||
* a common interface to these elements in support of convenient
|
||||
* serializability.
|
||||
*/
|
||||
export class ThreeDOMElement {
|
||||
readonly[$onUpdate]: () => void;
|
||||
// The Three.js scene graph construct for this element.
|
||||
[$correlatedObjects]: CorrelatedObjects;
|
||||
|
||||
constructor(onUpdate: () => void, correlatedObjects: CorrelatedObjects) {
|
||||
this[$onUpdate] = onUpdate;
|
||||
this[$correlatedObjects] = correlatedObjects;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
|
||||
import {style} from '../decorators.js';
|
||||
import ModelViewerElementBase, {$getModelIsVisible, $renderer, $scene, $tick} from '../model-viewer-base.js';
|
||||
import {degreesToRadians} from '../styles/conversions.js';
|
||||
import {EvaluatedStyle, Intrinsics} from '../styles/evaluators.js';
|
||||
import {numberNode, NumberNode} from '../styles/parsers.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
|
||||
import {CameraChangeDetails} from './controls.js';
|
||||
|
||||
|
||||
// How much the model will rotate per
|
||||
// second in radians:
|
||||
const DEFAULT_ROTATION_SPEED = Math.PI / 32;
|
||||
export const AUTO_ROTATE_DELAY_DEFAULT = 3000;
|
||||
|
||||
const rotationRateIntrinsics = {
|
||||
basis:
|
||||
[degreesToRadians(numberNode(DEFAULT_ROTATION_SPEED, 'rad')) as
|
||||
NumberNode<'rad'>],
|
||||
keywords: {auto: [null]}
|
||||
};
|
||||
|
||||
const $autoRotateStartTime = Symbol('autoRotateStartTime');
|
||||
const $radiansPerSecond = Symbol('radiansPerSecond');
|
||||
const $syncRotationRate = Symbol('syncRotationRate');
|
||||
const $onCameraChange = Symbol('onCameraChange');
|
||||
|
||||
export declare interface StagingInterface {
|
||||
autoRotate: boolean;
|
||||
autoRotateDelay: number;
|
||||
readonly turntableRotation: number;
|
||||
resetTurntableRotation(theta?: number): void;
|
||||
}
|
||||
|
||||
export const StagingMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<StagingInterface>&T => {
|
||||
class StagingModelViewerElement extends ModelViewerElement {
|
||||
@property({type: Boolean, attribute: 'auto-rotate'})
|
||||
autoRotate: boolean = false;
|
||||
|
||||
@property({type: Number, attribute: 'auto-rotate-delay'})
|
||||
autoRotateDelay: number = AUTO_ROTATE_DELAY_DEFAULT;
|
||||
|
||||
@style(
|
||||
{intrinsics: rotationRateIntrinsics, updateHandler: $syncRotationRate})
|
||||
@property({type: String, attribute: 'rotation-per-second'})
|
||||
rotationPerSecond: string = 'auto';
|
||||
|
||||
private[$autoRotateStartTime] = performance.now();
|
||||
private[$radiansPerSecond] = 0;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener(
|
||||
'camera-change', this[$onCameraChange] as EventListener);
|
||||
this[$autoRotateStartTime] = performance.now();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener(
|
||||
'camera-change', this[$onCameraChange] as EventListener);
|
||||
this[$autoRotateStartTime] = performance.now();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('autoRotate')) {
|
||||
this[$autoRotateStartTime] = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
[$syncRotationRate](style: EvaluatedStyle<Intrinsics<['rad']>>) {
|
||||
this[$radiansPerSecond] = style[0];
|
||||
}
|
||||
|
||||
[$tick](time: number, delta: number) {
|
||||
super[$tick](time, delta);
|
||||
|
||||
if (!this.autoRotate || !this[$getModelIsVisible]() ||
|
||||
this[$renderer].isPresenting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rotationDelta = Math.min(
|
||||
delta, time - this[$autoRotateStartTime] - this.autoRotateDelay);
|
||||
|
||||
if (rotationDelta > 0) {
|
||||
this[$scene].yaw = this.turntableRotation +
|
||||
this[$radiansPerSecond] * rotationDelta * 0.001;
|
||||
}
|
||||
}
|
||||
|
||||
[$onCameraChange] = (event: CustomEvent<CameraChangeDetails>) => {
|
||||
if (!this.autoRotate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.detail.source === 'user-interaction') {
|
||||
this[$autoRotateStartTime] = performance.now();
|
||||
}
|
||||
};
|
||||
|
||||
get turntableRotation(): number {
|
||||
return this[$scene].yaw;
|
||||
}
|
||||
|
||||
resetTurntableRotation(theta = 0) {
|
||||
this[$scene].yaw = theta;
|
||||
}
|
||||
}
|
||||
|
||||
return StagingModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,648 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ReactiveElement} from 'lit';
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {Camera as ThreeCamera, Event as ThreeEvent, Vector2, Vector3, WebGLRenderer} from 'three';
|
||||
|
||||
import {HAS_INTERSECTION_OBSERVER, HAS_RESIZE_OBSERVER} from './constants.js';
|
||||
import {$updateEnvironment} from './features/environment.js';
|
||||
import {makeTemplate} from './template.js';
|
||||
import {$evictionPolicy, CachingGLTFLoader} from './three-components/CachingGLTFLoader.js';
|
||||
import {ModelScene} from './three-components/ModelScene.js';
|
||||
import {ContextLostEvent, Renderer} from './three-components/Renderer.js';
|
||||
import {clamp, debounce} from './utilities.js';
|
||||
import {ProgressTracker} from './utilities/progress-tracker.js';
|
||||
|
||||
const CLEAR_MODEL_TIMEOUT_MS = 10;
|
||||
const FALLBACK_SIZE_UPDATE_THRESHOLD_MS = 50;
|
||||
const ANNOUNCE_MODEL_VISIBILITY_DEBOUNCE_THRESHOLD = 0;
|
||||
const UNSIZED_MEDIA_WIDTH = 300;
|
||||
const UNSIZED_MEDIA_HEIGHT = 150;
|
||||
|
||||
export const blobCanvas = document.createElement('canvas');
|
||||
|
||||
const $fallbackResizeHandler = Symbol('fallbackResizeHandler');
|
||||
const $defaultAriaLabel = Symbol('defaultAriaLabel');
|
||||
|
||||
const $resizeObserver = Symbol('resizeObserver');
|
||||
const $clearModelTimeout = Symbol('clearModelTimeout');
|
||||
const $onContextLost = Symbol('onContextLost');
|
||||
const $loaded = Symbol('loaded');
|
||||
const $status = Symbol('status');
|
||||
const $onFocus = Symbol('onFocus');
|
||||
const $onBlur = Symbol('onBlur');
|
||||
|
||||
export const $updateSize = Symbol('updateSize');
|
||||
export const $intersectionObserver = Symbol('intersectionObserver');
|
||||
export const $isElementInViewport = Symbol('isElementInViewport');
|
||||
export const $announceModelVisibility = Symbol('announceModelVisibility');
|
||||
export const $ariaLabel = Symbol('ariaLabel');
|
||||
export const $altDefaulted = Symbol('altDefaulted');
|
||||
export const $statusElement = Symbol('statusElement');
|
||||
export const $updateStatus = Symbol('updateStatus');
|
||||
export const $loadedTime = Symbol('loadedTime');
|
||||
export const $updateSource = Symbol('updateSource');
|
||||
export const $markLoaded = Symbol('markLoaded');
|
||||
export const $container = Symbol('container');
|
||||
export const $userInputElement = Symbol('input');
|
||||
export const $canvas = Symbol('canvas');
|
||||
export const $scene = Symbol('scene');
|
||||
export const $needsRender = Symbol('needsRender');
|
||||
export const $tick = Symbol('tick');
|
||||
export const $onModelLoad = Symbol('onModelLoad');
|
||||
export const $onResize = Symbol('onResize');
|
||||
export const $renderer = Symbol('renderer');
|
||||
export const $progressTracker = Symbol('progressTracker');
|
||||
export const $getLoaded = Symbol('getLoaded');
|
||||
export const $getModelIsVisible = Symbol('getModelIsVisible');
|
||||
export const $shouldAttemptPreload = Symbol('shouldAttemptPreload');
|
||||
|
||||
export interface Vector3D {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
toString(): string
|
||||
}
|
||||
|
||||
export const toVector3D = (v: Vector3) => {
|
||||
return {
|
||||
x: v.x,
|
||||
y: v.y,
|
||||
z: v.z,
|
||||
toString() {
|
||||
return `${this.x}m ${this.y}m ${this.z}m`;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export interface Vector2D {
|
||||
u: number
|
||||
v: number
|
||||
toString(): string
|
||||
}
|
||||
|
||||
export const toVector2D = (v: Vector2) => {
|
||||
return {
|
||||
u: v.x,
|
||||
v: v.y,
|
||||
toString() {
|
||||
return `${this.u} ${this.v}`;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
interface ToBlobOptions {
|
||||
mimeType?: string, qualityArgument?: number, idealAspect?: boolean
|
||||
}
|
||||
|
||||
export interface FramingInfo {
|
||||
framedRadius: number;
|
||||
fieldOfViewAspect: number;
|
||||
}
|
||||
|
||||
export interface Camera {
|
||||
viewMatrix: Array<number>;
|
||||
projectionMatrix: Array<number>;
|
||||
}
|
||||
|
||||
export interface EffectComposerInterface {
|
||||
setRenderer(renderer: WebGLRenderer): void;
|
||||
setMainScene(scene: ModelScene): void;
|
||||
setMainCamera(camera: ThreeCamera): void;
|
||||
setSize(width: number, height: number): void;
|
||||
beforeRender(time: DOMHighResTimeStamp, delta: DOMHighResTimeStamp): void;
|
||||
render(deltaTime?: DOMHighResTimeStamp): void;
|
||||
}
|
||||
|
||||
export interface RendererInterface {
|
||||
load(progressCallback: (progress: number) => void): Promise<FramingInfo>;
|
||||
render(camera: Camera): void;
|
||||
resize(width: number, height: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition for a basic <model-viewer> element.
|
||||
*/
|
||||
export default class ModelViewerElementBase extends ReactiveElement {
|
||||
static get is() {
|
||||
return 'model-viewer';
|
||||
}
|
||||
|
||||
/** @export */
|
||||
static set modelCacheSize(value: number) {
|
||||
CachingGLTFLoader[$evictionPolicy].evictionThreshold = value;
|
||||
}
|
||||
|
||||
/** @export */
|
||||
static get modelCacheSize(): number {
|
||||
return CachingGLTFLoader[$evictionPolicy].evictionThreshold
|
||||
}
|
||||
|
||||
/** @export */
|
||||
static set minimumRenderScale(value: number) {
|
||||
if (value > 1) {
|
||||
console.warn(
|
||||
'<model-viewer> minimumRenderScale has been clamped to a maximum value of 1.');
|
||||
}
|
||||
if (value <= 0) {
|
||||
console.warn(
|
||||
'<model-viewer> minimumRenderScale has been clamped to a minimum value of 0.25.');
|
||||
}
|
||||
Renderer.singleton.minScale = value;
|
||||
}
|
||||
|
||||
/** @export */
|
||||
static get minimumRenderScale(): number {
|
||||
return Renderer.singleton.minScale;
|
||||
}
|
||||
|
||||
@property({type: String}) alt: string|null = null;
|
||||
|
||||
@property({type: String}) src: string|null = null;
|
||||
|
||||
@property({type: Boolean, attribute: 'with-credentials'})
|
||||
withCredentials: boolean = false;
|
||||
|
||||
/**
|
||||
* Generates a 3D model schema https://schema.org/3DModel associated with
|
||||
* the loaded src and inserts it into the header of the page for search
|
||||
* engines to crawl.
|
||||
*/
|
||||
@property({type: Boolean, attribute: 'generate-schema'})
|
||||
generateSchema = false;
|
||||
|
||||
protected[$isElementInViewport] = false;
|
||||
protected[$loaded] = false;
|
||||
protected[$loadedTime] = 0;
|
||||
protected[$scene]: ModelScene;
|
||||
protected[$container]: HTMLDivElement;
|
||||
protected[$userInputElement]: HTMLDivElement;
|
||||
protected[$canvas]: HTMLCanvasElement;
|
||||
protected[$statusElement]: HTMLSpanElement;
|
||||
protected[$status] = '';
|
||||
protected[$defaultAriaLabel]: string;
|
||||
protected[$clearModelTimeout]: number|null = null;
|
||||
|
||||
protected[$fallbackResizeHandler] = debounce(() => {
|
||||
const boundingRect = this.getBoundingClientRect();
|
||||
this[$updateSize](boundingRect);
|
||||
}, FALLBACK_SIZE_UPDATE_THRESHOLD_MS);
|
||||
|
||||
protected[$announceModelVisibility] = debounce((oldVisibility: boolean) => {
|
||||
const newVisibility = this.modelIsVisible;
|
||||
if (newVisibility !== oldVisibility) {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
'model-visibility', {detail: {visible: newVisibility}}));
|
||||
}
|
||||
}, ANNOUNCE_MODEL_VISIBILITY_DEBOUNCE_THRESHOLD);
|
||||
|
||||
protected[$resizeObserver]: ResizeObserver|null = null;
|
||||
protected[$intersectionObserver]: IntersectionObserver|null = null;
|
||||
|
||||
protected[$progressTracker]: ProgressTracker = new ProgressTracker();
|
||||
|
||||
/** @export */
|
||||
get loaded() {
|
||||
return this[$getLoaded]();
|
||||
}
|
||||
|
||||
get[$renderer]() {
|
||||
return Renderer.singleton;
|
||||
}
|
||||
|
||||
/** @export */
|
||||
get modelIsVisible() {
|
||||
return this[$getModelIsVisible]();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ModelViewerElement.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attachShadow({mode: 'open'});
|
||||
|
||||
const shadowRoot = this.shadowRoot!;
|
||||
|
||||
makeTemplate(shadowRoot);
|
||||
|
||||
this[$container] = shadowRoot.querySelector('.container') as HTMLDivElement;
|
||||
this[$userInputElement] =
|
||||
shadowRoot.querySelector('.userInput') as HTMLDivElement;
|
||||
this[$canvas] = shadowRoot.querySelector('canvas') as HTMLCanvasElement;
|
||||
this[$statusElement] =
|
||||
shadowRoot.querySelector('#status') as HTMLSpanElement;
|
||||
this[$defaultAriaLabel] =
|
||||
this[$userInputElement].getAttribute('aria-label')!;
|
||||
|
||||
// Because of potential race conditions related to invoking the constructor
|
||||
// we only use the bounding rect to set the initial size if the element is
|
||||
// already connected to the document:
|
||||
let width, height;
|
||||
if (this.isConnected) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
} else {
|
||||
width = UNSIZED_MEDIA_WIDTH;
|
||||
height = UNSIZED_MEDIA_HEIGHT;
|
||||
}
|
||||
|
||||
// Create the underlying ModelScene.
|
||||
this[$scene] =
|
||||
new ModelScene({canvas: this[$canvas], element: this, width, height});
|
||||
|
||||
// Update initial size on microtask timing so that subclasses have a
|
||||
// chance to initialize
|
||||
Promise.resolve().then(() => {
|
||||
this[$updateSize](this.getBoundingClientRect());
|
||||
});
|
||||
|
||||
if (HAS_RESIZE_OBSERVER) {
|
||||
// Set up a resize observer so we can scale our canvas
|
||||
// if our <model-viewer> changes
|
||||
this[$resizeObserver] =
|
||||
new ResizeObserver((entries: Array<ResizeObserverEntry>) => {
|
||||
// Don't resize anything if in AR mode; otherwise the canvas
|
||||
// scaling to fullscreen on entering AR will clobber the flat/2d
|
||||
// dimensions of the element.
|
||||
if (this[$renderer].isPresenting) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let entry of entries) {
|
||||
if (entry.target === this) {
|
||||
this[$updateSize](entry.contentRect);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (HAS_INTERSECTION_OBSERVER) {
|
||||
this[$intersectionObserver] = new IntersectionObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
if (entry.target === this) {
|
||||
const oldVisibility = this.modelIsVisible;
|
||||
this[$isElementInViewport] = entry.isIntersecting;
|
||||
this[$announceModelVisibility](oldVisibility);
|
||||
if (this[$isElementInViewport] && !this.loaded) {
|
||||
this[$updateSource]();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
root: null,
|
||||
// We used to have margin here, but it was causing animated models below
|
||||
// the fold to steal the frame budget. Weirder still, it would also
|
||||
// cause input events to be swallowed, sometimes for seconds on the
|
||||
// model above the fold, but only when the animated model was completely
|
||||
// below. Setting this margin to zero fixed it.
|
||||
rootMargin: '0px',
|
||||
// With zero threshold, an element adjacent to but not intersecting the
|
||||
// viewport will be reported as intersecting, which will cause
|
||||
// unnecessary rendering. Any slight positive threshold alleviates this.
|
||||
threshold: 0.00001,
|
||||
});
|
||||
} else {
|
||||
// If there is no intersection observer, then all models should be visible
|
||||
// at all times:
|
||||
this[$isElementInViewport] = true;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
if (HAS_RESIZE_OBSERVER) {
|
||||
this[$resizeObserver]!.observe(this);
|
||||
} else {
|
||||
self.addEventListener('resize', this[$fallbackResizeHandler]);
|
||||
}
|
||||
|
||||
if (HAS_INTERSECTION_OBSERVER) {
|
||||
this[$intersectionObserver]!.observe(this);
|
||||
}
|
||||
|
||||
this.addEventListener('focus', this[$onFocus]);
|
||||
this.addEventListener('blur', this[$onBlur]);
|
||||
|
||||
const renderer = this[$renderer];
|
||||
renderer.addEventListener(
|
||||
'contextlost', this[$onContextLost] as (event: ThreeEvent) => void);
|
||||
|
||||
renderer.registerScene(this[$scene]);
|
||||
|
||||
if (this[$clearModelTimeout] != null) {
|
||||
self.clearTimeout(this[$clearModelTimeout]!);
|
||||
this[$clearModelTimeout] = null;
|
||||
// Force an update in case the model has been evicted from our GLTF cache
|
||||
// @see https://lit-element.polymer-project.org/guide/lifecycle#requestupdate
|
||||
this.requestUpdate('src', null);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback && super.disconnectedCallback();
|
||||
if (HAS_RESIZE_OBSERVER) {
|
||||
this[$resizeObserver]!.unobserve(this);
|
||||
} else {
|
||||
self.removeEventListener('resize', this[$fallbackResizeHandler]);
|
||||
}
|
||||
|
||||
if (HAS_INTERSECTION_OBSERVER) {
|
||||
this[$intersectionObserver]!.unobserve(this);
|
||||
}
|
||||
|
||||
this.removeEventListener('focus', this[$onFocus]);
|
||||
this.removeEventListener('blur', this[$onBlur]);
|
||||
|
||||
const renderer = this[$renderer];
|
||||
renderer.removeEventListener(
|
||||
'contextlost', this[$onContextLost] as (event: ThreeEvent) => void);
|
||||
|
||||
renderer.unregisterScene(this[$scene]);
|
||||
|
||||
this[$clearModelTimeout] = self.setTimeout(() => {
|
||||
this[$scene].dispose();
|
||||
this[$clearModelTimeout] = null;
|
||||
}, CLEAR_MODEL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string|number|symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
// NOTE(cdata): If a property changes from values A -> B -> A in the space
|
||||
// of a microtask, LitElement/UpdatingElement will notify of a change even
|
||||
// though the value has effectively not changed, so we need to check to make
|
||||
// sure that the value has actually changed before changing the loaded flag.
|
||||
if (changedProperties.has('src')) {
|
||||
if (this.src == null) {
|
||||
this[$loaded] = false;
|
||||
this[$loadedTime] = 0;
|
||||
this[$scene].reset();
|
||||
} else if (this.src !== this[$scene].url) {
|
||||
this[$loaded] = false;
|
||||
this[$loadedTime] = 0;
|
||||
this[$updateSource]();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has('alt')) {
|
||||
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
|
||||
}
|
||||
|
||||
if (changedProperties.has('generateSchema')) {
|
||||
if (this.generateSchema) {
|
||||
this[$scene].updateSchema(this.src);
|
||||
} else {
|
||||
this[$scene].updateSchema(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @export */
|
||||
toDataURL(type?: string, encoderOptions?: number): string {
|
||||
return this[$renderer]
|
||||
.displayCanvas(this[$scene])
|
||||
.toDataURL(type, encoderOptions);
|
||||
}
|
||||
|
||||
/** @export */
|
||||
async toBlob(options?: ToBlobOptions): Promise<Blob> {
|
||||
const mimeType = options ? options.mimeType : undefined;
|
||||
const qualityArgument = options ? options.qualityArgument : undefined;
|
||||
const useIdealAspect = options ? options.idealAspect : undefined;
|
||||
|
||||
const {width, height, idealAspect, aspect} = this[$scene];
|
||||
const {dpr, scaleFactor} = this[$renderer];
|
||||
let outputWidth = width * scaleFactor * dpr;
|
||||
let outputHeight = height * scaleFactor * dpr;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
if (useIdealAspect === true) {
|
||||
if (idealAspect > aspect) {
|
||||
const oldHeight = outputHeight;
|
||||
outputHeight = Math.round(outputWidth / idealAspect);
|
||||
offsetY = (oldHeight - outputHeight) / 2;
|
||||
} else {
|
||||
const oldWidth = outputWidth;
|
||||
outputWidth = Math.round(outputHeight * idealAspect);
|
||||
offsetX = (oldWidth - outputWidth) / 2;
|
||||
}
|
||||
}
|
||||
blobCanvas.width = outputWidth;
|
||||
blobCanvas.height = outputHeight;
|
||||
try {
|
||||
return new Promise<Blob>(async (resolve, reject) => {
|
||||
blobCanvas.getContext('2d')!.drawImage(
|
||||
this[$renderer].displayCanvas(this[$scene]),
|
||||
offsetX,
|
||||
offsetY,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
0,
|
||||
0,
|
||||
outputWidth,
|
||||
outputHeight);
|
||||
|
||||
blobCanvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
return reject(new Error('Unable to retrieve canvas blob'));
|
||||
}
|
||||
|
||||
resolve(blob);
|
||||
}, mimeType, qualityArgument);
|
||||
});
|
||||
} finally {
|
||||
this[$updateSize]({width, height});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new EffectComposer as the main rendering pipeline,
|
||||
* instead of the default ThreeJs renderer.
|
||||
* This method also calls setRenderer, setMainScene, and setMainCamera on
|
||||
* your effectComposer.
|
||||
* @param effectComposer An EffectComposer from `pmndrs/postprocessing`
|
||||
*/
|
||||
registerEffectComposer(effectComposer: EffectComposerInterface) {
|
||||
effectComposer.setRenderer(this[$renderer].threeRenderer);
|
||||
effectComposer.setMainCamera(this[$scene].getCamera());
|
||||
effectComposer.setMainScene(this[$scene]);
|
||||
this[$scene].effectRenderer = effectComposer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the registered EffectComposer
|
||||
*/
|
||||
unregisterEffectComposer() {
|
||||
this[$scene].effectRenderer = null;
|
||||
}
|
||||
|
||||
registerRenderer(renderer: RendererInterface) {
|
||||
this[$scene].externalRenderer = renderer;
|
||||
}
|
||||
|
||||
unregisterRenderer() {
|
||||
this[$scene].externalRenderer = null;
|
||||
}
|
||||
|
||||
get[$ariaLabel]() {
|
||||
return this[$altDefaulted];
|
||||
}
|
||||
|
||||
get[$altDefaulted]() {
|
||||
return (this.alt == null || this.alt === 'null') ? this[$defaultAriaLabel] :
|
||||
this.alt;
|
||||
}
|
||||
|
||||
// NOTE(cdata): Although this may seem extremely redundant, it is required in
|
||||
// order to support overloading when TypeScript is compiled to ES5
|
||||
// @see https://github.com/Polymer/lit-element/pull/745
|
||||
// @see https://github.com/microsoft/TypeScript/issues/338
|
||||
[$getLoaded](): boolean {
|
||||
return this[$loaded];
|
||||
}
|
||||
|
||||
// @see [$getLoaded]
|
||||
[$getModelIsVisible](): boolean {
|
||||
return this.loaded && this[$isElementInViewport];
|
||||
}
|
||||
|
||||
[$shouldAttemptPreload](): boolean {
|
||||
return !!this.src && this[$isElementInViewport];
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on initialization and when the resize observer fires.
|
||||
*/
|
||||
[$updateSize]({width, height}: {width: number, height: number}) {
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
this[$container].style.width = `${width}px`;
|
||||
this[$container].style.height = `${height}px`;
|
||||
|
||||
this[$onResize]({width, height});
|
||||
}
|
||||
|
||||
[$tick](time: number, delta: number) {
|
||||
this[$scene].effectRenderer?.beforeRender(time, delta);
|
||||
}
|
||||
|
||||
[$markLoaded]() {
|
||||
if (this[$loaded]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[$loaded] = true;
|
||||
this[$loadedTime] = performance.now();
|
||||
}
|
||||
|
||||
[$needsRender]() {
|
||||
this[$scene].queueRender();
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
}
|
||||
|
||||
[$updateStatus](status: string) {
|
||||
this[$status] = status;
|
||||
const rootNode = this.getRootNode() as Document | ShadowRoot | null;
|
||||
// Only change the aria-label if <model-viewer> is currently focused:
|
||||
if (rootNode != null && rootNode.activeElement === this &&
|
||||
this[$statusElement].textContent != status) {
|
||||
this[$statusElement].textContent = status;
|
||||
}
|
||||
}
|
||||
|
||||
[$onFocus] = () => {
|
||||
this[$statusElement].textContent = this[$status];
|
||||
};
|
||||
|
||||
[$onBlur] = () => {
|
||||
this[$statusElement].textContent = '';
|
||||
};
|
||||
|
||||
[$onResize](e: {width: number, height: number}) {
|
||||
this[$scene].setSize(e.width, e.height);
|
||||
}
|
||||
|
||||
[$onContextLost] = (event: ContextLostEvent) => {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
'error',
|
||||
{detail: {type: 'webglcontextlost', sourceError: event.sourceEvent}}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the element for an appropriate source URL and
|
||||
* sets the views to use the new model based.
|
||||
*/
|
||||
async[$updateSource]() {
|
||||
const scene = this[$scene];
|
||||
if (this.loaded || !this[$shouldAttemptPreload]() ||
|
||||
this.src === scene.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.generateSchema) {
|
||||
scene.updateSchema(this.src);
|
||||
}
|
||||
this[$updateStatus]('Loading');
|
||||
// If we are loading a new model, we need to stop the animation of
|
||||
// the current one (if any is playing). Otherwise, we might lose
|
||||
// the reference to the scene root and running actions start to
|
||||
// throw exceptions and/or behave in unexpected ways:
|
||||
scene.stopAnimation();
|
||||
|
||||
const updateSourceProgress =
|
||||
this[$progressTracker].beginActivity('model-load');
|
||||
const source = this.src;
|
||||
try {
|
||||
const srcUpdated = scene.setSource(
|
||||
source,
|
||||
(progress: number) =>
|
||||
updateSourceProgress(clamp(progress, 0, 1) * 0.95));
|
||||
|
||||
const envUpdated = (this as any)[$updateEnvironment]();
|
||||
|
||||
await Promise.all([srcUpdated, envUpdated]);
|
||||
|
||||
this[$markLoaded]();
|
||||
this[$onModelLoad]();
|
||||
|
||||
this.updateComplete.then(() => {
|
||||
this.dispatchEvent(new CustomEvent('before-render'));
|
||||
});
|
||||
|
||||
// Wait for shaders to compile and pixels to be drawn.
|
||||
await new Promise<void>(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('load', {detail: {url: source}}));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
'error', {detail: {type: 'loadfailure', sourceError: error}}));
|
||||
} finally {
|
||||
updateSourceProgress(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {AnimationMixin} from './features/animation.js';
|
||||
import {AnnotationMixin} from './features/annotation.js';
|
||||
import {ARMixin} from './features/ar.js';
|
||||
import {ControlsMixin} from './features/controls.js';
|
||||
import {EnvironmentMixin} from './features/environment.js';
|
||||
import {LoadingMixin} from './features/loading.js';
|
||||
import {SceneGraphMixin} from './features/scene-graph.js';
|
||||
import {StagingMixin} from './features/staging.js';
|
||||
import ModelViewerElementBase from './model-viewer-base.js';
|
||||
|
||||
// Export these to allow lazy-loaded LottieLoader.js to find what it needs.
|
||||
// Requires an import map - "three": "path/to/model-viewer.min.js".
|
||||
export {CanvasTexture, FileLoader, Loader, NearestFilter} from 'three';
|
||||
|
||||
export const ModelViewerElement =
|
||||
AnnotationMixin(SceneGraphMixin(StagingMixin(EnvironmentMixin(ControlsMixin(
|
||||
ARMixin(LoadingMixin(AnimationMixin(ModelViewerElementBase))))))));
|
||||
|
||||
export type ModelViewerElement = InstanceType<typeof ModelViewerElement>;
|
||||
|
||||
export type{RGB, RGBA} from './three-components/gltf-instance/gltf-2.0';
|
||||
|
||||
customElements.define('model-viewer', ModelViewerElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'model-viewer': ModelViewerElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {NumberNode, ZERO} from './parsers.js';
|
||||
|
||||
/**
|
||||
* Ensures that a given number is expressed in radians. If the number is already
|
||||
* in radians, does nothing. If the value is in degrees, converts it to radians.
|
||||
* If the value has no specified unit, the unit is assumed to be radians. If the
|
||||
* value is not in radians or degrees, the value is resolved as 0 radians.
|
||||
*
|
||||
* Also accepts a second argument that is a default value to use if the input
|
||||
* numberNode number is NaN or Infinity.
|
||||
*/
|
||||
export const degreesToRadians =
|
||||
(numberNode: NumberNode, fallbackRadianValue: number = 0): NumberNode => {
|
||||
let {number, unit} = numberNode;
|
||||
if (!isFinite(number)) {
|
||||
number = fallbackRadianValue;
|
||||
unit = 'rad';
|
||||
} else if (numberNode.unit === 'rad' || numberNode.unit == null) {
|
||||
return numberNode;
|
||||
}
|
||||
|
||||
const valueIsDegrees = unit === 'deg' && number != null;
|
||||
const value = valueIsDegrees ? number : 0;
|
||||
const radians = value * Math.PI / 180;
|
||||
|
||||
return {type: 'number', number: radians, unit: 'rad'};
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures that a given number is expressed in degrees. If the number is already
|
||||
* in degrees, does nothing. If the value is in radians or has no specified
|
||||
* unit, converts it to degrees. If the value is not in radians or degrees, the
|
||||
* value is resolved as 0 degrees.
|
||||
*
|
||||
* Also accepts a second argument that is a default value to use if the input
|
||||
* numberNode number is NaN or Infinity.
|
||||
*/
|
||||
export const radiansToDegrees =
|
||||
(numberNode: NumberNode, fallbackDegreeValue: number = 0): NumberNode => {
|
||||
let {number, unit} = numberNode;
|
||||
|
||||
if (!isFinite(number)) {
|
||||
number = fallbackDegreeValue;
|
||||
unit = 'deg';
|
||||
} else if (numberNode.unit === 'deg') {
|
||||
return numberNode;
|
||||
}
|
||||
|
||||
const valueIsRadians =
|
||||
(unit === null || unit === 'rad') && number != null;
|
||||
|
||||
const value = valueIsRadians ? number : 0;
|
||||
const degrees = value * 180 / Math.PI;
|
||||
|
||||
return {type: 'number', number: degrees, unit: 'deg'};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a given length to meters. Currently supported input units are
|
||||
* meters, centimeters and millimeters.
|
||||
*
|
||||
* Also accepts a second argument that is a default value to use if the input
|
||||
* numberNode number is NaN or Infinity.
|
||||
*/
|
||||
export const lengthToBaseMeters =
|
||||
(numberNode: NumberNode, fallbackMeterValue: number = 0): NumberNode => {
|
||||
let {number, unit} = numberNode;
|
||||
|
||||
if (!isFinite(number)) {
|
||||
number = fallbackMeterValue;
|
||||
unit = 'm';
|
||||
} else if (numberNode.unit === 'm') {
|
||||
return numberNode;
|
||||
}
|
||||
|
||||
let scale;
|
||||
|
||||
switch (unit) {
|
||||
default:
|
||||
scale = 1;
|
||||
break;
|
||||
case 'cm':
|
||||
scale = 1 / 100;
|
||||
break;
|
||||
case 'mm':
|
||||
scale = 1 / 1000;
|
||||
break;
|
||||
}
|
||||
|
||||
const value = scale * number;
|
||||
return {type: 'number', number: value, unit: 'm'};
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes the unit of a given input number so that it is expressed in a
|
||||
* preferred unit. For length nodes, the return value will be expressed in
|
||||
* meters. For angle nodes, the return value will be expressed in radians.
|
||||
*
|
||||
* Also takes a fallback number that is used when the number value is not a
|
||||
* valid number or when the unit of the given number cannot be normalized.
|
||||
*/
|
||||
export const normalizeUnit = (() => {
|
||||
const identity = (node: NumberNode) => node;
|
||||
const unitNormalizers: {[index: string]: (node: NumberNode) => NumberNode} = {
|
||||
'rad': identity,
|
||||
'deg': degreesToRadians,
|
||||
'm': identity,
|
||||
'mm': lengthToBaseMeters,
|
||||
'cm': lengthToBaseMeters
|
||||
};
|
||||
|
||||
return (node: NumberNode, fallback: NumberNode = ZERO) => {
|
||||
if (!isFinite(node.number)) {
|
||||
node.number = fallback.number;
|
||||
node.unit = fallback.unit;
|
||||
}
|
||||
|
||||
const {unit} = node;
|
||||
|
||||
if (unit == null) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const normalize = unitNormalizers[unit];
|
||||
|
||||
if (normalize == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return normalize(node);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {IdentNode, parseExpressions} from './parsers.js';
|
||||
|
||||
|
||||
/**
|
||||
* For our purposes, an enumeration is a fixed set of CSS-expression-compatible
|
||||
* names. When serialized, a selected subset of the members may be specified as
|
||||
* whitespace-separated strings. An enumeration deserializer is a function that
|
||||
* parses a serialized subset of an enumeration and returns any members that are
|
||||
* found as a Set.
|
||||
*
|
||||
* The following example will produce a deserializer for the days of the
|
||||
* week:
|
||||
*
|
||||
* const deserializeDaysOfTheWeek = enumerationDeserializer([
|
||||
* 'Monday',
|
||||
* 'Tuesday',
|
||||
* 'Wednesday',
|
||||
* 'Thursday',
|
||||
* 'Friday',
|
||||
* 'Saturday',
|
||||
* 'Sunday'
|
||||
* ]);
|
||||
*/
|
||||
export const enumerationDeserializer = <T extends string>(allowedNames: T[]) =>
|
||||
(valueString: string): Set<T> => {
|
||||
try {
|
||||
const expressions = parseExpressions(valueString);
|
||||
const names = (expressions.length ? expressions[0].terms : [])
|
||||
.filter<IdentNode>(
|
||||
(valueNode): valueNode is IdentNode =>
|
||||
valueNode && valueNode.type === 'ident')
|
||||
.map(valueNode => valueNode.value as T)
|
||||
.filter(name => allowedNames.indexOf(name) > -1);
|
||||
|
||||
return new Set<T>(names);
|
||||
} catch (_error) {
|
||||
}
|
||||
return new Set();
|
||||
};
|
||||
@@ -0,0 +1,561 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {normalizeUnit} from './conversions.js';
|
||||
import {ExpressionNode, ExpressionTerm, FunctionNode, IdentNode, NumberNode, numberNode, OperatorNode, Percentage, Unit, ZERO} from './parsers.js';
|
||||
|
||||
export type Evaluatable<T> = Evaluator<T>|T;
|
||||
|
||||
/**
|
||||
* A NumberNodeSequence is a vector of NumberNodes with a specified
|
||||
* sequence of units.
|
||||
*/
|
||||
export type NumberNodeSequence<T extends Array<Unit>, U = never> = {
|
||||
[I in keyof T]:
|
||||
NumberNode&{
|
||||
unit: T[I]|U;
|
||||
};
|
||||
};
|
||||
|
||||
export type Sparse<T> = {
|
||||
[I in keyof T]: null|T[I];
|
||||
};
|
||||
|
||||
/**
|
||||
* Intrinsics describe the metadata required to do four things for any given
|
||||
* type of number-based CSS expression:
|
||||
*
|
||||
* 1. Establish the expected units of a final, evaluated result
|
||||
* 2. Provide a foundational value that percentages should scale against
|
||||
* 3. Describe the analog number values that correspond to various keywords
|
||||
* 4. Have an available concrete value to fallback to when needed
|
||||
*
|
||||
* Intrinsics must always specify a basis and the substitute values for the
|
||||
* keyword 'auto'.
|
||||
*
|
||||
* Intrinsics may optionally specify the substitute values for any additional
|
||||
* number of keywords.
|
||||
*/
|
||||
export interface Intrinsics<T extends Array<Unit> = []> {
|
||||
basis: NumberNodeSequence<T>;
|
||||
keywords: {
|
||||
auto: Sparse<NumberNodeSequence<T, Percentage>>;
|
||||
[index: string]: Sparse<NumberNodeSequence<T, Percentage>>;
|
||||
};
|
||||
}
|
||||
|
||||
const $evaluate = Symbol('evaluate');
|
||||
const $lastValue = Symbol('lastValue');
|
||||
|
||||
/**
|
||||
* An Evaluator is used to derive a computed style from part (or all) of a CSS
|
||||
* expression AST. This construct is particularly useful for complex ASTs
|
||||
* containing function calls such as calc, var and env. Such styles could be
|
||||
* costly to re-evaluate on every frame (and in some cases we may try to do
|
||||
* that). The Evaluator construct allows us to mark sub-trees of the AST as
|
||||
* constant, so that only the dynamic parts are re-evaluated. It also separates
|
||||
* one-time AST preparation work from work that necessarily has to happen upon
|
||||
* each evaluation.
|
||||
*/
|
||||
export abstract class Evaluator<T> {
|
||||
/**
|
||||
* An Evaluatable is a NumberNode or an Evaluator that evaluates a NumberNode
|
||||
* as the result of invoking its evaluate method. This is mainly used to
|
||||
* ensure that CSS function nodes are cast to the corresponding Evaluators
|
||||
* that will resolve the result of the function, but is also used to ensure
|
||||
* that a percentage nested at arbitrary depth in the expression will always
|
||||
* be evaluated against the correct basis.
|
||||
*/
|
||||
static evaluatableFor(
|
||||
node: ExpressionTerm|Evaluator<NumberNode>,
|
||||
basis: NumberNode = ZERO): Evaluatable<NumberNode> {
|
||||
if (node instanceof Evaluator) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.type === 'number') {
|
||||
if (node.unit === '%') {
|
||||
return new PercentageEvaluator(node as NumberNode<'%'>, basis);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
switch ((node as FunctionNode).name.value) {
|
||||
case 'calc':
|
||||
return new CalcEvaluator(node as FunctionNode, basis);
|
||||
case 'env':
|
||||
return new EnvEvaluator(node as FunctionNode);
|
||||
}
|
||||
|
||||
return ZERO;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the input is an Evaluator, returns the result of evaluating it.
|
||||
* Otherwise, returns the input.
|
||||
*
|
||||
* This is a helper to aide in resolving a NumberNode without conditionally
|
||||
* checking if the Evaluatable is an Evaluator everywhere.
|
||||
*/
|
||||
static evaluate<T extends NumberNode|IdentNode>(evaluatable: Evaluatable<T>):
|
||||
T {
|
||||
if (evaluatable instanceof Evaluator) {
|
||||
return evaluatable.evaluate();
|
||||
}
|
||||
|
||||
return evaluatable;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the input is an Evaluator, returns the value of its isConstant property.
|
||||
* Returns true for all other input values.
|
||||
*/
|
||||
static isConstant<T>(evaluatable: Evaluatable<T>): boolean {
|
||||
if (evaluatable instanceof Evaluator) {
|
||||
return evaluatable.isConstant;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method applies a set of structured intrinsic metadata to an evaluated
|
||||
* result from a parsed CSS-like string of expressions. Intrinsics provide
|
||||
* sufficient metadata (e.g., basis values, analogs for keywords) such that
|
||||
* omitted values in the input string can be backfilled, and keywords can be
|
||||
* converted to concrete numbers.
|
||||
*
|
||||
* The result of applying intrinsics is a tuple of NumberNode values whose
|
||||
* units match the units used by the basis of the intrinsics.
|
||||
*
|
||||
* The following is a high-level description of how intrinsics are applied:
|
||||
*
|
||||
* 1. Determine the value of 'auto' for the current term
|
||||
* 2. If there is no corresponding input value for this term, substitute the
|
||||
* 'auto' value.
|
||||
* 3. If the term is an IdentNode, treat it as a keyword and perform the
|
||||
* appropriate substitution.
|
||||
* 4. If the term is still null, fallback to the 'auto' value
|
||||
* 5. If the term is a percentage, apply it to the basis and return that
|
||||
* value
|
||||
* 6. Normalize the unit of the term
|
||||
* 7. If the term's unit does not match the basis unit, return the basis
|
||||
* value
|
||||
* 8. Return the term as is
|
||||
*/
|
||||
static applyIntrinsics<T extends Array<Unit>>(
|
||||
evaluated: Array<any>, intrinsics: Intrinsics<T>): NumberNodeSequence<T> {
|
||||
const {basis, keywords} = intrinsics;
|
||||
const {auto} = keywords;
|
||||
|
||||
return basis.map<NumberNode>((basisNode, index) => {
|
||||
// Use an auto value if we have it, otherwise the auto value is the basis:
|
||||
const autoSubstituteNode = auto[index] == null ? basisNode : auto[index];
|
||||
|
||||
// If the evaluated nodes do not have a node at the current
|
||||
// index, fallback to the "auto" substitute right away:
|
||||
let evaluatedNode =
|
||||
evaluated[index] ? evaluated[index] : autoSubstituteNode;
|
||||
|
||||
// Any ident node is considered a keyword:
|
||||
if (evaluatedNode.type === 'ident') {
|
||||
const keyword = evaluatedNode.value;
|
||||
// Substitute any keywords for concrete values first:
|
||||
if (keyword in keywords) {
|
||||
evaluatedNode = keywords[keyword][index];
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a NumberNode at this point, fall back to whatever
|
||||
// is specified for auto:
|
||||
if (evaluatedNode == null || evaluatedNode.type === 'ident') {
|
||||
evaluatedNode = autoSubstituteNode;
|
||||
}
|
||||
|
||||
// For percentages, we always apply the percentage to the basis value:
|
||||
if (evaluatedNode.unit === '%') {
|
||||
return numberNode(
|
||||
evaluatedNode.number / 100 * basisNode.number, basisNode.unit);
|
||||
}
|
||||
|
||||
// Otherwise, normalize whatever we have:
|
||||
evaluatedNode = normalizeUnit(evaluatedNode, basisNode);
|
||||
|
||||
// If the normalized units do not match, return the basis as a fallback:
|
||||
if (evaluatedNode.unit !== basisNode.unit) {
|
||||
return basisNode;
|
||||
}
|
||||
|
||||
// Finally, return the evaluated node with intrinsics applied:
|
||||
return evaluatedNode;
|
||||
}) as NumberNodeSequence<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, the Evaluator will only evaluate its AST one time. If false, the
|
||||
* Evaluator will re-evaluate the AST each time that the public evaluate
|
||||
* method is invoked.
|
||||
*/
|
||||
get isConstant(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected[$lastValue]: T|null = null;
|
||||
|
||||
/**
|
||||
* This method must be implemented by subclasses. Its implementation should be
|
||||
* the actual steps to evaluate the AST, and should return the evaluated
|
||||
* result.
|
||||
*/
|
||||
protected abstract[$evaluate](): T;
|
||||
|
||||
/**
|
||||
* Evaluate the Evaluator and return the result. If the Evaluator is constant,
|
||||
* the corresponding AST will only be evaluated once, and the result of
|
||||
* evaluating it the first time will be returned on all subsequent
|
||||
* evaluations.
|
||||
*/
|
||||
evaluate(): T {
|
||||
if (!this.isConstant || this[$lastValue] == null) {
|
||||
this[$lastValue] = this[$evaluate]();
|
||||
}
|
||||
return this[$lastValue]!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const $percentage = Symbol('percentage');
|
||||
const $basis = Symbol('basis');
|
||||
|
||||
|
||||
/**
|
||||
* A PercentageEvaluator scales a given basis value by a given percentage value.
|
||||
* The evaluated result is always considered to be constant.
|
||||
*/
|
||||
export class PercentageEvaluator extends Evaluator<NumberNode> {
|
||||
protected[$percentage]: NumberNode<'%'>;
|
||||
protected[$basis]: NumberNode;
|
||||
|
||||
constructor(percentage: NumberNode<'%'>, basis: NumberNode) {
|
||||
super();
|
||||
|
||||
this[$percentage] = percentage;
|
||||
this[$basis] = basis;
|
||||
}
|
||||
|
||||
get isConstant() {
|
||||
return true;
|
||||
}
|
||||
|
||||
[$evaluate]() {
|
||||
return numberNode(
|
||||
this[$percentage].number / 100 * this[$basis].number,
|
||||
this[$basis].unit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const $identNode = Symbol('identNode');
|
||||
|
||||
/**
|
||||
* Evaluator for CSS-like env() functions. Currently, only one environment
|
||||
* variable is accepted as an argument for such functions: window-scroll-y.
|
||||
*
|
||||
* The env() Evaluator is explicitly dynamic because it always refers to
|
||||
* external state that changes as the user scrolls, so it should always be
|
||||
* re-evaluated to ensure we get the most recent value.
|
||||
*
|
||||
* Some important notes about this feature include:
|
||||
*
|
||||
* - There is no such thing as a "window-scroll-y" CSS environment variable in
|
||||
* any stable browser at the time that this comment is being written.
|
||||
* - The actual CSS env() function accepts a second argument as a fallback for
|
||||
* the case that the specified first argument isn't set; our syntax does not
|
||||
* support this second argument.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/env
|
||||
*/
|
||||
export class EnvEvaluator extends Evaluator<NumberNode> {
|
||||
protected[$identNode]: IdentNode|null = null;
|
||||
|
||||
constructor(envFunction: FunctionNode) {
|
||||
super();
|
||||
|
||||
const identNode =
|
||||
envFunction.arguments.length ? envFunction.arguments[0].terms[0] : null;
|
||||
|
||||
if (identNode != null && identNode.type === 'ident') {
|
||||
this[$identNode] = identNode;
|
||||
}
|
||||
}
|
||||
|
||||
get isConstant(): boolean {
|
||||
return false;
|
||||
};
|
||||
|
||||
[$evaluate](): NumberNode {
|
||||
if (this[$identNode] != null) {
|
||||
switch (this[$identNode]!.value) {
|
||||
case 'window-scroll-y':
|
||||
const verticalScrollPosition = window.pageYOffset;
|
||||
const verticalScrollMax = Math.max(
|
||||
document.body.scrollHeight,
|
||||
document.body.offsetHeight,
|
||||
document.documentElement.clientHeight,
|
||||
document.documentElement.scrollHeight,
|
||||
document.documentElement.offsetHeight);
|
||||
const scrollY = verticalScrollPosition /
|
||||
(verticalScrollMax - window.innerHeight) ||
|
||||
0;
|
||||
|
||||
return {type: 'number', number: scrollY, unit: null};
|
||||
}
|
||||
}
|
||||
|
||||
return ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const IS_MULTIPLICATION_RE = /[\*\/]/;
|
||||
const $evaluator = Symbol('evaluator');
|
||||
|
||||
/**
|
||||
* Evaluator for CSS-like calc() functions. Our implementation of calc()
|
||||
* evaluation currently support nested function calls, an unlimited number of
|
||||
* terms, and all four algebraic operators (+, -, * and /).
|
||||
*
|
||||
* The Evaluator is marked as constant unless the calc expression contains an
|
||||
* internal env expression at any depth, in which case it will be marked as
|
||||
* dynamic.
|
||||
*
|
||||
* @see https://www.w3.org/TR/css-values-3/#calc-syntax
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc
|
||||
*/
|
||||
export class CalcEvaluator extends Evaluator<NumberNode> {
|
||||
protected[$evaluator]: Evaluator<NumberNode>|null = null;
|
||||
|
||||
constructor(calcFunction: FunctionNode, basis: NumberNode = ZERO) {
|
||||
super();
|
||||
|
||||
if (calcFunction.arguments.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terms: Array<ExpressionTerm> =
|
||||
calcFunction.arguments[0].terms.slice();
|
||||
const secondOrderTerms: Array<ExpressionTerm|Evaluator<NumberNode>> = [];
|
||||
|
||||
while (terms.length) {
|
||||
const term: ExpressionTerm = terms.shift()!;
|
||||
|
||||
if (secondOrderTerms.length > 0) {
|
||||
const previousTerm =
|
||||
secondOrderTerms[secondOrderTerms.length - 1] as ExpressionTerm;
|
||||
if (previousTerm.type === 'operator' &&
|
||||
IS_MULTIPLICATION_RE.test(previousTerm.value)) {
|
||||
const operator = secondOrderTerms.pop() as OperatorNode;
|
||||
const leftValue = secondOrderTerms.pop();
|
||||
|
||||
if (leftValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
secondOrderTerms.push(new OperatorEvaluator(
|
||||
operator,
|
||||
Evaluator.evaluatableFor(leftValue, basis),
|
||||
Evaluator.evaluatableFor(term, basis)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
secondOrderTerms.push(
|
||||
term.type === 'operator' ? term :
|
||||
Evaluator.evaluatableFor(term, basis));
|
||||
}
|
||||
|
||||
while (secondOrderTerms.length > 2) {
|
||||
const [left, operator, right] = secondOrderTerms.splice(0, 3);
|
||||
if ((operator as ExpressionTerm).type !== 'operator') {
|
||||
return;
|
||||
}
|
||||
|
||||
secondOrderTerms.unshift(new OperatorEvaluator(
|
||||
operator as OperatorNode,
|
||||
Evaluator.evaluatableFor(left, basis),
|
||||
Evaluator.evaluatableFor(right, basis)));
|
||||
}
|
||||
|
||||
// There should only be one combined evaluator at this point:
|
||||
if (secondOrderTerms.length === 1) {
|
||||
this[$evaluator] = secondOrderTerms[0] as Evaluator<NumberNode>;
|
||||
}
|
||||
}
|
||||
|
||||
get isConstant() {
|
||||
return this[$evaluator] == null || Evaluator.isConstant(this[$evaluator]!);
|
||||
}
|
||||
|
||||
[$evaluate]() {
|
||||
return this[$evaluator] != null ? Evaluator.evaluate(this[$evaluator]!) :
|
||||
ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const $operator = Symbol('operator');
|
||||
const $left = Symbol('left');
|
||||
const $right = Symbol('right');
|
||||
|
||||
/**
|
||||
* An Evaluator for the operators found inside CSS calc() functions.
|
||||
* The evaluator accepts an operator and left/right operands. The operands can
|
||||
* be any valid expression term typically allowed inside a CSS calc function.
|
||||
*
|
||||
* As detail of this implementation, the only supported unit types are angles
|
||||
* expressed as radians or degrees, and lengths expressed as meters, centimeters
|
||||
* or millimeters.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc
|
||||
*/
|
||||
export class OperatorEvaluator extends Evaluator<NumberNode> {
|
||||
protected[$operator]: OperatorNode;
|
||||
protected[$left]: Evaluatable<NumberNode>;
|
||||
protected[$right]: Evaluatable<NumberNode>;
|
||||
|
||||
constructor(
|
||||
operator: OperatorNode, left: Evaluatable<NumberNode>,
|
||||
right: Evaluatable<NumberNode>) {
|
||||
super();
|
||||
this[$operator] = operator;
|
||||
this[$left] = left;
|
||||
this[$right] = right;
|
||||
}
|
||||
|
||||
get isConstant() {
|
||||
return Evaluator.isConstant(this[$left]) &&
|
||||
Evaluator.isConstant(this[$right]);
|
||||
}
|
||||
|
||||
[$evaluate](): NumberNode {
|
||||
const leftNode = normalizeUnit(Evaluator.evaluate(this[$left]));
|
||||
const rightNode = normalizeUnit(Evaluator.evaluate(this[$right]));
|
||||
const {number: leftValue, unit: leftUnit} = leftNode;
|
||||
const {number: rightValue, unit: rightUnit} = rightNode;
|
||||
|
||||
// Disallow operations for mismatched normalized units e.g., m and rad:
|
||||
if (rightUnit != null && leftUnit != null && rightUnit != leftUnit) {
|
||||
return ZERO;
|
||||
}
|
||||
|
||||
// NOTE(cdata): rules for calc type checking are defined here
|
||||
// https://drafts.csswg.org/css-values-3/#calc-type-checking
|
||||
// This is a simplification and may not hold up once we begin to support
|
||||
// additional unit types:
|
||||
const unit = leftUnit || rightUnit;
|
||||
let value;
|
||||
|
||||
switch (this[$operator].value) {
|
||||
case '+':
|
||||
value = leftValue + rightValue;
|
||||
break;
|
||||
case '-':
|
||||
value = leftValue - rightValue;
|
||||
break;
|
||||
case '/':
|
||||
value = leftValue / rightValue;
|
||||
break;
|
||||
case '*':
|
||||
value = leftValue * rightValue;
|
||||
break;
|
||||
default:
|
||||
return ZERO;
|
||||
}
|
||||
|
||||
return {type: 'number', number: value, unit};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type EvaluatedStyle<T extends Intrinsics<Array<Unit>>> = {
|
||||
[I in keyof T['basis']]: number;
|
||||
}&Array<never>;
|
||||
|
||||
const $evaluatables = Symbol('evaluatables');
|
||||
const $intrinsics = Symbol('intrinsics');
|
||||
|
||||
/**
|
||||
* A VectorEvaluator evaluates a series of numeric terms that usually represent
|
||||
* a data structure such as a multi-dimensional vector or a spherical
|
||||
*
|
||||
* The form of the evaluator's result is determined by the Intrinsics that are
|
||||
* given to it when it is constructed. For example, spherical intrinsics would
|
||||
* establish two angle terms and a length term, so the result of evaluating the
|
||||
* evaluator that is configured with spherical intrinsics is a three element
|
||||
* array where the first two elements represent angles in radians and the third
|
||||
* element representing a length in meters.
|
||||
*/
|
||||
export class StyleEvaluator<T extends Intrinsics<Array<any>>> extends
|
||||
Evaluator<EvaluatedStyle<T>> {
|
||||
protected[$intrinsics]: T;
|
||||
protected[$evaluatables]: Array<Evaluatable<NumberNode|IdentNode>>;
|
||||
|
||||
constructor(expressions: Array<ExpressionNode>, intrinsics: T) {
|
||||
super();
|
||||
|
||||
this[$intrinsics] = intrinsics;
|
||||
|
||||
const firstExpression = expressions[0];
|
||||
const terms = firstExpression != null ? firstExpression.terms : [];
|
||||
|
||||
this[$evaluatables] =
|
||||
intrinsics.basis.map<Evaluatable<NumberNode|IdentNode>>(
|
||||
(basisNode, index) => {
|
||||
const term = terms[index];
|
||||
if (term == null) {
|
||||
return {type: 'ident', value: 'auto'};
|
||||
}
|
||||
|
||||
if (term.type === 'ident') {
|
||||
return term;
|
||||
}
|
||||
|
||||
return Evaluator.evaluatableFor(term, basisNode);
|
||||
});
|
||||
}
|
||||
|
||||
get isConstant(): boolean {
|
||||
for (const evaluatable of this[$evaluatables]) {
|
||||
if (!Evaluator.isConstant(evaluatable)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
[$evaluate]() {
|
||||
const evaluated = this[$evaluatables].map<NumberNode|IdentNode>(
|
||||
evaluatable => Evaluator.evaluate(evaluatable));
|
||||
return Evaluator.applyIntrinsics(evaluated, this[$intrinsics])
|
||||
.map<number>(numberNode => numberNode.number) as
|
||||
EvaluatedStyle<T>;
|
||||
}
|
||||
}
|
||||
|
||||
// SphericalIntrinsics are Intrinsics that expect two angle terms
|
||||
// and one length term
|
||||
export type SphericalIntrinsics = Intrinsics<['rad', 'rad', 'm']>;
|
||||
// Vector3Intrinsics expect three length terms
|
||||
export type Vector3Intrinsics = Intrinsics<['m', 'm', 'm']>;
|
||||
@@ -0,0 +1,350 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// The operators that are available in CSS calc() functions
|
||||
// include symbols for addition, subtraction, multiplication and division
|
||||
// @see https://www.w3.org/TR/css-values-3/#calc-syntax
|
||||
export type Operator = '+'|'-'|'*'|'/';
|
||||
|
||||
// We only support units for length in meters, radians and degrees for angles
|
||||
// and percentage values
|
||||
export type Unit = 'm'|'cm'|'mm'|'rad'|'deg';
|
||||
export type Percentage = '%';
|
||||
|
||||
// Any node that might appear in a parsed expression is referred to as an
|
||||
// ExpressionTerm
|
||||
export type ExpressionTerm =
|
||||
IdentNode|HexNode|NumberNode|OperatorNode|FunctionNode;
|
||||
|
||||
export interface IdentNode {
|
||||
type: 'ident';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HexNode {
|
||||
type: 'hex';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface NumberNode<U = Unit | Percentage | null> {
|
||||
type: 'number';
|
||||
number: number;
|
||||
unit: U;
|
||||
}
|
||||
|
||||
export const numberNode =
|
||||
<T extends Unit|Percentage|null>(value: number, unit: T): NumberNode<T> =>
|
||||
({type: 'number', number: value, unit});
|
||||
|
||||
export interface OperatorNode {
|
||||
type: 'operator';
|
||||
value: Operator;
|
||||
}
|
||||
|
||||
export interface FunctionNode {
|
||||
type: 'function';
|
||||
name: IdentNode;
|
||||
arguments: Array<ExpressionNode>;
|
||||
}
|
||||
|
||||
export interface ExpressionNode {
|
||||
type: 'expression';
|
||||
terms: Array<ExpressionTerm>;
|
||||
}
|
||||
|
||||
export type ASTNode =
|
||||
IdentNode|HexNode|NumberNode|OperatorNode|FunctionNode|ExpressionNode;
|
||||
|
||||
// As an internal detail of this module, non-exported parsers return both a
|
||||
// set of nodes and the remaining string input to be parsed. This saves us a bit
|
||||
// of book keeping work and allows our internal parser implementations to remain
|
||||
// essentially stateless.
|
||||
interface ParseResult<T extends ASTNode> {
|
||||
nodes: Array<T>;
|
||||
remainingInput: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a string representing a comma-separated set of CSS-like expressions,
|
||||
* parses and returns an array of ASTs that correspond to those expressions.
|
||||
*
|
||||
* Currently supported syntax includes:
|
||||
*
|
||||
* - functions (top-level and nested)
|
||||
* - calc() arithmetic operators
|
||||
* - numbers with units
|
||||
* - hexadecimal-encoded colors in 3, 6 or 8 digit form
|
||||
* - idents
|
||||
*
|
||||
* All syntax is intended to match the parsing rules and semantics of the actual
|
||||
* CSS spec as closely as possible.
|
||||
*
|
||||
* @see https://www.w3.org/TR/CSS2/
|
||||
* @see https://www.w3.org/TR/css-values-3/
|
||||
*/
|
||||
export const parseExpressions = (() => {
|
||||
const cache: {[index: string]: Array<ExpressionNode>} = {};
|
||||
const MAX_PARSE_ITERATIONS = 1000; // Arbitrarily large
|
||||
|
||||
return (inputString: string): Array<ExpressionNode> => {
|
||||
const cacheKey = inputString;
|
||||
|
||||
if (cacheKey in cache) {
|
||||
return cache[cacheKey];
|
||||
}
|
||||
|
||||
const expressions: Array<ExpressionNode> = [];
|
||||
let parseIterations = 0;
|
||||
|
||||
while (inputString) {
|
||||
if (++parseIterations > MAX_PARSE_ITERATIONS) {
|
||||
// Avoid a potentially infinite loop due to typos:
|
||||
inputString = '';
|
||||
break;
|
||||
}
|
||||
|
||||
const expressionParseResult = parseExpression(inputString);
|
||||
const expression = expressionParseResult.nodes[0];
|
||||
|
||||
if (expression == null || expression.terms.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
expressions.push(expression);
|
||||
inputString = expressionParseResult.remainingInput;
|
||||
}
|
||||
|
||||
return cache[cacheKey] = expressions;
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* Parse a single expression. For the purposes of our supported syntax, an
|
||||
* expression is the set of semantically meaningful terms that appear before the
|
||||
* next comma, or between the parens of a function invocation.
|
||||
*/
|
||||
const parseExpression = (() => {
|
||||
const IS_IDENT_RE = /^(\-\-|[a-z\u0240-\uffff])/i;
|
||||
const IS_OPERATOR_RE = /^([\*\+\/]|[\-]\s)/i;
|
||||
const IS_EXPRESSION_END_RE = /^[\),]/;
|
||||
const FUNCTION_ARGUMENTS_FIRST_TOKEN = '(';
|
||||
const HEX_FIRST_TOKEN = '#';
|
||||
|
||||
return (inputString: string): ParseResult<ExpressionNode> => {
|
||||
const terms: Array<ExpressionTerm> = [];
|
||||
|
||||
while (inputString.length) {
|
||||
inputString = inputString.trim();
|
||||
|
||||
if (IS_EXPRESSION_END_RE.test(inputString)) {
|
||||
break;
|
||||
} else if (inputString[0] === FUNCTION_ARGUMENTS_FIRST_TOKEN) {
|
||||
const {nodes, remainingInput} = parseFunctionArguments(inputString);
|
||||
|
||||
inputString = remainingInput;
|
||||
|
||||
terms.push({
|
||||
type: 'function',
|
||||
name: {type: 'ident', value: 'calc'},
|
||||
arguments: nodes
|
||||
});
|
||||
} else if (IS_IDENT_RE.test(inputString)) {
|
||||
const identParseResult = parseIdent(inputString);
|
||||
const identNode = identParseResult.nodes[0];
|
||||
|
||||
inputString = identParseResult.remainingInput;
|
||||
|
||||
if (inputString[0] === FUNCTION_ARGUMENTS_FIRST_TOKEN) {
|
||||
const {nodes, remainingInput} = parseFunctionArguments(inputString);
|
||||
terms.push({type: 'function', name: identNode, arguments: nodes});
|
||||
inputString = remainingInput;
|
||||
} else {
|
||||
terms.push(identNode);
|
||||
}
|
||||
} else if (IS_OPERATOR_RE.test(inputString)) {
|
||||
// Operators are always a single character, so just pluck them out:
|
||||
terms.push({type: 'operator', value: inputString[0] as Operator});
|
||||
inputString = inputString.slice(1);
|
||||
} else {
|
||||
const {nodes, remainingInput} = inputString[0] === HEX_FIRST_TOKEN ?
|
||||
parseHex(inputString) :
|
||||
parseNumber(inputString);
|
||||
|
||||
// The remaining string may not have had any meaningful content. Exit
|
||||
// early if this is the case:
|
||||
if (nodes.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
terms.push(nodes[0]);
|
||||
inputString = remainingInput;
|
||||
}
|
||||
}
|
||||
|
||||
return {nodes: [{type: 'expression', terms}], remainingInput: inputString};
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* An ident is something like a function name or the keyword "auto".
|
||||
*/
|
||||
const parseIdent = (() => {
|
||||
const NOT_IDENT_RE = /[^a-z0-9_\-\u0240-\uffff]/i;
|
||||
|
||||
return (inputString: string): ParseResult<IdentNode> => {
|
||||
const match = inputString.match(NOT_IDENT_RE);
|
||||
const ident =
|
||||
match == null ? inputString : inputString.substr(0, match.index);
|
||||
const remainingInput =
|
||||
match == null ? '' : inputString.substr(match.index!);
|
||||
|
||||
return {nodes: [{type: 'ident', value: ident}], remainingInput};
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* Parses a number. A number value can be expressed with an integer or
|
||||
* non-integer syntax, and usually includes a unit (but does not strictly
|
||||
* require one for our purposes).
|
||||
*/
|
||||
const parseNumber = (() => {
|
||||
// @see https://www.w3.org/TR/css-syntax/#number-token-diagram
|
||||
const VALUE_RE = /[\+\-]?(\d+[\.]\d+|\d+|[\.]\d+)([eE][\+\-]?\d+)?/;
|
||||
const UNIT_RE = /^[a-z%]+/i;
|
||||
const ALLOWED_UNITS = /^(m|mm|cm|rad|deg|[%])$/;
|
||||
|
||||
return (inputString: string): ParseResult<NumberNode> => {
|
||||
const valueMatch = inputString.match(VALUE_RE);
|
||||
const value = valueMatch == null ? '0' : valueMatch[0];
|
||||
inputString = value == null ? inputString : inputString.slice(value.length);
|
||||
|
||||
const unitMatch = inputString.match(UNIT_RE);
|
||||
let unit = unitMatch != null && unitMatch[0] !== '' ? unitMatch[0] : null;
|
||||
const remainingInput =
|
||||
unitMatch == null ? inputString : inputString.slice(unit!.length);
|
||||
|
||||
if (unit != null && !ALLOWED_UNITS.test(unit)) {
|
||||
unit = null;
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [{
|
||||
type: 'number',
|
||||
number: parseFloat(value) || 0,
|
||||
unit: unit as Unit | Percentage | null
|
||||
}],
|
||||
remainingInput
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* Parses a hexadecimal-encoded color in 3, 6 or 8 digit form.
|
||||
*/
|
||||
const parseHex = (() => {
|
||||
// TODO(cdata): right now we don't actually enforce the number of digits
|
||||
const HEX_RE = /^[a-f0-9]*/i;
|
||||
|
||||
return (inputString: string): ParseResult<HexNode> => {
|
||||
inputString = inputString.slice(1).trim();
|
||||
const hexMatch = inputString.match(HEX_RE);
|
||||
const nodes: Array<HexNode> =
|
||||
hexMatch == null ? [] : [{type: 'hex', value: hexMatch[0]}];
|
||||
|
||||
return {
|
||||
nodes,
|
||||
remainingInput: hexMatch == null ? inputString :
|
||||
inputString.slice(hexMatch[0].length)
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* Parses arguments passed to a function invocation (e.g., the expressions
|
||||
* within a matched set of parens).
|
||||
*/
|
||||
const parseFunctionArguments =
|
||||
(inputString: string): ParseResult<ExpressionNode> => {
|
||||
const expressionNodes: Array<ExpressionNode> = [];
|
||||
|
||||
// Consume the opening paren
|
||||
inputString = inputString.slice(1).trim();
|
||||
|
||||
while (inputString.length) {
|
||||
const expressionParseResult = parseExpression(inputString);
|
||||
expressionNodes.push(expressionParseResult.nodes[0]);
|
||||
inputString = expressionParseResult.remainingInput.trim();
|
||||
|
||||
if (inputString[0] === ',') {
|
||||
inputString = inputString.slice(1).trim();
|
||||
} else if (inputString[0] === ')') {
|
||||
// Consume the closing paren and stop parsing
|
||||
inputString = inputString.slice(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {nodes: expressionNodes, remainingInput: inputString};
|
||||
};
|
||||
|
||||
|
||||
export type ASTWalkerCallback<T> = (node: T) => void;
|
||||
|
||||
const $visitedTypes = Symbol('visitedTypes');
|
||||
|
||||
/**
|
||||
* An ASTWalker walks an array of ASTs such as the type produced by
|
||||
* parseExpressions and invokes a callback for a configured set of nodes that
|
||||
* the user wishes to "visit" during the walk.
|
||||
*/
|
||||
export class ASTWalker<T extends ASTNode> {
|
||||
protected[$visitedTypes]: Array<string>;
|
||||
constructor(visitedTypes: Array<string>) {
|
||||
this[$visitedTypes] = visitedTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the given set of ASTs, and invoke the provided callback for nodes that
|
||||
* match the filtered set that the ASTWalker was constructed with.
|
||||
*/
|
||||
walk(ast: Array<ExpressionNode>, callback: ASTWalkerCallback<T>) {
|
||||
const remaining: Array<ASTNode> = ast.slice();
|
||||
|
||||
while (remaining.length) {
|
||||
const next = remaining.shift()!;
|
||||
|
||||
if (this[$visitedTypes].indexOf(next.type) > -1) {
|
||||
callback(next as T);
|
||||
}
|
||||
|
||||
switch (next.type) {
|
||||
case 'expression':
|
||||
remaining.unshift(...next.terms);
|
||||
break;
|
||||
case 'function':
|
||||
remaining.unshift(next.name, ...next.arguments);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ZERO: NumberNode =
|
||||
Object.freeze({type: 'number', number: 0, unit: null});
|
||||
@@ -0,0 +1,188 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ASTWalker, ExpressionNode, FunctionNode} from './parsers.js';
|
||||
|
||||
interface AnyObserver {
|
||||
observe(): void;
|
||||
disconnect(): void;
|
||||
}
|
||||
|
||||
const $instances = Symbol('instances');
|
||||
const $activateListener = Symbol('activateListener');
|
||||
const $deactivateListener = Symbol('deactivateListener');
|
||||
const $notifyInstances = Symbol('notifyInstances');
|
||||
const $notify = Symbol('notify');
|
||||
const $scrollCallback = Symbol('callback');
|
||||
|
||||
type ScrollObserverCallback = () => void;
|
||||
|
||||
/**
|
||||
* This internal helper is intended to work as a reference-counting manager of
|
||||
* scroll event listeners. Only one scroll listener is ever registered for all
|
||||
* instances of the class, and when the last ScrollObserver "disconnects", that
|
||||
* event listener is removed. This spares us from thrashing
|
||||
* the {add,remove}EventListener API (the binding cost of these methods has been
|
||||
* known to show up in performance analyses) as well as potential memory leaks.
|
||||
*/
|
||||
class ScrollObserver {
|
||||
private static[$notifyInstances]() {
|
||||
for (const instance of ScrollObserver[$instances]) {
|
||||
instance[$notify]();
|
||||
}
|
||||
}
|
||||
private static[$instances]: Set<ScrollObserver> = new Set();
|
||||
private static[$activateListener]() {
|
||||
window.addEventListener('scroll', this[$notifyInstances], {passive: true});
|
||||
}
|
||||
private static[$deactivateListener]() {
|
||||
window.removeEventListener('scroll', this[$notifyInstances]);
|
||||
}
|
||||
|
||||
private[$scrollCallback]: ScrollObserverCallback;
|
||||
|
||||
constructor(callback: ScrollObserverCallback) {
|
||||
this[$scrollCallback] = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for scroll events. The configured callback (passed to the
|
||||
* constructor) will be invoked for subsequent global scroll events.
|
||||
*/
|
||||
observe() {
|
||||
if (ScrollObserver[$instances].size === 0) {
|
||||
ScrollObserver[$activateListener]();
|
||||
}
|
||||
ScrollObserver[$instances].add(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for scroll events.
|
||||
*/
|
||||
disconnect() {
|
||||
ScrollObserver[$instances].delete(this);
|
||||
if (ScrollObserver[$instances].size === 0) {
|
||||
ScrollObserver[$deactivateListener]();
|
||||
}
|
||||
}
|
||||
|
||||
private[$notify]() {
|
||||
this[$scrollCallback]();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export type EnvironmentState = 'window-scroll';
|
||||
|
||||
export type StyleEffectorCallback = (record: EnvironmentChangeRecord) => void;
|
||||
export interface EnvironmentChangeRecord {
|
||||
relatedState: EnvironmentState;
|
||||
}
|
||||
|
||||
type EnvironmentDependencies = {
|
||||
[key in EnvironmentState]?: AnyObserver
|
||||
};
|
||||
|
||||
const $computeStyleCallback = Symbol('computeStyleCallback');
|
||||
const $astWalker = Symbol('astWalker');
|
||||
const $dependencies = Symbol('dependencies');
|
||||
|
||||
const $onScroll = Symbol('onScroll');
|
||||
|
||||
/**
|
||||
* The StyleEffector is configured with a callback that will be invoked at the
|
||||
* optimal time that some array of CSS expression ASTs ought to be evaluated.
|
||||
*
|
||||
* For example, our CSS-like expression syntax supports usage of the env()
|
||||
* function to incorporate the current top-level scroll position into a CSS
|
||||
* expression: env(window-scroll-y).
|
||||
*
|
||||
* This "environment variable" will change dynamically as the user scrolls the
|
||||
* page. If an AST contains such a usage of env(), we would have to evaluate the
|
||||
* AST on every frame in order to be sure that the computed style stays up to
|
||||
* date.
|
||||
*
|
||||
* The StyleEffector spares us from evaluating the expressions on every frame by
|
||||
* correlating specific parts of an AST with observers of the external effects
|
||||
* that they refer to (if any). So, if the AST contains env(window-scroll-y),
|
||||
* the StyleEffector manages the lifetime of a global scroll event listener and
|
||||
* notifies the user at the optimal time to evaluate the computed style.
|
||||
*/
|
||||
export class StyleEffector {
|
||||
protected[$dependencies]: EnvironmentDependencies = {};
|
||||
|
||||
protected[$computeStyleCallback]: StyleEffectorCallback;
|
||||
protected[$astWalker] = new ASTWalker<FunctionNode>(['function']);
|
||||
|
||||
constructor(callback: StyleEffectorCallback) {
|
||||
this[$computeStyleCallback] = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the expressions that govern when the StyleEffector callback will be
|
||||
* invoked.
|
||||
*/
|
||||
observeEffectsFor(ast: Array<ExpressionNode>) {
|
||||
const newDependencies: EnvironmentDependencies = {};
|
||||
const oldDependencies = this[$dependencies];
|
||||
|
||||
this[$astWalker].walk(ast, functionNode => {
|
||||
const {name} = functionNode;
|
||||
const firstArgument = functionNode.arguments[0];
|
||||
const firstTerm = firstArgument.terms[0];
|
||||
|
||||
if (name.value !== 'env' || firstTerm == null ||
|
||||
firstTerm.type !== 'ident') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (firstTerm.value) {
|
||||
case 'window-scroll-y':
|
||||
if (newDependencies['window-scroll'] == null) {
|
||||
const observer = 'window-scroll' in oldDependencies ?
|
||||
oldDependencies['window-scroll'] :
|
||||
new ScrollObserver(this[$onScroll]);
|
||||
observer!.observe();
|
||||
delete oldDependencies['window-scroll'];
|
||||
newDependencies['window-scroll'] = observer;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
for (const environmentState in oldDependencies) {
|
||||
const observer = oldDependencies[environmentState as EnvironmentState]!;
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
this[$dependencies] = newDependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of the StyleEffector by disconnecting all observers of external
|
||||
* effects.
|
||||
*/
|
||||
dispose() {
|
||||
for (const environmentState in this[$dependencies]) {
|
||||
const observer =
|
||||
this[$dependencies][environmentState as EnvironmentState]!;
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
protected[$onScroll] = () => {
|
||||
this[$computeStyleCallback]({relatedState: 'window-scroll'});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {html, render} from 'lit';
|
||||
|
||||
import CloseIcon from './assets/close-material-svg.js';
|
||||
import ControlsPrompt from './assets/controls-svg.js';
|
||||
import ARGlyph from './assets/view-in-ar-material-svg.js';
|
||||
|
||||
const templateResult = html`
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
contain: strict;
|
||||
width: 300px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.userInput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
position: relative;
|
||||
outline-offset: -1px;
|
||||
outline-width: 1px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
/* NOTE(cdata): Chrome 76 and below apparently have a bug
|
||||
* that causes our canvas not to display pixels unless it is
|
||||
* on its own render layer
|
||||
* @see https://github.com/google/model-viewer/pull/755#issuecomment-536597893
|
||||
*/
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Adapted from HTML5 Boilerplate
|
||||
*
|
||||
* @see https://github.com/h5bp/html5-boilerplate/blob/ceb4620c78fc82e13534fc44202a3f168754873f/dist/css/main.css#L122-L133 */
|
||||
.screen-reader-only {
|
||||
border: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slot > * {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.annotation-wrapper ::slotted(*) {
|
||||
opacity: var(--max-hotspot-opacity, 1);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.pointer-tumbling .annotation-wrapper ::slotted(*) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.annotation-wrapper ::slotted(*) {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.annotation-wrapper.hide ::slotted(*) {
|
||||
opacity: var(--min-hotspot-opacity, 0.25);
|
||||
}
|
||||
|
||||
.slot.poster {
|
||||
display: none;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.slot.poster.show {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.slot.poster > * {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.slot.poster:not(.show) > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#default-poster {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* The default poster is a <button> so we need to set display
|
||||
* to prevent it from being affected by text-align: */
|
||||
display: block;
|
||||
position: absolute;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-color: #fff0;
|
||||
}
|
||||
|
||||
#default-progress-bar {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#default-progress-bar > .bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: var(--progress-bar-height, 5px);
|
||||
background-color: var(--progress-bar-color, rgba(0, 0, 0, 0.4));
|
||||
transition: transform 0.09s;
|
||||
transform-origin: top left;
|
||||
transform: scaleX(0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#default-progress-bar > .bar.hide {
|
||||
transition: opacity 0.3s 1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.centered {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.interaction-prompt {
|
||||
display: var(--interaction-prompt-display, flex);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.slot.interaction-prompt.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.animated-container {
|
||||
will-change: transform, opacity;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.slot.interaction-prompt > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.ar-button {
|
||||
-moz-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
|
||||
display: var(--ar-button-display, block);
|
||||
}
|
||||
|
||||
.slot.ar-button:not(.enabled) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.fab > * {
|
||||
opacity: 0.87;
|
||||
}
|
||||
|
||||
#default-ar-button {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
transform: scale(var(--ar-button-scale, 1));
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.slot.pan-target {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
background-color: transparent;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
#default-pan-target {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0px 0px 2px 1px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.slot.default {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.progress-bar {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.exit-webxr-ar-button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.exit-webxr-ar-button:not(.enabled) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#default-exit-webxr-ar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: env(safe-area-inset-top, 16px);
|
||||
right: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#default-exit-webxr-ar-button > svg {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<div class="userInput" tabindex="0" role="img"
|
||||
aria-label="3D model">
|
||||
<div class="slot canvas">
|
||||
<slot name="canvas">
|
||||
<canvas></canvas>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- NOTE(cdata): We need to wrap slots because browsers without ShadowDOM
|
||||
will have their <slot> elements removed by ShadyCSS -->
|
||||
<div class="slot poster">
|
||||
<slot name="poster">
|
||||
<button type="button" id="default-poster" aria-hidden="true" aria-label="Loading 3D model"></button>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot ar-button">
|
||||
<slot name="ar-button">
|
||||
<a id="default-ar-button" part="default-ar-button" class="fab"
|
||||
tabindex="2"
|
||||
role="button"
|
||||
href="javascript:void(0);"
|
||||
aria-label="View in your space">
|
||||
${ARGlyph}
|
||||
</a>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot pan-target">
|
||||
<slot name="pan-target">
|
||||
<div id="default-pan-target">
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot interaction-prompt cover centered">
|
||||
<div id="prompt" class="animated-container">
|
||||
<slot name="interaction-prompt" aria-hidden="true">
|
||||
${ControlsPrompt}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="finger0" class="animated-container cover">
|
||||
<slot name="finger0" aria-hidden="true">
|
||||
</slot>
|
||||
</div>
|
||||
<div id="finger1" class="animated-container cover">
|
||||
<slot name="finger1" aria-hidden="true">
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot default">
|
||||
<slot></slot>
|
||||
|
||||
<div class="slot progress-bar">
|
||||
<slot name="progress-bar">
|
||||
<div id="default-progress-bar" aria-hidden="true">
|
||||
<div class="bar" part="default-progress-bar"></div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot exit-webxr-ar-button">
|
||||
<slot name="exit-webxr-ar-button">
|
||||
<a id="default-exit-webxr-ar-button" part="default-exit-webxr-ar-button"
|
||||
tabindex="3"
|
||||
aria-label="Exit AR"
|
||||
aria-hidden="true">
|
||||
${CloseIcon}
|
||||
</a>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="screen-reader-only" role="region" aria-label="Live announcements">
|
||||
<span id="status" role="status"></span>
|
||||
</div>`;
|
||||
|
||||
export const makeTemplate = (shadowRoot: ShadowRoot) => {
|
||||
render(templateResult, shadowRoot);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {expect} from 'chai';
|
||||
import {ReactiveElement} from 'lit';
|
||||
import {property} from 'lit/decorators.js';
|
||||
|
||||
import {style} from '../decorators.js';
|
||||
import {numberNode} from '../styles/parsers.js';
|
||||
import {timePasses} from '../utilities.js';
|
||||
|
||||
const $updateFoo = Symbol('updateFoo');
|
||||
|
||||
const fooIntrinsics = {
|
||||
basis: [numberNode(1, 'm'), numberNode(Math.PI, 'rad')],
|
||||
keywords: {auto: [null, numberNode(200, '%')]}
|
||||
};
|
||||
|
||||
class StylableElement extends ReactiveElement {
|
||||
@style({intrinsics: fooIntrinsics, updateHandler: $updateFoo})
|
||||
@property({type: String})
|
||||
foo: string = '200cm 1rad';
|
||||
|
||||
fooUpdates: Array<[number, number]> = [];
|
||||
|
||||
[$updateFoo](style: [number, number]) {
|
||||
this.fooUpdates.push(style);
|
||||
}
|
||||
}
|
||||
|
||||
suite('decorators', () => {
|
||||
suite('@style', () => {
|
||||
let instance = 0;
|
||||
let tagName: string;
|
||||
let element: StylableElement;
|
||||
|
||||
setup(async () => {
|
||||
tagName = `stylable-element-${instance++}`;
|
||||
customElements.define(tagName, class extends StylableElement {});
|
||||
|
||||
element = document.createElement(tagName) as StylableElement;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('invokes the update handler with the parsed default value', () => {
|
||||
expect(element.fooUpdates).to.be.eql([[2, 1]]);
|
||||
});
|
||||
|
||||
test(
|
||||
'invokes the update handler once with a parsed updated value',
|
||||
async () => {
|
||||
element.foo = '1m auto';
|
||||
await timePasses();
|
||||
expect(element.fooUpdates).to.be.eql([[2, 1], [1, 2 * Math.PI]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath} from '../helpers.js';
|
||||
|
||||
const TOLERANCE_SEC = 0.1;
|
||||
const NON_ANIMATED_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
const ANIMATED_GLB_PATH = assetPath('models/RobotExpressive.glb');
|
||||
const ANIMATED_GLB_DUPLICATE_ANIMATION_NAMES_PATH =
|
||||
assetPath('models/DuplicateAnimationNames.glb');
|
||||
|
||||
const animationIsPlaying = (element: any, animationName?: string): boolean => {
|
||||
const {currentAnimationAction} = element[$scene];
|
||||
|
||||
if (currentAnimationAction != null &&
|
||||
(animationName == null ||
|
||||
currentAnimationAction.getClip().name === animationName)) {
|
||||
return element.paused === false &&
|
||||
currentAnimationAction.enabled === true &&
|
||||
!currentAnimationAction.paused;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const animationWithIndexIsPlaying = (element: any, animationIndex = 0):
|
||||
boolean => {
|
||||
const {currentAnimationAction} = element[$scene];
|
||||
const {_currentGLTF} = element[$scene];
|
||||
|
||||
if (currentAnimationAction != null && animationIndex >= 0 &&
|
||||
animationIndex < _currentGLTF.animations.length &&
|
||||
currentAnimationAction.getClip() ==
|
||||
_currentGLTF.animations[animationIndex]) {
|
||||
return element.paused === false &&
|
||||
currentAnimationAction.enabled === true &&
|
||||
!currentAnimationAction.paused;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
suite('Animation', () => {
|
||||
suite('a model with animations', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = ANIMATED_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('remains in a paused state', () => {
|
||||
expect(element.paused).to.be.true;
|
||||
});
|
||||
|
||||
suite('when play is invoked with no options', () => {
|
||||
setup(async () => {
|
||||
const animationsPlay = waitForEvent(element, 'play');
|
||||
element.play();
|
||||
await animationsPlay;
|
||||
});
|
||||
|
||||
test('animations play', async () => {
|
||||
expect(animationIsPlaying(element)).to.be.true;
|
||||
});
|
||||
|
||||
test('has a duration greater than 0', () => {
|
||||
expect(element.duration).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
suite('when pause is invoked after a delay', () => {
|
||||
const delaySeconds = 0.2;
|
||||
setup(async () => {
|
||||
await timePasses(1000 * delaySeconds);
|
||||
const animationsPause = waitForEvent(element, 'pause');
|
||||
element.pause();
|
||||
await animationsPause;
|
||||
});
|
||||
|
||||
test('animations pause', () => {
|
||||
expect(animationIsPlaying(element)).to.be.false;
|
||||
});
|
||||
|
||||
test.skip('has a current time close to the delay', () => {
|
||||
expect(element.currentTime)
|
||||
.to.be.closeTo(delaySeconds, TOLERANCE_SEC);
|
||||
});
|
||||
|
||||
test('changing currentTime triggers render', () => {
|
||||
element.currentTime = 5;
|
||||
expect(element[$scene].shouldRender()).to.be.true;
|
||||
});
|
||||
|
||||
suite('when play is invoked again', () => {
|
||||
setup(async () => {
|
||||
const animationsPlay = waitForEvent(element, 'play');
|
||||
element.play();
|
||||
await animationsPlay;
|
||||
});
|
||||
|
||||
test('animations play', () => {
|
||||
expect(animationIsPlaying(element)).to.be.true;
|
||||
});
|
||||
|
||||
test('has a duration greater than 0', () => {
|
||||
expect(element.duration).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
test.skip('has a current time close to the delay', () => {
|
||||
expect(element.currentTime)
|
||||
.to.be.closeTo(delaySeconds, TOLERANCE_SEC);
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
suite('when play is invoked with options', () => {
|
||||
setup(async () => {
|
||||
element.animationName = 'Punch';
|
||||
await element.updateComplete;
|
||||
const animationsPlay = waitForEvent(element, 'play');
|
||||
element.play({repetitions: 2, pingpong: true});
|
||||
await animationsPlay;
|
||||
});
|
||||
|
||||
test.skip('plays forward, backward, and stops', async () => {
|
||||
await timePasses(element.duration * 0.8 * 1000);
|
||||
expect(animationIsPlaying(element), 'failed to start playing!')
|
||||
.to.be.true;
|
||||
const t = element.currentTime;
|
||||
|
||||
await timePasses(element.duration * 1.0 * 1000);
|
||||
expect(animationIsPlaying(element), 'not playing after 1.8 * duration!')
|
||||
.to.be.true;
|
||||
expect(element.currentTime, 'not playing backwards!').to.be.lessThan(t);
|
||||
|
||||
await timePasses(element.duration * 0.4 * 1000);
|
||||
expect(animationIsPlaying(element), 'failed to stop playing!')
|
||||
.to.be.false;
|
||||
expect(element.currentTime, 'did not return to beginning of animation!')
|
||||
.to.be.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
suite('when configured to autoplay', () => {
|
||||
setup(async () => {
|
||||
element.autoplay = true;
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays an animation', () => {
|
||||
expect(animationIsPlaying(element)).to.be.true;
|
||||
});
|
||||
|
||||
test('has a duration greater than 0', () => {
|
||||
expect(element.duration).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
test('plays the first animation by default', () => {
|
||||
expect(animationIsPlaying(element, element.availableAnimations[0]))
|
||||
.to.be.true;
|
||||
});
|
||||
|
||||
suite('with a specified animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = element.availableAnimations[1];
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the specified animation', () => {
|
||||
expect(animationIsPlaying(element, element.availableAnimations[1]))
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('with an invalid animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = 'invalid-animation-name';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the first animation', () => {
|
||||
expect(animationIsPlaying(element, element.availableAnimations[0]))
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a specified index as animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = '1';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the specified animation', () => {
|
||||
expect(animationWithIndexIsPlaying(element, 1)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('with an invalid index as animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = '-1';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the first animation', () => {
|
||||
expect(animationWithIndexIsPlaying(element, 0)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('a model with duplicate animation names', () => {
|
||||
setup(async () => {
|
||||
element.src = ANIMATED_GLB_DUPLICATE_ANIMATION_NAMES_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
element.animationName = '1';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the specified animation', () => {
|
||||
expect(animationWithIndexIsPlaying(element, 1)).to.be.true;
|
||||
});
|
||||
|
||||
suite('when playing a duplicate animation by name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = element.availableAnimations[1];
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test(
|
||||
'fails to play the specified animation and plays the last animation with that name instead',
|
||||
() => {
|
||||
expect(animationWithIndexIsPlaying(element, 3)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('a model without animations', () => {
|
||||
setup(async () => {
|
||||
element.src = NON_ANIMATED_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
});
|
||||
|
||||
test('does not play an animation', () => {
|
||||
expect(animationIsPlaying(element)).to.be.false;
|
||||
});
|
||||
|
||||
test('has a duration of 0', () => {
|
||||
expect(element.duration).to.be.equal(0);
|
||||
});
|
||||
|
||||
suite('with a specified animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = element.availableAnimations[1];
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('does not play an animation', () => {
|
||||
expect(animationIsPlaying(element)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a specified animation by index', () => {
|
||||
setup(async () => {
|
||||
element.animationName = '1';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('does not play an animation', () => {
|
||||
expect(animationIsPlaying(element)).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Vector3} from 'three';
|
||||
|
||||
import {$needsRender, $scene, toVector3D, Vector2D, Vector3D} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {Hotspot} from '../../three-components/Hotspot.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses} from '../helpers.js';
|
||||
|
||||
const sceneContainsHotspot =
|
||||
(scene: ModelScene, element: HTMLElement): boolean => {
|
||||
const {children} = scene.target;
|
||||
for (let i = 0, l = children.length; i < l; i++) {
|
||||
const hotspot = children[i];
|
||||
if (hotspot instanceof Hotspot &&
|
||||
(hotspot.element.children[0].children[0] as HTMLSlotElement)
|
||||
.name === element.slot) {
|
||||
// expect it has been changed from default
|
||||
expect(hotspot.position).to.not.eql(new Vector3());
|
||||
expect(hotspot.normal).to.not.eql(new Vector3(0, 1, 0));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const closeToVector3 = (a: Vector3D, b: Vector3) => {
|
||||
const delta = 0.001;
|
||||
expect(a.x).to.be.closeTo(b.x, delta);
|
||||
expect(a.y).to.be.closeTo(b.y, delta);
|
||||
expect(a.z).to.be.closeTo(b.z, delta);
|
||||
};
|
||||
|
||||
const withinRange = (a: Vector2D, start: number, finish: number) => {
|
||||
expect(a.u).to.be.within(start, finish);
|
||||
expect(a.v).to.be.within(start, finish);
|
||||
};
|
||||
|
||||
suite('Annotation', () => {
|
||||
let element: ModelViewerElement;
|
||||
let scene: ModelScene;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
scene = element[$scene];
|
||||
|
||||
element.src = assetPath('models/cube.gltf');
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
suite('a model-viewer element with a hotspot', () => {
|
||||
let hotspot: HTMLElement;
|
||||
let numSlots: number;
|
||||
|
||||
setup(async () => {
|
||||
hotspot = document.createElement('div');
|
||||
hotspot.setAttribute('slot', 'hotspot-1');
|
||||
hotspot.setAttribute('data-position', '1m 1m 1m');
|
||||
hotspot.setAttribute('data-normal', '0m 0m -1m');
|
||||
element.appendChild(hotspot);
|
||||
await timePasses();
|
||||
numSlots = scene.target.children.length;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (hotspot.parentElement === element) {
|
||||
element.removeChild(hotspot);
|
||||
}
|
||||
});
|
||||
|
||||
test('creates a corresponding slot', () => {
|
||||
expect(sceneContainsHotspot(scene, hotspot)).to.be.true;
|
||||
});
|
||||
|
||||
test.skip('querying it returns valid data', () => {
|
||||
// to test querying, place hotspot in the center and verify the screen
|
||||
// position is half the default width and height (300 x 150) with a depth
|
||||
// value of ~1.
|
||||
const defaultDimensions = {width: 300, height: 150};
|
||||
element.updateHotspot({name: 'hotspot-1', position: `0m 0m 0m`});
|
||||
|
||||
const hotspotData = element.queryHotspot('hotspot-1');
|
||||
|
||||
expect(hotspotData?.canvasPosition.x)
|
||||
.to.be.closeTo(defaultDimensions.width / 2, 0.0001);
|
||||
expect(hotspotData?.canvasPosition.y)
|
||||
.to.be.closeTo(defaultDimensions.height / 2, 0.0001);
|
||||
expect(hotspotData?.position.toString())
|
||||
.to.equal(toVector3D(new Vector3(0, 0, 0)).toString());
|
||||
expect(hotspotData?.normal.toString())
|
||||
.to.equal(toVector3D(new Vector3(0, 0, -1)).toString());
|
||||
expect(hotspotData?.facingCamera).to.be.true;
|
||||
});
|
||||
|
||||
suite('adding a second hotspot with the same name', () => {
|
||||
let hotspot2: HTMLElement;
|
||||
|
||||
setup(async () => {
|
||||
hotspot2 = document.createElement('div');
|
||||
hotspot2.setAttribute('slot', 'hotspot-1');
|
||||
hotspot2.setAttribute('data-position', '0m 1m 2m');
|
||||
hotspot2.setAttribute('data-normal', '1m 0m 0m');
|
||||
element.appendChild(hotspot2);
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (hotspot2.parentElement === element) {
|
||||
element.removeChild(hotspot2);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not change the slot', () => {
|
||||
expect(scene.target.children.length).to.be.equal(numSlots);
|
||||
});
|
||||
|
||||
test('does not change the data', () => {
|
||||
const {position, normal} =
|
||||
(scene.target.children[numSlots - 1] as Hotspot);
|
||||
expect(position).to.be.deep.equal(new Vector3(1, 1, 1));
|
||||
expect(normal).to.be.deep.equal(new Vector3(0, 0, -1));
|
||||
});
|
||||
|
||||
test('updateHotspot does change the data', () => {
|
||||
element.updateHotspot(
|
||||
{name: 'hotspot-1', position: '0m 1m 2m', normal: '1m 0m 0m'});
|
||||
const {position, normal} =
|
||||
(scene.target.children[numSlots - 1] as Hotspot);
|
||||
expect(position).to.be.deep.equal(new Vector3(0, 1, 2));
|
||||
expect(normal).to.be.deep.equal(new Vector3(1, 0, 0));
|
||||
});
|
||||
|
||||
test('updateHotspot does change the surface', () => {
|
||||
const hotspot = scene.target.children[numSlots - 1] as Hotspot;
|
||||
const {x} = hotspot.position;
|
||||
const surface = '0 0 1 2 3 0.217 0.341 0.442';
|
||||
element.updateHotspot({name: 'hotspot-1', surface});
|
||||
expect(x).to.not.be.equal(hotspot.position.x);
|
||||
});
|
||||
|
||||
test('and removing it does not remove the slot', async () => {
|
||||
element.removeChild(hotspot);
|
||||
await timePasses();
|
||||
|
||||
expect(scene.target.children.length).to.be.equal(numSlots);
|
||||
});
|
||||
|
||||
test('but removing both does remove the slot', async () => {
|
||||
element.removeChild(hotspot);
|
||||
element.removeChild(hotspot2);
|
||||
await timePasses();
|
||||
|
||||
expect(scene.target.children.length).to.be.equal(numSlots - 1);
|
||||
});
|
||||
|
||||
suite('with a camera', () => {
|
||||
let wrapper: HTMLElement;
|
||||
|
||||
setup(async () => {
|
||||
// This is to wait for the hotspots to be added to their slots, as
|
||||
// this triggers their visibility to "show". Otherwise, sometimes the
|
||||
// following hide() call will happen first, then when the camera
|
||||
// moves, we never get a hotspot-visibility event because they were
|
||||
// already visible.
|
||||
await rafPasses();
|
||||
|
||||
wrapper = (scene.target.children[numSlots - 1] as Hotspot).element;
|
||||
});
|
||||
|
||||
test('the hotspot is hidden', async () => {
|
||||
expect(wrapper.classList.contains('hide')).to.be.true;
|
||||
});
|
||||
|
||||
test('the hotspot is visible after turning', async () => {
|
||||
element[$scene].yaw = Math.PI;
|
||||
element[$scene].updateMatrixWorld();
|
||||
element[$needsRender]();
|
||||
|
||||
await waitForEvent(hotspot2, 'hotspot-visibility');
|
||||
|
||||
expect(!!wrapper.classList.contains('hide')).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('a model-viewer element with a loaded cube', () => {
|
||||
let rect: DOMRect;
|
||||
|
||||
setup(async () => {
|
||||
element.setAttribute('style', `width: 200px; height: 300px`);
|
||||
rect = element.getBoundingClientRect();
|
||||
element.cameraOrbit = '0deg 90deg 2m';
|
||||
element.jumpCameraToGoal();
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('gets expected hit result', async () => {
|
||||
await rafPasses();
|
||||
const hitResult = element.positionAndNormalFromPoint(
|
||||
rect.width / 2 + rect.x, rect.height / 2 + rect.y);
|
||||
expect(hitResult).to.be.ok;
|
||||
const {position, normal, uv} = hitResult!;
|
||||
closeToVector3(position, new Vector3(0, 0, 0.5));
|
||||
closeToVector3(normal, new Vector3(0, 0, 1));
|
||||
|
||||
if (uv != null) {
|
||||
withinRange(uv, 0, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('gets expected hit result when turned', async () => {
|
||||
element.resetTurntableRotation(-Math.PI / 2);
|
||||
await rafPasses();
|
||||
const hitResult = element.positionAndNormalFromPoint(
|
||||
rect.width / 2 + rect.x, rect.height / 2 + rect.y);
|
||||
expect(hitResult).to.be.ok;
|
||||
const {position, normal, uv} = hitResult!;
|
||||
closeToVector3(position, new Vector3(0.5, 0, 0));
|
||||
closeToVector3(normal, new Vector3(1, 0, 0));
|
||||
if (uv != null) {
|
||||
withinRange(uv, 0, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns a surface that shows and hides appropriately', async () => {
|
||||
await rafPasses();
|
||||
const surface = element.surfaceFromPoint(
|
||||
rect.width / 2 + rect.x, rect.height / 2 + rect.y);
|
||||
expect(surface).to.be.ok;
|
||||
|
||||
const hotspot = document.createElement('div');
|
||||
hotspot.setAttribute('slot', 'hotspot-1');
|
||||
hotspot.setAttribute('data-surface', surface!);
|
||||
element.appendChild(hotspot);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
expect(sceneContainsHotspot(scene, hotspot)).to.be.true;
|
||||
|
||||
const numSlots = scene.target.children.length;
|
||||
const wrapper = (scene.target.children[numSlots - 1] as Hotspot).element;
|
||||
|
||||
expect(wrapper.classList.contains('hide')).to.be.false;
|
||||
|
||||
element[$scene].yaw = Math.PI;
|
||||
element[$scene].updateMatrixWorld();
|
||||
element[$needsRender]();
|
||||
|
||||
await waitForEvent(hotspot, 'hotspot-visibility');
|
||||
|
||||
expect(wrapper.classList.contains('hide')).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {IS_ANDROID, IS_IOS} from '../../constants.js';
|
||||
import {$openIOSARQuickLook, $openSceneViewer} from '../../features/ar.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses, spy} from '../helpers.js';
|
||||
|
||||
suite('AR', () => {
|
||||
let element: ModelViewerElement;
|
||||
let intentUrls: Array<string>;
|
||||
let restoreAnchorClick: () => void;
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
intentUrls = [];
|
||||
restoreAnchorClick = spy(HTMLAnchorElement.prototype, 'click', {
|
||||
value: function() {
|
||||
intentUrls.push((this as HTMLAnchorElement).href);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
restoreAnchorClick();
|
||||
});
|
||||
|
||||
suite('openSceneViewer', () => {
|
||||
test('preserves query parameters in model URLs', () => {
|
||||
element.src = 'https://example.com/model.gltf?token=foo';
|
||||
element.alt = 'Example model';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
|
||||
expect(search.get('token')).to.equal('foo');
|
||||
});
|
||||
|
||||
test('keeps title and link when supplied', () => {
|
||||
element.src =
|
||||
'https://example.com/model.gltf?link=http://linkme.com&title=bar';
|
||||
element.alt = 'alt';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
|
||||
expect(search.get('title')).to.equal('bar');
|
||||
expect(search.get('link')).to.equal('http://linkme.com/');
|
||||
});
|
||||
|
||||
test('sets sound and link to absolute URLs', () => {
|
||||
element.src =
|
||||
'https://example.com/model.gltf?link=foo.html&sound=bar.ogg';
|
||||
element.alt = 'alt';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
|
||||
// Tests run in different locations
|
||||
expect(search.get('sound')).to.contain('http://');
|
||||
expect(search.get('sound')).to.contain('/bar.ogg');
|
||||
expect(search.get('link')).to.contain('http://');
|
||||
expect(search.get('link')).to.contain('/foo.html');
|
||||
});
|
||||
|
||||
test('strips hash params from SceneViewer model src', () => {
|
||||
element.src =
|
||||
'https://example.com/model.gltf#applePayButtonType=plain&checkoutTitle=TitleText';
|
||||
element.alt = 'alt';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
const file = new URL(search.get('file') as any);
|
||||
|
||||
expect(file.hash).to.equal('');
|
||||
});
|
||||
|
||||
test('strips hash params but preserves query params', () => {
|
||||
element.src =
|
||||
'https://example.com/model.gltf?link=http://linkme.com&title=bar#applePayButtonType=plain&checkoutTitle=TitleText';
|
||||
element.alt = 'alt';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
const file = new URL(search.get('file') as any);
|
||||
|
||||
expect(file.hash).to.equal('');
|
||||
expect(search.get('title')).to.equal('bar');
|
||||
expect(search.get('link')).to.equal('http://linkme.com/');
|
||||
});
|
||||
});
|
||||
|
||||
suite('openQuickLook', () => {
|
||||
test('sets hash for fixed scale', () => {
|
||||
element.src = 'https://example.com/model.gltf';
|
||||
element.iosSrc = 'https://example.com/model.usdz';
|
||||
element.arScale = 'fixed';
|
||||
(element as any)[$openIOSARQuickLook]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const url = new URL(intentUrls[0]);
|
||||
|
||||
expect(url.pathname).equal('/model.usdz');
|
||||
expect(url.hash).to.equal('#allowsContentScaling=0');
|
||||
});
|
||||
|
||||
test('keeps original hash too', () => {
|
||||
element.src = 'https://example.com/model.gltf';
|
||||
element.iosSrc =
|
||||
'https://example.com/model.usdz#custom=path-to-banner.html';
|
||||
element.arScale = 'fixed';
|
||||
(element as any)[$openIOSARQuickLook]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const url = new URL(intentUrls[0]);
|
||||
|
||||
expect(url.pathname).equal('/model.usdz');
|
||||
expect(url.hash).to.equal(
|
||||
'#custom=path-to-banner.html&allowsContentScaling=0');
|
||||
});
|
||||
|
||||
test('replicate src hash to usdz blob url', async () => {
|
||||
element.src =
|
||||
assetPath('models/cube.gltf') + '#custom=path-to-banner.html';
|
||||
element.arModes = 'webxr scene-viewer quick-look';
|
||||
|
||||
await (element as any)[$openIOSARQuickLook]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const url = new URL(intentUrls[0]);
|
||||
|
||||
expect(url.protocol).to.equal('blob:');
|
||||
|
||||
expect(url.hash).to.equal('#custom=path-to-banner.html');
|
||||
});
|
||||
|
||||
test(
|
||||
'replicate src hash to usdz blob and set hash for fixed scale',
|
||||
async () => {
|
||||
element.src =
|
||||
assetPath('models/cube.gltf') + '#custom=path-to-banner.html';
|
||||
element.arModes = 'webxr scene-viewer quick-look';
|
||||
element.arScale = 'fixed';
|
||||
|
||||
await (element as any)[$openIOSARQuickLook]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const url = new URL(intentUrls[0]);
|
||||
|
||||
expect(url.protocol).to.equal('blob:');
|
||||
|
||||
expect(url.hash).to.equal(
|
||||
'#custom=path-to-banner.html&allowsContentScaling=0');
|
||||
});
|
||||
});
|
||||
|
||||
suite('shows the AR button', () => {
|
||||
setup(async () => {
|
||||
element.ar = true;
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
test('on Android', () => {
|
||||
expect(element.canActivateAR).to.be.equal(IS_ANDROID);
|
||||
});
|
||||
|
||||
// This only works on a physical iOS device, not an emulated one.
|
||||
test.skip('with an ios-src on iOS', async () => {
|
||||
element.iosSrc = assetPath('models/Astronaut.usdz');
|
||||
await rafPasses();
|
||||
expect(element.canActivateAR).to.be.equal(IS_ANDROID || IS_IOS);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,281 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Texture} from 'three';
|
||||
|
||||
import {BASE_OPACITY} from '../../features/environment.js';
|
||||
import ModelViewerElementBase, {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {Renderer} from '../../three-components/Renderer.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses} from '../helpers.js';
|
||||
|
||||
const ALT_BG_IMAGE_URL = assetPath('environments/white_furnace.hdr');
|
||||
const HDR_BG_IMAGE_URL = assetPath('environments/spruit_sunrise_1k_HDR.hdr');
|
||||
const MODEL_URL = assetPath('models/reflective-sphere.gltf');
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when a given element is loaded
|
||||
* and has an environment map set that matches the passed in meta.
|
||||
*/
|
||||
const waitForLoadAndEnvMap = (element: ModelViewerElementBase) => {
|
||||
const load = waitForEvent(element, 'poster-dismissed');
|
||||
const envMap = waitForEvent(element, 'environment-change');
|
||||
return Promise.all([load, envMap]);
|
||||
};
|
||||
|
||||
suite('Environment', () => {
|
||||
suiteTeardown(() => {
|
||||
Renderer.resetSingleton();
|
||||
});
|
||||
|
||||
let element: ModelViewerElement;
|
||||
let scene: ModelScene;
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
scene = element[$scene];
|
||||
});
|
||||
|
||||
teardown(() => element.parentNode && element.parentNode.removeChild(element));
|
||||
|
||||
test('only generates an environment when in the render tree', async () => {
|
||||
let environmentChangeCount = 0;
|
||||
const environmentChangeHandler = () => environmentChangeCount++;
|
||||
element.addEventListener('environment-change', environmentChangeHandler);
|
||||
element.style.display = 'none';
|
||||
element.src = MODEL_URL;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await rafPasses();
|
||||
expect(environmentChangeCount).to.be.eq(0);
|
||||
element.style.display = 'block';
|
||||
await waitForEvent(element, 'environment-change');
|
||||
expect(environmentChangeCount).to.be.eq(1);
|
||||
element.removeEventListener('environment-change', environmentChangeHandler);
|
||||
});
|
||||
|
||||
suite('with no skybox-image property', () => {
|
||||
let environmentChanges = 0;
|
||||
suite('and a src property', () => {
|
||||
setup(async () => {
|
||||
const onLoad = waitForLoadAndEnvMap(element);
|
||||
element.src = MODEL_URL;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
|
||||
environmentChanges = 0;
|
||||
element.addEventListener('environment-change', () => {
|
||||
environmentChanges++;
|
||||
});
|
||||
await onLoad;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('applies a generated environment map on model', async function() {
|
||||
expect(scene.environment!.name).to.be.eq('neutral');
|
||||
});
|
||||
|
||||
test('changes the environment exactly once', async function() {
|
||||
expect(environmentChanges).to.be.eq(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('exposure', () => {
|
||||
setup(async () => {
|
||||
element.src = MODEL_URL;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
scene.visible = true;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('changes the tone mapping exposure of the renderer', async () => {
|
||||
const renderer = Renderer.singleton;
|
||||
const originalToneMappingExposure =
|
||||
renderer.threeRenderer.toneMappingExposure;
|
||||
element.exposure = 2.0;
|
||||
await timePasses();
|
||||
renderer.render(performance.now());
|
||||
|
||||
const newToneMappingExposure = renderer.threeRenderer.toneMappingExposure;
|
||||
|
||||
expect(newToneMappingExposure)
|
||||
.to.be.greaterThan(originalToneMappingExposure);
|
||||
});
|
||||
});
|
||||
|
||||
suite('shadow-intensity', () => {
|
||||
setup(async () => {
|
||||
element.src = MODEL_URL;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('changes the opacity of the static shadow', async () => {
|
||||
element.shadowIntensity = 1.0;
|
||||
await timePasses();
|
||||
const newIntensity = scene.shadow!.getIntensity();
|
||||
expect(newIntensity).to.be.eq(BASE_OPACITY);
|
||||
});
|
||||
});
|
||||
|
||||
suite('environment-image', () => {
|
||||
setup(async () => {
|
||||
const onLoad = waitForLoadAndEnvMap(element);
|
||||
element.setAttribute('src', MODEL_URL);
|
||||
element.setAttribute('environment-image', HDR_BG_IMAGE_URL);
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await onLoad;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('applies environment-image environment map on model', () => {
|
||||
expect(scene.environment!.name).to.be.eq(element.environmentImage);
|
||||
});
|
||||
|
||||
suite('and environment-image subsequently removed', () => {
|
||||
setup(async () => {
|
||||
const envMapChanged = waitForEvent(element, 'environment-change');
|
||||
element.removeAttribute('environment-image');
|
||||
await envMapChanged;
|
||||
});
|
||||
|
||||
test('reapplies generated environment map on model', () => {
|
||||
expect(scene.environment!.name).to.be.eq('neutral');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('with skybox-image property', () => {
|
||||
setup(async () => {
|
||||
const onLoad = waitForLoadAndEnvMap(element);
|
||||
element.setAttribute('src', MODEL_URL);
|
||||
element.setAttribute('skybox-image', HDR_BG_IMAGE_URL);
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await onLoad;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('displays background with skybox-image', async function() {
|
||||
expect((scene.background as Texture).name).to.be.eq(element.skyboxImage);
|
||||
});
|
||||
|
||||
test('applies skybox-image environment map on model', async function() {
|
||||
expect(scene.environment!.name).to.be.eq(element.skyboxImage);
|
||||
});
|
||||
|
||||
test('has tight radius', async function() {
|
||||
expect(scene.farRadius()).to.be.lessThan(2);
|
||||
});
|
||||
|
||||
suite('with skybox-height property', () => {
|
||||
setup(async () => {
|
||||
element.setAttribute('skybox-height', '1m');
|
||||
await element.updateComplete;
|
||||
});
|
||||
|
||||
test('switches background', async function() {
|
||||
expect(scene.background).to.be.null;
|
||||
});
|
||||
|
||||
test('has wide radius', async function() {
|
||||
expect(scene.farRadius()).to.be.greaterThan(2);
|
||||
});
|
||||
|
||||
test('no skybox-image disables grounded skybox', async function() {
|
||||
element.setAttribute('skybox-image', '');
|
||||
await element.updateComplete;
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
expect(scene.farRadius()).to.be.lessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
suite('with an environment-image', () => {
|
||||
setup(async () => {
|
||||
const environmentChanged = waitForEvent(element, 'environment-change');
|
||||
element.setAttribute('environment-image', ALT_BG_IMAGE_URL);
|
||||
await environmentChanged;
|
||||
});
|
||||
|
||||
test('prefers environment-image as environment map', () => {
|
||||
expect(scene.environment!.name).to.be.eq(ALT_BG_IMAGE_URL);
|
||||
});
|
||||
|
||||
suite('and environment-image subsequently removed', () => {
|
||||
setup(async () => {
|
||||
const environmentChanged =
|
||||
waitForEvent(element, 'environment-change');
|
||||
element.removeAttribute('environment-image');
|
||||
await environmentChanged;
|
||||
});
|
||||
|
||||
test('uses skybox-image as environment map', () => {
|
||||
expect(scene.environment!.name).to.be.eq(HDR_BG_IMAGE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
suite('and skybox-image subsequently removed', () => {
|
||||
setup(async () => {
|
||||
element.removeAttribute('skybox-image');
|
||||
await element.updateComplete;
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('continues using environment-image as environment map', () => {
|
||||
expect(scene.environment!.name).to.be.eq(ALT_BG_IMAGE_URL);
|
||||
});
|
||||
|
||||
test('removes the background', async function() {
|
||||
expect(scene.background).to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('and skybox-image subsequently removed', () => {
|
||||
setup(async () => {
|
||||
const envMapChanged = waitForEvent(element, 'environment-change');
|
||||
element.removeAttribute('skybox-image');
|
||||
await envMapChanged;
|
||||
});
|
||||
|
||||
test('removes the background', async function() {
|
||||
expect(scene.background).to.be.null;
|
||||
});
|
||||
|
||||
test('reapplies generated environment map on model', async function() {
|
||||
expect(scene.environment!.name).to.be.eq('neutral');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {$defaultPosterElement, $posterContainerElement} from '../../features/loading.js';
|
||||
import {$scene, $userInputElement} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {CachingGLTFLoader} from '../../three-components/CachingGLTFLoader.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, pickShadowDescendant, rafPasses, until} from '../helpers.js';
|
||||
|
||||
const CUBE_GLB_PATH = assetPath('models/cube.gltf');
|
||||
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
|
||||
|
||||
suite('Loading', () => {
|
||||
let element: ModelViewerElement;
|
||||
let firstChild: ChildNode|null;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
firstChild = document.body.firstChild;
|
||||
document.body.insertBefore(element, firstChild);
|
||||
|
||||
// Wait at least a microtask for size calculations
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
CachingGLTFLoader.clearCache();
|
||||
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
suite('with a second element outside the viewport', () => {
|
||||
let element2: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element2 = new ModelViewerElement();
|
||||
element2.loading = 'eager';
|
||||
document.body.insertBefore(element2, firstChild);
|
||||
element.style.height = '100vh';
|
||||
element2.style.height = '100vh';
|
||||
const load1 = waitForEvent(element, 'load');
|
||||
const load2 = waitForEvent(element2, 'load');
|
||||
element.src = CUBE_GLB_PATH;
|
||||
element2.src = CUBE_GLB_PATH;
|
||||
await Promise.all([load1, load2]);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element2.parentNode != null) {
|
||||
element2.parentNode.removeChild(element2);
|
||||
}
|
||||
});
|
||||
|
||||
test('first element is visible', () => {
|
||||
expect(element.modelIsVisible).to.be.true;
|
||||
});
|
||||
|
||||
test('second element is not visible', () => {
|
||||
expect(element2.modelIsVisible).to.be.false;
|
||||
});
|
||||
|
||||
suite('scroll to second element', () => {
|
||||
setup(() => {
|
||||
element2.scrollIntoView();
|
||||
});
|
||||
|
||||
test('first element is not visible', async () => {
|
||||
await waitForEvent<CustomEvent>(
|
||||
element,
|
||||
'model-visibility',
|
||||
event => event.detail.visible === false);
|
||||
});
|
||||
|
||||
test('second element is visible', async () => {
|
||||
await waitForEvent<CustomEvent>(
|
||||
element2,
|
||||
'model-visibility',
|
||||
event => event.detail.visible === true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('creates a poster element that captures interactions', async () => {
|
||||
const picked = pickShadowDescendant(element);
|
||||
expect(picked).to.be.ok;
|
||||
// TODO(cdata): Leaky internal details here:
|
||||
expect(picked!.id).to.be.equal('default-poster');
|
||||
});
|
||||
|
||||
test('does not load when hidden from render tree', async () => {
|
||||
let loadDispatched = false;
|
||||
const loadHandler = () => {
|
||||
loadDispatched = true;
|
||||
};
|
||||
|
||||
element.addEventListener('load', loadHandler);
|
||||
|
||||
element.style.display = 'none';
|
||||
|
||||
// Give IntersectionObserver a chance to notify. In Chrome, this takes
|
||||
// two rAFs (empirically observed). Await extra time just in case:
|
||||
await timePasses(100);
|
||||
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
await timePasses(500); // Arbitrary time to allow model to load
|
||||
|
||||
element.removeEventListener('load', loadHandler);
|
||||
|
||||
expect(loadDispatched).to.be.false;
|
||||
});
|
||||
|
||||
suite('load', () => {
|
||||
suite('when a model src changes after loading', () => {
|
||||
setup(async () => {
|
||||
// The shadow is here to expose an earlier bug on unloading models.
|
||||
element.shadowIntensity = 1;
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
test('only dispatches load once per src change', async () => {
|
||||
let loadCount = 0;
|
||||
const onLoad = () => {
|
||||
loadCount++;
|
||||
};
|
||||
|
||||
element.addEventListener('load', onLoad);
|
||||
|
||||
try {
|
||||
element.src = HORSE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
// Give any late-dispatching events a chance to dispatch
|
||||
await timePasses(300);
|
||||
|
||||
expect(loadCount).to.be.equal(2);
|
||||
} finally {
|
||||
element.removeEventListener('load', onLoad);
|
||||
}
|
||||
});
|
||||
|
||||
test('getDimensions() returns correct size', () => {
|
||||
const size = element.getDimensions();
|
||||
expect(size.x).to.be.eq(1);
|
||||
expect(size.y).to.be.eq(1);
|
||||
expect(size.z).to.be.eq(1);
|
||||
});
|
||||
|
||||
test('models are unloaded after src updates', async () => {
|
||||
element.src = HORSE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const {shadow, model, target} = element[$scene];
|
||||
const {children} = target;
|
||||
expect(children.length).to.be.eq(2, 'horse');
|
||||
expect(children).to.contain(shadow, 'horse shadow');
|
||||
expect(children).to.contain(model, 'horse model');
|
||||
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
const {children: children2} = target;
|
||||
expect(children2.length).to.be.eq(2, 'cube');
|
||||
expect(children2).to.contain(shadow, 'cube shadow');
|
||||
expect(children2).to.contain(element[$scene].model, 'cube model');
|
||||
});
|
||||
|
||||
test('generates 3DModel schema', async () => {
|
||||
element.generateSchema = true;
|
||||
await element.updateComplete;
|
||||
const {schemaElement} = element[$scene];
|
||||
expect(schemaElement.type).to.be.eq('application/ld+json');
|
||||
expect(schemaElement.parentElement).to.be.eq(document.head);
|
||||
const json = JSON.parse(schemaElement.textContent!);
|
||||
const encoding = json.encoding[0];
|
||||
|
||||
expect(encoding.contentUrl).to.be.eq(CUBE_GLB_PATH);
|
||||
expect(encoding.encodingFormat).to.be.eq('model/gltf+json');
|
||||
|
||||
element.generateSchema = false;
|
||||
await element.updateComplete;
|
||||
expect(schemaElement.parentElement).to.be.not.ok;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('loading', () => {
|
||||
suite('src changes quickly', () => {
|
||||
test('eventually notifies that current src is loaded', async () => {
|
||||
element.loading = 'eager';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
const loadCubeEvent =
|
||||
waitForEvent(element, 'load') as Promise<CustomEvent>;
|
||||
|
||||
await timePasses();
|
||||
|
||||
element.src = HORSE_GLB_PATH;
|
||||
|
||||
const loadCube = await loadCubeEvent;
|
||||
const loadHorse = await waitForEvent(element, 'load') as CustomEvent;
|
||||
|
||||
expect(loadCube.detail.url).to.be.eq(CUBE_GLB_PATH);
|
||||
expect(loadHorse.detail.url).to.be.eq(HORSE_GLB_PATH);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
suite('reveal', () => {
|
||||
suite('auto', () => {
|
||||
test('hides poster when element loads', async () => {
|
||||
element.src = CUBE_GLB_PATH;
|
||||
const input = element[$userInputElement];
|
||||
|
||||
expect(pickShadowDescendant(element))
|
||||
.to.be.not.equal(
|
||||
input, 'the poster should be shown until the model loads');
|
||||
|
||||
await waitForEvent(
|
||||
element,
|
||||
'model-visibility',
|
||||
(event: any) => event.detail.visible);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
expect(pickShadowDescendant(element)).to.be.equal(input);
|
||||
|
||||
element.reveal = 'manual';
|
||||
await element.updateComplete;
|
||||
await rafPasses();
|
||||
|
||||
expect(pickShadowDescendant(element))
|
||||
.to.be.equal(input, 'changing reveal should not show the poster');
|
||||
});
|
||||
});
|
||||
|
||||
suite('manual', () => {
|
||||
test('does not hide poster until dismissed', async () => {
|
||||
element.loading = 'eager';
|
||||
element.reveal = 'manual';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
const posterElement = (element as any)[$defaultPosterElement];
|
||||
const input = element[$userInputElement];
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
posterElement.focus();
|
||||
|
||||
expect(element.shadowRoot!.activeElement).to.be.equal(posterElement);
|
||||
|
||||
element.dismissPoster();
|
||||
|
||||
await until(() => {
|
||||
return element.shadowRoot!.activeElement === input;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('configuring poster via attribute', () => {
|
||||
suite('removing the attribute', () => {
|
||||
test('sets poster to null', async () => {
|
||||
// NOTE(cdata): This is less important after we resolve
|
||||
// https://github.com/PolymerLabs/model-viewer/issues/76
|
||||
element.setAttribute('poster', CUBE_GLB_PATH);
|
||||
await timePasses();
|
||||
element.removeAttribute('poster');
|
||||
await timePasses();
|
||||
expect(element.poster).to.be.equal(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('with loaded model src', () => {
|
||||
setup(() => {
|
||||
element.src = CUBE_GLB_PATH;
|
||||
});
|
||||
|
||||
test('can be hidden imperatively', async () => {
|
||||
const ostensiblyThePoster = pickShadowDescendant(element);
|
||||
|
||||
element.dismissPoster();
|
||||
|
||||
await waitForEvent<CustomEvent>(
|
||||
element, 'model-visibility', event => event.detail.visible === true);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
const ostensiblyNotThePoster = pickShadowDescendant(element);
|
||||
|
||||
expect(ostensiblyThePoster).to.not.be.equal(ostensiblyNotThePoster);
|
||||
});
|
||||
|
||||
suite('when poster is hidden', () => {
|
||||
setup(async () => {
|
||||
element.dismissPoster();
|
||||
await waitForEvent<CustomEvent>(
|
||||
element,
|
||||
'model-visibility',
|
||||
event => event.detail.visible === true);
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('allows the input to be interactive', async () => {
|
||||
const input = element[$userInputElement];
|
||||
const picked = pickShadowDescendant(element);
|
||||
|
||||
expect(picked).to.be.equal(input);
|
||||
});
|
||||
|
||||
test('when src is reset, poster is dismissible', async () => {
|
||||
const posterElement = (element as any)[$defaultPosterElement];
|
||||
const posterContainer = (element as any)[$posterContainerElement];
|
||||
const inputElement = element[$userInputElement];
|
||||
|
||||
element.reveal = 'manual';
|
||||
element.src = null;
|
||||
element.showPoster();
|
||||
|
||||
await timePasses();
|
||||
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
await timePasses();
|
||||
|
||||
expect(posterContainer.classList.contains('show')).to.be.true;
|
||||
|
||||
posterElement.focus();
|
||||
|
||||
expect(element.shadowRoot!.activeElement).to.be.equal(posterElement);
|
||||
|
||||
element.dismissPoster();
|
||||
|
||||
await until(() => {
|
||||
return element.shadowRoot!.activeElement === inputElement;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Mesh, MeshStandardMaterial} from 'three';
|
||||
|
||||
import {$currentGLTF} from '../../features/scene-graph.js';
|
||||
import {$primitivesList} from '../../features/scene-graph/model.js';
|
||||
import {PrimitiveNode} from '../../features/scene-graph/nodes/primitive-node.js';
|
||||
import {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ModelViewerGLTFInstance} from '../../three-components/gltf-instance/ModelViewerGLTFInstance.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses} from '../helpers.js';
|
||||
|
||||
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
|
||||
const CUBES_GLB_PATH = assetPath('models/cubes.gltf'); // has variants
|
||||
const MESH_PRIMITIVES_GLB_PATH =
|
||||
assetPath('models/MeshPrimitivesVariants.glb'); // has variants
|
||||
const CUBE_GLB_PATH = assetPath('models/cube.gltf'); // has UV coords
|
||||
const RIGGEDFIGURE_GLB_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/RiggedFigure/glTF-Binary/RiggedFigure.glb');
|
||||
|
||||
function getGLTFRoot(scene: ModelScene, hasBeenExportedOnce = false) {
|
||||
// TODO: export is putting in an extra node layer, because the loader
|
||||
// gives us a Group, but if the exporter doesn't get a Scene, then it
|
||||
// wraps everything in an "AuxScene" node. Feels like a three.js bug.
|
||||
return hasBeenExportedOnce ? scene.model!.children[0] : scene.model!;
|
||||
}
|
||||
|
||||
suite('SceneGraph', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
suite('scene export', () => {
|
||||
suite('transformations', () => {
|
||||
test(
|
||||
'setting scale before model loads has expected dimensions',
|
||||
async () => {
|
||||
element.scale = '1 2 3';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const dim = element.getDimensions();
|
||||
expect(dim.x).to.be.eq(1, 'x');
|
||||
expect(dim.y).to.be.eq(2, 'y');
|
||||
expect(dim.z).to.be.eq(3, 'z');
|
||||
});
|
||||
|
||||
test('orientation is applied after scale', async () => {
|
||||
element.orientation = '90deg 90deg 90deg';
|
||||
element.scale = '1 2 3';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const dim = element.getDimensions();
|
||||
expect(dim.x).to.be.closeTo(1, 0.001, 'x');
|
||||
expect(dim.y).to.be.closeTo(3, 0.001, 'y');
|
||||
expect(dim.z).to.be.closeTo(2, 0.001, 'z');
|
||||
});
|
||||
|
||||
test('exports and re-imports the rescaled model', async () => {
|
||||
element.scale = '1 2 3';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
element.scale = '1 1 1';
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
const dim = element.getDimensions();
|
||||
expect(dim.x).to.be.eq(1, 'x');
|
||||
expect(dim.y).to.be.eq(2, 'y');
|
||||
expect(dim.z).to.be.eq(3, 'z');
|
||||
});
|
||||
|
||||
test('exports and re-imports the transformed model', async () => {
|
||||
element.orientation = '90deg 90deg 90deg';
|
||||
element.scale = '1 2 3';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
|
||||
element.orientation = '0deg 0deg 0deg';
|
||||
element.scale = '1 1 1';
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
const dim = element.getDimensions();
|
||||
expect(dim.x).to.be.closeTo(1, 0.001, 'x');
|
||||
expect(dim.y).to.be.closeTo(3, 0.001, 'y');
|
||||
expect(dim.z).to.be.closeTo(2, 0.001, 'z');
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a loaded model', () => {
|
||||
setup(async () => {
|
||||
element.src = CUBES_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('exports the loaded model to GLTF', async () => {
|
||||
const exported = await element.exportScene({binary: false});
|
||||
expect(exported).to.be.not.undefined;
|
||||
expect(exported.size).to.be.greaterThan(500);
|
||||
});
|
||||
|
||||
test('exports the loaded model to GLB', async () => {
|
||||
const exported = await element.exportScene({binary: true});
|
||||
expect(exported).to.be.not.undefined;
|
||||
expect(exported.size).to.be.greaterThan(500);
|
||||
});
|
||||
|
||||
test('has variants', () => {
|
||||
expect(element[$scene].currentGLTF!.userData.variants.length)
|
||||
.to.be.eq(3);
|
||||
const gltfRoot = getGLTFRoot(element[$scene]);
|
||||
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
|
||||
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
|
||||
});
|
||||
|
||||
test('allows the scene graph to be manipulated', async () => {
|
||||
element.variantName = 'Yellow Red';
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
|
||||
const material =
|
||||
(element[$scene].model!.children[1] as Mesh).material as
|
||||
MeshStandardMaterial;
|
||||
|
||||
const mat = element.model!.getMaterialByName('red')!;
|
||||
|
||||
expect(mat.isActive).to.be.true;
|
||||
|
||||
mat.pbrMetallicRoughness.setBaseColorFactor([0.5, 0.5, 0.5, 1]);
|
||||
|
||||
const color = mat.pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(color).to.be.eql([0.5, 0.5, 0.5, 1]);
|
||||
|
||||
console.log(material.name, ': actual material ', material.uuid);
|
||||
|
||||
expect(material.color).to.include({r: 0.5, g: 0.5, b: 0.5});
|
||||
});
|
||||
|
||||
test(
|
||||
`Setting variantName to null results in primitive
|
||||
reverting to default/initial material`,
|
||||
async () => {
|
||||
let primitiveNode: PrimitiveNode|null = null
|
||||
// Finds the first primitive with material 0 assigned.
|
||||
for (const primitive of element.model![$primitivesList]) {
|
||||
if (primitive.variantInfo != null &&
|
||||
primitive.initialMaterialIdx == 0) {
|
||||
primitiveNode = primitive;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
expect(primitiveNode).to.not.be.null;
|
||||
|
||||
// Switches to a new variant.
|
||||
element.variantName = 'Yellow Red';
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
|
||||
.equal('red');
|
||||
|
||||
// Switches to null variant.
|
||||
element.variantName = null;
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
|
||||
.equal('purple');
|
||||
});
|
||||
|
||||
test('exports and re-imports the model with variants', async () => {
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
expect(element[$scene].currentGLTF!.userData.variants.length)
|
||||
.to.be.eq(3);
|
||||
const gltfRoot = getGLTFRoot(element[$scene], true);
|
||||
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
|
||||
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
|
||||
});
|
||||
});
|
||||
|
||||
suite(
|
||||
'with a loaded model containing a mesh with multiple primitives',
|
||||
() => {
|
||||
setup(async () => {
|
||||
element.src = MESH_PRIMITIVES_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('has variants', () => {
|
||||
expect(element[$scene].currentGLTF!.userData.variants.length)
|
||||
.to.be.eq(2);
|
||||
const gltfRoot = getGLTFRoot(element[$scene]);
|
||||
expect(
|
||||
gltfRoot.children[0].children[0].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
expect(
|
||||
gltfRoot.children[0].children[1].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
expect(
|
||||
gltfRoot.children[0].children[2].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
});
|
||||
|
||||
test(
|
||||
`Setting variantName to null results in primitive
|
||||
reverting to default/initial material`,
|
||||
async () => {
|
||||
let primitiveNode: PrimitiveNode|null = null
|
||||
// Finds the first primitive with material 0 assigned.
|
||||
for (const primitive of element.model![$primitivesList]) {
|
||||
if (primitive.variantInfo != null &&
|
||||
primitive.initialMaterialIdx == 0) {
|
||||
primitiveNode = primitive;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
expect(primitiveNode).to.not.be.null;
|
||||
|
||||
// Switches to a new variant.
|
||||
element.variantName = 'Inverse';
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
expect(
|
||||
(primitiveNode!.mesh.material as MeshStandardMaterial).name)
|
||||
.equal('STEEL RED X');
|
||||
|
||||
// Switches to null variant.
|
||||
element.variantName = null;
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
expect(
|
||||
(primitiveNode!.mesh.material as MeshStandardMaterial).name)
|
||||
.equal('STEEL METALLIC');
|
||||
});
|
||||
|
||||
test('exports and re-imports the model with variants', async () => {
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
expect(element[$scene].currentGLTF!.userData.variants.length)
|
||||
.to.be.eq(2);
|
||||
|
||||
const gltfRoot = getGLTFRoot(element[$scene], true);
|
||||
expect(
|
||||
gltfRoot.children[0].children[0].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
expect(
|
||||
gltfRoot.children[0].children[1].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
expect(
|
||||
gltfRoot.children[0].children[2].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.skip(
|
||||
'When loading a new JPEG texture from an ObjectURL, the GLB does not export PNG',
|
||||
async () => {
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
const url = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/DamagedHelmet/glTF/Default_albedo.jpg');
|
||||
const blob = await fetch(url).then(r => r.blob());
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const texture = await element.createTexture(objectUrl, 'image/jpeg');
|
||||
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture.setTexture(texture);
|
||||
|
||||
const exported = await element.exportScene({binary: true});
|
||||
expect(exported).to.be.not.undefined;
|
||||
// The JPEG is ~1 Mb and the equivalent PNG is about ~6 Mb, so this
|
||||
// just checks we saved an image and it wasn't too big.
|
||||
expect(exported.size).to.be.greaterThan(0.5e6);
|
||||
expect(exported.size).to.be.lessThan(1.5e6);
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a loaded scene graph', () => {
|
||||
let material: MeshStandardMaterial;
|
||||
|
||||
setup(async () => {
|
||||
element.src = ASTRONAUT_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
material =
|
||||
(element[$scene].model!.children[0].children[0] as Mesh).material as
|
||||
MeshStandardMaterial;
|
||||
});
|
||||
|
||||
test('allows the scene graph to be manipulated', async () => {
|
||||
await element.model!.materials[0].pbrMetallicRoughness.setBaseColorFactor(
|
||||
[1, 0, 0, 1]);
|
||||
|
||||
expect(material.color).to.include({r: 1, g: 0, b: 0});
|
||||
|
||||
const color =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(color).to.be.eql([1, 0, 0, 1]);
|
||||
});
|
||||
|
||||
suite('when the model changes', () => {
|
||||
test('updates when the model changes', async () => {
|
||||
const color =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(color).to.be.eql([0.5, 0.5, 0.5, 1]);
|
||||
|
||||
element.src = HORSE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const nextColor =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(nextColor).to.be.eql([1, 1, 1, 1]);
|
||||
});
|
||||
|
||||
test('allows the scene graph to be manipulated', async () => {
|
||||
element.src = HORSE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
await element.model!.materials[0]
|
||||
.pbrMetallicRoughness.setBaseColorFactor([1, 0, 0, 1]);
|
||||
|
||||
const color =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(color).to.be.eql([1, 0, 0, 1]);
|
||||
|
||||
const newMaterial =
|
||||
(element[$scene].model!.children[0] as Mesh).material as
|
||||
MeshStandardMaterial;
|
||||
|
||||
expect(newMaterial.color).to.include({r: 1, g: 0, b: 0});
|
||||
});
|
||||
});
|
||||
|
||||
suite('Scene-graph gltf-to-three mappings', () => {
|
||||
test('has a mapping for each primitive mesh', async () => {
|
||||
element.src = RIGGEDFIGURE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const gltf = (element as any)[$currentGLTF] as ModelViewerGLTFInstance;
|
||||
|
||||
for (const primitive of element.model![$primitivesList]) {
|
||||
expect(gltf.correlatedSceneGraph.threeObjectMap.get(primitive.mesh))
|
||||
.to.be.ok;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Material, MeshStandardMaterial, Texture as ThreeTexture} from 'three';
|
||||
|
||||
import {$threeTexture} from '../../../features/scene-graph/image.js';
|
||||
import {$lazyLoadGLTFInfo} from '../../../features/scene-graph/material.js';
|
||||
import {Model} from '../../../features/scene-graph/model.js';
|
||||
import {Texture} from '../../../features/scene-graph/texture.js';
|
||||
import {$correlatedObjects} from '../../../features/scene-graph/three-dom-element.js';
|
||||
import {ModelViewerElement} from '../../../model-viewer.js';
|
||||
import {waitForEvent} from '../../../utilities.js';
|
||||
import {assetPath} from '../../helpers.js';
|
||||
|
||||
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
|
||||
const HELMET_GLB_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/DamagedHelmet/glTF-Binary/DamagedHelmet.glb');
|
||||
const ALPHA_BLEND_MODE_TEST = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/AlphaBlendModeTest/glTF-Binary/AlphaBlendModeTest.glb');
|
||||
const REPLACEMENT_TEXTURE_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BoxTextured/glTF/CesiumLogoFlat.png');
|
||||
suite('scene-graph/material', () => {
|
||||
suite('Test Texture Slots', () => {
|
||||
let element: ModelViewerElement;
|
||||
let texture: Texture|null;
|
||||
let threeMaterials: Set<MeshStandardMaterial>;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = HELMET_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
texture = await element.createTexture(REPLACEMENT_TEXTURE_PATH);
|
||||
threeMaterials = element.model!.materials[0][$correlatedObjects] as
|
||||
Set<MeshStandardMaterial>;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
texture = null;
|
||||
});
|
||||
|
||||
test('Set a new base map', async () => {
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture].uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.map).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Set a new metallicRoughness map', async () => {
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.metallicRoughnessTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.metallicRoughnessTexture?.texture
|
||||
?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.metalnessMap).to.be.eq(threeTexture);
|
||||
expect(material.roughnessMap).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Set a new normal map', async () => {
|
||||
element.model!.materials[0].normalTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.normalTexture?.texture?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.normalMap).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Set a new occlusion map', async () => {
|
||||
element.model!.materials[0].occlusionTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.occlusionTexture?.texture?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.aoMap).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Set a new emissive map', async () => {
|
||||
element.model!.materials[0].emissiveTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.emissiveTexture?.texture?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.emissiveMap).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Material properties', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
const loadModel = async (path: string) => {
|
||||
element.src = path;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
};
|
||||
|
||||
test('test alpha cutoff expect disabled by default', async () => {
|
||||
await loadModel(HELMET_GLB_PATH);
|
||||
expect((element.model!.materials[0]![$correlatedObjects]
|
||||
?.values()
|
||||
.next()
|
||||
.value as Material)
|
||||
.alphaTest)
|
||||
.to.be.equal(0);
|
||||
});
|
||||
|
||||
test('test alpha cutoff expect valid value as default', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
expect(element.model!.materials[2].getAlphaCutoff()).to.be.equal(0.25);
|
||||
});
|
||||
|
||||
test('test alpha cutoff test setting and getting', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
element.model!.materials[2].setAlphaCutoff(0.5);
|
||||
expect(element.model!.materials[2].getAlphaCutoff()).to.be.equal(0.5);
|
||||
});
|
||||
|
||||
test('test double sided expect default is false', async () => {
|
||||
await loadModel(HELMET_GLB_PATH);
|
||||
expect(element.model!.materials[0].getDoubleSided()).to.be.equal(false);
|
||||
});
|
||||
|
||||
test('test double sided expect default is true', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(true);
|
||||
});
|
||||
|
||||
test('test double sided setting and getting', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(true);
|
||||
|
||||
element.model!.materials[1].setDoubleSided(false);
|
||||
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(false);
|
||||
|
||||
element.model!.materials[1].setDoubleSided(true);
|
||||
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(true);
|
||||
});
|
||||
|
||||
test('test alpha-mode, setting and getting', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
|
||||
element.model!.materials[0].setAlphaMode('BLEND');
|
||||
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('BLEND');
|
||||
element.model!.materials[0].setAlphaMode('MASK');
|
||||
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('MASK');
|
||||
element.model!.materials[0].setAlphaMode('OPAQUE');
|
||||
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('OPAQUE');
|
||||
});
|
||||
|
||||
test('test alpha-mode, expect default of opaque', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
|
||||
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('OPAQUE');
|
||||
});
|
||||
|
||||
test('test alpha-mode, expect default of blend', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
|
||||
expect(element.model!.materials[1].getAlphaMode()).to.be.equal('BLEND');
|
||||
});
|
||||
|
||||
test('test alpha-mode, expect default of mask', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
|
||||
expect(element.model!.materials[2].getAlphaMode()).to.be.equal('MASK');
|
||||
});
|
||||
});
|
||||
|
||||
suite('Material lazy loading', () => {
|
||||
let element: ModelViewerElement;
|
||||
let model: Model;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = CUBES_GLTF_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
model = element.model as Model;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('Accessing the name getter does not cause throw error.', async () => {
|
||||
expect(model.materials[2].name).to.equal('red');
|
||||
expect(model.materials[2][$lazyLoadGLTFInfo]).to.be.ok;
|
||||
});
|
||||
|
||||
test(
|
||||
'Accessing a getter of an unloaded material throws an error.',
|
||||
async () => {
|
||||
expect(() => {model.materials[2].pbrMetallicRoughness}).to.throw;
|
||||
expect(model.materials[2].isLoaded).to.be.false;
|
||||
});
|
||||
|
||||
test(
|
||||
'Accessing a getter of a loaded material has valid data.', async () => {
|
||||
await model.materials[2].ensureLoaded();
|
||||
expect(model.materials[2].isLoaded).to.be.true;
|
||||
expect(model.materials[2].name).to.equal('red');
|
||||
const pbr = model.materials[2].pbrMetallicRoughness;
|
||||
expect(pbr).to.be.ok;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {MeshStandardMaterial} from 'three/src/materials/MeshStandardMaterial.js';
|
||||
import {Mesh} from 'three/src/objects/Mesh.js';
|
||||
|
||||
import {$lazyLoadGLTFInfo} from '../../../features/scene-graph/material.js';
|
||||
import {$availableVariants, $materials, $primitivesList, $switchVariant, Model} from '../../../features/scene-graph/model.js';
|
||||
import {$correlatedObjects} from '../../../features/scene-graph/three-dom-element.js';
|
||||
import {$scene} from '../../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../../model-viewer.js';
|
||||
import {CorrelatedSceneGraph} from '../../../three-components/gltf-instance/correlated-scene-graph.js';
|
||||
import {waitForEvent} from '../../../utilities.js';
|
||||
import {assetPath, loadThreeGLTF, rafPasses} from '../../helpers.js';
|
||||
|
||||
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
const KHRONOS_TRIANGLE_GLB_PATH =
|
||||
assetPath('models/glTF-Sample-Assets/Models/Triangle/glTF/Triangle.gltf');
|
||||
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
|
||||
|
||||
suite('scene-graph/model', () => {
|
||||
suite('Model', () => {
|
||||
test('creates a "default" material, when none is specified', async () => {
|
||||
const threeGLTF = await loadThreeGLTF(KHRONOS_TRIANGLE_GLB_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
|
||||
expect(model.materials.length).to.be.eq(1);
|
||||
expect(model.materials[0].name).to.be.eq('Default');
|
||||
});
|
||||
|
||||
test.skip('exposes a list of materials in the scene', async () => {
|
||||
// TODO: This test is skipped because [$correlatedObjects] can contain
|
||||
// unused materials, because it can contain a base material and the
|
||||
// derived material (from assignFinalMaterial(), if for instance
|
||||
// vertexTangents are used) even if only the derived material is assigned
|
||||
// to a mesh. These extras make the test fail. We may want to remove these
|
||||
// unused materials from [$correlatedObjects] at which point this test
|
||||
// will pass, but it's not hurting anything.
|
||||
const threeGLTF = await loadThreeGLTF(ASTRONAUT_GLB_PATH);
|
||||
const materials: Set<MeshStandardMaterial> = new Set();
|
||||
|
||||
threeGLTF.scene.traverse((object) => {
|
||||
if ((object as Mesh).isMesh) {
|
||||
const material = (object as Mesh).material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach(
|
||||
(material) => materials.add(material as MeshStandardMaterial));
|
||||
} else {
|
||||
materials.add(material as MeshStandardMaterial);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
|
||||
const collectedMaterials = new Set<MeshStandardMaterial>();
|
||||
|
||||
model.materials.forEach((material) => {
|
||||
for (const threeMaterial of material[$correlatedObjects] as
|
||||
Set<MeshStandardMaterial>) {
|
||||
collectedMaterials.add(threeMaterial);
|
||||
expect(materials.has(threeMaterial)).to.be.true;
|
||||
}
|
||||
});
|
||||
|
||||
expect(collectedMaterials.size).to.be.equal(materials.size);
|
||||
});
|
||||
|
||||
suite('Model Variants', () => {
|
||||
test('Switch variant and lazy load', async () => {
|
||||
const threeGLTF = await loadThreeGLTF(CUBES_GLTF_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
expect(model[$materials][2][$correlatedObjects]).to.be.empty;
|
||||
expect(model[$materials][2][$lazyLoadGLTFInfo]).to.be.ok;
|
||||
|
||||
await model[$switchVariant]('Yellow Red');
|
||||
|
||||
expect(model[$materials][2][$correlatedObjects]).to.not.be.empty;
|
||||
expect(model[$materials][2][$lazyLoadGLTFInfo]).to.not.be.ok;
|
||||
});
|
||||
|
||||
test(
|
||||
'Switch back to default variant does not change correlations',
|
||||
async () => {
|
||||
const threeGLTF = await loadThreeGLTF(CUBES_GLTF_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
|
||||
const sizeBeforeSwitch =
|
||||
model[$materials][0][$correlatedObjects]!.size;
|
||||
|
||||
await model[$switchVariant]('Yellow Yellow');
|
||||
// Switches back to default.
|
||||
await model[$switchVariant]('Purple Yellow');
|
||||
|
||||
expect(model[$materials][0][$correlatedObjects]!.size)
|
||||
.equals(sizeBeforeSwitch);
|
||||
});
|
||||
|
||||
test(
|
||||
'Switching variant when model has no variants has not effect',
|
||||
async () => {
|
||||
const threeGLTF = await loadThreeGLTF(KHRONOS_TRIANGLE_GLB_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
|
||||
const threeMaterial =
|
||||
model[$materials][0][$correlatedObjects]!.values().next().value;
|
||||
const sizeBeforeSwitch =
|
||||
model[$materials][0][$correlatedObjects]!.size;
|
||||
await model[$switchVariant]('Does not exist');
|
||||
|
||||
expect(
|
||||
model[$materials][0][$correlatedObjects]!.values().next().value)
|
||||
.equals(threeMaterial);
|
||||
expect(model[$materials][0][$correlatedObjects]!.size)
|
||||
.equals(sizeBeforeSwitch);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Model e2e test', () => {
|
||||
let element: ModelViewerElement;
|
||||
let model: Model;
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
const loadModel = async (src: string) => {
|
||||
element.src = src;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
model = element.model!;
|
||||
};
|
||||
|
||||
test('getMaterialByName returns material when name exists', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
const material = model.getMaterialByName('red')!;
|
||||
expect(material).to.be.ok;
|
||||
expect(material.name).to.be.equal('red');
|
||||
});
|
||||
|
||||
test(
|
||||
'getMaterialByName returns null when name does not exists',
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
const material = model.getMaterialByName('does-not-exist')!;
|
||||
expect(material).to.be.null;
|
||||
});
|
||||
|
||||
suite('Create Variant', () => {
|
||||
test(
|
||||
`createMaterialInstanceForVariant() adds new primitive variants mapping
|
||||
only to primitives that use the source material`,
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
const primitive1 = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box';
|
||||
})!;
|
||||
const primitive2 = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box_1';
|
||||
})!;
|
||||
|
||||
const startingSize = primitive1.variantInfo.size;
|
||||
const startingSize2 = primitive2.variantInfo.size;
|
||||
|
||||
// Creates a variant from material 0.
|
||||
expect(model.createMaterialInstanceForVariant(
|
||||
0, 'test-material', 'test-variant'))
|
||||
.to.be.ok;
|
||||
|
||||
// primitive1 uses material '0' so it should have a vew variant.
|
||||
expect(primitive1.variantInfo.size).to.equal(startingSize + 1);
|
||||
// primitive2 to should remain unchanged.
|
||||
expect(primitive2.variantInfo.size).to.equal(startingSize2);
|
||||
});
|
||||
|
||||
test('Create variant and switch to it', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
const primitive = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box';
|
||||
})!;
|
||||
|
||||
model.createMaterialInstanceForVariant(
|
||||
0, 'test-material', 'test-variant');
|
||||
|
||||
element.variantName = 'test-variant';
|
||||
|
||||
await element.updateComplete;
|
||||
expect(primitive.getActiveMaterial().name).to.equal('test-material');
|
||||
});
|
||||
|
||||
test('New variant is available to model-viewer', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
model.createMaterialInstanceForVariant(
|
||||
0, 'test-material', 'test-variant');
|
||||
|
||||
expect(element.availableVariants.find(variant => {
|
||||
return variant === 'test-variant';
|
||||
})).to.be.ok;
|
||||
});
|
||||
|
||||
test(
|
||||
`createMaterialInstanceForVariant() fails when there is a variant name
|
||||
collision`,
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
expect(model.createMaterialInstanceForVariant(
|
||||
0, 'test-material', 'Purple Yellow'))
|
||||
.to.be.null;
|
||||
});
|
||||
|
||||
test('createVariant() creates a variant', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
model.createVariant('test-variant');
|
||||
|
||||
expect(element.availableVariants.find(variant => {
|
||||
return variant === 'test-variant';
|
||||
})).to.be.ok;
|
||||
});
|
||||
|
||||
test('createVariant() is a noop if the variant exists', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
const length = model[$availableVariants]().length;
|
||||
model.createVariant('Purple Yellow');
|
||||
|
||||
expect(length).to.equal(model[$availableVariants]().length);
|
||||
});
|
||||
|
||||
test(
|
||||
`setMaterialToVariant() adds variants mapping
|
||||
only to primitives that use the source material`,
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
const primitive1 = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box';
|
||||
})!;
|
||||
const primitive2 = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box_1';
|
||||
})!;
|
||||
|
||||
const startingSize = primitive1.variantInfo.size;
|
||||
const startingSize2 = primitive2.variantInfo.size;
|
||||
|
||||
model.createVariant('test-variant');
|
||||
|
||||
// Adds material 0 to the variant.
|
||||
model.setMaterialToVariant(0, 'test-variant');
|
||||
|
||||
// primitive1 uses material '0' so it should have a vew variant.
|
||||
expect(primitive1.variantInfo.size).to.equal(startingSize + 1);
|
||||
// primitive2 to should remain unchanged.
|
||||
expect(primitive2.variantInfo.size).to.equal(startingSize2);
|
||||
});
|
||||
|
||||
test('updateVariantName() updates the variant name', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
element.variantName = 'Yellow Red';
|
||||
await element.updateComplete;
|
||||
|
||||
element.model!.updateVariantName('Yellow Red', 'NewName');
|
||||
|
||||
expect(element.availableVariants[2]).equal('NewName');
|
||||
});
|
||||
|
||||
test(
|
||||
'deleteVariant() removes variant from primitives, materials and available variants.',
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
element.model!.deleteVariant('Yellow Red');
|
||||
|
||||
// Removed from the list of available variants.
|
||||
expect(element.availableVariants.length).equal(2);
|
||||
|
||||
// No longer present in primitives
|
||||
for (const primitive of model[$primitivesList]) {
|
||||
if (primitive.variantInfo.size > 0) {
|
||||
expect(primitive.variantInfo.has(2)).to.be.false;
|
||||
}
|
||||
}
|
||||
|
||||
// Materials do not reference the variant.
|
||||
for (const material of model.materials) {
|
||||
expect(material.hasVariant('Yellow Red')).to.be.false;
|
||||
}
|
||||
});
|
||||
|
||||
test('hasVariant() positive and negative test', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
expect(model.hasVariant('Yellow Red')).to.be.true;
|
||||
expect(model.hasVariant('DoesNotExist')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
suite('Intersecting', () => {
|
||||
test('materialFromPoint returns material', async () => {
|
||||
await loadModel(ASTRONAUT_GLB_PATH);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
const material = element.materialFromPoint(
|
||||
element[$scene].width / 2, element[$scene].height / 2)!;
|
||||
|
||||
expect(material).to.be.ok;
|
||||
});
|
||||
|
||||
test(
|
||||
'materialFromPoint returns null when intersect fails', async () => {
|
||||
await loadModel(ASTRONAUT_GLB_PATH);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
const material = element.materialFromPoint(
|
||||
element[$scene].width, element[$scene].height)!;
|
||||
expect(material).to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {MeshStandardMaterial} from 'three/src/materials/MeshStandardMaterial.js';
|
||||
|
||||
import {$primitivesList, $variantData, Model} from '../../../../features/scene-graph/model.js';
|
||||
import {ModelViewerElement} from '../../../../model-viewer.js';
|
||||
import {CorrelatedSceneGraph} from '../../../../three-components/gltf-instance/correlated-scene-graph.js';
|
||||
import {waitForEvent} from '../../../../utilities.js';
|
||||
import {assetPath, loadThreeGLTF} from '../../../helpers.js';
|
||||
|
||||
const BRAIN_STEM_GLB_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BrainStem/glTF-Binary/BrainStem.glb');
|
||||
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
|
||||
const CUBE_GLTF_PATH = assetPath('models/cube.gltf');
|
||||
const MESH_PRIMITIVES_GLB_PATH = assetPath('models/MeshPrimitivesVariants.glb');
|
||||
const KHRONOS_TRIANGLE_GLB_PATH =
|
||||
assetPath('models/glTF-Sample-Assets/Models/Triangle/glTF/Triangle.gltf');
|
||||
|
||||
const findPrimitivesWithVariant = (model: Model, variantName: string) => {
|
||||
const result = new Array<any>();
|
||||
for (const primitive of model![$primitivesList]) {
|
||||
if (primitive.variantInfo != null && model.hasVariant(variantName) &&
|
||||
primitive.variantInfo.has(
|
||||
model[$variantData].get(variantName)!.index)) {
|
||||
result.push(primitive);
|
||||
}
|
||||
}
|
||||
return result.length > 0 ? result : null;
|
||||
};
|
||||
|
||||
suite('scene-graph/model/mesh-primitives', () => {
|
||||
suite('Primitive with default material', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = KHRONOS_TRIANGLE_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('has a default material', async () => {
|
||||
const model = element.model!;
|
||||
expect(model[$primitivesList].length).to.equal(1);
|
||||
expect(model.materials.length).to.equal(1);
|
||||
expect(model[$primitivesList][0].initialMaterialIdx).to.equal(0);
|
||||
expect(model.materials[0].name).to.equal('Default');
|
||||
});
|
||||
});
|
||||
|
||||
suite('Static Primitive Without Variant', () => {
|
||||
let model: Model;
|
||||
setup(async () => {
|
||||
const threeGLTF = await loadThreeGLTF(CUBE_GLTF_PATH);
|
||||
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
});
|
||||
|
||||
test('Should not have any primitives with variant info', async () => {
|
||||
let hasVariantInfoCount = 0;
|
||||
for (const primitive of model![$primitivesList]) {
|
||||
if (primitive.variantInfo.size > 0) {
|
||||
hasVariantInfoCount++;
|
||||
}
|
||||
}
|
||||
expect(hasVariantInfoCount).equals(0);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Static Primitive With Variant', () => {
|
||||
let model: Model;
|
||||
setup(async () => {
|
||||
const threeGLTF = await loadThreeGLTF(CUBES_GLTF_PATH);
|
||||
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
});
|
||||
|
||||
test('Primitive count matches glTF file', async () => {
|
||||
expect(model![$primitivesList].length).to.equal(2);
|
||||
});
|
||||
|
||||
test('Primitives should have expected variant names', async () => {
|
||||
expect(findPrimitivesWithVariant(model, 'Purple Yellow')).to.not.be.null;
|
||||
expect(findPrimitivesWithVariant(model, 'Yellow Yellow')).to.not.be.null;
|
||||
expect(findPrimitivesWithVariant(model, 'Yellow Red')).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Should not have any primitives without variant info', async () => {
|
||||
let hasNoVariantInfoCount = 0;
|
||||
for (const primitive of model![$primitivesList]) {
|
||||
if (primitive.variantInfo == null) {
|
||||
hasNoVariantInfoCount++;
|
||||
}
|
||||
}
|
||||
expect(hasNoVariantInfoCount).equals(0);
|
||||
});
|
||||
|
||||
test('Switching to incorrect variant name', async () => {
|
||||
const primitive = findPrimitivesWithVariant(model, 'Purple Yellow')![0];
|
||||
const material = await primitive.enableVariant('Does not exist');
|
||||
expect(material).to.be.null;
|
||||
});
|
||||
|
||||
test('Switching to current variant', async () => {
|
||||
const primitives = findPrimitivesWithVariant(model, 'Purple Yellow')!;
|
||||
const materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Yellow Yellow') as
|
||||
MeshStandardMaterial);
|
||||
}
|
||||
expect(materials).to.not.be.empty;
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === 'yellow';
|
||||
})).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Switching to variant and then switch back', async () => {
|
||||
const primitives = findPrimitivesWithVariant(model, 'Purple Yellow')!;
|
||||
let materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Yellow Yellow') as
|
||||
MeshStandardMaterial);
|
||||
}
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === 'yellow';
|
||||
})).to.not.be.null;
|
||||
|
||||
materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Purple Yellow') as
|
||||
MeshStandardMaterial);
|
||||
}
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === 'purple';
|
||||
})).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Primitive switches to initial material', async () => {
|
||||
const primitive = findPrimitivesWithVariant(model, 'Purple Yellow')![0];
|
||||
|
||||
// Gets current material.
|
||||
const initialMaterial = await primitive.enableVariant('Purple Yellow');
|
||||
// Switches to variant.
|
||||
const variantMaterial = await primitive.enableVariant('Yellow Red');
|
||||
expect(initialMaterial).to.not.equal(variantMaterial)
|
||||
// Switches to initial material.
|
||||
const resetMaterial = await primitive.enableVariant(null);
|
||||
expect(resetMaterial).to.equal(initialMaterial);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Mesh with multiple primitives each with variants', () => {
|
||||
let model: Model;
|
||||
setup(async () => {
|
||||
const threeGLTF = await loadThreeGLTF(MESH_PRIMITIVES_GLB_PATH);
|
||||
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
});
|
||||
|
||||
test('Primitive count matches glTF file', async () => {
|
||||
expect(model![$primitivesList].length).to.equal(3);
|
||||
});
|
||||
|
||||
test('Primitives should have expected variant names', async () => {
|
||||
expect(findPrimitivesWithVariant(model, 'Normal')).to.not.be.null;
|
||||
expect(findPrimitivesWithVariant(model, 'Inverse')).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Switching to incorrect variant name', async () => {
|
||||
const primitive = findPrimitivesWithVariant(model, 'Normal')![0];
|
||||
const material = await primitive.enableVariant('Does not exist');
|
||||
expect(material).to.be.null;
|
||||
});
|
||||
|
||||
test('Switching to variant and then switch back', async () => {
|
||||
const MATERIAL_NAME = 'STEEL BLACK';
|
||||
const primitives = findPrimitivesWithVariant(model, 'Normal')!;
|
||||
let materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Inverse') as MeshStandardMaterial);
|
||||
}
|
||||
|
||||
expect(materials).to.not.be.empty;
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === MATERIAL_NAME;
|
||||
})).to.be.undefined;
|
||||
|
||||
materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Normal') as MeshStandardMaterial);
|
||||
}
|
||||
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === MATERIAL_NAME;
|
||||
})).to.be.ok;
|
||||
});
|
||||
|
||||
test('Primitive switches to initial material', async () => {
|
||||
const primitive = findPrimitivesWithVariant(model, 'Normal')![0];
|
||||
|
||||
// Gets current material.
|
||||
const initialMaterial = await primitive.enableVariant('Normal');
|
||||
// Switches to variant.
|
||||
const variantMaterial = await primitive.enableVariant('Inverse');
|
||||
expect(initialMaterial).to.not.equal(variantMaterial)
|
||||
// Switches to initial material.
|
||||
const resetMaterial = await primitive.enableVariant(null);
|
||||
expect(resetMaterial).to.equal(initialMaterial);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Skinned Primitive Without Variant', () => {
|
||||
test('Primitive count matches glTF file', async () => {
|
||||
const threeGLTF = await loadThreeGLTF(BRAIN_STEM_GLB_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
expect(model![$primitivesList].length).to.equal(59);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {TextureInfo} from '../../../features/scene-graph/texture-info.js';
|
||||
import {ModelViewerElement} from '../../../model-viewer.js';
|
||||
import {waitForEvent} from '../../../utilities.js';
|
||||
import {assetPath} from '../../helpers.js';
|
||||
|
||||
const DUCK_GLB_PATH =
|
||||
assetPath('models/glTF-Sample-Assets/Models/Duck/glTF-Binary/Duck.glb');
|
||||
const TEXTURED_CUBE_GLB_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BoxTextured/glTF-Binary/BoxTextured.glb');
|
||||
|
||||
suite('scene-graph/texture-info', () => {
|
||||
suite('texture-info', () => {
|
||||
let element: ModelViewerElement;
|
||||
let emptyTextureInfo: TextureInfo;
|
||||
let baseTextureInfo: TextureInfo;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = DUCK_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
emptyTextureInfo = element.model!.materials[0].normalTexture;
|
||||
baseTextureInfo =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorTexture;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('empty slot is null', async () => {
|
||||
// The duck doesn't have a normal texture.
|
||||
expect(emptyTextureInfo.texture).to.be.null;
|
||||
});
|
||||
|
||||
test('non-empty slot is not null', async () => {
|
||||
// The duck doesn't have a normal texture.
|
||||
expect(baseTextureInfo.texture).to.not.be.null;
|
||||
});
|
||||
|
||||
test('call setTexture', async () => {
|
||||
const texture = await element.createTexture(assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BoxTextured/glTF/CesiumLogoFlat.png'));
|
||||
|
||||
// Setting a texture, the normal texture should _not_ be null.
|
||||
emptyTextureInfo.setTexture(texture);
|
||||
expect(emptyTextureInfo.texture).to.not.be.null;
|
||||
|
||||
// Clearing a texture, the normal texture _should_ be null again.
|
||||
emptyTextureInfo.setTexture(null);
|
||||
expect(emptyTextureInfo.texture).to.be.null;
|
||||
});
|
||||
|
||||
test(
|
||||
'exports and re-imports the model with transformed texture',
|
||||
async () => {
|
||||
// Load textured glb.
|
||||
element.src = TEXTURED_CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
// Transform the textures.
|
||||
const sampler = element.model?.materials[0]
|
||||
.pbrMetallicRoughness['baseColorTexture']
|
||||
.texture?.sampler!;
|
||||
sampler.setRotation(0.1);
|
||||
sampler.setOffset({u: 0.2, v: 0.3});
|
||||
sampler.setScale({u: 0.4, v: 0.5});
|
||||
|
||||
// Export model.
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
|
||||
// Re-load model.
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const exported_sampler = element.model?.materials[0]
|
||||
.pbrMetallicRoughness['baseColorTexture']
|
||||
.texture?.sampler!;
|
||||
expect(exported_sampler.rotation).to.be.eq(0.1, 'rotation');
|
||||
expect(exported_sampler.offset!.u).to.be.eql(0.2, 'offset_u');
|
||||
expect(exported_sampler.offset!.v).to.be.eql(0.3, 'offset_v');
|
||||
expect(exported_sampler.scale!.u).to.be.eql(0.4, 'scale_u');
|
||||
expect(exported_sampler.scale!.v).to.be.eql(0.5, 'scale_v');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Texture as ThreeTexture} from 'three';
|
||||
|
||||
import {$threeTexture} from '../../../features/scene-graph/image.js';
|
||||
import {Texture} from '../../../features/scene-graph/texture.js';
|
||||
import {$correlatedObjects} from '../../../features/scene-graph/three-dom-element.js';
|
||||
import {ModelViewerElement} from '../../../model-viewer.js';
|
||||
import {waitForEvent} from '../../../utilities.js';
|
||||
import {assetPath} from '../../helpers.js';
|
||||
|
||||
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
suite('scene-graph/texture', () => {
|
||||
suite('Texture', () => {
|
||||
let element: ModelViewerElement;
|
||||
let texture: Texture|null;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = ASTRONAUT_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
texture = await element.createTexture(assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BoxTextured/glTF/CesiumLogoFlat.png'));
|
||||
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture!.setTexture(texture);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
texture = null;
|
||||
});
|
||||
|
||||
|
||||
test('Create a texture', async () => {
|
||||
expect(texture).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Set a texture', async () => {
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.source[$threeTexture]!;
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Verify legacy correlatedObjects are updated.', async () => {
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const uuidFromTextureObject: string =
|
||||
(element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.[$correlatedObjects]
|
||||
?.values()
|
||||
.next()
|
||||
.value as ThreeTexture)
|
||||
.uuid;
|
||||
expect(uuidFromTextureObject).to.be.equal(newUUID);
|
||||
|
||||
const uuidFromSamplerObject: string =
|
||||
(element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.sampler[$correlatedObjects]
|
||||
?.values()
|
||||
.next()
|
||||
.value as ThreeTexture)
|
||||
.uuid;
|
||||
expect(uuidFromSamplerObject).to.be.equal(newUUID);
|
||||
|
||||
const uuidFromImageObject: string =
|
||||
(element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.source[$correlatedObjects]
|
||||
?.values()
|
||||
.next()
|
||||
.value as ThreeTexture)
|
||||
.uuid;
|
||||
expect(uuidFromImageObject).to.be.equal(newUUID);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {CameraChangeDetails} from '../../features/controls.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ChangeSource} from '../../three-components/SmoothControls.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses} from '../helpers.js';
|
||||
|
||||
const ODD_SHAPE_GLB_PATH = assetPath('models/odd-shape.glb');
|
||||
const AUTO_ROTATE_DELAY = 50;
|
||||
|
||||
suite('Staging', () => {
|
||||
suite('with a visible loaded model', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = ODD_SHAPE_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('can manually rotate turntable', () => {
|
||||
element.resetTurntableRotation(3);
|
||||
expect(element.turntableRotation).to.be.equal(3);
|
||||
|
||||
element.resetTurntableRotation();
|
||||
expect(element.turntableRotation).to.be.equal(0);
|
||||
});
|
||||
|
||||
suite('auto-rotate', () => {
|
||||
setup(async () => {
|
||||
element.autoRotate = true;
|
||||
element.autoRotateDelay = AUTO_ROTATE_DELAY;
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test.skip('causes the model to rotate after a delay', async () => {
|
||||
const {turntableRotation} = element;
|
||||
await rafPasses();
|
||||
expect(element.turntableRotation).to.be.equal(turntableRotation);
|
||||
await timePasses(AUTO_ROTATE_DELAY);
|
||||
await rafPasses();
|
||||
expect(element.turntableRotation).to.be.greaterThan(turntableRotation);
|
||||
});
|
||||
|
||||
test(
|
||||
'retains turntable rotation when auto-rotate is toggled',
|
||||
async () => {
|
||||
element.autoRotateDelay = 0;
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
const {turntableRotation} = element;
|
||||
|
||||
expect(turntableRotation).to.be.greaterThan(0);
|
||||
|
||||
element.autoRotate = false;
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation).to.be.equal(turntableRotation);
|
||||
|
||||
element.autoRotate = true;
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation)
|
||||
.to.be.greaterThan(turntableRotation);
|
||||
});
|
||||
|
||||
test.skip('pauses rotate after user interaction', async () => {
|
||||
const {turntableRotation} = element;
|
||||
await timePasses(AUTO_ROTATE_DELAY);
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
const {turntableRotation: initialTurntableRotation} = element;
|
||||
expect(initialTurntableRotation).to.be.greaterThan(turntableRotation);
|
||||
|
||||
element.dispatchEvent(new CustomEvent<CameraChangeDetails>(
|
||||
'camera-change',
|
||||
{detail: {source: ChangeSource.USER_INTERACTION}}));
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation).to.be.equal(initialTurntableRotation);
|
||||
|
||||
await timePasses(AUTO_ROTATE_DELAY);
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation)
|
||||
.to.be.greaterThan(initialTurntableRotation);
|
||||
});
|
||||
|
||||
suite('when the model is not visible', () => {
|
||||
setup(() => {
|
||||
Object.defineProperty(element, 'modelIsVisible', {value: false});
|
||||
});
|
||||
|
||||
test('does not cause the model to rotate over time', async () => {
|
||||
const {turntableRotation} = element;
|
||||
|
||||
await timePasses(AUTO_ROTATE_DELAY);
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation).to.be.equal(turntableRotation);
|
||||
});
|
||||
});
|
||||
|
||||
suite('with zero auto-rotate-delay', () => {
|
||||
setup(async () => {
|
||||
element.autoRotateDelay = 0;
|
||||
await timePasses();
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('causes the model to rotate ASAP', async () => {
|
||||
const {turntableRotation} = element;
|
||||
await rafPasses();
|
||||
expect(element.turntableRotation)
|
||||
.to.be.greaterThan(turntableRotation);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {Group} from 'three';
|
||||
import {GLTF as ThreeGLTF, GLTFLoader, GLTFParser} from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
import {ExpressionNode, ExpressionTerm, FunctionNode, HexNode, IdentNode, Operator, OperatorNode} from '../styles/parsers.js';
|
||||
import {deserializeUrl, PredicateFunction, timePasses} from '../utilities.js';
|
||||
|
||||
export const elementFromLocalPoint =
|
||||
(document: Document|ShadowRoot, x: number, y: number): Element|null => {
|
||||
const host: HTMLElement = (document === window.document) ?
|
||||
window.document.body :
|
||||
(document as ShadowRoot).host as HTMLElement;
|
||||
const actualDocument =
|
||||
(window as any).ShadyCSS ? window.document : document;
|
||||
const boundingRect = host.getBoundingClientRect();
|
||||
|
||||
return actualDocument.elementFromPoint(
|
||||
boundingRect.left + x, boundingRect.top + y);
|
||||
};
|
||||
|
||||
export const pickShadowDescendant =
|
||||
(element: Element, x: number = 0, y: number = 0): Element|null => {
|
||||
return element.shadowRoot != null ?
|
||||
elementFromLocalPoint(element.shadowRoot, x, y) :
|
||||
null;
|
||||
};
|
||||
|
||||
export const until =
|
||||
async (predicate: PredicateFunction) => {
|
||||
while (!predicate()) {
|
||||
await timePasses();
|
||||
}
|
||||
}
|
||||
|
||||
export const rafPasses = (): Promise<void> =>
|
||||
new Promise(resolve => requestAnimationFrame(() => resolve()));
|
||||
|
||||
export interface SyntheticEventProperties {
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
deltaY?: number;
|
||||
key?: string;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a synthetic event on a given element with a given type, and
|
||||
* optionally with custom event properties. Returns the dispatched event.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {type} string
|
||||
* @param {*} properties
|
||||
*/
|
||||
export const dispatchSyntheticEvent =
|
||||
(target: EventTarget, type: string, properties: SyntheticEventProperties = {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
deltaY: 1.0
|
||||
}): CustomEvent => {
|
||||
const event = new CustomEvent(type, {cancelable: true, bubbles: true});
|
||||
Object.assign(event, properties);
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
|
||||
export const ASSETS_DIRECTORY = 'packages/shared-assets/';
|
||||
|
||||
/**
|
||||
* Returns the full path for an asset by name. This is a convenience helper so
|
||||
* that we don't need to change paths throughout all test suites if we ever
|
||||
* decide to move files around.
|
||||
*
|
||||
* @param {string} name
|
||||
* @return {string}
|
||||
*/
|
||||
export const assetPath = (name: string): string =>
|
||||
deserializeUrl(`${ASSETS_DIRECTORY}${name}`)!;
|
||||
|
||||
/**
|
||||
* Returns true if the given element is in the tree of the document of the
|
||||
* current frame.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const isInDocumentTree = (node: Node): boolean => {
|
||||
let root: Node = node.getRootNode();
|
||||
|
||||
while (root !== node && root != null) {
|
||||
if (root.nodeType === Node.DOCUMENT_NODE) {
|
||||
return root === document;
|
||||
}
|
||||
|
||||
root = (root as ShadowRoot).host && (root as ShadowRoot).host.getRootNode();
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* "Spies" on a particular object by replacing a specified part of its
|
||||
* implementation with a custom version. Returns a function that restores the
|
||||
* original implementation to the object when invoked.
|
||||
*/
|
||||
export const spy =
|
||||
(object: Object,
|
||||
property: string,
|
||||
descriptor: PropertyDescriptor): () => void => {
|
||||
let sourcePrototype = object;
|
||||
|
||||
while (sourcePrototype != null &&
|
||||
!sourcePrototype.hasOwnProperty(property)) {
|
||||
sourcePrototype = (sourcePrototype as any).__proto__;
|
||||
}
|
||||
|
||||
if (sourcePrototype == null) {
|
||||
throw new Error(`Cannot spy property "${property}" on ${object}`);
|
||||
}
|
||||
|
||||
const originalDescriptor =
|
||||
Object.getOwnPropertyDescriptor(sourcePrototype, property);
|
||||
|
||||
if (originalDescriptor == null) {
|
||||
throw new Error(`Cannot read descriptor of "${property}" on ${object}`);
|
||||
}
|
||||
|
||||
Object.defineProperty(sourcePrototype, property, descriptor);
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(sourcePrototype, property, originalDescriptor);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Helpers to assist in generating AST test fixtures
|
||||
*/
|
||||
export const expressionNode = (terms: Array<ExpressionTerm>): ExpressionNode =>
|
||||
({type: 'expression', terms});
|
||||
|
||||
export const hexNode = (value: string): HexNode => ({type: 'hex', value});
|
||||
|
||||
export const identNode = (value: string): IdentNode => ({type: 'ident', value});
|
||||
|
||||
export const operatorNode = (value: Operator): OperatorNode =>
|
||||
({type: 'operator', value});
|
||||
|
||||
export const functionNode =
|
||||
(name: string, args: Array<ExpressionNode>): FunctionNode =>
|
||||
({type: 'function', name: identNode(name), arguments: args});
|
||||
|
||||
export const loadThreeGLTF = (url: string): Promise<ThreeGLTF> => {
|
||||
const loader = new GLTFLoader();
|
||||
return new Promise<ThreeGLTF>((resolve, reject) => {
|
||||
loader.load(url, resolve, undefined, reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const createFakeThreeGLTF = () => {
|
||||
const scene = new Group();
|
||||
|
||||
return {
|
||||
animations: [],
|
||||
scene,
|
||||
scenes: [scene],
|
||||
cameras: [],
|
||||
asset: {},
|
||||
parser: {
|
||||
cache: new Map(),
|
||||
json: {scene: 0, scenes: [{}], materials: [], nodes: []},
|
||||
associations: new Map()
|
||||
} as unknown as GLTFParser,
|
||||
userData: {}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {$renderer, $scene, $userInputElement} from '../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../model-viewer.js';
|
||||
import {Renderer} from '../three-components/Renderer.js';
|
||||
import {timePasses, waitForEvent} from '../utilities.js';
|
||||
|
||||
import {assetPath, until} from './helpers.js';
|
||||
|
||||
const expectBlobDimensions =
|
||||
async (blob: Blob, width: number, height: number) => {
|
||||
const img = await new Promise<HTMLImageElement>((resolve) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve(img);
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
expect(img.width).to.be.equal(Math.round(width));
|
||||
expect(img.height).to.be.equal(Math.round(height));
|
||||
};
|
||||
|
||||
suite('ModelViewerElementBase', () => {
|
||||
suite('with an element', () => {
|
||||
let element: ModelViewerElement;
|
||||
let input: HTMLDivElement;
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
input = element[$userInputElement];
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
suite('with alt text', () => {
|
||||
test('gives the input a related aria-label', async () => {
|
||||
const altText = 'foo';
|
||||
element.alt = altText;
|
||||
await timePasses();
|
||||
expect(input.getAttribute('aria-label')).to.be.equal(altText);
|
||||
});
|
||||
|
||||
suite('that is removed', () => {
|
||||
test('reverts input to default aria-label', async () => {
|
||||
const defaultAriaLabel = input.getAttribute('aria-label');
|
||||
const altText = 'foo';
|
||||
|
||||
element.alt = altText;
|
||||
await timePasses();
|
||||
element.alt = null;
|
||||
await timePasses();
|
||||
|
||||
expect(input.getAttribute('aria-label'))
|
||||
.to.be.equal(defaultAriaLabel);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a valid src', () => {
|
||||
test('eventually dispatches a load event', async () => {
|
||||
const sourceLoads = waitForEvent(element, 'load');
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
await sourceLoads;
|
||||
});
|
||||
|
||||
suite('that changes before the model loads', () => {
|
||||
test('it loads the second value on microtask timing', async () => {
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
await timePasses();
|
||||
element.src = assetPath('models/Horse.glb');
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
expect(element[$scene].url)
|
||||
.to.be.equal(assetPath('models/Horse.glb'));
|
||||
});
|
||||
|
||||
test('it loads the second value on task timing', async () => {
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
await timePasses(1);
|
||||
element.src = assetPath('models/Horse.glb');
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
expect(element[$scene].url)
|
||||
.to.be.equal(assetPath('models/Horse.glb'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('with an invalid src, dispatches an error event', async () => {
|
||||
const sourceErrors = waitForEvent(element, 'error');
|
||||
element.src = './does-not-exist.glb';
|
||||
const event = await sourceErrors;
|
||||
|
||||
expect((event as any).detail.type).to.be.eq('loadfailure');
|
||||
});
|
||||
|
||||
test('when losing the GL context, dispatches an error event', async () => {
|
||||
const {threeRenderer, canvas3D} = Renderer.singleton;
|
||||
|
||||
canvas3D.addEventListener('webglcontextlost', function(event) {
|
||||
event.preventDefault();
|
||||
Renderer.resetSingleton();
|
||||
}, false);
|
||||
|
||||
const errorEventDispatches = waitForEvent(element, 'error');
|
||||
// We make a best effort to simulate the real scenario here, but
|
||||
// for some cases like headless Chrome WebGL might be disabled,
|
||||
// so we simulate the scenario.
|
||||
// @see https://threejs.org/docs/index.html#api/en/renderers/WebGLRenderer.forceContextLoss
|
||||
if (threeRenderer.getContext() != null) {
|
||||
threeRenderer.forceContextLoss();
|
||||
} else {
|
||||
threeRenderer.domElement.dispatchEvent(
|
||||
new CustomEvent('webglcontextlost'));
|
||||
}
|
||||
const event = await errorEventDispatches;
|
||||
expect((event as any).detail.type).to.be.equal('webglcontextlost');
|
||||
});
|
||||
|
||||
suite('capturing screenshots', () => {
|
||||
let width: number;
|
||||
let height: number;
|
||||
setup(async () => {
|
||||
// Avoid testing our memory ceiling in CI by limiting the size
|
||||
// of the screenshots we produce in these tests:
|
||||
width = 32;
|
||||
height = 64;
|
||||
element.style.width = `${width}px`;
|
||||
element.style.height = `${height}px`;
|
||||
|
||||
const modelLoads = waitForEvent(element, 'load');
|
||||
element.src = assetPath('models/cube.gltf');
|
||||
await modelLoads;
|
||||
});
|
||||
|
||||
suite('toDataURL', () => {
|
||||
test('produces a URL-compatible string', () => {
|
||||
const dataUrlMatcher = /^data\:image\//;
|
||||
expect(dataUrlMatcher.test(element.toDataURL())).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('toBlob', () => {
|
||||
test('produces a blob', async () => {
|
||||
const blob = await element.toBlob();
|
||||
expect(blob).to.not.be.null;
|
||||
});
|
||||
|
||||
test('can convert blob to object URL', async () => {
|
||||
const blob = await element.toBlob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const objectUrlMatcher = /^blob\:/;
|
||||
expect(objectUrlMatcher.test(objectUrl)).to.be.true;
|
||||
});
|
||||
|
||||
test('has size', async () => {
|
||||
const blob = await element.toBlob();
|
||||
expect(blob.size).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
test.skip('idealAspect gives the proper blob dimensions', async () => {
|
||||
const basicBlob = await element.toBlob();
|
||||
const idealBlob = await element.toBlob({idealAspect: true});
|
||||
const idealHeight = 32 / element[$scene].idealAspect;
|
||||
|
||||
const {dpr, scaleFactor} = element[$renderer];
|
||||
const f = dpr * scaleFactor;
|
||||
await expectBlobDimensions(basicBlob, width * f, height * f);
|
||||
await expectBlobDimensions(idealBlob, width * f, idealHeight * f);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('orchestrates rendering', () => {
|
||||
let elements: Array<ModelViewerElement> = [];
|
||||
|
||||
setup(async () => {
|
||||
elements.push(new ModelViewerElement());
|
||||
elements.push(new ModelViewerElement());
|
||||
elements.push(new ModelViewerElement());
|
||||
|
||||
for (let element of elements) {
|
||||
element.style.position = 'relative';
|
||||
element.style.marginBottom = '100vh';
|
||||
element.src = assetPath('models/cube.gltf');
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
}
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
elements.forEach(
|
||||
element => element.parentNode != null &&
|
||||
element.parentNode.removeChild(element));
|
||||
});
|
||||
|
||||
test('sets a model within viewport to be visible', async () => {
|
||||
await until(() => {
|
||||
return elements[2].modelIsVisible;
|
||||
});
|
||||
|
||||
expect(elements[2].modelIsVisible).to.be.true;
|
||||
});
|
||||
|
||||
test.skip('only models visible in the viewport', async () => {
|
||||
// IntersectionObserver needs to set appropriate
|
||||
// visibility on the scene, lots of timing issues when
|
||||
// running -- wait for the visibility flags to be flipped
|
||||
await until(() => {
|
||||
return elements
|
||||
.map((element, index) => {
|
||||
return (index === 0) === element.modelIsVisible;
|
||||
})
|
||||
.reduce(((l, r) => l && r), true);
|
||||
});
|
||||
|
||||
expect(elements[0].modelIsVisible).to.be.ok;
|
||||
expect(elements[1].modelIsVisible).to.not.be.ok;
|
||||
expect(elements[2].modelIsVisible).to.not.be.ok;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {$renderer} from '../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../model-viewer.js';
|
||||
import {Constructor, waitForEvent} from '../utilities.js';
|
||||
|
||||
import {assetPath, rafPasses} from './helpers.js';
|
||||
import {BasicSpecTemplate} from './templates.js';
|
||||
|
||||
const SUNRISE_HDR_PATH = 'environments/spruit_sunrise_1k_HDR.hdr';
|
||||
const SUNRISE_LDR_PATH = 'environments/spruit_sunrise_1k_LDR.jpg';
|
||||
|
||||
const COMPONENTS_PER_PIXEL = 4;
|
||||
|
||||
const setupLighting =
|
||||
async (modelViewer: ModelViewerElement, lighting?: string) => {
|
||||
const posterDismissed = waitForEvent(modelViewer, 'poster-dismissed');
|
||||
|
||||
if (lighting) {
|
||||
const lightingPath = assetPath(lighting);
|
||||
modelViewer.environmentImage = lightingPath;
|
||||
}
|
||||
modelViewer.src = assetPath('models/reflective-sphere.gltf');
|
||||
|
||||
await posterDismissed;
|
||||
await rafPasses();
|
||||
}
|
||||
|
||||
// TODO(sun765): this only test whether the screenshot
|
||||
// is colorless or not. Replace this with more robust
|
||||
// test in later pr.
|
||||
function testFidelity(screenshotContext: WebGLRenderingContext|
|
||||
WebGL2RenderingContext) {
|
||||
const width = screenshotContext.drawingBufferWidth;
|
||||
const height = screenshotContext.drawingBufferHeight;
|
||||
|
||||
const pixels = new Uint8Array(width * height * COMPONENTS_PER_PIXEL);
|
||||
// this function reads in the bottom-up direction from the coordinate
|
||||
// specified ((0,0) is the bottom-left corner).
|
||||
screenshotContext.readPixels(
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
screenshotContext.RGBA,
|
||||
screenshotContext.UNSIGNED_BYTE,
|
||||
pixels);
|
||||
|
||||
let transparentPixels = 0;
|
||||
let whitePixels = 0;
|
||||
let blackPixels = 0;
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
let isWhite = true;
|
||||
let isBlack = true;
|
||||
|
||||
// read pixel data from top left corner
|
||||
const index = (height - row - 1) * width + col;
|
||||
const position = index * COMPONENTS_PER_PIXEL;
|
||||
|
||||
if (pixels[position + 3] != 255) {
|
||||
transparentPixels++;
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const colorComponent = pixels[position + i];
|
||||
if (colorComponent != 255) {
|
||||
isWhite = false;
|
||||
}
|
||||
if (colorComponent != 0) {
|
||||
isBlack = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isWhite) {
|
||||
whitePixels++;
|
||||
}
|
||||
if (isBlack) {
|
||||
blackPixels++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imagePixelCount = width * height;
|
||||
expect(whitePixels + blackPixels + transparentPixels)
|
||||
.to.be.below(
|
||||
imagePixelCount,
|
||||
`Image had ${whitePixels} white pixels and ${
|
||||
blackPixels} black pixels and ${
|
||||
transparentPixels} background pixels.`);
|
||||
};
|
||||
|
||||
suite('ModelViewerElement', () => {
|
||||
let nextId: number = 0;
|
||||
let tagName: string;
|
||||
let ModelViewer: Constructor<ModelViewerElement>;
|
||||
|
||||
setup(() => {
|
||||
tagName = `model-viewer-${nextId++}`;
|
||||
ModelViewer = class extends ModelViewerElement {
|
||||
static get is() {
|
||||
return tagName;
|
||||
}
|
||||
}
|
||||
customElements.define(tagName, ModelViewer);
|
||||
});
|
||||
|
||||
BasicSpecTemplate(() => ModelViewer, () => tagName);
|
||||
|
||||
suite('Render Functionality Test', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.style.width = '100px';
|
||||
element.style.height = '100px';
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
test('Metal roughness sphere with generated lighting', async () => {
|
||||
await setupLighting(element);
|
||||
const screenshotContext = element[$renderer].threeRenderer.getContext();
|
||||
testFidelity(screenshotContext);
|
||||
});
|
||||
|
||||
test('Metal roughness sphere with HDR lighting', async () => {
|
||||
await setupLighting(element, SUNRISE_HDR_PATH);
|
||||
const screenshotContext = element[$renderer].threeRenderer.getContext();
|
||||
testFidelity(screenshotContext);
|
||||
});
|
||||
|
||||
test('Metal roughness sphere with LDR lighting', async () => {
|
||||
await setupLighting(element, SUNRISE_LDR_PATH);
|
||||
const screenshotContext = element[$renderer].threeRenderer.getContext();
|
||||
testFidelity(screenshotContext);
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {degreesToRadians, lengthToBaseMeters, normalizeUnit, radiansToDegrees} from '../../styles/conversions.js';
|
||||
import {numberNode} from '../../styles/parsers.js';
|
||||
|
||||
suite('conversions', () => {
|
||||
suite('degreesToRadians', () => {
|
||||
test('converts a number expressed in degrees to radians', () => {
|
||||
expect(degreesToRadians(numberNode(180, 'deg')))
|
||||
.to.be.eql(numberNode(Math.PI, 'rad'));
|
||||
});
|
||||
|
||||
test('passes through numbers expressed in radians', () => {
|
||||
expect(degreesToRadians(numberNode(1, 'rad')))
|
||||
.to.be.eql(numberNode(1, 'rad'));
|
||||
});
|
||||
|
||||
test('passes through numbers without a unit', () => {
|
||||
expect(degreesToRadians(numberNode(1, null)))
|
||||
.to.be.eql(numberNode(1, null));
|
||||
});
|
||||
});
|
||||
|
||||
suite('radiansToDegrees', () => {
|
||||
test('converts a number expressed in radians to degrees', () => {
|
||||
expect(radiansToDegrees(numberNode(Math.PI, 'rad')))
|
||||
.to.be.eql(numberNode(180, 'deg'));
|
||||
});
|
||||
|
||||
test('passes through numbers expressed in degrees', () => {
|
||||
expect(radiansToDegrees(numberNode(1, 'deg')))
|
||||
.to.be.eql(numberNode(1, 'deg'));
|
||||
});
|
||||
|
||||
test('treats numbers without a unit as radians', () => {
|
||||
expect(radiansToDegrees(numberNode(Math.PI, null)))
|
||||
.to.be.eql(numberNode(180, 'deg'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('lengthToBaseMeters', () => {
|
||||
test('passes through numbers expressed in base meters', () => {
|
||||
expect(lengthToBaseMeters(numberNode(1, 'm')))
|
||||
.to.be.eql(numberNode(1, 'm'));
|
||||
});
|
||||
|
||||
test('converts numbers expressed in centimeters to base meters', () => {
|
||||
expect(lengthToBaseMeters(numberNode(123, 'cm')))
|
||||
.to.be.eql(numberNode(1.23, 'm'));
|
||||
});
|
||||
|
||||
test('converts numbers expressed in millimeters to base meters', () => {
|
||||
expect(lengthToBaseMeters(numberNode(1234, 'mm')))
|
||||
.to.be.eql(numberNode(1.234, 'm'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('normalizeUnit', () => {
|
||||
test('normalizes angles to radians', () => {
|
||||
expect(normalizeUnit(numberNode(180, 'deg')))
|
||||
.to.be.eql(numberNode(Math.PI, 'rad'));
|
||||
|
||||
expect(normalizeUnit(numberNode(180, 'rad')))
|
||||
.to.be.eql(numberNode(180, 'rad'));
|
||||
});
|
||||
|
||||
test('normalizes lengths to base meters', () => {
|
||||
expect(normalizeUnit(numberNode(1, 'm'))).to.be.eql(numberNode(1, 'm'));
|
||||
|
||||
expect(normalizeUnit(numberNode(1000, 'mm')))
|
||||
.to.be.eql(numberNode(1, 'm'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {enumerationDeserializer} from '../../styles/deserializers.js';
|
||||
|
||||
suite('deserializers', () => {
|
||||
suite('enumerationDeserializer', () => {
|
||||
type Animal = 'elephant'|'octopus'|'chinchilla';
|
||||
let animals: Animal[];
|
||||
let deserializeAnimals: (input: string) => Set<Animal>;
|
||||
|
||||
setup(() => {
|
||||
animals = ['elephant', 'octopus', 'chinchilla'];
|
||||
deserializeAnimals = enumerationDeserializer<Animal>(animals);
|
||||
});
|
||||
|
||||
test('yields the members of the enumeration in the input string', () => {
|
||||
const deserialized = deserializeAnimals('elephant chinchilla');
|
||||
expect(deserialized.size).to.be.equal(2);
|
||||
expect(deserialized.has('elephant')).to.be.true;
|
||||
expect(deserialized.has('chinchilla')).to.be.true;
|
||||
});
|
||||
|
||||
test('filters out non-members of the enumeration', () => {
|
||||
const deserialized = deserializeAnimals('octopus paris');
|
||||
expect(deserialized.size).to.be.equal(1);
|
||||
expect(deserialized.has('octopus')).to.be.true;
|
||||
});
|
||||
|
||||
test('yields an empty set from null input', () => {
|
||||
// tsc would normally warn about null not being accepted
|
||||
// but it is worth ensuring the correct behavior all the same:
|
||||
const deserialized = deserializeAnimals(null as any);
|
||||
expect(deserialized.size).to.be.equal(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {CalcEvaluator, EnvEvaluator, OperatorEvaluator, PercentageEvaluator, SphericalIntrinsics, StyleEvaluator} from '../../styles/evaluators.js';
|
||||
import {numberNode} from '../../styles/parsers.js';
|
||||
import {expressionNode, functionNode, identNode, operatorNode, spy} from '../helpers.js';
|
||||
|
||||
suite('evaluators', () => {
|
||||
suite('EnvEvaluator', () => {
|
||||
test('is never constant', () => {
|
||||
const evaluator = new EnvEvaluator(functionNode('env', []));
|
||||
expect(evaluator.isConstant).to.be.false;
|
||||
});
|
||||
|
||||
test('with no arguments, evaluates to zero', () => {
|
||||
const evaluator = new EnvEvaluator(functionNode('env', []));
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
suite('window-scroll-y', () => {
|
||||
test('evaluates to current top-level scroll position', () => {
|
||||
const evaluator = new EnvEvaluator(functionNode(
|
||||
'env', [expressionNode([identNode('window-scroll-y')])]));
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
const scrollPosition = 10000;
|
||||
const scrollMax = 20000;
|
||||
|
||||
const restorePageYOffset =
|
||||
spy(window, 'pageYOffset', {value: scrollPosition});
|
||||
const restoreBodyScrollHeight =
|
||||
spy(document.documentElement, 'clientHeight', {value: scrollMax});
|
||||
|
||||
expect(evaluator.evaluate())
|
||||
.to.be.eql(numberNode(
|
||||
scrollPosition / (scrollMax - window.innerHeight), null));
|
||||
|
||||
restorePageYOffset();
|
||||
restoreBodyScrollHeight();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('PercentageEvaluator', () => {
|
||||
test('multiplies the percentage by the basis', () => {
|
||||
const evaluator =
|
||||
new PercentageEvaluator(numberNode(200, '%'), numberNode(1, 'm'));
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2, 'm'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('CalcEvaluator', () => {
|
||||
test('is constant if its operands are all numbers', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc',
|
||||
[expressionNode(
|
||||
[numberNode(1, null), operatorNode('+'), numberNode(1, null)])]));
|
||||
|
||||
expect(evaluator.isConstant).to.be.true;
|
||||
});
|
||||
|
||||
test('is constant if nested functions are constant', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc', [expressionNode([
|
||||
numberNode(1, null),
|
||||
operatorNode('+'),
|
||||
functionNode('calc', [expressionNode([numberNode(1, null)])])
|
||||
])]));
|
||||
|
||||
expect(evaluator.isConstant).to.be.true;
|
||||
});
|
||||
|
||||
test('is not constant if any nested function is not constant', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode('calc', [
|
||||
expressionNode([
|
||||
numberNode(1, null),
|
||||
operatorNode('+'),
|
||||
functionNode('calc', [expressionNode([numberNode(1, null)])]),
|
||||
operatorNode('+'),
|
||||
functionNode('env', [expressionNode([identNode('window-scroll-y')])])
|
||||
])
|
||||
]));
|
||||
|
||||
expect(evaluator.isConstant).to.be.false;
|
||||
});
|
||||
|
||||
test('with no arguments, evaluates to zero', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode('env', []));
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
test('evaluates basic addition', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc',
|
||||
[expressionNode(
|
||||
[numberNode(1, null), operatorNode('+'), numberNode(1, null)])]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2, null));
|
||||
});
|
||||
|
||||
test('evaluates basic subtraction', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc',
|
||||
[expressionNode(
|
||||
[numberNode(1, null), operatorNode('-'), numberNode(1, null)])]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
test('evaluates basic multiplication', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc',
|
||||
[expressionNode(
|
||||
[numberNode(5, null), operatorNode('*'), numberNode(4, null)])]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(20, null));
|
||||
});
|
||||
|
||||
test('evaluates basic division', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode('calc', [
|
||||
expressionNode(
|
||||
[numberNode(100, null), operatorNode('/'), numberNode(10, null)])
|
||||
]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(10, null));
|
||||
});
|
||||
|
||||
test('evaluates complex algebraic expressions', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode('calc', [
|
||||
expressionNode([
|
||||
numberNode(1, null),
|
||||
operatorNode('+'),
|
||||
numberNode(2, null),
|
||||
operatorNode('*'),
|
||||
numberNode(2.5, null),
|
||||
operatorNode('-'),
|
||||
numberNode(-2, null),
|
||||
operatorNode('/'),
|
||||
numberNode(-1, null)
|
||||
]),
|
||||
]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(4, null));
|
||||
});
|
||||
|
||||
suite('evaluates algebra with nested functions', () => {
|
||||
test('evaluates basic addition', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc', [expressionNode([
|
||||
numberNode(1, null),
|
||||
operatorNode('+'),
|
||||
functionNode('calc', [expressionNode([numberNode(1, null)])])
|
||||
])]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2, null));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('OperatorEvaluator', () => {
|
||||
test('evaluates zero for an unknown operator', () => {
|
||||
const evaluator = new OperatorEvaluator(
|
||||
operatorNode('$' as any), numberNode(1, null), numberNode(1, null));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
test('evaluates zero for operands with mismatching unit types', () => {
|
||||
const evaluator = new OperatorEvaluator(
|
||||
operatorNode('+'), numberNode(1, 'm'), numberNode(1, 'rad'));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
test('normalizes to base meters for length operands', () => {
|
||||
const evaluator = new OperatorEvaluator(
|
||||
operatorNode('+'), numberNode(1, 'm'), numberNode(1000, 'mm'));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2, 'm'));
|
||||
});
|
||||
|
||||
test('normalizes to radians for angular operands', () => {
|
||||
const evaluator = new OperatorEvaluator(
|
||||
operatorNode('+'),
|
||||
numberNode(180, 'deg'),
|
||||
numberNode(Math.PI, 'rad'));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2 * Math.PI, 'rad'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('VectorEvaluator', () => {
|
||||
suite('with SphericalIntrinsics', () => {
|
||||
let intrinsics: SphericalIntrinsics;
|
||||
setup(() => {
|
||||
intrinsics = {
|
||||
basis:
|
||||
[numberNode(1, 'rad'), numberNode(1, 'rad'), numberNode(1, 'm')],
|
||||
keywords: {auto: [numberNode(2, 'rad'), null, numberNode(200, '%')]}
|
||||
};
|
||||
});
|
||||
|
||||
test('evaluates to defaults (e.g., auto) for omitted expressions', () => {
|
||||
const evaluator = new StyleEvaluator([], intrinsics);
|
||||
expect(evaluator.evaluate()).to.be.eql([2, 1, 2]);
|
||||
});
|
||||
|
||||
test(
|
||||
'substitutes the keyword auto for the related intrinsic value',
|
||||
() => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([identNode('auto')])], intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([2, 1, 2]);
|
||||
});
|
||||
|
||||
test('treats missing values as equivalent to auto', () => {
|
||||
const evaluatorOne =
|
||||
new StyleEvaluator([expressionNode([])], intrinsics);
|
||||
const evaluatorTwo = new StyleEvaluator(
|
||||
[expressionNode(
|
||||
[identNode('auto'), identNode('auto'), identNode('auto')])],
|
||||
intrinsics);
|
||||
|
||||
expect(evaluatorOne.evaluate()).to.be.eql(evaluatorTwo.evaluate());
|
||||
});
|
||||
|
||||
test('scales the basis by an input percentage', () => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([numberNode(300, '%')])], intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([3, 1, 2]);
|
||||
});
|
||||
|
||||
test('evaluates spherical values from basic expressions', () => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([
|
||||
numberNode(1, 'rad'),
|
||||
numberNode(180, 'deg'),
|
||||
numberNode(100, 'cm')
|
||||
])],
|
||||
intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([1, Math.PI, 1]);
|
||||
});
|
||||
|
||||
test('applies a percentage at any expression depth to the basis', () => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([
|
||||
numberNode(150, '%'),
|
||||
numberNode(180, 'deg'),
|
||||
functionNode('calc', [expressionNode([
|
||||
numberNode(200, '%'),
|
||||
operatorNode('*'),
|
||||
functionNode('calc', [expressionNode(
|
||||
[numberNode(3, 'm')])]),
|
||||
])])
|
||||
])],
|
||||
intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([1.5, Math.PI, 6]);
|
||||
});
|
||||
|
||||
test('evaluates spherical values from complex expressions', () => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([
|
||||
numberNode(1, 'rad'),
|
||||
numberNode(180, 'deg'),
|
||||
functionNode(
|
||||
'calc', [expressionNode([
|
||||
numberNode(1, 'm'),
|
||||
operatorNode('+'),
|
||||
functionNode(
|
||||
'calc', [expressionNode([numberNode(1, null)])]),
|
||||
operatorNode('+'),
|
||||
functionNode(
|
||||
'env', [expressionNode([identNode('window-scroll-y')])])
|
||||
])])
|
||||
])],
|
||||
intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([1, Math.PI, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/* @license
|
||||
* Copyright 2018 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {ASTWalker, IdentNode, NumberNode, numberNode, parseExpressions} from '../../styles/parsers.js';
|
||||
import {expressionNode, functionNode, hexNode, identNode, operatorNode} from '../helpers.js';
|
||||
|
||||
suite('parsers', () => {
|
||||
suite('parseExpressions', () => {
|
||||
test('parses single numbers', () => {
|
||||
expect(parseExpressions('123rad')).to.be.eql([expressionNode(
|
||||
[numberNode(123, 'rad')])]);
|
||||
});
|
||||
|
||||
test('parses number tuples', () => {
|
||||
expect(parseExpressions('123rad 3.14deg -1m 2cm')).to.be.eql([
|
||||
expressionNode([
|
||||
numberNode(123, 'rad'),
|
||||
numberNode(3.14, 'deg'),
|
||||
numberNode(-1, 'm'),
|
||||
numberNode(2, 'cm')
|
||||
])
|
||||
]);
|
||||
});
|
||||
|
||||
test('parses exponential numbers', () => {
|
||||
expect(parseExpressions('123e10mm 123E10 123e-3 4e+3')).to.be.eql([
|
||||
expressionNode([
|
||||
numberNode(123e10, 'mm'),
|
||||
numberNode(123e10, null),
|
||||
numberNode(123e-3, null),
|
||||
numberNode(4e3, null)
|
||||
])
|
||||
]);
|
||||
});
|
||||
|
||||
test('parses hex colors', () => {
|
||||
expect(parseExpressions('#fff')).to.be.eql([expressionNode(
|
||||
[hexNode('fff')])]);
|
||||
expect(parseExpressions('#abc123')).to.be.eql([expressionNode(
|
||||
[hexNode('abc123')])]);
|
||||
expect(parseExpressions('#daf012ee')).to.be.eql([expressionNode(
|
||||
[hexNode('daf012ee')])]);
|
||||
});
|
||||
|
||||
test('parses functions', () => {
|
||||
expect(parseExpressions('rgba(255, 123, 0, 0.25)'))
|
||||
.to.be.eql([expressionNode([functionNode('rgba', [
|
||||
expressionNode([numberNode(255, null)]),
|
||||
expressionNode([numberNode(123, null)]),
|
||||
expressionNode([numberNode(0, null)]),
|
||||
expressionNode([numberNode(0.25, null)]),
|
||||
])])]);
|
||||
});
|
||||
|
||||
test('parses nested functions', () => {
|
||||
expect(parseExpressions('rgba(255, calc(100 + var(--blue)), 0, 0.25)'))
|
||||
.to.be.eql([expressionNode([functionNode('rgba', [
|
||||
expressionNode([numberNode(255, null)]),
|
||||
expressionNode([functionNode(
|
||||
'calc', [expressionNode([
|
||||
numberNode(100, null),
|
||||
operatorNode('+'),
|
||||
functionNode('var', [expressionNode([identNode('--blue')])])
|
||||
])])]),
|
||||
expressionNode([numberNode(0, null)]),
|
||||
expressionNode([numberNode(0.25, null)]),
|
||||
])])]);
|
||||
});
|
||||
|
||||
test('parses calc algebra', () => {
|
||||
expect(parseExpressions('1m - -2rad / 3 + 4deg * -10.5')).to.be.eql([
|
||||
expressionNode([
|
||||
numberNode(1, 'm'),
|
||||
operatorNode('-'),
|
||||
numberNode(-2, 'rad'),
|
||||
operatorNode('/'),
|
||||
numberNode(3, null),
|
||||
operatorNode('+'),
|
||||
numberNode(4, 'deg'),
|
||||
operatorNode('*'),
|
||||
numberNode(-10.5, null)
|
||||
])
|
||||
]);
|
||||
});
|
||||
|
||||
suite('failure cases', () => {
|
||||
suite('mismatched parens', () => {
|
||||
test('trailing paren is gracefully dropped', () => {
|
||||
expect(parseExpressions('calc(calc(123)))')).to.be.eql([
|
||||
expressionNode([functionNode(
|
||||
'calc',
|
||||
[expressionNode([functionNode(
|
||||
'calc', [expressionNode([numberNode(123, null)])])])])])
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('ASTWalker', () => {
|
||||
test('only walks configured node types', () => {
|
||||
const astWalker =
|
||||
new ASTWalker<NumberNode|IdentNode>(['number', 'ident']);
|
||||
const ast = parseExpressions('calc(123 + var(--size))')
|
||||
let visitedNodes = 0;
|
||||
|
||||
astWalker.walk(ast, (node: NumberNode|IdentNode) => {
|
||||
expect(node.type === 'number' || node.type === 'ident').to.be.true;
|
||||
visitedNodes++;
|
||||
});
|
||||
|
||||
expect(visitedNodes).to.be.equal(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {parseExpressions} from '../../styles/parsers.js';
|
||||
import {EnvironmentState, StyleEffector} from '../../styles/style-effector.js';
|
||||
import {dispatchSyntheticEvent} from '../helpers.js';
|
||||
|
||||
const triggerEnvironmentEffect = (type: EnvironmentState) => {
|
||||
switch (type) {
|
||||
case 'window-scroll':
|
||||
dispatchSyntheticEvent(window, 'scroll');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
suite('StyleEffector', () => {
|
||||
test('never invokes its callback for constant styles', () => {
|
||||
let callbackInvoked = false;
|
||||
const styleEffector = new StyleEffector(() => {
|
||||
callbackInvoked = true;
|
||||
});
|
||||
|
||||
styleEffector.observeEffectsFor(parseExpressions('123px calc(4 * 5%)'));
|
||||
|
||||
triggerEnvironmentEffect('window-scroll');
|
||||
|
||||
expect(callbackInvoked).to.be.false;
|
||||
|
||||
styleEffector.dispose();
|
||||
});
|
||||
|
||||
test('invokes its callback for styles that depend on scroll', () => {
|
||||
let callbackCount = 0;
|
||||
const styleEffector = new StyleEffector(() => {
|
||||
callbackCount++;
|
||||
});
|
||||
|
||||
styleEffector.observeEffectsFor(
|
||||
parseExpressions('123px calc(env(window-scroll-y) * 5%)'));
|
||||
|
||||
triggerEnvironmentEffect('window-scroll');
|
||||
|
||||
expect(callbackCount).to.be.equal(1);
|
||||
|
||||
styleEffector.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Cache} from 'three';
|
||||
|
||||
import ModelViewerElementBase from '../model-viewer-base.js';
|
||||
import {CachingGLTFLoader} from '../three-components/CachingGLTFLoader.js';
|
||||
import {timePasses} from '../utilities.js';
|
||||
|
||||
export type Constructor<T = object> = {
|
||||
new (...args: any[]): T
|
||||
};
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
export const BasicSpecTemplate =
|
||||
(ModelViewerElementAccessor: () => Constructor<ModelViewerElementBase>,
|
||||
tagNameAccessor: () => string) => {
|
||||
teardown(() => {
|
||||
CachingGLTFLoader.clearCache();
|
||||
Cache.clear();
|
||||
});
|
||||
|
||||
test('can be directly instantiated', () => {
|
||||
const ModelViewerElement = ModelViewerElementAccessor();
|
||||
const element = new ModelViewerElement();
|
||||
expect(element).to.be.ok;
|
||||
});
|
||||
|
||||
test('can be instantiated with document.createElement', () => {
|
||||
const tagName = tagNameAccessor();
|
||||
const element = document.createElement(tagName);
|
||||
expect(element).to.be.ok;
|
||||
});
|
||||
|
||||
suite('compatibility', () => {
|
||||
suite('when WebGL is not supported', () => {
|
||||
let nativeGetContext: any;
|
||||
|
||||
setup(() => {
|
||||
nativeGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function(
|
||||
type: string, ...args: Array<any>) {
|
||||
if (/webgl/.test(type)) {
|
||||
return null;
|
||||
}
|
||||
return nativeGetContext.call(this, type, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
HTMLCanvasElement.prototype.getContext = nativeGetContext;
|
||||
});
|
||||
|
||||
test(
|
||||
'does not explode when created and appended to the document',
|
||||
async () => {
|
||||
const ModelViewerElement = ModelViewerElementAccessor();
|
||||
const element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await timePasses();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,293 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Matrix4, PerspectiveCamera, Vector2, Vector3} from 'three';
|
||||
|
||||
import {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ARRenderer} from '../../three-components/ARRenderer.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {Renderer} from '../../three-components/Renderer.js';
|
||||
import {waitForEvent} from '../../utilities.js';
|
||||
import {assetPath} from '../helpers.js';
|
||||
|
||||
class MockXRFrame implements XRFrame {
|
||||
constructor(public session: XRSession) {
|
||||
}
|
||||
|
||||
readonly predictedDisplayTime = 0;
|
||||
|
||||
// We don't use nor test the returned XRPose other than its existence.
|
||||
getPose(_xrSpace: XRSpace, _frameOfRef: XRReferenceSpace) {
|
||||
return {} as XRPose;
|
||||
}
|
||||
|
||||
// We don't use getDepthInformation()
|
||||
getDepthInformation(_view: XRView): XRCPUDepthInformation|null|undefined {
|
||||
return;
|
||||
}
|
||||
|
||||
getViewerPose(_referenceSpace?: XRReferenceSpace): XRViewerPose {
|
||||
// Rotate 180 degrees on Y (so it's not the default)
|
||||
// and angle 45 degrees towards the ground, like a phone.
|
||||
const matrix = new Matrix4()
|
||||
.identity()
|
||||
.makeRotationAxis(new Vector3(0, 1, 0), Math.PI)
|
||||
.multiply(new Matrix4().makeRotationAxis(
|
||||
new Vector3(1, 0, 0), -Math.PI / 4));
|
||||
matrix.setPosition(10, 2, 3);
|
||||
const transform: XRRigidTransform = {
|
||||
matrix: matrix.elements as unknown as Float32Array,
|
||||
position: {} as DOMPointReadOnly,
|
||||
orientation: {} as DOMPointReadOnly,
|
||||
inverse: {} as XRRigidTransform
|
||||
};
|
||||
const camera = new PerspectiveCamera();
|
||||
const view: XRView = {
|
||||
eye: {} as XREye,
|
||||
projectionMatrix: camera.projectionMatrix.elements as unknown as
|
||||
Float32Array,
|
||||
transform: transform,
|
||||
requestViewportScale: (_scale: number|null) => {}
|
||||
};
|
||||
const viewerPos: XRViewerPose = {
|
||||
transform: transform,
|
||||
views: [view],
|
||||
emulatedPosition: false
|
||||
};
|
||||
|
||||
return viewerPos;
|
||||
}
|
||||
|
||||
getHitTestResults(_xrHitTestSource: XRHitTestSource) {
|
||||
return [];
|
||||
}
|
||||
|
||||
getHitTestResultsForTransientInput(_hitTestSource:
|
||||
XRTransientInputHitTestSource) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
suite('ARRenderer', () => {
|
||||
let element: ModelViewerElement;
|
||||
let arRenderer: ARRenderer;
|
||||
let xrSession: XRSession;
|
||||
|
||||
let inputSources: Array<XRInputSource> = [];
|
||||
|
||||
const setInputSources = (sources: Array<XRInputSource>) => {
|
||||
inputSources = sources;
|
||||
};
|
||||
|
||||
const stubWebXrInterface = (arRenderer: ARRenderer) => {
|
||||
arRenderer.resolveARSession = async () => {
|
||||
class FakeSession extends EventTarget implements XRSession {
|
||||
public renderState: XRRenderState = {
|
||||
baseLayer: {
|
||||
getViewport: () => {
|
||||
return {x: 0, y: 0, width: 320, height: 240} as XRViewport
|
||||
}
|
||||
} as unknown as XRLayer
|
||||
} as XRRenderState;
|
||||
|
||||
public hitTestSources: Set<XRHitTestSource> =
|
||||
new Set<XRHitTestSource>();
|
||||
|
||||
async updateRenderState(_object: any) {
|
||||
}
|
||||
|
||||
requestFrameOfReference() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async requestReferenceSpace(_type: XRReferenceSpaceType):
|
||||
Promise<XRReferenceSpace> {
|
||||
return {} as XRReferenceSpace;
|
||||
}
|
||||
|
||||
get inputSources(): Array<XRInputSource> {
|
||||
return inputSources;
|
||||
}
|
||||
|
||||
async requestHitTestSource(_options: XRHitTestOptionsInit):
|
||||
Promise<XRHitTestSource> {
|
||||
const result = {cancel: () => {}};
|
||||
|
||||
this.hitTestSources.add(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async requestHitTestSourceForTransientInput(
|
||||
_options: XRTransientInputHitTestOptionsInit) {
|
||||
const result = {cancel: () => {}};
|
||||
|
||||
this.hitTestSources.add(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
requestAnimationFrame() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
cancelAnimationFrame() {
|
||||
}
|
||||
|
||||
async end() {
|
||||
this.dispatchEvent(new CustomEvent('end'));
|
||||
}
|
||||
|
||||
readonly environmentBlendMode = {} as XREnvironmentBlendMode;
|
||||
readonly visibilityState = {} as XRVisibilityState;
|
||||
readonly isSystemKeyboardSupported = false;
|
||||
async updateTargetFrameRate(_rate: number) {
|
||||
return;
|
||||
}
|
||||
onend() {
|
||||
}
|
||||
oninputsourceschange() {
|
||||
}
|
||||
onselect() {
|
||||
}
|
||||
onselectstart() {
|
||||
}
|
||||
onselectend() {
|
||||
}
|
||||
onsqueeze() {
|
||||
}
|
||||
onsqueezestart() {
|
||||
}
|
||||
onsqueezeend() {
|
||||
}
|
||||
onvisibilitychange() {
|
||||
}
|
||||
onframeratechange() {
|
||||
}
|
||||
}
|
||||
|
||||
xrSession = new FakeSession();
|
||||
return xrSession;
|
||||
};
|
||||
};
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
arRenderer = Renderer.singleton.arRenderer;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
test('is not presenting if present has not been invoked', () => {
|
||||
expect(arRenderer.isPresenting).to.be.equal(false);
|
||||
});
|
||||
|
||||
suite('when presenting a scene', () => {
|
||||
let modelScene: ModelScene;
|
||||
let oldXRRay: any;
|
||||
|
||||
setup(async () => {
|
||||
const sourceLoads = waitForEvent(element, 'poster-dismissed');
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
await sourceLoads;
|
||||
modelScene = element[$scene];
|
||||
stubWebXrInterface(arRenderer);
|
||||
setInputSources([]);
|
||||
|
||||
oldXRRay = (window as any).XRRay;
|
||||
(window as any).XRRay = class MockXRRay implements XRRay {
|
||||
readonly origin = new DOMPointReadOnly;
|
||||
readonly direction = new DOMPointReadOnly;
|
||||
matrix = new Float32Array;
|
||||
|
||||
constructor(_origin: DOMPointInit, _direction: DOMPointInit) {
|
||||
}
|
||||
}
|
||||
|
||||
await arRenderer.present(modelScene);
|
||||
});
|
||||
|
||||
teardown(async () => {
|
||||
(window as any).XRRay = oldXRRay;
|
||||
await arRenderer.stopPresenting().catch(() => {});
|
||||
});
|
||||
|
||||
test('presents the model at its natural scale', () => {
|
||||
const scale = modelScene.target.getWorldScale(new Vector3());
|
||||
|
||||
expect(scale.x).to.be.equal(1);
|
||||
expect(scale.y).to.be.equal(1);
|
||||
expect(scale.z).to.be.equal(1);
|
||||
});
|
||||
|
||||
suite('presentation ends', () => {
|
||||
setup(async () => {
|
||||
await arRenderer.stopPresenting();
|
||||
});
|
||||
|
||||
test('restores the model to its natural scale', () => {
|
||||
const scale = modelScene.target.getWorldScale(new Vector3());
|
||||
|
||||
expect(scale.x).to.be.equal(1);
|
||||
expect(scale.y).to.be.equal(1);
|
||||
expect(scale.z).to.be.equal(1);
|
||||
});
|
||||
|
||||
test('restores original camera', () => {
|
||||
expect(modelScene.camera).to.be.equal(modelScene.camera);
|
||||
});
|
||||
|
||||
test('restores scene size', () => {
|
||||
expect(modelScene.width).to.be.equal(300);
|
||||
expect(modelScene.height).to.be.equal(150);
|
||||
});
|
||||
});
|
||||
|
||||
// We're going to need to mock out XRFrame more so it can set the camera
|
||||
// in order to properly test this.
|
||||
|
||||
suite('after initial placement', () => {
|
||||
let yaw: number;
|
||||
|
||||
setup(async () => {
|
||||
arRenderer.onWebXRFrame(0, new MockXRFrame(arRenderer.currentSession!));
|
||||
yaw = modelScene.yaw;
|
||||
});
|
||||
|
||||
test('places the model oriented to the camera', () => {
|
||||
const epsilon = 0.0001;
|
||||
const {target, position, camera} = modelScene;
|
||||
|
||||
const cameraPosition = camera.position;
|
||||
const cameraToHit = new Vector2(
|
||||
position.x - cameraPosition.x, position.z - cameraPosition.z);
|
||||
const forward = target.getWorldDirection(new Vector3());
|
||||
const forwardProjection = new Vector2(forward.x, forward.z);
|
||||
|
||||
expect(forward.y).to.be.equal(0);
|
||||
expect(cameraToHit.cross(forwardProjection)).to.be.closeTo(0, epsilon);
|
||||
expect(cameraToHit.dot(forwardProjection)).to.be.lessThan(0);
|
||||
expect(modelScene.yaw).to.be.equal(yaw);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import ModelViewerElementBase from '../../model-viewer-base.js';
|
||||
import {$evictionPolicy, CachingGLTFLoader} from '../../three-components/CachingGLTFLoader.js';
|
||||
import {ModelViewerGLTFInstance} from '../../three-components/gltf-instance/ModelViewerGLTFInstance.js';
|
||||
import {assetPath} from '../helpers.js';
|
||||
|
||||
const ModelViewerElement = class extends ModelViewerElementBase {
|
||||
static get is() {
|
||||
return 'model-viewer-loader';
|
||||
}
|
||||
};
|
||||
|
||||
customElements.define('model-viewer-loader', ModelViewerElement);
|
||||
|
||||
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
|
||||
suite('CachingGLTFLoader', () => {
|
||||
suite('when loading a glTF', () => {
|
||||
let loader: CachingGLTFLoader;
|
||||
let element: ModelViewerElementBase;
|
||||
|
||||
setup(() => {
|
||||
loader = new CachingGLTFLoader(ModelViewerGLTFInstance);
|
||||
element = new ModelViewerElement();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
CachingGLTFLoader.clearCache();
|
||||
});
|
||||
|
||||
suite('before glTF is loaded', () => {
|
||||
test('reports that it has not finished loading', async () => {
|
||||
const fileLoads = loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
try {
|
||||
expect(CachingGLTFLoader.hasFinishedLoading(ASTRONAUT_GLB_PATH))
|
||||
.to.be.false;
|
||||
} finally {
|
||||
await fileLoads;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
suite('after glTF is loaded', () => {
|
||||
test('reports that it has finished loading', async () => {
|
||||
await loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
expect(CachingGLTFLoader.hasFinishedLoading(ASTRONAUT_GLB_PATH))
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
test('synchronously populates the cache', async () => {
|
||||
const fileLoads = loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
try {
|
||||
expect(CachingGLTFLoader.has(ASTRONAUT_GLB_PATH)).to.be.true;
|
||||
} finally {
|
||||
await fileLoads;
|
||||
}
|
||||
});
|
||||
|
||||
test('yields a promise that resolves a group', async () => {
|
||||
const {scene} = await loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
expect(scene).to.be.ok;
|
||||
expect(scene!.type).to.be.equal('Group');
|
||||
});
|
||||
|
||||
suite('with items outside of the eviction threshold', () => {
|
||||
let naturalEvictionThreshold: number;
|
||||
|
||||
setup(() => {
|
||||
naturalEvictionThreshold = loader[$evictionPolicy].evictionThreshold;
|
||||
loader[$evictionPolicy].evictionThreshold = 0;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
loader[$evictionPolicy].evictionThreshold = naturalEvictionThreshold;
|
||||
});
|
||||
|
||||
test('deletes them when they are fully released', async () => {
|
||||
const gltf = await loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
|
||||
expect(CachingGLTFLoader.has(ASTRONAUT_GLB_PATH)).to.be.true;
|
||||
gltf.dispose();
|
||||
expect(CachingGLTFLoader.has(ASTRONAUT_GLB_PATH)).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {Damper} from '../../three-components/Damper.js';
|
||||
|
||||
const ONE_FRAME_DELTA = 1000.0 / 60.0;
|
||||
const FIFTY_FRAME_DELTA = 50 * ONE_FRAME_DELTA;
|
||||
|
||||
suite('Damper', () => {
|
||||
let damper: Damper;
|
||||
const initial = 5;
|
||||
const goal = 2;
|
||||
|
||||
setup(() => {
|
||||
damper = new Damper();
|
||||
});
|
||||
|
||||
test('converges to goal with large time step without overshoot', () => {
|
||||
const moving = damper.update(initial, goal, ONE_FRAME_DELTA, initial);
|
||||
const final = damper.update(moving, goal, FIFTY_FRAME_DELTA, initial);
|
||||
expect(final).to.be.eql(goal);
|
||||
});
|
||||
|
||||
test('stays at initial value for negative time step', () => {
|
||||
const final = damper.update(initial, goal, -1 * FIFTY_FRAME_DELTA, initial);
|
||||
expect(final).to.be.eql(initial);
|
||||
});
|
||||
|
||||
test('converges to goal when normalization is zero', () => {
|
||||
const final = damper.update(initial, goal, FIFTY_FRAME_DELTA, 0);
|
||||
expect(final).to.be.eql(goal);
|
||||
});
|
||||
|
||||
test('negative normalization is the same as positive', () => {
|
||||
const final = damper.update(initial, goal, FIFTY_FRAME_DELTA, -initial);
|
||||
expect(final).to.be.eql(goal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {GLTF} from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
import {$prepared, GLTFInstance, PreparedGLTF} from '../../three-components/GLTFInstance.js';
|
||||
import {createFakeThreeGLTF} from '../helpers.js';
|
||||
|
||||
suite('GLTFInstance', () => {
|
||||
let rawGLTF: GLTF;
|
||||
let preparedGLTF: PreparedGLTF;
|
||||
|
||||
setup(async () => {
|
||||
rawGLTF = createFakeThreeGLTF();
|
||||
preparedGLTF = await GLTFInstance.prepare(rawGLTF);
|
||||
});
|
||||
|
||||
suite('with a prepared GLTF', () => {
|
||||
test('exposes the same scene as the GLTF', () => {
|
||||
const gltfInstance = new GLTFInstance(preparedGLTF);
|
||||
expect(gltfInstance.scene).to.be.equal(preparedGLTF.scene);
|
||||
});
|
||||
|
||||
suite('when cloned', () => {
|
||||
test('creates a unique scene', async () => {
|
||||
const gltfInstance = new GLTFInstance(preparedGLTF);
|
||||
const cloneInstance = await gltfInstance.clone();
|
||||
|
||||
expect(cloneInstance.scene).to.be.ok;
|
||||
expect(cloneInstance.scene).to.not.be.equal(gltfInstance.scene);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('preparing the GLTF', () => {
|
||||
test('creates a prepared GLTF', () => {
|
||||
expect(preparedGLTF).to.not.be.equal(rawGLTF);
|
||||
expect(preparedGLTF[$prepared]).to.be.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {Hotspot, HotspotVisibilityDetails} from '../../three-components/Hotspot.js';
|
||||
import {waitForEvent} from '../../utilities.js';
|
||||
|
||||
suite('Hotspot', () => {
|
||||
suite('with only a name', () => {
|
||||
test('has a DOM element', () => {
|
||||
expect(new Hotspot({name: 'foo'}).element).to.be.ok;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
suite('with assigned nodes', () => {
|
||||
let host: HTMLElement;
|
||||
let assigned: HTMLElement;
|
||||
|
||||
setup(() => {
|
||||
host = document.createElement('div');
|
||||
host.attachShadow({mode: 'open'});
|
||||
|
||||
assigned = document.createElement('div');
|
||||
host.appendChild(assigned);
|
||||
|
||||
document.body.insertBefore(host, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(host);
|
||||
});
|
||||
|
||||
suite('with a configured visibilityAttribute', () => {
|
||||
let hotspot: Hotspot;
|
||||
|
||||
setup(async () => {
|
||||
hotspot = new Hotspot({name: 'foo'});
|
||||
host.shadowRoot!.appendChild(hotspot.element);
|
||||
assigned.slot = 'foo';
|
||||
assigned.setAttribute('data-visibility-attribute', 'bar');
|
||||
});
|
||||
|
||||
suite('when shown', () => {
|
||||
setup(() => {
|
||||
hotspot.show();
|
||||
});
|
||||
|
||||
test('adds a corresponding data-* attribute to assigned nodes', () => {
|
||||
expect(assigned.hasAttribute('data-bar')).to.be.true;
|
||||
});
|
||||
|
||||
suite('and then hidden', () => {
|
||||
setup(() => {
|
||||
hotspot.hide();
|
||||
});
|
||||
|
||||
test('removes the corresponding data-* attribute', () => {
|
||||
expect(assigned.hasAttribute('data-bar')).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('when shown', () => {
|
||||
let event: CustomEvent<HotspotVisibilityDetails>;
|
||||
let hotspot: Hotspot;
|
||||
|
||||
setup(async () => {
|
||||
hotspot = new Hotspot({name: 'foo'});
|
||||
host.shadowRoot!.appendChild(hotspot.element);
|
||||
|
||||
assigned.slot = 'foo';
|
||||
|
||||
const assignedNodeHides = waitForEvent(assigned, 'hotspot-visibility');
|
||||
|
||||
hotspot.show();
|
||||
|
||||
event = await (
|
||||
assignedNodeHides as
|
||||
Promise<CustomEvent<HotspotVisibilityDetails>>);
|
||||
});
|
||||
|
||||
test('dispatches a "visibility-change" on assigned nodes', async () => {
|
||||
expect(event.detail.visible).to.be.true;
|
||||
});
|
||||
|
||||
|
||||
suite('and then hidden', () => {
|
||||
test('dispatches a "visibility-change" on assigned nodes', async () => {
|
||||
const assignedNodeHides =
|
||||
waitForEvent(assigned, 'hotspot-visibility');
|
||||
|
||||
hotspot.hide();
|
||||
|
||||
const event = await (
|
||||
assignedNodeHides as
|
||||
Promise<CustomEvent<HotspotVisibilityDetails>>);
|
||||
|
||||
expect(event.detail.visible).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Matrix4, Mesh, SphereGeometry, Vector3} from 'three';
|
||||
|
||||
import {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {assetPath} from '../helpers.js';
|
||||
|
||||
suite('ModelScene', () => {
|
||||
let element: ModelViewerElement;
|
||||
let scene: ModelScene;
|
||||
let dummyRadius: number;
|
||||
let dummyMesh: Mesh;
|
||||
|
||||
setup(() => {
|
||||
// Set the radius of the sphere to 0.5 so that it's size is 1
|
||||
// for testing scaling.
|
||||
dummyRadius = 0.5;
|
||||
dummyMesh = new Mesh(new SphereGeometry(dummyRadius, 32, 32));
|
||||
element = new ModelViewerElement();
|
||||
scene = element[$scene];
|
||||
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
suite('with a model', () => {
|
||||
setup(async () => {
|
||||
await scene.setSource(assetPath('models/Astronaut.glb'));
|
||||
});
|
||||
|
||||
suite('setShadowIntensity', () => {
|
||||
test('can increase intensity and reset it to zero', () => {
|
||||
scene.setShadowIntensity(1);
|
||||
const shadow = scene.shadow!;
|
||||
expect(shadow).to.be.ok;
|
||||
expect(shadow.getIntensity()).to.be.equal(1);
|
||||
scene.setShadowIntensity(0);
|
||||
expect(shadow.getIntensity()).to.be.equal(0);
|
||||
});
|
||||
|
||||
test('shadow is only created when intensity is greater than zero', () => {
|
||||
expect(scene.shadow).to.be.not.ok;
|
||||
scene.setShadowIntensity(1);
|
||||
expect(scene.shadow).to.be.ok;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('setSize', () => {
|
||||
test('updates visual and buffer size', () => {
|
||||
scene.setSize(500, 200);
|
||||
expect(scene.width).to.be.equal(500);
|
||||
expect(scene.height).to.be.equal(200);
|
||||
});
|
||||
|
||||
test('model is not scaled', async () => {
|
||||
dummyMesh.geometry.applyMatrix4(new Matrix4().makeScale(1, 3, 10));
|
||||
await scene.setObject(dummyMesh);
|
||||
|
||||
scene.setSize(1000, 500);
|
||||
expect(scene.scale).to.be.eql(new Vector3(1, 1, 1));
|
||||
});
|
||||
|
||||
test('idealCameraDistance is set correctly', async () => {
|
||||
await scene.setObject(dummyMesh);
|
||||
|
||||
scene.framedFoVDeg = 25;
|
||||
const halfFov = (scene.framedFoVDeg / 2) * Math.PI / 180;
|
||||
const expectedDistance = dummyRadius / Math.sin(halfFov);
|
||||
expect(scene.idealCameraDistance())
|
||||
.to.be.closeTo(expectedDistance, 0.0001);
|
||||
});
|
||||
|
||||
test('idealAspect is set correctly', async () => {
|
||||
scene.framedFoVDeg = 25;
|
||||
await scene.setObject(dummyMesh);
|
||||
|
||||
expect(scene.idealAspect).to.be.closeTo(1, 0.001);
|
||||
});
|
||||
|
||||
test('cannot set the canvas smaller than 1x1', () => {
|
||||
scene.setSize(0, 0);
|
||||
expect(scene.width).to.be.equal(1);
|
||||
expect(scene.height).to.be.equal(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loaded 100 of 611 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user