Skip to content

Commit f852a0c

Browse files
committed
Stabilize RealityKit picking and post-process outlines
1 parent 9bd7280 commit f852a0c

6 files changed

Lines changed: 492 additions & 44 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ StageView provides a unified protocol and shared viewport components that Realit
2525
- **IBL Support**: Environment lighting with EV-style exposure control
2626
- **Scale Indicator**: Auto-switching scale reference (cm/m/km) based on scene size
2727
- **Colored Axes**: Visual axis indicators (X=red, Y=green, Z=blue)
28+
- **Selection Remapping Hooks**: Upgrade coarse imported pick results to semantic scene paths
2829

2930
## Modules
3031

@@ -128,6 +129,29 @@ config.showBackground = true
128129
let exponent = config.realityKitIntensityExponent
129130
```
130131

132+
### Upgrading Picked Paths
133+
134+
If RealityKit collapses imported geometry into generic entities such as
135+
`merged_1`, consumers can provide stronger scene-aware remapping:
136+
137+
```swift
138+
import RealityKitStageView
139+
140+
let provider = RealityKitProvider()
141+
142+
provider.setPickPathOverrides([
143+
"/RootNode/merged_1": "/RootNode/Forklift"
144+
])
145+
146+
provider.setPickPathResolver { directPath, entity, provider in
147+
guard directPath == "/RootNode/merged_1" else { return nil }
148+
return "/RootNode/Forklift/Body"
149+
}
150+
```
151+
152+
`StageView` applies consumer overrides first, then its built-in generic merged
153+
node fallback, then the direct imported mapping.
154+
131155
## Requirements
132156

133157
- **macOS 15.0+**

Sources/RealityKitStageView/Interactions/ArcballCameraControls.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ final class ArcballEventController {
152152
if activeMouseInteraction == nil && distance < 5 && duration < 0.5 && isEventInsideViewport(event) {
153153
let size = view.bounds.size
154154
pickLogger.debug("firing onPick at \(localPoint.x, privacy: .public),\(localPoint.y, privacy: .public) size=\(size.width, privacy: .public)x\(size.height, privacy: .public)")
155-
onPick?(CGPoint(x: localPoint.x, y: size.height - localPoint.y), size)
155+
onPick?(localPoint, size)
156156
}
157157
default:
158158
break

Sources/RealityKitStageView/RealityKitProvider.swift

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ public struct RealityKitDiscreteSnapshot: Equatable, Sendable {
5656
@Observable
5757
@MainActor
5858
public 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

Comments
 (0)