Skip to content

Commit

Permalink
feat(model3d): Add flyout for animation clips (#1369)
Browse files Browse the repository at this point in the history
  • Loading branch information
Conrad Chan authored May 5, 2021
1 parent 3164ac3 commit 09eefd9
Show file tree
Hide file tree
Showing 12 changed files with 299 additions and 32 deletions.
14 changes: 14 additions & 0 deletions src/lib/viewers/box3d/model3d/Model3DRenderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,20 @@ class Model3DRenderer extends Box3DRenderer {
initVrGamepadControls() {
this.vrControls = new Model3DVrControls(this.vrGamepads, this.box3d);
}

/**
* Gets any selected animation clip
*
* @return {string} selected animation clip id
*/
getAnimationClip() {
if (!this.instance) {
return '';
}

const component = this.instance.getComponentByScriptId('animation');
return component ? component.clipId : '';
}
}

export default Model3DRenderer;
20 changes: 11 additions & 9 deletions src/lib/viewers/box3d/model3d/Model3DViewer.js
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ class Model3DViewer extends Box3DViewer {
*/
handleSelectAnimationClip(clipId) {
this.renderer.setAnimationClip(clipId);
this.setAnimationState(false);
}

/**
Expand Down Expand Up @@ -294,15 +295,15 @@ class Model3DViewer extends Box3DViewer {
* @return {void}
*/
handleToggleAnimation(play) {
if (this.getViewerOption('useReactControls')) {
this.isAnimationPlaying = !this.isAnimationPlaying;
this.renderer.toggleAnimation(this.isAnimationPlaying);
this.setAnimationState(play);
}

if (this.controls) {
this.renderUI();
}
} else {
this.renderer.toggleAnimation(play);
setAnimationState(play) {
this.isAnimationPlaying = play;
this.renderer.toggleAnimation(this.isAnimationPlaying);

if (this.controls && this.getViewerOption('useReactControls')) {
this.renderUI();
}
}

Expand Down Expand Up @@ -422,13 +423,14 @@ class Model3DViewer extends Box3DViewer {
}

renderUI() {
if (!this.controls) {
if (!this.controls || !this.renderer) {
return;
}

this.controls.render(
<Model3DControlsNew
animationClips={this.animationClips}
currentAnimationClipId={this.renderer.getAnimationClip()}
isPlaying={this.isAnimationPlaying}
onAnimationClipSelect={this.handleSelectAnimationClip}
onFullscreenToggle={this.toggleFullscreen}
Expand Down
24 changes: 24 additions & 0 deletions src/lib/viewers/box3d/model3d/__tests__/Model3DRenderer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,7 @@ describe('lib/viewers/box3d/model3d/Model3DRenderer', () => {
};
animComp = {
asset: animAsset,
clipId: '123',
onUpdate: () => {},
pause: () => {},
play: () => {},
Expand Down Expand Up @@ -691,6 +692,29 @@ describe('lib/viewers/box3d/model3d/Model3DRenderer', () => {
renderer.stopAnimation();
});
});

describe('getAnimationClip()', () => {
test('should return empty string if no model instance is present', () => {
renderer.instance = undefined;
expect(renderer.getAnimationClip()).toBe('');
});

test('should return empty string if no componet is present', () => {
sandbox
.mock(renderer.instance)
.expects('getComponentByScriptId')
.returns(undefined);
expect(renderer.getAnimationClip()).toBe('');
});

test('should get the animation component on the model instance', () => {
sandbox
.mock(renderer.instance)
.expects('getComponentByScriptId')
.returns(animComp);
expect(renderer.getAnimationClip()).toBe('123');
});
});
});

describe('onUnsupportedRepresentation()', () => {
Expand Down
32 changes: 31 additions & 1 deletion src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
toggleAnimation: () => {},
load: () => Promise.resolve(),
initVr: () => {},
getAnimationClip: () => {},
getCamera: () => {},
initVrGamepadControls: () => {},
on: () => {},
Expand Down Expand Up @@ -867,7 +868,7 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
const nextIsPlaying = !isPlaying;
model3d.isAnimationPlaying = isPlaying;

model3d.handleToggleAnimation();
model3d.handleToggleAnimation(nextIsPlaying);

expect(model3d.isAnimationPlaying).toBe(nextIsPlaying);
expect(model3d.renderer.toggleAnimation).toBeCalledWith(nextIsPlaying);
Expand All @@ -876,6 +877,33 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
});
});

describe('handleSelectAnimationClip()', () => {
beforeEach(() => {
jest.spyOn(model3d.renderer, 'setAnimationClip');
jest.spyOn(model3d, 'renderUI').mockImplementation(() => {});
});

test('should set the clipId to the renderer', () => {
model3d.handleSelectAnimationClip('123');

expect(model3d.renderer.setAnimationClip).toBeCalledWith('123');
});

describe('with react controls enabled', () => {
beforeEach(() => {
jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true);
});

test('should set the clipId to the renderer and also reset animation state and render the UI', () => {
model3d.handleSelectAnimationClip('123');

expect(model3d.renderer.setAnimationClip).toBeCalledWith('123');
expect(model3d.isAnimationPlaying).toBe(false);
expect(model3d.renderUI).toBeCalled();
});
});
});

describe('renderUI()', () => {
const getProps = instance => instance.controls.render.mock.calls[0][0].props;

Expand All @@ -885,13 +913,15 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
destroy: jest.fn(),
render: jest.fn(),
};
jest.spyOn(model3d.renderer, 'getAnimationClip').mockImplementation(() => '123');
});

test('should render react controls with the correct props', () => {
model3d.renderUI();

expect(getProps(model3d)).toMatchObject({
animationClips: [],
currentAnimationClipId: '123',
isPlaying: false,
onAnimationClipSelect: model3d.handleSelectAnimationClip,
onFullscreenToggle: model3d.toggleFullscreen,
Expand Down
20 changes: 20 additions & 0 deletions src/lib/viewers/controls/model3d/AnimationClipsControl.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
.bp-AnimationClipsControl {
position: relative;
display: flex;
align-items: center;

.bp-SettingsFlyout {
right: auto;
left: 0;
max-width: 240px;
}
}

.bp-AnimationClipsControl-radioItem {
.bp-SettingsRadioItem-value {
max-width: 180px;
overflow-x: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}
40 changes: 34 additions & 6 deletions src/lib/viewers/controls/model3d/AnimationClipsControl.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from 'react';
import AnimationClipsToggle from './AnimationClipsToggle';
import Settings, { Menu } from '../settings';
import './AnimationClipsControl.scss';

type AnimationClip = {
duration: number;
Expand All @@ -13,12 +15,38 @@ export type Props = {
onAnimationClipSelect: () => void;
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export default function AnimationClipsControl(props: Props): JSX.Element {
export function formatDuration(time: number): string {
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
const seconds = Math.floor((time % 3600) % 60);
const hour = hours < 10 ? `0${hours.toString()}` : hours.toString();
const min = minutes < 10 ? `0${minutes.toString()}` : minutes.toString();
const sec = seconds < 10 ? `0${seconds.toString()}` : seconds.toString();

return `${hour}:${min}:${sec}`;
}

export default function AnimationClipsControl({
animationClips,
currentAnimationClipId,
onAnimationClipSelect,
}: Props): JSX.Element {
return (
<>
<AnimationClipsToggle />
{/* TODO: AnimationClipsFlyout */}
</>
<Settings className="bp-AnimationClipsControl" toggle={AnimationClipsToggle}>
<Settings.Menu name={Menu.MAIN}>
{animationClips.map(({ duration, id, name }) => {
return (
<Settings.RadioItem
key={id}
className="bp-AnimationClipsControl-radioItem"
isSelected={id === currentAnimationClipId}
label={`${formatDuration(duration)} ${name}`}
onChange={onAnimationClipSelect}
value={id}
/>
);
})}
</Settings.Menu>
</Settings>
);
}
14 changes: 12 additions & 2 deletions src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,20 @@ export type Props = {
onClick?: () => void;
};

export default function AnimationClipsToggle({ onClick }: Props): JSX.Element {
function AnimationClipsToggle(props: Props, ref: React.Ref<HTMLButtonElement>): JSX.Element {
const { onClick } = props;

return (
<button className="bp-AnimationClipsToggle" onClick={onClick} title={__('box3d_animation_clips')} type="button">
<button
ref={ref}
className="bp-AnimationClipsToggle"
onClick={onClick}
title={__('box3d_animation_clips')}
type="button"
>
<IconAnimation24 />
</button>
);
}

export default React.forwardRef(AnimationClipsToggle);
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import AnimationClipsControl, { formatDuration, Props as AnimationClipsControlProps } from '../AnimationClipsControl';
import AnimationClipsToggle from '../AnimationClipsToggle';
import Settings from '../../settings';

describe('AnimationClipsControl', () => {
const animationClips = [
{ duration: 1, id: '1', name: 'first' },
{ duration: 2, id: '2', name: 'second' },
];
const getDefaults = (): AnimationClipsControlProps => ({
animationClips,
currentAnimationClipId: '1',
onAnimationClipSelect: jest.fn(),
});
const getWrapper = (props = {}): ShallowWrapper => shallow(<AnimationClipsControl {...getDefaults()} {...props} />);

describe('render', () => {
test('should return a valid wrapper', () => {
const wrapper = getWrapper();

expect(wrapper.find(Settings).props()).toMatchObject({
className: 'bp-AnimationClipsControl',
toggle: AnimationClipsToggle,
});
expect(wrapper.exists(Settings.Menu)).toBe(true);
});

test('should return the animationClips as RadioItems', () => {
const onAnimationClipSelect = jest.fn();
const wrapper = getWrapper({ onAnimationClipSelect });
const radioItems = wrapper.find(Settings.RadioItem);

expect(radioItems).toHaveLength(2);
expect(radioItems.at(0).props()).toMatchObject({
className: 'bp-AnimationClipsControl-radioItem',
isSelected: true,
label: '00:00:01 first',
onChange: onAnimationClipSelect,
value: animationClips[0].id,
});
expect(radioItems.at(1).props()).toMatchObject({
className: 'bp-AnimationClipsControl-radioItem',
isSelected: false,
label: '00:00:02 second',
onChange: onAnimationClipSelect,
value: animationClips[1].id,
});
});
});

describe('formatDuration()', () => {
test.each`
time | expectedString
${0} | ${'00:00:00'}
${59} | ${'00:00:59'}
${61} | ${'00:01:01'}
${3599} | ${'00:59:59'}
${3661} | ${'01:01:01'}
`('should format $time as $expectedString', ({ time, expectedString }) => {
expect(formatDuration(time)).toBe(expectedString);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import React from 'react';
import { shallow, ShallowWrapper } from 'enzyme';
import AnimationClipsControl from '../AnimationClipsControl';
import AnimationControls, { Props as AnimationControlsProps } from '../AnimationControls';
import PlayPauseToggle from '../../media/PlayPauseToggle';

describe('AnimationControls', () => {
const getDefaults = (): AnimationControlsProps => ({
animationClips: [{ duration: 1, id: '1', name: 'one' }],
currentAnimationClipId: '1',
isPlaying: false,
onAnimationClipSelect: jest.fn(),
onPlayPause: jest.fn(),
});
const getWrapper = (props = {}): ShallowWrapper => shallow(<AnimationControls {...getDefaults()} {...props} />);

describe('render', () => {
test('should return null if animationClips is empty', () => {
const wrapper = getWrapper({ animationClips: [] });

expect(wrapper.isEmptyRender()).toBe(true);
});

test('should return valid wrapper', () => {
const onAnimationClipSelect = jest.fn();
const onPlayPause = jest.fn();
const wrapper = getWrapper({ onAnimationClipSelect, onPlayPause });

expect(wrapper.find(PlayPauseToggle).props()).toMatchObject({
isPlaying: false,
onPlayPause,
});
expect(wrapper.find(AnimationClipsControl).props()).toMatchObject({
animationClips: expect.any(Array),
currentAnimationClipId: '1',
onAnimationClipSelect,
});
});
});
});
Loading

0 comments on commit 09eefd9

Please sign in to comment.