1. added blender deformation probe for marker size picking and optimization

2. added unwarp map export in current cli, don't need to generate by precompute in sensor running
This commit is contained in:
ykcheungab
2026-09-25 15:44:44 +08:00
parent bc5a928852
commit c377c14474
34 changed files with 18032 additions and 1 deletions
+449
View File
@@ -0,0 +1,449 @@
"""Blender headless probe deformation (mm, object-local).
blender -b sensors/finger-xynova/r01/source/geometry.blend \\
-P blender/scripts/probe_deform.py -- \\
--job <abs/build/probe/finger-xynova/r01/_deform_job.json>
Reads the job written by ``orisys_sensor_design.deform``, resolves the mesh and
vertex group, builds the probe-average orthonormal basis, computes the
proportional falloff ONCE from the probe members' ORIGINAL positions (so the
weight vector is constant across every frame), then writes::
probe_frames.npz the deformation/rendering contract
deform_report.json measured probe statistics + honesty warnings
THE .BLEND IS NEVER WRITTEN. No ``bpy.ops.wm.save_mainfile`` /
``save_as_mainfile`` call exists in this script; the source file is opened
read-only and only read from. The script does leave Edit mode if the opened file
happens to be in it, but that changes the in-memory session only.
WHY NOT bpy.ops.transform.translate(use_proportional_edit=True)
--------------------------------------------------------------
That operator is viewport-modal. It needs a 3D-view region for the pivot and its
``tool_settings.proportional_size`` is interpreted in SCREEN SPACE (pixels)
relative to that region. Under ``blender -b`` there is no 3D-view region and no
screen-space scale, so the affected vertex set would depend on GUI state and
zoom and could not be reproduced headlessly. Every displacement here is computed
explicitly in millimetres by ``probe_falloff.py``::
delta_i = weight_i * (axis_vector * step_mm) (mm, object-local)
``weight_i = 1.0`` for the probe members, otherwise the falloff curve of
``d_i / radius_mm`` where ``d_i`` is the distance from the vertex's ORIGINAL
position to the nearest probe vertex. Do not "fix" this back to the operator.
Exit codes: 0 success, 1 scene/geometry error, 2 config/job error.
"""
# pyright: reportMissingImports=false
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
import bpy
import numpy as np
# Blender -P does not consistently include the script directory on sys.path.
sys.path.insert(0, str(Path(__file__).resolve().parent))
from probe_falloff import ( # noqa: E402
FALLOFF_MODES,
affected_bands,
edge_lengths_mm,
falloff_weights,
flatness_mm,
orthonormal_basis,
percentile,
)
JOB_SCHEMA = "orisys-sensor-design/probe-deform-job-v1"
REPORT_SCHEMA = "orisys-sensor-design/probe-deform-report-v1"
FRAMES_NPZ_FILENAME = "probe_frames.npz"
DEFORM_REPORT_FILENAME = "deform_report.json"
AXIS_ROWS = {"x": 0, "y": 1, "z": 2}
VALID_AXES = (None, "x", "y", "z")
class ConfigError(Exception):
"""Bad or incomplete job file (exit 2)."""
class SceneError(Exception):
"""Blend contents do not match the job (exit 1)."""
def _argv_after_double_dash() -> list[str]:
if "--" not in sys.argv:
return []
return sys.argv[sys.argv.index("--") + 1 :]
def parse_args(argv: list[str]) -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="probe_deform.py")
parser.add_argument(
"--job",
type=Path,
required=True,
help="Absolute path to the _deform_job.json written by orisys-design deform",
)
return parser.parse_args(argv)
def load_job(path: Path) -> dict[str, Any]:
"""Load and structurally validate the deform job (raises ConfigError)."""
if not path.is_file():
raise ConfigError(f"Deform job not found: {path}")
try:
job = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise ConfigError(f"Deform job is not readable JSON: {path}: {exc}") from exc
if not isinstance(job, dict):
raise ConfigError(f"Deform job must be a JSON object: {path}")
if job.get("schema") != JOB_SCHEMA:
raise ConfigError(
f"Deform job schema must be {JOB_SCHEMA!r}, got {job.get('schema')!r}"
)
for key in ("source_blend", "output_dir", "settings", "frames", "artifacts"):
if key not in job:
raise ConfigError(f"Deform job is missing {key!r}")
objects = job.get("objects")
if not isinstance(objects, dict) or not objects.get("mesh") or not objects.get("group"):
raise ConfigError("Deform job 'objects' must name a mesh and a vertex group")
settings = job["settings"]
if not isinstance(settings, dict):
raise ConfigError("Deform job 'settings' must be a mapping")
for key in ("radius_mm", "falloff", "step_mm", "max_mm", "axes"):
if key not in settings:
raise ConfigError(f"Deform job settings is missing {key!r}")
if not isinstance(settings["axes"], list) or not settings["axes"]:
raise ConfigError("Deform job settings.axes must be a non-empty list")
radius = float(settings["radius_mm"])
if not radius > 0.0:
raise ConfigError(f"Deform job settings.radius_mm must be > 0, got {radius!r}")
falloff = str(settings["falloff"])
if falloff not in FALLOFF_MODES:
raise ConfigError(
f"Deform job settings.falloff must be one of "
f"{', '.join(FALLOFF_MODES)}, got {falloff!r}"
)
step = float(settings["step_mm"])
max_mm = float(settings["max_mm"])
if not step > 0.0 or not max_mm > 0.0:
raise ConfigError("Deform job settings.step_mm/max_mm must be > 0")
frames = job["frames"]
if not isinstance(frames, list) or not frames:
raise ConfigError("Deform job 'frames' must be a non-empty list")
for position, frame in enumerate(frames):
if not isinstance(frame, dict):
raise ConfigError(f"frames[{position}] must be a mapping")
for key in ("index", "name", "axis", "step_mm"):
if key not in frame:
raise ConfigError(f"frames[{position}] is missing {key!r}")
if frame["index"] != position:
raise ConfigError(
f"frames[{position}].index must be {position}, got {frame['index']!r}"
)
if not isinstance(frame["name"], str) or not frame["name"]:
raise ConfigError(f"frames[{position}].name must be a non-empty string")
if frame["axis"] not in VALID_AXES:
raise ConfigError(
f"frames[{position}].axis must be null/x/y/z, got {frame['axis']!r}"
)
if float(frame["step_mm"]) < 0.0:
raise ConfigError(f"frames[{position}].step_mm must be >= 0")
if frames[0]["axis"] is not None or float(frames[0]["step_mm"]) != 0.0:
raise ConfigError("frames[0] must be the zero-displacement reference")
artifacts = job["artifacts"]
if not isinstance(artifacts, dict) or not artifacts.get("frames") or not artifacts.get("report"):
raise ConfigError("Deform job 'artifacts' must name 'frames' and 'report'")
source_blend = Path(str(job["source_blend"]))
if not source_blend.is_file():
raise ConfigError(f"Deform job source_blend not found: {source_blend}")
return job
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _require_mesh(name: str):
obj = bpy.data.objects.get(name)
if obj is None:
raise SceneError(f"Object not found: {name!r}")
if obj.type != "MESH":
raise SceneError(f"Object {name!r} has type {obj.type!r}, expected 'MESH'")
if obj.data is None or len(obj.data.vertices) == 0:
raise SceneError(f"Mesh object {name!r} has no vertices")
return obj
def _ensure_object_mode(obj) -> None:
"""Leave Edit mode if the opened file is in it (in-memory only, never saved)."""
if obj.mode == "OBJECT":
return
try:
view_layer = bpy.context.view_layer
if view_layer.objects.active is not obj:
view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="OBJECT")
except Exception as exc: # pragma: no cover - depends on the opened file
raise SceneError(f"Could not leave {obj.mode!r} mode for {obj.name!r}: {exc}") from exc
def _probe_members(obj, group_name: str) -> tuple[list[int], list[float]]:
group = obj.vertex_groups.get(group_name)
if group is None:
available = ", ".join(sorted(g.name for g in obj.vertex_groups)) or "<none>"
raise SceneError(
f"Vertex group {group_name!r} not found on {obj.name!r} (have: {available})"
)
members: list[int] = []
group_weights: list[float] = []
for vertex in obj.data.vertices:
for assignment in vertex.groups:
if assignment.group == group.index:
members.append(int(vertex.index))
group_weights.append(float(assignment.weight))
break
if not members:
raise SceneError(f"Vertex group {group_name!r} on {obj.name!r} is empty")
return members, group_weights
def _write_frames_npz(
path: Path,
*,
base_vertices: np.ndarray,
vertices: np.ndarray,
frame_names: list[str],
frame_axes: list[str],
frame_steps_mm: list[float],
weights: np.ndarray,
origin: np.ndarray,
basis: np.ndarray,
probe_indices: np.ndarray,
) -> None:
if len(vertices) != len(frame_names):
raise SceneError(
f"frame count mismatch: {len(vertices)} vertex sets, {len(frame_names)} names"
)
path.parent.mkdir(parents=True, exist_ok=True)
np.savez(
path,
base_vertices=np.asarray(base_vertices, dtype=np.float64),
vertices=np.asarray(vertices, dtype=np.float64),
frame_names=np.array(frame_names),
frame_axes=np.array(frame_axes, dtype="<U4"),
frame_steps_mm=np.asarray(frame_steps_mm, dtype=np.float64),
weights=np.asarray(weights, dtype=np.float64),
origin=np.asarray(origin, dtype=np.float64),
basis=np.asarray(basis, dtype=np.float64),
probe_indices=np.asarray(probe_indices, dtype=np.int32),
)
def main() -> int:
args = parse_args(_argv_after_double_dash())
try:
job = load_job(args.job.resolve())
except ConfigError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
try:
mesh_name = str(job["objects"]["mesh"])
group_name = str(job["objects"]["group"])
obj = _require_mesh(mesh_name)
_ensure_object_mode(obj)
settings = job["settings"]
radius_mm = float(settings["radius_mm"])
falloff = str(settings["falloff"])
members, group_weights = _probe_members(obj, group_name)
mesh = obj.data
base_vertices = np.array(
[vertex.co[:] for vertex in mesh.vertices], dtype=np.float64
)
normals = np.array(
[vertex.normal[:] for vertex in mesh.vertices], dtype=np.float64
)
member_array = np.asarray(sorted(members), dtype=np.int64)
origin, basis, basis_report = orthonormal_basis(
normals[member_array], origin=base_vertices[member_array].mean(axis=0)
)
if not basis_report["orthonormal"]:
raise SceneError(
"probe-average basis is not orthonormal within "
f"{1e-6:g} (error {basis_report['orthonormality_error']:.3g})"
)
# Weights come from the ORIGINAL coordinates, computed exactly once, so
# every frame shares one falloff vector (Blender proportional-edit
# semantics: distance is measured from the selected vertices' start).
weights = falloff_weights(base_vertices, member_array, radius_mm, falloff)
edges = np.array(
[edge.vertices[:] for edge in mesh.edges], dtype=np.int64
).reshape(-1, 2)
edge_lengths = edge_lengths_mm(base_vertices, edges)
if edge_lengths.size == 0:
raise SceneError(f"Mesh object {mesh_name!r} has no edges to measure")
edge_p50_mm = percentile(edge_lengths, 50.0)
except SceneError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
except Exception as exc:
print(f"ERROR: probe deformation failed: {exc}", file=sys.stderr)
return 1
frames = job["frames"]
frame_count = len(frames)
vertices = np.empty((frame_count, base_vertices.shape[0], 3), dtype=np.float64)
frame_deltas: list[float] = []
frame_moved: list[int] = []
for position, frame in enumerate(frames):
axis = frame["axis"]
if axis is None:
vector = np.zeros(3, dtype=np.float64)
else:
vector = basis[AXIS_ROWS[axis]] * float(frame["step_mm"])
vertices[position] = base_vertices + weights[:, None] * vector[None, :]
delta = vertices[position] - base_vertices
frame_deltas.append(float(np.linalg.norm(delta, axis=1).max()))
# Contract: vertices displaced by a non-zero amount IN THIS FRAME.
# 0 for the reference, 6 + partial (11 on Xynova) for any non-zero step.
frame_moved.append(int(np.count_nonzero(np.any(delta != 0.0, axis=1))))
if not np.array_equal(vertices[0], base_vertices):
print(
"ERROR: reference frame differs from the base vertices",
file=sys.stderr,
)
return 1
members_at_one = int((weights >= 1.0).sum())
partial = int(((weights > 0.0) & (weights < 1.0)).sum())
unaffected = int((weights == 0.0).sum())
warnings: list[str] = []
if partial < 4 * len(member_array):
warnings.append(
f"radius {radius_mm:g} mm reaches only {partial} vertices outside the "
f"probe group (threshold {4 * len(member_array)} = 4 * "
f"{len(member_array)} members); the deformation is local to the probe "
"neighbourhood"
)
if radius_mm < edge_p50_mm:
warnings.append(
f"radius {radius_mm:g} mm is below the mesh median edge length "
f"{edge_p50_mm:.6f} mm; affected vertices may be isolated"
)
if any(abs(weight - 1.0) > 1e-9 for weight in group_weights):
warnings.append(
f"probe group weights are not all 1.0 ({group_weights}); the contract "
"displaces probe members by the full vector regardless"
)
scale = [float(value) for value in obj.matrix_world.to_scale()]
if max(abs(value - 1.0) for value in scale) > 1e-4:
warnings.append(
f"mesh object scale {scale} is not 1; displacement units are "
"object-local, not world millimetres"
)
source_blend = Path(str(job["source_blend"]))
report = {
"schema": REPORT_SCHEMA,
"generated_at": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
"source_blend": {
"path": str(source_blend.resolve()),
"sha256": _sha256_file(source_blend),
},
"mesh": mesh_name,
"group": group_name,
"probe_indices": [int(index) for index in member_array],
"origin": [round(float(value), 9) for value in origin],
"basis": [[round(float(value), 9) for value in row] for row in basis],
"basis_orthonormal": bool(basis_report["orthonormal"]),
"normal_angle_max_deg": round(float(basis_report["normal_angle_max_deg"]), 3),
"flatness_mm": round(flatness_mm(base_vertices[member_array], origin, basis[2]), 6),
"radius_mm": radius_mm,
"falloff": falloff,
"vertex_count": int(base_vertices.shape[0]),
"edge_p50_mm": round(edge_p50_mm, 6),
"weights": {
"min": float(weights.min()),
"max": float(weights.max()),
"members_at_one": members_at_one,
"partial": partial,
"unaffected": unaffected,
},
"affect_bands": affected_bands(weights),
"frames": [
{
"index": int(frame["index"]),
"name": str(frame["name"]),
"axis": frame["axis"],
"step_mm": float(frame["step_mm"]),
"max_delta_mm": round(frame_deltas[position], 9),
# Vertices with a non-zero displacement in this frame: 0 for the
# reference, 6 + partial (= 11 on Xynova) for any non-zero step.
"moved_vertices": frame_moved[position],
}
for position, frame in enumerate(frames)
],
"warnings": warnings,
}
artifacts = job["artifacts"]
frames_path = Path(str(artifacts["frames"]))
report_path = Path(str(artifacts["report"]))
try:
_write_frames_npz(
frames_path,
base_vertices=base_vertices,
vertices=vertices,
frame_names=[str(frame["name"]) for frame in frames],
frame_axes=["null" if frame["axis"] is None else str(frame["axis"]) for frame in frames],
frame_steps_mm=[float(frame["step_mm"]) for frame in frames],
weights=weights,
origin=origin,
basis=basis,
probe_indices=member_array.astype(np.int32),
)
report_path.parent.mkdir(parents=True, exist_ok=True)
report_path.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
except (OSError, SceneError) as exc:
print(f"ERROR: could not write deform artifacts: {exc}", file=sys.stderr)
return 1
print(
f"Probe deform complete: {frame_count} frames, {base_vertices.shape[0]} vertices, "
f"{members_at_one} probe members, {partial} partial, {unaffected} unaffected"
)
for warning in warnings:
print(f"WARNING: {warning}", file=sys.stderr)
print(f" {frames_path}")
print(f" {report_path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+270
View File
@@ -0,0 +1,270 @@
"""bpy-FREE probe geometry maths: proportional falloff, basis, edge stats.
Why the falloff is computed here instead of with an operator
------------------------------------------------------------
``bpy.ops.transform.translate(use_proportional_edit=True)`` is deliberately NOT
used anywhere in this pipeline. That operator is viewport-modal: it needs a 3D
view region to define the pivot and, crucially, its
``tool_settings.proportional_size`` is interpreted in SCREEN SPACE (pixels)
relative to that region. Under ``blender -b`` there is no 3D-view region and no
screen-space scale, so the same script would affect a different vertex set
depending on GUI state or zoom -- and could not be reproduced at all headlessly.
The falloff is therefore computed explicitly, in millimetres, by this module.
Semantics replicate Blender's proportional editing with the probe members as the
selection:
* ``d`` = distance from the **original** position of a vertex to the nearest
probe vertex;
* ``t = d / radius_mm``;
* ``w = curve(t)``, with ``w = 0`` whenever ``t >= 1`` and ``w = 1`` for probe
members.
Because ``d`` is measured from the original (base) coordinates, the weight
vector is constant across every frame of the sweep -- exactly Blender's
behaviour, where the proportional falloff is computed once from the selected
vertices' starting positions.
Only numpy is required, so the normal pytest suite imports this module without
bpy (the same pattern as ``blender/scripts/patch_geometry.py``).
"""
from __future__ import annotations
from typing import Any, Sequence
import numpy as np
#: Blender proportional-edit falloff modes accepted by the pipeline contract.
FALLOFF_MODES = ("smooth", "linear", "sharp", "constant")
DEFAULT_FALLOFF = "smooth"
#: Contract failure threshold for the probe-average basis ("not orthonormal
#: within 1e-6" must fail loudly). The measured Xynova basis is orthonormal to
#: ~2.2e-16.
ORTHONORMAL_TOLERANCE = 1e-6
#: Weight histogram bands, lowest-inclusive/highest-exclusive except "1.0".
#: Empty bands are omitted so the report only prints what actually moves.
_WEIGHT_BANDS = (
("1.0", 1.0, 1.0),
("0.5-1.0", 0.5, 1.0),
("0.3-0.5", 0.3, 0.5),
("0.1-0.3", 0.1, 0.3),
("0.05-0.1", 0.05, 0.1),
("0.0-0.05", 0.0, 0.05),
)
def smooth_weight(t: float) -> float:
"""Blender's "Smooth" proportional curve: 1 at t=0, 0 at t>=1."""
return 1.0 - (3.0 * t * t - 2.0 * t * t * t)
def linear_weight(t: float) -> float:
"""Blender's "Linear" proportional curve: 1 - t."""
return 1.0 - t
def sharp_weight(t: float) -> float:
"""Blender's "Sharp" proportional curve: (1 - t)^2."""
return (1.0 - t) ** 2
def constant_weight(t: float) -> float:
"""Blender's "Constant" proportional curve: 1 everywhere inside radius."""
return 1.0
_FALLOFF_CURVES = {
"smooth": smooth_weight,
"linear": linear_weight,
"sharp": sharp_weight,
"constant": constant_weight,
}
def falloff_weight(t: float, mode: str = DEFAULT_FALLOFF) -> float:
"""Return the scalar falloff weight for a normalised distance ``t``.
``t <= 0`` gives 1.0 and ``t >= 1`` gives 0.0 for every mode.
"""
if mode not in _FALLOFF_CURVES:
allowed = ", ".join(FALLOFF_MODES)
raise ValueError(f"Unknown falloff mode {mode!r} (allowed: {allowed})")
t = float(t)
if t <= 0.0:
return 1.0
if t >= 1.0:
return 0.0
return float(_FALLOFF_CURVES[mode](t))
def _curve_values(t: np.ndarray, mode: str) -> np.ndarray:
clipped = np.clip(t, 0.0, 1.0)
if mode == "smooth":
values = 1.0 - (3.0 * clipped**2 - 2.0 * clipped**3)
elif mode == "linear":
values = 1.0 - clipped
elif mode == "sharp":
values = (1.0 - clipped) ** 2
else:
values = np.ones_like(clipped)
return np.where(t >= 1.0, 0.0, values)
def falloff_weights(
coords: np.ndarray,
member_indices: Sequence[int],
radius_mm: float,
mode: str = DEFAULT_FALLOFF,
) -> np.ndarray:
"""Return one falloff weight per vertex, measured from ORIGINAL positions.
``coords`` is ``(V, 3)`` object-local millimetres, ``member_indices`` are the
probe vertices (weight forced to exactly 1.0), ``radius_mm`` is the
proportional radius in millimetres. The result is constant across frames:
callers must pass the undeformed base coordinates every time.
"""
if mode not in _FALLOFF_CURVES:
allowed = ", ".join(FALLOFF_MODES)
raise ValueError(f"Unknown falloff mode {mode!r} (allowed: {allowed})")
coords = np.asarray(coords, dtype=np.float64)
if coords.ndim != 2 or coords.shape[1] != 3:
raise ValueError(f"coords must have shape (V, 3), got {coords.shape}")
members = np.asarray(list(member_indices), dtype=np.int64)
if members.size == 0:
raise ValueError("member_indices must not be empty")
if members.min() < 0 or members.max() >= coords.shape[0]:
raise ValueError("member_indices contains an out-of-range vertex index")
radius_mm = float(radius_mm)
if not radius_mm > 0.0:
raise ValueError(f"radius_mm must be > 0, got {radius_mm!r}")
deltas = coords[:, None, :] - coords[members][None, :, :]
distance = np.sqrt((deltas**2).sum(axis=-1)).min(axis=1)
weights = _curve_values(distance / radius_mm, mode)
weights[members] = 1.0
return weights
def affected_bands(weights: np.ndarray) -> dict[str, int]:
"""Histogram moved vertices by weight band; empty bands are omitted.
Matches the ``affect_bands`` block of ``deform_report.json``: the measured
Xynova default (radius 0.5 mm, smooth) is exactly
``{"1.0": 6, "0.3-0.5": 1, "0.05-0.1": 4}``.
"""
values = np.asarray(weights, dtype=np.float64).ravel()
bands: dict[str, int] = {}
for label, low, high in _WEIGHT_BANDS:
if label == "1.0":
mask = values >= 1.0
elif label == "0.0-0.05":
mask = (values > 0.0) & (values < high)
else:
mask = (values >= low) & (values < high)
count = int(mask.sum())
if count:
bands[label] = count
return bands
def orthonormality_error(basis: np.ndarray) -> float:
"""Return max ``|basis @ basis.T - I|`` (0.0 for a perfect frame)."""
matrix = np.asarray(basis, dtype=np.float64)
if matrix.shape != (3, 3):
raise ValueError(f"basis must have shape (3, 3), got {matrix.shape}")
return float(np.abs(matrix @ matrix.T - np.eye(3)).max())
def orthonormal_basis(
normals: np.ndarray,
origin: Sequence[float] | None = None,
) -> tuple[np.ndarray | None, np.ndarray, dict[str, Any]]:
"""Build the probe-average orthonormal basis from probe vertex normals.
Returns ``(origin, basis, report)``:
* ``basis`` rows are ``x``, ``y``, ``z`` in that order, object-local;
* ``z`` is the average of the unit normals (equal weight per vertex, so it
is area-independent), ``x = normalize(cross((0,0,1), z))`` falling back to
``(1, 0, 0)`` when that is degenerate, ``y = cross(z, x)``;
* ``origin`` is the probe centroid in object-local mm. Normals carry no
position, so the caller must pass it (usually
``base[member_indices].mean(axis=0)``); when omitted, ``None`` is returned
unchanged and ``report["origin_given"]`` is ``False``.
``report`` carries ``member_count``, ``normal_angle_max_deg``,
``orthonormality_error``, ``orthonormal`` and ``x_fallback``.
"""
normals = np.asarray(normals, dtype=np.float64)
if normals.ndim != 2 or normals.shape[1] != 3 or normals.shape[0] == 0:
raise ValueError(f"normals must have shape (P, 3), got {normals.shape}")
lengths = np.linalg.norm(normals, axis=1)
if not np.all(lengths > 0.0):
raise ValueError("probe vertex normals must be non-zero")
unit = normals / lengths[:, None]
mean_normal = unit.mean(axis=0)
mean_length = float(np.linalg.norm(mean_normal))
if mean_length <= 1e-12:
raise ValueError("probe vertex normals cancel out; no surface normal")
z = mean_normal / mean_length
x = np.cross(np.array([0.0, 0.0, 1.0]), z)
x_length = float(np.linalg.norm(x))
x_fallback = x_length < 1e-9
x = np.array([1.0, 0.0, 0.0]) if x_fallback else x / x_length
y = np.cross(z, x)
basis = np.vstack([x, y, z])
angles = np.degrees(np.arccos(np.clip(unit @ z, -1.0, 1.0)))
error = orthonormality_error(basis)
report: dict[str, Any] = {
"member_count": int(normals.shape[0]),
"normal_angle_max_deg": float(angles.max()),
"orthonormality_error": error,
"orthonormal": error <= ORTHONORMAL_TOLERANCE,
"x_fallback": bool(x_fallback),
"origin_given": origin is not None,
}
origin_out = None if origin is None else np.asarray(origin, dtype=np.float64)
return origin_out, basis, report
def flatness_mm(coords: np.ndarray, origin: Sequence[float], normal: Sequence[float]) -> float:
"""Peak-to-valley deviation of ``coords`` along the unit ``normal``, in mm.
This is the contract's ``flatness_mm``: for the measured Xynova probe it is
0.007611 mm (signed offsets along z span -0.00066..+0.00427 and
-0.00334..+0.00369).
"""
axis = np.asarray(normal, dtype=np.float64)
axis_length = float(np.linalg.norm(axis))
if axis_length <= 0.0:
raise ValueError("normal must be non-zero")
offsets = (np.asarray(coords, dtype=np.float64) - np.asarray(origin, dtype=np.float64)) @ (
axis / axis_length
)
return float(offsets.max() - offsets.min())
def edge_lengths_mm(coords: np.ndarray, edges: np.ndarray) -> np.ndarray:
"""Return the length of every mesh edge, in object-local millimetres."""
coords = np.asarray(coords, dtype=np.float64)
edges = np.asarray(edges, dtype=np.int64).reshape(-1, 2)
if edges.size == 0:
return np.zeros(0, dtype=np.float64)
return np.linalg.norm(coords[edges[:, 0]] - coords[edges[:, 1]], axis=1)
def percentile(values: np.ndarray, q: float) -> float:
"""Linear-interpolated percentile of a non-empty 1-D array (numpy default)."""
values = np.asarray(values, dtype=np.float64).ravel()
if values.size == 0:
raise ValueError("percentile of an empty array is undefined")
q = float(q)
if not 0.0 <= q <= 100.0:
raise ValueError(f"percentile q must be in [0, 100], got {q!r}")
return float(np.percentile(values, q))
File diff suppressed because it is too large Load Diff
+538
View File
@@ -0,0 +1,538 @@
"""Tests for the bpy-free probe falloff and basis helpers.
The Xynova regression fixture below is the measured set of nearest-probe-vertex
distances (mm) for the 2502 non-probe vertices of ``sensing_surface`` in
``sensors/finger-xynova/r01/source/geometry.blend`` (sha256
``1C8A050B8490B6832D3A9F269DFEF3DAFBF94DBFE3E943B3956BE1104A50D9C9``). It was
dumped once from Blender and is embedded as base64 float32 because pytest cannot
import bpy: the measured distribution has to travel with the test. The values
are sorted ascending, so the first entries are the ones the radius can reach.
"""
from __future__ import annotations
import base64
import numpy as np
import pytest
from probe_falloff import (
FALLOFF_MODES,
affected_bands,
edge_lengths_mm,
falloff_weight,
falloff_weights,
flatness_mm,
linear_weight,
orthonormal_basis,
orthonormality_error,
percentile,
sharp_weight,
smooth_weight,
)
#: Sorted nearest-probe distances, mm, for the 2502 non-probe vertices.
_NONPROBE_DISTANCES_F32_B64 = (
"SRaSPkIO1T5RDtU+nmLVPrFi1T44xks/DpdUPxiXVD8n71Q/pu9UPzvEYj8SxmI/h6BoP4egaD+vtWg/cAh7P40Iez8uv4E/"
"Yb+BP0dMmD90TJg/RHKYP2Hjnj+a454/lSefP8wnnz8YDaI/Yg2iPzz8vD+i/Lw/T+HLP9Hhyz/g59I/T+jSPyNF0z+XRdM/"
"FlnZP0tP3z9sUN8/PRrkPzSD5j/ag+Y/yC/nP4ai5z8Yxec/+uTrP3Oj7z+oo+8/GJfxP0AY+j/4GPo/R3b6P82K+z99JAJA"
"oiQCQPgiA0BSIwNAAmADQF5gA0D4kwNAfB0HQHQtCUDJLQlA0pAJQFMwEUCLMBFA7lYSQAobE0C7rxRAG7AUQIchFUDjIRVA"
"4+cbQCR2HECkdhxAKMUcQH7FHEDdoB9AWKEfQCN/I0BZfyNAxJcjQIZtJEDkbSRAqQcmQJwZKEBCGihAHX8pQCqILUDEli1A"
"PpctQF0BL0DMAS9AWDw0QPFnNUCQaDVADas1QH2rNUBnzjVA4s41QFZJNkDwSTZA4t43QCLfN0AO6zdAXOs3QGraOUCirztA"
"hrM7QKTMO0D11TtAMHo9QJhXPkBUPD9AkAJAQJc5QECyO0FARjxBQP28QUCVvUFAOTlDQPKOQ0Aej0NAW71EQD+zRkDzx0ZA"
"RO9HQL3vR0BtUEhApz9KQH+FTEAQhkxAmf1MQDP+TECNVE1AF+hNQBOuTkDReVBARclQQCMhUUDdZ1JAKGhSQBeKUkA+glVA"
"6ZJXQH3KV0AUy1dAr1NYQEZUWEDbSVpA+OlaQInqWkCQIltA6D5bQLlSW0DTbVtAEY5cQDS7XECjul1AbNtdQPz/XUA7jF5A"
"o75eQBHdXkBgDV9A2ThfQBp7YECne2BAvy5iQBlPYkARuGJAqeFiQAkIY0DACGNAqaNjQEGkY0AmmWRA1ZlkQPBjZUC2hmVA"
"6O9lQGbwZUDWaGZARX1mQMSwZkDZgGdArahnQKysZ0CTHGhAoCloQL8AaUAG82tAqRNtQC9XbUBEfW1A38ZtQCPmbUDi521A"
"j2FuQCpibkBatG5AeLRuQHXrbkAs7G5Ab3lvQHpRckA/hXJA4wl0QMoqdEAYT3RA0E51QKTtdkB3DXdAomN3QDbHd0AkPnhA"
"zT54QMkkeUAKiXlApLx5QCMqekD4KnpAmOp7QFwNfED2fnxAj398QOnyfEDWo31AIMt9QIxrf0B/YoBAbm6AQN9xgECcuoBA"
"4LqAQOLFgECZ34BA8wiBQMIVgUAcFoFA6TuBQPipgUDkuoFAucGBQALSgUDp4IFA5+GBQKP0gUADHoJARDaCQBg5gkBxQoJA"
"BFOCQAdTgkBpaYJA3a+CQD2wgkAQF4NAqi+DQDh0g0AthINA4omDQLeRhECuqoRAjdiEQMHYhECp2YRAdxGFQFwehUANI4VA"
"aSOFQFckhUAqM4VA7zyFQIluhUB+gIVA0hOGQEAUhkDSWIZAC4aGQCmfhkAd9IZALQSHQKZPh0CnY4dA9X6HQB6IiEAL64hA"
"TQSJQFFXiUCgaYlAXnGJQJyCiUAZz4lAbOiJQM4FikD3GIpAwxmKQF4Yi0DOGItA/zSLQCV1i0CGt4tAxNCLQIcCjEASEoxA"
"jSyMQMssjEC+54xAjf2MQBo9jUBZPY1Au0+NQG/jjUC+8I1A//ONQI0CjkCJOo5Aik+OQGF0jkCkdI5AQLmOQAbIjkBK0o5A"
"JeWOQEj+jkAgaY9AnAqQQAkSkEA/JpBAfvKQQCMEkUAZCZFAkhuRQGRZkUCbQZJA1kGSQKtukkAXcJJAHoKSQBeJkkByvJJA"
"tcCSQK/HkkB1zJJAaFWTQHMflUB3MJVArjGVQCsylUDnT5VAf2KVQJF0lUCchpVAtvSVQOv+lUATCJZAdAuWQOwVlkCBHpZA"
"gDmWQO5BlkCUT5ZANlOWQINplkAJbJZA/oCWQLSJlkB2n5ZASQmXQIRDl0BhW5dAI4iXQIuIl0COnZdAq++XQED/l0Dzi5hA"
"DqSYQH20mEDmuJhAYMaYQH0ImUCyHJlAwGGZQHy8mUDRzJlACwGaQEkBmkDXK5pAaz2aQHc/mkDLP5pAyFeaQHKHmkAKoJpA"
"pwybQCUgm0BmHpxApR6cQD1XnECxb5xAOJicQGmpnEB41pxAYR+dQHIxnUALl51AwqmdQEy9nkD1y55AIc2eQHLknkDgAJ9A"
"NBmfQB4gn0BpMZ9AcXWfQPinn0AyqJ9AuUCgQFJKoEAQTKBA91+gQECpoEA4u6BAIfSgQC35oECfBKFAEg6hQGaWoUDHraFA"
"066hQGy/oUBywqFAcSSiQJ44okC/rKJAj7GjQJXJo0D10aNAb+WjQOvwo0BEEqRAxiGkQNlApEBhUaRAJVKkQH9ipEAbsqRA"
"aMqkQNPppEB5vaVAj82lQI0GpkBDGaZA3IGmQJWDpkAqk6ZAcIKnQHe5p0DJuadAyxyoQPg0qEBeWKhA72ioQPm/qEBKxqhA"
"OdioQKgCqUCnSalALlupQAKzqUBMs6lA3dGpQAQXqkBFHqpAZyqqQJ0sqkCiM6pAQ0CqQHBXqkCcbapARIGqQPeVqkA0napA"
"n7OqQOLlqkBo9apAYVurQHhvq0Cwb6tAdYarQIabq0CHrKtANtOrQCHrq0ClEKxA3yGsQDScrEBLs6xASLWsQJ7IrEBz3axA"
"COesQB/3rEDxAq1AKQOtQAXHrUAG/K1ATA2uQKEhrkDvOK5AL5auQNGorkAZh69AX9CvQFTir0AB6K9A7vKvQBP9r0CBFLBA"
"rVuwQNJqsEDVnrBA0vuwQLIEsUCtDLFAnBaxQKXfsUA577FA+iiyQHhAskDI/bJAzg6zQAEBtECHArRAtgq0QMATtEAPFrRA"
"wRy0QA0itEBEMbRArTS0QM9EtECen7RAG6q0QCi3tED+vbRAqMe0QPNStUAyU7VATom1QGmatUCTrLVAAbS1QMq4tUCEubVA"
"RMe1QHastkA3vbZAvy23QPctt0CPMbdA1EC3QFpEt0DYVrdAzVu3QChzt0CAjrdAJJ+3QExPuEAfYLhAlna4QJKNuEDh+7hA"
"gQu5QNxjuUDUdblAMaK5QOZLukAVTLpAXFq6QIdxukCAc7pASIS6QDuku0BjtLtA5Ba8QBsovEDyzbxA/dy8QHRGvUCwmr1A"
"sbG9QEzJvUCd3b1Az++9QMP5vUCfBL5Acwq+QLIOvkB0Ir5AySe+QMcovkAROL5AyDy+QF12vkCai75AY62+QBTNvkAg2r5A"
"NuC+QJDvvkABUr9AWV6/QK5nv0AKb79AIre/QNnHv0CfEMBAICPAQKY5wEBNScBA7XnAQN+PwEApG8FA7zHBQLV2wUBt5MFA"
"wufBQFL2wUA8+sFA7v3BQPsKwkDRysJAEcvCQA44w0AzSMNAAWPDQG2aw0BsrcNAv7DDQGy8w0AST8RASmDEQNiKxEAii8RA"
"49jEQFLvxEAzQcVAfFDFQMOPxUAxpsVAYzTGQPfHxkBLMcdA1UHHQOhTx0COZMdAcH3HQKV9x0DHhMdAWYfHQM+Yx0DamsdA"
"icPHQOTZx0AQ6MdAiPzHQAz/x0D6EchA4szIQAfjyEAV+shAdQzJQMCKyUCXmclApDHKQPJHykAkrspAKr3KQMHyykD3AstA"
"CZDLQC+Qy0AN08xAKdbMQJPjzEAN5cxAS+zMQHL6zEBSJc9ADzXPQAKiz0DDr89AzcXPQPYF0EBUHtBAlR7QQHxq0ECmf9BA"
"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"
"hG0uQZR0LkFxjS5BeZQuQdrmLkHi7S5BP/AuQVHwLkGFVC9BplovQd1dL0HiZC9BAZgvQfaeL0Ef0i9BIdkvQYTzL0FUQzBB"
"WkowQduIMEEjlTBB6pswQW+xMEFruDBBiLwwQbYcMUGzIzFBsj0xQc5DMUGQhTFBiYwxQT2fMUHvpTFBaOwxQVjzMUGNUTJB"
"elgyQXSdMkEunjJB1qMyQaekMkFWtTJBQLwyQeroMkFv7zJBkvAyQdr2MkEuGDNBER8zQYVxM0EVeDNBhnozQWGBM0FTszNB"
"rdwzQYDjM0HuKjRBiTE0QfI+NEHHRTRBtaE0QXmoNEHSqDRB5q40QUkFNUFDBzVBDww1Qd4NNUFZajVBF3E1QX7RNUEz2DVB"
"TPg1Qez+NUGZqDZBQK82QW3wNkET9zZBD483QaiVN0HVvzdBxcI3QaXVN0GA2zdBr+U3QVDsN0G2XDhBjGI4QZhiOEFTYzhB"
"EIk4QZqPOEGR0DhBL9c4QfVAOUGKRzlBkE45QaNTOUFWcTlBqnc5QaCtOUExtDlBhRY6QRYdOkGuXTpBul06QQx8OkGagjpB"
"rt46QTXlOkHcPjtBYkU7QfqcO0F6oztBz8E7QdfHO0GT1ztBrd07QXL5O0G1/DtB4f87QbICPEHkBDxBDzA8QdgzPEELOjxB"
"uVQ8QSxbPEEVZDxBvGk8QVGvPEG8tTxBXso8QZHQPEGVCT1BAhA9QehjPUFIaj1BjHw9QVyCPUHijT1BGZQ9QZ++PUEAxT1B"
"MRo+QYggPkFscD5BonY+QUJ3PkGWfT5B1Ik+QW7WPkG33D5B7GM/QSJqP0HGnT9B/6M/QdFaQEEKYUBB5mhAQYJuQEHZdEBB"
"CntAQWRLQUGdUUFBdFJBQeRXQUHoX0FBDGZBQem+QUEdxUFBmSpCQY4uQkG8NEJBnTVCQZc7QkERmkJBRaBCQVUBQ0F7B0NB"
"7D1DQU9kQ0FxakNBeMNDQaPJQ0EJ0kNBFdJDQVwfREF6JURBd3hEQSR8REGMfkRBP89EQVfVREFv7kRBnfJEQSb0REG1E0VB"
"ZxZFQVsZRUEtHEVBLCRFQUIqRUHMd0VB2H1FQUyBRUEgh0VB9KhFQfyoRUGhykVBodBFQQb2RUEUHUZBoSJGQRYjRkF1KEZB"
"115GQVlkRkGPb0ZBkHVGQXvCRkFwyEZBJu1GQf3yRkFSFkdBSBxHQZNXR0FYw0dBO8lHQfzSR0Ha2EdB5XtIQcKBSEEuxkhB"
"CsxIQVYMSUGqEUlBy0RJQZlKSUFZuUlBN79JQQwfSkGyJEpB8C9KQck1SkEzo0pBBqlKQW7nSkEA7UpBRRJLQRwYS0GKVktB"
"6XxLQbeCS0HX4ktBpOhLQQxETEHUSUxBl6BMQVumTEH2+ExBuv5MQcoOTUHnE01BzE1NQYRTTUGWn01BSaVNQeLuTUGX9E1B"
"wgdOQboiTkGsJU5BGyhOQV80TkG1OU5BMTxOQeJBTkGgWk5BCmBOQSaITkHQjU5BtdFOQUnTTkEo105B59hOQREeT0GtI09B"
"BidPQRMnT0EITk9BOFNPQehoT0F9bk9BZ3tPQeeAT0GpAFBBMQZQQYZKUEEEUFBBJ59QQa2kUEHL21BB19tQQV8xUUHaNlFB"
"4klRQV1PUUEiv1FBkAVSQQQLUkEdIlJBmidSQQsuUkGOM1JBx4xSQRrDUkGXyFJBc89SQbbUUkHGD1NBQRVTQY5SU0EEWFNB"
"j2tTQYiCU0H+h1NBtYxTQe6RU0Gp4FNBFuZTQUbxU0G69lNBSVtUQbxgVEFKwFRBu8VUQbTsVEExIFVBmiVVQe96VUFagFVB"
"tdBVQRrWVUEVIlZBbydWQZtvVkH4dFZB+7lWQVS/VkHIAVdBGQdXQYpHV0HbTFdB2V1XQSVeV0HVYldBKmNXQaiBV0Hli1dB"
"LpFXQSakV0FAqVdBd89XQbfUV0GHJVhBoCpYQaNIWEGKTVhBKlZYQWhbWEGu1VhB2NpYQYLfWEG15FhBP3BZQWZ1WUGWp1lB"
"vaxZQRDrWUHjDVpBBhNaQdppWkHraVpB/nxaQa6BWkG2jVpB25JaQT69WkFTwlpBiXpbQad/W0FD/ltBUf5bQeQqXEHLL1xB"
"VGFcQW5mXEGmz1xBv9RcQXA5XUGDPl1B7J1dQf2iXUEJ811BjfddQfL8XUEEAl5BglZeQZFbXkGYkV5Bj6peQZ6vXkFq+V5B"
"cP5eQWM2X0GkQ19BqkhfQemJX0Hjjl9B5MxfQeDRX0Ey1V9BxddfQT0NYEEzEmBBiUtgQYFQYEF3iGBBZY1gQY2PYEE1lGBB"
"bKBgQSalYEGvxGBBmclgQSTzYEHf92BB/TxhQdxBYUFSfWFBEn5hQWp/YUEWgmFBerhhQVO9YUGRMmJBTjdiQSM8YkHvQGJB"
"Ys1iQSTSYkHeBWNBpwpjQQNxY0HFdWNBAupjQcXuY0GTxmRBJ8tkQcTRZEGM1mRBJixlQTChZUE9oWVBzLBlQY+1ZUE462VB"
"XRpmQR0fZkHVfmZBj4NmQZXdZkFR4mZB1RZnQeAWZ0F1NmdBLDtnQX2JZ0E1jmdBo8lnQdTWZ0F922dBrx5oQVQjaEHHYWhB"
"b2ZoQc2gaEFspWhBfNxoQRrhaEGHFWlBHhppQUkiaUGJTGlBIFFpQS+CaUHHhmlBPbdpQcu7aUFRyWlBqc1pQabpaUEB7mlB"
"giFqQQsmakHYR2pBPkxqQclcakEFYWpBqY9qQS+UakHF2WpBJd5qQZ8Ga0EZC2tB44trQVaQa0H2kmtBWJdrQRkdbEErIWxB"
"NCRsQZMkbEGdKGxBp2ZsQRZrbEHuR21BWkxtQVZibUGRZm1B5iluQU8ubkES7m5BoABvQQUFb0G7B29BqQtvQTZlb0GgaW9B"
"wmxvQUPEb0GdyG9BIh1wQXchcEHBb3BBHnRwQUa8cEGewHBBk9JwQaHScEHP8XBB0wJxQSUHcUG5Q3FBFUhxQcB/cUEMhHFB"
"nLdxQem7cUEV7HFBV/BxQab9cUHiHXJBIyJyQeQqckHvKnJBsk1yQfNRckEsqnJBba5yQacGc0HmCnNB/UZzQY5Hc0GYZ3NB"
"wmtzQZiic0GopnNB+tFzQR3Wc0FfO3RBaD90QU1LdEFqT3RBHoV0QTHYdEFE3HRBtfd0Qcr7dEEjy3VBOM91Qdb+dUG3AnZB"
"/qh2QQ2tdkHaT3dBlnZ3QbWEd0HCiHdBA+53QRLyd0HVUnhB3lZ4QVqyeEFntnhB6At5QesPeUHjXnlB6WJ5QVKreUFPr3lB"
"WPF5QVv1eUFfGXpBMDF6QS81ekFCa3pBQW96QU6gekFGpHpBE9F6QQvVekFu/npBYgJ7QRspe0ELLXtBzVF7QbhVe0Eon3tB"
"NqB7QRSke0Hz7ntBzvJ7QQECfEEQAnxBmUJ8QW5GfEG0U3xBXFd8QaeQfEFUlHxBu5V8QUaZfEE+oHxBEqR8QZcDfUFHB31B"
"aA19QTURfUFVPn1BWT59QZ6OfUFkkn1BoqJ9QVmmfUF45X1B7+h9QbdhfkFtZX5Bg+1+QZ/wfkFpNH9BIjh/QTcHgEETCYBB"
"1FOAQdpxgEGyc4BBeKSAQVKmgEGT1IBBataAQc8BgUGlA4FB2CuBQa4tgUFsUoFBQFSBQZB1gUFod4FBZpWBQTqXgUHKrIFB"
"BrKBQdezgUGxxYFBs8uBQYTNgUHK4oFBmOSBQbT3gUGB+YFB4QqCQbEMgkG1HIJBfx6CQZAtgkFXL4JBxU2CQZBPgkFOboJB"
"EHCCQXuRgkE8k4JB6LmCQaa7gkFL6oJBCeyCQeUkg0GeJoNBZ1SDQe2Yg0HxmINBrp+DQVChg0G5KYRBuymEQb40hEEINoRB"
"kbyEQUO+hEH4I4VBVSWFQZYlhUGjJYVBilSFQThWhUFhgoVBDoSFQSKthUHNroVBgNSFQSnWhUE5+IVB4/mFQVwYhkEIGoZB"
"ETWGQbc2hkF7ToZBIlCGQdxkhkGCZoZBl3iGQT16hkEfioZBwIuGQd2ZhkGAm4ZBPqGGQaG1hkE+t4ZBJL6GQf7OhkGV0IZB"
"P3aHQa93h0GGfIdB/X2HQaKoh0EdqodBw8GHQR3Dh0HM64dBS+2HQRL4h0FEQYhBxUKIQSmjiEGrpIhBygiJQY4LiUERDYlB"
"ATKJQQMyiUGvdIlBMXaJQb6niUFBqYlBDbaJQRC2iUHp2IlBatqJQYoHikEHCYpBLTOKQao0ikGIW4pBBV2KQUuAikHKgYpB"
"RKGKQcCiikGBvopB/L+KQSzYikGq2YpBeu6KQfDvikGnAYtBHAOLQR8Si0GWE4tBVCCLQcohi0G9LItBMC6LQbtBi0EuQ4tB"
"S1SLQbdVi0FGZ4tBtGiLQS59i0GZfotBMYSLQaOYi0EOmotBV7yLQcO9i0F16otB2OuLQRlLjEFmTIxBpMqMQVSMjUHlo45B"
"j6+OQRzNjkEezY5BDEWPQQxFj0EVxI9BX8WPQW7Sj0G2049Bt5aQQRyYkEFd25BBfdyQQcMUkUHjFZFBb2GRQZRikUHQvJFB"
"9b2RQTghkkFcIpJB/YiSQSSKkkHE7pJB6O+SQTL9kkFBH5NBZyCTQVNNk0F2TpNBX3iTQYN5k0ECoJNBJ6GTQfvDk0EbxZNB"
"/uOTQSDlk0HZ/5NB+gCUQa4XlEHSGJRBtSuUQdYslEEgPJRBQD2UQSdJlEFDSZRBYkqUQY9TlEGtVJRBbFuUQY9clEFcYZRB"
"fWKUQSpplEFIapRB92qUQflqlEFLbpRBZG+UQcVzlEHedJRBLnyUQUZ9lEE8ipRBUIuUQY+glEGhoZRBOMGUQUfClEEax5RB"
"Z9eUQWfXlEFrBZVBaAaVQR6JlUFs25VBBkWaQc1FmkGtkJpBdJGaQbXWmkHW5ppBoOeaQewJm0GnLZtBdEibQTlJm0EIsJtB"
"0rCbQQHMm0EtGJxB9xicQdd7nEGgfJxBnoycQW2qnEE3q5xBHdacQeXWnEFW/pxBHv+cQbsinUGCI51BDUOdQdZDnUEOX51B"
"1l+dQZF2nUFXd51BuomdQYOKnUFfi51B55WdQZSWnUGllp1BVZedQcuYnUGSmZ1BiJydQSOdnUFGnZ1B3Z2dQQGknUHKpJ1B"
"/qSdQcKlnUG1q51BeaydQUOtnUEHrp1BW66dQRivnUFTsJ1BFrGdQS+ynUFQsp1B9rKdQRaznUHH0Z1BcNKdQWgbnkG0lZ9B"
"7bCfQe6wn0HsBaBBHwagQT0IoEE/CKBBP4agQVGGoEGobaFBZW+kQQGepEH90aRBbeGkQd3hpEEB/aRBKk2lQZhNpUHntqVB"
"UrelQYK8pUHVGaZBPRqmQU1HpkG8R6ZBbXGmQdpxpkGpl6ZBE5imQbCwpkEEsaZBh7KmQd+ypkGLs6ZB8LOmQa25pkEZuqZB"
"G72mQYS9pkG+zaZBJc6mQT7XpkGu16ZBseKmQRrjpkEm8KZBkPCmQTL5pkGb+aZBNgSnQaAEp0FuDqdB1w6nQZcTp0EDFKdB"
"jx6nQf0ep0E6H6dBqB+nQeMkp0FNJadBZiWnQdMlp0EeKKdBcyinQYgop0HaKKdBhMOnQfUEqUGCrqlBh9upQfDnqUFa7qlB"
"0xaqQaUkqkHNZapB0WWqQZV3qkFZ1KpBd9uqQT/lqkHPQ6tBvk2rQeC2q0FXuqtBWB2sQcQerEHPfaxBtoOsQUDarEEL4qxB"
"f/KsQeAgrUEYKq1BOTCtQTY0rUGMWa1Bn1mtQRZnrUE7cq1BJHetQah4rUERfK1BdJytQYycrUGXpa1BlK6tQXK2rUGJuK1B"
"TdStQcjWrUFd2K1B6eWtQd7mrUEz+a1B4v2tQWYErkEFBa5BchGuQQwUrkGwFq5B6xauQSkXrkGWG65B3xuuQfkbrkFcHK5B"
)
#: Signed offsets of the 6 measured probe vertices from their centroid along the
#: basis z axis (mm). Peak-to-valley is the contract's flatness_mm = 0.007611.
_MEASURED_PROBE_OFFSETS_Z = (
-0.0006562,
0.0042672,
-0.0033438,
0.0036863,
-0.0033205,
-0.0006329,
)
def _measured_nonprobe_distances() -> np.ndarray:
raw = base64.b64decode(_NONPROBE_DISTANCES_F32_B64)
return np.frombuffer(raw, dtype="<f4").astype(np.float64)
def _measured_probe_cloud() -> tuple[np.ndarray, np.ndarray]:
"""Return (coords, member_indices) reproducing the measured radii exactly.
The 6 probe members sit at the origin; each non-probe vertex sits on the +x
axis at its measured nearest-probe distance, so the minimum distance to a
member equals the measured value.
"""
distances = _measured_nonprobe_distances()
members = np.zeros((6, 3), dtype=np.float64)
others = np.stack(
[distances, np.zeros_like(distances), np.zeros_like(distances)], axis=1
)
coords = np.vstack([members, others])
return coords, np.arange(6, dtype=np.int64)
# ------------------------------------------------------------- curves
def test_curve_endpoints() -> None:
assert smooth_weight(0.0) == 1.0
assert smooth_weight(1.0) == 0.0
assert linear_weight(0.0) == 1.0
assert linear_weight(1.0) == 0.0
assert sharp_weight(0.0) == 1.0
assert sharp_weight(1.0) == 0.0
assert smooth_weight(0.25) == pytest.approx(1.0 - (3 * 0.25**2 - 2 * 0.25**3))
def test_curve_monotonic_decreasing() -> None:
samples = np.linspace(0.0, 1.0, 101)
smooth = np.array([smooth_weight(t) for t in samples])
linear = np.array([linear_weight(t) for t in samples])
sharp = np.array([sharp_weight(t) for t in samples])
for curve in (smooth, linear, sharp):
assert np.all(np.diff(curve) <= 0.0)
assert curve.min() == pytest.approx(0.0)
assert curve.max() == pytest.approx(1.0)
def test_falloff_weight_thresholds_and_dispatch() -> None:
assert falloff_weight(0.0, "smooth") == 1.0
assert falloff_weight(-5.0, "sharp") == 1.0
assert falloff_weight(1.0, "constant") == 0.0
assert falloff_weight(1.5, "smooth") == 0.0
assert falloff_weight(0.4, "linear") == pytest.approx(0.6)
assert falloff_weight(0.4, "sharp") == pytest.approx(0.36)
assert falloff_weight(0.4, "constant") == 1.0
with pytest.raises(ValueError):
falloff_weight(0.5, "bounce")
def test_falloff_modes_list_matches_the_contract() -> None:
assert FALLOFF_MODES == ("smooth", "linear", "sharp", "constant")
# -------------------------------------------------------------- basis
def _mixed_normals() -> np.ndarray:
"""Six unit normals whose mean is tilted away from +z (mixed, not symmetric)."""
angle_a = np.radians(2.5533)
angle_b = np.radians(0.5)
tilted_a = np.array([np.sin(angle_a), 0.0, np.cos(angle_a)])
tilted_b = np.array([-np.sin(angle_b), 0.0, np.cos(angle_b)])
return np.array([tilted_a] * 3 + [tilted_b] * 3, dtype=np.float64)
def _tilted_normals(angle_deg: float) -> np.ndarray:
"""Six unit normals with mean exactly +z and max deviation angle_deg."""
angle = np.radians(angle_deg)
return np.array(
[[np.sin(angle), 0.0, np.cos(angle)]] * 3
+ [[-np.sin(angle), 0.0, np.cos(angle)]] * 3,
dtype=np.float64,
)
def test_orthonormal_basis_rows_are_x_y_z_and_right_handed() -> None:
normal = np.array([0.2, -0.3, 0.9])
normals = np.array([normal / np.linalg.norm(normal)] * 6)
origin, basis, report = orthonormal_basis(
normals, origin=[74.76961, 16.2155, 120.80202]
)
x, y, z = basis
assert report["member_count"] == 6
assert report["origin_given"] is True
assert origin == pytest.approx([74.76961, 16.2155, 120.80202])
assert z == pytest.approx(normals[0])
assert x == pytest.approx(
np.cross([0.0, 0.0, 1.0], normals[0])
/ np.linalg.norm(np.cross([0.0, 0.0, 1.0], normals[0]))
)
assert y == pytest.approx(np.cross(z, x))
assert np.allclose(np.cross(z, x), y, atol=1e-12)
assert orthonormality_error(basis) < 1e-12
assert report["orthonormal"] is True
assert report["x_fallback"] is False
assert report["normal_angle_max_deg"] == pytest.approx(0.0, abs=1e-9)
def test_orthonormal_basis_reports_max_normal_angle() -> None:
normals = _mixed_normals()
_, basis, report = orthonormal_basis(normals, origin=[0.0, 0.0, 0.0])
unit = normals / np.linalg.norm(normals, axis=1)[:, None]
expected = float(
np.degrees(np.arccos(np.clip(unit @ basis[2], -1.0, 1.0))).max()
)
assert report["normal_angle_max_deg"] == pytest.approx(expected, abs=1e-9)
assert 1.5 < report["normal_angle_max_deg"] < 1.6
assert orthonormality_error(basis) < 1e-12
def test_orthonormal_basis_origin_is_optional() -> None:
origin, basis, report = orthonormal_basis(_mixed_normals())
assert origin is None
assert report["origin_given"] is False
assert basis.shape == (3, 3)
def test_orthonormal_basis_degenerate_x_falls_back_to_plus_x() -> None:
origin, basis, report = orthonormal_basis(
np.array([[0.0, 0.0, 1.0]] * 4), origin=[0.0, 0.0, 0.0]
)
assert report["x_fallback"] is True
assert basis[0] == pytest.approx([1.0, 0.0, 0.0])
assert basis[1] == pytest.approx([0.0, 1.0, 0.0])
assert basis[2] == pytest.approx([0.0, 0.0, 1.0])
assert report["orthonormal"] is True
def test_orthonormal_basis_rejects_degenerate_input() -> None:
with pytest.raises(ValueError):
orthonormal_basis(np.zeros((0, 3)))
with pytest.raises(ValueError):
orthonormal_basis(np.zeros((2, 2)))
with pytest.raises(ValueError):
orthonormal_basis(np.array([[0.0, 0.0, 0.0]]))
with pytest.raises(ValueError):
# perfectly cancelling normals leave no surface normal
orthonormal_basis(
np.array([[1.0, 0.0, 0.0], [-1.0, 0.0, 0.0]])
)
def test_orthonormality_error_detects_a_bad_basis() -> None:
assert orthonormality_error(np.eye(3)) == 0.0
assert orthonormality_error(np.diag([2.0, 1.0, 1.0])) == pytest.approx(3.0)
with pytest.raises(ValueError):
orthonormality_error(np.eye(2))
# ----------------------------------------------------- falloff_weights
def test_falloff_weights_are_one_for_members_and_zero_at_radius() -> None:
coords = np.array(
[
[0.0, 0.0, 0.0], # member
[0.5, 0.0, 0.0], # member
[0.25, 0.0, 0.0], # inside (d = 0.25 from the first member)
[1.0, 0.0, 0.0], # exactly radius from the 0.5 member -> d = 0.5
[0.0, 0.9, 0.0], # outside
]
)
weights = falloff_weights(coords, [0, 1], 0.5, "smooth")
assert weights.dtype == np.float64
assert weights.shape == (5,)
assert weights[0] == 1.0
assert weights[1] == 1.0
assert weights[2] == pytest.approx(smooth_weight(0.5))
assert weights[3] == 0.0
assert weights[4] == 0.0
def test_falloff_weights_match_the_scalar_curve() -> None:
rng = np.random.default_rng(7)
coords = rng.uniform(-1.0, 1.0, size=(64, 3))
members = np.array([0, 1, 2])
for mode in FALLOFF_MODES:
weights = falloff_weights(coords, members, 0.75, mode)
for index in range(coords.shape[0]):
distance = float(
np.linalg.norm(coords[index] - coords[members], axis=1).min()
)
assert weights[index] == pytest.approx(
falloff_weight(distance / 0.75, mode), abs=1e-15
)
def test_falloff_weights_are_symmetric_in_offset_direction() -> None:
coords = np.array(
[
[0.0, 0.0, 0.0],
[0.3, 0.0, 0.0],
[-0.3, 0.0, 0.0],
[0.0, 0.3, 0.0],
[0.0, 0.0, 0.3],
[0.3 / np.sqrt(3)] * 3,
]
)
weights = falloff_weights(coords, [0], 0.5, "smooth")
for index in (2, 3, 4, 5):
assert weights[index] == pytest.approx(weights[1], abs=1e-15)
assert weights[1] == pytest.approx(smooth_weight(0.6))
def test_falloff_weights_constant_mode() -> None:
coords = np.array([[0.0, 0.0, 0.0], [0.4, 0.0, 0.0], [0.6, 0.0, 0.0]])
weights = falloff_weights(coords, [0], 0.5, "constant")
assert list(weights) == [1.0, 1.0, 0.0]
@pytest.mark.parametrize(
"kwargs",
[
{"radius_mm": 0.0},
{"radius_mm": -1.0},
{"mode": "bounce"},
],
)
def test_falloff_weights_reject_bad_settings(kwargs: dict) -> None:
coords = np.array([[0.0, 0.0, 0.0], [0.1, 0.0, 0.0]])
settings = {"radius_mm": 0.5}
settings.update(kwargs)
with pytest.raises(ValueError):
falloff_weights(coords, [0], **settings)
def test_falloff_weights_reject_bad_geometry() -> None:
with pytest.raises(ValueError):
falloff_weights(np.zeros((3, 2)), [0], 0.5)
with pytest.raises(ValueError):
falloff_weights(np.zeros((3, 3)), [], 0.5)
with pytest.raises(ValueError):
falloff_weights(np.zeros((3, 3)), [5], 0.5)
# -------------------------------------------------------- affected_bands
def test_affected_bands_omits_empty_bands() -> None:
weights = np.array([1.0, 1.0, 0.75, 0.4, 0.2, 0.07, 0.01, 0.0])
assert affected_bands(weights) == {
"1.0": 2,
"0.5-1.0": 1,
"0.3-0.5": 1,
"0.1-0.3": 1,
"0.05-0.1": 1,
"0.0-0.05": 1,
}
assert affected_bands(np.array([0.0, 0.0, 1.0])) == {"1.0": 1}
# ------------------------------------------- measured Xynova regression
def test_measured_xynova_radius_half_mm_reaches_exactly_five_vertices() -> None:
coords, members = _measured_probe_cloud()
assert coords.shape == (2508, 3)
assert len(members) == 6
weights = falloff_weights(coords, members, 0.5, "smooth")
inside = weights[(weights > 0.0) & (weights < 1.0)]
assert int((weights == 0.0).sum()) == 2497
assert int((weights >= 1.0).sum()) == 6
assert inside.size == 5, "radius 0.5 mm must touch exactly 5 non-probe vertices"
distances = _measured_nonprobe_distances()
assert float(distances[0]) == pytest.approx(0.2853262745311897, abs=1e-6)
assert float(distances[1]) == pytest.approx(0.41612439347389385, abs=1e-6)
assert float(distances[4]) == pytest.approx(0.41676858076285744, abs=1e-6)
assert float(distances[5]) == pytest.approx(0.795993, abs=1e-6)
# The gap between the 5th and 6th neighbour is what makes the count 5, not 11.
assert int((distances < 0.5).sum()) == 5
assert int((distances < 0.6).sum()) == 5
assert int((distances < 0.79).sum()) == 5
def test_measured_xynova_weight_bands_match_the_contract_report() -> None:
coords, members = _measured_probe_cloud()
weights = falloff_weights(coords, members, 0.5, "smooth")
inside = np.sort(weights[(weights > 0.0) & (weights < 1.0)])
assert affected_bands(weights) == {"1.0": 6, "0.3-0.5": 1, "0.05-0.1": 4}
assert inside[4] == pytest.approx(0.3947265, abs=1e-6) # d = 0.28533
assert inside[:4] == pytest.approx(
[0.0739043, 0.0739052, 0.0749794, 0.0749802], abs=1e-6
)
# One vertex in 0.3-0.5, four in 0.05-0.1, none anywhere else.
assert int(((inside >= 0.3) & (inside < 0.5)).sum()) == 1
assert int(((inside >= 0.05) & (inside < 0.1)).sum()) == 4
def test_measured_xynova_percentile_utility() -> None:
distances = _measured_nonprobe_distances()
assert percentile(distances, 0.0) == pytest.approx(float(distances.min()))
assert percentile(distances, 50.0) == pytest.approx(
float(np.percentile(distances, 50)), rel=1e-6
)
assert percentile(distances, 100.0) == pytest.approx(float(distances.max()))
# ----------------------------------------------------------- statistics
def test_edge_lengths_mm() -> None:
coords = np.array(
[[0.0, 0.0, 0.0], [3.0, 0.0, 0.0], [3.0, 4.0, 0.0], [0.0, 0.0, 2.0]]
)
edges = np.array([[0, 1], [1, 2], [0, 3]])
assert edge_lengths_mm(coords, edges) == pytest.approx([3.0, 4.0, 2.0])
assert edge_lengths_mm(coords, np.zeros((0, 2), dtype=np.int64)).size == 0
def test_edge_percentiles_match_the_measured_contract_row() -> None:
# Contract: mesh edges min 0.0504, p50 0.6285, p90 0.991, max 1.816 mm.
edges = np.array([0.0504, 0.628501, 0.991, 1.816])
assert percentile(edges, 0.0) == pytest.approx(0.0504)
assert percentile(edges, 100.0) == pytest.approx(1.816)
assert percentile(edges, 50.0) == pytest.approx(np.percentile(edges, 50))
def test_flatness_mm_is_peak_to_valley_along_the_normal() -> None:
offsets = np.array(_MEASURED_PROBE_OFFSETS_Z)
coords = np.stack([np.zeros_like(offsets), np.zeros_like(offsets), offsets], axis=1)
assert flatness_mm(coords, [0.0, 0.0, 0.0], [0.0, 0.0, 1.0]) == pytest.approx(
0.007611, abs=1e-6
)
assert flatness_mm(coords, [0.0, 0.0, 0.0], [0.0, 0.0, 2.0]) == pytest.approx(
0.007611, abs=1e-6
) # non-unit normals are normalised
with pytest.raises(ValueError):
flatness_mm(coords, [0.0, 0.0, 0.0], [0.0, 0.0, 0.0])
def test_percentile_edges() -> None:
assert percentile([3.0, 1.0, 2.0], 0.0) == 1.0
assert percentile([3.0, 1.0, 2.0], 100.0) == 3.0
assert percentile([3.0, 1.0, 2.0], 50.0) == 2.0
with pytest.raises(ValueError):
percentile([], 50.0)
with pytest.raises(ValueError):
percentile([1.0], 101.0)