enable BytesIO in STEP, STL and 3MF (via lib3mf/Mesher). Add necessary tests

This commit is contained in:
jdegenstein 2025-11-17 22:05:45 -06:00
parent d329cf1094
commit 7f4e92f0bf
6 changed files with 69 additions and 34 deletions

View file

@ -34,6 +34,7 @@ import math
import xml.etree.ElementTree as ET
from copy import copy
from enum import Enum, auto
from io import BytesIO
from os import PathLike, fsdecode
from typing import Any, TypeAlias
from warnings import warn
@ -636,13 +637,13 @@ class ExportDXF(Export2D):
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def write(self, file_name: PathLike | str | bytes):
def write(self, file_name: PathLike | str | bytes | BytesIO):
"""write
Writes the DXF data to the specified file name.
Args:
file_name (PathLike | str | bytes): The file name (including path) where
file_name (PathLike | str | bytes | BytesIO): The file name (including path) where
the DXF data will be written.
"""
# Reset the main CAD viewport of the model space to the
@ -650,7 +651,12 @@ class ExportDXF(Export2D):
# https://github.com/gumyr/build123d/issues/382 tracks
# exposing viewport control to the user.
zoom.extents(self._modelspace)
self._document.saveas(fsdecode(file_name))
if not isinstance(file_name, BytesIO):
file_name = fsdecode(file_name)
self._document.saveas(file_name)
else:
self._document.write(file_name, fmt="bin")
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
@ -1497,13 +1503,13 @@ class ExportSVG(Export2D):
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def write(self, path: PathLike | str | bytes):
def write(self, path: PathLike | str | bytes | BytesIO):
"""write
Writes the SVG data to the specified file path.
Args:
path (PathLike | str | bytes): The file path where the SVG data will be written.
path (PathLike | str | bytes | BytesIO): The file path where the SVG data will be written.
"""
# pylint: disable=too-many-locals
bb = self._bounds
@ -1549,5 +1555,9 @@ class ExportSVG(Export2D):
xml = ET.ElementTree(svg)
ET.indent(xml, " ")
if not isinstance(path, BytesIO):
path = fsdecode(path)
# xml.write(path, encoding="utf-8", xml_declaration=True, default_namespace=False)
xml.write(path, encoding="utf-8", xml_declaration=True, default_namespace=None)

View file

@ -182,7 +182,7 @@ def export_brep(
def export_gltf(
to_export: Shape,
file_path: PathLike | str | bytes | BytesIO,
file_path: PathLike | str | bytes,
unit: Unit = Unit.MM,
binary: bool = False,
linear_deflection: float = 0.001,
@ -198,7 +198,7 @@ def export_gltf(
Args:
to_export (Shape): object or assembly
file_path (Union[PathLike, str, bytes, BytesIO]): glTF file path
file_path (Union[PathLike, str, bytes]): glTF file path
unit (Unit, optional): shape units. Defaults to Unit.MM.
binary (bool, optional): output format. Defaults to False.
linear_deflection (float, optional): A linear deflection setting which limits
@ -234,12 +234,9 @@ def export_gltf(
# Create the XCAF document
doc: TDocStd_Document = _create_xde(to_export, unit)
if not isinstance(file_path, BytesIO):
file_path = fsdecode(file_path)
# Write the glTF file
writer = RWGltf_CafWriter(
theFile=TCollection_AsciiString(file_path, theIsBinary=binary
theFile=TCollection_AsciiString(fsdecode(file_path)), theIsBinary=binary
)
writer.SetParallel(True)
index_map = TColStd_IndexedDataMapOfStringString()
@ -330,9 +327,12 @@ def export_step(
writer.Transfer(doc, STEPControl_StepModelType.STEPControl_AsIs)
if not isinstance(file_path, BytesIO):
file_path = fspath(file_path)
status = (
writer.Write(fspath(file_path)) == IFSelect_ReturnStatus.IFSelect_RetDone
)
else:
status = writer.WriteStream(file_path) == IFSelect_ReturnStatus.IFSelect_RetDone
status = writer.Write(file_path) == IFSelect_ReturnStatus.IFSelect_RetDone
if not status:
raise RuntimeError("Failed to write STEP file")
@ -341,7 +341,7 @@ def export_step(
def export_stl(
to_export: Shape,
file_path: PathLike | str | bytes | BytesIO,
file_path: PathLike | str | bytes,
tolerance: float = 1e-3,
angular_tolerance: float = 0.1,
ascii_format: bool = False,
@ -352,7 +352,7 @@ def export_stl(
Args:
to_export (Shape): object or assembly
file_path (Union[PathLike, str, bytes, BytesIO]): The path and file name to write the STL output to.
file_path (Union[PathLike, str, bytes]): The path and file name to write the STL output to.
tolerance (float, optional): A linear deflection setting which limits the distance
between a curve and its tessellation. Setting this value too low will result in
large meshes that can consume computing resources. Setting the value too high can
@ -374,8 +374,4 @@ def export_stl(
writer = StlAPI_Writer()
writer.ASCIIMode = ascii_format
if not isinstance(file_path, BytesIO):
file_path = fsdecode(file_path)
return writer.Write(to_export.wrapped, file_path)
return writer.Write(to_export.wrapped, fsdecode(file_path))

View file

@ -83,6 +83,7 @@ license:
# pylint: disable=no-name-in-module, import-error
import copy as copy_module
import ctypes
from io import BytesIO
import math
import os
import sys
@ -312,12 +313,12 @@ class Mesher:
# Round off the vertices to avoid vertices within tolerance being
# considered as different vertices
digits = -int(round(math.log(TOLERANCE, 10), 1))
# Create vertex to index mapping directly
vertex_to_idx = {}
next_idx = 0
vert_table = {}
# First pass - create mapping
for i, (x, y, z) in enumerate(ocp_mesh_vertices):
key = (round(x, digits), round(y, digits), round(z, digits))
@ -325,17 +326,16 @@ class Mesher:
vertex_to_idx[key] = next_idx
next_idx += 1
vert_table[i] = vertex_to_idx[key]
# Create vertices array in one shot
vertices_3mf = [
Lib3MF.Position((ctypes.c_float * 3)(*v))
for v in vertex_to_idx.keys()
Lib3MF.Position((ctypes.c_float * 3)(*v)) for v in vertex_to_idx.keys()
]
# Pre-allocate triangles array and process in bulk
c_uint3 = ctypes.c_uint * 3
triangles_3mf = []
# Process triangles in bulk
for tri in triangles:
# Map indices directly without list comprehension
@ -343,11 +343,13 @@ class Mesher:
mapped_a = vert_table[a]
mapped_b = vert_table[b]
mapped_c = vert_table[c]
# Quick degenerate check without set creation
if mapped_a != mapped_b and mapped_b != mapped_c and mapped_c != mapped_a:
triangles_3mf.append(Lib3MF.Triangle(c_uint3(mapped_a, mapped_b, mapped_c)))
triangles_3mf.append(
Lib3MF.Triangle(c_uint3(mapped_a, mapped_b, mapped_c))
)
return (vertices_3mf, triangles_3mf)
def _add_color(self, b3d_shape: Shape, mesh_3mf: Lib3MF.MeshObject):
@ -540,7 +542,7 @@ class Mesher:
"""write
Args:
file_name Union[Pathlike, str, bytes]: file path
file_name Union[Pathlike, str, bytes, BytesIO]: file path
Raises:
ValueError: Unknown file format - must be 3mf or stl
@ -551,3 +553,8 @@ class Mesher:
raise ValueError(f"Unknown file format {output_file_extension}")
writer = self.model.QueryWriter(output_file_extension[1:])
writer.WriteToFile(file_name)
def write_stream(self, stream: BytesIO, file_type: str):
writer = self.model.QueryWriter(file_type)
result = bytes(writer.WriteToBuffer())
stream.write(result)

View file

@ -1,3 +1,4 @@
from io import BytesIO
from os import fsdecode, fsencode
from typing import Union, Iterable
import math
@ -194,7 +195,9 @@ class ExportersTestCase(unittest.TestCase):
@pytest.mark.parametrize(
"format", (Path, fsencode, fsdecode), ids=["path", "bytes", "str"]
"format",
(Path, fsencode, fsdecode),
ids=["path", "bytes", "str"],
)
@pytest.mark.parametrize("Exporter", (ExportSVG, ExportDXF))
def test_pathlike_exporters(tmp_path, format, Exporter):
@ -205,5 +208,14 @@ def test_pathlike_exporters(tmp_path, format, Exporter):
exporter.write(path)
@pytest.mark.parametrize("Exporter", (ExportSVG, ExportDXF))
def test_exporters_in_memory(Exporter):
buffer = BytesIO()
sketch = ExportersTestCase.create_test_sketch()
exporter = Exporter()
exporter.add_shape(sketch)
exporter.write(buffer)
if __name__ == "__main__":
unittest.main()

View file

@ -206,10 +206,11 @@ def test_pathlike_exporters(tmp_path, format, exporter):
exporter(box, path)
def test_export_brep_in_memory():
@pytest.mark.parametrize("exporter", (export_step, export_brep))
def test_exporters_in_memory(exporter):
buffer = io.BytesIO()
box = Box(1, 1, 1).locate(Pos(-1, -2, -3))
export_brep(box, buffer)
exporter(box, buffer)
if __name__ == "__main__":

View file

@ -1,4 +1,5 @@
import unittest, uuid
from io import BytesIO
from packaging.specifiers import SpecifierSet
from pathlib import Path
from os import fsdecode, fsencode
@ -237,5 +238,13 @@ def test_pathlike_mesher(tmp_path, format):
importer.read(path)
@pytest.mark.parametrize("file_type", ("3mf", "stl"))
def test_in_memory_mesher(file_type):
stream = BytesIO()
exporter = Mesher()
exporter.add_shape(Solid.make_box(1, 1, 1))
exporter.write_stream(stream, file_type)
if __name__ == "__main__":
unittest.main()