User Interface
In this tutorial, you will learn the basics of the user interface in DynaMaker. Every button and tab can be connected to any behavior according to the workflow of creating the custom product. As a product example, we will create an app to build custom conveyors from pre-designed parts. Each section of the whole conveyor will be able to be added manually so that any path can be created. Pretty similar to the typical roller coaster builder but without any zero-g rolls, unfortunately.
For this we will follow 5 steps:
Don't forget to check the common mistakes if you get stuck. Good luck!
1. Prepare Components
For this project, we will create three components: Roller, Section & Assembly. You can start with a new project with three new components, as done in previous tutorials.
A. Roller
Let's say that the roller models are previously designed (as STL). So, before going into geometry, let's start with the properties.
Properties
The roller will only have one property for its type: cylindrical or multidirectional.
- In CONSTANTS, make sure you have:
export enum ROLLER_TYPE {
CYLINDRICAL = 'roller-type-cylindrical',
MULTIDIRECTIONAL = 'roller-type-multidirectional',
}
export interface Properties {
rollerType: ROLLER_TYPE
}
- In COMPONENT, make sure you have:
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
super()
this.properties = {
rollerType: CONSTANTS.ROLLER_TYPE.MULTIDIRECTIONAL,
}
}
generateGeometry() {
// logic to generate geometry
}
}
Easy. Now the geometry!
Geometry
You can use your own (as GLB for now) or download the following for this tutorial:
- Cylindrical roller for
ROLLER_TYPE.CYLINDRICAL
. - Multidirectional roller for
ROLLER_TYPE.MULTIDIRECTIONAL
. - Base roller for both types as the part that joins the rollers with the structure.
To be able to use them in your components, you need to import them into your project first.
-
In the app dashboard, upload all files and create static models:
-
Before you can use these static models, make sure you import ASSETS within the component Roller, in the same way as if it was a subcomponent.
You should see ASSETS in imports within any Maker as soon as you upload a file in your app dashboard. Refresh the imports or the page (F5) if it does not show up.
-
Once you have imported them, create a function to get the corresponding geometry based on
rollerType
in GEOM3D, like for example:
export function getRollerGeometry(rollerType: string) {
const geometryGroup = new SKYCAD.GeometryGroup()
geometryGroup.addGeometry(ASSETS.STATIC_MODELS.BAR, {
scale: 1000,
})
switch (rollerType) {
case CONSTANTS.ROLLER_TYPE.CYLINDRICAL:
geometryGroup.addGeometry(ASSETS.STATIC_MODELS.CYLINDRICAL_ROLLER, {
scale: 1000,
})
break
case CONSTANTS.ROLLER_TYPE.MULTIDIRECTIONAL:
const multidirectionModel = ASSETS.STATIC_MODELS.MULTIDIRECTIONAL_ROLLER
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, -45, 0),
scale: 1000,
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, -71, 0),
rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
scale: 1000,
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, 45, 0),
scale: 1000,
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, 71, 0),
rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
scale: 1000,
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, 160, 0),
scale: 1000,
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, 186, 0),
rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
scale: 1000,
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, -160, 0),
scale: 1000,
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, -186, 0),
rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
scale: 1000,
})
break
}
return geometryGroup
}
Make sure to use the proper scale of your GLB files (which are usually in metres and therefore are to be scaled x1000). You can always do
geometryGroup
- In COMPONENT, call this function to generate the correct geometry, you can directly return it since there awill not be other geometries involved:
generateGeometry() {
const { rollerType } = this.getProperties()
return GEOM3D.getRollerGeometry(rollerType)
}
- In API export the constants so
ROLLER_TYPE
can be used in oher components:
export * from './component.js'
export * from './constants.js'
- In PRESETS COMP, create presets for both types to make sure the geometries are correctly generated.
export const preset = new PRESET.ComponentPreset(COMPONENT.Component, {
setCameraOnUpdate: false,
})
const RT = CONSTANTS.ROLLER_TYPE
preset.addCase({ rollerType: RT.CYLINDRICAL }, { label: RT.CYLINDRICAL })
preset.addCase({ rollerType: RT.MULTIDIRECTIONAL }, { label: RT.MULTIDIRECTIONAL })
- Save & Update and Publish.
Now that we have the rollers, let's add them to the Section.
B. Section
The conveyor can be divided into multiple sections. Each section will consist of a structure to hold the rollers, and the structure itself will be made of 2 legs and the bed where the rollers are located. For simplicity, the models of the bed and legs can be created within the Section component. Also, the direction or angle will be parametric.
To have a clear idea of what we are about to build, here are some pictures of what the main dimensions will represent and how the angle will affect the curvature of the rollers, bed and legs positions & rotations. However, you can choose your own design for the profile of the legs and bed. Notice that we will have exactly 8 rollers so that they can fit the section with a specific set of default properties.
This said, let's start with the properties and then go with the geometry.
Properties
To make it simple, height
, depth
and width
of the component Section will remain constant. Also, angle
and
rollerType
will be something that the user will be able to change in the app and therefore, will be part of the
properties.
-
First off, import the component Roller to reuse its constant
ROLLER_TYPE
viaedit imports...
. -
In CONSTANTS, make sure you have:
export interface Properties {
depth: number
height: number
width: number
angle: number
rollerType: ROLLER.ROLLER_TYPE
}
export const BED_HEIGHT = 150
export const BED_THICKNESS = 25
export const LEG_WIDTH = 50
export const ROLLERS_CC_DISTANCE = 100
- In COMPONENT try the following default property values:
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
super()
this.properties = {
depth: 500,
height: 1200,
width: 1000,
angle: 0,
rollerType: ROLLER.ROLLER_TYPE.CYLINDRICAL,
}
}
generateGeometry() {
// logic to generate geometry
}
}
Subcomponents
With the similar process as we did in My First Assembly:
- Still in COMPONENT, add
this.addRollers()
andthis.update()
within theconstructor
.
constructor() {
super()
// properties
this.addRollers()
this.update()
}
-
Create
addRollers()
to simply add the rollers to thecomponentHandler
:export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
super()
// properties
this.addRollers()
this.update()
}
addRollers() {
const nrRollers = 8
const rollerComponent = new ROLLER.Component()
for (let i = 0; i < nrRollers; i++) {
this.componentHandler.add(rollerComponent)
// you don't need to handle positions/rotations here since that will be done in update()
}
}
generateGeometry() {
// logic to generate geometry
} -
Create a function
getSectionRadius()
to get the radius for a section given its width and angle. This function will be used to place the rollers and legs correctly, and also to give the proper value for the bed width. You decide where to add it (within the classComponent
, outside or even in CONSTANTS). Let's add it inCONSTANTS for now:export function getSectionRadius(width: number, angle: number) {
const R = width / angle
return R
}
Be aware of the division by 0 in
getSectionRadius()
. In Typescript this will giveInfinity
, a value that can be avoided easily with an if statement to make sure theangle
is not 0. Continue to see how it's done.
- Create
update()
that handles the position and rotation of the rollers:
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
super()
// properties
this.addRollers()
this.update()
}
addRollers() {
// logic to add rollers
}
update() {
const { width, height, rollerType, angle } = this.getProperties()
// Rollers
const rollerComponent: ROLLER.Component = this.componentHandler.getComponents(ROLLER.Component)[0]
rollerComponent.setProperties({ rollerType })
const rollers = this.componentHandler.getInstances(ROLLER.Component)
const nrRollers = rollers.length
const stepPosX = width / nrRollers
let posX = stepPosX / 2
if (angle === 0) {
rollers.forEach((roller) => {
const position = new SKYMATH.Vector3D(posX, 0, height - CONSTANTS.BED_HEIGHT / 2)
roller.setPosition(position)
posX += stepPosX
})
} else {
const R = getSectionRadius(width, angle)
const rollerStepAngle = angle / nrRollers
let rotZ = rollerStepAngle / 2
let posY = (R * (1 - Math.cos(rotZ))) / 2
rollers.forEach((roller) => {
const position = new SKYMATH.Vector3D(posX, posY, height - CONSTANTS.BED_HEIGHT / 2)
const rotation = new SKYMATH.Vector3D(0, 0, rotZ)
roller.setPosition(position)
roller.setRotation(rotation)
rotZ += rollerStepAngle
posX = R * Math.sin(rotZ)
posY = R * (1 - Math.cos(rotZ))
})
}
}
generateGeometry() {
// logic to generate geometry
}
}
In this case, we don't clear the rollers and add them again. Since we always have 8 rollers in each section it's better to add them once (through
addRollers()
) and change their properties/position inupdate()
for the app's performance. This is quite convenient when you usually have the same subcomponents in some sort of assembly. Don't forget this for your future apps!
Great, now that we have the rollers in the componentHandler
we need to generate their geometry, together with the legs
and bed.
Geometry
To make it simple, the legs and bed will be generated directly within the component Section and added to the geometry of the Section. Let's start with the 2D sketches, then the 3D models, and finally the geometry.
- In GEOM2D, create the base of the leg and the profile for the bed:
export function generateLegBaseSketch() {
return SKYCAD.generateHexagonSketch(0, 0, CONSTANTS.LEG_WIDTH)
}
export function generateBedProfileSketch() {
const H = CONSTANTS.BED_HEIGHT
const T = CONSTANTS.BED_THICKNESS
const sketch = new SKYCAD.Sketch()
sketch.moveTo(0, 0)
sketch.lineTo(0, H / 2)
sketch.lineTo(T / 2, H / 2)
sketch.lineTo(T / 2, H)
sketch.lineTo(T, H)
sketch.lineTo(T, 0)
sketch.lineToId(0)
return sketch
}
- In GEOM3D, create the leg model as an extrude and the bed railing as a revolve for angles different from 0, and as an extrude for angles equal to 0:
export function generateLegModel(height: number) {
const model = new SKYCAD.ParametricModel()
const legSketch = GEOM2D.generateLegBaseSketch()
const plane = new SKYCAD.Plane(0, 0, 1, 0)
model.addExtrude(legSketch, plane, height - CONSTANTS.BED_HEIGHT)
return model
}
export function generateBedStraightModel(width: number, { invertedProfile = false } = {}) {
const model = new SKYCAD.ParametricModel()
const plane = new SKYCAD.Plane(-1, 0, 0, 0)
const profileSketch = GEOM2D.generateBedProfileSketch()
if (invertedProfile) profileSketch.mirrorInYaxis()
model.addExtrude(profileSketch, plane, -width)
return model
}
export function generateBedRevolveModel(radius: number, angle: number, { invertedProfile = false } = {}) {
const model = new SKYCAD.ParametricModel()
const plane = new SKYCAD.Plane(-1, 0, 0, 0)
const profileSketch = GEOM2D.generateBedProfileSketch()
if (invertedProfile) profileSketch.mirrorInYaxis()
profileSketch.translate(radius, 0)
model.addRevolve(profileSketch, plane, {
revolveAngle: angle,
axisDirection: new SKYMATH.Vector2D(0, 1),
})
return model
}
- In COMPONENT, create the geometry from these models, also add the rollers through the
componentHandler
as done in previous tutorials:
generateGeometry() {
const { width, depth, height, angle } = this.getProperties()
const model = GEOM3D.generateLegModel(height)
const geometryGroup = new SKYCAD.GeometryGroup()
// Legs
let leftLegPosition = new SKYMATH.Vector3D(width / 2, depth / 2, 0)
let rightLegPosition = new SKYMATH.Vector3D(width / 2, -depth / 2, 0)
if (angle !== 0) {
const R = CONSTANTS.getSectionRadius(width, angle)
leftLegPosition.x = (R - depth / 2) * Math.sin(angle / 2)
rightLegPosition.x = (R + depth / 2) * Math.sin(angle / 2)
leftLegPosition.y = depth / 2 + (R - depth / 2) * (1 - Math.cos(angle / 2))
rightLegPosition.y = -depth / 2 + (R + depth / 2) * (1 - Math.cos(angle / 2))
}
const legMaterials = [new SKYCAD.Material({ color: 0xCCCCCC })]
geometryGroup.addGeometry(model, {
materials: legMaterials,
position: leftLegPosition,
})
geometryGroup.addGeometry(model, {
materials: legMaterials,
position: rightLegPosition,
})
// Bed
const bedMaterials = [new SKYCAD.Material({ color: 0x8B0000 })]
if (angle === 0) {
geometryGroup.addGeometry(GEOM3D.generateBedStraightModel(width, { invertedProfile: false }), {
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, -depth / 2, height - CONSTANTS.BED_HEIGHT)
})
geometryGroup.addGeometry(GEOM3D.generateBedStraightModel(width, { invertedProfile: true }), {
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, depth / 2, height - CONSTANTS.BED_HEIGHT)
})
} else {
const radius = CONSTANTS.getSectionRadius(width, angle)
geometryGroup.addGeometry(GEOM3D.generateBedRevolveModel(radius + depth / 2, angle, { invertedProfile: false }), {
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, radius, height - CONSTANTS.BED_HEIGHT)
})
geometryGroup.addGeometry(GEOM3D.generateBedRevolveModel(radius - depth / 2, angle, { invertedProfile: true }), {
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, radius, height - CONSTANTS.BED_HEIGHT)
})
}
// Rollers
geometryGroup.addGeometry(this.componentHandler.generateAllGeometry())
return geometryGroup
}
- Save & Update and Publish.
Feel free to add custom textures or adjust the
metalness
to theSKYCAD.Material
we created here for the parametric models as we did in the previous tutorial.
Perfect! You can complement this Component Editor with some presets, using different roller types and angles to see the edge cases (e.g. if the rollers collide with each other for angle = ±90 degrees). Some examples:
C. Assembly
The entire conveyor or assembly will consist of multiple sections. This component should handle the adding and removing
of sections, which will be subcomponents driven by properties rollerType
& angle
. Then a configurator is needed for
the application. However, the logic to add & remove sections will be explained later, together with the UI Editor.
Properties
- First import the subcomponents Roller and Section via
edit imports...
. - In CONSTANTS, update the properties definition with just
rollerType
andangle
:
export interface Properties {
rollerType: ROLLER.ROLLER_TYPE
angle: number
}
- In COMPONENT, you can have the following class with
addSection()
andremoveSection()
, which will be triggered in the UI.
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
super()
this.properties = {
rollerType: ROLLER.ROLLER_TYPE.CYLINDRICAL,
angle: 0,
}
}
addSection() {
const { rollerType, angle } = this.getProperties()
const newSectionComponent = new SECTION.Component()
newSectionComponent.setProperties({ rollerType, angle })
this.componentHandler.add(newSectionComponent, {
// position for multiple sections will be added later
})
}
removeSection() {
const sections = this.componentHandler.getInstances(SECTION.Component)
if (sections.length > 0) {
const lastSection = sections[sections.length - 1]
const sectionsToRemove = [lastSection]
this.componentHandler.removeInstances(sectionsToRemove)
}
}
generateGeometry() {
return this.componentHandler.generateAllGeometry()
}
}
If you Save & Update you will see an empty preview because Assembly doesn't contain any section or rollers.
We will add presets later once addSection()
is completely finished.
Configurator
Keep in mind that the angle should be always in radians, because of the Math.sin(angle)
and Math.cos(angle)
used
previously. However, the displayed value should be in degrees to make it easier to read and update for the user.
- In PARAMETERS, create a parameter for each property.
Try yourself first and compare it with this new generateConfigurator():
export function generateConfigurator(component: COMPONENT.Component): SKYPARAM.Configurator {
const rollerTypeParameter = SKYPARAM.generateDropdown('rollerType', 'Roller type', [
new SKYPARAM.DropdownItem('Cylindrical', ROLLER.ROLLER_TYPE.CYLINDRICAL),
new SKYPARAM.DropdownItem('Multidirectional', ROLLER.ROLLER_TYPE.MULTIDIRECTIONAL),
])
rollerTypeParameter.setValue(component.getProperty('rollerType'))
const angleParameter = SKYPARAM.generateSlider('angle', 'Angle (deg)', {
defaultValue: -Math.round((component.getProperty('angle') * 180) / Math.PI),
maxValue: 90,
minValue: -90,
stepSize: 1,
})
const configurableParameters = [rollerTypeParameter, angleParameter]
const configurator = new SKYPARAM.Configurator(configurableParameters)
configurator.addCompletionCallback((validUserInput, values) => {
if (validUserInput) {
component.setProperties({
rollerType: values.rollerType,
angle: (-values.angle * Math.PI) / 180,
})
}
})
return configurator
}
Notice the negative sign in
defaultValue
andcomponent.setProperties()
. A negative angle means that the section goes to the left, and a positive angle means that it goes to the right. Doing this, we avoid using opposite angles for the calculations inupdate()
.
- Save & Update and Publish.
Great! We will go to the UI for now and get back to the assembly once we handle addSection()
together with connectors.
2. UI Editor
Once we have all the components, it's time to use the app. Open the UI Editor by clicking on the thumbnail, name or UI Studio in your app dashboard.
In the code editor you will see the following tabs:
- TESTS for adding tests regarding the app.
- UI where the tabs and buttons are created.
- STYLE to override the current css (color of buttons, style, UI sizes, etc).
- COMPONENTS meant for handling the components and geometries will be used in the app.
- SELECTION for handling all selection events (not part of this tutorial).
- HOTKEYS to assign keyboard shortcuts to specific (chains of) actions.
- ACTIONS that trigger any behavior (e.g. when clicking a button).
- CONSTANTS to store any constant.
- ADVANCED for the experienced user, including many functionalities (e.g. scene lighting & shadows, model edges, etc).
Keep in mind that the preview in the UI Editor is exactly what the user will see and use.
In this tutorial, we will focus on the tabs UI, explaining how to add some buttons/configurators to the UI, trigger specific actions (add or remove sections) in ACTIONS and bind those to some hotkeys in HOTKEYS.
Notice that the app is connected by default to the default cube component of the template project. Let's start by fixing that and then continue with the UI.
A. Connect Assembly
Similarly as done in My First App, we will modify the UI accordingly, but in order to keep UI clean, we will do it the help of the tab ACTIONS. But first:
- import your component Assembly to be able to use it in the app via
edit imports...
. If you used custom textures, import ASSETS too so that they get loaded in the visualization. - add an action that will be created later as
ACTIONS.addAssembly()
that handles adding an assembly to the visualization. - rename tab to
Product
. - leave
tabContent
empty for now (we will add buttons and configurators soon). - doing so, you should end up having something like the following in
onInit()
:
export function onInit() {
ACTIONS.addAssembly()
Studio.requestGeometryUpdate().then(() => updateCamera())
const productTab = new SKYUI.Tab({
title: 'Product',
icon: 'cog',
onInit: () => {
const tabContent = []
Studio.setTabContent(tabContent)
},
})
const tabs = [productTab]
Studio.setTabs(tabs)
}
If you Save & Update you will see the default assembly with an empty UI. Let's fix it then!
B. Prepare UI
In all apps, the user interface (UI) plays a critical role since it must be defined by the workflow of the design of the product, also known as user experience (UX). The app should give the user a feeling of customizing the product usually from main features to details, without breaking any rule. Even if the user goes back to a previous step to adjust any parameter, the product should behave accordingly without destroying the progress made so far (if possible). Therefore the UI always is driven by the UX, so making an app user-friendly is always the first priority.
In this case, we will prepare a simple UI consisting of 3 tabs:
- Product with the assembly configurator and the add & delete buttons.
- Edit intended for selecting already built sections and editing them (we will skip this and disable the tab instead).
- Export to export the conveyor as STL and more.
We will do a quick walkthrough of the functions as we are modifying them.
Add buttons & actions
Let's add the configurator and two buttons in the Product
tab:
export function onInit() {
ACTIONS.addAssembly()
Studio.requestGeometryUpdate().then(() => updateCamera())
const productTab = new SKYUI.Tab({
title: 'Product',
icon: 'cog',
onInit: () => {
const configurator = ACTIONS.getAssemblyConfigurator()
const addButton = new SKYUI.Button(
'Add section',
() => {
ACTIONS.addSection()
Studio.requestGeometryUpdate()
},
{ icon: 'plus' },
)
const deleteButton = new SKYUI.Button(
'Delete section',
() => {
ACTIONS.removeSection()
Studio.requestGeometryUpdate()
},
{ icon: 'trash' },
)
const tabContent = [configurator, addButton, deleteButton]
Studio.setTabContent(tabContent)
},
})
const tabs = [productTab]
Studio.setTabs(tabs)
}
function updateCamera() {
const cameraDirection = new SKYMATH.Vector3D(1, 1, -1)
Studio.setCameraToFitBounds({
direction: cameraDirection,
})
}
- In ACTIONS, create those functions like:
export function addAssembly() {
// wraps the first lines of onInit()
const componentHandler = manager.getComponentHandler()
const assemblyComponent = new ASSEMBLY.Component()
assemblyComponent.setProperties({ angle: 0 })
assemblyComponent.addSection()
componentHandler.add(assemblyComponent)
}
export function getAssemblyConfigurator() {
const assemblyComponent = getAssemblyComponent()
const configurator = ASSEMBLY.generateConfigurator(assemblyComponent)
return configurator
}
export function addSection() {
const assemblyComponent = getAssemblyComponent()
assemblyComponent.addSection()
}
export function removeSection() {
const assemblyComponent = getAssemblyComponent()
assemblyComponent.removeSection()
}
function getAssemblyComponent() {
const componentHandler = manager.getComponentHandler()
const assemblyComponents = componentHandler.getComponents(ASSEMBLY.Component)
if (assemblyComponents.length > 0) {
return assemblyComponents[0]!
} else {
throw new Error(`No assemblies found in componentHandler`)
}
}
- Save & Update to apply changes to the UI and see the first assembly.
You can read more about buttons and their optional arguments like
icon
in the SKYUI library.
Great. You can try adding and removing sections with the buttons but you will see that they are not connected. Now that we have connected the button with the respective functions of the assembly, let's add a new tabs before diving into the button actions.
Add new tab
Sometimes we want to show the customer the full potential of our app without implementing it. Instead of doing it at first, it is much more convenient to add buttons/tabs but disabled, so the customer could see how the prototype could look. So:
- Go back to your app, in UI
- Add a disabled
Edit
tab so the user could see that in the future the app could let them select any section and configure it individually. - Add a
Export
tab with some disabled buttons to download drawings, like:
export function onInit() {
ACTIONS.addAssembly()
Studio.requestGeometryUpdate().then(() => updateCamera())
const productTab = new SKYUI.Tab({
// previous settings and functions
})
const editTab = new SKYUI.Tab({
title: 'Edit',
icon: 'pencil',
disabled: true,
})
const exportTab = new SKYUI.Tab({
title: 'Export',
icon: 'download',
onInit: () => {
const downloadPdfButton = new SKYUI.Button('Download PDF', () => {}, { disabled: true, icon: 'download' })
const downloadStlButton = new SKYUI.Button('Download STL', () => {}, { disabled: true, icon: 'download' })
const tabContent = [downloadPdfButton, downloadStlButton]
Studio.setTabContent(tabContent)
},
})
const tabs = [productTab, editTab, exportTab]
Studio.setTabs(tabs)
}
You can read more about tab args like
disabled
in the SKYUI library.
Now that we have all the logic connected properly, we can add the connectors to add multiple sections in a row.
3. Connectors
To automatically add a second section at the end of an already-built one, we can use connectors, which are essentially a bundle of position and rotation with internal handling of local and global coordinate systems. We can add a connector to the Section component and then, the Assembly component makes sure that the new section is added to the last section's connector.
- Go back to the Section component.
- In COMPONENT, within the
class
creategetConnectors()
to add a connector at the end of the section (on the ground):
getConnectors() {
const { width, angle } = this.getProperties()
if (angle === 0) {
return [
new SKYCAD.Connector3D({
position: new SKYMATH.Vector3D(width, 0, 0),
rotation: new SKYMATH.Vector3D(0, 0, angle),
})
]
} else {
const R = CONSTANTS.getSectionRadius(width, angle)
return [
new SKYCAD.Connector3D({
position: new SKYMATH.Vector3D(R * Math.sin(angle), R * (1 - Math.cos(angle)), 0),
rotation: new SKYMATH.Vector3D(0, 0, angle),
})
]
}
}
- Save & Update and Publish this Section component.
getConnectors()
is a standard function in a component, meaning that it makes the connectors visible as a small blue-ish coordinate system in the Component Editor.
- Go back to the Assembly component.
- In COMPONENT, modify
addSection()
so that the new section is added to the previous section's connector, like:
addSection() {
const { rollerType, angle } = this.getProperties()
let previousSectionConnector = new SKYCAD.Connector3D()
const sections = this.componentHandler.getInstances(SECTION.Component)
if (sections.length > 0) {
const lastSection = sections[sections.length - 1]!
const lastSectionConnector = lastSection.getConnectors()[0]!
previousSectionConnector = lastSectionConnector
}
const newSectionComponent = new SECTION.Component()
newSectionComponent.setProperties({ rollerType, angle })
this.componentHandler.add(newSectionComponent, {
connector: previousSectionConnector,
})
}
- Save & Update and Publish this Assembly component.
Optionally add some presets (in PRESETS COMP) and debug them to see how these new functions work. For example:
export const preset = new PRESET.ComponentPreset(COMPONENT.Component, {
setCameraOnUpdate: false,
})
preset.addCase([], { label: 'Default (empty)' })
preset.addCase([], {
postProcessor: (component: COMPONENT.Component) => {
component.setProperties({
rollerType: ROLLER.ROLLER_TYPE.CYLINDRICAL,
angle: (30 * Math.PI) / 180,
})
component.addSection()
},
label: '1 section (cylindrical, 30 deg)',
})
preset.addCase([], {
postProcessor: (component: COMPONENT.Component) => {
component.setProperties({
rollerType: ROLLER.ROLLER_TYPE.CYLINDRICAL,
angle: (-60 * Math.PI) / 180,
})
component.addSection() // section 1
component.setProperties({
rollerType: ROLLER.ROLLER_TYPE.MULTIDIRECTIONAL,
angle: (30 * Math.PI) / 180,
})
component.addSection() // section 2
component.addSection() // section 3
component.removeSection()
},
label: '3 sections added and 1 removed',
})
Nice, we have a fully functional app! We will polish it in the following section. The camera, more UI items and the initial component are some of the things that remain to improve.
4. Polish App
As general polishing, we can adjust the camera, add some metrics and bind certain behaviors to hotkeys.
A. Camera
We can also adjust the camera when opening the app, so it's automatically adjusted to the geometry.
- In UI, notice the default function
updateCamera()
, that containsStudio.setCameraToFitBounds()
which updates the camera based on the current bounds of the instances seen in the preview. Choose your preference of yourcameraDirection
andtraveltime
function updateCamera() {
const cameraDirection = new SKYMATH.Vector3D(1, 0.75, -0.75)
Studio.setCameraToFitBounds({
direction: cameraDirection,
traveltime: 750,
})
}
- At the beginning of
onInit()
, you can change the camera settings withStudio.updateCameraSettings()
and add:groundLock: false
to disable the ground and be able to pan the camera freely.maxPolarAngle: Math.PI
to unlock the camera rotation up to the bottom (or nadir angle).orthographic: true
to have an orthographic view instead of perspective.maxTargetDistance: 1000
to limit the zoom when using perspective view (maxZoom
for orthographic)
export function onInit() {
Studio.updateCameraSettings({
groundLock: false,
maxPolarAngle: Math.PI,
orthographic: false, // perspective view
maxTargetDistance: 10000,
})
// rest of the function
}
// OR with orthographic view
export function onInit() {
Studio.updateCameraSettings({
orthographic: true,
maxZoom: 10000,
})
// rest of the function
}
Check more camera functionalities here.
B. Metrics
In DynaMaker there's also the possibility of adding additional elements to the UI, apart from the tabs. Metrics containing dynamaic values usually help to show certain key choices of your configurator. In this case we will show the current number of rollers including type and total conveyor length.
Since we are going to read the rollers within a section from the assembly, we could create a function that returns the component handler of the section. For this, simply:
- Go to the Section component.
- Create a function within the
class
that returns the roller instances (in COMPONENT):
// SECTION
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
// properties
}
getRollers() {
return this.componentHandler.getInstances(ROLLER.Component)
}
// rest of the functions
}
Great, now we can read easily the subcomponents of the section from the assembly:
- Go to the Assembly component.
- Create a function within the class that gets those values, you can wrap them in an object (in COMPONENT
within the
class
):
// ASSEMBLY
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
// properties
}
getMetrics() {
let totalConveyorLength = 0
let nrCylindricalRollers = 0
let nrMultidirectionalRollers = 0
const sections = this.componentHandler.getInstances(SECTION.Component)
sections.forEach((section) => {
const sectionComponent = section.getComponent()
// Length
const sectionLength = sectionComponent.getProperty('width')
totalConveyorLength += sectionLength
// Rollers
const rollers = sectionComponent.getRollers()
const nrRollers = rollers.length
const rollerType = sectionComponent.getProperty('rollerType')
if (rollerType === ROLLER.ROLLER_TYPE.CYLINDRICAL) {
nrCylindricalRollers += nrRollers
} else if (rollerType === ROLLER.ROLLER_TYPE.MULTIDIRECTIONAL) {
nrMultidirectionalRollers += nrRollers
}
})
return {
totalConveyorLength,
nrCylindricalRollers,
nrMultidirectionalRollers,
}
}
// rest of the functions
}
Now, this function can be called in the UI.
- Go back to the UI.
- In ACTIONS create a function that gets the metrics from the assembly, like:
export function updateMetrics() {
const assemblyComponent = getAssemblyComponent()
const metrics = assemblyComponent.getMetrics()
manager.defineVisibleMetrics([
{
id: 'totalConveyorlength',
label: 'Total conveyor length:',
value: `${Math.round(metrics.totalConveyorLength / 1000)} m`,
},
{
id: 'nrCylindricalRollers',
label: 'Nr cylindrical rollers:',
value: `${metrics.nrCylindricalRollers}`,
},
{
id: 'nrMultidirectionalRollers',
label: 'Nr multidirectional rollers:',
value: `${metrics.nrMultidirectionalRollers}`,
},
])
}
- In UI call this function in
onInit()
, after creating the assembly (otherwise there will not be metrics to look at):
export function onInit() {
// camera settings
ACTIONS.addAssembly()
ACTIONS.updateMetrics()
// rest of the function
}
- To update them dynamically, you need to update their values when clicking on
Add section
andDelete section
, so that:
const addButton = new SKYUI.Button(
'Add section',
() => {
ACTIONS.addSection()
ACTIONS.updateMetrics()
Studio.requestGeometryUpdate()
},
{ icon: 'plus' },
)
const deleteButton = new SKYUI.Button(
'Delete section',
() => {
ACTIONS.removeSection()
ACTIONS.updateMetrics()
Studio.requestGeometryUpdate()
},
{ icon: 'trash' },
)
Give it a go again, and see how the metrics update accordingly. Remember using static models and textures that are small in size to minimize the risk of affecting the app performance, since the bigger the files the longer loading times of the resulting geometry.
As the final step, you will learn how to create shortcuts or hotkeys in your app.
C. Hotkeys
In DynaMaker you can assign action events to hotkeys and have your customized key-bindings, meaning faster designing and easier user experience in your application. Let's take advantage of having an action event for adding and deleting the sections and assign this behavior to the keys.
You can easily find the hotkey value here. Give it a try and check that
Delete has the value of
46
.
We will add sections if we press +, and remove sections with - or Delete.
- In HOTKEYS, add the following code within
hotkeyEvent()
:
export function hotkeyEvent(event, manager: STUDIO.Manager) {
const keyMap = {
107: 'plus',
109: 'minus',
46: 'delete',
}
const key = keyMap[event.keyCode]
const keyFunctions = {
plus: () => {
ACTIONS.addSection()
ACTIONS.updateMetrics()
},
minus: () => {
ACTIONS.removeSection()
ACTIONS.updateMetrics()
},
delete: () => {
ACTIONS.removeSection()
ACTIONS.updateMetrics()
},
}
manager.requestGeometryUpdate()
return keyFunctions[key] && keyFunctions[key]()
}
Now you should be able to add and remove sections with these hotkeys.
Congratulations! You have learned the basics of the user interface, with a quick walkthrough of connectors, hotkeys and camera updates. The final application could look something like this with addtional UI features described below:
We have added a button to take a picture of the current view. Read more here if interested.
Additionally, we added the DynaMaker logo as a picture into the bottom-right corner, which can be done via CSS as you find with other useful snippets in here. Also, a quickbar with custom icons as images has been added too at the top of the visualization, together with an update of the buttons in the tab ⬇️ Exports.
Do you want to have a copy of this app in your team? Let us know at support@dynamaker.com! Remember that everyone has their own way of developing and there are multiple valid ways to do things as long as anyone can understand the code!