Skip to content

Commit 4c2a038

Browse files
sapphire-archesadam-urbanczykjmwrightlorenzncode
authored
Limit DXF BSpline Degree (#1226)
* Limit DXF BSpline Degree Most DXF renderers and processing tools can't handle BSplines with degree >= 3 (order >= 4). For maximum compatibility, we should approximate such BSplines using degree-3 splines. This change uses the OpenCascade facilities to do so, though ezdxf.math also provides some spline approximation facilities that could be used. Using the OpenCascade approach allows us to match FreeCAD's parameters which are presumably tuned on a diversity of real-world designs. Fixes #1225 * Make DXF BSpline degree limit optional This adds plumbing through the option infrastructure to make the DXF approximation optional, and expose the important control parameters to the user with reasonable defaults. Includes some additional documentation to ease discovery and explain why that should be important. * Black * Start refactoring * Add toSplines and toArcs * Refactor exportDXF and add arc approximation * Update tests * Rework opt handling * Update docs * Better docstring * Use C0 * Apply suggestions from code review Co-authored-by: Jeremy Wright <[email protected]> Co-authored-by: Lorenz <[email protected]> * Update docstring * Code review suggestion Co-authored-by: Jeremy Wright <[email protected]> --------- Co-authored-by: AU <[email protected]> Co-authored-by: Jeremy Wright <[email protected]> Co-authored-by: Lorenz <[email protected]>
1 parent 6c8030d commit 4c2a038

File tree

6 files changed

+163
-15
lines changed

6 files changed

+163
-15
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ target/*
1010
MANIFEST
1111
out.*
1212
res?.dxf
13+
limit?.dxf
1314
.~*
1415
.*.swp
1516
assy.wrl

cadquery/occ_impl/exporters/__init__.py

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import os
33
import io as StringIO
44

5-
from typing import IO, Optional, Union, cast
5+
from typing import IO, Optional, Union, cast, Dict, Any
66
from typing_extensions import Literal
77

88
from OCP.VrmlAPI import VrmlAPI
@@ -43,7 +43,7 @@ def export(
4343
exportType: Optional[ExportLiterals] = None,
4444
tolerance: float = 0.1,
4545
angularTolerance: float = 0.1,
46-
opt=None,
46+
opt: Optional[Dict[str, Any]] = None,
4747
):
4848

4949
"""
@@ -60,6 +60,9 @@ def export(
6060
shape: Shape
6161
f: IO
6262

63+
if not opt:
64+
opt = {}
65+
6366
if isinstance(w, Workplane):
6467
shape = toCompound(w)
6568
else:
@@ -98,21 +101,18 @@ def export(
98101
aw.writeAmf(f)
99102

100103
elif exportType == ExportTypes.THREEMF:
101-
tmfw = ThreeMFWriter(shape, tolerance, angularTolerance, **opt or {})
104+
tmfw = ThreeMFWriter(shape, tolerance, angularTolerance, **opt)
102105
with open(fname, "wb") as f:
103106
tmfw.write3mf(f)
104107

105108
elif exportType == ExportTypes.DXF:
106109
if isinstance(w, Workplane):
107-
exportDXF(w, fname)
110+
exportDXF(w, fname, **opt)
108111
else:
109112
raise ValueError("Only Workplanes can be exported as DXF")
110113

111114
elif exportType == ExportTypes.STEP:
112-
if opt:
113-
shape.exportStep(fname, **opt)
114-
else:
115-
shape.exportStep(fname)
115+
shape.exportStep(fname, **opt)
116116

117117
elif exportType == ExportTypes.STL:
118118
if opt:

cadquery/occ_impl/exporters/dxf.py

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
1-
from ...cq import Workplane, Plane
1+
from ...cq import Workplane, Plane, Face
22
from ...units import RAD2DEG
33
from ..shapes import Edge
44
from .utils import toCompound
55

66
from OCP.gp import gp_Dir
77
from OCP.GeomConvert import GeomConvert
88

9+
from typing import Optional, Literal
10+
911
import ezdxf
1012

1113
CURVE_TOLERANCE = 1e-9
1214

1315

14-
def _dxf_line(e, msp, plane):
16+
def _dxf_line(e: Edge, msp: ezdxf.layouts.Modelspace, plane: Plane):
1517

1618
msp.add_line(
1719
e.startPoint().toTuple(), e.endPoint().toTuple(),
@@ -105,12 +107,21 @@ def _dxf_spline(e: Edge, msp: ezdxf.layouts.Modelspace, plane: Plane):
105107
}
106108

107109

108-
def exportDXF(w: Workplane, fname: str):
110+
def exportDXF(
111+
w: Workplane,
112+
fname: str,
113+
approx: Optional[Literal["spline", "arc"]] = None,
114+
tolerance: float = 1e-3,
115+
):
109116
"""
110117
Export Workplane content to DXF. Works with 2D sections.
111118
112119
:param w: Workplane to be exported.
113-
:param fname: output filename.
120+
:param fname: Output filename.
121+
:param approx: Approximation strategy. None means no approximation is applied.
122+
"spline" results in all splines being approximated as cubic splines. "arc" results
123+
in all curves being approximated as arcs and straight segments.
124+
:param tolerance: Approximation tolerance.
114125
115126
"""
116127

@@ -120,7 +131,22 @@ def exportDXF(w: Workplane, fname: str):
120131
dxf = ezdxf.new()
121132
msp = dxf.modelspace()
122133

123-
for e in shape.Edges():
134+
if approx == "spline":
135+
edges = [
136+
e.toSplines() if e.geomType() == "BSPLINE" else e for e in shape.Edges()
137+
]
138+
139+
elif approx == "arc":
140+
edges = []
141+
142+
# this is needed to handle free wires
143+
for el in shape.Wires():
144+
edges.extend(Face.makeFromWires(el).toArcs(tolerance).Edges())
145+
146+
else:
147+
edges = shape.Edges()
148+
149+
for e in edges:
124150

125151
conv = DXF_CONVERTERS.get(e.geomType(), _dxf_spline)
126152
conv(e, msp, plane)

cadquery/occ_impl/shapes.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,10 @@
240240

241241
from OCP.Interface import Interface_Static
242242

243+
from OCP.ShapeCustom import ShapeCustom, ShapeCustom_RestrictionParameters
244+
245+
from OCP.BRepAlgo import BRepAlgo
246+
243247
from math import pi, sqrt, inf
244248

245249
import warnings
@@ -1252,6 +1256,34 @@ def tessellate(
12521256

12531257
return vertices, triangles
12541258

1259+
def toSplines(
1260+
self: T, degree: int = 3, tolerance: float = 1e-3, nurbs: bool = False
1261+
) -> T:
1262+
"""
1263+
Approximate shape with b-splines of the specified degree.
1264+
1265+
:param degree: Maximum degree.
1266+
:param tolerance: Approximation tolerance.
1267+
:param nurbs: Use rational splines.
1268+
"""
1269+
1270+
params = ShapeCustom_RestrictionParameters()
1271+
1272+
result = ShapeCustom.BSplineRestriction_s(
1273+
self.wrapped,
1274+
tolerance, # 3D tolerance
1275+
tolerance, # 2D tolerance
1276+
degree,
1277+
1, # dumy value, degree is leading
1278+
ga.GeomAbs_C0,
1279+
ga.GeomAbs_C0,
1280+
True, # set degree to be leading
1281+
not nurbs,
1282+
params,
1283+
)
1284+
1285+
return self.__class__(result)
1286+
12551287
def toVtkPolyData(
12561288
self,
12571289
tolerance: Optional[float] = None,
@@ -2562,6 +2594,15 @@ def project(self, other: "Face", d: VectorLike) -> "Face":
25622594

25632595
return self.constructOn(other, outer_p, *inner_p)
25642596

2597+
def toArcs(self, tolerance: float = 1e-3) -> "Face":
2598+
"""
2599+
Approximate planar face with arcs and straight line segments.
2600+
2601+
:param tolerance: Approximation tolerance.
2602+
"""
2603+
2604+
return self.__class__(BRepAlgo.ConvertFace_s(self.wrapped, tolerance))
2605+
25652606

25662607
class Shell(Shape):
25672608
"""

doc/importexport.rst

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,24 @@ optimum values that will produce an acceptable mesh.
231231
232232
exporters.export(result, '/path/to/file/mesh.vrml', tolerance=0.01, angularTolerance=0.1)
233233
234+
Exporting DXF
235+
##############
236+
237+
By default, the DXF exporter will output splines exactly as they are represented by the OpenCascade kernel. Unfortunately some software cannot handle higher-order splines resulting in missing curves after DXF import. To resolve this, specify an approximation strategy controlled by the following options:
238+
239+
* ``approx`` - ``None``, ``"spline"`` or ``"arc"``. ``"spline"`` results in all splines approximated with cubic splines. ``"arc"`` results in all curves approximated with arcs and line segments.
240+
* ``tolerance``: Acceptable error of the approximation, in the DXF's coordinate system. Defaults to 0.001 (1 thou for inch-scale drawings, 1 µm for mm-scale drawings).
241+
242+
.. code-block:: python
243+
244+
245+
cq.exporters.exportDXF(
246+
result,
247+
'/path/to/file/object.dxf',
248+
approx="spline"
249+
)
250+
251+
234252
Exporting Other Formats
235253
########################
236254

tests/test_exporters.py

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,22 @@
88
import re
99
import sys
1010
import pytest
11+
import ezdxf
12+
13+
from pytest import approx
1114

1215
# my modules
13-
from cadquery import *
14-
from cadquery import exporters, importers
16+
from cadquery import (
17+
exporters,
18+
importers,
19+
Workplane,
20+
Edge,
21+
Vertex,
22+
Assembly,
23+
Plane,
24+
Location,
25+
Vector,
26+
)
1527
from tests import BaseTest
1628
from OCP.GeomConvert import GeomConvert
1729
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
@@ -357,3 +369,53 @@ def test_tessellate(box123):
357369
verts, triangles = box123.val().tessellate(1e-6)
358370
assert len(verts) == 24
359371
assert len(triangles) == 12
372+
373+
374+
def _dxf_spline_max_degree(fname):
375+
376+
dxf = ezdxf.readfile(fname)
377+
msp = dxf.modelspace()
378+
379+
rv = 0
380+
381+
for el in msp:
382+
if isinstance(el, ezdxf.entities.Spline):
383+
rv = el.dxf.degree if el.dxf.degree > rv else rv
384+
385+
return rv
386+
387+
388+
def _check_dxf_no_spline(fname):
389+
390+
dxf = ezdxf.readfile(fname)
391+
msp = dxf.modelspace()
392+
393+
for el in msp:
394+
if isinstance(el, ezdxf.entities.Spline):
395+
return False
396+
397+
return True
398+
399+
400+
def test_dxf_approx():
401+
402+
pts = [(0, 0), (0, 0.5), (1, 1)]
403+
w1 = Workplane().spline(pts).close().extrude(1).edges("|Z").fillet(0.1).section()
404+
exporters.exportDXF(w1, "orig.dxf")
405+
406+
assert _dxf_spline_max_degree("orig.dxf") == 6
407+
408+
exporters.exportDXF(w1, "limit1.dxf", approx="spline")
409+
w1_i1 = importers.importDXF("limit1.dxf")
410+
411+
assert _dxf_spline_max_degree("limit1.dxf") == 3
412+
413+
assert w1.val().Area() == approx(w1_i1.val().Area(), 1e-3)
414+
assert w1.edges().size() == w1_i1.edges().size()
415+
416+
exporters.exportDXF(w1, "limit2.dxf", approx="arc")
417+
w1_i2 = importers.importDXF("limit2.dxf")
418+
419+
assert _check_dxf_no_spline("limit2.dxf")
420+
421+
assert w1.val().Area() == approx(w1_i2.val().Area(), 1e-3)

0 commit comments

Comments
 (0)