Working with Projects

When you open a Maptek application such as Vulcan GeologyCore or PointStudio, you need to select or create a project to work in. A project is a local store of the data you are interacting with during that application session. When you use the SDK to write a script to operate on your data, one of the first things you typically need to do is connect to a project that is open in a running application instance. This topic explores how do to this, and common ways of interacting with a project.

Connecting to a running application

The Project class represents a connection to a running application.

The simplest way to connect to an application is to call the Project constructor with no arguments, as shown in the example below.

from mapteksdk.project import Project
project = Project() # Connect to default project (whichever application is running)

The code example above will find the most recently opened instance of Maptek PointStudio, BlastLogic, Evolution, or Vulcan GeologyCore and connect to it. When the script finishes, it will automatically disconnect from the application.

The Project class also supports being used as a context manager using a with block. This causes the script to disconnect from the application when the with block ends. This allows for more precise control of when the script disconnects from the application, as shown in the example below.

from mapteksdk.project import Project

with Project() as project:
    pass
# The script will automatically disconnect from the project
# when the with block ends.

Default connection behaviour

The default Project constructor will connect to the most recently opened compatible application. To ensure you always connect to the expected application, it is recommended that you have only one compatible application open when running scripts.

To select which application to connect to, see Advanced: Selecting which application to connect to on this page.

Basic operations

Both examples in the previous section only connect to an application. Once a script has connected to an application, it can access and manipulate the data in that application by calling functions on the Project object.

Adding objects

Use the Project.add_object() function to add an object to a container in the project.

Adding multiple objects at once

The Project.add_objects() function is similar to Project.add_object(), but is more efficient at adding many objects to the same container. Thus:

The Project.add_objects() function accepts the following arguments:

  • The path to the container the objects should be added to.

  • The names and objects to add to the container. This can be a list of tuples of (name, object) pairs, or a dictionary where the keys are the names and the values are the object to add.

  • If existing objects should be overwritten by the operation (defaults to False).

The following example demonstrates a script that could potentially create thousands of objects. Using Project.add_objects() allows this script to more efficiently handle such cases.

from __future__ import annotations
 
from mapteksdk.project import Project
from mapteksdk.data import (
  Text2D, HorizontalAlignment, VerticalAlignment, ObjectID)
from mapteksdk.operations import(
  object_pick, active_view, show_message, Severity)
import numpy as np
 
def label_points(
    project: Project, points: np.ndarray) -> dict[str, ObjectID[Text2D]]:
  """Creates labels for the list of points.
 
  This creates one Text2D object in the project for each point in the points
  array with its location set to the point and its text set to that point's
  index in the array.
 
  Parameters
  ----------
  project
    The Project to use to create the labels.
  points
    Numpy array of shape (N, 3) where N is the point count. These are the
    points to create labels at.
 
  Returns
  -------
  dict[str, ObjectID[Text2D]]
    A dictionary where the key is the name of each label and the value
    is the object id of each label. Each label is an orphan. It is the
    caller's responsibility to add them to the Project.
  """
  labels: dict[str, ObjectID[Text2D]] = {}
  for i, point in enumerate(points):
    # A path of None means each label is created as an orphan.
    with project.new(None, Text2D) as label:
      label.location = point
      label.text = str(i)
      label.horizontal_alignment = HorizontalAlignment.CENTRED
      label.vertical_alignment = VerticalAlignment.CENTRED
      labels[f"{i}"] = label.id
  return labels
 
if __name__ == "__main__":
  project = Project()
 
  oid = object_pick(label="Pick an object to label the points of.")
  path = oid.path
  with project.read(oid) as data_object:
    if not hasattr(data_object, "points"):
      show_message(
        "Error", "The picked object does not have points", Severity.ERROR)
    else:
      labels = label_points(project, data_object.points)
 
  if labels:
    # Now that every label has been created, add them all at once.
    # This will greatly improve the performance if the object has
    # many thousands of points.
    project.add_objects(f"{path}_labels", labels, overwrite=True)
    view = active_view()
    view.add_objects(labels.values())

Renaming objects

The Project.rename_object() function can be used to rename an object in an application. The function takes two arguments: the path to the object to rename and the new path for the object.

The example below shows how to rename the object object_to_rename to new_name.

import sys
from mapteksdk.project import Project, ObjectDoesNotExistError


# It is possible for an object to exist with multiple paths in a project,
# so the Project().rename() function is used for renaming and/or moving an object.


with Project() as project:
    path = "scrapbook/surfaces/Pit model"
    try:
        project.rename_object(path, "Pit model 2", overwrite=True)
        found_at = project.find_object("/scrapbook/surfaces/Pit model 2")
        if found_at is None:
            print("Copy operation failed.")
            sys.exit(0)
        print(f"Object now called: {found_at.name}")
        project.rename_object(found_at, "Pit model")
        print(f"Object renamed back to: {found_at.name}")
    except ObjectDoesNotExistError:
        print(f"Object to copy not found at: {path}")


    # Example output:
    # Object now called: Pit model 2
    # Object renamed back to: Pit model

If there is no object at the path object_to_rename, then the script will fail with an ObjectDoesNotExistError.

Creating a new object

You can create a new object in the connected application by using the Project.new() function. This requires two arguments:

  • object_path: The path in the project to place the new object.

  • object_class: The type of object to create. See the mapteksdk.data module documentation for a complete list of supported types.

In the example below, we create a square in the XY plane, centred at the origin with an edge length of two.

from mapteksdk.project import Project
from mapteksdk.data import Polygon

project = Project()

with project.new("cad/square", Polygon) as square:
    # See Data section for an explanation of what this means:
    square.points = [[-1, -1, 0], [1, -1, 0], [1, 1, 0], [-1, 1, 0]]
# The square will appear in the application when the with block ends.

The Project.new() function should always be called inside a with block. The properties of the new object are set within the with block. See the API Reference for a type to see which properties it supports.

The new object is not saved and will not appear in the application until the with block ends. If an error occurs before the with block ends, the object will not be saved.

Reading an existing object

Project instances also support reading the properties of existing objects in the application. This is done via the Project.read() function. Unlike Project.new(), Project.read() accepts a single parameter that specifies the path to (or ObjectIdT ) the object to read. The Maptek Python SDK automatically determines the type of the read object.

In the example below, we read the square created in the previous example and print out its points.

from mapteksdk.project import Project

project = Project()

with project.read("cad/square") as read_square:
    print(read_square.points)

Project.read() is useful to query the properties of an object without editing it. Attempting to edit the object will either raise an error or be ignored.

Editing an existing object

Project also supports editing an existing object using the Project.edit() function. Similar to Project.read() , Project.edit() accepts the path to (or of) the object to edit and determines the type of the object automatically. Unlike Project.read(), Project.edit() allows the properties of the object to be edited.

In the example below, we use Project.edit() to move the square created in a previous example in metres in the X direction.

from mapteksdk.project import Project

project = Project()

with project.edit("cad/square") as edit_square:
    edit_square.points[:, 0] += 1
# The changes are saved when the with block ends.

Editing an object or creating an object if it does not exist

Project.new() will raise an error if there is already an object at the specified path. In some cases it is desirable to edit the object if it already exists. This can be achieved via the Project.new_or_edit() function.

If there is no object at the specified path, Project.new_or_edit() will create a new object. If there is an object at the specified path that matches the specified type, then that object will be opened for editing.

In the example below, we use Project.new_or_edit() to change the points of the Polyline created previously to a diamond centred at the origin. If the object is deleted and the example is run, it will instead create a new Polyline representing a diamond centred at the origin.

from mapteksdk.project import Project
from mapteksdk.data import Polyline

project = Project()

with project.new_or_edit("cad/square", Polyline) as polyline:
  polyline.points = [[-1, 0, 0], [0, 1, 0], [1, 0, 0], [0, -1, 0]]

Overwriting objects

In some cases it is desirable to delete any existing objects at the specified path rather than edit them. The Project.new_or_edit() function will raise an error if the object at the specified path is a different type to the given type, so if you wish to place an object of a different type at a path then you need to delete the existing object rather than edit it. This can be achieved by passing the overwrite argument to Project.new() as shown in the example below, where we replace the square created in a previous example with a diamond. This will always result in a new Polyline.

from mapteksdk.project import Project
from mapteksdk.data import Polyline

project = Project()

with project.new("cad/square", Polyline, overwrite=True) as new_line:
    new_line.points = [[-1, 0, 0], [0, 1, 0], [1, 0, 0], [0, -1, 0]]

Filtering types for Project.read() and Project.edit()

It is possible to pass the expected type of the object to Project.read() and Project.edit(). This causes the Project class to raise a TypeMismatchError if the object is not of the specified type.

from mapteksdk.project import Project
from mapteksdk.data import Surface

if __name__ == "__main__":
    path_to_surface = "path/to/surface"
    with Project() as project:
        with project.read("path/to/surface", Surface) as surface:
            # surface is known to be a Surface in this with block.
            pass

        # This works for both read and edit.
        with project.edit(path_to_surface, Surface) as edit_surface:
            pass

Because the object type is known in the with block, this allows interactive development environments to provide better autocomplete suggestions than would be possible in a with block with no type specified. For example, in the following animation Visual Studio Code provides autocomplete suggestions for the surface having points and facets, but not for blocks.

Specifying the type is useful if the following are true:

  • The script should fail quickly with an error if the object to open is not of the expected type. The type mismatch error makes it very clear that the problem is the object is the wrong type.

    Example:  Consider a script that performs a lengthy calculation on the points of a Surface and then another lengthy calculation on the facets of a surface. If such a script was run on a PointSet, then it wouldn’t fail until the second lengthy calculation, wasting time and computation power. By checking that the object is a surface at the beginning, this waste can be avoided.

  • The script knows that the object must be of a specified type.

Note
  • If the object is not of the specified type, then it will raise a TypeMismatchError. This error can be caught to trigger different behaviour if the object is of the wrong type.

  • Specifying a type should cause autocomplete suggestions for that type to appear in the with block.

  • When opening an object multiple times, it is more robust to pass the ObjectID rather than a path to Project.edit() and Project.read().

  • If you pass an ObjectID to Project.read() or Project.edit(), an interactive development environment will remember what type of object the ObjectID came from and provide autocomplete suggestions based on that type.

  • Unlike passing the type to Project.read() or Project.edit(), this does not provide any runtime checks that the object is actually of that type (And it could be of a different type).

  • Typically this means the first call to Project.edit() or Project.read() for an object should specify the type (Unless the type is already known from Project.new()). Later calls should use the ObjectID.

Recycle bin

Since 1.5

The Project.recycle() function on the project will move the given object to the recycle bin. The function accounts for if there is already an object of the same name in the recycle bin and will add a numerical suffix.

This could be used as an alternative when making destructive changes to an object when the user may still want a way to access the original. You could create a copy of a user’s object before making a change and either leave it in the same container or recycle the copy so they have the copy to fall back to.

Care should be taken when trying to recycle objects from the selection, because a selection can contain a container and its children. If you try to recycle each individual object it will move each object to the recycle bin but it will lose the hierarchy (children won’t be within their container in the recycle bin). This is where querying the roots of a selection has great value.

from mapteksdk.project import Project
    project = Project()
    selection = project.get_selected()
    for object_to_recycle in selection.roots:
        project.recycle(object_to_recycle)

Other operations on Projects

For information on other operations available in the package, see mapteksdk.project in the API reference.

Overwrite parameter

Overwriting objects

One common problem scripts face when creating new objects is what to do when there is already an object at the destination path. For example, consider the following script which creates a tetrahedron at surfaces/tetrahedron:

from mapteksdk.project import Project, OverwriteMode
from mapteksdk.data import Surface, ObjectID
from mapteksdk.operations import open_new_view


def create_tetrahedron_with_undo(project: Project, path: str) -> ObjectID[Surface]:
    """Create a tetrahedron which can be undone.


    Parameters
    ----------
    project
      Project to create the tetrahedron surface in.
    path
      The path to create the tetrahedron at. If there is already an object
      with this path the path will be postfixed with the smallest possible
      number to ensure that an existing object is not overwritten.
    """
    with project.undo():
        with project.new(path, Surface) as tetrahedron:
            size = 3
            tetrahedron.points = [
                [-size, -size, -size], [-size, size, size],
                [size, -size, size], [size, size, -size]]
            tetrahedron.facets = [[0, 1, 2], [0, 2, 3], [0, 1, 3], [1, 2, 3]]
    return tetrahedron.id


if __name__ == "__main__":
    main_project = Project()
    oid = create_tetrahedron_with_undo(main_project, "surfaces/tetrahedron")
    open_new_view([oid])

Now when this script is run multiple times, it will postfix the name with a number to ensure that the name of each tetrahedron has a unique:

A warning on re-opening

If you set overwrite to OverwriteMode.UNIQUE_NAME and you need to re-open the object within the same script, then you must not re-open the object using the path. Consider the following script that demonstrates the issue:

from mapteksdk.project import Project, OverwriteMode
from mapteksdk.data import Surface, ObjectID
from mapteksdk.operations import open_new_view


def create_tetrahedron_with_undo(project: Project, path: str) -> ObjectID[Surface]:
    """Create a tetrahedron which can be undone.


    Parameters
    ----------
    project
      Project to create the tetrahedron surface in.
    path
      The path to create the tetrahedron at. If there is already an object
      with this path the path will be postfixed with the smallest possible
      number to ensure that an existing object is not overwritten.
    """
    with project.undo():
        with project.new(path, Surface, overwrite=OverwriteMode.UNIQUE_NAME
                ) as tetrahedron:
            size = 3
            tetrahedron.points = [
                [-size, -size, -size], [-size, size, size],
                [size, -size, size], [size, size, -size]]
            tetrahedron.facets = [[0, 1, 2], [0, 2, 3], [0, 1, 3], [1, 2, 3]]


        with project.edit(path, Surface) as edit_tetrahedron:
            edit_tetrahedron.facet_colours = [220, 165, 0, 255]
    return tetrahedron.id


if __name__ == "__main__":
    main_project = Project()
    oid = create_tetrahedron_with_undo(main_project, "surfaces/orange_
tetrahedron")
    open_new_view([oid])

This script is the same as the previous script, except it re-opens the tetrahredron and colours it orange. If you run the above script twice then the first tetrahedron will look like this:

and the second will look like this:

You will notice that the script failed to colour the second tetrahedron orange and left it default-green. This behaviour is obvious if you follow both runs of the script carefully:

  1. The first run of the script creates the first tetrahedron and places it at surfaces/tetrahedron.

  2. The first run of the script then opens the object at surfaces/tetrahedron and colours it orange.

  3. The second run of the script creates the second tetrahedron and places it at surfaces/tetrahedron 1

  4. The first run of the script then opens the object at surfaces/tetrahedron and colours it orange.

Postfixing the name with a number to make it unique changed the path, so opening the object with the path given to the function opened the object made by the first run of the script rather than the second. To correct this issue, the object must be opened for edit with its ObjectID:

with project.edit(tetrahedron.id, Surface) as edit_tetrahedron:
    edit_tetrahedron.facet_colours = [220, 165, 0, 255]

Other functions that support overwrite

The Project.new() function is not the only function that supports the overwrite parameter. Many other functions of the Project class support it. This includes:

The parameter works the same for all functions, as detailed in the table below:

overwrite value Behaviour
False Raise an error if an object would be overwritten.

OverwriteMode.ERROR

True Silently overwrite existing objects.

OverwriteMode.OVERWRITE

OverwriteMode.UNIQUE_NAME

If there is already an object at the destination path, postfix the name with the smallest possible unique number to ensure that the name is unique.

Compatibility Note

The OverwriteMode enum was added in mapteksdk 1.6. Earlier versions only support setting the overwrite parameter to True or False.

Object IDs vs paths

The above examples refer to objects in the Project via their object paths. However always referring to objects via a path can be problematic because it will fail if an object is renamed. To counteract this, most functions that accept paths also accept an ObjectID. An ObjectID uniquely identifies a single object in a project. Unlike a path, an object’s ID never changes. If a script passes an ObjectID instead of an object path then it will always open the expected object even if that object is renamed or moved. In the example below, we demonstrate a script referring to an object that has been renamed with its ObjectID.

from mapteksdk.project import Project
from mapteksdk.data import Polyline

project = Project()

with project.new("cad/square", Polyline, overwrite=True) as new_line:
    new_line.points = [[-1, 0, 0], [0, 1, 0], [1, 0, 0], [0, -1, 0]]

Whether you should pass a path or an ObjectID to a function depends on context. Here are a few factors to keep in mind when deciding which to use:

  • Project.new() does not support taking an ObjectID and must always be passed a path.

  • An ObjectID only uniquely refers to an object within the context of a single Maptek project database (.maptekdb). They should never be hard-coded into a script or saved to a file.

  • If an object will be opened multiple times in a script, it is generally best to create or read the object the first time using its path then use the ObjectID for all future accesses.

  • Project.get_selected() returns a list of object IDs so there is no need to use the path for selected objects.

In the example below, we show how a path can be converted into an ObjectID without using Project.edit() or Project.read(). Additionally, the example shows how the path to an object can be extracted from the ObjectID.

from mapteksdk.project import Project

project = Project()

object_id = project.find_object("cad/points")

if object_id:
  print(f"The object id refers to the object at {object_id.path}")

An invalid ObjectID will evaluate to false. We can use this to check for errors.

Advanced: Selecting which application to connect to

The default Project constructor always connects to the most recently opened application. Support for choosing which application a Python script should connect to is still a work in progress. The only method for performing this is Project.find_running_applications(), which returns a list of running Maptek applications. These can be passed to the existing_mcpd argument of the Project constructor to ensure the script will connect to the specified application. In the example below, we show a simple script that requests the user to select which running application to connect to (If there is more than one application running) and then prints every object outside a container in the application.

"""Script which lists all running applications and asks the user to
select one of them. The script will connect to the application and
print all of the top-level objects contained in it.

If only one application is running, it will skip the application
selection step and connect to the application.

"""

from mapteksdk.project import Project

# Get a list of running applications.
applications = Project.find_running_applications()

for i, application in enumerate(applications):
  print(f"{i} - {application.bin_path}")

if len(applications) == 1:
  print("Only one application running - automatically connecting")
  index = 0
else:
  index = int(input("Which application do you want to connect to?\n"))

try:
  instance = applications[index]
except IndexError as error:
  raise IndexError(f"No application with index: {index}") from error

project = Project(existing_mcpd=instance)

for name, oid in project.get_children():
  print(name, oid)