from typing import TYPE_CHECKING, Any, Literal
import numpy as np
from pydantic import Field, StrictBool
import genesis as gs
from genesis.typing import (
FArrayType,
FGridType,
IArrayType,
NonNegativeFloat,
NonNegativeInt,
PositiveFArrayType,
PositiveFloat,
PositiveInt,
PositiveVec2FType,
UnitIntervalVec3Type,
UnitIntervalVec4Type,
Vec2FType,
)
from .options import (
ProbeSensorOptionsMixin,
ProbesWithNormalSensorOptionsMixin,
RigidSensorOptionsMixin,
SensorOptions,
SensorT,
SimpleSensorOptions,
_check_len_match,
)
if TYPE_CHECKING:
from genesis.engine.sensors.kinematic_tactile import (
ContactDepthProbeSensor,
ContactProbeSensor,
ElastomerTaxelSensor,
KinematicTaxelSensor,
ProximityTaxelSensor,
)
def _validate_filler_probe_radius(probe_radius, sensor_name: str) -> None:
"""
Validate a ``probe_radius`` that permits 0-valued (inactive padding for grid) entries.
"""
radii = np.atleast_1d(np.asarray(probe_radius, dtype=float))
if np.any(radii < 0.0):
gs.raise_exception(f"{sensor_name} probe_radius entries must be non-negative. Got {probe_radius}.")
if not np.any(radii > 0.0):
gs.raise_exception(f"{sensor_name} requires at least one positive probe_radius. Got {probe_radius}.")
class ViscoelasticHysteresisOptionsMixin(SensorOptions[SensorT]):
"""
Single-Maxwell viscoelastic hysteresis applied on the measured branch only.
Output equals ``x + hysteresis_strength * xi``, where ``xi`` is a per-cache-column state with
``xi_k = exp(-dt / hysteresis_tau) * xi_{k-1} + (x_k - x_{k-1})``. Equilibrium gain is 1 (steady-state output =
steady-state input). On a step input, output transiently overshoots by ``strength``, decaying with time constant
``tau``. On cyclic input this gives a loading-unloading loop in output-vs-input space.
Parameters
----------
hysteresis_strength : float, optional
Dimensionless ratio of the Maxwell branch to the equilibrium branch (``E_1 / E_inf`` with ``E_inf = 1``).
``0`` disables hysteresis. Default ``0``.
hysteresis_tau : float, optional
Relaxation time constant in seconds. Must be positive when ``hysteresis_strength > 0``.
"""
hysteresis_strength: NonNegativeFloat = 0.0
hysteresis_tau: NonNegativeFloat = 0.0
def model_post_init(self, context: Any) -> None:
super().model_post_init(context)
if self.hysteresis_strength > 0.0 and self.hysteresis_tau <= 0.0:
gs.raise_exception(
f"hysteresis_tau ({self.hysteresis_tau}) must be > 0 when hysteresis_strength "
f"({self.hysteresis_strength}) > 0."
)
def _validate_crosstalk_kernel(kernel) -> None:
"""
Validate an explicit crosstalk kernel: 2D ``(N, M)`` or 3D ``(G, N, M)`` with ``G`` in {1, 2, 3}, odd spatial dims
(center tap), and finite entries.
The conservation (sum ~ 1) check is a build-time warning in ``tactile_shared.build_crosstalk_kernels`` (where the
logger is available); the self tap is intentionally allowed to be < 1, which is how a conservative kernel shares a
peak with its neighbors.
"""
arr = np.asarray(kernel, dtype=float)
if arr.ndim not in (2, 3):
gs.raise_exception(f"crosstalk_kernel must be 2D (N, M) or 3D (G, N, M); got shape {arr.shape}.")
if arr.ndim == 3 and arr.shape[0] not in (1, 2, 3):
gs.raise_exception(f"crosstalk_kernel leading (group) dim must be 1, 2, or 3; got {arr.shape[0]}.")
n_rows, n_cols = int(arr.shape[-2]), int(arr.shape[-1])
if n_rows % 2 == 0 or n_cols % 2 == 0:
gs.raise_exception(f"crosstalk_kernel spatial dims must be odd (center tap); got ({n_rows}, {n_cols}).")
if not np.all(np.isfinite(arr)):
gs.raise_exception("crosstalk_kernel entries must be finite.")
class SpatialCrosstalkOptionsMixin(SensorOptions[SensorT]):
"""
Grid spatial crosstalk applied on the measured branch: each taxel's force/torque bleeds onto its grid neighbors,
modeling mechanical coupling through the sensor's compliant layer.
Requires a 2D grid ``probe_local_pos`` (shape ``(ny, nx, 3)`` with non-degenerate spacing); a 0-radius filler probe
stays zero.
Configure it one of two ways. A **Gaussian** blur via ``crosstalk_strength`` + ``crosstalk_sigma`` (one isotropic
kernel on all 6 force/torque channels). Or an **explicit kernel** via ``crosstalk_kernel`` -- a measured
point-spread function used as-is, optionally per force/torque mode. ``genesis.utils.misc.gaussian_crosstalk_kernel``
builds a kernel of a given size.
Parameters
----------
crosstalk_strength : float, optional
Gaussian crosstalk mixing fraction. ``0`` (default) disables; ``1`` is a pure Gaussian blur with sigma
``crosstalk_sigma``. Mutually exclusive with ``crosstalk_kernel``.
crosstalk_sigma : float, optional
Gaussian standard deviation in meters (same units as ``probe_local_pos`` spacing). Must be > 0 when
``crosstalk_strength > 0``.
crosstalk_kernel : array-like, optional
Explicit convolution kernel in taxel-grid-cell units (odd dims; center = self weight), used as-is so the
weights encode self-vs-neighbor coupling and conservation. Shape selects channel grouping: ``(N, M)`` one
kernel for all 6 channels; ``(2, N, M)`` ``[normal force, shear force + torque]``; ``(3, N, M)``
``[normal force, shear force, torque]``. Normal/shear are split along the grid normal. Mutually exclusive
with ``crosstalk_strength``.
"""
crosstalk_strength: NonNegativeFloat = 0.0
crosstalk_sigma: NonNegativeFloat = 0.0
# 2D (N, M) or 3D (G, N, M) float array; shape is validated in model_post_init (pydantic's nested-tuple unions
# do not cleanly disambiguate the 2D/3D float case).
crosstalk_kernel: Any | None = None
@property
def is_crosstalk_enabled(self) -> bool:
return self.crosstalk_strength > 0.0 or self.crosstalk_kernel is not None
def model_post_init(self, context: Any) -> None:
super().model_post_init(context)
if self.crosstalk_strength > 0.0 and self.crosstalk_kernel is not None:
gs.raise_exception("Set only one of crosstalk_strength (Gaussian) or crosstalk_kernel (explicit).")
if self.crosstalk_strength > 0.0 and self.crosstalk_sigma <= 0.0:
gs.raise_exception(
f"crosstalk_sigma ({self.crosstalk_sigma}) must be > 0 when crosstalk_strength "
f"({self.crosstalk_strength}) > 0."
)
if self.crosstalk_kernel is not None:
_validate_crosstalk_kernel(self.crosstalk_kernel)
class TactileProbeSensorOptionsMixin(ProbeSensorOptionsMixin[SensorT]):
"""
Tactile probe sensors estimate contact from geometric depth queries (SDF or raycast) around each probe position
rather than reading the physics solver's contact impulses, so they sense at arbitrary probe locations without
affecting simulation.
Parameters
----------
debug_contact_color: array-like[float, float, float]
RGB color of the debug probe spheres while in contact.
probe_gain : float | array-like[float], optional
Per-taxel multiplicative gain applied to the measured-branch contact depth. Default ``1.0`` (no gain). Accepts
a scalar (applied to all probes) or an array matching the probe count. Force/torque scale as
``gain**normal_exponent`` because the spring-damper sees the gained depth.
probe_gain_resample_range : (float, float), optional
If set, the per-probe gain is resampled uniformly in ``(low, high)`` on every ``scene.reset()``. Disables the
static ``probe_gain`` after the first reset. Default ``None`` (no resampling; gain stays at initial value).
dead_taxel_probability : float, optional
Per-probe Bernoulli probability that the taxel becomes dead on each ``scene.reset()``. Default ``0.0``
(no dead taxels). When set, the intermediate-cache value for dead probes is overwritten by a fresh
per-probe uniform sample in ``dead_taxel_value_range`` (the same value fills every output channel of that
probe) at the hardware-imperfections stage; the GT branch is untouched.
dead_taxel_value_range : (float, float), optional
Uniform range for the dead value sampled per probe on reset. Default ``(0.0, 0.0)``.
contact_depth_query : {"sdf", "raycast"} or None, optional
Per-probe contact-depth backend. ``"sdf"`` queries the per-geom analytic SDF grid (fast, exact for primitives,
requires SDF activation). ``"raycast"`` walks the rigid solver's per-frame collision-mesh BVH and takes the
signed distance to the nearest candidate triangle (sign from the triangle's face normal, negative inside),
so ``pen = R - signed_distance`` matches the SDF backend while handling arbitrary meshes uniformly (shares
the BVH with ``RaycasterSensor``). ``None`` (default) defers the choice: all sensors of the same class must
agree, and the resolved mode is ``"sdf"`` if no sensor of that class sets it.
"""
debug_contact_color: UnitIntervalVec3Type = (1.0, 0.2, 0.0)
probe_gain: PositiveFArrayType | PositiveFloat = 1.0
probe_gain_resample_range: PositiveVec2FType | None = None
dead_taxel_probability: NonNegativeFloat = 0.0
dead_taxel_value_range: Vec2FType = (0.0, 0.0)
contact_depth_query: Literal["sdf", "raycast"] | None = None
def model_post_init(self, context: Any) -> None:
super().model_post_init(context)
n_probes = int(np.prod(np.asarray(self.probe_local_pos).shape[:-1]))
_check_len_match(self.probe_gain, n_probes, "probe_gain", "probe_local_pos")
if self.probe_gain_resample_range is not None:
low, high = float(self.probe_gain_resample_range[0]), float(self.probe_gain_resample_range[1])
if low > high:
gs.raise_exception(f"probe_gain_resample_range must satisfy low <= high. Got ({low}, {high}).")
if self.dead_taxel_probability > 1.0:
gs.raise_exception(f"dead_taxel_probability must be in [0, 1]. Got {self.dead_taxel_probability}.")
low, high = float(self.dead_taxel_value_range[0]), float(self.dead_taxel_value_range[1])
if low > high:
gs.raise_exception(f"dead_taxel_value_range must satisfy low <= high. Got ({low}, {high}).")
class PointCloudTactileSensorMixin(TactileProbeSensorOptionsMixin[SensorT]):
"""
Options mixin for tactile sensors that sample a point cloud from tracked link meshes.
Parameters
----------
track_link_idx : array-like[int]
Global link indices whose mesh geometry is used to sample a point cloud from.
n_sample_points: int | array-like[int]
Total FPS samples split across ``track_link_idx``, or one count per tracked link. Per-variant
counts are not supported: when a tracked link belongs to a heterogeneous entity, the per-link
count is allocated to every variant on that link (so each parallel environment sees the full
count regardless of which variant is active).
use_visual_mesh : bool
Whether to use the visual mesh when sampling the point cloud.
debug_point_cloud_color : array-like[float, float, float, float]
The rgba color of the debug tracked object point cloud spheres.
debug_point_cloud_radius : float
The radius of the debug tracked object point cloud spheres.
"""
track_link_idx: IArrayType = Field(default_factory=tuple)
n_sample_points: IArrayType | NonNegativeInt = 500
use_visual_mesh: StrictBool = True
debug_point_cloud_color: UnitIntervalVec4Type = (1.0, 0.8, 0.0, 1.0)
debug_point_cloud_radius: PositiveFloat = 0.002
class ContactHysteresisOptionsMixin(SensorOptions[SensorT]):
"""
Schmitt-trigger contact gate shared by the stateful tactile sensors, in penetration-depth units.
Depth is in meters and backend-agnostic (both the ``"sdf"`` and ``"raycast"`` contact-depth backends produce the
same depth): the gate latches ON when depth >= ``contact_threshold`` and releases when depth <=
``release_threshold``, and the band between them suppresses chatter. What the gate controls is sensor-specific
(ContactProbe: the boolean output; ElastomerTaxel: the shear anchor state).
Parameters
----------
contact_threshold : float
Penetration depth (meters) at or above which the gate latches on.
release_threshold : float, optional
Penetration depth (meters) at or below which the gate releases. May be negative (the surface must
*separate* by that margin to release). Must be <= ``contact_threshold``. ``None`` (default) equals
``contact_threshold`` (no hysteresis).
"""
contact_threshold: NonNegativeFloat = 0.0001
release_threshold: float | None = None
def model_post_init(self, context: Any) -> None:
super().model_post_init(context)
if self.release_threshold is not None and self.release_threshold > self.contact_threshold:
gs.raise_exception(
f"release_threshold ({self.release_threshold}) must be <= contact_threshold ({self.contact_threshold})."
)
[docs]class KinematicTaxel(
RigidSensorOptionsMixin["KinematicTaxelSensor"],
SimpleSensorOptions["KinematicTaxelSensor"],
TactileProbeSensorOptionsMixin["KinematicTaxelSensor"],
ViscoelasticHysteresisOptionsMixin["KinematicTaxelSensor"],
SpatialCrosstalkOptionsMixin["KinematicTaxelSensor"],
):
"""
A tactile sensor which estimates force and torque per taxel by querying contact depth within the radius of the
probe positions along a rigid entity link and the relative velocity of the probe and the entity in contact.
The force and torque are aligned with the contact surface normal ``n`` at each probe -- the SDF gradient in
``"sdf"`` mode, or the nearest-triangle face normal in ``"raycast"`` mode. The returned force is a spring-damper
estimate based on contact depth and relative motion:
v_n = dot(relative_velocity, n) * n
v_t = relative_velocity - v_n
s = penetration ** normal_exponent
F = (normal_stiffness * s * n) + (normal_damping * s * dot(relative_velocity, n) * n) - (shear_scalar * v_t)
T = cross(probe_local_pos, F) - twist_scalar * dot(relative_angular_velocity, n) * n
as opposed to the actual impulse force on the link from the contact obtained from the physics solver.
Note
----
The depth query only runs against geometry the rigid solver reports as in contact with the sensor's link (the
force/torque come from the spring-damper estimate above, not the contact impulse, but contact *existence* is
gated by the solver). Since the solver skips collision between two fixed entities, a sensor on a fixed entity
will not detect contacts with other fixed entities.
``probe_local_pos`` may be either an arbitrary set of probes with shape ``(N, 3)`` or a grid-shaped set with shape
``(M, N, 3)``. Regular planar grids enable spatial crosstalk on the measured branch (see ``crosstalk_strength``).
A probe whose ``probe_radius`` is 0 is treated as an inactive filler -- it reads 0 force/torque and is skipped --
so an irregular taxel set can be padded into a regular grid for crosstalk.
Parameters
----------
probe_radius : float | array-like[float]
Probe sensing radius in meters. A scalar is shared by every probe; an array must match the probe count.
Array entries of 0 mark inactive filler probes (see the grid note above); at least one must be positive.
normal_stiffness : float
Stiffness for normal force estimation based on contact penetration depth and spring-damper model.
normal_damping : float
Damping for normal force estimation based on contact penetration depth and spring-damper model.
normal_exponent : float
Exponent for contact force estimation based on contact penetration depth and nonlinear spring-damper model.
Default is 1.0, which means linear spring-damper model. Use 1.5 for Hertzian (spherical) contact.
shear_scalar : float, optional
Coefficient for shear force estimation based on relative linear velocity of the probe and entity in contact.
twist_scalar : float, optional
Coefficient for twist torque estimation based on relative angular velocity of the probe and entity in contact.
See ``SpatialCrosstalkOptionsMixin`` for the measured-branch spatial crosstalk parameters
(``crosstalk_strength`` / ``crosstalk_sigma`` / ``crosstalk_kernel``); a regular planar grid is required.
"""
# Permits 0-valued (inactive filler) entries; see _validate_filler_probe_radius.
probe_radius: PositiveFloat | FArrayType | FGridType = 0.01
normal_stiffness: NonNegativeFloat = 1000.0
normal_damping: NonNegativeFloat = 1.0
normal_exponent: NonNegativeFloat = 1.0
shear_scalar: NonNegativeFloat = 1.0
twist_scalar: NonNegativeFloat = 1.0
def model_post_init(self, context: Any) -> None:
super().model_post_init(context)
_validate_filler_probe_radius(self.probe_radius, "KinematicTaxel")
if self.normal_exponent < 1.0:
gs.raise_exception(f"normal_exponent must be greater than or equal to 1.0. Got {self.normal_exponent}.")
[docs]class ElastomerTaxel(
RigidSensorOptionsMixin["ElastomerTaxelSensor"],
SimpleSensorOptions["ElastomerTaxelSensor"],
PointCloudTactileSensorMixin["ElastomerTaxelSensor"],
ProbesWithNormalSensorOptionsMixin["ElastomerTaxelSensor"],
ViscoelasticHysteresisOptionsMixin["ElastomerTaxelSensor"],
ContactHysteresisOptionsMixin["ElastomerTaxelSensor"],
):
"""
An elastomer tactile sensor that implements HydroShear-style marker displacement from Genesis SDF queries.
The tracked rigid links are sampled into indenter on-surface points for shear history, while marker dilation is
queried directly from the tracked geometry SDF.
Note
----
``probe_local_pos`` may be either an arbitrary set of probes with shape ``(N, 3)`` or a grid-shaped set with shape
``(M, N, 3)``. Regular planar grids with one shared normal use FFT acceleration for dilation; other layouts use the
direct dilation path. Shear is computed directly. A probe whose ``probe_radius`` is 0 is treated as an inactive
filler -- it reads 0 and is excluded from dilation/shear -- so an irregular taxel set can be padded into a
regular grid for FFT acceleration.
Note
----
``probe_gain`` is applied to ElastomerTaxel as a post-step linear scale of the measured marker displacement
(the dilation kernel writes a single shared field for both branches). This is exact for the tangential
dilation and shear components but approximate for the normal dilation term, which scales as
``depth**normal_exponent`` and would ideally scale as ``gain**normal_exponent`` rather than ``gain``. For
gains near 1 the error is small.
Parameters
----------
probe_local_pos: array-like[array-like[float, float, float]], shape (N, 3) or (M, N, 3)
Probe positions in link-local frame.
probe_local_normal : array-like[float, float, float] or array-like[array-like[float, float, float]]
Unit direction(s) in link-local frame: one normal for all probes, or one normal per probe matching
``probe_local_pos``.
probe_radius : float | array-like[float]
Probe sensing radius in meters. A scalar is shared by every probe; an array must match the probe count.
Array entries of 0 mark inactive filler probes (see the grid note above); at least one must be positive.
track_link_idx : array-like[int]
Global rigid link indices whose collision geometry is queried by SDF and whose mesh is sampled for shear.
n_sample_points: int | array-like[int]
Total surface samples split across ``track_link_idx``, or one count per tracked link.
compressibility : float
In-plane dilation kernel as a mix of a local and a global response, in ``[0, 1]``. ``1`` (default) is fully
compressible: a concentrated *local* bulge from the ``exp(-lambda_d * r^2)`` Gaussian, with no far-field
reach. ``0`` is fully incompressible: the in-plane displacement is the gradient of the inverse-Laplacian of
the indentation depth field (``~ r_hat / r``) -- the *global* volume-conserving stretch that reaches the
whole sensor, but with a soft center. Intermediate values superimpose a local bulge of weight
``compressibility`` on a global stretch of weight ``1 - compressibility`` (each kernel peak-normalized first
so the weight is meaningful), giving both a sharp local bulge and the global stretch. The endpoints ``0`` and
``1`` skip the unused kernel. The normal (out-of-plane) channel keeps the ``lambda_d`` Gaussian bulge for any
value.
elastomer_thickness : float
Gel layer thickness in meters, bottom face bonded to a rigid backing (the Dirichlet condition a flat-slab
FEM would use). When > 0 (and ``compressibility < 1``), the global in-plane response is that of an
incompressible elastic layer of this thickness instead of the free-space ``1/r``: indentation features much
smaller than the thickness produce little in-plane surface motion (incompressible half-space limit),
wavelengths comparable to the thickness respond most, and the long-range field recovers the ``1/r`` squeeze
flow. Grid (FFT) layouts use the exact spectral layer solution; non-grid (direct) layouts approximate it by
regularizing the ``1/r`` kernel at scale ``h`` (``r_hat * r / (r^2 + h^2)``). ``0`` (default) keeps the
free-space kernel, regularized at the probe spacing (a numerical guard, not a physical scale -- set the
thickness to control the global response physically).
lambda_d: float
Falloff coefficient (in 1/m^2) for the Gaussian dilation kernel ``exp(-lambda_d * r^2)`` (larger = sharper,
more localized; smaller = broader). It sets the width of the local in-plane bulge (weighted by
``compressibility``) and always sets the normal (out-of-plane) bulge width.
lambda_s: float
Gaussian falloff coefficient (in 1/m^2) for the shear kernel ``exp(-lambda_s * r^2)`` that spreads each
anchored tracked-surface point's tangential displacement to nearby probes. Larger values keep shear tightly
local to the contact patch; smaller values produce a softer, more diffuse shear response.
dilate_scale: float
Scalar gain applied to dilation displacement.
shear_scale: float
Scalar gain applied to shear displacement.
normal_exponent: float
Exponent of the penetration-depth power law for the normal (out-of-plane) marker dilation: the normal
bulge scales as ``depth ** normal_exponent``. Must be >= 1.0. Default ``2.0`` (the HydroShear quadratic
normal response). Tangential dilation and shear stay linear in depth regardless of this value.
contact_threshold / release_threshold : float
Schmitt-trigger gate for the **shear anchor** state (see ``ContactHysteresisOptionsMixin``): a tracked
surface point starts anchoring shear when its penetration into the elastomer reaches ``contact_threshold``
and releases when its depth drops to ``release_threshold`` (default ``-1e-4``: it must *separate* by 0.1 mm).
Only consumed when ``shear_scale > 0``; backend-agnostic (sdf and raycast depths alike).
Note
----
Genesis reuses rigid-body SDFs for HydroShear queries. For non-analytic tracked meshes, the collision geometry
should be watertight enough for signed-distance preprocessing, and the attached elastomer link's collision geometry
should represent the compliant contact surface.
"""
# Permits 0-valued (inactive filler) entries; see _validate_filler_probe_radius.
probe_radius: PositiveFloat | FArrayType | FGridType = 0.01
lambda_d: NonNegativeFloat = 700.0
lambda_s: NonNegativeFloat = 300.0
dilate_scale: NonNegativeFloat = 1.0
shear_scale: NonNegativeFloat = 1.0
normal_exponent: NonNegativeFloat = 2.0
compressibility: NonNegativeFloat = 1.0
elastomer_thickness: NonNegativeFloat = 0.0
# Shear-anchor gate defaults (see ContactHysteresisOptionsMixin): anchor at 10 um penetration, release only
# once separated by 0.1 mm.
contact_threshold: NonNegativeFloat = 1e-5
release_threshold: float | None = -1e-4
def model_post_init(self, context: Any) -> None:
super().model_post_init(context)
_validate_filler_probe_radius(self.probe_radius, "ElastomerTaxel")
if len(self.track_link_idx) == 0:
gs.raise_exception("ElastomerTaxel requires at least one tracked link in track_link_idx.")
if self.normal_exponent < 1.0:
gs.raise_exception(f"normal_exponent must be greater than or equal to 1.0. Got {self.normal_exponent}.")
if self.compressibility > 1.0:
gs.raise_exception(f"compressibility must be in [0, 1]. Got {self.compressibility}.")
[docs]class ProximityTaxel(
RigidSensorOptionsMixin["ProximityTaxelSensor"],
SimpleSensorOptions["ProximityTaxelSensor"],
PointCloudTactileSensorMixin["ProximityTaxelSensor"],
ProbesWithNormalSensorOptionsMixin["ProximityTaxelSensor"],
ViscoelasticHysteresisOptionsMixin["ProximityTaxelSensor"],
SpatialCrosstalkOptionsMixin["ProximityTaxelSensor"],
):
"""
A tactile sensor which estimates force and torque per taxel from proximity to point clouds sampled on tracked
meshes within a **spherical** sensing volume of nominal ``probe_radius`` around each taxel.
For each taxel, every tracked point inside that sphere contributes a penetration depth ``P_i = R_eff - ||p_i - o||``
where ``R_eff`` is drawn each simulation step when ``probe_radius_noise`` is non-zero (additive uniform noise
in meters around the sensing radius, clipped nonnegative). Normal force is aligned with ``probe_local_normal``;
shear uses tangential relative velocity. Generic SimpleSensor imperfections (bias, resolution, etc.) still apply.
Outputs are in link-local frame.
Parameters
----------
probe_local_normal : array-like[array-like[float, float, float]]
Unit direction(s) for the normal force channel in link-local frame: one ``(3,)`` for all taxels, or one row per
taxel matching ``probe_local_pos``. Default ``(0, 0, 1)``.
stiffness : float
Linear spring stiffness (N/m) scaling summed penetration depths into total reported force.
shear_coupling : float
Scales penetration-weighted tangential slip ``sum_i P_i * v_{t,i}`` into a shear force contribution (see
sensor documentation). Set to ``0.0`` to disable shear and use only the normal channel.
density_scalar : int
Reference point count for normalizing summed penetrations against tracked cloud size
(scale is ``density_scalar / max(N_pc, 1)`` for this sensor's tracked samples).
See ``SpatialCrosstalkOptionsMixin`` for the measured-branch spatial crosstalk parameters
(``crosstalk_strength`` / ``crosstalk_sigma`` / ``crosstalk_kernel``); a regular planar grid ``probe_local_pos``
is required to enable it.
"""
stiffness: NonNegativeFloat = 100.0
shear_coupling: NonNegativeFloat = 0.0
density_scalar: PositiveInt = 100