""" Export a member as a sealed triangle mesh. WHAT THIS IS, AND WHAT IT IS NOT **A member exporter.** ``ROADMAP.md`` §2 names three artifact classes: members are prismatic and exist, nodes are non-prismatic and do not, panels are sheet and do not. What is here sweeps a cross-section along +Z and caps both ends. It will never produce a node, and no amount of extending it should be attempted -- a node is a different representation, not a harder prism. The module is deliberately named for the artifact rather than the file format for that reason. ``stl.py`` would invite someone to add nodes to it. IDENTIFIERS IN THE OUTPUT ARE PROVENANCE, NOT A CHECKSUM ``ROADMAP.md`` §7 principle 4: mesh bytes are not reproducible across toolchain versions. Two exports carrying the same ``build_id`` may differ byte for byte and both be correct -- a Shapely or GEOS release can move a triangulation without moving the geometry, which is the F-034 mechanism applied to tessellation instead of to booleans. So the identifiers written into the file say *what produced this*. They do not say *these bytes*. Anyone diffing two exports of the same design and concluding the compiler is broken has misread them, and the header says so in the file itself. WHY NO CAD KERNEL A member is a prism. Caps are a constrained Delaunay triangulation of the section, walls are a quad strip per ring, and both are decided by the section's own rings. Nothing here needs a solid modeller, which is why STL export costs no new dependency while STEP would. THE PRECONDITION IS THE SECTION'S, NOT OURS ``region.is_region_simple`` exists because a section that touches itself at a point is a valid 2D outline that cannot be tessellated: it measures perfectly and then fails to extrude into a sealed solid. That is checked here and refused, rather than emitted as a mesh that opens in a slicer and prints wrong. """ from __future__ import annotations import struct from typing import Dict, List, NamedTuple, Sequence, Tuple Point = Tuple[float, float] Path = Sequence[Point] Region = Sequence[Path] Vertex = Tuple[float, float, float] Triangle = Tuple[int, int, int] class ExportRefused(Exception): """ The section cannot become a sealed solid. A refusal rather than an error, in the same sense as ``ProfileRejected``: the compiler declining to produce something it cannot vouch for is the compiler working. """ class Mesh(NamedTuple): vertices: List[Vertex] triangles: List[Triangle] @property def volume(self) -> float: """ Signed volume by the divergence theorem. Positive for outward-facing normals. Comparing this against ``region_area(section) * length`` checks the winding, the cap orientation and the sweep length in a single number -- three defects that are individually hard to see and jointly impossible to miss. """ total = 0.0 v = self.vertices for i, j, k in self.triangles: ax, ay, az = v[i] bx, by, bz = v[j] cx, cy, cz = v[k] total += (ax * (by * cz - bz * cy) - ay * (bx * cz - bz * cx) + az * (bx * cy - by * cx)) return total / 6.0 # --------------------------------------------------------------------------- # Building the mesh # --------------------------------------------------------------------------- def _signed_area(ring: Path) -> float: total = 0.0 n = len(ring) for i in range(n): x0, y0 = ring[i] x1, y1 = ring[(i + 1) % n] total += x0 * y1 - x1 * y0 return total / 2.0 def _ccw(ring: Path) -> List[Point]: return list(ring) if _signed_area(ring) >= 0 else list(reversed(ring)) def _cw(ring: Path) -> List[Point]: return list(ring) if _signed_area(ring) < 0 else list(reversed(ring)) class _Vertices: """Deduplicating vertex table, keyed on exact coordinates.""" def __init__(self) -> None: self.items: List[Vertex] = [] self._index: Dict[Vertex, int] = {} def add(self, x: float, y: float, z: float) -> int: key = (x, y, z) found = self._index.get(key) if found is None: found = len(self.items) self._index[key] = found self.items.append(key) return found def prism_mesh(section: Region, length: float) -> Mesh: """ Sweep ``section`` from z=0 to z=``length`` and seal both ends. Takes a region and a length rather than a ``Result``, which is the whole reason the signature looks like this. A member, its support, or the support alone are then three calls with different regions instead of three special cases inside one function -- and support is a designed part of an artifact when no printable orientation exists, not something a slicer adds. WINDING ``region_parts`` returns each part as an outer ring clockwise with its holes counter-clockwise, because ``region_area`` depends on that convention to make holes subtract. A prism swept along +Z needs the opposite: traversing the outer boundary counter-clockwise puts the material on the left and the wall normal on the right, which is outward. Both are therefore reversed here. The convention is not assumed from the input -- every ring is forced. """ from .geom.region import is_region_simple, region_parts, to_shapely if length <= 0: raise ExportRefused("length must be greater than zero, got %r" % (length,)) if not section: raise ExportRefused("the section is empty; there is nothing to sweep") if not is_region_simple(section): raise ExportRefused( "the section touches or crosses itself, so it cannot be sealed " "into a solid. It is a valid outline and measures correctly; it " "is not extrudable. See geom.region.is_region_simple.") try: from shapely import constrained_delaunay_triangles except ImportError as exc: # pragma: no cover raise ExportRefused( "constrained_delaunay_triangles requires Shapely 2.1 or newer" ) from exc verts = _Vertices() tris: List[Triangle] = [] # -- caps ------------------------------------------------------------ # # The triangulator is given the polygon with its holes, so no triangle # lands inside a bore. That is asserted in the tests rather than trusted: # a triangulator that filled the bores would produce a mesh that looks # right in a slicer and prints solid through the conduit. tessellation = constrained_delaunay_triangles(to_shapely(section)) faces = getattr(tessellation, "geoms", [tessellation]) cap_count = 0 for face in faces: if face.is_empty: continue ring = [(x, y) for x, y in list(face.exterior.coords)[:-1]] if len(ring) != 3: continue a, b, c = _ccw(ring) cap_count += 1 # Top at z=length: counter-clockwise seen from +Z faces +Z, outward. tris.append((verts.add(a[0], a[1], length), verts.add(b[0], b[1], length), verts.add(c[0], c[1], length))) # Bottom at z=0: the same triangle reversed faces -Z, also outward. tris.append((verts.add(c[0], c[1], 0.0), verts.add(b[0], b[1], 0.0), verts.add(a[0], a[1], 0.0))) if cap_count == 0: raise ExportRefused( "the section produced no triangles; it encloses no area") # -- walls ----------------------------------------------------------- for part in region_parts(section): rings = [_ccw(part[0])] + [_cw(hole) for hole in part[1:]] for ring in rings: n = len(ring) for i in range(n): x0, y0 = ring[i] x1, y1 = ring[(i + 1) % n] b0 = verts.add(x0, y0, 0.0) b1 = verts.add(x1, y1, 0.0) t0 = verts.add(x0, y0, length) t1 = verts.add(x1, y1, length) tris.append((b0, b1, t1)) tris.append((b0, t1, t0)) return Mesh(verts.items, tris) # --------------------------------------------------------------------------- # Writing it out # --------------------------------------------------------------------------- HEADER_BYTES = 80 def stl_header(input_id: str = "", build_id: str = "") -> bytes: """ The binary format's 80-byte header, carrying provenance. A file separated from its design record should still say what it is. Eighty bytes is not much, so it holds the two identifiers and nothing else -- about fifty-four characters of the eighty. These are NOT a checksum of the bytes that follow. See the module docstring: mesh bytes are not reproducible across toolchain versions, and two files with the same ``build_id`` may legitimately differ. """ text = "mechcomp" if input_id: text += " input=%s" % input_id if build_id: text += " build=%s" % build_id raw = text.encode("ascii", "replace")[:HEADER_BYTES] return raw + b"\0" * (HEADER_BYTES - len(raw)) def binary_stl(mesh: Mesh, input_id: str = "", build_id: str = "") -> bytes: """ The mesh as a binary STL. Facet normals are written as zeros. The format has a field for them and every consumer recomputes from the vertex order anyway; writing a stored normal that could disagree with the winding would create a second source of truth for which way a face points. """ out = [stl_header(input_id, build_id), struct.pack(" bytes: """ Export whatever ``build()`` returned. ``length`` defaults to the report's ``LENGTH_MM``, which is the value the record's ``VOLUME_MM3`` and ``MASS_G`` were computed against. Sweeping any other length would make the record describe a different object than the file shipped beside it, so the default is the only sensible one and an override exists for callers that know what they are doing. """ if length is None: length = result.report["LENGTH_MM"] mesh = prism_mesh(result.section, length) record = getattr(result, "record", None) return binary_stl(mesh, record.input_id if record else "", record.build_id if record else "") def stl_filename(result) -> str: """A name that identifies the design without needing the record beside it.""" record = getattr(result, "record", None) ident = record.input_id if record else "unidentified" family = result.report.get("FAMILY", "member") profile = str(result.report.get("PROFILE", "")).replace(" ", "-").lower() return "mechcomp-%s-%s-%s.stl" % (family, profile, ident)