Spatial Domains: Geometry Construction Inside IPL
1. Motivation
The Invocation Language describes causal relationships — completeness,
fills, invocations — with no inherent notion of space. Some definitions,
however, need to describe where things are: point coordinates,
connectivity between points, and surfaces built from that connectivity.
Rather than inventing a second language for this, MatterScript introduces
the @domain directive so that a definition can bring a small
set of geometry-construction functions into scope, and lets those
functions build up a parallel, loosely linked geometry graph alongside
the definition’s ordinary causal-network AST.
This matters beyond convenience. IPL’s whole premise is describing causal
relationships in the abstract — Fant’s model doesn’t actually care whether
the thing being resolved is a logic signal or a physical coordinate. The
spatial domain is the first proof that the same language, with the same
binding discipline and the same $-reference convention used everywhere
else in IPL, can also describe physical structure. How digital networks
and physical geometry eventually get coupled — a definition whose
resolution depends on both a signal’s value and a point’s position, say
— is still an open question, deliberately not addressed here. But the
direction this points toward is a single MatterScript program describing
both the electronics and the physical structure they live in, rather than
two separate toolchains that have to be kept in sync by hand.
The two ASTs are kept deliberately separate for now:
- The IPL network AST (
network.Definition,network.Statement, …) is unchanged by any of this. No new grammar, no new statement kinds. - The spatial graph (
spatial.SpatialGraph— points, edges, loops, faces) is built by a resolution pass that walks an already-parsed definition and interprets certain fills as geometry calls.
2. Scoping: @domain brings functions into scope
A definition declares its domain once, in its header:
Shape[
()
()
@domain(spatial2d)
...
]
@domain(spatial2d | spatial3d) is existing IPL syntax
(network.parseDomainSpec), previously used only to parametrize
cellular-automata generate blocks. It now does double duty: when a
definition’s domain_spec.kind is spatial2d or spatial3d, that
definition’s resolution body is understood to have a set of
domain-specific geometry functions in scope: point, edge, loop, and
face.
There is no per-statement annotation to sprinkle through a definition’s
body. The domain is declared once, at the top, exactly like any other
@domain usage already in the language.
spatial1d is not a geometry domain — it remains scoped to its
existing cellular-automata usage and has no function registry. Declaring
@domain(spatial1d) and then calling point(...) inside it is treated as
an ordinary (undefined-function) fill, not a geometry binding.
3. Binding: geometry calls are ordinary fills
No new syntax was introduced for defining a geometry entity. This is already-legal IPL:
p0<point(0.0, 0.0)>
This is parsed today as an ordinary Statement.fill:
dest_name: "p0"
expr: "point(0.0, 0.0)"
parsed_expr: Expr{ kind: .call, func: "point", args: [...] }
The IPL parser (expressions.parseILExpr) already recognizes
name(args...) inside <...> and produces a structured .call
expression — this was true before any of the spatial-domain work began.
The resolution pass (§6) is what gives that structure new meaning when
the enclosing definition is domain-scoped to spatial2d/spatial3d and
the call’s function name matches something in the registry; outside that
context, the exact same syntax continues to behave exactly as it always
has.
4. Reference: $name connects to an existing binding
To connect a new geometry call to something defined earlier in the same
definition, use $name — the same sigil IPL already uses everywhere else
to mean “look this up” rather than “declare this” (composed table keys,
generate-block variables, ordinary fill expressions):
p0<point(0.0, 0.0)>
p1<point(1.0, 0.0)>
e0<edge($p0, $p1)>
$p0 and $p1 parse as .variable expression nodes (distinct from the
.constant nodes that plain numeric literals produce — see §7), and the
resolution pass looks them up in the definition’s geometry-binding table
built so far.
This applies uniformly to every kind of geometric element, not just
points — edges are referenced by loop the same way points are
referenced by edge, and loops are referenced by face the same way:
l0<loop($e0, $e1, $e2, $e3)>
f0<face($l0)>
There is no special inline/nested form (face(loop(edge(...), ...)))
even though IPL’s expression grammar could parse one — every element,
at every level, is bound once at the top level and referenced by $name
from then on. This was a deliberate choice: it keeps every geometric
element individually addressable, with a single uniform rule instead of
a special case for elements built “inline.”
5. Binding semantics: define once, reference many times
A name may be bound to a geometry handle exactly once. After that
first binding, it is immutable — later statements may reference it via
$name as many times as needed, but nothing may redefine what it refers
to.
Concretely:
- The first
p0<point(...)>encountered bindsp0to a newspatial.PointIdin the resolution pass’s binding table. - Any later
$p0reference resolves to that same handle. - A second
p0<point(...)>statement — an attempt to rebind an already-bound name — is rejected witherror.DuplicateGeometryBinding, not silently overwritten and not silently ignored.
This mirrors ordinary single-assignment conventions and matches how a geometry graph is naturally authored: declare the entities that matter, then wire connectivity between them by reference, without ever having to worry that a later statement quietly changed what an earlier name meant.
6. Resolution: a single top-to-bottom pass
Geometry binding happens in a dedicated resolution pass
(spatial_domain.resolveSpatialBindings), run over an already-parsed
network.Definition — it does not run during parsing, and it does not
alter the parser’s output. The pass:
- Confirms
def.domain_specisspatial2dorspatial3d(returnserror.UnsupportedSpatialDomainforspatial1d,error.MissingDomainSpecif there’s no domain at all). - Walks
def.resolutionin source order. For each.fillstatement whoseparsed_expris a.callmatching a name in the domain’s function registry (point,edge,loop,face), it dispatches to that function’s handler, which builds the corresponding entity in aspatial.SpatialGraphand returns aGeometryHandle. - Records the handle against the fill’s
dest_namein astd.StringHashMapUnmanaged(GeometryHandle)binding table — this table, together with theSpatialGraph, is the definition’s “geometry AST,” addressed by the same names the IPL source uses. - Fill statements whose call name isn’t in the registry are left untouched, for ordinary fill-resolution logic elsewhere to handle.
Because this is a single forward pass, a name must be bound before it
is referenced. Writing edge($p0, $p1) before either point is defined
is a forward reference and is not currently supported —$p0 would
resolve against an empty binding table and fail with
error.UndefinedGeometryReference. This matches how geometry source is
naturally written (points, then the things that connect them), so in
practice it isn’t a real constraint on authoring — just documented here
since it isn’t enforced by any grammar rule, only by pass order.
7. Argument shapes
Two argument shapes matter for the implemented functions:
- Numeric literals (
point(0.0, 0.0)’s arguments) arrive asExpr{ .kind = .constant, .name = "0.0" }— the raw text, not a pre-parsed number. The resolution pass parses this withstd.fmt.parseFloat(f64, ...). This is a property ofparseILCallArgument, not something specific to the spatial domain — worth knowing if you add a function whose arguments need different types. - Name references (
edge($p0, $p1)’s arguments) arrive asExpr{ .kind = .variable, .name = "p0" }. Resolution looks the name up in the binding table and type-checks that the bound handle is the kind the function expects (e.g.edgerequires two.pointhandles,looprequires.edgehandles,facerequires a single.loophandle).
8. Loops, closure, and a note on shared edges
loop($e0, $e1, ...) builds an ordered boundary from previously-bound
edges, and validates that they actually close: each edge’s endpoint must
match the next edge’s start point, and the last edge must return to the
first edge’s starting point. A loop that doesn’t close raises
error.DiscontinuousLoop or error.UnclosedLoop; fewer than 3 edges
raises error.DegenerateLoop.
This closure check only cares about a single loop’s own continuity — it has no concept of two different loops needing to traverse a shared edge in opposite directions to stay consistent with each other (the way a proper watertight mesh normally works). In practice this means: when two faces share a physical edge, give each face its own edge binding for that segment, even though the two are geometrically identical. This is a small, deliberate duplication rather than a limitation to work around — it keeps every loop’s closure trivially self-contained and avoids needing any notion of “traverse this edge backward.”
9. The full pipeline: from IPL source to a mesh file
Putting the above together, an @domain(spatial2d|spatial3d) definition
flows through four stages, each a separate, independently testable piece:
.ms.ipl source
|
v
ipl_parser.parse (unchanged — ordinary IPL parsing)
|
v
spatial_domain.resolveSpatialBindings (§6 — builds SpatialGraph + binding table)
|
v
spatial.triangulate (SpatialGraph -> TriangulatedMesh)
|
v
spatial.exportPly (TriangulatedMesh -> binary PLY bytes)
This is wired into the CLI, not just tests: main.zig’s .ms.ipl
handling now calls ipl_export_mesh.writeNetworkMeshes after parsing,
which walks every definition in the file and, for each one whose
domain_spec is spatial2d/spatial3d, runs it through the pipeline
above and writes <definition-name>.ply under the run’s workspace
directory (ipl_export_mesh.writeDefinitionMesh does the single-
definition version of this, if you need to call it directly).
Definitions with no @domain, or with @domain(spatial1d), are left
alone by the mesh exporter — they go through the existing VHDL emission
path instead. A single .ms.ipl file can mix causal definitions and
geometry definitions side by side; each is routed to the emitter that
actually understands it, based purely on domain_spec. This is the seed
of the unification mentioned in §1: one source file, one parser, two
independent consumers of the same AST shape today — and eventually,
ideally, definitions that draw on both at once.
10. Worked example: a unit square, start to finish
cube[()()
@domain(spatial3d)
// 8 corners: b0..b3 form the bottom face (z=0),
// t0..t3 form the top face (z=1), directly above
// their b-counterparts.
p0< point(0.0, 0.0, 0.0) > // b0
p1< point(1.0, 0.0, 0.0) > // b1
p2< point(1.0, 1.0, 0.0) > // b2
p3< point(0.0, 1.0, 0.0) > // b3
p4< point(0.0, 0.0, 1.0) > // t0
p5< point(1.0, 0.0, 1.0) > // t1
p6< point(1.0, 1.0, 1.0) > // t2
p7< point(0.0, 1.0, 1.0) > // t3
// Each of the 6 faces owns its own 4 edges, even where an
// edge geometrically coincides with one used by a neighboring
// face (e.g. p0->p1 appears both here and in the front face).
// This keeps every loop closing in a single consistent
// direction with no reversed-edge traversal needed anywhere —
// unlike the Möbius example, an ordinary cube's faces never
// need to share a directed edge to stay correct.
// bottom face: b0 -> b1 -> b2 -> b3 -> b0
e_b0<edge($p0, $p1)>
e_b1<edge($p1, $p2)>
e_b2<edge($p2, $p3)>
e_b3<edge($p3, $p0)>
l_bottom<loop($e_b0, $e_b1, $e_b2, $e_b3)>
f_bottom<face($l_bottom)>
// top face: t0 -> t1 -> t2 -> t3 -> t0
e_t0<edge($p4, $p5)>
e_t1<edge($p5, $p6)>
e_t2<edge($p6, $p7)>
e_t3<edge($p7, $p4)>
l_top<loop($e_t0, $e_t1, $e_t2, $e_t3)>
f_top<face($l_top)>
// front face: b0 -> b1 -> t1 -> t0 -> b0
e_f0<edge($p0, $p1)>
e_f1<edge($p1, $p5)>
e_f2<edge($p5, $p4)>
e_f3<edge($p4, $p0)>
l_front<loop($e_f0, $e_f1, $e_f2, $e_f3)>
f_front<face($l_front)>
// right face: b1 -> b2 -> t2 -> t1 -> b1
e_r0<edge($p1, $p2)>
e_r1<edge($p2, $p6)>
e_r2<edge($p6, $p5)>
e_r3<edge($p5, $p1)>
l_right<loop($e_r0, $e_r1, $e_r2, $e_r3)>
f_right<face($l_right)>
// back face: b2 -> b3 -> t3 -> t2 -> b2
e_k0<edge($p2, $p3)>
e_k1<edge($p3, $p7)>
e_k2<edge($p7, $p6)>
e_k3<edge($p6, $p2)>
l_back<loop($e_k0, $e_k1, $e_k2, $e_k3)>
f_back<face($l_back)>
// left face: b3 -> b0 -> t0 -> t3 -> b3
e_l0<edge($p3, $p0)>
e_l1<edge($p0, $p4)>
e_l2<edge($p4, $p7)>
e_l3<edge($p7, $p3)>
l_left<loop($e_l0, $e_l1, $e_l2, $e_l3)>
f_left<face($l_left)>
:
]
No sources/destinations are needed — this definition exists to
produce geometry, not to compute a causal output, and neither the
resolution pass nor the mesh exporter looks at def.destinations at all.
(Note: if this definition were ever run through the VHDL emitter
instead, an empty destination list would trigger Fant’s §12.3.4
single-return normalization, which assumes any zero-destination
definition is implicitly returning something and would try to turn
p0–f0 into VHDL ports. That normalization currently doesn’t
distinguish a geometry-only definition from an ordinary one — the mesh
exporter sidesteps it entirely by never invoking writeDefinition, but
it’s a known rough edge if this definition path is ever exposed to VHDL
emission by mistake.)
Walking it through the pipeline:
- Parse. Each
pN<point(...)>and the rest parse as ordinaryStatement.fillvalues, each carrying a structured.callparsed_expr— nothing spatial-domain-specific happens yet. - Resolve.
resolveSpatialBindingsseesdomain_spec.kind == .spatial2d, and walks the four point fills, then the four edge fills (each resolving its$pNarguments against the binding table built so far), thenl0(resolving its four$eNedge references and validating that they close:p0→p1→p2→p3→p0), thenf0(resolving its single$l0reference). The result is aSpatialGraphwith 4 points, 4 edges, 1 loop, 1 face, and a binding table mapping all nine names to their handles. - Triangulate.
spatial.triangulateturns the single quad face into 2 triangles over the same 4 vertices. - Export.
spatial.exportPlywrites those 4 vertices and 2 triangular faces out as a binary PLY file — real bytes on disk, openable in MeshLab or any other mesh viewer.
11. Looking ahead
This is deliberately the smallest complete example: one face, closed by
hand-traced edges, small enough to walk through statement by statement as
above. The same primitives — point, edge, loop, face, the
$-reference convention, define-once-reference-many binding — scale
directly to more complex solids (multiple faces, each with its own
edges per §8) without any new syntax or resolution logic.
The larger direction this is building toward, per §1, is a single
MatterScript program in which the same language constructs — the same
@domain scoping, the same binding discipline — describe both the
digital logic of a system and the physical structure it’s embedded in or
actuates. That coupling isn’t designed yet, and this document doesn’t
attempt to anticipate its shape. What’s here is the first concrete proof
that IPL’s causal-network model and physical geometry can be expressed
side by side in the same file, resolved by independent passes over the
same parsed AST, without the language itself needing to know in advance
which one it’s describing.