A vector floor plan PDF goes in. Rooms, areas, doors, windows and a room adjacency graph come out. JSON, GeoJSON and a rough IFC.
No dependencies. The PDF reader, the geometry, the IFC writer and the GIF encoder above are all in this package, in the standard library.
python -m roomgraph.cli extract plan.pdf -o out/plan.pdf page 0
scale 1:50 (dimension, confidence 0.70) - 1 dimension string(s) agreed
walls 5
openings 2 {'door': 2}
id name category gross m2 net m2 labelled check connects to
---- ----------- -------- -------- ------ -------- ----- -----------
R001 PHONG KHACH living 30.00 27.07 30.00 ok R002
R002 BEP kitchen 24.00 21.38 24.00 ok R001
id kind symbol width mm conf
---- ---- ---------- -------- ----
O001 door door_swing 1000 0.98
O002 door door_swing 900 0.98
plan: scale_bar (scale_bar, 0.87) {'divisions': 5, 'measured_m': 10.0,
'stated_m': 10.0, 'delta_pct': 0.0, 'confirms_scale': True}
plan: north_arrow (north_arrow, 0.84) {'bearing_deg': 0.0, 'label': 'N'}
plan: hatch_legend (hatch_legend, 0.80) {'title': 'CHU THICH',
'entries': ['GACH XAY', 'BE TONG'], 'count': 2}
plan: hatch (hatch_pattern, 0.88) {'count': 2, 'named': 2, 'regions': [
{'material': 'GACH XAY', 'style': 'single', 'rulings': 54,
'families': [{'angle_deg': 45.0, 'spacing_mm': 120.0, 'rulings': 54}]},
{'material': 'BE TONG', 'style': 'cross', 'rulings': 61,
'families': [{'angle_deg': 45.0, 'spacing_mm': 120.0, 'rulings': 31},
{'angle_deg': 135.0, 'spacing_mm': 120.0, 'rulings': 30}]}]}
That is the plan in the animation above. Two materials, named in the drawing's own Vietnamese because its legend says what they are; a north bearing, which a plan records nowhere else; and a scale bar that independently confirms the scale — which had been read off a dimension string, an entirely separate witness.
That check column is the drawing's own printed room area compared against the
measured one. It is the cheapest available proof that the scale was read right.
A drawing states more than it is usually asked to. Where a plan carries them, roomgraph measures its scale bar against its own label, sums its dimension chains against the spans they cover, compares a stated travel distance against the route it measures, and counts the door schedule against the doors actually found. Each disagreement becomes a warning. They are independent witnesses, which is exactly what you want checking a number that everything else rests on.
Clean CAD-exported PDFs. Scans are explicitly out of scope, and that is the whole reason this is finishable: line and symbol detection on a scan is a research project, while a vector export already contains the lines — they just need to be understood. Feed it a scan and it will tell you it found no walls rather than guess.
See docs/LIMITATIONS.md for the rest of the boundary, including the ones that will bite you.
PDF content stream walls are drawn as TWO parallel lines, and an
| opening is where BOTH of them stop. Everything
v downstream follows from that one observation.
scale calibration dimension strings > title block > door widths,
| each reported with its confidence
v
line clustering segments -> the infinite lines they lie on
|
v
face pairing parallel lines 60-420mm apart -> walls + openings
|
v
corner repair free ends aimed at a missing corner are rejoined,
| the invented length becoming a bridged opening
v
planar arrangement split at crossings and T-junctions, walk half-edges
| -> minimal cycles = rooms
v
symbol library each gap classified: door, window, cased opening
|
v
room graph shared wall = adjacent; opening on it = walkable
Pairing lines before pairing segments is the part that matters. Match raw segments first and every wall broken by a doorway falls apart — and every real plan has those.
git clone https://github.com/sophie-nguyenthuthuy/roomgraph
cd roomgraph
python -m roomgraph.cli --helpPython 3.10+. That is the entire installation.
# everything, into out/
python -m roomgraph.cli extract plan.pdf -o out/ -f json,geojson,ifc,svg,gif
# when you know the scale, say so -- it is exact, and detection is not
python -m roomgraph.cli extract plan.pdf --scale 1:100
# what does this PDF actually contain? layers, text, scale candidates
python -m roomgraph.cli inspect plan.pdf --text 20
# what can the library recognise?
python -m roomgraph.cli symbols
# fail the build if anything is uncertain
python -m roomgraph.cli extract plan.pdf --strictAs a library:
from roomgraph import extract
model = extract("plan.pdf", scale="1:50")
for room in model.rooms:
print(room.name, room.area_gross_m2, model.graph.neighbours(room.id))
print(model.graph.is_connected()) # is every room reachable?
print([e.a for e in model.graph.entrances])
for w in model.warnings:
print("warning:", w)| Format | What it is for |
|---|---|
| JSON | the canonical model — rooms, walls, openings, graph, warnings. Every other export is a projection of this. |
| GeoJSON | rooms as polygons, walls as lines, openings as segments. Local metres by default, or pass --geo-origin lat,lon for real WGS84. |
| IFC | IFC4 SPF: Project/Site/Building/Storey, IfcSpace per room, IfcWallStandardCase with real voids, IfcDoor/IfcWindow filling them. Rough — every height is an assumption. |
| SVG | the figure above, with a toggleable graph layer. Doubles as the debugging view when a room comes out wrong. |
| GIF | the animation at the top. Written by export/raster.py, LZW and all. apartment.gif is the same animation on a plan with no hatch, so it skips the materials beat. |
One symbol is one file in roomgraph/symbols/. You add a file with a
SYMBOL and its FIXTURES; you do not touch the pipeline, the exporters, or
the tests. The suite discovers your symbol, runs your fixtures, and separately
checks that your detector does not outbid an existing symbol on that symbol's
own fixtures — so a too-greedy detector fails loudly instead of quietly stealing
openings.
Currently:
| id | scope | detects |
|---|---|---|
door_swing |
opening | leaf line plus a swing arc of the opening's width |
door_double |
opening | two half-width arcs hinged on opposite jambs |
door_sliding |
opening | one leaf parallel to the wall, offset, no arc |
door_folding |
opening | three or more equal leaves zigzagging across the opening |
door_revolving |
opening | evenly spaced leaves radiating from a hub at the opening centre |
door_roller |
opening | a slatted curtain, corrugated or wound onto a barrel |
door_fire_shutter |
opening | the same shutter, drawn on a fire-rated layer |
window |
opening | two or more glazing lines spanning the opening |
window_bay |
opening | straight facets projecting out of the wall, jamb to jamb (box, canted, bow) |
window_corner |
opening | glazing wrapping a corner the drawing left out |
curtain_wall |
opening | a wide glazed run divided by mullions at a regular module |
opening_plain |
opening | a gap with nothing drawn in it |
stairs |
room | three or more evenly spaced parallel treads |
stairs_spiral |
room | six or more treads radiating from a newel |
escalator |
room | a long run of steps between full-length balustrades |
travelator |
room | a walkway band, named or longer than any escalator |
ramp |
room | a band carrying a gradient label |
turning_circle |
room | an empty 1500 mm circle of clear floor |
kitchen |
room | units sharing one depth: a fitted run |
column |
room | small poched or repeating squares |
sanitary |
room | outlines matching standard bath, shower, WC, bidet or basin sizes |
lift |
room | a car-sized rectangle with both diagonals drawn |
dumbwaiter |
room | the same crossed box, too small to stand in |
parking_bay |
room | two or more car-sized rectangles of matching size |
furniture_layout |
room | standard bed and desk sizes |
fire_equipment |
room | cabinets on a fire layer or beside a fire label |
planting |
room | scalloped canopies: ragged where a circle is smooth |
lab_bench |
room | runs at bench depth (750-900) or island depth (1500-1800) |
theatre_seating |
room | a dozen or more identical seats in rows at a regular pitch |
ward_bay |
room | three or more beds of one size at a regular bay pitch |
plant_equipment |
room | equipment on a services layer, labelled, or in a plant room |
drainage |
room | gullies and channels on a drainage layer, with any stated fall |
extract_canopy |
room | a deep hood over a cooking line |
loading_dock |
room | matching leveller plates in a row |
raised_floor |
room | a tile grid at roughly 600 mm both ways |
structural_grid |
plan | reference lines at regular bays, ending in lettered bubbles |
escape_route |
plan | open polylines on an escape layer, measured against any stated distance |
north_arrow |
plan | an arrow beside an N, reported as a bearing |
scale_bar |
plan | graduated divisions, measured against their own label |
section_mark |
plan | a cut line ending in two bubbles carrying the same letter |
revision_cloud |
plan | a scalloped outline on a revision layer |
level_spot |
plan | stated floor levels, the only height a plan records |
dimension_chain |
plan | a run of collinear dimensions, checked against the span it covers |
elevation_mark |
plan | a lone lettered bubble with an arrow, attached to nothing |
door_schedule |
plan | the drawing's door list, counted against the doors found |
hatch_legend |
plan | a column of captioned swatches: the drawing's material vocabulary |
hatch_pattern |
plan | regions of dense rulings, named by matching the legend |
There are three scopes. Opening symbols compete, because one opening is one thing, and the most confident wins. Room symbols accumulate: a bathroom carries both fittings and a stair, and a ward reports its bays and the beds in them. Plan symbols see the whole drawing, including the geometry outside the building — a structural grid crosses every room and puts its bubbles in the margin, so neither of the other two scopes can reach it.
Forty-seven symbols. The specialised end of the library is where the method shows its limit: a lab bench is identified by depth, an auditorium by repetition, a plant room by the layer the drafter used. Where a drawing carries no such evidence, the symbol declines rather than guessing, and docs/LIMITATIONS.md says which ones do that and why.
Two plan symbols earn their place by reporting something nothing else in the
model can know: north_arrow gives the building's bearing, and level_spot
gives its datum. A plan is otherwise entirely flat and entirely unoriented.
hatch_pattern is worth singling out for what it refuses to do. It carries no
table of ANSI patterns, because what 45-degree rulings mean is a matter of
office and national convention — brickwork in one practice, general material in
another, and Vietnamese drawings differ again. Hard-coding one would be
inventing a standard nobody agreed to.
So it asks the drawing. A legend swatch is a labelled specimen of a pattern, so the same signature is computed inside each swatch and the regions elsewhere are matched against it. A plan with a legend gets its materials named in its own vocabulary; a plan without one gets the geometry, unnamed and honest about it.
That is the materials beat in the animation at the top: terracotta rulings for
GACH XAY, crosshatch for BE TONG, both names read off the drawing's own
legend. It draws the rulings the detector actually grouped rather than a filled
outline — a solid block would show something the drawing never contained — and
plans without hatch simply skip the beat.
Not every symbol is only a symbol file, though. A corner window deletes the
corner, so both walls stop short, nothing encloses, and the room is lost before
any detector runs — that one needed walls.bridge_corners to reconstruct the
missing corner first. If a symbol changes what counts as a wall, expect to
touch the wall stage too.
docs/SYMBOLS.md has the local-frame diagram, the context API, the confidence bands and a checklist.
python examples/make_fixtures.py # generate the fixture PDFs
python -m unittest discover -s tests # 249 tests
make test # the same, plus a demo renderFixture PDFs are generated by examples/make_fixtures.py rather than committed,
so their ground truth is written down next to the geometry that produces it. They
are synthetic — see the last section of
docs/LIMITATIONS.md for why that matters and what would
help most.
MIT.
