Merge branch 'gumyr:dev' into fix_issue_1296

This commit is contained in:
javimixet 2026-05-07 23:42:52 +02:00 committed by GitHub
commit f083704d41
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
54 changed files with 93642 additions and 140 deletions

View file

@ -0,0 +1,13 @@
<?xml version='1.0' encoding='utf-8'?>
<svg width="102.089996mm" height="42.089998mm" viewBox="-0.05225 -2.05225 5.1045 2.1045" version="1.1" xmlns="http://www.w3.org/2000/svg">
<g transform="scale(1,-1)" stroke-linecap="round">
<g fill="none" stroke="rgb(0,0,0)" stroke-width="0.004500000180000001">
<path d="M 0.0,0.0 C 1.0,2.0 2.0,2.0 2.75,1.5 C 3.5,1.0 4.0,-0.0 5.0,1.0" />
<circle cx="0.0" cy="0.0" r="0.05" />
<circle cx="1.0" cy="2.0" r="0.05" />
<circle cx="3.0" cy="2.0" r="0.05" />
<circle cx="4.0" cy="0.0" r="0.05" />
<circle cx="5.0" cy="1.0" r="0.05" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 632 B

View file

@ -20,6 +20,7 @@ Cheat Sheet
| :class:`~objects_curve.ArcArcTangentLine`
| :class:`~objects_curve.Bezier`
| :class:`~objects_curve.BlendCurve`
| :class:`~objects_curve.BSpline`
| :class:`~objects_curve.CenterArc`
| :class:`~objects_curve.ConstrainedArcs`
| :class:`~objects_curve.ConstrainedLines`

View file

@ -257,8 +257,11 @@ For example:
2D Importers
============
.. py:module:: importers
.. py:module:: import_dxf
.. autofunction:: import_dxf
.. py:module:: importers
.. autofunction:: import_svg
.. autofunction:: import_svg_as_buildline_code

View file

@ -97,6 +97,13 @@ The following objects all can be used in BuildLine contexts. Note that
+++
Curve blending curvature of two curves
.. grid-item-card:: :class:`~objects_curve.BSpline`
.. image:: assets/example_bspline.svg
+++
B-spline from control points and knot data
.. grid-item-card:: :class:`~objects_curve.CenterArc`
.. image:: assets/center_arc_example.svg
@ -275,6 +282,7 @@ Reference
.. autoclass:: Airfoil
.. autoclass:: Bezier
.. autoclass:: BlendCurve
.. autoclass:: BSpline
.. autoclass:: CenterArc
.. autoclass:: ConstrainedArcs
.. autoclass:: ConstrainedLines

View file

@ -0,0 +1,17 @@
from build123d import *
# from ocp_vscode import show_all
dot = Circle(0.05)
control_points = [(0, 0), (1, 2), (3, 2), (4, 0), (5, 1)]
knots = [0.0, 0.0, 0.0, 0.0, 1.0, 2.0, 2.0, 2.0, 2.0]
spline = BSpline(control_points, knots, degree=3)
s = 100 / max(*spline.bounding_box().size)
svg = ExportSVG(scale=s)
svg.add_shape(spline)
for p in control_points:
svg.add_shape(Pos(*p) * dot.scale(1))
svg.write("assets/example_bspline.svg")
# show_all()

View file

@ -8,6 +8,7 @@ from build123d.build_sketch import *
from build123d.exporters import *
from build123d.geometry import *
from build123d.importers import *
from build123d.import_dxf import import_dxf
from build123d.joints import *
from build123d.mesher import *
from build123d.objects_curve import *
@ -86,6 +87,7 @@ __all__ = [
"Airfoil",
"Bezier",
"BlendCurve",
"BSpline",
"CenterArc",
"ConstrainedArcs",
"ConstrainedLines",
@ -188,6 +190,7 @@ __all__ = [
# Importer functions
"detect_primitives",
"import_brep",
"import_dxf",
"import_step",
"import_stl",
"import_svg",

View file

@ -569,10 +569,7 @@ class Builder(ABC, Generic[ShapeT]):
all_vertices = self.vertices(select)
vertex_count = len(all_vertices)
if vertex_count != 1:
warnings.warn(
f"Found {vertex_count} vertices, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one vertex, found {vertex_count}")
return all_vertices[0]
def edges(self, select: Select = Select.ALL) -> ShapeList[Edge]:
@ -612,10 +609,7 @@ class Builder(ABC, Generic[ShapeT]):
all_edges = self.edges(select)
edge_count = len(all_edges)
if edge_count != 1:
warnings.warn(
f"Found {edge_count} edges, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one edge, found {edge_count}")
return all_edges[0]
def wires(self, select: Select = Select.ALL) -> ShapeList[Wire]:
@ -655,10 +649,7 @@ class Builder(ABC, Generic[ShapeT]):
all_wires = self.wires(select)
wire_count = len(all_wires)
if wire_count != 1:
warnings.warn(
f"Found {wire_count} wires, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one wire, found {wire_count}")
return all_wires[0]
def faces(self, select: Select = Select.ALL) -> ShapeList[Face]:
@ -698,10 +689,7 @@ class Builder(ABC, Generic[ShapeT]):
all_faces = self.faces(select)
face_count = len(all_faces)
if face_count != 1:
warnings.warn(
f"Found {face_count} faces, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one face, found {face_count}")
return all_faces[0]
def solids(self, select: Select = Select.ALL) -> ShapeList[Solid]:
@ -741,10 +729,7 @@ class Builder(ABC, Generic[ShapeT]):
all_solids = self.solids(select)
solid_count = len(all_solids)
if solid_count != 1:
warnings.warn(
f"Found {solid_count} solids, returning first",
stacklevel=2,
)
raise ValueError(f"Expected exactly one solid, found {solid_count}")
return all_solids[0]
def _shapes(

492
src/build123d/import_dxf.py Normal file
View file

@ -0,0 +1,492 @@
"""
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)

View file

@ -440,6 +440,67 @@ class BlendCurve(BaseEdgeObject):
super().__init__(joining_curve, mode=mode)
class BSpline(BaseEdgeObject):
"""Line Object: BSpline
An exact B-spline edge defined directly from control points and knot data.
BSpline creates an exact B-spline from control points, a knot sequence, and
optional weights. Control points define the control polygon that pulls the curve,
but the curve does not generally pass through them. Knots define the parameter-space
structure of the spline: they determine where polynomial spans begin and
end and how smoothly those spans join. Repeated knot values indicate knot multiplicity.
For a spline of degree p, a knot with multiplicity m has continuity
C^(p-m) at that location, so increasing multiplicity reduces smoothness. Repeating the
first and last knots degree + 1 times creates a clamped spline that
starts and ends at the first and last control points. Optional weights create a
rational B-spline, allowing some control points to pull more strongly than
others and enabling exact representation of conic sections.`
Unlike :class:`~build123d.objects_curve.Spline`, which creates an interpolated curve
through a set of points using ``GeomAPI_Interpolate``, ``BSpline`` preserves
the supplied spline definition by building the underlying OCCT
``Geom_BSplineCurve`` from its poles, knot vector, optional weights,
degree, and periodic flag.
Args:
control_points (Iterable[VectorLike]): Control points (poles) defining the
spline shape. These are not generally points on the curve.
knots (Iterable[float]): Knot sequence for the spline. Repeated knot
values are allowed and are converted internally into unique knot
values plus multiplicities as required by OCCT.
degree (int): Polynomial degree of the spline.
weights (Iterable[float] | None, optional): Optional per-control-point
weights for rational B-splines. If omitted, the spline is
non-rational.
periodic (bool, optional): Whether to create a periodic spline. Defaults
to ``False``.
mode (Mode, optional): Builder combination mode. Defaults to ``Mode.ADD``.
"""
def __init__(
self,
control_points: Iterable[VectorLike],
knots: Iterable[float],
degree: int,
weights: Iterable[float] | None = None,
periodic: bool = False,
mode: Mode = Mode.ADD,
):
context: BuildLine | None = BuildLine._get_context(self)
validate_inputs(context, self)
spline = Edge.make_bspline(
WorkplaneList.localize(*control_points),
knots,
degree,
weights=weights,
periodic=periodic,
)
super().__init__(spline, mode=mode)
class CenterArc(BaseEdgeObject):
"""Line Object: Center Arc
@ -1639,6 +1700,11 @@ class FilletPolyline(BaseLineObject):
validate_inputs(context, self)
points = flatten_sequence(*pts)
# Handle user closed polylines
if (Vector(points[0]) - Vector(points[-1])).length < TOLERANCE:
close = True
points.pop(-1)
if len(points) < 2:
raise ValueError("FilletPolyline requires two or more pts")
@ -1659,6 +1725,7 @@ class FilletPolyline(BaseLineObject):
raise ValueError(f"radius {r} must be non-negative")
lines_pts = WorkplaneList.localize(*points)
# Create the polyline
new_edges = [

View file

@ -77,7 +77,7 @@ def deserialize_shape(buffer: bytes) -> TopoDS_Shape:
return downcast(shape)
def serialize_location(location: TopLoc_Location) -> bytes | None:
def serialize_location(location: TopLoc_Location) -> bytearray | None:
"""
Serialize a OCP location, this method can be used to provide
a custom serialization algo for pickle

View file

@ -108,12 +108,12 @@ from .shape_core import (
downcast,
shapetype,
topods_dim,
_make_topods_compound_from_shapes,
)
from .three_d import Mixin3D, Solid
from .two_d import Face, Shell
from .utils import (
_extrude_topods_shape,
_make_topods_compound_from_shapes,
tuplify,
unwrapped_shapetype,
)
@ -593,16 +593,13 @@ class Compound(Mixin3D[TopoDS_Compound]):
middle = self.bounding_box().center()
return middle
def compound(self) -> Compound | None:
def compound(self) -> Compound:
"""Return the Compound"""
shape_list = self.compounds()
entity_count = len(shape_list)
if entity_count > 1:
warnings.warn(
f"Found {entity_count} compounds, returning first",
stacklevel=2,
)
return shape_list[0] if shape_list else None
if entity_count != 1:
raise ValueError(f"Expected exactly one compound, found {entity_count}")
return shape_list[0]
def compounds(self) -> ShapeList[Compound]:
"""compounds - all the compounds in this Shape"""

View file

@ -170,6 +170,7 @@ from OCP.Standard import (
)
from OCP.TColgp import TColgp_Array1OfPnt, TColgp_Array1OfVec, TColgp_HArray1OfPnt
from OCP.TColStd import (
TColStd_Array1OfInteger,
TColStd_Array1OfReal,
TColStd_HArray1OfBoolean,
TColStd_HArray1OfReal,
@ -2471,6 +2472,85 @@ class Edge(Mixin1D[TopoDS_Edge]):
return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge())
@classmethod
def make_bspline(
cls,
control_points: Iterable[VectorLike],
knots: Iterable[float],
degree: int,
weights: Iterable[float] | None = None,
periodic: bool = False,
) -> Edge:
"""Create an exact B-spline edge from control points and knot data.
Args:
control_points (Iterable[VectorLike]): Control points (poles) defining
the spline shape.
knots (Iterable[float]): Knot sequence for the spline. Repeated knot
values are converted to unique knot values plus multiplicities.
degree (int): Polynomial degree of the spline.
weights (Iterable[float] | None, optional): Optional per-control-point
weights for rational B-splines. Defaults to ``None``.
periodic (bool, optional): Whether to create a periodic spline.
Defaults to ``False``.
Raises:
ValueError: B-spline requires at least one knot.
Returns:
Edge: the B-spline edge
"""
knot_list = list(knots)
if not knot_list:
raise ValueError("B-spline requires at least one knot")
point_vectors = [Vector(point) for point in control_points]
unique_knots = [knot_list[0]]
multiplicities = [1]
for knot in knot_list[1:]:
if abs(knot - unique_knots[-1]) <= TOLERANCE:
multiplicities[-1] += 1
else:
unique_knots.append(knot)
multiplicities.append(1)
poles_array = TColgp_Array1OfPnt(1, len(point_vectors))
for index, point in enumerate(point_vectors, start=1):
poles_array.SetValue(index, point.to_pnt())
knots_array = TColStd_Array1OfReal(1, len(unique_knots))
for index, knot in enumerate(unique_knots, start=1):
knots_array.SetValue(index, float(knot))
multiplicities_array = TColStd_Array1OfInteger(1, len(multiplicities))
for index, multiplicity in enumerate(multiplicities, start=1):
multiplicities_array.SetValue(index, multiplicity)
weights_list = list(weights) if weights is not None else []
if weights_list:
weights_array = TColStd_Array1OfReal(1, len(weights_list))
for index, weight in enumerate(weights_list, start=1):
weights_array.SetValue(index, float(weight))
spline_geom = Geom_BSplineCurve(
poles_array,
weights_array,
knots_array,
multiplicities_array,
degree,
periodic,
)
else:
spline_geom = Geom_BSplineCurve(
poles_array,
knots_array,
multiplicities_array,
degree,
periodic,
)
return cls(BRepBuilderAPI_MakeEdge(spline_geom).Edge())
@classmethod
def make_spline_approx(
cls,
@ -3234,32 +3314,36 @@ class Edge(Mixin1D[TopoDS_Edge]):
Edge: trimmed edge
"""
# Get the normalized edge parameters
start_u = Mixin1D._to_param(self, start, "start")
end_u = Mixin1D._to_param(self, end, "end")
start_u, end_u = sorted([start_u, end_u])
start_point = self.position_at(start_u)
# if start_u >= end_u:
# raise ValueError(f"start ({start_u}) must be less than end ({end_u})")
# Convert the normalized edge parameters into curve parameters
comp_curve_start, parm_start, _ = self._occt_param_at(start_u)
_comp_curve_end, parm_end, _ = self._occt_param_at(end_u)
if self._wrapped is None:
raise ValueError("Can't trim empty edge")
self_copy = copy.deepcopy(self)
assert self_copy.wrapped is not None
new_curve = BRep_Tool.Curve_s(
self_copy.wrapped, self.param_at(0), self.param_at(1)
# Rebuild the edge
trimmed_edge = Edge(
BRepBuilderAPI_MakeEdge(
BRep_Tool.Curve_s(
self.wrapped,
comp_curve_start.FirstParameter(),
comp_curve_start.LastParameter(),
),
*sorted([parm_start, parm_end]),
).Edge()
)
parm_start = self.param_at(start_u)
parm_end = self.param_at(end_u)
trimmed_curve = Geom_TrimmedCurve(
new_curve,
parm_start,
parm_end,
# Reverse it if necessary
same_start = (trimmed_edge.position_at(0) - start_point).length < TOLERANCE
same_direction = (
self.tangent_at(start_u).dot(trimmed_edge.tangent_at(0)) > 1 - TOLERANCE
)
new_edge = BRepBuilderAPI_MakeEdge(trimmed_curve).Edge()
return Edge(new_edge)
if same_start and same_direction:
return trimmed_edge
return trimmed_edge.reversed(reconstruct=True)
def trim_to_length(self, start: float | VectorLike, length: float) -> Edge:
"""trim_to_length

View file

@ -116,6 +116,7 @@ from OCP.TopExp import TopExp, TopExp_Explorer
from OCP.TopLoc import TopLoc_Location
from OCP.TopoDS import (
TopoDS,
TopoDS_Builder,
TopoDS_Compound,
TopoDS_Edge,
TopoDS_Face,
@ -818,6 +819,48 @@ class Shape(NodeMixin, Generic[TOPODS]):
calc_function(obj.wrapped, properties)
return properties.Mass()
@overload
@staticmethod
def get_shape_list(
shape: Shape, entity_type: Literal["Vertex"]
) -> ShapeList[Vertex]: ...
@overload
@staticmethod
def get_shape_list(
shape: Shape, entity_type: Literal["Edge"]
) -> ShapeList[Edge]: ...
@overload
@staticmethod
def get_shape_list(
shape: Shape, entity_type: Literal["Wire"]
) -> ShapeList[Wire]: ...
@overload
@staticmethod
def get_shape_list(
shape: Shape, entity_type: Literal["Face"]
) -> ShapeList[Face]: ...
@overload
@staticmethod
def get_shape_list(
shape: Shape, entity_type: Literal["Shell"]
) -> ShapeList[Shell]: ...
@overload
@staticmethod
def get_shape_list(
shape: Shape, entity_type: Literal["Solid"]
) -> ShapeList[Solid]: ...
@overload
@staticmethod
def get_shape_list(
shape: Shape, entity_type: Literal["Compound"]
) -> ShapeList[Compound]: ...
@staticmethod
def get_shape_list(
shape: Shape,
@ -835,25 +878,55 @@ class Shape(NodeMixin, Generic[TOPODS]):
item.topo_parent = shape if shape.topo_parent is None else shape.topo_parent
return shape_list
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Vertex"]) -> Vertex: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Edge"]) -> Edge: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Wire"]) -> Wire: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Face"]) -> Face: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Shell"]) -> Shell: ...
@overload
@staticmethod
def get_single_shape(shape: Shape, entity_type: Literal["Solid"]) -> Solid: ...
@overload
@staticmethod
def get_single_shape(
shape: Shape, entity_type: Literal["Compound"]
) -> Compound: ...
@staticmethod
def get_single_shape(
shape: Shape,
entity_type: Literal[
"Vertex", "Edge", "Wire", "Face", "Shell", "Solid", "Compound"
],
) -> Shape | None:
"""Helper to extract a single entity of a specific type from a shape,
with a warning if count != 1."""
) -> Shape:
"""Return the single entity of the requested type.
Raises:
ValueError: if the number of matching entities is not exactly one.
"""
shape_list = Shape.get_shape_list(shape, entity_type)
entity_count = len(shape_list)
if entity_count == 0:
return None
if entity_count > 1:
warnings.warn(
f"Found {entity_count} {entity_type.lower()}s, returning first",
stacklevel=3,
if entity_count != 1:
raise ValueError(
f"Expected exactly one {entity_type.lower()}, found {entity_count}"
)
return shape_list[0] if shape_list else None
return shape_list[0]
# ---- Instance Methods ----
@ -1104,9 +1177,9 @@ class Shape(NodeMixin, Generic[TOPODS]):
"""Points on two shapes where the distance between them is minimal"""
return self.distance_to_with_closest_points(other)[1:3]
def compound(self) -> Compound | None:
def compound(self) -> Compound:
"""Return the Compound"""
return None
return Shape.get_single_shape(self, "Compound")
def compounds(self) -> ShapeList[Compound]:
"""compounds - all the compounds in this Shape"""
@ -1223,7 +1296,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
yield dist_calc.Value()
def edge(self) -> Edge | None:
def edge(self) -> Edge:
"""Return the Edge"""
return Shape.get_single_shape(self, "Edge")
@ -1240,7 +1313,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
return []
return _topods_entities(self.wrapped, topo_type)
def face(self) -> Face | None:
def face(self) -> Face:
"""Return the Face"""
return Shape.get_single_shape(self, "Face")
@ -1788,7 +1861,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
return self._apply_transform(transformation)
def shell(self) -> Shell | None:
def shell(self) -> Shell:
"""Return the Shell"""
return Shape.get_single_shape(self, "Shell")
@ -1846,7 +1919,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
result = Shape._show_tree(tree[0], show_center)
return result
def solid(self) -> Solid | None:
def solid(self) -> Solid:
"""Return the Solid"""
return Shape.get_single_shape(self, "Solid")
@ -2307,7 +2380,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
self_translated = self.moved(Location(TopLoc_Location(transformation)))
return self_translated
def wire(self) -> Wire | None:
def wire(self) -> Wire:
"""Return the Wire"""
return Shape.get_single_shape(self, "Wire")
@ -2370,21 +2443,45 @@ class Shape(NodeMixin, Generic[TOPODS]):
arg = TopTools_ListOfShape()
for obj in args:
if obj.wrapped is not None:
arg.Append(obj.wrapped)
if obj._wrapped is not None:
arg.Append(obj._wrapped)
tool = TopTools_ListOfShape()
for obj in tools:
if obj.wrapped is not None:
tool.Append(obj.wrapped)
if obj._wrapped is not None:
tool.Append(obj._wrapped)
operation.SetArguments(arg)
operation.SetTools(tool)
# Handle operations with "zero" shapes
topo_result = None
if isinstance(operation, BRepAlgoAPI_Cut):
if tool.IsEmpty():
if arg.Extent() == 1:
topo_result = arg.First()
else:
topo_result = _make_topods_compound_from_shapes(arg)
elif isinstance(operation, BRepAlgoAPI_Fuse):
if tool.IsEmpty():
if arg.Extent() == 1:
topo_result = arg.First()
else:
topo_result = _make_topods_compound_from_shapes(arg)
elif arg.IsEmpty():
if tool.Extent() == 1:
topo_result = tool.First()
else:
topo_result = _make_topods_compound_from_shapes(tool)
elif isinstance(operation, BRepAlgoAPI_Common):
if tool.IsEmpty() or arg.IsEmpty():
return self.__class__()
operation.SetRunParallel(True)
operation.Build()
if topo_result is None:
operation.SetArguments(arg)
operation.SetTools(tool)
topo_result = downcast(operation.Shape())
operation.SetRunParallel(True)
operation.Build()
topo_result = downcast(operation.Shape())
# Clean
if SkipClean.clean:
@ -2501,7 +2598,7 @@ class Shape(NodeMixin, Generic[TOPODS]):
return shape_to_html(self)._repr_html_()
return repr(self)
def vertex(self) -> Vertex | None:
def vertex(self) -> Vertex:
"""Return the Vertex"""
return Shape.get_single_shape(self, "Vertex")
@ -2749,9 +2846,7 @@ class ShapeList(list[T]):
compounds = self.compounds()
compound_count = len(compounds)
if compound_count != 1:
warnings.warn(
f"Found {compound_count} compounds, returning first", stacklevel=2
)
raise ValueError(f"Expected exactly one compound, found {compound_count}")
return compounds[0]
def compounds(self) -> ShapeList[Compound]:
@ -2763,7 +2858,7 @@ class ShapeList(list[T]):
edges = self.edges()
edge_count = len(edges)
if edge_count != 1:
warnings.warn(f"Found {edge_count} edges, returning first", stacklevel=2)
raise ValueError(f"Expected exactly one edge, found {edge_count}")
return edges[0]
def edges(self) -> ShapeList[Edge]:
@ -2775,8 +2870,7 @@ class ShapeList(list[T]):
faces = self.faces()
face_count = len(faces)
if face_count != 1:
msg = f"Found {face_count} faces, returning first"
warnings.warn(msg, stacklevel=2)
raise ValueError(f"Expected exactly one face, found {face_count}")
return faces[0]
def faces(self) -> ShapeList[Face]:
@ -3073,7 +3167,7 @@ class ShapeList(list[T]):
shells = self.shells()
shell_count = len(shells)
if shell_count != 1:
warnings.warn(f"Found {shell_count} shells, returning first", stacklevel=2)
raise ValueError(f"Expected exactly one shell, found {shell_count}")
return shells[0]
def shells(self) -> ShapeList[Shell]:
@ -3085,7 +3179,7 @@ class ShapeList(list[T]):
solids = self.solids()
solid_count = len(solids)
if solid_count != 1:
warnings.warn(f"Found {solid_count} solids, returning first", stacklevel=2)
raise ValueError(f"Expected exactly one solid, found {solid_count}")
return solids[0]
def solids(self) -> ShapeList[Solid]:
@ -3217,9 +3311,7 @@ class ShapeList(list[T]):
vertices = self.vertices()
vertex_count = len(vertices)
if vertex_count != 1:
warnings.warn(
f"Found {vertex_count} vertices, returning first", stacklevel=2
)
raise ValueError(f"Expected exactly one vertex, found {vertex_count}")
return vertices[0]
def vertices(self) -> ShapeList[Vertex]:
@ -3231,7 +3323,7 @@ class ShapeList(list[T]):
wires = self.wires()
wire_count = len(wires)
if wire_count != 1:
warnings.warn(f"Found {wire_count} wires, returning first", stacklevel=2)
raise ValueError(f"Expected exactly one wire, found {wire_count}")
return wires[0]
def wires(self) -> ShapeList[Wire]:
@ -3516,3 +3608,27 @@ def unwrap_topods_compound(
# If there are no elements or more than one element, return TopoDS_Compound
return compound
def _make_topods_compound_from_shapes(
occt_shapes: Iterable[TopoDS_Shape | None],
) -> TopoDS_Compound:
"""Create an OCCT TopoDS_Compound
Create an OCCT TopoDS_Compound object from an iterable of TopoDS_Shape objects
Args:
occt_shapes (Iterable[TopoDS_Shape]): OCCT shapes
Returns:
TopoDS_Compound: OCCT compound
"""
comp = TopoDS_Compound()
comp_builder = TopoDS_Builder()
comp_builder.MakeCompound(comp)
for shape in occt_shapes:
if shape is not None:
comp_builder.Add(comp, shape)
return comp

View file

@ -125,12 +125,12 @@ from .shape_core import (
get_top_level_topods_shapes,
shapetype,
unwrap_topods_compound,
_make_topods_compound_from_shapes,
)
from .two_d import Face, Mixin2D, Shell, sort_wires_by_build_order
from .utils import (
_extrude_topods_shape,
_make_loft,
_make_topods_compound_from_shapes,
find_max_dimension,
)
from .zero_d import Vertex

View file

@ -84,7 +84,14 @@ from OCP.TopoDS import (
)
from build123d.geometry import TOLERANCE, BoundBox, Vector, VectorLike
from .shape_core import Shape, ShapeList, downcast, shapetype, unwrap_topods_compound
from .shape_core import (
Shape,
ShapeList,
downcast,
shapetype,
unwrap_topods_compound,
_make_topods_compound_from_shapes,
)
if TYPE_CHECKING: # pragma: no cover
@ -197,30 +204,6 @@ def _make_loft(
return loft_builder.Shape()
def _make_topods_compound_from_shapes(
occt_shapes: Iterable[TopoDS_Shape | None],
) -> TopoDS_Compound:
"""Create an OCCT TopoDS_Compound
Create an OCCT TopoDS_Compound object from an iterable of TopoDS_Shape objects
Args:
occt_shapes (Iterable[TopoDS_Shape]): OCCT shapes
Returns:
TopoDS_Compound: OCCT compound
"""
comp = TopoDS_Compound()
comp_builder = TopoDS_Builder()
comp_builder.MakeCompound(comp)
for shape in occt_shapes:
if shape is not None:
comp_builder.Add(comp, shape)
return comp
def _make_topods_face_from_wires(
outer_wire: TopoDS_Wire, inner_wires: Iterable[TopoDS_Wire] | None = None
) -> TopoDS_Face:

File diff suppressed because it is too large Load diff

2934
tests/dxf/blocks1.dxf Normal file

File diff suppressed because it is too large Load diff

3576
tests/dxf/blocks2.dxf Normal file

File diff suppressed because it is too large Load diff

4184
tests/dxf/bridge.dxf Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

414
tests/dxf/cube.dxf Normal file
View file

@ -0,0 +1,414 @@
0
SECTION
2
HEADER
999
cube.dxf created by IVREAD.
999
Original data in cube.iv
0
ENDSEC
0
SECTION
2
TABLES
0
ENDSEC
0
SECTION
2
BLOCKS
0
ENDSEC
0
SECTION
2
ENTITIES
0
LINE
8
0
10
-0.5000
20
-0.5000
30
1.000
11
0.5000
21
-0.5000
31
1.000
0
LINE
8
0
10
0.5000
20
-0.5000
30
1.000
11
0.5000
21
0.5000
31
1.000
0
LINE
8
0
10
0.5000
20
0.5000
30
1.000
11
-0.5000
21
0.5000
31
1.000
0
LINE
8
0
10
-0.5000
20
0.5000
30
1.000
11
-0.5000
21
-0.5000
31
1.000
0
LINE
8
0
10
0.5000
20
-0.5000
30
0.0000E+00
11
-0.5000
21
-0.5000
31
0.0000E+00
0
LINE
8
0
10
-0.5000
20
-0.5000
30
0.0000E+00
11
-0.5000
21
0.5000
31
0.0000E+00
0
LINE
8
0
10
-0.5000
20
0.5000
30
0.0000E+00
11
0.5000
21
0.5000
31
0.0000E+00
0
LINE
8
0
10
0.5000
20
0.5000
30
0.0000E+00
11
0.5000
21
-0.5000
31
0.0000E+00
0
LINE
8
0
10
-0.5000
20
-0.5000
30
0.0000E+00
11
-0.5000
21
-0.5000
31
1.000
0
LINE
8
0
10
-0.5000
20
-0.5000
30
1.000
11
-0.5000
21
0.5000
31
1.000
0
LINE
8
0
10
-0.5000
20
0.5000
30
1.000
11
-0.5000
21
0.5000
31
0.0000E+00
0
LINE
8
0
10
-0.5000
20
0.5000
30
0.0000E+00
11
-0.5000
21
-0.5000
31
0.0000E+00
0
LINE
8
0
10
0.5000
20
-0.5000
30
1.000
11
0.5000
21
-0.5000
31
0.0000E+00
0
LINE
8
0
10
0.5000
20
-0.5000
30
0.0000E+00
11
0.5000
21
0.5000
31
0.0000E+00
0
LINE
8
0
10
0.5000
20
0.5000
30
0.0000E+00
11
0.5000
21
0.5000
31
1.000
0
LINE
8
0
10
0.5000
20
0.5000
30
1.000
11
0.5000
21
-0.5000
31
1.000
0
LINE
8
0
10
-0.5000
20
-0.5000
30
0.0000E+00
11
0.5000
21
-0.5000
31
0.0000E+00
0
LINE
8
0
10
0.5000
20
-0.5000
30
0.0000E+00
11
0.5000
21
-0.5000
31
1.000
0
LINE
8
0
10
0.5000
20
-0.5000
30
1.000
11
-0.5000
21
-0.5000
31
1.000
0
LINE
8
0
10
-0.5000
20
-0.5000
30
1.000
11
-0.5000
21
-0.5000
31
0.0000E+00
0
LINE
8
0
10
-0.5000
20
0.5000
30
1.000
11
0.5000
21
0.5000
31
1.000
0
LINE
8
0
10
0.5000
20
0.5000
30
1.000
11
0.5000
21
0.5000
31
0.0000E+00
0
LINE
8
0
10
0.5000
20
0.5000
30
0.0000E+00
11
-0.5000
21
0.5000
31
0.0000E+00
0
LINE
8
0
10
-0.5000
20
0.5000
30
0.0000E+00
11
-0.5000
21
0.5000
31
1.000
0
ENDSEC
0
EOF

200
tests/dxf/diamond.dxf Normal file
View file

@ -0,0 +1,200 @@
0
SECTION
2
ENTITIES
0
LINE
8
0
10
45.00
20
45.00
30
0.0000E+00
11
45.00
21
-45.00
31
0.0000E+00
0
LINE
8
0
10
45.00
20
-45.00
30
0.0000E+00
11
0.0000E+00
21
0.0000E+00
31
0.0000E+00
0
LINE
8
0
10
0.0000E+00
20
0.0000E+00
30
0.0000E+00
11
-45.00
21
45.00
31
0.0000E+00
0
LINE
8
0
10
-45.00
20
45.00
30
0.0000E+00
11
45.00
21
45.00
31
0.0000E+00
0
LINE
8
0
10
45.00
20
45.00
30
0.0000E+00
11
0.0000E+00
21
0.0000E+00
31
-78.00
0
LINE
8
0
10
0.0000E+00
20
0.0000E+00
30
-78.00
11
0.0000E+00
21
0.0000E+00
31
0.0000E+00
0
LINE
8
0
10
0.0000E+00
20
0.0000E+00
30
0.0000E+00
11
-45.00
21
-45.00
31
0.0000E+00
0
LINE
8
0
10
-45.00
20
-45.00
30
0.0000E+00
11
45.00
21
45.00
31
0.0000E+00
0
LINE
8
0
10
45.00
20
-45.00
30
0.0000E+00
11
-45.00
21
-45.00
31
0.0000E+00
0
LINE
8
0
10
-45.00
20
-45.00
30
0.0000E+00
11
-45.00
21
45.00
31
0.0000E+00
0
LINE
8
0
10
-45.00
20
45.00
30
0.0000E+00
11
0.0000E+00
21
0.0000E+00
31
-78.00
0
LINE
8
0
10
0.0000E+00
20
0.0000E+00
30
-78.00
11
45.00
21
-45.00
31
0.0000E+00
0
ENDSEC
0
EOF

2986
tests/dxf/ellipticalarcs.dxf Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

3044
tests/dxf/empty.dxf Normal file

File diff suppressed because it is too large Load diff

2870
tests/dxf/hatches.dxf Normal file

File diff suppressed because it is too large Load diff

2936
tests/dxf/layers.dxf Normal file

File diff suppressed because it is too large Load diff

2946
tests/dxf/lines.dxf Normal file

File diff suppressed because it is too large Load diff

2772
tests/dxf/lwpolylines.dxf Normal file

File diff suppressed because it is too large Load diff

3144
tests/dxf/output.dxf Normal file

File diff suppressed because it is too large Load diff

2720
tests/dxf/points.dxf Normal file

File diff suppressed because it is too large Load diff

1282
tests/dxf/polylines.dxf Normal file

File diff suppressed because it is too large Load diff

1092
tests/dxf/rectangle.dxf Normal file

File diff suppressed because it is too large Load diff

4210
tests/dxf/shaft_simple.dxf Normal file

File diff suppressed because it is too large Load diff

2758
tests/dxf/splineA.dxf Normal file

File diff suppressed because it is too large Load diff

2868
tests/dxf/splines.dxf Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

4132
tests/dxf/test-drawing.dxf Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

3296
tests/dxf/test-sketch.dxf Normal file

File diff suppressed because it is too large Load diff

4082
tests/dxf/test_export.dxf Normal file

File diff suppressed because it is too large Load diff

2832
tests/dxf/texts.dxf Normal file

File diff suppressed because it is too large Load diff

View file

@ -127,7 +127,7 @@ class TestBuilder(unittest.TestCase):
with BuildLine() as l:
CenterArc((0, 0), 1, 0, 90)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one vertex"):
l.vertex()
def test_edge(self):
@ -138,7 +138,7 @@ class TestBuilder(unittest.TestCase):
with BuildSketch() as s:
Rectangle(1, 1)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one edge"):
s.edge()
def test_wire(self):
@ -149,7 +149,7 @@ class TestBuilder(unittest.TestCase):
with BuildPart() as p:
Box(1, 1, 1)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one wire"):
p.wire()
def test_face(self):
@ -160,7 +160,7 @@ class TestBuilder(unittest.TestCase):
with BuildPart() as p:
Box(1, 1, 1)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one face"):
p.face()
def test_solid(self):
@ -171,7 +171,7 @@ class TestBuilder(unittest.TestCase):
with BuildSketch():
Text("Two", 10)
extrude(amount=5)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one solid"):
p.solid()
def test_workplanes_as_list(self):

View file

@ -99,6 +99,22 @@ class BuildLineTests(unittest.TestCase):
self.assertAlmostEqual(bz.wires()[0].length, 225.98661946375782, 5)
self.assertTrue(isinstance(b1, Edge))
def test_bspline(self):
control_points = [(0, 0), (1, 1), (2, 0)]
knots = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
with BuildLine() as bl:
spline = BSpline(control_points, knots, degree=2)
self.assertTrue(isinstance(spline, Edge))
self.assertEqual(spline.geom_type, GeomType.BSPLINE)
self.assertEqual(len(bl.edges()), 1)
self.assertAlmostEqual(bl.edge().start_point(), (0, 0, 0), 5)
self.assertAlmostEqual(bl.edge().end_point(), (2, 0, 0), 5)
with self.assertRaises(ValueError):
BSpline(control_points, knots=[], degree=2)
def test_double_tangent_arc(self):
l1 = Line((10, 0), (30, 20))
l2 = DoubleTangentArc((0, 5), (1, 0), l1)
@ -435,6 +451,15 @@ class BuildLineTests(unittest.TestCase):
)
assert len(p.edges()) > 0
# test FilletPolyline with a user closed shape
l1 = FilletPolyline(
(0, 0), (2, 6), (0, 5), (-2, 6), (0, 0), radius=(0, 0.1, 0, 0)
)
assert all(
sum(v in e.vertices() for e in l1.edges()) == 2 for v in l1.vertices()
)
assert len(l1.edges().filter_by(GeomType.CIRCLE)) == 1
def test_intersecting_line(self):
with BuildLine():
l1 = Line((0, 0), (10, 0))

View file

@ -113,6 +113,22 @@ class TestEdge(unittest.TestCase):
)
self.assertAlmostEqual(spline.end_point(), (3, 0, 0), 5)
def test_make_bspline(self):
control_points = [(0, 0), (1, 1), (2, 0)]
knots = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
spline = Edge.make_bspline(control_points, knots, degree=2)
weighted_spline = Edge.make_bspline(
control_points, knots, degree=2, weights=[1.0, 2.0, 1.0]
)
for edge in [spline, weighted_spline]:
self.assertEqual(edge.geom_type, GeomType.BSPLINE)
self.assertAlmostEqual(edge.start_point(), (0, 0, 0), 5)
self.assertAlmostEqual(edge.end_point(), (2, 0, 0), 5)
self.assertGreater((weighted_spline @ 0.5).Y, (spline @ 0.5).Y)
def test_distribute_locations(self):
line = Edge.make_line((0, 0, 0), (10, 0, 0))
locs = line.distribute_locations(3)

View file

@ -27,12 +27,16 @@ license:
"""
import os
import tempfile
import unittest
from anytree import PreOrderIter
from build123d.exporters3d import export_brep, export_step
from build123d.importers import import_brep, import_step, import_stl
from build123d.geometry import Location
from build123d.mesher import Mesher
from build123d.topology import Solid
from build123d.topology import Compound, Shape, Solid
class TestImportExport(unittest.TestCase):
@ -62,6 +66,54 @@ class TestImportExport(unittest.TestCase):
stl_box = import_stl("test.stl")
self.assertAlmostEqual(stl_box.position, (0, 0, 0), 5)
def test_step_round_trip_preserves_assembly_child_locations(self):
def labeled_node(root: Shape, label: str) -> Shape:
return next(iter(PreOrderIter(root, filter_=lambda n: n.label == label)))
box_a = Solid.make_box(1, 1, 1)
box_a.label = "box_a"
box_a.move(Location((10, 0, 0)))
box_b = Solid.make_box(1, 1, 1)
box_b.label = "box_b"
box_b.move(Location((0, 15, 0), (0, 0, 45)))
cylinder = Solid.make_cylinder(0.5, 2)
cylinder.label = "cylinder"
cylinder.move(Location((0, 0, 5), (90, 0, 0)))
sub_assembly = Compound(label="sub_assembly", children=[box_a, cylinder])
sub_assembly.move(Location((0, 0, 30), (0, 90, 0)))
root_assembly = Compound(label="root_assembly", children=[sub_assembly, box_b])
root_assembly.move(Location((3, 6, 9), (0, 0, 90)))
expected_locations = {
label: labeled_node(root_assembly, label).global_location
for label in ("box_a", "box_b", "cylinder")
}
with tempfile.NamedTemporaryFile(suffix=".step", delete=False) as tmp_step:
step_path = tmp_step.name
try:
export_step(root_assembly, step_path)
reloaded = import_step(step_path)
for label, expected in expected_locations.items():
reloaded_node = labeled_node(reloaded, label)
self.assertAlmostEqual(
reloaded_node.global_location.position,
expected.position,
places=5,
)
self.assertAlmostEqual(
reloaded_node.global_location.orientation,
expected.orientation,
places=5,
)
finally:
os.remove(step_path)
if __name__ == "__main__":
unittest.main()

View file

@ -33,6 +33,7 @@ from random import uniform
from unittest.mock import PropertyMock, patch
import numpy as np
from OCP.BRepAlgoAPI import BRepAlgoAPI_Cut, BRepAlgoAPI_Fuse
from anytree import PreOrderIter
from build123d.build_enums import CenterOf, GeomType, Keep
from build123d.geometry import (
@ -59,6 +60,7 @@ from build123d.topology import (
Vertex,
Wire,
)
from build123d.topology.composite import Part
from build123d.joints import RigidJoint
@ -118,6 +120,44 @@ class TestShape(unittest.TestCase):
self.assertTrue(fuzzy.is_valid)
self.assertAlmostEqual(fuzzy.volume, 2, 5)
def test_boolean_zero_cut(self):
box = Solid.make_box(1, 2, 3)
zero = Solid()
result = box.cut(zero)
self.assertTrue(isinstance(result, Solid))
self.assertTrue(result.is_valid)
self.assertAlmostEqual(result.volume, box.volume, 5)
result = box - zero
self.assertTrue(isinstance(result, Solid))
self.assertTrue(result.is_valid)
self.assertAlmostEqual(result.volume, box.volume, 5)
def test_boolean_zero_fuse(self):
box = Solid.make_box(1, 2, 3)
zero = Solid()
result = box.fuse(zero)
self.assertTrue(isinstance(result, Solid))
self.assertTrue(result.is_valid)
self.assertAlmostEqual(result.volume, box.volume, 5)
result = zero.fuse(box)
self.assertTrue(isinstance(result, Solid))
self.assertTrue(result.is_valid)
self.assertAlmostEqual(result.volume, box.volume, 5)
result = box + zero
self.assertTrue(isinstance(result, Solid))
self.assertTrue(result.is_valid)
self.assertAlmostEqual(result.volume, box.volume, 5)
result = zero + box
self.assertTrue(isinstance(result, Solid))
self.assertTrue(result.is_valid)
self.assertAlmostEqual(result.volume, box.volume, 5)
def test_faces_intersected_by_axis(self):
box = Solid.make_box(1, 1, 1, Plane((0, 0, 1)))
intersected_faces = box.faces_intersected_by_axis(Axis.Z)
@ -339,6 +379,46 @@ class TestShape(unittest.TestCase):
self.assertAlmostEqual(Vector(intersections[0]), (0.5, 0.5, 0), 5)
self.assertAlmostEqual(Vector(intersections[1]), (0.5, 0.5, 1), 5)
def test_boolean_zero_intersection(self):
box = Solid.make_box(1, 2, 3)
zero = Solid()
self.assertIsNone(box.intersect(zero))
self.assertIsNone(zero.intersect(box))
def test_boolean_zero_cut_multi_args(self):
box1 = Solid.make_box(1, 1, 1)
box2 = Solid.make_box(1, 1, 1, Plane((2, 0, 0)))
zero = Solid()
result = box1._bool_op((box1, box2), (zero,), BRepAlgoAPI_Cut())
self.assertTrue(isinstance(result, Part))
self.assertTrue(result.is_valid)
self.assertEqual(len(result.solids()), 2)
self.assertAlmostEqual(result.volume, box1.volume + box2.volume, 5)
def test_boolean_zero_fuse_multi_args(self):
box1 = Solid.make_box(1, 1, 1)
box2 = Solid.make_box(1, 1, 1, Plane((2, 0, 0)))
zero = Solid()
result = box1._bool_op((box1, box2), (zero,), BRepAlgoAPI_Fuse())
self.assertTrue(isinstance(result, Part))
self.assertTrue(result.is_valid)
self.assertEqual(len(result.solids()), 2)
self.assertAlmostEqual(result.volume, box1.volume + box2.volume, 5)
def test_boolean_zero_fuse_multi_tools(self):
box1 = Solid.make_box(1, 1, 1)
box2 = Solid.make_box(1, 1, 1, Plane((2, 0, 0)))
zero = Solid()
result = box1._bool_op((zero,), (box1, box2), BRepAlgoAPI_Fuse())
self.assertTrue(isinstance(result, Part))
self.assertTrue(result.is_valid)
self.assertEqual(len(result.solids()), 2)
self.assertAlmostEqual(result.volume, box1.volume + box2.volume, 5)
def test_clean_error(self):
"""Note that this test is here to alert build123d to changes in bad OCCT clean behavior
with spheres or hemispheres. The extra edge in a sphere seems to be the cause of this.
@ -395,44 +475,44 @@ class TestShape(unittest.TestCase):
def test_vertex(self):
v = Edge.make_circle(1).vertex()
self.assertTrue(isinstance(v, Vertex))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one vertex"):
Wire.make_rect(1, 1).vertex()
def test_edge(self):
e = Edge.make_circle(1).edge()
self.assertTrue(isinstance(e, Edge))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one edge"):
Wire.make_rect(1, 1).edge()
def test_wire(self):
w = Wire.make_circle(1).wire()
self.assertTrue(isinstance(w, Wire))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one wire"):
Solid.make_box(1, 1, 1).wire()
def test_compound(self):
c = Compound.make_text("hello", 10)
self.assertTrue(isinstance(c, Compound))
c2 = Compound.make_text("world", 10)
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one compound"):
Compound(children=[c, c2]).compound()
def test_face(self):
f = Face.make_rect(1, 1)
self.assertTrue(isinstance(f, Face))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one face"):
Solid.make_box(1, 1, 1).face()
def test_shell(self):
s = Solid.make_sphere(1).shell()
self.assertTrue(isinstance(s, Shell))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one shell"):
extrude(Compound.make_text("two", 10), amount=5).shell()
def test_solid(self):
s = Solid.make_sphere(1).solid()
self.assertTrue(isinstance(s, Solid))
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one solid"):
Compound(Solid.make_sphere(1).split(Plane.XY, keep=Keep.BOTH)).solid()
def test_manifold(self):
@ -625,12 +705,18 @@ class TestShape(unittest.TestCase):
self.assertEqual(Vertex(1, 1, 1).shells(), ShapeList())
self.assertEqual(Vertex(1, 1, 1).solids(), ShapeList())
self.assertEqual(Vertex(1, 1, 1).compounds(), ShapeList())
self.assertIsNone(Vertex(1, 1, 1).edge())
self.assertIsNone(Vertex(1, 1, 1).wire())
self.assertIsNone(Vertex(1, 1, 1).face())
self.assertIsNone(Vertex(1, 1, 1).shell())
self.assertIsNone(Vertex(1, 1, 1).solid())
self.assertIsNone(Vertex(1, 1, 1).compound())
with self.assertRaisesRegex(ValueError, "Expected exactly one edge"):
Vertex(1, 1, 1).edge()
with self.assertRaisesRegex(ValueError, "Expected exactly one wire"):
Vertex(1, 1, 1).wire()
with self.assertRaisesRegex(ValueError, "Expected exactly one face"):
Vertex(1, 1, 1).face()
with self.assertRaisesRegex(ValueError, "Expected exactly one shell"):
Vertex(1, 1, 1).shell()
with self.assertRaisesRegex(ValueError, "Expected exactly one solid"):
Vertex(1, 1, 1).solid()
with self.assertRaisesRegex(ValueError, "Expected exactly one compound"):
Vertex(1, 1, 1).compound()
def test_rotate(self):
line = Edge.make_line((0, 0), (1, 0))

View file

@ -323,7 +323,7 @@ class TestShapeList(unittest.TestCase):
sl = ShapeList([Edge.make_circle(1)])
self.assertAlmostEqual(tuple(sl.vertex()), (1, 0, 0), 5)
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one vertex"):
sl.vertex()
self.assertEqual(len(Edge().vertices()), 0)
@ -336,7 +336,7 @@ class TestShapeList(unittest.TestCase):
sl = ShapeList([Edge.make_circle(1)])
self.assertAlmostEqual(sl.edge().length, 2 * 1 * math.pi, 5)
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one edge"):
sl.edge()
def test_wires(self):
@ -348,7 +348,7 @@ class TestShapeList(unittest.TestCase):
sl = ShapeList([Wire.make_circle(1)])
self.assertAlmostEqual(sl.wire().length, 2 * 1 * math.pi, 5)
sl = ShapeList([Face.make_rect(1, 1), Face.make_rect(1, 1, Plane((4, 4)))])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one wire"):
sl.wire()
def test_faces(self):
@ -362,7 +362,7 @@ class TestShapeList(unittest.TestCase):
)
self.assertAlmostEqual(sl.face().area, 2 * 1, 5)
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one face"):
sl.face()
def test_shells(self):
@ -374,7 +374,7 @@ class TestShapeList(unittest.TestCase):
sl = ShapeList([Vertex(1, 1, 1), Solid.make_box(1, 1, 1)])
self.assertAlmostEqual(sl.shell().area, 6 * 1 * 1, 5)
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one shell"):
sl.shell()
def test_solids(self):
@ -384,7 +384,7 @@ class TestShapeList(unittest.TestCase):
def test_solid(self):
sl = ShapeList([Solid.make_box(1, 1, 1), Solid.make_cylinder(1, 1)])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one solid"):
sl.solid()
sl = ShapeList([Solid.make_box(1, 2, 3), Vertex(1, 1, 1)])
self.assertAlmostEqual(sl.solid().volume, 1 * 2 * 3, 5)
@ -396,7 +396,7 @@ class TestShapeList(unittest.TestCase):
def test_compound(self):
sl = ShapeList([Box(1, 1, 1), Cylinder(1, 1)])
with self.assertWarns(UserWarning):
with self.assertRaisesRegex(ValueError, "Expected exactly one compound"):
sl.compound()
sl = ShapeList([Box(1, 2, 3), Vertex(1, 1, 1)])
self.assertAlmostEqual(sl.compound().volume, 1 * 2 * 3, 5)

View file

@ -221,6 +221,22 @@ class TestWire(unittest.TestCase):
t8 = c.trim(0.4, 0.9)
self.assertAlmostEqual(c.length * 0.5, t8.length, 5)
reversed_arc = Edge.make_circle(
3, Plane((5, 3, 0)), start_angle=-90, end_angle=0
).reversed()
explicit_reversed_wire = Wire(
[
Edge.make_line((0, 0), (5, 0)),
reversed_arc,
Edge.make_line((8, 3), (12, 3)),
]
)
trimmed_reversed_wire = explicit_reversed_wire.trim(0.2, 0.8)
self.assertEqual(len(trimmed_reversed_wire.edges()), 3)
self.assertAlmostEqual(trimmed_reversed_wire.length, 8.227433388230814, 5)
self.assertAlmostEqual(trimmed_reversed_wire @ 0, (2.7424777960769386, 0, 0), 5)
self.assertAlmostEqual(trimmed_reversed_wire @ 1, (9.257522203923063, 3, 0), 5)
def test_param_at_point(self):
e = Edge.make_three_point_arc((0, -20), (5, 0), (0, 20))
# Three edges are created 0->0.5->0.75->1.0

574
tests/test_import_dxf.py Normal file
View file

@ -0,0 +1,574 @@
"""
Tests for the DXF importer
name: test_import_dxf.py
by: Gumyr
date: May 7 2026
desc:
This python module tests the dxf importer.
license:
Copyright 2026 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.
"""
from __future__ import annotations
import importlib
from io import BytesIO, StringIO
from pathlib import Path
from types import SimpleNamespace
import pytest
from build123d import import_dxf
from build123d.build_enums import TextAlign
from build123d.objects_curve import (
BSpline,
CenterArc,
EllipticalCenterArc,
Line,
SagittaArc,
)
from build123d.objects_sketch import Polygon, Text
from build123d.topology import Edge, Vertex, Wire
@pytest.fixture(scope="module")
def tests_dir() -> Path:
"""Reference the main tests directory."""
return Path(__file__).resolve().parent
@pytest.fixture(scope="module")
def dxf_dir(tests_dir: Path) -> Path:
"""Reference DXF fixtures stored under the tests directory."""
return tests_dir / "dxf"
@pytest.fixture(scope="module")
def import_dxf_module():
"""Load the importer module so helper functions can be tested directly."""
return importlib.import_module("build123d.import_dxf")
# Input contract
def test_import_empty(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "empty.dxf"))
assert len(result) == 0
def test_import_empty_from_text_stream(dxf_dir: Path):
dxf_text = (dxf_dir / "empty.dxf").read_text(encoding="latin1")
result = import_dxf(StringIO(dxf_text))
assert len(result) == 0
def test_import_empty_from_binary_stream(dxf_dir: Path):
dxf_bytes = (dxf_dir / "empty.dxf").read_bytes()
result = import_dxf(BytesIO(dxf_bytes))
assert len(result) == 0
def test_import_unsupported_input_type():
with pytest.raises(TypeError, match="Unsupported DXF input type"):
import_dxf(1.23) # type: ignore[arg-type]
def test_import_invalid_dxf_raises_value_error():
with pytest.raises(ValueError, match="Failed to read"):
import_dxf(StringIO("not a dxf"))
# Core entity fixtures
def test_import_lines(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "lines.dxf"))
assert len(result) == 11
assert all(isinstance(obj, Line) for obj in result)
def test_import_points(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "points.dxf"))
assert len(result) == 2
assert all(isinstance(obj, Vertex) for obj in result)
assert tuple(result[0]) == (10.0, 20.0, 0.0)
assert tuple(result[1]) == (30.0, 10.0, 0.0)
def test_import_lwpolylines(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "lwpolylines.dxf"))
assert len(result) == 2
assert all(isinstance(obj, Wire) for obj in result)
assert result[0].is_closed
assert len(result[0].edges()) == 4
assert not result[1].is_closed
assert len(result[1].edges()) == 6
def test_import_circles_ellipses_arcs(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "circlesellipsesarcs.dxf"))
assert len(result) == 5
assert sum(isinstance(obj, EllipticalCenterArc) for obj in result) == 2
assert sum(isinstance(obj, CenterArc) for obj in result) == 2
assert sum(type(obj) is Edge for obj in result) == 1
def test_import_single_spline(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "splineA.dxf"))
assert len(result) == 1
assert isinstance(result[0], BSpline)
def test_import_multiple_splines(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "splines.dxf"))
assert len(result) == 2
assert all(isinstance(obj, BSpline) for obj in result)
def test_import_polylines(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "polylines.dxf"))
assert len(result) == 2
assert all(isinstance(obj, Wire) for obj in result)
assert all(obj.is_closed for obj in result)
assert [len(obj.edges()) for obj in result] == [8, 8]
def test_import_rectangle_polyline(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "rectangle.dxf"))
assert len(result) == 1
assert isinstance(result[0], Wire)
assert result[0].is_closed
assert len(result[0].edges()) == 4
def test_import_square_and_circle(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "squareandcircle.dxf"))
assert len(result) == 2
assert sum(type(obj) is Edge for obj in result) == 1
assert sum(isinstance(obj, Wire) for obj in result) == 1
def test_import_hatch_perimeter(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "hatches.dxf"))
assert len(result.wires()) == 1
def test_import_mtext(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "texts.dxf"))
assert len(result) == 2
assert all(isinstance(obj, Text) for obj in result)
assert result[0].text_align == (TextAlign.LEFT, TextAlign.TOPFIRSTLINE)
assert result[1].text_align == (TextAlign.LEFT, TextAlign.BOTTOM)
# Regression fixtures
def test_import_blocks(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "blocks1.dxf"))
assert len(result) == 10
assert all(hasattr(obj, "bounding_box") for obj in result)
def test_import_closed_lwpolyline_block(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "closedlwpolylinebug.dxf"))
assert len(result) == 1
bbox = result[0].bounding_box()
assert round(bbox.min.X, 5) == 30.0
assert round(bbox.min.Y, 5) == 40.0
assert round(bbox.max.X, 5) == 50.0
assert round(bbox.max.Y, 5) == 70.0
def test_import_text_and_block_mtext(dxf_dir: Path):
result = import_dxf(str(dxf_dir / "blocks2.dxf"))
assert len(result) == 14
assert sum(isinstance(obj, Text) for obj in result) == 1
assert sum(type(obj).__name__ in {"Text", "Sketch"} for obj in result) == 3
# Unit branch coverage
def test_process_line_degenerate(import_dxf_module):
entity = SimpleNamespace(dxf=SimpleNamespace(start=(0, 0, 0), end=(0, 0, 0)))
with pytest.warns(UserWarning, match="Skipping degenerate LINE"):
assert import_dxf_module.process_line(entity) is None
def test_process_lwpolyline_degenerate(import_dxf_module):
entity = SimpleNamespace(
dxf=SimpleNamespace(elevation=0.0),
get_points=lambda _: [(0.0, 0.0, 0.0)],
)
with pytest.warns(UserWarning, match="Skipping degenerate LWPOLYLINE"):
assert import_dxf_module.process_lwpolyline(entity) is None
def test_process_polyline_bad_mode(import_dxf_module):
entity = SimpleNamespace(get_mode=lambda: "AcDb3dPolyline")
with pytest.raises(ValueError, match="Unsupported POLYLINE mode"):
import_dxf_module.process_polyline(entity)
def test_process_polyline_degenerate(import_dxf_module):
vertex = SimpleNamespace(
dxf=SimpleNamespace(location=SimpleNamespace(x=0, y=0, z=0))
)
entity = SimpleNamespace(
get_mode=lambda: "AcDb2dPolyline",
vertices=[vertex],
)
with pytest.warns(UserWarning, match="Skipping degenerate POLYLINE"):
assert import_dxf_module.process_polyline(entity) is None
def test_convert_bulge_polyline_single_edge(import_dxf_module):
edge = import_dxf_module._convert_bulge_polyline(
[(0.0, 0.0, 0.0), (1.0, 0.0, 0.0)], False, 0.0, "TEST"
)
assert isinstance(edge, Line)
def test_convert_bulge_polyline_sagitta_arc(import_dxf_module):
edge = import_dxf_module._convert_bulge_polyline(
[(0.0, 0.0, 1.0), (2.0, 0.0, 0.0)], False, 0.0, "TEST"
)
assert isinstance(edge, SagittaArc)
def test_convert_bulge_polyline_degenerate(import_dxf_module):
with pytest.warns(UserWarning, match="Skipping degenerate TEST"):
result = import_dxf_module._convert_bulge_polyline(
[(0.0, 0.0, 0.0), (0.0, 0.0, 0.0)], False, 0.0, "TEST"
)
assert result is None
def test_process_spline_fit_points_fallback(import_dxf_module):
entity = SimpleNamespace(
control_points=[],
fit_points=[(0, 0, 0), (1, 1, 0), (2, 0, 0)],
knots=[],
weights=[],
dxf=SimpleNamespace(
degree=3,
flags=0,
get=lambda key: (1, 0, 0) if key == "start_tangent" else (-1, 0, 0),
),
)
result = import_dxf_module.process_spline(entity)
assert result.geom_type.name == "BSPLINE"
assert tuple(result.start_point()) == (0.0, 0.0, 0.0)
assert tuple(result.end_point()) == (2.0, 0.0, 0.0)
def test_process_spline_invalid(import_dxf_module):
entity = SimpleNamespace(
control_points=[],
fit_points=[],
knots=[],
weights=[],
dxf=SimpleNamespace(degree=3, flags=0, get=lambda _key: None),
)
with pytest.raises(ValueError, match="Unsupported SPLINE entity"):
import_dxf_module.process_spline(entity)
def test_process_text_uses_align_point(import_dxf_module):
entity = SimpleNamespace(
dxf=SimpleNamespace(
text="Aligned",
height=2.0,
halign=1,
valign=3,
insert=(0.0, 0.0, 0.0),
align_point=(5.0, 6.0, 0.0),
get=lambda key, default=0: 0 if key == "rotation" else default,
hasattr=lambda key: key == "align_point",
)
)
result = import_dxf_module.process_text(entity)
assert isinstance(result, Text)
assert tuple(result.location.position) == (5.0, 6.0, 0.0)
assert result.text_align == (TextAlign.CENTER, TextAlign.TOP)
def test_process_hatch_unsupported_path_warning(import_dxf_module):
unsupported_path = object()
entity = SimpleNamespace(
dxf=SimpleNamespace(elevation=0.0, hatch_style=0),
paths=SimpleNamespace(rendering_paths=lambda _style: [unsupported_path]),
)
with pytest.warns(UserWarning, match="Unsupported HATCH boundary path"):
result = import_dxf_module.process_hatch(entity)
assert len(result) == 0
def test_process_hatch_edgepath_single_line(import_dxf_module, monkeypatch):
class FakeLineEdge:
pass
class FakeEdgePath:
pass
monkeypatch.setattr(import_dxf_module, "LineEdge", FakeLineEdge)
monkeypatch.setattr(import_dxf_module, "EdgePath", FakeEdgePath)
line_edge = FakeLineEdge()
line_edge.start = SimpleNamespace(x=0.0, y=0.0)
line_edge.end = SimpleNamespace(x=1.0, y=0.0)
edge_path = FakeEdgePath()
edge_path.edges = [line_edge]
entity = SimpleNamespace(
dxf=SimpleNamespace(elevation=0.0, hatch_style=0),
paths=SimpleNamespace(rendering_paths=lambda _style: [edge_path]),
)
result = import_dxf_module.process_hatch(entity)
assert len(result) == 1
assert isinstance(result[0], Line)
def test_process_hatch_polyline_path(import_dxf_module, monkeypatch):
class FakePolylinePath:
def __init__(self):
self.vertices = [(0.0, 0.0, 0.0), (1.0, 0.0, 0.0), (1.0, 1.0, 0.0)]
self.is_closed = False
monkeypatch.setattr(import_dxf_module, "PolylinePath", FakePolylinePath)
entity = SimpleNamespace(
dxf=SimpleNamespace(elevation=0.0, hatch_style=0),
paths=SimpleNamespace(rendering_paths=lambda _style: [FakePolylinePath()]),
)
result = import_dxf_module.process_hatch(entity)
assert len(result) == 1
assert isinstance(result[0], Wire)
def test_process_hatch_edgepath_empty(import_dxf_module, monkeypatch):
class FakeEdgePath:
def __init__(self):
self.edges = []
monkeypatch.setattr(import_dxf_module, "EdgePath", FakeEdgePath)
entity = SimpleNamespace(
dxf=SimpleNamespace(elevation=0.0, hatch_style=0),
paths=SimpleNamespace(rendering_paths=lambda _style: [FakeEdgePath()]),
)
result = import_dxf_module.process_hatch(entity)
assert len(result) == 0
def test_convert_hatch_edge_arc(import_dxf_module, monkeypatch):
class FakeArcEdge:
pass
monkeypatch.setattr(import_dxf_module, "ArcEdge", FakeArcEdge)
edge = FakeArcEdge()
edge.center = SimpleNamespace(x=1.0, y=2.0)
edge.radius = 3.0
edge.start_angle = 10.0
edge.end_angle = 70.0
edge.ccw = True
result = import_dxf_module._convert_hatch_edge(edge, 0.0)
assert isinstance(result, CenterArc)
assert tuple(result.arc_center) == (1.0, 2.0, 0.0)
def test_convert_hatch_edge_arc_clockwise(import_dxf_module, monkeypatch):
class FakeArcEdge:
pass
monkeypatch.setattr(import_dxf_module, "ArcEdge", FakeArcEdge)
edge = FakeArcEdge()
edge.center = SimpleNamespace(x=1.0, y=2.0)
edge.radius = 3.0
edge.start_angle = 10.0
edge.end_angle = 70.0
edge.ccw = False
result = import_dxf_module._convert_hatch_edge(edge, 0.0)
assert isinstance(result, CenterArc)
assert tuple(result.arc_center) == (1.0, 2.0, 0.0)
assert result.length > 0
def test_convert_hatch_edge_ellipse(import_dxf_module, monkeypatch):
class FakeEllipseEdge:
pass
monkeypatch.setattr(import_dxf_module, "EllipseEdge", FakeEllipseEdge)
edge = FakeEllipseEdge()
edge.center = SimpleNamespace(x=1.0, y=2.0)
edge.major_axis = SimpleNamespace(x=4.0, y=0.0)
edge.ratio = 0.5
edge.start_angle = 0.0
edge.end_angle = 90.0
edge.ccw = True
result = import_dxf_module._convert_hatch_edge(edge, 0.0)
assert isinstance(result, EllipticalCenterArc)
assert tuple(result.arc_center) == (1.0, 2.0, 0.0)
def test_convert_hatch_edge_ellipse_clockwise(import_dxf_module, monkeypatch):
class FakeEllipseEdge:
pass
monkeypatch.setattr(import_dxf_module, "EllipseEdge", FakeEllipseEdge)
edge = FakeEllipseEdge()
edge.center = SimpleNamespace(x=1.0, y=2.0)
edge.major_axis = SimpleNamespace(x=4.0, y=0.0)
edge.ratio = 0.5
edge.start_angle = 0.0
edge.end_angle = 90.0
edge.ccw = False
result = import_dxf_module._convert_hatch_edge(edge, 0.0)
assert isinstance(result, EllipticalCenterArc)
assert tuple(result.arc_center) == (1.0, 2.0, 0.0)
assert result.length > 0
def test_convert_hatch_edge_spline(import_dxf_module, monkeypatch):
class FakeSplineEdge:
pass
monkeypatch.setattr(import_dxf_module, "SplineEdge", FakeSplineEdge)
edge = FakeSplineEdge()
edge.control_points = [(0.0, 0.0), (1.0, 1.0), (2.0, 0.0)]
edge.knot_values = [0.0, 0.0, 0.0, 1.0, 1.0, 1.0]
edge.degree = 2
edge.weights = []
edge.periodic = 0
result = import_dxf_module._convert_hatch_edge(edge, 0.0)
assert isinstance(result, BSpline)
def test_convert_hatch_edge_unsupported(import_dxf_module):
with pytest.raises(ValueError, match="Unsupported HATCH edge type"):
import_dxf_module._convert_hatch_edge(object(), 0.0)
def test_process_solid_trace_3dface(import_dxf_module):
vertices = {
"v0": SimpleNamespace(x=0.0, y=0.0, z=0.0),
"v1": SimpleNamespace(x=1.0, y=0.0, z=0.0),
"v2": SimpleNamespace(x=1.0, y=1.0, z=0.0),
"v3": SimpleNamespace(x=0.0, y=1.0, z=0.0),
}
entity = SimpleNamespace(dxf=SimpleNamespace(get=lambda key: vertices[key]))
result = import_dxf_module.process_solid_trace_3dface(entity)
assert isinstance(result, Polygon)
assert len(result.vertices()) == 4
def test_process_solid_trace_3dface_three_vertices(import_dxf_module):
vertices = {
"v0": SimpleNamespace(x=0.0, y=0.0, z=0.0),
"v1": SimpleNamespace(x=1.0, y=0.0, z=0.0),
"v2": SimpleNamespace(x=0.0, y=1.0, z=0.0),
}
def get_vertex(key):
if key not in vertices:
raise AttributeError
return vertices[key]
entity = SimpleNamespace(dxf=SimpleNamespace(get=get_vertex))
result = import_dxf_module.process_solid_trace_3dface(entity)
assert isinstance(result, Polygon)
assert len(result.vertices()) == 3
def test_process_mtext_text_fallback(import_dxf_module):
entity = SimpleNamespace(
text="Fallback",
dxf=SimpleNamespace(
char_height=2.0,
attachment_point=7,
insert=(1.0, 2.0, 0.0),
get=lambda key, default=0: 0 if key == "rotation" else default,
),
)
result = import_dxf_module.process_mtext(entity)
assert isinstance(result, Text)
assert result.txt == "Fallback"
def test_flatten_import_result(import_dxf_module):
assert import_dxf_module._flatten_import_result(None) == []
assert len(import_dxf_module._flatten_import_result([None, Vertex(0, 0, 0)])) == 1
assert (
len(
import_dxf_module._flatten_import_result(
import_dxf_module.ShapeList([Vertex(0, 0, 0)])
)
)
== 1
)
def test_process_entity_unsupported_warning(import_dxf_module):
entity = SimpleNamespace(dxftype=lambda: "NOPE")
with pytest.warns(UserWarning, match="Unable to convert NOPE"):
result = import_dxf_module._process_entity(entity, doc=None)
assert result == []
# Integration fixtures
@pytest.mark.parametrize(
"filename",
[
"dxf/test-conic-section.dxf",
"dxf/test-circle-rotation.dxf",
"dxf/test-sketch.dxf",
"dxf/test-drawing.dxf",
"dxf/test-angled-section.dxf",
"dxf/test-ellipse-rotation.dxf",
"dxf/diamond.dxf",
"dxf/test_export.dxf",
"dxf/accumulatortest.dxf",
"dxf/shaft_simple.dxf",
"dxf/layers.dxf",
"dxf/bridge.dxf",
"dxf/output.dxf",
"dxf/ellipticalarcs.dxf",
"dxf/ellipticalarcs2.dxf",
"dxf/cube.dxf",
],
)
def test_import_integration_fixtures(filename: str, tests_dir: Path):
result = import_dxf(str(tests_dir / filename))
assert len(result) > 0