To install, set up a conda environment with one of the environment.yml files (currently only the windows one is fully up-to-date.)
Then install with pip install -e .
To get started writing programs, import AIDL in debug mode:
from AIDL import *
The primary code files to look at are:
- structure.py : Defines the Structure class and its subclasses
- geometry.py : Defines the basic primitive geometry types
- constraints.py : Defines the geometric and structural constraints
- expression.py : Defines numeric and logical expressions for constraints
Other files that may be of interest are:
- library.py : Defines additional compound geometry types
- solver.py : A mostly 1:1 wrapper of the solvespace python bindings with some additional tricks
AI-DL is implemented with a two tier architecture; the high-level DSL, and the low-lovel solver. The high level DSL is responsible for defining the hierarchical model structure and orchestrating hierarchical constraint solving, while the low-level solver models and solves individual constraint problems within that hierarchy.
The lowel-level solver is based on SolveSpace, an open-source geometric constraint solver. We are using the python_solvespace wrapper for this library, which presents a basic interface for defining geometry and constraints, partitioned into groups which are solved separately (constraints and geometry can refer to geometry outside of their group; a group defines an addressable space of constraints to mantain and variables to allow to be free for a given call to the solver).
Our low-level solver wraps the python_solvespace solver to provide 3 primary capabilities:
- Semantic Naming for feedback: All entities and contraints can now have string-typed names to aid in generating feedback.
- Rendering Support: Our wrapped solver systems can be rendered with a pluggable rendering system. We currently support matplotlib drawing for 2d and 3d systems and OpenCascade support for STEP output and rendering. The rendering system also augments entities with a rendering type that is either SKETCH, DRAW, CONSTRUCTION, or INTERNAL, which controls how solved entities are interpretted and interact in the rendering phase.
Solving a low-level system is done as a fixed-point iteration between solving the soft and hard systems, terminating whenever all constraints are satisfied, or the system diverges or times out. The wrapped constraint solver behaves like a state machine; in order to facilitate easier low-level programming, we implemented syntactic sugar using Python's "with" statement to allow solver groups and geometry types to be switched between as scopes:
with system.push_group():
# create geometry
# create geometry
# create constraints
system.solve() # geometry in indented code is treated as fixed during the solveThe high-level DSL represents CAD models as a tree of Structures, which conceptually represent structural units within a complex object. Concretely, each Structure corresponds to one coordinate frame in which associated Geometry, and more fine-grained sub-Structures can be defined, as well as Geometric and Compositional constraints relating sub-Structures and Geometry. Structures come in three types, ASSEMBLY, SOLID, and HOLE. Assembly nodes cannot contain any constructible geometry (but can contain CONSTRUCTION geometry for positioning other Geometry), and any substructures within them are treated as separate parts in the final model. This is in contrast to SOLIDs, which are always unioned with any child SOLIDs. HOLEs behave similarly, but they are used for boolean subtraction instead. In this way, non-planar 3D objects can be constructed from 2D planar sketches. AI DL thus supports sketch-and-extrude modeling with boolean operations.
Geometry nodes are attached to Structures, and form DAGs terminating in the basic Geometry type Point, which represents a 2D point embedded in the containing Structure's x,y-plane, called the "sketchplane." In order to stay well-defined and within the same plane, each Geometry DAG can only be connected to one Structure. In addition to Point, there are 3 other basic Geometry types: Line, Arc, and Circle, which represent line segments, arcs, and circles in the workplane. All structures also implicitly contain 2 pieces of 3D geometry, a Point3D for their origin, and a "Normal" quaternion that encodes the frame's rotation. Only these 6 Geometry types (Point, Line, Arc, Circle, Point3D, Normal) have meaning to the solver, but additional composite types can be defined that combine and relate the basic 2D Geometry. AI DL comes with a Library of common composite Geometry, like boxes, triangles, rounded rectangles, and reflections. Composite Geometry can contain constraints (e.g. the sides of a box are parallel and perpendicular).
The final type of node, already mention, are Constraints. Constraints represent relationships between Structures or Geometry. They come in many types, (equal, coincident, above, symmetric, etc.) but have in common that they each reference one or more Structure or Geometry. Each constraint belongs to one Structure (either directly or transitively through the Geometry it is attached to). Constraints are only allowed to refer to nodes in the subtree of their parent Structure. This allows for subtrees to be solved as separate optimization problems; essentially separate sub-programs.
Constraints come in two flavors; Geometric, equality relations between Geometry -- hese are natively supported by SolveSpace -- and Compositional, which relate Structures and can include inequality constraints, requiring them to be solved by a different method. Compositional constraints are defined based on the axis-aligned bounding boxes between geometry, as realized in the frame containing the constraint; e.g. substructure B is left of substructure C in the frame of structure A.
AIDL implements this model structure using Python objects that are instances of the three node type classes: Structure, Geometry, and Constraint. Child nodes are added by assigning the child node as a named attribute of the parent. This forces every level of structure, piece of geometry, and constraint to have a well defined semantic name (using Python's dot '.' conventies, e.g. structure.geometry.constraint.). In order to facilitate a more hierarchical structure when coding in AIDL, we added some sugaring to AIDL.
-
Similar to the low-level solver language, we added a "with" statement systax that mantains a stack of scopes. Child nodes created within these frames are automatically added to the correct scope. This is particularly helpful for defining constraints because semantic naming of constraints can be unwieldy; so automatically collecting defined constraints into the current stack is very handy. The same scoping stack is also hooked into by a metaclass of Structures, so that the init method of a Structure subclass puts that instance in scope until after initialization.
Finally, if nodes are constructed without any scope, they get added to an implicit enclosing ASSEMBLY (unless they are already children of another subtree). This turns Structure forests into Structure trees for joint solving.
-
To enable substructures to be defined and related before their substructures are defined, Structures without geometry or children with geometry are assigned an unconstrained bounding box. This allows the solver to check if there can possible exist a valid solution given the Compositional constraints (e.g. do any of them conflict) prior to the completion of the program.
To further ease this sort of iterative coding, we also override sub-Structure assignment to act as a merge between Structures, with the latest assignment's conflicts taking priority. This allows for substructure contents to be defined as a separate function or class, and simply be re-assigned on top of a previously constructed Structure without breaking reference structures from other parts in the program. Doing so is beneficial to coding with generative AI because CAD models in AI DL can be modified by appending code rather than trying to modify already written code.
-
There are tons of different conventions in existing CAD packages for defining basic geometry, which can be very confusing for an LLM. In AIDL, we support many alternative calling conventions in both the basic Geometry and composite Geometry via type-based dynamic dispatch in constructors. This achieves two goals; allowing for varied calling conventions (e.g. Line(p1, p2) vs Line((x1,y1), (x2,y2))), as well as automatic primitive sharing (Line(p1,p2) and Line(p1,p3) will always meet at p1 since it is a sared reference).
-
We also consider Nodes stored within collections to be children, and name them according to the Python access convention to get to them. So a Geometry in a dict of lists of Geometries could be called
descriptive_dict_name['descriptive_key'][5]. This is especially handy, for example, with list comprehensions.
Here is an example incorporating several of these patterns. In normal use one convention should be used per-program for consistency:
from AIDL import *
# First Code Generation
class Sundial(Structure):
def __init__(self):
self.gnomon = Structure(RightPlane) # Defines an initialization plane other than the default
base = Structure() # Using scoping hook to implicitly assign as self.base
Ortogonal(self.gnomon, base) # Using scoping hook to implicity assign to self as 'orthogonal(gnomon, base)'
sundial = Sundial()
# Program compiles and runs successfully
# Second Code Generation
with sundial.base:
dial = Circle((0,0),1)
markings = Structure()
with markings:
geo_type = GeometryType.DRAWING # Use scope hook to modify the geo_type of the structure, which will be inherited by all geometry
angles = [-pi + pi / 12 for i in range(12)]
lines = [Line((0,0),(cos(t), sin(t))) for t in angles] # Deep scope magic (IDK if I can actually get this to work reliably)
# Program compiles and runs successfully
# Third Code Generation
class Gnomon(Structure):
def __init__(self):
self.outline = RightTriangle((0,0),1,0.5)
with sundial: # Re-enter the sundial scope after it was created to make modifications
gnomon = Gnomon() # merges with the previously empty sundial.gnomon to add geometry and constraints
Coincident(gnomon.base, base.markings.lines[6]) # sundial-scoped constraintNote: Currently the "with" syntax for constraints and the entity merging are known to work, which is the bare minimum needed to get the bulk of this behavior; we could get the "local" scoping back (and it could even be more Pythonic), if make the with statements actually return the object so we could have statements like
with sundial as self:
self.gnomon = Gnomon()
Coincident(self.gnomon.base, self.base.markings.lines[6])There is a bit of a danger here if it is used inside of an init method since the "as" statement is not scoped in Python,
so the self variable will not return to what it was beforehand. We could perhaps try and get around this with some very
tricky hacking by using introspection to see if the variable referred to by as is being re-assigned, then creating a
proxy object to return that resets to pointing at the original "self" in the __exit__ of the with block.
Making the "selfless" wih statements work is pretty simple with simple assignments of a single object, since we can use code introspection to parse out the assignment operator, but for nested statements, loop comprehensions, or assignment to collections, we would need to somehow get a hook into the collection itself. This may be possible by patching and wrapping the built-in collection classes to detect if they are in a with block and if so re-assign. The other, perhaps simpler way of doing this is to check all identifiers at the beginning and end of a with block, and any new ones get added as children with appropriate names at the end of the block. Nested withs would need to be taken into account if that were to work.