Files
cadquery/tests/test_exporters.py
Reed Koser 4c2a038349 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 <wrightjmf@gmail.com>
Co-authored-by: Lorenz <hello@lorenz.space>

* Update docstring

* Code review suggestion

Co-authored-by: Jeremy Wright <wrightjmf@gmail.com>

---------

Co-authored-by: AU <adam-urbanczyk@users.noreply.github.com>
Co-authored-by: Jeremy Wright <wrightjmf@gmail.com>
Co-authored-by: Lorenz <hello@lorenz.space>
2023-02-21 08:10:44 +01:00

422 lines
11 KiB
Python

"""
Tests basic workplane functionality
"""
# core modules
import os
import io
from pathlib import Path
import re
import sys
import pytest
import ezdxf
from pytest import approx
# my modules
from cadquery import (
exporters,
importers,
Workplane,
Edge,
Vertex,
Assembly,
Plane,
Location,
Vector,
)
from tests import BaseTest
from OCP.GeomConvert import GeomConvert
from OCP.BRepBuilderAPI import BRepBuilderAPI_MakeEdge
@pytest.fixture(scope="module")
def tmpdir(tmp_path_factory):
return tmp_path_factory.mktemp("out")
@pytest.fixture()
def box123():
return Workplane().box(1, 2, 3)
def test_step_options(tmpdir):
"""
Exports a box using the options to decrease STEP file size and
then imports that STEP to validate it.
"""
# Use a temporary directory
box_path = os.path.join(tmpdir, "out.step")
# Simple object to export
box = Workplane().box(1, 1, 1)
# Export the STEP with the size-saving options and then import it back in
box.val().exportStep(box_path, write_pcurves=False, precision_mode=0)
w = importers.importStep(box_path)
# Make sure there was a valid box in the exported file
assert w.solids().size() == 1
assert w.faces().size() == 6
class TestExporters(BaseTest):
def _exportBox(self, eType, stringsToFind, tolerance=0.1, angularTolerance=0.1):
"""
Exports a test object, and then looks for
all of the supplied strings to be in the result
returns the result in case the case wants to do more checks also
"""
p = Workplane("XY").box(1, 2, 3)
if eType in (exporters.ExportTypes.AMF, exporters.ExportTypes.THREEMF):
s = io.BytesIO()
else:
s = io.StringIO()
exporters.exportShape(
p, eType, s, tolerance=tolerance, angularTolerance=angularTolerance
)
result = "{}".format(s.getvalue())
for q in stringsToFind:
self.assertTrue(result.find(q) > -1)
return result
def _box(self):
return Workplane().box(1, 1, 1)
def testSTL(self):
# New STL tests have been added; Keep this to test deprecated exportShape
self._exportBox(exporters.ExportTypes.STL, ["facet normal"])
def testSVG(self):
self._exportBox(exporters.ExportTypes.SVG, ["<svg", "<g transform"])
exporters.export(self._box(), "out.svg")
def testSVGOptions(self):
self._exportBox(exporters.ExportTypes.SVG, ["<svg", "<g transform"])
exporters.export(
self._box(),
"out.svg",
opt={
"width": 100,
"height": 100,
"marginLeft": 10,
"marginTop": 10,
"showAxes": False,
"projectionDir": (0, 0, 1),
"strokeWidth": 0.25,
"strokeColor": (255, 0, 0),
"hiddenColor": (0, 0, 255),
"showHidden": True,
},
)
def testAMF(self):
self._exportBox(exporters.ExportTypes.AMF, ["<amf units", "</object>"])
exporters.export(self._box(), "out.amf")
def testSTEP(self):
self._exportBox(exporters.ExportTypes.STEP, ["FILE_SCHEMA"])
exporters.export(self._box(), "out.step")
def test3MF(self):
self._exportBox(
exporters.ExportTypes.THREEMF,
["3D/3dmodel.model", "[Content_Types].xml", "_rels/.rels"],
)
exporters.export(self._box(), "out1.3mf") # Compound
exporters.export(self._box().val(), "out2.3mf") # Solid
# No zlib support
import zlib
sys.modules["zlib"] = None
exporters.export(self._box(), "out3.3mf")
sys.modules["zlib"] = zlib
def testTJS(self):
self._exportBox(
exporters.ExportTypes.TJS, ["vertices", "formatVersion", "faces"]
)
exporters.export(self._box(), "out.tjs")
def testVRML(self):
exporters.export(self._box(), "out.vrml")
with open("out.vrml") as f:
res = f.read(10)
assert res.startswith("#VRML V2.0")
# export again to trigger all paths in the code
exporters.export(self._box(), "out.vrml")
def testVTP(self):
exporters.export(self._box(), "out.vtp")
with open("out.vtp") as f:
res = f.read(100)
assert res.startswith('<?xml version="1.0"?>\n<VTKFile')
def testDXF(self):
exporters.export(self._box().section(), "out.dxf")
with self.assertRaises(ValueError):
exporters.export(self._box().val(), "out.dxf")
s1 = (
Workplane("XZ")
.polygon(10, 10)
.ellipse(1, 2)
.extrude(1)
.edges("|Y")
.fillet(1)
.section()
)
exporters.dxf.exportDXF(s1, "res1.dxf")
s1_i = importers.importDXF("res1.dxf")
self.assertAlmostEqual(s1.val().Area(), s1_i.val().Area(), 6)
self.assertAlmostEqual(s1.edges().size(), s1_i.edges().size())
pts = [(0, 0), (0, 0.5), (1, 1)]
s2 = (
Workplane().spline(pts).close().extrude(1).edges("|Z").fillet(0.1).section()
)
exporters.dxf.exportDXF(s2, "res2.dxf")
s2_i = importers.importDXF("res2.dxf")
self.assertAlmostEqual(s2.val().Area(), s2_i.val().Area(), 6)
self.assertAlmostEqual(s2.edges().size(), s2_i.edges().size())
s3 = (
Workplane("XY")
.ellipseArc(1, 2, 0, 180)
.close()
.extrude(1)
.edges("|Z")
.fillet(0.1)
.section()
)
exporters.dxf.exportDXF(s3, "res3.dxf")
s3_i = importers.importDXF("res3.dxf")
self.assertAlmostEqual(s3.val().Area(), s3_i.val().Area(), 6)
self.assertAlmostEqual(s3.edges().size(), s3_i.edges().size())
cyl = Workplane("XY").circle(22).extrude(10, both=True).translate((-50, 0, 0))
s4 = Workplane("XY").box(80, 60, 5).cut(cyl).section()
exporters.dxf.exportDXF(s4, "res4.dxf")
s4_i = importers.importDXF("res4.dxf")
self.assertAlmostEqual(s4.val().Area(), s4_i.val().Area(), 6)
self.assertAlmostEqual(s4.edges().size(), s4_i.edges().size())
# test periodic spline
w = Workplane().spline([(1, 1), (2, 2), (3, 2), (3, 1)], periodic=True)
exporters.dxf.exportDXF(w, "res5.dxf")
w_i = importers.importDXF("res5.dxf")
self.assertAlmostEqual(w.val().Length(), w_i.wires().val().Length(), 6)
# test rational spline
c = Edge.makeCircle(1)
adaptor = c._geomAdaptor()
curve = GeomConvert.CurveToBSplineCurve_s(adaptor.Curve().Curve())
e = Workplane().add(Edge(BRepBuilderAPI_MakeEdge(curve).Shape()))
exporters.dxf.exportDXF(e, "res6.dxf")
e_i = importers.importDXF("res6.dxf")
self.assertAlmostEqual(e.val().Length(), e_i.wires().val().Length(), 6)
# test non-planar section
s5 = (
Workplane()
.spline([(0, 0), (1, 0), (1, 1), (0, 1)])
.close()
.extrude(1, both=True)
.translate((-3, -4, 0))
)
s5.plane = Plane(origin=(0, 0.1, 0.5), normal=(0.05, 0.05, 1))
s5 = s5.section()
exporters.dxf.exportDXF(s5, "res7.dxf")
s5_i = importers.importDXF("res7.dxf")
self.assertAlmostEqual(s5.val().Area(), s5_i.val().Area(), 4)
def testTypeHandling(self):
with self.assertRaises(ValueError):
exporters.export(self._box(), "out.random")
with self.assertRaises(ValueError):
exporters.export(self._box(), "out.stl", "STP")
@pytest.mark.parametrize(
"id, opt, matchvals",
[
(0, {"ascii": True}, ["solid", "facet normal"]),
(1, {"ASCII": True}, ["solid", "facet normal"]),
(2, {"unknown_opt": 1, "ascii": True}, ["solid", "facet normal"]),
(3, {"ASCII": False, "ascii": True}, ["solid", "facet normal"]),
],
)
def test_stl_ascii(tmpdir, box123, id, opt, matchvals):
"""
:param tmpdir: temporary directory fixture
:param box123: box fixture
:param id: The index or id; output filename is <test name>_<id>.stl
:param opt: The export opt dict
:param matchval: List of strings to match at start of file
"""
fpath = tmpdir.joinpath(f"stl_ascii_{id}.stl").resolve()
assert not fpath.exists()
assert matchvals
exporters.export(box123, str(fpath), None, 0.1, 0.1, opt)
with open(fpath, "r") as f:
for i, line in enumerate(f):
if i > len(matchvals) - 1:
break
assert line.find(matchvals[i]) > -1
@pytest.mark.parametrize(
"id, opt, matchval",
[
(0, None, b"STL Exported by Open CASCADE"),
(1, {"ascii": False}, b"STL Exported by Open CASCADE"),
(2, {"ASCII": False}, b"STL Exported by Open CASCADE"),
(3, {"unknown_opt": 1}, b"STL Exported by Open CASCADE"),
(4, {"unknown_opt": 1, "ascii": False}, b"STL Exported by Open CASCADE"),
],
)
def test_stl_binary(tmpdir, box123, id, opt, matchval):
"""
:param tmpdir: temporary directory fixture
:param box123: box fixture
:param id: The index or id; output filename is <test name>_<id>.stl
:param opt: The export opt dict
:param matchval: Check that the file starts with the specified value
"""
fpath = tmpdir.joinpath(f"stl_binary_{id}.stl").resolve()
assert not fpath.exists()
assert matchval
exporters.export(box123, str(fpath), None, 0.1, 0.1, opt)
with open(fpath, "rb") as f:
r = f.read(len(matchval))
assert r == matchval
def test_assy_vtk_rotation(tmpdir):
v0 = Vertex.makeVertex(1, 0, 0)
assy = Assembly()
assy.add(
v0, name="v0", loc=Location(Vector(0, 0, 0), Vector(1, 0, 0), 90),
)
fwrl = Path(tmpdir, "v0.wrl")
assert not fwrl.exists()
assy.save(str(fwrl), "VRML")
assert fwrl.exists()
matched_rot = False
with open(fwrl) as f:
pat_rot = re.compile("""rotation 1 0 0 1.5707963267""")
for line in f:
if m := re.search(pat_rot, line):
matched_rot = True
break
assert matched_rot
def test_tessellate(box123):
verts, triangles = box123.val().tessellate(1e-6)
assert len(verts) == 24
assert len(triangles) == 12
def _dxf_spline_max_degree(fname):
dxf = ezdxf.readfile(fname)
msp = dxf.modelspace()
rv = 0
for el in msp:
if isinstance(el, ezdxf.entities.Spline):
rv = el.dxf.degree if el.dxf.degree > rv else rv
return rv
def _check_dxf_no_spline(fname):
dxf = ezdxf.readfile(fname)
msp = dxf.modelspace()
for el in msp:
if isinstance(el, ezdxf.entities.Spline):
return False
return True
def test_dxf_approx():
pts = [(0, 0), (0, 0.5), (1, 1)]
w1 = Workplane().spline(pts).close().extrude(1).edges("|Z").fillet(0.1).section()
exporters.exportDXF(w1, "orig.dxf")
assert _dxf_spline_max_degree("orig.dxf") == 6
exporters.exportDXF(w1, "limit1.dxf", approx="spline")
w1_i1 = importers.importDXF("limit1.dxf")
assert _dxf_spline_max_degree("limit1.dxf") == 3
assert w1.val().Area() == approx(w1_i1.val().Area(), 1e-3)
assert w1.edges().size() == w1_i1.edges().size()
exporters.exportDXF(w1, "limit2.dxf", approx="arc")
w1_i2 = importers.importDXF("limit2.dxf")
assert _check_dxf_no_spline("limit2.dxf")
assert w1.val().Area() == approx(w1_i2.val().Area(), 1e-3)