Source code for pyvcad_metamaterials.properties

"""Lightweight geometric characterization for architected materials.

The functions in this module use surface meshes, signed-distance samples, or
unit-cell graph definitions. They intentionally do not estimate stiffness,
strength, permeability, or any other solver-dependent property.
"""

# STD
import math
from collections import Counter

# NumPy
import numpy as np

# OpenVCAD
import pyvcad as pv

# Local
from .unit_cells import unit_cell_edges


AXES = ("x", "y", "z")


def _summary(values):
    values = tuple(float(value) for value in values)
    if not values:
        return None, None, None, None
    return min(values), sum(values) / len(values), max(values), values


def _vec3_tuple(value):
    if hasattr(value, "x") and hasattr(value, "y") and hasattr(value, "z"):
        return float(value.x), float(value.y), float(value.z)
    values = tuple(float(item) for item in value)
    if len(values) != 3:
        raise ValueError("expected exactly three coordinates")
    return values


def _coerce_bounds(bounds):
    try:
        minimum, maximum = bounds
    except (TypeError, ValueError):
        raise ValueError("bounds must contain minimum and maximum 3D points") from None
    minimum = _vec3_tuple(minimum)
    maximum = _vec3_tuple(maximum)
    if any(maximum[index] <= minimum[index] for index in range(3)):
        raise ValueError("bounds maximum must be greater than minimum on every axis")
    return minimum, maximum


def _coerce_cell_size(cell_size):
    if isinstance(cell_size, (int, float)):
        values = (float(cell_size),) * 3
    else:
        values = _vec3_tuple(cell_size)
    if any(value <= 0.0 for value in values):
        raise ValueError("cell_size must be positive on every axis")
    return values


def _format_number(value):
    if value is None:
        return "not defined"
    return "{:.6g}".format(value)


[docs] class CrossSectionProfile: """Directional solid-area samples through a voxelized design.""" __slots__ = { "axis": "Axis normal to the sections: 'x', 'y' or 'z'.", "positions": "Section positions along the axis in mm (voxel centres).", "solid_areas": "Solid area of each section in mm^2.", "area_fractions": "Solid fraction of each section, in [0, 1].", "minimum_area_fraction": "Smallest section fraction, or None with no sections.", "mean_area_fraction": "Mean section fraction, or None with no sections.", "maximum_area_fraction": "Largest section fraction, or None with no sections.", "coefficient_of_variation": ( "Standard deviation of the fractions divided by their mean; None when " "the mean is 0." ), } def __init__(self, axis, positions, solid_areas, area_fractions): self.axis = axis self.positions = tuple(float(value) for value in positions) self.solid_areas = tuple(float(value) for value in solid_areas) self.area_fractions = tuple(float(value) for value in area_fractions) if self.area_fractions: self.minimum_area_fraction = min(self.area_fractions) self.mean_area_fraction = sum(self.area_fractions) / len(self.area_fractions) self.maximum_area_fraction = max(self.area_fractions) if self.mean_area_fraction > 0.0: variance = sum( (value - self.mean_area_fraction) ** 2 for value in self.area_fractions ) / len(self.area_fractions) self.coefficient_of_variation = math.sqrt(variance) / self.mean_area_fraction else: self.coefficient_of_variation = None else: self.minimum_area_fraction = None self.mean_area_fraction = None self.maximum_area_fraction = None self.coefficient_of_variation = None def as_dict(self): return { "axis": self.axis, "positions": self.positions, "solid_areas": self.solid_areas, "area_fractions": self.area_fractions, "minimum_area_fraction": self.minimum_area_fraction, "mean_area_fraction": self.mean_area_fraction, "maximum_area_fraction": self.maximum_area_fraction, "coefficient_of_variation": self.coefficient_of_variation, }
[docs] class GeometricProperties: """Geometric measurements derived from a closed triangle surface mesh.""" __slots__ = { "bounds": "Analysis envelope as ((x0, y0, z0), (x1, y1, z1)) in mm.", "envelope_size": "Envelope edge lengths (x, y, z) in mm.", "envelope_volume": "Envelope volume in mm^3.", "solid_volume": "Solid volume in mm^3.", "void_volume": "Envelope volume minus solid volume in mm^3 (at least 0).", "relative_density": "Solid volume divided by envelope volume.", "porosity": "One minus the relative density.", "surface_area": ( "Area of every mesh triangle in mm^2, including caps on the envelope " "faces." ), "interface_area": ( "Solid-void interface area in mm^2: surface_area without triangles " "lying on an envelope face." ), "surface_area_to_solid_volume": ( "surface_area / solid_volume in 1/mm, or None for zero volume." ), "interface_area_to_solid_volume": ( "interface_area / solid_volume in 1/mm, or None for zero volume." ), "specific_surface_area": "interface_area / envelope_volume in 1/mm.", "normalized_interface_area": ( "interface_area / envelope_volume^(2/3), dimensionless." ), "hydraulic_diameter": ( "4 * void_volume / interface_area in mm, or None with no interface." ), "centroid": "Centroid of the solid (x, y, z) in mm.", "mesh_vertex_count": "Number of mesh vertices used by triangles.", "mesh_edge_count": "Number of unique mesh edges.", "mesh_triangle_count": "Number of mesh triangles.", "surface_component_count": "Number of connected surface pieces in the mesh.", "solid_component_count": ( "Number of 26-connected solid pieces in the voxel grid (characterize() " "only), else None." ), "euler_characteristic": "Mesh Euler characteristic V - E + F.", "genus": ( "Total genus of a closed mesh, or None when the mesh is open or the " "value is not an integer." ), "is_closed": "True when every mesh edge is shared by exactly two triangles.", "voxel_size": "Sampling voxel size in mm (characterize() only), else None.", "periodic_axes": "Axes treated as periodic when counting solid components.", "cross_sections": ( "CrossSectionProfile per axis name (characterize() only), else empty." ), } def __init__( self, bounds, envelope_size, envelope_volume, solid_volume, surface_area, interface_area, centroid, mesh_vertex_count, mesh_edge_count, mesh_triangle_count, surface_component_count, euler_characteristic, genus, is_closed, voxel_size=None, periodic_axes=(), cross_sections=None, solid_component_count=None, ): self.bounds = bounds self.envelope_size = envelope_size self.envelope_volume = float(envelope_volume) self.solid_volume = float(solid_volume) self.void_volume = max(self.envelope_volume - self.solid_volume, 0.0) self.relative_density = self.solid_volume / self.envelope_volume self.porosity = 1.0 - self.relative_density self.surface_area = float(surface_area) self.interface_area = float(interface_area) self.surface_area_to_solid_volume = ( self.surface_area / self.solid_volume if self.solid_volume > 0.0 else None ) self.interface_area_to_solid_volume = ( self.interface_area / self.solid_volume if self.solid_volume > 0.0 else None ) self.specific_surface_area = self.interface_area / self.envelope_volume self.normalized_interface_area = ( self.interface_area / self.envelope_volume ** (2.0 / 3.0) ) self.hydraulic_diameter = ( 4.0 * self.void_volume / self.interface_area if self.interface_area > 0.0 else None ) self.centroid = centroid self.mesh_vertex_count = int(mesh_vertex_count) self.mesh_edge_count = int(mesh_edge_count) self.mesh_triangle_count = int(mesh_triangle_count) self.surface_component_count = int(surface_component_count) self.solid_component_count = solid_component_count self.euler_characteristic = int(euler_characteristic) self.genus = genus self.is_closed = bool(is_closed) self.voxel_size = voxel_size self.periodic_axes = tuple(periodic_axes) self.cross_sections = cross_sections or {} def as_dict(self): result = { name: getattr(self, name) for name in self.__slots__ if name != "cross_sections" } result["cross_sections"] = { axis: profile.as_dict() for axis, profile in self.cross_sections.items() } return result def summary(self): lines = [ "Geometric characterization", " relative density: {}".format(_format_number(self.relative_density)), " porosity: {}".format(_format_number(self.porosity)), " solid volume: {} mm^3".format(_format_number(self.solid_volume)), " interface area: {} mm^2".format(_format_number(self.interface_area)), " specific surface area: {} mm^-1".format( _format_number(self.specific_surface_area) ), " hydraulic diameter: {} mm".format( _format_number(self.hydraulic_diameter) ), " surface components: {}".format(self.surface_component_count), ] if self.solid_component_count is not None: lines.append(" voxel solid components: {}".format(self.solid_component_count)) if self.periodic_axes: lines.append( " periodic connectivity axes: {}".format( ", ".join(axis.upper() for axis in self.periodic_axes) ) ) for axis in AXES: profile = self.cross_sections.get(axis) if profile is not None: lines.append( " {} cross-section fraction: mean {}, CV {}".format( axis.upper(), _format_number(profile.mean_area_fraction), _format_number(profile.coefficient_of_variation), ) ) return "\n".join(lines) def __str__(self): return self.summary()
[docs] class GraphProperties: """Exact geometric and connectivity measurements for a strut unit cell.""" __slots__ = { "name": "Name of the characterized unit cell.", "cell_size": "Cell size (x, y, z) in mm.", "beam_radius": "Strut radius in mm.", "node_count": "Number of nodes (strut end points) in the cell.", "strut_count": "Number of struts in the cell.", "component_count": "Number of connected components of the strut graph.", "cycle_rank": "Independent cycles: strut_count - node_count + component_count.", "degree_histogram": ( "Map from coordination number (struts per node) to node count." ), "minimum_degree": "Smallest coordination number.", "mean_degree": "Mean coordination number.", "maximum_degree": "Largest coordination number.", "total_strut_length": "Sum of strut lengths in mm.", "minimum_strut_length": "Shortest strut in mm.", "mean_strut_length": "Mean strut length in mm.", "maximum_strut_length": "Longest strut in mm.", "minimum_aspect_ratio": "Smallest strut length / diameter (L/D).", "mean_aspect_ratio": "Mean strut L/D.", "maximum_aspect_ratio": "Largest strut L/D.", "minimum_slenderness_ratio": ( "Smallest strut length / radius of gyration (L/r_g, with r_g = r/2)." ), "mean_slenderness_ratio": "Mean strut L/r_g.", "maximum_slenderness_ratio": "Largest strut L/r_g.", "strut_lengths": "Length of every strut in mm.", "aspect_ratios": "L/D of every strut.", "slenderness_ratios": "L/r_g of every strut.", } def __init__( self, name, cell_size, beam_radius, node_count, strut_count, component_count, degrees, strut_lengths, ): self.name = name self.cell_size = cell_size self.beam_radius = float(beam_radius) self.node_count = int(node_count) self.strut_count = int(strut_count) self.component_count = int(component_count) self.cycle_rank = self.strut_count - self.node_count + self.component_count self.degree_histogram = dict(sorted(Counter(degrees).items())) degree_min, degree_mean, degree_max, _ = _summary(degrees) self.minimum_degree = degree_min self.mean_degree = degree_mean self.maximum_degree = degree_max length_min, length_mean, length_max, lengths = _summary(strut_lengths) self.strut_lengths = lengths self.minimum_strut_length = length_min self.mean_strut_length = length_mean self.maximum_strut_length = length_max self.total_strut_length = sum(lengths) aspects = tuple(length / (2.0 * self.beam_radius) for length in lengths) slenderness = tuple(2.0 * length / self.beam_radius for length in lengths) aspect_min, aspect_mean, aspect_max, self.aspect_ratios = _summary(aspects) slender_min, slender_mean, slender_max, self.slenderness_ratios = _summary( slenderness ) self.minimum_aspect_ratio = aspect_min self.mean_aspect_ratio = aspect_mean self.maximum_aspect_ratio = aspect_max self.minimum_slenderness_ratio = slender_min self.mean_slenderness_ratio = slender_mean self.maximum_slenderness_ratio = slender_max def as_dict(self): return {name: getattr(self, name) for name in self.__slots__} def summary(self): return "\n".join( [ "Graph unit-cell characterization: {}".format(self.name), " nodes / struts: {} / {}".format(self.node_count, self.strut_count), " connected components: {}".format(self.component_count), " cycle rank: {}".format(self.cycle_rank), " mean coordination number: {}".format( _format_number(self.mean_degree) ), " total strut length: {} mm".format( _format_number(self.total_strut_length) ), " mean L/D aspect ratio: {}".format( _format_number(self.mean_aspect_ratio) ), " mean L/r_g slenderness: {}".format( _format_number(self.mean_slenderness_ratio) ), ] ) def __str__(self): return self.summary()
def _mesh_topology(triangles): edge_counts = Counter() adjacency = {} used_vertices = set() for triangle in triangles: a, b, c = (int(value) for value in triangle) used_vertices.update((a, b, c)) adjacency.setdefault(a, set()).update((b, c)) adjacency.setdefault(b, set()).update((a, c)) adjacency.setdefault(c, set()).update((a, b)) for edge in ((a, b), (b, c), (c, a)): edge_counts[tuple(sorted(edge))] += 1 components = 0 unseen = set(used_vertices) while unseen: components += 1 stack = [unseen.pop()] while stack: current = stack.pop() for neighbor in adjacency.get(current, ()): if neighbor in unseen: unseen.remove(neighbor) stack.append(neighbor) is_closed = bool(edge_counts) and all(count == 2 for count in edge_counts.values()) characteristic = len(used_vertices) - len(edge_counts) + len(triangles) genus = None if is_closed: genus_value = (2 * components - characteristic) / 2.0 if genus_value >= 0.0 and abs(genus_value - round(genus_value)) < 1e-8: genus = int(round(genus_value)) return len(used_vertices), len(edge_counts), components, characteristic, genus, is_closed def _characterize_mesh( mesh, bounds, boundary_tolerance, solid_volume_override=None, centroid_override=None, allow_open=False, ): if not isinstance(mesh, pv.SurfaceMesh): raise TypeError("mesh must be a pv.SurfaceMesh") vertices = np.asarray([_vec3_tuple(value) for value in mesh.vertices], dtype=float) triangles = np.asarray(mesh.triangles, dtype=np.int64) if vertices.size == 0 or triangles.size == 0: raise ValueError("mesh must contain vertices and triangles") if triangles.ndim != 2 or triangles.shape[1] != 3: raise ValueError("mesh triangles must have three vertex indices") if bounds is None: bounds = _coerce_bounds(mesh.bounding_box()) else: bounds = _coerce_bounds(bounds) minimum = np.asarray(bounds[0], dtype=float) maximum = np.asarray(bounds[1], dtype=float) envelope_size = maximum - minimum envelope_volume = float(np.prod(envelope_size)) if boundary_tolerance is None: boundary_tolerance = max(float(np.linalg.norm(envelope_size)) * 1e-8, 1e-9) boundary_tolerance = float(boundary_tolerance) if boundary_tolerance < 0.0: raise ValueError("boundary_tolerance must be non-negative") a = vertices[triangles[:, 0]] b = vertices[triangles[:, 1]] c = vertices[triangles[:, 2]] cross_products = np.cross(b - a, c - a) triangle_areas = 0.5 * np.linalg.norm(cross_products, axis=1) surface_area = float(np.sum(triangle_areas)) on_envelope = np.zeros(len(triangles), dtype=bool) triangle_vertices = (a, b, c) for axis in range(3): for plane in (minimum[axis], maximum[axis]): on_plane = np.ones(len(triangles), dtype=bool) for points in triangle_vertices: on_plane &= np.abs(points[:, axis] - plane) <= boundary_tolerance on_envelope |= on_plane interface_area = float(np.sum(triangle_areas[~on_envelope])) if solid_volume_override is None: signed_volumes = np.einsum("ij,ij->i", a, np.cross(b, c)) / 6.0 signed_volume = float(np.sum(signed_volumes)) solid_volume = abs(signed_volume) if solid_volume <= 0.0: raise ValueError("mesh encloses zero volume") centroid_numerator = np.sum( signed_volumes[:, None] * (a + b + c) / 4.0, axis=0, ) centroid = tuple(float(value) for value in centroid_numerator / signed_volume) else: solid_volume = float(solid_volume_override) centroid = tuple(float(value) for value in centroid_override) volume_tolerance = max(envelope_volume * 1e-7, 1e-9) if solid_volume > envelope_volume + volume_tolerance: raise ValueError("mesh volume exceeds the supplied envelope volume") ( vertex_count, edge_count, surface_components, characteristic, genus, is_closed, ) = _mesh_topology(triangles) if not is_closed and not allow_open: raise ValueError("mesh must be closed for volume and centroid integration") return GeometricProperties( bounds=tuple(tuple(float(value) for value in point) for point in bounds), envelope_size=tuple(float(value) for value in envelope_size), envelope_volume=envelope_volume, solid_volume=solid_volume, surface_area=surface_area, interface_area=interface_area, centroid=centroid, mesh_vertex_count=vertex_count, mesh_edge_count=edge_count, mesh_triangle_count=len(triangles), surface_component_count=surface_components, euler_characteristic=characteristic, genus=genus, is_closed=is_closed, )
[docs] def characterize_mesh(mesh, bounds=None, boundary_tolerance=None): """Characterize a closed ``pv.SurfaceMesh`` inside a rectangular envelope. ``surface_area`` includes all triangles. ``interface_area`` excludes triangles lying on an envelope face, which removes artificial caps created where a periodic structure is clipped to its unit-cell boundary. """ return _characterize_mesh( mesh, bounds=bounds, boundary_tolerance=boundary_tolerance, )
def _solid_components(occupancy, periodic_axes=()): """Count 26-connected solid components of a [z, y, x] occupancy grid natively.""" occupancy = np.ascontiguousarray(np.asarray(occupancy, dtype=bool)) periodic = tuple(axis in periodic_axes for axis in AXES) report = pv.label_connected_components(occupancy, 26, periodic) return int(report.component_count) def _cross_section_profiles(occupancy, positions, bounds): minimum, maximum = bounds envelope_size = tuple(maximum[index] - minimum[index] for index in range(3)) profiles = {} reductions = { "x": (0, 1), "y": (0, 2), "z": (1, 2), } orthogonal_areas = { "x": envelope_size[1] * envelope_size[2], "y": envelope_size[0] * envelope_size[2], "z": envelope_size[0] * envelope_size[1], } for axis in AXES: fractions = occupancy.mean(axis=reductions[axis]) areas = fractions * orthogonal_areas[axis] profiles[axis] = CrossSectionProfile( axis, positions[axis], areas, fractions, ) return profiles
[docs] def characterize( root, voxel_size, bounds=None, compute_connectivity=True, periodic_axes=(), ): """Characterize any OpenVCAD node using a mesh and a uniform SDF grid. Lengths are in millimetres, areas in square millimetres, volumes in cubic millimetres, and ratios are dimensionless unless their name states otherwise. Smaller ``voxel_size`` values improve fidelity at additional runtime and memory cost. """ if not isinstance(root, pv.Node): raise TypeError("root must be a pyvcad Node") voxel_size = float(voxel_size) if voxel_size <= 0.0: raise ValueError("voxel_size must be positive") if isinstance(periodic_axes, str): periodic_axes = tuple(periodic_axes.lower()) else: periodic_axes = tuple(str(axis).lower() for axis in periodic_axes) if any(axis not in AXES for axis in periodic_axes): raise ValueError("periodic_axes may only contain 'x', 'y', and 'z'") periodic_axes = tuple(axis for axis in AXES if axis in periodic_axes) sample_step = pv.Vec3(voxel_size, voxel_size, voxel_size) root.prepare(sample_step, 3.0) root_bounds = _coerce_bounds(root.bounding_box()) analysis_bounds = _coerce_bounds(bounds) if bounds is not None else root_bounds bounds_tolerance = max(voxel_size * 1e-6, 1e-9) if any( analysis_bounds[0][index] < root_bounds[0][index] - bounds_tolerance or analysis_bounds[1][index] > root_bounds[1][index] + bounds_tolerance for index in range(3) ): raise ValueError("analysis bounds must lie within the node bounding box") sampler = pv.TreeSampler(root, sample_step) nx, ny, nz = sampler.sample_dimensions() signed_distance = np.asarray(sampler.as_signed_distance_array()) signed_distance = signed_distance.reshape((nz, ny, nx)) origin = tuple(root_bounds[0][index] - 2.0 * voxel_size for index in range(3)) coordinates = [ origin[index] + np.arange(dimension) * voxel_size for index, dimension in enumerate((nx, ny, nz)) ] selections = [ (values >= analysis_bounds[0][index]) & (values < analysis_bounds[1][index]) for index, values in enumerate(coordinates) ] if any(not np.any(selection) for selection in selections): raise ValueError("analysis bounds do not contain any sample points") occupancy = signed_distance[ np.ix_(selections[2], selections[1], selections[0]) ] <= 0.0 solid_voxel_count = int(np.count_nonzero(occupancy)) if solid_voxel_count == 0: raise ValueError("analysis bounds contain no solid sample points") envelope_volume = math.prod( analysis_bounds[1][index] - analysis_bounds[0][index] for index in range(3) ) solid_volume = solid_voxel_count / occupancy.size * envelope_volume positions = { "x": coordinates[0][selections[0]], "y": coordinates[1][selections[1]], "z": coordinates[2][selections[2]], } occupied_indices = np.argwhere(occupancy) centroid = ( float(np.mean(positions["x"][occupied_indices[:, 2]])), float(np.mean(positions["y"][occupied_indices[:, 1]])), float(np.mean(positions["z"][occupied_indices[:, 0]])), ) bounds_match = all( abs(analysis_bounds[side][index] - root_bounds[side][index]) <= bounds_tolerance for side in range(2) for index in range(3) ) mesh_root = root if not bounds_match: bounds_node = pv.RectPrism.FromMinAndMax( pv.Vec3(*analysis_bounds[0]), pv.Vec3(*analysis_bounds[1]), ) mesh_root = pv.Intersection(root, bounds_node) mesh = pv.SurfaceMesh(mesh_root, voxel_size, disable_validation=True) report = _characterize_mesh( mesh, bounds=analysis_bounds, boundary_tolerance=voxel_size * 1.5, solid_volume_override=solid_volume, centroid_override=centroid, allow_open=True, ) report.voxel_size = voxel_size report.periodic_axes = periodic_axes report.cross_sections = _cross_section_profiles( occupancy, positions, analysis_bounds, ) if compute_connectivity: report.solid_component_count = _solid_components(occupancy, periodic_axes) return report
[docs] def characterize_graph_cell(cell, cell_size, beam_radius): """Characterize a named or custom strut unit-cell graph exactly. ``aspect_ratio`` is ``L / (2r)``. ``slenderness_ratio`` is ``L / r_g`` for a solid circular strut, where ``r_g = r / 2`` and therefore ``L / r_g = 2L/r``. """ cell_size = _coerce_cell_size(cell_size) beam_radius = float(beam_radius) if beam_radius <= 0.0: raise ValueError("beam_radius must be positive") if isinstance(cell, str): name = cell edges = unit_cell_edges(cell) else: name = "custom" edges = list(cell) if not edges: raise ValueError("cell must contain at least one strut") node_indices = {} indexed_edges = [] lengths = [] for edge in edges: if len(edge) != 2: raise ValueError("every strut must contain two endpoints") endpoints = tuple(_vec3_tuple(point) for point in edge) for point in endpoints: if point not in node_indices: node_indices[point] = len(node_indices) first = node_indices[endpoints[0]] second = node_indices[endpoints[1]] if first == second: raise ValueError("struts must have nonzero length") delta = tuple( (endpoints[1][index] - endpoints[0][index]) * cell_size[index] for index in range(3) ) length = math.sqrt(sum(value * value for value in delta)) if length <= 0.0: raise ValueError("struts must have nonzero length") indexed_edges.append((first, second)) lengths.append(length) adjacency = [set() for _ in node_indices] for first, second in indexed_edges: adjacency[first].add(second) adjacency[second].add(first) unseen = set(range(len(adjacency))) components = 0 while unseen: components += 1 stack = [unseen.pop()] while stack: current = stack.pop() for neighbor in adjacency[current]: if neighbor in unseen: unseen.remove(neighbor) stack.append(neighbor) degrees = [len(neighbors) for neighbors in adjacency] return GraphProperties( name=name, cell_size=cell_size, beam_radius=beam_radius, node_count=len(node_indices), strut_count=len(indexed_edges), component_count=components, degrees=degrees, strut_lengths=lengths, )
__all__ = [ "CrossSectionProfile", "GeometricProperties", "GraphProperties", "characterize", "characterize_mesh", "characterize_graph_cell", ]