squash commits
This commit is contained in:
commit
0f545c5e4d
611 files changed
+272565
No files matched your search
@@ -0,0 +1,5 @@
|
||||
node_modules/*
|
||||
dist/*
|
||||
lib/*
|
||||
tsconfig.tsbuildinfo
|
||||
shared-assets
|
||||
@@ -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="<model-viewer> 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 <model-viewer> 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="<model-viewer> 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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}];
|
||||
@@ -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><model-viewer> template</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="description" content="<model-viewer> 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
+129
@@ -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 <model-viewer> 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;
|
||||
}
|
||||
+249
@@ -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 <model-viewer> 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;
|
||||
}
|
||||
+1
@@ -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;
|
||||
}
|
||||
}
|
||||
+158
@@ -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="<model-viewer> 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="<model-viewer> 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;
|
||||
}
|
||||
}
|
||||
+42
@@ -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;
|
||||
}
|
||||
}
|
||||
+46
@@ -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"><model-viewer></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
Reference in new issue
Block a user