Buildings read as graphs: adjacency, access, sightlines, and a classifier over the massing.
TopologicPy throughout, with graph learning through its PyG module (PyTorch Geometric).
MaCAD 2026, Module 03: Graph Machine Learning, phases 01 to 03. By Emilie El Chidiac.
Three phases, three questions:
| Phase | Subject | Question |
|---|---|---|
| 01 · graph generation | police station, Salt, Spain | can a building's security logic be made computable? |
| 02 · spatial analysis | the same ground floor | where are the chokepoints, the communities, the blind spots? |
| 03 · building graph representation | Lever House, New York | can a trained classifier read a massing's relationship to the ground? |
This project translates the Police Station in Salt, Spain into a series of mathematical graphs using topologicpy. The goal is to move beyond standard 3D modeling and make the building's spatial logic computable — encoding security hierarchy, physical access, and visual control as graph structures that can be queried and analyzed.
Three graphs are produced, each answering a distinct spatial question:
- Primal Graph — which rooms are geometrically adjacent?
- Movement Graph — which rooms can physically reach each other?
- Visual Graph — which rooms have a line of sight to each other?
A police station is an extreme case of designed spatial control. Its floor plan is not arbitrary — it is a security protocol encoded in architecture. The building deliberately separates three user groups (public, staff, detainees) whose paths must never accidentally intersect. Visual control is weaponized: officers need to survey multiple zones without physically occupying every room. Access is rationed through apertures: every door is a checkpoint.
This makes it an ideal subject for graph analysis. The gap between geometric adjacency (primal) and actual access (movement graph) is precisely where the security design lives. The visual graph then tests whether the surveillance logic holds up computationally.
spatial-graph-analysis/
├── README.md
├── reference/ # Source Rhino model & reference plans
│ └── police-station-salt-jfb.3dm
│
├── phase-01-graph-generation/ # Phase 01 — Graph Generation
│ ├── assets/
│ │ ├── rooms-geo.obj # Extruded room volumes from Rhino
│ │ ├── doors-geo.obj # Solid door apertures
│ │ ├── glass-doors-geo.obj # Glass door apertures
│ │ └── windows-geo.obj # Window apertures
│ ├── notebooks/
│ │ └── graph-gen.ipynb # Graph generation notebook
│ ├── plots/
│ │ ├── ps-rooms-colored.png # Room volumes colored by category
│ │ ├── ps-primal-graph.png # Primal graph — geometric adjacency
│ │ ├── ps-movement-graph.png # Movement graph — physical access
│ │ └── ps-visual-graph.png # Visual graph — line of sight
│ └── pdf/
│ └── ps-phase-01-presentation.pdf # Phase 01 presentation
│
├── phase-02-spatial-analysis/ # Phase 02 — Spatial Analysis
│ ├── assets/
│ │ ├── gf-floor-plan.obj # Ground-floor plan mesh
│ │ ├── gf-floor-plan.brep # Raw BREP — exported by obj-to-brep.ipynb
│ │ └── gf-floor-plan-face.brep # Single-face BREP — used by si notebooks
│ └── notebooks/
│ ├── obj-to-brep.ipynb # Step 0: convert OBJ → BREP
│ ├── si-centrality.ipynb # Closeness & Betweenness centrality
│ ├── si-community.ipynb # Community detection & Degree centrality
│ └── si-isovist.ipynb # Isovist-based visibility analysis
│
└── phase-03-building-graph-representation/ # Phase 03 — Building Graph Representation
├── assets/
│ ├── ground.obj · columns.obj · offices.obj · core.obj · plinth.obj
│ ├── LeverHouse.3dm # Source Rhino model (Lever House)
│ ├── bgr_dataset/ # Exported graph CSVs — the Lever House graph
│ ├── dataset_graph_classification/ # Training dataset — labelled massing typologies
│ ├── bgr_model.pt # Pre-trained classifier (used for prediction)
│ └── pyg_model.pt # Model trained in notebook 00
├── notebooks/
│ ├── 00-graph-classification.ipynb # Train the GraphSAGE graph classifier
│ ├── 01-create-bgr-graph.ipynb # Build the Lever House graph from OBJs
│ └── 02-predict-bgr-graph.ipynb # Predict the building–ground relationship
├── plots/
│ ├── lh-cell-complex.png # Merged cell complex
│ ├── lh-cells-colored.png # Cells colored by element type
│ ├── lh-adjacency-graph.png # Adjacency graph
│ └── lh-prediction-result.png # Classification result
└── pdf/
└── lh-phase-03-presentation.pdf # Phase 03 presentation
All files and folders in this repo follow a single consistent convention:
| Rule | Pattern | Example |
|---|---|---|
| Everything | lowercase-hyphen-separated |
rooms-geo.obj |
| Phase folders | phase-XX-descriptor |
phase-01-graph-generation/ |
| Notebooks | topic-name.ipynb |
si-centrality.ipynb |
| Geometry assets | descriptor-geo.obj/.mtl |
glass-doors-geo.obj |
| Plot images | ps-descriptor.png |
ps-movement-graph.png |
| PDFs | ps-phase-XX-descriptor.pdf |
ps-phase-01-presentation.pdf |
| Generated files | descriptor-type.ext |
gf-floor-plan-face.brep |
ps = police station · gf = ground floor · si = spatial intelligence · lh = lever house · bgr = building graph representation
topologicpy >= 0.9.31
Run in VS Code, Jupyter, or Google Colab. Set the renderer at the top of the notebook:
renderer = "vscode" # or "colab" / "browser"Each room is classified by keyword match on its Rhino object name. Classification controls node color across all graphs.
| Category | Keyword Match | Color | Description |
|---|---|---|---|
public |
lobby, public, security |
Green gradient | Entry and publicly accessible spaces |
circulation |
circulation, vertical |
Red (flat) | Corridors, stairwells, the operational spine |
cell |
cell |
Black (flat) | Detainee holding spaces |
private |
(fallback) | Yellow gradient | Staff offices, administrative areas |
The gradient within public and private categories is computed with a linear interpolation (lerp_color) across the number of rooms in that category, giving each room a unique shade while keeping the zone visually coherent.
To change node size for rooms: edit SIZE_ROOM in Cell 02.
To change node size for apertures: edit SIZE_DOOR, SIZE_GLASS_DOOR, SIZE_WINDOW in Cell 07.
objects = Topology.ByOBJPath("../assets/rooms-geo.obj")Imports the OBJ as a list of Cluster topology objects. Each object carries a dictionary with its Rhino layer-based name.
Two-pass loop:
- Pass 1: classify each room by name keyword, collect into
room_data - Pass 2: assign color (gradient or flat) and
vertex_size, buildcellsandselectorslists
The two-pass approach is necessary because gradient coloring requires knowing the total count per category before assigning individual values.
Cell.ByFaces(faces) # builds a closed solid from the OBJ faces
Topology.RemoveCollinearEdges(c) # cleans up redundant vertices
Topology.InternalVertex(c) # places selector point guaranteed inside the volume
Dictionary.SetValuesAtKeys(...) # attaches color and vertex_size to the selectorhouse = CellComplex.ByCells(cells)
house = Topology.TransferDictionariesBySelectors(house, selectors, tranCells=True)CellComplex.ByCells merges all room solids into a single topology where shared faces are detected automatically. TransferDictionariesBySelectors pushes the color/size dictionaries from the selector points onto the correct cells using spatial containment — this is what makes node colors survive into the graph.
g = Graph.ByTopology(house, useInternalVertex=True)| Parameter | Value | Effect |
|---|---|---|
useInternalVertex |
True |
Node placed inside the volume (not at centroid, which can fall outside non-convex shapes) |
Default behavior connects any two cells sharing a face — regardless of apertures. This is geometric adjacency only.
Node size is set to degree normalized to a fixed range (10–30):
degrees = [Graph.VertexDegree(g, v) for v in verts]
size = 10 + (degree - min_deg) / max(max_deg - min_deg, 1) * 20More connected rooms appear larger. This makes the circulation spine visually dominant and peripheral cells visually recessive.
Three aperture types are loaded as separate lists so they can be combined selectively:
doors → doors-geo.obj # burgundy (#800020)
glass_doors → glass-doors-geo.obj # cyan
windows → windows-geo.obj # dark blueEach aperture face gets a type, color, and vertex_size dictionary entry. Aperture nodes are intentionally smaller than room nodes to maintain visual hierarchy.
house_movement = Topology.AddApertures(house, doors + glass_doors, subTopologyType="face")
house_visual = Topology.AddApertures(house, glass_doors + windows, subTopologyType="face")Two versions of the same building, each loaded with only the apertures relevant to that analysis. subTopologyType="face" attaches apertures to the faces (walls) they belong to, which is what Graph.ByTopology uses to detect connections.
g_movement = Graph.ByTopology(house_movement,
direct=False,
viaSharedApertures=True,
toExteriorApertures=False)| Parameter | Value | Effect |
|---|---|---|
direct |
False |
Rooms sharing only a wall get no edge — adjacency alone is not access |
viaSharedApertures |
True |
Edges form only where an aperture (door/glass door) sits on the shared face |
toExteriorApertures |
False |
No exterior connections — movement is internal only |
The aperture face itself becomes a node in the graph, sitting between the two rooms it connects. This makes doors and glass doors first-class spatial entities in the graph, not just edge weights.
g_visual = Graph.ByTopology(house_visual,
direct=False,
viaSharedApertures=True,
toExteriorApertures=True)| Parameter | Value | Effect |
|---|---|---|
toExteriorApertures |
True |
Windows also connect rooms to an exterior node — reveals surveillance reach to outside |
Glass doors appear in both graphs: they allow passage (movement graph) and sight (visual graph). Windows appear only in the visual graph.
| Node type | Color | Size | Appears in |
|---|---|---|---|
| Public room | Green gradient | SIZE_ROOM |
All graphs |
| Circulation room | Red | SIZE_ROOM |
All graphs |
| Private room | Yellow gradient | SIZE_ROOM |
All graphs |
| Cell room | Black | SIZE_ROOM |
All graphs |
| Solid door | Burgundy #800020 |
SIZE_DOOR |
Movement graph |
| Glass door | Cyan | SIZE_GLASS_DOOR |
Movement + Visual graphs |
| Window | Dark blue | SIZE_WINDOW |
Visual graph |
Default sizes: SIZE_ROOM = 35 · SIZE_DOOR = SIZE_GLASS_DOOR = SIZE_WINDOW = 10
| # | Name | Method | Question |
|---|---|---|---|
| 05 | Escape Distance | Graph.ShortestPath |
How many doors between each cell and an exit? |
| 06 | Betweenness Centrality | Graph centrality algorithms | Which spaces are mandatory chokepoints? |
| 07 | Visibility Reach | N-hop traversal on visual graph | Where are the optimal guard positions? |
| 08 | Zone Permeability | Cross-zone edge count | How many boundary violations exist? |
| 09 | Geometry vs. Access Delta | Primal minus movement graph | Where are the intentional barriers? |
| Concept | TopologicPy class | Role |
|---|---|---|
| Closed solid | Cell |
Represents one room |
| Multi-room topology | CellComplex |
Detects shared faces automatically |
| Point inside solid | Topology.InternalVertex |
Correct node placement |
| Metadata on topology | Dictionary |
Stores color, size, type, category |
| Graph from topology | Graph.ByTopology |
Core graph construction |
| Aperture attachment | Topology.AddApertures |
Links doors/windows to wall faces |
| Node connectivity count | Graph.VertexDegree |
Drives degree-based node sizing |
Worth stating plainly, because a graph-ML repo can easily imply more authorship than it has.
Solo work, all three phases.
- Mine: the case study choice and the argument for it, the Rhino modelling and export, all graph construction, the classification scheme, every analysis notebook, the plots and the three presentations.
- Course provided: the Lever House Rhino model, the labelled massing-typology training set
in
dataset_graph_classification/, and the pretrained classifierbgr_model.ptused for prediction in phase 03. Notebook00-graph-classification.ipynbtrains a GraphSAGE model on that provided dataset; it is applied machine learning on someone else's data, not a model I designed. - Reference material, not mine: the police station in Salt, Spain is a real building by
another practice. Its model and plans in
reference/came with the course and are here so the analysis is reproducible. They are reference for academic study, not work of mine, and not offered for reuse. Every notebook reads the derived OBJ exports rather than the source model.
- Room classification is a keyword match on Rhino layer names.
lobby,cell,circulationand so on, with a fallback toprivate. It works, and it is also the weakest link in the whole pipeline: the analysis silently inherits the modeller's naming discipline, and one badly named layer moves a room into the wrong zone with no error. A robust version would classify on geometry and connectivity instead of on strings. That is a real limitation, not a to-do I am pretending is small. - Node placement had to move off the centroid.
Graph.ByTopologywith a centroid places nodes outside non-convex rooms, which looks like a rendering glitch and is actually wrong data.useInternalVertex=Truefixes it. You find this by noticing nodes floating outside the building. - Colouring needed two passes, not one. A gradient across a category cannot be assigned until the category's total count is known, so classification and colouring are separate loops. Obvious in hindsight, not while writing it.
- The interesting result is a gap, not a number. The primal graph and the movement graph differ exactly where the architects put a barrier. The security design lives in the difference between the two, which is why both are built rather than just the useful one.
- The
Future Analysestable above is honest. Those analyses are designed and not run.