diff --git a/src/lib/viewers/box3d/Box3DViewer.js b/src/lib/viewers/box3d/Box3DViewer.js
index 06f8c217e..2506d0635 100644
--- a/src/lib/viewers/box3d/Box3DViewer.js
+++ b/src/lib/viewers/box3d/Box3DViewer.js
@@ -92,7 +92,8 @@ class Box3DViewer extends BaseViewer {
* @return {void}
*/
attachEventHandlers() {
- if (this.controls) {
+ if (this.controls && !this.getViewerOption('useReactControls')) {
+ // TODO: This can be removed once Image360 and Video360 controls are migrated to React
this.controls.on(EVENT_TOGGLE_FULLSCREEN, this.toggleFullscreen);
this.controls.on(EVENT_TOGGLE_VR, this.handleToggleVr);
this.controls.on(EVENT_RESET, this.handleReset);
@@ -118,7 +119,7 @@ class Box3DViewer extends BaseViewer {
* @return {void}
*/
detachEventHandlers() {
- if (this.controls) {
+ if (this.controls && !this.getViewerOption('useReactControls')) {
this.controls.removeListener(EVENT_TOGGLE_FULLSCREEN, this.toggleFullscreen);
this.controls.removeListener(EVENT_TOGGLE_VR, this.handleToggleVr);
this.controls.removeListener(EVENT_RESET, this.handleReset);
@@ -298,7 +299,7 @@ class Box3DViewer extends BaseViewer {
this.wrapperEl.classList.remove(CLASS_VR_ENABLED);
}
- if (this.controls) {
+ if (this.controls && !this.getViewerOption('useReactControls')) {
this.controls.vrEnabled = vrDevice && vrDevice.isPresenting;
}
}
diff --git a/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js b/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js
index bfcfdde21..23b6019ee 100644
--- a/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js
+++ b/src/lib/viewers/box3d/__tests__/Box3DViewer-test.js
@@ -137,6 +137,13 @@ describe('lib/viewers/box3d/Box3DViewer', () => {
expect(onSpy).toBeCalledWith(EVENT_RESET, expect.any(Function));
});
+
+ test('should not invoke any box3d.controls.on() if using react controls', () => {
+ jest.spyOn(box3d, 'getViewerOption').mockImplementation(() => true);
+ box3d.controls = { destroy: jest.fn(), on: jest.fn() };
+ box3d.attachEventHandlers();
+ expect(box3d.controls.on).not.toBeCalled();
+ });
});
test("should not attach handlers to controls if controls instance doesn't exist", () => {
@@ -216,6 +223,13 @@ describe('lib/viewers/box3d/Box3DViewer', () => {
expect(detachSpy).toBeCalledWith(EVENT_RESET, expect.any(Function));
});
+
+ test('should not invoke any box3d.controls.removeListener() if using react controls', () => {
+ jest.spyOn(box3d, 'getViewerOption').mockImplementation(() => true);
+ box3d.controls = { destroy: jest.fn(), removeListener: jest.fn() };
+ box3d.detachEventHandlers();
+ expect(box3d.controls.removeListener).not.toBeCalled();
+ });
});
test('should not invoke controls.removeListener() when controls is undefined', () => {
@@ -311,6 +325,15 @@ describe('lib/viewers/box3d/Box3DViewer', () => {
expect(box3d.controls.destroy).toBeCalled();
});
+ test('should still call controls.destroy() if using react controls', () => {
+ jest.spyOn(box3d, 'getViewerOption').mockImplementation(() => true);
+ box3d.controls = { destroy: jest.fn(), on: jest.fn() };
+
+ box3d.destroy();
+
+ expect(box3d.controls.destroy).toBeCalled();
+ });
+
test('should call renderer.destroy() if it exists', () => {
jest.spyOn(box3d.renderer, 'destroy');
@@ -474,6 +497,15 @@ describe('lib/viewers/box3d/Box3DViewer', () => {
expect(box3d.wrapperEl).not.toHaveClass('vr-enabled');
expect(box3d.controls.vrEnabled).not.toBe(true);
});
+
+ test('should not add vr-enabled if use react controls is enabled', () => {
+ jest.spyOn(box3d, 'getViewerOption').mockImplementation(() => true);
+ jest.spyOn(Browser, 'isMobile').mockReturnValue(true);
+
+ box3d.onVrPresentChange();
+
+ expect(box3d.controls.vrEnabled).toBe(false);
+ });
});
describe('handleSceneLoaded()', () => {
diff --git a/src/lib/viewers/box3d/model3d/Model3DControlsNew.tsx b/src/lib/viewers/box3d/model3d/Model3DControlsNew.tsx
new file mode 100644
index 000000000..c728b6500
--- /dev/null
+++ b/src/lib/viewers/box3d/model3d/Model3DControlsNew.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import AnimationControls, { Props as AnimationControlsProps } from '../../controls/model3d/AnimationControls';
+import ControlsBar from '../../controls/controls-bar';
+import FullscreenToggle, { Props as FullscreenToggleProps } from '../../controls/fullscreen';
+import ResetControl, { Props as ResetControlProps } from '../../controls/model3d/ResetControl';
+
+export type Props = AnimationControlsProps & FullscreenToggleProps & ResetControlProps;
+
+export default function Model3DControls({
+ animationClips,
+ currentAnimationClipId,
+ isPlaying,
+ onAnimationClipSelect,
+ onFullscreenToggle,
+ onPlayPause,
+ onReset,
+}: Props): JSX.Element {
+ const handleReset = (): void => {
+ // TODO: will need to reset the state to defaults
+ onReset();
+ };
+
+ return (
+
+
+
+ {/* TODO: VR button */}
+ {/* TODO: Settings button */}
+
+
+ );
+}
diff --git a/src/lib/viewers/box3d/model3d/Model3DViewer.js b/src/lib/viewers/box3d/model3d/Model3DViewer.js
index bd5d40f43..7550b73ca 100644
--- a/src/lib/viewers/box3d/model3d/Model3DViewer.js
+++ b/src/lib/viewers/box3d/model3d/Model3DViewer.js
@@ -1,5 +1,8 @@
+import React from 'react';
import Box3DViewer from '../Box3DViewer';
+import ControlsRoot from '../../controls/controls-root';
import Model3DControls from './Model3DControls';
+import Model3DControlsNew from './Model3DControlsNew';
import Model3DRenderer from './Model3DRenderer';
import {
CAMERA_PROJECTION_PERSPECTIVE,
@@ -28,8 +31,8 @@ const LOAD_TIMEOUT = 180000; // 3 minutes
* This is the entry point for the model3d preview.
*/
class Model3DViewer extends Box3DViewer {
- /** @property {Object[]} - List of Box3D instances added to the scene */
- instances = [];
+ /** @property {Object[]} - List of animation clips for the given Box3D file */
+ animationClips = [];
/** @property {Object} - Tracks up and forward axes for the model alignment in the scene */
axes = {
@@ -37,6 +40,12 @@ class Model3DViewer extends Box3DViewer {
forward: null,
};
+ /** @property {Object[]} - List of Box3D instances added to the scene */
+ instances = [];
+
+ /** @property {boolean} - Boolean indicating whether the animation is playihng */
+ isAnimationPlaying = false;
+
/** @inheritdoc */
constructor(option) {
super(option);
@@ -51,6 +60,7 @@ class Model3DViewer extends Box3DViewer {
this.handleToggleAnimation = this.handleToggleAnimation.bind(this);
this.handleToggleHelpers = this.handleToggleHelpers.bind(this);
this.handleCanvasClick = this.handleCanvasClick.bind(this);
+ this.initViewer = this.initViewer.bind(this);
this.onMetadataError = this.onMetadataError.bind(this);
}
@@ -75,7 +85,9 @@ class Model3DViewer extends Box3DViewer {
* @inheritdoc
*/
createSubModules() {
- this.controls = new Model3DControls(this.wrapperEl);
+ this.controls = this.getViewerOption('useReactControls')
+ ? new ControlsRoot({ containerEl: this.wrapperEl, fileId: this.options.file.id })
+ : new Model3DControls(this.wrapperEl);
this.renderer = new Model3DRenderer(this.wrapperEl, this.boxSdk, { api: this.api });
}
@@ -85,7 +97,7 @@ class Model3DViewer extends Box3DViewer {
attachEventHandlers() {
super.attachEventHandlers();
- if (this.controls) {
+ if (this.controls && !this.getViewerOption('useReactControls')) {
this.controls.on(EVENT_ROTATE_ON_AXIS, this.handleRotateOnAxis);
this.controls.on(EVENT_SELECT_ANIMATION_CLIP, this.handleSelectAnimationClip);
this.controls.on(EVENT_SET_CAMERA_PROJECTION, this.handleSetCameraProjection);
@@ -108,7 +120,7 @@ class Model3DViewer extends Box3DViewer {
detachEventHandlers() {
super.detachEventHandlers();
- if (this.controls) {
+ if (this.controls && !this.getViewerOption('useReactControls')) {
this.controls.removeListener(EVENT_ROTATE_ON_AXIS, this.handleRotateOnAxis);
this.controls.removeListener(EVENT_SELECT_ANIMATION_CLIP, this.handleSelectAnimationClip);
this.controls.removeListener(EVENT_SET_CAMERA_PROJECTION, this.handleSetCameraProjection);
@@ -174,39 +186,45 @@ class Model3DViewer extends Box3DViewer {
return response.response;
})
.catch(this.onMetadataError)
- .then(defaults => {
- if (this.controls) {
- this.controls.addUi();
- }
+ .then(this.initViewer);
+ }
- this.axes.up = defaults.upAxis || DEFAULT_AXIS_UP;
- this.axes.forward = defaults.forwardAxis || DEFAULT_AXIS_FORWARD;
- this.renderMode = defaults.defaultRenderMode || RENDER_MODE_LIT;
- this.projection = defaults.cameraProjection || CAMERA_PROJECTION_PERSPECTIVE;
- if (defaults.renderGrid === 'true') {
- this.renderGrid = true;
- } else if (defaults.renderGrid === 'false') {
- this.renderGrid = false;
- } else {
- this.renderGrid = DEFAULT_RENDER_GRID;
- }
+ initViewer(defaults) {
+ if (this.controls) {
+ if (this.getViewerOption('useReactControls')) {
+ this.renderUI();
+ } else {
+ this.controls.addUi();
+ }
+ }
- if (this.axes.up !== DEFAULT_AXIS_UP || this.axes.forward !== DEFAULT_AXIS_FORWARD) {
- this.handleRotationAxisSet(this.axes.up, this.axes.forward, false);
- }
+ this.axes.up = defaults.upAxis || DEFAULT_AXIS_UP;
+ this.axes.forward = defaults.forwardAxis || DEFAULT_AXIS_FORWARD;
+ this.renderMode = defaults.defaultRenderMode || RENDER_MODE_LIT;
+ this.projection = defaults.cameraProjection || CAMERA_PROJECTION_PERSPECTIVE;
+ if (defaults.renderGrid === 'true') {
+ this.renderGrid = true;
+ } else if (defaults.renderGrid === 'false') {
+ this.renderGrid = false;
+ } else {
+ this.renderGrid = DEFAULT_RENDER_GRID;
+ }
- // Update controls ui
- this.handleReset();
+ if (this.axes.up !== DEFAULT_AXIS_UP || this.axes.forward !== DEFAULT_AXIS_FORWARD) {
+ this.handleRotationAxisSet(this.axes.up, this.axes.forward, false);
+ }
+
+ // Update controls ui
+ this.handleReset();
- // Initialize animation controls when animations are present.
- this.populateAnimationControls();
+ // Initialize animation controls when animations are present.
+ this.populateAnimationControls();
- this.showWrapper();
+ this.showWrapper();
- this.emit(EVENT_LOAD);
+ this.emit(EVENT_LOAD);
- return true;
- });
+ return true;
}
/**
@@ -239,15 +257,31 @@ class Model3DViewer extends Box3DViewer {
if (animations.length > 0) {
const clipIds = animations[0].getClipIds();
- clipIds.forEach(clipId => {
- const clip = animations[0].getClip(clipId);
- const duration = clip.stop - clip.start;
- this.controls.addAnimationClip(clipId, clip.name, duration);
- });
-
- if (clipIds.length > 0) {
- this.controls.showAnimationControls();
- this.controls.selectAnimationClip(clipIds[0]);
+ if (this.getViewerOption('useReactControls')) {
+ this.animationClips = clipIds.map(clipId => {
+ const { name, start, stop } = animations[0].getClip(clipId);
+ const duration = stop - start;
+ return {
+ duration,
+ id: clipId,
+ name,
+ };
+ });
+
+ this.renderer.setAnimationClip(this.animationClips[0].id);
+
+ this.renderUI();
+ } else {
+ clipIds.forEach(clipId => {
+ const clip = animations[0].getClip(clipId);
+ const duration = clip.stop - clip.start;
+ this.controls.addAnimationClip(clipId, clip.name, duration);
+ });
+
+ if (clipIds.length > 0) {
+ this.controls.showAnimationControls();
+ this.controls.selectAnimationClip(clipIds[0]);
+ }
}
}
}
@@ -260,7 +294,16 @@ class Model3DViewer extends Box3DViewer {
* @return {void}
*/
handleToggleAnimation(play) {
- this.renderer.toggleAnimation(play);
+ if (this.getViewerOption('useReactControls')) {
+ this.isAnimationPlaying = !this.isAnimationPlaying;
+ this.renderer.toggleAnimation(this.isAnimationPlaying);
+
+ if (this.controls) {
+ this.renderUI();
+ }
+ } else {
+ this.renderer.toggleAnimation(play);
+ }
}
/**
@@ -270,7 +313,9 @@ class Model3DViewer extends Box3DViewer {
* @return {void}
*/
handleCanvasClick() {
- this.controls.hidePullups();
+ if (!this.getViewerOption('useReactControls')) {
+ this.controls.hidePullups();
+ }
}
/**
@@ -288,12 +333,18 @@ class Model3DViewer extends Box3DViewer {
handleReset() {
super.handleReset();
+ this.isAnimationPlaying = false;
+
if (this.controls) {
- this.controls.handleSetRenderMode(this.renderMode);
- this.controls.setCurrentProjectionMode(this.projection);
- this.controls.handleSetSkeletonsVisible(false);
- this.controls.handleSetWireframesVisible(false);
- this.controls.handleSetGridVisible(this.renderGrid);
+ if (this.getViewerOption('useReactControls')) {
+ this.renderUI();
+ } else {
+ this.controls.handleSetRenderMode(this.renderMode);
+ this.controls.setCurrentProjectionMode(this.projection);
+ this.controls.handleSetSkeletonsVisible(false);
+ this.controls.handleSetWireframesVisible(false);
+ this.controls.handleSetGridVisible(this.renderGrid);
+ }
}
if (this.renderer) {
@@ -369,6 +420,23 @@ class Model3DViewer extends Box3DViewer {
handleShowGrid(visible) {
this.renderer.setGridVisible(visible);
}
+
+ renderUI() {
+ if (!this.controls) {
+ return;
+ }
+
+ this.controls.render(
+ ,
+ );
+ }
}
export default Model3DViewer;
diff --git a/src/lib/viewers/box3d/model3d/__tests__/Model3DControlsNew-test.tsx b/src/lib/viewers/box3d/model3d/__tests__/Model3DControlsNew-test.tsx
new file mode 100644
index 000000000..bcfdbe1c0
--- /dev/null
+++ b/src/lib/viewers/box3d/model3d/__tests__/Model3DControlsNew-test.tsx
@@ -0,0 +1,50 @@
+import React from 'react';
+import { shallow, ShallowWrapper } from 'enzyme';
+import Model3DControls, { Props } from '../Model3DControlsNew';
+import ResetControl from '../../../controls/model3d/ResetControl';
+import AnimationControls from '../../../controls/model3d/AnimationControls';
+import FullscreenToggle from '../../../controls/fullscreen';
+
+describe('lib/viewers/box3d/model3d/Model3DControlsNew', () => {
+ const getDefaults = (): Props => ({
+ animationClips: [],
+ currentAnimationClipId: '123',
+ isPlaying: false,
+ onAnimationClipSelect: jest.fn(),
+ onFullscreenToggle: jest.fn(),
+ onPlayPause: jest.fn(),
+ onReset: jest.fn(),
+ });
+
+ const getWrapper = (props: Partial): ShallowWrapper =>
+ shallow();
+ describe('render()', () => {
+ test('should return a valid wrapper', () => {
+ const onAnimationClipSelect = jest.fn();
+ const onFullscreenToggle = jest.fn();
+ const onPlayPause = jest.fn();
+
+ const wrapper = getWrapper({ onAnimationClipSelect, onFullscreenToggle, onPlayPause });
+
+ expect(wrapper.find(AnimationControls).props()).toMatchObject({
+ animationClips: [],
+ currentAnimationClipId: '123',
+ isPlaying: false,
+ onAnimationClipSelect,
+ onPlayPause,
+ });
+ expect(wrapper.find(FullscreenToggle).prop('onFullscreenToggle')).toEqual(onFullscreenToggle);
+ });
+ });
+
+ describe('onReset()', () => {
+ test('should call onReset prop when reset button is clicked', () => {
+ const onReset = jest.fn();
+ const wrapper = getWrapper({ onReset });
+
+ wrapper.find(ResetControl).prop('onReset')();
+
+ expect(onReset).toBeCalled();
+ });
+ });
+});
diff --git a/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js b/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js
index 8eb1849ea..6159d85ba 100644
--- a/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js
+++ b/src/lib/viewers/box3d/model3d/__tests__/Model3DViewer-test.js
@@ -1,6 +1,7 @@
/* eslint-disable no-unused-expressions */
import BaseViewer from '../../../BaseViewer';
import Box3DRuntime from '../../__mocks__/Box3DRuntime';
+import ControlsRoot from '../../../controls/controls-root';
import Model3DControls from '../Model3DControls';
import Model3DRenderer from '../Model3DRenderer';
import Model3DViewer from '../Model3DViewer';
@@ -135,6 +136,32 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
expect(m3d.controls).toBeInstanceOf(Model3DControls);
expect(m3d.renderer).toBeInstanceOf(Model3DRenderer);
});
+
+ test('should create ControlsRoot if using react controls', () => {
+ const m3d = new Model3DViewer({
+ file: {
+ id: 0,
+ file_version: {
+ id: 1,
+ },
+ },
+ container: containerEl,
+ representation: {
+ content: {
+ url_template: 'foo',
+ },
+ },
+ });
+ jest.spyOn(m3d, 'getViewerOption').mockImplementation(() => true);
+ Object.defineProperty(BaseViewer.prototype, 'setup', { value: sandbox.mock() });
+ m3d.containerEl = containerEl;
+ m3d.setup();
+
+ m3d.createSubModules();
+
+ expect(m3d.controls).toBeInstanceOf(ControlsRoot);
+ expect(m3d.renderer).toBeInstanceOf(Model3DRenderer);
+ });
});
describe('event handlers', () => {
@@ -229,6 +256,17 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
});
});
});
+
+ describe('with react controls enabled', () => {
+ eventBindings.forEach(binding => {
+ test(`should not create an event listener for ${binding.event} events`, () => {
+ jest.spyOn(m3d, 'getViewerOption').mockImplementation(() => true);
+ const onStub = jest.spyOn(m3d.controls, 'on');
+ m3d.attachEventHandlers();
+ expect(onStub).not.toBeCalledWith(binding.event, m3d[binding.callback]);
+ });
+ });
+ });
});
describe('detachEventHandlers()', () => {
@@ -247,6 +285,17 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
});
});
});
+
+ describe('with react controls enabled', () => {
+ eventBindings.forEach(binding => {
+ test(`should not remove an event listener for ${binding.event} events`, () => {
+ jest.spyOn(m3d, 'getViewerOption').mockImplementation(() => true);
+ const removeStub = jest.spyOn(m3d.controls, 'removeListener');
+ m3d.detachEventHandlers();
+ expect(removeStub).not.toBeCalledWith(binding.event, m3d[binding.callback]);
+ });
+ });
+ });
});
});
@@ -301,6 +350,8 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
};
b3dMock = sandbox.mock(model3d.renderer.box3d);
controlMock = sandbox.mock(model3d.controls);
+ jest.spyOn(model3d.renderer, 'setAnimationClip').mockImplementation(() => {});
+ jest.spyOn(model3d, 'renderUI').mockImplementation(() => {});
});
afterEach(() => {
@@ -434,6 +485,47 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
model3d.populateAnimationControls();
});
+
+ describe('with react controls enabled', () => {
+ beforeEach(() => {
+ const animation = {
+ getClipIds: () => {},
+ getClip: () => {},
+ };
+ const clipOne = {
+ start: 0,
+ stop: 1,
+ name: 'one',
+ };
+ const animMock = sandbox.mock(animation);
+ animMock.expects('getClipIds').returns(['1']);
+ animMock
+ .expects('getClip')
+ .withArgs('1')
+ .returns(clipOne);
+ b3dMock.expects('getEntitiesByType').returns([animation]);
+
+ jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true);
+ });
+
+ test('should call renderUI', () => {
+ model3d.populateAnimationControls();
+
+ expect(model3d.renderUI).toBeCalled();
+ });
+
+ test('should set animationClips', () => {
+ model3d.populateAnimationControls();
+
+ expect(model3d.animationClips).toEqual([{ duration: 1, id: '1', name: 'one' }]);
+ });
+
+ test('should set first animation clip to the renderer', () => {
+ model3d.populateAnimationControls();
+
+ expect(model3d.renderer.setAnimationClip).toBeCalledWith('1');
+ });
+ });
});
});
@@ -638,6 +730,15 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
sandbox.mock(model3d.controls).expects('hidePullups');
model3d.handleCanvasClick();
});
+
+ test('should not invoke controls.hidePullups() if using react controls', () => {
+ jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true);
+ jest.spyOn(model3d.controls, 'hidePullups');
+
+ model3d.handleCanvasClick();
+
+ expect(model3d.controls.hidePullups).not.toBeCalled();
+ });
});
describe('handleReset()', () => {
@@ -651,5 +752,152 @@ describe('lib/viewers/box3d/model3d/Model3DViewer', () => {
renderMock.expects('stopAnimation').once();
model3d.handleReset();
});
+
+ describe('with react controls enabled', () => {
+ beforeEach(() => {
+ jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true);
+ jest.spyOn(model3d, 'renderUI').mockImplementation(() => {});
+ });
+
+ test('should not reset control settings', () => {
+ jest.spyOn(model3d.controls, 'handleSetRenderMode');
+ jest.spyOn(model3d.controls, 'setCurrentProjectionMode');
+ jest.spyOn(model3d.controls, 'handleSetSkeletonsVisible');
+ jest.spyOn(model3d.controls, 'handleSetWireframesVisible');
+ jest.spyOn(model3d.controls, 'handleSetGridVisible');
+
+ model3d.handleReset();
+
+ expect(model3d.controls.handleSetRenderMode).not.toBeCalled();
+ expect(model3d.controls.setCurrentProjectionMode).not.toBeCalled();
+ expect(model3d.controls.handleSetSkeletonsVisible).not.toBeCalled();
+ expect(model3d.controls.handleSetWireframesVisible).not.toBeCalled();
+ expect(model3d.controls.handleSetGridVisible).not.toBeCalled();
+ });
+
+ test('should reset controls state and call renderUI', () => {
+ model3d.isAnimationPlaying = true;
+
+ model3d.handleReset();
+
+ expect(model3d.isAnimationPlaying).toBe(false);
+ expect(model3d.renderUI).toBeCalled();
+ });
+ });
+ });
+
+ describe('initViewer()', () => {
+ let addUISpy;
+ let renderUISpy;
+ beforeEach(() => {
+ jest.spyOn(model3d, 'handleReset').mockImplementation(() => {});
+ jest.spyOn(model3d, 'handleRotationAxisSet').mockImplementation(() => {});
+ jest.spyOn(model3d, 'populateAnimationControls').mockImplementation(() => {});
+ jest.spyOn(model3d, 'showWrapper').mockImplementation(() => {});
+
+ renderUISpy = jest.spyOn(model3d, 'renderUI').mockImplementation(() => {});
+ addUISpy = jest.spyOn(model3d.controls, 'addUi').mockImplementation(() => {});
+ });
+
+ test('should add the controls UI', () => {
+ model3d.initViewer({});
+
+ expect(addUISpy).toBeCalled();
+ });
+
+ test('should initialize viewer settings to defaults if meta is empty', () => {
+ model3d.initViewer({});
+
+ expect(model3d.axes.up).toBe('+Y');
+ expect(model3d.axes.forward).toBe('+Z');
+ expect(model3d.renderMode).toBe('Lit');
+ expect(model3d.projection).toBe('Perspective');
+ expect(model3d.renderGrid).toBe(true);
+ expect(model3d.handleRotationAxisSet).not.toBeCalled();
+ });
+
+ test('should initialize viewer settings to provided defaults', () => {
+ const defaults = {
+ cameraProjection: 'Orthographic',
+ defaultRenderMode: 'Unlit',
+ forwardAxis: 'forward',
+ renderGrid: 'false',
+ upAxis: 'up',
+ };
+ model3d.initViewer(defaults);
+
+ expect(model3d.axes.up).toBe(defaults.upAxis);
+ expect(model3d.axes.forward).toBe(defaults.forwardAxis);
+ expect(model3d.renderMode).toBe(defaults.defaultRenderMode);
+ expect(model3d.projection).toBe(defaults.cameraProjection);
+ expect(model3d.renderGrid).toBe(false);
+ expect(model3d.handleRotationAxisSet).toBeCalledWith(defaults.upAxis, defaults.forwardAxis, false);
+ });
+
+ describe('with react controls enabled', () => {
+ beforeEach(() => jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true));
+
+ test('should call renderUI if using react controls', () => {
+ model3d.initViewer({});
+
+ expect(renderUISpy).toBeCalled();
+ expect(addUISpy).not.toBeCalled();
+ });
+ });
+ });
+
+ describe('handleToggleAnimation()', () => {
+ beforeEach(() => {
+ jest.spyOn(model3d.renderer, 'toggleAnimation');
+ jest.spyOn(model3d, 'renderUI').mockImplementation(() => {});
+ });
+
+ test.each([true, false])('should toggle the animation for the renderer as %s', play => {
+ model3d.handleToggleAnimation(play);
+
+ expect(model3d.renderer.toggleAnimation).toBeCalledWith(play);
+ });
+
+ describe('with react controls enabled', () => {
+ beforeEach(() => {
+ jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true);
+ });
+
+ test.each([true, false])('should toggle the animation when initially %s', isPlaying => {
+ const nextIsPlaying = !isPlaying;
+ model3d.isAnimationPlaying = isPlaying;
+
+ model3d.handleToggleAnimation();
+
+ expect(model3d.isAnimationPlaying).toBe(nextIsPlaying);
+ expect(model3d.renderer.toggleAnimation).toBeCalledWith(nextIsPlaying);
+ expect(model3d.renderUI).toBeCalled();
+ });
+ });
+ });
+
+ describe('renderUI()', () => {
+ const getProps = instance => instance.controls.render.mock.calls[0][0].props;
+
+ beforeEach(() => {
+ jest.spyOn(model3d, 'getViewerOption').mockImplementation(() => true);
+ model3d.controls = {
+ destroy: jest.fn(),
+ render: jest.fn(),
+ };
+ });
+
+ test('should render react controls with the correct props', () => {
+ model3d.renderUI();
+
+ expect(getProps(model3d)).toMatchObject({
+ animationClips: [],
+ isPlaying: false,
+ onAnimationClipSelect: model3d.handleSelectAnimationClip,
+ onFullscreenToggle: model3d.toggleFullscreen,
+ onPlayPause: model3d.handleToggleAnimation,
+ onReset: model3d.handleReset,
+ });
+ });
});
});
diff --git a/src/lib/viewers/controls/icons/IconAnimation24.tsx b/src/lib/viewers/controls/icons/IconAnimation24.tsx
new file mode 100644
index 000000000..3358d8801
--- /dev/null
+++ b/src/lib/viewers/controls/icons/IconAnimation24.tsx
@@ -0,0 +1,15 @@
+import * as React from 'react';
+
+function IconAnimation24(props: React.SVGProps): JSX.Element {
+ return (
+
+ );
+}
+
+export default IconAnimation24;
diff --git a/src/lib/viewers/controls/icons/IconReset24.tsx b/src/lib/viewers/controls/icons/IconReset24.tsx
new file mode 100644
index 000000000..39aec50b1
--- /dev/null
+++ b/src/lib/viewers/controls/icons/IconReset24.tsx
@@ -0,0 +1,11 @@
+import * as React from 'react';
+
+function IconReset24(props: React.SVGProps): JSX.Element {
+ return (
+
+ );
+}
+
+export default IconReset24;
diff --git a/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx b/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx
new file mode 100644
index 000000000..74b2683ae
--- /dev/null
+++ b/src/lib/viewers/controls/model3d/AnimationClipsControl.tsx
@@ -0,0 +1,24 @@
+import React from 'react';
+import AnimationClipsToggle from './AnimationClipsToggle';
+
+type AnimationClip = {
+ duration: number;
+ id: string;
+ name: string;
+};
+
+export type Props = {
+ animationClips: Array;
+ currentAnimationClipId?: string;
+ onAnimationClipSelect: () => void;
+};
+
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export default function AnimationClipsControl(props: Props): JSX.Element {
+ return (
+ <>
+
+ {/* TODO: AnimationClipsFlyout */}
+ >
+ );
+}
diff --git a/src/lib/viewers/controls/model3d/AnimationClipsToggle.scss b/src/lib/viewers/controls/model3d/AnimationClipsToggle.scss
new file mode 100644
index 000000000..88a801af2
--- /dev/null
+++ b/src/lib/viewers/controls/model3d/AnimationClipsToggle.scss
@@ -0,0 +1,5 @@
+@import '../styles';
+
+.bp-AnimationClipsToggle {
+ @include bp-Control;
+}
diff --git a/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx b/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx
new file mode 100644
index 000000000..6684ca02b
--- /dev/null
+++ b/src/lib/viewers/controls/model3d/AnimationClipsToggle.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+import IconAnimation24 from '../icons/IconAnimation24';
+import './AnimationClipsToggle.scss';
+
+export type Props = {
+ onClick?: () => void;
+};
+
+export default function AnimationClipsToggle({ onClick }: Props): JSX.Element {
+ return (
+
+ );
+}
diff --git a/src/lib/viewers/controls/model3d/AnimationControls.scss b/src/lib/viewers/controls/model3d/AnimationControls.scss
new file mode 100644
index 000000000..6df42cdf9
--- /dev/null
+++ b/src/lib/viewers/controls/model3d/AnimationControls.scss
@@ -0,0 +1,11 @@
+@import '../styles';
+
+.bp-AnimationControls {
+ display: flex;
+ align-items: center;
+
+ .bp-PlayPauseToggle {
+ width: $bp-controls-size-default;
+ height: $bp-controls-size-default;
+ }
+}
diff --git a/src/lib/viewers/controls/model3d/AnimationControls.tsx b/src/lib/viewers/controls/model3d/AnimationControls.tsx
new file mode 100644
index 000000000..805af1785
--- /dev/null
+++ b/src/lib/viewers/controls/model3d/AnimationControls.tsx
@@ -0,0 +1,29 @@
+import React from 'react';
+import AnimationClipsControl, { Props as AnimationClipsControlProps } from './AnimationClipsControl';
+import PlayPauseToggle, { Props as PlayPauseToggleProps } from '../media/PlayPauseToggle';
+import './AnimationControls.scss';
+
+export type Props = AnimationClipsControlProps & PlayPauseToggleProps;
+
+export default function AnimationControls({
+ animationClips,
+ currentAnimationClipId,
+ isPlaying,
+ onAnimationClipSelect,
+ onPlayPause,
+}: Props): JSX.Element | null {
+ if (!animationClips.length) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/lib/viewers/controls/model3d/ResetControl.scss b/src/lib/viewers/controls/model3d/ResetControl.scss
new file mode 100644
index 000000000..4a421875d
--- /dev/null
+++ b/src/lib/viewers/controls/model3d/ResetControl.scss
@@ -0,0 +1,5 @@
+@import '../styles';
+
+.bp-ResetControl {
+ @include bp-Control;
+}
diff --git a/src/lib/viewers/controls/model3d/ResetControl.tsx b/src/lib/viewers/controls/model3d/ResetControl.tsx
new file mode 100644
index 000000000..25acf0c25
--- /dev/null
+++ b/src/lib/viewers/controls/model3d/ResetControl.tsx
@@ -0,0 +1,19 @@
+import React from 'react';
+import IconReset24 from '../icons/IconReset24';
+import './ResetControl.scss';
+
+export type Props = {
+ onReset: () => void;
+};
+
+export default function ResetControl({ onReset }: Props): JSX.Element {
+ const handleClick = (): void => {
+ onReset();
+ };
+
+ return (
+
+ );
+}