Creating Complex 3D Model Graphs with AnchorSCAD

by Gianni Mariani 11-Feb-2024

AnchorSCAD provides an entire workflow for building 3D models. By using Python you get to leverage powerful IDEs including AI tools like github copilot to complement your model building journey. By employing the “class is a shape” paradigm, adding special features to your models becomes simpler, like special anchor functions or other specialised shape specific functionality.

AnchorSCAD empowers you with a complete software development ecosystem. This means you can leverage code reusability, maintain version control, and enjoy the ease of testing and debugging your designs. Imagine building modular components that seamlessly integrate into your grand vision, all within a framework that promotes clean code and efficient development.

There are a number of key components that make the AnchorSCAD ecosystem unique that all work together to minimise code duplication and help you concentrate on building models.

Building a Model Graph

Anchors: Navigating Your AnchorSCAD Model

Anatomy of an Anchor

Core Shape Anchor Functions

Customising Anchors with the @anchor Annotation

Building Model Graphs

Class Relationship Diagram

Table 1: Class Inheritance for Core Classes

Table 2: Instance Functions and Relations

Shape Operator Functions

Table 3: NamedShape Attributes

Step-by-Step Guide

Step 1: Creating a Basic Shape

Step 2: Naming and Modifying Attributes

Step 3: Defining the Frame of Reference Generates a Maker

Step 4: Adding Shapes to the Maker

Understanding Anchors

Creating Complex Model Hierarchies


Introducing datatrees

Field injection and Field binding

Datatree Wraps Dataclass

Help for Class A

Datatree Inject and Bind

Injected Fields

Example of field binding,

Example of overriding the field binding.

Binding Parameters In self default Fields

Injecting Computed Defaults

Injecting Function Parameters

Inheritance with Datatrees

Mapping Injected Names with a Prefix or Suffix


Hole Gauge Example

From: hole_guage

Building a Model Graph

This section introduces shapes, makers, and composite shapes - the fundamental building blocks that form the foundation of your 3D creations within AnchorSCAD.

Foundation Elements:

Shape Modifiers:

Key Points:

Creating a model hierarchy in AnchorSCAD is based on the concepts behind the Shape, NamedShape, Maker and CompositeShape classes and the various shape modifiers, solid(), hole(), cage(), composite(), hull(), minkowski() and intersect(). The positioning of shapes within a Maker uses “anchors”. Each Shape class can define @anchor functions that can be used to define shape specific functions to locate points on the shape.

The AnchorSCAD API is designed to minimise the possibility of producing illegal model graphs. The API also uses chaining where appropriate to help maintain backwards compatibility as more features are added. The example below shows the construction of “Maker”, a node in the model graph using the Cone shape, applying the “solid” operator, setting the colour and material attributes. Finally, the “at()” function provides the transformation that is used to align the graph node with the graph it is being added to.

The following code demonstrates the creation of  Maker objects (which are graph nodes). It constructs a 3D model consisting of a cone with a sphere placed on its top. The sphere has 6 cylindrical holes evenly distributed around its circumference. The colour and material calls are optional and shown to illustrate how node attributes are applied.

from anchorscad import Cone, Material, Sphere, Cylinder

# Create a Cone shape with height (h), base radius (r_base),
# and top radius (r_top) with chaining to set additional
# properties and specify the frame of reference

graph_maker = Cone(h=20, r_base=10, r_top=5) \
'MyCone') \
'blue') \
'PLA')) \

# Create a Sphere the name radius as the top radius of the Cone.
sphere_maker = Sphere(r=5) \
'MySphere') \


# Place 6 holes on the circumference of MyShpere.
cylinder_shape = Cylinder(r=1.2, h=5)
n = 6
for i in range(n):
       cylinder_shape.hole((hole, i)).at(
'MySphere', 'surface', (i / n * 360, 0))

While you can render the OpenSCAD code using print(ad.render(maker).rendered_shape), it’s much easier to use a CompositeShape, see the examples below.

Anchors: Navigating Your AnchorSCAD Model

As the name suggests, AnchorSCAD has a versatile mechanism for defining placement of shapes relative to each other. Quite simply, an anchor resolves to a 4x4 transformation matrix. It is a frame of reference. When adding two shapes, the nominated anchors are aligned and the resulting transformation to perform that alignment is used as the graph.

Perhaps the most important feature of anchors is that they are statically specified and can be supplied as a parameter. This allows for separation of the model building code from the design constraints. Additionally anchors

Below is an example of an anchor. The 'shell' component nominates the name of the shape being selected, this happens to be a Box shape. 'face_edge' is the anchor function on the Box shape and it takes 3 parameters, face, edge, and relative position on the edge which corresponds to 'front', 1, 0.15 finally, an arbitrary post multiplied transformation is applied that translates the anchor up the Z axis by epsilon.

 'shell', 'face_edge', 'front', 1, 0.15, post=tranZ(epsilon)

Anatomy of an Anchor

An anchor consists of several key components:

Core Shape Anchor Functions

Each shape will define anchor functions that are semantically appropriate, for example Box has ‘face_corner’, ‘face_edge’, ‘face_centre’ and ‘centre’ while Cylinder has anchors ‘top’, ‘base’, ‘surface’ and ‘centre’. Each Core shape has examples rendered with anchors displayed. Here is Cone example render showing the ‘top’ and ‘surface’ anchors with various parameters:

By convention, anchors should have the Z axis pointing away from the shape and normal to the surface unless otherwise specified. Note the ‘surface’ parameter ‘tangent’ is used to determine the orientation of the anchor, either it is normal to the cone surface or if ‘tangent’ is false, it aligns the Y axis with the cone’s central axis.

Customising Anchors with the @anchor Annotation

While core shapes offer predefined functions, you can create custom anchor functions for shape classes (including CompositeShape) using the @anchor annotation. This allows you to define specific reference points relevant to your custom shapes.

Below is an example of an anchor defined in the Pipe class. The inner cylinder of a pipe is a hole and by definition, the normal direction is different. Hence we create an “inner_surface” anchor function that flips the direction by rotating the resultant 4x4 matrix by a rotation around the X axis of 180 degrees.

@anchor('inner surface anchor')
def inner_surface(self, *args, **kwds):
'''Inner surface anchor with corrected Z points away from surface.'''
return'inner', 'surface', *args, **kwds) * ad.ROTX_180

This is an “EXAMPLE_ANCHORS” render for the “inner_surface” anchor specified in the basic/ model.


Inherits From


Box, Cone, Cylinder, Sphere, Text


Basic shapes inherit directly from Shape.

Arrow, Coordinates, AnnotatedCoordinates


Specialised composite shapes.



Builder of nodes in the model graph. Shapes added can be solid, hole etc



A shape composed of other shapes.



For applying attributes (colour, material etc) and frame of reference to project to.


Source Class

Target Class


solid(name), hole(name), cage(name), composite(name), intersect(name), hull(name), minkowski(name)



Allows specifying modifiers (colour, material etc) for the subnodes.

build(self) -> Maker



Abstract function build() called on instance initialization returns a Maker representing the Shape.

add_at(maker, anchor, pre, post) -> self



Adds model graph node (Maker) at a specified anchor.

Modifier Function



Indicates the shape is represented as a solid. If the subgraph has any holes, they are applied and the resulting shape collapses into the representative solid.


Designates the shape as a hole, implying that it should be subtracted from other solids added to this node. Any holes within the node are collapsed and the remaining solid (if any) is transformed into a hole.


The shape is not rendered (nor hole or solid) but anchors are available. It’s a way to adopt anchors from a shape but not actually rendering the shape.


The subgraph node shapes are elevated into the current node and holes and solids are preserved. This allows shapes to be composed of “holes” and “solids”. It avoids collapsing holes prematurely. Example is when combining pipes, pipes consist of a “solid” cylinder and a “hole” cylinder. Adding a pipe to a parent node with this modifier ensures that the pipe’s holes remain holes.


Intersects any solid elements within the contained node.


Applies a convex hull operation to the shape or group of shapes within this node.


Applies the Minkowski operator to the group of shapes within this node.






Sets the colour of the shape.



Makes the shape transparent or opaque.

fa(float), fs(float), fn(int)


Maps to openscad $fn, $fa and $fs values.



Disables the shape. Maps to the openscad ‘*’ operator.



If true, only this shape is shown. Maps to the openscad ‘!’ operator.



Enables debugging mode for the shape. Maps to the openscad ‘#’ operator.



Specifies whether to use polyhedrons or LinearExtrude or RotateExtrude operations.



Specifies the material properties of the shape.



Applies the mapping for materials in the subtree.

at(<anchor>, pre, post)



Specifies positioning of the shape relative to an anchor.

Step-by-Step Guide

Step 1: Creating a Basic Shape

Start by creating a basic shape. This is the foundation of your model.

# Creates a box with dimensions 10x20x30.

shape = Box([10, 20, 30])

Step 2: Naming and Modifying Attributes

After creating a shape, you can name it and modify its attributes. Naming is crucial for identifying the shape within the model hierarchy.

# Names the shape and sets its colour.

named_shape = shape.solid("box").colour("red")

Step 3: Defining the Frame of Reference Generates a Maker

The at() function specifies the frame of reference for the shape, determining its position within the model hierarchy.

# Sets the frame of reference for the shape.
maker =

Step 4: Adding Shapes to the Maker

Use the Maker's functions to add other Maker’s to the model. It’s notable that a Maker can only accept a Maker to be added. All Shape objects must have a shape operator applied and given a frame of reference for it to be added as a node in a Maker.
add_at() call aligns the anchor specified in the other_maker and the anchor provided as the second parameter in the add_at() call. This anchor can reference any node currently in the maker’s model graph.

other_maker = Sphere(10).hole('hole').at('centre')

# Adding another shape to the maker at a specific anchor.
"face_centre", 'top')

Understanding Anchors

Anchors define specific points or specifically frames of reference on shapes that serve as a reference for positioning other shapes. Each shape type, like Box or Cylinder, has predefined anchors (e.g., "centre", "face_centre", "top", "base").

When you specify an anchor in the at() or add_at() function, AnchorSCAD resolves the path component of the anchor and calls the corresponding anchor function on the shape, which returns a transformation matrix defining the frame of reference for that anchor.

print(ad.Box((10, 20, 30)).at('centre'))
print(ad.Box((10, 20, 30)).centre())

The above code prints the GMatrix, note they’re identical.

   [1.0, 0.0, 0.0, 5.0],
   [0.0, 1.0, 0.0, 10.0],
   [0.0, 0.0, 1.0, 15.0],
   [0.0, 0.0, 0.0, 1.0]]
   [1.0, 0.0, 0.0, 5.0],
   [0.0, 1.0, 0.0, 10.0],
   [0.0, 0.0, 1.0, 15.0],
   [0.0, 0.0, 0.0, 1.0]]

Creating Complex Model Hierarchies

By nesting Maker instances and utilising anchors, you can create complex model hierarchies. Each Maker can be thought of as a container for shapes and other Makers, allowing for detailed and structured model construction.

Remember, once a Maker is used within another Maker, it is copied, ensuring that mutations to the Maker after its use are not reflected in nodes that have already been constructed. This behaviour facilitates immutable design patterns, making your model hierarchy more predictable and easier to manage.


Building complex hierarchical data objects using dataclasses reduces much of the otherwise needed boilerplate code. This obviously being the point of dataclasses. While using dataclasses to develop AnchorSCAD I found it to be still a very verbose and repetitive and subsequently fragile when building complex 3D models.

Introducing datatrees

datatrees extends (as a wrapper over datatlasses.dataclass) to include:

datatrees can dramatically reduce the overall boilerplate code, however, one still needs to be careful that the datatrees bindings produce the desired outcomes particularly when a multiple classes being injected may cause undesriable name collission.

Field injection and Field binding

Often one desires building dataclass objects containing other dataclass objects from within the __post_init__ function potentially with many instances of the same dataclass type. When creating AnchorSCAD models in particular it may be necessary to build many instances of a dataclass type. For example a 3D model of hole gauge, a plate with multiple holes with different sizes. The height (z) parameter may be shared with all the contained holes while the radius of each individual hole would be specific to that hole. Such a model can be found here (hole_guage).

Datatree Wraps Dataclass

class A:
'''Demonstrates Python dataclasses default value
   functionality. Even though @datatree is used, this
   example only uses the base Python @dataclass

   v1: int
   v2: int=2
   v3: int=field(default=3)
   v4: int=field(default_factory=
lambda: 7 - 3)

Construct with v1 provided..

 -> A(v1=1, v2=2, v3=3, v4=4)

Comparison of two different instances with the same value.

A(v1=2) == A(v1=2)

Help for Class A

class A(builtins.object)
A(v1: int, v2: int = 2, v3: int = 3, v4: int = <factory>, override: anchorscad.datatrees.Overrides = None) -> None
|  Demonstrates Python dataclasses default value
|  functionality. Even though @datatree is used, this
|  example only uses the base Python @dataclass
|  functionality.
| ...

Datatree Inject and Bind

class Anode:
'''Injects fields from class A and provides a
   default value for A.v1. The a_node field becomes a
   callable object that provides an instance of A
   with field values retrieved from self.

   v1: int=55
   a_node: Node=Node(A)  
# Inject field names from A

Injected Fields

Datatree uses the a_node field in class Anode above to inject class A constructor parameters as fields in the containing class (class Anode). Node allows specification of name mappings with mapping dictionaries, prefixes, suffixes or specific exclusion of specific parameters.

In the class Anode example above, the field v1 is specified so the field v1 from class A will not be injected and the default parameters specified in class Anode will remain. However the remaining parameters (v2, v3 and v4) will be injected with the default values specified in class A.

Constructing a default class Anode will demonstrate the injected values, see parameters v2, v3 and v4 below:

 -> Anode(v1=55, a_node=BoundNode(node=Node(clz_or_func=A, use_defaults=
True, suffix='', prefix='', expose_all=True, node_doc=None)), v4=4, v2=2, v3=3)

Note how the a_node field is transformed into a BoundNode. This is a factory for A objects that will pull all constructor parameters from the Anode object that created it. The example below shows how the a_node constructs a  class A object pulling all parameters from the instance containing it.

Example of field binding,

 -> A(v1=55, v2=2, v3=3, v4=4)

The BoundNode factory also allows all the parameters provided in the associated class (or function) from the corresponding Node. In this case, the v3 parameter is overridden with the value 33.

Example of overriding the field binding.

 -> A(v1=55, v2=2, v3=33, v4=4)

Binding Parameters In self default Fields

dataclasses.field provides field attributes default and factory_default parameters, the latter being evaluated at object initialization. datatree.dtfield wraps dataclasses.field providing a self_default parameter that takes a function object with a single parameter. At class initialization, these functions are evaluated with the object instance as its value.

The order of evaluation is the order in which they are declared in the class additionally they are evaluated after all the Node fields have been bound (transformed into BoundNode factories). This allows fields specified with a self_default attribute to use any field specified with Node as long as invoking BoundNodes factories (bindings) do not attempt to access self_default fields that are not yet evaluated.

Injecting Computed Defaults

In the example below, class Bind defines the field v1 as a computed sum v2 + v3.

class Bind:
'''Demonstrates the use of a computed default value.
   Often a value used in nodes should be computed with other parameters
   provided to this instance.'''

   v1: int=dtfield(self_default=
lambda s: s.v2 + s.v3)
   a_node: Node=field(default=Node(A), repr=
False, init=False

The default value of class Bind is Bind(v1=5, v2=2, v4=4, v3=3). Note the value of v1 being the specified sum.

This demonstrates the value of v1 is evaluated with the values of v2 and v2 that are provided in the constructor.

 -> Bind(v1=13, v2=10, v4=4, v3=3)

Injecting Function Parameters

The example below shows that any function (or lambda) can be used as a datatrees.Node. In the example below, the fields lamba_node and func_node use different declaration of essentially the same function but the parameter names from func_a are also injected.

def func_a(x: int=1, y:int=3):
return x + y

class BindFunc:
'''Injected function parameters.'''
   a: int=1
   b: int=1
   lambda_node: Node=Node(
lambda a, b: a + b)
   func_node: Node=Node(func_a)

The lambda_node will bind the values a and b and pass it to the lambda in the in the class declaration.

 -> 2

This demonstrates the default value for x being overridden and the default value for y being used in the call to func_a.

 -> 8

Inheritance with Datatrees

class E:
'''Using the dtfield() function to create a BindingDefault entry.'''
   v1: int=1
   v2: int=2
   v_computed: Node=dtfield(self_default=
lambda s: s.v1 + s.v2)

class F(E, A):
'''Inheritance of datatree classes is allowed, fields are merged.
   in the same way as dataclasses.'''

 -> F(v1=1, v2=2, v3=3, v4=4, v_computed=3)

 -> F(v1=1, v2=2, v3=3, v4=44, v_computed=3)

Mapping Injected Names with a Prefix or Suffix

It is often important to inject the same or similar class with identical field names. datatree.Node provides for prefix= and suffix= specifiers that can be used to map injected parameter names.

class C:
'''Multiple nodes of the same type with parameter name mapping.'''
   a_v1: int=11
   a_node: Node=field(default=Node(A, prefix=
'a_'), repr=False)
   b_v1: int=12
   b_node: Node=field(default=Node(A, prefix=
'b_'), repr=False)
   computed: int=BindingDefault(
lambda s: s.a_v2 + s.b_v2)
def __post_init__(self):
pass # Called after initialization is complete.
def make_stuff(self):
return self.a_node(v2=22), self.b_node(), self.computed

Note the call below to make_stuff() that returns a tuple of the result of invoking a_node() and b_node() from a default class C instance. The fields a_v1 and b_v1 are injected into calls for a_node() and b_node() respectively and hence the difference in the instances of the instances of class A being created.

 -> (A(v1=11, v2=22, v3=3, v4=4), A(v1=12, v2=2, v3=3, v4=4), 4)


As mentioned earlier, the override parameter should only be used for debugging purposes. This uses the datatrees.override function to generate a datatrees.Override instance. The parameters to datatrees.override are simply parameters to override that will replace any provided value including explicit values. Since the override parameter is passed down, it also is able to hierarchically provide override values for contained bound nodes.

class O:
'''Deep tree of injected fields.'''
   c_node: Node=dtfield(Node(C,
'a_v1'), init=False, repr=False

Note that class O has only one injected parameter, a_v1. The default constructor demonstrates the only provided parameter. The default of a_v1 is from class C’s default value for a_v1,

 -> O(a_v1=11)

This demonstrates the node generating an instance of class C.

 -> C(a_v1=11, a_v2=2, a_v4=4, a_v3=3, b_v1=12, b_v2=2, b_v4=4, b_v3=3, computed=4)

This demonstrates using the override parameter with an Override specifier that overrides multiple layers within the class O datatree class. Node how the b_node override specifier for v2=909090 is reflected in the resulting value of A() while b_node is called with a value for v2 that is ignored.

 -> A(v1=12, v2=909090, v3=3, v4=4)

Overriding values in this way can cause code to be fragile as disregarding parameter values with those provided by some unrelated function will inevitably cause unexpected and silent issues to appear which are difficult to diagnose. This is why the override parameter should only be used for debugging purposes and not otherwise used.

Hole Gauge Example

The code below (found in hole_guage) demonstrates how two anchorscad.CompositeShape 3D models can be used where one shape class instance consists of multiple instances of another 3D model and the parameters can be shared. It also demonstrates how self_default bindings can be used in conjunction with BoundNode factories.

To demonstrate the doc field attribute, the following is the pydoc generated for HoleGuage. Note the fields fn, fa and fs are used in AnchorSCAD and it’s desirable to pass these attributes to constructors of contained classes as these are used by the rendering engine to determine the complexity of the polyhedrons approximations of curved surfaces. This is a feature of the anchorscad.ShapeNode subclass of datatrees.Node.

   class HoleGauge(anchorscad.core.CompositeShape)
HoleGauge(hole_rss: Tuple[Tuple[float]], sep: float = 5, fn: int = None, epsilon: float = 0.005, fa: float = None, h: float = 5, fs: float = None, override: anchorscad.datatrees.Overrides = None) -> None
    |  A plate with a matrix of holes of different radii provided in hole_rss.
    |  Args:
    |      hole_rss: Tuple of tuple of hole radii
    |      sep:
None: Margin of separation between holes and edges
    |      fn:
None: None: fixed number of segments. Overrides fa and fs
    |      epsilon:
None: Fudge factor to remove aliasing
    |      fa:
None: None: minimum angle (in degrees) of each segment
    |      h:
None: Depth of plate
    |      fs:
None: None: minimum length of each segment
    |  Other args:
    |      override

From: hole_guage

The code below is a 3D model using AnchorSCAD that generates a plate of holes. The SingleHoleGauge class generates a single row of holes with radii specified by the hole_rs parameter and the thickness of the plate specified by the h parameter. The HoleGauge class is similar but generates a grid of holes specified by a tuple of tuples of hole radii via parameter hole_rss.

HoleGauge generates a number of SingleHoleGauge shapes and stitches them together but also creates an overall plate sized to contain all the SingleHoleGauge shapes in a single ‘assembly’ plate.

import anchorscad as ad
from typing import Tuple

class SingleHoleGauge(ad.CompositeShape):
'''A plate with a line of holes of different radii provided in hole_rs.'''
   hole_rs: Tuple[float]=ad.dtfield(doc=
'Tuple of hole radii')
   h: float=ad.dtfield(5,
'Depth of plate')
   sep: float=ad.dtfield(5,
'Margin of separation between holes and edges')
   x: float=ad.dtfield(doc=
'Width (x) of plate',
lambda s:
                           sum(s.hole_rs) * 2 + (len(s.hole_rs) + 1) * s.sep)
   y: float=ad.dtfield(doc=
'Depth (y) of plate',
lambda s: max(s.hole_rs) * 2 + 2 * s.sep)
   plate_size: Tuple[float]=ad.dtfield(doc=
'The (x, y, z) size of the plate Box shape',
lambda s: (s.x, s.y, s.h - 2 * s.epsilon))
   plate_node: ad.Node=ad.dtfield(ad.ShapeNode(ad.Box, prefix=
'plate_'), init=False)
   hole_node: ad.Node=ad.dtfield(ad.ShapeNode(ad.Cylinder,
'h'), init=False)
   epsilon: float=ad.dtfield(0.005,
'Fudge factor to remove aliasing')
   EXAMPLE_SHAPE_ARGS=ad.args(fn=64, hole_rs=(3, 4, 5, 6, 10))

def build(self) -> ad.Maker:
       maker = self.plate_node().solid(
       offset = self.sep
for i, r in enumerate(self.hole_rs):
           hole = self.hole_node(r=r)
           offset += r
'hole', i))
'base', post=ad.translate((0, -offset, -self.epsilon))),
'face_edge', 'base', 1)
           offset += r + self.sep
return maker

class HoleGauge(ad.CompositeShape):
'''A plate with a matrix of holes of different radii provided in hole_rss.'''
   hole_rss: Tuple[Tuple[float]]=ad.dtfield(doc=
'Tuple of tuple of hole radii')
   single_hole_gauge: ad.Node=ad.dtfield(
'hole_rs', 'x', 'y', 'plate_size')),
   shapes: Tuple[ad.Shape]=ad.dtfield(
'Tuple of shapes placed in the hole gauge plate',
lambda s: tuple(s.single_hole_gauge(hole_rs=rs) for rs in s.hole_rss),
   plate_size: Tuple[float]=ad.dtfield(
'The (x, y, z) size of the plate Box shape',
lambda s:(
for sh in s.shapes),
for sh in s.shapes) - (len(s.shapes) - 1) * s.sep,
               s.h - 2 * s.epsilon),
   plate_node: ad.Node=ad.dtfield(ad.ShapeNode(ad.Box, prefix=
'The plate node factory',
       hole_rss=((3, 4, 5, 6, 8, 10), (16, 14, 12)))
def build(self) -> ad.Maker:
# Create a builder plate the size of the entire assembly.
       maker = self.plate_node().solid(
# Adds all shapes into the assembly plate.
       offset = 0
for i, shape in enumerate(self.shapes):
'inner_plate', i))
'face_edge', 'base', 0, post=ad.tranY(-offset)),
'face_edge', 'base', 0)
           offset += shape.y - self.sep
return maker

True)  # Default to --write

if __name__ == "__main__":