Skip to main content

Custom UI

In this tutorial, you will learn the basics of customizing the User Interface in DynaMaker. 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:

  1. Prepare components
  2. UI Editor
  3. Connectors
  4. Polish app

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

A1. Geometry

  • Download these two GLB files:

  • For the cylindrical type:

    • In the component editor, click Model > From file... > Choose file ROLLER_BAR.glb, then Create.
    • Do the same for ROLLER_CYLINDRICAL.glb for a second function.
    • Merge both functions so that a single geometryGroup has both static models.
    • Rename function to getCylindricalGeometry so that you have:
    export function getCylindricalGeometry() {
    const geometryGroup = new SKYCAD.GeometryGroup()
    geometryGroup.addGeometry(ASSETS.STATIC_MODELS.ROLLER_BAR)
    geometryGroup.addGeometry(ASSETS.STATIC_MODELS.ROLLER_CYLINDRICAL)
    return geometryGroup
    }

  • For the multidirectional type:

    • In the component editor, click Model > From file... > Choose file ROLLER_MULTIDIRECTIONAL.glb, then Create.
    • Rename function to getMultidirectionalGeometry.
    • Add the bar in the same way done in getCylindricalGeometry.
    • Duplicate the static model so that it consists of 4 pairs distributed along the Y-axis (within 200 mm), so like:
    export function getMultidirectionalGeometry() {
    const geometryGroup = new SKYCAD.GeometryGroup()

    geometryGroup.addGeometry(ASSETS.STATIC_MODELS.ROLLER_BAR)

    const multidirectionModel = ASSETS.STATIC_MODELS.ROLLER_MULTIDIRECTIONAL

    geometryGroup.addGeometry(multidirectionModel, {
    position: new SKYMATH.Vector3D(0, -45, 0),
    })
    geometryGroup.addGeometry(multidirectionModel, {
    position: new SKYMATH.Vector3D(0, -71, 0),
    rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
    })
    geometryGroup.addGeometry(multidirectionModel, {
    position: new SKYMATH.Vector3D(0, 45, 0),
    })
    geometryGroup.addGeometry(multidirectionModel, {
    position: new SKYMATH.Vector3D(0, 71, 0),
    rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
    })
    geometryGroup.addGeometry(multidirectionModel, {
    position: new SKYMATH.Vector3D(0, 160, 0),
    })
    geometryGroup.addGeometry(multidirectionModel, {
    position: new SKYMATH.Vector3D(0, 186, 0),
    rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
    })
    geometryGroup.addGeometry(multidirectionModel, {
    position: new SKYMATH.Vector3D(0, -160, 0),
    })
    geometryGroup.addGeometry(multidirectionModel, {
    position: new SKYMATH.Vector3D(0, -186, 0),
    rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
    })

    return geometryGroup
    }

A2. Constants

  • In CONSTANTS add the following to define the type of rollerType later:
export type RollerType = 'cylindrical' | 'multidirectional'
  • In EXPORTS, make sure constants are exported for other components:
export * from './components.js'
export * from './constants.js'
// export * from './parameters.js' <-- this can be removed

A3. Component

  • Click Component > Part from e.g. getCylindricalGeometry, then Create.
  • Create rollerType property:
    • a) define it within interface ComponentProperties like:
export interface ComponentProperties {
rollerType: CONSTANTS.RollerType
}
  • b) in constructor within this.properties add it with a default value:
this.properties = {
rollerType: 'cylindrical',
}
  • In generateGeometry, fetch the correct geometry depending on rollerType with an if- or switch-statement:
export interface ComponentProperties {
rollerType: CONSTANTS.RollerType
}
export class Component extends STUDIO.BaseComponent<ComponentProperties> {
constructor() {
super()
this.properties = {
rollerType: 'cylindrical',
}
}
generateGeometry() {
const { rollerType } = this.properties
const geometryGroup = new SKYCAD.GeometryGroup()

switch (rollerType) {
case 'cylindrical':
geometryGroup.addGeometry(GEOM3D.getCylindricalGeometry())
break
case 'multidirectional':
geometryGroup.addGeometry(GEOM3D.getMultidirectionalGeometry())
break
}

return geometryGroup
}
}

tip

Although you can test right away by adjusting the default value of rollerType, it's best to create a Component Test (bottom-left of editor) for each case like the following for an easier overview of both geometries without altering the code:

Component test: Cylindrical
export default function (): ROLLER.Component {
const component = new ROLLER.Component()
component.setProperties({ rollerType: 'cylindrical' })
return component
}
Component test: Multidirectional
export default function (): ROLLER.Component {
const component = new ROLLER.Component()
component.setProperties({ rollerType: 'multidirectional' })
return component
}

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 two legs (in the middle) and the bed (red rails) where the rollers are located. To avoid cluttering in the app with too many components, the models of the bed and legs will be created within the Section component. Also notice, 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.

For the curved sections, the center line or neutral axis will have the exact same length as width.

B2. Properties

To make it simple, height, depth and width of the component Section will remain constant. Also, angle (in radians!) and rollerType will be something that the user will be able to change in the app and therefore, will be part of the properties.

  • At the top of the left sidebar, open IMPORTS > Import other editors to use them here > Check ROLLER, then Apply changes.
  • In CONSTANTS, make sure you have:
export interface Properties {
angle: number
rollerType: ROLLER.RollerType
}

export const DEPTH = 500
export const WIDTH = 1000
export const HEIGHT = 1200

export const BED_HEIGHT = 150
export const BED_THICKNESS = 25
export const LEG_WIDTH = 50
export const ROLLERS_CC_DISTANCE = 100
  • Use snippet Component > Part for a faster setup.
  • Make sure it takes the new CONSTANTS.Properties instead of the default ComponentProperties.
  • Use the default values angle: 0 and rollerType: 'cylindrical'.
  • Leave generateGeometry empty for now, so like:
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
super()

this.properties = {
angle: 0, // [rad]
rollerType: 'cylindrical',
}
}

generateGeometry() {
const geometryGroup = new SKYCAD.GeometryGroup()
return geometryGroup
}
}

B3. Subcomponents

With a similar process as done in Tutorial 6. Assemblies:

  • Add not-yet-created component-class functions addRollers and update within the constructor like:
constructor() {
super()

this.properties = {
angle: 0, // [rad]
rollerType: 'cylindrical',
}

this.addRollers()
this.update()
}
  • Create addRollers() to simply add the rollers to the componentHandler (no positioning or rollerType yet):
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() {
// ...empty geometry
}
  • In CONSTANTS, add new function getSectionRadius based on width and angle:
export function getSectionRadius(width: number, angle: number) {
const R = width / angle
return R
}
caution

Be aware of the division by 0 in getSectionRadius(). In Typescript this will give Infinity, a value that can be troublesome to work with if not handled with caution with some if-statements.

  • Create update() that handles the position and rotation of the rollers based on the properties.
See completed component with rollers updating properly:
tip

Make sure to add tests that cover different values of angle in combination with rollerType.

export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
super()

this.properties = {
angle: 0, // [rad]
rollerType: 'cylindrical',
}

this.addRollers() // <-- 8 rollers are added once here!
this.update()
}

addRollers() {
const nrRollers = 8
const rollerComponent = new ROLLER.Component()
for (let i = 0; i < nrRollers; i++) {
this.componentHandler.add(rollerComponent) // <-- positioning is done in update()
}
}

update() {
const { 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 = CONSTANTS.WIDTH / nrRollers
let posX = stepPosX / 2

if (angle === 0) {
rollers.forEach((roller) => {
const position = new SKYMATH.Vector3D(posX, 0, CONSTANTS.HEIGHT - CONSTANTS.BED_HEIGHT / 2)
roller.setPosition(position)
roller.setRotation(new SKYMATH.Vector3D(0, 0, 0))
posX += stepPosX
})
} else {
const R = CONSTANTS.getSectionRadius(CONSTANTS.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, CONSTANTS.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() {
const geometryGroup = this.componentHandler.generateAllGeometry()
return geometryGroup
}
}
tip

See that we don't clear the rollers in update(). Since we always have 8, addRollers() creates them once, and update() takes care of their properties and positioning. This improves vastly the app performance since the geometry doesn't regenerate over and over, especially when it's only the position/rotation that changes.

B4. Legs & Bed

Technically the legs and the bed (rails) could be other subcomponents. However they have such a simple geometry that it's enough to add them in the section's generateGeometry, simplifying the app structure by avoiding too many unnecessary layers. This said let's create their geometry!

  • In GEOM2D, create the base of the leg and the cross-section for the bed:
See completed GEOM2D with both sketches:
GEOM2D
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, separating the case for angle = 0.
See completed GEOM3D with both geometry groups:
note

Feel free to add custom textures or change e.g. metalness for these parametric models as done in previous tutorials.

GEOM3D
const W = CONSTANTS.WIDTH
const D = CONSTANTS.DEPTH
const H = CONSTANTS.HEIGHT

export function getLegsGeometry(angle: number) {
const geometryGroup = new SKYCAD.GeometryGroup()
const legModel = generateLegModel(H)
let leftLegPosition = new SKYMATH.Vector3D(W / 2, D / 2, 0)
let rightLegPosition = new SKYMATH.Vector3D(W / 2, -D / 2, 0)
if (angle !== 0) {
const R = CONSTANTS.getSectionRadius(W, angle)
leftLegPosition.x = (R + D / 2) * Math.sin(angle / 2)
rightLegPosition.x = (R - D / 2) * Math.sin(angle / 2)
leftLegPosition.y = D / 2 - (R + D / 2) * (1 - Math.cos(angle / 2))
rightLegPosition.y = -D / 2 - (R - D / 2) * (1 - Math.cos(angle / 2))
}
const legMaterials = [new SKYCAD.Material({ color: 0xcccccc, metalness: 1, roughness: 0.3 })]
geometryGroup.addGeometry(legModel, {
materials: legMaterials,
position: leftLegPosition,
})
geometryGroup.addGeometry(legModel, {
materials: legMaterials,
position: rightLegPosition,
})
return geometryGroup
}

export function getBedGeometry(angle: number) {
const geometryGroup = new SKYCAD.GeometryGroup()
const bedMaterials = [new SKYCAD.Material({ color: 0xad0000, metalness: 1, roughness: 0.3 })]
if (angle === 0) {
geometryGroup.addGeometry(generateBedStraightModel(W, { invertedProfile: false }), {
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, -D / 2, 0),
})
geometryGroup.addGeometry(generateBedStraightModel(W, { invertedProfile: true }), {
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, D / 2, 0),
})
} else {
const R = CONSTANTS.getSectionRadius(W, angle)
geometryGroup.addGeometry(
generateBedRevolveModel(-R + D / 2, angle, {
invertedProfile: false,
}),
{
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, -R, 0),
},
)
geometryGroup.addGeometry(
generateBedRevolveModel(-R - D / 2, angle, {
invertedProfile: true,
}),
{
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, -R, 0),
},
)
}
return geometryGroup
}

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
}

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
}

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 COMPONENTS, add both getLegsGeometry and getBedGeometry together with the subcomponent's:
generateGeometry() {
const { angle } = this.properties
const geometryGroup = this.componentHandler.generateAllGeometry()

geometryGroup.addGeometry(GEOM3D.getLegsGeometry(angle))

geometryGroup.addGeometry(GEOM3D.getBedGeometry(angle), {
position: new SKYMATH.Vector3D(
0,
0,
CONSTANTS.HEIGHT - CONSTANTS.BED_HEIGHT,
),
})
return geometryGroup
}

Always doublecheck your component tests to make sure angle and rollerType are working as expected:

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.

C1. Component

  • Open IMPORTS > Import other editors to use them here > Check ROLLER and SECTION, then Apply changes.
  • Use the following component which has:
    • addSection() to add a new section component with the properties accordingly.
    • removeSection() to remove the last existing section (if any).
COMPONENTS
interface ComponentProperties {
sectionToAddAngle: number
sectionToAddRollerType: ROLLER.RollerType
}

export class Component extends STUDIO.BaseComponent<ComponentProperties> {
constructor() {
super()

this.properties = {
sectionToAddAngle: 0,
sectionToAddRollerType: 'cylindrical',
}
}

addSection() {
const { sectionToAddRollerType, sectionToAddAngle } = this.getProperties()
const newSectionComponent = new SECTION.Component()
newSectionComponent.setProperties({
rollerType: sectionToAddRollerType,
angle: sectionToAddAngle,
})
this.componentHandler.add(newSectionComponent, {
// positioning will be handled later in the tutorial
})
}

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()
}
}
  • Save for now this assembly which doesn't produce any visualization yet.

C2. Configurator

A configurator with two parameters (one for each property) will be created.

note

Note that angle is handled in radians throughout the subcomponents, but the user will use degrees, so a manual conversion is needed to present its parameter properly. Don't forget to revert the unit-conversion when assigning the new parameter values to the properties.

  • In PARAMETERS, create a configurator for both properties:
Try yourself first and compare it with this new generateConfigurator():
const PARAMETER_ID = {
ROLLER_TYPE: 'roller-type',
ANGLE: 'angle',
}

export function generateConfigurator(assemblyComponent: COMPONENTS.Component): SKYPARAM.Configurator {
const { sectionToAddAngle, sectionToAddRollerType } = assemblyComponent.getProperties()

const rollerTypeParameter = SKYPARAM.generateDropdown(
PARAMETER_ID.ROLLER_TYPE,
'Roller type',
[
new SKYPARAM.DropdownItem('Cylindrical', 'cylindrical'),
new SKYPARAM.DropdownItem('Multidirectional', 'multidirectional'),
],
{ defaultValue: sectionToAddRollerType },
)

const angleParameter = SKYPARAM.generateSlider(PARAMETER_ID.ANGLE, 'Angle (deg)', {
defaultValue: Math.round((sectionToAddAngle * 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) {
assemblyComponent.setProperties({
sectionToAddRollerType: values[PARAMETER_ID.ROLLER_TYPE],
sectionToAddAngle: (values[PARAMETER_ID.ANGLE] * Math.PI) / 180,
})
}
})

return configurator
}

The assembly is now prepared for the User Interface!

2. UI Editor

Once we have all the components, it's time for the User Interface:

  • In the dashboard, click + Create > Custom User Interface, then open the new User Interface.

In this UI-editor we will focus on two places on the left sidebar:

  • UI where the tabs with buttons and configurators are created.
  • ACTIONS that trigger any behavior (e.g. when clicking a button).

Notice that the default user interface app has 1 tab with an input-field parameter and a button with a not-yet-added component. Let's change that so it takes the Assembly properly instead.

A. Actions

  • Open IMPORTS, check ASSEMBLY, then Apply changes.
  • In ACTIONS, add these functions that will be used later:
export function updateCamera() {
manager.setCameraToFitBounds({
direction: new SKYMATH.Vector3D(1, 1, -0.75),
})
}

export function addAssembly() {
const assemblyComponent = new ASSEMBLY.Component()
assemblyComponent.addSection()
manager.getComponentHandler().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 assemblyComponents = manager.getComponentHandler().getComponents(ASSEMBLY.Component)
if (assemblyComponents.length > 0) {
return assemblyComponents[0]!
} else {
throw new Error(`No assemblies found!`)
}
}

B. onInit

  • In UI update onInit(), which is the function that is triggered when the app is opened for the first time, with the following so it includes a tab with the assembly configurator and two buttons to add and delete sections:

    tip

    A tab:

    • can be created as:

    const tab1 = new SKYUI.Tab({
    title: 'Name on tab',
    icon: 'cog',
    onInit: () => {
    // whatever actions
    },
    })
    • can be added to the UI as:

    const tabs = [tab1, tab2]
    Studio.setTabs(tabs)

    A button:

    • can be created as:

    const button1 = new SKYUI.Button(
    'Name on button',
    () => {
    // whatever actions
    },
    { icon: 'plus' },
    )
    • can be added to a tab (within its onInit())as:

    const tabContent = [button1, button2, configurator1]
    Studio.setTabContent(tabContent)
UI
export function onInit() {
ACTIONS.addAssembly()
Studio.requestGeometryUpdate().then(() => ACTIONS.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)
}
  • Save & Update to apply changes.

Now you have a fully functional UI: it adds sections with the chosen parameters and removes the last one (if any). However all new sections are added to the origin instead of at the end of each one - this we will fix in ASSEMBLY by using connectors.

3. Connectors

Each new section will be placed at the end point of the previous section. Instead of manually calculating the position and rotation of every single new section, we will use connectors to do the hard work.

tip

A connector is a 3D point that consists of position and rotation that can be created as:

const connector1 = new SKYCAD.Connector3D({
position: new SKYMATH.Vector3D(100, 200, 300),
rotation: new SKYMATH.Vector3D(0, 0, Math.PI / 2),
})

We will:

A. Section's Connectors

Create getConnectors() that returns a list with a single connector at the end of the section (on the ground):

  • Go back to the SECTION component.
  • In COMPONENTS within the class, create getConnectors() as:
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
// logic of constructor
}

getConnectors() {
const { angle } = this.getProperties()
if (angle === 0) {
return [
new SKYCAD.Connector3D({
position: new SKYMATH.Vector3D(CONSTANTS.WIDTH, 0, 0),
}),
]
} else {
const R = CONSTANTS.getSectionRadius(CONSTANTS.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),
}),
]
}
}

addRollers() {
// logic of addRollers
}

update() {
// logic of update
}

generateGeometry() {
// logic of generateGeometry
}
}
tip

Just like update(), getConnectors() is a built-in function in a DynaMaker component. It strictly expects a list of SKYCAD.Connector3D, which are shown in the visualization as a mini coordinate system in blue.

B. Assemble Sections

  • Go back to the ASSEMBLY component.
  • In COMPONENTS, modify addSection() so that the new section is added taking the previous section's connector, like:
addSection() {
const { sectionToAddRollerType, sectionToAddAngle } = 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: sectionToAddRollerType,
angle: sectionToAddAngle,
})
this.componentHandler.add(newSectionComponent, {
connector: previousSectionConnector,
})
}
Ideally add some components tests to see how these new functions work. For example:
Default (empty)
export default function (): ASSEMBLY.Component {
const component = new ASSEMBLY.Component()
return component
}
1 section (cylindrical, 30°)
export default function (): ASSEMBLY.Component {
const component = new ASSEMBLY.Component()
component.setProperties({
sectionToAddRollerType: 'cylindrical',
sectionToAddAngle: (30 * Math.PI) / 180,
})
component.addSection()
return component
}
3 sections added and 1 removed
export default function (): ASSEMBLY.Component {
const component = new ASSEMBLY.Component()
component.setProperties({
sectionToAddRollerType: 'multidirectional',
sectionToAddAngle: (-60 * Math.PI) / 180,
})
component.addSection() // section 1

component.setProperties({
sectionToAddRollerType: 'cylindrical',
sectionToAddAngle: (45 * Math.PI) / 180,
})
component.addSection() // section 2

component.addSection() // section 3

component.removeSection()
return component
}

  • Test back in the UI-editor with the new connectors, so that it looks like this app:

4. Polish App

As general polishing, we will go through:

A. Extra Tabs

When you're building your first demo or prototype, it helps to sketch out the UI of what the final app might look like. This makes it easier to understand the user's workflow without having to build every feature right away. Using locked or disabled buttons can be a simple way to show what's planned while keeping the focus on the core flow.

So we could add:

  • a1) 2nd tab Edit (disabled for now), meant for editing existing sections individually:
    • add an extra tab to tabs with its optional input disabled: true, like:
export function onInit() {
// ...logic to add assembly, generate geometry and update camera

const productTab = new SKYUI.Tab({
title: 'Product',
icon: 'cog',
onInit: () => {
// ...logic for productTab
},
})

const editTab = new SKYUI.Tab({
title: 'Edit',
icon: 'pencil',
disabled: true,
})

const tabs = [productTab, editTab]
Studio.setTabs(tabs)
}

  • a2) 3rd tab Exports, meant for adding buttons to export (disabled for now) like:
    • button to export a technical drawing (.pdf).
    • button to export 3D geometry (.step).
    • button to export Bill Of Materials or BOM table (.csv).
    • button to take a picture of the visualization (.png).
    • button to Add to cart (in case of a possible integration with a website).
export function onInit() {
// ...logic to add assembly, generate geometry and update camera

const productTab = new SKYUI.Tab({
title: 'Product',
icon: 'cog',
onInit: () => {
// ...logic for productTab
},
})

const editTab = new SKYUI.Tab({
title: 'Edit',
icon: 'pencil',
disabled: true,
})

const exportsTab = new SKYUI.Tab({
title: 'Exports',
icon: 'export',
onInit: () => {
const drawingButton = new SKYUI.Button('Drawing (.pdf)', () => {}, {
icon: 'download',
disabled: true,
disabledTooltip: 'Disabled for demo',
})
const stepButton = new SKYUI.Button('3D Model (.step)', () => {}, {
icon: 'download',
disabled: true,
disabledTooltip: 'Disabled for demo',
})
const bomButton = new SKYUI.Button('BOM (.csv)', () => {}, {
icon: 'download',
disabled: true,
disabledTooltip: 'Disabled for demo',
})
const pictureButton = new SKYUI.Button('Picture (.png)', () => {}, {
icon: 'download',
disabled: true,
disabledTooltip: 'Disabled for demo',
})
const addToCartButton = new SKYUI.Button('Add to cart', () => {}, {
icon: 'shopping-cart',
disabled: true,
disabledTooltip: 'Disabled for demo',
})

Studio.setTabContent([drawingButton, stepButton, bomButton, pictureButton, addToCart])
},
})

const tabs = [productTab, editTab, exportsTab]
Studio.setTabs(tabs)
}

B. Camera

The camera could be adjusted so that it:

  • b1) auto-zooms out when adding a new section:
    • within addButton, after updating the geometry with Studio.requestGeometryUpdate(), update the camera via ACTIONS.updateCamera(), like:
UI
const addButton = new SKYUI.Button(
'Add section',
() => {
ACTIONS.addSection()
Studio.requestGeometryUpdate().then(() => {
ACTIONS.updateCamera()
})
},
{ icon: 'plus' },
)
  • b2) can be rotated under the ground, panned perpendicular to the screen and with the mouse-left click:
    • In UI, override the default camera settings with Studio.updateCameraSettings() with its optional arguments:
UI
export function onInit() {
Studio.updateCameraSettings({
groundLock: false, // allows panning perpendicular to screen (instead of to ground)
maxPolarAngle: Math.PI, // unlocks rotation up to 180° (instead of default 90°)
mouseButtons: {
left: 'rotate', // overrides left-click to rotate camera (instead of default 'select')
},
})

// ...rest of logic of onInit
}
  • b3) moves nicely from a higher point when opening the app for the first time:

    • In ACTIONS, add optional input to updateCamera() for travelTime to define the duration of the camera update in milliseconds, like:
      ACTIONS
      export function updateCamera({ travelTime = 750 } = {}) {
      manager.setCameraToFitBounds({
      direction: new SKYMATH.Vector3D(1, 1, -0.75),
      travelTime,
      })
      }
    • In UI, use Studio.setCameraPosition(vector3D) to start from a high point, then increase travel time of the first camera update like:
    UI
    export function onInit() {
    Studio.updateCameraSettings({
    // ...camera settings
    })

    ACTIONS.addAssembly()

    Studio.setCameraPosition(new SKYMATH.Vector3D(-1000, -1000, 10000), { travelTime: 0 })

    Studio.requestGeometryUpdate().then(() => {
    ACTIONS.updateCamera({ travelTime: 2000 }) // <-- the 1st camera movement will take 2s (instead of default 0.75s)
    })

    // ... rest of logic (tabs)
    }
tip

Explore all camera functionalities here.

C. Metrics

In DynaMaker it's possible to add any element to the UI, apart from the tabs. Besides any custom HTML items, there are a few standard ones built-in like metrics. These can contain dynamic 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.
  • In COMPONENTS, create a class function that returns the roller instances:
SECTION > COMPONENTS
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
// ...logic of constructor
}

getRollers() {
return this.componentHandler.getInstances(ROLLER.Component)
}

// ...rest of class functions (getConnectors, addRollers, update, generateGeometry)
}
  • In EXPORTS, make sure to export everything from its CONSTANTS like:
SECTION > EXPORTS
export * from './components.js'
export * from './constants.js'
  • Go to the ASSEMBLY component.
  • Create a class function that gets e.g. the total length and roller quantity by type, the output can be wrapped in an object like:
ASSEMBLY > COMPONENTS
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 = SECTION.WIDTH
totalConveyorLength += sectionLength

// Rollers
const rollers = sectionComponent.getRollers()
const nrRollers = rollers.length
const rollerType = sectionComponent.getProperty('rollerType')
if (rollerType === 'cylindrical') {
nrCylindricalRollers += nrRollers
} else if (rollerType === 'multidirectional') {
nrMultidirectionalRollers += nrRollers
}
})

return {
totalConveyorLength,
nrCylindricalRollers,
nrMultidirectionalRollers,
}
}

// ...rest of functions (addSection, removeSection, generateGeometry)
}

Time to implement metrics in the UI-editor:

  • Go back to the UI-editor.
  • In ACTIONS create a function that gets the metrics from the assembly, like:
UI-editor > ACTIONS
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):
UI-editor > UI
export function onInit() {
// ...camera settings

ACTIONS.addAssembly()
ACTIONS.updateMetrics()

// ...rest of the function
}

So far we update the metrics only once (when opening the app for the 1st time), but we also want them to be updated for every change:

  • In UI, add ACTIONS.updateMetrics() for within the actions of the buttons like
UI-editor > UI
const addButton = new SKYUI.Button(
'Add section',
() => {
ACTIONS.addSection()
ACTIONS.updateMetrics()
Studio.requestGeometryUpdate().then(() => {
ACTIONS.updateCamera()
})
},
{ icon: 'plus' },
)

const deleteButton = new SKYUI.Button(
'Delete section',
() => {
ACTIONS.removeSection()
ACTIONS.updateMetrics()
Studio.requestGeometryUpdate()
},
{ icon: 'trash' },
)

D. Hotkeys

In DynaMaker you can assign action events to your keyboard keys or hotkeys and have your customized key-bindings, meaning faster designing and easier user experience in your app. Let's take advantage of already having an action for adding and deleting the sections and assign this behavior to the keys.

tip

You can easily find the hotkey value here. Try pressing Delete and see that it has the keycode 46.

We will add sections if we press +, and remove sections with - or Delete.

  • In HOTKEYS, add the following code within hotkeyEvent():
UI-editor > HOTKEYS
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.

Doublecheck your solution here:
  • Component ROLLER:
ROLLER > COMPONENTS
export interface ComponentProperties {
rollerType: CONSTANTS.RollerType
}
export class Component extends STUDIO.BaseComponent<ComponentProperties> {
constructor() {
super()
this.properties = {
rollerType: 'cylindrical',
}
}
generateGeometry() {
const { rollerType } = this.properties
const geometryGroup = new SKYCAD.GeometryGroup()

switch (rollerType) {
case 'cylindrical':
geometryGroup.addGeometry(GEOM3D.getCylindricalGeometry())
break
case 'multidirectional':
geometryGroup.addGeometry(GEOM3D.getMultidirectionalGeometry())
break
}

return geometryGroup
}
}
ROLLER > GEOM3D
export function getCylindricalGeometry() {
const geometryGroup = new SKYCAD.GeometryGroup()
geometryGroup.addGeometry(ASSETS.STATIC_MODELS.ROLLER_BAR)
geometryGroup.addGeometry(ASSETS.STATIC_MODELS.ROLLER_CYLINDRICAL)
return geometryGroup
}

export function getMultidirectionalGeometry() {
const geometryGroup = new SKYCAD.GeometryGroup()

geometryGroup.addGeometry(ASSETS.STATIC_MODELS.ROLLER_BAR)

const multidirectionModel = ASSETS.STATIC_MODELS.ROLLER_MULTIDIRECTIONAL

geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, -45, 0),
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, -71, 0),
rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, 45, 0),
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, 71, 0),
rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, 160, 0),
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, 186, 0),
rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, -160, 0),
})
geometryGroup.addGeometry(multidirectionModel, {
position: new SKYMATH.Vector3D(0, -186, 0),
rotation: new SKYMATH.Vector3D(0, Math.PI / 5, 0),
})

return geometryGroup
}
ROLLER > CONSTANTS
export type RollerType = 'cylindrical' | 'multidirectional'
ROLLER > EXPORTS
export * from './components.js'
export * from './constants.js'

  • Component SECTION:
SECTION > COMPONENTS
export class Component extends STUDIO.BaseComponent<CONSTANTS.Properties> {
constructor() {
super()

this.properties = {
angle: 0, // [rad]
rollerType: 'cylindrical',
}

this.addRollers() // <-- 8 rollers are added once here!
this.update()
}

getConnectors() {
const { angle } = this.getProperties()
if (angle === 0) {
return [
new SKYCAD.Connector3D({
position: new SKYMATH.Vector3D(CONSTANTS.WIDTH, 0, 0),
}),
]
} else {
const R = CONSTANTS.getSectionRadius(CONSTANTS.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),
}),
]
}
}

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()
}
}

getRollers() {
return this.componentHandler.getInstances(ROLLER.Component)
}

update() {
const { 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 = CONSTANTS.WIDTH / nrRollers
let posX = stepPosX / 2

if (angle === 0) {
rollers.forEach((roller) => {
const position = new SKYMATH.Vector3D(posX, 0, CONSTANTS.HEIGHT - CONSTANTS.BED_HEIGHT / 2)
roller.setPosition(position)
roller.setRotation(new SKYMATH.Vector3D(0, 0, 0))
posX += stepPosX
})
} else {
const R = CONSTANTS.getSectionRadius(CONSTANTS.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, CONSTANTS.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() {
const { angle } = this.properties
const geometryGroup = this.componentHandler.generateAllGeometry()

geometryGroup.addGeometry(GEOM3D.getLegsGeometry(angle))
geometryGroup.addGeometry(GEOM3D.getBedGeometry(angle), {
position: new SKYMATH.Vector3D(0, 0, CONSTANTS.HEIGHT - CONSTANTS.BED_HEIGHT),
})
return geometryGroup
}
}
SECTION > GEOM3D
const W = CONSTANTS.WIDTH
const D = CONSTANTS.DEPTH
const H = CONSTANTS.HEIGHT

export function getLegsGeometry(angle: number) {
const geometryGroup = new SKYCAD.GeometryGroup()
const legModel = generateLegModel(H)
let leftLegPosition = new SKYMATH.Vector3D(W / 2, D / 2, 0)
let rightLegPosition = new SKYMATH.Vector3D(W / 2, -D / 2, 0)
if (angle !== 0) {
const R = CONSTANTS.getSectionRadius(W, angle)
leftLegPosition.x = (R + D / 2) * Math.sin(angle / 2)
rightLegPosition.x = (R - D / 2) * Math.sin(angle / 2)
leftLegPosition.y = D / 2 - (R + D / 2) * (1 - Math.cos(angle / 2))
rightLegPosition.y = -D / 2 - (R - D / 2) * (1 - Math.cos(angle / 2))
}
const legMaterials = [new SKYCAD.Material({ color: 0xcccccc, metalness: 1, roughness: 0.3 })]
geometryGroup.addGeometry(legModel, {
materials: legMaterials,
position: leftLegPosition,
})
geometryGroup.addGeometry(legModel, {
materials: legMaterials,
position: rightLegPosition,
})
return geometryGroup
}

export function getBedGeometry(angle: number) {
const geometryGroup = new SKYCAD.GeometryGroup()
const bedMaterials = [new SKYCAD.Material({ color: 0xad0000, metalness: 1, roughness: 0.3 })]
if (angle === 0) {
geometryGroup.addGeometry(generateBedStraightModel(W, { invertedProfile: false }), {
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, -D / 2, 0),
})
geometryGroup.addGeometry(generateBedStraightModel(W, { invertedProfile: true }), {
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, D / 2, 0),
})
} else {
const R = CONSTANTS.getSectionRadius(W, angle)
geometryGroup.addGeometry(
generateBedRevolveModel(-R + D / 2, angle, {
invertedProfile: false,
}),
{
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, -R, 0),
},
)
geometryGroup.addGeometry(
generateBedRevolveModel(-R - D / 2, angle, {
invertedProfile: true,
}),
{
materials: bedMaterials,
position: new SKYMATH.Vector3D(0, -R, 0),
},
)
}
return geometryGroup
}

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
}

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
}

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
}
SECTION > GEOM2D
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
}
SECTION > CONSTANTS
export interface Properties {
angle: number
rollerType: ROLLER.RollerType
}

export const DEPTH = 500
export const WIDTH = 1000
export const HEIGHT = 1200

export const BED_HEIGHT = 150
export const BED_THICKNESS = 25
export const LEG_WIDTH = 50
export const ROLLERS_CC_DISTANCE = 100

export function getSectionRadius(width: number, angle: number) {
const R = width / angle
return R
}
SECTION > EXPORTS
export * from './components.js'
export * from './constants.js'

  • Component ASSEMBLY
ASSEMBLY > COMPONENTS
interface ComponentProperties {
sectionToAddAngle: number
sectionToAddRollerType: ROLLER.RollerType
}

export class Component extends STUDIO.BaseComponent<ComponentProperties> {
constructor() {
super()

this.properties = {
sectionToAddAngle: 0,
sectionToAddRollerType: 'cylindrical',
}
}

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 = SECTION.WIDTH
totalConveyorLength += sectionLength

// Rollers
const rollers = sectionComponent.getRollers()
const nrRollers = rollers.length
const rollerType = sectionComponent.getProperty('rollerType')
if (rollerType === 'cylindrical') {
nrCylindricalRollers += nrRollers
} else if (rollerType === 'multidirectional') {
nrMultidirectionalRollers += nrRollers
}
})

return {
totalConveyorLength,
nrCylindricalRollers,
nrMultidirectionalRollers,
}
}

addSection() {
const { sectionToAddRollerType, sectionToAddAngle } = 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: sectionToAddRollerType,
angle: sectionToAddAngle,
})
this.componentHandler.add(newSectionComponent, {
connector: previousSectionConnector,
})
}

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()
}
}
ASSEMBLY > GEOM2D
export * from './components.js'
export * from './constants.js'
ASSEMBLY > PARAMETERS
const PARAMETER_ID = {
ROLLER_TYPE: 'roller-type',
ANGLE: 'angle',
}

export function generateConfigurator(assemblyComponent: COMPONENTS.Component): SKYPARAM.Configurator {
const { sectionToAddAngle, sectionToAddRollerType } = assemblyComponent.getProperties()

const rollerTypeParameter = SKYPARAM.generateDropdown(
PARAMETER_ID.ROLLER_TYPE,
'Roller type',
[
new SKYPARAM.DropdownItem('Cylindrical', 'cylindrical'),
new SKYPARAM.DropdownItem('Multidirectional', 'multidirectional'),
],
{ defaultValue: sectionToAddRollerType },
)

const angleParameter = SKYPARAM.generateSlider(PARAMETER_ID.ANGLE, 'Angle (deg)', {
// defaultValue: -Math.round((sectionToAddAngle * 180) / Math.PI),
defaultValue: Math.round((sectionToAddAngle * 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) {
assemblyComponent.setProperties({
sectionToAddRollerType: values[PARAMETER_ID.ROLLER_TYPE],
// sectionToAddAngle: (-values[PARAMETER_ID.ANGLE] * Math.PI) / 180,
sectionToAddAngle: (values[PARAMETER_ID.ANGLE] * Math.PI) / 180,
})
}
})

return configurator
}
ASSEMBLY > EXPORTS
export * from './components.js'
export * from './parameters.js'

  • UI-Editor:
UI-Editor > UI
export function onInit() {
Studio.updateCameraSettings({
groundLock: false,
maxPolarAngle: Math.PI,
mouseButtons: {
left: 'rotate',
},
})

ACTIONS.addAssembly()
ACTIONS.updateMetrics()

Studio.setCameraPosition(new SKYMATH.Vector3D(-1000, -1000, 10000), {
travelTime: 0,
})

Studio.requestGeometryUpdate().then(() => ACTIONS.updateCamera({ travelTime: 2000 }))

const productTab = new SKYUI.Tab({
title: 'Product',
icon: 'cog',
onInit: () => {
const configurator = ACTIONS.getAssemblyConfigurator()

const addButton = new SKYUI.Button(
'Add section',
() => {
ACTIONS.addSection()
ACTIONS.updateMetrics()
Studio.requestGeometryUpdate().then(() => {
ACTIONS.updateCamera()
})
},
{ icon: 'plus' },
)
const deleteButton = new SKYUI.Button(
'Delete section',
() => {
ACTIONS.removeSection()
ACTIONS.updateMetrics()
Studio.requestGeometryUpdate()
},
{ icon: 'trash' },
)

const tabContent = [configurator, addButton, deleteButton]
Studio.setTabContent(tabContent)
},
})

const editTab = new SKYUI.Tab({
title: 'Edit',
icon: 'pencil',
disabled: true,
})

const exportsTab = new SKYUI.Tab({
title: 'Exports',
icon: 'export',
onInit: () => {
const drawingButton = new SKYUI.Button('Drawing (.pdf)', () => {}, {
icon: 'download',
disabled: true,
disabledTooltip: 'Disabled for demo',
})
const stepButton = new SKYUI.Button('3D Model (.step)', () => {}, {
icon: 'download',
disabled: true,
disabledTooltip: 'Disabled for demo',
})
const bomButton = new SKYUI.Button('BOM (.csv)', () => {}, {
icon: 'download',
disabled: true,
disabledTooltip: 'Disabled for demo',
})
const pictureButton = new SKYUI.Button('Picture (.png)', () => {}, {
icon: 'download',
disabled: true,
disabledTooltip: 'Disabled for demo',
})
const addToCartButton = new SKYUI.Button('Add to cart', () => {}, {
icon: 'shopping-cart',
disabled: true,
disabledTooltip: 'Disabled for demo',
})

Studio.setTabContent([drawingButton, stepButton, bomButton, pictureButton, addToCartButton])
},
})

const tabs = [productTab, editTab, exportsTab]
Studio.setTabs(tabs)
}
UI-Editor > ACTIONS
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}`,
},
])
}

export function updateCamera({ travelTime = 750 } = {}) {
manager.setCameraToFitBounds({
direction: new SKYMATH.Vector3D(1, 1, -0.75),
travelTime,
})
}

export function addAssembly() {
const assemblyComponent = new ASSEMBLY.Component()
assemblyComponent.addSection()
manager.getComponentHandler().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 assemblyComponents = manager.getComponentHandler().getComponents(ASSEMBLY.Component)
if (assemblyComponents.length > 0) {
return assemblyComponents[0]!
} else {
throw new Error(`No assemblies found!`)
}
}
UI-Editor > HOTKEYS
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]()
}


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:


Do you want to extend this demo and implement the currently disabled exports? Check these docs-sections for:

Now that you know the basics of creating a Custom UI, it's time to learn how to find and fix your errors easily in the next short tutorial Debugging.