Topology-based procedural modelling¶
ada.topology is a domain-generic procedural engine: you describe a model as
a set of spaces (boxes), the engine partitions them into a cell graph with
classified faces and edges, and a blueprint turns that topology into
geometry. ada.topo_model is the in-repo reference implementation — a small
steel structure with equipment, service systems and routed connections.
The engine in a nutshell¶
Spaces go in, an assembly comes out:
import ada
from ada.topology import TopologyBuilder
from ada.topo_model import SteelStru
boxes = [
ada.PrimBox("Cell1", (0, 0, 0), (5, 5, 3)),
ada.PrimBox("Cell2", (5, 0, 0), (10, 5, 3)),
]
builder = TopologyBuilder.from_prim_boxes(boxes, blueprint=SteelStru())
builder.build()
a = builder.get_output_assembly("MyModel")
The CellGraph behind the builder answers topology questions: which faces
are external floors or walls, which wall is shared between two cells
(get_internal_walls), and each face carries its ordered outline points,
edges and normal. Two adjacent cells share one wall and the girder/column edges
along it — the engine deduplicates those for you.
Or in one line, with the demo’s defaults:
from ada.topo_model import build_topo_model
a = build_topo_model()
Writing a blueprint¶
A blueprint subclasses BlueprintBase and
implements build(): read the cell graph, emit parts, register them per
area with add_to_area and finish with load_parts_from_area_map():
import ada
from ada.topology import BlueprintBase
class MyStru(BlueprintBase):
def _group_prefix(self) -> str:
return "MyStru"
def build(self) -> ada.Part:
self.output_part = ada.Part("MyStru")
cg = self.builder.cell_graph
for i, face in enumerate(cg.get_external_floors()):
plate = ada.Plate.from_3d_points(f"pl{i}", face.get_points(), 0.01)
self.add_to_area("floors", ada.Part(f"Floor_{i}") / plate)
self.load_parts_from_area_map()
return self.output_part
ada.topo_model.SteelStru is the full worked example: reinforced floors
(plate + evenly spaced stringers), girders from deduplicated floor-face edges
and columns from the vertical wall edges.
Equipment with ports¶
Equipment carries typed connection points — ports — at local positions with
outward directions and a service category (process/electrical/signal):
import ada
pump = ada.Equipment("P1", mass=1000, cog=(0, 0, 0.5), origin=(2.5, 2.5, 3.0), lx=1, ly=1, lz=1)
pump.add_port(ada.Port("discharge", (0, 0, 1.0), (0, 0, 1), ada.PortDirection.OUT, "process"))
pump.add_port(ada.Port("power", (0.5, 0, 0.5), (1, 0, 0), ada.PortDirection.IN, "electrical"))
The demo archetypes create_pump / create_tank in ada.topo_model
ship with realistic port layouts, and ada.Voltage enumerates typical
industrial supply levels (230 V – 11 kV).
Wiring systems¶
A System is a logical service network with a fixed category; connecting it
to ports is fluent and fail-fast (wrong category or an already-connected port
raises with a clear message):
cooling = (
ada.PipingSystem("CoolingWater", medium="water")
.connect(pump, "discharge")
.connect(tank, "inlet")
)
power = ada.ElectricalSystem("PowerFeed", voltage=ada.Voltage.LV_690).connect(pump, "power")
The references are bidirectional: port.connected_system points at the
system, system.ports / system.connected_equipment point back.
Routing¶
Systems route over a CellGrid node lattice with
6-connected A*; occupied nodes are avoided and the routed polyline keeps only
its bends:
from ada.topology import CellGrid, RoutingRules
grid = CellGrid.from_bounds((0, 0, 3.0), (10, 5, 5.5), spacing=0.5)
grid.register(grid.index_of(5.0, 2.5, 3.5), "obstruction")
cooling.route(grid) # default rules
power.route(grid, rules=RoutingRules(elevation_penalty=5.0, bend_penalty=1.0))
Rules are pluggable: is_allowed / move_cost callables plus elevation
and bend penalties. system.route(...) also generates the route geometry
matched to the service: a round ada.Pipe run (with auto-inserted elbows)
for piping, a rectangular BOX ada.Beam run for ducting, and an open
UNP-channel ada.Beam run for cable trays / electrical. For
blueprint-driven routing, subclass
RoutingBlueprintBase and override rules_for
per system and/or build_routing_grid.
Penetrations¶
Where a routed system crosses a wall or floor, a penetration blueprint turns
the crossing into a detail. StandardPenetrations keys the detail on the
routing type — a pipe sleeve for process runs, an MCT-style transit block for
cable/electrical, a rectangular frame for ducts — and cuts the through-hole in
the crossed face’s built wall plate:
from ada.topo_model import StandardPenetrations
pens = StandardPenetrations(systems=[service], faces=cg.get_internal_walls())
a.add_part(pens.build()) # one detail part per crossing; wall plates get the hole
The demo builds its shared internal wall as a reinforced wall
(SteelStru(reinforce_internal_walls=True) — plate + vertical stiffeners)
and routes an interior service run straight through it; subclass
PenetrationBlueprintBase and override
build_penetration for your own detail standard.
Pluggable design rules¶
Routing and penetration rules can be handed to the engine as plain callables — no subclassing. The engine runs in two phases, and every rule is a function that fully encompasses its stage:
Plan (geometry-free, runs first over the whole cell complex): a
plan_routecallable turns(system, cell complex, grid)into aRoutePlan, and aplan_penetrationcallable turns(system, routed path, penetrated members)into a list ofPenetrationcrossings. Planners see theCellGraph(the cell complex — cells + classified faces) and the routingCellGridlattice, plus the penetrated members for penetration rules. They emit data, never geometry.Model (plan → geometry): a
model_routecallable turns aRoutePlaninto adapy geometry, and amodel_penetrationcallable turns aPenetrationinto a detail part.
DesignRules bundles the four callables and
run_design() drives both phases in order (plan
everything, then model everything), returning a
DesignResult — the routed geometry
(route_geometry keyed by system name), the planned penetrations and their
penetration_parts, and any skipped systems (see skip_failed below).
The defaults reproduce the built-in routing; supply your own callables to fully
override a stage:
from ada.topology import DesignRules, RoutePlan, RoutingRules, run_design
from ada.topo_model import standard_design_rules
# A planning rule that fully encompasses routing: forbid a keep-out zone and
# prefer a fixed service elevation. Planners return data (the polyline).
def plan_route(ctx):
def is_allowed(idx, grid):
x, y, z = grid.coord_from_index(idx)
return not (2.0 < x < 3.0) # keep-out corridor
from ada.topology.routing import route_system
poly = route_system(ctx.system, ctx.grid, rules=RoutingRules(is_allowed=is_allowed))
return RoutePlan(system=ctx.system, polyline=poly)
# A modelling rule for the detail geometry at each crossing: a short sleeve
# centred on the crossing point, along the crossed face normal.
def model_penetration(pen, name):
import ada
p1 = tuple(pen.point - pen.normal * 0.15)
p2 = tuple(pen.point + pen.normal * 0.15)
return ada.Part(name) / ada.PrimCyl(f"{name}_sleeve", p1, p2, r=0.1)
rules = DesignRules(plan_route=plan_route, model_penetration=model_penetration)
result = run_design(systems, cell_graph=cg, grid=grid, rules=rules)
# Or reuse the reference detail standard (pipe sleeve / cable block / duct frame):
result = run_design(systems, cell_graph=cg, grid=grid, rules=standard_design_rules())
The same ruleset threads into the higher-level entry points:
) for
blueprint-driven routing, and compile_procedural_doc(..., design_rules=...)
for the viewer’s compile (which defaults to standard_design_rules()). With
skip_failed=True a run that can’t be planned is dropped and named in
result.skipped rather than sinking the whole model. The legacy
subclass scaffolds (RoutingBlueprintBase’s
rules_for/build_routing_grid overrides and
PenetrationBlueprintBase’s
build_penetration) still work unchanged — the defaults simply wrap them.
Named rulesets for the viewer¶
A JSON document can’t carry Python callables, so the viewer selects a ruleset
by name. ada.topo_model keeps a small registry
(DESIGN_RULESETS) mapping a slug to a
DesignRules factory:
standard— route runs and add the standard penetration detail at each wall crossing (pipe sleeve / cable block / duct frame, with the wall plate cut).route_only— route runs and detect crossings, but emit no detail geometry.
A cell-model document names one via doc["design_rules"], and
compile_procedural_doc resolves it with
resolve_design_rules() (an unknown/absent slug falls back
to standard):
from ada.topo_model import resolve_design_rules, design_ruleset_specs
rules = resolve_design_rules("route_only") # -> DesignRules | None
design_ruleset_specs() # [{slug, name, description}, ...]
The hosted viewer wires this end-to-end: workers advertise
design_ruleset_specs(), the API serves the built-in rulesets ∪ the advertised
ones at GET /procedural-models/design-rulesets, and the cellbuilder’s
Design rules dropdown writes the chosen slug into doc.design_rules so the
compile worker applies it. Register your own ruleset by adding a slug →
DesignRules factory to DESIGN_RULESETS; it then appears in the dropdown
of any scope served by a worker that ships it.
The missing-I/O report¶
Every port left unconnected is a hole in the design. The validation helpers walk a part tree and report them:
from ada.api.systems import find_unconnected_ports, format_port_report
print(format_port_report(find_unconnected_ports(a)))
Equipment Port Category Direction
--------- ------- -------- ---------
Pump1 suction process IN
Pump1 signal signal INOUT
IFC export¶
Equipment and systems export as proper IFC4 distribution entities: the
equipment element class follows Equipment.ifc_element_class (the demo pump
is an IfcPump, the tank an IfcTank), ports become nested
IfcDistributionPort entities with mapped flow directions, each system is an
IfcDistributionSystem (typed WATERSUPPLY/ELECTRICAL/…) grouping its
routed segments and connected equipment, and cable/duct runs emit
IfcCableSegment/IfcDuctSegment instead of pipe segments.
Building and viewing the demo¶
pixi run -e prod topo-model-demo
The task builds the model, prints the missing-I/O report, exports a GLB,
uploads it to your personal viewer scope (when ADAPY_BASE_URL /
ADAPY_API_TOKEN are configured in .env; skipped otherwise) and streams
the scene to the websocket viewer via assembly.show(). Use --no-upload
/ --no-show to opt out of either side effect.
Compiling a whole model with ProceduralBuilder¶
The engine-in-a-nutshell example above builds a bare structure. A full
procedural model — spaces plus equipment, routed systems, openings and a
design ruleset — is compiled by
ProceduralBuilder, the root object that owns
the whole model. It is object-first: you hand it explicit, validated entity
objects (TopoSpace /
TopoEquipment /
TopoSystem /
TopoOpening) rather than a loose dict, and
compile() returns GLB bytes:
from ada.topo_model import ProceduralBuilder
from ada.topology.entities import TopoSpace, TopoEquipment, TopoSystem
spaces = [
TopoSpace(NAME="Cell1", X=0, Y=0, Z=0, DX=5, DY=5, DZ=3),
TopoSpace(NAME="Cell2", X=5, Y=0, Z=0, DX=5, DY=5, DZ=3),
]
equipment = [
TopoEquipment(NAME="Pump2", DESCRIPTION="pump", SPACE_NAME="Cell1",
SPACE_LOC="FLOOR", X=2, Y=2, Z=0, LX=1, LY=1, LZ=1,
COGx=0, COGy=0, COGz=0.5, massDry=1000, massCont=0),
TopoEquipment(NAME="Tank2", DESCRIPTION="tank", SPACE_NAME="Cell2",
SPACE_LOC="FLOOR", X=6.5, Y=1.5, Z=0, LX=2, LY=2, LZ=2,
COGx=0, COGy=0, COGz=1.0, massDry=1000, massCont=0),
]
systems = [
TopoSystem(NAME="CoolingWater", TYPE="piping", MEDIUM="water", CONNECTIONS=[
{"EQUIPMENT": "Pump2", "PORT": "discharge"},
{"EQUIPMENT": "Tank2", "PORT": "inlet"},
]),
]
glb_bytes = ProceduralBuilder(spaces=spaces, equipments=equipment, systems=systems).compile()
compile() runs the phases in order — build_structure →
build_equipment → build_systems → to_glb. Drive them individually to
inspect the owned state (blueprint, cell_graph, equipment_map,
systems_parts, assembly) in between:
pb = ProceduralBuilder(spaces=spaces, equipments=equipment, systems=systems)
pb.build_structure()
print(pb.cell_graph.get_external_floors()) # the built topology
pb.build_equipment()
print(pb.equipment_map) # {"Pump2": <ada.Equipment>, ...}
pb.build_systems()
glb_bytes = pb.to_glb()
Every child reaches the root through an injected .procedural reference — the
blueprint directly, and any GraphFace through its cell graph — so a blueprint
or a face-level rule can consult the whole model (equipment, systems, the design
ruleset, the LOD). The topology engine
(TopologyBuilder) is reached the other way, as
pb.topology; the LOD lives once on the root (pb.detail):
pb.blueprint.procedural is pb # True
face = pb.cell_graph.get_external_floors()[0]
face.parent_cell.cell_graph.procedural is pb # True
Loading from dict / JSON / Excel¶
Explicit objects are the sturdy path, but a model also loads from the three document formats — each parses and validates into those same entity objects, so dict parsing lives in exactly one place:
# a procedural document (the viewer's commit format) — dict or a JSON file
pb = ProceduralBuilder.from_dict(doc)
pb = ProceduralBuilder.from_json("model.json")
pb = ProceduralBuilder.from_json('{"spaces": [...], "systems": [...]}')
# a multi-sheet workbook: Spaces / Equipments / Openings / Systems + a
# vertical Model sheet (name, blueprint, blueprint options, design ruleset)
pb = ProceduralBuilder.from_excel("model.xlsx")
# and the inverse — round-trips the whole model back out
pb.to_json("model.json")
pb.to_excel("model.xlsx")
The functional compile_procedural_doc(doc, ...) is a thin wrapper over
from_dict + compile — use the builder when you want the phases, the
intermediate model, or the object/Excel round-trips; the function for a one-shot
document compile.
Multiple structures in one model¶
A single document (or workbook) can carry several topology models —
structures — each a named group of spaces/openings placed at its own origin. A
Structures sheet (TopoStructure: NAME +
X/Y/Z) lists them, and every entity is tagged with its
STRUCTURE_NAME:
doc = {
"structures": [
{"NAME": "Deck_A", "X": 0, "Y": 0, "Z": 0},
{"NAME": "Deck_B", "X": 20, "Y": 0, "Z": 0},
],
"spaces": [
{"NAME": "A1", "STRUCTURE_NAME": "Deck_A", "X": 0, "Y": 0, "Z": 0, "DX": 5, "DY": 5, "DZ": 3},
{"NAME": "B1", "STRUCTURE_NAME": "Deck_B", "X": 0, "Y": 0, "Z": 0, "DX": 5, "DY": 5, "DZ": 3},
],
}
glb_bytes = ProceduralBuilder.from_dict(doc).compile()
The same ProceduralBuilder builds one topology model per structure (grouped by
STRUCTURE_NAME) and places each at its origin — no separate multi-builder.
Equipment and systems stay a single shared layer (not duplicated per
structure). With no structures the whole document is one implicit model, so a
plain single-structure build is unchanged. The Structures sheet + entity
STRUCTURE_NAME mirror the sibling procedural-modelling tool’s workbook, so a
model round-trips between the two.
Reading the catalog from Python¶
When the viewer’s postgres is reachable, ProceduralCatalog
reads a scope’s reusable equipment types and system templates and turns them
into the objects above. equipment_resolver() returns the slug -> catalog
doc callable the builder uses to expand a placed catalog equipment (referenced
by its DESCRIPTION slug) into a full ada.Equipment — ports and IFC
class included:
from ada.topo_model import ProceduralBuilder, ProceduralCatalog
with ProceduralCatalog.connect(scope_kind="user", scope_id="me") as cat:
for et in cat.list_equipment_types():
print(et.slug, et.name, et.doc["ifc_element_class"])
for st in cat.list_system_templates():
print(st.slug, st.doc["type"], st.doc.get("medium"))
# instantiate directly from a catalog type…
pump = cat.get_equipment_type("pump").to_equipment("P1", origin=(2, 2, 3))
cw = cat.get_system_template("cooling_water").to_system(
"CW", connections=[{"EQUIPMENT": "P1", "PORT": "discharge"}])
# …or let the builder resolve placed catalog slugs at compile time
pb = ProceduralBuilder(spaces=spaces, equipments=equipment, systems=systems,
equipment_resolver=cat.equipment_resolver())
glb_bytes = pb.compile()
connect defaults database_url to the DATABASE_URL environment
variable and binds the reader to one scope for its lifetime; use it as a context
manager (or call close()) to release the connection pool.
Viewer catalogs: equipment types and system templates¶
The catalogs above are edited in the hosted viewer’s admin panels and mirror the Python API — the same equipment-type / system-template rows feed both.
The hosted viewer exposes two per-scope catalogs that feed the cellbuilder,
backed by postgres (migrations 023/024) and edited from admin panels:
Equipment types — reusable archetypes with a name/description/slug, a bounding box, mass, IFC element class and a port/nozzle list (each port a local position + outward direction, tagged process/electrical/signal). A CAD asset can be attached (uploaded or copied from a scope file); a worker
equipment_bboxjob then infers the bounding box and renders a preview GLB. Placed catalog equipment resolve by slug at compile time (compile_procedural_doc(..., equipment_resolver=...)) into a fullada.Equipment— ports and IFC class included.System templates — named service systems (category/type, medium, voltage, pipe radius/wall thickness) that seed the cellbuilder’s systems inspector.
When a compiled model enables “use CAD models for equipment”
(doc["equipment_cad"]), catalog equipment that have a linked CAD asset are
built without their placeholder box and the real CAD geometry is spliced into
the output GLB at the cell footprint
(compile_procedural_doc(..., cad_scene_resolver=...)).