@webspatial/platform-visionos 1.2.1 → 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.
Files changed (34) hide show
  1. package/package.json +2 -1
  2. package/web-spatial/EventEmitter.swift +11 -11
  3. package/web-spatial/JSBCommand.swift +38 -3
  4. package/web-spatial/WebMsgCommand.swift +5 -16
  5. package/web-spatial/WebSpatialApp.swift +10 -10
  6. package/web-spatial/Window.swift +2 -2
  7. package/web-spatial/manager/AttachmentManager.swift +84 -0
  8. package/web-spatial/manager/Dynamic3DManager.swift +10 -0
  9. package/web-spatial/manager/JSBManager.swift +1 -2
  10. package/web-spatial/manager/WKWebViewManager.swift +4 -4
  11. package/web-spatial/manifest.swift +11 -6
  12. package/web-spatial/model/SpatialApp.swift +60 -56
  13. package/web-spatial/model/SpatialScene.swift +233 -16
  14. package/web-spatial/model/Spatialized2DElement.swift +4 -5
  15. package/web-spatial/model/SpatializedDynamic3DElement.swift +12 -0
  16. package/web-spatial/model/SpatializedElement.swift +40 -0
  17. package/web-spatial/model/SpatializedStatic3DElement.swift +1 -1
  18. package/web-spatial/model/dynamic3d/SpatialComponent.swift +27 -27
  19. package/web-spatial/model/dynamic3d/SpatialEntity.swift +8 -2
  20. package/web-spatial/model/dynamic3d/SpatialMaterial.swift +15 -15
  21. package/web-spatial/model/dynamic3d/SpatialModelEntity.swift +10 -10
  22. package/web-spatial/model/dynamic3d/SpatialModelResource.swift +1 -1
  23. package/web-spatial/model/dynamic3d/SpatialTextureResource.swift +8 -8
  24. package/web-spatial/view/SceneHandlerUIView.swift +29 -1
  25. package/web-spatial/view/SpatialNavView.swift +52 -47
  26. package/web-spatial/view/SpatializedDynamic3DView.swift +88 -5
  27. package/web-spatial/view/SpatializedElementView.swift +85 -47
  28. package/web-spatial/view/SpatializedStatic3DView.swift +9 -7
  29. package/web-spatial/view/view-modifier/HideViewModifier.swift +2 -2
  30. package/web-spatial/webview/SpatialWebController.swift +42 -25
  31. package/web-spatial/webview/SpatialWebView.swift +5 -1
  32. package/web-spatial/webview/SpatialWebViewModel.swift +13 -7
  33. package/web-spatial.xcodeproj/project.pbxproj +13 -0
  34. package/web-spatialTests/NavigationCleanupTests.swift +33 -0
@@ -1,10 +1,9 @@
1
+ import CoreGraphics
1
2
  import SwiftUI
2
3
 
3
- // zIndex() have some bug, so use zOrderBias to simulate zIndex effect
4
- let zOrderBias = 0.001
5
-
6
- final class GestureFlags {
4
+ final class GestureState {
7
5
  var isDrag = false
6
+ var proxyTransform: AffineTransform3D = .identity
8
7
  }
9
8
 
10
9
  struct SpatializedElementView<Content: View>: View {
@@ -14,32 +13,44 @@ struct SpatializedElementView<Content: View>: View {
14
13
  var parentScrollOffset: Vec2
15
14
  var content: Content
16
15
 
17
- @State private var gestureFlags = GestureFlags()
16
+ @State private var gestureState = GestureState()
18
17
 
19
18
  init(parentScrollOffset: Vec2, @ViewBuilder content: () -> Content) {
20
19
  self.parentScrollOffset = parentScrollOffset
21
20
  self.content = content()
22
21
  }
23
22
 
24
- // Begin Interaction
23
+ /// Begin Interaction
25
24
  var gesture: some Gesture {
26
25
  DragGesture(minimumDistance: 10)
27
26
  .onChanged(onDragging)
28
27
  .onEnded(onDraggingEnded)
29
28
  .simultaneously(with:
30
- RotateGesture3D()
29
+ makeRotateGesture3D()
31
30
  .onChanged(onRotateGesture3D)
32
- .onEnded(onRotateGesture3DEnd)
33
- )
31
+ .onEnded(onRotateGesture3DEnd))
34
32
  .simultaneously(with:
35
33
  MagnifyGesture()
36
34
  .onChanged(onMagnifyGesture)
37
- .onEnded(onMagnifyGestureEnd)
38
- )
35
+ .onEnded(onMagnifyGestureEnd))
39
36
  .simultaneously(with:
40
37
  SpatialTapGesture(count: 1)
41
- .onEnded(onTapEnded)
42
- )
38
+ .onEnded(onTapEnded))
39
+ }
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)
43
54
  }
44
55
 
45
56
  private func onRotateGesture3D(_ event: RotateGesture3D.Value) {
@@ -63,11 +74,18 @@ struct SpatializedElementView<Content: View>: View {
63
74
  }
64
75
 
65
76
  private func onDragging(_ event: DragGesture.Value) {
66
- if spatializedElement.enableDragStartGesture, !gestureFlags.isDrag {
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)
67
85
  let gestureEvent = WebSpatialDragStartGuestureEvent(detail: .init(
68
- startLocation3D: event.startLocation3D
86
+ startLocation3D: startLocal,
87
+ globalLocation3D: globalPoint3D
69
88
  ))
70
-
71
89
  spatialScene.sendWebMsg(spatializedElement.id, gestureEvent)
72
90
  }
73
91
 
@@ -79,20 +97,40 @@ struct SpatializedElementView<Content: View>: View {
79
97
  spatialScene.sendWebMsg(spatializedElement.id, gestureEvent)
80
98
  }
81
99
 
82
- gestureFlags.isDrag = true
100
+ gestureState.isDrag = true
83
101
  }
84
102
 
85
103
  private func onDraggingEnded(_ event: DragGesture.Value) {
86
- gestureFlags.isDrag = false
104
+ gestureState.isDrag = false
87
105
  if spatializedElement.enableDragEndGesture {
88
106
  let gestureEvent = WebSpatialDragEndGuestureEvent()
89
107
  spatialScene.sendWebMsg(spatializedElement.id, gestureEvent)
90
108
  }
91
109
  }
92
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
+
93
124
  private func onTapEnded(_ event: SpatialTapGesture.Value) {
94
125
  if spatializedElement.enableTapGesture {
95
- spatialScene.sendWebMsg(spatializedElement.id, WebSpatialTapGuestureEvent(detail: .init(location3D: event.location3D)))
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)))
96
134
  }
97
135
  }
98
136
 
@@ -101,7 +139,8 @@ struct SpatializedElementView<Content: View>: View {
101
139
  let gestureEvent = WebSpatialMagnifyGuestureEvent(
102
140
  detail: .init(
103
141
  magnification: event.magnification
104
- ))
142
+ )
143
+ )
105
144
  spatialScene.sendWebMsg(spatializedElement.id, gestureEvent)
106
145
  }
107
146
  }
@@ -114,12 +153,8 @@ struct SpatializedElementView<Content: View>: View {
114
153
 
115
154
  // End Interaction
116
155
 
117
- @ViewBuilder
118
156
  var body: some View {
119
157
  let transform = spatializedElement.transform
120
- let translation = transform.translation
121
- let scale = transform.scale
122
- let rotation = transform.rotation!
123
158
 
124
159
  let width = spatializedElement.width
125
160
  let height = spatializedElement.height
@@ -133,8 +168,17 @@ struct SpatializedElementView<Content: View>: View {
133
168
  let visible = spatializedElement.visible
134
169
  let enableGesture = spatializedElement.enableGesture
135
170
 
136
- let z = translation.z + (spatializedElement.zIndex * zOrderBias)
137
- 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)
138
182
 
139
183
  // when spatialdiv have regular/thick/thin material and alignment is back, there'll be a bug that clipping content
140
184
  // so when spatializedElement is spatialdiv, .center alignment will be applied
@@ -143,32 +187,26 @@ struct SpatializedElementView<Content: View>: View {
143
187
  content
144
188
  .frame(width: width, height: height)
145
189
  .frame(depth: depth, alignment: alignment)
146
- .onGeometryChange3D(for: AffineTransform3D.self) { proxy in
147
- let rect3d = proxy.frame(in: .named("SpatialScene"))
148
- spatialScene.sendWebMsg(spatializedElement.id, SpatiaizedContainerClientCube(origin: rect3d.origin, size: rect3d.size))
149
- return proxy.transform(in: .named("SpatialScene"))!
150
- } action: { _, new in
151
- spatialScene.sendWebMsg(spatializedElement.id, SpatiaizedContainerTransform(detail: new))
152
- }
153
190
  .frame(depth: 0, alignment: .back)
154
191
  // use .offset(smallVal) to workaround for glassEffect not working and small width/height spatialDiv not working
155
192
  .offset(z: smallOffset)
156
- .scaleEffect(
157
- x: scale.width,
158
- y: scale.height,
159
- z: scale.depth,
160
- anchor: anchor
161
- )
162
- .rotation3DEffect(
163
- rotation,
164
- anchor: anchor
165
- )
166
- .offset(x: translation.x, y: translation.y)
167
- .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
+
168
208
  .position(x: centerX + width / 2, y: centerY + height / 2)
169
- .offset(z: spatializedElement.backOffset)
170
209
  .opacity(opacity)
171
210
  .hidden(!visible)
172
- .simultaneousGesture(enableGesture ? gesture : nil)
173
211
  }
174
212
  }
@@ -8,7 +8,7 @@ struct SpatializedStatic3DView: View {
8
8
  private var spatializedStatic3DElement: SpatializedStatic3DElement {
9
9
  return spatializedElement as! SpatializedStatic3DElement
10
10
  }
11
-
11
+
12
12
  func onLoadSuccess() {
13
13
  spatialScene.sendWebMsg(spatializedElement.id, ModelLoadSuccess())
14
14
  }
@@ -17,7 +17,6 @@ struct SpatializedStatic3DView: View {
17
17
  spatialScene.sendWebMsg(spatializedElement.id, ModelLoadFailure())
18
18
  }
19
19
 
20
- @ViewBuilder
21
20
  var body: some View {
22
21
  let depth = spatializedElement.depth
23
22
  let transform = spatializedStatic3DElement.modelTransform
@@ -27,14 +26,17 @@ struct SpatializedStatic3DView: View {
27
26
  let x = translation.x
28
27
  let y = translation.y
29
28
  let z = translation.z
30
-
29
+
31
30
  let enableGesture = spatializedElement.enableGesture
32
- 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) {
33
36
  Model3D(url: url) { newPhase in
34
37
  switch newPhase {
35
38
  case .empty:
36
39
  ProgressView()
37
-
38
40
  case let .success(resolvedModel3D):
39
41
  resolvedModel3D
40
42
  .resizable(true)
@@ -42,11 +44,11 @@ struct SpatializedStatic3DView: View {
42
44
  nil,
43
45
  contentMode: .fit
44
46
  )
45
- .if(!depth.isZero){ view in view.scaledToFit3D()}
47
+ .if(!depth.isZero) { view in view.scaledToFit3D() }
46
48
  .onAppear {
47
49
  self.onLoadSuccess()
48
50
  }
49
- .if(enableGesture) { view in view.hoverEffect()}
51
+ .if(enableGesture) { view in view.hoverEffect() }
50
52
  case .failure:
51
53
  Text("").onAppear {
52
54
  self.onLoadFailure()
@@ -2,14 +2,14 @@ import SwiftUI
2
2
 
3
3
  struct HideViewModifier: ViewModifier {
4
4
  let isHidden: Bool
5
- @ViewBuilder func body(content: Content) -> some View {
5
+ func body(content: Content) -> some View {
6
6
  content
7
7
  .opacity(isHidden ? 0 : 1)
8
8
  .disabled(isHidden)
9
9
  }
10
10
  }
11
11
 
12
- // Extending on View to apply to all Views
12
+ /// Extending on View to apply to all Views
13
13
  extension View {
14
14
  func hidden(_ isHidden: Bool) -> some View {
15
15
  modifier(HideViewModifier(isHidden: isHidden))
@@ -9,7 +9,7 @@ class SpatialWebController: NSObject, WKNavigationDelegate, WKScriptMessageHandl
9
9
  private var openWindowInvoke: ((_ data: URL) -> WebViewElementInfo?)?
10
10
  private var webviewStateChangeInvoke: ((_ type: SpatialWebViewState) -> Void)?
11
11
  private var scorllUpdateInvoke: ((_ type: ScrollState, _ point: CGPoint) -> Void)?
12
- private var webviewTitle: String? = nil
12
+ private var webviewTitle: String?
13
13
  private var firstLoad = true
14
14
  private var jsbManager = JSBManager()
15
15
 
@@ -62,25 +62,29 @@ class SpatialWebController: NSObject, WKNavigationDelegate, WKScriptMessageHandl
62
62
  }
63
63
  }
64
64
 
65
- // navigation request
66
- // SpatialDiv/forcestyle/normal web link protocol
65
+ /// navigation request
66
+ /// SpatialDiv/forcestyle/normal web link protocol
67
67
  func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Swift.Void) {
68
68
  let deciside = navigationInvoke?(navigationAction.request.url!)
69
69
  if deciside == true {
70
- if !firstLoad{
70
+ if !firstLoad {
71
71
  webviewStateChangeInvoke?(.didUnload)
72
72
  }
73
73
  firstLoad = false
74
74
  }
75
75
  var needAllow = deciside ?? false
76
-
77
- if !needAllow{
78
- UIApplication.shared.open(navigationAction.request.url!, options: [:], completionHandler: nil)
76
+
77
+ if !needAllow {
78
+ // webspatial:// is an internal scheme for in-app routing between main and attachment windows.
79
+ // Only open non-webspatial URLs externally via the system.
80
+ if navigationAction.request.url?.scheme != "webspatial" {
81
+ UIApplication.shared.open(navigationAction.request.url!, options: [:], completionHandler: nil)
82
+ }
79
83
  }
80
84
  decisionHandler(needAllow ? .allow : .cancel)
81
85
  }
82
86
 
83
- // open window request
87
+ /// open window request
84
88
  func webView(
85
89
  _ webView: WKWebView,
86
90
  createWebViewWith configuration: WKWebViewConfiguration,
@@ -95,7 +99,7 @@ class SpatialWebController: NSObject, WKNavigationDelegate, WKScriptMessageHandl
95
99
  return nil
96
100
  }
97
101
 
98
- // invoke jsb
102
+ /// invoke jsb
99
103
  func userContentController(
100
104
  _ userContentController: WKUserContentController,
101
105
  didReceive message: WKScriptMessage, replyHandler: @escaping (Any?, String?) -> Void
@@ -104,12 +108,26 @@ class SpatialWebController: NSObject, WKNavigationDelegate, WKScriptMessageHandl
104
108
  jsbManager.handlerMessage(message.body as! String, replyHandler)
105
109
  }
106
110
 
107
- // custom scheme request
111
+ /// custom scheme request
108
112
  func webView(_ webView: WKWebView, start urlSchemeTask: any WKURLSchemeTask) {
109
113
  print("urlSchemeTask")
110
114
  let url = urlSchemeTask.request.url
111
115
  if url!.absoluteString.starts(with: "file://") {
112
- 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
+ }
113
131
 
114
132
  let session = URLSession(configuration: URLSessionConfiguration.default)
115
133
  let dataTask = session.dataTask(with: urlRequest) { [task = urlSchemeTask as AnyObject] data, response, _ in
@@ -221,8 +239,8 @@ class SpatialWebController: NSObject, WKNavigationDelegate, WKScriptMessageHandl
221
239
  scorllUpdateInvoke = nil
222
240
  model = nil
223
241
  }
224
-
225
- private var state:SpatialWebViewState?
242
+
243
+ private var state: SpatialWebViewState?
226
244
 
227
245
  func destroyView() {
228
246
  stopObserving()
@@ -236,15 +254,15 @@ class SpatialWebController: NSObject, WKNavigationDelegate, WKScriptMessageHandl
236
254
  webviewStateChangeInvoke?(.didDestroyView)
237
255
  }
238
256
  }
239
-
257
+
240
258
  private var isPageLoaded = false
241
-
259
+
242
260
  private var jsQueue: [String] = []
243
-
261
+
244
262
  private func enqueueJS(_ js: String) {
245
263
  jsQueue.append(js)
246
264
  }
247
-
265
+
248
266
  private func flushJSQueue() {
249
267
  guard !jsQueue.isEmpty else { return }
250
268
  let combined = jsQueue.joined(separator: ";")
@@ -253,7 +271,7 @@ class SpatialWebController: NSObject, WKNavigationDelegate, WKScriptMessageHandl
253
271
  }
254
272
 
255
273
  func callJS(_ js: String) {
256
- if webview != nil && isPageLoaded {
274
+ if webview != nil, isPageLoaded {
257
275
  webview!.evaluateJavaScript(js)
258
276
  } else {
259
277
  enqueueJS(js)
@@ -268,13 +286,13 @@ enum ScrollState {
268
286
  case end
269
287
  }
270
288
 
271
- // extend webview to support file://
272
- @available(iOS 11.0, *)
273
- extension WKWebView {
289
+ /// extend webview to support file://
290
+ @available(iOS 11.0, *)
291
+ extension WKWebView {
274
292
  /// WKWebView, Support setting file scheme in configuration
275
293
  public private(set) static var isEnableFileSupport = false
276
294
  public static func enableFileScheme() {
277
- /// This method supports adapting supported files through Configuration, but cannot be cancelled (Configuration is immutable).
295
+ // This method supports adapting supported files through Configuration, but cannot be cancelled (Configuration is immutable).
278
296
  if !isEnableFileSupport {
279
297
  switchHandlesURLScheme()
280
298
  }
@@ -292,9 +310,8 @@ enum ScrollState {
292
310
  }
293
311
 
294
312
  /// Return true if WKWebview supports handling this protocol, but WKWebview supports HTTP by default, so return false to support using custom HTTP Handler
295
- @objc private dynamic
296
- static func wrapHandles(urlScheme: String) -> Bool {
313
+ @objc private dynamic static func wrapHandles(urlScheme: String) -> Bool {
297
314
  if urlScheme == "file" { return false }
298
315
  return wrapHandles(urlScheme: urlScheme)
299
316
  }
300
- }
317
+ }
@@ -8,7 +8,11 @@ struct SpatialWebView: UIViewRepresentable {
8
8
 
9
9
  func makeUIView(context: Context) -> WKWebView {
10
10
  webviewStateChangeInvoke?(.didMakeView)
11
- return model!.getController().webview!
11
+ let controller = model?.getController()
12
+ if controller?.webview == nil {
13
+ model?.load()
14
+ }
15
+ return controller?.webview ?? WKWebView(frame: .zero, configuration: WKWebViewConfiguration())
12
16
  }
13
17
 
14
18
  func makeCoordinator() -> SpatialWebController {
@@ -76,6 +76,12 @@ class SpatialWebViewModel {
76
76
  }
77
77
 
78
78
  func getController() -> SpatialWebController {
79
+ // See TODO in AttachmentManager — async destroy() during SwiftUI teardown
80
+ // can nil the controller while the view is still being removed.
81
+ if controller == nil {
82
+ controller = SpatialWebController()
83
+ controller!.model = self
84
+ }
79
85
  return controller!
80
86
  }
81
87
 
@@ -103,8 +109,8 @@ class SpatialWebViewModel {
103
109
  controller?.setWebViewTitle(title)
104
110
  }
105
111
 
106
- // events
107
- // navigation event
112
+ /// events
113
+ /// navigation event
108
114
  func addNavigationListener(protocal: String, event: @escaping (_ data: URL) -> Bool) {
109
115
  navigationList[protocal] = event
110
116
  }
@@ -113,7 +119,7 @@ class SpatialWebViewModel {
113
119
  navigationList = [:]
114
120
  }
115
121
 
116
- // open window event
122
+ /// open window event
117
123
  func addOpenWindowListener(protocal: String, _ event: @escaping (_ data: URL) -> WebViewElementInfo?) {
118
124
  openWindowList[protocal] = event
119
125
  }
@@ -122,7 +128,7 @@ class SpatialWebViewModel {
122
128
  openWindowList = [:]
123
129
  }
124
130
 
125
- // jsb event
131
+ /// jsb event
126
132
  func addJSBListener<T: CommandDataProtocol>(_ dataClass: T.Type, _ event: @escaping (T, @escaping JSBManager.ResolveHandler<Encodable>) -> Void) {
127
133
  controller?.registeJSBHandler(dataClass, event)
128
134
  }
@@ -143,7 +149,7 @@ class SpatialWebViewModel {
143
149
  controller?.mockJSB(command)
144
150
  }
145
151
 
146
- // webview state event
152
+ /// webview state event
147
153
  func addStateListener(_ event: @escaping (_ type: SpatialWebViewState) -> Void) {
148
154
  stateChangeListeners.append(event)
149
155
  }
@@ -176,7 +182,7 @@ class SpatialWebViewModel {
176
182
  stateListeners[state] = nil
177
183
  }
178
184
 
179
- // scroll update event
185
+ /// scroll update event
180
186
  func addScrollUpdateListener(_ event: @escaping (_ type: ScrollState, _ point: CGPoint) -> Void) {
181
187
  scrollUpdateListeners.append(event)
182
188
  }
@@ -199,7 +205,7 @@ class SpatialWebViewModel {
199
205
  removeAllScrollUpdateListener()
200
206
  }
201
207
 
202
- // invokes
208
+ /// invokes
203
209
  private func onNavigationInvoke(_ url: URL) -> Bool {
204
210
  var matchProtocol = false
205
211
  for key in navigationList.keys {
@@ -13,6 +13,7 @@
13
13
  2B06AEE42E4C1AE8000327E9 /* manifest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B06AEC12E4C1AE8000327E9 /* manifest.swift */; };
14
14
  2B06AEE62E4C1AE8000327E9 /* JSBCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B06AEC02E4C1AE8000327E9 /* JSBCommand.swift */; };
15
15
  2B06AEE92E4C1AE8000327E9 /* EventEmitter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B06AEBF2E4C1AE8000327E9 /* EventEmitter.swift */; };
16
+ 2B1008A22F0B000800000002 /* NavigationCleanupTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2B1008A12F0B000800000001 /* NavigationCleanupTests.swift */; };
16
17
  2B2F1D692BEBFAAA006897EE /* RealityKitContent in Frameworks */ = {isa = PBXBuildFile; productRef = 2B2F1D682BEBFAAA006897EE /* RealityKitContent */; };
17
18
  2B2F1D712BEBFAAC006897EE /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2B2F1D702BEBFAAC006897EE /* Assets.xcassets */; };
18
19
  2B2F1D742BEBFAAC006897EE /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2B2F1D732BEBFAAC006897EE /* Preview Assets.xcassets */; };
@@ -37,6 +38,7 @@
37
38
  2B06AEC22E4C1AE8000327E9 /* SpatialObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpatialObject.swift; sourceTree = "<group>"; };
38
39
  2B06AEC32E4C1AE8000327E9 /* WebSpatialApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebSpatialApp.swift; sourceTree = "<group>"; };
39
40
  2B06AEC42E4C1AE8000327E9 /* Window.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Window.swift; sourceTree = "<group>"; };
41
+ 2B1008A12F0B000800000001 /* NavigationCleanupTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationCleanupTests.swift; sourceTree = "<group>"; };
40
42
  2B2F1D632BEBFAAA006897EE /* WebSpatial.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = WebSpatial.app; sourceTree = BUILT_PRODUCTS_DIR; };
41
43
  2B2F1D672BEBFAAA006897EE /* RealityKitContent */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = RealityKitContent; sourceTree = "<group>"; };
42
44
  2B2F1D702BEBFAAC006897EE /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
@@ -51,6 +53,7 @@
51
53
  2B06AF302E4C1AF8000327E9 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = {
52
54
  isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
53
55
  membershipExceptions = (
56
+ AttachmentManager.swift,
54
57
  Dynamic3DManager.swift,
55
58
  FileCoordinator.swift,
56
59
  JSBManager.swift,
@@ -152,10 +155,19 @@
152
155
  /* End PBXFrameworksBuildPhase section */
153
156
 
154
157
  /* Begin PBXGroup section */
158
+ 2B1008A32F0B000800000003 /* web-spatialTests */ = {
159
+ isa = PBXGroup;
160
+ children = (
161
+ 2B1008A12F0B000800000001 /* NavigationCleanupTests.swift */,
162
+ );
163
+ path = "web-spatialTests";
164
+ sourceTree = "<group>";
165
+ };
155
166
  2B2F1D5A2BEBFAAA006897EE = {
156
167
  isa = PBXGroup;
157
168
  children = (
158
169
  2B2F1D652BEBFAAA006897EE /* web-spatial */,
170
+ 2B1008A32F0B000800000003 /* web-spatialTests */,
159
171
  2B2F1D662BEBFAAA006897EE /* Packages */,
160
172
  2B2F1D642BEBFAAA006897EE /* Products */,
161
173
  );
@@ -334,6 +346,7 @@
334
346
  isa = PBXSourcesBuildPhase;
335
347
  buildActionMask = 2147483647;
336
348
  files = (
349
+ 2B1008A22F0B000800000002 /* NavigationCleanupTests.swift in Sources */,
337
350
  );
338
351
  runOnlyForDeploymentPostprocessing = 0;
339
352
  };
@@ -0,0 +1,33 @@
1
+ @testable import WebSpatial
2
+ import XCTest
3
+
4
+ final class NavigationCleanupTests: XCTestCase {
5
+ func test_resetForNavigation_destroysSceneSpatialObjectsAndAttachments() {
6
+ // Given: a scene with spatial objects and attachments created by the current page
7
+ let scene = SpatialApp.Instance.createScene(
8
+ "http://localhost:5173/",
9
+ .window,
10
+ .visible
11
+ )
12
+
13
+ let panel: Spatialized2DElement = scene.createSpatializedElement(.Spatialized2DElement)
14
+ panel.setParent(scene)
15
+ XCTAssertNotNil(scene.findSpatialObject(panel.id) as Spatialized2DElement?)
16
+
17
+ _ = scene.attachmentManager.create(
18
+ id: "test-attachment",
19
+ parentEntityId: "test-parent-entity",
20
+ position: SIMD3<Float>(0, 0, 0),
21
+ size: CGSize(width: 100, height: 100)
22
+ )
23
+ XCTAssertFalse(scene.attachmentManager.attachments.isEmpty)
24
+
25
+ // When: navigation happens (reload/back/forward/new URL)
26
+ scene.resetForNavigation()
27
+
28
+ // Then: all existing spatial objects and attachments should be cleaned up
29
+ XCTAssertNil(scene.findSpatialObject(panel.id) as Spatialized2DElement?)
30
+ XCTAssertTrue(scene.attachmentManager.attachments.isEmpty)
31
+ XCTAssertNil(SpatialObject.get(panel.id))
32
+ }
33
+ }