"""Field-level topology mixing helpers."""
# STD
import math
import numbers
# OpenVCAD
import pyvcad as pv
# Local
from .fields import _coerce_attribute
def _point(value):
if isinstance(value, pv.Vec3):
return (float(value.x), float(value.y), float(value.z))
try:
coordinates = tuple(float(item) for item in value)
except TypeError as error:
raise TypeError("points must be pv.Vec3 objects or three-number iterables") from error
if len(coordinates) != 3:
raise ValueError("points must contain exactly three coordinates")
if not all(math.isfinite(item) for item in coordinates):
raise ValueError("point coordinates must be finite")
return coordinates
def _axis_index(axis):
if isinstance(axis, str):
normalized = axis.lower()
indices = {"x": 0, "u": 0, "y": 1, "v": 1, "z": 2, "w": 2}
if normalized in indices:
return indices[normalized]
elif isinstance(axis, numbers.Integral) and 0 <= int(axis) <= 2:
return int(axis)
raise ValueError("axis must be x/y/z, u/v/w, or an integer from zero to two")
def _coordinates(coordinates):
"""Accept a CellMap as shorthand for its logical U/V/W position."""
if isinstance(coordinates, pv.CellMap):
return coordinates.logical_position
return coordinates
def _guard(value, coordinates, outside, cell_map=None):
"""Give attributes built on custom coordinates a value where they cannot be sampled.
With a known CellMap the guard skips evaluation outside the map domain (fast); otherwise
any sampling error or non-finite value falls back.
"""
if coordinates is None or outside is None:
return value
fallback = float(outside)
if not math.isfinite(fallback):
raise ValueError("outside must be finite or None")
return value.with_fallback(fallback, cell_map)
[docs]
def ramp(start, end, axis="x", smooth=True, coordinates=None, outside=0.0, easing=None, shape=None,
voxel_size=0.5, bandwidth=10.0):
"""Return a composed, clamped scalar attribute along an axis, between points or between bodies.
The result is 0 at ``start``, 1 at ``end`` and clamped to [0, 1] beyond them, so the
endpoints may be given in either order. ``smooth=True`` applies a smoothstep so the
ramp starts and ends with zero slope; ``smooth=False`` keeps it linear. ``easing`` picks
any curve of ``FloatAttribute.ease`` instead (``"linear"``, ``"smoothstep"``,
``"smootherstep"``, ``"power"``, ``"sine"`` or ``"exponential"``, with ``shape`` for the
exponent of power or the rate of exponential) and overrides ``smooth``. Equal endpoints
raise ``ValueError``: use a sharp region (``pv.Intersection``/``pv.Difference``) for a
step instead of a zero-width band.
The endpoints may be numbers (positions along ``axis``), points, or two nodes. Between
two nodes the ramp is ``pv.FloatAttribute.between(start, end)``: 0 on and inside the
first body, 1 on and inside the second, graded by the distances to both; ``voxel_size``
and ``bandwidth`` prepare the bodies (choose a bandwidth at least as large as the gap).
``coordinates`` may be a scalar attribute for scalar endpoints, a Vec3Attribute (or a
``pv.CellMap``, meaning its ``logical_position``) for axis ramps along u/v/w, or a
Vec3Attribute for point endpoints. It defaults to ordinary Cartesian consumer
coordinates. Custom coordinates such as a CellMap's logical position are undefined
outside the map's domain; there the ramp returns ``outside`` (default 0) instead of
raising. Pass ``outside=None`` to keep the strict behaviour.
"""
cell_map = coordinates if isinstance(coordinates, pv.CellMap) else None
coordinates = _coordinates(coordinates)
if isinstance(start, pv.Node) or isinstance(end, pv.Node):
if not (isinstance(start, pv.Node) and isinstance(end, pv.Node)):
raise TypeError("a ramp between bodies needs two nodes")
if coordinates is not None:
raise ValueError("a ramp between bodies measures distances in the bodies' own space; coordinates do not apply")
value = pv.FloatAttribute.between(start, end, voxel_size=float(voxel_size), bandwidth=float(bandwidth))
elif isinstance(start, numbers.Real) and isinstance(end, numbers.Real):
start_value = float(start)
end_value = float(end)
if not math.isfinite(start_value) or not math.isfinite(end_value):
raise ValueError("ramp endpoints must be finite")
if start_value == end_value:
raise ValueError("ramp endpoints must be distinct")
axis_index = _axis_index(axis)
if coordinates is None:
coordinate = pv.FloatAttribute(("x", "y", "z")[axis_index])
elif isinstance(coordinates, pv.FloatAttribute):
coordinate = coordinates
elif isinstance(coordinates, pv.Vec3Attribute):
coordinate = coordinates.component(axis_index)
else:
raise TypeError("coordinates must be a FloatAttribute or Vec3Attribute")
value = coordinate.map_range(start_value, end_value, 0.0, 1.0)
else:
first = _point(start)
second = _point(end)
direction = tuple(second[index] - first[index] for index in range(3))
squared_length = sum(value * value for value in direction)
if squared_length <= 0.0:
raise ValueError("ramp points must be distinct")
if coordinates is None:
position = pv.Vec3Attribute("x", "y", "z")
elif isinstance(coordinates, pv.Vec3Attribute):
position = coordinates
else:
raise TypeError("point ramps require coordinates to be a Vec3Attribute")
origin = pv.Vec3Attribute(*first)
direction_attribute = pv.Vec3Attribute(*direction)
value = (position - origin).dot(direction_attribute) / squared_length
# ease() clamps to [0, 1] before shaping, so the ramp holds 0 and 1 beyond its ends.
if easing is None:
easing = "smoothstep" if smooth else "linear"
result = value.ease(easing, shape)
return _guard(result, coordinates, outside, cell_map)
[docs]
def idw_weight(points, index, exponent=2.0, coordinates=None, outside=0.0):
"""Return one seed's normalized inverse-distance weight as a scalar attribute.
The weight is 1 at its own seed, 0 at the other seeds, and the weights of all seeds
sum to 1 everywhere. Seeds must be distinct. ``coordinates`` (a Vec3Attribute or a
``pv.CellMap``) measures distances in another space, for example logical cell units;
where those coordinates are undefined the weight is ``outside`` (``None`` raises).
"""
cell_map = coordinates if isinstance(coordinates, pv.CellMap) else None
coordinates = _coordinates(coordinates)
seeds = [_point(point) for point in points]
if not seeds:
raise ValueError("points must contain at least one seed")
target = int(index)
if target != index or target < 0 or target >= len(seeds):
raise ValueError("index must select one seed")
power = float(exponent)
if not math.isfinite(power) or power <= 0.0:
raise ValueError("exponent must be positive and finite")
for first in range(len(seeds)):
for second in range(first + 1, len(seeds)):
if seeds[first] == seeds[second]:
raise ValueError("IDW seed points must be distinct")
if coordinates is None:
position = pv.Vec3Attribute("x", "y", "z")
elif isinstance(coordinates, pv.Vec3Attribute):
position = coordinates
else:
raise TypeError("coordinates must be a Vec3Attribute")
# The same native weight as the two-body field (pv.FloatAttribute.between), over seed distances.
distances = [(position - pv.Vec3Attribute(*seed)).magnitude() for seed in seeds]
weight = pv.FloatAttribute.inverse_distance_weight(distances, target, power)
return _guard(weight, coordinates, outside, cell_map)
[docs]
def mix(left, right, weight=None, *, region=None, half_width=None):
"""Mix two nodes with a scalar attribute or native region-distance ramp."""
if region is not None:
if weight is not None:
raise ValueError("provide either weight or region, not both")
if half_width is None:
raise ValueError("region mixing requires half_width")
return pv.Mix(left, right, region, float(half_width))
if half_width is not None:
raise ValueError("half_width is only valid with region")
if weight is None:
raise ValueError("field-driven mixing requires weight")
return pv.Mix(left, right, _coerce_attribute(weight))
[docs]
def mix_many(nodes, points, exponent=2.0, coordinates=None, outside=0.0):
"""Fold N nodes into one binary Mix tree using partial IDW weights.
Node ``i`` dominates near ``points[i]``. Where custom ``coordinates`` are undefined
(outside a CellMap domain) every partial weight is ``outside``; the default 0 keeps the
first node there.
"""
bodies = list(nodes)
seeds = list(points)
if not bodies:
raise ValueError("mix_many requires at least one node")
if len(bodies) != len(seeds):
raise ValueError("mix_many requires one seed point per node")
result = bodies[0]
for index in range(1, len(bodies)):
partial = idw_weight(
seeds[:index + 1], index, exponent=exponent, coordinates=coordinates,
outside=outside,
)
result = mix(result, bodies[index], partial)
return result
def _combine_region(inside, rest, region, mode, half_width):
if mode == "sharp":
return pv.Union(pv.Intersection(inside, region), pv.Difference(rest, region))
if mode == "mix":
return pv.Mix(inside, rest, region, half_width)
return overlap(inside, rest, region, half_width)
[docs]
def overlap(inside, outside, region, half_width):
"""Join two structures by keeping both in a band around a region surface.
``inside`` is kept within ``region`` grown by ``half_width``; ``outside`` is kept outside
``region`` shrunk by ``half_width``. In the ``2 * half_width`` band both are present and
their physical intersections form the join. This is the robust choice for unlike
families (TPMS to graph, Voronoi to TPMS) where blending the two distance fields can
pinch apart. ``region`` can be any closed node, for example a box, sphere, or CAD body.
"""
width = float(half_width)
if not math.isfinite(width) or width <= 0.0:
raise ValueError("half_width must be positive and finite")
grown = pv.Offset(width, region)
shrunk = pv.Offset(-width, region)
return pv.Union(pv.Intersection(inside, grown), pv.Difference(outside, shrunk))
[docs]
def zones(nodes, regions, mode="sharp", half_width=None):
"""Assign one structure per region with N-way priority.
``nodes[i]`` fills ``regions[i]`` (minus earlier regions) and the last node fills the
rest, so ``len(nodes)`` must be ``len(regions) + 1``. ``mode`` chooses how neighbours
meet: ``"sharp"`` cuts at the region surface, ``"mix"`` blends distance fields across a
``2 * half_width`` band (compatible families only, see :func:`mix`), and ``"overlap"``
keeps both structures in the band (see :func:`overlap`). Build every structure on the
same CellMap so cells line up across zones.
"""
bodies = list(nodes)
cutters = list(regions)
if not cutters:
raise ValueError("zones requires at least one region")
if len(bodies) != len(cutters) + 1:
raise ValueError("zones requires exactly one more node than regions")
if mode not in ("sharp", "mix", "overlap"):
raise ValueError("mode must be 'sharp', 'mix', or 'overlap'")
width = None
if mode == "sharp":
if half_width is not None:
raise ValueError("half_width is only used by the 'mix' and 'overlap' modes")
else:
if half_width is None:
raise ValueError("the '{}' mode requires half_width".format(mode))
width = float(half_width)
if not math.isfinite(width) or width <= 0.0:
raise ValueError("half_width must be positive and finite")
result = bodies[-1]
for index in range(len(cutters) - 1, -1, -1):
result = _combine_region(bodies[index], result, cutters[index], mode, width)
return result