"""Random-to-regular Voronoi transitions by seed regularization.
A Voronoi diagram of a *regular* point set is itself a familiar strut lattice:
========== ========================================= ==========================
Sites Voronoi cells Matching graph unit cell
========== ========================================= ==========================
``"sc"`` cubes (sites at cell centres) ``"cubic"``
``"bcc"`` truncated octahedra (Kelvin cells) ``"kelvin"``
``"fcc"`` rhombic dodecahedra :func:`voronoi_unit_cell`
========== ========================================= ==========================
So a stochastic Voronoi lattice turns into a periodic strut lattice when its seeds move
from random positions onto such sites. The helpers below build those seeds from a
regularity field (0 = random, 1 = regular) and feed them to one ``StochasticGraphLattice``:
the random foam and the regular lattice are the same Voronoi graph, so they are connected
node to node by construction. Placing the sites through a ``CellMap`` makes the regular
region follow a curved part and line up with a mapped graph lattice of the matching cell.
"""
# STD
import math
import numbers
# Third-party
import numpy as np
# OpenVCAD
import pyvcad as pv
# Local
from .fields import _coerce_attribute
from .maps import _as_maps
from .points import StochasticPointSet
from .stochastic import voronoi_graph_lattice
SITE_KINDS = ("sc", "bcc", "fcc")
# Site offsets inside one unit cell, in cell units, chosen so the Voronoi cells line up with
# the cell boundaries of a graph lattice on the same grid.
_SITE_OFFSETS = {
"sc": ((0.5, 0.5, 0.5),),
"bcc": ((0.0, 0.0, 0.0), (0.5, 0.5, 0.5)),
"fcc": ((0.0, 0.0, 0.0), (0.5, 0.5, 0.0), (0.5, 0.0, 0.5), (0.0, 0.5, 0.5)),
}
# Nearest-neighbour distance of each site set for a unit cubic cell.
_NEAREST_NEIGHBOR = {"sc": 1.0, "bcc": math.sqrt(3.0) / 2.0, "fcc": 1.0 / math.sqrt(2.0)}
# Saturated Poisson-disk samples from pv.sample_poisson_seeds hold about this many points
# per spacing^3 (measured in a large box); used to match random and regular seed density.
_POISSON_DENSITY = 0.60
def _kind(value):
name = str(value).lower()
if name not in _SITE_OFFSETS:
raise ValueError("kind must be one of: " + ", ".join(SITE_KINDS))
return name
def _vec3_tuple(value):
if isinstance(value, pv.Vec3):
return (float(value.x), float(value.y), float(value.z))
if isinstance(value, numbers.Real):
return (float(value),) * 3
values = tuple(float(item) for item in value)
if len(values) != 3:
raise ValueError("expected a number or three coordinates")
return values
def _point_list(points):
if isinstance(points, StochasticPointSet):
return list(points.points)
if isinstance(points, TransitionSeeds):
return list(points.points)
if isinstance(points, np.ndarray):
return [pv.Vec3(float(x), float(y), float(z)) for x, y, z in points.reshape(-1, 3)]
return [point if isinstance(point, pv.Vec3) else pv.Vec3(*_vec3_tuple(point)) for point in points]
def _as_array(points):
return np.asarray([(p.x, p.y, p.z) for p in points], dtype=np.float64).reshape(-1, 3)
[docs]
def voronoi_unit_cell(kind):
"""Return the graph unit cell whose tiling equals the Voronoi edges of ``kind`` sites.
``"sc"`` gives the cubic cell, ``"bcc"`` the Kelvin cell (truncated octahedra) and
``"fcc"`` the rhombic-dodecahedron cell. The edges are in the same origin-centred,
normalized ``[-0.5, 0.5]`` convention as the built-in cells, so the result can be passed
to :func:`pyvcad_metamaterials.graph_lattice` with the same CellMap used for
:func:`cell_map_sites`. It is :func:`periodic_voronoi` of the sites, so other seed sets
work the same way.
"""
from .tessellations import periodic_voronoi
return periodic_voronoi(_SITE_OFFSETS[_kind(kind)]).edges()
[docs]
def lattice_sites(kind, bounds, cell_size, origin=None, margin=0.0):
"""Return regular ``"sc"``, ``"bcc"`` or ``"fcc"`` sites inside an axis-aligned box.
``bounds`` is a ``(minimum, maximum)`` pair; ``cell_size`` is a number or three numbers in
mm; ``origin`` is a lattice corner (defaults to ``minimum``). Sites within ``margin`` of
the box are included. To line the sites up with a rectangular map's graph lattice, use
the map's minimum corner as ``origin`` and its cell size.
"""
name = _kind(kind)
minimum = np.asarray(_vec3_tuple(bounds[0]))
maximum = np.asarray(_vec3_tuple(bounds[1]))
size = np.asarray(_vec3_tuple(cell_size))
if np.any(size <= 0.0) or not np.all(np.isfinite(size)):
raise ValueError("cell_size must be positive and finite")
if np.any(maximum <= minimum):
raise ValueError("bounds maximum must exceed minimum on every axis")
start = minimum if origin is None else np.asarray(_vec3_tuple(origin))
lower = minimum - float(margin)
upper = maximum + float(margin)
first = np.floor((lower - start) / size) - 1
last = np.ceil((upper - start) / size) + 1
axes = [np.arange(first[axis], last[axis] + 1) for axis in range(3)]
cells = np.stack(np.meshgrid(*axes, indexing="ij"), axis=-1).reshape(-1, 3)
sites = []
tolerance = 1e-9 * float(np.max(size))
for offset in _SITE_OFFSETS[name]:
positions = start + (cells + np.asarray(offset)) * size
inside = np.all((positions >= lower - tolerance) & (positions <= upper + tolerance), axis=1)
sites.append(positions[inside])
positions = np.concatenate(sites, axis=0)
order = np.lexsort((positions[:, 0], positions[:, 1], positions[:, 2]))
return [pv.Vec3(float(x), float(y), float(z)) for x, y, z in positions[order]]
def _extended_forward(cell_map, logical, counts):
"""Map a logical point, continuing the map linearly beyond its logical box."""
clamped = [min(max(logical[axis], 0.0), float(counts[axis])) for axis in range(3)]
base = cell_map.forward(pv.Vec3(*clamped))
if all(clamped[axis] == logical[axis] for axis in range(3)):
return base
du, dv, dw = cell_map.jacobian(pv.Vec3(*clamped))
step = [logical[axis] - clamped[axis] for axis in range(3)]
return pv.Vec3(
base.x + du.x * step[0] + dv.x * step[1] + dw.x * step[2],
base.y + du.y * step[0] + dv.y * step[1] + dw.y * step[2],
base.z + du.z * step[0] + dv.z * step[1] + dw.z * step[2],
)
[docs]
def cell_map_sites(cell_map, kind, margin=0.0):
"""Return ``kind`` sites placed through a CellMap ("align to graph points").
Sites are generated in logical cell coordinates and mapped to world space, so the
regular region of a transition follows the map, and its Voronoi cells line up with a
graph lattice of :func:`voronoi_unit_cell` on the same map. Sites in trimmed-away
elements are skipped, periodic seams are not duplicated, and a
``pv.CellMapCollection`` contributes the sites of every map.
``margin`` (in cells) also returns sites up to that far outside the map's logical box
on non-periodic axes, continuing the map linearly from its boundary. These outside
sites bound the Voronoi cells at the map surface (see :func:`voronoi_transition`).
Voronoi cells are only similarity-invariant: the alignment is exact for cubic cells on a
rectangular map and approximate where the map stretches or bends cells. Keep cells near
cubic (similar size along U, V and W).
"""
name = _kind(kind)
extra = float(margin)
if not math.isfinite(extra) or extra < 0.0:
raise ValueError("margin must be non-negative and finite")
result = []
for item in _as_maps(cell_map):
counts = item.cells
periodic = item.periodic_axes
for offset in _SITE_OFFSETS[name]:
ranges = []
for axis in range(3):
if periodic[axis]:
values = [index + offset[axis] for index in range(counts[axis])]
else:
low = -int(math.ceil(extra)) - 1
high = counts[axis] + int(math.ceil(extra)) + 1
values = [index + offset[axis] for index in range(low, high + 1)]
values = [value for value in values
if -extra - 1e-9 <= value <= counts[axis] + extra + 1e-9]
ranges.append(values)
for w in ranges[2]:
for v in ranges[1]:
for u in ranges[0]:
try:
result.append(_extended_forward(item, (u, v, w), counts))
except (RuntimeError, ValueError):
continue
return result
def _nearest_neighbor_distance(kind, cell_size=None, cell_map=None):
if cell_size is not None:
size = np.asarray(_vec3_tuple(cell_size))
return _NEAREST_NEIGHBOR[kind] * float(np.prod(size) ** (1.0 / 3.0))
lengths = []
for item in _as_maps(cell_map):
counts = item.cells
for u in np.linspace(0.25, counts[0] - 0.25, 5):
for v in np.linspace(0.25, counts[1] - 0.25, 5):
for w in np.linspace(0.25, counts[2] - 0.25, 3):
try:
du, dv, dw = item.jacobian(pv.Vec3(float(u), float(v), float(w)))
except (RuntimeError, ValueError):
continue
volume = abs(np.linalg.det(np.asarray([_vec3_tuple(du), _vec3_tuple(dv),
_vec3_tuple(dw)])))
if volume > 0.0 and math.isfinite(volume):
lengths.append(volume ** (1.0 / 3.0))
if not lengths:
raise ValueError("could not measure the CellMap's cell size")
return _NEAREST_NEIGHBOR[kind] * float(np.median(lengths))
[docs]
def matched_spacing(kind, cell_size):
"""Return the Poisson-disk spacing whose seed density equals ``kind`` sites.
``cell_size`` is the cubic cell edge in mm (or three edges). Random seeds sampled with
this spacing make Voronoi cells of the same average size as the regular lattice, so the
cell size stays continuous across a transition.
"""
name = _kind(kind)
size = np.asarray(_vec3_tuple(cell_size))
site_density = len(_SITE_OFFSETS[name]) / float(np.prod(size))
return (_POISSON_DENSITY / site_density) ** (1.0 / 3.0)
[docs]
def random_seeds(host, spacing, seed=0, max_count=None, boundary_clearance=0.0,
attempts_per_point=30):
"""Sample Poisson-disk seeds in a prepared host quickly (native, ~10^5 points).
``spacing`` is a positive number, ``FloatAttribute`` or scalar callable (mm). Two seeds
are accepted together when their distance is at least the mean of their local spacings,
the same rule as :func:`sample_points_in_host`, but the native sampler grows the point
set front by front instead of testing every pair, so it suits large hosts. Results are
deterministic for a given ``seed`` but differ from :func:`sample_points_in_host`.
Returns a :class:`StochasticPointSet`.
"""
if isinstance(spacing, numbers.Real) and float(spacing) <= 0.0:
raise ValueError("spacing must be positive")
result = pv.sample_poisson_seeds(
host,
_coerce_attribute(spacing),
seed=int(seed),
max_count=0 if max_count is None else int(max_count),
boundary_clearance=float(boundary_clearance),
attempts_per_point=int(attempts_per_point),
)
return StochasticPointSet(
list(result.points),
list(result.spacings),
int(seed),
int(result.candidate_count),
int(result.rejected_count),
)
[docs]
class TransitionSeeds:
"""Seeds from :func:`transition_seeds` with provenance for inspection.
Attributes:
points (list[pv.Vec3]): Seeds to pass to a Voronoi lattice.
regularity (list[float]): Regularity weight that placed each seed.
sites (list[pv.Vec3]): Regular sites inside the host.
random_points (list[pv.Vec3]): Random seeds used (empty for ``method="jitter"``).
regular_index, random_index (list[int]): Source site / random seed of each point or -1.
matched_count (int): Random seeds paired with a site (``method="match"``).
dropped_count (int): Seeds removed by the minimum-separation check.
nearest_neighbor (float): Site nearest-neighbour distance in mm.
"""
def __init__(self, result, sites, random_points, nearest_neighbor, method):
self.points = list(result.points)
self.regularity = list(result.regularity)
self.regular_index = list(result.regular_index)
self.random_index = list(result.random_index)
self.matched_count = int(result.matched_count)
self.dropped_count = int(result.separation_dropped_count)
self.sites = sites
self.random_points = random_points
self.nearest_neighbor = float(nearest_neighbor)
self.method = method
def __len__(self):
return len(self.points)
def __iter__(self):
return iter(self.points)
def __getitem__(self, index):
return self.points[index]
[docs]
def transition_seeds(
host,
regularity,
kind="bcc",
cell_size=None,
cell_map=None,
origin=None,
method="match",
random_points=None,
random_spacing=None,
jitter=0.5,
match_radius=None,
minimum_separation=None,
seed=0,
boundary_clearance=0.0,
site_margin=0.0,
):
"""Build Voronoi seeds that change from random to a regular lattice.
``regularity`` (number, ``FloatAttribute`` or callable) is 0 where the foam should be
random and 1 where it should be the regular lattice; ramps from :func:`ramp` work well.
Where it cannot be evaluated (for example outside a CellMap domain) it counts as 0.
The regular sites are ``kind`` (``"sc"``, ``"bcc"`` or ``"fcc"``) sites either on an
axis-aligned grid with ``cell_size`` (and optional lattice ``origin``) or, with
``cell_map``, placed through the map (see :func:`cell_map_sites`). Only sites inside the
prepared ``host`` are used.
``method="match"`` (default) pairs each site with a nearby random seed and slides the
seed onto the site by the local regularity, so the seed count and cell size stay
continuous. The random seeds are ``random_points`` (for example a graded
:func:`sample_points_in_host` result) or, by default, a Poisson-disk sample whose density
matches the sites (``random_spacing`` overrides it). ``method="jitter"`` instead displaces
every site by up to ``(1 - regularity) * jitter * nearest_neighbour`` (a perturbed
lattice), which needs no random set.
``match_radius`` (default 0.75 x the site nearest-neighbour distance) limits pairing and
``minimum_separation`` (default 0.3 x) removes seeds that would make sliver cells.
``site_margin`` (cells) extends CellMap sites past the map boundary (see
:func:`cell_map_sites`). Returns :class:`TransitionSeeds`.
"""
name = _kind(kind)
if method not in ("match", "jitter"):
raise ValueError("method must be 'match' or 'jitter'")
if (cell_size is None) == (cell_map is None):
raise ValueError("provide exactly one of cell_size or cell_map")
weight = _coerce_attribute(regularity).with_fallback(0.0)
neighbor = _nearest_neighbor_distance(name, cell_size=cell_size, cell_map=cell_map)
if cell_map is not None:
candidates = cell_map_sites(cell_map, name, margin=site_margin)
else:
minimum, maximum = host.bounding_box()
candidates = lattice_sites(name, (minimum, maximum), cell_size, origin=origin)
kept = pv.select_points_in_host(host, candidates, float(boundary_clearance))
sites = [candidates[index] for index in kept]
if not sites:
raise ValueError("no regular sites lie inside the host; check cell_size, origin and the host")
radius = 0.75 * neighbor if match_radius is None else float(match_radius)
separation = 0.3 * neighbor if minimum_separation is None else float(minimum_separation)
if method == "jitter":
result = pv.jitter_seed_sites(
host, sites, weight, float(jitter) * neighbor, seed=int(seed),
minimum_separation=separation, boundary_clearance=float(boundary_clearance))
return TransitionSeeds(result, sites, [], neighbor, method)
if random_points is None:
if random_spacing is None:
if cell_size is not None:
random_spacing = matched_spacing(name, cell_size)
else:
cube = neighbor / _NEAREST_NEIGHBOR[name]
random_spacing = matched_spacing(name, cube)
random_points = random_seeds(
host, random_spacing, seed=seed, boundary_clearance=boundary_clearance).points
else:
random_points = _point_list(random_points)
result = pv.blend_seed_sets(
host, random_points, sites, weight, radius,
minimum_separation=separation, boundary_clearance=float(boundary_clearance))
return TransitionSeeds(result, sites, random_points, neighbor, method)
[docs]
def voronoi_transition(
host,
regularity,
kind="bcc",
cell_size=None,
cell_map=None,
beam_thickness=1.0,
node_radius=None,
seeds=None,
ghost_layer=None,
boundary="open",
member_blend_radius=0.0,
field_tolerance=0.05,
boundary_tolerance=0.1,
prune_loose=True,
**seed_options
):
"""One call: a Voronoi strut lattice that turns from random foam into a regular lattice.
Builds :func:`transition_seeds` (pass seed options such as ``method``, ``seed`` or
``random_points`` as keywords) and a Voronoi graph lattice from them. Where
``regularity`` is 1 the struts are the edges of cubes (``"sc"``), Kelvin cells
(``"bcc"``) or rhombic dodecahedra (``"fcc"``). Both regions belong to one Voronoi graph,
so they share nodes and the transition itself cannot pinch apart.
``ghost_layer`` (mm, default one site spacing) also places seeds in a layer just outside
the host; the host still clips the geometry. Without that layer the outermost Voronoi
cells are unbounded, so regular cells at the surface lose the struts that lie on it.
``prune_loose=True`` (default) drops strut pieces that the host cuts off at both ends
(chords through a corner or a concave part of the host), which would otherwise print as
loose fragments. With precomputed ``seeds`` no ghost layer is added unless requested.
The host must be prepared. Check the result with
:func:`pyvcad_metamaterials.check_connectivity`; a very thin host can still split.
"""
if seeds is not None and seed_options:
raise ValueError("seed options cannot be combined with precomputed seeds")
if seeds is None:
name = _kind(kind)
if (cell_size is None) == (cell_map is None):
raise ValueError("provide exactly one of cell_size or cell_map")
neighbor = _nearest_neighbor_distance(name, cell_size=cell_size, cell_map=cell_map)
ghost = neighbor if ghost_layer is None else float(ghost_layer)
else:
ghost = 0.0 if ghost_layer is None else float(ghost_layer)
if not math.isfinite(ghost) or ghost < 0.0:
raise ValueError("ghost_layer must be non-negative and finite")
work_host = host
if ghost > 0.0:
work_host = pv.Offset(ghost, host)
step = 0.25 * ghost
work_host.prepare(pv.Vec3(step, step, step), 2.0 * ghost)
if seeds is None:
if cell_map is not None and ghost > 0.0:
seed_options.setdefault("site_margin", 1.0)
seeds = transition_seeds(work_host, regularity, kind=kind, cell_size=cell_size,
cell_map=cell_map, **seed_options)
return voronoi_graph_lattice(
host,
_point_list(seeds),
beam_thickness=beam_thickness,
node_radius=node_radius,
boundary=boundary,
field_tolerance=field_tolerance,
boundary_tolerance=boundary_tolerance,
member_blend_radius=member_blend_radius,
prune_loose=prune_loose,
allow_outside_seeds=True,
)
[docs]
def voronoi_tpms_transition(
host,
region,
tpms,
half_width,
regularity=0.0,
kind="bcc",
cell_size=None,
cell_map=None,
beam_thickness=1.0,
check_voxel_size=None,
**options
):
"""Join a Voronoi foam inside ``region`` to a TPMS outside it through an overlap band.
A TPMS sheet and a strut foam have nothing in common to blend, so this keeps both
structures in a ``2 * half_width`` band around the region surface and lets them
intersect (see :func:`pyvcad_metamaterials.overlap`). The foam is built by
:func:`voronoi_transition` on ``host`` grown into the band, with loose strut pieces
pruned against exactly that boundary, so the band edge leaves no loose fragments.
``regularity`` (0 = random foam) and ``kind``/``cell_size``/``cell_map`` set the foam's
seeds; use the TPMS's own ``cell_map`` so the foam cells match the TPMS cell size.
``beam_thickness`` and the TPMS wall thickness may be fields, for example ramps that
thin each structure as it crosses the band. Extra ``options`` go to
:func:`voronoi_transition`.
Choose ``half_width`` between a quarter and a half of the cell size: narrower bands give
few contacts, much wider ones double the material. With ``check_voxel_size`` the result
is checked with :func:`pyvcad_metamaterials.require_connected` (raises ``ValueError``
when it is not one piece). Returns the joined node, clipped to ``host``.
"""
from .connectivity import require_connected
from .mixing import overlap
width = float(half_width)
if not math.isfinite(width) or width <= 0.0:
raise ValueError("half_width must be positive and finite")
foam_host = pv.Intersection(host, pv.Offset(width, region))
step = 0.25 * width
foam_host.prepare(pv.Vec3(step, step, step), 2.0 * width)
foam = voronoi_transition(
foam_host,
regularity,
kind=kind,
cell_size=cell_size,
cell_map=cell_map,
beam_thickness=beam_thickness,
**options
)
result = pv.Intersection(overlap(foam, tpms, region, width), host)
if check_voxel_size is not None:
require_connected(result, check_voxel_size)
return result
[docs]
def voronoi_foam(
host,
spacing,
seed=0,
beam_thickness=1.0,
node_radius=None,
ghost_layer=None,
prune_loose=True,
member_blend_radius=0.0,
boundary="open",
field_tolerance=0.05,
boundary_tolerance=0.1,
):
"""One call: a random Voronoi strut foam that fills a prepared host in one piece.
Seeds come from :func:`random_seeds` with ``spacing`` (mm; a number, ``FloatAttribute``
or scalar callable), so a spacing field grades the cell size, for example smaller cells
near a head with ``mm.ramp(0, 15, coordinates=mm.distance_field(head))``. Seeds are also
placed in a ``ghost_layer`` just outside the host (default: the typical local spacing)
so the cells at the surface are closed, and ``prune_loose`` drops strut pieces the host
cuts off at both ends. A spacing field must therefore be defined a little outside the
host as well. The host can be any prepared node, including ``mm.domain(cell_map)`` for
a conformal liner volume. Returns ``voronoi_graph_lattice(host, ...)``.
"""
if ghost_layer is None:
probe = pv.sample_poisson_seeds(host, _coerce_attribute(spacing), seed=int(seed), max_count=64)
if len(probe.spacings) == 0:
raise ValueError("could not place seeds in the host; check preparation and spacing")
ghost = float(np.median(np.asarray(probe.spacings)))
else:
ghost = float(ghost_layer)
if not math.isfinite(ghost) or ghost < 0.0:
raise ValueError("ghost_layer must be non-negative and finite")
work_host = host
if ghost > 0.0:
work_host = pv.Offset(ghost, host)
step = 0.25 * ghost
work_host.prepare(pv.Vec3(step, step, step), 2.0 * ghost)
seeds = random_seeds(work_host, spacing, seed=seed)
return voronoi_graph_lattice(
host,
seeds,
beam_thickness=beam_thickness,
node_radius=node_radius,
boundary=boundary,
field_tolerance=field_tolerance,
boundary_tolerance=boundary_tolerance,
member_blend_radius=member_blend_radius,
prune_loose=prune_loose,
allow_outside_seeds=True,
)