squash commits

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

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules/*
dist/*
lib/*
tsconfig.tsbuildinfo
shared-assets
+293
View File
@@ -0,0 +1,293 @@
<!--
@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.
-->
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="description" content="&lt;model-viewer&gt; editor">
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<meta property="og:title" content='﹤model-viewer﹥ Editor - Edit your asset files for 3D & AR' />
<meta property="og:description"
content="A tool to edit glTF and glb files ✏️, and configure them to use the ﹤model-viewer﹥ 3D & AR web component 🌐" />
<title>Model Editor</title>
<link href="https://fonts.googleapis.com/css?family=Material+Icons&display=block" rel="stylesheet">
<link rel="shortcut icon" type="image/png" href="../../shared-assets/icons/favicon.png" />
<script defer src="https://web3dsurvey.com/collector.js"></script>
<!-- Web animations for paper-dropdown -->
<script src="../../../node_modules/web-animations-js/web-animations-next-lite.min.js"></script>
<script src="../../../node_modules/js-beautify/js/lib/beautify-html.js"></script>
<script src="../../../node_modules/js-beautify/js/lib/beautify-css.js"></script>
<script>
// Necessary hack for Redux. See: https://github.com/reduxjs/redux/pull/2910
window.process = {
env: {
NODE_ENV: 'production'
}
};
</script>
<script type="module" src="../dist/space-opera.js">
</script>
<script>
window.ga = window.ga || function () { (ga.q = ga.q || []).push(arguments) }; ga.l = +new Date;
ga('create', 'UA-169901325-1', { 'storage': 'none' });
ga('set', 'referrer', document.referrer.split('?')[0]);
ga('set', 'anonymizeIp', true);
ga('send', 'pageview');
</script>
<script async src='https://www.google-analytics.com/analytics.js'></script>
</head>
<style>
:root {
--expandable-section-background: #2b2d30;
--expandable-section-text: #EEEEEE;
--text-on-expandable-background: #F5F5F5;
--number-input-background: #212121;
}
html, body {
width: 100vw;
height: 100lvh;
overflow: hidden;
}
.hamburger {
position: absolute;
z-index: 999;
top: 0.72rem;
left: 0.72rem;
border-radius: 4px;
width: 3rem;
height: 3rem;
cursor: pointer;
box-shadow: 2.75px 2.75px 4px 0px #00000024;
background: #ffffffc4;
backdrop-filter: blur(4px) brightness(0.92);
display: none;
}
.hamburger input {
display: none;
}
.hamburger svg {
/* The size of the SVG defines the overall size */
height: 3em;
/* Define the transition for transforming the SVG */
transition: transform 600ms cubic-bezier(0.4, 0, 0.2, 1);
}
.line {
fill: none;
stroke: #3c4043;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 3;
/* Define the transition for transforming the Stroke */
transition: stroke-dasharray 600ms cubic-bezier(0.4, 0, 0.2, 1),
stroke-dashoffset 600ms cubic-bezier(0.4, 0, 0.2, 1);
}
.line-top-bottom {
stroke-dasharray: 12 63;
}
.hamburger input:checked+svg {
transform: rotate(-45deg);
}
.hamburger input:checked+svg .line-top-bottom {
stroke-dasharray: 20 300;
stroke-dashoffset: -32.42;
}
body {
margin: 0;
overflow: hidden;
}
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.header {
align-items: center;
display: flex;
flex: 0 0 auto;
height: 48px;
justify-content: space-between;
padding: 0 12px;
}
/* Container for renderer (left) and tabs (right). */
.editor-body-root {
display: flex;
flex: auto;
/* Grows to fill space under header */
height: 0;
overflow: hidden;
}
.mvContainer {
align-items: stretch;
display: flex;
flex-direction: column;
height: 100%;
position: relative;
width: 100%;
}
model-viewer-preview {
height: 100%;
}
me-tabs {
background-color: #202124;
/* GOOGLE_GREY_900 */
padding: 0;
width: 360px;
min-width: 360px;
max-width: 360px;
}
me-tabs a {
color: #dadcff;
/* GOOGLE_GREY_300, but.. bluer */
}
me-tabbed-panel {
max-height: calc(100vh - 105px);
overflow-y: auto;
display: block;
}
me-tabbed-panel::-webkit-scrollbar {
width: 8px;
background-color: #c1c1c1;
border-radius: 0.2rem;
}
me-tabbed-panel::-webkit-scrollbar-thumb {
background-color: #202124b0;
border-radius: 0.2rem;
}
me-tabbed-panel::-webkit-scrollbar-thumb:hover {
background-color: #202124b8;
}
.privacy {
color: #BDBDBD;
padding: 5px 16px;
position: absolute;
bottom: 0;
font-size: small;
z-index: 0;
background-color: rgba(32, 33, 36, .75);
backdrop-filter: blur(5px) brightness(0.92) saturate(1.27) contrast(1.2);
}
@media only screen and (max-width: 768px) {
me-tabs {
position: absolute;
top: 0;
right: 0;
transform: translateX(100%);
transition: transform 600ms cubic-bezier(0.4, 0, 0.2, 1);
min-height: 100%;
}
.hamburger {
display: block;
}
.hamburger:has(>input:checked)+model-editor me-tabs {
transform: translateX(0);
}
}
@media only screen and (max-width: 450px) {
me-tabs {
min-width: 100%;
}
}
@media only screen and (max-height: 600px) {
.privacy {
z-index: -1;
}
}
.privacy-link {
color: #BDBDBD;
}
</style>
<body>
<label class="hamburger">
<input type="checkbox">
<svg viewBox="0 0 32 32">
<path class="line line-top-bottom"
d="M27 10 13 10C10.8 10 9 8.2 9 6 9 3.5 10.8 2 13 2 15.2 2 17 3.8 17 6L17 26C17 28.2 18.8 30 21 30 23.2 30 25 28.2 25 26 25 23.8 23.2 22 21 22L7 22">
</path>
<path class="line" d="M7 16 27 16"></path>
</svg>
</label>
<model-editor>
<div class="app">
<div class="editor-body-root">
<div class="mvContainer">
<model-viewer-preview id="editing_adapter">
</model-viewer-preview>
</div>
<me-tabs>
<me-tabbed-panel icon="import_export">
<me-export-panel></me-export-panel>
<div style="margin-bottom: 70px;"></div>
<div class="privacy">
This &lt;model-viewer&gt; editor does not send any imported content to servers except to deploy to your
mobile device:
<a href="https://policies.google.com/privacy" class="privacy-link" target="_blank">
Privacy
</a>
</div>
</me-tabbed-panel>
<me-tabbed-panel icon="create">
<me-export-panel header></me-export-panel>
<me-ibl-selector></me-ibl-selector>
<me-animation-controls></me-animation-controls>
<me-hotspot-panel></me-hotspot-panel>
</me-tabbed-panel>
<me-tabbed-panel icon="photo_camera">
<me-export-panel header></me-export-panel>
<me-camera-settings></me-camera-settings>
</me-tabbed-panel>
<me-tabbed-panel icon="color_lens">
<me-materials-panel></me-materials-panel>
</me-tabbed-panel>
<me-tabbed-panel icon="search">
<me-inspector-panel></me-inspector-panel>
</me-tabbed-panel>
</me-tabs>
</div>
</div>
</model-editor>
</body>
@@ -0,0 +1,56 @@
<!--
@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.
-->
<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="description" content="&lt;model-viewer&gt; editor">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Model Editor</title>
<link href="https://fonts.googleapis.com/css?family=Material+Icons&display=block" rel="stylesheet">
<link rel="shortcut icon" type="image/png" href="../../../shared-assets/icons/favicon.png" />
<!-- Web animations for paper-dropdown -->
<script src="../../../../node_modules/web-animations-js/web-animations-next-lite.min.js"></script>
<script src="../../../../node_modules/js-beautify/js/lib/beautify-html.js"></script>
<script src="../../../../node_modules/js-beautify/js/lib/beautify-css.js"></script>
<script>
// Necessary hack for Redux. See: https://github.com/reduxjs/redux/pull/2910
window.process = {
env: {
NODE_ENV: 'production'
}
};
</script>
<script type="module" src="../../dist/space-opera.js"></script>
</head>
<style>
:root {
--expandable-section-background: #2b2d30;
--expandable-section-text: #EEEEEE;
--text-on-expandable-background: #F5F5F5;
--number-input-background: #212121;
}
body {
margin: 0
}
</style>
<body>
<mobile-view></mobile-view>
</body>
+81
View File
@@ -0,0 +1,81 @@
{
"name": "@google/model-viewer-space-opera",
"type": "module",
"private": true,
"version": "0.0.1",
"description": "An interactive, client-side web UI for editing GLBs and model-viewer attributes.",
"scripts": {
"prepack": "rm $(find ./lib -name *_test.*)",
"build": "npm run build:tsc && npm run build:rollup",
"build:tsc": "tsc --incremental",
"build:rollup": "rollup -c --environment NODE_ENV:production",
"clean": "rm tsconfig.tsbuildinfo; rm -rf lib && rm -rf dist",
"test": "web-test-runner --playwright --browsers chromium",
"test:ci": "web-test-runner --static-logging --playwright --browsers chromium webkit",
"prepare": "if [ ! -L './shared-assets' ]; then ln -s ../shared-assets ./shared-assets; fi",
"watch:test": "web-test-runner --playwright --browsers chromium --watch",
"watch:tsc": "tsc -w",
"watch:rollup": "rollup -c -w",
"serve": "./node_modules/.bin/http-server -a 127.0.0.1 -o /editor/ -c-1",
"pyserve": "echo This assumes 'python' is Python 2. && which python && echo Visit $(hostname):8000/index.html && python -m SimpleHTTPServer 8000",
"dev": "npm run build -- --incremental && npm-run-all --parallel 'watch:tsc -- --preserveWatchOutput --incremental' 'watch:rollup' 'watch:test' 'serve -- -s'",
"dev-py": "npm run build -- --incremental && npm-run-all --parallel 'watch:tsc -- --preserveWatchOutput --incremental' 'watch:rollup' 'watch:test' 'pyserve'"
},
"license": "Apache-2.0",
"engines": {
"node": ">=6.0.0"
},
"contributors": [
"Charles Han <[email protected]>",
"Daniel Bauman <[email protected]>",
"Emma Freeman <[email protected]>",
"Steven An <[email protected]>",
"James Ma <[email protected]>",
"Emmett Lalish <[email protected]>",
"Chris George <[email protected]>"
],
"devDependencies": {
"@gltf-transform/core": "^4.1.2",
"@gltf-transform/extensions": "^4.1.2",
"@gltf-transform/functions": "^4.1.2",
"@google/model-viewer": "^4.1.0",
"@material/mwc-button": "^0.27.0",
"@material/mwc-checkbox": "^0.27.0",
"@material/mwc-dialog": "^0.27.0",
"@material/mwc-icon-button": "^0.27.0",
"@material/mwc-switch": "^0.27.0",
"@material/mwc-tab": "^0.27.0",
"@material/mwc-tab-bar": "^0.27.0",
"@material/mwc-textfield": "^0.27.0",
"@polymer/paper-dialog": "^3.0.1",
"@polymer/paper-dropdown-menu": "^3.2.0",
"@polymer/paper-item": "^3.0.1",
"@polymer/paper-listbox": "^3.0.1",
"@polymer/paper-slider": "^3.0.1",
"@reduxjs/toolkit": "^2.6.1",
"@rollup/plugin-commonjs": "^28.0.3",
"@rollup/plugin-node-resolve": "^16.0.0",
"@rollup/plugin-swc": "^0.4.0",
"@swc/core": "^1.11.8",
"@types/js-beautify": "^1.14.3",
"@types/remote-redux-devtools": "^0.5.8",
"@web/test-runner": "^0.20.0",
"@web/test-runner-playwright": "^0.11.0",
"chai": "^5.2.0",
"gltf-validator": "^2.0.0-dev.3.10",
"http-server": "^14.1.1",
"js-beautify": "^1.15.4",
"jszip": "^3.10.1",
"lit": "^3.2.1",
"npm-run-all": "^4.1.5",
"pwa-helpers": "^0.9.1",
"qrious": "^4.0.2",
"redux": "^5.0.1",
"remote-redux-devtools": "^0.5.16",
"rollup": "^4.35.0",
"simple-dropzone": "^0.8.3",
"ts-closure-library": "^2022.502.5",
"typescript": "5.8.2",
"web-animations-js": "^2.3.2"
}
}
+50
View File
@@ -0,0 +1,50 @@
/**
* @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 commonjs from '@rollup/plugin-commonjs';
import resolve from '@rollup/plugin-node-resolve';
import swc from '@rollup/plugin-swc';
const watchFiles = ['lib/**', '../model-viewer/lib/**'];
const plugins = [
{
resolveId: (id) => {
if (id === 'jszip') {
return '../../node_modules/jszip/dist/jszip.min.js';
}
return null;
},
},
resolve(),
commonjs(),
swc()
];
export default [{
input: './lib/app.js',
output: {file: './dist/space-opera.js', format: 'esm', name: 'Space Opera'},
onwarn(warning, warn) {
if (warning.code === 'THIS_IS_UNDEFINED')
return;
warn(warning);
},
plugins,
watch: {
include: watchFiles,
},
}];
+47
View File
@@ -0,0 +1,47 @@
/**
* @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.
*
*/
/**
* @fileoverview Model editor for uploading, parsing, modifying GLTF/GLB files.
*/
import '@material/mwc-button';
import '@polymer/paper-dialog/paper-dialog';
// The order of imports is important, as this is the order in which their
// stateChanged() methods will be called. We put the preview first to apply
// changes to the <model-viewer> element in case they need to be queried by
// subsequent components. Keep in mind that those will still have to await
// updateComplete since rendering is async in Lit Element.
import './components/model_viewer_preview/model_viewer_preview.js';
import './components/animation_controls/animation_controls.js';
import './components/camera_settings/camera_settings.js';
import './components/model_viewer_snippet/components/download_button.js';
import './components/hotspot_panel/hotspot_panel.js';
import './components/ibl_selector/ibl_selector.js';
import './components/materials_panel/materials_panel.js';
import './components/model_viewer_snippet/components/open_button.js';
import './components/model_viewer_snippet/components/validation.js';
import './components/model_editor/model_editor.js';
import './components/model_viewer_snippet/model_viewer_snippet.js';
import './components/inspector/inspector.js';
import './components/shared/tabs/tabs.js';
import './components/shared/mv_link/mv_link.js';
import './components/mobile_view/mobile_view.js';
import './components/mobile_view/open_mobile_view.js';
import './components/mobile_view/components/mobile_modal.js';
import './components/mobile_view/components/mobile_expandable_section.js';
import './components/best_practices/best_practices.js';
@@ -0,0 +1,136 @@
/**
* @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 '../shared/expandable_content/expandable_tab.js';
import '../shared/dropdown/dropdown.js';
import '../shared/checkbox/checkbox.js';
import '@polymer/paper-item';
import {html} from 'lit';
import {customElement, state, query} from 'lit/decorators.js';
import {reduxStore} from '../../space_opera_base.js';
import {State} from '../../types.js';
import {dispatchAnimationName, dispatchAutoplayEnabled, getConfig} from '../config/reducer';
import {ConnectedLitElement} from '../connected_lit_element/connected_lit_element.js';
import {getModelViewer, getUpdatedModelViewer, isLoaded} from '../model_viewer_preview/reducer.js';
import {CheckboxElement} from '../shared/checkbox/checkbox.js';
import {Dropdown} from '../shared/dropdown/dropdown.js';
import {SectionRow} from '../shared/section_row/section_row.js';
import {SliderWithInputElement} from '../shared/slider_with_input/slider_with_input.js';
/**
* Animation controls for gltf and model-viewer.
*/
@customElement('me-animation-controls')
export class AnimationControls extends ConnectedLitElement {
@query('me-checkbox#animation-autoplay') autoplayCheckbox!: CheckboxElement;
@query('me-slider-with-input#scrubber') scrubber!: SliderWithInputElement;
@query('me-section-row#time') timeElement!: SectionRow;
@state() animationNames: string[] = [];
@state() selectedAnimation: string|undefined = undefined;
@state() autoplay = false;
@state() clipLength = 0;
stateChanged(state: State) {
const config = getConfig(state);
this.selectedAnimation = config.animationName;
this.autoplay = !!config.autoplay;
if (isLoaded(state)) {
this.animationNames = getModelViewer().availableAnimations ?? [];
this.updateScrubber();
}
}
render() {
let selectedAnimationIndex = this.selectedAnimation ?
this.animationNames.findIndex(
(name) => name === this.selectedAnimation) :
0; // Select first animation as model-viewer default
if (selectedAnimationIndex === -1) {
selectedAnimationIndex = 0;
}
const hasAnims = this.animationNames.length > 0;
const tabHeader = hasAnims ? 'Animations' : 'Animations (Model has none)';
return html`
<me-expandable-tab tabName=${tabHeader} .enabled=${hasAnims} .open=${
true}>
<div slot="content">
<me-dropdown id="animation-name-selector"
selectedIndex=${selectedAnimationIndex}
@select=${this.onAnimationNameChange}>
${this.animationNames.map(name => {
return html`
<paper-item value="${name}">
${name}
</paper-item>`;
})}
</me-dropdown>
<me-checkbox id="animation-autoplay" label="Play"
?checked="${!!this.autoplay}"
@change=${this.onAutoplayChange}></me-checkbox>
<me-section-row class="Row" label="Time" id="time">
<me-slider-with-input
id="scrubber"
min=0 max=${this.clipLength} step=0.01
@change=${this.onScrub}>
</me-slider-with-input>
</me-section-row>
</div>
</me-expandable-tab>
`;
}
onAutoplayChange() {
const {checked} = this.autoplayCheckbox!;
reduxStore.dispatch(dispatchAutoplayEnabled(checked));
if (checked === false) {
getModelViewer()?.pause();
}
}
onAnimationNameChange(event: CustomEvent) {
// Autoplay must be enabled otherwise the animation won't play and the
// console throws a warning.
const dropdown = event.target as Dropdown;
const value = dropdown.selectedItem?.getAttribute('value') || undefined;
if (value !== undefined && this.animationNames.indexOf(value) !== -1) {
reduxStore.dispatch(dispatchAnimationName(value));
}
}
onScrub() {
const time = this.scrubber.value;
getModelViewer().currentTime = time;
}
async updateScrubber() {
const modelViewer = await getUpdatedModelViewer();
this.clipLength = Math.floor(modelViewer.duration * 100) / 100;
this.scrubber.value = modelViewer.currentTime;
this.timeElement.style.display = this.autoplay ? 'none' : '';
}
}
declare global {
interface AnimationControls {
'me-animation-controls': AnimationControls;
}
}
@@ -0,0 +1,89 @@
/**
* @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 {html} from 'lit';
import {customElement, state, query} from 'lit/decorators.js';
import {reduxStore} from '../../space_opera_base';
import {BestPracticesState, State} from '../../types';
import {ConnectedLitElement} from '../connected_lit_element/connected_lit_element';
import {CheckboxElement} from '../shared/checkbox/checkbox';
import {dispatchSetARButton, dispatchSetARPrompt, dispatchSetProgressBar, getBestPractices} from './reducer';
/**
* A section of best practices to enable or disable.
*/
@customElement('best-practices')
export class BestPractices extends ConnectedLitElement {
@state() bestPractices?: BestPracticesState;
@query('me-checkbox#progress-bar') progressBarCheckbox!: CheckboxElement;
@query('me-checkbox#ar-button') arButtonCheckbox!: CheckboxElement;
@query('me-checkbox#ar-prompt') arPromptCheckbox!: CheckboxElement;
stateChanged(state: State) {
this.bestPractices = getBestPractices(state);
}
onProgressBarChange() {
reduxStore.dispatch(
dispatchSetProgressBar(this.progressBarCheckbox.checked));
}
onARButtonChange() {
reduxStore.dispatch(dispatchSetARButton(this.arButtonCheckbox.checked));
}
onARPromptChange() {
reduxStore.dispatch(dispatchSetARPrompt(this.arPromptCheckbox.checked));
}
render() {
return html`
<div style="font-size: 14px; font-weight: 500; margin: 0px 0px 10px 0px;">
Use Custom:
</div>
<me-checkbox
id="progress-bar"
label="Progress Bar"
?checked="${this.bestPractices?.progressBar}"
@change=${this.onProgressBarChange}
>
</me-checkbox>
<me-checkbox
id="ar-button"
label="AR Button"
?checked="${this.bestPractices?.arButton}"
@change=${this.onARButtonChange}
>
</me-checkbox>
<me-checkbox
id="ar-prompt"
label="AR Prompt"
?checked="${this.bestPractices?.arPrompt}"
@change=${this.onARPromptChange}
>
</me-checkbox>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'best-practices': BestPractices;
}
}
@@ -0,0 +1,50 @@
/**
* @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.
*
*/
export const modelViewerTemplate = `<!doctype html>
<html lang="en">
<head>
<title>&lt;model-viewer&gt; template</title>
<meta charset="utf-8">
<meta name="description" content="&lt;model-viewer&gt; template">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link type="text/css" href="./styles.css" rel="stylesheet"/>
</head>
<body>
<!-- <model-viewer> HTML element -->
REPLACEME
<!-- Loads <model-viewer> for browsers: -->
<script type="module" src="https://ajax.googleapis.com/ajax/libs/model-viewer/4.0.0/model-viewer.min.js"></script>
</body>
</html>`;
export const scriptTemplate = ` <script src="script.js"></script>`;
export const progressBar =
`// Handles loading the events for <model-viewer>'s slotted progress bar
const onProgress = (event) => {
const progressBar = event.target.querySelector('.progress-bar');
const updatingBar = event.target.querySelector('.update-bar');
updatingBar.style.width = \`\${event.detail.totalProgress * 100}%\`;
if (event.detail.totalProgress === 1) {
progressBar.classList.add('hide');
event.target.removeEventListener('progress', onProgress);
} else {
progressBar.classList.remove('hide');
}
};
document.querySelector('model-viewer').addEventListener('progress', onProgress);`;
@@ -0,0 +1,53 @@
/**
* @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 {Action, BestPracticesState, INITIAL_STATE, State} from '../../types';
const SET_PROGRESS_BAR = 'SET_PROGRESS_BAR';
export function dispatchSetProgressBar(isUsingCustomProgressBar: boolean) {
return {type: SET_PROGRESS_BAR, payload: isUsingCustomProgressBar};
}
const SET_AR_BUTTON = 'SET_AR_BUTTON';
export function dispatchSetARButton(isUsingCustomARButton: boolean) {
return {type: SET_AR_BUTTON, payload: isUsingCustomARButton};
}
const SET_AR_PROMPT = 'SET_AR_PROMPT';
export function dispatchSetARPrompt(isUsingCustomARPrompt: boolean) {
return {type: SET_AR_PROMPT, payload: isUsingCustomARPrompt};
}
export const getBestPractices = (state: State) =>
state.entities.modelViewerSnippet.bestPractices;
export function bestPracticesReducer(
state: BestPracticesState =
INITIAL_STATE.entities.modelViewerSnippet.bestPractices,
action: Action): BestPracticesState {
switch (action.type) {
case SET_PROGRESS_BAR:
return {...state, progressBar: action.payload};
case SET_AR_BUTTON:
return {...state, arButton: action.payload};
case SET_AR_PROMPT:
return {...state, arPrompt: action.payload};
default:
return state;
}
}
@@ -0,0 +1,61 @@
/**
* @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 {html} from 'lit';
import {getMobileModelViewer} from '../mobile_view/reducer';
import {getModelViewer} from '../model_viewer_preview/reducer';
export function renderProgressBar(isEditor) {
if (isEditor) {
getModelViewer()?.addEventListener('progress', onProgress);
} else {
getMobileModelViewer()?.addEventListener('progress', onProgress);
}
return html`
<div class="progress-bar hide" slot="progress-bar">
<div class="update-bar"></div>
</div>`;
}
export const onProgress = (event) => {
const progressBar = event.target.querySelector('.progress-bar');
const updatingBar = event.target.querySelector('.update-bar');
if (!progressBar || !updatingBar)
return;
updatingBar.style.width = `${event.detail.totalProgress * 100}%`;
if (event.detail.totalProgress === 1) {
progressBar.classList.add('hide');
event.target.removeEventListener('progress', onProgress);
} else {
progressBar.classList.remove('hide');
}
};
export function renderARButton() {
return html`
<button slot="ar-button" id="ar-button">
View in your space
</button>`;
}
export function renderARPrompt() {
return html`
<div id="ar-prompt">
<img src="https://modelviewer.dev/shared-assets/icons/hand.png">
</div>`;
}
@@ -0,0 +1,131 @@
/**
* @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 {css, CSSResult} from 'lit';
export const modelViewerStyles: CSSResult = css`:not(:defined) > * {
display: none;
}
html {
height: 100%;
}
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
}
model-viewer {
width: 100%;
height: 90%;
background-color: #ffffff;
}
`;
export const progressBarCSS: CSSResult = css`
.progress-bar {
display: block;
width: 33%;
height: 10%;
max-height: 2%;
position: absolute;
left: 50%;
top: 50%;
transform: translate3d(-50%, -50%, 0);
border-radius: 25px;
box-shadow: 0px 3px 10px 3px rgba(0, 0, 0, 0.5), 0px 0px 5px 1px rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.9);
background-color: rgba(0, 0, 0, 0.5);
}
.progress-bar.hide {
visibility: hidden;
transition: visibility 0.3s;
}
.update-bar {
background-color: rgba(255, 255, 255, 0.9);
width: 0%;
height: 100%;
border-radius: 25px;
float: left;
transition: width 0.3s;
}`;
export const arButtonCSS: CSSResult = css`
#ar-button {
background-image: url(https://modelviewer.dev/shared-assets/icons/ic_view_in_ar_new_googblue_48dp.png);
background-repeat: no-repeat;
background-size: 20px 20px;
background-position: 12px 50%;
background-color: #fff;
position: absolute;
left: 50%;
transform: translateX(-50%);
white-space: nowrap;
bottom: 16px;
padding: 0px 16px 0px 40px;
font-family: Roboto Regular, Helvetica Neue, sans-serif;
font-size: 14px;
color:#4285f4;
height: 36px;
line-height: 36px;
border-radius: 18px;
border: 1px solid #DADCE0;
}
#ar-button:active {
background-color: #E8EAED;
}
#ar-button:focus {
outline: none;
}
#ar-button:focus-visible {
outline: 1px solid #4285f4;
}`;
export const arPromptCSS: CSSResult = css`
@keyframes circle {
from { transform: translateX(-50%) rotate(0deg) translateX(50px) rotate(0deg); }
to { transform: translateX(-50%) rotate(360deg) translateX(50px) rotate(-360deg); }
}
@keyframes elongate {
from { transform: translateX(100px); }
to { transform: translateX(-100px); }
}
model-viewer > #ar-prompt {
position: absolute;
left: 50%;
bottom: 60px;
animation: elongate 2s infinite ease-in-out alternate;
display: none;
}
model-viewer[ar-status="session-started"] > #ar-prompt {
display: block;
}
model-viewer > #ar-prompt > img {
animation: circle 4s linear infinite;
}`;
@@ -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 './components/pitch_limits.js';
import './components/yaw_limits.js';
import './components/zoom.js';
import '../shared/checkbox/checkbox.js';
import '@material/mwc-button';
import '../shared/expandable_content/expandable_tab.js';
import '../shared/section_row/section_row.js';
import '../shared/draggable_input/draggable_input.js';
import '../shared/checkbox/checkbox.js';
import {html} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {reduxStore} from '../../space_opera_base.js';
import {cameraSettingsStyles} from '../../styles.css.js';
import {ModelViewerConfig, State} from '../../types.js';
import {dispatchAutoRotate, dispatchCameraTarget, dispatchSaveCameraFov, dispatchSaveCameraOrbit, getConfig} from '../config/reducer.js';
import {Vector3D} from '../config/types.js';
import {ConnectedLitElement} from '../connected_lit_element/connected_lit_element.js';
import {getModelViewer, getUpdatedModelViewer, isLoaded} from '../model_viewer_preview/reducer.js';
import {CheckboxElement} from '../shared/checkbox/checkbox.js';
import {DraggableInput} from '../shared/draggable_input/draggable_input.js';
import {styles as draggableInputRowStyles} from '../shared/draggable_input/draggable_input_row.css.js';
import {checkFinite, degToRad, radToDeg} from '../utils/reducer_utils.js';
@customElement('me-camera-orbit-editor')
class CameraOrbitEditor extends ConnectedLitElement {
static styles = [cameraSettingsStyles, draggableInputRowStyles];
@query('me-draggable-input#yaw') yawInput!: DraggableInput;
@query('me-draggable-input#pitch') pitchInput!: DraggableInput;
private onChange() {
this.dispatchEvent(new CustomEvent('change'));
}
render() {
return html`
<div style="justify-content: space-between; width: 100%; display: flex;">
<div>
<me-draggable-input
id="yaw"
innerLabel="yaw"
min=-9999 max=9999
style="min-width: 90px; max-width: 90px;"
@change=${this.onChange}>
</me-draggable-input>
<me-draggable-input
id="pitch"
innerLabel="pitch"
min=-9999 max=9999
style="min-width: 90px; max-width: 90px;"
@change=${this.onChange}>
</me-draggable-input>
</div>
</div>
`;
}
}
/** Camera target input panel. */
@customElement('me-camera-target-input')
export class CameraTargetInput extends ConnectedLitElement {
static styles = [draggableInputRowStyles, cameraSettingsStyles];
@query('me-draggable-input#camera-target-x') xInput!: HTMLInputElement;
@query('me-draggable-input#camera-target-y') yInput!: HTMLInputElement;
@query('me-draggable-input#camera-target-z') zInput!: HTMLInputElement;
@property({attribute: false}) change?: (newValue: Vector3D) => void;
@state() target?: Vector3D;
protected onInputChange(event: Event) {
event.preventDefault();
if (!this.change) {
return;
}
const target = {
x: checkFinite(Number(this.xInput.value)),
y: checkFinite(Number(this.yInput.value)),
z: checkFinite(Number(this.zInput.value)),
};
this.change(target);
}
resetTarget() {
reduxStore.dispatch(dispatchCameraTarget());
}
render() {
if (!this.target) {
return html`<div class="note">Waiting for camera target...</div>`;
}
return html`
<me-draggable-input value=${this.target.x}
id="camera-target-x" min=-9999 max=9999 dragStepSize=0.01 @change=${
this.onInputChange} innerLabel="X"></me-draggable-input>
<me-draggable-input id="camera-target-y" min=-9999 max=9999 dragStepSize=0.01 value=${
this.target.y} @change=${
this.onInputChange} innerLabel="Y"></me-draggable-input>
<me-draggable-input id="camera-target-z" value=${
this.target.z} min=-9999 max=9999 dragStepSize=0.01 @change=${
this.onInputChange} innerLabel="Z"></me-draggable-input>
<mwc-icon-button class="RevertButton" style="align-self: center; margin-top: -10px;" id="revert" icon="undo"
title="Reset target" @click=${this.resetTarget}>
</mwc-icon-button>
`;
}
}
/** The Camera Settings panel. */
@customElement('me-camera-settings')
export class CameraSettings extends ConnectedLitElement {
static styles = cameraSettingsStyles;
@state() config: ModelViewerConfig = {};
@query('me-camera-orbit-editor') cameraOrbitEditor!: CameraOrbitEditor;
@query('me-camera-target-input') cameraTargetInput!: CameraTargetInput;
@query('me-checkbox#auto-rotate') autoRotateCheckbox!: CheckboxElement;
// Specifically overriding a super class method.
// tslint:disable-next-line:enforce-name-casing
async getUpdateComplete() {
const noTrigger = await super.getUpdateComplete();
await this.cameraOrbitEditor.updateComplete;
await this.autoRotateCheckbox.updateComplete;
return noTrigger;
}
stateChanged(state: State) {
const config = getConfig(state);
if (config !== this.config) {
this.config = config;
if (isLoaded(state)) {
this.updateInitialCamera();
}
}
}
async updateInitialCamera() {
const modelViewer = await getUpdatedModelViewer();
this.cameraTargetInput.target = modelViewer.getCameraTarget();
if (this.config.cameraOrbit == null) {
this.cameraOrbitEditor.style.display = 'none';
} else {
const currentOrbit = modelViewer.getCameraOrbit();
this.cameraOrbitEditor.yawInput.value = radToDeg(currentOrbit.theta);
this.cameraOrbitEditor.pitchInput.value = radToDeg(currentOrbit.phi);
this.cameraOrbitEditor.style.display = '';
}
}
async onSaveCameraOrbit() {
const modelViewer = await getUpdatedModelViewer();
reduxStore.dispatch(dispatchSaveCameraOrbit(modelViewer.getCameraOrbit()));
reduxStore.dispatch(dispatchSaveCameraFov(modelViewer.getFieldOfView()));
}
resetInitialCamera() {
this.cameraOrbitEditor.style.display = 'none';
reduxStore.dispatch(dispatchSaveCameraOrbit(undefined));
reduxStore.dispatch(dispatchSaveCameraFov(undefined));
}
onUpdateFraming() {
const modelViewer = getModelViewer();
modelViewer.updateFraming();
modelViewer.cameraOrbit = this.config.cameraOrbit!;
}
onCameraOrbitEditorChange() {
const orbit = getModelViewer().getCameraOrbit();
orbit.phi = degToRad(this.cameraOrbitEditor.pitchInput.value),
orbit.theta = degToRad(this.cameraOrbitEditor.yawInput.value),
reduxStore.dispatch(dispatchSaveCameraOrbit(orbit));
getModelViewer().jumpCameraToGoal();
}
onCameraTargetChange(newValue: Vector3D) {
reduxStore.dispatch(dispatchCameraTarget(newValue));
}
onAutoRotateChange() {
reduxStore.dispatch(dispatchAutoRotate(this.autoRotateCheckbox.checked));
}
render() {
return html`
<me-expandable-tab tabName="Camera Setup" .open=${true}>
<div slot="content">
<me-checkbox id="auto-rotate" label="Auto-rotate"
?checked="${!!this.config.autoRotate}"
@change=${this.onAutoRotateChange}>
</me-checkbox>
<div>
<div style="font-size: 14px; font-weight: 500; margin-top: 10px">Initial Camera Position:</div>
<me-camera-orbit-editor
@change=${this.onCameraOrbitEditorChange}>
</me-camera-orbit-editor>
<div style="justify-content: space-between; width: 100%; display: flex;">
<mwc-button
class="SaveCameraButton"
id="save-camera-angle"
unelevated
icon="photo_camera"
style="align-self: center"
@click=${this.onSaveCameraOrbit}>
Save current as initial
</mwc-button>
<mwc-icon-button class="RevertButton" style="align-self: center; margin-top: 10px;" id="revert" icon="undo"
title="Reset initial camera" @click=${this.resetInitialCamera}>
</mwc-icon-button>
</div>
<mwc-button
class="SaveCameraButton"
id="update-framing"
unelevated
icon="photo_camera"
style="align-self: center"
@click=${this.onUpdateFraming}>
Reset framing
</mwc-button>
</div>
<div style="font-size: 14px; font-weight: 500; margin-top: 20px">Target Point:</div>
<me-camera-target-input .change=${this.onCameraTargetChange}>
</me-camera-target-input>
</div>
</me-expandable-tab>
<me-expandable-tab tabName="Customize Limits">
<div slot="content">
<me-camera-yaw-limits></me-camera-yaw-limits>
<me-camera-pitch-limits></me-camera-pitch-limits>
<me-camera-zoom-limits></me-camera-zoom-limits>
</div>
</me-expandable-tab>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-camera-orbit-editor': CameraOrbitEditor;
'me-camera-settings': CameraSettings;
'me-camera-target-input': CameraTargetInput;
'me-draggable-input': DraggableInput;
}
}
@@ -0,0 +1,36 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`.LabelRowContent {
width: 100%;
}
mwc-button {
float: right;
}
.SetButton {
width: 120px;
}
.LimitSlider,
.MaxLabelRow {
margin-top: 10px;
}
`;
@@ -0,0 +1,172 @@
/**
* @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/mwc-button';
import '../../shared/expandable_content/expandable_tab.js';
import '../../shared/section_row/section_row.js';
import '../../shared/slider_with_input/slider_with_input.js';
import '../../shared/checkbox/checkbox.js';
import {html} from 'lit';
import {state, query} from 'lit/decorators.js';
import {Limits} from '../../config/types.js';
import {ConnectedLitElement} from '../../connected_lit_element/connected_lit_element.js';
import {getModelViewer} from '../../model_viewer_preview/reducer.js';
import {SliderWithInputElement} from '../../shared/slider_with_input/slider_with_input.js';
import {styles} from './limits_base.css.js';
/** Abstract component that can be extended for editing scalar limits. */
export abstract class LimitsBase extends ConnectedLitElement {
static styles = [styles];
@state() limitsProperty?: Limits;
abstract get label(): string;
abstract get minimumLabel(): string;
abstract get maximumLabel(): string;
abstract get absoluteMinimum(): number;
abstract get absoluteMaximum(): number;
abstract get currentPreviewValue(): number;
abstract dispatchLimits(limits?: Limits): void;
get decimalPlaces(): number {
return 0;
}
get sliderStep(): number {
return 1;
}
@query('#minimum') minimumInput!: SliderWithInputElement;
@query('#maximum') maximumInput!: SliderWithInputElement;
@query('#limit-enabled') enabledInput!: HTMLInputElement;
get inputLimits() {
if (!this.maximumInput || !this.minimumInput || !this.enabledInput) {
throw new Error('Rendering not complete');
}
return {
enabled: this.enabledInput.checked,
min: this.minimumInput.value,
max: this.maximumInput.value,
};
}
onToggle(event: Event) {
const checked = (event.target as HTMLInputElement).checked;
if (!this.limitsProperty) {
this.limitsProperty = {
enabled: checked,
min: this.absoluteMinimum,
max: this.absoluteMaximum,
};
} else {
this.limitsProperty = {...this.limitsProperty, enabled: checked};
}
this.dispatchLimits();
}
onSetMin() {
this.limitsProperty!.min = this.currentPreviewValue;
this.dispatchLimits();
}
onSetMax() {
this.limitsProperty!.max = this.currentPreviewValue;
this.dispatchLimits();
}
onMinimumInputChange() {
if (this.limitsProperty == null) {
return;
}
this.limitsProperty.min = this.minimumInput.value;
// Push max if needed
this.limitsProperty.max =
Math.max(this.limitsProperty.max, this.limitsProperty.min);
this.dispatchLimits();
}
onMaximumInputChange() {
if (this.limitsProperty == null) {
return;
}
this.limitsProperty.max = this.maximumInput.value;
// Push min if needed
this.limitsProperty.min =
Math.min(this.limitsProperty.max, this.limitsProperty.min);
this.dispatchLimits();
}
render() {
return html`
<me-checkbox
id="limit-enabled"
label=${this.label}
?checked="${!!this.limitsProperty?.enabled}"
@change=${this.onToggle}>
</me-checkbox>
${this.renderLimits()}
`;
}
renderLimits() {
if (!this.limitsProperty?.enabled || !getModelViewer())
return html``;
return html`
<me-section-row label="${this.minimumLabel}">
<div class="LabelRowContent">
<mwc-button id="set-min-button" class="SetButton" unelevated @click="${
this.onSetMin}">Set to ${
this.currentPreviewValue.toFixed(this.decimalPlaces)}</mwc-button>
</div>
</me-section-row>
<me-slider-with-input
class="LimitSlider"
id="minimum"
min=${this.absoluteMinimum} max=${this.absoluteMaximum} step="${
this.sliderStep}"
value=${this.limitsProperty.min}
@change=${this.onMinimumInputChange}>
</me-slider-with-input>
<me-section-row class="MaxLabelRow" label="${this.maximumLabel}">
<div class="LabelRowContent">
<mwc-button id="set-max-button" class="SetButton" unelevated @click="${
this.onSetMax}">Set to ${
this.currentPreviewValue.toFixed(this.decimalPlaces)}</mwc-button>
</div>
</me-section-row>
<me-slider-with-input
class="LimitSlider"
id="maximum"
min=${this.absoluteMinimum} max=${this.absoluteMaximum} step="${
this.sliderStep}"
value=${this.limitsProperty.max}
@change=${this.onMaximumInputChange}>
</me-slider-with-input>
`;
}
}
@@ -0,0 +1,82 @@
/**
* @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 {customElement, state} from 'lit/decorators.js';
import {reduxStore} from '../../../space_opera_base.js';
import {State} from '../../../types.js';
import {dispatchPitchLimits} from '../../config/reducer.js';
import {getModelViewer} from '../../model_viewer_preview/reducer.js';
import {radToDeg} from '../../utils/reducer_utils.js';
import {getIsDirtyCamera} from '../reducer.js';
import {LimitsBase} from './limits_base.js';
// Pitch is degrees from the up-vector.
/** Default minimum pitch angle (degrees) */
export const DEFAULT_MIN_PITCH = 0;
/** Default maximum pitch angle (degrees) */
export const DEFAULT_MAX_PITCH = 180;
/** The Camera Settings panel. */
@customElement('me-camera-pitch-limits')
export class PitchLimits extends LimitsBase {
@state() isDirtyCamera: boolean = false;
stateChanged(state: State) {
this.isDirtyCamera = getIsDirtyCamera(state);
}
dispatchLimits() {
reduxStore.dispatch(dispatchPitchLimits(this.limitsProperty));
}
get label() {
return 'Apply Pitch Limits';
}
get minimumLabel() {
return 'Top-down Limit';
}
get maximumLabel() {
return 'Bottom-up Limit';
}
get absoluteMinimum() {
return DEFAULT_MIN_PITCH;
}
get absoluteMaximum() {
return DEFAULT_MAX_PITCH;
}
get currentPreviewValue() {
const currentOrbit = getModelViewer()!.getCameraOrbit();
return Math.round(radToDeg(currentOrbit.phi));
}
}
declare global {
interface HTMLElementTagNameMap {
'me-camera-pitch-limits': PitchLimits;
}
}
@@ -0,0 +1,81 @@
/**
* @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 {customElement, state} from 'lit/decorators.js';
import {reduxStore} from '../../../space_opera_base.js';
import {State} from '../../../types.js';
import {dispatchYawLimits} from '../../config/reducer.js';
import {getModelViewer} from '../../model_viewer_preview/reducer.js';
import {radToDeg} from '../../utils/reducer_utils.js';
import {getIsDirtyCamera} from '../reducer.js';
import {LimitsBase} from './limits_base.js';
// Yaw is degrees from forward.
/** Default minimum yaw angle (degrees) */
export const DEFAULT_MIN_YAW = -180;
/** Default maximum yaw angle (degrees) */
export const DEFAULT_MAX_YAW = 180;
/** The Camera Settings panel. */
@customElement('me-camera-yaw-limits')
export class YawLimits extends LimitsBase {
@state() isDirtyCamera: boolean = false;
stateChanged(state: State) {
this.isDirtyCamera = getIsDirtyCamera(state);
}
dispatchLimits() {
reduxStore.dispatch(dispatchYawLimits(this.limitsProperty));
}
get label() {
return 'Apply Yaw Limits';
}
get minimumLabel() {
return 'Counter-Clockwise Limit';
}
get maximumLabel() {
return 'Clockwise Limit';
}
get absoluteMinimum() {
return DEFAULT_MIN_YAW;
}
get absoluteMaximum() {
return DEFAULT_MAX_YAW;
}
get currentPreviewValue() {
const currentOrbit = getModelViewer()!.getCameraOrbit();
return Math.round(radToDeg(currentOrbit.theta));
}
}
declare global {
interface HTMLElementTagNameMap {
'me-camera-yaw-limits': YawLimits;
}
}
@@ -0,0 +1,84 @@
/**
* @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 {html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {reduxStore} from '../../../space_opera_base.js';
import {zoomStyles} from '../../../styles.css.js';
import {dispatchSetMinZoom} from '../../config/reducer.js';
import {ConnectedLitElement} from '../../connected_lit_element/connected_lit_element.js';
import {getModelViewer} from '../../model_viewer_preview/reducer.js';
/** The Camera Settings panel. */
@customElement('me-camera-zoom-limits')
export class ZooomLimits extends ConnectedLitElement {
static styles = zoomStyles;
@state() enabled = false;
@state() minRadius?: number = undefined;
@state() minFov?: number = undefined;
onToggle(event: Event) {
this.enabled = (event.target as HTMLInputElement).checked;
if (this.enabled) {
reduxStore.dispatch(dispatchSetMinZoom(this.minFov, this.minRadius));
} else {
reduxStore.dispatch(dispatchSetMinZoom());
}
}
dispatchMin() {
const modelViewer = getModelViewer()!;
this.minFov = modelViewer.getFieldOfView();
const currentOrbit = modelViewer.getCameraOrbit();
this.minRadius = currentOrbit.radius;
reduxStore.dispatch(dispatchSetMinZoom(this.minFov, this.minRadius));
}
dispatchResetMin() {
this.minRadius = undefined;
this.minFov = undefined;
reduxStore.dispatch(dispatchSetMinZoom());
}
render() {
return html`
<me-checkbox
id="limit-enabled"
label="Apply Minimum Zoom"
?checked="${this.enabled}"
@change=${this.onToggle}>
</me-checkbox>
${
this.enabled ? html`
<mwc-button id="set-min-button" class="SetButton" unelevated
@click="${this.dispatchMin}">Set Min</mwc-button>
<mwc-button id="set-min-button" class="SetButton" unelevated icon="undo"
@click="${this.dispatchResetMin}">Reset Min</mwc-button>
` :
html``}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-camera-zoom-limits': ZooomLimits;
}
}
@@ -0,0 +1,37 @@
/**
* @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 {Action, State} from '../../types.js';
// DIRTY CAMERA//////////////
const IS_DIRTY_CAMERA = 'IS_DIRTY_CAMERA';
export function dispatchCameraIsDirty() {
return {type: IS_DIRTY_CAMERA};
}
export const getIsDirtyCamera = (state: State) => state.entities.isDirtyCamera;
export function isDirtyCameraReducer(
state: boolean = false, action: Action): boolean {
switch (action.type) {
case IS_DIRTY_CAMERA:
return !state;
default:
return state;
}
}
@@ -0,0 +1,263 @@
/**
* @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 {SphericalPosition} from '@google/model-viewer/lib/features/controls.js';
import {Action, INITIAL_STATE, ModelViewerConfig, State} from '../../types.js';
import {radToDeg, roundToDigits} from '../utils/reducer_utils.js';
import {Limits, Vector3D} from './types.js';
const DIGITS = 4;
function getMinString(limits: Limits|undefined, suffix: string) {
if (!limits || !limits.enabled) {
return 'auto';
}
return `${roundToDigits(limits.min, DIGITS)}${suffix}`;
}
function getMaxString(limits: Limits|undefined, suffix: string) {
if (!limits || !limits.enabled) {
return 'auto';
}
return `${roundToDigits(limits.max, DIGITS)}${suffix}`;
}
function getUpdatedLimits(
state: ModelViewerConfig, limits: Limits, position: number) {
const DEFAULT = 'auto auto auto';
const suffix = position === 2 ? 'm' : 'deg';
const {minCameraOrbit, maxCameraOrbit} = state;
const min = (minCameraOrbit == null ? DEFAULT : minCameraOrbit).split(' ');
min[position] = getMinString(limits, suffix);
const minString = min.join(' ');
const max = (maxCameraOrbit == null ? DEFAULT : maxCameraOrbit).split(' ');
max[position] = getMaxString(limits, suffix);
const maxString = max.join(' ');
return {
minCameraOrbit: minString === DEFAULT ? undefined : minString,
maxCameraOrbit: maxString === DEFAULT ? undefined : maxString
};
}
export function getOrbitString(orbit: SphericalPosition) {
return `${roundToDigits(radToDeg(orbit.theta), DIGITS)}deg ${
roundToDigits(
radToDeg(orbit.phi),
DIGITS)}deg ${roundToDigits(orbit.radius, DIGITS)}m`;
}
const SET_CAMERA_CONTROLS_ENABLED = 'SET_CAMERA_CONTROLS_ENABLED';
export function dispatchCameraControlsEnabled(enabled?: boolean) {
return {type: SET_CAMERA_CONTROLS_ENABLED, payload: !!enabled};
}
const SET_AUTO_ROTATE = 'SET_AUTO_ROTATE';
export function dispatchAutoRotate(autoRotate?: boolean) {
return {type: SET_AUTO_ROTATE, payload: autoRotate};
}
const SET_AUTOPLAY_ENABLED = 'SET_AUTOPLAY_ENABLED';
export function dispatchAutoplayEnabled(enabled?: boolean) {
return {type: SET_AUTOPLAY_ENABLED, payload: !!enabled};
}
const SET_ANIMATION_NAME = 'SET_ANIMATION_NAME';
export function dispatchAnimationName(animationName?: string) {
return {type: SET_ANIMATION_NAME, payload: animationName};
}
const UPDATE_IBL = 'UPDATE_IBL';
export function dispatchEnvironmentImage(ibl?: string) {
return {type: UPDATE_IBL, payload: ibl};
}
const UPDATE_EXPOSURE = 'UPDATE_EXPOSURE';
export function dispatchExposure(exposure?: number) {
return {type: UPDATE_EXPOSURE, payload: exposure};
}
const UPDATE_TONE_MAPPING = 'UPDATE_TONE_MAPPING';
export function dispatchToneMapping(toneMapping?: string) {
return {type: UPDATE_TONE_MAPPING, payload: toneMapping};
}
const SET_USE_ENV_AS_SKYBOX = 'SET_USE_ENV_AS_SKYBOX';
export function dispatchUseEnvAsSkybox(useEnvAsSkybox?: boolean) {
return {type: SET_USE_ENV_AS_SKYBOX, payload: useEnvAsSkybox};
}
const UPDATE_SHADOW_INTENSITY = 'UPDATE_SHADOW_INTENSITY';
export function dispatchShadowIntensity(shadowIntensity?: number) {
return {type: UPDATE_SHADOW_INTENSITY, payload: shadowIntensity};
}
const UPDATE_SHADOW_SOFTNESS = 'UPDATE_SHADOW_SOFTNESS';
export function dispatchShadowSoftness(shadowSoftness?: number) {
return {type: UPDATE_SHADOW_SOFTNESS, payload: shadowSoftness};
}
const SET_POSTER = 'SET_POSTER';
export function dispatchSetPoster(poster?: string) {
return {type: SET_POSTER, payload: poster};
}
// CURRENTLY UNUSED
const SET_REVEAL = 'SET_REVEAL';
export function dispatchSetReveal(reveal?: string) {
return {type: SET_REVEAL, payload: reveal};
}
// CAMERA //////////////
const SET_CAMERA_YAW_LIMITS = 'SET_CAMERA_YAW_LIMITS';
export function dispatchYawLimits(yawLimitsDeg?: Limits) {
return {type: SET_CAMERA_YAW_LIMITS, payload: yawLimitsDeg};
}
const SET_CAMERA_RADIUS_LIMITS = 'SET_CAMERA_RADIUS_LIMITS';
export function dispatchRadiusLimits(radiusLimits?: Limits) {
return {type: SET_CAMERA_RADIUS_LIMITS, payload: radiusLimits};
}
const SET_CAMERA_PITCH_LIMITS = 'SET_CAMERA_PITCH_LIMITS';
export function dispatchPitchLimits(pitchLimitsDeg?: Limits) {
return {type: SET_CAMERA_PITCH_LIMITS, payload: pitchLimitsDeg};
}
const SET_CAMERA_FOV_LIMITS = 'SET_CAMERA_FOV_LIMITS';
export function dispatchFovLimits(fovLimitsDeg?: Limits) {
return {type: SET_CAMERA_FOV_LIMITS, payload: fovLimitsDeg};
}
const SET_MIN_ZOOM = 'SET_MIN_ZOOM';
export function dispatchSetMinZoom(fovDeg?: number, radius?: number) {
return {
type: SET_MIN_ZOOM, payload: {radius: radius, fov: fovDeg}
}
}
const SAVE_CAMERA_ORBIT = 'SAVE_CAMERA_ORBIT';
export function dispatchSaveCameraOrbit(orbit?: SphericalPosition) {
return {type: SAVE_CAMERA_ORBIT, payload: orbit};
}
const SAVE_CAMERA_FOV = 'SAVE_CAMERA_FOV';
export function dispatchSaveCameraFov(fov?: number) {
return {type: SAVE_CAMERA_FOV, payload: fov};
}
const SET_CAMERA_TARGET = 'SET_CAMERA_TARGET';
export function dispatchCameraTarget(target?: Vector3D) {
return {type: SET_CAMERA_TARGET, payload: target};
}
const SET_CONFIG = 'SET_CONFIG';
export function dispatchConfig(config: ModelViewerConfig) {
return {type: SET_CONFIG, payload: config};
}
export const getConfig = (state: State) =>
state.entities.modelViewerSnippet.config;
export function configReducer(
state: ModelViewerConfig = INITIAL_STATE.entities.modelViewerSnippet.config,
action: Action): ModelViewerConfig {
switch (action.type) {
case SET_CONFIG:
return action.payload;
case SET_REVEAL:
return {...state, reveal: action.payload};
case SET_POSTER:
return {...state, poster: action.payload};
case UPDATE_SHADOW_SOFTNESS:
return {...state, shadowSoftness: action.payload};
case UPDATE_SHADOW_INTENSITY:
return {...state, shadowIntensity: action.payload};
case SET_USE_ENV_AS_SKYBOX:
return {...state, useEnvAsSkybox: action.payload};
case UPDATE_EXPOSURE:
return {...state, exposure: action.payload};
case UPDATE_TONE_MAPPING:
return {...state, toneMapping: action.payload};
case UPDATE_IBL:
return {...state, environmentImage: action.payload};
case SET_AUTOPLAY_ENABLED:
return {...state, autoplay: action.payload};
case SET_ANIMATION_NAME:
return {...state, animationName: action.payload};
case SET_CAMERA_CONTROLS_ENABLED:
return {...state, cameraControls: action.payload};
case SET_AUTO_ROTATE:
return {...state, autoRotate: action.payload};
case SET_CAMERA_TARGET:
const target = action.payload;
const cameraTarget = target == null ?
undefined :
`${roundToDigits(target.x, DIGITS)}m ${
roundToDigits(
target.y, DIGITS)}m ${roundToDigits(target.z, DIGITS)}m`;
return {...state, cameraTarget};
case SAVE_CAMERA_ORBIT:
const orbit = action.payload;
const cameraOrbit = orbit == null ? undefined : getOrbitString(orbit);
return {...state, cameraOrbit};
case SAVE_CAMERA_FOV:
const fieldOfView = action.payload == null ?
undefined :
`${roundToDigits(action.payload, DIGITS)}deg`;
return {...state, fieldOfView};
case SET_CAMERA_FOV_LIMITS:
return {
...state,
minFov: getMinString(action.payload, 'deg'),
maxFov: getMaxString(action.payload, 'deg')
};
case SET_CAMERA_PITCH_LIMITS:
return {...state, ...getUpdatedLimits(state, action.payload, 1)};
case SET_CAMERA_RADIUS_LIMITS:
return {...state, ...getUpdatedLimits(state, action.payload, 2)};
case SET_CAMERA_YAW_LIMITS:
return {...state, ...getUpdatedLimits(state, action.payload, 0)};
case SET_MIN_ZOOM:
const orbitLimits = getUpdatedLimits(
state,
{
enabled: action.payload.fov != null,
min: action.payload.radius,
max: -1
},
2);
const minFov = getMinString(
{
enabled: action.payload.fov != null,
min: action.payload.fov,
max: -1
},
'deg');
return {
...state,
minCameraOrbit: orbitLimits.minCameraOrbit,
minFov: minFov === 'auto' ? undefined : minFov
};
default:
return state;
}
}
@@ -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.
*
*/
// We may want to use shared model-viewer types later (b/155754690), but really
// that's not necessary. They technically aren't even the same, because here we
// want them to be read-only.
/** A 3D Cartesian coordinate */
export interface Vector3D {
x: number;
y: number;
z: number;
}
/**
* Min/max limits. We have 'enabled' here so we can disable the limit, but not
* lose the values if the user wants them back. We could support single-sided
* limits with 2 booleans, but there's no use case right now.
*/
export interface Limits {
enabled: boolean;
min: number;
max: number;
}
@@ -0,0 +1,36 @@
/**
* @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.
*
*/
//@ts-ignore
import {$CombinedState} from '@reduxjs/toolkit';
import {LitElement} from 'lit';
import {connect} from 'pwa-helpers/connect-mixin';
import {reduxStore} from '../../space_opera_base.js';
// This is only here because we can't export this directly.
const connectedLitElementInternal = connect(reduxStore)(LitElement);
/**
* Extending this class means your element will have its "stateChanged" method
* invoked any time SpaceOpera's Redux store is changed.
*/
export class ConnectedLitElement extends connectedLitElementInternal {
get storeState() {
return reduxStore.getState();
}
}
@@ -0,0 +1,82 @@
/**
* @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 '../shared/expandable_content/expandable_tab.js';
import '../shared/section_row/section_row.js';
import '@material/mwc-icon-button';
import {html, LitElement, PropertyValues} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
import {reduxStore} from '../../space_opera_base.js';
import {hotspotEditorStyles} from '../../styles.css.js';
import {dispatchRemoveHotspot, dispatchUpdateHotspot} from './reducer.js';
import {HotspotConfig} from './types.js';
/** A editor card for a single hotspot */
@customElement('me-hotspot-editor')
export class HotspotEditorElement extends LitElement {
static styles = hotspotEditorStyles;
@property({type: Object}) config?: HotspotConfig;
@query('textarea#annotation') annotationInput!: HTMLTextAreaElement;
updated(properties: PropertyValues) {
if (properties.has('config')) {
this.annotationInput.value = this.config?.annotation || '';
}
}
render() {
if (!this.config)
return html``;
return html`
<me-section-row label="Label:">
<textarea id="annotation" @input=${this.onAnnotationInput}>${
this.config.annotation}</textarea>
<mwc-icon-button id="remove-hotspot"
icon="delete"
@click="${this.onRemoveHotspot}"></mwc-icon-button>
</me-section-row>
`;
}
onAnnotationInput() {
if (!this.annotationInput)
return;
const newConfig = {
...this.config,
annotation: this.annotationInput.value
} as HotspotConfig;
reduxStore.dispatch(dispatchUpdateHotspot(newConfig));
}
onRemoveHotspot() {
if (!this.config) {
throw new Error('Invalid config');
}
reduxStore.dispatch(dispatchRemoveHotspot(this.config.name));
}
}
declare global {
interface HTMLElementTagNameMap {
'me-hotspot-editor': HotspotEditorElement;
}
}
@@ -0,0 +1,71 @@
/**
* @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 '../shared/expandable_content/expandable_tab.js';
import './hotspot_editor.js';
import '@material/mwc-button';
import {html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {reduxStore} from '../../space_opera_base.js';
import {State} from '../../types.js';
import {ConnectedLitElement} from '../connected_lit_element/connected_lit_element.js';
import {dispatchUpdateHotspotMode, getHotspotMode, getHotspots} from './reducer.js';
import {HotspotConfig} from './types.js';
/** Hotspot panel. */
@customElement('me-hotspot-panel')
export class HotspotPanel extends ConnectedLitElement {
@state() addHotspotMode = false;
@state() hotspots: HotspotConfig[] = [];
stateChanged(state: State) {
this.addHotspotMode = getHotspotMode(state) || false;
this.hotspots = getHotspots(state);
}
onAddHotspot() {
reduxStore.dispatch(dispatchUpdateHotspotMode(true));
}
render() {
return html`
<me-expandable-tab tabName="Hotspots">
<span slot="content">
${
this.hotspots.map(
(hotspot) => html`<me-hotspot-editor .config="${
hotspot}"></me-hotspot-editor>`)}
${
this.addHotspotMode ?
html`<div> Click on the model in the preview to add a hotspot.</div>` :
html`<mwc-button unelevated icon="add_circle"
id="add-hotspot" @click="${this.onAddHotspot}">Add hotspot
</mwc-button>`}
</span>
</me-expandable-tab>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-hotspot-panel': HotspotPanel;
}
}
@@ -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 {Action, State} from '../../types.js';
import {immutableArrayUpdate} from '../utils/reducer_utils.js';
import {HotspotConfig} from './types.js';
let nextHotspotId = 1;
let hotspotNameSet = new Set();
/** Generates a unique hotspot name */
export function generateUniqueHotspotName() {
let name = (nextHotspotId++).toString();
while (hotspotNameSet.has(name)) {
name = (nextHotspotId++).toString();
}
return name;
}
/**
* Helper function to find the index of hotspot with given name, throws an
* Error if not found.
*/
function findHotspotIndex(hotspots: HotspotConfig[], name: string) {
const index = hotspots.findIndex((hotspot) => hotspot.name === name);
if (index === -1) {
throw new Error(`Hotspot name doesn't exist: ${name}`);
}
return index;
}
function addHotspot(state: HotspotConfig[], config: HotspotConfig) {
if (hotspotNameSet.has(config.name)) {
throw new Error(`Hotspot name duplicate: ${config.name}`);
}
hotspotNameSet.add(config.name);
const hotspots = [...(state ?? []), config];
return hotspots;
}
function updateHotspot(state: HotspotConfig[], config: HotspotConfig) {
const index = findHotspotIndex(state, config.name);
const hotspots = immutableArrayUpdate(state, index, config);
return hotspots;
}
function removeHotspot(state: HotspotConfig[], name: string) {
const index = findHotspotIndex(state, name);
const hotspots = [...state];
hotspots.splice(index, 1);
hotspotNameSet.delete(name);
return hotspots;
}
const SET_HOTSPOTS = 'SET_HOTSPOTS';
export function dispatchSetHotspots(hotspots: HotspotConfig[]) {
hotspotNameSet = new Set(hotspots.map(hotspot => hotspot.name));
nextHotspotId = 1;
return {type: SET_HOTSPOTS, payload: hotspots};
}
const CLEAR_HOTSPOTS = 'CLEAR_HOTSPOTS';
export function dispatchClearHotspot() {
hotspotNameSet.clear();
nextHotspotId = 1;
return {type: CLEAR_HOTSPOTS, payload: []};
}
const REMOVE_HOTSPOT = 'REMOVE_HOTSPOT';
export function dispatchRemoveHotspot(name?: string) {
return {type: REMOVE_HOTSPOT, payload: name};
}
const UPDATE_HOTSPOT = 'UPDATE_HOTSPOT';
export function dispatchUpdateHotspot(config?: HotspotConfig) {
return {type: UPDATE_HOTSPOT, payload: config};
};
const ADD_HOTSPOT = 'ADD_HOTSPOT';
export function dispatchAddHotspot(config?: HotspotConfig) {
return {type: ADD_HOTSPOT, payload: config};
}
export const getHotspots = (state: State) =>
state.entities.modelViewerSnippet.hotspots;
export function hotspotsReducer(
state: HotspotConfig[] = [], action: Action): HotspotConfig[] {
switch (action.type) {
case SET_HOTSPOTS:
return action.payload;
case CLEAR_HOTSPOTS:
return action.payload;
case REMOVE_HOTSPOT:
return removeHotspot(state, action.payload);
case UPDATE_HOTSPOT:
return updateHotspot(state, action.payload);
case ADD_HOTSPOT:
return addHotspot(state, action.payload);
default:
return state;
}
}
const UPDATE_HOTSPOT_MODE = 'UPDATE_HOTSPOT_MODE';
export function dispatchUpdateHotspotMode(addHotspot: boolean) {
return {type: UPDATE_HOTSPOT_MODE, payload: addHotspot};
}
export const getHotspotMode = (state: State) => state.ui.hotspots.addHotspot;
export function hotspotsUiReducer(
state = {
addHotspot: false
},
action: Action) {
switch (action.type) {
case UPDATE_HOTSPOT_MODE:
return {
...state, addHotspot: action.payload
}
default:
return state;
}
}
@@ -0,0 +1,26 @@
/**
* @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.
*
*/
/** Config for a single hotspot */
export interface HotspotConfig {
// Name of the hotspot, needs to be unique among all hotspots.
name: string;
surface?: string;
position?: string;
normal?: string;
annotation?: string;
}
@@ -0,0 +1,226 @@
/**
* @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 '../shared/expandable_content/expandable_tab.js';
import '../shared/dropdown/dropdown.js';
import '../shared/section_row/section_row.js';
import '../shared/slider_with_input/slider_with_input.js';
import '../shared/checkbox/checkbox.js';
import '@polymer/paper-item';
import '@material/mwc-button';
import {html} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {reduxStore} from '../../space_opera_base.js';
import {fileModalStyles, iblSelectorStyles} from '../../styles.css.js';
import {ModelViewerConfig, State} from '../../types.js';
import {dispatchEnvironmentImage, dispatchExposure, dispatchShadowIntensity, dispatchShadowSoftness, dispatchToneMapping, dispatchUseEnvAsSkybox, getConfig} from '../config/reducer.js';
import {ConnectedLitElement} from '../connected_lit_element/connected_lit_element.js';
import {dispatchSetEnvironmentName} from '../relative_file_paths/reducer.js';
import {CheckboxElement} from '../shared/checkbox/checkbox.js';
import {Dropdown} from '../shared/dropdown/dropdown.js';
import {SliderWithInputElement} from '../shared/slider_with_input/slider_with_input.js';
import {IMAGE_MIME_TYPES} from '../utils/gltf_constants.js';
import {createBlobUrlFromEnvironmentImage, dispatchAddEnvironmentImage, getEnvironmentImages} from './reducer.js';
import {DEFAULT_EXPOSURE, DEFAULT_SHADOW_INTENSITY, DEFAULT_SHADOW_SOFTNESS, EnvironmentImage} from './types.js';
const ACCEPT_IMAGE_TYPE = IMAGE_MIME_TYPES.join(',') + ',.hdr';
/**
* IBL environment selector.
*/
@customElement('me-ibl-selector')
export class IblSelector extends ConnectedLitElement {
static styles = [iblSelectorStyles, fileModalStyles];
@state() config: ModelViewerConfig = {};
@state() environmentImages: EnvironmentImage[] = [];
@query('me-slider-with-input#exposure')
exposureSlider!: SliderWithInputElement;
@query('input#imageUpload') fileInput!: HTMLInputElement;
@query('me-checkbox#skybox') skyboxCheckbox!: CheckboxElement;
@query('me-slider-with-input#shadow-intensity')
shadowIntensitySlider!: SliderWithInputElement;
@query('me-slider-with-input#shadow-softness')
shadowSoftnessSlider!: SliderWithInputElement;
// Specifically overriding a super class method.
// tslint:disable-next-line:enforce-name-casing
async getUpdateComplete() {
const noTrigger = await super.getUpdateComplete();
await this.exposureSlider.updateComplete;
await this.skyboxCheckbox.updateComplete;
await this.shadowIntensitySlider.updateComplete;
await this.shadowSoftnessSlider.updateComplete;
return noTrigger;
}
stateChanged(state: State) {
this.config = getConfig(state);
this.environmentImages = getEnvironmentImages(state);
}
onSelectEnvironmentImage(event: CustomEvent) {
const dropdownElement = event.target as Dropdown;
// Polymer dropdown emits an deselect event before selection, we need to
// filter that out
const value = dropdownElement.selectedItem.getAttribute('value');
if (value !== this.config.environmentImage) {
reduxStore.dispatch(dispatchEnvironmentImage(value || undefined));
// dropdown value equals null when "Default" is selected
if (value === null) {
reduxStore.dispatch(dispatchSetEnvironmentName(undefined));
} else {
const envImageList = value.split('/');
const envImageName = envImageList![envImageList!.length - 1];
reduxStore.dispatch(dispatchSetEnvironmentName(envImageName));
}
}
}
onSelectToneMapping(event: CustomEvent) {
const dropdownElement = event.target as Dropdown;
// Polymer dropdown emits an deselect event before selection, we need to
// filter that out
const value = dropdownElement.selectedItem.getAttribute('value');
if (value !== this.config.toneMapping) {
reduxStore.dispatch(dispatchToneMapping(value || undefined));
}
}
onExposureChange() {
reduxStore.dispatch(dispatchExposure(this.exposureSlider.value));
}
onUseEnvAsSkyboxChange() {
reduxStore.dispatch(dispatchUseEnvAsSkybox(this.skyboxCheckbox.checked));
}
onShadowIntensityChange() {
reduxStore.dispatch(
dispatchShadowIntensity(this.shadowIntensitySlider.value));
}
onShadowSoftnessChange() {
reduxStore.dispatch(
dispatchShadowSoftness(this.shadowSoftnessSlider.value));
}
// TODO:: Add test to this.
async onUploadHDR() {
const files = this.fileInput.files;
if (!files) {
return;
}
const file = files[0] as File;
const unsafeUrl = await createBlobUrlFromEnvironmentImage(file);
reduxStore.dispatch(
dispatchAddEnvironmentImage({uri: unsafeUrl, name: file.name}));
reduxStore.dispatch(dispatchEnvironmentImage(unsafeUrl));
reduxStore.dispatch(dispatchSetEnvironmentName(file.name));
}
// TODO: On snippet input if IBL is defined, select the
// correct option from the dropdown.
render() {
const value = this.config.environmentImage;
const selectedIndex = value == null ?
0 :
value === 'legacy' ?
1 :
2 + this.environmentImages.findIndex((image) => image.uri === value);
return html`
<me-expandable-tab tabName="Lighting" .open=${true}>
<div slot="content">
<div class="HeaderLabel">Environment Image:</div>
<div style="display: flex; justify-content: space-between">
<me-dropdown
class="EnvironmentImageDropdown"
selectedIndex=${selectedIndex}
style="align-self: center; width: 70%;"
@select=${this.onSelectEnvironmentImage}>
<paper-item>Neutral</paper-item>
<paper-item value="legacy">Legacy</paper-item>
${
this.environmentImages.map(
environmentImage => html`<paper-item value=${
environmentImage.uri}>${environmentImage.name}</paper-item>`)}
</me-dropdown>
<mwc-button unelevated label="HDR" icon="file_upload" class="UploadButton UploadHDRButton">
<label for="imageUpload" class="FileInputLabel"/>
</mwc-button>
<input type="file" id="imageUpload" accept=${
ACCEPT_IMAGE_TYPE} @change="${this.onUploadHDR}"/>
</div>
<me-section-row class="Row" label="Exposure">
<me-slider-with-input min="0" max="2" step="0.01" id="exposure"
@change="${this.onExposureChange}"
value="${this.config.exposure ?? DEFAULT_EXPOSURE}">
</me-slider-with-input>
</me-section-row>
<me-checkbox
id="skybox"
label="Use Environment as Skybox"
?checked="${!!this.config.useEnvAsSkybox}"
@change=${this.onUseEnvAsSkyboxChange}
>
</me-checkbox>
${
selectedIndex === 0 && this.config.useEnvAsSkybox ?
html`<div class="defaultError"><small>Choose a non-default environment</small></div>` :
html``}
<me-section-row class="Row" label="Shadow Intensity">
<me-slider-with-input min="0" max="2" step="0.01" id="shadow-intensity"
@change="${this.onShadowIntensityChange}"
value="${
this.config.shadowIntensity ?? DEFAULT_SHADOW_INTENSITY}">
</me-slider-with-input>
</me-section-row>
<me-section-row class="Row" label="Shadow Softness">
<me-slider-with-input min="0" max="1" step="0.01" id="shadow-softness"
@change="${this.onShadowSoftnessChange}"
value="${this.config.shadowSoftness ?? DEFAULT_SHADOW_SOFTNESS}">
</me-slider-with-input>
</me-section-row>
<me-section-row class="Row" label="Tone Mapping">
<me-dropdown
style="align-self: center; width: 70%;"
@select=${this.onSelectToneMapping}>
<paper-item value="neutral">Neutral</paper-item>
<paper-item value="aces">ACES</paper-item>
<paper-item value="agx">AgX</paper-item>
</me-dropdown>
</me-section-row>
</div>
</me-expandable-tab>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-ibl-selector': IblSelector;
}
}
@@ -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 {Action, EnvironmentState, State} from '../../types.js';
import {createSafeObjectUrlFromArrayBuffer} from '../utils/create_object_url.js';
import {EnvironmentImage, INITIAL_ENVIRONMENT_IMAGES} from './types.js';
/**
* Creates a blob url from an uploaded file, should be used only for
* environment image.
*/
export async function createBlobUrlFromEnvironmentImage(file: File) {
const arrayBuffer = await file.arrayBuffer();
const safeObjectUrl = createSafeObjectUrlFromArrayBuffer(arrayBuffer);
const unsafeUrl = file.name.match(/\.(hdr)$/i) ?
safeObjectUrl.unsafeUrl + '#.hdr' :
safeObjectUrl.unsafeUrl;
return unsafeUrl;
}
// EnvironmentImages //////////////////
/** Dispatch an edit to potential environment images to select. */
const UPLOAD_ENVIRONMENT_IMAGE = 'UPLOAD_ENVIRONMENT_IMAGE';
export function dispatchAddEnvironmentImage(image?: EnvironmentImage) {
return {type: UPLOAD_ENVIRONMENT_IMAGE, payload: image};
}
function addEnvironmentImage(
state: EnvironmentImage[], image: EnvironmentImage) {
const environmentImages = [...state, image];
return environmentImages;
}
export const getEnvironmentImages = (state: State) =>
state.entities.environment.environmentImages;
export function environmentReducer(
state: EnvironmentState = {
environmentImages: INITIAL_ENVIRONMENT_IMAGES
},
action: Action): EnvironmentState {
switch (action.type) {
case UPLOAD_ENVIRONMENT_IMAGE:
return {
environmentImages:
addEnvironmentImage(state.environmentImages, action.payload)
};
default:
return state;
}
}
@@ -0,0 +1,55 @@
/**
* @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.
*
*/
/**
* An environment image that can be used in model-viewer environment attribute
*/
export interface EnvironmentImage {
readonly uri: string;
// The display name of the environment image to users
readonly name?: string;
}
/** Default exposure for model-viewer */
export const DEFAULT_EXPOSURE = 1.0;
/** Default shadow intensity for model-viewer */
export const DEFAULT_SHADOW_INTENSITY = 0;
/** Default shadow softness for model-viewer */
export const DEFAULT_SHADOW_SOFTNESS = 1;
export const INITIAL_ENVIRONMENT_IMAGES: EnvironmentImage[] = [
{
uri: '../shared-assets/environments/spruit_sunrise_1k_HDR.hdr',
name: 'Spruit Sunrise'
},
{
uri: '../shared-assets/environments/aircraft_workshop_01_1k.hdr',
name: 'Aircraft Workshop'
},
{
uri: '../shared-assets/environments/music_hall_01_1k.hdr',
name: 'Music Hall'
},
{uri: '../shared-assets/environments/pillars_1k.hdr', name: 'Pillars'},
{
uri: '../shared-assets/environments/whipple_creek_regional_park_04_1k.hdr',
name: 'Whipple Creek'
},
];
@@ -0,0 +1,70 @@
/**
* @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.
*
*/
/**
* @fileoverview Inspector panel for GLTF/GLB files.
*/
import {html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {State} from '../../types.js';
import {ConnectedLitElement} from '../connected_lit_element/connected_lit_element.js';
import {getModel, isLoaded} from '../model_viewer_preview/reducer.js';
import {Thumbnail} from '../model_viewer_preview/types.js';
/**
* The inspector.
*/
@customElement('me-inspector-panel')
export class InspectorPanel extends ConnectedLitElement {
@state() gltfJsonstring?: string = '';
@state() thumbnailsById = new Map<string, Thumbnail>();
stateChanged(state: State) {
if (!isLoaded(state)) {
return;
}
const model = getModel(state);
if (model.thumbnailsById != null) {
this.thumbnailsById = model.thumbnailsById;
}
this.gltfJsonstring = model.originalGltfJson;
}
render() {
const thumbnails = [...this.thumbnailsById.values()];
return html`
<div>
<div class="texture-images">
${
thumbnails.map(
thumbnail => html`<img width="100" src="${thumbnail.objectUrl}">`)}
</div>
<pre class="inspector-content">${
this.gltfJsonstring || 'No model loaded'}
</pre>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-inspector-panel': InspectorPanel;
}
}
@@ -0,0 +1,83 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`.EditingMaterial {
padding-bottom: 5px;
}
.MaterialInfo {
display: flex;
flex-direction: column;
padding-left: 15px;
}
.TexturePickerContainer {
width: 100%;
}
.RevertButton {
float: right;
--mdc-icon-button-size: 32px;
}
.TexturePickerContainer .RevertButton {
padding: 9px 0;
}
.CheckboxContainer .RevertButton {
padding: 4px 0;
}
.DropdownContainer .RevertButton {
padding: 6px 0;
}
.MRSliderLabel {
font-size: 14px;
font-weight: 500;
}
.MRSlider {
margin-left: -15px;
width: calc(100% - 32px);
}
.MRSliders {
display: flex;
flex-direction: column;
padding: 15px 0 5px 0;
}
.SectionLabel {
font-size: 14px;
font-weight: 500;
}
.DropdownContainer me-dropdown {
width: calc(100% - 40px);
}
.TopMargin {
margin-top: 10px;
}
.EditableSelector {
display: flex;
}
`;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,129 @@
/**
* @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 {html, LitElement, TemplateResult} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
import {ArConfigState} from '../../../types';
import {CheckboxElement} from '../../shared/checkbox/checkbox';
/**
* The delegated rendered component for the open mobile view.
*/
@customElement('mobile-expandable-section')
export class MobileExpanadableSection extends LitElement {
@property({type: Boolean}) isDeployed?: boolean;
@property({type: Boolean}) isDeployable?: boolean;
@property({attribute: false}) onInitialDeploy?: Function;
@property({type: Boolean}) haveReceivedResponse?: boolean;
@property({type: Boolean}) isSendingData?: boolean;
@property({type: Boolean}) contentHasChanged?: boolean;
@property({attribute: false}) openModal?: Function;
@property({attribute: false}) postInfo?: Function;
@property({type: Boolean}) defaultToSceneViewer?: boolean;
@property({attribute: false}) onSelectArMode?: Function;
@property() arConfig?: ArConfigState;
@query('me-checkbox#ar') arCheckbox!: CheckboxElement;
@query('me-checkbox#ar-modes') arModesCheckbox!: CheckboxElement;
renderDeployButton(): TemplateResult {
return html`
<mwc-button unelevated
icon="file_download"
?disabled=${!this.isDeployable}
@click=${this.onInitialDeploy}>
Deploy Mobile
</mwc-button>`;
}
get optionalMessage(): TemplateResult {
const isOutOfSync = this.haveReceivedResponse &&
(!this.isSendingData && this.contentHasChanged);
if (isOutOfSync) {
return html`
<div style="color: #DC143C; margin-top: 5px;">
Your mobile view is out of sync with the editor.
</div>`;
} else if (this.isSendingData) {
return html`
<div style="color: white; margin-top: 5px;">
Sending data to mobile device. Textured models will take some time.
</div>`;
} else if (!this.haveReceivedResponse) {
return html`
<div style="color: white; margin-top: 5px;">
Use the QR Code to open the mobile viewing page on a mobile device.
</div>`;
}
return html``;
}
selectArMode() {
this.onSelectArMode!(this.arModesCheckbox.checked);
}
renderMobileInfo() {
const isOutOfSync = this.haveReceivedResponse &&
(!this.isSendingData && this.contentHasChanged);
const outOfSyncColor = isOutOfSync ? '#DC143C' : '#4285F4';
return html`
<div>
<mwc-button unelevated @click=${
this.openModal} style="margin-bottom: 10px;">
View QR Code
</mwc-button>
<mwc-button unelevated icon="cached" @click=${this.postInfo}
?disabled=${
!this.haveReceivedResponse || this.isSendingData}
style="--mdc-theme-primary: ${outOfSyncColor}">
Refresh Mobile
</mwc-button>
${this.optionalMessage}
</div>
`;
}
renderAR() {
return html`
<me-checkbox
id="ar-modes"
label="Default AR Mode to Scene Viewer"
?checked="${this.defaultToSceneViewer}"
@change=${this.selectArMode}
>
</me-checkbox>
`;
}
render() {
return html`
${!this.isDeployed ? this.renderDeployButton() : html``}
${this.isDeployed ? this.renderMobileInfo() : html``}
${this.renderAR()}
`
}
}
declare global {
interface HTMLElementTagNameMap {
'mobile-expandable-section': MobileExpanadableSection;
}
}
@@ -0,0 +1,89 @@
/**
* @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 {html} from 'lit';
import {customElement, state, property, query} from 'lit/decorators.js';
// @ts-ignore, the qrious package isn't typed
import QRious from 'qrious';
import {openModalStyles} from '../../../styles.css.js';
import {ConnectedLitElement} from '../../connected_lit_element/connected_lit_element.js';
/**
* Modal for displaying the QR Code & link
*/
@customElement('mobile-modal')
export class MobileModal extends ConnectedLitElement {
static styles = openModalStyles;
@property({type: Number}) pipeId = 0;
@state() isOpen: boolean = false;
@state() isNewQRCode = true;
@query('canvas#qr') canvasQR!: HTMLCanvasElement;
get viewableSite(): string {
const path = window.location.origin + window.location.pathname;
return `${path}view/?id=${this.pipeId}`;
}
open() {
if (this.isNewQRCode) {
new QRious({element: this.canvasQR, value: this.viewableSite, size: 200});
this.isNewQRCode = false
}
this.isOpen = true;
}
close() {
this.isOpen = false;
}
render() {
return html`
<paper-dialog id="file-modal" modal ?opened=${this.isOpen} class="dialog">
<div class="FileModalContainer">
<div class="FileModalHeader">
<div>Mobile View</div>
</div>
<div class="modal-text">
Use the QR Code to open your edited model, environment image, and &ltmodel-viewer&gt snippet on a mobile device to test out AR features.
After every subsequent change, click the "Refresh Mobile" button.
</div>
<canvas id="qr" style="display: block; margin-bottom: 10px;"></canvas>
<div class="modal-text" style="margin-bottom: 80px;">
<a href="${this.viewableSite}" target="_blank" style="color: white">${this.viewableSite}</a>
</div>
<div class="modal-text">
This uses a third-party <a href="https://github.com/nwtgck/piping-server" target="_blank" class="piping-link">piping server</a> to deploy to your mobile device. This server does not store data.
</div>
<div class="modal-text">
We use this server: <a href="https://ppng.io/" target="_blank" class="piping-link">https://ppng.io/</a>
</div>
</div>
<div class="FileModalCancel">
<mwc-button unelevated icon="cancel"
@click=${this.close}>Close</mwc-button>
</div>
</paper-dialog>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'mobile-modal': MobileModal;
}
}
@@ -0,0 +1,270 @@
/**
* @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 {ModelViewerElement} from '@google/model-viewer/lib/model-viewer';
import {html, LitElement} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {ifDefined} from 'lit/directives/if-defined.js';
import {toastStyles} from '../../styles.css.js';
import {timePasses} from '../../test/utils/test_utils.js';
import {ArConfigState, BestPracticesState, ModelViewerConfig, ModelViewerSnippetState} from '../../types.js';
import {arButtonCSS, arPromptCSS, progressBarCSS} from '../best_practices/styles.css.js';
import {HotspotConfig} from '../hotspot_panel/types.js';
import {renderCommonChildElements} from '../model_viewer_preview/reducer.js';
import {styles as hotspotStyles} from '../utils/hotspot/hotspot.css.js';
import {spread} from '../utils/spread_directive';
import {styles as mobileStyles} from './styles.css.js';
import {EditorUpdates, MobilePacket, MobileSession, URLs} from './types.js';
import {envToSession, getMobileOperatingSystem, getPingUrl, getRandomInt, getSessionUrl, getWithTimeout, gltfToSession, post, posterToSession} from './utils.js';
const TOAST_TIME = 3000; // 3s
/**
* The view loaded at /editor/view/?id=xyz
* The id links the editor to this mobile session.
*/
@customElement('mobile-view')
export class MobileView extends LitElement {
static styles = [
mobileStyles,
toastStyles,
hotspotStyles,
arButtonCSS,
progressBarCSS,
arPromptCSS
];
@query('model-viewer') readonly modelViewer!: ModelViewerElement;
@state() modelViewerUrl: string = '';
@state() posterUrl: string = '';
@state() currentBlob?: Blob;
@state() editorUrls?: URLs;
@state() config: ModelViewerConfig = {};
@state() arConfig: ArConfigState = {};
@state() extraAttributes: any = {};
@state() hotspots: HotspotConfig[] = [];
@state() bestPractices?: BestPracticesState;
@state() envImageUrl: string|undefined;
@state() pipeId = window.location.search.replace('?id=', '');
@state() mobilePingUrl = getPingUrl(this.pipeId);
@state() toastClassName: string = '';
@state() toastBody: string = '';
@query('div#overlay') overlay?: HTMLElement;
@state() sessionId = getRandomInt(1e+20);
@state() sessionUrl = getSessionUrl(this.pipeId, this.sessionId);
@state() sessionOs = getMobileOperatingSystem();
updateState(snippet: ModelViewerSnippetState, urls: URLs) {
this.editorUrls = urls;
this.hotspots = snippet.hotspots;
// Set all of the other relevant snippet information
this.arConfig = snippet.arConfig;
this.config = snippet.config;
this.extraAttributes = snippet.extraAttributes;
this.bestPractices = snippet.bestPractices;
}
repostGLTF = () => {
try {
if (this.sessionOs === 'Android') {
post(this.currentBlob!, this.modelViewerUrl);
}
} catch (error) {
console.log('Post failed on ar button press...');
}
};
// We set modelViewerUrl instead of directly fetching it because
// scene-viewer requires the same url from the current model-viewer state,
// and we need to make a POST request to that URL when scene-viewer is
// triggered.
waitForData(json: MobilePacket) {
const updatedContent: EditorUpdates = json.updatedContent;
this.overlay!.style.display = 'block';
this.updateState(json.snippet, json.urls);
this.posterUrl =
posterToSession(this.pipeId, this.sessionId, updatedContent.posterId);
if (updatedContent.gltfChanged) {
this.modelViewerUrl =
gltfToSession(this.pipeId, this.sessionId, updatedContent.gltfId);
}
const {environmentImage} = this.config;
this.envImageUrl = environmentImage == null ||
environmentImage === 'neutral' || environmentImage === 'legacy' ?
environmentImage :
envToSession(this.pipeId, this.sessionId, updatedContent.envIsHdr);
const arButton =
this.modelViewer.shadowRoot!.getElementById('default-ar-button')!;
arButton.removeEventListener('click', this.repostGLTF);
if (this.sceneViewerMode()) {
// Send a new POST out for each scene-viewer button press
arButton.addEventListener('click', this.repostGLTF);
}
this.overlay!.style.display = 'none';
}
initializeToast(json: EditorUpdates) {
let body = json.gltfChanged ? 'gltf model, ' : '';
body = json.envChanged ? body.concat('environment image, ') : body;
body = json.stateChanged ? body.concat('snippet, ') : body;
body = body.slice(0, body.length - 2).concat('.');
this.toastBody = `Loading ${body}`;
this.toastClassName = 'show';
}
// Keep listening for a new update from the editor.
async fetchLoop() {
const response = await getWithTimeout(this.sessionUrl);
if (response.ok) {
this.modelViewer.showPoster();
const json: MobilePacket = await response.json();
this.initializeToast(json.updatedContent);
setTimeout(() => {
this.toastClassName = '';
}, TOAST_TIME);
this.waitForData(json);
await this.updateComplete;
} else {
console.error('Error:', response);
await timePasses(1000);
}
}
async triggerFetchLoop() {
try {
await this.fetchLoop();
} catch (error) {
console.log('error...', error);
await timePasses(1000);
}
await this.triggerFetchLoop();
}
sceneViewerMode() {
return this.arConfig.ar &&
this.arConfig.arModes?.split(' ')[0] === 'scene-viewer';
}
// When the model is loaded, we make a post for this specific model for
// scene-viewer. Subsequently, everytime scene-viewer is opened, we send the
// POST again.
async modelIsLoaded() {
if (!this.sceneViewerMode()) {
return;
}
this.currentBlob = await this.modelViewer.exportScene();
try {
await post(this.currentBlob, this.modelViewerUrl);
} catch (error) {
console.log('Post failed on model loaded...');
}
}
render() {
const config = this.config;
const skyboxImage = (config.useEnvAsSkybox && this.editorUrls?.env) ?
this.envImageUrl :
undefined;
// Renders elements common between mobile and editor.
const childElements =
renderCommonChildElements(this.hotspots, this.bestPractices!, false);
return html`
<div id="overlay"></div>
<div class="app">
<div class="mvContainer">
<model-viewer ${spread(this.extraAttributes)}
src=${this.modelViewerUrl}
?ar=${ifDefined(!!this.arConfig.ar)}
ar-modes=${ifDefined(this.arConfig!.arModes)}
?autoplay=${!!config.autoplay}
?auto-rotate=${!!config.autoRotate}
?camera-controls=${!!config.cameraControls}
environment-image=${ifDefined(this.envImageUrl)}
skybox-image=${ifDefined(skyboxImage)}
exposure=${ifDefined(config.exposure)}
tone-mapping=${ifDefined(config.toneMapping)}
poster=${this.posterUrl}
reveal=${ifDefined(config.reveal)}
shadow-intensity=${ifDefined(config.shadowIntensity)}
shadow-softness=${ifDefined(config.shadowSoftness)}
camera-target=${ifDefined(config.cameraTarget)}
camera-orbit=${ifDefined(config.cameraOrbit)}
field-of-view=${ifDefined(config.fieldOfView)}
min-camera-orbit=${ifDefined(config.minCameraOrbit)}
max-camera-orbit=${ifDefined(config.maxCameraOrbit)}
min-field-of-view=${ifDefined(config.minFov)}
max-field-of-view=${ifDefined(config.maxFov)}
@load=${this.modelIsLoaded}
>
${childElements}
</model-viewer>
</div>
</div>
<div class="${this.toastClassName}" id="snackbar-mobile">
${this.toastBody}
</div>
`;
}
updated() {
this.modelViewer.cameraOrbit = 'auto auto auto';
const {cameraOrbit} = this.config;
if (cameraOrbit) {
this.modelViewer.cameraOrbit = cameraOrbit.toString();
}
this.modelViewer.jumpCameraToGoal();
this.modelViewer.dismissPoster();
}
// Ping the editor
async ping() {
const ping: MobileSession = {
os: getMobileOperatingSystem(),
id: this.sessionId,
isStale: true,
};
await post(JSON.stringify(ping), this.mobilePingUrl);
}
// (Overriding default) Tell editor session that it is ready for data.
firstUpdated() {
this.ping();
this.triggerFetchLoop();
}
}
declare global {
interface HTMLElementTagNameMap {
'mobile-view': MobileView;
}
}
@@ -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 {html} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {reduxStore} from '../../space_opera_base.js';
import {openMobileViewStyles} from '../../styles.css.js';
import {timePasses} from '../../test/utils/test_utils.js';
import {ArConfigState, ModelViewerSnippetState, State} from '../../types.js';
import {getConfig} from '../config/reducer.js';
import {ConnectedLitElement} from '../connected_lit_element/connected_lit_element.js';
import {dispatchModelDirty, getGltfUrl, getModel, getModelViewer} from '../model_viewer_preview/reducer.js';
import {getModelViewerSnippet} from '../model_viewer_snippet/reducer.js';
import {createPoster} from '../utils/render_model_viewer.js';
import {MobileModal} from './components/mobile_modal.js';
import {dispatchAr, dispatchArModes, dispatchSetForcePost, dispatchSetRefreshable, getArConfig, getForcePost, getRefreshable} from './reducer.js';
import {EditorUpdates, MobilePacket, MobileSession, URLs} from './types.js';
import {envToSession, getPingUrl, getRandomInt, getSessionUrl, getWithTimeout, gltfToSession, post, posterToSession} from './utils.js';
const REFRESH_DELAY = 20000; // 20s
/**
* Section for displaying QR Code and other info related for mobile devices.
* This is the section on the editor under the File Manager.
*/
@customElement('open-mobile-view')
export class OpenMobileView extends ConnectedLitElement {
static styles = openMobileViewStyles;
@state() pipeId = getRandomInt(1e+20);
@state() isDeployed = false;
@state() isDeployable = false;
@state() isSendingData = false;
@state() contentHasChanged = false;
@state() urls: URLs = {gltf: '', env: ''};
@state() lastUrlsSent: URLs = {gltf: '', env: ''};
@state() snippet!: ModelViewerSnippetState;
@state() lastSnippetSent!: ModelViewerSnippetState;
@state() modelIsDirty = false;
@query('mobile-modal') mobileModal!: MobileModal;
@state() haveReceivedResponse: boolean = false;
@state() arConfig?: ArConfigState;
@state() defaultToSceneViewer: boolean = false;
@state() sessionList: MobileSession[] = [];
@state() mobilePingUrl = getPingUrl(this.pipeId);
get canRefresh(): boolean {
return this.isDeployed && this.haveReceivedResponse &&
!this.isSendingData && this.contentHasChanged;
}
stateChanged(state: State) {
this.arConfig = getArConfig(state);
const gltfURL = getGltfUrl(state);
this.isDeployable = gltfURL !== undefined;
// Update urls with most recent from redux state.
// If the values are different from this.lastUrlsSent, values are sent when
// the refresh button is pressed.
this.urls = {gltf: gltfURL, env: getConfig(state).environmentImage};
this.snippet = getModelViewerSnippet(state);
this.modelIsDirty = !!getModel(state)?.isDirty;
this.contentHasChanged = this.getContentHasChanged();
// only update if different, need conditional because it would infinitely
// loop otherwise
if (getRefreshable(state) !== this.canRefresh) {
reduxStore.dispatch(dispatchSetRefreshable(this.canRefresh));
}
this.defaultToSceneViewer =
this.arConfig.arModes === 'scene-viewer webxr quick-look';
if (getForcePost(state) === true) {
this.postInfo();
reduxStore.dispatch(dispatchSetForcePost(false));
}
}
// True if any content we'd send to the mobile view has changed.
getContentHasChanged(): boolean {
return this.stateHasChanged() || this.isNewModel() ||
this.isNewSource(this.urls.env, this.lastUrlsSent.env);
}
envIsHdr(): boolean {
return typeof this.urls.env === 'string' &&
this.urls.env.substr(this.urls.env.length - 4) === '.hdr';
}
stateHasChanged() {
return JSON.stringify(this.snippet) !==
JSON.stringify(this.lastSnippetSent);
}
isNewSource(src: string|undefined, lastSrc: string|undefined) {
return src !== undefined && (src !== lastSrc);
}
isNewModel() {
return this.isNewSource(this.urls.gltf, this.lastUrlsSent.gltf) ||
this.modelIsDirty;
}
// Used for non-stale sessions, to only send updated content.
getUpdatedContent(): EditorUpdates {
return {
gltfChanged: this.isNewModel(), stateChanged: this.stateHasChanged(),
posterId: getRandomInt(1e+20),
envChanged: this.isNewSource(this.urls.env, this.lastUrlsSent.env),
envIsHdr: this.envIsHdr(), gltfId: getRandomInt(1e+20),
}
}
// If a session didn't received content last time (isStale), we'll force
// everything to update.
getStaleContent(): EditorUpdates {
return {
gltfChanged: true, stateChanged: true, posterId: getRandomInt(1e+20),
envChanged: this.urls.env != undefined &&
this.urls.env !== 'neutral' && this.urls.env !== 'legacy',
envIsHdr: this.envIsHdr(), gltfId: getRandomInt(1e+20),
}
}
// For a single session, POST all of the relevant content that needs to be
// updated for that session.
// A session will remain stale if any of the POSTs fail. A stale session will
// be forced to send all of the content the next time the refresh button is
// clicked.
async sendSessionContent(
session: MobileSession, updatedContent: EditorUpdates, posterBlob: Blob,
gltfBlob: Blob|undefined, envBlob: Blob|undefined) {
if (session.isStale) {
updatedContent = this.getStaleContent();
}
session.isStale = true;
const packet: MobilePacket = {
updatedContent: updatedContent,
snippet: this.snippet,
urls: this.urls
};
await post(JSON.stringify(packet), getSessionUrl(this.pipeId, session.id));
await post(
posterBlob,
posterToSession(this.pipeId, session.id, updatedContent.posterId));
if (updatedContent.gltfChanged && gltfBlob) {
await post(
gltfBlob,
gltfToSession(this.pipeId, session.id, updatedContent.gltfId));
}
if (updatedContent.envChanged && envBlob) {
await post(
envBlob,
envToSession(this.pipeId, session.id, updatedContent.envIsHdr));
}
// The isStale flag will stay true if all of the requests are not delivered.
session.isStale = false;
}
async sendSessionContentHolder(
session: MobileSession, updatedContent: EditorUpdates, posterBlob: Blob,
gltfBlob: Blob|undefined, envBlob: Blob|undefined) {
try {
await this.sendSessionContent(
session, updatedContent, posterBlob, gltfBlob, envBlob);
} catch (e) {
console.log('error posting...');
}
}
// Send any state, model, or environment iamge that has been updated since the
// last refresh.
async postInfo() {
console.log('posting info...');
if (this.isSendingData) {
return;
}
this.isSendingData = true;
reduxStore.dispatch(dispatchSetRefreshable(this.canRefresh));
const sessionList = [...this.sessionList];
setTimeout(() => {
this.isSendingData = false;
reduxStore.dispatch(dispatchSetRefreshable(this.canRefresh));
}, REFRESH_DELAY);
const updatedContent = this.getUpdatedContent();
const staleContent = this.getStaleContent();
// If any of the sessions are stale, we want to prepare the relavent blobs
// to POST before we loop through the sessions
let haveStale = false;
for (let session of this.sessionList) {
haveStale = haveStale || session.isStale;
}
const gltfBlob = (updatedContent.gltfChanged ||
(haveStale && staleContent.gltfChanged)) ?
await getModelViewer()!.exportScene() :
undefined;
let envBlob: Blob|undefined;
const {env, gltf} = this.urls;
if (env != null && env !== 'neutral' && env !== 'legacy' &&
(updatedContent.envChanged || (haveStale && staleContent.envChanged))) {
const response = await fetch(env);
if (!response.ok) {
throw new Error(`Failed to fetch url: ${env}`);
}
envBlob = await response.blob();
}
const posterBlob = await createPoster();
// Iterate through the list of active mobile sessions, and allow them to
// post their information asynchronously.
for (let session of sessionList) {
this.sendSessionContentHolder(
session, updatedContent, posterBlob, gltfBlob, envBlob);
}
this.lastSnippetSent = {...this.snippet};
this.lastUrlsSent.env = env;
this.lastUrlsSent.gltf = gltf;
reduxStore.dispatch(dispatchModelDirty(false));
this.contentHasChanged = this.getContentHasChanged();
reduxStore.dispatch(dispatchSetRefreshable(this.canRefresh));
}
// update haveReceivedResponse when a ping was received from the mobile view
async waitForPing() {
const response = await getWithTimeout(this.mobilePingUrl);
if (response.ok) {
const json: MobileSession = await response.json();
this.sessionList.push(json);
// Only update if not currently updating...
if (!this.isSendingData) {
this.postInfo();
}
this.haveReceivedResponse = true;
return true;
}
return false;
}
// If a ping was received, then a new page has been opened or a page was
// refreshed.
async pingLoop() {
try {
if (!await this.waitForPing()) {
await timePasses(1000);
}
} catch (error) {
console.log('error...', error);
await timePasses(1000);
}
this.pingLoop();
}
openModal() {
this.mobileModal.open();
}
// Initialize AR values and start deploy loop
onInitialDeploy() {
this.openModal();
this.isDeployed = true;
if (this.arConfig?.arModes === undefined) {
reduxStore.dispatch(dispatchArModes('webxr scene-viewer quick-look'));
}
this.pingLoop();
}
onEnableARChange(isEnabled: boolean) {
reduxStore.dispatch(dispatchAr(isEnabled));
}
onSelectArMode(isSceneViewer: boolean) {
this.defaultToSceneViewer = isSceneViewer;
if (this.defaultToSceneViewer) {
reduxStore.dispatch(dispatchArModes('scene-viewer webxr quick-look'));
} else {
reduxStore.dispatch(dispatchArModes('webxr scene-viewer quick-look'));
}
}
render() {
return html`
<mobile-expandable-section
.isDeployed=${this.isDeployed}
.isDeployable=${this.isDeployable}
.onInitialDeploy=${this.onInitialDeploy.bind(this)}
.haveReceivedResponse=${this.haveReceivedResponse}
.isSendingData=${this.isSendingData}
.contentHasChanged=${this.contentHasChanged}
.openModal=${this.openModal.bind(this)}
.postInfo=${this.postInfo.bind(this)}
.defaultToSceneViewer=${this.defaultToSceneViewer}
.onSelectArMode=${this.onSelectArMode.bind(this)}
.arConfig=${this.arConfig}
.onEnableARChange=${this.onEnableARChange.bind(this)}
>
</mobile-expandable-section>
<mobile-modal .pipeId=${this.pipeId}></mobile-modal>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'open-mobile-view': OpenMobileView;
}
}
@@ -0,0 +1,90 @@
/**
* @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 {Action, ArConfigState, INITIAL_STATE, State} from '../../types.js';
import {MobileState} from './types.js';
export function getMobileModelViewer() {
return document.querySelector('mobile-view')?.modelViewer;
}
const SET_AR = 'SET_AR';
export function dispatchAr(isAr: boolean) {
return {type: SET_AR, payload: isAr};
}
const SET_AR_MODES = 'SET_AR_MODES';
export function dispatchArModes(arModes: string) {
return {type: SET_AR_MODES, payload: arModes};
}
const SET_AR_CONFIG = 'SET_AR_CONFIG';
export function dispatchArConfig(arConfig: ArConfigState) {
return {type: SET_AR_CONFIG, payload: arConfig};
}
export const getArConfig = (state: State) =>
state.entities.modelViewerSnippet.arConfig;
export function arReducer(
state: ArConfigState = INITIAL_STATE.entities.modelViewerSnippet.arConfig,
action: Action): ArConfigState {
switch (action.type) {
case SET_AR_CONFIG:
return action.payload;
case SET_AR:
return {...state, ar: action.payload};
case SET_AR_MODES:
return {...state, arModes: action.payload};
default:
return state;
}
}
const SET_REFRESHABLE = 'SET_REFRESHABLE';
export function dispatchSetRefreshable(canRefresh: boolean) {
return {type: SET_REFRESHABLE, payload: canRefresh};
}
const SET_FORCE_POST = 'SET_FORCE_POST';
export function dispatchSetForcePost(forcePost: boolean) {
return {type: SET_FORCE_POST, payload: forcePost};
}
export const getRefreshable = (state: State) =>
state.entities.mobile.isRefreshable;
// Allow the ability to trigger a postInfo (for a mobile device) from anywhere
// in the code.
export const getForcePost = (state: State) => state.entities.mobile.forcePost;
export function mobileReducer(
state: MobileState = {
isRefreshable: false,
forcePost: false
},
action: Action): MobileState {
switch (action.type) {
case SET_REFRESHABLE:
return {...state, isRefreshable: action.payload};
case SET_FORCE_POST:
return {...state, forcePost: action.payload};
default:
return state;
}
}
@@ -0,0 +1,44 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`
.app {
display: flex;
flex-direction: column;
height: 80vh;
}
.mvContainer {
display: flex;
height: 100%;
position: relative;
width: 100%;
}
model-viewer {
cursor: -webkit-grab;
cursor: -moz-grab;
display: flex;
height: 100%;
width: 100%;
overflow: hidden;
position: relative;
user-select: none;
background-color: #eee;
}
`;
@@ -0,0 +1,49 @@
/**
* @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 {ModelViewerSnippetState} from '../../types';
export interface URLs {
gltf: string|undefined;
env: string|undefined;
}
export interface MobileSession {
id: number;
isStale: boolean;
os: string;
}
export interface EditorUpdates {
gltfChanged: boolean;
gltfId: number;
stateChanged: boolean;
posterId: number;
envChanged: boolean;
envIsHdr: boolean;
}
export interface MobilePacket {
updatedContent: EditorUpdates;
snippet: ModelViewerSnippetState;
urls: URLs;
}
export interface MobileState {
isRefreshable: boolean;
forcePost: boolean;
}
@@ -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.
*
*/
export const DOMAIN = 'https://ppng.io/';
export function getRandomInt(max: number): number {
return Math.floor(Math.random() * Math.floor(max));
}
// ex: 'https://piping.nwtgck.repl.co/123-456'
export function getSessionUrl(
pipeId: number|string, sessionId: number): string {
return `${DOMAIN}${pipeId}-${sessionId}`;
}
// ex: 'https://piping.nwtgck.repl.co/ping-123'
export function getPingUrl(pipeId: number|string) {
return `${DOMAIN}ping-${pipeId}`;
}
// ex: 'https://piping.nwtgck.repl.co/123-456-789-poster'
export function posterToSession(
pipeId: number|string, sessionID: number, modelId: number): string {
return `${DOMAIN}${pipeId}-${sessionID}-${modelId}-poster`;
}
// ex: 'https://piping.nwtgck.repl.co/123-456-789'
export function gltfToSession(
pipeId: number|string, sessionID: number, modelId: number): string {
return `${DOMAIN}${pipeId}-${sessionID}-${modelId}`;
}
// ex: 'https://piping.nwtgck.repl.co/123-456-env'
export function envToSession(
pipeId: number|string, sessionID: number, envIsHdr: boolean): string {
const addOn = envIsHdr ? '#.hdr' : '';
return `${DOMAIN}${pipeId}-${sessionID}-env${addOn}`;
}
export async function post(content: Blob|string, url: string) {
const response = await fetch(url, {
method: 'POST',
body: content,
});
if (response.ok) {
console.log('Success:', response);
} else {
throw new Error(`Failed to post: ${url}`);
}
}
// Aborting the get request as opposed to failing will keep the site pipe
// running smoother.
// https://dmitripavlutin.com/timeout-fetch-request/#2-timeout-a-fetch-request
export async function getWithTimeout(url: string) {
const timeout = 30000;
const controller = new AbortController();
const id = setTimeout(() => controller.abort(), timeout);
const response = await fetch(url, {method: 'GET', signal: controller.signal});
clearTimeout(id);
return response;
}
/**
* Determine the mobile operating system.
* This function returns one of 'iOS', 'Android', 'Windows Phone', or
* 'unknown'.
* https://stackoverflow.com/questions/21741841/detecting-ios-android-operating-system
*/
export function getMobileOperatingSystem(): 'iOS'|'Android'|'Windows Phone'|
'unknown' {
const userAgent =
navigator.userAgent || navigator.vendor || (window as any).opera;
// Windows Phone must come first because its UA also contains "Android"
if (/windows phone/i.test(userAgent)) {
return 'Windows Phone';
}
if (/android/i.test(userAgent)) {
return 'Android';
}
// iOS detection from: http://stackoverflow.com/a/9039885/177710
if (/iPad|iPhone|iPod/.test(userAgent) && !(window as any).MSStream) {
return 'iOS';
}
return 'unknown';
}
@@ -0,0 +1,41 @@
/**
* @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.
*
*/
/**
* @fileoverview Model editor for uploading, parsing, modifying GLTF/GLB files.
*/
import {html, LitElement} from 'lit';
import {customElement} from 'lit/decorators.js';
/**
* The <model-editor> element
*/
@customElement('model-editor')
export class ModelEditorElement extends LitElement {
render() {
return html`
<slot></slot>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'model-editor': ModelEditorElement;
}
}
@@ -0,0 +1,238 @@
/**
* @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.
*
*/
/**
* @fileoverview Use lit-html to output a model-viewer tag with the current
* settings applied to the GLB.
*/
import '@material/mwc-icon-button';
import {ModelViewerElement} from '@google/model-viewer/lib/model-viewer';
import {html} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {reduxStore} from '../../space_opera_base.js';
import {modelViewerPreviewStyles} from '../../styles.css.js';
import {ArConfigState, BestPracticesState, extractStagingConfig, ModelViewerConfig, State} from '../../types.js';
import {getBestPractices} from '../best_practices/reducer.js';
import {arButtonCSS, progressBarCSS} from '../best_practices/styles.css.js';
import {dispatchCameraIsDirty} from '../camera_settings/reducer.js';
import {dispatchAutoplayEnabled, dispatchCameraControlsEnabled, dispatchConfig, dispatchEnvironmentImage, getConfig} from '../config/reducer.js';
import {ConnectedLitElement} from '../connected_lit_element/connected_lit_element.js';
import {dispatchAddHotspot, dispatchSetHotspots, dispatchUpdateHotspotMode, generateUniqueHotspotName, getHotspotMode, getHotspots} from '../hotspot_panel/reducer.js';
import {HotspotConfig} from '../hotspot_panel/types.js';
import {createBlobUrlFromEnvironmentImage, dispatchAddEnvironmentImage} from '../ibl_selector/reducer.js';
import {dispatchSetForcePost, getArConfig, getRefreshable} from '../mobile_view/reducer.js';
import {getExtraAttributes} from '../model_viewer_snippet/reducer.js';
import {dispatchSetEnvironmentName, dispatchSetModelName} from '../relative_file_paths/reducer.js';
import {createSafeObjectUrlFromArrayBuffer} from '../utils/create_object_url.js';
import {styles as hotspotStyles} from '../utils/hotspot/hotspot.css.js';
import {renderModelViewer} from '../utils/render_model_viewer.js';
import {dispatchGltfUrl, dispatchModel, getGltfUrl, getModelViewer, renderCommonChildElements} from './reducer.js';
/**
* Renders and updates the model-viewer tag, serving as a preview of the edits.
*/
@customElement('model-viewer-preview')
export class ModelViewerPreview extends ConnectedLitElement {
static styles =
[modelViewerPreviewStyles, hotspotStyles, arButtonCSS, progressBarCSS];
@query('model-viewer') readonly modelViewer!: ModelViewerElement;
@state() config: ModelViewerConfig = {};
@state() arConfig: ArConfigState = {};
@state() hotspots: HotspotConfig[] = [];
@state() addHotspotMode = false;
@state() gltfUrl?: string;
@state() extraAttributes: any = {};
@state() refreshButtonIsReady: boolean = false;
@state() bestPractices?: BestPracticesState;
// The loadComplete promise is a testing hook that resolves once all async
// load-related operations have completed. Await this promise after causing a
// gltfUrl to be dispatched and after awaiting this element's updateComplete.
loadComplete?: Promise<void>;
private resolveLoad = () => {};
stateChanged(state: State) {
this.addHotspotMode = getHotspotMode(state) || false;
this.config = getConfig(state);
this.arConfig = getArConfig(state);
this.hotspots = getHotspots(state);
this.extraAttributes = getExtraAttributes(state);
this.refreshButtonIsReady = getRefreshable(state);
this.bestPractices = getBestPractices(state);
const gltfUrl = getGltfUrl(state);
if (gltfUrl !== this.gltfUrl) {
this.loadComplete = new Promise((resolve) => {
this.resolveLoad = resolve;
});
this.gltfUrl = gltfUrl;
}
}
firstUpdated() {
this.addEventListener('drop', this.onDrop);
this.addEventListener('dragover', this.onDragover);
(self as any).ModelViewerElement = (self as any).ModelViewerElement || {};
(self as any).ModelViewerElement.meshoptDecoderLocation =
'https://cdn.jsdelivr.net/npm/meshoptimizer/meshopt_decoder.js';
}
forcePost() {
reduxStore.dispatch(dispatchSetForcePost(true));
}
protected render() {
const editedConfig = {
...this.config,
src: this.gltfUrl,
// Always enable camera controls for preview
cameraControls: true,
interactionPrompt: 'none'
};
const hasModel = !!editedConfig.src;
const refreshMobileButton = this.refreshButtonIsReady === true ? html
`<mwc-button icon="cached" @click=${this.forcePost}
style="--mdc-theme-primary: #DC143C; border: #DC143C" class="RefreshMobileButton">
Refresh Mobile
</mwc-button>`: html``;
// Renders elements common between mobile and editor.
const childElements =
renderCommonChildElements(this.hotspots, this.bestPractices!, true);
// Add additional elements, editor specific.
childElements.push(refreshMobileButton);
if (!hasModel) {
childElements.push(
html
`<div class="HelpText">Drag a glTF or GLB here!<br/>
<small>Groups, folders, and Zip archives supported</small><br/>
<small>Drop an HDR for lighting</small></div>`);
}
return html`${
renderModelViewer(
editedConfig,
this.arConfig,
this.extraAttributes,
{
load: () => {
this.onModelLoaded();
},
cameraChange: () => {
this.onCameraChange();
},
click: (event: MouseEvent) => {
if (this.addHotspotMode) {
this.addHotspot(event);
}
}
},
childElements)}`;
}
// Handle the case when the model is loaded for the first time.
private async onModelLoaded() {
reduxStore.dispatch(await dispatchModel());
if (this.modelViewer.availableAnimations.length > 0) {
reduxStore.dispatch(dispatchAutoplayEnabled(true));
}
const config = getConfig(reduxStore.getState());
reduxStore.dispatch(dispatchConfig({...config}));
this.resolveLoad();
}
private onCameraChange() {
reduxStore.dispatch(dispatchCameraIsDirty());
}
private addHotspot(event: MouseEvent) {
if (getModelViewer().availableAnimations.length > 0) {
const surface =
this.modelViewer.surfaceFromPoint(event.clientX, event.clientY);
if (!surface) {
console.log('Click was not on model, no hotspot added.');
return;
}
reduxStore.dispatch(dispatchAddHotspot({
name: generateUniqueHotspotName(),
surface,
}));
} else {
const point = this.modelViewer.positionAndNormalFromPoint(
event.clientX, event.clientY);
if (!point) {
console.log('Click was not on model, no hotspot added.');
return;
}
reduxStore.dispatch(dispatchAddHotspot({
name: generateUniqueHotspotName(),
position: point.position.toString(),
normal: point.normal.toString()
}));
}
reduxStore.dispatch(dispatchUpdateHotspotMode(false));
}
private onDragover(event: DragEvent) {
if (!event.dataTransfer)
return;
event.stopPropagation();
event.preventDefault();
}
private async onDrop(event: DragEvent) {
event.stopPropagation();
event.preventDefault();
if (event.dataTransfer && event.dataTransfer.items[0].kind === 'file') {
const file = event.dataTransfer.items[0].getAsFile();
if (!file)
return;
if (file.name.match(/\.(glb|gltf)$/i)) {
const arrayBuffer = await file.arrayBuffer();
reduxStore.dispatch(dispatchSetModelName(file.name));
const url =
createSafeObjectUrlFromArrayBuffer(arrayBuffer).unsafeUrl;
reduxStore.dispatch(dispatchGltfUrl(url));
dispatchConfig(extractStagingConfig(this.config));
reduxStore.dispatch(dispatchCameraControlsEnabled(true));
reduxStore.dispatch(dispatchSetHotspots([]));
}
if (file.name.match(/\.(hdr|png|jpg|jpeg)$/i)) {
const unsafeUrl = await createBlobUrlFromEnvironmentImage(file);
reduxStore.dispatch(
dispatchAddEnvironmentImage({uri: unsafeUrl, name: file.name}));
reduxStore.dispatch(dispatchEnvironmentImage(unsafeUrl));
reduxStore.dispatch(dispatchSetEnvironmentName(file.name));
}
}
}
}
declare global {
interface HTMLElementTagNameMap {
'model-viewer-preview': ModelViewerPreview;
}
}
@@ -0,0 +1,172 @@
/**
* @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 {TextureInfo} from '@google/model-viewer/lib/features/scene-graph/texture-info.js';
import {Image} from '@google/model-viewer/lib/three-components/gltf-instance/gltf-2.0.js';
import {Action, BestPracticesState, INITIAL_STATE, State} from '../../types.js';
import {renderARButton, renderARPrompt, renderProgressBar} from '../best_practices/render_best_practices.js';
import {HotspotConfig} from '../hotspot_panel/types.js';
import {ModelState, Thumbnail} from '../model_viewer_preview/types.js';
import {renderHotspots} from '../utils/hotspot/render_hotspots.js';
const THUMBNAIL_SIZE = 256;
export function getModelViewer() {
return document.querySelector('model-viewer-preview')!.modelViewer;
}
export async function getUpdatedModelViewer() {
const preview = document.querySelector('model-viewer-preview')!;
const viewer = preview.modelViewer;
await preview.updateComplete;
await viewer.updateComplete;
return viewer;
}
export function renderCommonChildElements(
hotspots: HotspotConfig[],
bestPractices?: BestPracticesState,
isEditor?: boolean) {
const childElements: any[] = [
...renderHotspots(hotspots),
];
if (bestPractices?.progressBar) {
childElements.push(renderProgressBar(isEditor));
}
if (bestPractices?.arButton && !isEditor) {
childElements.push(renderARButton());
}
if (bestPractices?.arPrompt && !isEditor) {
childElements.push(renderARPrompt());
}
return childElements;
}
export async function downloadContents(url: string): Promise<ArrayBuffer> {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to fetch url ${url}`);
}
const blob = await response.blob();
if (!blob) {
throw new Error(`Could not extract binary blob from response of ${url}`);
}
return blob.arrayBuffer();
}
export function getTextureId(gltfImage: Image): string {
return gltfImage.uri ?? gltfImage.bufferView!.toString();
}
export async function pushThumbnail(
thumbnailsById: Map<string, Thumbnail>,
textureInfo: TextureInfo): Promise<string|null> {
const {texture} = textureInfo;
if (texture == null) {
return null;
}
const {source} = texture;
const id = getTextureId(source);
if (!thumbnailsById.has(id)) {
thumbnailsById.set(id, {
objectUrl: await source.createThumbnail(THUMBNAIL_SIZE, THUMBNAIL_SIZE),
texture
});
}
return id;
}
async function createThumbnails(): Promise<Map<string, Thumbnail>> {
const thumbnailsById = new Map<string, Thumbnail>();
for (const material of getModelViewer()!.model?.materials!) {
await material.ensureLoaded();
const {
pbrMetallicRoughness,
normalTexture,
emissiveTexture,
occlusionTexture
} = material;
const {baseColorTexture, metallicRoughnessTexture} = pbrMetallicRoughness;
await pushThumbnail(thumbnailsById, normalTexture);
await pushThumbnail(thumbnailsById, emissiveTexture);
await pushThumbnail(thumbnailsById, occlusionTexture);
await pushThumbnail(thumbnailsById, baseColorTexture);
await pushThumbnail(thumbnailsById, metallicRoughnessTexture);
}
return thumbnailsById;
}
const SET_GLTF_URL = 'SET_GLTF_URL';
export function dispatchGltfUrl(gltfUrl?: string|undefined) {
return {type: SET_GLTF_URL, payload: gltfUrl};
}
const SET_ROOT_PATH = 'SET_ROOT_PATH';
export function dispatchRootPath(path: string) {
return {type: SET_ROOT_PATH, payload: path};
}
const SET_FILE_MAP = 'SET_FILE_MAP';
export function dispatchFileMap(fileMap: Map<string, File>) {
return {type: SET_FILE_MAP, payload: fileMap};
}
const SET_MODEL = 'SET_MODEL';
export async function dispatchModel() {
const thumbnailsById = await createThumbnails();
const originalGltfJson =
JSON.stringify(getModelViewer()?.originalGltfJson, null, 2);
// Make a deep copy of the JSON so changes are not reflected.
const originalGltf = JSON.parse(originalGltfJson);
return {
type: SET_MODEL,
payload: {thumbnailsById, originalGltf, originalGltfJson}
};
}
const SET_MODEL_DIRTY = 'SET_MODEL_DIRTY';
export function dispatchModelDirty(isDirty: boolean = true) {
return {type: SET_MODEL_DIRTY, payload: {isDirty}};
}
export const getGltfUrl = (state: State) => state.entities.model.gltfUrl;
export const getFileMap = (state: State) => state.entities.model.fileMap;
export const getModel = (state: State) => state.entities.model;
export const isLoaded = (state: State) => !!state.entities.model.originalGltf;
export function modelReducer(
state: ModelState = INITIAL_STATE.entities.model,
action: Action): ModelState {
switch (action.type) {
case SET_GLTF_URL:
return {...state, gltfUrl: action.payload};
case SET_ROOT_PATH:
return {...state, rootPath: action.payload};
case SET_FILE_MAP:
return {...state, fileMap: action.payload};
case SET_MODEL:
return {...state, ...action.payload};
case SET_MODEL_DIRTY:
return {...state, ...action.payload};
default:
return state;
}
}
@@ -0,0 +1,34 @@
/**
* @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} from '@google/model-viewer/lib/features/scene-graph/texture';
import {GLTF} from '@google/model-viewer/lib/three-components/gltf-instance/gltf-defaulted';
export interface Thumbnail {
objectUrl: string;
texture: Texture;
}
export interface ModelState {
gltfUrl?: string;
rootPath?: string;
fileMap: Map<string, File>;
thumbnailsById?: Map<string, Thumbnail>;
originalGltfJson?: string;
originalGltf?: GLTF;
isDirty?: boolean;
}
@@ -0,0 +1,249 @@
/**
* @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/mwc-button';
// tslint:disable-next-line:enforce-name-casing JSZip is a class.
import JSZip from 'jszip';
import {css, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {BestPracticesState, ModelViewerConfig, RelativeFilePathsState, State} from '../../../types.js';
import {modelViewerTemplate, progressBar, scriptTemplate} from '../../best_practices/constants.js';
import {getBestPractices} from '../../best_practices/reducer.js';
import {arButtonCSS, arPromptCSS, modelViewerStyles, progressBarCSS} from '../../best_practices/styles.css.js';
import {getConfig} from '../../config/reducer.js';
import {ConnectedLitElement} from '../../connected_lit_element/connected_lit_element.js';
import {getHotspots} from '../../hotspot_panel/reducer.js';
import {getModelViewer} from '../../model_viewer_preview/reducer.js';
import {getRelativeFilePaths} from '../../relative_file_paths/reducer.js';
import {safeDownloadCallback} from '../../utils/create_object_url.js';
import {styles as hotspotStyles} from '../../utils/hotspot/hotspot.css.js';
import {createPoster} from '../../utils/render_model_viewer.js';
interface Payload {
blob: Blob;
filename: string;
}
/**
* A generic button base class for downloading file resources.
*/
class GenericDownloadButton extends ConnectedLitElement {
static get styles() {
return css`
:host {--mdc-button-disabled-fill-color: rgba(255,255,255,.88)}
`;
}
@state() buttonLabel = '';
@state() preparePayload?: () => Promise<Payload|null>;
// NOTE: Because this is async, it is possible for multiple downloads to be
// kicked off at once. But this is unlikely, and each download has no
// side-effects anyway, so nothing bad can happen.
async onDownloadClick() {
const payload = await this.preparePayload!();
if (!payload)
return;
await safeDownloadCallback(payload.blob, payload.filename)();
}
render() {
return html`<mwc-button unelevated
icon="file_download"
?disabled=${!this.preparePayload}
@click=${this.onDownloadClick}>
${this.buttonLabel}</mwc-button>`;
}
}
export async function prepareGlbPayload(modelName: string): Promise<Payload> {
return {blob: await getModelViewer()!.exportScene(), filename: modelName};
}
async function preparePosterPayload(posterName: string): Promise<Payload> {
return {blob: await createPoster(), filename: posterName};
}
// Fixes some formatting issues with the snippet as it is being placed into the
// template.
function beautify_snippet(snippetList: string[]): string {
let snippet = '';
let i = 0;
for (let line of snippetList) {
if (i == 0) {
snippet = `${line}\n`;
} else if (i !== 0 && line.includes('model-viewer')) {
snippet = `${snippet} ${line}`;
} else {
snippet = `${snippet} ${line}\n`;
}
i += 1;
}
return snippet;
}
/**
* Add elements to ZIP as necessary.
*/
async function prepareZipArchive(
config: ModelViewerConfig,
data: {snippetText: string},
relativeFilePaths: RelativeFilePathsState,
bestPractices: BestPracticesState,
hasHotspots: boolean): Promise<Payload> {
const zip = new JSZip();
const glb = await prepareGlbPayload(relativeFilePaths.modelName!);
zip.file(glb.filename, glb.blob);
// check if legal envrionment url
if (config.environmentImage != null &&
config.environmentImage !== 'neutral' &&
config.environmentImage !== 'legacy') {
const response = await fetch(config.environmentImage);
if (!response.ok) {
throw new Error(`Failed to fetch url ${config.environmentImage}`);
}
zip.file(relativeFilePaths.environmentName!, response.blob());
}
// always generate a poster image
const posterBlob = await createPoster();
zip.file(relativeFilePaths.posterName!, posterBlob);
let template = modelViewerTemplate;
// Conditionally set script if anything requires javascript. Currently, this
// is only the progressbar.
const script = bestPractices.progressBar ? scriptTemplate : '';
const arPrompt = 'https://modelviewer.dev/shared-assets/icons/hand.png';
const arIcon =
'https://modelviewer.dev/shared-assets/icons/ic_view_in_ar_new_googblue_48dp.png';
const relativePrompt = 'ar_hand_prompt.png';
const relativeIcon = 'ar_icon.png';
let snippet = beautify_snippet(data.snippetText.split('\n'));
snippet = `${snippet}\n${script}\n`;
if (bestPractices.arPrompt) {
snippet = snippet.replace(arPrompt, relativePrompt);
}
// Replace placeholders in html strings with content
template = template.replace('REPLACEME', snippet);
template = template.replace(/(^[ \t]*\n)/gm, ''); // remove extra newlines
zip.file('index.html', template);
// Keep model-viewer snippet
zip.file('snippet.txt', data.snippetText);
// Add css file for the model-viewer and other related add ons
let cssText = modelViewerStyles.cssText;
if (hasHotspots) {
cssText = `${cssText}\n${hotspotStyles.cssText}`;
}
if (bestPractices.progressBar) {
cssText = `${cssText}\n${progressBarCSS.cssText}`;
}
if (bestPractices.arButton) {
const arCSS = arButtonCSS.cssText.replace(arIcon, relativeIcon);
cssText = `${cssText}\n${arCSS}`;
const response = await fetch(arIcon);
if (!response.ok) {
console.log(`Failed to fetch url ${arIcon}`);
} else {
zip.file(relativeIcon, response.blob());
}
}
if (bestPractices.arPrompt) {
cssText = `${cssText}\n${arPromptCSS.cssText}`;
const response = await fetch(arPrompt);
if (!response.ok) {
console.log(`Failed to fetch url ${arPrompt}`);
} else {
zip.file(relativePrompt, response.blob());
}
}
zip.file('styles.css', cssText);
// Add a script file if any javascript is needed
if (bestPractices.progressBar) {
zip.file('script.js', progressBar);
}
return {
blob: await zip.generateAsync({type: 'blob', compression: 'DEFLATE'}),
filename: 'model.zip'
};
}
/**
* A button to download GLB file resources.
*/
@customElement('me-export-poster-button')
export class ExportPosterButton extends GenericDownloadButton {
constructor() {
super();
this.buttonLabel = 'POSTER';
}
stateChanged(state: State) {
const loaded = getModelViewer()?.loaded;
const {posterName} = getRelativeFilePaths(state);
this.preparePayload =
loaded ? () => preparePosterPayload(posterName) : undefined;
}
}
/**
* A button to download all file resources as a ZIP.
*/
@customElement('me-export-zip-button')
export class ExportZipButton extends GenericDownloadButton {
snippetText: string = '';
constructor() {
super();
this.buttonLabel = 'DOWNLOAD SCENE';
}
stateChanged(state: State) {
const config = getConfig(state);
const loaded = getModelViewer()?.loaded;
const relativeFilePaths = getRelativeFilePaths(state);
const bestPractices = getBestPractices(state);
const hasHotspots = getHotspots(state).length > 0;
if (!loaded) {
this.preparePayload = undefined;
return;
}
// Note that snippet text will necessarily be set manually post-update,
// and therefore we must pass a containing object (in our case, this) by
// reference.
this.preparePayload = () => prepareZipArchive(
config, this, relativeFilePaths, bestPractices, hasHotspots);
}
}
declare global {
interface HTMLElementTagNameMap {
'me-export-poster-button': ExportPosterButton;
'me-export-zip-button': ExportZipButton;
}
}
@@ -0,0 +1,366 @@
/**
* @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/mwc-button';
import {ModelViewerElement} from '@google/model-viewer/lib/model-viewer';
import {html, LitElement} from 'lit';
import {customElement, query, state} from 'lit/decorators.js';
import {SimpleDropzone} from 'simple-dropzone';
import {dispatchConfig, dispatchEnvironmentImage, getConfig} from '../../../components/config/reducer.js';
import {reduxStore} from '../../../space_opera_base.js';
import {fileModalStyles, openModalStyles} from '../../../styles.css.js';
import {ArConfigState, extractStagingConfig, ModelViewerConfig, RelativeFilePathsState, State} from '../../../types.js';
import {ConnectedLitElement} from '../../connected_lit_element/connected_lit_element.js';
import {dispatchSetHotspots} from '../../hotspot_panel/reducer.js';
import {dispatchAddEnvironmentImage} from '../../ibl_selector/reducer.js';
import {dispatchArConfig, getArConfig} from '../../mobile_view/reducer.js';
import {dispatchFileMap, dispatchGltfUrl, dispatchRootPath, getGltfUrl, getModelViewer} from '../../model_viewer_preview/reducer.js';
import {dispatchSetEnvironmentName, dispatchSetModelName, getRelativeFilePaths} from '../../relative_file_paths/reducer.js';
import {Dropdown} from '../../shared/dropdown/dropdown.js';
import {SnippetViewer} from '../../shared/snippet_viewer/snippet_viewer.js';
import {isObjectUrl} from '../../utils/create_object_url.js';
import {renderModelViewer} from '../../utils/render_model_viewer.js';
import {parseHotspotsFromSnippet} from '../parse_hotspot_config.js';
import {applyRelativeFilePaths, dispatchExtraAttributes, getExtraAttributes} from '../reducer.js';
import {parseExtraAttributes, parseSnippet, parseSnippetAr} from './parsing.js';
@customElement('me-open-modal')
export class OpenModal extends ConnectedLitElement {
static styles = openModalStyles;
@state() isOpen: boolean = false;
@state() config: ModelViewerConfig = {};
@state() arConfig: ArConfigState = {};
@state() errors: string[] = [];
@state() gltfUrl?: string;
@state() relativeFilePaths?: RelativeFilePathsState;
@query('snippet-viewer#snippet-input')
private readonly snippetViewer!: SnippetViewer;
@state() extraAttributes: any = {};
stateChanged(state: State) {
this.config = getConfig(state);
this.arConfig = getArConfig(state);
this.gltfUrl = getGltfUrl(state);
this.relativeFilePaths = getRelativeFilePaths(state);
this.extraAttributes = getExtraAttributes(state);
}
async handleSubmitSnippet(value?: string) {
const textArea = this.snippetViewer.snippet;
if (!textArea)
return;
this.errors = [];
let inputText: string = '';
if (value === undefined) {
inputText = textArea.value.trim();
} else {
inputText = value;
}
if (inputText.match(
/<\s*model-viewer[^>]*\s*>(\n|.)*<\s*\/\s*model-viewer>/)) {
const config = parseSnippet(inputText);
const arConfig = parseSnippetAr(inputText);
const extraAttributes = parseExtraAttributes(inputText);
reduxStore.dispatch(dispatchExtraAttributes(extraAttributes));
const hotspotErrors: Error[] = [];
const hotspotConfigs = parseHotspotsFromSnippet(inputText, hotspotErrors);
for (const error of hotspotErrors) {
this.errors.push(error.message);
}
try {
// If we can't fetch the snippet's src, don't even bother using it.
// But still dispatch the config, hotspots, etc.
if (config.src && (await fetch(config.src)).ok) {
reduxStore.dispatch(dispatchGltfUrl(undefined));
// Because of update-batching, we need to sleep first to force reload.
await new Promise(resolve => {
setTimeout(resolve, 0);
});
const {src} = config;
const index = src.lastIndexOf('/');
const rootPath = index === -1 ? './' : src.substr(0, index + 1);
const fileName = src.substr(index + 1);
reduxStore.dispatch(dispatchSetModelName(fileName));
reduxStore.dispatch(dispatchRootPath(rootPath));
reduxStore.dispatch(dispatchFileMap(new Map<string, File>()));
reduxStore.dispatch(dispatchGltfUrl(src));
}
} catch (e: any) {
console.log(
`Could not download 'src' attribute - OK, ignoring it. Error: ${
e.message}`);
}
if (config.environmentImage && isObjectUrl(config.environmentImage)) {
// If new env image is legal, use it
const engImageList = config.environmentImage.split('/');
const envImageName = engImageList[engImageList.length - 1];
reduxStore.dispatch(dispatchSetEnvironmentName(envImageName));
} else if (this.config.environmentImage) {
// else, if there was an env image in the state, leave it alone
config.environmentImage = this.config.environmentImage;
} else {
// else, reset env image
config.environmentImage = undefined;
reduxStore.dispatch(dispatchSetEnvironmentName(undefined));
}
// NOTE: It's important to dispatch these *after* the URL dispatches. If
// we dispatch the config and THEN clear the model URL, then
// config.animationName is cleared too (animation UI tries to find the
// anim by name, can't find it because the model is empty, thus
// triggering a change event selecting none).
reduxStore.dispatch(dispatchConfig(config));
reduxStore.dispatch(dispatchSetHotspots(hotspotConfigs));
reduxStore.dispatch(dispatchArConfig(arConfig));
} else {
this.errors = ['Could not find "model-viewer" tag in snippet'];
}
}
updated() {
const textArea = this.snippetViewer.snippet;
// Work-around closureZ issue.
if (textArea != null) {
textArea.style.backgroundColor =
this.errors.length > 0 ? 'pink' : 'white';
}
}
open() {
this.isOpen = true;
}
close() {
this.isOpen = false;
}
saveAndClose(event: Event) {
event.preventDefault();
this.handleSubmitSnippet();
if (this.errors.length === 0) {
this.close();
}
}
render() {
// Get the model-viewer snippet for the edit snippet modal
const editedConfig = {...this.config};
applyRelativeFilePaths(editedConfig, this.gltfUrl, this.relativeFilePaths!);
const exampleLoadableSnippet = renderModelViewer(
editedConfig, this.arConfig, this.extraAttributes, {}, undefined);
return html`
<paper-dialog id="file-modal" modal ?opened=${this.isOpen}>
<div class="FileModalContainer SnippetModal">
<div class="FileModalHeader">
<div>Edit Snippet</div>
</div>
<div style="font-size: 14px; font-weight: 500; margin-top: 10px; color: white">Edit or paste a &lt;model-viewer&gt snippet</div>
<div class="InnerSnippetModal">
<snippet-viewer id="snippet-input" .renderedSnippet=${
exampleLoadableSnippet} .isReadOnly=${false}>
</snippet-viewer>
</div>
</div>
<div class="FileModalCancel">
<mwc-button unelevated icon="save_alt" class="SaveButton"
@click=${this.saveAndClose}>Save</mwc-button>
<mwc-button unelevated icon="cancel"
@click=${this.close}>Cancel</mwc-button>
</div>
</paper-dialog>`;
}
}
/**
* A button to open file resources.
*/
@customElement('me-import-card')
export class ImportCard extends LitElement {
@query('me-open-modal#open-modal') openModal!: OpenModal;
@query('input#file-input') fileInput!: HTMLInputElement;
@state() selectedDefaultOption: number = 0;
static styles = fileModalStyles;
firstUpdated() {
const dropControl = new SimpleDropzone(getModelViewer(), this.fileInput);
dropControl.on('drop', ({files}: any) => this.onUpload(files));
}
async onUpload(fileMap: Map<string, File>) {
const modelViewer = getModelViewer();
let rootPath: string;
for (const [path, file] of fileMap) {
const filename = file.name.toLowerCase();
if (filename.match(/\.(gltf|glb)$/)) {
const blobURLs: Array<string> = [];
rootPath = path.replace(file.name, '');
ModelViewerElement.mapURLs((url: string) => {
const index = url.lastIndexOf('/');
const normalizedURL =
rootPath + url.substr(index + 1).replace(/^(\.?\/)/, '');
if (fileMap.has(normalizedURL)) {
const blob = fileMap.get(normalizedURL)!;
const blobURL = URL.createObjectURL(blob);
blobURLs.push(blobURL);
return blobURL;
}
return url;
});
modelViewer.addEventListener('load', () => {
blobURLs.forEach(URL.revokeObjectURL);
});
const fileURL =
typeof file === 'string' ? file : URL.createObjectURL(file);
const state = reduxStore.getState();
this.selectedDefaultOption = 0;
reduxStore.dispatch(dispatchSetModelName(file.name));
reduxStore.dispatch(dispatchRootPath(rootPath));
reduxStore.dispatch(dispatchFileMap(fileMap));
reduxStore.dispatch(
dispatchConfig(extractStagingConfig(getConfig(state))));
reduxStore.dispatch(dispatchSetHotspots([]));
reduxStore.dispatch(dispatchGltfUrl(fileURL));
}
}
if (fileMap.size === 1) {
const file = fileMap.values().next().value!;
const filename = file.name.toLowerCase();
let uri = URL.createObjectURL(file);
if (filename.match(/\.(hdr)$/)) {
uri += '#.hdr';
} else if (!filename.match(/\.(png|jpg)$/)) {
return;
}
reduxStore.dispatch(dispatchAddEnvironmentImage({uri, name: file.name}));
reduxStore.dispatch(dispatchEnvironmentImage(uri));
reduxStore.dispatch(dispatchSetEnvironmentName(file.name));
}
}
onSnippetOpen() {
this.openModal.open();
}
onDefaultSelect(event: CustomEvent) {
const dropdown = event.target as Dropdown;
const key = dropdown.selectedItem?.getAttribute('value');
const simpleMap: any = {
'Astronaut': 1,
'Horse': 2,
'RobotExpressive': 3,
'alpha-blend-litmus': 4,
'MacbethBalls': 11
};
const advancedMap: any = {
'BoomBox': 5,
'BrainStem': 6,
'Corset': 7,
'DamagedHelmet': 8,
'Lantern': 9,
'SpecGlossVsMetalRough': 10
};
if (key == null) {
return;
}
if (key === 'none') {
this.selectedDefaultOption = 0;
return;
}
const rootPath = 'https://modelviewer.dev/shared-assets/models/' +
(key in advancedMap ? `glTF-Sample-Assets/Models/${key}/glTF-Binary/` :
'');
const fileName = `${key}.glb`;
this.selectedDefaultOption =
key in advancedMap ? advancedMap[key] : simpleMap[key];
const src = rootPath + fileName;
const state = reduxStore.getState();
reduxStore.dispatch(dispatchSetModelName(fileName));
reduxStore.dispatch(dispatchRootPath(rootPath));
reduxStore.dispatch(dispatchFileMap(new Map<string, File>()));
reduxStore.dispatch(dispatchConfig(extractStagingConfig(getConfig(state))));
reduxStore.dispatch(dispatchSetHotspots([]));
reduxStore.dispatch(dispatchGltfUrl(src));
}
render() {
return html`
<me-open-modal id="open-modal"></me-open-modal>
<me-file-modal accept=".glb,model/gltf-binary"></me-file-modal>
<div style="font-size: 14px; font-weight: 500; margin-bottom: 10px;">Select Model:</div>
<div style="display: flex; justify-content: space-between">
<me-dropdown
.selectedIndex=${this.selectedDefaultOption}
slot="content" style="align-self: center; width: 71%;"
@select=${this.onDefaultSelect}
>
<paper-item value='none'>None</paper-item>
<paper-item value='Astronaut'>Astronaut</paper-item>
<paper-item value='Horse'>Horse</paper-item>
<paper-item value='RobotExpressive'>Robot</paper-item>
<paper-item value='alpha-blend-litmus'>Transparency Test</paper-item>
<paper-item value='BoomBox'>Boom Box</paper-item>
<paper-item value='BrainStem'>Brain Stem</paper-item>
<paper-item value='Corset'>Corset</paper-item>
<paper-item value='DamagedHelmet'>Damaged Helmet</paper-item>
<paper-item value='Lantern'>Lantern</paper-item>
<paper-item value='SpecGlossVsMetalRough'>Water Bottles</paper-item>
<paper-item value='MacbethBalls'>Macbeth</paper-item>
</me-dropdown>
<mwc-button unelevated label="GLB" icon="file_upload" class="UploadButton">
<label for="file-input" class="FileInputLabel"/>
</mwc-button>
<input type="file" id="file-input" multiple/>
</div>
<me-validation></me-validation>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-import-card': ImportCard;
'me-open-modal': OpenModal;
}
}
@@ -0,0 +1,132 @@
/**
* @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 {ArConfigState, ModelViewerConfig} from '../../../types';
// A list of accepted attributes in render_model_viewer. Used when parsing for
// extra attributes.
const ATTRIBUTE_LIST: string[] = [
'src',
'auto-rotate',
'camera-controls',
'environment-image',
'skybox-image',
'exposure',
'tone-mapping',
'poster',
'reveal',
'shadow-intensity',
'shadow-softness',
'max-camera-orbit',
'min-camera-orbit',
'max-field-of-view',
'min-field-of-view',
'autoplay',
'animation-name',
'camera-orbit',
'camera-target',
'field-of-view',
'ar',
'ar-modes'
];
// Creating object in this way such that "spread" works.
// https://open-wc.org/docs/development/lit-helpers/#regular-spread
export function parseExtraAttributes(snippet: string): string {
// Parse snippet and extract attributes from model-viewer
const parsedInput = new DOMParser().parseFromString(snippet, 'text/html');
const modelViewer = parsedInput.body.getElementsByTagName('model-viewer')[0];
let extraAttributes: any = {};
const attributes = modelViewer.attributes;
// Loop through every attribute, only add the attributes to extraAttributes
// if they are not a part of config or arConfig
for (let i = 0; i < attributes.length; i++) {
const name: string = attributes[i].name;
const value = attributes[i].value;
// if neither config has the key, add it to extra attribute string
if (!(ATTRIBUTE_LIST.includes(name))) {
if (value.length === 0) {
// Boolean attribute is true
extraAttributes[`?${name}`] = true;
} else {
// Normal attribute is set to it's snippet's value
extraAttributes[`${name}`] = value;
}
}
}
return extraAttributes;
}
// logic similar to parseSnippet inside of editing adapter.
export function parseSnippetAr(snippet: string): ArConfigState {
const parsedInput = new DOMParser().parseFromString(snippet, 'text/html');
const modelViewer = parsedInput.body.getElementsByTagName('model-viewer')[0];
const arConfig: ArConfigState = {};
arConfig.ar = modelViewer.hasAttribute('ar') || undefined;
arConfig.arModes = modelViewer.getAttribute('ar-modes') || undefined;
return arConfig
}
function tryParseNumberAttribute(element: Element, attribute: string): number|
undefined {
const attributeValue = element.getAttribute(attribute);
if (element.hasAttribute(attribute) && attributeValue !== '' &&
isFinite(Number(attributeValue))) {
return Number(attributeValue);
}
return undefined;
}
/**
* Parse a string representation of a model-viewer tag.
*/
export function parseSnippet(snippet: string): ModelViewerConfig {
const parsedInput = new DOMParser().parseFromString(snippet, 'text/html');
const modelViewer = parsedInput.body.getElementsByTagName('model-viewer')[0];
const config: ModelViewerConfig = {};
config.src = modelViewer.getAttribute('src') || undefined;
config.autoRotate = modelViewer.hasAttribute('auto-rotate');
config.cameraControls = modelViewer.hasAttribute('camera-controls');
// NOTE: bgcolor not well-supported yet, since real mv tags put this in
// the style tag. Will need to reconsider how we approach style in general.
config.environmentImage =
modelViewer.getAttribute('environment-image') || undefined;
config.useEnvAsSkybox = config.environmentImage !== undefined &&
modelViewer.getAttribute('skybox-image') === config.environmentImage;
config.exposure = tryParseNumberAttribute(modelViewer, 'exposure');
config.toneMapping = modelViewer.getAttribute('tone-mapping') || undefined;
config.poster = modelViewer.getAttribute('poster') || undefined;
config.reveal = modelViewer.getAttribute('reveal') || undefined;
config.shadowIntensity =
tryParseNumberAttribute(modelViewer, 'shadow-intensity');
config.shadowSoftness =
tryParseNumberAttribute(modelViewer, 'shadow-softness');
config.maxCameraOrbit =
modelViewer.getAttribute('max-camera-orbit') || undefined;
config.minCameraOrbit =
modelViewer.getAttribute('min-camera-orbit') || undefined;
config.maxFov = modelViewer.getAttribute('max-field-of-view') || undefined;
config.minFov = modelViewer.getAttribute('min-field-of-view') || undefined;
config.autoplay = modelViewer.hasAttribute('autoplay');
config.animationName =
modelViewer.getAttribute('animation-name') || undefined;
config.cameraOrbit = modelViewer.getAttribute('camera-orbit') || undefined;
config.cameraTarget = modelViewer.getAttribute('camera-target') || undefined;
config.fieldOfView = modelViewer.getAttribute('field-of-view') || undefined;
return config;
}
@@ -0,0 +1 @@
declare module 'simple-dropzone';
@@ -0,0 +1,285 @@
/**
* @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 {WebIO} from '@gltf-transform/core';
import {KHRONOS_EXTENSIONS} from '@gltf-transform/extensions';
import {metalRough} from '@gltf-transform/functions';
import {html, LitElement} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import {validationStyles} from '../../../styles.css.js';
import {State} from '../../../types.js';
import {ConnectedLitElement} from '../../connected_lit_element/connected_lit_element';
import {dispatchGltfUrl, getModel, getModelViewer} from '../../model_viewer_preview/reducer.js';
import {resolveExternalResource, validateGltf} from './validation_utils.js';
import type {Report, Message} from './validation_utils';
import {GLTF} from '@google/model-viewer/lib/three-components/gltf-instance/gltf-defaulted';
import {reduxStore} from '../../../space_opera_base.js';
@customElement('me-validation-modal')
export class ValidationModal extends LitElement {
static styles = [validationStyles];
@property({attribute: false}) report: Report = {};
@state() isOpen: boolean = false;
open() {
this.isOpen = true;
}
close() {
this.isOpen = false;
}
renderTable(color: string, title: string, messages: Message) {
return html`
<table class="report-table">
<thead>
<tr style="background: ${color};">
<th>${title}</th>
<th>Message</th>
<th>Pointer</th>
</tr>
</thead>
<tbody>
${messages.map(message => html`
<tr>
<td><code>${message.code}</code></td>
<td>${message.message}</td>
<td><code>${message.pointer}</code></td>
</tr>
`)}
</tbody>
</table>
`;
}
renderMetaData() {
return html`
<div class="report">
<h1>Validation report</h1>
<ul>
<li><b>Format:</b> glTF ${this.report.info!.version}</li>
<li><b>Generator:</b> ${this.report.info!.generator}</li>
<li>
<b>Stats:</b>
<ul>
<li>${this.report.info!.drawCallCount} draw calls</li>
<li>${this.report.info!.animationCount} animations</li>
<li>${this.report.info!.totalJointCount} joints</li>
<li>${this.report.info!.materialCount} materials</li>
<li>${this.report.info!.totalVertexCount} vertices</li>
<li>${this.report.info!.totalTriangleCount} triangles</li>
<li>${this.report.info!.width?.toPrecision(3)} m x-width</li>
<li>${this.report.info!.height?.toPrecision(3)} m y-height</li>
<li>${this.report.info!.length?.toPrecision(3)} m z-length</li>
<li>Extensions used: ${
this.report.info!.extensionsUsed?.join(', ')}</li>
${
this.report.info!.converted ? html`
<li><b>KHR_materials_pbrSpecularGlossiness extension was encountered, but is no longer supported.
This file has been automatically (and losslessly) converted to Metallic-Roughness.
Please download the GLB to get the updated version.</b></li>` :
html``}
</ul>
</li>
</ul>
<hr/>
<p>
Report generated by
<a href="https://github.com/KhronosGroup/glTF-Validator/">KhronosGroup/glTF-Validator</a>
${this.report.validatorVersion}.
</p>
</div>
`;
}
render() {
if (this.report.info == null) {
return html``;
}
return html`
<paper-dialog id="file-modal" modal ?opened=${this.isOpen}>
<div class="container">
<div class="cancel">
<mwc-button unelevated icon="cancel" @click=${this.close}>
Close
</mwc-button>
</div>
${this.renderMetaData()}
${
this.report.issues!.numErrors ?
this.renderTable('#f44336', 'Error', this.report!.errors!) :
html``}
${
this.report.issues!.numWarnings ?
this.renderTable('#f9a825', 'Warning', this.report!.warnings!) :
html``}
${
this.report.issues!.numHints ?
this.renderTable('#8bc34a', 'Hint', this.report!.hints!) :
html``}
${
this.report.issues!.numInfos ?
this.renderTable('#2196f3', 'Info', this.report!.infos!) :
html``}
</div>
</paper-dialog>`;
}
}
/**
* Model validator
*/
@customElement('me-validation')
export class Validation extends ConnectedLitElement {
@query('me-validation-modal#validation-modal')
validationModal!: ValidationModal;
@state() gltfUrl?: string;
@state() rootPath = '';
@state() fileMap = new Map<string, File>();
@state() originalGltf?: GLTF;
@state() report: Report = {};
@state() converted = false;
@state() severityTitle: string = '';
@state() severityColor: string = '';
async stateChanged(state: State) {
const {rootPath, originalGltf, gltfUrl, fileMap} = getModel(state);
if (rootPath != null) {
this.rootPath = rootPath;
}
if (originalGltf != null && gltfUrl != null &&
this.originalGltf !== originalGltf) {
if (this.severityTitle = 'Converted') {
URL.revokeObjectURL(this.gltfUrl!);
}
this.originalGltf = originalGltf;
this.fileMap = fileMap;
this.gltfUrl = gltfUrl;
await this.awaitLoad(gltfUrl);
if (this.severityTitle === 'Converting') {
// Auto-convert SpecGloss to MetalRough and reload.
const io = new WebIO().registerExtensions(KHRONOS_EXTENSIONS);
const doc = await io.read(gltfUrl);
await doc.transform(metalRough());
const glb = await io.writeBinary(doc);
const blob = new Blob([glb], {type: 'application/octet-stream'});
const fileURL = URL.createObjectURL(blob);
this.converted = true;
reduxStore.dispatch(dispatchGltfUrl(fileURL));
return;
} else {
this.converted = false;
}
this.countJoints(originalGltf);
const dimensions = getModelViewer().getDimensions();
this.report.info = {
...this.report?.info,
width: dimensions.x,
length: dimensions.z,
height: dimensions.y
};
}
}
async awaitLoad(url: string) {
this.report = await validateGltf(url, (uri: string) => {
return resolveExternalResource(uri, this.rootPath, this.fileMap);
});
this.severityTitle = 'Model Details';
this.severityColor = '#3c4043';
if (this.report.issues!.numInfos) {
this.severityColor = '#2196f3';
this.severityTitle = 'Info';
}
if (this.report.issues!.numHints) {
this.severityColor = '#8bc34a';
this.severityTitle = 'Hint';
}
if (this.report.issues!.numWarnings) {
this.severityColor = '#f9a825';
this.severityTitle = 'Warning';
}
if (this.report.issues!.numErrors) {
this.severityColor = '#f44336';
this.severityTitle = 'Error';
}
if (this.converted) {
this.report.info!.converted = true;
this.severityColor = '#8bc34a';
this.severityTitle = 'Converted';
}
if (!!this.report.info?.extensionsUsed?.find(
(e) => e == 'KHR_materials_pbrSpecularGlossiness')) {
this.severityColor = '#f9a825';
this.severityTitle = 'Converting';
}
}
countJoints(gltf: GLTF) {
const jointSet = new Set();
if (gltf.skins != null) {
for (const skin of gltf.skins) {
for (const joint of skin.joints) {
jointSet.add(joint);
}
}
}
this.report.info = {...this.report?.info, totalJointCount: jointSet.size};
}
onOpen() {
this.validationModal.open();
}
render() {
return html`
${
this.severityTitle.length > 0 ? html`
<div style="font-size: 14px; font-weight: 500; margin: 10px 0px;">Validation Report:</div>
<mwc-button unelevated style="align-self: center; padding-top: 10px;"
@click=${this.onOpen} style="--mdc-theme-primary: ${this.severityColor}">
${this.severityTitle}
</mwc-button>` :
html``}
${
this.report ? html`
<me-validation-modal id="validation-modal" .report=${this.report}>
</me-validation-modal>
` :
html``}
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-validation-modal': ValidationModal;
'me-validation': Validation;
}
}
@@ -0,0 +1,158 @@
/**
* @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.
*
*/
// Code extracted from:
// https://github.com/donmccurdy/three-gltf-viewer/
import {validateBytes} from 'gltf-validator';
const SEVERITY_MAP = ['Errors', 'Warnings', 'Infos', 'Hints'];
export type Report = {
info?: {
version?: string;
generator?: string;
extensionsUsed?: [string];
drawCallCount?: number;
animationCount?: number;
materialCount?: number;
totalVertexCount?: number;
totalTriangleCount?: number;
totalJointCount?: number;
width?: number;
length?: number;
height?: number;
converted?: boolean;
};
validatorVersion?: string;
issues?: {
numErrors?: number;
numWarnings?: number;
numHints?: number;
numInfos?: number;
};
errors?: Message;
warnings?: Message;
hints?: Message;
infos?: Message;
};
export type Message = {
code: string; message: string; pointer: string;
}[];
/**
* Passes the gltf url to be validated.
*/
export async function validateGltf(
url: string,
externalResourceFunction: (uri: string) => Promise<Uint8Array>) {
try {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const report =
await validateBytes(new Uint8Array(buffer), {externalResourceFunction});
return setReport(report);
} catch (e) {
console.log('Error,', e);
}
}
/**
* Loads a resource (either locally or from the network) and returns it.
*/
export async function resolveExternalResource(
uri: string, rootPath: string, fileMap: Map<string, File>):
Promise<Uint8Array> {
const index = uri.lastIndexOf('/');
const normalizedURL =
rootPath + uri.substr(index + 1).replace(/^(\.?\/)/, '');
if (fileMap.has(normalizedURL)) {
const blob = fileMap.get(normalizedURL);
const buffer = await blob!.arrayBuffer();
return new Uint8Array(buffer);
}
const response = await fetch(rootPath + uri);
const buffer = await response.arrayBuffer();
return new Uint8Array(buffer);
}
/**
* Sets the values of the report.
* @param {GLTFValidator.Report} report returned object
*/
function setReport(report) {
report.issues.maxSeverity = -1;
SEVERITY_MAP.forEach((severity, index) => {
if (report.issues[`num${severity}`] > 0 &&
report.issues.maxSeverity === -1) {
report.issues.maxSeverity = index;
}
});
report.errors = report.issues.messages.filter((msg) => msg.severity === 0);
report.warnings = report.issues.messages.filter((msg) => msg.severity === 1);
report.infos = report.issues.messages.filter((msg) => msg.severity === 2);
report.hints = report.issues.messages.filter((msg) => msg.severity === 3);
groupMessages(report);
return report;
}
/**
* Orders messages for errors correctly.
*/
function groupMessages(report) {
const CODES = {
ACCESSOR_NON_UNIT: {
message: '{count} accessor elements not of unit length: 0. [AGGREGATED]',
pointerCounts: {}
},
ACCESSOR_ANIMATION_INPUT_NON_INCREASING: {
message:
'{count} animation input accessor elements not in ascending order. [AGGREGATED]',
pointerCounts: {}
}
};
report.errors.forEach((message) => {
if (!CODES[message.code])
return;
if (!CODES[message.code].pointerCounts[message.pointer]) {
CODES[message.code].pointerCounts[message.pointer] = 0;
}
CODES[message.code].pointerCounts[message.pointer]++;
});
report.errors = report.errors.filter((message) => {
if (!CODES[message.code])
return true;
if (!CODES[message.code].pointerCounts[message.pointer])
return true;
return CODES[message.code].pointerCounts[message.pointer] < 2;
});
Object.keys(CODES).forEach((code) => {
Object.keys(CODES[code].pointerCounts).forEach((pointer) => {
report.errors.push({
code: code,
pointer: pointer,
message: CODES[code].message.replace(
'{count}', CODES[code].pointerCounts[pointer])
});
});
});
}
@@ -0,0 +1,180 @@
/**
* @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/mwc-button';
import './components/open_button.js';
import './components/download_button.js';
import '../mobile_view/open_mobile_view.js';
import '../shared/snippet_viewer/snippet_viewer.js';
import '../shared/expandable_content/expandable_tab.js';
import {html} from 'lit';
import {customElement, state, property, query} from 'lit/decorators.js';
import {reduxStore} from '../../space_opera_base.js';
import {ArConfigState, BestPracticesState, ImageType, INITIAL_STATE, ModelViewerConfig, RelativeFilePathsState, State} from '../../types.js';
import {getBestPractices} from '../best_practices/reducer.js';
import {getConfig} from '../config/reducer.js';
import {ConnectedLitElement} from '../connected_lit_element/connected_lit_element.js';
import {getHotspots} from '../hotspot_panel/reducer.js';
import {HotspotConfig} from '../hotspot_panel/types.js';
import {getArConfig} from '../mobile_view/reducer.js';
import {getGltfUrl, renderCommonChildElements} from '../model_viewer_preview/reducer.js';
import {dispatchSetPosterName, getRelativeFilePaths} from '../relative_file_paths/reducer.js';
import {DraggableInput} from '../shared/draggable_input/draggable_input.js';
import {Dropdown} from '../shared/dropdown/dropdown.js';
import {SnippetViewer} from '../shared/snippet_viewer/snippet_viewer.js';
import {renderModelViewer} from '../utils/render_model_viewer.js';
import {ExportZipButton} from './components/download_button.js';
import {ImportCard} from './components/open_button.js';
import {applyRelativeFilePaths, dispatchHeight, dispatchMimeType, getExtraAttributes} from './reducer.js';
const DEFAULT_POSTER_HEIGHT =
INITIAL_STATE.entities.modelViewerSnippet.poster.height;
/**
*
*/
@customElement('me-export-panel')
export class ExportPanel extends ConnectedLitElement {
@property({type: Boolean}) header = false;
@state() config: ModelViewerConfig = {};
@state() arConfig: ArConfigState = {};
@state() hotspots: HotspotConfig[] = [];
@state() relativeFilePaths?: RelativeFilePathsState;
@state() gltfUrl?: string;
@state() extraAttributes: any = {};
@state() bestPractices?: BestPracticesState;
@query('snippet-viewer') snippetViewer!: SnippetViewer;
@query('me-export-zip-button') exportZipButton!: ExportZipButton;
@query('me-import-card') importCard!: ImportCard;
@query('me-draggable-input#height') heightInput!: DraggableInput;
stateChanged(state: State) {
this.config = getConfig(state);
this.arConfig = getArConfig(state);
this.hotspots = getHotspots(state);
this.gltfUrl = getGltfUrl(state);
this.relativeFilePaths = getRelativeFilePaths(state);
this.extraAttributes = getExtraAttributes(state);
this.bestPractices = getBestPractices(state);
}
snippetCopyToClipboard() {
this.snippetViewer.copyToClipboard();
}
onSnippetOpen() {
this.importCard.onSnippetOpen();
}
onHeightChange() {
reduxStore.dispatch(dispatchHeight(this.heightInput.value));
}
onPosterSelect(event: CustomEvent) {
const dropdown = event.target as Dropdown;
const type = dropdown.selectedItem.getAttribute('value') as ImageType;
reduxStore.dispatch(dispatchMimeType(type));
const name = 'poster.' + type.substring(6);
reduxStore.dispatch(dispatchSetPosterName(name));
}
render() {
const editedConfig = {...this.config};
applyRelativeFilePaths(editedConfig, this.gltfUrl, this.relativeFilePaths!);
const childElements =
renderCommonChildElements(this.hotspots, this.bestPractices!);
const snippet = renderModelViewer(
editedConfig, this.arConfig, this.extraAttributes, {}, childElements);
if (this.header === true) {
return html`
<me-expandable-tab tabName="&lt;model-viewer&gt; snippet"
.open=${true} .sticky=${false}
.copyFunction=${this.snippetCopyToClipboard.bind(this)}>
<div slot="content">
<snippet-viewer id="snippet-header" .renderedSnippet=${snippet}>
</snippet-viewer>
</div>
</me-expandable-tab>`;
}
// on import/export tab
return html`
<me-expandable-tab tabName="&lt;model-viewer&gt; snippet"
.open=${true} .sticky=${false}
.copyFunction=${this.snippetCopyToClipboard.bind(this)}>
<div slot="content">
<snippet-viewer id="snippet-header" .renderedSnippet=${snippet}>
</snippet-viewer>
<mwc-button unelevated @click=${this.onSnippetOpen}>
Edit Snippet
</mwc-button>
</div>
</me-expandable-tab>
<me-expandable-tab tabName="File Manager" .open=${true}>
<div slot="content">
<me-import-card></me-import-card>
<me-section-row class="Row" style="display: flex; margin-top: 10px;" label="Poster:">
<me-draggable-input id="height" style="width: 100px; align-self: center;" value=${
DEFAULT_POSTER_HEIGHT}
min=256 max=2048 precision=0 dragStepSize=8 @change=${
this.onHeightChange} innerLabel="Height"></me-draggable-input>
<me-dropdown style="width: 80px; margin-left: 10px;"
@select=${this.onPosterSelect}
>
<paper-item value='image/webp'>WEBP</paper-item>
<paper-item value='image/png'>PNG</paper-item>
<paper-item value='image/jpeg'>JPEG</paper-item>
</me-dropdown>
</me-section-row>
<div style="display: flex; justify-content: space-between; margin-top: 10px; gap: 0.5rem">
<me-export-zip-button id="export-zip"></me-export-zip-button>
<me-export-poster-button></me-export-poster-button>
</div>
</div>
</me-expandable-tab>
<me-expandable-tab tabName="Mobile View" .open=${true}>
<open-mobile-view slot="content"></open-mobile-view>
</me-expandable-tab>
<me-expandable-tab tabName="Best Practices" .open=${true}>
<best-practices slot="content"></best-practices>
</me-expandable-tab>
`;
}
protected updated() {
this.snippetViewer.updateComplete.then(() => {
if (this.exportZipButton) {
this.exportZipButton.snippetText =
this.snippetViewer.snippet.textContent || '';
}
});
}
}
declare global {
interface HTMLElementTagNameMap {
'me-export-panel': ExportPanel;
}
}
@@ -0,0 +1,79 @@
/**
* @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 {HotspotConfig} from '../hotspot_panel/types.js';
/**
* Parses a paragraph of model-viewer tag snippet and extracts the hotspot
* configs. Add corresponding error to errorList if exists
*/
export function parseHotspotsFromSnippet(
snippet: string, errorList?: Error[]): HotspotConfig[] {
const parsedInput = new DOMParser().parseFromString(snippet, 'text/html');
const modelViewer = parsedInput.body.getElementsByTagName('model-viewer')[0];
if (!modelViewer) {
throw new Error('Invalid snippet, no model-viewer tag found.');
}
const configs: HotspotConfig[] = [];
const childElements = Array.from(modelViewer.children);
for (const hotspotElement of childElements) {
try {
const hotspotHtmlElement = hotspotElement as HTMLElement;
// Parse the hotspot config if the slot-name fit hotspot format, continue
// if it doesn't
if (parseHotspotName(hotspotHtmlElement)) {
const config = parseHotspotConfig(hotspotElement as HTMLElement);
configs.push(config);
}
} catch (error) {
errorList?.push(error as Error);
}
}
return configs;
}
function parseHotspotConfig(element: HTMLElement): HotspotConfig {
const name = parseHotspotName(element);
if (!name) {
throw new Error(
`Invalid hotspot slot name: ${element.getAttribute('slot')}`);
}
const surface = element.dataset['surface'];
const position = element.dataset['position'];
const normal = element.dataset['normal'];
if (!surface && !position) {
throw new Error(`no surface or position for hotspot at slot "${
element.getAttribute('slot')}"`);
}
const annotation = (element.querySelector('.HotspotAnnotation') as HTMLElement)?.innerText || undefined; // Update here
return {name, surface, position, normal, annotation};
}
/**
* Returns the slot name of the element without 'hotspot-'. Returns undefined if
* the slot name doesn't fit hotspot- format required by model-viewer.
*/
function parseHotspotName(element: HTMLElement): string|undefined {
let name = element.getAttribute('slot');
if (!name || !name.match(/^hotspot-/)) {
return undefined;
}
name = name.replace(/^hotspot-/, '');
return name;
}
@@ -0,0 +1,89 @@
/**
* @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 {Action, ImageType, INITIAL_STATE, ModelViewerConfig, ModelViewerSnippetState, PosterConfig, RelativeFilePathsState, State} from '../../types.js';
export const getModelViewerSnippet = (state: State): ModelViewerSnippetState =>
state.entities.modelViewerSnippet;
/**
* Sets the filepaths of a copy of the config.
* Used when displaying and exporting the snippet.
* NOT used inside the actual model-viewer element.
*/
export function applyRelativeFilePaths(
editedConfig: ModelViewerConfig,
gltfUrl: string|undefined,
relativeFilePaths: RelativeFilePathsState) {
if (gltfUrl) {
editedConfig.src = relativeFilePaths.modelName
} else {
editedConfig.src = 'Upload model...';
}
if (editedConfig.environmentImage) {
editedConfig.environmentImage = relativeFilePaths.environmentName;
}
editedConfig.poster = relativeFilePaths.posterName;
}
const SET_EXTRA_ATTRIBUTES = 'SET_EXTRA_ATTRIBUTES';
export function dispatchExtraAttributes(attributes: any) {
return {type: SET_EXTRA_ATTRIBUTES, payload: attributes};
}
export const getExtraAttributes = (state: State): any =>
state.entities.modelViewerSnippet.extraAttributes;
export function extraAttributesReducer(
state: any = INITIAL_STATE.entities.modelViewerSnippet.extraAttributes,
action: Action): any {
switch (action.type) {
case SET_EXTRA_ATTRIBUTES:
return action.payload;
default:
return state;
}
}
const SET_HEIGHT = 'SET_HEIGHT';
export function dispatchHeight(height: number) {
return {type: SET_HEIGHT, payload: height};
}
const SET_MIMETYPE = 'SET_MIMETYPE';
export function dispatchMimeType(type: ImageType) {
return {type: SET_MIMETYPE, payload: type};
}
export function getPosterConfig(state: State) {
return state.entities.modelViewerSnippet.poster;
}
export function posterReducer(
state: PosterConfig = INITIAL_STATE.entities.modelViewerSnippet.poster,
action: Action): PosterConfig {
switch (action.type) {
case SET_HEIGHT:
return {...state, height: action.payload};
case SET_MIMETYPE:
return {...state, mimeType: action.payload};
default:
return state;
}
}
@@ -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 {Action, INITIAL_STATE, RelativeFilePathsState, State} from '../../types';
const SET_MODEL_NAME = 'SET_MODEL_NAME';
export function dispatchSetModelName(name: string|undefined) {
return {type: SET_MODEL_NAME, payload: name};
}
const SET_ENVIRONMENT_NAME = 'SET_ENVIRONMENT_NAME';
export function dispatchSetEnvironmentName(name: string|undefined) {
return {type: SET_ENVIRONMENT_NAME, payload: name};
}
const SET_POSTER_NAME = 'SET_POSTER_NAME';
export function dispatchSetPosterName(name: string|undefined) {
return {type: SET_POSTER_NAME, payload: name};
}
export const getRelativeFilePaths = (state: State) =>
state.entities.modelViewerSnippet.relativeFilePaths;
export function relativeFilePathsReducer(
state: RelativeFilePathsState =
INITIAL_STATE.entities.modelViewerSnippet.relativeFilePaths,
action: Action): RelativeFilePathsState {
switch (action.type) {
case SET_MODEL_NAME:
return {...state, modelName: action.payload};
case SET_ENVIRONMENT_NAME:
return {...state, environmentName: action.payload};
case SET_POSTER_NAME:
return {...state, posterName: action.payload};
default:
return state;
}
}
@@ -0,0 +1,37 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`
:host {
display: flex;
--material-color-google-blue-500: #4285f4;
--material-color-google-grey-300: #dadce0;
}
mwc-checkbox {
--mdc-theme-secondary: var(--material-color-google-blue-500);
--mdc-checkbox-unchecked-color: var(--text-on-expandable-background);
margin: auto 0;
}
.Label {
margin: auto 0;
color: var(--text-on-expandable-background);
}
`;
@@ -0,0 +1,71 @@
/**
* @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/mwc-checkbox';
import {Checkbox} from '@material/mwc-checkbox';
import {html, LitElement} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
import {styles} from './checkbox.css.js';
/**
* A styled checkbox with label
*/
@customElement('me-checkbox')
export class CheckboxElement extends LitElement {
static styles = styles;
/** Proxies to mwc-checkbox's checked field */
@property({type: Boolean}) checked = false;
@property({type: String}) label = '';
@query('mwc-checkbox') checkbox!: Checkbox;
// Specifically overriding a super class method.
// tslint:disable-next-line:enforce-name-casing
async getUpdateComplete() {
const noTrigger = await super.getUpdateComplete();
await this.checkbox.updateComplete;
return noTrigger;
}
render() {
return html`
<mwc-checkbox ?checked="${this.checked}" @change="${
this.onCheckedChange}"></mwc-checkbox>
<div class="Label">
${this.label}
</div>
`;
}
onCheckedChange() {
this.checked = this.checkbox.checked;
this.dispatchEvent(new Event('change'));
}
// Convenience function for testing
click() {
this.checkbox.click();
}
}
declare global {
interface HTMLElementTagNameMap {
'me-checkbox': CheckboxElement;
}
}
@@ -0,0 +1,92 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
height: 150px;
width: 200px;
position: relative;
display: block;
--thumb-size: 12px;
}
.ColorMapContainer {
height: 100%;
}
.El {
position: relative;
min-width: 100px;
min-height: 100px;
touch-action: none; /* prevents touch events causing page scroll. */
}
.ColorPointer {
position: absolute;
box-sizing: border-box;
width: 12px;
height: 12px;
top: 50%;
left: 50%;
border-radius: 50%;
margin: -6px 0 0 -6px;
z-index: 2;
}
.LargeSwatches .ColorMap .ColorPointer {
width: 20px;
height: 20px;
}
.ColorMapBackground {
position: absolute;
top: 0;
width: 100%;
height: 100%;
left: 0;
}
.WhiteToTransparentBackground {
background: linear-gradient(to right, #fff 0%, rgba(255,255,255,0) 100%);
z-index: 0;
}
.WhiteToBlackBackground {
background: linear-gradient(to bottom, transparent 0%, #000 100%);
z-index: 1;
}
.Outer {
border: 2px solid rgba(255, 255, 255, .8);
box-sizing: border-box;
border-radius: 100%;
width: 12px;
height: 12px;
position: absolute;
}
.Ink {
border-radius: 100%;
height: var(--thumb-size);
left: 0;
opacity: 0;
position: absolute;
width: var(--thumb-size);
}
`;
@@ -0,0 +1,103 @@
/**
* @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 {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styleMap} from 'lit/directives/style-map.js';
import * as color from 'ts-closure-library/lib/color/color'; // from //third_party/javascript/closure/color
import {clamp} from '../../utils/reducer_utils.js';
import {styles} from './color_map.css.js';
/**
* A color map for HSV colors, displays a gradient field of Saturation (x-axis)
* versus Value (y-axis) given Hue.
*/
@customElement('me-color-map')
export class ColorMap extends LitElement {
static styles = styles;
@property({type: Number}) hue = 0;
@property({type: Number}) saturation = 0;
@property({type: Number}) value = 0;
readonly mouseEventHandler = this.onMouseEvent.bind(this); // NOTYPO
readonly mouseUpHandler = this.onMouseUp.bind(this); // NOTYPO
/** Used for rendering the hsv colormap. */
private get backgroundColor() {
return color.hslToHex(this.hue, 1, 0.5);
}
get selectedColorHex() {
return color.hsvToHex(this.hue, this.saturation, this.value);
}
onMouseDown(event: MouseEvent) {
document.body.addEventListener('mousemove', this.mouseEventHandler);
document.body.addEventListener('mouseup', this.mouseUpHandler);
this.onMouseEvent(event);
}
onMouseEvent(event: MouseEvent) {
const clientRect = this.getBoundingClientRect();
this.saturation =
clamp((event.clientX - clientRect.left) / this.offsetWidth, 0, 1);
this.value = clamp(
255 - (event.clientY - clientRect.top) / this.offsetHeight * 255,
0,
255);
event.preventDefault();
this.dispatchEvent(new Event('change'));
}
onMouseUp() {
document.body.removeEventListener('mousemove', this.mouseEventHandler);
document.body.removeEventListener('mouseup', this.mouseEventHandler);
}
render() {
return html`
<div class="ColorMapContainer" style=${styleMap({
'background-color': this.backgroundColor
})}
@mousedown="${this.onMouseDown}">
<div class="ColorPointer" style=${styleMap({
left: `${this.saturation * 100}%`,
top: `${100 - this.value * 100 / 255}%`,
'background-color': this.selectedColorHex,
})}>
<div class="Ink exportInk"></div>
<div class="Outer exportOuterThumb"></div>
</div>
<div class="ColorMapBackground WhiteToTransparentBackground"></div>
<div class="ColorMapBackground WhiteToBlackBackground"></div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-color-map': ColorMap;
}
}
@@ -0,0 +1,61 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
--me-swatch-size: 48px;
--material-color-google-grey-800: #3c4043;
}
.ColorPickerContent {
width: 200px;
display: block;
background: var(--material-color-google-grey-800);
padding: 10px;
}
.Thumbnail {
height: calc(var(--me-swatch-size) - 2px);
width: calc(var(--me-swatch-size) - 2px);
border-radius: 3px;
border: 1px solid white;
}
.HueSlider {
-webkit-appearance: none;
background: linear-gradient(to right,hsl(0,100%,50%),hsl(60,100%,50%),hsl(120,100%,50%),hsl(180,100%,50%),hsl(240,100%,50%),hsl(300,100%,50%),hsl(360,100%,50%));
border-radius: 5px;
height: 10px;
margin-top: 10px;
outline: none;
width: 100%;
}
.HexInput {
background: #202124; /* GOOGLE GREY 900 */
border-radius: 4px;
border: none;
color: #dadce0; /* GOOGLE GREY 300 */
font-size: 16px;
height: 20px;
margin-top: 18px;
outline: none;
padding: 5px 10px;
width: calc(100% - 20px);
}
`;
@@ -0,0 +1,99 @@
/**
* @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_map.js';
import '../popup/popup.js';
import {html, LitElement} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
import {styleMap} from 'lit/directives/style-map.js';
import * as color from 'ts-closure-library/lib/color/color'; // from //third_party/javascript/closure/color
import {ColorMap} from './color_map.js';
import {styles} from './color_picker.css.js';
/** Color picker. */
@customElement('me-color-picker')
export class ColorPicker extends LitElement {
static styles = styles;
@query('me-color-map') colorMap!: ColorMap;
@query('input#hueSlider') hueSlider!: HTMLInputElement;
@query('input#hexInput') hexInput!: HTMLInputElement;
@property({type: String}) selectedColorHex: string = '#000000';
get selectedColorHsv() {
return color.hexToHsv(this.selectedColorHex);
}
onColorChange() {
this.selectedColorHex = this.colorMap.selectedColorHex;
this.dispatchEvent(new Event('change'));
}
onHueInput() {
this.selectedColorHex = color.hsvToHex(
Number(this.hueSlider.value),
this.selectedColorHsv[1],
this.selectedColorHsv[2]);
this.dispatchEvent(new Event('change'));
}
onHexInput() {
try {
const inputValue = color.normalizeHex(this.hexInput.value);
this.selectedColorHex = inputValue;
this.dispatchEvent(new Event('change'));
} catch (error) {
// Warns normalizeHex error on invalid format
console.warn(error);
}
}
updated() {
// Force updates hueSlider and hexInput value.
this.hueSlider.value = String(this.selectedColorHsv[0]);
this.hexInput.value = String(this.selectedColorHex);
}
render() {
return html`
<me-popup>
<div slot="label" class="Thumbnail"
style=${styleMap({
backgroundColor: this.selectedColorHex,
})}>
</div>
<div slot="content" class="ColorPickerContent">
<me-color-map hue=${this.selectedColorHsv[0]}
saturation=${this.selectedColorHsv[1]}
value=${this.selectedColorHsv[2]}
@change="${this.onColorChange}"></me-color-map>
<input class="HueSlider" type="range" min="0" max="360" id="hueSlider" value=${
this.selectedColorHsv[0]} @input="${this.onHueInput}">
<input class="HexInput" id="hexInput" value="${this.selectedColorHex}"
@change="${this.onHexInput}">
</div>
</me-popup>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-color-picker': ColorPicker;
}
}
@@ -0,0 +1,27 @@
/**
* @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.
*
*/
/** Details emitted with the color-changed event. */
export interface ColorChangeDetail {
hex: string;
}
declare global {
interface HTMLElementEventMap {
'color-changed': CustomEvent<ColorChangeDetail>;
}
}
@@ -0,0 +1,111 @@
/**
* @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/mwc-textfield';
import {Button} from '@material/mwc-button';
import {Dialog} from '@material/mwc-dialog';
import {TextField} from '@material/mwc-textfield';
import {html, LitElement} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
/**
* A styled checkbox with label
*/
@customElement('input-dialog')
export class InputDialog extends LitElement {
/** Proxies to mwc-checkbox's checked field */
@property({type: Boolean}) openDialog = false;
@property({type: Boolean}) modal = false;
@property({type: String}) placeholder = 'Enter Value';
@property({attribute: false}) OnOK = (_value: string) => {};
@property({attribute: false})
onValidate = (_value: string) => {
return {valid: true, validationMessage: ''};
};
@query('mwc-textfield') textfield!: TextField;
@query('mwc-button') button!: Button;
@query('mwc-dialog') inputDialog!: Dialog;
onClick = () => {
this.OnOK(this.textfield.value);
};
get open() {
return this.inputDialog.open;
}
set open(value: boolean) {
this.textfield.validationMessage = '';
this.textfield.value = '';
this.textfield.placeholder = this.placeholder;
this.inputDialog.open = value;
}
get textFieldValue() {
return this.textfield.value;
}
set textFieldValue(value: string) {
this.textfield.value = value;
}
render() {
return html`
<mwc-dialog scrimClickAction="${this.modal ? '' : 'close'}">
<mwc-textfield placeholder=${
this.placeholder} autoValidate="true"></mwc-textfield>
<mwc-button
slot="primaryAction"
dialogAction="ok"
disabled="true"
@click=${this.onClick}>
Ok
</mwc-button>
<mwc-button
slot="secondaryAction"
dialogAction="cancel">
Cancel
</mwc-button>
</mwc-dialog>
`;
}
firstUpdated() {
const self = this;
this.textfield.validityTransform =
(value: string, nativeValidity: ValidityState) => {
const {valid, validationMessage} = self.onValidate(value);
if (!valid) {
self.button.disabled = true;
this.textfield.validationMessage = validationMessage;
return {valid: false};
}
self.button.disabled = false;
return nativeValidity;
};
}
}
declare global {
interface HTMLElementTagNameMap {
'input-dialog': InputDialog;
}
}
@@ -0,0 +1,75 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
border-radius: 4px;
display: inline-flex;
justify-content: space-between;
padding: 6px 12px;
overflow: hidden;
background: #3c4043;
min-width: 60px;
max-height: 19px;
align-items: center;
}
.InlineInput {
/* Disable Firefox native up/down arrows on number inputs */
-moz-appearance:textfield;
background: transparent;
border: none;
border-style: unset;
flex: auto;
font-size: 14px;
outline: none;
width: 0;
color: #dadce0;
}
/**
* Disable native up/down arrows on number inputs for browswers like Safari and
* Chrome
*/
.InlineInput::-webkit-inner-spin-button,
.InlineInput::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.InlineLabel {
color: #888;
cursor: col-resize;
margin-left: 12px;
text-align: right;
user-select: none;
}
/**
* This is needed to persist the cursor style throughout a dragging motion
* starting from a draggable input field label.
*/
.isDragging {
cursor: col-resize;
}
/* This stops text fields from overriding the cursor when dragged over. */
.isDragging .isDraggingNoPointer {
pointer-events: none;
}
`;
@@ -0,0 +1,99 @@
/**
* @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 {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {checkFinite} from '../../utils/reducer_utils.js';
import {styles} from './draggable_input.css.js';
/**
* A draggable input tag.
*/
@customElement('me-draggable-input')
export class DraggableInput extends LitElement {
static styles = styles;
@property({type: Number}) value = 0;
@property({type: String}) innerLabel = 'X';
@property({type: Number}) min = 0;
@property({type: Number}) max = 100;
@property({type: Number}) dragStepSize = 1;
@property({type: Number}) precision = 2;
private readonly bodyMoveHandler =
this.onDocumentBodyMove.bind(this); // NOTYPO
private readonly mouseUpHandler = this.onMouseup.bind(this); // NOTYPO
private inputElement?: HTMLInputElement;
private startingDragX: number = 0;
private startingDragValue: number = 0;
firstUpdated() {
this.inputElement =
this.shadowRoot!.querySelector('input') as HTMLInputElement;
}
render() {
return html`
<input class="InlineInput exportInputInlineInput"
type="number"
@change="${this.onChange}"
.value="${this.value.toFixed(this.precision)}"
.step="${String(Math.pow(10, -this.precision))}">
<div class="InlineLabel exportInputInlineLabel" @mousedown="${
this.onMousedown}">
${this.innerLabel}
</div>
`;
}
onChange() {
const numValue = Number(this.inputElement?.value);
checkFinite(numValue);
this.setValue(numValue);
}
onMousedown(e: MouseEvent) {
document.body.addEventListener('mousemove', this.bodyMoveHandler);
document.body.addEventListener('mouseup', this.mouseUpHandler);
this.startingDragX = e.clientX;
this.startingDragValue = Number(this.inputElement!.value);
}
onMouseup() {
document.body.removeEventListener('mousemove', this.bodyMoveHandler);
}
onDocumentBodyMove(e: MouseEvent) {
const currentX = e.clientX;
this.setValue(
this.startingDragValue +
Math.round((currentX - this.startingDragX)) * this.dragStepSize);
}
setValue(value: number) {
this.value = Math.min(Math.max(value, this.min), this.max);
this.dispatchEvent(new CustomEvent('change'));
}
}
declare global {
interface HTMLElementTagNameMap {
'me-draggable-input': DraggableInput;
}
}
@@ -0,0 +1,36 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult =
css`/* Used for a row of multiple draggable input */
:host {
display: flex;
}
me-draggable-input {
flex: 1;
margin-left: 10px;
}
me-draggable-input:first-child {
margin-left: 0;
}
`;
@@ -0,0 +1,51 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
--input-background: #3c4043;
--input-font-color: #dadce0;
display: block;
}
.EditorDropdown {
width: 100%;
border-radius: 4px;
background-color: var(--input-background);
--primary-text-color: #f8f9fa;
--primary-background-color: var(--input-background);
--paper-input-container-shared-input-style_-_padding: 0 5px;
--paper-input-container-shared-input-style_-_line-height: 24px;
--paper-input-container-underline-color: none;
--paper-input-container-underline-focus-color: none;
}
/* @overrideSelector {quantum.wiz.menu.paperselect} */
.EditorDropdown .exportSelectPopup {
border-radius: 4px;
overflow: none;
}
::slotted(paper-item) {
font-weight: normal;
color: var(--input-font-color);
padding: 6px 24px;
--primary-background-color: var(--input-background);
--paper-item-min-height: none;
}
`;
@@ -0,0 +1,109 @@
/**
* @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 '@polymer/paper-dropdown-menu/paper-dropdown-menu';
import '@polymer/paper-listbox';
import '@polymer/paper-item';
import {PaperDropdownMenuElement} from '@polymer/paper-dropdown-menu/paper-dropdown-menu';
import {PaperListboxElement} from '@polymer/paper-listbox';
import {html, LitElement} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
import {checkFinite} from '../../utils/reducer_utils.js';
import {styles} from './dropdown.css.js';
/**
* A dropdown button.
*/
@customElement('me-dropdown')
export class Dropdown extends LitElement {
static styles = styles;
@property({type: String}) label = '';
/** Proxies to paper-listbox's selected attribute */
// Note that paperListbox also accept string, we explicitly require index
// only.
@property({type: Number}) selectedIndex = 0;
@query('paper-dropdown-menu') paperDropdownMenu!: PaperDropdownMenuElement;
@query('paper-listbox') paperListbox!: PaperListboxElement;
readonly observer = new MutationObserver(this.onSlotChange.bind(this));
firstUpdated() {
// Observe a character update in children (slot), then force update on
// paperListbox
this.observer.observe(this, {
childList: true,
subtree: true,
characterData: true,
});
}
/**
* Proxies to paper-dropdown-menu's selectedItem
*/
get selectedItem(): Element {
return this.paperDropdownMenu.selectedItem as Element;
}
onValueChanged() {
// Filters unselect event fired by paper-dropdown-menu
if (this.paperDropdownMenu.selectedItem &&
this.paperListbox.selected != null &&
this.paperListbox.selected != this.selectedIndex) {
this.selectedIndex = checkFinite(Number(this.paperListbox.selected));
this.dispatchEvent(new Event('select'));
}
}
private onSlotChange() {
// Force update paperListbox
this.paperListbox.set('selected', null);
this.paperListbox.set('selected', this.selectedIndex);
}
render() {
// The custom style gets rid of the underline in the NPM build.
return html`
<custom-style>
<style>
paper-dropdown-menu {
--paper-input-container-underline: { display: none; };
--paper-input-container-underline-focus: { display: none; };
}
</style>
</custom-style>
<paper-dropdown-menu class="EditorDropdown exportEditorDropdown exportSpacecraftTheme" label="${
this.label}" no-label-float @value-changed="${this.onValueChanged}">
<paper-listbox slot="dropdown-content" class="exportSelectPopup" selected="${
this.selectedIndex}">
<slot></slot>
</paper-listbox>
</paper-dropdown-menu>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-dropdown': Dropdown;
}
}
@@ -0,0 +1,57 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`/* Panel */
:host {
--editor-panel-width: 360px;
--body-font-size: 13px;
/* Material color google grey 300 */
--text-color: #dadce0;
}
/* Text */
.EditorViewRoot {
font-size: var(--body-font-size);
line-height: 1.25rem;
}
.EditorViewRoot .EditorLabel {
color: var(--text-color);
padding: 0;
}
.EditorHeaderLabel {
color: #f8f9fa;
font-family: 'Google Sans';
font-size: 16px;
letter-spacing: .1px;
line-height: 48px;
margin: 0 15px;
}
.Spacer {
background-color: #2b2d30;
display: flex;
height: 2px;
}
.EditorPanel {
width: var(--editor-panel-width);
}
`;
@@ -0,0 +1,49 @@
/**
* @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 {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styles} from './editor_panel.css.js';
/**
* Editor Panel
*/
@customElement('me-editor-panel')
export class EditorPanel extends LitElement {
static styles = styles;
@property({type: String}) headerLabel = '';
render() {
return html`
<div class="EditorPanel">
<span class="EditorHeaderLabel">
${this.headerLabel}
</span>
<div class="Spacer"></div>
<slot></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-editor-panel': EditorPanel;
}
}
@@ -0,0 +1,42 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`
:host {
--me-theme-container-background: #2b2d30;
}
.SectionContent {
max-height: 0;
overflow: hidden;
padding: 0 20px;
}
.SectionContent[open] {
background-color: var(--me-theme-container-background);
overflow: visible;
max-height: 100vh;
padding: 20px;
background-color: var(--expandable-section-background);
}
.sticky[open] {
background-color: #202124; /* GOOGLE_GREY_900 */
padding: 0 20px;
}
`;
@@ -0,0 +1,47 @@
/**
* @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 {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styles} from './expandable_section.css.js';
/**
* An expandable content section.
*/
@customElement('me-expandable-section')
export class ExpandableSection extends LitElement {
@property({type: Boolean}) open = false;
@property({type: Boolean}) sticky? = false;
static styles = styles;
render() {
const stickyClass = this.sticky ? 'sticky' : '';
return html`
<div class="SectionContent ${stickyClass}" ?open=${this.open}>
<slot name="content"></slot>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-expandable-section': ExpandableSection;
}
}
@@ -0,0 +1,46 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`.LabeledSwitchContainer {
cursor: pointer;
display: flex;
justify-content: space-between;
line-height: 40px;
margin: 0 16px;
}
mwc-switch {
--mdc-theme-secondary: white;
}
.LabeledSwitch {
align-self: center;
}
.SwitchLabel {
font-size: 14px;
font-weight: 500;
}
.Spacer {
background-color: #2b2d30;
display: flex;
height: 2px;
}
`;
@@ -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 '@material/mwc-switch';
import './expandable_section.js';
import {Switch} from '@material/mwc-switch';
import {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styles} from './expandable_switch.css.js';
/**
* An expandable switch.
*/
@customElement('me-expandable-switch')
export class ExpandableSwitch extends LitElement {
@property({type: Boolean}) open = false;
@property({type: String}) label = '';
static styles = styles;
render() {
return html`
<div class="LabeledSwitchContainer exportLabeledSwitchContainer">
<div class="SwitchLabel exportSwitchLabel">
${this.label}
</div>
<mwc-switch @change="${this.onChange}">
</mwc-switch>
</div>
<me-expandable-section ?open=${this.open}>
<span slot="content">
<slot name="content"></slot>
</span>
</me-expandable-section>
<div class="Spacer"></div>
`;
}
protected onChange(event: Event) {
const checkbox = event.target as Switch;
this.open = checkbox.selected;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-expandable-switch': ExpandableSwitch;
}
}
@@ -0,0 +1,147 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`
:host {
--me-theme-container-background: #202124;
}
.TabHeader{
cursor: pointer;
display: flex;
font-size: 14px;
justify-content: space-between;
line-height: 40px;
padding: 0 16px;
color: var(--expandable-section-text);
background-color: var(--me-theme-container-background);
align-items: center;
font-weight: 500;
}
.sticky-container {
white-space: nowrap;
display: flex;
align-items: center;
cursor: default;
font-size: 14px;
color: var(--expandable-section-text);
background-color: var(--me-theme-container-background);
padding: 2px 16px;
}
.StickyWithLineHeight {
line-height: 40px;
padding: 0px 16px;
}
.upload {
--mdc-icon-button-size: 32px;
margin: 0;
}
.DisabledTabHeader {
color: #888;
}
.TabLabel {
position: relative;
color: var(--expandable-section-text);
}
.TabLabel .exportInfoIcon {
position: relative;
top: 3px;
}
.sticky-label {
margin-right: 5px;
font-size: 14px;
color: var(--expandable-section-text);
}
.IconArea {
display: inline;
height: 20px;
pointer-events: none;
position: relative;
margin-top: -5px;
}
.ArrowIcon {
left: -24px;
position: absolute;
top: 10px;
}
.ArrowIcon.isVisible {
display: inherit;
}
.ArrowIcon.isHidden {
display: none;
}
.TabContent,
.SectionContent {
max-height: 0;
overflow: hidden;
padding: 0 20px;
transition: max-height 500ms ease-in-out;
background-color: var(--me-theme-container-background);
}
.TabContent[open],
.SectionContent[open] {
background-color: var(--me-theme-container-background);
padding: 20px;
overflow: unset;
max-height: none;
transition: max-height 500ms ease-in-out;
}
.RadioButtonExpandableContent {
max-height: 0;
overflow: hidden;
padding: 0;
}
.RadioButtonExpandableContent[open] {
background-color: var(--me-theme-container-background);
padding: 0;
overflow: unset;
max-height: none;
}
.CheckboxHeader {
display: flex;
justify-content: space-between;
margin-top: 20px;
padding-bottom: 8px;
}
.sticky {
position: sticky;
z-index: 10;
top: 0;
background-color: var(--me-theme-container-background);
font-weight: 500;
}
`;
@@ -0,0 +1,122 @@
/**
* @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 './expandable_section.js';
import '@material/mwc-icon';
import {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styles} from './expandable_tab.css.js';
/**
* An expandable tab with a label and an arrow icon indicating its expanded
* state.
*/
@customElement('me-expandable-tab')
export class ExpandableTab extends LitElement {
@property({type: String}) tabName = '';
@property({type: Boolean}) open = false;
@property({type: Boolean}) enabled = true;
@property({type: Boolean}) sticky? = false;
@property({attribute: false}) copyFunction?: Function;
static styles = styles;
render() {
const stickyClass = this.sticky ? 'sticky' : '';
const copy = this.copyFunction !== undefined ? html`
<mwc-button class="upload" id="uploadButton"
icon="file_copy" @click="${this.copyFunction}">
</mwc-button>` :
html``;
const StickyWithLineHeightClass =
this.copyFunction !== undefined ? '' : 'StickyWithLineHeight';
if (this.sticky) {
return html`
<div class="expandableTab ${stickyClass}">
<div data-element-type="expandableTab">
<div class="sticky-container ${StickyWithLineHeightClass}">
<div class="sticky-label">
${this.tabName}
</div>
${copy}
</div>
</div>
<me-expandable-section ?open=${this.open} .sticky=${true}>
<span slot="content">
<slot name="content"></slot>
</span>
</me-expandable-section>
<div class="Spacer"></div>
</div>
`;
}
if (!this.enabled) {
return html`
<div class="expandableTab">
<div data-element-type="expandableTab">
<div class="TabHeader DisabledTabHeader">
<span class="TabLabel">
${this.tabName}
<slot name="tooltip"></slot>
</span>
</div>
</div>
</div>
`;
}
return html`
<div class="expandableTab">
<div data-element-type="expandableTab">
<div class="TabHeader" @click="${this.toggle}">
<span class="TabLabel">
${this.tabName}
<slot name="tooltip"></slot>
</span>
<div class="IconArea">
<mwc-icon>
${this.open ? html`keyboard_arrow_up` : html`keyboard_arrow_down`}
</mwc-icon>
</div>
</div>
</div>
<me-expandable-section ?open=${this.open}>
<span slot="content">
<slot name="content"></slot>
</span>
</me-expandable-section>
</div>
`;
}
toggle() {
if (!this.sticky) {
this.open = !this.open;
}
}
}
declare global {
interface HTMLElementTagNameMap {
'me-expandable-tab': ExpandableTab;
}
}
@@ -0,0 +1,45 @@
/**
* @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 {html, LitElement} from 'lit';
import {customElement} from 'lit/decorators.js';
import {styles} from './styles.css.js';
/**
* Link to modelviewer.dev site.
*/
@customElement('mv-link')
export class MVLink extends LitElement {
static styles = styles;
render() {
return html`
<div class="outer-container">
<a href="https://modelviewer.dev/" target="_blank" class="inner-container">
<div class="icon-button"></div>
<div class="attribute">&lt;model-viewer&gt;</div>
</a>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'mv-link': MVLink;
}
}
@@ -0,0 +1,65 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`
.outer-container {
align-items: center;
background-color: #efefef;
display: flex;
overflow: auto;
padding: 10px;
white-space: nowrap;
border-radius: 0 0 1rem 1rem;
box-shadow: 1px 5px 5px 0px #02020233;
}
.inner-container {
align-items: center;
display: flex;
margin-left: auto;
margin-right: auto;
opacity: 1;
text-decoration: none;
}
.inner-container:hover {
opacity: .5;
}
.icon-button {
background-image: url(https://modelviewer.dev/shared-assets/icons/ic_modelviewer.svg);
background-position: 50% 50%;
background-repeat: no-repeat;
background-size: 34px;
cursor: pointer;
display: inline-block;
height: 34px;
margin-left: -4px;
margin-right: 8px;
width: 34px;
}
.attribute {
align-items: center;
color: black;
display: flex;
font-family: 'Roboto Mono', monospace;
font-size: 1.4em;
white-space: pre-wrap !important;
}
`;
@@ -0,0 +1,68 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
--material-color-google-grey-300: #dadce0;
--material-color-google-blue-500: #4285f4;
}
.PillButtons {
display: flex;
}
::slotted(*:first-child) label {
border-bottom-left-radius: 2px;
border-top-left-radius: 2px;
margin-left: 0px;
}
::slotted(*:last-child) label {
border-bottom-right-radius: 2px;
border-top-right-radius: 2px;
}
.PillRadio {
flex: 1 1 50%;
}
.PillRadioInput {
display: none;
}
.PillRadioInput + .RadioLabel {
border: 1px solid transparentize(var(--material-color-google-grey-300), 0.88);
box-sizing: border-box;
color: var(--material-color-google-grey-300);
cursor: pointer;
display: inline-block;
font-family: 'Google Sans';
font-weight: 500;
height: 35px;
line-height: 33px;
margin-bottom: 8px;
margin-left: -1px;
text-align: center;
width: 100%;
}
.PillRadioInput:checked + .RadioLabel {
border: 1px solid var(--material-color-google-blue-500);
color: var(--material-color-google-grey-300);
}
`;
@@ -0,0 +1,90 @@
/**
* @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 {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styles} from './pill_buttons.css.js';
/**
* A pill radio button group. The closest material design analog is probably
* a choice chip, but those are yet to be implemented in MWC; for now just copy
* over the Spacecraft implementation.
*/
@customElement('me-pill-buttons')
export class PillButtons extends LitElement {
static styles = styles;
@property({type: String}) name = '';
get buttons() {
const slot = this.shadowRoot!.querySelector('slot');
return slot &&
slot.assignedElements().filter(e => e instanceof PillButton) ||
[];
}
render() {
return html`
<div class="PillButtons">
${this.buttons.map(button => this.renderButton(button as PillButton))}
</div>
<slot
@slotchange=${() => this.requestUpdate()}
style='display:none'></slot>
`;
}
renderButton(button: PillButton) {
return html`
<div class="PillRadio">
<input type="radio"
name="${this.name}"
class="PillRadioInput"
value="${button.value}"
id="${button.id}"
?checked=${button.checked}>
<label
class="RadioLabel"
for="${button.id}" value="${button.value}">
${button.textContent}
</label>
</div>`;
}
}
/**
* A single pill button.
*/
@customElement('me-pill-button')
export class PillButton extends LitElement {
@property({type: String}) id = '';
@property({type: String}) value = '';
@property({type: Boolean}) checked = false;
render() {
return html`<slot></slot>`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-pill-button': PillButton;
'me-pill-buttons': PillButtons;
}
}
@@ -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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
position: relative;
}
.PopupLabel {
cursor: pointer;
display: inline-block;
}
.PopupContainer[open] {
display: block;
}
.PopupContainer {
display: none;
position: absolute;
z-index: 2;
}
`;
@@ -0,0 +1,86 @@
/**
* @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 {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styles} from './popup.css.js';
/**
* A label that renders a custom popup area when clicked
*/
@customElement('me-popup')
export class PopUp extends LitElement {
static styles = styles;
@property({type: Boolean}) open = false;
private readonly bodyClickHandler =
this.onDocumentBodyClick.bind(this); // NOTYPO
private mousedown = false;
render() {
return html`
<div class="PopupLabel" @click="${this.togglePopup}">
<slot name="label"></slot>
</div>
<div class="PopupContainer" ?open=${this.open} @mousedown=${this.onMousedown}>
<slot name="content"></slot>
</div>
`;
}
togglePopup() {
this.open = !this.open;
if (this.open) {
document.body.addEventListener('click', this.bodyClickHandler);
} else {
document.body.removeEventListener('click', this.bodyClickHandler);
}
}
onMousedown() {
this.mousedown = true;
}
onDocumentBodyClick(event: Event) {
// Don't close the popup if user drag out the popup.
if (this.mousedown) {
this.mousedown = false;
return;
}
this.mousedown = false;
// If the event target is a descendant of this element, don't close the
// popup.
// Note: We check composed path because LitElement retargets event.target
// attribute.
if (event.composedPath().indexOf(this) !== -1) {
return;
}
this.open = false;
document.body.removeEventListener('click', this.bodyClickHandler);
}
}
declare global {
interface HTMLElementTagNameMap {
'me-popup': PopUp;
}
}
@@ -0,0 +1,33 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
--material-color-google-blue-500: #4285f4;
--material-color-google-grey-300: #dadce0;
}
::slotted(paper-radio-button) {
display: block;
--paper-radio-button-unchecked-color: var(--material-color-google-grey-300);
--paper-radio-button-unchecked-ink-color: var(--material-color-google-grey-300);
--paper-radio-button-checked-color: var(--material-color-google-blue-500);
--paper-radio-button-checked-ink-color: var(--material-color-google-blue-500);
--paper-radio-button-label-color: var(--material-color-google-grey-300);
}
`;
@@ -0,0 +1,47 @@
/**
* @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 '@polymer/paper-radio-button';
import '@polymer/paper-radio-group';
import {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styles} from './radio_group.css.js';
/**
* LitElement for a radio group
*/
@customElement('me-radio-group')
export class RadioGroup extends LitElement {
@property({type: String}) selected = '';
static styles = styles;
render() {
return html`
<paper-radio-group selected="${this.selected}">
<slot></slot>
</paper-radio-group>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-radio-group': RadioGroup;
}
}
@@ -0,0 +1,45 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
color: #dadce0; /* GOOGLE_GREY_300 */
display: block;
font-family: Roboto;
}
.SectionRow {
display: flex;
justify-content: space-between;
}
.RowLabel {
font-size: 14px;
flex-direction: row;
font-weight: 500;
justify-content: space-between;
margin: auto 12px auto 0;
}
.RowContent {
display: inline-flex;
flex-grow: 1;
margin: auto 0 auto auto;
max-width: 240px;
}
`;
@@ -0,0 +1,47 @@
/**
* @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 {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styles} from './section_row.css.js';
/**
* A section row with label and content.
*/
@customElement('me-section-row')
export class SectionRow extends LitElement {
static styles = styles;
@property({type: String}) label = '';
render() {
return html`
<div class="SectionRow">
${this.label ? html`<div class="RowLabel">${this.label}</div>` : ''}
<div class="RowContent">
<slot></slot>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-section-row': SectionRow;
}
}
@@ -0,0 +1,73 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
--google-grey-300: #dadce0;
--google-grey-800: #3c4043;
display: block;
}
.Container {
display: flex;
flex-direction: row;
width: 100%;
}
.Slider {
align-items: center;
display: flex;
flex: auto;
margin-right: 12px;
}
paper-slider {
--paper-slider-knob-color: var(--google-grey-300);
--paper-slider-active-color: var(--google-grey-300);
--paper-slider-knob-start-color: var(--google-grey-300);
--paper-slider-knob-start-border-color: var(--google-grey-300);
}
.InputArea {
display: flex;
flex: none;
margin: auto;
}
.InputContainer {
background: var(--google-grey-800);
border-radius: 4px;
display: inline-flex;
justify-content: space-between;
max-width: 60px;
overflow: hidden;
padding: 6px 12px;
}
.InlineInput {
color: var(--google-grey-300);
-moz-appearance:textfield;
background: transparent;
border: none;
border-style: unset;
font-size: 13px;
cursor: pointer;
outline: none;
width: 100%;
}
`;
@@ -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 '@polymer/paper-slider';
import '@material/mwc-textfield';
import {PaperSliderElement} from '@polymer/paper-slider';
import {html, LitElement} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
import {clamp} from '../../utils/reducer_utils.js';
import {styles} from './slider_with_input.css.js';
/**
* LitElement for a slider and input box pair that should always stay in sync
* with each other.
*/
@customElement('me-slider-with-input')
export class SliderWithInputElement extends LitElement {
@property({type: Number}) min = 0;
@property({type: Number}) max = 10;
@property({type: Number}) step = 1;
@property({type: Number}) value = 0;
@property({type: String}) inputLabel = '';
@property({type: Boolean}) disabled = false;
@query('paper-slider') slider!: PaperSliderElement;
@query('input') input!: HTMLInputElement;
static styles = styles;
updated() {
this.value = clamp(this.value, this.min, this.max);
// Force input value to update, this is necessary as programmatic value
// update is disabled after user keyboard input
this.input.value = String(this.value);
}
onSliderChange(event: CustomEvent) {
event.stopPropagation(); // Retarget input event
this.value = this.slider.immediateValue!;
this.dispatchEvent(new Event('change'));
}
onInputChange(event: InputEvent) {
event.stopPropagation(); // Retarget input event
const newNumber = Number(this.input.value);
this.value =
clamp(isFinite(newNumber) ? newNumber : this.value, this.min, this.max);
this.dispatchEvent(new Event('change'));
}
render() {
return html`
<div class="Container">
<paper-slider
class="Slider"
value="${this.value}"
min="${this.min}"
max="${this.max}"
step="${this.step}"
secondary-progress="-180"
@immediate-value-change=${this.onSliderChange}
@change=${this.onSliderChange}></paper-slider>
<div class="InputContainer">
<input type="number"
class="InlineInput"
value="${String(this.value)}"
step="${this.step}"
min="${this.min}" max="${this.max}"
@change="${this.onInputChange}"
?disabled=${this.disabled}>
</div>
</div>`;
}
// Convenience function for testing
clickTo(value: number) {
this.value = value;
this.dispatchEvent(new Event('change'));
}
}
declare global {
interface HTMLElementTagNameMap {
'me-slider-with-input': SliderWithInputElement;
}
}
@@ -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 '@material/mwc-button';
declare function html_beautify(html: string): string;
declare function css_beautify(css: string): string;
import {css, html, LitElement} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
import {TemplateResult} from 'lit';
/**
* Displays the inner text of an arbitrary TemplateResult.
*/
@customElement('snippet-viewer')
export class SnippetViewer extends LitElement {
static get styles() {
return css`
textarea#snippet {
width: 100%;
resize: none;
text-wrap: nowrap;
border-radius: 4px;
border: none;
}
textarea#snippet::-webkit-scrollbar {
width: 8px;
height: 8px;
background-color: #fff;
border-radius: 0.2rem;
}
textarea#snippet::-webkit-scrollbar-thumb {
background-color: #202124b0;
border-radius: 0.2rem;
}
textarea#snippet::-webkit-scrollbar-thumb:hover {
background-color: #202124b8;
}
`;
}
@property({type: Object}) renderedSnippet: TemplateResult = html``;
@property({type: String}) renderedStyle = '';
@property({type: Boolean}) isReadOnly?: Boolean = true;
@query('span#tag') shadowTag!: HTMLElement;
@query('textarea#snippet') snippet!: HTMLTextAreaElement;
getText() {
return this.snippet.value;
}
copyToClipboard() {
this.snippet.select();
document.execCommand('copy');
}
protected render() {
if (this.isReadOnly === false) {
return html`<span id="tag" style='display: none'>${
this.renderedSnippet}</span>
<textarea id="snippet" rows=15></textarea>`
}
return html`
<span id="tag" style='display: none'>${this.renderedSnippet}</span>
<textarea id="snippet" readonly rows=10></textarea>
`;
}
protected updated() {
this.snippet.textContent = this.formattedHtml + '\n' + this.formattedStyle;
this.snippet.value = this.formattedHtml + '\n' + this.formattedStyle;
}
get formattedStyle() {
return this.renderedStyle ? `<style>
${css_beautify(this.renderedStyle)}
</style>
` :
``;
}
get formattedHtml() {
// Removes LitElement render artifacts e.g. <!--?lit$515089429$-->
// the `?` in `.*?` is a non-greedy match.
let html = this.shadowTag.innerHTML.replace(/<!--.*?-->/g, '');
// Removes empty lines that may result from the previous line
html = html.replace(/\n\s*\n/g, '');
// Remove the ar-status runtime-added tag
html = html.replace(/ar-status="[\w- ]+" */, '');
// Remove redundant ="" for boolean attribs
html = html.replace(/=""/g, '');
return html_beautify(html);
}
}
declare global {
interface HTMLElementTagNameMap {
'snippet-viewer': SnippetViewer;
}
}
@@ -0,0 +1,75 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
font-family: Roboto;
/* MATERIAL_COLOR_GOOGLE_GREY_300 */
color: #dadce0;
--me-theme-focused-color: #4285f4;
/* MATERIAL_COLOR_GOOGLE_GREY_800 */
--me-theme-tab-background-color: #3c4043;
/* MATERIAL_COLOR_GOOGLE_GREY_300 */
--me-theme-tab-default-color: #dadce0;
/* MATERIAL_COLOR_GOOGLE_BLUE_500 */
--me-theme-tab-selected-color: #4285f4;
/* MATERIAL_COLOR_GOOGLE_GREY_900 */
--me-theme-container-background-color: #202124;
--mdc-theme-primary: var(--me-theme-tab-selected-color);
--mdc-text-transform: none;
--mdc-tab-color-default: var(--me-theme-tab-default-color);
--mdc-tab-text-label-color-default: var(--me-theme-tab-default-color);
--mdc-tab-stacked-height: 100px;
--mdc-tab-min-width: 48px;
background: var(--me-theme-container-background-color);
display: flex;
flex-direction: column;
}
:host .exportTab.isFocused {
background-color: var(--me-theme-tab-background-color);
outline: var(--me-theme-focused-color) solid 1px;
outline-offset: -1px;
}
.TabHeader {
background-color: var(--me-theme-tab-background-color);
font-family: 'Google Sans';
font-size: 14px;
letter-spacing: .25px;
line-height: 40px;
}
.TabPanel {
display: none;
}
.TabPanel[selected] {
display: block;
}
::slotted(span) {
display: contents;
}
`;
export const tabStyles: CSSResult = css`
.mdc-tab {
min-width: var(--mdc-tab-min-width, 48px);
}
`;
@@ -0,0 +1,43 @@
/**
* @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 {TabBase} from '@material/mwc-tab/mwc-tab-base';
import {styles as baseStyles} from '@material/mwc-tab/mwc-tab.css';
import {customElement} from 'lit/decorators.js';
import {tabStyles} from './styles.css';
declare global {
interface HTMLElementTagNameMap {
'me-tab': Tab;
}
}
@customElement('me-tab')
export class Tab extends TabBase {
// MWC tab did not allow us to modify min-width, so we have to override the
// styles ourselves. M2 MWC has sent out its final release as they are now
// focusing on M3 making this safe to override according to:
// https://lit-and-friends.slack.com/archives/CC0Q3PRCL/p1651599492903519
static override styles: any = [
baseStyles,
tabStyles,
// This cast is needed because MWC uses Lit 2, while we've updated to Lit 3.
// The code is mutually compatible though.
] as typeof TabBase.styles;
}
@@ -0,0 +1,105 @@
/**
* @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/mwc-tab-bar';
import {html, LitElement} from 'lit';
import {customElement, property} from 'lit/decorators.js';
import {styles} from './styles.css.js';
import './tab';
/**
* A tabbed panel.
*/
@customElement('me-tabs')
export class Tabs extends LitElement {
static styles = styles;
get panels() {
const slot = this.shadowRoot!.querySelector('slot');
return slot &&
slot.assignedElements().filter(e => e instanceof TabbedPanel) ||
[];
}
renderPanelIcon(panel: Element) {
if (panel instanceof TabbedPanel) {
return html`
<me-tab
icon='${panel.icon}'
label='${panel.label}'
></me-tab>`;
}
return html``;
}
// Mystery.. But this is required for mwc-tab-bar to correctly select the
// initial panel.
firstUpdated() {
this.requestUpdate();
}
render() {
return html`
<div class="TabHeader">
<mv-link></mv-link>
<mwc-tab-bar
@MDCTabBar:activated=${this.tabActivated}>
${this.panels.map(this.renderPanelIcon)}
</mwc-tab-bar>
</div>
<div class="TabPanelContainer">
<slot @slotchange=${() => {
this.requestUpdate();
}}></slot>
</div>
`;
}
tabActivated(event: CustomEvent<{index: number}>) {
const targetIndex = event.detail.index;
this.panels.forEach((panel, index) => {
(panel as TabbedPanel).selected = index === targetIndex;
});
}
}
/**
* A panel of content, plus an icon and label to display in the tab bar.
*/
@customElement('me-tabbed-panel')
export class TabbedPanel extends LitElement {
static styles = styles;
@property({type: String, reflect: true}) icon = '';
@property({type: String}) label = '';
@property({type: Boolean}) selected = false;
render() {
return html`
<slot class="TabPanel" ?selected=${this.selected}></slot>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'me-tabbed-panel': TabbedPanel;
'me-tabs': Tabs;
}
}
@@ -0,0 +1,99 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`:host {
--me-swatch-size: 48px;
--me-swatch-border-color: white;
--material-color-google-grey-800: #3c4043;
--material-color-google-grey-900: #202124;
}
.NullTextureSquare {
background:linear-gradient(to top right,
rgba(0,0,0,0) 0%,
rgba(0,0,0,0) calc(50% - 0.8px),
rgba(255,0,0,1) 50%,
rgba(0,0,0,0) calc(50% + 0.8px),
rgba(0,0,0,0) 100%);
background-color: white;
border-radius: 3px;
border: 1px solid var(--me-swatch-border-color);
cursor: pointer;
height: calc(var(--me-swatch-size) - 2px);
width: calc(var(--me-swatch-size) - 2px);
}
.NullTextureSquareInList{
background:linear-gradient(to top right,
rgba(0,0,0,0) 0%,
rgba(0,0,0,0) calc(50% - 0.8px),
rgba(255,0,0,1) 50%,
rgba(0,0,0,0) calc(50% + 0.8px),
rgba(0,0,0,0) 100%);
background-color: white;
cursor: pointer;
height: calc(var(--me-swatch-size) - 2px);
width: calc(var(--me-swatch-size) - 2px);
}
.PickerContentContainer {
background: var(--material-color-google-grey-800);
display: flex;
flex-direction: column;
width: 214px;
}
.TextureImage {
background: 'https://ssl.gstatic.com/vr/poly/ui/transparent.svg';
background-size: 51%;
height: calc(var(--me-swatch-size) - 2px);
width: calc(var(--me-swatch-size) - 2px);
cursor: pointer;
}
.TextureList {
align-content: flex-start;
align-self: stretch;
background-color: var(--material-color-google-grey-900);
display: flex;
flex-wrap: wrap;
height: 150px;
overflow-y: scroll;
}
.TextureOptionInput {
display: none;
}
.TexturePanel {
padding: 15px 15px 0;
}
.TextureSquare {
background-color: white;
border-radius: 3px;
border: 1px solid var(--me-swatch-border-color);
height: calc(var(--me-swatch-size) - 2px);
width: calc(var(--me-swatch-size) - 2px);
}
.TextureSquare > .TextureImage {
mix-blend-mode: multiply;
}
`;
@@ -0,0 +1,135 @@
/**
* @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/mwc-button';
import '../popup/popup.js';
import {html, LitElement} from 'lit';
import {customElement, property, query} from 'lit/decorators.js';
import {fileModalStyles} from '../../../styles.css.js';
import {createSafeObjectURL, SafeObjectUrl} from '../../utils/create_object_url.js';
import {IMAGE_MIME_TYPES} from '../../utils/gltf_constants.js';
import {checkFinite} from '../../utils/reducer_utils.js';
import {styles} from './texture_picker.css.js';
const ACCEPT_IMAGE_TYPE = IMAGE_MIME_TYPES.join(',');
export interface FileDetails {
url: string;
type: string;
}
/**
* LitElement for a texture picker which allows user to select one of the
* texture images presented
*
* @fires texture-selected
* @fires texture-uploaded<SafeObjectUrl>
*/
@customElement('me-texture-picker')
export class TexturePicker extends LitElement {
static styles = [styles, fileModalStyles];
@property({type: Array}) images: SafeObjectUrl[] = [];
@property({type: Number}) selectedIndex?: number;
@query('input#texture-input') fileInput!: HTMLInputElement;
render() {
return html`
<me-popup>
${this.renderTextureSquare()}
<div slot="content" class="PickerContentContainer">
<div class="TexturePanel">
<div class="TextureList">
${this.images.map((imageUrl, index) => html`
<label>
<input
class="TextureOptionInput"
index="${index}"
type="radio"
name="textureSelect"
@click="${this.onTextureChange}">
<img class="TextureImage" src="${imageUrl}">
</label>
`)}
<div slot="label" id="nullTextureSquare" class="NullTextureSquareInList" @click=${
this.onTextureClear}>
<span class="TextureImage"></span>
</div>
</div>
${this.renderTextureUploadButton()}
</div>
</div>
</me-popup>
`;
}
renderTextureSquare() {
if (this.selectedIndex === undefined || this.images.length === 0) {
return html`
<div slot="label" class="NullTextureSquare">
<span class="TextureImage"></span>
</div>
`;
} else {
return html`
<div slot="label" class="TextureSquare">
<img class="TextureImage" src="${
this.images[this.selectedIndex]}"></div>`;
}
}
renderTextureUploadButton() {
return html`
<mwc-button unelevated label="IMAGE" id="uploadButton" icon="file_upload">
<label for="texture-input" class="FileInputLabel"/>
</mwc-button>
<input type="file" accept=${
ACCEPT_IMAGE_TYPE} id="texture-input" @change="${
this.onUploadImage}"/>`;
}
onTextureChange(event: Event) {
this.selectedIndex = checkFinite(
Number((event.target as HTMLInputElement).getAttribute('index')));
this.dispatchEvent(new CustomEvent('texture-changed'));
}
onTextureClear() {
this.selectedIndex = undefined;
this.dispatchEvent(new CustomEvent('texture-changed'));
}
async onUploadImage() {
const files = this.fileInput.files;
if (!files) {
return;
}
const url = createSafeObjectURL(files[0]).unsafeUrl;
this.dispatchEvent(new CustomEvent<FileDetails>(
'texture-uploaded', {detail: {url, type: files[0].type}}));
}
}
declare global {
interface HTMLElementTagNameMap {
'me-texture-picker': TexturePicker;
}
}
@@ -0,0 +1,81 @@
/**
* @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.
*
*/
/**
* Use this type when you need to store safe object URLs. And then when you need
* the actual url, just access the .url member.
*/
export class SafeObjectUrl {
constructor(readonly url: string) {
}
get unsafeUrl(): string {
return this.url;
}
}
/**
* Returns a SafeUrl, for google3-specific lit-html checks which require them.
*/
export function createSafeObjectURL(blob: Blob): SafeObjectUrl {
return new SafeObjectUrl(URL.createObjectURL(blob));
}
/** Returns true if the given raw URL is an object URL. */
export function isObjectUrl(url: string): boolean {
try {
return new URL(url).protocol === 'blob:';
} catch (_) {
return false;
}
}
/**
* Sanitizes an unsafe URI into a safe one, assuming it points to a supported
* type (such as an image).
*/
export async function createSafeObjectUrlFromUnsafe(unsafeUri: string):
Promise<SafeObjectUrl> {
return new SafeObjectUrl(unsafeUri);
}
/**
* This should only be used when you don't care what the blob type is. For
* example, when loading GLBs directly via modelviewer.src.
*/
export function createSafeObjectUrlFromArrayBuffer(contents: ArrayBuffer) {
return createSafeObjectURL(new Blob([new Uint8Array(contents)]));
}
/**
* Return a closure which will initiate download of the given blob
*/
// Underscore var name needed for NPM build
// tslint:disable-next-line:enforce-name-casing
export function safeDownloadCallback(blob: Blob, filename: string) {
return () => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => {
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, 250);
};
}
@@ -0,0 +1,45 @@
/**
* @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.
*
*/
/**
* @fileoverview All default values as specified in the glTF 2.0 specification:
* https://github.com/KhronosGroup/glTF/tree/master/specification/2.0
*/
import {RGB, RGBA} from '@google/model-viewer/lib/model-viewer';
/** Value to use if pbrMetallicRoughness.baseColorFactor is missing. */
export const DEFAULT_BASE_COLOR_FACTOR: RGBA = [1, 1, 1, 1];
/**
* Value to use if emissiveFactor is missing.
* Reference from:
* https://github.com/KhronosGroup/glTF/blob/master/specification/2.0/schema/material.schema.json#L39
*/
export const DEFAULT_EMISSIVE_FACTOR: RGB = [0, 0, 0];
/** Value to use if pbrMetallicRoughness.roughnessFactor is missing. */
export const DEFAULT_ROUGHNESS_FACTOR = 1.0;
/** Value to use if pbrMetallicRoughness.metallicFactor is missing. */
export const DEFAULT_METALLIC_FACTOR = 1.0;
/** Supported alpha blend modes. */
export const ALPHA_BLEND_MODES: string[] = ['OPAQUE', 'BLEND', 'MASK'];
/** Supported mime types for images. */
export const IMAGE_MIME_TYPES = ['image/jpeg', 'image/png'];
@@ -0,0 +1,79 @@
/**
* @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 {css, CSSResult} from 'lit';
export const styles: CSSResult = css`.Hotspot {
background: #fff;
border-radius: 32px;
border: 0;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
box-sizing: border-box;
cursor: pointer;
height: 24px;
padding: 8px;
position: relative;
transition: opacity 0.3s;
width: 24px;
}
.Hotspot:not([data-visible]) {
background: transparent;
border: 4px solid #fff;
box-shadow: none;
height: 32px;
pointer-events: none;
width: 32px;
}
.Hotspot:focus {
border: 4px solid rgb(0, 128, 200);
height: 32px;
outline: none;
width: 32px;
}
.Hotspot > * {
opacity: 1;
transform: translateY(-50%);
}
.HotspotAnnotation{
background: #fff;
border-radius: 4px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25);
color: rgba(0, 0, 0, 0.8);
display: block;
font-family: Futura, Helvetica Neue, sans-serif;
font-size: 18px;
font-weight: 700;
left: calc(100% + 1em);
max-width: 128px;
overflow-wrap: break-word;
padding: 0.5em 1em;
position: absolute;
top: 50%;
width: max-content;
}
.Hotspot:not([data-visible]) > * {
opacity: 0;
pointer-events: none;
transform: translateY(calc(-50% + 4px));
transition: transform 0.3s, opacity 0.3s;
}
`;
@@ -0,0 +1,59 @@
/**
* @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 {HotspotConfig} from '../../hotspot_panel/types.js';
/**
* Renders a list of hotspots
*/
export function renderHotspots(hotspots: HotspotConfig[]) {
const existingNames = new Set();
for (const hotspot of hotspots) {
if (existingNames.has(hotspot.name)) {
throw new Error(`Hotspot contains duplicate name: ${hotspot.name}`);
}
existingNames.add(hotspot.name);
}
return hotspots.map(hotspot => renderHotspot(hotspot));
}
/**
* Render a hotspot
*/
export function renderHotspot(config: HotspotConfig) {
// Note that we have to do this without lit html because rendering slot name
// will result in zClosurez
const hotspotElement = document.createElement('button');
hotspotElement.classList.add('Hotspot');
hotspotElement.slot = `hotspot-${config.name}`;
if (config.surface != null) {
hotspotElement.dataset['surface'] = config.surface;
} else {
hotspotElement.dataset['position'] = config.position;
hotspotElement.dataset['normal'] = config.normal;
}
hotspotElement.dataset['visibilityAttribute'] = 'visible';
if (config.annotation) {
const annotationElement = document.createElement('div');
annotationElement.classList.add('HotspotAnnotation');
annotationElement.textContent = config.annotation;
hotspotElement.appendChild(annotationElement);
}
return hotspotElement;
}
@@ -0,0 +1,76 @@
/**
* @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.
*
*/
/**
* Helper function for updating a single entry in an array.
*/
export function immutableArrayUpdate<T>(array: T[], index: number, value: T) {
const clone = [...array];
if (value === clone[index]) {
throw new Error(
'Tried to update array with the same object - do not update in place!');
}
clone[index] = value;
return clone;
}
/**
* Helper function to update a map entry in an array.
*/
export function immutableMapUpdate<K, V>(map: Map<K, V>, key: K, value: V) {
const clone = new Map(map);
clone.set(key, value);
return clone;
}
/**
* Throws if the given number is not finite. Returns the value otherwise. You
* should only use this to catch programmer errors, so do NOT use this to
* validate user input. Just use isFinite directly in those cases.
*/
export function checkFinite(value: number) {
if (!isFinite(value)) {
throw new Error('Number was not finite');
}
return value;
}
/** Convert degrees to radians */
export function degToRad(degrees: number): number {
return degrees * Math.PI / 180.0;
}
/** Convert radians to degrees */
export function radToDeg(radians: number): number {
return radians * 180.0 / Math.PI;
}
/** Clamps 'value' to be within [lowerLimit, upperLimit] */
export function clamp(value: number, lowerLimit: number, upperLimit: number) {
return Math.min(Math.max(value, lowerLimit), upperLimit);
}
/** Round to the given number of significant figures (digits) */
export function roundToDigits(value: number, digits: number): number {
if (value === 0) {
return 0;
}
const x =
10 ** (Math.floor(digits) - Math.floor(Math.log10(Math.abs(value))) - 1);
return Math.round(value * x) / x;
}
@@ -0,0 +1,137 @@
/**
* @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 '@google/model-viewer/lib/model-viewer';
import {html, TemplateResult} from 'lit';
import {ifDefined} from 'lit/directives/if-defined.js';
import {reduxStore} from '../../space_opera_base';
import {rafPasses} from '../../test/utils/test_utils';
import {ArConfigState, ModelViewerConfig} from '../../types';
import {getConfig, getOrbitString} from '../config/reducer';
import {getModelViewer} from '../model_viewer_preview/reducer';
import {getPosterConfig} from '../model_viewer_snippet/reducer';
import {spread} from '../utils/spread_directive';
/** Optional handlers for model-viewer events */
export interface ModelViewerEventHandlers {
readonly load?: () => void;
readonly cameraChange?: () => void;
readonly modelVisibility?: () => void;
readonly play?: () => void;
readonly pause?: () => void;
readonly click?: (event: MouseEvent) => void;
readonly error?: (details: CustomEvent) => void;
}
interface ModelViewerStatic extends CustomElementConstructor {
minimumRenderScale: number;
}
export async function createPoster() {
const modelViewer = getModelViewer();
const ModelViewerElement =
customElements.get('model-viewer') as ModelViewerStatic;
const oldMinScale = ModelViewerElement.minimumRenderScale;
ModelViewerElement.minimumRenderScale = 1;
const state = reduxStore.getState();
const poster = getPosterConfig(state);
const height = poster.height / window.devicePixelRatio;
modelViewer.style.width = `${height}px`;
modelViewer.style.height = `${height}px`;
// Set to beginning of animation
const oldTime = modelViewer.currentTime;
modelViewer.autoplay = false;
modelViewer.currentTime = 0;
// Set to initial camera orbit
const oldOrbit = modelViewer.getCameraOrbit();
const config = getConfig(state);
modelViewer.cameraOrbit = config.cameraOrbit!;
modelViewer.jumpCameraToGoal();
// Wait for model-viewer to resize and render.
await rafPasses();
await rafPasses();
const posterBlob = await modelViewer.toBlob(
{idealAspect: true, mimeType: poster.mimeType, qualityArgument: 0.85});
// Reset to original state
modelViewer.autoplay = !!config.autoplay;
modelViewer.currentTime = oldTime;
modelViewer.cameraOrbit = getOrbitString(oldOrbit);
modelViewer.jumpCameraToGoal();
modelViewer.style.width = '';
modelViewer.style.height = '';
ModelViewerElement.minimumRenderScale = oldMinScale;
return posterBlob;
}
/**
* Renders a model-viewer tag given the config.
*/
export function renderModelViewer(
config: ModelViewerConfig,
arConfig: ArConfigState,
extraAttributes: any,
eventHandlers?: ModelViewerEventHandlers,
childElements?: Array<TemplateResult|HTMLElement>) {
const skyboxImage =
config.useEnvAsSkybox ? config.environmentImage : undefined;
return html`<model-viewer ${spread(extraAttributes)}
src=${config.src || ''}
?ar=${!!arConfig.ar}
ar-modes=${ifDefined(arConfig.arModes)}
?autoplay=${!!config.autoplay}
?auto-rotate=${!!config.autoRotate}
?camera-controls=${!!config.cameraControls}
environment-image=${ifDefined(config.environmentImage)}
skybox-image=${ifDefined(skyboxImage)}
exposure=${ifDefined(config.exposure)}
tone-mapping=${ifDefined(config.toneMapping)}
poster=${ifDefined(config.poster)}
reveal=${ifDefined(config.reveal)}
interaction-prompt=${ifDefined(config.interactionPrompt)}
shadow-intensity=${ifDefined(config.shadowIntensity)}
shadow-softness=${ifDefined(config.shadowSoftness)}
camera-target=${ifDefined(config.cameraTarget)}
camera-orbit=${ifDefined(config.cameraOrbit)}
field-of-view=${ifDefined(config.fieldOfView)}
min-camera-orbit=${ifDefined(config.minCameraOrbit)}
max-camera-orbit=${ifDefined(config.maxCameraOrbit)}
min-field-of-view=${ifDefined(config.minFov)}
max-field-of-view=${ifDefined(config.maxFov)}
animation-name=${ifDefined(config.animationName)}
@load=${eventHandlers?.load}
@camera-change=${eventHandlers?.cameraChange}
@model-visibility=${eventHandlers?.modelVisibility}
@play=${eventHandlers?.play}
@pause=${eventHandlers?.pause}
@click=${eventHandlers?.click}
@error=${eventHandlers?.error}
>
${childElements}
</model-viewer>`;
}
@@ -0,0 +1,155 @@
/* @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 {
directive,
Directive,
DirectiveParameters,
ElementPart,
PartInfo,
PartType,
} from 'lit/directive.js';
const Binding = {
ATTRIBUTE: 'ATTRIBUTE' as 'ATTRIBUTE',
PROPERTY: 'PROPERTY' as 'PROPERTY',
BOOLEAN_ATTRIBUTE: 'BOOLEAN_ATTRIBUTE' as 'BOOLEAN_ATTRIBUTE',
EVENT: 'EVENT' as 'EVENT',
};
type Binding = keyof typeof Binding;
class Spread extends Directive {
oldBinds: { [bind: string]: unknown } = {};
constructor(info: PartInfo) {
super(info);
if (info.type !== PartType.ELEMENT) {
throw new Error(`spread directive can only be used on element parts.`);
}
}
update(part: ElementPart, [binds]: DirectiveParameters<this>) {
const newEntires = Object.entries(binds);
this.oldBinds.hasOwnProperty('$spread');
for (const [bind, value] of newEntires) {
const [bindKind, bindName] = this.parseBind(bind);
if (this.oldBinds.hasOwnProperty(bind)) {
this.handleExistingBind(part, bind, bindName, value, bindKind);
} else {
this.handleNewBind(part, bindName, value, bindKind);
}
}
const oldEntries = Object.entries(this.oldBinds);
for (const [bind, value] of oldEntries) {
const [bindKind, bindName] = this.parseBind(bind);
if (!binds.hasOwnProperty(bind)) {
this.handleRemovedBind(part, bindName, value, bindKind);
}
}
this.oldBinds = {...binds};
}
handleExistingBind(
part: ElementPart,
bind: string,
bindName: string,
value: unknown,
bindKind: Binding
) {
const hasBindChanged = this.oldBinds[bind] !== value;
if (!hasBindChanged) {
return;
}
if (bindKind === Binding.EVENT) {
part.element.removeEventListener(
bindName,
this.oldBinds[bind] as EventListener
);
}
this.handleNewBind(part, bindName, value, bindKind);
return;
}
handleNewBind(
part: ElementPart,
bindName: string,
value: unknown,
bindKind: Binding
) {
switch (bindKind) {
case Binding.ATTRIBUTE:
part.element.setAttribute(bindName, value as string);
break;
case Binding.PROPERTY:
part.element[bindName] = value;
break;
case Binding.BOOLEAN_ATTRIBUTE:
part.element.toggleAttribute(bindName, !!value);
break;
case Binding.EVENT:
part.element.addEventListener(bindName, value as EventListener);
break;
}
}
handleRemovedBind(
part: ElementPart,
bindName: string,
value: unknown,
bindKind: Binding
) {
switch (bindKind) {
case Binding.ATTRIBUTE:
case Binding.BOOLEAN_ATTRIBUTE:
part.element.removeAttribute(bindName);
break;
case Binding.EVENT:
part.element.removeEventListener(bindName, value as EventListener);
break;
case Binding.PROPERTY:
break;
}
}
parseBind(bind: string): [Binding, string] {
const firstChar = bind[0];
switch (firstChar) {
case '@':
return [Binding.EVENT, bind.slice(1)];
case '.':
return [Binding.PROPERTY, bind.slice(1)];
case '?':
return [Binding.BOOLEAN_ATTRIBUTE, bind.slice(1)];
default:
return [Binding.ATTRIBUTE, bind];
}
}
render(binds: { [bind: string]: unknown }) {
this.oldBinds = {...binds};
}
}
export const spread = directive(Spread);
export type { Spread };
Loaded 100 of 611 files, more files were not shown because too many files have changed in this diff. Show more