# Copyright (c) 2026, pinkfish
#
# Licensed under the BSD 2-Clause License. See the LICENSE file in the project
# root for the full license text.
# SPDX-License-Identifier: BSD-2-Clause
# DocCategory: Foundational
# LibFile: pybosl2/export.py
# FileSummary: Mesh writers -- STL, OBJ, OFF, PLY -- straight from a VNF, with no CAD runtime.
# FileGroup: BOSL2
"""Write a mesh to a file (SPEC S-53, S-54, S-55).
The way out of the library. :meth:`pybosl2.vnf.VNF.export` and
:meth:`~pybosl2._backend.Solid.export` are the calls a user makes; this module holds the format
writers behind them.
Every format here is **pure data**, so it is written by pybosl2 itself with no native runtime
involved (S-54, A-2): a mesh built with nothing but numpy can be saved with nothing but numpy.
Formats that are a CAD kernel's own (3MF, AMF) are the kernel's job and are refused by name rather
than half-written.
Winding follows the rest of the library: faces counter-clockwise seen from outside, so
:meth:`~pybosl2.vnf.VNF.volume` is positive for a solid. Normals are computed per face on the way
out where the format wants them.
"""
from __future__ import annotations
import struct
from typing import TYPE_CHECKING
import numpy as np
from pybosl2.exceptions import Bosl2ValueError
if TYPE_CHECKING:
from pathlib import Path
from pybosl2.vnf import VNF
__all__ = ["FORMATS", "format_for", "write_mesh"]
#: The mesh formats pybosl2 writes itself, mapped from the file suffixes that select them.
#: ``"stl"`` is binary; ``"stla"`` is the ASCII form, chosen with an explicit ``format=``.
FORMATS: dict[str, str] = {
".stl": "stl",
".obj": "obj",
".off": "off",
".ply": "ply",
}
#: Formats a CAD kernel owns. Named here so the refusal can say what is missing rather than
#: writing a file a slicer will reject (SPEC S-54).
_DELEGATED: dict[str, str] = {
".3mf": "3MF",
".amf": "AMF",
".dxf": "DXF",
".svg": "SVG",
}
def _triangles(mesh: "VNF") -> "np.ndarray":
"""Return every face as a triangle, fanning any polygon with more than three corners."""
vertices = np.asarray(mesh.vertices, dtype=float)
tris: list[list[int]] = []
for face in mesh.faces:
for i in range(1, len(face) - 1):
tris.append([face[0], face[i], face[i + 1]])
if not tris:
raise Bosl2ValueError("export(): the mesh has no faces to write.")
return np.asarray(vertices[np.asarray(tris, dtype=int)], dtype=float)
def _normals(tris: "np.ndarray") -> "np.ndarray":
"""Return the unit normal of each triangle, derived from its winding."""
edge1 = tris[:, 1] - tris[:, 0]
edge2 = tris[:, 2] - tris[:, 0]
normals = np.cross(edge1, edge2)
lengths = np.linalg.norm(normals, axis=1, keepdims=True)
return np.asarray(np.divide(normals, lengths, out=np.zeros_like(normals), where=lengths > 0), dtype=float)
def _write_stl_binary(mesh: "VNF", path: "Path") -> None:
tris = _triangles(mesh)
normals = _normals(tris)
with path.open("wb") as handle:
handle.write(b"pybosl2".ljust(80, b"\0"))
handle.write(struct.pack("<I", len(tris)))
for normal, tri in zip(normals, tris, strict=True):
handle.write(struct.pack("<3f", *normal))
for point in tri:
handle.write(struct.pack("<3f", *point))
handle.write(struct.pack("<H", 0))
def _write_stl_ascii(mesh: "VNF", path: "Path") -> None:
tris = _triangles(mesh)
normals = _normals(tris)
lines = ["solid pybosl2"]
for normal, tri in zip(normals, tris, strict=True):
lines.append(" facet normal {:.6e} {:.6e} {:.6e}".format(*normal))
lines.append(" outer loop")
lines.extend(" vertex {:.6e} {:.6e} {:.6e}".format(*point) for point in tri)
lines.append(" endloop")
lines.append(" endfacet")
lines.append("endsolid pybosl2\n")
path.write_text("\n".join(lines))
def _write_obj(mesh: "VNF", path: "Path") -> None:
lines = ["# written by pybosl2"]
lines.extend("v {:.6f} {:.6f} {:.6f}".format(*point) for point in mesh.vertices)
# OBJ indexes from 1
lines.extend("f " + " ".join(str(i + 1) for i in face) for face in mesh.faces)
path.write_text("\n".join(lines) + "\n")
def _write_off(mesh: "VNF", path: "Path") -> None:
edges = sum(len(face) for face in mesh.faces)
lines = ["OFF", f"{len(mesh.vertices)} {len(mesh.faces)} {edges}"]
lines.extend("{:.6f} {:.6f} {:.6f}".format(*point) for point in mesh.vertices)
lines.extend(f"{len(face)} " + " ".join(str(i) for i in face) for face in mesh.faces)
path.write_text("\n".join(lines) + "\n")
def _write_ply(mesh: "VNF", path: "Path") -> None:
lines = [
"ply",
"format ascii 1.0",
"comment written by pybosl2",
f"element vertex {len(mesh.vertices)}",
"property float x",
"property float y",
"property float z",
f"element face {len(mesh.faces)}",
"property list uchar int vertex_index",
"end_header",
]
lines.extend("{:.6f} {:.6f} {:.6f}".format(*point) for point in mesh.vertices)
lines.extend(f"{len(face)} " + " ".join(str(i) for i in face) for face in mesh.faces)
path.write_text("\n".join(lines) + "\n")
_WRITERS = {
"stl": _write_stl_binary,
"stla": _write_stl_ascii,
"obj": _write_obj,
"off": _write_off,
"ply": _write_ply,
}
def open_edges(mesh: "VNF") -> list[tuple[int, int]]:
"""Return the edges that fewer or more than two faces share.
An empty list means the surface closes: every edge is walked once in each direction, which is
what a slicer means by watertight.
Args:
mesh: the mesh to inspect.
Returns:
The offending vertex-index pairs, lowest-index-first, deduplicated.
Examples:
>>> from pybosl2 import Path2D
>>> box = Path2D([[-5, -5], [5, -5], [5, 5], [-5, 5]], closed=True).linear_sweep(height=10)
>>> open_edges(box.vnf)
[]
"""
counts: dict[tuple[int, int], int] = {}
for face in mesh.faces:
for i, start in enumerate(face):
end = face[(i + 1) % len(face)]
key = (start, end) if start < end else (end, start)
counts[key] = counts.get(key, 0) + 1
return sorted(edge for edge, count in counts.items() if count != 2)
def _is_vnf(mesh: object) -> bool:
"""Return True if *mesh* is a VNF, importing it lazily to keep this module CAD-runtime free."""
from pybosl2.vnf import VNF
return isinstance(mesh, VNF)
def check_exportable(mesh: "VNF") -> None:
"""Raise if *mesh* is not a solid a slicer would accept (SPEC S-55).
A mesh that is open or wound inside-out is a bug the caller wants to hear about here, not from
their slicer an hour later: an inverted mesh exports cleanly on its own and then *adds*
material wherever it is used to cut.
Args:
mesh: the mesh about to be written.
Raises:
Bosl2ValueError: If the mesh is empty, has open edges, or is wound inside out.
"""
from pybosl2.vnf import VNF
if not isinstance(mesh, VNF):
# A shape is the natural thing to reach for here, because `Shape.export()` takes one.
# Meshing it implicitly is the one thing this must not do: deciding when a field becomes
# a mesh is the caller's call, not the exporter's (SPEC T3). So say what to call instead.
raise Bosl2ValueError(
f"export(): expected a VNF mesh, got {type(mesh).__name__}. Call `shape.export(path)` "
f"to write a shape directly, or `shape.vnf()` to mesh it yourself first -- this "
f"function will not mesh for you, because when a shape is meshed is your decision."
)
if not mesh.vertices or not mesh.faces:
raise Bosl2ValueError("export(): the mesh is empty -- there is nothing to write.")
holes = open_edges(mesh)
if holes:
raise Bosl2ValueError(
f"export(): the mesh is not watertight -- {len(holes)} edge(s) are not shared by exactly "
f"two faces, starting at {holes[0]}. Pass check=False to write it anyway (an open "
f"surface is legal in STL, but no slicer will print it)."
)
if mesh.volume() < 0:
raise Bosl2ValueError(
"export(): the mesh is wound inside out (negative volume), so it would add material "
"wherever it was used to cut. Fix the winding with VNF.reverse(), or pass check=False."
)
[docs]
def write_mesh(mesh: "VNF", path: "Path", *, file_format: str | None = None, check: bool = True) -> "Path":
"""Write *mesh* to *path* (SPEC S-53).
Args:
mesh: the mesh to write.
path: the destination; its suffix picks the format unless *format* says otherwise.
file_format: explicit format name, overriding the suffix. Spelled with the ``file_``
prefix because ``format`` is a Python builtin.
check: validate watertightness and winding first (SPEC S-55). ``False`` for a surface that
is open on purpose.
Returns:
The path written, so the call can be chained or logged.
Raises:
Bosl2ValueError: If the format is unknown, or *check* is on and the mesh is not a solid.
"""
name = format_for(path, file_format)
if check:
check_exportable(mesh)
elif not _is_vnf(mesh):
# `check=False` waives the *watertightness* checks, not the type -- without this the
# wrong argument reached a writer and failed on a backend attribute instead.
raise Bosl2ValueError(
f"export(): expected a VNF mesh, got {type(mesh).__name__}. Call `shape.export(path)` "
f"to write a shape directly, or `shape.vnf()` to mesh it yourself first."
)
path.parent.mkdir(parents=True, exist_ok=True)
_WRITERS[name](mesh, path)
return path