I have wrapped the concept in an extension and it appears to work - still a bit moody when it comes to picking vertices in dense meshes but no errors - best, Nils
Workaround idea: in Kit 109, RayQueryResult.primitive_id can behave like a triangle/primitive index
(or 1-based face index), but omni.kit.tool.measure assumes it’s a USD face index. We remap it.
from future import annotations
from typing import Any
import carb
import omni.ext
class _RayQueryResultProxy:
“”“Proxy object to override primitive_id while delegating everything else.”“”
def __init__(self, original_result: Any, primitive_id_override: int):
self._original_result = original_result
self.primitive_id = primitive_id_override
def __getattr__(self, item):
return getattr(self._original_result, item)
class Extension(omni.ext.IExt):
def on_startup(self, ext_id):
try:
from pxr import Sdf, UsdGeom # type: ignore
from omni.kit.tool.measure.viewport.snap import provider as measure_provider # type: ignore
from omni.kit.tool.measure.viewport.snap.provider import AttributeValueCache # type: ignore
except Exception as exc:
carb.log_warn(f"[zha.measure_fix] Import failed, no patch applied: {exc}")
return
provider_cls = getattr(measure_provider, "MeshBasedSnapProvider", None)
if provider_cls is None or not hasattr(provider_cls, "_get_vert_world_pos_on_hit_face"):
carb.log_warn("[zha.measure_fix] MeshBasedSnapProvider not found; no patch applied.")
return
original = getattr(provider_cls, "_get_vert_world_pos_on_hit_face")
def _wrapped(self_obj, result):
# On Kit 109 some raycast paths appear to return primitive_id that does not map to USD face indices.
#
# Observed cases:
# - /Environment/ground: len(faceVertexCounts)=1 but primitive_id=1
# - /World/Cube: len(faceVertexCounts)=6 but primitive_id=10
#
# This matches "triangle id" semantics (triangulated face primitive index), not "face index".
# We attempt to map primitive_id -> face index using faceVertexCounts (tris per face = n-2),
# and only fall back to a 1-based correction if that fits.
try:
prim_path = Sdf.Path(result.get_target_usd_path())
except Exception:
return original(self_obj, result)
try:
primitive_id_raw = getattr(result, "primitive_id", None)
if primitive_id_raw is None:
return original(self_obj, result)
primitive_id = int(primitive_id_raw)
except Exception:
return original(self_obj, result)
try:
value_cache = AttributeValueCache()
face_vertex_counts = value_cache.get_value(prim_path.AppendProperty(UsdGeom.Tokens.faceVertexCounts))
face_count = len(face_vertex_counts) if face_vertex_counts is not None else 0
except Exception:
return original(self_obj, result)
if face_count <= 0:
return original(self_obj, result)
# If primitive_id already looks like a face index, leave it alone.
if 0 <= primitive_id < face_count:
return original(self_obj, result)
# Build candidate face indices using multiple interpretations of primitive_id.
candidate_face_indices: set[int] = set()
# Fallback: treat primitive_id as 1-based face index.
if 0 <= (primitive_id - 1) < face_count:
candidate_face_indices.add(primitive_id - 1)
# Try interpret primitive_id as a triangle index within the triangulated mesh.
# Map triangle index -> face index by accumulating triangle counts per face (tris per face = n-2).
def _face_index_from_triangle_index(triangle_index: int) -> int | None:
if triangle_index < 0:
return None
running = 0
for fi, c in enumerate(face_vertex_counts):
tri_count = int(c) - 2
if tri_count <= 0:
continue
if triangle_index < running + tri_count:
return fi
running += tri_count
return None
tri_candidates: set[int] = {primitive_id, primitive_id - 1}
instance_id_raw = getattr(result, "instance_id", None)
try:
instance_id = int(instance_id_raw) if instance_id_raw is not None else None
except Exception:
instance_id = None
# Some raycast paths appear to include a small offset; include instance_id-derived candidates defensively.
if instance_id is not None:
tri_candidates.add(primitive_id - instance_id)
tri_candidates.add(primitive_id - instance_id - 1)
for tri_id in tri_candidates:
fi = _face_index_from_triangle_index(tri_id)
if fi is not None and 0 <= fi < face_count:
candidate_face_indices.add(fi)
if not candidate_face_indices:
return original(self_obj, result)
# Choose best candidate by closest distance from hit point to any edge of the candidate face.
# This avoids picking the wrong face when primitive_id semantics are ambiguous.
try:
from pxr import Gf # type: ignore
hit_pos = getattr(result, "hit_position", None)
if hit_pos is None:
hit_pos = getattr(result, "position", None)
if hit_pos is None:
return original(self_obj, result)
hit_point = Gf.Vec3d(hit_pos[0], hit_pos[1], hit_pos[2])
except Exception:
return original(self_obj, result)
best_face_index: int | None = None
best_sqdistance: float | None = None
def _edge_distance_sq(verts) -> float | None: # noqa: ANN001
if not verts:
return None
nearest_sq = None
for i in range(len(verts)):
a = verts[i]
b = verts[(i + 1) % len(verts)]
direction = b - a
length = direction.Normalize()
if length <= 0:
continue
begin_to_hit = hit_point - a
proj = Gf.Dot(begin_to_hit, direction)
if proj < 0:
nearest = a
elif proj > length:
nearest = b
else:
nearest = a + direction * proj
diff = nearest - hit_point
sq = diff * diff
if nearest_sq is None or sq < nearest_sq:
nearest_sq = sq
return float(nearest_sq) if nearest_sq is not None else None
for fi in candidate_face_indices:
verts = original(self_obj, _RayQueryResultProxy(result, fi))
sq = _edge_distance_sq(verts)
if sq is None:
continue
if best_sqdistance is None or sq < best_sqdistance:
best_sqdistance = sq
best_face_index = fi
if best_face_index is None:
return original(self_obj, result)
return original(self_obj, _RayQueryResultProxy(result, best_face_index))
setattr(provider_cls, "_get_vert_world_pos_on_hit_face", _wrapped)
carb.log_info("[zha.measure_fix] Installed primitive_id 1-based correction wrapper for measure snap provider.")
def on_shutdown(self):
# No unpatch; app lifetime only.
return