Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(model3d): Add flyout for animation clips #1369

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 {
jstoffan marked this conversation as resolved.
Show resolved Hide resolved
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 {
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved
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);
ConradJChan marked this conversation as resolved.
Show resolved Hide resolved
});
});
});
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