Kit 109 Measure Tool snap “Invalid hit face index” even when /rtx-transient/scenedb/useUniformsReindexing=True

  • Extension: omni.kit.tool.measure 200.0.4 with Kit109 (v 108.0.1 works in Kit108)

  • Reproduction of issue:

    • Create Mesh Cube on empty stage
    • Enable Measure tool
    • Select Vertex/Edge/Midpoint snap
    • Hover/click mesh /World/Cube
  • Expected: snapping works - no errors

  • Actual: spam error “Invalid hit face index … must be True” and snap failing intermittendly, stops working entirely on complex meshes

  • Extra evidence:

    • Runtime check shows carb.settings.get_settings().get(“/rtx-transient/scenedb/useUniformsReindexing”) == True
    • In Kit 109, raycast primitive_id behaves like triangle/primitive index, not face index (example: cube primitive_id=10 with 6 faces)
  • Workaround: extension with local patch maps triangle index → face index - I can share the code snippet if needed

Operating System:
Windows
Kit Version:
109.0.2 (Kit App Template)
Kit Template:
USD Composer
GPU Hardware:
40 series
GPU Driver:
Latest Studio

Thank you for letting me know. If you want, yes please post the code fix here.

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

(sorry the formatting comes out a bit messy when pasting the code in - everything from the first “from” statement onwards is code)

Thanks for posting the code !

Any chance this will be fixed in the next release?

Hey Nils, thanks for posting this!

Just want to make sure I got the formatting right because I’m hitting a few issues related to objects added after the stage is first opened, but after further inspection this only happens once at startup so I guess that makes sense. Posting with the formatting I finished with for future users.

Am I correct in seeing this only works with objects that existed in the stage when it was first launched? The error spam returns for objects added afterwards.

Again, thanks for this, it seems to be working better (but not perfect like you already warned) with this enabled and at the very least stops the error spam.

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