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.
Anchors: Navigating Your AnchorSCAD Model
Customising Anchors with the @anchor Annotation
Table 1: Class Inheritance for Core Classes
Table 2: Instance Functions and Relations
Table 3: NamedShape Attributes
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
Creating Complex Model Hierarchies
Field injection and Field binding
Example of overriding the field binding.
Binding Parameters In self default Fields
Mapping Injected Names with a Prefix or Suffix
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
|
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.
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)
An anchor consists of several key components:
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.
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') |
This is an “EXAMPLE_ANCHORS” render for the “inner_surface” anchor specified in the basic/pipe.py model.
Subclass | Inherits From | Description |
Box, Cone, Cylinder, Sphere, Text | Shape | Basic shapes inherit directly from Shape. |
Arrow, Coordinates, AnnotatedCoordinates | CompositeShape | Specialised composite shapes. |
Maker | Shape | Builder of nodes in the model graph. Shapes added can be solid, hole etc |
CompositeShape | Shape | A shape composed of other shapes. |
NamedShape | - | For applying attributes (colour, material etc) and frame of reference to project to. |
Function | Source Class | Target Class | Description |
solid(name), hole(name), cage(name), composite(name), intersect(name), hull(name), minkowski(name) | Shape | NamedShape | Allows specifying modifiers (colour, material etc) for the subnodes. |
build(self) -> Maker | CompositeShape | Maker | Abstract function build() called on instance initialization returns a Maker representing the Shape. |
add_at(maker, anchor, pre, post) -> self | Maker | Maker | Adds model graph node (Maker) at a specified anchor. |
Modifier Function | Description |
solid(name) | 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. |
hole(name) | 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. |
cage(name) | 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. |
composite(name) | 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. |
intersect(name) | Intersects any solid elements within the contained node. |
hull(name) | Applies a convex hull operation to the shape or group of shapes within this node. |
minkowski(name) | Applies the Minkowski operator to the group of shapes within this node. |
Attribute | Type | Description |
colour(rgba) | RGBA | Sets the colour of the shape. |
transparent(bool) | Boolean | Makes the shape transparent or opaque. |
fa(float), fs(float), fn(int) | Various | Maps to openscad $fn, $fa and $fs values. |
disable(bool) | Boolean | Disables the shape. Maps to the openscad ‘*’ operator. |
show_only(bool) | Boolean | If true, only this shape is shown. Maps to the openscad ‘!’ operator. |
debug(bool) | Boolean | Enables debugging mode for the shape. Maps to the openscad ‘#’ operator. |
use_polyhedrons(bool) | Boolean | Specifies whether to use polyhedrons or LinearExtrude or RotateExtrude operations. |
material(Material) | Material | Specifies the material properties of the shape. |
material_map(MaterialMap) | MaterialMap | Applies the mapping for materials in the subtree. |
at(<anchor>, pre, post) | pre=GMatrix post=GMatrix | Specifies positioning of the shape relative to an anchor. |
Start by creating a basic shape. This is the foundation of your model.
# Creates a box with dimensions 10x20x30. shape = Box([10, 20, 30]) |
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") |
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. |
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.
The 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. |
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')) |
The above code prints the GMatrix, note they’re identical.
[ |
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.
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.
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 |
Construct with v1 provided..
A(v1=1) |
Comparison of two different instances with the same value.
A(v1=2) == A(v1=2) |
class A(builtins.object) |
@datatree |
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() |
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.
Anode().a_node() |
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.
Anode().a_node(v3=33) |
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.
In the example below, class Bind defines the field v1 as a computed sum v2 + v3.
@datatree |
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(v2=10) |
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): |
The lambda_node will bind the values a and b and pass it to the lambda in the in the class declaration.
BindFunc().lambda_node() |
This demonstrates the default value for x being overridden and the default value for y being used in the call to func_a.
BindFunc(x=5).func_node() |
@datatree |
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.
@datatree |
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.
C().make_stuff() |
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.
@datatree |
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() |
This demonstrates the node generating an instance of class C.
O().c_node() |
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.
O( |
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.
The hole_guage.py 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) |
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 |