Source code for pyvcad_metamaterials.connectivity

"""Connected-component checks for lattice transitions and other joined structures."""

# STD
import math

# OpenVCAD
import pyvcad as pv


def _vec3(value):
    return (float(value.x), float(value.y), float(value.z))


[docs] class ConnectivityReport: """Readable result of :func:`check_connectivity`. Attributes: component_count (int): Number of separate solid pieces. connected (bool): True when the solid is one piece (or empty pieces were ignored). volumes (list[float]): Component volumes in mm^3, largest first. sample_points (list[tuple]): One solid point inside each component (x, y, z) in mm. bounds (list[tuple]): ``(minimum, maximum)`` sample box of each component. voxel_size (float): Sampling pitch in mm. ignored_count (int): Components smaller than ``min_island_volume`` that were ignored. """ def __init__(self, report, min_island_volume): components = list(report.components) kept = [item for item in components if item.volume >= min_island_volume] if components and not kept: kept = components[:1] self.voxel_size = float(report.voxel_size.x) self.min_island_volume = float(min_island_volume) self.solid_voxel_count = int(report.solid_voxel_count) self.ignored_count = len(components) - len(kept) self.component_count = len(kept) self.connected = self.component_count == 1 self.volumes = [float(item.volume) for item in kept] self.sample_points = [_vec3(item.sample_point) for item in kept] self.bounds = [(_vec3(item.minimum), _vec3(item.maximum)) for item in kept] self.raw = report @property def islands(self): """Every component except the largest, as ``(volume, sample_point)`` pairs.""" return list(zip(self.volumes[1:], self.sample_points[1:])) @property def largest_fraction(self): """Fraction of the kept solid volume in the largest component.""" total = sum(self.volumes) return self.volumes[0] / total if total > 0.0 else 0.0 def __bool__(self): return self.connected def __str__(self): if self.component_count == 0: return "connectivity: no solid material found at {} mm voxels".format(self.voxel_size) lines = [ "connectivity: {} component{} at {} mm voxels{}".format( self.component_count, "" if self.component_count == 1 else "s", self.voxel_size, " (connected)" if self.connected else "", ) ] for index, (volume, point) in enumerate(zip(self.volumes, self.sample_points)): if index >= 8: lines.append(" ... {} more".format(self.component_count - index)) break lines.append(" #{}: {:.3f} mm^3 near ({:.2f}, {:.2f}, {:.2f})".format( index, volume, point[0], point[1], point[2])) if self.ignored_count: lines.append(" ({} specks below {:.3g} mm^3 ignored)".format( self.ignored_count, self.min_island_volume)) return "\n".join(lines) __repr__ = __str__
[docs] def check_connectivity(node, voxel_size, connectivity=26, bounds=None, min_island_volume=None, cull_far_cells=False): """Count the separate solid pieces of a node on a voxel grid. A lattice transition that pinches apart, or clipping that leaves loose strut fragments, shows up as more than one component. The node is sampled at voxel centres over its bounding box (or ``bounds``, a ``(minimum, maximum)`` pair of ``pv.Vec3``) in parallel, and solid voxels are joined through face, edge and corner neighbours (``connectivity=26``) or faces only (``6``). Resolution matters: choose ``voxel_size`` at most about half the thinnest strut radius or wall thickness. Thinner necks may be reported as breaks, and gaps narrower than one voxel may be bridged. Where a member leaves the part at a grazing angle the grid can also catch a sliver as a separate one- or two-voxel speck that shrinks with finer voxels. ``min_island_volume`` (mm^3) ignores pieces smaller than that volume when deciding ``connected``; the default ``None`` ignores pieces of fewer than 8 voxels. Pass ``0`` to count every piece. ``cull_far_cells=True`` decides whole 8x8x8 voxel blocks from one probe when the field there is far from zero, which skips empty space around a curved part (about twice as fast on a dome liner). It is exact for true distances, such as stochastic strut lattices, primitives and their booleans, but TPMS fields can change faster than distance, so keep the default ``False`` for designs that contain them. Returns a :class:`ConnectivityReport`; ``bool(report)`` is True for one piece. """ pitch = float(voxel_size) if not math.isfinite(pitch) or pitch <= 0.0: raise ValueError("voxel_size must be positive and finite") threshold = 8.0 * pitch ** 3 if min_island_volume is None else float(min_island_volume) if not math.isfinite(threshold) or threshold < 0.0: raise ValueError("min_island_volume must be non-negative and finite") report = pv.analyze_connected_components( node, pitch, connectivity=int(connectivity), bounds=bounds, cull_far_cells=bool(cull_far_cells), ) return ConnectivityReport(report, threshold)
[docs] def require_connected(node, voxel_size, connectivity=26, bounds=None, min_island_volume=None): """Raise ``ValueError`` unless :func:`check_connectivity` finds exactly one piece. The message lists where the extra pieces are so the transition band, beam thickness or seed settings can be adjusted. Returns the report when the node is connected. """ report = check_connectivity(node, voxel_size, connectivity, bounds, min_island_volume) if not report.connected: raise ValueError( "structure is not a single connected piece; widen the transition band, thicken " "members, or remove islands\n" + str(report) ) return report