Compare commits

...

4 commits

Author SHA1 Message Date
jdegenstein
7a4f1f7e55
Merge pull request #1141 from jdegenstein/bytesio
Some checks are pending
benchmarks / benchmarks (macos-14, 3.12) (push) Waiting to run
benchmarks / benchmarks (macos-15-intel, 3.12) (push) Waiting to run
benchmarks / benchmarks (ubuntu-latest, 3.12) (push) Waiting to run
benchmarks / benchmarks (windows-latest, 3.12) (push) Waiting to run
Upload coverage reports to Codecov / run (push) Waiting to run
pylint / lint (3.10) (push) Waiting to run
Run type checker / typecheck (3.10) (push) Waiting to run
Run type checker / typecheck (3.13) (push) Waiting to run
Wheel building and publishing / Build wheel on ubuntu-latest (push) Waiting to run
Wheel building and publishing / upload_pypi (push) Blocked by required conditions
tests / tests (macos-14, 3.10) (push) Waiting to run
tests / tests (macos-14, 3.13) (push) Waiting to run
tests / tests (macos-15-intel, 3.10) (push) Waiting to run
tests / tests (macos-15-intel, 3.13) (push) Waiting to run
tests / tests (ubuntu-latest, 3.10) (push) Waiting to run
tests / tests (ubuntu-latest, 3.13) (push) Waiting to run
tests / tests (windows-latest, 3.10) (push) Waiting to run
tests / tests (windows-latest, 3.13) (push) Waiting to run
Add BytesIO export support to SVG, DXF, STEP, and STL/3MF via a lib3mf/Mesher workaround
2025-11-20 15:56:02 -06:00
jdegenstein
70764bbe08 revert spurious docstring change for Mesher.write 2025-11-20 15:28:37 -06:00
jdegenstein
7f4e92f0bf enable BytesIO in STEP, STL and 3MF (via lib3mf/Mesher). Add necessary tests 2025-11-17 22:05:45 -06:00
jdegenstein
d329cf1094 initial changes to support BytesIO 2025-11-17 10:09:54 -06:00
6 changed files with 68 additions and 26 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

@ -262,7 +262,7 @@ def export_gltf(
def export_step(
to_export: Shape,
file_path: PathLike | str | bytes,
file_path: PathLike | str | bytes | BytesIO,
unit: Unit = Unit.MM,
write_pcurves: bool = True,
precision_mode: PrecisionMode = PrecisionMode.AVERAGE,
@ -277,7 +277,7 @@ def export_step(
Args:
to_export (Shape): object or assembly
file_path (Union[PathLike, str, bytes]): step file path
file_path (Union[PathLike, str, bytes, BytesIO]): step file path
unit (Unit, optional): shape units. Defaults to Unit.MM.
write_pcurves (bool, optional): write parametric curves to the STEP file.
Defaults to True.
@ -326,7 +326,13 @@ def export_step(
Interface_Static.SetIVal_s("write.precision.mode", precision_mode.value)
writer.Transfer(doc, STEPControl_StepModelType.STEPControl_AsIs)
status = writer.Write(fspath(file_path)) == IFSelect_ReturnStatus.IFSelect_RetDone
if not isinstance(file_path, BytesIO):
status = (
writer.Write(fspath(file_path)) == IFSelect_ReturnStatus.IFSelect_RetDone
)
else:
status = writer.WriteStream(file_path) == IFSelect_ReturnStatus.IFSelect_RetDone
if not status:
raise RuntimeError("Failed to write STEP file")
@ -346,7 +352,7 @@ def export_stl(
Args:
to_export (Shape): object or assembly
file_path (str): 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
@ -368,7 +374,4 @@ def export_stl(
writer = StlAPI_Writer()
writer.ASCIIMode = ascii_format
file_path = str(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):
@ -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()