import_dxf のソースコード

"""
build123d import dxf

name: import_dxf.py
by:   Gumyr
date: November 10th, 2024

desc:
    This python module imports a DXF file as build123d objects.

license:

    Copyright 2024 Gumyr

    Licensed under the Apache License, Version 2.0 (the "License");
    you may not use this file except in compliance with the License.
    You may obtain a copy of the License at

        http://www.apache.org/licenses/LICENSE-2.0

    Unless required by applicable law or agreed to in writing, software
    distributed under the License is distributed on an "AS IS" BASIS,
    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    See the License for the specific language governing permissions and
    limitations under the License.

"""

import math
import warnings
from io import BytesIO, StringIO, TextIOBase
from os import PathLike
from typing import BinaryIO, Callable, TextIO, cast

import ezdxf
from ezdxf.entities import DXFGraphic
from ezdxf.entities.boundary_paths import (
    ArcEdge,
    EdgePath,
    EllipseEdge,
    LineEdge,
    PolylinePath,
    SplineEdge,
)

from build123d.build_enums import TextAlign
from build123d.geometry import TOLERANCE, Axis, Pos, Vector, VectorLike
from build123d.objects_curve import (
    BSpline,
    CenterArc,
    EllipticalCenterArc,
    Line,
    SagittaArc,
    Spline,
)
from build123d.objects_sketch import Circle, Polygon, Text
from build123d.operations_generic import scale
from build123d.topology import Edge, Shape, ShapeList, Vertex, Wire

# Unfortunately exdxf is not fully typed
# mypy: disable-error-code="attr-defined"


def process_arc(entity: DXFGraphic) -> CenterArc:
    """Convert ARC"""
    start, _, end = entity.angles(3)
    arc_size = (end - start + 360.0) % 360.0
    return CenterArc(entity.dxf.center, entity.dxf.radius, start, arc_size)


def process_circle(entity: DXFGraphic) -> Circle:
    """Convert CIRCLE"""
    return Circle(entity.dxf.radius).edge().moved(Pos(*entity.dxf.center))


def process_ellipse(entity: DXFGraphic) -> EllipticalCenterArc:
    """Convert ELLIPSE"""
    center = entity.dxf.center
    major_axis = entity.dxf.major_axis
    x_radius = (major_axis[0] ** 2 + major_axis[1] ** 2) ** 0.5
    y_radius = x_radius * entity.dxf.ratio
    rotation = math.degrees(math.atan2(major_axis[1], major_axis[0]))
    start_angle = math.degrees(entity.dxf.start_param)
    arc_size = math.degrees(entity.dxf.end_param - entity.dxf.start_param)
    arc_size = (arc_size + 360.0) % 360.0

    return EllipticalCenterArc(
        center=center,
        x_radius=x_radius,
        y_radius=y_radius,
        start_angle=start_angle,
        arc_size=arc_size,
        rotation=rotation,
    )


def process_insert(entity, doc):
    """Process INSERT by referencing block definition and applying transformations."""
    block_name = entity.dxf.name
    insert_point = Vector(entity.dxf.insert)
    scale_factors = (
        entity.dxf.xscale,
        entity.dxf.yscale,
        entity.dxf.zscale if entity.dxf.zscale != 0 else 1.0,
    )
    rotation_angle = entity.dxf.rotation
    column_count = entity.dxf.column_count
    row_count = entity.dxf.row_count
    column_spacing = entity.dxf.column_spacing
    row_spacing = entity.dxf.row_spacing

    # Retrieve the block definition
    block = doc.blocks.get(block_name)
    block_base_point = Vector(block.block.dxf.base_point)
    transformed_entities = []

    # Process each entity in the block definition
    for block_entity in block:
        for entity_object in _process_entity(block_entity, doc):
            for row_index in range(row_count):
                for column_index in range(column_count):
                    array_offset = Vector(
                        column_index * column_spacing,
                        row_index * row_spacing,
                        0,
                    )
                    array_offset = Vector(
                        array_offset.X * scale_factors[0],
                        array_offset.Y * scale_factors[1],
                        array_offset.Z * scale_factors[2],
                    ).rotate(Axis.Z, rotation_angle)
                    # INSERT places the block definition base point at insert_point.
                    # Normalize block geometry to that local origin before scaling/rotation.
                    transformed_entity = entity_object.translate(-block_base_point)
                    transformed_entity = scale(transformed_entity, scale_factors)
                    transformed_entity = transformed_entity.rotate(
                        Axis.Z, rotation_angle
                    )
                    transformed_entity = transformed_entity.translate(
                        insert_point + array_offset
                    )
                    transformed_entities.append(transformed_entity)

    return ShapeList(transformed_entities)


def process_line(entity: DXFGraphic) -> Line | None:
    """Convert LINE"""
    start, end = Vector(*entity.dxf.start), Vector(*entity.dxf.end)
    if (start - end).length < TOLERANCE:
        warnings.warn("Skipping degenerate LINE", stacklevel=3)
        return None
    return Line(start, end)


def process_lwpolyline(entity: DXFGraphic) -> Edge | Wire | None:
    """Convert LWPOLYLINE"""
    elevation = entity.dxf.elevation
    # elevation could be a vector or just a single value
    try:
        z_value = elevation.z
    except AttributeError:
        z_value = elevation

    points = entity.get_points("xyb")
    if len(points) < 2:
        warnings.warn("Skipping degenerate LWPOLYLINE", stacklevel=3)
        return None
    return _convert_bulge_polyline(points, entity.closed, z_value, "LWPOLYLINE")


def process_point(entity: DXFGraphic) -> Vertex:
    """Convert POINT"""
    point = entity.dxf.location
    return Vertex(point[0], point[1], point[2])


def process_polyline(entity: DXFGraphic) -> Edge | Wire | None:
    """Convert 2D POLYLINE - a collection of LINE and ARC segments."""
    if entity.get_mode() != "AcDb2dPolyline":
        raise ValueError(f"Unsupported POLYLINE mode: {entity.get_mode()}")

    vertices = list(entity.vertices)
    if len(vertices) < 2:
        warnings.warn("Skipping degenerate POLYLINE", stacklevel=3)
        return None
    # Note: the bulge data is not a z value - processed by _convert_bulge_polyline
    points = [
        (
            cast(float, vertex.dxf.location.x),
            cast(float, vertex.dxf.location.y),
            cast(float, vertex.dxf.get("bulge", 0)),
        )
        for vertex in vertices
    ]
    z_value = vertices[0].dxf.location.z
    return _convert_bulge_polyline(points, entity.is_closed, z_value, "POLYLINE")


def _convert_bulge_polyline(
    points: list[tuple[float, float, float]], closed: bool, z_value: float, label: str
) -> Edge | Wire | None:
    """Convert a 2D polyline described by vertices with optional bulge values."""
    edges = []
    segment_count = len(points) if closed else len(points) - 1

    for i in range(segment_count):
        start_data = points[i]
        end_data = points[(i + 1) % len(points)]
        start_point = (start_data[0], start_data[1], z_value)
        end_point = (end_data[0], end_data[1], z_value)
        bulge = start_data[2] if len(start_data) > 2 else 0

        if math.dist(start_point, end_point) < TOLERANCE:
            continue

        if abs(bulge) < TOLERANCE:
            edge = Line(start_point, end_point)
        else:
            sagitta = bulge * math.dist(start_point, end_point) / 2
            edge = SagittaArc(start_point, end_point, sagitta)
        edges.append(edge)

    if not edges:
        warnings.warn(f"Skipping degenerate {label}", stacklevel=3)
        return None
    if len(edges) == 1:
        return edges[0]
    return Wire(edges=edges)


def _convert_hatch_edge(edge, z_value: float) -> Edge:
    """Convert a hatch edge-path edge into build123d geometry."""
    if isinstance(edge, LineEdge):
        return Line(
            (edge.start.x, edge.start.y, z_value), (edge.end.x, edge.end.y, z_value)
        )
    if isinstance(edge, ArcEdge):
        arc_size = edge.end_angle - edge.start_angle
        if not edge.ccw:
            arc_size = -arc_size
        return CenterArc(
            (edge.center.x, edge.center.y, z_value),
            edge.radius,
            start_angle=edge.start_angle,
            arc_size=arc_size,
        )
    if isinstance(edge, EllipseEdge):
        major_axis = Vector(edge.major_axis.x, edge.major_axis.y, 0)
        x_radius = major_axis.length
        rotation = math.degrees(math.atan2(major_axis.Y, major_axis.X))
        arc_size = edge.end_angle - edge.start_angle
        if not edge.ccw:
            arc_size = -arc_size
        return EllipticalCenterArc(
            center=(edge.center.x, edge.center.y, z_value),
            x_radius=x_radius,
            y_radius=x_radius * edge.ratio,
            start_angle=edge.start_angle,
            arc_size=arc_size,
            rotation=rotation,
        )
    if isinstance(edge, SplineEdge):
        return BSpline(
            control_points=[(p[0], p[1], z_value) for p in edge.control_points],
            knots=edge.knot_values,
            degree=edge.degree,
            weights=edge.weights if edge.weights else None,
            periodic=bool(edge.periodic),
        )
    raise ValueError(f"Unsupported HATCH edge type: {type(edge).__name__}")


def process_hatch(entity: DXFGraphic) -> ShapeList[Edge | Wire]:
    """Convert HATCH by importing only its perimeter boundary paths."""
    elevation = entity.dxf.elevation
    try:
        z_value = elevation.z
    except AttributeError:
        z_value = elevation

    boundaries: ShapeList[Edge | Wire] = ShapeList()
    for path in entity.paths.rendering_paths(entity.dxf.hatch_style):
        if isinstance(path, PolylinePath):
            boundary = _convert_bulge_polyline(
                path.vertices, path.is_closed, z_value, "HATCH"
            )
        elif isinstance(path, EdgePath):
            edges = [_convert_hatch_edge(edge, z_value) for edge in path.edges]
            if not edges:
                continue
            boundary = edges[0] if len(edges) == 1 else Wire(edges=edges)
        else:
            warnings.warn(
                f"Unsupported HATCH boundary path: {type(path).__name__}", stacklevel=3
            )
            continue

        if boundary is not None:
            boundaries.append(boundary)

    return boundaries


def process_solid_trace_3dface(entity: DXFGraphic):
    """Convert filled objects - i.e. Faces"""
    # Gather vertices as a list of (x, y, z) tuples
    vertices = []
    for i in range(4):
        # Some entities like SOLID or TRACE may define only 3 vertices, repeating the last one
        # if the fourth vertex is not defined.
        try:
            vertex = entity.dxf.get(f"v{i}")
            vertices.append((vertex.x, vertex.y, vertex.z))
        except AttributeError:
            break

    # Create the Polygon object
    polygon_obj = Polygon(*vertices)
    return polygon_obj


def process_spline(entity: DXFGraphic) -> Edge:
    """Convert SPLINE"""
    control_points = list(entity.control_points)
    fit_points = list(entity.fit_points)
    knots = list(entity.knots)
    weights = list(entity.weights)
    degree = entity.dxf.degree
    periodic = bool(entity.dxf.flags & 2)

    if control_points and knots:
        return BSpline(
            control_points=control_points,
            knots=knots,
            degree=degree,
            weights=weights if weights else None,
            periodic=periodic,
        )

    start_tangent = entity.dxf.get("start_tangent")
    end_tangent = entity.dxf.get("end_tangent")
    if fit_points:
        tangents: tuple[VectorLike, ...] = ()
        if start_tangent is not None and end_tangent is not None:
            tangents = (start_tangent, end_tangent)
        return Spline(*fit_points, tangents=tangents)

    raise ValueError("Unsupported SPLINE entity: missing control points and knots")


def process_text(entity: DXFGraphic) -> Text:
    """Convert TEXT."""
    v_alignment = {
        0: TextAlign.BOTTOM,  # baseline approximation
        1: TextAlign.BOTTOM,
        2: TextAlign.CENTER,
        3: TextAlign.TOP,
    }
    h_alignment = {
        0: TextAlign.LEFT,
        1: TextAlign.CENTER,
        2: TextAlign.RIGHT,
        3: TextAlign.LEFT,  # aligned
        4: TextAlign.CENTER,  # middle
        5: TextAlign.LEFT,  # fit
    }

    position = entity.dxf.insert
    if (entity.dxf.halign != 0 or entity.dxf.valign != 0) and entity.dxf.hasattr(
        "align_point"
    ):
        position = entity.dxf.align_point

    return Text(
        entity.dxf.text,
        font_size=entity.dxf.height,
        rotation=entity.dxf.get("rotation", 0),
        text_align=(
            h_alignment.get(entity.dxf.halign, TextAlign.LEFT),
            v_alignment.get(entity.dxf.valign, TextAlign.BOTTOM),
        ),
    ).moved(Pos(*position))


def process_mtext(entity: DXFGraphic) -> Text:
    """Convert MTEXT."""
    attachment_align = {
        1: (TextAlign.LEFT, TextAlign.TOPFIRSTLINE),
        2: (TextAlign.CENTER, TextAlign.TOPFIRSTLINE),
        3: (TextAlign.RIGHT, TextAlign.TOPFIRSTLINE),
        4: (TextAlign.LEFT, TextAlign.CENTER),
        5: (TextAlign.CENTER, TextAlign.CENTER),
        6: (TextAlign.RIGHT, TextAlign.CENTER),
        7: (TextAlign.LEFT, TextAlign.BOTTOM),
        8: (TextAlign.CENTER, TextAlign.BOTTOM),
        9: (TextAlign.RIGHT, TextAlign.BOTTOM),
    }

    if hasattr(entity, "plain_text"):
        content = entity.plain_text()
    else:
        content = entity.text

    return Text(
        content,
        font_size=entity.dxf.char_height,
        rotation=entity.dxf.get("rotation", 0),
        text_align=attachment_align.get(
            entity.dxf.attachment_point,
            (TextAlign.LEFT, TextAlign.TOPFIRSTLINE),
        ),
    ).moved(Pos(*entity.dxf.insert))


# Dispatch dictionary mapping entity types to processing functions
entity_dispatch: dict[str, Callable] = {
    "3DFACE": process_solid_trace_3dface,
    "ARC": process_arc,
    "CIRCLE": process_circle,
    "ELLIPSE": process_ellipse,
    "HATCH": process_hatch,
    "INSERT": process_insert,
    "LINE": process_line,
    "LWPOLYLINE": process_lwpolyline,
    "MTEXT": process_mtext,
    "POINT": process_point,
    "POLYLINE": process_polyline,
    "SOLID": process_solid_trace_3dface,
    "SPLINE": process_spline,
    "TEXT": process_text,
    "TRACE": process_solid_trace_3dface,
}


def _flatten_import_result(new_object) -> list[Shape]:
    """Normalize handler results into a flat list of shapes."""
    if new_object is None:
        return []
    if isinstance(new_object, ShapeList):
        return [obj for obj in new_object if obj is not None]
    if isinstance(new_object, list):
        return [obj for obj in new_object if obj is not None]
    return [new_object]


def _process_entity(entity, doc) -> list[Shape]:
    """Convert a single DXF entity into zero or more build123d shapes."""
    dxftype = entity.dxftype()
    if dxftype not in entity_dispatch:
        warnings.warn(f"Unable to convert {dxftype}", stacklevel=3)
        return []

    if dxftype == "INSERT":
        new_object = entity_dispatch[dxftype](entity, doc)
    else:
        new_object = entity_dispatch[dxftype](entity)
    return _flatten_import_result(new_object)


[ドキュメント] def import_dxf(dxf_file: str | PathLike | TextIO | BinaryIO) -> ShapeList: """Import shapes from a DXF file Args: dxf_file (str | PathLike | TextIO | BinaryIO): dxf file path or readable stream Raises: DXFStructureError: file not found Returns: ShapeList: build123d objects """ try: if isinstance(dxf_file, (str, PathLike)): doc = ezdxf.readfile(dxf_file) elif isinstance(dxf_file, TextIOBase): doc = ezdxf.read(dxf_file) elif isinstance(dxf_file, BytesIO) or hasattr(dxf_file, "read"): data = dxf_file.read() text = data.decode("latin1") if isinstance(data, bytes) else data doc = ezdxf.read(StringIO(text)) else: raise TypeError(f"Unsupported DXF input type: {type(dxf_file).__name__}") except ezdxf.DXFStructureError as exc: raise ValueError(f"Failed to read {dxf_file}") from exc build123d_objects = [] # Iterate over all entities in the model space for entity in doc.modelspace(): build123d_objects.extend(_process_entity(entity, doc)) return ShapeList(build123d_objects)