squash commits

allowCeilingPlacement
MrPlatnum 2025-09-05 15:20:42 +02:00
commit 0f545c5e4d
611 changed files with 272565 additions and 0 deletions

14
.clang-format Normal file
View File

@ -0,0 +1,14 @@
BasedOnStyle: Google
Language: JavaScript
AlignAfterOpenBracket: AlwaysBreak
AllowAllParametersOfDeclarationOnNextLine: false
AllowShortBlocksOnASingleLine: false
AllowShortCaseLabelsOnASingleLine: false
AllowShortFunctionsOnASingleLine: None
AllowShortIfStatementsOnASingleLine: false
AllowShortLoopsOnASingleLine: false
BinPackArguments: false
ColumnLimit: 80
# This breaks async functions sometimes, see
# https://github.com/Polymer/polymer-analyzer/pull/393
# BinPackParameters: false

12
.eslintignore Normal file
View File

@ -0,0 +1,12 @@
**/node_modules
scripts/**/*.js
packages/*/dist
packages/**/*.d.ts
packages/*/src/*-css.ts
packages/model-viewer/**/*
packages/model-viewer-effects/**/*
packages/modelviewer.dev/**/*
packages/render-fidelity-tools/**/*
packages/shared-assets/**/*
packages/space-opera/**/*

68
.eslintrc.yaml Normal file
View File

@ -0,0 +1,68 @@
extends:
- eslint:recommended
- google
- plugin:@typescript-eslint/eslint-recommended
- plugin:@typescript-eslint/recommended
- plugin:wc/recommended
globals:
goog: false
env:
browser: true
parser: "@typescript-eslint/parser"
plugins:
- "@typescript-eslint"
- mocha
- wc
parserOptions:
ecmaVersion: 2017
sourceType: module
settings:
wc:
elementBaseClasses: ["BaseElement", "LitElement", "FormElement"]
rules:
# Rules temporally disabled
"@typescript-eslint/explicit-function-return-type": off
# Rules disabled in favor of clang-format
"@typescript-eslint/indent": off
indent: off
max-len: off
block-spacing: off
# Remove if we switch away from clang-format:
keyword-spacing: off
# clang-format wants async(foo) => {} without a space
space-before-function-paren: off
"@typescript-eslint/explicit-member-accessibility": [error, {"accessibility": "no-public"}]
no-new: warn
quotes: [error, single, {"avoidEscape": true}]
no-var: error
curly: error
no-floating-decimal: error
# tsc handles this for us, and eslint has false positives
no-unused-vars: error
"@typescript-eslint/no-unused-vars": off
require-jsdoc: off
valid-jsdoc: off
prefer-const: error
comma-dangle: off
mocha/handle-done-callback: error
mocha/no-exclusive-tests: error
mocha/no-identical-title: error
mocha/no-nested-tests: error
mocha/no-pending-tests: error
mocha/no-skipped-tests: error
overrides:
- files: ["packages/**/*.ts"]
rules:
no-unused-vars: off
no-invalid-this: off
new-cap: off
- files: ["packages/**/*.ts"]
rules:
"@typescript-eslint/no-non-null-assertion": off

53
.github/ISSUE_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,53 @@
<!--
PLEASE FILL OUT THIS TEMPLATE COMPLETELY! If you don't have enough information
to fill in all of this, you will be better served by starting a Q&A discussion:
https://github.com/google/model-viewer/discussions/new. Rest assured that you
will get a response as fast or faster than by filing an issue. Please do not
post the same question in multiple places. For feature requests, please start a
discussion under the Ideas category, or better yet, submit a PR.
-->
### Description
Please provide a detailed description of the bug, how to reproduce it, and the
expected behavior. Always include a code snippet, screenshot, any errors
reported in the console, and the model to help us understand and fix the
problem.
#### Live Demo
<!-- glitch.me starting point (remix and edit -- must be logged in to persist!) -->
https://glitch.com/edit/#!/model-viewer
<!-- ...or provide your own repro URL -->
### Version
<!--
If you're not sure, paste your script src (like https://unpkg.com/@google/model-viewer)
into your browser and it will redirect to a numbered version.
-->
- model-viewer: vX.X.X
### Browser Affected
<!-- Check all that apply and please include the version tested (try chrome://version) -->
- [ ] Chrome, version: xx.x.xxxx.xx
- [ ] Edge
- [ ] Firefox
- [ ] IE
- [ ] Safari
### OS
<!-- Check all that apply and please include the version tested -->
- [ ] Android
- [ ] iOS
- [ ] Linux
- [ ] MacOS
- [ ] Windows
### AR
<!--
If your issue involves AR, please check which AR modes reproduce it. If you're
not sure, restrict to one at a time using the `ar-modes` attribute. We can only
control WebXR mode in this project, but we will forward SceneViewer bugs to the
appropriate team.
-->
- [ ] WebXR
- [ ] SceneViewer
- [ ] QuickLook

3
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,3 @@
<!-- Instructions: https://github.com/google/model-viewer/blob/master/CONTRIBUTING.md#code-reviews -->
### Reference Issue
<!-- Example: Fixes #1234 -->

View File

@ -0,0 +1,41 @@
name: Deploy documentation
on:
push:
branches:
- master
permissions:
contents: write
jobs:
deploy_github_pages:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-node@v4
- name: NPM install
run: npm ci
# - name: Lint TypeScript sources
# run: npm run lint
- name: Build packages
run: npm run build
- name: Stage documentation artifacts
run: ./packages/modelviewer.dev/scripts/ci-before-deploy.sh
- name: Deploy to Github Pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_branch: gh-pages
force_orphan: true
publish_dir: ./packages/modelviewer.dev/dist

30
.github/workflows/fidelity-tests.yml vendored Normal file
View File

@ -0,0 +1,30 @@
name: Fidelity tests
on: pull_request
jobs:
compare_renders:
runs-on: ubuntu-latest
timeout-minutes: 90
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-node@v4
- name: NPM install
run: npm ci
# - name: Lint TypeScript sources
# run: npm run lint
- name: Build packages
run: |
npm run build --workspace=@google/model-viewer --workspace=@google/model-viewer-render-fidelity-tools
- name: Fidelity tests
uses: coactions/setup-xvfb@v1
with:
run: npm run test --workspace=@google/model-viewer-render-fidelity-tools -- --quiet

31
.github/workflows/unit-tests.yml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Unit tests
on: pull_request
jobs:
full_test_run:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
submodules: true
- uses: actions/setup-node@v4
- name: NPM install
run: npm ci
- name: Install playwright browsers
run: npx playwright install --with-deps
# - name: Lint TypeScript sources
# run: npm run lint
- name: Build packages
run: npm run build
- name: Unit tests
run: npm run test:ci

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
.vscode
*.swp
.DS_Store
lib
node_modules
dist
.idea

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "packages/shared-assets/models/glTF-Sample-Assets"]
path = packages/shared-assets/models/glTF-Sample-Assets
url = git@github.com:KhronosGroup/glTF-Sample-Assets

1
.nvmrc Normal file
View File

@ -0,0 +1 @@
12

62
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,62 @@
# How to Contribute
We'd love to accept your patches and contributions to this project. There are
just a few small guidelines you need to follow.
## Contributor License Agreement
Contributions to this project must be accompanied by a Contributor License
Agreement. You (or your employer) retain the copyright to your contribution,
this simply gives us permission to use and redistribute your contributions as
part of the project. Head over to <https://cla.developers.google.com/> to see
your current agreements on file or to sign a new one.
You generally only need to submit a CLA once, so if you've already submitted one
(even if it was for a different project), you probably don't need to do it
again.
## Filing bugs
If you've found a issue, please do file a bug for us. We ask that you include
some information (included in the new issue template) so that we can reproduce
and fix the problem.
Occasionally we'll close issues if they appear stale or are too vague - please
don't take this personally! Please feel free to re-open issues we've closed if
there's something we've missed and they still need to be addressed.
## Code reviews
All submissions, including submissions by project members, require review. We
use GitHub pull requests for this purpose. Consult
[GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
information on using pull requests.
Note that we request issues to be filed for all pull requests. To start, open
an issue describing the problem you're looking to solve (or locate an existing
issue that represents the problem). Include your approach to solving the
problem as this makes it easier to have a conversation about the best general
approach.
Please include tests in your pull request.
We recommend making your pull request from a fork. See [creating a pull
request from a
fork](https://help.github.com/articles/creating-a-pull-request-from-a-fork/)
for more information.
## Stability
Its important that our users can depend on our product, and not to worry
about changes in model-viewer causing regressions in their use of the
component. However, its also important that we continue to improve
model-viewer, making changes to improve ergonomics and rendering quality.
To this end <model-viewer> will adhere to [semver](https://semver.org). We
consider the API as documented on [modelviewer.dev](https://modelviewer.dev)
our public API.
We'll also strive to keep rendering changes in the spirit of semver - although
most of our rendering changes are likely to be increased adherence to PBR,
which we wouldn't consider to be an incompatible change for purposes of
semver.

202
LICENSE Normal file
View File

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

127
README.md Normal file
View File

@ -0,0 +1,127 @@
# The `<model-viewer>` project
This is the main GitHub repository for the `<model-viewer>` web component and
all of its related projects.
**Getting started?** Check out the [`<model-viewer>`](packages/model-viewer) project!
The repository is organized into sub-directories containing the various projects.
Check out the README.md files for specific projects to get more details:
👩‍🚀 **[`<model-viewer>`](packages/model-viewer)** • The `<model-viewer>` web component (probably what you are looking for)
**[`<model-viewer-effects>`](packages/model-viewer-effects)** • The PostProcessing plugin for `<model-viewer>`
🌐 **[modelviewer.dev](packages/modelviewer.dev)** • The source for the `<model-viewer>` documentation website
🖼 **[render-fidelity-tools](packages/render-fidelity-tools)** • Tools for testing how well `<model-viewer>` renders models
🎨 **[shared-assets](packages/shared-assets)** • 3D models, environment maps and other assets shared across many sub-projects
🚀 **[space-opera](packages/space-opera/)** • The source of the `<model-viewer>` [editor](https://modelviewer.dev/editor/)
## Development
When developing across all the projects in this repository, first install git,
Node.js and npm.
Then, perform the following steps to get set up for development:
```sh
git clone --depth=1 git@github.com:google/model-viewer.git
cd model-viewer
npm install
```
Note: depth=1 keeps you from downloading our ~3Gb of history, which is dominated by all the versions of our golden render fidelity images.
The following global commands are available:
Command | Description
------------------------------ | -----------
`npm ci` | Install dependencies and cross-links sub-projects
`npm run build` | Runs the build step for all sub-projects
`npm run serve` | Runs a web server and opens a new browser tab pointed to the local copy of modelviewer.dev (don't forget to build!)
`npm run test` | Runs tests in all sub-projects that have them
`npm run clean` | Removes built artifacts from all sub-projects
You should now be ready to work on any of the `<model-viewer>` projects!
## Windows 10/11 Setup
Due to dependency issues on Windows 10 we recommend running `<model-viewer>` setup from a WSL2 environment.
* [WSL2 Install walkthrough](https://docs.microsoft.com/en-us/windows/wsl/install-win10)
And installing Node.js & npm via NVM
* [Node.js/NVM install walkthrough](https://docs.microsoft.com/en-us/windows/nodejs/setup-on-wsl2)
You should clone model-viewer from _inside_ WSL, not from inside Windows. Otherwise, you might run into line endings and symlink issues.
To clone via HTTPS in WSL (there are known file permissions issues with SSH keys inside WSL):
```
git clone --depth=1 https://github.com/google/model-viewer.git
cd model-viewer
npm install
```
To run tests in WSL, you need to bind `CHROME_BIN`:
```
export CHROME_BIN="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
npm run test
```
Note that you should be able to run the `packages/model-viewer` and `packages/model-viewer-effects` tests with that setup, but running fidelity tests requires GUI support which is only available in WSL on Windows 11.
<details>
<summary>Additional WSL Troubleshooting provided for reference only</summary>
> These issues should not happen when you have followed the above WSL setup steps (clone via HTTPS, clone from inside WSL, bind CHROME_BIN). The notes here might be helpful if you're trying to develop model-viewer from inside Windows (not WSL) instead (not recommended).
### Running Tests
Running `npm run test` requires an environment variable on WSL that points to `CHROME_BIN`.
You can set that via this command (this is the default Chrome install directory, might be somewhere else on your machine)
```
export CHROME_BIN="/mnt/c/Program Files/Google/Chrome/Application/chrome.exe"
npm run test
```
Tests in `packages/model-viewer` and `packages/model-viewer-effects` should now run properly; fidelity tests might still fail (see errors and potential workarounds below).
### Error: `/bin/bash^M: bad interpreter: No such file or directory`
**Symptom**
Running a .sh script, for example `fetch-khronos-gltf-samples.sh`, throws an error message `/bin/bash^M: bad interpreter: No such file or directory`
Alternative error:
```
! was unexpected at this time.
npm ERR! code ELIFECYCLE
npm ERR! errno 1
npm ERR! @google/model-viewer@1.10.1 prepare: `if [ ! -L './shared-assets' ]; then ln -s ../shared-assets ./shared-assets; fi && ../shared-assets/scripts/fetch-khronos-gltf-samples.sh`
```
**Solution**
This is caused by incorrect line endings in some of the .sh files due to git changing these on checkout on Windows (not inside WSL). It's recommended to clone the model-viewer repository from a WSL session.
As a workaround, you can re-write line endings using the following command:
```
sed -i -e 's/\r$//' ../shared-assets/scripts/fetch-khronos-gltf-samples.sh
```
### Error: `ERROR:browser_main_loop.cc(1409)] Unable to open X display.`
**Symptom**
When trying to `npm run test`, errors are logged similar to:
```
❌Fail to analyze scenario :khronos-IridescentDishWithOlives! Error message: ❌ Failed to capture model-viewer's screenshot
[836:836:0301/095227.204808:ERROR:browser_main_loop.cc(1409)] Unable to open X display.
```
Pupeteer tests need a display output; this means GUI support for WSL is required which seems to only be (easily) available on Windows 11, not Windows 10.
https://docs.microsoft.com/de-de/windows/wsl/tutorials/gui-apps#install-support-for-linux-gui-apps
So, the workaround seems to be running Windows 11 (but not tested yet).
### Error: `ERROR: Task not found: "'watch:tsc"`
**Symptom**
Running `npm run dev` in `packages/model-viewer` on Windows throws error `ERROR: Task not found: "'watch:tsc"`.
**Solution**
(if you have one please make a PR!)
</details>

19
cert.pem Normal file
View File

@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDCTCCAfGgAwIBAgIUQGIO9tiyZURfzSXOdAU1ZHqA0zkwDQYJKoZIhvcNAQEL
BQAwFDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI1MDgxODEwNDYyOFoXDTI2MDgx
ODEwNDYyOFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjANBgkqhkiG9w0BAQEF
AAOCAQ8AMIIBCgKCAQEAjGpqyd362woJWOAe2BEvS/EUIfEhXsdvUDG0/Mf2kgfZ
WDvy92xxc5qJWl8xHFfq389nlP1MMlzmPhaWyM5FEXOxjzqIiAxliUJ+cPxch+Eu
O8WjGOTPsDmAeOEco8cuAUViTZuvV7kyrbQfO0PjbcpLsTbjtsZZF+eboSMAoolB
8OI25aA7+UL0Ow3SDE8Z/lOMHUZBeB9cVGTSLighOdAgzLqQXQLLwMdDdUiaMoR9
JNGX6c4a8RLXrHmy1TcKWqFVQTfAxi0R3BDFDq9nkU54wjhEoDBem2ax1HJmVzCB
p+3DNA52lx9pzPV2jHJ4y/o2EAjg2dxh69PCC0UqIQIDAQABo1MwUTAdBgNVHQ4E
FgQUliQL/u/DIY+/Pj5OnlTu4ck0fTQwHwYDVR0jBBgwFoAUliQL/u/DIY+/Pj5O
nlTu4ck0fTQwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAXRgb
78bTAM92L2QY1z2Opvgk1Etwa5bi3gUDdreAclmd5XNgKNfQbwgj5yHAo3mzh/PT
puFbpJLbd4IyyvuBoi0A2DuYxNi1Mm3dhzh5ZIhawU8DGR69noeqKrs8+czTfFGl
D7MFq9ApSs1SLfz6+ozz4674RKVj9ZuxFsyQ2Wm3kYy+VLduRMJQOptYH5rqdPq/
YlsC9coapWSFerd1q3+t07cuBvOUJ+UjujtQJE5dfpaZ0rsLCQRc/tgiIotdZyT/
0a3CZbB/kZc2WH/+MgsYiPe+EY2ZB+oWrBZZPMjqa0SoEha2raOdXA2+BxP8bB+y
7m2ySW5lCEpzwgtwSA==
-----END CERTIFICATE-----

290
index.html Normal file
View File

@ -0,0 +1,290 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<!-- This file was created with the aha Ansi HTML Adapter. https://github.com/theZiz/aha -->
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="application/xml+xhtml; charset=UTF-8"/>
<title>stdin</title>
</head>
<body>
<pre>
<span style="font-weight:bold;">diff --git a/packages/model-viewer/src/three-components/ARRenderer.ts b/packages/model-viewer/src/three-components/ARRenderer.ts</span>
<span style="font-weight:bold;">index 5b5778d..ebd066f 100644</span>
<span style="font-weight:bold;">--- a/packages/model-viewer/src/three-components/ARRenderer.ts</span>
<span style="font-weight:bold;">+++ b/packages/model-viewer/src/three-components/ARRenderer.ts</span>
<span style="color:teal;">@@ -43,6 +43,7 @@</span> const HIT_ANGLE_DEG = 20;
const SCALE_SNAP = 0.2;
// upward-oriented ray for ceiling detection
const CEILING_HIT_ANGLE_DEG = 20;
<span style="color:green;">const CEILING_ORIENTATION_THRESHOLD = 15; // degrees</span>
// For automatic dynamic viewport scaling, don't let the scale drop below this
// limit.
const MIN_VIEWPORT_SCALE = 0.25;
<span style="color:teal;">@@ -752,20 +753,35 @@</span> export class ARRenderer extends EventDispatcher&lt;
const {pivot, element} = scene;
const {position} = pivot;
const xrCamera = scene.getCamera();
const {width, height} = this.overlay!.getBoundingClientRect();
scene.setSize(width, height);
xrCamera.projectionMatrixInverse.copy(xrCamera.projectionMatrix).invert();
const {theta} = (element as ModelViewerElementBase &amp; ControlsInterface)
.getCameraOrbit();
// Orient model to match the 3D camera view
const cameraDirection = xrCamera.getWorldDirection(vector3);
scene.yaw = Math.atan2(-cameraDirection.x, -cameraDirection.z) - theta;
this.goalYaw = scene.yaw;
<span style="color:green;">if (this.placeOnCeiling &amp;&amp; !this.isViewPointingUp()) {</span>
<span style="color:green;"> scene.visible = false; // Hide until properly oriented</span>
<span style="color:green;"> scene.setHotspotsVisibility(true); // Still show UI</span>
<span style="color:green;"> </span>
<span style="color:green;"> // Set up touch interaction for screen-space mode</span>
<span style="color:green;"> if (this.xrMode === XRMode.SCREEN_SPACE) {</span>
<span style="color:green;"> const {session} = this.frame!;</span>
<span style="color:green;"> session.addEventListener('selectstart', this.onSelectStart);</span>
<span style="color:green;"> session.addEventListener('selectend', this.onSelectEnd);</span>
<span style="color:green;"> session.requestHitTestSourceForTransientInput!({profile: 'generic-touchscreen'})!</span>
<span style="color:green;"> .then(hitTestSource =&gt; { this.transientHitTestSource = hitTestSource; });</span>
<span style="color:green;"> }</span>
<span style="color:green;"> return; // Exit early - don't place yet</span>
<span style="color:green;"> }</span>
// Use different placement logic for world-space vs screen-space
if (this.xrMode === XRMode.WORLD_SPACE &amp;&amp; !this.worldSpaceInitialPlacementDone) {
// Use automatic optimal placement for world-space AR only on first session
<span style="color:teal;">@@ -795,28 +811,74 @@</span> export class ARRenderer extends EventDispatcher&lt;
const radius = Math.max(1, 2 * scene.boundingSphere.radius);
position.copy(xrCamera.position)
.add(cameraDirection.multiplyScalar(radius));
this.updateTarget();
const target = scene.getTarget();
position.add(target).sub(this.oldTarget);
this.goalPosition.copy(position);
}
scene.setHotspotsVisibility(true);
<span style="color:green;">scene.visible = true; // Model is properly oriented, show it</span>
if (this.xrMode === XRMode.SCREEN_SPACE) {
const {session} = this.frame!;
session.addEventListener('selectstart', this.onSelectStart);
session.addEventListener('selectend', this.onSelectEnd);
<span style="color:red;">session</span>
<span style="color:red;"> .requestHitTestSourceForTransientInput!</span>
<span style="color:red;"> ({profile: 'generic-touchscreen'})!.then(hitTestSource</span><span style="color:green;">session.requestHitTestSourceForTransientInput!({profile: 'generic-touchscreen'})!</span>
<span style="color:green;"> .then(hitTestSource</span> =&gt; { this.transientHitTestSource = hitTestSource; });
}
}
<span style="color:green;">private checkForDeferredCeilingPlacement(): void {</span>
<span style="color:green;"> // Check on every frame—both XR modes, only when ceiling is the target and the model is hidden</span>
<span style="color:green;"> if (!this.placeOnCeiling || !this.presentedScene || this.presentedScene.visible) return;</span>
<span style="color:green;"> </span>
<span style="color:green;"> const isWorldSpaceDeferred = this.xrMode === XRMode.WORLD_SPACE &amp;&amp; !this.worldSpaceInitialPlacementDone;</span>
<span style="color:green;"> const isScreenSpaceDeferred = this.xrMode === XRMode.SCREEN_SPACE;</span>
<span style="color:green;"> </span>
<span style="color:green;"> if (isWorldSpaceDeferred || isScreenSpaceDeferred) {</span>
<span style="color:green;"> if (this.isViewPointingUp()) {</span>
<span style="color:green;"> this.performDeferredPlacement();</span>
<span style="color:green;"> }</span>
<span style="color:green;"> }</span>
<span style="color:green;"> }</span>
<span style="color:green;"> </span>
<span style="color:green;"> private performDeferredPlacement(): void {</span>
<span style="color:green;"> const scene = this.presentedScene!;</span>
<span style="color:green;"> if (this.xrMode === XRMode.WORLD_SPACE) {</span>
<span style="color:green;"> const xrCamera = scene.getCamera();</span>
<span style="color:green;"> const {position, scale} = this.calculateWorldSpaceOptimalPlacement(scene, xrCamera);</span>
<span style="color:green;"> this.goalPosition.copy(position);</span>
<span style="color:green;"> this.goalScale = scale;</span>
<span style="color:green;"> this.initialModelScale = scale;</span>
<span style="color:green;"> scene.pivot.position.copy(position);</span>
<span style="color:green;"> scene.pivot.scale.set(scale, scale, scale);</span>
<span style="color:green;"> this.worldSpaceInitialPlacementDone = true;</span>
<span style="color:green;"> this.calculateWorldSpaceScaleLimits(scene);</span>
<span style="color:green;"> this.enableWorldSpaceUserInteraction();</span>
<span style="color:green;"> } else { // SCREEN_SPACE</span>
<span style="color:green;"> const xrCamera = scene.getCamera();</span>
<span style="color:green;"> const cameraDirection = xrCamera.getWorldDirection(new Vector3());</span>
<span style="color:green;"> const radius = Math.max(1, 2 * scene.boundingSphere.radius);</span>
<span style="color:green;"> scene.pivot.position.copy(xrCamera.position).add(cameraDirection.multiplyScalar(radius));</span>
<span style="color:green;"> this.updateTarget();</span>
<span style="color:green;"> const target = scene.getTarget();</span>
<span style="color:green;"> scene.pivot.position.add(target).sub(this.oldTarget);</span>
<span style="color:green;"> this.goalPosition.copy(scene.pivot.position);</span>
<span style="color:green;"> // Setup touch interaction if needed</span>
<span style="color:green;"> const {session} = this.frame!;</span>
<span style="color:green;"> session.addEventListener('selectstart', this.onSelectStart);</span>
<span style="color:green;"> session.addEventListener('selectend', this.onSelectEnd);</span>
<span style="color:green;"> session.requestHitTestSourceForTransientInput!({profile: 'generic-touchscreen'})!</span>
<span style="color:green;"> .then(hitTestSource =&gt; { this.transientHitTestSource = hitTestSource; });</span>
<span style="color:green;"> }</span>
<span style="color:green;"> scene.visible = true;</span>
<span style="color:green;"> scene.setHotspotsVisibility(true);</span>
<span style="color:green;"> this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});</span>
<span style="color:green;"> }</span>
private getTouchLocation(): Vector3|null {
const {axes} = this.inputSource!.gamepad!;
let location = this.placementBox!.getExpandedHit(
<span style="color:teal;">@@ -869,42 +931,71 @@</span> export class ARRenderer extends EventDispatcher&lt;
* until a ceiling hit arrives (no premature floor placement).
*/
public moveToAnchor(frame: XRFrame) {
<span style="color:green;">// Handle deferred initial placement for ceiling mode</span>
if <span style="color:red;">(this.xrMode</span><span style="color:green;">(this.placeOnCeiling &amp;&amp; </span>
<span style="color:green;"> this.xrMode</span> === XRMode.WORLD_SPACE &amp;&amp;
<span style="color:red;">!this.worldSpaceInitialPlacementDone)</span><span style="color:green;">!this.worldSpaceInitialPlacementDone &amp;&amp;</span>
<span style="color:green;"> !this.presentedScene!.visible) {</span>
<span style="color:green;"> </span>
<span style="color:green;"> // Check if orientation is now sufficient</span>
<span style="color:green;"> if (!this.isViewPointingUp())</span> {
<span style="color:red;">this.placementBox!.show</span><span style="color:green;">console.log('[ARR/moveToAnchor] Still waiting for proper ceiling orientation');</span>
<span style="color:green;"> return;</span>
<span style="color:green;"> }</span>
<span style="color:green;"> </span>
<span style="color:green;"> // Orientation is good - complete the deferred world-space placement</span>
<span style="color:green;"> const scene = this.presentedScene!;</span>
<span style="color:green;"> const xrCamera = scene.getCamera();</span>
<span style="color:green;"> const {position: optimalPosition, scale: optimalScale} = </span>
<span style="color:green;"> this.calculateWorldSpaceOptimalPlacement(scene, xrCamera);</span>
<span style="color:green;"> </span>
<span style="color:green;"> this.goalPosition.copy(optimalPosition);</span>
<span style="color:green;"> this.goalScale = optimalScale;</span>
<span style="color:green;"> this.initialModelScale = optimalScale;</span>
<span style="color:green;"> </span>
<span style="color:green;"> scene.pivot.position.copy(optimalPosition);</span>
<span style="color:green;"> scene.pivot.scale.set(optimalScale, optimalScale, optimalScale);</span>
<span style="color:green;"> </span>
<span style="color:green;"> this.worldSpaceInitialPlacementDone = true;</span>
<span style="color:green;"> this.calculateWorldSpaceScaleLimits(scene);</span>
<span style="color:green;"> this.enableWorldSpaceUserInteraction();</span>
<span style="color:green;"> </span>
<span style="color:green;"> scene.visible</span> = <span style="color:red;">false;</span><span style="color:green;">true;</span>
this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});
return;
}
<span style="color:red;">const hitSource = this.initialHitSource;</span>
<span style="color:red;"> if (!hitSource) return;</span>
<span style="color:red;"> </span>
<span style="color:red;"> const hits = frame.getHitTestResults(hitSource);</span><span style="color:green;">// Skip for world-space mode after initial placement (unless ceiling was deferred)</span>
if <span style="color:red;">(hits.length</span><span style="color:green;">(this.xrMode</span> === <span style="color:red;">0) return;</span>
<span style="color:red;"> </span>
<span style="color:red;"> const hitPoint</span><span style="color:green;">XRMode.WORLD_SPACE &amp;&amp; this.worldSpaceInitialPlacementDone) {</span>
<span style="color:green;"> this.placementBox!.show</span> = <span style="color:red;">this.getHitPoint(hits[0]); // applies normal filtering</span>
<span style="color:red;"> if (!hitPoint)</span><span style="color:green;">false;</span>
<span style="color:green;"> this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});</span>
return;
<span style="color:green;">}</span>
<span style="color:green;"> }</span>
<span style="color:red;">this.placementBox!.show</span><span style="color:green;">private isViewPointingUp(thresholdDeg: number</span> = <span style="color:red;">true;</span>
<span style="color:red;"> this.presentedScene!.visible</span><span style="color:green;">CEILING_ORIENTATION_THRESHOLD): boolean {</span>
<span style="color:green;"> const cam</span> = <span style="color:red;">!(this.placeOnCeiling</span><span style="color:green;">this.presentedScene!.getCamera();</span>
<span style="color:green;"> </span>
<span style="color:green;"> // Handle ArrayCamera (common in XR)</span>
<span style="color:green;"> const realCam: any = (cam as any).isArrayCamera</span> &amp;&amp; <span style="color:red;">!this.placementComplete);</span><span style="color:green;">Array.isArray((cam as any).cameras)</span>
<span style="color:green;"> ? (cam as any).cameras[0] // Use first sub-camera</span>
<span style="color:green;"> : cam;</span>
if <span style="color:red;">(!this.isTranslating) {</span>
<span style="color:red;"> if (this.placeOnWall) {</span>
<span style="color:red;"> this.goalPosition.copy(hitPoint); // wall → full XYZ</span>
<span style="color:red;"> } else if (this.placeOnCeiling) {</span>
<span style="color:red;"> this.goalPosition.copy(hitPoint);</span>
<span style="color:red;"> } else {</span>
<span style="color:red;"> </span>
<span style="color:red;"> this.goalPosition.y = hitPoint.y; // floor → drop only Y</span>
<span style="color:red;"> }</span><span style="color:green;">(!realCam || typeof realCam.updateMatrixWorld !== 'function') {</span>
<span style="color:green;"> return false;</span>
}
<span style="color:red;">hitSource.cancel();</span>
<span style="color:red;"> this.initialHitSource = null;</span>
<span style="color:red;"> this.placementComplete</span><span style="color:green;">// Update camera matrix to get current world orientation</span>
<span style="color:green;"> realCam.updateMatrixWorld(true);</span>
<span style="color:green;"> const elements</span> = <span style="color:red;">true;</span>
<span style="color:red;"> this.presentedScene!.visible</span><span style="color:green;">realCam.matrixWorld.elements;</span>
<span style="color:green;"> </span>
<span style="color:green;"> // Get forward direction from camera matrix (-Z column)</span>
<span style="color:green;"> const forwardY</span> = <span style="color:red;">true;</span><span style="color:green;">-elements[9];</span> // <span style="color:red;">reveal after hit</span>
<span style="color:red;"> </span>
<span style="color:red;"> this.dispatchEvent({type: 'status', status: ARStatus.OBJECT_PLACED});</span><span style="color:green;">Y component of forward vector</span>
<span style="color:green;"> const minY = Math.sin(thresholdDeg * Math.PI / 180);</span>
<span style="color:green;"> return forwardY &gt;= minY;</span>
}
private onSelectStart = (event: Event) =&gt; {
const hitSource = this.transientHitTestSource;
<span style="color:teal;">@@ -1281,7 +1372,6 @@</span> export class ARRenderer extends EventDispatcher&lt;
}
this.frame = frame;
<span style="color:red;"> this.ensureCeilingHitTestSource(frame);</span>
// increamenets a counter tracking how many frames have been processed sinces the session started
++this.frames;
// refSpace and pose are used to get the user's current position and orientation in the XR session.
<span style="color:teal;">@@ -1320,6 +1410,7 @@</span> export class ARRenderer extends EventDispatcher&lt;
this.updateView(view);
if (isFirstView) {
<span style="color:green;">this.checkForDeferredCeilingPlacement();</span>
this.handleFirstView(frame, time);
isFirstView = false;
}
<span style="color:teal;">@@ -1328,43 +1419,6 @@</span> export class ARRenderer extends EventDispatcher&lt;
}
}
<span style="color:red;"> // ToDo check if this method is really necessary.</span>
<span style="color:red;"> // Compiler wont let the code compile without this function...</span>
<span style="color:red;"> private ensureCeilingHitTestSource(frame: XRFrame) {</span>
<span style="color:red;"> if (!this.placeOnCeiling || this.initialHitSource) return;</span>
<span style="color:red;"> </span>
<span style="color:red;"> // Guard frame and session </span>
<span style="color:red;"> // ToDo is this necessary?</span>
<span style="color:red;"> const session = frame?.session;</span>
<span style="color:red;"> if (!session) return;</span>
<span style="color:red;"> </span>
<span style="color:red;"> const hasRequestHitTestSource =</span>
<span style="color:red;"> 'requestHitTestSource' in session &amp;&amp;</span>
<span style="color:red;"> typeof (session as any).requestHitTestSource === 'function';</span>
<span style="color:red;"> </span>
<span style="color:red;"> if (!hasRequestHitTestSource) {</span>
<span style="color:red;"> return;</span>
<span style="color:red;"> }</span>
<span style="color:red;"> </span>
<span style="color:red;"> // Use viewer reference space for the directional ray</span>
<span style="color:red;"> session.requestReferenceSpace('viewer')</span>
<span style="color:red;"> .then(viewerSpace =&gt; {</span>
<span style="color:red;"> const r = CEILING_HIT_ANGLE_DEG * Math.PI / 180;</span>
<span style="color:red;"> return (session as any).requestHitTestSource({</span>
<span style="color:red;"> space: viewerSpace,</span>
<span style="color:red;"> offsetRay: new XRRay(</span>
<span style="color:red;"> new DOMPoint(0, 0, 0),</span>
<span style="color:red;"> { x: 0, y: Math.sin(r), z: -Math.cos(r) }</span>
<span style="color:red;"> ),</span>
<span style="color:red;"> });</span>
<span style="color:red;"> })</span>
<span style="color:red;"> .then((src: XRHitTestSource) =&gt; {</span>
<span style="color:red;"> this.initialHitSource = src;</span>
<span style="color:red;"> })</span>
<span style="color:red;"> .catch(() =&gt; {</span>
<span style="color:red;"> // Not ready yet (e.g., early frames); silently retry next frame</span>
<span style="color:red;"> });</span>
<span style="color:red;"> }</span>
/**
* Calculate optimal scale and position for world-space AR presentation
</pre>
</body>
</html>

28
key.pem Normal file
View File

@ -0,0 +1,28 @@
-----BEGIN PRIVATE KEY-----
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCMamrJ3frbCglY
4B7YES9L8RQh8SFex29QMbT8x/aSB9lYO/L3bHFzmolaXzEcV+rfz2eU/UwyXOY+
FpbIzkURc7GPOoiIDGWJQn5w/FyH4S47xaMY5M+wOYB44Ryjxy4BRWJNm69XuTKt
tB87Q+NtykuxNuO2xlkX55uhIwCiiUHw4jbloDv5QvQ7DdIMTxn+U4wdRkF4H1xU
ZNIuKCE50CDMupBdAsvAx0N1SJoyhH0k0ZfpzhrxEtesebLVNwpaoVVBN8DGLRHc
EMUOr2eRTnjCOESgMF6bZrHUcmZXMIGn7cM0DnaXH2nM9XaMcnjL+jYQCODZ3GHr
08ILRSohAgMBAAECggEAN4R75ITYAnLdbz5t85AX2zbedu0X/Jlt8Y81uBx36RUh
YjmRvzIpSUP4urqYeFRqkUM3+TiNP/xrLHFR/ONRe6z6r169TM1Z+ANKavHcw/zM
guWJrvYJB+w0V5bp8/d0wRvl2jmMAms/Fl75Wj1lVqt9cfv53PJfl4wDhJqKTbjR
Wh06aeKnUkAfbufNliUJOSB1w5j7k3C9g1ptmBr6BKPSHSzES+3ORQNNH5/fV0sP
w379+pzka2+e66kqfwV7eO0/9NaIBJXM24DhY4aDqBlshfDr8dE62ttNVB1Buf42
kVj2XRDultsdhyQNQmsCGs+AGHs4WI1rkJgHnJrgYQKBgQDCCn4d4RMLXM6SxFQI
MjgazdHANbRxz6r3PAQUgDjiGonssXcOlB8md5B2uhtz68QkS50+cNPYhhEx1Csq
e4UyoA5bAA1YmbBvJ3pFg49R0Fv2sdXCG+zN+rOI4Ndrc2SKGoq/8vo9313latd8
POAIOn/Z00zZUXoDHM1QJsVNYwKBgQC5QG2Jkymrs7Jobbkcn6fgntNflYq0vej2
9OQXM/moj5tWor5lAiuMH9zBeqIOGXCHfo/HysCSep2koIGHjuL6mYeWaRgZ+cRO
wWoH/xK8YHjnZTdqip1iMVr0ALMvxdeorY8ydWiEhCDKyGACTwQg//SZ9xIDYrSv
mpabGp1zqwKBgQCUFWvgI6fkEQS5b0luI907X33GwXWfMcwY+F1ow94ld/lwgJMK
tjH7ql2+rhNLaU98H5S2VWbnJJG0xGXY+wFQ/GNYQXbt+gRzH96pdFiJKIk2gMtQ
Yv1ayQwA1w6vuxWsa8sd6DHfzDqdXedrsg2LWhG+TAqnAw4pl+58T8pdXwKBgH0C
diiNb2RXyf/gczdWodHZO+hXoJdhRFFKZpUl1Majyf6HqhW9hidz5OOHXs5G6oH0
rQ/0yUjPh4vtaBtTF+ZmLnIYj1QQESHYMTYeMcV/EHeN/Pxfd98oUSkxQ7nsNyCz
pls1kYdDJmHRH8DPE4k1UBmJ+dThCe8qUZFvP2srAoGABG4of+SlUQciu6NtD4tg
CzEhkU7ZL/t4iC4WyC3uihjWgNaiozSDvG4zWkjuiOTuU8QRV9btf7ngMm04ZL8U
MdOP/jbU/W1mBQDg0EkpENUxr4uDS5rLSWEWx9wncVISgcbChGkqMpkXP1TMKck/
Nj1qTqzdeMI8iI56oXQzz70=
-----END PRIVATE KEY-----

18868
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

56
package.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "model-viewer-packages",
"private": true,
"description": "The repository for all <model-viewer> packages",
"scripts": {
"test": "npm run test --workspaces",
"test:ci": "npm run test:ci --workspaces",
"format": "find packages/**/src -name '*.ts' | grep -v .d.ts | xargs ./node_modules/.bin/clang-format --verbose -i",
"lint": "./node_modules/.bin/eslint \"packages/**/*.ts\"",
"lint:fix": "npm run lint -- --fix",
"clean": "npm run clean --workspaces",
"build": "npm run build --workspaces",
"serve": "./node_modules/.bin/http-server -c-1",
"update:package-lock": "npm exec -y --workspaces -- npx rimraf package-lock.json node_modules && npx rimraf package-lock.json node_modules && npm install --save && npm run clean --workspaces && node ./scripts/postinstall.js"
},
"husky": {
"hooks": {
"pre-commit": "./scripts/pre-commit.sh"
}
},
"repository": {
"type": "git",
"url": "git+https://github.com/google/model-viewer.git"
},
"workspaces": [
"packages/model-viewer",
"packages/model-viewer-effects",
"packages/modelviewer.dev",
"packages/render-fidelity-tools",
"packages/space-opera"
],
"author": "DevXR Contributors",
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
},
"bugs": {
"url": "https://github.com/google/model-viewer/issues"
},
"homepage": "https://github.com/google/model-viewer#readme",
"devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.26.1",
"@typescript-eslint/parser": "^8.26.1",
"clang-format": "^1.8.0",
"eslint": "^9.22.0",
"eslint-config-google": "^0.14.0",
"eslint-plugin-mocha": "^10.5.0",
"eslint-plugin-wc": "^2.2.1",
"http-server": "^14.1.1",
"husky": "^9.1.7",
"typescript": "5.8.2"
},
"dependencies": {
"puppeteer": "^24.4.0"
}
}

View File

@ -0,0 +1,8 @@
node_modules/*
shared-assets
dist/*
lib/*
**/*.sw*
.DS_Store
.idea
*.iml

View File

@ -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 projects 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 Stewards 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

View File

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

View File

@ -0,0 +1,99 @@
# `<model-viewer-effects>`
![npm bundle size](https://img.shields.io/bundlephobia/min/@google/model-viewer-effects)
![npm (scoped)](https://img.shields.io/npm/v/@google/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.
![A 3D Model of a Rocket Ship](https://raw.githubusercontent.com/google/model-viewer/master/packages/model-viewer-effects/screenshot.png)
[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:es-module-shims@1.7.1/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)*

View File

@ -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 <adam.v.beili@gmail.com>"
],
"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"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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": []
}

View File

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

9
packages/model-viewer/.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
renderers/*
node_modules/*
shared-assets
dist/*
lib/*
**/*.sw*
.DS_Store
.idea
*.iml

View File

@ -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 projects 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 Stewards 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

View File

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

View File

@ -0,0 +1,100 @@
# `<model-viewer>`
[![Min Zip](https://badgen.net/bundlephobia/minzip/@google/model-viewer)](https://bundlephobia.com/result?p=@google/model-viewer)
[![Latest Release](https://img.shields.io/github/v/release/google/model-viewer)](https://github.com/google/model-viewer/releases)
[![NPM Package](https://img.shields.io/npm/v/@google/model-viewer)](https://www.npmjs.com/package/@google/model-viewer)
[![follow on Twitter](https://img.shields.io/twitter/follow/modelviewer?style=social&logo=twitter)](https://twitter.com/intent/follow?screen_name=modelviewer)
[![Github Discussions](https://img.shields.io/github/stars/google/model-viewer.svg?style=social&label=Star&maxAge=2592000)](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. Well 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 youve installed via [NPM](https://www.npmjs.com/package/@google/model-viewer), youre all set - youll 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

View File

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

View File

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

View File

@ -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 <jsantell@google.com>",
"Chris Joel <cdata@google.com>",
"Emmett Lalish <elalish@google.com>",
"Ricardo Cabello <ricardocabello@google.com>",
"Matt Small <mbsmall@google.com>",
"Yuin Chien <yuin@google.com>",
"Diego Teran <dteran@google.com>"
],
"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"
}
}

View File

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

View File

@ -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>`;

View File

@ -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>`;

View File

@ -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>`;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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/three@0.149.0/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;
};

View File

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

View File

@ -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];

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More