"""STEP export utilities for yapCAD surfaces and solids."""
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from typing import Dict, List, Optional, Sequence, TextIO, Tuple, Union
import os
import tempfile
from yapcad.geometry_utils import triangles_from_mesh
from yapcad.mesh import mesh_view
Vec3 = Tuple[float, float, float]
# Check OCC availability
_OCC_AVAILABLE = False
try:
from OCC.Core.STEPControl import STEPControl_Writer, STEPControl_AsIs
from OCC.Core.IFSelect import IFSelect_RetDone
from OCC.Core.Interface import Interface_Static
_OCC_AVAILABLE = True
except ImportError:
pass
[docs]
def write_step_analytic(obj: Sequence,
path: str,
*,
name: str = 'yapCAD',
fallback_to_faceted: bool = True) -> bool:
"""Export ``obj`` to a STEP file using analytic BREP if available.
This function attempts to export the object using its native BREP
representation (with analytic surfaces like planes, cylinders, spheres).
If native BREP data is not available or OCC is not installed, it can
fall back to faceted BREP export.
Parameters
----------
obj : Sequence
A yapCAD solid or surface to export.
path : str
The output file path.
name : str, optional
The product name in the STEP file. Default 'yapCAD'.
fallback_to_faceted : bool, optional
If True and analytic export fails, fall back to faceted export.
Default True.
Returns
-------
bool
True if analytic export succeeded, False if fell back to faceted.
Raises
------
RuntimeError
If OCC is not available and fallback_to_faceted is False.
ValueError
If native BREP is not available and fallback_to_faceted is False.
"""
if not _OCC_AVAILABLE:
if fallback_to_faceted:
write_step(obj, path, name=name)
return False
raise RuntimeError('pythonocc-core is required for analytic STEP export')
# Try to get native BREP and convert to OCC
occ_shape = _get_occ_shape_from_obj(obj)
if occ_shape is None:
if fallback_to_faceted:
write_step(obj, path, name=name)
return False
raise ValueError('Object has no native BREP data for analytic export')
# Export using OCC's STEPControl_Writer
writer = STEPControl_Writer()
# Set author/organization info (API changed in newer pythonocc)
try:
# New API (pythonocc 7.7+)
Interface_Static.SetCVal_s("write.step.product.name", name)
except (AttributeError, TypeError):
# Old API fallback
try:
Interface_Static.SetCVal("write.step.product.name", name)
except (AttributeError, TypeError):
pass # Skip if neither works - product name is optional
# Transfer the shape
status = writer.Transfer(occ_shape, STEPControl_AsIs)
if status != IFSelect_RetDone:
if fallback_to_faceted:
write_step(obj, path, name=name)
return False
raise RuntimeError(f'Failed to transfer shape to STEP writer: {status}')
# Write to file
status = writer.Write(path)
if status != IFSelect_RetDone:
if fallback_to_faceted:
write_step(obj, path, name=name)
return False
raise RuntimeError(f'Failed to write STEP file: {status}')
return True
def _get_occ_shape_from_obj(obj):
"""Extract or convert an OCC shape from a yapCAD object.
Parameters
----------
obj : Sequence
A yapCAD solid, surface, or geometry object.
Returns
-------
TopoDS_Shape or None
The OCC shape if available, None otherwise.
"""
# Check for direct BrepSolid first (OCC-backed)
try:
from yapcad.brep import BrepSolid
if isinstance(obj, BrepSolid):
return obj.shape
except ImportError:
pass
# Check if obj is wrapped in a Geometry object
try:
from yapcad.geometry import Geometry
if isinstance(obj, Geometry):
elem = obj._Geometry__elem
# Check for BrepSolid from brep.py (OCC-backed)
try:
from yapcad.brep import BrepSolid
if isinstance(elem, BrepSolid):
return elem.shape
except ImportError:
pass
# Check for native BREP in wrapped solid
try:
from yapcad.native_brep import has_native_brep, native_brep_from_solid
from yapcad.occ_native_convert import native_brep_to_occ
if has_native_brep(elem):
graph = native_brep_from_solid(elem)
if graph is not None:
return native_brep_to_occ(graph)
except (ImportError, Exception):
pass
except ImportError:
pass
# Check if this is a solid with native BREP (direct solid, not wrapped)
try:
from yapcad.native_brep import has_native_brep, native_brep_from_solid
from yapcad.occ_native_convert import native_brep_to_occ
if has_native_brep(obj):
graph = native_brep_from_solid(obj)
if graph is not None:
return native_brep_to_occ(graph)
except (ImportError, Exception):
pass
# Check for embedded BrepSolid in solid metadata (via attach_brep_to_solid)
try:
from yapcad.brep import brep_from_solid, has_brep_data
if has_brep_data(obj):
brep = brep_from_solid(obj)
if brep is not None:
return brep.shape
except (ImportError, Exception):
pass
return None
@dataclass(frozen=True)
class _Vertex:
coords: Vec3
point_id: int
vertex_id: int
@dataclass(frozen=True)
class _EdgeData:
edge_curve: int
oriented_forward: int
oriented_reverse: int
start_vertex: int
end_vertex: int
@dataclass(frozen=True)
class _TriRecord:
v0: _Vertex
v1: _Vertex
v2: _Vertex
normal: Vec3
class _EntityWriter:
"""Helper to append STEP entities with sequential ids."""
def __init__(self) -> None:
self.entities: List[str] = []
def add(self, record: str) -> int:
idx = len(self.entities) + 1
self.entities.append(f"#{idx} = {record};")
return idx
def write(self, stream: TextIO) -> None:
for entity in self.entities:
stream.write(entity + "\n")
[docs]
def write_step(obj: Sequence,
path_or_file,
*,
name: str = 'yapCAD',
schema: str = 'AUTOMOTIVE_DESIGN_CC2') -> None:
"""Export ``obj`` (surface or solid) to a STEP file using a faceted BREP."""
triangles = list(triangles_from_mesh(mesh_view(obj)))
if not triangles:
raise ValueError('object produced no triangles for STEP export')
writer = _EntityWriter()
vertex_map: Dict[Vec3, _Vertex] = {}
for tri in triangles:
for coords in (tri.v0, tri.v1, tri.v2):
if coords not in vertex_map:
pt = writer.add(
f"CARTESIAN_POINT('', ({coords[0]:.6f}, {coords[1]:.6f}, {coords[2]:.6f}))"
)
vert = writer.add(f"VERTEX_POINT('', #{pt})")
vertex_map[coords] = _Vertex(coords, pt, vert)
def _line_for_edge(start: _Vertex, end: _Vertex) -> Tuple[int, int, int]:
direction_vec = _normalize((end.coords[0] - start.coords[0],
end.coords[1] - start.coords[1],
end.coords[2] - start.coords[2]))
direction_id = writer.add(
f"DIRECTION('', ({direction_vec[0]:.6f}, {direction_vec[1]:.6f}, {direction_vec[2]:.6f}))"
)
length = _distance(start.coords, end.coords)
vector_id = writer.add(f"VECTOR('', #{direction_id}, {length:.6f})")
line_id = writer.add(f"LINE('', #{start.point_id}, #{vector_id})")
return direction_id, vector_id, line_id
tri_records: List[_TriRecord] = []
edge_to_faces: Dict[Tuple[int, int], List[int]] = defaultdict(list)
for idx, tri in enumerate(triangles):
v0 = vertex_map[tri.v0]
v1 = vertex_map[tri.v1]
v2 = vertex_map[tri.v2]
tri_records.append(_TriRecord(v0=v0, v1=v1, v2=v2, normal=_normalize(tri.normal)))
for start, end in ((v0, v1), (v1, v2), (v2, v0)):
key = _edge_key(start.vertex_id, end.vertex_id)
edge_to_faces[key].append(idx)
neighbors: Dict[int, set[int]] = {i: set() for i in range(len(tri_records))}
for faces in edge_to_faces.values():
for i in range(len(faces)):
for j in range(i + 1, len(faces)):
a, b = faces[i], faces[j]
neighbors[a].add(b)
neighbors[b].add(a)
tri_component = [-1] * len(tri_records)
components: List[List[int]] = []
for idx in range(len(tri_records)):
if tri_component[idx] != -1:
continue
comp_index = len(components)
stack = [idx]
tri_component[idx] = comp_index
comp_faces: List[int] = []
while stack:
current = stack.pop()
comp_faces.append(current)
for nb in neighbors[current]:
if tri_component[nb] == -1:
tri_component[nb] = comp_index
stack.append(nb)
components.append(comp_faces)
component_closed = [True] * len(components)
for faces in edge_to_faces.values():
comp_counts: Dict[int, int] = {}
for face_idx in faces:
comp_idx = tri_component[face_idx]
comp_counts[comp_idx] = comp_counts.get(comp_idx, 0) + 1
for comp_idx, count in comp_counts.items():
if count != 2:
component_closed[comp_idx] = False
edge_store: Dict[Tuple[int, int], _EdgeData] = {}
face_ids_by_component: List[List[int]] = [[] for _ in components]
for tri_idx, tri in enumerate(tri_records):
component_index = tri_component[tri_idx]
loop_edges: List[int] = []
for start, end in ((tri.v0, tri.v1), (tri.v1, tri.v2), (tri.v2, tri.v0)):
key = _edge_key(start.vertex_id, end.vertex_id)
data = edge_store.get(key)
if data is None:
_, _, line_id = _line_for_edge(start, end)
edge_curve = writer.add(
f"EDGE_CURVE('', #{start.vertex_id}, #{end.vertex_id}, #{line_id}, .T.)"
)
oriented_forward = writer.add(
f"ORIENTED_EDGE('', *, *, #{edge_curve}, .T.)"
)
oriented_reverse = writer.add(
f"ORIENTED_EDGE('', *, *, #{edge_curve}, .F.)"
)
data = _EdgeData(edge_curve, oriented_forward, oriented_reverse,
start.vertex_id, end.vertex_id)
edge_store[key] = data
if data.start_vertex == start.vertex_id and data.end_vertex == end.vertex_id:
loop_edges.append(data.oriented_forward)
else:
loop_edges.append(data.oriented_reverse)
edge_loop = writer.add(
"EDGE_LOOP('', (" + ", ".join(f"#{eid}" for eid in loop_edges) + "))"
)
face_outer = writer.add(f"FACE_OUTER_BOUND('', #{edge_loop}, .T.)")
ref_vec = _perpendicular(tri.normal)
normal_id = writer.add(
f"DIRECTION('', ({tri.normal[0]:.6f}, {tri.normal[1]:.6f}, {tri.normal[2]:.6f}))"
)
ref_dir = writer.add(
f"DIRECTION('', ({ref_vec[0]:.6f}, {ref_vec[1]:.6f}, {ref_vec[2]:.6f}))"
)
axis = writer.add(f"AXIS2_PLACEMENT_3D('', #{tri.v0.point_id}, #{normal_id}, #{ref_dir})")
plane = writer.add(f"PLANE('', #{axis})")
face = writer.add(f"ADVANCED_FACE('', (#{face_outer}), #{plane}, .T.)")
face_ids_by_component[component_index].append(face)
representation_items: List[int] = []
for idx, face_ids in enumerate(face_ids_by_component):
if not face_ids:
continue
shell_name = name if len(face_ids_by_component) == 1 else f"{name}_{idx + 1}"
if component_closed[idx]:
shell_id = writer.add(_shell_record('CLOSED_SHELL', face_ids))
brep_id = writer.add(f"MANIFOLD_SOLID_BREP('{shell_name}', #{shell_id})")
representation_items.append(brep_id)
else:
shell_id = writer.add(_shell_record('OPEN_SHELL', face_ids))
model_id = writer.add(
f"SHELL_BASED_SURFACE_MODEL('{shell_name}', (#{shell_id}))"
)
representation_items.append(model_id)
if not representation_items:
raise ValueError('STEP export generated no representation items')
# Representation contexts and product definitions
app_context = writer.add("APPLICATION_CONTEXT('mechanical design')")
prod_context = writer.add(
f"PRODUCT_CONTEXT('part definition', #{app_context}, 'mechanical')"
)
product = writer.add(
f"PRODUCT('{name}', '{name}', '', (#{prod_context}))"
)
formation = writer.add(f"PRODUCT_DEFINITION_FORMATION('', '', #{product})")
pd_context = writer.add(
f"PRODUCT_DEFINITION_CONTEXT('design', #{app_context}, 'mechanical')"
)
product_def = writer.add(
f"PRODUCT_DEFINITION('', '', #{formation}, #{pd_context})"
)
shape = writer.add(f"PRODUCT_DEFINITION_SHAPE('', '', #{product_def})")
length_unit = writer.add(
"( NAMED_UNIT(*) LENGTH_UNIT() SI_UNIT(.MILLI., .METRE.) )"
)
plane_angle_unit = writer.add(
"( NAMED_UNIT(*) PLANE_ANGLE_UNIT() SI_UNIT($, .RADIAN.) )"
)
solid_angle_unit = writer.add(
"( NAMED_UNIT(*) SOLID_ANGLE_UNIT() SI_UNIT($, .STERADIAN.) )"
)
measure_with_unit = writer.add(
f"MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-06), #{length_unit})"
)
uncertainty = writer.add(
f"UNCERTAINTY_MEASURE_WITH_UNIT(#{measure_with_unit}, 'distance accuracy value')"
)
geom_context = writer.add(
"( REPRESENTATION_CONTEXT('', '')"
" AND GEOMETRIC_REPRESENTATION_CONTEXT(3)"
f" AND GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{uncertainty}))"
f" AND GLOBAL_UNIT_ASSIGNED_CONTEXT((#{length_unit}, #{plane_angle_unit}, #{solid_angle_unit})) )"
)
items_block = _format_id_list(representation_items)
brep_shape = writer.add(
"ADVANCED_BREP_SHAPE_REPRESENTATION('', (\n"
+ items_block
+ f"\n), #{geom_context})"
)
writer.add(f"SHAPE_DEFINITION_REPRESENTATION(#{shape}, #{brep_shape})")
close_stream = False
if hasattr(path_or_file, 'write'):
stream = path_or_file
else:
stream = open(path_or_file, 'w', encoding='utf-8')
close_stream = True
try:
_write_header(stream, name, schema)
stream.write("DATA;\n")
writer.write(stream)
stream.write("ENDSEC;\nEND-ISO-10303-21;\n")
finally:
if close_stream:
stream.close()
def _write_header(stream: TextIO, name: str, schema: str) -> None:
stream.write("ISO-10303-21;\n")
stream.write("HEADER;\n")
stream.write("FILE_DESCRIPTION(('yapCAD export'),'2;1');\n")
stream.write(
"FILE_NAME('"
+ name
+ "','2024-01-01T00:00:00',('yapCAD'),('yapCAD organization'), 'yapCAD', 'yapCAD', '');\n"
)
stream.write("FILE_SCHEMA(('" + schema + "'));\n")
stream.write("ENDSEC;\n")
def _normalize(vec: Vec3) -> Vec3:
length = (vec[0] ** 2 + vec[1] ** 2 + vec[2] ** 2) ** 0.5
if length == 0.0:
return (0.0, 0.0, 1.0)
return (vec[0] / length, vec[1] / length, vec[2] / length)
def _distance(a: Vec3, b: Vec3) -> float:
return ((a[0] - b[0]) ** 2 + (a[1] - b[1]) ** 2 + (a[2] - b[2]) ** 2) ** 0.5
def _perpendicular(normal: Vec3) -> Vec3:
x, y, z = normal
if abs(x) < abs(z):
return _normalize((-z, 0.0, x))
return _normalize((0.0, z, -y))
def _edge_key(a: int, b: int) -> Tuple[int, int]:
return (a, b) if a <= b else (b, a)
def _shell_record(kind: str, face_ids: Sequence[int]) -> str:
block = _format_id_list(face_ids)
return f"{kind}('', (\n{block}\n))"
def _format_id_list(ids: Sequence[int], *, indent: str = ' ', per_line: int = 8) -> str:
if not ids:
return indent
lines: List[str] = []
total = len(ids)
for index, entity in enumerate(ids):
suffix = ',' if index + 1 < total else ''
lines.append(f"{indent}#{entity}{suffix}")
return "\n".join(lines)
__all__ = ['write_step', 'write_step_analytic']