Source code for pyvcad_metamaterials.points

"""Deterministic world-space point sampling inside prepared implicit hosts."""

# STD
import math
import numbers

# Third-party
import numpy as np

# OpenVCAD
import pyvcad as pv

# Local
from .fields import _coerce_attribute


[docs] class StochasticPointSet: """Inspectable output from :func:`sample_points_in_host`.""" def __init__(self, points, local_spacings, seed, candidate_count, rejected_count): #: Accepted points as a list of ``pv.Vec3``. self.points = points #: Local target spacing in mm at each accepted point. self.local_spacings = local_spacings #: Random seed the points were drawn with. self.seed = seed #: Number of random candidates drawn before relaxation. self.candidate_count = candidate_count #: Candidates that were not kept (``candidate_count - len(points)``). self.rejected_count = rejected_count def __len__(self): return len(self.points) def __iter__(self): return iter(self.points) def __getitem__(self, index): return self.points[index]
def _field(value, name): if isinstance(value, numbers.Real) and float(value) <= 0.0: raise ValueError(f"{name} must be positive") return _coerce_attribute(value) def _sample_field(field, point, signed_distance, name): value = float(field.sample( float(point[0]), float(point[1]), float(point[2]), float(signed_distance) )) if not math.isfinite(value) or value <= 0.0: raise ValueError(f"{name} must sample to a positive finite value") return value def _inside_distance(host, point, boundary_clearance): distance = host.evaluate(float(point[0]), float(point[1]), float(point[2])) if distance is None or not math.isfinite(float(distance)): return None if float(distance) > -boundary_clearance: return None return float(distance) def _is_separated(candidate, spacing, points, spacings, ignored_index=None): if not points: return True coordinates = np.asarray(points, dtype=np.float64) deltas = coordinates - candidate squared = np.einsum("ij,ij->i", deltas, deltas) required = 0.5 * (np.asarray(spacings, dtype=np.float64) + spacing) if ignored_index is not None: squared[ignored_index] = math.inf return bool(np.all(squared >= required * required))
[docs] def sample_points_in_host( host, spacing=None, density=None, seed=0, max_count=None, relaxation=0, boundary_clearance=0.0, attempts_per_point=80, ): """Sample deterministic, variably spaced world-space points in a prepared host. Exactly one of ``spacing`` or ``density`` is required. Each may be a positive number, ``FloatAttribute``, or scalar callable accepted by OpenVCAD. Density is interpreted as target points per cubic world unit and converted locally to ``density ** (-1/3)`` spacing. Two samples are accepted when their separation is at least the mean of their local spacings. The host must already have been prepared. A point is inside when its host SDF is at most ``-boundary_clearance``. Rejection sampling therefore remains correct for disconnected and non-convex hosts, but a loose bounding box can be expensive. ``relaxation`` applies deterministic separation-preserving jitter passes. """ if (spacing is None) == (density is None): raise ValueError("provide exactly one of spacing or density") if max_count is not None and int(max_count) < 1: raise ValueError("max_count must be at least one") if int(relaxation) < 0: raise ValueError("relaxation must be non-negative") if float(boundary_clearance) < 0.0: raise ValueError("boundary_clearance must be non-negative") if int(attempts_per_point) < 1: raise ValueError("attempts_per_point must be at least one") minimum, maximum = host.bounding_box() lower = np.array([minimum.x, minimum.y, minimum.z], dtype=np.float64) upper = np.array([maximum.x, maximum.y, maximum.z], dtype=np.float64) extent = upper - lower if not np.all(np.isfinite(extent)) or np.any(extent <= 0.0): raise ValueError("prepared host must have a finite, non-empty bounding box") source_name = "spacing" if spacing is not None else "density" source_field = _field(spacing if spacing is not None else density, source_name) rng = np.random.default_rng(int(seed)) if max_count is None: probe = 0.5 * (lower + upper) probe_distance = _inside_distance(host, probe, float(boundary_clearance)) for _ in range(4096): if probe_distance is not None: break probe = rng.uniform(lower, upper) probe_distance = _inside_distance(host, probe, float(boundary_clearance)) if probe_distance is None: raise RuntimeError( "could not find an interior host point; check preparation, bbox, and clearance" ) source_value = _sample_field(source_field, probe, probe_distance, source_name) estimate_spacing = source_value if spacing is not None else source_value ** (-1.0 / 3.0) max_count = max(4, min(10000, int(np.prod(extent) / estimate_spacing ** 3 * 2.0))) else: max_count = int(max_count) accepted = [] local_spacings = [] candidate_count = 0 consecutive_rejections = 0 rejection_limit = max(200, int(attempts_per_point) * max(4, max_count)) while len(accepted) < max_count and consecutive_rejections < rejection_limit: candidate_count += 1 candidate = rng.uniform(lower, upper) signed_distance = _inside_distance(host, candidate, float(boundary_clearance)) if signed_distance is None: consecutive_rejections += 1 continue source_value = _sample_field(source_field, candidate, signed_distance, source_name) local_spacing = source_value if spacing is not None else source_value ** (-1.0 / 3.0) if _is_separated(candidate, local_spacing, accepted, local_spacings): accepted.append(candidate) local_spacings.append(local_spacing) consecutive_rejections = 0 else: consecutive_rejections += 1 for _ in range(int(relaxation)): for index in rng.permutation(len(accepted)): candidate = accepted[index] + rng.normal(0.0, 0.12 * local_spacings[index], 3) signed_distance = _inside_distance(host, candidate, float(boundary_clearance)) if signed_distance is None: continue source_value = _sample_field(source_field, candidate, signed_distance, source_name) local_spacing = source_value if spacing is not None else source_value ** (-1.0 / 3.0) if _is_separated( candidate, local_spacing, accepted, local_spacings, ignored_index=index ): accepted[index] = candidate local_spacings[index] = local_spacing points = [pv.Vec3(float(point[0]), float(point[1]), float(point[2])) for point in accepted] return StochasticPointSet( points, [float(value) for value in local_spacings], int(seed), candidate_count, candidate_count - len(points), )