"""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",
]