squash commits

This commit is contained in:
MrPlatnum committed 2025-09-05 15:20:42 +02:00
commit 0f545c5e4d
611 files changed
+272565

No files matched your search

@@ -0,0 +1,36 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {html} from 'lit';
export default html`
<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#000000">
<!-- NOTE(cdata): This SVG filter is a stop-gap until we can implement
support for dynamic re-coloring of UI components -->
<defs>
<filter id="drop-shadow" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur in="SourceAlpha" stdDeviation="1"/>
<feOffset dx="0" dy="0" result="offsetblur"/>
<feFlood flood-color="#000000"/>
<feComposite in2="offsetblur" operator="in"/>
<feMerge>
<feMergeNode/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
</defs>
<path filter="url(#drop-shadow)" d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/>
<path d="M0 0h24v24H0z" fill="none"/>
</svg>`;
@@ -0,0 +1,37 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {html} from 'lit';
export default html`
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="25" height="36">
<defs>
<path id="A" d="M.001.232h24.997V36H.001z" />
</defs>
<g transform="translate(-11 -4)" fill="none" fill-rule="evenodd">
<path fill-opacity="0" fill="#fff" d="M0 0h44v44H0z" />
<g transform="translate(11 3)">
<path d="M8.733 11.165c.04-1.108.766-2.027 1.743-2.307a2.54 2.54 0 0 1 .628-.089c.16 0 .314.017.463.044 1.088.2 1.9 1.092 1.9 2.16v8.88h1.26c2.943-1.39 5-4.45 5-8.025a9.01 9.01 0 0 0-1.9-5.56l-.43-.5c-.765-.838-1.683-1.522-2.712-2-1.057-.49-2.226-.77-3.46-.77s-2.4.278-3.46.77c-1.03.478-1.947 1.162-2.71 2l-.43.5a9.01 9.01 0 0 0-1.9 5.56 9.04 9.04 0 0 0 .094 1.305c.03.21.088.41.13.617l.136.624c.083.286.196.56.305.832l.124.333a8.78 8.78 0 0 0 .509.953l.065.122a8.69 8.69 0 0 0 3.521 3.191l1.11.537v-9.178z" fill-opacity=".5" fill="#e4e4e4" />
<path d="M22.94 26.218l-2.76 7.74c-.172.485-.676.8-1.253.8H12.24c-1.606 0-3.092-.68-3.98-1.82-1.592-2.048-3.647-3.822-6.11-5.27-.095-.055-.15-.137-.152-.23-.004-.1.046-.196.193-.297.56-.393 1.234-.6 1.926-.6a3.43 3.43 0 0 1 .691.069l4.922.994V10.972c0-.663.615-1.203 1.37-1.203s1.373.54 1.373 1.203v9.882h2.953c.273 0 .533.073.757.21l6.257 3.874c.027.017.045.042.07.06.41.296.586.77.426 1.22M4.1 16.614c-.024-.04-.042-.083-.065-.122a8.69 8.69 0 0 1-.509-.953c-.048-.107-.08-.223-.124-.333l-.305-.832c-.058-.202-.09-.416-.136-.624l-.13-.617a9.03 9.03 0 0 1-.094-1.305c0-2.107.714-4.04 1.9-5.56l.43-.5c.764-.84 1.682-1.523 2.71-2 1.058-.49 2.226-.77 3.46-.77s2.402.28 3.46.77c1.03.477 1.947 1.16 2.712 2l.428.5a9 9 0 0 1 1.901 5.559c0 3.577-2.056 6.636-5 8.026h-1.26v-8.882c0-1.067-.822-1.96-1.9-2.16-.15-.028-.304-.044-.463-.044-.22 0-.427.037-.628.09-.977.28-1.703 1.198-1.743 2.306v9.178l-1.11-.537C6.18 19.098 4.96 18 4.1 16.614M22.97 24.09l-6.256-3.874c-.102-.063-.218-.098-.33-.144 2.683-1.8 4.354-4.855 4.354-8.243 0-.486-.037-.964-.104-1.43a9.97 9.97 0 0 0-1.57-4.128l-.295-.408-.066-.092a10.05 10.05 0 0 0-.949-1.078c-.342-.334-.708-.643-1.094-.922-1.155-.834-2.492-1.412-3.94-1.65l-.732-.088-.748-.03a9.29 9.29 0 0 0-1.482.119c-1.447.238-2.786.816-3.94 1.65a9.33 9.33 0 0 0-.813.686 9.59 9.59 0 0 0-.845.877l-.385.437-.36.5-.288.468-.418.778-.04.09c-.593 1.28-.93 2.71-.93 4.222 0 3.832 2.182 7.342 5.56 8.938l1.437.68v4.946L5 25.64a4.44 4.44 0 0 0-.888-.086c-.017 0-.034.003-.05.003-.252.004-.503.033-.75.08a5.08 5.08 0 0 0-.237.056c-.193.046-.382.107-.568.18-.075.03-.15.057-.225.1-.25.114-.494.244-.723.405a1.31 1.31 0 0 0-.566 1.122 1.28 1.28 0 0 0 .645 1.051C4 29.925 5.96 31.614 7.473 33.563a5.06 5.06 0 0 0 .434.491c1.086 1.082 2.656 1.713 4.326 1.715h6.697c.748-.001 1.43-.333 1.858-.872.142-.18.256-.38.336-.602l2.757-7.74c.094-.26.13-.53.112-.794s-.088-.52-.203-.76a2.19 2.19 0 0 0-.821-.91" fill-opacity=".6" fill="#000" />
<path d="M22.444 24.94l-6.257-3.874a1.45 1.45 0 0 0-.757-.211h-2.953v-9.88c0-.663-.616-1.203-1.373-1.203s-1.37.54-1.37 1.203v16.643l-4.922-.994a3.44 3.44 0 0 0-.692-.069 3.35 3.35 0 0 0-1.925.598c-.147.102-.198.198-.194.298.004.094.058.176.153.23 2.462 1.448 4.517 3.22 6.11 5.27.887 1.14 2.373 1.82 3.98 1.82h6.686c.577 0 1.08-.326 1.253-.8l2.76-7.74c.16-.448-.017-.923-.426-1.22-.025-.02-.043-.043-.07-.06z" fill="#fff" />
<g transform="translate(0 .769)">
<mask id="B" fill="#fff">
<use xlink:href="#A" />
</mask>
<path d="M23.993 24.992a1.96 1.96 0 0 1-.111.794l-2.758 7.74c-.08.22-.194.423-.336.602-.427.54-1.11.87-1.857.872h-6.698c-1.67-.002-3.24-.633-4.326-1.715-.154-.154-.3-.318-.434-.49C5.96 30.846 4 29.157 1.646 27.773c-.385-.225-.626-.618-.645-1.05a1.31 1.31 0 0 1 .566-1.122 4.56 4.56 0 0 1 .723-.405l.225-.1a4.3 4.3 0 0 1 .568-.18l.237-.056c.248-.046.5-.075.75-.08.018 0 .034-.003.05-.003.303-.001.597.027.89.086l3.722.752V20.68l-1.436-.68c-3.377-1.596-5.56-5.106-5.56-8.938 0-1.51.336-2.94.93-4.222.015-.03.025-.06.04-.09.127-.267.268-.525.418-.778.093-.16.186-.316.288-.468.063-.095.133-.186.2-.277L3.773 5c.118-.155.26-.29.385-.437.266-.3.544-.604.845-.877a9.33 9.33 0 0 1 .813-.686C6.97 2.167 8.31 1.59 9.757 1.35a9.27 9.27 0 0 1 1.481-.119 8.82 8.82 0 0 1 .748.031c.247.02.49.05.733.088 1.448.238 2.786.816 3.94 1.65.387.28.752.588 1.094.922a9.94 9.94 0 0 1 .949 1.078l.066.092c.102.133.203.268.295.408a9.97 9.97 0 0 1 1.571 4.128c.066.467.103.945.103 1.43 0 3.388-1.67 6.453-4.353 8.243.11.046.227.08.33.144l6.256 3.874c.37.23.645.55.82.9.115.24.185.498.203.76m.697-1.195c-.265-.55-.677-1.007-1.194-1.326l-5.323-3.297c2.255-2.037 3.564-4.97 3.564-8.114 0-2.19-.637-4.304-1.84-6.114-.126-.188-.26-.37-.4-.552-.645-.848-1.402-1.6-2.252-2.204C15.472.91 13.393.232 11.238.232A10.21 10.21 0 0 0 5.23 2.19c-.848.614-1.606 1.356-2.253 2.205-.136.18-.272.363-.398.55C1.374 6.756.737 8.87.737 11.06c0 4.218 2.407 8.08 6.133 9.842l.863.41v3.092l-2.525-.51c-.356-.07-.717-.106-1.076-.106a5.45 5.45 0 0 0-3.14.996c-.653.46-1.022 1.202-.99 1.983a2.28 2.28 0 0 0 1.138 1.872c2.24 1.318 4.106 2.923 5.543 4.772 1.26 1.62 3.333 2.59 5.55 2.592h6.698c1.42-.001 2.68-.86 3.134-2.138l2.76-7.74c.272-.757.224-1.584-.134-2.325" fill-opacity=".05" fill="#000" mask="url(#B)" />
</g>
</g>
</g>
</svg>`;
@@ -0,0 +1,34 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {html} from 'lit';
export default html`
<svg version="1.1" id="view_x5F_in_x5F_AR_x5F_icon"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" width="24px" height="24px"
viewBox="0 0 24 24" enable-background="new 0 0 24 24" xml:space="preserve">
<rect id="Bounding_Box" x="0" y="0" fill="none" width="24" height="24"/>
<g id="Art_layer">
<path d="M3,4c0-0.55,0.45-1,1-1h2V1H4C2.35,1,1,2.35,1,4v2h2V4z"/>
<path d="M20,3c0.55,0,1,0.45,1,1v2h2V4c0-1.65-1.35-3-3-3h-2v2H20z"/>
<path d="M4,21c-0.55,0-1-0.45-1-1v-2H1v2c0,1.65,1.35,3,3,3h2v-2H4z"/>
<path d="M20,21c0.55,0,1-0.45,1-1v-2h2v2c0,1.65-1.35,3-3,3h-2v-2H20z"/>
<g>
<path d="M18.25,7.6l-5.5-3.18c-0.46-0.27-1.04-0.27-1.5,0L5.75,7.6C5.29,7.87,5,8.36,5,8.9v6.35c0,0.54,0.29,1.03,0.75,1.3
l5.5,3.18c0.46,0.27,1.04,0.27,1.5,0l5.5-3.18c0.46-0.27,0.75-0.76,0.75-1.3V8.9C19,8.36,18.71,7.87,18.25,7.6z M7,14.96v-4.62
l4,2.32v4.61L7,14.96z M12,10.93L8,8.61l4-2.31l4,2.31L12,10.93z M13,17.27v-4.61l4-2.32v4.62L13,17.27z"/>
</g>
</g>
</svg>`;
+103
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;
}
})();
+162
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;
}
}
}
});
};
};
@@ -0,0 +1,309 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {property} from 'lit/decorators.js';
import {LoopOnce, LoopPingPong, LoopRepeat} from 'three';
import ModelViewerElementBase, {$getModelIsVisible, $needsRender, $onModelLoad, $renderer, $scene, $tick} from '../model-viewer-base.js';
import {Constructor} from '../utilities.js';
const MILLISECONDS_PER_SECOND = 1000.0
const $changeAnimation = Symbol('changeAnimation');
const $appendAnimation = Symbol('appendAnimation');
const $detachAnimation = Symbol('detachAnimation');
const $paused = Symbol('paused');
interface PlayAnimationOptions {
repetitions: number, pingpong: boolean,
}
interface AppendAnimationOptions {
pingpong: boolean, repetitions: number|null, weight: number,
timeScale: number, fade: boolean|number, warp: boolean|number,
relativeWarp: boolean, time: number|null
}
interface DetachAnimationOptions {
fade: boolean|number
}
const DEFAULT_PLAY_OPTIONS: PlayAnimationOptions = {
repetitions: Infinity,
pingpong: false
};
const DEFAULT_APPEND_OPTIONS: AppendAnimationOptions = {
pingpong: false,
repetitions: null,
weight: 1,
timeScale: 1,
fade: false,
warp: false,
relativeWarp: true,
time: null
};
const DEFAULT_DETACH_OPTIONS: DetachAnimationOptions = {
fade: true
};
export declare interface AnimationInterface {
autoplay: boolean;
animationName: string|void;
animationCrossfadeDuration: number;
readonly availableAnimations: Array<string>;
readonly paused: boolean;
readonly duration: number;
currentTime: number;
timeScale: number;
pause(): void;
play(options?: PlayAnimationOptions): void;
appendAnimation(animationName: string, options?: AppendAnimationOptions):
void;
detachAnimation(animationName: string, options?: DetachAnimationOptions):
void;
}
export const AnimationMixin = <T extends Constructor<ModelViewerElementBase>>(
ModelViewerElement: T): Constructor<AnimationInterface>&T => {
class AnimationModelViewerElement extends ModelViewerElement {
@property({type: Boolean}) autoplay: boolean = false;
@property({type: String, attribute: 'animation-name'})
animationName: string|undefined = undefined;
@property({type: Number, attribute: 'animation-crossfade-duration'})
animationCrossfadeDuration: number = 300;
protected[$paused]: boolean = true;
constructor(...args: any[]) {
super(args);
this[$scene].subscribeMixerEvent('loop', (e) => {
const count = e.action._loopCount;
const name = e.action._clip.name;
const uuid = e.action._clip.uuid;
const targetAnimation =
this[$scene].markedAnimations.find(e => e.name === name);
if (targetAnimation) {
this[$scene].updateAnimationLoop(
targetAnimation.name,
targetAnimation.loopMode,
targetAnimation.repetitionCount);
const filtredArr =
this[$scene].markedAnimations.filter(e => e.name !== name);
this[$scene].markedAnimations = filtredArr;
}
this.dispatchEvent(
new CustomEvent('loop', {detail: {count, name, uuid}}));
});
this[$scene].subscribeMixerEvent('finished', (e) => {
if (!this[$scene].appendedAnimations.includes(e.action._clip.name)) {
this[$paused] = true;
} else {
const filteredList = this[$scene].appendedAnimations.filter(
i => i !== e.action._clip.name);
this[$scene].appendedAnimations = filteredList;
}
this.dispatchEvent(new CustomEvent('finished'));
});
}
/**
* Returns an array
*/
get availableAnimations(): Array<string> {
if (this.loaded) {
return this[$scene].animationNames;
}
return [];
}
get duration(): number {
return this[$scene].duration;
}
get paused(): boolean {
return this[$paused];
}
get currentTime(): number {
return this[$scene].animationTime;
}
get appendedAnimations(): string[] {
return this[$scene].appendedAnimations;
}
set currentTime(value: number) {
this[$scene].animationTime = value;
this[$needsRender]();
}
get timeScale(): number {
return this[$scene].animationTimeScale;
}
set timeScale(value: number) {
this[$scene].animationTimeScale = value;
}
pause() {
if (this[$paused]) {
return;
}
this[$paused] = true;
this.dispatchEvent(new CustomEvent('pause'));
}
play(options?: PlayAnimationOptions) {
if (this.availableAnimations.length > 0) {
this[$paused] = false;
this[$changeAnimation](options);
this.dispatchEvent(new CustomEvent('play'));
}
}
appendAnimation(animationName: string, options?: AppendAnimationOptions) {
if (this.availableAnimations.length > 0) {
this[$paused] = false;
this[$appendAnimation](animationName, options);
this.dispatchEvent(new CustomEvent('append-animation'));
}
}
detachAnimation(animationName: string, options?: DetachAnimationOptions) {
if (this.availableAnimations.length > 0) {
this[$paused] = false;
this[$detachAnimation](animationName, options);
this.dispatchEvent(new CustomEvent('detach-animation'));
}
}
[$onModelLoad]() {
super[$onModelLoad]();
this[$paused] = true;
if (this.animationName != null) {
this[$changeAnimation]();
}
if (this.autoplay) {
this.play();
}
}
[$tick](_time: number, delta: number) {
super[$tick](_time, delta);
if (this[$paused] ||
(!this[$getModelIsVisible]() && !this[$renderer].isPresenting)) {
return;
}
this[$scene].updateAnimation(delta / MILLISECONDS_PER_SECOND);
this[$needsRender]();
}
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('autoplay') && this.autoplay) {
this.play();
}
if (changedProperties.has('animationName')) {
this[$changeAnimation]();
}
}
[$changeAnimation](options: PlayAnimationOptions = DEFAULT_PLAY_OPTIONS) {
const repetitions = options.repetitions ?? Infinity;
const mode = options.pingpong ?
LoopPingPong :
(repetitions === 1 ? LoopOnce : LoopRepeat);
this[$scene].playAnimation(
this.animationName,
this.animationCrossfadeDuration / MILLISECONDS_PER_SECOND,
mode,
repetitions);
// If we are currently paused, we need to force a render so that
// the scene updates to the first frame of the new animation
if (this[$paused]) {
this[$scene].updateAnimation(0);
this[$needsRender]();
}
}
[$appendAnimation](
animationName: string = '',
options: AppendAnimationOptions = DEFAULT_APPEND_OPTIONS) {
const repetitions = options.repetitions ?? Infinity;
const mode = options.pingpong ?
LoopPingPong :
(repetitions === 1 ? LoopOnce : LoopRepeat);
const needsToStop = !!options.repetitions || 'pingpong' in options;
this[$scene].appendAnimation(
animationName ? animationName : this.animationName,
mode,
repetitions,
options.weight,
options.timeScale,
options.fade,
options.warp,
options.relativeWarp,
options.time,
needsToStop);
// If we are currently paused, we need to force a render so that
// the scene updates to the first frame of the new animation
if (this[$paused]) {
this[$scene].updateAnimation(0);
this[$needsRender]();
}
}
[$detachAnimation](
animationName: string = '',
options: DetachAnimationOptions = DEFAULT_DETACH_OPTIONS) {
this[$scene].detachAnimation(
animationName ? animationName : this.animationName, options.fade);
// If we are currently paused, we need to force a render so that
// the scene updates to the first frame of the new animation
if (this[$paused]) {
this[$scene].updateAnimation(0);
this[$needsRender]();
}
}
}
return AnimationModelViewerElement;
};
@@ -0,0 +1,272 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Matrix4, Vector3} from 'three';
import ModelViewerElementBase, {$needsRender, $onModelLoad, $scene, $tick, toVector2D, toVector3D, Vector2D, Vector3D} from '../model-viewer-base.js';
import {Hotspot, HotspotConfiguration} from '../three-components/Hotspot.js';
import {Constructor} from '../utilities.js';
const $hotspotMap = Symbol('hotspotMap');
const $mutationCallback = Symbol('mutationCallback');
const $observer = Symbol('observer');
const $addHotspot = Symbol('addHotspot');
const $removeHotspot = Symbol('removeHotspot');
const worldToModel = new Matrix4();
export declare type HotspotData = {
position: Vector3D,
normal: Vector3D,
canvasPosition: Vector3D,
facingCamera: boolean,
}
export declare interface AnnotationInterface {
updateHotspot(config: HotspotConfiguration): void;
queryHotspot(name: string): HotspotData|null;
positionAndNormalFromPoint(pixelX: number, pixelY: number):
{position: Vector3D, normal: Vector3D, uv: Vector2D|null}|null;
surfaceFromPoint(pixelX: number, pixelY: number): string|null;
}
/**
* AnnotationMixin implements a declarative API to add hotspots and annotations.
* Child elements of the <model-viewer> element that have a slot name that
* begins with "hotspot" and data-position and data-normal attributes in
* the format of the camera-target attribute will be added to the scene and
* track the specified model coordinates.
*/
export const AnnotationMixin = <T extends Constructor<ModelViewerElementBase>>(
ModelViewerElement: T): Constructor<AnnotationInterface>&T => {
class AnnotationModelViewerElement extends ModelViewerElement {
private[$hotspotMap] = new Map<string, Hotspot>();
private[$mutationCallback] = (mutations: Array<unknown>) => {
mutations.forEach((mutation) => {
// NOTE: Be wary that in ShadyDOM cases, the MutationRecord
// only has addedNodes and removedNodes (and no other details).
if (!(mutation instanceof MutationRecord) ||
mutation.type === 'childList') {
(mutation as MutationRecord).addedNodes.forEach((node) => {
this[$addHotspot](node);
});
(mutation as MutationRecord).removedNodes.forEach((node) => {
this[$removeHotspot](node);
});
this[$needsRender]();
}
});
};
private[$observer] = new MutationObserver(this[$mutationCallback]);
connectedCallback() {
super.connectedCallback();
for (let i = 0; i < this.children.length; ++i) {
this[$addHotspot](this.children[i]);
}
const {ShadyDOM} = self as any;
if (ShadyDOM == null) {
this[$observer].observe(this, {childList: true});
} else {
this[$observer] =
ShadyDOM.observeChildren(this, this[$mutationCallback]);
}
}
disconnectedCallback() {
super.disconnectedCallback();
const {ShadyDOM} = self as any;
if (ShadyDOM == null) {
this[$observer].disconnect();
} else {
ShadyDOM.unobserveChildren(this[$observer]);
}
}
[$onModelLoad]() {
super[$onModelLoad]();
const scene = this[$scene];
scene.forHotspots((hotspot) => {
scene.updateSurfaceHotspot(hotspot);
});
}
[$tick](time: number, delta: number) {
super[$tick](time, delta);
const scene = this[$scene];
const {annotationRenderer} = scene;
const camera = scene.getCamera();
if (scene.shouldRender()) {
scene.animateSurfaceHotspots();
scene.updateHotspotsVisibility(camera.position);
annotationRenderer.domElement.style.display = '';
annotationRenderer.render(scene, camera);
}
}
/**
* Since the data-position and data-normal attributes are not observed, use
* this method to move a hotspot. Keep in mind that all hotspots with the
* same slot name use a single location and the first definition takes
* precedence, until updated with this method.
*/
updateHotspot(config: HotspotConfiguration) {
const hotspot = this[$hotspotMap].get(config.name);
if (hotspot == null) {
return;
}
hotspot.updatePosition(config.position);
hotspot.updateNormal(config.normal);
hotspot.surface = config.surface;
this[$scene].updateSurfaceHotspot(hotspot);
this[$needsRender]();
}
/**
* This method returns in-scene data about a requested hotspot including
* its position in screen (canvas) space and its current visibility.
*/
queryHotspot(name: string): HotspotData|null {
const hotspot = this[$hotspotMap].get(name);
if (hotspot == null) {
return null;
}
const position = toVector3D(hotspot.position);
const normal = toVector3D(hotspot.normal);
const facingCamera = hotspot.facingCamera;
const scene = this[$scene];
const camera = scene.getCamera();
const vector = new Vector3();
vector.setFromMatrixPosition(hotspot.matrixWorld);
vector.project(camera);
const widthHalf = scene.width / 2;
const heightHalf = scene.height / 2;
vector.x = (vector.x * widthHalf) + widthHalf;
vector.y = -(vector.y * heightHalf) + heightHalf;
const canvasPosition =
toVector3D(new Vector3(vector.x, vector.y, vector.z));
if (!Number.isFinite(canvasPosition.x) ||
!Number.isFinite(canvasPosition.y)) {
return null;
}
return {position, normal, canvasPosition, facingCamera};
}
/**
* This method returns the model position, normal and texture coordinate
* of the point on the mesh corresponding to the input pixel coordinates
* given relative to the model-viewer element. The position and normal
* are returned as strings in the format suitable for putting in a
* hotspot's data-position and data-normal attributes. If the mesh is
* not hit, the result is null.
*/
positionAndNormalFromPoint(pixelX: number, pixelY: number):
{position: Vector3D, normal: Vector3D, uv: Vector2D|null}|null {
const scene = this[$scene];
const ndcPosition = scene.getNDC(pixelX, pixelY);
const hit = scene.positionAndNormalFromPoint(ndcPosition);
if (hit == null) {
return null;
}
worldToModel.copy(scene.target.matrixWorld).invert();
const position = toVector3D(hit.position.applyMatrix4(worldToModel));
const normal = toVector3D(hit.normal.transformDirection(worldToModel));
let uv = null;
if (hit.uv != null) {
uv = toVector2D(hit.uv);
}
return {position: position, normal: normal, uv: uv};
}
/**
* This method returns a dynamic hotspot ID string of the point on the mesh
* corresponding to the input pixel coordinates given relative to the
* model-viewer element. The ID string can be used in the data-surface
* attribute of the hotspot to make it follow this point on the surface even
* as the model animates. If the mesh is not hit, the result is null.
*/
surfaceFromPoint(pixelX: number, pixelY: number): string|null {
const scene = this[$scene];
const ndcPosition = scene.getNDC(pixelX, pixelY);
return scene.surfaceFromPoint(ndcPosition);
}
private[$addHotspot](node: Node) {
if (!(node instanceof HTMLElement &&
node.slot.indexOf('hotspot') === 0)) {
return;
}
let hotspot = this[$hotspotMap].get(node.slot);
if (hotspot != null) {
hotspot.increment();
} else {
hotspot = new Hotspot({
name: node.slot,
position: node.dataset.position,
normal: node.dataset.normal,
surface: node.dataset.surface,
});
this[$hotspotMap].set(node.slot, hotspot);
this[$scene].addHotspot(hotspot);
}
this[$scene].queueRender();
}
private[$removeHotspot](node: Node) {
if (!(node instanceof HTMLElement)) {
return;
}
const hotspot = this[$hotspotMap].get(node.slot);
if (!hotspot) {
return;
}
if (hotspot.decrement()) {
this[$scene].removeHotspot(hotspot);
this[$hotspotMap].delete(node.slot);
}
this[$scene].queueRender();
}
}
return AnnotationModelViewerElement;
};
+486
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;
};
@@ -0,0 +1,956 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {property} from 'lit/decorators.js';
import {Event, PerspectiveCamera, Spherical, Vector3} from 'three';
import {style} from '../decorators.js';
import ModelViewerElementBase, {$ariaLabel, $container, $getModelIsVisible, $loadedTime, $needsRender, $onModelLoad, $onResize, $renderer, $scene, $tick, $updateStatus, $userInputElement, toVector3D, Vector3D} from '../model-viewer-base.js';
import {degreesToRadians, normalizeUnit} from '../styles/conversions.js';
import {EvaluatedStyle, Intrinsics, SphericalIntrinsics, StyleEvaluator, Vector3Intrinsics} from '../styles/evaluators.js';
import {IdentNode, NumberNode, numberNode, parseExpressions} from '../styles/parsers.js';
import {DECAY_MILLISECONDS} from '../three-components/Damper.js';
import {ChangeSource, PointerChangeEvent, SmoothControls} from '../three-components/SmoothControls.js';
import {Constructor} from '../utilities.js';
import {Path, timeline, TimingFunction} from '../utilities/animation.js';
// NOTE(cdata): The following "animation" timing functions are deliberately
// being used in favor of CSS animations. In Safari 12.1 and 13, CSS animations
// would cause the interaction prompt to glitch unexpectedly
// @see https://github.com/google/model-viewer/issues/839
const PROMPT_ANIMATION_TIME = 5000;
// For timing purposes, a "frame" is a timing agnostic relative unit of time
// and a "value" is a target value for the Frame.
const wiggle = timeline({
initialValue: 0,
keyframes: [
{frames: 5, value: -1},
{frames: 1, value: -1},
{frames: 8, value: 1},
{frames: 1, value: 1},
{frames: 5, value: 0},
{frames: 18, value: 0}
]
});
const fade = timeline({
initialValue: 0,
keyframes: [
{frames: 1, value: 1},
{frames: 5, value: 1},
{frames: 1, value: 0},
{frames: 6, value: 0}
]
});
export const DEFAULT_FOV_DEG = 30;
export const DEFAULT_MIN_FOV_DEG = 12;
export const DEFAULT_CAMERA_ORBIT = '0deg 75deg 105%';
const DEFAULT_CAMERA_TARGET = 'auto auto auto';
const DEFAULT_FIELD_OF_VIEW = 'auto';
const MINIMUM_RADIUS_RATIO = 2.2;
const AZIMUTHAL_QUADRANT_LABELS = ['front', 'right', 'back', 'left'];
const POLAR_TRIENT_LABELS = ['upper-', '', 'lower-'];
export const DEFAULT_INTERACTION_PROMPT_THRESHOLD = 3000;
export const INTERACTION_PROMPT = '. Use mouse, touch or arrow keys to move.';
export interface CameraChangeDetails {
source: ChangeSource;
}
export interface SphericalPosition {
theta: number; // equator angle around the y (up) axis.
phi: number; // polar angle from the y (up) axis.
radius: number;
toString(): string;
}
export interface Finger {
x: Path;
y: Path;
}
export interface A11yTranslationsInterface {
left: string;
right: string;
front: string;
back: string;
'upper-left': string;
'upper-right': string;
'upper-front': string;
'upper-back': string;
'lower-left': string;
'lower-right': string;
'lower-front': string;
'lower-back': string;
'interaction-prompt': string;
}
export type InteractionPromptStrategy = 'auto'|'none';
export type InteractionPromptStyle = 'basic'|'wiggle';
export type TouchAction = 'pan-y'|'pan-x'|'none';
export const InteractionPromptStrategy:
{[index: string]: InteractionPromptStrategy} = {
AUTO: 'auto',
NONE: 'none'
};
export const InteractionPromptStyle:
{[index: string]: InteractionPromptStyle} = {
BASIC: 'basic',
WIGGLE: 'wiggle'
};
export const TouchAction: {[index: string]: TouchAction} = {
PAN_Y: 'pan-y',
PAN_X: 'pan-x',
NONE: 'none'
};
export const fieldOfViewIntrinsics = () => {
return {
basis:
[degreesToRadians(numberNode(DEFAULT_FOV_DEG, 'deg')) as
NumberNode<'rad'>],
keywords: {auto: [null]}
};
};
const minFieldOfViewIntrinsics = () => {
return {
basis:
[degreesToRadians(numberNode(DEFAULT_MIN_FOV_DEG, 'deg')) as
NumberNode<'rad'>],
keywords: {auto: [null]}
};
};
export const cameraOrbitIntrinsics = (() => {
const defaultTerms =
parseExpressions(DEFAULT_CAMERA_ORBIT)[0]
.terms as [NumberNode<'rad'>, NumberNode<'rad'>, IdentNode];
const theta = normalizeUnit(defaultTerms[0]) as NumberNode<'rad'>;
const phi = normalizeUnit(defaultTerms[1]) as NumberNode<'rad'>;
return (element: ModelViewerElementBase) => {
const radius = element[$scene].idealCameraDistance();
return {
basis: [theta, phi, numberNode(radius, 'm')],
keywords: {auto: [null, null, numberNode(105, '%')]}
};
};
})();
const minCameraOrbitIntrinsics = (element: ModelViewerElementBase&
ControlsInterface) => {
const radius = MINIMUM_RADIUS_RATIO * element[$scene].boundingSphere.radius;
return {
basis: [
numberNode(-Infinity, 'rad'),
numberNode(0, 'rad'),
numberNode(radius, 'm')
],
keywords: {auto: [null, null, null]}
};
};
const maxCameraOrbitIntrinsics = (element: ModelViewerElementBase) => {
const orbitIntrinsics = cameraOrbitIntrinsics(element);
const evaluator = new StyleEvaluator([], orbitIntrinsics);
const defaultRadius = evaluator.evaluate()[2];
return {
basis: [
numberNode(Infinity, 'rad'),
numberNode(Math.PI, 'rad'),
numberNode(defaultRadius, 'm')
],
keywords: {auto: [null, null, null]}
};
};
export const cameraTargetIntrinsics = (element: ModelViewerElementBase) => {
const center = element[$scene].boundingBox.getCenter(new Vector3());
return {
basis: [
numberNode(center.x, 'm'),
numberNode(center.y, 'm'),
numberNode(center.z, 'm')
],
keywords: {auto: [null, null, null]}
};
};
const HALF_PI = Math.PI / 2.0;
const THIRD_PI = Math.PI / 3.0;
const QUARTER_PI = HALF_PI / 2.0;
const TAU = 2.0 * Math.PI;
export const $controls = Symbol('controls');
export const $panElement = Symbol('panElement');
export const $promptElement = Symbol('promptElement');
export const $promptAnimatedContainer = Symbol('promptAnimatedContainer');
export const $fingerAnimatedContainers = Symbol('fingerAnimatedContainers');
const $deferInteractionPrompt = Symbol('deferInteractionPrompt');
const $updateAria = Symbol('updateAria');
const $a11y = Symbol('a11y');
const $updateA11y = Symbol('updateA11y');
const $updateCameraForRadius = Symbol('updateCameraForRadius');
const $cancelPrompts = Symbol('cancelPrompts');
const $onChange = Symbol('onChange');
const $onPointerChange = Symbol('onPointerChange');
const $waitingToPromptUser = Symbol('waitingToPromptUser');
const $userHasInteracted = Symbol('userHasInteracted');
const $promptElementVisibleTime = Symbol('promptElementVisibleTime');
const $lastPromptOffset = Symbol('lastPromptOffset');
const $cancellationSource = Symbol('cancellationSource');
const $lastSpherical = Symbol('lastSpherical');
const $jumpCamera = Symbol('jumpCamera');
const $initialized = Symbol('initialized');
const $maintainThetaPhi = Symbol('maintainThetaPhi');
const $syncCameraOrbit = Symbol('syncCameraOrbit');
const $syncFieldOfView = Symbol('syncFieldOfView');
const $syncCameraTarget = Symbol('syncCameraTarget');
const $syncMinCameraOrbit = Symbol('syncMinCameraOrbit');
const $syncMaxCameraOrbit = Symbol('syncMaxCameraOrbit');
const $syncMinFieldOfView = Symbol('syncMinFieldOfView');
const $syncMaxFieldOfView = Symbol('syncMaxFieldOfView');
export declare interface ControlsInterface {
cameraControls: boolean;
cameraOrbit: string;
cameraTarget: string;
fieldOfView: string;
minCameraOrbit: string;
maxCameraOrbit: string;
minFieldOfView: string;
maxFieldOfView: string;
interactionPrompt: InteractionPromptStrategy;
interactionPromptStyle: InteractionPromptStyle;
interactionPromptThreshold: number;
orbitSensitivity: number;
zoomSensitivity: number;
panSensitivity: number;
touchAction: TouchAction;
interpolationDecay: number;
disableZoom: boolean;
disablePan: boolean;
disableTap: boolean;
a11y: A11yTranslationsInterface|string|null;
getCameraOrbit(): SphericalPosition;
getCameraTarget(): Vector3D;
getFieldOfView(): number;
getMinimumFieldOfView(): number;
getMaximumFieldOfView(): number;
getIdealAspect(): number;
jumpCameraToGoal(): void;
updateFraming(): Promise<void>;
resetInteractionPrompt(): void;
zoom(keyPresses: number): void;
interact(duration: number, finger0: Finger, finger1?: Finger): void;
inputSensitivity: number;
}
export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
ModelViewerElement: T): Constructor<ControlsInterface>&T => {
class ControlsModelViewerElement extends ModelViewerElement {
@property({type: Boolean, attribute: 'camera-controls'})
cameraControls: boolean = false;
@style({
intrinsics: cameraOrbitIntrinsics,
observeEffects: true,
updateHandler: $syncCameraOrbit
})
@property({type: String, attribute: 'camera-orbit', hasChanged: () => true})
cameraOrbit: string = DEFAULT_CAMERA_ORBIT;
@style({
intrinsics: cameraTargetIntrinsics,
observeEffects: true,
updateHandler: $syncCameraTarget
})
@property(
{type: String, attribute: 'camera-target', hasChanged: () => true})
cameraTarget: string = DEFAULT_CAMERA_TARGET;
@style({
intrinsics: fieldOfViewIntrinsics,
observeEffects: true,
updateHandler: $syncFieldOfView
})
@property(
{type: String, attribute: 'field-of-view', hasChanged: () => true})
fieldOfView: string = DEFAULT_FIELD_OF_VIEW;
@style({
intrinsics: minCameraOrbitIntrinsics,
updateHandler: $syncMinCameraOrbit
})
@property(
{type: String, attribute: 'min-camera-orbit', hasChanged: () => true})
minCameraOrbit: string = 'auto';
@style({
intrinsics: maxCameraOrbitIntrinsics,
updateHandler: $syncMaxCameraOrbit
})
@property(
{type: String, attribute: 'max-camera-orbit', hasChanged: () => true})
maxCameraOrbit: string = 'auto';
@style({
intrinsics: minFieldOfViewIntrinsics,
updateHandler: $syncMinFieldOfView
})
@property(
{type: String, attribute: 'min-field-of-view', hasChanged: () => true})
minFieldOfView: string = 'auto';
@style(
{intrinsics: fieldOfViewIntrinsics, updateHandler: $syncMaxFieldOfView})
@property(
{type: String, attribute: 'max-field-of-view', hasChanged: () => true})
maxFieldOfView: string = 'auto';
@property({type: Number, attribute: 'interaction-prompt-threshold'})
interactionPromptThreshold: number = DEFAULT_INTERACTION_PROMPT_THRESHOLD;
@property({type: String, attribute: 'interaction-prompt'})
interactionPrompt: InteractionPromptStrategy =
InteractionPromptStrategy.AUTO;
@property({type: String, attribute: 'interaction-prompt-style'})
interactionPromptStyle: InteractionPromptStyle =
InteractionPromptStyle.WIGGLE;
@property({type: Number, attribute: 'orbit-sensitivity'})
orbitSensitivity: number = 1;
@property({type: Number, attribute: 'zoom-sensitivity'})
zoomSensitivity: number = 1;
@property({type: Number, attribute: 'pan-sensitivity'})
panSensitivity: number = 1;
@property({type: String, attribute: 'touch-action'})
touchAction: TouchAction = TouchAction.NONE;
@property({type: Boolean, attribute: 'disable-zoom'})
disableZoom: boolean = false;
@property({type: Boolean, attribute: 'disable-pan'})
disablePan: boolean = false;
@property({type: Boolean, attribute: 'disable-tap'})
disableTap: boolean = false;
@property({type: Number, attribute: 'interpolation-decay'})
interpolationDecay: number = DECAY_MILLISECONDS;
@property() a11y: A11yTranslationsInterface|string|null = null;
protected[$promptElement] =
this.shadowRoot!.querySelector('.interaction-prompt') as HTMLElement;
protected[$promptAnimatedContainer] =
this.shadowRoot!.querySelector('#prompt') as HTMLElement;
protected[$fingerAnimatedContainers]: HTMLElement[] = [
this.shadowRoot!.querySelector('#finger0')!,
this.shadowRoot!.querySelector('#finger1')!
];
protected[$panElement] =
this.shadowRoot!.querySelector('.pan-target') as HTMLElement;
protected[$lastPromptOffset] = 0;
protected[$promptElementVisibleTime] = Infinity;
protected[$userHasInteracted] = false;
protected[$waitingToPromptUser] = false;
protected[$cancellationSource] = ChangeSource.AUTOMATIC;
protected[$controls] = new SmoothControls(
this[$scene].camera as PerspectiveCamera, this[$userInputElement],
this[$scene]);
protected[$lastSpherical] = new Spherical();
protected[$jumpCamera] = false;
protected[$initialized] = false;
protected[$maintainThetaPhi] = false;
protected[$a11y] = {} as A11yTranslationsInterface;
get inputSensitivity(): number {
return this[$controls].inputSensitivity;
}
set inputSensitivity(value: number) {
this[$controls].inputSensitivity = value;
}
getCameraOrbit(): SphericalPosition {
const {theta, phi, radius} = this[$lastSpherical];
return {
theta,
phi,
radius,
toString() {
return `${this.theta}rad ${this.phi}rad ${this.radius}m`;
}
};
}
getCameraTarget(): Vector3D {
return toVector3D(
this[$renderer].isPresenting ? this[$renderer].arRenderer.target :
this[$scene].getDynamicTarget());
}
getFieldOfView(): number {
return this[$controls].getFieldOfView();
}
// Provided so user code does not have to parse these from attributes.
getMinimumFieldOfView(): number {
return this[$controls].options.minimumFieldOfView!;
}
getMaximumFieldOfView(): number {
return this[$controls].options.maximumFieldOfView!;
}
getIdealAspect(): number {
return this[$scene].idealAspect;
}
jumpCameraToGoal() {
this[$jumpCamera] = true;
this.requestUpdate($jumpCamera, false);
}
resetInteractionPrompt() {
this[$lastPromptOffset] = 0;
this[$promptElementVisibleTime] = Infinity;
this[$userHasInteracted] = false;
this[$waitingToPromptUser] =
this.interactionPrompt === InteractionPromptStrategy.AUTO &&
this.cameraControls;
}
zoom(keyPresses: number) {
const event = new WheelEvent('wheel', {deltaY: -30 * keyPresses});
this[$userInputElement].dispatchEvent(event);
}
connectedCallback() {
super.connectedCallback();
this[$controls].addEventListener(
'user-interaction', this[$cancelPrompts]);
this[$controls].addEventListener(
'pointer-change-start',
this[$onPointerChange] as (event: Event) => void);
this[$controls].addEventListener(
'pointer-change-end',
this[$onPointerChange] as (event: Event) => void);
}
disconnectedCallback() {
super.disconnectedCallback();
this[$controls].removeEventListener(
'user-interaction', this[$cancelPrompts]);
this[$controls].removeEventListener(
'pointer-change-start',
this[$onPointerChange] as (event: Event) => void);
this[$controls].removeEventListener(
'pointer-change-end',
this[$onPointerChange] as (event: Event) => void);
}
updated(changedProperties: Map<string|number|symbol, unknown>) {
super.updated(changedProperties);
const controls = this[$controls];
const scene = this[$scene];
if (changedProperties.has('cameraControls')) {
if (this.cameraControls) {
controls.enableInteraction();
if (this.interactionPrompt === InteractionPromptStrategy.AUTO) {
this[$waitingToPromptUser] = true;
}
} else {
controls.disableInteraction();
this[$deferInteractionPrompt]();
}
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
}
if (changedProperties.has('disableZoom')) {
controls.disableZoom = this.disableZoom;
}
if (changedProperties.has('disablePan')) {
controls.enablePan = !this.disablePan;
}
if (changedProperties.has('disableTap')) {
controls.enableTap = !this.disableTap;
}
if (changedProperties.has('interactionPrompt') ||
changedProperties.has('cameraControls') ||
changedProperties.has('src')) {
if (this.interactionPrompt === InteractionPromptStrategy.AUTO &&
this.cameraControls && !this[$userHasInteracted]) {
this[$waitingToPromptUser] = true;
} else {
this[$deferInteractionPrompt]();
}
}
if (changedProperties.has('interactionPromptStyle')) {
this[$promptAnimatedContainer].style.opacity =
this.interactionPromptStyle == InteractionPromptStyle.BASIC ? '1' :
'0';
}
if (changedProperties.has('touchAction')) {
const touchAction = this.touchAction;
controls.applyOptions({touchAction});
controls.updateTouchActionStyle();
}
if (changedProperties.has('orbitSensitivity')) {
controls.orbitSensitivity = this.orbitSensitivity;
}
if (changedProperties.has('zoomSensitivity')) {
controls.zoomSensitivity = this.zoomSensitivity;
}
if (changedProperties.has('panSensitivity')) {
controls.panSensitivity = this.panSensitivity;
}
if (changedProperties.has('interpolationDecay')) {
controls.setDamperDecayTime(this.interpolationDecay);
scene.setTargetDamperDecayTime(this.interpolationDecay);
}
if (changedProperties.has('a11y')) {
this[$updateA11y]();
}
if (this[$jumpCamera] === true) {
Promise.resolve().then(() => {
controls.jumpToGoal();
scene.jumpToGoal();
this[$onChange]();
this[$jumpCamera] = false;
});
}
}
async updateFraming() {
const scene = this[$scene];
const oldFramedFoV = scene.adjustedFoV(scene.framedFoVDeg);
await scene.updateFraming();
const newFramedFoV = scene.adjustedFoV(scene.framedFoVDeg);
const zoom = this[$controls].getFieldOfView() / oldFramedFoV;
this[$controls].setFieldOfView(newFramedFoV * zoom);
this[$maintainThetaPhi] = true;
this.requestUpdate('maxFieldOfView');
this.requestUpdate('fieldOfView');
this.requestUpdate('minCameraOrbit');
this.requestUpdate('maxCameraOrbit');
this.requestUpdate('cameraOrbit');
await this.updateComplete;
}
interact(duration: number, finger0: Finger, finger1?: Finger) {
const inputElement = this[$userInputElement];
const fingerElements = this[$fingerAnimatedContainers];
if (fingerElements[0].style.opacity === '1') {
console.warn(
'interact() failed because an existing interaction is running.')
return;
}
const xy = new Array<{x: TimingFunction, y: TimingFunction}>();
xy.push({x: timeline(finger0.x), y: timeline(finger0.y)});
const positions = [{x: xy[0].x(0), y: xy[0].y(0)}];
if (finger1 != null) {
xy.push({x: timeline(finger1.x), y: timeline(finger1.y)});
positions.push({x: xy[1].x(0), y: xy[1].y(0)});
}
let startTime = performance.now();
const {width, height} = this[$scene];
const rect = this.getBoundingClientRect();
const dispatchTouches = (type: string) => {
for (const [i, position] of positions.entries()) {
const {style} = fingerElements[i];
style.transform = `translateX(${width * position.x}px) translateY(${
height * position.y}px)`;
if (type === 'pointerdown') {
style.opacity = '1';
} else if (type === 'pointerup') {
style.opacity = '0';
}
const init = {
pointerId: i - 5678, // help ensure uniqueness
pointerType: 'touch',
target: inputElement,
clientX: width * position.x + rect.x,
clientY: height * position.y + rect.y,
altKey: true // flag that this is not a user interaction
} as PointerEventInit;
inputElement.dispatchEvent(new PointerEvent(type, init));
}
};
const moveTouches = () => {
// Cancel interaction if something else moves the camera or input is
// removed from the DOM.
const changeSource = this[$cancellationSource];
if (changeSource !== ChangeSource.AUTOMATIC ||
!inputElement.isConnected) {
for (const fingerElement of this[$fingerAnimatedContainers]) {
fingerElement.style.opacity = '0';
}
dispatchTouches('pointercancel');
this.dispatchEvent(new CustomEvent<CameraChangeDetails>(
'interact-stopped', {detail: {source: changeSource}}));
document.removeEventListener('visibilitychange', onVisibilityChange);
return;
}
const time = Math.min(1, (performance.now() - startTime) / duration);
for (const [i, position] of positions.entries()) {
position.x = xy[i].x(time);
position.y = xy[i].y(time);
}
dispatchTouches('pointermove');
if (time < 1) {
requestAnimationFrame(moveTouches);
} else {
dispatchTouches('pointerup');
this.dispatchEvent(new CustomEvent<CameraChangeDetails>(
'interact-stopped', {detail: {source: ChangeSource.AUTOMATIC}}));
document.removeEventListener('visibilitychange', onVisibilityChange);
}
};
const onVisibilityChange = () => {
let elapsed = 0;
if (document.visibilityState === 'hidden') {
elapsed = performance.now() - startTime;
} else {
startTime = performance.now() - elapsed;
}
};
document.addEventListener('visibilitychange', onVisibilityChange);
dispatchTouches('pointerdown');
this[$cancellationSource] = ChangeSource.AUTOMATIC;
requestAnimationFrame(moveTouches);
}
[$syncFieldOfView](style: EvaluatedStyle<Intrinsics<['rad']>>) {
const controls = this[$controls];
const scene = this[$scene];
scene.framedFoVDeg = style[0] * 180 / Math.PI;
controls.changeSource = ChangeSource.NONE;
controls.setFieldOfView(scene.adjustedFoV(scene.framedFoVDeg));
this[$cancelPrompts]();
}
[$syncCameraOrbit](style: EvaluatedStyle<SphericalIntrinsics>) {
const controls = this[$controls];
if (this[$maintainThetaPhi]) {
const {theta, phi} = this.getCameraOrbit();
style[0] = theta;
style[1] = phi;
this[$maintainThetaPhi] = false;
}
controls.changeSource = ChangeSource.NONE;
controls.setOrbit(style[0], style[1], style[2]);
this[$cancelPrompts]();
}
[$syncMinCameraOrbit](style: EvaluatedStyle<SphericalIntrinsics>) {
this[$controls].applyOptions({
minimumAzimuthalAngle: style[0],
minimumPolarAngle: style[1],
minimumRadius: style[2]
});
this.jumpCameraToGoal();
}
[$syncMaxCameraOrbit](style: EvaluatedStyle<SphericalIntrinsics>) {
this[$controls].applyOptions({
maximumAzimuthalAngle: style[0],
maximumPolarAngle: style[1],
maximumRadius: style[2]
});
this[$updateCameraForRadius](style[2]);
this.jumpCameraToGoal();
}
[$syncMinFieldOfView](style: EvaluatedStyle<Intrinsics<['rad']>>) {
this[$controls].applyOptions(
{minimumFieldOfView: style[0] * 180 / Math.PI});
this.jumpCameraToGoal();
}
[$syncMaxFieldOfView](style: EvaluatedStyle<Intrinsics<['rad']>>) {
const fov = this[$scene].adjustedFoV(style[0] * 180 / Math.PI);
this[$controls].applyOptions({maximumFieldOfView: fov});
this.jumpCameraToGoal();
}
[$syncCameraTarget](style: EvaluatedStyle<Vector3Intrinsics>) {
const [x, y, z] = style;
if (!this[$renderer].arRenderer.isPresenting) {
this[$scene].setTarget(x, y, z);
}
this[$controls].changeSource = ChangeSource.NONE;
this[$renderer].arRenderer.updateTarget();
this[$cancelPrompts]();
}
[$tick](time: number, delta: number) {
super[$tick](time, delta);
if (this[$renderer].isPresenting || !this[$getModelIsVisible]()) {
return;
}
const controls = this[$controls];
const scene = this[$scene];
const now = performance.now();
if (this[$waitingToPromptUser]) {
if (this.loaded &&
now > this[$loadedTime] + this.interactionPromptThreshold) {
this[$waitingToPromptUser] = false;
this[$promptElementVisibleTime] = now;
this[$promptElement].classList.add('visible');
}
}
if (isFinite(this[$promptElementVisibleTime]) &&
this.interactionPromptStyle === InteractionPromptStyle.WIGGLE) {
const animationTime =
((now - this[$promptElementVisibleTime]) / PROMPT_ANIMATION_TIME) %
1;
const offset = wiggle(animationTime);
const opacity = fade(animationTime);
this[$promptAnimatedContainer].style.opacity = `${opacity}`;
if (offset !== this[$lastPromptOffset]) {
const xOffset = offset * scene.width * 0.05;
const deltaTheta = (offset - this[$lastPromptOffset]) * Math.PI / 16;
this[$promptAnimatedContainer].style.transform =
`translateX(${xOffset}px)`;
controls.changeSource = ChangeSource.AUTOMATIC;
controls.adjustOrbit(deltaTheta, 0, 0);
this[$lastPromptOffset] = offset;
}
}
const cameraMoved = controls.update(time, delta);
const targetMoved = scene.updateTarget(delta);
if (cameraMoved || targetMoved) {
this[$onChange]();
}
}
[$deferInteractionPrompt]() {
// Effectively cancel the timer waiting for user interaction:
this[$waitingToPromptUser] = false;
this[$promptElement].classList.remove('visible');
this[$promptElementVisibleTime] = Infinity;
}
/**
* Updates the camera's near and far planes to enclose the scene when
* orbiting at the supplied radius.
*/
[$updateCameraForRadius](radius: number) {
const maximumRadius = Math.max(this[$scene].farRadius(), radius);
const near = 0;
const far = Math.abs(2 * maximumRadius);
this[$controls].updateNearFar(near, far);
}
[$updateAria]() {
const {theta, phi} =
this[$controls]!.getCameraSpherical(this[$lastSpherical]);
const azimuthalQuadrant =
(4 + Math.floor(((theta % TAU) + QUARTER_PI) / HALF_PI)) % 4;
const polarTrient = Math.floor(phi / THIRD_PI);
const azimuthalQuadrantLabel =
AZIMUTHAL_QUADRANT_LABELS[azimuthalQuadrant];
const polarTrientLabel = POLAR_TRIENT_LABELS[polarTrient];
const position = `${polarTrientLabel}${azimuthalQuadrantLabel}`;
const key = position as keyof A11yTranslationsInterface;
if (key in this[$a11y]) {
this[$updateStatus](this[$a11y][key]);
} else {
this[$updateStatus](`View from stage ${position}`);
}
}
get[$ariaLabel]() {
let interactionPrompt = INTERACTION_PROMPT;
if ('interaction-prompt' in this[$a11y]) {
interactionPrompt = `. ${this[$a11y]['interaction-prompt']}`;
}
return super[$ariaLabel].replace(/\.$/, '') +
(this.cameraControls ? interactionPrompt : '');
}
async[$onResize](event: any) {
const controls = this[$controls];
const scene = this[$scene];
const oldFramedFoV = scene.adjustedFoV(scene.framedFoVDeg);
// The super of $onResize may update the scene's adjustedFoV, so we
// compare the before and after to calculate the proper zoom.
super[$onResize](event);
const fovRatio = scene.adjustedFoV(scene.framedFoVDeg) / oldFramedFoV;
const fov =
controls.getFieldOfView() * (isFinite(fovRatio) ? fovRatio : 1);
controls.updateAspect(this[$scene].aspect);
this.requestUpdate('maxFieldOfView', this.maxFieldOfView);
await this.updateComplete;
this[$controls].setFieldOfView(fov);
this.jumpCameraToGoal();
}
[$onModelLoad]() {
super[$onModelLoad]();
if (this[$initialized]) {
this[$maintainThetaPhi] = true;
} else {
this[$initialized] = true;
}
this.requestUpdate('maxFieldOfView', this.maxFieldOfView);
this.requestUpdate('fieldOfView', this.fieldOfView);
this.requestUpdate('minCameraOrbit', this.minCameraOrbit);
this.requestUpdate('maxCameraOrbit', this.maxCameraOrbit);
this.requestUpdate('cameraOrbit', this.cameraOrbit);
this.requestUpdate('cameraTarget', this.cameraTarget);
this.jumpCameraToGoal();
}
[$cancelPrompts] = () => {
const source = this[$controls].changeSource;
this[$cancellationSource] = source;
if (source === ChangeSource.USER_INTERACTION) {
this[$userHasInteracted] = true;
this[$deferInteractionPrompt]();
}
};
[$onChange] = () => {
this[$updateAria]();
this[$needsRender]();
const source = this[$controls].changeSource;
this.dispatchEvent(new CustomEvent<CameraChangeDetails>(
'camera-change', {detail: {source}}));
};
[$onPointerChange] = (event: PointerChangeEvent) => {
this[$container].classList.toggle(
'pointer-tumbling', event.type === 'pointer-change-start');
};
[$updateA11y]() {
if (typeof this.a11y === 'string') {
if (this.a11y.startsWith('{')) {
try {
this[$a11y] = JSON.parse(this.a11y);
} catch (error) {
console.warn('Error parsing a11y JSON:', error);
}
} else if (this.a11y.length > 0) {
console.warn(
'Error not supported format, should be a JSON string:',
this.a11y);
} else {
this[$a11y] = <A11yTranslationsInterface>{};
}
} else if (typeof this.a11y === 'object' && this.a11y != null) {
this[$a11y] = Object.assign({}, this.a11y);
} else {
this[$a11y] = <A11yTranslationsInterface>{};
}
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
}
}
return ControlsModelViewerElement;
};
@@ -0,0 +1,173 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {property} from 'lit/decorators.js';
import {ACESFilmicToneMapping, AgXToneMapping, CineonToneMapping, LinearToneMapping, NeutralToneMapping, NoToneMapping, ReinhardToneMapping, Texture} from 'three';
import ModelViewerElementBase, {$needsRender, $progressTracker, $renderer, $scene, $shouldAttemptPreload} from '../model-viewer-base.js';
import {clamp, Constructor, deserializeUrl} from '../utilities.js';
export const BASE_OPACITY = 0.5;
const DEFAULT_SHADOW_INTENSITY = 0.0;
const DEFAULT_SHADOW_SOFTNESS = 1.0;
const DEFAULT_EXPOSURE = 1.0;
export type ToneMappingValue = 'auto'|'aces'|'agx'|'commerce'|'neutral'|
'reinhard'|'cineon'|'linear'|'none';
export const $currentEnvironmentMap = Symbol('currentEnvironmentMap');
export const $currentBackground = Symbol('currentBackground');
export const $updateEnvironment = Symbol('updateEnvironment');
const $cancelEnvironmentUpdate = Symbol('cancelEnvironmentUpdate');
export declare interface EnvironmentInterface {
environmentImage: string|null;
skyboxImage: string|null;
skyboxHeight: string;
shadowIntensity: number;
shadowSoftness: number;
exposure: number;
hasBakedShadow(): boolean;
}
export const EnvironmentMixin = <T extends Constructor<ModelViewerElementBase>>(
ModelViewerElement: T): Constructor<EnvironmentInterface>&T => {
class EnvironmentModelViewerElement extends ModelViewerElement {
@property({type: String, attribute: 'environment-image'})
environmentImage: string|null = null;
@property({type: String, attribute: 'skybox-image'})
skyboxImage: string|null = null;
@property({type: Number, attribute: 'shadow-intensity'})
shadowIntensity: number = DEFAULT_SHADOW_INTENSITY;
@property({type: Number, attribute: 'shadow-softness'})
shadowSoftness: number = DEFAULT_SHADOW_SOFTNESS;
@property({type: Number}) exposure: number = DEFAULT_EXPOSURE;
@property({type: String, attribute: 'tone-mapping'})
toneMapping: ToneMappingValue = 'auto';
@property({type: String, attribute: 'skybox-height'})
skyboxHeight: string = '0';
protected[$currentEnvironmentMap]: Texture|null = null;
protected[$currentBackground]: Texture|null = null;
private[$cancelEnvironmentUpdate]: ((...args: any[]) => any)|null = null;
updated(changedProperties: Map<string|number|symbol, unknown>) {
super.updated(changedProperties);
if (changedProperties.has('shadowIntensity')) {
this[$scene].setShadowIntensity(this.shadowIntensity * BASE_OPACITY);
this[$needsRender]();
}
if (changedProperties.has('shadowSoftness')) {
this[$scene].setShadowSoftness(this.shadowSoftness);
this[$needsRender]();
}
if (changedProperties.has('exposure')) {
this[$scene].exposure = this.exposure;
this[$needsRender]();
}
if (changedProperties.has('toneMapping')) {
const TONE_MAPPING = new Map([
['aces', ACESFilmicToneMapping],
['agx', AgXToneMapping],
['reinhard', ReinhardToneMapping],
['cineon', CineonToneMapping],
['linear', LinearToneMapping],
['none', NoToneMapping]
]);
this[$scene].toneMapping = TONE_MAPPING.get(this.toneMapping) ?? NeutralToneMapping;
this[$needsRender]();
}
if ((changedProperties.has('environmentImage') ||
changedProperties.has('skyboxImage')) &&
this[$shouldAttemptPreload]()) {
this[$updateEnvironment]();
}
if (changedProperties.has('skyboxHeight')) {
this[$scene].setGroundedSkybox();
this[$needsRender]();
}
}
hasBakedShadow(): boolean {
return this[$scene].bakedShadows.size > 0;
}
async[$updateEnvironment]() {
const {skyboxImage, environmentImage} = this;
if (this[$cancelEnvironmentUpdate] != null) {
this[$cancelEnvironmentUpdate]!();
this[$cancelEnvironmentUpdate] = null;
}
const {textureUtils} = this[$renderer];
if (textureUtils == null) {
return;
}
const updateEnvProgress =
this[$progressTracker].beginActivity('environment-update');
try {
const {environmentMap, skybox} =
await textureUtils.generateEnvironmentMapAndSkybox(
deserializeUrl(skyboxImage),
environmentImage,
(progress: number) => updateEnvProgress(clamp(progress, 0, 1)),
this.withCredentials);
if (this[$currentEnvironmentMap] !== environmentMap) {
this[$currentEnvironmentMap] = environmentMap;
this.dispatchEvent(new CustomEvent('environment-change'));
}
if (skybox != null) {
// When using the same environment and skybox, use the environment as
// it gives HDR filtering.
this[$currentBackground] =
skybox.name === environmentMap.name ? environmentMap : skybox;
} else {
this[$currentBackground] = null;
}
this[$scene].setEnvironmentAndSkybox(
this[$currentEnvironmentMap], this[$currentBackground]);
} catch (errorOrPromise) {
if (errorOrPromise instanceof Error) {
this[$scene].setEnvironmentAndSkybox(null, null);
throw errorOrPromise;
}
} finally {
updateEnvProgress(1.0);
}
}
}
return EnvironmentModelViewerElement;
};
@@ -0,0 +1,422 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {property} from 'lit/decorators.js';
import {Vector3} from 'three';
import ModelViewerElementBase, {$altDefaulted, $announceModelVisibility, $getModelIsVisible, $isElementInViewport, $progressTracker, $scene, $shouldAttemptPreload, $updateSource, $userInputElement, toVector3D, Vector3D} from '../model-viewer-base.js';
import {$loader, CachingGLTFLoader} from '../three-components/CachingGLTFLoader.js';
import {Renderer} from '../three-components/Renderer.js';
import {Constructor, throttle} from '../utilities.js';
export type RevealAttributeValue = 'auto'|'manual';
export type LoadingAttributeValue = 'auto'|'lazy'|'eager';
export const PROGRESS_BAR_UPDATE_THRESHOLD = 100;
const DEFAULT_DRACO_DECODER_LOCATION =
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
const DEFAULT_KTX2_TRANSCODER_LOCATION =
'https://www.gstatic.com/basis-universal/versioned/2021-04-15-ba1c3e4/';
const DEFAULT_LOTTIE_LOADER_LOCATION =
'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/loaders/LottieLoader.js';
const RevealStrategy: {[index: string]: RevealAttributeValue} = {
AUTO: 'auto',
MANUAL: 'manual'
};
const LoadingStrategy: {[index: string]: LoadingAttributeValue} = {
AUTO: 'auto',
LAZY: 'lazy',
EAGER: 'eager'
};
export const $defaultProgressBarElement = Symbol('defaultProgressBarElement');
export const $posterContainerElement = Symbol('posterContainerElement');
export const $defaultPosterElement = Symbol('defaultPosterElement');
const $shouldDismissPoster = Symbol('shouldDismissPoster');
const $hidePoster = Symbol('hidePoster');
const $modelIsRevealed = Symbol('modelIsRevealed');
const $updateProgressBar = Symbol('updateProgressBar');
const $ariaLabelCallToAction = Symbol('ariaLabelCallToAction');
const $onProgress = Symbol('onProgress');
export declare interface LoadingInterface {
poster: string|null;
reveal: RevealAttributeValue;
loading: LoadingAttributeValue;
readonly loaded: boolean;
readonly modelIsVisible: boolean;
dismissPoster(): void;
showPoster(): void;
getDimensions(): Vector3D;
getBoundingBoxCenter(): Vector3D;
}
export declare interface LoadingStaticInterface {
dracoDecoderLocation: string;
ktx2TranscoderLocation: string;
meshoptDecoderLocation: string;
lottieLoaderLocation: string;
mapURLs(callback: (url: string) => string): void;
}
export interface ModelViewerGlobalConfig {
dracoDecoderLocation?: string;
ktx2TranscoderLocation?: string;
meshoptDecoderLocation?: string;
lottieLoaderLocation?: string;
powerPreference?: string;
}
/**
* LoadingMixin implements features related to lazy loading, as well as
* presentation details related to the pre-load / pre-render presentation of a
* <model-viewer>
*
* This mixin implements support for models with DRACO-compressed meshes.
* The DRACO decoder will be loaded on-demand if a glTF that uses the DRACO mesh
* compression extension is encountered.
*
* By default, the DRACO decoder will be loaded from a Google CDN. It is
* possible to customize where the decoder is loaded from by defining a global
* configuration option for `<model-viewer>` like so:
*
* ```html
* <script>
* self.ModelViewerElement = self.ModelViewerElement || {};
* self.ModelViewerElement.dracoDecoderLocation =
* 'http://example.com/location/of/draco/decoder/files/';
* </script>
* ```
*
* Note that the above configuration strategy must be performed *before* the
* first `<model-viewer>` element is created in the browser. The configuration
* can be done anywhere, but the easiest way to ensure it is done at the right
* time is to do it in the `<head>` of the HTML document. This is the
* recommended way to set the location because it is most compatible with
* scenarios where the `<model-viewer>` library is lazily loaded.
*
* If you absolutely have to set the DRACO decoder location *after* the first
* `<model-viewer>` element is created, you can do it this way:
*
* ```html
* <script>
* const ModelViewerElement = customElements.get('model-viewer');
* ModelViewerElement.dracoDecoderLocation =
* 'http://example.com/location/of/draco/decoder/files/';
* </script>
* ```
*
* Note that the above configuration approach will not work until *after*
* `<model-viewer>` is defined in the browser. Also note that this configuration
* *must* be set *before* the first DRACO model is fully loaded.
*
* It is recommended that users who intend to take advantage of DRACO mesh
* compression consider whether or not it is acceptable for their use case to
* have code side-loaded from a Google CDN. If it is not acceptable, then the
* location must be customized before loading any DRACO models in order to cause
* the decoder to be loaded from an alternative, acceptable location.
*/
export const LoadingMixin = <T extends Constructor<ModelViewerElementBase>>(
ModelViewerElement:
T): Constructor<LoadingInterface, LoadingStaticInterface>&T => {
class LoadingModelViewerElement extends ModelViewerElement {
static set dracoDecoderLocation(value: string) {
CachingGLTFLoader.setDRACODecoderLocation(value);
}
static get dracoDecoderLocation() {
return CachingGLTFLoader.getDRACODecoderLocation();
}
static set ktx2TranscoderLocation(value: string) {
CachingGLTFLoader.setKTX2TranscoderLocation(value);
}
static get ktx2TranscoderLocation() {
return CachingGLTFLoader.getKTX2TranscoderLocation();
}
static set meshoptDecoderLocation(value: string) {
CachingGLTFLoader.setMeshoptDecoderLocation(value);
}
static get meshoptDecoderLocation() {
return CachingGLTFLoader.getMeshoptDecoderLocation();
}
static set lottieLoaderLocation(value: string) {
Renderer.singleton.textureUtils!.lottieLoaderUrl = value;
}
static get lottieLoaderLocation() {
return Renderer.singleton.textureUtils!.lottieLoaderUrl
}
/**
* If provided, the callback will be passed each resource URL before a
* request is sent. The callback may return the original URL, or a new URL
* to override loading behavior. This behavior can be used to load assets
* from .ZIP files, drag-and-drop APIs, and Data URIs.
*/
static mapURLs(callback: (url: string) => string) {
Renderer.singleton.loader[$loader].manager.setURLModifier(callback);
}
/**
* A URL pointing to the image to use as a poster in scenarios where the
* <model-viewer> is not ready to reveal a rendered model to the viewer.
*/
@property({type: String}) poster: string|null = null;
/**
* An enumerable attribute describing under what conditions the
* <model-viewer> should reveal a model to the viewer.
*
* The default value is "auto". The only supported alternative values is
* "manual".
*/
@property({type: String})
reveal: RevealAttributeValue = RevealStrategy.AUTO;
/**
* An enumerable attribute describing under what conditions the
* <model-viewer> should preload a model.
*
* The default value is "auto". The only supported alternative values are
* "lazy" and "eager". Auto is equivalent to lazy, which loads the model
* when it is near the viewport for reveal = "auto", and when interacted
* with for reveal = "interaction". Eager loads the model immediately.
*/
@property({type: String})
loading: LoadingAttributeValue = LoadingStrategy.AUTO;
/**
* Dismisses the poster, causing the model to load and render if
* necessary. This is currently effectively the same as interacting with
* the poster via user input.
*/
dismissPoster() {
if (this.loaded) {
this[$hidePoster]();
} else {
this[$shouldDismissPoster] = true;
this[$updateSource]();
}
}
/**
* Displays the poster, hiding the 3D model. If this is called after the 3D
* model has been revealed, then it must be dismissed by a call to
* dismissPoster().
*/
showPoster() {
const posterContainerElement = this[$posterContainerElement];
if (posterContainerElement.classList.contains('show')) {
return;
}
posterContainerElement.classList.add('show');
this[$userInputElement].classList.remove('show');
const defaultPosterElement = this[$defaultPosterElement];
defaultPosterElement.removeAttribute('tabindex');
defaultPosterElement.removeAttribute('aria-hidden');
const oldVisibility = this.modelIsVisible;
this[$modelIsRevealed] = false;
this[$announceModelVisibility](oldVisibility);
}
/**
* Returns the model's bounding box dimensions in meters, independent of
* turntable rotation.
*/
getDimensions(): Vector3D {
return toVector3D(this[$scene].size);
}
getBoundingBoxCenter(): Vector3D {
return toVector3D(this[$scene].boundingBox.getCenter(new Vector3()));
}
protected[$modelIsRevealed] = false;
protected[$shouldDismissPoster] = false;
// TODO: Add this to the shadow root as part of this mixin's
// implementation:
protected[$posterContainerElement]: HTMLElement =
this.shadowRoot!.querySelector('.slot.poster') as HTMLElement;
protected[$defaultPosterElement]: HTMLElement =
this.shadowRoot!.querySelector('#default-poster') as HTMLElement;
protected[$defaultProgressBarElement]: HTMLElement =
this.shadowRoot!.querySelector('#default-progress-bar > .bar') as
HTMLElement;
protected[$ariaLabelCallToAction] =
this[$defaultPosterElement].getAttribute('aria-label');
protected[$updateProgressBar] = throttle((progress: number) => {
const parentNode = this[$defaultProgressBarElement].parentNode as Element;
requestAnimationFrame(() => {
this[$defaultProgressBarElement].style.transform =
`scaleX(${progress})`;
if (progress === 0) {
// NOTE(cdata): We remove and re-append the progress bar in this
// condition so that the progress bar does not appear to
// transition backwards from the right when we reset to 0 (or
// otherwise <1) progress after having already reached 1 progress
// previously.
parentNode.removeChild(this[$defaultProgressBarElement]);
parentNode.appendChild(this[$defaultProgressBarElement]);
}
this[$defaultProgressBarElement].classList.toggle('hide', progress === 1.0);
});
}, PROGRESS_BAR_UPDATE_THRESHOLD);
constructor(...args: Array<any>) {
super(...args);
const ModelViewerElement: ModelViewerGlobalConfig =
(self as any).ModelViewerElement || {};
const dracoDecoderLocation = ModelViewerElement.dracoDecoderLocation ||
DEFAULT_DRACO_DECODER_LOCATION;
CachingGLTFLoader.setDRACODecoderLocation(dracoDecoderLocation);
const ktx2TranscoderLocation =
ModelViewerElement.ktx2TranscoderLocation ||
DEFAULT_KTX2_TRANSCODER_LOCATION;
CachingGLTFLoader.setKTX2TranscoderLocation(ktx2TranscoderLocation);
if (ModelViewerElement.meshoptDecoderLocation) {
CachingGLTFLoader.setMeshoptDecoderLocation(
ModelViewerElement.meshoptDecoderLocation);
}
const lottieLoaderLocation = ModelViewerElement.lottieLoaderLocation ||
DEFAULT_LOTTIE_LOADER_LOCATION;
Renderer.singleton.textureUtils!.lottieLoaderUrl = lottieLoaderLocation;
}
connectedCallback() {
super.connectedCallback();
if (!this.loaded) {
this.showPoster();
}
this[$progressTracker].addEventListener('progress', this[$onProgress]);
}
disconnectedCallback() {
super.disconnectedCallback();
this[$progressTracker].removeEventListener('progress', this[$onProgress]);
}
async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('poster') && this.poster != null) {
this[$defaultPosterElement].style.backgroundImage =
`url(${this.poster})`;
}
if (changedProperties.has('alt')) {
this[$defaultPosterElement].setAttribute(
'aria-label', this[$altDefaulted]);
}
if (changedProperties.has('reveal') || changedProperties.has('loading')) {
this[$updateSource]();
}
}
[$onProgress] = (event: Event) => {
const progress = (event as any).detail.totalProgress;
const reason = (event as any).detail.reason;
if (progress === 1.0) {
this[$updateProgressBar].flush();
if (this.loaded &&
(this[$shouldDismissPoster] ||
this.reveal === RevealStrategy.AUTO)) {
this[$hidePoster]();
}
}
this[$updateProgressBar](progress);
this.dispatchEvent(
new CustomEvent('progress', {detail: {totalProgress: progress, reason}}));
};
[$shouldAttemptPreload](): boolean {
return !!this.src &&
(this[$shouldDismissPoster] ||
this.loading === LoadingStrategy.EAGER ||
(this.reveal === RevealStrategy.AUTO && this[$isElementInViewport]));
}
[$hidePoster]() {
this[$shouldDismissPoster] = false;
const posterContainerElement = this[$posterContainerElement];
if (!posterContainerElement.classList.contains('show')) {
return;
}
posterContainerElement.classList.remove('show');
this[$userInputElement].classList.add('show');
const oldVisibility = this.modelIsVisible;
this[$modelIsRevealed] = true;
this[$announceModelVisibility](oldVisibility);
const root = this.getRootNode();
// If the <model-viewer> is still focused, forward the focus to
// the canvas that has just been revealed
if (root && (root as Document | ShadowRoot).activeElement === this) {
this[$userInputElement].focus();
}
// Ensure that the poster is no longer focusable or visible to
// screen readers
const defaultPosterElement = this[$defaultPosterElement];
defaultPosterElement.setAttribute('aria-hidden', 'true');
defaultPosterElement.tabIndex = -1;
this.dispatchEvent(new CustomEvent('poster-dismissed'));
}
[$getModelIsVisible]() {
return super[$getModelIsVisible]() && this[$modelIsRevealed];
}
}
return LoadingModelViewerElement;
};
@@ -0,0 +1,300 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {property} from 'lit/decorators.js';
import {CanvasTexture, RepeatWrapping, SRGBColorSpace, Texture, VideoTexture} from 'three';
import {GLTFExporter, GLTFExporterOptions} from 'three/examples/jsm/exporters/GLTFExporter.js';
import ModelViewerElementBase, {$needsRender, $onModelLoad, $progressTracker, $renderer, $scene} from '../model-viewer-base.js';
import {GLTF} from '../three-components/gltf-instance/gltf-defaulted.js';
import {ModelViewerGLTFInstance} from '../three-components/gltf-instance/ModelViewerGLTFInstance.js';
import GLTFExporterMaterialsVariantsExtension from '../three-components/gltf-instance/VariantMaterialExporterPlugin.js';
import {Constructor} from '../utilities.js';
import {Image, PBRMetallicRoughness, Sampler, TextureInfo} from './scene-graph/api.js';
import {Material} from './scene-graph/material.js';
import {$availableVariants, $materialFromPoint, $prepareVariantsForExport, $switchVariant, Model} from './scene-graph/model.js';
import {Texture as ModelViewerTexture} from './scene-graph/texture.js';
export const $currentGLTF = Symbol('currentGLTF');
export const $originalGltfJson = Symbol('originalGltfJson');
export const $model = Symbol('model');
const $getOnUpdateMethod = Symbol('getOnUpdateMethod');
const $buildTexture = Symbol('buildTexture');
interface SceneExportOptions {
binary?: boolean, trs?: boolean, onlyVisible?: boolean,
maxTextureSize?: number, includeCustomExtensions?: boolean,
forceIndices?: boolean
}
export interface SceneGraphInterface {
readonly model?: Model;
variantName: string|null;
readonly availableVariants: string[];
orientation: string;
scale: string;
readonly originalGltfJson: GLTF|null;
exportScene(options?: SceneExportOptions): Promise<Blob>;
createTexture(uri: string, type?: string): Promise<ModelViewerTexture|null>;
createLottieTexture(uri: string, quality?: number):
Promise<ModelViewerTexture|null>;
createVideoTexture(uri: string): ModelViewerTexture;
createCanvasTexture(): ModelViewerTexture;
/**
* Intersects a ray with the scene and returns a list of materials who's
* objects were intersected.
* @param pixelX X coordinate of the mouse.
* @param pixelY Y coordinate of the mouse.
* @returns a material, if no intersection is made then null is returned.
*/
materialFromPoint(pixelX: number, pixelY: number): Material|null;
}
/**
* SceneGraphMixin manages exposes a model API in order to support operations on
* the <model-viewer> scene graph.
*/
export const SceneGraphMixin = <T extends Constructor<ModelViewerElementBase>>(
ModelViewerElement: T): Constructor<SceneGraphInterface>&T => {
class SceneGraphModelViewerElement extends ModelViewerElement {
protected[$model]: Model|undefined = undefined;
protected[$currentGLTF]: ModelViewerGLTFInstance|null = null;
private[$originalGltfJson]: GLTF|null = null;
@property({type: String, attribute: 'variant-name'})
variantName: string|null = null;
@property({type: String, attribute: 'orientation'})
orientation: string = '0 0 0';
@property({type: String, attribute: 'scale'}) scale: string = '1 1 1';
// Scene-graph API:
/** @export */
get model() {
return this[$model];
}
get availableVariants() {
return this.model ? this.model[$availableVariants]() : [] as string[];
}
/**
* Returns a deep copy of the gltf JSON as loaded. It will not reflect
* changes to the scene-graph, nor will editing it have any effect.
*/
get originalGltfJson() {
return this[$originalGltfJson];
}
/**
* References to each element constructor. Supports instanceof checks; these
* classes are not directly constructable.
*/
static Model: Constructor<Model>;
static Material: Constructor<Material>;
static PBRMetallicRoughness: Constructor<PBRMetallicRoughness>;
static Sampler: Constructor<Sampler>;
static TextureInfo: Constructor<TextureInfo>;
static Texture: Constructor<Texture>;
static Image: Constructor<Image>;
private[$getOnUpdateMethod]() {
return () => {
this[$needsRender]();
};
}
private[$buildTexture](texture: Texture): ModelViewerTexture {
// Applies glTF default settings.
texture.colorSpace = SRGBColorSpace;
texture.wrapS = RepeatWrapping;
texture.wrapT = RepeatWrapping;
return new ModelViewerTexture(this[$getOnUpdateMethod](), texture);
}
async createTexture(uri: string, type: string = 'image/png'):
Promise<ModelViewerTexture> {
const {textureUtils} = this[$renderer];
const texture = await textureUtils!.loadImage(uri, this.withCredentials);
texture.userData.mimeType = type;
return this[$buildTexture](texture);
}
async createLottieTexture(uri: string, quality = 1):
Promise<ModelViewerTexture> {
const {textureUtils} = this[$renderer];
const texture =
await textureUtils!.loadLottie(uri, quality, this.withCredentials);
return this[$buildTexture](texture);
}
createVideoTexture(uri: string): ModelViewerTexture {
const video = document.createElement('video');
video.crossOrigin =
this.withCredentials ? 'use-credentials' : 'anonymous';
video.src = uri;
video.muted = true;
video.playsInline = true;
video.loop = true;
video.play();
const texture = new VideoTexture(video);
return this[$buildTexture](texture);
}
createCanvasTexture(): ModelViewerTexture {
const canvas = document.createElement('canvas');
const texture = new CanvasTexture(canvas);
return this[$buildTexture](texture);
}
async updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('variantName')) {
const updateVariantProgress =
this[$progressTracker].beginActivity('variant-update');
updateVariantProgress(0.1);
const model = this[$model];
const {variantName} = this;
if (model != null) {
await model[$switchVariant](variantName!);
this[$needsRender]();
this.dispatchEvent(new CustomEvent('variant-applied'));
}
updateVariantProgress(1.0);
}
if (changedProperties.has('orientation') ||
changedProperties.has('scale')) {
if (!this.loaded) {
return;
}
const scene = this[$scene];
scene.applyTransform();
scene.updateBoundingBox();
scene.updateShadow();
this[$renderer].arRenderer.onUpdateScene();
this[$needsRender]();
}
}
[$onModelLoad]() {
super[$onModelLoad]();
const {currentGLTF} = this[$scene];
if (currentGLTF != null) {
const {correlatedSceneGraph} = currentGLTF;
if (correlatedSceneGraph != null &&
currentGLTF !== this[$currentGLTF]) {
this[$model] =
new Model(correlatedSceneGraph, this[$getOnUpdateMethod]());
this[$originalGltfJson] =
JSON.parse(JSON.stringify(correlatedSceneGraph.gltf));
}
// KHR_materials_variants extension spec:
// https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_materials_variants
if ('variants' in currentGLTF.userData) {
this.requestUpdate('variantName');
}
}
this[$currentGLTF] = currentGLTF;
}
/** @export */
async exportScene(options?: SceneExportOptions): Promise<Blob> {
const scene = this[$scene];
return new Promise<Blob>(async (resolve, reject) => {
// Defaults
const opts = {
binary: true,
onlyVisible: true,
maxTextureSize: Infinity,
includeCustomExtensions: false,
forceIndices: false
} as GLTFExporterOptions;
Object.assign(opts, options);
// Not configurable
opts.animations = scene.animations;
opts.truncateDrawRange = true;
const shadow = scene.shadow;
let visible = false;
// Remove shadow from export
if (shadow != null) {
visible = shadow.visible;
shadow.visible = false;
}
await this[$model]![$prepareVariantsForExport]();
const exporter =
(new GLTFExporter() as any)
.register(
(writer: any) =>
new GLTFExporterMaterialsVariantsExtension(writer));
exporter.parse(
scene.model,
(gltf: object) => {
return resolve(new Blob(
[opts.binary ? gltf as Blob : JSON.stringify(gltf)], {
type: opts.binary ? 'application/octet-stream' :
'application/json'
}));
},
() => {
return reject('glTF export failed');
},
opts);
if (shadow != null) {
shadow.visible = visible;
}
});
}
materialFromPoint(pixelX: number, pixelY: number): Material|null {
const model = this[$model];
if (model == null) {
return null;
}
const scene = this[$scene];
const ndcCoords = scene.getNDC(pixelX, pixelY);
const hit = scene.hitFromPoint(ndcCoords);
if (hit == null || hit.face == null) {
return null;
}
return model[$materialFromPoint](hit);
}
}
return SceneGraphModelViewerElement;
};
@@ -0,0 +1,443 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {AlphaMode, MagFilter, MinFilter, WrapMode} from '../../three-components/gltf-instance/gltf-2.0.js';
/**
* All constructs in a 3DOM scene graph have a corresponding string name.
* This is similar in spirit to the concept of a "tag name" in HTML, and exists
* in support of looking up 3DOM elements by type.
*/
export declare interface ThreeDOMElementMap {
'model': Model;
'material': Material;
'pbr-metallic-roughness': PBRMetallicRoughness;
'sampler': Sampler;
'image': Image;
'texture': Texture;
'texture-info': TextureInfo;
}
/** A 2D Cartesian coordinate */
export interface Vector2DInterface {
u: number;
v: number;
}
/**
* A Model is the root element of a 3DOM scene graph. It gives scripts access
* to the sub-elements found without the graph.
*/
export declare interface Model {
/**
* An ordered set of unique Materials found in this model. The Materials
* correspond to the listing of materials in the glTF, with the possible
* addition of a default material at the end.
*/
readonly materials: Readonly<Material[]>;
/**
* Gets a material(s) by name.
* @param name the name of the material to return.
* @returns the first material to whose name matches `name`
*/
getMaterialByName(name: string): Material|null;
/**
* Creates a new material variant from an existing material.
* @param originalMaterialIndex index of the material to clone the variant
* from.
* @param materialName the name of the new material
* @param variantName the name of the variant
* @param activateVariant activates this material variant, i.e. the variant
* material is rendered, not the existing material.
* @returns returns a clone of the original material, returns `null` if the
* material instance for this variant already exists.
*/
createMaterialInstanceForVariant(
originalMaterialIndex: number, newMaterialName: string,
variantName: string, activateVariant: boolean): Material|null;
/**
* Adds a variant name to the model.
* @param variantName
*/
createVariant(variantName: string): void;
/**
* Adds an existing material to a variant name.
* @param materialIndex
* @param targetVariantName
*/
setMaterialToVariant(materialIndex: number, targetVariantName: string): void;
/**
* Removes the variant name from the model.
* @param variantName the variant to remove.
*/
deleteVariant(variantName: string): void;
}
/**
* A Material gives the script access to modify a single, unique material found
* in a model's scene graph.
*
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-material
*/
export declare interface Material {
/**
* The name of the material, if any.
*/
name: string;
readonly normalTexture: TextureInfo|null;
readonly occlusionTexture: TextureInfo|null;
readonly emissiveTexture: TextureInfo|null;
readonly emissiveFactor: Readonly<RGB>;
setEmissiveFactor(rgb: RGB|string): void;
setAlphaCutoff(cutoff: number): void;
getAlphaCutoff(): number;
setDoubleSided(doubleSided: boolean): void;
getDoubleSided(): boolean;
setAlphaMode(alphaMode: AlphaMode): void;
getAlphaMode(): AlphaMode;
/**
* PBR Next properties.
*/
readonly emissiveStrength: number;
readonly clearcoatFactor: number;
readonly clearcoatRoughnessFactor: number;
readonly clearcoatTexture: TextureInfo;
readonly clearcoatRoughnessTexture: TextureInfo;
readonly clearcoatNormalTexture: TextureInfo;
readonly clearcoatNormalScale: number;
readonly ior: number;
readonly sheenColorFactor: Readonly<RGB>;
readonly sheenColorTexture: TextureInfo;
readonly sheenRoughnessFactor: number;
readonly sheenRoughnessTexture: TextureInfo;
readonly transmissionFactor: number;
readonly transmissionTexture: TextureInfo;
readonly thicknessFactor: number;
readonly thicknessTexture: TextureInfo;
readonly attenuationDistance: number;
readonly attenuationColor: Readonly<RGB>;
readonly specularFactor: number;
readonly specularTexture: TextureInfo;
readonly specularColorFactor: Readonly<RGB>;
readonly specularColorTexture: TextureInfo;
readonly iridescenceFactor: number;
readonly iridescenceTexture: TextureInfo;
readonly iridescenceIor: number;
readonly iridescenceThicknessMinimum: number;
readonly iridescenceThicknessMaximum: number;
readonly iridescenceThicknessTexture: TextureInfo;
readonly anisotropyStrength: number;
readonly anisotropyRotation: number;
readonly anisotropyTexture: TextureInfo;
setEmissiveStrength(emissiveStrength: number): void;
setClearcoatFactor(clearcoatFactor: number): void;
setClearcoatRoughnessFactor(clearcoatRoughnessFactor: number): void;
setClearcoatNormalScale(clearcoatNormalScale: number): void;
setIor(ior: number): void;
setSheenColorFactor(rgb: RGB|string): void;
setSheenRoughnessFactor(roughness: number): void;
setTransmissionFactor(transmission: number): void;
setThicknessFactor(thickness: number): void;
setAttenuationDistance(attenuationDistance: number): void;
setAttenuationColor(rgb: RGB|string): void;
setSpecularFactor(specularFactor: number): void;
setSpecularColorFactor(rgb: RGB|string): void;
setIridescenceFactor(iridescence: number): void;
setIridescenceIor(ior: number): void;
setIridescenceThicknessMinimum(thicknessMin: number): void;
setIridescenceThicknessMaximum(thicknessMax: number): void;
setAnisotropyStrength(strength: number): void;
setAnisotropyRotation(rotation: number): void;
/**
* The PBRMetallicRoughness configuration of the material.
*/
readonly pbrMetallicRoughness: PBRMetallicRoughness;
/**
* Asynchronously loads the underlying material resource if it's currently
* unloaded, otherwise the method is a noop.
*/
ensureLoaded(): void;
/**
* Returns true if the material participates in the variant.
* @param name the variant name.
*/
hasVariant(name: string): boolean;
/**
* Returns true if the material is loaded.
*/
readonly isLoaded: boolean;
/**
* Returns true if the material is participating in scene renders.
*/
readonly isActive: boolean;
/**
* Returns the glTF index of this material.
*/
readonly index: number;
}
/**
* The PBRMetallicRoughness encodes the PBR properties of a material
*
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-pbrmetallicroughness
*/
export declare interface PBRMetallicRoughness {
/**
* The base color factor of the material, represented as RGBA values
*/
readonly baseColorFactor: Readonly<RGBA>;
/**
* Metalness factor of the material, represented as number between 0 and 1
*/
readonly metallicFactor: number;
/**
* Roughness factor of the material, represented as number between 0 and 1
*/
readonly roughnessFactor: number;
/**
* A texture reference, associating an image with color information and
* a sampler for describing base color factor for a UV coordinate space.
*/
readonly baseColorTexture: TextureInfo|null;
/**
* A texture reference, associating an image with color information and
* a sampler for describing metalness (B channel) and roughness (G channel)
* for a UV coordinate space.
*/
readonly metallicRoughnessTexture: TextureInfo|null;
/**
* Changes the base color factor of the material to the given value.
*/
setBaseColorFactor(rgba: RGBA|string): void;
/**
* Changes the metalness factor of the material to the given value.
*/
setMetallicFactor(value: number): void;
/**
* Changes the roughness factor of the material to the given value.
*/
setRoughnessFactor(value: number): void;
}
/**
* A TextureInfo is a pointer to a specific Texture in use on a Material
*
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-textureinfo
*/
export declare interface TextureInfo {
/**
* The Texture being referenced by this TextureInfo.
*/
readonly texture: Texture|null;
/**
* Sets the texture, or removes it if argument is null. Note you cannot build
* your own Texture object, but must either use one from another TextureInfo,
* or create one with the createTexture method.
*/
setTexture(texture: Texture|null): void;
}
/**
* A Texture pairs an Image and a Sampler for use in a Material
*
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-texture
*/
export declare interface Texture {
/**
* The name of the texture, if any.
*/
readonly name: string;
/**
* The Sampler for this Texture
*/
readonly sampler: Sampler;
/**
* The source Image for this Texture
*/
readonly source: Image;
}
/**
* A Sampler describes how to filter and wrap textures
*
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-sampler
*/
export declare interface Sampler {
/**
* The name of the sampler, if any.
*/
readonly name: string;
/**
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplerminfilter
*/
readonly minFilter: MinFilter;
/**
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplermagfilter
*/
readonly magFilter: MagFilter;
/**
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplerwraps
*/
readonly wrapS: WrapMode;
/**
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplerwrapt
*/
readonly wrapT: WrapMode;
/**
* The texture rotation in radians.
*/
readonly rotation: number|null;
/**
* The texture scale.
*/
readonly scale: Vector2DInterface|null;
/**
* The texture offset.
*/
readonly offset: Vector2DInterface|null;
/**
* Configure the minFilter value of the Sampler.
*/
setMinFilter(filter: MinFilter): void;
/**
* Configure the magFilter value of the Sampler.
*/
setMagFilter(filter: MagFilter): void;
/**
* Configure the S (U) wrap mode of the Sampler.
*/
setWrapS(mode: WrapMode): void;
/**
* Configure the T (V) wrap mode of the Sampler.
*/
setWrapT(mode: WrapMode): void;
/**
* Sets the texture rotation, or resets it to zero if argument is null.
* Rotation is in radians, positive for counter-clockwise.
*/
setRotation(rotation: number|null): void;
/**
* Sets the texture scale, or resets it to (1, 1) if argument is null.
* As the scale value increases, the repetition of the texture will increase.
*/
setScale(scale: Vector2DInterface|null): void;
/**
* Sets the texture offset, or resets it to (0, 0) if argument is null.
*/
setOffset(offset: Vector2DInterface|null): void;
}
/**
* An Image represents an embedded or external image used to provide texture
* color data.
*
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-image
*/
export declare interface Image {
/**
* The name of the image, if any.
*/
readonly name: string;
/**
* The type is 'external' if the image has a configured URI. Otherwise, it is
* considered to be 'embedded'. Note: this distinction is only implied by the
* glTF spec, and is made explicit here for convenience.
*/
readonly type: 'embedded'|'external';
/**
* The URI of the image, if it is external.
*/
readonly uri?: string;
/**
* The bufferView of the image, if it is embedded.
*/
readonly bufferView?: number;
/**
* The backing HTML element, if this is a video or canvas texture.
*/
readonly element?: HTMLVideoElement|HTMLCanvasElement;
/**
* The Lottie animation object, if this is a Lottie texture. You may wish to
* do image.animation as import('lottie-web').AnimationItem; to get its type
* info.
*/
readonly animation?: any;
/**
* A method to create an object URL of this image at the desired
* resolution. Especially useful for KTX2 textures which are GPU compressed,
* and so are unreadable on the CPU without a method like this.
*/
createThumbnail(width: number, height: number): Promise<string>;
/**
* Only applies to canvas textures. Call when the content of the canvas has
* been updated and should be reflected in the model.
*/
update(): void;
}
/**
* An RGBA-encoded color, with channels represented as floating point values
* from [0,1].
*/
export declare type RGBA = [number, number, number, number];
export declare type RGB = [number, number, number];
@@ -0,0 +1,140 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, Texture as ThreeTexture, WebGLRenderTarget} from 'three';
import {blobCanvas} from '../../model-viewer-base.js';
import {Renderer} from '../../three-components/Renderer.js';
import {Image as ImageInterface} from './api.js';
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
const quadMaterial = new MeshBasicMaterial();
const quad = new PlaneGeometry(2, 2);
let adhocNum = 0;
export const $threeTexture = Symbol('threeTexture');
export const $threeTextures = Symbol('threeTextures');
export const $applyTexture = Symbol('applyTexture');
/**
* Image facade implementation for Three.js textures
*/
export class Image extends ThreeDOMElement implements ImageInterface {
get[$threeTexture]() {
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
}
get[$threeTextures](): Set<ThreeTexture> {
return this[$correlatedObjects] as Set<ThreeTexture>;
}
constructor(onUpdate: () => void, texture: ThreeTexture) {
super(onUpdate, new Set<ThreeTexture>(texture ? [texture] : []));
if (!this[$threeTexture].image.src) {
this[$threeTexture].image.src =
texture.name ? texture.name : 'adhoc_image' + adhocNum++;
}
if (!this[$threeTexture].image.name) {
this[$threeTexture].image.name =
(texture && texture.image && texture.image.src) ?
texture.image.src.split('/').pop() :
'adhoc_image';
}
}
get name(): string {
return this[$threeTexture].image.name || '';
}
get uri(): string|undefined {
return this[$threeTexture].image.src;
}
get bufferView(): number|undefined {
return this[$threeTexture].image.bufferView;
}
get element(): HTMLVideoElement|HTMLCanvasElement|undefined {
const texture = this[$threeTexture] as any;
if (texture && (texture.isCanvasTexture || texture.isVideoTexture)) {
return texture.image;
}
return;
}
get animation(): any|undefined {
const texture = this[$threeTexture] as any;
if (texture && texture.isCanvasTexture && texture.animation) {
return texture.animation;
}
return;
}
get type(): 'embedded'|'external' {
return this.uri != null ? 'external' : 'embedded';
}
set name(name: string) {
for (const texture of this[$threeTextures]) {
texture.image.name = name;
}
}
update() {
const texture = this[$threeTexture] as any;
// Applies to non-Lottie canvas textures only
if (texture && texture.isCanvasTexture && !texture.animation) {
this[$threeTexture].needsUpdate = true;
this[$onUpdate]();
}
}
async createThumbnail(width: number, height: number): Promise<string> {
const scene = new Scene();
quadMaterial.map = this[$threeTexture];
const mesh = new Mesh(quad, quadMaterial);
scene.add(mesh);
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
const {threeRenderer} = Renderer.singleton;
const renderTarget = new WebGLRenderTarget(width, height);
threeRenderer.setRenderTarget(renderTarget);
threeRenderer.render(scene, camera);
threeRenderer.setRenderTarget(null);
const buffer = new Uint8Array(width * height * 4);
threeRenderer.readRenderTargetPixels(
renderTarget, 0, 0, width, height, buffer);
blobCanvas.width = width;
blobCanvas.height = height;
const blobContext = blobCanvas.getContext('2d')!;
const imageData = blobContext.createImageData(width, height);
imageData.data.set(buffer);
blobContext.putImageData(imageData, 0, 0);
return new Promise<string>(async (resolve, reject) => {
blobCanvas.toBlob(blob => {
if (!blob) {
return reject('Failed to capture thumbnail.');
}
resolve(URL.createObjectURL(blob));
}, 'image/png');
});
}
}
@@ -0,0 +1,689 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Color, ColorRepresentation, DoubleSide, FrontSide, MeshPhysicalMaterial, Vector2} from 'three';
import {AlphaMode, RGB} from '../../three-components/gltf-instance/gltf-2.0.js';
import {Material as MaterialInterface} from './api.js';
import {LazyLoader, VariantData} from './model.js';
import {PBRMetallicRoughness} from './pbr-metallic-roughness.js';
import {TextureInfo, TextureUsage} from './texture-info.js';
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
const $pbrMetallicRoughness = Symbol('pbrMetallicRoughness');
const $normalTexture = Symbol('normalTexture');
const $occlusionTexture = Symbol('occlusionTexture');
const $emissiveTexture = Symbol('emissiveTexture');
const $backingThreeMaterial = Symbol('backingThreeMaterial');
const $applyAlphaCutoff = Symbol('applyAlphaCutoff');
const $getAlphaMode = Symbol('getAlphaMode');
export const $lazyLoadGLTFInfo = Symbol('lazyLoadGLTFInfo');
const $initialize = Symbol('initialize');
export const $getLoadedMaterial = Symbol('getLoadedMaterial');
export const $ensureMaterialIsLoaded = Symbol('ensureMaterialIsLoaded');
export const $gltfIndex = Symbol('gltfIndex');
export const $setActive = Symbol('setActive');
export const $variantIndices = Symbol('variantIndices');
const $isActive = Symbol('isActive');
const $modelVariants = Symbol('modelVariants');
const $name = Symbol('name');
const $pbrTextures = Symbol('pbrTextures');
/**
* Material facade implementation for Three.js materials
*/
export class Material extends ThreeDOMElement implements MaterialInterface {
private[$pbrMetallicRoughness]!: PBRMetallicRoughness;
private[$normalTexture]!: TextureInfo;
private[$occlusionTexture]!: TextureInfo;
private[$emissiveTexture]!: TextureInfo;
private[$lazyLoadGLTFInfo]?: LazyLoader;
private[$gltfIndex]: number;
private[$isActive]: boolean;
public[$variantIndices] = new Set<number>();
private[$name]?: string;
readonly[$modelVariants]: Map<string, VariantData>;
private[$pbrTextures] = new Map<TextureUsage, TextureInfo>();
get[$backingThreeMaterial](): MeshPhysicalMaterial {
return (this[$correlatedObjects] as Set<MeshPhysicalMaterial>)
.values()
.next()
.value!;
}
constructor(
onUpdate: () => void,
gltfIndex: number,
isActive: boolean,
modelVariants: Map<string, VariantData>,
correlatedMaterials: Set<MeshPhysicalMaterial>,
name: string|undefined,
lazyLoadInfo: LazyLoader|undefined = undefined,
) {
super(onUpdate, correlatedMaterials);
this[$gltfIndex] = gltfIndex;
this[$isActive] = isActive;
this[$modelVariants] = modelVariants;
this[$name] = name;
if (lazyLoadInfo == null) {
this[$initialize]();
} else {
this[$lazyLoadGLTFInfo] = lazyLoadInfo;
}
}
private[$initialize](): void {
const onUpdate = this[$onUpdate] as () => void;
const correlatedMaterials =
this[$correlatedObjects] as Set<MeshPhysicalMaterial>;
this[$pbrMetallicRoughness] =
new PBRMetallicRoughness(onUpdate, correlatedMaterials);
const {normalMap, aoMap, emissiveMap} =
correlatedMaterials.values().next().value!;
this[$normalTexture] = new TextureInfo(
onUpdate,
TextureUsage.Normal,
normalMap,
correlatedMaterials,
);
this[$occlusionTexture] = new TextureInfo(
onUpdate,
TextureUsage.Occlusion,
aoMap,
correlatedMaterials,
);
this[$emissiveTexture] = new TextureInfo(
onUpdate,
TextureUsage.Emissive,
emissiveMap,
correlatedMaterials,
);
const createTextureInfo = (usage: TextureUsage) => {
this[$pbrTextures].set(
usage,
new TextureInfo(
onUpdate,
usage,
null,
correlatedMaterials,
));
};
createTextureInfo(TextureUsage.Clearcoat);
createTextureInfo(TextureUsage.ClearcoatRoughness);
createTextureInfo(TextureUsage.ClearcoatNormal);
createTextureInfo(TextureUsage.SheenColor);
createTextureInfo(TextureUsage.SheenRoughness);
createTextureInfo(TextureUsage.Transmission);
createTextureInfo(TextureUsage.Thickness);
createTextureInfo(TextureUsage.Specular);
createTextureInfo(TextureUsage.SpecularColor);
createTextureInfo(TextureUsage.Iridescence);
createTextureInfo(TextureUsage.IridescenceThickness);
createTextureInfo(TextureUsage.Anisotropy);
}
async[$getLoadedMaterial](): Promise<MeshPhysicalMaterial|null> {
if (this[$lazyLoadGLTFInfo] != null) {
const material = await this[$lazyLoadGLTFInfo]!.doLazyLoad();
this[$initialize]();
// Releases lazy load info.
this[$lazyLoadGLTFInfo] = undefined;
// Redefines the method as a noop method.
this.ensureLoaded = async () => {};
return material as MeshPhysicalMaterial;
}
return null;
}
private colorFromRgb(rgb: RGB|string): Color {
const color = new Color();
if (rgb instanceof Array) {
color.fromArray(rgb);
} else {
color.set(rgb as ColorRepresentation);
}
return color;
}
[$ensureMaterialIsLoaded]() {
if (this[$lazyLoadGLTFInfo] == null) {
return;
}
throw new Error(`Material "${this.name}" has not been loaded, call 'await
myMaterial.ensureLoaded()' before using an unloaded material.`);
}
async ensureLoaded() {
await this[$getLoadedMaterial]();
}
get isLoaded() {
return this[$lazyLoadGLTFInfo] == null;
}
get isActive(): boolean {
return this[$isActive];
}
[$setActive](isActive: boolean) {
this[$isActive] = isActive;
}
get name(): string {
return this[$name] || '';
}
set name(name: string) {
this[$name] = name;
if (this[$correlatedObjects] != null) {
for (const threeMaterial of this[$correlatedObjects]!) {
threeMaterial.name = name;
}
}
}
get pbrMetallicRoughness(): PBRMetallicRoughness {
this[$ensureMaterialIsLoaded]();
return this[$pbrMetallicRoughness];
}
get normalTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$normalTexture];
}
get occlusionTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$occlusionTexture];
}
get emissiveTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$emissiveTexture];
}
get emissiveFactor(): RGB {
this[$ensureMaterialIsLoaded]();
return (this[$backingThreeMaterial].emissive.toArray() as RGB);
}
get index(): number {
return this[$gltfIndex];
}
hasVariant(name: string): boolean {
const variantData = this[$modelVariants].get(name);
return variantData != null && this[$variantIndices].has(variantData.index);
}
setEmissiveFactor(rgb: RGB|string) {
this[$ensureMaterialIsLoaded]();
const color = this.colorFromRgb(rgb);
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.emissive.set(color);
}
this[$onUpdate]();
}
[$getAlphaMode](): string {
// Follows implementation of GLTFExporter from three.js
if (this[$backingThreeMaterial].transparent) {
return 'BLEND';
} else {
if (this[$backingThreeMaterial].alphaTest > 0.0) {
return 'MASK';
}
}
return 'OPAQUE';
}
[$applyAlphaCutoff]() {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
if (this[$getAlphaMode]() === 'MASK') {
if (material.alphaTest == undefined) {
material.alphaTest = 0.5;
}
} else {
(material.alphaTest as number | undefined) = undefined;
}
material.needsUpdate = true;
}
}
setAlphaCutoff(cutoff: number): void {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.alphaTest = cutoff;
material.needsUpdate = true;
}
// Set AlphaCutoff to undefined if AlphaMode is not MASK.
this[$applyAlphaCutoff]();
this[$onUpdate]();
}
getAlphaCutoff(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].alphaTest;
}
setDoubleSided(doubleSided: boolean): void {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
// When double-sided is disabled gltf spec dictates that Back-Face culling
// must be disabled, in three.js parlance that would mean FrontSide
// rendering only.
// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#double-sided
material.side = doubleSided ? DoubleSide : FrontSide;
material.needsUpdate = true;
}
this[$onUpdate]();
}
getDoubleSided(): boolean {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].side == DoubleSide;
}
setAlphaMode(alphaMode: AlphaMode): void {
this[$ensureMaterialIsLoaded]();
const enableTransparency =
(material: MeshPhysicalMaterial, enabled: boolean): void => {
material.transparent = enabled;
material.depthWrite = !enabled;
};
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
enableTransparency(material, alphaMode === 'BLEND');
if (alphaMode === 'MASK') {
material.alphaTest = 0.5;
} else {
(material.alphaTest as number | undefined) = undefined;
}
material.needsUpdate = true;
}
this[$onUpdate]();
}
getAlphaMode(): AlphaMode {
this[$ensureMaterialIsLoaded]();
return (this[$getAlphaMode]() as AlphaMode);
}
/**
* PBR Next properties.
*/
// KHR_materials_emissive_strength
get emissiveStrength(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].emissiveIntensity;
}
setEmissiveStrength(emissiveStrength: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.emissiveIntensity = emissiveStrength;
}
this[$onUpdate]();
}
// KHR_materials_clearcoat
get clearcoatFactor(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].clearcoat;
}
get clearcoatRoughnessFactor(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].clearcoatRoughness;
}
get clearcoatTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.Clearcoat)!;
}
get clearcoatRoughnessTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.ClearcoatRoughness)!;
}
get clearcoatNormalTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.ClearcoatNormal)!;
}
get clearcoatNormalScale(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].clearcoatNormalScale.x;
}
setClearcoatFactor(clearcoatFactor: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.clearcoat = clearcoatFactor;
}
this[$onUpdate]();
}
setClearcoatRoughnessFactor(clearcoatRoughnessFactor: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.clearcoatRoughness = clearcoatRoughnessFactor;
}
this[$onUpdate]();
}
setClearcoatNormalScale(clearcoatNormalScale: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.clearcoatNormalScale =
new Vector2(clearcoatNormalScale, clearcoatNormalScale);
}
this[$onUpdate]();
}
// KHR_materials_ior
get ior(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].ior;
}
setIor(ior: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.ior = ior;
}
this[$onUpdate]();
}
// KHR_materials_sheen
get sheenColorFactor(): RGB {
this[$ensureMaterialIsLoaded]();
return (this[$backingThreeMaterial].sheenColor.toArray() as RGB);
}
get sheenColorTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.SheenColor)!;
}
get sheenRoughnessFactor(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].sheenRoughness;
}
get sheenRoughnessTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.SheenRoughness)!;
}
setSheenColorFactor(rgb: RGB|string) {
this[$ensureMaterialIsLoaded]();
const color = this.colorFromRgb(rgb);
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.sheenColor.set(color);
// Three.js GLTFExporter checks for internal sheen value.
material.sheen = 1;
}
this[$onUpdate]();
}
setSheenRoughnessFactor(roughness: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.sheenRoughness = roughness;
// Three.js GLTFExporter checks for internal sheen value.
material.sheen = 1;
}
this[$onUpdate]();
}
// KHR_materials_transmission
get transmissionFactor(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].transmission;
}
get transmissionTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.Transmission)!;
}
setTransmissionFactor(transmission: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.transmission = transmission;
}
this[$onUpdate]();
}
// KHR_materials_volume
get thicknessFactor(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].thickness;
}
get thicknessTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.Thickness)!;
}
get attenuationDistance(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].attenuationDistance;
}
get attenuationColor(): RGB {
this[$ensureMaterialIsLoaded]();
return (this[$backingThreeMaterial].attenuationColor.toArray() as RGB);
}
setThicknessFactor(thickness: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.thickness = thickness;
}
this[$onUpdate]();
}
setAttenuationDistance(attenuationDistance: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.attenuationDistance = attenuationDistance;
}
this[$onUpdate]();
}
setAttenuationColor(rgb: RGB|string) {
this[$ensureMaterialIsLoaded]();
const color = this.colorFromRgb(rgb);
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.attenuationColor.set(color);
}
this[$onUpdate]();
}
// KHR_materials_specular
get specularFactor(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].specularIntensity;
}
get specularTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.Specular)!;
}
get specularColorFactor(): RGB {
this[$ensureMaterialIsLoaded]();
return (this[$backingThreeMaterial].specularColor.toArray() as RGB);
}
get specularColorTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.SheenColor)!;
}
setSpecularFactor(specularFactor: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.specularIntensity = specularFactor;
}
this[$onUpdate]();
}
setSpecularColorFactor(rgb: RGB|string) {
this[$ensureMaterialIsLoaded]();
const color = this.colorFromRgb(rgb);
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.specularColor.set(color);
}
this[$onUpdate]();
}
// KHR_materials_iridescence
get iridescenceFactor(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].iridescence;
}
get iridescenceTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.Iridescence)!;
}
get iridescenceIor(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].iridescenceIOR;
}
get iridescenceThicknessMinimum(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].iridescenceThicknessRange[0];
}
get iridescenceThicknessMaximum(): number {
this[$ensureMaterialIsLoaded]();
return this[$backingThreeMaterial].iridescenceThicknessRange[1];
}
get iridescenceThicknessTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.IridescenceThickness)!;
}
setIridescenceFactor(iridescence: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.iridescence = iridescence;
}
this[$onUpdate]();
}
setIridescenceIor(ior: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.iridescenceIOR = ior;
}
this[$onUpdate]();
}
setIridescenceThicknessMinimum(thicknessMin: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.iridescenceThicknessRange[0] = thicknessMin;
}
this[$onUpdate]();
}
setIridescenceThicknessMaximum(thicknessMax: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
material.iridescenceThicknessRange[1] = thicknessMax;
}
this[$onUpdate]();
}
// KHR_materials_anisotropy
get anisotropyStrength(): number {
this[$ensureMaterialIsLoaded]();
return (this[$backingThreeMaterial] as any).anisotropy;
}
get anisotropyRotation(): number {
this[$ensureMaterialIsLoaded]();
return (this[$backingThreeMaterial] as any).anisotropyRotation;
}
get anisotropyTexture(): TextureInfo {
this[$ensureMaterialIsLoaded]();
return this[$pbrTextures].get(TextureUsage.Anisotropy)!;
}
setAnisotropyStrength(strength: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
(material as any).anisotropy = strength;
}
this[$onUpdate]();
}
setAnisotropyRotation(rotation: number) {
this[$ensureMaterialIsLoaded]();
for (const material of this[$correlatedObjects] as
Set<MeshPhysicalMaterial>) {
(material as any).anisotropyRotation = rotation;
}
this[$onUpdate]();
}
}
@@ -0,0 +1,397 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Intersection, Material as ThreeMaterial, Mesh, MeshPhysicalMaterial, Object3D} from 'three';
import {CorrelatedSceneGraph, GLTFElementToThreeObjectMap} from '../../three-components/gltf-instance/correlated-scene-graph.js';
import {GLTF, GLTFElement} from '../../three-components/gltf-instance/gltf-2.0.js';
import {Model as ModelInterface} from './api.js';
import {$setActive, $variantIndices, Material} from './material.js';
import {Node, PrimitiveNode} from './nodes/primitive-node.js';
import {$correlatedObjects} from './three-dom-element.js';
export const $materials = Symbol('materials');
const $hierarchy = Symbol('hierarchy');
const $roots = Symbol('roots');
export const $primitivesList = Symbol('primitives');
export const $loadVariant = Symbol('loadVariant');
export const $prepareVariantsForExport = Symbol('prepareVariantsForExport');
export const $switchVariant = Symbol('switchVariant');
export const $materialFromPoint = Symbol('materialFromPoint');
export const $nodeFromPoint = Symbol('nodeFromPoint');
export const $nodeFromIndex = Symbol('nodeFromIndex');
export const $variantData = Symbol('variantData');
export const $availableVariants = Symbol('availableVariants');
const $modelOnUpdate = Symbol('modelOnUpdate');
const $cloneMaterial = Symbol('cloneMaterial');
// Holds onto temporary scene context information needed to perform lazy loading
// of a resource.
export class LazyLoader {
gltf: GLTF;
gltfElementMap: GLTFElementToThreeObjectMap;
mapKey: GLTFElement;
doLazyLoad: () => Promise<ThreeMaterial>;
constructor(
gltf: GLTF, gltfElementMap: GLTFElementToThreeObjectMap,
mapKey: GLTFElement, doLazyLoad: () => Promise<ThreeMaterial>) {
this.gltf = gltf;
this.gltfElementMap = gltfElementMap;
this.mapKey = mapKey;
this.doLazyLoad = doLazyLoad;
}
}
/**
* Facades variant mapping data.
*/
export interface VariantData {
name: string;
index: number;
}
/**
* A Model facades the top-level GLTF object returned by Three.js' GLTFLoader.
* Currently, the model only bothers itself with the materials in the Three.js
* scene graph.
*/
export class Model implements ModelInterface {
private[$materials] = new Array<Material>();
private[$hierarchy] = new Array<Node>();
private[$roots] = new Array<Node>();
private[$primitivesList] = new Array<PrimitiveNode>();
private[$modelOnUpdate]: () => void = () => {};
private[$variantData] = new Map<string, VariantData>();
constructor(
correlatedSceneGraph: CorrelatedSceneGraph,
onUpdate: () => void = () => {}) {
this[$modelOnUpdate] = onUpdate;
const {gltf, threeGLTF, gltfElementMap} = correlatedSceneGraph;
for (const [i, material] of gltf.materials!.entries()) {
const correlatedMaterial =
gltfElementMap.get(material) as Set<MeshPhysicalMaterial>| null;
if (correlatedMaterial != null) {
this[$materials].push(new Material(
onUpdate,
i,
true,
this[$variantData],
correlatedMaterial,
material.name));
} else {
const elementArray = gltf['materials'] || [];
const gltfMaterialDef = elementArray[i];
const threeMaterialSet = new Set<MeshPhysicalMaterial>();
gltfElementMap.set(gltfMaterialDef, threeMaterialSet);
const materialLoadCallback = async () => {
const threeMaterial = await threeGLTF.parser.getDependency(
'material', i) as MeshPhysicalMaterial;
threeMaterialSet.add(threeMaterial);
return threeMaterial;
};
// Configures the material for lazy loading.
this[$materials].push(new Material(
onUpdate,
i,
false,
this[$variantData],
threeMaterialSet,
material.name,
new LazyLoader(
gltf, gltfElementMap, gltfMaterialDef, materialLoadCallback)));
}
}
// Creates a hierarchy of Nodes. Allows not just for switching which
// material is applied to a mesh but also exposes a way to provide API
// for switching materials and general assignment/modification.
// Prepares for scene iteration.
const parentMap = new Map<object, Node>();
const nodeStack = new Array<Object3D>();
for (const object of threeGLTF.scene.children) {
nodeStack.push(object);
}
// Walks the hierarchy and creates a node tree.
while (nodeStack.length > 0) {
const object = nodeStack.pop()!;
let node: Node|null = null;
if (object instanceof Mesh) {
node = new PrimitiveNode(
object as Mesh,
this.materials,
this[$variantData],
correlatedSceneGraph);
this[$primitivesList].push(node as PrimitiveNode);
} else {
node = new Node(object.name);
}
const parent: Node|undefined = parentMap.get(object);
if (parent != null) {
parent.children.push(node);
} else {
this[$roots].push(node);
}
this[$hierarchy].push(node);
for (const child of object.children) {
nodeStack.push(child);
parentMap.set(object, node);
}
}
}
/**
* Materials are listed in the order of the GLTF materials array, plus a
* default material at the end if one is used.
*
* TODO(#1003): How do we handle non-active scenes?
*/
get materials(): Material[] {
return this[$materials];
}
[$availableVariants]() {
const variants = Array.from(this[$variantData].values());
variants.sort((a, b) => {
return a.index - b.index;
});
return variants.map((data) => {
return data.name;
});
}
getMaterialByName(name: string): Material|null {
const matches = this[$materials].filter(material => {
return material.name === name;
});
if (matches.length > 0) {
return matches[0];
}
return null;
}
[$nodeFromIndex](mesh: number, primitive: number): PrimitiveNode|null {
const found = this[$hierarchy].find((node: Node) => {
if (node instanceof PrimitiveNode) {
const {meshes, primitives} = node.mesh.userData.associations;
if (meshes == mesh && primitives == primitive) {
return true;
}
}
return false;
});
return found == null ? null : found as PrimitiveNode;
}
[$nodeFromPoint](hit: Intersection<Object3D>): PrimitiveNode {
return this[$hierarchy].find((node: Node) => {
if (node instanceof PrimitiveNode) {
const primitive = node as PrimitiveNode;
if (primitive.mesh === hit.object) {
return true;
}
}
return false;
}) as PrimitiveNode;
}
/**
* Intersects a ray with the Model and returns the first material whose
* object was intersected.
*/
[$materialFromPoint](hit: Intersection<Object3D>): Material {
return this[$nodeFromPoint](hit).getActiveMaterial();
}
/**
* Switches model variant to the variant name provided, or switches to
* default/initial materials if 'null' is provided.
*/
async[$switchVariant](variantName: string|null) {
for (const primitive of this[$primitivesList]) {
await primitive.enableVariant(variantName);
}
for (const material of this.materials) {
material[$setActive](false);
}
// Marks the materials that are now in use after the variant switch.
for (const primitive of this[$primitivesList]) {
this.materials[primitive.getActiveMaterial().index][$setActive](true);
}
}
async[$prepareVariantsForExport]() {
const promises = new Array<Promise<void>>();
for (const primitive of this[$primitivesList]) {
promises.push(primitive.instantiateVariants());
}
await Promise.all(promises);
}
[$cloneMaterial](index: number, newMaterialName: string): Material {
const material = this.materials[index];
if (!material.isLoaded) {
console.error(`Cloning an unloaded material,
call 'material.ensureLoaded() before cloning the material.`);
}
const threeMaterialSet =
material[$correlatedObjects] as Set<MeshPhysicalMaterial>;
const clonedSet = new Set<MeshPhysicalMaterial>();
for (const [i, threeMaterial] of threeMaterialSet.entries()) {
const clone = threeMaterial.clone() as MeshPhysicalMaterial;
clone.name =
newMaterialName + (threeMaterialSet.size > 1 ? '_inst' + i : '');
clonedSet.add(clone);
}
const clonedMaterial = new Material(
this[$modelOnUpdate],
this[$materials].length,
false, // Cloned as inactive.
this[$variantData],
clonedSet,
newMaterialName);
this[$materials].push(clonedMaterial);
return clonedMaterial;
}
createMaterialInstanceForVariant(
originalMaterialIndex: number, newMaterialName: string,
variantName: string, activateVariant: boolean = true): Material|null {
let variantMaterialInstance: Material|null = null;
for (const primitive of this[$primitivesList]) {
const variantData = this[$variantData].get(variantName);
// Skips the primitive if the variant already exists.
if (variantData != null && primitive.variantInfo.has(variantData.index)) {
continue;
}
// Skips the primitive if the source/original material does not exist.
if (primitive.getMaterial(originalMaterialIndex) == null) {
continue;
}
if (!this.hasVariant(variantName)) {
this.createVariant(variantName);
}
if (variantMaterialInstance == null) {
variantMaterialInstance =
this[$cloneMaterial](originalMaterialIndex, newMaterialName);
}
primitive.addVariant(variantMaterialInstance, variantName)
}
if (activateVariant && variantMaterialInstance != null) {
(variantMaterialInstance as Material)[$setActive](true);
this.materials[originalMaterialIndex][$setActive](false);
for (const primitive of this[$primitivesList]) {
primitive.enableVariant(variantName);
}
}
return variantMaterialInstance;
}
createVariant(variantName: string) {
if (!this[$variantData].has(variantName)) {
// Adds the name if it's not already in the list.
this[$variantData].set(
variantName,
{name: variantName, index: this[$variantData].size} as VariantData);
} else {
console.warn(`Variant '${variantName}'' already exists`);
}
}
hasVariant(variantName: string) {
return this[$variantData].has(variantName);
}
setMaterialToVariant(materialIndex: number, targetVariantName: string) {
if (this[$availableVariants]().find(name => name === targetVariantName) ==
null) {
console.warn(`Can't add material to '${
targetVariantName}', the variant does not exist.'`);
return;
}
if (materialIndex < 0 || materialIndex >= this.materials.length) {
console.error(`setMaterialToVariant(): materialIndex is out of bounds.`);
return;
}
for (const primitive of this[$primitivesList]) {
const material = primitive.getMaterial(materialIndex);
// Ensures the material exists on the primitive before setting it to a
// variant.
if (material != null) {
primitive.addVariant(material, targetVariantName);
}
}
}
updateVariantName(currentName: string, newName: string) {
const variantData = this[$variantData].get(currentName);
if (variantData == null) {
return;
}
variantData.name = newName;
this[$variantData].set(newName, variantData!);
this[$variantData].delete(currentName);
}
deleteVariant(variantName: string) {
const variant = this[$variantData].get(variantName);
if (variant == null) {
return;
}
for (const material of this.materials) {
if (material.hasVariant(variantName)) {
material[$variantIndices].delete(variant.index);
}
}
for (const primitive of this[$primitivesList]) {
primitive.deleteVariant(variant.index);
}
this[$variantData].delete(variantName);
}
}
@@ -0,0 +1,267 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Material as ThreeMaterial, Mesh, MeshPhysicalMaterial} from 'three';
import {GLTFParser, GLTFReference} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {CorrelatedSceneGraph} from '../../../three-components/gltf-instance/correlated-scene-graph.js';
import {KHRMaterialsVariants, Primitive} from '../../../three-components/gltf-instance/gltf-2.0.js';
import {UserDataVariantMapping} from '../../../three-components/gltf-instance/VariantMaterialLoaderPlugin.js';
import {$getLoadedMaterial, $variantIndices, Material} from '../material.js';
import {VariantData} from '../model.js';
import {$correlatedObjects} from '../three-dom-element.js';
// Defines the base level node methods and data.
export class Node {
name: string = '';
children = new Array<Node>();
constructor(name: string) {
this.name = name;
}
}
// Represents a primitive in a glTF mesh.
export class PrimitiveNode extends Node {
public mesh: Mesh;
// Maps glTF material index number to a material that this primitive supports.
public materials = new Map<number, Material>();
// Maps variant index to material.
private variantToMaterialMap = new Map<number, Material>();
public initialMaterialIdx = 0;
private activeMaterialIdx = 0;
private modelVariants: Map<string, VariantData>;
private parser: GLTFParser;
constructor(
mesh: Mesh, mvMaterials: Material[],
modelVariants: Map<string, VariantData>,
correlatedSceneGraph: CorrelatedSceneGraph) {
super(mesh.name);
this.mesh = mesh;
const {gltf, threeGLTF, threeObjectMap} = correlatedSceneGraph;
this.parser = threeGLTF.parser;
this.modelVariants = modelVariants;
this.mesh.userData.variantData = modelVariants;
// Captures the primitive's initial material.
const materialMappings =
threeObjectMap.get(mesh.material as ThreeMaterial)!;
if (materialMappings.materials != null) {
this.initialMaterialIdx = this.activeMaterialIdx =
materialMappings.materials;
} else {
console.error(
`Primitive (${mesh.name}) missing initial material reference.`);
}
// Gets the mesh index from the node.
const associations =
(mesh.userData.associations as GLTFReference & {primitives: number}) ||
{};
if (associations.meshes == null) {
console.error('Mesh is missing primitive index association');
return;
}
// The gltf mesh array to sample from.
const meshElementArray = gltf['meshes'] || [];
// List of primitives under the mesh.
const gltfPrimitives =
(meshElementArray[associations.meshes].primitives || []) as Primitive[];
const gltfPrimitive = gltfPrimitives[associations.primitives];
if (gltfPrimitive == null) {
console.error('Mesh primitive definition is missing.');
return;
}
// Maps the gltfPrimitive default to a material.
if (gltfPrimitive.material != null) {
this.materials.set(
gltfPrimitive.material, mvMaterials[gltfPrimitive.material]);
} else {
const defaultIdx = mvMaterials.findIndex((mat: Material) => {
return mat.name === 'Default';
});
if (defaultIdx >= 0) {
this.materials.set(defaultIdx, mvMaterials[defaultIdx]);
} else {
console.warn('gltfPrimitive has no material!');
}
}
if (gltfPrimitive.extensions &&
gltfPrimitive.extensions['KHR_materials_variants']) {
const variantsExtension =
gltfPrimitive.extensions['KHR_materials_variants'] as
KHRMaterialsVariants;
const extensions = threeGLTF.parser.json.extensions;
const variantNames = extensions['KHR_materials_variants'].variants;
// Provides definition now that we know there are variants to
// support.
for (const mapping of variantsExtension.mappings) {
const mvMaterial = mvMaterials[mapping.material];
// Maps variant indices to Materials.
this.materials.set(mapping.material, mvMaterial);
for (const variant of mapping.variants) {
const {name} = variantNames[variant];
this.variantToMaterialMap.set(variant, mvMaterial);
// Provides variant info for material self lookup.
mvMaterial[$variantIndices].add(variant);
// Updates the models variant data.
if (!modelVariants.has(name)) {
modelVariants.set(name, {name, index: variant} as VariantData);
}
}
}
}
}
async setActiveMaterial(material: number): Promise<ThreeMaterial|null> {
const mvMaterial = this.materials.get(material)!;
if (material !== this.activeMaterialIdx) {
const backingMaterials =
mvMaterial[$correlatedObjects] as Set<MeshPhysicalMaterial>;
const baseMaterial = await mvMaterial[$getLoadedMaterial]();
if (baseMaterial != null) {
this.mesh.material = baseMaterial;
} else {
this.mesh.material = backingMaterials.values().next().value!;
}
this.parser.assignFinalMaterial(this.mesh);
backingMaterials.add(this.mesh.material as MeshPhysicalMaterial);
this.activeMaterialIdx = material;
}
return this.mesh.material as ThreeMaterial;
}
getActiveMaterial(): Material {
return this.materials.get(this.activeMaterialIdx)!;
}
getMaterial(index: number): Material|undefined {
return this.materials.get(index);
}
async enableVariant(name: string|null): Promise<ThreeMaterial|null> {
if (name == null) {
return this.setActiveMaterial(this.initialMaterialIdx);
}
if (this.variantToMaterialMap != null && this.modelVariants.has(name)) {
const modelVariants = this.modelVariants.get(name)!;
return this.enableVariantHelper(modelVariants.index);
}
return null;
}
private async enableVariantHelper(index: number|
null): Promise<ThreeMaterial|null> {
if (this.variantToMaterialMap != null && index != null) {
const material = this.variantToMaterialMap.get(index);
if (material != null) {
return this.setActiveMaterial(material.index);
}
}
return null;
}
async instantiateVariants() {
if (this.variantToMaterialMap == null) {
return;
}
for (const index of this.variantToMaterialMap.keys()) {
const variantMaterial = this.mesh.userData.variantMaterials.get(index) as
UserDataVariantMapping;
if (variantMaterial.material != null) {
continue;
}
const threeMaterial = await this.enableVariantHelper(index);
if (threeMaterial != null) {
variantMaterial.material = threeMaterial;
}
}
}
get variantInfo() {
return this.variantToMaterialMap;
}
addVariant(materialVariant: Material, variantName: string) {
if (!this.ensureVariantIsUnused(variantName)) {
return false;
}
// Adds the variant to the model variants if needed.
if (!this.modelVariants.has(variantName)) {
this.modelVariants.set(
variantName, {name: variantName, index: this.modelVariants.size});
}
const modelVariantData = this.modelVariants.get(variantName)!;
const variantIndex = modelVariantData.index;
// Updates materials mapped to the variant.
materialVariant[$variantIndices].add(variantIndex);
// Updates internal mappings.
this.variantToMaterialMap.set(variantIndex, materialVariant);
this.materials.set(materialVariant.index, materialVariant);
this.updateVariantUserData(variantIndex, materialVariant);
return true;
}
deleteVariant(variantIndex: number) {
if (this.variantInfo.has(variantIndex)) {
this.variantInfo.delete(variantIndex);
const userDataMap = this.mesh.userData.variantMaterials! as
Map<number, UserDataVariantMapping>;
if (userDataMap != null) {
userDataMap.delete(variantIndex);
}
}
}
private updateVariantUserData(
variantIndex: number, materialVariant: Material) {
// Adds variants name to material variants set.
materialVariant[$variantIndices].add(variantIndex);
this.mesh.userData.variantData = this.modelVariants;
// Updates import data (see VariantMaterialLoaderPlugin.ts).
this.mesh.userData.variantMaterials = this.mesh.userData.variantMaterials ||
new Map<number, UserDataVariantMapping>();
const map = this.mesh.userData.variantMaterials! as
Map<number, UserDataVariantMapping>;
map.set(variantIndex, {
material: materialVariant[$correlatedObjects]!.values().next().value as
ThreeMaterial,
gltfMaterialIndex: materialVariant.index,
});
}
private ensureVariantIsUnused(variantName: string) {
const modelVariants = this.modelVariants.get(variantName);
if (modelVariants != null && this.variantInfo.has(modelVariants!.index)) {
console.warn(`Primitive cannot add variant '${
variantName}' for this material, it already exists.`);
return false;
}
return true;
}
}
@@ -0,0 +1,116 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Color, ColorRepresentation, MeshPhysicalMaterial} from 'three';
import {PBRMetallicRoughness as PBRMetallicRoughnessInterface, RGBA} from './api.js';
import {TextureInfo, TextureUsage} from './texture-info.js';
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
const $threeMaterial = Symbol('threeMaterial');
const $threeMaterials = Symbol('threeMaterials');
const $baseColorTexture = Symbol('baseColorTexture');
const $metallicRoughnessTexture = Symbol('metallicRoughnessTexture');
/**
* PBR material properties facade implementation for Three.js materials
*/
export class PBRMetallicRoughness extends ThreeDOMElement implements
PBRMetallicRoughnessInterface {
private[$baseColorTexture]: TextureInfo;
private[$metallicRoughnessTexture]: TextureInfo;
private get[$threeMaterials](): Set<MeshPhysicalMaterial> {
return this[$correlatedObjects] as Set<MeshPhysicalMaterial>;
}
private get[$threeMaterial]() {
return this[$correlatedObjects]?.values().next().value as
MeshPhysicalMaterial;
}
constructor(
onUpdate: () => void, correlatedMaterials: Set<MeshPhysicalMaterial>) {
super(onUpdate, correlatedMaterials);
const {map, metalnessMap} = correlatedMaterials.values().next().value!;
this[$baseColorTexture] =
new TextureInfo(onUpdate, TextureUsage.Base, map, correlatedMaterials);
this[$metallicRoughnessTexture] = new TextureInfo(
onUpdate,
TextureUsage.MetallicRoughness,
metalnessMap,
correlatedMaterials);
}
get baseColorFactor(): RGBA {
const rgba = [0, 0, 0, this[$threeMaterial].opacity];
this[$threeMaterial].color.toArray(rgba);
return rgba as RGBA;
}
get metallicFactor(): number {
return this[$threeMaterial].metalness;
}
get roughnessFactor(): number {
return this[$threeMaterial].roughness;
}
get baseColorTexture(): TextureInfo {
return this[$baseColorTexture];
}
get metallicRoughnessTexture(): TextureInfo {
return this[$metallicRoughnessTexture];
}
setBaseColorFactor(rgba: RGBA|string) {
const color = new Color();
if (rgba instanceof Array) {
color.fromArray(rgba);
} else {
color.set(rgba as ColorRepresentation);
}
for (const material of this[$threeMaterials]) {
material.color.set(color);
if (rgba instanceof Array && rgba.length > 3) {
material.opacity = rgba[3];
} else {
rgba = [0, 0, 0, material.opacity];
color.toArray(rgba);
}
}
this[$onUpdate]();
}
setMetallicFactor(value: number) {
for (const material of this[$threeMaterials]) {
material.metalness = value;
}
this[$onUpdate]();
}
setRoughnessFactor(value: number) {
for (const material of this[$threeMaterials]) {
material.roughness = value;
}
this[$onUpdate]();
}
}
@@ -0,0 +1,197 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {ClampToEdgeWrapping, LinearFilter, LinearMipmapLinearFilter, LinearMipmapNearestFilter, MagnificationTextureFilter, MinificationTextureFilter, MirroredRepeatWrapping, NearestFilter, NearestMipmapLinearFilter, NearestMipmapNearestFilter, RepeatWrapping, Texture as ThreeTexture, Vector2, Wrapping} from 'three';
import {toVector2D, Vector2D} from '../../model-viewer-base.js';
import {Filter, MagFilter, MinFilter, Wrap, WrapMode} from '../../three-components/gltf-instance/gltf-2.0.js';
import {Sampler as DefaultedSampler} from '../../three-components/gltf-instance/gltf-defaulted.js';
import {Sampler as SamplerInterface, Vector2DInterface} from './api.js';
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
// Convertion between gltf standards and threejs standards.
const wrapModeToWrapping = new Map<WrapMode, Wrapping>([
[Wrap.Repeat, RepeatWrapping],
[Wrap.ClampToEdge, ClampToEdgeWrapping],
[Wrap.MirroredRepeat, MirroredRepeatWrapping]
]);
const wrappingToWrapMode = new Map<Wrapping, WrapMode>([
[RepeatWrapping, Wrap.Repeat],
[ClampToEdgeWrapping, Wrap.ClampToEdge],
[MirroredRepeatWrapping, Wrap.MirroredRepeat]
]);
const minFilterToMinification = new Map<MinFilter, MinificationTextureFilter>([
[Filter.Nearest, NearestFilter],
[Filter.Linear, LinearFilter],
[Filter.NearestMipmapNearest, NearestMipmapNearestFilter],
[Filter.LinearMipmapNearest, LinearMipmapNearestFilter],
[Filter.NearestMipmapLinear, NearestMipmapLinearFilter],
[Filter.LinearMipmapLinear, LinearMipmapLinearFilter]
]);
const minificationToMinFilter = new Map<MinificationTextureFilter, MinFilter>([
[NearestFilter, Filter.Nearest],
[LinearFilter, Filter.Linear],
[NearestMipmapNearestFilter, Filter.NearestMipmapNearest],
[LinearMipmapNearestFilter, Filter.LinearMipmapNearest],
[NearestMipmapLinearFilter, Filter.NearestMipmapLinear],
[LinearMipmapLinearFilter, Filter.LinearMipmapLinear]
]);
const magFilterToMagnification = new Map<MagFilter, MagnificationTextureFilter>(
[[Filter.Nearest, NearestFilter], [Filter.Linear, LinearFilter]]);
const magnificationToMagFilter = new Map<MagnificationTextureFilter, MagFilter>(
[[NearestFilter, Filter.Nearest], [LinearFilter, Filter.Linear]]);
// Checks for threejs standards.
const isMinFilter = (() => {
return (value: unknown): value is MinificationTextureFilter =>
minificationToMinFilter.has(value as MinificationTextureFilter);
})();
const isMagFilter = (() => {
return (value: unknown): value is MagnificationTextureFilter =>
magnificationToMagFilter.has(value as MagnificationTextureFilter);
})();
const isWrapping = (() => {
return (value: unknown): value is Wrapping =>
wrappingToWrapMode.has(value as Wrapping);
})();
const isValidSamplerValue =
<P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'|'rotation'|'repeat'|
'offset'>(property: P, value: unknown): value is DefaultedSampler[P] => {
switch (property) {
case 'minFilter':
return isMinFilter(value);
case 'magFilter':
return isMagFilter(value);
case 'wrapS':
case 'wrapT':
return isWrapping(value);
case 'rotation':
case 'repeat':
case 'offset':
return true;
default:
throw new Error(`Cannot configure property "${property}" on Sampler`);
}
};
const $threeTexture = Symbol('threeTexture');
const $threeTextures = Symbol('threeTextures');
const $setProperty = Symbol('setProperty');
/**
* Sampler facade implementation for Three.js textures
*/
export class Sampler extends ThreeDOMElement implements SamplerInterface {
private get[$threeTexture]() {
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
}
private get[$threeTextures]() {
return this[$correlatedObjects] as Set<ThreeTexture>;
}
constructor(onUpdate: () => void, texture: ThreeTexture) {
super(onUpdate, new Set<ThreeTexture>(texture ? [texture] : []));
}
get name(): string {
return this[$threeTexture].name || '';
}
get minFilter(): MinFilter {
return minificationToMinFilter.get(this[$threeTexture].minFilter)!;
}
get magFilter(): MagFilter {
return magnificationToMagFilter.get(this[$threeTexture].magFilter)!;
}
get wrapS(): WrapMode {
return wrappingToWrapMode.get(this[$threeTexture].wrapS)!;
}
get wrapT(): WrapMode {
return wrappingToWrapMode.get(this[$threeTexture].wrapT)!;
}
get rotation(): number {
return this[$threeTexture].rotation;
}
get scale(): Vector2D {
return toVector2D(this[$threeTexture].repeat);
}
get offset(): Vector2D|null {
return toVector2D(this[$threeTexture].offset);
}
setMinFilter(filter: MinFilter) {
this[$setProperty]('minFilter', minFilterToMinification.get(filter)!);
}
setMagFilter(filter: MagFilter) {
this[$setProperty]('magFilter', magFilterToMagnification.get(filter)!);
}
setWrapS(mode: WrapMode) {
this[$setProperty]('wrapS', wrapModeToWrapping.get(mode)!);
}
setWrapT(mode: WrapMode) {
this[$setProperty]('wrapT', wrapModeToWrapping.get(mode)!);
}
setRotation(rotation: number|null): void {
if (rotation == null) {
// Reset rotation.
rotation = 0;
}
this[$setProperty]('rotation', rotation);
}
setScale(scale: Vector2DInterface|null): void {
if (scale == null) {
// Reset scale.
scale = {u: 1, v: 1};
}
this[$setProperty]('repeat', new Vector2(scale.u, scale.v));
}
setOffset(offset: Vector2DInterface|null): void {
if (offset == null) {
// Reset offset.
offset = {u: 0, v: 0};
}
this[$setProperty]('offset', new Vector2(offset.u, offset.v));
}
private[$setProperty]<P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'|
'rotation'|'repeat'|'offset'>(
property: P, value: MinFilter|MagFilter|Wrapping|number|Vector2) {
if (isValidSamplerValue(property, value)) {
for (const texture of this[$threeTextures]) {
(texture[property] as MinFilter | MagFilter | Wrapping | number |
Vector2) = value;
texture.needsUpdate = true;
}
}
this[$onUpdate]();
}
}
@@ -0,0 +1,211 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {ColorSpace, LinearSRGBColorSpace, MeshPhysicalMaterial, SRGBColorSpace, Texture as ThreeTexture, Vector2, VideoTexture} from 'three';
import {TextureInfo as TextureInfoInterface} from './api.js';
import {$threeTexture} from './image.js';
import {Texture} from './texture.js';
const $texture = Symbol('texture');
const $transform = Symbol('transform');
export const $materials = Symbol('materials');
export const $usage = Symbol('usage');
const $onUpdate = Symbol('onUpdate');
const $activeVideo = Symbol('activeVideo');
// Defines what a texture will be used for.
export enum TextureUsage {
Base,
MetallicRoughness,
Normal,
Occlusion,
Emissive,
Clearcoat,
ClearcoatRoughness,
ClearcoatNormal,
SheenColor,
SheenRoughness,
Transmission,
Thickness,
Specular,
SpecularColor,
Iridescence,
IridescenceThickness,
Anisotropy,
}
interface TextureTransform {
rotation: number;
scale: Vector2;
offset: Vector2;
}
/**
* TextureInfo facade implementation for Three.js materials
*/
export class TextureInfo implements TextureInfoInterface {
private[$texture]: Texture|null = null;
private[$transform]: TextureTransform = {
rotation: 0,
scale: new Vector2(1, 1),
offset: new Vector2(0, 0)
};
// Holds a reference to the Three data that backs the material object.
private[$materials]: Set<MeshPhysicalMaterial>|null;
// Texture usage defines the how the texture is used (ie Normal, Emissive...
// etc)
private[$usage]: TextureUsage;
private[$onUpdate]: () => void;
private[$activeVideo] = false;
constructor(
onUpdate: () => void, usage: TextureUsage,
threeTexture: ThreeTexture|null, material: Set<MeshPhysicalMaterial>) {
// Creates image, sampler, and texture if valid texture info is provided.
if (threeTexture) {
this[$transform].rotation = threeTexture.rotation;
this[$transform].scale.copy(threeTexture.repeat);
this[$transform].offset.copy(threeTexture.offset);
this[$texture] = new Texture(onUpdate, threeTexture);
}
this[$onUpdate] = onUpdate;
this[$materials] = material;
this[$usage] = usage;
}
get texture(): Texture|null {
return this[$texture];
}
setTexture(texture: Texture|null): void {
const threeTexture: ThreeTexture|null =
texture != null ? texture.source[$threeTexture] : null;
const oldTexture = this[$texture]?.source[$threeTexture] as VideoTexture;
if (oldTexture != null && oldTexture.isVideoTexture) {
this[$activeVideo] = false;
} else if (this[$texture]?.source.animation) {
this[$texture].source.animation.removeEventListener(
'enterFrame', this[$onUpdate]);
}
this[$texture] = texture;
if (threeTexture != null && (threeTexture as VideoTexture).isVideoTexture) {
const element = threeTexture.image;
this[$activeVideo] = true;
if (element.requestVideoFrameCallback != null) {
const update = () => {
if (!this[$activeVideo]) {
return;
}
this[$onUpdate]();
element.requestVideoFrameCallback(update);
};
element.requestVideoFrameCallback(update);
} else {
const update = () => {
if (!this[$activeVideo]) {
return;
}
this[$onUpdate]();
requestAnimationFrame(update);
};
requestAnimationFrame(update);
}
} else if (texture?.source.animation != null) {
texture.source.animation.addEventListener('enterFrame', this[$onUpdate]);
}
let colorSpace: ColorSpace = SRGBColorSpace;
if (this[$materials]) {
for (const material of this[$materials]!) {
switch (this[$usage]) {
case TextureUsage.Base:
material.map = threeTexture;
break;
case TextureUsage.MetallicRoughness:
colorSpace = LinearSRGBColorSpace;
material.metalnessMap = threeTexture;
material.roughnessMap = threeTexture;
break;
case TextureUsage.Normal:
colorSpace = LinearSRGBColorSpace;
material.normalMap = threeTexture;
break;
case TextureUsage.Occlusion:
colorSpace = LinearSRGBColorSpace;
material.aoMap = threeTexture;
break;
case TextureUsage.Emissive:
material.emissiveMap = threeTexture;
break;
case TextureUsage.Clearcoat:
material.clearcoatMap = threeTexture;
break;
case TextureUsage.ClearcoatRoughness:
material.clearcoatRoughnessMap = threeTexture;
break;
case TextureUsage.ClearcoatNormal:
material.clearcoatNormalMap = threeTexture;
break;
case TextureUsage.SheenColor:
material.sheenColorMap = threeTexture;
break;
case TextureUsage.SheenRoughness:
material.sheenRoughnessMap = threeTexture;
break;
case TextureUsage.Transmission:
material.transmissionMap = threeTexture;
break;
case TextureUsage.Thickness:
material.thicknessMap = threeTexture;
break;
case TextureUsage.Specular:
material.specularIntensityMap = threeTexture;
break;
case TextureUsage.SpecularColor:
material.specularColorMap = threeTexture;
break;
case TextureUsage.Iridescence:
material.iridescenceMap = threeTexture;
break;
case TextureUsage.IridescenceThickness:
material.iridescenceThicknessMap = threeTexture;
break;
case TextureUsage.Anisotropy:
(material as any).anisotropyMap = threeTexture;
break;
default:
}
material.needsUpdate = true;
}
}
if (threeTexture) {
// Updates the colorSpace for the texture, affects all references.
threeTexture.colorSpace = colorSpace;
threeTexture.rotation = this[$transform].rotation;
threeTexture.repeat = this[$transform].scale;
threeTexture.offset = this[$transform].offset;
}
this[$onUpdate]();
}
}
@@ -0,0 +1,64 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Texture as ThreeTexture} from 'three';
import {Texture as TextureInterface} from './api.js';
import {Image} from './image.js';
import {Sampler} from './sampler.js';
import {$correlatedObjects, ThreeDOMElement} from './three-dom-element.js';
const $image = Symbol('image');
const $sampler = Symbol('sampler');
const $threeTexture = Symbol('threeTexture');
/**
* Material facade implementation for Three.js materials
*/
export class Texture extends ThreeDOMElement implements TextureInterface {
private[$image]: Image;
private[$sampler]: Sampler;
private get[$threeTexture]() {
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
}
constructor(onUpdate: () => void, threeTexture: ThreeTexture) {
super(onUpdate, new Set<ThreeTexture>(threeTexture ? [threeTexture] : []));
this[$sampler] = new Sampler(onUpdate, threeTexture);
this[$image] = new Image(onUpdate, threeTexture);
}
get name(): string {
return this[$threeTexture].name || '';
}
set name(name: string) {
for (const texture of this[$correlatedObjects] as Set<ThreeTexture>) {
texture.name = name;
}
}
get sampler(): Sampler {
return this[$sampler];
}
get source(): Image {
return this[$image];
}
}
@@ -0,0 +1,38 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Material, Object3D, Texture} from 'three';
export const $correlatedObjects = Symbol('correlatedObjects');
export const $onUpdate = Symbol('onUpdate');
type CorrelatedObjects = Set<Object3D>|Set<Material>|Set<Texture>;
/**
* A SerializableThreeDOMElement is the common primitive of all scene graph
* elements that have been facaded in the host execution context. It adds
* a common interface to these elements in support of convenient
* serializability.
*/
export class ThreeDOMElement {
readonly[$onUpdate]: () => void;
// The Three.js scene graph construct for this element.
[$correlatedObjects]: CorrelatedObjects;
constructor(onUpdate: () => void, correlatedObjects: CorrelatedObjects) {
this[$onUpdate] = onUpdate;
this[$correlatedObjects] = correlatedObjects;
}
}
@@ -0,0 +1,133 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {property} from 'lit/decorators.js';
import {style} from '../decorators.js';
import ModelViewerElementBase, {$getModelIsVisible, $renderer, $scene, $tick} from '../model-viewer-base.js';
import {degreesToRadians} from '../styles/conversions.js';
import {EvaluatedStyle, Intrinsics} from '../styles/evaluators.js';
import {numberNode, NumberNode} from '../styles/parsers.js';
import {Constructor} from '../utilities.js';
import {CameraChangeDetails} from './controls.js';
// How much the model will rotate per
// second in radians:
const DEFAULT_ROTATION_SPEED = Math.PI / 32;
export const AUTO_ROTATE_DELAY_DEFAULT = 3000;
const rotationRateIntrinsics = {
basis:
[degreesToRadians(numberNode(DEFAULT_ROTATION_SPEED, 'rad')) as
NumberNode<'rad'>],
keywords: {auto: [null]}
};
const $autoRotateStartTime = Symbol('autoRotateStartTime');
const $radiansPerSecond = Symbol('radiansPerSecond');
const $syncRotationRate = Symbol('syncRotationRate');
const $onCameraChange = Symbol('onCameraChange');
export declare interface StagingInterface {
autoRotate: boolean;
autoRotateDelay: number;
readonly turntableRotation: number;
resetTurntableRotation(theta?: number): void;
}
export const StagingMixin = <T extends Constructor<ModelViewerElementBase>>(
ModelViewerElement: T): Constructor<StagingInterface>&T => {
class StagingModelViewerElement extends ModelViewerElement {
@property({type: Boolean, attribute: 'auto-rotate'})
autoRotate: boolean = false;
@property({type: Number, attribute: 'auto-rotate-delay'})
autoRotateDelay: number = AUTO_ROTATE_DELAY_DEFAULT;
@style(
{intrinsics: rotationRateIntrinsics, updateHandler: $syncRotationRate})
@property({type: String, attribute: 'rotation-per-second'})
rotationPerSecond: string = 'auto';
private[$autoRotateStartTime] = performance.now();
private[$radiansPerSecond] = 0;
connectedCallback() {
super.connectedCallback();
this.addEventListener(
'camera-change', this[$onCameraChange] as EventListener);
this[$autoRotateStartTime] = performance.now();
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener(
'camera-change', this[$onCameraChange] as EventListener);
this[$autoRotateStartTime] = performance.now();
}
updated(changedProperties: Map<string, any>) {
super.updated(changedProperties);
if (changedProperties.has('autoRotate')) {
this[$autoRotateStartTime] = performance.now();
}
}
[$syncRotationRate](style: EvaluatedStyle<Intrinsics<['rad']>>) {
this[$radiansPerSecond] = style[0];
}
[$tick](time: number, delta: number) {
super[$tick](time, delta);
if (!this.autoRotate || !this[$getModelIsVisible]() ||
this[$renderer].isPresenting) {
return;
}
const rotationDelta = Math.min(
delta, time - this[$autoRotateStartTime] - this.autoRotateDelay);
if (rotationDelta > 0) {
this[$scene].yaw = this.turntableRotation +
this[$radiansPerSecond] * rotationDelta * 0.001;
}
}
[$onCameraChange] = (event: CustomEvent<CameraChangeDetails>) => {
if (!this.autoRotate) {
return;
}
if (event.detail.source === 'user-interaction') {
this[$autoRotateStartTime] = performance.now();
}
};
get turntableRotation(): number {
return this[$scene].yaw;
}
resetTurntableRotation(theta = 0) {
this[$scene].yaw = theta;
}
}
return StagingModelViewerElement;
};
@@ -0,0 +1,648 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {ReactiveElement} from 'lit';
import {property} from 'lit/decorators.js';
import {Camera as ThreeCamera, Event as ThreeEvent, Vector2, Vector3, WebGLRenderer} from 'three';
import {HAS_INTERSECTION_OBSERVER, HAS_RESIZE_OBSERVER} from './constants.js';
import {$updateEnvironment} from './features/environment.js';
import {makeTemplate} from './template.js';
import {$evictionPolicy, CachingGLTFLoader} from './three-components/CachingGLTFLoader.js';
import {ModelScene} from './three-components/ModelScene.js';
import {ContextLostEvent, Renderer} from './three-components/Renderer.js';
import {clamp, debounce} from './utilities.js';
import {ProgressTracker} from './utilities/progress-tracker.js';
const CLEAR_MODEL_TIMEOUT_MS = 10;
const FALLBACK_SIZE_UPDATE_THRESHOLD_MS = 50;
const ANNOUNCE_MODEL_VISIBILITY_DEBOUNCE_THRESHOLD = 0;
const UNSIZED_MEDIA_WIDTH = 300;
const UNSIZED_MEDIA_HEIGHT = 150;
export const blobCanvas = document.createElement('canvas');
const $fallbackResizeHandler = Symbol('fallbackResizeHandler');
const $defaultAriaLabel = Symbol('defaultAriaLabel');
const $resizeObserver = Symbol('resizeObserver');
const $clearModelTimeout = Symbol('clearModelTimeout');
const $onContextLost = Symbol('onContextLost');
const $loaded = Symbol('loaded');
const $status = Symbol('status');
const $onFocus = Symbol('onFocus');
const $onBlur = Symbol('onBlur');
export const $updateSize = Symbol('updateSize');
export const $intersectionObserver = Symbol('intersectionObserver');
export const $isElementInViewport = Symbol('isElementInViewport');
export const $announceModelVisibility = Symbol('announceModelVisibility');
export const $ariaLabel = Symbol('ariaLabel');
export const $altDefaulted = Symbol('altDefaulted');
export const $statusElement = Symbol('statusElement');
export const $updateStatus = Symbol('updateStatus');
export const $loadedTime = Symbol('loadedTime');
export const $updateSource = Symbol('updateSource');
export const $markLoaded = Symbol('markLoaded');
export const $container = Symbol('container');
export const $userInputElement = Symbol('input');
export const $canvas = Symbol('canvas');
export const $scene = Symbol('scene');
export const $needsRender = Symbol('needsRender');
export const $tick = Symbol('tick');
export const $onModelLoad = Symbol('onModelLoad');
export const $onResize = Symbol('onResize');
export const $renderer = Symbol('renderer');
export const $progressTracker = Symbol('progressTracker');
export const $getLoaded = Symbol('getLoaded');
export const $getModelIsVisible = Symbol('getModelIsVisible');
export const $shouldAttemptPreload = Symbol('shouldAttemptPreload');
export interface Vector3D {
x: number
y: number
z: number
toString(): string
}
export const toVector3D = (v: Vector3) => {
return {
x: v.x,
y: v.y,
z: v.z,
toString() {
return `${this.x}m ${this.y}m ${this.z}m`;
}
};
};
export interface Vector2D {
u: number
v: number
toString(): string
}
export const toVector2D = (v: Vector2) => {
return {
u: v.x,
v: v.y,
toString() {
return `${this.u} ${this.v}`;
}
};
};
interface ToBlobOptions {
mimeType?: string, qualityArgument?: number, idealAspect?: boolean
}
export interface FramingInfo {
framedRadius: number;
fieldOfViewAspect: number;
}
export interface Camera {
viewMatrix: Array<number>;
projectionMatrix: Array<number>;
}
export interface EffectComposerInterface {
setRenderer(renderer: WebGLRenderer): void;
setMainScene(scene: ModelScene): void;
setMainCamera(camera: ThreeCamera): void;
setSize(width: number, height: number): void;
beforeRender(time: DOMHighResTimeStamp, delta: DOMHighResTimeStamp): void;
render(deltaTime?: DOMHighResTimeStamp): void;
}
export interface RendererInterface {
load(progressCallback: (progress: number) => void): Promise<FramingInfo>;
render(camera: Camera): void;
resize(width: number, height: number): void;
}
/**
* Definition for a basic <model-viewer> element.
*/
export default class ModelViewerElementBase extends ReactiveElement {
static get is() {
return 'model-viewer';
}
/** @export */
static set modelCacheSize(value: number) {
CachingGLTFLoader[$evictionPolicy].evictionThreshold = value;
}
/** @export */
static get modelCacheSize(): number {
return CachingGLTFLoader[$evictionPolicy].evictionThreshold
}
/** @export */
static set minimumRenderScale(value: number) {
if (value > 1) {
console.warn(
'<model-viewer> minimumRenderScale has been clamped to a maximum value of 1.');
}
if (value <= 0) {
console.warn(
'<model-viewer> minimumRenderScale has been clamped to a minimum value of 0.25.');
}
Renderer.singleton.minScale = value;
}
/** @export */
static get minimumRenderScale(): number {
return Renderer.singleton.minScale;
}
@property({type: String}) alt: string|null = null;
@property({type: String}) src: string|null = null;
@property({type: Boolean, attribute: 'with-credentials'})
withCredentials: boolean = false;
/**
* Generates a 3D model schema https://schema.org/3DModel associated with
* the loaded src and inserts it into the header of the page for search
* engines to crawl.
*/
@property({type: Boolean, attribute: 'generate-schema'})
generateSchema = false;
protected[$isElementInViewport] = false;
protected[$loaded] = false;
protected[$loadedTime] = 0;
protected[$scene]: ModelScene;
protected[$container]: HTMLDivElement;
protected[$userInputElement]: HTMLDivElement;
protected[$canvas]: HTMLCanvasElement;
protected[$statusElement]: HTMLSpanElement;
protected[$status] = '';
protected[$defaultAriaLabel]: string;
protected[$clearModelTimeout]: number|null = null;
protected[$fallbackResizeHandler] = debounce(() => {
const boundingRect = this.getBoundingClientRect();
this[$updateSize](boundingRect);
}, FALLBACK_SIZE_UPDATE_THRESHOLD_MS);
protected[$announceModelVisibility] = debounce((oldVisibility: boolean) => {
const newVisibility = this.modelIsVisible;
if (newVisibility !== oldVisibility) {
this.dispatchEvent(new CustomEvent(
'model-visibility', {detail: {visible: newVisibility}}));
}
}, ANNOUNCE_MODEL_VISIBILITY_DEBOUNCE_THRESHOLD);
protected[$resizeObserver]: ResizeObserver|null = null;
protected[$intersectionObserver]: IntersectionObserver|null = null;
protected[$progressTracker]: ProgressTracker = new ProgressTracker();
/** @export */
get loaded() {
return this[$getLoaded]();
}
get[$renderer]() {
return Renderer.singleton;
}
/** @export */
get modelIsVisible() {
return this[$getModelIsVisible]();
}
/**
* Creates a new ModelViewerElement.
*/
constructor() {
super();
this.attachShadow({mode: 'open'});
const shadowRoot = this.shadowRoot!;
makeTemplate(shadowRoot);
this[$container] = shadowRoot.querySelector('.container') as HTMLDivElement;
this[$userInputElement] =
shadowRoot.querySelector('.userInput') as HTMLDivElement;
this[$canvas] = shadowRoot.querySelector('canvas') as HTMLCanvasElement;
this[$statusElement] =
shadowRoot.querySelector('#status') as HTMLSpanElement;
this[$defaultAriaLabel] =
this[$userInputElement].getAttribute('aria-label')!;
// Because of potential race conditions related to invoking the constructor
// we only use the bounding rect to set the initial size if the element is
// already connected to the document:
let width, height;
if (this.isConnected) {
const rect = this.getBoundingClientRect();
width = rect.width;
height = rect.height;
} else {
width = UNSIZED_MEDIA_WIDTH;
height = UNSIZED_MEDIA_HEIGHT;
}
// Create the underlying ModelScene.
this[$scene] =
new ModelScene({canvas: this[$canvas], element: this, width, height});
// Update initial size on microtask timing so that subclasses have a
// chance to initialize
Promise.resolve().then(() => {
this[$updateSize](this.getBoundingClientRect());
});
if (HAS_RESIZE_OBSERVER) {
// Set up a resize observer so we can scale our canvas
// if our <model-viewer> changes
this[$resizeObserver] =
new ResizeObserver((entries: Array<ResizeObserverEntry>) => {
// Don't resize anything if in AR mode; otherwise the canvas
// scaling to fullscreen on entering AR will clobber the flat/2d
// dimensions of the element.
if (this[$renderer].isPresenting) {
return;
}
for (let entry of entries) {
if (entry.target === this) {
this[$updateSize](entry.contentRect);
}
}
});
}
if (HAS_INTERSECTION_OBSERVER) {
this[$intersectionObserver] = new IntersectionObserver(entries => {
for (let entry of entries) {
if (entry.target === this) {
const oldVisibility = this.modelIsVisible;
this[$isElementInViewport] = entry.isIntersecting;
this[$announceModelVisibility](oldVisibility);
if (this[$isElementInViewport] && !this.loaded) {
this[$updateSource]();
}
}
}
}, {
root: null,
// We used to have margin here, but it was causing animated models below
// the fold to steal the frame budget. Weirder still, it would also
// cause input events to be swallowed, sometimes for seconds on the
// model above the fold, but only when the animated model was completely
// below. Setting this margin to zero fixed it.
rootMargin: '0px',
// With zero threshold, an element adjacent to but not intersecting the
// viewport will be reported as intersecting, which will cause
// unnecessary rendering. Any slight positive threshold alleviates this.
threshold: 0.00001,
});
} else {
// If there is no intersection observer, then all models should be visible
// at all times:
this[$isElementInViewport] = true;
}
}
connectedCallback() {
super.connectedCallback && super.connectedCallback();
if (HAS_RESIZE_OBSERVER) {
this[$resizeObserver]!.observe(this);
} else {
self.addEventListener('resize', this[$fallbackResizeHandler]);
}
if (HAS_INTERSECTION_OBSERVER) {
this[$intersectionObserver]!.observe(this);
}
this.addEventListener('focus', this[$onFocus]);
this.addEventListener('blur', this[$onBlur]);
const renderer = this[$renderer];
renderer.addEventListener(
'contextlost', this[$onContextLost] as (event: ThreeEvent) => void);
renderer.registerScene(this[$scene]);
if (this[$clearModelTimeout] != null) {
self.clearTimeout(this[$clearModelTimeout]!);
this[$clearModelTimeout] = null;
// Force an update in case the model has been evicted from our GLTF cache
// @see https://lit-element.polymer-project.org/guide/lifecycle#requestupdate
this.requestUpdate('src', null);
}
}
disconnectedCallback() {
super.disconnectedCallback && super.disconnectedCallback();
if (HAS_RESIZE_OBSERVER) {
this[$resizeObserver]!.unobserve(this);
} else {
self.removeEventListener('resize', this[$fallbackResizeHandler]);
}
if (HAS_INTERSECTION_OBSERVER) {
this[$intersectionObserver]!.unobserve(this);
}
this.removeEventListener('focus', this[$onFocus]);
this.removeEventListener('blur', this[$onBlur]);
const renderer = this[$renderer];
renderer.removeEventListener(
'contextlost', this[$onContextLost] as (event: ThreeEvent) => void);
renderer.unregisterScene(this[$scene]);
this[$clearModelTimeout] = self.setTimeout(() => {
this[$scene].dispose();
this[$clearModelTimeout] = null;
}, CLEAR_MODEL_TIMEOUT_MS);
}
updated(changedProperties: Map<string|number|symbol, any>) {
super.updated(changedProperties);
// NOTE(cdata): If a property changes from values A -> B -> A in the space
// of a microtask, LitElement/UpdatingElement will notify of a change even
// though the value has effectively not changed, so we need to check to make
// sure that the value has actually changed before changing the loaded flag.
if (changedProperties.has('src')) {
if (this.src == null) {
this[$loaded] = false;
this[$loadedTime] = 0;
this[$scene].reset();
} else if (this.src !== this[$scene].url) {
this[$loaded] = false;
this[$loadedTime] = 0;
this[$updateSource]();
}
}
if (changedProperties.has('alt')) {
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
}
if (changedProperties.has('generateSchema')) {
if (this.generateSchema) {
this[$scene].updateSchema(this.src);
} else {
this[$scene].updateSchema(null);
}
}
}
/** @export */
toDataURL(type?: string, encoderOptions?: number): string {
return this[$renderer]
.displayCanvas(this[$scene])
.toDataURL(type, encoderOptions);
}
/** @export */
async toBlob(options?: ToBlobOptions): Promise<Blob> {
const mimeType = options ? options.mimeType : undefined;
const qualityArgument = options ? options.qualityArgument : undefined;
const useIdealAspect = options ? options.idealAspect : undefined;
const {width, height, idealAspect, aspect} = this[$scene];
const {dpr, scaleFactor} = this[$renderer];
let outputWidth = width * scaleFactor * dpr;
let outputHeight = height * scaleFactor * dpr;
let offsetX = 0;
let offsetY = 0;
if (useIdealAspect === true) {
if (idealAspect > aspect) {
const oldHeight = outputHeight;
outputHeight = Math.round(outputWidth / idealAspect);
offsetY = (oldHeight - outputHeight) / 2;
} else {
const oldWidth = outputWidth;
outputWidth = Math.round(outputHeight * idealAspect);
offsetX = (oldWidth - outputWidth) / 2;
}
}
blobCanvas.width = outputWidth;
blobCanvas.height = outputHeight;
try {
return new Promise<Blob>(async (resolve, reject) => {
blobCanvas.getContext('2d')!.drawImage(
this[$renderer].displayCanvas(this[$scene]),
offsetX,
offsetY,
outputWidth,
outputHeight,
0,
0,
outputWidth,
outputHeight);
blobCanvas.toBlob((blob) => {
if (!blob) {
return reject(new Error('Unable to retrieve canvas blob'));
}
resolve(blob);
}, mimeType, qualityArgument);
});
} finally {
this[$updateSize]({width, height});
};
}
/**
* Registers a new EffectComposer as the main rendering pipeline,
* instead of the default ThreeJs renderer.
* This method also calls setRenderer, setMainScene, and setMainCamera on
* your effectComposer.
* @param effectComposer An EffectComposer from `pmndrs/postprocessing`
*/
registerEffectComposer(effectComposer: EffectComposerInterface) {
effectComposer.setRenderer(this[$renderer].threeRenderer);
effectComposer.setMainCamera(this[$scene].getCamera());
effectComposer.setMainScene(this[$scene]);
this[$scene].effectRenderer = effectComposer;
}
/**
* Removes the registered EffectComposer
*/
unregisterEffectComposer() {
this[$scene].effectRenderer = null;
}
registerRenderer(renderer: RendererInterface) {
this[$scene].externalRenderer = renderer;
}
unregisterRenderer() {
this[$scene].externalRenderer = null;
}
get[$ariaLabel]() {
return this[$altDefaulted];
}
get[$altDefaulted]() {
return (this.alt == null || this.alt === 'null') ? this[$defaultAriaLabel] :
this.alt;
}
// NOTE(cdata): Although this may seem extremely redundant, it is required in
// order to support overloading when TypeScript is compiled to ES5
// @see https://github.com/Polymer/lit-element/pull/745
// @see https://github.com/microsoft/TypeScript/issues/338
[$getLoaded](): boolean {
return this[$loaded];
}
// @see [$getLoaded]
[$getModelIsVisible](): boolean {
return this.loaded && this[$isElementInViewport];
}
[$shouldAttemptPreload](): boolean {
return !!this.src && this[$isElementInViewport];
}
/**
* Called on initialization and when the resize observer fires.
*/
[$updateSize]({width, height}: {width: number, height: number}) {
if (width === 0 || height === 0) {
return;
}
this[$container].style.width = `${width}px`;
this[$container].style.height = `${height}px`;
this[$onResize]({width, height});
}
[$tick](time: number, delta: number) {
this[$scene].effectRenderer?.beforeRender(time, delta);
}
[$markLoaded]() {
if (this[$loaded]) {
return;
}
this[$loaded] = true;
this[$loadedTime] = performance.now();
}
[$needsRender]() {
this[$scene].queueRender();
}
[$onModelLoad]() {
}
[$updateStatus](status: string) {
this[$status] = status;
const rootNode = this.getRootNode() as Document | ShadowRoot | null;
// Only change the aria-label if <model-viewer> is currently focused:
if (rootNode != null && rootNode.activeElement === this &&
this[$statusElement].textContent != status) {
this[$statusElement].textContent = status;
}
}
[$onFocus] = () => {
this[$statusElement].textContent = this[$status];
};
[$onBlur] = () => {
this[$statusElement].textContent = '';
};
[$onResize](e: {width: number, height: number}) {
this[$scene].setSize(e.width, e.height);
}
[$onContextLost] = (event: ContextLostEvent) => {
this.dispatchEvent(new CustomEvent(
'error',
{detail: {type: 'webglcontextlost', sourceError: event.sourceEvent}}));
};
/**
* Parses the element for an appropriate source URL and
* sets the views to use the new model based.
*/
async[$updateSource]() {
const scene = this[$scene];
if (this.loaded || !this[$shouldAttemptPreload]() ||
this.src === scene.url) {
return;
}
if (this.generateSchema) {
scene.updateSchema(this.src);
}
this[$updateStatus]('Loading');
// If we are loading a new model, we need to stop the animation of
// the current one (if any is playing). Otherwise, we might lose
// the reference to the scene root and running actions start to
// throw exceptions and/or behave in unexpected ways:
scene.stopAnimation();
const updateSourceProgress =
this[$progressTracker].beginActivity('model-load');
const source = this.src;
try {
const srcUpdated = scene.setSource(
source,
(progress: number) =>
updateSourceProgress(clamp(progress, 0, 1) * 0.95));
const envUpdated = (this as any)[$updateEnvironment]();
await Promise.all([srcUpdated, envUpdated]);
this[$markLoaded]();
this[$onModelLoad]();
this.updateComplete.then(() => {
this.dispatchEvent(new CustomEvent('before-render'));
});
// Wait for shaders to compile and pixels to be drawn.
await new Promise<void>(resolve => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
this.dispatchEvent(
new CustomEvent('load', {detail: {url: source}}));
resolve();
});
});
});
} catch (error) {
this.dispatchEvent(new CustomEvent(
'error', {detail: {type: 'loadfailure', sourceError: error}}));
} finally {
updateSourceProgress(1.0);
}
}
}
+44
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;
}
}
@@ -0,0 +1,147 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {NumberNode, ZERO} from './parsers.js';
/**
* Ensures that a given number is expressed in radians. If the number is already
* in radians, does nothing. If the value is in degrees, converts it to radians.
* If the value has no specified unit, the unit is assumed to be radians. If the
* value is not in radians or degrees, the value is resolved as 0 radians.
*
* Also accepts a second argument that is a default value to use if the input
* numberNode number is NaN or Infinity.
*/
export const degreesToRadians =
(numberNode: NumberNode, fallbackRadianValue: number = 0): NumberNode => {
let {number, unit} = numberNode;
if (!isFinite(number)) {
number = fallbackRadianValue;
unit = 'rad';
} else if (numberNode.unit === 'rad' || numberNode.unit == null) {
return numberNode;
}
const valueIsDegrees = unit === 'deg' && number != null;
const value = valueIsDegrees ? number : 0;
const radians = value * Math.PI / 180;
return {type: 'number', number: radians, unit: 'rad'};
};
/**
* Ensures that a given number is expressed in degrees. If the number is already
* in degrees, does nothing. If the value is in radians or has no specified
* unit, converts it to degrees. If the value is not in radians or degrees, the
* value is resolved as 0 degrees.
*
* Also accepts a second argument that is a default value to use if the input
* numberNode number is NaN or Infinity.
*/
export const radiansToDegrees =
(numberNode: NumberNode, fallbackDegreeValue: number = 0): NumberNode => {
let {number, unit} = numberNode;
if (!isFinite(number)) {
number = fallbackDegreeValue;
unit = 'deg';
} else if (numberNode.unit === 'deg') {
return numberNode;
}
const valueIsRadians =
(unit === null || unit === 'rad') && number != null;
const value = valueIsRadians ? number : 0;
const degrees = value * 180 / Math.PI;
return {type: 'number', number: degrees, unit: 'deg'};
};
/**
* Converts a given length to meters. Currently supported input units are
* meters, centimeters and millimeters.
*
* Also accepts a second argument that is a default value to use if the input
* numberNode number is NaN or Infinity.
*/
export const lengthToBaseMeters =
(numberNode: NumberNode, fallbackMeterValue: number = 0): NumberNode => {
let {number, unit} = numberNode;
if (!isFinite(number)) {
number = fallbackMeterValue;
unit = 'm';
} else if (numberNode.unit === 'm') {
return numberNode;
}
let scale;
switch (unit) {
default:
scale = 1;
break;
case 'cm':
scale = 1 / 100;
break;
case 'mm':
scale = 1 / 1000;
break;
}
const value = scale * number;
return {type: 'number', number: value, unit: 'm'};
};
/**
* Normalizes the unit of a given input number so that it is expressed in a
* preferred unit. For length nodes, the return value will be expressed in
* meters. For angle nodes, the return value will be expressed in radians.
*
* Also takes a fallback number that is used when the number value is not a
* valid number or when the unit of the given number cannot be normalized.
*/
export const normalizeUnit = (() => {
const identity = (node: NumberNode) => node;
const unitNormalizers: {[index: string]: (node: NumberNode) => NumberNode} = {
'rad': identity,
'deg': degreesToRadians,
'm': identity,
'mm': lengthToBaseMeters,
'cm': lengthToBaseMeters
};
return (node: NumberNode, fallback: NumberNode = ZERO) => {
if (!isFinite(node.number)) {
node.number = fallback.number;
node.unit = fallback.unit;
}
const {unit} = node;
if (unit == null) {
return node;
}
const normalize = unitNormalizers[unit];
if (normalize == null) {
return fallback;
}
return normalize(node);
};
})();
@@ -0,0 +1,54 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {IdentNode, parseExpressions} from './parsers.js';
/**
* For our purposes, an enumeration is a fixed set of CSS-expression-compatible
* names. When serialized, a selected subset of the members may be specified as
* whitespace-separated strings. An enumeration deserializer is a function that
* parses a serialized subset of an enumeration and returns any members that are
* found as a Set.
*
* The following example will produce a deserializer for the days of the
* week:
*
* const deserializeDaysOfTheWeek = enumerationDeserializer([
* 'Monday',
* 'Tuesday',
* 'Wednesday',
* 'Thursday',
* 'Friday',
* 'Saturday',
* 'Sunday'
* ]);
*/
export const enumerationDeserializer = <T extends string>(allowedNames: T[]) =>
(valueString: string): Set<T> => {
try {
const expressions = parseExpressions(valueString);
const names = (expressions.length ? expressions[0].terms : [])
.filter<IdentNode>(
(valueNode): valueNode is IdentNode =>
valueNode && valueNode.type === 'ident')
.map(valueNode => valueNode.value as T)
.filter(name => allowedNames.indexOf(name) > -1);
return new Set<T>(names);
} catch (_error) {
}
return new Set();
};
@@ -0,0 +1,561 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {normalizeUnit} from './conversions.js';
import {ExpressionNode, ExpressionTerm, FunctionNode, IdentNode, NumberNode, numberNode, OperatorNode, Percentage, Unit, ZERO} from './parsers.js';
export type Evaluatable<T> = Evaluator<T>|T;
/**
* A NumberNodeSequence is a vector of NumberNodes with a specified
* sequence of units.
*/
export type NumberNodeSequence<T extends Array<Unit>, U = never> = {
[I in keyof T]:
NumberNode&{
unit: T[I]|U;
};
};
export type Sparse<T> = {
[I in keyof T]: null|T[I];
};
/**
* Intrinsics describe the metadata required to do four things for any given
* type of number-based CSS expression:
*
* 1. Establish the expected units of a final, evaluated result
* 2. Provide a foundational value that percentages should scale against
* 3. Describe the analog number values that correspond to various keywords
* 4. Have an available concrete value to fallback to when needed
*
* Intrinsics must always specify a basis and the substitute values for the
* keyword 'auto'.
*
* Intrinsics may optionally specify the substitute values for any additional
* number of keywords.
*/
export interface Intrinsics<T extends Array<Unit> = []> {
basis: NumberNodeSequence<T>;
keywords: {
auto: Sparse<NumberNodeSequence<T, Percentage>>;
[index: string]: Sparse<NumberNodeSequence<T, Percentage>>;
};
}
const $evaluate = Symbol('evaluate');
const $lastValue = Symbol('lastValue');
/**
* An Evaluator is used to derive a computed style from part (or all) of a CSS
* expression AST. This construct is particularly useful for complex ASTs
* containing function calls such as calc, var and env. Such styles could be
* costly to re-evaluate on every frame (and in some cases we may try to do
* that). The Evaluator construct allows us to mark sub-trees of the AST as
* constant, so that only the dynamic parts are re-evaluated. It also separates
* one-time AST preparation work from work that necessarily has to happen upon
* each evaluation.
*/
export abstract class Evaluator<T> {
/**
* An Evaluatable is a NumberNode or an Evaluator that evaluates a NumberNode
* as the result of invoking its evaluate method. This is mainly used to
* ensure that CSS function nodes are cast to the corresponding Evaluators
* that will resolve the result of the function, but is also used to ensure
* that a percentage nested at arbitrary depth in the expression will always
* be evaluated against the correct basis.
*/
static evaluatableFor(
node: ExpressionTerm|Evaluator<NumberNode>,
basis: NumberNode = ZERO): Evaluatable<NumberNode> {
if (node instanceof Evaluator) {
return node;
}
if (node.type === 'number') {
if (node.unit === '%') {
return new PercentageEvaluator(node as NumberNode<'%'>, basis);
}
return node;
}
switch ((node as FunctionNode).name.value) {
case 'calc':
return new CalcEvaluator(node as FunctionNode, basis);
case 'env':
return new EnvEvaluator(node as FunctionNode);
}
return ZERO;
}
/**
* If the input is an Evaluator, returns the result of evaluating it.
* Otherwise, returns the input.
*
* This is a helper to aide in resolving a NumberNode without conditionally
* checking if the Evaluatable is an Evaluator everywhere.
*/
static evaluate<T extends NumberNode|IdentNode>(evaluatable: Evaluatable<T>):
T {
if (evaluatable instanceof Evaluator) {
return evaluatable.evaluate();
}
return evaluatable;
}
/**
* If the input is an Evaluator, returns the value of its isConstant property.
* Returns true for all other input values.
*/
static isConstant<T>(evaluatable: Evaluatable<T>): boolean {
if (evaluatable instanceof Evaluator) {
return evaluatable.isConstant;
}
return true;
}
/**
* This method applies a set of structured intrinsic metadata to an evaluated
* result from a parsed CSS-like string of expressions. Intrinsics provide
* sufficient metadata (e.g., basis values, analogs for keywords) such that
* omitted values in the input string can be backfilled, and keywords can be
* converted to concrete numbers.
*
* The result of applying intrinsics is a tuple of NumberNode values whose
* units match the units used by the basis of the intrinsics.
*
* The following is a high-level description of how intrinsics are applied:
*
* 1. Determine the value of 'auto' for the current term
* 2. If there is no corresponding input value for this term, substitute the
* 'auto' value.
* 3. If the term is an IdentNode, treat it as a keyword and perform the
* appropriate substitution.
* 4. If the term is still null, fallback to the 'auto' value
* 5. If the term is a percentage, apply it to the basis and return that
* value
* 6. Normalize the unit of the term
* 7. If the term's unit does not match the basis unit, return the basis
* value
* 8. Return the term as is
*/
static applyIntrinsics<T extends Array<Unit>>(
evaluated: Array<any>, intrinsics: Intrinsics<T>): NumberNodeSequence<T> {
const {basis, keywords} = intrinsics;
const {auto} = keywords;
return basis.map<NumberNode>((basisNode, index) => {
// Use an auto value if we have it, otherwise the auto value is the basis:
const autoSubstituteNode = auto[index] == null ? basisNode : auto[index];
// If the evaluated nodes do not have a node at the current
// index, fallback to the "auto" substitute right away:
let evaluatedNode =
evaluated[index] ? evaluated[index] : autoSubstituteNode;
// Any ident node is considered a keyword:
if (evaluatedNode.type === 'ident') {
const keyword = evaluatedNode.value;
// Substitute any keywords for concrete values first:
if (keyword in keywords) {
evaluatedNode = keywords[keyword][index];
}
}
// If we don't have a NumberNode at this point, fall back to whatever
// is specified for auto:
if (evaluatedNode == null || evaluatedNode.type === 'ident') {
evaluatedNode = autoSubstituteNode;
}
// For percentages, we always apply the percentage to the basis value:
if (evaluatedNode.unit === '%') {
return numberNode(
evaluatedNode.number / 100 * basisNode.number, basisNode.unit);
}
// Otherwise, normalize whatever we have:
evaluatedNode = normalizeUnit(evaluatedNode, basisNode);
// If the normalized units do not match, return the basis as a fallback:
if (evaluatedNode.unit !== basisNode.unit) {
return basisNode;
}
// Finally, return the evaluated node with intrinsics applied:
return evaluatedNode;
}) as NumberNodeSequence<T>;
}
/**
* If true, the Evaluator will only evaluate its AST one time. If false, the
* Evaluator will re-evaluate the AST each time that the public evaluate
* method is invoked.
*/
get isConstant(): boolean {
return false;
}
protected[$lastValue]: T|null = null;
/**
* This method must be implemented by subclasses. Its implementation should be
* the actual steps to evaluate the AST, and should return the evaluated
* result.
*/
protected abstract[$evaluate](): T;
/**
* Evaluate the Evaluator and return the result. If the Evaluator is constant,
* the corresponding AST will only be evaluated once, and the result of
* evaluating it the first time will be returned on all subsequent
* evaluations.
*/
evaluate(): T {
if (!this.isConstant || this[$lastValue] == null) {
this[$lastValue] = this[$evaluate]();
}
return this[$lastValue]!;
}
}
const $percentage = Symbol('percentage');
const $basis = Symbol('basis');
/**
* A PercentageEvaluator scales a given basis value by a given percentage value.
* The evaluated result is always considered to be constant.
*/
export class PercentageEvaluator extends Evaluator<NumberNode> {
protected[$percentage]: NumberNode<'%'>;
protected[$basis]: NumberNode;
constructor(percentage: NumberNode<'%'>, basis: NumberNode) {
super();
this[$percentage] = percentage;
this[$basis] = basis;
}
get isConstant() {
return true;
}
[$evaluate]() {
return numberNode(
this[$percentage].number / 100 * this[$basis].number,
this[$basis].unit);
}
}
const $identNode = Symbol('identNode');
/**
* Evaluator for CSS-like env() functions. Currently, only one environment
* variable is accepted as an argument for such functions: window-scroll-y.
*
* The env() Evaluator is explicitly dynamic because it always refers to
* external state that changes as the user scrolls, so it should always be
* re-evaluated to ensure we get the most recent value.
*
* Some important notes about this feature include:
*
* - There is no such thing as a "window-scroll-y" CSS environment variable in
* any stable browser at the time that this comment is being written.
* - The actual CSS env() function accepts a second argument as a fallback for
* the case that the specified first argument isn't set; our syntax does not
* support this second argument.
*
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/env
*/
export class EnvEvaluator extends Evaluator<NumberNode> {
protected[$identNode]: IdentNode|null = null;
constructor(envFunction: FunctionNode) {
super();
const identNode =
envFunction.arguments.length ? envFunction.arguments[0].terms[0] : null;
if (identNode != null && identNode.type === 'ident') {
this[$identNode] = identNode;
}
}
get isConstant(): boolean {
return false;
};
[$evaluate](): NumberNode {
if (this[$identNode] != null) {
switch (this[$identNode]!.value) {
case 'window-scroll-y':
const verticalScrollPosition = window.pageYOffset;
const verticalScrollMax = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight);
const scrollY = verticalScrollPosition /
(verticalScrollMax - window.innerHeight) ||
0;
return {type: 'number', number: scrollY, unit: null};
}
}
return ZERO;
}
}
const IS_MULTIPLICATION_RE = /[\*\/]/;
const $evaluator = Symbol('evaluator');
/**
* Evaluator for CSS-like calc() functions. Our implementation of calc()
* evaluation currently support nested function calls, an unlimited number of
* terms, and all four algebraic operators (+, -, * and /).
*
* The Evaluator is marked as constant unless the calc expression contains an
* internal env expression at any depth, in which case it will be marked as
* dynamic.
*
* @see https://www.w3.org/TR/css-values-3/#calc-syntax
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc
*/
export class CalcEvaluator extends Evaluator<NumberNode> {
protected[$evaluator]: Evaluator<NumberNode>|null = null;
constructor(calcFunction: FunctionNode, basis: NumberNode = ZERO) {
super();
if (calcFunction.arguments.length !== 1) {
return;
}
const terms: Array<ExpressionTerm> =
calcFunction.arguments[0].terms.slice();
const secondOrderTerms: Array<ExpressionTerm|Evaluator<NumberNode>> = [];
while (terms.length) {
const term: ExpressionTerm = terms.shift()!;
if (secondOrderTerms.length > 0) {
const previousTerm =
secondOrderTerms[secondOrderTerms.length - 1] as ExpressionTerm;
if (previousTerm.type === 'operator' &&
IS_MULTIPLICATION_RE.test(previousTerm.value)) {
const operator = secondOrderTerms.pop() as OperatorNode;
const leftValue = secondOrderTerms.pop();
if (leftValue == null) {
return;
}
secondOrderTerms.push(new OperatorEvaluator(
operator,
Evaluator.evaluatableFor(leftValue, basis),
Evaluator.evaluatableFor(term, basis)));
continue;
}
}
secondOrderTerms.push(
term.type === 'operator' ? term :
Evaluator.evaluatableFor(term, basis));
}
while (secondOrderTerms.length > 2) {
const [left, operator, right] = secondOrderTerms.splice(0, 3);
if ((operator as ExpressionTerm).type !== 'operator') {
return;
}
secondOrderTerms.unshift(new OperatorEvaluator(
operator as OperatorNode,
Evaluator.evaluatableFor(left, basis),
Evaluator.evaluatableFor(right, basis)));
}
// There should only be one combined evaluator at this point:
if (secondOrderTerms.length === 1) {
this[$evaluator] = secondOrderTerms[0] as Evaluator<NumberNode>;
}
}
get isConstant() {
return this[$evaluator] == null || Evaluator.isConstant(this[$evaluator]!);
}
[$evaluate]() {
return this[$evaluator] != null ? Evaluator.evaluate(this[$evaluator]!) :
ZERO;
}
}
const $operator = Symbol('operator');
const $left = Symbol('left');
const $right = Symbol('right');
/**
* An Evaluator for the operators found inside CSS calc() functions.
* The evaluator accepts an operator and left/right operands. The operands can
* be any valid expression term typically allowed inside a CSS calc function.
*
* As detail of this implementation, the only supported unit types are angles
* expressed as radians or degrees, and lengths expressed as meters, centimeters
* or millimeters.
*
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc
*/
export class OperatorEvaluator extends Evaluator<NumberNode> {
protected[$operator]: OperatorNode;
protected[$left]: Evaluatable<NumberNode>;
protected[$right]: Evaluatable<NumberNode>;
constructor(
operator: OperatorNode, left: Evaluatable<NumberNode>,
right: Evaluatable<NumberNode>) {
super();
this[$operator] = operator;
this[$left] = left;
this[$right] = right;
}
get isConstant() {
return Evaluator.isConstant(this[$left]) &&
Evaluator.isConstant(this[$right]);
}
[$evaluate](): NumberNode {
const leftNode = normalizeUnit(Evaluator.evaluate(this[$left]));
const rightNode = normalizeUnit(Evaluator.evaluate(this[$right]));
const {number: leftValue, unit: leftUnit} = leftNode;
const {number: rightValue, unit: rightUnit} = rightNode;
// Disallow operations for mismatched normalized units e.g., m and rad:
if (rightUnit != null && leftUnit != null && rightUnit != leftUnit) {
return ZERO;
}
// NOTE(cdata): rules for calc type checking are defined here
// https://drafts.csswg.org/css-values-3/#calc-type-checking
// This is a simplification and may not hold up once we begin to support
// additional unit types:
const unit = leftUnit || rightUnit;
let value;
switch (this[$operator].value) {
case '+':
value = leftValue + rightValue;
break;
case '-':
value = leftValue - rightValue;
break;
case '/':
value = leftValue / rightValue;
break;
case '*':
value = leftValue * rightValue;
break;
default:
return ZERO;
}
return {type: 'number', number: value, unit};
}
}
export type EvaluatedStyle<T extends Intrinsics<Array<Unit>>> = {
[I in keyof T['basis']]: number;
}&Array<never>;
const $evaluatables = Symbol('evaluatables');
const $intrinsics = Symbol('intrinsics');
/**
* A VectorEvaluator evaluates a series of numeric terms that usually represent
* a data structure such as a multi-dimensional vector or a spherical
*
* The form of the evaluator's result is determined by the Intrinsics that are
* given to it when it is constructed. For example, spherical intrinsics would
* establish two angle terms and a length term, so the result of evaluating the
* evaluator that is configured with spherical intrinsics is a three element
* array where the first two elements represent angles in radians and the third
* element representing a length in meters.
*/
export class StyleEvaluator<T extends Intrinsics<Array<any>>> extends
Evaluator<EvaluatedStyle<T>> {
protected[$intrinsics]: T;
protected[$evaluatables]: Array<Evaluatable<NumberNode|IdentNode>>;
constructor(expressions: Array<ExpressionNode>, intrinsics: T) {
super();
this[$intrinsics] = intrinsics;
const firstExpression = expressions[0];
const terms = firstExpression != null ? firstExpression.terms : [];
this[$evaluatables] =
intrinsics.basis.map<Evaluatable<NumberNode|IdentNode>>(
(basisNode, index) => {
const term = terms[index];
if (term == null) {
return {type: 'ident', value: 'auto'};
}
if (term.type === 'ident') {
return term;
}
return Evaluator.evaluatableFor(term, basisNode);
});
}
get isConstant(): boolean {
for (const evaluatable of this[$evaluatables]) {
if (!Evaluator.isConstant(evaluatable)) {
return false;
}
}
return true;
}
[$evaluate]() {
const evaluated = this[$evaluatables].map<NumberNode|IdentNode>(
evaluatable => Evaluator.evaluate(evaluatable));
return Evaluator.applyIntrinsics(evaluated, this[$intrinsics])
.map<number>(numberNode => numberNode.number) as
EvaluatedStyle<T>;
}
}
// SphericalIntrinsics are Intrinsics that expect two angle terms
// and one length term
export type SphericalIntrinsics = Intrinsics<['rad', 'rad', 'm']>;
// Vector3Intrinsics expect three length terms
export type Vector3Intrinsics = Intrinsics<['m', 'm', 'm']>;
+350
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});
@@ -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'});
};
}
+379
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);
};
@@ -0,0 +1,74 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {ReactiveElement} from 'lit';
import {property} from 'lit/decorators.js';
import {style} from '../decorators.js';
import {numberNode} from '../styles/parsers.js';
import {timePasses} from '../utilities.js';
const $updateFoo = Symbol('updateFoo');
const fooIntrinsics = {
basis: [numberNode(1, 'm'), numberNode(Math.PI, 'rad')],
keywords: {auto: [null, numberNode(200, '%')]}
};
class StylableElement extends ReactiveElement {
@style({intrinsics: fooIntrinsics, updateHandler: $updateFoo})
@property({type: String})
foo: string = '200cm 1rad';
fooUpdates: Array<[number, number]> = [];
[$updateFoo](style: [number, number]) {
this.fooUpdates.push(style);
}
}
suite('decorators', () => {
suite('@style', () => {
let instance = 0;
let tagName: string;
let element: StylableElement;
setup(async () => {
tagName = `stylable-element-${instance++}`;
customElements.define(tagName, class extends StylableElement {});
element = document.createElement(tagName) as StylableElement;
document.body.insertBefore(element, document.body.firstChild);
await timePasses();
});
teardown(() => {
document.body.removeChild(element);
});
test('invokes the update handler with the parsed default value', () => {
expect(element.fooUpdates).to.be.eql([[2, 1]]);
});
test(
'invokes the update handler once with a parsed updated value',
async () => {
element.foo = '1m auto';
await timePasses();
expect(element.fooUpdates).to.be.eql([[2, 1], [1, 2 * Math.PI]]);
});
});
});
@@ -0,0 +1,298 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {$scene} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {timePasses, waitForEvent} from '../../utilities.js';
import {assetPath} from '../helpers.js';
const TOLERANCE_SEC = 0.1;
const NON_ANIMATED_GLB_PATH = assetPath('models/Astronaut.glb');
const ANIMATED_GLB_PATH = assetPath('models/RobotExpressive.glb');
const ANIMATED_GLB_DUPLICATE_ANIMATION_NAMES_PATH =
assetPath('models/DuplicateAnimationNames.glb');
const animationIsPlaying = (element: any, animationName?: string): boolean => {
const {currentAnimationAction} = element[$scene];
if (currentAnimationAction != null &&
(animationName == null ||
currentAnimationAction.getClip().name === animationName)) {
return element.paused === false &&
currentAnimationAction.enabled === true &&
!currentAnimationAction.paused;
}
return false;
};
const animationWithIndexIsPlaying = (element: any, animationIndex = 0):
boolean => {
const {currentAnimationAction} = element[$scene];
const {_currentGLTF} = element[$scene];
if (currentAnimationAction != null && animationIndex >= 0 &&
animationIndex < _currentGLTF.animations.length &&
currentAnimationAction.getClip() ==
_currentGLTF.animations[animationIndex]) {
return element.paused === false &&
currentAnimationAction.enabled === true &&
!currentAnimationAction.paused;
}
return false;
}
suite('Animation', () => {
suite('a model with animations', () => {
let element: ModelViewerElement;
setup(async () => {
element = new ModelViewerElement();
element.src = ANIMATED_GLB_PATH;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'poster-dismissed');
});
teardown(() => {
document.body.removeChild(element);
});
test('remains in a paused state', () => {
expect(element.paused).to.be.true;
});
suite('when play is invoked with no options', () => {
setup(async () => {
const animationsPlay = waitForEvent(element, 'play');
element.play();
await animationsPlay;
});
test('animations play', async () => {
expect(animationIsPlaying(element)).to.be.true;
});
test('has a duration greater than 0', () => {
expect(element.duration).to.be.greaterThan(0);
});
suite('when pause is invoked after a delay', () => {
const delaySeconds = 0.2;
setup(async () => {
await timePasses(1000 * delaySeconds);
const animationsPause = waitForEvent(element, 'pause');
element.pause();
await animationsPause;
});
test('animations pause', () => {
expect(animationIsPlaying(element)).to.be.false;
});
test.skip('has a current time close to the delay', () => {
expect(element.currentTime)
.to.be.closeTo(delaySeconds, TOLERANCE_SEC);
});
test('changing currentTime triggers render', () => {
element.currentTime = 5;
expect(element[$scene].shouldRender()).to.be.true;
});
suite('when play is invoked again', () => {
setup(async () => {
const animationsPlay = waitForEvent(element, 'play');
element.play();
await animationsPlay;
});
test('animations play', () => {
expect(animationIsPlaying(element)).to.be.true;
});
test('has a duration greater than 0', () => {
expect(element.duration).to.be.greaterThan(0);
});
test.skip('has a current time close to the delay', () => {
expect(element.currentTime)
.to.be.closeTo(delaySeconds, TOLERANCE_SEC);
});
})
});
});
suite('when play is invoked with options', () => {
setup(async () => {
element.animationName = 'Punch';
await element.updateComplete;
const animationsPlay = waitForEvent(element, 'play');
element.play({repetitions: 2, pingpong: true});
await animationsPlay;
});
test.skip('plays forward, backward, and stops', async () => {
await timePasses(element.duration * 0.8 * 1000);
expect(animationIsPlaying(element), 'failed to start playing!')
.to.be.true;
const t = element.currentTime;
await timePasses(element.duration * 1.0 * 1000);
expect(animationIsPlaying(element), 'not playing after 1.8 * duration!')
.to.be.true;
expect(element.currentTime, 'not playing backwards!').to.be.lessThan(t);
await timePasses(element.duration * 0.4 * 1000);
expect(animationIsPlaying(element), 'failed to stop playing!')
.to.be.false;
expect(element.currentTime, 'did not return to beginning of animation!')
.to.be.equal(0);
});
});
suite('when configured to autoplay', () => {
setup(async () => {
element.autoplay = true;
await timePasses();
});
test('plays an animation', () => {
expect(animationIsPlaying(element)).to.be.true;
});
test('has a duration greater than 0', () => {
expect(element.duration).to.be.greaterThan(0);
});
test('plays the first animation by default', () => {
expect(animationIsPlaying(element, element.availableAnimations[0]))
.to.be.true;
});
suite('with a specified animation-name', () => {
setup(async () => {
element.animationName = element.availableAnimations[1];
await timePasses();
});
test('plays the specified animation', () => {
expect(animationIsPlaying(element, element.availableAnimations[1]))
.to.be.true;
});
});
suite('with an invalid animation-name', () => {
setup(async () => {
element.animationName = 'invalid-animation-name';
await timePasses();
});
test('plays the first animation', () => {
expect(animationIsPlaying(element, element.availableAnimations[0]))
.to.be.true;
});
});
suite('with a specified index as animation-name', () => {
setup(async () => {
element.animationName = '1';
await timePasses();
});
test('plays the specified animation', () => {
expect(animationWithIndexIsPlaying(element, 1)).to.be.true;
});
});
suite('with an invalid index as animation-name', () => {
setup(async () => {
element.animationName = '-1';
await timePasses();
});
test('plays the first animation', () => {
expect(animationWithIndexIsPlaying(element, 0)).to.be.true;
});
});
suite('a model with duplicate animation names', () => {
setup(async () => {
element.src = ANIMATED_GLB_DUPLICATE_ANIMATION_NAMES_PATH;
await waitForEvent(element, 'load');
element.animationName = '1';
await timePasses();
});
test('plays the specified animation', () => {
expect(animationWithIndexIsPlaying(element, 1)).to.be.true;
});
suite('when playing a duplicate animation by name', () => {
setup(async () => {
element.animationName = element.availableAnimations[1];
await timePasses();
});
test(
'fails to play the specified animation and plays the last animation with that name instead',
() => {
expect(animationWithIndexIsPlaying(element, 3)).to.be.true;
});
});
});
suite('a model without animations', () => {
setup(async () => {
element.src = NON_ANIMATED_GLB_PATH;
await waitForEvent(element, 'load');
});
test('does not play an animation', () => {
expect(animationIsPlaying(element)).to.be.false;
});
test('has a duration of 0', () => {
expect(element.duration).to.be.equal(0);
});
suite('with a specified animation-name', () => {
setup(async () => {
element.animationName = element.availableAnimations[1];
await timePasses();
});
test('does not play an animation', () => {
expect(animationIsPlaying(element)).to.be.false;
});
});
suite('with a specified animation by index', () => {
setup(async () => {
element.animationName = '1';
await timePasses();
});
test('does not play an animation', () => {
expect(animationIsPlaying(element)).to.be.false;
});
});
});
});
});
});
@@ -0,0 +1,278 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Vector3} from 'three';
import {$needsRender, $scene, toVector3D, Vector2D, Vector3D} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {Hotspot} from '../../three-components/Hotspot.js';
import {ModelScene} from '../../three-components/ModelScene.js';
import {timePasses, waitForEvent} from '../../utilities.js';
import {assetPath, rafPasses} from '../helpers.js';
const sceneContainsHotspot =
(scene: ModelScene, element: HTMLElement): boolean => {
const {children} = scene.target;
for (let i = 0, l = children.length; i < l; i++) {
const hotspot = children[i];
if (hotspot instanceof Hotspot &&
(hotspot.element.children[0].children[0] as HTMLSlotElement)
.name === element.slot) {
// expect it has been changed from default
expect(hotspot.position).to.not.eql(new Vector3());
expect(hotspot.normal).to.not.eql(new Vector3(0, 1, 0));
return true;
}
}
return false;
};
const closeToVector3 = (a: Vector3D, b: Vector3) => {
const delta = 0.001;
expect(a.x).to.be.closeTo(b.x, delta);
expect(a.y).to.be.closeTo(b.y, delta);
expect(a.z).to.be.closeTo(b.z, delta);
};
const withinRange = (a: Vector2D, start: number, finish: number) => {
expect(a.u).to.be.within(start, finish);
expect(a.v).to.be.within(start, finish);
};
suite('Annotation', () => {
let element: ModelViewerElement;
let scene: ModelScene;
setup(async () => {
element = new ModelViewerElement();
document.body.insertBefore(element, document.body.firstChild);
scene = element[$scene];
element.src = assetPath('models/cube.gltf');
await waitForEvent(element, 'poster-dismissed');
});
teardown(() => {
if (element.parentNode != null) {
element.parentNode.removeChild(element);
}
});
suite('a model-viewer element with a hotspot', () => {
let hotspot: HTMLElement;
let numSlots: number;
setup(async () => {
hotspot = document.createElement('div');
hotspot.setAttribute('slot', 'hotspot-1');
hotspot.setAttribute('data-position', '1m 1m 1m');
hotspot.setAttribute('data-normal', '0m 0m -1m');
element.appendChild(hotspot);
await timePasses();
numSlots = scene.target.children.length;
});
teardown(() => {
if (hotspot.parentElement === element) {
element.removeChild(hotspot);
}
});
test('creates a corresponding slot', () => {
expect(sceneContainsHotspot(scene, hotspot)).to.be.true;
});
test.skip('querying it returns valid data', () => {
// to test querying, place hotspot in the center and verify the screen
// position is half the default width and height (300 x 150) with a depth
// value of ~1.
const defaultDimensions = {width: 300, height: 150};
element.updateHotspot({name: 'hotspot-1', position: `0m 0m 0m`});
const hotspotData = element.queryHotspot('hotspot-1');
expect(hotspotData?.canvasPosition.x)
.to.be.closeTo(defaultDimensions.width / 2, 0.0001);
expect(hotspotData?.canvasPosition.y)
.to.be.closeTo(defaultDimensions.height / 2, 0.0001);
expect(hotspotData?.position.toString())
.to.equal(toVector3D(new Vector3(0, 0, 0)).toString());
expect(hotspotData?.normal.toString())
.to.equal(toVector3D(new Vector3(0, 0, -1)).toString());
expect(hotspotData?.facingCamera).to.be.true;
});
suite('adding a second hotspot with the same name', () => {
let hotspot2: HTMLElement;
setup(async () => {
hotspot2 = document.createElement('div');
hotspot2.setAttribute('slot', 'hotspot-1');
hotspot2.setAttribute('data-position', '0m 1m 2m');
hotspot2.setAttribute('data-normal', '1m 0m 0m');
element.appendChild(hotspot2);
await timePasses();
});
teardown(() => {
if (hotspot2.parentElement === element) {
element.removeChild(hotspot2);
}
});
test('does not change the slot', () => {
expect(scene.target.children.length).to.be.equal(numSlots);
});
test('does not change the data', () => {
const {position, normal} =
(scene.target.children[numSlots - 1] as Hotspot);
expect(position).to.be.deep.equal(new Vector3(1, 1, 1));
expect(normal).to.be.deep.equal(new Vector3(0, 0, -1));
});
test('updateHotspot does change the data', () => {
element.updateHotspot(
{name: 'hotspot-1', position: '0m 1m 2m', normal: '1m 0m 0m'});
const {position, normal} =
(scene.target.children[numSlots - 1] as Hotspot);
expect(position).to.be.deep.equal(new Vector3(0, 1, 2));
expect(normal).to.be.deep.equal(new Vector3(1, 0, 0));
});
test('updateHotspot does change the surface', () => {
const hotspot = scene.target.children[numSlots - 1] as Hotspot;
const {x} = hotspot.position;
const surface = '0 0 1 2 3 0.217 0.341 0.442';
element.updateHotspot({name: 'hotspot-1', surface});
expect(x).to.not.be.equal(hotspot.position.x);
});
test('and removing it does not remove the slot', async () => {
element.removeChild(hotspot);
await timePasses();
expect(scene.target.children.length).to.be.equal(numSlots);
});
test('but removing both does remove the slot', async () => {
element.removeChild(hotspot);
element.removeChild(hotspot2);
await timePasses();
expect(scene.target.children.length).to.be.equal(numSlots - 1);
});
suite('with a camera', () => {
let wrapper: HTMLElement;
setup(async () => {
// This is to wait for the hotspots to be added to their slots, as
// this triggers their visibility to "show". Otherwise, sometimes the
// following hide() call will happen first, then when the camera
// moves, we never get a hotspot-visibility event because they were
// already visible.
await rafPasses();
wrapper = (scene.target.children[numSlots - 1] as Hotspot).element;
});
test('the hotspot is hidden', async () => {
expect(wrapper.classList.contains('hide')).to.be.true;
});
test('the hotspot is visible after turning', async () => {
element[$scene].yaw = Math.PI;
element[$scene].updateMatrixWorld();
element[$needsRender]();
await waitForEvent(hotspot2, 'hotspot-visibility');
expect(!!wrapper.classList.contains('hide')).to.be.false;
});
});
});
});
suite('a model-viewer element with a loaded cube', () => {
let rect: DOMRect;
setup(async () => {
element.setAttribute('style', `width: 200px; height: 300px`);
rect = element.getBoundingClientRect();
element.cameraOrbit = '0deg 90deg 2m';
element.jumpCameraToGoal();
await rafPasses();
});
test('gets expected hit result', async () => {
await rafPasses();
const hitResult = element.positionAndNormalFromPoint(
rect.width / 2 + rect.x, rect.height / 2 + rect.y);
expect(hitResult).to.be.ok;
const {position, normal, uv} = hitResult!;
closeToVector3(position, new Vector3(0, 0, 0.5));
closeToVector3(normal, new Vector3(0, 0, 1));
if (uv != null) {
withinRange(uv, 0, 1);
}
});
test('gets expected hit result when turned', async () => {
element.resetTurntableRotation(-Math.PI / 2);
await rafPasses();
const hitResult = element.positionAndNormalFromPoint(
rect.width / 2 + rect.x, rect.height / 2 + rect.y);
expect(hitResult).to.be.ok;
const {position, normal, uv} = hitResult!;
closeToVector3(position, new Vector3(0.5, 0, 0));
closeToVector3(normal, new Vector3(1, 0, 0));
if (uv != null) {
withinRange(uv, 0, 1);
}
});
test('returns a surface that shows and hides appropriately', async () => {
await rafPasses();
const surface = element.surfaceFromPoint(
rect.width / 2 + rect.x, rect.height / 2 + rect.y);
expect(surface).to.be.ok;
const hotspot = document.createElement('div');
hotspot.setAttribute('slot', 'hotspot-1');
hotspot.setAttribute('data-surface', surface!);
element.appendChild(hotspot);
await rafPasses();
expect(sceneContainsHotspot(scene, hotspot)).to.be.true;
const numSlots = scene.target.children.length;
const wrapper = (scene.target.children[numSlots - 1] as Hotspot).element;
expect(wrapper.classList.contains('hide')).to.be.false;
element[$scene].yaw = Math.PI;
element[$scene].updateMatrixWorld();
element[$needsRender]();
await waitForEvent(hotspot, 'hotspot-visibility');
expect(wrapper.classList.contains('hide')).to.be.true;
});
});
});
@@ -0,0 +1,209 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {IS_ANDROID, IS_IOS} from '../../constants.js';
import {$openIOSARQuickLook, $openSceneViewer} from '../../features/ar.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {waitForEvent} from '../../utilities.js';
import {assetPath, rafPasses, spy} from '../helpers.js';
suite('AR', () => {
let element: ModelViewerElement;
let intentUrls: Array<string>;
let restoreAnchorClick: () => void;
setup(() => {
element = new ModelViewerElement();
document.body.insertBefore(element, document.body.firstChild);
intentUrls = [];
restoreAnchorClick = spy(HTMLAnchorElement.prototype, 'click', {
value: function() {
intentUrls.push((this as HTMLAnchorElement).href);
}
});
});
teardown(() => {
if (element.parentNode != null) {
element.parentNode.removeChild(element);
}
restoreAnchorClick();
});
suite('openSceneViewer', () => {
test('preserves query parameters in model URLs', () => {
element.src = 'https://example.com/model.gltf?token=foo';
element.alt = 'Example model';
(element as any)[$openSceneViewer]();
expect(intentUrls.length).to.be.equal(1);
const search = new URLSearchParams(new URL(intentUrls[0]).search);
expect(search.get('token')).to.equal('foo');
});
test('keeps title and link when supplied', () => {
element.src =
'https://example.com/model.gltf?link=http://linkme.com&title=bar';
element.alt = 'alt';
(element as any)[$openSceneViewer]();
expect(intentUrls.length).to.be.equal(1);
const search = new URLSearchParams(new URL(intentUrls[0]).search);
expect(search.get('title')).to.equal('bar');
expect(search.get('link')).to.equal('http://linkme.com/');
});
test('sets sound and link to absolute URLs', () => {
element.src =
'https://example.com/model.gltf?link=foo.html&sound=bar.ogg';
element.alt = 'alt';
(element as any)[$openSceneViewer]();
expect(intentUrls.length).to.be.equal(1);
const search = new URLSearchParams(new URL(intentUrls[0]).search);
// Tests run in different locations
expect(search.get('sound')).to.contain('http://');
expect(search.get('sound')).to.contain('/bar.ogg');
expect(search.get('link')).to.contain('http://');
expect(search.get('link')).to.contain('/foo.html');
});
test('strips hash params from SceneViewer model src', () => {
element.src =
'https://example.com/model.gltf#applePayButtonType=plain&checkoutTitle=TitleText';
element.alt = 'alt';
(element as any)[$openSceneViewer]();
expect(intentUrls.length).to.be.equal(1);
const search = new URLSearchParams(new URL(intentUrls[0]).search);
const file = new URL(search.get('file') as any);
expect(file.hash).to.equal('');
});
test('strips hash params but preserves query params', () => {
element.src =
'https://example.com/model.gltf?link=http://linkme.com&title=bar#applePayButtonType=plain&checkoutTitle=TitleText';
element.alt = 'alt';
(element as any)[$openSceneViewer]();
expect(intentUrls.length).to.be.equal(1);
const search = new URLSearchParams(new URL(intentUrls[0]).search);
const file = new URL(search.get('file') as any);
expect(file.hash).to.equal('');
expect(search.get('title')).to.equal('bar');
expect(search.get('link')).to.equal('http://linkme.com/');
});
});
suite('openQuickLook', () => {
test('sets hash for fixed scale', () => {
element.src = 'https://example.com/model.gltf';
element.iosSrc = 'https://example.com/model.usdz';
element.arScale = 'fixed';
(element as any)[$openIOSARQuickLook]();
expect(intentUrls.length).to.be.equal(1);
const url = new URL(intentUrls[0]);
expect(url.pathname).equal('/model.usdz');
expect(url.hash).to.equal('#allowsContentScaling=0');
});
test('keeps original hash too', () => {
element.src = 'https://example.com/model.gltf';
element.iosSrc =
'https://example.com/model.usdz#custom=path-to-banner.html';
element.arScale = 'fixed';
(element as any)[$openIOSARQuickLook]();
expect(intentUrls.length).to.be.equal(1);
const url = new URL(intentUrls[0]);
expect(url.pathname).equal('/model.usdz');
expect(url.hash).to.equal(
'#custom=path-to-banner.html&allowsContentScaling=0');
});
test('replicate src hash to usdz blob url', async () => {
element.src =
assetPath('models/cube.gltf') + '#custom=path-to-banner.html';
element.arModes = 'webxr scene-viewer quick-look';
await (element as any)[$openIOSARQuickLook]();
expect(intentUrls.length).to.be.equal(1);
const url = new URL(intentUrls[0]);
expect(url.protocol).to.equal('blob:');
expect(url.hash).to.equal('#custom=path-to-banner.html');
});
test(
'replicate src hash to usdz blob and set hash for fixed scale',
async () => {
element.src =
assetPath('models/cube.gltf') + '#custom=path-to-banner.html';
element.arModes = 'webxr scene-viewer quick-look';
element.arScale = 'fixed';
await (element as any)[$openIOSARQuickLook]();
expect(intentUrls.length).to.be.equal(1);
const url = new URL(intentUrls[0]);
expect(url.protocol).to.equal('blob:');
expect(url.hash).to.equal(
'#custom=path-to-banner.html&allowsContentScaling=0');
});
});
suite('shows the AR button', () => {
setup(async () => {
element.ar = true;
element.src = assetPath('models/Astronaut.glb');
await waitForEvent(element, 'poster-dismissed');
});
test('on Android', () => {
expect(element.canActivateAR).to.be.equal(IS_ANDROID);
});
// This only works on a physical iOS device, not an emulated one.
test.skip('with an ios-src on iOS', async () => {
element.iosSrc = assetPath('models/Astronaut.usdz');
await rafPasses();
expect(element.canActivateAR).to.be.equal(IS_ANDROID || IS_IOS);
});
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,281 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Texture} from 'three';
import {BASE_OPACITY} from '../../features/environment.js';
import ModelViewerElementBase, {$scene} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {ModelScene} from '../../three-components/ModelScene.js';
import {Renderer} from '../../three-components/Renderer.js';
import {timePasses, waitForEvent} from '../../utilities.js';
import {assetPath, rafPasses} from '../helpers.js';
const ALT_BG_IMAGE_URL = assetPath('environments/white_furnace.hdr');
const HDR_BG_IMAGE_URL = assetPath('environments/spruit_sunrise_1k_HDR.hdr');
const MODEL_URL = assetPath('models/reflective-sphere.gltf');
/**
* Returns a promise that resolves when a given element is loaded
* and has an environment map set that matches the passed in meta.
*/
const waitForLoadAndEnvMap = (element: ModelViewerElementBase) => {
const load = waitForEvent(element, 'poster-dismissed');
const envMap = waitForEvent(element, 'environment-change');
return Promise.all([load, envMap]);
};
suite('Environment', () => {
suiteTeardown(() => {
Renderer.resetSingleton();
});
let element: ModelViewerElement;
let scene: ModelScene;
setup(() => {
element = new ModelViewerElement();
scene = element[$scene];
});
teardown(() => element.parentNode && element.parentNode.removeChild(element));
test('only generates an environment when in the render tree', async () => {
let environmentChangeCount = 0;
const environmentChangeHandler = () => environmentChangeCount++;
element.addEventListener('environment-change', environmentChangeHandler);
element.style.display = 'none';
element.src = MODEL_URL;
document.body.insertBefore(element, document.body.firstChild);
await rafPasses();
expect(environmentChangeCount).to.be.eq(0);
element.style.display = 'block';
await waitForEvent(element, 'environment-change');
expect(environmentChangeCount).to.be.eq(1);
element.removeEventListener('environment-change', environmentChangeHandler);
});
suite('with no skybox-image property', () => {
let environmentChanges = 0;
suite('and a src property', () => {
setup(async () => {
const onLoad = waitForLoadAndEnvMap(element);
element.src = MODEL_URL;
document.body.insertBefore(element, document.body.firstChild);
environmentChanges = 0;
element.addEventListener('environment-change', () => {
environmentChanges++;
});
await onLoad;
});
teardown(() => {
document.body.removeChild(element);
});
test('applies a generated environment map on model', async function() {
expect(scene.environment!.name).to.be.eq('neutral');
});
test('changes the environment exactly once', async function() {
expect(environmentChanges).to.be.eq(1);
});
});
});
suite('exposure', () => {
setup(async () => {
element.src = MODEL_URL;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'poster-dismissed');
scene.visible = true;
});
teardown(() => {
document.body.removeChild(element);
});
test('changes the tone mapping exposure of the renderer', async () => {
const renderer = Renderer.singleton;
const originalToneMappingExposure =
renderer.threeRenderer.toneMappingExposure;
element.exposure = 2.0;
await timePasses();
renderer.render(performance.now());
const newToneMappingExposure = renderer.threeRenderer.toneMappingExposure;
expect(newToneMappingExposure)
.to.be.greaterThan(originalToneMappingExposure);
});
});
suite('shadow-intensity', () => {
setup(async () => {
element.src = MODEL_URL;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'poster-dismissed');
});
teardown(() => {
document.body.removeChild(element);
});
test('changes the opacity of the static shadow', async () => {
element.shadowIntensity = 1.0;
await timePasses();
const newIntensity = scene.shadow!.getIntensity();
expect(newIntensity).to.be.eq(BASE_OPACITY);
});
});
suite('environment-image', () => {
setup(async () => {
const onLoad = waitForLoadAndEnvMap(element);
element.setAttribute('src', MODEL_URL);
element.setAttribute('environment-image', HDR_BG_IMAGE_URL);
document.body.insertBefore(element, document.body.firstChild);
await onLoad;
});
teardown(() => {
document.body.removeChild(element);
});
test('applies environment-image environment map on model', () => {
expect(scene.environment!.name).to.be.eq(element.environmentImage);
});
suite('and environment-image subsequently removed', () => {
setup(async () => {
const envMapChanged = waitForEvent(element, 'environment-change');
element.removeAttribute('environment-image');
await envMapChanged;
});
test('reapplies generated environment map on model', () => {
expect(scene.environment!.name).to.be.eq('neutral');
});
});
});
suite('with skybox-image property', () => {
setup(async () => {
const onLoad = waitForLoadAndEnvMap(element);
element.setAttribute('src', MODEL_URL);
element.setAttribute('skybox-image', HDR_BG_IMAGE_URL);
document.body.insertBefore(element, document.body.firstChild);
await onLoad;
});
teardown(() => {
document.body.removeChild(element);
});
test('displays background with skybox-image', async function() {
expect((scene.background as Texture).name).to.be.eq(element.skyboxImage);
});
test('applies skybox-image environment map on model', async function() {
expect(scene.environment!.name).to.be.eq(element.skyboxImage);
});
test('has tight radius', async function() {
expect(scene.farRadius()).to.be.lessThan(2);
});
suite('with skybox-height property', () => {
setup(async () => {
element.setAttribute('skybox-height', '1m');
await element.updateComplete;
});
test('switches background', async function() {
expect(scene.background).to.be.null;
});
test('has wide radius', async function() {
expect(scene.farRadius()).to.be.greaterThan(2);
});
test('no skybox-image disables grounded skybox', async function() {
element.setAttribute('skybox-image', '');
await element.updateComplete;
await rafPasses();
await rafPasses();
expect(scene.farRadius()).to.be.lessThan(2);
});
});
suite('with an environment-image', () => {
setup(async () => {
const environmentChanged = waitForEvent(element, 'environment-change');
element.setAttribute('environment-image', ALT_BG_IMAGE_URL);
await environmentChanged;
});
test('prefers environment-image as environment map', () => {
expect(scene.environment!.name).to.be.eq(ALT_BG_IMAGE_URL);
});
suite('and environment-image subsequently removed', () => {
setup(async () => {
const environmentChanged =
waitForEvent(element, 'environment-change');
element.removeAttribute('environment-image');
await environmentChanged;
});
test('uses skybox-image as environment map', () => {
expect(scene.environment!.name).to.be.eq(HDR_BG_IMAGE_URL);
});
});
suite('and skybox-image subsequently removed', () => {
setup(async () => {
element.removeAttribute('skybox-image');
await element.updateComplete;
await rafPasses();
});
test('continues using environment-image as environment map', () => {
expect(scene.environment!.name).to.be.eq(ALT_BG_IMAGE_URL);
});
test('removes the background', async function() {
expect(scene.background).to.be.null;
});
});
});
suite('and skybox-image subsequently removed', () => {
setup(async () => {
const envMapChanged = waitForEvent(element, 'environment-change');
element.removeAttribute('skybox-image');
await envMapChanged;
});
test('removes the background', async function() {
expect(scene.background).to.be.null;
});
test('reapplies generated environment map on model', async function() {
expect(scene.environment!.name).to.be.eq('neutral');
});
});
});
});
@@ -0,0 +1,364 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {$defaultPosterElement, $posterContainerElement} from '../../features/loading.js';
import {$scene, $userInputElement} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {CachingGLTFLoader} from '../../three-components/CachingGLTFLoader.js';
import {timePasses, waitForEvent} from '../../utilities.js';
import {assetPath, pickShadowDescendant, rafPasses, until} from '../helpers.js';
const CUBE_GLB_PATH = assetPath('models/cube.gltf');
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
suite('Loading', () => {
let element: ModelViewerElement;
let firstChild: ChildNode|null;
setup(async () => {
element = new ModelViewerElement();
firstChild = document.body.firstChild;
document.body.insertBefore(element, firstChild);
// Wait at least a microtask for size calculations
await timePasses();
});
teardown(() => {
CachingGLTFLoader.clearCache();
if (element.parentNode != null) {
element.parentNode.removeChild(element);
}
});
suite('with a second element outside the viewport', () => {
let element2: ModelViewerElement;
setup(async () => {
element2 = new ModelViewerElement();
element2.loading = 'eager';
document.body.insertBefore(element2, firstChild);
element.style.height = '100vh';
element2.style.height = '100vh';
const load1 = waitForEvent(element, 'load');
const load2 = waitForEvent(element2, 'load');
element.src = CUBE_GLB_PATH;
element2.src = CUBE_GLB_PATH;
await Promise.all([load1, load2]);
});
teardown(() => {
if (element2.parentNode != null) {
element2.parentNode.removeChild(element2);
}
});
test('first element is visible', () => {
expect(element.modelIsVisible).to.be.true;
});
test('second element is not visible', () => {
expect(element2.modelIsVisible).to.be.false;
});
suite('scroll to second element', () => {
setup(() => {
element2.scrollIntoView();
});
test('first element is not visible', async () => {
await waitForEvent<CustomEvent>(
element,
'model-visibility',
event => event.detail.visible === false);
});
test('second element is visible', async () => {
await waitForEvent<CustomEvent>(
element2,
'model-visibility',
event => event.detail.visible === true);
});
});
});
test('creates a poster element that captures interactions', async () => {
const picked = pickShadowDescendant(element);
expect(picked).to.be.ok;
// TODO(cdata): Leaky internal details here:
expect(picked!.id).to.be.equal('default-poster');
});
test('does not load when hidden from render tree', async () => {
let loadDispatched = false;
const loadHandler = () => {
loadDispatched = true;
};
element.addEventListener('load', loadHandler);
element.style.display = 'none';
// Give IntersectionObserver a chance to notify. In Chrome, this takes
// two rAFs (empirically observed). Await extra time just in case:
await timePasses(100);
element.src = CUBE_GLB_PATH;
await timePasses(500); // Arbitrary time to allow model to load
element.removeEventListener('load', loadHandler);
expect(loadDispatched).to.be.false;
});
suite('load', () => {
suite('when a model src changes after loading', () => {
setup(async () => {
// The shadow is here to expose an earlier bug on unloading models.
element.shadowIntensity = 1;
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'poster-dismissed');
});
test('only dispatches load once per src change', async () => {
let loadCount = 0;
const onLoad = () => {
loadCount++;
};
element.addEventListener('load', onLoad);
try {
element.src = HORSE_GLB_PATH;
await waitForEvent(element, 'load');
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
// Give any late-dispatching events a chance to dispatch
await timePasses(300);
expect(loadCount).to.be.equal(2);
} finally {
element.removeEventListener('load', onLoad);
}
});
test('getDimensions() returns correct size', () => {
const size = element.getDimensions();
expect(size.x).to.be.eq(1);
expect(size.y).to.be.eq(1);
expect(size.z).to.be.eq(1);
});
test('models are unloaded after src updates', async () => {
element.src = HORSE_GLB_PATH;
await waitForEvent(element, 'load');
const {shadow, model, target} = element[$scene];
const {children} = target;
expect(children.length).to.be.eq(2, 'horse');
expect(children).to.contain(shadow, 'horse shadow');
expect(children).to.contain(model, 'horse model');
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const {children: children2} = target;
expect(children2.length).to.be.eq(2, 'cube');
expect(children2).to.contain(shadow, 'cube shadow');
expect(children2).to.contain(element[$scene].model, 'cube model');
});
test('generates 3DModel schema', async () => {
element.generateSchema = true;
await element.updateComplete;
const {schemaElement} = element[$scene];
expect(schemaElement.type).to.be.eq('application/ld+json');
expect(schemaElement.parentElement).to.be.eq(document.head);
const json = JSON.parse(schemaElement.textContent!);
const encoding = json.encoding[0];
expect(encoding.contentUrl).to.be.eq(CUBE_GLB_PATH);
expect(encoding.encodingFormat).to.be.eq('model/gltf+json');
element.generateSchema = false;
await element.updateComplete;
expect(schemaElement.parentElement).to.be.not.ok;
});
});
});
suite('loading', () => {
suite('src changes quickly', () => {
test('eventually notifies that current src is loaded', async () => {
element.loading = 'eager';
element.src = CUBE_GLB_PATH;
const loadCubeEvent =
waitForEvent(element, 'load') as Promise<CustomEvent>;
await timePasses();
element.src = HORSE_GLB_PATH;
const loadCube = await loadCubeEvent;
const loadHorse = await waitForEvent(element, 'load') as CustomEvent;
expect(loadCube.detail.url).to.be.eq(CUBE_GLB_PATH);
expect(loadHorse.detail.url).to.be.eq(HORSE_GLB_PATH);
});
});
suite('reveal', () => {
suite('auto', () => {
test('hides poster when element loads', async () => {
element.src = CUBE_GLB_PATH;
const input = element[$userInputElement];
expect(pickShadowDescendant(element))
.to.be.not.equal(
input, 'the poster should be shown until the model loads');
await waitForEvent(
element,
'model-visibility',
(event: any) => event.detail.visible);
await rafPasses();
expect(pickShadowDescendant(element)).to.be.equal(input);
element.reveal = 'manual';
await element.updateComplete;
await rafPasses();
expect(pickShadowDescendant(element))
.to.be.equal(input, 'changing reveal should not show the poster');
});
});
suite('manual', () => {
test('does not hide poster until dismissed', async () => {
element.loading = 'eager';
element.reveal = 'manual';
element.src = CUBE_GLB_PATH;
const posterElement = (element as any)[$defaultPosterElement];
const input = element[$userInputElement];
await waitForEvent(element, 'load');
posterElement.focus();
expect(element.shadowRoot!.activeElement).to.be.equal(posterElement);
element.dismissPoster();
await until(() => {
return element.shadowRoot!.activeElement === input;
});
});
});
});
});
suite('configuring poster via attribute', () => {
suite('removing the attribute', () => {
test('sets poster to null', async () => {
// NOTE(cdata): This is less important after we resolve
// https://github.com/PolymerLabs/model-viewer/issues/76
element.setAttribute('poster', CUBE_GLB_PATH);
await timePasses();
element.removeAttribute('poster');
await timePasses();
expect(element.poster).to.be.equal(null);
});
});
});
suite('with loaded model src', () => {
setup(() => {
element.src = CUBE_GLB_PATH;
});
test('can be hidden imperatively', async () => {
const ostensiblyThePoster = pickShadowDescendant(element);
element.dismissPoster();
await waitForEvent<CustomEvent>(
element, 'model-visibility', event => event.detail.visible === true);
await rafPasses();
const ostensiblyNotThePoster = pickShadowDescendant(element);
expect(ostensiblyThePoster).to.not.be.equal(ostensiblyNotThePoster);
});
suite('when poster is hidden', () => {
setup(async () => {
element.dismissPoster();
await waitForEvent<CustomEvent>(
element,
'model-visibility',
event => event.detail.visible === true);
await rafPasses();
});
test('allows the input to be interactive', async () => {
const input = element[$userInputElement];
const picked = pickShadowDescendant(element);
expect(picked).to.be.equal(input);
});
test('when src is reset, poster is dismissible', async () => {
const posterElement = (element as any)[$defaultPosterElement];
const posterContainer = (element as any)[$posterContainerElement];
const inputElement = element[$userInputElement];
element.reveal = 'manual';
element.src = null;
element.showPoster();
await timePasses();
element.src = CUBE_GLB_PATH;
await timePasses();
expect(posterContainer.classList.contains('show')).to.be.true;
posterElement.focus();
expect(element.shadowRoot!.activeElement).to.be.equal(posterElement);
element.dismissPoster();
await until(() => {
return element.shadowRoot!.activeElement === inputElement;
});
});
});
});
});
@@ -0,0 +1,399 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Mesh, MeshStandardMaterial} from 'three';
import {$currentGLTF} from '../../features/scene-graph.js';
import {$primitivesList} from '../../features/scene-graph/model.js';
import {PrimitiveNode} from '../../features/scene-graph/nodes/primitive-node.js';
import {$scene} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {ModelViewerGLTFInstance} from '../../three-components/gltf-instance/ModelViewerGLTFInstance.js';
import {ModelScene} from '../../three-components/ModelScene.js';
import {waitForEvent} from '../../utilities.js';
import {assetPath, rafPasses} from '../helpers.js';
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
const CUBES_GLB_PATH = assetPath('models/cubes.gltf'); // has variants
const MESH_PRIMITIVES_GLB_PATH =
assetPath('models/MeshPrimitivesVariants.glb'); // has variants
const CUBE_GLB_PATH = assetPath('models/cube.gltf'); // has UV coords
const RIGGEDFIGURE_GLB_PATH = assetPath(
'models/glTF-Sample-Assets/Models/RiggedFigure/glTF-Binary/RiggedFigure.glb');
function getGLTFRoot(scene: ModelScene, hasBeenExportedOnce = false) {
// TODO: export is putting in an extra node layer, because the loader
// gives us a Group, but if the exporter doesn't get a Scene, then it
// wraps everything in an "AuxScene" node. Feels like a three.js bug.
return hasBeenExportedOnce ? scene.model!.children[0] : scene.model!;
}
suite('SceneGraph', () => {
let element: ModelViewerElement;
setup(() => {
element = new ModelViewerElement();
document.body.insertBefore(element, document.body.firstChild);
});
teardown(() => {
document.body.removeChild(element);
});
suite('scene export', () => {
suite('transformations', () => {
test(
'setting scale before model loads has expected dimensions',
async () => {
element.scale = '1 2 3';
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const dim = element.getDimensions();
expect(dim.x).to.be.eq(1, 'x');
expect(dim.y).to.be.eq(2, 'y');
expect(dim.z).to.be.eq(3, 'z');
});
test('orientation is applied after scale', async () => {
element.orientation = '90deg 90deg 90deg';
element.scale = '1 2 3';
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const dim = element.getDimensions();
expect(dim.x).to.be.closeTo(1, 0.001, 'x');
expect(dim.y).to.be.closeTo(3, 0.001, 'y');
expect(dim.z).to.be.closeTo(2, 0.001, 'z');
});
test('exports and re-imports the rescaled model', async () => {
element.scale = '1 2 3';
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);
element.scale = '1 1 1';
element.src = url;
await waitForEvent(element, 'load');
await rafPasses();
const dim = element.getDimensions();
expect(dim.x).to.be.eq(1, 'x');
expect(dim.y).to.be.eq(2, 'y');
expect(dim.z).to.be.eq(3, 'z');
});
test('exports and re-imports the transformed model', async () => {
element.orientation = '90deg 90deg 90deg';
element.scale = '1 2 3';
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);
element.orientation = '0deg 0deg 0deg';
element.scale = '1 1 1';
element.src = url;
await waitForEvent(element, 'load');
await rafPasses();
const dim = element.getDimensions();
expect(dim.x).to.be.closeTo(1, 0.001, 'x');
expect(dim.y).to.be.closeTo(3, 0.001, 'y');
expect(dim.z).to.be.closeTo(2, 0.001, 'z');
});
});
suite('with a loaded model', () => {
setup(async () => {
element.src = CUBES_GLB_PATH;
await waitForEvent(element, 'load');
await rafPasses();
});
test('exports the loaded model to GLTF', async () => {
const exported = await element.exportScene({binary: false});
expect(exported).to.be.not.undefined;
expect(exported.size).to.be.greaterThan(500);
});
test('exports the loaded model to GLB', async () => {
const exported = await element.exportScene({binary: true});
expect(exported).to.be.not.undefined;
expect(exported.size).to.be.greaterThan(500);
});
test('has variants', () => {
expect(element[$scene].currentGLTF!.userData.variants.length)
.to.be.eq(3);
const gltfRoot = getGLTFRoot(element[$scene]);
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
});
test('allows the scene graph to be manipulated', async () => {
element.variantName = 'Yellow Red';
await waitForEvent(element, 'variant-applied');
const material =
(element[$scene].model!.children[1] as Mesh).material as
MeshStandardMaterial;
const mat = element.model!.getMaterialByName('red')!;
expect(mat.isActive).to.be.true;
mat.pbrMetallicRoughness.setBaseColorFactor([0.5, 0.5, 0.5, 1]);
const color = mat.pbrMetallicRoughness.baseColorFactor;
expect(color).to.be.eql([0.5, 0.5, 0.5, 1]);
console.log(material.name, ': actual material ', material.uuid);
expect(material.color).to.include({r: 0.5, g: 0.5, b: 0.5});
});
test(
`Setting variantName to null results in primitive
reverting to default/initial material`,
async () => {
let primitiveNode: PrimitiveNode|null = null
// Finds the first primitive with material 0 assigned.
for (const primitive of element.model![$primitivesList]) {
if (primitive.variantInfo != null &&
primitive.initialMaterialIdx == 0) {
primitiveNode = primitive;
return;
}
}
expect(primitiveNode).to.not.be.null;
// Switches to a new variant.
element.variantName = 'Yellow Red';
await waitForEvent(element, 'variant-applied');
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
.equal('red');
// Switches to null variant.
element.variantName = null;
await waitForEvent(element, 'variant-applied');
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
.equal('purple');
});
test('exports and re-imports the model with variants', async () => {
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);
element.src = url;
await waitForEvent(element, 'load');
await rafPasses();
expect(element[$scene].currentGLTF!.userData.variants.length)
.to.be.eq(3);
const gltfRoot = getGLTFRoot(element[$scene], true);
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
});
});
suite(
'with a loaded model containing a mesh with multiple primitives',
() => {
setup(async () => {
element.src = MESH_PRIMITIVES_GLB_PATH;
await waitForEvent(element, 'load');
await rafPasses();
});
test('has variants', () => {
expect(element[$scene].currentGLTF!.userData.variants.length)
.to.be.eq(2);
const gltfRoot = getGLTFRoot(element[$scene]);
expect(
gltfRoot.children[0].children[0].userData.variantMaterials.size)
.to.be.eq(2);
expect(
gltfRoot.children[0].children[1].userData.variantMaterials.size)
.to.be.eq(2);
expect(
gltfRoot.children[0].children[2].userData.variantMaterials.size)
.to.be.eq(2);
});
test(
`Setting variantName to null results in primitive
reverting to default/initial material`,
async () => {
let primitiveNode: PrimitiveNode|null = null
// Finds the first primitive with material 0 assigned.
for (const primitive of element.model![$primitivesList]) {
if (primitive.variantInfo != null &&
primitive.initialMaterialIdx == 0) {
primitiveNode = primitive;
return;
}
}
expect(primitiveNode).to.not.be.null;
// Switches to a new variant.
element.variantName = 'Inverse';
await waitForEvent(element, 'variant-applied');
expect(
(primitiveNode!.mesh.material as MeshStandardMaterial).name)
.equal('STEEL RED X');
// Switches to null variant.
element.variantName = null;
await waitForEvent(element, 'variant-applied');
expect(
(primitiveNode!.mesh.material as MeshStandardMaterial).name)
.equal('STEEL METALLIC');
});
test('exports and re-imports the model with variants', async () => {
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);
element.src = url;
await waitForEvent(element, 'load');
await rafPasses();
expect(element[$scene].currentGLTF!.userData.variants.length)
.to.be.eq(2);
const gltfRoot = getGLTFRoot(element[$scene], true);
expect(
gltfRoot.children[0].children[0].userData.variantMaterials.size)
.to.be.eq(2);
expect(
gltfRoot.children[0].children[1].userData.variantMaterials.size)
.to.be.eq(2);
expect(
gltfRoot.children[0].children[2].userData.variantMaterials.size)
.to.be.eq(2);
});
});
test.skip(
'When loading a new JPEG texture from an ObjectURL, the GLB does not export PNG',
async () => {
element.src = CUBE_GLB_PATH;
await waitForEvent(element, 'load');
await rafPasses();
const url = assetPath(
'models/glTF-Sample-Assets/Models/DamagedHelmet/glTF/Default_albedo.jpg');
const blob = await fetch(url).then(r => r.blob());
const objectUrl = URL.createObjectURL(blob);
const texture = await element.createTexture(objectUrl, 'image/jpeg');
element.model!.materials[0]
.pbrMetallicRoughness.baseColorTexture.setTexture(texture);
const exported = await element.exportScene({binary: true});
expect(exported).to.be.not.undefined;
// The JPEG is ~1 Mb and the equivalent PNG is about ~6 Mb, so this
// just checks we saved an image and it wasn't too big.
expect(exported.size).to.be.greaterThan(0.5e6);
expect(exported.size).to.be.lessThan(1.5e6);
});
});
suite('with a loaded scene graph', () => {
let material: MeshStandardMaterial;
setup(async () => {
element.src = ASTRONAUT_GLB_PATH;
await waitForEvent(element, 'load');
material =
(element[$scene].model!.children[0].children[0] as Mesh).material as
MeshStandardMaterial;
});
test('allows the scene graph to be manipulated', async () => {
await element.model!.materials[0].pbrMetallicRoughness.setBaseColorFactor(
[1, 0, 0, 1]);
expect(material.color).to.include({r: 1, g: 0, b: 0});
const color =
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
expect(color).to.be.eql([1, 0, 0, 1]);
});
suite('when the model changes', () => {
test('updates when the model changes', async () => {
const color =
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
expect(color).to.be.eql([0.5, 0.5, 0.5, 1]);
element.src = HORSE_GLB_PATH;
await waitForEvent(element, 'load');
const nextColor =
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
expect(nextColor).to.be.eql([1, 1, 1, 1]);
});
test('allows the scene graph to be manipulated', async () => {
element.src = HORSE_GLB_PATH;
await waitForEvent(element, 'load');
await element.model!.materials[0]
.pbrMetallicRoughness.setBaseColorFactor([1, 0, 0, 1]);
const color =
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
expect(color).to.be.eql([1, 0, 0, 1]);
const newMaterial =
(element[$scene].model!.children[0] as Mesh).material as
MeshStandardMaterial;
expect(newMaterial.color).to.include({r: 1, g: 0, b: 0});
});
});
suite('Scene-graph gltf-to-three mappings', () => {
test('has a mapping for each primitive mesh', async () => {
element.src = RIGGEDFIGURE_GLB_PATH;
await waitForEvent(element, 'load');
const gltf = (element as any)[$currentGLTF] as ModelViewerGLTFInstance;
for (const primitive of element.model![$primitivesList]) {
expect(gltf.correlatedSceneGraph.threeObjectMap.get(primitive.mesh))
.to.be.ok;
}
});
});
});
});
@@ -0,0 +1,274 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Material, MeshStandardMaterial, Texture as ThreeTexture} from 'three';
import {$threeTexture} from '../../../features/scene-graph/image.js';
import {$lazyLoadGLTFInfo} from '../../../features/scene-graph/material.js';
import {Model} from '../../../features/scene-graph/model.js';
import {Texture} from '../../../features/scene-graph/texture.js';
import {$correlatedObjects} from '../../../features/scene-graph/three-dom-element.js';
import {ModelViewerElement} from '../../../model-viewer.js';
import {waitForEvent} from '../../../utilities.js';
import {assetPath} from '../../helpers.js';
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
const HELMET_GLB_PATH = assetPath(
'models/glTF-Sample-Assets/Models/DamagedHelmet/glTF-Binary/DamagedHelmet.glb');
const ALPHA_BLEND_MODE_TEST = assetPath(
'models/glTF-Sample-Assets/Models/AlphaBlendModeTest/glTF-Binary/AlphaBlendModeTest.glb');
const REPLACEMENT_TEXTURE_PATH = assetPath(
'models/glTF-Sample-Assets/Models/BoxTextured/glTF/CesiumLogoFlat.png');
suite('scene-graph/material', () => {
suite('Test Texture Slots', () => {
let element: ModelViewerElement;
let texture: Texture|null;
let threeMaterials: Set<MeshStandardMaterial>;
setup(async () => {
element = new ModelViewerElement();
element.src = HELMET_GLB_PATH;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'load');
texture = await element.createTexture(REPLACEMENT_TEXTURE_PATH);
threeMaterials = element.model!.materials[0][$correlatedObjects] as
Set<MeshStandardMaterial>;
});
teardown(() => {
document.body.removeChild(element);
texture = null;
});
test('Set a new base map', async () => {
element.model!.materials[0]
.pbrMetallicRoughness.baseColorTexture.setTexture(texture);
// Gets new UUID to compare with UUID of texture accessible through the
// material.
const newUUID: string|undefined = texture?.source[$threeTexture].uuid;
const threeTexture: ThreeTexture =
element.model!.materials[0]
.pbrMetallicRoughness.baseColorTexture?.texture
?.source[$threeTexture]!;
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
expect(material.map).to.be.eq(threeTexture);
}
expect(threeTexture.uuid).to.be.equal(newUUID);
});
test('Set a new metallicRoughness map', async () => {
element.model!.materials[0]
.pbrMetallicRoughness.metallicRoughnessTexture.setTexture(texture);
// Gets new UUID to compare with UUID of texture accessible through the
// material.
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
const threeTexture: ThreeTexture =
element.model!.materials[0]
.pbrMetallicRoughness.metallicRoughnessTexture?.texture
?.source[$threeTexture]!;
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
expect(material.metalnessMap).to.be.eq(threeTexture);
expect(material.roughnessMap).to.be.eq(threeTexture);
}
expect(threeTexture.uuid).to.be.equal(newUUID);
});
test('Set a new normal map', async () => {
element.model!.materials[0].normalTexture.setTexture(texture);
// Gets new UUID to compare with UUID of texture accessible through the
// material.
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
const threeTexture: ThreeTexture =
element.model!.materials[0]
.normalTexture?.texture?.source[$threeTexture]!;
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
expect(material.normalMap).to.be.eq(threeTexture);
}
expect(threeTexture.uuid).to.be.equal(newUUID);
});
test('Set a new occlusion map', async () => {
element.model!.materials[0].occlusionTexture.setTexture(texture);
// Gets new UUID to compare with UUID of texture accessible through the
// material.
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
const threeTexture: ThreeTexture =
element.model!.materials[0]
.occlusionTexture?.texture?.source[$threeTexture]!;
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
expect(material.aoMap).to.be.eq(threeTexture);
}
expect(threeTexture.uuid).to.be.equal(newUUID);
});
test('Set a new emissive map', async () => {
element.model!.materials[0].emissiveTexture.setTexture(texture);
// Gets new UUID to compare with UUID of texture accessible through the
// material.
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
const threeTexture: ThreeTexture =
element.model!.materials[0]
.emissiveTexture?.texture?.source[$threeTexture]!;
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
expect(material.emissiveMap).to.be.eq(threeTexture);
}
expect(threeTexture.uuid).to.be.equal(newUUID);
});
});
suite('Material properties', () => {
let element: ModelViewerElement;
setup(async () => {
element = new ModelViewerElement();
});
teardown(() => {
document.body.removeChild(element);
});
const loadModel = async (path: string) => {
element.src = path;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'load');
};
test('test alpha cutoff expect disabled by default', async () => {
await loadModel(HELMET_GLB_PATH);
expect((element.model!.materials[0]![$correlatedObjects]
?.values()
.next()
.value as Material)
.alphaTest)
.to.be.equal(0);
});
test('test alpha cutoff expect valid value as default', async () => {
await loadModel(ALPHA_BLEND_MODE_TEST);
expect(element.model!.materials[2].getAlphaCutoff()).to.be.equal(0.25);
});
test('test alpha cutoff test setting and getting', async () => {
await loadModel(ALPHA_BLEND_MODE_TEST);
element.model!.materials[2].setAlphaCutoff(0.5);
expect(element.model!.materials[2].getAlphaCutoff()).to.be.equal(0.5);
});
test('test double sided expect default is false', async () => {
await loadModel(HELMET_GLB_PATH);
expect(element.model!.materials[0].getDoubleSided()).to.be.equal(false);
});
test('test double sided expect default is true', async () => {
await loadModel(ALPHA_BLEND_MODE_TEST);
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(true);
});
test('test double sided setting and getting', async () => {
await loadModel(ALPHA_BLEND_MODE_TEST);
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(true);
element.model!.materials[1].setDoubleSided(false);
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(false);
element.model!.materials[1].setDoubleSided(true);
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(true);
});
test('test alpha-mode, setting and getting', async () => {
await loadModel(ALPHA_BLEND_MODE_TEST);
element.model!.materials[0].setAlphaMode('BLEND');
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('BLEND');
element.model!.materials[0].setAlphaMode('MASK');
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('MASK');
element.model!.materials[0].setAlphaMode('OPAQUE');
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('OPAQUE');
});
test('test alpha-mode, expect default of opaque', async () => {
await loadModel(ALPHA_BLEND_MODE_TEST);
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('OPAQUE');
});
test('test alpha-mode, expect default of blend', async () => {
await loadModel(ALPHA_BLEND_MODE_TEST);
expect(element.model!.materials[1].getAlphaMode()).to.be.equal('BLEND');
});
test('test alpha-mode, expect default of mask', async () => {
await loadModel(ALPHA_BLEND_MODE_TEST);
expect(element.model!.materials[2].getAlphaMode()).to.be.equal('MASK');
});
});
suite('Material lazy loading', () => {
let element: ModelViewerElement;
let model: Model;
setup(async () => {
element = new ModelViewerElement();
element.src = CUBES_GLTF_PATH;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'load');
model = element.model as Model;
});
teardown(() => {
document.body.removeChild(element);
});
test('Accessing the name getter does not cause throw error.', async () => {
expect(model.materials[2].name).to.equal('red');
expect(model.materials[2][$lazyLoadGLTFInfo]).to.be.ok;
});
test(
'Accessing a getter of an unloaded material throws an error.',
async () => {
expect(() => {model.materials[2].pbrMetallicRoughness}).to.throw;
expect(model.materials[2].isLoaded).to.be.false;
});
test(
'Accessing a getter of a loaded material has valid data.', async () => {
await model.materials[2].ensureLoaded();
expect(model.materials[2].isLoaded).to.be.true;
expect(model.materials[2].name).to.equal('red');
const pbr = model.materials[2].pbrMetallicRoughness;
expect(pbr).to.be.ok;
});
});
});
@@ -0,0 +1,340 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {MeshStandardMaterial} from 'three/src/materials/MeshStandardMaterial.js';
import {Mesh} from 'three/src/objects/Mesh.js';
import {$lazyLoadGLTFInfo} from '../../../features/scene-graph/material.js';
import {$availableVariants, $materials, $primitivesList, $switchVariant, Model} from '../../../features/scene-graph/model.js';
import {$correlatedObjects} from '../../../features/scene-graph/three-dom-element.js';
import {$scene} from '../../../model-viewer-base.js';
import {ModelViewerElement} from '../../../model-viewer.js';
import {CorrelatedSceneGraph} from '../../../three-components/gltf-instance/correlated-scene-graph.js';
import {waitForEvent} from '../../../utilities.js';
import {assetPath, loadThreeGLTF, rafPasses} from '../../helpers.js';
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
const KHRONOS_TRIANGLE_GLB_PATH =
assetPath('models/glTF-Sample-Assets/Models/Triangle/glTF/Triangle.gltf');
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
suite('scene-graph/model', () => {
suite('Model', () => {
test('creates a "default" material, when none is specified', async () => {
const threeGLTF = await loadThreeGLTF(KHRONOS_TRIANGLE_GLB_PATH);
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
expect(model.materials.length).to.be.eq(1);
expect(model.materials[0].name).to.be.eq('Default');
});
test.skip('exposes a list of materials in the scene', async () => {
// TODO: This test is skipped because [$correlatedObjects] can contain
// unused materials, because it can contain a base material and the
// derived material (from assignFinalMaterial(), if for instance
// vertexTangents are used) even if only the derived material is assigned
// to a mesh. These extras make the test fail. We may want to remove these
// unused materials from [$correlatedObjects] at which point this test
// will pass, but it's not hurting anything.
const threeGLTF = await loadThreeGLTF(ASTRONAUT_GLB_PATH);
const materials: Set<MeshStandardMaterial> = new Set();
threeGLTF.scene.traverse((object) => {
if ((object as Mesh).isMesh) {
const material = (object as Mesh).material;
if (Array.isArray(material)) {
material.forEach(
(material) => materials.add(material as MeshStandardMaterial));
} else {
materials.add(material as MeshStandardMaterial);
}
}
});
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
const collectedMaterials = new Set<MeshStandardMaterial>();
model.materials.forEach((material) => {
for (const threeMaterial of material[$correlatedObjects] as
Set<MeshStandardMaterial>) {
collectedMaterials.add(threeMaterial);
expect(materials.has(threeMaterial)).to.be.true;
}
});
expect(collectedMaterials.size).to.be.equal(materials.size);
});
suite('Model Variants', () => {
test('Switch variant and lazy load', async () => {
const threeGLTF = await loadThreeGLTF(CUBES_GLTF_PATH);
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
expect(model[$materials][2][$correlatedObjects]).to.be.empty;
expect(model[$materials][2][$lazyLoadGLTFInfo]).to.be.ok;
await model[$switchVariant]('Yellow Red');
expect(model[$materials][2][$correlatedObjects]).to.not.be.empty;
expect(model[$materials][2][$lazyLoadGLTFInfo]).to.not.be.ok;
});
test(
'Switch back to default variant does not change correlations',
async () => {
const threeGLTF = await loadThreeGLTF(CUBES_GLTF_PATH);
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
const sizeBeforeSwitch =
model[$materials][0][$correlatedObjects]!.size;
await model[$switchVariant]('Yellow Yellow');
// Switches back to default.
await model[$switchVariant]('Purple Yellow');
expect(model[$materials][0][$correlatedObjects]!.size)
.equals(sizeBeforeSwitch);
});
test(
'Switching variant when model has no variants has not effect',
async () => {
const threeGLTF = await loadThreeGLTF(KHRONOS_TRIANGLE_GLB_PATH);
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
const threeMaterial =
model[$materials][0][$correlatedObjects]!.values().next().value;
const sizeBeforeSwitch =
model[$materials][0][$correlatedObjects]!.size;
await model[$switchVariant]('Does not exist');
expect(
model[$materials][0][$correlatedObjects]!.values().next().value)
.equals(threeMaterial);
expect(model[$materials][0][$correlatedObjects]!.size)
.equals(sizeBeforeSwitch);
});
});
suite('Model e2e test', () => {
let element: ModelViewerElement;
let model: Model;
setup(async () => {
element = new ModelViewerElement();
});
teardown(() => {
document.body.removeChild(element);
});
const loadModel = async (src: string) => {
element.src = src;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'load');
model = element.model!;
};
test('getMaterialByName returns material when name exists', async () => {
await loadModel(CUBES_GLTF_PATH);
const material = model.getMaterialByName('red')!;
expect(material).to.be.ok;
expect(material.name).to.be.equal('red');
});
test(
'getMaterialByName returns null when name does not exists',
async () => {
await loadModel(CUBES_GLTF_PATH);
const material = model.getMaterialByName('does-not-exist')!;
expect(material).to.be.null;
});
suite('Create Variant', () => {
test(
`createMaterialInstanceForVariant() adds new primitive variants mapping
only to primitives that use the source material`,
async () => {
await loadModel(CUBES_GLTF_PATH);
const primitive1 = model[$primitivesList].find(prim => {
return prim.mesh.name === 'Box';
})!;
const primitive2 = model[$primitivesList].find(prim => {
return prim.mesh.name === 'Box_1';
})!;
const startingSize = primitive1.variantInfo.size;
const startingSize2 = primitive2.variantInfo.size;
// Creates a variant from material 0.
expect(model.createMaterialInstanceForVariant(
0, 'test-material', 'test-variant'))
.to.be.ok;
// primitive1 uses material '0' so it should have a vew variant.
expect(primitive1.variantInfo.size).to.equal(startingSize + 1);
// primitive2 to should remain unchanged.
expect(primitive2.variantInfo.size).to.equal(startingSize2);
});
test('Create variant and switch to it', async () => {
await loadModel(CUBES_GLTF_PATH);
const primitive = model[$primitivesList].find(prim => {
return prim.mesh.name === 'Box';
})!;
model.createMaterialInstanceForVariant(
0, 'test-material', 'test-variant');
element.variantName = 'test-variant';
await element.updateComplete;
expect(primitive.getActiveMaterial().name).to.equal('test-material');
});
test('New variant is available to model-viewer', async () => {
await loadModel(CUBES_GLTF_PATH);
model.createMaterialInstanceForVariant(
0, 'test-material', 'test-variant');
expect(element.availableVariants.find(variant => {
return variant === 'test-variant';
})).to.be.ok;
});
test(
`createMaterialInstanceForVariant() fails when there is a variant name
collision`,
async () => {
await loadModel(CUBES_GLTF_PATH);
expect(model.createMaterialInstanceForVariant(
0, 'test-material', 'Purple Yellow'))
.to.be.null;
});
test('createVariant() creates a variant', async () => {
await loadModel(CUBES_GLTF_PATH);
model.createVariant('test-variant');
expect(element.availableVariants.find(variant => {
return variant === 'test-variant';
})).to.be.ok;
});
test('createVariant() is a noop if the variant exists', async () => {
await loadModel(CUBES_GLTF_PATH);
const length = model[$availableVariants]().length;
model.createVariant('Purple Yellow');
expect(length).to.equal(model[$availableVariants]().length);
});
test(
`setMaterialToVariant() adds variants mapping
only to primitives that use the source material`,
async () => {
await loadModel(CUBES_GLTF_PATH);
const primitive1 = model[$primitivesList].find(prim => {
return prim.mesh.name === 'Box';
})!;
const primitive2 = model[$primitivesList].find(prim => {
return prim.mesh.name === 'Box_1';
})!;
const startingSize = primitive1.variantInfo.size;
const startingSize2 = primitive2.variantInfo.size;
model.createVariant('test-variant');
// Adds material 0 to the variant.
model.setMaterialToVariant(0, 'test-variant');
// primitive1 uses material '0' so it should have a vew variant.
expect(primitive1.variantInfo.size).to.equal(startingSize + 1);
// primitive2 to should remain unchanged.
expect(primitive2.variantInfo.size).to.equal(startingSize2);
});
test('updateVariantName() updates the variant name', async () => {
await loadModel(CUBES_GLTF_PATH);
element.variantName = 'Yellow Red';
await element.updateComplete;
element.model!.updateVariantName('Yellow Red', 'NewName');
expect(element.availableVariants[2]).equal('NewName');
});
test(
'deleteVariant() removes variant from primitives, materials and available variants.',
async () => {
await loadModel(CUBES_GLTF_PATH);
element.model!.deleteVariant('Yellow Red');
// Removed from the list of available variants.
expect(element.availableVariants.length).equal(2);
// No longer present in primitives
for (const primitive of model[$primitivesList]) {
if (primitive.variantInfo.size > 0) {
expect(primitive.variantInfo.has(2)).to.be.false;
}
}
// Materials do not reference the variant.
for (const material of model.materials) {
expect(material.hasVariant('Yellow Red')).to.be.false;
}
});
test('hasVariant() positive and negative test', async () => {
await loadModel(CUBES_GLTF_PATH);
expect(model.hasVariant('Yellow Red')).to.be.true;
expect(model.hasVariant('DoesNotExist')).to.be.false;
});
});
suite('Intersecting', () => {
test('materialFromPoint returns material', async () => {
await loadModel(ASTRONAUT_GLB_PATH);
await rafPasses();
const material = element.materialFromPoint(
element[$scene].width / 2, element[$scene].height / 2)!;
expect(material).to.be.ok;
});
test(
'materialFromPoint returns null when intersect fails', async () => {
await loadModel(ASTRONAUT_GLB_PATH);
await rafPasses();
const material = element.materialFromPoint(
element[$scene].width, element[$scene].height)!;
expect(material).to.be.null;
});
});
});
});
});
@@ -0,0 +1,239 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {MeshStandardMaterial} from 'three/src/materials/MeshStandardMaterial.js';
import {$primitivesList, $variantData, Model} from '../../../../features/scene-graph/model.js';
import {ModelViewerElement} from '../../../../model-viewer.js';
import {CorrelatedSceneGraph} from '../../../../three-components/gltf-instance/correlated-scene-graph.js';
import {waitForEvent} from '../../../../utilities.js';
import {assetPath, loadThreeGLTF} from '../../../helpers.js';
const BRAIN_STEM_GLB_PATH = assetPath(
'models/glTF-Sample-Assets/Models/BrainStem/glTF-Binary/BrainStem.glb');
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
const CUBE_GLTF_PATH = assetPath('models/cube.gltf');
const MESH_PRIMITIVES_GLB_PATH = assetPath('models/MeshPrimitivesVariants.glb');
const KHRONOS_TRIANGLE_GLB_PATH =
assetPath('models/glTF-Sample-Assets/Models/Triangle/glTF/Triangle.gltf');
const findPrimitivesWithVariant = (model: Model, variantName: string) => {
const result = new Array<any>();
for (const primitive of model![$primitivesList]) {
if (primitive.variantInfo != null && model.hasVariant(variantName) &&
primitive.variantInfo.has(
model[$variantData].get(variantName)!.index)) {
result.push(primitive);
}
}
return result.length > 0 ? result : null;
};
suite('scene-graph/model/mesh-primitives', () => {
suite('Primitive with default material', () => {
let element: ModelViewerElement;
setup(async () => {
element = new ModelViewerElement();
element.src = KHRONOS_TRIANGLE_GLB_PATH;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'load');
});
teardown(() => {
document.body.removeChild(element);
});
test('has a default material', async () => {
const model = element.model!;
expect(model[$primitivesList].length).to.equal(1);
expect(model.materials.length).to.equal(1);
expect(model[$primitivesList][0].initialMaterialIdx).to.equal(0);
expect(model.materials[0].name).to.equal('Default');
});
});
suite('Static Primitive Without Variant', () => {
let model: Model;
setup(async () => {
const threeGLTF = await loadThreeGLTF(CUBE_GLTF_PATH);
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
});
test('Should not have any primitives with variant info', async () => {
let hasVariantInfoCount = 0;
for (const primitive of model![$primitivesList]) {
if (primitive.variantInfo.size > 0) {
hasVariantInfoCount++;
}
}
expect(hasVariantInfoCount).equals(0);
});
});
suite('Static Primitive With Variant', () => {
let model: Model;
setup(async () => {
const threeGLTF = await loadThreeGLTF(CUBES_GLTF_PATH);
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
});
test('Primitive count matches glTF file', async () => {
expect(model![$primitivesList].length).to.equal(2);
});
test('Primitives should have expected variant names', async () => {
expect(findPrimitivesWithVariant(model, 'Purple Yellow')).to.not.be.null;
expect(findPrimitivesWithVariant(model, 'Yellow Yellow')).to.not.be.null;
expect(findPrimitivesWithVariant(model, 'Yellow Red')).to.not.be.null;
});
test('Should not have any primitives without variant info', async () => {
let hasNoVariantInfoCount = 0;
for (const primitive of model![$primitivesList]) {
if (primitive.variantInfo == null) {
hasNoVariantInfoCount++;
}
}
expect(hasNoVariantInfoCount).equals(0);
});
test('Switching to incorrect variant name', async () => {
const primitive = findPrimitivesWithVariant(model, 'Purple Yellow')![0];
const material = await primitive.enableVariant('Does not exist');
expect(material).to.be.null;
});
test('Switching to current variant', async () => {
const primitives = findPrimitivesWithVariant(model, 'Purple Yellow')!;
const materials = new Array<MeshStandardMaterial>();
for (const primitive of primitives) {
materials.push(
await primitive.enableVariant('Yellow Yellow') as
MeshStandardMaterial);
}
expect(materials).to.not.be.empty;
expect(materials.find((material: MeshStandardMaterial) => {
return material.name === 'yellow';
})).to.not.be.null;
});
test('Switching to variant and then switch back', async () => {
const primitives = findPrimitivesWithVariant(model, 'Purple Yellow')!;
let materials = new Array<MeshStandardMaterial>();
for (const primitive of primitives) {
materials.push(
await primitive.enableVariant('Yellow Yellow') as
MeshStandardMaterial);
}
expect(materials.find((material: MeshStandardMaterial) => {
return material.name === 'yellow';
})).to.not.be.null;
materials = new Array<MeshStandardMaterial>();
for (const primitive of primitives) {
materials.push(
await primitive.enableVariant('Purple Yellow') as
MeshStandardMaterial);
}
expect(materials.find((material: MeshStandardMaterial) => {
return material.name === 'purple';
})).to.not.be.null;
});
test('Primitive switches to initial material', async () => {
const primitive = findPrimitivesWithVariant(model, 'Purple Yellow')![0];
// Gets current material.
const initialMaterial = await primitive.enableVariant('Purple Yellow');
// Switches to variant.
const variantMaterial = await primitive.enableVariant('Yellow Red');
expect(initialMaterial).to.not.equal(variantMaterial)
// Switches to initial material.
const resetMaterial = await primitive.enableVariant(null);
expect(resetMaterial).to.equal(initialMaterial);
});
});
suite('Mesh with multiple primitives each with variants', () => {
let model: Model;
setup(async () => {
const threeGLTF = await loadThreeGLTF(MESH_PRIMITIVES_GLB_PATH);
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
});
test('Primitive count matches glTF file', async () => {
expect(model![$primitivesList].length).to.equal(3);
});
test('Primitives should have expected variant names', async () => {
expect(findPrimitivesWithVariant(model, 'Normal')).to.not.be.null;
expect(findPrimitivesWithVariant(model, 'Inverse')).to.not.be.null;
});
test('Switching to incorrect variant name', async () => {
const primitive = findPrimitivesWithVariant(model, 'Normal')![0];
const material = await primitive.enableVariant('Does not exist');
expect(material).to.be.null;
});
test('Switching to variant and then switch back', async () => {
const MATERIAL_NAME = 'STEEL BLACK';
const primitives = findPrimitivesWithVariant(model, 'Normal')!;
let materials = new Array<MeshStandardMaterial>();
for (const primitive of primitives) {
materials.push(
await primitive.enableVariant('Inverse') as MeshStandardMaterial);
}
expect(materials).to.not.be.empty;
expect(materials.find((material: MeshStandardMaterial) => {
return material.name === MATERIAL_NAME;
})).to.be.undefined;
materials = new Array<MeshStandardMaterial>();
for (const primitive of primitives) {
materials.push(
await primitive.enableVariant('Normal') as MeshStandardMaterial);
}
expect(materials.find((material: MeshStandardMaterial) => {
return material.name === MATERIAL_NAME;
})).to.be.ok;
});
test('Primitive switches to initial material', async () => {
const primitive = findPrimitivesWithVariant(model, 'Normal')![0];
// Gets current material.
const initialMaterial = await primitive.enableVariant('Normal');
// Switches to variant.
const variantMaterial = await primitive.enableVariant('Inverse');
expect(initialMaterial).to.not.equal(variantMaterial)
// Switches to initial material.
const resetMaterial = await primitive.enableVariant(null);
expect(resetMaterial).to.equal(initialMaterial);
});
});
suite('Skinned Primitive Without Variant', () => {
test('Primitive count matches glTF file', async () => {
const threeGLTF = await loadThreeGLTF(BRAIN_STEM_GLB_PATH);
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
expect(model![$primitivesList].length).to.equal(59);
});
});
});
@@ -0,0 +1,107 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {TextureInfo} from '../../../features/scene-graph/texture-info.js';
import {ModelViewerElement} from '../../../model-viewer.js';
import {waitForEvent} from '../../../utilities.js';
import {assetPath} from '../../helpers.js';
const DUCK_GLB_PATH =
assetPath('models/glTF-Sample-Assets/Models/Duck/glTF-Binary/Duck.glb');
const TEXTURED_CUBE_GLB_PATH = assetPath(
'models/glTF-Sample-Assets/Models/BoxTextured/glTF-Binary/BoxTextured.glb');
suite('scene-graph/texture-info', () => {
suite('texture-info', () => {
let element: ModelViewerElement;
let emptyTextureInfo: TextureInfo;
let baseTextureInfo: TextureInfo;
setup(async () => {
element = new ModelViewerElement();
element.src = DUCK_GLB_PATH;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'load');
emptyTextureInfo = element.model!.materials[0].normalTexture;
baseTextureInfo =
element.model!.materials[0].pbrMetallicRoughness.baseColorTexture;
});
teardown(() => {
document.body.removeChild(element);
});
test('empty slot is null', async () => {
// The duck doesn't have a normal texture.
expect(emptyTextureInfo.texture).to.be.null;
});
test('non-empty slot is not null', async () => {
// The duck doesn't have a normal texture.
expect(baseTextureInfo.texture).to.not.be.null;
});
test('call setTexture', async () => {
const texture = await element.createTexture(assetPath(
'models/glTF-Sample-Assets/Models/BoxTextured/glTF/CesiumLogoFlat.png'));
// Setting a texture, the normal texture should _not_ be null.
emptyTextureInfo.setTexture(texture);
expect(emptyTextureInfo.texture).to.not.be.null;
// Clearing a texture, the normal texture _should_ be null again.
emptyTextureInfo.setTexture(null);
expect(emptyTextureInfo.texture).to.be.null;
});
test(
'exports and re-imports the model with transformed texture',
async () => {
// Load textured glb.
element.src = TEXTURED_CUBE_GLB_PATH;
await waitForEvent(element, 'load');
// Transform the textures.
const sampler = element.model?.materials[0]
.pbrMetallicRoughness['baseColorTexture']
.texture?.sampler!;
sampler.setRotation(0.1);
sampler.setOffset({u: 0.2, v: 0.3});
sampler.setScale({u: 0.4, v: 0.5});
// Export model.
const exported = await element.exportScene({binary: true});
const url = URL.createObjectURL(exported);
// Re-load model.
element.src = url;
await waitForEvent(element, 'load');
URL.revokeObjectURL(url);
const exported_sampler = element.model?.materials[0]
.pbrMetallicRoughness['baseColorTexture']
.texture?.sampler!;
expect(exported_sampler.rotation).to.be.eq(0.1, 'rotation');
expect(exported_sampler.offset!.u).to.be.eql(0.2, 'offset_u');
expect(exported_sampler.offset!.v).to.be.eql(0.3, 'offset_v');
expect(exported_sampler.scale!.u).to.be.eql(0.4, 'scale_u');
expect(exported_sampler.scale!.v).to.be.eql(0.5, 'scale_v');
});
});
});
@@ -0,0 +1,102 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Texture as ThreeTexture} from 'three';
import {$threeTexture} from '../../../features/scene-graph/image.js';
import {Texture} from '../../../features/scene-graph/texture.js';
import {$correlatedObjects} from '../../../features/scene-graph/three-dom-element.js';
import {ModelViewerElement} from '../../../model-viewer.js';
import {waitForEvent} from '../../../utilities.js';
import {assetPath} from '../../helpers.js';
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
suite('scene-graph/texture', () => {
suite('Texture', () => {
let element: ModelViewerElement;
let texture: Texture|null;
setup(async () => {
element = new ModelViewerElement();
element.src = ASTRONAUT_GLB_PATH;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'load');
texture = await element.createTexture(assetPath(
'models/glTF-Sample-Assets/Models/BoxTextured/glTF/CesiumLogoFlat.png'));
element.model!.materials[0]
.pbrMetallicRoughness.baseColorTexture!.setTexture(texture);
});
teardown(() => {
document.body.removeChild(element);
texture = null;
});
test('Create a texture', async () => {
expect(texture).to.not.be.null;
});
test('Set a texture', async () => {
// Gets new UUID to compare with UUID of texture accessible through the
// material.
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
const threeTexture: ThreeTexture =
element.model!.materials[0]
.pbrMetallicRoughness.baseColorTexture?.texture
?.source[$threeTexture]!;
expect(threeTexture.uuid).to.be.equal(newUUID);
});
test('Verify legacy correlatedObjects are updated.', async () => {
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
const uuidFromTextureObject: string =
(element.model!.materials[0]
.pbrMetallicRoughness.baseColorTexture?.texture
?.[$correlatedObjects]
?.values()
.next()
.value as ThreeTexture)
.uuid;
expect(uuidFromTextureObject).to.be.equal(newUUID);
const uuidFromSamplerObject: string =
(element.model!.materials[0]
.pbrMetallicRoughness.baseColorTexture?.texture
?.sampler[$correlatedObjects]
?.values()
.next()
.value as ThreeTexture)
.uuid;
expect(uuidFromSamplerObject).to.be.equal(newUUID);
const uuidFromImageObject: string =
(element.model!.materials[0]
.pbrMetallicRoughness.baseColorTexture?.texture
?.source[$correlatedObjects]
?.values()
.next()
.value as ThreeTexture)
.uuid;
expect(uuidFromImageObject).to.be.equal(newUUID);
});
});
});
@@ -0,0 +1,149 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {CameraChangeDetails} from '../../features/controls.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {ChangeSource} from '../../three-components/SmoothControls.js';
import {timePasses, waitForEvent} from '../../utilities.js';
import {assetPath, rafPasses} from '../helpers.js';
const ODD_SHAPE_GLB_PATH = assetPath('models/odd-shape.glb');
const AUTO_ROTATE_DELAY = 50;
suite('Staging', () => {
suite('with a visible loaded model', () => {
let element: ModelViewerElement;
setup(async () => {
element = new ModelViewerElement();
element.src = ODD_SHAPE_GLB_PATH;
document.body.insertBefore(element, document.body.firstChild);
await waitForEvent(element, 'poster-dismissed');
await rafPasses();
});
teardown(() => {
document.body.removeChild(element);
});
test('can manually rotate turntable', () => {
element.resetTurntableRotation(3);
expect(element.turntableRotation).to.be.equal(3);
element.resetTurntableRotation();
expect(element.turntableRotation).to.be.equal(0);
});
suite('auto-rotate', () => {
setup(async () => {
element.autoRotate = true;
element.autoRotateDelay = AUTO_ROTATE_DELAY;
await timePasses();
});
test.skip('causes the model to rotate after a delay', async () => {
const {turntableRotation} = element;
await rafPasses();
expect(element.turntableRotation).to.be.equal(turntableRotation);
await timePasses(AUTO_ROTATE_DELAY);
await rafPasses();
expect(element.turntableRotation).to.be.greaterThan(turntableRotation);
});
test(
'retains turntable rotation when auto-rotate is toggled',
async () => {
element.autoRotateDelay = 0;
await rafPasses();
await rafPasses();
const {turntableRotation} = element;
expect(turntableRotation).to.be.greaterThan(0);
element.autoRotate = false;
await rafPasses();
await rafPasses();
expect(element.turntableRotation).to.be.equal(turntableRotation);
element.autoRotate = true;
await rafPasses();
await rafPasses();
expect(element.turntableRotation)
.to.be.greaterThan(turntableRotation);
});
test.skip('pauses rotate after user interaction', async () => {
const {turntableRotation} = element;
await timePasses(AUTO_ROTATE_DELAY);
await rafPasses();
await rafPasses();
const {turntableRotation: initialTurntableRotation} = element;
expect(initialTurntableRotation).to.be.greaterThan(turntableRotation);
element.dispatchEvent(new CustomEvent<CameraChangeDetails>(
'camera-change',
{detail: {source: ChangeSource.USER_INTERACTION}}));
await rafPasses();
await rafPasses();
expect(element.turntableRotation).to.be.equal(initialTurntableRotation);
await timePasses(AUTO_ROTATE_DELAY);
await rafPasses();
await rafPasses();
expect(element.turntableRotation)
.to.be.greaterThan(initialTurntableRotation);
});
suite('when the model is not visible', () => {
setup(() => {
Object.defineProperty(element, 'modelIsVisible', {value: false});
});
test('does not cause the model to rotate over time', async () => {
const {turntableRotation} = element;
await timePasses(AUTO_ROTATE_DELAY);
await rafPasses();
expect(element.turntableRotation).to.be.equal(turntableRotation);
});
});
suite('with zero auto-rotate-delay', () => {
setup(async () => {
element.autoRotateDelay = 0;
await timePasses();
await rafPasses();
});
test('causes the model to rotate ASAP', async () => {
const {turntableRotation} = element;
await rafPasses();
expect(element.turntableRotation)
.to.be.greaterThan(turntableRotation);
});
});
});
});
});
+190
View File
@@ -0,0 +1,190 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Group} from 'three';
import {GLTF as ThreeGLTF, GLTFLoader, GLTFParser} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {ExpressionNode, ExpressionTerm, FunctionNode, HexNode, IdentNode, Operator, OperatorNode} from '../styles/parsers.js';
import {deserializeUrl, PredicateFunction, timePasses} from '../utilities.js';
export const elementFromLocalPoint =
(document: Document|ShadowRoot, x: number, y: number): Element|null => {
const host: HTMLElement = (document === window.document) ?
window.document.body :
(document as ShadowRoot).host as HTMLElement;
const actualDocument =
(window as any).ShadyCSS ? window.document : document;
const boundingRect = host.getBoundingClientRect();
return actualDocument.elementFromPoint(
boundingRect.left + x, boundingRect.top + y);
};
export const pickShadowDescendant =
(element: Element, x: number = 0, y: number = 0): Element|null => {
return element.shadowRoot != null ?
elementFromLocalPoint(element.shadowRoot, x, y) :
null;
};
export const until =
async (predicate: PredicateFunction) => {
while (!predicate()) {
await timePasses();
}
}
export const rafPasses = (): Promise<void> =>
new Promise(resolve => requestAnimationFrame(() => resolve()));
export interface SyntheticEventProperties {
clientX?: number;
clientY?: number;
deltaY?: number;
key?: string;
shiftKey?: boolean;
}
/**
* Dispatch a synthetic event on a given element with a given type, and
* optionally with custom event properties. Returns the dispatched event.
*
* @param {HTMLElement} element
* @param {type} string
* @param {*} properties
*/
export const dispatchSyntheticEvent =
(target: EventTarget, type: string, properties: SyntheticEventProperties = {
clientX: 0,
clientY: 0,
deltaY: 1.0
}): CustomEvent => {
const event = new CustomEvent(type, {cancelable: true, bubbles: true});
Object.assign(event, properties);
target.dispatchEvent(event);
return event;
};
export const ASSETS_DIRECTORY = 'packages/shared-assets/';
/**
* Returns the full path for an asset by name. This is a convenience helper so
* that we don't need to change paths throughout all test suites if we ever
* decide to move files around.
*
* @param {string} name
* @return {string}
*/
export const assetPath = (name: string): string =>
deserializeUrl(`${ASSETS_DIRECTORY}${name}`)!;
/**
* Returns true if the given element is in the tree of the document of the
* current frame.
*
* @param {HTMLElement} element
* @return {boolean}
*/
export const isInDocumentTree = (node: Node): boolean => {
let root: Node = node.getRootNode();
while (root !== node && root != null) {
if (root.nodeType === Node.DOCUMENT_NODE) {
return root === document;
}
root = (root as ShadowRoot).host && (root as ShadowRoot).host.getRootNode();
}
return false;
};
/**
* "Spies" on a particular object by replacing a specified part of its
* implementation with a custom version. Returns a function that restores the
* original implementation to the object when invoked.
*/
export const spy =
(object: Object,
property: string,
descriptor: PropertyDescriptor): () => void => {
let sourcePrototype = object;
while (sourcePrototype != null &&
!sourcePrototype.hasOwnProperty(property)) {
sourcePrototype = (sourcePrototype as any).__proto__;
}
if (sourcePrototype == null) {
throw new Error(`Cannot spy property "${property}" on ${object}`);
}
const originalDescriptor =
Object.getOwnPropertyDescriptor(sourcePrototype, property);
if (originalDescriptor == null) {
throw new Error(`Cannot read descriptor of "${property}" on ${object}`);
}
Object.defineProperty(sourcePrototype, property, descriptor);
return () => {
Object.defineProperty(sourcePrototype, property, originalDescriptor);
};
};
/**
* Helpers to assist in generating AST test fixtures
*/
export const expressionNode = (terms: Array<ExpressionTerm>): ExpressionNode =>
({type: 'expression', terms});
export const hexNode = (value: string): HexNode => ({type: 'hex', value});
export const identNode = (value: string): IdentNode => ({type: 'ident', value});
export const operatorNode = (value: Operator): OperatorNode =>
({type: 'operator', value});
export const functionNode =
(name: string, args: Array<ExpressionNode>): FunctionNode =>
({type: 'function', name: identNode(name), arguments: args});
export const loadThreeGLTF = (url: string): Promise<ThreeGLTF> => {
const loader = new GLTFLoader();
return new Promise<ThreeGLTF>((resolve, reject) => {
loader.load(url, resolve, undefined, reject);
});
};
export const createFakeThreeGLTF = () => {
const scene = new Group();
return {
animations: [],
scene,
scenes: [scene],
cameras: [],
asset: {},
parser: {
cache: new Map(),
json: {scene: 0, scenes: [{}], materials: [], nodes: []},
associations: new Map()
} as unknown as GLTFParser,
userData: {}
};
};
@@ -0,0 +1,244 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {$renderer, $scene, $userInputElement} from '../model-viewer-base.js';
import {ModelViewerElement} from '../model-viewer.js';
import {Renderer} from '../three-components/Renderer.js';
import {timePasses, waitForEvent} from '../utilities.js';
import {assetPath, until} from './helpers.js';
const expectBlobDimensions =
async (blob: Blob, width: number, height: number) => {
const img = await new Promise<HTMLImageElement>((resolve) => {
const url = URL.createObjectURL(blob);
const img = new Image();
img.onload = () => {
resolve(img);
};
img.src = url;
});
expect(img.width).to.be.equal(Math.round(width));
expect(img.height).to.be.equal(Math.round(height));
};
suite('ModelViewerElementBase', () => {
suite('with an element', () => {
let element: ModelViewerElement;
let input: HTMLDivElement;
setup(() => {
element = new ModelViewerElement();
input = element[$userInputElement];
document.body.insertBefore(element, document.body.firstChild);
});
teardown(() => {
if (element.parentNode != null) {
element.parentNode.removeChild(element);
}
});
suite('with alt text', () => {
test('gives the input a related aria-label', async () => {
const altText = 'foo';
element.alt = altText;
await timePasses();
expect(input.getAttribute('aria-label')).to.be.equal(altText);
});
suite('that is removed', () => {
test('reverts input to default aria-label', async () => {
const defaultAriaLabel = input.getAttribute('aria-label');
const altText = 'foo';
element.alt = altText;
await timePasses();
element.alt = null;
await timePasses();
expect(input.getAttribute('aria-label'))
.to.be.equal(defaultAriaLabel);
});
});
});
suite('with a valid src', () => {
test('eventually dispatches a load event', async () => {
const sourceLoads = waitForEvent(element, 'load');
element.src = assetPath('models/Astronaut.glb');
await sourceLoads;
});
suite('that changes before the model loads', () => {
test('it loads the second value on microtask timing', async () => {
element.src = assetPath('models/Astronaut.glb');
await timePasses();
element.src = assetPath('models/Horse.glb');
await waitForEvent(element, 'load');
expect(element[$scene].url)
.to.be.equal(assetPath('models/Horse.glb'));
});
test('it loads the second value on task timing', async () => {
element.src = assetPath('models/Astronaut.glb');
await timePasses(1);
element.src = assetPath('models/Horse.glb');
await waitForEvent(element, 'load');
expect(element[$scene].url)
.to.be.equal(assetPath('models/Horse.glb'));
});
});
});
test('with an invalid src, dispatches an error event', async () => {
const sourceErrors = waitForEvent(element, 'error');
element.src = './does-not-exist.glb';
const event = await sourceErrors;
expect((event as any).detail.type).to.be.eq('loadfailure');
});
test('when losing the GL context, dispatches an error event', async () => {
const {threeRenderer, canvas3D} = Renderer.singleton;
canvas3D.addEventListener('webglcontextlost', function(event) {
event.preventDefault();
Renderer.resetSingleton();
}, false);
const errorEventDispatches = waitForEvent(element, 'error');
// We make a best effort to simulate the real scenario here, but
// for some cases like headless Chrome WebGL might be disabled,
// so we simulate the scenario.
// @see https://threejs.org/docs/index.html#api/en/renderers/WebGLRenderer.forceContextLoss
if (threeRenderer.getContext() != null) {
threeRenderer.forceContextLoss();
} else {
threeRenderer.domElement.dispatchEvent(
new CustomEvent('webglcontextlost'));
}
const event = await errorEventDispatches;
expect((event as any).detail.type).to.be.equal('webglcontextlost');
});
suite('capturing screenshots', () => {
let width: number;
let height: number;
setup(async () => {
// Avoid testing our memory ceiling in CI by limiting the size
// of the screenshots we produce in these tests:
width = 32;
height = 64;
element.style.width = `${width}px`;
element.style.height = `${height}px`;
const modelLoads = waitForEvent(element, 'load');
element.src = assetPath('models/cube.gltf');
await modelLoads;
});
suite('toDataURL', () => {
test('produces a URL-compatible string', () => {
const dataUrlMatcher = /^data\:image\//;
expect(dataUrlMatcher.test(element.toDataURL())).to.be.true;
});
});
suite('toBlob', () => {
test('produces a blob', async () => {
const blob = await element.toBlob();
expect(blob).to.not.be.null;
});
test('can convert blob to object URL', async () => {
const blob = await element.toBlob();
const objectUrl = URL.createObjectURL(blob);
const objectUrlMatcher = /^blob\:/;
expect(objectUrlMatcher.test(objectUrl)).to.be.true;
});
test('has size', async () => {
const blob = await element.toBlob();
expect(blob.size).to.be.greaterThan(0);
});
test.skip('idealAspect gives the proper blob dimensions', async () => {
const basicBlob = await element.toBlob();
const idealBlob = await element.toBlob({idealAspect: true});
const idealHeight = 32 / element[$scene].idealAspect;
const {dpr, scaleFactor} = element[$renderer];
const f = dpr * scaleFactor;
await expectBlobDimensions(basicBlob, width * f, height * f);
await expectBlobDimensions(idealBlob, width * f, idealHeight * f);
});
});
});
});
suite('orchestrates rendering', () => {
let elements: Array<ModelViewerElement> = [];
setup(async () => {
elements.push(new ModelViewerElement());
elements.push(new ModelViewerElement());
elements.push(new ModelViewerElement());
for (let element of elements) {
element.style.position = 'relative';
element.style.marginBottom = '100vh';
element.src = assetPath('models/cube.gltf');
document.body.insertBefore(element, document.body.firstChild);
}
});
teardown(() => {
elements.forEach(
element => element.parentNode != null &&
element.parentNode.removeChild(element));
});
test('sets a model within viewport to be visible', async () => {
await until(() => {
return elements[2].modelIsVisible;
});
expect(elements[2].modelIsVisible).to.be.true;
});
test.skip('only models visible in the viewport', async () => {
// IntersectionObserver needs to set appropriate
// visibility on the scene, lots of timing issues when
// running -- wait for the visibility flags to be flipped
await until(() => {
return elements
.map((element, index) => {
return (index === 0) === element.modelIsVisible;
})
.reduce(((l, r) => l && r), true);
});
expect(elements[0].modelIsVisible).to.be.ok;
expect(elements[1].modelIsVisible).to.not.be.ok;
expect(elements[2].modelIsVisible).to.not.be.ok;
});
});
});
@@ -0,0 +1,144 @@
import {expect} from 'chai';
import {$renderer} from '../model-viewer-base.js';
import {ModelViewerElement} from '../model-viewer.js';
import {Constructor, waitForEvent} from '../utilities.js';
import {assetPath, rafPasses} from './helpers.js';
import {BasicSpecTemplate} from './templates.js';
const SUNRISE_HDR_PATH = 'environments/spruit_sunrise_1k_HDR.hdr';
const SUNRISE_LDR_PATH = 'environments/spruit_sunrise_1k_LDR.jpg';
const COMPONENTS_PER_PIXEL = 4;
const setupLighting =
async (modelViewer: ModelViewerElement, lighting?: string) => {
const posterDismissed = waitForEvent(modelViewer, 'poster-dismissed');
if (lighting) {
const lightingPath = assetPath(lighting);
modelViewer.environmentImage = lightingPath;
}
modelViewer.src = assetPath('models/reflective-sphere.gltf');
await posterDismissed;
await rafPasses();
}
// TODO(sun765): this only test whether the screenshot
// is colorless or not. Replace this with more robust
// test in later pr.
function testFidelity(screenshotContext: WebGLRenderingContext|
WebGL2RenderingContext) {
const width = screenshotContext.drawingBufferWidth;
const height = screenshotContext.drawingBufferHeight;
const pixels = new Uint8Array(width * height * COMPONENTS_PER_PIXEL);
// this function reads in the bottom-up direction from the coordinate
// specified ((0,0) is the bottom-left corner).
screenshotContext.readPixels(
0,
0,
width,
height,
screenshotContext.RGBA,
screenshotContext.UNSIGNED_BYTE,
pixels);
let transparentPixels = 0;
let whitePixels = 0;
let blackPixels = 0;
for (let row = 0; row < height; row++) {
for (let col = 0; col < width; col++) {
let isWhite = true;
let isBlack = true;
// read pixel data from top left corner
const index = (height - row - 1) * width + col;
const position = index * COMPONENTS_PER_PIXEL;
if (pixels[position + 3] != 255) {
transparentPixels++;
continue;
}
for (let i = 0; i < 3; i++) {
const colorComponent = pixels[position + i];
if (colorComponent != 255) {
isWhite = false;
}
if (colorComponent != 0) {
isBlack = false;
}
}
if (isWhite) {
whitePixels++;
}
if (isBlack) {
blackPixels++;
}
}
}
const imagePixelCount = width * height;
expect(whitePixels + blackPixels + transparentPixels)
.to.be.below(
imagePixelCount,
`Image had ${whitePixels} white pixels and ${
blackPixels} black pixels and ${
transparentPixels} background pixels.`);
};
suite('ModelViewerElement', () => {
let nextId: number = 0;
let tagName: string;
let ModelViewer: Constructor<ModelViewerElement>;
setup(() => {
tagName = `model-viewer-${nextId++}`;
ModelViewer = class extends ModelViewerElement {
static get is() {
return tagName;
}
}
customElements.define(tagName, ModelViewer);
});
BasicSpecTemplate(() => ModelViewer, () => tagName);
suite('Render Functionality Test', () => {
let element: ModelViewerElement;
setup(async () => {
element = new ModelViewerElement();
element.style.width = '100px';
element.style.height = '100px';
document.body.insertBefore(element, document.body.firstChild);
});
teardown(() => {
if (element.parentNode != null) {
element.parentNode.removeChild(element);
}
});
test('Metal roughness sphere with generated lighting', async () => {
await setupLighting(element);
const screenshotContext = element[$renderer].threeRenderer.getContext();
testFidelity(screenshotContext);
});
test('Metal roughness sphere with HDR lighting', async () => {
await setupLighting(element, SUNRISE_HDR_PATH);
const screenshotContext = element[$renderer].threeRenderer.getContext();
testFidelity(screenshotContext);
});
test('Metal roughness sphere with LDR lighting', async () => {
await setupLighting(element, SUNRISE_LDR_PATH);
const screenshotContext = element[$renderer].threeRenderer.getContext();
testFidelity(screenshotContext);
});
});
})
@@ -0,0 +1,89 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {degreesToRadians, lengthToBaseMeters, normalizeUnit, radiansToDegrees} from '../../styles/conversions.js';
import {numberNode} from '../../styles/parsers.js';
suite('conversions', () => {
suite('degreesToRadians', () => {
test('converts a number expressed in degrees to radians', () => {
expect(degreesToRadians(numberNode(180, 'deg')))
.to.be.eql(numberNode(Math.PI, 'rad'));
});
test('passes through numbers expressed in radians', () => {
expect(degreesToRadians(numberNode(1, 'rad')))
.to.be.eql(numberNode(1, 'rad'));
});
test('passes through numbers without a unit', () => {
expect(degreesToRadians(numberNode(1, null)))
.to.be.eql(numberNode(1, null));
});
});
suite('radiansToDegrees', () => {
test('converts a number expressed in radians to degrees', () => {
expect(radiansToDegrees(numberNode(Math.PI, 'rad')))
.to.be.eql(numberNode(180, 'deg'));
});
test('passes through numbers expressed in degrees', () => {
expect(radiansToDegrees(numberNode(1, 'deg')))
.to.be.eql(numberNode(1, 'deg'));
});
test('treats numbers without a unit as radians', () => {
expect(radiansToDegrees(numberNode(Math.PI, null)))
.to.be.eql(numberNode(180, 'deg'));
});
});
suite('lengthToBaseMeters', () => {
test('passes through numbers expressed in base meters', () => {
expect(lengthToBaseMeters(numberNode(1, 'm')))
.to.be.eql(numberNode(1, 'm'));
});
test('converts numbers expressed in centimeters to base meters', () => {
expect(lengthToBaseMeters(numberNode(123, 'cm')))
.to.be.eql(numberNode(1.23, 'm'));
});
test('converts numbers expressed in millimeters to base meters', () => {
expect(lengthToBaseMeters(numberNode(1234, 'mm')))
.to.be.eql(numberNode(1.234, 'm'));
});
});
suite('normalizeUnit', () => {
test('normalizes angles to radians', () => {
expect(normalizeUnit(numberNode(180, 'deg')))
.to.be.eql(numberNode(Math.PI, 'rad'));
expect(normalizeUnit(numberNode(180, 'rad')))
.to.be.eql(numberNode(180, 'rad'));
});
test('normalizes lengths to base meters', () => {
expect(normalizeUnit(numberNode(1, 'm'))).to.be.eql(numberNode(1, 'm'));
expect(normalizeUnit(numberNode(1000, 'mm')))
.to.be.eql(numberNode(1, 'm'));
});
});
});
@@ -0,0 +1,51 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {enumerationDeserializer} from '../../styles/deserializers.js';
suite('deserializers', () => {
suite('enumerationDeserializer', () => {
type Animal = 'elephant'|'octopus'|'chinchilla';
let animals: Animal[];
let deserializeAnimals: (input: string) => Set<Animal>;
setup(() => {
animals = ['elephant', 'octopus', 'chinchilla'];
deserializeAnimals = enumerationDeserializer<Animal>(animals);
});
test('yields the members of the enumeration in the input string', () => {
const deserialized = deserializeAnimals('elephant chinchilla');
expect(deserialized.size).to.be.equal(2);
expect(deserialized.has('elephant')).to.be.true;
expect(deserialized.has('chinchilla')).to.be.true;
});
test('filters out non-members of the enumeration', () => {
const deserialized = deserializeAnimals('octopus paris');
expect(deserialized.size).to.be.equal(1);
expect(deserialized.has('octopus')).to.be.true;
});
test('yields an empty set from null input', () => {
// tsc would normally warn about null not being accepted
// but it is worth ensuring the correct behavior all the same:
const deserialized = deserializeAnimals(null as any);
expect(deserialized.size).to.be.equal(0);
});
});
});
@@ -0,0 +1,299 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {CalcEvaluator, EnvEvaluator, OperatorEvaluator, PercentageEvaluator, SphericalIntrinsics, StyleEvaluator} from '../../styles/evaluators.js';
import {numberNode} from '../../styles/parsers.js';
import {expressionNode, functionNode, identNode, operatorNode, spy} from '../helpers.js';
suite('evaluators', () => {
suite('EnvEvaluator', () => {
test('is never constant', () => {
const evaluator = new EnvEvaluator(functionNode('env', []));
expect(evaluator.isConstant).to.be.false;
});
test('with no arguments, evaluates to zero', () => {
const evaluator = new EnvEvaluator(functionNode('env', []));
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
});
suite('window-scroll-y', () => {
test('evaluates to current top-level scroll position', () => {
const evaluator = new EnvEvaluator(functionNode(
'env', [expressionNode([identNode('window-scroll-y')])]));
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
const scrollPosition = 10000;
const scrollMax = 20000;
const restorePageYOffset =
spy(window, 'pageYOffset', {value: scrollPosition});
const restoreBodyScrollHeight =
spy(document.documentElement, 'clientHeight', {value: scrollMax});
expect(evaluator.evaluate())
.to.be.eql(numberNode(
scrollPosition / (scrollMax - window.innerHeight), null));
restorePageYOffset();
restoreBodyScrollHeight();
});
});
});
suite('PercentageEvaluator', () => {
test('multiplies the percentage by the basis', () => {
const evaluator =
new PercentageEvaluator(numberNode(200, '%'), numberNode(1, 'm'));
expect(evaluator.evaluate()).to.be.eql(numberNode(2, 'm'));
});
});
suite('CalcEvaluator', () => {
test('is constant if its operands are all numbers', () => {
const evaluator = new CalcEvaluator(functionNode(
'calc',
[expressionNode(
[numberNode(1, null), operatorNode('+'), numberNode(1, null)])]));
expect(evaluator.isConstant).to.be.true;
});
test('is constant if nested functions are constant', () => {
const evaluator = new CalcEvaluator(functionNode(
'calc', [expressionNode([
numberNode(1, null),
operatorNode('+'),
functionNode('calc', [expressionNode([numberNode(1, null)])])
])]));
expect(evaluator.isConstant).to.be.true;
});
test('is not constant if any nested function is not constant', () => {
const evaluator = new CalcEvaluator(functionNode('calc', [
expressionNode([
numberNode(1, null),
operatorNode('+'),
functionNode('calc', [expressionNode([numberNode(1, null)])]),
operatorNode('+'),
functionNode('env', [expressionNode([identNode('window-scroll-y')])])
])
]));
expect(evaluator.isConstant).to.be.false;
});
test('with no arguments, evaluates to zero', () => {
const evaluator = new CalcEvaluator(functionNode('env', []));
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
});
test('evaluates basic addition', () => {
const evaluator = new CalcEvaluator(functionNode(
'calc',
[expressionNode(
[numberNode(1, null), operatorNode('+'), numberNode(1, null)])]));
expect(evaluator.evaluate()).to.be.eql(numberNode(2, null));
});
test('evaluates basic subtraction', () => {
const evaluator = new CalcEvaluator(functionNode(
'calc',
[expressionNode(
[numberNode(1, null), operatorNode('-'), numberNode(1, null)])]));
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
});
test('evaluates basic multiplication', () => {
const evaluator = new CalcEvaluator(functionNode(
'calc',
[expressionNode(
[numberNode(5, null), operatorNode('*'), numberNode(4, null)])]));
expect(evaluator.evaluate()).to.be.eql(numberNode(20, null));
});
test('evaluates basic division', () => {
const evaluator = new CalcEvaluator(functionNode('calc', [
expressionNode(
[numberNode(100, null), operatorNode('/'), numberNode(10, null)])
]));
expect(evaluator.evaluate()).to.be.eql(numberNode(10, null));
});
test('evaluates complex algebraic expressions', () => {
const evaluator = new CalcEvaluator(functionNode('calc', [
expressionNode([
numberNode(1, null),
operatorNode('+'),
numberNode(2, null),
operatorNode('*'),
numberNode(2.5, null),
operatorNode('-'),
numberNode(-2, null),
operatorNode('/'),
numberNode(-1, null)
]),
]));
expect(evaluator.evaluate()).to.be.eql(numberNode(4, null));
});
suite('evaluates algebra with nested functions', () => {
test('evaluates basic addition', () => {
const evaluator = new CalcEvaluator(functionNode(
'calc', [expressionNode([
numberNode(1, null),
operatorNode('+'),
functionNode('calc', [expressionNode([numberNode(1, null)])])
])]));
expect(evaluator.evaluate()).to.be.eql(numberNode(2, null));
});
});
});
suite('OperatorEvaluator', () => {
test('evaluates zero for an unknown operator', () => {
const evaluator = new OperatorEvaluator(
operatorNode('$' as any), numberNode(1, null), numberNode(1, null));
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
});
test('evaluates zero for operands with mismatching unit types', () => {
const evaluator = new OperatorEvaluator(
operatorNode('+'), numberNode(1, 'm'), numberNode(1, 'rad'));
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
});
test('normalizes to base meters for length operands', () => {
const evaluator = new OperatorEvaluator(
operatorNode('+'), numberNode(1, 'm'), numberNode(1000, 'mm'));
expect(evaluator.evaluate()).to.be.eql(numberNode(2, 'm'));
});
test('normalizes to radians for angular operands', () => {
const evaluator = new OperatorEvaluator(
operatorNode('+'),
numberNode(180, 'deg'),
numberNode(Math.PI, 'rad'));
expect(evaluator.evaluate()).to.be.eql(numberNode(2 * Math.PI, 'rad'));
});
});
suite('VectorEvaluator', () => {
suite('with SphericalIntrinsics', () => {
let intrinsics: SphericalIntrinsics;
setup(() => {
intrinsics = {
basis:
[numberNode(1, 'rad'), numberNode(1, 'rad'), numberNode(1, 'm')],
keywords: {auto: [numberNode(2, 'rad'), null, numberNode(200, '%')]}
};
});
test('evaluates to defaults (e.g., auto) for omitted expressions', () => {
const evaluator = new StyleEvaluator([], intrinsics);
expect(evaluator.evaluate()).to.be.eql([2, 1, 2]);
});
test(
'substitutes the keyword auto for the related intrinsic value',
() => {
const evaluator = new StyleEvaluator(
[expressionNode([identNode('auto')])], intrinsics);
expect(evaluator.evaluate()).to.be.eql([2, 1, 2]);
});
test('treats missing values as equivalent to auto', () => {
const evaluatorOne =
new StyleEvaluator([expressionNode([])], intrinsics);
const evaluatorTwo = new StyleEvaluator(
[expressionNode(
[identNode('auto'), identNode('auto'), identNode('auto')])],
intrinsics);
expect(evaluatorOne.evaluate()).to.be.eql(evaluatorTwo.evaluate());
});
test('scales the basis by an input percentage', () => {
const evaluator = new StyleEvaluator(
[expressionNode([numberNode(300, '%')])], intrinsics);
expect(evaluator.evaluate()).to.be.eql([3, 1, 2]);
});
test('evaluates spherical values from basic expressions', () => {
const evaluator = new StyleEvaluator(
[expressionNode([
numberNode(1, 'rad'),
numberNode(180, 'deg'),
numberNode(100, 'cm')
])],
intrinsics);
expect(evaluator.evaluate()).to.be.eql([1, Math.PI, 1]);
});
test('applies a percentage at any expression depth to the basis', () => {
const evaluator = new StyleEvaluator(
[expressionNode([
numberNode(150, '%'),
numberNode(180, 'deg'),
functionNode('calc', [expressionNode([
numberNode(200, '%'),
operatorNode('*'),
functionNode('calc', [expressionNode(
[numberNode(3, 'm')])]),
])])
])],
intrinsics);
expect(evaluator.evaluate()).to.be.eql([1.5, Math.PI, 6]);
});
test('evaluates spherical values from complex expressions', () => {
const evaluator = new StyleEvaluator(
[expressionNode([
numberNode(1, 'rad'),
numberNode(180, 'deg'),
functionNode(
'calc', [expressionNode([
numberNode(1, 'm'),
operatorNode('+'),
functionNode(
'calc', [expressionNode([numberNode(1, null)])]),
operatorNode('+'),
functionNode(
'env', [expressionNode([identNode('window-scroll-y')])])
])])
])],
intrinsics);
expect(evaluator.evaluate()).to.be.eql([1, Math.PI, 2]);
});
});
});
});
@@ -0,0 +1,129 @@
/* @license
* Copyright 2018 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {ASTWalker, IdentNode, NumberNode, numberNode, parseExpressions} from '../../styles/parsers.js';
import {expressionNode, functionNode, hexNode, identNode, operatorNode} from '../helpers.js';
suite('parsers', () => {
suite('parseExpressions', () => {
test('parses single numbers', () => {
expect(parseExpressions('123rad')).to.be.eql([expressionNode(
[numberNode(123, 'rad')])]);
});
test('parses number tuples', () => {
expect(parseExpressions('123rad 3.14deg -1m 2cm')).to.be.eql([
expressionNode([
numberNode(123, 'rad'),
numberNode(3.14, 'deg'),
numberNode(-1, 'm'),
numberNode(2, 'cm')
])
]);
});
test('parses exponential numbers', () => {
expect(parseExpressions('123e10mm 123E10 123e-3 4e+3')).to.be.eql([
expressionNode([
numberNode(123e10, 'mm'),
numberNode(123e10, null),
numberNode(123e-3, null),
numberNode(4e3, null)
])
]);
});
test('parses hex colors', () => {
expect(parseExpressions('#fff')).to.be.eql([expressionNode(
[hexNode('fff')])]);
expect(parseExpressions('#abc123')).to.be.eql([expressionNode(
[hexNode('abc123')])]);
expect(parseExpressions('#daf012ee')).to.be.eql([expressionNode(
[hexNode('daf012ee')])]);
});
test('parses functions', () => {
expect(parseExpressions('rgba(255, 123, 0, 0.25)'))
.to.be.eql([expressionNode([functionNode('rgba', [
expressionNode([numberNode(255, null)]),
expressionNode([numberNode(123, null)]),
expressionNode([numberNode(0, null)]),
expressionNode([numberNode(0.25, null)]),
])])]);
});
test('parses nested functions', () => {
expect(parseExpressions('rgba(255, calc(100 + var(--blue)), 0, 0.25)'))
.to.be.eql([expressionNode([functionNode('rgba', [
expressionNode([numberNode(255, null)]),
expressionNode([functionNode(
'calc', [expressionNode([
numberNode(100, null),
operatorNode('+'),
functionNode('var', [expressionNode([identNode('--blue')])])
])])]),
expressionNode([numberNode(0, null)]),
expressionNode([numberNode(0.25, null)]),
])])]);
});
test('parses calc algebra', () => {
expect(parseExpressions('1m - -2rad / 3 + 4deg * -10.5')).to.be.eql([
expressionNode([
numberNode(1, 'm'),
operatorNode('-'),
numberNode(-2, 'rad'),
operatorNode('/'),
numberNode(3, null),
operatorNode('+'),
numberNode(4, 'deg'),
operatorNode('*'),
numberNode(-10.5, null)
])
]);
});
suite('failure cases', () => {
suite('mismatched parens', () => {
test('trailing paren is gracefully dropped', () => {
expect(parseExpressions('calc(calc(123)))')).to.be.eql([
expressionNode([functionNode(
'calc',
[expressionNode([functionNode(
'calc', [expressionNode([numberNode(123, null)])])])])])
]);
});
});
});
});
suite('ASTWalker', () => {
test('only walks configured node types', () => {
const astWalker =
new ASTWalker<NumberNode|IdentNode>(['number', 'ident']);
const ast = parseExpressions('calc(123 + var(--size))')
let visitedNodes = 0;
astWalker.walk(ast, (node: NumberNode|IdentNode) => {
expect(node.type === 'number' || node.type === 'ident').to.be.true;
visitedNodes++;
});
expect(visitedNodes).to.be.equal(4);
});
});
});
@@ -0,0 +1,61 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {parseExpressions} from '../../styles/parsers.js';
import {EnvironmentState, StyleEffector} from '../../styles/style-effector.js';
import {dispatchSyntheticEvent} from '../helpers.js';
const triggerEnvironmentEffect = (type: EnvironmentState) => {
switch (type) {
case 'window-scroll':
dispatchSyntheticEvent(window, 'scroll');
break;
}
};
suite('StyleEffector', () => {
test('never invokes its callback for constant styles', () => {
let callbackInvoked = false;
const styleEffector = new StyleEffector(() => {
callbackInvoked = true;
});
styleEffector.observeEffectsFor(parseExpressions('123px calc(4 * 5%)'));
triggerEnvironmentEffect('window-scroll');
expect(callbackInvoked).to.be.false;
styleEffector.dispose();
});
test('invokes its callback for styles that depend on scroll', () => {
let callbackCount = 0;
const styleEffector = new StyleEffector(() => {
callbackCount++;
});
styleEffector.observeEffectsFor(
parseExpressions('123px calc(env(window-scroll-y) * 5%)'));
triggerEnvironmentEffect('window-scroll');
expect(callbackCount).to.be.equal(1);
styleEffector.dispose();
});
});
@@ -0,0 +1,78 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {Cache} from 'three';
import ModelViewerElementBase from '../model-viewer-base.js';
import {CachingGLTFLoader} from '../three-components/CachingGLTFLoader.js';
import {timePasses} from '../utilities.js';
export type Constructor<T = object> = {
new (...args: any[]): T
};
import {expect} from 'chai';
export const BasicSpecTemplate =
(ModelViewerElementAccessor: () => Constructor<ModelViewerElementBase>,
tagNameAccessor: () => string) => {
teardown(() => {
CachingGLTFLoader.clearCache();
Cache.clear();
});
test('can be directly instantiated', () => {
const ModelViewerElement = ModelViewerElementAccessor();
const element = new ModelViewerElement();
expect(element).to.be.ok;
});
test('can be instantiated with document.createElement', () => {
const tagName = tagNameAccessor();
const element = document.createElement(tagName);
expect(element).to.be.ok;
});
suite('compatibility', () => {
suite('when WebGL is not supported', () => {
let nativeGetContext: any;
setup(() => {
nativeGetContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function(
type: string, ...args: Array<any>) {
if (/webgl/.test(type)) {
return null;
}
return nativeGetContext.call(this, type, ...args);
};
});
teardown(() => {
HTMLCanvasElement.prototype.getContext = nativeGetContext;
});
test(
'does not explode when created and appended to the document',
async () => {
const ModelViewerElement = ModelViewerElementAccessor();
const element = new ModelViewerElement();
document.body.insertBefore(element, document.body.firstChild);
await timePasses();
document.body.removeChild(element);
});
});
});
};
@@ -0,0 +1,293 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Matrix4, PerspectiveCamera, Vector2, Vector3} from 'three';
import {$scene} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {ARRenderer} from '../../three-components/ARRenderer.js';
import {ModelScene} from '../../three-components/ModelScene.js';
import {Renderer} from '../../three-components/Renderer.js';
import {waitForEvent} from '../../utilities.js';
import {assetPath} from '../helpers.js';
class MockXRFrame implements XRFrame {
constructor(public session: XRSession) {
}
readonly predictedDisplayTime = 0;
// We don't use nor test the returned XRPose other than its existence.
getPose(_xrSpace: XRSpace, _frameOfRef: XRReferenceSpace) {
return {} as XRPose;
}
// We don't use getDepthInformation()
getDepthInformation(_view: XRView): XRCPUDepthInformation|null|undefined {
return;
}
getViewerPose(_referenceSpace?: XRReferenceSpace): XRViewerPose {
// Rotate 180 degrees on Y (so it's not the default)
// and angle 45 degrees towards the ground, like a phone.
const matrix = new Matrix4()
.identity()
.makeRotationAxis(new Vector3(0, 1, 0), Math.PI)
.multiply(new Matrix4().makeRotationAxis(
new Vector3(1, 0, 0), -Math.PI / 4));
matrix.setPosition(10, 2, 3);
const transform: XRRigidTransform = {
matrix: matrix.elements as unknown as Float32Array,
position: {} as DOMPointReadOnly,
orientation: {} as DOMPointReadOnly,
inverse: {} as XRRigidTransform
};
const camera = new PerspectiveCamera();
const view: XRView = {
eye: {} as XREye,
projectionMatrix: camera.projectionMatrix.elements as unknown as
Float32Array,
transform: transform,
requestViewportScale: (_scale: number|null) => {}
};
const viewerPos: XRViewerPose = {
transform: transform,
views: [view],
emulatedPosition: false
};
return viewerPos;
}
getHitTestResults(_xrHitTestSource: XRHitTestSource) {
return [];
}
getHitTestResultsForTransientInput(_hitTestSource:
XRTransientInputHitTestSource) {
return [];
}
}
suite('ARRenderer', () => {
let element: ModelViewerElement;
let arRenderer: ARRenderer;
let xrSession: XRSession;
let inputSources: Array<XRInputSource> = [];
const setInputSources = (sources: Array<XRInputSource>) => {
inputSources = sources;
};
const stubWebXrInterface = (arRenderer: ARRenderer) => {
arRenderer.resolveARSession = async () => {
class FakeSession extends EventTarget implements XRSession {
public renderState: XRRenderState = {
baseLayer: {
getViewport: () => {
return {x: 0, y: 0, width: 320, height: 240} as XRViewport
}
} as unknown as XRLayer
} as XRRenderState;
public hitTestSources: Set<XRHitTestSource> =
new Set<XRHitTestSource>();
async updateRenderState(_object: any) {
}
requestFrameOfReference() {
return {};
}
async requestReferenceSpace(_type: XRReferenceSpaceType):
Promise<XRReferenceSpace> {
return {} as XRReferenceSpace;
}
get inputSources(): Array<XRInputSource> {
return inputSources;
}
async requestHitTestSource(_options: XRHitTestOptionsInit):
Promise<XRHitTestSource> {
const result = {cancel: () => {}};
this.hitTestSources.add(result);
return result;
}
async requestHitTestSourceForTransientInput(
_options: XRTransientInputHitTestOptionsInit) {
const result = {cancel: () => {}};
this.hitTestSources.add(result);
return result;
}
requestAnimationFrame() {
return 1;
}
cancelAnimationFrame() {
}
async end() {
this.dispatchEvent(new CustomEvent('end'));
}
readonly environmentBlendMode = {} as XREnvironmentBlendMode;
readonly visibilityState = {} as XRVisibilityState;
readonly isSystemKeyboardSupported = false;
async updateTargetFrameRate(_rate: number) {
return;
}
onend() {
}
oninputsourceschange() {
}
onselect() {
}
onselectstart() {
}
onselectend() {
}
onsqueeze() {
}
onsqueezestart() {
}
onsqueezeend() {
}
onvisibilitychange() {
}
onframeratechange() {
}
}
xrSession = new FakeSession();
return xrSession;
};
};
setup(() => {
element = new ModelViewerElement();
document.body.insertBefore(element, document.body.firstChild);
arRenderer = Renderer.singleton.arRenderer;
});
teardown(() => {
if (element.parentNode != null) {
element.parentNode.removeChild(element);
}
});
test('is not presenting if present has not been invoked', () => {
expect(arRenderer.isPresenting).to.be.equal(false);
});
suite('when presenting a scene', () => {
let modelScene: ModelScene;
let oldXRRay: any;
setup(async () => {
const sourceLoads = waitForEvent(element, 'poster-dismissed');
element.src = assetPath('models/Astronaut.glb');
await sourceLoads;
modelScene = element[$scene];
stubWebXrInterface(arRenderer);
setInputSources([]);
oldXRRay = (window as any).XRRay;
(window as any).XRRay = class MockXRRay implements XRRay {
readonly origin = new DOMPointReadOnly;
readonly direction = new DOMPointReadOnly;
matrix = new Float32Array;
constructor(_origin: DOMPointInit, _direction: DOMPointInit) {
}
}
await arRenderer.present(modelScene);
});
teardown(async () => {
(window as any).XRRay = oldXRRay;
await arRenderer.stopPresenting().catch(() => {});
});
test('presents the model at its natural scale', () => {
const scale = modelScene.target.getWorldScale(new Vector3());
expect(scale.x).to.be.equal(1);
expect(scale.y).to.be.equal(1);
expect(scale.z).to.be.equal(1);
});
suite('presentation ends', () => {
setup(async () => {
await arRenderer.stopPresenting();
});
test('restores the model to its natural scale', () => {
const scale = modelScene.target.getWorldScale(new Vector3());
expect(scale.x).to.be.equal(1);
expect(scale.y).to.be.equal(1);
expect(scale.z).to.be.equal(1);
});
test('restores original camera', () => {
expect(modelScene.camera).to.be.equal(modelScene.camera);
});
test('restores scene size', () => {
expect(modelScene.width).to.be.equal(300);
expect(modelScene.height).to.be.equal(150);
});
});
// We're going to need to mock out XRFrame more so it can set the camera
// in order to properly test this.
suite('after initial placement', () => {
let yaw: number;
setup(async () => {
arRenderer.onWebXRFrame(0, new MockXRFrame(arRenderer.currentSession!));
yaw = modelScene.yaw;
});
test('places the model oriented to the camera', () => {
const epsilon = 0.0001;
const {target, position, camera} = modelScene;
const cameraPosition = camera.position;
const cameraToHit = new Vector2(
position.x - cameraPosition.x, position.z - cameraPosition.z);
const forward = target.getWorldDirection(new Vector3());
const forwardProjection = new Vector2(forward.x, forward.z);
expect(forward.y).to.be.equal(0);
expect(cameraToHit.cross(forwardProjection)).to.be.closeTo(0, epsilon);
expect(cameraToHit.dot(forwardProjection)).to.be.lessThan(0);
expect(modelScene.yaw).to.be.equal(yaw);
});
});
});
});
@@ -0,0 +1,103 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import ModelViewerElementBase from '../../model-viewer-base.js';
import {$evictionPolicy, CachingGLTFLoader} from '../../three-components/CachingGLTFLoader.js';
import {ModelViewerGLTFInstance} from '../../three-components/gltf-instance/ModelViewerGLTFInstance.js';
import {assetPath} from '../helpers.js';
const ModelViewerElement = class extends ModelViewerElementBase {
static get is() {
return 'model-viewer-loader';
}
};
customElements.define('model-viewer-loader', ModelViewerElement);
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
suite('CachingGLTFLoader', () => {
suite('when loading a glTF', () => {
let loader: CachingGLTFLoader;
let element: ModelViewerElementBase;
setup(() => {
loader = new CachingGLTFLoader(ModelViewerGLTFInstance);
element = new ModelViewerElement();
});
teardown(() => {
CachingGLTFLoader.clearCache();
});
suite('before glTF is loaded', () => {
test('reports that it has not finished loading', async () => {
const fileLoads = loader.load(ASTRONAUT_GLB_PATH, element);
try {
expect(CachingGLTFLoader.hasFinishedLoading(ASTRONAUT_GLB_PATH))
.to.be.false;
} finally {
await fileLoads;
}
});
});
suite('after glTF is loaded', () => {
test('reports that it has finished loading', async () => {
await loader.load(ASTRONAUT_GLB_PATH, element);
expect(CachingGLTFLoader.hasFinishedLoading(ASTRONAUT_GLB_PATH))
.to.be.true;
});
});
test('synchronously populates the cache', async () => {
const fileLoads = loader.load(ASTRONAUT_GLB_PATH, element);
try {
expect(CachingGLTFLoader.has(ASTRONAUT_GLB_PATH)).to.be.true;
} finally {
await fileLoads;
}
});
test('yields a promise that resolves a group', async () => {
const {scene} = await loader.load(ASTRONAUT_GLB_PATH, element);
expect(scene).to.be.ok;
expect(scene!.type).to.be.equal('Group');
});
suite('with items outside of the eviction threshold', () => {
let naturalEvictionThreshold: number;
setup(() => {
naturalEvictionThreshold = loader[$evictionPolicy].evictionThreshold;
loader[$evictionPolicy].evictionThreshold = 0;
});
teardown(() => {
loader[$evictionPolicy].evictionThreshold = naturalEvictionThreshold;
});
test('deletes them when they are fully released', async () => {
const gltf = await loader.load(ASTRONAUT_GLB_PATH, element);
expect(CachingGLTFLoader.has(ASTRONAUT_GLB_PATH)).to.be.true;
gltf.dispose();
expect(CachingGLTFLoader.has(ASTRONAUT_GLB_PATH)).to.be.false;
});
});
});
});
@@ -0,0 +1,52 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Damper} from '../../three-components/Damper.js';
const ONE_FRAME_DELTA = 1000.0 / 60.0;
const FIFTY_FRAME_DELTA = 50 * ONE_FRAME_DELTA;
suite('Damper', () => {
let damper: Damper;
const initial = 5;
const goal = 2;
setup(() => {
damper = new Damper();
});
test('converges to goal with large time step without overshoot', () => {
const moving = damper.update(initial, goal, ONE_FRAME_DELTA, initial);
const final = damper.update(moving, goal, FIFTY_FRAME_DELTA, initial);
expect(final).to.be.eql(goal);
});
test('stays at initial value for negative time step', () => {
const final = damper.update(initial, goal, -1 * FIFTY_FRAME_DELTA, initial);
expect(final).to.be.eql(initial);
});
test('converges to goal when normalization is zero', () => {
const final = damper.update(initial, goal, FIFTY_FRAME_DELTA, 0);
expect(final).to.be.eql(goal);
});
test('negative normalization is the same as positive', () => {
const final = damper.update(initial, goal, FIFTY_FRAME_DELTA, -initial);
expect(final).to.be.eql(goal);
});
});
@@ -0,0 +1,54 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {GLTF} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {$prepared, GLTFInstance, PreparedGLTF} from '../../three-components/GLTFInstance.js';
import {createFakeThreeGLTF} from '../helpers.js';
suite('GLTFInstance', () => {
let rawGLTF: GLTF;
let preparedGLTF: PreparedGLTF;
setup(async () => {
rawGLTF = createFakeThreeGLTF();
preparedGLTF = await GLTFInstance.prepare(rawGLTF);
});
suite('with a prepared GLTF', () => {
test('exposes the same scene as the GLTF', () => {
const gltfInstance = new GLTFInstance(preparedGLTF);
expect(gltfInstance.scene).to.be.equal(preparedGLTF.scene);
});
suite('when cloned', () => {
test('creates a unique scene', async () => {
const gltfInstance = new GLTFInstance(preparedGLTF);
const cloneInstance = await gltfInstance.clone();
expect(cloneInstance.scene).to.be.ok;
expect(cloneInstance.scene).to.not.be.equal(gltfInstance.scene);
});
});
});
suite('preparing the GLTF', () => {
test('creates a prepared GLTF', () => {
expect(preparedGLTF).to.not.be.equal(rawGLTF);
expect(preparedGLTF[$prepared]).to.be.equal(true);
});
});
});
@@ -0,0 +1,117 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Hotspot, HotspotVisibilityDetails} from '../../three-components/Hotspot.js';
import {waitForEvent} from '../../utilities.js';
suite('Hotspot', () => {
suite('with only a name', () => {
test('has a DOM element', () => {
expect(new Hotspot({name: 'foo'}).element).to.be.ok;
});
});
suite('with assigned nodes', () => {
let host: HTMLElement;
let assigned: HTMLElement;
setup(() => {
host = document.createElement('div');
host.attachShadow({mode: 'open'});
assigned = document.createElement('div');
host.appendChild(assigned);
document.body.insertBefore(host, document.body.firstChild);
});
teardown(() => {
document.body.removeChild(host);
});
suite('with a configured visibilityAttribute', () => {
let hotspot: Hotspot;
setup(async () => {
hotspot = new Hotspot({name: 'foo'});
host.shadowRoot!.appendChild(hotspot.element);
assigned.slot = 'foo';
assigned.setAttribute('data-visibility-attribute', 'bar');
});
suite('when shown', () => {
setup(() => {
hotspot.show();
});
test('adds a corresponding data-* attribute to assigned nodes', () => {
expect(assigned.hasAttribute('data-bar')).to.be.true;
});
suite('and then hidden', () => {
setup(() => {
hotspot.hide();
});
test('removes the corresponding data-* attribute', () => {
expect(assigned.hasAttribute('data-bar')).to.be.false;
});
});
});
});
suite('when shown', () => {
let event: CustomEvent<HotspotVisibilityDetails>;
let hotspot: Hotspot;
setup(async () => {
hotspot = new Hotspot({name: 'foo'});
host.shadowRoot!.appendChild(hotspot.element);
assigned.slot = 'foo';
const assignedNodeHides = waitForEvent(assigned, 'hotspot-visibility');
hotspot.show();
event = await (
assignedNodeHides as
Promise<CustomEvent<HotspotVisibilityDetails>>);
});
test('dispatches a "visibility-change" on assigned nodes', async () => {
expect(event.detail.visible).to.be.true;
});
suite('and then hidden', () => {
test('dispatches a "visibility-change" on assigned nodes', async () => {
const assignedNodeHides =
waitForEvent(assigned, 'hotspot-visibility');
hotspot.hide();
const event = await (
assignedNodeHides as
Promise<CustomEvent<HotspotVisibilityDetails>>);
expect(event.detail.visible).to.be.false;
});
});
});
});
});
@@ -0,0 +1,106 @@
/* @license
* Copyright 2019 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {Matrix4, Mesh, SphereGeometry, Vector3} from 'three';
import {$scene} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {ModelScene} from '../../three-components/ModelScene.js';
import {assetPath} from '../helpers.js';
suite('ModelScene', () => {
let element: ModelViewerElement;
let scene: ModelScene;
let dummyRadius: number;
let dummyMesh: Mesh;
setup(() => {
// Set the radius of the sphere to 0.5 so that it's size is 1
// for testing scaling.
dummyRadius = 0.5;
dummyMesh = new Mesh(new SphereGeometry(dummyRadius, 32, 32));
element = new ModelViewerElement();
scene = element[$scene];
document.body.insertBefore(element, document.body.firstChild);
});
teardown(() => {
document.body.removeChild(element);
});
suite('with a model', () => {
setup(async () => {
await scene.setSource(assetPath('models/Astronaut.glb'));
});
suite('setShadowIntensity', () => {
test('can increase intensity and reset it to zero', () => {
scene.setShadowIntensity(1);
const shadow = scene.shadow!;
expect(shadow).to.be.ok;
expect(shadow.getIntensity()).to.be.equal(1);
scene.setShadowIntensity(0);
expect(shadow.getIntensity()).to.be.equal(0);
});
test('shadow is only created when intensity is greater than zero', () => {
expect(scene.shadow).to.be.not.ok;
scene.setShadowIntensity(1);
expect(scene.shadow).to.be.ok;
});
});
});
suite('setSize', () => {
test('updates visual and buffer size', () => {
scene.setSize(500, 200);
expect(scene.width).to.be.equal(500);
expect(scene.height).to.be.equal(200);
});
test('model is not scaled', async () => {
dummyMesh.geometry.applyMatrix4(new Matrix4().makeScale(1, 3, 10));
await scene.setObject(dummyMesh);
scene.setSize(1000, 500);
expect(scene.scale).to.be.eql(new Vector3(1, 1, 1));
});
test('idealCameraDistance is set correctly', async () => {
await scene.setObject(dummyMesh);
scene.framedFoVDeg = 25;
const halfFov = (scene.framedFoVDeg / 2) * Math.PI / 180;
const expectedDistance = dummyRadius / Math.sin(halfFov);
expect(scene.idealCameraDistance())
.to.be.closeTo(expectedDistance, 0.0001);
});
test('idealAspect is set correctly', async () => {
scene.framedFoVDeg = 25;
await scene.setObject(dummyMesh);
expect(scene.idealAspect).to.be.closeTo(1, 0.001);
});
test('cannot set the canvas smaller than 1x1', () => {
scene.setSize(0, 0);
expect(scene.width).to.be.equal(1);
expect(scene.height).to.be.equal(1);
});
});
});
@@ -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 {Vector2} from 'three';
import {$controls} from '../../features/controls.js';
import {$intersectionObserver, $isElementInViewport, $onResize, $renderer, $scene, $updateSize, Camera, RendererInterface} 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 {waitForEvent} from '../../utilities.js';
import {assetPath} from '../helpers.js';
let externalCamera: Camera;
let externalWidth = 0;
let externalHeight = 0;
class ExternalRenderer implements RendererInterface {
load(callback: (progress: number) => void) {
callback(1.0);
return Promise.resolve({framedRadius: 15, fieldOfViewAspect: 2});
}
render(camera: Camera) {
externalCamera = camera;
}
resize(width: number, height: number) {
externalWidth = width;
externalHeight = height;
}
}
function createScene(external: boolean = false): ModelScene {
const element = new ModelViewerElement();
document.body.insertBefore(element, document.body.firstChild);
element[$intersectionObserver]!.unobserve(element);
element[$isElementInViewport] = false;
if (external) {
const externalRenderer = new ExternalRenderer();
element.registerRenderer(externalRenderer);
}
element.src = assetPath('models/Astronaut.glb');
// manual render loop
element[$renderer].threeRenderer.setAnimationLoop(null);
return element[$scene];
}
function disposeScene(scene: ModelScene) {
const {element} = scene;
if (scene.externalRenderer != null) {
element.unregisterRenderer();
}
if (element.parentNode != null) {
element.parentNode.removeChild(element);
}
}
suite('Renderer with two scenes', () => {
let scene: ModelScene;
let otherScene: ModelScene;
let renderer: Renderer;
setup(() => {
renderer = Renderer.singleton;
// Ensure tests are not rescaling
ModelViewerElement.minimumRenderScale = 1;
scene = createScene();
otherScene = createScene();
});
teardown(() => {
disposeScene(scene);
disposeScene(otherScene);
renderer.render(performance.now());
});
test('pre-renders eager, invisible scenes', async () => {
const sourceLoads = waitForEvent(scene.element, 'load');
(scene.element as ModelViewerElement).loading = 'eager';
await sourceLoads;
renderer.render(performance.now());
expect(scene.renderCount).to.be.equal(1, 'scene first render');
expect(otherScene.renderCount).to.be.equal(0, 'otherScene first render');
});
suite('and an externally-rendered scene', () => {
let externalScene: ModelScene;
let externalElement: ModelViewerElement;
setup(() => {
externalScene = createScene(true);
externalElement = externalScene.element as any;
});
teardown(() => {
disposeScene(externalScene);
renderer.render(performance.now());
});
test('load sets framing', async () => {
expect(externalScene.idealAspect).to.be.eq(0);
const sourceLoads = waitForEvent(externalScene.element, 'load');
externalElement[$isElementInViewport] = true;
await sourceLoads;
expect(externalScene.idealAspect).to.be.eq(2);
expect((externalElement as any)[$controls].options.minimumRadius)
.to.be.greaterThan(15);
});
test('camera-orbit updates camera in external render method', async () => {
const sceneVisible = waitForEvent(externalElement, 'poster-dismissed');
externalElement[$isElementInViewport] = true;
await sceneVisible;
const time = performance.now();
renderer.render(time);
const cameraY = externalCamera.viewMatrix[13];
expect(cameraY).to.not.eq(0);
externalElement.cameraOrbit = '45deg 45deg 1.6m';
await externalElement.updateComplete;
renderer.render(time + 1000);
expect(externalCamera.viewMatrix[13]).to.not.eq(cameraY);
});
test('resize forwards pixel dimensions', () => {
const width = 200;
const height = 400;
externalElement[$onResize]({width, height});
const dpr = window.devicePixelRatio;
expect(externalWidth).to.be.eq(width * dpr);
expect(externalHeight).to.be.eq(height * dpr);
});
});
suite.skip('with two loaded scenes', () => {
setup(async () => {
const sceneVisible = waitForEvent(scene.element, 'poster-dismissed');
const otherSceneVisible =
waitForEvent(otherScene.element, 'poster-dismissed');
scene.element[$isElementInViewport] = true;
otherScene.element[$isElementInViewport] = true;
await Promise.all([sceneVisible, otherSceneVisible]);
});
test('renders only dirty scenes', () => {
renderer.render(performance.now());
expect(scene.renderCount).to.be.equal(1, 'scene first render');
expect(otherScene.renderCount).to.be.equal(1, 'otherScene first render');
scene.queueRender();
renderer.render(performance.now());
expect(scene.renderCount).to.be.equal(2, 'scene second render');
expect(otherScene.renderCount).to.be.equal(1, 'otherScene second render');
});
test('renders only visible scenes', () => {
renderer.render(performance.now());
expect(scene.renderCount).to.be.equal(1, 'scene first render');
expect(otherScene.renderCount).to.be.equal(1, 'otherScene first render');
scene.queueRender();
otherScene.queueRender();
otherScene.element[$isElementInViewport] = false;
renderer.render(performance.now());
expect(scene.renderCount).to.be.equal(2, 'scene second render');
expect(otherScene.renderCount).to.be.equal(1, 'otherScene second render');
});
test('uses the proper canvas when unregistering scenes', () => {
renderer.render(performance.now());
expect(renderer.canvas3D.parentElement).to.be.not.ok;
expect(scene.canvas.classList.contains('show'))
.to.be.eq(true, 'scene canvas should be shown with multiple scenes.');
expect(otherScene.canvas.classList.contains('show'))
.to.be.eq(
true, 'otherScene canvas should be shown with multiple scenes.');
renderer.unregisterScene(scene);
otherScene.queueRender();
renderer.render(performance.now());
expect(renderer.canvas3D.parentElement)
.to.be.eq(
otherScene.canvas.parentElement,
'webgl canvas should be shown with single scene.');
expect(otherScene.canvas.classList.contains('show'))
.to.be.eq(
false,
'otherScene canvas should not be shown when it is the only scene.');
});
test('when unregistering, does not re-render when not dirty', () => {
renderer.render(performance.now());
renderer.unregisterScene(scene);
renderer.render(performance.now());
expect(scene.renderCount)
.to.be.equal(1, 'scene should have rendered once');
expect(otherScene.renderCount)
.to.be.equal(1, 'otherScene should have rendered once');
expect(renderer.canvas3D.parentElement).to.be.not.ok;
expect(otherScene.canvas.classList.contains('show'))
.to.be.eq(true, 'otherScene canvas should still be shown.');
});
test(
'When registered, the scene canvas dimensions match the renderer size',
() => {
renderer.render(performance.now());
const oldSize = new Vector2();
renderer.threeRenderer.getSize(oldSize);
renderer.unregisterScene(scene);
otherScene.element[$updateSize]({width: 400, height: 200});
renderer.render(performance.now());
const size = new Vector2();
renderer.threeRenderer.getSize(size);
expect(size.x).to.be.greaterThan(oldSize.width, 'renderer width');
expect(size.y).to.be.greaterThan(oldSize.height, 'renderer height');
renderer.registerScene(scene);
renderer.render(performance.now());
expect(scene.canvas.width).to.be.eq(size.x, 'canvas width');
expect(scene.canvas.height).to.be.eq(size.y, 'canvas height');
});
suite('when resizing', () => {
let originalDpr: number;
setup(() => {
originalDpr = self.devicePixelRatio;
});
teardown(() => {
Object.defineProperty(self, 'devicePixelRatio', {value: originalDpr});
});
test('updates effective DPR', () => {
const {element} = scene;
const initialDpr = renderer.dpr;
const {width, height} = scene.getSize();
element[$onResize]({width, height});
Object.defineProperty(
self, 'devicePixelRatio', {value: initialDpr + 1});
renderer.render(performance.now());
const newDpr = renderer.dpr;
expect(newDpr).to.be.equal(initialDpr + 1);
});
});
});
});
@@ -0,0 +1,290 @@
/* @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 {PerspectiveCamera, Vector3} from 'three';
import {$controls} from '../../features/controls.js';
import {$userInputElement} from '../../model-viewer-base.js';
import {ModelViewerElement} from '../../model-viewer.js';
import {SmoothControls} from '../../three-components/SmoothControls.js';
import {waitForEvent} from '../../utilities.js';
import {assetPath, dispatchSyntheticEvent} from '../helpers.js';
const ONE_FRAME_DELTA = 1000.0 / 60.0;
const FIFTY_FRAME_DELTA = 50.0 * ONE_FRAME_DELTA;
const HALF_PI = Math.PI / 2.0;
const QUARTER_PI = HALF_PI / 2.0;
const THREE_QUARTERS_PI = HALF_PI + QUARTER_PI;
/**
* Settle controls by performing 50 frames worth of updates
*/
export const settleControls = (controls: SmoothControls) =>
controls.update(performance.now(), FIFTY_FRAME_DELTA);
suite('SmoothControls', () => {
let controls: SmoothControls;
let camera: PerspectiveCamera;
let modelViewer: ModelViewerElement;
let element: HTMLDivElement;
setup(async () => {
modelViewer = new ModelViewerElement();
element = modelViewer[$userInputElement];
controls = (modelViewer as any)[$controls];
camera = controls.camera;
modelViewer.style.height = '100px';
document.body.insertBefore(modelViewer, document.body.firstChild);
modelViewer.cameraControls = true;
modelViewer.src = assetPath('models/cube.gltf');
await waitForEvent(modelViewer, 'poster-dismissed');
});
teardown(() => {
document.body.removeChild(modelViewer);
});
suite('when updated', () => {
test('repositions the camera within the configured radius options', () => {
controls.setOrbit(0, HALF_PI, 1.5);
settleControls(controls);
const radius = camera.position.length();
expect(radius).to.be.within(
controls.options.minimumRadius as number,
controls.options.maximumRadius as number);
});
suite('when orbit is changed', () => {
suite('radius', () => {
test('changes the absolute distance to the target', () => {
settleControls(controls);
controls.setOrbit(0, HALF_PI, 2.5);
settleControls(controls);
expect(camera.position.length()).to.be.equal(2.5);
});
});
suite('azimuth', () => {
test('wraps and takes the shortest path', () => {
controls.setOrbit(QUARTER_PI);
settleControls(controls);
expect(controls.getCameraSpherical().theta).to.be.equal(QUARTER_PI);
controls.setOrbit(4 * Math.PI - HALF_PI);
expect(controls.getCameraSpherical().theta)
.to.be.closeTo(4 * Math.PI + QUARTER_PI, 0.0001);
controls.update(performance.now(), ONE_FRAME_DELTA);
expect(Math.abs(controls.getCameraSpherical().theta))
.to.be.lessThan(4 * Math.PI + QUARTER_PI);
settleControls(controls);
expect(controls.getCameraSpherical().theta)
.to.be.closeTo(4 * Math.PI - HALF_PI, 0.0001);
controls.setOrbit(THREE_QUARTERS_PI);
expect(controls.getCameraSpherical().theta)
.to.be.closeTo(Math.PI + HALF_PI, 0.0001);
controls.update(performance.now(), ONE_FRAME_DELTA);
expect(Math.abs(controls.getCameraSpherical().theta))
.to.be.lessThan(Math.PI + HALF_PI);
settleControls(controls);
expect(controls.getCameraSpherical().theta)
.to.be.closeTo(THREE_QUARTERS_PI, 0.0001);
});
test(
'adjustOrbit does not move the goal theta more than pi past the current theta',
() => {
controls.adjustOrbit(-Math.PI * 3 / 2, 0, 0);
controls.update(performance.now(), ONE_FRAME_DELTA);
const startingTheta = controls.getCameraSpherical().theta;
expect(startingTheta).to.be.greaterThan(0);
controls.adjustOrbit(-Math.PI * 3 / 2, 0, 0);
settleControls(controls);
const goalTheta = controls.getCameraSpherical().theta;
expect(goalTheta).to.be.greaterThan(Math.PI);
expect(goalTheta).to.be.lessThan(startingTheta + Math.PI);
});
});
});
suite('keyboard input', () => {
let initialCameraPosition: Vector3;
setup(() => {
settleControls(controls);
initialCameraPosition = camera.position.clone();
});
suite('global keyboard input', () => {
test('does not change orbital position of camera', () => {
dispatchSyntheticEvent(window, 'keydown', {key: 'ArrowUp'});
settleControls(controls);
expect(camera.position.z).to.be.equal(initialCameraPosition.z);
});
});
suite('local keyboard input', () => {
test('changes orbital position of camera', () => {
element.focus();
dispatchSyntheticEvent(element, 'keydown', {key: 'ArrowUp'});
settleControls(controls);
expect(camera.position.z).to.not.be.equal(initialCameraPosition.z);
});
test('changes pan position of camera', () => {
element.focus();
const initialCameraTarget = controls.scene.getTarget();
dispatchSyntheticEvent(
element, 'keydown', {key: 'ArrowLeft', shiftKey: true});
settleControls(controls);
const postCameraTarget = controls.scene.getTarget();
expect(postCameraTarget.x).to.be.greaterThan(initialCameraTarget.x);
});
});
});
suite('customizing options', () => {
suite('azimuth', () => {
setup(() => {
controls.applyOptions({
minimumAzimuthalAngle: -1 * THREE_QUARTERS_PI,
maximumAzimuthalAngle: THREE_QUARTERS_PI
});
});
test('prevents camera azimuth from exceeding options', () => {
controls.setOrbit(-Math.PI, 0, 0);
settleControls(controls);
expect(controls.getCameraSpherical().theta)
.to.be.equal(-1 * THREE_QUARTERS_PI);
controls.setOrbit(Math.PI, 0, 0);
controls.update(performance.now(), ONE_FRAME_DELTA);
expect(Math.abs(controls.getCameraSpherical().theta))
.to.be.lessThan(THREE_QUARTERS_PI);
settleControls(controls);
expect(controls.getCameraSpherical().theta)
.to.be.equal(THREE_QUARTERS_PI);
});
});
suite('pole', () => {
setup(() => {
controls.applyOptions({
minimumPolarAngle: QUARTER_PI,
maximumPolarAngle: THREE_QUARTERS_PI
});
});
test('prevents camera polar angle from exceeding options', () => {
controls.setOrbit(0, 0, 0);
settleControls(controls);
expect(controls.getCameraSpherical().phi).to.be.equal(QUARTER_PI);
controls.setOrbit(0, Math.PI, 0);
settleControls(controls);
expect(controls.getCameraSpherical().phi)
.to.be.equal(THREE_QUARTERS_PI);
});
});
suite('radius', () => {
setup(() => {
controls.applyOptions({minimumRadius: 10, maximumRadius: 20});
});
test('prevents camera distance from exceeding options', () => {
controls.setOrbit(0, 0, 0);
settleControls(controls);
expect(controls.getCameraSpherical().radius).to.be.equal(10);
controls.setOrbit(0, 0, 100);
settleControls(controls);
expect(controls.getCameraSpherical().radius).to.be.equal(20);
});
});
suite('field of view', () => {
setup(() => {
controls.applyOptions(
{minimumFieldOfView: 15, maximumFieldOfView: 20});
});
test('prevents field of view from exceeding options', () => {
controls.setFieldOfView(5);
settleControls(controls);
expect(controls.getFieldOfView()).to.be.closeTo(15, 0.00001);
controls.setFieldOfView(30);
settleControls(controls);
expect(controls.getFieldOfView()).to.be.closeTo(20, 0.00001);
});
});
suite('interaction', () => {
test('orbits when pointing, even while blurred', () => {
const originalPhi = controls.getCameraSpherical().phi;
element.dispatchEvent(new PointerEvent(
'pointerdown', {pointerId: 8, clientX: 0, clientY: 10}));
element.dispatchEvent(new PointerEvent(
'pointermove', {pointerId: 8, clientX: 0, clientY: 0}));
settleControls(controls);
expect(controls.getCameraSpherical().phi)
.to.be.greaterThan(originalPhi);
});
test('zooms when scrolling, even while blurred', () => {
const fov = controls.getFieldOfView();
dispatchSyntheticEvent(element, 'wheel', {deltaY: -1});
settleControls(controls);
expect(controls.getFieldOfView()).to.be.lessThan(fov);
});
});
});
});
});
@@ -0,0 +1,185 @@
/* @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 {Cache, CubeReflectionMapping, EquirectangularReflectionMapping, WebGLRenderer} from 'three';
import TextureUtils from '../../three-components/TextureUtils.js';
import {assetPath} from '../helpers.js';
// Reuse the same canvas as to not stress the WebGL
// context limit
const canvas = document.createElement('canvas');
const EQUI_URL = assetPath('environments/spruit_sunrise_1k_LDR.jpg');
const HDR_EQUI_URL = assetPath('environments/spruit_sunrise_1k_HDR.hdr');
suite('TextureUtils', () => {
let threeRenderer: WebGLRenderer;
suiteSetup(() => {
// The threeRenderer can retain state, so these tests have the possibility
// of getting different results in different orders. However, our use of the
// threeRenderer *should* always return its state to what it was before to
// avoid this kind of problem (and many other headaches).
threeRenderer = new WebGLRenderer({canvas});
threeRenderer.debug.checkShaderErrors = true;
});
suiteTeardown(() => {
// Ensure we free up memory from loading large environment maps:
Cache.clear();
threeRenderer.dispose();
});
let textureUtils: TextureUtils;
setup(() => {
textureUtils = new TextureUtils(threeRenderer);
});
teardown(async () => {
await textureUtils.dispose();
});
suite('load', () => {
test('loads a valid texture from URL', async () => {
let texture = await textureUtils.loadEquirect(EQUI_URL);
texture.dispose();
expect(texture.isTexture).to.be.ok;
expect(texture.name).to.be.eq(EQUI_URL);
expect(texture.mapping).to.be.eq(EquirectangularReflectionMapping);
});
test('loads a valid HDR texture from URL', async () => {
let texture = await textureUtils.loadEquirect(HDR_EQUI_URL);
texture.dispose();
expect(texture.isTexture).to.be.ok;
expect(texture.name).to.be.eq(HDR_EQUI_URL);
expect(texture.mapping).to.be.eq(EquirectangularReflectionMapping);
});
test('throws on invalid URL', async () => {
try {
await textureUtils.loadEquirect('');
expect(false).to.be.ok;
} catch (e) {
expect(true).to.be.ok;
}
});
test('throws if texture not found', async () => {
try {
await textureUtils.loadEquirect('./nope.png');
expect(false).to.be.ok;
} catch (e) {
expect(true).to.be.ok;
}
});
});
suite('generating an environment map and skybox', () => {
test('returns an environmentMap and skybox texture from url', async () => {
const textures =
await textureUtils.generateEnvironmentMapAndSkybox(EQUI_URL);
const skybox = textures.skybox!;
const environment = textures.environmentMap;
expect(skybox.isTexture).to.be.ok;
expect(environment.isTexture).to.be.ok;
expect(skybox.name).to.be.eq(EQUI_URL);
expect(skybox.mapping).to.be.eq(EquirectangularReflectionMapping);
expect(environment.name).to.be.eq(EQUI_URL);
expect(environment.mapping).to.be.eq(EquirectangularReflectionMapping);
});
test(
'returns an environmentMap and skybox texture from an HDR url',
async () => {
const textures =
await textureUtils.generateEnvironmentMapAndSkybox(HDR_EQUI_URL);
const skybox = textures.skybox!;
const environment = textures.environmentMap;
expect(skybox.isTexture).to.be.ok;
expect(environment.isTexture).to.be.ok;
expect(skybox.name).to.be.eq(HDR_EQUI_URL);
expect(skybox.mapping).to.be.eq(EquirectangularReflectionMapping);
expect(environment.name).to.be.eq(HDR_EQUI_URL);
expect(environment.mapping)
.to.be.eq(EquirectangularReflectionMapping);
});
test(
'returns an environmentMap and skybox texture from two urls',
async () => {
const textures = await textureUtils.generateEnvironmentMapAndSkybox(
EQUI_URL, HDR_EQUI_URL);
const skybox = textures.skybox!;
const environment = textures.environmentMap;
expect(skybox.isTexture).to.be.ok;
expect(environment.isTexture).to.be.ok;
expect(skybox.name).to.be.eq(EQUI_URL);
expect(skybox.mapping).to.be.eq(EquirectangularReflectionMapping);
expect(environment.name).to.be.eq(HDR_EQUI_URL);
expect(environment.mapping)
.to.be.eq(EquirectangularReflectionMapping);
});
test('throws if given an invalid url', async () => {
try {
await textureUtils.generateEnvironmentMapAndSkybox();
expect(false).to.be.ok;
} catch (e) {
expect(true).to.be.ok;
}
});
});
suite('dynamically generating environment maps', () => {
test('creates a cubemap render target with PMREM by default', async () => {
const environment =
(await textureUtils.generateEnvironmentMapAndSkybox()).environmentMap;
expect(environment.name).to.be.eq('neutral');
expect(environment.mapping).to.be.eq(CubeReflectionMapping);
});
test('creates a cubemap render target with PMREM for neutral', async () => {
const environment =
(await textureUtils.generateEnvironmentMapAndSkybox(null, 'neutral'))
.environmentMap;
expect(environment.name).to.be.eq('neutral');
expect(environment.mapping).to.be.eq(CubeReflectionMapping);
});
test('creates a cubemap render target with PMREM for legacy', async () => {
const environment =
(await textureUtils.generateEnvironmentMapAndSkybox(null, 'legacy'))
.environmentMap;
expect(environment.name).to.be.eq('legacy');
expect(environment.mapping).to.be.eq(CubeReflectionMapping);
});
});
});
@@ -0,0 +1,93 @@
/* @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 {BufferGeometry, DoubleSide, Mesh, MeshStandardMaterial} from 'three';
import {GLTF} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {ModelViewerGLTFInstance} from '../../../three-components/gltf-instance/ModelViewerGLTFInstance.js';
import {PreparedGLTF} from '../../../three-components/GLTFInstance.js';
import {createFakeThreeGLTF} from '../../helpers.js';
suite('ModelViewerGLTFInstance', () => {
let rawGLTF: GLTF;
let preparedGLTF: PreparedGLTF;
setup(async () => {
rawGLTF = createFakeThreeGLTF();
const materialOne = new MeshStandardMaterial();
const materialTwo = new MeshStandardMaterial();
materialTwo.transparent = true;
materialTwo.side = DoubleSide;
const meshOne = new Mesh(new BufferGeometry(), materialOne);
const meshTwo = new Mesh(new BufferGeometry(), materialOne);
const meshThree = new Mesh(new BufferGeometry(), materialTwo);
rawGLTF.scene.add(meshOne, meshTwo, meshThree);
preparedGLTF = await ModelViewerGLTFInstance.prepare(rawGLTF);
});
suite('with a prepared GLTF', () => {
suite('when cloned', () => {
let cloneInstance: ModelViewerGLTFInstance;
let gltfInstance: ModelViewerGLTFInstance;
setup(async () => {
gltfInstance = new ModelViewerGLTFInstance(preparedGLTF);
cloneInstance = await gltfInstance.clone<ModelViewerGLTFInstance>();
});
teardown(() => {
gltfInstance.dispose();
cloneInstance.dispose();
});
test('clones materials in a mesh', () => {
const [originalMeshOne, originalMeshTwo, originalMeshThree] =
gltfInstance.scene.children as [Mesh, Mesh, Mesh];
const [meshOne, meshTwo, meshThree] =
cloneInstance.scene.children as [Mesh, Mesh, Mesh];
expect(originalMeshOne.material).to.not.be.equal(meshOne.material);
expect(originalMeshTwo.material).to.not.be.equal(meshTwo.material);
expect(originalMeshThree.material).to.not.be.equal(meshThree.material);
});
test('only clones a discrete material once', () => {
const [meshOne, meshTwo, meshThree] =
cloneInstance.scene.children as [Mesh, Mesh, Mesh];
expect(meshOne.material).to.be.equal(meshTwo.material);
expect(meshOne.material).to.not.be.equal(meshThree.material);
});
});
});
suite('preparing the GLTF', () => {
test('sets meshes to cast shadows', () => {
(preparedGLTF.scene.children as [Mesh, Mesh, Mesh])
.forEach(mesh => expect(mesh.castShadow).to.be.true);
});
test('disables frustum culling on meshes', () => {
(preparedGLTF.scene.children as [Mesh, Mesh, Mesh])
.forEach(mesh => expect(mesh.frustumCulled).to.be.false);
});
});
});
@@ -0,0 +1,166 @@
/* @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 {Group, Mesh, MeshStandardMaterial, Object3D} from 'three';
import {GLTF} from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils.js';
import {CorrelatedSceneGraph} from '../../../three-components/gltf-instance/correlated-scene-graph.js';
import {Material, PBRMetallicRoughness, Texture, TextureInfo} from '../../../three-components/gltf-instance/gltf-2.0.js';
import {assetPath, loadThreeGLTF} from '../../helpers.js';
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
const ORDER_TEST_GLB_PATH = assetPath('models/order-test/order-test.glb');
const KHRONOS_TRIANGLE_GLB_PATH =
assetPath('models/glTF-Sample-Assets/Models/Triangle/glTF/Triangle.gltf');
const BRAINSTEM_GLB_PATH = assetPath(
'models/glTF-Sample-Assets/Models/BrainStem/glTF-Binary/BrainStem.glb');
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
const getObject3DByName =
<T extends Object3D>(root: Object3D, name: string): T|null => {
const objects = [root];
while (objects.length) {
const next = objects.shift()!;
if (next.name === name) {
return next as T;
}
objects.push(...next.children);
}
return null;
};
suite('correlated-scene-graph', () => {
suite('CorrelatedSceneGraph', () => {
test('maps Three.js materials to glTF elements', async () => {
const threeGLTF = await loadThreeGLTF(HORSE_GLB_PATH);
const correlatedSceneGraph = CorrelatedSceneGraph.from(threeGLTF);
const threeMaterial =
((threeGLTF.scene.children[0] as Mesh).material as
MeshStandardMaterial);
const gltfMaterial = threeGLTF.parser.json.materials[0]! as Material;
const gltfReference =
correlatedSceneGraph.threeObjectMap.get(threeMaterial);
expect(gltfReference).to.be.ok;
const {materials} = gltfReference!;
const referencedGltfMaterial =
threeGLTF.parser.json['materials'][materials!];
expect(referencedGltfMaterial).to.be.equal(gltfMaterial);
});
test('maps Three.js textures to glTF elements', async () => {
const threeGLTF = await loadThreeGLTF(ORDER_TEST_GLB_PATH);
const correlatedSceneGraph = CorrelatedSceneGraph.from(threeGLTF);
const threeMaterial =
getObject3DByName<Mesh>(threeGLTF.scene, 'Node0')!.material as
MeshStandardMaterial;
const threeTexture = threeMaterial.map!;
const gltfMaterial = threeGLTF.parser.json.materials[2]! as Material;
const textureIndex =
((gltfMaterial.pbrMetallicRoughness as PBRMetallicRoughness)
.baseColorTexture as TextureInfo)
.index;
const gltfTexture =
threeGLTF.parser.json.textures[textureIndex] as Texture;
const gltfReference =
correlatedSceneGraph.threeObjectMap.get(threeTexture);
expect(gltfReference).to.be.ok;
const {textures} = gltfReference!;
const referencedGltfTexture =
threeGLTF.parser.json['textures'][textures!];
expect(referencedGltfTexture).to.be.equal(gltfTexture);
});
test('has a mapping for each node in scene', async () => {
const threeGLTF = await loadThreeGLTF(BRAINSTEM_GLB_PATH);
const correlatedSceneGraph = CorrelatedSceneGraph.from(threeGLTF);
threeGLTF.scene.traverse(node => {
if (threeGLTF.scene === node) {
return;
}
expect(correlatedSceneGraph.threeObjectMap.get(node)).to.be.ok;
});
});
test('has a mapping for each material & texture', async () => {
const threeGLTF = await loadThreeGLTF(ASTRONAUT_GLB_PATH);
const correlatedSceneGraph = CorrelatedSceneGraph.from(threeGLTF);
for (const texture of threeGLTF.parser.json.textures) {
expect(correlatedSceneGraph.gltfElementMap.get(texture)).to.be.ok;
}
for (const material of threeGLTF.parser.json.materials) {
expect(correlatedSceneGraph.gltfElementMap.get(material)).to.be.ok;
}
});
suite('when correlating a cloned glTF', () => {
test('creates a GLTFLoader "default" material', async () => {
const threeGLTF = await loadThreeGLTF(KHRONOS_TRIANGLE_GLB_PATH);
const correlatedSceneGraph = CorrelatedSceneGraph.from(threeGLTF);
const scene = SkeletonUtils.clone(threeGLTF.scene) as Group;
const scenes: Group[] = [scene];
const cloneThreeGLTF: GLTF = {...threeGLTF, scene, scenes};
const cloneCorrelatedSceneGraph =
CorrelatedSceneGraph.from(cloneThreeGLTF, correlatedSceneGraph);
let name;
cloneCorrelatedSceneGraph.threeObjectMap.forEach(
(mappings, threeObject) => {
if ((threeObject as MeshStandardMaterial).isMaterial) {
const index = mappings.materials!;
name = cloneCorrelatedSceneGraph.gltf.materials![index].name;
}
});
expect(name).to.be.eq('Default');
});
test('Only one default material after cloning', async () => {
const threeGLTF = await loadThreeGLTF(KHRONOS_TRIANGLE_GLB_PATH);
const correlatedSceneGraph = CorrelatedSceneGraph.from(threeGLTF);
const scene = SkeletonUtils.clone(threeGLTF.scene) as Group;
const scenes: Group[] = [scene];
const cloneThreeGLTF: GLTF = {...threeGLTF, scene, scenes};
const cloneCorrelatedSceneGraph =
CorrelatedSceneGraph.from(cloneThreeGLTF, correlatedSceneGraph);
expect(cloneCorrelatedSceneGraph.gltf.materials!.length).to.be.eq(1);
expect(cloneCorrelatedSceneGraph.gltf.materials![0].name)
.to.be.eq('Default');
});
});
});
});
@@ -0,0 +1,67 @@
/* @license
* Copyright 2020 Google LLC. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {expect} from 'chai';
import {GLTF} from '../../../three-components/gltf-instance/gltf-2.0.js';
import {GLTFTreeVisitor} from '../../../three-components/gltf-instance/utilities.js';
import {assetPath, loadThreeGLTF} from '../../helpers.js';
const ORDER_TEST_GLB_PATH = assetPath('models/order-test/order-test.glb');
suite('utilities', () => {
suite('GLTFTreeVisitor', () => {
let gltf: GLTF;
setup(async () => {
const threeGLTF = await loadThreeGLTF(ORDER_TEST_GLB_PATH);
gltf = threeGLTF.parser.json;
});
test('visits materials in tree order', () => {
const materials: string[] = [];
const visitor = new GLTFTreeVisitor(
{material: (material) => materials.push(material.name!)});
visitor.visit(gltf);
expect(materials).to.be.deep.equal([
'Material0',
'Material1',
'Material2',
'Material2',
'Material1',
'Material0',
'Material2',
]);
});
suite('sparse traversal', () => {
test('visits materials in tree order', () => {
const materials: string[] = [];
const visitor = new GLTFTreeVisitor(
{material: (material) => materials.push(material.name!)});
visitor.visit(gltf, {sparse: true});
expect(materials).to.be.deep.equal([
'Material0',
'Material1',
'Material2',
]);
});
});
});
});
@@ -0,0 +1,60 @@
/* @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 {clamp, deserializeUrl, step} from '../utilities.js';
suite('utils', () => {
suite('deserializeUrl', () => {
test('returns a string given a string', () => {
expect(deserializeUrl('foo')).to.be.a('string');
});
test('returns null given a null-ish value', () => {
expect(deserializeUrl(null)).to.be.equal(null);
});
test('yields a url on the same origin for relative paths', () => {
const {origin} = window.location;
expect(deserializeUrl('foo')!.indexOf(origin)).to.be.equal(0);
});
});
suite('step', () => {
test('returns 0 for values below edge', () => {
expect(step(0.5, 0.1)).to.be.equal(0);
});
test('returns 1 for values above edge', () => {
expect(step(0.5, 0.9)).to.be.equal(1);
});
});
suite('clamp', () => {
test('numbers below lower limit adjusted to lower limit', () => {
expect(clamp(1.0, 2.0, 3.0)).to.be.equal(2.0);
});
test('numbers above upper limit adjusted to upper limit', () => {
expect(clamp(4.0, 2.0, 3.0)).to.be.equal(3.0);
});
test('numbers within lower and upper limits unchanged', () => {
expect(clamp(2.5, 2.0, 3.0)).to.be.equal(2.5);
});
});
});
@@ -0,0 +1,79 @@
/* @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 {interpolate, sequence, timeline} from '../../utilities/animation.js';
const easeLinear = (t: number) => t;
const PRECISION = 0.000001;
suite('animation', () => {
suite('interpolate', () => {
test('interpolates from start to end values', () => {
const timingForward = interpolate(0, 100, easeLinear);
const timingBackward = interpolate(100, 0, easeLinear);
for (let i = 0; i < 10; ++i) {
expect(timingForward(i / 10)).to.be.equal(i * 10);
expect(timingBackward(i / 10)).to.be.equal(100 - i * 10);
}
});
});
suite('sequence', () => {
test('interpolates across a sequence of timing functions', () => {
const timingForward = interpolate(0, 100, easeLinear);
const timingBackward = interpolate(100, 0, easeLinear);
const forwardBackward = sequence([timingForward, timingBackward], [1, 1]);
for (let i = 0; i < 10; ++i) {
expect(forwardBackward(i / 10))
.to.be.closeTo(i < 5 ? i * 20 : 100 - (i - 5) * 20, PRECISION);
}
});
test('allows for timing functions to be relatively weighted', () => {
const timingForward = interpolate(0, 100, easeLinear);
const timingBackward = interpolate(90, 0, easeLinear);
const forwardBackward = sequence([timingForward, timingBackward], [1, 9]);
for (let i = 0; i < 10; ++i) {
expect(forwardBackward(i / 10))
.to.be.closeTo(i < 2 ? i * 100 : (10 - i) * 10, PRECISION)
}
});
});
suite('timeline', () => {
test('creates a timing function sequence from keyframes', () => {
const forwardBackward = timeline({
initialValue: 0,
keyframes: [
{frames: 5, value: 100, ease: easeLinear},
{frames: 5, value: 0, ease: easeLinear}
]
});
for (let i = 0; i < 10; ++i) {
for (let i = 0; i < 10; ++i) {
expect(forwardBackward(i / 10))
.to.be.closeTo(i < 5 ? i * 20 : 100 - (i - 5) * 20, PRECISION);
}
}
});
});
});
@@ -0,0 +1,82 @@
/* @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 {CacheEvictionPolicy} from '../../utilities/cache-eviction-policy.js';
suite('CacheEvictionPolicy', () => {
let cache: Set<string>;
let policy: CacheEvictionPolicy<string>;
setup(() => {
cache = new Set();
policy = new CacheEvictionPolicy(cache);
});
test('reports zero retainers for uncached items', () => {
policy.retain('foo');
expect(policy.retainerCount('bar')).to.be.equal(0);
});
test('accounts for total retainers of cached items', () => {
policy.retain('foo');
policy.retain('foo');
policy.release('foo');
policy.retain('foo');
policy.release('foo');
expect(policy.retainerCount('foo')).to.be.equal(1);
});
test('ignores non-retained cached items within the threshold', () => {
cache.add('foo');
policy.retain('foo');
policy.release('foo');
expect(policy.retainerCount('foo')).to.be.equal(0);
expect(cache.has('foo')).to.be.true;
});
test('deletes non-retained cached items outside the threshold', () => {
cache.add('foo');
policy.retain('foo');
policy.evictionThreshold = 0;
policy.release('foo');
expect(policy.retainerCount('foo')).to.be.equal(0);
expect(cache.has('foo')).to.be.false;
});
test('deletes non-retained cached items least-recently-used first', () => {
cache.add('foo');
cache.add('bar');
policy.retain('foo');
policy.retain('bar');
policy.evictionThreshold = 1;
policy.release('foo');
policy.release('bar');
expect(policy.retainerCount('foo')).to.be.equal(0);
expect(policy.retainerCount('bar')).to.be.equal(0);
expect(cache.has('foo')).to.be.false;
expect(cache.has('bar')).to.be.true;
});
});
@@ -0,0 +1,167 @@
/* @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 {waitForEvent} from '../../utilities.js';
import {Activity, ProgressDetails, ProgressTracker} from '../../utilities/progress-tracker.js';
suite('ProgressTracker', () => {
const progressReason = 'progress-test';
let progressTracker: ProgressTracker;
setup(() => {
progressTracker = new ProgressTracker();
});
test('starts out with zero ongoing activities', () => {
expect(progressTracker.ongoingActivityCount).to.be.equal(0);
});
test('event includes the reason for the progress', async () => {
const activity = progressTracker.beginActivity(progressReason);
const progressEventDispatches =
waitForEvent<CustomEvent<ProgressDetails>>(progressTracker, 'progress');
activity(0.5);
const event = await progressEventDispatches;
expect(event.detail.reason).to.be.equal(progressReason);
});
suite('an activity', () => {
let firstActivity: Activity;
setup(() => {
firstActivity = progressTracker.beginActivity(progressReason);
});
test('increases the ongoing activities count', () => {
expect(progressTracker.ongoingActivityCount).to.be.equal(1);
});
suite('with partial progress', () => {
test(
'causes the ProgressTracker to dispatch a progress event',
async () => {
const progressEventDispatches =
waitForEvent<CustomEvent<ProgressDetails>>(
progressTracker, 'progress');
firstActivity(0.5);
const event = await progressEventDispatches;
expect(event.detail.totalProgress).to.be.equal(0.5);
const progressEventDispatches2 =
waitForEvent<CustomEvent<ProgressDetails>>(
progressTracker, 'progress');
firstActivity(0.75);
const event2 = await progressEventDispatches2;
expect(event2.detail.totalProgress).to.be.equal(0.75);
});
test('only allows progress to advance', () => {
const initialProgress = firstActivity(0.5);
const nextProgress = firstActivity(0.2);
expect(nextProgress).to.be.equal(initialProgress);
});
suite('a late-added activity', () => {
setup(() => {
firstActivity(0.5);
});
test('is added to the current stack of activities', () => {
progressTracker.beginActivity(progressReason);
expect(progressTracker.ongoingActivityCount).to.be.equal(2);
});
test('defers marking all activities completed', () => {
progressTracker.beginActivity(progressReason);
firstActivity(1.0);
expect(progressTracker.ongoingActivityCount).to.be.equal(2);
});
});
});
suite('completed', () => {
test('ProgressTracker dispatches a final progress event', async () => {
const progressEventDispatches =
waitForEvent<CustomEvent<ProgressDetails>>(
progressTracker, 'progress');
firstActivity(1.0);
const event: CustomEvent<ProgressDetails> =
await progressEventDispatches;
expect(event.detail.totalProgress).to.be.equal(1);
});
test('ProgressTracker resets ongoing activity count', () => {
firstActivity(1.0);
expect(progressTracker.ongoingActivityCount).to.be.equal(0);
});
});
suite('with another activity', () => {
let secondActivity: Activity;
setup(() => {
secondActivity = progressTracker.beginActivity(progressReason);
});
test('increases the ongoing activity count', () => {
expect(progressTracker.ongoingActivityCount).to.be.equal(2);
});
test('each activity progresses independently', () => {
let firstProgress = firstActivity(0.2);
let secondProgress = secondActivity(0.1);
expect(firstProgress).to.be.equal(0.2);
expect(secondProgress).to.be.equal(0.1);
secondProgress = secondActivity(1.0);
firstProgress = firstActivity(0.3);
expect(firstProgress).to.be.equal(0.3);
expect(secondProgress).to.be.equal(1.0);
});
suite('all activities completed', () => {
test('ProgressTracker resets ongoing activity count', () => {
firstActivity(1.0);
secondActivity(1.0);
expect(progressTracker.ongoingActivityCount).to.be.equal(0);
});
test(
'ProgressTracker dispatches a final progress event after the last activity is completed',
async () => {
secondActivity(1.0);
const progressEventDispatches =
waitForEvent<CustomEvent<ProgressDetails>>(
progressTracker, 'progress');
firstActivity(1.0);
const event: CustomEvent<ProgressDetails> =
await progressEventDispatches;
expect(event.detail.totalProgress).to.be.equal(1);
});
});
});
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,241 @@
/* @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 {EventDispatcher, Texture, WebGLRenderer} from 'three';
import {MeshoptDecoder} from 'three/examples/jsm/libs/meshopt_decoder.module.js';
import {DRACOLoader} from 'three/examples/jsm/loaders/DRACOLoader.js';
import {GLTF, GLTFLoader} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {KTX2Loader} from 'three/examples/jsm/loaders/KTX2Loader.js';
import ModelViewerElementBase from '../model-viewer-base.js';
import {CacheEvictionPolicy} from '../utilities/cache-eviction-policy.js';
import GLTFMaterialsVariantsExtension from './gltf-instance/VariantMaterialLoaderPlugin.js';
import {GLTFInstance, GLTFInstanceConstructor} from './GLTFInstance.js';
export type ProgressCallback = (progress: number) => void;
(Texture as any).DEFAULT_ANISOTROPY = 4;
/**
* A helper to Promise-ify a Three.js GLTFLoader
*/
export const loadWithLoader =
(url: string,
loader: GLTFLoader,
progressCallback: ProgressCallback = () => {}) => {
const onProgress = (event: ProgressEvent) => {
const fraction = event.loaded / event.total;
progressCallback!
(Math.max(0, Math.min(1, isFinite(fraction) ? fraction : 1)));
};
return new Promise<GLTF>((resolve, reject) => {
loader.load(url, resolve, onProgress, reject);
});
};
/** Helper to load a script tag. */
const fetchScript = (src: string): Promise<Event> => {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
document.body.appendChild(script);
script.onload = resolve;
script.onerror = reject;
script.async = true;
script.src = src;
});
};
const cache = new Map<string, Promise<GLTFInstance>>();
const preloaded = new Map<string, boolean>();
let dracoDecoderLocation: string;
const dracoLoader = new DRACOLoader();
let ktx2TranscoderLocation: string;
const ktx2Loader = new KTX2Loader();
let meshoptDecoderLocation: string;
let meshoptDecoder: Promise<typeof MeshoptDecoder>|undefined;
export const $loader = Symbol('loader');
export const $evictionPolicy = Symbol('evictionPolicy');
const $GLTFInstance = Symbol('GLTFInstance');
export class CachingGLTFLoader<T extends GLTFInstanceConstructor =
GLTFInstanceConstructor> extends
EventDispatcher<
{'preload': {element: ModelViewerElementBase, src: String}}> {
static setDRACODecoderLocation(url: string) {
dracoDecoderLocation = url;
dracoLoader.setDecoderPath(url);
}
static getDRACODecoderLocation() {
return dracoDecoderLocation;
}
static setKTX2TranscoderLocation(url: string) {
ktx2TranscoderLocation = url;
ktx2Loader.setTranscoderPath(url);
}
static getKTX2TranscoderLocation() {
return ktx2TranscoderLocation;
}
static setMeshoptDecoderLocation(url: string) {
if (meshoptDecoderLocation !== url) {
meshoptDecoderLocation = url;
meshoptDecoder = fetchScript(url)
.then(() => MeshoptDecoder.ready)
.then(() => MeshoptDecoder);
}
}
static getMeshoptDecoderLocation() {
return meshoptDecoderLocation;
}
static initializeKTX2Loader(renderer: WebGLRenderer) {
ktx2Loader.detectSupport(renderer);
}
static[$evictionPolicy]: CacheEvictionPolicy =
new CacheEvictionPolicy(CachingGLTFLoader);
static get cache() {
return cache;
}
/** @nocollapse */
static clearCache() {
cache.forEach((_value, url) => {
this.delete(url);
});
this[$evictionPolicy].reset();
}
static has(url: string) {
return cache.has(url);
}
/** @nocollapse */
static async delete(url: string) {
if (!this.has(url)) {
return;
}
const gltfLoads = cache.get(url);
preloaded.delete(url);
cache.delete(url);
const gltf = await gltfLoads;
// Dispose of the cached glTF's materials and geometries:
gltf!.dispose();
}
/**
* Returns true if the model that corresponds to the specified url is
* available in our local cache.
*/
static hasFinishedLoading(url: string) {
return !!preloaded.get(url);
}
constructor(GLTFInstance: T) {
super();
this[$GLTFInstance] = GLTFInstance;
this[$loader].setDRACOLoader(dracoLoader);
this[$loader].setKTX2Loader(ktx2Loader);
}
protected[$loader]: GLTFLoader = new GLTFLoader().register(
parser => new GLTFMaterialsVariantsExtension(parser));
protected[$GLTFInstance]: T;
protected get[$evictionPolicy](): CacheEvictionPolicy {
return (this.constructor as typeof CachingGLTFLoader)[$evictionPolicy];
}
/**
* Preloads a glTF, populating the cache. Returns a promise that resolves
* when the cache is populated.
*/
async preload(
url: string, element: ModelViewerElementBase,
progressCallback: ProgressCallback = () => {}) {
this[$loader].setWithCredentials(element.withCredentials);
this.dispatchEvent({type: 'preload', element: element, src: url});
if (!cache.has(url)) {
if (meshoptDecoder != null) {
this[$loader].setMeshoptDecoder(await meshoptDecoder);
}
const rawGLTFLoads =
loadWithLoader(url, this[$loader], (progress: number) => {
progressCallback(progress * 0.8);
});
const GLTFInstance = this[$GLTFInstance];
const gltfInstanceLoads = rawGLTFLoads
.then((rawGLTF) => {
return GLTFInstance.prepare(rawGLTF);
})
.then((preparedGLTF) => {
progressCallback(0.9);
return new GLTFInstance(preparedGLTF);
})
.catch((reason => {
console.error(reason);
return new GLTFInstance();
}));
cache.set(url, gltfInstanceLoads);
}
await cache.get(url);
preloaded.set(url, true);
if (progressCallback) {
progressCallback(1.0);
}
}
/**
* Loads a glTF from the specified url and resolves a unique clone of the
* glTF. If the glTF has already been loaded, makes a clone of the cached
* copy.
*/
async load(
url: string, element: ModelViewerElementBase,
progressCallback: ProgressCallback = () => {}): Promise<InstanceType<T>> {
await this.preload(url, element, progressCallback);
const gltf = await cache.get(url)!;
const clone = await gltf.clone() as InstanceType<T>;
this[$evictionPolicy].retain(url);
// Patch dispose so that we can properly account for instance use
// in the caching layer:
clone.dispose = () => {
this[$evictionPolicy].release(url);
};
return clone;
}
}
@@ -0,0 +1,80 @@
/* @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.
*/
export const SETTLING_TIME = 10000; // plenty long enough
const MIN_DECAY_MILLISECONDS = 0.001;
export const DECAY_MILLISECONDS = 50;
/**
* The Damper class is a generic second-order critically damped system that does
* one linear step of the desired length of time. The only parameter is
* DECAY_MILLISECONDS. This common parameter makes all states converge at the
* same rate regardless of scale. xNormalization is a number to provide the
* rough scale of x, such that NIL_SPEED clamping also happens at roughly the
* same convergence for all states.
*/
export class Damper {
private velocity: number = 0;
private naturalFrequency: number = 0;
constructor(decayMilliseconds: number = DECAY_MILLISECONDS) {
this.setDecayTime(decayMilliseconds);
}
setDecayTime(decayMilliseconds: number) {
this.naturalFrequency =
1 / Math.max(MIN_DECAY_MILLISECONDS, decayMilliseconds);
}
update(
x: number,
xGoal: number,
timeStepMilliseconds: number,
xNormalization: number,
): number {
const nilSpeed = 0.0002 * this.naturalFrequency;
if (x == null || xNormalization === 0) {
return xGoal;
}
if (x === xGoal && this.velocity === 0) {
return xGoal;
}
if (timeStepMilliseconds < 0) {
return x;
}
// Exact solution to a critically damped second-order system, where:
// acceleration = this.naturalFrequency * this.naturalFrequency * (xGoal
// - x) - 2 * this.naturalFrequency * this.velocity;
const deltaX = (x - xGoal);
const intermediateVelocity = this.velocity + this.naturalFrequency * deltaX;
const intermediateX = deltaX + timeStepMilliseconds * intermediateVelocity;
const decay = Math.exp(-this.naturalFrequency * timeStepMilliseconds);
const newVelocity =
(intermediateVelocity - this.naturalFrequency * intermediateX) * decay;
const acceleration =
-this.naturalFrequency * (newVelocity + intermediateVelocity * decay);
if (Math.abs(newVelocity) < nilSpeed * Math.abs(xNormalization) &&
acceleration * deltaX >= 0) {
// This ensures the controls settle and stop calling this function instead
// of asymptotically approaching their goal.
this.velocity = 0;
return xGoal;
} else {
this.velocity = newVelocity;
return xGoal + intermediateX * decay;
}
}
}
@@ -0,0 +1,244 @@
/* @license
* Copyright 2021 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 {BackSide, BoxGeometry, Mesh, MeshBasicMaterial, MeshStandardMaterial, PointLight, Scene} from 'three';
interface Box {
position: [number, number, number];
rotation: number;
scale: [number, number, number];
}
interface Light {
intensity: number;
position: [number, number, number];
scale: [number, number, number];
}
interface Env {
topLight: {
intensity: number,
position: [number, number, number],
};
room: {
position: [number, number, number],
scale: [number, number, number],
};
boxes: Box[];
lights: Light[];
}
const legacy = {
topLight: {
intensity: 500,
position: [0.418, 16.199, 0.300],
},
room: {
position: [-0.757, 13.219, 0.717],
scale: [31.713, 28.305, 28.591],
},
boxes: [
{
position: [-10.906, 2.009, 1.846],
rotation: -0.195,
scale: [2.328, 7.905, 4.651],
},
{
position: [-5.607, -0.754, -0.758],
rotation: 0.994,
scale: [1.970, 1.534, 3.955],
},
{
position: [6.167, 0.857, 7.803],
rotation: 0.561,
scale: [3.927, 6.285, 3.687],
},
{
position: [-2.017, 0.018, 6.124],
rotation: 0.333,
scale: [2.002, 4.566, 2.064],
},
{
position: [2.291, -0.756, -2.621],
rotation: -0.286,
scale: [1.546, 1.552, 1.496],
},
{
position: [-2.193, -0.369, -5.547],
rotation: 0.516,
scale: [3.875, 3.487, 2.986],
},
],
lights: [
{
intensity: 50,
position: [-16.116, 14.37, 8.208],
scale: [0.1, 2.428, 2.739],
},
{
intensity: 50,
position: [-16.109, 18.021, -8.207],
scale: [0.1, 2.425, 2.751],
},
{
intensity: 17,
position: [14.904, 12.198, -1.832],
scale: [0.15, 4.265, 6.331],
},
{
intensity: 43,
position: [-0.462, 8.89, 14.520],
scale: [4.38, 5.441, 0.088],
},
{
intensity: 20,
position: [3.235, 11.486, -12.541],
scale: [2.5, 2.0, 0.1],
},
{
intensity: 100,
position: [0.0, 20.0, 0.0],
scale: [1.0, 0.1, 1.0],
},
]
} as Env;
const neutral = {
topLight: {
intensity: 400,
position: [0.5, 14.0, 0.5],
},
room: {
position: [0.0, 13.2, 0.0],
scale: [31.5, 28.5, 31.5],
},
boxes: [
{
position: [-10.906, -1.0, 1.846],
rotation: -0.195,
scale: [2.328, 7.905, 4.651],
},
{
position: [-5.607, -0.754, -0.758],
rotation: 0.994,
scale: [1.970, 1.534, 3.955],
},
{
position: [6.167, -0.16, 7.803],
rotation: 0.561,
scale: [3.927, 6.285, 3.687],
},
{
position: [-2.017, 0.018, 6.124],
rotation: 0.333,
scale: [2.002, 4.566, 2.064],
},
{
position: [2.291, -0.756, -2.621],
rotation: -0.286,
scale: [1.546, 1.552, 1.496],
},
{
position: [-2.193, -0.369, -5.547],
rotation: 0.516,
scale: [3.875, 3.487, 2.986],
},
],
lights: [
{
intensity: 80,
position: [-14.0, 10.0, 8.0],
scale: [0.1, 2.5, 2.5],
},
{
intensity: 80,
position: [-14.0, 14.0, -4.0],
scale: [0.1, 2.5, 2.5],
},
{
intensity: 23,
position: [14.0, 12.0, 0.0],
scale: [0.1, 5.0, 5.0],
},
{
intensity: 16,
position: [0.0, 9.0, 14.0],
scale: [5.0, 5.0, 0.1],
},
{
intensity: 80,
position: [7.0, 8.0, -14.0],
scale: [2.5, 2.5, 0.1],
},
{
intensity: 80,
position: [-7.0, 16.0, -14.0],
scale: [2.5, 2.5, 0.1],
},
{
intensity: 1,
position: [0.0, 20.0, 0.0],
scale: [0.1, 0.1, 0.1],
},
]
} as Env;
export default class EnvironmentScene extends Scene {
constructor(name: 'legacy'|'neutral') {
super();
this.position.y = -3.5;
const geometry = new BoxGeometry();
geometry.deleteAttribute('uv');
const roomMaterial =
new MeshStandardMaterial({metalness: 0, side: BackSide});
const boxMaterial = new MeshStandardMaterial({metalness: 0});
const data = name == 'legacy' ? legacy : neutral;
const mainLight = new PointLight(0xffffff, data.topLight.intensity, 28, 2);
mainLight.position.set(...data.topLight.position);
this.add(mainLight);
const room = new Mesh(geometry, roomMaterial);
room.position.set(...data.room.position);
room.scale.set(...data.room.scale);
this.add(room);
for (const box of data.boxes) {
const box1 = new Mesh(geometry, boxMaterial);
box1.position.set(...box.position);
box1.rotation.set(0, box.rotation, 0);
box1.scale.set(...box.scale);
this.add(box1);
}
for (const light of data.lights) {
const light1 =
new Mesh(geometry, this.createAreaLightMaterial(light.intensity));
light1.position.set(...light.position);
light1.scale.set(...light.scale);
this.add(light1);
}
}
createAreaLightMaterial(intensity: number): MeshBasicMaterial {
const material = new MeshBasicMaterial();
material.color.setScalar(intensity);
return material;
}
}
@@ -0,0 +1,206 @@
/* @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 {Group, Mesh, Object3D, Texture} from 'three';
import {GLTF} from 'three/examples/jsm/loaders/GLTFLoader.js';
import * as SkeletonUtils from 'three/examples/jsm/utils/SkeletonUtils.js';
import {Constructor} from '../utilities.js';
export const $prepared = Symbol('prepared');
export interface PreparedGLTF extends GLTF {
[$prepared]?: boolean;
}
export const $prepare = Symbol('prepare');
export const $preparedGLTF = Symbol('preparedGLTF');
export const $clone = Symbol('clone');
/**
* Represents the preparation and enhancement of the output of a Three.js
* GLTFLoader (a Three.js-flavor "GLTF"), to make it suitable for optimal,
* correct viewing in a given presentation context and also make the cloning
* process more explicit and legible.
*
* A GLTFInstance is API-compatible with a Three.js-flavor "GLTF", so it should
* be considered to be interchangeable with the loaded result of a GLTFLoader.
*
* This basic implementation only implements trivial preparation and enhancement
* of a GLTF. These operations are intended to be enhanced by inheriting
* classes.
*/
export class GLTFInstance implements GLTF {
/**
* Prepares a given GLTF for presentation and future cloning. A GLTF that is
* prepared can safely have this method invoked on it multiple times; it will
* only be prepared once, including after being cloned.
*/
static prepare(source: GLTF): PreparedGLTF {
if (source.scene == null) {
throw new Error('Model does not have a scene');
}
if ((source as PreparedGLTF)[$prepared]) {
return source;
}
const prepared = this[$prepare](source) as Partial<PreparedGLTF>;
// NOTE: ES5 Symbol polyfill is not compatible with spread operator
// so {...prepared, [$prepared]: true} does not work
prepared[$prepared] = true;
return prepared as PreparedGLTF;
}
/**
* Override in an inheriting class to apply specialty one-time preparations
* for a given input GLTF.
*/
protected static[$prepare](source: GLTF): GLTF {
// TODO(#195,#1003): We don't currently support multiple scenes, so we don't
// bother preparing extra scenes for now:
const {scene} = source;
const scenes = [scene];
return {...source, scene, scenes};
}
protected[$preparedGLTF]: PreparedGLTF;
get parser() {
return this[$preparedGLTF].parser;
}
get animations() {
return this[$preparedGLTF].animations;
}
get scene() {
return this[$preparedGLTF].scene;
}
get scenes() {
return this[$preparedGLTF].scenes;
}
get cameras() {
return this[$preparedGLTF].cameras;
}
get asset() {
return this[$preparedGLTF].asset;
}
get userData() {
return this[$preparedGLTF].userData;
}
constructor(preparedGLTF: PreparedGLTF) {
this[$preparedGLTF] = preparedGLTF;
}
/**
* Creates and returns a copy of this instance.
*/
clone<T extends GLTFInstance>(): T {
const GLTFInstanceConstructor = this.constructor as Constructor<T>;
const clonedGLTF = this[$clone]();
return new GLTFInstanceConstructor(clonedGLTF);
}
/**
* Cleans up any retained memory that might not otherwise be released when
* this instance is done being used.
*/
dispose(): void {
this.scenes.forEach((scene: Group) => {
scene.traverse((object: Object3D) => {
const mesh = object as Mesh;
if (!mesh.material) {
return;
}
const materials =
Array.isArray(mesh.material) ? mesh.material : [mesh.material];
materials.forEach(material => {
// Explicitly dispose any textures assigned to this material
for (const propertyName in material) {
const texture = (material as any)[propertyName];
if (texture instanceof Texture) {
const image = texture.source.data;
if (image.close != null) {
image.close();
}
texture.dispose();
}
}
material.dispose();
});
mesh.geometry.dispose();
});
});
}
/**
* Override in an inheriting class to implement specialized cloning strategies
*/
protected[$clone](): PreparedGLTF {
const source = this[$preparedGLTF];
// TODO(#195,#1003): We don't currently support multiple scenes, so we don't
// bother cloning extra scenes for now:
const scene = SkeletonUtils.clone(this.scene) as Group;
cloneVariantMaterials(scene, this.scene);
const scenes = [scene];
const userData = source.userData ? {...source.userData} : {};
return {...source, scene, scenes, userData};
}
}
// Variant materials and original material instances are stored under
// object.userData.variantMaterials/originalMaterial.
// Three.js Object3D.clone() doesn't clone Three.js objects under
// .userData so this function is a workaround.
const cloneVariantMaterials = (dst: Object3D, src: Object3D) => {
traversePair(dst, src, (dst, src) => {
if (src.userData.variantMaterials !== undefined) {
dst.userData.variantMaterials = new Map(src.userData.variantMaterials);
}
if (src.userData.variantData !== undefined) {
dst.userData.variantData = src.userData.variantData;
}
if (src.userData.originalMaterial !== undefined) {
dst.userData.originalMaterial = src.userData.originalMaterial;
}
});
};
const traversePair =
(obj1: Object3D,
obj2: Object3D,
callback: (obj1: Object3D, obj2: Object3D) => void) => {
callback(obj1, obj2);
// Assume obj1 and obj2 have the same tree structure
for (let i = 0; i < obj1.children.length; i++) {
traversePair(obj1.children[i], obj2.children[i], callback);
}
};
export type GLTFInstanceConstructor =
Constructor<GLTFInstance, {prepare: typeof GLTFInstance['prepare']}>;
@@ -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 {BufferAttribute, Mesh, MeshBasicMaterial, SphereGeometry, Texture, Vector3} from 'three';
export class GroundedSkybox extends Mesh {
private height = 0;
private radius = 0;
private resolution = 0;
constructor() {
super(undefined, new MeshBasicMaterial({depthWrite: false}));
this.userData.noHit = true;
}
get map() {
return (this.material as MeshBasicMaterial).map;
}
set map(skybox: Texture|null) {
(this.material as MeshBasicMaterial).map = skybox;
}
isUsable() {
return this.height > 0 && this.radius > 0 && this.geometry != null &&
this.map != null;
}
updateGeometry(height = this.height, radius = this.radius, resolution = 128) {
if (height != this.height || radius != this.radius ||
resolution != this.resolution) {
this.height = height;
this.radius = radius;
this.resolution = resolution;
if (height > 0 && radius > 0) {
this.geometry.dispose();
this.geometry = makeGeometry(height, radius, resolution);
}
}
}
}
function makeGeometry(height: number, radius: number, resolution: number) {
const geometry = new SphereGeometry(radius, 2 * resolution, resolution);
geometry.scale(1, 1, -1);
const pos = geometry.getAttribute('position') as BufferAttribute;
const tmp = new Vector3();
for (let i = 0; i < pos.count; ++i) {
tmp.fromBufferAttribute(pos, i);
if (tmp.y < 0) {
// Smooth out the transition from flat floor to sphere:
const y1 = -height * 3 / 2;
const f =
tmp.y < y1 ? -height / tmp.y : (1 - tmp.y * tmp.y / (3 * y1 * y1));
tmp.multiplyScalar(f);
tmp.toArray(pos.array, 3 * i);
}
}
pos.needsUpdate = true;
return geometry;
}
@@ -0,0 +1,203 @@
/* @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 {Matrix3, Mesh, Quaternion, Triangle, Vector3} from 'three';
import {CSS2DObject} from 'three/examples/jsm/renderers/CSS2DRenderer.js';
import {normalizeUnit} from '../styles/conversions.js';
import {NumberNode, parseExpressions} from '../styles/parsers.js';
import {ModelScene} from './ModelScene.js';
export interface HotspotVisibilityDetails {
visible: boolean;
}
/**
* Hotspots are configured by slot name, and this name must begin with "hotspot"
* to be recognized. The position and normal strings are in the form of the
* camera-target attribute and default to "0m 0m 0m" and "0m 1m 0m",
* respectively.
*/
export interface HotspotConfiguration {
name: string;
position?: string;
normal?: string;
surface?: string;
}
const a = new Vector3();
const b = new Vector3();
const c = new Vector3();
const mat = new Matrix3();
const triangle = new Triangle();
const quat = new Quaternion();
/**
* The Hotspot object is a reference-counted slot. If decrement() returns true,
* it should be removed from the tree so it can be garbage-collected.
*/
export class Hotspot extends CSS2DObject {
public normal: Vector3 = new Vector3(0, 1, 0);
public surface?: string;
public mesh?: Mesh;
public tri?: Vector3;
public bary?: Vector3;
private initialized = false;
private referenceCount = 1;
private pivot = document.createElement('div');
private slot: HTMLSlotElement = document.createElement('slot');
constructor(config: HotspotConfiguration) {
super(document.createElement('div'));
this.element.classList.add('annotation-wrapper');
this.slot.name = config.name;
this.element.appendChild(this.pivot);
this.pivot.appendChild(this.slot);
this.updatePosition(config.position);
this.updateNormal(config.normal);
this.surface = config.surface;
}
get facingCamera(): boolean {
return !this.element.classList.contains('hide');
}
/**
* Sets the hotspot to be in the highly visible foreground state.
*/
show() {
if (!this.facingCamera || !this.initialized) {
this.updateVisibility(true);
}
}
/**
* Sets the hotspot to be in the diminished background state.
*/
hide() {
if (this.facingCamera || !this.initialized) {
this.updateVisibility(false);
}
}
/**
* Call this when adding elements to the same slot to keep track.
*/
increment() {
this.referenceCount++;
}
/**
* Call this when removing elements from the slot; returns true when the slot
* is unused.
*/
decrement(): boolean {
if (this.referenceCount > 0) {
--this.referenceCount;
}
return this.referenceCount === 0;
}
/**
* Change the position of the hotspot to the input string, in the same format
* as the data-position attribute.
*/
updatePosition(position?: string) {
if (position == null)
return;
const positionNodes = parseExpressions(position)[0].terms;
for (let i = 0; i < 3; ++i) {
this.position.setComponent(
i, normalizeUnit(positionNodes[i] as NumberNode<'m'>).number);
}
this.updateMatrixWorld();
}
/**
* Change the hotspot's normal to the input string, in the same format as the
* data-normal attribute.
*/
updateNormal(normal?: string) {
if (normal == null)
return;
const normalNodes = parseExpressions(normal)[0].terms;
for (let i = 0; i < 3; ++i) {
this.normal.setComponent(i, (normalNodes[i] as NumberNode).number);
}
}
updateSurface() {
const {mesh, tri, bary} = this;
if (mesh == null || tri == null || bary == null) {
return;
}
(mesh as any).getVertexPosition(tri.x, a);
(mesh as any).getVertexPosition(tri.y, b);
(mesh as any).getVertexPosition(tri.z, c);
a.toArray(mat.elements, 0);
b.toArray(mat.elements, 3);
c.toArray(mat.elements, 6);
this.position.copy(bary).applyMatrix3(mat);
const target = this.parent!;
target.worldToLocal(mesh.localToWorld(this.position));
triangle.set(a, b, c);
triangle.getNormal(this.normal).transformDirection(mesh.matrixWorld);
const pivot = target.parent as ModelScene;
quat.setFromAxisAngle(a.set(0, 1, 0), -pivot.rotation.y);
this.normal.applyQuaternion(quat);
}
orient(radians: number) {
this.pivot.style.transform = `rotate(${radians}rad)`;
}
updateVisibility(show: boolean) {
this.element.classList.toggle('hide', !show);
// NOTE: ShadyDOM doesn't support slot.assignedElements, otherwise we could
// use that here.
this.slot.assignedNodes().forEach((node) => {
if (node.nodeType !== Node.ELEMENT_NODE) {
return;
}
const element = node as HTMLElement;
// Visibility attribute can be configured per-node in the hotspot:
const visibilityAttribute = element.dataset.visibilityAttribute;
if (visibilityAttribute != null) {
const attributeName = `data-${visibilityAttribute}`;
element.toggleAttribute(attributeName, show);
}
element.dispatchEvent(new CustomEvent('hotspot-visibility', {
detail: {
visible: show,
},
}));
});
this.initialized = true;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,535 @@
/* @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 { BoxGeometry, BufferGeometry, DoubleSide, Float32BufferAttribute, Material, Mesh, MeshBasicMaterial, PlaneGeometry, Vector2, Vector3, XRTargetRaySpace, Color, AdditiveBlending, NormalBlending } from 'three';
import { Damper } from './Damper.js';
import { ModelScene } from './ModelScene.js';
import { Side } from './Shadow.js';
// Enhanced configuration for dynamic sizing and visual design
const CONFIG = {
// Dynamic sizing - slightly bigger
MIN_TOUCH_AREA: 0.05, // minimum touch area
BASE_RADIUS: 0.15, // base radius
LINE_WIDTH: 0.02, // line width
SEGMENTS: 16, // segments for smoother curves
DELTA_PHI: Math.PI / (2 * 16),
// Enhanced visual design with more vibrant colors
COLORS: {
EDGE_FALLOFF: new Color(0.98, 0.98, 0.98), // Brighter light gray
EDGE_CUTOFF: new Color(0.8, 0.8, 0.8), // Brighter medium gray
FILL_FALLOFF: new Color(0.4, 0.4, 0.4), // Brighter dark gray
FILL_CUTOFF: new Color(0.4, 0.4, 0.4), // Brighter dark gray
ACTIVE_EDGE: new Color(1.0, 1.0, 1.0), // Pure white when active
ACTIVE_FILL: new Color(0.6, 0.6, 0.6), // Brighter fill when active
},
// Opacity settings - now configurable
MAX_OPACITY: 0.75,
ACTIVE_OPACITY: 0.9,
FILL_OPACITY_MULTIPLIER: 0.5, // Fill opacity relative to edge opacity
INTERACTIVE_OPACITY_MULTIPLIER: 1.2, // Edge opacity multiplier when interactive
// Distance-based scaling (similar to Footprint)
MIN_DISTANCE: 0.5,
MAX_DISTANCE: 10.0,
BASE_SCALE: 1.0,
DISTANCE_SCALE_FACTOR: 0.3,
// Animation timing - optimized for performance
FADE_IN_DURATION: 0.12,
FADE_OUT_DURATION: 0.12,
SIZE_UPDATE_DURATION: 0.05,
COLOR_LERP_FACTOR: 0.15, // Color transition speed
// Screen space scaling - now configurable
SCREEN_SPACE_SCALE: 1.2, // Scale factor for screen space mode
// Performance optimization thresholds
SIZE_UPDATE_THRESHOLD: 0.001, // Minimum size change to trigger geometry update
GEOMETRY_UPDATE_DEBOUNCE: 100, // ms to debounce geometry updates
} as const;
const vector2 = new Vector2();
/**
* Adds a quarter-annulus of vertices to the array, centered on cornerX,
* cornerY.
*/
const addCorner =
(vertices: Array<number>, cornerX: number, cornerY: number, radius: number, lineWidth: number) => {
let phi = cornerX > 0 ? (cornerY > 0 ? 0 : -Math.PI / 2) :
(cornerY > 0 ? Math.PI / 2 : Math.PI);
for (let i = 0; i <= CONFIG.SEGMENTS; ++i) {
vertices.push(
cornerX + (radius - lineWidth) * Math.cos(phi),
cornerY + (radius - lineWidth) * Math.sin(phi),
0,
cornerX + radius * Math.cos(phi),
cornerY + radius * Math.sin(phi),
0);
phi += CONFIG.DELTA_PHI;
}
};
/**
* Enhanced PlacementBox that dynamically updates based on model size changes
* and features improved visual design inspired by Footprint.
*/
export class PlacementBox extends Mesh {
private hitPlane!: Mesh;
private hitBox!: Mesh;
private shadowHeight: number = 0;
private side: Side;
private goalOpacity: number;
private opacityDamper: Damper;
// Dynamic sizing properties
private currentSize: Vector3;
private goalSize: Vector3;
private sizeDamper: Damper;
private scene: ModelScene;
// Visual state
private isActive: boolean = false;
private isHovered: boolean = false;
private edgeMaterial!: MeshBasicMaterial;
private fillMaterial!: MeshBasicMaterial;
// Performance optimization
private lastGeometryUpdateTime: number = 0;
private needsGeometryUpdate: boolean = false;
constructor(scene: ModelScene, side: Side) {
const geometry = new BufferGeometry();
super(geometry);
this.scene = scene;
this.side = side;
this.currentSize = new Vector3();
this.goalSize = new Vector3();
this.sizeDamper = new Damper();
// Initialize with current scene size
this.updateSizeFromScene();
// Create enhanced materials with better visual properties
this.edgeMaterial = new MeshBasicMaterial({
color: CONFIG.COLORS.EDGE_FALLOFF,
transparent: true,
opacity: 0,
side: DoubleSide,
depthWrite: false, // Better transparency handling
blending: AdditiveBlending // Subtle glow effect
});
this.fillMaterial = new MeshBasicMaterial({
color: CONFIG.COLORS.FILL_FALLOFF,
transparent: true,
opacity: 0,
side: DoubleSide,
depthWrite: false, // Better transparency handling
blending: NormalBlending
});
this.material = this.edgeMaterial;
this.goalOpacity = 0;
this.opacityDamper = new Damper();
// Create hit testing meshes
this.createHitMeshes();
// Position based on scene
this.updatePositionFromScene();
// Add to scene
scene.target.add(this);
scene.target.add(this.hitBox);
this.offsetHeight = 0;
}
private updateSizeFromScene(): void {
const { size } = this.scene;
this.goalSize.copy(size);
// Apply proportional minimum size constraints
// For small models, use a smaller minimum size
const modelDiagonal = Math.sqrt(size.x * size.x + size.z * size.z);
const proportionalMinSize = Math.max(CONFIG.MIN_TOUCH_AREA, modelDiagonal * 0.4); // Increased from 0.3 to 0.4
// Only apply minimum size if the model is very small
if (this.goalSize.x < proportionalMinSize) {
this.goalSize.x = proportionalMinSize;
}
if (this.goalSize.z < proportionalMinSize) {
this.goalSize.z = proportionalMinSize;
}
// Update geometry with new size
this.updateGeometry();
}
private updateGeometry(): void {
const geometry = this.geometry as BufferGeometry;
const triangles: Array<number> = [];
const vertices: Array<number> = [];
const x = this.goalSize.x / 2;
const y = (this.side === 'back' ? this.goalSize.y : this.goalSize.z) / 2;
// Use dynamic radius based on size - slightly bigger for better visibility
const modelSize = Math.min(x, y);
const radius = Math.max(CONFIG.BASE_RADIUS * 0.7, modelSize * 0.2); // Increased multipliers
const lineWidth = Math.max(CONFIG.LINE_WIDTH * 0.7, modelSize * 0.025); // Increased line width
addCorner(vertices, x, y, radius, lineWidth);
addCorner(vertices, -x, y, radius, lineWidth);
addCorner(vertices, -x, -y, radius, lineWidth);
addCorner(vertices, x, -y, radius, lineWidth);
const numVertices = vertices.length / 3;
for (let i = 0; i < numVertices - 2; i += 2) {
triangles.push(i, i + 1, i + 3, i, i + 3, i + 2);
}
const i = numVertices - 2;
triangles.push(i, i + 1, 1, i, 1, 0);
geometry.setAttribute('position', new Float32BufferAttribute(vertices, 3));
geometry.setIndex(triangles);
geometry.computeBoundingSphere();
}
private createHitMeshes(): void {
const x = this.goalSize.x / 2;
const y = (this.side === 'back' ? this.goalSize.y : this.goalSize.z) / 2;
const modelSize = Math.min(x, y);
const radius = Math.max(CONFIG.BASE_RADIUS * 0.7, modelSize * 0.2);
this.hitPlane = new Mesh(
new PlaneGeometry(2 * (x + radius), 2 * (y + radius)));
this.hitPlane.visible = false;
(this.hitPlane.material as Material).side = DoubleSide;
this.add(this.hitPlane);
this.hitBox = new Mesh(new BoxGeometry(
this.goalSize.x + 2 * radius,
this.goalSize.y + radius,
this.goalSize.z + 2 * radius));
this.hitBox.visible = false;
(this.hitBox.material as Material).side = DoubleSide;
this.add(this.hitBox);
}
private updatePositionFromScene(): void {
const { boundingBox } = this.scene;
boundingBox.getCenter(this.position);
// Reset rotation to ensure proper orientation
this.rotation.set(0, 0, 0);
switch (this.side) {
case 'bottom':
// Ensure the placement box is horizontal for floor placement
this.rotateX(-Math.PI / 2);
this.shadowHeight = boundingBox.min.y;
this.position.y = this.shadowHeight;
break;
case 'back':
// For wall placement, keep it vertical but ensure proper orientation
this.shadowHeight = boundingBox.min.z;
this.position.z = this.shadowHeight;
break;
case 'top':
// move placement box to the top of the model
this.rotateX(-Math.PI / 2);
this.shadowHeight = boundingBox.max.y;
this.position.y = this.shadowHeight;
break;
}
// Update hit box position with proper offset
if (this.hitBox) {
const offset = this.side === 'back' ?
(this.goalSize.y + CONFIG.BASE_RADIUS) / 2 :
(this.goalSize.y + CONFIG.BASE_RADIUS) / 2;
this.hitBox.position.y = offset + boundingBox.min.y;
}
}
/**
* Update the placement box when model size changes
* Optimized to batch updates and reduce performance impact
*/
updateFromModelChanges(): void {
this.updateSizeFromScene();
this.updatePositionFromScene();
// Force immediate geometry update for model changes
this.updateGeometry();
this.updateHitMeshes();
this.ensureProperOrientation();
// Reset performance tracking
this.needsGeometryUpdate = false;
this.lastGeometryUpdateTime = performance.now();
}
/**
* Ensure the placement box is properly oriented for the current mode
*/
private ensureProperOrientation(): void {
// Force proper orientation based on side
if (this.side === 'bottom') {
// For floor placement, ensure it's horizontal
this.rotation.x = -Math.PI / 2;
this.rotation.y = 0;
this.rotation.z = 0;
} else if (this.side === 'back') {
// For wall placement, ensure it's vertical
this.rotation.x = 0;
this.rotation.y = 0;
this.rotation.z = 0;
}
}
/**
* Set screen space mode to adjust positioning for mobile AR
*/
setScreenSpaceMode(isScreenSpace: boolean): void {
if (isScreenSpace) {
// In screen space mode, ensure the placement box is more visible
// and properly positioned for touch interaction
this.scale.set(CONFIG.SCREEN_SPACE_SCALE, CONFIG.SCREEN_SPACE_SCALE, CONFIG.SCREEN_SPACE_SCALE);
} else {
// Reset scale for world space mode
this.scale.set(1.0, 1.0, 1.0);
}
}
private updateHitMeshes(): void {
if (this.hitPlane && this.hitBox) {
const x = this.goalSize.x / 2;
const y = (this.side === 'back' ? this.goalSize.y : this.goalSize.z) / 2;
const modelSize = Math.min(x, y);
const radius = Math.max(CONFIG.BASE_RADIUS * 0.7, modelSize * 0.2);
// Update hit plane geometry
const hitPlaneGeometry = new PlaneGeometry(2 * (x + radius), 2 * (y + radius));
this.hitPlane.geometry.dispose();
this.hitPlane.geometry = hitPlaneGeometry;
// Update hit box geometry
const hitBoxGeometry = new BoxGeometry(
this.goalSize.x + 2 * radius,
this.goalSize.y + radius,
this.goalSize.z + 2 * radius);
this.hitBox.geometry.dispose();
this.hitBox.geometry = hitBoxGeometry;
}
}
/**
* Set interaction state for visual feedback
*/
setInteractionState(isActive: boolean, isHovered: boolean = false): void {
this.isActive = isActive;
this.isHovered = isHovered;
this.updateVisualState();
}
private updateVisualState(): void {
let targetColor: Color;
let targetFillColor: Color;
if (this.isActive) {
targetColor = CONFIG.COLORS.ACTIVE_EDGE;
targetFillColor = CONFIG.COLORS.ACTIVE_FILL;
} else if (this.isHovered) {
targetColor = CONFIG.COLORS.EDGE_FALLOFF;
targetFillColor = CONFIG.COLORS.FILL_FALLOFF;
} else {
targetColor = CONFIG.COLORS.EDGE_CUTOFF;
targetFillColor = CONFIG.COLORS.FILL_CUTOFF;
}
// Smoothly transition colors with configurable response speed
this.edgeMaterial.color.lerp(targetColor, CONFIG.COLOR_LERP_FACTOR);
this.fillMaterial.color.lerp(targetFillColor, CONFIG.COLOR_LERP_FACTOR);
}
/**
* Apply distance-based scaling
*/
applyDistanceScaling(cameraPosition: Vector3): void {
const distanceToCamera = cameraPosition.distanceTo(this.position);
const clampedDistance = Math.max(
CONFIG.MIN_DISTANCE,
Math.min(CONFIG.MAX_DISTANCE, distanceToCamera)
);
const scaleFactor = CONFIG.BASE_SCALE +
(clampedDistance - CONFIG.MIN_DISTANCE) * CONFIG.DISTANCE_SCALE_FACTOR;
this.scale.set(scaleFactor, scaleFactor, scaleFactor);
}
/**
* Get the world hit position if the touch coordinates hit the box, and null
* otherwise. Pass the scene in to get access to its raycaster.
*/
getHit(scene: ModelScene, screenX: number, screenY: number): Vector3 | null {
vector2.set(screenX, -screenY);
this.hitPlane.visible = true;
const hitResult = scene.positionAndNormalFromPoint(vector2, this.hitPlane);
this.hitPlane.visible = false;
return hitResult == null ? null : hitResult.position;
}
getExpandedHit(scene: ModelScene, screenX: number, screenY: number): Vector3 | null {
this.hitPlane.scale.set(1000, 1000, 1000);
this.hitPlane.updateMatrixWorld();
const hitResult = this.getHit(scene, screenX, screenY);
this.hitPlane.scale.set(1, 1, 1);
return hitResult;
}
controllerIntersection(scene: ModelScene, controller: XRTargetRaySpace) {
this.hitBox.visible = true;
const hitResult = scene.hitFromController(controller, this.hitBox);
this.hitBox.visible = false;
return hitResult;
}
/**
* Offset the height of the box relative to the bottom of the scene. Positive
* is up, so generally only negative values are used.
*/
set offsetHeight(offset: number) {
offset -= 0.001; // push 1 mm below shadow to avoid z-fighting
if (this.side === 'back') {
this.position.z = this.shadowHeight + offset;
} else {
this.position.y = this.shadowHeight + offset;
}
}
get offsetHeight(): number {
if (this.side === 'back') {
return this.position.z - this.shadowHeight;
} else {
return this.position.y - this.shadowHeight;
}
}
/**
* Set the box's visibility; it will fade in and out.
*/
set show(visible: boolean) {
this.goalOpacity = visible ? CONFIG.MAX_OPACITY : 0;
}
/**
* Call on each frame with the frame delta to fade the box.
*/
updateOpacity(delta: number) {
const material = this.material as MeshBasicMaterial;
const newOpacity = this.opacityDamper.update(
material.opacity,
this.goalOpacity,
delta,
1
);
// Update both edge and fill materials with configurable visibility
this.edgeMaterial.opacity = newOpacity;
this.fillMaterial.opacity = newOpacity * CONFIG.FILL_OPACITY_MULTIPLIER;
// Add subtle glow effect when active or hovered
if (this.isActive || this.isHovered) {
this.edgeMaterial.opacity = newOpacity * CONFIG.INTERACTIVE_OPACITY_MULTIPLIER;
}
this.visible = newOpacity > 0;
}
/**
* Update method to be called each frame for smooth transitions
* Optimized to reduce frequent geometry updates for better performance
*/
update(delta: number, cameraPosition?: Vector3): void {
// Update opacity
this.updateOpacity(delta);
// Update size transitions with performance optimization
if (!this.currentSize.equals(this.goalSize)) {
const newSize = new Vector3();
newSize.x = this.sizeDamper.update(this.currentSize.x, this.goalSize.x, delta, 1);
newSize.y = this.sizeDamper.update(this.currentSize.y, this.goalSize.y, delta, 1);
newSize.z = this.sizeDamper.update(this.currentSize.z, this.goalSize.z, delta, 1);
// Check if size change is significant enough to warrant geometry update
const sizeChange = newSize.distanceTo(this.currentSize);
if (sizeChange > CONFIG.SIZE_UPDATE_THRESHOLD) {
this.currentSize.copy(newSize);
this.needsGeometryUpdate = true;
}
}
// Debounce geometry updates to prevent excessive updates
const now = performance.now();
if (this.needsGeometryUpdate &&
(now - this.lastGeometryUpdateTime) > CONFIG.GEOMETRY_UPDATE_DEBOUNCE) {
this.updateGeometry();
this.updateHitMeshes();
this.needsGeometryUpdate = false;
this.lastGeometryUpdateTime = now;
}
// Apply distance scaling if camera position is provided
if (cameraPosition) {
this.applyDistanceScaling(cameraPosition);
}
// Update visual state
this.updateVisualState();
}
/**
* Get the current size of the placement box
*/
getSize(): Vector3 {
return this.goalSize.clone();
}
/**
* Call this to clean up Three's cache when you remove the box.
*/
dispose() {
const { geometry, material } = this.hitPlane;
geometry.dispose();
(material as Material).dispose();
this.hitBox.geometry.dispose();
(this.hitBox.material as Material).dispose();
this.geometry.dispose();
this.edgeMaterial.dispose();
this.fillMaterial.dispose();
this.hitBox.removeFromParent();
this.removeFromParent();
}
}
@@ -0,0 +1,557 @@
/* @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 {Event, EventDispatcher, NeutralToneMapping, Vector2, WebGLRenderer} from 'three';
import {$updateEnvironment} from '../features/environment.js';
import {ModelViewerGlobalConfig} from '../features/loading.js';
import ModelViewerElementBase, {$canvas, $tick, $updateSize} from '../model-viewer-base.js';
import {clamp, isDebugMode} from '../utilities.js';
import {ARRenderer} from './ARRenderer.js';
import {CachingGLTFLoader} from './CachingGLTFLoader.js';
import {ModelViewerGLTFInstance} from './gltf-instance/ModelViewerGLTFInstance.js';
import {ModelScene} from './ModelScene.js';
import TextureUtils from './TextureUtils.js';
export interface RendererOptions {
powerPreference: string;
debug?: boolean;
}
export interface ContextLostEvent extends Event {
type: 'contextlost';
sourceEvent: WebGLContextEvent;
}
// Between 0 and 1: larger means the average responds faster and is less smooth.
const DURATION_DECAY = 0.2;
const LOW_FRAME_DURATION_MS = 40;
const HIGH_FRAME_DURATION_MS = 60;
const MAX_AVG_CHANGE_MS = 5;
const SCALE_STEPS = [1, 0.79, 0.62, 0.5, 0.4, 0.31, 0.25];
const DEFAULT_LAST_STEP = 3;
export const DEFAULT_POWER_PREFERENCE: string = 'high-performance';
const COMMERCE_EXPOSURE = 1.3;
/**
* Registers canvases with Canvas2DRenderingContexts and renders them
* all in the same WebGLRenderingContext, spitting out textures to apply
* to the canvases. Creates a fullscreen WebGL canvas that is not added
* to the DOM, and on each frame, renders each registered canvas on a portion
* of the WebGL canvas, and applies the texture on the registered canvas.
*
* In the future, can use ImageBitmapRenderingContext instead of
* Canvas2DRenderingContext if supported for cheaper transferring of
* the texture.
*/
export class Renderer extends
EventDispatcher<{contextlost: {sourceEvent: WebGLContextEvent}}> {
private static _singleton: Renderer;
static get singleton() {
if (!this._singleton) {
this._singleton = new Renderer({
powerPreference: (((self as any).ModelViewerElement || {}) as
ModelViewerGlobalConfig)
.powerPreference ||
DEFAULT_POWER_PREFERENCE,
debug: isDebugMode()
});
}
return this._singleton;
}
static resetSingleton() {
const elements = this._singleton.dispose();
for (const element of elements) {
element.disconnectedCallback();
}
this._singleton = new Renderer({
powerPreference:
(((self as any).ModelViewerElement || {}) as ModelViewerGlobalConfig)
.powerPreference ||
DEFAULT_POWER_PREFERENCE,
debug: isDebugMode()
});
for (const element of elements) {
element.connectedCallback();
}
}
public threeRenderer!: WebGLRenderer;
public canvas3D: HTMLCanvasElement;
public textureUtils: TextureUtils|null;
public arRenderer: ARRenderer;
public loader = new CachingGLTFLoader(ModelViewerGLTFInstance);
public width = 0;
public height = 0;
public dpr = 1;
private scenes: Set<ModelScene> = new Set();
private multipleScenesVisible = false;
private lastTick = performance.now();
private renderedLastFrame = false;
private scaleStep = 0;
private lastStep = DEFAULT_LAST_STEP;
private avgFrameDuration =
(HIGH_FRAME_DURATION_MS + LOW_FRAME_DURATION_MS) / 2;
get canRender() {
return this.threeRenderer != null;
}
get scaleFactor() {
return SCALE_STEPS[this.scaleStep];
}
set minScale(scale: number) {
let i = 1;
while (i < SCALE_STEPS.length) {
if (SCALE_STEPS[i] < scale) {
break;
}
++i;
}
this.lastStep = i - 1;
}
constructor(options: RendererOptions) {
super();
this.dpr = window.devicePixelRatio;
this.canvas3D = document.createElement('canvas');
this.canvas3D.id = 'webgl-canvas';
this.canvas3D.classList.add('show');
try {
this.threeRenderer = new WebGLRenderer({
canvas: this.canvas3D,
alpha: true,
antialias: true,
powerPreference: options.powerPreference as WebGLPowerPreference,
preserveDrawingBuffer: true,
});
this.threeRenderer.autoClear = true;
this.threeRenderer.setPixelRatio(1); // handle pixel ratio externally
this.threeRenderer.debug = {
checkShaderErrors: !!options.debug,
onShaderError: null
};
// ACESFilmicToneMapping appears to be the most "saturated",
// and similar to Filament's gltf-viewer.
this.threeRenderer.toneMapping = NeutralToneMapping;
} catch (error) {
console.warn(error);
}
this.arRenderer = new ARRenderer(this);
this.textureUtils =
this.canRender ? new TextureUtils(this.threeRenderer) : null;
CachingGLTFLoader.initializeKTX2Loader(this.threeRenderer);
this.canvas3D.addEventListener('webglcontextlost', this.onWebGLContextLost);
this.canvas3D.addEventListener(
'webglcontextrestored', this.onWebGLContextRestored);
this.updateRendererSize();
}
registerScene(scene: ModelScene) {
this.scenes.add(scene);
scene.forceRescale();
const size = new Vector2();
this.threeRenderer.getSize(size);
scene.canvas.width = size.x;
scene.canvas.height = size.y;
if (this.canRender && this.scenes.size > 0) {
this.threeRenderer.setAnimationLoop(
(time: number, frame?: any) => this.render(time, frame));
}
}
unregisterScene(scene: ModelScene) {
this.scenes.delete(scene);
if (this.canvas3D.parentElement === scene.canvas.parentElement) {
scene.canvas.parentElement!.removeChild(this.canvas3D);
}
if (this.canRender && this.scenes.size === 0) {
this.threeRenderer.setAnimationLoop(null);
}
}
displayCanvas(scene: ModelScene): HTMLCanvasElement {
return scene.element.modelIsVisible && !this.multipleScenesVisible ?
this.canvas3D :
scene.element[$canvas];
}
/**
* The function enables an optimization, where when there is only a single
* <model-viewer> element, we can use the renderer's 3D canvas directly for
* display. Otherwise we need to use the element's 2D canvas and copy the
* renderer's result into it.
*/
private countVisibleScenes() {
const {canvas3D} = this;
let visibleScenes = 0;
let canvas3DScene = null;
for (const scene of this.scenes) {
const {element} = scene;
if (element.modelIsVisible && scene.externalRenderer == null) {
++visibleScenes;
}
if (canvas3D.parentElement === scene.canvas.parentElement) {
canvas3DScene = scene;
}
}
const multipleScenesVisible = visibleScenes > 1;
if (canvas3DScene != null) {
const newlyMultiple =
multipleScenesVisible && !this.multipleScenesVisible;
const disappearing = !canvas3DScene.element.modelIsVisible;
if (newlyMultiple || disappearing) {
const {width, height} = this.sceneSize(canvas3DScene);
this.copyPixels(canvas3DScene, width, height);
canvas3D.parentElement!.removeChild(canvas3D);
}
}
this.multipleScenesVisible = multipleScenesVisible;
}
/**
* Updates the renderer's size based on the largest scene and any changes to
* device pixel ratio.
*/
private updateRendererSize() {
const dpr = window.devicePixelRatio;
if (dpr !== this.dpr) {
// If the device pixel ratio has changed due to page zoom, elements
// specified by % width do not fire a resize event even though their CSS
// pixel dimensions change, so we force them to update their size here.
for (const scene of this.scenes) {
const {element} = scene;
element[$updateSize](element.getBoundingClientRect());
}
}
// Make the renderer the size of the largest scene
let width = 0;
let height = 0;
for (const scene of this.scenes) {
width = Math.max(width, scene.width);
height = Math.max(height, scene.height);
}
if (width === this.width && height === this.height && dpr === this.dpr) {
return;
}
this.width = width;
this.height = height;
this.dpr = dpr;
width = Math.ceil(width * dpr);
height = Math.ceil(height * dpr);
if (this.canRender) {
this.threeRenderer.setSize(width, height, false);
}
// Each scene's canvas must match the renderer size. In general they can be
// larger than the element that contains them, but the overflow is hidden
// and only the portion that is shown is copied over.
for (const scene of this.scenes) {
const {canvas} = scene;
canvas.width = width;
canvas.height = height;
scene.forceRescale();
scene.effectRenderer?.setSize(width, height);
}
}
private updateRendererScale(delta: number) {
const scaleStep = this.scaleStep;
this.avgFrameDuration += clamp(
DURATION_DECAY * (delta - this.avgFrameDuration),
-MAX_AVG_CHANGE_MS,
MAX_AVG_CHANGE_MS);
if (this.avgFrameDuration > HIGH_FRAME_DURATION_MS) {
++this.scaleStep;
} else if (
this.avgFrameDuration < LOW_FRAME_DURATION_MS && this.scaleStep > 0) {
--this.scaleStep;
}
this.scaleStep = Math.min(this.scaleStep, this.lastStep);
if (scaleStep !== this.scaleStep) {
this.avgFrameDuration =
(HIGH_FRAME_DURATION_MS + LOW_FRAME_DURATION_MS) / 2;
}
}
private shouldRender(scene: ModelScene): boolean {
if (!scene.shouldRender()) {
// The first frame we stop rendering the scene (because it stops moving),
// trigger one extra render at full scale.
if (scene.scaleStep != 0) {
scene.scaleStep = 0;
this.rescaleCanvas(scene);
} else {
return false;
}
} else if (scene.scaleStep != this.scaleStep) {
// Update render scale
scene.scaleStep = this.scaleStep;
this.rescaleCanvas(scene);
}
return true;
}
private rescaleCanvas(scene: ModelScene) {
const scale = SCALE_STEPS[scene.scaleStep];
const width = Math.ceil(this.width / scale);
const height = Math.ceil(this.height / scale);
const {style} = scene.canvas;
style.width = `${width}px`;
style.height = `${height}px`;
this.canvas3D.style.width = `${width}px`;
this.canvas3D.style.height = `${height}px`;
const renderedDpr = this.dpr * scale;
const reason = scale < 1 ? 'GPU throttling' :
this.dpr !== window.devicePixelRatio ? 'No meta viewport tag' :
'';
scene.element.dispatchEvent(new CustomEvent('render-scale', {
detail: {
reportedDpr: window.devicePixelRatio,
renderedDpr: renderedDpr,
minimumDpr: this.dpr * SCALE_STEPS[this.lastStep],
pixelWidth: Math.ceil(scene.width * renderedDpr),
pixelHeight: Math.ceil(scene.height * renderedDpr),
reason: reason
}
}));
}
private sceneSize(scene: ModelScene) {
const {dpr} = this;
const scaleFactor = SCALE_STEPS[scene.scaleStep];
// We avoid using the Three.js PixelRatio and handle it ourselves here so
// that we can do proper rounding and avoid white boundary pixels.
const width = Math.min(
Math.ceil(scene.width * scaleFactor * dpr), this.canvas3D.width);
const height = Math.min(
Math.ceil(scene.height * scaleFactor * dpr), this.canvas3D.height);
return {width, height};
}
private copyPixels(scene: ModelScene, width: number, height: number) {
const context2D = scene.context;
if (context2D == null) {
console.log('could not acquire 2d context');
return;
}
context2D.clearRect(0, 0, width, height);
context2D.drawImage(
this.canvas3D, 0, 0, width, height, 0, 0, width, height);
scene.canvas.classList.add('show');
}
/**
* Returns an array version of this.scenes where the non-visible ones are
* first. This allows eager scenes to be rendered before they are visible,
* without needing the multi-canvas render path.
*/
private orderedScenes(): Array<ModelScene> {
const scenes = [];
for (const visible of [false, true]) {
for (const scene of this.scenes) {
if (scene.element.modelIsVisible === visible) {
scenes.push(scene);
}
}
}
return scenes;
}
get isPresenting(): boolean {
return this.arRenderer.isPresenting;
}
/**
* This method takes care of updating the element and renderer state based on
* the time that has passed since the last rendered frame.
*/
preRender(scene: ModelScene, t: number, delta: number) {
const {element, exposure, toneMapping} = scene;
element[$tick](t, delta);
const exposureIsNumber =
typeof exposure === 'number' && !Number.isNaN(exposure);
const env = element.environmentImage;
const sky = element.skyboxImage;
const compensateExposure = toneMapping === NeutralToneMapping &&
(env === 'neutral' || env === 'legacy' || (!env && !sky));
this.threeRenderer.toneMappingExposure =
(exposureIsNumber ? exposure : 1.0) *
(compensateExposure ? COMMERCE_EXPOSURE : 1.0);
}
render(t: number, frame?: XRFrame) {
if (frame != null) {
this.arRenderer.onWebXRFrame(t, frame);
return;
}
const delta = t - this.lastTick;
this.lastTick = t;
if (!this.canRender || this.isPresenting) {
return;
}
this.countVisibleScenes();
this.updateRendererSize();
if (this.renderedLastFrame) {
this.updateRendererScale(delta);
this.renderedLastFrame = false;
}
const {canvas3D} = this;
for (const scene of this.orderedScenes()) {
const {element} = scene;
if (!element.loaded ||
(!element.modelIsVisible && scene.renderCount > 0)) {
continue;
}
this.preRender(scene, t, delta);
if (!this.shouldRender(scene)) {
continue;
}
if (scene.externalRenderer != null) {
const camera = scene.getCamera();
camera.updateMatrix();
const {matrix, projectionMatrix} = camera;
const viewMatrix = matrix.elements.slice();
const target = scene.getTarget();
viewMatrix[12] += target.x;
viewMatrix[13] += target.y;
viewMatrix[14] += target.z;
scene.externalRenderer.render({
viewMatrix: viewMatrix,
projectionMatrix: projectionMatrix.elements
});
continue;
}
if (!element.modelIsVisible && !this.multipleScenesVisible) {
// Here we are pre-rendering on the visible canvas, so we must mark the
// visible scene dirty to ensure it overwrites us.
for (const visibleScene of this.scenes) {
if (visibleScene.element.modelIsVisible) {
visibleScene.queueRender();
}
}
}
const {width, height} = this.sceneSize(scene);
scene.renderShadow(this.threeRenderer);
// Need to set the render target in order to prevent
// clearing the depth from a different buffer
this.threeRenderer.setRenderTarget(null);
this.threeRenderer.setViewport(
0, Math.ceil(this.height * this.dpr) - height, width, height);
if (scene.effectRenderer != null) {
scene.effectRenderer.render(delta);
} else {
this.threeRenderer.autoClear =
true; // this might get reset by the effectRenderer
this.threeRenderer.toneMapping = scene.toneMapping;
this.threeRenderer.render(scene, scene.camera);
}
if (this.multipleScenesVisible ||
(!scene.element.modelIsVisible && scene.renderCount === 0)) {
this.copyPixels(scene, width, height);
} else if (canvas3D.parentElement !== scene.canvas.parentElement) {
scene.canvas.parentElement!.appendChild(canvas3D);
scene.canvas.classList.remove('show');
}
scene.hasRendered();
++scene.renderCount;
this.renderedLastFrame = true;
}
}
dispose(): Array<ModelViewerElementBase> {
if (this.textureUtils != null) {
this.textureUtils.dispose();
}
if (this.threeRenderer != null) {
this.threeRenderer.dispose();
}
this.textureUtils = null;
(this as any).threeRenderer = null;
const elements = [];
for (const scene of this.scenes) {
elements.push(scene.element);
}
this.canvas3D.removeEventListener(
'webglcontextlost', this.onWebGLContextLost);
this.canvas3D.removeEventListener(
'webglcontextrestored', this.onWebGLContextRestored);
return elements;
}
onWebGLContextLost = (event: Event) => {
this.dispatchEvent(
{type: 'contextlost', sourceEvent: event} as ContextLostEvent);
};
onWebGLContextRestored = () => {
this.textureUtils?.dispose();
this.textureUtils = new TextureUtils(this.threeRenderer);
for (const scene of this.scenes) {
(scene.element as any)[$updateEnvironment]();
}
};
}
@@ -0,0 +1,356 @@
/* @license
* Copyright 2022 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 {BackSide, DoubleSide, Box3, Material, Mesh, MeshBasicMaterial, MeshDepthMaterial, Object3D, OrthographicCamera, PlaneGeometry, RenderTargetOptions, RGBAFormat, Scene, ShaderMaterial, Vector3, WebGLRenderer, WebGLRenderTarget} from 'three';
import {HorizontalBlurShader} from 'three/examples/jsm/shaders/HorizontalBlurShader.js';
import {VerticalBlurShader} from 'three/examples/jsm/shaders/VerticalBlurShader.js';
import {lerp} from 'three/src/math/MathUtils.js';
import {ModelScene} from './ModelScene.js';
export type Side = 'back'|'bottom' | 'top';
// The softness [0, 1] of the shadow is mapped to a resolution between
// 2^LOG_MAX_RESOLUTION and 2^LOG_MIN_RESOLUTION.
const LOG_MAX_RESOLUTION = 9;
const LOG_MIN_RESOLUTION = 6;
// Animated models are not in general contained in their bounding box, as this
// is calculated only for their resting pose. We create a cubic shadow volume
// for animated models sized to their largest bounding box dimension multiplied
// by this scale factor.
const ANIMATION_SCALING = 2;
// Since hard shadows are not lightened by blurring and depth, set a lower
// default intensity to make them more perceptually similar to the intensity of
// the soft shadows.
const DEFAULT_HARD_INTENSITY = 0.3;
/**
* The Shadow class creates a shadow that fits a given scene and follows a
* target. This shadow will follow the scene without any updates needed so long
* as the shadow and scene are both parented to the same object (call it the
* scene) and this scene is passed as the target parameter to the shadow's
* constructor. We also must constrain the scene to motion within the horizontal
* plane and call the setRotation() method whenever the scene's Y-axis rotation
* changes. For motion outside of the horizontal plane, this.needsUpdate must be
* set to true.
*
* The softness of the shadow is controlled by changing its resolution, making
* softer shadows faster, but less precise.
*/
export class Shadow extends Object3D {
private camera = new OrthographicCamera();
// private cameraHelper = new CameraHelper(this.camera);
private renderTarget: WebGLRenderTarget|null = null;
private renderTargetBlur: WebGLRenderTarget|null = null;
private depthMaterial = new MeshDepthMaterial();
private horizontalBlurMaterial = new ShaderMaterial(HorizontalBlurShader);
private verticalBlurMaterial = new ShaderMaterial(VerticalBlurShader);
private intensity = 0;
private softness = 1;
private floor: Mesh;
private blurPlane: Mesh;
private boundingBox = new Box3;
private size = new Vector3;
private maxDimension = 0;
private isAnimated = false;
public needsUpdate = false;
constructor(scene: ModelScene, softness: number, side: Side) {
super();
const {camera} = this;
camera.rotation.x = Math.PI / 2;
camera.left = -0.5;
camera.right = 0.5;
camera.bottom = -0.5;
camera.top = 0.5;
this.add(camera);
// this.add(this.cameraHelper);
// this.cameraHelper.updateMatrixWorld = function() {
// this.matrixWorld = this.camera.matrixWorld;
// };
const plane = new PlaneGeometry();
const shadowMaterial = new MeshBasicMaterial({
// color: new Color(1, 0, 0),
opacity: 1,
transparent: true,
side: BackSide,
});
this.floor = new Mesh(plane, shadowMaterial);
this.floor.userData.noHit = true;
camera.add(this.floor);
// the plane onto which to blur the texture
this.blurPlane = new Mesh(plane);
this.blurPlane.visible = false;
camera.add(this.blurPlane);
scene.target.add(this);
// like MeshDepthMaterial, but goes from black to transparent
this.depthMaterial.onBeforeCompile = function(shader) {
shader.fragmentShader = shader.fragmentShader.replace(
'gl_FragColor = vec4( vec3( 1.0 - fragCoordZ ), opacity );',
'gl_FragColor = vec4( vec3( 0.0 ), ( 1.0 - fragCoordZ ) * opacity );');
};
// Render both sides, back sides face the light source and
// front sides supply depth information for soft shadows
this.depthMaterial.side = DoubleSide;
this.horizontalBlurMaterial.depthTest = false;
this.verticalBlurMaterial.depthTest = false;
this.setScene(scene, softness, side);
}
/**
* Update the shadow's size and position for a new scene. Softness is also
* needed, as this controls the shadow's resolution.
*/
setScene(scene: ModelScene, softness: number, side: Side) {
const {boundingBox, size, rotation, position} = this;
this.isAnimated = scene.animationNames.length > 0;
this.boundingBox.copy(scene.boundingBox);
this.size.copy(scene.size);
this.maxDimension = Math.max(size.x, size.y, size.z) *
(this.isAnimated ? ANIMATION_SCALING : 1);
this.boundingBox.getCenter(position);
if (side === 'back') {
const {min, max} = boundingBox;
[min.y, min.z] = [min.z, min.y];
[max.y, max.z] = [max.z, max.y];
[size.y, size.z] = [size.z, size.y];
rotation.x = Math.PI / 2;
rotation.y = Math.PI;
} else {
rotation.x = 0;
rotation.y = 0;
}
if (this.isAnimated) {
const minY = boundingBox.min.y;
const maxY = boundingBox.max.y;
size.y = this.maxDimension;
boundingBox.expandByVector(
size.subScalar(this.maxDimension).multiplyScalar(-0.5));
boundingBox.min.y = minY;
boundingBox.max.y = maxY;
size.set(this.maxDimension, maxY - minY, this.maxDimension);
}
if (side === 'bottom') {
position.y = boundingBox.min.y;
} else {
position.z = boundingBox.min.y;
}
this.setSoftness(softness);
}
/**
* Update the shadow's resolution based on softness (between 0 and 1). Should
* not be called frequently, as this results in reallocation.
*/
setSoftness(softness: number) {
this.softness = softness;
const {size, camera} = this;
const scaleY = (this.isAnimated ? ANIMATION_SCALING : 1);
const resolution = scaleY *
Math.pow(
2,
LOG_MAX_RESOLUTION -
softness * (LOG_MAX_RESOLUTION - LOG_MIN_RESOLUTION));
this.setMapSize(resolution);
const softFar = size.y / 2;
const hardFar = size.y * scaleY;
camera.near = 0;
camera.far = lerp(hardFar, softFar, softness);
// we have co-opted opacity to scale the depth to clip
this.depthMaterial.opacity = 1.0 / softness;
camera.updateProjectionMatrix();
// this.cameraHelper.update();
this.setIntensity(this.intensity);
this.setOffset(0);
}
/**
* Lower-level version of the above function.
*/
setMapSize(maxMapSize: number) {
const {size} = this;
if (this.isAnimated) {
maxMapSize *= ANIMATION_SCALING;
}
const baseWidth =
Math.floor(size.x > size.z ? maxMapSize : maxMapSize * size.x / size.z);
const baseHeight =
Math.floor(size.x > size.z ? maxMapSize * size.z / size.x : maxMapSize);
// width of blur filter in pixels (not adjustable)
const TAP_WIDTH = 10;
const width = TAP_WIDTH + baseWidth;
const height = TAP_WIDTH + baseHeight;
if (this.renderTarget != null &&
(this.renderTarget.width !== width ||
this.renderTarget.height !== height)) {
this.renderTarget.dispose();
this.renderTarget = null;
this.renderTargetBlur!.dispose();
this.renderTargetBlur = null;
}
if (this.renderTarget == null) {
const params: RenderTargetOptions = {format: RGBAFormat};
this.renderTarget = new WebGLRenderTarget(width, height, params);
this.renderTargetBlur = new WebGLRenderTarget(width, height, params);
(this.floor.material as MeshBasicMaterial).map =
this.renderTarget.texture;
}
// These pads account for the softening radius around the shadow.
this.camera.scale.set(
size.x * (1 + TAP_WIDTH / baseWidth),
size.z * (1 + TAP_WIDTH / baseHeight),
1);
this.needsUpdate = true;
}
/**
* Set the shadow's intensity (0 to 1), which is just its opacity. Turns off
* shadow rendering if zero.
*/
setIntensity(intensity: number) {
this.intensity = intensity;
if (intensity > 0) {
this.visible = true;
this.floor.visible = true;
(this.floor.material as MeshBasicMaterial).opacity = intensity *
lerp(DEFAULT_HARD_INTENSITY, 1, this.softness * this.softness);
} else {
this.visible = false;
this.floor.visible = false;
}
}
getIntensity(): number {
return this.intensity;
}
/**
* An offset can be specified to move the
* shadow vertically relative to the bottom of the scene. Positive is up, so
* values are generally negative. A small offset keeps our shadow from
* z-fighting with any baked-in shadow plane.
*/
setOffset(offset: number) {
this.floor.position.z = -offset + this.gap();
}
gap() {
return 0.001 * this.maxDimension;
}
render(renderer: WebGLRenderer, scene: Scene) {
// this.cameraHelper.visible = false;
// force the depthMaterial to everything
scene.overrideMaterial = this.depthMaterial;
// set renderer clear alpha
const initialClearAlpha = renderer.getClearAlpha();
renderer.setClearAlpha(0);
this.floor.visible = false;
// disable XR for offscreen rendering
const xrEnabled = renderer.xr.enabled;
renderer.xr.enabled = false;
// render to the render target to get the depths
const oldRenderTarget = renderer.getRenderTarget();
renderer.setRenderTarget(this.renderTarget);
renderer.render(scene, this.camera);
// and reset the override material
scene.overrideMaterial = null;
this.floor.visible = true;
this.blurShadow(renderer);
// reset and render the normal scene
renderer.xr.enabled = xrEnabled;
renderer.setRenderTarget(oldRenderTarget);
renderer.setClearAlpha(initialClearAlpha);
// this.cameraHelper.visible = true;
}
blurShadow(renderer: WebGLRenderer) {
const {
camera,
horizontalBlurMaterial,
verticalBlurMaterial,
renderTarget,
renderTargetBlur,
blurPlane
} = this;
blurPlane.visible = true;
// blur horizontally and draw in the renderTargetBlur
blurPlane.material = horizontalBlurMaterial;
horizontalBlurMaterial.uniforms.h.value = 1 / this.renderTarget!.width;
horizontalBlurMaterial.uniforms.tDiffuse.value = this.renderTarget!.texture;
renderer.setRenderTarget(renderTargetBlur);
renderer.render(blurPlane, camera);
// blur vertically and draw in the main renderTarget
blurPlane.material = verticalBlurMaterial;
verticalBlurMaterial.uniforms.v.value = 1 / this.renderTarget!.height;
verticalBlurMaterial.uniforms.tDiffuse.value =
this.renderTargetBlur!.texture;
renderer.setRenderTarget(renderTarget);
renderer.render(blurPlane, camera);
blurPlane.visible = false;
}
dispose() {
if (this.renderTarget != null) {
this.renderTarget.dispose();
}
if (this.renderTargetBlur != null) {
this.renderTargetBlur.dispose();
}
this.depthMaterial.dispose();
this.horizontalBlurMaterial.dispose();
this.verticalBlurMaterial.dispose();
(this.floor.material as Material).dispose();
this.floor.geometry.dispose();
this.blurPlane.geometry.dispose();
this.removeFromParent();
}
}
@@ -0,0 +1,923 @@
/* @license
* 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 {Euler, Event as ThreeEvent, EventDispatcher, Matrix3, PerspectiveCamera, Spherical, Vector2, Vector3} from 'three';
import {$panElement, TouchAction} from '../features/controls.js';
import {clamp} from '../utilities.js';
import {Damper, SETTLING_TIME} from './Damper.js';
import {ModelScene} from './ModelScene.js';
const PAN_SENSITIVITY = 0.018;
const TAP_DISTANCE = 2;
const TAP_MS = 300;
const vector2 = new Vector2();
const vector3 = new Vector3();
export type TouchMode = null|((dx: number, dy: number) => void);
export interface Pointer {
clientX: number;
clientY: number;
id: number;
}
export interface SmoothControlsOptions {
// The closest the camera can be to the target
minimumRadius?: number;
// The farthest the camera can be from the target
maximumRadius?: number;
// The minimum angle between model-up and the camera polar position
minimumPolarAngle?: number;
// The maximum angle between model-up and the camera polar position
maximumPolarAngle?: number;
// The minimum angle between model-forward and the camera azimuthal position
minimumAzimuthalAngle?: number;
// The maximum angle between model-forward and the camera azimuthal position
maximumAzimuthalAngle?: number;
// The minimum camera field of view in degrees
minimumFieldOfView?: number;
// The maximum camera field of view in degrees
maximumFieldOfView?: number;
// Controls scrolling behavior
touchAction?: TouchAction;
}
export const DEFAULT_OPTIONS = Object.freeze<SmoothControlsOptions>({
minimumRadius: 0,
maximumRadius: Infinity,
minimumPolarAngle: 0,
maximumPolarAngle: Math.PI,
minimumAzimuthalAngle: -Infinity,
maximumAzimuthalAngle: Infinity,
minimumFieldOfView: 10,
maximumFieldOfView: 45,
touchAction: 'none'
});
// Constants
const KEYBOARD_ORBIT_INCREMENT = Math.PI / 8;
const ZOOM_SENSITIVITY = 0.04;
// The move size on pan key event
const PAN_KEY_INCREMENT = 10;
export const KeyCode = {
PAGE_UP: 33,
PAGE_DOWN: 34,
LEFT: 37,
UP: 38,
RIGHT: 39,
DOWN: 40
};
export type ChangeSource = 'user-interaction'|'none'|'automatic';
export const ChangeSource: {[index: string]: ChangeSource} = {
USER_INTERACTION: 'user-interaction',
NONE: 'none',
AUTOMATIC: 'automatic'
};
/**
* ChangEvents are dispatched whenever the camera position or orientation has
* changed
*/
export interface ChangeEvent extends ThreeEvent {
/**
* determines what was the originating reason for the change event eg user or
* none
*/
source: ChangeSource,
}
export interface PointerChangeEvent extends ThreeEvent {
type: 'pointer-change-start'|'pointer-change-end';
}
/**
* SmoothControls is a Three.js helper for adding delightful pointer and
* keyboard-based input to a staged Three.js scene. Its API is very similar to
* OrbitControls, but it offers more opinionated (subjectively more delightful)
* defaults, easy extensibility and subjectively better out-of-the-box keyboard
* support.
*
* One important change compared to OrbitControls is that the `update` method
* of SmoothControls must be invoked on every frame, otherwise the controls
* will not have an effect.
*
* Another notable difference compared to OrbitControls is that SmoothControls
* does not currently support panning (but probably will in a future revision).
*
* Like OrbitControls, SmoothControls assumes that the orientation of the camera
* has been set in terms of position, rotation and scale, so it is important to
* ensure that the camera's matrixWorld is in sync before using SmoothControls.
*/
export class SmoothControls extends EventDispatcher<{
'user-interaction': {},
'pointer-change-start': {},
'pointer-change-end': {}
}> {
public orbitSensitivity = 1;
public zoomSensitivity = 1;
public panSensitivity = 1;
public inputSensitivity = 1;
public changeSource = ChangeSource.NONE;
private _interactionEnabled: boolean = false;
private _options: SmoothControlsOptions;
private _disableZoom = false;
private isUserPointing = false;
// Pan state
public enablePan = true;
public enableTap = true;
private panProjection = new Matrix3();
private panPerPixel = 0;
// Internal orbital position state
private spherical = new Spherical();
private goalSpherical = new Spherical();
private thetaDamper = new Damper();
private phiDamper = new Damper();
private radiusDamper = new Damper();
private logFov = Math.log(DEFAULT_OPTIONS.maximumFieldOfView!);
private goalLogFov = this.logFov;
private fovDamper = new Damper();
// Pointer state
private touchMode: TouchMode = null;
private pointers: Pointer[] = [];
private startTime = 0;
private startPointerPosition = {clientX: 0, clientY: 0};
private lastSeparation = 0;
private touchDecided = false;
constructor(
readonly camera: PerspectiveCamera, readonly element: HTMLElement,
readonly scene: ModelScene) {
super();
this._options = Object.assign({}, DEFAULT_OPTIONS);
this.setOrbit(0, Math.PI / 2, 1);
this.setFieldOfView(100);
this.jumpToGoal();
}
get interactionEnabled(): boolean {
return this._interactionEnabled;
}
enableInteraction() {
if (this._interactionEnabled === false) {
const {element} = this;
element.addEventListener('pointerdown', this.onPointerDown);
element.addEventListener('pointercancel', this.onPointerUp);
if (!this._disableZoom) {
element.addEventListener('wheel', this.onWheel);
}
element.addEventListener('keydown', this.onKeyDown);
// This little beauty is to work around a WebKit bug that otherwise makes
// touch events randomly not cancelable.
element.addEventListener('touchmove', () => {}, {passive: false});
element.addEventListener('contextmenu', this.onContext);
this.element.style.cursor = 'grab';
this._interactionEnabled = true;
this.updateTouchActionStyle();
}
}
disableInteraction() {
if (this._interactionEnabled === true) {
const {element} = this;
element.removeEventListener('pointerdown', this.onPointerDown);
element.removeEventListener('pointermove', this.onPointerMove);
element.removeEventListener('pointerup', this.onPointerUp);
element.removeEventListener('pointercancel', this.onPointerUp);
element.removeEventListener('wheel', this.onWheel);
element.removeEventListener('keydown', this.onKeyDown);
element.removeEventListener('contextmenu', this.onContext);
element.style.cursor = '';
this.touchMode = null;
this._interactionEnabled = false;
this.updateTouchActionStyle();
}
}
/**
* The options that are currently configured for the controls instance.
*/
get options() {
return this._options;
}
onContext = (event: MouseEvent) => {
if (this.enablePan) {
event.preventDefault();
} else {
for (const pointer of this.pointers) {
// Required because of a common browser bug where the context menu never
// fires a pointercancel event.
this.onPointerUp(new PointerEvent(
'pointercancel',
{...this.startPointerPosition, pointerId: pointer.id}));
}
}
};
set disableZoom(disable: boolean) {
if (this._disableZoom != disable) {
this._disableZoom = disable;
if (disable === true) {
this.element.removeEventListener('wheel', this.onWheel);
} else {
this.element.addEventListener('wheel', this.onWheel);
}
this.updateTouchActionStyle();
}
}
/**
* Copy the spherical values that represent the current camera orbital
* position relative to the configured target into a provided Spherical
* instance. If no Spherical is provided, a new Spherical will be allocated
* to copy the values into. The Spherical that values are copied into is
* returned.
*/
getCameraSpherical(target: Spherical = new Spherical()) {
return target.copy(this.spherical);
}
/**
* Returns the camera's current vertical field of view in degrees.
*/
getFieldOfView(): number {
return this.camera.fov;
}
/**
* Configure the _options of the controls. Configured _options will be
* merged with whatever _options have already been configured for this
* controls instance.
*/
applyOptions(_options: SmoothControlsOptions) {
Object.assign(this._options, _options);
// Re-evaluates clamping based on potentially new values for min/max
// polar, azimuth and radius:
this.setOrbit();
this.setFieldOfView(Math.exp(this.goalLogFov));
}
/**
* Sets the near and far planes of the camera.
*/
updateNearFar(nearPlane: number, farPlane: number) {
this.camera.far = farPlane === 0 ? 2 : farPlane;
this.camera.near = Math.max(nearPlane, this.camera.far / 1000);
this.camera.updateProjectionMatrix();
}
/**
* Sets the aspect ratio of the camera
*/
updateAspect(aspect: number) {
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
/**
* Set the absolute orbital goal of the camera. The change will be
* applied over a number of frames depending on configured acceleration and
* dampening _options.
*
* Returns true if invoking the method will result in the camera changing
* position and/or rotation, otherwise false.
*/
setOrbit(
goalTheta: number = this.goalSpherical.theta,
goalPhi: number = this.goalSpherical.phi,
goalRadius: number = this.goalSpherical.radius): boolean {
const {
minimumAzimuthalAngle,
maximumAzimuthalAngle,
minimumPolarAngle,
maximumPolarAngle,
minimumRadius,
maximumRadius
} = this._options;
const {theta, phi, radius} = this.goalSpherical;
const nextTheta =
clamp(goalTheta, minimumAzimuthalAngle!, maximumAzimuthalAngle!);
if (!isFinite(minimumAzimuthalAngle!) &&
!isFinite(maximumAzimuthalAngle!)) {
this.spherical.theta =
this.wrapAngle(this.spherical.theta - nextTheta) + nextTheta;
}
const nextPhi = clamp(goalPhi, minimumPolarAngle!, maximumPolarAngle!);
const nextRadius = clamp(goalRadius, minimumRadius!, maximumRadius!);
if (nextTheta === theta && nextPhi === phi && nextRadius === radius) {
return false;
}
if (!isFinite(nextTheta) || !isFinite(nextPhi) || !isFinite(nextRadius)) {
return false;
}
this.goalSpherical.theta = nextTheta;
this.goalSpherical.phi = nextPhi;
this.goalSpherical.radius = nextRadius;
this.goalSpherical.makeSafe();
return true;
}
/**
* Subset of setOrbit() above, which only sets the camera's radius.
*/
setRadius(radius: number) {
this.goalSpherical.radius = radius;
this.setOrbit();
}
/**
* Sets the goal field of view for the camera
*/
setFieldOfView(fov: number) {
const {minimumFieldOfView, maximumFieldOfView} = this._options;
fov = clamp(fov, minimumFieldOfView!, maximumFieldOfView!);
this.goalLogFov = Math.log(fov);
}
/**
* Sets the smoothing decay time.
*/
setDamperDecayTime(decayMilliseconds: number) {
this.thetaDamper.setDecayTime(decayMilliseconds);
this.phiDamper.setDecayTime(decayMilliseconds);
this.radiusDamper.setDecayTime(decayMilliseconds);
this.fovDamper.setDecayTime(decayMilliseconds);
}
/**
* Adjust the orbital position of the camera relative to its current orbital
* position. Does not let the theta goal get more than pi ahead of the current
* theta, which ensures interpolation continues in the direction of the delta.
* The deltaZoom parameter adjusts both the field of view and the orbit radius
* such that they progress across their allowed ranges in sync.
*/
adjustOrbit(deltaTheta: number, deltaPhi: number, deltaZoom: number) {
const {theta, phi, radius} = this.goalSpherical;
const {
minimumRadius,
maximumRadius,
minimumFieldOfView,
maximumFieldOfView
} = this._options;
const dTheta = this.spherical.theta - theta;
const dThetaLimit = Math.PI - 0.001;
const goalTheta =
theta - clamp(deltaTheta, -dThetaLimit - dTheta, dThetaLimit - dTheta);
const goalPhi = phi - deltaPhi;
const deltaRatio = deltaZoom === 0 ?
0 :
((deltaZoom > 0 ? maximumRadius! : minimumRadius!) - radius) /
(Math.log(
deltaZoom > 0 ? maximumFieldOfView! : minimumFieldOfView!) -
this.goalLogFov);
const goalRadius = radius +
deltaZoom *
(isFinite(deltaRatio) ? deltaRatio :
(maximumRadius! - minimumRadius!) * 2);
this.setOrbit(goalTheta, goalPhi, goalRadius);
if (deltaZoom !== 0) {
const goalLogFov = this.goalLogFov + deltaZoom;
this.setFieldOfView(Math.exp(goalLogFov));
}
}
/**
* Move the camera instantly instead of accelerating toward the goal
* parameters.
*/
jumpToGoal() {
this.update(0, SETTLING_TIME);
}
/**
* Update controls. In most cases, this will result in the camera
* interpolating its position and rotation until it lines up with the
* designated goal orbital position. Returns false if the camera did not move.
*
* Time and delta are measured in milliseconds.
*/
update(_time: number, delta: number): boolean {
if (this.isStationary()) {
return false;
}
const {maximumPolarAngle, maximumRadius} = this._options;
const dTheta = this.spherical.theta - this.goalSpherical.theta;
if (Math.abs(dTheta) > Math.PI &&
!isFinite(this._options.minimumAzimuthalAngle!) &&
!isFinite(this._options.maximumAzimuthalAngle!)) {
this.spherical.theta -= Math.sign(dTheta) * 2 * Math.PI;
}
this.spherical.theta = this.thetaDamper.update(
this.spherical.theta, this.goalSpherical.theta, delta, Math.PI);
this.spherical.phi = this.phiDamper.update(
this.spherical.phi, this.goalSpherical.phi, delta, maximumPolarAngle!);
this.spherical.radius = this.radiusDamper.update(
this.spherical.radius, this.goalSpherical.radius, delta, maximumRadius!
);
this.logFov = this.fovDamper.update(this.logFov, this.goalLogFov, delta, 1);
this.moveCamera();
return true;
}
updateTouchActionStyle() {
const {style} = this.element;
if (this._interactionEnabled) {
const {touchAction} = this._options;
if (this._disableZoom && touchAction !== 'none') {
style.touchAction = 'manipulation';
} else {
style.touchAction = touchAction!;
}
} else {
style.touchAction = '';
}
}
private isStationary(): boolean {
return this.goalSpherical.theta === this.spherical.theta &&
this.goalSpherical.phi === this.spherical.phi &&
this.goalSpherical.radius === this.spherical.radius &&
this.goalLogFov === this.logFov;
}
private moveCamera() {
// Derive the new camera position from the updated spherical:
this.spherical.makeSafe();
this.camera.position.setFromSpherical(this.spherical);
this.camera.setRotationFromEuler(new Euler(
this.spherical.phi - Math.PI / 2, this.spherical.theta, 0, 'YXZ'));
if (this.camera.fov !== Math.exp(this.logFov)) {
this.camera.fov = Math.exp(this.logFov);
this.camera.updateProjectionMatrix();
}
}
private userAdjustOrbit(
deltaTheta: number, deltaPhi: number, deltaZoom: number) {
this.adjustOrbit(
deltaTheta * this.orbitSensitivity * this.inputSensitivity,
deltaPhi * this.orbitSensitivity * this.inputSensitivity,
deltaZoom * this.inputSensitivity);
}
// Wraps to between -pi and pi
private wrapAngle(radians: number): number {
const normalized = (radians + Math.PI) / (2 * Math.PI);
const wrapped = normalized - Math.floor(normalized);
return wrapped * 2 * Math.PI - Math.PI;
}
private pixelLengthToSphericalAngle(pixelLength: number): number {
return 2 * Math.PI * pixelLength / this.scene.height;
}
private twoTouchDistance(touchOne: Pointer, touchTwo: Pointer): number {
const {clientX: xOne, clientY: yOne} = touchOne;
const {clientX: xTwo, clientY: yTwo} = touchTwo;
const xDelta = xTwo - xOne;
const yDelta = yTwo - yOne;
return Math.sqrt(xDelta * xDelta + yDelta * yDelta);
}
private touchModeZoom: TouchMode = (dx: number, dy: number) => {
if (!this._disableZoom) {
const touchDistance =
this.twoTouchDistance(this.pointers[0], this.pointers[1]);
const deltaZoom = ZOOM_SENSITIVITY * this.zoomSensitivity *
(this.lastSeparation - touchDistance) * 50 / this.scene.height;
this.lastSeparation = touchDistance;
this.userAdjustOrbit(0, 0, deltaZoom);
}
if (this.panPerPixel > 0) {
this.movePan(dx, dy);
}
};
// We implement our own version of the browser's CSS touch-action, enforced by
// this function, because the iOS implementation of pan-y is bad and doesn't
// match Android. Specifically, even if a touch gesture begins by panning X,
// iOS will switch to scrolling as soon as the gesture moves in the Y, rather
// than staying in the same mode until the end of the gesture.
private disableScroll = (event: TouchEvent) => {
event.preventDefault();
};
private touchModeRotate: TouchMode = (dx: number, dy: number) => {
const {touchAction} = this._options;
if (!this.touchDecided && touchAction !== 'none') {
this.touchDecided = true;
const dxMag = Math.abs(dx);
const dyMag = Math.abs(dy);
// If motion is mostly vertical, assume scrolling is the intent.
if (this.changeSource === ChangeSource.USER_INTERACTION &&
((touchAction === 'pan-y' && dyMag > dxMag) ||
(touchAction === 'pan-x' && dxMag > dyMag))) {
this.touchMode = null;
return;
} else {
this.element.addEventListener(
'touchmove', this.disableScroll, {passive: false});
}
}
this.handleSinglePointerMove(dx, dy);
};
private handleSinglePointerMove(dx: number, dy: number) {
const deltaTheta = this.pixelLengthToSphericalAngle(dx);
const deltaPhi = this.pixelLengthToSphericalAngle(dy);
if (this.isUserPointing === false) {
this.isUserPointing = true;
this.dispatchEvent({type: 'pointer-change-start'});
}
this.userAdjustOrbit(deltaTheta, deltaPhi, 0);
}
private initializePan() {
const {theta, phi} = this.spherical;
const psi = theta - this.scene.yaw;
this.panPerPixel =
PAN_SENSITIVITY * this.panSensitivity / this.scene.height;
this.panProjection.set(
-Math.cos(psi),
-Math.cos(phi) * Math.sin(psi),
0,
0,
Math.sin(phi),
0,
Math.sin(psi),
-Math.cos(phi) * Math.cos(psi),
0);
}
private movePan(dx: number, dy: number) {
const {scene} = this;
const dxy = vector3.set(dx, dy, 0).multiplyScalar(this.inputSensitivity);
const metersPerPixel =
this.spherical.radius * Math.exp(this.logFov) * this.panPerPixel;
dxy.multiplyScalar(metersPerPixel);
const target = scene.getTarget();
target.add(dxy.applyMatrix3(this.panProjection));
scene.boundingSphere.clampPoint(target, target);
scene.setTarget(target.x, target.y, target.z);
}
private recenter(pointer: PointerEvent) {
if (performance.now() > this.startTime + TAP_MS ||
Math.abs(pointer.clientX - this.startPointerPosition.clientX) >
TAP_DISTANCE ||
Math.abs(pointer.clientY - this.startPointerPosition.clientY) >
TAP_DISTANCE) {
return;
}
const {scene} = this;
const hit = scene.positionAndNormalFromPoint(
scene.getNDC(pointer.clientX, pointer.clientY));
if (hit == null) {
const {cameraTarget} = scene.element;
scene.element.cameraTarget = '';
scene.element.cameraTarget = cameraTarget;
// Zoom all the way out.
this.userAdjustOrbit(0, 0, 1);
} else {
scene.target.worldToLocal(hit.position);
scene.setTarget(hit.position.x, hit.position.y, hit.position.z);
}
}
private resetRadius() {
const {scene} = this;
const hit = scene.positionAndNormalFromPoint(vector2.set(0, 0));
if (hit == null) {
return;
}
scene.target.worldToLocal(hit.position);
const goalTarget = scene.getTarget();
const {theta, phi} = this.spherical;
// Set target to surface hit point, except the target is still settling,
// so offset the goal accordingly so the transition is smooth even though
// this will drift the target slightly away from the hit point.
const psi = theta - scene.yaw;
const n = vector3.set(
Math.sin(phi) * Math.sin(psi),
Math.cos(phi),
Math.sin(phi) * Math.cos(psi));
const dr = n.dot(hit.position.sub(goalTarget));
goalTarget.add(n.multiplyScalar(dr));
scene.setTarget(goalTarget.x, goalTarget.y, goalTarget.z);
// Change the camera radius to match the change in target so that the
// camera itself does not move, unless it hits a radius bound.
this.setOrbit(undefined, undefined, this.goalSpherical.radius - dr);
}
private onPointerDown = (event: PointerEvent) => {
if (this.pointers.length > 2) {
return;
}
const {element} = this;
if (this.pointers.length === 0) {
element.addEventListener('pointermove', this.onPointerMove);
element.addEventListener('pointerup', this.onPointerUp);
this.touchMode = null;
this.touchDecided = false;
this.startPointerPosition.clientX = event.clientX;
this.startPointerPosition.clientY = event.clientY;
this.startTime = performance.now();
}
try {
element.setPointerCapture(event.pointerId);
} catch {
}
this.pointers.push(
{clientX: event.clientX, clientY: event.clientY, id: event.pointerId});
this.isUserPointing = false;
if (event.pointerType === 'touch') {
this.changeSource = event.altKey ? // set by interact() in controls.ts
ChangeSource.AUTOMATIC :
ChangeSource.USER_INTERACTION;
this.onTouchChange(event);
} else {
this.changeSource = ChangeSource.USER_INTERACTION;
this.onMouseDown(event);
}
if (this.changeSource === ChangeSource.USER_INTERACTION) {
this.dispatchEvent({type: 'user-interaction'});
}
};
private onPointerMove = (event: PointerEvent) => {
const pointer =
this.pointers.find((pointer) => pointer.id === event.pointerId);
if (pointer == null) {
return;
}
// In case no one gave us a pointerup or pointercancel event.
if (event.pointerType === 'mouse' && event.buttons === 0) {
this.onPointerUp(event);
return;
}
const numTouches = this.pointers.length;
const dx = (event.clientX - pointer.clientX) / numTouches;
const dy = (event.clientY - pointer.clientY) / numTouches;
if (dx === 0 && dy === 0) {
return;
}
pointer.clientX = event.clientX;
pointer.clientY = event.clientY;
if (event.pointerType === 'touch') {
this.changeSource = event.altKey ? // set by interact() in controls.ts
ChangeSource.AUTOMATIC :
ChangeSource.USER_INTERACTION;
if (this.touchMode !== null) {
this.touchMode(dx, dy);
}
} else {
this.changeSource = ChangeSource.USER_INTERACTION;
if (this.panPerPixel > 0) {
this.movePan(dx, dy);
} else {
this.handleSinglePointerMove(dx, dy);
}
}
};
private onPointerUp = (event: PointerEvent) => {
const {element} = this;
const index =
this.pointers.findIndex((pointer) => pointer.id === event.pointerId);
if (index !== -1) {
this.pointers.splice(index, 1);
}
// altKey indicates an interaction prompt; don't reset radius in this case
// as it will cause the camera to drift.
if (this.panPerPixel > 0 && !event.altKey) {
this.resetRadius();
}
if (this.pointers.length === 0) {
element.removeEventListener('pointermove', this.onPointerMove);
element.removeEventListener('pointerup', this.onPointerUp);
element.removeEventListener('touchmove', this.disableScroll);
if (this.enablePan && this.enableTap) {
this.recenter(event);
}
} else if (this.touchMode !== null) {
this.onTouchChange(event);
}
(this.scene.element as any)[$panElement].style.opacity = 0;
element.style.cursor = 'grab';
this.panPerPixel = 0;
if (this.isUserPointing) {
this.dispatchEvent({type: 'pointer-change-end'});
}
};
private onTouchChange(event: PointerEvent) {
if (this.pointers.length === 1) {
this.touchMode = this.touchModeRotate;
} else {
if (this._disableZoom) {
this.touchMode = null;
this.element.removeEventListener('touchmove', this.disableScroll);
return;
}
this.touchMode = (this.touchDecided && this.touchMode === null) ?
null :
this.touchModeZoom;
this.touchDecided = true;
this.element.addEventListener(
'touchmove', this.disableScroll, {passive: false});
this.lastSeparation =
this.twoTouchDistance(this.pointers[0], this.pointers[1]);
if (this.enablePan && this.touchMode != null) {
this.initializePan();
if (!event.altKey) { // user interaction, not prompt
(this.scene.element as any)[$panElement].style.opacity = 1;
}
}
}
}
private onMouseDown(event: MouseEvent) {
this.panPerPixel = 0;
if (this.enablePan &&
(event.button === 2 || event.ctrlKey || event.metaKey ||
event.shiftKey)) {
this.initializePan();
(this.scene.element as any)[$panElement].style.opacity = 1;
}
this.element.style.cursor = 'grabbing';
}
private onWheel = (event: Event) => {
this.changeSource = ChangeSource.USER_INTERACTION;
const deltaZoom = (event as WheelEvent).deltaY *
((event as WheelEvent).deltaMode == 1 ? 18 : 1) * ZOOM_SENSITIVITY *
this.zoomSensitivity / 30;
this.userAdjustOrbit(0, 0, deltaZoom);
event.preventDefault();
this.dispatchEvent({type: 'user-interaction'});
};
private onKeyDown = (event: KeyboardEvent) => {
// We track if the key is actually one we respond to, so as not to
// accidentally clobber unrelated key inputs when the <model-viewer> has
// focus.
const {changeSource} = this;
this.changeSource = ChangeSource.USER_INTERACTION;
const relevantKey = (event.shiftKey && this.enablePan) ?
this.panKeyCodeHandler(event) :
this.orbitZoomKeyCodeHandler(event);
if (relevantKey) {
event.preventDefault();
this.dispatchEvent({type: 'user-interaction'});
} else {
this.changeSource = changeSource;
}
};
/**
* Handles the orbit and Zoom key presses
* Uses constants for the increment.
* @param event The keyboard event for the .key value
* @returns boolean to indicate if the key event has been handled
*/
private orbitZoomKeyCodeHandler(event: KeyboardEvent) {
let relevantKey = true;
switch (event.key) {
case 'PageUp':
this.userAdjustOrbit(0, 0, ZOOM_SENSITIVITY * this.zoomSensitivity);
break;
case 'PageDown':
this.userAdjustOrbit(
0, 0, -1 * ZOOM_SENSITIVITY * this.zoomSensitivity);
break;
case 'ArrowUp':
this.userAdjustOrbit(0, -KEYBOARD_ORBIT_INCREMENT, 0);
break;
case 'ArrowDown':
this.userAdjustOrbit(0, KEYBOARD_ORBIT_INCREMENT, 0);
break;
case 'ArrowLeft':
this.userAdjustOrbit(-KEYBOARD_ORBIT_INCREMENT, 0, 0);
break;
case 'ArrowRight':
this.userAdjustOrbit(KEYBOARD_ORBIT_INCREMENT, 0, 0);
break;
default:
relevantKey = false;
break;
}
return relevantKey;
}
/**
* Handles the Pan key presses
* Uses constants for the increment.
* @param event The keyboard event for the .key value
* @returns boolean to indicate if the key event has been handled
*/
private panKeyCodeHandler(event: KeyboardEvent) {
this.initializePan();
let relevantKey = true;
switch (event.key) {
case 'ArrowUp':
this.movePan(
0,
-1 * PAN_KEY_INCREMENT); // This is the negative one so that the
// model appears to move as the arrow
// direction rather than the view moving
break;
case 'ArrowDown':
this.movePan(0, PAN_KEY_INCREMENT);
break;
case 'ArrowLeft':
this.movePan(-1 * PAN_KEY_INCREMENT, 0);
break;
case 'ArrowRight':
this.movePan(PAN_KEY_INCREMENT, 0);
break;
default:
relevantKey = false;
break;
}
return relevantKey;
}
}
@@ -0,0 +1,457 @@
/* @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 {GainMapDecoderMaterial, HDRJPGLoader, QuadRenderer} from '@monogrid/gainmap-js';
import {BackSide, BoxGeometry, CubeCamera, CubeTexture, DataTexture, EquirectangularReflectionMapping, HalfFloatType, LinearSRGBColorSpace, Loader, Mesh, NoBlending, NoToneMapping, RGBAFormat, Scene, ShaderMaterial, SRGBColorSpace, Texture, TextureLoader, Vector3, WebGLCubeRenderTarget, WebGLRenderer} from 'three';
import {RGBELoader} from 'three/examples/jsm/loaders/RGBELoader.js';
import {deserializeUrl, timePasses} from '../utilities.js';
import EnvironmentScene from './EnvironmentScene.js';
export interface EnvironmentMapAndSkybox {
environmentMap: Texture;
skybox: Texture|null;
}
const GENERATED_SIGMA = 0.04;
// The maximum length of the blur for loop. Smaller sigmas will use fewer
// samples and exit early, but not recompile the shader.
const MAX_SAMPLES = 20;
const HDR_FILE_RE = /\.hdr(\.js)?$/;
export default class TextureUtils {
public lottieLoaderUrl = '';
private _ldrLoader: TextureLoader|null = null;
private _imageLoader: HDRJPGLoader|null = null;
private _hdrLoader: RGBELoader|null = null;
private _lottieLoader: Loader|null = null;
private generatedEnvironmentMap: Promise<CubeTexture>|null = null;
private generatedEnvironmentMapAlt: Promise<CubeTexture>|null = null;
private skyboxCache = new Map<string, Promise<Texture>>();
private blurMaterial: ShaderMaterial|null = null;
private blurScene: Scene|null = null;
constructor(private threeRenderer: WebGLRenderer) {
}
private ldrLoader(withCredentials: boolean): TextureLoader {
if (this._ldrLoader == null) {
this._ldrLoader = new TextureLoader();
}
this._ldrLoader.setWithCredentials(withCredentials);
return this._ldrLoader;
}
private imageLoader(withCredentials: boolean): HDRJPGLoader {
if (this._imageLoader == null) {
this._imageLoader = new HDRJPGLoader(this.threeRenderer);
}
this._imageLoader.setWithCredentials(withCredentials);
return this._imageLoader;
}
private hdrLoader(withCredentials: boolean): RGBELoader {
if (this._hdrLoader == null) {
this._hdrLoader = new RGBELoader();
this._hdrLoader.setDataType(HalfFloatType);
}
this._hdrLoader.setWithCredentials(withCredentials);
return this._hdrLoader;
}
async getLottieLoader(withCredentials: boolean): Promise<any> {
if (this._lottieLoader == null) {
const {LottieLoader} =
await import(/* webpackIgnore: true */ this.lottieLoaderUrl);
this._lottieLoader = new LottieLoader() as Loader;
}
this._lottieLoader.setWithCredentials(withCredentials);
return this._lottieLoader;
}
async loadImage(url: string, withCredentials: boolean): Promise<Texture> {
const texture: Texture = await new Promise<Texture>(
(resolve, reject) => this.ldrLoader(withCredentials)
.load(url, resolve, () => {}, reject));
texture.name = url;
texture.flipY = false;
return texture;
}
async loadLottie(url: string, quality: number, withCredentials: boolean):
Promise<Texture> {
const loader = await this.getLottieLoader(withCredentials);
loader.setQuality(quality);
const texture: Texture = await new Promise<Texture>(
(resolve, reject) => loader.load(url, resolve, () => {}, reject));
texture.name = url;
return texture;
}
async loadEquirect(
url: string, withCredentials = false,
progressCallback: (progress: number) => void = () => {}):
Promise<Texture> {
try {
const isHDR: boolean = HDR_FILE_RE.test(url);
const loader = isHDR ? this.hdrLoader(withCredentials) :
this.imageLoader(withCredentials);
const texture: Texture = await new Promise<Texture>(
(resolve, reject) => loader.load(
url,
(result) => {
const {renderTarget} =
result as QuadRenderer<1016, GainMapDecoderMaterial>;
if (renderTarget != null) {
const {texture} = renderTarget;
result.dispose(false);
resolve(texture);
} else {
resolve(result as DataTexture);
}
},
(event: {loaded: number, total: number}) => {
progressCallback(event.loaded / event.total * 0.9);
},
reject));
progressCallback(1.0);
texture.name = url;
texture.mapping = EquirectangularReflectionMapping;
if (!isHDR) {
texture.colorSpace = SRGBColorSpace;
}
return texture;
} finally {
if (progressCallback) {
progressCallback(1);
}
}
}
/**
* Returns a { skybox, environmentMap } object with the targets/textures
* accordingly. `skybox` is a WebGLRenderCubeTarget, and `environmentMap`
* is a Texture from a WebGLRenderCubeTarget.
*/
async generateEnvironmentMapAndSkybox(
skyboxUrl: string|null = null, environmentMapUrl: string|null = null,
progressCallback: (progress: number) => void = () => {},
withCredentials = false): Promise<EnvironmentMapAndSkybox> {
const useAltEnvironment = environmentMapUrl !== 'legacy';
if (environmentMapUrl === 'legacy' || environmentMapUrl === 'neutral') {
environmentMapUrl = null;
}
environmentMapUrl = deserializeUrl(environmentMapUrl);
let skyboxLoads: Promise<Texture|null> = Promise.resolve(null);
let environmentMapLoads: Promise<Texture>;
// If we have a skybox URL, attempt to load it as a cubemap
if (!!skyboxUrl) {
skyboxLoads = this.loadEquirectFromUrl(
skyboxUrl, withCredentials, progressCallback);
}
if (!!environmentMapUrl) {
// We have an available environment map URL
environmentMapLoads = this.loadEquirectFromUrl(
environmentMapUrl, withCredentials, progressCallback);
} else if (!!skyboxUrl) {
// Fallback to deriving the environment map from an available skybox
environmentMapLoads = this.loadEquirectFromUrl(
skyboxUrl, withCredentials, progressCallback);
} else {
// Fallback to generating the environment map
environmentMapLoads = useAltEnvironment ?
this.loadGeneratedEnvironmentMapAlt() :
this.loadGeneratedEnvironmentMap();
}
const [environmentMap, skybox] =
await Promise.all([environmentMapLoads, skyboxLoads]);
if (environmentMap == null) {
throw new Error('Failed to load environment map.');
}
return {environmentMap, skybox};
}
/**
* Loads an equirect Texture from a given URL, for use as a skybox.
*/
private async loadEquirectFromUrl(
url: string, withCredentials: boolean,
progressCallback: (progress: number) => void): Promise<Texture> {
if (!this.skyboxCache.has(url)) {
const skyboxMapLoads =
this.loadEquirect(url, withCredentials, progressCallback);
this.skyboxCache.set(url, skyboxMapLoads);
}
return this.skyboxCache.get(url)!;
}
private async GenerateEnvironmentMap(scene: Scene, name: string) {
await timePasses();
const renderer = this.threeRenderer;
const cubeTarget = new WebGLCubeRenderTarget(256, {
generateMipmaps: false,
type: HalfFloatType,
format: RGBAFormat,
colorSpace: LinearSRGBColorSpace,
depthBuffer: true
});
const cubeCamera = new CubeCamera(0.1, 100, cubeTarget);
const generatedEnvironmentMap = cubeCamera.renderTarget.texture;
generatedEnvironmentMap.name = name;
const outputColorSpace = renderer.outputColorSpace;
const toneMapping = renderer.toneMapping;
renderer.toneMapping = NoToneMapping;
renderer.outputColorSpace = LinearSRGBColorSpace;
cubeCamera.update(renderer, scene);
this.blurCubemap(cubeTarget, GENERATED_SIGMA);
renderer.toneMapping = toneMapping;
renderer.outputColorSpace = outputColorSpace;
return generatedEnvironmentMap;
}
/**
* Loads a dynamically generated environment map.
*/
private async loadGeneratedEnvironmentMap(): Promise<CubeTexture> {
if (this.generatedEnvironmentMap == null) {
this.generatedEnvironmentMap =
this.GenerateEnvironmentMap(new EnvironmentScene('legacy'), 'legacy');
}
return this.generatedEnvironmentMap;
}
/**
* Loads a dynamically generated environment map, designed to be neutral and
* color-preserving. Shows less contrast around the different sides of the
* object.
*/
private async loadGeneratedEnvironmentMapAlt(): Promise<CubeTexture> {
if (this.generatedEnvironmentMapAlt == null) {
this.generatedEnvironmentMapAlt = this.GenerateEnvironmentMap(
new EnvironmentScene('neutral'), 'neutral');
}
return this.generatedEnvironmentMapAlt;
}
private blurCubemap(cubeTarget: WebGLCubeRenderTarget, sigma: number) {
if (this.blurMaterial == null) {
this.blurMaterial = this.getBlurShader(MAX_SAMPLES);
const box = new BoxGeometry();
const blurMesh = new Mesh(box, this.blurMaterial!);
this.blurScene = new Scene();
this.blurScene.add(blurMesh);
}
const tempTarget = cubeTarget.clone();
this.halfblur(cubeTarget, tempTarget, sigma, 'latitudinal');
this.halfblur(tempTarget, cubeTarget, sigma, 'longitudinal');
// Disposing this target after we're done with it somehow corrupts Safari's
// whole graphics driver. It's random, but occurs more frequently on
// lower-powered GPUs (macbooks with intel graphics, older iPhones). It goes
// beyond just messing up the PMREM, as it also occasionally causes
// visible corruption on the canvas and even on the rest of the page.
/** tempTarget.dispose(); */
}
private halfblur(
targetIn: WebGLCubeRenderTarget, targetOut: WebGLCubeRenderTarget,
sigmaRadians: number, direction: 'latitudinal'|'longitudinal') {
// Number of standard deviations at which to cut off the discrete
// approximation.
const STANDARD_DEVIATIONS = 3;
const pixels = targetIn.width;
const radiansPerPixel = isFinite(sigmaRadians) ?
Math.PI / (2 * pixels) :
2 * Math.PI / (2 * MAX_SAMPLES - 1);
const sigmaPixels = sigmaRadians / radiansPerPixel;
const samples = isFinite(sigmaRadians) ?
1 + Math.floor(STANDARD_DEVIATIONS * sigmaPixels) :
MAX_SAMPLES;
if (samples > MAX_SAMPLES) {
console.warn(`sigmaRadians, ${
sigmaRadians}, is too large and will clip, as it requested ${
samples} samples when the maximum is set to ${MAX_SAMPLES}`);
}
const weights = [];
let sum = 0;
for (let i = 0; i < MAX_SAMPLES; ++i) {
const x = i / sigmaPixels;
const weight = Math.exp(-x * x / 2);
weights.push(weight);
if (i == 0) {
sum += weight;
} else if (i < samples) {
sum += 2 * weight;
}
}
for (let i = 0; i < weights.length; i++) {
weights[i] = weights[i] / sum;
}
const blurUniforms = this.blurMaterial!.uniforms;
blurUniforms['envMap'].value = targetIn.texture;
blurUniforms['samples'].value = samples;
blurUniforms['weights'].value = weights;
blurUniforms['latitudinal'].value = direction === 'latitudinal';
blurUniforms['dTheta'].value = radiansPerPixel;
const cubeCamera = new CubeCamera(0.1, 100, targetOut);
cubeCamera.update(this.threeRenderer, this.blurScene!);
}
private getBlurShader(maxSamples: number) {
const weights = new Float32Array(maxSamples);
const poleAxis = new Vector3(0, 1, 0);
const shaderMaterial = new ShaderMaterial({
name: 'SphericalGaussianBlur',
defines: {'n': maxSamples},
uniforms: {
'envMap': {value: null},
'samples': {value: 1},
'weights': {value: weights},
'latitudinal': {value: false},
'dTheta': {value: 0},
'poleAxis': {value: poleAxis}
},
vertexShader: /* glsl */ `
varying vec3 vOutputDirection;
void main() {
vOutputDirection = vec3( position );
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}
`,
fragmentShader: /* glsl */ `
varying vec3 vOutputDirection;
uniform samplerCube envMap;
uniform int samples;
uniform float weights[ n ];
uniform bool latitudinal;
uniform float dTheta;
uniform vec3 poleAxis;
vec3 getSample( float theta, vec3 axis ) {
float cosTheta = cos( theta );
// Rodrigues' axis-angle rotation
vec3 sampleDirection = vOutputDirection * cosTheta
+ cross( axis, vOutputDirection ) * sin( theta )
+ axis * dot( axis, vOutputDirection ) * ( 1.0 - cosTheta );
return vec3( textureCube( envMap, sampleDirection ) );
}
void main() {
vec3 axis = latitudinal ? poleAxis : cross( poleAxis, vOutputDirection );
if ( all( equal( axis, vec3( 0.0 ) ) ) ) {
axis = vec3( vOutputDirection.z, 0.0, - vOutputDirection.x );
}
axis = normalize( axis );
gl_FragColor = vec4( 0.0, 0.0, 0.0, 1.0 );
gl_FragColor.rgb += weights[ 0 ] * getSample( 0.0, axis );
for ( int i = 1; i < n; i++ ) {
if ( i >= samples ) {
break;
}
float theta = dTheta * float( i );
gl_FragColor.rgb += weights[ i ] * getSample( -1.0 * theta, axis );
gl_FragColor.rgb += weights[ i ] * getSample( theta, axis );
}
}
`,
blending: NoBlending,
depthTest: false,
depthWrite: false,
side: BackSide
});
return shaderMaterial;
}
async dispose() {
for (const [, promise] of this.skyboxCache) {
const skybox = await promise;
skybox.dispose();
}
if (this.generatedEnvironmentMap != null) {
(await this.generatedEnvironmentMap).dispose();
this.generatedEnvironmentMap = null;
}
if (this.generatedEnvironmentMapAlt != null) {
(await this.generatedEnvironmentMapAlt).dispose();
this.generatedEnvironmentMapAlt = null;
}
if (this.blurMaterial != null) {
this.blurMaterial.dispose();
}
}
}
@@ -0,0 +1,335 @@
import { Camera, CanvasTexture, Mesh, Object3D, Shape, ShapeGeometry, LinearFilter, MeshBasicMaterial, PlaneGeometry, XRTargetRaySpace, Vector3 } from 'three';
import { Damper } from './Damper.js';
import { ModelScene } from './ModelScene.js';
import { PlacementBox } from './PlacementBox.js';
// SVG strings for the icons are defined here to avoid io and better performance for xr.
const CLOSE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#e8eaed">
<path d="M6.4,19L5,17.6L10.6,12L5,6.4L6.4,5L12,10.6L17.6,5L19,6.4L13.4,12L19,17.6L17.6,19L12,13.4L6.4,19Z"/>
</svg>`;
const VIEW_REAL_SIZE_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#e8eaed">
<path d="M7,17V9H5V7H9V17H7ZM11,17V15H13V17H11ZM16,17V9H14V7H18V17H16ZM11,13V11H13V13H11Z"/>
</svg>`;
const REPLAY_ICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24px" height="24px" viewBox="0 0 24 24" fill="#E1E2E8">
<defs>
<clipPath id="clip0">
<path d="M0,0h24v24h-24z"/>
</clipPath>
</defs>
<g clip-path="url(#clip0)">
<path d="M12,22C10.75,22 9.575,21.767 8.475,21.3C7.392,20.817 6.442,20.175 5.625,19.375C4.825,18.558 4.183,17.608 3.7,16.525C3.233,15.425 3,14.25 3,13H5C5,14.95 5.675,16.608 7.025,17.975C8.392,19.325 10.05,20 12,20C13.95,20 15.6,19.325 16.95,17.975C18.317,16.608 19,14.95 19,13C19,11.05 18.317,9.4 16.95,8.05C15.6,6.683 13.95,6 12,6H11.85L13.4,7.55L12,9L8,5L12,1L13.4,2.45L11.85,4H12C13.25,4 14.417,4.242 15.5,4.725C16.6,5.192 17.55,5.833 18.35,6.65C19.167,7.45 19.808,8.4 20.275,9.5C20.758,10.583 21,11.75 21,13C21,14.25 20.758,15.425 20.275,16.525C19.808,17.608 19.167,18.558 18.35,19.375C17.55,20.175 16.6,20.817 15.5,21.3C14.417,21.767 13.25,22 12,22Z"/>
</g>
</svg>`;
// Panel configuration
const PANEL_CONFIG = {
width: 0.16,
height: 0.07,
cornerRadius: 0.03,
opacity: 1,
color: 0x000000,
// Distance-based scaling configuration
minDistance: 0.5, // Minimum distance for scaling (meters)
maxDistance: 10.0, // Maximum distance for scaling (meters)
baseScale: 1.0, // Base scale factor
distanceScaleFactor: 0.3 // How much to scale per meter of distance
} as const;
// Button configuration
const BUTTON_CONFIG = {
size: 0.05, // Fixed size for all buttons
zOffset: 0.01, // Distance from panel surface
spacing: 0.07 // Space between button centers
} as const;
// Icon configuration
const ICON_CONFIG = {
canvasSize: 128,
filter: LinearFilter
} as const;
export class XRMenuPanel extends Object3D {
private panelMesh!: Mesh;
private exitButton!: Mesh;
private toggleButton!: Mesh;
private goalOpacity: number;
private opacityDamper: Damper;
private isActualSize: boolean = false; // Start with normalized size
// Cache for pre-rendered textures
private static readonly iconTextures = new Map<string, CanvasTexture>();
constructor() {
super();
// Pre-render all icons
this.preRenderIcons();
this.createPanel();
this.createButtons();
this.opacityDamper = new Damper();
this.goalOpacity = PANEL_CONFIG.opacity;
}
private createPanel(): void {
const panelShape = this.createPanelShape();
const geometry = new ShapeGeometry(panelShape);
const material = new MeshBasicMaterial({
color: PANEL_CONFIG.color,
opacity: PANEL_CONFIG.opacity,
transparent: true
});
this.panelMesh = new Mesh(geometry, material);
this.panelMesh.name = 'MenuPanel';
this.add(this.panelMesh);
}
private createButtons(): void {
// Create exit button
this.exitButton = this.createButton('close');
this.exitButton.name = 'ExitButton';
this.exitButton.position.set(BUTTON_CONFIG.spacing / 2, 0, BUTTON_CONFIG.zOffset);
this.add(this.exitButton);
// Create toggle button
this.toggleButton = this.createButton('view-real-size');
this.toggleButton.name = 'ToggleButton';
this.toggleButton.position.set(-BUTTON_CONFIG.spacing / 2, 0, BUTTON_CONFIG.zOffset);
this.add(this.toggleButton);
}
private createPanelShape(): Shape {
const shape = new Shape();
const { width: w, height: h, cornerRadius: r } = PANEL_CONFIG;
// Create rounded rectangle path
shape.moveTo(-w / 2 + r, -h / 2);
shape.lineTo(w / 2 - r, -h / 2);
shape.quadraticCurveTo(w / 2, -h / 2, w / 2, -h / 2 + r);
shape.lineTo(w / 2, h / 2 - r);
shape.quadraticCurveTo(w / 2, h / 2, w / 2 - r, h / 2);
shape.lineTo(-w / 2 + r, h / 2);
shape.quadraticCurveTo(-w / 2, h / 2, -w / 2, h / 2 - r);
shape.lineTo(-w / 2, -h / 2 + r);
shape.quadraticCurveTo(-w / 2, -h / 2, -w / 2 + r, -h / 2);
return shape;
}
private preRenderIcons(): void {
const iconSvgs = [
{ key: 'close', svg: CLOSE_ICON_SVG },
{ key: 'view-real-size', svg: VIEW_REAL_SIZE_ICON_SVG },
{ key: 'replay', svg: REPLAY_ICON_SVG }
];
iconSvgs.forEach(({ key, svg }) => {
if (!XRMenuPanel.iconTextures.has(key)) {
this.createTextureFromSvg(svg, key);
}
});
}
private createTextureFromSvg(svgContent: string, key: string): void {
const canvas = document.createElement('canvas');
canvas.width = ICON_CONFIG.canvasSize;
canvas.height = ICON_CONFIG.canvasSize;
const ctx = canvas.getContext('2d')!;
// Create an image from SVG content
const img = new Image();
const svgBlob = new Blob([svgContent], { type: 'image/svg+xml' });
const url = URL.createObjectURL(svgBlob);
img.onload = () => {
ctx.drawImage(img, 0, 0, ICON_CONFIG.canvasSize, ICON_CONFIG.canvasSize);
const texture = new CanvasTexture(canvas);
texture.needsUpdate = true;
texture.minFilter = ICON_CONFIG.filter;
XRMenuPanel.iconTextures.set(key, texture);
URL.revokeObjectURL(url);
};
img.src = url;
}
createButton(iconKey: string): Mesh {
// Create a placeholder mesh
const material = new MeshBasicMaterial({ transparent: true });
const geometry = new PlaneGeometry(BUTTON_CONFIG.size, BUTTON_CONFIG.size);
const mesh = new Mesh(geometry, material);
// Try to get cached texture, or create a fallback
const cachedTexture = XRMenuPanel.iconTextures.get(iconKey);
if (cachedTexture) {
(mesh.material as MeshBasicMaterial).map = cachedTexture;
(mesh.material as MeshBasicMaterial).needsUpdate = true;
} else {
// RACE CONDITION FIX: Texture creation is async (img.onload), but button creation is sync
// This fallback handles the case where buttons are created before textures finish loading
this.createTextureFromSvg(iconKey === 'close' ? CLOSE_ICON_SVG :
iconKey === 'view-real-size' ? VIEW_REAL_SIZE_ICON_SVG :
REPLAY_ICON_SVG, iconKey);
// Polling mechanism: Wait for async texture creation to complete
// This prevents white squares from appearing on first load
const checkTexture = () => {
const texture = XRMenuPanel.iconTextures.get(iconKey);
if (texture) {
// Texture is ready - apply it to the mesh
(mesh.material as MeshBasicMaterial).map = texture;
(mesh.material as MeshBasicMaterial).needsUpdate = true;
} else {
// Texture not ready yet - check again in 10ms
setTimeout(checkTexture, 10);
}
};
checkTexture();
}
return mesh;
}
exitButtonControllerIntersection(scene: ModelScene, controller: XRTargetRaySpace) {
const hitResult = scene.hitFromController(controller, this.exitButton);
return hitResult;
}
scaleModeButtonControllerIntersection(scene: ModelScene, controller: XRTargetRaySpace) {
const hitResult = scene.hitFromController(controller, this.toggleButton);
return hitResult;
}
handleScaleToggle(
worldSpaceInitialPlacementDone: boolean,
initialModelScale: number,
minScale: number,
maxScale: number
): number | null {
if (!worldSpaceInitialPlacementDone) {
return null;
}
this.isActualSize = !this.isActualSize;
// Toggle between view real size icon and replay icon
// When isActualSize is true, show replay icon (to reset)
// When isActualSize is false, show view real size icon (to go to actual size)
const iconKey = this.isActualSize ? 'replay' : 'view-real-size';
this.updateScaleModeButtonLabel(iconKey);
const targetScale = this.isActualSize ? 1.0 : initialModelScale;
const goalScale = Math.max(minScale, Math.min(maxScale, targetScale));
return goalScale;
}
private updateScaleModeButtonLabel(iconKey: string) {
const cachedTexture = XRMenuPanel.iconTextures.get(iconKey);
if (cachedTexture) {
(this.toggleButton.material as MeshBasicMaterial).map = cachedTexture;
(this.toggleButton.material as MeshBasicMaterial).needsUpdate = true;
}
}
updatePosition(camera: Camera, placementBox: PlacementBox) {
if (!placementBox) {
return;
}
// Get the world position of the placement box
const placementBoxWorldPos = new Vector3();
placementBox.getWorldPosition(placementBoxWorldPos);
// Get the placement box size to calculate dynamic offsets
const placementBoxSize = placementBox.getSize();
const placementBoxMinDimension = Math.min(placementBoxSize.x, placementBoxSize.z);
// Calculate dynamic offsets based on placement box size
// Base offsets with placement box size scaling
const baseOffsetUp = -0.2;
const baseOffsetForward = 0.9;
const sizeScaleFactor = Math.max(0.5, Math.min(2.0, placementBoxMinDimension / 1.0)); // Scale between 0.5x and 2x
const offsetUp = baseOffsetUp * sizeScaleFactor;
const offsetForward = baseOffsetForward * sizeScaleFactor;
// Get direction from placement box to camera (horizontal only)
const directionToCamera = new Vector3()
.copy(camera.position)
.sub(placementBoxWorldPos);
directionToCamera.y = 0; // Zero out vertical component
directionToCamera.normalize();
// Calculate the final position
const panelPosition = new Vector3()
.copy(placementBoxWorldPos)
.add(new Vector3(0, offsetUp, 0)) // Move up
.add(directionToCamera.multiplyScalar(offsetForward)); // Move forward
this.position.copy(panelPosition);
// Calculate distance-based scaling
const distanceToCamera = camera.position.distanceTo(panelPosition);
const clampedDistance = Math.max(PANEL_CONFIG.minDistance, Math.min(PANEL_CONFIG.maxDistance, distanceToCamera));
const scaleFactor = PANEL_CONFIG.baseScale + (clampedDistance - PANEL_CONFIG.minDistance) * PANEL_CONFIG.distanceScaleFactor;
// Apply scaling to the entire panel (including buttons)
this.scale.set(scaleFactor, scaleFactor, scaleFactor);
// Make the menu panel face the camera
this.lookAt(camera.position);
}
/**
* Set the box's visibility; it will fade in and out.
*/
set show(visible: boolean) {
this.goalOpacity = visible ? PANEL_CONFIG.opacity : 0;
}
/**
* Call on each frame with the frame delta to fade the box.
*/
updateOpacity(delta: number) {
const material = this.panelMesh.material as MeshBasicMaterial;
const currentOpacity = material.opacity;
const newOpacity = this.opacityDamper.update(currentOpacity, this.goalOpacity, delta, 1);
this.traverse((child) => {
if (child instanceof Mesh) {
const mat = child.material as MeshBasicMaterial;
if (mat.transparent) mat.opacity = newOpacity;
}
});
this.visible = newOpacity > 0;
}
dispose() {
this.children.forEach(child => {
if (child instanceof Mesh) {
// Dispose geometry first
if (child.geometry) {
child.geometry.dispose();
}
// Handle material(s)
// Material can be a single Material or an array of Materials
const materials = Array.isArray(child.material) ? child.material : [child.material];
materials.forEach(material => {
if (material) { // Ensure material exists before proceeding
// Dispose texture if it exists and is a CanvasTexture
// We specifically created CanvasTextures for buttons, so check for that type.
if ('map' in material && material.map instanceof CanvasTexture) { // Check if 'map' property exists and is a CanvasTexture
material.map.dispose();
}
// Dispose material itself
material.dispose();
}
});
}
});
// Remove the panel itself from its parent in the scene graph
this.parent?.remove(this);
}
}
@@ -0,0 +1,153 @@
/* @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 {FrontSide, Material, Mesh, MeshStandardMaterial, Object3D, Sphere, SpotLight} from 'three';
import {GLTF} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {$clone, $prepare, $preparedGLTF, GLTFInstance, PreparedGLTF} from '../GLTFInstance.js';
import {CorrelatedSceneGraph} from './correlated-scene-graph.js';
const $correlatedSceneGraph = Symbol('correlatedSceneGraph');
interface PreparedModelViewerGLTF extends PreparedGLTF {
[$correlatedSceneGraph]?: CorrelatedSceneGraph;
}
/**
* This specialization of GLTFInstance collects all of the processing needed
* to prepare a model and to clone it making special considerations for
* <model-viewer> use cases.
*/
export class ModelViewerGLTFInstance extends GLTFInstance {
/**
* @override
*/
protected static[$prepare](source: GLTF) {
const prepared = super[$prepare](source) as PreparedModelViewerGLTF;
if (prepared[$correlatedSceneGraph] == null) {
prepared[$correlatedSceneGraph] = CorrelatedSceneGraph.from(prepared);
}
const {scene} = prepared;
const nullSphere = new Sphere(undefined, Infinity);
scene.traverse((node: Object3D) => {
// Set a high renderOrder while we're here to ensure the model
// always renders on top of the sky sphere
node.renderOrder = 1000;
// Three.js seems to cull some animated models incorrectly. Since we
// expect to view our whole scene anyway, we turn off the frustum
// culling optimization here.
node.frustumCulled = false;
// Animations for objects without names target their UUID instead. When
// objects are cloned, they get new UUIDs which the animation can't
// find. To fix this, we assign their UUID as their name.
if (!node.name) {
node.name = node.uuid;
}
const mesh = node as Mesh;
if (mesh.material) {
const {geometry} = mesh;
mesh.castShadow = true;
if ((mesh as any).isSkinnedMesh) {
// Akin to disabling frustum culling above, we have to also manually
// disable the bounds to make raycasting correct for skinned meshes.
geometry.boundingSphere = nullSphere;
// The bounding box is set in GLTFLoader by the accessor bounds, which
// are not updated with animation.
geometry.boundingBox = null;
}
const material = mesh.material as MeshStandardMaterial;
if ((material as any).isMeshBasicMaterial === true) {
material.toneMapped = false;
}
// This makes shadows better for non-manifold meshes
material.shadowSide = FrontSide;
// Fixes an edge case with unused extra UV-coords being incorrectly
// referenced by three.js; remove when
// https://github.com/mrdoob/three.js/pull/23974 is merged.
if (material.aoMap) {
const {gltf, threeObjectMap} = prepared[$correlatedSceneGraph]!;
const gltfRef = threeObjectMap.get(material);
if (gltf.materials != null && gltfRef != null &&
gltfRef.materials != null) {
const gltfMaterial = gltf.materials[gltfRef.materials];
if (gltfMaterial.occlusionTexture &&
gltfMaterial.occlusionTexture.texCoord === 0 &&
geometry.attributes.uv != null) {
geometry.setAttribute('uv2', geometry.attributes.uv);
}
}
}
}
});
return prepared;
}
get correlatedSceneGraph() {
return (
this[$preparedGLTF] as PreparedModelViewerGLTF)[$correlatedSceneGraph]!;
}
/**
* @override
*/
[$clone](): PreparedGLTF {
const clone: PreparedModelViewerGLTF = super[$clone]();
const sourceUUIDToClonedMaterial = new Map<string, Material>();
clone.scene.traverse((node: Object3D) => {
// Materials aren't cloned when cloning meshes; geometry
// and materials are copied by reference. This is necessary
// for the same model to be used twice with different
// scene-graph operations.
const mesh = node as Mesh;
if (mesh.material) {
const material = mesh.material as MeshStandardMaterial;
if (material != null) {
if (sourceUUIDToClonedMaterial.has(material.uuid)) {
mesh.material = sourceUUIDToClonedMaterial.get(material.uuid)!;
return;
}
mesh.material = material.clone() as MeshStandardMaterial;
sourceUUIDToClonedMaterial.set(material.uuid, mesh.material);
}
}
const light = node as SpotLight;
if (light.target !== undefined) {
// The target's parent is lost in the cloning process, but in
// GLTFLoader, all light targets are children of their light.
light.add(light.target);
}
});
// Cross-correlate the scene graph by relying on information in the
// current scene graph; without this step, relationships between the
// Three.js object graph and the glTF scene graph will be lost.
clone[$correlatedSceneGraph] =
CorrelatedSceneGraph.from(clone, this.correlatedSceneGraph);
return clone;
}
}
@@ -0,0 +1,184 @@
/* @license
* Copyright 2021 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.
*/
/**
* Materials variants extension
*
* Specification:
* https://github.com/takahirox/three-gltf-extensions/tree/main/loaders/KHR_materials_variants
*/
/**
* The code in this file is based on
* https://github.com/takahirox/three-gltf-extensions/tree/main/exporters/KHR_materials_variants
*/
import {Material, Mesh, Object3D} from 'three';
import {GLTFExporterPlugin} from 'three/examples/jsm/Addons.js';
import {VariantData} from '../../features/scene-graph/model.js';
import {UserDataVariantMapping} from './VariantMaterialLoaderPlugin.js';
/**
* @param object {THREE.Object3D}
* @return {boolean}
*/
const compatibleObject = (object: Object3D) => {
// @TODO: Need properer variantMaterials format validation?
return (object as Mesh).material !==
undefined && // easier than (!object.isMesh && !object.isLine &&
// !object.isPoints)
object.userData && // just in case
object.userData.variantMaterials &&
// Is this line costly?
!!Array
.from((object.userData.variantMaterials as
Map<number, UserDataVariantMapping>)
.values())
.filter(m => compatibleMaterial(m.material));
};
/**
* @param material {THREE.Material}
* @return {boolean}
*/
const compatibleMaterial = (material: Material|null) => {
// @TODO: support multi materials?
return material && material.isMaterial && !Array.isArray(material);
};
export default class GLTFExporterMaterialsVariantsExtension implements
GLTFExporterPlugin {
writer: any; // @TODO: Replace with GLTFWriter when GLTFExporter plugin TS
// declaration is ready
name: string;
variantNames: string[];
constructor(writer: any) {
this.writer = writer;
this.name = 'KHR_materials_variants';
this.variantNames = [];
}
beforeParse(objects: Object3D|Object3D[]) {
// Find all variant names and store them to the table
const variantNameSet = new Set<string>();
const addVariantNames = (o: Object3D) => {
if (!compatibleObject(o)) {
return;
}
const variantMaterials =
o.userData.variantMaterials as Map<number, UserDataVariantMapping>;
const variantDataMap = o.userData.variantData as Map<string, VariantData>;
for (const [variantName, variantData] of variantDataMap) {
const variantMaterial = variantMaterials.get(variantData.index);
// Ignore unloaded variant materials
if (variantMaterial && compatibleMaterial(variantMaterial.material)) {
variantNameSet.add(variantName);
}
}
};
if (Array.isArray(objects)) {
for (const object of objects) {
object.traverse(addVariantNames);
}
} else {
objects.traverse(addVariantNames);
}
// We may want to sort?
variantNameSet.forEach(name => this.variantNames.push(name));
}
async writeMesh(mesh: Mesh, meshDef: any) {
if (!compatibleObject(mesh)) {
return;
}
const userData = mesh.userData;
const variantMaterials =
userData.variantMaterials as Map<number, UserDataVariantMapping>;
const variantDataMap = userData.variantData as Map<string, VariantData>;
const mappingTable =
new Map<number, {material: number, variants: number[]}>();
// Removes gaps in the variant indices list (caused by deleting variants).
const reIndexedVariants = new Map<number, number>();
const variants = Array.from(variantDataMap.values()).sort((a, b) => {
return a.index - b.index;
});
for (const [i, variantData] of variants.entries()) {
reIndexedVariants.set(variantData.index, i);
}
for (const variantData of variantDataMap.values()) {
const variantInstance = variantMaterials.get(variantData.index);
if (!variantInstance || !compatibleMaterial(variantInstance.material)) {
continue;
}
const materialIndex =
await this.writer.processMaterialAsync(variantInstance.material);
if (!mappingTable.has(materialIndex)) {
mappingTable.set(
materialIndex, {material: materialIndex, variants: []});
}
mappingTable.get(materialIndex)!.variants.push(
reIndexedVariants.get(variantData.index)!);
}
const mappingsDef =
Array.from(mappingTable.values())
.map((m => {return m.variants.sort((a, b) => a - b) && m}))
.sort((a, b) => a.material - b.material);
if (mappingsDef.length === 0) {
return;
}
const originalMaterialIndex =
compatibleMaterial(userData.originalMaterial) ?
await this.writer.processMaterialAsync(userData.originalMaterial) :
-1;
for (const primitiveDef of meshDef.primitives) {
// Override primitiveDef.material with original material.
if (originalMaterialIndex >= 0) {
primitiveDef.material = originalMaterialIndex;
}
primitiveDef.extensions = primitiveDef.extensions || {};
primitiveDef.extensions[this.name] = {mappings: mappingsDef};
}
}
afterParse() {
if (this.variantNames.length === 0) {
return;
}
const root = this.writer.json;
root.extensions = root.extensions || {};
const variantsDef = this.variantNames.map(n => {
return {name: n};
});
root.extensions[this.name] = {variants: variantsDef};
this.writer.extensionsUsed[this.name] = true;
}
}
@@ -0,0 +1,150 @@
/* @license
* Copyright 2021 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.
*/
/**
* Materials variants extension
*
* Specification:
* https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_materials_variants
*/
/**
* The code in this file is based on
* https://github.com/takahirox/three-gltf-extensions/tree/main/loaders/KHR_materials_variants
*/
import {Material as ThreeMaterial, Mesh} from 'three';
import {GLTFReference} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {GLTF, GLTFLoaderPlugin, GLTFParser} from 'three/examples/jsm/loaders/GLTFLoader.js';
export interface UserDataVariantMapping {
material: ThreeMaterial|null;
gltfMaterialIndex: number;
}
/**
* KHR_materials_variants specification allows duplicated variant names
* but it makes handling the extension complex.
* We ensure tha names and make it easier.
* If you want to export the extension with the original names
* you are recommended to write GLTFExporter plugin to restore the names.
*
* @param variantNames {Array<string>}
* @return {Array<string>}
*/
const ensureUniqueNames = (variantNames: string[]) => {
const uniqueNames = [];
const knownNames = new Set<string>();
for (const name of variantNames) {
let uniqueName = name;
let suffix = 0;
// @TODO: An easy solution.
// O(N^2) in the worst scenario where N is variantNames.length.
// Fix me if needed.
while (knownNames.has(uniqueName)) {
uniqueName = name + '.' + (++suffix);
}
knownNames.add(uniqueName);
uniqueNames.push(uniqueName);
}
return uniqueNames;
};
/**
* Convert mappings array to table object to make handling the extension easier.
*
* @param
* extensionDef {glTF.meshes[n].primitive.extensions.KHR_materials_variants}
* @param variantNames {Array<string>} Required to be unique names
* @return {Map}
*/
const mappingsArrayToTable = (extensionDef:
any): Map<number, UserDataVariantMapping> => {
const table = new Map<number, UserDataVariantMapping>();
for (const mapping of extensionDef.mappings) {
for (const variant of mapping.variants) {
table.set(variant, {material: null, gltfMaterialIndex: mapping.material});
}
}
return table;
};
export default class GLTFMaterialsVariantsExtension implements
GLTFLoaderPlugin {
parser: GLTFParser;
name: string;
constructor(parser: GLTFParser) {
this.parser = parser;
this.name = 'KHR_materials_variants';
}
// Note that the following properties will be overridden even if they are
// pre-defined
// - gltf.userData.variants
// - mesh.userData.variantMaterials
afterRoot(gltf: GLTF) {
const parser = this.parser;
const json = parser.json;
if (json.extensions === undefined ||
json.extensions[this.name] === undefined) {
return null;
}
const extensionDef = json.extensions[this.name];
const variantsDef = extensionDef.variants || [];
const variants =
ensureUniqueNames(variantsDef.map((v: {name: string}) => v.name));
for (const scene of gltf.scenes) {
// Save the variants data under associated mesh.userData
scene.traverse(object => {
const mesh = object as Mesh;
if (!mesh.material) {
return;
}
const association = parser.associations.get(mesh) as GLTFReference &
{primitives: number};
if (association == null || association.meshes == null ||
association.primitives == null) {
return;
}
const meshDef = json.meshes[association.meshes];
const primitivesDef = meshDef.primitives;
const primitiveDef = primitivesDef[association.primitives];
const extensionsDef = primitiveDef.extensions;
if (!extensionsDef || !extensionsDef[this.name]) {
return;
}
mesh.userData.variantMaterials =
mappingsArrayToTable(extensionsDef[this.name]);
});
}
gltf.userData.variants = variants;
return Promise.resolve();
}
}
@@ -0,0 +1,254 @@
import {Group, Material, Mesh, Object3D, Texture} from 'three';
import {GLTF as ThreeGLTF, GLTFReference, GLTFReferenceType} from 'three/examples/jsm/loaders/GLTFLoader.js';
import {GLTF, GLTFElement} from '../../three-components/gltf-instance/gltf-2.0.js';
export type ThreeSceneObject = Object3D|Material|Texture;
type ThreeSceneObjectCallback = (a: ThreeSceneObject, b: ThreeSceneObject) =>
void;
export type ThreeObjectSet = Set<ThreeSceneObject>;
export type GLTFElementToThreeObjectMap = Map<GLTFElement, ThreeObjectSet>;
export type ThreeObjectToGLTFElementHandleMap =
Map<ThreeSceneObject, GLTFReference>;
const $threeGLTF = Symbol('threeGLTF');
const $gltf = Symbol('gltf');
const $gltfElementMap = Symbol('gltfElementMap');
const $threeObjectMap = Symbol('threeObjectMap');
const $parallelTraverseThreeScene = Symbol('parallelTraverseThreeScene');
const $correlateOriginalThreeGLTF = Symbol('correlateOriginalThreeGLTF');
const $correlateCloneThreeGLTF = Symbol('correlateCloneThreeGLTF');
/**
* The Three.js GLTFLoader provides us with an in-memory representation
* of a glTF in terms of Three.js constructs. It also provides us with a copy
* of the deserialized glTF without any Three.js decoration, and a mapping of
* glTF elements to their corresponding Three.js constructs.
*
* A CorrelatedSceneGraph exposes a synchronously available mapping of glTF
* element references to their corresponding Three.js constructs.
*/
export class CorrelatedSceneGraph {
/**
* Produce a CorrelatedSceneGraph from a naturally generated Three.js GLTF.
* Such GLTFs are produced by Three.js' GLTFLoader, and contain cached
* details that expedite the correlation step.
*
* If a CorrelatedSceneGraph is provided as the second argument, re-correlates
* a cloned Three.js GLTF with a clone of the glTF hierarchy used to produce
* the upstream Three.js GLTF that the clone was created from. The result
* CorrelatedSceneGraph is representative of the cloned hierarchy.
*/
static from(
threeGLTF: ThreeGLTF,
upstreamCorrelatedSceneGraph?: CorrelatedSceneGraph):
CorrelatedSceneGraph {
if (upstreamCorrelatedSceneGraph != null) {
return this[$correlateCloneThreeGLTF](
threeGLTF, upstreamCorrelatedSceneGraph);
} else {
return this[$correlateOriginalThreeGLTF](threeGLTF);
}
}
private static[$correlateOriginalThreeGLTF](threeGLTF: ThreeGLTF):
CorrelatedSceneGraph {
const gltf = threeGLTF.parser.json as GLTF;
const associations =
threeGLTF.parser.associations as Map<ThreeSceneObject, GLTFReference>;
const gltfElementMap: GLTFElementToThreeObjectMap = new Map();
const defaultMaterial = {name: 'Default'} as Material;
const defaultReference = {type: 'materials', index: -1};
for (const threeMaterial of associations.keys()) {
// Note: GLTFLoader creates a "default" material that has no
// corresponding glTF element in the case that no materials are
// specified in the source glTF. In this case we append a default
// material to allow this to be operated upon.
if (threeMaterial instanceof Material &&
associations.get(threeMaterial) == null) {
if (defaultReference.index < 0) {
if (gltf.materials == null) {
gltf.materials = [];
}
defaultReference.index = gltf.materials.length;
gltf.materials.push(defaultMaterial);
}
threeMaterial.name = defaultMaterial.name;
associations.set(threeMaterial, {materials: defaultReference.index});
}
}
// Creates a reverse look up map (gltf-object to Three-object)
for (const [threeObject, gltfMappings] of associations) {
if (gltfMappings) {
threeObject.userData = threeObject.userData || {};
threeObject.userData.associations = gltfMappings;
}
for (const mapping in gltfMappings) {
if (mapping != null && mapping !== 'primitives') {
const type = mapping as GLTFReferenceType;
const elementArray = gltf[type] || [];
const gltfElement = elementArray[gltfMappings[type]!];
if (gltfElement == null) {
// TODO: Maybe throw here...
continue;
}
let threeObjects = gltfElementMap.get(gltfElement);
if (threeObjects == null) {
threeObjects = new Set();
gltfElementMap.set(gltfElement, threeObjects);
}
threeObjects.add(threeObject);
}
}
}
return new CorrelatedSceneGraph(
threeGLTF, gltf, associations, gltfElementMap);
}
/**
* Transfers the association between a raw glTF and a Three.js scene graph
* to a clone of the Three.js scene graph, resolved as a new
* CorrelatedSceneGraph instance.
*/
private static[$correlateCloneThreeGLTF](
cloneThreeGLTF: ThreeGLTF,
upstreamCorrelatedSceneGraph: CorrelatedSceneGraph):
CorrelatedSceneGraph {
const originalThreeGLTF = upstreamCorrelatedSceneGraph.threeGLTF;
const originalGLTF = upstreamCorrelatedSceneGraph.gltf;
const cloneGLTF: GLTF = JSON.parse(JSON.stringify(originalGLTF));
const cloneThreeObjectMap: ThreeObjectToGLTFElementHandleMap = new Map();
const cloneGLTFElementMap: GLTFElementToThreeObjectMap = new Map();
for (let i = 0; i < originalThreeGLTF.scenes.length; i++) {
this[$parallelTraverseThreeScene](
originalThreeGLTF.scenes[i],
cloneThreeGLTF.scenes[i],
(object: ThreeSceneObject, cloneObject: ThreeSceneObject) => {
const elementReference =
upstreamCorrelatedSceneGraph.threeObjectMap.get(object);
if (elementReference == null) {
return;
}
for (const mapping in elementReference) {
if (mapping != null && mapping !== 'primitives') {
const type = mapping as GLTFReferenceType;
const index = elementReference[type]!;
const cloneElement = cloneGLTF[type]![index];
const mappings =
cloneThreeObjectMap.get(cloneObject) || {} as GLTFReference;
mappings[type] = index;
cloneThreeObjectMap.set(cloneObject, mappings);
const cloneObjects: Set<typeof cloneObject> =
cloneGLTFElementMap.get(cloneElement) || new Set();
cloneObjects.add(cloneObject);
cloneGLTFElementMap.set(cloneElement, cloneObjects);
}
}
});
}
return new CorrelatedSceneGraph(
cloneThreeGLTF, cloneGLTF, cloneThreeObjectMap, cloneGLTFElementMap);
}
/**
* Traverses two presumably identical Three.js scenes, and invokes a
* callback for each Object3D or Material encountered, including the initial
* scene. Adapted from
* https://github.com/mrdoob/three.js/blob/7c1424c5819ab622a346dd630ee4e6431388021e/examples/jsm/utils/SkeletonUtils.js#L586-L596
*/
private static[$parallelTraverseThreeScene](
sceneOne: Group, sceneTwo: Group, callback: ThreeSceneObjectCallback) {
const traverse = (a: Object3D, b: Object3D) => {
callback(a, b);
if (a.isObject3D) {
const meshA = a as Mesh;
const meshB = b as Mesh;
if (meshA.material) {
if (Array.isArray(meshA.material)) {
for (let i = 0; i < meshA.material.length; ++i) {
callback(meshA.material[i], (meshB.material as Material[])[i]);
}
} else {
callback(meshA.material, meshB.material as Material);
}
}
for (let i = 0; i < a.children.length; ++i) {
traverse(a.children[i], b.children[i]);
}
}
};
traverse(sceneOne, sceneTwo);
}
private[$threeGLTF]: ThreeGLTF;
private[$gltf]: GLTF;
private[$gltfElementMap]: GLTFElementToThreeObjectMap;
private[$threeObjectMap]: ThreeObjectToGLTFElementHandleMap;
/**
* The source Three.js GLTF result given to us by a Three.js GLTFLoader.
*/
get threeGLTF(): ThreeGLTF {
return this[$threeGLTF];
}
/**
* The in-memory deserialized source glTF.
*/
get gltf(): GLTF {
return this[$gltf];
}
/**
* A Map of glTF element references to arrays of corresponding Three.js
* object references. Three.js objects are kept in arrays to account for
* cases where more than one Three.js object corresponds to a single glTF
* element.
*/
get gltfElementMap(): GLTFElementToThreeObjectMap {
return this[$gltfElementMap];
}
/**
* A map of individual Three.js objects to corresponding elements in the
* source glTF.
*/
get threeObjectMap(): ThreeObjectToGLTFElementHandleMap {
return this[$threeObjectMap];
}
constructor(
threeGLTF: ThreeGLTF, gltf: GLTF,
threeObjectMap: ThreeObjectToGLTFElementHandleMap,
gltfElementMap: GLTFElementToThreeObjectMap) {
this[$threeGLTF] = threeGLTF;
this[$gltf] = gltf;
this[$gltfElementMap] = gltfElementMap;
this[$threeObjectMap] = threeObjectMap;
}
}
@@ -0,0 +1,329 @@
export type RGB = [number, number, number];
export type RGBA = [number, number, number, number];
export type Quaternion = [number, number, number, number];
export type Vector3 = [number, number, number];
export type Matrix4 = [
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
number,
];
export type Extras = any;
// TODO: Document known extensions and update this type
export interface ExtensionDictionary {
[index: string]: any;
}
export type VariantMapping = {
material: number; variants: Array<number>;
}
export type VariantMappings = Array<VariantMapping>;
export type Variants = Array<{name: string}>;
export interface PerspectiveCameraIntrinsics {
aspectRatio?: number;
yfov: number;
zfar: number;
znear: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface OrthographicCameraIntrinsics {
xmag: number;
ymag: number;
zfar: number;
znear: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export type CameraType = 'perspective'|'orthographic';
export interface PerspectiveCamera {
name?: string;
type: 'perspective';
perspective?: PerspectiveCameraIntrinsics;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface OrthographicCamera {
name?: string;
type: 'orthographic';
orthographic?: OrthographicCamera;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export type Camera = PerspectiveCamera|OrthographicCamera;
export enum Filter {
Nearest = 9728,
Linear = 9729,
NearestMipmapNearest = 9984,
LinearMipmapNearest = 9985,
NearestMipmapLinear = 9986,
LinearMipmapLinear = 9987,
}
export type NearestFilter = Filter.Nearest;
export type LinearFilter = Filter.Linear;
export type NearestMipmapNearestFilter = Filter.NearestMipmapNearest;
export type LinearMipmapNearestFilter = Filter.LinearMipmapNearest;
export type NearestMipmapLinearFilter = Filter.NearestMipmapLinear;
export type LinearMipmapLinearFilter = Filter.LinearMipmapLinear;
export type MagFilter = NearestFilter|LinearFilter;
export type MinFilter = NearestFilter|LinearFilter|NearestMipmapNearestFilter|
LinearMipmapNearestFilter|NearestMipmapLinearFilter|
LinearMipmapLinearFilter;
export enum Wrap {
ClampToEdge = 33071,
MirroredRepeat = 33648,
Repeat = 10497,
}
export type ClampToEdgeWrap = Wrap.ClampToEdge;
export type MirroredRepeatWrap = Wrap.MirroredRepeat;
export type RepeatWrap = Wrap.Repeat;
export type WrapMode = RepeatWrap|ClampToEdgeWrap|MirroredRepeatWrap;
export interface Sampler {
name?: string;
magFilter?: MagFilter;
minFilter?: MinFilter;
wrapS?: WrapMode;
wrapT?: WrapMode;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Texture {
name?: string;
sampler?: number;
source?: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface TextureInfo {
index: number;
texCoord?: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface OcclusionTextureInfo extends TextureInfo {
strength?: number;
}
export interface NormalTextureInfo extends TextureInfo {
scale?: number;
}
export interface PBRMetallicRoughness {
baseColorFactor?: RGBA;
baseColorTexture?: TextureInfo;
metallicRoughnessTexture?: TextureInfo;
metallicFactor?: number;
roughnessFactor?: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export type AlphaMode = 'OPAQUE'|'MASK'|'BLEND';
export interface Material {
name?: string;
doubleSided?: boolean;
alphaMode?: AlphaMode;
alphaCutoff?: number;
emissiveFactor?: RGB;
pbrMetallicRoughness?: PBRMetallicRoughness;
normalTexture?: NormalTextureInfo;
occlusionTexture?: OcclusionTextureInfo;
emissiveTexture?: TextureInfo;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface KHRMaterialsVariants {
mappings: VariantMapping[];
}
export type AttributeDictionary = {
[index: string]: number;
};
export interface Primitive {
attributes: AttributeDictionary;
indices?: number;
material?: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Mesh {
name?: string;
primitives: Primitive[];
weights: number[];
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Node {
name?: string;
mesh?: number;
matrix?: Matrix4;
rotation?: Quaternion;
scale?: Vector3;
translation?: Vector3;
weights?: number[];
children?: number[];
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Image {
name?: string;
uri?: string;
bufferView?: number;
mimeType?: string;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Scene {
name?: string;
nodes: number[];
extensions?: ExtensionDictionary;
extras?: Extras;
}
export type AccessorType = 'SCALAR'|'VEC2'|'VEC3'|'VEC4'|'MAT2'|'MAT3'|'MAT4';
export interface Accessor {
name?: string;
bufferView?: number;
byteOffset?: number;
componentType: number;
normalized?: boolean;
count: number;
type: AccessorType;
max?: number;
min?: number;
extensions?: ExtensionDictionary;
extras?: Extras;
// TODO: What is this?
// @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#accessorsparse
sparse?: any;
}
export type ChannelTargetPath = 'rotation'|'scale'|'translation'|'weights';
export interface ChannelTarget {
node: number;
path: ChannelTargetPath;
}
export interface Channel {
sampler: number;
target: ChannelTarget;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export type AnimationInterpolation = 'LINEAR'|'STEP'|'CUBICSPLINE';
export interface AnimationSampler {
input: number;
interpolation: AnimationInterpolation;
output: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Animation {
name?: string;
channels: Channel[];
samplers: AnimationSampler[];
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Skin {
inverseBindMatrices?: number;
skeleton?: number;
joints: number[];
name?: string;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export type GLTFElement =
Scene|Node|Mesh|Material|Image|Texture|TextureInfo|Sampler|
PBRMetallicRoughness|Accessor|Camera|Animation|AnimationSampler|Skin;
export interface GLTFElementMap {
'scene': Scene;
'node': Node;
'mesh': Mesh;
'material': Material;
'image': Image;
'texture': Texture;
'texture-info': TextureInfo;
'sampler': Sampler;
'accessor': Accessor;
'camera': Camera;
'animation': Animation;
'animation-sampler': AnimationSampler;
'skin': Skin;
}
export interface Asset {
version: '2.0';
copyright?: string;
generator?: string;
minVersion?: string;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface GLTF {
asset: Asset;
scene?: number;
scenes?: Scene[];
nodes?: Node[];
materials?: Material[];
accessors?: Accessor[];
samplers?: Sampler[];
images?: Image[];
textures?: Texture[];
meshes?: Mesh[];
cameras?: Camera[];
animations?: Animation[];
skins?: Skin[];
}
@@ -0,0 +1,102 @@
import {Vector2} from 'three';
import {Accessor, AlphaMode, AnimationSampler, Asset, Camera, ExtensionDictionary, Extras, MagFilter, Mesh, MinFilter, RGB, RGBA, Scene, WrapMode} from './gltf-2.0.js';
export interface Sampler {
name?: string;
magFilter: MagFilter;
minFilter: MinFilter;
wrapS: WrapMode;
wrapT: WrapMode;
extensions?: ExtensionDictionary;
extras?: Extras;
rotation: number;
repeat: Vector2;
offset: Vector2;
}
export interface Texture {
name?: string;
sampler: number;
source: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface TextureInfo {
index: number;
texCoord?: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface OcclusionTextureInfo extends TextureInfo {
strength?: number;
}
export interface NormalTextureInfo extends TextureInfo {
scale?: number;
}
export interface PBRMetallicRoughness {
baseColorFactor: RGBA;
baseColorTexture?: TextureInfo;
metallicRoughnessTexture?: TextureInfo;
metallicFactor: number;
roughnessFactor: number;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Material {
name?: string;
doubleSided: boolean;
alphaMode: AlphaMode;
alphaCutoff: number;
emissiveFactor: RGB;
pbrMetallicRoughness: PBRMetallicRoughness;
normalTexture?: NormalTextureInfo;
occlusionTexture?: OcclusionTextureInfo;
emissiveTexture?: TextureInfo;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Image {
name?: string;
uri?: string;
bufferView?: number;
mimeType?: string;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export interface Skin {
inverseBindMatrices?: number;
skeleton?: number;
joints: number[];
name?: string;
extensions?: ExtensionDictionary;
extras?: Extras;
}
export type GLTFElement =
Scene|Node|Mesh|Material|Image|Texture|TextureInfo|Sampler|
PBRMetallicRoughness|Accessor|Camera|Animation|AnimationSampler|Skin;
export interface GLTF {
asset: Asset;
scene: number;
scenes: Scene[];
nodes?: Node[];
materials: Material[];
accessors?: Accessor[];
samplers?: Sampler[];
images?: Image[];
textures?: Texture[];
meshes?: Mesh[];
cameras?: Camera[];
animations?: Animation[];
skins?: Skin[];
}
@@ -0,0 +1,208 @@
/* @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 {GLTF, GLTFElement, GLTFElementMap} from '../../three-components/gltf-instance/gltf-2.0.js';
/**
* A VisitorCallback is used to access an element of a GLTF scene graph. The
* callback receives a reference to the element, the element's index relative
* to other elements of its type (as it would appear in the top-level element
* array in the corresponding GLTF) and a hierarchy array that is the list of
* ancestor elements (inclusive of the current element) that preceded the
* currently accessed element in the GLTF scene graph hierarchy.
*/
export type VisitorCallback<T extends GLTFElement> =
(element: T, index: number, hierarchy: GLTFElement[]) => void;
/**
* There is a corresponding VisitorCallback type for every type of GLTFElement.
*/
export type VisitorCallbacks = {
[T in keyof GLTFElementMap]?: VisitorCallback<GLTFElementMap[T]>;
}
/**
* Supported options for configuring a GLTFTreeVisitor include:
* - allScenes: if true, all scenes (not just the active one) will be visited
* - sparse: if true, elements that have been visited once will not be visited
* again if and when they are encountered a second time in the scene graph
*
* Note that glTF defines a top-level field ("scene") that refers to the scene
* that will be presented first ("at load time"). In the case of <model-viewer>,
* there is no way to specify an alternative scene to display (although we plan
* to add configuration for this). Consequently, the ability to traverse all
* scenes is not likely to be used at this time. However, some cases will call
* for visiting all nodes in a glTF regardless of whether they are a part of
* the current scene. Eventually, <model-viewer> will support changing the
* active scene, and the ability to traverse all scenes at once will become
* handy.
*
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#scenes
* @see https://github.com/google/model-viewer/issues/195
*/
export interface VisitOptions {
allScenes?: boolean;
sparse?: boolean;
}
interface VisitorTraversalState {
hierarchy: GLTFElement[];
visited: Set<GLTFElement>;
sparse: boolean;
gltf: GLTF;
}
const $callbacks = Symbol('callbacks');
const $visitMesh = Symbol('visitMesh');
const $visitElement = Symbol('visitElement');
const $visitNode = Symbol('visitNode');
const $visitScene = Symbol('visitScene');
const $visitMaterial = Symbol('visitMaterial');
/**
* GLTFTreeVisitor implements a deterministic traversal order of a valid,
* deserialized glTF 2.0 model. It supports selective element visitation via
* callbacks configured based on element type. For example, to visit all
* materials in all scenes in a glTF:
*
* ```javascript
* const visitor = new GLTFTreeVisitor({
* material: (material, index, hierarchy) => {
* // material is a glTF 2.0 Material
* // index is the index of material in gltf.materials
* // hierarchy includes ancestors of material in the glTF
* }
* });
*
* visitor.visit(someInMemoryGLTF, { allScenes: true });
* ```
*
* The traversal order of the visitor is pre-order, depth-first.
*
* Note that the traversal order is not guaranteed to correspond to the
* index of a given element in any way. Rather, traversal order is based
* on the hierarchical order of the scene graph.
*/
export class GLTFTreeVisitor {
private[$callbacks]: VisitorCallbacks;
constructor(callbacks: VisitorCallbacks) {
this[$callbacks] = callbacks;
}
/**
* Visit a given in-memory glTF via the configured callbacks of this visitor.
* Optionally, all scenes may be visited (as opposed to just the active one).
* Sparse traversal can also be specified, in which case elements that
* re-appear multiple times in the scene graph will only be visited once.
*/
visit(gltf: GLTF, options: VisitOptions = {}): void {
const allScenes = !!options.allScenes;
const sparse = !!options.sparse;
const scenes = allScenes ?
gltf.scenes || [] :
(gltf.scenes && gltf.scene != null) ? [gltf.scenes[gltf.scene]] : [];
const state: VisitorTraversalState =
{hierarchy: [], visited: new Set(), sparse, gltf};
for (const scene of scenes) {
this[$visitScene](gltf.scenes!.indexOf(scene), state);
}
}
private[$visitElement]<T extends GLTFElement>(
index: number, elementList: T[]|undefined, state: VisitorTraversalState,
visit?: VisitorCallback<T>, traverse?: (element: T) => void) {
if (elementList == null) {
return;
}
const element = elementList[index];
const {sparse, hierarchy, visited} = state;
if (element == null) {
return;
}
if (sparse && visited.has(element)) {
return;
}
visited.add(element);
hierarchy.push(element);
if (visit != null) {
visit(element, index, hierarchy);
}
if (traverse != null) {
traverse(element);
}
hierarchy.pop();
}
private[$visitScene](index: number, state: VisitorTraversalState) {
const {gltf} = state;
const {scene: visit} = this[$callbacks];
this[$visitElement](index, gltf.scenes, state, visit, (scene) => {
// A scene is not required to have a list of nodes
if (scene.nodes == null) {
return;
}
for (const nodeIndex of scene.nodes) {
this[$visitNode](nodeIndex, state);
}
});
}
private[$visitNode](index: number, state: VisitorTraversalState) {
const {gltf} = state;
const {node: visit} = this[$callbacks];
this[$visitElement](index, gltf.nodes, state, visit, (node) => {
if (node.mesh != null) {
this[$visitMesh](node.mesh, state);
}
if (node.children != null) {
for (const childNodeIndex of node.children) {
this[$visitNode](childNodeIndex, state);
}
}
});
}
private[$visitMesh](index: number, state: VisitorTraversalState) {
const {gltf} = state;
const {mesh: visit} = this[$callbacks];
this[$visitElement](index, gltf.meshes, state, visit, (mesh) => {
for (const primitive of mesh.primitives) {
if (primitive.material != null) {
this[$visitMaterial](primitive.material, state);
}
}
});
}
private[$visitMaterial](index: number, state: VisitorTraversalState) {
const {gltf} = state;
const {material: visit} = this[$callbacks];
this[$visitElement](index, gltf.materials, state, visit);
}
}
+166
View File
@@ -0,0 +1,166 @@
/* @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 {HAS_WEBXR_DEVICE_API, HAS_WEBXR_HIT_TEST_API, IS_WEBXR_AR_CANDIDATE} from './constants.js';
export type Constructor<T = object, U = object> = {
new (...args: any[]): T,
prototype: T
}&U;
export const deserializeUrl = (url: string|null): string|null =>
(!!url && url !== 'null') ? toFullUrl(url) : null;
export const assertIsArCandidate = () => {
if (IS_WEBXR_AR_CANDIDATE) {
return;
}
const missingApis = [];
if (!HAS_WEBXR_DEVICE_API) {
missingApis.push('WebXR Device API');
}
if (!HAS_WEBXR_HIT_TEST_API) {
missingApis.push('WebXR Hit Test API');
}
throw new Error(
`The following APIs are required for AR, but are missing in this browser: ${
missingApis.join(', ')}`);
};
/**
* 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();
};
/**
* Returns a throttled version of a given function that is only invoked at most
* once within a given threshold of time in milliseconds.
*
* The throttled version of the function has a "flush" property that resets the
* threshold for cases when immediate invocation is desired.
*/
export const throttle = (fn: (...args: Array<any>) => any, ms: number) => {
let timer: number|null = null;
const throttled = (...args: Array<any>) => {
if (timer != null) {
return;
}
fn(...args);
timer = self.setTimeout(() => timer = null, ms);
};
throttled.flush = () => {
if (timer != null) {
self.clearTimeout(timer);
timer = null;
}
};
return throttled;
};
export const debounce = (fn: (...args: Array<any>) => any, ms: number) => {
let timer: number|null = null;
return (...args: Array<any>) => {
if (timer != null) {
self.clearTimeout(timer);
}
timer = self.setTimeout(() => {
timer = null;
fn(...args);
}, ms);
};
};
/**
* @param {Number} edge
* @param {Number} value
* @return {Number} 0 if value is less than edge, otherwise 1
*/
export const step = (edge: number, value: number): number => {
return value < edge ? 0 : 1;
};
/**
* @param {Number} value
* @param {Number} lowerLimit
* @param {Number} upperLimit
* @return {Number} value clamped within lowerLimit..upperLimit
*/
export const clamp =
(value: number, lowerLimit: number, upperLimit: number): number =>
Math.max(lowerLimit, Math.min(upperLimit, value));
/**
* Debug mode is enabled when one of the two following conditions is true:
*
* 1. A 'model-viewer-debug-mode' query parameter is present in the current
* search string
* 2. There is a global object ModelViewerElement with a debugMode property set
* to true
*/
export const isDebugMode = (() => {
const debugQueryParameterName = 'model-viewer-debug-mode';
const debugQueryParameter = new RegExp(`[?&]${debugQueryParameterName}(&|$)`);
return () => ((self as any).ModelViewerElement &&
(self as any).ModelViewerElement.debugMode) ||
(self.location && self.location.search &&
self.location.search.match(debugQueryParameter));
})();
export type PredicateFunction<T = void> = (value: T) => boolean;
export const timePasses = (ms: number = 0): Promise<void> =>
new Promise(resolve => setTimeout(resolve, ms));
/**
* @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);
});
@@ -0,0 +1,98 @@
/* @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 {clamp} from '../utilities.js';
// Adapted from https://gist.github.com/gre/1650294
export const easeInOutQuad: TimingFunction = (t: number) =>
t < .5 ? 2 * t * t : -1 + (4 - 2 * t) * t;
/**
* A TimingFunction accepts a value from 0-1 and returns a corresponding
* interpolated value
*/
export type TimingFunction = (time: number) => number;
/**
* Creates a TimingFunction that uses a given ease to interpolate between
* two configured number values.
*/
export const interpolate =
(start: number, end: number, ease: TimingFunction = easeInOutQuad):
TimingFunction => (time: number) => start + (end - start) * ease(time);
/**
* Creates a TimingFunction that interpolates through a weighted list
* of other TimingFunctions ("tracks"). Tracks are interpolated in order, and
* allocated a percentage of the total time based on their relative weight.
*/
export const sequence =
(tracks: Array<TimingFunction>, weights: Array<number>): TimingFunction => {
const cumulativeSum = ((sum: number) => (value: number) => sum += value);
const times = weights.map(cumulativeSum(0));
return (time: number) => {
time = clamp(time, 0, 1);
time *= times[times.length - 1];
const i = times.findIndex((val) => val >= time);
const start = i < 1 ? 0 : times[i - 1];
const end = times[i];
return tracks[i]((time - start) / (end - start));
}
};
/**
* A Frame groups a target value, the number of frames to interpolate towards
* that value and an optional easing function to use for interpolation.
*/
export interface Frame {
value: number;
frames: number;
ease?: TimingFunction;
}
export interface Path {
initialValue: number;
keyframes: Frame[];
}
/**
* Creates a "timeline" TimingFunction out of an initial value and a series of
* Keyframes. The timeline function accepts value from 0-1 and returns the
* current value based on keyframe interpolation across the total number of
* frames. Frames are only used to indicate the relative length of each keyframe
* transition, so interpolated values will be computed for fractional frames.
*/
export const timeline = (path: Path): TimingFunction => {
const tracks: Array<TimingFunction> = [];
const weights: Array<number> = [];
let lastValue = path.initialValue;
for (let i = 0; i < path.keyframes.length; ++i) {
const keyframe = path.keyframes[i];
const {value, frames} = keyframe;
const ease = keyframe.ease || easeInOutQuad;
const track = interpolate(lastValue, value, ease);
tracks.push(track);
weights.push(frames);
lastValue = value;
}
return sequence(tracks, weights);
};
@@ -0,0 +1,139 @@
/* @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.
*/
/**
* A mutable cache is any object that has that allows cache
* items to be deleted imperatively given their key
*/
export interface MutableCache<T> {
delete(key: T): void;
}
const $retainerCount = Symbol('retainerCount');
const $recentlyUsed = Symbol('recentlyUsed');
const $evict = Symbol('evict');
const $evictionThreshold = Symbol('evictionThreshold');
const $cache = Symbol('cache');
/**
* The CacheEvictionPolicy manages the lifecycle for items in a cache,
* evicting any items outside some threshold bounds in "recently used" order,
* if they are evictable.
*
* Items are considered cached as they are retained. When all retainers
* of an item release it, that item is considered evictable.
*/
export class CacheEvictionPolicy<T = string> {
private[$retainerCount] = new Map<T, number>();
private[$recentlyUsed]: Array<T> = [];
private[$evictionThreshold]: number;
private[$cache]: MutableCache<T>;
constructor(cache: MutableCache<T>, evictionThreshold: number = 5) {
this[$cache] = cache;
this[$evictionThreshold] = evictionThreshold;
}
/**
* The eviction threshold is the maximum number of items to hold
* in cache indefinitely. Items within the threshold (in recently
* used order) will continue to be cached even if they have zero
* retainers.
*/
set evictionThreshold(value: number) {
this[$evictionThreshold] = value;
this[$evict]();
}
get evictionThreshold(): number {
return this[$evictionThreshold];
}
/**
* A reference to the cache that operates under this policy
*/
get cache(): MutableCache<T> {
return this[$cache];
}
/**
* Given an item key, returns the number of retainers of that item
*/
retainerCount(key: T): number {
return this[$retainerCount].get(key) || 0;
}
/**
* Resets the internal tracking of cache item retainers. Use only in cases
* where it is certain that all retained cache items have been accounted for!
*/
reset() {
this[$retainerCount].clear();
this[$recentlyUsed] = [];
}
/**
* Mark a given cache item as retained, where the item is represented
* by its key. An item can have any number of retainers.
*/
retain(key: T) {
if (!this[$retainerCount].has(key)) {
this[$retainerCount].set(key, 0);
}
this[$retainerCount].set(key, this[$retainerCount].get(key)! + 1);
const recentlyUsedIndex = this[$recentlyUsed].indexOf(key);
if (recentlyUsedIndex !== -1) {
this[$recentlyUsed].splice(recentlyUsedIndex, 1);
}
this[$recentlyUsed].unshift(key);
// Evict, in case retaining a new item pushed an evictable item beyond the
// eviction threshold
this[$evict]();
}
/**
* Mark a given cache item as released by one of its retainers, where the item
* is represented by its key. When all retainers of an item have released it,
* the item is considered evictable.
*/
release(key: T) {
if (this[$retainerCount].has(key)) {
this[$retainerCount].set(
key, Math.max(this[$retainerCount].get(key)! - 1, 0));
}
this[$evict]();
}
[$evict]() {
if (this[$recentlyUsed].length < this[$evictionThreshold]) {
return;
}
for (let i = this[$recentlyUsed].length - 1; i >= this[$evictionThreshold];
--i) {
const key = this[$recentlyUsed][i];
const retainerCount = this[$retainerCount].get(key);
if (retainerCount === 0) {
this[$cache].delete(key);
this[$recentlyUsed].splice(i, 1);
}
}
}
}
@@ -0,0 +1,49 @@
/* @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 {WebGLRenderTarget} from 'three';
import {Renderer} from '../three-components/Renderer.js';
/**
* Debug method to save an offscreen render target to an image; filename should
* have a .png extension to ensure lossless transmission.
*/
export const saveTarget = (target: WebGLRenderTarget, filename: string) => {
const {width, height} = target;
const output = document.createElement('canvas');
output.width = width;
output.height = height;
const ctx = output.getContext('2d')!;
const img = ctx.getImageData(0, 0, width, height);
Renderer.singleton.threeRenderer.readRenderTargetPixels(
target, 0, 0, width, height, img.data);
ctx.putImageData(img, 0, 0);
output.toBlob(function(blob) {
if (blob == null) {
return;
}
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
});
}
@@ -0,0 +1,151 @@
/* @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 {clamp} from '../utilities.js';
interface OngoingActivity {
progress: number;
completed: boolean;
}
/**
* An Activity is represented by a callback that accepts values from 0 to 1,
* where 1 represents the completion of the activity. The callback returns the
* actual progress as it is stored by the ProgressTracker (which may be clamped,
* and can never be lower than its previous value).
*/
export type Activity = (progress: number) => number;
/**
* A progress event contains the total progress of all ongoing activities in the
* ProgressTracker. The total progress is a heuristic, but has some useful
* properties: for a single activity, it equals the input progress; for multiple
* activities that progress in lockstep, it will also equal each input progress.
* When more activities overlap as time goes on, total progress will tend to
* decelerate.
*/
export interface ProgressDetails {
totalProgress: number;
reason: string;
}
/**
* ProgressTracker is an event emitter that helps to track the ongoing progress
* of many simultaneous actions.
*
* ProgressTracker reports progress activity in the form of a progress event.
* The event.detail.totalProgress value indicates the elapsed progress of all
* activities being tracked by the ProgressTracker.
*
* The value of totalProgress is a number that progresses from 0 to 1. The
* ProgressTracker allows for the lazy accumulation of tracked actions, so the
* total progress represents an abstract, non-absolute progress towards the
* completion of all currently tracked events.
*
* When all currently tracked activities are finished, the ProgressTracker
* emits one final progress event and then resets the list of its currently
* tracked activities. This means that from an observer's perspective,
* ongoing activities will accumulate and collectively contribute to the notion
* of total progress until all currently tracked ongoing activities have
* completed.
*/
export class ProgressTracker extends EventTarget {
private ongoingActivities: Set<OngoingActivity> = new Set();
private totalProgress = 0;
/**
* The total number of activities currently being tracked.
*/
get ongoingActivityCount(): number {
return this.ongoingActivities.size;
}
/**
* Registers a new activity to be tracked by the progress tracker. The method
* returns a special callback that should be invoked whenever new progress is
* ready to be reported. The progress should be reported as a value between 0
* and 1, where 0 would represent the beginning of the action and 1 would
* represent its completion.
*
* There is no built-in notion of a time-out for ongoing activities, so once
* an ongoing activity is begun, it is up to the consumer of this API to
* update the progress until that activity is no longer ongoing.
*
* Progress is only allowed to move forward for any given activity. If a lower
* progress is reported than the previously reported progress, it will be
* ignored.
*/
beginActivity(reason: string): Activity {
const activity: OngoingActivity = {progress: 0, completed: false};
this.ongoingActivities.add(activity);
if (this.ongoingActivityCount === 1) {
// Announce the first progress event (which should always be 0 / 1
// total progress):
this.announceTotalProgress(activity, 0, reason);
}
return (progress: number): number => {
let nextProgress: number;
nextProgress = Math.max(clamp(progress, 0, 1), activity.progress);
if (nextProgress !== activity.progress) {
this.announceTotalProgress(activity, nextProgress, reason);
}
return activity.progress;
};
}
private announceTotalProgress(
updatedActivity: OngoingActivity, nextProgress: number, reason: string) {
let progressLeft = 0;
let completedActivities = 0;
if (nextProgress == 1.0)
updatedActivity.completed = true;
for (const activity of this.ongoingActivities) {
const {progress} = activity;
progressLeft += 1.0 - progress;
if (activity.completed) {
completedActivities++;
}
}
const lastProgress = updatedActivity.progress;
updatedActivity.progress = nextProgress;
// Advance the total progress by the fraction of total remaining progress
// due to this activity.
this.totalProgress += (nextProgress - lastProgress) *
(1.0 - this.totalProgress) / progressLeft;
const totalProgress = completedActivities === this.ongoingActivityCount ?
1.0 :
this.totalProgress;
this.dispatchEvent(new CustomEvent<ProgressDetails>(
'progress', {detail: {totalProgress, reason }}));
if (completedActivities === this.ongoingActivityCount) {
this.totalProgress = 0.0;
this.ongoingActivities.clear();
}
}
}