From 26213a22e0e6d960adc92277220bf03076617824 Mon Sep 17 00:00:00 2001 From: Greg Hansen Date: Wed, 1 Apr 2026 13:13:55 -0400 Subject: [PATCH] Add AutoCAD DXF data source using ezdxf library Implement a PySpark Python Data Source for reading DXF (Drawing Exchange Format) files. Extracts geometric entities (LINE, CIRCLE, ARC, TEXT, LWPOLYLINE, ELLIPSE, SPLINE, INSERT, etc.) into a tabular schema with entity type, layer, handle, and JSON attributes. Supports layer filtering, recursive file lookup, and configurable partitioning. Closes #23 Co-authored-by: Isaac --- README.md | 2 + dxf/Makefile | 28 + dxf/README.md | 75 + dxf/requirements.txt | 1 + dxf/src/__init__.py | 0 dxf/src/dxf_datasource.py | 366 ++++ dxf/test/sample.dxf | 3218 +++++++++++++++++++++++++++++++ dxf/test/test_dxf_datasource.py | 213 ++ 8 files changed, 3903 insertions(+) create mode 100644 dxf/Makefile create mode 100644 dxf/README.md create mode 100644 dxf/requirements.txt create mode 100644 dxf/src/__init__.py create mode 100644 dxf/src/dxf_datasource.py create mode 100644 dxf/test/sample.dxf create mode 100644 dxf/test/test_dxf_datasource.py diff --git a/README.md b/README.md index 3fc2ffb..b76fbee 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ Introduced in Spark 4.x, Python Data Source API allows you to create PySpark Data Sources leveraging long standing python libraries for handling unique file types or specialized interfaces with spark read, readStream, write and writeStream APIs. | Data Source Name | Purpose | | --- | --- | +| [dxf](dxf/README.md) | Read AutoCAD DXF drawing exchange files | | [zipdcm](zipdcm/README.md) | Read DICOM files from Zip file archives | ## Documentation @@ -27,5 +28,6 @@ Please see our [installation guide](./INSTALL.md) | Datasource | Package | Purpose | License | Source | | ---------- | ---------- | --------------------------------- | ----------- | ------------------------------------ | +| dxf | ezdxf | Python library for DXF files | MIT | https://github.com/mozman/ezdxf | | zipdcm | pydicom | Python api for DICOM files | MIT | https://github.com/pydicom/pydicom | | zipdcm | pylibjpeg | Decoding / Encoding pixel formats | GPLv3 & MIT | https://github.com/pydicom/pylibjpeg | diff --git a/dxf/Makefile b/dxf/Makefile new file mode 100644 index 0000000..b81d4cc --- /dev/null +++ b/dxf/Makefile @@ -0,0 +1,28 @@ +.PHONY: dev test unit style check + +all: clean style test + +clean: ## Remove build artifacts and cache files + rm -rf build/ + rm -rf dist/ + rm -rf *.egg-info/ + rm -rf htmlcov/ + rm -rf .coverage + rm -rf coverage.xml + rm -rf .pytest_cache/ + rm -rf .mypy_cache/ + rm -rf .ruff_cache/ + find . -type d -name __pycache__ -delete + find . -type f -name "*.pyc" -delete + +test: + pip install -r requirements.txt + pytest . + +dev: + pip install -r requirements.txt + +style: + pre-commit run --all-files + +check: style test diff --git a/dxf/README.md b/dxf/README.md new file mode 100644 index 0000000..a332e28 --- /dev/null +++ b/dxf/README.md @@ -0,0 +1,75 @@ +# DXF Data Source + +A PySpark Python Data Source for reading AutoCAD DXF (Drawing Exchange Format) files using the [ezdxf](https://ezdxf.readthedocs.io/en/stable/) library. + +DXF is an open exchange format for CAD systems. This data source extracts geometric entities from DXF files into a tabular format suitable for analysis in Spark. + +## Schema + +| Column | Type | Description | +|--------|------|-------------| +| file_path | STRING | Path to the source DXF file | +| entity_type | STRING | DXF entity type (LINE, CIRCLE, ARC, TEXT, etc.) | +| layer | STRING | The layer the entity belongs to | +| handle | STRING | Unique entity handle within the DXF file | +| attributes | STRING | JSON string containing entity-specific attributes | + +## Supported Entity Types + +| Entity Type | Attributes | +|-------------|-----------| +| LINE | start_x/y/z, end_x/y/z | +| CIRCLE | center_x/y/z, radius | +| ARC | center_x/y/z, radius, start_angle, end_angle | +| POINT | location_x/y/z | +| TEXT | text, insert_x/y/z, height, rotation | +| MTEXT | text, insert_x/y/z, height | +| LWPOLYLINE | points (array), is_closed | +| POLYLINE | vertices (array), is_closed | +| SPLINE | control_points (array), degree, is_closed | +| ELLIPSE | center_x/y/z, major_axis_x/y/z, ratio, start_param, end_param | +| INSERT | block_name, insert_x/y/z, x/y/z_scale, rotation | +| DIMENSION | dimtype, defpoint_x/y/z | +| HATCH | pattern_name, solid_fill | + +## Usage + +```python +# Register the data source +from dxf_datasource import DXFDataSource +spark.dataSource.register(DXFDataSource) + +# Read all entities from DXF files +df = spark.read.format("dxf").option("path", "/path/to/files").load() + +# Filter by layer at read time +df = spark.read.format("dxf") \ + .option("path", "/path/to/files") \ + .option("layerFilter", "walls") \ + .load() + +# Extract attributes from JSON +from pyspark.sql.functions import get_json_object, col + +circles = df.filter(col("entity_type") == "CIRCLE") \ + .select( + "layer", + get_json_object(col("attributes"), "$.center_x").alias("cx"), + get_json_object(col("attributes"), "$.center_y").alias("cy"), + get_json_object(col("attributes"), "$.radius").alias("radius"), + ) +``` + +## Options + +| Option | Default | Description | +|--------|---------|-------------| +| path | (required) | Path to DXF file(s) or directory | +| pathGlobFilter | `*.dxf` | Glob pattern for file matching | +| numPartitions | 4 | Number of partitions to split files across | +| recursiveFileLookup | false | Recursively search subdirectories | +| layerFilter | (all layers) | Filter entities by layer name | + +## Dependencies + +- [ezdxf](https://ezdxf.readthedocs.io/en/stable/) - Python library for reading/writing DXF files diff --git a/dxf/requirements.txt b/dxf/requirements.txt new file mode 100644 index 0000000..004e999 --- /dev/null +++ b/dxf/requirements.txt @@ -0,0 +1 @@ +ezdxf==1.4.2 diff --git a/dxf/src/__init__.py b/dxf/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dxf/src/dxf_datasource.py b/dxf/src/dxf_datasource.py new file mode 100644 index 0000000..168a390 --- /dev/null +++ b/dxf/src/dxf_datasource.py @@ -0,0 +1,366 @@ +import json +import logging +from pathlib import Path +from typing import Iterator, Sequence, Tuple + +from pyspark.sql.datasource import DataSource, DataSourceReader, InputPartition +from pyspark.sql.types import StructType + +logger = logging.getLogger(__name__) + +DEFAULT_numPartitions = 4 +DEFAULT_pathGlobFilter = "*.dxf" + + +def _path_handler(path: str, glob_pattern: str, recursive: bool = False) -> list: + """ + Discover files matching the glob pattern in the given path. + + Args: + path: Path to search for files + glob_pattern: Glob pattern to match files (e.g., "*.dxf") + recursive: If True, recursively search subdirectories using rglob + + Returns: + List of file paths matching the pattern + """ + path_obj = Path(path) + + if path_obj.is_file(): + return [str(path_obj)] + elif path_obj.is_dir(): + if recursive: + files = sorted(path_obj.rglob(glob_pattern)) + else: + files = sorted(path_obj.glob(glob_pattern)) + return [str(f) for f in files if f.is_file()] + else: + parent = path_obj.parent + if parent.exists(): + files = sorted(parent.glob(path_obj.name)) + return [str(f) for f in files if f.is_file()] + return [] + + +class RangePartition(InputPartition): + """ + Range partition for splitting file lists. + """ + + def __init__(self, start: int, end: int): + self.start = start + self.end = end + + def __repr__(self): + return f"RangePartition({self.start}, {self.end})" + + +def _extract_entity_attributes(entity) -> dict: + """ + Extract common and type-specific attributes from a DXF entity. + + Args: + entity: An ezdxf entity object + + Returns: + Dictionary of extracted attributes + """ + attrs = {} + dxftype = entity.dxftype() + + if dxftype == "LINE": + attrs["start_x"] = entity.dxf.start.x + attrs["start_y"] = entity.dxf.start.y + attrs["start_z"] = entity.dxf.start.z + attrs["end_x"] = entity.dxf.end.x + attrs["end_y"] = entity.dxf.end.y + attrs["end_z"] = entity.dxf.end.z + elif dxftype == "CIRCLE": + attrs["center_x"] = entity.dxf.center.x + attrs["center_y"] = entity.dxf.center.y + attrs["center_z"] = entity.dxf.center.z + attrs["radius"] = entity.dxf.radius + elif dxftype == "ARC": + attrs["center_x"] = entity.dxf.center.x + attrs["center_y"] = entity.dxf.center.y + attrs["center_z"] = entity.dxf.center.z + attrs["radius"] = entity.dxf.radius + attrs["start_angle"] = entity.dxf.start_angle + attrs["end_angle"] = entity.dxf.end_angle + elif dxftype == "POINT": + attrs["location_x"] = entity.dxf.location.x + attrs["location_y"] = entity.dxf.location.y + attrs["location_z"] = entity.dxf.location.z + elif dxftype in ("TEXT", "MTEXT"): + if dxftype == "TEXT": + attrs["text"] = entity.dxf.text + attrs["insert_x"] = entity.dxf.insert.x + attrs["insert_y"] = entity.dxf.insert.y + attrs["insert_z"] = entity.dxf.insert.z + attrs["height"] = entity.dxf.height + attrs["rotation"] = entity.dxf.get("rotation", 0.0) + else: + attrs["text"] = entity.text + attrs["insert_x"] = entity.dxf.insert.x + attrs["insert_y"] = entity.dxf.insert.y + attrs["insert_z"] = entity.dxf.insert.z + attrs["height"] = entity.dxf.get("char_height", 1.0) + elif dxftype == "LWPOLYLINE": + points = list(entity.get_points(format="xyseb")) + attrs["points"] = [ + {"x": p[0], "y": p[1], "start_width": p[2], "end_width": p[3], "bulge": p[4]} + for p in points + ] + attrs["is_closed"] = entity.closed + elif dxftype == "POLYLINE": + vertices = [] + for v in entity.vertices: + vertices.append({ + "x": v.dxf.location.x, + "y": v.dxf.location.y, + "z": v.dxf.location.z, + }) + attrs["vertices"] = vertices + attrs["is_closed"] = entity.is_closed + elif dxftype == "SPLINE": + ctrl_points = [{"x": p.x, "y": p.y, "z": p.z} for p in entity.control_points] + attrs["control_points"] = ctrl_points + attrs["degree"] = entity.dxf.degree + attrs["is_closed"] = entity.closed + elif dxftype == "ELLIPSE": + attrs["center_x"] = entity.dxf.center.x + attrs["center_y"] = entity.dxf.center.y + attrs["center_z"] = entity.dxf.center.z + attrs["major_axis_x"] = entity.dxf.major_axis.x + attrs["major_axis_y"] = entity.dxf.major_axis.y + attrs["major_axis_z"] = entity.dxf.major_axis.z + attrs["ratio"] = entity.dxf.ratio + attrs["start_param"] = entity.dxf.start_param + attrs["end_param"] = entity.dxf.end_param + elif dxftype == "INSERT": + attrs["block_name"] = entity.dxf.name + attrs["insert_x"] = entity.dxf.insert.x + attrs["insert_y"] = entity.dxf.insert.y + attrs["insert_z"] = entity.dxf.insert.z + attrs["x_scale"] = entity.dxf.get("xscale", 1.0) + attrs["y_scale"] = entity.dxf.get("yscale", 1.0) + attrs["z_scale"] = entity.dxf.get("zscale", 1.0) + attrs["rotation"] = entity.dxf.get("rotation", 0.0) + elif dxftype == "DIMENSION": + attrs["dimtype"] = entity.dxf.get("dimtype", 0) + if hasattr(entity.dxf, "defpoint"): + attrs["defpoint_x"] = entity.dxf.defpoint.x + attrs["defpoint_y"] = entity.dxf.defpoint.y + attrs["defpoint_z"] = entity.dxf.defpoint.z + elif dxftype == "HATCH": + attrs["pattern_name"] = entity.dxf.get("pattern_name", "") + attrs["solid_fill"] = entity.dxf.get("solid_fill", 1) + + return attrs + + +def _read_dxf_file(file_path: str, layer_filter: str = None) -> Iterator[Tuple]: + """ + Read a single DXF file and yield rows. + + Args: + file_path: Path to the DXF file + layer_filter: Optional layer name to filter entities. If None or "*", read all layers. + + Yields: + Tuples of (file_path, entity_type, layer, handle, attributes_json) + """ + import ezdxf + + logger.debug(f"Reading DXF file: {file_path}, layer_filter: {layer_filter}") + + if layer_filter == "*": + layer_filter = None + + try: + doc = ezdxf.readfile(file_path) + msp = doc.modelspace() + + for entity in msp: + dxftype = entity.dxftype() + layer = entity.dxf.layer if hasattr(entity.dxf, "layer") else "0" + handle = entity.dxf.handle if hasattr(entity.dxf, "handle") else "" + + if layer_filter and layer != layer_filter: + continue + + try: + attrs = _extract_entity_attributes(entity) + except Exception as e: + logger.warning(f"Error extracting attributes for {dxftype} entity: {e}") + attrs = {"error": str(e)} + + attrs_json = json.dumps(attrs) + + yield ( + str(file_path), + dxftype, + layer, + handle, + attrs_json, + ) + except Exception as e: + logger.error(f"Error reading DXF file {file_path}: {e}") + raise + + +def _read_dxf_partition( + partition: RangePartition, paths: list, layer_filter: str = None +) -> Iterator[Tuple]: + """ + Read DXF files for a given partition range. + + Args: + partition: RangePartition with start and end indices + paths: List of file paths to process + layer_filter: Optional layer name to filter entities + + Yields: + Tuples of (file_path, entity_type, layer, handle, attributes_json) + """ + logger.debug( + f"Processing partition: {partition}, paths subset: {paths[partition.start:partition.end]}, layer_filter: {layer_filter}" + ) + + for file_path in paths[partition.start : partition.end]: + yield from _read_dxf_file(file_path, layer_filter=layer_filter) + + +class DXFDataSourceReader(DataSourceReader): + """ + Facilitate reading AutoCAD DXF files. + """ + + def __init__(self, schema, options): + logger.debug(f"DXFDataSourceReader(schema: {schema}, options: {options})") + self.schema: StructType = schema + self.options = options + self.path = self.options.get("path", None) + self.pathGlobFilter = self.options.get("pathGlobFilter", DEFAULT_pathGlobFilter) + self.recursiveFileLookup = bool( + self.options.get("recursiveFileLookup", "false") + ) + self.numPartitions = int( + self.options.get("numPartitions", DEFAULT_numPartitions) + ) + self.layerFilter = self.options.get("layerFilter", None) + + if self.layerFilter == "*": + self.layerFilter = None + + assert self.path is not None, "path option is required" + self.paths = _path_handler( + self.path, self.pathGlobFilter, recursive=self.recursiveFileLookup + ) + + if not self.paths: + logger.warning( + f"No DXF files found at path: {self.path} with filter: {self.pathGlobFilter}" + ) + + if self.recursiveFileLookup: + logger.info( + f"Recursive file lookup enabled, found {len(self.paths)} files" + ) + + if self.layerFilter: + logger.info(f"Layer filter enabled: {self.layerFilter}") + + def partitions(self) -> Sequence[RangePartition]: + """ + Compute 'splits' of the data to read. + + Returns: + List of RangePartition objects + """ + logger.debug( + f"DXFDataSourceReader.partitions({self.numPartitions}, {self.path}, paths: {self.paths})" + ) + + length = len(self.paths) + if length == 0: + return [RangePartition(0, 0)] + + partitions = [] + partition_size_max = int(max(1, length / self.numPartitions)) + start = 0 + + while start < length: + end = min(length, start + partition_size_max) + partitions.append(RangePartition(start, end)) + start = start + partition_size_max + + logger.debug(f"#partitions {len(partitions)} {partitions}") + return partitions + + def read(self, partition: InputPartition) -> Iterator[Tuple]: + """ + Executor level method, performs read by Range Partition. + + Args: + partition: The partition to read + + Returns: + Iterator of tuples (file_path, entity_type, layer, handle, attributes_json) + """ + logger.debug( + f"DXFDataSourceReader.read({partition}, {self.path}, paths: {self.paths}, layerFilter: {self.layerFilter})" + ) + + assert self.path is not None, f"path: {self.path}" + assert self.paths is not None, f"paths: {self.paths}" + + # Library imports must be within the method for executor-level execution + return _read_dxf_partition( + partition, self.paths, layer_filter=self.layerFilter + ) + + +class DXFDataSource(DataSource): + """ + A data source for batch query over AutoCAD DXF files using the ezdxf library. + + Usage: + # Read all entities + df = spark.read.format("dxf").option("path", "/path/to/dxf/files").load() + + # Filter by specific layer at read time + df = spark.read.format("dxf") \\ + .option("path", "/path/to/dxf/files") \\ + .option("layerFilter", "walls") \\ + .load() + + Options: + - path: Path to DXF file(s) or directory (required) + - pathGlobFilter: Glob pattern for file matching (default: "*.dxf") + - numPartitions: Number of partitions to split files across (default: 4) + - recursiveFileLookup: Recursively search subdirectories (default: false) + - layerFilter: Filter entities by layer name (optional). Use "*" or omit to read all layers. + + Schema: + - file_path: STRING - Path to the source DXF file + - entity_type: STRING - DXF entity type (LINE, CIRCLE, ARC, TEXT, etc.) + - layer: STRING - The layer the entity belongs to + - handle: STRING - Unique entity handle within the DXF file + - attributes: STRING - JSON string containing entity-specific attributes + """ + + @classmethod + def name(cls): + datasource_type = "dxf" + logger.debug(f"DXFDataSource.name({datasource_type})") + return datasource_type + + def schema(self): + schema = "file_path STRING, entity_type STRING, layer STRING, handle STRING, attributes STRING" + logger.debug(f"DXFDataSource.schema({schema})") + return schema + + def reader(self, schema: StructType): + logger.debug(f"DXFDataSource.reader({schema}, options={self.options})") + return DXFDataSourceReader(schema, self.options) diff --git a/dxf/test/sample.dxf b/dxf/test/sample.dxf new file mode 100644 index 0000000..3aab5d1 --- /dev/null +++ b/dxf/test/sample.dxf @@ -0,0 +1,3218 @@ + 0 +SECTION + 2 +HEADER + 9 +$ACADVER + 1 +AC1024 + 9 +$ACADMAINTVER + 70 +6 + 9 +$DWGCODEPAGE + 3 +ANSI_1252 + 9 +$LASTSAVEDBY + 1 +ezdxf + 9 +$INSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$EXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$EXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$LIMMIN + 10 +0.0 + 20 +0.0 + 9 +$LIMMAX + 10 +420.0 + 20 +297.0 + 9 +$ORTHOMODE + 70 +0 + 9 +$REGENMODE + 70 +1 + 9 +$FILLMODE + 70 +1 + 9 +$QTEXTMODE + 70 +0 + 9 +$MIRRTEXT + 70 +1 + 9 +$LTSCALE + 40 +1.0 + 9 +$ATTMODE + 70 +1 + 9 +$TEXTSIZE + 40 +2.5 + 9 +$TRACEWID + 40 +1.0 + 9 +$TEXTSTYLE + 7 +Standard + 9 +$CLAYER + 8 +0 + 9 +$CELTYPE + 6 +ByLayer + 9 +$CECOLOR + 62 +256 + 9 +$CELTSCALE + 40 +1.0 + 9 +$DISPSILH + 70 +0 + 9 +$DIMSCALE + 40 +1.0 + 9 +$DIMASZ + 40 +2.5 + 9 +$DIMEXO + 40 +0.625 + 9 +$DIMDLI + 40 +3.75 + 9 +$DIMRND + 40 +0.0 + 9 +$DIMDLE + 40 +0.0 + 9 +$DIMEXE + 40 +1.25 + 9 +$DIMTP + 40 +0.0 + 9 +$DIMTM + 40 +0.0 + 9 +$DIMTXT + 40 +2.5 + 9 +$DIMCEN + 40 +2.5 + 9 +$DIMTSZ + 40 +0.0 + 9 +$DIMTOL + 70 +0 + 9 +$DIMLIM + 70 +0 + 9 +$DIMTIH + 70 +0 + 9 +$DIMTOH + 70 +0 + 9 +$DIMSE1 + 70 +0 + 9 +$DIMSE2 + 70 +0 + 9 +$DIMTAD + 70 +1 + 9 +$DIMZIN + 70 +8 + 9 +$DIMBLK + 1 + + 9 +$DIMASO + 70 +1 + 9 +$DIMSHO + 70 +1 + 9 +$DIMPOST + 1 + + 9 +$DIMAPOST + 1 + + 9 +$DIMALT + 70 +0 + 9 +$DIMALTD + 70 +3 + 9 +$DIMALTF + 40 +0.03937007874 + 9 +$DIMLFAC + 40 +1.0 + 9 +$DIMTOFL + 70 +1 + 9 +$DIMTVP + 40 +0.0 + 9 +$DIMTIX + 70 +0 + 9 +$DIMSOXD + 70 +0 + 9 +$DIMSAH + 70 +0 + 9 +$DIMBLK1 + 1 + + 9 +$DIMBLK2 + 1 + + 9 +$DIMSTYLE + 2 +ISO-25 + 9 +$DIMCLRD + 70 +0 + 9 +$DIMCLRE + 70 +0 + 9 +$DIMCLRT + 70 +0 + 9 +$DIMTFAC + 40 +1.0 + 9 +$DIMGAP + 40 +0.625 + 9 +$DIMJUST + 70 +0 + 9 +$DIMSD1 + 70 +0 + 9 +$DIMSD2 + 70 +0 + 9 +$DIMTOLJ + 70 +0 + 9 +$DIMTZIN + 70 +8 + 9 +$DIMALTZ + 70 +0 + 9 +$DIMALTTZ + 70 +0 + 9 +$DIMUPT + 70 +0 + 9 +$DIMDEC + 70 +2 + 9 +$DIMTDEC + 70 +2 + 9 +$DIMALTU + 70 +2 + 9 +$DIMALTTD + 70 +3 + 9 +$DIMTXSTY + 7 +Standard + 9 +$DIMAUNIT + 70 +0 + 9 +$DIMADEC + 70 +0 + 9 +$DIMALTRND + 40 +0.0 + 9 +$DIMAZIN + 70 +0 + 9 +$DIMDSEP + 70 +44 + 9 +$DIMATFIT + 70 +3 + 9 +$DIMFRAC + 70 +0 + 9 +$DIMLDRBLK + 1 + + 9 +$DIMLUNIT + 70 +2 + 9 +$DIMLWD + 70 +-2 + 9 +$DIMLWE + 70 +-2 + 9 +$DIMTMOVE + 70 +0 + 9 +$DIMFXL + 40 +1.0 + 9 +$DIMFXLON + 70 +0 + 9 +$DIMJOGANG + 40 +0.785398163397 + 9 +$DIMTFILL + 70 +0 + 9 +$DIMTFILLCLR + 70 +0 + 9 +$DIMARCSYM + 70 +0 + 9 +$DIMLTYPE + 6 + + 9 +$DIMLTEX1 + 6 + + 9 +$DIMLTEX2 + 6 + + 9 +$DIMTXTDIRECTION + 70 +0 + 9 +$LUNITS + 70 +2 + 9 +$LUPREC + 70 +4 + 9 +$SKETCHINC + 40 +1.0 + 9 +$FILLETRAD + 40 +10.0 + 9 +$AUNITS + 70 +0 + 9 +$AUPREC + 70 +2 + 9 +$MENU + 1 +. + 9 +$ELEVATION + 40 +0.0 + 9 +$PELEVATION + 40 +0.0 + 9 +$THICKNESS + 40 +0.0 + 9 +$LIMCHECK + 70 +0 + 9 +$CHAMFERA + 40 +0.0 + 9 +$CHAMFERB + 40 +0.0 + 9 +$CHAMFERC + 40 +0.0 + 9 +$CHAMFERD + 40 +0.0 + 9 +$SKPOLY + 70 +0 + 9 +$TDCREATE + 40 +2461132.5501851854 + 9 +$TDUCREATE + 40 +2458532.153996898 + 9 +$TDUPDATE + 40 +2461132.5501851854 + 9 +$TDUUPDATE + 40 +2458532.1544311 + 9 +$TDINDWG + 40 +0.0 + 9 +$TDUSRTIMER + 40 +0.0 + 9 +$USRTIMER + 70 +1 + 9 +$ANGBASE + 50 +0.0 + 9 +$ANGDIR + 70 +0 + 9 +$PDMODE + 70 +0 + 9 +$PDSIZE + 40 +0.0 + 9 +$PLINEWID + 40 +0.0 + 9 +$SPLFRAME + 70 +0 + 9 +$SPLINETYPE + 70 +6 + 9 +$SPLINESEGS + 70 +8 + 9 +$HANDSEED + 5 +39 + 9 +$SURFTAB1 + 70 +6 + 9 +$SURFTAB2 + 70 +6 + 9 +$SURFTYPE + 70 +6 + 9 +$SURFU + 70 +6 + 9 +$SURFV + 70 +6 + 9 +$UCSBASE + 2 + + 9 +$UCSNAME + 2 + + 9 +$UCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$UCSORTHOREF + 2 + + 9 +$UCSORTHOVIEW + 70 +0 + 9 +$UCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$UCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSBASE + 2 + + 9 +$PUCSNAME + 2 + + 9 +$PUCSORG + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSXDIR + 10 +1.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSYDIR + 10 +0.0 + 20 +1.0 + 30 +0.0 + 9 +$PUCSORTHOREF + 2 + + 9 +$PUCSORTHOVIEW + 70 +0 + 9 +$PUCSORGTOP + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBOTTOM + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGLEFT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGRIGHT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGFRONT + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PUCSORGBACK + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$USERI1 + 70 +0 + 9 +$USERI2 + 70 +0 + 9 +$USERI3 + 70 +0 + 9 +$USERI4 + 70 +0 + 9 +$USERI5 + 70 +0 + 9 +$USERR1 + 40 +0.0 + 9 +$USERR2 + 40 +0.0 + 9 +$USERR3 + 40 +0.0 + 9 +$USERR4 + 40 +0.0 + 9 +$USERR5 + 40 +0.0 + 9 +$WORLDVIEW + 70 +1 + 9 +$SHADEDGE + 70 +3 + 9 +$SHADEDIF + 70 +70 + 9 +$TILEMODE + 70 +1 + 9 +$MAXACTVP + 70 +64 + 9 +$PINSBASE + 10 +0.0 + 20 +0.0 + 30 +0.0 + 9 +$PLIMCHECK + 70 +0 + 9 +$PEXTMIN + 10 +1e+20 + 20 +1e+20 + 30 +1e+20 + 9 +$PEXTMAX + 10 +-1e+20 + 20 +-1e+20 + 30 +-1e+20 + 9 +$PLIMMIN + 10 +0.0 + 20 +0.0 + 9 +$PLIMMAX + 10 +420.0 + 20 +297.0 + 9 +$UNITMODE + 70 +0 + 9 +$VISRETAIN + 70 +1 + 9 +$PLINEGEN + 70 +0 + 9 +$PSLTSCALE + 70 +1 + 9 +$TREEDEPTH + 70 +3020 + 9 +$CMLSTYLE + 2 +Standard + 9 +$CMLJUST + 70 +0 + 9 +$CMLSCALE + 40 +20.0 + 9 +$PROXYGRAPHICS + 70 +1 + 9 +$MEASUREMENT + 70 +1 + 9 +$CELWEIGHT +370 +-1 + 9 +$ENDCAPS +280 +0 + 9 +$JOINSTYLE +280 +0 + 9 +$LWDISPLAY +290 +0 + 9 +$INSUNITS + 70 +6 + 9 +$HYPERLINKBASE + 1 + + 9 +$STYLESHEET + 1 + + 9 +$XEDIT +290 +1 + 9 +$CEPSNTYPE +380 +0 + 9 +$PSTYLEMODE +290 +1 + 9 +$FINGERPRINTGUID + 2 +{6653328F-1C99-46E3-AC36-8C878A66F0E4} + 9 +$VERSIONGUID + 2 +{FAED5709-22A4-4D30-A80B-4F31557CBB1D} + 9 +$EXTNAMES +290 +1 + 9 +$PSVPSCALE + 40 +0.0 + 9 +$OLESTARTUP +290 +0 + 9 +$SORTENTS +280 +127 + 9 +$INDEXCTL +280 +0 + 9 +$HIDETEXT +280 +1 + 9 +$XCLIPFRAME +280 +1 + 9 +$HALOGAP +280 +0 + 9 +$OBSCOLOR + 70 +257 + 9 +$OBSLTYPE +280 +0 + 9 +$INTERSECTIONDISPLAY +280 +0 + 9 +$INTERSECTIONCOLOR + 70 +257 + 9 +$DIMASSOC +280 +2 + 9 +$PROJECTNAME + 1 + + 9 +$CAMERADISPLAY +290 +0 + 9 +$LENSLENGTH + 40 +50.0 + 9 +$CAMERAHEIGHT + 40 +0.0 + 9 +$STEPSPERSEC + 40 +24.0 + 9 +$STEPSIZE + 40 +100.0 + 9 +$3DDWFPREC + 40 +2.0 + 9 +$PSOLWIDTH + 40 +0.005 + 9 +$PSOLHEIGHT + 40 +0.08 + 9 +$LOFTANG1 + 40 +1.570796326795 + 9 +$LOFTANG2 + 40 +1.570796326795 + 9 +$LOFTMAG1 + 40 +0.0 + 9 +$LOFTMAG2 + 40 +0.0 + 9 +$LOFTPARAM + 70 +7 + 9 +$LOFTNORMALS +280 +1 + 9 +$LATITUDE + 40 +37.795 + 9 +$LONGITUDE + 40 +-122.394 + 9 +$NORTHDIRECTION + 40 +0.0 + 9 +$TIMEZONE + 70 +-8000 + 9 +$LIGHTGLYPHDISPLAY +280 +1 + 9 +$TILEMODELIGHTSYNCH +280 +1 + 9 +$CMATERIAL +347 +20 + 9 +$SOLIDHIST +280 +0 + 9 +$SHOWHIST +280 +1 + 9 +$DWFFRAME +280 +2 + 9 +$DGNFRAME +280 +2 + 9 +$REALWORLDSCALE +290 +1 + 9 +$INTERFERECOLOR + 62 +256 + 9 +$CSHADOW +280 +0 + 9 +$SHADOWPLANELOCATION + 40 +0.0 + 0 +ENDSEC + 0 +SECTION + 2 +CLASSES + 0 +CLASS + 1 +ACDBDICTIONARYWDFLT + 2 +AcDbDictionaryWithDefault + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SUN + 2 +AcDbSun + 3 +SCENEOE + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +VISUALSTYLE + 2 +AcDbVisualStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MATERIAL + 2 +AcDbMaterial + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +SCALE + 2 +AcDbScale + 3 +ObjectDBX Classes + 90 +1153 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +TABLESTYLE + 2 +AcDbTableStyle + 3 +ObjectDBX Classes + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MLEADERSTYLE + 2 +AcDbMLeaderStyle + 3 +ACDB_MLEADERSTYLE_CLASS + 90 +4095 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +DICTIONARYVAR + 2 +AcDbDictionaryVar + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +CELLSTYLEMAP + 2 +AcDbCellStyleMap + 3 +ObjectDBX Classes + 90 +1152 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +MENTALRAYRENDERSETTINGS + 2 +AcDbMentalRayRenderSettings + 3 +SCENEOE + 90 +1024 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBDETAILVIEWSTYLE + 2 +AcDbDetailViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBSECTIONVIEWSTYLE + 2 +AcDbSectionViewStyle + 3 +ObjectDBX Classes + 90 +1025 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +RASTERVARIABLES + 2 +AcDbRasterVariables + 3 +ISM + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +ACDBPLACEHOLDER + 2 +AcDbPlaceHolder + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +CLASS + 1 +LAYOUT + 2 +AcDbLayout + 3 +ObjectDBX Classes + 90 +0 + 91 +0 +280 +0 +281 +0 + 0 +ENDSEC + 0 +SECTION + 2 +TABLES + 0 +TABLE + 2 +VPORT + 5 +8 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +VPORT + 5 +23 +330 +8 +100 +AcDbSymbolTableRecord +100 +AcDbViewportTableRecord + 2 +*Active + 70 +0 + 10 +0.0 + 20 +0.0 + 11 +1.0 + 21 +1.0 + 12 +0.0 + 22 +0.0 + 13 +0.0 + 23 +0.0 + 14 +0.5 + 24 +0.5 + 15 +0.5 + 25 +0.5 + 16 +0.0 + 26 +0.0 + 36 +1.0 + 17 +0.0 + 27 +0.0 + 37 +0.0 + 40 +1000.0 + 41 +1.34 + 42 +50.0 + 43 +0.0 + 44 +0.0 + 50 +0.0 + 51 +0.0 + 71 +0 + 72 +1000 + 73 +1 + 74 +3 + 75 +0 + 76 +0 + 77 +0 + 78 +0 +281 +0 + 65 +0 +146 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LTYPE + 5 +2 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +LTYPE + 5 +24 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByBlock + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +25 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +ByLayer + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +LTYPE + 5 +26 +330 +2 +100 +AcDbSymbolTableRecord +100 +AcDbLinetypeTableRecord + 2 +Continuous + 70 +0 + 3 + + 72 +65 + 73 +0 + 40 +0.0 + 0 +ENDTAB + 0 +TABLE + 2 +LAYER + 5 +1 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +LAYER + 5 +27 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +0 + 70 +0 + 62 +7 + 6 +Continuous +370 +-3 +390 +13 +347 +21 + 0 +LAYER + 5 +28 +330 +1 +100 +AcDbSymbolTableRecord +100 +AcDbLayerTableRecord + 2 +Defpoints + 70 +0 + 62 +7 + 6 +Continuous +290 +0 +370 +-3 +390 +13 +347 +21 + 0 +ENDTAB + 0 +TABLE + 2 +STYLE + 5 +5 +330 +0 +100 +AcDbSymbolTable + 70 +1 + 0 +STYLE + 5 +29 +330 +5 +100 +AcDbSymbolTableRecord +100 +AcDbTextStyleTableRecord + 2 +Standard + 70 +0 + 40 +0.0 + 41 +1.0 + 50 +0.0 + 71 +0 + 42 +2.5 + 3 +txt + 4 + + 0 +ENDTAB + 0 +TABLE + 2 +VIEW + 5 +7 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +UCS + 5 +6 +330 +0 +100 +AcDbSymbolTable + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +APPID + 5 +3 +330 +0 +100 +AcDbSymbolTable + 70 +3 + 0 +APPID + 5 +2A +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +ACAD + 70 +0 + 0 +APPID + 5 +36 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +HATCHBACKGROUNDCOLOR + 70 +0 + 0 +APPID + 5 +37 +330 +3 +100 +AcDbSymbolTableRecord +100 +AcDbRegAppTableRecord + 2 +EZDXF + 70 +0 + 0 +ENDTAB + 0 +TABLE + 2 +DIMSTYLE + 5 +4 +330 +0 +100 +AcDbSymbolTable + 70 +1 +100 +AcDbDimStyleTable + 0 +DIMSTYLE +105 +2B +330 +4 +100 +AcDbSymbolTableRecord +100 +AcDbDimStyleTableRecord + 2 +Standard + 70 +0 + 40 +1.0 + 41 +2.5 + 42 +0.625 + 43 +3.75 + 44 +1.25 + 45 +0.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +2.5 +140 +2.5 +141 +2.5 +142 +0.0 +143 +0.03937007874 +144 +1.0 +145 +0.0 +146 +1.0 +147 +0.625 +148 +0.0 + 69 +0 + 70 +0 + 71 +0 + 72 +0 + 73 +0 + 74 +0 + 75 +0 + 76 +0 + 77 +1 + 78 +8 + 79 +3 +170 +0 +171 +3 +172 +1 +173 +0 +174 +0 +175 +0 +176 +0 +177 +0 +178 +0 +179 +2 +271 +2 +272 +2 +273 +2 +274 +3 +275 +0 +276 +0 +277 +2 +278 +44 +279 +0 +280 +0 +281 +0 +282 +0 +283 +0 +284 +8 +285 +0 +286 +0 +288 +0 +289 +3 +290 +0 +371 +-2 +372 +-2 + 0 +ENDTAB + 0 +TABLE + 2 +BLOCK_RECORD + 5 +9 +330 +0 +100 +AcDbSymbolTable + 70 +2 + 0 +BLOCK_RECORD + 5 +17 +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Model_Space +340 +1A + 70 +0 +280 +1 +281 +0 + 0 +BLOCK_RECORD + 5 +1B +330 +9 +100 +AcDbSymbolTableRecord +100 +AcDbBlockTableRecord + 2 +*Paper_Space +340 +1E + 70 +0 +280 +1 +281 +0 + 0 +ENDTAB + 0 +ENDSEC + 0 +SECTION + 2 +BLOCKS + 0 +BLOCK + 5 +18 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Model_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Model_Space + 1 + + 0 +ENDBLK + 5 +19 +330 +17 +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +BLOCK + 5 +1C +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockBegin + 2 +*Paper_Space + 70 +0 + 10 +0.0 + 20 +0.0 + 30 +0.0 + 3 +*Paper_Space + 1 + + 0 +ENDBLK + 5 +1D +330 +1B +100 +AcDbEntity + 8 +0 +100 +AcDbBlockEnd + 0 +ENDSEC + 0 +SECTION + 2 +ENTITIES + 0 +LINE + 5 +2F +330 +17 +100 +AcDbEntity + 8 +geometry +100 +AcDbLine + 10 +0.0 + 20 +0.0 + 30 +0.0 + 11 +10.0 + 21 +10.0 + 31 +0.0 + 0 +CIRCLE + 5 +30 +330 +17 +100 +AcDbEntity + 8 +geometry +100 +AcDbCircle + 10 +5.0 + 20 +5.0 + 30 +0.0 + 40 +3.0 + 0 +ARC + 5 +31 +330 +17 +100 +AcDbEntity + 8 +geometry +100 +AcDbCircle + 10 +5.0 + 20 +5.0 + 30 +0.0 + 40 +2.0 +100 +AcDbArc + 50 +0.0 + 51 +90.0 + 0 +POINT + 5 +32 +330 +17 +100 +AcDbEntity + 8 +points +100 +AcDbPoint + 10 +1.0 + 20 +1.0 + 30 +0.0 + 0 +TEXT + 5 +33 +330 +17 +100 +AcDbEntity + 8 +annotations +100 +AcDbText + 10 +0.0 + 20 +0.0 + 30 +0.0 + 40 +0.5 + 1 +Hello DXF +100 +AcDbText + 0 +LWPOLYLINE + 5 +34 +330 +17 +100 +AcDbEntity + 8 +geometry +100 +AcDbPolyline + 90 +4 + 70 +1 + 10 +0.0 + 20 +0.0 + 10 +5.0 + 20 +0.0 + 10 +5.0 + 20 +5.0 + 10 +0.0 + 20 +5.0 + 0 +ELLIPSE + 5 +35 +330 +17 +100 +AcDbEntity + 8 +geometry +100 +AcDbEllipse + 10 +5.0 + 20 +5.0 + 30 +0.0 + 11 +3.0 + 21 +0.0 + 31 +0.0 + 40 +0.5 + 41 +0.0 + 42 +6.283185307179586 + 0 +ENDSEC + 0 +SECTION + 2 +OBJECTS + 0 +DICTIONARY + 5 +A +330 +0 +100 +AcDbDictionary +281 +1 + 3 +ACAD_COLOR +350 +B + 3 +ACAD_GROUP +350 +C + 3 +ACAD_LAYOUT +350 +D + 3 +ACAD_MATERIAL +350 +E + 3 +ACAD_MLEADERSTYLE +350 +F + 3 +ACAD_MLINESTYLE +350 +10 + 3 +ACAD_PLOTSETTINGS +350 +11 + 3 +ACAD_PLOTSTYLENAME +350 +12 + 3 +ACAD_SCALELIST +350 +14 + 3 +ACAD_TABLESTYLE +350 +15 + 3 +ACAD_VISUALSTYLE +350 +16 + 3 +EZDXF_META +350 +2D + 0 +DICTIONARY + 5 +B +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +C +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +D +330 +A +100 +AcDbDictionary +281 +1 + 3 +Model +350 +1A + 3 +Layout1 +350 +1E + 0 +DICTIONARY + 5 +E +330 +A +100 +AcDbDictionary +281 +1 + 3 +ByBlock +350 +1F + 3 +ByLayer +350 +20 + 3 +Global +350 +21 + 0 +DICTIONARY + 5 +F +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +2C + 0 +DICTIONARY + 5 +10 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Standard +350 +22 + 0 +DICTIONARY + 5 +11 +330 +A +100 +AcDbDictionary +281 +1 + 0 +ACDBDICTIONARYWDFLT + 5 +12 +330 +A +100 +AcDbDictionary +281 +1 + 3 +Normal +350 +13 +100 +AcDbDictionaryWithDefault +340 +13 + 0 +ACDBPLACEHOLDER + 5 +13 +330 +12 + 0 +DICTIONARY + 5 +14 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +15 +330 +A +100 +AcDbDictionary +281 +1 + 0 +DICTIONARY + 5 +16 +330 +A +100 +AcDbDictionary +281 +1 + 0 +LAYOUT + 5 +1A +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +1024 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Model + 70 +1 + 71 +0 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +17 + 0 +LAYOUT + 5 +1E +330 +D +100 +AcDbPlotSettings + 1 + + 4 +A3 + 6 + + 40 +7.5 + 41 +20.0 + 42 +7.5 + 43 +20.0 + 44 +420.0 + 45 +297.0 + 46 +0.0 + 47 +0.0 + 48 +0.0 + 49 +0.0 +140 +0.0 +141 +0.0 +142 +1.0 +143 +1.0 + 70 +0 + 72 +1 + 73 +0 + 74 +5 + 7 + + 75 +16 + 76 +0 + 77 +2 + 78 +300 +147 +1.0 +148 +0.0 +149 +0.0 +100 +AcDbLayout + 1 +Layout1 + 70 +1 + 71 +1 + 10 +0.0 + 20 +0.0 + 11 +420.0 + 21 +297.0 + 12 +0.0 + 22 +0.0 + 32 +0.0 + 14 +1e+20 + 24 +1e+20 + 34 +1e+20 + 15 +-1e+20 + 25 +-1e+20 + 35 +-1e+20 +146 +0.0 + 13 +0.0 + 23 +0.0 + 33 +0.0 + 16 +1.0 + 26 +0.0 + 36 +0.0 + 17 +0.0 + 27 +1.0 + 37 +0.0 + 76 +1 +330 +1B + 0 +MATERIAL + 5 +1F +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByBlock + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +20 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +ByLayer + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MATERIAL + 5 +21 +102 +{ACAD_REACTORS +330 +E +102 +} +330 +E +100 +AcDbMaterial + 1 +Global + 2 + + 70 +0 + 40 +1.0 + 71 +1 + 41 +1.0 + 91 +-1023410177 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 44 +0.5 + 73 +0 + 45 +1.0 + 46 +1.0 + 77 +1 + 4 + + 78 +1 + 79 +1 +170 +1 + 48 +1.0 +171 +1 + 6 + +172 +1 +173 +1 +174 +1 +140 +1.0 +141 +1.0 +175 +1 + 7 + +176 +1 +177 +1 +178 +1 +143 +1.0 +179 +1 + 8 + +270 +1 +271 +1 +272 +1 +145 +1.0 +146 +1.0 +273 +1 + 9 + +274 +1 +275 +1 +276 +1 + 42 +1.0 + 72 +1 + 3 + + 73 +1 + 74 +1 + 75 +1 + 94 +63 + 0 +MLINESTYLE + 5 +22 +102 +{ACAD_REACTORS +330 +10 +102 +} +330 +10 +100 +AcDbMlineStyle + 2 +Standard + 70 +0 + 3 + + 62 +256 + 51 +90.0 + 52 +90.0 + 71 +2 + 49 +0.5 + 62 +256 + 6 +BYLAYER + 49 +-0.5 + 62 +256 + 6 +BYLAYER + 0 +MLEADERSTYLE + 5 +2C +102 +{ACAD_REACTORS +330 +F +102 +} +330 +F +100 +AcDbMLeaderStyle +179 +2 +170 +2 +171 +1 +172 +0 + 90 +2 + 40 +0.0 + 41 +0.0 +173 +1 + 91 +-1056964608 + 92 +-2 +290 +1 + 42 +2.0 +291 +1 + 43 +8.0 + 3 +Standard + 44 +4.0 +300 + +342 +29 +174 +1 +175 +1 +176 +0 +178 +1 + 93 +-1056964608 + 45 +4.0 +292 +0 +297 +0 + 46 +4.0 + 94 +-1056964608 + 47 +1.0 + 49 +1.0 +140 +1.0 +294 +1 +141 +0.0 +177 +0 +142 +1.0 +295 +0 +296 +0 +143 +3.75 +271 +0 +272 +9 +273 +9 + 0 +DICTIONARY + 5 +2D +330 +A +100 +AcDbDictionary +280 +1 +281 +1 + 3 +CREATED_BY_EZDXF +350 +2E + 3 +WRITTEN_BY_EZDXF +350 +38 + 0 +DICTIONARYVAR + 5 +2E +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.3 @ 2026-04-01T17:12:16.572530+00:00 + 0 +DICTIONARYVAR + 5 +38 +330 +2D +100 +DictionaryVariables +280 +0 + 1 +1.4.3 @ 2026-04-01T17:12:16.573974+00:00 + 0 +ENDSEC + 0 +EOF diff --git a/dxf/test/test_dxf_datasource.py b/dxf/test/test_dxf_datasource.py new file mode 100644 index 0000000..f5d7fe7 --- /dev/null +++ b/dxf/test/test_dxf_datasource.py @@ -0,0 +1,213 @@ +""" +Unit tests for the DXF data source. + +Tests verify entity extraction, path handling, layer filtering, +and partition logic without requiring PySpark. +""" + +import json +import sys +import tempfile +import os +from pathlib import Path + +# Add src directory to Python path +sys.path.insert(0, str(Path(__file__).parent.parent / "src")) + + +SAMPLE_DXF = str(Path(__file__).parent / "sample.dxf") + + +def test_path_handler_single_file(): + """Test _path_handler with a single file path.""" + from dxf_datasource import _path_handler + + paths = _path_handler(SAMPLE_DXF, "*.dxf") + assert len(paths) == 1 + assert paths[0] == SAMPLE_DXF + + +def test_path_handler_directory(): + """Test _path_handler with a directory path.""" + from dxf_datasource import _path_handler + + test_dir = str(Path(__file__).parent) + paths = _path_handler(test_dir, "*.dxf") + assert len(paths) >= 1 + assert any("sample.dxf" in p for p in paths) + + +def test_path_handler_recursive(): + """Test _path_handler with recursive lookup.""" + from dxf_datasource import _path_handler + + with tempfile.TemporaryDirectory() as tmpdir: + tmppath = Path(tmpdir) + + # Create nested DXF files + (tmppath / "top.dxf").touch() + subdir = tmppath / "sub" + subdir.mkdir() + (subdir / "nested.dxf").touch() + (tmppath / "readme.txt").touch() + + # Non-recursive: only top level + paths = _path_handler(str(tmppath), "*.dxf", recursive=False) + assert len(paths) == 1 + assert "top.dxf" in paths[0] + + # Recursive: both levels + paths = _path_handler(str(tmppath), "*.dxf", recursive=True) + assert len(paths) == 2 + names = [Path(p).name for p in paths] + assert "top.dxf" in names + assert "nested.dxf" in names + + +def test_path_handler_empty(): + """Test _path_handler with empty directory.""" + from dxf_datasource import _path_handler + + with tempfile.TemporaryDirectory() as tmpdir: + paths = _path_handler(tmpdir, "*.dxf") + assert len(paths) == 0 + + +def test_read_dxf_file(): + """Test reading entities from the sample DXF file.""" + from dxf_datasource import _read_dxf_file + + rows = list(_read_dxf_file(SAMPLE_DXF)) + assert len(rows) == 7, f"Expected 7 entities, got {len(rows)}" + + # Each row should be a 5-tuple + for row in rows: + assert len(row) == 5 + file_path, entity_type, layer, handle, attrs_json = row + assert file_path == SAMPLE_DXF + assert isinstance(entity_type, str) + assert isinstance(layer, str) + assert isinstance(handle, str) + # Verify attrs_json is valid JSON + attrs = json.loads(attrs_json) + assert isinstance(attrs, dict) + + +def test_read_dxf_entity_types(): + """Test that expected entity types are extracted.""" + from dxf_datasource import _read_dxf_file + + rows = list(_read_dxf_file(SAMPLE_DXF)) + entity_types = [row[1] for row in rows] + + assert "LINE" in entity_types + assert "CIRCLE" in entity_types + assert "ARC" in entity_types + assert "POINT" in entity_types + assert "TEXT" in entity_types + assert "LWPOLYLINE" in entity_types + assert "ELLIPSE" in entity_types + + +def test_line_attributes(): + """Test LINE entity attributes are correctly extracted.""" + from dxf_datasource import _read_dxf_file + + rows = list(_read_dxf_file(SAMPLE_DXF)) + line_rows = [r for r in rows if r[1] == "LINE"] + assert len(line_rows) == 1 + + attrs = json.loads(line_rows[0][4]) + assert attrs["start_x"] == 0.0 + assert attrs["start_y"] == 0.0 + assert attrs["end_x"] == 10.0 + assert attrs["end_y"] == 10.0 + + +def test_circle_attributes(): + """Test CIRCLE entity attributes are correctly extracted.""" + from dxf_datasource import _read_dxf_file + + rows = list(_read_dxf_file(SAMPLE_DXF)) + circle_rows = [r for r in rows if r[1] == "CIRCLE"] + assert len(circle_rows) == 1 + + attrs = json.loads(circle_rows[0][4]) + assert attrs["center_x"] == 5.0 + assert attrs["center_y"] == 5.0 + assert attrs["radius"] == 3.0 + + +def test_text_attributes(): + """Test TEXT entity attributes are correctly extracted.""" + from dxf_datasource import _read_dxf_file + + rows = list(_read_dxf_file(SAMPLE_DXF)) + text_rows = [r for r in rows if r[1] == "TEXT"] + assert len(text_rows) == 1 + + attrs = json.loads(text_rows[0][4]) + assert attrs["text"] == "Hello DXF" + assert attrs["height"] == 0.5 + + +def test_lwpolyline_attributes(): + """Test LWPOLYLINE entity attributes are correctly extracted.""" + from dxf_datasource import _read_dxf_file + + rows = list(_read_dxf_file(SAMPLE_DXF)) + poly_rows = [r for r in rows if r[1] == "LWPOLYLINE"] + assert len(poly_rows) == 1 + + attrs = json.loads(poly_rows[0][4]) + assert attrs["is_closed"] is True + assert len(attrs["points"]) == 4 + + +def test_layer_filter(): + """Test layer filtering.""" + from dxf_datasource import _read_dxf_file + + # Filter to geometry layer + rows = list(_read_dxf_file(SAMPLE_DXF, layer_filter="geometry")) + layers = {row[2] for row in rows} + assert layers == {"geometry"} + assert len(rows) == 5 # line, circle, arc, lwpolyline, ellipse + + # Filter to annotations layer + rows = list(_read_dxf_file(SAMPLE_DXF, layer_filter="annotations")) + assert len(rows) == 1 + assert rows[0][1] == "TEXT" + + # Wildcard should return all + rows = list(_read_dxf_file(SAMPLE_DXF, layer_filter="*")) + assert len(rows) == 7 + + +def test_partition_logic(): + """Test RangePartition and partition splitting.""" + from dxf_datasource import RangePartition, _read_dxf_partition + + # Test single partition over all paths + paths = [SAMPLE_DXF] + partition = RangePartition(0, 1) + rows = list(_read_dxf_partition(partition, paths)) + assert len(rows) == 7 + + # Test empty partition + partition = RangePartition(0, 0) + rows = list(_read_dxf_partition(partition, paths)) + assert len(rows) == 0 + + +def test_extract_entity_attributes_unknown(): + """Test that unknown entity types return empty attributes.""" + from dxf_datasource import _extract_entity_attributes + + class MockEntity: + def dxftype(self): + return "UNKNOWN_TYPE" + + entity = MockEntity() + attrs = _extract_entity_attributes(entity) + assert attrs == {}