@webspatial/platform-visionos 1.3.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "@webspatial/platform-visionos",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "description": "Used to publish WebSpatial projects to Apple Vision Pro",
5
5
  "type": "commonjs",
6
+ "main": "package.json",
6
7
  "engines": {
7
8
  "node": ">=14.15.0"
8
9
  },
@@ -129,6 +129,13 @@ struct ConvertFromSceneToEntity: CommandDataProtocol {
129
129
  let position: Vec3
130
130
  }
131
131
 
132
+ struct ConvertCoordinate: CommandDataProtocol {
133
+ static let commandType: String = "ConvertCoordinate"
134
+ let position: Vec3
135
+ let fromId: String
136
+ let toId: String
137
+ }
138
+
132
139
  struct InspectCommand: CommandDataProtocol {
133
140
  static let commandType: String = "Inspect"
134
141
  var id: String?
@@ -166,6 +173,7 @@ protocol SpatializedElementProperties: SpatialObjectCommand {
166
173
  var enableMagnifyGesture: Bool? { get }
167
174
  var enableMagnifyEndGesture: Bool? { get }
168
175
  var enableTapGesture: Bool? { get }
176
+ var rotateConstrainedToAxis: Vec3? { get }
169
177
  }
170
178
 
171
179
  struct UpdateSpatialized2DElementProperties: SpatializedElementProperties {
@@ -193,6 +201,8 @@ struct UpdateSpatialized2DElementProperties: SpatializedElementProperties {
193
201
  var enableMagnifyEndGesture: Bool?
194
202
  var enableTapGesture: Bool?
195
203
 
204
+ let rotateConstrainedToAxis: Vec3?
205
+
196
206
  let scrollPageEnabled: Bool?
197
207
  let material: BackgroundMaterial?
198
208
  let cornerRadius: CornerRadius?
@@ -227,6 +237,8 @@ struct UpdateSpatializedStatic3DElementProperties: SpatializedElementProperties
227
237
  let enableMagnifyEndGesture: Bool?
228
238
  let enableTapGesture: Bool?
229
239
 
240
+ let rotateConstrainedToAxis: Vec3?
241
+
230
242
  let modelURL: String?
231
243
  let modelTransform: [Double]?
232
244
  }
@@ -255,6 +267,8 @@ struct UpdateSpatializedDynamic3DElementProperties: SpatializedElementProperties
255
267
  let enableMagnifyGesture: Bool?
256
268
  let enableMagnifyEndGesture: Bool?
257
269
  let enableTapGesture: Bool?
270
+
271
+ let rotateConstrainedToAxis: Vec3?
258
272
  }
259
273
 
260
274
  struct UpdateSpatializedElementTransform: SpatialObjectCommand {
@@ -339,6 +353,15 @@ struct GetSpatialSceneStateCommand: CommandDataProtocol {
339
353
  static let commandType = "GetSpatialSceneState"
340
354
  }
341
355
 
356
+ struct InitializeAttachmentCommand: CommandDataProtocol {
357
+ static let commandType = "InitializeAttachment"
358
+ let id: String
359
+ let parentEntityId: String
360
+ let position: [Float]?
361
+ let size: AttachmentSize?
362
+ let ownerViewId: String
363
+ }
364
+
342
365
  struct UpdateAttachmentEntityCommand: CommandDataProtocol {
343
366
  static let commandType = "UpdateAttachmentEntity"
344
367
  let id: String
@@ -14,8 +14,6 @@ enum WebSpatialGestureType: String, Encodable {
14
14
  }
15
15
 
16
16
  enum SpatialWebMsgType: String, Encodable {
17
- case cubeInfo
18
- case transform
19
17
  case modelloaded
20
18
  case modelloadfailed
21
19
  case spatialtap
@@ -30,19 +28,6 @@ enum SpatialWebMsgType: String, Encodable {
30
28
  case objectdestroy
31
29
  }
32
30
 
33
- /// notify Spatialized3DElement Container Cube, used for ref.current.getBoundingClientCube()
34
- struct SpatiaizedContainerClientCube: Encodable {
35
- let type: SpatialWebMsgType = .cubeInfo
36
- let origin: Point3D
37
- let size: Size3D
38
- }
39
-
40
- /// notify Spatialized3DElement Container Transform to SpatialScene, used for ref.current.convertToSpatialScene()
41
- struct SpatiaizedContainerTransform: Encodable {
42
- let type: SpatialWebMsgType = .transform
43
- let detail: AffineTransform3D
44
- }
45
-
46
31
  struct WebSpatialTapGuestureEventDetail: Encodable {
47
32
  let location3D: Point3D
48
33
  /// Global scene location (maps to clientX/clientY/clientZ on the web side).
@@ -9,7 +9,7 @@ struct AttachmentInfo: Identifiable, Equatable {
9
9
  var webViewModel: SpatialWebViewModel
10
10
 
11
11
  static func == (lhs: AttachmentInfo, rhs: AttachmentInfo) -> Bool {
12
- return lhs.id == rhs.id
12
+ lhs.id == rhs.id
13
13
  }
14
14
  }
15
15
 
@@ -29,9 +29,9 @@ class AttachmentManager {
29
29
  id: String,
30
30
  parentEntityId: String,
31
31
  position: SIMD3<Float>,
32
- size: CGSize
32
+ size: CGSize,
33
+ webViewModel: SpatialWebViewModel
33
34
  ) -> AttachmentInfo {
34
- let webViewModel = SpatialWebViewModel(url: nil)
35
35
  webViewModel.setBackgroundTransparent(true)
36
36
  // webViewModel.scrollEnabled = false
37
37
 
@@ -48,12 +48,14 @@ class AttachmentManager {
48
48
 
49
49
  func update(id: String, position: SIMD3<Float>?, size: CGSize?) {
50
50
  guard var info = attachments[id] else { return }
51
+
51
52
  if let position = position {
52
53
  info.position = position
53
54
  }
54
55
  if let size = size {
55
56
  info.size = size
56
57
  }
58
+
57
59
  attachments[id] = info
58
60
  }
59
61
 
@@ -66,12 +68,13 @@ class AttachmentManager {
66
68
  }
67
69
 
68
70
  func get(id: String) -> AttachmentInfo? {
69
- return attachments[id]
71
+ attachments[id]
70
72
  }
71
73
 
72
74
  func destroyAll() {
73
75
  let toDestroy = Array(attachments.values)
74
76
  attachments.removeAll()
77
+
75
78
  DispatchQueue.main.async {
76
79
  for info in toDestroy {
77
80
  info.webViewModel.destroy()
@@ -71,6 +71,16 @@ class Dynamic3DManager {
71
71
  }
72
72
 
73
73
  static func loadResourceToLocal(_ urlString: String, loadComplete: @escaping (Result<URL, Error>) -> Void) {
74
+ // load local file
75
+ if urlString.starts(with: "file://") {
76
+ guard let localUrl = URL(string: pwaManager.getLocalResourceURL(url: urlString)) else {
77
+ loadComplete(.failure(NSError(domain: "Download Error", code: 0, userInfo: [NSLocalizedDescriptionKey: "Local file is not found"])))
78
+ return
79
+ }
80
+ loadComplete(.success(localUrl))
81
+ return
82
+ }
83
+ // load net file
74
84
  guard let url = URL(string: urlString) else {
75
85
  loadComplete(.failure(NSError(domain: "Invalid URL", code: 0, userInfo: [NSLocalizedDescriptionKey: "Failed to create URL from string: \(urlString)"])))
76
86
  return
@@ -8,8 +8,8 @@ class WKWebViewManager {
8
8
 
9
9
  func create(controller: SpatialWebController, configuration: WKWebViewConfiguration? = nil, spatialId: String? = "") -> WKWebView {
10
10
  let userContentController = WKUserContentController()
11
- // TODO: get native api instead of PACKAGE_VERSION
12
- let userScript = WKUserScript(source: "window.WebSpatailEnabled = true; window.WebSpatailNativeVersion = 'PACKAGE_VERSION';", injectionTime: .atDocumentStart, forMainFrameOnly: false)
11
+ // TODO: get native api instead of using the injected WS_SDK_VERSION placeholder
12
+ let userScript = WKUserScript(source: "window.WebSpatailEnabled = true; window.WebSpatailNativeVersion = 'WS_SDK_VERSION';", injectionTime: .atDocumentStart, forMainFrameOnly: false)
13
13
  userContentController.addUserScript(userScript)
14
14
  // userContentController.add(controller, name: "bridge")
15
15
  userContentController.addScriptMessageHandler(controller, contentWorld: .page, name: "bridge")
@@ -25,8 +25,8 @@ class WKWebViewManager {
25
25
  // change webview ua
26
26
  let ua = controller.webview!.value(forKey: "userAgent") as? String ?? ""
27
27
  let webviewVersion = ua.split(separator: configUA)[0].split(separator: "AppleWebKit")[1]
28
- // TODO: get native api instead of PACKAGE_VERSION
29
- controller.webview!.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; wv) AppleWebKit\(webviewVersion)WebSpatial/\("PACKAGE_VERSION") SpatialID/\(spatialId!)"
28
+ // TODO: get native api instead of relying on injected shell/sdk versions
29
+ controller.webview!.customUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7; wv) AppleWebKit\(webviewVersion)WSAppShell/\(pwaManager.getShellVersion()) WebSpatial/\(pwaManager.getSdkVersion()) SpatialID/\(spatialId!)"
30
30
  controller.webview!.uiDelegate = controller
31
31
  controller.webview!.allowsBackForwardNavigationGestures = false
32
32
  controller.webview!.isInspectable = true
@@ -6,9 +6,9 @@ var pwaManager = PWAManager()
6
6
  struct PWAManager: Codable {
7
7
  var isLocal: Bool = false
8
8
 
9
- var start_url: String = "http://localhost:5173"
9
+ var start_url: String = "http://localhost:5173/#/geometry-verify"
10
10
 
11
- // var start_url: String = "http://localhost:5173/webspatial/avp/materialApiTest"
11
+ // var start_url: String = "http://localhost:5173/#/spatial-drag-gesture"
12
12
 
13
13
  var scope: String = ""
14
14
  var id: String = "com.webspatial.pico"
@@ -38,7 +38,8 @@ struct PWAManager: Codable {
38
38
  baseplateVisibility: nil
39
39
  )
40
40
  var useMainScene: Bool = true
41
- private var version: String = "PACKAGE_VERSION"
41
+ private var shellVersion: String = "WS_SHELL_VERSION"
42
+ private var sdkVersion: String = "WS_SDK_VERSION"
42
43
 
43
44
  mutating func _init() {
44
45
  let urlType = start_url.split(separator: "://").first
@@ -102,8 +103,12 @@ struct PWAManager: Codable {
102
103
  return resource
103
104
  }
104
105
 
105
- func getVersion() -> String {
106
- return version
106
+ func getShellVersion() -> String {
107
+ return shellVersion
108
+ }
109
+
110
+ func getSdkVersion() -> String {
111
+ return sdkVersion
107
112
  }
108
113
  }
109
114
 
@@ -4,7 +4,7 @@ import SwiftUI
4
4
  let logger = Logger()
5
5
 
6
6
  /// To load a local path, remove http:// eg. "static-web/"
7
- let nativeAPIVersion = pwaManager.getVersion()
7
+ let nativeAPIVersion = pwaManager.getShellVersion()
8
8
 
9
9
  /// start URL
10
10
  let startURL = pwaManager.start_url
@@ -84,7 +84,7 @@ class SpatialApp {
84
84
  }
85
85
 
86
86
  var version: String {
87
- pwaManager.getVersion()
87
+ pwaManager.getShellVersion()
88
88
  }
89
89
 
90
90
  var startURL: String {
@@ -1,5 +1,6 @@
1
1
  import Combine
2
2
  import Foundation
3
+ import RealityKit
3
4
  import simd
4
5
  import SwiftUI
5
6
 
@@ -86,6 +87,9 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
86
87
 
87
88
  var spatialWebViewModel: SpatialWebViewModel
88
89
 
90
+ private var meterToPtUnscaled: Double?
91
+ private var meterToPtScaled: Double?
92
+
89
93
  init(
90
94
  _ url: String,
91
95
  _ windowStyle: WindowStyle,
@@ -108,6 +112,20 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
108
112
  spatialWebViewModel.sendWebEvent(id, msg)
109
113
  }
110
114
 
115
+ func onUpdatePhysicalMetrics(meterToPtUnscaled: Double, meterToPtScaled: Double) {
116
+ self.meterToPtUnscaled = meterToPtUnscaled
117
+ self.meterToPtScaled = meterToPtScaled
118
+ let js = """
119
+ window.__webspatialsdk__ = window.__webspatialsdk__ || {};
120
+ window.__webspatialsdk__.physicalMetrics = {
121
+ meterToPtUnscaled: \(meterToPtUnscaled),
122
+ meterToPtScaled: \(meterToPtScaled)
123
+ };
124
+ """
125
+ spatialWebViewModel.getController().callJS(js)
126
+ sendWebMsg("window", "")
127
+ }
128
+
111
129
  private func setupSpatialWebView() {
112
130
  setupJSBListeners()
113
131
  setupWebViewStateListener()
@@ -300,6 +318,8 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
300
318
  spatialWebViewModel.addJSBListener(ConvertFromEntityToEntity.self, onConvertFromEntityToEntity)
301
319
  spatialWebViewModel.addJSBListener(ConvertFromEntityToScene.self, onConvertFromEntityToScene)
302
320
  spatialWebViewModel.addJSBListener(ConvertFromSceneToEntity.self, onConvertFromSceneToEntity)
321
+ spatialWebViewModel.addJSBListener(InitializeAttachmentCommand.self, onInitializeAttachment)
322
+ spatialWebViewModel.addJSBListener(ConvertCoordinate.self, onConvertCoordinate)
303
323
 
304
324
  spatialWebViewModel.addJSBListener(UpdateAttachmentEntityCommand.self, onUpdateAttachmentEntity)
305
325
 
@@ -326,8 +346,8 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
326
346
 
327
347
  // write through
328
348
  spatialWebViewModel.updateWindowKV([
329
- "innerDepth": depth,
330
- "outerDepth": depth,
349
+ "xrInnerDepth": depth,
350
+ "xrOuterDepth": depth,
331
351
  "outerHeight": height + SpatialScene.navHeight,
332
352
  ])
333
353
  }
@@ -349,6 +369,17 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
349
369
  self.handleWindowClose()
350
370
  }
351
371
 
372
+ spatialWebViewModel.addStateListener(.didReceive) {
373
+ if let meterToPtUnscaled = self.meterToPtUnscaled,
374
+ let meterToPtScaled = self.meterToPtScaled
375
+ {
376
+ self.onUpdatePhysicalMetrics(
377
+ meterToPtUnscaled: meterToPtUnscaled,
378
+ meterToPtScaled: meterToPtScaled
379
+ )
380
+ }
381
+ }
382
+
352
383
  spatialWebViewModel.addStateListener(.didFailLoad) {
353
384
  self.didFailLoad = true
354
385
  }
@@ -394,46 +425,51 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
394
425
  }
395
426
  }
396
427
 
428
+ // Temporary storage for webview models awaiting JSB initialization
429
+ private var pendingAttachmentWebViewModels = [String: SpatialWebViewModel]()
430
+
397
431
  private func handleCreateAttachment(_ url: URL) -> WebViewElementInfo? {
398
- guard let components = URLComponents(string: url.absoluteString),
399
- let queryItems = components.queryItems
400
- else {
401
- print("❌ fail to parse attachment URL")
402
- return nil
403
- }
432
+ // Just create a bare webview — metadata arrives via InitializeAttachment JSB
433
+ let id = UUID().uuidString
434
+ let webViewModel = SpatialWebViewModel(url: nil)
435
+ webViewModel.setBackgroundTransparent(true)
436
+ pendingAttachmentWebViewModels[id] = webViewModel
437
+ return WebViewElementInfo(id: id, element: webViewModel)
438
+ }
404
439
 
405
- guard let parentEntityId = queryItems.first(where: { $0.name == "parentEntityId" })?.value else {
406
- print("❌ missing parentEntityId for attachment")
407
- return nil
440
+ private func onInitializeAttachment(
441
+ command: InitializeAttachmentCommand,
442
+ resolve: @escaping JSBManager.ResolveHandler<Encodable>
443
+ ) {
444
+ guard let webViewModel = pendingAttachmentWebViewModels.removeValue(forKey: command.id) else {
445
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "No pending attachment for \(command.id)")))
446
+ return
408
447
  }
409
448
 
410
- // Parse position (JSON array like [0,0.1,0])
411
449
  var position = SIMD3<Float>(0, 0, 0)
412
- if let positionStr = queryItems.first(where: { $0.name == "position" })?.value?.removingPercentEncoding,
413
- let positionData = positionStr.data(using: .utf8),
414
- let positionArray = try? JSONDecoder().decode([Float].self, from: positionData),
415
- positionArray.count >= 3
416
- {
417
- position = SIMD3<Float>(positionArray[0], positionArray[1], positionArray[2])
450
+ if let posArray = command.position, posArray.count >= 3 {
451
+ position = SIMD3<Float>(posArray[0], posArray[1], posArray[2])
418
452
  }
419
453
 
420
- // Parse size (JSON object like {"width":100,"height":100})
421
- var size = CGSize(width: 100, height: 100)
422
- if let sizeStr = queryItems.first(where: { $0.name == "size" })?.value?.removingPercentEncoding,
423
- let sizeData = sizeStr.data(using: .utf8),
424
- let sizeObj = try? JSONDecoder().decode(AttachmentSize.self, from: sizeData)
425
- {
426
- size = CGSize(width: sizeObj.width, height: sizeObj.height)
427
- }
454
+ let size = CGSize(
455
+ width: command.size?.width ?? 100,
456
+ height: command.size?.height ?? 100
457
+ )
428
458
 
429
- let info = attachmentManager.create(
430
- id: UUID().uuidString,
431
- parentEntityId: parentEntityId,
459
+ let ownerId = command.ownerViewId
460
+ if spatialObjects[ownerId] == nil {
461
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "ownerViewId must belong to the current scene for attachment \(command.id)")))
462
+ return
463
+ }
464
+ attachmentManager.create(
465
+ id: command.id,
466
+ parentEntityId: command.parentEntityId,
432
467
  position: position,
433
- size: size
468
+ size: size,
469
+ webViewModel: webViewModel
434
470
  )
435
471
 
436
- return WebViewElementInfo(id: info.id, element: info.webViewModel)
472
+ resolve(.success(baseReplyData))
437
473
  }
438
474
 
439
475
  private func onPageStartLoad() {
@@ -442,7 +478,6 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
442
478
  for spatialObject in spatialObjectArray {
443
479
  spatialObject.destroy()
444
480
  }
445
- // destroy all attachments
446
481
  attachmentManager.destroyAll()
447
482
  backgroundMaterial = .None
448
483
  }
@@ -715,6 +750,10 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
715
750
  spatializedElement.enableRotateEndGesture = enableRotateEndGesture
716
751
  }
717
752
 
753
+ if let rotateConstrainedToAxis = command.rotateConstrainedToAxis {
754
+ spatializedElement.rotateConstrainedToAxis = rotateConstrainedToAxis
755
+ }
756
+
718
757
  if let enableMagnifyGesture = command.enableMagnifyGesture {
719
758
  spatializedElement.enableMagnifyGesture = enableMagnifyGesture
720
759
  }
@@ -1068,22 +1107,91 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
1068
1107
  resolve(.success(ConvertReply(id: command.entityId, position: point)))
1069
1108
  }
1070
1109
 
1110
+ /// Input: command.position, command.fromId, command.toId
1111
+ /// fromId/toId can reference either the scene (window) or an entity.
1112
+ /// Step 1: Convert position to window coordinates (view global, px)
1113
+ /// - If from is window (scene), position is already in view global (px). Go to Step 2.
1114
+ /// - If from is 2d frame(SpatializedElement), position is in view local (px).
1115
+ /// - view local → window (view global, px) using SpatializedElement.convertToScene
1116
+ /// - If from is an entity, position is in reality entity local (meters):
1117
+ /// - entity local → reality world (scene)
1118
+ /// - reality world → window (view global, px)
1119
+ /// Step 2: Convert window coordinates (view global, px) to target output
1120
+ /// - If to is window, output directly.
1121
+ /// - If to is 2d frame(SpatializedElement), output in view local (px).
1122
+ /// - window (view global, px) → view local using SpatializedElement.convertFromScene
1123
+ /// - If to is an entity, output in reality entity local (meters):
1124
+ /// - window (view global, px) → reality world (scene)
1125
+ /// - reality world → reality entity local (meters)
1126
+
1127
+ private func onConvertCoordinate(command: ConvertCoordinate, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1128
+ func isSceneId(_ id: String) -> Bool {
1129
+ return id.isEmpty
1130
+ }
1131
+ let input = SIMD3<Float>(Float(command.position.x), Float(command.position.y), Float(command.position.z))
1132
+ let fromEntity = spatialObjects[command.fromId] as? SpatialEntity
1133
+ let from2dFrame = spatialObjects[command.fromId] as? SpatializedElement
1134
+ let toEntity = spatialObjects[command.toId] as? SpatialEntity
1135
+ let to2dFrame = spatialObjects[command.toId] as? SpatializedElement
1136
+
1137
+ var globalPx: Point3D
1138
+ if isSceneId(command.fromId) {
1139
+ globalPx = Point3D(x: Double(input.x), y: Double(input.y), z: Double(input.z))
1140
+ } else if let fromEntity {
1141
+ let world = fromEntity.convert(position: input, to: nil)
1142
+ guard let content = findSpatializedDynamic3DElement(containingEntityId: fromEntity.spatialId)?.getViewContent() else {
1143
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "RealityView content unavailable for conversion")))
1144
+ return
1145
+ }
1146
+ globalPx = content.convert(point: world, from: .scene, to: .global)
1147
+ } else if let from2dFrame {
1148
+ let localPoint = SIMD3<Double>(Double(input.x), Double(input.y), Double(input.z))
1149
+ let scenePoint = from2dFrame.convertToScene(localPoint)
1150
+ globalPx = Point3D(x: scenePoint.x, y: scenePoint.y, z: scenePoint.z)
1151
+ } else {
1152
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Invalid fromId")))
1153
+ return
1154
+ }
1155
+
1156
+ if isSceneId(command.toId) {
1157
+ let result = Vec3(x: CGFloat(globalPx.x), y: CGFloat(globalPx.y), z: CGFloat(globalPx.z))
1158
+ resolve(.success(result))
1159
+ return
1160
+ } else if let toEntity {
1161
+ guard let content = findSpatializedDynamic3DElement(containingEntityId: toEntity.spatialId)?.getViewContent() else {
1162
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "RealityView content unavailable for conversion")))
1163
+ return
1164
+ }
1165
+ let world = content.convert(globalPx, from: .global, to: .scene)
1166
+ let local = toEntity.convert(position: world, from: nil)
1167
+ let ret = Vec3(x: CGFloat(local.x), y: CGFloat(local.y), z: CGFloat(local.z))
1168
+ resolve(.success(ret))
1169
+ return
1170
+ } else if let to2dFrame {
1171
+ let scenePoint = SIMD3<Double>(globalPx.x, globalPx.y, globalPx.z)
1172
+ let localPoint = to2dFrame.convertFromScene(scenePoint)
1173
+ let ret = Vec3(x: CGFloat(localPoint.x), y: CGFloat(localPoint.y), z: CGFloat(localPoint.z))
1174
+ resolve(.success(ret))
1175
+ return
1176
+ } else {
1177
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Invalid toId")))
1178
+ return
1179
+ }
1180
+ }
1181
+
1071
1182
  private func onUpdateAttachmentEntity(command: UpdateAttachmentEntityCommand, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1072
1183
  guard attachmentManager.get(id: command.id) != nil else {
1073
1184
  resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Attachment \(command.id) not found")))
1074
1185
  return
1075
1186
  }
1076
-
1077
1187
  var newPosition: SIMD3<Float>? = nil
1078
1188
  if let posArray = command.position, posArray.count >= 3 {
1079
1189
  newPosition = SIMD3<Float>(posArray[0], posArray[1], posArray[2])
1080
1190
  }
1081
-
1082
1191
  var newSize: CGSize? = nil
1083
1192
  if let sizeObj = command.size {
1084
1193
  newSize = CGSize(width: sizeObj.width, height: sizeObj.height)
1085
1194
  }
1086
-
1087
1195
  attachmentManager.update(id: command.id, position: newPosition, size: newSize)
1088
1196
  resolve(.success(baseReplyData))
1089
1197
  }
@@ -1111,6 +1219,32 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
1111
1219
  sendWebMsg(spatialObject.spatialId, SpatialObjectDestroiedEvent())
1112
1220
  }
1113
1221
 
1222
+ /// Find the dynamic 3D container (SpatializedDynamic3DElement) that contains the entity by ID.
1223
+ /// - Parameter entityId: The entity's spatialId.
1224
+ /// - Returns: The container if the entity is a descendant of the container's root; otherwise nil.
1225
+ private func findSpatializedDynamic3DElement(containingEntityId entityId: String) -> SpatializedDynamic3DElement? {
1226
+ guard let entity = spatialObjects[entityId] as? SpatialEntity else {
1227
+ return nil
1228
+ }
1229
+
1230
+ for (_, object) in spatialObjects {
1231
+ guard let dynamic3dElement = object as? SpatializedDynamic3DElement else {
1232
+ continue
1233
+ }
1234
+
1235
+ let root = dynamic3dElement.getRoot()
1236
+ var current: Entity? = entity
1237
+ while let node = current {
1238
+ if node === root {
1239
+ return dynamic3dElement
1240
+ }
1241
+ current = node.parent
1242
+ }
1243
+ }
1244
+
1245
+ return nil
1246
+ }
1247
+
1114
1248
  func findSpatialObject<T: SpatialObjectProtocol>(_ id: String) -> T? {
1115
1249
  return spatialObjects[id] as? T
1116
1250
  }
@@ -1,8 +1,11 @@
1
+ import _RealityKit_SwiftUI
1
2
  import Foundation
3
+ import RealityKit
2
4
 
3
5
  @Observable
4
6
  class SpatializedDynamic3DElement: SpatializedElement {
5
7
  private var rootEntity = SpatialEntity()
8
+ private var viewContent: RealityViewContent? = nil
6
9
 
7
10
  func getRoot() -> SpatialEntity {
8
11
  return rootEntity
@@ -16,6 +19,14 @@ class SpatializedDynamic3DElement: SpatializedElement {
16
19
  rootEntity.removeChild(entity)
17
20
  }
18
21
 
22
+ func getViewContent() -> RealityViewContent? {
23
+ return viewContent
24
+ }
25
+
26
+ func setViewContent(_ content: RealityViewContent?) {
27
+ viewContent = content
28
+ }
29
+
19
30
  enum CodingKeys: String, CodingKey {
20
31
  case type, root
21
32
  }
@@ -28,6 +39,7 @@ class SpatializedDynamic3DElement: SpatializedElement {
28
39
  }
29
40
 
30
41
  override func onDestroy() {
42
+ viewContent = nil
31
43
  rootEntity.destroy()
32
44
  super.onDestroy()
33
45
  }
@@ -2,6 +2,9 @@ import Foundation
2
2
  import RealityKit
3
3
  import SwiftUI
4
4
 
5
+ /// zIndex() have some bug, so use zOrderBias to simulate zIndex effect
6
+ let zOrderBias = 0.001
7
+
5
8
  enum SpatializedElementType: String, Codable {
6
9
  case Spatialized2DElement
7
10
  case SpatializedStatic3DElement
@@ -32,8 +35,45 @@ class SpatializedElement: SpatialObject {
32
35
  var enableMagnifyEndGesture: Bool = false
33
36
  var enableTapGesture: Bool = false
34
37
 
38
+ /// When non-nil and non-zero length, rotate gesture is constrained to this axis (world space).
39
+ var rotateConstrainedToAxis: Vec3?
40
+
35
41
  var defaultAlignment: DepthAlignment = .back
36
42
 
43
+ /// Raw layout→scene transform from onGeometryChange3D proxy.
44
+ /// Does NOT include backOffset or zIndex offset.
45
+ /// Updated by SpatializedElementView whenever layout changes.
46
+ var proxySceneTransform: AffineTransform3D = .identity
47
+
48
+ /// Full local→scene transform accounting for --xr-back and zIndex.
49
+ /// Computed on-the-fly so backOffset/zIndex changes are always reflected.
50
+ var sceneTransform: AffineTransform3D {
51
+ let frameZ = (zIndex * zOrderBias) + backOffset
52
+ let localZ = AffineTransform3D(translation: Vector3D(x: 0, y: 0, z: frameZ))
53
+ return proxySceneTransform.concatenating(localZ)
54
+ }
55
+
56
+ /// Converts a point from this element's local coordinate system to scene space.
57
+ func convertToScene(_ localPoint: SIMD3<Double>) -> SIMD3<Double> {
58
+ let p = SIMD4<Double>(localPoint.x, localPoint.y, localPoint.z, 1.0)
59
+ let scene = sceneTransform.matrix * p
60
+ return SIMD3<Double>(scene.x, scene.y, scene.z)
61
+ }
62
+
63
+ /// Converts a point from scene space to this element's local coordinate system.
64
+ func convertFromScene(_ scenePoint: SIMD3<Double>) -> SIMD3<Double> {
65
+ let inv = sceneTransform.inverse!
66
+ let p = SIMD4<Double>(scenePoint.x, scenePoint.y, scenePoint.z, 1.0)
67
+ let local = inv.matrix * p
68
+ return SIMD3<Double>(local.x, local.y, local.z)
69
+ }
70
+
71
+ /// Converts a point from this element's local space to another element's local space.
72
+ func convert(_ localPoint: SIMD3<Double>, to target: SpatializedElement) -> SIMD3<Double> {
73
+ let scenePoint = convertToScene(localPoint)
74
+ return target.convertFromScene(scenePoint)
75
+ }
76
+
37
77
  var enableGesture: Bool {
38
78
  return enableDragStartGesture || enableDragGesture || enableDragEndGesture || enableRotateGesture || enableRotateEndGesture || enableMagnifyGesture || enableMagnifyEndGesture || enableTapGesture
39
79
  }
@@ -138,10 +138,16 @@ class SpatialEntity: Entity, SpatialObjectProtocol {
138
138
  if !components.has(InputTargetComponent.self) {
139
139
  components.set(InputTargetComponent())
140
140
  }
141
+ if !components.has(HoverEffectComponent.self) {
142
+ components.set(HoverEffectComponent())
143
+ }
141
144
  } else {
142
145
  if components.has(InputTargetComponent.self) {
143
146
  components.remove(InputTargetComponent.self)
144
147
  }
148
+ if components.has(HoverEffectComponent.self) {
149
+ components.remove(HoverEffectComponent.self)
150
+ }
145
151
  }
146
152
  }
147
153
 
@@ -10,6 +10,9 @@ struct SceneHandlerUIView: View {
10
10
  @State var spatialScene: SpatialScene
11
11
 
12
12
  @Environment(\.scenePhase) private var scenePhase
13
+ @Environment(\.physicalMetrics) private var converter
14
+ @State private var latestScaled: Double?
15
+ @State private var latestUnscaled: Double?
13
16
 
14
17
  private func setResizibility(resizingRestrictions: UIWindowScene.ResizingRestrictions) {
15
18
  sceneDelegate.window?.windowScene?
@@ -41,10 +44,35 @@ struct SceneHandlerUIView: View {
41
44
  }
42
45
  }
43
46
 
47
+ private func updatePhysicalMetricsIfReady() {
48
+ if let scaled = latestScaled, let unscaled = latestUnscaled {
49
+ spatialScene.onUpdatePhysicalMetrics(meterToPtUnscaled: unscaled, meterToPtScaled: scaled)
50
+ }
51
+ }
52
+
44
53
  var body: some View {
54
+ let meterToPtScaled = converter.worldScalingCompensation(.scaled).convert(
55
+ 1,
56
+ from: .meters
57
+ )
58
+ let meterToPtUnscaled = converter.worldScalingCompensation(.unscaled).convert(
59
+ 1,
60
+ from: .meters
61
+ )
45
62
  VStack {}
46
63
  .onAppear {
47
- // window scene only resize logic
64
+ latestScaled = meterToPtScaled
65
+ latestUnscaled = meterToPtUnscaled
66
+ updatePhysicalMetricsIfReady()
67
+ }
68
+ .onChange(of: meterToPtScaled) { _, newValue in
69
+ latestScaled = newValue
70
+ updatePhysicalMetricsIfReady()
71
+ }
72
+ .onChange(of: meterToPtUnscaled) { _, newValue in
73
+ latestUnscaled = newValue
74
+ updatePhysicalMetricsIfReady()
75
+ }.onAppear {
48
76
  guard spatialScene.windowStyle == .window else {
49
77
  return
50
78
  }
@@ -37,7 +37,7 @@ struct SpatializedDynamic3DView: View {
37
37
  }
38
38
 
39
39
  var rotate3dEvent: some Gesture {
40
- RotateGesture3D().targetedToAnyEntity().onChanged { value in
40
+ makeRotateGesture3D().targetedToAnyEntity().onChanged { value in
41
41
  // Always forward rotate gesture events to JS
42
42
  if let entity = value.entity as? SpatialEntity {
43
43
  let gestureEvent = WebSpatialRotateGuestureEvent(
@@ -62,6 +62,21 @@ struct SpatializedDynamic3DView: View {
62
62
  }
63
63
  }
64
64
 
65
+ private func makeRotateGesture3D() -> RotateGesture3D {
66
+ guard let raw = spatializedElement.rotateConstrainedToAxis else {
67
+ return RotateGesture3D()
68
+ }
69
+ let dx = Double(raw.x)
70
+ let dy = Double(raw.y)
71
+ let dz = Double(raw.z)
72
+ let len = (dx * dx + dy * dy + dz * dz).squareRoot()
73
+ if len < 1e-9 {
74
+ return RotateGesture3D()
75
+ }
76
+ let axis = RotationAxis3D(x: dx / len, y: dy / len, z: dz / len)
77
+ return RotateGesture3D(constrainedToAxis: axis)
78
+ }
79
+
65
80
  var magnifyEvent: some Gesture {
66
81
  MagnifyGesture().targetedToAnyEntity().onChanged { value in
67
82
  // Always forward magnify gesture events to JS
@@ -122,6 +137,7 @@ struct SpatializedDynamic3DView: View {
122
137
  RealityView(make: { content, attachments in
123
138
  let rootEntity = spatializedDynamic3DElement.getRoot()
124
139
  content.add(rootEntity)
140
+ spatializedDynamic3DElement.setViewContent(content)
125
141
 
126
142
  // Add existing attachments on initial creation
127
143
  for (_, info) in spatialScene.attachmentManager.attachments {
@@ -168,6 +184,9 @@ struct SpatializedDynamic3DView: View {
168
184
  .simultaneousGesture(rotate3dEvent)
169
185
  .simultaneousGesture(dragEvent)
170
186
  .simultaneousGesture(magnifyEvent)
187
+ .onDisappear {
188
+ spatializedDynamic3DElement.setViewContent(nil)
189
+ }
171
190
  }
172
191
 
173
192
  private func findSpatialEntity(_ spatialId: String) -> SpatialEntity? {
@@ -1,15 +1,9 @@
1
1
  import CoreGraphics
2
2
  import SwiftUI
3
3
 
4
- /// zIndex() have some bug, so use zOrderBias to simulate zIndex effect
5
- let zOrderBias = 0.001
6
-
7
- final class GestureFlags {
4
+ final class GestureState {
8
5
  var isDrag = false
9
- }
10
-
11
- final class TransformHolder {
12
- var transform: AffineTransform3D = .identity
6
+ var proxyTransform: AffineTransform3D = .identity
13
7
  }
14
8
 
15
9
  struct SpatializedElementView<Content: View>: View {
@@ -19,9 +13,7 @@ struct SpatializedElementView<Content: View>: View {
19
13
  var parentScrollOffset: Vec2
20
14
  var content: Content
21
15
 
22
- @State private var gestureFlags = GestureFlags()
23
- @State private var transformHolder = TransformHolder()
24
- @State private var currentTransform: AffineTransform3D = .identity
16
+ @State private var gestureState = GestureState()
25
17
 
26
18
  init(parentScrollOffset: Vec2, @ViewBuilder content: () -> Content) {
27
19
  self.parentScrollOffset = parentScrollOffset
@@ -34,7 +26,7 @@ struct SpatializedElementView<Content: View>: View {
34
26
  .onChanged(onDragging)
35
27
  .onEnded(onDraggingEnded)
36
28
  .simultaneously(with:
37
- RotateGesture3D()
29
+ makeRotateGesture3D()
38
30
  .onChanged(onRotateGesture3D)
39
31
  .onEnded(onRotateGesture3DEnd))
40
32
  .simultaneously(with:
@@ -46,6 +38,21 @@ struct SpatializedElementView<Content: View>: View {
46
38
  .onEnded(onTapEnded))
47
39
  }
48
40
 
41
+ private func makeRotateGesture3D() -> RotateGesture3D {
42
+ guard let raw = spatializedElement.rotateConstrainedToAxis else {
43
+ return RotateGesture3D()
44
+ }
45
+ let dx = Double(raw.x)
46
+ let dy = Double(raw.y)
47
+ let dz = Double(raw.z)
48
+ let len = (dx * dx + dy * dy + dz * dz).squareRoot()
49
+ if len < 1e-9 {
50
+ return RotateGesture3D()
51
+ }
52
+ let axis = RotationAxis3D(x: dx / len, y: dy / len, z: dz / len)
53
+ return RotateGesture3D(constrainedToAxis: axis)
54
+ }
55
+
49
56
  private func onRotateGesture3D(_ event: RotateGesture3D.Value) {
50
57
  if spatializedElement.enableRotateGesture {
51
58
  let quaternion = event.rotation.quaternion
@@ -67,16 +74,18 @@ struct SpatializedElementView<Content: View>: View {
67
74
  }
68
75
 
69
76
  private func onDragging(_ event: DragGesture.Value) {
70
- if spatializedElement.enableDragStartGesture, !gestureFlags.isDrag {
71
- let localPoint = SIMD4<Double>(event.startLocation3D.x, event.startLocation3D.y, event.startLocation3D.z, 1.0)
72
- let transformedPoint = transformHolder.transform.matrix * localPoint
73
- let globalPoint3D = Point3D(x: transformedPoint.x, y: transformedPoint.y, z: transformedPoint.z)
74
-
77
+ if spatializedElement.enableDragStartGesture, !gestureState.isDrag {
78
+ let frameZ = localFrameOffsetZ()
79
+ let startLocal = Point3D(
80
+ x: event.startLocation3D.x,
81
+ y: event.startLocation3D.y,
82
+ z: event.startLocation3D.z - frameZ
83
+ )
84
+ let globalPoint3D = localToScene(event.startLocation3D)
75
85
  let gestureEvent = WebSpatialDragStartGuestureEvent(detail: .init(
76
- startLocation3D: event.startLocation3D,
86
+ startLocation3D: startLocal,
77
87
  globalLocation3D: globalPoint3D
78
88
  ))
79
-
80
89
  spatialScene.sendWebMsg(spatializedElement.id, gestureEvent)
81
90
  }
82
91
 
@@ -88,24 +97,40 @@ struct SpatializedElementView<Content: View>: View {
88
97
  spatialScene.sendWebMsg(spatializedElement.id, gestureEvent)
89
98
  }
90
99
 
91
- gestureFlags.isDrag = true
100
+ gestureState.isDrag = true
92
101
  }
93
102
 
94
103
  private func onDraggingEnded(_ event: DragGesture.Value) {
95
- gestureFlags.isDrag = false
104
+ gestureState.isDrag = false
96
105
  if spatializedElement.enableDragEndGesture {
97
106
  let gestureEvent = WebSpatialDragEndGuestureEvent()
98
107
  spatialScene.sendWebMsg(spatializedElement.id, gestureEvent)
99
108
  }
100
109
  }
101
110
 
111
+ /// Z offset that defines the local coordinate system (front face = z=0).
112
+ /// Only backOffset and zIndex*bias; excludes translation.z (CSS translateZ is visual-only).
113
+ private func localFrameOffsetZ() -> Double {
114
+ (spatializedElement.zIndex * zOrderBias) + spatializedElement.backOffset
115
+ }
116
+
117
+ /// Maps a point in the gesture's local coordinate system to SpatialScene.
118
+ private func localToScene(_ localPoint: Point3D) -> Point3D {
119
+ let p = SIMD4<Double>(localPoint.x, localPoint.y, localPoint.z, 1.0)
120
+ let scene = gestureState.proxyTransform.matrix * p
121
+ return Point3D(x: scene.x, y: scene.y, z: scene.z)
122
+ }
123
+
102
124
  private func onTapEnded(_ event: SpatialTapGesture.Value) {
103
125
  if spatializedElement.enableTapGesture {
104
- let localPoint = SIMD4<Double>(event.location3D.x, event.location3D.y, event.location3D.z, 1.0)
105
- let transformedPoint = transformHolder.transform.matrix * localPoint
106
- let globalPoint3D = Point3D(x: transformedPoint.x, y: transformedPoint.y, z: transformedPoint.z)
107
-
108
- spatialScene.sendWebMsg(spatializedElement.id, WebSpatialTapGuestureEvent(detail: .init(location3D: event.location3D, globalLocation3D: globalPoint3D)))
126
+ let frameZ = localFrameOffsetZ()
127
+ let localPoint3D = Point3D(
128
+ x: event.location3D.x,
129
+ y: event.location3D.y,
130
+ z: event.location3D.z - frameZ
131
+ )
132
+ let globalPoint3D = localToScene(event.location3D)
133
+ spatialScene.sendWebMsg(spatializedElement.id, WebSpatialTapGuestureEvent(detail: .init(location3D: localPoint3D, globalLocation3D: globalPoint3D)))
109
134
  }
110
135
  }
111
136
 
@@ -130,9 +155,6 @@ struct SpatializedElementView<Content: View>: View {
130
155
 
131
156
  var body: some View {
132
157
  let transform = spatializedElement.transform
133
- let translation = transform.translation
134
- let scale = transform.scale
135
- let rotation = transform.rotation!
136
158
 
137
159
  let width = spatializedElement.width
138
160
  let height = spatializedElement.height
@@ -146,8 +168,17 @@ struct SpatializedElementView<Content: View>: View {
146
168
  let visible = spatializedElement.visible
147
169
  let enableGesture = spatializedElement.enableGesture
148
170
 
149
- let z = translation.z + (spatializedElement.zIndex * zOrderBias)
150
- let smallOffset = z == 0.0 ? 0.0001 : 0
171
+ let frameOffsetZ = localFrameOffsetZ()
172
+ let smallOffset = abs(frameOffsetZ) < 0.0001 ? 0.0001 : 0
173
+
174
+ // Wrap CSS transform with anchor (CSS transform-origin) since
175
+ // transform3DEffect does not support anchor. Preserves the original
176
+ // CSS transform order (e.g. rotateX(90deg) translateZ(100px)).
177
+ let ax = width * anchor.x
178
+ let ay = height * anchor.y
179
+ let toAnchor = AffineTransform3D(translation: Vector3D(x: -ax, y: -ay, z: 0))
180
+ let fromAnchor = AffineTransform3D(translation: Vector3D(x: ax, y: ay, z: 0))
181
+ let anchoredTransform = fromAnchor.concatenating(transform).concatenating(toAnchor)
151
182
 
152
183
  // when spatialdiv have regular/thick/thin material and alignment is back, there'll be a bug that clipping content
153
184
  // so when spatializedElement is spatialdiv, .center alignment will be applied
@@ -156,34 +187,26 @@ struct SpatializedElementView<Content: View>: View {
156
187
  content
157
188
  .frame(width: width, height: height)
158
189
  .frame(depth: depth, alignment: alignment)
159
- .onGeometryChange3D(for: AffineTransform3D.self) { proxy in
160
- let rect3d = proxy.frame(in: .named("SpatialScene"))
161
- spatialScene.sendWebMsg(spatializedElement.id, SpatiaizedContainerClientCube(origin: rect3d.origin, size: rect3d.size))
162
- let transform = proxy.transform(in: .named("SpatialScene"))!
163
- transformHolder.transform = transform
164
- return transform
165
- } action: { _, new in
166
- spatialScene.sendWebMsg(spatializedElement.id, SpatiaizedContainerTransform(detail: new))
167
- }
168
190
  .frame(depth: 0, alignment: .back)
169
191
  // use .offset(smallVal) to workaround for glassEffect not working and small width/height spatialDiv not working
170
192
  .offset(z: smallOffset)
171
- .scaleEffect(
172
- x: scale.width,
173
- y: scale.height,
174
- z: scale.depth,
175
- anchor: anchor
176
- )
177
- .rotation3DEffect(
178
- rotation,
179
- anchor: anchor
180
- )
181
- .offset(x: translation.x, y: translation.y)
182
- .offset(z: z)
193
+ // Full CSS transform matrix with anchor baked in. Preserves transform
194
+ // composition order; CSS translateZ participates in rotation direction.
195
+ .transform3DEffect(anchoredTransform)
196
+ // backOffset + zIndex: always along parent Z, independent of CSS transform.
197
+ .offset(z: frameOffsetZ)
198
+ // Gesture before .position(): event.location3D is in the element's local space
199
+ // (top-left origin), and does not include visual transforms.
200
+ .simultaneousGesture(enableGesture ? gesture : nil)
201
+ .onGeometryChange3D(for: AffineTransform3D.self) { proxy in
202
+ proxy.transform(in: .named("SpatialScene"))!
203
+ } action: { new in
204
+ gestureState.proxyTransform = new
205
+ spatializedElement.proxySceneTransform = new
206
+ }
207
+
183
208
  .position(x: centerX + width / 2, y: centerY + height / 2)
184
- .offset(z: spatializedElement.backOffset)
185
209
  .opacity(opacity)
186
210
  .hidden(!visible)
187
- .simultaneousGesture(enableGesture ? gesture : nil)
188
211
  }
189
212
  }
@@ -28,7 +28,11 @@ struct SpatializedStatic3DView: View {
28
28
  let z = translation.z
29
29
 
30
30
  let enableGesture = spatializedElement.enableGesture
31
- if let url = URL(string: spatializedStatic3DElement.modelURL) {
31
+ let rawModelURL = spatializedStatic3DElement.modelURL
32
+ let modelURL = rawModelURL.hasPrefix("file://")
33
+ ? pwaManager.getLocalResourceURL(url: rawModelURL)
34
+ : rawModelURL
35
+ if let url = URL(string: modelURL) {
32
36
  Model3D(url: url) { newPhase in
33
37
  switch newPhase {
34
38
  case .empty:
@@ -113,7 +113,21 @@ class SpatialWebController: NSObject, WKNavigationDelegate, WKScriptMessageHandl
113
113
  print("urlSchemeTask")
114
114
  let url = urlSchemeTask.request.url
115
115
  if url!.absoluteString.starts(with: "file://") {
116
- let urlRequest = urlSchemeTask.request
116
+ let resource: String = pwaManager.getLocalResourceURL(url: url!.absoluteString)
117
+ var urlRequest = urlSchemeTask.request
118
+ if resource != "" {
119
+ if let resourceUrl = URL(string: resource) {
120
+ urlRequest = URLRequest(url: resourceUrl)
121
+ } else {
122
+ let error = NSError(domain: "LocalResourceError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Local file resource mapping failed"])
123
+ urlSchemeTask.didFailWithError(error)
124
+ return
125
+ }
126
+ } else {
127
+ let error = NSError(domain: "LocalResourceError", code: 404, userInfo: [NSLocalizedDescriptionKey: "Local resource not found"])
128
+ urlSchemeTask.didFailWithError(error)
129
+ return
130
+ }
117
131
 
118
132
  let session = URLSession(configuration: URLSessionConfiguration.default)
119
133
  let dataTask = session.dataTask(with: urlRequest) { [task = urlSchemeTask as AnyObject] data, response, _ in
@@ -155,22 +155,22 @@
155
155
  /* End PBXFrameworksBuildPhase section */
156
156
 
157
157
  /* Begin PBXGroup section */
158
- 2B2F1D5A2BEBFAAA006897EE = {
158
+ 2B1008A32F0B000800000003 /* web-spatialTests */ = {
159
159
  isa = PBXGroup;
160
160
  children = (
161
- 2B2F1D652BEBFAAA006897EE /* web-spatial */,
162
- 2B1008A32F0B000800000003 /* web-spatialTests */,
163
- 2B2F1D662BEBFAAA006897EE /* Packages */,
164
- 2B2F1D642BEBFAAA006897EE /* Products */,
161
+ 2B1008A12F0B000800000001 /* NavigationCleanupTests.swift */,
165
162
  );
163
+ path = "web-spatialTests";
166
164
  sourceTree = "<group>";
167
165
  };
168
- 2B1008A32F0B000800000003 /* web-spatialTests */ = {
166
+ 2B2F1D5A2BEBFAAA006897EE = {
169
167
  isa = PBXGroup;
170
168
  children = (
171
- 2B1008A12F0B000800000001 /* NavigationCleanupTests.swift */,
169
+ 2B2F1D652BEBFAAA006897EE /* web-spatial */,
170
+ 2B1008A32F0B000800000003 /* web-spatialTests */,
171
+ 2B2F1D662BEBFAAA006897EE /* Packages */,
172
+ 2B2F1D642BEBFAAA006897EE /* Products */,
172
173
  );
173
- path = "web-spatialTests";
174
174
  sourceTree = "<group>";
175
175
  };
176
176
  2B2F1D642BEBFAAA006897EE /* Products */ = {