@@ -56,6 +56,8 @@ public struct RealityKitDiscreteSnapshot: Equatable, Sendable {
5656@Observable
5757@MainActor
5858public final class RealityKitProvider {
59+ public typealias PickPathResolver = @MainActor ( _ directPath: String , _ entity: Entity , _ provider: RealityKitProvider ) -> String ?
60+
5961 private enum LoadError : LocalizedError {
6062 case timeout( seconds: Int )
6163 var errorDescription : String ? {
@@ -82,18 +84,25 @@ public final class RealityKitProvider {
8284 public var selectedPrimPath : String ? {
8385 didSet { emitDiscreteSnapshotIfNeeded ( ) }
8486 }
87+ public private( set) var selectionGeneration : UInt64 = 0
8588
8689 /// Update selection from programmatic sync (e.g. TCA).
8790 public func setSelection( _ path: String ? ) {
91+ if self . selectedPrimPath == path {
92+ self . isUserInteraction = false
93+ return
94+ }
8895 self . isUserInteraction = false
8996 self . selectedPrimPath = path
97+ self . selectionGeneration &+= 1
9098 }
9199
92100 /// Update selection from viewport interaction (e.g. pick).
93101 public func userDidPick( _ path: String ? ) {
94102 providerLogger. debug ( " userDidPick path= \( path ?? " nil " , privacy: . public) " )
95103 self . isUserInteraction = true
96104 self . selectedPrimPath = path
105+ self . selectionGeneration &+= 1
97106 }
98107
99108 // MARK: - File State
@@ -118,9 +127,23 @@ public final class RealityKitProvider {
118127 /// Bidirectional prim path ↔ entity mapping, built once per model load.
119128 private( set) var primPathToEntityID : [ String : Entity . ID ] = [ : ]
120129 private( set) var entityIDToPrimPath : [ Entity . ID : String ] = [ : ]
130+ private var pickPathOverrides : [ String : String ] = [ : ]
131+ private var pickPathResolver : PickPathResolver ?
121132
122133 public init ( ) { }
123134
135+ /// Provide consumer-owned remappings from coarse importer paths to more
136+ /// semantic prim paths.
137+ public func setPickPathOverrides( _ overrides: [ String : String ] ) {
138+ pickPathOverrides = overrides
139+ }
140+
141+ /// Provide a consumer-owned resolver for upgrading picked prim paths when
142+ /// the imported RealityKit graph is coarser than the source scene graph.
143+ public func setPickPathResolver( _ resolver: PickPathResolver ? ) {
144+ pickPathResolver = resolver
145+ }
146+
124147 public func activateViewport( _ id: UUID ) {
125148 activeViewportID = id
126149 }
@@ -526,6 +549,13 @@ extension RealityKitProvider {
526549 for child in root. children {
527550 walk ( child, parentPrimPath: " " )
528551 }
552+
553+ let mappedPaths = primPathToEntityID. keys. sorted ( )
554+ providerLogger. debug ( " Built prim path mapping with \( mappedPaths. count, privacy: . public) entries " )
555+ if mappedPaths. count <= 4 || mappedPaths. contains ( where: { $0. contains ( " /merged_ " ) || $0. hasSuffix ( " /merged " ) } ) {
556+ let sample = mappedPaths. prefix ( 8 ) . joined ( separator: " , " )
557+ providerLogger. info ( " Prim path mapping sample: \( sample, privacy: . public) " )
558+ }
529559 }
530560
531561 /// Resolve a USD prim path to its RealityKit entity via the cached mapping.
@@ -597,6 +627,58 @@ extension RealityKitProvider {
597627 }
598628 return nil
599629 }
630+
631+ /// Resolve the best USD prim path for a viewport pick.
632+ ///
633+ /// RealityKit can collapse imported meshes into generic buckets such as
634+ /// `merged_1`, which makes a direct entity-to-prim lookup too coarse for
635+ /// selection. This resolver keeps the nearest mapped path when it is
636+ /// meaningful, but falls back to a more semantic sibling/descendant path
637+ /// when the importer only exposes a generic merged node.
638+ public func preferredPickPrimPath( from entity: Entity ) -> String ? {
639+ guard let directPath = nearestMappedPrimPath ( from: entity) else { return nil }
640+ if let overridePath = remappedPickPath ( for: directPath, entity: entity) {
641+ return overridePath
642+ }
643+ guard isGenericImportedPath ( directPath) else { return directPath }
644+
645+ if let semanticPath = preferredSemanticPath ( near: directPath) {
646+ providerLogger. debug (
647+ " Resolved generic pick path \( directPath, privacy: . public) to semantic path \( semanticPath, privacy: . public) "
648+ )
649+ return semanticPath
650+ }
651+
652+ return directPath
653+ }
654+
655+ /// Resolve the best USD prim path from an ordered list of raycast hits.
656+ ///
657+ /// This prefers the first specific non-generic mapping in raycast order
658+ /// before falling back to merged/importer-generated buckets.
659+ public func preferredPickPrimPath( from entities: [ Entity ] ) -> String ? {
660+ var fallback : String ?
661+
662+ for entity in entities {
663+ guard let directPath = nearestMappedPrimPath ( from: entity) else { continue }
664+
665+ if let remapped = remappedPickPath ( for: directPath, entity: entity) {
666+ if isGenericImportedPath ( remapped) == false {
667+ return remapped
668+ }
669+ fallback = fallback ?? remapped
670+ continue
671+ }
672+
673+ if isGenericImportedPath ( directPath) == false {
674+ return directPath
675+ }
676+
677+ fallback = fallback ?? preferredPickPrimPath ( from: entity)
678+ }
679+
680+ return fallback
681+ }
600682
601683 // MARK: - Private Helpers
602684
@@ -716,6 +798,109 @@ extension RealityKitProvider {
716798 }
717799 }
718800
801+ private func isGenericImportedPath( _ primPath: String ) -> Bool {
802+ guard let leaf = primPath. split ( separator: " / " ) . last else { return false }
803+ return isGenericImportedName ( String ( leaf) )
804+ }
805+
806+ private func remappedPickPath( for directPath: String , entity: Entity ) -> String ? {
807+ if let overridePath = pickPathOverrides [ directPath] , overridePath. isEmpty == false {
808+ providerLogger. debug (
809+ " Resolved pick path \( directPath, privacy: . public) using override path \( overridePath, privacy: . public) "
810+ )
811+ return overridePath
812+ }
813+
814+ if let resolvedPath = pickPathResolver ? ( directPath, entity, self ) ,
815+ resolvedPath. isEmpty == false {
816+ providerLogger. debug (
817+ " Resolved pick path \( directPath, privacy: . public) using custom resolver path \( resolvedPath, privacy: . public) "
818+ )
819+ return resolvedPath
820+ }
821+
822+ return nil
823+ }
824+
825+ private func isGenericImportedName( _ name: String ) -> Bool {
826+ let lowered = name. lowercased ( )
827+ if lowered == " merged " || lowered. hasPrefix ( " merged_ " ) {
828+ return true
829+ }
830+ if lowered == " mesh " || lowered. hasPrefix ( " mesh_ " ) {
831+ return true
832+ }
833+ return false
834+ }
835+
836+ private func preferredSemanticPath( near genericPath: String ) -> String ? {
837+ let parentPath : String
838+ if let slash = genericPath. lastIndex ( of: " / " ) , slash != genericPath. startIndex {
839+ parentPath = String ( genericPath [ ..< slash] )
840+ } else {
841+ parentPath = " / "
842+ }
843+
844+ let directChildren = semanticDirectChildren ( of: parentPath)
845+ if let bestDirectChild = directChildren. min ( by: semanticPathOrdering) {
846+ return bestDirectChild
847+ }
848+
849+ let descendants = semanticDescendants ( of: parentPath)
850+ if let bestDescendant = descendants. min ( by: semanticPathOrdering) {
851+ return bestDescendant
852+ }
853+
854+ if let ancestor = nearestNonGenericAncestorPath ( for: parentPath) {
855+ return ancestor
856+ }
857+
858+ return nil
859+ }
860+
861+ private func semanticDirectChildren( of parentPath: String ) -> [ String ] {
862+ let parentDepth = pathDepth ( parentPath)
863+ let prefix = parentPath == " / " ? " / " : parentPath + " / "
864+
865+ return primPathToEntityID. keys. filter { candidate in
866+ guard candidate. hasPrefix ( prefix) , candidate != parentPath else { return false }
867+ guard pathDepth ( candidate) == parentDepth + 1 else { return false }
868+ return isGenericImportedPath ( candidate) == false
869+ }
870+ }
871+
872+ private func semanticDescendants( of parentPath: String ) -> [ String ] {
873+ let prefix = parentPath == " / " ? " / " : parentPath + " / "
874+ return primPathToEntityID. keys. filter { candidate in
875+ guard candidate. hasPrefix ( prefix) , candidate != parentPath else { return false }
876+ return isGenericImportedPath ( candidate) == false
877+ }
878+ }
879+
880+ private func nearestNonGenericAncestorPath( for primPath: String ) -> String ? {
881+ var cursor = primPath
882+ while true {
883+ guard let slash = cursor. lastIndex ( of: " / " ) , slash != cursor. startIndex else {
884+ return nil
885+ }
886+ cursor = String ( cursor [ ..< slash] )
887+ if primPathToEntityID [ cursor] != nil , isGenericImportedPath ( cursor) == false {
888+ return cursor
889+ }
890+ }
891+ }
892+
893+ private func pathDepth( _ primPath: String ) -> Int {
894+ primPath. split ( separator: " / " ) . count
895+ }
896+
897+ private func semanticPathOrdering( lhs: String , rhs: String ) -> Bool {
898+ let lhsDepth = pathDepth ( lhs)
899+ let rhsDepth = pathDepth ( rhs)
900+ if lhsDepth != rhsDepth { return lhsDepth < rhsDepth }
901+ return lhs. localizedStandardCompare ( rhs) == . orderedAscending
902+ }
903+
719904 /// BlendShape prims can map to mesh entities, so we walk up path ancestry
720905 /// until we find an entity that carries BlendShapeWeightsComponent.
721906 private func resolveBlendShapeEntity( for primPath: String ) -> Entity ? {
0 commit comments