Wrapping SpaceClaim as a Tesseract

This guide outlines how to wrap Ansys SpaceClaim as a Tesseract. For this, we will use non-containerized execution to start an HTTP server that dispatches requests to SpaceClaim through SpaceClaim scripts (.scscript).

See also

The full code for this Tesseract can be found under demo/_showcase/ansys-shapeopt/spaceclaim in the Tesseract Core repository.

The Tesseract can be seen in action within our rocket fin optimization showcase.

Why SpaceClaim as a Tesseract?

Complex CAD models imported from parametric CAD software often require pre-processing before they can be fed into a simulator, such as extracting a fluid volume or naming domain faces such that appropriate boundary conditions can be applied.

SpaceClaim is commonly used to generate parametric geometries and perform pre-processing actions on them. In this example we demonstrate the use of SpaceClaim as a geometry engine within Tesseract-driven processing pipelines. This unlocks powerful applications operating on real-world CAD geometries.

../../../_images/spaceclaim_tesseract_workflow.png

Architecture of the SpaceClaim Tesseract implemented here.

Core concepts

Folder structure

When creating a new Tesseract you should have a directory with three files like so:

$ tesseract init --name spaceclaim --target-dir ./spaceclaim
$ tree ./spaceclaim
spaceclaim
├── tesseract_api.py
├── tesseract_config.yaml
└── tesseract_requirements.txt

See also

If this doesn’t look familiar you can learn more about Tesseract basics here.

To wrap SpaceClaim as a Tesseract, we will have to implement each of these files:

  • tesseract_api.py contains all logic on accepted inputs / outputs and dispatches calls to SpaceClaim.

  • tesseract_config.yaml is not relevant here, since we will be invoking the Tesseract without containerization (see below).

  • tesseract_requirements.txt contains additional Python requirements to run the Tesseract.

Non-containerized usage via tesseract-runtime

Note

Tesseract without containerization — Tesseracts are most commonly used in the form of Docker containers. This is not a neccessary requirement, and any object that adheres to the Tesseract interface is a valid Tesseract (see also Tesseracts without containerization).

SpaceClaim is typically running directly on a Windows host machine, so instead of using Docker to build an image and spin up a container, we leverage the Tesseract runtime CLI to serve the SpaceClaim Tesseract on bare metal and expose SpaceClaim’s functionality over HTTP.

So instead of using the more common tesseract build, we install and use the tesseract-runtime CLI application which will provide us an interface with the Tesseract:

$ pip install tesseract-core[runtime]

Warning

Windows is officially only supported via Windows Subsystem for Linux (WSL), see Windows support. Make sure to use an appropriate WSL setup when running into issues.

Now with an a open port of your choice, and from within the Tesseract directory, we can execute:

$ pip install ./tesseract-requirements.txt
$ tesseract-runtime serve --host 0.0.0.0 --port port_number

The result is a Tesseract Runtime Server.

$ tesseract-runtime serve --host 0.0.0.0 --port 443
INFO:     Started server process [14888]
INFO:     Waiting for application startup.
INFO:     Application startup complete.
INFO:     Uvicorn running on http://0.0.0.0:443 (Press CTRL+C to quit)

Implementing the Tesseract

tesseract_requirements.txt

Besides a SpaceClaim installation and a recent Python version, this Tesseract only requires one dependency, the Python package trimesh. We add it to tesseract_requirements.txt:

trimesh==4.9.0

Since we are running the Tesseract in a non-containerized way, all requirements need to be installed into the current Python interpreter:

$ pip install -r tesseract_requirements.txt

tesseract_api.py — Input and output schema

When using SpaceClaim as a geometry engine, the goal is typically to map design parameters in the parametric CAD model to a surface mesh. Here, we are creating a SpaceClaim Tesseract that operates on a grid fin geometry with a number of parameters representing the position of bars and their thickness.

Note

This particular choice of inputs and outputs is motivated in our rocket fin optimization showcase.

Input schema

This Tesseract accepts multiple goemetry parameters to create N grid fin geometries simulatanously. That way, we hide the startup latency of SpaceClaim when requesting a large number of geometries.

The InputSchema class looks like this:

class InputSchema(BaseModel):
    """Input schema for bar geometry design and SDF generation."""

    differentiable_parameters: list[
        Array[
            (None,),
            Float32,
        ]
    ] = Field(
        description=(
            "Angular positions around the unit circle for the bar geometry. "
            "The shape is (num_bars+1, 2), where num_bars is the number of bars "
            "and the second dimension has the start then end location of each bar."
            "The first (+1) entry represents the two z height coordinates for the cutting plane which combine with "
            "a third fixed coordinate centered on the grid with z = grid_height / 2"
        )
    )

    non_differentiable_parameters: list[
        Array[
            (None,),
            Float32,
        ]
    ] = Field(
        description=(
            "Flattened array of non-differentiable geometry parameters. "
            "The shape is (2), the first float is the maximum height (mm) of the "
            "grid (pre z-plane cutting). The second is the beam thickness (mm)."
        )
    )

    static_parameters: list[list[int]] = Field(
        description=("List of integers used to construct the geometry."),
        default=[],
    )

    string_parameters: list[str] = Field(
        description=(
            "Two string parameters for geometry construction. "
            "First str is Path to SpaceClaim executable. "
            "Second str is Path to SpaceClaim Script (.scscript)."
        )
    )

Output schema

The output of the Tesseract is a list of TriangularMesh objects representing the N grid fin meshes:

class OutputSchema(BaseModel):
    """Output schema for generated geometry."""

    meshes: list[TriangularMesh] = Field(
        description="Triangular meshes representing the geometries"
    )
class TriangularMesh(BaseModel):
    """Triangular mesh representation with fixed-size arrays."""

    points: Array[(None, 3), Float32] = Field(description="Array of vertex positions.")
    faces: Array[(None, 3), Float32] = Field(
        description="Array of triangular faces defined by indices into the points array."
    )

tesseract_api.pyapply

The apply function that we are invoking with the above command builds each of the grid fin geometries and extracts the mesh data from the trimesh objects.

def apply(inputs: InputSchema) -> OutputSchema:
    """Create a SpaceClaim geometry based on input parameters.

    Returns TraingularMesh objects.
    """
    trimeshes = build_geometries(
        differentiable_parameters=inputs.differentiable_parameters,
        non_differentiable_parameters=inputs.non_differentiable_parameters,
        static_parameters=inputs.static_parameters,
        string_parameters=inputs.string_parameters,
    )

    return OutputSchema(
        meshes=[
            TriangularMesh(
                points=mesh.vertices.astype(np.float32),
                faces=mesh.faces.astype(np.int32),
            )
            for mesh in trimeshes
        ]
    )

To build the geometries we first prepare the SpaceClaim .scscript by replacing placeholder values with the user inputs via string substitution. SpaceClaim is then run, outputting .stl meshes that are read with trimesh.

def build_geometries(
    differentiable_parameters: list[np.ndarray],
    non_differentiable_parameters: list[np.ndarray],
    static_parameters: list[list[int]],
    string_parameters: list[str],
) -> list[trimesh.Trimesh]:
    """Build SpaceClaim geometries from the parameters by modifying template .scscript.

    Returns a list of trimesh objects.
    """
    spaceclaim_exe = Path(string_parameters[0])
    spaceclaim_script = Path(string_parameters[1])

    with TemporaryDirectory() as temp_dir:
        prepped_script_path = _prep_scscript(
            temp_dir,
            spaceclaim_script,
            differentiable_parameters,
            non_differentiable_parameters,
        )
        run_spaceclaim(spaceclaim_exe, prepped_script_path)

        meshes = []
        for output_stl in sorted(Path(temp_dir).glob("*.stl")):
            mesh = trimesh.load(output_stl)
            meshes.append(mesh)

    return meshes

The .scscript preperation is unique to this grid fin example, with the user input values being processed into dictionaries that are then used within the string substitution. For a different geometry one would have to create their own .scscript and dictionaries with all the neccessary inputs required.

def _prep_scscript(
    temp_dir: TemporaryDirectory,
    spaceclaim_script: Path,
    differentiable_parameters: list[np.ndarray],
    non_differentiable_parameters: list[np.ndarray],
) -> list[str]:
    """Take Tesseract inputs and place into a temp .scscript that will be used to run SpaceClaim.

    Return the Path location of this script and the output .stl.
    """
    # Define output file name and location
    output_file = os.path.join(
        temp_dir, "grid_fin"
    )  # .stl ending is included in .scscript
    prepped_script_path = os.path.join(temp_dir, os.path.basename(spaceclaim_script))
    shutil.copy(spaceclaim_script, prepped_script_path)

    # Define dict used to input params to .scscript
    # Converts np.float32 to python floats so string substitution is clean
    keyvalues = {}
    keyvalues["__output__"] = output_file
    keyvalues["__params__.zeds"] = [
        [float(geom_params[0]), float(geom_params[1])]
        for geom_params in differentiable_parameters
    ]
    keyvalues["__params__.height"] = non_differentiable_parameters[0][0]
    keyvalues["__params__.thickness"] = non_differentiable_parameters[0][1]

    num_of_batches = len(differentiable_parameters)  # number of geometries requested
    num_of_bars = (
        len(differentiable_parameters[0]) - 2
    ) // 2  # Use first geometry in batch to test number of beams

    assert num_of_bars == 8

    batch_starts = []
    batch_ends = []
    for i in range(num_of_batches):
        geom_starts = []
        geom_ends = []
        for j in range(num_of_bars):
            geom_starts.append(float(differentiable_parameters[i][j * 2 + 2]))
            geom_ends.append(float(differentiable_parameters[i][j * 2 + 3]))

        batch_starts.append(geom_starts)
        batch_ends.append(geom_ends)

    keyvalues["__params__.starts"] = (
        batch_starts  # convert to string to ease injection into file text
    )
    keyvalues["__params__.ends"] = batch_ends

    _find_and_replace_keys_in_archive(prepped_script_path, keyvalues)

    return prepped_script_path
def _find_and_replace_keys_in_archive(file: Path, keyvalues: dict) -> None:
    # work on the zip in a temporary directory
    with TemporaryDirectory() as temp_dir:
        # extract zip
        with zipfile.ZipFile(file, "r") as zip_ref:
            zip_ref.extractall(temp_dir)

        # walk through the extracted files/folders
        for foldername, _subfolders, filenames in os.walk(temp_dir):
            for filename in filenames:
                # read in file
                filepath = os.path.join(foldername, filename)
                try:
                    with open(filepath) as f:
                        filedata = f.read()
                except Exception:
                    filedata = None

                # find/replace
                if filedata:
                    for key, value in keyvalues.items():
                        if value is not None:
                            filedata = _safereplace(filedata, key, value)

                    # write to file
                    with open(filepath, "w") as f:
                        f.write(filedata)

        # write out all files back to zip
        with zipfile.ZipFile(file, "w") as zip_ref:
            for foldername, _subfolders, filenames in os.walk(temp_dir):
                for filename in filenames:
                    filepath = os.path.join(foldername, filename)
                    zip_ref.write(
                        filepath,
                        arcname=os.path.relpath(filepath, start=temp_dir),
                    )
def _safereplace(filedata: str, key: str, value: str) -> str:
    # ensure double backspace in windows path
    if isinstance(value, WindowsPath):
        value = str(value)
        value = value.replace("\\", "\\\\")
    else:
        value = str(value)
    return filedata.replace(key, value)

Once the .scscript is ready the final step is to run SpaceClaim. Here it is easy to see how this process could be extended to any software that is running on the host machine. For example Ansys Fluent could also be wrapped in a runtime Tesseract, potentially using an adjoint solver to produce gradient information, allowing the Tesseract to be differentiable.

def run_spaceclaim(spaceclaim_exe: Path, spaceclaim_script: Path) -> None:
    """Runs SpaceClaim subprocess with .exe and script Path locations.

    Returns the subprocess return code.
    """
    env = os.environ.copy()
    cmd = str(
        f'"{spaceclaim_exe}" /UseLicenseMode=True /Welcome=False /Splash=False '
        + f'/RunScript="{spaceclaim_script}" /ExitAfterScript=True /Headless=True'
    )

    result = subprocess.run(
        cmd,
        shell=True,
        check=False,
        capture_output=True,
        text=True,
        env=env,
    )

    return result.returncode

Invoking the Tesseract

Now that we have defined the Tesseract we can use it. From within the Tesseract’s root directory, start the runtime server with a port of your choice:

$ tesseract-runtime serve --host 0.0.0.0 --port 443

We can now test the Tesseract manually by sending an HTTP request for two grid fin geometries either from the same computer, as shown here, or via the network. Make sure to change the URL IP and port to reflect your setup, along with the SpaceClaim.exe path:

# Bash
$ curl -d '{
  "inputs": {
    "differentiable_parameters": [
    [200, 600, 0, 3.14, 0.39, 3.53, 0.79, 3.93, 1.18, 4.32, 1.57, 4.71, 1.96, 5.11, 2.36, 5.50, 2.75, 5.89],
    [400, 400, 0, 3.14, 0.39, 3.53, 0.79, 3.93, 1.18, 4.32, 1.57, 4.71, 1.96, 5.11, 2.36, 5.50, 2.75, 5.89]
    ],
    "non_differentiable_parameters": [
      [800, 100],
      [800, 100]
    ],
    "string_parameters": [
      "F:\\Ansys installations\\ANSYS Inc\\v241\\scdm\\SpaceClaim.exe",
      "geometry_generation.scscript"
    ]
  }
}' \
-H "Content-Type: application/json" \
http://127.0.0.1:443/apply

Or:

# Windows PowerShell
curl -Method POST `
     -Uri "http://127.0.0.1:443/apply" `
     -ContentType "application/json" `
     -Body '{"inputs":{"differentiable_parameters":[[200,600,0,3.14,0.39,3.53,0.79,3.93,1.18,4.32,1.57,4.71,1.96,5.11,2.36,5.50,2.75,5.89],[400,400,0,3.14,0.39,3.53,0.79,3.93,1.18,4.32,1.57,4.71,1.96,5.11,2.36,5.50,2.75,5.89]],"non_differentiable_parameters":[[800,100],[800,100]],"string_parameters":["F:\\Ansys installations\\ANSYS Inc\\v241\\scdm\\SpaceClaim.exe","geometry_generation.scscript"]}}'

After about ~15 seconds the mesh output is returned and displayed in text form in your terminal. The point coordinates and cells correspond to a grid fin like below (shown with randomised cross beam locations).

../../../_images/grid_fin_stl.png

Grid fin geometry shown with randomised beam locations.

Next steps

Invoking SpaceClaim via HTTP is only the start of the Tesseract journey.

For example, by using finite difference approximations under the hood, we can make the resulting geometry differentiable with respect to the design parameters. For a concrete demonstration of end-to-end shape optimization in action, please have a look at our rocket fin optimization showcase.