Skip to content

Commit

Permalink
Fix wiggle in Safari and IE11 (google#843)
Browse files Browse the repository at this point in the history
* Fix wiggling interaction prompt in Safari and IE

* Fix event contention, respond to feedback

* Add comment about keyframes
  • Loading branch information
Christopher Joel authored Oct 21, 2019
1 parent 9079514 commit e316b45
Show file tree
Hide file tree
Showing 13 changed files with 607 additions and 431 deletions.
12 changes: 11 additions & 1 deletion index.html
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ <h3 style="font-weight:400;">Easily display interactive 3D models on the web & i
<template>
<!-- Import the component -->
<script type="module" src="https://unpkg.com/@google/model-viewer/dist/model-viewer.js"></script>
<script nomodule src="https://unpkg.com/@google/model-viewer/dist/model-viewer-legacy.js"></script>
<script type="noexecute" nomodule src="https://unpkg.com/@google/model-viewer/dist/model-viewer-legacy.js"></script>
<!-- Use it like any other HTML element -->
<model-viewer src="examples/assets/Astronaut.glb" alt="A 3D model of an astronaut" auto-rotate camera-controls background-color="#455A64"></model-viewer>
</template>
Expand Down Expand Up @@ -245,6 +245,16 @@ <h3 class="grouping-title">Attributes</h3>
focus. The interaction prompt will only display if camera-controls
are enabled. Defaults to "auto".</p>
</li>
<li>
<div>interaction-prompt-style</div>
<p>Configures the presentation style of the interaction-prompt when
it is raised. The two allowed values are "wiggle" and "basic". When
set to "wiggle", the prompt will animate from horizontally and the
model will appear to be rotated as though the prompt is interacting
with it. When set to "basic", the prompt is not animated, and instead
simply appears until it is dismissed by user interaction.
Defaults to "wiggle".</p>
</li>
<li>
<div>interaction-prompt-threshold</div>
<p>When camera-controls are enabled, &lt;model-viewer&gt; will
Expand Down
2 changes: 2 additions & 0 deletions src/documentation/components/example-snippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,8 @@ export class ExampleSnippet extends UpdatingElement {

let snippet = template.innerHTML;

snippet.replace('type="noexecute" ', '');

if (!this.preserveWhitespace) {
snippet = snippet.trim();
}
Expand Down
91 changes: 61 additions & 30 deletions src/features/controls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,33 @@ import {IdentNode, NumberNode, numberNode, parseExpressions} from '../styles/par
import {DEFAULT_FOV_DEG} from '../three-components/Model.js';
import {ChangeEvent, ChangeSource, SmoothControls} from '../three-components/SmoothControls.js';
import {Constructor} from '../utilities.js';
import {timeline} from '../utilities/animation.js';

// NOTE(cdata): The following "animation" timing functions are deliberately
// being used in favor of CSS animations. In Safari 12.1 and 13, CSS animations
// would cause the interaction prompt to glitch unexpectedly
// @see https://github.com/GoogleWebComponents/model-viewer/issues/839
const PROMPT_ANIMATION_TIME = 5000;

// For timing purposes, a "frame" is a timing agnostic relative unit of time
// and a "value" is a target value for the keyframe.
const wiggle = timeline(0, [
{frames: 6, value: 0},
{frames: 5, value: -1},
{frames: 1, value: -1},
{frames: 8, value: 1},
{frames: 1, value: 1},
{frames: 5, value: 0},
{frames: 12, value: 0}
]);

const fade = timeline(0, [
{frames: 2, value: 0},
{frames: 1, value: 1},
{frames: 5, value: 1},
{frames: 1, value: 0},
{frames: 4, value: 0}
]);

export interface CameraChangeDetails {
source: ChangeSource;
Expand Down Expand Up @@ -100,7 +127,6 @@ export const cameraTargetIntrinsics = (element: ModelViewerElementBase) => {
};
};

const OFFSET_ROTATION_MULTIPLIER = 5;
const HALF_FIELD_OF_VIEW_RADIANS = (DEFAULT_FOV_DEG / 2) * Math.PI / 180;
const HALF_PI = Math.PI / 2.0;
const THIRD_PI = Math.PI / 3.0;
Expand Down Expand Up @@ -135,8 +161,8 @@ const $onChange = Symbol('onChange');
const $shouldPromptUserToInteract = Symbol('shouldPromptUserToInteract');
const $waitingToPromptUser = Symbol('waitingToPromptUser');
const $userPromptedOnce = Symbol('userPromptedOnce');
const $promptElementVisible = Symbol('promptElementVisible');
const $lastPromptOffset = Symbol('lastPromptOffste');
const $promptElementVisibleTime = Symbol('promptElementVisibleTime');
const $lastPromptOffset = Symbol('lastPromptOffset');
const $focusedTime = Symbol('focusedTime');

const $zoomAdjustedFieldOfView = Symbol('zoomAdjustedFieldOfView');
Expand Down Expand Up @@ -209,13 +235,14 @@ export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
interactionPolicy: InteractionPolicy = InteractionPolicy.ALWAYS_ALLOW;

protected[$promptElement] =
this.shadowRoot!.querySelector('.interaction-prompt')!;
protected[$promptAnimatedContainer] = this.shadowRoot!.querySelector(
'.interaction-prompt > .animated-container')!;
this.shadowRoot!.querySelector('.interaction-prompt') as HTMLElement;
protected[$promptAnimatedContainer] =
this.shadowRoot!.querySelector(
'.interaction-prompt > .animated-container') as HTMLElement;

protected[$focusedTime] = Infinity;
protected[$lastPromptOffset] = 0;
protected[$promptElementVisible] = false;
protected[$promptElementVisibleTime] = Infinity;
protected[$userPromptedOnce] = false;
protected[$waitingToPromptUser] = false;
protected[$shouldPromptUserToInteract] = true;
Expand Down Expand Up @@ -345,6 +372,7 @@ export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
this.interactionPrompt === InteractionPromptStrategy.AUTO ?
this[$loadedTime] :
this[$focusedTime];

if (this.loaded &&
time > thresholdTime + this.interactionPromptThreshold) {
this[$scene].canvas.setAttribute('aria-label', INTERACTION_PROMPT);
Expand All @@ -356,32 +384,33 @@ export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
// again for this particular <model-element> instance:
this[$userPromptedOnce] = true;
this[$waitingToPromptUser] = false;
this[$promptElementVisible] = true;
this[$promptElementVisibleTime] = time;

this[$promptElement].classList.add('visible');
}
}

if (this[$promptElementVisible] &&

if (isFinite(this[$promptElementVisibleTime]) &&
this.interactionPromptStyle === InteractionPromptStyle.WIGGLE) {
const transformString =
self.getComputedStyle(this[$promptAnimatedContainer])
.getPropertyValue('transform');
// Parse the fifth term of computed transform style
// which is in form of matrix(n0, n1, n2, n3, n4, n5)
// @see https://www.w3.org/TR/css-transforms-1/#serialization-of-the-computed-value
const offset = parseFloat(transformString.split(',')[4]);
const delta = offset - this[$lastPromptOffset];

if (isFinite(offset)) {
const scene = this[$scene];

this[$lastPromptOffset] = offset;
scene.setPivotRotation(
scene.getPivotRotation() +
delta / scene.width * OFFSET_ROTATION_MULTIPLIER);
this[$needsRender]();
}
const scene = this[$scene];
const animationTime =
((time - this[$promptElementVisibleTime]) / PROMPT_ANIMATION_TIME) %
1;
const offset = wiggle(animationTime);
const opacity = fade(animationTime);

const xOffset = offset * scene.width * 0.05;
const deltaTheta = (offset - this[$lastPromptOffset]) * Math.PI / 16;

this[$promptAnimatedContainer].style.transform =
`translateX(${xOffset}px)`;
this[$promptAnimatedContainer].style.opacity = `${opacity}`;

this[$controls].adjustOrbit(deltaTheta, 0, 0, 0);

this[$lastPromptOffset] = offset;
this[$needsRender]();
}

this[$controls].update(time, delta);
Expand All @@ -396,7 +425,7 @@ export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
// Effectively cancel the timer waiting for user interaction:
this[$waitingToPromptUser] = false;
this[$promptElement].classList.remove('visible');
this[$promptElementVisible] = false;
this[$promptElementVisibleTime] = Infinity;

// Implicitly there was some reason to defer the prompt. If the user
// has been prompted at least once already, we no longer need to
Expand Down Expand Up @@ -518,15 +547,17 @@ export const ControlsMixin = <T extends Constructor<ModelViewerElementBase>>(
// camera controls (that is, we "should" prompt the user), we begin
// the idle timer and indicate that we are waiting for it to cross the
// prompt threshold:
if (this[$shouldPromptUserToInteract]) {
if (!isFinite(this[$promptElementVisibleTime]) &&
this[$shouldPromptUserToInteract]) {
this[$waitingToPromptUser] = true;
}
}

[$onBlur]() {
this[$waitingToPromptUser] = false;
this[$promptElement].classList.remove('visible');
this[$promptElementVisible] = false;

this[$promptElementVisibleTime] = Infinity;
this[$focusedTime] = Infinity;
}

Expand Down
12 changes: 7 additions & 5 deletions src/features/staging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,19 +89,20 @@ export const StagingMixin = <T extends Constructor<ModelViewerElementBase>>(
this[$autoRotateTimer].tick(delta);

if (this[$autoRotateTimer].hasStopped) {
const rotation =
this.turntableRotation + ROTATION_SPEED * delta * 0.001;
this[$scene].setPivotRotation(rotation);
this[$scene].setPivotRotation(
this[$scene].getPivotRotation() + ROTATION_SPEED * delta * 0.001);
this[$needsRender]();
}
}

[$onCameraChange](_event: CustomEvent<CameraChangeDetails>) {
[$onCameraChange](event: CustomEvent<CameraChangeDetails>) {
if (!this.autoRotate) {
return;
}

this[$autoRotateTimer].reset();
if (event.detail.source === 'user-interaction') {
this[$autoRotateTimer].reset();
}
}

get turntableRotation(): number {
Expand All @@ -110,6 +111,7 @@ export const StagingMixin = <T extends Constructor<ModelViewerElementBase>>(

resetTurntableRotation() {
this[$scene].setPivotRotation(0);
this[$needsRender]();
}
}

Expand Down
Loading

0 comments on commit e316b45

Please sign in to comment.