squash commits
This commit is contained in:
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>`;
|
||||
@@ -0,0 +1,103 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// NOTE(cdata): The HAS_WEBXR_* constants can be enabled in Chrome by turning on
|
||||
// the appropriate flags. However, just because we have the API does not
|
||||
// guarantee that AR will work.
|
||||
export const HAS_WEBXR_DEVICE_API = navigator.xr != null &&
|
||||
(self as any).XRSession != null && navigator.xr.isSessionSupported != null;
|
||||
|
||||
export const HAS_WEBXR_HIT_TEST_API = HAS_WEBXR_DEVICE_API &&
|
||||
(self as any).XRSession.prototype.requestHitTestSource != null;
|
||||
|
||||
export const HAS_RESIZE_OBSERVER = self.ResizeObserver != null;
|
||||
|
||||
export const HAS_INTERSECTION_OBSERVER = self.IntersectionObserver != null;
|
||||
|
||||
export const IS_WEBXR_AR_CANDIDATE = HAS_WEBXR_HIT_TEST_API;
|
||||
|
||||
export const IS_MOBILE = (() => {
|
||||
const userAgent =
|
||||
navigator.userAgent || navigator.vendor || (self as any).opera;
|
||||
let check = false;
|
||||
// eslint-disable-next-line
|
||||
if (/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i
|
||||
.test(userAgent) ||
|
||||
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i
|
||||
.test(userAgent.substr(0, 4))) {
|
||||
check = true;
|
||||
}
|
||||
return check;
|
||||
})();
|
||||
|
||||
export const IS_CHROMEOS = /\bCrOS\b/.test(navigator.userAgent);
|
||||
|
||||
export const IS_ANDROID = /android/i.test(navigator.userAgent);
|
||||
|
||||
// Prior to iOS 13, detecting iOS Safari was relatively straight-forward.
|
||||
// As of iOS 13, Safari on iPad (in its default configuration) reports the same
|
||||
// user-agent string as Safari on desktop MacOS. Strictly speaking, we only care
|
||||
// about iOS for the purposes if selecting for cases where Quick Look is known
|
||||
// to be supported. However, for API correctness purposes, we must rely on
|
||||
// known, detectable signals to distinguish iOS Safari from MacOS Safari. At the
|
||||
// time of this writing, there are no non-iOS/iPadOS Apple devices with
|
||||
// multi-touch displays.
|
||||
// @see https://stackoverflow.com/questions/57765958/how-to-detect-ipad-and-ipad-os-version-in-ios-13-and-up
|
||||
// @see https://forums.developer.apple.com/thread/119186
|
||||
// @see https://github.com/google/model-viewer/issues/758
|
||||
export const IS_IOS =
|
||||
(/iPad|iPhone|iPod/.test(navigator.userAgent) && !(self as any).MSStream) ||
|
||||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
|
||||
// @see https://developer.chrome.com/multidevice/user-agent
|
||||
export const IS_SAFARI = /Safari\//.test(navigator.userAgent);
|
||||
export const IS_FIREFOX = /firefox/i.test(navigator.userAgent);
|
||||
export const IS_OCULUS = /OculusBrowser/.test(navigator.userAgent);
|
||||
export const IS_IOS_CHROME = IS_IOS && /CriOS\//.test(navigator.userAgent);
|
||||
export const IS_IOS_SAFARI = IS_IOS && IS_SAFARI;
|
||||
|
||||
export const IS_SCENEVIEWER_CANDIDATE = IS_ANDROID && !IS_FIREFOX && !IS_OCULUS;
|
||||
|
||||
// Extend Window type with webkit property,
|
||||
// required to check if iOS is running within a WKWebView browser instance.
|
||||
declare global {
|
||||
interface Window {
|
||||
webkit?: any;
|
||||
}
|
||||
}
|
||||
|
||||
export const IS_WKWEBVIEW =
|
||||
Boolean(window.webkit && window.webkit.messageHandlers);
|
||||
|
||||
// If running in iOS Safari proper, and not within a WKWebView component
|
||||
// instance, check for ARQL feature support. Otherwise, if running in a
|
||||
// WKWebView instance, check for known ARQL compatible iOS browsers, including:
|
||||
// Chrome (CriOS), Edge (EdgiOS), Firefox (FxiOS), Google App (GSA), DuckDuckGo
|
||||
// (DuckDuckGo). All other iOS browsers / apps will fail by default.
|
||||
export const IS_AR_QUICKLOOK_CANDIDATE = (() => {
|
||||
if (IS_IOS) {
|
||||
if (!IS_WKWEBVIEW) {
|
||||
const tempAnchor = document.createElement('a');
|
||||
return Boolean(
|
||||
tempAnchor.relList && tempAnchor.relList.supports &&
|
||||
tempAnchor.relList.supports('ar'));
|
||||
} else {
|
||||
return Boolean(/CriOS\/|EdgiOS\/|FxiOS\/|GSA\/|DuckDuckGo\//.test(
|
||||
navigator.userAgent));
|
||||
}
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,162 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ReactiveElement} from 'lit';
|
||||
|
||||
import {EvaluatedStyle, Intrinsics, StyleEvaluator} from './styles/evaluators.js';
|
||||
import {parseExpressions, Unit} from './styles/parsers.js';
|
||||
import {StyleEffector} from './styles/style-effector.js';
|
||||
|
||||
// An IntrinsicsFactory generates up-to-date intrinsics for a given ModelViewer
|
||||
// element instance when it is invoked.
|
||||
export type IntrinsicsFactory<T extends Intrinsics<Array<Unit>>,
|
||||
U extends ReactiveElement> =
|
||||
(element: U) => T;
|
||||
|
||||
// When applying the @style decorator, it needs to be configured with
|
||||
// corresponding Intrinsics and the property key of a method to receive updated
|
||||
// values. Optionally, it can also be configured to observe environment effects,
|
||||
// which causes a StyleEffector to be created for the property.
|
||||
export interface StyleDecoratorConfig<T extends Intrinsics<Array<Unit>>,
|
||||
U extends ReactiveElement> {
|
||||
intrinsics: T|IntrinsicsFactory<T, U>;
|
||||
updateHandler: symbol;
|
||||
observeEffects?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The @style decorator is responsible for coordinating the conversion of a
|
||||
* CSS-like string property value into numbers that can be applied to
|
||||
* lower-level constructs. It also can optionally manage the lifecycle of a
|
||||
* StyleEffector which allows automatic updates for styles that use env() or
|
||||
* var() functions.
|
||||
*
|
||||
* The decorator is configured with Intrinsics and the property key for a
|
||||
* method that handles updates. The named update handler is invoked with the
|
||||
* result of parsing and evaluating the raw property string value. The format of
|
||||
* the evaluated result is derived from the basis of the configured Intrinsics,
|
||||
* and is always an array of numbers of fixed length.
|
||||
*
|
||||
* NOTE: This decorator depends on the property updating mechanism defined by
|
||||
* UpdatingElement as exported by the lit-element module. That means it *must*
|
||||
* be used in conjunction with the @property decorator, or equivalent
|
||||
* JavaScript.
|
||||
*
|
||||
* Supported configurations are:
|
||||
*
|
||||
* - `intrinsics`: An Intrinsics struct that describes how to interpret a
|
||||
* serialized style attribute. For more detail on intrinsics see
|
||||
* ./styles/evaluators.ts
|
||||
* - `updateHandler`: A string or Symbol that is the key of a method to be
|
||||
* invoked with the result of parsing and evaluating a serialized style string.
|
||||
* - `observeEffects`: Optional, if set to true then styles that use env() will
|
||||
* cause their update handlers to be invoked every time the corresponding
|
||||
* environment variable changes (even if the style attribute itself remains
|
||||
* static).
|
||||
*/
|
||||
export const style =
|
||||
<T extends Intrinsics<Array<Unit>>, U extends ReactiveElement>(
|
||||
config: StyleDecoratorConfig<T, U>) => {
|
||||
const observeEffects: boolean = config.observeEffects || false;
|
||||
const getIntrinsics = config.intrinsics instanceof Function ?
|
||||
config.intrinsics :
|
||||
(() => config.intrinsics) as IntrinsicsFactory<T, U>;
|
||||
|
||||
return <U extends typeof ReactiveElement['prototype']>(
|
||||
proto: U, propertyName: string) => {
|
||||
const originalUpdated = (proto as any).updated;
|
||||
const originalConnectedCallback = proto.connectedCallback;
|
||||
const originalDisconnectedCallback = proto.disconnectedCallback;
|
||||
|
||||
const $styleEffector = Symbol(`${propertyName}StyleEffector`);
|
||||
const $styleEvaluator = Symbol(`${propertyName}StyleEvaluator`);
|
||||
const $updateEvaluator = Symbol(`${propertyName}UpdateEvaluator`);
|
||||
const $evaluateAndSync = Symbol(`${propertyName}EvaluateAndSync`);
|
||||
|
||||
Object.defineProperties(proto, {
|
||||
[$styleEffector]:
|
||||
{value: null as StyleEffector | null, writable: true},
|
||||
[$styleEvaluator]:
|
||||
{value: null as StyleEvaluator<T>| null, writable: true},
|
||||
|
||||
[$updateEvaluator]: {
|
||||
value: function() {
|
||||
const ast = parseExpressions(
|
||||
this[propertyName as keyof ReactiveElement] as string);
|
||||
this[$styleEvaluator] =
|
||||
new StyleEvaluator(ast, getIntrinsics(this));
|
||||
|
||||
if (this[$styleEffector] == null && observeEffects) {
|
||||
this[$styleEffector] =
|
||||
new StyleEffector(() => this[$evaluateAndSync]());
|
||||
}
|
||||
|
||||
if (this[$styleEffector] != null) {
|
||||
this[$styleEffector].observeEffectsFor(ast);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
[$evaluateAndSync]: {
|
||||
value: function() {
|
||||
if (this[$styleEvaluator] == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = this[$styleEvaluator].evaluate();
|
||||
|
||||
// @see https://github.com/microsoft/TypeScript/pull/30769
|
||||
// @see https://github.com/Microsoft/TypeScript/issues/1863
|
||||
(this as unknown as Record<
|
||||
string,
|
||||
(style: EvaluatedStyle<T>) =>
|
||||
void>)[config.updateHandler as unknown as string](
|
||||
result);
|
||||
}
|
||||
},
|
||||
|
||||
updated: {
|
||||
value: function(changedProperties: Map<string, any>) {
|
||||
// Always invoke updates to styles first. This gives a class that
|
||||
// uses this decorator the opportunity to override the effect, or
|
||||
// respond to it, in its own implementation of `updated`.
|
||||
if (changedProperties.has(propertyName)) {
|
||||
this[$updateEvaluator]();
|
||||
this[$evaluateAndSync]();
|
||||
}
|
||||
|
||||
originalUpdated.call(this, changedProperties);
|
||||
}
|
||||
},
|
||||
|
||||
connectedCallback: {
|
||||
value: function() {
|
||||
originalConnectedCallback.call(this);
|
||||
this.requestUpdate(propertyName, this[propertyName]);
|
||||
}
|
||||
},
|
||||
|
||||
disconnectedCallback: {
|
||||
value: function() {
|
||||
originalDisconnectedCallback.call(this);
|
||||
if (this[$styleEffector] != null) {
|
||||
this[$styleEffector].dispose();
|
||||
this[$styleEffector] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {LoopOnce, LoopPingPong, LoopRepeat} from 'three';
|
||||
|
||||
import ModelViewerElementBase, {$getModelIsVisible, $needsRender, $onModelLoad, $renderer, $scene, $tick} from '../model-viewer-base.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
|
||||
const MILLISECONDS_PER_SECOND = 1000.0
|
||||
|
||||
const $changeAnimation = Symbol('changeAnimation');
|
||||
const $appendAnimation = Symbol('appendAnimation');
|
||||
const $detachAnimation = Symbol('detachAnimation');
|
||||
const $paused = Symbol('paused');
|
||||
|
||||
interface PlayAnimationOptions {
|
||||
repetitions: number, pingpong: boolean,
|
||||
}
|
||||
|
||||
interface AppendAnimationOptions {
|
||||
pingpong: boolean, repetitions: number|null, weight: number,
|
||||
timeScale: number, fade: boolean|number, warp: boolean|number,
|
||||
relativeWarp: boolean, time: number|null
|
||||
}
|
||||
|
||||
interface DetachAnimationOptions {
|
||||
fade: boolean|number
|
||||
}
|
||||
|
||||
const DEFAULT_PLAY_OPTIONS: PlayAnimationOptions = {
|
||||
repetitions: Infinity,
|
||||
pingpong: false
|
||||
};
|
||||
|
||||
const DEFAULT_APPEND_OPTIONS: AppendAnimationOptions = {
|
||||
pingpong: false,
|
||||
repetitions: null,
|
||||
weight: 1,
|
||||
timeScale: 1,
|
||||
fade: false,
|
||||
warp: false,
|
||||
relativeWarp: true,
|
||||
time: null
|
||||
};
|
||||
|
||||
const DEFAULT_DETACH_OPTIONS: DetachAnimationOptions = {
|
||||
fade: true
|
||||
};
|
||||
|
||||
export declare interface AnimationInterface {
|
||||
autoplay: boolean;
|
||||
animationName: string|void;
|
||||
animationCrossfadeDuration: number;
|
||||
readonly availableAnimations: Array<string>;
|
||||
readonly paused: boolean;
|
||||
readonly duration: number;
|
||||
currentTime: number;
|
||||
timeScale: number;
|
||||
pause(): void;
|
||||
play(options?: PlayAnimationOptions): void;
|
||||
appendAnimation(animationName: string, options?: AppendAnimationOptions):
|
||||
void;
|
||||
detachAnimation(animationName: string, options?: DetachAnimationOptions):
|
||||
void;
|
||||
}
|
||||
|
||||
export const AnimationMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<AnimationInterface>&T => {
|
||||
class AnimationModelViewerElement extends ModelViewerElement {
|
||||
@property({type: Boolean}) autoplay: boolean = false;
|
||||
@property({type: String, attribute: 'animation-name'})
|
||||
animationName: string|undefined = undefined;
|
||||
@property({type: Number, attribute: 'animation-crossfade-duration'})
|
||||
animationCrossfadeDuration: number = 300;
|
||||
|
||||
protected[$paused]: boolean = true;
|
||||
|
||||
constructor(...args: any[]) {
|
||||
super(args);
|
||||
|
||||
this[$scene].subscribeMixerEvent('loop', (e) => {
|
||||
const count = e.action._loopCount;
|
||||
const name = e.action._clip.name;
|
||||
const uuid = e.action._clip.uuid;
|
||||
const targetAnimation =
|
||||
this[$scene].markedAnimations.find(e => e.name === name);
|
||||
|
||||
if (targetAnimation) {
|
||||
this[$scene].updateAnimationLoop(
|
||||
targetAnimation.name,
|
||||
targetAnimation.loopMode,
|
||||
targetAnimation.repetitionCount);
|
||||
const filtredArr =
|
||||
this[$scene].markedAnimations.filter(e => e.name !== name);
|
||||
this[$scene].markedAnimations = filtredArr;
|
||||
}
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('loop', {detail: {count, name, uuid}}));
|
||||
});
|
||||
this[$scene].subscribeMixerEvent('finished', (e) => {
|
||||
if (!this[$scene].appendedAnimations.includes(e.action._clip.name)) {
|
||||
this[$paused] = true;
|
||||
} else {
|
||||
const filteredList = this[$scene].appendedAnimations.filter(
|
||||
i => i !== e.action._clip.name);
|
||||
this[$scene].appendedAnimations = filteredList;
|
||||
}
|
||||
this.dispatchEvent(new CustomEvent('finished'));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array
|
||||
*/
|
||||
get availableAnimations(): Array<string> {
|
||||
if (this.loaded) {
|
||||
return this[$scene].animationNames;
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this[$scene].duration;
|
||||
}
|
||||
|
||||
get paused(): boolean {
|
||||
return this[$paused];
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
return this[$scene].animationTime;
|
||||
}
|
||||
|
||||
get appendedAnimations(): string[] {
|
||||
return this[$scene].appendedAnimations;
|
||||
}
|
||||
|
||||
set currentTime(value: number) {
|
||||
this[$scene].animationTime = value;
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
get timeScale(): number {
|
||||
return this[$scene].animationTimeScale;
|
||||
}
|
||||
|
||||
set timeScale(value: number) {
|
||||
this[$scene].animationTimeScale = value;
|
||||
}
|
||||
|
||||
pause() {
|
||||
if (this[$paused]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[$paused] = true;
|
||||
this.dispatchEvent(new CustomEvent('pause'));
|
||||
}
|
||||
|
||||
play(options?: PlayAnimationOptions) {
|
||||
if (this.availableAnimations.length > 0) {
|
||||
this[$paused] = false;
|
||||
|
||||
this[$changeAnimation](options);
|
||||
|
||||
this.dispatchEvent(new CustomEvent('play'));
|
||||
}
|
||||
}
|
||||
|
||||
appendAnimation(animationName: string, options?: AppendAnimationOptions) {
|
||||
if (this.availableAnimations.length > 0) {
|
||||
this[$paused] = false;
|
||||
|
||||
this[$appendAnimation](animationName, options);
|
||||
|
||||
this.dispatchEvent(new CustomEvent('append-animation'));
|
||||
}
|
||||
}
|
||||
|
||||
detachAnimation(animationName: string, options?: DetachAnimationOptions) {
|
||||
if (this.availableAnimations.length > 0) {
|
||||
this[$paused] = false;
|
||||
|
||||
this[$detachAnimation](animationName, options);
|
||||
|
||||
this.dispatchEvent(new CustomEvent('detach-animation'));
|
||||
}
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
super[$onModelLoad]();
|
||||
|
||||
this[$paused] = true;
|
||||
|
||||
if (this.animationName != null) {
|
||||
this[$changeAnimation]();
|
||||
}
|
||||
|
||||
if (this.autoplay) {
|
||||
this.play();
|
||||
}
|
||||
}
|
||||
|
||||
[$tick](_time: number, delta: number) {
|
||||
super[$tick](_time, delta);
|
||||
|
||||
if (this[$paused] ||
|
||||
(!this[$getModelIsVisible]() && !this[$renderer].isPresenting)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[$scene].updateAnimation(delta / MILLISECONDS_PER_SECOND);
|
||||
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('autoplay') && this.autoplay) {
|
||||
this.play();
|
||||
}
|
||||
|
||||
if (changedProperties.has('animationName')) {
|
||||
this[$changeAnimation]();
|
||||
}
|
||||
}
|
||||
|
||||
[$changeAnimation](options: PlayAnimationOptions = DEFAULT_PLAY_OPTIONS) {
|
||||
const repetitions = options.repetitions ?? Infinity;
|
||||
const mode = options.pingpong ?
|
||||
LoopPingPong :
|
||||
(repetitions === 1 ? LoopOnce : LoopRepeat);
|
||||
this[$scene].playAnimation(
|
||||
this.animationName,
|
||||
this.animationCrossfadeDuration / MILLISECONDS_PER_SECOND,
|
||||
mode,
|
||||
repetitions);
|
||||
|
||||
// If we are currently paused, we need to force a render so that
|
||||
// the scene updates to the first frame of the new animation
|
||||
if (this[$paused]) {
|
||||
this[$scene].updateAnimation(0);
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
|
||||
[$appendAnimation](
|
||||
animationName: string = '',
|
||||
options: AppendAnimationOptions = DEFAULT_APPEND_OPTIONS) {
|
||||
const repetitions = options.repetitions ?? Infinity;
|
||||
const mode = options.pingpong ?
|
||||
LoopPingPong :
|
||||
(repetitions === 1 ? LoopOnce : LoopRepeat);
|
||||
|
||||
const needsToStop = !!options.repetitions || 'pingpong' in options;
|
||||
|
||||
this[$scene].appendAnimation(
|
||||
animationName ? animationName : this.animationName,
|
||||
mode,
|
||||
repetitions,
|
||||
options.weight,
|
||||
options.timeScale,
|
||||
options.fade,
|
||||
options.warp,
|
||||
options.relativeWarp,
|
||||
options.time,
|
||||
needsToStop);
|
||||
|
||||
// If we are currently paused, we need to force a render so that
|
||||
// the scene updates to the first frame of the new animation
|
||||
if (this[$paused]) {
|
||||
this[$scene].updateAnimation(0);
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
|
||||
[$detachAnimation](
|
||||
animationName: string = '',
|
||||
options: DetachAnimationOptions = DEFAULT_DETACH_OPTIONS) {
|
||||
this[$scene].detachAnimation(
|
||||
animationName ? animationName : this.animationName, options.fade);
|
||||
|
||||
// If we are currently paused, we need to force a render so that
|
||||
// the scene updates to the first frame of the new animation
|
||||
if (this[$paused]) {
|
||||
this[$scene].updateAnimation(0);
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return AnimationModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,272 @@
|
||||
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Matrix4, Vector3} from 'three';
|
||||
|
||||
import ModelViewerElementBase, {$needsRender, $onModelLoad, $scene, $tick, toVector2D, toVector3D, Vector2D, Vector3D} from '../model-viewer-base.js';
|
||||
import {Hotspot, HotspotConfiguration} from '../three-components/Hotspot.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
|
||||
const $hotspotMap = Symbol('hotspotMap');
|
||||
const $mutationCallback = Symbol('mutationCallback');
|
||||
const $observer = Symbol('observer');
|
||||
const $addHotspot = Symbol('addHotspot');
|
||||
const $removeHotspot = Symbol('removeHotspot');
|
||||
|
||||
const worldToModel = new Matrix4();
|
||||
|
||||
export declare type HotspotData = {
|
||||
position: Vector3D,
|
||||
normal: Vector3D,
|
||||
canvasPosition: Vector3D,
|
||||
facingCamera: boolean,
|
||||
}
|
||||
|
||||
export declare interface AnnotationInterface {
|
||||
updateHotspot(config: HotspotConfiguration): void;
|
||||
queryHotspot(name: string): HotspotData|null;
|
||||
positionAndNormalFromPoint(pixelX: number, pixelY: number):
|
||||
{position: Vector3D, normal: Vector3D, uv: Vector2D|null}|null;
|
||||
surfaceFromPoint(pixelX: number, pixelY: number): string|null;
|
||||
}
|
||||
|
||||
/**
|
||||
* AnnotationMixin implements a declarative API to add hotspots and annotations.
|
||||
* Child elements of the <model-viewer> element that have a slot name that
|
||||
* begins with "hotspot" and data-position and data-normal attributes in
|
||||
* the format of the camera-target attribute will be added to the scene and
|
||||
* track the specified model coordinates.
|
||||
*/
|
||||
export const AnnotationMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<AnnotationInterface>&T => {
|
||||
class AnnotationModelViewerElement extends ModelViewerElement {
|
||||
private[$hotspotMap] = new Map<string, Hotspot>();
|
||||
private[$mutationCallback] = (mutations: Array<unknown>) => {
|
||||
mutations.forEach((mutation) => {
|
||||
// NOTE: Be wary that in ShadyDOM cases, the MutationRecord
|
||||
// only has addedNodes and removedNodes (and no other details).
|
||||
if (!(mutation instanceof MutationRecord) ||
|
||||
mutation.type === 'childList') {
|
||||
(mutation as MutationRecord).addedNodes.forEach((node) => {
|
||||
this[$addHotspot](node);
|
||||
});
|
||||
(mutation as MutationRecord).removedNodes.forEach((node) => {
|
||||
this[$removeHotspot](node);
|
||||
});
|
||||
this[$needsRender]();
|
||||
}
|
||||
});
|
||||
};
|
||||
private[$observer] = new MutationObserver(this[$mutationCallback]);
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
for (let i = 0; i < this.children.length; ++i) {
|
||||
this[$addHotspot](this.children[i]);
|
||||
}
|
||||
|
||||
const {ShadyDOM} = self as any;
|
||||
|
||||
if (ShadyDOM == null) {
|
||||
this[$observer].observe(this, {childList: true});
|
||||
} else {
|
||||
this[$observer] =
|
||||
ShadyDOM.observeChildren(this, this[$mutationCallback]);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
const {ShadyDOM} = self as any;
|
||||
|
||||
if (ShadyDOM == null) {
|
||||
this[$observer].disconnect();
|
||||
} else {
|
||||
ShadyDOM.unobserveChildren(this[$observer]);
|
||||
}
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
super[$onModelLoad]();
|
||||
|
||||
const scene = this[$scene];
|
||||
scene.forHotspots((hotspot) => {
|
||||
scene.updateSurfaceHotspot(hotspot);
|
||||
});
|
||||
}
|
||||
|
||||
[$tick](time: number, delta: number) {
|
||||
super[$tick](time, delta);
|
||||
const scene = this[$scene];
|
||||
const {annotationRenderer} = scene;
|
||||
const camera = scene.getCamera();
|
||||
|
||||
if (scene.shouldRender()) {
|
||||
scene.animateSurfaceHotspots();
|
||||
scene.updateHotspotsVisibility(camera.position);
|
||||
annotationRenderer.domElement.style.display = '';
|
||||
annotationRenderer.render(scene, camera);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Since the data-position and data-normal attributes are not observed, use
|
||||
* this method to move a hotspot. Keep in mind that all hotspots with the
|
||||
* same slot name use a single location and the first definition takes
|
||||
* precedence, until updated with this method.
|
||||
*/
|
||||
updateHotspot(config: HotspotConfiguration) {
|
||||
const hotspot = this[$hotspotMap].get(config.name);
|
||||
|
||||
if (hotspot == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
hotspot.updatePosition(config.position);
|
||||
hotspot.updateNormal(config.normal);
|
||||
hotspot.surface = config.surface;
|
||||
this[$scene].updateSurfaceHotspot(hotspot);
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns in-scene data about a requested hotspot including
|
||||
* its position in screen (canvas) space and its current visibility.
|
||||
*/
|
||||
queryHotspot(name: string): HotspotData|null {
|
||||
const hotspot = this[$hotspotMap].get(name);
|
||||
if (hotspot == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const position = toVector3D(hotspot.position);
|
||||
const normal = toVector3D(hotspot.normal);
|
||||
const facingCamera = hotspot.facingCamera;
|
||||
|
||||
const scene = this[$scene];
|
||||
const camera = scene.getCamera();
|
||||
const vector = new Vector3();
|
||||
|
||||
vector.setFromMatrixPosition(hotspot.matrixWorld);
|
||||
vector.project(camera);
|
||||
|
||||
const widthHalf = scene.width / 2;
|
||||
const heightHalf = scene.height / 2;
|
||||
|
||||
vector.x = (vector.x * widthHalf) + widthHalf;
|
||||
vector.y = -(vector.y * heightHalf) + heightHalf;
|
||||
|
||||
const canvasPosition =
|
||||
toVector3D(new Vector3(vector.x, vector.y, vector.z));
|
||||
|
||||
if (!Number.isFinite(canvasPosition.x) ||
|
||||
!Number.isFinite(canvasPosition.y)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {position, normal, canvasPosition, facingCamera};
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns the model position, normal and texture coordinate
|
||||
* of the point on the mesh corresponding to the input pixel coordinates
|
||||
* given relative to the model-viewer element. The position and normal
|
||||
* are returned as strings in the format suitable for putting in a
|
||||
* hotspot's data-position and data-normal attributes. If the mesh is
|
||||
* not hit, the result is null.
|
||||
*/
|
||||
positionAndNormalFromPoint(pixelX: number, pixelY: number):
|
||||
{position: Vector3D, normal: Vector3D, uv: Vector2D|null}|null {
|
||||
const scene = this[$scene];
|
||||
const ndcPosition = scene.getNDC(pixelX, pixelY);
|
||||
|
||||
const hit = scene.positionAndNormalFromPoint(ndcPosition);
|
||||
if (hit == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
worldToModel.copy(scene.target.matrixWorld).invert();
|
||||
const position = toVector3D(hit.position.applyMatrix4(worldToModel));
|
||||
const normal = toVector3D(hit.normal.transformDirection(worldToModel));
|
||||
|
||||
let uv = null;
|
||||
if (hit.uv != null) {
|
||||
uv = toVector2D(hit.uv);
|
||||
}
|
||||
|
||||
return {position: position, normal: normal, uv: uv};
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a dynamic hotspot ID string of the point on the mesh
|
||||
* corresponding to the input pixel coordinates given relative to the
|
||||
* model-viewer element. The ID string can be used in the data-surface
|
||||
* attribute of the hotspot to make it follow this point on the surface even
|
||||
* as the model animates. If the mesh is not hit, the result is null.
|
||||
*/
|
||||
surfaceFromPoint(pixelX: number, pixelY: number): string|null {
|
||||
const scene = this[$scene];
|
||||
const ndcPosition = scene.getNDC(pixelX, pixelY);
|
||||
|
||||
return scene.surfaceFromPoint(ndcPosition);
|
||||
}
|
||||
|
||||
private[$addHotspot](node: Node) {
|
||||
if (!(node instanceof HTMLElement &&
|
||||
node.slot.indexOf('hotspot') === 0)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let hotspot = this[$hotspotMap].get(node.slot);
|
||||
|
||||
if (hotspot != null) {
|
||||
hotspot.increment();
|
||||
} else {
|
||||
hotspot = new Hotspot({
|
||||
name: node.slot,
|
||||
position: node.dataset.position,
|
||||
normal: node.dataset.normal,
|
||||
surface: node.dataset.surface,
|
||||
});
|
||||
this[$hotspotMap].set(node.slot, hotspot);
|
||||
this[$scene].addHotspot(hotspot);
|
||||
}
|
||||
this[$scene].queueRender();
|
||||
}
|
||||
|
||||
private[$removeHotspot](node: Node) {
|
||||
if (!(node instanceof HTMLElement)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hotspot = this[$hotspotMap].get(node.slot);
|
||||
|
||||
if (!hotspot) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (hotspot.decrement()) {
|
||||
this[$scene].removeHotspot(hotspot);
|
||||
this[$hotspotMap].delete(node.slot);
|
||||
}
|
||||
this[$scene].queueRender();
|
||||
}
|
||||
}
|
||||
|
||||
return AnnotationModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,486 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {USDZExporter} from 'three/examples/jsm/exporters/USDZExporter.js';
|
||||
|
||||
import {IS_AR_QUICKLOOK_CANDIDATE, IS_SCENEVIEWER_CANDIDATE, IS_WEBXR_AR_CANDIDATE} from '../constants.js';
|
||||
import ModelViewerElementBase, {$needsRender, $progressTracker, $renderer, $scene, $shouldAttemptPreload, $updateSource} from '../model-viewer-base.js';
|
||||
import {enumerationDeserializer} from '../styles/deserializers.js';
|
||||
import {ARStatus, ARTracking} from '../three-components/ARRenderer.js';
|
||||
import {Constructor, waitForEvent} from '../utilities.js';
|
||||
|
||||
let isWebXRBlocked = false;
|
||||
let isSceneViewerBlocked = false;
|
||||
const noArViewerSigil = '#model-viewer-no-ar-fallback';
|
||||
|
||||
export type ARMode = 'quick-look'|'scene-viewer'|'webxr'|'none';
|
||||
|
||||
const deserializeARModes = enumerationDeserializer<ARMode>(
|
||||
['quick-look', 'scene-viewer', 'webxr', 'none']);
|
||||
|
||||
const DEFAULT_AR_MODES = 'webxr scene-viewer quick-look';
|
||||
|
||||
const ARMode: {[index: string]: ARMode} = {
|
||||
QUICK_LOOK: 'quick-look',
|
||||
SCENE_VIEWER: 'scene-viewer',
|
||||
WEBXR: 'webxr',
|
||||
NONE: 'none'
|
||||
};
|
||||
|
||||
export interface ARStatusDetails {
|
||||
status: ARStatus;
|
||||
}
|
||||
|
||||
export interface ARTrackingDetails {
|
||||
status: ARTracking;
|
||||
}
|
||||
|
||||
const $arButtonContainer = Symbol('arButtonContainer');
|
||||
const $enterARWithWebXR = Symbol('enterARWithWebXR');
|
||||
export const $openSceneViewer = Symbol('openSceneViewer');
|
||||
export const $openIOSARQuickLook = Symbol('openIOSARQuickLook');
|
||||
const $canActivateAR = Symbol('canActivateAR');
|
||||
const $arMode = Symbol('arMode');
|
||||
const $arModes = Symbol('arModes');
|
||||
const $arAnchor = Symbol('arAnchor');
|
||||
const $preload = Symbol('preload');
|
||||
|
||||
const $onARButtonContainerClick = Symbol('onARButtonContainerClick');
|
||||
const $onARStatus = Symbol('onARStatus');
|
||||
const $onARTracking = Symbol('onARTracking');
|
||||
const $onARTap = Symbol('onARTap');
|
||||
const $selectARMode = Symbol('selectARMode');
|
||||
const $triggerLoad = Symbol('triggerLoad');
|
||||
|
||||
export declare interface ARInterface {
|
||||
ar: boolean;
|
||||
arModes: string;
|
||||
arScale: string;
|
||||
arPlacement: 'floor'|'wall'|'ceiling';
|
||||
iosSrc: string|null;
|
||||
xrEnvironment: boolean;
|
||||
arUsdzMaxTextureSize: string;
|
||||
readonly canActivateAR: boolean;
|
||||
activateAR(): Promise<void>;
|
||||
}
|
||||
|
||||
export const ARMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<ARInterface>&T => {
|
||||
class ARModelViewerElement extends ModelViewerElement {
|
||||
@property({type: Boolean, attribute: 'ar'}) ar: boolean = false;
|
||||
|
||||
@property({type: String, attribute: 'ar-scale'}) arScale: string = 'auto';
|
||||
|
||||
@property({type: String, attribute: 'ar-usdz-max-texture-size'})
|
||||
arUsdzMaxTextureSize: string = 'auto';
|
||||
|
||||
@property({type: String, attribute: 'ar-placement'})
|
||||
arPlacement: 'floor'|'wall'|'ceiling' = 'floor';
|
||||
|
||||
@property({type: String, attribute: 'ar-modes'})
|
||||
arModes: string = DEFAULT_AR_MODES;
|
||||
|
||||
@property({type: String, attribute: 'ios-src'}) iosSrc: string|null = null;
|
||||
|
||||
@property({type: Boolean, attribute: 'xr-environment'})
|
||||
xrEnvironment: boolean = false;
|
||||
|
||||
get canActivateAR(): boolean {
|
||||
return this[$arMode] !== ARMode.NONE;
|
||||
}
|
||||
|
||||
protected[$canActivateAR]: boolean = false;
|
||||
|
||||
// TODO: Add this to the shadow root as part of this mixin's
|
||||
// implementation:
|
||||
protected[$arButtonContainer]: HTMLElement =
|
||||
this.shadowRoot!.querySelector('.ar-button') as HTMLElement;
|
||||
|
||||
protected[$arAnchor] = document.createElement('a');
|
||||
|
||||
protected[$arModes]: Set<ARMode> = new Set();
|
||||
protected[$arMode]: ARMode = ARMode.NONE;
|
||||
protected[$preload] = false;
|
||||
|
||||
private[$onARButtonContainerClick] = (event: Event) => {
|
||||
event.preventDefault();
|
||||
this.activateAR();
|
||||
};
|
||||
|
||||
private[$onARStatus] = ({status}: {status: ARStatus}) => {
|
||||
if (status === ARStatus.NOT_PRESENTING ||
|
||||
this[$renderer].arRenderer.presentedScene === this[$scene]) {
|
||||
this.setAttribute('ar-status', status);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<ARStatusDetails>('ar-status', {detail: {status}}));
|
||||
if (status === ARStatus.NOT_PRESENTING) {
|
||||
this.removeAttribute('ar-tracking');
|
||||
} else if (status === ARStatus.SESSION_STARTED) {
|
||||
this.setAttribute('ar-tracking', ARTracking.TRACKING);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private[$onARTracking] = ({status}: {status: ARTracking}) => {
|
||||
this.setAttribute('ar-tracking', status);
|
||||
this.dispatchEvent(new CustomEvent<ARTrackingDetails>(
|
||||
'ar-tracking', {detail: {status}}));
|
||||
};
|
||||
|
||||
private[$onARTap] = (event: Event) => {
|
||||
if ((event as any).data == '_apple_ar_quicklook_button_tapped') {
|
||||
this.dispatchEvent(new CustomEvent('quick-look-button-tapped'));
|
||||
}
|
||||
};
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this[$renderer].arRenderer.addEventListener('status', this[$onARStatus]);
|
||||
this.setAttribute('ar-status', ARStatus.NOT_PRESENTING);
|
||||
|
||||
this[$renderer].arRenderer.addEventListener(
|
||||
'tracking', this[$onARTracking]);
|
||||
|
||||
this[$arAnchor].addEventListener('message', this[$onARTap]);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this[$renderer].arRenderer.removeEventListener(
|
||||
'status', this[$onARStatus]);
|
||||
this[$renderer].arRenderer.removeEventListener(
|
||||
'tracking', this[$onARTracking]);
|
||||
|
||||
this[$arAnchor].removeEventListener('message', this[$onARTap]);
|
||||
}
|
||||
|
||||
update(changedProperties: Map<string, any>) {
|
||||
super.update(changedProperties);
|
||||
|
||||
if (changedProperties.has('arScale')) {
|
||||
this[$scene].canScale = this.arScale !== 'fixed';
|
||||
}
|
||||
|
||||
if (changedProperties.has('arPlacement')) {
|
||||
this[$scene].updateShadow();
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('arModes')) {
|
||||
this[$arModes] = deserializeARModes(this.arModes);
|
||||
}
|
||||
|
||||
if (changedProperties.has('ar') || changedProperties.has('arModes') ||
|
||||
changedProperties.has('src') || changedProperties.has('iosSrc') ||
|
||||
changedProperties.has('arUsdzMaxTextureSize')) {
|
||||
this[$selectARMode]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Activates AR. Note that for any mode that is not WebXR-based, this
|
||||
* method most likely has to be called synchronous from a user
|
||||
* interaction handler. Otherwise, attempts to activate modes that
|
||||
* require user interaction will most likely be ignored.
|
||||
*/
|
||||
async activateAR() {
|
||||
switch (this[$arMode]) {
|
||||
case ARMode.QUICK_LOOK:
|
||||
await this[$openIOSARQuickLook]();
|
||||
break;
|
||||
case ARMode.WEBXR:
|
||||
await this[$enterARWithWebXR]();
|
||||
break;
|
||||
case ARMode.SCENE_VIEWER:
|
||||
this[$openSceneViewer]();
|
||||
break;
|
||||
default:
|
||||
console.warn(
|
||||
'No AR Mode can be activated. This is probably due to missing \
|
||||
configuration or device capabilities');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async[$selectARMode]() {
|
||||
let arMode = ARMode.NONE;
|
||||
if (this.ar) {
|
||||
if (this.src != null) {
|
||||
for (const value of this[$arModes]) {
|
||||
if (value === 'webxr' && IS_WEBXR_AR_CANDIDATE && !isWebXRBlocked &&
|
||||
await this[$renderer].arRenderer.supportsPresentation()) {
|
||||
arMode = ARMode.WEBXR;
|
||||
break;
|
||||
}
|
||||
if (value === 'scene-viewer' && !isSceneViewerBlocked &&
|
||||
(IS_SCENEVIEWER_CANDIDATE ||
|
||||
((navigator as any).userAgentData &&
|
||||
(navigator as any).userAgentData.getHighEntropyValues &&
|
||||
(await (navigator as any).userAgentData.getHighEntropyValues([
|
||||
'formFactor'
|
||||
])).formFactor?.includes('XR')))) {
|
||||
arMode = ARMode.SCENE_VIEWER;
|
||||
break;
|
||||
}
|
||||
if (value === 'quick-look' && IS_AR_QUICKLOOK_CANDIDATE) {
|
||||
arMode = ARMode.QUICK_LOOK;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The presence of ios-src overrides the absence of quick-look
|
||||
// ar-mode.
|
||||
if (arMode === ARMode.NONE && this.iosSrc != null &&
|
||||
IS_AR_QUICKLOOK_CANDIDATE) {
|
||||
arMode = ARMode.QUICK_LOOK;
|
||||
}
|
||||
}
|
||||
|
||||
if (arMode !== ARMode.NONE) {
|
||||
this[$arButtonContainer].classList.add('enabled');
|
||||
this[$arButtonContainer].addEventListener(
|
||||
'click', this[$onARButtonContainerClick]);
|
||||
} else if (this[$arButtonContainer].classList.contains('enabled')) {
|
||||
this[$arButtonContainer].removeEventListener(
|
||||
'click', this[$onARButtonContainerClick]);
|
||||
this[$arButtonContainer].classList.remove('enabled');
|
||||
|
||||
// If AR went from working to not, notify the element.
|
||||
const status = ARStatus.FAILED;
|
||||
this.setAttribute('ar-status', status);
|
||||
this.dispatchEvent(
|
||||
new CustomEvent<ARStatusDetails>('ar-status', {detail: {status}}));
|
||||
}
|
||||
this[$arMode] = arMode;
|
||||
}
|
||||
|
||||
protected async[$enterARWithWebXR]() {
|
||||
console.log('Attempting to present in AR with WebXR...');
|
||||
|
||||
await this[$triggerLoad]();
|
||||
|
||||
try {
|
||||
this[$arButtonContainer].removeEventListener(
|
||||
'click', this[$onARButtonContainerClick]);
|
||||
const {arRenderer} = this[$renderer];
|
||||
if (this.arPlacement === 'wall') {
|
||||
arRenderer.placementMode = 'wall';
|
||||
} else if (this.arPlacement === 'ceiling') {
|
||||
arRenderer.placementMode = 'ceiling';
|
||||
} else {
|
||||
arRenderer.placementMode = 'floor';
|
||||
}
|
||||
await arRenderer.present(this[$scene], this.xrEnvironment);
|
||||
} catch (error) {
|
||||
console.warn('Error while trying to present in AR with WebXR');
|
||||
console.error(error);
|
||||
await this[$renderer].arRenderer.stopPresenting();
|
||||
isWebXRBlocked = true;
|
||||
console.warn('Falling back to next ar-mode');
|
||||
await this[$selectARMode]();
|
||||
this.activateAR();
|
||||
} finally {
|
||||
this[$selectARMode]();
|
||||
}
|
||||
}
|
||||
|
||||
async[$triggerLoad]() {
|
||||
if (!this.loaded) {
|
||||
this[$preload] = true;
|
||||
this[$updateSource]();
|
||||
await waitForEvent(this, 'load');
|
||||
this[$preload] = false;
|
||||
}
|
||||
}
|
||||
|
||||
[$shouldAttemptPreload](): boolean {
|
||||
return super[$shouldAttemptPreload]() || this[$preload];
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a URL and a title string, and attempts to launch Scene Viewer on
|
||||
* the current device.
|
||||
*/
|
||||
[$openSceneViewer]() {
|
||||
const location = self.location.toString();
|
||||
const locationUrl = new URL(location);
|
||||
const modelUrl = new URL(this.src!, location);
|
||||
if (modelUrl.hash)
|
||||
modelUrl.hash = '';
|
||||
const params = new URLSearchParams(modelUrl.search);
|
||||
|
||||
locationUrl.hash = noArViewerSigil;
|
||||
|
||||
// modelUrl can contain title/link/sound etc.
|
||||
params.set('mode', 'ar_preferred');
|
||||
if (!params.has('disable_occlusion')) {
|
||||
params.set('disable_occlusion', 'true');
|
||||
}
|
||||
if (this.arScale === 'fixed') {
|
||||
params.set('resizable', 'false');
|
||||
}
|
||||
if (this.arPlacement === 'wall') {
|
||||
params.set('enable_vertical_placement', 'true');
|
||||
}
|
||||
if (params.has('sound')) {
|
||||
const soundUrl = new URL(params.get('sound')!, location);
|
||||
params.set('sound', soundUrl.toString());
|
||||
}
|
||||
if (params.has('link')) {
|
||||
const linkUrl = new URL(params.get('link')!, location);
|
||||
params.set('link', linkUrl.toString());
|
||||
}
|
||||
|
||||
const intent = `intent://arvr.google.com/scene-viewer/1.2?${
|
||||
params.toString() + '&file=' +
|
||||
encodeURIComponent(
|
||||
modelUrl
|
||||
.toString())}#Intent;scheme=https;package=com.google.android.googlequicksearchbox;action=android.intent.action.VIEW;S.browser_fallback_url=${
|
||||
encodeURIComponent(locationUrl.toString())};end;`;
|
||||
|
||||
const undoHashChange = () => {
|
||||
if (self.location.hash === noArViewerSigil) {
|
||||
isSceneViewerBlocked = true;
|
||||
// The new history will be the current URL with a new hash.
|
||||
// Go back one step so that we reset to the expected URL.
|
||||
// NOTE(cdata): this should not invoke any browser-level navigation
|
||||
// because hash-only changes modify the URL in-place without
|
||||
// navigating:
|
||||
self.history.back();
|
||||
console.warn('Error while trying to present in AR with Scene Viewer');
|
||||
console.warn('Falling back to next ar-mode');
|
||||
this[$selectARMode]();
|
||||
// Would be nice to activateAR() here, but webXR fails due to not
|
||||
// seeing a user activation.
|
||||
}
|
||||
};
|
||||
|
||||
self.addEventListener('hashchange', undoHashChange, {once: true});
|
||||
|
||||
this[$arAnchor].setAttribute('href', intent);
|
||||
console.log('Attempting to present in AR with Scene Viewer...');
|
||||
this[$arAnchor].click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Takes a URL to a USDZ file and sets the appropriate fields so that
|
||||
* Safari iOS can intent to their AR Quick Look.
|
||||
*/
|
||||
async[$openIOSARQuickLook]() {
|
||||
const generateUsdz = !this.iosSrc;
|
||||
|
||||
this[$arButtonContainer].classList.remove('enabled');
|
||||
|
||||
const objectURL = generateUsdz ? await this.prepareUSDZ() : this.iosSrc!;
|
||||
const modelUrl = new URL(objectURL, self.location.toString());
|
||||
|
||||
if (generateUsdz) {
|
||||
const location = self.location.toString();
|
||||
const locationUrl = new URL(location);
|
||||
const srcUrl = new URL(this.src!, locationUrl);
|
||||
if (srcUrl.hash) {
|
||||
modelUrl.hash = srcUrl.hash;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.arScale === 'fixed') {
|
||||
if (modelUrl.hash) {
|
||||
modelUrl.hash += '&';
|
||||
}
|
||||
modelUrl.hash += 'allowsContentScaling=0';
|
||||
}
|
||||
|
||||
const anchor = this[$arAnchor];
|
||||
anchor.setAttribute('rel', 'ar');
|
||||
const img = document.createElement('img');
|
||||
anchor.appendChild(img);
|
||||
anchor.setAttribute('href', modelUrl.toString());
|
||||
if (generateUsdz) {
|
||||
anchor.setAttribute('download', 'model.usdz');
|
||||
}
|
||||
|
||||
// attach anchor to shadow DOM to ensure iOS16 ARQL banner click message
|
||||
// event propagation
|
||||
anchor.style.display = 'none';
|
||||
if (!anchor.isConnected)
|
||||
this.shadowRoot!.appendChild(anchor);
|
||||
|
||||
console.log('Attempting to present in AR with Quick Look...');
|
||||
anchor.click();
|
||||
anchor.removeChild(img);
|
||||
if (generateUsdz) {
|
||||
URL.revokeObjectURL(objectURL);
|
||||
}
|
||||
this[$arButtonContainer].classList.add('enabled');
|
||||
}
|
||||
|
||||
async prepareUSDZ(): Promise<string> {
|
||||
const updateSourceProgress =
|
||||
this[$progressTracker].beginActivity('usdz-conversion');
|
||||
|
||||
await this[$triggerLoad]();
|
||||
|
||||
const {model, shadow, target} = this[$scene];
|
||||
if (model == null) {
|
||||
return '';
|
||||
}
|
||||
|
||||
let visible = false;
|
||||
|
||||
// Remove shadow from export
|
||||
if (shadow != null) {
|
||||
visible = shadow.visible;
|
||||
shadow.visible = false;
|
||||
}
|
||||
|
||||
updateSourceProgress(0.2);
|
||||
|
||||
const exporter = new USDZExporter();
|
||||
|
||||
target.remove(model);
|
||||
model.position.copy(target.position);
|
||||
model.updateWorldMatrix(false, true);
|
||||
|
||||
const arraybuffer = await exporter.parseAsync(model, {
|
||||
maxTextureSize: isNaN(this.arUsdzMaxTextureSize as any) ?
|
||||
Infinity :
|
||||
Math.max(parseInt(this.arUsdzMaxTextureSize), 16),
|
||||
});
|
||||
|
||||
model.position.set(0, 0, 0);
|
||||
target.add(model);
|
||||
|
||||
const blob = new Blob([arraybuffer], {
|
||||
type: 'model/vnd.usdz+zip',
|
||||
});
|
||||
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
updateSourceProgress(1);
|
||||
|
||||
if (shadow != null) {
|
||||
shadow.visible = visible;
|
||||
}
|
||||
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
return ARModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,956 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {Event, PerspectiveCamera, Spherical, Vector3} from 'three';
|
||||
|
||||
import {style} from '../decorators.js';
|
||||
import ModelViewerElementBase, {$ariaLabel, $container, $getModelIsVisible, $loadedTime, $needsRender, $onModelLoad, $onResize, $renderer, $scene, $tick, $updateStatus, $userInputElement, toVector3D, Vector3D} from '../model-viewer-base.js';
|
||||
import {degreesToRadians, normalizeUnit} from '../styles/conversions.js';
|
||||
import {EvaluatedStyle, Intrinsics, SphericalIntrinsics, StyleEvaluator, Vector3Intrinsics} from '../styles/evaluators.js';
|
||||
import {IdentNode, NumberNode, numberNode, parseExpressions} from '../styles/parsers.js';
|
||||
import {DECAY_MILLISECONDS} from '../three-components/Damper.js';
|
||||
import {ChangeSource, PointerChangeEvent, SmoothControls} from '../three-components/SmoothControls.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
import {Path, timeline, TimingFunction} from '../utilities/animation.js';
|
||||
|
||||
|
||||
|
||||
// NOTE(cdata): The following "animation" timing functions are deliberately
|
||||
// being used in favor of CSS animations. In Safari 12.1 and 13, CSS animations
|
||||
// would cause the interaction prompt to glitch unexpectedly
|
||||
// @see https://github.com/google/model-viewer/issues/839
|
||||
const PROMPT_ANIMATION_TIME = 5000;
|
||||
|
||||
// For timing purposes, a "frame" is a timing agnostic relative unit of time
|
||||
// and a "value" is a target value for the Frame.
|
||||
const wiggle = timeline({
|
||||
initialValue: 0,
|
||||
keyframes: [
|
||||
{frames: 5, value: -1},
|
||||
{frames: 1, value: -1},
|
||||
{frames: 8, value: 1},
|
||||
{frames: 1, value: 1},
|
||||
{frames: 5, value: 0},
|
||||
{frames: 18, value: 0}
|
||||
]
|
||||
});
|
||||
|
||||
const fade = timeline({
|
||||
initialValue: 0,
|
||||
keyframes: [
|
||||
{frames: 1, value: 1},
|
||||
{frames: 5, value: 1},
|
||||
{frames: 1, value: 0},
|
||||
{frames: 6, value: 0}
|
||||
]
|
||||
});
|
||||
|
||||
export const DEFAULT_FOV_DEG = 30;
|
||||
export const DEFAULT_MIN_FOV_DEG = 12;
|
||||
|
||||
export const DEFAULT_CAMERA_ORBIT = '0deg 75deg 105%';
|
||||
const DEFAULT_CAMERA_TARGET = 'auto auto auto';
|
||||
const DEFAULT_FIELD_OF_VIEW = 'auto';
|
||||
|
||||
const MINIMUM_RADIUS_RATIO = 2.2;
|
||||
|
||||
const AZIMUTHAL_QUADRANT_LABELS = ['front', 'right', 'back', 'left'];
|
||||
const POLAR_TRIENT_LABELS = ['upper-', '', 'lower-'];
|
||||
|
||||
export const DEFAULT_INTERACTION_PROMPT_THRESHOLD = 3000;
|
||||
export const INTERACTION_PROMPT = '. Use mouse, touch or arrow keys to move.';
|
||||
|
||||
export interface CameraChangeDetails {
|
||||
source: ChangeSource;
|
||||
}
|
||||
|
||||
export interface SphericalPosition {
|
||||
theta: number; // equator angle around the y (up) axis.
|
||||
phi: number; // polar angle from the y (up) axis.
|
||||
radius: number;
|
||||
toString(): string;
|
||||
}
|
||||
|
||||
export interface Finger {
|
||||
x: Path;
|
||||
y: Path;
|
||||
}
|
||||
|
||||
export interface A11yTranslationsInterface {
|
||||
left: string;
|
||||
right: string;
|
||||
front: string;
|
||||
back: string;
|
||||
'upper-left': string;
|
||||
'upper-right': string;
|
||||
'upper-front': string;
|
||||
'upper-back': string;
|
||||
'lower-left': string;
|
||||
'lower-right': string;
|
||||
'lower-front': string;
|
||||
'lower-back': string;
|
||||
'interaction-prompt': string;
|
||||
}
|
||||
|
||||
export type InteractionPromptStrategy = 'auto'|'none';
|
||||
export type InteractionPromptStyle = 'basic'|'wiggle';
|
||||
export type TouchAction = 'pan-y'|'pan-x'|'none';
|
||||
|
||||
export const InteractionPromptStrategy:
|
||||
{[index: string]: InteractionPromptStrategy} = {
|
||||
AUTO: 'auto',
|
||||
NONE: 'none'
|
||||
};
|
||||
|
||||
export const InteractionPromptStyle:
|
||||
{[index: string]: InteractionPromptStyle} = {
|
||||
BASIC: 'basic',
|
||||
WIGGLE: 'wiggle'
|
||||
};
|
||||
|
||||
export const TouchAction: {[index: string]: TouchAction} = {
|
||||
PAN_Y: 'pan-y',
|
||||
PAN_X: 'pan-x',
|
||||
NONE: 'none'
|
||||
};
|
||||
|
||||
export const fieldOfViewIntrinsics = () => {
|
||||
return {
|
||||
basis:
|
||||
[degreesToRadians(numberNode(DEFAULT_FOV_DEG, 'deg')) as
|
||||
NumberNode<'rad'>],
|
||||
keywords: {auto: [null]}
|
||||
};
|
||||
};
|
||||
|
||||
const minFieldOfViewIntrinsics = () => {
|
||||
return {
|
||||
basis:
|
||||
[degreesToRadians(numberNode(DEFAULT_MIN_FOV_DEG, 'deg')) as
|
||||
NumberNode<'rad'>],
|
||||
keywords: {auto: [null]}
|
||||
};
|
||||
};
|
||||
|
||||
export const cameraOrbitIntrinsics = (() => {
|
||||
const defaultTerms =
|
||||
parseExpressions(DEFAULT_CAMERA_ORBIT)[0]
|
||||
.terms as [NumberNode<'rad'>, NumberNode<'rad'>, IdentNode];
|
||||
|
||||
const theta = normalizeUnit(defaultTerms[0]) as NumberNode<'rad'>;
|
||||
const phi = normalizeUnit(defaultTerms[1]) as NumberNode<'rad'>;
|
||||
|
||||
return (element: ModelViewerElementBase) => {
|
||||
const radius = element[$scene].idealCameraDistance();
|
||||
|
||||
return {
|
||||
basis: [theta, phi, numberNode(radius, 'm')],
|
||||
keywords: {auto: [null, null, numberNode(105, '%')]}
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
const minCameraOrbitIntrinsics = (element: ModelViewerElementBase&
|
||||
ControlsInterface) => {
|
||||
const radius = MINIMUM_RADIUS_RATIO * element[$scene].boundingSphere.radius;
|
||||
|
||||
return {
|
||||
basis: [
|
||||
numberNode(-Infinity, 'rad'),
|
||||
numberNode(0, 'rad'),
|
||||
numberNode(radius, 'm')
|
||||
],
|
||||
keywords: {auto: [null, null, null]}
|
||||
};
|
||||
};
|
||||
|
||||
const maxCameraOrbitIntrinsics = (element: ModelViewerElementBase) => {
|
||||
const orbitIntrinsics = cameraOrbitIntrinsics(element);
|
||||
const evaluator = new StyleEvaluator([], orbitIntrinsics);
|
||||
const defaultRadius = evaluator.evaluate()[2];
|
||||
|
||||
return {
|
||||
basis: [
|
||||
numberNode(Infinity, 'rad'),
|
||||
numberNode(Math.PI, 'rad'),
|
||||
numberNode(defaultRadius, 'm')
|
||||
],
|
||||
keywords: {auto: [null, null, null]}
|
||||
};
|
||||
};
|
||||
|
||||
export const cameraTargetIntrinsics = (element: ModelViewerElementBase) => {
|
||||
const center = element[$scene].boundingBox.getCenter(new Vector3());
|
||||
|
||||
return {
|
||||
basis: [
|
||||
numberNode(center.x, 'm'),
|
||||
numberNode(center.y, 'm'),
|
||||
numberNode(center.z, 'm')
|
||||
],
|
||||
keywords: {auto: [null, null, null]}
|
||||
};
|
||||
};
|
||||
|
||||
const HALF_PI = Math.PI / 2.0;
|
||||
const THIRD_PI = Math.PI / 3.0;
|
||||
const QUARTER_PI = HALF_PI / 2.0;
|
||||
const TAU = 2.0 * Math.PI;
|
||||
|
||||
export const $controls = Symbol('controls');
|
||||
export const $panElement = Symbol('panElement');
|
||||
export const $promptElement = Symbol('promptElement');
|
||||
export const $promptAnimatedContainer = Symbol('promptAnimatedContainer');
|
||||
export const $fingerAnimatedContainers = Symbol('fingerAnimatedContainers');
|
||||
|
||||
const $deferInteractionPrompt = Symbol('deferInteractionPrompt');
|
||||
const $updateAria = Symbol('updateAria');
|
||||
const $a11y = Symbol('a11y');
|
||||
const $updateA11y = Symbol('updateA11y');
|
||||
const $updateCameraForRadius = Symbol('updateCameraForRadius');
|
||||
|
||||
const $cancelPrompts = Symbol('cancelPrompts');
|
||||
const $onChange = Symbol('onChange');
|
||||
const $onPointerChange = Symbol('onPointerChange');
|
||||
|
||||
const $waitingToPromptUser = Symbol('waitingToPromptUser');
|
||||
const $userHasInteracted = Symbol('userHasInteracted');
|
||||
const $promptElementVisibleTime = Symbol('promptElementVisibleTime');
|
||||
const $lastPromptOffset = Symbol('lastPromptOffset');
|
||||
const $cancellationSource = Symbol('cancellationSource');
|
||||
|
||||
const $lastSpherical = Symbol('lastSpherical');
|
||||
const $jumpCamera = Symbol('jumpCamera');
|
||||
const $initialized = Symbol('initialized');
|
||||
const $maintainThetaPhi = Symbol('maintainThetaPhi');
|
||||
|
||||
const $syncCameraOrbit = Symbol('syncCameraOrbit');
|
||||
const $syncFieldOfView = Symbol('syncFieldOfView');
|
||||
const $syncCameraTarget = Symbol('syncCameraTarget');
|
||||
|
||||
const $syncMinCameraOrbit = Symbol('syncMinCameraOrbit');
|
||||
const $syncMaxCameraOrbit = Symbol('syncMaxCameraOrbit');
|
||||
const $syncMinFieldOfView = Symbol('syncMinFieldOfView');
|
||||
const $syncMaxFieldOfView = Symbol('syncMaxFieldOfView');
|
||||
|
||||
export declare interface ControlsInterface {
|
||||
cameraControls: boolean;
|
||||
cameraOrbit: string;
|
||||
cameraTarget: string;
|
||||
fieldOfView: string;
|
||||
minCameraOrbit: string;
|
||||
maxCameraOrbit: string;
|
||||
minFieldOfView: string;
|
||||
maxFieldOfView: string;
|
||||
interactionPrompt: InteractionPromptStrategy;
|
||||
interactionPromptStyle: InteractionPromptStyle;
|
||||
interactionPromptThreshold: number;
|
||||
orbitSensitivity: number;
|
||||
zoomSensitivity: number;
|
||||
panSensitivity: number;
|
||||
touchAction: TouchAction;
|
||||
interpolationDecay: number;
|
||||
disableZoom: boolean;
|
||||
disablePan: boolean;
|
||||
disableTap: boolean;
|
||||
a11y: A11yTranslationsInterface|string|null;
|
||||
getCameraOrbit(): SphericalPosition;
|
||||
getCameraTarget(): Vector3D;
|
||||
getFieldOfView(): number;
|
||||
getMinimumFieldOfView(): number;
|
||||
getMaximumFieldOfView(): number;
|
||||
getIdealAspect(): number;
|
||||
jumpCameraToGoal(): void;
|
||||
updateFraming(): Promise<void>;
|
||||
resetInteractionPrompt(): void;
|
||||
zoom(keyPresses: number): void;
|
||||
interact(duration: number, finger0: Finger, finger1?: Finger): void;
|
||||
inputSensitivity: number;
|
||||
}
|
||||
|
||||
export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<ControlsInterface>&T => {
|
||||
class ControlsModelViewerElement extends ModelViewerElement {
|
||||
@property({type: Boolean, attribute: 'camera-controls'})
|
||||
cameraControls: boolean = false;
|
||||
|
||||
@style({
|
||||
intrinsics: cameraOrbitIntrinsics,
|
||||
observeEffects: true,
|
||||
updateHandler: $syncCameraOrbit
|
||||
})
|
||||
@property({type: String, attribute: 'camera-orbit', hasChanged: () => true})
|
||||
cameraOrbit: string = DEFAULT_CAMERA_ORBIT;
|
||||
|
||||
@style({
|
||||
intrinsics: cameraTargetIntrinsics,
|
||||
observeEffects: true,
|
||||
updateHandler: $syncCameraTarget
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'camera-target', hasChanged: () => true})
|
||||
cameraTarget: string = DEFAULT_CAMERA_TARGET;
|
||||
|
||||
@style({
|
||||
intrinsics: fieldOfViewIntrinsics,
|
||||
observeEffects: true,
|
||||
updateHandler: $syncFieldOfView
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'field-of-view', hasChanged: () => true})
|
||||
fieldOfView: string = DEFAULT_FIELD_OF_VIEW;
|
||||
|
||||
@style({
|
||||
intrinsics: minCameraOrbitIntrinsics,
|
||||
updateHandler: $syncMinCameraOrbit
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'min-camera-orbit', hasChanged: () => true})
|
||||
minCameraOrbit: string = 'auto';
|
||||
|
||||
@style({
|
||||
intrinsics: maxCameraOrbitIntrinsics,
|
||||
updateHandler: $syncMaxCameraOrbit
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'max-camera-orbit', hasChanged: () => true})
|
||||
maxCameraOrbit: string = 'auto';
|
||||
|
||||
@style({
|
||||
intrinsics: minFieldOfViewIntrinsics,
|
||||
updateHandler: $syncMinFieldOfView
|
||||
})
|
||||
@property(
|
||||
{type: String, attribute: 'min-field-of-view', hasChanged: () => true})
|
||||
minFieldOfView: string = 'auto';
|
||||
|
||||
@style(
|
||||
{intrinsics: fieldOfViewIntrinsics, updateHandler: $syncMaxFieldOfView})
|
||||
@property(
|
||||
{type: String, attribute: 'max-field-of-view', hasChanged: () => true})
|
||||
maxFieldOfView: string = 'auto';
|
||||
|
||||
@property({type: Number, attribute: 'interaction-prompt-threshold'})
|
||||
interactionPromptThreshold: number = DEFAULT_INTERACTION_PROMPT_THRESHOLD;
|
||||
|
||||
@property({type: String, attribute: 'interaction-prompt'})
|
||||
interactionPrompt: InteractionPromptStrategy =
|
||||
InteractionPromptStrategy.AUTO;
|
||||
|
||||
@property({type: String, attribute: 'interaction-prompt-style'})
|
||||
interactionPromptStyle: InteractionPromptStyle =
|
||||
InteractionPromptStyle.WIGGLE;
|
||||
|
||||
@property({type: Number, attribute: 'orbit-sensitivity'})
|
||||
orbitSensitivity: number = 1;
|
||||
|
||||
@property({type: Number, attribute: 'zoom-sensitivity'})
|
||||
zoomSensitivity: number = 1;
|
||||
|
||||
@property({type: Number, attribute: 'pan-sensitivity'})
|
||||
panSensitivity: number = 1;
|
||||
|
||||
@property({type: String, attribute: 'touch-action'})
|
||||
touchAction: TouchAction = TouchAction.NONE;
|
||||
|
||||
@property({type: Boolean, attribute: 'disable-zoom'})
|
||||
disableZoom: boolean = false;
|
||||
|
||||
@property({type: Boolean, attribute: 'disable-pan'})
|
||||
disablePan: boolean = false;
|
||||
|
||||
@property({type: Boolean, attribute: 'disable-tap'})
|
||||
disableTap: boolean = false;
|
||||
|
||||
@property({type: Number, attribute: 'interpolation-decay'})
|
||||
interpolationDecay: number = DECAY_MILLISECONDS;
|
||||
|
||||
@property() a11y: A11yTranslationsInterface|string|null = null;
|
||||
|
||||
protected[$promptElement] =
|
||||
this.shadowRoot!.querySelector('.interaction-prompt') as HTMLElement;
|
||||
protected[$promptAnimatedContainer] =
|
||||
this.shadowRoot!.querySelector('#prompt') as HTMLElement;
|
||||
protected[$fingerAnimatedContainers]: HTMLElement[] = [
|
||||
this.shadowRoot!.querySelector('#finger0')!,
|
||||
this.shadowRoot!.querySelector('#finger1')!
|
||||
];
|
||||
protected[$panElement] =
|
||||
this.shadowRoot!.querySelector('.pan-target') as HTMLElement;
|
||||
|
||||
protected[$lastPromptOffset] = 0;
|
||||
protected[$promptElementVisibleTime] = Infinity;
|
||||
protected[$userHasInteracted] = false;
|
||||
protected[$waitingToPromptUser] = false;
|
||||
protected[$cancellationSource] = ChangeSource.AUTOMATIC;
|
||||
|
||||
protected[$controls] = new SmoothControls(
|
||||
this[$scene].camera as PerspectiveCamera, this[$userInputElement],
|
||||
this[$scene]);
|
||||
|
||||
protected[$lastSpherical] = new Spherical();
|
||||
protected[$jumpCamera] = false;
|
||||
protected[$initialized] = false;
|
||||
protected[$maintainThetaPhi] = false;
|
||||
protected[$a11y] = {} as A11yTranslationsInterface;
|
||||
|
||||
get inputSensitivity(): number {
|
||||
return this[$controls].inputSensitivity;
|
||||
}
|
||||
|
||||
set inputSensitivity(value: number) {
|
||||
this[$controls].inputSensitivity = value;
|
||||
}
|
||||
|
||||
getCameraOrbit(): SphericalPosition {
|
||||
const {theta, phi, radius} = this[$lastSpherical];
|
||||
return {
|
||||
theta,
|
||||
phi,
|
||||
radius,
|
||||
toString() {
|
||||
return `${this.theta}rad ${this.phi}rad ${this.radius}m`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getCameraTarget(): Vector3D {
|
||||
return toVector3D(
|
||||
this[$renderer].isPresenting ? this[$renderer].arRenderer.target :
|
||||
this[$scene].getDynamicTarget());
|
||||
}
|
||||
|
||||
getFieldOfView(): number {
|
||||
return this[$controls].getFieldOfView();
|
||||
}
|
||||
|
||||
// Provided so user code does not have to parse these from attributes.
|
||||
getMinimumFieldOfView(): number {
|
||||
return this[$controls].options.minimumFieldOfView!;
|
||||
}
|
||||
|
||||
getMaximumFieldOfView(): number {
|
||||
return this[$controls].options.maximumFieldOfView!;
|
||||
}
|
||||
|
||||
getIdealAspect(): number {
|
||||
return this[$scene].idealAspect;
|
||||
}
|
||||
|
||||
jumpCameraToGoal() {
|
||||
this[$jumpCamera] = true;
|
||||
this.requestUpdate($jumpCamera, false);
|
||||
}
|
||||
|
||||
resetInteractionPrompt() {
|
||||
this[$lastPromptOffset] = 0;
|
||||
this[$promptElementVisibleTime] = Infinity;
|
||||
this[$userHasInteracted] = false;
|
||||
this[$waitingToPromptUser] =
|
||||
this.interactionPrompt === InteractionPromptStrategy.AUTO &&
|
||||
this.cameraControls;
|
||||
}
|
||||
|
||||
zoom(keyPresses: number) {
|
||||
const event = new WheelEvent('wheel', {deltaY: -30 * keyPresses});
|
||||
this[$userInputElement].dispatchEvent(event);
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
this[$controls].addEventListener(
|
||||
'user-interaction', this[$cancelPrompts]);
|
||||
this[$controls].addEventListener(
|
||||
'pointer-change-start',
|
||||
this[$onPointerChange] as (event: Event) => void);
|
||||
this[$controls].addEventListener(
|
||||
'pointer-change-end',
|
||||
this[$onPointerChange] as (event: Event) => void);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this[$controls].removeEventListener(
|
||||
'user-interaction', this[$cancelPrompts]);
|
||||
this[$controls].removeEventListener(
|
||||
'pointer-change-start',
|
||||
this[$onPointerChange] as (event: Event) => void);
|
||||
this[$controls].removeEventListener(
|
||||
'pointer-change-end',
|
||||
this[$onPointerChange] as (event: Event) => void);
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string|number|symbol, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
const controls = this[$controls];
|
||||
const scene = this[$scene];
|
||||
|
||||
if (changedProperties.has('cameraControls')) {
|
||||
if (this.cameraControls) {
|
||||
controls.enableInteraction();
|
||||
if (this.interactionPrompt === InteractionPromptStrategy.AUTO) {
|
||||
this[$waitingToPromptUser] = true;
|
||||
}
|
||||
} else {
|
||||
controls.disableInteraction();
|
||||
this[$deferInteractionPrompt]();
|
||||
}
|
||||
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
|
||||
}
|
||||
|
||||
if (changedProperties.has('disableZoom')) {
|
||||
controls.disableZoom = this.disableZoom;
|
||||
}
|
||||
|
||||
if (changedProperties.has('disablePan')) {
|
||||
controls.enablePan = !this.disablePan;
|
||||
}
|
||||
|
||||
if (changedProperties.has('disableTap')) {
|
||||
controls.enableTap = !this.disableTap;
|
||||
}
|
||||
|
||||
if (changedProperties.has('interactionPrompt') ||
|
||||
changedProperties.has('cameraControls') ||
|
||||
changedProperties.has('src')) {
|
||||
if (this.interactionPrompt === InteractionPromptStrategy.AUTO &&
|
||||
this.cameraControls && !this[$userHasInteracted]) {
|
||||
this[$waitingToPromptUser] = true;
|
||||
} else {
|
||||
this[$deferInteractionPrompt]();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has('interactionPromptStyle')) {
|
||||
this[$promptAnimatedContainer].style.opacity =
|
||||
this.interactionPromptStyle == InteractionPromptStyle.BASIC ? '1' :
|
||||
'0';
|
||||
}
|
||||
|
||||
if (changedProperties.has('touchAction')) {
|
||||
const touchAction = this.touchAction;
|
||||
controls.applyOptions({touchAction});
|
||||
controls.updateTouchActionStyle();
|
||||
}
|
||||
|
||||
if (changedProperties.has('orbitSensitivity')) {
|
||||
controls.orbitSensitivity = this.orbitSensitivity;
|
||||
}
|
||||
|
||||
if (changedProperties.has('zoomSensitivity')) {
|
||||
controls.zoomSensitivity = this.zoomSensitivity;
|
||||
}
|
||||
|
||||
if (changedProperties.has('panSensitivity')) {
|
||||
controls.panSensitivity = this.panSensitivity;
|
||||
}
|
||||
|
||||
if (changedProperties.has('interpolationDecay')) {
|
||||
controls.setDamperDecayTime(this.interpolationDecay);
|
||||
scene.setTargetDamperDecayTime(this.interpolationDecay);
|
||||
}
|
||||
|
||||
if (changedProperties.has('a11y')) {
|
||||
this[$updateA11y]();
|
||||
}
|
||||
|
||||
if (this[$jumpCamera] === true) {
|
||||
Promise.resolve().then(() => {
|
||||
controls.jumpToGoal();
|
||||
scene.jumpToGoal();
|
||||
this[$onChange]();
|
||||
this[$jumpCamera] = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async updateFraming() {
|
||||
const scene = this[$scene];
|
||||
const oldFramedFoV = scene.adjustedFoV(scene.framedFoVDeg);
|
||||
|
||||
await scene.updateFraming();
|
||||
|
||||
const newFramedFoV = scene.adjustedFoV(scene.framedFoVDeg);
|
||||
const zoom = this[$controls].getFieldOfView() / oldFramedFoV;
|
||||
this[$controls].setFieldOfView(newFramedFoV * zoom);
|
||||
this[$maintainThetaPhi] = true;
|
||||
|
||||
this.requestUpdate('maxFieldOfView');
|
||||
this.requestUpdate('fieldOfView');
|
||||
this.requestUpdate('minCameraOrbit');
|
||||
this.requestUpdate('maxCameraOrbit');
|
||||
this.requestUpdate('cameraOrbit');
|
||||
await this.updateComplete;
|
||||
}
|
||||
|
||||
interact(duration: number, finger0: Finger, finger1?: Finger) {
|
||||
const inputElement = this[$userInputElement];
|
||||
const fingerElements = this[$fingerAnimatedContainers];
|
||||
|
||||
if (fingerElements[0].style.opacity === '1') {
|
||||
console.warn(
|
||||
'interact() failed because an existing interaction is running.')
|
||||
return;
|
||||
}
|
||||
|
||||
const xy = new Array<{x: TimingFunction, y: TimingFunction}>();
|
||||
xy.push({x: timeline(finger0.x), y: timeline(finger0.y)});
|
||||
const positions = [{x: xy[0].x(0), y: xy[0].y(0)}];
|
||||
|
||||
if (finger1 != null) {
|
||||
xy.push({x: timeline(finger1.x), y: timeline(finger1.y)});
|
||||
positions.push({x: xy[1].x(0), y: xy[1].y(0)});
|
||||
}
|
||||
|
||||
let startTime = performance.now();
|
||||
const {width, height} = this[$scene];
|
||||
const rect = this.getBoundingClientRect();
|
||||
|
||||
const dispatchTouches = (type: string) => {
|
||||
for (const [i, position] of positions.entries()) {
|
||||
const {style} = fingerElements[i];
|
||||
style.transform = `translateX(${width * position.x}px) translateY(${
|
||||
height * position.y}px)`;
|
||||
if (type === 'pointerdown') {
|
||||
style.opacity = '1';
|
||||
} else if (type === 'pointerup') {
|
||||
style.opacity = '0';
|
||||
}
|
||||
|
||||
const init = {
|
||||
pointerId: i - 5678, // help ensure uniqueness
|
||||
pointerType: 'touch',
|
||||
target: inputElement,
|
||||
clientX: width * position.x + rect.x,
|
||||
clientY: height * position.y + rect.y,
|
||||
altKey: true // flag that this is not a user interaction
|
||||
} as PointerEventInit;
|
||||
|
||||
inputElement.dispatchEvent(new PointerEvent(type, init));
|
||||
}
|
||||
};
|
||||
|
||||
const moveTouches = () => {
|
||||
// Cancel interaction if something else moves the camera or input is
|
||||
// removed from the DOM.
|
||||
const changeSource = this[$cancellationSource];
|
||||
if (changeSource !== ChangeSource.AUTOMATIC ||
|
||||
!inputElement.isConnected) {
|
||||
for (const fingerElement of this[$fingerAnimatedContainers]) {
|
||||
fingerElement.style.opacity = '0';
|
||||
}
|
||||
dispatchTouches('pointercancel');
|
||||
this.dispatchEvent(new CustomEvent<CameraChangeDetails>(
|
||||
'interact-stopped', {detail: {source: changeSource}}));
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
return;
|
||||
}
|
||||
|
||||
const time = Math.min(1, (performance.now() - startTime) / duration);
|
||||
for (const [i, position] of positions.entries()) {
|
||||
position.x = xy[i].x(time);
|
||||
position.y = xy[i].y(time);
|
||||
}
|
||||
dispatchTouches('pointermove');
|
||||
|
||||
if (time < 1) {
|
||||
requestAnimationFrame(moveTouches);
|
||||
} else {
|
||||
dispatchTouches('pointerup');
|
||||
this.dispatchEvent(new CustomEvent<CameraChangeDetails>(
|
||||
'interact-stopped', {detail: {source: ChangeSource.AUTOMATIC}}));
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
}
|
||||
};
|
||||
|
||||
const onVisibilityChange = () => {
|
||||
let elapsed = 0;
|
||||
if (document.visibilityState === 'hidden') {
|
||||
elapsed = performance.now() - startTime;
|
||||
} else {
|
||||
startTime = performance.now() - elapsed;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', onVisibilityChange);
|
||||
|
||||
dispatchTouches('pointerdown');
|
||||
|
||||
this[$cancellationSource] = ChangeSource.AUTOMATIC;
|
||||
|
||||
requestAnimationFrame(moveTouches);
|
||||
}
|
||||
|
||||
[$syncFieldOfView](style: EvaluatedStyle<Intrinsics<['rad']>>) {
|
||||
const controls = this[$controls];
|
||||
const scene = this[$scene];
|
||||
scene.framedFoVDeg = style[0] * 180 / Math.PI;
|
||||
controls.changeSource = ChangeSource.NONE;
|
||||
controls.setFieldOfView(scene.adjustedFoV(scene.framedFoVDeg));
|
||||
this[$cancelPrompts]();
|
||||
}
|
||||
|
||||
[$syncCameraOrbit](style: EvaluatedStyle<SphericalIntrinsics>) {
|
||||
const controls = this[$controls];
|
||||
if (this[$maintainThetaPhi]) {
|
||||
const {theta, phi} = this.getCameraOrbit();
|
||||
style[0] = theta;
|
||||
style[1] = phi;
|
||||
this[$maintainThetaPhi] = false;
|
||||
}
|
||||
controls.changeSource = ChangeSource.NONE;
|
||||
controls.setOrbit(style[0], style[1], style[2]);
|
||||
this[$cancelPrompts]();
|
||||
}
|
||||
|
||||
[$syncMinCameraOrbit](style: EvaluatedStyle<SphericalIntrinsics>) {
|
||||
this[$controls].applyOptions({
|
||||
minimumAzimuthalAngle: style[0],
|
||||
minimumPolarAngle: style[1],
|
||||
minimumRadius: style[2]
|
||||
});
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$syncMaxCameraOrbit](style: EvaluatedStyle<SphericalIntrinsics>) {
|
||||
this[$controls].applyOptions({
|
||||
maximumAzimuthalAngle: style[0],
|
||||
maximumPolarAngle: style[1],
|
||||
maximumRadius: style[2]
|
||||
});
|
||||
this[$updateCameraForRadius](style[2]);
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$syncMinFieldOfView](style: EvaluatedStyle<Intrinsics<['rad']>>) {
|
||||
this[$controls].applyOptions(
|
||||
{minimumFieldOfView: style[0] * 180 / Math.PI});
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$syncMaxFieldOfView](style: EvaluatedStyle<Intrinsics<['rad']>>) {
|
||||
const fov = this[$scene].adjustedFoV(style[0] * 180 / Math.PI);
|
||||
this[$controls].applyOptions({maximumFieldOfView: fov});
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$syncCameraTarget](style: EvaluatedStyle<Vector3Intrinsics>) {
|
||||
const [x, y, z] = style;
|
||||
if (!this[$renderer].arRenderer.isPresenting) {
|
||||
this[$scene].setTarget(x, y, z);
|
||||
}
|
||||
this[$controls].changeSource = ChangeSource.NONE;
|
||||
this[$renderer].arRenderer.updateTarget();
|
||||
this[$cancelPrompts]();
|
||||
}
|
||||
|
||||
[$tick](time: number, delta: number) {
|
||||
super[$tick](time, delta);
|
||||
|
||||
if (this[$renderer].isPresenting || !this[$getModelIsVisible]()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const controls = this[$controls];
|
||||
const scene = this[$scene];
|
||||
|
||||
const now = performance.now();
|
||||
if (this[$waitingToPromptUser]) {
|
||||
if (this.loaded &&
|
||||
now > this[$loadedTime] + this.interactionPromptThreshold) {
|
||||
this[$waitingToPromptUser] = false;
|
||||
this[$promptElementVisibleTime] = now;
|
||||
|
||||
this[$promptElement].classList.add('visible');
|
||||
}
|
||||
}
|
||||
|
||||
if (isFinite(this[$promptElementVisibleTime]) &&
|
||||
this.interactionPromptStyle === InteractionPromptStyle.WIGGLE) {
|
||||
const animationTime =
|
||||
((now - this[$promptElementVisibleTime]) / PROMPT_ANIMATION_TIME) %
|
||||
1;
|
||||
const offset = wiggle(animationTime);
|
||||
const opacity = fade(animationTime);
|
||||
|
||||
this[$promptAnimatedContainer].style.opacity = `${opacity}`;
|
||||
|
||||
if (offset !== this[$lastPromptOffset]) {
|
||||
const xOffset = offset * scene.width * 0.05;
|
||||
const deltaTheta = (offset - this[$lastPromptOffset]) * Math.PI / 16;
|
||||
|
||||
this[$promptAnimatedContainer].style.transform =
|
||||
`translateX(${xOffset}px)`;
|
||||
|
||||
controls.changeSource = ChangeSource.AUTOMATIC;
|
||||
controls.adjustOrbit(deltaTheta, 0, 0);
|
||||
|
||||
this[$lastPromptOffset] = offset;
|
||||
}
|
||||
}
|
||||
|
||||
const cameraMoved = controls.update(time, delta);
|
||||
const targetMoved = scene.updateTarget(delta);
|
||||
|
||||
if (cameraMoved || targetMoved) {
|
||||
this[$onChange]();
|
||||
}
|
||||
}
|
||||
|
||||
[$deferInteractionPrompt]() {
|
||||
// Effectively cancel the timer waiting for user interaction:
|
||||
this[$waitingToPromptUser] = false;
|
||||
this[$promptElement].classList.remove('visible');
|
||||
this[$promptElementVisibleTime] = Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the camera's near and far planes to enclose the scene when
|
||||
* orbiting at the supplied radius.
|
||||
*/
|
||||
[$updateCameraForRadius](radius: number) {
|
||||
const maximumRadius = Math.max(this[$scene].farRadius(), radius);
|
||||
|
||||
const near = 0;
|
||||
const far = Math.abs(2 * maximumRadius);
|
||||
this[$controls].updateNearFar(near, far);
|
||||
}
|
||||
|
||||
[$updateAria]() {
|
||||
const {theta, phi} =
|
||||
this[$controls]!.getCameraSpherical(this[$lastSpherical]);
|
||||
|
||||
const azimuthalQuadrant =
|
||||
(4 + Math.floor(((theta % TAU) + QUARTER_PI) / HALF_PI)) % 4;
|
||||
|
||||
const polarTrient = Math.floor(phi / THIRD_PI);
|
||||
|
||||
const azimuthalQuadrantLabel =
|
||||
AZIMUTHAL_QUADRANT_LABELS[azimuthalQuadrant];
|
||||
const polarTrientLabel = POLAR_TRIENT_LABELS[polarTrient];
|
||||
const position = `${polarTrientLabel}${azimuthalQuadrantLabel}`;
|
||||
|
||||
const key = position as keyof A11yTranslationsInterface;
|
||||
if (key in this[$a11y]) {
|
||||
this[$updateStatus](this[$a11y][key]);
|
||||
} else {
|
||||
this[$updateStatus](`View from stage ${position}`);
|
||||
}
|
||||
}
|
||||
|
||||
get[$ariaLabel]() {
|
||||
let interactionPrompt = INTERACTION_PROMPT;
|
||||
if ('interaction-prompt' in this[$a11y]) {
|
||||
interactionPrompt = `. ${this[$a11y]['interaction-prompt']}`;
|
||||
}
|
||||
|
||||
return super[$ariaLabel].replace(/\.$/, '') +
|
||||
(this.cameraControls ? interactionPrompt : '');
|
||||
}
|
||||
|
||||
async[$onResize](event: any) {
|
||||
const controls = this[$controls];
|
||||
const scene = this[$scene];
|
||||
const oldFramedFoV = scene.adjustedFoV(scene.framedFoVDeg);
|
||||
|
||||
// The super of $onResize may update the scene's adjustedFoV, so we
|
||||
// compare the before and after to calculate the proper zoom.
|
||||
super[$onResize](event);
|
||||
|
||||
const fovRatio = scene.adjustedFoV(scene.framedFoVDeg) / oldFramedFoV;
|
||||
const fov =
|
||||
controls.getFieldOfView() * (isFinite(fovRatio) ? fovRatio : 1);
|
||||
|
||||
controls.updateAspect(this[$scene].aspect);
|
||||
|
||||
this.requestUpdate('maxFieldOfView', this.maxFieldOfView);
|
||||
await this.updateComplete;
|
||||
this[$controls].setFieldOfView(fov);
|
||||
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
super[$onModelLoad]();
|
||||
|
||||
if (this[$initialized]) {
|
||||
this[$maintainThetaPhi] = true;
|
||||
} else {
|
||||
this[$initialized] = true;
|
||||
}
|
||||
this.requestUpdate('maxFieldOfView', this.maxFieldOfView);
|
||||
this.requestUpdate('fieldOfView', this.fieldOfView);
|
||||
this.requestUpdate('minCameraOrbit', this.minCameraOrbit);
|
||||
this.requestUpdate('maxCameraOrbit', this.maxCameraOrbit);
|
||||
this.requestUpdate('cameraOrbit', this.cameraOrbit);
|
||||
this.requestUpdate('cameraTarget', this.cameraTarget);
|
||||
this.jumpCameraToGoal();
|
||||
}
|
||||
|
||||
[$cancelPrompts] = () => {
|
||||
const source = this[$controls].changeSource;
|
||||
this[$cancellationSource] = source;
|
||||
|
||||
if (source === ChangeSource.USER_INTERACTION) {
|
||||
this[$userHasInteracted] = true;
|
||||
this[$deferInteractionPrompt]();
|
||||
}
|
||||
};
|
||||
|
||||
[$onChange] = () => {
|
||||
this[$updateAria]();
|
||||
this[$needsRender]();
|
||||
const source = this[$controls].changeSource;
|
||||
|
||||
this.dispatchEvent(new CustomEvent<CameraChangeDetails>(
|
||||
'camera-change', {detail: {source}}));
|
||||
};
|
||||
|
||||
[$onPointerChange] = (event: PointerChangeEvent) => {
|
||||
this[$container].classList.toggle(
|
||||
'pointer-tumbling', event.type === 'pointer-change-start');
|
||||
};
|
||||
|
||||
[$updateA11y]() {
|
||||
if (typeof this.a11y === 'string') {
|
||||
if (this.a11y.startsWith('{')) {
|
||||
try {
|
||||
this[$a11y] = JSON.parse(this.a11y);
|
||||
} catch (error) {
|
||||
console.warn('Error parsing a11y JSON:', error);
|
||||
}
|
||||
} else if (this.a11y.length > 0) {
|
||||
console.warn(
|
||||
'Error not supported format, should be a JSON string:',
|
||||
this.a11y);
|
||||
} else {
|
||||
this[$a11y] = <A11yTranslationsInterface>{};
|
||||
}
|
||||
} else if (typeof this.a11y === 'object' && this.a11y != null) {
|
||||
this[$a11y] = Object.assign({}, this.a11y);
|
||||
} else {
|
||||
this[$a11y] = <A11yTranslationsInterface>{};
|
||||
}
|
||||
|
||||
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
|
||||
}
|
||||
}
|
||||
|
||||
return ControlsModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,173 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {ACESFilmicToneMapping, AgXToneMapping, CineonToneMapping, LinearToneMapping, NeutralToneMapping, NoToneMapping, ReinhardToneMapping, Texture} from 'three';
|
||||
|
||||
import ModelViewerElementBase, {$needsRender, $progressTracker, $renderer, $scene, $shouldAttemptPreload} from '../model-viewer-base.js';
|
||||
import {clamp, Constructor, deserializeUrl} from '../utilities.js';
|
||||
|
||||
export const BASE_OPACITY = 0.5;
|
||||
const DEFAULT_SHADOW_INTENSITY = 0.0;
|
||||
const DEFAULT_SHADOW_SOFTNESS = 1.0;
|
||||
const DEFAULT_EXPOSURE = 1.0;
|
||||
|
||||
export type ToneMappingValue = 'auto'|'aces'|'agx'|'commerce'|'neutral'|
|
||||
'reinhard'|'cineon'|'linear'|'none';
|
||||
|
||||
export const $currentEnvironmentMap = Symbol('currentEnvironmentMap');
|
||||
export const $currentBackground = Symbol('currentBackground');
|
||||
export const $updateEnvironment = Symbol('updateEnvironment');
|
||||
const $cancelEnvironmentUpdate = Symbol('cancelEnvironmentUpdate');
|
||||
|
||||
export declare interface EnvironmentInterface {
|
||||
environmentImage: string|null;
|
||||
skyboxImage: string|null;
|
||||
skyboxHeight: string;
|
||||
shadowIntensity: number;
|
||||
shadowSoftness: number;
|
||||
exposure: number;
|
||||
hasBakedShadow(): boolean;
|
||||
}
|
||||
|
||||
export const EnvironmentMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<EnvironmentInterface>&T => {
|
||||
class EnvironmentModelViewerElement extends ModelViewerElement {
|
||||
@property({type: String, attribute: 'environment-image'})
|
||||
environmentImage: string|null = null;
|
||||
|
||||
@property({type: String, attribute: 'skybox-image'})
|
||||
skyboxImage: string|null = null;
|
||||
|
||||
@property({type: Number, attribute: 'shadow-intensity'})
|
||||
shadowIntensity: number = DEFAULT_SHADOW_INTENSITY;
|
||||
|
||||
@property({type: Number, attribute: 'shadow-softness'})
|
||||
shadowSoftness: number = DEFAULT_SHADOW_SOFTNESS;
|
||||
|
||||
@property({type: Number}) exposure: number = DEFAULT_EXPOSURE;
|
||||
|
||||
@property({type: String, attribute: 'tone-mapping'})
|
||||
toneMapping: ToneMappingValue = 'auto';
|
||||
|
||||
@property({type: String, attribute: 'skybox-height'})
|
||||
skyboxHeight: string = '0';
|
||||
|
||||
protected[$currentEnvironmentMap]: Texture|null = null;
|
||||
protected[$currentBackground]: Texture|null = null;
|
||||
|
||||
private[$cancelEnvironmentUpdate]: ((...args: any[]) => any)|null = null;
|
||||
|
||||
updated(changedProperties: Map<string|number|symbol, unknown>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('shadowIntensity')) {
|
||||
this[$scene].setShadowIntensity(this.shadowIntensity * BASE_OPACITY);
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('shadowSoftness')) {
|
||||
this[$scene].setShadowSoftness(this.shadowSoftness);
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('exposure')) {
|
||||
this[$scene].exposure = this.exposure;
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('toneMapping')) {
|
||||
const TONE_MAPPING = new Map([
|
||||
['aces', ACESFilmicToneMapping],
|
||||
['agx', AgXToneMapping],
|
||||
['reinhard', ReinhardToneMapping],
|
||||
['cineon', CineonToneMapping],
|
||||
['linear', LinearToneMapping],
|
||||
['none', NoToneMapping]
|
||||
]);
|
||||
|
||||
this[$scene].toneMapping = TONE_MAPPING.get(this.toneMapping) ?? NeutralToneMapping;
|
||||
this[$needsRender]();
|
||||
}
|
||||
|
||||
if ((changedProperties.has('environmentImage') ||
|
||||
changedProperties.has('skyboxImage')) &&
|
||||
this[$shouldAttemptPreload]()) {
|
||||
this[$updateEnvironment]();
|
||||
}
|
||||
|
||||
if (changedProperties.has('skyboxHeight')) {
|
||||
this[$scene].setGroundedSkybox();
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
|
||||
hasBakedShadow(): boolean {
|
||||
return this[$scene].bakedShadows.size > 0;
|
||||
}
|
||||
|
||||
async[$updateEnvironment]() {
|
||||
const {skyboxImage, environmentImage} = this;
|
||||
|
||||
if (this[$cancelEnvironmentUpdate] != null) {
|
||||
this[$cancelEnvironmentUpdate]!();
|
||||
this[$cancelEnvironmentUpdate] = null;
|
||||
}
|
||||
|
||||
const {textureUtils} = this[$renderer];
|
||||
|
||||
if (textureUtils == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updateEnvProgress =
|
||||
this[$progressTracker].beginActivity('environment-update');
|
||||
|
||||
try {
|
||||
const {environmentMap, skybox} =
|
||||
await textureUtils.generateEnvironmentMapAndSkybox(
|
||||
deserializeUrl(skyboxImage),
|
||||
environmentImage,
|
||||
(progress: number) => updateEnvProgress(clamp(progress, 0, 1)),
|
||||
this.withCredentials);
|
||||
|
||||
if (this[$currentEnvironmentMap] !== environmentMap) {
|
||||
this[$currentEnvironmentMap] = environmentMap;
|
||||
this.dispatchEvent(new CustomEvent('environment-change'));
|
||||
}
|
||||
if (skybox != null) {
|
||||
// When using the same environment and skybox, use the environment as
|
||||
// it gives HDR filtering.
|
||||
this[$currentBackground] =
|
||||
skybox.name === environmentMap.name ? environmentMap : skybox;
|
||||
} else {
|
||||
this[$currentBackground] = null;
|
||||
}
|
||||
|
||||
this[$scene].setEnvironmentAndSkybox(
|
||||
this[$currentEnvironmentMap], this[$currentBackground]);
|
||||
} catch (errorOrPromise) {
|
||||
if (errorOrPromise instanceof Error) {
|
||||
this[$scene].setEnvironmentAndSkybox(null, null);
|
||||
throw errorOrPromise;
|
||||
}
|
||||
} finally {
|
||||
updateEnvProgress(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return EnvironmentModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,422 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {Vector3} from 'three';
|
||||
|
||||
import ModelViewerElementBase, {$altDefaulted, $announceModelVisibility, $getModelIsVisible, $isElementInViewport, $progressTracker, $scene, $shouldAttemptPreload, $updateSource, $userInputElement, toVector3D, Vector3D} from '../model-viewer-base.js';
|
||||
import {$loader, CachingGLTFLoader} from '../three-components/CachingGLTFLoader.js';
|
||||
import {Renderer} from '../three-components/Renderer.js';
|
||||
import {Constructor, throttle} from '../utilities.js';
|
||||
|
||||
export type RevealAttributeValue = 'auto'|'manual';
|
||||
export type LoadingAttributeValue = 'auto'|'lazy'|'eager';
|
||||
|
||||
export const PROGRESS_BAR_UPDATE_THRESHOLD = 100;
|
||||
|
||||
const DEFAULT_DRACO_DECODER_LOCATION =
|
||||
'https://www.gstatic.com/draco/versioned/decoders/1.5.6/';
|
||||
|
||||
const DEFAULT_KTX2_TRANSCODER_LOCATION =
|
||||
'https://www.gstatic.com/basis-universal/versioned/2021-04-15-ba1c3e4/';
|
||||
|
||||
const DEFAULT_LOTTIE_LOADER_LOCATION =
|
||||
'https://cdn.jsdelivr.net/npm/[email protected]/examples/jsm/loaders/LottieLoader.js';
|
||||
|
||||
const RevealStrategy: {[index: string]: RevealAttributeValue} = {
|
||||
AUTO: 'auto',
|
||||
MANUAL: 'manual'
|
||||
};
|
||||
|
||||
const LoadingStrategy: {[index: string]: LoadingAttributeValue} = {
|
||||
AUTO: 'auto',
|
||||
LAZY: 'lazy',
|
||||
EAGER: 'eager'
|
||||
};
|
||||
|
||||
export const $defaultProgressBarElement = Symbol('defaultProgressBarElement');
|
||||
|
||||
export const $posterContainerElement = Symbol('posterContainerElement');
|
||||
export const $defaultPosterElement = Symbol('defaultPosterElement');
|
||||
|
||||
const $shouldDismissPoster = Symbol('shouldDismissPoster');
|
||||
const $hidePoster = Symbol('hidePoster');
|
||||
const $modelIsRevealed = Symbol('modelIsRevealed');
|
||||
const $updateProgressBar = Symbol('updateProgressBar');
|
||||
|
||||
const $ariaLabelCallToAction = Symbol('ariaLabelCallToAction');
|
||||
|
||||
const $onProgress = Symbol('onProgress');
|
||||
|
||||
export declare interface LoadingInterface {
|
||||
poster: string|null;
|
||||
reveal: RevealAttributeValue;
|
||||
loading: LoadingAttributeValue;
|
||||
readonly loaded: boolean;
|
||||
readonly modelIsVisible: boolean;
|
||||
dismissPoster(): void;
|
||||
showPoster(): void;
|
||||
getDimensions(): Vector3D;
|
||||
getBoundingBoxCenter(): Vector3D;
|
||||
}
|
||||
|
||||
export declare interface LoadingStaticInterface {
|
||||
dracoDecoderLocation: string;
|
||||
ktx2TranscoderLocation: string;
|
||||
meshoptDecoderLocation: string;
|
||||
lottieLoaderLocation: string;
|
||||
mapURLs(callback: (url: string) => string): void;
|
||||
}
|
||||
|
||||
export interface ModelViewerGlobalConfig {
|
||||
dracoDecoderLocation?: string;
|
||||
ktx2TranscoderLocation?: string;
|
||||
meshoptDecoderLocation?: string;
|
||||
lottieLoaderLocation?: string;
|
||||
powerPreference?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* LoadingMixin implements features related to lazy loading, as well as
|
||||
* presentation details related to the pre-load / pre-render presentation of a
|
||||
* <model-viewer>
|
||||
*
|
||||
* This mixin implements support for models with DRACO-compressed meshes.
|
||||
* The DRACO decoder will be loaded on-demand if a glTF that uses the DRACO mesh
|
||||
* compression extension is encountered.
|
||||
*
|
||||
* By default, the DRACO decoder will be loaded from a Google CDN. It is
|
||||
* possible to customize where the decoder is loaded from by defining a global
|
||||
* configuration option for `<model-viewer>` like so:
|
||||
*
|
||||
* ```html
|
||||
* <script>
|
||||
* self.ModelViewerElement = self.ModelViewerElement || {};
|
||||
* self.ModelViewerElement.dracoDecoderLocation =
|
||||
* 'http://example.com/location/of/draco/decoder/files/';
|
||||
* </script>
|
||||
* ```
|
||||
*
|
||||
* Note that the above configuration strategy must be performed *before* the
|
||||
* first `<model-viewer>` element is created in the browser. The configuration
|
||||
* can be done anywhere, but the easiest way to ensure it is done at the right
|
||||
* time is to do it in the `<head>` of the HTML document. This is the
|
||||
* recommended way to set the location because it is most compatible with
|
||||
* scenarios where the `<model-viewer>` library is lazily loaded.
|
||||
*
|
||||
* If you absolutely have to set the DRACO decoder location *after* the first
|
||||
* `<model-viewer>` element is created, you can do it this way:
|
||||
*
|
||||
* ```html
|
||||
* <script>
|
||||
* const ModelViewerElement = customElements.get('model-viewer');
|
||||
* ModelViewerElement.dracoDecoderLocation =
|
||||
* 'http://example.com/location/of/draco/decoder/files/';
|
||||
* </script>
|
||||
* ```
|
||||
*
|
||||
* Note that the above configuration approach will not work until *after*
|
||||
* `<model-viewer>` is defined in the browser. Also note that this configuration
|
||||
* *must* be set *before* the first DRACO model is fully loaded.
|
||||
*
|
||||
* It is recommended that users who intend to take advantage of DRACO mesh
|
||||
* compression consider whether or not it is acceptable for their use case to
|
||||
* have code side-loaded from a Google CDN. If it is not acceptable, then the
|
||||
* location must be customized before loading any DRACO models in order to cause
|
||||
* the decoder to be loaded from an alternative, acceptable location.
|
||||
*/
|
||||
export const LoadingMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement:
|
||||
T): Constructor<LoadingInterface, LoadingStaticInterface>&T => {
|
||||
class LoadingModelViewerElement extends ModelViewerElement {
|
||||
static set dracoDecoderLocation(value: string) {
|
||||
CachingGLTFLoader.setDRACODecoderLocation(value);
|
||||
}
|
||||
|
||||
static get dracoDecoderLocation() {
|
||||
return CachingGLTFLoader.getDRACODecoderLocation();
|
||||
}
|
||||
|
||||
static set ktx2TranscoderLocation(value: string) {
|
||||
CachingGLTFLoader.setKTX2TranscoderLocation(value);
|
||||
}
|
||||
|
||||
static get ktx2TranscoderLocation() {
|
||||
return CachingGLTFLoader.getKTX2TranscoderLocation();
|
||||
}
|
||||
|
||||
static set meshoptDecoderLocation(value: string) {
|
||||
CachingGLTFLoader.setMeshoptDecoderLocation(value);
|
||||
}
|
||||
|
||||
static get meshoptDecoderLocation() {
|
||||
return CachingGLTFLoader.getMeshoptDecoderLocation();
|
||||
}
|
||||
|
||||
static set lottieLoaderLocation(value: string) {
|
||||
Renderer.singleton.textureUtils!.lottieLoaderUrl = value;
|
||||
}
|
||||
|
||||
static get lottieLoaderLocation() {
|
||||
return Renderer.singleton.textureUtils!.lottieLoaderUrl
|
||||
}
|
||||
|
||||
/**
|
||||
* If provided, the callback will be passed each resource URL before a
|
||||
* request is sent. The callback may return the original URL, or a new URL
|
||||
* to override loading behavior. This behavior can be used to load assets
|
||||
* from .ZIP files, drag-and-drop APIs, and Data URIs.
|
||||
*/
|
||||
static mapURLs(callback: (url: string) => string) {
|
||||
Renderer.singleton.loader[$loader].manager.setURLModifier(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* A URL pointing to the image to use as a poster in scenarios where the
|
||||
* <model-viewer> is not ready to reveal a rendered model to the viewer.
|
||||
*/
|
||||
@property({type: String}) poster: string|null = null;
|
||||
|
||||
/**
|
||||
* An enumerable attribute describing under what conditions the
|
||||
* <model-viewer> should reveal a model to the viewer.
|
||||
*
|
||||
* The default value is "auto". The only supported alternative values is
|
||||
* "manual".
|
||||
*/
|
||||
@property({type: String})
|
||||
reveal: RevealAttributeValue = RevealStrategy.AUTO;
|
||||
|
||||
/**
|
||||
* An enumerable attribute describing under what conditions the
|
||||
* <model-viewer> should preload a model.
|
||||
*
|
||||
* The default value is "auto". The only supported alternative values are
|
||||
* "lazy" and "eager". Auto is equivalent to lazy, which loads the model
|
||||
* when it is near the viewport for reveal = "auto", and when interacted
|
||||
* with for reveal = "interaction". Eager loads the model immediately.
|
||||
*/
|
||||
@property({type: String})
|
||||
loading: LoadingAttributeValue = LoadingStrategy.AUTO;
|
||||
|
||||
/**
|
||||
* Dismisses the poster, causing the model to load and render if
|
||||
* necessary. This is currently effectively the same as interacting with
|
||||
* the poster via user input.
|
||||
*/
|
||||
dismissPoster() {
|
||||
if (this.loaded) {
|
||||
this[$hidePoster]();
|
||||
} else {
|
||||
this[$shouldDismissPoster] = true;
|
||||
this[$updateSource]();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the poster, hiding the 3D model. If this is called after the 3D
|
||||
* model has been revealed, then it must be dismissed by a call to
|
||||
* dismissPoster().
|
||||
*/
|
||||
showPoster() {
|
||||
const posterContainerElement = this[$posterContainerElement];
|
||||
if (posterContainerElement.classList.contains('show')) {
|
||||
return;
|
||||
}
|
||||
posterContainerElement.classList.add('show');
|
||||
this[$userInputElement].classList.remove('show');
|
||||
|
||||
const defaultPosterElement = this[$defaultPosterElement];
|
||||
defaultPosterElement.removeAttribute('tabindex');
|
||||
defaultPosterElement.removeAttribute('aria-hidden');
|
||||
|
||||
const oldVisibility = this.modelIsVisible;
|
||||
this[$modelIsRevealed] = false;
|
||||
this[$announceModelVisibility](oldVisibility);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the model's bounding box dimensions in meters, independent of
|
||||
* turntable rotation.
|
||||
*/
|
||||
getDimensions(): Vector3D {
|
||||
return toVector3D(this[$scene].size);
|
||||
}
|
||||
|
||||
getBoundingBoxCenter(): Vector3D {
|
||||
return toVector3D(this[$scene].boundingBox.getCenter(new Vector3()));
|
||||
}
|
||||
|
||||
protected[$modelIsRevealed] = false;
|
||||
|
||||
protected[$shouldDismissPoster] = false;
|
||||
|
||||
// TODO: Add this to the shadow root as part of this mixin's
|
||||
// implementation:
|
||||
protected[$posterContainerElement]: HTMLElement =
|
||||
this.shadowRoot!.querySelector('.slot.poster') as HTMLElement;
|
||||
|
||||
protected[$defaultPosterElement]: HTMLElement =
|
||||
this.shadowRoot!.querySelector('#default-poster') as HTMLElement;
|
||||
|
||||
protected[$defaultProgressBarElement]: HTMLElement =
|
||||
this.shadowRoot!.querySelector('#default-progress-bar > .bar') as
|
||||
HTMLElement;
|
||||
|
||||
protected[$ariaLabelCallToAction] =
|
||||
this[$defaultPosterElement].getAttribute('aria-label');
|
||||
|
||||
protected[$updateProgressBar] = throttle((progress: number) => {
|
||||
const parentNode = this[$defaultProgressBarElement].parentNode as Element;
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this[$defaultProgressBarElement].style.transform =
|
||||
`scaleX(${progress})`;
|
||||
|
||||
if (progress === 0) {
|
||||
// NOTE(cdata): We remove and re-append the progress bar in this
|
||||
// condition so that the progress bar does not appear to
|
||||
// transition backwards from the right when we reset to 0 (or
|
||||
// otherwise <1) progress after having already reached 1 progress
|
||||
// previously.
|
||||
parentNode.removeChild(this[$defaultProgressBarElement]);
|
||||
parentNode.appendChild(this[$defaultProgressBarElement]);
|
||||
}
|
||||
|
||||
this[$defaultProgressBarElement].classList.toggle('hide', progress === 1.0);
|
||||
});
|
||||
}, PROGRESS_BAR_UPDATE_THRESHOLD);
|
||||
|
||||
constructor(...args: Array<any>) {
|
||||
super(...args);
|
||||
|
||||
const ModelViewerElement: ModelViewerGlobalConfig =
|
||||
(self as any).ModelViewerElement || {};
|
||||
|
||||
const dracoDecoderLocation = ModelViewerElement.dracoDecoderLocation ||
|
||||
DEFAULT_DRACO_DECODER_LOCATION;
|
||||
CachingGLTFLoader.setDRACODecoderLocation(dracoDecoderLocation);
|
||||
|
||||
const ktx2TranscoderLocation =
|
||||
ModelViewerElement.ktx2TranscoderLocation ||
|
||||
DEFAULT_KTX2_TRANSCODER_LOCATION;
|
||||
CachingGLTFLoader.setKTX2TranscoderLocation(ktx2TranscoderLocation);
|
||||
|
||||
if (ModelViewerElement.meshoptDecoderLocation) {
|
||||
CachingGLTFLoader.setMeshoptDecoderLocation(
|
||||
ModelViewerElement.meshoptDecoderLocation);
|
||||
}
|
||||
|
||||
const lottieLoaderLocation = ModelViewerElement.lottieLoaderLocation ||
|
||||
DEFAULT_LOTTIE_LOADER_LOCATION;
|
||||
Renderer.singleton.textureUtils!.lottieLoaderUrl = lottieLoaderLocation;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (!this.loaded) {
|
||||
this.showPoster();
|
||||
}
|
||||
|
||||
this[$progressTracker].addEventListener('progress', this[$onProgress]);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
this[$progressTracker].removeEventListener('progress', this[$onProgress]);
|
||||
}
|
||||
|
||||
async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('poster') && this.poster != null) {
|
||||
this[$defaultPosterElement].style.backgroundImage =
|
||||
`url(${this.poster})`;
|
||||
}
|
||||
|
||||
if (changedProperties.has('alt')) {
|
||||
this[$defaultPosterElement].setAttribute(
|
||||
'aria-label', this[$altDefaulted]);
|
||||
}
|
||||
|
||||
if (changedProperties.has('reveal') || changedProperties.has('loading')) {
|
||||
this[$updateSource]();
|
||||
}
|
||||
}
|
||||
|
||||
[$onProgress] = (event: Event) => {
|
||||
const progress = (event as any).detail.totalProgress;
|
||||
const reason = (event as any).detail.reason;
|
||||
|
||||
if (progress === 1.0) {
|
||||
this[$updateProgressBar].flush();
|
||||
if (this.loaded &&
|
||||
(this[$shouldDismissPoster] ||
|
||||
this.reveal === RevealStrategy.AUTO)) {
|
||||
this[$hidePoster]();
|
||||
}
|
||||
}
|
||||
|
||||
this[$updateProgressBar](progress);
|
||||
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('progress', {detail: {totalProgress: progress, reason}}));
|
||||
};
|
||||
|
||||
[$shouldAttemptPreload](): boolean {
|
||||
return !!this.src &&
|
||||
(this[$shouldDismissPoster] ||
|
||||
this.loading === LoadingStrategy.EAGER ||
|
||||
(this.reveal === RevealStrategy.AUTO && this[$isElementInViewport]));
|
||||
}
|
||||
|
||||
[$hidePoster]() {
|
||||
this[$shouldDismissPoster] = false;
|
||||
const posterContainerElement = this[$posterContainerElement];
|
||||
if (!posterContainerElement.classList.contains('show')) {
|
||||
return;
|
||||
}
|
||||
posterContainerElement.classList.remove('show');
|
||||
this[$userInputElement].classList.add('show');
|
||||
|
||||
const oldVisibility = this.modelIsVisible;
|
||||
this[$modelIsRevealed] = true;
|
||||
this[$announceModelVisibility](oldVisibility);
|
||||
|
||||
const root = this.getRootNode();
|
||||
|
||||
// If the <model-viewer> is still focused, forward the focus to
|
||||
// the canvas that has just been revealed
|
||||
if (root && (root as Document | ShadowRoot).activeElement === this) {
|
||||
this[$userInputElement].focus();
|
||||
}
|
||||
|
||||
// Ensure that the poster is no longer focusable or visible to
|
||||
// screen readers
|
||||
const defaultPosterElement = this[$defaultPosterElement];
|
||||
defaultPosterElement.setAttribute('aria-hidden', 'true');
|
||||
defaultPosterElement.tabIndex = -1;
|
||||
this.dispatchEvent(new CustomEvent('poster-dismissed'));
|
||||
}
|
||||
|
||||
[$getModelIsVisible]() {
|
||||
return super[$getModelIsVisible]() && this[$modelIsRevealed];
|
||||
}
|
||||
}
|
||||
|
||||
return LoadingModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,300 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {CanvasTexture, RepeatWrapping, SRGBColorSpace, Texture, VideoTexture} from 'three';
|
||||
import {GLTFExporter, GLTFExporterOptions} from 'three/examples/jsm/exporters/GLTFExporter.js';
|
||||
|
||||
import ModelViewerElementBase, {$needsRender, $onModelLoad, $progressTracker, $renderer, $scene} from '../model-viewer-base.js';
|
||||
import {GLTF} from '../three-components/gltf-instance/gltf-defaulted.js';
|
||||
import {ModelViewerGLTFInstance} from '../three-components/gltf-instance/ModelViewerGLTFInstance.js';
|
||||
import GLTFExporterMaterialsVariantsExtension from '../three-components/gltf-instance/VariantMaterialExporterPlugin.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
|
||||
import {Image, PBRMetallicRoughness, Sampler, TextureInfo} from './scene-graph/api.js';
|
||||
import {Material} from './scene-graph/material.js';
|
||||
import {$availableVariants, $materialFromPoint, $prepareVariantsForExport, $switchVariant, Model} from './scene-graph/model.js';
|
||||
import {Texture as ModelViewerTexture} from './scene-graph/texture.js';
|
||||
|
||||
|
||||
|
||||
export const $currentGLTF = Symbol('currentGLTF');
|
||||
export const $originalGltfJson = Symbol('originalGltfJson');
|
||||
export const $model = Symbol('model');
|
||||
const $getOnUpdateMethod = Symbol('getOnUpdateMethod');
|
||||
const $buildTexture = Symbol('buildTexture');
|
||||
|
||||
interface SceneExportOptions {
|
||||
binary?: boolean, trs?: boolean, onlyVisible?: boolean,
|
||||
maxTextureSize?: number, includeCustomExtensions?: boolean,
|
||||
forceIndices?: boolean
|
||||
}
|
||||
|
||||
export interface SceneGraphInterface {
|
||||
readonly model?: Model;
|
||||
variantName: string|null;
|
||||
readonly availableVariants: string[];
|
||||
orientation: string;
|
||||
scale: string;
|
||||
readonly originalGltfJson: GLTF|null;
|
||||
exportScene(options?: SceneExportOptions): Promise<Blob>;
|
||||
createTexture(uri: string, type?: string): Promise<ModelViewerTexture|null>;
|
||||
createLottieTexture(uri: string, quality?: number):
|
||||
Promise<ModelViewerTexture|null>;
|
||||
createVideoTexture(uri: string): ModelViewerTexture;
|
||||
createCanvasTexture(): ModelViewerTexture;
|
||||
/**
|
||||
* Intersects a ray with the scene and returns a list of materials who's
|
||||
* objects were intersected.
|
||||
* @param pixelX X coordinate of the mouse.
|
||||
* @param pixelY Y coordinate of the mouse.
|
||||
* @returns a material, if no intersection is made then null is returned.
|
||||
*/
|
||||
materialFromPoint(pixelX: number, pixelY: number): Material|null;
|
||||
}
|
||||
|
||||
/**
|
||||
* SceneGraphMixin manages exposes a model API in order to support operations on
|
||||
* the <model-viewer> scene graph.
|
||||
*/
|
||||
export const SceneGraphMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<SceneGraphInterface>&T => {
|
||||
class SceneGraphModelViewerElement extends ModelViewerElement {
|
||||
protected[$model]: Model|undefined = undefined;
|
||||
protected[$currentGLTF]: ModelViewerGLTFInstance|null = null;
|
||||
private[$originalGltfJson]: GLTF|null = null;
|
||||
|
||||
@property({type: String, attribute: 'variant-name'})
|
||||
variantName: string|null = null;
|
||||
|
||||
@property({type: String, attribute: 'orientation'})
|
||||
orientation: string = '0 0 0';
|
||||
|
||||
@property({type: String, attribute: 'scale'}) scale: string = '1 1 1';
|
||||
|
||||
// Scene-graph API:
|
||||
/** @export */
|
||||
get model() {
|
||||
return this[$model];
|
||||
}
|
||||
|
||||
get availableVariants() {
|
||||
return this.model ? this.model[$availableVariants]() : [] as string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a deep copy of the gltf JSON as loaded. It will not reflect
|
||||
* changes to the scene-graph, nor will editing it have any effect.
|
||||
*/
|
||||
get originalGltfJson() {
|
||||
return this[$originalGltfJson];
|
||||
}
|
||||
|
||||
/**
|
||||
* References to each element constructor. Supports instanceof checks; these
|
||||
* classes are not directly constructable.
|
||||
*/
|
||||
static Model: Constructor<Model>;
|
||||
static Material: Constructor<Material>;
|
||||
static PBRMetallicRoughness: Constructor<PBRMetallicRoughness>;
|
||||
static Sampler: Constructor<Sampler>;
|
||||
static TextureInfo: Constructor<TextureInfo>;
|
||||
static Texture: Constructor<Texture>;
|
||||
static Image: Constructor<Image>;
|
||||
|
||||
private[$getOnUpdateMethod]() {
|
||||
return () => {
|
||||
this[$needsRender]();
|
||||
};
|
||||
}
|
||||
|
||||
private[$buildTexture](texture: Texture): ModelViewerTexture {
|
||||
// Applies glTF default settings.
|
||||
texture.colorSpace = SRGBColorSpace;
|
||||
texture.wrapS = RepeatWrapping;
|
||||
texture.wrapT = RepeatWrapping;
|
||||
return new ModelViewerTexture(this[$getOnUpdateMethod](), texture);
|
||||
}
|
||||
|
||||
async createTexture(uri: string, type: string = 'image/png'):
|
||||
Promise<ModelViewerTexture> {
|
||||
const {textureUtils} = this[$renderer];
|
||||
const texture = await textureUtils!.loadImage(uri, this.withCredentials);
|
||||
|
||||
texture.userData.mimeType = type;
|
||||
|
||||
return this[$buildTexture](texture);
|
||||
}
|
||||
|
||||
async createLottieTexture(uri: string, quality = 1):
|
||||
Promise<ModelViewerTexture> {
|
||||
const {textureUtils} = this[$renderer];
|
||||
const texture =
|
||||
await textureUtils!.loadLottie(uri, quality, this.withCredentials);
|
||||
|
||||
return this[$buildTexture](texture);
|
||||
}
|
||||
|
||||
createVideoTexture(uri: string): ModelViewerTexture {
|
||||
const video = document.createElement('video');
|
||||
video.crossOrigin =
|
||||
this.withCredentials ? 'use-credentials' : 'anonymous';
|
||||
video.src = uri;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.loop = true;
|
||||
video.play();
|
||||
const texture = new VideoTexture(video);
|
||||
|
||||
return this[$buildTexture](texture);
|
||||
}
|
||||
|
||||
createCanvasTexture(): ModelViewerTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
const texture = new CanvasTexture(canvas);
|
||||
|
||||
return this[$buildTexture](texture);
|
||||
}
|
||||
|
||||
async updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('variantName')) {
|
||||
const updateVariantProgress =
|
||||
this[$progressTracker].beginActivity('variant-update');
|
||||
updateVariantProgress(0.1);
|
||||
const model = this[$model];
|
||||
const {variantName} = this;
|
||||
|
||||
if (model != null) {
|
||||
await model[$switchVariant](variantName!);
|
||||
this[$needsRender]();
|
||||
this.dispatchEvent(new CustomEvent('variant-applied'));
|
||||
}
|
||||
updateVariantProgress(1.0);
|
||||
}
|
||||
|
||||
if (changedProperties.has('orientation') ||
|
||||
changedProperties.has('scale')) {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
const scene = this[$scene];
|
||||
scene.applyTransform();
|
||||
scene.updateBoundingBox();
|
||||
scene.updateShadow();
|
||||
this[$renderer].arRenderer.onUpdateScene();
|
||||
this[$needsRender]();
|
||||
}
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
super[$onModelLoad]();
|
||||
|
||||
const {currentGLTF} = this[$scene];
|
||||
|
||||
if (currentGLTF != null) {
|
||||
const {correlatedSceneGraph} = currentGLTF;
|
||||
|
||||
if (correlatedSceneGraph != null &&
|
||||
currentGLTF !== this[$currentGLTF]) {
|
||||
this[$model] =
|
||||
new Model(correlatedSceneGraph, this[$getOnUpdateMethod]());
|
||||
this[$originalGltfJson] =
|
||||
JSON.parse(JSON.stringify(correlatedSceneGraph.gltf));
|
||||
}
|
||||
|
||||
// KHR_materials_variants extension spec:
|
||||
// https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Khronos/KHR_materials_variants
|
||||
|
||||
if ('variants' in currentGLTF.userData) {
|
||||
this.requestUpdate('variantName');
|
||||
}
|
||||
}
|
||||
|
||||
this[$currentGLTF] = currentGLTF;
|
||||
}
|
||||
|
||||
/** @export */
|
||||
async exportScene(options?: SceneExportOptions): Promise<Blob> {
|
||||
const scene = this[$scene];
|
||||
return new Promise<Blob>(async (resolve, reject) => {
|
||||
// Defaults
|
||||
const opts = {
|
||||
binary: true,
|
||||
onlyVisible: true,
|
||||
maxTextureSize: Infinity,
|
||||
includeCustomExtensions: false,
|
||||
forceIndices: false
|
||||
} as GLTFExporterOptions;
|
||||
|
||||
Object.assign(opts, options);
|
||||
// Not configurable
|
||||
opts.animations = scene.animations;
|
||||
opts.truncateDrawRange = true;
|
||||
|
||||
const shadow = scene.shadow;
|
||||
let visible = false;
|
||||
// Remove shadow from export
|
||||
if (shadow != null) {
|
||||
visible = shadow.visible;
|
||||
shadow.visible = false;
|
||||
}
|
||||
|
||||
await this[$model]![$prepareVariantsForExport]();
|
||||
|
||||
const exporter =
|
||||
(new GLTFExporter() as any)
|
||||
.register(
|
||||
(writer: any) =>
|
||||
new GLTFExporterMaterialsVariantsExtension(writer));
|
||||
exporter.parse(
|
||||
scene.model,
|
||||
(gltf: object) => {
|
||||
return resolve(new Blob(
|
||||
[opts.binary ? gltf as Blob : JSON.stringify(gltf)], {
|
||||
type: opts.binary ? 'application/octet-stream' :
|
||||
'application/json'
|
||||
}));
|
||||
},
|
||||
() => {
|
||||
return reject('glTF export failed');
|
||||
},
|
||||
opts);
|
||||
|
||||
if (shadow != null) {
|
||||
shadow.visible = visible;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
materialFromPoint(pixelX: number, pixelY: number): Material|null {
|
||||
const model = this[$model];
|
||||
if (model == null) {
|
||||
return null;
|
||||
}
|
||||
const scene = this[$scene];
|
||||
const ndcCoords = scene.getNDC(pixelX, pixelY);
|
||||
const hit = scene.hitFromPoint(ndcCoords);
|
||||
if (hit == null || hit.face == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return model[$materialFromPoint](hit);
|
||||
}
|
||||
}
|
||||
|
||||
return SceneGraphModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,443 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {AlphaMode, MagFilter, MinFilter, WrapMode} from '../../three-components/gltf-instance/gltf-2.0.js';
|
||||
|
||||
|
||||
/**
|
||||
* All constructs in a 3DOM scene graph have a corresponding string name.
|
||||
* This is similar in spirit to the concept of a "tag name" in HTML, and exists
|
||||
* in support of looking up 3DOM elements by type.
|
||||
*/
|
||||
export declare interface ThreeDOMElementMap {
|
||||
'model': Model;
|
||||
'material': Material;
|
||||
'pbr-metallic-roughness': PBRMetallicRoughness;
|
||||
'sampler': Sampler;
|
||||
'image': Image;
|
||||
'texture': Texture;
|
||||
'texture-info': TextureInfo;
|
||||
}
|
||||
|
||||
/** A 2D Cartesian coordinate */
|
||||
export interface Vector2DInterface {
|
||||
u: number;
|
||||
v: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Model is the root element of a 3DOM scene graph. It gives scripts access
|
||||
* to the sub-elements found without the graph.
|
||||
*/
|
||||
export declare interface Model {
|
||||
/**
|
||||
* An ordered set of unique Materials found in this model. The Materials
|
||||
* correspond to the listing of materials in the glTF, with the possible
|
||||
* addition of a default material at the end.
|
||||
*/
|
||||
readonly materials: Readonly<Material[]>;
|
||||
|
||||
/**
|
||||
* Gets a material(s) by name.
|
||||
* @param name the name of the material to return.
|
||||
* @returns the first material to whose name matches `name`
|
||||
*/
|
||||
getMaterialByName(name: string): Material|null;
|
||||
|
||||
/**
|
||||
* Creates a new material variant from an existing material.
|
||||
* @param originalMaterialIndex index of the material to clone the variant
|
||||
* from.
|
||||
* @param materialName the name of the new material
|
||||
* @param variantName the name of the variant
|
||||
* @param activateVariant activates this material variant, i.e. the variant
|
||||
* material is rendered, not the existing material.
|
||||
* @returns returns a clone of the original material, returns `null` if the
|
||||
* material instance for this variant already exists.
|
||||
*/
|
||||
createMaterialInstanceForVariant(
|
||||
originalMaterialIndex: number, newMaterialName: string,
|
||||
variantName: string, activateVariant: boolean): Material|null;
|
||||
|
||||
/**
|
||||
* Adds a variant name to the model.
|
||||
* @param variantName
|
||||
*/
|
||||
createVariant(variantName: string): void;
|
||||
|
||||
/**
|
||||
* Adds an existing material to a variant name.
|
||||
* @param materialIndex
|
||||
* @param targetVariantName
|
||||
*/
|
||||
setMaterialToVariant(materialIndex: number, targetVariantName: string): void;
|
||||
|
||||
/**
|
||||
* Removes the variant name from the model.
|
||||
* @param variantName the variant to remove.
|
||||
*/
|
||||
deleteVariant(variantName: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Material gives the script access to modify a single, unique material found
|
||||
* in a model's scene graph.
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-material
|
||||
*/
|
||||
export declare interface Material {
|
||||
/**
|
||||
* The name of the material, if any.
|
||||
*/
|
||||
name: string;
|
||||
|
||||
readonly normalTexture: TextureInfo|null;
|
||||
readonly occlusionTexture: TextureInfo|null;
|
||||
readonly emissiveTexture: TextureInfo|null;
|
||||
|
||||
readonly emissiveFactor: Readonly<RGB>;
|
||||
setEmissiveFactor(rgb: RGB|string): void;
|
||||
setAlphaCutoff(cutoff: number): void;
|
||||
getAlphaCutoff(): number;
|
||||
setDoubleSided(doubleSided: boolean): void;
|
||||
getDoubleSided(): boolean;
|
||||
setAlphaMode(alphaMode: AlphaMode): void;
|
||||
getAlphaMode(): AlphaMode;
|
||||
|
||||
/**
|
||||
* PBR Next properties.
|
||||
*/
|
||||
readonly emissiveStrength: number;
|
||||
readonly clearcoatFactor: number;
|
||||
readonly clearcoatRoughnessFactor: number;
|
||||
readonly clearcoatTexture: TextureInfo;
|
||||
readonly clearcoatRoughnessTexture: TextureInfo;
|
||||
readonly clearcoatNormalTexture: TextureInfo;
|
||||
readonly clearcoatNormalScale: number;
|
||||
readonly ior: number;
|
||||
readonly sheenColorFactor: Readonly<RGB>;
|
||||
readonly sheenColorTexture: TextureInfo;
|
||||
readonly sheenRoughnessFactor: number;
|
||||
readonly sheenRoughnessTexture: TextureInfo;
|
||||
readonly transmissionFactor: number;
|
||||
readonly transmissionTexture: TextureInfo;
|
||||
readonly thicknessFactor: number;
|
||||
readonly thicknessTexture: TextureInfo;
|
||||
readonly attenuationDistance: number;
|
||||
readonly attenuationColor: Readonly<RGB>;
|
||||
readonly specularFactor: number;
|
||||
readonly specularTexture: TextureInfo;
|
||||
readonly specularColorFactor: Readonly<RGB>;
|
||||
readonly specularColorTexture: TextureInfo;
|
||||
readonly iridescenceFactor: number;
|
||||
readonly iridescenceTexture: TextureInfo;
|
||||
readonly iridescenceIor: number;
|
||||
readonly iridescenceThicknessMinimum: number;
|
||||
readonly iridescenceThicknessMaximum: number;
|
||||
readonly iridescenceThicknessTexture: TextureInfo;
|
||||
readonly anisotropyStrength: number;
|
||||
readonly anisotropyRotation: number;
|
||||
readonly anisotropyTexture: TextureInfo;
|
||||
|
||||
setEmissiveStrength(emissiveStrength: number): void;
|
||||
setClearcoatFactor(clearcoatFactor: number): void;
|
||||
setClearcoatRoughnessFactor(clearcoatRoughnessFactor: number): void;
|
||||
setClearcoatNormalScale(clearcoatNormalScale: number): void;
|
||||
setIor(ior: number): void;
|
||||
setSheenColorFactor(rgb: RGB|string): void;
|
||||
setSheenRoughnessFactor(roughness: number): void;
|
||||
setTransmissionFactor(transmission: number): void;
|
||||
setThicknessFactor(thickness: number): void;
|
||||
setAttenuationDistance(attenuationDistance: number): void;
|
||||
setAttenuationColor(rgb: RGB|string): void;
|
||||
setSpecularFactor(specularFactor: number): void;
|
||||
setSpecularColorFactor(rgb: RGB|string): void;
|
||||
setIridescenceFactor(iridescence: number): void;
|
||||
setIridescenceIor(ior: number): void;
|
||||
setIridescenceThicknessMinimum(thicknessMin: number): void;
|
||||
setIridescenceThicknessMaximum(thicknessMax: number): void;
|
||||
setAnisotropyStrength(strength: number): void;
|
||||
setAnisotropyRotation(rotation: number): void;
|
||||
|
||||
/**
|
||||
* The PBRMetallicRoughness configuration of the material.
|
||||
*/
|
||||
readonly pbrMetallicRoughness: PBRMetallicRoughness;
|
||||
|
||||
/**
|
||||
* Asynchronously loads the underlying material resource if it's currently
|
||||
* unloaded, otherwise the method is a noop.
|
||||
*/
|
||||
ensureLoaded(): void;
|
||||
|
||||
/**
|
||||
* Returns true if the material participates in the variant.
|
||||
* @param name the variant name.
|
||||
*/
|
||||
hasVariant(name: string): boolean;
|
||||
|
||||
/**
|
||||
* Returns true if the material is loaded.
|
||||
*/
|
||||
readonly isLoaded: boolean;
|
||||
|
||||
/**
|
||||
* Returns true if the material is participating in scene renders.
|
||||
*/
|
||||
readonly isActive: boolean;
|
||||
|
||||
/**
|
||||
* Returns the glTF index of this material.
|
||||
*/
|
||||
readonly index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The PBRMetallicRoughness encodes the PBR properties of a material
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-pbrmetallicroughness
|
||||
*/
|
||||
export declare interface PBRMetallicRoughness {
|
||||
/**
|
||||
* The base color factor of the material, represented as RGBA values
|
||||
*/
|
||||
readonly baseColorFactor: Readonly<RGBA>;
|
||||
|
||||
/**
|
||||
* Metalness factor of the material, represented as number between 0 and 1
|
||||
*/
|
||||
readonly metallicFactor: number;
|
||||
|
||||
/**
|
||||
* Roughness factor of the material, represented as number between 0 and 1
|
||||
*/
|
||||
readonly roughnessFactor: number;
|
||||
|
||||
/**
|
||||
* A texture reference, associating an image with color information and
|
||||
* a sampler for describing base color factor for a UV coordinate space.
|
||||
*/
|
||||
readonly baseColorTexture: TextureInfo|null;
|
||||
|
||||
/**
|
||||
* A texture reference, associating an image with color information and
|
||||
* a sampler for describing metalness (B channel) and roughness (G channel)
|
||||
* for a UV coordinate space.
|
||||
*/
|
||||
readonly metallicRoughnessTexture: TextureInfo|null;
|
||||
|
||||
/**
|
||||
* Changes the base color factor of the material to the given value.
|
||||
*/
|
||||
setBaseColorFactor(rgba: RGBA|string): void;
|
||||
|
||||
/**
|
||||
* Changes the metalness factor of the material to the given value.
|
||||
*/
|
||||
setMetallicFactor(value: number): void;
|
||||
|
||||
/**
|
||||
* Changes the roughness factor of the material to the given value.
|
||||
*/
|
||||
setRoughnessFactor(value: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A TextureInfo is a pointer to a specific Texture in use on a Material
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-textureinfo
|
||||
*/
|
||||
export declare interface TextureInfo {
|
||||
/**
|
||||
* The Texture being referenced by this TextureInfo.
|
||||
*/
|
||||
readonly texture: Texture|null;
|
||||
|
||||
/**
|
||||
* Sets the texture, or removes it if argument is null. Note you cannot build
|
||||
* your own Texture object, but must either use one from another TextureInfo,
|
||||
* or create one with the createTexture method.
|
||||
*/
|
||||
setTexture(texture: Texture|null): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Texture pairs an Image and a Sampler for use in a Material
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-texture
|
||||
*/
|
||||
export declare interface Texture {
|
||||
/**
|
||||
* The name of the texture, if any.
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* The Sampler for this Texture
|
||||
*/
|
||||
readonly sampler: Sampler;
|
||||
|
||||
/**
|
||||
* The source Image for this Texture
|
||||
*/
|
||||
readonly source: Image;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Sampler describes how to filter and wrap textures
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-sampler
|
||||
*/
|
||||
export declare interface Sampler {
|
||||
/**
|
||||
* The name of the sampler, if any.
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplerminfilter
|
||||
*/
|
||||
readonly minFilter: MinFilter;
|
||||
|
||||
/**
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplermagfilter
|
||||
*/
|
||||
readonly magFilter: MagFilter;
|
||||
|
||||
/**
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplerwraps
|
||||
*/
|
||||
readonly wrapS: WrapMode;
|
||||
|
||||
/**
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#samplerwrapt
|
||||
*/
|
||||
readonly wrapT: WrapMode;
|
||||
|
||||
/**
|
||||
* The texture rotation in radians.
|
||||
*/
|
||||
readonly rotation: number|null;
|
||||
|
||||
/**
|
||||
* The texture scale.
|
||||
*/
|
||||
readonly scale: Vector2DInterface|null;
|
||||
|
||||
/**
|
||||
* The texture offset.
|
||||
*/
|
||||
readonly offset: Vector2DInterface|null;
|
||||
|
||||
/**
|
||||
* Configure the minFilter value of the Sampler.
|
||||
*/
|
||||
setMinFilter(filter: MinFilter): void;
|
||||
|
||||
/**
|
||||
* Configure the magFilter value of the Sampler.
|
||||
*/
|
||||
setMagFilter(filter: MagFilter): void;
|
||||
|
||||
/**
|
||||
* Configure the S (U) wrap mode of the Sampler.
|
||||
*/
|
||||
setWrapS(mode: WrapMode): void;
|
||||
|
||||
/**
|
||||
* Configure the T (V) wrap mode of the Sampler.
|
||||
*/
|
||||
setWrapT(mode: WrapMode): void;
|
||||
|
||||
/**
|
||||
* Sets the texture rotation, or resets it to zero if argument is null.
|
||||
* Rotation is in radians, positive for counter-clockwise.
|
||||
*/
|
||||
setRotation(rotation: number|null): void;
|
||||
|
||||
/**
|
||||
* Sets the texture scale, or resets it to (1, 1) if argument is null.
|
||||
* As the scale value increases, the repetition of the texture will increase.
|
||||
*/
|
||||
setScale(scale: Vector2DInterface|null): void;
|
||||
|
||||
/**
|
||||
* Sets the texture offset, or resets it to (0, 0) if argument is null.
|
||||
*/
|
||||
setOffset(offset: Vector2DInterface|null): void;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* An Image represents an embedded or external image used to provide texture
|
||||
* color data.
|
||||
*
|
||||
* @see https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#reference-image
|
||||
*/
|
||||
export declare interface Image {
|
||||
/**
|
||||
* The name of the image, if any.
|
||||
*/
|
||||
readonly name: string;
|
||||
|
||||
/**
|
||||
* The type is 'external' if the image has a configured URI. Otherwise, it is
|
||||
* considered to be 'embedded'. Note: this distinction is only implied by the
|
||||
* glTF spec, and is made explicit here for convenience.
|
||||
*/
|
||||
readonly type: 'embedded'|'external';
|
||||
|
||||
/**
|
||||
* The URI of the image, if it is external.
|
||||
*/
|
||||
readonly uri?: string;
|
||||
|
||||
/**
|
||||
* The bufferView of the image, if it is embedded.
|
||||
*/
|
||||
readonly bufferView?: number;
|
||||
|
||||
/**
|
||||
* The backing HTML element, if this is a video or canvas texture.
|
||||
*/
|
||||
readonly element?: HTMLVideoElement|HTMLCanvasElement;
|
||||
|
||||
/**
|
||||
* The Lottie animation object, if this is a Lottie texture. You may wish to
|
||||
* do image.animation as import('lottie-web').AnimationItem; to get its type
|
||||
* info.
|
||||
*/
|
||||
readonly animation?: any;
|
||||
|
||||
/**
|
||||
* A method to create an object URL of this image at the desired
|
||||
* resolution. Especially useful for KTX2 textures which are GPU compressed,
|
||||
* and so are unreadable on the CPU without a method like this.
|
||||
*/
|
||||
createThumbnail(width: number, height: number): Promise<string>;
|
||||
|
||||
/**
|
||||
* Only applies to canvas textures. Call when the content of the canvas has
|
||||
* been updated and should be reflected in the model.
|
||||
*/
|
||||
update(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* An RGBA-encoded color, with channels represented as floating point values
|
||||
* from [0,1].
|
||||
*/
|
||||
export declare type RGBA = [number, number, number, number];
|
||||
export declare type RGB = [number, number, number];
|
||||
@@ -0,0 +1,140 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Mesh, MeshBasicMaterial, OrthographicCamera, PlaneGeometry, Scene, Texture as ThreeTexture, WebGLRenderTarget} from 'three';
|
||||
|
||||
import {blobCanvas} from '../../model-viewer-base.js';
|
||||
import {Renderer} from '../../three-components/Renderer.js';
|
||||
|
||||
import {Image as ImageInterface} from './api.js';
|
||||
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
|
||||
const quadMaterial = new MeshBasicMaterial();
|
||||
const quad = new PlaneGeometry(2, 2);
|
||||
let adhocNum = 0;
|
||||
|
||||
export const $threeTexture = Symbol('threeTexture');
|
||||
export const $threeTextures = Symbol('threeTextures');
|
||||
export const $applyTexture = Symbol('applyTexture');
|
||||
|
||||
/**
|
||||
* Image facade implementation for Three.js textures
|
||||
*/
|
||||
export class Image extends ThreeDOMElement implements ImageInterface {
|
||||
get[$threeTexture]() {
|
||||
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
|
||||
}
|
||||
|
||||
get[$threeTextures](): Set<ThreeTexture> {
|
||||
return this[$correlatedObjects] as Set<ThreeTexture>;
|
||||
}
|
||||
|
||||
constructor(onUpdate: () => void, texture: ThreeTexture) {
|
||||
super(onUpdate, new Set<ThreeTexture>(texture ? [texture] : []));
|
||||
|
||||
if (!this[$threeTexture].image.src) {
|
||||
this[$threeTexture].image.src =
|
||||
texture.name ? texture.name : 'adhoc_image' + adhocNum++;
|
||||
}
|
||||
if (!this[$threeTexture].image.name) {
|
||||
this[$threeTexture].image.name =
|
||||
(texture && texture.image && texture.image.src) ?
|
||||
texture.image.src.split('/').pop() :
|
||||
'adhoc_image';
|
||||
}
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this[$threeTexture].image.name || '';
|
||||
}
|
||||
|
||||
get uri(): string|undefined {
|
||||
return this[$threeTexture].image.src;
|
||||
}
|
||||
|
||||
get bufferView(): number|undefined {
|
||||
return this[$threeTexture].image.bufferView;
|
||||
}
|
||||
|
||||
get element(): HTMLVideoElement|HTMLCanvasElement|undefined {
|
||||
const texture = this[$threeTexture] as any;
|
||||
if (texture && (texture.isCanvasTexture || texture.isVideoTexture)) {
|
||||
return texture.image;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
get animation(): any|undefined {
|
||||
const texture = this[$threeTexture] as any;
|
||||
if (texture && texture.isCanvasTexture && texture.animation) {
|
||||
return texture.animation;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
get type(): 'embedded'|'external' {
|
||||
return this.uri != null ? 'external' : 'embedded';
|
||||
}
|
||||
|
||||
set name(name: string) {
|
||||
for (const texture of this[$threeTextures]) {
|
||||
texture.image.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
update() {
|
||||
const texture = this[$threeTexture] as any;
|
||||
// Applies to non-Lottie canvas textures only
|
||||
if (texture && texture.isCanvasTexture && !texture.animation) {
|
||||
this[$threeTexture].needsUpdate = true;
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
|
||||
async createThumbnail(width: number, height: number): Promise<string> {
|
||||
const scene = new Scene();
|
||||
quadMaterial.map = this[$threeTexture];
|
||||
const mesh = new Mesh(quad, quadMaterial);
|
||||
scene.add(mesh);
|
||||
const camera = new OrthographicCamera(-1, 1, 1, -1, 0, 1);
|
||||
|
||||
const {threeRenderer} = Renderer.singleton;
|
||||
const renderTarget = new WebGLRenderTarget(width, height);
|
||||
threeRenderer.setRenderTarget(renderTarget);
|
||||
threeRenderer.render(scene, camera);
|
||||
threeRenderer.setRenderTarget(null);
|
||||
|
||||
const buffer = new Uint8Array(width * height * 4);
|
||||
threeRenderer.readRenderTargetPixels(
|
||||
renderTarget, 0, 0, width, height, buffer);
|
||||
|
||||
blobCanvas.width = width;
|
||||
blobCanvas.height = height;
|
||||
const blobContext = blobCanvas.getContext('2d')!;
|
||||
const imageData = blobContext.createImageData(width, height);
|
||||
imageData.data.set(buffer);
|
||||
blobContext.putImageData(imageData, 0, 0);
|
||||
|
||||
return new Promise<string>(async (resolve, reject) => {
|
||||
blobCanvas.toBlob(blob => {
|
||||
if (!blob) {
|
||||
return reject('Failed to capture thumbnail.');
|
||||
}
|
||||
resolve(URL.createObjectURL(blob));
|
||||
}, 'image/png');
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,689 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Color, ColorRepresentation, DoubleSide, FrontSide, MeshPhysicalMaterial, Vector2} from 'three';
|
||||
|
||||
import {AlphaMode, RGB} from '../../three-components/gltf-instance/gltf-2.0.js';
|
||||
|
||||
import {Material as MaterialInterface} from './api.js';
|
||||
import {LazyLoader, VariantData} from './model.js';
|
||||
import {PBRMetallicRoughness} from './pbr-metallic-roughness.js';
|
||||
import {TextureInfo, TextureUsage} from './texture-info.js';
|
||||
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
|
||||
|
||||
const $pbrMetallicRoughness = Symbol('pbrMetallicRoughness');
|
||||
const $normalTexture = Symbol('normalTexture');
|
||||
const $occlusionTexture = Symbol('occlusionTexture');
|
||||
const $emissiveTexture = Symbol('emissiveTexture');
|
||||
const $backingThreeMaterial = Symbol('backingThreeMaterial');
|
||||
const $applyAlphaCutoff = Symbol('applyAlphaCutoff');
|
||||
const $getAlphaMode = Symbol('getAlphaMode');
|
||||
export const $lazyLoadGLTFInfo = Symbol('lazyLoadGLTFInfo');
|
||||
const $initialize = Symbol('initialize');
|
||||
export const $getLoadedMaterial = Symbol('getLoadedMaterial');
|
||||
export const $ensureMaterialIsLoaded = Symbol('ensureMaterialIsLoaded');
|
||||
export const $gltfIndex = Symbol('gltfIndex');
|
||||
export const $setActive = Symbol('setActive');
|
||||
export const $variantIndices = Symbol('variantIndices');
|
||||
const $isActive = Symbol('isActive');
|
||||
const $modelVariants = Symbol('modelVariants');
|
||||
const $name = Symbol('name');
|
||||
const $pbrTextures = Symbol('pbrTextures');
|
||||
|
||||
/**
|
||||
* Material facade implementation for Three.js materials
|
||||
*/
|
||||
export class Material extends ThreeDOMElement implements MaterialInterface {
|
||||
private[$pbrMetallicRoughness]!: PBRMetallicRoughness;
|
||||
private[$normalTexture]!: TextureInfo;
|
||||
private[$occlusionTexture]!: TextureInfo;
|
||||
private[$emissiveTexture]!: TextureInfo;
|
||||
private[$lazyLoadGLTFInfo]?: LazyLoader;
|
||||
private[$gltfIndex]: number;
|
||||
private[$isActive]: boolean;
|
||||
public[$variantIndices] = new Set<number>();
|
||||
private[$name]?: string;
|
||||
readonly[$modelVariants]: Map<string, VariantData>;
|
||||
private[$pbrTextures] = new Map<TextureUsage, TextureInfo>();
|
||||
|
||||
get[$backingThreeMaterial](): MeshPhysicalMaterial {
|
||||
return (this[$correlatedObjects] as Set<MeshPhysicalMaterial>)
|
||||
.values()
|
||||
.next()
|
||||
.value!;
|
||||
}
|
||||
|
||||
constructor(
|
||||
onUpdate: () => void,
|
||||
gltfIndex: number,
|
||||
isActive: boolean,
|
||||
modelVariants: Map<string, VariantData>,
|
||||
correlatedMaterials: Set<MeshPhysicalMaterial>,
|
||||
name: string|undefined,
|
||||
lazyLoadInfo: LazyLoader|undefined = undefined,
|
||||
) {
|
||||
super(onUpdate, correlatedMaterials);
|
||||
this[$gltfIndex] = gltfIndex;
|
||||
this[$isActive] = isActive;
|
||||
this[$modelVariants] = modelVariants;
|
||||
this[$name] = name;
|
||||
|
||||
if (lazyLoadInfo == null) {
|
||||
this[$initialize]();
|
||||
} else {
|
||||
this[$lazyLoadGLTFInfo] = lazyLoadInfo;
|
||||
}
|
||||
}
|
||||
|
||||
private[$initialize](): void {
|
||||
const onUpdate = this[$onUpdate] as () => void;
|
||||
const correlatedMaterials =
|
||||
this[$correlatedObjects] as Set<MeshPhysicalMaterial>;
|
||||
|
||||
this[$pbrMetallicRoughness] =
|
||||
new PBRMetallicRoughness(onUpdate, correlatedMaterials);
|
||||
|
||||
const {normalMap, aoMap, emissiveMap} =
|
||||
correlatedMaterials.values().next().value!;
|
||||
|
||||
this[$normalTexture] = new TextureInfo(
|
||||
onUpdate,
|
||||
TextureUsage.Normal,
|
||||
normalMap,
|
||||
correlatedMaterials,
|
||||
);
|
||||
|
||||
this[$occlusionTexture] = new TextureInfo(
|
||||
onUpdate,
|
||||
TextureUsage.Occlusion,
|
||||
aoMap,
|
||||
correlatedMaterials,
|
||||
);
|
||||
|
||||
this[$emissiveTexture] = new TextureInfo(
|
||||
onUpdate,
|
||||
TextureUsage.Emissive,
|
||||
emissiveMap,
|
||||
correlatedMaterials,
|
||||
);
|
||||
|
||||
const createTextureInfo = (usage: TextureUsage) => {
|
||||
this[$pbrTextures].set(
|
||||
usage,
|
||||
new TextureInfo(
|
||||
onUpdate,
|
||||
usage,
|
||||
null,
|
||||
correlatedMaterials,
|
||||
));
|
||||
};
|
||||
|
||||
createTextureInfo(TextureUsage.Clearcoat);
|
||||
createTextureInfo(TextureUsage.ClearcoatRoughness);
|
||||
createTextureInfo(TextureUsage.ClearcoatNormal);
|
||||
createTextureInfo(TextureUsage.SheenColor);
|
||||
createTextureInfo(TextureUsage.SheenRoughness);
|
||||
createTextureInfo(TextureUsage.Transmission);
|
||||
createTextureInfo(TextureUsage.Thickness);
|
||||
createTextureInfo(TextureUsage.Specular);
|
||||
createTextureInfo(TextureUsage.SpecularColor);
|
||||
createTextureInfo(TextureUsage.Iridescence);
|
||||
createTextureInfo(TextureUsage.IridescenceThickness);
|
||||
createTextureInfo(TextureUsage.Anisotropy);
|
||||
}
|
||||
|
||||
async[$getLoadedMaterial](): Promise<MeshPhysicalMaterial|null> {
|
||||
if (this[$lazyLoadGLTFInfo] != null) {
|
||||
const material = await this[$lazyLoadGLTFInfo]!.doLazyLoad();
|
||||
|
||||
this[$initialize]();
|
||||
// Releases lazy load info.
|
||||
this[$lazyLoadGLTFInfo] = undefined;
|
||||
// Redefines the method as a noop method.
|
||||
this.ensureLoaded = async () => {};
|
||||
return material as MeshPhysicalMaterial;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private colorFromRgb(rgb: RGB|string): Color {
|
||||
const color = new Color();
|
||||
if (rgb instanceof Array) {
|
||||
color.fromArray(rgb);
|
||||
} else {
|
||||
color.set(rgb as ColorRepresentation);
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
[$ensureMaterialIsLoaded]() {
|
||||
if (this[$lazyLoadGLTFInfo] == null) {
|
||||
return;
|
||||
}
|
||||
throw new Error(`Material "${this.name}" has not been loaded, call 'await
|
||||
myMaterial.ensureLoaded()' before using an unloaded material.`);
|
||||
}
|
||||
|
||||
async ensureLoaded() {
|
||||
await this[$getLoadedMaterial]();
|
||||
}
|
||||
|
||||
get isLoaded() {
|
||||
return this[$lazyLoadGLTFInfo] == null;
|
||||
}
|
||||
|
||||
get isActive(): boolean {
|
||||
return this[$isActive];
|
||||
}
|
||||
|
||||
[$setActive](isActive: boolean) {
|
||||
this[$isActive] = isActive;
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this[$name] || '';
|
||||
}
|
||||
|
||||
set name(name: string) {
|
||||
this[$name] = name;
|
||||
if (this[$correlatedObjects] != null) {
|
||||
for (const threeMaterial of this[$correlatedObjects]!) {
|
||||
threeMaterial.name = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get pbrMetallicRoughness(): PBRMetallicRoughness {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrMetallicRoughness];
|
||||
}
|
||||
|
||||
get normalTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$normalTexture];
|
||||
}
|
||||
|
||||
get occlusionTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$occlusionTexture];
|
||||
}
|
||||
|
||||
get emissiveTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$emissiveTexture];
|
||||
}
|
||||
|
||||
get emissiveFactor(): RGB {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial].emissive.toArray() as RGB);
|
||||
}
|
||||
|
||||
get index(): number {
|
||||
return this[$gltfIndex];
|
||||
}
|
||||
|
||||
hasVariant(name: string): boolean {
|
||||
const variantData = this[$modelVariants].get(name);
|
||||
return variantData != null && this[$variantIndices].has(variantData.index);
|
||||
}
|
||||
|
||||
setEmissiveFactor(rgb: RGB|string) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const color = this.colorFromRgb(rgb);
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.emissive.set(color);
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
[$getAlphaMode](): string {
|
||||
// Follows implementation of GLTFExporter from three.js
|
||||
if (this[$backingThreeMaterial].transparent) {
|
||||
return 'BLEND';
|
||||
} else {
|
||||
if (this[$backingThreeMaterial].alphaTest > 0.0) {
|
||||
return 'MASK';
|
||||
}
|
||||
}
|
||||
return 'OPAQUE';
|
||||
}
|
||||
|
||||
[$applyAlphaCutoff]() {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
if (this[$getAlphaMode]() === 'MASK') {
|
||||
if (material.alphaTest == undefined) {
|
||||
material.alphaTest = 0.5;
|
||||
}
|
||||
} else {
|
||||
(material.alphaTest as number | undefined) = undefined;
|
||||
}
|
||||
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
setAlphaCutoff(cutoff: number): void {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.alphaTest = cutoff;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
// Set AlphaCutoff to undefined if AlphaMode is not MASK.
|
||||
this[$applyAlphaCutoff]();
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
getAlphaCutoff(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].alphaTest;
|
||||
}
|
||||
|
||||
setDoubleSided(doubleSided: boolean): void {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
// When double-sided is disabled gltf spec dictates that Back-Face culling
|
||||
// must be disabled, in three.js parlance that would mean FrontSide
|
||||
// rendering only.
|
||||
// https://github.com/KhronosGroup/glTF/tree/master/specification/2.0#double-sided
|
||||
material.side = doubleSided ? DoubleSide : FrontSide;
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
getDoubleSided(): boolean {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].side == DoubleSide;
|
||||
}
|
||||
|
||||
setAlphaMode(alphaMode: AlphaMode): void {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const enableTransparency =
|
||||
(material: MeshPhysicalMaterial, enabled: boolean): void => {
|
||||
material.transparent = enabled;
|
||||
material.depthWrite = !enabled;
|
||||
};
|
||||
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
enableTransparency(material, alphaMode === 'BLEND');
|
||||
if (alphaMode === 'MASK') {
|
||||
material.alphaTest = 0.5;
|
||||
} else {
|
||||
(material.alphaTest as number | undefined) = undefined;
|
||||
}
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
getAlphaMode(): AlphaMode {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$getAlphaMode]() as AlphaMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* PBR Next properties.
|
||||
*/
|
||||
|
||||
// KHR_materials_emissive_strength
|
||||
get emissiveStrength(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].emissiveIntensity;
|
||||
}
|
||||
|
||||
setEmissiveStrength(emissiveStrength: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.emissiveIntensity = emissiveStrength;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_clearcoat
|
||||
get clearcoatFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].clearcoat;
|
||||
}
|
||||
|
||||
get clearcoatRoughnessFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].clearcoatRoughness;
|
||||
}
|
||||
|
||||
get clearcoatTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Clearcoat)!;
|
||||
}
|
||||
|
||||
get clearcoatRoughnessTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.ClearcoatRoughness)!;
|
||||
}
|
||||
|
||||
get clearcoatNormalTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.ClearcoatNormal)!;
|
||||
}
|
||||
|
||||
get clearcoatNormalScale(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].clearcoatNormalScale.x;
|
||||
}
|
||||
|
||||
setClearcoatFactor(clearcoatFactor: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.clearcoat = clearcoatFactor;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setClearcoatRoughnessFactor(clearcoatRoughnessFactor: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.clearcoatRoughness = clearcoatRoughnessFactor;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setClearcoatNormalScale(clearcoatNormalScale: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.clearcoatNormalScale =
|
||||
new Vector2(clearcoatNormalScale, clearcoatNormalScale);
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_ior
|
||||
get ior(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].ior;
|
||||
}
|
||||
|
||||
setIor(ior: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.ior = ior;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_sheen
|
||||
get sheenColorFactor(): RGB {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial].sheenColor.toArray() as RGB);
|
||||
}
|
||||
get sheenColorTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.SheenColor)!;
|
||||
}
|
||||
|
||||
get sheenRoughnessFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].sheenRoughness;
|
||||
}
|
||||
|
||||
get sheenRoughnessTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.SheenRoughness)!;
|
||||
}
|
||||
|
||||
setSheenColorFactor(rgb: RGB|string) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const color = this.colorFromRgb(rgb);
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.sheenColor.set(color);
|
||||
// Three.js GLTFExporter checks for internal sheen value.
|
||||
material.sheen = 1;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setSheenRoughnessFactor(roughness: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.sheenRoughness = roughness;
|
||||
// Three.js GLTFExporter checks for internal sheen value.
|
||||
material.sheen = 1;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_transmission
|
||||
get transmissionFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].transmission;
|
||||
}
|
||||
|
||||
get transmissionTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Transmission)!;
|
||||
}
|
||||
|
||||
setTransmissionFactor(transmission: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.transmission = transmission;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_volume
|
||||
get thicknessFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].thickness;
|
||||
}
|
||||
|
||||
get thicknessTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Thickness)!;
|
||||
}
|
||||
|
||||
get attenuationDistance(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].attenuationDistance;
|
||||
}
|
||||
|
||||
get attenuationColor(): RGB {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial].attenuationColor.toArray() as RGB);
|
||||
}
|
||||
|
||||
setThicknessFactor(thickness: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.thickness = thickness;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setAttenuationDistance(attenuationDistance: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.attenuationDistance = attenuationDistance;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setAttenuationColor(rgb: RGB|string) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const color = this.colorFromRgb(rgb);
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.attenuationColor.set(color);
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_specular
|
||||
get specularFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].specularIntensity;
|
||||
}
|
||||
|
||||
get specularTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Specular)!;
|
||||
}
|
||||
|
||||
get specularColorFactor(): RGB {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial].specularColor.toArray() as RGB);
|
||||
}
|
||||
|
||||
get specularColorTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.SheenColor)!;
|
||||
}
|
||||
|
||||
setSpecularFactor(specularFactor: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.specularIntensity = specularFactor;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setSpecularColorFactor(rgb: RGB|string) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
const color = this.colorFromRgb(rgb);
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.specularColor.set(color);
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_iridescence
|
||||
get iridescenceFactor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].iridescence;
|
||||
}
|
||||
|
||||
get iridescenceTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Iridescence)!;
|
||||
}
|
||||
|
||||
get iridescenceIor(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].iridescenceIOR;
|
||||
}
|
||||
|
||||
get iridescenceThicknessMinimum(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].iridescenceThicknessRange[0];
|
||||
}
|
||||
|
||||
get iridescenceThicknessMaximum(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$backingThreeMaterial].iridescenceThicknessRange[1];
|
||||
}
|
||||
|
||||
get iridescenceThicknessTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.IridescenceThickness)!;
|
||||
}
|
||||
|
||||
setIridescenceFactor(iridescence: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.iridescence = iridescence;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setIridescenceIor(ior: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.iridescenceIOR = ior;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setIridescenceThicknessMinimum(thicknessMin: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.iridescenceThicknessRange[0] = thicknessMin;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setIridescenceThicknessMaximum(thicknessMax: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
material.iridescenceThicknessRange[1] = thicknessMax;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
// KHR_materials_anisotropy
|
||||
get anisotropyStrength(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial] as any).anisotropy;
|
||||
}
|
||||
|
||||
get anisotropyRotation(): number {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return (this[$backingThreeMaterial] as any).anisotropyRotation;
|
||||
}
|
||||
|
||||
get anisotropyTexture(): TextureInfo {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
return this[$pbrTextures].get(TextureUsage.Anisotropy)!;
|
||||
}
|
||||
|
||||
setAnisotropyStrength(strength: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
(material as any).anisotropy = strength;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setAnisotropyRotation(rotation: number) {
|
||||
this[$ensureMaterialIsLoaded]();
|
||||
for (const material of this[$correlatedObjects] as
|
||||
Set<MeshPhysicalMaterial>) {
|
||||
(material as any).anisotropyRotation = rotation;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Intersection, Material as ThreeMaterial, Mesh, MeshPhysicalMaterial, Object3D} from 'three';
|
||||
|
||||
import {CorrelatedSceneGraph, GLTFElementToThreeObjectMap} from '../../three-components/gltf-instance/correlated-scene-graph.js';
|
||||
import {GLTF, GLTFElement} from '../../three-components/gltf-instance/gltf-2.0.js';
|
||||
|
||||
import {Model as ModelInterface} from './api.js';
|
||||
import {$setActive, $variantIndices, Material} from './material.js';
|
||||
import {Node, PrimitiveNode} from './nodes/primitive-node.js';
|
||||
import {$correlatedObjects} from './three-dom-element.js';
|
||||
|
||||
|
||||
|
||||
export const $materials = Symbol('materials');
|
||||
const $hierarchy = Symbol('hierarchy');
|
||||
const $roots = Symbol('roots');
|
||||
export const $primitivesList = Symbol('primitives');
|
||||
export const $loadVariant = Symbol('loadVariant');
|
||||
export const $prepareVariantsForExport = Symbol('prepareVariantsForExport');
|
||||
export const $switchVariant = Symbol('switchVariant');
|
||||
export const $materialFromPoint = Symbol('materialFromPoint');
|
||||
export const $nodeFromPoint = Symbol('nodeFromPoint');
|
||||
export const $nodeFromIndex = Symbol('nodeFromIndex');
|
||||
export const $variantData = Symbol('variantData');
|
||||
export const $availableVariants = Symbol('availableVariants');
|
||||
const $modelOnUpdate = Symbol('modelOnUpdate');
|
||||
const $cloneMaterial = Symbol('cloneMaterial');
|
||||
|
||||
// Holds onto temporary scene context information needed to perform lazy loading
|
||||
// of a resource.
|
||||
export class LazyLoader {
|
||||
gltf: GLTF;
|
||||
gltfElementMap: GLTFElementToThreeObjectMap;
|
||||
mapKey: GLTFElement;
|
||||
doLazyLoad: () => Promise<ThreeMaterial>;
|
||||
constructor(
|
||||
gltf: GLTF, gltfElementMap: GLTFElementToThreeObjectMap,
|
||||
mapKey: GLTFElement, doLazyLoad: () => Promise<ThreeMaterial>) {
|
||||
this.gltf = gltf;
|
||||
this.gltfElementMap = gltfElementMap;
|
||||
this.mapKey = mapKey;
|
||||
this.doLazyLoad = doLazyLoad;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Facades variant mapping data.
|
||||
*/
|
||||
export interface VariantData {
|
||||
name: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Model facades the top-level GLTF object returned by Three.js' GLTFLoader.
|
||||
* Currently, the model only bothers itself with the materials in the Three.js
|
||||
* scene graph.
|
||||
*/
|
||||
export class Model implements ModelInterface {
|
||||
private[$materials] = new Array<Material>();
|
||||
private[$hierarchy] = new Array<Node>();
|
||||
private[$roots] = new Array<Node>();
|
||||
private[$primitivesList] = new Array<PrimitiveNode>();
|
||||
private[$modelOnUpdate]: () => void = () => {};
|
||||
private[$variantData] = new Map<string, VariantData>();
|
||||
|
||||
constructor(
|
||||
correlatedSceneGraph: CorrelatedSceneGraph,
|
||||
onUpdate: () => void = () => {}) {
|
||||
this[$modelOnUpdate] = onUpdate;
|
||||
const {gltf, threeGLTF, gltfElementMap} = correlatedSceneGraph;
|
||||
|
||||
for (const [i, material] of gltf.materials!.entries()) {
|
||||
const correlatedMaterial =
|
||||
gltfElementMap.get(material) as Set<MeshPhysicalMaterial>| null;
|
||||
|
||||
if (correlatedMaterial != null) {
|
||||
this[$materials].push(new Material(
|
||||
onUpdate,
|
||||
i,
|
||||
true,
|
||||
this[$variantData],
|
||||
correlatedMaterial,
|
||||
material.name));
|
||||
} else {
|
||||
const elementArray = gltf['materials'] || [];
|
||||
const gltfMaterialDef = elementArray[i];
|
||||
|
||||
const threeMaterialSet = new Set<MeshPhysicalMaterial>();
|
||||
gltfElementMap.set(gltfMaterialDef, threeMaterialSet);
|
||||
const materialLoadCallback = async () => {
|
||||
const threeMaterial = await threeGLTF.parser.getDependency(
|
||||
'material', i) as MeshPhysicalMaterial;
|
||||
threeMaterialSet.add(threeMaterial);
|
||||
|
||||
return threeMaterial;
|
||||
};
|
||||
|
||||
// Configures the material for lazy loading.
|
||||
this[$materials].push(new Material(
|
||||
onUpdate,
|
||||
i,
|
||||
false,
|
||||
this[$variantData],
|
||||
threeMaterialSet,
|
||||
material.name,
|
||||
new LazyLoader(
|
||||
gltf, gltfElementMap, gltfMaterialDef, materialLoadCallback)));
|
||||
}
|
||||
}
|
||||
|
||||
// Creates a hierarchy of Nodes. Allows not just for switching which
|
||||
// material is applied to a mesh but also exposes a way to provide API
|
||||
// for switching materials and general assignment/modification.
|
||||
|
||||
// Prepares for scene iteration.
|
||||
const parentMap = new Map<object, Node>();
|
||||
const nodeStack = new Array<Object3D>();
|
||||
for (const object of threeGLTF.scene.children) {
|
||||
nodeStack.push(object);
|
||||
}
|
||||
|
||||
// Walks the hierarchy and creates a node tree.
|
||||
while (nodeStack.length > 0) {
|
||||
const object = nodeStack.pop()!;
|
||||
|
||||
let node: Node|null = null;
|
||||
|
||||
if (object instanceof Mesh) {
|
||||
node = new PrimitiveNode(
|
||||
object as Mesh,
|
||||
this.materials,
|
||||
this[$variantData],
|
||||
correlatedSceneGraph);
|
||||
this[$primitivesList].push(node as PrimitiveNode);
|
||||
} else {
|
||||
node = new Node(object.name);
|
||||
}
|
||||
|
||||
const parent: Node|undefined = parentMap.get(object);
|
||||
if (parent != null) {
|
||||
parent.children.push(node);
|
||||
} else {
|
||||
this[$roots].push(node);
|
||||
}
|
||||
this[$hierarchy].push(node);
|
||||
|
||||
for (const child of object.children) {
|
||||
nodeStack.push(child);
|
||||
parentMap.set(object, node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Materials are listed in the order of the GLTF materials array, plus a
|
||||
* default material at the end if one is used.
|
||||
*
|
||||
* TODO(#1003): How do we handle non-active scenes?
|
||||
*/
|
||||
get materials(): Material[] {
|
||||
return this[$materials];
|
||||
}
|
||||
|
||||
[$availableVariants]() {
|
||||
const variants = Array.from(this[$variantData].values());
|
||||
variants.sort((a, b) => {
|
||||
return a.index - b.index;
|
||||
});
|
||||
|
||||
return variants.map((data) => {
|
||||
return data.name;
|
||||
});
|
||||
}
|
||||
|
||||
getMaterialByName(name: string): Material|null {
|
||||
const matches = this[$materials].filter(material => {
|
||||
return material.name === name;
|
||||
});
|
||||
|
||||
if (matches.length > 0) {
|
||||
return matches[0];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
[$nodeFromIndex](mesh: number, primitive: number): PrimitiveNode|null {
|
||||
const found = this[$hierarchy].find((node: Node) => {
|
||||
if (node instanceof PrimitiveNode) {
|
||||
const {meshes, primitives} = node.mesh.userData.associations;
|
||||
if (meshes == mesh && primitives == primitive) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
return found == null ? null : found as PrimitiveNode;
|
||||
}
|
||||
|
||||
[$nodeFromPoint](hit: Intersection<Object3D>): PrimitiveNode {
|
||||
return this[$hierarchy].find((node: Node) => {
|
||||
if (node instanceof PrimitiveNode) {
|
||||
const primitive = node as PrimitiveNode;
|
||||
if (primitive.mesh === hit.object) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}) as PrimitiveNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Intersects a ray with the Model and returns the first material whose
|
||||
* object was intersected.
|
||||
*/
|
||||
[$materialFromPoint](hit: Intersection<Object3D>): Material {
|
||||
return this[$nodeFromPoint](hit).getActiveMaterial();
|
||||
}
|
||||
|
||||
/**
|
||||
* Switches model variant to the variant name provided, or switches to
|
||||
* default/initial materials if 'null' is provided.
|
||||
*/
|
||||
async[$switchVariant](variantName: string|null) {
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
await primitive.enableVariant(variantName);
|
||||
}
|
||||
|
||||
for (const material of this.materials) {
|
||||
material[$setActive](false);
|
||||
}
|
||||
// Marks the materials that are now in use after the variant switch.
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
this.materials[primitive.getActiveMaterial().index][$setActive](true);
|
||||
}
|
||||
}
|
||||
|
||||
async[$prepareVariantsForExport]() {
|
||||
const promises = new Array<Promise<void>>();
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
promises.push(primitive.instantiateVariants());
|
||||
}
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
[$cloneMaterial](index: number, newMaterialName: string): Material {
|
||||
const material = this.materials[index];
|
||||
|
||||
if (!material.isLoaded) {
|
||||
console.error(`Cloning an unloaded material,
|
||||
call 'material.ensureLoaded() before cloning the material.`);
|
||||
}
|
||||
|
||||
const threeMaterialSet =
|
||||
material[$correlatedObjects] as Set<MeshPhysicalMaterial>;
|
||||
|
||||
const clonedSet = new Set<MeshPhysicalMaterial>();
|
||||
for (const [i, threeMaterial] of threeMaterialSet.entries()) {
|
||||
const clone = threeMaterial.clone() as MeshPhysicalMaterial;
|
||||
clone.name =
|
||||
newMaterialName + (threeMaterialSet.size > 1 ? '_inst' + i : '');
|
||||
clonedSet.add(clone);
|
||||
}
|
||||
|
||||
const clonedMaterial = new Material(
|
||||
this[$modelOnUpdate],
|
||||
this[$materials].length,
|
||||
false, // Cloned as inactive.
|
||||
this[$variantData],
|
||||
clonedSet,
|
||||
newMaterialName);
|
||||
|
||||
this[$materials].push(clonedMaterial);
|
||||
|
||||
return clonedMaterial;
|
||||
}
|
||||
|
||||
createMaterialInstanceForVariant(
|
||||
originalMaterialIndex: number, newMaterialName: string,
|
||||
variantName: string, activateVariant: boolean = true): Material|null {
|
||||
let variantMaterialInstance: Material|null = null;
|
||||
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
const variantData = this[$variantData].get(variantName);
|
||||
// Skips the primitive if the variant already exists.
|
||||
if (variantData != null && primitive.variantInfo.has(variantData.index)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skips the primitive if the source/original material does not exist.
|
||||
if (primitive.getMaterial(originalMaterialIndex) == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!this.hasVariant(variantName)) {
|
||||
this.createVariant(variantName);
|
||||
}
|
||||
|
||||
if (variantMaterialInstance == null) {
|
||||
variantMaterialInstance =
|
||||
this[$cloneMaterial](originalMaterialIndex, newMaterialName);
|
||||
}
|
||||
primitive.addVariant(variantMaterialInstance, variantName)
|
||||
}
|
||||
|
||||
if (activateVariant && variantMaterialInstance != null) {
|
||||
(variantMaterialInstance as Material)[$setActive](true);
|
||||
this.materials[originalMaterialIndex][$setActive](false);
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
primitive.enableVariant(variantName);
|
||||
}
|
||||
}
|
||||
|
||||
return variantMaterialInstance;
|
||||
}
|
||||
|
||||
createVariant(variantName: string) {
|
||||
if (!this[$variantData].has(variantName)) {
|
||||
// Adds the name if it's not already in the list.
|
||||
this[$variantData].set(
|
||||
variantName,
|
||||
{name: variantName, index: this[$variantData].size} as VariantData);
|
||||
} else {
|
||||
console.warn(`Variant '${variantName}'' already exists`);
|
||||
}
|
||||
}
|
||||
|
||||
hasVariant(variantName: string) {
|
||||
return this[$variantData].has(variantName);
|
||||
}
|
||||
|
||||
setMaterialToVariant(materialIndex: number, targetVariantName: string) {
|
||||
if (this[$availableVariants]().find(name => name === targetVariantName) ==
|
||||
null) {
|
||||
console.warn(`Can't add material to '${
|
||||
targetVariantName}', the variant does not exist.'`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (materialIndex < 0 || materialIndex >= this.materials.length) {
|
||||
console.error(`setMaterialToVariant(): materialIndex is out of bounds.`);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
const material = primitive.getMaterial(materialIndex);
|
||||
// Ensures the material exists on the primitive before setting it to a
|
||||
// variant.
|
||||
if (material != null) {
|
||||
primitive.addVariant(material, targetVariantName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateVariantName(currentName: string, newName: string) {
|
||||
const variantData = this[$variantData].get(currentName);
|
||||
if (variantData == null) {
|
||||
return;
|
||||
}
|
||||
variantData.name = newName;
|
||||
this[$variantData].set(newName, variantData!);
|
||||
this[$variantData].delete(currentName);
|
||||
}
|
||||
|
||||
deleteVariant(variantName: string) {
|
||||
const variant = this[$variantData].get(variantName);
|
||||
if (variant == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const material of this.materials) {
|
||||
if (material.hasVariant(variantName)) {
|
||||
material[$variantIndices].delete(variant.index);
|
||||
}
|
||||
}
|
||||
|
||||
for (const primitive of this[$primitivesList]) {
|
||||
primitive.deleteVariant(variant.index);
|
||||
}
|
||||
|
||||
this[$variantData].delete(variantName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {Material as ThreeMaterial, Mesh, MeshPhysicalMaterial} from 'three';
|
||||
import {GLTFParser, GLTFReference} from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
import {CorrelatedSceneGraph} from '../../../three-components/gltf-instance/correlated-scene-graph.js';
|
||||
import {KHRMaterialsVariants, Primitive} from '../../../three-components/gltf-instance/gltf-2.0.js';
|
||||
import {UserDataVariantMapping} from '../../../three-components/gltf-instance/VariantMaterialLoaderPlugin.js';
|
||||
import {$getLoadedMaterial, $variantIndices, Material} from '../material.js';
|
||||
import {VariantData} from '../model.js';
|
||||
import {$correlatedObjects} from '../three-dom-element.js';
|
||||
|
||||
// Defines the base level node methods and data.
|
||||
export class Node {
|
||||
name: string = '';
|
||||
children = new Array<Node>();
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Represents a primitive in a glTF mesh.
|
||||
export class PrimitiveNode extends Node {
|
||||
public mesh: Mesh;
|
||||
// Maps glTF material index number to a material that this primitive supports.
|
||||
public materials = new Map<number, Material>();
|
||||
// Maps variant index to material.
|
||||
private variantToMaterialMap = new Map<number, Material>();
|
||||
public initialMaterialIdx = 0;
|
||||
private activeMaterialIdx = 0;
|
||||
private modelVariants: Map<string, VariantData>;
|
||||
private parser: GLTFParser;
|
||||
|
||||
constructor(
|
||||
mesh: Mesh, mvMaterials: Material[],
|
||||
modelVariants: Map<string, VariantData>,
|
||||
correlatedSceneGraph: CorrelatedSceneGraph) {
|
||||
super(mesh.name);
|
||||
this.mesh = mesh;
|
||||
const {gltf, threeGLTF, threeObjectMap} = correlatedSceneGraph;
|
||||
this.parser = threeGLTF.parser;
|
||||
this.modelVariants = modelVariants;
|
||||
this.mesh.userData.variantData = modelVariants;
|
||||
// Captures the primitive's initial material.
|
||||
const materialMappings =
|
||||
threeObjectMap.get(mesh.material as ThreeMaterial)!;
|
||||
if (materialMappings.materials != null) {
|
||||
this.initialMaterialIdx = this.activeMaterialIdx =
|
||||
materialMappings.materials;
|
||||
} else {
|
||||
console.error(
|
||||
`Primitive (${mesh.name}) missing initial material reference.`);
|
||||
}
|
||||
|
||||
// Gets the mesh index from the node.
|
||||
const associations =
|
||||
(mesh.userData.associations as GLTFReference & {primitives: number}) ||
|
||||
{};
|
||||
|
||||
if (associations.meshes == null) {
|
||||
console.error('Mesh is missing primitive index association');
|
||||
return;
|
||||
}
|
||||
// The gltf mesh array to sample from.
|
||||
const meshElementArray = gltf['meshes'] || [];
|
||||
// List of primitives under the mesh.
|
||||
const gltfPrimitives =
|
||||
(meshElementArray[associations.meshes].primitives || []) as Primitive[];
|
||||
|
||||
const gltfPrimitive = gltfPrimitives[associations.primitives];
|
||||
if (gltfPrimitive == null) {
|
||||
console.error('Mesh primitive definition is missing.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Maps the gltfPrimitive default to a material.
|
||||
if (gltfPrimitive.material != null) {
|
||||
this.materials.set(
|
||||
gltfPrimitive.material, mvMaterials[gltfPrimitive.material]);
|
||||
} else {
|
||||
const defaultIdx = mvMaterials.findIndex((mat: Material) => {
|
||||
return mat.name === 'Default';
|
||||
});
|
||||
if (defaultIdx >= 0) {
|
||||
this.materials.set(defaultIdx, mvMaterials[defaultIdx]);
|
||||
} else {
|
||||
console.warn('gltfPrimitive has no material!');
|
||||
}
|
||||
}
|
||||
|
||||
if (gltfPrimitive.extensions &&
|
||||
gltfPrimitive.extensions['KHR_materials_variants']) {
|
||||
const variantsExtension =
|
||||
gltfPrimitive.extensions['KHR_materials_variants'] as
|
||||
KHRMaterialsVariants;
|
||||
const extensions = threeGLTF.parser.json.extensions;
|
||||
const variantNames = extensions['KHR_materials_variants'].variants;
|
||||
// Provides definition now that we know there are variants to
|
||||
// support.
|
||||
for (const mapping of variantsExtension.mappings) {
|
||||
const mvMaterial = mvMaterials[mapping.material];
|
||||
// Maps variant indices to Materials.
|
||||
this.materials.set(mapping.material, mvMaterial);
|
||||
for (const variant of mapping.variants) {
|
||||
const {name} = variantNames[variant];
|
||||
this.variantToMaterialMap.set(variant, mvMaterial);
|
||||
// Provides variant info for material self lookup.
|
||||
mvMaterial[$variantIndices].add(variant);
|
||||
// Updates the models variant data.
|
||||
if (!modelVariants.has(name)) {
|
||||
modelVariants.set(name, {name, index: variant} as VariantData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async setActiveMaterial(material: number): Promise<ThreeMaterial|null> {
|
||||
const mvMaterial = this.materials.get(material)!;
|
||||
if (material !== this.activeMaterialIdx) {
|
||||
const backingMaterials =
|
||||
mvMaterial[$correlatedObjects] as Set<MeshPhysicalMaterial>;
|
||||
|
||||
const baseMaterial = await mvMaterial[$getLoadedMaterial]();
|
||||
if (baseMaterial != null) {
|
||||
this.mesh.material = baseMaterial;
|
||||
} else {
|
||||
this.mesh.material = backingMaterials.values().next().value!;
|
||||
}
|
||||
|
||||
this.parser.assignFinalMaterial(this.mesh);
|
||||
backingMaterials.add(this.mesh.material as MeshPhysicalMaterial);
|
||||
this.activeMaterialIdx = material;
|
||||
}
|
||||
return this.mesh.material as ThreeMaterial;
|
||||
}
|
||||
|
||||
getActiveMaterial(): Material {
|
||||
return this.materials.get(this.activeMaterialIdx)!;
|
||||
}
|
||||
|
||||
getMaterial(index: number): Material|undefined {
|
||||
return this.materials.get(index);
|
||||
}
|
||||
|
||||
async enableVariant(name: string|null): Promise<ThreeMaterial|null> {
|
||||
if (name == null) {
|
||||
return this.setActiveMaterial(this.initialMaterialIdx);
|
||||
}
|
||||
if (this.variantToMaterialMap != null && this.modelVariants.has(name)) {
|
||||
const modelVariants = this.modelVariants.get(name)!;
|
||||
return this.enableVariantHelper(modelVariants.index);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async enableVariantHelper(index: number|
|
||||
null): Promise<ThreeMaterial|null> {
|
||||
if (this.variantToMaterialMap != null && index != null) {
|
||||
const material = this.variantToMaterialMap.get(index);
|
||||
if (material != null) {
|
||||
return this.setActiveMaterial(material.index);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async instantiateVariants() {
|
||||
if (this.variantToMaterialMap == null) {
|
||||
return;
|
||||
}
|
||||
for (const index of this.variantToMaterialMap.keys()) {
|
||||
const variantMaterial = this.mesh.userData.variantMaterials.get(index) as
|
||||
UserDataVariantMapping;
|
||||
if (variantMaterial.material != null) {
|
||||
continue;
|
||||
}
|
||||
const threeMaterial = await this.enableVariantHelper(index);
|
||||
if (threeMaterial != null) {
|
||||
variantMaterial.material = threeMaterial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get variantInfo() {
|
||||
return this.variantToMaterialMap;
|
||||
}
|
||||
|
||||
addVariant(materialVariant: Material, variantName: string) {
|
||||
if (!this.ensureVariantIsUnused(variantName)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Adds the variant to the model variants if needed.
|
||||
if (!this.modelVariants.has(variantName)) {
|
||||
this.modelVariants.set(
|
||||
variantName, {name: variantName, index: this.modelVariants.size});
|
||||
}
|
||||
const modelVariantData = this.modelVariants.get(variantName)!;
|
||||
const variantIndex = modelVariantData.index;
|
||||
|
||||
// Updates materials mapped to the variant.
|
||||
materialVariant[$variantIndices].add(variantIndex);
|
||||
|
||||
// Updates internal mappings.
|
||||
this.variantToMaterialMap.set(variantIndex, materialVariant);
|
||||
this.materials.set(materialVariant.index, materialVariant);
|
||||
|
||||
this.updateVariantUserData(variantIndex, materialVariant);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
deleteVariant(variantIndex: number) {
|
||||
if (this.variantInfo.has(variantIndex)) {
|
||||
this.variantInfo.delete(variantIndex);
|
||||
|
||||
const userDataMap = this.mesh.userData.variantMaterials! as
|
||||
Map<number, UserDataVariantMapping>;
|
||||
if (userDataMap != null) {
|
||||
userDataMap.delete(variantIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private updateVariantUserData(
|
||||
variantIndex: number, materialVariant: Material) {
|
||||
// Adds variants name to material variants set.
|
||||
materialVariant[$variantIndices].add(variantIndex);
|
||||
|
||||
this.mesh.userData.variantData = this.modelVariants;
|
||||
// Updates import data (see VariantMaterialLoaderPlugin.ts).
|
||||
this.mesh.userData.variantMaterials = this.mesh.userData.variantMaterials ||
|
||||
new Map<number, UserDataVariantMapping>();
|
||||
const map = this.mesh.userData.variantMaterials! as
|
||||
Map<number, UserDataVariantMapping>;
|
||||
map.set(variantIndex, {
|
||||
material: materialVariant[$correlatedObjects]!.values().next().value as
|
||||
ThreeMaterial,
|
||||
gltfMaterialIndex: materialVariant.index,
|
||||
});
|
||||
}
|
||||
|
||||
private ensureVariantIsUnused(variantName: string) {
|
||||
const modelVariants = this.modelVariants.get(variantName);
|
||||
|
||||
if (modelVariants != null && this.variantInfo.has(modelVariants!.index)) {
|
||||
console.warn(`Primitive cannot add variant '${
|
||||
variantName}' for this material, it already exists.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Color, ColorRepresentation, MeshPhysicalMaterial} from 'three';
|
||||
|
||||
import {PBRMetallicRoughness as PBRMetallicRoughnessInterface, RGBA} from './api.js';
|
||||
import {TextureInfo, TextureUsage} from './texture-info.js';
|
||||
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
|
||||
const $threeMaterial = Symbol('threeMaterial');
|
||||
const $threeMaterials = Symbol('threeMaterials');
|
||||
const $baseColorTexture = Symbol('baseColorTexture');
|
||||
const $metallicRoughnessTexture = Symbol('metallicRoughnessTexture');
|
||||
|
||||
/**
|
||||
* PBR material properties facade implementation for Three.js materials
|
||||
*/
|
||||
export class PBRMetallicRoughness extends ThreeDOMElement implements
|
||||
PBRMetallicRoughnessInterface {
|
||||
private[$baseColorTexture]: TextureInfo;
|
||||
private[$metallicRoughnessTexture]: TextureInfo;
|
||||
|
||||
private get[$threeMaterials](): Set<MeshPhysicalMaterial> {
|
||||
return this[$correlatedObjects] as Set<MeshPhysicalMaterial>;
|
||||
}
|
||||
|
||||
private get[$threeMaterial]() {
|
||||
return this[$correlatedObjects]?.values().next().value as
|
||||
MeshPhysicalMaterial;
|
||||
}
|
||||
|
||||
constructor(
|
||||
onUpdate: () => void, correlatedMaterials: Set<MeshPhysicalMaterial>) {
|
||||
super(onUpdate, correlatedMaterials);
|
||||
|
||||
const {map, metalnessMap} = correlatedMaterials.values().next().value!;
|
||||
|
||||
this[$baseColorTexture] =
|
||||
new TextureInfo(onUpdate, TextureUsage.Base, map, correlatedMaterials);
|
||||
|
||||
this[$metallicRoughnessTexture] = new TextureInfo(
|
||||
onUpdate,
|
||||
TextureUsage.MetallicRoughness,
|
||||
metalnessMap,
|
||||
correlatedMaterials);
|
||||
}
|
||||
|
||||
|
||||
get baseColorFactor(): RGBA {
|
||||
const rgba = [0, 0, 0, this[$threeMaterial].opacity];
|
||||
this[$threeMaterial].color.toArray(rgba);
|
||||
return rgba as RGBA;
|
||||
}
|
||||
|
||||
get metallicFactor(): number {
|
||||
return this[$threeMaterial].metalness;
|
||||
}
|
||||
|
||||
get roughnessFactor(): number {
|
||||
return this[$threeMaterial].roughness;
|
||||
}
|
||||
|
||||
get baseColorTexture(): TextureInfo {
|
||||
return this[$baseColorTexture];
|
||||
}
|
||||
|
||||
get metallicRoughnessTexture(): TextureInfo {
|
||||
return this[$metallicRoughnessTexture];
|
||||
}
|
||||
|
||||
setBaseColorFactor(rgba: RGBA|string) {
|
||||
const color = new Color();
|
||||
if (rgba instanceof Array) {
|
||||
color.fromArray(rgba);
|
||||
} else {
|
||||
color.set(rgba as ColorRepresentation);
|
||||
}
|
||||
for (const material of this[$threeMaterials]) {
|
||||
material.color.set(color);
|
||||
if (rgba instanceof Array && rgba.length > 3) {
|
||||
material.opacity = rgba[3];
|
||||
} else {
|
||||
rgba = [0, 0, 0, material.opacity];
|
||||
color.toArray(rgba);
|
||||
}
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setMetallicFactor(value: number) {
|
||||
for (const material of this[$threeMaterials]) {
|
||||
material.metalness = value;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
|
||||
setRoughnessFactor(value: number) {
|
||||
for (const material of this[$threeMaterials]) {
|
||||
material.roughness = value;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ClampToEdgeWrapping, LinearFilter, LinearMipmapLinearFilter, LinearMipmapNearestFilter, MagnificationTextureFilter, MinificationTextureFilter, MirroredRepeatWrapping, NearestFilter, NearestMipmapLinearFilter, NearestMipmapNearestFilter, RepeatWrapping, Texture as ThreeTexture, Vector2, Wrapping} from 'three';
|
||||
|
||||
import {toVector2D, Vector2D} from '../../model-viewer-base.js';
|
||||
import {Filter, MagFilter, MinFilter, Wrap, WrapMode} from '../../three-components/gltf-instance/gltf-2.0.js';
|
||||
import {Sampler as DefaultedSampler} from '../../three-components/gltf-instance/gltf-defaulted.js';
|
||||
|
||||
import {Sampler as SamplerInterface, Vector2DInterface} from './api.js';
|
||||
import {$correlatedObjects, $onUpdate, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
// Convertion between gltf standards and threejs standards.
|
||||
const wrapModeToWrapping = new Map<WrapMode, Wrapping>([
|
||||
[Wrap.Repeat, RepeatWrapping],
|
||||
[Wrap.ClampToEdge, ClampToEdgeWrapping],
|
||||
[Wrap.MirroredRepeat, MirroredRepeatWrapping]
|
||||
]);
|
||||
const wrappingToWrapMode = new Map<Wrapping, WrapMode>([
|
||||
[RepeatWrapping, Wrap.Repeat],
|
||||
[ClampToEdgeWrapping, Wrap.ClampToEdge],
|
||||
[MirroredRepeatWrapping, Wrap.MirroredRepeat]
|
||||
]);
|
||||
const minFilterToMinification = new Map<MinFilter, MinificationTextureFilter>([
|
||||
[Filter.Nearest, NearestFilter],
|
||||
[Filter.Linear, LinearFilter],
|
||||
[Filter.NearestMipmapNearest, NearestMipmapNearestFilter],
|
||||
[Filter.LinearMipmapNearest, LinearMipmapNearestFilter],
|
||||
[Filter.NearestMipmapLinear, NearestMipmapLinearFilter],
|
||||
[Filter.LinearMipmapLinear, LinearMipmapLinearFilter]
|
||||
]);
|
||||
const minificationToMinFilter = new Map<MinificationTextureFilter, MinFilter>([
|
||||
[NearestFilter, Filter.Nearest],
|
||||
[LinearFilter, Filter.Linear],
|
||||
[NearestMipmapNearestFilter, Filter.NearestMipmapNearest],
|
||||
[LinearMipmapNearestFilter, Filter.LinearMipmapNearest],
|
||||
[NearestMipmapLinearFilter, Filter.NearestMipmapLinear],
|
||||
[LinearMipmapLinearFilter, Filter.LinearMipmapLinear]
|
||||
]);
|
||||
const magFilterToMagnification = new Map<MagFilter, MagnificationTextureFilter>(
|
||||
[[Filter.Nearest, NearestFilter], [Filter.Linear, LinearFilter]]);
|
||||
const magnificationToMagFilter = new Map<MagnificationTextureFilter, MagFilter>(
|
||||
[[NearestFilter, Filter.Nearest], [LinearFilter, Filter.Linear]]);
|
||||
|
||||
// Checks for threejs standards.
|
||||
const isMinFilter = (() => {
|
||||
return (value: unknown): value is MinificationTextureFilter =>
|
||||
minificationToMinFilter.has(value as MinificationTextureFilter);
|
||||
})();
|
||||
|
||||
const isMagFilter = (() => {
|
||||
return (value: unknown): value is MagnificationTextureFilter =>
|
||||
magnificationToMagFilter.has(value as MagnificationTextureFilter);
|
||||
})();
|
||||
|
||||
const isWrapping = (() => {
|
||||
return (value: unknown): value is Wrapping =>
|
||||
wrappingToWrapMode.has(value as Wrapping);
|
||||
})();
|
||||
|
||||
const isValidSamplerValue =
|
||||
<P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'|'rotation'|'repeat'|
|
||||
'offset'>(property: P, value: unknown): value is DefaultedSampler[P] => {
|
||||
switch (property) {
|
||||
case 'minFilter':
|
||||
return isMinFilter(value);
|
||||
case 'magFilter':
|
||||
return isMagFilter(value);
|
||||
case 'wrapS':
|
||||
case 'wrapT':
|
||||
return isWrapping(value);
|
||||
case 'rotation':
|
||||
case 'repeat':
|
||||
case 'offset':
|
||||
return true;
|
||||
default:
|
||||
throw new Error(`Cannot configure property "${property}" on Sampler`);
|
||||
}
|
||||
};
|
||||
|
||||
const $threeTexture = Symbol('threeTexture');
|
||||
const $threeTextures = Symbol('threeTextures');
|
||||
const $setProperty = Symbol('setProperty');
|
||||
|
||||
/**
|
||||
* Sampler facade implementation for Three.js textures
|
||||
*/
|
||||
export class Sampler extends ThreeDOMElement implements SamplerInterface {
|
||||
private get[$threeTexture]() {
|
||||
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
|
||||
}
|
||||
|
||||
private get[$threeTextures]() {
|
||||
return this[$correlatedObjects] as Set<ThreeTexture>;
|
||||
}
|
||||
|
||||
constructor(onUpdate: () => void, texture: ThreeTexture) {
|
||||
super(onUpdate, new Set<ThreeTexture>(texture ? [texture] : []));
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this[$threeTexture].name || '';
|
||||
}
|
||||
|
||||
get minFilter(): MinFilter {
|
||||
return minificationToMinFilter.get(this[$threeTexture].minFilter)!;
|
||||
}
|
||||
|
||||
get magFilter(): MagFilter {
|
||||
return magnificationToMagFilter.get(this[$threeTexture].magFilter)!;
|
||||
}
|
||||
|
||||
get wrapS(): WrapMode {
|
||||
return wrappingToWrapMode.get(this[$threeTexture].wrapS)!;
|
||||
}
|
||||
|
||||
get wrapT(): WrapMode {
|
||||
return wrappingToWrapMode.get(this[$threeTexture].wrapT)!;
|
||||
}
|
||||
|
||||
get rotation(): number {
|
||||
return this[$threeTexture].rotation;
|
||||
}
|
||||
|
||||
get scale(): Vector2D {
|
||||
return toVector2D(this[$threeTexture].repeat);
|
||||
}
|
||||
|
||||
get offset(): Vector2D|null {
|
||||
return toVector2D(this[$threeTexture].offset);
|
||||
}
|
||||
|
||||
setMinFilter(filter: MinFilter) {
|
||||
this[$setProperty]('minFilter', minFilterToMinification.get(filter)!);
|
||||
}
|
||||
|
||||
setMagFilter(filter: MagFilter) {
|
||||
this[$setProperty]('magFilter', magFilterToMagnification.get(filter)!);
|
||||
}
|
||||
|
||||
setWrapS(mode: WrapMode) {
|
||||
this[$setProperty]('wrapS', wrapModeToWrapping.get(mode)!);
|
||||
}
|
||||
|
||||
setWrapT(mode: WrapMode) {
|
||||
this[$setProperty]('wrapT', wrapModeToWrapping.get(mode)!);
|
||||
}
|
||||
|
||||
setRotation(rotation: number|null): void {
|
||||
if (rotation == null) {
|
||||
// Reset rotation.
|
||||
rotation = 0;
|
||||
}
|
||||
this[$setProperty]('rotation', rotation);
|
||||
}
|
||||
|
||||
setScale(scale: Vector2DInterface|null): void {
|
||||
if (scale == null) {
|
||||
// Reset scale.
|
||||
scale = {u: 1, v: 1};
|
||||
}
|
||||
this[$setProperty]('repeat', new Vector2(scale.u, scale.v));
|
||||
}
|
||||
|
||||
setOffset(offset: Vector2DInterface|null): void {
|
||||
if (offset == null) {
|
||||
// Reset offset.
|
||||
offset = {u: 0, v: 0};
|
||||
}
|
||||
this[$setProperty]('offset', new Vector2(offset.u, offset.v));
|
||||
}
|
||||
|
||||
private[$setProperty]<P extends 'minFilter'|'magFilter'|'wrapS'|'wrapT'|
|
||||
'rotation'|'repeat'|'offset'>(
|
||||
property: P, value: MinFilter|MagFilter|Wrapping|number|Vector2) {
|
||||
if (isValidSamplerValue(property, value)) {
|
||||
for (const texture of this[$threeTextures]) {
|
||||
(texture[property] as MinFilter | MagFilter | Wrapping | number |
|
||||
Vector2) = value;
|
||||
texture.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ColorSpace, LinearSRGBColorSpace, MeshPhysicalMaterial, SRGBColorSpace, Texture as ThreeTexture, Vector2, VideoTexture} from 'three';
|
||||
|
||||
import {TextureInfo as TextureInfoInterface} from './api.js';
|
||||
import {$threeTexture} from './image.js';
|
||||
import {Texture} from './texture.js';
|
||||
|
||||
const $texture = Symbol('texture');
|
||||
const $transform = Symbol('transform');
|
||||
export const $materials = Symbol('materials');
|
||||
export const $usage = Symbol('usage');
|
||||
const $onUpdate = Symbol('onUpdate');
|
||||
const $activeVideo = Symbol('activeVideo');
|
||||
|
||||
// Defines what a texture will be used for.
|
||||
export enum TextureUsage {
|
||||
Base,
|
||||
MetallicRoughness,
|
||||
Normal,
|
||||
Occlusion,
|
||||
Emissive,
|
||||
Clearcoat,
|
||||
ClearcoatRoughness,
|
||||
ClearcoatNormal,
|
||||
SheenColor,
|
||||
SheenRoughness,
|
||||
Transmission,
|
||||
Thickness,
|
||||
Specular,
|
||||
SpecularColor,
|
||||
Iridescence,
|
||||
IridescenceThickness,
|
||||
Anisotropy,
|
||||
}
|
||||
|
||||
interface TextureTransform {
|
||||
rotation: number;
|
||||
scale: Vector2;
|
||||
offset: Vector2;
|
||||
}
|
||||
|
||||
/**
|
||||
* TextureInfo facade implementation for Three.js materials
|
||||
*/
|
||||
export class TextureInfo implements TextureInfoInterface {
|
||||
private[$texture]: Texture|null = null;
|
||||
private[$transform]: TextureTransform = {
|
||||
rotation: 0,
|
||||
scale: new Vector2(1, 1),
|
||||
offset: new Vector2(0, 0)
|
||||
};
|
||||
|
||||
// Holds a reference to the Three data that backs the material object.
|
||||
private[$materials]: Set<MeshPhysicalMaterial>|null;
|
||||
|
||||
// Texture usage defines the how the texture is used (ie Normal, Emissive...
|
||||
// etc)
|
||||
private[$usage]: TextureUsage;
|
||||
private[$onUpdate]: () => void;
|
||||
private[$activeVideo] = false;
|
||||
|
||||
constructor(
|
||||
onUpdate: () => void, usage: TextureUsage,
|
||||
threeTexture: ThreeTexture|null, material: Set<MeshPhysicalMaterial>) {
|
||||
// Creates image, sampler, and texture if valid texture info is provided.
|
||||
if (threeTexture) {
|
||||
this[$transform].rotation = threeTexture.rotation;
|
||||
this[$transform].scale.copy(threeTexture.repeat);
|
||||
this[$transform].offset.copy(threeTexture.offset);
|
||||
|
||||
this[$texture] = new Texture(onUpdate, threeTexture);
|
||||
}
|
||||
|
||||
this[$onUpdate] = onUpdate;
|
||||
this[$materials] = material;
|
||||
this[$usage] = usage;
|
||||
}
|
||||
|
||||
get texture(): Texture|null {
|
||||
return this[$texture];
|
||||
}
|
||||
|
||||
setTexture(texture: Texture|null): void {
|
||||
const threeTexture: ThreeTexture|null =
|
||||
texture != null ? texture.source[$threeTexture] : null;
|
||||
|
||||
const oldTexture = this[$texture]?.source[$threeTexture] as VideoTexture;
|
||||
if (oldTexture != null && oldTexture.isVideoTexture) {
|
||||
this[$activeVideo] = false;
|
||||
} else if (this[$texture]?.source.animation) {
|
||||
this[$texture].source.animation.removeEventListener(
|
||||
'enterFrame', this[$onUpdate]);
|
||||
}
|
||||
|
||||
this[$texture] = texture;
|
||||
|
||||
if (threeTexture != null && (threeTexture as VideoTexture).isVideoTexture) {
|
||||
const element = threeTexture.image;
|
||||
this[$activeVideo] = true;
|
||||
if (element.requestVideoFrameCallback != null) {
|
||||
const update = () => {
|
||||
if (!this[$activeVideo]) {
|
||||
return;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
element.requestVideoFrameCallback(update);
|
||||
};
|
||||
element.requestVideoFrameCallback(update);
|
||||
} else {
|
||||
const update = () => {
|
||||
if (!this[$activeVideo]) {
|
||||
return;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
requestAnimationFrame(update);
|
||||
};
|
||||
requestAnimationFrame(update);
|
||||
}
|
||||
} else if (texture?.source.animation != null) {
|
||||
texture.source.animation.addEventListener('enterFrame', this[$onUpdate]);
|
||||
}
|
||||
|
||||
let colorSpace: ColorSpace = SRGBColorSpace;
|
||||
if (this[$materials]) {
|
||||
for (const material of this[$materials]!) {
|
||||
switch (this[$usage]) {
|
||||
case TextureUsage.Base:
|
||||
material.map = threeTexture;
|
||||
break;
|
||||
case TextureUsage.MetallicRoughness:
|
||||
colorSpace = LinearSRGBColorSpace;
|
||||
material.metalnessMap = threeTexture;
|
||||
material.roughnessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Normal:
|
||||
colorSpace = LinearSRGBColorSpace;
|
||||
material.normalMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Occlusion:
|
||||
colorSpace = LinearSRGBColorSpace;
|
||||
material.aoMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Emissive:
|
||||
material.emissiveMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Clearcoat:
|
||||
material.clearcoatMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.ClearcoatRoughness:
|
||||
material.clearcoatRoughnessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.ClearcoatNormal:
|
||||
material.clearcoatNormalMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.SheenColor:
|
||||
material.sheenColorMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.SheenRoughness:
|
||||
material.sheenRoughnessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Transmission:
|
||||
material.transmissionMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Thickness:
|
||||
material.thicknessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Specular:
|
||||
material.specularIntensityMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.SpecularColor:
|
||||
material.specularColorMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Iridescence:
|
||||
material.iridescenceMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.IridescenceThickness:
|
||||
material.iridescenceThicknessMap = threeTexture;
|
||||
break;
|
||||
case TextureUsage.Anisotropy:
|
||||
(material as any).anisotropyMap = threeTexture;
|
||||
break;
|
||||
default:
|
||||
}
|
||||
material.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (threeTexture) {
|
||||
// Updates the colorSpace for the texture, affects all references.
|
||||
threeTexture.colorSpace = colorSpace;
|
||||
threeTexture.rotation = this[$transform].rotation;
|
||||
threeTexture.repeat = this[$transform].scale;
|
||||
threeTexture.offset = this[$transform].offset;
|
||||
}
|
||||
this[$onUpdate]();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Texture as ThreeTexture} from 'three';
|
||||
|
||||
import {Texture as TextureInterface} from './api.js';
|
||||
import {Image} from './image.js';
|
||||
import {Sampler} from './sampler.js';
|
||||
import {$correlatedObjects, ThreeDOMElement} from './three-dom-element.js';
|
||||
|
||||
|
||||
|
||||
const $image = Symbol('image');
|
||||
const $sampler = Symbol('sampler');
|
||||
const $threeTexture = Symbol('threeTexture');
|
||||
|
||||
/**
|
||||
* Material facade implementation for Three.js materials
|
||||
*/
|
||||
export class Texture extends ThreeDOMElement implements TextureInterface {
|
||||
private[$image]: Image;
|
||||
private[$sampler]: Sampler;
|
||||
|
||||
private get[$threeTexture]() {
|
||||
return this[$correlatedObjects]?.values().next().value as ThreeTexture;
|
||||
}
|
||||
|
||||
constructor(onUpdate: () => void, threeTexture: ThreeTexture) {
|
||||
super(onUpdate, new Set<ThreeTexture>(threeTexture ? [threeTexture] : []));
|
||||
|
||||
this[$sampler] = new Sampler(onUpdate, threeTexture);
|
||||
this[$image] = new Image(onUpdate, threeTexture);
|
||||
}
|
||||
|
||||
get name(): string {
|
||||
return this[$threeTexture].name || '';
|
||||
}
|
||||
|
||||
set name(name: string) {
|
||||
for (const texture of this[$correlatedObjects] as Set<ThreeTexture>) {
|
||||
texture.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
get sampler(): Sampler {
|
||||
return this[$sampler];
|
||||
}
|
||||
|
||||
get source(): Image {
|
||||
return this[$image];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Material, Object3D, Texture} from 'three';
|
||||
|
||||
export const $correlatedObjects = Symbol('correlatedObjects');
|
||||
export const $onUpdate = Symbol('onUpdate');
|
||||
|
||||
type CorrelatedObjects = Set<Object3D>|Set<Material>|Set<Texture>;
|
||||
|
||||
/**
|
||||
* A SerializableThreeDOMElement is the common primitive of all scene graph
|
||||
* elements that have been facaded in the host execution context. It adds
|
||||
* a common interface to these elements in support of convenient
|
||||
* serializability.
|
||||
*/
|
||||
export class ThreeDOMElement {
|
||||
readonly[$onUpdate]: () => void;
|
||||
// The Three.js scene graph construct for this element.
|
||||
[$correlatedObjects]: CorrelatedObjects;
|
||||
|
||||
constructor(onUpdate: () => void, correlatedObjects: CorrelatedObjects) {
|
||||
this[$onUpdate] = onUpdate;
|
||||
this[$correlatedObjects] = correlatedObjects;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {property} from 'lit/decorators.js';
|
||||
|
||||
import {style} from '../decorators.js';
|
||||
import ModelViewerElementBase, {$getModelIsVisible, $renderer, $scene, $tick} from '../model-viewer-base.js';
|
||||
import {degreesToRadians} from '../styles/conversions.js';
|
||||
import {EvaluatedStyle, Intrinsics} from '../styles/evaluators.js';
|
||||
import {numberNode, NumberNode} from '../styles/parsers.js';
|
||||
import {Constructor} from '../utilities.js';
|
||||
|
||||
import {CameraChangeDetails} from './controls.js';
|
||||
|
||||
|
||||
// How much the model will rotate per
|
||||
// second in radians:
|
||||
const DEFAULT_ROTATION_SPEED = Math.PI / 32;
|
||||
export const AUTO_ROTATE_DELAY_DEFAULT = 3000;
|
||||
|
||||
const rotationRateIntrinsics = {
|
||||
basis:
|
||||
[degreesToRadians(numberNode(DEFAULT_ROTATION_SPEED, 'rad')) as
|
||||
NumberNode<'rad'>],
|
||||
keywords: {auto: [null]}
|
||||
};
|
||||
|
||||
const $autoRotateStartTime = Symbol('autoRotateStartTime');
|
||||
const $radiansPerSecond = Symbol('radiansPerSecond');
|
||||
const $syncRotationRate = Symbol('syncRotationRate');
|
||||
const $onCameraChange = Symbol('onCameraChange');
|
||||
|
||||
export declare interface StagingInterface {
|
||||
autoRotate: boolean;
|
||||
autoRotateDelay: number;
|
||||
readonly turntableRotation: number;
|
||||
resetTurntableRotation(theta?: number): void;
|
||||
}
|
||||
|
||||
export const StagingMixin = <T extends Constructor<ModelViewerElementBase>>(
|
||||
ModelViewerElement: T): Constructor<StagingInterface>&T => {
|
||||
class StagingModelViewerElement extends ModelViewerElement {
|
||||
@property({type: Boolean, attribute: 'auto-rotate'})
|
||||
autoRotate: boolean = false;
|
||||
|
||||
@property({type: Number, attribute: 'auto-rotate-delay'})
|
||||
autoRotateDelay: number = AUTO_ROTATE_DELAY_DEFAULT;
|
||||
|
||||
@style(
|
||||
{intrinsics: rotationRateIntrinsics, updateHandler: $syncRotationRate})
|
||||
@property({type: String, attribute: 'rotation-per-second'})
|
||||
rotationPerSecond: string = 'auto';
|
||||
|
||||
private[$autoRotateStartTime] = performance.now();
|
||||
private[$radiansPerSecond] = 0;
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener(
|
||||
'camera-change', this[$onCameraChange] as EventListener);
|
||||
this[$autoRotateStartTime] = performance.now();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener(
|
||||
'camera-change', this[$onCameraChange] as EventListener);
|
||||
this[$autoRotateStartTime] = performance.now();
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has('autoRotate')) {
|
||||
this[$autoRotateStartTime] = performance.now();
|
||||
}
|
||||
}
|
||||
|
||||
[$syncRotationRate](style: EvaluatedStyle<Intrinsics<['rad']>>) {
|
||||
this[$radiansPerSecond] = style[0];
|
||||
}
|
||||
|
||||
[$tick](time: number, delta: number) {
|
||||
super[$tick](time, delta);
|
||||
|
||||
if (!this.autoRotate || !this[$getModelIsVisible]() ||
|
||||
this[$renderer].isPresenting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rotationDelta = Math.min(
|
||||
delta, time - this[$autoRotateStartTime] - this.autoRotateDelay);
|
||||
|
||||
if (rotationDelta > 0) {
|
||||
this[$scene].yaw = this.turntableRotation +
|
||||
this[$radiansPerSecond] * rotationDelta * 0.001;
|
||||
}
|
||||
}
|
||||
|
||||
[$onCameraChange] = (event: CustomEvent<CameraChangeDetails>) => {
|
||||
if (!this.autoRotate) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.detail.source === 'user-interaction') {
|
||||
this[$autoRotateStartTime] = performance.now();
|
||||
}
|
||||
};
|
||||
|
||||
get turntableRotation(): number {
|
||||
return this[$scene].yaw;
|
||||
}
|
||||
|
||||
resetTurntableRotation(theta = 0) {
|
||||
this[$scene].yaw = theta;
|
||||
}
|
||||
}
|
||||
|
||||
return StagingModelViewerElement;
|
||||
};
|
||||
@@ -0,0 +1,648 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ReactiveElement} from 'lit';
|
||||
import {property} from 'lit/decorators.js';
|
||||
import {Camera as ThreeCamera, Event as ThreeEvent, Vector2, Vector3, WebGLRenderer} from 'three';
|
||||
|
||||
import {HAS_INTERSECTION_OBSERVER, HAS_RESIZE_OBSERVER} from './constants.js';
|
||||
import {$updateEnvironment} from './features/environment.js';
|
||||
import {makeTemplate} from './template.js';
|
||||
import {$evictionPolicy, CachingGLTFLoader} from './three-components/CachingGLTFLoader.js';
|
||||
import {ModelScene} from './three-components/ModelScene.js';
|
||||
import {ContextLostEvent, Renderer} from './three-components/Renderer.js';
|
||||
import {clamp, debounce} from './utilities.js';
|
||||
import {ProgressTracker} from './utilities/progress-tracker.js';
|
||||
|
||||
const CLEAR_MODEL_TIMEOUT_MS = 10;
|
||||
const FALLBACK_SIZE_UPDATE_THRESHOLD_MS = 50;
|
||||
const ANNOUNCE_MODEL_VISIBILITY_DEBOUNCE_THRESHOLD = 0;
|
||||
const UNSIZED_MEDIA_WIDTH = 300;
|
||||
const UNSIZED_MEDIA_HEIGHT = 150;
|
||||
|
||||
export const blobCanvas = document.createElement('canvas');
|
||||
|
||||
const $fallbackResizeHandler = Symbol('fallbackResizeHandler');
|
||||
const $defaultAriaLabel = Symbol('defaultAriaLabel');
|
||||
|
||||
const $resizeObserver = Symbol('resizeObserver');
|
||||
const $clearModelTimeout = Symbol('clearModelTimeout');
|
||||
const $onContextLost = Symbol('onContextLost');
|
||||
const $loaded = Symbol('loaded');
|
||||
const $status = Symbol('status');
|
||||
const $onFocus = Symbol('onFocus');
|
||||
const $onBlur = Symbol('onBlur');
|
||||
|
||||
export const $updateSize = Symbol('updateSize');
|
||||
export const $intersectionObserver = Symbol('intersectionObserver');
|
||||
export const $isElementInViewport = Symbol('isElementInViewport');
|
||||
export const $announceModelVisibility = Symbol('announceModelVisibility');
|
||||
export const $ariaLabel = Symbol('ariaLabel');
|
||||
export const $altDefaulted = Symbol('altDefaulted');
|
||||
export const $statusElement = Symbol('statusElement');
|
||||
export const $updateStatus = Symbol('updateStatus');
|
||||
export const $loadedTime = Symbol('loadedTime');
|
||||
export const $updateSource = Symbol('updateSource');
|
||||
export const $markLoaded = Symbol('markLoaded');
|
||||
export const $container = Symbol('container');
|
||||
export const $userInputElement = Symbol('input');
|
||||
export const $canvas = Symbol('canvas');
|
||||
export const $scene = Symbol('scene');
|
||||
export const $needsRender = Symbol('needsRender');
|
||||
export const $tick = Symbol('tick');
|
||||
export const $onModelLoad = Symbol('onModelLoad');
|
||||
export const $onResize = Symbol('onResize');
|
||||
export const $renderer = Symbol('renderer');
|
||||
export const $progressTracker = Symbol('progressTracker');
|
||||
export const $getLoaded = Symbol('getLoaded');
|
||||
export const $getModelIsVisible = Symbol('getModelIsVisible');
|
||||
export const $shouldAttemptPreload = Symbol('shouldAttemptPreload');
|
||||
|
||||
export interface Vector3D {
|
||||
x: number
|
||||
y: number
|
||||
z: number
|
||||
toString(): string
|
||||
}
|
||||
|
||||
export const toVector3D = (v: Vector3) => {
|
||||
return {
|
||||
x: v.x,
|
||||
y: v.y,
|
||||
z: v.z,
|
||||
toString() {
|
||||
return `${this.x}m ${this.y}m ${this.z}m`;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export interface Vector2D {
|
||||
u: number
|
||||
v: number
|
||||
toString(): string
|
||||
}
|
||||
|
||||
export const toVector2D = (v: Vector2) => {
|
||||
return {
|
||||
u: v.x,
|
||||
v: v.y,
|
||||
toString() {
|
||||
return `${this.u} ${this.v}`;
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
interface ToBlobOptions {
|
||||
mimeType?: string, qualityArgument?: number, idealAspect?: boolean
|
||||
}
|
||||
|
||||
export interface FramingInfo {
|
||||
framedRadius: number;
|
||||
fieldOfViewAspect: number;
|
||||
}
|
||||
|
||||
export interface Camera {
|
||||
viewMatrix: Array<number>;
|
||||
projectionMatrix: Array<number>;
|
||||
}
|
||||
|
||||
export interface EffectComposerInterface {
|
||||
setRenderer(renderer: WebGLRenderer): void;
|
||||
setMainScene(scene: ModelScene): void;
|
||||
setMainCamera(camera: ThreeCamera): void;
|
||||
setSize(width: number, height: number): void;
|
||||
beforeRender(time: DOMHighResTimeStamp, delta: DOMHighResTimeStamp): void;
|
||||
render(deltaTime?: DOMHighResTimeStamp): void;
|
||||
}
|
||||
|
||||
export interface RendererInterface {
|
||||
load(progressCallback: (progress: number) => void): Promise<FramingInfo>;
|
||||
render(camera: Camera): void;
|
||||
resize(width: number, height: number): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Definition for a basic <model-viewer> element.
|
||||
*/
|
||||
export default class ModelViewerElementBase extends ReactiveElement {
|
||||
static get is() {
|
||||
return 'model-viewer';
|
||||
}
|
||||
|
||||
/** @export */
|
||||
static set modelCacheSize(value: number) {
|
||||
CachingGLTFLoader[$evictionPolicy].evictionThreshold = value;
|
||||
}
|
||||
|
||||
/** @export */
|
||||
static get modelCacheSize(): number {
|
||||
return CachingGLTFLoader[$evictionPolicy].evictionThreshold
|
||||
}
|
||||
|
||||
/** @export */
|
||||
static set minimumRenderScale(value: number) {
|
||||
if (value > 1) {
|
||||
console.warn(
|
||||
'<model-viewer> minimumRenderScale has been clamped to a maximum value of 1.');
|
||||
}
|
||||
if (value <= 0) {
|
||||
console.warn(
|
||||
'<model-viewer> minimumRenderScale has been clamped to a minimum value of 0.25.');
|
||||
}
|
||||
Renderer.singleton.minScale = value;
|
||||
}
|
||||
|
||||
/** @export */
|
||||
static get minimumRenderScale(): number {
|
||||
return Renderer.singleton.minScale;
|
||||
}
|
||||
|
||||
@property({type: String}) alt: string|null = null;
|
||||
|
||||
@property({type: String}) src: string|null = null;
|
||||
|
||||
@property({type: Boolean, attribute: 'with-credentials'})
|
||||
withCredentials: boolean = false;
|
||||
|
||||
/**
|
||||
* Generates a 3D model schema https://schema.org/3DModel associated with
|
||||
* the loaded src and inserts it into the header of the page for search
|
||||
* engines to crawl.
|
||||
*/
|
||||
@property({type: Boolean, attribute: 'generate-schema'})
|
||||
generateSchema = false;
|
||||
|
||||
protected[$isElementInViewport] = false;
|
||||
protected[$loaded] = false;
|
||||
protected[$loadedTime] = 0;
|
||||
protected[$scene]: ModelScene;
|
||||
protected[$container]: HTMLDivElement;
|
||||
protected[$userInputElement]: HTMLDivElement;
|
||||
protected[$canvas]: HTMLCanvasElement;
|
||||
protected[$statusElement]: HTMLSpanElement;
|
||||
protected[$status] = '';
|
||||
protected[$defaultAriaLabel]: string;
|
||||
protected[$clearModelTimeout]: number|null = null;
|
||||
|
||||
protected[$fallbackResizeHandler] = debounce(() => {
|
||||
const boundingRect = this.getBoundingClientRect();
|
||||
this[$updateSize](boundingRect);
|
||||
}, FALLBACK_SIZE_UPDATE_THRESHOLD_MS);
|
||||
|
||||
protected[$announceModelVisibility] = debounce((oldVisibility: boolean) => {
|
||||
const newVisibility = this.modelIsVisible;
|
||||
if (newVisibility !== oldVisibility) {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
'model-visibility', {detail: {visible: newVisibility}}));
|
||||
}
|
||||
}, ANNOUNCE_MODEL_VISIBILITY_DEBOUNCE_THRESHOLD);
|
||||
|
||||
protected[$resizeObserver]: ResizeObserver|null = null;
|
||||
protected[$intersectionObserver]: IntersectionObserver|null = null;
|
||||
|
||||
protected[$progressTracker]: ProgressTracker = new ProgressTracker();
|
||||
|
||||
/** @export */
|
||||
get loaded() {
|
||||
return this[$getLoaded]();
|
||||
}
|
||||
|
||||
get[$renderer]() {
|
||||
return Renderer.singleton;
|
||||
}
|
||||
|
||||
/** @export */
|
||||
get modelIsVisible() {
|
||||
return this[$getModelIsVisible]();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new ModelViewerElement.
|
||||
*/
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attachShadow({mode: 'open'});
|
||||
|
||||
const shadowRoot = this.shadowRoot!;
|
||||
|
||||
makeTemplate(shadowRoot);
|
||||
|
||||
this[$container] = shadowRoot.querySelector('.container') as HTMLDivElement;
|
||||
this[$userInputElement] =
|
||||
shadowRoot.querySelector('.userInput') as HTMLDivElement;
|
||||
this[$canvas] = shadowRoot.querySelector('canvas') as HTMLCanvasElement;
|
||||
this[$statusElement] =
|
||||
shadowRoot.querySelector('#status') as HTMLSpanElement;
|
||||
this[$defaultAriaLabel] =
|
||||
this[$userInputElement].getAttribute('aria-label')!;
|
||||
|
||||
// Because of potential race conditions related to invoking the constructor
|
||||
// we only use the bounding rect to set the initial size if the element is
|
||||
// already connected to the document:
|
||||
let width, height;
|
||||
if (this.isConnected) {
|
||||
const rect = this.getBoundingClientRect();
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
} else {
|
||||
width = UNSIZED_MEDIA_WIDTH;
|
||||
height = UNSIZED_MEDIA_HEIGHT;
|
||||
}
|
||||
|
||||
// Create the underlying ModelScene.
|
||||
this[$scene] =
|
||||
new ModelScene({canvas: this[$canvas], element: this, width, height});
|
||||
|
||||
// Update initial size on microtask timing so that subclasses have a
|
||||
// chance to initialize
|
||||
Promise.resolve().then(() => {
|
||||
this[$updateSize](this.getBoundingClientRect());
|
||||
});
|
||||
|
||||
if (HAS_RESIZE_OBSERVER) {
|
||||
// Set up a resize observer so we can scale our canvas
|
||||
// if our <model-viewer> changes
|
||||
this[$resizeObserver] =
|
||||
new ResizeObserver((entries: Array<ResizeObserverEntry>) => {
|
||||
// Don't resize anything if in AR mode; otherwise the canvas
|
||||
// scaling to fullscreen on entering AR will clobber the flat/2d
|
||||
// dimensions of the element.
|
||||
if (this[$renderer].isPresenting) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let entry of entries) {
|
||||
if (entry.target === this) {
|
||||
this[$updateSize](entry.contentRect);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (HAS_INTERSECTION_OBSERVER) {
|
||||
this[$intersectionObserver] = new IntersectionObserver(entries => {
|
||||
for (let entry of entries) {
|
||||
if (entry.target === this) {
|
||||
const oldVisibility = this.modelIsVisible;
|
||||
this[$isElementInViewport] = entry.isIntersecting;
|
||||
this[$announceModelVisibility](oldVisibility);
|
||||
if (this[$isElementInViewport] && !this.loaded) {
|
||||
this[$updateSource]();
|
||||
}
|
||||
}
|
||||
}
|
||||
}, {
|
||||
root: null,
|
||||
// We used to have margin here, but it was causing animated models below
|
||||
// the fold to steal the frame budget. Weirder still, it would also
|
||||
// cause input events to be swallowed, sometimes for seconds on the
|
||||
// model above the fold, but only when the animated model was completely
|
||||
// below. Setting this margin to zero fixed it.
|
||||
rootMargin: '0px',
|
||||
// With zero threshold, an element adjacent to but not intersecting the
|
||||
// viewport will be reported as intersecting, which will cause
|
||||
// unnecessary rendering. Any slight positive threshold alleviates this.
|
||||
threshold: 0.00001,
|
||||
});
|
||||
} else {
|
||||
// If there is no intersection observer, then all models should be visible
|
||||
// at all times:
|
||||
this[$isElementInViewport] = true;
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback && super.connectedCallback();
|
||||
if (HAS_RESIZE_OBSERVER) {
|
||||
this[$resizeObserver]!.observe(this);
|
||||
} else {
|
||||
self.addEventListener('resize', this[$fallbackResizeHandler]);
|
||||
}
|
||||
|
||||
if (HAS_INTERSECTION_OBSERVER) {
|
||||
this[$intersectionObserver]!.observe(this);
|
||||
}
|
||||
|
||||
this.addEventListener('focus', this[$onFocus]);
|
||||
this.addEventListener('blur', this[$onBlur]);
|
||||
|
||||
const renderer = this[$renderer];
|
||||
renderer.addEventListener(
|
||||
'contextlost', this[$onContextLost] as (event: ThreeEvent) => void);
|
||||
|
||||
renderer.registerScene(this[$scene]);
|
||||
|
||||
if (this[$clearModelTimeout] != null) {
|
||||
self.clearTimeout(this[$clearModelTimeout]!);
|
||||
this[$clearModelTimeout] = null;
|
||||
// Force an update in case the model has been evicted from our GLTF cache
|
||||
// @see https://lit-element.polymer-project.org/guide/lifecycle#requestupdate
|
||||
this.requestUpdate('src', null);
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback && super.disconnectedCallback();
|
||||
if (HAS_RESIZE_OBSERVER) {
|
||||
this[$resizeObserver]!.unobserve(this);
|
||||
} else {
|
||||
self.removeEventListener('resize', this[$fallbackResizeHandler]);
|
||||
}
|
||||
|
||||
if (HAS_INTERSECTION_OBSERVER) {
|
||||
this[$intersectionObserver]!.unobserve(this);
|
||||
}
|
||||
|
||||
this.removeEventListener('focus', this[$onFocus]);
|
||||
this.removeEventListener('blur', this[$onBlur]);
|
||||
|
||||
const renderer = this[$renderer];
|
||||
renderer.removeEventListener(
|
||||
'contextlost', this[$onContextLost] as (event: ThreeEvent) => void);
|
||||
|
||||
renderer.unregisterScene(this[$scene]);
|
||||
|
||||
this[$clearModelTimeout] = self.setTimeout(() => {
|
||||
this[$scene].dispose();
|
||||
this[$clearModelTimeout] = null;
|
||||
}, CLEAR_MODEL_TIMEOUT_MS);
|
||||
}
|
||||
|
||||
updated(changedProperties: Map<string|number|symbol, any>) {
|
||||
super.updated(changedProperties);
|
||||
|
||||
// NOTE(cdata): If a property changes from values A -> B -> A in the space
|
||||
// of a microtask, LitElement/UpdatingElement will notify of a change even
|
||||
// though the value has effectively not changed, so we need to check to make
|
||||
// sure that the value has actually changed before changing the loaded flag.
|
||||
if (changedProperties.has('src')) {
|
||||
if (this.src == null) {
|
||||
this[$loaded] = false;
|
||||
this[$loadedTime] = 0;
|
||||
this[$scene].reset();
|
||||
} else if (this.src !== this[$scene].url) {
|
||||
this[$loaded] = false;
|
||||
this[$loadedTime] = 0;
|
||||
this[$updateSource]();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has('alt')) {
|
||||
this[$userInputElement].setAttribute('aria-label', this[$ariaLabel]);
|
||||
}
|
||||
|
||||
if (changedProperties.has('generateSchema')) {
|
||||
if (this.generateSchema) {
|
||||
this[$scene].updateSchema(this.src);
|
||||
} else {
|
||||
this[$scene].updateSchema(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @export */
|
||||
toDataURL(type?: string, encoderOptions?: number): string {
|
||||
return this[$renderer]
|
||||
.displayCanvas(this[$scene])
|
||||
.toDataURL(type, encoderOptions);
|
||||
}
|
||||
|
||||
/** @export */
|
||||
async toBlob(options?: ToBlobOptions): Promise<Blob> {
|
||||
const mimeType = options ? options.mimeType : undefined;
|
||||
const qualityArgument = options ? options.qualityArgument : undefined;
|
||||
const useIdealAspect = options ? options.idealAspect : undefined;
|
||||
|
||||
const {width, height, idealAspect, aspect} = this[$scene];
|
||||
const {dpr, scaleFactor} = this[$renderer];
|
||||
let outputWidth = width * scaleFactor * dpr;
|
||||
let outputHeight = height * scaleFactor * dpr;
|
||||
let offsetX = 0;
|
||||
let offsetY = 0;
|
||||
if (useIdealAspect === true) {
|
||||
if (idealAspect > aspect) {
|
||||
const oldHeight = outputHeight;
|
||||
outputHeight = Math.round(outputWidth / idealAspect);
|
||||
offsetY = (oldHeight - outputHeight) / 2;
|
||||
} else {
|
||||
const oldWidth = outputWidth;
|
||||
outputWidth = Math.round(outputHeight * idealAspect);
|
||||
offsetX = (oldWidth - outputWidth) / 2;
|
||||
}
|
||||
}
|
||||
blobCanvas.width = outputWidth;
|
||||
blobCanvas.height = outputHeight;
|
||||
try {
|
||||
return new Promise<Blob>(async (resolve, reject) => {
|
||||
blobCanvas.getContext('2d')!.drawImage(
|
||||
this[$renderer].displayCanvas(this[$scene]),
|
||||
offsetX,
|
||||
offsetY,
|
||||
outputWidth,
|
||||
outputHeight,
|
||||
0,
|
||||
0,
|
||||
outputWidth,
|
||||
outputHeight);
|
||||
|
||||
blobCanvas.toBlob((blob) => {
|
||||
if (!blob) {
|
||||
return reject(new Error('Unable to retrieve canvas blob'));
|
||||
}
|
||||
|
||||
resolve(blob);
|
||||
}, mimeType, qualityArgument);
|
||||
});
|
||||
} finally {
|
||||
this[$updateSize]({width, height});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a new EffectComposer as the main rendering pipeline,
|
||||
* instead of the default ThreeJs renderer.
|
||||
* This method also calls setRenderer, setMainScene, and setMainCamera on
|
||||
* your effectComposer.
|
||||
* @param effectComposer An EffectComposer from `pmndrs/postprocessing`
|
||||
*/
|
||||
registerEffectComposer(effectComposer: EffectComposerInterface) {
|
||||
effectComposer.setRenderer(this[$renderer].threeRenderer);
|
||||
effectComposer.setMainCamera(this[$scene].getCamera());
|
||||
effectComposer.setMainScene(this[$scene]);
|
||||
this[$scene].effectRenderer = effectComposer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the registered EffectComposer
|
||||
*/
|
||||
unregisterEffectComposer() {
|
||||
this[$scene].effectRenderer = null;
|
||||
}
|
||||
|
||||
registerRenderer(renderer: RendererInterface) {
|
||||
this[$scene].externalRenderer = renderer;
|
||||
}
|
||||
|
||||
unregisterRenderer() {
|
||||
this[$scene].externalRenderer = null;
|
||||
}
|
||||
|
||||
get[$ariaLabel]() {
|
||||
return this[$altDefaulted];
|
||||
}
|
||||
|
||||
get[$altDefaulted]() {
|
||||
return (this.alt == null || this.alt === 'null') ? this[$defaultAriaLabel] :
|
||||
this.alt;
|
||||
}
|
||||
|
||||
// NOTE(cdata): Although this may seem extremely redundant, it is required in
|
||||
// order to support overloading when TypeScript is compiled to ES5
|
||||
// @see https://github.com/Polymer/lit-element/pull/745
|
||||
// @see https://github.com/microsoft/TypeScript/issues/338
|
||||
[$getLoaded](): boolean {
|
||||
return this[$loaded];
|
||||
}
|
||||
|
||||
// @see [$getLoaded]
|
||||
[$getModelIsVisible](): boolean {
|
||||
return this.loaded && this[$isElementInViewport];
|
||||
}
|
||||
|
||||
[$shouldAttemptPreload](): boolean {
|
||||
return !!this.src && this[$isElementInViewport];
|
||||
}
|
||||
|
||||
/**
|
||||
* Called on initialization and when the resize observer fires.
|
||||
*/
|
||||
[$updateSize]({width, height}: {width: number, height: number}) {
|
||||
if (width === 0 || height === 0) {
|
||||
return;
|
||||
}
|
||||
this[$container].style.width = `${width}px`;
|
||||
this[$container].style.height = `${height}px`;
|
||||
|
||||
this[$onResize]({width, height});
|
||||
}
|
||||
|
||||
[$tick](time: number, delta: number) {
|
||||
this[$scene].effectRenderer?.beforeRender(time, delta);
|
||||
}
|
||||
|
||||
[$markLoaded]() {
|
||||
if (this[$loaded]) {
|
||||
return;
|
||||
}
|
||||
|
||||
this[$loaded] = true;
|
||||
this[$loadedTime] = performance.now();
|
||||
}
|
||||
|
||||
[$needsRender]() {
|
||||
this[$scene].queueRender();
|
||||
}
|
||||
|
||||
[$onModelLoad]() {
|
||||
}
|
||||
|
||||
[$updateStatus](status: string) {
|
||||
this[$status] = status;
|
||||
const rootNode = this.getRootNode() as Document | ShadowRoot | null;
|
||||
// Only change the aria-label if <model-viewer> is currently focused:
|
||||
if (rootNode != null && rootNode.activeElement === this &&
|
||||
this[$statusElement].textContent != status) {
|
||||
this[$statusElement].textContent = status;
|
||||
}
|
||||
}
|
||||
|
||||
[$onFocus] = () => {
|
||||
this[$statusElement].textContent = this[$status];
|
||||
};
|
||||
|
||||
[$onBlur] = () => {
|
||||
this[$statusElement].textContent = '';
|
||||
};
|
||||
|
||||
[$onResize](e: {width: number, height: number}) {
|
||||
this[$scene].setSize(e.width, e.height);
|
||||
}
|
||||
|
||||
[$onContextLost] = (event: ContextLostEvent) => {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
'error',
|
||||
{detail: {type: 'webglcontextlost', sourceError: event.sourceEvent}}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Parses the element for an appropriate source URL and
|
||||
* sets the views to use the new model based.
|
||||
*/
|
||||
async[$updateSource]() {
|
||||
const scene = this[$scene];
|
||||
if (this.loaded || !this[$shouldAttemptPreload]() ||
|
||||
this.src === scene.url) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.generateSchema) {
|
||||
scene.updateSchema(this.src);
|
||||
}
|
||||
this[$updateStatus]('Loading');
|
||||
// If we are loading a new model, we need to stop the animation of
|
||||
// the current one (if any is playing). Otherwise, we might lose
|
||||
// the reference to the scene root and running actions start to
|
||||
// throw exceptions and/or behave in unexpected ways:
|
||||
scene.stopAnimation();
|
||||
|
||||
const updateSourceProgress =
|
||||
this[$progressTracker].beginActivity('model-load');
|
||||
const source = this.src;
|
||||
try {
|
||||
const srcUpdated = scene.setSource(
|
||||
source,
|
||||
(progress: number) =>
|
||||
updateSourceProgress(clamp(progress, 0, 1) * 0.95));
|
||||
|
||||
const envUpdated = (this as any)[$updateEnvironment]();
|
||||
|
||||
await Promise.all([srcUpdated, envUpdated]);
|
||||
|
||||
this[$markLoaded]();
|
||||
this[$onModelLoad]();
|
||||
|
||||
this.updateComplete.then(() => {
|
||||
this.dispatchEvent(new CustomEvent('before-render'));
|
||||
});
|
||||
|
||||
// Wait for shaders to compile and pixels to be drawn.
|
||||
await new Promise<void>(resolve => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('load', {detail: {url: source}}));
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
} catch (error) {
|
||||
this.dispatchEvent(new CustomEvent(
|
||||
'error', {detail: {type: 'loadfailure', sourceError: error}}));
|
||||
} finally {
|
||||
updateSourceProgress(1.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {AnimationMixin} from './features/animation.js';
|
||||
import {AnnotationMixin} from './features/annotation.js';
|
||||
import {ARMixin} from './features/ar.js';
|
||||
import {ControlsMixin} from './features/controls.js';
|
||||
import {EnvironmentMixin} from './features/environment.js';
|
||||
import {LoadingMixin} from './features/loading.js';
|
||||
import {SceneGraphMixin} from './features/scene-graph.js';
|
||||
import {StagingMixin} from './features/staging.js';
|
||||
import ModelViewerElementBase from './model-viewer-base.js';
|
||||
|
||||
// Export these to allow lazy-loaded LottieLoader.js to find what it needs.
|
||||
// Requires an import map - "three": "path/to/model-viewer.min.js".
|
||||
export {CanvasTexture, FileLoader, Loader, NearestFilter} from 'three';
|
||||
|
||||
export const ModelViewerElement =
|
||||
AnnotationMixin(SceneGraphMixin(StagingMixin(EnvironmentMixin(ControlsMixin(
|
||||
ARMixin(LoadingMixin(AnimationMixin(ModelViewerElementBase))))))));
|
||||
|
||||
export type ModelViewerElement = InstanceType<typeof ModelViewerElement>;
|
||||
|
||||
export type{RGB, RGBA} from './three-components/gltf-instance/gltf-2.0';
|
||||
|
||||
customElements.define('model-viewer', ModelViewerElement);
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
'model-viewer': ModelViewerElement;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {NumberNode, ZERO} from './parsers.js';
|
||||
|
||||
/**
|
||||
* Ensures that a given number is expressed in radians. If the number is already
|
||||
* in radians, does nothing. If the value is in degrees, converts it to radians.
|
||||
* If the value has no specified unit, the unit is assumed to be radians. If the
|
||||
* value is not in radians or degrees, the value is resolved as 0 radians.
|
||||
*
|
||||
* Also accepts a second argument that is a default value to use if the input
|
||||
* numberNode number is NaN or Infinity.
|
||||
*/
|
||||
export const degreesToRadians =
|
||||
(numberNode: NumberNode, fallbackRadianValue: number = 0): NumberNode => {
|
||||
let {number, unit} = numberNode;
|
||||
if (!isFinite(number)) {
|
||||
number = fallbackRadianValue;
|
||||
unit = 'rad';
|
||||
} else if (numberNode.unit === 'rad' || numberNode.unit == null) {
|
||||
return numberNode;
|
||||
}
|
||||
|
||||
const valueIsDegrees = unit === 'deg' && number != null;
|
||||
const value = valueIsDegrees ? number : 0;
|
||||
const radians = value * Math.PI / 180;
|
||||
|
||||
return {type: 'number', number: radians, unit: 'rad'};
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures that a given number is expressed in degrees. If the number is already
|
||||
* in degrees, does nothing. If the value is in radians or has no specified
|
||||
* unit, converts it to degrees. If the value is not in radians or degrees, the
|
||||
* value is resolved as 0 degrees.
|
||||
*
|
||||
* Also accepts a second argument that is a default value to use if the input
|
||||
* numberNode number is NaN or Infinity.
|
||||
*/
|
||||
export const radiansToDegrees =
|
||||
(numberNode: NumberNode, fallbackDegreeValue: number = 0): NumberNode => {
|
||||
let {number, unit} = numberNode;
|
||||
|
||||
if (!isFinite(number)) {
|
||||
number = fallbackDegreeValue;
|
||||
unit = 'deg';
|
||||
} else if (numberNode.unit === 'deg') {
|
||||
return numberNode;
|
||||
}
|
||||
|
||||
const valueIsRadians =
|
||||
(unit === null || unit === 'rad') && number != null;
|
||||
|
||||
const value = valueIsRadians ? number : 0;
|
||||
const degrees = value * 180 / Math.PI;
|
||||
|
||||
return {type: 'number', number: degrees, unit: 'deg'};
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts a given length to meters. Currently supported input units are
|
||||
* meters, centimeters and millimeters.
|
||||
*
|
||||
* Also accepts a second argument that is a default value to use if the input
|
||||
* numberNode number is NaN or Infinity.
|
||||
*/
|
||||
export const lengthToBaseMeters =
|
||||
(numberNode: NumberNode, fallbackMeterValue: number = 0): NumberNode => {
|
||||
let {number, unit} = numberNode;
|
||||
|
||||
if (!isFinite(number)) {
|
||||
number = fallbackMeterValue;
|
||||
unit = 'm';
|
||||
} else if (numberNode.unit === 'm') {
|
||||
return numberNode;
|
||||
}
|
||||
|
||||
let scale;
|
||||
|
||||
switch (unit) {
|
||||
default:
|
||||
scale = 1;
|
||||
break;
|
||||
case 'cm':
|
||||
scale = 1 / 100;
|
||||
break;
|
||||
case 'mm':
|
||||
scale = 1 / 1000;
|
||||
break;
|
||||
}
|
||||
|
||||
const value = scale * number;
|
||||
return {type: 'number', number: value, unit: 'm'};
|
||||
};
|
||||
|
||||
/**
|
||||
* Normalizes the unit of a given input number so that it is expressed in a
|
||||
* preferred unit. For length nodes, the return value will be expressed in
|
||||
* meters. For angle nodes, the return value will be expressed in radians.
|
||||
*
|
||||
* Also takes a fallback number that is used when the number value is not a
|
||||
* valid number or when the unit of the given number cannot be normalized.
|
||||
*/
|
||||
export const normalizeUnit = (() => {
|
||||
const identity = (node: NumberNode) => node;
|
||||
const unitNormalizers: {[index: string]: (node: NumberNode) => NumberNode} = {
|
||||
'rad': identity,
|
||||
'deg': degreesToRadians,
|
||||
'm': identity,
|
||||
'mm': lengthToBaseMeters,
|
||||
'cm': lengthToBaseMeters
|
||||
};
|
||||
|
||||
return (node: NumberNode, fallback: NumberNode = ZERO) => {
|
||||
if (!isFinite(node.number)) {
|
||||
node.number = fallback.number;
|
||||
node.unit = fallback.unit;
|
||||
}
|
||||
|
||||
const {unit} = node;
|
||||
|
||||
if (unit == null) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const normalize = unitNormalizers[unit];
|
||||
|
||||
if (normalize == null) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return normalize(node);
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {IdentNode, parseExpressions} from './parsers.js';
|
||||
|
||||
|
||||
/**
|
||||
* For our purposes, an enumeration is a fixed set of CSS-expression-compatible
|
||||
* names. When serialized, a selected subset of the members may be specified as
|
||||
* whitespace-separated strings. An enumeration deserializer is a function that
|
||||
* parses a serialized subset of an enumeration and returns any members that are
|
||||
* found as a Set.
|
||||
*
|
||||
* The following example will produce a deserializer for the days of the
|
||||
* week:
|
||||
*
|
||||
* const deserializeDaysOfTheWeek = enumerationDeserializer([
|
||||
* 'Monday',
|
||||
* 'Tuesday',
|
||||
* 'Wednesday',
|
||||
* 'Thursday',
|
||||
* 'Friday',
|
||||
* 'Saturday',
|
||||
* 'Sunday'
|
||||
* ]);
|
||||
*/
|
||||
export const enumerationDeserializer = <T extends string>(allowedNames: T[]) =>
|
||||
(valueString: string): Set<T> => {
|
||||
try {
|
||||
const expressions = parseExpressions(valueString);
|
||||
const names = (expressions.length ? expressions[0].terms : [])
|
||||
.filter<IdentNode>(
|
||||
(valueNode): valueNode is IdentNode =>
|
||||
valueNode && valueNode.type === 'ident')
|
||||
.map(valueNode => valueNode.value as T)
|
||||
.filter(name => allowedNames.indexOf(name) > -1);
|
||||
|
||||
return new Set<T>(names);
|
||||
} catch (_error) {
|
||||
}
|
||||
return new Set();
|
||||
};
|
||||
@@ -0,0 +1,561 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {normalizeUnit} from './conversions.js';
|
||||
import {ExpressionNode, ExpressionTerm, FunctionNode, IdentNode, NumberNode, numberNode, OperatorNode, Percentage, Unit, ZERO} from './parsers.js';
|
||||
|
||||
export type Evaluatable<T> = Evaluator<T>|T;
|
||||
|
||||
/**
|
||||
* A NumberNodeSequence is a vector of NumberNodes with a specified
|
||||
* sequence of units.
|
||||
*/
|
||||
export type NumberNodeSequence<T extends Array<Unit>, U = never> = {
|
||||
[I in keyof T]:
|
||||
NumberNode&{
|
||||
unit: T[I]|U;
|
||||
};
|
||||
};
|
||||
|
||||
export type Sparse<T> = {
|
||||
[I in keyof T]: null|T[I];
|
||||
};
|
||||
|
||||
/**
|
||||
* Intrinsics describe the metadata required to do four things for any given
|
||||
* type of number-based CSS expression:
|
||||
*
|
||||
* 1. Establish the expected units of a final, evaluated result
|
||||
* 2. Provide a foundational value that percentages should scale against
|
||||
* 3. Describe the analog number values that correspond to various keywords
|
||||
* 4. Have an available concrete value to fallback to when needed
|
||||
*
|
||||
* Intrinsics must always specify a basis and the substitute values for the
|
||||
* keyword 'auto'.
|
||||
*
|
||||
* Intrinsics may optionally specify the substitute values for any additional
|
||||
* number of keywords.
|
||||
*/
|
||||
export interface Intrinsics<T extends Array<Unit> = []> {
|
||||
basis: NumberNodeSequence<T>;
|
||||
keywords: {
|
||||
auto: Sparse<NumberNodeSequence<T, Percentage>>;
|
||||
[index: string]: Sparse<NumberNodeSequence<T, Percentage>>;
|
||||
};
|
||||
}
|
||||
|
||||
const $evaluate = Symbol('evaluate');
|
||||
const $lastValue = Symbol('lastValue');
|
||||
|
||||
/**
|
||||
* An Evaluator is used to derive a computed style from part (or all) of a CSS
|
||||
* expression AST. This construct is particularly useful for complex ASTs
|
||||
* containing function calls such as calc, var and env. Such styles could be
|
||||
* costly to re-evaluate on every frame (and in some cases we may try to do
|
||||
* that). The Evaluator construct allows us to mark sub-trees of the AST as
|
||||
* constant, so that only the dynamic parts are re-evaluated. It also separates
|
||||
* one-time AST preparation work from work that necessarily has to happen upon
|
||||
* each evaluation.
|
||||
*/
|
||||
export abstract class Evaluator<T> {
|
||||
/**
|
||||
* An Evaluatable is a NumberNode or an Evaluator that evaluates a NumberNode
|
||||
* as the result of invoking its evaluate method. This is mainly used to
|
||||
* ensure that CSS function nodes are cast to the corresponding Evaluators
|
||||
* that will resolve the result of the function, but is also used to ensure
|
||||
* that a percentage nested at arbitrary depth in the expression will always
|
||||
* be evaluated against the correct basis.
|
||||
*/
|
||||
static evaluatableFor(
|
||||
node: ExpressionTerm|Evaluator<NumberNode>,
|
||||
basis: NumberNode = ZERO): Evaluatable<NumberNode> {
|
||||
if (node instanceof Evaluator) {
|
||||
return node;
|
||||
}
|
||||
|
||||
if (node.type === 'number') {
|
||||
if (node.unit === '%') {
|
||||
return new PercentageEvaluator(node as NumberNode<'%'>, basis);
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
switch ((node as FunctionNode).name.value) {
|
||||
case 'calc':
|
||||
return new CalcEvaluator(node as FunctionNode, basis);
|
||||
case 'env':
|
||||
return new EnvEvaluator(node as FunctionNode);
|
||||
}
|
||||
|
||||
return ZERO;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the input is an Evaluator, returns the result of evaluating it.
|
||||
* Otherwise, returns the input.
|
||||
*
|
||||
* This is a helper to aide in resolving a NumberNode without conditionally
|
||||
* checking if the Evaluatable is an Evaluator everywhere.
|
||||
*/
|
||||
static evaluate<T extends NumberNode|IdentNode>(evaluatable: Evaluatable<T>):
|
||||
T {
|
||||
if (evaluatable instanceof Evaluator) {
|
||||
return evaluatable.evaluate();
|
||||
}
|
||||
|
||||
return evaluatable;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the input is an Evaluator, returns the value of its isConstant property.
|
||||
* Returns true for all other input values.
|
||||
*/
|
||||
static isConstant<T>(evaluatable: Evaluatable<T>): boolean {
|
||||
if (evaluatable instanceof Evaluator) {
|
||||
return evaluatable.isConstant;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method applies a set of structured intrinsic metadata to an evaluated
|
||||
* result from a parsed CSS-like string of expressions. Intrinsics provide
|
||||
* sufficient metadata (e.g., basis values, analogs for keywords) such that
|
||||
* omitted values in the input string can be backfilled, and keywords can be
|
||||
* converted to concrete numbers.
|
||||
*
|
||||
* The result of applying intrinsics is a tuple of NumberNode values whose
|
||||
* units match the units used by the basis of the intrinsics.
|
||||
*
|
||||
* The following is a high-level description of how intrinsics are applied:
|
||||
*
|
||||
* 1. Determine the value of 'auto' for the current term
|
||||
* 2. If there is no corresponding input value for this term, substitute the
|
||||
* 'auto' value.
|
||||
* 3. If the term is an IdentNode, treat it as a keyword and perform the
|
||||
* appropriate substitution.
|
||||
* 4. If the term is still null, fallback to the 'auto' value
|
||||
* 5. If the term is a percentage, apply it to the basis and return that
|
||||
* value
|
||||
* 6. Normalize the unit of the term
|
||||
* 7. If the term's unit does not match the basis unit, return the basis
|
||||
* value
|
||||
* 8. Return the term as is
|
||||
*/
|
||||
static applyIntrinsics<T extends Array<Unit>>(
|
||||
evaluated: Array<any>, intrinsics: Intrinsics<T>): NumberNodeSequence<T> {
|
||||
const {basis, keywords} = intrinsics;
|
||||
const {auto} = keywords;
|
||||
|
||||
return basis.map<NumberNode>((basisNode, index) => {
|
||||
// Use an auto value if we have it, otherwise the auto value is the basis:
|
||||
const autoSubstituteNode = auto[index] == null ? basisNode : auto[index];
|
||||
|
||||
// If the evaluated nodes do not have a node at the current
|
||||
// index, fallback to the "auto" substitute right away:
|
||||
let evaluatedNode =
|
||||
evaluated[index] ? evaluated[index] : autoSubstituteNode;
|
||||
|
||||
// Any ident node is considered a keyword:
|
||||
if (evaluatedNode.type === 'ident') {
|
||||
const keyword = evaluatedNode.value;
|
||||
// Substitute any keywords for concrete values first:
|
||||
if (keyword in keywords) {
|
||||
evaluatedNode = keywords[keyword][index];
|
||||
}
|
||||
}
|
||||
|
||||
// If we don't have a NumberNode at this point, fall back to whatever
|
||||
// is specified for auto:
|
||||
if (evaluatedNode == null || evaluatedNode.type === 'ident') {
|
||||
evaluatedNode = autoSubstituteNode;
|
||||
}
|
||||
|
||||
// For percentages, we always apply the percentage to the basis value:
|
||||
if (evaluatedNode.unit === '%') {
|
||||
return numberNode(
|
||||
evaluatedNode.number / 100 * basisNode.number, basisNode.unit);
|
||||
}
|
||||
|
||||
// Otherwise, normalize whatever we have:
|
||||
evaluatedNode = normalizeUnit(evaluatedNode, basisNode);
|
||||
|
||||
// If the normalized units do not match, return the basis as a fallback:
|
||||
if (evaluatedNode.unit !== basisNode.unit) {
|
||||
return basisNode;
|
||||
}
|
||||
|
||||
// Finally, return the evaluated node with intrinsics applied:
|
||||
return evaluatedNode;
|
||||
}) as NumberNodeSequence<T>;
|
||||
}
|
||||
|
||||
/**
|
||||
* If true, the Evaluator will only evaluate its AST one time. If false, the
|
||||
* Evaluator will re-evaluate the AST each time that the public evaluate
|
||||
* method is invoked.
|
||||
*/
|
||||
get isConstant(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
protected[$lastValue]: T|null = null;
|
||||
|
||||
/**
|
||||
* This method must be implemented by subclasses. Its implementation should be
|
||||
* the actual steps to evaluate the AST, and should return the evaluated
|
||||
* result.
|
||||
*/
|
||||
protected abstract[$evaluate](): T;
|
||||
|
||||
/**
|
||||
* Evaluate the Evaluator and return the result. If the Evaluator is constant,
|
||||
* the corresponding AST will only be evaluated once, and the result of
|
||||
* evaluating it the first time will be returned on all subsequent
|
||||
* evaluations.
|
||||
*/
|
||||
evaluate(): T {
|
||||
if (!this.isConstant || this[$lastValue] == null) {
|
||||
this[$lastValue] = this[$evaluate]();
|
||||
}
|
||||
return this[$lastValue]!;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const $percentage = Symbol('percentage');
|
||||
const $basis = Symbol('basis');
|
||||
|
||||
|
||||
/**
|
||||
* A PercentageEvaluator scales a given basis value by a given percentage value.
|
||||
* The evaluated result is always considered to be constant.
|
||||
*/
|
||||
export class PercentageEvaluator extends Evaluator<NumberNode> {
|
||||
protected[$percentage]: NumberNode<'%'>;
|
||||
protected[$basis]: NumberNode;
|
||||
|
||||
constructor(percentage: NumberNode<'%'>, basis: NumberNode) {
|
||||
super();
|
||||
|
||||
this[$percentage] = percentage;
|
||||
this[$basis] = basis;
|
||||
}
|
||||
|
||||
get isConstant() {
|
||||
return true;
|
||||
}
|
||||
|
||||
[$evaluate]() {
|
||||
return numberNode(
|
||||
this[$percentage].number / 100 * this[$basis].number,
|
||||
this[$basis].unit);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const $identNode = Symbol('identNode');
|
||||
|
||||
/**
|
||||
* Evaluator for CSS-like env() functions. Currently, only one environment
|
||||
* variable is accepted as an argument for such functions: window-scroll-y.
|
||||
*
|
||||
* The env() Evaluator is explicitly dynamic because it always refers to
|
||||
* external state that changes as the user scrolls, so it should always be
|
||||
* re-evaluated to ensure we get the most recent value.
|
||||
*
|
||||
* Some important notes about this feature include:
|
||||
*
|
||||
* - There is no such thing as a "window-scroll-y" CSS environment variable in
|
||||
* any stable browser at the time that this comment is being written.
|
||||
* - The actual CSS env() function accepts a second argument as a fallback for
|
||||
* the case that the specified first argument isn't set; our syntax does not
|
||||
* support this second argument.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/env
|
||||
*/
|
||||
export class EnvEvaluator extends Evaluator<NumberNode> {
|
||||
protected[$identNode]: IdentNode|null = null;
|
||||
|
||||
constructor(envFunction: FunctionNode) {
|
||||
super();
|
||||
|
||||
const identNode =
|
||||
envFunction.arguments.length ? envFunction.arguments[0].terms[0] : null;
|
||||
|
||||
if (identNode != null && identNode.type === 'ident') {
|
||||
this[$identNode] = identNode;
|
||||
}
|
||||
}
|
||||
|
||||
get isConstant(): boolean {
|
||||
return false;
|
||||
};
|
||||
|
||||
[$evaluate](): NumberNode {
|
||||
if (this[$identNode] != null) {
|
||||
switch (this[$identNode]!.value) {
|
||||
case 'window-scroll-y':
|
||||
const verticalScrollPosition = window.pageYOffset;
|
||||
const verticalScrollMax = Math.max(
|
||||
document.body.scrollHeight,
|
||||
document.body.offsetHeight,
|
||||
document.documentElement.clientHeight,
|
||||
document.documentElement.scrollHeight,
|
||||
document.documentElement.offsetHeight);
|
||||
const scrollY = verticalScrollPosition /
|
||||
(verticalScrollMax - window.innerHeight) ||
|
||||
0;
|
||||
|
||||
return {type: 'number', number: scrollY, unit: null};
|
||||
}
|
||||
}
|
||||
|
||||
return ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const IS_MULTIPLICATION_RE = /[\*\/]/;
|
||||
const $evaluator = Symbol('evaluator');
|
||||
|
||||
/**
|
||||
* Evaluator for CSS-like calc() functions. Our implementation of calc()
|
||||
* evaluation currently support nested function calls, an unlimited number of
|
||||
* terms, and all four algebraic operators (+, -, * and /).
|
||||
*
|
||||
* The Evaluator is marked as constant unless the calc expression contains an
|
||||
* internal env expression at any depth, in which case it will be marked as
|
||||
* dynamic.
|
||||
*
|
||||
* @see https://www.w3.org/TR/css-values-3/#calc-syntax
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc
|
||||
*/
|
||||
export class CalcEvaluator extends Evaluator<NumberNode> {
|
||||
protected[$evaluator]: Evaluator<NumberNode>|null = null;
|
||||
|
||||
constructor(calcFunction: FunctionNode, basis: NumberNode = ZERO) {
|
||||
super();
|
||||
|
||||
if (calcFunction.arguments.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
const terms: Array<ExpressionTerm> =
|
||||
calcFunction.arguments[0].terms.slice();
|
||||
const secondOrderTerms: Array<ExpressionTerm|Evaluator<NumberNode>> = [];
|
||||
|
||||
while (terms.length) {
|
||||
const term: ExpressionTerm = terms.shift()!;
|
||||
|
||||
if (secondOrderTerms.length > 0) {
|
||||
const previousTerm =
|
||||
secondOrderTerms[secondOrderTerms.length - 1] as ExpressionTerm;
|
||||
if (previousTerm.type === 'operator' &&
|
||||
IS_MULTIPLICATION_RE.test(previousTerm.value)) {
|
||||
const operator = secondOrderTerms.pop() as OperatorNode;
|
||||
const leftValue = secondOrderTerms.pop();
|
||||
|
||||
if (leftValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
secondOrderTerms.push(new OperatorEvaluator(
|
||||
operator,
|
||||
Evaluator.evaluatableFor(leftValue, basis),
|
||||
Evaluator.evaluatableFor(term, basis)));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
secondOrderTerms.push(
|
||||
term.type === 'operator' ? term :
|
||||
Evaluator.evaluatableFor(term, basis));
|
||||
}
|
||||
|
||||
while (secondOrderTerms.length > 2) {
|
||||
const [left, operator, right] = secondOrderTerms.splice(0, 3);
|
||||
if ((operator as ExpressionTerm).type !== 'operator') {
|
||||
return;
|
||||
}
|
||||
|
||||
secondOrderTerms.unshift(new OperatorEvaluator(
|
||||
operator as OperatorNode,
|
||||
Evaluator.evaluatableFor(left, basis),
|
||||
Evaluator.evaluatableFor(right, basis)));
|
||||
}
|
||||
|
||||
// There should only be one combined evaluator at this point:
|
||||
if (secondOrderTerms.length === 1) {
|
||||
this[$evaluator] = secondOrderTerms[0] as Evaluator<NumberNode>;
|
||||
}
|
||||
}
|
||||
|
||||
get isConstant() {
|
||||
return this[$evaluator] == null || Evaluator.isConstant(this[$evaluator]!);
|
||||
}
|
||||
|
||||
[$evaluate]() {
|
||||
return this[$evaluator] != null ? Evaluator.evaluate(this[$evaluator]!) :
|
||||
ZERO;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const $operator = Symbol('operator');
|
||||
const $left = Symbol('left');
|
||||
const $right = Symbol('right');
|
||||
|
||||
/**
|
||||
* An Evaluator for the operators found inside CSS calc() functions.
|
||||
* The evaluator accepts an operator and left/right operands. The operands can
|
||||
* be any valid expression term typically allowed inside a CSS calc function.
|
||||
*
|
||||
* As detail of this implementation, the only supported unit types are angles
|
||||
* expressed as radians or degrees, and lengths expressed as meters, centimeters
|
||||
* or millimeters.
|
||||
*
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/calc
|
||||
*/
|
||||
export class OperatorEvaluator extends Evaluator<NumberNode> {
|
||||
protected[$operator]: OperatorNode;
|
||||
protected[$left]: Evaluatable<NumberNode>;
|
||||
protected[$right]: Evaluatable<NumberNode>;
|
||||
|
||||
constructor(
|
||||
operator: OperatorNode, left: Evaluatable<NumberNode>,
|
||||
right: Evaluatable<NumberNode>) {
|
||||
super();
|
||||
this[$operator] = operator;
|
||||
this[$left] = left;
|
||||
this[$right] = right;
|
||||
}
|
||||
|
||||
get isConstant() {
|
||||
return Evaluator.isConstant(this[$left]) &&
|
||||
Evaluator.isConstant(this[$right]);
|
||||
}
|
||||
|
||||
[$evaluate](): NumberNode {
|
||||
const leftNode = normalizeUnit(Evaluator.evaluate(this[$left]));
|
||||
const rightNode = normalizeUnit(Evaluator.evaluate(this[$right]));
|
||||
const {number: leftValue, unit: leftUnit} = leftNode;
|
||||
const {number: rightValue, unit: rightUnit} = rightNode;
|
||||
|
||||
// Disallow operations for mismatched normalized units e.g., m and rad:
|
||||
if (rightUnit != null && leftUnit != null && rightUnit != leftUnit) {
|
||||
return ZERO;
|
||||
}
|
||||
|
||||
// NOTE(cdata): rules for calc type checking are defined here
|
||||
// https://drafts.csswg.org/css-values-3/#calc-type-checking
|
||||
// This is a simplification and may not hold up once we begin to support
|
||||
// additional unit types:
|
||||
const unit = leftUnit || rightUnit;
|
||||
let value;
|
||||
|
||||
switch (this[$operator].value) {
|
||||
case '+':
|
||||
value = leftValue + rightValue;
|
||||
break;
|
||||
case '-':
|
||||
value = leftValue - rightValue;
|
||||
break;
|
||||
case '/':
|
||||
value = leftValue / rightValue;
|
||||
break;
|
||||
case '*':
|
||||
value = leftValue * rightValue;
|
||||
break;
|
||||
default:
|
||||
return ZERO;
|
||||
}
|
||||
|
||||
return {type: 'number', number: value, unit};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export type EvaluatedStyle<T extends Intrinsics<Array<Unit>>> = {
|
||||
[I in keyof T['basis']]: number;
|
||||
}&Array<never>;
|
||||
|
||||
const $evaluatables = Symbol('evaluatables');
|
||||
const $intrinsics = Symbol('intrinsics');
|
||||
|
||||
/**
|
||||
* A VectorEvaluator evaluates a series of numeric terms that usually represent
|
||||
* a data structure such as a multi-dimensional vector or a spherical
|
||||
*
|
||||
* The form of the evaluator's result is determined by the Intrinsics that are
|
||||
* given to it when it is constructed. For example, spherical intrinsics would
|
||||
* establish two angle terms and a length term, so the result of evaluating the
|
||||
* evaluator that is configured with spherical intrinsics is a three element
|
||||
* array where the first two elements represent angles in radians and the third
|
||||
* element representing a length in meters.
|
||||
*/
|
||||
export class StyleEvaluator<T extends Intrinsics<Array<any>>> extends
|
||||
Evaluator<EvaluatedStyle<T>> {
|
||||
protected[$intrinsics]: T;
|
||||
protected[$evaluatables]: Array<Evaluatable<NumberNode|IdentNode>>;
|
||||
|
||||
constructor(expressions: Array<ExpressionNode>, intrinsics: T) {
|
||||
super();
|
||||
|
||||
this[$intrinsics] = intrinsics;
|
||||
|
||||
const firstExpression = expressions[0];
|
||||
const terms = firstExpression != null ? firstExpression.terms : [];
|
||||
|
||||
this[$evaluatables] =
|
||||
intrinsics.basis.map<Evaluatable<NumberNode|IdentNode>>(
|
||||
(basisNode, index) => {
|
||||
const term = terms[index];
|
||||
if (term == null) {
|
||||
return {type: 'ident', value: 'auto'};
|
||||
}
|
||||
|
||||
if (term.type === 'ident') {
|
||||
return term;
|
||||
}
|
||||
|
||||
return Evaluator.evaluatableFor(term, basisNode);
|
||||
});
|
||||
}
|
||||
|
||||
get isConstant(): boolean {
|
||||
for (const evaluatable of this[$evaluatables]) {
|
||||
if (!Evaluator.isConstant(evaluatable)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
[$evaluate]() {
|
||||
const evaluated = this[$evaluatables].map<NumberNode|IdentNode>(
|
||||
evaluatable => Evaluator.evaluate(evaluatable));
|
||||
return Evaluator.applyIntrinsics(evaluated, this[$intrinsics])
|
||||
.map<number>(numberNode => numberNode.number) as
|
||||
EvaluatedStyle<T>;
|
||||
}
|
||||
}
|
||||
|
||||
// SphericalIntrinsics are Intrinsics that expect two angle terms
|
||||
// and one length term
|
||||
export type SphericalIntrinsics = Intrinsics<['rad', 'rad', 'm']>;
|
||||
// Vector3Intrinsics expect three length terms
|
||||
export type Vector3Intrinsics = Intrinsics<['m', 'm', 'm']>;
|
||||
@@ -0,0 +1,350 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
// The operators that are available in CSS calc() functions
|
||||
// include symbols for addition, subtraction, multiplication and division
|
||||
// @see https://www.w3.org/TR/css-values-3/#calc-syntax
|
||||
export type Operator = '+'|'-'|'*'|'/';
|
||||
|
||||
// We only support units for length in meters, radians and degrees for angles
|
||||
// and percentage values
|
||||
export type Unit = 'm'|'cm'|'mm'|'rad'|'deg';
|
||||
export type Percentage = '%';
|
||||
|
||||
// Any node that might appear in a parsed expression is referred to as an
|
||||
// ExpressionTerm
|
||||
export type ExpressionTerm =
|
||||
IdentNode|HexNode|NumberNode|OperatorNode|FunctionNode;
|
||||
|
||||
export interface IdentNode {
|
||||
type: 'ident';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface HexNode {
|
||||
type: 'hex';
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface NumberNode<U = Unit | Percentage | null> {
|
||||
type: 'number';
|
||||
number: number;
|
||||
unit: U;
|
||||
}
|
||||
|
||||
export const numberNode =
|
||||
<T extends Unit|Percentage|null>(value: number, unit: T): NumberNode<T> =>
|
||||
({type: 'number', number: value, unit});
|
||||
|
||||
export interface OperatorNode {
|
||||
type: 'operator';
|
||||
value: Operator;
|
||||
}
|
||||
|
||||
export interface FunctionNode {
|
||||
type: 'function';
|
||||
name: IdentNode;
|
||||
arguments: Array<ExpressionNode>;
|
||||
}
|
||||
|
||||
export interface ExpressionNode {
|
||||
type: 'expression';
|
||||
terms: Array<ExpressionTerm>;
|
||||
}
|
||||
|
||||
export type ASTNode =
|
||||
IdentNode|HexNode|NumberNode|OperatorNode|FunctionNode|ExpressionNode;
|
||||
|
||||
// As an internal detail of this module, non-exported parsers return both a
|
||||
// set of nodes and the remaining string input to be parsed. This saves us a bit
|
||||
// of book keeping work and allows our internal parser implementations to remain
|
||||
// essentially stateless.
|
||||
interface ParseResult<T extends ASTNode> {
|
||||
nodes: Array<T>;
|
||||
remainingInput: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a string representing a comma-separated set of CSS-like expressions,
|
||||
* parses and returns an array of ASTs that correspond to those expressions.
|
||||
*
|
||||
* Currently supported syntax includes:
|
||||
*
|
||||
* - functions (top-level and nested)
|
||||
* - calc() arithmetic operators
|
||||
* - numbers with units
|
||||
* - hexadecimal-encoded colors in 3, 6 or 8 digit form
|
||||
* - idents
|
||||
*
|
||||
* All syntax is intended to match the parsing rules and semantics of the actual
|
||||
* CSS spec as closely as possible.
|
||||
*
|
||||
* @see https://www.w3.org/TR/CSS2/
|
||||
* @see https://www.w3.org/TR/css-values-3/
|
||||
*/
|
||||
export const parseExpressions = (() => {
|
||||
const cache: {[index: string]: Array<ExpressionNode>} = {};
|
||||
const MAX_PARSE_ITERATIONS = 1000; // Arbitrarily large
|
||||
|
||||
return (inputString: string): Array<ExpressionNode> => {
|
||||
const cacheKey = inputString;
|
||||
|
||||
if (cacheKey in cache) {
|
||||
return cache[cacheKey];
|
||||
}
|
||||
|
||||
const expressions: Array<ExpressionNode> = [];
|
||||
let parseIterations = 0;
|
||||
|
||||
while (inputString) {
|
||||
if (++parseIterations > MAX_PARSE_ITERATIONS) {
|
||||
// Avoid a potentially infinite loop due to typos:
|
||||
inputString = '';
|
||||
break;
|
||||
}
|
||||
|
||||
const expressionParseResult = parseExpression(inputString);
|
||||
const expression = expressionParseResult.nodes[0];
|
||||
|
||||
if (expression == null || expression.terms.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
expressions.push(expression);
|
||||
inputString = expressionParseResult.remainingInput;
|
||||
}
|
||||
|
||||
return cache[cacheKey] = expressions;
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* Parse a single expression. For the purposes of our supported syntax, an
|
||||
* expression is the set of semantically meaningful terms that appear before the
|
||||
* next comma, or between the parens of a function invocation.
|
||||
*/
|
||||
const parseExpression = (() => {
|
||||
const IS_IDENT_RE = /^(\-\-|[a-z\u0240-\uffff])/i;
|
||||
const IS_OPERATOR_RE = /^([\*\+\/]|[\-]\s)/i;
|
||||
const IS_EXPRESSION_END_RE = /^[\),]/;
|
||||
const FUNCTION_ARGUMENTS_FIRST_TOKEN = '(';
|
||||
const HEX_FIRST_TOKEN = '#';
|
||||
|
||||
return (inputString: string): ParseResult<ExpressionNode> => {
|
||||
const terms: Array<ExpressionTerm> = [];
|
||||
|
||||
while (inputString.length) {
|
||||
inputString = inputString.trim();
|
||||
|
||||
if (IS_EXPRESSION_END_RE.test(inputString)) {
|
||||
break;
|
||||
} else if (inputString[0] === FUNCTION_ARGUMENTS_FIRST_TOKEN) {
|
||||
const {nodes, remainingInput} = parseFunctionArguments(inputString);
|
||||
|
||||
inputString = remainingInput;
|
||||
|
||||
terms.push({
|
||||
type: 'function',
|
||||
name: {type: 'ident', value: 'calc'},
|
||||
arguments: nodes
|
||||
});
|
||||
} else if (IS_IDENT_RE.test(inputString)) {
|
||||
const identParseResult = parseIdent(inputString);
|
||||
const identNode = identParseResult.nodes[0];
|
||||
|
||||
inputString = identParseResult.remainingInput;
|
||||
|
||||
if (inputString[0] === FUNCTION_ARGUMENTS_FIRST_TOKEN) {
|
||||
const {nodes, remainingInput} = parseFunctionArguments(inputString);
|
||||
terms.push({type: 'function', name: identNode, arguments: nodes});
|
||||
inputString = remainingInput;
|
||||
} else {
|
||||
terms.push(identNode);
|
||||
}
|
||||
} else if (IS_OPERATOR_RE.test(inputString)) {
|
||||
// Operators are always a single character, so just pluck them out:
|
||||
terms.push({type: 'operator', value: inputString[0] as Operator});
|
||||
inputString = inputString.slice(1);
|
||||
} else {
|
||||
const {nodes, remainingInput} = inputString[0] === HEX_FIRST_TOKEN ?
|
||||
parseHex(inputString) :
|
||||
parseNumber(inputString);
|
||||
|
||||
// The remaining string may not have had any meaningful content. Exit
|
||||
// early if this is the case:
|
||||
if (nodes.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
terms.push(nodes[0]);
|
||||
inputString = remainingInput;
|
||||
}
|
||||
}
|
||||
|
||||
return {nodes: [{type: 'expression', terms}], remainingInput: inputString};
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* An ident is something like a function name or the keyword "auto".
|
||||
*/
|
||||
const parseIdent = (() => {
|
||||
const NOT_IDENT_RE = /[^a-z0-9_\-\u0240-\uffff]/i;
|
||||
|
||||
return (inputString: string): ParseResult<IdentNode> => {
|
||||
const match = inputString.match(NOT_IDENT_RE);
|
||||
const ident =
|
||||
match == null ? inputString : inputString.substr(0, match.index);
|
||||
const remainingInput =
|
||||
match == null ? '' : inputString.substr(match.index!);
|
||||
|
||||
return {nodes: [{type: 'ident', value: ident}], remainingInput};
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* Parses a number. A number value can be expressed with an integer or
|
||||
* non-integer syntax, and usually includes a unit (but does not strictly
|
||||
* require one for our purposes).
|
||||
*/
|
||||
const parseNumber = (() => {
|
||||
// @see https://www.w3.org/TR/css-syntax/#number-token-diagram
|
||||
const VALUE_RE = /[\+\-]?(\d+[\.]\d+|\d+|[\.]\d+)([eE][\+\-]?\d+)?/;
|
||||
const UNIT_RE = /^[a-z%]+/i;
|
||||
const ALLOWED_UNITS = /^(m|mm|cm|rad|deg|[%])$/;
|
||||
|
||||
return (inputString: string): ParseResult<NumberNode> => {
|
||||
const valueMatch = inputString.match(VALUE_RE);
|
||||
const value = valueMatch == null ? '0' : valueMatch[0];
|
||||
inputString = value == null ? inputString : inputString.slice(value.length);
|
||||
|
||||
const unitMatch = inputString.match(UNIT_RE);
|
||||
let unit = unitMatch != null && unitMatch[0] !== '' ? unitMatch[0] : null;
|
||||
const remainingInput =
|
||||
unitMatch == null ? inputString : inputString.slice(unit!.length);
|
||||
|
||||
if (unit != null && !ALLOWED_UNITS.test(unit)) {
|
||||
unit = null;
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: [{
|
||||
type: 'number',
|
||||
number: parseFloat(value) || 0,
|
||||
unit: unit as Unit | Percentage | null
|
||||
}],
|
||||
remainingInput
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* Parses a hexadecimal-encoded color in 3, 6 or 8 digit form.
|
||||
*/
|
||||
const parseHex = (() => {
|
||||
// TODO(cdata): right now we don't actually enforce the number of digits
|
||||
const HEX_RE = /^[a-f0-9]*/i;
|
||||
|
||||
return (inputString: string): ParseResult<HexNode> => {
|
||||
inputString = inputString.slice(1).trim();
|
||||
const hexMatch = inputString.match(HEX_RE);
|
||||
const nodes: Array<HexNode> =
|
||||
hexMatch == null ? [] : [{type: 'hex', value: hexMatch[0]}];
|
||||
|
||||
return {
|
||||
nodes,
|
||||
remainingInput: hexMatch == null ? inputString :
|
||||
inputString.slice(hexMatch[0].length)
|
||||
};
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
/**
|
||||
* Parses arguments passed to a function invocation (e.g., the expressions
|
||||
* within a matched set of parens).
|
||||
*/
|
||||
const parseFunctionArguments =
|
||||
(inputString: string): ParseResult<ExpressionNode> => {
|
||||
const expressionNodes: Array<ExpressionNode> = [];
|
||||
|
||||
// Consume the opening paren
|
||||
inputString = inputString.slice(1).trim();
|
||||
|
||||
while (inputString.length) {
|
||||
const expressionParseResult = parseExpression(inputString);
|
||||
expressionNodes.push(expressionParseResult.nodes[0]);
|
||||
inputString = expressionParseResult.remainingInput.trim();
|
||||
|
||||
if (inputString[0] === ',') {
|
||||
inputString = inputString.slice(1).trim();
|
||||
} else if (inputString[0] === ')') {
|
||||
// Consume the closing paren and stop parsing
|
||||
inputString = inputString.slice(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {nodes: expressionNodes, remainingInput: inputString};
|
||||
};
|
||||
|
||||
|
||||
export type ASTWalkerCallback<T> = (node: T) => void;
|
||||
|
||||
const $visitedTypes = Symbol('visitedTypes');
|
||||
|
||||
/**
|
||||
* An ASTWalker walks an array of ASTs such as the type produced by
|
||||
* parseExpressions and invokes a callback for a configured set of nodes that
|
||||
* the user wishes to "visit" during the walk.
|
||||
*/
|
||||
export class ASTWalker<T extends ASTNode> {
|
||||
protected[$visitedTypes]: Array<string>;
|
||||
constructor(visitedTypes: Array<string>) {
|
||||
this[$visitedTypes] = visitedTypes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk the given set of ASTs, and invoke the provided callback for nodes that
|
||||
* match the filtered set that the ASTWalker was constructed with.
|
||||
*/
|
||||
walk(ast: Array<ExpressionNode>, callback: ASTWalkerCallback<T>) {
|
||||
const remaining: Array<ASTNode> = ast.slice();
|
||||
|
||||
while (remaining.length) {
|
||||
const next = remaining.shift()!;
|
||||
|
||||
if (this[$visitedTypes].indexOf(next.type) > -1) {
|
||||
callback(next as T);
|
||||
}
|
||||
|
||||
switch (next.type) {
|
||||
case 'expression':
|
||||
remaining.unshift(...next.terms);
|
||||
break;
|
||||
case 'function':
|
||||
remaining.unshift(next.name, ...next.arguments);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const ZERO: NumberNode =
|
||||
Object.freeze({type: 'number', number: 0, unit: null});
|
||||
@@ -0,0 +1,188 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {ASTWalker, ExpressionNode, FunctionNode} from './parsers.js';
|
||||
|
||||
interface AnyObserver {
|
||||
observe(): void;
|
||||
disconnect(): void;
|
||||
}
|
||||
|
||||
const $instances = Symbol('instances');
|
||||
const $activateListener = Symbol('activateListener');
|
||||
const $deactivateListener = Symbol('deactivateListener');
|
||||
const $notifyInstances = Symbol('notifyInstances');
|
||||
const $notify = Symbol('notify');
|
||||
const $scrollCallback = Symbol('callback');
|
||||
|
||||
type ScrollObserverCallback = () => void;
|
||||
|
||||
/**
|
||||
* This internal helper is intended to work as a reference-counting manager of
|
||||
* scroll event listeners. Only one scroll listener is ever registered for all
|
||||
* instances of the class, and when the last ScrollObserver "disconnects", that
|
||||
* event listener is removed. This spares us from thrashing
|
||||
* the {add,remove}EventListener API (the binding cost of these methods has been
|
||||
* known to show up in performance analyses) as well as potential memory leaks.
|
||||
*/
|
||||
class ScrollObserver {
|
||||
private static[$notifyInstances]() {
|
||||
for (const instance of ScrollObserver[$instances]) {
|
||||
instance[$notify]();
|
||||
}
|
||||
}
|
||||
private static[$instances]: Set<ScrollObserver> = new Set();
|
||||
private static[$activateListener]() {
|
||||
window.addEventListener('scroll', this[$notifyInstances], {passive: true});
|
||||
}
|
||||
private static[$deactivateListener]() {
|
||||
window.removeEventListener('scroll', this[$notifyInstances]);
|
||||
}
|
||||
|
||||
private[$scrollCallback]: ScrollObserverCallback;
|
||||
|
||||
constructor(callback: ScrollObserverCallback) {
|
||||
this[$scrollCallback] = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for scroll events. The configured callback (passed to the
|
||||
* constructor) will be invoked for subsequent global scroll events.
|
||||
*/
|
||||
observe() {
|
||||
if (ScrollObserver[$instances].size === 0) {
|
||||
ScrollObserver[$activateListener]();
|
||||
}
|
||||
ScrollObserver[$instances].add(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop listening for scroll events.
|
||||
*/
|
||||
disconnect() {
|
||||
ScrollObserver[$instances].delete(this);
|
||||
if (ScrollObserver[$instances].size === 0) {
|
||||
ScrollObserver[$deactivateListener]();
|
||||
}
|
||||
}
|
||||
|
||||
private[$notify]() {
|
||||
this[$scrollCallback]();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export type EnvironmentState = 'window-scroll';
|
||||
|
||||
export type StyleEffectorCallback = (record: EnvironmentChangeRecord) => void;
|
||||
export interface EnvironmentChangeRecord {
|
||||
relatedState: EnvironmentState;
|
||||
}
|
||||
|
||||
type EnvironmentDependencies = {
|
||||
[key in EnvironmentState]?: AnyObserver
|
||||
};
|
||||
|
||||
const $computeStyleCallback = Symbol('computeStyleCallback');
|
||||
const $astWalker = Symbol('astWalker');
|
||||
const $dependencies = Symbol('dependencies');
|
||||
|
||||
const $onScroll = Symbol('onScroll');
|
||||
|
||||
/**
|
||||
* The StyleEffector is configured with a callback that will be invoked at the
|
||||
* optimal time that some array of CSS expression ASTs ought to be evaluated.
|
||||
*
|
||||
* For example, our CSS-like expression syntax supports usage of the env()
|
||||
* function to incorporate the current top-level scroll position into a CSS
|
||||
* expression: env(window-scroll-y).
|
||||
*
|
||||
* This "environment variable" will change dynamically as the user scrolls the
|
||||
* page. If an AST contains such a usage of env(), we would have to evaluate the
|
||||
* AST on every frame in order to be sure that the computed style stays up to
|
||||
* date.
|
||||
*
|
||||
* The StyleEffector spares us from evaluating the expressions on every frame by
|
||||
* correlating specific parts of an AST with observers of the external effects
|
||||
* that they refer to (if any). So, if the AST contains env(window-scroll-y),
|
||||
* the StyleEffector manages the lifetime of a global scroll event listener and
|
||||
* notifies the user at the optimal time to evaluate the computed style.
|
||||
*/
|
||||
export class StyleEffector {
|
||||
protected[$dependencies]: EnvironmentDependencies = {};
|
||||
|
||||
protected[$computeStyleCallback]: StyleEffectorCallback;
|
||||
protected[$astWalker] = new ASTWalker<FunctionNode>(['function']);
|
||||
|
||||
constructor(callback: StyleEffectorCallback) {
|
||||
this[$computeStyleCallback] = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the expressions that govern when the StyleEffector callback will be
|
||||
* invoked.
|
||||
*/
|
||||
observeEffectsFor(ast: Array<ExpressionNode>) {
|
||||
const newDependencies: EnvironmentDependencies = {};
|
||||
const oldDependencies = this[$dependencies];
|
||||
|
||||
this[$astWalker].walk(ast, functionNode => {
|
||||
const {name} = functionNode;
|
||||
const firstArgument = functionNode.arguments[0];
|
||||
const firstTerm = firstArgument.terms[0];
|
||||
|
||||
if (name.value !== 'env' || firstTerm == null ||
|
||||
firstTerm.type !== 'ident') {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (firstTerm.value) {
|
||||
case 'window-scroll-y':
|
||||
if (newDependencies['window-scroll'] == null) {
|
||||
const observer = 'window-scroll' in oldDependencies ?
|
||||
oldDependencies['window-scroll'] :
|
||||
new ScrollObserver(this[$onScroll]);
|
||||
observer!.observe();
|
||||
delete oldDependencies['window-scroll'];
|
||||
newDependencies['window-scroll'] = observer;
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
for (const environmentState in oldDependencies) {
|
||||
const observer = oldDependencies[environmentState as EnvironmentState]!;
|
||||
observer.disconnect();
|
||||
}
|
||||
|
||||
this[$dependencies] = newDependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Disposes of the StyleEffector by disconnecting all observers of external
|
||||
* effects.
|
||||
*/
|
||||
dispose() {
|
||||
for (const environmentState in this[$dependencies]) {
|
||||
const observer =
|
||||
this[$dependencies][environmentState as EnvironmentState]!;
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
protected[$onScroll] = () => {
|
||||
this[$computeStyleCallback]({relatedState: 'window-scroll'});
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {html, render} from 'lit';
|
||||
|
||||
import CloseIcon from './assets/close-material-svg.js';
|
||||
import ControlsPrompt from './assets/controls-svg.js';
|
||||
import ARGlyph from './assets/view-in-ar-material-svg.js';
|
||||
|
||||
const templateResult = html`
|
||||
<style>
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
contain: strict;
|
||||
width: 300px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.userInput {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: none;
|
||||
position: relative;
|
||||
outline-offset: -1px;
|
||||
outline-width: 1px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
position: absolute;
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
/* NOTE(cdata): Chrome 76 and below apparently have a bug
|
||||
* that causes our canvas not to display pixels unless it is
|
||||
* on its own render layer
|
||||
* @see https://github.com/google/model-viewer/pull/755#issuecomment-536597893
|
||||
*/
|
||||
transform: translateZ(0);
|
||||
}
|
||||
|
||||
.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Adapted from HTML5 Boilerplate
|
||||
*
|
||||
* @see https://github.com/h5bp/html5-boilerplate/blob/ceb4620c78fc82e13534fc44202a3f168754873f/dist/css/main.css#L122-L133 */
|
||||
.screen-reader-only {
|
||||
border: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
width: 1px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slot > * {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.annotation-wrapper ::slotted(*) {
|
||||
opacity: var(--max-hotspot-opacity, 1);
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.pointer-tumbling .annotation-wrapper ::slotted(*) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.annotation-wrapper ::slotted(*) {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.annotation-wrapper.hide ::slotted(*) {
|
||||
opacity: var(--min-hotspot-opacity, 0.25);
|
||||
}
|
||||
|
||||
.slot.poster {
|
||||
display: none;
|
||||
background-color: inherit;
|
||||
}
|
||||
|
||||
.slot.poster.show {
|
||||
display: inherit;
|
||||
}
|
||||
|
||||
.slot.poster > * {
|
||||
pointer-events: initial;
|
||||
}
|
||||
|
||||
.slot.poster:not(.show) > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
#default-poster {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* The default poster is a <button> so we need to set display
|
||||
* to prevent it from being affected by text-align: */
|
||||
display: block;
|
||||
position: absolute;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background-size: contain;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
background-color: #fff0;
|
||||
}
|
||||
|
||||
#default-progress-bar {
|
||||
display: block;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#default-progress-bar > .bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: var(--progress-bar-height, 5px);
|
||||
background-color: var(--progress-bar-color, rgba(0, 0, 0, 0.4));
|
||||
transition: transform 0.09s;
|
||||
transform-origin: top left;
|
||||
transform: scaleX(0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#default-progress-bar > .bar.hide {
|
||||
transition: opacity 0.3s 1s;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.centered {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.cover {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.interaction-prompt {
|
||||
display: var(--interaction-prompt-display, flex);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
will-change: opacity;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.slot.interaction-prompt.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.animated-container {
|
||||
will-change: transform, opacity;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.slot.interaction-prompt > * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.ar-button {
|
||||
-moz-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
user-select: none;
|
||||
|
||||
display: var(--ar-button-display, block);
|
||||
}
|
||||
|
||||
.slot.ar-button:not(.enabled) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
box-shadow: 0px 0px 4px rgba(0, 0, 0, 0.15);
|
||||
border-radius: 100px;
|
||||
}
|
||||
|
||||
.fab > * {
|
||||
opacity: 0.87;
|
||||
}
|
||||
|
||||
#default-ar-button {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
transform: scale(var(--ar-button-scale, 1));
|
||||
transform-origin: bottom right;
|
||||
}
|
||||
|
||||
.slot.pan-target {
|
||||
display: block;
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate3d(-50%, -50%, 0);
|
||||
background-color: transparent;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
#default-pan-target {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0px 0px 2px 1px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.slot.default {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.progress-bar {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.exit-webxr-ar-button {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slot.exit-webxr-ar-button:not(.enabled) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#default-exit-webxr-ar-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: env(safe-area-inset-top, 16px);
|
||||
right: 16px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#default-exit-webxr-ar-button > svg {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
<div class="container">
|
||||
<div class="userInput" tabindex="0" role="img"
|
||||
aria-label="3D model">
|
||||
<div class="slot canvas">
|
||||
<slot name="canvas">
|
||||
<canvas></canvas>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- NOTE(cdata): We need to wrap slots because browsers without ShadowDOM
|
||||
will have their <slot> elements removed by ShadyCSS -->
|
||||
<div class="slot poster">
|
||||
<slot name="poster">
|
||||
<button type="button" id="default-poster" aria-hidden="true" aria-label="Loading 3D model"></button>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot ar-button">
|
||||
<slot name="ar-button">
|
||||
<a id="default-ar-button" part="default-ar-button" class="fab"
|
||||
tabindex="2"
|
||||
role="button"
|
||||
href="javascript:void(0);"
|
||||
aria-label="View in your space">
|
||||
${ARGlyph}
|
||||
</a>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot pan-target">
|
||||
<slot name="pan-target">
|
||||
<div id="default-pan-target">
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot interaction-prompt cover centered">
|
||||
<div id="prompt" class="animated-container">
|
||||
<slot name="interaction-prompt" aria-hidden="true">
|
||||
${ControlsPrompt}
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="finger0" class="animated-container cover">
|
||||
<slot name="finger0" aria-hidden="true">
|
||||
</slot>
|
||||
</div>
|
||||
<div id="finger1" class="animated-container cover">
|
||||
<slot name="finger1" aria-hidden="true">
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot default">
|
||||
<slot></slot>
|
||||
|
||||
<div class="slot progress-bar">
|
||||
<slot name="progress-bar">
|
||||
<div id="default-progress-bar" aria-hidden="true">
|
||||
<div class="bar" part="default-progress-bar"></div>
|
||||
</div>
|
||||
</slot>
|
||||
</div>
|
||||
|
||||
<div class="slot exit-webxr-ar-button">
|
||||
<slot name="exit-webxr-ar-button">
|
||||
<a id="default-exit-webxr-ar-button" part="default-exit-webxr-ar-button"
|
||||
tabindex="3"
|
||||
aria-label="Exit AR"
|
||||
aria-hidden="true">
|
||||
${CloseIcon}
|
||||
</a>
|
||||
</slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="screen-reader-only" role="region" aria-label="Live announcements">
|
||||
<span id="status" role="status"></span>
|
||||
</div>`;
|
||||
|
||||
export const makeTemplate = (shadowRoot: ShadowRoot) => {
|
||||
render(templateResult, shadowRoot);
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {expect} from 'chai';
|
||||
import {ReactiveElement} from 'lit';
|
||||
import {property} from 'lit/decorators.js';
|
||||
|
||||
import {style} from '../decorators.js';
|
||||
import {numberNode} from '../styles/parsers.js';
|
||||
import {timePasses} from '../utilities.js';
|
||||
|
||||
const $updateFoo = Symbol('updateFoo');
|
||||
|
||||
const fooIntrinsics = {
|
||||
basis: [numberNode(1, 'm'), numberNode(Math.PI, 'rad')],
|
||||
keywords: {auto: [null, numberNode(200, '%')]}
|
||||
};
|
||||
|
||||
class StylableElement extends ReactiveElement {
|
||||
@style({intrinsics: fooIntrinsics, updateHandler: $updateFoo})
|
||||
@property({type: String})
|
||||
foo: string = '200cm 1rad';
|
||||
|
||||
fooUpdates: Array<[number, number]> = [];
|
||||
|
||||
[$updateFoo](style: [number, number]) {
|
||||
this.fooUpdates.push(style);
|
||||
}
|
||||
}
|
||||
|
||||
suite('decorators', () => {
|
||||
suite('@style', () => {
|
||||
let instance = 0;
|
||||
let tagName: string;
|
||||
let element: StylableElement;
|
||||
|
||||
setup(async () => {
|
||||
tagName = `stylable-element-${instance++}`;
|
||||
customElements.define(tagName, class extends StylableElement {});
|
||||
|
||||
element = document.createElement(tagName) as StylableElement;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('invokes the update handler with the parsed default value', () => {
|
||||
expect(element.fooUpdates).to.be.eql([[2, 1]]);
|
||||
});
|
||||
|
||||
test(
|
||||
'invokes the update handler once with a parsed updated value',
|
||||
async () => {
|
||||
element.foo = '1m auto';
|
||||
await timePasses();
|
||||
expect(element.fooUpdates).to.be.eql([[2, 1], [1, 2 * Math.PI]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath} from '../helpers.js';
|
||||
|
||||
const TOLERANCE_SEC = 0.1;
|
||||
const NON_ANIMATED_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
const ANIMATED_GLB_PATH = assetPath('models/RobotExpressive.glb');
|
||||
const ANIMATED_GLB_DUPLICATE_ANIMATION_NAMES_PATH =
|
||||
assetPath('models/DuplicateAnimationNames.glb');
|
||||
|
||||
const animationIsPlaying = (element: any, animationName?: string): boolean => {
|
||||
const {currentAnimationAction} = element[$scene];
|
||||
|
||||
if (currentAnimationAction != null &&
|
||||
(animationName == null ||
|
||||
currentAnimationAction.getClip().name === animationName)) {
|
||||
return element.paused === false &&
|
||||
currentAnimationAction.enabled === true &&
|
||||
!currentAnimationAction.paused;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
const animationWithIndexIsPlaying = (element: any, animationIndex = 0):
|
||||
boolean => {
|
||||
const {currentAnimationAction} = element[$scene];
|
||||
const {_currentGLTF} = element[$scene];
|
||||
|
||||
if (currentAnimationAction != null && animationIndex >= 0 &&
|
||||
animationIndex < _currentGLTF.animations.length &&
|
||||
currentAnimationAction.getClip() ==
|
||||
_currentGLTF.animations[animationIndex]) {
|
||||
return element.paused === false &&
|
||||
currentAnimationAction.enabled === true &&
|
||||
!currentAnimationAction.paused;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
suite('Animation', () => {
|
||||
suite('a model with animations', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = ANIMATED_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('remains in a paused state', () => {
|
||||
expect(element.paused).to.be.true;
|
||||
});
|
||||
|
||||
suite('when play is invoked with no options', () => {
|
||||
setup(async () => {
|
||||
const animationsPlay = waitForEvent(element, 'play');
|
||||
element.play();
|
||||
await animationsPlay;
|
||||
});
|
||||
|
||||
test('animations play', async () => {
|
||||
expect(animationIsPlaying(element)).to.be.true;
|
||||
});
|
||||
|
||||
test('has a duration greater than 0', () => {
|
||||
expect(element.duration).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
suite('when pause is invoked after a delay', () => {
|
||||
const delaySeconds = 0.2;
|
||||
setup(async () => {
|
||||
await timePasses(1000 * delaySeconds);
|
||||
const animationsPause = waitForEvent(element, 'pause');
|
||||
element.pause();
|
||||
await animationsPause;
|
||||
});
|
||||
|
||||
test('animations pause', () => {
|
||||
expect(animationIsPlaying(element)).to.be.false;
|
||||
});
|
||||
|
||||
test.skip('has a current time close to the delay', () => {
|
||||
expect(element.currentTime)
|
||||
.to.be.closeTo(delaySeconds, TOLERANCE_SEC);
|
||||
});
|
||||
|
||||
test('changing currentTime triggers render', () => {
|
||||
element.currentTime = 5;
|
||||
expect(element[$scene].shouldRender()).to.be.true;
|
||||
});
|
||||
|
||||
suite('when play is invoked again', () => {
|
||||
setup(async () => {
|
||||
const animationsPlay = waitForEvent(element, 'play');
|
||||
element.play();
|
||||
await animationsPlay;
|
||||
});
|
||||
|
||||
test('animations play', () => {
|
||||
expect(animationIsPlaying(element)).to.be.true;
|
||||
});
|
||||
|
||||
test('has a duration greater than 0', () => {
|
||||
expect(element.duration).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
test.skip('has a current time close to the delay', () => {
|
||||
expect(element.currentTime)
|
||||
.to.be.closeTo(delaySeconds, TOLERANCE_SEC);
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
suite('when play is invoked with options', () => {
|
||||
setup(async () => {
|
||||
element.animationName = 'Punch';
|
||||
await element.updateComplete;
|
||||
const animationsPlay = waitForEvent(element, 'play');
|
||||
element.play({repetitions: 2, pingpong: true});
|
||||
await animationsPlay;
|
||||
});
|
||||
|
||||
test.skip('plays forward, backward, and stops', async () => {
|
||||
await timePasses(element.duration * 0.8 * 1000);
|
||||
expect(animationIsPlaying(element), 'failed to start playing!')
|
||||
.to.be.true;
|
||||
const t = element.currentTime;
|
||||
|
||||
await timePasses(element.duration * 1.0 * 1000);
|
||||
expect(animationIsPlaying(element), 'not playing after 1.8 * duration!')
|
||||
.to.be.true;
|
||||
expect(element.currentTime, 'not playing backwards!').to.be.lessThan(t);
|
||||
|
||||
await timePasses(element.duration * 0.4 * 1000);
|
||||
expect(animationIsPlaying(element), 'failed to stop playing!')
|
||||
.to.be.false;
|
||||
expect(element.currentTime, 'did not return to beginning of animation!')
|
||||
.to.be.equal(0);
|
||||
});
|
||||
});
|
||||
|
||||
suite('when configured to autoplay', () => {
|
||||
setup(async () => {
|
||||
element.autoplay = true;
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays an animation', () => {
|
||||
expect(animationIsPlaying(element)).to.be.true;
|
||||
});
|
||||
|
||||
test('has a duration greater than 0', () => {
|
||||
expect(element.duration).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
test('plays the first animation by default', () => {
|
||||
expect(animationIsPlaying(element, element.availableAnimations[0]))
|
||||
.to.be.true;
|
||||
});
|
||||
|
||||
suite('with a specified animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = element.availableAnimations[1];
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the specified animation', () => {
|
||||
expect(animationIsPlaying(element, element.availableAnimations[1]))
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('with an invalid animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = 'invalid-animation-name';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the first animation', () => {
|
||||
expect(animationIsPlaying(element, element.availableAnimations[0]))
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a specified index as animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = '1';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the specified animation', () => {
|
||||
expect(animationWithIndexIsPlaying(element, 1)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('with an invalid index as animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = '-1';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the first animation', () => {
|
||||
expect(animationWithIndexIsPlaying(element, 0)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('a model with duplicate animation names', () => {
|
||||
setup(async () => {
|
||||
element.src = ANIMATED_GLB_DUPLICATE_ANIMATION_NAMES_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
element.animationName = '1';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('plays the specified animation', () => {
|
||||
expect(animationWithIndexIsPlaying(element, 1)).to.be.true;
|
||||
});
|
||||
|
||||
suite('when playing a duplicate animation by name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = element.availableAnimations[1];
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test(
|
||||
'fails to play the specified animation and plays the last animation with that name instead',
|
||||
() => {
|
||||
expect(animationWithIndexIsPlaying(element, 3)).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('a model without animations', () => {
|
||||
setup(async () => {
|
||||
element.src = NON_ANIMATED_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
});
|
||||
|
||||
test('does not play an animation', () => {
|
||||
expect(animationIsPlaying(element)).to.be.false;
|
||||
});
|
||||
|
||||
test('has a duration of 0', () => {
|
||||
expect(element.duration).to.be.equal(0);
|
||||
});
|
||||
|
||||
suite('with a specified animation-name', () => {
|
||||
setup(async () => {
|
||||
element.animationName = element.availableAnimations[1];
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('does not play an animation', () => {
|
||||
expect(animationIsPlaying(element)).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a specified animation by index', () => {
|
||||
setup(async () => {
|
||||
element.animationName = '1';
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test('does not play an animation', () => {
|
||||
expect(animationIsPlaying(element)).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Vector3} from 'three';
|
||||
|
||||
import {$needsRender, $scene, toVector3D, Vector2D, Vector3D} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {Hotspot} from '../../three-components/Hotspot.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses} from '../helpers.js';
|
||||
|
||||
const sceneContainsHotspot =
|
||||
(scene: ModelScene, element: HTMLElement): boolean => {
|
||||
const {children} = scene.target;
|
||||
for (let i = 0, l = children.length; i < l; i++) {
|
||||
const hotspot = children[i];
|
||||
if (hotspot instanceof Hotspot &&
|
||||
(hotspot.element.children[0].children[0] as HTMLSlotElement)
|
||||
.name === element.slot) {
|
||||
// expect it has been changed from default
|
||||
expect(hotspot.position).to.not.eql(new Vector3());
|
||||
expect(hotspot.normal).to.not.eql(new Vector3(0, 1, 0));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const closeToVector3 = (a: Vector3D, b: Vector3) => {
|
||||
const delta = 0.001;
|
||||
expect(a.x).to.be.closeTo(b.x, delta);
|
||||
expect(a.y).to.be.closeTo(b.y, delta);
|
||||
expect(a.z).to.be.closeTo(b.z, delta);
|
||||
};
|
||||
|
||||
const withinRange = (a: Vector2D, start: number, finish: number) => {
|
||||
expect(a.u).to.be.within(start, finish);
|
||||
expect(a.v).to.be.within(start, finish);
|
||||
};
|
||||
|
||||
suite('Annotation', () => {
|
||||
let element: ModelViewerElement;
|
||||
let scene: ModelScene;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
scene = element[$scene];
|
||||
|
||||
element.src = assetPath('models/cube.gltf');
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
suite('a model-viewer element with a hotspot', () => {
|
||||
let hotspot: HTMLElement;
|
||||
let numSlots: number;
|
||||
|
||||
setup(async () => {
|
||||
hotspot = document.createElement('div');
|
||||
hotspot.setAttribute('slot', 'hotspot-1');
|
||||
hotspot.setAttribute('data-position', '1m 1m 1m');
|
||||
hotspot.setAttribute('data-normal', '0m 0m -1m');
|
||||
element.appendChild(hotspot);
|
||||
await timePasses();
|
||||
numSlots = scene.target.children.length;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (hotspot.parentElement === element) {
|
||||
element.removeChild(hotspot);
|
||||
}
|
||||
});
|
||||
|
||||
test('creates a corresponding slot', () => {
|
||||
expect(sceneContainsHotspot(scene, hotspot)).to.be.true;
|
||||
});
|
||||
|
||||
test.skip('querying it returns valid data', () => {
|
||||
// to test querying, place hotspot in the center and verify the screen
|
||||
// position is half the default width and height (300 x 150) with a depth
|
||||
// value of ~1.
|
||||
const defaultDimensions = {width: 300, height: 150};
|
||||
element.updateHotspot({name: 'hotspot-1', position: `0m 0m 0m`});
|
||||
|
||||
const hotspotData = element.queryHotspot('hotspot-1');
|
||||
|
||||
expect(hotspotData?.canvasPosition.x)
|
||||
.to.be.closeTo(defaultDimensions.width / 2, 0.0001);
|
||||
expect(hotspotData?.canvasPosition.y)
|
||||
.to.be.closeTo(defaultDimensions.height / 2, 0.0001);
|
||||
expect(hotspotData?.position.toString())
|
||||
.to.equal(toVector3D(new Vector3(0, 0, 0)).toString());
|
||||
expect(hotspotData?.normal.toString())
|
||||
.to.equal(toVector3D(new Vector3(0, 0, -1)).toString());
|
||||
expect(hotspotData?.facingCamera).to.be.true;
|
||||
});
|
||||
|
||||
suite('adding a second hotspot with the same name', () => {
|
||||
let hotspot2: HTMLElement;
|
||||
|
||||
setup(async () => {
|
||||
hotspot2 = document.createElement('div');
|
||||
hotspot2.setAttribute('slot', 'hotspot-1');
|
||||
hotspot2.setAttribute('data-position', '0m 1m 2m');
|
||||
hotspot2.setAttribute('data-normal', '1m 0m 0m');
|
||||
element.appendChild(hotspot2);
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (hotspot2.parentElement === element) {
|
||||
element.removeChild(hotspot2);
|
||||
}
|
||||
});
|
||||
|
||||
test('does not change the slot', () => {
|
||||
expect(scene.target.children.length).to.be.equal(numSlots);
|
||||
});
|
||||
|
||||
test('does not change the data', () => {
|
||||
const {position, normal} =
|
||||
(scene.target.children[numSlots - 1] as Hotspot);
|
||||
expect(position).to.be.deep.equal(new Vector3(1, 1, 1));
|
||||
expect(normal).to.be.deep.equal(new Vector3(0, 0, -1));
|
||||
});
|
||||
|
||||
test('updateHotspot does change the data', () => {
|
||||
element.updateHotspot(
|
||||
{name: 'hotspot-1', position: '0m 1m 2m', normal: '1m 0m 0m'});
|
||||
const {position, normal} =
|
||||
(scene.target.children[numSlots - 1] as Hotspot);
|
||||
expect(position).to.be.deep.equal(new Vector3(0, 1, 2));
|
||||
expect(normal).to.be.deep.equal(new Vector3(1, 0, 0));
|
||||
});
|
||||
|
||||
test('updateHotspot does change the surface', () => {
|
||||
const hotspot = scene.target.children[numSlots - 1] as Hotspot;
|
||||
const {x} = hotspot.position;
|
||||
const surface = '0 0 1 2 3 0.217 0.341 0.442';
|
||||
element.updateHotspot({name: 'hotspot-1', surface});
|
||||
expect(x).to.not.be.equal(hotspot.position.x);
|
||||
});
|
||||
|
||||
test('and removing it does not remove the slot', async () => {
|
||||
element.removeChild(hotspot);
|
||||
await timePasses();
|
||||
|
||||
expect(scene.target.children.length).to.be.equal(numSlots);
|
||||
});
|
||||
|
||||
test('but removing both does remove the slot', async () => {
|
||||
element.removeChild(hotspot);
|
||||
element.removeChild(hotspot2);
|
||||
await timePasses();
|
||||
|
||||
expect(scene.target.children.length).to.be.equal(numSlots - 1);
|
||||
});
|
||||
|
||||
suite('with a camera', () => {
|
||||
let wrapper: HTMLElement;
|
||||
|
||||
setup(async () => {
|
||||
// This is to wait for the hotspots to be added to their slots, as
|
||||
// this triggers their visibility to "show". Otherwise, sometimes the
|
||||
// following hide() call will happen first, then when the camera
|
||||
// moves, we never get a hotspot-visibility event because they were
|
||||
// already visible.
|
||||
await rafPasses();
|
||||
|
||||
wrapper = (scene.target.children[numSlots - 1] as Hotspot).element;
|
||||
});
|
||||
|
||||
test('the hotspot is hidden', async () => {
|
||||
expect(wrapper.classList.contains('hide')).to.be.true;
|
||||
});
|
||||
|
||||
test('the hotspot is visible after turning', async () => {
|
||||
element[$scene].yaw = Math.PI;
|
||||
element[$scene].updateMatrixWorld();
|
||||
element[$needsRender]();
|
||||
|
||||
await waitForEvent(hotspot2, 'hotspot-visibility');
|
||||
|
||||
expect(!!wrapper.classList.contains('hide')).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('a model-viewer element with a loaded cube', () => {
|
||||
let rect: DOMRect;
|
||||
|
||||
setup(async () => {
|
||||
element.setAttribute('style', `width: 200px; height: 300px`);
|
||||
rect = element.getBoundingClientRect();
|
||||
element.cameraOrbit = '0deg 90deg 2m';
|
||||
element.jumpCameraToGoal();
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('gets expected hit result', async () => {
|
||||
await rafPasses();
|
||||
const hitResult = element.positionAndNormalFromPoint(
|
||||
rect.width / 2 + rect.x, rect.height / 2 + rect.y);
|
||||
expect(hitResult).to.be.ok;
|
||||
const {position, normal, uv} = hitResult!;
|
||||
closeToVector3(position, new Vector3(0, 0, 0.5));
|
||||
closeToVector3(normal, new Vector3(0, 0, 1));
|
||||
|
||||
if (uv != null) {
|
||||
withinRange(uv, 0, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('gets expected hit result when turned', async () => {
|
||||
element.resetTurntableRotation(-Math.PI / 2);
|
||||
await rafPasses();
|
||||
const hitResult = element.positionAndNormalFromPoint(
|
||||
rect.width / 2 + rect.x, rect.height / 2 + rect.y);
|
||||
expect(hitResult).to.be.ok;
|
||||
const {position, normal, uv} = hitResult!;
|
||||
closeToVector3(position, new Vector3(0.5, 0, 0));
|
||||
closeToVector3(normal, new Vector3(1, 0, 0));
|
||||
if (uv != null) {
|
||||
withinRange(uv, 0, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('returns a surface that shows and hides appropriately', async () => {
|
||||
await rafPasses();
|
||||
const surface = element.surfaceFromPoint(
|
||||
rect.width / 2 + rect.x, rect.height / 2 + rect.y);
|
||||
expect(surface).to.be.ok;
|
||||
|
||||
const hotspot = document.createElement('div');
|
||||
hotspot.setAttribute('slot', 'hotspot-1');
|
||||
hotspot.setAttribute('data-surface', surface!);
|
||||
element.appendChild(hotspot);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
expect(sceneContainsHotspot(scene, hotspot)).to.be.true;
|
||||
|
||||
const numSlots = scene.target.children.length;
|
||||
const wrapper = (scene.target.children[numSlots - 1] as Hotspot).element;
|
||||
|
||||
expect(wrapper.classList.contains('hide')).to.be.false;
|
||||
|
||||
element[$scene].yaw = Math.PI;
|
||||
element[$scene].updateMatrixWorld();
|
||||
element[$needsRender]();
|
||||
|
||||
await waitForEvent(hotspot, 'hotspot-visibility');
|
||||
|
||||
expect(wrapper.classList.contains('hide')).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,209 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {IS_ANDROID, IS_IOS} from '../../constants.js';
|
||||
import {$openIOSARQuickLook, $openSceneViewer} from '../../features/ar.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses, spy} from '../helpers.js';
|
||||
|
||||
suite('AR', () => {
|
||||
let element: ModelViewerElement;
|
||||
let intentUrls: Array<string>;
|
||||
let restoreAnchorClick: () => void;
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
intentUrls = [];
|
||||
restoreAnchorClick = spy(HTMLAnchorElement.prototype, 'click', {
|
||||
value: function() {
|
||||
intentUrls.push((this as HTMLAnchorElement).href);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
restoreAnchorClick();
|
||||
});
|
||||
|
||||
suite('openSceneViewer', () => {
|
||||
test('preserves query parameters in model URLs', () => {
|
||||
element.src = 'https://example.com/model.gltf?token=foo';
|
||||
element.alt = 'Example model';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
|
||||
expect(search.get('token')).to.equal('foo');
|
||||
});
|
||||
|
||||
test('keeps title and link when supplied', () => {
|
||||
element.src =
|
||||
'https://example.com/model.gltf?link=http://linkme.com&title=bar';
|
||||
element.alt = 'alt';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
|
||||
expect(search.get('title')).to.equal('bar');
|
||||
expect(search.get('link')).to.equal('http://linkme.com/');
|
||||
});
|
||||
|
||||
test('sets sound and link to absolute URLs', () => {
|
||||
element.src =
|
||||
'https://example.com/model.gltf?link=foo.html&sound=bar.ogg';
|
||||
element.alt = 'alt';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
|
||||
// Tests run in different locations
|
||||
expect(search.get('sound')).to.contain('http://');
|
||||
expect(search.get('sound')).to.contain('/bar.ogg');
|
||||
expect(search.get('link')).to.contain('http://');
|
||||
expect(search.get('link')).to.contain('/foo.html');
|
||||
});
|
||||
|
||||
test('strips hash params from SceneViewer model src', () => {
|
||||
element.src =
|
||||
'https://example.com/model.gltf#applePayButtonType=plain&checkoutTitle=TitleText';
|
||||
element.alt = 'alt';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
const file = new URL(search.get('file') as any);
|
||||
|
||||
expect(file.hash).to.equal('');
|
||||
});
|
||||
|
||||
test('strips hash params but preserves query params', () => {
|
||||
element.src =
|
||||
'https://example.com/model.gltf?link=http://linkme.com&title=bar#applePayButtonType=plain&checkoutTitle=TitleText';
|
||||
element.alt = 'alt';
|
||||
(element as any)[$openSceneViewer]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const search = new URLSearchParams(new URL(intentUrls[0]).search);
|
||||
const file = new URL(search.get('file') as any);
|
||||
|
||||
expect(file.hash).to.equal('');
|
||||
expect(search.get('title')).to.equal('bar');
|
||||
expect(search.get('link')).to.equal('http://linkme.com/');
|
||||
});
|
||||
});
|
||||
|
||||
suite('openQuickLook', () => {
|
||||
test('sets hash for fixed scale', () => {
|
||||
element.src = 'https://example.com/model.gltf';
|
||||
element.iosSrc = 'https://example.com/model.usdz';
|
||||
element.arScale = 'fixed';
|
||||
(element as any)[$openIOSARQuickLook]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const url = new URL(intentUrls[0]);
|
||||
|
||||
expect(url.pathname).equal('/model.usdz');
|
||||
expect(url.hash).to.equal('#allowsContentScaling=0');
|
||||
});
|
||||
|
||||
test('keeps original hash too', () => {
|
||||
element.src = 'https://example.com/model.gltf';
|
||||
element.iosSrc =
|
||||
'https://example.com/model.usdz#custom=path-to-banner.html';
|
||||
element.arScale = 'fixed';
|
||||
(element as any)[$openIOSARQuickLook]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const url = new URL(intentUrls[0]);
|
||||
|
||||
expect(url.pathname).equal('/model.usdz');
|
||||
expect(url.hash).to.equal(
|
||||
'#custom=path-to-banner.html&allowsContentScaling=0');
|
||||
});
|
||||
|
||||
test('replicate src hash to usdz blob url', async () => {
|
||||
element.src =
|
||||
assetPath('models/cube.gltf') + '#custom=path-to-banner.html';
|
||||
element.arModes = 'webxr scene-viewer quick-look';
|
||||
|
||||
await (element as any)[$openIOSARQuickLook]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const url = new URL(intentUrls[0]);
|
||||
|
||||
expect(url.protocol).to.equal('blob:');
|
||||
|
||||
expect(url.hash).to.equal('#custom=path-to-banner.html');
|
||||
});
|
||||
|
||||
test(
|
||||
'replicate src hash to usdz blob and set hash for fixed scale',
|
||||
async () => {
|
||||
element.src =
|
||||
assetPath('models/cube.gltf') + '#custom=path-to-banner.html';
|
||||
element.arModes = 'webxr scene-viewer quick-look';
|
||||
element.arScale = 'fixed';
|
||||
|
||||
await (element as any)[$openIOSARQuickLook]();
|
||||
|
||||
expect(intentUrls.length).to.be.equal(1);
|
||||
|
||||
const url = new URL(intentUrls[0]);
|
||||
|
||||
expect(url.protocol).to.equal('blob:');
|
||||
|
||||
expect(url.hash).to.equal(
|
||||
'#custom=path-to-banner.html&allowsContentScaling=0');
|
||||
});
|
||||
});
|
||||
|
||||
suite('shows the AR button', () => {
|
||||
setup(async () => {
|
||||
element.ar = true;
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
test('on Android', () => {
|
||||
expect(element.canActivateAR).to.be.equal(IS_ANDROID);
|
||||
});
|
||||
|
||||
// This only works on a physical iOS device, not an emulated one.
|
||||
test.skip('with an ios-src on iOS', async () => {
|
||||
element.iosSrc = assetPath('models/Astronaut.usdz');
|
||||
await rafPasses();
|
||||
expect(element.canActivateAR).to.be.equal(IS_ANDROID || IS_IOS);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,281 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Texture} from 'three';
|
||||
|
||||
import {BASE_OPACITY} from '../../features/environment.js';
|
||||
import ModelViewerElementBase, {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {Renderer} from '../../three-components/Renderer.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses} from '../helpers.js';
|
||||
|
||||
const ALT_BG_IMAGE_URL = assetPath('environments/white_furnace.hdr');
|
||||
const HDR_BG_IMAGE_URL = assetPath('environments/spruit_sunrise_1k_HDR.hdr');
|
||||
const MODEL_URL = assetPath('models/reflective-sphere.gltf');
|
||||
|
||||
/**
|
||||
* Returns a promise that resolves when a given element is loaded
|
||||
* and has an environment map set that matches the passed in meta.
|
||||
*/
|
||||
const waitForLoadAndEnvMap = (element: ModelViewerElementBase) => {
|
||||
const load = waitForEvent(element, 'poster-dismissed');
|
||||
const envMap = waitForEvent(element, 'environment-change');
|
||||
return Promise.all([load, envMap]);
|
||||
};
|
||||
|
||||
suite('Environment', () => {
|
||||
suiteTeardown(() => {
|
||||
Renderer.resetSingleton();
|
||||
});
|
||||
|
||||
let element: ModelViewerElement;
|
||||
let scene: ModelScene;
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
scene = element[$scene];
|
||||
});
|
||||
|
||||
teardown(() => element.parentNode && element.parentNode.removeChild(element));
|
||||
|
||||
test('only generates an environment when in the render tree', async () => {
|
||||
let environmentChangeCount = 0;
|
||||
const environmentChangeHandler = () => environmentChangeCount++;
|
||||
element.addEventListener('environment-change', environmentChangeHandler);
|
||||
element.style.display = 'none';
|
||||
element.src = MODEL_URL;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await rafPasses();
|
||||
expect(environmentChangeCount).to.be.eq(0);
|
||||
element.style.display = 'block';
|
||||
await waitForEvent(element, 'environment-change');
|
||||
expect(environmentChangeCount).to.be.eq(1);
|
||||
element.removeEventListener('environment-change', environmentChangeHandler);
|
||||
});
|
||||
|
||||
suite('with no skybox-image property', () => {
|
||||
let environmentChanges = 0;
|
||||
suite('and a src property', () => {
|
||||
setup(async () => {
|
||||
const onLoad = waitForLoadAndEnvMap(element);
|
||||
element.src = MODEL_URL;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
|
||||
environmentChanges = 0;
|
||||
element.addEventListener('environment-change', () => {
|
||||
environmentChanges++;
|
||||
});
|
||||
await onLoad;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('applies a generated environment map on model', async function() {
|
||||
expect(scene.environment!.name).to.be.eq('neutral');
|
||||
});
|
||||
|
||||
test('changes the environment exactly once', async function() {
|
||||
expect(environmentChanges).to.be.eq(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('exposure', () => {
|
||||
setup(async () => {
|
||||
element.src = MODEL_URL;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
scene.visible = true;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('changes the tone mapping exposure of the renderer', async () => {
|
||||
const renderer = Renderer.singleton;
|
||||
const originalToneMappingExposure =
|
||||
renderer.threeRenderer.toneMappingExposure;
|
||||
element.exposure = 2.0;
|
||||
await timePasses();
|
||||
renderer.render(performance.now());
|
||||
|
||||
const newToneMappingExposure = renderer.threeRenderer.toneMappingExposure;
|
||||
|
||||
expect(newToneMappingExposure)
|
||||
.to.be.greaterThan(originalToneMappingExposure);
|
||||
});
|
||||
});
|
||||
|
||||
suite('shadow-intensity', () => {
|
||||
setup(async () => {
|
||||
element.src = MODEL_URL;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('changes the opacity of the static shadow', async () => {
|
||||
element.shadowIntensity = 1.0;
|
||||
await timePasses();
|
||||
const newIntensity = scene.shadow!.getIntensity();
|
||||
expect(newIntensity).to.be.eq(BASE_OPACITY);
|
||||
});
|
||||
});
|
||||
|
||||
suite('environment-image', () => {
|
||||
setup(async () => {
|
||||
const onLoad = waitForLoadAndEnvMap(element);
|
||||
element.setAttribute('src', MODEL_URL);
|
||||
element.setAttribute('environment-image', HDR_BG_IMAGE_URL);
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await onLoad;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('applies environment-image environment map on model', () => {
|
||||
expect(scene.environment!.name).to.be.eq(element.environmentImage);
|
||||
});
|
||||
|
||||
suite('and environment-image subsequently removed', () => {
|
||||
setup(async () => {
|
||||
const envMapChanged = waitForEvent(element, 'environment-change');
|
||||
element.removeAttribute('environment-image');
|
||||
await envMapChanged;
|
||||
});
|
||||
|
||||
test('reapplies generated environment map on model', () => {
|
||||
expect(scene.environment!.name).to.be.eq('neutral');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('with skybox-image property', () => {
|
||||
setup(async () => {
|
||||
const onLoad = waitForLoadAndEnvMap(element);
|
||||
element.setAttribute('src', MODEL_URL);
|
||||
element.setAttribute('skybox-image', HDR_BG_IMAGE_URL);
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await onLoad;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('displays background with skybox-image', async function() {
|
||||
expect((scene.background as Texture).name).to.be.eq(element.skyboxImage);
|
||||
});
|
||||
|
||||
test('applies skybox-image environment map on model', async function() {
|
||||
expect(scene.environment!.name).to.be.eq(element.skyboxImage);
|
||||
});
|
||||
|
||||
test('has tight radius', async function() {
|
||||
expect(scene.farRadius()).to.be.lessThan(2);
|
||||
});
|
||||
|
||||
suite('with skybox-height property', () => {
|
||||
setup(async () => {
|
||||
element.setAttribute('skybox-height', '1m');
|
||||
await element.updateComplete;
|
||||
});
|
||||
|
||||
test('switches background', async function() {
|
||||
expect(scene.background).to.be.null;
|
||||
});
|
||||
|
||||
test('has wide radius', async function() {
|
||||
expect(scene.farRadius()).to.be.greaterThan(2);
|
||||
});
|
||||
|
||||
test('no skybox-image disables grounded skybox', async function() {
|
||||
element.setAttribute('skybox-image', '');
|
||||
await element.updateComplete;
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
expect(scene.farRadius()).to.be.lessThan(2);
|
||||
});
|
||||
});
|
||||
|
||||
suite('with an environment-image', () => {
|
||||
setup(async () => {
|
||||
const environmentChanged = waitForEvent(element, 'environment-change');
|
||||
element.setAttribute('environment-image', ALT_BG_IMAGE_URL);
|
||||
await environmentChanged;
|
||||
});
|
||||
|
||||
test('prefers environment-image as environment map', () => {
|
||||
expect(scene.environment!.name).to.be.eq(ALT_BG_IMAGE_URL);
|
||||
});
|
||||
|
||||
suite('and environment-image subsequently removed', () => {
|
||||
setup(async () => {
|
||||
const environmentChanged =
|
||||
waitForEvent(element, 'environment-change');
|
||||
element.removeAttribute('environment-image');
|
||||
await environmentChanged;
|
||||
});
|
||||
|
||||
test('uses skybox-image as environment map', () => {
|
||||
expect(scene.environment!.name).to.be.eq(HDR_BG_IMAGE_URL);
|
||||
});
|
||||
});
|
||||
|
||||
suite('and skybox-image subsequently removed', () => {
|
||||
setup(async () => {
|
||||
element.removeAttribute('skybox-image');
|
||||
await element.updateComplete;
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('continues using environment-image as environment map', () => {
|
||||
expect(scene.environment!.name).to.be.eq(ALT_BG_IMAGE_URL);
|
||||
});
|
||||
|
||||
test('removes the background', async function() {
|
||||
expect(scene.background).to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('and skybox-image subsequently removed', () => {
|
||||
setup(async () => {
|
||||
const envMapChanged = waitForEvent(element, 'environment-change');
|
||||
element.removeAttribute('skybox-image');
|
||||
await envMapChanged;
|
||||
});
|
||||
|
||||
test('removes the background', async function() {
|
||||
expect(scene.background).to.be.null;
|
||||
});
|
||||
|
||||
test('reapplies generated environment map on model', async function() {
|
||||
expect(scene.environment!.name).to.be.eq('neutral');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {$defaultPosterElement, $posterContainerElement} from '../../features/loading.js';
|
||||
import {$scene, $userInputElement} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {CachingGLTFLoader} from '../../three-components/CachingGLTFLoader.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, pickShadowDescendant, rafPasses, until} from '../helpers.js';
|
||||
|
||||
const CUBE_GLB_PATH = assetPath('models/cube.gltf');
|
||||
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
|
||||
|
||||
suite('Loading', () => {
|
||||
let element: ModelViewerElement;
|
||||
let firstChild: ChildNode|null;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
firstChild = document.body.firstChild;
|
||||
document.body.insertBefore(element, firstChild);
|
||||
|
||||
// Wait at least a microtask for size calculations
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
CachingGLTFLoader.clearCache();
|
||||
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
suite('with a second element outside the viewport', () => {
|
||||
let element2: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element2 = new ModelViewerElement();
|
||||
element2.loading = 'eager';
|
||||
document.body.insertBefore(element2, firstChild);
|
||||
element.style.height = '100vh';
|
||||
element2.style.height = '100vh';
|
||||
const load1 = waitForEvent(element, 'load');
|
||||
const load2 = waitForEvent(element2, 'load');
|
||||
element.src = CUBE_GLB_PATH;
|
||||
element2.src = CUBE_GLB_PATH;
|
||||
await Promise.all([load1, load2]);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element2.parentNode != null) {
|
||||
element2.parentNode.removeChild(element2);
|
||||
}
|
||||
});
|
||||
|
||||
test('first element is visible', () => {
|
||||
expect(element.modelIsVisible).to.be.true;
|
||||
});
|
||||
|
||||
test('second element is not visible', () => {
|
||||
expect(element2.modelIsVisible).to.be.false;
|
||||
});
|
||||
|
||||
suite('scroll to second element', () => {
|
||||
setup(() => {
|
||||
element2.scrollIntoView();
|
||||
});
|
||||
|
||||
test('first element is not visible', async () => {
|
||||
await waitForEvent<CustomEvent>(
|
||||
element,
|
||||
'model-visibility',
|
||||
event => event.detail.visible === false);
|
||||
});
|
||||
|
||||
test('second element is visible', async () => {
|
||||
await waitForEvent<CustomEvent>(
|
||||
element2,
|
||||
'model-visibility',
|
||||
event => event.detail.visible === true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('creates a poster element that captures interactions', async () => {
|
||||
const picked = pickShadowDescendant(element);
|
||||
expect(picked).to.be.ok;
|
||||
// TODO(cdata): Leaky internal details here:
|
||||
expect(picked!.id).to.be.equal('default-poster');
|
||||
});
|
||||
|
||||
test('does not load when hidden from render tree', async () => {
|
||||
let loadDispatched = false;
|
||||
const loadHandler = () => {
|
||||
loadDispatched = true;
|
||||
};
|
||||
|
||||
element.addEventListener('load', loadHandler);
|
||||
|
||||
element.style.display = 'none';
|
||||
|
||||
// Give IntersectionObserver a chance to notify. In Chrome, this takes
|
||||
// two rAFs (empirically observed). Await extra time just in case:
|
||||
await timePasses(100);
|
||||
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
await timePasses(500); // Arbitrary time to allow model to load
|
||||
|
||||
element.removeEventListener('load', loadHandler);
|
||||
|
||||
expect(loadDispatched).to.be.false;
|
||||
});
|
||||
|
||||
suite('load', () => {
|
||||
suite('when a model src changes after loading', () => {
|
||||
setup(async () => {
|
||||
// The shadow is here to expose an earlier bug on unloading models.
|
||||
element.shadowIntensity = 1;
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
});
|
||||
|
||||
test('only dispatches load once per src change', async () => {
|
||||
let loadCount = 0;
|
||||
const onLoad = () => {
|
||||
loadCount++;
|
||||
};
|
||||
|
||||
element.addEventListener('load', onLoad);
|
||||
|
||||
try {
|
||||
element.src = HORSE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
// Give any late-dispatching events a chance to dispatch
|
||||
await timePasses(300);
|
||||
|
||||
expect(loadCount).to.be.equal(2);
|
||||
} finally {
|
||||
element.removeEventListener('load', onLoad);
|
||||
}
|
||||
});
|
||||
|
||||
test('getDimensions() returns correct size', () => {
|
||||
const size = element.getDimensions();
|
||||
expect(size.x).to.be.eq(1);
|
||||
expect(size.y).to.be.eq(1);
|
||||
expect(size.z).to.be.eq(1);
|
||||
});
|
||||
|
||||
test('models are unloaded after src updates', async () => {
|
||||
element.src = HORSE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const {shadow, model, target} = element[$scene];
|
||||
const {children} = target;
|
||||
expect(children.length).to.be.eq(2, 'horse');
|
||||
expect(children).to.contain(shadow, 'horse shadow');
|
||||
expect(children).to.contain(model, 'horse model');
|
||||
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
const {children: children2} = target;
|
||||
expect(children2.length).to.be.eq(2, 'cube');
|
||||
expect(children2).to.contain(shadow, 'cube shadow');
|
||||
expect(children2).to.contain(element[$scene].model, 'cube model');
|
||||
});
|
||||
|
||||
test('generates 3DModel schema', async () => {
|
||||
element.generateSchema = true;
|
||||
await element.updateComplete;
|
||||
const {schemaElement} = element[$scene];
|
||||
expect(schemaElement.type).to.be.eq('application/ld+json');
|
||||
expect(schemaElement.parentElement).to.be.eq(document.head);
|
||||
const json = JSON.parse(schemaElement.textContent!);
|
||||
const encoding = json.encoding[0];
|
||||
|
||||
expect(encoding.contentUrl).to.be.eq(CUBE_GLB_PATH);
|
||||
expect(encoding.encodingFormat).to.be.eq('model/gltf+json');
|
||||
|
||||
element.generateSchema = false;
|
||||
await element.updateComplete;
|
||||
expect(schemaElement.parentElement).to.be.not.ok;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('loading', () => {
|
||||
suite('src changes quickly', () => {
|
||||
test('eventually notifies that current src is loaded', async () => {
|
||||
element.loading = 'eager';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
const loadCubeEvent =
|
||||
waitForEvent(element, 'load') as Promise<CustomEvent>;
|
||||
|
||||
await timePasses();
|
||||
|
||||
element.src = HORSE_GLB_PATH;
|
||||
|
||||
const loadCube = await loadCubeEvent;
|
||||
const loadHorse = await waitForEvent(element, 'load') as CustomEvent;
|
||||
|
||||
expect(loadCube.detail.url).to.be.eq(CUBE_GLB_PATH);
|
||||
expect(loadHorse.detail.url).to.be.eq(HORSE_GLB_PATH);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
suite('reveal', () => {
|
||||
suite('auto', () => {
|
||||
test('hides poster when element loads', async () => {
|
||||
element.src = CUBE_GLB_PATH;
|
||||
const input = element[$userInputElement];
|
||||
|
||||
expect(pickShadowDescendant(element))
|
||||
.to.be.not.equal(
|
||||
input, 'the poster should be shown until the model loads');
|
||||
|
||||
await waitForEvent(
|
||||
element,
|
||||
'model-visibility',
|
||||
(event: any) => event.detail.visible);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
expect(pickShadowDescendant(element)).to.be.equal(input);
|
||||
|
||||
element.reveal = 'manual';
|
||||
await element.updateComplete;
|
||||
await rafPasses();
|
||||
|
||||
expect(pickShadowDescendant(element))
|
||||
.to.be.equal(input, 'changing reveal should not show the poster');
|
||||
});
|
||||
});
|
||||
|
||||
suite('manual', () => {
|
||||
test('does not hide poster until dismissed', async () => {
|
||||
element.loading = 'eager';
|
||||
element.reveal = 'manual';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
const posterElement = (element as any)[$defaultPosterElement];
|
||||
const input = element[$userInputElement];
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
posterElement.focus();
|
||||
|
||||
expect(element.shadowRoot!.activeElement).to.be.equal(posterElement);
|
||||
|
||||
element.dismissPoster();
|
||||
|
||||
await until(() => {
|
||||
return element.shadowRoot!.activeElement === input;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('configuring poster via attribute', () => {
|
||||
suite('removing the attribute', () => {
|
||||
test('sets poster to null', async () => {
|
||||
// NOTE(cdata): This is less important after we resolve
|
||||
// https://github.com/PolymerLabs/model-viewer/issues/76
|
||||
element.setAttribute('poster', CUBE_GLB_PATH);
|
||||
await timePasses();
|
||||
element.removeAttribute('poster');
|
||||
await timePasses();
|
||||
expect(element.poster).to.be.equal(null);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('with loaded model src', () => {
|
||||
setup(() => {
|
||||
element.src = CUBE_GLB_PATH;
|
||||
});
|
||||
|
||||
test('can be hidden imperatively', async () => {
|
||||
const ostensiblyThePoster = pickShadowDescendant(element);
|
||||
|
||||
element.dismissPoster();
|
||||
|
||||
await waitForEvent<CustomEvent>(
|
||||
element, 'model-visibility', event => event.detail.visible === true);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
const ostensiblyNotThePoster = pickShadowDescendant(element);
|
||||
|
||||
expect(ostensiblyThePoster).to.not.be.equal(ostensiblyNotThePoster);
|
||||
});
|
||||
|
||||
suite('when poster is hidden', () => {
|
||||
setup(async () => {
|
||||
element.dismissPoster();
|
||||
await waitForEvent<CustomEvent>(
|
||||
element,
|
||||
'model-visibility',
|
||||
event => event.detail.visible === true);
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('allows the input to be interactive', async () => {
|
||||
const input = element[$userInputElement];
|
||||
const picked = pickShadowDescendant(element);
|
||||
|
||||
expect(picked).to.be.equal(input);
|
||||
});
|
||||
|
||||
test('when src is reset, poster is dismissible', async () => {
|
||||
const posterElement = (element as any)[$defaultPosterElement];
|
||||
const posterContainer = (element as any)[$posterContainerElement];
|
||||
const inputElement = element[$userInputElement];
|
||||
|
||||
element.reveal = 'manual';
|
||||
element.src = null;
|
||||
element.showPoster();
|
||||
|
||||
await timePasses();
|
||||
|
||||
element.src = CUBE_GLB_PATH;
|
||||
|
||||
await timePasses();
|
||||
|
||||
expect(posterContainer.classList.contains('show')).to.be.true;
|
||||
|
||||
posterElement.focus();
|
||||
|
||||
expect(element.shadowRoot!.activeElement).to.be.equal(posterElement);
|
||||
|
||||
element.dismissPoster();
|
||||
|
||||
await until(() => {
|
||||
return element.shadowRoot!.activeElement === inputElement;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,399 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Mesh, MeshStandardMaterial} from 'three';
|
||||
|
||||
import {$currentGLTF} from '../../features/scene-graph.js';
|
||||
import {$primitivesList} from '../../features/scene-graph/model.js';
|
||||
import {PrimitiveNode} from '../../features/scene-graph/nodes/primitive-node.js';
|
||||
import {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ModelViewerGLTFInstance} from '../../three-components/gltf-instance/ModelViewerGLTFInstance.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses} from '../helpers.js';
|
||||
|
||||
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
const HORSE_GLB_PATH = assetPath('models/Horse.glb');
|
||||
const CUBES_GLB_PATH = assetPath('models/cubes.gltf'); // has variants
|
||||
const MESH_PRIMITIVES_GLB_PATH =
|
||||
assetPath('models/MeshPrimitivesVariants.glb'); // has variants
|
||||
const CUBE_GLB_PATH = assetPath('models/cube.gltf'); // has UV coords
|
||||
const RIGGEDFIGURE_GLB_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/RiggedFigure/glTF-Binary/RiggedFigure.glb');
|
||||
|
||||
function getGLTFRoot(scene: ModelScene, hasBeenExportedOnce = false) {
|
||||
// TODO: export is putting in an extra node layer, because the loader
|
||||
// gives us a Group, but if the exporter doesn't get a Scene, then it
|
||||
// wraps everything in an "AuxScene" node. Feels like a three.js bug.
|
||||
return hasBeenExportedOnce ? scene.model!.children[0] : scene.model!;
|
||||
}
|
||||
|
||||
suite('SceneGraph', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
suite('scene export', () => {
|
||||
suite('transformations', () => {
|
||||
test(
|
||||
'setting scale before model loads has expected dimensions',
|
||||
async () => {
|
||||
element.scale = '1 2 3';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const dim = element.getDimensions();
|
||||
expect(dim.x).to.be.eq(1, 'x');
|
||||
expect(dim.y).to.be.eq(2, 'y');
|
||||
expect(dim.z).to.be.eq(3, 'z');
|
||||
});
|
||||
|
||||
test('orientation is applied after scale', async () => {
|
||||
element.orientation = '90deg 90deg 90deg';
|
||||
element.scale = '1 2 3';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const dim = element.getDimensions();
|
||||
expect(dim.x).to.be.closeTo(1, 0.001, 'x');
|
||||
expect(dim.y).to.be.closeTo(3, 0.001, 'y');
|
||||
expect(dim.z).to.be.closeTo(2, 0.001, 'z');
|
||||
});
|
||||
|
||||
test('exports and re-imports the rescaled model', async () => {
|
||||
element.scale = '1 2 3';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
element.scale = '1 1 1';
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
const dim = element.getDimensions();
|
||||
expect(dim.x).to.be.eq(1, 'x');
|
||||
expect(dim.y).to.be.eq(2, 'y');
|
||||
expect(dim.z).to.be.eq(3, 'z');
|
||||
});
|
||||
|
||||
test('exports and re-imports the transformed model', async () => {
|
||||
element.orientation = '90deg 90deg 90deg';
|
||||
element.scale = '1 2 3';
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
|
||||
element.orientation = '0deg 0deg 0deg';
|
||||
element.scale = '1 1 1';
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
const dim = element.getDimensions();
|
||||
expect(dim.x).to.be.closeTo(1, 0.001, 'x');
|
||||
expect(dim.y).to.be.closeTo(3, 0.001, 'y');
|
||||
expect(dim.z).to.be.closeTo(2, 0.001, 'z');
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a loaded model', () => {
|
||||
setup(async () => {
|
||||
element.src = CUBES_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('exports the loaded model to GLTF', async () => {
|
||||
const exported = await element.exportScene({binary: false});
|
||||
expect(exported).to.be.not.undefined;
|
||||
expect(exported.size).to.be.greaterThan(500);
|
||||
});
|
||||
|
||||
test('exports the loaded model to GLB', async () => {
|
||||
const exported = await element.exportScene({binary: true});
|
||||
expect(exported).to.be.not.undefined;
|
||||
expect(exported.size).to.be.greaterThan(500);
|
||||
});
|
||||
|
||||
test('has variants', () => {
|
||||
expect(element[$scene].currentGLTF!.userData.variants.length)
|
||||
.to.be.eq(3);
|
||||
const gltfRoot = getGLTFRoot(element[$scene]);
|
||||
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
|
||||
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
|
||||
});
|
||||
|
||||
test('allows the scene graph to be manipulated', async () => {
|
||||
element.variantName = 'Yellow Red';
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
|
||||
const material =
|
||||
(element[$scene].model!.children[1] as Mesh).material as
|
||||
MeshStandardMaterial;
|
||||
|
||||
const mat = element.model!.getMaterialByName('red')!;
|
||||
|
||||
expect(mat.isActive).to.be.true;
|
||||
|
||||
mat.pbrMetallicRoughness.setBaseColorFactor([0.5, 0.5, 0.5, 1]);
|
||||
|
||||
const color = mat.pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(color).to.be.eql([0.5, 0.5, 0.5, 1]);
|
||||
|
||||
console.log(material.name, ': actual material ', material.uuid);
|
||||
|
||||
expect(material.color).to.include({r: 0.5, g: 0.5, b: 0.5});
|
||||
});
|
||||
|
||||
test(
|
||||
`Setting variantName to null results in primitive
|
||||
reverting to default/initial material`,
|
||||
async () => {
|
||||
let primitiveNode: PrimitiveNode|null = null
|
||||
// Finds the first primitive with material 0 assigned.
|
||||
for (const primitive of element.model![$primitivesList]) {
|
||||
if (primitive.variantInfo != null &&
|
||||
primitive.initialMaterialIdx == 0) {
|
||||
primitiveNode = primitive;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
expect(primitiveNode).to.not.be.null;
|
||||
|
||||
// Switches to a new variant.
|
||||
element.variantName = 'Yellow Red';
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
|
||||
.equal('red');
|
||||
|
||||
// Switches to null variant.
|
||||
element.variantName = null;
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
expect((primitiveNode!.mesh.material as MeshStandardMaterial).name)
|
||||
.equal('purple');
|
||||
});
|
||||
|
||||
test('exports and re-imports the model with variants', async () => {
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
expect(element[$scene].currentGLTF!.userData.variants.length)
|
||||
.to.be.eq(3);
|
||||
const gltfRoot = getGLTFRoot(element[$scene], true);
|
||||
expect(gltfRoot.children[0].userData.variantMaterials.size).to.be.eq(3);
|
||||
expect(gltfRoot.children[1].userData.variantMaterials.size).to.be.eq(3);
|
||||
});
|
||||
});
|
||||
|
||||
suite(
|
||||
'with a loaded model containing a mesh with multiple primitives',
|
||||
() => {
|
||||
setup(async () => {
|
||||
element.src = MESH_PRIMITIVES_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('has variants', () => {
|
||||
expect(element[$scene].currentGLTF!.userData.variants.length)
|
||||
.to.be.eq(2);
|
||||
const gltfRoot = getGLTFRoot(element[$scene]);
|
||||
expect(
|
||||
gltfRoot.children[0].children[0].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
expect(
|
||||
gltfRoot.children[0].children[1].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
expect(
|
||||
gltfRoot.children[0].children[2].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
});
|
||||
|
||||
test(
|
||||
`Setting variantName to null results in primitive
|
||||
reverting to default/initial material`,
|
||||
async () => {
|
||||
let primitiveNode: PrimitiveNode|null = null
|
||||
// Finds the first primitive with material 0 assigned.
|
||||
for (const primitive of element.model![$primitivesList]) {
|
||||
if (primitive.variantInfo != null &&
|
||||
primitive.initialMaterialIdx == 0) {
|
||||
primitiveNode = primitive;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
expect(primitiveNode).to.not.be.null;
|
||||
|
||||
// Switches to a new variant.
|
||||
element.variantName = 'Inverse';
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
expect(
|
||||
(primitiveNode!.mesh.material as MeshStandardMaterial).name)
|
||||
.equal('STEEL RED X');
|
||||
|
||||
// Switches to null variant.
|
||||
element.variantName = null;
|
||||
await waitForEvent(element, 'variant-applied');
|
||||
expect(
|
||||
(primitiveNode!.mesh.material as MeshStandardMaterial).name)
|
||||
.equal('STEEL METALLIC');
|
||||
});
|
||||
|
||||
test('exports and re-imports the model with variants', async () => {
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
expect(element[$scene].currentGLTF!.userData.variants.length)
|
||||
.to.be.eq(2);
|
||||
|
||||
const gltfRoot = getGLTFRoot(element[$scene], true);
|
||||
expect(
|
||||
gltfRoot.children[0].children[0].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
expect(
|
||||
gltfRoot.children[0].children[1].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
expect(
|
||||
gltfRoot.children[0].children[2].userData.variantMaterials.size)
|
||||
.to.be.eq(2);
|
||||
});
|
||||
});
|
||||
|
||||
test.skip(
|
||||
'When loading a new JPEG texture from an ObjectURL, the GLB does not export PNG',
|
||||
async () => {
|
||||
element.src = CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
await rafPasses();
|
||||
|
||||
const url = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/DamagedHelmet/glTF/Default_albedo.jpg');
|
||||
const blob = await fetch(url).then(r => r.blob());
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const texture = await element.createTexture(objectUrl, 'image/jpeg');
|
||||
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture.setTexture(texture);
|
||||
|
||||
const exported = await element.exportScene({binary: true});
|
||||
expect(exported).to.be.not.undefined;
|
||||
// The JPEG is ~1 Mb and the equivalent PNG is about ~6 Mb, so this
|
||||
// just checks we saved an image and it wasn't too big.
|
||||
expect(exported.size).to.be.greaterThan(0.5e6);
|
||||
expect(exported.size).to.be.lessThan(1.5e6);
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a loaded scene graph', () => {
|
||||
let material: MeshStandardMaterial;
|
||||
|
||||
setup(async () => {
|
||||
element.src = ASTRONAUT_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
material =
|
||||
(element[$scene].model!.children[0].children[0] as Mesh).material as
|
||||
MeshStandardMaterial;
|
||||
});
|
||||
|
||||
test('allows the scene graph to be manipulated', async () => {
|
||||
await element.model!.materials[0].pbrMetallicRoughness.setBaseColorFactor(
|
||||
[1, 0, 0, 1]);
|
||||
|
||||
expect(material.color).to.include({r: 1, g: 0, b: 0});
|
||||
|
||||
const color =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(color).to.be.eql([1, 0, 0, 1]);
|
||||
});
|
||||
|
||||
suite('when the model changes', () => {
|
||||
test('updates when the model changes', async () => {
|
||||
const color =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(color).to.be.eql([0.5, 0.5, 0.5, 1]);
|
||||
|
||||
element.src = HORSE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const nextColor =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(nextColor).to.be.eql([1, 1, 1, 1]);
|
||||
});
|
||||
|
||||
test('allows the scene graph to be manipulated', async () => {
|
||||
element.src = HORSE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
await element.model!.materials[0]
|
||||
.pbrMetallicRoughness.setBaseColorFactor([1, 0, 0, 1]);
|
||||
|
||||
const color =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorFactor;
|
||||
|
||||
expect(color).to.be.eql([1, 0, 0, 1]);
|
||||
|
||||
const newMaterial =
|
||||
(element[$scene].model!.children[0] as Mesh).material as
|
||||
MeshStandardMaterial;
|
||||
|
||||
expect(newMaterial.color).to.include({r: 1, g: 0, b: 0});
|
||||
});
|
||||
});
|
||||
|
||||
suite('Scene-graph gltf-to-three mappings', () => {
|
||||
test('has a mapping for each primitive mesh', async () => {
|
||||
element.src = RIGGEDFIGURE_GLB_PATH;
|
||||
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
const gltf = (element as any)[$currentGLTF] as ModelViewerGLTFInstance;
|
||||
|
||||
for (const primitive of element.model![$primitivesList]) {
|
||||
expect(gltf.correlatedSceneGraph.threeObjectMap.get(primitive.mesh))
|
||||
.to.be.ok;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,274 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Material, MeshStandardMaterial, Texture as ThreeTexture} from 'three';
|
||||
|
||||
import {$threeTexture} from '../../../features/scene-graph/image.js';
|
||||
import {$lazyLoadGLTFInfo} from '../../../features/scene-graph/material.js';
|
||||
import {Model} from '../../../features/scene-graph/model.js';
|
||||
import {Texture} from '../../../features/scene-graph/texture.js';
|
||||
import {$correlatedObjects} from '../../../features/scene-graph/three-dom-element.js';
|
||||
import {ModelViewerElement} from '../../../model-viewer.js';
|
||||
import {waitForEvent} from '../../../utilities.js';
|
||||
import {assetPath} from '../../helpers.js';
|
||||
|
||||
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
|
||||
const HELMET_GLB_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/DamagedHelmet/glTF-Binary/DamagedHelmet.glb');
|
||||
const ALPHA_BLEND_MODE_TEST = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/AlphaBlendModeTest/glTF-Binary/AlphaBlendModeTest.glb');
|
||||
const REPLACEMENT_TEXTURE_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BoxTextured/glTF/CesiumLogoFlat.png');
|
||||
suite('scene-graph/material', () => {
|
||||
suite('Test Texture Slots', () => {
|
||||
let element: ModelViewerElement;
|
||||
let texture: Texture|null;
|
||||
let threeMaterials: Set<MeshStandardMaterial>;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = HELMET_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
texture = await element.createTexture(REPLACEMENT_TEXTURE_PATH);
|
||||
threeMaterials = element.model!.materials[0][$correlatedObjects] as
|
||||
Set<MeshStandardMaterial>;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
texture = null;
|
||||
});
|
||||
|
||||
test('Set a new base map', async () => {
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture].uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.map).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Set a new metallicRoughness map', async () => {
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.metallicRoughnessTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.metallicRoughnessTexture?.texture
|
||||
?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.metalnessMap).to.be.eq(threeTexture);
|
||||
expect(material.roughnessMap).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Set a new normal map', async () => {
|
||||
element.model!.materials[0].normalTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.normalTexture?.texture?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.normalMap).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Set a new occlusion map', async () => {
|
||||
element.model!.materials[0].occlusionTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.occlusionTexture?.texture?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.aoMap).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Set a new emissive map', async () => {
|
||||
element.model!.materials[0].emissiveTexture.setTexture(texture);
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.emissiveTexture?.texture?.source[$threeTexture]!;
|
||||
|
||||
for (const material of threeMaterials as Set<MeshStandardMaterial>) {
|
||||
expect(material.emissiveMap).to.be.eq(threeTexture);
|
||||
}
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Material properties', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
const loadModel = async (path: string) => {
|
||||
element.src = path;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
};
|
||||
|
||||
test('test alpha cutoff expect disabled by default', async () => {
|
||||
await loadModel(HELMET_GLB_PATH);
|
||||
expect((element.model!.materials[0]![$correlatedObjects]
|
||||
?.values()
|
||||
.next()
|
||||
.value as Material)
|
||||
.alphaTest)
|
||||
.to.be.equal(0);
|
||||
});
|
||||
|
||||
test('test alpha cutoff expect valid value as default', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
expect(element.model!.materials[2].getAlphaCutoff()).to.be.equal(0.25);
|
||||
});
|
||||
|
||||
test('test alpha cutoff test setting and getting', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
element.model!.materials[2].setAlphaCutoff(0.5);
|
||||
expect(element.model!.materials[2].getAlphaCutoff()).to.be.equal(0.5);
|
||||
});
|
||||
|
||||
test('test double sided expect default is false', async () => {
|
||||
await loadModel(HELMET_GLB_PATH);
|
||||
expect(element.model!.materials[0].getDoubleSided()).to.be.equal(false);
|
||||
});
|
||||
|
||||
test('test double sided expect default is true', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(true);
|
||||
});
|
||||
|
||||
test('test double sided setting and getting', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(true);
|
||||
|
||||
element.model!.materials[1].setDoubleSided(false);
|
||||
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(false);
|
||||
|
||||
element.model!.materials[1].setDoubleSided(true);
|
||||
expect(element.model!.materials[1].getDoubleSided()).to.be.equal(true);
|
||||
});
|
||||
|
||||
test('test alpha-mode, setting and getting', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
|
||||
element.model!.materials[0].setAlphaMode('BLEND');
|
||||
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('BLEND');
|
||||
element.model!.materials[0].setAlphaMode('MASK');
|
||||
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('MASK');
|
||||
element.model!.materials[0].setAlphaMode('OPAQUE');
|
||||
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('OPAQUE');
|
||||
});
|
||||
|
||||
test('test alpha-mode, expect default of opaque', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
|
||||
expect(element.model!.materials[0].getAlphaMode()).to.be.equal('OPAQUE');
|
||||
});
|
||||
|
||||
test('test alpha-mode, expect default of blend', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
|
||||
expect(element.model!.materials[1].getAlphaMode()).to.be.equal('BLEND');
|
||||
});
|
||||
|
||||
test('test alpha-mode, expect default of mask', async () => {
|
||||
await loadModel(ALPHA_BLEND_MODE_TEST);
|
||||
|
||||
expect(element.model!.materials[2].getAlphaMode()).to.be.equal('MASK');
|
||||
});
|
||||
});
|
||||
|
||||
suite('Material lazy loading', () => {
|
||||
let element: ModelViewerElement;
|
||||
let model: Model;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = CUBES_GLTF_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
model = element.model as Model;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('Accessing the name getter does not cause throw error.', async () => {
|
||||
expect(model.materials[2].name).to.equal('red');
|
||||
expect(model.materials[2][$lazyLoadGLTFInfo]).to.be.ok;
|
||||
});
|
||||
|
||||
test(
|
||||
'Accessing a getter of an unloaded material throws an error.',
|
||||
async () => {
|
||||
expect(() => {model.materials[2].pbrMetallicRoughness}).to.throw;
|
||||
expect(model.materials[2].isLoaded).to.be.false;
|
||||
});
|
||||
|
||||
test(
|
||||
'Accessing a getter of a loaded material has valid data.', async () => {
|
||||
await model.materials[2].ensureLoaded();
|
||||
expect(model.materials[2].isLoaded).to.be.true;
|
||||
expect(model.materials[2].name).to.equal('red');
|
||||
const pbr = model.materials[2].pbrMetallicRoughness;
|
||||
expect(pbr).to.be.ok;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,340 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {MeshStandardMaterial} from 'three/src/materials/MeshStandardMaterial.js';
|
||||
import {Mesh} from 'three/src/objects/Mesh.js';
|
||||
|
||||
import {$lazyLoadGLTFInfo} from '../../../features/scene-graph/material.js';
|
||||
import {$availableVariants, $materials, $primitivesList, $switchVariant, Model} from '../../../features/scene-graph/model.js';
|
||||
import {$correlatedObjects} from '../../../features/scene-graph/three-dom-element.js';
|
||||
import {$scene} from '../../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../../model-viewer.js';
|
||||
import {CorrelatedSceneGraph} from '../../../three-components/gltf-instance/correlated-scene-graph.js';
|
||||
import {waitForEvent} from '../../../utilities.js';
|
||||
import {assetPath, loadThreeGLTF, rafPasses} from '../../helpers.js';
|
||||
|
||||
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
const KHRONOS_TRIANGLE_GLB_PATH =
|
||||
assetPath('models/glTF-Sample-Assets/Models/Triangle/glTF/Triangle.gltf');
|
||||
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
|
||||
|
||||
suite('scene-graph/model', () => {
|
||||
suite('Model', () => {
|
||||
test('creates a "default" material, when none is specified', async () => {
|
||||
const threeGLTF = await loadThreeGLTF(KHRONOS_TRIANGLE_GLB_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
|
||||
expect(model.materials.length).to.be.eq(1);
|
||||
expect(model.materials[0].name).to.be.eq('Default');
|
||||
});
|
||||
|
||||
test.skip('exposes a list of materials in the scene', async () => {
|
||||
// TODO: This test is skipped because [$correlatedObjects] can contain
|
||||
// unused materials, because it can contain a base material and the
|
||||
// derived material (from assignFinalMaterial(), if for instance
|
||||
// vertexTangents are used) even if only the derived material is assigned
|
||||
// to a mesh. These extras make the test fail. We may want to remove these
|
||||
// unused materials from [$correlatedObjects] at which point this test
|
||||
// will pass, but it's not hurting anything.
|
||||
const threeGLTF = await loadThreeGLTF(ASTRONAUT_GLB_PATH);
|
||||
const materials: Set<MeshStandardMaterial> = new Set();
|
||||
|
||||
threeGLTF.scene.traverse((object) => {
|
||||
if ((object as Mesh).isMesh) {
|
||||
const material = (object as Mesh).material;
|
||||
if (Array.isArray(material)) {
|
||||
material.forEach(
|
||||
(material) => materials.add(material as MeshStandardMaterial));
|
||||
} else {
|
||||
materials.add(material as MeshStandardMaterial);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
|
||||
const collectedMaterials = new Set<MeshStandardMaterial>();
|
||||
|
||||
model.materials.forEach((material) => {
|
||||
for (const threeMaterial of material[$correlatedObjects] as
|
||||
Set<MeshStandardMaterial>) {
|
||||
collectedMaterials.add(threeMaterial);
|
||||
expect(materials.has(threeMaterial)).to.be.true;
|
||||
}
|
||||
});
|
||||
|
||||
expect(collectedMaterials.size).to.be.equal(materials.size);
|
||||
});
|
||||
|
||||
suite('Model Variants', () => {
|
||||
test('Switch variant and lazy load', async () => {
|
||||
const threeGLTF = await loadThreeGLTF(CUBES_GLTF_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
expect(model[$materials][2][$correlatedObjects]).to.be.empty;
|
||||
expect(model[$materials][2][$lazyLoadGLTFInfo]).to.be.ok;
|
||||
|
||||
await model[$switchVariant]('Yellow Red');
|
||||
|
||||
expect(model[$materials][2][$correlatedObjects]).to.not.be.empty;
|
||||
expect(model[$materials][2][$lazyLoadGLTFInfo]).to.not.be.ok;
|
||||
});
|
||||
|
||||
test(
|
||||
'Switch back to default variant does not change correlations',
|
||||
async () => {
|
||||
const threeGLTF = await loadThreeGLTF(CUBES_GLTF_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
|
||||
const sizeBeforeSwitch =
|
||||
model[$materials][0][$correlatedObjects]!.size;
|
||||
|
||||
await model[$switchVariant]('Yellow Yellow');
|
||||
// Switches back to default.
|
||||
await model[$switchVariant]('Purple Yellow');
|
||||
|
||||
expect(model[$materials][0][$correlatedObjects]!.size)
|
||||
.equals(sizeBeforeSwitch);
|
||||
});
|
||||
|
||||
test(
|
||||
'Switching variant when model has no variants has not effect',
|
||||
async () => {
|
||||
const threeGLTF = await loadThreeGLTF(KHRONOS_TRIANGLE_GLB_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
|
||||
const threeMaterial =
|
||||
model[$materials][0][$correlatedObjects]!.values().next().value;
|
||||
const sizeBeforeSwitch =
|
||||
model[$materials][0][$correlatedObjects]!.size;
|
||||
await model[$switchVariant]('Does not exist');
|
||||
|
||||
expect(
|
||||
model[$materials][0][$correlatedObjects]!.values().next().value)
|
||||
.equals(threeMaterial);
|
||||
expect(model[$materials][0][$correlatedObjects]!.size)
|
||||
.equals(sizeBeforeSwitch);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Model e2e test', () => {
|
||||
let element: ModelViewerElement;
|
||||
let model: Model;
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
const loadModel = async (src: string) => {
|
||||
element.src = src;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
model = element.model!;
|
||||
};
|
||||
|
||||
test('getMaterialByName returns material when name exists', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
const material = model.getMaterialByName('red')!;
|
||||
expect(material).to.be.ok;
|
||||
expect(material.name).to.be.equal('red');
|
||||
});
|
||||
|
||||
test(
|
||||
'getMaterialByName returns null when name does not exists',
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
const material = model.getMaterialByName('does-not-exist')!;
|
||||
expect(material).to.be.null;
|
||||
});
|
||||
|
||||
suite('Create Variant', () => {
|
||||
test(
|
||||
`createMaterialInstanceForVariant() adds new primitive variants mapping
|
||||
only to primitives that use the source material`,
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
const primitive1 = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box';
|
||||
})!;
|
||||
const primitive2 = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box_1';
|
||||
})!;
|
||||
|
||||
const startingSize = primitive1.variantInfo.size;
|
||||
const startingSize2 = primitive2.variantInfo.size;
|
||||
|
||||
// Creates a variant from material 0.
|
||||
expect(model.createMaterialInstanceForVariant(
|
||||
0, 'test-material', 'test-variant'))
|
||||
.to.be.ok;
|
||||
|
||||
// primitive1 uses material '0' so it should have a vew variant.
|
||||
expect(primitive1.variantInfo.size).to.equal(startingSize + 1);
|
||||
// primitive2 to should remain unchanged.
|
||||
expect(primitive2.variantInfo.size).to.equal(startingSize2);
|
||||
});
|
||||
|
||||
test('Create variant and switch to it', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
const primitive = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box';
|
||||
})!;
|
||||
|
||||
model.createMaterialInstanceForVariant(
|
||||
0, 'test-material', 'test-variant');
|
||||
|
||||
element.variantName = 'test-variant';
|
||||
|
||||
await element.updateComplete;
|
||||
expect(primitive.getActiveMaterial().name).to.equal('test-material');
|
||||
});
|
||||
|
||||
test('New variant is available to model-viewer', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
model.createMaterialInstanceForVariant(
|
||||
0, 'test-material', 'test-variant');
|
||||
|
||||
expect(element.availableVariants.find(variant => {
|
||||
return variant === 'test-variant';
|
||||
})).to.be.ok;
|
||||
});
|
||||
|
||||
test(
|
||||
`createMaterialInstanceForVariant() fails when there is a variant name
|
||||
collision`,
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
expect(model.createMaterialInstanceForVariant(
|
||||
0, 'test-material', 'Purple Yellow'))
|
||||
.to.be.null;
|
||||
});
|
||||
|
||||
test('createVariant() creates a variant', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
model.createVariant('test-variant');
|
||||
|
||||
expect(element.availableVariants.find(variant => {
|
||||
return variant === 'test-variant';
|
||||
})).to.be.ok;
|
||||
});
|
||||
|
||||
test('createVariant() is a noop if the variant exists', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
const length = model[$availableVariants]().length;
|
||||
model.createVariant('Purple Yellow');
|
||||
|
||||
expect(length).to.equal(model[$availableVariants]().length);
|
||||
});
|
||||
|
||||
test(
|
||||
`setMaterialToVariant() adds variants mapping
|
||||
only to primitives that use the source material`,
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
const primitive1 = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box';
|
||||
})!;
|
||||
const primitive2 = model[$primitivesList].find(prim => {
|
||||
return prim.mesh.name === 'Box_1';
|
||||
})!;
|
||||
|
||||
const startingSize = primitive1.variantInfo.size;
|
||||
const startingSize2 = primitive2.variantInfo.size;
|
||||
|
||||
model.createVariant('test-variant');
|
||||
|
||||
// Adds material 0 to the variant.
|
||||
model.setMaterialToVariant(0, 'test-variant');
|
||||
|
||||
// primitive1 uses material '0' so it should have a vew variant.
|
||||
expect(primitive1.variantInfo.size).to.equal(startingSize + 1);
|
||||
// primitive2 to should remain unchanged.
|
||||
expect(primitive2.variantInfo.size).to.equal(startingSize2);
|
||||
});
|
||||
|
||||
test('updateVariantName() updates the variant name', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
element.variantName = 'Yellow Red';
|
||||
await element.updateComplete;
|
||||
|
||||
element.model!.updateVariantName('Yellow Red', 'NewName');
|
||||
|
||||
expect(element.availableVariants[2]).equal('NewName');
|
||||
});
|
||||
|
||||
test(
|
||||
'deleteVariant() removes variant from primitives, materials and available variants.',
|
||||
async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
element.model!.deleteVariant('Yellow Red');
|
||||
|
||||
// Removed from the list of available variants.
|
||||
expect(element.availableVariants.length).equal(2);
|
||||
|
||||
// No longer present in primitives
|
||||
for (const primitive of model[$primitivesList]) {
|
||||
if (primitive.variantInfo.size > 0) {
|
||||
expect(primitive.variantInfo.has(2)).to.be.false;
|
||||
}
|
||||
}
|
||||
|
||||
// Materials do not reference the variant.
|
||||
for (const material of model.materials) {
|
||||
expect(material.hasVariant('Yellow Red')).to.be.false;
|
||||
}
|
||||
});
|
||||
|
||||
test('hasVariant() positive and negative test', async () => {
|
||||
await loadModel(CUBES_GLTF_PATH);
|
||||
|
||||
expect(model.hasVariant('Yellow Red')).to.be.true;
|
||||
expect(model.hasVariant('DoesNotExist')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
suite('Intersecting', () => {
|
||||
test('materialFromPoint returns material', async () => {
|
||||
await loadModel(ASTRONAUT_GLB_PATH);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
const material = element.materialFromPoint(
|
||||
element[$scene].width / 2, element[$scene].height / 2)!;
|
||||
|
||||
expect(material).to.be.ok;
|
||||
});
|
||||
|
||||
test(
|
||||
'materialFromPoint returns null when intersect fails', async () => {
|
||||
await loadModel(ASTRONAUT_GLB_PATH);
|
||||
|
||||
await rafPasses();
|
||||
|
||||
const material = element.materialFromPoint(
|
||||
element[$scene].width, element[$scene].height)!;
|
||||
expect(material).to.be.null;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {MeshStandardMaterial} from 'three/src/materials/MeshStandardMaterial.js';
|
||||
|
||||
import {$primitivesList, $variantData, Model} from '../../../../features/scene-graph/model.js';
|
||||
import {ModelViewerElement} from '../../../../model-viewer.js';
|
||||
import {CorrelatedSceneGraph} from '../../../../three-components/gltf-instance/correlated-scene-graph.js';
|
||||
import {waitForEvent} from '../../../../utilities.js';
|
||||
import {assetPath, loadThreeGLTF} from '../../../helpers.js';
|
||||
|
||||
const BRAIN_STEM_GLB_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BrainStem/glTF-Binary/BrainStem.glb');
|
||||
const CUBES_GLTF_PATH = assetPath('models/cubes.gltf');
|
||||
const CUBE_GLTF_PATH = assetPath('models/cube.gltf');
|
||||
const MESH_PRIMITIVES_GLB_PATH = assetPath('models/MeshPrimitivesVariants.glb');
|
||||
const KHRONOS_TRIANGLE_GLB_PATH =
|
||||
assetPath('models/glTF-Sample-Assets/Models/Triangle/glTF/Triangle.gltf');
|
||||
|
||||
const findPrimitivesWithVariant = (model: Model, variantName: string) => {
|
||||
const result = new Array<any>();
|
||||
for (const primitive of model![$primitivesList]) {
|
||||
if (primitive.variantInfo != null && model.hasVariant(variantName) &&
|
||||
primitive.variantInfo.has(
|
||||
model[$variantData].get(variantName)!.index)) {
|
||||
result.push(primitive);
|
||||
}
|
||||
}
|
||||
return result.length > 0 ? result : null;
|
||||
};
|
||||
|
||||
suite('scene-graph/model/mesh-primitives', () => {
|
||||
suite('Primitive with default material', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = KHRONOS_TRIANGLE_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('has a default material', async () => {
|
||||
const model = element.model!;
|
||||
expect(model[$primitivesList].length).to.equal(1);
|
||||
expect(model.materials.length).to.equal(1);
|
||||
expect(model[$primitivesList][0].initialMaterialIdx).to.equal(0);
|
||||
expect(model.materials[0].name).to.equal('Default');
|
||||
});
|
||||
});
|
||||
|
||||
suite('Static Primitive Without Variant', () => {
|
||||
let model: Model;
|
||||
setup(async () => {
|
||||
const threeGLTF = await loadThreeGLTF(CUBE_GLTF_PATH);
|
||||
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
});
|
||||
|
||||
test('Should not have any primitives with variant info', async () => {
|
||||
let hasVariantInfoCount = 0;
|
||||
for (const primitive of model![$primitivesList]) {
|
||||
if (primitive.variantInfo.size > 0) {
|
||||
hasVariantInfoCount++;
|
||||
}
|
||||
}
|
||||
expect(hasVariantInfoCount).equals(0);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Static Primitive With Variant', () => {
|
||||
let model: Model;
|
||||
setup(async () => {
|
||||
const threeGLTF = await loadThreeGLTF(CUBES_GLTF_PATH);
|
||||
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
});
|
||||
|
||||
test('Primitive count matches glTF file', async () => {
|
||||
expect(model![$primitivesList].length).to.equal(2);
|
||||
});
|
||||
|
||||
test('Primitives should have expected variant names', async () => {
|
||||
expect(findPrimitivesWithVariant(model, 'Purple Yellow')).to.not.be.null;
|
||||
expect(findPrimitivesWithVariant(model, 'Yellow Yellow')).to.not.be.null;
|
||||
expect(findPrimitivesWithVariant(model, 'Yellow Red')).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Should not have any primitives without variant info', async () => {
|
||||
let hasNoVariantInfoCount = 0;
|
||||
for (const primitive of model![$primitivesList]) {
|
||||
if (primitive.variantInfo == null) {
|
||||
hasNoVariantInfoCount++;
|
||||
}
|
||||
}
|
||||
expect(hasNoVariantInfoCount).equals(0);
|
||||
});
|
||||
|
||||
test('Switching to incorrect variant name', async () => {
|
||||
const primitive = findPrimitivesWithVariant(model, 'Purple Yellow')![0];
|
||||
const material = await primitive.enableVariant('Does not exist');
|
||||
expect(material).to.be.null;
|
||||
});
|
||||
|
||||
test('Switching to current variant', async () => {
|
||||
const primitives = findPrimitivesWithVariant(model, 'Purple Yellow')!;
|
||||
const materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Yellow Yellow') as
|
||||
MeshStandardMaterial);
|
||||
}
|
||||
expect(materials).to.not.be.empty;
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === 'yellow';
|
||||
})).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Switching to variant and then switch back', async () => {
|
||||
const primitives = findPrimitivesWithVariant(model, 'Purple Yellow')!;
|
||||
let materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Yellow Yellow') as
|
||||
MeshStandardMaterial);
|
||||
}
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === 'yellow';
|
||||
})).to.not.be.null;
|
||||
|
||||
materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Purple Yellow') as
|
||||
MeshStandardMaterial);
|
||||
}
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === 'purple';
|
||||
})).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Primitive switches to initial material', async () => {
|
||||
const primitive = findPrimitivesWithVariant(model, 'Purple Yellow')![0];
|
||||
|
||||
// Gets current material.
|
||||
const initialMaterial = await primitive.enableVariant('Purple Yellow');
|
||||
// Switches to variant.
|
||||
const variantMaterial = await primitive.enableVariant('Yellow Red');
|
||||
expect(initialMaterial).to.not.equal(variantMaterial)
|
||||
// Switches to initial material.
|
||||
const resetMaterial = await primitive.enableVariant(null);
|
||||
expect(resetMaterial).to.equal(initialMaterial);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Mesh with multiple primitives each with variants', () => {
|
||||
let model: Model;
|
||||
setup(async () => {
|
||||
const threeGLTF = await loadThreeGLTF(MESH_PRIMITIVES_GLB_PATH);
|
||||
model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
});
|
||||
|
||||
test('Primitive count matches glTF file', async () => {
|
||||
expect(model![$primitivesList].length).to.equal(3);
|
||||
});
|
||||
|
||||
test('Primitives should have expected variant names', async () => {
|
||||
expect(findPrimitivesWithVariant(model, 'Normal')).to.not.be.null;
|
||||
expect(findPrimitivesWithVariant(model, 'Inverse')).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Switching to incorrect variant name', async () => {
|
||||
const primitive = findPrimitivesWithVariant(model, 'Normal')![0];
|
||||
const material = await primitive.enableVariant('Does not exist');
|
||||
expect(material).to.be.null;
|
||||
});
|
||||
|
||||
test('Switching to variant and then switch back', async () => {
|
||||
const MATERIAL_NAME = 'STEEL BLACK';
|
||||
const primitives = findPrimitivesWithVariant(model, 'Normal')!;
|
||||
let materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Inverse') as MeshStandardMaterial);
|
||||
}
|
||||
|
||||
expect(materials).to.not.be.empty;
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === MATERIAL_NAME;
|
||||
})).to.be.undefined;
|
||||
|
||||
materials = new Array<MeshStandardMaterial>();
|
||||
for (const primitive of primitives) {
|
||||
materials.push(
|
||||
await primitive.enableVariant('Normal') as MeshStandardMaterial);
|
||||
}
|
||||
|
||||
expect(materials.find((material: MeshStandardMaterial) => {
|
||||
return material.name === MATERIAL_NAME;
|
||||
})).to.be.ok;
|
||||
});
|
||||
|
||||
test('Primitive switches to initial material', async () => {
|
||||
const primitive = findPrimitivesWithVariant(model, 'Normal')![0];
|
||||
|
||||
// Gets current material.
|
||||
const initialMaterial = await primitive.enableVariant('Normal');
|
||||
// Switches to variant.
|
||||
const variantMaterial = await primitive.enableVariant('Inverse');
|
||||
expect(initialMaterial).to.not.equal(variantMaterial)
|
||||
// Switches to initial material.
|
||||
const resetMaterial = await primitive.enableVariant(null);
|
||||
expect(resetMaterial).to.equal(initialMaterial);
|
||||
});
|
||||
});
|
||||
|
||||
suite('Skinned Primitive Without Variant', () => {
|
||||
test('Primitive count matches glTF file', async () => {
|
||||
const threeGLTF = await loadThreeGLTF(BRAIN_STEM_GLB_PATH);
|
||||
const model = new Model(CorrelatedSceneGraph.from(threeGLTF));
|
||||
expect(model![$primitivesList].length).to.equal(59);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {TextureInfo} from '../../../features/scene-graph/texture-info.js';
|
||||
import {ModelViewerElement} from '../../../model-viewer.js';
|
||||
import {waitForEvent} from '../../../utilities.js';
|
||||
import {assetPath} from '../../helpers.js';
|
||||
|
||||
const DUCK_GLB_PATH =
|
||||
assetPath('models/glTF-Sample-Assets/Models/Duck/glTF-Binary/Duck.glb');
|
||||
const TEXTURED_CUBE_GLB_PATH = assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BoxTextured/glTF-Binary/BoxTextured.glb');
|
||||
|
||||
suite('scene-graph/texture-info', () => {
|
||||
suite('texture-info', () => {
|
||||
let element: ModelViewerElement;
|
||||
let emptyTextureInfo: TextureInfo;
|
||||
let baseTextureInfo: TextureInfo;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = DUCK_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
emptyTextureInfo = element.model!.materials[0].normalTexture;
|
||||
baseTextureInfo =
|
||||
element.model!.materials[0].pbrMetallicRoughness.baseColorTexture;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('empty slot is null', async () => {
|
||||
// The duck doesn't have a normal texture.
|
||||
expect(emptyTextureInfo.texture).to.be.null;
|
||||
});
|
||||
|
||||
test('non-empty slot is not null', async () => {
|
||||
// The duck doesn't have a normal texture.
|
||||
expect(baseTextureInfo.texture).to.not.be.null;
|
||||
});
|
||||
|
||||
test('call setTexture', async () => {
|
||||
const texture = await element.createTexture(assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BoxTextured/glTF/CesiumLogoFlat.png'));
|
||||
|
||||
// Setting a texture, the normal texture should _not_ be null.
|
||||
emptyTextureInfo.setTexture(texture);
|
||||
expect(emptyTextureInfo.texture).to.not.be.null;
|
||||
|
||||
// Clearing a texture, the normal texture _should_ be null again.
|
||||
emptyTextureInfo.setTexture(null);
|
||||
expect(emptyTextureInfo.texture).to.be.null;
|
||||
});
|
||||
|
||||
test(
|
||||
'exports and re-imports the model with transformed texture',
|
||||
async () => {
|
||||
// Load textured glb.
|
||||
element.src = TEXTURED_CUBE_GLB_PATH;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
// Transform the textures.
|
||||
const sampler = element.model?.materials[0]
|
||||
.pbrMetallicRoughness['baseColorTexture']
|
||||
.texture?.sampler!;
|
||||
sampler.setRotation(0.1);
|
||||
sampler.setOffset({u: 0.2, v: 0.3});
|
||||
sampler.setScale({u: 0.4, v: 0.5});
|
||||
|
||||
// Export model.
|
||||
const exported = await element.exportScene({binary: true});
|
||||
const url = URL.createObjectURL(exported);
|
||||
|
||||
// Re-load model.
|
||||
element.src = url;
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
const exported_sampler = element.model?.materials[0]
|
||||
.pbrMetallicRoughness['baseColorTexture']
|
||||
.texture?.sampler!;
|
||||
expect(exported_sampler.rotation).to.be.eq(0.1, 'rotation');
|
||||
expect(exported_sampler.offset!.u).to.be.eql(0.2, 'offset_u');
|
||||
expect(exported_sampler.offset!.v).to.be.eql(0.3, 'offset_v');
|
||||
expect(exported_sampler.scale!.u).to.be.eql(0.4, 'scale_u');
|
||||
expect(exported_sampler.scale!.v).to.be.eql(0.5, 'scale_v');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Texture as ThreeTexture} from 'three';
|
||||
|
||||
import {$threeTexture} from '../../../features/scene-graph/image.js';
|
||||
import {Texture} from '../../../features/scene-graph/texture.js';
|
||||
import {$correlatedObjects} from '../../../features/scene-graph/three-dom-element.js';
|
||||
import {ModelViewerElement} from '../../../model-viewer.js';
|
||||
import {waitForEvent} from '../../../utilities.js';
|
||||
import {assetPath} from '../../helpers.js';
|
||||
|
||||
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
suite('scene-graph/texture', () => {
|
||||
suite('Texture', () => {
|
||||
let element: ModelViewerElement;
|
||||
let texture: Texture|null;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = ASTRONAUT_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
texture = await element.createTexture(assetPath(
|
||||
'models/glTF-Sample-Assets/Models/BoxTextured/glTF/CesiumLogoFlat.png'));
|
||||
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture!.setTexture(texture);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
texture = null;
|
||||
});
|
||||
|
||||
|
||||
test('Create a texture', async () => {
|
||||
expect(texture).to.not.be.null;
|
||||
});
|
||||
|
||||
test('Set a texture', async () => {
|
||||
// Gets new UUID to compare with UUID of texture accessible through the
|
||||
// material.
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const threeTexture: ThreeTexture =
|
||||
element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.source[$threeTexture]!;
|
||||
|
||||
expect(threeTexture.uuid).to.be.equal(newUUID);
|
||||
});
|
||||
|
||||
test('Verify legacy correlatedObjects are updated.', async () => {
|
||||
const newUUID: string|undefined = texture?.source[$threeTexture]?.uuid;
|
||||
|
||||
const uuidFromTextureObject: string =
|
||||
(element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.[$correlatedObjects]
|
||||
?.values()
|
||||
.next()
|
||||
.value as ThreeTexture)
|
||||
.uuid;
|
||||
expect(uuidFromTextureObject).to.be.equal(newUUID);
|
||||
|
||||
const uuidFromSamplerObject: string =
|
||||
(element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.sampler[$correlatedObjects]
|
||||
?.values()
|
||||
.next()
|
||||
.value as ThreeTexture)
|
||||
.uuid;
|
||||
expect(uuidFromSamplerObject).to.be.equal(newUUID);
|
||||
|
||||
const uuidFromImageObject: string =
|
||||
(element.model!.materials[0]
|
||||
.pbrMetallicRoughness.baseColorTexture?.texture
|
||||
?.source[$correlatedObjects]
|
||||
?.values()
|
||||
.next()
|
||||
.value as ThreeTexture)
|
||||
.uuid;
|
||||
expect(uuidFromImageObject).to.be.equal(newUUID);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {CameraChangeDetails} from '../../features/controls.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ChangeSource} from '../../three-components/SmoothControls.js';
|
||||
import {timePasses, waitForEvent} from '../../utilities.js';
|
||||
import {assetPath, rafPasses} from '../helpers.js';
|
||||
|
||||
const ODD_SHAPE_GLB_PATH = assetPath('models/odd-shape.glb');
|
||||
const AUTO_ROTATE_DELAY = 50;
|
||||
|
||||
suite('Staging', () => {
|
||||
suite('with a visible loaded model', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.src = ODD_SHAPE_GLB_PATH;
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
|
||||
await waitForEvent(element, 'poster-dismissed');
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
test('can manually rotate turntable', () => {
|
||||
element.resetTurntableRotation(3);
|
||||
expect(element.turntableRotation).to.be.equal(3);
|
||||
|
||||
element.resetTurntableRotation();
|
||||
expect(element.turntableRotation).to.be.equal(0);
|
||||
});
|
||||
|
||||
suite('auto-rotate', () => {
|
||||
setup(async () => {
|
||||
element.autoRotate = true;
|
||||
element.autoRotateDelay = AUTO_ROTATE_DELAY;
|
||||
await timePasses();
|
||||
});
|
||||
|
||||
test.skip('causes the model to rotate after a delay', async () => {
|
||||
const {turntableRotation} = element;
|
||||
await rafPasses();
|
||||
expect(element.turntableRotation).to.be.equal(turntableRotation);
|
||||
await timePasses(AUTO_ROTATE_DELAY);
|
||||
await rafPasses();
|
||||
expect(element.turntableRotation).to.be.greaterThan(turntableRotation);
|
||||
});
|
||||
|
||||
test(
|
||||
'retains turntable rotation when auto-rotate is toggled',
|
||||
async () => {
|
||||
element.autoRotateDelay = 0;
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
const {turntableRotation} = element;
|
||||
|
||||
expect(turntableRotation).to.be.greaterThan(0);
|
||||
|
||||
element.autoRotate = false;
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation).to.be.equal(turntableRotation);
|
||||
|
||||
element.autoRotate = true;
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation)
|
||||
.to.be.greaterThan(turntableRotation);
|
||||
});
|
||||
|
||||
test.skip('pauses rotate after user interaction', async () => {
|
||||
const {turntableRotation} = element;
|
||||
await timePasses(AUTO_ROTATE_DELAY);
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
const {turntableRotation: initialTurntableRotation} = element;
|
||||
expect(initialTurntableRotation).to.be.greaterThan(turntableRotation);
|
||||
|
||||
element.dispatchEvent(new CustomEvent<CameraChangeDetails>(
|
||||
'camera-change',
|
||||
{detail: {source: ChangeSource.USER_INTERACTION}}));
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation).to.be.equal(initialTurntableRotation);
|
||||
|
||||
await timePasses(AUTO_ROTATE_DELAY);
|
||||
await rafPasses();
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation)
|
||||
.to.be.greaterThan(initialTurntableRotation);
|
||||
});
|
||||
|
||||
suite('when the model is not visible', () => {
|
||||
setup(() => {
|
||||
Object.defineProperty(element, 'modelIsVisible', {value: false});
|
||||
});
|
||||
|
||||
test('does not cause the model to rotate over time', async () => {
|
||||
const {turntableRotation} = element;
|
||||
|
||||
await timePasses(AUTO_ROTATE_DELAY);
|
||||
await rafPasses();
|
||||
|
||||
expect(element.turntableRotation).to.be.equal(turntableRotation);
|
||||
});
|
||||
});
|
||||
|
||||
suite('with zero auto-rotate-delay', () => {
|
||||
setup(async () => {
|
||||
element.autoRotateDelay = 0;
|
||||
await timePasses();
|
||||
await rafPasses();
|
||||
});
|
||||
|
||||
test('causes the model to rotate ASAP', async () => {
|
||||
const {turntableRotation} = element;
|
||||
await rafPasses();
|
||||
expect(element.turntableRotation)
|
||||
.to.be.greaterThan(turntableRotation);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {Group} from 'three';
|
||||
import {GLTF as ThreeGLTF, GLTFLoader, GLTFParser} from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
import {ExpressionNode, ExpressionTerm, FunctionNode, HexNode, IdentNode, Operator, OperatorNode} from '../styles/parsers.js';
|
||||
import {deserializeUrl, PredicateFunction, timePasses} from '../utilities.js';
|
||||
|
||||
export const elementFromLocalPoint =
|
||||
(document: Document|ShadowRoot, x: number, y: number): Element|null => {
|
||||
const host: HTMLElement = (document === window.document) ?
|
||||
window.document.body :
|
||||
(document as ShadowRoot).host as HTMLElement;
|
||||
const actualDocument =
|
||||
(window as any).ShadyCSS ? window.document : document;
|
||||
const boundingRect = host.getBoundingClientRect();
|
||||
|
||||
return actualDocument.elementFromPoint(
|
||||
boundingRect.left + x, boundingRect.top + y);
|
||||
};
|
||||
|
||||
export const pickShadowDescendant =
|
||||
(element: Element, x: number = 0, y: number = 0): Element|null => {
|
||||
return element.shadowRoot != null ?
|
||||
elementFromLocalPoint(element.shadowRoot, x, y) :
|
||||
null;
|
||||
};
|
||||
|
||||
export const until =
|
||||
async (predicate: PredicateFunction) => {
|
||||
while (!predicate()) {
|
||||
await timePasses();
|
||||
}
|
||||
}
|
||||
|
||||
export const rafPasses = (): Promise<void> =>
|
||||
new Promise(resolve => requestAnimationFrame(() => resolve()));
|
||||
|
||||
export interface SyntheticEventProperties {
|
||||
clientX?: number;
|
||||
clientY?: number;
|
||||
deltaY?: number;
|
||||
key?: string;
|
||||
shiftKey?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a synthetic event on a given element with a given type, and
|
||||
* optionally with custom event properties. Returns the dispatched event.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @param {type} string
|
||||
* @param {*} properties
|
||||
*/
|
||||
export const dispatchSyntheticEvent =
|
||||
(target: EventTarget, type: string, properties: SyntheticEventProperties = {
|
||||
clientX: 0,
|
||||
clientY: 0,
|
||||
deltaY: 1.0
|
||||
}): CustomEvent => {
|
||||
const event = new CustomEvent(type, {cancelable: true, bubbles: true});
|
||||
Object.assign(event, properties);
|
||||
target.dispatchEvent(event);
|
||||
return event;
|
||||
};
|
||||
|
||||
|
||||
export const ASSETS_DIRECTORY = 'packages/shared-assets/';
|
||||
|
||||
/**
|
||||
* Returns the full path for an asset by name. This is a convenience helper so
|
||||
* that we don't need to change paths throughout all test suites if we ever
|
||||
* decide to move files around.
|
||||
*
|
||||
* @param {string} name
|
||||
* @return {string}
|
||||
*/
|
||||
export const assetPath = (name: string): string =>
|
||||
deserializeUrl(`${ASSETS_DIRECTORY}${name}`)!;
|
||||
|
||||
/**
|
||||
* Returns true if the given element is in the tree of the document of the
|
||||
* current frame.
|
||||
*
|
||||
* @param {HTMLElement} element
|
||||
* @return {boolean}
|
||||
*/
|
||||
export const isInDocumentTree = (node: Node): boolean => {
|
||||
let root: Node = node.getRootNode();
|
||||
|
||||
while (root !== node && root != null) {
|
||||
if (root.nodeType === Node.DOCUMENT_NODE) {
|
||||
return root === document;
|
||||
}
|
||||
|
||||
root = (root as ShadowRoot).host && (root as ShadowRoot).host.getRootNode();
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* "Spies" on a particular object by replacing a specified part of its
|
||||
* implementation with a custom version. Returns a function that restores the
|
||||
* original implementation to the object when invoked.
|
||||
*/
|
||||
export const spy =
|
||||
(object: Object,
|
||||
property: string,
|
||||
descriptor: PropertyDescriptor): () => void => {
|
||||
let sourcePrototype = object;
|
||||
|
||||
while (sourcePrototype != null &&
|
||||
!sourcePrototype.hasOwnProperty(property)) {
|
||||
sourcePrototype = (sourcePrototype as any).__proto__;
|
||||
}
|
||||
|
||||
if (sourcePrototype == null) {
|
||||
throw new Error(`Cannot spy property "${property}" on ${object}`);
|
||||
}
|
||||
|
||||
const originalDescriptor =
|
||||
Object.getOwnPropertyDescriptor(sourcePrototype, property);
|
||||
|
||||
if (originalDescriptor == null) {
|
||||
throw new Error(`Cannot read descriptor of "${property}" on ${object}`);
|
||||
}
|
||||
|
||||
Object.defineProperty(sourcePrototype, property, descriptor);
|
||||
|
||||
return () => {
|
||||
Object.defineProperty(sourcePrototype, property, originalDescriptor);
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
/**
|
||||
* Helpers to assist in generating AST test fixtures
|
||||
*/
|
||||
export const expressionNode = (terms: Array<ExpressionTerm>): ExpressionNode =>
|
||||
({type: 'expression', terms});
|
||||
|
||||
export const hexNode = (value: string): HexNode => ({type: 'hex', value});
|
||||
|
||||
export const identNode = (value: string): IdentNode => ({type: 'ident', value});
|
||||
|
||||
export const operatorNode = (value: Operator): OperatorNode =>
|
||||
({type: 'operator', value});
|
||||
|
||||
export const functionNode =
|
||||
(name: string, args: Array<ExpressionNode>): FunctionNode =>
|
||||
({type: 'function', name: identNode(name), arguments: args});
|
||||
|
||||
export const loadThreeGLTF = (url: string): Promise<ThreeGLTF> => {
|
||||
const loader = new GLTFLoader();
|
||||
return new Promise<ThreeGLTF>((resolve, reject) => {
|
||||
loader.load(url, resolve, undefined, reject);
|
||||
});
|
||||
};
|
||||
|
||||
export const createFakeThreeGLTF = () => {
|
||||
const scene = new Group();
|
||||
|
||||
return {
|
||||
animations: [],
|
||||
scene,
|
||||
scenes: [scene],
|
||||
cameras: [],
|
||||
asset: {},
|
||||
parser: {
|
||||
cache: new Map(),
|
||||
json: {scene: 0, scenes: [{}], materials: [], nodes: []},
|
||||
associations: new Map()
|
||||
} as unknown as GLTFParser,
|
||||
userData: {}
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,244 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {$renderer, $scene, $userInputElement} from '../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../model-viewer.js';
|
||||
import {Renderer} from '../three-components/Renderer.js';
|
||||
import {timePasses, waitForEvent} from '../utilities.js';
|
||||
|
||||
import {assetPath, until} from './helpers.js';
|
||||
|
||||
const expectBlobDimensions =
|
||||
async (blob: Blob, width: number, height: number) => {
|
||||
const img = await new Promise<HTMLImageElement>((resolve) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
resolve(img);
|
||||
};
|
||||
img.src = url;
|
||||
});
|
||||
|
||||
expect(img.width).to.be.equal(Math.round(width));
|
||||
expect(img.height).to.be.equal(Math.round(height));
|
||||
};
|
||||
|
||||
suite('ModelViewerElementBase', () => {
|
||||
suite('with an element', () => {
|
||||
let element: ModelViewerElement;
|
||||
let input: HTMLDivElement;
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
input = element[$userInputElement];
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
suite('with alt text', () => {
|
||||
test('gives the input a related aria-label', async () => {
|
||||
const altText = 'foo';
|
||||
element.alt = altText;
|
||||
await timePasses();
|
||||
expect(input.getAttribute('aria-label')).to.be.equal(altText);
|
||||
});
|
||||
|
||||
suite('that is removed', () => {
|
||||
test('reverts input to default aria-label', async () => {
|
||||
const defaultAriaLabel = input.getAttribute('aria-label');
|
||||
const altText = 'foo';
|
||||
|
||||
element.alt = altText;
|
||||
await timePasses();
|
||||
element.alt = null;
|
||||
await timePasses();
|
||||
|
||||
expect(input.getAttribute('aria-label'))
|
||||
.to.be.equal(defaultAriaLabel);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('with a valid src', () => {
|
||||
test('eventually dispatches a load event', async () => {
|
||||
const sourceLoads = waitForEvent(element, 'load');
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
await sourceLoads;
|
||||
});
|
||||
|
||||
suite('that changes before the model loads', () => {
|
||||
test('it loads the second value on microtask timing', async () => {
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
await timePasses();
|
||||
element.src = assetPath('models/Horse.glb');
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
expect(element[$scene].url)
|
||||
.to.be.equal(assetPath('models/Horse.glb'));
|
||||
});
|
||||
|
||||
test('it loads the second value on task timing', async () => {
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
await timePasses(1);
|
||||
element.src = assetPath('models/Horse.glb');
|
||||
await waitForEvent(element, 'load');
|
||||
|
||||
expect(element[$scene].url)
|
||||
.to.be.equal(assetPath('models/Horse.glb'));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('with an invalid src, dispatches an error event', async () => {
|
||||
const sourceErrors = waitForEvent(element, 'error');
|
||||
element.src = './does-not-exist.glb';
|
||||
const event = await sourceErrors;
|
||||
|
||||
expect((event as any).detail.type).to.be.eq('loadfailure');
|
||||
});
|
||||
|
||||
test('when losing the GL context, dispatches an error event', async () => {
|
||||
const {threeRenderer, canvas3D} = Renderer.singleton;
|
||||
|
||||
canvas3D.addEventListener('webglcontextlost', function(event) {
|
||||
event.preventDefault();
|
||||
Renderer.resetSingleton();
|
||||
}, false);
|
||||
|
||||
const errorEventDispatches = waitForEvent(element, 'error');
|
||||
// We make a best effort to simulate the real scenario here, but
|
||||
// for some cases like headless Chrome WebGL might be disabled,
|
||||
// so we simulate the scenario.
|
||||
// @see https://threejs.org/docs/index.html#api/en/renderers/WebGLRenderer.forceContextLoss
|
||||
if (threeRenderer.getContext() != null) {
|
||||
threeRenderer.forceContextLoss();
|
||||
} else {
|
||||
threeRenderer.domElement.dispatchEvent(
|
||||
new CustomEvent('webglcontextlost'));
|
||||
}
|
||||
const event = await errorEventDispatches;
|
||||
expect((event as any).detail.type).to.be.equal('webglcontextlost');
|
||||
});
|
||||
|
||||
suite('capturing screenshots', () => {
|
||||
let width: number;
|
||||
let height: number;
|
||||
setup(async () => {
|
||||
// Avoid testing our memory ceiling in CI by limiting the size
|
||||
// of the screenshots we produce in these tests:
|
||||
width = 32;
|
||||
height = 64;
|
||||
element.style.width = `${width}px`;
|
||||
element.style.height = `${height}px`;
|
||||
|
||||
const modelLoads = waitForEvent(element, 'load');
|
||||
element.src = assetPath('models/cube.gltf');
|
||||
await modelLoads;
|
||||
});
|
||||
|
||||
suite('toDataURL', () => {
|
||||
test('produces a URL-compatible string', () => {
|
||||
const dataUrlMatcher = /^data\:image\//;
|
||||
expect(dataUrlMatcher.test(element.toDataURL())).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
suite('toBlob', () => {
|
||||
test('produces a blob', async () => {
|
||||
const blob = await element.toBlob();
|
||||
expect(blob).to.not.be.null;
|
||||
});
|
||||
|
||||
test('can convert blob to object URL', async () => {
|
||||
const blob = await element.toBlob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
const objectUrlMatcher = /^blob\:/;
|
||||
expect(objectUrlMatcher.test(objectUrl)).to.be.true;
|
||||
});
|
||||
|
||||
test('has size', async () => {
|
||||
const blob = await element.toBlob();
|
||||
expect(blob.size).to.be.greaterThan(0);
|
||||
});
|
||||
|
||||
test.skip('idealAspect gives the proper blob dimensions', async () => {
|
||||
const basicBlob = await element.toBlob();
|
||||
const idealBlob = await element.toBlob({idealAspect: true});
|
||||
const idealHeight = 32 / element[$scene].idealAspect;
|
||||
|
||||
const {dpr, scaleFactor} = element[$renderer];
|
||||
const f = dpr * scaleFactor;
|
||||
await expectBlobDimensions(basicBlob, width * f, height * f);
|
||||
await expectBlobDimensions(idealBlob, width * f, idealHeight * f);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('orchestrates rendering', () => {
|
||||
let elements: Array<ModelViewerElement> = [];
|
||||
|
||||
setup(async () => {
|
||||
elements.push(new ModelViewerElement());
|
||||
elements.push(new ModelViewerElement());
|
||||
elements.push(new ModelViewerElement());
|
||||
|
||||
for (let element of elements) {
|
||||
element.style.position = 'relative';
|
||||
element.style.marginBottom = '100vh';
|
||||
element.src = assetPath('models/cube.gltf');
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
}
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
elements.forEach(
|
||||
element => element.parentNode != null &&
|
||||
element.parentNode.removeChild(element));
|
||||
});
|
||||
|
||||
test('sets a model within viewport to be visible', async () => {
|
||||
await until(() => {
|
||||
return elements[2].modelIsVisible;
|
||||
});
|
||||
|
||||
expect(elements[2].modelIsVisible).to.be.true;
|
||||
});
|
||||
|
||||
test.skip('only models visible in the viewport', async () => {
|
||||
// IntersectionObserver needs to set appropriate
|
||||
// visibility on the scene, lots of timing issues when
|
||||
// running -- wait for the visibility flags to be flipped
|
||||
await until(() => {
|
||||
return elements
|
||||
.map((element, index) => {
|
||||
return (index === 0) === element.modelIsVisible;
|
||||
})
|
||||
.reduce(((l, r) => l && r), true);
|
||||
});
|
||||
|
||||
expect(elements[0].modelIsVisible).to.be.ok;
|
||||
expect(elements[1].modelIsVisible).to.not.be.ok;
|
||||
expect(elements[2].modelIsVisible).to.not.be.ok;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {$renderer} from '../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../model-viewer.js';
|
||||
import {Constructor, waitForEvent} from '../utilities.js';
|
||||
|
||||
import {assetPath, rafPasses} from './helpers.js';
|
||||
import {BasicSpecTemplate} from './templates.js';
|
||||
|
||||
const SUNRISE_HDR_PATH = 'environments/spruit_sunrise_1k_HDR.hdr';
|
||||
const SUNRISE_LDR_PATH = 'environments/spruit_sunrise_1k_LDR.jpg';
|
||||
|
||||
const COMPONENTS_PER_PIXEL = 4;
|
||||
|
||||
const setupLighting =
|
||||
async (modelViewer: ModelViewerElement, lighting?: string) => {
|
||||
const posterDismissed = waitForEvent(modelViewer, 'poster-dismissed');
|
||||
|
||||
if (lighting) {
|
||||
const lightingPath = assetPath(lighting);
|
||||
modelViewer.environmentImage = lightingPath;
|
||||
}
|
||||
modelViewer.src = assetPath('models/reflective-sphere.gltf');
|
||||
|
||||
await posterDismissed;
|
||||
await rafPasses();
|
||||
}
|
||||
|
||||
// TODO(sun765): this only test whether the screenshot
|
||||
// is colorless or not. Replace this with more robust
|
||||
// test in later pr.
|
||||
function testFidelity(screenshotContext: WebGLRenderingContext|
|
||||
WebGL2RenderingContext) {
|
||||
const width = screenshotContext.drawingBufferWidth;
|
||||
const height = screenshotContext.drawingBufferHeight;
|
||||
|
||||
const pixels = new Uint8Array(width * height * COMPONENTS_PER_PIXEL);
|
||||
// this function reads in the bottom-up direction from the coordinate
|
||||
// specified ((0,0) is the bottom-left corner).
|
||||
screenshotContext.readPixels(
|
||||
0,
|
||||
0,
|
||||
width,
|
||||
height,
|
||||
screenshotContext.RGBA,
|
||||
screenshotContext.UNSIGNED_BYTE,
|
||||
pixels);
|
||||
|
||||
let transparentPixels = 0;
|
||||
let whitePixels = 0;
|
||||
let blackPixels = 0;
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let col = 0; col < width; col++) {
|
||||
let isWhite = true;
|
||||
let isBlack = true;
|
||||
|
||||
// read pixel data from top left corner
|
||||
const index = (height - row - 1) * width + col;
|
||||
const position = index * COMPONENTS_PER_PIXEL;
|
||||
|
||||
if (pixels[position + 3] != 255) {
|
||||
transparentPixels++;
|
||||
continue;
|
||||
}
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const colorComponent = pixels[position + i];
|
||||
if (colorComponent != 255) {
|
||||
isWhite = false;
|
||||
}
|
||||
if (colorComponent != 0) {
|
||||
isBlack = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isWhite) {
|
||||
whitePixels++;
|
||||
}
|
||||
if (isBlack) {
|
||||
blackPixels++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const imagePixelCount = width * height;
|
||||
expect(whitePixels + blackPixels + transparentPixels)
|
||||
.to.be.below(
|
||||
imagePixelCount,
|
||||
`Image had ${whitePixels} white pixels and ${
|
||||
blackPixels} black pixels and ${
|
||||
transparentPixels} background pixels.`);
|
||||
};
|
||||
|
||||
suite('ModelViewerElement', () => {
|
||||
let nextId: number = 0;
|
||||
let tagName: string;
|
||||
let ModelViewer: Constructor<ModelViewerElement>;
|
||||
|
||||
setup(() => {
|
||||
tagName = `model-viewer-${nextId++}`;
|
||||
ModelViewer = class extends ModelViewerElement {
|
||||
static get is() {
|
||||
return tagName;
|
||||
}
|
||||
}
|
||||
customElements.define(tagName, ModelViewer);
|
||||
});
|
||||
|
||||
BasicSpecTemplate(() => ModelViewer, () => tagName);
|
||||
|
||||
suite('Render Functionality Test', () => {
|
||||
let element: ModelViewerElement;
|
||||
|
||||
setup(async () => {
|
||||
element = new ModelViewerElement();
|
||||
element.style.width = '100px';
|
||||
element.style.height = '100px';
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
test('Metal roughness sphere with generated lighting', async () => {
|
||||
await setupLighting(element);
|
||||
const screenshotContext = element[$renderer].threeRenderer.getContext();
|
||||
testFidelity(screenshotContext);
|
||||
});
|
||||
|
||||
test('Metal roughness sphere with HDR lighting', async () => {
|
||||
await setupLighting(element, SUNRISE_HDR_PATH);
|
||||
const screenshotContext = element[$renderer].threeRenderer.getContext();
|
||||
testFidelity(screenshotContext);
|
||||
});
|
||||
|
||||
test('Metal roughness sphere with LDR lighting', async () => {
|
||||
await setupLighting(element, SUNRISE_LDR_PATH);
|
||||
const screenshotContext = element[$renderer].threeRenderer.getContext();
|
||||
testFidelity(screenshotContext);
|
||||
});
|
||||
});
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {degreesToRadians, lengthToBaseMeters, normalizeUnit, radiansToDegrees} from '../../styles/conversions.js';
|
||||
import {numberNode} from '../../styles/parsers.js';
|
||||
|
||||
suite('conversions', () => {
|
||||
suite('degreesToRadians', () => {
|
||||
test('converts a number expressed in degrees to radians', () => {
|
||||
expect(degreesToRadians(numberNode(180, 'deg')))
|
||||
.to.be.eql(numberNode(Math.PI, 'rad'));
|
||||
});
|
||||
|
||||
test('passes through numbers expressed in radians', () => {
|
||||
expect(degreesToRadians(numberNode(1, 'rad')))
|
||||
.to.be.eql(numberNode(1, 'rad'));
|
||||
});
|
||||
|
||||
test('passes through numbers without a unit', () => {
|
||||
expect(degreesToRadians(numberNode(1, null)))
|
||||
.to.be.eql(numberNode(1, null));
|
||||
});
|
||||
});
|
||||
|
||||
suite('radiansToDegrees', () => {
|
||||
test('converts a number expressed in radians to degrees', () => {
|
||||
expect(radiansToDegrees(numberNode(Math.PI, 'rad')))
|
||||
.to.be.eql(numberNode(180, 'deg'));
|
||||
});
|
||||
|
||||
test('passes through numbers expressed in degrees', () => {
|
||||
expect(radiansToDegrees(numberNode(1, 'deg')))
|
||||
.to.be.eql(numberNode(1, 'deg'));
|
||||
});
|
||||
|
||||
test('treats numbers without a unit as radians', () => {
|
||||
expect(radiansToDegrees(numberNode(Math.PI, null)))
|
||||
.to.be.eql(numberNode(180, 'deg'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('lengthToBaseMeters', () => {
|
||||
test('passes through numbers expressed in base meters', () => {
|
||||
expect(lengthToBaseMeters(numberNode(1, 'm')))
|
||||
.to.be.eql(numberNode(1, 'm'));
|
||||
});
|
||||
|
||||
test('converts numbers expressed in centimeters to base meters', () => {
|
||||
expect(lengthToBaseMeters(numberNode(123, 'cm')))
|
||||
.to.be.eql(numberNode(1.23, 'm'));
|
||||
});
|
||||
|
||||
test('converts numbers expressed in millimeters to base meters', () => {
|
||||
expect(lengthToBaseMeters(numberNode(1234, 'mm')))
|
||||
.to.be.eql(numberNode(1.234, 'm'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('normalizeUnit', () => {
|
||||
test('normalizes angles to radians', () => {
|
||||
expect(normalizeUnit(numberNode(180, 'deg')))
|
||||
.to.be.eql(numberNode(Math.PI, 'rad'));
|
||||
|
||||
expect(normalizeUnit(numberNode(180, 'rad')))
|
||||
.to.be.eql(numberNode(180, 'rad'));
|
||||
});
|
||||
|
||||
test('normalizes lengths to base meters', () => {
|
||||
expect(normalizeUnit(numberNode(1, 'm'))).to.be.eql(numberNode(1, 'm'));
|
||||
|
||||
expect(normalizeUnit(numberNode(1000, 'mm')))
|
||||
.to.be.eql(numberNode(1, 'm'));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {enumerationDeserializer} from '../../styles/deserializers.js';
|
||||
|
||||
suite('deserializers', () => {
|
||||
suite('enumerationDeserializer', () => {
|
||||
type Animal = 'elephant'|'octopus'|'chinchilla';
|
||||
let animals: Animal[];
|
||||
let deserializeAnimals: (input: string) => Set<Animal>;
|
||||
|
||||
setup(() => {
|
||||
animals = ['elephant', 'octopus', 'chinchilla'];
|
||||
deserializeAnimals = enumerationDeserializer<Animal>(animals);
|
||||
});
|
||||
|
||||
test('yields the members of the enumeration in the input string', () => {
|
||||
const deserialized = deserializeAnimals('elephant chinchilla');
|
||||
expect(deserialized.size).to.be.equal(2);
|
||||
expect(deserialized.has('elephant')).to.be.true;
|
||||
expect(deserialized.has('chinchilla')).to.be.true;
|
||||
});
|
||||
|
||||
test('filters out non-members of the enumeration', () => {
|
||||
const deserialized = deserializeAnimals('octopus paris');
|
||||
expect(deserialized.size).to.be.equal(1);
|
||||
expect(deserialized.has('octopus')).to.be.true;
|
||||
});
|
||||
|
||||
test('yields an empty set from null input', () => {
|
||||
// tsc would normally warn about null not being accepted
|
||||
// but it is worth ensuring the correct behavior all the same:
|
||||
const deserialized = deserializeAnimals(null as any);
|
||||
expect(deserialized.size).to.be.equal(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {CalcEvaluator, EnvEvaluator, OperatorEvaluator, PercentageEvaluator, SphericalIntrinsics, StyleEvaluator} from '../../styles/evaluators.js';
|
||||
import {numberNode} from '../../styles/parsers.js';
|
||||
import {expressionNode, functionNode, identNode, operatorNode, spy} from '../helpers.js';
|
||||
|
||||
suite('evaluators', () => {
|
||||
suite('EnvEvaluator', () => {
|
||||
test('is never constant', () => {
|
||||
const evaluator = new EnvEvaluator(functionNode('env', []));
|
||||
expect(evaluator.isConstant).to.be.false;
|
||||
});
|
||||
|
||||
test('with no arguments, evaluates to zero', () => {
|
||||
const evaluator = new EnvEvaluator(functionNode('env', []));
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
suite('window-scroll-y', () => {
|
||||
test('evaluates to current top-level scroll position', () => {
|
||||
const evaluator = new EnvEvaluator(functionNode(
|
||||
'env', [expressionNode([identNode('window-scroll-y')])]));
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
const scrollPosition = 10000;
|
||||
const scrollMax = 20000;
|
||||
|
||||
const restorePageYOffset =
|
||||
spy(window, 'pageYOffset', {value: scrollPosition});
|
||||
const restoreBodyScrollHeight =
|
||||
spy(document.documentElement, 'clientHeight', {value: scrollMax});
|
||||
|
||||
expect(evaluator.evaluate())
|
||||
.to.be.eql(numberNode(
|
||||
scrollPosition / (scrollMax - window.innerHeight), null));
|
||||
|
||||
restorePageYOffset();
|
||||
restoreBodyScrollHeight();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('PercentageEvaluator', () => {
|
||||
test('multiplies the percentage by the basis', () => {
|
||||
const evaluator =
|
||||
new PercentageEvaluator(numberNode(200, '%'), numberNode(1, 'm'));
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2, 'm'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('CalcEvaluator', () => {
|
||||
test('is constant if its operands are all numbers', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc',
|
||||
[expressionNode(
|
||||
[numberNode(1, null), operatorNode('+'), numberNode(1, null)])]));
|
||||
|
||||
expect(evaluator.isConstant).to.be.true;
|
||||
});
|
||||
|
||||
test('is constant if nested functions are constant', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc', [expressionNode([
|
||||
numberNode(1, null),
|
||||
operatorNode('+'),
|
||||
functionNode('calc', [expressionNode([numberNode(1, null)])])
|
||||
])]));
|
||||
|
||||
expect(evaluator.isConstant).to.be.true;
|
||||
});
|
||||
|
||||
test('is not constant if any nested function is not constant', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode('calc', [
|
||||
expressionNode([
|
||||
numberNode(1, null),
|
||||
operatorNode('+'),
|
||||
functionNode('calc', [expressionNode([numberNode(1, null)])]),
|
||||
operatorNode('+'),
|
||||
functionNode('env', [expressionNode([identNode('window-scroll-y')])])
|
||||
])
|
||||
]));
|
||||
|
||||
expect(evaluator.isConstant).to.be.false;
|
||||
});
|
||||
|
||||
test('with no arguments, evaluates to zero', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode('env', []));
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
test('evaluates basic addition', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc',
|
||||
[expressionNode(
|
||||
[numberNode(1, null), operatorNode('+'), numberNode(1, null)])]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2, null));
|
||||
});
|
||||
|
||||
test('evaluates basic subtraction', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc',
|
||||
[expressionNode(
|
||||
[numberNode(1, null), operatorNode('-'), numberNode(1, null)])]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
test('evaluates basic multiplication', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc',
|
||||
[expressionNode(
|
||||
[numberNode(5, null), operatorNode('*'), numberNode(4, null)])]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(20, null));
|
||||
});
|
||||
|
||||
test('evaluates basic division', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode('calc', [
|
||||
expressionNode(
|
||||
[numberNode(100, null), operatorNode('/'), numberNode(10, null)])
|
||||
]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(10, null));
|
||||
});
|
||||
|
||||
test('evaluates complex algebraic expressions', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode('calc', [
|
||||
expressionNode([
|
||||
numberNode(1, null),
|
||||
operatorNode('+'),
|
||||
numberNode(2, null),
|
||||
operatorNode('*'),
|
||||
numberNode(2.5, null),
|
||||
operatorNode('-'),
|
||||
numberNode(-2, null),
|
||||
operatorNode('/'),
|
||||
numberNode(-1, null)
|
||||
]),
|
||||
]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(4, null));
|
||||
});
|
||||
|
||||
suite('evaluates algebra with nested functions', () => {
|
||||
test('evaluates basic addition', () => {
|
||||
const evaluator = new CalcEvaluator(functionNode(
|
||||
'calc', [expressionNode([
|
||||
numberNode(1, null),
|
||||
operatorNode('+'),
|
||||
functionNode('calc', [expressionNode([numberNode(1, null)])])
|
||||
])]));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2, null));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('OperatorEvaluator', () => {
|
||||
test('evaluates zero for an unknown operator', () => {
|
||||
const evaluator = new OperatorEvaluator(
|
||||
operatorNode('$' as any), numberNode(1, null), numberNode(1, null));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
test('evaluates zero for operands with mismatching unit types', () => {
|
||||
const evaluator = new OperatorEvaluator(
|
||||
operatorNode('+'), numberNode(1, 'm'), numberNode(1, 'rad'));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(0, null));
|
||||
});
|
||||
|
||||
test('normalizes to base meters for length operands', () => {
|
||||
const evaluator = new OperatorEvaluator(
|
||||
operatorNode('+'), numberNode(1, 'm'), numberNode(1000, 'mm'));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2, 'm'));
|
||||
});
|
||||
|
||||
test('normalizes to radians for angular operands', () => {
|
||||
const evaluator = new OperatorEvaluator(
|
||||
operatorNode('+'),
|
||||
numberNode(180, 'deg'),
|
||||
numberNode(Math.PI, 'rad'));
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql(numberNode(2 * Math.PI, 'rad'));
|
||||
});
|
||||
});
|
||||
|
||||
suite('VectorEvaluator', () => {
|
||||
suite('with SphericalIntrinsics', () => {
|
||||
let intrinsics: SphericalIntrinsics;
|
||||
setup(() => {
|
||||
intrinsics = {
|
||||
basis:
|
||||
[numberNode(1, 'rad'), numberNode(1, 'rad'), numberNode(1, 'm')],
|
||||
keywords: {auto: [numberNode(2, 'rad'), null, numberNode(200, '%')]}
|
||||
};
|
||||
});
|
||||
|
||||
test('evaluates to defaults (e.g., auto) for omitted expressions', () => {
|
||||
const evaluator = new StyleEvaluator([], intrinsics);
|
||||
expect(evaluator.evaluate()).to.be.eql([2, 1, 2]);
|
||||
});
|
||||
|
||||
test(
|
||||
'substitutes the keyword auto for the related intrinsic value',
|
||||
() => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([identNode('auto')])], intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([2, 1, 2]);
|
||||
});
|
||||
|
||||
test('treats missing values as equivalent to auto', () => {
|
||||
const evaluatorOne =
|
||||
new StyleEvaluator([expressionNode([])], intrinsics);
|
||||
const evaluatorTwo = new StyleEvaluator(
|
||||
[expressionNode(
|
||||
[identNode('auto'), identNode('auto'), identNode('auto')])],
|
||||
intrinsics);
|
||||
|
||||
expect(evaluatorOne.evaluate()).to.be.eql(evaluatorTwo.evaluate());
|
||||
});
|
||||
|
||||
test('scales the basis by an input percentage', () => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([numberNode(300, '%')])], intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([3, 1, 2]);
|
||||
});
|
||||
|
||||
test('evaluates spherical values from basic expressions', () => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([
|
||||
numberNode(1, 'rad'),
|
||||
numberNode(180, 'deg'),
|
||||
numberNode(100, 'cm')
|
||||
])],
|
||||
intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([1, Math.PI, 1]);
|
||||
});
|
||||
|
||||
test('applies a percentage at any expression depth to the basis', () => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([
|
||||
numberNode(150, '%'),
|
||||
numberNode(180, 'deg'),
|
||||
functionNode('calc', [expressionNode([
|
||||
numberNode(200, '%'),
|
||||
operatorNode('*'),
|
||||
functionNode('calc', [expressionNode(
|
||||
[numberNode(3, 'm')])]),
|
||||
])])
|
||||
])],
|
||||
intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([1.5, Math.PI, 6]);
|
||||
});
|
||||
|
||||
test('evaluates spherical values from complex expressions', () => {
|
||||
const evaluator = new StyleEvaluator(
|
||||
[expressionNode([
|
||||
numberNode(1, 'rad'),
|
||||
numberNode(180, 'deg'),
|
||||
functionNode(
|
||||
'calc', [expressionNode([
|
||||
numberNode(1, 'm'),
|
||||
operatorNode('+'),
|
||||
functionNode(
|
||||
'calc', [expressionNode([numberNode(1, null)])]),
|
||||
operatorNode('+'),
|
||||
functionNode(
|
||||
'env', [expressionNode([identNode('window-scroll-y')])])
|
||||
])])
|
||||
])],
|
||||
intrinsics);
|
||||
|
||||
expect(evaluator.evaluate()).to.be.eql([1, Math.PI, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/* @license
|
||||
* Copyright 2018 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {ASTWalker, IdentNode, NumberNode, numberNode, parseExpressions} from '../../styles/parsers.js';
|
||||
import {expressionNode, functionNode, hexNode, identNode, operatorNode} from '../helpers.js';
|
||||
|
||||
suite('parsers', () => {
|
||||
suite('parseExpressions', () => {
|
||||
test('parses single numbers', () => {
|
||||
expect(parseExpressions('123rad')).to.be.eql([expressionNode(
|
||||
[numberNode(123, 'rad')])]);
|
||||
});
|
||||
|
||||
test('parses number tuples', () => {
|
||||
expect(parseExpressions('123rad 3.14deg -1m 2cm')).to.be.eql([
|
||||
expressionNode([
|
||||
numberNode(123, 'rad'),
|
||||
numberNode(3.14, 'deg'),
|
||||
numberNode(-1, 'm'),
|
||||
numberNode(2, 'cm')
|
||||
])
|
||||
]);
|
||||
});
|
||||
|
||||
test('parses exponential numbers', () => {
|
||||
expect(parseExpressions('123e10mm 123E10 123e-3 4e+3')).to.be.eql([
|
||||
expressionNode([
|
||||
numberNode(123e10, 'mm'),
|
||||
numberNode(123e10, null),
|
||||
numberNode(123e-3, null),
|
||||
numberNode(4e3, null)
|
||||
])
|
||||
]);
|
||||
});
|
||||
|
||||
test('parses hex colors', () => {
|
||||
expect(parseExpressions('#fff')).to.be.eql([expressionNode(
|
||||
[hexNode('fff')])]);
|
||||
expect(parseExpressions('#abc123')).to.be.eql([expressionNode(
|
||||
[hexNode('abc123')])]);
|
||||
expect(parseExpressions('#daf012ee')).to.be.eql([expressionNode(
|
||||
[hexNode('daf012ee')])]);
|
||||
});
|
||||
|
||||
test('parses functions', () => {
|
||||
expect(parseExpressions('rgba(255, 123, 0, 0.25)'))
|
||||
.to.be.eql([expressionNode([functionNode('rgba', [
|
||||
expressionNode([numberNode(255, null)]),
|
||||
expressionNode([numberNode(123, null)]),
|
||||
expressionNode([numberNode(0, null)]),
|
||||
expressionNode([numberNode(0.25, null)]),
|
||||
])])]);
|
||||
});
|
||||
|
||||
test('parses nested functions', () => {
|
||||
expect(parseExpressions('rgba(255, calc(100 + var(--blue)), 0, 0.25)'))
|
||||
.to.be.eql([expressionNode([functionNode('rgba', [
|
||||
expressionNode([numberNode(255, null)]),
|
||||
expressionNode([functionNode(
|
||||
'calc', [expressionNode([
|
||||
numberNode(100, null),
|
||||
operatorNode('+'),
|
||||
functionNode('var', [expressionNode([identNode('--blue')])])
|
||||
])])]),
|
||||
expressionNode([numberNode(0, null)]),
|
||||
expressionNode([numberNode(0.25, null)]),
|
||||
])])]);
|
||||
});
|
||||
|
||||
test('parses calc algebra', () => {
|
||||
expect(parseExpressions('1m - -2rad / 3 + 4deg * -10.5')).to.be.eql([
|
||||
expressionNode([
|
||||
numberNode(1, 'm'),
|
||||
operatorNode('-'),
|
||||
numberNode(-2, 'rad'),
|
||||
operatorNode('/'),
|
||||
numberNode(3, null),
|
||||
operatorNode('+'),
|
||||
numberNode(4, 'deg'),
|
||||
operatorNode('*'),
|
||||
numberNode(-10.5, null)
|
||||
])
|
||||
]);
|
||||
});
|
||||
|
||||
suite('failure cases', () => {
|
||||
suite('mismatched parens', () => {
|
||||
test('trailing paren is gracefully dropped', () => {
|
||||
expect(parseExpressions('calc(calc(123)))')).to.be.eql([
|
||||
expressionNode([functionNode(
|
||||
'calc',
|
||||
[expressionNode([functionNode(
|
||||
'calc', [expressionNode([numberNode(123, null)])])])])])
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('ASTWalker', () => {
|
||||
test('only walks configured node types', () => {
|
||||
const astWalker =
|
||||
new ASTWalker<NumberNode|IdentNode>(['number', 'ident']);
|
||||
const ast = parseExpressions('calc(123 + var(--size))')
|
||||
let visitedNodes = 0;
|
||||
|
||||
astWalker.walk(ast, (node: NumberNode|IdentNode) => {
|
||||
expect(node.type === 'number' || node.type === 'ident').to.be.true;
|
||||
visitedNodes++;
|
||||
});
|
||||
|
||||
expect(visitedNodes).to.be.equal(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {parseExpressions} from '../../styles/parsers.js';
|
||||
import {EnvironmentState, StyleEffector} from '../../styles/style-effector.js';
|
||||
import {dispatchSyntheticEvent} from '../helpers.js';
|
||||
|
||||
const triggerEnvironmentEffect = (type: EnvironmentState) => {
|
||||
switch (type) {
|
||||
case 'window-scroll':
|
||||
dispatchSyntheticEvent(window, 'scroll');
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
suite('StyleEffector', () => {
|
||||
test('never invokes its callback for constant styles', () => {
|
||||
let callbackInvoked = false;
|
||||
const styleEffector = new StyleEffector(() => {
|
||||
callbackInvoked = true;
|
||||
});
|
||||
|
||||
styleEffector.observeEffectsFor(parseExpressions('123px calc(4 * 5%)'));
|
||||
|
||||
triggerEnvironmentEffect('window-scroll');
|
||||
|
||||
expect(callbackInvoked).to.be.false;
|
||||
|
||||
styleEffector.dispose();
|
||||
});
|
||||
|
||||
test('invokes its callback for styles that depend on scroll', () => {
|
||||
let callbackCount = 0;
|
||||
const styleEffector = new StyleEffector(() => {
|
||||
callbackCount++;
|
||||
});
|
||||
|
||||
styleEffector.observeEffectsFor(
|
||||
parseExpressions('123px calc(env(window-scroll-y) * 5%)'));
|
||||
|
||||
triggerEnvironmentEffect('window-scroll');
|
||||
|
||||
expect(callbackCount).to.be.equal(1);
|
||||
|
||||
styleEffector.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {Cache} from 'three';
|
||||
|
||||
import ModelViewerElementBase from '../model-viewer-base.js';
|
||||
import {CachingGLTFLoader} from '../three-components/CachingGLTFLoader.js';
|
||||
import {timePasses} from '../utilities.js';
|
||||
|
||||
export type Constructor<T = object> = {
|
||||
new (...args: any[]): T
|
||||
};
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
export const BasicSpecTemplate =
|
||||
(ModelViewerElementAccessor: () => Constructor<ModelViewerElementBase>,
|
||||
tagNameAccessor: () => string) => {
|
||||
teardown(() => {
|
||||
CachingGLTFLoader.clearCache();
|
||||
Cache.clear();
|
||||
});
|
||||
|
||||
test('can be directly instantiated', () => {
|
||||
const ModelViewerElement = ModelViewerElementAccessor();
|
||||
const element = new ModelViewerElement();
|
||||
expect(element).to.be.ok;
|
||||
});
|
||||
|
||||
test('can be instantiated with document.createElement', () => {
|
||||
const tagName = tagNameAccessor();
|
||||
const element = document.createElement(tagName);
|
||||
expect(element).to.be.ok;
|
||||
});
|
||||
|
||||
suite('compatibility', () => {
|
||||
suite('when WebGL is not supported', () => {
|
||||
let nativeGetContext: any;
|
||||
|
||||
setup(() => {
|
||||
nativeGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
HTMLCanvasElement.prototype.getContext = function(
|
||||
type: string, ...args: Array<any>) {
|
||||
if (/webgl/.test(type)) {
|
||||
return null;
|
||||
}
|
||||
return nativeGetContext.call(this, type, ...args);
|
||||
};
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
HTMLCanvasElement.prototype.getContext = nativeGetContext;
|
||||
});
|
||||
|
||||
test(
|
||||
'does not explode when created and appended to the document',
|
||||
async () => {
|
||||
const ModelViewerElement = ModelViewerElementAccessor();
|
||||
const element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
await timePasses();
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,293 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Matrix4, PerspectiveCamera, Vector2, Vector3} from 'three';
|
||||
|
||||
import {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ARRenderer} from '../../three-components/ARRenderer.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {Renderer} from '../../three-components/Renderer.js';
|
||||
import {waitForEvent} from '../../utilities.js';
|
||||
import {assetPath} from '../helpers.js';
|
||||
|
||||
class MockXRFrame implements XRFrame {
|
||||
constructor(public session: XRSession) {
|
||||
}
|
||||
|
||||
readonly predictedDisplayTime = 0;
|
||||
|
||||
// We don't use nor test the returned XRPose other than its existence.
|
||||
getPose(_xrSpace: XRSpace, _frameOfRef: XRReferenceSpace) {
|
||||
return {} as XRPose;
|
||||
}
|
||||
|
||||
// We don't use getDepthInformation()
|
||||
getDepthInformation(_view: XRView): XRCPUDepthInformation|null|undefined {
|
||||
return;
|
||||
}
|
||||
|
||||
getViewerPose(_referenceSpace?: XRReferenceSpace): XRViewerPose {
|
||||
// Rotate 180 degrees on Y (so it's not the default)
|
||||
// and angle 45 degrees towards the ground, like a phone.
|
||||
const matrix = new Matrix4()
|
||||
.identity()
|
||||
.makeRotationAxis(new Vector3(0, 1, 0), Math.PI)
|
||||
.multiply(new Matrix4().makeRotationAxis(
|
||||
new Vector3(1, 0, 0), -Math.PI / 4));
|
||||
matrix.setPosition(10, 2, 3);
|
||||
const transform: XRRigidTransform = {
|
||||
matrix: matrix.elements as unknown as Float32Array,
|
||||
position: {} as DOMPointReadOnly,
|
||||
orientation: {} as DOMPointReadOnly,
|
||||
inverse: {} as XRRigidTransform
|
||||
};
|
||||
const camera = new PerspectiveCamera();
|
||||
const view: XRView = {
|
||||
eye: {} as XREye,
|
||||
projectionMatrix: camera.projectionMatrix.elements as unknown as
|
||||
Float32Array,
|
||||
transform: transform,
|
||||
requestViewportScale: (_scale: number|null) => {}
|
||||
};
|
||||
const viewerPos: XRViewerPose = {
|
||||
transform: transform,
|
||||
views: [view],
|
||||
emulatedPosition: false
|
||||
};
|
||||
|
||||
return viewerPos;
|
||||
}
|
||||
|
||||
getHitTestResults(_xrHitTestSource: XRHitTestSource) {
|
||||
return [];
|
||||
}
|
||||
|
||||
getHitTestResultsForTransientInput(_hitTestSource:
|
||||
XRTransientInputHitTestSource) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
suite('ARRenderer', () => {
|
||||
let element: ModelViewerElement;
|
||||
let arRenderer: ARRenderer;
|
||||
let xrSession: XRSession;
|
||||
|
||||
let inputSources: Array<XRInputSource> = [];
|
||||
|
||||
const setInputSources = (sources: Array<XRInputSource>) => {
|
||||
inputSources = sources;
|
||||
};
|
||||
|
||||
const stubWebXrInterface = (arRenderer: ARRenderer) => {
|
||||
arRenderer.resolveARSession = async () => {
|
||||
class FakeSession extends EventTarget implements XRSession {
|
||||
public renderState: XRRenderState = {
|
||||
baseLayer: {
|
||||
getViewport: () => {
|
||||
return {x: 0, y: 0, width: 320, height: 240} as XRViewport
|
||||
}
|
||||
} as unknown as XRLayer
|
||||
} as XRRenderState;
|
||||
|
||||
public hitTestSources: Set<XRHitTestSource> =
|
||||
new Set<XRHitTestSource>();
|
||||
|
||||
async updateRenderState(_object: any) {
|
||||
}
|
||||
|
||||
requestFrameOfReference() {
|
||||
return {};
|
||||
}
|
||||
|
||||
async requestReferenceSpace(_type: XRReferenceSpaceType):
|
||||
Promise<XRReferenceSpace> {
|
||||
return {} as XRReferenceSpace;
|
||||
}
|
||||
|
||||
get inputSources(): Array<XRInputSource> {
|
||||
return inputSources;
|
||||
}
|
||||
|
||||
async requestHitTestSource(_options: XRHitTestOptionsInit):
|
||||
Promise<XRHitTestSource> {
|
||||
const result = {cancel: () => {}};
|
||||
|
||||
this.hitTestSources.add(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async requestHitTestSourceForTransientInput(
|
||||
_options: XRTransientInputHitTestOptionsInit) {
|
||||
const result = {cancel: () => {}};
|
||||
|
||||
this.hitTestSources.add(result);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
requestAnimationFrame() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
cancelAnimationFrame() {
|
||||
}
|
||||
|
||||
async end() {
|
||||
this.dispatchEvent(new CustomEvent('end'));
|
||||
}
|
||||
|
||||
readonly environmentBlendMode = {} as XREnvironmentBlendMode;
|
||||
readonly visibilityState = {} as XRVisibilityState;
|
||||
readonly isSystemKeyboardSupported = false;
|
||||
async updateTargetFrameRate(_rate: number) {
|
||||
return;
|
||||
}
|
||||
onend() {
|
||||
}
|
||||
oninputsourceschange() {
|
||||
}
|
||||
onselect() {
|
||||
}
|
||||
onselectstart() {
|
||||
}
|
||||
onselectend() {
|
||||
}
|
||||
onsqueeze() {
|
||||
}
|
||||
onsqueezestart() {
|
||||
}
|
||||
onsqueezeend() {
|
||||
}
|
||||
onvisibilitychange() {
|
||||
}
|
||||
onframeratechange() {
|
||||
}
|
||||
}
|
||||
|
||||
xrSession = new FakeSession();
|
||||
return xrSession;
|
||||
};
|
||||
};
|
||||
|
||||
setup(() => {
|
||||
element = new ModelViewerElement();
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
arRenderer = Renderer.singleton.arRenderer;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
if (element.parentNode != null) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
});
|
||||
|
||||
test('is not presenting if present has not been invoked', () => {
|
||||
expect(arRenderer.isPresenting).to.be.equal(false);
|
||||
});
|
||||
|
||||
suite('when presenting a scene', () => {
|
||||
let modelScene: ModelScene;
|
||||
let oldXRRay: any;
|
||||
|
||||
setup(async () => {
|
||||
const sourceLoads = waitForEvent(element, 'poster-dismissed');
|
||||
element.src = assetPath('models/Astronaut.glb');
|
||||
await sourceLoads;
|
||||
modelScene = element[$scene];
|
||||
stubWebXrInterface(arRenderer);
|
||||
setInputSources([]);
|
||||
|
||||
oldXRRay = (window as any).XRRay;
|
||||
(window as any).XRRay = class MockXRRay implements XRRay {
|
||||
readonly origin = new DOMPointReadOnly;
|
||||
readonly direction = new DOMPointReadOnly;
|
||||
matrix = new Float32Array;
|
||||
|
||||
constructor(_origin: DOMPointInit, _direction: DOMPointInit) {
|
||||
}
|
||||
}
|
||||
|
||||
await arRenderer.present(modelScene);
|
||||
});
|
||||
|
||||
teardown(async () => {
|
||||
(window as any).XRRay = oldXRRay;
|
||||
await arRenderer.stopPresenting().catch(() => {});
|
||||
});
|
||||
|
||||
test('presents the model at its natural scale', () => {
|
||||
const scale = modelScene.target.getWorldScale(new Vector3());
|
||||
|
||||
expect(scale.x).to.be.equal(1);
|
||||
expect(scale.y).to.be.equal(1);
|
||||
expect(scale.z).to.be.equal(1);
|
||||
});
|
||||
|
||||
suite('presentation ends', () => {
|
||||
setup(async () => {
|
||||
await arRenderer.stopPresenting();
|
||||
});
|
||||
|
||||
test('restores the model to its natural scale', () => {
|
||||
const scale = modelScene.target.getWorldScale(new Vector3());
|
||||
|
||||
expect(scale.x).to.be.equal(1);
|
||||
expect(scale.y).to.be.equal(1);
|
||||
expect(scale.z).to.be.equal(1);
|
||||
});
|
||||
|
||||
test('restores original camera', () => {
|
||||
expect(modelScene.camera).to.be.equal(modelScene.camera);
|
||||
});
|
||||
|
||||
test('restores scene size', () => {
|
||||
expect(modelScene.width).to.be.equal(300);
|
||||
expect(modelScene.height).to.be.equal(150);
|
||||
});
|
||||
});
|
||||
|
||||
// We're going to need to mock out XRFrame more so it can set the camera
|
||||
// in order to properly test this.
|
||||
|
||||
suite('after initial placement', () => {
|
||||
let yaw: number;
|
||||
|
||||
setup(async () => {
|
||||
arRenderer.onWebXRFrame(0, new MockXRFrame(arRenderer.currentSession!));
|
||||
yaw = modelScene.yaw;
|
||||
});
|
||||
|
||||
test('places the model oriented to the camera', () => {
|
||||
const epsilon = 0.0001;
|
||||
const {target, position, camera} = modelScene;
|
||||
|
||||
const cameraPosition = camera.position;
|
||||
const cameraToHit = new Vector2(
|
||||
position.x - cameraPosition.x, position.z - cameraPosition.z);
|
||||
const forward = target.getWorldDirection(new Vector3());
|
||||
const forwardProjection = new Vector2(forward.x, forward.z);
|
||||
|
||||
expect(forward.y).to.be.equal(0);
|
||||
expect(cameraToHit.cross(forwardProjection)).to.be.closeTo(0, epsilon);
|
||||
expect(cameraToHit.dot(forwardProjection)).to.be.lessThan(0);
|
||||
expect(modelScene.yaw).to.be.equal(yaw);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import ModelViewerElementBase from '../../model-viewer-base.js';
|
||||
import {$evictionPolicy, CachingGLTFLoader} from '../../three-components/CachingGLTFLoader.js';
|
||||
import {ModelViewerGLTFInstance} from '../../three-components/gltf-instance/ModelViewerGLTFInstance.js';
|
||||
import {assetPath} from '../helpers.js';
|
||||
|
||||
const ModelViewerElement = class extends ModelViewerElementBase {
|
||||
static get is() {
|
||||
return 'model-viewer-loader';
|
||||
}
|
||||
};
|
||||
|
||||
customElements.define('model-viewer-loader', ModelViewerElement);
|
||||
|
||||
const ASTRONAUT_GLB_PATH = assetPath('models/Astronaut.glb');
|
||||
|
||||
suite('CachingGLTFLoader', () => {
|
||||
suite('when loading a glTF', () => {
|
||||
let loader: CachingGLTFLoader;
|
||||
let element: ModelViewerElementBase;
|
||||
|
||||
setup(() => {
|
||||
loader = new CachingGLTFLoader(ModelViewerGLTFInstance);
|
||||
element = new ModelViewerElement();
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
CachingGLTFLoader.clearCache();
|
||||
});
|
||||
|
||||
suite('before glTF is loaded', () => {
|
||||
test('reports that it has not finished loading', async () => {
|
||||
const fileLoads = loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
try {
|
||||
expect(CachingGLTFLoader.hasFinishedLoading(ASTRONAUT_GLB_PATH))
|
||||
.to.be.false;
|
||||
} finally {
|
||||
await fileLoads;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
suite('after glTF is loaded', () => {
|
||||
test('reports that it has finished loading', async () => {
|
||||
await loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
expect(CachingGLTFLoader.hasFinishedLoading(ASTRONAUT_GLB_PATH))
|
||||
.to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
test('synchronously populates the cache', async () => {
|
||||
const fileLoads = loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
try {
|
||||
expect(CachingGLTFLoader.has(ASTRONAUT_GLB_PATH)).to.be.true;
|
||||
} finally {
|
||||
await fileLoads;
|
||||
}
|
||||
});
|
||||
|
||||
test('yields a promise that resolves a group', async () => {
|
||||
const {scene} = await loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
expect(scene).to.be.ok;
|
||||
expect(scene!.type).to.be.equal('Group');
|
||||
});
|
||||
|
||||
suite('with items outside of the eviction threshold', () => {
|
||||
let naturalEvictionThreshold: number;
|
||||
|
||||
setup(() => {
|
||||
naturalEvictionThreshold = loader[$evictionPolicy].evictionThreshold;
|
||||
loader[$evictionPolicy].evictionThreshold = 0;
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
loader[$evictionPolicy].evictionThreshold = naturalEvictionThreshold;
|
||||
});
|
||||
|
||||
test('deletes them when they are fully released', async () => {
|
||||
const gltf = await loader.load(ASTRONAUT_GLB_PATH, element);
|
||||
|
||||
expect(CachingGLTFLoader.has(ASTRONAUT_GLB_PATH)).to.be.true;
|
||||
gltf.dispose();
|
||||
expect(CachingGLTFLoader.has(ASTRONAUT_GLB_PATH)).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {Damper} from '../../three-components/Damper.js';
|
||||
|
||||
const ONE_FRAME_DELTA = 1000.0 / 60.0;
|
||||
const FIFTY_FRAME_DELTA = 50 * ONE_FRAME_DELTA;
|
||||
|
||||
suite('Damper', () => {
|
||||
let damper: Damper;
|
||||
const initial = 5;
|
||||
const goal = 2;
|
||||
|
||||
setup(() => {
|
||||
damper = new Damper();
|
||||
});
|
||||
|
||||
test('converges to goal with large time step without overshoot', () => {
|
||||
const moving = damper.update(initial, goal, ONE_FRAME_DELTA, initial);
|
||||
const final = damper.update(moving, goal, FIFTY_FRAME_DELTA, initial);
|
||||
expect(final).to.be.eql(goal);
|
||||
});
|
||||
|
||||
test('stays at initial value for negative time step', () => {
|
||||
const final = damper.update(initial, goal, -1 * FIFTY_FRAME_DELTA, initial);
|
||||
expect(final).to.be.eql(initial);
|
||||
});
|
||||
|
||||
test('converges to goal when normalization is zero', () => {
|
||||
const final = damper.update(initial, goal, FIFTY_FRAME_DELTA, 0);
|
||||
expect(final).to.be.eql(goal);
|
||||
});
|
||||
|
||||
test('negative normalization is the same as positive', () => {
|
||||
const final = damper.update(initial, goal, FIFTY_FRAME_DELTA, -initial);
|
||||
expect(final).to.be.eql(goal);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
/* @license
|
||||
* Copyright 2020 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {GLTF} from 'three/examples/jsm/loaders/GLTFLoader.js';
|
||||
|
||||
import {$prepared, GLTFInstance, PreparedGLTF} from '../../three-components/GLTFInstance.js';
|
||||
import {createFakeThreeGLTF} from '../helpers.js';
|
||||
|
||||
suite('GLTFInstance', () => {
|
||||
let rawGLTF: GLTF;
|
||||
let preparedGLTF: PreparedGLTF;
|
||||
|
||||
setup(async () => {
|
||||
rawGLTF = createFakeThreeGLTF();
|
||||
preparedGLTF = await GLTFInstance.prepare(rawGLTF);
|
||||
});
|
||||
|
||||
suite('with a prepared GLTF', () => {
|
||||
test('exposes the same scene as the GLTF', () => {
|
||||
const gltfInstance = new GLTFInstance(preparedGLTF);
|
||||
expect(gltfInstance.scene).to.be.equal(preparedGLTF.scene);
|
||||
});
|
||||
|
||||
suite('when cloned', () => {
|
||||
test('creates a unique scene', async () => {
|
||||
const gltfInstance = new GLTFInstance(preparedGLTF);
|
||||
const cloneInstance = await gltfInstance.clone();
|
||||
|
||||
expect(cloneInstance.scene).to.be.ok;
|
||||
expect(cloneInstance.scene).to.not.be.equal(gltfInstance.scene);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('preparing the GLTF', () => {
|
||||
test('creates a prepared GLTF', () => {
|
||||
expect(preparedGLTF).to.not.be.equal(rawGLTF);
|
||||
expect(preparedGLTF[$prepared]).to.be.equal(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import {expect} from 'chai';
|
||||
|
||||
import {Hotspot, HotspotVisibilityDetails} from '../../three-components/Hotspot.js';
|
||||
import {waitForEvent} from '../../utilities.js';
|
||||
|
||||
suite('Hotspot', () => {
|
||||
suite('with only a name', () => {
|
||||
test('has a DOM element', () => {
|
||||
expect(new Hotspot({name: 'foo'}).element).to.be.ok;
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
suite('with assigned nodes', () => {
|
||||
let host: HTMLElement;
|
||||
let assigned: HTMLElement;
|
||||
|
||||
setup(() => {
|
||||
host = document.createElement('div');
|
||||
host.attachShadow({mode: 'open'});
|
||||
|
||||
assigned = document.createElement('div');
|
||||
host.appendChild(assigned);
|
||||
|
||||
document.body.insertBefore(host, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(host);
|
||||
});
|
||||
|
||||
suite('with a configured visibilityAttribute', () => {
|
||||
let hotspot: Hotspot;
|
||||
|
||||
setup(async () => {
|
||||
hotspot = new Hotspot({name: 'foo'});
|
||||
host.shadowRoot!.appendChild(hotspot.element);
|
||||
assigned.slot = 'foo';
|
||||
assigned.setAttribute('data-visibility-attribute', 'bar');
|
||||
});
|
||||
|
||||
suite('when shown', () => {
|
||||
setup(() => {
|
||||
hotspot.show();
|
||||
});
|
||||
|
||||
test('adds a corresponding data-* attribute to assigned nodes', () => {
|
||||
expect(assigned.hasAttribute('data-bar')).to.be.true;
|
||||
});
|
||||
|
||||
suite('and then hidden', () => {
|
||||
setup(() => {
|
||||
hotspot.hide();
|
||||
});
|
||||
|
||||
test('removes the corresponding data-* attribute', () => {
|
||||
expect(assigned.hasAttribute('data-bar')).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('when shown', () => {
|
||||
let event: CustomEvent<HotspotVisibilityDetails>;
|
||||
let hotspot: Hotspot;
|
||||
|
||||
setup(async () => {
|
||||
hotspot = new Hotspot({name: 'foo'});
|
||||
host.shadowRoot!.appendChild(hotspot.element);
|
||||
|
||||
assigned.slot = 'foo';
|
||||
|
||||
const assignedNodeHides = waitForEvent(assigned, 'hotspot-visibility');
|
||||
|
||||
hotspot.show();
|
||||
|
||||
event = await (
|
||||
assignedNodeHides as
|
||||
Promise<CustomEvent<HotspotVisibilityDetails>>);
|
||||
});
|
||||
|
||||
test('dispatches a "visibility-change" on assigned nodes', async () => {
|
||||
expect(event.detail.visible).to.be.true;
|
||||
});
|
||||
|
||||
|
||||
suite('and then hidden', () => {
|
||||
test('dispatches a "visibility-change" on assigned nodes', async () => {
|
||||
const assignedNodeHides =
|
||||
waitForEvent(assigned, 'hotspot-visibility');
|
||||
|
||||
hotspot.hide();
|
||||
|
||||
const event = await (
|
||||
assignedNodeHides as
|
||||
Promise<CustomEvent<HotspotVisibilityDetails>>);
|
||||
|
||||
expect(event.detail.visible).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/* @license
|
||||
* Copyright 2019 Google LLC. All Rights Reserved.
|
||||
* Licensed under the Apache License, Version 2.0 (the 'License');
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an 'AS IS' BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {expect} from 'chai';
|
||||
import {Matrix4, Mesh, SphereGeometry, Vector3} from 'three';
|
||||
|
||||
import {$scene} from '../../model-viewer-base.js';
|
||||
import {ModelViewerElement} from '../../model-viewer.js';
|
||||
import {ModelScene} from '../../three-components/ModelScene.js';
|
||||
import {assetPath} from '../helpers.js';
|
||||
|
||||
suite('ModelScene', () => {
|
||||
let element: ModelViewerElement;
|
||||
let scene: ModelScene;
|
||||
let dummyRadius: number;
|
||||
let dummyMesh: Mesh;
|
||||
|
||||
setup(() => {
|
||||
// Set the radius of the sphere to 0.5 so that it's size is 1
|
||||
// for testing scaling.
|
||||
dummyRadius = 0.5;
|
||||
dummyMesh = new Mesh(new SphereGeometry(dummyRadius, 32, 32));
|
||||
element = new ModelViewerElement();
|
||||
scene = element[$scene];
|
||||
|
||||
document.body.insertBefore(element, document.body.firstChild);
|
||||
});
|
||||
|
||||
teardown(() => {
|
||||
document.body.removeChild(element);
|
||||
});
|
||||
|
||||
suite('with a model', () => {
|
||||
setup(async () => {
|
||||
await scene.setSource(assetPath('models/Astronaut.glb'));
|
||||
});
|
||||
|
||||
suite('setShadowIntensity', () => {
|
||||
test('can increase intensity and reset it to zero', () => {
|
||||
scene.setShadowIntensity(1);
|
||||
const shadow = scene.shadow!;
|
||||
expect(shadow).to.be.ok;
|
||||
expect(shadow.getIntensity()).to.be.equal(1);
|
||||
scene.setShadowIntensity(0);
|
||||
expect(shadow.getIntensity()).to.be.equal(0);
|
||||
});
|
||||
|
||||
test('shadow is only created when intensity is greater than zero', () => {
|
||||
expect(scene.shadow).to.be.not.ok;
|
||||
scene.setShadowIntensity(1);
|
||||
expect(scene.shadow).to.be.ok;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
suite('setSize', () => {
|
||||
test('updates visual and buffer size', () => {
|
||||
scene.setSize(500, 200);
|
||||
expect(scene.width).to.be.equal(500);
|
||||
expect(scene.height).to.be.equal(200);
|
||||
});
|
||||
|
||||
test('model is not scaled', async () => {
|
||||
dummyMesh.geometry.applyMatrix4(new Matrix4().makeScale(1, 3, 10));
|
||||
await scene.setObject(dummyMesh);
|
||||
|
||||
scene.setSize(1000, 500);
|
||||
expect(scene.scale).to.be.eql(new Vector3(1, 1, 1));
|
||||
});
|
||||
|
||||
test('idealCameraDistance is set correctly', async () => {
|
||||
await scene.setObject(dummyMesh);
|
||||
|
||||
scene.framedFoVDeg = 25;
|
||||
const halfFov = (scene.framedFoVDeg / 2) * Math.PI / 180;
|
||||
const expectedDistance = dummyRadius / Math.sin(halfFov);
|
||||
expect(scene.idealCameraDistance())
|
||||
.to.be.closeTo(expectedDistance, 0.0001);
|
||||
});
|
||||
|
||||
test('idealAspect is set correctly', async () => {
|
||||
scene.framedFoVDeg = 25;
|
||||
await scene.setObject(dummyMesh);
|
||||
|
||||
expect(scene.idealAspect).to.be.closeTo(1, 0.001);
|
||||
});
|
||||
|
||||
test('cannot set the canvas smaller than 1x1', () => {
|
||||
scene.setSize(0, 0);
|
||||
expect(scene.width).to.be.equal(1);
|
||||
expect(scene.height).to.be.equal(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+93
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+166
@@ -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;
|
||||
}
|
||||
}
|
||||
+184
@@ -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;
|
||||
}
|
||||
}
|
||||
+150
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user