@webspatial/platform-visionos 1.3.0 → 1.5.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.5.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?
@@ -138,6 +145,26 @@ protocol SpatialObjectCommand: CommandDataProtocol {
138
145
  var id: String { get }
139
146
  }
140
147
 
148
+ struct UpdateUnlitMaterialProperties: CommandDataProtocol {
149
+ static let commandType: String = "UpdateUnlitMaterialProperties"
150
+ let id: String
151
+ let color: String?
152
+ let transparent: Bool?
153
+ let opacity: Float?
154
+ }
155
+
156
+ struct RemoveComponentFromEntity: CommandDataProtocol {
157
+ static let commandType: String = "RemoveComponentFromEntity"
158
+ let entityId: String
159
+ let componentId: String
160
+ }
161
+
162
+ struct SetMaterialsOnEntity: CommandDataProtocol {
163
+ static let commandType: String = "SetMaterialsOnEntity"
164
+ let entityId: String
165
+ let materialIds: [String]
166
+ }
167
+
141
168
  struct DestroyCommand: CommandDataProtocol {
142
169
  static let commandType: String = "Destroy"
143
170
  var id: String
@@ -166,6 +193,7 @@ protocol SpatializedElementProperties: SpatialObjectCommand {
166
193
  var enableMagnifyGesture: Bool? { get }
167
194
  var enableMagnifyEndGesture: Bool? { get }
168
195
  var enableTapGesture: Bool? { get }
196
+ var rotateConstrainedToAxis: Vec3? { get }
169
197
  }
170
198
 
171
199
  struct UpdateSpatialized2DElementProperties: SpatializedElementProperties {
@@ -193,6 +221,8 @@ struct UpdateSpatialized2DElementProperties: SpatializedElementProperties {
193
221
  var enableMagnifyEndGesture: Bool?
194
222
  var enableTapGesture: Bool?
195
223
 
224
+ let rotateConstrainedToAxis: Vec3?
225
+
196
226
  let scrollPageEnabled: Bool?
197
227
  let material: BackgroundMaterial?
198
228
  let cornerRadius: CornerRadius?
@@ -227,6 +257,8 @@ struct UpdateSpatializedStatic3DElementProperties: SpatializedElementProperties
227
257
  let enableMagnifyEndGesture: Bool?
228
258
  let enableTapGesture: Bool?
229
259
 
260
+ let rotateConstrainedToAxis: Vec3?
261
+
230
262
  let modelURL: String?
231
263
  let modelTransform: [Double]?
232
264
  }
@@ -255,6 +287,8 @@ struct UpdateSpatializedDynamic3DElementProperties: SpatializedElementProperties
255
287
  let enableMagnifyGesture: Bool?
256
288
  let enableMagnifyEndGesture: Bool?
257
289
  let enableTapGesture: Bool?
290
+
291
+ let rotateConstrainedToAxis: Vec3?
258
292
  }
259
293
 
260
294
  struct UpdateSpatializedElementTransform: SpatialObjectCommand {
@@ -339,6 +373,15 @@ struct GetSpatialSceneStateCommand: CommandDataProtocol {
339
373
  static let commandType = "GetSpatialSceneState"
340
374
  }
341
375
 
376
+ struct InitializeAttachmentCommand: CommandDataProtocol {
377
+ static let commandType = "InitializeAttachment"
378
+ let id: String
379
+ let parentEntityId: String
380
+ let position: [Float]?
381
+ let size: AttachmentSize?
382
+ let ownerViewId: String
383
+ }
384
+
342
385
  struct UpdateAttachmentEntityCommand: CommandDataProtocol {
343
386
  static let commandType = "UpdateAttachmentEntity"
344
387
  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/"
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,12 @@ 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)
323
+
324
+ spatialWebViewModel.addJSBListener(UpdateUnlitMaterialProperties.self, onUpdateUnlitMaterialProperties)
325
+ spatialWebViewModel.addJSBListener(RemoveComponentFromEntity.self, onRemoveComponentFromEntity)
326
+ spatialWebViewModel.addJSBListener(SetMaterialsOnEntity.self, onSetMaterialsOnEntity)
303
327
 
304
328
  spatialWebViewModel.addJSBListener(UpdateAttachmentEntityCommand.self, onUpdateAttachmentEntity)
305
329
 
@@ -326,8 +350,8 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
326
350
 
327
351
  // write through
328
352
  spatialWebViewModel.updateWindowKV([
329
- "innerDepth": depth,
330
- "outerDepth": depth,
353
+ "xrInnerDepth": depth,
354
+ "xrOuterDepth": depth,
331
355
  "outerHeight": height + SpatialScene.navHeight,
332
356
  ])
333
357
  }
@@ -349,6 +373,17 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
349
373
  self.handleWindowClose()
350
374
  }
351
375
 
376
+ spatialWebViewModel.addStateListener(.didReceive) {
377
+ if let meterToPtUnscaled = self.meterToPtUnscaled,
378
+ let meterToPtScaled = self.meterToPtScaled
379
+ {
380
+ self.onUpdatePhysicalMetrics(
381
+ meterToPtUnscaled: meterToPtUnscaled,
382
+ meterToPtScaled: meterToPtScaled
383
+ )
384
+ }
385
+ }
386
+
352
387
  spatialWebViewModel.addStateListener(.didFailLoad) {
353
388
  self.didFailLoad = true
354
389
  }
@@ -394,46 +429,51 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
394
429
  }
395
430
  }
396
431
 
432
+ // Temporary storage for webview models awaiting JSB initialization
433
+ private var pendingAttachmentWebViewModels = [String: SpatialWebViewModel]()
434
+
397
435
  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
- }
436
+ // Just create a bare webview — metadata arrives via InitializeAttachment JSB
437
+ let id = UUID().uuidString
438
+ let webViewModel = SpatialWebViewModel(url: nil)
439
+ webViewModel.setBackgroundTransparent(true)
440
+ pendingAttachmentWebViewModels[id] = webViewModel
441
+ return WebViewElementInfo(id: id, element: webViewModel)
442
+ }
404
443
 
405
- guard let parentEntityId = queryItems.first(where: { $0.name == "parentEntityId" })?.value else {
406
- print("❌ missing parentEntityId for attachment")
407
- return nil
444
+ private func onInitializeAttachment(
445
+ command: InitializeAttachmentCommand,
446
+ resolve: @escaping JSBManager.ResolveHandler<Encodable>
447
+ ) {
448
+ guard let webViewModel = pendingAttachmentWebViewModels.removeValue(forKey: command.id) else {
449
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "No pending attachment for \(command.id)")))
450
+ return
408
451
  }
409
452
 
410
- // Parse position (JSON array like [0,0.1,0])
411
453
  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])
454
+ if let posArray = command.position, posArray.count >= 3 {
455
+ position = SIMD3<Float>(posArray[0], posArray[1], posArray[2])
418
456
  }
419
457
 
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
- }
458
+ let size = CGSize(
459
+ width: command.size?.width ?? 100,
460
+ height: command.size?.height ?? 100
461
+ )
428
462
 
429
- let info = attachmentManager.create(
430
- id: UUID().uuidString,
431
- parentEntityId: parentEntityId,
463
+ let ownerId = command.ownerViewId
464
+ if spatialObjects[ownerId] == nil {
465
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "ownerViewId must belong to the current scene for attachment \(command.id)")))
466
+ return
467
+ }
468
+ attachmentManager.create(
469
+ id: command.id,
470
+ parentEntityId: command.parentEntityId,
432
471
  position: position,
433
- size: size
472
+ size: size,
473
+ webViewModel: webViewModel
434
474
  )
435
475
 
436
- return WebViewElementInfo(id: info.id, element: info.webViewModel)
476
+ resolve(.success(baseReplyData))
437
477
  }
438
478
 
439
479
  private func onPageStartLoad() {
@@ -442,7 +482,6 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
442
482
  for spatialObject in spatialObjectArray {
443
483
  spatialObject.destroy()
444
484
  }
445
- // destroy all attachments
446
485
  attachmentManager.destroyAll()
447
486
  backgroundMaterial = .None
448
487
  }
@@ -715,6 +754,10 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
715
754
  spatializedElement.enableRotateEndGesture = enableRotateEndGesture
716
755
  }
717
756
 
757
+ if let rotateConstrainedToAxis = command.rotateConstrainedToAxis {
758
+ spatializedElement.rotateConstrainedToAxis = rotateConstrainedToAxis
759
+ }
760
+
718
761
  if let enableMagnifyGesture = command.enableMagnifyGesture {
719
762
  spatializedElement.enableMagnifyGesture = enableMagnifyGesture
720
763
  }
@@ -1068,26 +1111,141 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
1068
1111
  resolve(.success(ConvertReply(id: command.entityId, position: point)))
1069
1112
  }
1070
1113
 
1114
+ /// Input: command.position, command.fromId, command.toId
1115
+ /// fromId/toId can reference either the scene (window) or an entity.
1116
+ /// Step 1: Convert position to window coordinates (view global, px)
1117
+ /// - If from is window (scene), position is already in view global (px). Go to Step 2.
1118
+ /// - If from is 2d frame(SpatializedElement), position is in view local (px).
1119
+ /// - view local → window (view global, px) using SpatializedElement.convertToScene
1120
+ /// - If from is an entity, position is in reality entity local (meters):
1121
+ /// - entity local → reality world (scene)
1122
+ /// - reality world → window (view global, px)
1123
+ /// Step 2: Convert window coordinates (view global, px) to target output
1124
+ /// - If to is window, output directly.
1125
+ /// - If to is 2d frame(SpatializedElement), output in view local (px).
1126
+ /// - window (view global, px) → view local using SpatializedElement.convertFromScene
1127
+ /// - If to is an entity, output in reality entity local (meters):
1128
+ /// - window (view global, px) → reality world (scene)
1129
+ /// - reality world → reality entity local (meters)
1130
+
1131
+ private func onConvertCoordinate(command: ConvertCoordinate, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1132
+ func isSceneId(_ id: String) -> Bool {
1133
+ return id.isEmpty
1134
+ }
1135
+ let input = SIMD3<Float>(Float(command.position.x), Float(command.position.y), Float(command.position.z))
1136
+ let fromEntity = spatialObjects[command.fromId] as? SpatialEntity
1137
+ let from2dFrame = spatialObjects[command.fromId] as? SpatializedElement
1138
+ let toEntity = spatialObjects[command.toId] as? SpatialEntity
1139
+ let to2dFrame = spatialObjects[command.toId] as? SpatializedElement
1140
+
1141
+ var globalPx: Point3D
1142
+ if isSceneId(command.fromId) {
1143
+ globalPx = Point3D(x: Double(input.x), y: Double(input.y), z: Double(input.z))
1144
+ } else if let fromEntity {
1145
+ let world = fromEntity.convert(position: input, to: nil)
1146
+ guard let content = findSpatializedDynamic3DElement(containingEntityId: fromEntity.spatialId)?.getViewContent() else {
1147
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "RealityView content unavailable for conversion")))
1148
+ return
1149
+ }
1150
+ globalPx = content.convert(point: world, from: .scene, to: .global)
1151
+ } else if let from2dFrame {
1152
+ let localPoint = SIMD3<Double>(Double(input.x), Double(input.y), Double(input.z))
1153
+ let scenePoint = from2dFrame.convertToScene(localPoint)
1154
+ globalPx = Point3D(x: scenePoint.x, y: scenePoint.y, z: scenePoint.z)
1155
+ } else {
1156
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Invalid fromId")))
1157
+ return
1158
+ }
1159
+
1160
+ if isSceneId(command.toId) {
1161
+ let result = Vec3(x: CGFloat(globalPx.x), y: CGFloat(globalPx.y), z: CGFloat(globalPx.z))
1162
+ resolve(.success(result))
1163
+ return
1164
+ } else if let toEntity {
1165
+ guard let content = findSpatializedDynamic3DElement(containingEntityId: toEntity.spatialId)?.getViewContent() else {
1166
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "RealityView content unavailable for conversion")))
1167
+ return
1168
+ }
1169
+ let world = content.convert(globalPx, from: .global, to: .scene)
1170
+ let local = toEntity.convert(position: world, from: nil)
1171
+ let ret = Vec3(x: CGFloat(local.x), y: CGFloat(local.y), z: CGFloat(local.z))
1172
+ resolve(.success(ret))
1173
+ return
1174
+ } else if let to2dFrame {
1175
+ let scenePoint = SIMD3<Double>(globalPx.x, globalPx.y, globalPx.z)
1176
+ let localPoint = to2dFrame.convertFromScene(scenePoint)
1177
+ let ret = Vec3(x: CGFloat(localPoint.x), y: CGFloat(localPoint.y), z: CGFloat(localPoint.z))
1178
+ resolve(.success(ret))
1179
+ return
1180
+ } else {
1181
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Invalid toId")))
1182
+ return
1183
+ }
1184
+ }
1185
+
1071
1186
  private func onUpdateAttachmentEntity(command: UpdateAttachmentEntityCommand, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1072
1187
  guard attachmentManager.get(id: command.id) != nil else {
1073
1188
  resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Attachment \(command.id) not found")))
1074
1189
  return
1075
1190
  }
1076
-
1077
1191
  var newPosition: SIMD3<Float>? = nil
1078
1192
  if let posArray = command.position, posArray.count >= 3 {
1079
1193
  newPosition = SIMD3<Float>(posArray[0], posArray[1], posArray[2])
1080
1194
  }
1081
-
1082
1195
  var newSize: CGSize? = nil
1083
1196
  if let sizeObj = command.size {
1084
1197
  newSize = CGSize(width: sizeObj.width, height: sizeObj.height)
1085
1198
  }
1086
-
1087
1199
  attachmentManager.update(id: command.id, position: newPosition, size: newSize)
1088
1200
  resolve(.success(baseReplyData))
1089
1201
  }
1090
1202
 
1203
+ private func onUpdateUnlitMaterialProperties(command: UpdateUnlitMaterialProperties, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1204
+ guard let material = spatialObjects[command.id] as? SpatialUnlitMaterial else {
1205
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Material \(command.id) not found")))
1206
+ return
1207
+ }
1208
+ material.updateProperties(color: command.color, transparent: command.transparent, opacity: command.opacity)
1209
+ // Re-apply material to any ModelComponent or ModelEntity override that references it
1210
+ for (_, obj) in spatialObjects {
1211
+ if let comp = obj as? SpatialModelComponent, comp.usesMaterial(command.id) {
1212
+ comp.refreshMaterials()
1213
+ } else if let modelEntity = obj as? SpatialModelEntity, modelEntity.usesMaterial(command.id) {
1214
+ modelEntity.refreshMaterials()
1215
+ }
1216
+ }
1217
+ resolve(.success(baseReplyData))
1218
+ }
1219
+
1220
+ private func onRemoveComponentFromEntity(command: RemoveComponentFromEntity, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1221
+ guard let entity = spatialObjects[command.entityId] as? SpatialEntity,
1222
+ let component = spatialObjects[command.componentId] as? SpatialComponent
1223
+ else {
1224
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Remove component failed")))
1225
+ return
1226
+ }
1227
+ entity.removeComponent(component)
1228
+ resolve(.success(baseReplyData))
1229
+ }
1230
+
1231
+ private func onSetMaterialsOnEntity(command: SetMaterialsOnEntity, resolve: @escaping JSBManager.ResolveHandler<Encodable>) {
1232
+ guard let entity = spatialObjects[command.entityId] as? SpatialModelEntity else {
1233
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "ModelEntity \(command.entityId) not found")))
1234
+ return
1235
+ }
1236
+ var materials: [SpatialMaterial] = []
1237
+ for mid in command.materialIds {
1238
+ if let material = spatialObjects[mid] as? SpatialMaterial {
1239
+ materials.append(material)
1240
+ } else {
1241
+ resolve(.failure(JsbError(code: .InvalidSpatialObject, message: "Material \(mid) not found")))
1242
+ return
1243
+ }
1244
+ }
1245
+ entity.setMaterials(materials)
1246
+ resolve(.success(baseReplyData))
1247
+ }
1248
+
1091
1249
  private func addSpatialObject(_ object: any SpatialObjectProtocol) {
1092
1250
  var spatialObject = object
1093
1251
  spatialObjects[spatialObject.spatialId] = spatialObject
@@ -1111,6 +1269,25 @@ class SpatialScene: SpatialObject, ScrollAbleSpatialElementContainer, WebMsgSend
1111
1269
  sendWebMsg(spatialObject.spatialId, SpatialObjectDestroiedEvent())
1112
1270
  }
1113
1271
 
1272
+ /// Find the dynamic 3D container (SpatializedDynamic3DElement) that contains the entity by ID.
1273
+ /// - Parameter entityId: The entity's spatialId.
1274
+ /// - Returns: The container if the entity is a descendant of the container's root; otherwise nil.
1275
+ private func findSpatializedDynamic3DElement(containingEntityId entityId: String) -> SpatializedDynamic3DElement? {
1276
+ guard let entity = spatialObjects[entityId] as? SpatialEntity else {
1277
+ return nil
1278
+ }
1279
+
1280
+ var current: Entity? = entity
1281
+ while let node = current {
1282
+ if let rootEntity = node as? SpatialRootEntity {
1283
+ return rootEntity.root
1284
+ }
1285
+ current = node.parent
1286
+ }
1287
+
1288
+ return nil
1289
+ }
1290
+
1114
1291
  func findSpatialObject<T: SpatialObjectProtocol>(_ id: String) -> T? {
1115
1292
  return spatialObjects[id] as? T
1116
1293
  }
@@ -1,8 +1,16 @@
1
+ import _RealityKit_SwiftUI
1
2
  import Foundation
3
+ import RealityKit
2
4
 
3
5
  @Observable
4
6
  class SpatializedDynamic3DElement: SpatializedElement {
5
- private var rootEntity = SpatialEntity()
7
+ private var rootEntity = SpatialRootEntity()
8
+ private var viewContent: RealityViewContent? = nil
9
+
10
+ override init() {
11
+ super.init()
12
+ rootEntity.root = self
13
+ }
6
14
 
7
15
  func getRoot() -> SpatialEntity {
8
16
  return rootEntity
@@ -16,6 +24,14 @@ class SpatializedDynamic3DElement: SpatializedElement {
16
24
  rootEntity.removeChild(entity)
17
25
  }
18
26
 
27
+ func getViewContent() -> RealityViewContent? {
28
+ return viewContent
29
+ }
30
+
31
+ func setViewContent(_ content: RealityViewContent?) {
32
+ viewContent = content
33
+ }
34
+
19
35
  enum CodingKeys: String, CodingKey {
20
36
  case type, root
21
37
  }
@@ -28,6 +44,7 @@ class SpatializedDynamic3DElement: SpatializedElement {
28
44
  }
29
45
 
30
46
  override func onDestroy() {
47
+ viewContent = nil
31
48
  rootEntity.destroy()
32
49
  super.onDestroy()
33
50
  }
@@ -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
  }
@@ -43,7 +43,12 @@ class SpatialComponent: SpatialObject {
43
43
 
44
44
  @Observable
45
45
  class SpatialModelComponent: SpatialComponent {
46
+ private(set) var spatialMaterials: [SpatialMaterial] = []
47
+ private(set) var mesh: Geometry?
48
+
46
49
  init(mesh: Geometry, mats: [SpatialMaterial]) {
50
+ spatialMaterials = mats
51
+ self.mesh = mesh
47
52
  super.init(.ModelComponent)
48
53
  var materials: [any RealityKit.Material] = []
49
54
  for item in mats {
@@ -52,6 +57,27 @@ class SpatialModelComponent: SpatialComponent {
52
57
  _resource = ModelComponent(mesh: mesh.resource!, materials: materials)
53
58
  }
54
59
 
60
+ /// Rebuild the ModelComponent with current material resources (called after material properties change)
61
+ func refreshMaterials() {
62
+ guard let mesh = mesh else { return }
63
+ var materials: [any RealityKit.Material] = []
64
+ for item in spatialMaterials {
65
+ if let res = item.resource {
66
+ materials.append(res)
67
+ }
68
+ }
69
+ _resource = ModelComponent(mesh: mesh.resource!, materials: materials)
70
+ if let entity = _entity {
71
+ entity.components.set(_resource!)
72
+ entity.generateCollisionShapes(recursive: true)
73
+ }
74
+ }
75
+
76
+ /// Check if this component uses the given material
77
+ func usesMaterial(_ materialId: String) -> Bool {
78
+ return spatialMaterials.contains { $0.id == materialId }
79
+ }
80
+
55
81
  override func addToEntity(entity: SpatialEntity) {
56
82
  super.addToEntity(entity: entity)
57
83
  entity.generateCollisionShapes(recursive: true)
@@ -63,7 +89,12 @@ class SpatialModelComponent: SpatialComponent {
63
89
  }
64
90
 
65
91
  override func onDestroy() {
92
+ // TODO(P2): `mesh` is a registered `Geometry` spatial object; clearing the reference does not
93
+ // run `Geometry.destroy()`, so dynamic mesh rebuilds can leak mesh/registry entries until the
94
+ // scene ends. Call `mesh?.destroy()` (or equivalent) before nil-ing when ownership is exclusive.
66
95
  _resource = nil
96
+ spatialMaterials = []
97
+ mesh = nil
67
98
  }
68
99
  }
69
100
 
@@ -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
 
@@ -22,16 +22,38 @@ class SpatialMaterial: SpatialObject {
22
22
 
23
23
  @Observable
24
24
  class SpatialUnlitMaterial: SpatialMaterial {
25
- let color: UIColor
25
+ private(set) var currentColor: UIColor
26
+ private(set) var currentTexture: TextureResource?
27
+ private(set) var currentTransparent: Bool
28
+ private(set) var currentOpacity: Float
26
29
 
27
30
  init(_ color: String, _ texture: TextureResource? = nil, _ transparent: Bool = true, _ opacity: Float = 1) {
28
- self.color = UIColor(Color(hex: color))
31
+ currentColor = UIColor(Color(hex: color))
32
+ currentTexture = texture
33
+ currentTransparent = transparent
34
+ currentOpacity = opacity
29
35
  super.init(.UnlitMaterial)
30
36
  var mat = UnlitMaterial()
31
- mat.color = .init(tint: UIColor(Color(hex: color)), texture: texture != nil ? .init(texture!) : nil)
37
+ mat.color = .init(tint: currentColor, texture: texture != nil ? .init(texture!) : nil)
32
38
  mat.blending = transparent ? .transparent(opacity: .init(scale: opacity)) : .opaque
33
39
  _resource = mat
34
40
  }
41
+
42
+ func updateProperties(color: String?, transparent: Bool?, opacity: Float?) {
43
+ if let color = color {
44
+ currentColor = UIColor(Color(hex: color))
45
+ }
46
+ if let transparent = transparent {
47
+ currentTransparent = transparent
48
+ }
49
+ if let opacity = opacity {
50
+ currentOpacity = opacity
51
+ }
52
+ var mat = UnlitMaterial()
53
+ mat.color = .init(tint: currentColor, texture: currentTexture != nil ? .init(currentTexture!) : nil)
54
+ mat.blending = currentTransparent ? .transparent(opacity: .init(scale: currentOpacity)) : .opaque
55
+ _resource = mat
56
+ }
35
57
  }
36
58
 
37
59
  enum SpatialMaterialType: String {
@@ -4,6 +4,9 @@ import SwiftUI
4
4
  @Observable
5
5
  class SpatialModelEntity: SpatialEntity {
6
6
  private var modelEntity: Entity?
7
+ /// Retained so `UpdateUnlitMaterialProperties` can re-apply current `SpatialMaterial.resource` after native material updates.
8
+ private(set) var overrideSpatialMaterials: [SpatialMaterial] = []
9
+
7
10
  required init(_ modelResource: SpatialModelResource, _ _name: String = "") {
8
11
  super.init(_name)
9
12
  modelEntity = modelResource.resource
@@ -15,12 +18,44 @@ class SpatialModelEntity: SpatialEntity {
15
18
  super.init()
16
19
  }
17
20
 
21
+ func setMaterials(_ materials: [SpatialMaterial]) {
22
+ overrideSpatialMaterials = materials
23
+ applyOverrideMaterials()
24
+ }
25
+
26
+ /// Re-apply stored override materials using each `SpatialMaterial`'s current `resource` (e.g. after unlit property updates).
27
+ func refreshMaterials() {
28
+ applyOverrideMaterials()
29
+ }
30
+
31
+ func usesMaterial(_ materialId: String) -> Bool {
32
+ overrideSpatialMaterials.contains { $0.id == materialId }
33
+ }
34
+
35
+ private func applyOverrideMaterials() {
36
+ guard let modelEntity = modelEntity else { return }
37
+ // TODO(P1): Clearing overrides (`setMaterials([])`) assigns an empty material list here; there is
38
+ // no baseline of the model asset’s authored materials to restore. Persist per-component defaults
39
+ // at load (or skip writing when overrides are empty) so clears return to the authored look.
40
+ func applyMaterials(to entity: Entity) {
41
+ if var modelComp = entity.components[ModelComponent.self] {
42
+ modelComp.materials = overrideSpatialMaterials.compactMap { $0.resource }
43
+ entity.components.set(modelComp)
44
+ }
45
+ for child in entity.children {
46
+ applyMaterials(to: child)
47
+ }
48
+ }
49
+ applyMaterials(to: modelEntity)
50
+ }
51
+
18
52
  override func onDestroy() {
19
53
  super.onDestroy()
20
54
  if let modelEntity = modelEntity {
21
55
  removeChild(modelEntity)
22
56
  }
23
57
  modelEntity = nil
58
+ overrideSpatialMaterials = []
24
59
  }
25
60
 
26
61
  enum CodingKeys: String, CodingKey {
@@ -0,0 +1,12 @@
1
+ import RealityKit
2
+ import SwiftUI
3
+
4
+ @Observable
5
+ class SpatialRootEntity: SpatialEntity {
6
+ weak var root: SpatializedDynamic3DElement?
7
+
8
+ convenience init(root: SpatializedDynamic3DElement) {
9
+ self.init()
10
+ self.root = root
11
+ }
12
+ }
@@ -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
@@ -70,6 +70,7 @@
70
70
  dynamic3d/SpatialMaterial.swift,
71
71
  dynamic3d/SpatialModelEntity.swift,
72
72
  dynamic3d/SpatialModelResource.swift,
73
+ dynamic3d/SpatialRootEntity.swift,
73
74
  dynamic3d/SpatialTextureResource.swift,
74
75
  SpatialApp.swift,
75
76
  Spatialized2DElement.swift,
@@ -155,22 +156,22 @@
155
156
  /* End PBXFrameworksBuildPhase section */
156
157
 
157
158
  /* Begin PBXGroup section */
158
- 2B2F1D5A2BEBFAAA006897EE = {
159
+ 2B1008A32F0B000800000003 /* web-spatialTests */ = {
159
160
  isa = PBXGroup;
160
161
  children = (
161
- 2B2F1D652BEBFAAA006897EE /* web-spatial */,
162
- 2B1008A32F0B000800000003 /* web-spatialTests */,
163
- 2B2F1D662BEBFAAA006897EE /* Packages */,
164
- 2B2F1D642BEBFAAA006897EE /* Products */,
162
+ 2B1008A12F0B000800000001 /* NavigationCleanupTests.swift */,
165
163
  );
164
+ path = "web-spatialTests";
166
165
  sourceTree = "<group>";
167
166
  };
168
- 2B1008A32F0B000800000003 /* web-spatialTests */ = {
167
+ 2B2F1D5A2BEBFAAA006897EE = {
169
168
  isa = PBXGroup;
170
169
  children = (
171
- 2B1008A12F0B000800000001 /* NavigationCleanupTests.swift */,
170
+ 2B2F1D652BEBFAAA006897EE /* web-spatial */,
171
+ 2B1008A32F0B000800000003 /* web-spatialTests */,
172
+ 2B2F1D662BEBFAAA006897EE /* Packages */,
173
+ 2B2F1D642BEBFAAA006897EE /* Products */,
172
174
  );
173
- path = "web-spatialTests";
174
175
  sourceTree = "<group>";
175
176
  };
176
177
  2B2F1D642BEBFAAA006897EE /* Products */ = {