Skip to content

DXF Floorplan Extraction

DXF floorplan polygon extraction and labelling.

This module provides utilities for extracting closed polygon geometries from DXF floorplan linework and attaching textual room labels to those polygons using spatial point-in-polygon relationships.

The intended use case is indoor floorplans where all geometries exist in a single, local Cartesian coordinate space. No coordinate reference system (CRS) is assumed or required. All spatial operations are therefore purely topological.

Typical workflow: 1. Read a DXF file into a GeoDataFrame. 2. Polygonise linework from a specified DXF layer. 3. Extract room label point geometries from another layer. 4. Filter labels by floor using a string prefix. 5. Apply configured polygon corrections. 6. Spatially join labels to polygons and aggregate them deterministically. 7. Normalise accepted shared walls to common midlines. 8. Construct configured open-boundary spans from the final room boundaries. 9. Project door symbols onto the final room boundaries.

The module makes the following assumptions: - DXF layer names are stable and known in advance. - A column named "Layer" is present in the input GeoDataFrame. - Room labels are stored as point geometries. - Multiple labels may fall within a single polygon and will be concatenated in sorted order.

The public entry point is extract_polygons. All other functions are internal helpers and are not part of the public API.

DoorAttachmentConfig dataclass

Configuration for extracting and attaching canonical door segments.

Attributes:

Name Type Description
entity_col str

Column linking the CAD geometries that represent the same door.

x_col str

Column containing centroid x coordinates retained for compatibility.

y_col str

Column containing centroid y coordinates retained for compatibility.

out_col str

Name of the output column added to the room polygons.

predicate str

Spatial predicate retained for configuration compatibility.

boundary_tolerance float

Maximum distance from a room-boundary segment used when projecting source door geometry.

min_door_length float

Minimum projected door length to retain.

max_attached_rooms int

Maximum expected number of rooms connected by one door.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
@dataclass(frozen=True)
class DoorAttachmentConfig:
    """
    Configuration for extracting and attaching canonical door segments.

    Attributes
    ----------
    entity_col : str
        Column linking the CAD geometries that represent the same door.
    x_col : str
        Column containing centroid x coordinates retained for compatibility.
    y_col : str
        Column containing centroid y coordinates retained for compatibility.
    out_col : str
        Name of the output column added to the room polygons.
    predicate : str
        Spatial predicate retained for configuration compatibility.
    boundary_tolerance : float
        Maximum distance from a room-boundary segment used when projecting
        source door geometry.
    min_door_length : float
        Minimum projected door length to retain.
    max_attached_rooms : int
        Maximum expected number of rooms connected by one door.

    """

    entity_col: str = "EntityHandle"
    x_col: str = "x"
    y_col: str = "y"
    out_col: str = "doors"
    predicate: str = "intersects"
    boundary_tolerance: float = 0.1
    min_door_length: float = 0.2
    max_attached_rooms: int = 2

ExtractionConfig dataclass

Top-level configuration for DXF extraction.

Attributes:

Name Type Description
polygons PolygonExtractionConfig

Configuration controlling polygon generation and label attachment.

door_layer_name str

Name of the DXF layer containing door geometries.

doors DoorAttachmentConfig or None

If provided, doors are extracted and attached to polygons.

shared_walls SharedWallConfig or None

If provided and enabled, shared-wall normalisation may be applied by downstream processing.

open_boundaries OpenBoundaryConfig or None

Explicit wallless room pairs to construct from final room boundaries.

polygon_splits list[PolygonSplitConfig]

Floorplan-specific polygon splits applied before label attachment.

polygon_additions list[PolygonAdditionConfig]

Floorplan-specific polygons added before label attachment.

polygon_merges list[PolygonMergeConfig]

Floorplan-specific labelled polygons merged before geometry normalisation.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
@dataclass(frozen=True)
class ExtractionConfig:
    """
    Top-level configuration for DXF extraction.

    Attributes
    ----------
    polygons : PolygonExtractionConfig
        Configuration controlling polygon generation and label attachment.
    door_layer_name : str
        Name of the DXF layer containing door geometries.
    doors : DoorAttachmentConfig or None
        If provided, doors are extracted and attached to polygons.
    shared_walls : SharedWallConfig or None
        If provided and enabled, shared-wall normalisation may be applied by
        downstream processing.
    open_boundaries : OpenBoundaryConfig or None
        Explicit wallless room pairs to construct from final room boundaries.
    polygon_splits : list[PolygonSplitConfig]
        Floorplan-specific polygon splits applied before label attachment.
    polygon_additions : list[PolygonAdditionConfig]
        Floorplan-specific polygons added before label attachment.
    polygon_merges : list[PolygonMergeConfig]
        Floorplan-specific labelled polygons merged before geometry normalisation.

    """

    polygons: PolygonExtractionConfig
    door_layer_name: str | None = None
    doors: DoorAttachmentConfig | None = None
    shared_walls: SharedWallConfig | None = None
    open_boundaries: OpenBoundaryConfig | None = None
    polygon_splits: list[PolygonSplitConfig] = field(default_factory=list)
    polygon_additions: list[PolygonAdditionConfig] = field(default_factory=list)
    polygon_merges: list[PolygonMergeConfig] = field(default_factory=list)

OpenBoundaryConfig dataclass

Configuration for constructing explicit wallless room boundaries.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
173
174
175
176
177
178
179
@dataclass(frozen=True)
class OpenBoundaryConfig:
    """Configuration for constructing explicit wallless room boundaries."""

    tolerance: float = 1e-6
    min_length: float = 0.0
    pairs: list[OpenBoundaryPairConfig] = field(default_factory=list)

OpenBoundaryPairConfig dataclass

Configuration identifying one wallless boundary between two rooms.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
164
165
166
167
168
169
170
@dataclass(frozen=True)
class OpenBoundaryPairConfig:
    """Configuration identifying one wallless boundary between two rooms."""

    rooms: tuple[str, str]
    selector_point: XY | None = None
    allow_multiple_spans: bool = False

OpenBoundarySpan dataclass

Canonical shared boundary span for one configured room pair.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
182
183
184
185
186
187
@dataclass(frozen=True)
class OpenBoundarySpan:
    """Canonical shared boundary span for one configured room pair."""

    rooms: tuple[str, str]
    geometry: LineString

PolygonAdditionConfig dataclass

Explicit polygon missing from source room-area linework.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
148
149
150
151
152
153
@dataclass(frozen=True)
class PolygonAdditionConfig:
    """Explicit polygon missing from source room-area linework."""

    label: str
    coordinates: list[XY]

PolygonExtractionConfig dataclass

Configuration for DXF polygon extraction and labelling.

Attributes:

Name Type Description
polygon_layer_name str

Name of the DXF layer containing polygon boundary linework.

label_layer_name str

Name of the DXF layer containing room label point geometries.

polygon_label_column str

Name of the column containing room label text.

polygon_label_target str

Name of the output column to store aggregated polygon labels.

floor_filter str

Prefix used to select labels belonging to a specific floor.

excluded_room_numbers list[str]

List of room numbers to exclude from labelling.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
@dataclass(frozen=True)
class PolygonExtractionConfig:
    """
    Configuration for DXF polygon extraction and labelling.

    Attributes
    ----------
    polygon_layer_name : str
        Name of the DXF layer containing polygon boundary linework.
    label_layer_name : str
        Name of the DXF layer containing room label point geometries.
    polygon_label_column : str
        Name of the column containing room label text.
    polygon_label_target : str
        Name of the output column to store aggregated polygon labels.
    floor_filter : str
        Prefix used to select labels belonging to a specific floor.
    excluded_room_numbers : list[str]
        List of room numbers to exclude from labelling.

    """

    polygon_layer_name: str
    label_layer_name: str
    polygon_label_column: str
    polygon_label_target: str
    floor_filter: str
    excluded_room_numbers: list[str]

PolygonMergeConfig dataclass

Configuration for merging labelled polygons into one target polygon.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
156
157
158
159
160
161
@dataclass(frozen=True)
class PolygonMergeConfig:
    """Configuration for merging labelled polygons into one target polygon."""

    target_label: str
    source_labels: list[str]

PolygonSplitConfig dataclass

Configuration for splitting one polygon at known floorplan boundaries.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
139
140
141
142
143
144
145
@dataclass(frozen=True)
class PolygonSplitConfig:
    """Configuration for splitting one polygon at known floorplan boundaries."""

    selector_point: XY
    cut_lines: list[CutLine]
    regions: list[PolygonSplitRegionConfig]

PolygonSplitRegionConfig dataclass

Explicit label for one region produced by a configured polygon split.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
131
132
133
134
135
136
@dataclass(frozen=True)
class PolygonSplitRegionConfig:
    """Explicit label for one region produced by a configured polygon split."""

    label: str
    seed_point: XY

attach_open_boundary_doors(labelled_polygons, spans, polygon_label_target, door_column='doors')

Add configured open-boundary spans to the existing room door column.

Exact CAD-derived matches are deduplicated. A partial or enclosing overlap with a CAD-derived segment is rejected because it would create ambiguous connectivity on the same room interface.

Parameters:

Name Type Description Default
labelled_polygons GeoDataFrame

Final labelled room polygons, optionally containing attached doors.

required
spans list[OpenBoundarySpan]

Canonical spans constructed from the final room boundaries.

required
polygon_label_target str

Column containing the unique room labels.

required
door_column str

Column containing serialised [x1, y1, x2, y2] door segments.

"doors"

Returns:

Type Description
GeoDataFrame

Copy of labelled_polygons with open-boundary spans attached to both configured rooms. The spans are also retained in an openings column for dedicated YAML serialisation. door_count and open_boundary_count are recalculated for every room. Attachment provenance is stored in result.attrs["open_boundary_attachment_report"].

Raises:

Type Description
ValueError

If a span cannot be serialised or partially overlaps an existing door segment.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
def attach_open_boundary_doors(
    labelled_polygons: gpd.GeoDataFrame,
    spans: list[OpenBoundarySpan],
    polygon_label_target: str,
    door_column: str = "doors",
) -> gpd.GeoDataFrame:
    """
    Add configured open-boundary spans to the existing room door column.

    Exact CAD-derived matches are deduplicated. A partial or enclosing overlap
    with a CAD-derived segment is rejected because it would create ambiguous
    connectivity on the same room interface.

    Parameters
    ----------
    labelled_polygons : geopandas.GeoDataFrame
        Final labelled room polygons, optionally containing attached doors.
    spans : list[OpenBoundarySpan]
        Canonical spans constructed from the final room boundaries.
    polygon_label_target : str
        Column containing the unique room labels.
    door_column : str, default "doors"
        Column containing serialised ``[x1, y1, x2, y2]`` door segments.

    Returns
    -------
    geopandas.GeoDataFrame
        Copy of ``labelled_polygons`` with open-boundary spans attached to
        both configured rooms. The spans are also retained in an ``openings``
        column for dedicated YAML serialisation. ``door_count`` and
        ``open_boundary_count`` are recalculated for every room. Attachment
        provenance is stored in ``result.attrs["open_boundary_attachment_report"]``.

    Raises
    ------
    ValueError
        If a span cannot be serialised or partially overlaps an existing
        door segment.

    """
    if not spans:
        return labelled_polygons

    result = labelled_polygons.copy()
    source_attrs = dict(result.attrs)
    if door_column not in result.columns:
        result[door_column] = [[] for _ in range(len(result))]
    else:
        result[door_column] = result[door_column].apply(
            lambda value: (
                [list(door) for door in value] if isinstance(value, list) else []
            )
        )
    result["openings"] = result.get(
        "openings",
        pd.Series([[] for _ in range(len(result))], index=result.index),
    ).apply(
        lambda value: (
            [list(opening) for opening in value] if isinstance(value, list) else []
        )
    )

    room_indices: dict[str, object] = {}
    for span in spans:
        for room_label in span.rooms:
            matches = result.index[result[polygon_label_target] == room_label]
            if len(matches) != 1:
                msg = (
                    "Open-boundary room label must identify exactly one final "
                    f"polygon; label {room_label!r} found {len(matches)}"
                )
                raise ValueError(msg)
            room_indices[room_label] = matches[0]

    open_boundary_counts = dict.fromkeys(result.index, 0)
    attachment_report: list[dict[str, object]] = []
    for span in spans:
        door_quad = _line_to_xyxy(span.geometry)
        if door_quad is None:
            msg = f"Open-boundary span for room pair {span.rooms!r} is not linear"
            raise ValueError(msg)
        span_key = _canonical_line_key(span.geometry)

        room_attachments: list[dict[str, str]] = []
        for room_label in span.rooms:
            room_index = room_indices[room_label]
            room_doors = result.loc[room_index, door_column]
            attached = _append_open_boundary_door(
                room_doors,
                span,
                door_quad,
                span_key,
                room_label,
            )
            room_openings = result.loc[room_index, "openings"]
            if door_quad not in room_openings:
                room_openings.append(door_quad.copy())
            open_boundary_counts[room_index] += 1
            room_attachments.append(
                {
                    "room": room_label,
                    "status": "attached" if attached else "deduplicated",
                }
            )

        attachment_report.append(
            {
                "rooms": list(span.rooms),
                "door_xyxy": door_quad.copy(),
                "attached_room_count": len(span.rooms),
                "deduplicated_room_count": sum(
                    item["status"] == "deduplicated" for item in room_attachments
                ),
                "room_attachments": room_attachments,
            }
        )

    result["open_boundary_count"] = [
        open_boundary_counts.get(room_index, 0) for room_index in result.index
    ]
    result["door_count"] = result[door_column].apply(len)
    result.attrs.update(source_attrs)
    result.attrs["open_boundary_spans"] = spans
    result.attrs["open_boundary_attachment_report"] = attachment_report
    return result

attach_room_doors(labelled_polygons, doors, config=None)

Project CAD door geometry onto room boundaries and attach the segments.

Source geometries belonging to the same door are combined using config.entity_col. Candidate spans are projected onto straight exterior segments of the final room polygons. The preferred projection is the segment shared by the greatest number of rooms, with projected length used as a secondary ranking criterion.

Parameters:

Name Type Description Default
labelled_polygons GeoDataFrame

GeoDataFrame containing room polygon geometries. The index is used as the room identifier.

required
doors GeoDataFrame

GeoDataFrame containing one or more CAD geometries per physical door.

required
config DoorAttachmentConfig

Configuration controlling the join predicate, column names, and output column name.

DoorAttachmentConfig()

Returns:

Type Description
GeoDataFrame

Copy of labelled_polygons with an added column config.out_col. Each value is a list of [x1, y1, x2, y2] lists. Rooms with no doors contain an empty list. Attachment diagnostics are stored in result.attrs["door_attachment_report"].

Raises:

Type Description
KeyError

If required columns are missing from either input GeoDataFrame.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
def attach_room_doors(
    labelled_polygons: gpd.GeoDataFrame,
    doors: gpd.GeoDataFrame,
    config: DoorAttachmentConfig | None = None,
) -> gpd.GeoDataFrame:
    """
    Project CAD door geometry onto room boundaries and attach the segments.

    Source geometries belonging to the same door are combined using
    ``config.entity_col``. Candidate spans are projected onto straight
    exterior segments of the final room polygons. The preferred projection is
    the segment shared by the greatest number of rooms, with projected length
    used as a secondary ranking criterion.

    Parameters
    ----------
    labelled_polygons : geopandas.GeoDataFrame
        GeoDataFrame containing room polygon geometries. The index is used as
        the room identifier.
    doors : geopandas.GeoDataFrame
        GeoDataFrame containing one or more CAD geometries per physical door.
    config : DoorAttachmentConfig, default DoorAttachmentConfig()
        Configuration controlling the join predicate, column names, and output
        column name.

    Returns
    -------
    geopandas.GeoDataFrame
        Copy of ``labelled_polygons`` with an added column ``config.out_col``.
        Each value is a list of ``[x1, y1, x2, y2]`` lists. Rooms with no doors
        contain an empty list. Attachment diagnostics are stored in
        ``result.attrs["door_attachment_report"]``.

    Raises
    ------
    KeyError
        If required columns are missing from either input GeoDataFrame.

    """
    if config is None:
        config = DoorAttachmentConfig()

    required_doors_cols = {config.entity_col, "geometry"}
    missing = required_doors_cols.difference(doors.columns)
    if missing:
        msg = f"doors is missing required columns: {sorted(missing)}"
        raise KeyError(msg)

    if "geometry" not in labelled_polygons.columns:
        msg = "labelled_polygons must have a 'geometry' column"
        raise KeyError(msg)

    source_attrs = dict(labelled_polygons.attrs)
    combined_doors = _combine_door_geometries(doors, config)
    projected_doors = _project_doors_onto_room_boundaries(
        combined_doors,
        labelled_polygons,
        config,
    )

    valid_rows = [
        {
            "_room_idx": room_id,
            config.entity_col: door[config.entity_col],
            "door_xyxy": door["door_xyxy"],
        }
        for _, door in projected_doors.iterrows()
        for room_id in door["projected_room_ids"]
    ]
    valid = pd.DataFrame(valid_rows)

    if valid.empty:
        doors_by_room = pd.Series(name=config.out_col, dtype=object)
        attachment_counts = pd.Series(name="attached_room_count", dtype=int)
    else:
        attachments = (
            valid.groupby(["_room_idx", config.entity_col], sort=False)["door_xyxy"]
            .first()
            .reset_index()
        )
        doors_by_room = (
            attachments.groupby("_room_idx", sort=False)["door_xyxy"]
            .apply(list)
            .rename(config.out_col)
        )
        attachment_counts = (
            valid.groupby(config.entity_col)["_room_idx"]
            .nunique()
            .rename("attached_room_count")
        )

    unresolved_doors = (
        combined_doors[[config.entity_col]]
        .merge(
            attachment_counts,
            left_on=config.entity_col,
            right_index=True,
            how="left",
        )
        .fillna({"attached_room_count": 0})
    )

    unresolved_doors["door_attachment_warning"] = (
        unresolved_doors["attached_room_count"].astype(int) > config.max_attached_rooms
    ) | (unresolved_doors["attached_room_count"].astype(int) == 0)

    result = labelled_polygons.copy()
    result = result.join(doors_by_room, how="left")
    result[config.out_col] = result[config.out_col].apply(
        lambda v: v if isinstance(v, list) else []
    )

    result["door_count"] = result[config.out_col].apply(len)

    result.attrs.update(source_attrs)
    result.attrs["door_attachment_report"] = unresolved_doors.to_dict(orient="records")

    return result

config_from_yaml(path)

Load extraction configuration from a YAML file.

Expected YAML structure

polygons: polygon_layer_name: ... label_layer_name: ... polygon_label_column: ... polygon_label_target: ... floor_filter: ... excluded_room_numbers: [...]

doors: # optional layer_name: DOORS entity_col: EntityHandle x_col: x y_col: y out_col: doors predicate: intersects

shared_walls: # optional enabled: true min_gap: 50 max_gap: 130 angle_tolerance_degrees: 2 min_overlap_ratio: 0.75 min_overlap_length: 250 canonical_line: midline

open_boundaries: # optional tolerance: 1.0e-6 min_length: 0.0 pairs: - rooms: [ROOM_A, ROOM_B] selector_point: [x, y] # optional

polygon_splits: # optional - selector_point: [x, y] cut_lines: - [x1, y1, x2, y2] regions: - label: ROOM_CODE seed_point: [x, y]

polygon_additions: # optional - label: CORRIDOR coordinates: - [x, y]

polygon_merges: # optional - target_label: ROOM_CODE source_labels: [CORRIDOR]

Parameters:

Name Type Description Default
path Path

Path to the YAML configuration file.

required

Returns:

Type Description
ExtractionConfig

Parsed and validated configuration object.

Raises:

Type Description
TypeError

If the YAML structure is invalid.

KeyError

If required keys are missing.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
def config_from_yaml(path: Path) -> ExtractionConfig:
    """
    Load extraction configuration from a YAML file.

    Expected YAML structure
    -----------------------

    polygons:
      polygon_layer_name: ...
      label_layer_name: ...
      polygon_label_column: ...
      polygon_label_target: ...
      floor_filter: ...
      excluded_room_numbers: [...]

    doors:                       # optional
      layer_name: DOORS
      entity_col: EntityHandle
      x_col: x
      y_col: y
      out_col: doors
      predicate: intersects

    shared_walls:                # optional
      enabled: true
      min_gap: 50
      max_gap: 130
      angle_tolerance_degrees: 2
      min_overlap_ratio: 0.75
      min_overlap_length: 250
      canonical_line: midline

    open_boundaries:             # optional
      tolerance: 1.0e-6
      min_length: 0.0
      pairs:
        - rooms: [ROOM_A, ROOM_B]
          selector_point: [x, y] # optional

    polygon_splits:              # optional
      - selector_point: [x, y]
        cut_lines:
          - [x1, y1, x2, y2]
        regions:
          - label: ROOM_CODE
            seed_point: [x, y]

    polygon_additions:           # optional
      - label: CORRIDOR
        coordinates:
          - [x, y]

    polygon_merges:              # optional
      - target_label: ROOM_CODE
        source_labels: [CORRIDOR]

    Parameters
    ----------
    path : pathlib.Path
        Path to the YAML configuration file.

    Returns
    -------
    ExtractionConfig
        Parsed and validated configuration object.

    Raises
    ------
    TypeError
        If the YAML structure is invalid.
    KeyError
        If required keys are missing.

    """
    with path.open(encoding="utf-8") as f:
        data: Any = yaml.safe_load(f)

    if not isinstance(data, dict):
        msg = "YAML configuration must be a mapping"
        raise TypeError(msg)

    if "polygons" not in data:
        msg = "Missing required 'polygons' configuration block"
        raise KeyError(msg)

    polygons_cfg = PolygonExtractionConfig(**data["polygons"])

    door_layer_name: str | None = None
    door_config: DoorAttachmentConfig | None = None
    shared_wall_config: SharedWallConfig | None = None
    open_boundary_config: OpenBoundaryConfig | None = None

    if "doors" in data:
        door_block = data["doors"]

        if not isinstance(door_block, dict):
            msg = "'doors' block must be a mapping"
            raise TypeError(msg)

        if "layer_name" not in door_block:
            msg = "'doors.layer_name' is required when doors block is present"
            raise KeyError(msg)

        door_layer_name = str(door_block["layer_name"])

        door_config = DoorAttachmentConfig(
            entity_col=door_block.get("entity_col", "EntityHandle"),
            x_col=door_block.get("x_col", "x"),
            y_col=door_block.get("y_col", "y"),
            out_col=door_block.get("out_col", "doors"),
            predicate=door_block.get("predicate", "intersects"),
        )

    if "shared_walls" in data:
        shared_wall_block = data["shared_walls"]

        if not isinstance(shared_wall_block, dict):
            msg = "'shared_walls' block must be a mapping"
            raise TypeError(msg)

        canonical_line = str(shared_wall_block.get("canonical_line", "midline"))
        if canonical_line not in VALID_SHARED_WALL_CANONICAL_LINES:
            valid_values = ", ".join(sorted(VALID_SHARED_WALL_CANONICAL_LINES))
            msg = (
                "'shared_walls.canonical_line' must be one of "
                f"{valid_values}; got {canonical_line!r}"
            )
            raise ValueError(msg)

        shared_wall_config = SharedWallConfig(
            enabled=bool(shared_wall_block.get("enabled", False)),
            min_gap=float(shared_wall_block.get("min_gap", 50.0)),
            max_gap=float(shared_wall_block.get("max_gap", 130.0)),
            angle_tolerance_degrees=float(
                shared_wall_block.get("angle_tolerance_degrees", 2.0)
            ),
            min_overlap_ratio=float(shared_wall_block.get("min_overlap_ratio", 0.75)),
            min_overlap_length=float(
                shared_wall_block.get("min_overlap_length", 250.0)
            ),
            canonical_line=canonical_line,
        )

    if "open_boundaries" in data:
        open_boundary_config = _parse_open_boundaries(data["open_boundaries"])

    polygon_splits, polygon_additions, polygon_merges = _parse_polygon_corrections(data)

    return ExtractionConfig(
        polygons=polygons_cfg,
        door_layer_name=door_layer_name,
        doors=door_config,
        shared_walls=shared_wall_config,
        open_boundaries=open_boundary_config,
        polygon_splits=polygon_splits,
        polygon_additions=polygon_additions,
        polygon_merges=polygon_merges,
    )

construct_open_boundaries(labelled_polygons, config, polygon_label_target)

Construct canonical spans for explicitly configured wallless boundaries.

The input polygons must be the final corrected and shared-wall-normalised geometries. Each configured pair is snapped within config.tolerance before boundary intersection. Each pair accepts one non-zero, straight span unless explicitly configured to retain multiple straight spans.

Parameters:

Name Type Description Default
labelled_polygons GeoDataFrame

Final labelled room polygons.

required
config OpenBoundaryConfig

Explicit room pairs and geometric acceptance thresholds.

required
polygon_label_target str

Column containing the unique room labels.

required

Returns:

Type Description
list[OpenBoundarySpan]

Canonical spans in the same deterministic order as config.pairs.

Raises:

Type Description
ValueError

If a configured pair has no unique valid shared span, participates in a third room, or fails the configured geometry checks.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
def construct_open_boundaries(
    labelled_polygons: gpd.GeoDataFrame,
    config: OpenBoundaryConfig,
    polygon_label_target: str,
) -> list[OpenBoundarySpan]:
    """
    Construct canonical spans for explicitly configured wallless boundaries.

    The input polygons must be the final corrected and shared-wall-normalised
    geometries. Each configured pair is snapped within ``config.tolerance``
    before boundary intersection. Each pair accepts one non-zero, straight span
    unless explicitly configured to retain multiple straight spans.

    Parameters
    ----------
    labelled_polygons : geopandas.GeoDataFrame
        Final labelled room polygons.
    config : OpenBoundaryConfig
        Explicit room pairs and geometric acceptance thresholds.
    polygon_label_target : str
        Column containing the unique room labels.

    Returns
    -------
    list[OpenBoundarySpan]
        Canonical spans in the same deterministic order as ``config.pairs``.

    Raises
    ------
    ValueError
        If a configured pair has no unique valid shared span, participates in
        a third room, or fails the configured geometry checks.

    """
    _validate_open_boundary_room_labels(
        labelled_polygons,
        config,
        polygon_label_target,
    )

    return [
        OpenBoundarySpan(rooms=pair.rooms, geometry=span)
        for pair in config.pairs
        for span in _construct_open_boundary_spans(
            labelled_polygons,
            pair,
            config,
            polygon_label_target,
        )
    ]

extract_polygons(input_dxf_path, config)

Extract labelled polygons from a DXF floorplan.

The DXF file is read into a GeoDataFrame, polygon geometries are generated from linework, and room labels are filtered and attached. Configured polygon corrections are applied before optional shared-wall normalisation and canonical door projection.

Parameters:

Name Type Description Default
input_dxf_path Path

Path to the input DXF file.

required
config ExtractionConfig

Configuration object controlling layer names, label filtering, and output column names.

required

Returns:

Type Description
GeoDataFrame

Labelled room polygons with extraction metadata. The columns include has_label and needs_review. Door and shared-wall columns are included when their corresponding configuration blocks are enabled.

Source code in src/floorplan_extractor/dxf_polygon_extraction.py
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
def extract_polygons(
    input_dxf_path: Path,
    config: ExtractionConfig,
) -> gpd.GeoDataFrame:
    """
    Extract labelled polygons from a DXF floorplan.

    The DXF file is read into a GeoDataFrame, polygon geometries are generated
    from linework, and room labels are filtered and attached. Configured
    polygon corrections are applied before optional shared-wall
    normalisation and canonical door projection.

    Parameters
    ----------
    input_dxf_path : pathlib.Path
        Path to the input DXF file.
    config : ExtractionConfig
        Configuration object controlling layer names, label filtering, and output column
        names.

    Returns
    -------
    geopandas.GeoDataFrame
        Labelled room polygons with extraction metadata. The columns include
        ``has_label`` and ``needs_review``. Door and shared-wall columns are
        included when their corresponding configuration blocks are enabled.

    """
    gdf = gpd.read_file(input_dxf_path)

    polygons = _generate_polygons(gdf, config.polygons.polygon_layer_name)
    polygons = _apply_polygon_splits(polygons, config.polygon_splits)
    polygons = _apply_polygon_additions(polygons, config.polygon_additions)

    room_numbers = _generate_room_numbers(
        gdf,
        config.polygons.label_layer_name,
        config.polygons.floor_filter,
        config.polygons.polygon_label_column,
        config.polygons.excluded_room_numbers,
    )

    labelled_polygons = _attach_polygon_labels(
        polygons,
        room_numbers,
        config.polygons.polygon_label_column,
        config.polygons.polygon_label_target,
    )
    labelled_polygons = _apply_split_region_labels(
        labelled_polygons,
        config.polygon_splits,
        config.polygons.polygon_label_target,
    )
    labelled_polygons = _apply_addition_labels(
        labelled_polygons,
        config.polygon_additions,
        config.polygons.polygon_label_target,
    )
    labelled_polygons = _apply_polygon_merges(
        labelled_polygons,
        config.polygon_merges,
        config.polygons.polygon_label_target,
    )

    if config.shared_walls and config.shared_walls.enabled:
        labelled_polygons = normalise_shared_walls(
            labelled_polygons,
            config.shared_walls,
        )

    open_boundary_spans: list[OpenBoundarySpan] = []
    if config.open_boundaries:
        open_boundary_spans = construct_open_boundaries(
            labelled_polygons,
            config.open_boundaries,
            config.polygons.polygon_label_target,
        )
        labelled_polygons.attrs["open_boundary_spans"] = open_boundary_spans

    if config.door_layer_name and config.doors:
        doors = _generate_doors(gdf, config.door_layer_name)
        labelled_polygons = attach_room_doors(
            labelled_polygons,
            doors,
            config.doors,
        )

    if open_boundary_spans:
        door_column = config.doors.out_col if config.doors else "doors"
        labelled_polygons = attach_open_boundary_doors(
            labelled_polygons,
            open_boundary_spans,
            config.polygons.polygon_label_target,
            door_column,
        )

    labelled_polygons["has_label"] = labelled_polygons[
        config.polygons.polygon_label_target
    ].notna()

    labelled_polygons["needs_review"] = (
        ~labelled_polygons["has_label"]
        | labelled_polygons.get("label_ambiguous", False)
        | (labelled_polygons.get("door_count", 0) == 0)
        | labelled_polygons.get("shared_wall_review", False)
    )

    return labelled_polygons