Source code for pyvcad_metamaterials.mixing

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