Optical Simulation SDK

Begin

Our optical simulation SDK is released in a beta version and has only limited features support at the moment.
We are working on supporting more features and you can expect new versions to come out soon.

Install

The python package installation is available using PyPi:

pip install threed-optix

Get your API key

Get your API key from 3DOptix user interface (under “user settings”):

Start

tdo.Client object is used to communicate with 3DOptix databases and simulation engine.

import threed_optix as tdo

#Your API key from 3DOptix user interface
api_key = '<your_api_key>'

#api is the object that manages the communication with 3DOptix systems
client = tdo.Client(api_key)

Basics

Setups

tdo.Setup are the objects that represent your simulation setups in the SDK.
You could access their information:

Get a list of your 3DOptix setups:

#'setups' will be a list of your simulation setups as a `tdo.Setup` objects
setups = client.get_setups()

Create a tdo.Setup

Creating a new setup is not supported at the moment, but we’re working on it.

Find a setup:

First, we need to identify the setup we want to work on:

#Examine the setups:
for s in setups:
    print(s.name, s.id)

Note
A setup id is unique, but the name is not unique

Then, we can get the setup object by using client.get(name) and client[id] methods:

#Get setup by name
setup_name = '<your setup name>'
setup = client.get(setup_name)

#Get setup by id
setup_id = '<your setup id'
setup = client[setup_id]

If the setups is not found, client[setup_id] will lead to an error, and client.get(setup_name) will not.

Another way to do this is by looping over the setps list:

#Chosen setup id
setup_id = '<your setup id>'

#Get the 'Setup' object to work on
setup = None
for s in setups:
    #Get your setup by name
    if s.id == setup_id:
        setup = s
        break

assert setup is not None, "Setup was not found"

Parts:

tdo.Part are the objects that represent your setup parts in the SDK.
You could access their information:

Warning
Setups with parts that were loaded from a CAD file are not supported fully at the moment.
These CAD parts will not lead to an error, but they might lead to unexpected behavior.

tdo.Detector is the object used to represent detectors. It inherits all properties and functionalities from tdo.Part while also introducing specific attributes tailored for representing detectors within the SDK.
tdo.Detector has the following additional information:

tdo.LightSource is the object used to represent light sources. It inherits all properties and functionalities from tdo.Part while also introducing specific attributes tailored for representing light sources within the SDK.
tdo.LightSource has the following additional information:

You could see a full description of these objects, as well as how to modify them, later on this document.

Create or add a tdo.Part:

Creating a new part, light source or detector is not supported at the moment, but we’re working on it.

Find a part:

Almost identical to finding a setup.

#Examine the parts of the setup
for part in setup:
    print(part.label, part.id)

Note
A part id is unique, but the label is not unique

Then, we can identify the part and start working, similarly to how we identified the setup:

#Get part by label
part_label = '<your part label>'
part = setup.get(part_label)

#Get part by id
part_id = '<your part id'
part = setup[part_id]

If the part is not found, setup[part_id] will lead to an error, and setup.get(part_label) will not.

Or, alternatively, looping over the tdo.Setup object:

#Chosen part id
part_id = '<the id of the part to change>'

#Get the 'Part' object to work on
part = None
for p in setup:
    #Get part by id
    if p.id == part_id:
       part = p
       break

assert part is not None, "Part was not found"

Surface

tdo.Surface are the objects that represent the surfaces of the part in the SDK.
You could access their information:

Create or add a tdo.Surface

Creation of new surfaces is not possible.

Find a surface:

Almost identical to finding your setups and parts within your setups.

for surface in part:
    print(surface.name, surface.id)

Then, we can identify the surface by part.get() and part[] methods:

#Get surface by name
surface_name = '<your surface name>'
surface = part.get(surface_name)

#Get surface by id
surface_id = '<your surface id>'
surface = part[surface_id]

If the surface is not found, part[surface_id] will lead to an error, and part.get(surface_name) will not.

Or looping over the part:

#Choose surface id
surface_id = '<the id of the surface to perform analysis on>'

#Get the 'tdo.Surface' object:
surface = None
for s in part:
    #Get surface by id
    if s.id == surface_id:
      surface = s
      break

assert surface is not None, "Surface was not found"

Analysis

tdo.Analysis are the objects that represent analyses that can be performed on a surface.
They are defined by:

A surface can contain several analysis with identical properties and different ids.
However, each analysis consumes your account resources, since analysis id holds its results.
When you run the analysis again, the last result is deleted from 3DOptix systems.
So, we reccomend storing iterations of the same analysis locally and use duplicated analyses only when you think it’s necessary.

You can always get a reminder of the possible analyses with tdo.analysis_names variable.

Find existing analysis

Every new analysis consumes storage resources for you account.
So, we reccomend sticking to the same analysis if they have the same properties instead of creating new ones.
You could get a list of the tdo.Analysis of the surface like this:

setup = client.get('example')
detector = setup.get('my_detector')
detector_front = detector.get('front')
detector_front_analyses = detector_front.analyses

# Then, you could check their properties and choose the right one:

for analysis in detector_front_analyses:
    print(analysis)

Or get an existing analyses with required parameters, if the id doesn’t matter:

detector_front = setup.get('my_detector').get('front')
analysis = detector_front.find_analysis(name = name, rays = rays, resolution = resolution)
assert analysis

Create and add analysis

If you want to create an analysis that doesn’t exist for the surface yet, or you want to create a duplicated analysis, we can create one:

analysis = tdo.Analysis(surface: tdo.Surface,
                        name: str,
                        rays: dict {ls_object: int, ...}
                        resolution: list [int, int]
                        )

For example:

laser = setup.get('ls')
surface = detector.get('front')

analysis = tdo.Analysis(
                        surface = surface,
                        resolution = [500, 500],
                        rays = {laser: 1e5},
                        name = "Spot (Incoherent Irradiance)"
                        )

Reminder
You can always access all the possible analysis names with tdo.analysis_names

After we created an analysis, we need to add it to the surface and setup to be able to run it.

# In case the surface doesn't have analysis with this parameter
surface.add_analysis(analysis)

# In case that the surface has an analysis with these parameter, and we want to add a new one anyway
surface.add_analysis(analysis, force = True)

If the analysis is duplicated, you have to use force = True to indicate that you understand that you are adding a duplicated analysis that will use more storage.
Otherwise, you will get an error.

Make Changes

Backup

Before making any changes, we reccomend storing the original part or setup, before any changes:

#Backup the part
part = setup.get('part_label')
part.backup()

#Some code...

#Restore the original state of the part
part.restore()

In the case of multiple changes to multiple parts, you might find this easier:

# Backup the setup
setup = client['setup_id']
setup.backup()

#Some code...

#Restore the original state of the part
setup.restore()

Transformations

You could move and rotate part using part.change_pose() method that moves the part to a location on the three axis (x, y, z) and rotates it in respect to them (alpha, beta, gamma).

All six numbers indicating absolute future value in respect to the part’s coordinate system, not change and not with respect to the global coordinate system.

#Define the rotation
new_pose = [x, y, z, alpha, beta, gamma]

#Apply on the part
part.change_pose(new_pose)

# In case that the rotation is in radians
part.change_pose(new_pose, radians = True)

#Verify change
assert part.pose == new_pose

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

Note
Rotation is stated using degrees by default.
use part.change_pose(new_pose, radians = True) for radians.

Reminder
Changing the pose of a part also changes the poses of every part that is related to its coordinate system.

Change part’s label

It’s possible to change part’s label:

part.change_label(new_label)

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

Modify Detectors

Detectors have a detector.change_opacity() and detector.change_size() methods, that changes the detector’s opacity and size, accordingly.
This is how you use them:

# Get the detector
detector = setup.get('detector_label')

#Backup the original detector's state
detector.backup()

#Apply changes
detector.change_size([new_half_height, new_half_width])
detector.change_opacity(new_opacity)
detector.change_pose([x, y, z, alpha, beta, gamma])

#Verify change
print(detector.size, detector.opacity, detector.pose)

# Restore if needed
detector.restore()

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

Modify Light Sources

Change wavelengths

Light sources have a light_source.change_wavelengths() and light_source.add_wavelengths() methods, that changes the light source’s wavelengths or add new ones, accordingly.
In both cases, you could pass a list of equal weight wavelengths or a dict, defining wavelength-weight pairs.
This is how you use them:

# Get the light source
light_source = setup['light_source_id']

#Backup the original light source's state
light_source.backup()

#For equal-weight wavelengths
new_wavelengths = [550, 600, 650]
#For non-equal weight wavelengths
new_wavelengths = {550: 0.5, 600: 0.7, 700: 0.3}

#Change the wavelengths completely
light_source.change_wavelengths(new_wavelengths)
#Add new ones
light_source.add_wavelengths(new_wavelengths)

#Change pose
light_source.change_pose([x, y, z, alpha, beta, gamma])

#Verify change
print(light_source.wavelengths, light_source.pose)

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

Create normal distribution
If you want to create wavelengths spectrum with a normal disribution, you could use tdo.utils.wavelengths_normal_distribution:

import threed_optix.utils as tu

# Define the spectrum
wavelengths_spectrum = tu.wavelengths_normal_distribution(mean_wavelength, std_dev, num_wavelengths)

# Modify the light source
light_source.change_wavelengths(wavelengths_spectrum)

Where:

Create uniform distribution
Similarly, If you want to create wavelengths spectrum with a uniform disribution, you could use tdo.utils.wavelengths_uniform_distribution:

import threed_optix.utils as tu

# Define the spectrum
wavelengths_spectrum = tu.wavelengths_uniform_distribution(min_wavelength, max_wavelength, num_wavelengths)

# Modify the light source
light_source.change_wavelengths(wavelengths_spectrum)

Where:

Change to gaussian beam

ls.to_gaussian() allows you to change the light source beam to a gaussian and define it.
Arguments:

For example:

ls = setup['light_source_id']

gaussian_beam_config = {
    "waist_x": 1,
    "waist_y": 1,
    "waist_position_x": 0,
    "waist_position_y": 0
}

ls.to_gaussian(**gaussian_beam_config)

Change to plane wave beam

ls.to_point_source() allows you to change the light source beam to a point source and define it.
Arguments:

For example:

plane_wave_data = {
    "source_shape": "RECTANGULAR",
    "width": 10,
    "height": 10
}

plane_wave_config = {
    "density_pattern": "CONCENTRIC_CIRCLES",
    "plane_wave_data": plane_wave_data
}

ls.to_plane_wave(**plane_wave_config)

plane_wave_data = {
    "source_shape": "CIRCULAR",
    "radius": 5,
}

plane_wave_config = {
    "plane_wave_data": plane_wave_data,
    "density_pattern": "XY_GRID"
}

ls.to_plane_wave(**plane_wave_config)
plane_wave_data = {
    "source_shape": "ELLIPTICAL",
    "radius_x": 7,
    "radius_y": 7
}
plane_wave_config = {
    "plane_wave_data": plane_wave_data,
    "density_pattern": "RANDOM"
}

ls.to_plane_wave(**plane_wave_config)

Change to point source beam

ls.to_point_source() allows you to change the light source beam to a point source and define it.
Arguments:

For example:

ls = setup.get('light_source_label')
point_source_data = {
    "type": "HALF_CONE_ANGLE",
    "angle_y": 10,
    "angle_x": 10
}
point_source_config = {
    "point_source_data": point_source_data,
    "density_pattern": "XY_GRID",
    "model_radius": 1
}
ls.to_point_source(**point_source_config)
point_source_data = {
    "type": "HALF_WIDTH_AT_Z",
    "dist_z": 50,
    "half_width_x_at_dist": 10,
    "half_width_y_at_dist": 10,
}
point_source_config = {
    "point_source_data": point_source_data,
    "model_radius": 1,
    "density_pattern": "RANDOM"
}
ls.to_point_source(**point_source_config)

Change other properties

Other changable properties are:

At the beginning, we reccomend frequent sanity-checks in the GUI to make sure you got everything right.

Modify Several Properties Together

Changing multiple parameters sequentially can be time consuming.
In order to change several properties together at one time faster, you could use part.change_config.
In most cases, the argument are the same arguments of the original method.
In light source source type, it’s a dictionary with the arguments and values of the appropriate method.

Modify multiple properties of parts

part = setup.get('part_label')
part.change_config(label: str,
                   pose: list[float]
                   )

Modify multiple properties of detectors

detector = setup['detector_id']
detector.change_config(pose: str,
                      label: str,
                      size: tuple,
                      opacity: float
                      ):

Modify multiple properties of light sources

light_source = setup['light_source_id']
light_source.change_config(pose: list, #[0, 0, 0, 0, 0, 0,] 
                           label: str, #"New label"
                           wavelengths: Union[dict,list], #{550: 0.5, 650: 1}
                           add_wavelengths: Union[dict, list], #{750: 1, 850: 0.5}
                           power: float, #1
                           vis_count: int, #150
                           count_type: str, #TOTAL
                           rays_direction_config: dict, #{'theta': 0, "phi": 0, "azimuth_z": 10}
                           opacity: float, # 0.5
                           color: str, # "#000000"
                           gaussian_beam: dict, #config
                           point_source: dict, #config
                           plane_wave: dict #config
                           ):

gaussian_beam, point_source and plane_wave should be the same dictionaries defined in to_gaussian, to_point_source, and to_plane_wave.

Reminder
For beginners, we reccomend step-by-step changes with frequent sanity-checks in the GUI to make sure you got everything right.

Run Simulations And Analyses

Simulations

Running the simulation is really simple:

#run the simulation
ray_table = setup.run()

#Save the data locally
data_path = 'path/to/save/data.csv'
result.to_csv(data_path)

#View them as pd.DataFrame
results

The ray table is a custom pd.DataFrame object where each line is a single ray. The columns are:

idx Ox Oy Oz Dx Dy Dz As Ap phase_s
105 7.383775711 102.4612579 150.5897217 -0.02501097694 -0.008337006904 0.9996524453 2126145.5 2133974.0 3.118281841
114 -12.16847992 92.69891357 151.9878235 0.04484132305 0.02690477483 0.9986317754 2056683.875 2082956.125 5.680591106
173 5.660968781 103.3965912 250.0 -0.06809257716 -0.04085547104 0.9968421459 2481887.5 2514519.5 1.11269021
186 -1.279565811 101.2795563 250.0000153 0.01233130135 -0.01233132742 0.9998478889 2591881.75 2593823.0 1.747841358
212 5.587198734 96.64768219 251.0799866 -0.06809251755 0.04085548222 0.9968422651 2481887.5 2514519.5 0.2006378919
phase_p diffraction_order Hx Hy Hz f_s f_p refractive_index
3.118281841 0.0 7.148333549 102.3827744 160.0 0.7891492248 0.7908383608 1.518522382
5.680591106 0.0 -11.80871105 92.91477966 160.0 0.7770783901 0.782813549 1.518522382
1.11269021 0.0 5.58719492 103.3523254 251.0800018 1.0 1.0 1.0
1.747841358 0.0 -1.266245365 101.266243 251.0800018 1.0 1.0 1.0
0.2006378919 0.0 2.182572842 98.69045258 300.9220886 1.0 1.0 1.0
parent_idx family_idx surface wavelength light_source
89.0 9.0 LP86NPVUVMR 550 LP86R718Q6B
66.0 18.0 LP86NPVUVMR 550 LP86R718Q6B
141.0 13.0 LP86PQO2JLV 550 LP86R718Q6B
154.0 26.0 LP86PQO2JLV 550 LP86R718Q6B
164.0 4.0 LP86NPVY4K9 550 LP86R718Q6B

Analyses

Run

We can run analysis that is already in surface.analyses straight away:

surface = part['surface_id']
analysis = surface.find_analysis(name = "Spot (Coherent Irradiance) Huygens",
                                 rays = {laser: 1e6, laser2: 1e8},
                                 resolution = (400, 400)
                                 )
results = setup.run(analysis)

If the analyses that we want is not added yet, we need to add it and then run it.
We have two ways of doing that:

surface.add_analysis(analysis)
results = setup.run(analysis)

Reminder
If you would try to add analysis with exactly the same parameters as one that you already have, you should use force = True argument to make sure that you are interested with duplicated analysis.
Otherwise, choose the existing analysis and run it instead. This helps optimizing your system memory credits usage.

If we want to run multiple analyses with the same command, you can do that by passing tdo.Analysis list:

analyses = detector_front.analyses[1:4]
results = setup.run(analyses)

The recieved results will be a list of results in the same order of the analyses list, so that results[i] is the result of analysis analyses[i].

Results

Even if we didn’t store it in another variable, we can view and analize the latest results in a raw form:

print(analysis.results)

The result will be a JSON where each key is a wavelength in the analysis results.
Each wavelength contains the np.ndarray matrices for each polarization.
For example, let’s say I am looking for the “X” polarization, 400 nm rays hit matrix:

matrix = results[400]['X']

For analysis without polarization, polarization kind is “NONE”.

Note
If you plan on running the anlysis again, it is really important to store a deepcopy of analysis.results in the matrix variable.
Otherwise, the variable will hold the pointer to the results property of the analysis, and the previous results will be overriden.
Another option is to store the results of analysis() in another variable

If we want to see the matrices as a images, we can simply:

#for static figure
analysis.show()

#for interactive figure
analysis.show_interactive()

Arguments

analysis.show(upscale = True,
              polarizations = ['X'],
              wavelengths = [550, 600],
              figsize = (15, 15))
analysis.show_interactive(upscale = True,
                          polarizations = ['Y', 'Z'],
                          wavelengths = [500, 700],
                          figsize = (20, 20))

Advanced

Versioning

We can use the backup options to create versions of the parts and setups and manage them.
In order to do that, we give the back a name:

# For part
part.backup(name = '10z')
# Some code
part.restore(name = '10z')

# For entire setup
setup.backup(name = 'system phase 1')
# Some code
setup.restore(name = 'system phase 1')

The same backup name for both setup and part overrides those backups, also for individual parts inside the setup.

Ask questions

If you have any questions about the SDK, you can send them to client.ask() and get a response from OptiChat, our optics copilot.

client.ask('How can I get the front surface of my detector object?')
client.ask('How can I define a uniform distribution between 500 nm and 600 nm to my light source?')
client.ask('Why do I need to add the `force` argument when I want to add a duplicated analysis?')

Utils Module

Get spot size

tdo.utils.calculate_spot_size(matrix) calculates the diameter of the blocking circle of the biggest contours of the matrix, in pixels.

import threed_optix.utils as tu

# Assuming that this analysis exists already
setup = client['setup_id']
detector = setup.get('detector_label')
detector_front = detector.get('front')

analysis = detector_front.analysis_with(name ="Spot (Incoherent Irradiance)",
                                        rays = {laser1: 2.5e6, laser2: 2.5e6},
                                        resolution = (1000, 1000)
                                        )
# Run the analysis
results = setup.run(analysis)

# Calculate spot size
matrix = results[550]['X']
spot_size_dia = tu.calculate_spot_size(matrix)
print(f'Analysis spot size diameter for X polarization at 550 nm is {spot_size_dia}')

tdo.utils.encircled_energy(matrix, percent) calculates the diameter of the circle that centers at the center of energy mass, and contains percantage of the matrix’s total energy.

encircled_energy_radius, center = tu.encircled_energy(matrix, 0.9)
print(f'Analysis encircled 90% energy radius for X polarization at 550 nm is {encircled_energy_radius}')

Of course, these values are pixel values. In order to get absolute values, use tdo.utils.absolute_pixel_size:

pixel_radius, center = tu.encircled_energy(matrix, 0.95)
absolute_radius = tu.absolute_pixel_size(detector.size, analysis.resolution)[0] * pixel_radius
print(f'95% Encircled energy radius is {absolute_radius} mm')

Scans

In order to perform a scan, all we need to do is to define an analysis:

analysis = tdo.Analysis(name = "Spot (Incoherent Irradiance)",
                        rays = {light_source: 1e5, light_source2: 5e4}
                        resolution = [500, 500],
                        surface = detector.get('front')
                        )
detector_front.add_analysis(analysis)

Or choosing an existing one:

analysis = detector_front.analysis_with(name = name,
                                        rays = rays,
                                        resolution = resolution
                                        )
assert analysis is not None

Then, we need to iteratively change the properties of some part in the setup and store the results.

def scan_z(lens, analysis, dz_range, steps):

    # Make a backup
    lens.backup()
    
    # Store the original pose
    original_pose = lens.pose.copy()
    
    # Store the results here
    results_history = []

    # Iterate over the lens location in the z axis
    for dz in np.arange(dz_range[0], dz_range[1] + steps, steps):

        #Define absolute new pose
        delta = [0, 0, dz, 0, 0, 0]
        new_pose = [j+h for j, h in zip(original_pose, delta)]

        # Apply changes
        lens.change_pose(new_pose)

        # Run analysis
        results = setup.run(analysis)
        results = {"dz": dz, "results": results}

        # Store them
        results_history.append(results)

    lens.restore()

    return results_history

results = scan_z(lens, analysis, (-1, 1), 0.1)

Similarly, you will be able to perform grid scan, changing multiple parameters together.
If we want, we can always store a version of the system at each iteration and restore it afterwards:

for i, config in lens_configs:
    lens.change_config(**config)
    lens.backup(name = i)
    results = setup.run(analysis)
    results_history.append(results)

# Choose the best version
lens.restore(name = best_i)

Optimizations

If you have a merit or loss function you wish to optimize or minimize, consider using tdo.optimize.
Here are few examples:

import threed_optix.optimize as opt
import threed_optix.utils as tu

def loss(new_yz):

    # Define absolute new pose
    y, z = new_yz
    new_pose = original_pose.copy()
    new_pose[1] = y
    new_pose[2] = z
    
    # Change len's pose
    lens.change_pose(new_pose)
    
    # Run analysis
    results = setup.run(analysis)
    
    # Get the right image
    image = results[550]['X']
    
    # Calculate spot size can be any function that returns a scalar you want to minimize.
    spot_size_diameter = tu.calculate_spot_size(image)
    
    print(f"dz: {dz}, dy: {dy}, spot size: {spot_size_diameter}")
    return spot_size_diameter

# Assuming the initial guess is the current lens position
lens = setup.get('lens1')
lens.backup(name = 'before_optimization')
original_pose = lens.pose.copy()

y = original_pose[1]
z = original_pose[2]
initial_guess = [y, z]

# Define bounds to avoid getting out of desired range
low_y = y -1
high_y = y + 1
low_z = z -1
high_z = z + 1
bounds = [(low_y, high_y), (low_z, high_z)]

# Execute search
result = opt.minimize(loss, initial_guess, method='Nelder-Mead', bounds = bounds)

# Restore backup values
lens.restore(name = 'before_optimization')

# Get the optimized values
best_y, best_z = result.x

# Output the best values found
print(f"Best z: {best_z}, Best y: {best_y}")

best_pose = original_pose.copy()
best_pose[1] = best_y
best_pose[2] = best_z
lens.change_pose(best_pose)

Ofcourse, the optimization will be done up to the point where there is no change in the pixel radius.
In order to bypass this, you could make the detector smaller and smaller as the iteration goes.
If you do so, you should return the absolute value, rather then pixel one.

detector = setup['detector_id']
lens = setup['lens_id']

def loss(new_yz):
    y, z = new_yz
    new_pose = detector.pose
    new_pose[1] = y
    new_pose[2] = z
    # Change len's pose
    lens.change_pose(new_pose)
    # Run analysis
    results = setup.run(analysis)
    # Get the right image
    image = results[550]['X']
    # Calculate spot size can be any function that returns a scalar you want to minimize.
    pixel_radius, center = tu.encircled_energy(image, percent = 0.9)
    # Assuming your resolution and detector size are squares. Otherwise, ofcourse, further modifications are required.
    absolute_radius = tu.absolute_pixel_size(detector.size, analysis.resolution)[0] * pixel_radius
    # Make the detector smaller to increase optimization accuracy
    size = max(min(absolute_radius * 5, 200), minimum_search_size)
    detector.change_size([size, size])

    return absolute_radius

Matlab code

If you have a general matlab code you wish to use in our SDK, you could simply translate it to python. We recommend using matlab2python library from the github repo:

pip install matlab2python@git+https://github.com/ebranlard/matlab2python.git#egg=m
atlab2python

And then use in your code:

import matlabparser as mpars

mlines="""# a comment
x = linspace(0,1,100);
y = cos(x) + x**2;
"""
pylines = mpars.matlablines2python(mlines, output='stdout')
print(pylines)

Warning
This is an external library that’s not part of our SDK, nor was built by us.
Use output code with caution.

Send Feedback

We would love to hear you feedback and suggestions.
Please send any thoughts and ideas you have to us by using client.feedback() method.

client.feedback('Thanks for reading so far!')

License

3DOptix API is available with MIT License.